213 lines
8.1 KiB
JavaScript
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;
|
|
},
|
|
};
|