TRAVERSAL LAB — ZipRecruiter (host class: aggregator + paid distributor)¶
Scope: ZipRecruiter only, as one hop in the job-link resolution chain
(...Adzuna -> ZipRecruiter -> jobs.spectrum.com -> careers-charter.icims.com).
Mirrors the house pattern in IcimsSelectorAndJudgementTests.cs: philosophy first,
then an ordered selector chain the tests pin down.
All facts below are labeled OBSERVED (verified live today via a headful Playwright-style
browser session against public ZipRecruiter pages, no login, no bot-evasion) or INFERRED
(reasoned from the observed shape but not independently confirmed). Search: "software engineer",
Remote/Belleville IL, ziprecruiter.com/jobs-search and the /job-redirect endpoint it emits.
1. TRAVERSAL PHILOSOPHY¶
ZipRecruiter is structurally two different things wearing one skin, and the traversal chain has to branch on which one it is looking at before it decides what "the answer" means:
- Aggregator-with-hop: most listings carry an
<a>tagged "Apply" whosehrefisziprecruiter.com/job-redirect?match_token=<opaque>. The token is an opaque, server-signed blob (base64url-encoded, protobuf-shaped) — it is NOT a parseable URL and must never be decoded or regex'd client-side. The only legitimate way to learn the destination is to actually navigate the anchor and read where the browser lands (OBSERVED: it resolves via at least one intermediate ad-tech redirector before the employer domain — see negative rules). This is the same category of problem as Adzuna's signedazttJWT: a real network hop is required, not string parsing. - Aggregator-as-terminal ("Quick Apply"): a meaningful fraction of listings replace the
<a href>with a plain<button>(OBSERVED: nohref,type="button", text "Quick Apply"). Clicking it opens an in-page application flow hosted BY ZipRecruiter itself — there is no employer ATS to hop to because the employer chose to host the application on ZipRecruiter (confirmed by ZipRecruiter's own employer-help documentation: employers choose either "host on ZipRecruiter" or "custom apply URL"). For traversal purposes this is a terminal node that is not an ATS — the chain must stop and report "no onward ATS; apply happens on ZipRecruiter" rather than keep hopping or guessing a fake destination.
The deciding signal is never page text or a company/vendor name mention — it is the shape of
the CTA control itself: anchor with an href to /job-redirect → hop; button with no href →
terminal-on-ZipRecruiter. Everything else on the page (job description text, which is often
copied/rewritten aggregator content) is untrusted for vendor identification, same principle as
the iCIMS/Lever/Radancy marker-soup trap on jobs.spectrum.com one hop later.
Paid-distribution fact (OBSERVED, corroborates the brief): the ad-tech token embedded in the
match_token for the SPECTRUM listing carries a campaign path segment OSBg87b, and the final
employer-domain URL after following the redirect carries p_sid=OSBg87b alongside
ss=paid&utm_medium=sponsored-jobs&utm_source=ziprecruiter&utm_campaign=...&utm_content=....
The sponsor tag is provable end-to-end without trusting either side's self-report — the
campaign id round-trips.
2. ORDERED SELECTOR / URL-PREDICATE CHAIN¶
Playwright-compatible. Most-specific-and-most-trustworthy first, same discipline as the iCIMS lab (never lead with something structurally weak).
public static class ZipRecruiterSelectors
{
// STEP 0 — host predicate: are we even looking at a ZipRecruiter page?
// Verified UA-side hosts: "ziprecruiter.com" and "www.ziprecruiter.com".
public static bool IsZipRecruiterHost(Uri u) =>
u.Host.Equals("ziprecruiter.com", StringComparison.OrdinalIgnoreCase) ||
u.Host.Equals("www.ziprecruiter.com", StringComparison.OrdinalIgnoreCase);
// STEP 1 — the ONLY reliable "there is a hop" selector.
// OBSERVED: <a href="https://www.ziprecruiter.com/job-redirect?match_token=...">Apply</a>
// Href-based, not text-based — text "Apply" alone is reused elsewhere (share dialogs,
// "Apply now" marketing copy) and is NOT sufficient on its own.
public static readonly string[] OnwardHopAnchor =
[
"a[href*='ziprecruiter.com/job-redirect']", // most specific: full host+path
"a[href^='/job-redirect']", // relative-path fallback, same origin
];
// STEP 2 — the "no hop, terminal here" selector. Checked BEFORE assuming step 1 failed
// means "keep looking" — its presence is itself the answer, not an absence.
// OBSERVED: <button type="button">Quick Apply</button> — no href, plain button role.
public static readonly string QuickApplyTerminalButton =
"button:has-text('Quick Apply')";
// STEP 3 — the URL-predicate for the redirector endpoint itself, used to recognize
// "we are mid-hop, not yet at an answer" while a navigation is resolving.
public static bool IsJobRedirectEndpoint(Uri u) =>
IsZipRecruiterHost(u) &&
u.AbsolutePath.Equals("/job-redirect", StringComparison.OrdinalIgnoreCase) &&
System.Web.HttpUtility.ParseQueryString(u.Query)["match_token"] is not null;
// STEP 4 — sponsor/paid-placement corroboration predicate on the LANDED page (not on
// ZipRecruiter's own page). Used to attach provenance, never to decide "is this the
// answer" — the landed host is the answer regardless of whether these params are present.
public static bool CarriesZipRecruiterSponsorTag(Uri landedUri)
{
var q = System.Web.HttpUtility.ParseQueryString(landedUri.Query);
return string.Equals(q["utm_source"], "ziprecruiter", StringComparison.OrdinalIgnoreCase)
|| string.Equals(q["ss"], "paid", StringComparison.OrdinalIgnoreCase)
|| string.Equals(q["utm_medium"], "sponsored-jobs", StringComparison.OrdinalIgnoreCase);
}
}
Ordering rationale:
1. Host predicate first — cheap, and every later selector is meaningless off-host.
2. a[href*='job-redirect'] before anything text-based, because href is a real DOM
attribute we verified exists (OBSERVED), while the visible label ("Apply") is decorative
and reused elsewhere on the same page (share widgets, unrelated CTAs).
3. Quick-Apply terminal check happens at the SAME priority tier as step 1, not after a
failed step 1 — it is not a fallback, it is a different but equally valid resolved state.
A chain that treats "no <a href> found" as failure rather than checking for the button
first will fail-closed on a large fraction of legitimately-terminal listings.
4. The redirect-endpoint URL predicate is for mid-flight bookkeeping only (e.g., logging
"currently hopping through ZipRecruiter's own redirector") — it must never be treated as
the final answer.
5. The sponsor-tag predicate runs LAST and only on the page ZipRecruiter's redirect
ultimately lands on, never on a ZipRecruiter page. It is metadata, not routing.
3. NEGATIVE RULES¶
- Never regex or base64-decode
match_tokento "extract" a destination URL. OBSERVED: it is an opaque, apparently protobuf-encoded, server-signed blob. Even though a destination fragment is visibly embedded in it in cleartext-ish form in some samples, that is an implementation detail of ZipRecruiter's ad partner, not a contract. Treat it as a capability token to be navigated, never parsed. - Never treat the intermediate ad-tech redirector as terminal. OBSERVED: the resolved
chain for the SPECTRUM listing passed through an ad-tech domain
(
dsp.prng.co/<campaign-id>?clickid=...) embedded in the token before reachingjobs.spectrum.com. A predicate that stops at the first non-ziprecruiter.com host will silently mis-resolve every paid listing to an ad-tech clickthrough page instead of the employer site. Keep following 30x redirects until the host is neitherziprecruiter.comnor a known ad-redirector pattern, and the response is a real 200 content page. - Never resolve vendor/ATS identity from ZipRecruiter's own page text or job description.
The description is aggregator-side copy (may be rewritten/truncated) and is not evidence of
where the apply flow leads — same trap class as the iCIMS/Lever/Radancy marker soup one hop
downstream on
jobs.spectrum.com. Only the followed-through landed URL counts. - Never select on Tailwind/utility CSS classes. OBSERVED: both the Apply anchor and the
Quick Apply button carry only hashed/utility class names
(e.g.
inline-flex justify-center ... bg-button-primary-default) with noidordata-testid. These are cosmetic, framework-generated, and unstable across releases — never lead a selector chain with a class-name match on this vendor. - Never assume the search-results list page exposes a per-job canonical URL.
OBSERVED:
ziprecruiter.com/jobs-searchis a client-rendered SPA; clicking a job card opens an in-page panel via abutton(not a navigable<a>) and fetches details through an internal Connect/protobuf RPC (.../job_card.api_public.public.api.v1.API/GetJobDetails). The address bar does not change to a job-specific path. Do not build a chain step that expects to read the posting id off the browser URL at this stage. - Never treat
/job-redirectitself, or any URL still onziprecruiter.com/dsp.prng.co(or same-shaped ad-redirector hosts), as the answer even if it 200s. A redirector returning 200 while still mid-chain is not the same as arriving.
4. TERMINATION PREDICATE¶
Given a page confirmed on a ziprecruiter.com host (Step 0):
IF a Quick-Apply button (Step 2 selector) is present and no job-redirect anchor exists:
RESOLVE => Terminal(vendor: "ziprecruiter", atsVendor: None,
reason: "employer hosts application on ZipRecruiter; no onward ATS")
-- this is a VALID, non-failure resolution. Do not report Unknown.
ELSE IF a job-redirect anchor (Step 1 selector) is present:
NAVIGATE the anchor, following redirects, until:
(a) the resulting host is NOT ziprecruiter.com and NOT a known ad-redirector host
(dsp.prng.co and equivalent single-purpose click-tracking domains), AND
(b) the response is a 200 content page (not another redirect / not an error / not
another Cloudflare-style interstitial)
THEN => HAND OFF that landed URL to the next hop's lab (employer / ats / blocked class)
as determined by ITS OWN selectors — ZipRecruiter's job is done at hand-off.
Optionally attach CarriesZipRecruiterSponsorTag(landedUri) as provenance metadata.
IF redirect chain exceeds a bounded hop count (e.g. 5) without landing, or terminates in
a non-200 / block page:
FAIL CLOSED => Unknown(reason: "job-redirect did not resolve to a content page")
ELSE (neither control found — page structure unrecognized, e.g. blocked, logged-out-gated,
or vendor changed markup):
FAIL CLOSED => Unknown(reason: "no recognized apply control on ZipRecruiter page")
Unknown is always preferred over guessing a destination from match_token contents or from
page text — a wrong guess corrupts the dedupe key downstream; Unknown does not.
5. TEST ASSERTIONS (xunit, house style)¶
using WorkWingman.Core.Models;
using WorkWingman.ScraperLab.ZipRecruiter;
namespace WorkWingman.ScraperLab.ZipRecruiter.Tests;
/// <summary>
/// Covers the ZipRecruiter traversal philosophy: href-based hop detection beats text/class
/// based detection, Quick-Apply is a first-class terminal (not a failure), the job-redirect
/// match_token is opaque and never parsed, and ad-tech redirectors are never mistaken for the
/// employer landing page.
/// </summary>
public class ZipRecruiterTraversalSelectorTests
{
[Fact]
public void Onward_hop_chain_leads_with_href_predicate_not_visible_text()
{
// Unlike a naive "find the button labeled Apply" chain, the FIRST selector must key
// off the href attribute, because "Apply"-labeled controls also appear off-CTA
// (share widgets, marketing copy) and are not evidence of a hop.
Assert.Contains("href*='ziprecruiter.com/job-redirect'", ZipRecruiterSelectors.OnwardHopAnchor[0]);
// No selector in the onward-hop chain should key off a bare text match alone.
foreach (var sel in ZipRecruiterSelectors.OnwardHopAnchor)
Assert.DoesNotContain(":has-text", sel);
}
[Fact]
public void Quick_apply_button_is_a_first_class_terminal_not_a_fallback_failure()
{
// The Quick Apply selector must be checked at the same priority tier as the hop
// anchor, not treated as "what's left after the anchor search failed." Encode that
// as: resolving a Quick-Apply page must never produce an Unknown/failure result.
var resolution = ZipRecruiterTraversal.Resolve(hasJobRedirectAnchor: false, hasQuickApplyButton: true);
Assert.Equal(TraversalOutcome.Terminal, resolution.Outcome);
Assert.Null(resolution.AtsVendor);
Assert.NotEqual(TraversalOutcome.Unknown, resolution.Outcome);
}
[Fact]
public void Match_token_is_never_parsed_for_a_destination()
{
// The token is opaque; there is no decode/regex helper for it, by design. This test
// pins the ABSENCE of such a helper so nobody quietly adds one later.
var members = typeof(ZipRecruiterSelectors).GetMethods()
.Select(m => m.Name.ToLowerInvariant());
Assert.DoesNotContain(members, n => n.Contains("decodematchtoken") || n.Contains("parsematchtoken"));
}
[Theory]
[InlineData("https://jobs.spectrum.com/job/-/-/4673/98813983680?p_sid=OSBg87b&ss=paid&utm_source=ziprecruiter&utm_medium=sponsored-jobs", true)]
[InlineData("https://jobs.spectrum.com/job/-/-/4673/98813983680", false)]
public void Sponsor_tag_predicate_reads_landed_page_only(string landedUrl, bool expectSponsored)
{
var uri = new Uri(landedUrl);
Assert.Equal(expectSponsored, ZipRecruiterSelectors.CarriesZipRecruiterSponsorTag(uri));
}
// ---- NEGATIVE TESTS ----
[Fact]
public void Adtech_redirector_host_is_never_accepted_as_the_landed_answer()
{
// Measured: the job-redirect token's embedded next-hop is an ad-tech clickthrough
// (dsp.prng.co), not the employer. A chain that stops at the first non-ziprecruiter
// host would wrongly resolve HERE instead of continuing to jobs.spectrum.com.
var adTechUri = new Uri("https://dsp.prng.co/OSBg87b?clickid=abc123");
Assert.False(ZipRecruiterTraversal.IsAcceptableLandingHost(adTechUri),
"an ad-tech redirector must never be accepted as the terminal landing host");
}
[Fact]
public void Spectrum_landing_page_is_not_misidentified_as_ziprecruiter_or_the_redirector()
{
// Guards against a lazy "still contains ziprecruiter in the URL somewhere" check
// matching the sponsor query params on the landed employer page.
var landed = new Uri("https://jobs.spectrum.com/job/-/-/4673/98813983680?utm_source=ziprecruiter&ss=paid");
Assert.False(ZipRecruiterSelectors.IsZipRecruiterHost(landed));
Assert.True(ZipRecruiterTraversal.IsAcceptableLandingHost(landed));
}
[Fact]
public void Bare_apply_text_without_job_redirect_href_does_not_trigger_a_hop()
{
// Negative case for rule 1: an "Apply" or "Apply now" label elsewhere on the page
// (e.g. marketing copy) with no /job-redirect href must not be treated as the hop
// control.
var found = ZipRecruiterTraversal.TryFindHopAnchor(
candidateHrefs: ["https://www.facebook.com/sharer/sharer.php?u=https://ziprecruiter.com/..."],
candidateLabels: ["Apply now and change your career"]);
Assert.False(found);
}
[Fact]
public void Redirect_chain_that_never_leaves_ziprecruiter_or_adtech_hosts_fails_closed()
{
var hops = new[]
{
new Uri("https://www.ziprecruiter.com/job-redirect?match_token=abc"),
new Uri("https://dsp.prng.co/OSBg87b?clickid=abc"),
new Uri("https://dsp.prng.co/OSBg87b?clickid=abc&r=2"), // stuck looping in ad-tech
};
var resolution = ZipRecruiterTraversal.ResolveRedirectChain(hops, maxHops: 5);
Assert.Equal(TraversalOutcome.Unknown, resolution.Outcome);
}
}
ZipRecruiterTraversal / TraversalOutcome above are the (not-yet-written) production types
this lab specifies the contract for — same relationship the iCIMS lab has to
IcimsAutomationEngine: the lab pins the philosophy in tests before/alongside the
implementation.
6. POSTING ID EXTRACTION¶
UNKNOWN — do not build a dedupe key on any ZipRecruiter-side identifier yet.
What was OBSERVED:
- A lk query param on the SPA state URL (e.g. ?lk=3rFXgZ9Kb85_eyF7Jn8Z1w, ~22-char
base64url-shaped opaque token) that also appears embedded inside the match_token blob for
the same listing. This is the closest thing to a "ZipRecruiter listing key" observed.
- The true internal job id (if one exists) is carried inside a binary Connect/protobuf RPC
response (GetJobDetails) that was not decoded in this investigation — no plaintext numeric
id was confirmed.
- No canonical /c/{Company}/Job/{Title}?jid=...-style permalink was reached from the
search-results SPA in this session; that historical ZipRecruiter URL shape was NOT observed
live today (only found referenced in secondhand documentation search results, not fetched).
Why this is marked UNKNOWN rather than adopted:
- The lk token's stability across sessions/searches/time was not tested — it may be a
session/search-scoped token rather than a permanent posting identifier. Adopting it as a
dedupe key on a single observation risks silently merging or splitting postings incorrectly,
which the brief calls out as worse than having no key.
- Per the termination predicate above, ZipRecruiter's hop is not where the dedupe key should
come from anyway in the common case — the posting id belongs to the ATS at the end of the
chain (e.g. iCIMS posting 77665, extractable and stable per the iCIMS lab). ZipRecruiter
should be treated as a pass-through hop for identity purposes whenever a downstream ATS hop
exists.
- The one case where ZipRecruiter identity would matter for dedupe — Quick-Apply terminal
listings with no downstream ATS — currently has no confirmed stable id at all. Recommend:
do not dedupe Quick-Apply listings on a ZipRecruiter id until the GetJobDetails payload is
decoded and a field is confirmed stable across repeat fetches of the same listing.
OBSERVED vs INFERRED — quick index¶
OBSERVED (live, today, public pages, no login):
- ziprecruiter.com/jobs-search is a client-rendered SPA; job cards are <button>s, not
navigable anchors; address bar does not change per-job.
- Non-Quick-Apply listings render <a href="https://www.ziprecruiter.com/job-redirect?match_token=...">Apply</a>.
- Quick-Apply listings render <button type="button">Quick Apply</button> with no href.
- Following the job-redirect anchor for a SPECTRUM/ZipRecruiter listing landed on
https://jobs.spectrum.com/job/-/-/4673/98813983680?p_sid=OSBg87b&p_uid=...&ss=paid&utm_campaign=direct-sales&utm_content=Residential-Connectivity-Sales&utm_medium=sponsored-jobs&utm_source=ziprecruiter&utm_term=RD —
matching the brief's measured chain and sponsor-tag claim exactly.
- The match_token value, when base64-decoded as a raw string, visibly contains an embedded
URL fragment pointing at dsp.prng.co/OSBg87b?clickid=[click_id] — an ad-tech redirector —
and the campaign segment OSBg87b reappears as p_sid=OSBg87b on the landed employer page.
- Both the Apply anchor and Quick Apply button carry only Tailwind-style utility classes, no
id or data-testid attributes.
- A brief Cloudflare "Just a moment..." interstitial was hit once on first navigation to
ziprecruiter.com in this session and cleared on its own within a few seconds in the
ordinary (non-stealth) browser context — consistent with the brief's framing that a real
headful browser, not a fresh/bare fetch, is the intended posture for this class of site.
INFERRED (reasoned, not independently confirmed today):
- dsp.prng.co is treated as a generic "ad-tech redirector" pattern; its owning company was
not independently identified/verified.
- The lk opaque token's cross-session stability is inferred to be uncertain (session/search
scoped), not confirmed either way.
- The historical ziprecruiter.com/c/{Company}/Job/{Title}?jid=... permalink shape is
secondhand (search-engine result descriptions only); not fetched or confirmed live today.
- The redirect chain length/hop count through dsp.prng.co before reaching the employer
domain was not fully instrumented (network log only showed the final landed request in this
tool's capture); "at least one intermediate hop" is inferred from the embedded URL inside
the token plus the campaign-id round-trip, not from a captured 3xx trace.