#!/usr/bin/env node // @ts-check /** * scan-dayforce.mjs — Dayforce (Ceridian) Recruiting scanner via Playwright * * Dayforce's job-search API sits behind Cloudflare (`cf_clearance`/`__cf_bm` * cookies) plus a NextAuth CSRF token. A bare Node `fetch()` — even with a * spoofed browser User-Agent/Origin/Referer — gets a hard 403; a real * Chromium session against the same endpoint gets 200 every time (confirmed * live, 2026-09-02). So this does NOT fit scan.mjs's HTTP-only provider * contract (`providers/*.mjs`, ctx.fetchJson/fetchText/fetchResponse, no * browser) — it's a standalone script, same architectural pattern as * scan-interamt.mjs (Interamt.de, also Playwright-driven because its host * has no bare-HTTP-reachable API). * * Flow per (tenant, jobBoardCode, culture) board: * 1. page.goto() the canonical board URL — bootstraps the Cloudflare * cookies and the NextAuth session automatically via the browser. * 2. GET /api/auth/csrf via page.request (Playwright's APIRequestContext * bound to the page's BrowserContext — it shares that context's cookie * jar automatically, so the request rides the session bootstrapped in * step 1 without the CORS/CSP exposure of a page.evaluate(fetch)). * 3. POST /api/geo/{tenant}/jobposting/search with X-CSRF-TOKEN, paginating * by the *returned* offset+count (never a hardcoded +25) until * offset+count >= maxCount or a short/empty page comes back. * 4. Only for postings that already pass all list-level title, location, * content, country-eligibility, and visa filters: GET the per-posting * detail endpoint for the full JD text. This is an explicit design * requirement from issue #3726, not an optimization — minimize load on * the browser session by never fetching detail for a posting that would * be filtered out anyway. * * Session hygiene: never persists cf_clearance/session cookies to disk — * each run launches a fresh browser context per tenant and closes it when * that tenant is done. On 403/429 the context is discarded and a fresh one * is bootstrapped for one retry rather than hammering the stale session. * * No bypass (providers/ADDING_A_PROVIDER.md §2, rule 3): if the board is * still showing an interactive challenge (Turnstile, "Just a moment...", * hCaptcha) once the page settles, scanBoard() throws DayforceChallengeError * and the board is skipped — never retried, never solved, never relayed. * * Reads `dayforce_boards` from portals.yml: * dayforce_boards: * - name: Give & Go Prepared Foods * tenant: gnghcm * board: CANDIDATEPORTAL # jobBoardCode; optional, default CANDIDATEPORTAL * culture: en-US # optional, default en-US * jobBoardId: 1 # optional, default 1 — see NOTE below * * NOTE on jobBoardId: the detail endpoint's path segment * `/jobposting/{tenant}/{culture}/{jobBoardId}/{jobPostingId}` uses a small * integer distinct from jobBoardCode (a string like CANDIDATEPORTAL). This * was confirmed to exist and be small (e.g. `1`) but not confirmed across * every tenant this session — if a tenant's detail fetches all 404, set * `jobBoardId` explicitly in portals.yml for that entry. * * Usage: * node scan-dayforce.mjs * node scan-dayforce.mjs --dry-run * node scan-dayforce.mjs --debug * node scan-dayforce.mjs --tenant gnghcm # scan a single configured tenant */ import { chromium } from 'playwright'; import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; import { loadYaml } from './lib/yaml-document.mjs'; import { appendToPipeline, appendToScanHistory, appendUnrecordedToScanHistory, BROWSER_PROCESS_ENV, DATA_ROOT as SCAN_DATA_ROOT, loadSeenUrls, scanHistoryPolicy, seenJobKey, buildTitleFilter, buildLocationFilter, buildContentFilterOrExit, buildTitleGate, buildCountryEligibilityFilter, buildVisaFilter, loadCandidateCountry, matchedTitleKeywords, loadBlacklist, PORTALS_PATH, } from './scan.mjs'; import { normalizeCompany } from './tracker-utils.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { localToday } from './lib/local-today.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { htmlToText } from './providers/_html-to-text.mjs'; import { decodeEntities } from './providers/_html-entities.mjs'; import { BOT_CHALLENGE_PATTERNS } from './liveness-core.mjs'; import { validateFlags } from './lib/cli-flags.mjs'; import { browserLaunchOptions } from './lib/browser-launch.mjs'; // ── Constants ──────────────────────────────────────────────────────── export const ALLOWED_HOST = 'jobs.dayforcehcm.com'; const DEFAULT_BOARD_CODE = 'CANDIDATEPORTAL'; const DEFAULT_CULTURE = 'en-US'; const DEFAULT_JOB_BOARD_ID = '1'; const MAX_PAGES_PER_BOARD = 200; // 25/page observed → 5000 postings ceiling; a real cap, not a magic number const RETRY_DELAY_MS = 3000; const DATA_ROOT = getCareerOpsRoot(); // Alphanumeric + `_`/`-` only — same discipline as providers/_trust-validator.mjs // and the SSRF/injection guard every other provider in this codebase applies // before interpolating an operator-supplied value into a URL. const TENANT_RE = /^[A-Za-z0-9_-]+$/; const BOARD_CODE_RE = /^[A-Za-z0-9_-]+$/; const CULTURE_RE = /^[a-z]{2}-[A-Z]{2}$/; const JOB_BOARD_ID_RE = /^\d+$/; const JOB_POSTING_ID_RE = /^\d+$/; // Markup that only exists on Cloudflare's own interstitial challenge page. const INTERSTITIAL_SELECTORS = ['#challenge-form', '#challenge-stage']; // Widgets a normal page can embed, with the field each one fills in once // solved. A widget only blocks while it's visible and that field is empty: // invisible/non-interactive Turnstile, or one already completed, does not. const CHALLENGE_WIDGETS = [ { selector: '.cf-turnstile', response: 'cf-turnstile-response' }, { selector: '.h-captcha', response: 'h-captcha-response' }, ]; // ── Challenge detection ────────────────────────────────────────────── /** * The board answered with an interactive challenge instead of its listings, * whether goto() settled on it or timed out on it. * Never retriable: a fresh session would just meet the same wall, and working * around it is exactly what the "No bypass" rule forbids. */ export class DayforceChallengeError extends Error { /** @param {string} tenant @param {string} board @param {string} signal */ constructor(tenant, board, signal) { super(`board ${tenant}/${board} is behind an interactive challenge (${signal}) — skipped, not retried`); this.name = 'DayforceChallengeError'; this.tenant = tenant; this.retriable = false; } } /** * Inspect the settled page for an interactive challenge. Runs after * `waitUntil: 'networkidle'`, so a non-interactive JS check that clears on * its own has already navigated to the real board and is not reported. * * @param {import('playwright').Page} page * @returns {Promise} the matching signal, or null for a normal board */ export async function detectChallenge(page) { const title = await page.title().catch(() => ''); if (BOT_CHALLENGE_PATTERNS.some(re => re.test(title))) return `title "${title}"`; for (const selector of INTERSTITIAL_SELECTORS) { const count = await page.locator(selector).count().catch(() => 0); if (count > 0) return `element ${selector}`; } for (const { selector, response } of CHALLENGE_WIDGETS) { const widgets = page.locator(selector); const count = await widgets.count().catch(() => 0); for (let i = 0; i < count; i++) { const widget = widgets.nth(i); if (!(await widget.isVisible().catch(() => false))) continue; const token = await widget .evaluate((el, name) => el.querySelector(`[name="${name}"]`)?.value ?? '', response) .catch(() => ''); if (!token) return `unsolved ${selector} widget`; } } return null; } // ── Validation ─────────────────────────────────────────────────────── /** @param {unknown} v */ export function validateTenant(v) { return typeof v === 'string' && TENANT_RE.test(v); } /** @param {unknown} v */ export function validateBoardCode(v) { return typeof v === 'string' && BOARD_CODE_RE.test(v); } /** @param {unknown} v */ export function validateCulture(v) { return typeof v === 'string' && CULTURE_RE.test(v); } /** @param {unknown} v */ export function validateJobBoardId(v) { return typeof v === 'string' && JOB_BOARD_ID_RE.test(v); } /** @param {unknown} v */ export function validateJobPostingId(v) { const s = typeof v === 'number' ? String(v) : v; return typeof s === 'string' && JOB_POSTING_ID_RE.test(s); } /** * Validate and normalize one portals.yml `dayforce_boards` entry. * Returns null (never throws) for a malformed entry so one bad row does not * abort the whole scan — the caller logs and skips it. * * @param {any} entry * @returns {{ name: string, tenant: string, board: string, culture: string, jobBoardId: string } | null} */ export function normalizeBoardEntry(entry) { if (!entry || typeof entry !== 'object') return null; const name = typeof entry.name === 'string' ? entry.name.trim() : ''; const tenant = entry.tenant; const board = entry.board || entry.jobBoardCode || DEFAULT_BOARD_CODE; const culture = entry.culture || DEFAULT_CULTURE; const jobBoardId = entry.jobBoardId !== undefined && entry.jobBoardId !== null ? String(entry.jobBoardId) : DEFAULT_JOB_BOARD_ID; if (!name) return null; if (!validateTenant(tenant)) return null; if (!validateBoardCode(board)) return null; if (!validateCulture(culture)) return null; if (!validateJobBoardId(jobBoardId)) return null; return { name, tenant, board, culture, jobBoardId }; } /** * Parse a URL and assert it is exactly https://jobs.dayforcehcm.com — pins * the host so a redirect or a crafted response field can never send a * request (or a job link handed to the user) to another host. * * Exported for tests; every URL builder below routes through it. * * @param {string} url */ export function assertDayforceUrl(url) { const parsed = new URL(url); if (parsed.protocol !== 'https:' || parsed.hostname !== ALLOWED_HOST) { throw new Error(`Refusing non-Dayforce URL: ${url}`); } return parsed; } // ── URL builders ───────────────────────────────────────────────────── /** @param {string} culture @param {string} tenant @param {string} board */ export function buildBoardUrl(culture, tenant, board) { const url = `https://${ALLOWED_HOST}/${culture}/${tenant}/${board}`; assertDayforceUrl(url); return url; } /** @param {string} culture @param {string} tenant @param {string} board @param {string|number} jobPostingId */ export function buildJobUrl(culture, tenant, board, jobPostingId) { const url = `https://${ALLOWED_HOST}/${culture}/${tenant}/${board}/jobs/${jobPostingId}`; assertDayforceUrl(url); return url; } export function buildCsrfUrl() { return `https://${ALLOWED_HOST}/api/auth/csrf`; } /** @param {string} tenant */ export function buildSearchUrl(tenant) { const url = `https://${ALLOWED_HOST}/api/geo/${tenant}/jobposting/search`; assertDayforceUrl(url); return url; } /** @param {string} tenant @param {string} culture @param {string} jobBoardId @param {string|number} jobPostingId */ export function buildDetailUrl(tenant, culture, jobBoardId, jobPostingId) { const url = `https://${ALLOWED_HOST}/api/geo/${tenant}/jobposting/${tenant}/${culture}/${jobBoardId}/${jobPostingId}`; assertDayforceUrl(url); return url; } // ── Pagination ─────────────────────────────────────────────────────── /** * Compute the next `paginationStart`, or null when the board is exhausted. * Always advances by the *returned* offset+count (never a hardcoded step), * per the issue spec — the list endpoint's own accounting is the only * trustworthy stop condition. * * @param {number} offset * @param {number} count * @param {number} maxCount * @returns {number | null} */ export function nextPaginationStart(offset, count, maxCount) { if (!Number.isFinite(offset) || !Number.isFinite(count) || !Number.isFinite(maxCount)) return null; if (count <= 0) return null; // empty/short page — nothing more to fetch const next = offset + count; if (next >= maxCount) return null; return next; } // ── Location / description shaping ────────────────────────────────── /** * Join postingLocations[] into one display string. Prefers each location's * formattedAddress; falls back to [city, state, country] when absent (either * field can legitimately be missing per-location, not just board-wide). * * @param {any[] | undefined} postingLocations * @returns {string} */ export function joinLocations(postingLocations) { if (!Array.isArray(postingLocations) || postingLocations.length === 0) return ''; const parts = postingLocations.map(loc => { if (!loc || typeof loc !== 'object') return ''; if (typeof loc.formattedAddress === 'string' && loc.formattedAddress.trim()) { return loc.formattedAddress.trim(); } return [loc.cityName, loc.stateCode, loc.isoCountryCode].filter(Boolean).join(', '); }).filter(Boolean); return parts.join(' | '); } /** * Concatenate header→body→footer JD content. Any of the three can * legitimately be empty — don't assume all are populated. Runs the shared * htmlToText pipeline once over the joined text (safe on plain-text pieces * too: no tags to strip, entities still decode, whitespace still collapses). * * @param {{ jobDescriptionHeader?: string, jobDescription?: string, jobDescriptionFooter?: string } | undefined} content * @returns {string} */ export function buildJobDescriptionText(content) { if (!content || typeof content !== 'object') return ''; const pieces = [content.jobDescriptionHeader, content.jobDescription, content.jobDescriptionFooter] .filter(p => typeof p === 'string' && p.trim()); return htmlToText(pieces.join('\n\n')); } // ── Args ───────────────────────────────────────────────────────────── /** * Parse CLI options at execution time so importing this module never reads the * caller's argv or exits its process. * * @param {string[]} [argv] */ export function parseArgs(argv = process.argv.slice(2)) { const tenantIdx = argv.indexOf('--tenant'); if (tenantIdx !== -1 && (argv[tenantIdx + 1] === undefined || argv[tenantIdx + 1].startsWith('--'))) { throw new Error('--tenant requires a value, e.g. --tenant gnghcm'); } return { dryRun: argv.includes('--dry-run'), debug: argv.includes('--debug'), singleTenant: tenantIdx !== -1 ? argv[tenantIdx + 1] : null, }; } // ── Load portals.yml ───────────────────────────────────────────────── /** * Read user configuration only when the CLI actually runs. Keeping this out * of module initialization makes the exported pure helpers safe to import in * tests and from other tooling, regardless of the caller's user-layer files. * * @param {string} [portalsPath] * @param {string|null} [singleTenant] */ export function loadConfig(portalsPath = PORTALS_PATH, singleTenant = null) { let config = {}; if (existsSync(portalsPath)) { config = loadYaml(readFileSync(portalsPath, 'utf-8')) || {}; } const rawBoards = Array.isArray(config.dayforce_boards) ? config.dayforce_boards : []; const normalizedBoards = rawBoards.map(normalizeBoardEntry); const invalidCount = normalizedBoards.filter(board => !board).length; const blacklist = loadBlacklist(); const validBoards = normalizedBoards.filter(Boolean) .filter(board => !blacklist.has(normalizeCompany(board.name))); const boards = validBoards.filter(b => !singleTenant || b.tenant === singleTenant); const titleFilter = buildTitleFilter(config.title_filter); const contentFilter = buildContentFilterOrExit(config.content_filter); const filters = { titleFilter, // #6020. Same title decision as scan.mjs, so rescue_title_misses means // the same thing here. titleGate: buildTitleGate(config.title_filter, contentFilter, titleFilter), locationFilter: buildLocationFilter(config.location_filter), contentFilter, countryEligibilityFilter: buildCountryEligibilityFilter( config.country_eligibility_filter, loadCandidateCountry(), ), visaFilter: buildVisaFilter(config.visa_filter), matchedTitleKeywords: (title) => matchedTitleKeywords(title, config.title_filter), }; // The recheck window scan.mjs applies to the same seen set. return { boards, invalidCount, filters, historyPolicy: scanHistoryPolicy(config) }; } // ── Fetch helpers (all run through page.request — same context, same cookies) ── /** * @param {import('playwright').Page} page */ async function fetchCsrfToken(page) { const resp = await page.request.get(buildCsrfUrl(), { maxRedirects: 0 }); if (!resp.ok()) throw new Error(`csrf fetch failed: HTTP ${resp.status()}`); const json = await resp.json(); if (!json || typeof json.csrfToken !== 'string' || !json.csrfToken) { throw new Error('csrf response missing csrfToken'); } return json.csrfToken; } /** * @param {import('playwright').Page} page * @param {{ tenant: string, board: string, culture: string }} boardCfg * @param {string} csrfToken * @param {number} paginationStart */ async function fetchSearchPage(page, boardCfg, csrfToken, paginationStart) { const resp = await page.request.post(buildSearchUrl(boardCfg.tenant), { maxRedirects: 0, headers: { 'X-CSRF-TOKEN': csrfToken, 'Content-Type': 'application/json' }, data: { clientNamespace: boardCfg.tenant, jobBoardCode: boardCfg.board, cultureCode: boardCfg.culture, distanceUnit: 0, paginationStart, }, }); if (resp.status() === 403 || resp.status() === 429) { const err = new Error(`search HTTP ${resp.status()}`); // @ts-ignore err.retriable = true; throw err; } if (!resp.ok()) throw new Error(`search fetch failed: HTTP ${resp.status()}`); return resp.json(); } /** * @param {import('playwright').Page} page * @param {{ tenant: string, culture: string, jobBoardId: string }} boardCfg * @param {string|number} jobPostingId */ async function fetchDetail(page, boardCfg, jobPostingId) { const resp = await page.request.get(buildDetailUrl(boardCfg.tenant, boardCfg.culture, boardCfg.jobBoardId, jobPostingId), { maxRedirects: 0 }); if (resp.status() === 404 || resp.status() === 410) return null; // closed between list and detail — skip, don't abort if (resp.status() === 403 || resp.status() === 429) { const err = new Error(`detail HTTP ${resp.status()}`); // @ts-ignore err.retriable = true; throw err; } if (!resp.ok()) throw new Error(`detail fetch failed: HTTP ${resp.status()}`); return resp.json(); } // ── Per-board scan ─────────────────────────────────────────────────── /** * @param {import('playwright').Page} page * @param {{ name: string, tenant: string, board: string, culture: string, jobBoardId: string }} boardCfg * @param {{ titleFilter: Function, titleGate?: Function, locationFilter: Function, contentFilter: Function, countryEligibilityFilter: Function, visaFilter: Function, matchedTitleKeywords: Function }} [filters] * @param {{ debug?: boolean }} [options] */ export async function scanBoard(page, boardCfg, filters = {}, { debug = false } = {}) { filters = { titleFilter: () => true, locationFilter: () => true, contentFilter: () => true, countryEligibilityFilter: () => true, visaFilter: () => true, matchedTitleKeywords: () => [], ...filters, }; // Without a titleGate (callers that predate #6020), the title decision is // titleFilter alone: no posting is rescued. const titleGate = filters.titleGate ?? ((t) => (filters.titleFilter(t) ? 'match' : 'reject')); const found = []; const boardUrl = buildBoardUrl(boardCfg.culture, boardCfg.tenant, boardCfg.board); try { await page.goto(boardUrl, { waitUntil: 'networkidle', timeout: 30000 }); } catch (err) { // A live challenge page keeps polling Cloudflare, so the network never // goes idle and goto times out (seen on real "Just a moment..." and // Turnstile pages). Look at what's on screen before reporting the timeout. const challenge = await detectChallenge(page); if (challenge) throw new DayforceChallengeError(boardCfg.tenant, boardCfg.board, challenge); throw err; } if (debug) { const debugDir = join(DATA_ROOT, 'output'); mkdirSync(debugDir, { recursive: true }); const debugPng = join(debugDir, `debug-dayforce-${boardCfg.tenant}.png`); await page.screenshot({ path: debugPng, fullPage: true }).catch(() => null); console.log(` [debug] screenshot → ${debugPng}`); console.log(` [debug] url: ${page.url()}`); } const challenge = await detectChallenge(page); if (challenge) throw new DayforceChallengeError(boardCfg.tenant, boardCfg.board, challenge); const csrfToken = await fetchCsrfToken(page); let paginationStart = 0; let pageCount = 0; const listRows = []; while (pageCount < MAX_PAGES_PER_BOARD) { const data = await fetchSearchPage(page, boardCfg, csrfToken, paginationStart); const rows = Array.isArray(data.jobPostings) ? data.jobPostings : []; listRows.push(...rows); pageCount++; const next = nextPaginationStart(Number(data.offset), Number(data.count), Number(data.maxCount)); if (next === null) break; paginationStart = next; } if (debug) console.log(` [debug] ${boardCfg.tenant}: ${listRows.length} listing row(s) across ${pageCount} page(s)`); // Filter pass on list-level data only — zero extra requests for anything // that would be filtered out anyway (explicit design requirement, #3726). const shortlisted = []; const titleSkipped = []; const locationSkipped = []; const contentSkipped = []; const countryEligibilitySkipped = []; const visaSkipped = []; for (const row of listRows) { // Titles arrive HTML-entity-escaped (e.g. "Strategy & Acquisition") — // decode without stripMarkup's tag handling, since a title is plain text. const title = decodeEntities(String(row.jobTitle || row.title || '')).trim(); const jobPostingId = row.jobPostingId ?? row.id; if (!title || !validateJobPostingId(jobPostingId)) continue; const listLocation = joinLocations(row.postingLocations); const listDescription = htmlToText(row.jobDescription || ''); const url = buildJobUrl(boardCfg.culture, boardCfg.tenant, boardCfg.board, jobPostingId); const skipped = { title, url, location: listLocation }; // #6020. A rescued title miss passes only on its list description, and a // failure there is booked as a title skip, as scan.mjs books it. A filter // in between that drops it first books it as its own. const verdict = titleGate(title); if (verdict === 'reject') { titleSkipped.push(skipped); continue; } if (!filters.locationFilter(listLocation, url, title, { description: listDescription })) { locationSkipped.push(skipped); continue; } if (verdict === 'rescue') { if (!filters.contentFilter(listDescription, [], { titleMissed: true })) { titleSkipped.push(skipped); continue; } } else if (!filters.contentFilter(listDescription, filters.matchedTitleKeywords(title))) { contentSkipped.push(skipped); continue; } if (!filters.countryEligibilityFilter(listDescription)) { countryEligibilitySkipped.push(skipped); continue; } if (!filters.visaFilter(listDescription)) { visaSkipped.push(skipped); continue; } shortlisted.push({ jobPostingId, title, listLocation, listDescription, url }); } // Detail fetch only for the shortlist. for (const item of shortlisted) { const detail = await fetchDetail(page, boardCfg, item.jobPostingId); if (!detail) continue; // 404/410 — closed between list and detail const description = buildJobDescriptionText(detail.jobPostingContent) || item.listDescription; const location = joinLocations(detail.postingLocations) || item.listLocation; const title = decodeEntities(String(detail.jobTitle || item.title)); const detailPolicy = filters.locationFilter.workplacePolicyOnly; if (typeof detailPolicy === 'function' && !detailPolicy(location, item.url, title, { description })) { locationSkipped.push({ title, url: item.url, location }); continue; } const postedAt = detail.postingStartTimestampUTC ? Date.parse(detail.postingStartTimestampUTC) : undefined; found.push({ url: item.url, company: boardCfg.name, title, location, source: 'dayforce', description, postedAt: Number.isFinite(postedAt) ? postedAt : undefined, }); } return { found, titleSkipped, locationSkipped, contentSkipped, countryEligibilitySkipped, visaSkipped, totalFound: listRows.length, }; } // ── Per-board retry ────────────────────────────────────────────────── /** * Scan one board in a fresh browser context, retrying once with a new * context on a retriable (403/429) error. A non-retriable error — including * DayforceChallengeError — ends the board on the first attempt. * * `launch` and `retryDelayMs` are injectable for tests. * * @param {{ name: string, tenant: string, board: string, culture: string, jobBoardId: string }} boardCfg * @param {any} filters * @param {{ debug?: boolean, launch?: () => Promise, retryDelayMs?: number }} [options] * @returns {Promise<{ result: any, error: null } | { result: null, error: any }>} */ export async function scanBoardWithRetry(boardCfg, filters, { debug = false, launch = () => chromium.launch(browserLaunchOptions( { headless: true }, { projectRoot: SCAN_DATA_ROOT, processEnv: BROWSER_PROCESS_ENV }, )), retryDelayMs = RETRY_DELAY_MS, } = {}) { let lastError = null; for (let attempt = 1; attempt <= 2; attempt++) { // Browser startup sits inside the try so a launch failure is reported // for this board like any other error, instead of aborting the whole scan. let browser = null; let context = null; try { browser = await launch(); context = await browser.newContext(); const page = await context.newPage(); return { result: await scanBoard(page, boardCfg, filters, { debug }), error: null }; } catch (err) { lastError = err; // @ts-ignore if (!(err && err.retriable) || attempt === 2) break; if (debug) console.log(`\n [debug] ${boardCfg.tenant}: ${err.message} — re-bootstrapping session and retrying once`); await new Promise(r => setTimeout(r, retryDelayMs)); } finally { // Close only what was opened; a failing close must not hide the real error. await context?.close().catch(() => {}); await browser?.close().catch(() => {}); } } return { result: null, error: lastError }; } // ── Main ───────────────────────────────────────────────────────────── async function main() { const { dryRun, debug, singleTenant } = parseArgs(); mkdirSync(join(DATA_ROOT, 'data'), { recursive: true }); const { boards, invalidCount, filters, historyPolicy } = loadConfig(PORTALS_PATH, singleTenant); if (invalidCount > 0) { console.log(` Skipping ${invalidCount} malformed dayforce_boards entr${invalidCount === 1 ? 'y' : 'ies'} (bad tenant/board/culture/jobBoardId)`); } if (boards.length === 0) { console.log(' No dayforce_boards configured in portals.yml (or none matched --tenant). Nothing to scan.'); console.log(' Add a dayforce_boards: section — see scan-dayforce.mjs header comment for the shape.'); return; } const { seen } = loadSeenUrls(historyPolicy); const date = localToday(); let totalFound = 0; const newOffers = []; const titleSkipped = []; const locationSkipped = []; const contentSkipped = []; const countryEligibilitySkipped = []; const visaSkipped = []; const dupeSkipped = []; const errors = []; for (const boardCfg of boards) { process.stdout.write(` Scanning ${boardCfg.tenant}/${boardCfg.board}... `); const { result, error } = await scanBoardWithRetry(boardCfg, filters, { debug }); if (!result) { errors.push({ tenant: boardCfg.tenant, error: error?.message ?? String(error) }); process.stdout.write(error instanceof DayforceChallengeError ? 'SKIPPED (interactive challenge)\n' : 'ERROR\n'); continue; } totalFound += result.totalFound; process.stdout.write(`${result.totalFound} found, ${result.found.length} passed filters\n`); for (const skip of result.titleSkipped) { seen.add(seenJobKey(skip.url)); titleSkipped.push({ ...skip, source: 'dayforce', company: boardCfg.name }); } for (const skip of result.locationSkipped) { seen.add(seenJobKey(skip.url)); locationSkipped.push({ ...skip, source: 'dayforce', company: boardCfg.name }); } for (const skip of result.contentSkipped) { seen.add(seenJobKey(skip.url)); contentSkipped.push({ ...skip, source: 'dayforce', company: boardCfg.name }); } for (const skip of result.countryEligibilitySkipped) { seen.add(seenJobKey(skip.url)); countryEligibilitySkipped.push({ ...skip, source: 'dayforce', company: boardCfg.name }); } for (const skip of result.visaSkipped) { seen.add(seenJobKey(skip.url)); visaSkipped.push({ ...skip, source: 'dayforce', company: boardCfg.name }); } for (const offer of result.found) { const key = seenJobKey(offer.url); if (seen.has(key)) { dupeSkipped.push(offer); continue; } seen.add(key); newOffers.push(offer); } } if (!dryRun) { if (newOffers.length > 0) await appendToPipeline(newOffers); if (newOffers.length > 0) await appendToScanHistory(newOffers, date, 'added'); // Every run hands the same skipped postings back, so each is recorded // once per status, not once per run (#2695, as scan.mjs records its own). await appendUnrecordedToScanHistory(titleSkipped, date, 'skipped_title'); await appendUnrecordedToScanHistory(locationSkipped, date, 'skipped_location'); await appendUnrecordedToScanHistory(contentSkipped, date, 'skipped_content'); await appendUnrecordedToScanHistory(countryEligibilitySkipped, date, 'skipped_country_eligibility'); await appendUnrecordedToScanHistory(visaSkipped, date, 'skipped_visa'); await appendUnrecordedToScanHistory(dupeSkipped, date, 'skipped_dup'); } console.log(`\n${'━'.repeat(45)}`); console.log(`Dayforce Scan — ${date}`); console.log(`${'━'.repeat(45)}`); console.log(`Boards scanned: ${boards.length}`); console.log(`Total found: ${totalFound}`); console.log(`Filtered by title: ${titleSkipped.length}`); console.log(`Filtered location: ${locationSkipped.length}`); console.log(`Filtered content: ${contentSkipped.length}`); console.log(`Country ineligible: ${countryEligibilitySkipped.length}`); console.log(`Filtered by visa: ${visaSkipped.length}`); console.log(`Duplicates: ${dupeSkipped.length}`); console.log(`New offers: ${newOffers.length}`); if (errors.length > 0) { console.log(`\nErrors (${errors.length}):`); for (const e of errors) console.log(` ✗ ${e.tenant}: ${e.error}`); } if (newOffers.length > 0) { console.log('\nNew offers:'); for (const o of newOffers) { console.log(` + ${o.company} | ${o.title} | ${o.location || 'N/A'}`); } if (dryRun) { console.log('\n(dry run — not saved)'); } else { console.log('\nSaved to data/pipeline.md'); } } console.log('\n→ Run /career-ops pipeline to evaluate new offers.'); } // Guarded like every sibling scanner (scan-hn.mjs, scan-interamt.mjs, // scan-ats-full.mjs) — merely importing this module must not drive a live // browser scan and mutate the user's pipeline/scan history as a side effect // of the import (#3510). const KNOWN_FLAGS = ['--dry-run', '--debug', '--tenant', '--help', '-h']; const USAGE = `Usage: node scan-dayforce.mjs [--tenant NAME] [--dry-run] [--debug] Scans Dayforce (Ceridian) Recruiting career sites listed under dayforce_boards in portals.yml. jobs.dayforcehcm.com is Cloudflare and NextAuth CSRF gated with no bare-HTTP-reachable API, so this drives a real browser via Playwright. --tenant NAME only this tenant, instead of every enabled board --dry-run scan and report, write nothing to the pipeline or history --debug keep the browser visible and log each navigation --help, -h show this message `; if (isMainModule(import.meta.url)) { // No `valueFlags` for --tenant, deliberately. Declaring it would accept // `--tenant=gnghcm`, and parseArgs() reads the value by `indexOf('--tenant')` // plus the next argv slot — so the `=` form is silently ignored and the run // scans every board instead of the one asked for. A tenant name never starts // with `-`, which is the only case valueFlags exists to protect, so the space // form needs nothing here. // // It also keeps parseArgs()'s own message for a missing value ("--tenant // requires a value, e.g. --tenant gnghcm"), which says more than // requireOperand's generic line — the trade lib/cli-flags.mjs documents. validateFlags(process.argv.slice(2), KNOWN_FLAGS, USAGE); main().catch(err => { console.error('Fatal:', err.message); process.exit(1); }); }