#!/usr/bin/env node // coverage-check.mjs — when the user brings in a posting the scanners did not // surface, say WHY, and propose the one change that would have caught it. // Zero-network: it reads portals.yml, data/scan-history.tsv, data/pipeline.md, // the tracker and the reverse-sweep company lists cached under // data/cache/ats-companies/, and loads the provider modules only to run their // offline detect(). // // It proposes, never edits portals.yml. The verdict is a closed set (GAPS), so // data/coverage-misses.tsv can be counted later to see which channel keeps // missing roles. Whether a miss is a one-off (an agency or aggregator posting, // an off-target role) is the caller's judgment, recorded with --note. import { appendFileSync, existsSync, readFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { loadYaml } from './lib/yaml-document.mjs'; import { buildLocationFilter, extractPipelineCompanyRole, seenJobKey, APPLICATIONS_PATH, PORTALS_PATH, SCAN_HISTORY_PATH, PIPELINE_PATH } from './scan.mjs'; import { buildTitleFilter } from './title-keywords.mjs'; import { loadProviders, resolveProvider } from './providers/_registry.mjs'; import { mergeProviderPlugins } from './plugins/_engine.mjs'; import { parseTrackerRow, resolveColumns } from './tracker-parse.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 { flagValue, validateFlags } from './lib/cli-flags.mjs'; import { scanHistoryDataLines } from './lib/scan-history-columns.mjs'; const CODE_ROOT = path.dirname(fileURLToPath(import.meta.url)); const DATA_ROOT = getCareerOpsRoot(); const DATASET_DIR = path.join(DATA_ROOT, 'data/cache/ats-companies'); export const MISSES_PATH = path.join(DATA_ROOT, 'data/coverage-misses.tsv'); const MISSES_HEADER = 'date\turl\tcompany\ttitle\tgap\tfix\tnote'; const KNOWN_FLAGS = ['--company', '--title', '--location', '--note', '--log', '--json', '--help', '-h']; const VALUE_FLAGS = ['--company', '--title', '--location', '--note']; const USAGE = `Usage: node coverage-check.mjs --company --title [--location <loc>] node coverage-check.mjs <url> ... --log [--note <text>] # also append to data/coverage-misses.tsv node coverage-check.mjs <url> ... --json # machine-readable verdict node coverage-check.mjs --help # show this message`; export const GAPS = { 'surfaced': 'a scanner already surfaced it', 'filtered': 'a scanner saw it and a filter dropped it', 'tracked-disabled': 'tracked, but the entry is disabled', 'tracked-no-provider': 'tracked, but no zero-token provider resolves the entry', 'tracked-title-filter': 'tracked, but title_filter drops this title', 'tracked-location-filter': 'tracked, but location_filter drops this location', 'tracked-not-seen': 'tracked and passes the filters, but no scan has seen it yet', 'untracked': 'company not in portals.yml', 'untracked-unsupported-ats': 'company not in portals.yml, and no provider reads its job site', }; // The job sites the reverse sweep (scan-ats-full.mjs) and the company-board // providers understand, keyed the way data/cache/ats-companies/*.json lists them. export function atsFromUrl(url) { let u; try { u = new URL(url); } catch { return { ats: null, host: null }; } const host = u.hostname.toLowerCase(); // A stray % in a pasted URL ("100%-remote") is not a valid escape and makes // decodeURIComponent throw; keep such a segment as written. const decode = (segment) => { try { return decodeURIComponent(segment); } catch { return segment; } }; const seg = u.pathname.split('/').filter(Boolean).map(decode); if (/(^|\.)greenhouse\.io$/.test(host)) { if (seg[0] && !['embed', 'v1'].includes(seg[0])) return { ats: 'greenhouse', slug: seg[0].toLowerCase(), host }; const forParam = u.searchParams.get('for'); if (forParam) return { ats: 'greenhouse', slug: forParam.toLowerCase(), host }; } if (/(^|\.)lever\.co$/.test(host) && seg[0]) return { ats: 'lever', slug: seg[0].toLowerCase(), host }; if (host === 'jobs.ashbyhq.com' && seg[0]) return { ats: 'ashby', slug: seg[0].toLowerCase(), host }; if (host === 'apply.workable.com' && seg[0]) return { ats: 'workable', slug: seg[0].toLowerCase(), host }; const wd = host.match(/^([^.]+)\.(wd\d+)\.myworkdayjobs\.com$/); if (wd) { // /{locale}/{site}/job/... or /{site}/job/...: the site is the segment before "job". const jobIdx = seg.indexOf('job'); const site = (jobIdx > 0 ? seg[jobIdx - 1] : seg.find(s => !/^[a-z]{2}-[A-Z]{2}$/.test(s))) || ''; return { ats: 'workday', slug: `${wd[1]}|${wd[2]}|${site}`.toLowerCase(), host }; } const ic = host.match(/^(?:careers-)?([^.]+)\.icims\.com$/); if (ic) return { ats: 'icims', slug: ic[1].toLowerCase(), host }; // A custom careers domain that embeds Greenhouse: the board token is not in the URL. if (u.searchParams.has('gh_jid')) return { ats: 'greenhouse', slug: null, host }; return { ats: null, host }; } export function careersUrlFor({ ats, slug }) { if (!slug) return null; switch (ats) { case 'greenhouse': return `https://job-boards.greenhouse.io/${slug}`; case 'lever': return `https://jobs.lever.co/${slug}`; case 'ashby': return `https://jobs.ashbyhq.com/${slug}`; case 'workable': return `https://apply.workable.com/${slug}`; case 'icims': return `https://careers-${slug}.icims.com/jobs`; case 'workday': { const [tenant, wd, site] = slug.split('|'); return `https://${tenant}.${wd}.myworkdayjobs.com/${site}`; } default: return null; } } // "Acme (EU roles only)" is Acme: entry names often carry a note in parentheses. export const companyKey = (name) => normalizeCompany(String(name || '').replace(/\([^)]*\)/g, ' ')); // The boards an entry reads, as atsFromUrl() keys them. const entryBoards = (e) => ['careers_url', 'api'] .filter(field => typeof e[field] === 'string') .map(field => atsFromUrl(e[field])); // A board match wins over a name match, so an entry named like the company // but reading another board never shadows the entry that reads this one. export function findTrackedEntry(entries, { ats, slug, company }) { const key = companyKey(company); return (slug && entries.find(e => entryBoards(e).some(b => b.ats === ats && b.slug === slug))) || (key && entries.find(e => companyKey(e.name) === key)) || null; } const hostOf = (url) => { try { return new URL(url).hostname.toLowerCase().replace(/^www\./, ''); } catch { return null; } }; // Every posting on disk that names a URL: scan history, pipeline and tracker. function knownPostings(ctx) { const rows = [...ctx.history.values()].map(row => ({ url: row[0], company: row[4], source: 'scan history' })); for (const line of ctx.pipelineText.split('\n')) { const entry = extractPipelineCompanyRole(line); if (entry) rows.push({ url: entry.url, company: entry.company, source: 'pipeline' }); } const trackerLines = (ctx.trackerText || '').split('\n'); const colmap = resolveColumns(trackerLines); for (const line of trackerLines) { const row = parseTrackerRow(line, colmap); if (row?.url) rows.push({ url: row.url, company: row.company, source: 'tracker' }); } return rows; } // What else is on disk for the posting's host. Hosts match exactly, so tenants // on their own subdomains (acme.example.com, beta.example.com) don't count as // one site: without a public-suffix list, co.uk would look like a site too. function othersOnHost(posting, ctx) { const host = hostOf(posting.url); const self = seenJobKey(posting.url); const selfCompany = companyKey(posting.company); const postings = new Set(); const sources = new Set(); const companies = new Map(); for (const { url, company, source } of knownPostings(ctx)) { if (hostOf(url) !== host || seenJobKey(url) === self) continue; postings.add(seenJobKey(url)); sources.add(source); const key = companyKey(company); if (key && key !== selfCompany && !companies.has(key)) companies.set(key, String(company).trim()); } return { host, postings: postings.size, sources: [...sources], companies: [...companies.values()] }; } // A provider that already reads this site. detect() alone misses some: it knows // a careers page, and a posting URL can differ (Dover's /Acme/careers/<uuid> vs // /apply/Acme/<id>), so a provider named after a label of the host counts too. function siteProvider(posting, host, providers) { const resolved = resolveProvider({ name: posting.company, careers_url: posting.url }, providers); if (resolved && !resolved.error) return resolved.provider.id; const labels = host.split('.'); return [...providers.keys()].find(id => labels.includes(id)) || null; } const MAX_NAMED_COMPANIES = 5; // For a site no job-site rule recognizes: the host evidence, and whether a // provider for the site is the better fix than one company's portals.yml line. // A provider covers every company on a multi-tenant site at once, and only an // outside look at the site can say if it has a source a provider may read. function siteVerdict(posting, target, ctx) { if (target.ats || !target.host) return { provider: null, reads: '', count: '', lead: '' }; const { host, postings, sources, companies } = othersOnHost(posting, ctx); const provider = siteProvider(posting, host, ctx.providers); const reads = provider ? `provider ${provider} looks like it reads ${host}` : `no provider reads ${host}`; const named = companies.slice(0, MAX_NAMED_COMPANIES).join(', '); const more = companies.length > MAX_NAMED_COMPANIES ? `, +${companies.length - MAX_NAMED_COMPANIES} more` : ''; const count = postings > 0 ? `${postings} other posting${postings === 1 ? '' : 's'} on ${host} (${sources.join(', ')})${named ? `: ${named}${more}` : ''}` : ''; const others = companies.length === 1 ? '1 other company posts' : `${companies.length} other companies post`; const lead = companies.length && !provider ? `${others} on ${host} too: one provider could cover all of them, if the site has a public, no-auth source (providers/ADDING_A_PROVIDER.md). For "${posting.company}" alone: ` : ''; return { provider, reads, count, lead }; } /** * @param {{url: string, company: string, title: string, location?: string}} posting * @param {{config: any, entries: any[], providers: Map<string, any>, history: Map<string, string[]>, * historyCompanies: Map<string, Set<string>>, pipelineText: string, trackerText?: string, * datasets: Map<string, Set<string>>}} ctx */ export function diagnose(posting, ctx) { const { url, company, title, location } = posting; const evidence = []; const titleOk = buildTitleFilter(ctx.config.title_filter); const locationOk = location ? buildLocationFilter(ctx.config.location_filter)(location, url, title) : null; const target = atsFromUrl(url); const seen = ctx.history.get(seenJobKey(url)); if (seen) { const [, firstSeen, portal, , , status] = seen; evidence.push(`scan-history: ${status} on ${firstSeen} via ${portal}`); if (status === 'added') { const inPipeline = ctx.pipelineText.includes(url) || ctx.pipelineText.includes(seen[0]); evidence.push(inPipeline ? 'it is in data/pipeline.md' : 'not in data/pipeline.md any more (triaged or pruned)'); return { gap: 'surfaced', evidence, fix: 'none: the scanners have it' }; } const fix = status === 'skipped_title' ? 'title keyword: decide whether title_filter should match this title' : status === 'skipped_location' ? 'location: decide whether location_filter should keep this location' : `none, unless the ${status} decision was wrong`; return { gap: 'filtered', evidence, fix }; } const entry = findTrackedEntry(ctx.entries, { ...target, company }); if (entry) { evidence.push(`portals.yml entry "${entry.name}" (${entry.careers_url || entry.api || 'no careers_url'})`); // Matched by name only: the company may have moved ATS or run a second board. const boards = entryBoards(entry); const otherBoard = Boolean(target.ats && target.slug) && !boards.some(b => b.ats === target.ats && b.slug === target.slug); if (otherBoard) { const reads = boards.filter(b => b.ats).map(b => `${b.ats}:${b.slug ?? '?'}`).join(', '); evidence.push(`posting is on ${target.ats}:${target.slug}, entry reads ${reads || (entry.careers_url || entry.api || 'no board')}`); } if (entry.enabled === false) return { gap: 'tracked-disabled', evidence, fix: `re-enable "${entry.name}" if its board is live` }; const resolved = resolveProvider(entry, ctx.providers); const inactiveReason = resolved && !resolved.error ? resolved.provider.inactiveReason : undefined; if (resolved && !resolved.error) { evidence.push(`provider: ${resolved.provider.id}${inactiveReason ? ` (plugin inactive: ${inactiveReason})` : ''}`); } else { // Scanners with their own company lists (scan-hn.mjs, scan-interamt.mjs, ...) // show up only in scan-history, under their own portal names. const searches = new Set((ctx.config.search_queries || []).map(q => q.name)); const scanners = [...(ctx.historyCompanies.get(companyKey(company)) || [])].filter(p => !searches.has(p)); if (scanners.length === 0) { evidence.push(resolved?.error || `no provider detects ${entry.careers_url || entry.name}` + (entry.scan_method === 'websearch' ? ' (scan_method: websearch, so only a WebSearch pass covers it)' : '')); const better = careersUrlFor(target); if (better) { return { gap: 'tracked-no-provider', evidence, fix: `point "${entry.name}".careers_url at ${better} (the posting lives on ${target.ats})` }; } const site = siteVerdict(posting, target, ctx); if (site.count) evidence.push(site.count); return { gap: 'tracked-no-provider', evidence, fix: site.provider ? `point "${entry.name}".careers_url at its careers page on ${target.host}: provider ${site.provider} looks like it reads that site` : `${site.lead}none zero-token: no provider reads this site; WebSearch only`, }; } evidence.push(`no provider for the portals.yml entry, but scanned as ${scanners.join(', ')}`); } if (!titleOk(title)) return { gap: 'tracked-title-filter', evidence, fix: `title keyword: no title_filter keyword matches "${title}"` }; if (locationOk === false) return { gap: 'tracked-location-filter', evidence, fix: `location: location_filter drops "${location}"` }; // An enabled provider plugin that cannot run (missing key, failed // integrity check) resolves to a stub: the scanner never reads this board. if (inactiveReason) { return { gap: 'tracked-not-seen', evidence, fix: `activate the "${resolved.provider.id}" plugin: ${inactiveReason} (node doctor.mjs shows its setup)` }; } if (otherBoard) { return { gap: 'tracked-not-seen', evidence, fix: `the entry reads another board: point "${entry.name}".careers_url at ${careersUrlFor(target)}, or add that board as a second entry` }; } return { gap: 'tracked-not-seen', evidence, fix: 'none: newer than the last scan, or the provider did not return it (check its board once)' }; } // Untracked. Would anything that already runs have caught it? const seenVia = ctx.historyCompanies.get(companyKey(company)); if (seenVia) evidence.push(`company seen before via ${[...seenVia].join(', ')}`); if (target.ats && target.slug) { const list = ctx.datasets.get(target.ats); if (list) { evidence.push(list.has(target.slug) ? `in the reverse-sweep company list (${target.ats}: ${target.slug})` : `NOT in the reverse-sweep company list for ${target.ats} (${target.slug})`); const full = buildTitleFilter(ctx.config.title_filter_full || ctx.config.title_filter); evidence.push(`${ctx.config.title_filter_full ? 'title_filter_full' : 'title_filter'} ${full(title) ? 'keeps' : 'drops'} this title in the reverse sweep`); } } const wouldPass = titleOk(title); evidence.push(`as a tracked company, title_filter would ${wouldPass ? 'keep' : 'drop'} this title`); if (locationOk === false) evidence.push(`location_filter drops "${location}"`); const caveat = locationOk === false ? ' (location_filter would still drop this posting)' : ''; const careersUrl = careersUrlFor(target); if (!careersUrl) { const site = siteVerdict(posting, target, ctx); evidence.push(target.ats ? `${target.ats} posting on a custom domain, board token unknown` : site.reads || 'no provider reads this URL'); if (site.count) evidence.push(site.count); return { gap: 'untracked-unsupported-ats', evidence, fix: `${site.lead}add "${company}" with its careers page; if no provider resolves it, scan_method: websearch${caveat}`, }; } const resolves = resolveProvider({ name: company, careers_url: careersUrl }, ctx.providers); evidence.push(resolves && !resolves.error ? `proposed careers_url resolves to provider ${resolves.provider.id}` : 'proposed careers_url resolves to no provider'); const fix = wouldPass ? `add to tracked_companies: { name: "${company}", careers_url: ${careersUrl} }${caveat}` : `add the company only with a title keyword that matches "${title}", or not at all`; return { gap: 'untracked', evidence, fix }; } export function loadContext() { const config = loadYaml(readFileSync(PORTALS_PATH, 'utf8')) || {}; const entries = [...(config.tracked_companies || []), ...(config.job_boards || [])].filter(e => e && typeof e === 'object'); const history = new Map(); const historyCompanies = new Map(); if (existsSync(SCAN_HISTORY_PATH)) { for (const line of scanHistoryDataLines(readFileSync(SCAN_HISTORY_PATH, 'utf8'))) { const row = line.split('\t'); if (!row[0]) continue; history.set(seenJobKey(row[0]), row); const key = companyKey(row[4]); if (key) (historyCompanies.get(key) || historyCompanies.set(key, new Set()).get(key)).add(row[2]); } } const datasets = loadDatasets(DATASET_DIR); const pipelineText = existsSync(PIPELINE_PATH) ? readFileSync(PIPELINE_PATH, 'utf8') : ''; const trackerText = existsSync(APPLICATIONS_PATH) ? readFileSync(APPLICATIONS_PATH, 'utf8') : ''; return { config, entries, history, historyCompanies, datasets, pipelineText, trackerText }; } // The reverse sweep's company lists are optional evidence: a file truncated // by an interrupted sweep, or holding anything but an array, is skipped. export function loadDatasets(dir) { const datasets = new Map(); for (const ats of ['greenhouse', 'lever', 'ashby', 'workday', 'icims']) { const file = path.join(dir, `${ats}.json`); if (!existsSync(file)) continue; try { const list = JSON.parse(readFileSync(file, 'utf8')); if (Array.isArray(list)) datasets.set(ats, new Set(list.map(s => String(s).toLowerCase()))); } catch { /* unreadable: no reverse-sweep evidence for this ATS */ } } return datasets; } const clean = (s) => String(s ?? '').replace(/[\t\r\n]+/g, ' ').trim(); export function appendMiss(file, posting, result, note, date) { const existing = existsSync(file) ? readFileSync(file, 'utf8') : ''; const key = seenJobKey(posting.url); const logged = existing.split('\n').slice(1).some(l => { const url = l.split('\t')[1]; return Boolean(url) && seenJobKey(url) === key; }); if (logged) return false; const row = [date, posting.url, posting.company, posting.title, result.gap, result.fix, note].map(clean).join('\t'); // An unterminated last row (a hand edit) is closed first, or the new row fuses onto it. const lead = existing ? (existing.endsWith('\n') ? '' : '\n') : MISSES_HEADER + '\n'; appendFileSync(file, lead + row + '\n'); return true; } async function main() { const args = process.argv.slice(2); validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); const valueIdx = new Set(args.flatMap((a, i) => (VALUE_FLAGS.includes(a) ? [i + 1] : []))); const url = args.find((a, i) => !a.startsWith('-') && !valueIdx.has(i)); const posting = { url, company: flagValue(args, '--company') || '', title: flagValue(args, '--title') || '', location: flagValue(args, '--location') || '' }; if (!posting.url || !posting.company || !posting.title) { console.error(`Error: a URL, --company and --title are required\n\n${USAGE}`); process.exit(1); } if (!existsSync(PORTALS_PATH)) { console.error(`Error: portals.yml not found at ${PORTALS_PATH}. Run onboarding first - the check reads its title_filter and tracked_companies.`); process.exit(1); } const providers = await loadProviders(path.join(CODE_ROOT, 'providers')); // Same provider map scan.mjs builds (#4026): without the enabled provider // plugins, an entry the scanner reads through one is diagnosed as // tracked-no-provider. No-op when config/plugins.yml is absent. await mergeProviderPlugins(providers, { root: CODE_ROOT, dataRoot: DATA_ROOT }); const ctx = { ...loadContext(), providers }; const result = diagnose(posting, ctx); const log = args.includes('--log'); const logged = log && result.gap !== 'surfaced' ? appendMiss(MISSES_PATH, posting, result, flagValue(args, '--note') || '', localToday()) : false; if (args.includes('--json')) { console.log(JSON.stringify({ ...posting, ...result, meaning: GAPS[result.gap], logged }, null, 2)); return; } console.log(`${posting.company} | ${posting.title}`); console.log(`gap: ${result.gap} (${GAPS[result.gap]})`); for (const e of result.evidence) console.log(` - ${e}`); console.log(`fix: ${result.fix}`); if (log) console.log(logged ? `logged to ${path.relative(DATA_ROOT, MISSES_PATH)}` : 'not logged (already surfaced, or URL already in the log)'); } if (isMainModule(import.meta.url)) await main();