#!/usr/bin/env node /** * detect-reposts.mjs — Repost Detector for career-ops * * Reads data/scan-history.tsv, groups rows by company, fuzzy-matches role * titles with roleFuzzyMatch from role-matcher.mjs, and flags any * company+role that appears 2+ times with different URLs within a 90-day * window. Such clusters are almost certainly the same opening being * re-listed by the employer — useful for tracking stale pipelines and * ghost postings. * * Only rows with status `added` are considered. Rows with a non-`added` * status (`skipped_expired`, `skipped_invalid_url`, `skipped_blocked_host`) * describe dead postings, not reposts, and are skipped. * * Run: node detect-reposts.mjs (JSON to stdout) * node detect-reposts.mjs --summary (human-readable table) * node detect-reposts.mjs --window 60 (override 90-day window) * node detect-reposts.mjs --self-test * * Issue #1205 — github.com/santifer/career-ops */ import { readFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { roleFuzzyMatch } from './role-matcher.mjs'; const CAREER_OPS = dirname(fileURLToPath(import.meta.url)); const SCAN_HISTORY_PATH = join(CAREER_OPS, 'data/scan-history.tsv'); const DEFAULT_WINDOW_DAYS = 90; // --- CLI args --- const args = process.argv.slice(2); const summaryMode = args.includes('--summary'); const selfTestMode = args.includes('--self-test'); const windowIdx = args.indexOf('--window'); const windowDays = windowIdx !== -1 && args[windowIdx + 1] !== undefined ? (Number.isNaN(parseInt(args[windowIdx + 1], 10)) ? DEFAULT_WINDOW_DAYS : parseInt(args[windowIdx + 1], 10)) : DEFAULT_WINDOW_DAYS; // --- Date helpers --- function parseDate(dateStr) { const iso = String(dateStr || '').trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null; const date = new Date(`${iso}T00:00:00Z`); if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== iso) return null; return date; } function daysBetween(d1, d2) { return Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24)); } // --- Parse scan-history.tsv --- // Format: url, first_seen, portal, title, company, status, location export function parseScanHistory(content) { const lines = content.split('\n').filter(line => line.trim()); if (lines.length === 0) return []; const rows = []; // Only skip the header when it actually looks like one — older // headerless scan-history.tsv files and the seed file in the repo // don't have a header row, and slice(1) would silently lose row 0. const hasHeader = /^\s*url\s*\t/i.test(lines[0]); for (const line of lines.slice(hasHeader ? 1 : 0)) { const cols = line.split('\t'); if (cols.length < 5) continue; const [url, firstSeen, portal = '', title = '', company = '', status = 'added', location = ''] = cols; const date = parseDate(firstSeen); if (!url || !date) continue; rows.push({ url: url.trim(), date, dateStr: firstSeen.trim(), portal: portal.trim(), title: title.trim(), company: company.trim(), status: (status || 'added').trim(), location: (location || '').trim(), }); } return rows; } function loadScanHistory(path = SCAN_HISTORY_PATH) { if (!existsSync(path)) return []; return parseScanHistory(readFileSync(path, 'utf-8')); } // --- Core detection --- // // Group rows by company (case-insensitive), then within each company group // compare all pairs of titles via roleFuzzyMatch. Build clusters of matching // rows with union-find, then keep a cluster only if (a) it contains 2+ rows, // (b) at least two rows have different URLs, and (c) the cluster's first_seen // dates all fall within `windowDays` of each other. // // Exported so external tests can call detectReposts() directly on a row list. export function detectReposts(rows, windowDays = DEFAULT_WINDOW_DAYS) { if (!Array.isArray(rows)) return []; const valid = rows .filter(r => r && typeof r === 'object' && r.status === 'added' && typeof r.url === 'string' && r.url.trim() && r.date instanceof Date && !Number.isNaN(r.date.getTime()) && typeof r.company === 'string' && r.company.trim() && typeof r.title === 'string' && r.title.trim() ) .map(r => ({ ...r, url: r.url.trim(), company: r.company.trim(), title: r.title.trim(), })); if (valid.length < 2) return []; // Group by company (case-insensitive). const byCompany = new Map(); for (const row of valid) { const key = row.company.toLowerCase(); if (!byCompany.has(key)) byCompany.set(key, []); byCompany.get(key).push(row); } const clusters = []; for (const [, groupRows] of byCompany) { if (groupRows.length < 2) continue; clusters.push(...detectRepostsInGroup(groupRows, windowDays)); } return clusters.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1)); } // Cluster rows in a single company group. Rows are first grouped by title // (exact or fuzzy match), then each title group is sorted by date and a // sliding window finds sub-clusters within the windowDays span. This two-phase // approach prevents non-matching roles (e.g. a Product Manager between two // Backend Engineer postings) from breaking a valid repost cluster. function detectRepostsInGroup(rows, windowDays) { const titleGroups = []; const used = new Set(); for (const row of rows) { if (used.has(row)) continue; const group = [row]; used.add(row); for (const other of rows) { if (used.has(other)) continue; if (row.title.toLowerCase() === other.title.toLowerCase() || roleFuzzyMatch(row.title, other.title)) { group.push(other); used.add(other); } } titleGroups.push(group); } const results = []; for (const group of titleGroups) { if (group.length < 2) continue; const sorted = [...group].sort((a, b) => (a.date < b.date ? -1 : 1)); let cluster = []; for (const row of sorted) { if (cluster.length === 0) { cluster = [row]; continue; } const first = cluster[0]; const span = daysBetween(first.date, row.date); if (span <= windowDays) { cluster.push(row); } else { // Span exceeds window. Seal the current cluster if it has 2+ rows, // then slide the window: drop the oldest row(s) until the new row // fits within windowDays of the new cluster start. This preserves // valid overlapping repost pairs that would otherwise be dropped // (e.g. Jan 1 + Mar 15 sealed, but Mar 15 + Jun 10 also valid). if (cluster.length >= 2) { const result = buildRepostCluster(cluster, windowDays); if (result) results.push(result); } cluster = cluster.filter(c => daysBetween(c.date, row.date) <= windowDays); cluster.push(row); } } if (cluster.length >= 2) { const result = buildRepostCluster(cluster, windowDays); if (result) results.push(result); } } return results; } // A fuzzy-matched cluster becomes a repost cluster only when (a) at least two // distinct URLs are present (same URL means a dedup hit, not a repost), and // (b) every row's first_seen date falls within windowDays of every other row. // We enforce the window by requiring max-min span <= windowDays. Rows sharing // the same URL are collapsed (only the earliest sighting is kept) so a URL // seen on multiple scan dates doesn't inflate the repost count. function buildRepostCluster(clusterRows, windowDays) { const byUrl = new Map(); for (const row of clusterRows) { if (!byUrl.has(row.url) || row.date < byUrl.get(row.url).date) { byUrl.set(row.url, row); } } const deduped = [...byUrl.values()]; if (deduped.length < 2) return null; const sorted = [...deduped].sort((a, b) => (a.date < b.date ? -1 : 1)); const first = sorted[0]; const last = sorted[sorted.length - 1]; const span = daysBetween(first.date, last.date); if (span > windowDays) return null; const role = last.title; const appearances = sorted.map(r => ({ url: r.url, date: r.dateStr, title: r.title })); return { company: clusterRows[0].company, role, repostCount: appearances.length, firstSeen: first.dateStr, lastSeen: last.dateStr, daysSpan: span, appearances, }; } // --- Summary mode --- function printSummary(clusters) { console.log(`\n${'='.repeat(78)}`); console.log(' Repost Detector — career-ops'); console.log(` window: ${windowDays} days | clusters: ${clusters.length}`); console.log(`${'='.repeat(78)}\n`); if (clusters.length === 0) { console.log(' No reposted roles detected.\n'); return; } const header = ' ' + 'Company'.padEnd(22) + 'Role'.padEnd(34) + 'Reposts'.padEnd(9) + 'Span'.padEnd(12) + 'First → Last'; console.log(header); console.log(' ' + '-'.repeat(90)); for (const c of clusters) { const company = (c.company || '').substring(0, 20).padEnd(22); const role = (c.role || '').substring(0, 32).padEnd(34); const reposts = String(c.repostCount).padEnd(9); const span = `${c.daysSpan}d`.padEnd(12); const range = `${c.firstSeen} → ${c.lastSeen}`; console.log(' ' + company + role + reposts + span + range); } console.log(''); } // --- Self-test --- function runSelfTest() { const baseRows = [ // Genuine repost: same role, different URL, within 90 days. { url: 'https://acme.com/jobs/sre-1', date: parseDate('2024-01-10'), dateStr: '2024-01-10', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' }, { url: 'https://acme.com/jobs/sre-2', date: parseDate('2024-03-01'), dateStr: '2024-03-01', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' }, // Distinct role at the same company — must NOT be flagged. { url: 'https://acme.com/jobs/eng-mgr', date: parseDate('2024-02-15'), dateStr: '2024-02-15', title: 'Engineering Manager Platform', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' }, // Same role + same URL — dedup hit, NOT a repost. { url: 'https://acme.com/jobs/sre-1', date: parseDate('2024-03-20'), dateStr: '2024-03-20', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' }, // Same role + different URL but outside 90-day window — NOT flagged. { url: 'https://acme.com/jobs/sre-3', date: parseDate('2024-12-01'), dateStr: '2024-12-01', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'added', portal: 'greenhouse', location: '' }, // Skipped (expired) row — must be ignored entirely. { url: 'https://acme.com/jobs/sre-4', date: parseDate('2024-02-01'), dateStr: '2024-02-01', title: 'Senior Site Reliability Engineer', company: 'Acme', status: 'skipped_expired', portal: 'greenhouse', location: '' }, ]; const clusters = detectReposts(baseRows, DEFAULT_WINDOW_DAYS); let pass = 0; let fail = 0; const check = (cond, label) => { if (cond) { pass += 1; } else { fail += 1; console.error(` FAIL: ${label}`); } }; // The genuine repost cluster (sre-1 on 2024-01-10, sre-2 on 2024-03-01). const repostClusters = clusters.filter(c => c.company === 'Acme' && /Site Reliability/.test(c.role) && c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-1') && c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-2') ); check(repostClusters.length === 1, 'genuine repost (same role, different URL, within 90d) should be flagged'); // The "same URL" row (sre-1 on 2024-03-20) must NOT inflate the cluster with // itself as a separate appearance — it collapses onto the sre-1 edge. if (repostClusters.length === 1) { const urls = repostClusters[0].appearances.map(a => a.url); check(new Set(urls).size === urls.length, 'appearances should not duplicate the same URL within one cluster'); check(repostClusters[0].repostCount === 2, 'repostCount should be 2 for the genuine cluster (sre-1, sre-2)'); } // The distinct Engineering Manager role must NOT appear in any cluster. const mgrClusters = clusters.filter(c => /Engineering Manager/.test(c.role)); check(mgrClusters.length === 0, 'distinct role at the same company should NOT be flagged'); // The outside-window row (sre-3 on 2024-12-01) must NOT be in the 90-day cluster. const sre3Clusters = clusters.filter(c => c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-3')); check(sre3Clusters.length === 0, 'same role + different URL but outside 90-day window should NOT be flagged'); // The skipped_expired row must never appear. const expiredClusters = clusters.filter(c => c.appearances.some(a => a.url === 'https://acme.com/jobs/sre-4')); check(expiredClusters.length === 0, 'rows with skipped_expired status must be ignored'); // Empty input -> empty output, no crash. check(detectReposts([], DEFAULT_WINDOW_DAYS).length === 0, 'empty input should return no clusters'); check(detectReposts(baseRows.filter(r => r.status !== 'added'), DEFAULT_WINDOW_DAYS).length === 0, 'only-skipped rows should return no clusters'); console.log(`\n detect-reposts self-test: ${pass} passed, ${fail} failed\n`); process.exit(fail > 0 ? 1 : 0); } // --- Run (CLI only; guarded so the module is safely importable for tests) --- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { if (selfTestMode) { runSelfTest(); } const rows = loadScanHistory(); const clusters = detectReposts(rows, windowDays); if (summaryMode) { printSummary(clusters); } else { console.log(JSON.stringify({ metadata: { windowDays, totalRows: rows.length, clusters: clusters.length, }, clusters, }, null, 2)); } }