Scraper automation learnings — UKG Pro Recruiting (technical)¶
Plain-language version: ../plain/scraper-automation-learnings-ukg.md
This documents what the local, offline UKG scraper lab taught us about automating
UKG Pro Recruiting / UltiPro Onboarding candidate apply flows, and how the UkgAutomationEngine
adapter is hardened against them. It is the sibling of the Workday lab, built for a different ATS
family whose markup conventions differ substantially from Workday's.
Everything in the lab is local and offline. The harness drives a loopback HttpListener
fixture on 127.0.0.1. There is no real network, no real UKG/UltiPro site, no account creation,
and no real submit endpoint. The final "Submit" is a no-op that logs would submit (suppressed)
and restarts with a new randomized variation.
Where the lab lives¶
tools/WorkWingman.ScraperLab.Ukg/
Program.cs CLI entry (--iterations N | --minutes M | --out PATH)
FakeUkgSite.cs loopback HttpListener serving the UKG apply flow (7 steps)
SiteVariation.cs per-iteration randomization (anchors, labels, missing fields, slow loads)
SelectorChain.cs priority fallback chain + hit/miss telemetry
UkgAutomationEngine.cs Playwright adapter that fills each step from an IntakeProfile
LearningLoop.cs the loop runner: JSONL log + selector tallies + failure modes
SampleProfile.cs the Andrew Jones seed (reuses Core IntakeProfile shapes)
tests/WorkWingman.Tests/UkgScraperLabTests.cs fixture + selector-chain + detection tests
How UKG differs from Workday (the core lesson)¶
The Workday adapter leans on data-automation-id — Workday stamps a stable, semantic
data-automation-id on essentially every control (input[data-automation-id*='email'],
button[data-automation-id*='signIn']). That single attribute makes Workday scraping unusually
stable.
UKG has no equivalent. From public knowledge of the UKG Pro Recruiting / UltiPro candidate portal, UKG renders:
| Concern | Workday | UKG Pro Recruiting |
|---|---|---|
| Primary stable hook | data-automation-id on every control |
none — no universal automation attribute |
| Field identity | data-automation-id |
semantic id / form name (e.g. id="candidate-email", name="email") |
| Accessibility anchors | present but secondary | aria-label / role widely stamped — often the most stable anchor |
| Framework | Workday's own SPA | React/Angular candidate portal; client-side routing, SPA hydration |
| Labels | fairly stable | heavily localized / reworded per tenant |
| Voluntary EEO | inline step | frequently a collapsed accordion the user expands |
| Resume vs manual | resume-optional | often resume-upload-first with a secondary "Enter manually" link |
The practical consequence: you cannot pin a UKG field to one selector. The adapter must use a prioritized fallback chain and treat every field as "the stable id might be gone this release."
The selector fallback chain (adapter approach)¶
SelectorChain tries candidates in priority order, waiting for the first visible match. The
priority order encodes the UKG lesson:
#id— semantic id (UKG's most stable hook when present).[name='...']— the form field name; survives when the id is dropped or renamed.[aria-label='...']— ARIA label; UKG stamps these widely and they track the visible label.- label-sibling (
label:has-text('…') + input) — visible-label association, resilient to attribute churn but sensitive to label rewording. [placeholder='…']/ CSS — last-resort, brittle; only used when everything semantic is gone.
Two timing refinements matter for UKG's SPA nature:
- Short probe on lead candidates, full budget on the last. Early candidates get a ~400ms
probe so a dead lead selector (id removed this release) falls through fast instead of paying a
full timeout per field. Only the last candidate gets the full ~1.5s budget, because it may be
waiting on SPA hydration. Before this change, a "drop all ids" iteration spent ~27s purely
waiting out dead
#idselectors across ~18 fields. - URL-or-anchor step waits. UKG routes on the client (
location.href/ SPA router), so URL-only waits are flaky.WaitForNextStepAsyncresolves as soon as either the URL matches or the next step's anchor element is visible. This removed the one nav-timeout failure seen in the first validation run.
What the loop measured (validation run, --iterations 20)¶
After the timing fixes, a 20-iteration run produced 20/20 completed, 0 failures, every
iteration detected as AtsKind.Ukg, every one submit-suppressed, and zero fields fully missed
(the chain always resolved something). Selector telemetry (excerpt):
- Every
#idselector: ~11 hit / 9 miss — the misses are exactly the ~45% of iterations that dropped stable ids. Each miss fell through to[name='…']. - Every
[name='…']selector: ~9 hit / 0 miss — the name anchor caught every dropped-id case. This is the single most valuable finding:nameis UKG's most reliable fallback. [aria-label='…']: fired occasionally (index 2), when a variation combined dropped ids with a name-anchor edge case — proof the third tier earns its place.[name='continue']: highest hit count — the shared "Continue" buttonnameacross every step.
Failure modes observed¶
| Mode | Root cause | Hardening |
|---|---|---|
timeout (nav never appeared) |
client-side route change on sign-in; URL-only wait raced hydration | WaitForNextStepAsync (URL or anchor) |
| slow iterations (~36s) | dead #id lead selectors paying full timeout ×18 fields |
short-probe lead candidates, full budget only on last |
| resume-upload-first stall (potential) | naive adapter tries to attach a file | detect + click "Enter manually", then fill fields |
| collapsed EEO block (potential) | voluntary fields hidden in an accordion | detect + expand accordion before filling |
Judgement calls (ambiguity, not guessing)¶
Mirroring the Workday "degree" case, the education step raises a judgement call every
iteration: the profile carries Degree = "Bachelor of Science" and FieldOfStudy = "Game
Development" separately, but some UKG tenants render a single free-text degree box that expects a
combined "B.S. in Game Development". The adapter flags this as ambiguous (logged JUDGEMENT —
degree format ambiguous) and records it in AmbiguousFields rather than silently picking a
format. In production this is where the run would pause for the user's call and persist the choice
as a reusable rule.
Safety model (identical discipline to the Workday lab)¶
- No real network. The fixture is loopback-only (
http://127.0.0.1:<port>/). - Submit is a no-op. The Review step's submit control is located and verified to exist, then
the adapter logs
would submit (SUPPRESSED)and never clicks it. The fixture's/review/submithandler only sets a flag and returns a "LOCAL NO-OP" page. - Hard assertion. Each iteration asserts
FakeUkgSite.SubmitWasRequested == falseafter the run; a true value throws aSAFETY VIOLATIONand is counted as a failure. Across all runs this never fired. - Realistic data, local only. The fill-input is the Andrew Jones seed (reusing Core's
IntakeProfileshapes). It is never transmitted anywhere.
Detection¶
The lab reuses the production AtsDetector, which already recognizes UKG via ukg.com /
ultipro host markers (AtsKind.Ukg). Every iteration confirms detection against a synthesized
recruiting.ultipro.com apply link before driving the flow, so the adapter's entry point matches
what the real detector would route.
Running it¶
Short validation:
dotnet run --project tools/WorkWingman.ScraperLab.Ukg -- --iterations 20
Long local hardening run (time-bounded, e.g. an hour), writing a rolling JSONL log:
dotnet run --project tools/WorkWingman.ScraperLab.Ukg -- --minutes 60 --out ukg-run.jsonl
Each JSONL line is one iteration: variation, detected ATS, outcome, steps completed, ambiguous count, missed fields, submit-suppressed flag, elapsed ms, and the per-iteration log. The console prints a rolling summary: selector hit/miss table (most-missed first), fields that missed the whole chain, and failure-mode counts.
Playwright's Chromium is required once:
pwsh tools/WorkWingman.ScraperLab.Ukg/bin/Debug/net10.0/playwright.ps1 install chromium
Takeaways for the production UKG adapter¶
- Lead with
id, fall back toname, thenaria-label.nameis the workhorse fallback;aria-labelis the safety net. Placeholder/CSS are last resorts only. - Probe lead selectors cheaply. Don't pay a full timeout on a selector that may be gone this release; reserve the long wait for the final candidate that may be hydrating.
- Wait on URL or anchor. UKG routes client-side; never gate step progress on URL alone.
- Handle UKG's structural quirks explicitly: resume-upload-first (choose manual entry), collapsed EEO accordion (expand first), heavy label localization (don't rely on label text).
- Flag ambiguity, don't guess — carry the Workday judgement-call discipline into UKG.
- Keep submit suppressed and asserted — the safety boundary is a hard invariant, not a convention.