Skip to content

Scraper Automation Learnings — Lever

Source of these learnings: the offline learning harness at tools/WorkWingman.ScraperLab.Lever. It drives the real LeverAutomationEngine against a loopback-only FakeLeverSite (an HttpListener on 127.0.0.1) built from public knowledge of Lever apply forms. There is no real network egress, no jobs.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% on Phone/Location reflects deep fallbacks + the ~8% Missing rate on optional fields, not failure — every iteration still completed.
  • fallbackHits > 0 is the important signal. Company/Org hit 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, and Custom hit 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

  1. Judgement-pause was being clobbered (bug found + fixed). The first 20-iteration run reported JudgementCalls: 0 despite 18 PAUSED log lines. Root cause: after a custom question set run.Status = AwaitingJudgement and returned, FillApplicationAsync unconditionally set run.Status = AwaitingFinalReview when locating the submit button, erasing the pause. Fix: guard the submit-locate status write with if (run.Status != AwaitingJudgement). After the fix the same run reported JudgementCalls: 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".
  2. 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 JudgementCall with ranked options instead of guessing. Authorization/sponsorship/education questions are answered directly from the profile at high confidence.
  3. Renamed / themed field names (IdStyle.Degraded) miss the primary name= selector; the [name*='token'] substring rung recovers them (visible as fallbackHits).
  4. Absent optional fields (IdStyle.Missing on phone/org/location) resolve to null on every rung; the engine logs "optional, skipped" and continues rather than throwing.
  5. Dropped data-qa (~25% of iterations): the whole [data-qa=…] rung misses; name/aria rungs carry the load, confirming the chain does not depend on data-qa being present.
  6. Slow-rendered custom-question cards (client visibility toggled after a delay): tolerated because ResolveAsync uses CountAsync on 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 fallbackHits prove the lower rungs fire in practice.
  • Locate custom questions positionally within their .application-question card, since the cards[<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.