scan-dayforce.mjs
Rama main · réplica de lectura del repositorio original.
#!/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<string | null>} 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<any>, 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);
});
}