Ctrl K
ComunidadGuías…

scan-dayforce.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 &amp; 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);
  });
}