Scraper automation learnings — Paycom ATS (technical)¶
Plain-language version: ../plain/scraper-automation-learnings-paycom.md
This documents what the local, offline Paycom scraper lab taught us about automating
Paycom Applicant Tracking candidate-portal apply flows, and how a PaycomAutomationEngine
adapter is hardened against them. It is the sibling of the Workday/UKG/Greenhouse labs, built for
a different ATS family whose markup conventions and account model differ from all three.
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 Paycom/paycom.com site, no account is
ever created (the "Create account" step is a purely local simulation with no external identity
call), 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.Paycom/
Program.cs CLI entry (--iterations N | --minutes M | --out PATH)
FakePaycomSite.cs loopback HttpListener serving the Paycom apply flow (8 steps)
SiteVariation.cs per-iteration randomization (anchors, labels, missing fields, slow loads)
SelectorChain.cs priority fallback chain + hit/miss telemetry
PaycomAutomationEngine.cs Playwright adapter that fills each step from an IntakeProfile
LoopRunner.cs the loop runner: JSONL log + selector tallies + failure modes
SampleProfile.cs the Andrew Jones seed (reuses Core IntakeProfile shapes)
tools/WorkWingman.ScraperLab.Paycom.Tests/
FakePaycomSiteTests.cs fixture + detection + safety tests
SelectorLogicTests.cs selector-chain + variation-randomization tests
Also touched (additive only, reused by every ATS lab): AtsKind.Paycom was added to
src/WorkWingman.Core/Models/JobPosting.cs, and a paycomonline.com / paycomonline.net /
paycom.com detection branch was added to src/WorkWingman.Infrastructure/Automation/AtsDetector.cs
— mirroring how Workday/Greenhouse/Lever/iCIMS/UKG are already recognized there.
How Paycom 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. Paycom has no equivalent, and — unlike
UKG — it also does not stamp aria-label consistently. From public knowledge of the Paycom ATS
candidate portal (https://pc00.paycomonline.com/v4/ats/web.php/jobs/apply?clientkey=...):
| Concern | Workday | UKG Pro Recruiting | Paycom ATS |
|---|---|---|---|
| Primary stable hook | data-automation-id on every control |
none (leans on ARIA) | none — plain id/name only |
| Field identity | data-automation-id |
semantic id/name |
semantic id/name (classic form-field style) |
| Accessibility anchors | present but secondary | aria-label/role widely stamped |
aria-label present but inconsistent — never the primary anchor |
| Framework | Workday's own SPA | React/Angular, client-side routing | moderately modern DOM: mostly server-rendered steps, light hydration for tenant custom questions |
| Candidate account | required | required | required — candidates create/sign in to a self-service portal (simulated locally here, never a real account) |
| Voluntary EEO | inline step | collapsed accordion | step OR folded inline at Review, per tenant — no accordion |
| Resume vs manual | resume-optional | resume-upload-first common | resume-upload-first common (same pattern as UKG) |
| Custom questions | fixed step schema | fixed step schema | fully tenant-defined per requisition — no fixed schema at all |
The practical consequences for the adapter:
- No ARIA-first fallback tier (unlike UKG) — Paycom's
aria-labelis present often enough to keep as a weak fourth-tier signal, but leading with it (as UKG does) would have under-performed: Paycom's most durable anchor is the plainid, thenname, then the visible label. - The EEO step's location is itself a variable, not just its content — some tenants render it as its own step, others fold it into the tail of Review. The adapter must detect where it is, not just fill it.
- Custom screening questions have no fixed schema. The adapter cannot hard-code "salary" / "start date" fields the way it could assume core contact fields; it must probe for whichever questions a given requisition happens to render.
The selector fallback chain (adapter approach)¶
SelectorChain tries candidates in priority order, waiting for the first visible match. The
priority order encodes the Paycom lesson — different from both Workday and UKG:
#id— plain id (Paycom's most stable hook when present).[name='...']— the form field name; survives when the id is dropped or renamed. This turned out to be, once again, the workhorse fallback (as it was for UKG).- label-sibling (
label:has-text('…') + input) — visible-label association. Promoted ahead of ARIA for Paycom, because Paycom's labels proved more consistently present than itsaria-labelattributes. [aria-label='…']— a weaker signal on Paycom than on UKG; still occasionally the only anchor left standing, so it stays in the chain, just demoted to 4th.[placeholder='…']— last-resort, brittle.
Two structural refinements matter for Paycom specifically:
- Short probe on lead candidates, full budget on the last — identical timing discipline to the UKG lab: ~400ms probes on early candidates, ~1.5s on the last (which may be waiting on the contact-info step's tenant-config hydration).
- Detecting where the EEO step lives, not just its fields. See the failure-mode section below — this required two rounds of fixing during the lab's own validation runs.
What the loop measured (validation run, --iterations 20)¶
A clean 20-iteration run produced 20/20 completed, 0 failures, every iteration detected as
AtsKind.Paycom, every one submit-suppressed, and no field's whole fallback chain went unresolved.
Selector telemetry highlights:
[name='continue']— the shared "Continue" button name across every step — was the single highest-hit selector (30 hits in one run), confirmingnameas Paycom's most reliable fallback, exactly as it was for UKG.- Every
#idselector showed a roughly 65/35 hit/miss split, matching the ~40%DropStableIdsprobability built intoSiteVariation— every one of those misses fell through cleanly to[name='…']. label:has-text(...)fired only rarely (once in a 20-run sample) — it earns its place as tier 3, but in practiceid/namecover the overwhelming majority of cases for Paycom, more so than for UKG where ARIA carried more of the load.- Tenant custom-question selectors (
#cq_*/[name='customQ*']) only appear in the ~60% of iterations whereHasCustomQuestionsis true — the adapter's generic-probe approach (§ below) handled the other 40% (no questions rendered) without any special-casing.
Failure modes found and fixed during lab validation¶
This is the most important section: the lab's whole purpose is to surface exactly these bugs before they'd hit a real Paycom tenant.
| # | Symptom | Root cause | Fix |
|---|---|---|---|
| 1 | eeoContinue showed up as a "missed field" in ~14/20 iterations, even though every iteration completed |
FillVoluntaryEeoAsync probed #eeoGender, [name='gender'] to decide "is EEO on its own page?" — but the Review page's inline EEO fold-in also renders a name='gender' select (by design, so the two code paths share a mapping function). The probe couldn't tell the two apart. |
Probe on an anchor that exists only on the dedicated EEO page (veteran/disability), never on the shared gender field. |
| 2 | After fix #1, 3/20 iterations flipped to no-submit — ReviewAndSuppressSubmitAsync couldn't find the submit control at all |
The fix-#1 probe used #eeoVeteran, #eeoDisability, #eeoContinue — all id-scoped. On iterations where DropStableIds=true and the EEO step really was on its own dedicated page, the ids were gone, so the probe wrongly concluded "folded inline," skipped the real EEO page entirely (never clicking its Continue), and Playwright was still sitting on the EEO page when Review's submit-locator ran. |
Probe using both the id and name form (#eeoVeteran, [name='veteran'], #eeoDisability, [name='disability']) so the check survives DropStableIds the same way every other field's fallback chain does. |
After fix #2, 20/20 completed with every submit suppressed and located. The lesson generalizes:
any "which branch of the flow am I on?" probe is itself a selector-chain problem — it needs the
same id→name fallback discipline as an ordinary field, not a single hard-coded anchor. A probe that
only checks #id will silently mis-route exactly on the DropStableIds iterations that are the
whole point of the fuzzing.
Judgement calls (ambiguity, not guessing)¶
Mirroring the Workday/UKG "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 Paycom tenants render a single free-text degree box that expects
a combined "B.S. in Game Development". The adapter flags this (AmbiguousFields, logged
JUDGEMENT — degree format ambiguous) rather than silently picking a format.
Tenant-defined custom questions are a second, structural source of ambiguity: because there is no
fixed schema, the adapter maps its three synthetic fixture questions from direct IntakeProfile
fields (earliest start date, salary expectation, relocation preference) when present, and simply
skips the step when the fixture renders none — it never invents an answer to a question shape it
doesn't recognize.
Safety model (identical discipline to the Workday/UKG/Greenhouse labs)¶
- No real network. The fixture is loopback-only (
http://127.0.0.1:<port>/), and itsApplyUrlmimics the public.../v4/ats/web.php/jobs/apply?clientkey=...shape purely as a string — it never resolves to a real host. - No account is ever created. The fixture's "Create account" path
(
/v4/ats/web.php/jobs/apply/create-account) is a local-only simulated redirect; it sets aAccountCreationSimulatedflag for tests to observe and nothing else. The engine'sSignInAsyncalways takes the "Sign In" path against the local fixture with a throwaway password, never "Create account," and never touches any real identity system. - 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/v4/ats/web.php/jobs/apply/submithandler only sets a flag and returns a "LOCAL NO-OP" page. - Hard assertion. Each iteration asserts
FakePaycomSite.SubmitWasRequested == falseafter the run; a true value throws aSAFETY VIOLATIONand is counted as a failure. Across all validation runs this never fired. - Realistic data, local only. The fill-input is the Andrew Jones seed (reusing Core's
IntakeProfileshapes: Full Sail University B.S. Game Development, Equifax). It is never transmitted anywhere.
Detection¶
AtsKind.Paycom and a paycomonline.com / paycomonline.net / paycom.com detection branch were
added to the production AtsDetector (additive only — no existing detection logic was changed).
Because the fixture itself must stay loopback-only, each loop iteration synthesizes a realistic
https://pc00.paycomonline.com/v4/ats/web.php/jobs/apply?clientkey=<synthetic> string (never
resolved, never dialed) and confirms the detector classifies it as AtsKind.Paycom before driving
the flow — the same "confirm detection matches the real router" discipline the UKG lab uses.
Running it¶
Short validation:
dotnet run --project tools/WorkWingman.ScraperLab.Paycom -- --iterations 20
Long local hardening run (time-bounded, e.g. an hour), writing a rolling JSONL log:
dotnet run --project tools/WorkWingman.ScraperLab.Paycom -- --minutes 60 --out paycom-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.Paycom/bin/Debug/net10.0/playwright.ps1 install chromium
Tests:
dotnet test tools/WorkWingman.ScraperLab.Paycom.Tests
Takeaways for a production Paycom adapter¶
- Lead with
id, fall back toname, then label, thenaria-label.nameis the workhorse fallback, as it is for UKG;aria-labelis present but weaker on Paycom than on UKG, so it's demoted a tier. - Treat "which branch of the flow am I on?" as a selector-chain problem, not a single-selector check. The EEO-location probe bug (see failure-mode table) generalizes: any branch decision needs the same id→name→label fallback discipline as filling a field, or it will silently mis-route on exactly the fuzzed variations the lab exists to catch.
- Custom screening questions have no fixed schema. Probe generically for whichever questions a requisition renders; never assume a specific set exists.
- Never create a real candidate account during automated hardening. Only ever sign in against a local fixture with throwaway credentials.
- Flag ambiguity, don't guess — carry the Workday/UKG judgement-call discipline into Paycom (degree format, unmapped custom questions).
- Keep submit suppressed and asserted — the safety boundary is a hard invariant, not a convention.