import { existsSync, readFileSync } from 'fs'; import path from 'path'; import { atsVendorOf } from './ats-vendor.mjs'; import { parseScanHistoryLine } from './lib/scan-history-columns.mjs'; import { detectColumns, extractCellUrl, isHeaderRow, isSeparatorRow, linkedReportNumbers, reportUrlForNumbers as reportUrlFromReports } from './tracker-parse.mjs'; import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs'; import { hireHiveTenantOf } from './providers/hirehive.mjs'; import { resolveHibobTenant } from './providers/hibob.mjs'; import { resolveBoard as resolveTalentlinkBoard } from './providers/talentlink.mjs'; import { resolveWorkspaceRoot } from './tracker-utils.mjs'; const WEB_URL_RE = /https?:\/\/[^\s|)>]+/i; function webUrlFrom(value) { const match = String(value ?? '').match(WEB_URL_RE); return match?.[0] ?? null; } /** * Convert a historical posting URL into the board-shaped URL expected by the * corresponding provider. Unknown vendors keep the original URL: they remain * visible in the derived seed set and become scannable automatically once a * provider with the same id exists. */ export function atsBoardUrlOf(rawUrl, vendor = atsVendorOf(rawUrl)) { let u; try { u = new URL(rawUrl); } catch { return null; } if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; const parts = u.pathname.split('/').filter(Boolean); if (vendor === 'greenhouse') { // Older Greenhouse postings use boards.greenhouse.io, but the current // provider detects the public boards API at job-boards.greenhouse.io. if (u.hostname.toLowerCase() === 'boards.greenhouse.io') { u.hostname = 'job-boards.greenhouse.io'; } if (parts[0]) u.pathname = `/${parts[0]}`; } else if (vendor === 'lever' || vendor === 'ashby' || vendor === 'smartrecruiters') { if (parts[0]) u.pathname = `/${parts[0]}`; } else if (vendor === 'workday') { const withoutLocale = /^[a-z]{2}-[A-Z]{2}$/.test(parts[0] || '') ? parts.slice(1) : parts; if (withoutLocale[0]) u.pathname = `/${withoutLocale[0]}`; } else if (vendor === 'successfactors') { const endpointAt = parts.findIndex((part) => /^(?:job|search|tile-search-results|services)$/i.test(part)); u.pathname = endpointAt > 0 ? `/${parts.slice(0, endpointAt).join('/')}` : '/'; } else if (vendor === 'icims' || vendor === 'bamboohr') { u.pathname = '/'; } else if (vendor === 'hibob') { // Seeds are sampled under --limit before detect() runs, so a host the // provider refuses is dropped here instead of taking a real board's slot. if (!resolveHibobTenant(rawUrl)) return null; u.pathname = '/'; } else if (vendor === 'hirehive') { // Seeds are sampled under --limit before detect() runs, so a host the // provider refuses is dropped here instead of taking a real board's slot. if (u.protocol !== 'https:' || !hireHiveTenantOf(u.hostname)) return null; u.pathname = '/'; } else if (vendor === 'talentlink') { // The site path (`/internal`, `/FO//components`) is part of the // board; only the page file (`details.html`, `index.html`) is dropped. // Seeds are sampled under --limit before detect() runs, so a URL the // provider refuses is dropped here instead of taking a real board's slot. const board = resolveTalentlinkBoard(rawUrl); if (!board) return null; u = new URL(board.boardDir); } u.search = ''; u.hash = ''; return u.href.replace(/\/$/, ''); } function seedOf(company, rawUrl, source) { const url = webUrlFrom(rawUrl); const vendor = atsVendorOf(url); if (!url || !vendor) return null; const careersUrl = atsBoardUrlOf(url, vendor); if (!careersUrl) return null; let host; try { host = new URL(careersUrl).hostname; } catch { return null; } return { company: String(company ?? '').trim() || host, vendor, careersUrl, source, }; } export function reportUrlForNumbers(reportNumbers, reportsRoot, accept = atsVendorOf) { return reportUrlFromReports(reportNumbers, reportsRoot, accept); } export function parseTrackerAtsSeeds(text, { reportsRoot } = {}) { if (typeof text !== 'string' || !text) return []; const lines = text.split(/\r?\n/); const columns = detectColumns(lines); if (!columns || columns.company == null) return []; const seeds = []; for (const line of lines) { if (!line.startsWith('|') || isHeaderRow(line) || isSeparatorRow(line)) continue; const cells = line.split('|').map((cell) => cell.trim()); // The canonical nine-column tracker has no URL column. Its Report cell // links to the evaluation report that owns the posting's **URL:** field. // Custom trackers may carry URL directly; prefer it but never scrape an // arbitrary Notes URL, which may be unrelated evidence. // The URL cell may be a markdown link (#3516): read its HREF through the // shared extractor rather than relying on WEB_URL_RE happening to stop // at the link's closing paren — the same contract every tracker reader // follows, and one regex edit away from breaking if left implicit. const directUrl = columns.url == null ? null : extractCellUrl(cells[columns.url]); const reportNumbers = linkedReportNumbers( columns.report == null ? '' : cells[columns.report], columns.notes == null ? '' : cells[columns.notes], ); const url = webUrlFrom(directUrl) || reportUrlForNumbers(reportNumbers, reportsRoot); const seed = seedOf(cells[columns.company], url, 'tracker'); if (seed) seeds.push(seed); } return seeds; } export function parseScanHistoryAtsSeeds(text) { if (typeof text !== 'string' || !text) return []; const seeds = []; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; const row = parseScanHistoryLine(line); if (row.url.trim().toLowerCase() === 'url') continue; const seed = seedOf(row.company, row.url, 'scan-history'); if (seed) seeds.push(seed); } return seeds; } function readIfPresent(file) { if (!file || !existsSync(file)) return ''; try { return readFileSync(file, 'utf-8'); } catch { return ''; } } /** * Read the user's tracker and scan history without writing either one. * Duplicate postings from the same board collapse to one provider fetch. */ export function loadHistoryAtsSeeds({ dataRoot = getCareerOpsRoot(), trackerPath = resolveTrackerPath(dataRoot), scanHistoryPath = process.env.CAREER_OPS_SCAN_HISTORY || path.join(dataRoot, 'data/scan-history.tsv'), } = {}) { const reportsRoot = path.join(resolveWorkspaceRoot(trackerPath), 'reports'); const combined = [ ...parseTrackerAtsSeeds(readIfPresent(trackerPath), { reportsRoot }), ...parseScanHistoryAtsSeeds(readIfPresent(scanHistoryPath)), ]; const unique = new Map(); for (const seed of combined) { const key = `${seed.vendor}\t${seed.careersUrl}`; if (!unique.has(key)) unique.set(key, seed); } return [...unique.values()]; }