Skip to content

WorkWingman — Scraper & Automation Learnings (Technical)

What the Workday application engine and the LinkedIn scraper should target, and why — distilled from an offline learning loop that drives the real WorkdayAutomationEngine against a local fake Workday site thousands of iterations at a time. Everything here is grounded in observed selector hit/miss data and failure modes from that loop, not guesswork.

Safety context. The loop is 100% local. It never touches LinkedIn, never touches a real Workday tenant, never creates an account, and never submits. See The harness for how that is guaranteed by construction.

TL;DR

  1. data-automation-id first, always. Real Workday renders every meaningful control with a stable data-automation-id (legalNameSection_firstName, formField-degree, addButton, pageFooterNextButton, pageFooterSubmitButton). Class names and DOM structure are React-generated and churn between tenants/releases. Every selector chain in WorkdaySelectors now leads with an automation-id selector, then degrades: exact id → id substring (*=) → aria/label → generic tag.
  2. Scope to visible elements. A Workday application is a single-page app that keeps previously-rendered steps mounted (just hidden). Locator(sel).First grabs a stale hidden node from an earlier step whenever an automation-id repeats across steps. The engine now resolves with Playwright's >> visible=true filter so .First means "first visible match". This was the single highest-impact fix — before it, every iteration faulted; after it, zero did.
  3. The click target can be a descendant of the automation-id. For the degree combobox the id lives on the wrapping <div data-automation-id="formField-degree"> and the clickable control is its inner <button>. The chain must therefore lead with [data-automation-id='formField-degree'] button, not button[data-automation-id='formField-degree'].
  4. The degree dropdown is a judgement call, not a guess. A tenant's curated degree list often has no exact match for "B.S. Game Development". The engine scores options against the profile value and, when nothing clears the auto-fill confidence threshold, raises a JudgementCall and pauses (in production) rather than picking blindly.
  5. Log every selector attempt. ResolveAsync reports each attempt (which link in the fallback chain matched or missed) through OnSelectorAttempt, so brittle selectors surface as low primary-hit rates in the metrics instead of hiding behind a working fallback.

Workday markup patterns the engine targets

Step Control Primary selector (data-automation-id) Notes
Sign in Email input[data-automation-id='email'] also userName on some tenants
Sign in Password input[data-automation-id='password']
Sign in Submit button[data-automation-id='signInSubmitButton']
My Information First name input[data-automation-id='legalNameSection_firstName'] section-prefixed id
My Information Last name input[data-automation-id='legalNameSection_lastName']
My Information Phone input[data-automation-id='phone-number'] often optional / pre-filled
My Experience Add block button[data-automation-id='addButton'] repeater — click before each extra block
My Experience Job title input[data-automation-id='jobTitle']
My Experience Company input[data-automation-id='company']
Education School input[data-automation-id='school']
Education Degree [data-automation-id='formField-degree'] button container id, inner button
Education Option row [data-automation-id='promptOption'] rendered into a listbox on open
Voluntary Disclosures Gender / Veteran [data-automation-id='formField-gender'] etc. always optional
Nav Next / Continue button[data-automation-id='pageFooterNextButton'] one per step (see visibility note)
Review Submit button[data-automation-id='pageFooterSubmitButton'] never auto-clicked

The full ordered chains (with every fallback link and the reasoning) live in WorkdaySelectors.cs.

Fallback-chain design

Each field is an ordered array, most-specific → most-generic:

exact data-automation-id
  → data-automation-id substring (*=, case-insensitive)
    → aria-label / name / type
      → generic tag

WorkdayAutomationEngine.ResolveAsync walks the chain and:

  • restricts every selector to >> visible=true (skips stale hidden SPA nodes);
  • fires OnSelectorAttempt(label, selector, chainIndex, matched) for each attempt;
  • logs a warning when a fallback (index > 0) matched — a signal the primary is drifting;
  • logs an error when the whole chain missed;
  • returns the first visible locator, or null (optional fields are then skipped, not fatal).

The degree-dropdown ambiguity (the canonical judgement call)

The profile says B.S. Game Development. A tenant's curated promptOption list may contain an exact match, only near-neighbours (Bachelor of Science (BS), Bachelor of Arts (BA), …), or only unrelated degrees. WorkdaySelectors.BestOptionMatch / SimilarityScore decide what to do:

  • token-overlap similarity, with an abbreviation bridge so B.S.Bachelor of Science and BS reconcile (consecutive single-letter tokens b+s are collapsed to bs, then expanded to bachelor/science);
  • an exact option scores 100 and is auto-selected;
  • a best near-neighbour that does not clear the 90% auto-fill threshold does not get picked silently — the engine raises a JudgementCall with the ranked options and pauses.

