liveness-api.mjs
Rama main · réplica de lectura del repositorio original.
// @ts-check
/**
* liveness-api.mjs — zero-token liveness check for ATS-hosted job postings.
*
* Many postings live on ATS platforms (Greenhouse, Lever, Ashby, Workday, ...) that
* expose a public JSON endpoint. We can confirm whether a posting is still live by
* hitting that endpoint directly — no browser, no LLM tokens — and only fall back to
* the Playwright check (liveness-browser.mjs) for non-ATS pages or when the API is
* inconclusive. This is the cheap first rung of the liveness ladder.
*
* CONSERVATIVE BY DESIGN: a false "expired" is worse than the status quo (the user
* misses a real job). So we return `expired` only on a definitive signal — a
* 404/410, or a closed marker the provider's `interpret` step recognises — and for
* anything ambiguous (unknown ATS, redirect, 429/5xx, network/timeout) we return
* `null` (→ caller falls back to Playwright).
*
* Three endpoint shapes:
* - Per-job (Greenhouse, Lever, Workday): the URL maps to a single-job endpoint,
* so a 200 is itself proof the posting is live. Workday also reads its 403
* body, whose error code tells a withdrawn posting apart from a blocked
* request.
* - Org-level (Ashby): the URL maps to the org's whole job board. A 200 only
* proves the board exists, so the provider's `interpret` step parses the board
* and confirms THIS posting is still listed before returning active/expired.
* (Ashby pages are JS-rendered, so the browser/static rung sees only nav/footer
* and false-reports live postings as expired — this API rung is authoritative.)
* - Per-job HTML (LinkedIn): the guest endpoint returns the rendered posting as
* HTML and answers 200 for closed postings too, so `interpret` reads two
* independent signals out of the body and only concludes when they agree.
* Zoho Recruit and UltiPro are the same shape: their posting pages embed
* the record and answer 200 for a removed posting too, so `interpret` reads
* the body.
*
* SSRF-safe by construction: the request URL is built from a FIXED, hard-coded API
* host plus path segments extracted from the posting URL with a strict charset
* (no slashes / traversal), and server-side redirects are refused.
*/
import { DEFAULT_USER_AGENT } from './user-agent.mjs';
import { parseWwrFeed } from './providers/weworkremotely.mjs';
import { hireHiveTenantOf } from './providers/hirehive.mjs';
import { ZOHO_HOST_RE, isZohoRecordClosed, parseZohoDetailPage } from './providers/zoho-recruit.mjs';
import { HOST_RE as ULTIPRO_HOST_RE, extractCandidateOpportunityDetail } from './providers/ultipro.mjs';
import { VEJ_KEY_MONITOR } from './providers/vdab.mjs';
import { PATH_BREAKING_CHAR, PATH_BREAKING_CHAR_ENCODED_ONCE } from './providers/arbeitsagentur.mjs';
import { resolveHibobTenant } from './providers/hibob.mjs';
import { atsVendorOf } from './ats-vendor.mjs';
import { linkedInJobId, linkedInGuestUrl } from './lib/linkedin-url.mjs';
const TIMEOUT_MS = 8_000;
// Arbeitsagentur: the hexadecimal digits of an encoded character can be in either case.
const PATH_BREAKING_CHAR_ENCODED_ONCE_RE = new RegExp(PATH_BREAKING_CHAR_ENCODED_ONCE, 'gi');
// Strict path-segment charset. Anything with a slash, dot-dot, or other char is
// rejected before it can reach the fixed-host API URL template. The one escape
// allowed is %20, because some Ashby org slugs contain a space (#5018). A space
// cannot end a segment or form a traversal; %2F and every other escape still fail.
const SAFE_SEGMENT = /^(?:[A-Za-z0-9._-]|%20)+$/;
// Most providers extract single path segments (SAFE_SEGMENT covers those directly).
// Workday's job path is genuinely multi-segment (a location slug + a title slug,
// e.g. "Toronto-ON-CAN/Agentic-AI-Engineer_R260010125"), so a `parts` value may
// itself contain slashes. This still validates every individual segment against
// the same strict charset (and rejects ".." in any of them) — it only relaxes
// "no slash at all" to "no *unsafe* content between slashes", so the traversal/
// injection guarantee is unchanged.
function isSafeValue(v) {
if (typeof v !== 'string' || v.length === 0) return false;
// SAFE_SEGMENT's charset includes "." (some real segments use dots), so ".."
// alone passes that regex — same as the single-segment guard in
// resolveAtsApi below, the explicit `!includes('..')` check per segment is
// load-bearing, not redundant with the regex test.
return v.split('/').every((seg) => seg.length > 0 && SAFE_SEGMENT.test(seg) && !seg.includes('..'));
}
// Each ATS: detect its posting URL, then map to a public JSON API URL.
// `match` returns the extracted path params (or null); `api` builds the FIXED-host URL.
// Optional per-provider fields:
// `timeoutMs` — override the default fetch timeout (slow/rate-limited APIs).
// `throttleMs` — minimum interval between our requests to this provider.
// `accept` — override the Accept header (providers that answer in HTML).
// `headers` — extra request headers, or a function of the extracted
// params that returns them (see the `hibob` entry).
// `method` — HTTP method of the API request; defaults to GET.
// `body` — builds the request body from the extracted params, for an
// endpoint that only answers a POST (see the `meituan` entry).
// `interpret` — read the response body to decide liveness (org-level APIs
// where a 200 alone doesn't prove THIS posting is live, and
// per-job APIs that answer 200 for a closed posting). The third
// argument is the request's abort signal, for a provider that
// needs a second request to decide (see the `hibob` entry).
// `interpretStatuses` — the statuses whose body `interpret` reads; defaults
// to [200]. A provider that answers a closed posting with an
// error status carrying a recognisable body lists that status
// here instead (see the `workday` entry); a 200 it leaves out
// takes the default per-job reading, proof the posting is live.
// A 404 or 410 listed here is read the same way, in place of
// the 404 rule below (see the `nofluffjobs` entry).
// `api404Authoritative` — defaults to true (a 404/410 means gone). Set to
// false when the provider's public API can 404 a posting that
// is still genuinely live elsewhere (see the `lever` entry).
const ATS_PROVIDERS = [
{
id: 'greenhouse-embedded',
// Company careers pages can expose only gh_jid, with no Greenhouse board.
// The embed redirect supplies the board; a second per-job API request is
// still required because even closed jobs can have an embed redirect.
// Scanners often record these pages as plain http (http://block.xyz/careers/
// jobs/N?gh_jid=N); only the gh_jid matters and both API hosts are fixed, so
// the page's scheme is accepted. Without this the posting fell to the browser,
// which timed out or saw the id dropped by a redirect and answered uncertain.
acceptsHttp: true,
match(u) {
if (/(^|\.)greenhouse\.io$/.test(u.hostname)) return null;
const id = u.searchParams.get('gh_jid');
return id && /^\d+$/.test(id) ? { id } : null;
},
api: ({ id }) => `https://boards.greenhouse.io/embed/job_app?token=${id}`,
async followEmbed(res, { id }) {
if (res.status !== 301 && res.status !== 302) return null;
let target;
try { target = new URL(res.headers.get('location'), 'https://boards.greenhouse.io'); }
catch { return null; }
if (target.protocol !== 'https:' || !/(^|\.)greenhouse\.io$/.test(target.hostname)) return null;
const board = target.searchParams.get('for');
// The board is one URL path segment. isSafeValue also accepts Workday's
// multi-segment paths, so reject a decoded slash here explicitly.
if (!isSafeValue(board) || board.includes('/') || target.searchParams.get('token') !== id) return null;
return `https://boards-api.greenhouse.io/v1/boards/${board}/jobs/${id}`;
},
},
{
id: 'greenhouse',
// boards.greenhouse.io/{board}/jobs/{id} · job-boards[.eu].greenhouse.io/{board}/jobs/{id}
match(u) {
if (atsVendorOf(u.href) !== 'greenhouse') return null;
const m = u.pathname.match(/^\/([^/]+)\/jobs\/(\d+)\/?$/);
return m ? { board: m[1], id: m[2] } : null;
},
api: ({ board, id }) => `https://boards-api.greenhouse.io/v1/boards/${board}/jobs/${id}`,
},
{
id: 'lever',
// jobs.(eu.)?lever.co/{slug}/{id}
match(u) {
if (atsVendorOf(u.href) !== 'lever') return null;
const host = u.hostname.match(/^jobs\.((?:eu\.)?lever\.co)$/);
if (!host) return null;
const m = u.pathname.match(/^\/([^/]+)\/([^/?#]+)\/?$/);
return m ? { apiHost: `api.${host[1]}`, slug: m[1], id: m[2] } : null;
},
api: ({ apiHost, slug, id }) => `https://${apiHost}/v0/postings/${slug}/${id}`,
// Lever's Confidential/Internal Postings feature explicitly excludes some
// live postings from the public v0/postings API while the direct
// jobs.lever.co page keeps serving them normally. Real-world repro
// (2026-08-09): api.lever.co 404s two postings whose jobs.lever.co pages
// return 200 with the real job title and a working Apply control. A 404
// here is NOT proof of removal — fall through to Playwright instead.
api404Authoritative: false,
},
{
id: 'workable',
// apply.workable.com/{account}/j/{shortcode}
match(u) {
if (u.hostname !== 'apply.workable.com') return null;
const m = u.pathname.match(/^\/([^/]+)\/j\/([A-Za-z0-9_-]+)\/?$/);
return m ? { account: m[1], shortcode: m[2] } : null;
},
api: ({ account, shortcode }) => `https://apply.workable.com/api/v2/accounts/${account}/jobs/${shortcode}`,
// Workable's posting pages render client-side, so the browser rung reads a
// not-yet-painted live posting as nav/footer only (insufficient_content),
// which used to read as expired, the costly direction: scan --verify wrote
// that to scan-history as skipped. The v2 JSON endpoint is per-job and needs no
// browser. Measured live (#5000): 200 with state "published" is the live
// shape and 404 ("Job not found") is the removed shape, so a 404 is
// authoritative here and any other readable state stays uncertain.
async interpret(res) {
let posting;
try { posting = await res.json(); } catch { return null; }
return classifyWorkablePosting(posting);
},
},
{
id: 'workable-short',
// apply.workable.com/j/{shortcode} is the share link, and it carries no
// account. Following its own redirect yields /{account}/j/{shortcode},
// which names the account the v2 endpoint needs (#5000).
match(u) {
if (u.hostname !== 'apply.workable.com') return null;
const m = u.pathname.match(/^\/j\/([A-Za-z0-9_-]+)\/?$/);
return m ? { shortcode: m[1] } : null;
},
api: ({ shortcode }) => `https://apply.workable.com/j/${shortcode}`,
async followEmbed(res, { shortcode }) {
if (res.status !== 301 && res.status !== 302) return null;
let target;
try { target = new URL(res.headers.get('location'), 'https://apply.workable.com'); }
catch { return null; }
if (target.protocol !== 'https:' || target.hostname !== 'apply.workable.com') return null;
const m = target.pathname.match(/^\/([^/]+)\/j\/([A-Za-z0-9_-]+)\/?$/);
if (!m || m[2] !== shortcode || !isSafeValue(m[1])) return null;
return `https://apply.workable.com/api/v2/accounts/${m[1]}/jobs/${m[2]}`;
},
async interpret(res) {
let posting;
try { posting = await res.json(); } catch { return null; }
return classifyWorkablePosting(posting);
},
},
{
id: 'ashby',
// jobs.ashbyhq.com/{org}/{jobId}[/application]. Ashby's public posting API is
// ORG-level (the whole job board), not per-job — so `api` maps to the board and
// `interpret` confirms this {jobId} is still listed. Only {org} reaches the
// fixed-host URL; {jobId} is used solely to filter the parsed board (SAFE_SEGMENT
// still validates both).
match(u) {
if (atsVendorOf(u.href) !== 'ashby' || u.hostname !== 'jobs.ashbyhq.com') return null;
const m = u.pathname.match(/^\/([^/]+)\/([^/]+)(?:\/application)?\/?$/);
return m ? { org: m[1], jobId: m[2] } : null;
},
api: ({ org }) => `https://api.ashbyhq.com/posting-api/job-board/${org}`,
// Ashby's posting-api has a server-side latency floor and rate-limits repeated
// unauthenticated hits (see providers/ashby.mjs). Give it more room than the ATS
// default so a slow-but-live board doesn't time out into a Playwright fallback.
timeoutMs: 20_000,
// The API is board-level, so a 404 means the board API is off for this org
// (embed-only boards such as Whatnot, providers/ashby.mjs `ashby.embed`) or
// the slug is wrong. Neither says THIS posting is gone: removal shows as a
// 200 board that no longer lists the id (`ashby_api_unlisted`).
api404Authoritative: false,
// One board answers for every posting under the org, so a caller checking
// several postings can pass checkLivenessViaApi a `boardCache` and fetch it
// once (check-liveness.mjs does, per run).
interpretBoard: (json, { jobId }) => classifyAshbyBoard(json, jobId),
async interpret(res, { jobId }) {
let json;
try {
json = await res.json();
} catch {
return null; // unparseable body → inconclusive, let the browser decide
}
return classifyAshbyBoard(json, jobId);
},
},
{
id: 'workday',
// {tenant}.{shard}.myworkdayjobs.com[/{xx-XX}]/{site}/job/{jobPath...}
// Mirrors the tenant/shard/site detection in providers/workday.mjs, but for a
// single posting rather than the board-wide CXS search endpoint. Workday's
// per-job CXS endpoint (`/wday/cxs/{tenant}/{site}/job/{jobPath}`) is a
// genuinely PER-JOB API like Greenhouse/Lever: a live posting returns 200, a
// missing one returns 404, and a withdrawn posting returns a structured 403,
// whose body classifyWorkday403 reads.
//
// jobPath is intentionally multi-segment (Workday encodes a location slug and
// a title slug as separate path parts, e.g.
// "Toronto-ON-CAN/Agentic-AI-Engineer_R260010125") — isSafeValue (not the
// single-segment SAFE_SEGMENT check other providers use directly) validates
// it component-by-component.
match(u) {
if (atsVendorOf(u.href) !== 'workday') return null;
const m = `${u.hostname}${u.pathname}`.match(
/^([\w-]+)\.(wd[\w-]*)\.myworkdayjobs\.com\/(?:[a-z]{2}-[A-Z]{2}\/)?([^/?#]+)\/job\/(.+?)\/?$/
);
if (!m) return null;
const [, tenant, shard, site, jobPath] = m;
return { tenant, shard, site, jobPath };
},
api: ({ tenant, shard, site, jobPath }) =>
`https://${tenant}.${shard}.myworkdayjobs.com/wday/cxs/${tenant}/${site}/job/${jobPath}`,
interpretStatuses: [403],
async interpret(res) {
let json;
try {
json = await res.json();
} catch {
return null; // unparseable body (e.g. a WAF HTML interstitial) → inconclusive
}
return classifyWorkday403(json);
},
},
{
id: 'smartrecruiters',
match(u) {
if (u.hostname !== 'jobs.smartrecruiters.com') return null;
const m = u.pathname.match(/^\/([^/]+)\/([A-Za-z0-9]+)(?:-[^/]*)?\/?$/);
return m ? { company: m[1], id: m[2] } : null;
},
api: ({ company, id }) => `https://api.smartrecruiters.com/v1/companies/${company}/postings/${id}`,
api404Authoritative: false, // the API also uses 400 for unknown IDs
async interpret(res, { id }) {
let posting;
try { posting = await res.json(); } catch { return null; }
if (String(posting?.id) !== id || typeof posting.active !== 'boolean') return null;
return posting.active
? { result: 'active', code: 'smartrecruiters_api_active', reason: 'SmartRecruiters marks the posting active' }
: { result: 'expired', code: 'smartrecruiters_api_inactive', reason: 'SmartRecruiters marks the posting inactive' };
},
},
{
id: 'zoho-recruit',
// {tenant}.zohorecruit.<regional tld>/jobs/{portal}/{id}[/{slug}]. Zoho Recruit has no
// JSON endpoint; the posting page itself embeds the record (see
// providers/zoho-recruit.mjs), so `api` is that page and `interpret` reads
// it. Zoho answers 200 for a removed posting too, so liveness comes from
// the body, never from the status.
match(u) {
const host = u.hostname.match(ZOHO_HOST_RE);
if (!host) return null;
const m = u.pathname.match(/^\/jobs\/([^/]+)\/(\d+)(?:\/[^/]*)?\/?$/);
return m ? { tenant: host[1], host: u.hostname, portal: m[1], id: m[2] } : null;
},
api: ({ host, portal, id }) => `https://${host}/jobs/${portal}/${id}`,
accept: 'text/html',
async interpret(res, { id }) {
let html;
try { html = await res.text(); } catch { return null; }
return classifyZohoPostingPage(html, id);
},
},
{
id: 'hirehive',
// <tenant>.hirehive.com/<title-slug>-<shortid> reads the tenant's per-posting
// v2 API (`job_<shortid>`); /job/<number>[/<title-slug>] reads the v1 API by
// that number. Both answer 200 with the posting and 404 with a JSON
// `message`. The posting page says "This job is no longer available." for
// an unpublished posting, which is the API's "Job is not published"; the
// other messages ("Job not found", "Company not found", "Job does not match
// company") do not prove a posting closed, so they stay inconclusive.
match(u) {
const tenant = hireHiveTenantOf(u.hostname);
if (!tenant) return null;
const host = `${tenant}.hirehive.com`;
const byNumber = u.pathname.match(/^\/job\/(\d+)(?:\/[^/]*)?\/?$/);
if (byNumber) return { host, version: 'v1', id: byNumber[1] };
const bySlug = u.pathname.match(/^\/[^/]*-([A-Za-z0-9]+)\/?$/);
return bySlug ? { host, version: 'v2', id: `job_${bySlug[1]}` } : null;
},
api: ({ host, version, id }) => `https://${host}/api/${version}/jobs/${id}`,
interpretStatuses: [200, 404],
api404Authoritative: false,
async interpret(res, { id }) {
let json;
try { json = await res.json(); } catch { return null; }
return classifyHireHivePosting(json, id, res.status);
},
},
{
id: 'ultipro',
// {host}/{tenant}/JobBoard/{boardId}/OpportunityDetail?opportunityId={id}, the
// URL providers/ultipro.mjs writes. Like Zoho Recruit, UKG has no per-posting
// JSON endpoint: the detail page embeds the record (see providers/ultipro.mjs)
// and answers 200 for a closed posting too, so `interpret` reads the body.
//
// The browser reads a closed posting here as uncertain: it either keeps its
// page without an Apply control or gets the board's near-empty page, and on
// 2026-10-28 all 57 closed postings read uncertain. Measured that day on 156
// postings across 24 tenants, against each board's own listing: 99 listed
// postings carried an open record, 14 unlisted ones a closed record and 42
// the empty page's notice; the last, an internal-only posting, stays with
// the browser.
match(u) {
if (!ULTIPRO_HOST_RE.test(u.hostname)) return null;
const m = u.pathname.match(/^\/([^/]+)\/JobBoard\/([^/]+)\/OpportunityDetail\/?$/);
const id = u.searchParams.get('opportunityId');
if (!m || !id || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) return null;
// UKG reads the id in any case and answers with the record's own lower-case id.
return { host: u.hostname, tenant: m[1], boardId: m[2], id: id.toLowerCase() };
},
api: ({ host, tenant, boardId, id }) => `https://${host}/${tenant}/JobBoard/${boardId}/OpportunityDetail?opportunityId=${id}`,
accept: 'text/html',
async interpret(res, { id }) {
let html;
try { html = await res.text(); } catch { return null; }
return classifyUltiproDetailPage(html, id);
},
},
{
id: 'hibob',
// {tenant}.careers.hibob.com/jobs/{uuid}[/apply], the URL providers/hibob.mjs
// writes. The posting page reads only this per-posting endpoint and, when
// it answers 404, shows "No job openings match this URL" in place of the
// posting, and a 200 is a live posting. A label with no careers site answers
// 404 with the same body on its board endpoint too, so a 404 closes the
// posting only when the tenant's board answers 200; otherwise it stays
// inconclusive. The `companyidentifier` header names the tenant; without it,
// or for a label that is not a tenant, the answer is 401, also inconclusive.
match(u) {
const tenant = resolveHibobTenant(u.href);
if (!tenant) return null;
const m = u.pathname.match(/^\/jobs\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/apply)?\/?$/i);
return m ? { tenant, jobId: m[1] } : null;
},
api: ({ tenant, jobId }) => `https://${tenant}.careers.hibob.com/api/job-ad/${jobId}/application-form`,
headers: ({ tenant }) => ({ companyidentifier: tenant }),
interpretStatuses: [404],
api404Authoritative: false,
async interpret(res, { tenant }, signal) {
await res.body?.cancel().catch(() => {});
const board = await fetch(`https://${tenant}.careers.hibob.com/api/job-ad`, {
headers: { 'user-agent': DEFAULT_USER_AGENT, accept: 'application/json', companyidentifier: tenant },
redirect: 'error',
signal,
});
await board.body?.cancel().catch(() => {});
return board.status === 200
? { result: 'expired', code: 'hibob_api_gone', reason: 'HiBob answers 404 for the posting while the tenant board answers 200' }
: null;
},
},
{
id: 'arbeitsagentur',
match(u) {
if (u.hostname !== 'www.arbeitsagentur.de') return null;
const m = u.pathname.match(/^\/jobsuche\/jobdetail\/([^/]+)\/?$/);
if (!m) return null;
// The path holds the reference number encoded once, except a slash, which
// is encoded once or twice (%2F or %252F). Decoding once keeps a literal
// percent sign, and leaves only a twice-encoded slash still encoded.
// The API takes the base64url of the real number. Only that form is passed
// on: the real number can hold characters the safe-value check refuses.
try {
const refnr = decodeURIComponent(m[1]).replace(PATH_BREAKING_CHAR_ENCODED_ONCE_RE, PATH_BREAKING_CHAR);
return { refnrBase64: Buffer.from(refnr).toString('base64url') };
} catch {
return null;
}
},
api: ({ refnrBase64 }) => `https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v4/jobdetails/${refnrBase64}`,
headers: { 'X-API-Key': 'jobboerse-jobsuche' },
api404Authoritative: false, // no expired real-job control; absence stays unknown
async interpret(res, { refnrBase64 }) {
let posting;
try { posting = await res.json(); } catch { return null; }
return posting?.referenznummer === Buffer.from(refnrBase64, 'base64url').toString() && posting?.stellenangebotsTitel
? { result: 'active', code: 'arbeitsagentur_api_active', reason: 'Arbeitsagentur returns the matching job detail' }
: null;
},
},
{
id: 'weworkremotely',
match(u) {
if (u.hostname !== 'weworkremotely.com' || !/^\/remote-jobs\/[^/]+\/?$/.test(u.pathname)) return null;
return { slug: u.pathname.split('/')[2] };
},
api: () => 'https://weworkremotely.com/remote-jobs.rss',
accept: 'application/rss+xml',
api404Authoritative: false,
async interpret(res, { slug }) {
let feed;
try { feed = await res.text(); } catch { return null; }
if (!/<rss\b/i.test(feed)) return null;
const listed = parseWwrFeed(feed).some((job) => new URL(job.url).pathname.replace(/\/$/, '') === `/remote-jobs/${slug}`);
return listed
? { result: 'active', code: 'weworkremotely_feed_listed', reason: 'Posting is listed in the current RSS feed' }
: null; // feed is bounded; absence cannot prove expiry
},
},
{
id: 'linkedin',
// linkedin.com/jobs/view/{id} · .../jobs/view/{title-slug}-{id} · any page that
// carries the posting in ?currentJobId= (search and collection views).
//
// LinkedIn had no rung here, so every LinkedIn URL fell through to Playwright —
// where /jobs/view/{id} redirects to a generic search page and no verdict can be
// trusted. The guest endpoint below returns the rendered posting as HTML with no
// auth and no browser, and it answers 200 for closed postings as well as live
// ones, so liveness has to come from the body (`interpret`), never from the
// status code alone.
match(u) {
const id = linkedInJobId(u);
return id ? { id } : null;
},
api: ({ id }) => linkedInGuestUrl(id),
// The endpoint is unauthenticated and rate-limited. Space our calls; the
// interval sits on the provider so it holds for every caller rather than only
// the loop in check-liveness.mjs.
throttleMs: 3_500,
// We parse HTML here, so ask for it. The endpoint also serves HTML under an
// application/json Accept, but a request that misdescribes what it wants is one
// content-negotiation change away from breaking silently.
accept: 'text/html',
async interpret(res) {
let html;
try {
html = await res.text();
} catch {
return null; // unreadable body → inconclusive, let the browser decide
}
return classifyLinkedInPosting(html);
},
},
{
id: 'nofluffjobs',
// nofluffjobs.com/pl/job/{slug}, the URL providers/nofluffjobs.mjs writes, and
// the same posting under /job/{slug} or another language prefix.
//
// The browser reads this board backwards (#4832). A live posting puts "Aplikuj"
// inside a <header> (the posting's own apply block), which the control walk
// skips as page chrome: 40 of 40 live postings read no_apply_control on
// 2026-10-18. A closed posting answers 200 and then moves to a search page
// (/pl/?criteria=...), where an "APLIKUJ ›" promo link reads as an Apply
// control: 42 of 59 closed postings read active. The board's public
// per-posting API needs no browser and says which it is.
match(u) {
if (u.hostname !== 'nofluffjobs.com' && u.hostname !== 'www.nofluffjobs.com') return null;
const m = u.pathname.match(/^\/(?:[a-z]{2}\/)?job\/([A-Za-z0-9-]+)\/?$/);
return m ? { slug: m[1] } : null;
},
api: ({ slug }) => `https://nofluffjobs.com/api/posting/${slug}`,
// Measured 2026-10-18: a live posting answers 200 with status "PUBLISHED", a
// closed one 200 with status "EXPIRED" (and expiredBeforeDays), and a slug the
// board does not have 404 {"status":"DOES_NOT_EXIST"}. The 404 body is read
// too, so only that answer closes a posting, which scan --verify records for
// good: a 404 saying anything else falls back to the browser.
interpretStatuses: [200, 404],
async interpret(res) {
let posting;
try { posting = await res.json(); } catch { return null; }
return classifyNoFluffJobsPosting(posting, res.status);
},
},
{
id: 'tencent',
// careers.tencent.com/jobdesc.html?postId={id}, the URL providers/tencent.mjs
// writes. It usually copies the API's own PostURL, which is plain http (1,920
// of 2,250 postings on 2026-10-18). The API host is fixed, so the page's
// scheme is accepted, as for greenhouse-embedded.
//
// The browser reads this board wrong both ways (#4832): a live posting's
// 申请岗位 is not an Apply label it knows (no_apply_control, dropped by
// scan --verify), and a closed posting moves to /404.html served with a 200
// (uncertain, kept by scan --verify). The per-posting endpoint the page
// itself calls tells the two apart.
acceptsHttp: true,
match(u) {
if (u.hostname !== 'careers.tencent.com' || u.pathname !== '/jobdesc.html') return null;
const id = u.searchParams.get('postId');
return id && /^\d+$/.test(id) ? { id } : null;
},
api: ({ id }) => `https://careers.tencent.com/tencentcareer/api/post/ByPostId?postId=${id}&language=zh-cn`,
// Measured 2026-10-18: a live posting answers 200 {"Code":200,"Data":{…}},
// a closed one HTTP 500 {"Code":500,"Data":"E1005"}.
interpretStatuses: [200, 500],
api404Authoritative: false, // a closed posting has its own answer
throttleMs: 1_000,
async interpret(res, { id }) {
let json;
try { json = await res.json(); } catch { return null; }
return classifyTencentPost(json, id);
},
},
{
id: 'meituan',
// zhaopin.meituan.com/web/position/detail?jobUnionId={id}, the URL
// providers/meituan.mjs writes. The browser misses a live posting's 立即申请
// (no_apply_control, dropped by scan --verify), and a closed posting's page
// keeps the same button (#4832), so no Apply label can tell the two apart.
// The getJobDetail endpoint the page calls can, and it only answers a POST
// (a GET gets status 0 "请求方式不正确!").
match(u) {
if (u.hostname !== 'zhaopin.meituan.com' || u.pathname !== '/web/position/detail') return null;
const id = u.searchParams.get('jobUnionId');
return id && /^\d+$/.test(id) ? { id } : null;
},
api: () => 'https://zhaopin.meituan.com/api/official/job/getJobDetail',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: ({ id }) => JSON.stringify({ jobUnionId: id, jobShareType: '1' }),
api404Authoritative: false, // a closed posting has its own answer, a 200
throttleMs: 1_000,
async interpret(res, { id }) {
let json;
try { json = await res.json(); } catch { return null; }
return classifyMeituanJob(json, id);
},
},
{
id: 'alibaba',
// talent.alibaba.com/off-campus/position-detail?positionId={id}, the URL
// providers/alibaba.mjs writes. The site does not deep-link (#2055): that
// page lands on the home page for any posting, so the browser rung cannot
// tell a live posting from a closed one and only this rung can.
match(u) {
if (u.hostname !== 'talent.alibaba.com' || u.pathname !== '/off-campus/position-detail') return null;
const id = u.searchParams.get('positionId');
// The id goes out as a JSON number, as the site sends it; up to 15
// digits it converts exactly.
return id && /^\d{1,15}$/.test(id) ? { id } : null;
},
api: () => 'https://talent.alibaba.com/position/detail',
method: 'POST',
// The stateless double-submit check providers/alibaba.mjs passes with a
// fresh UUID per run: any matching cookie and header are accepted (this
// constant measured 2026-10-18), so it needs no pre-request. Without the
// pair the endpoint answers 403, and a GET 405.
headers: { 'content-type': 'application/json', cookie: 'XSRF-TOKEN=career-ops', 'x-xsrf-token': 'career-ops' },
body: ({ id }) => JSON.stringify({ id: Number(id), channel: 'group_official_site' }),
api404Authoritative: false, // a closed posting has its own answer, a 200
throttleMs: 1_000,
async interpret(res, { id }) {
let json;
try { json = await res.json(); } catch { return null; }
return classifyAlibabaPosition(json, id);
},
},
{
id: 'vdab',
// www.vdab.be/vindeenjob/vacatures/{id}, the URL providers/vdab.mjs writes;
// older links add a title slug after the id.
//
// A closed vacancy keeps its page and its "Solliciteer nu" button, and only
// a banner says it is closed, "Deze vacature is niet meer gepubliceerd"
// (#5244). The browser reads the page while it renders: on 2026-10-22 it
// read 13 of 87 loads of unpublished vacancies active (the button rendered
// before the banner) and 5 no_apply_control, as it did 2 of 40 loads of
// published ones. The detail endpoint the page itself calls has no render
// to wait for.
match(u) {
if (u.hostname !== 'www.vdab.be' && u.hostname !== 'vdab.be') return null;
const m = u.pathname.match(/^\/vindeenjob\/vacatures\/(\d+)(?:\/[^/]+)?\/?$/);
return m ? { id: m[1] } : null;
},
api: ({ id }) => `https://www.vdab.be/rest/vindeenjob/v4/vacatures/${id}?preview=false`,
// Without the key the endpoint answers 403, which falls back to the browser,
// so a rotated key costs this rung, not the check.
headers: { 'vej-key-monitor': VEJ_KEY_MONITOR },
// Measured 2026-10-22: a published vacancy answers 200 with gepubliceerd
// true, an unpublished one 200 with gepubliceerd false and a status, and an
// id VDAB never had 404 {"code":"NOT_FOUND"}. The 404 body is read too, so
// a 404 saying anything else falls back to the browser instead of closing
// the vacancy for good, and so does a 410, which VDAB never answered.
interpretStatuses: [200, 404],
api404Authoritative: false,
async interpret(res, { id }) {
let json;
try { json = await res.json(); } catch { return null; }
return classifyVdabVacancy(json, id, res.status);
},
},
];
// LinkedIn renders a closed posting with an explicit banner
// (`<figcaption class="closed-job__flavor--closed">No longer accepting
// applications</figcaption>`) and drops the apply control. A live posting shows one
// of two apply controls: the on-site button, or the off-site sign-in modal.
const LINKEDIN_CLOSED_MARKER = /No longer accepting applications/i;
const LINKEDIN_APPLY_CONTROL = /public_jobs_apply-link-onsite|job-details-topcard-apply-modal/;
/**
* Decide liveness for one LinkedIn posting from its guest-endpoint HTML.
* Pure + deterministic (no I/O), mirroring classifyLiveness in liveness-core.mjs.
*
* The two signals are read independently and must agree before this concludes
* anything. A body carrying the closed banner AND an apply control, or neither, is
* a page we do not recognise — a layout change, a partial render, an interstitial —
* and it returns `uncertain` rather than picking the likelier answer.
*
* The asymmetry is deliberate and is the whole reason for the two-signal rule, and
* it is this module's CONSERVATIVE BY DESIGN rule applied to a body read rather than
* a status code: a wrong `expired` costs the user a real job they never see again,
* while a wrong `uncertain` costs one re-check on the next sweep.
*
* @param {any} html - the guest endpoint's response body
* @returns {{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null}
* null = nothing to read (empty or non-string body) → caller falls back.
*/
export function classifyLinkedInPosting(html) {
if (typeof html !== 'string' || html.length === 0) return null;
const closed = LINKEDIN_CLOSED_MARKER.test(html);
const apply = LINKEDIN_APPLY_CONTROL.test(html);
if (closed && !apply) {
return {
result: 'expired',
code: 'linkedin_closed_marker',
reason: 'LinkedIn shows "No longer accepting applications" and no apply control',
};
}
if (apply && !closed) {
return {
result: 'active',
code: 'linkedin_apply_control',
reason: 'LinkedIn shows an apply control and no closure banner (live)',
};
}
return {
result: 'uncertain',
code: 'linkedin_signals_disagree',
reason: closed
? 'LinkedIn shows both a closure banner and an apply control — unrecognised page'
: 'LinkedIn shows neither a closure banner nor an apply control — unrecognised page',
};
}
// Reserved send times per provider, so back-to-back callers queue behind each
// other instead of all reading the same "last request" timestamp and firing
// together.
const nextRequestAt = new Map();
/**
* Wait until this provider's next request slot, then reserve the one after it.
*
* @param {string} providerId
* @param {number} [intervalMs] - minimum spacing; falsy means no throttling
* @returns {Promise<number>} milliseconds actually waited
*/
export async function throttleProviderRequest(providerId, intervalMs) {
if (!intervalMs) return 0;
const now = Date.now();
const earliest = Math.max(now, nextRequestAt.get(providerId) ?? 0);
nextRequestAt.set(providerId, earliest + intervalMs);
const wait = earliest - now;
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
return wait;
}
/**
* Decide liveness for one Ashby posting from its org's job-board API payload.
* Pure + deterministic (no I/O), mirroring classifyLiveness in liveness-core.mjs.
*
* The public board lists only currently-published postings, so a posting that is
* absent (or explicitly `isListed: false`) has been removed/unlisted → expired.
* A present, listed posting → active. An unexpected shape → null (inconclusive),
* so a future API change degrades to a Playwright fallback rather than a false
* "expired".
*
* @param {any} json - parsed job-board response, expected shape `{ jobs: [...] }`
* @param {string} jobId - the {jobId} from jobs.ashbyhq.com/{org}/{jobId}
* @returns {{ result: 'active' | 'expired', code: string, reason: string } | null}
*/
export function classifyAshbyBoard(json, jobId) {
if (!json || !Array.isArray(json.jobs)) return null; // unexpected shape → fall back
const target = String(jobId).toLowerCase();
const job = json.jobs.find((j) => typeof j?.id === 'string' && j.id.toLowerCase() === target);
if (job && job.isListed !== false) {
return { result: 'active', code: 'ashby_api_ok', reason: 'Ashby posting is listed on the board (live)' };
}
return { result: 'expired', code: 'ashby_api_unlisted', reason: 'Ashby posting not listed on the board — removed/unlisted' };
}
/**
* Decide liveness for one Workable posting from its v2 endpoint JSON.
* Pure + deterministic (no I/O), mirroring classifyLiveness in liveness-core.mjs.
*
* The live shape measured against a real posting is `state: "published"` (#5000);
* every other readable state stays `uncertain` rather than expired, because the
* endpoint is authoritative in the removed direction through its 404 alone, and a
* state this rung does not recognise is exactly the case the browser rung cannot
* improve on: Workable pages render client-side, so the fallback would call a live
* posting expired. An unreadable body returns null so a future API change degrades
* to the browser rung rather than to a verdict.
*
* @param {any} posting - the parsed v2 endpoint response
* @returns {{ result: 'active' | 'uncertain', code: string, reason: string } | null}
*/
export function classifyWorkablePosting(posting) {
if (!posting || typeof posting !== 'object') return null;
if (posting.state === 'published') {
return { result: 'active', code: 'workable_api_published', reason: 'Workable reports the posting published (live)' };
}
return {
result: 'uncertain',
code: 'workable_state_unrecognised',
reason: `Workable reports state ${String(posting.state)}; not a verdict this rung can make`,
};
}
/**
* Decide liveness for one No Fluff Jobs posting from its /api/posting/{slug} answer.
* Pure + deterministic (no I/O), mirroring classifyWorkablePosting.
*
* "PUBLISHED" is live and "EXPIRED" is the board's own word for closed; on a 404,
* only "DOES_NOT_EXIST" says the posting is gone. Any other status on a 200 is one
* this rung has not seen, so it stays uncertain rather than falling to the
* browser, which reads most closed postings on this board as active. A body with
* no status, and a 404 saying anything else, return null so a future API change
* degrades to the browser rung rather than to a verdict.
*
* @param {any} posting - the parsed per-posting API response
* @param {number} [httpStatus=200] - the response's HTTP status (200 or 404)
* @returns {{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null}
*/
export function classifyNoFluffJobsPosting(posting, httpStatus = 200) {
if (!posting || typeof posting !== 'object' || typeof posting.status !== 'string' || !posting.status) return null;
if (httpStatus === 404) {
return posting.status === 'DOES_NOT_EXIST'
? { result: 'expired', code: 'nofluffjobs_api_gone', reason: 'No Fluff Jobs has no posting under this slug (404 DOES_NOT_EXIST)' }
: null;
}
if (posting.status === 'PUBLISHED') {
return { result: 'active', code: 'nofluffjobs_api_published', reason: 'No Fluff Jobs reports the posting published (live)' };
}
if (posting.status === 'EXPIRED') {
const days = Number.isFinite(posting.expiredBeforeDays) ? ` ${posting.expiredBeforeDays} day(s) ago` : '';
return { result: 'expired', code: 'nofluffjobs_api_expired', reason: `No Fluff Jobs reports the posting expired${days}` };
}
return {
result: 'uncertain',
code: 'nofluffjobs_status_unrecognised',
reason: `No Fluff Jobs reports status ${String(posting.status)}; not a verdict this rung can make`,
};
}
// True when an id read from a board's answer is THIS posting's id: the same
// string, or a safe integer that prints as it. An array, an object, or a number
// too large to be exact (a 19-digit Tencent id parsed as a number) never matches.
function isSameId(value, id) {
if (typeof value === 'string') return value === id;
return Number.isSafeInteger(value) && String(value) === id;
}
/**
* Decide liveness for one Tencent posting from its ByPostId answer (HTTP 200 or
* 500). Pure + deterministic (no I/O), mirroring classifyNoFluffJobsPosting.
*
* `Code 200` carrying THIS PostId is live. `Code 500` with `Data: "E1005"` is
* the board's answer for a posting it no longer has; an id that never existed
* gets it too, and neither belongs in the pipeline. Any other readable answer,
* a Code 200 for another PostId included, stays uncertain rather than falling
* to the browser, which misreads this board both ways (#4832). An unreadable
* body returns null so the browser rung decides.
*
* @param {any} json - parsed ByPostId body
* @param {string} postId - the postId query parameter of the posting URL
* @returns {{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null}
*/
export function classifyTencentPost(json, postId) {
if (!json || typeof json !== 'object') return null;
if (json.Code === 200 && isSameId(json.Data?.PostId, postId)) {
return { result: 'active', code: 'tencent_api_active', reason: 'Tencent returns the posting (live)' };
}
if (json.Code === 500 && json.Data === 'E1005') {
return { result: 'expired', code: 'tencent_api_e1005', reason: 'Tencent answers E1005: posting closed or removed' };
}
// Data is a string on every non-200 answer seen; name anything else by its type.
const data = typeof json.Data === 'string' ? json.Data.slice(0, 80) : json.Data === null ? 'null' : typeof json.Data;
return {
result: 'uncertain',
code: 'tencent_answer_unrecognised',
reason: json.Code === 200
? 'Tencent answers Code 200 without this PostId; not a verdict this rung can make'
: `Tencent answers Code ${String(json.Code)}, Data ${data}; not a verdict this rung can make`,
};
}
/**
* Decide liveness for one Meituan posting from its getJobDetail answer.
* Pure + deterministic (no I/O), mirroring classifyTencentPost.
*
* A closed posting keeps `status: 1` and its whole record; only `jobStatus`
* changes, from "000" (live) to "001" (closed). Any other readable answer
* (another jobStatus, a record for another id, or `status: 0` "职位已下线或不存在!",
* seen only for ids that never existed and for a wrong request method) stays
* uncertain rather than falling to the browser, which cannot tell live from
* closed on this board (#4832). An unreadable body returns null so the browser
* rung decides.
*
* @param {any} json - parsed getJobDetail body
* @param {string} jobUnionId - the jobUnionId query parameter of the posting URL
* @returns {{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null}
*/
export function classifyMeituanJob(json, jobUnionId) {
if (!json || typeof json !== 'object') return null;
if (json.status !== 1 || !isSameId(json.data?.jobUnionId, jobUnionId)) {
return {
result: 'uncertain',
code: 'meituan_answer_unrecognised',
reason: json.status === 1
? 'Meituan answers status 1 without this jobUnionId; not a verdict this rung can make'
: `Meituan answers status ${String(json.status)}; not a verdict this rung can make`,
};
}
if (json.data.jobStatus === '000') {
return { result: 'active', code: 'meituan_api_open', reason: 'Meituan reports jobStatus "000" (live)' };
}
if (json.data.jobStatus === '001') {
return { result: 'expired', code: 'meituan_api_closed', reason: 'Meituan reports jobStatus "001" (closed)' };
}
return {
result: 'uncertain',
code: 'meituan_answer_unrecognised',
reason: `Meituan reports jobStatus ${String(json.data.jobStatus)}; not a verdict this rung can make`,
};
}
/**
* Decide liveness for one Alibaba posting from its position/detail answer.
* Pure + deterministic (no I/O), mirroring classifyTencentPost.
*
* Every measured answer was a 200 with `success: true`. A `content` carrying
* THIS id is live, and `content: null` is the answer for a posting the board
* no longer has (or never had). Any other readable answer, a failed call
* (`success` not true, the in-band error providers/alibaba.mjs also checks) or
* a record for another id, stays uncertain rather than falling to the browser,
* which cannot tell live from closed on this board (#2055). An unreadable body
* returns null so the browser rung decides.
*
* @param {any} json - parsed position/detail body
* @param {string} id - the positionId query parameter of the posting URL
* @returns {{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null}
*/
export function classifyAlibabaPosition(json, id) {
if (!json || typeof json !== 'object') return null;
if (json.success === true && json.content === null) {
return { result: 'expired', code: 'alibaba_api_no_content', reason: 'Alibaba returns no position for the id: closed or removed' };
}
if (json.success === true && isSameId(json.content?.id, id)) {
return { result: 'active', code: 'alibaba_api_active', reason: 'Alibaba returns the position (live)' };
}
return {
result: 'uncertain',
code: 'alibaba_answer_unrecognised',
reason: json.success === true
? 'Alibaba answers success true without this position id; not a verdict this rung can make'
: `Alibaba answers success ${String(json.success)}, errorCode ${String(json.errorCode)}; not a verdict this rung can make`,
};
}
/**
* Decide liveness for one VDAB vacancy from its detail answer (HTTP 200 or 404).
* Pure + deterministic (no I/O), mirroring classifyNoFluffJobsPosting.
*
* `gepubliceerd` is what the page's closed banner reports ("Deze vacature is
* niet meer gepubliceerd"): true is live. An unpublished vacancy's `status`
* says why. PASSIEF is closed. HANGEND (pending) also covers vacancies VDAB has
* not published yet, so it stays uncertain: an expired verdict is one
* scan --verify records for good. An unpublished status this rung has not seen
* stays uncertain too. On a 404, only NOT_FOUND says the id is gone. A record
* for another vacancy, or one without the flag, returns null so the browser
* rung decides.
*
* @param {any} json - the parsed detail response
* @param {string} id - the vacancy id from the posting URL
* @param {number} [httpStatus=200] - the response's HTTP status (200 or 404)
* @returns {{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null}
*/
export function classifyVdabVacancy(json, id, httpStatus = 200) {
if (!json || typeof json !== 'object') return null;
if (httpStatus === 404) {
return json.code === 'NOT_FOUND'
? { result: 'expired', code: 'vdab_api_gone', reason: 'VDAB has no vacancy under this id (404 NOT_FOUND)' }
: null;
}
if (!isSameId(json.referentie?.id, id) || typeof json.gepubliceerd !== 'boolean') return null;
if (json.gepubliceerd) {
return { result: 'active', code: 'vdab_api_published', reason: 'VDAB reports the vacancy published (live)' };
}
if (json.status === 'PASSIEF') {
return { result: 'expired', code: 'vdab_api_closed', reason: 'VDAB reports the vacancy no longer published (PASSIEF)' };
}
if (json.status === 'HANGEND') {
return { result: 'uncertain', code: 'vdab_api_pending', reason: 'VDAB reports the vacancy pending (HANGEND): not published, and not closed' };
}
return {
result: 'uncertain',
code: 'vdab_status_unrecognised',
reason: `VDAB reports the vacancy unpublished with status ${String(json.status)}; not a verdict this rung can make`,
};
}
/**
* Classify one HireHive per-posting API answer (see the `hirehive` entry).
* A 200 counts only when the body is the posting the URL names.
* @param {any} json
* @param {string} id - `job_<shortid>` (v2) or the posting number (v1)
* @param {number} httpStatus
*/
export function classifyHireHivePosting(json, id, httpStatus = 200) {
if (httpStatus === 404) {
return json?.message === 'Job is not published'
? { result: 'expired', code: 'hirehive_unpublished', reason: 'HireHive API: "Job is not published" (the posting page says the job is no longer available)' }
: null;
}
if (httpStatus !== 200 || !json || String(json.id) !== String(id) || typeof json.title !== 'string' || !json.title.trim()) return null;
return { result: 'active', code: 'hirehive_api_ok', reason: 'HireHive API returns the published posting (live)' };
}
/**
* Decide liveness for one Zoho Recruit posting from its own page.
* Pure + deterministic (no I/O), mirroring classifyAshbyBoard.
*
* A live posting page embeds its record; a record for THIS id marked
* `Is_Locked` or unpublished is a posting the career site shows as filled
* (expired), any other record for it is live. A removed posting is a 200 page
* with no record and Zoho's "This job posting is no longer available." notice.
* Anything else (a record for another id, an error page, a markup change)
* returns null so the browser rung decides.
*
* @param {any} html - the posting page's body
* @param {string} id - the {id} path segment from the posting URL
* @returns {{ result: 'active' | 'expired', code: string, reason: string } | null}
*/
export function classifyZohoPostingPage(html, id) {
if (typeof html !== 'string' || !html) return null;
const record = parseZohoDetailPage(html);
if (record) {
if (String(record.id) !== String(id)) return null;
return isZohoRecordClosed(record)
? { result: 'expired', code: 'zoho_recruit_filled', reason: 'Zoho Recruit marks the posting locked or unpublished (filled)' }
: { result: 'active', code: 'zoho_recruit_page_ok', reason: 'Zoho Recruit posting page carries the open posting (live)' };
}
return /This job posting is no longer available/i.test(html)
? { result: 'expired', code: 'zoho_recruit_unavailable', reason: 'Zoho Recruit shows "This job posting is no longer available."' }
: null;
}
// The notice on UKG's empty detail page, the one the browser shows as "This
// opportunity is currently not available." An internal-only posting's page has
// no record either, but carries a sign-in notice instead, so it does not match.
const ULTIPRO_UNAVAILABLE_NOTICE = 'data-i18n="Opportunity.OpportunityError.OpportunityUnavailableMessage"';
/**
* Decide liveness for one UltiPro (UKG Pro Recruiting) posting from its own
* detail page. Pure + deterministic (no I/O), mirroring classifyZohoPostingPage.
*
* A posting's page embeds its record, and the record for THIS id says whether
* it is closed: `OpportunityIsClosed` false is live, true is closed (a closed
* posting can keep its whole page, only without an Apply control). A posting
* UKG no longer shows gets the board's empty page: no record, and the
* "opportunity unavailable" notice. Anything else (a record for another id or
* without the flag, an internal-only notice, an error page, a markup change)
* returns null so the browser rung decides.
*
* @param {any} html - the detail page's body
* @param {string} id - the opportunityId from the posting URL
* @returns {{ result: 'active' | 'expired', code: string, reason: string } | null}
*/
export function classifyUltiproDetailPage(html, id) {
if (typeof html !== 'string' || !html) return null;
let page;
try {
page = extractCandidateOpportunityDetail(html, id);
} catch {
return null; // a record for another id, or one that no longer parses
}
if (page.status === 'ok') {
const closed = page.detail.OpportunityIsClosed;
if (typeof closed !== 'boolean') return null;
return closed
? { result: 'expired', code: 'ultipro_api_closed', reason: 'UKG marks the opportunity closed (OpportunityIsClosed)' }
: { result: 'active', code: 'ultipro_api_open', reason: 'UKG marks the opportunity open (live)' };
}
return html.includes(ULTIPRO_UNAVAILABLE_NOTICE)
? { result: 'expired', code: 'ultipro_api_unavailable', reason: 'UKG shows "This opportunity is currently not available."' }
: null;
}
/**
* Decide liveness for one Workday posting from the body of a 403 on its per-job
* CXS endpoint. Pure + deterministic (no I/O), mirroring classifyAshbyBoard.
*
* On this endpoint a live posting is a 200 and a missing one is a 404, both
* concluded from the status alone by the generic path. A withdrawn posting is a
* 403 whose JSON body carries `errorCode: "S22"`, "permission denied" — the
* only case where the body decides. Only that exact S22 shape reads as expired;
* any other 403 body — a WAF or bot-wall page, or a Workday error with a
* different code — stays inconclusive.
*
* @param {any} json - parsed body of a 403 from the CXS per-job endpoint
* @returns {{ result: 'expired', code: string, reason: string } | null}
*/
export function classifyWorkday403(json) {
return json?.httpStatus === 403 && json?.errorCode === 'S22'
? { result: 'expired', code: 'workday_api_withdrawn', reason: 'Workday CXS answers 403 S22 — posting withdrawn' }
: null;
}
/**
* Map a posting URL to its ATS API URL, or null if it isn't a known ATS posting
* (or any extracted segment fails the strict charset). Pure + deterministic.
* @param {string} rawUrl
* @returns {{ ats: string, apiUrl: string, method: string, body?: string, parts: Record<string, string>, timeoutMs?: number, throttleMs?: number, accept?: string, interpret?: (res: Response, parts: Record<string, string>, signal: AbortSignal) => Promise<{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null>, interpretStatuses?: number[], api404Authoritative: boolean } | null}
*/
export function resolveAtsApi(rawUrl) {
let u;
try {
u = new URL(rawUrl);
} catch {
return null;
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
const plainHttp = u.protocol === 'http:';
for (const provider of ATS_PROVIDERS) {
// Plain http resolves only for a provider that opts in: one whose API host is
// fixed and never derived from the page URL, so the page's scheme is irrelevant.
if (plainHttp && !provider.acceptsHttp) continue;
const parts = provider.match(u);
if (!parts) continue;
// SSRF guard: every derived value must be safe — a single path segment for
// most providers, or (Workday) a slash-separated sequence of safe segments.
// isSafeValue enforces the same charset + no-".." rule either way.
if (!Object.values(parts).every(isSafeValue)) return null;
return {
ats: provider.id,
apiUrl: provider.api(parts),
method: provider.method || 'GET',
body: provider.body?.(parts),
parts,
timeoutMs: provider.timeoutMs,
throttleMs: provider.throttleMs,
accept: provider.accept,
headers: typeof provider.headers === 'function' ? provider.headers(parts) : provider.headers,
followEmbed: provider.followEmbed,
interpret: provider.interpret,
interpretStatuses: provider.interpretStatuses,
api404Authoritative: provider.api404Authoritative !== false,
interpretBoard: provider.interpretBoard,
};
}
return null;
}
/** True if `url` is an ATS posting we can check via API (lets callers stay lazy about the browser). */
export function isAtsPosting(url) {
return resolveAtsApi(url) !== null;
}
// ATS ids whose public API returns the actual JD body (not just a liveness
// signal). Greenhouse (`content`), Lever (`descriptionPlain`), Ashby
// (`descriptionPlain` on the org board), Workday (`jobPostingInfo.jobDescription`
// on the per-job CXS endpoint) and SmartRecruiters (`jobAd.sections`) all ship
// full text for free in the same payload resolveAtsApi() already points at.
// greenhouse-embedded (a company careers page carrying only `?gh_jid=`) reaches
// the same per-job Greenhouse endpoint once its embed redirect names the board.
// Zoho Recruit has no JSON API; the posting page resolveAtsApi() points at
// embeds the record with its HTML `Job_Description`. UltiPro is the same: its
// detail page embeds the `CandidateOpportunityDetail` record with its HTML
// `Description`. HiBob's per-posting endpoint carries the posting's HTML
// description, responsibilities, requirements and benefits.
// Workable canonical and share links expose description, requirements and benefits
// on the per-posting v2 API, after the existing guarded account-resolution hop.
// HireHive's per-posting API carries the description HTML.
// Microsoft and LinkedIn are on ATS_PROVIDERS
// for liveness only — their public endpoints answer search/status, never body
// text — so they are deliberately excluded here; see fetch-jd.mjs / the
// fetch*Jd() family in browser-extract.mjs for the per-provider fetchers.
//
// One ATS per line, in alphabetical order: PRs that add different ATSes then
// touch different lines, which reduces merge conflicts.
export const JD_TEXT_API_ATS = new Set([
'ashby',
'greenhouse',
'greenhouse-embedded',
'hibob',
'hirehive',
'lever',
'smartrecruiters',
'ultipro',
'workable',
'workable-short',
'workday',
'zoho-recruit',
]);
/**
* Zero-token liveness check via the posting's ATS API.
* @param {string} url
* @param {{ boardCache?: Map<string, unknown> }} [options]
* boardCache: for a provider whose API is the whole board (Ashby), the parsed
* board is kept here under its API URL and reused for the next posting on the
* same board. Only a 200 that parsed into a board is kept; anything else is
* retried next time.
* Without it, every posting fetches its board, as before.
* @returns {Promise<{ result: 'active' | 'expired' | 'uncertain', code: string, reason: string } | null>}
* null = not a known ATS posting, or inconclusive → caller should fall back to Playwright.
* `uncertain` is a conclusion in its own right: the provider reached the posting
* and could not read it, and no other rung would do better.
*/
export async function checkLivenessViaApi(url, { boardCache } = {}) {
const resolved = resolveAtsApi(url);
if (!resolved) return null;
const { ats, apiUrl, method, body, parts, interpret, interpretStatuses, timeoutMs, throttleMs, accept, headers, followEmbed, api404Authoritative, interpretBoard } = resolved;
const cacheBoard = Boolean(boardCache && interpretBoard);
// Checked before the throttle: a board already in hand costs no request.
if (cacheBoard && boardCache.has(apiUrl)) return interpretBoard(boardCache.get(apiUrl), parts);
// Wait out any provider rate limit BEFORE arming the timeout, so the spacing
// does not eat the budget the request itself needs.
await throttleProviderRequest(ats, throttleMs);
// The timeout guards the whole classification (fetch + any `interpret` body read),
// since aborting the shared signal also tears down an in-flight res.json().
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs || TIMEOUT_MS);
try {
let res;
try {
res = await fetch(apiUrl, {
method,
headers: { 'user-agent': DEFAULT_USER_AGENT, accept: accept || 'application/json', ...headers },
body,
redirect: followEmbed ? 'manual' : 'error',
signal: controller.signal,
});
if (followEmbed) {
const jobApiUrl = await followEmbed(res, parts);
if (!jobApiUrl) return null;
res = await fetch(jobApiUrl, {
method: 'GET',
headers: { 'user-agent': DEFAULT_USER_AGENT, accept: 'application/json' },
redirect: 'error',
signal: controller.signal,
});
}
} catch {
return null; // network / timeout / redirect → inconclusive, let Playwright decide
}
if ((res.status === 404 || res.status === 410) && !interpretStatuses?.includes(res.status)) {
if (!api404Authoritative) return null; // inconclusive → let Playwright check the real page
return { result: 'expired', code: `${ats}_api_gone`, reason: `ATS API ${res.status} — posting removed` };
}
// Providers with an `interpret` step read the body of the statuses they
// list (by default the 200: org-level Ashby, and per-job APIs whose 200 can
// still describe a closed posting); any other 200 is proof the posting is live.
if (cacheBoard && res.status === 200) {
let json;
try {
json = await res.json();
} catch {
return null; // unparseable body → inconclusive, and not cached
}
// Kept only when it classifies: a 200 whose body is not a board (no
// `jobs` array) stays uncached, so the next posting retries the API.
const verdict = interpretBoard(json, parts);
if (verdict) boardCache.set(apiUrl, json);
return verdict;
}
if (interpret && (interpretStatuses || [200]).includes(res.status)) return await interpret(res, parts, controller.signal);
if (res.status === 200) {
return { result: 'active', code: `${ats}_api_ok`, reason: 'ATS API returns the posting (live)' };
}
return null; // 429/5xx/other → inconclusive, fall back to the browser check
} catch {
return null; // interpret abort / unexpected error → inconclusive
} finally {
clearTimeout(timer);
}
}