Files
santifer--career-ops/providers/workday.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

308 lines
15 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// Workday provider — hits the public CXS jobs endpoint (POST, paginated).
// Auto-detects from careers_url pattern
// `https://<tenant>.<instance>.myworkdayjobs.com[/<locale>]/<site>`,
// e.g. https://23andme.wd5.myworkdayjobs.com/23 →
// POST https://23andme.wd5.myworkdayjobs.com/wday/cxs/23andme/23/jobs
//
// Workday only exposes a relative "postedOn" label ("Posted Today",
// "Posted 5 Days Ago", "Posted 30+ Days Ago"); postedAt is derived from it
// and omitted for the unbounded "30+ Days Ago" form.
const PAGE_SIZE = 20;
// Safety cap on pagination — applied regardless of what the upstream reports
// as `total` (or, when `total` is absent, regardless of how many full pages
// keep coming back), so a misbehaving/compromised API can't drive this into
// fetching an unbounded number of pages. Override with `max_pages` on the
// portal entry for a tenant that genuinely exceeds it.
const DEFAULT_MAX_PAGES = 100;
// Hard ceiling even for an explicit override. 1500 pages (30,000 postings)
// covers known large tenants (dollartree: 23,609; oreillyauto: 17,061;
// cvshealth: ~16,800) with headroom — not a completeness guarantee, since a
// company directory this size has no fixed upper bound.
const MAX_PAGES_CAP = 1500;
// Retry policy for transient page failures (429 rate-limit, 5xx, timeouts/aborts).
// Workday's CXS API is fronted by a WAF that rate-limits in bursts; without
// retry, a single 429 silently truncates an entire tenant (e.g. a
// 3,383-posting tenant reduced to 20 jobs on page 2). Non-transient errors
// (4xx other than 429) are not retried — retrying a malformed request just
// wastes the budget.
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 500;
const RETRY_MAX_DELAY_MS = 8_000;
// Delay between successive pages *within one tenant's own pagination loop*
// (not between tenants — that's scan-ats-full.mjs's concurrency, a separate
// knob). A burst of same-host requests with zero delay risks Workday's
// WAF-level rate limiting on any tenant that paginates several pages deep
// (large boards like rollsroyce, sec, roche). Only tenants that loop past
// page 1 pay this; no-date-skip and early-stopped tenants never do.
const INTER_PAGE_DELAY_MS = 150;
// Workday returns postings newest-first, so pagination can stop once a
// page's oldest *dated* posting is well past --since — no point paying for
// (and rate-limit-risking) pages that are entirely stale. Only unambiguous
// numeric ages ("Posted N Days Ago", N < 30) count for this; the unbounded
// "30+ Days Ago" bucket never triggers it, so a wide --since (>=30 days)
// simply never early-stops rather than risk a false stop.
//
// The sort isn't perfectly monotonic day-to-day — some tenants (e.g. Adobe)
// return day-labels slightly out of order across consecutive postings ("27
// Days Ago | 26 Days Ago | 27 Days Ago"), roughly 1 day of jitter. The
// margin only needs to clear that; 2 is double it as a plain safety factor,
// not a second measurement.
const EARLY_STOP_MARGIN_MS = 2 * 86_400_000;
/** Resolve the page cap: a positive integer `max_pages` on the entry, capped. */
function resolveMaxPages(entry) {
const v = entry?.max_pages;
if (Number.isInteger(v) && v > 0) return Math.min(v, MAX_PAGES_CAP);
return DEFAULT_MAX_PAGES;
}
function sleep(ms, ctx) {
if (typeof ctx?.sleep === 'function') return ctx.sleep(ms);
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Parses a `Retry-After` header value (seconds, or an HTTP-date) to ms, or null. */
function parseRetryAfterMs(value) {
if (!value) return null;
const secs = Number(value);
if (Number.isFinite(secs) && secs >= 0) return secs * 1000;
const dateMs = Date.parse(value);
return Number.isFinite(dateMs) ? Math.max(0, dateMs - Date.now()) : null;
}
function isRetryableError(err) {
const status = err?.status;
if (status === 429) return true;
if (typeof status === 'number' && status >= 500) return true;
return status === undefined; // network error / timeout / abort — no status set
}
/** Fetches a single page, retrying transient failures with backoff. */
async function fetchPageWithRetry(ctx, api, opts) {
let lastErr;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await ctx.fetchJson(api, opts);
} catch (err) {
lastErr = err;
if (attempt === MAX_RETRIES || !isRetryableError(err)) throw err;
const backoff = Math.min(RETRY_BASE_DELAY_MS * 2 ** attempt, RETRY_MAX_DELAY_MS);
// A server-supplied Retry-After is honored, but still clamped — an
// unbounded value (hostile or just misconfigured: Retry-After: 86400)
// would otherwise stall this tenant's fetch for as long as the server
// says, defeating the whole point of a bounded backoff.
const retryAfterMs = parseRetryAfterMs(err?.retryAfter);
const delayMs = retryAfterMs !== null ? Math.min(retryAfterMs, RETRY_MAX_DELAY_MS * 4) : (backoff + Math.random() * 250);
await sleep(delayMs, ctx);
}
}
throw lastErr;
}
/** True once a page's oldest unambiguously-dated posting is past the --since window. */
function pageIsPastWindow(pageJobs, sinceMs) {
if (typeof sinceMs !== 'number') return false;
const dated = pageJobs.map((j) => j.postedAt).filter((v) => typeof v === 'number');
if (dated.length === 0) return false;
return Math.min(...dated) < sinceMs - EARLY_STOP_MARGIN_MS;
}
function resolveEndpoint(entry) {
// Try api: first, then careers_url (mirrors greenhouse/ashby), returning the
// first that matches the Workday tenant pattern. This lets a branded page
// (e.g. https://www.ptc.com/en/careers) stay as careers_url while the Workday
// tenant URL is pinned via api: — and, because we fall through on a non-match,
// a non-Workday api: value doesn't shadow a valid careers_url.
for (const url of [entry.api, entry.careers_url]) {
if (typeof url !== 'string' || !url) continue;
const m = url.match(/^https:\/\/([\w-]+)\.(wd[\w-]*)\.myworkdayjobs\.com\/(?:[a-z]{2}-[A-Z]{2}\/)?([^/?#]+)/);
if (!m) continue;
const [, tenant, instance, site] = m;
const origin = `https://${tenant}.${instance}.myworkdayjobs.com`;
return {
api: `${origin}/wday/cxs/${tenant}/${site}/jobs`,
// externalPath is relative to the site, not the host root — without the
// site segment the URL 404s.
jobBase: `${origin}/${site}`,
};
}
return null;
}
function parsePostedOn(label) {
if (!label) return undefined;
if (/posted\s+today/i.test(label)) return Date.now();
if (/posted\s+yesterday/i.test(label)) return Date.now() - 86_400_000;
const m = label.match(/posted\s+(\d+)(\+?)\s*day/i);
if (!m || m[2] === '+') return undefined; // "30+ Days Ago" — unbounded, no usable date
return Date.now() - Number(m[1]) * 86_400_000;
}
// Workday URL path encodes location as /job/{Location-Slug}/{title-slug}.
// Use it as fallback when locationsText is absent (common on some tenants).
function locationFromPath(externalPath) {
const m = String(externalPath || '').match(/\/job\/([^/]+)\//);
if (!m) return '';
let segment;
try { segment = decodeURIComponent(m[1]); } catch { segment = m[1]; }
return segment.replace(/-/g, ' ');
}
export function parseWorkdayResponse(json, entry) {
const ep = resolveEndpoint(entry);
const jobBase = ep?.jobBase || '';
const postings = Array.isArray(json?.jobPostings) ? json.jobPostings : [];
const jobs = [];
for (const j of postings) {
if (j == null) continue;
if (!j.externalPath || !String(j.title || '').trim()) continue;
jobs.push({
title: j.title || '',
url: jobBase + j.externalPath,
company: entry.name,
location: j.locationsText || locationFromPath(j.externalPath),
postedAt: parsePostedOn(j.postedOn),
});
}
return jobs;
}
/** @type {Provider} */
export default {
id: 'workday',
detect(entry) {
const ep = resolveEndpoint(entry);
return ep ? { url: ep.api } : null;
},
async fetch(entry, ctx) {
const ep = resolveEndpoint(entry);
if (!ep) throw new Error(`workday: cannot derive CXS endpoint for ${entry.name}`);
const postOpts = { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json', accept: 'application/json' } };
const makeBody = (offset) => JSON.stringify({ limit: PAGE_SIZE, offset, searchText: '', appliedFacets: {} });
const sinceMs = typeof ctx?.sinceMs === 'number' ? ctx.sinceMs : null;
const first = await fetchPageWithRetry(ctx, ep.api, { ...postOpts, body: makeBody(0) });
const jobs = parseWorkdayResponse(first, entry);
const total = typeof first?.total === 'number' ? first.total : null;
const firstPostings = Array.isArray(first?.jobPostings) ? first.jobPostings : [];
const maxPages = resolveMaxPages(entry);
// How many pages to fetch in total (including the first, already-fetched
// one): bounded by `total` when the server reports it, always capped at
// maxPages. When `total` is absent, only probe further pages if the first
// one was full — a short first page already means there's nothing more.
let pagesToFetch = total !== null
? Math.min(Math.ceil(total / PAGE_SIZE), maxPages)
: (firstPostings.length >= PAGE_SIZE ? maxPages : 1);
// Honor a context page cap — verify-portals' liveness probe sets
// `ctx.maxPages: 1` so it only needs to know a board is live, not its full
// count. Without this we'd fetch page 0, then request page 1 and trip the
// probe's second-request sentinel; fetchPageWithRetry treats that abort as
// transient and retries it MAX_RETRIES times (with backoff) before giving up
// — noisy in the logs and rude to the tenant. Capping here makes workday a
// "cooperating provider" that stops after one page and reports an exact
// first-page count. Kept separate from `maxPages` so the entry-cap warning
// below (pagesToFetch === maxPages) stays quiet. No effect on real scans,
// which don't set ctx.maxPages.
const ctxCap = Number.isInteger(ctx?.maxPages) && ctx.maxPages > 0 ? ctx.maxPages : Infinity;
pagesToFetch = Math.min(pagesToFetch, ctxCap);
// Why pagination stopped — drives which warning (if any) fires below.
// 'fetch-error' must NOT produce the "raise max_pages" advice: that knob
// does nothing for a tenant that died on a rate limit rather than hit the cap.
let stopReason = 'complete';
if (pageIsPastWindow(jobs, sinceMs)) stopReason = 'early-stop';
// Some tenants' CXS responses never include postedOn at all (e.g.
// adventhealth, on every page). Early-stop can't apply then — there's
// no dated posting to recognize as "past the window".
const sawAnyDatedPosting = jobs.some((j) => typeof j.postedAt === 'number');
// Zero dated postings on page 0, --include-undated off, --since-bounded
// scan: further pagination is pure waste — every posting from this
// tenant will be dropped downstream as undated regardless of page count
// (newest-first sort means if the *freshest* postings lack a date, older
// ones will too). Return page 0's results instead of grinding to maxPages.
if (stopReason === 'complete' && sinceMs !== null && ctx?.includeUndated !== true
&& !sawAnyDatedPosting && jobs.length > 0) {
stopReason = 'no-date-skip';
}
// Sequential, not concurrent (mirrors providers/4dayweek.mjs, thehub.mjs,
// arbeitnow.mjs, jibeapply.mjs) — a single tenant's API has no reason to
// receive a burst of parallel requests, and a mid-run failure stops
// cleanly with whatever pages were already gathered instead of
// discarding them (Promise.all would fail the whole batch on one error).
let page = 1;
if (stopReason === 'complete') {
for (; page < pagesToFetch; page++) {
await sleep(INTER_PAGE_DELAY_MS, ctx);
let json;
try {
json = await fetchPageWithRetry(ctx, ep.api, { ...postOpts, body: makeBody(page * PAGE_SIZE) });
} catch (err) {
const jobsSummary = `${jobs.length}${total !== null ? ` of ${total}` : ''} jobs`;
console.error(`⚠️ workday: ${entry.name} truncated at ${page + 1} of ${pagesToFetch} pages after ${MAX_RETRIES + 1} attempts (${jobsSummary}): ${err.message}`);
stopReason = 'fetch-error';
break;
}
const pageJobs = parseWorkdayResponse(json, entry);
jobs.push(...pageJobs);
if (total === null) {
const postings = Array.isArray(json?.jobPostings) ? json.jobPostings : [];
if (postings.length < PAGE_SIZE) break; // short page → last page reached
}
if (pageIsPastWindow(pageJobs, sinceMs)) { stopReason = 'early-stop'; break; }
}
if (stopReason === 'complete' && page === pagesToFetch && pagesToFetch === maxPages) {
stopReason = 'cap';
}
}
// The cap is a safety net, not a working limit — silent by design, but a
// tenant that actually hits it needs to be surfaced, in one short line
// (a full-directory scan can hit this on dozens of tenants).
//
// "raise max_pages" only applies when `entry` is a real portals.yml
// tracked_companies entry (scan.mjs, sinceMs === null). scan-ats-full.mjs's
// reverse scan (the only caller that sets ctx.sinceMs) synthesizes entries
// from the external dataset — there's no portal entry to edit, and no
// fixed cap can guarantee full coverage of an unbounded company
// directory anyway, so there's nothing else to suggest.
if (stopReason === 'cap') {
const jobsSummary = `${jobs.length}${total !== null ? ` of ${total}` : ''} jobs`;
if (sinceMs === null) {
console.error(`⚠️ workday: ${entry.name} truncated at max_pages=${maxPages} (${jobsSummary}) — raise max_pages on this entry for more`);
} else {
// Workday's CXS backend can report `total` as exactly
// maxPages*PAGE_SIZE when the real count is far higher (e.g.
// dickssportinggoods: total=2000, public site lists 7,120; requests
// at offset 2000/4000 return the same first posting as offset 0).
// Flag it, don't explain it here.
const suspectTag = total !== null && total === maxPages * PAGE_SIZE ? ' (total may be Workday-capped, not real)' : '';
console.error(`⚠️ workday: ${entry.name} truncated at ${maxPages} pages (${jobsSummary})${suspectTag}`);
}
}
// 'no-date-skip' hits many tenants in a full-directory scan (a company
// with several Workday sites, like a1group or ashealthnet, triggers it
// once per site) — a console.error per hit would repeat thousands of
// times, so tag the array instead; scan-ats-full.mjs aggregates it into
// one summary line.
if (stopReason === 'no-date-skip') jobs.workdayNoDateSkip = true;
return jobs;
},
};