#!/usr/bin/env node /** * scan-hn.mjs — Hacker News scanner with Optional AI Enhancement. * Following the "Zero-Keys" architecture: * 1. Deterministic fetch via HN Provider API. * 2. Optional AI-layer if GEMINI_API_KEY is present. * 3. Fallback to keyword-matching if no key is present. */ try { const { config } = await import('dotenv'); config(); } catch (e) {} import { readFileSync, existsSync } from 'fs'; import { loadYaml } from './lib/yaml-document.mjs'; import { GoogleGenerativeAI } from '@google/generative-ai'; import { appendToPipeline, appendToScanHistory, PORTALS_PATH } from './scan.mjs'; import { makeHttpCtx } from './providers/_http.mjs'; import { buildTitleFilter } from './title-keywords.mjs'; import { createAdmissionGate, formatSkipCounts } from './lib/admission-gate.mjs'; import { localToday } from './lib/local-today.mjs'; import { assertGoogleRequestAllowed } from './lib/llm-outbound.mjs'; import { installPiiInterceptor, PrivacyGuardError } from './lib/pii-interceptor.mjs'; // The Google SDK issues its own global fetch; this ambient install covers it. // The explicit gate inside extractWithAI() is the primary layer. installPiiInterceptor(); // Import the deterministic provider import hnProvider from './providers/hackernews.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { printScanSummaryHeader } from './lib/scan-summary-marker.mjs'; import { validateFlags } from './lib/cli-flags.mjs'; const KNOWN_FLAGS = ['--help', '-h']; const USAGE = `Usage: node scan-hn.mjs # scan the latest "Ask HN: Who is hiring?" thread Fetches the newest hiring thread from Hacker News and keeps the postings whose header line matches hn_hiring.keywords in portals.yml (default: "Software Engineer"). title_filter.negative, location_filter and data/blacklist.md apply as they do in scan.mjs. With GEMINI_API_KEY set, Gemini extracts the company, title and location of each matching posting. New offers are appended to data/pipeline.md and to the scan history. Flags: --help, -h # show this help and exit`; // ── Configuration ──────────────────────────────────────────────────── // Imported from scan.mjs so it honors CAREER_OPS_PORTALS and the data root (#3510). function loadPortals() { if (!existsSync(PORTALS_PATH)) return {}; try { return loadYaml(readFileSync(PORTALS_PATH, 'utf-8')) || {}; } catch (err) { // Same message and exit as scan.mjs. Swallowing this ran the scan on the // default keyword, so a user who set hn_hiring.keywords got postings they // never asked for in data/pipeline.md and nothing saying why (#4921). console.error(`Error: failed to parse ${PORTALS_PATH}: ${err.message}`); process.exit(1); } } function loadKeywords(portals) { const keywords = portals?.hn_hiring?.keywords; // One keyword written without list brackets (`keywords: Platform Engineer`) // is a YAML scalar. buildTitleFilter reads only a list and drops anything // else, so the scanner's only positive filter was gone and every HN post // matched. This is scan-hn's own key, with no validator warning behind it // (title_filter's bare-string case gets one in validate-portals), so the // obvious reading is taken: one keyword. if (typeof keywords === 'string' && keywords.trim()) return [keywords]; return keywords || ["Software Engineer"]; } // hn_hiring.keywords is this scanner's positive list: a post's header line // ("Company | Role | Location | URL") is not an ATS title, so title_filter's // positives would not fit it. The user's title_filter negatives still apply, // through the same compiled matcher scan.mjs uses (word boundaries for short // acronyms, `word:` prefixes, accent folding), not a substring test (#5478). export function buildHnTitleFilter(keywords, titleFilter = {}) { return buildTitleFilter({ positive: keywords, negative: titleFilter?.negative }); } // extractWithAI's placeholder for "the post names no location". It is not a // location, so the location filter must not judge it. const UNKNOWN_LOCATION = 'Remote/Unknown'; // ── AI Extraction Layer ───────────────────────────────────────── export async function extractWithAI(rawText, model) { const prompt = `--- BEGIN UNTRUSTED DATA ---\n${rawText.substring(0, 2000)}\n--- END UNTRUSTED DATA ---`; // Privacy gate. Built from the same prompt handed to the SDK below, so the // inspected payload and the transmitted one cannot drift. HN postings are // public-third-party text, but they can carry a poster's contact details, and // the configured values are the candidate's own. assertGoogleRequestAllowed({ turns: [prompt] }); // A failed API call is not a non-match, so it is not caught here: an invalid // key, an exhausted quota or a retired model must reach the caller, which // counts it. Returning null for it made every job look like a non-match and // the run end with "New offers: 0" and exit 0. const result = await model.generateContent(prompt); try { const response = result.response.text(); const clean = response.replace(/```yaml|```/g, '').trim(); let parsed; try { parsed = loadYaml(clean); } catch { return null; // Always return null on parse errors } if (!parsed || typeof parsed !== 'object') return null; return { company: (parsed.company || parsed.COMPANY || '').trim(), title: (parsed.title || parsed.TITLE || '').trim(), location: (parsed.location || parsed.LOCATION || 'Remote/Unknown').trim() }; } catch { return null; } } // ── Main Logic ─────────────────────────────────────────────────────── async function main() { const apiKey = process.env.GEMINI_API_KEY; const portals = loadPortals(); const myKeywords = loadKeywords(portals); // One gate for both branches: the seen set (normalized URLs), the blacklist, // the keywords and title_filter negatives, and location_filter. The AI branch // used to admit every post Gemini could extract, because the keywords only // appeared in its prompt (#5478). const gate = createAdmissionGate({ config: portals, titleFilter: buildHnTitleFilter(myKeywords, portals.title_filter), }); console.log(`🔍 Fetching latest HN Hiring data...`); // The same HTTP context scan.mjs hands every provider: timeout, 10 MB body cap, // a non-2xx guard with the status in the error, and the redirect option the // provider passes (hackernews asks for redirect:'error'). const ctx = makeHttpCtx(); const rawJobs = await hnProvider.fetch({ name: 'HN' }, ctx); const newOffers = []; let aiCalls = 0; let aiFailures = 0; let firstAiError = ''; // STEP 2: The Architecture Branch if (apiKey) { console.log(`✨ AI Key detected. Processing with Gemini...`); // Same default as gemini-eval.mjs. gemini-1.5-flash is shut down and every // request to it returns 404. const modelName = process.env.GEMINI_MODEL || 'gemini-3.6-flash'; const genAI = new GoogleGenerativeAI(apiKey); const model = genAI.getGenerativeModel({ model: modelName, systemInstruction: `Extract job data. Match: [${myKeywords.join(', ')}]. Format: YAML (company, title, location).`, }); for (const job of rawJobs) { // Through check(), not a bare continue, so the summary counts it as in // the no-key branch. if (gate.isSeen(job)) { gate.check(job); continue; } // The keywords decide before Gemini is paid to read the post. A post // whose header names none of them is recorded as a title skip here. if (!gate.titleFilter(job.title)) { gate.check(job); continue; } aiCalls++; let extracted; try { extracted = await extractWithAI(job.title + " " + (job.text || ""), model); } catch (err) { aiFailures++; if (!firstAiError) firstAiError = `${modelName}: ${err?.message || err}`; // A PrivacyGuardError means a configured value was found in the job // text before it reached the network. The job was never evaluated, so // it must NOT be recorded as seen — it should surface again on the next // scan once the user has resolved the privacy configuration. if (err?.code !== 'PRIVACY_GUARD_BLOCKED') gate.markSeen(job); continue; } if (!extracted || !extracted.company || !extracted.title) { gate.markSeen(job); continue; } // The provider's own postedAt (the comment's created_at), never the scan // time: scan-history records it as posted_at, and a guessed date is // worse than an absent one (modes/oferta.md). const offer = { ...job, ...extracted, source: 'hn-hiring', postedAt: job.postedAt }; // The blacklist and location filter judge what Gemini extracted; the // title filter reads the header line the keywords already matched. const judged = extracted.location === UNKNOWN_LOCATION ? { ...offer, location: job.location || '' } : offer; if (gate.check(judged, { titleText: job.title })) continue; newOffers.push(offer); console.log(` ✅ AI Match: ${extracted.company}`); } } else { // STEP 3: Fallback Mode (Deterministic/No-Key) console.log(`⚠️ No AI key. Using keyword filtering mode...`); for (const job of rawJobs) { if (gate.check(job)) continue; newOffers.push({ ...job, source: 'hn-hiring' }); console.log(` ✅ Match: ${job.company}`); } } if (newOffers.length > 0) { await appendToPipeline(newOffers); await appendToScanHistory(newOffers, localToday(), 'added'); } // Printed on every run, including the zero-match one. This scanner used to // end in silence when nothing matched, which reads identically to a run that // died at the fetch — the summary is what tells those apart (#3560). printScanSummaryHeader('HN Scan', localToday()); console.log(`Postings fetched: ${rawJobs.length}`); console.log(`New offers: ${newOffers.length}`); const skipped = formatSkipCounts(gate.counts); if (skipped) console.log(`Skipped: ${skipped}`); if (aiFailures > 0) { console.log(`AI errors: ${aiFailures} of ${aiCalls}`); console.warn(`\n⚠️ ${aiFailures} of ${aiCalls} Gemini extractions failed, so those postings were not checked. First error: ${firstAiError}`); // Every call failed: the AI pass did not run at all, so a zero here is // not a result. Exit non-zero so a scheduled run does not report success. if (aiFailures === aiCalls) process.exitCode = 1; } if (newOffers.length > 0) console.log(`\n🎉 Success: ${newOffers.length} offers added.`); } if (isMainModule(import.meta.url)) { // Before main(): --help must not start a live scan, and a flag this script // does not have (a --dry-run it never had) must fail rather than fall // through to one. Inside the main-module guard because // tests/hn-scanner.test.mjs imports this module. validateFlags(process.argv.slice(2), KNOWN_FLAGS, USAGE); main().catch(err => { console.error("Fatal:", err.message); process.exit(1); }); }