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¶
data-automation-idfirst, always. Real Workday renders every meaningful control with a stabledata-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 inWorkdaySelectorsnow leads with an automation-id selector, then degrades: exact id → id substring (*=) → aria/label → generic tag.- Scope to visible elements. A Workday application is a single-page app that keeps
previously-rendered steps mounted (just hidden).
Locator(sel).Firstgrabs a stale hidden node from an earlier step whenever an automation-id repeats across steps. The engine now resolves with Playwright's>> visible=truefilter so.Firstmeans "first visible match". This was the single highest-impact fix — before it, every iteration faulted; after it, zero did. - 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, notbutton[data-automation-id='formField-degree']. - 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
JudgementCalland pauses (in production) rather than picking blindly. - Log every selector attempt.
ResolveAsyncreports each attempt (which link in the fallback chain matched or missed) throughOnSelectorAttempt, 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 | 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 ScienceandBSreconcile (consecutive single-letter tokensb+sare collapsed tobs, then expanded tobachelor/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
JudgementCallwith 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:
LinkedInJobsScraperalready uses fallback selector chains + JS bulk-harvest + fixed pacing and log-and-continue (a failed job detail returnsnullrather 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 nodata-automation-idequivalent, so its chains lead with the most stable class/data-testhooks and fall back to structural selectors.AtsDetectorkeys offmyworkdayjobs.comin the page HTML and extracts the tenant host — the same host the engine signs into. Kept simple and string-based; covered byAtsDetectorTests.
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¶
FakeWorkdaySitebinds tohttp://127.0.0.1:<free-port>/— loopback only, no external prefix.- The
LoopRunnernavigates only tosite.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
submitroute the fake server has is a client-side no-op that records nothing. LabVaultreturns 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 withdata-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).