/** * Detect SAP SuccessFactors career sites that run behind an employer's own * domain (#5739, follow-up to #3697/#3912). * * `atsVendorOf()` only sees the URL host, so `jobs.example.com` on SuccessFactors * is labelled by hostname and never reaches `providers/successfactors.mjs`. * `detectBrandedSuccessFactors()` is the pure, offline half: it reads an already * fetched career-page body (and optional response headers) for stable markers. * `classifyUnknownHostSeeds()` is the opt-in half: it probes each distinct * unknown host at most once through an injected fetcher and relabels only hosts * the detector confirms. A failed probe never changes a label. */ import { KNOWN_ATS_VENDORS } from './ats-vendor.mjs'; import { atsBoardUrlOf } from './history-ats-seeds.mjs'; const SF_HOST = '[a-z0-9.-]*(?:successfactors\\.(?:com|eu)|sapsf\\.(?:com|eu|cn)|jobs2web\\.com)'; // Asset-bearing tags only. A plain to successfactors.com (a blog // post, a vendor footer) is not evidence that the page itself is served by it. const SF_ASSET_RE = new RegExp( `<(?:script|link|img|iframe)\\b[^>]*?\\b(?:src|href|data-src)\\s*=\\s*["']?(?:https?:)?//${SF_HOST}(?=[/:"'?#\\s>]|$)`, 'i', ); // Signatures of the two page families providers/successfactors.mjs scrapes. // RMK (ex-jobs2web): the tile fragment endpoint, or a job-tile list carrying // the jobTitle-link anchors the provider parses. const RMK_ENDPOINT_RE = /\/tile-search-results\/?(?:\?|["'\s<])/i; const RMK_TILE_RE = /class\s*=\s*["'][^"']*\bjob-tile\b[^"']*["']/i; const RMK_TITLE_LINK_RE = /class\s*=\s*["'][^"']*\bjobTitle-link\b/i; // CSB (Career Site Builder): the unified-search script or its jobs API path. const CSB_API_RE = /\/services\/recruiting\/v1\/jobs\b/i; const CSB_SCRIPT_RE = /\bsearchResultsUnify(?:\.min)?\.js\b/i; const SF_HEADER_RE = new RegExp(`(?:^|[\\s;,<"'/.])${SF_HOST}(?=[\\s;,>"'/:]|$)`, 'i'); function headerValues(headers) { if (!headers) return []; const out = []; if (typeof headers.forEach === 'function' && typeof headers.get === 'function') { headers.forEach((value, key) => { if (/^(?:link|set-cookie|content-security-policy)$/i.test(key)) out.push(String(value)); }); return out; } if (typeof headers === 'object') { for (const [key, value] of Object.entries(headers)) { if (!/^(?:link|set-cookie|content-security-policy)$/i.test(key)) continue; if (Array.isArray(value)) out.push(...value.map(String)); else if (value != null) out.push(String(value)); } } return out; } /** * @param {unknown} html Fetched career-page body. * @param {Headers|Record|null} [headers] Optional response headers. * @returns {boolean} true only when the page carries SuccessFactors markers. */ export function detectBrandedSuccessFactors(html, headers = null) { const body = typeof html === 'string' ? html : ''; if (body) { if (SF_ASSET_RE.test(body)) return true; if (RMK_ENDPOINT_RE.test(body)) return true; if (RMK_TILE_RE.test(body) && RMK_TITLE_LINK_RE.test(body)) return true; if (CSB_SCRIPT_RE.test(body)) return true; if (CSB_API_RE.test(body) && /\bsuccessfactors\b|\bsapsf\b|\bjobs2web\b/i.test(body)) return true; } return headerValues(headers).some((value) => SF_HEADER_RE.test(value)); } /** * Build the default probe around fetchTextHead: body capped at maxBytes, and the * response headers captured through onResponse so header-only markers count. * @param {(url: string, opts: object) => Promise} fetchHead */ export function makeHeadProbe(fetchHead, maxBytes = 262144) { return async (url) => { let headers = null; const html = await fetchHead(url, { maxBytes, onResponse: (response) => { headers = response?.headers ?? null; }, }); return { html, headers }; }; } /** A seed is "unknown-host" when its vendor label is just a hostname. */ export function isUnknownHostSeed(seed) { return Boolean(seed?.vendor) && !KNOWN_ATS_VENDORS.includes(seed.vendor) && seed.vendor === safeHostname(seed.careersUrl); } function safeHostname(url) { try { return new URL(url).hostname.toLowerCase(); } catch { return null; } } /** * Relabel unknown-host seeds whose host is a branded SuccessFactors site. * * @param {Array<{company:string,vendor:string,careersUrl:string,source?:string}>} seeds * @param {object} options * @param {(url: string) => Promise<{html?: string, headers?: any}|string|null>} options.probe * Injected fetcher, called once per host with the first seed URL seen on it * (a real career/posting page, not the bare origin, which may redirect or be * a generic homepage). Throwing, or returning null, means * "no verdict" and leaves every seed on that host untouched. * @param {Map} [options.cache] Per-host verdicts (host to * true/false, null for a failed probe). Pass the same Map across calls to reuse. * @param {number} [options.concurrency] * @returns {Promise} seeds, with confirmed hosts routed to successfactors * (`probeConfirmed: true` lets the sweep skip URL-only detect(), like an * explicit `provider: successfactors` in portals.yml). */ export async function classifyUnknownHostSeeds(seeds, { probe, cache = new Map(), concurrency = 4 } = {}) { if (typeof probe !== 'function' || !Array.isArray(seeds)) return seeds; const probeUrlByHost = new Map(); for (const seed of seeds) { if (!isUnknownHostSeed(seed)) continue; const host = seed.vendor; if (cache.has(host) || probeUrlByHost.has(host)) continue; try { const u = new URL(seed.careersUrl); if ((u.protocol === 'https:' || u.protocol === 'http:') && u.hostname.toLowerCase() === host) { u.hash = ''; probeUrlByHost.set(host, u.href); } } catch { /* skip */ } } const pending = [...probeUrlByHost.entries()]; let next = 0; const worker = async () => { while (next < pending.length) { const [host, probeUrl] = pending[next++]; let verdict = null; try { const res = await probe(probeUrl); if (typeof res === 'string') verdict = detectBrandedSuccessFactors(res); else if (res && typeof res === 'object') verdict = detectBrandedSuccessFactors(res.html, res.headers); } catch { verdict = null; } cache.set(host, verdict); } }; await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, pending.length)) }, worker)); const out = []; const seen = new Set(); for (const seed of seeds) { let result = seed; if (isUnknownHostSeed(seed) && cache.get(seed.vendor) === true) { const careersUrl = atsBoardUrlOf(seed.careersUrl, 'successfactors'); if (careersUrl) result = { ...seed, vendor: 'successfactors', careersUrl, probeConfirmed: true }; } const key = `${result.vendor}\t${result.careersUrl}`; if (seen.has(key)) continue; seen.add(key); out.push(result); } return out; }