← back to Crazy News Channel Shadowman

scripts/lib/auto-tag-real-news.mjs

101 lines

// scripts/lib/auto-tag-real-news.mjs — shared real-news auto-tagging
// heuristic. Deterministic, $0, no API call, no LLM.
//
// HARD RULE (TK-12165, 2026-09-24): every entry in real-news-data.js MUST
// carry a non-empty `tags` array (3-6 short, lowercase-ish, human-readable
// tags). fetch-real-news.mjs imports autoTagRealNews() from here on every
// refresh so a future run can never write an untagged entry. If the
// keyword vocabulary below needs a new rule for a topic that keeps
// recurring untagged, add it here — this is the ONE place both the
// backfill and the live fetcher read from, so the vocabulary stays shared
// and tags actually recur across posts instead of drifting apart.

const KEYWORD_RULES = [
  [/migrant|migration|border cross|channel cross/i, "immigration"],
  [/senate race|house race|campaign trail|primary election|\bballot\b|\bcandidate\b/i, "elections"],
  [/\bsenate\b|\bcongress\b|\bcapitol\b|lawmakers|legislation|\bbill\b/i, "congress"],
  [/white house|the administration\b/i, "white house"],
  [/\bfaa\b|\bairline\b|\bairport\b|aviation/i, "aviation"],
  [/nor'?easter|hurricane|tropical storm|storm surge/i, "storms"],
  [/flooding|\bflood\b/i, "flooding"],
  [/el ni[nñ]o|la ni[nñ]a/i, "climate patterns"],
  [/heat wave|heatwave|triple-digit/i, "heat wave"],
  [/wildfire|\bdrought\b/i, "climate patterns"],
  [/privacy|smartglasses|smart glasses/i, "consumer tech"],
  [/\bapple\b|iphone|apple watch|mac mini|macbook/i, "apple"],
  [/\bgoogle\b|pixel \d|\bgemini\b/i, "google"],
  [/\bmeta\b|zuckerberg|\bfacebook\b/i, "meta"],
  [/\bcamera\b|\bnikon\b|\bcanon\b|sony a\d/i, "cameras"],
  [/headphones|earbuds|\bbeats\b|audio gear/i, "audio gear"],
  [/\bnfl\b|quarterback|touchdown|packers|falcons|\bgiants\b|chiefs|cowboys/i, "nfl"],
  [/college football|\bncaa\b/i, "college football"],
  [/\bboxing\b|heavyweight|\bufc\b|\bmma\b/i, "combat sports"],
  [/music video|\balbum\b|\btour\b|\bvmas\b|\bgrammy\b|billboard/i, "music"],
  [/box office|\bfilm\b|\bmovie\b|\bstudio\b/i, "film & tv"],
  [/streaming|netflix|\bhbo\b|paramount\+/i, "streaming tv"],
  [/survivor|reality (tv|show)/i, "reality tv"],
  [/\bmerger\b|antitrust/i, "corporate mergers"],
  [/mortgage|housing market|home prices/i, "housing market"],
  [/stock market|s&p 500|dow jones|nasdaq/i, "stock market"],
  [/prediction market|polymarket|\bgambling\b/i, "prediction markets"],
  [/data center|\borbital\b|space launch|\brocket\b/i, "space & data centers"],
  [/\boil\b|\bopec\b|energy prices|strait of hormuz/i, "energy markets"],
  [/missile|airstrike|drone attack|\bmilitary\b/i, "military conflict"],
  [/\bnato\b|\brussia\b|\bukraine\b|geopolit/i, "geopolitics"],
  [/\barrest\b|stabbing|\bpolice\b|\bcrime\b/i, "crime"],
  [/hitler|\bnazi\b|world war/i, "history"],
];

const CATEGORY_FALLBACK = {
  politics: "washington",
  weather: "forecast",
  scitech: "tech news",
  sports: "game day",
  entertainment: "pop culture",
  business: "markets",
  uncategorized: "world news",
};

function normalizeOutlet(name) {
  if (!name) return null;
  let n = String(name).trim();
  n = n.replace(/\.com$/i, "");
  const MAP = {
    "9to5Google": "9to5google",
    "bgr": "bgr",
    "gearpatrol": "gear patrol",
    "arstechnica": "ars technica",
    "kare11": "kare 11",
    "ABC7 Los Angeles": "abc7",
    "E! News": "e! news",
  };
  if (MAP[n]) return MAP[n];
  return n.toLowerCase();
}

// Returns a tags array, always length 3-6, for one real-news story object
// { category, categoryLabel, sourceName, stages: [{headline}] }.
export function autoTagRealNews(story) {
  const headline = (story.stages && story.stages[0] && story.stages[0].headline) || "";
  const tags = [];
  const seen = new Set();
  const push = (t) => {
    if (!t) return;
    const k = t.toLowerCase();
    if (seen.has(k)) return;
    seen.add(k);
    tags.push(t);
  };
  push((story.categoryLabel || story.category || "").toLowerCase());
  push(normalizeOutlet(story.sourceName));
  for (const [re, tag] of KEYWORD_RULES) {
    if (tags.length >= 6) break;
    if (re.test(headline)) push(tag);
  }
  if (tags.length < 3) {
    push(CATEGORY_FALLBACK[story.category] || "wire report");
  }
  if (tags.length < 3) push("wire report");
  return tags.slice(0, 6);
}