Skip to content

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:

  1. No ARIA-first fallback tier (unlike UKG) — Paycom's aria-label is 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 plain id, then name, then the visible label.
  2. 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.
  3. 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:

  1. #id — plain id (Paycom's most stable hook when present).
  2. [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).
  3. label-sibling (label:has-text('…') + input) — visible-label association. Promoted ahead of ARIA for Paycom, because Paycom's labels proved more consistently present than its aria-label attributes.
  4. [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.
  5. [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), confirming name as Paycom's most reliable fallback, exactly as it was for UKG.
  • Every #id selector showed a roughly 65/35 hit/miss split, matching the ~40% DropStableIds probability built into SiteVariation — 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 practice id/name cover 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 where HasCustomQuestions is 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-submitReviewAndSuppressSubmitAsync 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 its ApplyUrl mimics 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 a AccountCreationSimulated flag for tests to observe and nothing else. The engine's SignInAsync always 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/submit handler only sets a flag and returns a "LOCAL NO-OP" page.
  • Hard assertion. Each iteration asserts FakePaycomSite.SubmitWasRequested == false after the run; a true value throws a SAFETY VIOLATION and 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 IntakeProfile shapes: 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

  1. Lead with id, fall back to name, then label, then aria-label. name is the workhorse fallback, as it is for UKG; aria-label is present but weaker on Paycom than on UKG, so it's demoted a tier.
  2. 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.
  3. Custom screening questions have no fixed schema. Probe generically for whichever questions a requisition renders; never assume a specific set exists.
  4. Never create a real candidate account during automated hardening. Only ever sign in against a local fixture with throwaway credentials.
  5. Flag ambiguity, don't guess — carry the Workday/UKG judgement-call discipline into Paycom (degree format, unmapped custom questions).
  6. Keep submit suppressed and asserted — the safety boundary is a hard invariant, not a convention.