Scraper Automation Learnings — Greenhouse (offline lab)¶
This document captures what the offline Greenhouse learning harness
(tools/WorkWingman.ScraperLab.Greenhouse) taught us about automating
Greenhouse-powered application forms, and how a production adapter should target
Greenhouse markup.
Everything here was learned locally. The harness never touches
boards.greenhouse.io, never opens a real Greenhouse tenant, never creates an
account, and never submits. The only site involved is a loopback HttpListener
on 127.0.0.1 (FakeGreenhouseSite) that reproduces Greenhouse's public markup
patterns. The submit button (#submit_app) is located but never clicked; even
if clicked it is a client-side no-op with no network call.
1. What the lab is¶
| Piece | File | Role |
|---|---|---|
| Fake site | FakeGreenhouseSite.cs |
Loopback HTTP server rendering a realistic single-page Greenhouse apply form; parameterized per iteration. |
| Variation | SiteVariation.cs |
Randomizes field ids / labels / required-ness / iframe embed / hydration delay / which custom questions appear. |
| Selectors | GreenhouseSelectors.cs |
Ordered fallback selector chains (most-specific first) + the SelectorAttempt record. |
| Engine | GreenhouseAutomationEngine.cs |
Drives the fixture via Playwright: resolves the form frame, fills core fields, attaches resume, answers custom questions, raises judgement calls, locates but never clicks submit. |
| Metrics | LabMetrics.cs |
Rolling JSONL log + selector hit/miss tallies + failure-mode counts. |
| Loop | LoopRunner.cs / Program.cs |
Iterates against the fixture, log-and-continue, --iterations N / --minutes M. |
| Profile | SampleProfile.cs |
Local IntakeProfile (Andrew Jones) reusing the real WorkWingman.Core shapes. |
Run:
dotnet run --project tools/WorkWingman.ScraperLab.Greenhouse -- --iterations 20
# long local soak:
dotnet run --project tools/WorkWingman.ScraperLab.Greenhouse -- --minutes 60 --seed 7
2. Greenhouse markup patterns to target (and WHY)¶
Greenhouse's application form is far simpler and more semantic than Workday's. From public knowledge, a Greenhouse apply page has these stable properties, which the adapter targets:
-
Single page. All fields render at once inside one
<form id="application_form">. There is no multi-step wizard, no "Save and Continue", no per-step navigation. Why it matters: the adapter does not need step orchestration or "wait for next step" logic — it fills everything, then locates submit. -
Core fields use plain semantic
ids.#first_name,#last_name,#email,#phone, and the resume file input#resume. Names followjob_application[first_name], etc. Why it matters: the primary selector is a direct#id— cheap and reliable when present. This is the opposite of Workday, which uses opaquedata-automation-idattributes. -
Resume is a file
<input type="file" id="resume">, often alongside a "paste"<textarea>. Why it matters: attach withSetInputFileson the file input specifically — see the wrapper-div hazard in §4. -
Custom questions are
job_application[answers_attributes][N][...]inputs. Each has a hidden[question_id], and a value field that is one of[text_value](free text),[answer_selected](select), or a boolean. The only reliable anchor is the visible<label>text — the numeric indexNshifts from posting to posting and thequestion_idis opaque. Why it matters: custom questions must be resolved by label (page.GetByLabel(...)), never by id or index. This is the single most important Greenhouse-specific rule. -
Iframe embed. A company careers page frequently embeds the board via
<iframe id="grnhse_app" src="https://boards.greenhouse.io/embed/...">. The form lives inside the iframe, not the top document. Why it matters: the adapter must resolve the correct frame first (ResolveFormFrame) and run all locators against that frame. A top-frame-only adapter silently finds nothing on embedded postings. -
Submit is
#submit_app. Why it matters: a single canonical id, easy to locate — but in the lab it is never clicked.
3. How Greenhouse differs from Workday (the core lesson)¶
| Dimension | Workday | Greenhouse |
|---|---|---|
| Flow | Multi-step wizard (Sign In → My Info → Experience → Education → EEO → Review) | Single page — all fields at once |
| Field anchor | data-automation-id (opaque, React-emitted) |
Plain semantic id (#first_name) + name |
| Account | Usually requires sign-in / account creation | Usually no account — apply directly |
| Custom questions | Structured form fields per step | answers_attributes[N] arrays, label-anchored only |
| Dropdowns | Custom button + listbox (promptOption) |
Native <select> (SelectOptionAsync works directly) |
| Embedding | Own domain (*.myworkdayjobs.com) |
Often an #grnhse_app iframe embed |
| Degree/education | Ambiguous listbox → judgement call | Sometimes a native <select>, sometimes absent |
| Adapter complexity | Step orchestration + wait-for-step | Frame resolution + label-driven custom questions |
Takeaway: the Workday adapter's hard part is navigation and opaque ids; the
Greenhouse adapter's hard part is frame resolution and label-anchored custom
questions. The selector strategy inverts: Workday starts from
data-automation-id, Greenhouse starts from #id and falls back to label.
4. Selector findings (from the loop)¶
The engine tries an ordered fallback chain per field, most-specific first, and
records every attempt (SelectorAttempt { Label, ChainIndex, Selector, Matched }).
ChainIndex == 0 is a primary (canonical-id) hit; anything higher is a fallback.
Core-field chain (GreenhouseSelectors.CoreCssChain):
0: #first_name canonical id
1: [id*='first_name'] renamed/suffixed id (#first_name_input)
2: [name*='[first_name]'] name attribute (job_application[first_name])
3: [name*='first_name'] looser name-substring
+ getByLabel('First Name') last-resort label fallback
Findings from a 20-iteration run (seed 1337, all 20 completed, 0 faulted):
-
Fallbacks fire often and are necessary. Roughly a third of core-field resolutions came from a fallback link rather than the canonical
#id, because the variation renames or drops ids. A single hard-coded#first_namewould have missed those. The label fallback is the safety net that keeps the match rate at 100% of present fields. -
The
[id*=]substring hazard (a real bug the lab caught). The first version of the resume chain used[id*='resume']. On the fixture that matched the wrapper<div id="resume_field">before the file input, andSetInputFilesthen threwNode is not an HTMLInputElement— faulting half the iterations. Fix: scope substring id-matches by element type, e.g.input[type='file'][id*='resume'], never a bare[id*='resume']. Lesson: a loose*=id match will happily grab a container. Always constrain the tag. -
Custom questions matched 100% via label (
getByLabel), confirming the label-anchored approach. Index/id-based matching was never needed and would have been brittle. -
Native
<select>works withSelectOptionAsync. No custom-listbox dance is needed (unlike Workday'spromptOption).
5. Failure modes observed¶
judgement:unmapped-custom-question— a custom question whose label the heuristic couldn't confidently map to a profile answer (e.g. "Why do you want to work here?", or an unfamiliar yes/no). In the lab this is recorded and auto-resolved (top option / blank); in production it becomes a genuine pause for the user. This is the expected, healthy failure mode — the adapter should never guess an answer to an unmapped question.judgement:option-not-offered— a dropdown whose desired label the posting doesn't offer (e.g. education level phrased differently). Raised, not forced.judgement:resume-input-not-found/ field-not-found — the fallback chain exhausted without a hit. Optional fields (phone) log-and-skip; required fields raise.exception:*(Playwright) — caught by the log-and-continue loop; one iteration's fault never stops the run. (TheNode is not an HTMLInputElementcase in §4 was one of these until fixed.)
6. Hardening approach¶
- Resolve the frame first. Always call
ResolveFormFrameand run every locator against the resolved frame, so embedded (#grnhse_app) and direct-board postings behave identically. - Chain, don't hard-code. Primary
#id→ substring id (tag-scoped!) →name→ label. Record every attempt so hit/miss telemetry shows which anchor is actually load-bearing per tenant. - Custom questions by label, always. Never trust the
answers_attributes[N]index or the opaquequestion_id. - Tag-scope every
*=selector. A bare[id*='x']can match a wrapper; scope it to the expected element (input[...],button[...]). - Judgement over guessing. Unmapped custom questions and unoffered dropdown options raise a judgement call rather than submitting a wrong answer.
- Log-and-continue. Every iteration is independent; failures are recorded and the loop proceeds.
- Submit is located, never clicked. The lab proves the adapter can find
#submit_appwithout ever transmitting anything.
7. Safety statement¶
- Loopback only:
http://127.0.0.1:<random-port>, bound viaHttpListenertoIPAddress.Loopback. - No real Greenhouse, no
boards.greenhouse.io, no account creation, no network egress of any kind. The engine performs local fill-input only. - The
/submitfixture endpoint is a no-op that returns a "nothing was submitted" page and is never requested by the loop. The#submit_appbutton is a client-side no-op. - The profile is local fixture data (Andrew Jones) and is never transmitted.