Scraper Automation Learnings — Amazon¶
Source of these learnings: the offline learning harness at
tools/WorkWingman.ScraperLab.Amazon. It drives the realAmazonAutomationEngineagainst a loopback-onlyFakeAmazonSite(anHttpListeneron127.0.0.1) built from public knowledge of how amazon.jobs's apply flow is structured. There is no real network egress, no amazon.jobs, no real Amazon account or login, and the final Submit is never clicked — it is a client-side no-op that logs and restarts. Everything below was observed locally against the fixture, exactly like the sibling Lever/Workday labs.
What Amazon's apply flow looks like (bespoke multi-step DOM)¶
amazon.jobs's real apply flow is a bespoke, hand-built, multi-step wizard — it is NOT
built on Workday, Greenhouse, Lever, or iCIMS. There is no single shared frontend component
library behind it; different Amazon internal teams have built and re-built pieces of it over
the years. That shows up as inconsistent, ad-hoc identifying hooks: sometimes a clean semantic
id/name, sometimes a themed/renamed one, sometimes nothing but a <label for="...">
association or an aria-label.
<section id="step-contact" class="active">
<h2>Contact information</h2>
<!-- Primary style: clean semantic id + name -->
<label for="firstName">First name</label>
<input type="text" id="firstName" name="firstName" aria-label="First name" />
<!-- Degraded style: renamed/themed id, still substring-matchable -->
<label for="candidate-last-name-field">Last name</label>
<input type="text" id="candidate-last-name-field" name="candidate-last-name-field"
aria-label="Last name" />
<!-- LabelOnly style: NO usable id/name at all -->
<label>Phone number
<input type="tel" aria-label="Phone number" placeholder="Phone number" />
</label>
<button class="next-button" onclick="go('step-resume')">Save and continue</button>
</section>
Six sequential steps, each a <section id="step-x" class="active"> (borrowing the Workday
lab's go('nextStepId') wizard-navigation pattern):
- Account — fake local "Sign in or create account" (simulated entirely by the loopback fixture; NEVER a real Amazon Identity endpoint). Sometimes skipped (simulated existing session cookie), mirroring how a returning candidate skips sign-in on the real site.
- Contact information — name / email / phone / address.
- Resume — file upload input, located only, never uploaded.
- Job-specific questions — work authorization, sponsorship, start date, "why do you want to work at Amazon," desired shift. This is the judgement-pause surface.
- Voluntary disclosures (EEO) — gender, race/ethnicity, veteran status, disability status. Always optional; never triggers a judgement call.
- Review & Submit — the Submit control lives here; located, suppressed, logged, never clicked.
Table of identifying hooks, in order of stability¶
| Element | Best-guess hook | Why |
|---|---|---|
| Contact fields | #firstName / #lastName / #email / #phoneNumber / #address |
A clean semantic id is the most common real-world pattern for a hand-rolled form, but — unlike Workday/Lever — no single vendor enforces this, so it is a best guess, not a contract. |
| Account fields | #signInEmail / #signInPassword |
Same best-guess-id pattern, scoped to the (simulated, local-only) account step. |
| Resume | #resumeUpload / input[type=file] |
Single file input; located only, never uploaded. |
| Job-specific questions | [data-question-card='<cardId>-<index>'] |
Keyed by a per-posting card id + index, similar in spirit to Lever's cards[<uuid>][<idx>] — not portable across postings, so located positionally within its card. |
| EEO fields | #gender / #raceEthnicity / #veteranStatus / #disabilityStatus |
Consistent within this fixture but, per the bespoke-DOM premise, still only a best guess in the real world. |
| Submit | #submitApplication / button[type=submit] |
No consistent class across Amazon's internal teams; best-guess id first, then generic role/type fallback. |
| Label association | <label for="..."> or bare wrapping/sibling <label> |
The one thing that's usually present even when id/name are absent — but inconsistently for-associated. |
Selector strategy / fallback chain — worked example (Email)¶
AmazonSelectors.Email:
#email (0: best-guess primary anchor)
input[id*='email' i] (1: substring/contains fallback)
input[name='email'] (2: name-attribute fallback, if it differs from id)
input[aria-label='Email'] (3: aria-label exact match)
label-sibling:Email (4: label-text-to-sibling-input resolution — C# logic, not CSS)
input[placeholder*='email' i] (5: placeholder fallback, loosest possible match)
Why this order differs from Lever and Workday: Workday's primary hook is
data-automation-id and Lever's is the input name attribute — both are reliable because ONE
vendor's shared frontend renders every tenant's/posting's form, so the same attribute
convention holds everywhere. Amazon's apply flow has no equivalent single vendor contract, so
there is no "always-there" hook to lead with. Instead, AmazonSelectors leads with a
best-guess semantic id (the most common convention for a hand-rolled form), then degrades
through substring matching, the name attribute (when it differs from id), aria-label,
label-sibling resolution, and finally placeholder text.
Rung 4 (label-sibling:*) is not expressible as a pure CSS selector string — Playwright's
GetByLabel locator (and, for the deepest degradation, an XPath following-sibling:: walk) is
implemented directly in AmazonAutomationEngine.ResolveAsync/ResolveLabelSiblingAsync. This
is the one meaningful engine-side addition beyond the sibling labs' pure-CSS-chain approach,
because Amazon's LabelOnly fields genuinely have no id/name for CSS to grab onto.
Observed selector hit/miss table — REAL numbers from an actual 20-iteration run¶
These are REAL observed numbers, not estimates. Playwright Chromium was already installed
in this sandbox; the harness was run for real: dotnet run --project tools/WorkWingman.ScraperLab.Amazon -- --iterations 20 --seed 1337.
The run was repeated multiple times AFTER all bugs below were fixed (see Failure modes) to
confirm determinism — every post-fix run produced the byte-for-byte identical per-iteration
outcome sequence and selector tallies shown below (20/20 completed, 0 faulted, 19 judgement
calls, 20 suppressed submits, submit button located in exactly the 1 iteration that drew zero
extra ambiguous questions).
Account email attempts=20 match%=50.0 primaryHits=6 fallbackHits=4
Account password attempts=10 match%=100.0 primaryHits=10 fallbackHits=0
Address attempts=48 match%=37.5 primaryHits=8 fallbackHits=10
Custom attempts=59 match%=100.0 primaryHits=59 fallbackHits=0
Disability status attempts=1 match%=100.0 primaryHits=1 fallbackHits=0
Email attempts=37 match%=54.1 primaryHits=13 fallbackHits=7
First name attempts=34 match%=58.8 primaryHits=12 fallbackHits=8
Gender attempts=1 match%=100.0 primaryHits=1 fallbackHits=0
Last name attempts=36 match%=55.6 primaryHits=12 fallbackHits=8
Phone attempts=39 match%=48.7 primaryHits=11 fallbackHits=8
Race/Ethnicity attempts=1 match%=100.0 primaryHits=1 fallbackHits=0
Resume upload attempts=20 match%=100.0 primaryHits=20 fallbackHits=0
Submit button attempts=1 match%=100.0 primaryHits=1 fallbackHits=0
Veteran status attempts=1 match%=100.0 primaryHits=1 fallbackHits=0
Iterations: 20 Completed: 20 Faulted: 0 JudgementCalls: 19 SuppressedSubmits: 20
Failure modes / judgement triggers:
judgement-call:custom-question: 19
Reading the numbers:
- Contact fields (
First name,Phone,Address,Account email) show the deepest fallback usage —fallbackHitsis close to (or exceeds)primaryHits, confirming theAnchorStyle.DegradedandAnchorStyle.LabelOnlyrenders (which together make up ~45% of rolls perPickAnchorStyle) genuinely exercise the substring/aria/label-sibling rungs, not just the primary#idguess. - EEO fields and Submit button only appear in the stats for iterations that reached those
steps — since every job-specific-questions step draws the two mandatory eligibility
questions plus 0-3 more, and "why do you want to work at Amazon" (when drawn) always pauses
the run, only the 1 iteration (out of 20, this particular seed) that happened to draw zero
extra questions ever reached EEO/Review. That iteration hit its EEO/Submit selectors at 100%
primary match — a sample of one, so not statistically meaningful on its own, but consistent
with the deterministic design (see
AmazonVariation.Random'sextraCountcomment). Custom(job-specific questions) hits primary 100% — the fixture's[data-question-card='<cardId>-<index>']hook is always rendered consistently in this iteration of the fixture (no degraded variant was implemented for custom questions, only for the Anchor-styled contact/account fields).JudgementCalls: 19out of 20 — reflects that "why do you want to work at Amazon" (12% confidence) or "which shift" (55%, still below the 90% threshold) is drawn on 19 of 20 iterations (a random 0-3 subset of a 3-question optional pool), each time correctly triggering a pause rather than a guess.- Zero faulted iterations — every judgement pause correctly halts the wizard before
touching hidden EEO/Review DOM, and every field/step resolution correctly scopes to the
currently-active
<section>(see Failure modes below for the two collision bugs this caught).
Comparison: Amazon vs. Workday vs. Lever¶
| Dimension | Workday | Lever | Amazon |
|---|---|---|---|
| Page model | Multi-step wizard (Sign in → My Info → Experience → Education → EEO → Review). | Single flat page — one fill pass, no step navigation. | Multi-step wizard (Account → Contact → Resume → Questions → EEO → Review) — stateful/sequential like Workday, but with an "already signed in" skip axis Workday's tenant-account model doesn't need. |
| Primary hook | data-automation-id (one vendor's React component ids, shared by every tenant). |
name attribute (Lever's own convention, shared by every posting). |
Best-guess semantic id/name — no shared vendor contract; genuinely varies field-by-field because many internal teams built different parts over time. |
| Login | Tenant account required; engine signs in with vault creds. | No login for the public apply flow. | Simulated locally only — the fixture's account step is never a real Amazon Identity endpoint; can be skipped via a simulated existing-session cookie. |
| Custom fields | Curated dropdowns keyed by data-automation-id (e.g. formField-degree) with promptOption listbox items. |
Bracketed names: urls[<Label>], cards[<uuid>][<idx>]. |
data-question-card='<cardId>-<index>' — per-posting, positionally located, mixed input kinds (radio/select/textarea/text). |
| Dropdown style | Custom button + role=listbox widget (click to open, click promptOption). |
Native <select> for most questions. |
Mix of native <select> (EEO, shift question) and native radio groups (Yes/No eligibility questions) — no custom widget. |
| Submit control | button[data-automation-id=pageFooterSubmitButton]. |
button.postings-btn[type=submit]. |
#submitApplication best-guess id, button[type=submit] generic fallback — no consistent class across teams. |
Consequence for the engine: the Amazon engine is stateful/sequential like Workday (it must
navigate .next-button clicks scoped to the currently active <section>), but its selector
chains lean on best-guess id-first, degrade-through-everything ordering because — unlike
Workday's and Lever's single shared vendor contract — there is no equally reliable hook to lead
with.
Failure modes surfaced by the loop¶
- Scoped-click bug (found + fixed during this build). The very first real run faulted
every iteration with a Playwright timeout clicking
.next-button: Playwright's.Locator(".next-button").Firstmatched whichever "Save and continue" button happened to come first in document order across the whole page — including buttons ondisplay:none(inactive) sections, which still exist in the DOM. The fix scopes every navigation click tosection.active .next-button, so it always targets the currently visible step. This is analogous to (but distinct from) the Lever lab's judgement-pause clobbering bug: both were "the obviously-intended behavior wasn't actually what the code did until we watched a real run fail." - EEO select-timeout bug (found + fixed during this build).
FillEeoAsynctriedSelectOptionAsyncwith the seed profile's exact demographic text (e.g."Prefer not to say"), which doesn't match the fixture's rendered EEO option list ("Decline to answer"). Playwright retries a failing select action for its full timeout budget before throwing, eating 1.5s per field and (combined with several EEO fields) risking cumulative timeouts. Fixed by pre-checking the rendered<option>text before attempting the select — a non-matching value is expected and normal for a voluntary field, not an error. - Judgement-pause must halt navigation, not just skip one click. Early in this build, only
the "Continue" click after the questions step was guarded by
if (run.Status != AwaitingJudgement)— butFillEeoAsyncstill ran unconditionally afterward and threw trying to interact with the still-hidden EEO section. Fixed by nesting the EEO fill AND the Review/Submit step inside the same guard, so a judgement pause halts the entire rest of the wizard walk, not just the next button click. - Ambiguous job-specific questions ("why do you want to work at Amazon" at 12% confidence,
"which shift" at 55%) score below the 90% auto-fill threshold ⇒ the engine raises a
JudgementCallwith ranked options instead of guessing. Work authorization (97%) and sponsorship (95%) are answered directly from the profile at high confidence. - Renamed/themed (
Degraded) and label-only (LabelOnly) contact fields miss the primary#idselector; the substring, aria-label, and label-sibling rungs recover them (visible asfallbackHitson First name/Last name/Phone/Address in the table above). - Unscoped radio lookup (found + fixed via Codex review, before commit). Work authorization
and sponsorship are both rendered as Yes/No radio groups. The first implementation answered a
radio question with
page.Locator("input[type='radio'][value='Yes|No']").First— a page-wide lookup with no question scoping. Because both eligibility questions share the sameYes/Novalues, answering sponsorship could silently check the authorization question's radio instead, leaving sponsorship unanswered while the run still reported success. Caught by a Codex review pass before this lab was committed; fixed by scoping the radio locator to the question's own[data-question-card='<cardId>-<index>']card, and covered by a dedicated Playwright-driven regression test (Engine_answers_each_radio_question_within_its_own_question_card_not_the_first_match_on_page) that drives two same-valued radio questions through the real engine and asserts each lands on its own card. - Cross-step field-name collision (found + fixed via Codex review, before commit).
ResolveAsync's selector chains were originally evaluated page-wide (page.Locator(sel).First), not scoped to the active step. Because the wizard keeps every step's markup in the DOM (hidden via CSS, not removed) and the account step's degraded email field (account-email-field) and the contact step's email field both contain the substring "email", a page-wideinput[id*='email' i]lookup could resolve to the WRONG step's (hidden, not-yet-visible) input depending on which happened to appear first in document order — producing an intermittent (seed-order-dependent, not literally random) Playwright timeout trying to fill an invisible element. This is the same class of bug as failure mode #1 (unscoped.next-buttonclick) but on field resolution rather than navigation. Fixed by scoping everyResolveAsynclookup — including the label-sibling fallback — topage.Locator("section.active"). Re-ran the 20-iteration loop three times after the fix; all three produced byte-for-byte identical results, confirming the collision (not environmental flakiness) was the root cause of an earlier non-reproducing timeout seen during development. - Non-seeded ids silently broke the
--seedreproducibility contract (found + fixed via Codex review, before commit). The first implementation built each iteration's job-question card id and requisition id withGuid.NewGuid()— unseeded — even though every other randomized decision inAmazonVariation.Randomcorrectly draws from the passed-in seededRandominstance. Since these ids are embedded in selectors, URLs, and JSONL events, two runs with the identical--seed Nwould NOT reproduce identical output, defeating the documented purpose of--seed. Fixed by deriving both ids from the same seededrnginstead, and strengthenedVariation_seed_reproduces_identical_sequenceto assert onRequisitionIdandCustomQuestions[].CardIdequality (previously the test only checked fields that happened not to touch the broken code path, so it could not have caught this). - Failure-mode metrics mislabeled expected judgement pauses as submit failures (found + fixed
via Codex review, before commit).
LoopRunnerunconditionally recordedsubmit-not-foundwheneversubmitLocatedwas false — but when a job-specific question raises aJudgementCall,FillApplicationAsyncintentionally returns before ever reaching the Review step, sosubmitLocatedis false BY DESIGN, not because a selector failed. The original tally therefore reportedsubmit-not-found: 15-19on every 20-iteration run purely as a side effect of the (correct, expected) judgement-pause rate, drowning out any genuine submit-selector regression in noise. Fixed by only recordingsubmit-not-foundwhen the engine actually walked to the review step and failed there (if (!submitLocated && !judgementRaised)).
Hardening approach / lessons carried over¶
- Order chains best-guess-id-first, then substring, then name, then aria, then
label-sibling, then placeholder. No single selector is load-bearing; the run's
fallbackHitsprove the lower rungs fire in practice. - Scope EVERY page interaction — navigation clicks AND field resolution — to the currently
active
<section>. This was the single biggest lesson of this build: a multi-step wizard fixture keeps every step's markup in the DOM (hidden via CSS, not removed), so ANY unscoped.Locator(...).First— whether clicking "Continue" (failure mode #1) or resolving a field by substring/aria/label (failure mode #7) — can silently grab a hidden, wrong-step element instead of the visible one. BothAmazonAutomationEngine's step-navigation clicks and itsResolveAsyncselector-chain walk are scoped tosection.activefor exactly this reason. Any future multi-step wizard lab should scope from the start rather than discovering this via a failing real run, as this build did twice. - Confidence-gate every non-derivable answer. If the profile can't answer a job-specific
question with high confidence, raise a
JudgementCall— do not guess. - Never let a "unique-looking" id generator quietly bypass the seeded RNG.
Guid.NewGuid()looks harmless next torng.Next()calls, but it silently breaks--seed's reproducibility promise the moment it's used anywhere in the variation-building path (see failure mode #8). Route every generated value — including ids meant only to "look unique," not to carry semantic weight — through the same seededRandominstance. - Re-applying the Lever lab's "judgement-pause clobbering" fix, pre-emptively. The Lever lab
found (and documented) a bug where locating the Submit button unconditionally overwrote
run.Status, silently erasing an activeAwaitingJudgementpause. This Amazon engine guards the exact same status write from day one:if (run.Status != RunStatus.AwaitingJudgement) run.Status = RunStatus.AwaitingFinalReview;— and additionally guards the entire EEO/Review walk (see failure mode #3), since Amazon's extra wizard steps between the pause point and Submit gave this class of bug more surface area to hide in than Lever's single-page form did. - Submit is located but never clicked in the harness; production keeps the final submit as a locked, user-gated action. The fixture additionally has no real endpoint, so there is no path to an accidental submit.
- Log-and-continue: a single iteration's exception is recorded as a failure mode and the loop proceeds, so a long soak run keeps producing selector statistics instead of aborting.
Why Amazon is a prime PageAgent-advisor case¶
Amazon's apply flow is hand-built by many internal teams over time, with no single shared
frontend component library enforcing a consistent attribute contract — unlike Workday's
data-automation-id or Lever's name convention, both reliable because one vendor's shared
frontend renders every tenant's/posting's form. That means the selector chains in
AmazonSelectors are genuinely best-guesses tuned against one plausible fixture, not a
guaranteed contract the way Workday's and Lever's chains are. A themed id, a missing label
for, or a renamed aria-label on the real site could silently break a hardcoded chain in a way
that's much harder to anticipate than "Workday renamed legalNameSection_firstName" or "Lever
suffixed name with -field-input" — because there's no vendor changelog or shared component
library to reason about. This is exactly the scenario an LLM-driven page-advisor (reading
arbitrary rendered DOM/accessibility-tree at run time rather than relying on precompiled
selector strings) is suited for.
The planned IPageAdvisor seam (see docs/technical/pageagent-feasibility.md research, and the
in-progress feature-pageagent-swap branch) plugs into this lab's judgement pause unchanged —
AmazonAutomationEngine's AwaitingJudgement/JudgementCall/RankedOption pause-and-ask
contract is advisor-agnostic: today the loop auto-resolves by taking the top-ranked heuristic
guess, but the exact same pause point is where an advisor's DOM-read + ranked-option proposal
would plug in instead, with zero changes to the pause/resume contract.
To be direct: IPageAdvisor does not exist as code anywhere in this repo yet. It is a planned
interface from a research spike, not a real dependency of this lab — nothing here is wired to
it, and this lab does not depend on it existing to be useful today.
Running it¶
# short verification run (default is also 20 iterations)
dotnet run --project tools/WorkWingman.ScraperLab.Amazon -- --iterations 20
# long local soak (reproducible variations via --seed)
dotnet run --project tools/WorkWingman.ScraperLab.Amazon -- --minutes 60 --seed 7 --output lab-output
Output is rolling JSONL (one event per line: iteration, judgement-call) plus a console
summary of selector hit/miss tallies and failure-mode counts. Everything stays on 127.0.0.1.
JSONL log: lab-output/lab-amazon-<timestamp>.jsonl
No application was ever submitted. No real network call was ever made. No real Amazon account was ever touched.