#!/usr/bin/env node /** * discard-analytics.mjs — Analyze the discard log for filter tuning * * The discard log (data/discard.log, or batch/logs/discard.log for batch runs) * is the only record of what the filters threw away and why. This script reads * it and produces a summary of rejection reasons, domains, and daily volume, * plus the most frequent title_mismatch URLs for the candidate to review. * * Read-only: never modifies the log. * * The top title_mismatch section is deliberately dumb — it lists the URLs and * lets the candidate judge whether a keyword is costing them real roles. It * never infers which keyword to add; that is a guess wearing a number. * * Run: node discard-analytics.mjs [--log ] [--reason ] [--top N] [--since YYYY-MM-DD] * * --log defaults to data/discard.log. Pass batch/logs/discard.log to analyze * the batch variant (same shape, one extra id field per row). */ import { readFileSync, existsSync, statSync } from 'fs'; import { join } from 'path'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { flagValue, validateFlags } from './lib/cli-flags.mjs'; /** * Parse a discard log's contents into entries. * * Interactive rows are 3 fields: {timestamp}\t{url}\t{reason} * Batch rows are 4 fields: {timestamp}\t{id}\t{url}\t{reason} * The URL is always the field before the reason; detected by field count. * * @param {string} text - Raw log contents. * @returns {Array<{timestamp:string, url:string, reason:string}>} */ export function parseDiscardLog(text) { const out = []; for (const line of String(text).replace(/^\uFEFF/, '').split(/\r?\n/)) { const parts = line.split('\t'); if (parts.length < 3) continue; let timestamp, url, reason; if (parts.length >= 4) { [timestamp, , url, reason] = parts; } else { [timestamp, url, reason] = parts; } if (!timestamp || !url || !reason) continue; out.push({ timestamp, url, reason: reason.trim() }); } return out; } /** * Aggregate parsed entries into sorted reason/domain/day counts. * * @param {Array<{timestamp:string, url:string, reason:string}>} entries * @returns {{ * total: number, * byReason: Array<[string, number]>, * byDomain: Array<[string, number]>, * byDay: Array<[string, number]>, * titleMismatch: Array<[string, number]> * }} */ export function aggregateDiscards(entries) { const reasonCounts = {}; const domainCounts = {}; const dayCounts = {}; const titleMismatchCounts = {}; for (const e of entries) { reasonCounts[e.reason] = (reasonCounts[e.reason] || 0) + 1; try { const u = new URL(e.url); domainCounts[u.hostname] = (domainCounts[u.hostname] || 0) + 1; } catch { /* malformed URL */ } const day = e.timestamp.slice(0, 10); dayCounts[day] = (dayCounts[day] || 0) + 1; if (e.reason.includes('title_mismatch')) { titleMismatchCounts[e.url] = (titleMismatchCounts[e.url] || 0) + 1; } } const byReason = Object.entries(reasonCounts).sort((a, b) => b[1] - a[1]); const byDomain = Object.entries(domainCounts).sort((a, b) => b[1] - a[1]); const byDay = Object.entries(dayCounts).sort((a, b) => a[0].localeCompare(b[0])); const titleMismatch = Object.entries(titleMismatchCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); return { total: entries.length, byReason, byDomain, byDay, titleMismatch }; } /** * Render the summary to a string (the CLI prints it). * @param {ReturnType} agg * @param {number} topN - Number of domains/URLs to show. * @param {string} firstDate - Earliest timestamp in the filtered set. * @param {string} lastDate - Latest timestamp in the filtered set. * @returns {string} */ export function renderSummary(agg, topN, firstDate, lastDate) { const pct = (n) => ((n / agg.total) * 100).toFixed(1); const pad = (s, n) => String(s).padEnd(n); const lines = []; lines.push('=== Discard Log Analytics ==='); lines.push(`Period: ${firstDate} to ${lastDate}`); lines.push(`Total discards: ${agg.total}`); lines.push(''); lines.push('By reason:'); for (const [reason, count] of agg.byReason) { lines.push(` ${pad(reason, 30)} ${pad(count, 5)} (${pct(count)}%)`); } lines.push(''); lines.push(`By domain (top ${topN}):`); for (const [domain, count] of agg.byDomain.slice(0, topN)) { lines.push(` ${pad(domain, 35)} ${pad(count, 5)} (${pct(count)}%)`); } lines.push(''); lines.push('By day:'); for (const [day, count] of agg.byDay) { lines.push(` ${day} ${pad(count, 5)} ${'#'.repeat(Math.min(count, 50))}`); } lines.push(''); if (agg.titleMismatch.length > 0) { lines.push(`Top title_mismatch URLs (review for keyword tuning):`); for (const [url, count] of agg.titleMismatch.slice(0, topN)) { lines.push(` ${count}. ${url}`); } lines.push(''); } lines.push(`Summary: ${agg.byReason.length} unique reasons, ${agg.byDomain.length} domains, ${agg.byDay.length} active days`); if (agg.byReason.length > 0) { const [topReason, count] = agg.byReason[0]; lines.push(`Top rejection: "${topReason}" (${count} occurrences, ${pct(count)}%)`); } return lines.join('\n'); } // ── CLI entry point ──────────────────────────────────────────────────────── if (isMainModule(import.meta.url)) { // Before any work. argValue() below used indexOf(), which cannot see // `--log=path`, `--since=2026-08-26`, `--reason=x` or `--top=5`: the value // was dropped and the run used the default log and no filter at exit 0. // validateFlags rejects an unknown or valueless flag; flagValue reads both // spellings. const USAGE = 'Usage: node discard-analytics.mjs [--log ] [--reason ] [--top N] [--since YYYY-MM-DD]\n' + ' Analyzes the discard log for filter tuning insights.'; const args = process.argv.slice(2); validateFlags(args, ['--log', '--reason', '--top', '--since', '--help', '-h'], USAGE, { valueFlags: ['--log', '--reason', '--top', '--since'], requireOperand: true, }); const argValue = (flag) => flagValue(args, flag) ?? null; const filterReason = argValue('--reason'); const parsedTopN = Number(argValue('--top') || '10'); const topN = Number.isInteger(parsedTopN) && parsedTopN > 0 ? parsedTopN : 10; const sinceDate = argValue('--since'); const customLog = argValue('--log'); const logFile = customLog || join(getCareerOpsRoot(), 'data/discard.log'); let logStat = null; try { logStat = existsSync(logFile) ? statSync(logFile) : null; } catch { logStat = null; } if (!logStat?.isFile()) { console.log(`No discard log found at ${logFile}`); process.exit(0); } let entries = parseDiscardLog(readFileSync(logFile, 'utf-8')); if (sinceDate) entries = entries.filter((e) => e.timestamp >= sinceDate); if (filterReason) entries = entries.filter((e) => e.reason.toLowerCase().includes(filterReason.toLowerCase())); if (entries.length === 0) { console.log('No matching entries in discard log.'); process.exit(0); } const agg = aggregateDiscards(entries); const firstDate = agg.byDay[0][0]; const lastDate = agg.byDay[agg.byDay.length - 1][0]; console.log(renderSummary(agg, topN, firstDate, lastDate)); }