init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,187 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* liveness-api.mjs — zero-token liveness check for ATS-hosted job postings.
|
||||
*
|
||||
* Many postings live on ATS platforms (Greenhouse, Lever, Ashby, ...) 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 on a definitive 404/410 we return `expired`, and for
|
||||
* anything ambiguous (unknown ATS, redirect, 429/5xx, network/timeout) we return
|
||||
* `null` (→ caller falls back to Playwright).
|
||||
*
|
||||
* Two endpoint shapes:
|
||||
* - Per-job (Greenhouse, Lever): the URL maps to a single-job endpoint, so a 200
|
||||
* is itself proof the posting is live.
|
||||
* - 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.)
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const TIMEOUT_MS = 8_000;
|
||||
// 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.
|
||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
// 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).
|
||||
// `interpret` — read the 200 response body to decide liveness (org-level APIs
|
||||
// where a 200 alone doesn't prove THIS posting is live).
|
||||
const ATS_PROVIDERS = [
|
||||
{
|
||||
id: 'greenhouse',
|
||||
// boards.greenhouse.io/{board}/jobs/{id} · job-boards[.eu].greenhouse.io/{board}/jobs/{id}
|
||||
match(u) {
|
||||
if (!/(^|\.)greenhouse\.io$/.test(u.hostname)) 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.lever.co/{slug}/{id}
|
||||
match(u) {
|
||||
if (u.hostname !== 'jobs.lever.co') return null;
|
||||
const m = u.pathname.match(/^\/([^/]+)\/([^/?#]+)\/?$/);
|
||||
return m ? { slug: m[1], id: m[2] } : null;
|
||||
},
|
||||
api: ({ slug, id }) => `https://api.lever.co/v0/postings/${slug}/${id}`,
|
||||
},
|
||||
{
|
||||
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 (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,
|
||||
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);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, parts: Record<string, string>, timeoutMs?: number, interpret?: (res: Response, parts: Record<string, string>) => Promise<{ result: 'active' | 'expired', code: string, reason: string } | null> } | null}
|
||||
*/
|
||||
export function resolveAtsApi(rawUrl) {
|
||||
let u;
|
||||
try {
|
||||
u = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== 'https:') return null;
|
||||
for (const provider of ATS_PROVIDERS) {
|
||||
const parts = provider.match(u);
|
||||
if (!parts) continue;
|
||||
// SSRF guard: every derived segment must be a single safe path segment.
|
||||
if (!Object.values(parts).every((v) => SAFE_SEGMENT.test(v) && !v.includes('..'))) return null;
|
||||
return { ats: provider.id, apiUrl: provider.api(parts), parts, timeoutMs: provider.timeoutMs, interpret: provider.interpret };
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-token liveness check via the posting's ATS API.
|
||||
* @param {string} url
|
||||
* @returns {Promise<{ result: 'active' | 'expired', code: string, reason: string } | null>}
|
||||
* null = not a known ATS posting, or inconclusive → caller should fall back to Playwright.
|
||||
*/
|
||||
export async function checkLivenessViaApi(url) {
|
||||
const resolved = resolveAtsApi(url);
|
||||
if (!resolved) return null;
|
||||
const { ats, apiUrl, parts, interpret, timeoutMs } = resolved;
|
||||
|
||||
// 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: 'GET',
|
||||
headers: { 'user-agent': 'career-ops-liveness/1.0', accept: 'application/json' },
|
||||
redirect: 'error', // refuse server-side redirects (SSRF + ambiguity guard)
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
return null; // network / timeout / redirect → inconclusive, let Playwright decide
|
||||
}
|
||||
|
||||
if (res.status === 404 || res.status === 410) {
|
||||
return { result: 'expired', code: `${ats}_api_gone`, reason: `ATS API ${res.status} — posting removed` };
|
||||
}
|
||||
if (res.status === 200) {
|
||||
// Org-level APIs (Ashby) inspect the body to confirm THIS posting; per-job
|
||||
// APIs (Greenhouse, Lever) treat a 200 as proof the posting is live.
|
||||
if (interpret) return await interpret(res, parts);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user