scan-hn.mjs
Rama main · réplica de lectura del repositorio original.
#!/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); });
}