ATS traversal strategy (WING-333)¶
Authored by Jenny (Gemini 3.1 Pro High) on a brief built from measured findings, 2026-08-05. Posting-ID regexes VERIFIED against live producer APIs where possible: Greenhouse (7995199 via careers.airbnb.com vanity + gh_jid), Ashby (uuid), iCIMS (77665) all MATCH the id the vendor's own API reports. Lever UNTESTED — its public API returned an HTTP error during verification. Phenom deliberately left UNKNOWN rather than guessed.
1. Anchor-Scan Rules¶
When analyzing a page's HTML without a browser, extract all <a> tags with an href attribute. Exclude mailto:, tel:, and javascript: schemes. Resolve relative URLs using the page's base URI.
Rank the extracted anchors using the following precedence rules (Highest to Lowest). The highest-scoring anchor that meets the criteria is chosen as the apply URL.
- Priority 1: Known ATS Shape + "Apply" Intent
- Condition: The
hrefmatches a known ATS URL pattern (e.g.,*.icims.com/jobs/*,boards.greenhouse.io/*) AND the anchor's inner text,aria-label, ortitlecontainsapply,submit, orapplication(case-insensitive). - Action: Immediately select.
- Condition: The
- Priority 2: Known ATS Shape Only
- Condition: The
hrefmatches a known ATS URL pattern, but lacks explicit "apply" text (e.g., an icon link or a generically named button). - Action: Select, provided no Priority 1 links exist. If multiple different known ATS URLs exist (e.g., one to iCIMS, one to Lever), return Unknown (Ambiguous).
- Condition: The
- Priority 3: Aggregator / Tracking Handoff
- Condition: The
hrefdoes not match an ATS, but the anchor text is exactlyApply NoworApply(ignoring whitespace/case). - Action: Select for next-hop traversal. (Must verify it's not a generic
/how-to-applypage by ensuring the URL has query parameters or dynamic path segments).
- Condition: The
- Exclude/Discard:
- Anchor text contains:
how to apply,application process,learn more,contact us,login. hrefcontains:/search-jobs,/subscribe,/saved-jobs,/alerts.
- Anchor text contains:
2. Posting ID Extraction¶
Use the following URL shapes and regexes to extract the stable posting ID. Note: Always run the regex against the path and query string, not the entire URL string, to avoid matching tenant IDs or hostnames.
| ATS | URL Shape | Regex (Group 1 = ID) |
Notes |
|---|---|---|---|
| Greenhouse | boards.greenhouse.io/{tenant}/jobs/{id} .../embed/job_app?gh_jid={id} |
(?:/jobs/\|\?gh_jid=\|token=)([0-9]+) |
IDs are numeric. |
| Lever | jobs.lever.co/{tenant}/{id} |
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}) |
UUID format. |
| Ashby | jobs.ashbyhq.com/{tenant}/{id} |
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}) |
UUID format. |
| iCIMS | careers-{tenant}.icims.com/jobs/{id}/... |
/jobs/(\d+)/ |
IDs are numeric. |
| Workday | {tenant}.myworkdayjobs.com/.../job/{loc}/{slug}_{id} |
(?:/job/[^/]+/[^/]+_)(JR\d+\|[A-Z0-9-]+)$ |
Usually prefixed with JR (e.g., JR12345). |
| Workable | apply.workable.com/{tenant}/j/{id} |
/j/([A-Z0-9]+) |
Alphanumeric (e.g., 0123456789A). |
| Jobvite | jobs.jobvite.com/{tenant}/job/{id} |
/job/([a-zA-Z0-9]+) |
Alphanumeric. |
| SmartRecruiters | jobs.smartrecruiters.com/{tenant}/{id}-{slug} |
/([0-9]{15,})- |
IDs are typically 15+ digit numeric strings. |
| Phenom | careers.{tenant}.com/.../job/{id} |
UNKNOWN | Highly variable per tenant. Cannot reliably extract without tenant-specific rules. |
3. HTTP vs. Browser Decision Rule¶
Evaluate the plain HTTP GET response to decide if Playwright is necessary.
- HTTP is Sufficient (Stop and Parse):
- Status is
200 OK. Content-Length> 10 KB (or body length > 10,000 characters).- The DOM contains identifiable
<a>tags withhrefattributes. - The body contains plain text matching job description content (e.g., "responsibilities", "requirements").
- Status is
- Browser Required (Escalate to Playwright):
- Response is a JS-only SPA shell:
<div id="root"></div>or<app-root></app-root>with little to no static body text. - The only "Apply" buttons found are
<button>elements withonclickhandlers and no associated<a>tags orhrefattributes. - The target ATS is known to rely on heavily dynamic rendering (e.g., Workday forms).
- Response is a JS-only SPA shell:
4. Stop Conditions and Fail-Closed¶
Halt traversal immediately and return Unknown if any of the following occur:
- Bot Protection / WAF Detected: HTTP 403, or HTTP 200 with
<title>Just a moment...</title>,window._cf_chl_opt(Cloudflare),Incapsula, ordatadomein the response body. Do not escalate to browser. - Login Walls: The resolved URL path contains
/login,/signin, orauth., OR the page title contains "Log In" or "Sign In". - Search/Listing Page Detected: The page contains > 3 distinct known ATS Apply URLs pointing to different posting IDs. This indicates a search index, not a single job posting.
- Consent Interstitials (Blocking): The URL path explicitly points to a
/cookie-consentor/privacy-policyinterstitial. - HTTP Errors: HTTP 400-499 (excluding WAFs) or 500-599.
- Ambiguity: Multiple known ATS URLs are found for different vendors on the same page, and priority rules cannot safely break the tie.
5. Loop and Trap Avoidance¶
- Hop Caps: Hard limit of 5 HTTP 3xx redirects per request, and 3 logical page traversals (e.g., Aggregator $\rightarrow$ Vanity $\rightarrow$ ATS).
- Cycle Detection: Maintain a
HashSet<string>of normalized absolute URLs (stripped of fragments and utm parameters). If a URL is encountered twice, abort and return Unknown. - Aggregator Trap: If an employer's vanity page contains outbound links to known other aggregators (e.g., ZipRecruiter, Indeed) instead of ATS vendors, treat the page as a secondary aggregator. Do not traverse further.
6. Playwright Specifics¶
When escalating to a browser, use the following minimal sequence to avoid timeouts and fragile selectors.
- Launch: Use standard user agent and viewport. Do not inject stealth scripts (violates constraint).
- Navigation:
await page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded });(Never wait forNetworkIdle). - Target Acquisition: Wait for the job description or apply button to appear:
await page.WaitForSelectorAsync("text=/Apply/i", new PageWaitForSelectorOptions { Timeout = 5000 }); - Interaction:
// Prepare to catch either a new tab or a cross-origin top-level navigation var newPageTask = context.WaitForPageAsync(); await page.ClickAsync("button:has-text('Apply'), a:has-text('Apply')"); - Handoff Detection:
- If a new tab opens: Check the URL of the
newPageTaskresult. - If no tab opens: Wait up to 3000ms for
page.Urlto change. Ifpage.Urlchanges to a cross-origin domain (e.g.,jobs.spectrum.com$\rightarrow$careers-charter.icims.com), handoff is successful. Extract the URL. - If URL stays the same: Check for injected iframes (e.g., Greenhouse embedded):
page.Frames.FirstOrDefault(f => f.Url.Contains("boards.greenhouse.io")). Extract iframesrcif found.
- If a new tab opens: Check the URL of the