Scraper Automation Learnings — Lever¶
Source of these learnings: the offline learning harness at
tools/WorkWingman.ScraperLab.Lever. It drives the realLeverAutomationEngineagainst a loopback-onlyFakeLeverSite(anHttpListeneron127.0.0.1) built from public knowledge of Lever apply forms. There is no real network egress, nojobs.lever.co, no account creation, and the final Submit is never clicked — it is a client-side no-op that logs and restarts. Everything below was observed locally.
What Lever's apply form looks like (public markup patterns)¶
Lever hosts applications at https://jobs.lever.co/<company>/<postingId>/apply. The apply
page is a single flat form, not a wizard:
<form class="application-form" data-qa="application-form" method="post" action="…">
<ul class="application-fields">
<li class="application-field">
<label>Full name<input name="name" type="text" /></label>
</li>
<li class="application-field">
<label>Email<input name="email" type="email" /></label>
</li>
<li class="application-field">
<label>Phone<input name="phone" type="tel" /></label>
</li>
<li class="application-field">
<label>Current company<input name="org" type="text" /></label>
</li>
</ul>
<!-- resume upload -->
<input type="file" name="resume" accept=".pdf,.doc,.docx" />
<!-- URL cards: Lever's distinctive bracketed names -->
<input name="urls[LinkedIn]" type="url" />
<input name="urls[GitHub]" type="url" />
<input name="urls[Portfolio]" type="url" />
<!-- per-posting custom questions -->
<div class="application-question required" data-qa="custom-question-0">
<label>Are you authorized to work in the US?
<select name="cards[<uuid>][0]" required>…</select>
</label>
</div>
<!-- optional GDPR-style consent -->
<input type="checkbox" name="consent" />
<button type="submit" class="postings-btn template-btn-submit" data-qa="btn-submit">
Submit application
</button>
</form>
Key identifying hooks, in order of stability:
| Element | Primary hook | Why |
|---|---|---|
| Core fields | input[name=name\|email\|phone\|org] |
The input name is Lever's stable contract. Themes restyle classes, but names are what Lever's own backend binds to. |
| Custom URL cards | input[name="urls[LinkedIn]"] |
Bracketed urls[<Label>] names are a Lever fingerprint. The label inside the brackets is the link type. |
| Custom questions | input\|select\|textarea[name="cards[<cardUuid>][<idx>]"] |
Per-posting questions are keyed by a card UUID + index — unstable across postings, so you locate them positionally within the card, not by a fixed selector. |
| Resume | input[name=resume] / input[type=file] |
Single file input; Lever will parse it, but we never upload in the lab. |
| Submit | button.postings-btn[type=submit] |
.postings-btn is Lever's canonical submit class; button[type=submit] is the generic fallback. |
| Form root | form.application-form / form[data-qa=application-form] |
data-qa hooks exist on modern themes but are dropped by some custom/older themes. |
Selector strategy (fallback chains)¶
LeverSelectors defines an ordered chain per field, index 0 = most stable, later = looser.
LeverAutomationEngine.ResolveAsync walks the chain, fires an OnSelectorAttempt callback for
every step (hit or miss), and returns the first present locator. Example for email:
input[name='email'] (primary)
[data-qa='email-input'] (modern data-qa hook)
input[type='email'] (type fallback)
input[name*='email'] (substring fallback — survives themed/suffixed names)
input[aria-label='Email'] (label fallback)
The lab renders each field in one of four IdStyles per iteration — Primary (canonical
name+data-qa), Degraded (a themed/suffixed name like email-field-input, still
substring-matchable), AriaOnly (no name/data-qa; only label/placeholder), or Missing
(absent) — to force the engine down every rung of the chain.
Observed selector hit/miss (20 iterations, seed 1337)¶
Company/Org attempts=41 match%=48.8 primaryHits=10 fallbackHits=10
Email attempts=34 match%=58.8 primaryHits=13 fallbackHits=7
Name attempts=32 match%=62.5 primaryHits=16 fallbackHits=4
Phone attempts=40 match%=45.0 primaryHits=12 fallbackHits=6
Location attempts=40 match%=45.0 primaryHits=12 fallbackHits=6
Resume upload attempts=26 match%=65.4 primaryHits=17 fallbackHits=0
Submit button attempts=20 match%=100 primaryHits=20 fallbackHits=0
URL attempts=29 match%=100 primaryHits=29 fallbackHits=0
Custom attempts=27 match%=100 primaryHits=27 fallbackHits=0
Reading the numbers:
match%is per-attempt, not per-field. A field with a 5-rung chain that hits on rung 3 logs 3 attempts (2 miss + 1 hit) ⇒ ~33% attempt match even though the field was found. Low attempt-match% onPhone/Locationreflects deep fallbacks + the ~8%Missingrate on optional fields, not failure — every iteration still completed.fallbackHits > 0is the important signal.Company/Orghit its fallback rungs as often as its primary (10/10), confirming the[name*='org']and aria fallbacks earn their keep when a theme renames the field.Submit,URL, andCustomhit primary 100% — their names (postings-btn,urls[...],cards[...][...]) are the most contractually stable hooks on the page.
How Lever differs from Workday (the important part)¶
| Dimension | Workday | Lever |
|---|---|---|
| Page model | Multi-step wizard (Sign in → My Info → Experience → Education → EEO → Review). Engine must navigate pageFooterNextButton and wait for each step to render. |
Single flat page. One fill pass; no step navigation, no per-step waits. |
| Primary hook | data-automation-id (React-component ids like legalNameSection_firstName). |
name attribute (name, email, urls[LinkedIn], cards[…]). |
| Login | Tenant account required; engine signs in with vault creds (and may create an account). | No login for the public apply flow — you land straight on the form. |
| Custom fields | Curated dropdowns keyed by data-automation-id (e.g. formField-degree) with promptOption listbox items. |
Bracketed names: urls[<Label>] for links, cards[<uuid>][<idx>] for per-posting questions. The UUID is posting-specific ⇒ locate positionally, not by fixed selector. |
| Field naming | Descriptive component ids. | Terse HTML names; the label text carries the human meaning, so label/aria fallbacks matter more. |
| Dropdowns | Custom button + role=listbox widget (click to open, click promptOption to pick). |
Native <select> for most questions ⇒ SelectOptionAsync by label works directly. Simpler. |
| Submit control | button[data-automation-id=pageFooterSubmitButton]. |
button.postings-btn[type=submit]. |
Consequence for the engine: the Workday engine is stateful/sequential (walks a step machine);
the Lever engine is single-pass and leans harder on name-based + label fallbacks because
Lever's HTML names are terse and custom-question selectors are non-portable across postings.
Failure modes surfaced by the loop¶
- Judgement-pause was being clobbered (bug found + fixed). The first 20-iteration run
reported
JudgementCalls: 0despite 18PAUSEDlog lines. Root cause: after a custom question setrun.Status = AwaitingJudgementand returned,FillApplicationAsyncunconditionally setrun.Status = AwaitingFinalReviewwhen locating the submit button, erasing the pause. Fix: guard the submit-locate status write withif (run.Status != AwaitingJudgement). After the fix the same run reportedJudgementCalls: 18. This is exactly the class of bug the harness exists to catch — a safe outcome (pause for the user) being silently downgraded to "ready to submit". - Ambiguous custom questions (desired salary, "how did you hear about us", free-text project
descriptions) score below the 90% auto-fill threshold ⇒ the engine raises a
JudgementCallwith ranked options instead of guessing. Authorization/sponsorship/education questions are answered directly from the profile at high confidence. - Renamed / themed field names (
IdStyle.Degraded) miss the primaryname=selector; the[name*='token']substring rung recovers them (visible asfallbackHits). - Absent optional fields (
IdStyle.Missingon phone/org/location) resolve tonullon every rung; the engine logs "optional, skipped" and continues rather than throwing. - Dropped
data-qa(~25% of iterations): the whole[data-qa=…]rung misses; name/aria rungs carry the load, confirming the chain does not depend ondata-qabeing present. - Slow-rendered custom-question cards (client visibility toggled after a delay): tolerated
because
ResolveAsyncusesCountAsyncon a located element rather than assuming synchronous render, and the loop is log-and-continue.
Hardening approach¶
- Order chains name-first, then data-qa, then substring, then label/aria. Never let a single
selector be load-bearing; the loop's
fallbackHitsprove the lower rungs fire in practice. - Locate custom questions positionally within their
.application-questioncard, since thecards[<uuid>][<idx>]name is not portable across postings. - Confidence gate every non-derivable answer. If the profile can't answer a custom question
with high confidence, raise a
JudgementCall— do not guess. And make sure later steps (like locating Submit) never overwrite that paused status (see failure mode #1). - 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.
Running it¶
# short verification run (default is also 20 iterations)
dotnet run --project tools/WorkWingman.ScraperLab.Lever -- --iterations 20
# long local soak (reproducible variations via --seed)
dotnet run --project tools/WorkWingman.ScraperLab.Lever -- --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-lever-<timestamp>.jsonl
No application was ever submitted. No real network call was ever made.