coverage-check.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 <url> --company <name> --title <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();