#!/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, iCIMS, BambooHR, Paylocity, join.com,
* Workable) and surfaces fresh postings that match your portals.yml
* `title_filter` / `location_filter` — no manual company curation needed.
*
* Optional `title_filter_full` in portals.yml overrides `title_filter` for
* THIS scanner only, so the keywords tuned for scan.mjs's curated company
* list do not have to double as the filter for every public board. Absent,
* `title_filter` is used exactly as before.
*
* Optional `title_filter_overrides` broadens the title net further still,
* but scoped to specific companies (matched by slug) rather than the whole
* sweep — e.g. letting known university/college employers surface general
* campus-admin postings without loosening the net for everyone else. See
* scan.mjs's buildTitleFilterOverrides()/buildTitleFilterWithOverrides().
*
* Optional `domain_filter` in portals.yml gates whole BOARDS before any title
* is tested (#3105): a board with no domain-bearing posting is not this user's
* industry, and is skipped entirely. Absent — the default — every board is
* swept exactly as before. Not applied to --seeds: a VC portfolio is already a
* curated corpus, so gating it would be redundant.
*
* Company directories come from the public job-board-aggregator dataset
* (github.com/Feashliaa/job-board-aggregator), except join.com's, which is
* read from join.com's own company sitemap. Each is cached in data/cache/ for
* 24h. Workable has no directory: its public XML job feed carries every
* tenant's postings, so that source reads the feed once per run (about 140 MB
* compressed), keeps a copy, and on later runs downloads it again only when
* Workable says it changed (see workableFeedCache below and loadWorkableFeed
* in providers/workable.mjs).
*
* 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 --history-seeds --ats successfactors # scan history-derived boards
* 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 --include-blacklisted # audit: let data/blacklist.md matches through, annotated
* 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 --resume # continue an interrupted sweep from its checkpoint
* node scan-ats-full.mjs --help # print this usage block and exit
*/
import { captureWorkplaceEvidence } from './providers/_country.mjs';
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSync, createReadStream, createWriteStream } from 'fs';
import { finished } from 'node:stream/promises';
import { createGunzip, createGzip } from 'node:zlib';
import { createHash } from 'crypto';
import path from 'path';
import { fileURLToPath } from 'url';
import { loadYaml } from './lib/yaml-document.mjs';
import { renameSyncWithRetry } from './tracker-utils.mjs';
import { makeHttpCtx, fetchJson, fetchText, fetchTextHead, fetchTextStream } from './providers/_http.mjs';
import { withConnectionScope, withAbortScope } from './providers/_connection-scope.mjs';
import { isResolverFailure, dnsPacingStats } from './providers/_dns-cache.mjs';
import greenhouse from './providers/greenhouse.mjs';
import lever from './providers/lever.mjs';
import ashby from './providers/ashby.mjs';
import workday, { WORKDAY_TRUNCATED_REASON } from './providers/workday.mjs';
import icims from './providers/icims.mjs';
import bamboohr from './providers/bamboohr.mjs';
import paylocity, { boardUrl as paylocityBoardUrl } from './providers/paylocity.mjs';
import join, { parseCompanySitemap } from './providers/join.mjs';
import { loadWorkableFeed, WORKABLE_FEED_URL } from './providers/workable.mjs';
import { openProviderIdCache, PROVIDER_ID_CACHE_DIR } from './provider-id-cache.mjs';
import { decodeEntities } from './providers/_html-entities.mjs';
import { buildTitleFilter, buildTitleFilterOverrides, buildTitleFilterWithOverrides, buildLocationFilter, buildContentFilterOrExit, matchedTitleKeywords, loadSeenUrls, scanHistoryPolicy, seenJobKey, appendToPipeline, appendToScanHistory, findBlacklistEntry, loadBlacklist, parseSinceDays, BROWSER_PROCESS_ENV, DATA_ROOT as SCAN_DATA_ROOT, PORTALS_PATH, PIPELINE_PATH, SCAN_HISTORY_PATH } from './scan.mjs';
import { localToday } from './lib/local-today.mjs';
import { printScanSummaryHeader } from './lib/scan-summary-marker.mjs';
import { SEED_SOURCES, toPortalEntry } from './seeds/vc-portfolios.mjs';
import { validateFlags, flagValue, hasFlag } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { boardKey, loadDeadBoards, recordBoardFailure, recordBoardResult, saveDeadBoards, shouldSkipDeadBoard } from './dead-boards.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { barePrefixDomainKeywords, buildDomainFilter } from './title-keywords.mjs';
import { KNOWN_ATS_VENDORS } from './ats-vendor.mjs';
import { loadHistoryAtsSeeds } from './history-ats-seeds.mjs';
import { classifyUnknownHostSeeds, makeHeadProbe } from './branded-successfactors.mjs';
import { loadProviders } from './providers/_registry.mjs';
import { browserLaunchOptions } from './lib/browser-launch.mjs';
// ── Config ──────────────────────────────────────────────────────────
// PORTALS_PATH and PIPELINE_PATH are imported from scan.mjs rather than
// re-derived here (#3510). This file appends results through scan.mjs's
// appendToPipeline, so its own bare-relative copy meant it could create an empty
// data/pipeline.md in the cwd and then write the actual matches somewhere else.
// Its portals fallback had the same split: it honored CAREER_OPS_PORTALS but
// otherwise looked in the cwd instead of the data root.
const DATA_ROOT = getCareerOpsRoot();
const PROVIDERS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'providers');
const CACHE_DIR = path.join(DATA_ROOT, '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';
// Default fan-out. Correct for sources whose boards each live on their OWN
// host — workday is {tenant}.{instance}.myworkdayjobs.com, so 20 in flight is
// 20 different servers and the resolver, not any one vendor, is the limit.
const CONCURRENCY = 20;
// Greenhouse, Lever and Ashby each serve their ENTIRE directory from a single
// hostname (boards-api.greenhouse.io, api.lever.co, api.ashbyhq.com), so the
// default is 20 sustained connections to ONE API across thousands of boards.
// That earns an HTTP throttle, and a throttled sweep loses live boards rather
// than failing loudly: two full sweeps an hour apart saw lever's unreachable
// count go 2,436 -> 4,100 and ashby's 683 -> 1,675, then recover to 2,525 / 684
// after a cooldown with no change to the dataset. The boards were never dead —
// they were refused, and the matches on them were silently missed.
const SINGLE_HOST_CONCURRENCY = 6;
// recruiting.paylocity.com is also one host for every board, but throttles far
// earlier: at SINGLE_HOST_CONCURRENCY a full sweep got HTTP 429 on 9,052 of
// 10,252 boards. The provider retries a 429 with backoff; this keeps the sweep
// from provoking one on nearly every board.
const PAYLOCITY_CONCURRENCY = 2;
// join.com's job list endpoint holds every client to about ten boards a second
// across all tenants and answers faster requests with a rate-limit error,
// which the provider retries with backoff. In sweeps of the same tenants, more
// than two workers was no faster and, from four, lost boards whose retries ran
// out.
const JOIN_CONCURRENCY = 2;
// A refusing resolver fails every lookup in milliseconds, so a sweep that
// keeps going just feeds it (#2229). Stop after this many consecutive
// resolver-level failures — high enough that a handful of unlucky boards
// can't trip it, low enough to stop within seconds of a real outage.
const RESOLVER_FAILURE_LIMIT = 50;
// Crash insurance for multi-hour directory sweeps: progress + matches are
// checkpointed every CHECKPOINT_EVERY companies so --resume can continue a
// dead run (with its ORIGINAL date window) instead of restarting from zero.
// Anchored too (#3510): a sweep resumed from a different directory found no
// checkpoint and silently restarted a multi-hour run from zero — the one failure
// mode --resume exists to prevent.
const CHECKPOINT_PATH = path.join(DATA_ROOT, 'data/cache/ats-full-checkpoint.json');
const CHECKPOINT_EVERY = 500;
const DEAD_BOARDS_PATH = path.join(DATA_ROOT, 'data/dead-boards.tsv');
export function loadCheckpoint(file = CHECKPOINT_PATH) {
if (!existsSync(file)) return null;
try {
const cp = JSON.parse(readFileSync(file, 'utf-8'));
if (cp?.version !== 1) return null;
// A version tag alone isn't enough: `current` is consumed unchecked below
// (`entriesAll.slice(resumeAt)`), so a truncated or hand-edited record with
// a missing/non-numeric resumeAt would silently rescan from zero and then
// persist `startAt + resumeAt === NaN` into every later checkpoint — a state
// no subsequent resume can recover from. Reject the malformed record instead.
if (cp.current !== null && cp.current !== undefined && !validCheckpointCurrent(cp.current)) return null;
return cp;
} catch {
return null;
}
}
function validCheckpointCurrent(cur) {
return typeof cur === 'object'
&& typeof cur.name === 'string'
&& Number.isInteger(cur.resumeAt) && cur.resumeAt >= 0
&& Number.isInteger(cur.datasetLen) && cur.datasetLen >= 0
&& (cur.deferred === undefined
|| (Array.isArray(cur.deferred) && cur.deferred.every((i) => Number.isInteger(i) && i >= 0)));
}
// A checkpoint written under different scan settings must not be resumed —
// silently mixing sources, caps, or undated policy would corrupt the run's
// semantics. --shuffle is never resumable: the sampled order isn't reproducible.
export function checkpointCompatible(cp, opts) {
return Boolean(cp)
&& !opts.shuffle
&& JSON.stringify(cp.ats) === JSON.stringify(opts.ats)
&& (cp.limit ?? null) === (opts.limit === Infinity ? null : opts.limit)
&& cp.includeUndated === opts.includeUndated;
}
// Flags are not the only settings a resume can mix. The portals.yml filters
// decide which boards and postings the swept part kept, so a filter edited
// between a run and its --resume leaves the checkpointed matches judged by one
// configuration and the rest by another. Each filter is fingerprinted under its
// own key, so the refusal can name what changed. title_filter_full replaces
// title_filter when present, so the pair is recorded under whichever key the
// sweep actually read. Keys are sorted before hashing: reordering a YAML
// mapping changes nothing the sweep does.
export function filterFingerprints(config) {
const titleKey = config?.title_filter_full ? 'title_filter_full' : 'title_filter';
const fp = (v) => datasetFingerprint(sortKeys(v ?? null));
return {
[titleKey]: fp(resolveTitleFilterConfig(config)),
title_filter_overrides: fp(config?.title_filter_overrides),
location_filter: fp(config?.location_filter),
content_filter: fp(config?.content_filter),
domain_filter: fp(config?.domain_filter),
};
}
function sortKeys(v) {
if (Array.isArray(v)) return v.map(sortKeys);
if (v && typeof v === 'object') {
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
}
return v;
}
// A checkpoint written before filters were recorded carries no fingerprints, so
// there is nothing to compare and it resumes as it did before.
export function changedFilterKeys(cp, fingerprints) {
if (!cp?.filters) return [];
return Object.keys(fingerprints).filter((k) => cp.filters[k] !== fingerprints[k]);
}
// Never throws: this runs from parallelEach's `finally`, so an escaping error
// (ENOSPC, EACCES, read-only volume) would reject the whole sweep and discard
// every in-memory match from a multi-hour run — the checkpoint killing the work
// it exists to protect. A failed write costs resumability, not the results.
function writeCheckpoint(cp) {
try {
mkdirSync(CACHE_DIR, { recursive: true });
const tmp = `${CHECKPOINT_PATH}.tmp`;
writeFileSync(tmp, JSON.stringify(cp), 'utf-8');
renameSyncWithRetry(tmp, CHECKPOINT_PATH); // atomic: a crash mid-write can't corrupt the checkpoint; retries Windows contention
return true;
} catch (err) {
console.error(`\n⚠ checkpoint write failed (${err.message}) — sweep continues, --resume unavailable`);
return false;
}
}
// Dead-board memory is useful resumability metadata, but a cache write must
// never discard the matches from a long-running sweep. Keep it best-effort,
// just like the main checkpoint write above.
function saveDeadBoardsBestEffort(rows) {
try {
saveDeadBoards(DEAD_BOARDS_PATH, rows);
return true;
} catch (err) {
console.error(`\n⚠ dead-board cache write failed (${err.message}) — sweep continues`);
return false;
}
}
/**
* `ctx` with `provider`'s slice of the id cache as ctx.idCache, or `ctx` as is
* when there is no store (a caller that does not pass one, tests).
*/
export function withIdCache(ctx, idCaches, provider) {
return idCaches ? { ...ctx, idCache: idCaches.forProvider(provider?.id) } : ctx;
}
// Same rule for the provider id cache: a failed write costs later runs a
// lookup, never this sweep's matches. It never rejects, so a checkpoint can
// start it without awaiting.
async function saveProviderIdCacheBestEffort(idCaches) {
try {
await idCaches.save();
} catch (err) {
console.error(`\n⚠ provider id cache write failed (${err.message}) — sweep continues`);
}
}
// Cheap content fingerprint of a source's company list. --resume relies on the
// dataset order being byte-for-byte reproducible; a same-length regeneration
// with different members would pass a bare length check and silently resume at
// the wrong offset (companies skipped or re-scanned). Hashing the list detects
// that drift so we can fail loudly instead.
export function datasetFingerprint(list) {
return createHash('sha1').update(JSON.stringify(list)).digest('hex').slice(0, 16);
}
// Dataset entries are external input destined for URL interpolation — reject
// anything outside a conservative slug charset.
const SLUG_RE = /^[A-Za-z0-9._-]+$/;
// ~47% of workday_companies.json is a data-quality defect in the upstream
// job-board-aggregator dataset: the tenant field holds an instance-name
// lookalike (wd1, wd5, wd12, ...) instead of a real company, with the site
// field copied from the corresponding real entry — every one of these hosts
// is unresolvable. Confirmed against the full dataset (2026-09): exactly 15
// distinct values match this pattern, every matching row sits inside one
// contiguous corrupted block, and none corresponds to a real company (#4454).
const WORKDAY_JUNK_TENANT_RE = /^wd\d+$/i;
// 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;
}
// The public iCIMS dataset is not consistent about what an entry is. Most are a
// bare tenant ("acmefreight", served at careers-acmefreight.icims.com), but
// thousands are already the full portal subdomain: "careers-acmefreight",
// "uscareers-acme", "acmecareers-west". Prefixing every entry with "careers-"
// built hosts like careers-careers-acmefreight.icims.com that do not exist, so
// those boards answered 404 and were recorded as dead. Neither reading is safe
// on its own (a bare tenant can contain a hyphen, and some bare tenants are
// served without the prefix), so return the likelier host first and the other
// shape as a fallback that icims.fetch() tries only on a first-page 404.
export function icimsHostCandidates(slug) {
const s = String(slug ?? '').toLowerCase().replace(/^-+/, '');
if (!s) return [];
const asIs = `${s}.icims.com`;
const prefixed = `careers-${s}.icims.com`;
if (s.startsWith('careers-')) return [asIs];
return s.includes('careers') ? [asIs, prefixed] : [prefixed, asIs];
}
// A Paylocity dataset row is a board GUID plus the company name. The dataset
// stores names HTML-escaped ("A & B"), so they are decoded here. An empty
// name is kept empty: the provider then names the company from the board's
// own title.
export function paylocityEntry(row) {
const careersUrl = paylocityBoardUrl(row?.guid);
if (!careersUrl) return null;
const name = typeof row.name === 'string' ? decodeEntities(row.name).trim() : '';
return entryOnHost(name, careersUrl, h => h === 'recruiting.paylocity.com');
}
// The Workable source's boards arrive with their postings from the feed, so
// its "provider" only hands them back. It is not registered in providers/:
// nothing in portals.yml can name it, and scan.mjs never downloads the feed.
const WORKABLE_FEED_PROVIDER = Object.freeze({
id: 'workable-feed',
fetch: async (entry) => (Array.isArray(entry?.jobs) ? entry.jobs : []),
});
// 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.
// A source with `loadDataset` builds its list itself instead of downloading a
// company directory (see loadSourceList).
export const SOURCES = {
greenhouse: {
provider: greenhouse,
// Whole directory behind one host — see SINGLE_HOST_CONCURRENCY.
concurrency: SINGLE_HOST_CONCURRENCY,
// One host means one keep-alive pool, so per-board connection scopes would
// only add a TLS handshake per board (see boardScope below).
singleHost: true,
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,
// Whole directory behind one host — see SINGLE_HOST_CONCURRENCY.
concurrency: SINGLE_HOST_CONCURRENCY,
// One host means one keep-alive pool, so per-board connection scopes would
// only add a TLS handshake per board (see boardScope below).
singleHost: true,
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,
// Whole directory behind one host — see SINGLE_HOST_CONCURRENCY.
concurrency: SINGLE_HOST_CONCURRENCY,
// One host means one keep-alive pool, so per-board connection scopes would
// only add a TLS handshake per board (see boardScope below).
singleHost: true,
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;
if (WORKDAY_JUNK_TENANT_RE.test(tenant)) return null;
return entryOnHost(
tenant,
`https://${tenant}.${instance}.myworkdayjobs.com/${site}`,
h => h === `${tenant}.${instance}.myworkdayjobs.com` && h.endsWith('.myworkdayjobs.com'),
);
},
},
icims: {
provider: icims,
dataset: `${DATASET_BASE}/icims_companies.json`,
toEntry: (slug) => {
if (!SLUG_RE.test(String(slug))) return null;
const hosts = icimsHostCandidates(slug);
if (hosts.length === 0) return null;
const [primary, ...fallbacks] = hosts.map((h) => `https://${h}/jobs/search?ss=1&in_iframe=1`);
const entry = entryOnHost(String(slug), primary, (h) => h === hosts[0]);
if (entry && fallbacks.length) entry.fallback_urls = fallbacks;
return entry;
},
},
bamboohr: {
provider: bamboohr,
// Per-tenant own host (.bamboohr.com), like workday/icims — default
// CONCURRENCY is correct here, not SINGLE_HOST_CONCURRENCY.
dataset: `${DATASET_BASE}/bamboohr_companies.json`,
toEntry: (slug) => SLUG_RE.test(String(slug))
? entryOnHost(String(slug), `https://${slug}.bamboohr.com/careers`, h => h === `${slug}.bamboohr.com`)
: null,
},
paylocity: {
provider: paylocity,
// Whole directory behind one host that throttles early — see PAYLOCITY_CONCURRENCY.
concurrency: PAYLOCITY_CONCURRENCY,
// One host means one keep-alive pool, so per-board connection scopes would
// only add a TLS handshake per board (see boardScope below).
singleHost: true,
// Entries are { guid, name, jobs } objects, not slugs. `jobs` is the
// dataset's count at snapshot time and does not decide whether a board is
// scanned: it is out of date by the time the sweep runs.
dataset: `${DATASET_BASE}/paylocity_companies_clean.json`,
toEntry: (row) => paylocityEntry(row),
},
join: {
provider: join,
// Whole directory behind one host that rate-limits — see JOIN_CONCURRENCY.
concurrency: JOIN_CONCURRENCY,
singleHost: true,
// job-board-aggregator has no join file; join.com publishes its own
// company directory, listed under `Sitemap:` in its robots.txt.
dataset: 'https://join.com/companies/sitemap.xml',
parseDataset: parseCompanySitemap,
toEntry: (slug) => SLUG_RE.test(String(slug))
? entryOnHost(String(slug), `https://join.com/companies/${slug}`, h => h === 'join.com')
: null,
},
workable: {
// Workable publishes every tenant's postings in one hourly XML feed, so
// there is no company directory to walk and nothing to fetch per board:
// loadDataset reads the feed once (loadWorkableFeed in
// providers/workable.mjs, through workableFeedCache) and hands back
// one board per employer with its postings attached, already narrowed to
// the --since window and the title filter. The rest of the sweep (domain
// gate, location and content filters, blacklist, dedup) runs per employer
// as for any other source. Title-filter overrides match the employer name.
provider: WORKABLE_FEED_PROVIDER,
singleHost: true,
// The feed changes every hour, so an interrupted pass cannot resume at an
// offset into it; it starts again, and seenUrls keeps the matches already
// saved from being added twice.
restartOnResume: true,
dataset: WORKABLE_FEED_URL,
loadDataset: async ({ cutoff, includeUndated, titleFilter, cacheDir }) => {
const cache = workableFeedCache(cacheDir ? { cacheDir } : {});
try {
const { companies } = await loadWorkableFeed({ cutoffMs: cutoff, includeUndated, titleFilter, fetchStream: cache.fetchStream });
cache.commit();
return { list: companies, status: cache.from === 'stale-cache' ? 'stale' : 'ok' };
} catch (err) {
cache.discard();
throw err;
}
},
toEntry: (row) => (row && typeof row.company === 'string' && row.company
? { name: row.company, ...(row.website ? { website: row.website } : {}), jobs: Array.isArray(row.jobs) ? row.jobs : [] }
: null),
},
};
// ── CLI args ────────────────────────────────────────────────────────
const KNOWN_FLAGS = [
'--since', '--limit', '--ats', '--seeds', '--history-seeds', '--probe-unknown-hosts', '--dry-run', '--liveness',
'--verbose', '--md-out', '--json', '--include-undated', '--include-blacklisted',
'--shuffle', '--resume', '--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 --history-seeds # also scan boards from local application history
node scan-ats-full.mjs --history-seeds --ats successfactors # history-derived boards for this ATS
node scan-ats-full.mjs --history-seeds --probe-unknown-hosts # also detect branded SuccessFactors hosts (one GET per unknown host)
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 --include-blacklisted # audit: let data/blacklist.md matches through, annotated
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 --resume # continue an interrupted sweep from its checkpoint
node scan-ats-full.mjs --help # print this usage block and exit`;
export function parseArgs(argv) {
const args = argv.slice(2);
// Shared with reply-watch.mjs/dedup-tracker.mjs/scan.mjs via
// lib/cli-flags.mjs (#2775). This also fixes a latent ordering bug this
// script had before the pattern was consolidated: the unrecognized-flag
// check now runs BEFORE --help, so `--help --bogus` still errors instead
// of exiting 0 having never looked at `--bogus` (the same ordering
// CodeRabbit flagged on #2745/#2746).
validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS });
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;
};
// Validated by the SAME parser scan.mjs uses, so one flag name cannot mean
// two different things (#2498). `Number(...) || 3` silently swallowed every
// malformed operand: `--since abc` and `--since 0` became 3 while the user
// believed they had scanned the window they typed; `--since -5` put the
// cutoff in the FUTURE so nothing was ever eligible, which reads exactly like
// "no new postings"; `--since 1e400` became Infinity → an -Infinity cutoff,
// i.e. no window at all. Only the DEFAULT stays local: absent --since means
// 3 days here, where scan.mjs means no bound.
const sinceArg = parseSinceDays(args);
if (sinceArg.error) {
console.error(`Error: ${sinceArg.error}`);
process.exit(1);
}
const sinceDays = sinceArg.days ?? 3;
// The same rule --since follows (#2498): a malformed operand is an error,
// never a silent default. `Number(...) || Infinity` turned `--limit abc`,
// `--limit 0`, a typo like `--limit 1O`, and a bare `--limit` into an
// UNBOUNDED sweep, the slowest and most network-hungry run this script has,
// while the user believed they had capped it. The pattern alone is not
// enough: a long enough run of digits passes it and converts to Infinity,
// the same sweep, so the value must also be a safe integer.
let limit = Infinity;
if (hasFlag(args, '--limit')) {
// Two caps are refused, as two --since windows are: which one applied
// depended on how each was spelled, not on the order typed.
const occurrences = args.filter((a) => a === '--limit' || a.startsWith('--limit='));
if (occurrences.length > 1) {
console.error(`Error: --limit given ${occurrences.length} times; pass it once`);
process.exit(1);
}
const raw = String(flagValue(args, '--limit') ?? '').trim();
if (!/^[1-9]\d*$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
console.error(`Error: --limit expects a positive whole number of companies, got ${raw ? `"${raw}"` : 'no value'}`);
process.exit(1);
}
limit = Number(raw);
}
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 validAts = new Set([...Object.keys(SOURCES), ...KNOWN_ATS_VENDORS]);
const unknown = ats.filter(a => !validAts.has(a));
if (unknown.length) {
console.error(`Error: unknown ATS source(s): ${unknown.join(', ')}. Valid: ${[...validAts].join(', ')}`);
process.exit(1);
}
const historySeeds = args.includes('--history-seeds');
const historyOnly = ats.filter(a => !SOURCES[a]);
if (!historySeeds && historyOnly.length) {
throw new Error(
`--ats ${historyOnly.join(',')} ${historyOnly.length === 1 ? 'has' : 'have'} no public directory source; ` +
're-run with --history-seeds to scan boards derived from local application history.',
);
}
return {
sinceDays,
limit,
ats,
atsExplicit: Boolean(atsArg),
historySeeds,
probeUnknownHosts: args.includes('--probe-unknown-hosts') && historySeeds,
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'),
includeBlacklisted: args.includes('--include-blacklisted'),
shuffle: args.includes('--shuffle'),
resume: args.includes('--resume'),
};
}
// ── 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.
//
// A source with `parseDataset(text) → array` publishes its directory in some
// other format (join: a sitemap). The text is parsed before it is cached, so
// the cache file always holds the array, like every other source's.
export async function loadCompanyList(name, url, parseDataset, { cacheDir = CACHE_DIR, fetchJsonFn = fetchJson, fetchTextFn = fetchText } = {}) {
mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, `${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 = typeof parseDataset === 'function'
? parseDataset(await fetchTextFn(url, { timeoutMs: 30_000 }))
: await fetchJsonFn(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' };
}
// A source with `loadDataset` (workable) builds its own list from the sweep's
// window and title filter; every other source downloads its company directory
// through loadCompanyList. Both answer { list, status }, and a failed
// loadDataset reads as 'empty', reported once, like a directory that could not
// be downloaded and has no cache.
export async function loadSourceList(name, source, datasetOpts, { loadCompanyListFn = loadCompanyList } = {}) {
if (typeof source?.loadDataset !== 'function') return loadCompanyListFn(name, source.dataset, source.parseDataset);
try {
const loaded = await source.loadDataset(datasetOpts);
// A loader answers its list, or { list, status } when it can say more
// (a list read from an expired cache is 'stale').
const list = Array.isArray(loaded) ? loaded : loaded?.list;
const status = !Array.isArray(loaded) && ['ok', 'stale'].includes(loaded?.status) ? loaded.status : 'ok';
return { list: Array.isArray(list) ? list : [], status };
} catch (err) {
console.error(`⚠️ ${name}: could not read ${source.dataset} — ${err.message}`);
return { list: [], status: 'empty' };
}
}
// Workable rebuilds its job feed about once an hour and refuses a second
// download inside the hour (HTTP 429, Retry-After up to 3600). It answers a
// conditional request, though: the feed carries Last-Modified, and a GET with
// that date in If-Modified-Since gets 304 with no body and no refusal. So the
// last download is kept, gzipped, beside its Last-Modified, and every run asks
// whether the feed changed: on 304 it reads the copy, on 200 it keeps the new
// download. When the request is refused before any of the feed arrived, the
// copy is read and the source reports 'stale', as an expired directory cache
// does.
const WORKABLE_FEED_CACHE_FILE = 'workable.xml.gz';
const WORKABLE_FEED_META_FILE = 'workable.meta.json';
/**
* Wait until a full gzip stream can take more. Also ends when the gzip stream
* or the file it feeds fails or closes: a broken file never drains the gzip
* stream. Every listener is removed when the wait ends.
*/
export function drainOrFailure(gzip, out) {
if (gzip.destroyed || out.destroyed) return Promise.resolve();
return new Promise((resolve) => {
const events = [[gzip, 'drain'], [gzip, 'error'], [gzip, 'close'], [out, 'error'], [out, 'close']];
const done = () => {
for (const [emitter, name] of events) emitter.off(name, done);
resolve();
};
for (const [emitter, name] of events) emitter.on(name, done);
});
}
/**
* A fetchTextStream stand-in for loadWorkableFeed that serves and keeps the
* feed copy. A fresh download is written to a temporary file and becomes the
* copy only on commit(), after the caller accepted the whole document, so a
* copy is always a complete feed; discard() drops a download the caller
* refused.
*
* @param {{ cacheDir?: string, fetchStream?: typeof fetchTextStream, now?: () => number }} [opts]
*/
export function workableFeedCache({ cacheDir = CACHE_DIR, fetchStream = fetchTextStream, now = Date.now } = {}) {
const file = path.join(cacheDir, WORKABLE_FEED_CACHE_FILE);
const metaFile = path.join(cacheDir, WORKABLE_FEED_META_FILE);
const tmp = `${file}.tmp-${process.pid}`;
/** @type {'network' | 'cache' | 'stale-cache'} */
let from = 'network';
/** Last-Modified of the download in `tmp`, written beside the copy on commit(). */
let downloadedLastModified = null;
const removeQuietly = (p) => { try { unlinkSync(p); } catch { /* not there */ } };
const copyLastModified = () => {
if (!existsSync(file)) return null;
try {
const value = JSON.parse(readFileSync(metaFile, 'utf-8'))?.lastModified;
return typeof value === 'string' && value.trim() ? value.trim() : null;
} catch {
return null;
}
};
async function readCopy(onText) {
const decoder = new TextDecoder('utf-8');
let bytes = 0;
for await (const chunk of createReadStream(file).pipe(createGunzip())) {
bytes += chunk.length;
if (await onText(decoder.decode(chunk, { stream: true })) === false) return { bytes, complete: false };
}
const tail = decoder.decode();
if (tail) await onText(tail);
return { bytes, complete: true };
}
async function stream(url, opts, onText) {
const validator = copyLastModified();
const requestOpts = validator
? { ...opts, headers: { ...(opts?.headers ?? {}), 'if-modified-since': validator }, acceptNotModified: true }
: opts;
// The copy is written only once text arrives, so a refused request never
// touches the disk. A failure to write it costs the copy, not the read.
/** @type {import('node:zlib').Gzip | null} */
let gzip = null;
/** @type {import('node:fs').WriteStream | null} */
let out = null;
let writeFailed = false;
const sink = () => {
if (!gzip) {
mkdirSync(cacheDir, { recursive: true });
gzip = createGzip({ level: 1 });
out = createWriteStream(tmp);
gzip.on('error', () => { writeFailed = true; });
out.on('error', () => { writeFailed = true; });
gzip.pipe(out);
}
return gzip;
};
const closeSink = async (keep) => {
if (!gzip) return;
if (keep && !writeFailed) {
gzip.end();
await finished(out).catch(() => { writeFailed = true; });
} else {
gzip.destroy();
out.destroy();
await finished(out).catch(() => {});
}
if (!keep || writeFailed) removeQuietly(tmp);
};
let delivered = false;
try {
const result = await fetchStream(url, requestOpts, async (text) => {
delivered = true;
if (!writeFailed) {
const g = sink();
if (!g.write(text) && !writeFailed) await drainOrFailure(g, out);
}
return onText(text);
});
if (result?.notModified) {
await closeSink(false);
from = 'cache';
return readCopy(onText);
}
await closeSink(true);
from = 'network';
downloadedLastModified = typeof result?.lastModified === 'string' ? result.lastModified : null;
return result;
} catch (err) {
await closeSink(false);
// Only a refusal that delivered nothing can fall back: once part of the
// feed reached the caller, the copy would be read on top of it.
if (!delivered && existsSync(file)) {
from = 'stale-cache';
return readCopy(onText);
}
if (err?.status === 429) {
// A missing header is unknown, not 0 seconds.
const raw = typeof err.retryAfter === 'string' ? err.retryAfter.trim() : '';
const retryS = raw === '' ? NaN : Number(raw);
const when = Number.isFinite(retryS) && retryS >= 0 ? `after ${new Date(now() + retryS * 1000).toISOString()}` : 'later';
throw new Error(`Workable serves its job feed about once an hour and refused this request (HTTP 429); try again ${when}`);
}
throw err;
}
}
return {
fetchStream: stream,
get from() { return from; },
/**
* Keep a fresh download as the copy, with the date the next run asks
* about. A failure here costs the copy, not the feed already read.
*/
commit() {
if (from !== 'network' || !existsSync(tmp)) return;
try {
renameSyncWithRetry(tmp, file);
if (downloadedLastModified) writeFileSync(metaFile, JSON.stringify({ lastModified: downloadedLastModified }), 'utf-8');
else removeQuietly(metaFile);
} catch (err) {
console.error(`⚠️ workable: could not keep the feed copy (${err.message}); the next run downloads the feed again`);
removeQuietly(tmp);
removeQuietly(metaFile);
}
},
/** Drop a download the caller refused. */
discard() {
removeQuietly(tmp);
},
};
}
// 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';
}
// #6020. content_filter.rescue_title_misses is not applied here.
// listingMayMatch() below skips a whole board when no title on it matches,
// before any posting body is downloaded; that precheck is what makes a sweep of
// a full public directory affordable, and rescuing title misses would mean
// downloading every board. Say so instead of ignoring the key silently.
export const RESCUE_TITLE_MISSES_IGNORED_WARNING = '⚠️ content_filter.rescue_title_misses is ignored by scan-ats-full.mjs: it skips boards by title before downloading descriptions. Title misses are dropped here as without the key; scan.mjs and scan-dayforce.mjs apply it.';
export function warnRescueTitleMissesIgnored(contentFilter, warn = console.error) {
if (contentFilter?.rescueTitleMisses === true) warn(RESCUE_TITLE_MISSES_IGNORED_WARNING);
}
// Can this board hold a match? Asked of a provider's cheap listing (titles and
// dates, no posting bodies) before its full fetch. Only the two checks that
// need no body run here, the same ones processJobs() starts with, so a board
// that might match still goes through fetch() and processJobs() unchanged.
// Undated postings count as possible: processJobs() decides them.
export function listingMayMatch(listing, { cutoff, titleFilter, companySlug }) {
return listing.some(job => job.url && job.title
&& classifyPostingDate(job, cutoff) !== 'stale'
&& titleFilter(job.title, companySlug));
}
// Undated postings on a board the listing ruled out. processJobs() counts
// these into "Undated dropped" before its title filter, so a skipped board
// must report them too, or the pre-check would hide the degraded-scan signal.
export function undatedInListing(listing, cutoff) {
return listing.filter(job => job.url && job.title
&& classifyPostingDate(job, cutoff) === 'undated').length;
}
// Apply the same user-owned do-not-apply gate as scan.mjs to reverse-scan
// results. Absent/empty blacklist is a no-op. Default skips are counted and
// never silent; --include-blacklisted keeps matches but marks them for audit.
export function filterBlacklistedOffers(offers, blacklist, { includeBlacklisted = false } = {}) {
if (!blacklist || blacklist.size === 0) {
return { offers, filteredBlacklist: 0, annotatedBlacklisted: 0 };
}
const kept = [];
let filteredBlacklist = 0;
let annotatedBlacklisted = 0;
for (const offer of offers) {
const entry = findBlacklistEntry(blacklist, offer.company || '', offer.url);
if (!entry) {
kept.push(offer);
continue;
}
if (!includeBlacklisted) {
filteredBlacklist++;
continue;
}
annotatedBlacklisted++;
const label = `blacklisted${entry.reason ? `: ${entry.reason}` : ''}`;
kept.push({
...offer,
blacklisted: true,
note: typeof offer.note === 'string' && offer.note.trim()
? `${label} — ${offer.note}`
: label,
});
}
return { offers: kept, filteredBlacklist, annotatedBlacklisted };
}
// `title_filter` is written for scan.mjs, whose corpus is the curated
// tracked_companies list. That corpus is what makes a broad keyword safe:
// "Backend" at a company you already vetted is a real lead, and dropping it
// costs real roles — so tightening the shared key to protect this sweep is a
// regression for the scanner that actually produces applications.
//
// This sweep removes the corpus and leaves the same keywords carrying the
// whole burden against every public board. Broad keywords then match on a
// scale they were never chosen for, and ordinary English words land as
// unrelated product names: one 38,854-board sweep wrote 426 postings, of
// which 142 passed on "Backend" alone and the rest on Fuel Associate,
// Traffic Anchor, ICP Mass Spec, Principal Mechanical Canister Eng. Across
// 194 distinct companies, one matched a crypto name — a gas-station chain.
//
// Optional `title_filter_full` lets one portals.yml carry a strict profile
// for this sweep and a broad one for scan.mjs. Absent — the default — this
// returns `title_filter` and behaviour is byte-identical to before.
export function resolveTitleFilterConfig(config) {
return config?.title_filter_full ?? config?.title_filter;
}
// The company-level gate (#3105). `title_filter` runs on the wrong axis for a
// reverse sweep: it asks the TITLE to answer both "is this the right role" and
// "is this employer in my industry", and a title cannot answer the second one —
// "Senior Backend Engineer" is character-for-character identical at a Solana
// infrastructure company and at a supermarket chain. scan.mjs never had that
// problem because tracked_companies settles the employer before a title is
// read; the sweep deletes that premise and hands the whole burden to keywords
// that were never chosen for it.
//
// So ask the cheaper question of the board as a whole first, using data already
// paid for — the board is fully in memory before filtering starts, so this
// costs one extra pass over an array and no HTTP request at all.
//
// THRESHOLD IS ONE, and that is measured rather than cautious. Over 546
// postings on 249 boards, raising it does not trade recall for precision, it
// inverts the gate: at two, twelve boards survive and every genuine crypto
// employer is gone, because ten of the twelve qualify on `defi` appearing
// repeatedly inside a Portuguese "Deficiência" or an Ohio "Defiance". A
// repeated false-positive substring is a property of large boards; the real
// finds are single openings at small companies — conduit, ethereum-foundation,
// mesh and ing each had exactly one domain-bearing posting, which is precisely
// the find the sweep exists for.
//
// KNOWN LIMIT: the gate assumes one board is one employer, and an aggregator
// breaks that premise. `jobgether` carried 25 domain-bearing postings among 93
// and so passes at every threshold under every matcher, admitting 68 unrelated
// ones on the strength of its crypto listings. No threshold fixes it — the
// board is simply not an employer. There the gate degrades to today's
// behaviour, which is a bounded failure and one of the reasons the whole
// feature is opt-in.
export function boardInDomain(jobs, domainFilter) {
return jobs.some(job => job?.title && domainFilter(job.title));
}
/**
* Decide what the sweep does with one fetched board, before any title is read.
*
* Split out from the call site because the truncation rule is the whole point
* and an inline `if` hid it: an earlier revision gated a `workdayTruncated`
* response on the part that was fetched, "like every other gate here". That
* reasoning is wrong for exactly this gate. Every other filter here judges a
* posting it has in hand, so a short response costs it the rows it never saw;
* this one judges the BOARD from its rows, so a short response can invert the
* verdict — the only domain-bearing posting may sit in the tail the sequential
* retry is about to fetch, and the board is then dropped into a counter that
* reads as "correctly excluded" rather than "not yet known".
*
* So an unmatched truncated board is deferred, not gated: it stays queued for
* the retry and the fuller result decides it. A truncated board that ALREADY
* matches needs no deferral — a domain-bearing posting in hand settles the
* verdict whatever the retry returns — and processing its partial page keeps
* today's behaviour of banking those matches even if the retry later fails.
* What the retry then does with each kind is retryGateDecision's job.
*
* Reachability, since the obvious objection is that a truncated page might be
* a ranked prefix and so already carry any domain-bearing posting: it is not.
* `workdayTruncated` is set on `fetch-error` (retries exhausted MID-pagination
* while other tenants hammered the same uplink), on `splitIncomplete` and on
* `budgetExhausted` — see providers/workday.mjs. The cut is wherever the error
* or the budget landed, which bears no relation to posting order, so no
* ordering argument makes the deferral unnecessary.
*
* @param {object[]} jobs - Postings as returned by the provider.
* @param {((title: string) => boolean)|null} domainFilter - Compiled gate, or null when opt-out.
* @returns {'process'|'gate'|'defer'} What the caller should do with the board.
*/
export function boardGateDecision(jobs, domainFilter) {
if (!domainFilter) return 'process';
if (boardInDomain(jobs, domainFilter)) return 'process';
// An iCIMS board stopped at the provider's page cap is never retried, so,
// like a structural Workday cut, it is admitted ungated rather than judged
// on the pages that happened to fit under the cap.
if (jobs.icimsTruncated) return 'process';
if (!jobs.workdayTruncated) return 'gate';
// Only a transient cut is retried. A structural one comes back cut at the
// same bound, so there is no fuller fetch to defer to: the board is admitted
// ungated now, as the retry admits a board truncated twice.
return jobs.workdayTruncated === WORKDAY_TRUNCATED_REASON.TRANSIENT ? 'defer' : 'process';
}
/**
* Decide what the sequential retry does with a board the sweep queued.
*
* The retry is where a deferred board gets its verdict, so it needs the same
* callable shape as the sweep, plus the one fact only the sweep knows: whether
* the board was deferred or already admitted. Two cases hinge on that:
*
* 1. Already admitted (its truncated page matched). The retry cannot take
* that back: the retry can come back truncated too, cut somewhere else,
* so "the retry returns a superset" does not hold, and a cut that misses
* the domain posting would count a board whose matches are already banked
* as gated. It is processed, whatever the retry shows.
* 2. Deferred, and the retry is truncated AGAIN with nothing domain-bearing.
* There is no third fetch to defer to, and the gated counter means
* "correctly excluded", which an incomplete page cannot establish. The
* board is admitted ungated, the same fallback the gate takes whenever it
* cannot judge a board (see the aggregator note on boardInDomain); the
* caller still counts it as an error for staying truncated.
*
* Only a deferred board whose retry is complete and carries nothing
* domain-bearing is gated.
*
* @param {object[]} jobs - Postings from the retry fetch.
* @param {((title: string) => boolean)|null} domainFilter - Compiled gate, or null when opt-out.
* @param {boolean} wasDeferred - Whether the sweep deferred this board rather than processing it.
* @returns {'process'|'gate'} What the caller should do with the board.
*/
export function retryGateDecision(jobs, domainFilter, wasDeferred) {
if (!wasDeferred) return 'process';
return boardGateDecision(jobs, domainFilter) === 'gate' ? 'gate' : 'process';
}
// Title/location/content filter chain for one posting, used by runSeedScan().
// The main ATS-directory loop below inlines the same three checks (it tracks
// a droppedContent counter per stage for the run summary), but this shared,
// pure, exported helper keeps the content_filter.by_title_keyword wiring
// (#1846) unit-testable without mocking providers or duplicating the rule
// order in two places for the caller that doesn't need per-stage counts.
export function passesFilters(job, { titleFilter, locationFilter, contentFilter, titleFilterConfig, companySlug }) {
if (!titleFilter(job.title, companySlug)) return false;
// job.url is passed so the location filter can fall back to the URL's own
// location segment when the provider reports a rolled-up "N Locations" string;
// job.title so a title-stated remote role survives a city-only location.
if (!locationFilter(job.location, job.url, job.title, job)) return false;
if (contentFilter && !contentFilter(job.description, matchedTitleKeywords(job.title, titleFilterConfig))) return false;
return true;
}
// Prefer a provider's own scoped dedup key over URL normalization when the
// provider can derive one — e.g. workday.mjs's requisition ID, which
// collapses the same posting served under several sites of one tenant
// (different paths, sometimes different hosts) that seenJobKey
// can't recognize as duplicates (#3439). `provider` is optional so this stays
// safe to call for seed-pass offers, which carry no SOURCES entry to look one
// up from. Falls back to the pre-#3439 behavior whenever no key is derived.
export function dedupTokenFor(job, provider) {
return provider?.dedupKey?.(job) || seenJobKey(job.url);
}
// Resolve an offer's provider from its recorded `source` string — shared by
// the checkpoint-resume reseed below and by loadSeenUrls' extraTokensFor
// hook. `source` is "{sourcesKey}-full" for the main sweep and
// "{seedId}-seed" for seed offers; SOURCES has no seed entries, so a seed
// offer's lookup misses and callers fall back to URL-only dedup, unchanged
// from before #3439.
export function providerForSource(source) {
return SOURCES[String(source || '').replace(/(?:-history)?-full$/, '')]?.provider;
}
// 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 = false) {
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);
}
/** One stderr JSON line per kept offer in `--json` mode so Explore can paint
* cards while the walk continues. Stdout stays the single summary object (#1199). */
export function formatLiveOfferLine(job, source) {
let postedAt = null;
if (job?.postedAt) {
const d = new Date(job.postedAt);
if (!Number.isNaN(d.getTime())) postedAt = d.toISOString().slice(0, 10);
}
return JSON.stringify({
kind: 'offer',
company: job.company,
title: job.title,
url: job.url,
location: job.location || null,
...(job.geographyEvidence ? { geographyEvidence: job.geographyEvidence } : {}),
postedAt,
source: source || job.source || '',
});
}
export function parseLiveOfferLine(line) {
const raw = String(line || '');
const start = raw.indexOf('{');
if (start < 0) return null;
let ev;
try {
ev = JSON.parse(raw.slice(start));
} catch {
return null;
}
if (!ev || ev.kind !== 'offer') return null;
const url = typeof ev.url === 'string' ? ev.url.trim() : '';
if (!url || !ev.company || !ev.title) return null;
return ev;
}
export function emitLiveOffer(job, source, { json } = {}) {
if (!json || !job?.url || !job.company || !job.title) return;
console.error(formatLiveOfferLine(job, source));
}
export function keepAndMaybeEmit(job, source, sink, blacklist, opts) {
const kept = { ...job, ...(opts.workplacePolicy ? { geographyEvidence: captureWorkplaceEvidence(job) } : {}), source, dateStatus: job.postedAt ? 'dated' : 'unknown' };
sink.push(kept);
const live = filterBlacklistedOffers([kept], blacklist, { includeBlacklisted: opts.includeBlacklisted });
if (live.offers.length) emitLiveOffer(live.offers[0], source, { json: opts.json });
}
// ── 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.
* @param {object} [idCaches] Store from openProviderIdCache(); each provider gets its slice as ctx.idCache.
* @returns {Promise