#!/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} New job offers (same shape as ATS scan offers). */ export async function runSeedScan(seedId, opts, ctx, seenUrls, label) { const source = SEED_SOURCES[seedId]; if (!source) throw new Error(`runSeedScan: unknown seed "${seedId}"`); let companies; try { companies = await source.fetch(); } catch (err) { console.error(`⚠️ ${seedId}: could not fetch portfolio — ${err.message}`); return []; } // Apply the --limit cap here too (by slug, consistent with sampleCompanies). const capped = opts.limit < companies.length ? (opts.shuffle ? (() => { const c = companies.slice(); for (let i = c.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [c[i], c[j]] = [c[j], c[i]]; } return c.slice(0, opts.limit); })() : companies.slice(0, opts.limit)) : companies; const cutoff = Date.now() - opts.sinceDays * 86_400_000; const offers = []; let errors = 0; await parallelEach(capped, CONCURRENCY, async (company) => { const entry = toPortalEntry(company); if (!entry.careers_url) return; // Try each ATS provider's detect() — first hit wins. let provider = null; for (const p of SEED_PROVIDERS) { try { if (p.detect?.(entry)) { provider = p; break; } } catch { /* no-op */ } } if (!provider) return; // No ATS detected — skip silently (or log in --verbose). let jobs; try { jobs = await provider.fetch(entry, ctx); } catch (err) { errors++; if (opts.verbose) console.error(` ✗ ${seedId}/${entry.name}: ${err.message}`); return; } const sourceName = `${seedId}-seed`; for (const job of jobs) { if (!job.url || !job.title) continue; const dateClass = classifyPostingDate(job, cutoff); if (dateClass === 'stale') continue; if (dateClass === 'undated' && !opts.includeUndated) continue; if (!opts.titleFilter(job.title)) continue; if (!opts.locationFilter(job.location)) continue; if (seenUrls.has(job.url)) continue; seenUrls.add(job.url); offers.push({ ...job, source: sourceName, dateStatus: job.postedAt ? 'dated' : 'unknown' }); } }); return { offers, errors, total: capped.length }; } // ── Parallel fetch with concurrency limit ─────────────────────────── async function parallelEach(items, limit, fn) { let i = 0; async function worker() { while (i < items.length) { const item = items[i++]; await fn(item); } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); } // ── Liveness verification (reuses liveness-browser.mjs) ──────────── async function filterLive(offers) { let chromium, checkUrlLiveness, newLivenessPage; try { ({ chromium } = await import('playwright')); ({ checkUrlLiveness, newLivenessPage } = await import('./liveness-browser.mjs')); } catch (err) { throw new Error( `--liveness requires Playwright with Chromium (run "npx playwright install chromium"): ${err.message}`, { cause: err }, ); } console.error(`\nVerifying liveness of ${offers.length} match(es) with Playwright (sequential)...`); const browser = await chromium.launch({ headless: true }); const live = []; try { const page = await newLivenessPage(browser); // Sequential — project rule: never Playwright in parallel for (const offer of offers) { const { result, reason } = await checkUrlLiveness(page, offer.url); const icon = result === 'active' ? '✅' : result === 'expired' ? '❌' : '⚠️'; console.error(` ${icon} ${result.padEnd(9)} ${offer.company} | ${offer.title}${result === 'expired' ? ` (${reason})` : ''}`); if (result !== 'expired') live.push(offer); // keep 'uncertain' — transient errors retry next scan } } finally { await browser.close(); } console.error(` → ${live.length}/${offers.length} passed liveness`); return live; } // ── Main ──────────────────────────────────────────────────────────── async function main() { const opts = parseArgs(process.argv); const cutoff = Date.now() - opts.sinceDays * 86_400_000; // In --json mode, stdout is reserved for the single machine-readable result, // so every human-facing line goes to stderr instead. const log = opts.json ? (...a) => console.error(...a) : (...a) => console.log(...a); const progress = (s) => { if (!opts.json) process.stdout.write(s); }; if (!existsSync(PORTALS_PATH)) { console.error('Error: portals.yml not found. Run onboarding first — the reverse scan reuses its title_filter/location_filter.'); process.exit(1); } const config = yaml.load(readFileSync(PORTALS_PATH, 'utf-8')); const titleFilter = buildTitleFilter(config?.title_filter); const locationFilter = buildLocationFilter(config?.location_filter); if (!config?.title_filter?.positive?.length) { console.error('⚠️ portals.yml has no title_filter.positive — every fresh posting on every board will match. Consider adding keywords.'); } // Attach filters to opts so runSeedScan can use them without extra parameters. opts.titleFilter = titleFilter; opts.locationFilter = locationFilter; const atsSummary = opts.ats.length ? `ats: ${opts.ats.join(', ')}` : ''; const seedsSummary = opts.seeds.length ? `seeds: ${opts.seeds.join(', ')}` : ''; const sourcesSummary = [atsSummary, seedsSummary].filter(Boolean).join(' | '); log(`Reverse ATS scan — ${sourcesSummary} | since ${opts.sinceDays}d${opts.limit < Infinity ? ` | limit ${opts.limit}/ats` : ''}${opts.shuffle ? ' | shuffled' : ''}${opts.includeUndated ? ' | +undated' : ''}${opts.liveness ? ' | liveness' : ''}${opts.dryRun ? ' | DRY RUN' : ''}`); const { seen: seenUrls } = loadSeenUrls(); // sinceMs and includeUndated let providers (currently only workday.mjs) // stop paginating a tenant early instead of always walking to max_pages: // sinceMs once postings are confidently past the --since window, and // includeUndated (when false) for a tenant that exposes no postedOn at // all, since its postings would all be dropped as undated below anyway. const ctx = { ...makeHttpCtx(), sinceMs: cutoff, includeUndated: opts.includeUndated }; const date = new Date().toISOString().slice(0, 10); const newOffers = []; let totalCompaniesScanned = 0; let totalCompaniesAvailable = 0; let totalErrors = 0; let droppedNoDate = 0; let capHit = false; // Aggregated from providers/workday.mjs's jobs.workdayNoDateSkip tag — see // there for why this is a counter instead of a per-company console.error // (thousands of tenants hit this on a full directory scan; one line each // would just be the same message repeated thousands of times). let noDateSkipCompanies = 0; let noDateSkipJobs = 0; const datasetStatus = {}; for (const name of opts.ats) { const source = SOURCES[name]; const { list, status } = await loadCompanyList(name, source.dataset); datasetStatus[name] = status; totalCompaniesAvailable += list.length; if (opts.limit < list.length) capHit = true; const entries = sampleCompanies(list, opts.limit, opts.shuffle).map(source.toEntry).filter(Boolean); totalCompaniesScanned += entries.length; log(`\n⚙ ${name} — ${entries.length} companies${status !== 'ok' ? ` (dataset: ${status})` : ''}`); let done = 0; let errors = 0; await parallelEach(entries, CONCURRENCY, async (entry) => { try { const jobs = await source.provider.fetch(entry, ctx); if (jobs.workdayNoDateSkip) { noDateSkipCompanies++; noDateSkipJobs += jobs.length; } for (const job of jobs) { if (!job.url || !job.title) continue; // Confirmed-stale postings are always dropped. Undated postings are // dropped by default (a reverse scan targets *fresh* roles) but // COUNTED so callers see the gap; --include-undated keeps them, marked. const dateClass = classifyPostingDate(job, cutoff); if (dateClass === 'stale') continue; if (dateClass === 'undated' && !opts.includeUndated) { droppedNoDate++; continue; } if (!titleFilter(job.title)) continue; if (!locationFilter(job.location)) continue; if (seenUrls.has(job.url)) continue; seenUrls.add(job.url); // intra-scan dedup newOffers.push({ ...job, source: `${name}-full`, dateStatus: job.postedAt ? 'dated' : 'unknown' }); } } catch (err) { // Mostly defunct boards in the public dataset — expected noise, so the // default stays quiet; --verbose surfaces per-board failures. errors++; if (opts.verbose) console.error(` ✗ ${name}/${entry.name}: ${err.message}`); } done++; if (done % 200 === 0 || done === entries.length) { progress(` ${done}/${entries.length} scanned, ${newOffers.length} total matches\r`); } }); totalErrors += errors; log(`\n done (${errors} unreachable boards skipped)`); } // ── VC portfolio seed sources (--seeds flag) ─────────────────────── for (const seedId of opts.seeds) { const seedSource = SEED_SOURCES[seedId]; log(`\n🌱 ${seedSource.label} (${seedId}-seed) — fetching portfolio...`); const result = await runSeedScan(seedId, opts, ctx, seenUrls, seedSource.label); if (result && result.offers) { totalCompaniesScanned += result.total || 0; totalErrors += result.errors || 0; newOffers.push(...result.offers); log(` done — ${result.total} companies probed, ${result.offers.length} matches (${result.errors} errors)`); } } let offers = newOffers; if (offers.length && opts.liveness) offers = await filterLive(newOffers); offers.sort((a, b) => (b.postedAt || 0) - (a.postedAt || 0)); log(`\n${'━'.repeat(45)}`); log(`Reverse ATS Scan — ${date}`); log(`${'━'.repeat(45)}`); log(`Companies scanned: ${totalCompaniesScanned}${capHit ? ` of ${totalCompaniesAvailable} (capped)` : ''}`); log(`Unreachable boards: ${totalErrors}`); // noDateSkipJobs is a subset of droppedNoDate, not a separate pool: every // no-postedOn workday posting counted here also hits the per-job undated // filter in the scan loop above and gets dropped there too. Report it as // a breakdown, not a second count — the two aren't additive. if (droppedNoDate) { const breakdown = noDateSkipCompanies ? ` (incl. ${noDateSkipJobs} from ${noDateSkipCompanies} workday companies with no postedOn)` : ''; log(`Undated dropped: ${droppedNoDate}${breakdown}${opts.includeUndated ? '' : '. Use --include-undated to keep'}`); } log(`New matches: ${offers.length}`); if (offers.length) { log('\nNew offers:'); for (const o of offers) { const posted = o.postedAt ? new Date(o.postedAt).toISOString().slice(0, 10) : 'n/a'; log(` + [${o.source}] ${posted} | ${o.company} | ${o.title} | ${o.location || 'N/A'}\n ${o.url}`); } } // Persist (unless dry-run, or nothing to save). let saved = false; if (offers.length && !opts.dryRun) { // appendToPipeline assumes the file exists (onboarding creates it) — cover fresh setups. if (!existsSync(PIPELINE_PATH)) { mkdirSync(path.dirname(PIPELINE_PATH), { recursive: true }); writeFileSync(PIPELINE_PATH, '# Pipeline\n\n## Pendientes\n', 'utf-8'); } appendToPipeline(offers); appendToScanHistory(offers, date); saved = true; log(`\nResults saved to ${PIPELINE_PATH} and data/scan-history.tsv`); if (opts.mdOut) { try { mkdirSync(opts.mdOut, { recursive: true }); const digest = [ `# Reverse ATS Scan — ${date}`, `> ${offers.length} jobs | since ${opts.sinceDays}d | ${opts.liveness ? 'liveness ✓' : 'no liveness check'}`, '', ...offers.map(o => { const posted = o.postedAt ? new Date(o.postedAt).toISOString().slice(0, 10) : 'n/a'; return `- [${o.title} @ ${o.company}](${o.url}) — ${o.location || 'N/A'} | ${o.source} | ${posted}`; }), '', ].join('\n'); writeFileSync(path.join(opts.mdOut, `${date}.md`), digest, 'utf-8'); log(`Markdown digest saved to ${path.join(opts.mdOut, `${date}.md`)}`); } catch (err) { console.error(`⚠️ Could not write markdown digest: ${err.message}`); } } } // The authoritative machine-readable result: lets a caller (e.g. the web) // tell a *degraded* scan (capped / stale dataset / undated dropped) apart // from a genuinely *empty* one. In --json mode stdout carries ONLY this. if (opts.json) { process.stdout.write(JSON.stringify({ date, sources: opts.ats, sinceDays: opts.sinceDays, companiesAvailable: totalCompaniesAvailable, companiesScanned: totalCompaniesScanned, capHit, datasetStatus, postingsKept: offers.length, postingsDroppedNoDate: droppedNoDate, unreachableBoards: totalErrors, saved, offers: offers.map(o => ({ company: o.company, title: o.title, url: o.url, location: o.location || null, postedAt: o.postedAt ? new Date(o.postedAt).toISOString().slice(0, 10) : null, dateStatus: o.dateStatus || (o.postedAt ? 'dated' : 'unknown'), source: o.source, })), }) + '\n'); return; } if (!offers.length) { log('\nNothing new.'); return; } if (opts.dryRun) { log('\n(dry run — run without --dry-run to save results)'); return; } log(`\n→ Run /career-ops pipeline to evaluate new offers.`); } // Only run main() when invoked directly, not when imported by tests. if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { main().catch(err => { console.error('Fatal:', err.message); process.exit(1); }); }