Files
santifer--career-ops/scan.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

1787 lines
72 KiB
JavaScript

#!/usr/bin/env node
/**
* scan.mjs — Zero-token portal scanner with a plugin-based provider layer.
*
* Providers live in providers/*.mjs and are loaded at startup. Each provider
* exports a default object with:
* - id: string — matched against `provider:` in portals.yml
* - detect(entry): {url}|null — optional auto-detection from careers_url
* - fetch(entry, ctx): [{title,url,company,location}] — required
*
* Files prefixed with _ are shared helpers (e.g. _http.mjs) and are never
* loaded as providers. Adding a new HTTP/API source = drop a *.mjs into
* providers/. Local executable parsers use `providers/local-parser.mjs` when
* `parser.command` + `parser.script` are set in portals.yml.
*
* A tracked_companies entry can set `provider:` explicitly to bypass
* URL-based auto-detection. The `transport:` field is reserved for future
* transports — Phase A only ships the http transport.
*
* Zero Claude API tokens — pure HTTP + JSON.
*
* Usage:
* node scan.mjs # scan all enabled companies
* node scan.mjs --dry-run # preview without writing files
* node scan.mjs --company Cohere # scan a single company
* node scan.mjs --verify # Playwright-check each new URL; drop expired postings
* node scan.mjs --verify --headed-fallback # retry anti-bot-blocked URLs in a headed browser (needs a display)
* node scan.mjs --verify --throttle # jittered ~5-10s gap between checks (stay under rate limits)
* node scan.mjs --verify --throttle=8000 # custom base gap in ms (waits base..2*base)
* node scan.mjs --include-blacklisted # let data/blacklist.md matches through (annotated)
*/
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
import { pathToFileURL, fileURLToPath } from 'url';
import path from 'path';
import yaml from 'js-yaml';
import { makeHttpCtx } from './providers/_http.mjs';
import { buildTrustValidator } from './providers/_trust-validator.mjs';
import { loadProviders, resolveProvider } from './providers/_registry.mjs';
import { mergeProviderPlugins } from './plugins/_engine.mjs';
import { classifyFetchError } from './verify-portals.mjs';
import { fingerprintText, findCrossListings } from './fingerprint-core.mjs';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
import { normalizeCompany } from './tracker-utils.mjs';
try {
const { config } = await import('dotenv');
config();
} catch {
// dotenv is optional — fall back to process.env if not installed
}
const parseYaml = yaml.load;
// ── Config ──────────────────────────────────────────────────────────
const PORTALS_PATH = process.env.CAREER_OPS_PORTALS || 'portals.yml';
const PROFILE_PATH = process.env.CAREER_OPS_PROFILE || 'config/profile.yml';
const SCAN_HISTORY_PATH = 'data/scan-history.tsv';
const PIPELINE_PATH = 'data/pipeline.md';
const APPLICATIONS_PATH = 'data/applications.md';
const PROVIDERS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'providers');
// Ensure required directories exist (fresh setup)
mkdirSync('data', { recursive: true });
const CONCURRENCY = 10;
// Provider loading + routing live in providers/_registry.mjs so the portal
// health check (verify-portals.mjs) can reuse the exact same layer without
// importing this module.
// ── Title filter ────────────────────────────────────────────────────
// Compile a lowercased keyword into a matcher. Short all-letter acronyms
// (2-3 chars: cfo, coo, sdr, bdr, gsi…) match on WORD BOUNDARIES so "COO" no
// longer matches "Coordinator", "SDR" no longer matches anything mid-word, etc.
// Multi-word phrases and keywords containing non-letters (".NET", "SAP ",
// "L&D") keep fast, permissive substring matching.
export function compileKeyword(kw) {
if (/^[a-z]{2,3}$/.test(kw)) {
const re = new RegExp(`\\b${kw}\\b`);
return (lower) => re.test(lower);
}
return (lower) => lower.includes(kw);
}
export function buildTitleFilter(titleFilter) {
// Normalize defensively: a malformed title_filter (a null, numeric, or otherwise
// non-string entry in the YAML) must not crash the scan via k.toLowerCase().
const normalize = (arr) => (Array.isArray(arr) ? arr : [])
.filter(k => typeof k === 'string')
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0)
.map(compileKeyword);
const positive = normalize(titleFilter?.positive);
const negative = normalize(titleFilter?.negative);
return (title) => {
const lower = (title || '').toLowerCase();
const hasPositive = positive.length === 0 || positive.some(m => m(lower));
const hasNegative = negative.some(m => m(lower));
return hasPositive && !hasNegative;
};
}
// Compiled-matcher cache for matchedTitleKeywords(), keyed by the
// `title_filter.positive` array reference. The scan loop calls this once per
// job with the same titleFilter config object, so caching avoids recompiling
// every keyword (compileKeyword()) on every single job.
const compiledPositiveCache = new WeakMap();
function compiledPositiveMatchers(positiveList) {
if (compiledPositiveCache.has(positiveList)) return compiledPositiveCache.get(positiveList);
const compiled = positiveList
.filter(k => typeof k === 'string' && k.trim().length > 0)
.map(k => ({ raw: k, match: compileKeyword(k.trim().toLowerCase()) }));
compiledPositiveCache.set(positiveList, compiled);
return compiled;
}
// Returns the raw (as-written in portals.yml) `title_filter.positive` keywords
// that matched a given title — used to scope `content_filter.by_title_keyword`
// overrides to only the categories that opted into a stricter content check.
export function matchedTitleKeywords(title, titleFilter) {
const raw = Array.isArray(titleFilter?.positive) ? titleFilter.positive : [];
const lower = (title || '').toLowerCase();
return compiledPositiveMatchers(raw)
.filter(({ match }) => match(lower))
.map(({ raw: kw }) => kw);
}
// ── Location filter ─────────────────────────────────────────────────
// Optional. If `location_filter` is absent from portals.yml, all locations pass.
// Semantics (case-insensitive substring, in this order):
// - Empty / whitespace-only / non-string location → pass (don't penalize
// missing or malformed provider data)
// - `always_allow` matches → pass (takes precedence over `block` — lets a
// multi-location string like "Remote, Belgium or France" through because
// the home region is an option, even though "france" is blocked)
// - `block` matches → reject
// - `allow` empty → pass (already cleared block)
// - `allow` non-empty → must match at least one keyword
// Normalize a keyword list from portals.yml: tolerates a bare string
// (wrapped to a 1-item array), null/undefined (→ []), and non-string
// entries (filtered out). Survivors are lowercased, trimmed, and any
// resulting empty strings are dropped — an empty keyword would otherwise
// match every location via String.includes(''), silently bypassing the
// other tiers.
function normalizeKeywordList(value) {
if (value == null) return [];
const arr = Array.isArray(value) ? value : [value];
return arr
.filter(k => typeof k === 'string')
.map(k => k.toLowerCase().trim())
.filter(Boolean);
}
export function buildLocationFilter(locationFilter) {
if (!locationFilter) return () => true;
const alwaysAllow = normalizeKeywordList(locationFilter.always_allow);
const allow = normalizeKeywordList(locationFilter.allow);
const block = normalizeKeywordList(locationFilter.block);
return (location) => {
if (typeof location !== 'string' || location.trim() === '') return true;
const lower = location.toLowerCase();
if (alwaysAllow.length > 0 && alwaysAllow.some(k => lower.includes(k))) return true;
if (block.length > 0 && block.some(k => lower.includes(k))) return false;
if (allow.length === 0) return true;
return allow.some(k => lower.includes(k));
};
}
// ── Posting-age filter ──────────────────────────────────────────────
// Optional opt-in. If `max_posting_age_days` is absent (or not a positive
// integer) in portals.yml, every offer passes. An offer is skipped only when
// the provider supplied a postedAt (epoch ms) AND it is older than N days.
// Offers with no date always pass — same "don't penalize missing data"
// convention as the location filter. `now` is injectable for deterministic tests.
export function buildPostingAgeFilter(maxAgeDays, now = Date.now()) {
const max = Number(maxAgeDays);
if (!Number.isInteger(max) || max <= 0) return () => true;
const cutoff = now - max * 24 * 60 * 60 * 1000; // N days in ms, subtracted from now
return (postedAt) => {
if (typeof postedAt !== 'number' || !Number.isFinite(postedAt)) return true;
return postedAt >= cutoff;
};
}
// ── Content filter ──────────────────────────────────────────────────
// Optional. If `content_filter` is absent from portals.yml, all jobs pass.
// Filters on the job DESCRIPTION text to separate same-titled roles with
// different stacks (a "Software Engineer" listing that mentions "PHP" vs one
// that mentions "Rust"). Semantics (case-insensitive substring, in order):
// - Empty / whitespace-only / non-string description → PASS. The scanner is
// zero-token and only sees descriptions a provider already returns in its
// list payload; providers without one must never be silently dropped.
// - any `negative` keyword present → reject
// - `positive` empty → pass (already cleared negatives)
// - `positive` non-empty → at least one keyword must be present
//
// `content_filter.by_title_keyword` (optional): scopes a stricter positive/
// negative pair to only the jobs whose title matched a specific
// `title_filter.positive` keyword, so e.g. an "AI Engineer" title-match can
// require the description to actually mention a concrete AI tool, without
// that requirement leaking onto unrelated categories like "Instructional
// Designer". When one or more of a job's matched title keywords has an
// override, the overrides govern (any override passing is enough); the
// global `positive`/`negative` pair is the fallback for jobs whose matched
// keyword(s) have no override entry.
//
// Provider support: only providers whose list API ships the description for
// free (no extra per-job request, which would break the zero-token design)
// populate `job.description`. Lever (`descriptionPlain`) does today; others
// leave it empty and therefore always pass this filter.
export function buildContentFilter(contentFilter) {
if (!contentFilter) return () => true;
const positive = normalizeKeywordList(contentFilter.positive);
const negative = normalizeKeywordList(contentFilter.negative);
const byTitleKeyword = new Map();
if (contentFilter.by_title_keyword && typeof contentFilter.by_title_keyword === 'object' && !Array.isArray(contentFilter.by_title_keyword)) {
for (const [kw, rule] of Object.entries(contentFilter.by_title_keyword)) {
if (typeof kw !== 'string' || !kw.trim()) continue;
byTitleKeyword.set(kw.trim().toLowerCase(), {
positive: normalizeKeywordList(rule?.positive),
negative: normalizeKeywordList(rule?.negative),
});
}
}
return (description, matchedKeywords = []) => {
if (typeof description !== 'string' || description.trim() === '') return true;
const lower = description.toLowerCase();
const overrides = matchedKeywords
.filter(k => typeof k === 'string')
.map(k => byTitleKeyword.get(k.trim().toLowerCase()))
.filter(Boolean);
if (overrides.length > 0) {
return overrides.some(rule => {
if (rule.negative.length > 0 && rule.negative.some(k => lower.includes(k))) return false;
if (rule.positive.length === 0) return true;
return rule.positive.some(k => lower.includes(k));
});
}
if (negative.length > 0 && negative.some(k => lower.includes(k))) return false;
if (positive.length === 0) return true;
return positive.some(k => lower.includes(k));
};
}
// ── Salary filter ───────────────────────────────────────────────────
// Optional. If `salary_filter` is absent from portals.yml, all salaries pass.
// Semantics:
// - min/max are annual compensation filters (use annualized values)
// - max: 0 means "no upper limit"
// - If no salary data exists on a job, it passes (conservative behavior)
// - If both currencies are known and mismatch (e.g., USD filter, EUR job), it fails
// - Partial ranges (min only or max only) work correctly via overlap logic
// Uses null-safe checks (!= null, ??) to preserve 0 values correctly.
export function buildSalaryFilter(salaryFilter) {
if (!salaryFilter) return () => true;
// Coerce and validate bounds — malformed YAML must not silently mis-filter
const min = Number(salaryFilter.min ?? 0);
const max = Number(salaryFilter.max ?? 0);
const filterCurrency = (salaryFilter.currency || '').trim().toUpperCase();
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < 0) {
console.error('Warning: salary_filter.min/max must be non-negative numbers — salary filter disabled');
return () => true;
}
if (max > 0 && min > max) {
console.error('Warning: salary_filter.min cannot exceed salary_filter.max — salary filter disabled');
return () => true;
}
// If both min and max are 0, no filtering applied
if (min === 0 && max === 0) return () => true;
return (salary) => {
// If no salary data exists, pass (conservative - many providers don't expose salary)
if (!salary) return true;
const jobMin = salary.min ?? salary.max ?? null;
const jobMax = salary.max ?? salary.min ?? null;
// If we have no usable salary values, pass conservatively
if (jobMin == null && jobMax == null) return true;
// Currency handling - reject only if BOTH currencies exist and mismatch
const jobCurrency = (salary.currency || '').trim().toUpperCase();
if (filterCurrency && jobCurrency && filterCurrency !== jobCurrency) {
return false;
}
// Range overlap logic - reject ONLY if job is completely outside filter range
// Job entirely below user minimum
if (min > 0 && jobMax != null && jobMax < min) {
return false;
}
// Job entirely above user maximum
if (max > 0 && jobMin != null && jobMin > max) {
return false;
}
// Otherwise pass (overlap exists or no valid range to compare)
return true;
};
}
export function companyMatch(jobCompany, windowCompany) {
const cleanNoSpaces = (str) => String(str || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const c1NoSpaces = cleanNoSpaces(jobCompany);
const c2NoSpaces = cleanNoSpaces(windowCompany);
if (c1NoSpaces === c2NoSpaces) return true;
const cleanWithSpaces = (str) => String(str || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
const c1WithSpaces = cleanWithSpaces(jobCompany);
const c2WithSpaces = cleanWithSpaces(windowCompany);
if (!c1WithSpaces || !c2WithSpaces) return false;
const regex1 = new RegExp('\\b' + c2WithSpaces.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '\\b');
const regex2 = new RegExp('\\b' + c1WithSpaces.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '\\b');
return regex1.test(c1WithSpaces) || regex2.test(c2WithSpaces);
}
export function addDays(dateStr, days) {
const date = new Date(`${dateStr}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
export function loadReApplyWindows(profilePath = PROFILE_PATH) {
if (!existsSync(profilePath)) return {};
try {
const raw = yaml.load(readFileSync(profilePath, 'utf-8')) || {};
const windows = raw.re_apply_windows || {};
const validWindows = {};
for (const [company, win] of Object.entries(windows)) {
if (!win || typeof win !== 'object') continue;
const lastApplyDate = win.last_apply_date;
if (typeof lastApplyDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(lastApplyDate)) continue;
if (isNaN(Date.parse(lastApplyDate))) continue;
const sameRoleDays = win.same_role_days;
if (sameRoleDays !== undefined && (!Number.isInteger(sameRoleDays) || sameRoleDays < 0)) continue;
if (win.applied_to !== undefined && !Array.isArray(win.applied_to)) continue;
if (win.applied_to !== undefined && win.applied_to.some(x => typeof x !== 'string')) continue;
if (win.cross_role_bucket !== undefined && typeof win.cross_role_bucket !== 'string') continue;
validWindows[company] = win;
}
return validWindows;
} catch {
return {};
}
}
export function buildCooldownFilter(windows, today) {
if (!windows || Object.keys(windows).length === 0) {
return () => ({ skip: false });
}
const genericKeywords = new Set(['all', 'roles', 'role', 'family', 'bucket', 'group', 'team']);
return (job) => {
const jobCompany = job.company || '';
const jobTitleLower = (job.title || '').toLowerCase();
for (const [windowCompany, window] of Object.entries(windows)) {
if (companyMatch(jobCompany, windowCompany)) {
const lastApplyDate = window.last_apply_date;
const sameRoleDays = Number(window.same_role_days || 0);
if (!lastApplyDate) continue;
const cooldownUntil = addDays(lastApplyDate, sameRoleDays);
if (today >= cooldownUntil) {
continue;
}
if (Array.isArray(window.applied_to)) {
const matchesApplied = window.applied_to.some(role => {
const roleLower = role.toLowerCase();
return jobTitleLower.includes(roleLower);
});
if (matchesApplied) {
return { skip: true, reason: `cooldown:${windowCompany}:${cooldownUntil}`, cooldownUntil };
}
}
if (window.cross_role_bucket) {
const bucketKeywords = window.cross_role_bucket
.toLowerCase()
.split('_')
.filter(kw => kw && !genericKeywords.has(kw));
const matchesBucket = bucketKeywords.some(kw => {
if (kw === 'em') {
return /\bem\b/i.test(jobTitleLower) || jobTitleLower.includes('engineering manager');
}
return jobTitleLower.includes(kw);
});
if (matchesBucket) {
return { skip: true, reason: `cooldown:${windowCompany}:${cooldownUntil}`, cooldownUntil };
}
}
}
}
return { skip: false };
};
}
// ── URL rediscovery (--rediscover-404) ──────────────────────────────
// When a tracked company's job URL returns 404/410, the role may have just
// moved to a new URL (Workday/Greenhouse rotate URLs without closing roles).
// These helpers back an opt-in search-and-reverify fallback before giving up.
// extractCareersUrlDomain returns the hostname of a company's careers_url, or
// null when it's missing/unparseable. The presence of a domain is what gates
// the fallback — broad-discovery offers without a careers_url stay ineligible.
export function extractCareersUrlDomain(careersUrl) {
if (!careersUrl) return null;
try {
return new URL(careersUrl).hostname;
} catch {
return null;
}
}
// resolveSearchHref unwraps a DuckDuckGo HTML redirect (`/l/?uddg=<encoded>`)
// to its real destination, so domain matching sees the actual host instead of
// duckduckgo.com. Non-redirect hrefs pass through unchanged.
function resolveSearchHref(href) {
try {
const u = new URL(href, 'https://duckduckgo.com');
const isDdgHost = u.hostname === 'duckduckgo.com' || u.hostname.endsWith('.duckduckgo.com');
if (isDdgHost && u.pathname === '/l/') {
const target = u.searchParams.get('uddg');
if (target) return target;
}
} catch {
/* fall through to the raw href */
}
return href;
}
// pickRediscoveredUrl chooses the first result whose hostname *exactly* equals
// the careers domain (no substring/look-alike matches), unwrapping search-engine
// redirects first. Pure + exported so result-matching is unit-testable without
// driving a real browser. Returns null when nothing matches.
export function pickRediscoveredUrl(hrefs, domain) {
if (!domain || !Array.isArray(hrefs)) return null;
for (const raw of hrefs) {
const href = resolveSearchHref(raw);
let host;
try {
host = new URL(href).hostname;
} catch {
continue;
}
if (host === domain) return href;
}
return null;
}
// REDISCOVER_TIMEOUT_MS bounds the single fallback search so a slow or blocked
// search engine can't stall the sequential verify loop.
const REDISCOVER_TIMEOUT_MS = 10_000;
// searchForNewUrl runs one site-scoped search for a moved tracked role and
// returns a same-domain URL if found, else null. Every failure path returns
// null — the fallback must never throw into the verify loop. Leaves the page on
// a blank document so the next checkUrlLiveness call starts clean.
async function searchForNewUrl(page, offer) {
const domain = offer.careersUrlDomain;
if (!domain) return null;
const query = `"${offer.title}" "${offer.company}" site:${domain}`;
try {
await page.goto(
`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
{ waitUntil: 'domcontentloaded', timeout: REDISCOVER_TIMEOUT_MS },
);
const hrefs = await page.evaluate(() =>
Array.from(document.querySelectorAll('a.result__a'))
.map((a) => a.getAttribute('href'))
.filter(Boolean),
);
return pickRediscoveredUrl(hrefs, domain);
} catch {
return null;
} finally {
try {
await page.goto('about:blank');
} catch {
/* ignore — best-effort cleanup */
}
}
}
// ── Dedup ───────────────────────────────────────────────────────────
const PERMANENT_SCAN_HISTORY_STATUSES = new Set([
'skipped_invalid_url',
'skipped_blocked_host',
]);
function daysBetweenIsoDates(start, end) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || !/^\d{4}-\d{2}-\d{2}$/.test(end)) return null;
const startDate = new Date(`${start}T00:00:00Z`);
const endDate = new Date(`${end}T00:00:00Z`);
if (startDate.toISOString().slice(0, 10) !== start || endDate.toISOString().slice(0, 10) !== end) return null;
return Math.floor((endDate - startDate) / (1000 * 60 * 60 * 24));
}
export function shouldDedupScanHistoryRow({ firstSeen, status = 'added' }, { recheckAfterDays = null, today = new Date().toISOString().slice(0, 10) } = {}) {
if (PERMANENT_SCAN_HISTORY_STATUSES.has(status)) return true;
if (status.startsWith('cooldown:')) {
const parts = status.split(':');
const cooldownUntil = parts[parts.length - 1];
return today < cooldownUntil;
}
if (status !== 'added') return true;
if (recheckAfterDays == null) return true;
const ageDays = daysBetweenIsoDates(firstSeen, today);
if (ageDays == null) return true;
return ageDays < recheckAfterDays;
}
function scanHistoryPolicy(config = {}) {
const raw = config.scan_history?.recheck_after_days;
const parsed = Number.parseInt(raw, 10);
return {
recheckAfterDays: Number.isFinite(parsed) && parsed >= 0 ? parsed : null,
};
}
export function loadSeenUrls(policy = {}) {
const seen = new Set();
let recheckEligible = 0;
// scan-history.tsv
if (existsSync(SCAN_HISTORY_PATH)) {
const lines = readFileSync(SCAN_HISTORY_PATH, 'utf-8').split('\n');
for (const line of lines.slice(1)) { // skip header
const [url, firstSeen, , , , status = 'added'] = line.split('\t');
if (!url) continue;
if (shouldDedupScanHistoryRow({ firstSeen, status }, policy)) seen.add(url);
else recheckEligible++;
}
}
// pipeline.md — extract URLs from checkbox lines
if (existsSync(PIPELINE_PATH)) {
const text = readFileSync(PIPELINE_PATH, 'utf-8');
for (const match of text.matchAll(/- \[[ x]\] (https?:\/\/\S+)/g)) {
seen.add(match[1]);
}
}
// applications.md — extract URLs from report links and any inline URLs
if (existsSync(APPLICATIONS_PATH)) {
const text = readFileSync(APPLICATIONS_PATH, 'utf-8');
for (const match of text.matchAll(/https?:\/\/[^\s|)]+/g)) {
seen.add(match[0]);
}
}
return { seen, recheckEligible };
}
/**
* Normalize a company label when no alias map is configured.
*
* This deliberately does only the pre-existing behavior: trim and lowercase the
* raw company name. `buildCompanyCanonicalizer` wraps this with the optional
* alias map so installs without `company_aliases` keep byte-for-byte dedupe
* semantics.
*
* @param {unknown} name - Raw company value from a tracker row or provider job.
* @returns {string} Lowercased, trimmed company key.
*/
function defaultCompanyNormalizer(name) {
return String(name ?? '').trim().toLowerCase();
}
/**
* Build a company-name canonicalizer from `config.company_aliases`.
*
* The map is `{ CanonicalName: [alias, ...] }`; every alias and the canonical
* name itself resolve to the lowercased canonical name. This closes the gap
* where an ATS org name, for example Greenhouse "Intercom", differs from the
* tracker/brand label, for example "Fin". Without it, the company+role dedupe
* key never matches the tracker and the same role is re-scanned every run.
*
* Unknown names pass through as plain lowercased text, so behavior is unchanged
* for companies with no alias entry.
*
* Canonical names always keep their own identity when an alias collides with
* one. An alias claimed by multiple canonical companies also passes through
* unchanged so malformed config cannot silently merge unrelated companies.
*
* @param {Record<string, unknown>|undefined|null} aliases - Optional canonical
* company name to alias list map.
* @returns {(name: unknown) => string} Canonicalizer for tracker and scan-side
* company labels.
*/
export function buildCompanyCanonicalizer(aliases) {
const map = new Map();
if (aliases && typeof aliases === 'object' && !Array.isArray(aliases)) {
const entries = Object.entries(aliases);
const canonicalKeys = new Set();
// Canonical names always own their identity, independent of YAML key order.
for (const [canonical] of entries) {
const canon = defaultCompanyNormalizer(canonical);
if (!canon) continue;
map.set(canon, canon);
canonicalKeys.add(canon);
}
const aliasTargets = new Map();
for (const [canonical, list] of entries) {
const canon = defaultCompanyNormalizer(canonical);
if (!canon) continue;
const arr = Array.isArray(list) ? list : [list];
for (const a of arr) {
const alias = defaultCompanyNormalizer(a);
if (!alias || canonicalKeys.has(alias)) continue;
if (!aliasTargets.has(alias)) aliasTargets.set(alias, new Set());
aliasTargets.get(alias).add(canon);
}
}
// Ambiguous aliases fail open as their raw normalized label. This may allow
// a duplicate through, but it cannot silently suppress another company.
for (const [alias, targets] of aliasTargets) {
if (targets.size === 1) map.set(alias, targets.values().next().value);
}
}
/**
* Canonicalize one raw company label through the alias map.
*
* @param {unknown} name - Raw company value from a tracker row or provider job.
* @returns {string} Canonical lowercased company key.
*/
return function canonicalizeCompany(name) {
const key = defaultCompanyNormalizer(name);
return map.get(key) ?? key;
};
}
const ROLE_LOCATION_SUFFIXES = new Set([
'amer',
'americas',
'amsterdam',
'apac',
'austin',
'barcelona',
'bay area',
'belgium',
'berlin',
'boston',
'brussels',
'budapest',
'canada',
'chicago',
'copenhagen',
'dublin',
'emea',
'eu',
'europe',
'finland',
'france',
'frankfurt',
'germany',
'hamburg',
'helsinki',
'india',
'ireland',
'italy',
'la',
'latin america',
'lisbon',
'london',
'los angeles',
'madrid',
'melbourne',
'milan',
'montreal',
'munich',
'netherlands',
'new york',
'north america',
'nyc',
'on site',
'onsite',
'oslo',
'paris',
'poland',
'porto',
'prague',
'remote',
'rome',
'san francisco',
'seattle',
'sf',
'singapore',
'spain',
'stockholm',
'sydney',
'tokyo',
'toronto',
'uk',
'united kingdom',
'united states',
'us',
'usa',
'vancouver',
'vienna',
'warsaw',
'zurich',
]);
const ROLE_REMOTE_SUFFIXES = new Set([
'distributed',
'hybrid',
'on site',
'onsite',
'remote',
'wfh',
'work from home',
]);
/**
* Normalize bracket text before checking whether it is a location suffix.
*
* @param {unknown} tag - Text from a trailing parenthetical or bracket suffix.
* @returns {string} Lowercased, punctuation-normalized suffix text.
*/
function normalizeRoleSuffixTag(tag) {
return String(tag ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
/**
* Decide whether a trailing role-title suffix is a location/remote tag.
*
* Only known remote/location suffixes are stripped. Seniority, discipline, team,
* and product qualifiers are intentionally preserved so distinct role variants
* do not collapse to the same scanner dedupe key.
*
* @param {unknown} tag - Text from a trailing parenthetical or bracket suffix.
* @returns {boolean} True when the suffix is safe to remove for dedupe.
*/
function isRoleLocationSuffix(tag) {
const normalized = normalizeRoleSuffixTag(tag);
if (!normalized) return false;
if (ROLE_LOCATION_SUFFIXES.has(normalized)) return true;
const raw = String(tag ?? '').toLowerCase();
const parts = raw
.split(/[,/|;]+|\s+(?:and|or)\s+/g)
.map(normalizeRoleSuffixTag)
.filter(Boolean);
if (parts.length > 1 && parts.every(part => ROLE_LOCATION_SUFFIXES.has(part))) return true;
for (const remote of ROLE_REMOTE_SUFFIXES) {
const prefix = `${remote} `;
if (normalized.startsWith(prefix) && ROLE_LOCATION_SUFFIXES.has(normalized.slice(prefix.length))) {
return true;
}
}
return false;
}
/**
* Normalize a role title for stable scan-time duplicate identity.
*
* Equivalent tracker/provider titles should collapse to one key when a company
* splits a role per location with a trailing tag like "(Berlin)". Requisition
* IDs live in URLs rather than titles, so this identity remains URL-agnostic.
*
* The normalizer lowercases the title, strips trailing location/remote
* parenthetical/bracketed tags such as "(Berlin)" and "[Remote]", then
* collapses punctuation and whitespace so em dash vs hyphen or double spaces do
* not split a key.
*
* This helper does not infer posting churn or detect repost clusters. Those
* post-tracking facts remain the responsibility of detect-reposts.mjs and the
* company-history `postingChurn` axis.
*
* @param {unknown} role - Raw role title from a tracker row or provider job.
* @returns {string} Normalized role key.
*/
export function normalizeRoleForDedup(role) {
let title = String(role ?? '').toLowerCase();
while (true) {
const match = title.match(/\s*[\[(]([^[\]()]+)[\])]\s*$/);
if (!match || !isRoleLocationSuffix(match[1])) break;
title = title.slice(0, match.index).trimEnd();
}
return title.replace(/[^a-z0-9]+/g, ' ').trim();
}
/**
* Build the canonical company+role dedupe key.
*
* This shared helper is used by both the tracker-side load and the scan-side
* check so those two code paths cannot drift. `canonicalize` defaults to plain
* lowercase/trim behavior when no alias map is configured.
*
* @param {unknown} company - Raw company label.
* @param {unknown} role - Raw role title.
* @param {(name: unknown) => string} [canonicalize] - Company canonicalizer.
* @returns {string} Stable dedupe key in `company::role` form.
*/
export function companyRoleDedupKey(company, role, canonicalize = defaultCompanyNormalizer) {
return `${canonicalize(company)}::${normalizeRoleForDedup(role)}`;
}
/**
* Load company+role keys already present in the applications tracker.
*
* Existing tracker rows are canonicalized with the same company aliasing and
* role-title normalization used for freshly scanned jobs. That lets URL-new
* duplicates match older tracker entries instead of being evaluated again.
*
* @param {string} [appsPath=APPLICATIONS_PATH] - Applications tracker path.
* @param {(name: unknown) => string} [canonicalize=defaultCompanyNormalizer] -
* Company canonicalizer shared with scan-side dedupe.
* @returns {Set<string>} Existing company+role dedupe keys.
*/
export function loadSeenCompanyRoles(appsPath = APPLICATIONS_PATH, canonicalize = defaultCompanyNormalizer) {
const seen = new Set();
if (existsSync(appsPath)) {
// Header-aware parse (tracker-parse.mjs, #954) — the old positional regex
// captured the wrong cells on customized layouts (e.g. with a Location
// column), so the seen-set keyed on garbage and dedup misfired.
const lines = readFileSync(appsPath, 'utf-8').split('\n');
const colmap = resolveColumns(lines);
for (const line of lines) {
const row = parseTrackerRow(line, colmap);
if (!row) continue;
const company = row.company.trim();
const role = row.role.trim();
if (company && role) seen.add(companyRoleDedupKey(company, role, canonicalize));
}
}
return seen;
}
// ── Pipeline writer ─────────────────────────────────────────────────
function normalizeScanScalar(value) {
return String(value ?? '')
.replace(/[\r\n\t]+/g, ' ')
.replace(/ {2,}/g, ' ')
.trim();
}
function normalizeScanUrl(value) {
return String(value ?? '').trim().split(/\s+/)[0] || '';
}
const MARKDOWN_ESCAPE_CHARS = {
'\\': '\\\\',
'[': '\\[',
']': '\\]',
};
export function sanitizeMarkdownField(value) {
return normalizeScanScalar(value)
.replace(/[\\[\]]/g, char => MARKDOWN_ESCAPE_CHARS[char])
.replace(/\|/g, '/');
}
function sanitizePipelineUrl(value) {
return normalizeScanUrl(value)
.replace(/[\\[\]]/g, char => MARKDOWN_ESCAPE_CHARS[char])
.replace(/\|/g, '%7C');
}
export function sanitizeTsvField(value) {
const normalized = normalizeScanScalar(value);
return /^[=+\-@]/.test(normalized) ? `'${normalized}` : normalized;
}
// Format an offer's parsed compensation (the annualized {min,max,currency} that
// providers like Ashby attach as `offer.salary`) into a compact, sanitized cell
// such as `120000-160000 USD`. Returns '' when there is no usable salary data.
// Non-positive bounds are dropped (a 0 min/max is meaningless comp data, not "$0").
export function formatCompensation(salary) {
if (!salary || typeof salary !== 'object') return '';
const num = (n) => (Number.isFinite(n) && n > 0 ? String(Math.round(n)) : null);
const lo = num(salary.min);
const hi = num(salary.max);
const range = lo && hi && lo !== hi ? `${lo}-${hi}` : (lo || hi || '');
if (!range) return '';
const currency = typeof salary.currency === 'string' ? salary.currency.trim() : '';
return sanitizeMarkdownField(currency ? `${range} ${currency}` : range);
}
export function formatPipelineOffer(offer) {
const url = sanitizePipelineUrl(offer.url);
const company = sanitizeMarkdownField(offer.company);
const title = sanitizeMarkdownField(offer.title);
// Optional trailing columns, each sanitized like every other field:
// 4th = location, 5th = compensation.
// Gate location on an actual string so malformed provider data (a number or
// object) degrades to the 3-column form instead of stringifying into a
// spurious column. The columns are positional, so a present compensation
// forces the (possibly empty) location cell to keep comp in column 5.
// loadSeenUrls dedups on the URL and ignores trailing columns (backward-compatible).
const location = typeof offer.location === 'string' ? sanitizeMarkdownField(offer.location) : '';
const compensation = formatCompensation(offer.salary);
const base = `- [ ] ${url} | ${company} | ${title}`;
let line = base;
if (compensation) line = `${base} | ${location} | ${compensation}`;
else if (location) line = `${base} | ${location}`;
// Optional labeled posting-date segment (like note:) — keeps the positional
// 1/3/4/5-column contract in modes/pipeline.md intact.
const posted = postedAtIsoDate(offer.postedAt);
if (posted) line = `${line} | posted: ${posted}`;
// Optional free-text ranking signal (e.g. a curated-list flag an importer
// attaches). Labeled — not positional like location/compensation — so it can
// ride on any row shape (bare URL, 3-, 4-, or 5-column) without a reader
// confusing it for a positional cell, and it stays generic: nothing here is
// source-specific, and an offer without `note` produces byte-identical output.
const note = typeof offer.note === 'string' ? sanitizeMarkdownField(offer.note) : '';
return note ? `${line} | note: ${note}` : line;
}
// postedAt arrives as epoch ms (or absent). Convert to 'YYYY-MM-DD', or '' when missing.
function postedAtIsoDate(postedAt) {
if (typeof postedAt !== 'number' || !Number.isFinite(postedAt) || postedAt <= 0) return '';
return new Date(postedAt).toISOString().slice(0, 10);
}
export function formatScanHistoryRow(offer, date, status = 'added') {
return [
normalizeScanUrl(offer.url),
date,
offer.source,
offer.title,
offer.company,
status,
offer.location || '',
// JD-content fingerprint (#1597): 16 hex chars when the provider's list
// API shipped a usable description, '' otherwise. Lets later scans flag
// the same body re-posted under a different company (agency cross-listing)
// without storing the body. All readers tolerate the extra column.
offer.fingerprint ?? fingerprintText(offer.description),
// New trailing column: posting date. Existing readers index by position up to
// col 7, so appending col 8 is backward-compatible.
postedAtIsoDate(offer.postedAt),
].map(sanitizeTsvField).join('\t');
}
/**
* Read scan-history.tsv rows that carry a fingerprint, for the cross-listing
* check. Older rows without the 8th column simply never match.
*
* @param {string} [historyPath] - Override for tests.
* @returns {Array<{url: string, dateStr: string, company: string, title: string, fingerprint: string}>}
*/
export function loadFingerprintHistory(historyPath = SCAN_HISTORY_PATH) {
if (!existsSync(historyPath)) return [];
const rows = [];
for (const line of readFileSync(historyPath, 'utf-8').split('\n')) {
const cols = line.split('\t');
if (cols.length < 8 || !cols[7].trim()) continue;
rows.push({
url: (cols[0] || '').trim(),
dateStr: (cols[1] || '').trim(),
title: (cols[3] || '').trim(),
company: (cols[4] || '').trim(),
fingerprint: cols[7].trim(),
});
}
return rows;
}
// Standard skeleton created on fresh install — matches the format documented
// in modes/pipeline.md and expected by /career-ops pipeline.
const PIPELINE_SKELETON = `# Pipeline — Pending URLs
Paste job URLs below as \`- [ ] {url}\` then run \`/career-ops pipeline\`.
## Pending
## Processed
`;
// Current section names (English). Legacy Spanish names are checked as fallback
// so existing pipeline.md files created before this change keep working.
const PENDING_MARKERS = ['## Pending', '## Pendientes'];
const PROCESSED_MARKERS = ['## Processed', '## Procesadas'];
export function appendToPipeline(offers) {
if (offers.length === 0) return;
// Auto-create with standard skeleton if missing (fresh-install guard).
if (!existsSync(PIPELINE_PATH)) {
writeFileSync(PIPELINE_PATH, PIPELINE_SKELETON, 'utf-8');
}
let text = readFileSync(PIPELINE_PATH, 'utf-8');
const marker = PENDING_MARKERS.find(m => text.includes(m)) ?? null;
const idx = marker !== null ? text.indexOf(marker) : -1;
if (idx === -1) {
// No Pending section found — insert one before Processed (or at end)
const procIdx = PROCESSED_MARKERS.reduce((found, m) => {
const i = text.indexOf(m);
return (found === -1 || (i !== -1 && i < found)) ? i : found;
}, -1);
const insertAt = procIdx === -1 ? text.length : procIdx;
const block = `\n## Pending\n\n` + offers.map(formatPipelineOffer).join('\n') + '\n\n';
text = text.slice(0, insertAt) + block + text.slice(insertAt);
} else {
// Find the end of existing Pending content (next ## or end)
const afterMarker = idx + marker.length;
const nextSection = text.indexOf('\n## ', afterMarker);
const insertAt = nextSection === -1 ? text.length : nextSection;
const block = '\n' + offers.map(formatPipelineOffer).join('\n') + '\n';
text = text.slice(0, insertAt) + block + text.slice(insertAt);
}
writeFileSync(PIPELINE_PATH, text, 'utf-8');
}
export function appendToScanHistory(offers, date, status = 'added') {
// Ensure file + header exist. Location appended as 7th column for non-breaking
// backward compat — older scan-history.tsv files with 6 columns still parse fine
// since loadSeenUrls only reads column 0. `status` is parameterized so callers
// can record verify outcomes (`skipped_expired`, etc.) without the legacy
// `(expired)` suffix in `source`.
if (!existsSync(SCAN_HISTORY_PATH)) {
writeFileSync(SCAN_HISTORY_PATH, 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\tlocation\n', 'utf-8');
}
const lines = offers.map(o => formatScanHistoryRow(o, date, status)).join('\n') + '\n';
appendFileSync(SCAN_HISTORY_PATH, lines, 'utf-8');
}
// ── Company blacklist (#1742) ───────────────────────────────────────
const BLACKLIST_PATH = 'data/blacklist.md';
/**
* Parse the user's do-not-apply list (data/blacklist.md, user layer, opt-in).
*
* The file is a small markdown table the user owns:
* `| Company | Since | Scope | Reason |`. Nothing here ever creates or writes
* it — an absent file means no filtering. Companies are keyed with the same
* normalization every tracker writer shares (normalizeCompany, #1460), so a
* blacklist row "Acme Corp." still catches an ATS feed that says "acme corp".
*
* @param {string} text - Raw data/blacklist.md content.
* @returns {Map<string, {company: string, since: string, scope: string, reason: string}>}
* Normalized company key → entry. First row wins on duplicate keys.
*/
export function parseBlacklist(text) {
const entries = new Map();
for (const line of String(text ?? '').replace(/\r/g, '').split('\n')) {
if (!line.trim().startsWith('|')) continue;
const cells = line.split('|').map(s => s.trim());
const company = cells[1] || '';
if (!company || /^[-: ]+$/.test(company)) continue; // separator row
if (company.toLowerCase() === 'company') continue; // header row
const key = normalizeCompany(company);
if (!key || entries.has(key)) continue;
entries.set(key, {
company,
since: cells[2] || '',
scope: cells[3] || '',
reason: cells[4] || '',
});
}
return entries;
}
/**
* Load data/blacklist.md if the user opted in. Absent file = empty Map = no
* filtering anywhere — the scan stays byte-identical to a pre-#1742 run.
*
* @param {string} [filePath] - Override for tests.
* @returns {Map<string, {company: string, since: string, scope: string, reason: string}>}
*/
export function loadBlacklist(filePath = BLACKLIST_PATH) {
if (!existsSync(filePath)) return new Map();
return parseBlacklist(readFileSync(filePath, 'utf-8'));
}
// ── Scan-run persistence (#1604) ────────────────────────────────────
const SCAN_RUNS_PATH = 'data/scan-runs.tsv';
// One row of run counters per non-dry scan — today these numbers are printed
// once in the summary and lost when the terminal scrolls. Full ISO timestamp
// (two scans in one day must not collapse). `status` is reserved: always
// 'completed' in v1; a follow-up wires failure-path writes so trend stats can
// exclude survivorship bias. Consumers MUST parse by header name, never by
// position — columns may be appended in later versions.
export const SCAN_RUNS_HEADER = 'timestamp\tstatus\tcompanies\tboards\tfound\tfiltered_title\tfiltered_tier\tfiltered_location\tfiltered_posting_age\tfiltered_salary\tfiltered_content\tfiltered_cooldown\tdupes\tnew_added\terrors\tfiltered_blacklist\n';
export function appendScanRunSummary(c, filePath = SCAN_RUNS_PATH) {
if (!existsSync(filePath)) writeFileSync(filePath, SCAN_RUNS_HEADER, 'utf-8');
const row = [
c.timestamp, c.status ?? 'completed', c.companies, c.boards, c.found,
c.filteredTitle, c.filteredTier, c.filteredLocation, c.filteredPostingAge,
c.filteredSalary, c.filteredContent, c.filteredCooldown, c.dupes, c.newAdded, c.errors,
// filtered_blacklist (#1742) appended at the END, per the header-name
// contract above: files created with an older header keep parsing (the
// extra trailing cell is simply not named there).
c.filteredBlacklist ?? 0,
].join('\t') + '\n';
appendFileSync(filePath, row, 'utf-8');
}
// ── Parallel fetch with concurrency limit ───────────────────────────
async function parallelFetch(tasks, limit) {
const results = [];
let i = 0;
async function next() {
while (i < tasks.length) {
const task = tasks[i++];
results.push(await task());
}
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => next());
await Promise.all(workers);
return results;
}
// ── Main ────────────────────────────────────────────────────────────
async function verifyOffers(offers, { headedFallback = false, throttleBaseMs = 0, rediscover = false } = {}) {
// Dynamic imports keep the default zero-token path free of Playwright startup
let chromium;
let checkUrlLiveness;
let checkUrlLivenessWithFallback;
let createHeadedPageProvider;
let newLivenessPage;
let jitteredDelayMs;
let sleep;
try {
({ chromium } = await import('playwright'));
({ checkUrlLiveness, checkUrlLivenessWithFallback, createHeadedPageProvider, newLivenessPage, jitteredDelayMs, sleep } = await import('./liveness-browser.mjs'));
} catch (err) {
throw new Error(
`--verify requires Playwright with Chromium (run "npx playwright install chromium"): ${err.message}`,
{ cause: err },
);
}
let browser;
try {
browser = await chromium.launch({ headless: true });
} catch (err) {
throw new Error(
`--verify could not launch Chromium (run "npx playwright install chromium" or re-run without --verify): ${err.message}`,
{ cause: err },
);
}
// Three permanent buckets + one transient passthrough:
// verified → active pages and transient nav errors (retry next scan)
// expired → classifier-confirmed dead postings (HTTP 4xx, redirect markers,
// body patterns, listing pages, insufficient content)
// dropped → page loaded but classifier saw no Apply control. --verify is an
// opt-in stricter filter; keeping these defeats the purpose.
// invalid → up-front URL guard rejections (malformed / non-http / private)
const verified = [];
const expired = [];
const dropped = [];
const invalid = [];
const migrated = [];
const headed = headedFallback ? createHeadedPageProvider(chromium) : null;
const getHeadedPage = headed ? () => headed.get() : undefined;
try {
const page = await newLivenessPage(browser);
// Sequential — project rule: never Playwright in parallel
for (let i = 0; i < offers.length; i++) {
const offer = offers[i];
const { result, code, reason } = headed
? await checkUrlLivenessWithFallback(page, offer.url, { getHeadedPage })
: await checkUrlLiveness(page, offer.url);
if (result === 'expired') {
// 404/410 on a tracked company may just be a moved role — run one
// search + re-verify before giving up (opt-in via --rediscover-404).
// Only http_gone (HTTP 404/410) qualifies; soft-expiry signals
// (redirect/body/listing) are real closures, not URL moves.
if (rediscover && code === 'http_gone' && offer.tracked && offer.careersUrlDomain) {
const newUrl = await searchForNewUrl(page, offer);
if (newUrl) {
// Mirror the primary check: without the headed fallback, a
// challenge-prone domain would flag the rediscovered URL as
// expired just because the recheck hit the same anti-bot wall.
const recheck = headed
? await checkUrlLivenessWithFallback(page, newUrl, { getHeadedPage })
: await checkUrlLiveness(page, newUrl);
// Require a *confirmed* live page before migrating. A transient
// 'uncertain' (timeout/DNS/5xx) must not commit an unverified URL —
// fall through to expired (the original 404/410 is a real closure).
if (recheck.result === 'active') {
migrated.push({ ...offer, url: newUrl, previousUrl: offer.url });
console.log(` 🔄 migrated ${offer.company} | ${offer.title}${newUrl}`);
continue;
}
}
}
expired.push({ ...offer, reason });
console.log(` ❌ expired ${offer.company} | ${offer.title} (${reason})`);
} else if (result === 'uncertain' && GUARD_CODES.has(code)) {
// Guard failures are permanent (not transient like a timeout) — record them
// separately so they don't end up in pipeline.md but DO appear in scan-history
// with a precise status, dedup-blocking them on subsequent scans.
invalid.push({ ...offer, code, reason });
console.log(` ⛔ invalid ${offer.company} | ${offer.title} (${reason})`);
} else if (result === 'uncertain' && code === 'no_apply_control') {
// Page loaded but classifier could not find an Apply control. Treat like
// expired for routing — drop from pipeline AND record in scan-history so
// we don't burn a verify cycle on the same URL next scan.
dropped.push({ ...offer, reason });
console.log(` ⚠️ no-apply ${offer.company} | ${offer.title} (${reason})`);
} else {
// 'active' or 'uncertain' due to navigation_error (transient — retry next scan)
verified.push(offer);
const icon = result === 'active' ? '✅' : '⚠️';
console.log(` ${icon} ${result.padEnd(9)} ${offer.company} | ${offer.title}`);
}
const wait = i < offers.length - 1 ? jitteredDelayMs(throttleBaseMs) : 0;
if (wait) await sleep(wait);
}
} finally {
if (headed) await headed.close();
await browser.close();
}
return { verified, expired, dropped, invalid, migrated };
}
// Stable codes from liveness-browser's up-front URL guard. Routing dispatches
// on these codes (not on regex over reason strings) so wording can change
// without breaking the pipeline.
const GUARD_CODES = new Set(['invalid_url', 'unsupported_protocol', 'blocked_host']);
// guardStatusFor maps a guard code to the canonical scan-history status string.
function guardStatusFor(code) {
if (code === 'blocked_host') return 'skipped_blocked_host';
// invalid_url and unsupported_protocol both surface as malformed input
return 'skipped_invalid_url';
}
async function main() {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const verify = args.includes('--verify');
// Opt-in: on an anti-bot challenge (e.g. pracuj.pl Cloudflare wall), retry the
// URL in a headed browser. Off by default — headed Chromium needs a display, so
// scheduled/unattended scans should not rely on it.
const headedFallback = args.includes('--headed-fallback');
// --throttle or --throttle=<ms>: jittered gap between --verify checks to stay
// under rate-based WAF limits (pracuj.pl flags the session after a few rapid
// hits). Default base 5000ms. Off by default — most ATS feeds don't need it.
const throttleArg = args.find((a) => a === '--throttle' || a.startsWith('--throttle='));
const throttleBaseMs = throttleArg ? (Number(throttleArg.split('=')[1]) || 5000) : 0;
// --rediscover-404: when a tracked company's URL 404/410s, search for the
// moved role and re-verify before marking it expired. Opt-in; rides on --verify.
const rediscover = args.includes('--rediscover-404');
// --include-blacklisted: bypass the data/blacklist.md filter for auditing.
// Matching postings flow through annotated instead of being counted out.
const includeBlacklisted = args.includes('--include-blacklisted');
const companyFlag = args.indexOf('--company');
const filterCompany = companyFlag !== -1 ? args[companyFlag + 1]?.toLowerCase() : null;
// 1. Load providers
const providers = await loadProviders(PROVIDERS_DIR);
// Opt-in: merge enabled keyed/auth-gated provider plugins. Returns immediately
// (no discovery, no dotenv, no process.env mutation) when config/plugins.yml is
// absent — so a plain scan with no plugins configured stays byte-identical.
await mergeProviderPlugins(providers, { root: path.dirname(PROVIDERS_DIR) });
if (providers.size === 0) {
console.error('Error: no providers loaded from providers/');
process.exit(1);
}
// 2. Read portals.yml
if (!existsSync(PORTALS_PATH)) {
console.error('Error: portals.yml not found. Run onboarding first.');
process.exit(1);
}
let rawConfig;
try {
rawConfig = parseYaml(readFileSync(PORTALS_PATH, 'utf-8'));
} catch (err) {
console.error(`Error: failed to parse ${PORTALS_PATH}: ${err.message}`);
process.exit(1);
}
const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
const companies = Array.isArray(config.tracked_companies) ? config.tracked_companies : [];
const boards = Array.isArray(config.job_boards) ? config.job_boards : [];
const titleFilter = buildTitleFilter(config.title_filter);
// Seniority tier classifier integration
let classifyTier = null;
const skipTiers = Array.isArray(config.skip_tiers)
? config.skip_tiers.filter(t => typeof t === 'string').map(t => t.toLowerCase())
: [];
if (skipTiers.length > 0) {
const mod = await import('./classify-tier.mjs');
classifyTier = mod.classifyTier || mod.default;
}
const locationFilter = buildLocationFilter(config.location_filter);
const postingAgeFilter = buildPostingAgeFilter(config.max_posting_age_days);
const salaryFilter = buildSalaryFilter(config.salary_filter);
const trustValidator = buildTrustValidator(config.trust_filter);
const contentFilter = buildContentFilter(config.content_filter);
// 3. Resolve a provider for each enabled company / board
const targets = [];
let skippedCount = 0;
let boardCount = 0;
const resolveErrors = [];
const agentHandoff = [];
/**
* Processes a list of configuration entries, resolves their appropriate data providers,
* and appends valid entries to the global scanning targets list.
* @param {Array<{ name?: string, enabled?: boolean, [key: string]: unknown }>} entries - List of entries.
* @param {{ isBoard?: boolean }} [options={}] - Configuration options.
*/
function resolveEntries(entries, { isBoard = false } = {}) {
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (entry.enabled === false) continue;
if (typeof entry.name !== 'string' || !entry.name.trim()) {
console.error(`⚠️ Skipping entry — missing or non-string 'name' field: ${JSON.stringify(entry)}`);
continue;
}
if (filterCompany && !entry.name.toLowerCase().includes(filterCompany)) continue;
const resolved = resolveProvider(entry, providers);
if (!resolved) {
skippedCount++;
if (entry.scan_method === 'websearch') {
agentHandoff.push({
company: entry.name,
method: 'websearch',
query: entry.scan_query || entry.search_query || entry.careers_url || '',
});
}
continue;
}
if (resolved.error) {
resolveErrors.push({ company: entry.name, error: resolved.error });
continue;
}
targets.push({ ...entry, _provider: resolved.provider, _isBoard: isBoard });
if (isBoard) boardCount++;
}
}
resolveEntries(companies);
resolveEntries(boards, { isBoard: true });
const localParserCount = targets.filter(t => t._provider.id === 'local-parser').length;
const companyCount = targets.length - boardCount;
const parts = [`${companyCount} companies`];
if (boardCount > 0) parts.push(`${boardCount} job boards`);
parts.push(`${localParserCount} local parser`);
parts.push(`${skippedCount} skipped — no provider matched`);
console.log(`Scanning ${parts.join('; ')} via providers`);
if (dryRun) console.log('(dry run — no files will be written)\n');
// 3.5. Load the user's do-not-apply list (#1742). Opt-in: absent file =
// empty Map = the filter below never fires.
const blacklist = loadBlacklist();
// 4. Load dedup sets
const historyPolicy = scanHistoryPolicy(config);
const seenUrlState = loadSeenUrls(historyPolicy);
const seenUrls = seenUrlState.seen;
const canonicalizeCompany = buildCompanyCanonicalizer(config.company_aliases);
const seenCompanyRoles = loadSeenCompanyRoles(APPLICATIONS_PATH, canonicalizeCompany);
// 5. Fetch from each target
const date = new Date().toISOString().slice(0, 10);
const windows = loadReApplyWindows();
const cooldownFilter = buildCooldownFilter(windows, date);
let totalFilteredCooldown = 0;
const cooldownOffers = [];
let totalFound = 0;
let totalFilteredTitle = 0;
let totalFilteredTier = 0;
let totalFilteredLocation = 0;
let totalFilteredPostingAge = 0;
let totalFilteredSalary = 0;
let totalFilteredContent = 0;
let totalFilteredBlacklist = 0;
let annotatedBlacklisted = 0;
let totalDupes = 0;
const newOffers = [];
const errors = [...resolveErrors];
const emptyTargets = [];
const tasks = targets.map(company => async () => {
let provider = company._provider;
const ctx = makeHttpCtx();
let sourceName = provider.id === 'local-parser' ? 'local-parser' : `${provider.id}-api`;
try {
let jobs;
try {
jobs = await provider.fetch(company, ctx);
} catch (parserErr) {
if (provider.id !== 'local-parser') throw parserErr;
const fallback = resolveProvider(company, providers, { skipIds: ['local-parser'] });
if (!fallback || fallback.error) throw parserErr;
provider = fallback.provider;
sourceName = `${provider.id}-api`;
jobs = await provider.fetch(company, ctx);
errors.push({
company: company.name,
error: `local parser failed, used API fallback: ${parserErr.message}`,
});
}
if (!Array.isArray(jobs)) {
throw new Error(`${provider.id}: fetch() did not return an array`);
}
totalFound += jobs.length;
if (!company._isBoard && jobs.length === 0) {
emptyTargets.push(company.name);
}
for (const job of jobs) {
// Trust enrichment — runs before filters, never drops
const trustResult = trustValidator(job);
job.trustScore = trustResult.score;
job.trustFlags = trustResult.flags;
job.trustLevel = trustResult.level;
// Company blacklist (#1742) — the user's own do-not-apply decision,
// checked first: it's company-level, not a per-posting signal. Never
// silent: skips are counted and reported in the run summary, and
// --include-blacklisted lets the posting through annotated instead.
if (blacklist.size > 0) {
const blEntry = blacklist.get(normalizeCompany(job.company || company.name || ''));
if (blEntry) {
if (!includeBlacklisted) {
totalFilteredBlacklist++;
continue;
}
annotatedBlacklisted++;
job.blacklisted = true;
const label = `blacklisted${blEntry.reason ? `: ${blEntry.reason}` : ''}`;
job.note = typeof job.note === 'string' && job.note.trim()
? `${label}${job.note}`
: label;
}
}
if (!titleFilter(job.title)) {
totalFilteredTitle++;
continue;
}
if (classifyTier && skipTiers.includes(classifyTier(job.title))) {
totalFilteredTier++;
continue;
}
if (!locationFilter(job.location)) {
totalFilteredLocation++;
continue;
}
if (!postingAgeFilter(job.postedAt)) {
totalFilteredPostingAge++;
continue;
}
if (!salaryFilter(job.salary)) {
totalFilteredSalary++;
continue;
}
if (!contentFilter(job.description, matchedTitleKeywords(job.title, config.title_filter))) {
totalFilteredContent++;
continue;
}
if (seenUrls.has(job.url)) {
totalDupes++;
continue;
}
const key = companyRoleDedupKey(job.company, job.title, canonicalizeCompany);
if (seenCompanyRoles.has(key)) {
totalDupes++;
continue;
}
const cooldownResult = cooldownFilter(job);
if (cooldownResult.skip) {
totalFilteredCooldown++;
cooldownOffers.push({
job: { ...job, source: sourceName },
status: cooldownResult.reason,
});
continue;
}
// Mark as seen to avoid intra-scan dupes
seenUrls.add(job.url);
seenCompanyRoles.add(key);
// Tag with the company's careers domain so verify can offer a 404/410
// rediscovery fallback. A null domain (no careers_url) marks the offer
// as broad-discovery — ineligible for the fallback, per the issue scope.
const careersUrlDomain = extractCareersUrlDomain(company.careers_url);
newOffers.push({
...job,
source: sourceName,
tracked: Boolean(careersUrlDomain),
careersUrlDomain,
});
}
} catch (err) {
errors.push({
company: company.name,
error: err.message,
kind: classifyFetchError(err),
});
}
});
await parallelFetch(tasks, CONCURRENCY);
// 5.5. Optional liveness verification — drop expired and guard-rejected postings
let verifiedOffers = newOffers;
let expiredOffers = [];
let droppedOffers = [];
let invalidOffers = [];
let migratedOffers = [];
if (verify && newOffers.length > 0) {
console.log(`\nVerifying liveness of ${newOffers.length} new offer(s) with Playwright (sequential)...`);
const result = await verifyOffers(newOffers, { headedFallback, throttleBaseMs, rediscover });
verifiedOffers = result.verified;
expiredOffers = result.expired;
droppedOffers = result.dropped;
invalidOffers = result.invalid;
migratedOffers = result.migrated;
// Migrated offers re-enter the pipeline at their newly discovered URL.
if (migratedOffers.length > 0) {
verifiedOffers = [...verifiedOffers, ...migratedOffers];
}
}
// 5.7. Cross-listing check (#1597): fingerprint each new offer's JD body and
// compare against recent history rows from a DIFFERENT company — the same
// requirements text under two names is usually an agency re-post of a direct
// listing (or vice versa), which URL and company+role dedup both miss.
// Fingerprints are computed once here and reused by appendToScanHistory.
for (const offer of verifiedOffers) {
offer.fingerprint = fingerprintText(offer.description);
}
const crossListings = findCrossListings(verifiedOffers, loadFingerprintHistory());
// 6. Write results
if (!dryRun && verifiedOffers.length > 0) {
appendToPipeline(verifiedOffers);
appendToScanHistory(verifiedOffers, date);
}
if (!dryRun && cooldownOffers.length > 0) {
const cooldownGroups = {};
for (const item of cooldownOffers) {
if (!cooldownGroups[item.status]) {
cooldownGroups[item.status] = [];
}
cooldownGroups[item.status].push(item.job);
}
for (const [status, group] of Object.entries(cooldownGroups)) {
appendToScanHistory(group, date, status);
}
}
// Expired postings — plus the old URLs of migrated offers — are recorded as
// skipped_expired so subsequent scans dedup-skip the dead URLs.
const expiredForHistory = [
...expiredOffers,
...migratedOffers.map(o => ({ ...o, url: o.previousUrl })),
];
if (!dryRun && expiredForHistory.length > 0) {
appendToScanHistory(expiredForHistory, date, 'skipped_expired');
}
// Pages that loaded but had no Apply control: record so we don't re-verify
// them next scan, but never let them reach pipeline.md.
if (!dryRun && droppedOffers.length > 0) {
appendToScanHistory(droppedOffers, date, 'skipped_no_apply_control');
}
// Guard-rejected URLs (invalid / unsupported protocol / blocked host) are
// recorded with a precise status so subsequent scans dedup-skip them via
// loadSeenUrls, but they never reach pipeline.md.
if (!dryRun && invalidOffers.length > 0) {
// Group by code so the TSV reflects the actual reason category.
const byStatus = new Map();
for (const o of invalidOffers) {
const status = guardStatusFor(o.code);
if (!byStatus.has(status)) byStatus.set(status, []);
byStatus.get(status).push(o);
}
for (const [status, group] of byStatus) {
appendToScanHistory(group, date, status);
}
}
// 7. Print summary
console.log(`\n${'━'.repeat(45)}`);
console.log(`Portal Scan — ${date}`);
console.log(`${'━'.repeat(45)}`);
const summaryCompanies = targets.filter(t => !t._isBoard).length;
const summaryBoards = targets.filter(t => t._isBoard).length;
console.log(`Companies scanned: ${summaryCompanies}`);
if (summaryBoards > 0) console.log(`Job boards scanned: ${summaryBoards}`);
console.log(`Total jobs found: ${totalFound}`);
console.log(`Filtered by title: ${totalFilteredTitle} removed`);
if (skipTiers.length > 0) {
console.log(`Filtered by tier: ${totalFilteredTier} removed`);
}
console.log(`Filtered by location: ${totalFilteredLocation} removed`);
if (config.max_posting_age_days != null || totalFilteredPostingAge > 0) {
console.log(`Filtered by age: ${totalFilteredPostingAge} removed`);
}
console.log(`Filtered by salary: ${totalFilteredSalary} removed`);
console.log(`Filtered by content: ${totalFilteredContent} removed`);
if (Object.keys(windows).length > 0 || totalFilteredCooldown > 0) {
console.log(`Filtered by cooldown: ${totalFilteredCooldown} removed`);
}
console.log(`Duplicates: ${totalDupes} skipped`);
if (blacklist.size > 0) {
if (includeBlacklisted) {
console.log(`Blacklisted: ${annotatedBlacklisted} let through annotated (--include-blacklisted)`);
} else {
console.log(`Blacklisted: ${totalFilteredBlacklist} skipped (blacklist)`);
}
}
if (crossListings.length > 0) {
console.log(`\n⚠️ Possible cross-listings (same JD text, different company) — warn only, nothing was dropped:`);
for (const { offer, row, score } of crossListings) {
console.log(` - ${offer.company}${offer.title}`);
console.log(` ≈ ${Math.round(score * 100)}% of ${row.company}${row.title} (seen ${row.dateStr})`);
console.log(` ${offer.url}`);
console.log(` vs ${row.url}`);
}
console.log(` If one side is an agency, apply through ONE channel only — a double submission burns both (#1596).`);
}
if (historyPolicy.recheckAfterDays != null) {
console.log(`Recheck eligible: ${seenUrlState.recheckEligible} old scan-history URL(s)`);
}
if (verify) {
console.log(`Expired (verified): ${expiredOffers.length} dropped`);
console.log(`Rediscovered (moved): ${migratedOffers.length} migrated`);
console.log(`No apply control: ${droppedOffers.length} dropped`);
console.log(`Invalid (guarded): ${invalidOffers.length} dropped`);
}
console.log(`New offers added: ${verifiedOffers.length}`);
// Trust validation summary (only when trust_filter is configured)
if (config.trust_filter && config.trust_filter.enabled !== false && verifiedOffers.length > 0) {
const trustHigh = verifiedOffers.filter(o => o.trustLevel === 'high').length;
const trustMedium = verifiedOffers.filter(o => o.trustLevel === 'medium').length;
const trustLow = verifiedOffers.filter(o => o.trustLevel === 'low').length;
console.log(`Trust validation: ${trustHigh} high, ${trustMedium} medium, ${trustLow} low`);
// Flag breakdown
/** @type {Record<string, number>} */
const flagCounts = {};
for (const o of verifiedOffers) {
for (const f of (o.trustFlags || [])) {
flagCounts[f] = (flagCounts[f] || 0) + 1;
}
}
if (Object.keys(flagCounts).length > 0) {
const parts = Object.entries(flagCounts).map(([k, v]) => `${k}: ${v}`);
console.log(`Trust flags: ${parts.join(', ')}`);
}
}
if (agentHandoff.length > 0) {
console.log(`Agent/WebSearch handoff: ${agentHandoff.length} compan${agentHandoff.length === 1 ? 'y' : 'ies'} not handled by zero-token providers`);
for (const item of agentHandoff.slice(0, 25)) {
const hint = item.query ? ` — ${item.query}` : '';
console.log(` • ${item.company} (${item.method})${hint}`);
}
if (agentHandoff.length > 25) {
console.log(` … ${agentHandoff.length - 25} more omitted; narrow with --company or inspect portals.yml`);
}
}
const unreachableTargets = errors.filter((e) => e.kind === 'slug_gone');
const networkTargets = errors.filter((e) => e.kind === 'network');
const otherErrors = errors.filter((e) => e.kind !== 'slug_gone' && e.kind !== 'network');
if (unreachableTargets.length > 0) {
const names = unreachableTargets.map((e) => e.company).join(', ');
console.log(`\n⚠️ ${unreachableTargets.length} target(s) unreachable (slug?): ${names} — run: node verify-portals.mjs`);
}
if (emptyTargets.length > 0) {
console.log(`🟡 ${emptyTargets.length} target(s) live but empty: ${emptyTargets.join(', ')}`);
}
if (networkTargets.length > 0) {
console.log(`\nNetwork errors (${networkTargets.length}):`);
for (const e of networkTargets) {
console.log(` ✗ ${e.company}: ${e.error}`);
}
}
if (otherErrors.length > 0) {
console.log(`\nErrors (${otherErrors.length}):`);
for (const e of otherErrors) {
console.log(` ✗ ${e.company}: ${e.error}`);
}
}
if (verifiedOffers.length > 0) {
console.log('\nNew offers:');
for (const o of verifiedOffers) {
const trustSuffix = o.trustScore != null && o.trustScore < 100
? ` [Trust: ${o.trustScore}/100${o.trustFlags?.length ? ' — ' + o.trustFlags.join(', ') : ''}]`
: '';
const blacklistSuffix = o.blacklisted ? ' [BLACKLISTED — on your do-not-apply list]' : '';
console.log(` + ${o.company} | ${o.title} | ${o.location || 'N/A'}${trustSuffix}${blacklistSuffix}`);
}
if (dryRun) {
console.log('\n(dry run — run without --dry-run to save results)');
} else {
console.log(`\nResults saved to ${PIPELINE_PATH} and ${SCAN_HISTORY_PATH}`);
}
}
// Persist this run's counters (#1604) — guarded exactly like the other
// writes; a --dry-run must leave no trace.
if (!dryRun) {
appendScanRunSummary({
timestamp: new Date().toISOString(), status: 'completed',
companies: summaryCompanies, boards: summaryBoards, found: totalFound,
filteredTitle: totalFilteredTitle, filteredTier: totalFilteredTier,
filteredLocation: totalFilteredLocation, filteredPostingAge: totalFilteredPostingAge,
filteredSalary: totalFilteredSalary,
filteredContent: totalFilteredContent, filteredCooldown: totalFilteredCooldown,
dupes: totalDupes, newAdded: verifiedOffers.length, errors: errors.length,
filteredBlacklist: totalFilteredBlacklist,
});
}
console.log(`\n→ Run /career-ops pipeline to evaluate new offers.`);
console.log('→ Share results and get help: https://discord.gg/8pRpHETxa4');
}
// Only run main() when invoked directly (`node scan.mjs`), not when imported by tests.
// `|| ''` guards the case where Node is invoked without a script arg (e.g. `node -e`).
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
main().catch(err => {
console.error('Fatal:', err.message);
process.exit(1);
});
}