Skip to content

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" whose href is ziprecruiter.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 signed aztt JWT: 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: no href, 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

  1. Never regex or base64-decode match_token to "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.
  2. 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 reaching jobs.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 neither ziprecruiter.com nor a known ad-redirector pattern, and the response is a real 200 content page.
  3. 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.
  4. 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 no id or data-testid. These are cosmetic, framework-generated, and unstable across releases — never lead a selector chain with a class-name match on this vendor.
  5. Never assume the search-results list page exposes a per-job canonical URL. OBSERVED: ziprecruiter.com/jobs-search is a client-rendered SPA; clicking a job card opens an in-page panel via a button (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.
  6. Never treat /job-redirect itself, or any URL still on ziprecruiter.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.