Skip to content

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:

  1. #id — semantic id (UKG's most stable hook when present).
  2. [name='...'] — the form field name; survives when the id is dropped or renamed.
  3. [aria-label='...'] — ARIA label; UKG stamps these widely and they track the visible label.
  4. label-sibling (label:has-text('…') + input) — visible-label association, resilient to attribute churn but sensitive to label rewording.
  5. [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 #id selectors across ~18 fields.
  • URL-or-anchor step waits. UKG routes on the client (location.href / SPA router), so URL-only waits are flaky. WaitForNextStepAsync resolves 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 #id selector: ~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: name is 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" button name across 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/submit handler only sets a flag and returns a "LOCAL NO-OP" page.
  • Hard assertion. Each iteration asserts FakeUkgSite.SubmitWasRequested == false after the run; a true value throws a SAFETY VIOLATION and 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 IntakeProfile shapes). 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

  1. Lead with id, fall back to name, then aria-label. name is the workhorse fallback; aria-label is the safety net. Placeholder/CSS are last resorts only.
  2. 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.
  3. Wait on URL or anchor. UKG routes client-side; never gate step progress on URL alone.
  4. 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).
  5. Flag ambiguity, don't guess — carry the Workday judgement-call discipline into UKG.
  6. Keep submit suppressed and asserted — the safety boundary is a hard invariant, not a convention.