#!/usr/bin/env node
/**
* scan-ats-full.mjs — Reverse ATS discovery scanner. Part of #230.
*
* Where scan.mjs scans the companies you track in portals.yml, this script
* inverts the direction: it walks public directories of companies per ATS
* (Greenhouse, Lever, Ashby, Workday) and surfaces fresh postings that match
* your portals.yml `title_filter` / `location_filter` — no manual company
* curation needed.
*
* Company directories come from the public job-board-aggregator dataset
* (github.com/Feashliaa/job-board-aggregator), cached in data/cache/ for 24h.
*
* Zero LLM tokens — pure HTTP + JSON, same providers/ modules as scan.mjs.
* Postings without a usable publish date are skipped: a reverse scan is only
* useful for fresh postings, and stale results would flood the pipeline.
*
* Usage:
* node scan-ats-full.mjs # scan all ATS directories, last 3 days
* node scan-ats-full.mjs --since 7 # postings from the last 7 days
* node scan-ats-full.mjs --ats greenhouse,workday # subset of sources
* node scan-ats-full.mjs --limit 200 # max companies per ATS (default: all)
* node scan-ats-full.mjs --dry-run # preview without writing files
* node scan-ats-full.mjs --liveness # Playwright-verify matches before writing
* node scan-ats-full.mjs --verbose # log per-board fetch failures
* node scan-ats-full.mjs --md-out
# also write a dated markdown digest to
* node scan-ats-full.mjs --help # print this usage block and exit
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
import { pathToFileURL } from 'url';
import path from 'path';
import yaml from 'js-yaml';
import { makeHttpCtx, fetchJson } from './providers/_http.mjs';
import greenhouse from './providers/greenhouse.mjs';
import lever from './providers/lever.mjs';
import ashby from './providers/ashby.mjs';
import workday from './providers/workday.mjs';
import { buildTitleFilter, buildLocationFilter, loadSeenUrls, appendToPipeline, appendToScanHistory } from './scan.mjs';
import { SEED_SOURCES, toPortalEntry } from './seeds/vc-portfolios.mjs';
// ── Config ──────────────────────────────────────────────────────────
const PORTALS_PATH = process.env.CAREER_OPS_PORTALS || 'portals.yml';
const PIPELINE_PATH = 'data/pipeline.md';
const CACHE_DIR = 'data/cache/ats-companies';
const CACHE_TTL_HOURS = 24;
// Tracks `main` deliberately: the dataset's value is freshness (new boards
// appear weekly), so pinning a commit would defeat the purpose. Integrity rests
// on two layers instead: SLUG_RE validates every entry against a safe charset
// before interpolation, and entryOnHost (below) re-parses each finished
// careers_url and drops anything that doesn't resolve to the ATS's own host —
// so a tampered dataset can at worst name boards that don't exist.
const DATASET_BASE = 'https://raw.githubusercontent.com/Feashliaa/job-board-aggregator/main/data';
const CONCURRENCY = 20;
// Dataset entries are external input destined for URL interpolation — reject
// anything outside a conservative slug charset.
const SLUG_RE = /^[A-Za-z0-9._-]+$/;
// SSRF guard / defense in depth: confirm a constructed careers_url actually
// resolves to the expected ATS host before it reaches provider.fetch. Returns
// the synthetic entry, or null if the URL won't parse or the host isn't canonical.
export function entryOnHost(name, careersUrl, isCanonicalHost) {
let hostname;
try {
({ hostname } = new URL(careersUrl));
} catch {
return null;
}
return isCanonicalHost(hostname) ? { name, careers_url: careersUrl } : null;
}
// Each source: the provider module that does the fetching, plus how to turn a
// dataset entry into a synthetic PortalEntry the provider can detect/fetch.
const SOURCES = {
greenhouse: {
provider: greenhouse,
dataset: `${DATASET_BASE}/greenhouse_companies.json`,
toEntry: (slug) => SLUG_RE.test(String(slug))
? entryOnHost(String(slug), `https://job-boards.greenhouse.io/${slug}`, h => h === 'job-boards.greenhouse.io')
: null,
},
lever: {
provider: lever,
dataset: `${DATASET_BASE}/lever_companies.json`,
toEntry: (slug) => SLUG_RE.test(String(slug))
? entryOnHost(String(slug), `https://jobs.lever.co/${slug}`, h => h === 'jobs.lever.co')
: null,
},
ashby: {
provider: ashby,
dataset: `${DATASET_BASE}/ashby_companies.json`,
toEntry: (slug) => SLUG_RE.test(String(slug))
? entryOnHost(String(slug), `https://jobs.ashbyhq.com/${slug}`, h => h === 'jobs.ashbyhq.com')
: null,
},
workday: {
provider: workday,
dataset: `${DATASET_BASE}/workday_companies.json`,
// Dataset entries are "tenant|instance|site" triples.
toEntry: (line) => {
const [tenant, instance, site] = String(line).split('|');
if (![tenant, instance, site].every(p => p && SLUG_RE.test(p))) return null;
return entryOnHost(
tenant,
`https://${tenant}.${instance}.myworkdayjobs.com/${site}`,
h => h === `${tenant}.${instance}.myworkdayjobs.com` && h.endsWith('.myworkdayjobs.com'),
);
},
},
};
// ── CLI args ────────────────────────────────────────────────────────
const KNOWN_FLAGS = [
'--since', '--limit', '--ats', '--seeds', '--dry-run', '--liveness',
'--verbose', '--md-out', '--json', '--include-undated', '--shuffle',
'--help', '-h',
];
// Flags that consume the next argv token as a value (space-separated form —
// the `--flag=value` form is self-contained and never needs this).
const VALUE_FLAGS = ['--since', '--limit', '--ats', '--seeds', '--md-out'];
const USAGE = `Usage:
node scan-ats-full.mjs # scan all ATS directories, last 3 days
node scan-ats-full.mjs --since 7 # postings from the last 7 days
node scan-ats-full.mjs --ats greenhouse,workday # subset of sources
node scan-ats-full.mjs --limit 200 # max companies per ATS (default: all)
node scan-ats-full.mjs --dry-run # preview without writing files
node scan-ats-full.mjs --liveness # Playwright-verify matches before writing
node scan-ats-full.mjs --verbose # log per-board fetch failures
node scan-ats-full.mjs --md-out # also write a dated markdown digest to
node scan-ats-full.mjs --help # print this usage block and exit`;
function parseArgs(argv) {
const args = argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(USAGE);
process.exit(0);
}
// A value-taking flag's space-separated value (e.g. the `-tmp` in
// `--md-out -tmp`) must not be mistaken for an unrecognized flag just
// because it happens to start with `-`. Mirrors valueOf()'s own adjacency
// rule below so `--flag value` and `--flag=value` are validated consistently.
const consumedValueIndices = new Set();
args.forEach((a, idx) => {
if (VALUE_FLAGS.includes(a) && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')) {
consumedValueIndices.add(idx + 1);
}
});
const unknownFlags = args.filter((a, idx) =>
a.startsWith('-') && !consumedValueIndices.has(idx) && !KNOWN_FLAGS.includes(a.split('=')[0]));
if (unknownFlags.length) {
console.error(`Error: unrecognized flag(s): ${unknownFlags.join(', ')}. Valid flags: ${KNOWN_FLAGS.join(', ')}`);
process.exit(1);
}
const valueOf = (flag) => {
const idx = args.indexOf(flag);
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) return args[idx + 1];
const kv = args.find(a => a.startsWith(flag + '='));
return kv ? kv.split('=').slice(1).join('=') : null;
};
const sinceDays = Number(valueOf('--since')) || 3;
const limit = Number(valueOf('--limit')) || Infinity;
const atsArg = valueOf('--ats');
// --seeds: optional comma-separated VC portfolio sources (e.g. yc,a16z).
// When set, the seed companies are fetched and probed via the ATS providers
// instead of (or in addition to) the regular ATS directory walk.
const seedsArg = valueOf('--seeds');
const seeds = seedsArg
? seedsArg.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
: [];
const unknownSeeds = seeds.filter(s => !SEED_SOURCES[s]);
if (unknownSeeds.length) {
console.error(`Error: unknown seed source(s): ${unknownSeeds.join(', ')}. Valid: ${Object.keys(SEED_SOURCES).join(', ')}`);
process.exit(1);
}
// When --seeds is the only discovery flag (no --ats), default --ats to none
// so we don't also walk the full ATS directories unintentionally.
const ats = atsArg
? atsArg.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
: (seeds.length > 0 ? [] : Object.keys(SOURCES));
const unknown = ats.filter(a => !SOURCES[a]);
if (unknown.length) {
console.error(`Error: unknown ATS source(s): ${unknown.join(', ')}. Valid: ${Object.keys(SOURCES).join(', ')}`);
process.exit(1);
}
return {
sinceDays,
limit,
ats,
seeds,
dryRun: args.includes('--dry-run'),
liveness: args.includes('--liveness'),
verbose: args.includes('--verbose'),
mdOut: valueOf('--md-out'),
json: args.includes('--json'),
includeUndated: args.includes('--include-undated'),
shuffle: args.includes('--shuffle'),
};
}
// ── Company list cache (24h TTL) ────────────────────────────────────
// Returns { list, status } where status is:
// 'ok' — fresh fetch, or a cache entry still within TTL
// 'stale' — network fetch failed; falling back to an expired cache
// 'empty' — no data at all (no cache, fetch failed/non-array)
// The status lets callers (and --json) distinguish a degraded scan from an empty one.
async function loadCompanyList(name, url) {
mkdirSync(CACHE_DIR, { recursive: true });
const cacheFile = path.join(CACHE_DIR, `${name}.json`);
if (existsSync(cacheFile)) {
const ageHours = (Date.now() - statSync(cacheFile).mtimeMs) / 3_600_000;
if (ageHours < CACHE_TTL_HOURS) {
try { return { list: JSON.parse(readFileSync(cacheFile, 'utf-8')), status: 'ok' }; } catch { /* refetch below */ }
}
}
try {
const data = await fetchJson(url, { timeoutMs: 30_000 });
if (Array.isArray(data)) {
writeFileSync(cacheFile, JSON.stringify(data), 'utf-8');
return { list: data, status: 'ok' };
}
} catch (err) {
console.error(`⚠️ ${name}: could not download company list — ${err.message}`);
}
// Stale cache beats nothing.
if (existsSync(cacheFile)) {
try { return { list: JSON.parse(readFileSync(cacheFile, 'utf-8')), status: 'stale' }; } catch { /* fall through */ }
}
return { list: [], status: 'empty' };
}
// Date gate for one posting in a reverse (fresh-first) scan:
// 'stale' — dated, but older than the cutoff → always dropped
// 'undated' — no usable publish date → dropped by default, kept with --include-undated
// 'keep' — dated and within the window
export function classifyPostingDate(job, cutoff) {
if (job.postedAt && job.postedAt < cutoff) return 'stale';
if (!job.postedAt) return 'undated';
return 'keep';
}
// Cap-aware company sampling. Default: the dataset's natural (alphabetical)
// prefix. With --shuffle: a random sample of `limit` companies, so a capped
// scan isn't always biased to the same alphabetical-first slice. Pure; returns
// a new array and never mutates `list`.
export function sampleCompanies(list, limit, shuffle) {
if (!shuffle || limit >= list.length) return list.slice(0, limit);
const copy = list.slice();
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy.slice(0, limit);
}
// ── VC portfolio seed scan ──────────────────────────────────────────
// ATS providers that can auto-detect from a careers_url, in probe order.
// Workday is excluded: its URL format requires a tenant|instance|site triple
// that can't be derived from a portfolio slug alone.
const SEED_PROVIDERS = [greenhouse, lever, ashby];
/**
* Scan a VC portfolio seed source and return matching job offers.
* Companies are converted to PortalEntry shape, then each ATS provider's
* detect() is tried in order (greenhouse → lever → ashby). The first hit
* wins and its fetch() is called — identical to how portals.yml tracked
* companies flow through scan.mjs.
*
* @param {string} seedId Key from SEED_SOURCES (e.g. 'yc').
* @param {object} opts Parsed CLI options.
* @param {object} ctx HTTP context from makeHttpCtx().
* @param {Set} seenUrls Shared dedup set (mutated in place).
* @param {string} label Human-readable source label for logs.
* @returns {Promise