Observed in the loop (representative iteration): desired B.S. Game Development, no exact option, top match Bachelor of Science (BS) at confidence 38 vs Bachelor of Arts (BA) at 10 → judgement call raised, correctly ranking BS above BA via the abbreviation bridge. In the lab it auto-resolves to the top option (never pauses forever); in production it genuinely waits for the user and the answer persists as a reusable rule.

Selector hit/miss findings from the loop

Over 20-iteration validation runs (seeds vary the markup each iteration):

  • Sign-in / Next / Submit: ~100% primary hits. These automation-ids are the most stable and should always be the primary target.
  • Contact / experience / education text fields: 50–75% primary, remainder on fallbacks. The loop deliberately renames ids (…_v2_field) and drops them to aria-only ~30–45% of the time; the substring and aria fallbacks absorb this, which is exactly what the chains exist for. A primary rate that low in production would be the alarm that a tenant changed its ids.
  • Degree "open" started at 0% primary until the chain was reordered to put the container-descendant button first — a concrete bug the loop caught.
  • Repeated automation-ids across steps caused 100% iteration failure until visibility scoping was added — the highest-severity finding.

Failure modes discovered

Mode Root cause Fix
Every iteration TimeoutException at a later step .First selected a hidden earlier-step node with a repeated automation-id >> visible=true scoping in ResolveAsync
Degree "open" never hit its primary selector automation-id is on the container div, click target is the inner button reorder chain: [id] button before button[id]
Slow-rendering steps flaky fields appear after an injected delay per-selector visibility timeout + a tolerant WaitForStep that does not throw
Missing optional field treated as fatal strict single-selector fill chain returns null → optional fills skip and continue

LinkedIn scraper & ATS detector notes

The loop focuses on Workday, but the same principles harden the sibling components:

  • LinkedInJobsScraper already uses fallback selector chains + JS bulk-harvest + fixed pacing and log-and-continue (a failed job detail returns null rather than aborting the scrape). The ScraperLab reinforces that pattern: prefer stable attributes, degrade gracefully, never let one element-not-found kill the run. LinkedIn has no data-automation-id equivalent, so its chains lead with the most stable class/data-test hooks and fall back to structural selectors.
  • AtsDetector keys off myworkdayjobs.com in the page HTML and extracts the tenant host — the same host the engine signs into. Kept simple and string-based; covered by AtsDetectorTests.

The harness (WorkWingman.ScraperLab)

A .NET 10 console app in tools/WorkWingman.ScraperLab:

File Role
FakeWorkdaySite.cs Loopback HttpListener (127.0.0.1 only) serving realistic, parameterizable Workday markup
FixtureVariation.cs Per-iteration randomizer: renamed/missing automation-ids, missing degree options, slow fields, shuffled steps
WorkdaySelectors.cs* The hardened, data-automation-id-first fallback chains + degree matcher (*lives in Infrastructure)
LoopRunner.cs Drives the real engine, iteration after iteration; auto-resolves the degree call; suppresses submit
LabMetrics.cs Rolling JSONL log + selector hit/miss tallies + failure-mode counts
LabProfile.cs Seed fill-input mirroring the app's Andrew Jones profile (local fixture input only)
Program.cs Arg parsing (--iterations, --minutes, --headed, --seed, --output)

Running it

# short validation
dotnet run --project tools/WorkWingman.ScraperLab -- --iterations 20

# long local run (headless), 60 minutes
dotnet run --project tools/WorkWingman.ScraperLab -- --minutes 60 --seed 7

# watch it drive the browser
dotnet run --project tools/WorkWingman.ScraperLab -- --iterations 20 --headed

Each run writes lab-output/lab-<timestamp>.jsonl (gitignored) and prints a rolling summary every 5 iterations plus a final tally.

Why there is no real network or submit path

  • FakeWorkdaySite binds to http://127.0.0.1:<free-port>/loopback only, no external prefix.
  • The LoopRunner navigates only to site.ApplyUrl; there is no other URL in the code path.
  • At Review & Submit the runner locates the submit button (to prove the engine can find it) and then logs would submit (suppressed) — nothing sent. It never calls .ClickAsync() on it.
  • The only submit route the fake server has is a client-side no-op that records nothing.
  • LabVault returns a throwaway fixture password; no real KeePass file is opened and no account is created anywhere.

The production WorkdayAutomationEngine keeps its locked behavior: the final submit always waits for the user — the lab does not weaken that, and the hardening applied (visibility scoping, chain reordering, richer logging) is pure reliability improvement with no change to the submit contract.

Tests

  • WorkdaySelectorsTests — every chain leads with data-automation-id; degree chain targets the container's inner button first.
  • DegreeMatchingTests — abbreviation bridge, exact-match auto-select, no-match → below-threshold (raises a judgement call).
  • FakeWorkdaySiteTests — loopback-only binding, realistic automation-ids present, submit route is a no-op, required name fields never dropped.
  • WorkdayAutomationEngineTests — existing judgement-call / threshold behavior (unchanged).

See also the plain-language version: scraper-automation-learnings (plain).