#!/usr/bin/env node /** * discover-ats.mjs — Company-list → scannable ATS board resolver for career-ops * * Takes a list of companies and resolves each to a scannable ATS board by * probing the public JSON APIs career-ops already supports (see VENDOR_ORDER) * via the existing providers/ layer — zero LLM tokens, zero auth. A company * "resolves" when a vendor's board exists AND currently lists ≥1 job. * * portals.yml is a USER-LAYER file, so by DEFAULT this command is preview-only: * it prints the entries it WOULD add (pendingEntries) and writes nothing. Pass * --write to explicitly opt in to appending them to portals.yml `tracked_companies` * (a text splice that preserves the file's comments and formatting; deduped; * idempotent; atomic temp-then-rename). Companies that don't resolve — * JS-rendered portals, non-standard slugs, or Workday without a hint — are * flagged for manual follow-up instead of being silently dropped. * * Input: a YAML file `companies: [{name, slug?, website?}]` (via --in), and/or * bare company names as positional CLI args. * * Run: node discover-ats.mjs --in companies.yml (preview — writes nothing) * node discover-ats.mjs --in companies.yml --write (opt in: append to portals.yml) * node discover-ats.mjs Stripe Ramp Mollie (bare names) * node discover-ats.mjs --in companies.yml --summary (human table) * node discover-ats.mjs --in companies.yml --vendors gh,ashby * node discover-ats.mjs --self-test * * Probing hits live third-party APIs, so honor CAREER_OPS_PORTALS to point at a * scratch portals file during tests/experiments. * * Issue #1864 — github.com/career-ops-hq/career-ops */ import { readFileSync, existsSync, writeFileSync } from 'fs'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { loadYaml } from './lib/yaml-document.mjs'; import { renameSyncWithRetry } from './tracker-utils.mjs'; import { withPipelineLock } from './pipeline-lock.mjs'; import { makeHttpCtx, isRefusedRedirectError } from './providers/_http.mjs'; import greenhouse from './providers/greenhouse.mjs'; import ashby from './providers/ashby.mjs'; import lever from './providers/lever.mjs'; import workday from './providers/workday.mjs'; import workable from './providers/workable.mjs'; import smartrecruiters from './providers/smartrecruiters.mjs'; import recruitee from './providers/recruitee.mjs'; import breezy from './providers/breezy.mjs'; import bamboohr from './providers/bamboohr.mjs'; import pinpoint from './providers/pinpoint.mjs'; import rippling from './providers/rippling.mjs'; import joinProvider from './providers/join.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { DEFAULT_SMALL_THRESHOLD, isSmallBoard, smallBoardDetail } from './lib/small-board.mjs'; import { findBlacklistEntry, loadBlacklist } from './scan.mjs'; const CAREER_OPS = dirname(fileURLToPath(import.meta.url)); const DATA_ROOT = getCareerOpsRoot(); const PORTALS_PATH = process.env.CAREER_OPS_PORTALS || join(DATA_ROOT, 'portals.yml'); const BLACKLIST_PATH = process.env.CAREER_OPS_BLACKLIST || join(DATA_ROOT, 'data', 'blacklist.md'); // Safe charset for a slug that will be interpolated into an ATS URL. Consistent // with the SLUG_RE guard in scan-ats-full.mjs and seeds/vc-portfolios.mjs — a // tampered or malformed input can never inject unexpected characters into a URL. // Mixed case is intentional: Ashby boards are case-sensitive (AlephAlpha, DeepL). export const SLUG_RE = /^[A-Za-z0-9._-]+$/; // Bounded concurrency for live probes — lower than scan-ats-full.mjs's 20 // because Ashby's provider holds a ~30s connection per board. const DEFAULT_CONCURRENCY = 8; // Vendor probe registry. buildUrl(slug) produces a careers_url in the exact // shape each provider's detect() recognizes, so the probe reuses the real scan // path — a board we confirm here is exactly one scan.mjs can later read. // // Greenhouse/Ashby/Lever resolve from a single slug. Workday is different: it // needs ..myworkdayjobs.com/ — three coordinates, only // one of which (tenant ≈ slug) is derivable from a name. The site name in // particular is unguessable (e.g. "NVIDIAExternalCareerSite" vs // "External_Career_Site"), so a name alone can't resolve a Workday board. It // resolves from a Workday hint instead (see resolveWorkday): a full careers URL, // or an explicit {tenant, site[, instance]} block — with a bounded instance // auto-probe when the instance is the only missing coordinate. // `host` pins the hostname buildCandidateUrls asserts against. Vendors that put // the company in a SUBDOMAIN have a slug-dependent host, so they supply // `hostFor(slug)` instead of a constant; `buildCandidateUrls` prefers it when // present. Those hosts are matched lowercase-only by their providers, so their // buildUrl lowercases the slug rather than emitting a URL the provider would // then reject (Ashby is the reason slugs are not lowercased globally: its boards // are case-sensitive). const lower = (s) => String(s).toLowerCase(); const VENDORS = { gh: { id: 'greenhouse', provider: greenhouse, host: 'job-boards.greenhouse.io', buildUrl: (s) => `https://job-boards.greenhouse.io/${s}`, api: (s) => `https://boards-api.greenhouse.io/v1/boards/${s}/jobs` }, ashby: { id: 'ashby', provider: ashby, host: 'jobs.ashbyhq.com', buildUrl: (s) => `https://jobs.ashbyhq.com/${s}` }, lever: { id: 'lever', provider: lever, host: 'jobs.lever.co', buildUrl: (s) => `https://jobs.lever.co/${s}` }, // Long tail, probed only after the three above miss (see VENDOR_ORDER). workable: { id: 'workable', provider: workable, host: 'apply.workable.com', buildUrl: (s) => `https://apply.workable.com/${s}` }, smartrecruiters: { id: 'smartrecruiters', provider: smartrecruiters, host: 'careers.smartrecruiters.com', buildUrl: (s) => `https://careers.smartrecruiters.com/${s}` }, rippling: { id: 'rippling', provider: rippling, host: 'ats.rippling.com', buildUrl: (s) => `https://ats.rippling.com/${s}/jobs` }, join: { id: 'join', provider: joinProvider, host: 'join.com', buildUrl: (s) => `https://join.com/companies/${s}` }, recruitee: { id: 'recruitee', provider: recruitee, hostFor: (s) => `${lower(s)}.recruitee.com`, buildUrl: (s) => `https://${lower(s)}.recruitee.com` }, breezy: { id: 'breezy', provider: breezy, hostFor: (s) => `${lower(s)}.breezy.hr`, buildUrl: (s) => `https://${lower(s)}.breezy.hr` }, bamboohr: { id: 'bamboohr', provider: bamboohr, hostFor: (s) => `${lower(s)}.bamboohr.com`, buildUrl: (s) => `https://${lower(s)}.bamboohr.com` }, pinpoint: { id: 'pinpoint', provider: pinpoint, hostFor: (s) => `${lower(s)}.pinpointhq.com`, buildUrl: (s) => `https://${lower(s)}.pinpointhq.com` }, }; // Slug-resolvable vendors, probed in order for each company (first match wins). // Probe order is also a cost decision. resolveCompany probes candidates in this // order and returns on the FIRST match, so a resolvable company pays only for the // vendors ahead of its own: one probe on Greenhouse, two on Ashby, three on // Lever. Keeping the three highest-hit-rate vendors first therefore leaves every // company they can resolve costing exactly what it did before this list grew, and // the long tail is paid for only by a company none of the three could resolve. // A company on no supported board is the case that got more expensive: it now // probes every vendor before giving up, which is the honest price of the extra // coverage. const VENDOR_ORDER = ['gh', 'ashby', 'lever', 'workable', 'smartrecruiters', 'recruitee', 'bamboohr', 'breezy', 'pinpoint', 'rippling', 'join']; // Workday instance subdomains, most common first. Used only when the user gives // a tenant + site but no instance: we try each `..myworkdayjobs.com` // CXS endpoint and stop at the first that returns jobs. Bounded and ordered so a // probe is cheap and polite (a handful of requests, not a brute-force sweep). const WORKDAY_INSTANCES = ['wd1', 'wd2', 'wd3', 'wd5', 'wd10', 'wd12', 'wd101', 'wd103']; const USAGE = `Usage: node discover-ats.mjs --in companies.yml # PREVIEW — resolve + print entries, write nothing node discover-ats.mjs --in companies.yml --write # opt in: append resolved entries to portals.yml node discover-ats.mjs Stripe Ramp Mollie # company names as positional args node discover-ats.mjs --in companies.yml --summary # human-readable table node discover-ats.mjs --in companies.yml --vendors gh,ashby,lever # restrict probes node discover-ats.mjs --in companies.yml --vendors workday # Workday only node discover-ats.mjs --in companies.yml --small-threshold 10 # what counts as a small board (default ${DEFAULT_SMALL_THRESHOLD}, 0 = off) node discover-ats.mjs --self-test # inline test suite node discover-ats.mjs --help # print this usage block portals.yml is a user-layer file: this command NEVER writes it unless you pass --write. The default previews the entries it would add (see pendingEntries). Vendors: gh, ashby, lever, workable, smartrecruiters, recruitee, bamboohr, breezy, pinpoint, rippling, join (all resolve from a name/slug) and workday (resolves from a coordinate hint — a name alone can't locate a Workday site). Default: all of them, probed in that order, first match wins. Input YAML shape: companies: - name: Adyen - name: Monzo slug: monzo-bank # optional explicit slug (needed for camelCase Ashby boards) - name: Mollie website: mollie.com # optional; surfaced for unresolved companies # Workday — give a full careers URL ... - name: Nvidia workday: https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite # ... or the coordinates (instance auto-probed if omitted): - name: Salesforce workday: { tenant: salesforce, site: External_Career_Site }`; // ── Pure functions (exported for tests) ────────────────────────────── /** * Derive a URL-safe slug from a company name. Mirrors seeds/vc-portfolios.mjs. * Lowercases — so camelCase Ashby boards (AlephAlpha, DeepL) need an explicit * `slug` in the input; a derived slug will miss them. * @param {string} name * @returns {string} */ export function deriveSlug(name) { return String(name || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } /** * Parse the company list from an input YAML string and/or bare CLI names. * Never throws on malformed YAML — returns a warning instead. Drops entries * with no usable name; dedupes by lowercased name (input file wins over CLI). * * @param {string} rawYaml Contents of the --in file, or '' when none given. * @param {string[]} [cliNames] Bare positional company names. * @returns {{companies: {name:string, slug?:string, website?:string, workday?:string|object}[], warnings: string[]}} */ export function parseCompanyInput(rawYaml, cliNames = []) { const warnings = []; /** @type {Map} */ const byName = new Map(); const add = (raw, origin) => { if (!raw || typeof raw !== 'object') { if (raw !== undefined && raw !== null) warnings.push(`${origin}: dropped non-object entry`); return; } const name = typeof raw.name === 'string' ? raw.name.trim() : ''; if (!name) { warnings.push(`${origin}: dropped entry with missing/empty name`); return; } const key = name.toLowerCase(); if (byName.has(key)) return; // first occurrence wins /** @type {{name:string, slug?:string, website?:string, workday?:string|object}} */ const entry = { name }; if (typeof raw.slug === 'string' && raw.slug.trim()) entry.slug = raw.slug.trim(); if (typeof raw.website === 'string' && raw.website.trim()) entry.website = raw.website.trim(); // Workday hint: a full careers URL (string) or a {tenant, site, instance?} // object. parseWorkdayHint validates the contents downstream. Warn on a // present-but-wrong-typed value (e.g. a number) so it isn't dropped silently. if (typeof raw.workday === 'string' && raw.workday.trim()) entry.workday = raw.workday.trim(); else if (raw.workday && typeof raw.workday === 'object') entry.workday = raw.workday; else if (raw.workday !== undefined && raw.workday !== null && raw.workday !== '') { warnings.push(`${origin}: ignored "workday" hint for "${name}" — expected a URL string or {tenant, site} object`); } byName.set(key, entry); }; if (rawYaml && rawYaml.trim()) { let doc; try { doc = loadYaml(rawYaml); } catch (err) { warnings.push(`input: malformed YAML — ${err.message}`); doc = null; } const list = Array.isArray(doc?.companies) ? doc.companies : (Array.isArray(doc) ? doc : null); if (doc && !list) { warnings.push('input: expected a top-level `companies:` list (or a bare YAML list)'); } for (const item of list || []) { // Allow bare strings in the list too: `- Adyen`. add(typeof item === 'string' ? { name: item } : item, 'input'); } } for (const raw of cliNames) { if (typeof raw === 'string' && raw.trim()) add({ name: raw.trim() }, 'args'); } return { companies: [...byName.values()], warnings }; } /** * Build the candidate {vendor, slug, careers_url} probes for one company. * SLUG_RE is enforced before every interpolation — the SSRF choke point. * A vendor whose slug fails the guard is skipped (recorded in `skipped`). * A vendor that CAN'T represent this slug at all — the URL is well-formed and * on the right host, but the provider's own contract rejects its shape — is * recorded in `unsupported` and never becomes a candidate (see below). * * @param {{name:string, slug?:string}} company * @param {string[]} [vendors] Subset of VENDOR_ORDER. * @returns {{candidates: {vendor:string, slug:string, careers_url:string}[], skipped: string[], unsupported: string[]}} */ export function buildCandidateUrls(company, vendors = VENDOR_ORDER) { const slug = company.slug || deriveSlug(company.name); const candidates = []; const skipped = []; const unsupported = []; for (const vendor of vendors) { const cfg = VENDORS[vendor]; if (!cfg) continue; if (!SLUG_RE.test(slug)) { skipped.push(vendor); continue; } const careers_url = cfg.buildUrl(slug); // Defense-in-depth: re-parse the URL we just built and confirm its hostname // is EXACTLY the intended ATS host before it's ever probed — never trust the // string by shape alone. SLUG_RE already forbids '/', '@', etc., so a slug // can't smuggle in a host; this assertion makes that guarantee explicit and // survives any future change to buildUrl. (Mirrors each provider's own // new URL(...).hostname allowlist check.) let host; try { host = new URL(careers_url).hostname; } catch { host = null; } // Subdomain vendors derive their host from the slug, so the expected value is // computed the same way rather than being a constant. The assertion itself is // unchanged: whatever we are about to probe must be EXACTLY the host we meant. const expected = cfg.hostFor ? cfg.hostFor(slug) : cfg.host; if (host !== expected) { skipped.push(vendor); continue; } // Last gate, and the only one that knows each vendor's REAL slug contract: // ask the provider itself whether it can derive an API URL from this URL. // detect() is pure and local (a URL parse, no network, no side effects). // // The host assertion above can't catch this: `expected` is built by the same // concatenation as the URL, so for a subdomain vendor a dotted slug like // `foo.bar` yields host === expected === `foo.bar.bamboohr.com` and sails // through — the suffix is always appended, so nothing escapes the vendor // domain (no SSRF), but that host cannot exist. Every subdomain provider // pins a SINGLE tenant label (`^[a-z0-9][a-z0-9-]*\.bamboohr\.com$`), and // several path vendors have their own slug shape too, so their detect() // returns null. Left in, such a candidate is recorded as a probe ERROR // ("no API URL derivable") indistinguishable from a transient network // failure, which drags resolveCompany's reason to "board status unknown — // re-run" and invites a retry that can never succeed. Deriving the rule from // the provider rather than re-declaring a slug regex here means the // discovery guard and the provider contract cannot drift apart. if (!cfg.provider.detect({ name: company.name, careers_url })) { unsupported.push(vendor); continue; } candidates.push({ vendor, slug, careers_url }); } return { candidates, skipped, unsupported }; } // Coordinate token guard — tenant/instance/site segments interpolated into a // Workday host/path. Workday site names contain letters, digits, `_` and `-` // (e.g. NVIDIAExternalCareerSite, External_Career_Site); instances are wdNN. const WORKDAY_SEGMENT_RE = /^[A-Za-z0-9_-]+$/; /** * Extract Workday coordinates {tenant, instance?, site} from a company's hints. * Accepts, in priority order: * 1. A full Workday URL in `workday`, `careers_url`, or `website`, either as a * careers page or as the CXS endpoint it resolves to: * https://..myworkdayjobs.com[/]/[/...] * https://..myworkdayjobs.com/wday/cxs//[/jobs] * 2. An explicit object `workday: { tenant, site, instance? }`. * Returns null when no Workday coordinates are present. `instance` may be null * (caller then auto-probes WORKDAY_INSTANCES). Every returned segment is * guaranteed to pass WORKDAY_SEGMENT_RE. * * @param {{name?:string, workday?:string|object, careers_url?:string, website?:string}} company * @returns {{tenant:string, instance:string|null, site:string}|null} */ export function parseWorkdayHint(company) { const clean = (v) => (typeof v === 'string' && WORKDAY_SEGMENT_RE.test(v) ? v : null); // 1. Explicit object form. if (company.workday && typeof company.workday === 'object') { const tenant = clean(company.workday.tenant); const site = clean(company.workday.site); const instance = clean(company.workday.instance); if (tenant && site) return { tenant, instance: instance || null, site }; } // 2. URL form — check every field that might carry a Workday link. No // substring pre-filter here (CodeQL js/incomplete-url-substring-sanitization): // the anchored regexes below are the actual gate and already reject anything // that isn't a well-formed *.myworkdayjobs.com URL. const urlCandidates = [company.workday, company.careers_url, company.website] .filter((v) => typeof v === 'string'); for (const raw of urlCandidates) { // A CXS endpoint carries the site one level deeper, behind /wday/cxs/{tenant}/. // It also matches the careers-page pattern below, which captures the literal // `wday` as the site — here that lands in a generated portals.yml entry as // `careers_url: https://{tenant}.{instance}.myworkdayjobs.com/wday`, a // plausible-looking line for a board that doesn't exist (#3498). Checked // first, same as providers/workday.mjs resolveEndpoint(). // Both patterns are anchored: unanchored, they match a Workday URL embedded // anywhere in the candidate (e.g. `https://evil.example/r?next=https://acme // .wd5.myworkdayjobs.com/Careers`), so a redirect wrapper silently yields // coordinates for whatever tenant it carries. Anchoring is also what the // comment above has always claimed this gate does. const cxs = raw.match(/^https?:\/\/([\w-]+)\.(wd[\w-]*)\.myworkdayjobs\.com\/wday\/cxs\/[\w-]+\/([^/?#]+)(?:\/jobs)?(?:[/?#]|$)/); // The tenant comes from the host, not from the /wday/cxs/{tenant}/ segment: // these coordinates rebuild a careers URL host-first (buildWorkdayCandidates), // and the host is what has to stay reachable. const m = cxs || raw.match(/^https?:\/\/([\w-]+)\.(wd[\w-]*)\.myworkdayjobs\.com\/(?:[a-z]{2}-[A-Z]{2}\/)?([^/?#]+)/); if (!m) continue; const [, tenant, instance, site] = m; if (clean(tenant) && clean(instance) && clean(site)) { return { tenant, instance, site }; } } return null; } /** * Build the candidate Workday careers_url list for a hint. When the instance is * known, that's the single candidate; when it's null, expand across * WORKDAY_INSTANCES so the caller can probe for the live one. * * @param {{tenant:string, instance:string|null, site:string}} coords * @returns {{careers_url:string, instance:string}[]} */ export function buildWorkdayCandidates(coords) { const instances = coords.instance ? [coords.instance] : WORKDAY_INSTANCES; return instances.map((instance) => ({ instance, careers_url: `https://${coords.tenant}.${instance}.myworkdayjobs.com/${coords.site}`, })); } /** * Quote a YAML scalar only when it needs it. Bare values stay bare (matching the * existing hand-written portals.yml style); values with YAML-special characters * are double-quoted and escaped. * @param {string} value * @returns {string} */ export function yamlScalar(value) { const s = String(value ?? ''); const needsQuote = s === '' || /^[\s]|[\s]$/.test(s) || /[:#"'{}\[\],&*!|>%@`]/.test(s); if (!needsQuote) return s; return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } /** * Render one resolved match as a portals.yml tracked_companies entry snippet. * Leads with a newline so it slots cleanly against surrounding entries. Only * Greenhouse gets an `api:` line (matching every GH entry in the shipped file). * Workday gets an explicit `provider: workday` line — its detect() keys off the * myworkdayjobs.com host, but pinning it is unambiguous and matches how * provider-specific entries are written elsewhere in portals.yml. * * @param {{name:string, careers_url:string, api?:string, provider?:string, notes?:string}} match * @returns {string} */ export function renderPortalEntry(match) { const lines = [` - name: ${yamlScalar(match.name)}`]; lines.push(` careers_url: ${match.careers_url}`); if (match.api) lines.push(` api: ${match.api}`); if (match.provider) lines.push(` provider: ${match.provider}`); lines.push(` enabled: true`); if (match.notes) lines.push(` notes: ${yamlScalar(match.notes)}`); // A comment, not a key: portals.yml still parses to the same fields, but the // user sees the prompt next to the entry when they open the file (#4772). if (match.smallBoard) lines.push(` # verify: ${smallBoardDetail(match.jobCount)}`); return '\n' + lines.join('\n') + '\n'; } /** * Flag resolved boards that list few postings. Purely advisory: it adds a * `smallBoard: true` field and never changes what resolves (#4772). * * @param {any[]} resolved * @param {number} [threshold] * @returns {any[]} the same array, entries mutated in place */ export function flagSmallBoards(resolved, threshold = DEFAULT_SMALL_THRESHOLD) { for (const r of resolved) { if (isSmallBoard(r.jobCount, threshold)) r.smallBoard = true; } return resolved; } /** Normalize a careers_url/api for dedupe comparison: lowercase, strip trailing slash. */ function normalizeUrl(u) { return String(u || '').trim().toLowerCase().replace(/\/+$/, ''); } /** * Split resolved matches into {fresh, duplicates} against the tracker's existing * entries. A match is a duplicate if its lowercased name OR its normalized * careers_url/api already appears. Also self-dedupes within `fresh` (two input * companies resolving to the same board). * * @param {{name:string, careers_url:string, api?:string}[]} matches * @param {any[]} existingEntries Parsed portals.yml tracked_companies (or []). * @returns {{fresh: any[], duplicates: any[]}} */ export function dedupeAgainstPortals(matches, existingEntries) { const names = new Set(); const urls = new Set(); for (const e of Array.isArray(existingEntries) ? existingEntries : []) { if (!e || typeof e !== 'object') continue; if (typeof e.name === 'string') names.add(e.name.trim().toLowerCase()); if (e.careers_url) urls.add(normalizeUrl(e.careers_url)); if (e.api) urls.add(normalizeUrl(e.api)); } const fresh = []; const duplicates = []; for (const m of matches) { const nameKey = String(m.name || '').trim().toLowerCase(); const urlKey = normalizeUrl(m.careers_url); const apiKey = m.api ? normalizeUrl(m.api) : null; const dup = names.has(nameKey) || urls.has(urlKey) || (apiKey && urls.has(apiKey)); if (dup) { duplicates.push(m); } else { fresh.push(m); names.add(nameKey); urls.add(urlKey); if (apiKey) urls.add(apiKey); } } return { fresh, duplicates }; } /** * Splice rendered entry snippets into the tracked_companies block of a * portals.yml text, preserving every other byte (comments, other blocks, * ordering). Never re-serializes the document. * * @param {string} fileText Current portals.yml contents. * @param {string[]} snippets Output of renderPortalEntry(), one per entry. * @returns {string} */ export function insertIntoTrackedCompanies(fileText, snippets) { if (!snippets.length) return fileText; let block = snippets.join(''); // The header line as a user can legitimately write it: bare, with a trailing // comment, or as an empty flow list (`tracked_companies: []`). The last two // used to miss this match, so a SECOND `tracked_companies:` key was appended // to the file, which no YAML parser accepts. const header = fileText.match(/^tracked_companies:[ \t]*(?:\[[ \t]*\][ \t]*)?(?:#[^\n]*)?$/m); if (!header) { // No block at all — append a fresh one at EOF. const sep = fileText.endsWith('\n') ? '\n' : '\n\n'; return `${fileText}${sep}tracked_companies:${block}`; } // An empty flow list becomes the block form; its trailing comment stays. const headerText = header[0].replace(/^(tracked_companies:)[ \t]*\[[ \t]*\]/, '$1'); const headerEnd = header.index + header[0].length; // index of the newline after the header const rest = fileText.slice(headerEnd); // Find the block's end: the next top-level key (a line starting with a // non-space, non-# char and containing a colon). Comments and indented lines // stay in-block. `m` anchors ^ to line starts; the leading \n keeps us from // matching the header line itself. A list item written at column 0 (`- name:`, // valid YAML and what some YAML emitters produce) is part of the block, not a // key, so a line that starts a sequence entry is never the boundary. const boundary = rest.match(/\n(?!-(?:[ \t]|\r?\n|$))[^\s#][^\n]*:/); const blockEnd = boundary ? headerEnd + boundary.index : fileText.length; // Match the indentation of the entries already there: our snippets are // indented for the template's two-space style, and a sequence whose entries // sit at two different indents is not valid YAML. const firstItem = fileText.slice(headerEnd, blockEnd).match(/\n([ ]*)-[ \t]/); if (firstItem && firstItem[1].length !== 2) { const shift = firstItem[1].length - 2; block = block.replace(/^( *)(\S)/gm, (_m, spaces, ch) => ( shift > 0 ? `${' '.repeat(shift)}${spaces}${ch}` : `${spaces.slice(Math.min(-shift, spaces.length))}${ch}` )); } let before = fileText.slice(0, header.index) + headerText + fileText.slice(headerEnd, blockEnd); const after = fileText.slice(blockEnd); // Trim trailing blank lines that belong to the block so our leading-newline // snippets don't stack extra blank lines before the next key. const bodyStart = header.index + headerText.length; before = before.replace(/\n[ \t]*(?=\n*$)/g, (m2, off) => (off >= bodyStart ? '\n' : m2)); return before + block + after; } /** * Whether a spliced portals.yml text still parses and carries exactly the * expected number of tracked_companies entries. The text splice above works on * lines, not on the YAML, so --write checks its own output before replacing the * user's file. * * @param {string} text * @param {number} expectedCount * @returns {boolean} */ export function splicedCorrectly(text, expectedCount) { try { const doc = loadYaml(text); return Array.isArray(doc?.tracked_companies) && doc.tracked_companies.length === expectedCount; } catch { return false; } } // ── Network functions (separated from pure logic, like vc-portfolios.mjs) ── /** * Probe a single vendor candidate for one company. * @returns {Promise<{status:'match'|'empty'|'error', jobCount:number, error?:string}>} */ export async function probeVendor(company, candidate, ctx) { const cfg = VENDORS[candidate.vendor]; const entry = { name: company.name, careers_url: candidate.careers_url }; if (!cfg || !cfg.provider.detect(entry)) { return { status: 'error', jobCount: 0, error: 'no API URL derivable' }; } try { const jobs = await cfg.provider.fetch(entry, ctx); const jobCount = Array.isArray(jobs) ? jobs.length : 0; return { status: jobCount > 0 ? 'match' : 'empty', jobCount }; } catch (err) { /** @type {any} */ const out = { status: 'error', jobCount: 0, error: err?.message || String(err) }; // _http.mjs attaches the numeric status to the thrown error; keeping it is // what lets the caller tell a definitive 404 from a transient 5xx instead of // re-parsing the message text (#2883). if (Number.isInteger(err?.status)) out.httpStatus = err.status; // A refused redirect has no status at all: it is a bare `TypeError: fetch // failed`, and the vendor's actual answer lives one level down in // err.cause. Recording only err.message discarded that answer, so a // BambooHR tenant that does not exist — answered with `302 → // www.bamboohr.com`, not a 404 — reached the user as the single word // "fetch failed" under advice to re-run it. Same reasoning as the // httpStatus line above: keep the discriminator on the object rather than // re-parsing prose downstream (#3788). if (isRefusedRedirectError(err)) { out.refusedRedirect = true; out.error = `${out.error} (${err.cause.message})`; } return out; } } /** * Whether a probe failure ESTABLISHES that no board exists. * * 404 and 410 are answers: the vendor was reached and said the board is not * there. Everything else — 5xx, a timeout, DNS, a rejected redirect, or a * candidate whose API URL could not even be derived — leaves the question open, * because nothing ever answered it. * * @param {{httpStatus?: number}} probeError * @returns {boolean} */ export function isDefinitiveAbsence(probeError) { return probeError?.httpStatus === 404 || probeError?.httpStatus === 410; } /** * Resolve a Workday board from parsed coordinates. Probes each candidate host * (one, when the instance is known; a bounded list otherwise) via the real * providers/workday.mjs fetch, capped to a single CXS page (ctx.maxPages: 1 — * the provider honors this as a live-probe). First host that returns ≥1 job * wins. * * @returns {Promise<{resolved:any}|{status:'empty'|'error', tried:string[], careers_url?:string, detail?:string}>} */ export async function resolveWorkday(company, coords, ctx) { const candidates = buildWorkdayCandidates(coords); const probeCtx = { ...ctx, maxPages: 1 }; const tried = []; let emptyUrl = null; let lastError; let lastRefusedRedirect = false; for (const candidate of candidates) { tried.push(candidate.careers_url); const entry = { name: company.name, careers_url: candidate.careers_url }; if (!workday.detect(entry)) { lastError = 'no CXS endpoint derivable'; continue; } try { const jobs = await workday.fetch(entry, probeCtx); const jobCount = Array.isArray(jobs) ? jobs.length : 0; if (jobCount > 0) { return { resolved: { name: company.name, vendor: 'workday', provider: 'workday', slug: coords.tenant, careers_url: candidate.careers_url, jobCount, }, }; } // Remember the FIRST host that was confirmed live-but-empty, so the // reported emptyBoards URL is one we actually probed (not always wd1). if (!emptyUrl) emptyUrl = candidate.careers_url; } catch (err) { lastError = err?.message || String(err); // The file's second `.fetch(` site, and workday.mjs passes // redirect:'error' too — so a Workday host that redirects arrives here as // the same bare "fetch failed" probeVendor used to report. Keeping the // cause costs nothing and stops the two error paths from describing the // same failure differently (#3788). if (isRefusedRedirectError(err)) { lastError = `${lastError} (${err.cause.message})`; lastRefusedRedirect = true; } else { lastRefusedRedirect = false; } } } return emptyUrl ? { status: 'empty', tried, careers_url: emptyUrl } : { status: 'error', tried, detail: lastError, refusedRedirect: lastRefusedRedirect }; } /** * Resolve one company: probe slug vendors in VENDOR_ORDER (first with ≥1 job * wins), then — if unresolved and Workday coordinates are present/requested — * probe Workday. Returns either a resolved record or an unresolved record. */ export async function resolveCompany(company, { vendors = VENDOR_ORDER, ctx, includeWorkday = true } = {}) { const { candidates, skipped, unsupported } = buildCandidateUrls(company, vendors); const triedVendors = []; const emptyBoards = []; const errors = []; for (const candidate of candidates) { triedVendors.push(candidate.vendor); const result = await probeVendor(company, candidate, ctx); if (result.status === 'match') { const cfg = VENDORS[candidate.vendor]; /** @type {any} */ const resolved = { name: company.name, vendor: cfg.id, slug: candidate.slug, careers_url: candidate.careers_url, jobCount: result.jobCount, }; if (cfg.api) resolved.api = cfg.api(candidate.slug); return { resolved }; } if (result.status === 'empty' && candidate.vendor === 'smartrecruiters') { // SmartRecruiters' public postings API answers 200 with totalFound:0 // for ANY slug, including one that does not exist — unlike every other // probed vendor, "empty" here establishes nothing. Counting it in // emptyBoards let a single phantom hit outrank a real "not found" // verdict from every other vendor, since emptyBoards is checked first // in the reason ladder below: an unmatched company name would read as // "board(s) found but currently list 0 jobs — re-run later", advising // a retry that can never produce a different answer (#4179). Silently // excluded from both emptyBoards and errors[] — the vendor still shows // up in triedVendors, it just contributes no verdict either way. The // accepted cost: a genuinely existing SmartRecruiters board with zero // OPEN postings right now also reads as "not found" instead of "found, // empty" — cheaper than every unmatched name reading as "found". } else if (result.status === 'empty') { emptyBoards.push({ vendor: candidate.vendor, careers_url: candidate.careers_url }); } else { /** @type {any} */ const entry = { vendor: candidate.vendor, error: result.error }; if (Number.isInteger(result.httpStatus)) entry.httpStatus = result.httpStatus; if (result.refusedRedirect) entry.refusedRedirect = true; if (isDefinitiveAbsence(result)) entry.definitive = true; errors.push(entry); } } // Workday: only when a coordinate hint is present (a name alone can't resolve // a Workday site). Confirmed live via the real workday provider. const coords = includeWorkday ? parseWorkdayHint(company) : null; if (coords) { triedVendors.push('workday'); const wd = await resolveWorkday(company, coords, ctx); if (wd.resolved) return { resolved: wd.resolved }; if (wd.status === 'empty') { // Use the host resolveWorkday actually confirmed empty, not always wd1. emptyBoards.push({ vendor: 'workday', careers_url: wd.careers_url }); } else if (wd.detail) { /** @type {any} */ const wdEntry = { vendor: 'workday', error: wd.detail }; // Carried so errors[] describes a Workday refusal the same way it // describes a BambooHR one. It cannot reach the reason ladder — the // refused-redirect branch is guarded by !coords and a Workday probe only // runs WITH coords — but a machine reading errors[] should not have to // know that (#3788). if (wd.refusedRedirect) wdEntry.refusedRedirect = true; errors.push(wdEntry); } } // A Workday hint was supplied (via any field parseWorkdayHint reads) but // produced no usable coords — i.e. it was malformed/rejected. Distinct from // "no hint at all", so the message can tell the user to fix their input // rather than suggesting they add one. `includeWorkday` gates this the same // way the probe above is gated. const workdayHintProvided = includeWorkday && (typeof company.workday === 'string' ? !!company.workday.trim() : (company.workday && typeof company.workday === 'object')); // A failure is SETTLED when the vendor answered and re-running the identical // probe cannot produce a different answer. Two ways that happens, and they // mean different things: // // - a definitive 404/410 — the board is not there (#2883); // - a refused redirect — the vendor pointed us off-tenant, which says "not // this slug", NOT "no board". BambooHR does not 404 an unknown tenant; it // answers `302 → www.bamboohr.com`. MaRS Discovery District redirects on // the derived `mars-discovery-district` and returns three jobs on // `marsdd`, so calling that absence would be as wrong as calling it // transient (#3788). // // Both are settled, so neither should advise re-running the same probe; only // the first establishes absence, which is why refusedRedirect gets its own // verdict below instead of joining isDefinitiveAbsence(). const isSettled = (e) => isDefinitiveAbsence(e) || e.refusedRedirect === true; const reason = emptyBoards.length ? 'board(s) found but currently list 0 jobs — re-run later or force-add manually' // Every probe errored and nothing was confirmed absent or empty — don't // claim "no board found", the status is unknown. // // Unless every failure was SETTLED. A 404 from Greenhouse/Ashby/Lever is // an answer, not a hiccup: the board is not there and re-running will say so // again. Advising a retry in that case erased the difference between "this // company has no board" and "the network hiccuped", which is exactly the // pair a user pruning portals.yml has to tell apart (#2883). One transient // failure among them is enough to leave the question open — that vendor // never answered, so absence is not established. : (errors.length && !coords && !errors.every(isSettled)) ? 'probe error(s) occurred — board status unknown, see errors[] and re-run' // Settled, but by a redirect rather than a 404: the actionable fix is the // slug, not a retry and not a Workday hint. : (errors.length && !coords && errors.some((e) => e.refusedRedirect)) ? 'vendor(s) redirected off-tenant (' + errors.filter((e) => e.refusedRedirect).map((e) => e.vendor).join(', ') + ') — the slug is wrong, or no board exists under it. The same probe ' + 'will redirect again, so set an explicit `slug` and re-run.' // A hint was given but rejected by parseWorkdayHint (bad chars, missing // tenant/site): tell the user to fix it, not to add one. : (workdayHintProvided && !coords) ? 'Workday hint given but rejected (invalid/missing tenant or site) — check the `workday` field and re-run' : coords ? 'Workday coordinates given but no live board with open jobs found at the probed host(s).' // Nothing was probeable: every vendor's own contract rejected this // slug's shape, so no board was ruled out — say that, rather than // implying we looked and found nothing. : (!candidates.length && unsupported.length) ? `slug "${company.slug || deriveSlug(company.name)}" is not a valid board slug for any probed vendor ` + `(${unsupported.join(', ')}) — nothing was probed; fix the \`slug\` field and re-run.` // VENDOR_ORDER covers eleven vendors now, so name none of them here. : 'no supported ATS board found. If this company uses Workday, add a hint — ' + 'a full careers URL (workday: https://.wd.myworkdayjobs.com/) or ' + 'workday: {tenant, site} — and re-run; discover-ats will confirm and add it.'; /** @type {any} */ const unresolved = { name: company.name, triedVendors, reason }; if (skipped.length) unresolved.skippedUnsafeSlug = skipped; // Well-formed URL on the right host, but the vendor's own slug contract // rejects its shape — reported separately so it isn't read as a probe failure. if (unsupported.length) unresolved.unsupportedSlugShape = unsupported; if (emptyBoards.length) unresolved.emptyBoards = emptyBoards; if (errors.length) unresolved.errors = errors; if (company.website) unresolved.website = company.website; return { unresolved }; } /** * Split companies on the user's do-not-apply list (data/blacklist.md) away from * the rest, using scan.mjs's own findBlacklistEntry so discovery and scanning * agree on what is blacklisted. Before probing, a company is checked by name and * by every URL it carries (a Workday URL hint AND `website`: either one can be * the host a domain-scope entry names); after probing, `urlsOf` lets the caller * re-check a resolved board's careers_url. A blacklisted company is never * written to portals.yml: scan.mjs would drop every posting from it anyway. * * @returns {{allowed:any[], blacklisted:{name:string, entry:string, reason:string}[]}} */ export function partitionBlacklisted(companies, blacklist, urlsOf = (c) => [typeof c.workday === 'string' ? c.workday : null, c.website]) { const allowed = []; const blacklisted = []; for (const c of companies) { const urls = urlsOf(c).filter(Boolean).map(asBlacklistUrl); let hit = null; for (const url of urls.length ? urls : ['']) { hit = findBlacklistEntry(blacklist, c.name, url); if (hit) break; } if (hit) blacklisted.push({ name: c.name, entry: hit.company, reason: hit.reason }); else allowed.push(c); } return { allowed, blacklisted }; } /** * The input format documents a bare `website: mollie.com`, which `new URL()` * inside findBlacklistEntry rejects, so a scheme-less value would never reach a * domain-scope entry. Give it one; a value that already has a scheme is kept. */ function asBlacklistUrl(value) { const v = String(value).trim(); return /^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `https://${v}`; } /** Bounded-concurrency map (mirrors scan-ats-full.mjs parallelEach; not exported there). */ async function parallelEach(items, limit, fn) { let i = 0; async function worker() { while (i < items.length) { const idx = i++; await fn(items[idx], idx); } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); } /** * Probe every company (bounded concurrency) and split into resolved/unresolved. * @returns {Promise<{resolved:any[], unresolved:any[]}>} */ export async function runDiscovery(companies, { vendors = VENDOR_ORDER, ctx, concurrency = DEFAULT_CONCURRENCY, includeWorkday = true } = {}) { const results = new Array(companies.length); const httpCtx = ctx || makeHttpCtx(); await parallelEach(companies, concurrency, async (company, idx) => { results[idx] = await resolveCompany(company, { vendors, ctx: httpCtx, includeWorkday }); }); const resolved = []; const unresolved = []; for (const r of results) { if (r?.resolved) resolved.push(r.resolved); else if (r?.unresolved) unresolved.push(r.unresolved); } return { resolved, unresolved }; } // ── Summary output ──────────────────────────────────────────────────── function printSummary({ resolved, unresolved, duplicates, blacklisted = [] }) { console.log(`\n${'='.repeat(78)}`); console.log(' ATS Discovery — career-ops'); console.log(` resolved: ${resolved.length} | unresolved: ${unresolved.length} | duplicates skipped: ${duplicates.length} | blacklisted skipped: ${blacklisted.length}`); console.log(`${'='.repeat(78)}\n`); if (resolved.length) { console.log(' ' + 'Company'.padEnd(24) + 'Vendor'.padEnd(12) + 'Jobs'.padEnd(7) + 'Board'); console.log(' ' + '-'.repeat(90)); for (const r of resolved) { console.log(' ' + String(r.name).substring(0, 22).padEnd(24) + String(r.vendor).padEnd(12) + String(r.jobCount).padEnd(7) + r.careers_url + (r.smallBoard ? ` [small board — verify ownership]` : '')); } console.log(''); } if (unresolved.length) { console.log(' Unresolved (manual follow-up):'); for (const u of unresolved) { const site = u.website ? ` [${u.website}]` : ''; console.log(` - ${u.name}${site}: ${u.reason}`); } console.log(''); } if (blacklisted.length) { console.log(' Skipped — on data/blacklist.md (never added):'); for (const b of blacklisted) { console.log(` - ${b.name} (matched "${b.entry}")${b.reason ? `: ${b.reason}` : ''}`); } console.log(''); } } // ── Self-test (pure, no network) ──────────────────────────────────────── function runSelfTest() { let pass = 0; let fail = 0; const check = (cond, label) => { if (cond) { pass += 1; } else { fail += 1; console.error(` FAIL: ${label}`); } }; // deriveSlug check(deriveSlug('Trade Republic') === 'trade-republic', 'deriveSlug spaces → dashes'); check(deriveSlug(' N8N! ') === 'n8n', 'deriveSlug trims + strips punctuation'); check(deriveSlug('Adyen') === 'adyen', 'deriveSlug simple lowercases'); // parseCompanyInput const p1 = parseCompanyInput('companies:\n - name: Adyen\n - name: Monzo\n slug: monzo-bank\n', ['Ramp']); check(p1.companies.length === 3, 'parseCompanyInput merges file + CLI names'); check(p1.companies[1].slug === 'monzo-bank', 'parseCompanyInput keeps explicit slug'); const p2 = parseCompanyInput('companies:\n - name: Adyen\n', ['adyen']); check(p2.companies.length === 1, 'parseCompanyInput dedupes by lowercased name'); const p3 = parseCompanyInput(': : not valid yaml : :\n[', []); check(p3.companies.length === 0 && p3.warnings.length > 0, 'parseCompanyInput warns on malformed YAML, never throws'); const p4 = parseCompanyInput('companies:\n - name: ""\n - slug: x\n', []); check(p4.companies.length === 0, 'parseCompanyInput drops nameless entries'); // buildCandidateUrls const b1 = buildCandidateUrls({ name: 'Adyen' }); // Counted off VENDOR_ORDER rather than a literal, so adding a vendor does not // require editing an unrelated assertion. check(b1.candidates.length === VENDOR_ORDER.length, 'buildCandidateUrls emits one candidate per vendor'); check(b1.candidates[0].vendor === 'gh' && b1.candidates[0].careers_url === 'https://job-boards.greenhouse.io/adyen', 'buildCandidateUrls GH url'); // The three highest-hit-rate vendors stay first: resolveCompany returns on the // first match, so this ordering is what caps a company they can resolve at // three probes (one on gh, two on ashby, three on lever) rather than eleven. check(b1.candidates.slice(0, 3).map((c) => c.vendor).join(',') === 'gh,ashby,lever', 'buildCandidateUrls probes the common vendors first'); const b2 = buildCandidateUrls({ name: 'X', slug: 'bad/slug' }); check(b2.candidates.length === 0 && b2.skipped.length === VENDOR_ORDER.length, 'buildCandidateUrls SLUG_RE rejects unsafe slug (no URL built)'); // Long-tail vendors: path-style hosts are constant, subdomain-style hosts are // derived from the slug, and every built URL must survive its own host assertion. const byVendor = Object.fromEntries(b1.candidates.map((c) => [c.vendor, c.careers_url])); check(byVendor.workable === 'https://apply.workable.com/adyen', 'buildCandidateUrls workable url'); check(byVendor.smartrecruiters === 'https://careers.smartrecruiters.com/adyen', 'buildCandidateUrls smartrecruiters url'); check(byVendor.rippling === 'https://ats.rippling.com/adyen/jobs', 'buildCandidateUrls rippling url'); check(byVendor.join === 'https://join.com/companies/adyen', 'buildCandidateUrls join url'); check(byVendor.recruitee === 'https://adyen.recruitee.com', 'buildCandidateUrls recruitee subdomain url'); check(byVendor.breezy === 'https://adyen.breezy.hr', 'buildCandidateUrls breezy subdomain url'); check(byVendor.bamboohr === 'https://adyen.bamboohr.com', 'buildCandidateUrls bamboohr subdomain url'); check(byVendor.pinpoint === 'https://adyen.pinpointhq.com', 'buildCandidateUrls pinpoint subdomain url'); // Every candidate must be accepted by its own provider's detect(), or the probe // would report "no API URL derivable" instead of actually checking the board. check( b1.candidates.every((c) => !!VENDORS[c.vendor].provider.detect({ name: 'Adyen', careers_url: c.careers_url })), 'every built candidate URL is recognized by its provider detect()', ); // Ashby boards are case-sensitive so slugs are not lowercased globally, but the // subdomain vendors only match lowercase hosts. A mixed-case slug must still // produce a probeable URL for them rather than being silently skipped. const bMixed = buildCandidateUrls({ name: 'DeepL', slug: 'DeepL' }); const mixed = Object.fromEntries(bMixed.candidates.map((c) => [c.vendor, c.careers_url])); check(mixed.ashby === 'https://jobs.ashbyhq.com/DeepL', 'buildCandidateUrls preserves slug case for Ashby'); check(mixed.recruitee === 'https://deepl.recruitee.com', 'buildCandidateUrls lowercases a subdomain host'); check(bMixed.skipped.length === 0, 'a mixed-case slug is not skipped by the host assertion'); // Dotted slug. SLUG_RE allows dots (path vendors accept them), but a subdomain // vendor turns `foo.bar` into `foo.bar.bamboohr.com` — two tenant labels, which // every subdomain provider's `.` regex rejects. The host // assertion can't catch it (expected is built by the same concatenation), so // the provider-contract gate is what keeps it out of the probe loop. const SUBDOMAIN_VENDORS = ['recruitee', 'breezy', 'bamboohr', 'pinpoint']; const bDot = buildCandidateUrls({ name: 'X', slug: 'foo.bar' }); const dotCandidates = new Set(bDot.candidates.map((c) => c.vendor)); check( SUBDOMAIN_VENDORS.every((v) => !dotCandidates.has(v) && bDot.unsupported.includes(v)), 'a dotted slug is unsupported for every subdomain vendor, never a candidate', ); check(bDot.skipped.length === 0, 'a dotted slug is an unsupported shape, not an unsafe-slug skip'); check( bDot.candidates.length > 0 && bDot.candidates.every((c) => !!VENDORS[c.vendor].provider.detect({ name: 'X', careers_url: c.careers_url })), 'a dotted slug still probes the vendors whose contract accepts it', ); // The security property, asserted rather than argued: buildUrl always appends // the vendor suffix, so no slug can move the host off the vendor's own domain. // A dotted slug is a wasted-probe/reporting problem, not an SSRF one. const SUBDOMAIN_SUFFIX = { recruitee: '.recruitee.com', breezy: '.breezy.hr', bamboohr: '.bamboohr.com', pinpoint: '.pinpointhq.com', }; let offDomain = 0; for (const s of ['foo.bar', 'evil.com', 'a.b.c.d', '..evil.com', 'x.bamboohr.com', '169.254.169.254']) { for (const c of buildCandidateUrls({ name: 'X', slug: s }, SUBDOMAIN_VENDORS).candidates) { if (!new URL(c.careers_url).hostname.endsWith(SUBDOMAIN_SUFFIX[c.vendor])) offDomain += 1; } } check(offDomain === 0, 'no slug can move a subdomain-vendor host off the vendor domain'); const b3 = buildCandidateUrls({ name: 'Adyen' }, ['ashby']); check(b3.candidates.length === 1 && b3.candidates[0].vendor === 'ashby', 'buildCandidateUrls honors vendor subset'); // renderPortalEntry const gh = renderPortalEntry({ name: 'Adyen', careers_url: 'https://job-boards.greenhouse.io/adyen', api: 'https://boards-api.greenhouse.io/v1/boards/adyen/jobs' }); check(gh.includes(' - name: Adyen') && gh.includes(' api: https://boards-api.greenhouse.io/v1/boards/adyen/jobs'), 'renderPortalEntry GH includes api line'); const lv = renderPortalEntry({ name: 'Mistral AI', careers_url: 'https://jobs.lever.co/mistral' }); check(!lv.includes('api:'), 'renderPortalEntry non-GH omits api line'); const q = renderPortalEntry({ name: 'Foo: Bar', careers_url: 'https://jobs.ashbyhq.com/foo' }); check(q.includes('name: "Foo: Bar"'), 'renderPortalEntry quotes name with colon'); // small-board flag (#4772): advisory field + `# verify:` comment, only when flagged const sb = flagSmallBoards([{ name: 'A', jobCount: 1 }, { name: 'B', jobCount: 6 }]); check(sb[0].smallBoard === true && sb[1].smallBoard === undefined, 'flagSmallBoards flags only boards at or under the threshold'); check(renderPortalEntry({ name: 'A', careers_url: 'https://x', smallBoard: true, jobCount: 1 }).includes('# verify:'), 'renderPortalEntry adds # verify: for a small board'); check(!renderPortalEntry({ name: 'B', careers_url: 'https://x', jobCount: 6 }).includes('# verify:'), 'renderPortalEntry omits # verify: for a larger board'); // dedupeAgainstPortals const existing = [{ name: 'Adyen', careers_url: 'https://job-boards.greenhouse.io/adyen/' }]; const d1 = dedupeAgainstPortals([{ name: 'Adyen', careers_url: 'x' }], existing); check(d1.duplicates.length === 1 && d1.fresh.length === 0, 'dedupe by name hit'); const d2 = dedupeAgainstPortals([{ name: 'Other', careers_url: 'https://job-boards.greenhouse.io/adyen' }], existing); check(d2.duplicates.length === 1, 'dedupe by careers_url hit (trailing slash normalized)'); const d3 = dedupeAgainstPortals([{ name: 'A', careers_url: 'u1' }, { name: 'A', careers_url: 'u2' }], []); check(d3.fresh.length === 1, 'dedupe self-dedupes within fresh'); // insertIntoTrackedCompanies — normal block with trailing top-level key const doc = 'title_filter:\n positive: [a]\n\ntracked_companies:\n - name: Existing\n careers_url: https://jobs.lever.co/existing\n\njob_boards:\n - name: Foo\n'; const snippet = renderPortalEntry({ name: 'New', careers_url: 'https://jobs.lever.co/new' }); const inserted = insertIntoTrackedCompanies(doc, [snippet]); check(inserted.indexOf('- name: New') < inserted.indexOf('job_boards:'), 'insert lands before job_boards:'); check(inserted.indexOf('- name: New') > inserted.indexOf('tracked_companies:'), 'insert lands after tracked_companies:'); check(inserted.startsWith('title_filter:\n positive: [a]\n'), 'insert preserves leading bytes'); check(inserted.includes('job_boards:\n - name: Foo\n'), 'insert preserves trailing block'); // idempotency via dedupe: the same board already present → nothing fresh → no insert const parsed = loadYaml(inserted); const again = dedupeAgainstPortals([{ name: 'New', careers_url: 'https://jobs.lever.co/new' }], parsed.tracked_companies); check(again.fresh.length === 0, 'insert is idempotent through dedupe'); // missing header → appended const noHeader = insertIntoTrackedCompanies('title_filter:\n positive: [a]\n', [snippet]); check(/tracked_companies:/.test(noHeader) && noHeader.includes('- name: New'), 'insert appends fresh block when header missing'); // empty block const emptyBlock = insertIntoTrackedCompanies('tracked_companies:\njob_boards:\n - name: Foo\n', [snippet]); check(emptyBlock.indexOf('- name: New') < emptyBlock.indexOf('job_boards:'), 'insert handles empty block'); // parseWorkdayHint — URL form const wh1 = parseWorkdayHint({ name: 'Nvidia', workday: 'https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite' }); check(wh1 && wh1.tenant === 'nvidia' && wh1.instance === 'wd5' && wh1.site === 'NVIDIAExternalCareerSite', 'parseWorkdayHint parses full URL'); const wh1b = parseWorkdayHint({ name: 'X', careers_url: 'https://acme.wd3.myworkdayjobs.com/en-US/Careers/job/foo' }); check(wh1b && wh1b.tenant === 'acme' && wh1b.instance === 'wd3' && wh1b.site === 'Careers', 'parseWorkdayHint strips locale + trailing path'); // object form, instance optional const wh2 = parseWorkdayHint({ name: 'Salesforce', workday: { tenant: 'salesforce', site: 'External_Career_Site' } }); check(wh2 && wh2.tenant === 'salesforce' && wh2.instance === null && wh2.site === 'External_Career_Site', 'parseWorkdayHint object form, null instance'); // no hint → null check(parseWorkdayHint({ name: 'Adyen' }) === null, 'parseWorkdayHint returns null without hint'); // unsafe segment rejected check(parseWorkdayHint({ name: 'X', workday: { tenant: 'bad/tenant', site: 'S' } }) === null, 'parseWorkdayHint rejects unsafe segment'); // buildWorkdayCandidates const wc1 = buildWorkdayCandidates({ tenant: 'nvidia', instance: 'wd5', site: 'NVIDIAExternalCareerSite' }); check(wc1.length === 1 && wc1[0].careers_url === 'https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite', 'buildWorkdayCandidates known instance → single URL'); const wc2 = buildWorkdayCandidates({ tenant: 'salesforce', instance: null, site: 'External_Career_Site' }); check(wc2.length === WORKDAY_INSTANCES.length && wc2[0].careers_url.includes('salesforce.wd1.'), 'buildWorkdayCandidates null instance → expands across instances'); // renderPortalEntry — workday provider line const wdEntry = renderPortalEntry({ name: 'Nvidia', careers_url: 'https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite', provider: 'workday' }); check(wdEntry.includes(' provider: workday') && !wdEntry.includes('api:'), 'renderPortalEntry emits provider: workday, no api line'); console.log(`\n discover-ats self-test: ${pass} passed, ${fail} failed\n`); process.exit(fail > 0 ? 1 : 0); } // ── CLI arg parsing ────────────────────────────────────────────────── // --write is the explicit opt-in to modify portals.yml (a user-layer file); // without it the run is preview-only. --dry-run is accepted as a harmless alias // for "don't write" (the default) so an older invocation never surprises anyone. const KNOWN_FLAGS = ['--in', '--vendors', '--small-threshold', '--write', '--dry-run', '--summary', '--self-test', '--help', '-h']; const VALUE_FLAGS = ['--in', '--vendors', '--small-threshold']; function parseArgs(argv) { const args = argv.slice(2); if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); process.exit(0); } 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; }; // --vendors accepts the slug vendors (gh/ashby/lever) plus `workday` as a // toggle. Workday only fires when a company carries a coordinate hint; listing // it here alongside slug vendors lets a user scope a run to Workday alone // (--vendors workday) or drop it (--vendors gh,ashby,lever). const vendorsArg = valueOf('--vendors'); const requested = vendorsArg ? vendorsArg.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) : [...VENDOR_ORDER, 'workday']; const validVendors = new Set([...VENDOR_ORDER, 'workday']); const unknownVendors = requested.filter((v) => !validVendors.has(v)); if (unknownVendors.length) { console.error(`Error: unknown vendor(s): ${unknownVendors.join(', ')}. Valid: ${[...validVendors].join(', ')}`); process.exit(1); } const vendors = requested.filter((v) => VENDORS[v]); const includeWorkday = requested.includes('workday'); // Positional args (not flags, not a consumed flag value) are company names. const names = args.filter((a, idx) => !a.startsWith('-') && !consumedValueIndices.has(idx)); // Same shape check as audit-portals.mjs: a non-negative number, 0 = off. const rawThreshold = valueOf('--small-threshold'); const smallThreshold = rawThreshold === null ? DEFAULT_SMALL_THRESHOLD : Number(rawThreshold); if (!Number.isFinite(smallThreshold) || smallThreshold < 0 || String(rawThreshold).trim() === '') { console.error(`Error: --small-threshold expects a non-negative number, got "${rawThreshold}"`); process.exit(1); } return { smallThreshold, inPath: valueOf('--in'), vendors, includeWorkday, // Preview by default; only --write may touch portals.yml. --dry-run is an // accepted no-op alias (it just reaffirms the default). write: args.includes('--write'), summary: args.includes('--summary'), selfTest: args.includes('--self-test'), names, }; } // ── Main ────────────────────────────────────────────────────────────── async function main() { const opts = parseArgs(process.argv); if (opts.selfTest) runSelfTest(); let rawYaml = ''; if (opts.inPath) { const path = resolve(process.cwd(), opts.inPath); if (!existsSync(path)) { console.error(`Error: input file not found: ${opts.inPath}`); process.exit(1); } rawYaml = readFileSync(path, 'utf-8'); } const { companies, warnings } = parseCompanyInput(rawYaml, opts.names); if (companies.length === 0) { // Emit the same metadata shape as the normal path (documented in // modes/discover.md) so a consumer reading previewOnly/portalsPath on a // zero-company run gets the documented envelope, not undefined fields. const out = { metadata: { resolved: 0, unresolved: 0, duplicatesSkipped: 0, blacklistedSkipped: 0, fresh: 0, freshWritten: 0, written: false, previewOnly: true, portalsPath: PORTALS_PATH, warnings, }, resolved: [], unresolved: [], blacklisted: [], }; if (opts.summary) printSummary({ resolved: [], unresolved: [], duplicates: [] }); else console.log(JSON.stringify(out, null, 2)); process.exit(0); } const blacklist = loadBlacklist(BLACKLIST_PATH); const pre = partitionBlacklisted(companies, blacklist); const discovered = await runDiscovery(pre.allowed, { vendors: opts.vendors, includeWorkday: opts.includeWorkday }); const unresolved = discovered.unresolved; // A resolved board's host can hit a domain-scope entry the name did not // (e.g. a company listed under a different name on a blacklisted host). const post = partitionBlacklisted(discovered.resolved, blacklist, (r) => [r.careers_url]); const resolved = post.allowed; const blacklisted = [...pre.blacklisted, ...post.blacklisted]; // Advisory only: resolution and exit codes are unchanged (#4772). flagSmallBoards(resolved, opts.smallThreshold); // Hold the portals lock across dedupe and replacement so concurrent // --write runs merge their discoveries instead of overwriting each other. const updatePortals = () => { // Dedupe resolved matches against the existing tracker. let existingEntries = []; if (existsSync(PORTALS_PATH)) { try { const parsed = loadYaml(readFileSync(PORTALS_PATH, 'utf-8')); existingEntries = Array.isArray(parsed?.tracked_companies) ? parsed.tracked_companies : []; } catch (err) { warnings.push(`portals.yml: could not parse for dedupe — ${err.message}`); } } const { fresh, duplicates } = dedupeAgainstPortals(resolved, existingEntries); const snippets = fresh.map(renderPortalEntry); // Data-contract rule: portals.yml is a USER-LAYER file and is NEVER written // unless the user explicitly opts in with --write. The default is preview — // we print the entries we WOULD add and touch nothing. This mirrors how the // rest of career-ops treats user files (see DATA_CONTRACT.md). let written = false; if (opts.write && fresh.length && existsSync(PORTALS_PATH)) { const current = readFileSync(PORTALS_PATH, 'utf-8'); const next = insertIntoTrackedCompanies(current, snippets); if (splicedCorrectly(next, existingEntries.length + fresh.length)) { // Write-to-temp-then-rename: atomic on the same filesystem, so a crash // mid-write can't leave the user's portals.yml truncated. const tmpPath = `${PORTALS_PATH}.tmp-${process.pid}`; writeFileSync(tmpPath, next, 'utf-8'); renameSyncWithRetry(tmpPath, PORTALS_PATH); written = true; } else { // A text splice cannot cover every way tracked_companies can be written. // Rather than leave a portals.yml the scanner cannot parse, keep the file // as it is and print the entries for the user to paste. warnings.push(`--write skipped — splicing into ${PORTALS_PATH} would not leave it as valid YAML with ${existingEntries.length + fresh.length} tracked_companies entries; the file is unchanged and the entries are shown in pendingEntries to add by hand`); } } else if (opts.write && fresh.length && !existsSync(PORTALS_PATH)) { warnings.push(`--write given but portals.yml not found at ${PORTALS_PATH} — printing entries instead`); } else if (!opts.write && fresh.length) { warnings.push(`preview only — ${fresh.length} new entr${fresh.length === 1 ? 'y' : 'ies'} shown in pendingEntries; re-run with --write to append them to portals.yml`); } return { fresh, duplicates, snippets, written }; }; const { fresh, duplicates, snippets, written } = opts.write ? await withPipelineLock(PORTALS_PATH, updatePortals) : updatePortals(); const metadata = { resolved: resolved.length, unresolved: unresolved.length, duplicatesSkipped: duplicates.length, blacklistedSkipped: blacklisted.length, fresh: fresh.length, freshWritten: written ? fresh.length : 0, written, previewOnly: !written, portalsPath: PORTALS_PATH, warnings, }; if (opts.summary) { printSummary({ resolved, unresolved, duplicates, blacklisted }); } else { const out = { metadata, resolved, unresolved, blacklisted }; // Show the would-be YAML whenever we didn't write it (preview, or --write // that couldn't find the file), so the user can paste it manually. if (!written) out.pendingEntries = snippets.join(''); console.log(JSON.stringify(out, null, 2)); } // No process.exit(0) here: main() returns, and the process exits 0 once the // event loop drains. On Windows, Node 24 aborts when process.exit() runs right // after a request (nodejs/node#56645): it prints a libuv assertion and exits // with a crash code instead of 0, the crash reported in #3788. Measured // 2026-11-08 on Node 24.14.1: a run that resolved one BambooHR board, or found // no Greenhouse, Ashby or BambooHR board, aborted 4 times out of 4. The exits // earlier in main() run before any request, so they keep process.exit(). } // --- Run (CLI only; guarded so the module is safely importable for tests) --- if (isMainModule(import.meta.url)) { main().catch((err) => { console.error(`discover-ats: ${err?.stack || err?.message || err}`); process.exitCode = 1; // not process.exit(1), for the reason given at the end of main() }); }