Files
santifer--career-ops/providers/csod.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

213 lines
8.1 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// Cornerstone OnDemand (CSOD) career-site provider — the hosted boards at
// https://{tenant}.csod.com/ux/ats/careersite/{siteId}/home?c={corpName}
// (e.g. OHB: career-ohb.csod.com/ux/ats/careersite/4/home?c=career-ohb).
//
// The search API is public but wants a bearer token. The career-site home page
// is a small (~5 KB) bootstrap document that embeds an ANONYMOUS JWT as
// `"token":"eyJ…"` — no login, no session cookies needed. Flow per fetch:
//
// 1. GET {origin}/ux/ats/careersite/{siteId}/home?c={corpName} → extract token
// 2. POST {origin}/services/x/career-site/v1/search → page through
// body: {careerSiteId, careerSitePageId, pageNumber (1-based), pageSize,
// cultureId, cultureName, searchText:"", …empty facet arrays}
// → {data: {totalCount, requisitions: [{requisitionId, displayJobTitle,
// postingEffectiveDate: "M/D/YYYY", locations: [{city,state,country}]}]}}
//
// Job detail URL (verified live): {origin}/ux/ats/careersite/{siteId}/home/
// requisition/{requisitionId}?c={corpName}.
//
// Detection: *.csod.com hosts auto-claim when the path carries the careersite
// shape; the branded corporate page goes in careers_url and the csod.com URL in
// `api:` (same convention as workday/successfactors).
const PAGE_SIZE = 25; // server default; verified OHB serves exactly 25/page
const MAX_PAGES = 40; // safety cap on request count (40*25 = 1000 postings)
const MAX_JOBS = 1000; // cap total postings pulled per site
const PAGE_DELAY_MS = 120; // polite pacing between search requests
/**
* Parse tenant/site/corp out of a careersite URL.
* @param {import('./_types.js').PortalEntry} entry
* @returns {{origin: string, siteId: number, corpName: string, homeUrl: string, searchApi: string} | null}
*/
export function resolveConfig(entry) {
const raw = entry.api || entry.careers_url || '';
let u;
try {
u = new URL(raw);
} catch {
return null;
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
const host = u.host.toLowerCase();
if (host !== 'csod.com' && !host.endsWith('.csod.com')) return null;
const m = u.pathname.match(/\/ux\/ats\/careersite\/(\d+)\//i) || u.pathname.match(/\/ux\/ats\/careersite\/(\d+)$/i);
if (!m) return null;
const siteId = Number(m[1]);
const corpName = u.searchParams.get('c') || host.split('.')[0];
return {
origin: u.origin,
siteId,
corpName,
homeUrl: `${u.origin}/ux/ats/careersite/${siteId}/home?c=${encodeURIComponent(corpName)}`,
searchApi: `${u.origin}/services/x/career-site/v1/search`,
};
}
/**
* Pull the anonymous bearer token out of the bootstrap home page.
* @param {string} html @returns {string}
*/
export function extractToken(html) {
const m = typeof html === 'string' ? html.match(/"token"\s*:\s*"([A-Za-z0-9._-]+)"/) : null;
return m ? m[1] : '';
}
// postingEffectiveDate is US-format M/D/YYYY ("7/3/2026") → epoch ms (UTC for
// determinism; consumers only use this for coarse recency ranking).
/** @param {unknown} raw @returns {number | undefined} */
export function parseCsodDate(raw) {
const m = typeof raw === 'string' ? raw.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/) : null;
if (!m) return undefined;
const month = Number(m[1]);
const day = Number(m[2]);
const year = Number(m[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) return undefined;
const ms = Date.UTC(year, month - 1, day);
if (new Date(ms).getUTCDate() !== day) return undefined; // catches 4/31, 2/30, etc.
return Number.isFinite(ms) ? ms : undefined;
}
// locations is an array of {city, state, country}. City is the useful part;
// append the country code when present ("Bremen, DE"). Multiple work locations
// join with " / ".
/** @param {unknown} raw @returns {string} */
export function cleanLocations(raw) {
const list = Array.isArray(raw) ? raw : [];
const out = [];
for (const loc of list) {
if (!loc || typeof loc !== 'object') continue;
const city = String(loc.city || '').trim();
const country = String(loc.country || '').trim();
const s = city ? (country ? `${city}, ${country}` : city) : country;
if (s && !out.includes(s)) out.push(s);
}
return out.join(' / ');
}
/**
* Map one search response page to raw {id, title, url, location, postedAt}.
* Records without an id or a title are skipped (no stable dedup key / no
* meaningful listing).
* @param {any} json @param {{origin:string, siteId:number, corpName:string}} cfg
*/
export function parseRequisitions(json, cfg) {
const list = Array.isArray(json?.data?.requisitions) ? json.data.requisitions : [];
const out = [];
for (const r of list) {
if (!r || typeof r !== 'object') continue;
const id = r.requisitionId != null ? String(r.requisitionId) : '';
const title = String(r.displayJobTitle || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (!id || !title) continue;
out.push({
id,
title,
url: `${cfg.origin}/ux/ats/careersite/${cfg.siteId}/home/requisition/${id}?c=${encodeURIComponent(cfg.corpName)}`,
location: cleanLocations(r.locations),
postedAt: parseCsodDate(r.postingEffectiveDate),
});
}
return out;
}
/** Resolve the page cap: positive integer `max_pages`, else default. */
function resolveMaxPages(entry) {
const v = entry?.max_pages;
if (Number.isInteger(v) && v > 0) return Math.min(v, MAX_PAGES);
return MAX_PAGES;
}
/** @type {Provider} */
export default {
id: 'csod',
detect(entry) {
const url = entry.api || entry.careers_url || '';
if (typeof url !== 'string') return null;
// Host check (not a path substring) so evil.com/x.csod.com can't spoof it,
// and the URL must carry the careersite path shape we know how to drive.
return resolveConfig({ api: url }) ? { url } : null;
},
async fetch(entry, ctx) {
const cfg = resolveConfig(entry);
if (!cfg) throw new Error(`csod: cannot resolve careersite URL for ${entry.name}`);
const html = await ctx.fetchText(cfg.homeUrl, { headers: { accept: 'text/html' } });
const token = extractToken(html);
if (!token) throw new Error(`csod: no anonymous token on ${cfg.homeUrl}`);
const wait = (ms) => (ctx.sleep ? ctx.sleep(ms) : new Promise((r) => setTimeout(r, ms)));
const maxPages = resolveMaxPages(entry);
const jobs = [];
const seen = new Set();
let total = null;
for (let page = 1; page <= maxPages; page++) {
if (page > 1) await wait(PAGE_DELAY_MS);
const json = await ctx.fetchJson(cfg.searchApi, {
method: 'POST',
redirect: 'error',
headers: {
'content-type': 'application/json',
accept: 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({
careerSiteId: cfg.siteId,
careerSitePageId: cfg.siteId,
pageNumber: page,
pageSize: PAGE_SIZE,
cultureId: 1,
cultureName: 'en-US',
searchText: '',
states: [],
countryCodes: [],
cities: [],
placeID: '',
radius: null,
postingsWithinDays: null,
customFieldCheckboxKeys: [],
customFieldDropdowns: [],
customFieldRadios: [],
}),
});
if (total === null) {
total = typeof json?.data?.totalCount === 'number' ? json.data.totalCount : null;
}
const rows = parseRequisitions(json, cfg);
if (rows.length === 0) break;
let fresh = 0;
for (const row of rows) {
if (seen.has(row.id)) continue;
seen.add(row.id);
fresh++;
const job = { title: row.title, url: row.url, company: entry.name, location: row.location };
if (typeof row.postedAt === 'number') job.postedAt = row.postedAt;
jobs.push(job);
if (jobs.length >= MAX_JOBS) break;
}
// No new ids → server ignored the page number (or we've looped). Stop.
if (fresh === 0) break;
if (jobs.length >= MAX_JOBS) break;
if (total !== null && page * PAGE_SIZE >= total) break;
if (rows.length < PAGE_SIZE) break;
}
return jobs;
},
};