{
  "name": "Monitor RSS feeds for brand and regulatory mentions with rule-based scoring and email digests",
  "tags": [],
  "nodes": [
    {
      "id": "5939f65f3a7bc5e3a499b47e908f5d3e3982",
      "name": "Sticky Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        64
      ],
      "parameters": {
        "width": 780,
        "height": 416,
        "content": "## Monitor RSS feeds for brand and regulatory mentions\n\nRule-based media monitoring: deterministic JavaScript, no per-run API costs, one SMTP credential. Built for comms teams that scan the news for client departments, PR and brand watchers, and competitor or industry trackers.\n\n### How it works\n1. An hourly trigger starts the run; every setting lives in **Config**.\n2. Feeds are fetched, then articles are matched against your topic rules, scored for relevance and sentiment, entity-tagged, and de-duplicated across runs.\n3. One scored HTML digest per audience: the full digest plus one per route in digest.routes. Empty runs are skipped.\n\n### Setup\n1. Edit **Config**: feeds, topics, entities, recipients.\n2. Attach an SMTP credential on **Send Email**.\n3. Run once to test, then activate.\n\nThe notes on each section explain the details."
      },
      "typeVersion": 1
    },
    {
      "id": "c4f0662f3531872ed1f5937cdf685b39e835",
      "name": "Sticky Section A",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 452,
        "height": 360,
        "content": "### 1. Schedule + Config\nHourly by default; change the interval in the trigger. **Config** is the only node you edit: feeds, topics, entities, scoring, lexicon, and the digest recipients and routes."
      },
      "typeVersion": 1
    },
    {
      "id": "46c5b947af7f328743e1f82dd6079ac42ba2",
      "name": "Sticky Section B",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        480,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 732,
        "height": 360,
        "content": "### 2. Fetch + score\nOne item per feed; a broken feed does not stop the rest. **Process Articles** applies whole-word topic rules, scores relevance and sentiment, tags entities, and de-duplicates across runs via workflow static data."
      },
      "typeVersion": 1
    },
    {
      "id": "80ddd5550df8e3cab4380a7c90c30324e1b9",
      "name": "Sticky Section C",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1248,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 684,
        "height": 508,
        "content": "### 3. Digest + send\n**Build Digest** renders one scored HTML email per audience: the full digest plus one per route (per client department). **Has Matches?** skips empty runs unless digest.sendEmpty is true. Recipient addresses are placeholders in Config."
      },
      "typeVersion": 1
    },
    {
      "id": "128b5176ada25c083207866cba61c71c7e93",
      "name": "Sticky SMTP Credential",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1648,
        640
      ],
      "parameters": {
        "color": 3,
        "width": 236,
        "height": 92,
        "content": "### Required\nAttach an SMTP credential on **Send Email** before the first run."
      },
      "typeVersion": 1
    },
    {
      "id": "98bbe02e692aac33394b557ce4966bb1ff32",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        48,
        720
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 1
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "43ee64b6538a9b2329089f07db254a64eb65",
      "name": "Config",
      "type": "n8n-nodes-base.code",
      "notes": "Edit feeds[], topics[], entities, lexicon, scoring, digest, seenCap.\nSee README \u00a7Configure and examples/config.example.js.",
      "position": [
        288,
        720
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "return [{\n  json: {\n    // ---- 1. Sources ----------------------------------------------------------\n    // Any RSS/Atom URL. One bad feed will not stop the others (RSS Read is set\n    // to continueRegularOutput).\n    feeds: [\n      \"https://www.theverge.com/rss/index.xml\",\n      \"https://techcrunch.com/feed/\",\n      \"https://www.reuters.com/business/finance/rss\",\n      \"https://feeds.bbci.co.uk/news/business/rss.xml\"\n    ],\n\n    // ---- 2. Topics -----------------------------------------------------------\n    // Each topic has an include list (ALL terms must be present, boolean AND)\n    // and an exclude list (any match \u2192 topic skipped). Whole-word, case-\n    // insensitive. An article that matches NO topic is dropped.\n    topics: [\n      {\n        name: \"BrandMentions\",\n        include: [\"acme\"],\n        exclude: [\"acme tool company\", \"wile e coyote\"]\n      },\n      {\n        name: \"Competitors\",\n        include: [\"globex\"],\n        exclude: []\n      },\n      {\n        name: \"RegulatoryNews\",\n        include: [\"regulation\"],\n        exclude: [\"sports\", \"fashion\"]\n      }\n    ],\n\n    // ---- 3. Scoring ----------------------------------------------------------\n    // Relevance score = clamp(0..100, round(\n    //   termWeight   * min(totalIncludeHits, 10)\n    // + sourceWeight * min(sources[host] || sources.default || 1, 2)\n    // + recencyWeight * max(0, 1 - hoursOld / recencyHalfLifeHours)\n    // ))\n    scoring: {\n      termWeight: 6,            // raise to ~12 if you want keyword density to dominate (good for very specific brand names)\n      sourceWeight: 20,         // raise if you trust a few outlets a lot more than the rest\n      recencyWeight: 30,        // lower to ~10 for slow-moving regulatory/industry feeds; raise for breaking-news monitoring\n      recencyHalfLifeHours: 48, // raise to 168 (1 week) for weekly digests; lower to 12 for \"what happened overnight\"\n      sources: {\n        \"reuters.com\": 1.5,\n        \"bbc.co.uk\":   1.4,\n        \"techcrunch.com\": 1.2,\n        \"theverge.com\": 1.1,\n        default: 1.0           // anything not listed above gets this multiplier\n      }\n    },\n\n    // ---- 4. Sentiment lexicon -----------------------------------------------\n    // AFINN-style word \u2192 integer score (typical \u22125..+5). Sum is normalized by\n    // sqrt(tokenCount) so long articles don't dominate. Label thresholds:\n    //   \u2265 +0.5 \u2192 positive,  \u2264 -0.5 \u2192 negative,  else neutral.\n    lexicon: {\n      surge: 3, soar: 3, beat: 2, growth: 2, profit: 2, win: 2, gain: 2,\n      record: 1, upbeat: 2, strong: 1, rise: 1, rally: 2,\n      loss: -2, miss: -2, slump: -3, plunge: -3, fall: -1, drop: -1,\n      lawsuit: -3, fraud: -4, breach: -3, scandal: -3, fine: -2, probe: -2,\n      layoff: -3, layoffs: -3, recall: -2, hack: -3, outage: -2\n    },\n\n    // ---- 5. Entity dictionary -----------------------------------------------\n    // Label \u2192 list of aliases. Whole-word, case-insensitive. Each label is\n    // attached at most once per article.\n    entities: {\n      \"Acme Corp\":  [\"acme\", \"acme corp\", \"acmecorp\"],\n      \"Globex Inc\": [\"globex\", \"globex corp\"],\n      \"Initech\":    [\"initech\"]\n    },\n\n    // ---- 6. Digest email ----------------------------------------------------\n    digest: {\n      subjectPrefix: \"[MediaMonitor]\",\n      minRelevance: 30,   // hide low-score noise from the email\n      maxItems: 50,       // hard cap across all topics\n      to: \"ops@example.com\",      // full-digest recipient; set to \"\" to send routed digests only\n      from: \"monitor@example.com\",\n      sendEmpty: false,   // true = also email runs with zero matches (\"No new matches\")\n\n      // Routes: one EXTRA email per entry, filtered to the listed topics.\n      // Lets a comms desk send each client department only its own coverage.\n      // An article matching two routed topics appears in both emails.\n      routes: [\n        // { name: \"Environment Dept\", to: \"env-comms@example.gov\",   topics: [\"RegulatoryNews\"] },\n        // { name: \"Trade Dept\",       to: \"trade-comms@example.gov\", topics: [\"Competitors\"] }\n      ]\n    },\n\n    // ---- 7. Dedup --------------------------------------------------------\n    // Max number of link hashes kept in workflow static data. Older entries\n    // are dropped FIFO.\n    seenCap: 5000\n  }\n}];\n\n// ---- Starter-pack topics (uncomment and rename to use) ----------------------\n// Drop any of these into topics[] above. Replace the placeholder terms with\n// your real brand/competitor/regulator/industry keywords.\n//\n//   { name: \"OwnBrand\",        include: [\"yourbrand\"],         exclude: [] },\n//   { name: \"Top3Competitors\", include: [\"competitor\"],        exclude: [] },\n//   { name: \"RegulatoryRisk\",  include: [\"regulator\"],         exclude: [\"sports\"] },\n//   { name: \"IndustryGeneral\", include: [\"fintech\"],           exclude: [\"crypto\"] },\n//   { name: \"Tourism\",         include: [\"tourism\"],           exclude: [] },\n//   { name: \"Hospitality\",     include: [\"hotel\"],             exclude: [\"hotel california\"] },\n//   { name: \"Cruise\",          include: [\"cruise\"],            exclude: [\"tom cruise\", \"cruise missile\"] },\n",
        "language": "javaScript"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "c4d445bc0b739664687dc54c6e23d3fa308c",
      "name": "Feed URLs",
      "type": "n8n-nodes-base.code",
      "position": [
        528,
        720
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Fan out: one item per feed URL from Config.\nconst feeds = $('Config').first().json.feeds || [];\nreturn feeds.map(url => ({ json: { url } }));\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "33019dc91262e668ad526153e9d04d8a149c",
      "name": "RSS Read",
      "type": "n8n-nodes-base.rssFeedRead",
      "onError": "continueRegularOutput",
      "position": [
        768,
        720
      ],
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {}
      },
      "typeVersion": 1
    },
    {
      "id": "a8391248c70e77f060b11f71d5a3ec327df1",
      "name": "Process Articles",
      "type": "n8n-nodes-base.code",
      "notes": "The brain. Rule-based enrichment: topic match, relevance, sentiment, entities, dedup.\nPure functions inlined from src/lib.mjs. Reads $(\"Config\").first().json + workflow static data.",
      "position": [
        1008,
        720
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Process Articles: rule-based enrichment.\n// Pure functions are inlined below (copy of src/lib.mjs) so this node is\n// self-contained inside the exported workflow. Edit src/lib.mjs and re-run\n// scripts/build-workflow.mjs to regenerate.\n\nconst HTML_ENTITIES = {\n  '&amp;': '&', '&lt;': '<', '&gt;': '>',\n  '&quot;': '\"', '&#39;': \"'\", '&apos;': \"'\", '&nbsp;': ' '\n};\n\nfunction stripHtml(s) {\n  if (!s) return '';\n  return String(s)\n    .replace(/<\\s*br\\s*\\/?\\s*>/gi, ' ')\n    .replace(/<\\/?\\s*(p|div|li|h[1-6])\\s*>/gi, ' ')\n    .replace(/<[^>]+>/g, '')\n    .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, m => HTML_ENTITIES[m] ?? m)\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nfunction safeHostname(link) {\n  try {\n    return new URL(link).hostname.replace(/^www\\./, '').toLowerCase();\n  } catch {\n    return '';\n  }\n}\n\nfunction escapeRegex(s) {\n  return String(s).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction termRegex(term) {\n  // Whole-word, case-insensitive. \\b works for ASCII; phrases with spaces\n  // get \\b on the outer tokens which is the common-case intent.\n  return new RegExp(`\\\\b${escapeRegex(term)}\\\\b`, 'gi');\n}\n\nfunction countMatches(haystack, term) {\n  const m = haystack.match(termRegex(term));\n  return m ? m.length : 0;\n}\n\nfunction hasMatch(haystack, term) {\n  return termRegex(term).test(haystack);\n}\n\n/**\n * Cut a summary at `n` characters on a word boundary and append an ellipsis.\n * Used by the digest builder for tidy article previews.\n * @param {string} text  - input string\n * @param {number} [n=280] - max length before truncation\n * @returns {string} truncated text, or original if already short enough\n */\nfunction truncateSummary(text, n = 280) {\n  const s = String(text ?? '');\n  if (s.length <= n) return s;\n  const cut = s.slice(0, n);\n  const lastSpace = cut.lastIndexOf(' ');\n  return (lastSpace > n * 0.6 ? cut.slice(0, lastSpace) : cut) + '\u2026';\n}\n\n/**\n * Normalize a raw RSS/Atom item into a flat article shape.\n * Strips HTML, picks the best content field, derives `source` from the link host,\n * and parses `publishedAt` to ISO 8601.\n * @param {object} raw - raw feed item (from n8n RSS Read node)\n * @param {string} feedUrl - the feed URL (fallback for source host)\n * @returns {{title:string, link:string, source:string, publishedAt:string, summary:string, contentText:string}}\n */\nfunction normalizeArticle(raw, feedUrl) {\n  const title = stripHtml(raw?.title ?? '');\n  const link = String(raw?.link ?? raw?.url ?? raw?.guid ?? '').trim();\n  const summary = stripHtml(raw?.contentSnippet ?? raw?.summary ?? raw?.description ?? '');\n  const content = stripHtml(raw?.content ?? raw?.['content:encoded'] ?? '');\n  const contentText = (content && content.length > summary.length) ? content : summary;\n  const published = raw?.isoDate ?? raw?.pubDate ?? raw?.published ?? raw?.date ?? null;\n  let publishedAt;\n  if (published) {\n    const d = new Date(published);\n    publishedAt = isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();\n  } else {\n    publishedAt = new Date().toISOString();\n  }\n  const source = safeHostname(link) || safeHostname(feedUrl) || '';\n  return { title, link, source, publishedAt, summary, contentText };\n}\n\n/**\n * Hash a URL to a short stable fingerprint for cross-run dedup.\n * Lowercases, strips tracking query params (utm_*, gclid, fbclid, mc_cid, mc_eid),\n * drops trailing slash and fragment, then djb2 \u2192 32-bit hex.\n * @param {string} link\n * @returns {string} 8-char hex fingerprint, '0' for empty input\n */\nfunction hashLink(link) {\n  if (!link) return '0';\n  let normalized = String(link).trim().toLowerCase();\n  try {\n    const u = new URL(normalized);\n    u.hash = '';\n    const drop = [];\n    for (const k of u.searchParams.keys()) {\n      if (k.startsWith('utm_') || k === 'gclid' || k === 'fbclid' || k === 'mc_cid' || k === 'mc_eid') {\n        drop.push(k);\n      }\n    }\n    drop.forEach(k => u.searchParams.delete(k));\n    normalized = `${u.protocol}//${u.hostname}${u.pathname.replace(/\\/$/, '')}${u.search}`;\n  } catch {\n    normalized = normalized.replace(/#.*$/, '').replace(/\\/$/, '');\n  }\n  // djb2 32-bit\n  let h = 5381;\n  for (let i = 0; i < normalized.length; i++) {\n    h = (((h << 5) + h) + normalized.charCodeAt(i)) | 0;\n  }\n  return (h >>> 0).toString(16);\n}\n\n/**\n * Whole-word, case-insensitive boolean topic match.\n * For each topic: ALL include[] terms must appear AND no exclude[] term may appear.\n * @param {{title?:string, contentText?:string}} article\n * @param {Array<{name:string, include:string[], exclude:string[]}>} topics\n * @returns {string[]} names of topics matched, [] if none\n */\nfunction matchTopics(article, topics) {\n  const hay = `${article.title ?? ''} ${article.contentText ?? ''}`;\n  const matched = [];\n  for (const t of topics ?? []) {\n    const include = t.include ?? [];\n    const exclude = t.exclude ?? [];\n    if (include.length === 0) continue;\n    const allInclude = include.every(term => hasMatch(hay, term));\n    if (!allInclude) continue;\n    const anyExclude = exclude.some(term => hasMatch(hay, term));\n    if (anyExclude) continue;\n    matched.push(t.name);\n  }\n  return matched;\n}\n\n/**\n * Compute a relevance score 0\u2013100 from term frequency, source weight, recency.\n * @param {{title?:string, contentText?:string, source?:string, publishedAt?:string}} article\n * @param {string[]} topicsMatched - names of topics this article matched\n * @param {object} scoring - { termWeight, sourceWeight, recencyWeight, recencyHalfLifeHours, sources }\n * @param {Array<{name:string, include:string[]}>} allTopics\n * @param {number} [now=Date.now()] - reference timestamp for recency math\n * @returns {number} integer 0..100\n */\nfunction scoreRelevance(article, topicsMatched, scoring, allTopics, now = Date.now()) {\n  if (!topicsMatched || topicsMatched.length === 0) return 0;\n  const cfg = scoring ?? {};\n  const termWeight = cfg.termWeight ?? 6;\n  const sourceWeight = cfg.sourceWeight ?? 20;\n  const recencyWeight = cfg.recencyWeight ?? 30;\n  const halfLife = cfg.recencyHalfLifeHours ?? 48;\n  const sources = cfg.sources ?? {};\n\n  const hay = `${article.title ?? ''} ${article.contentText ?? ''}`;\n  let hits = 0;\n  for (const tName of topicsMatched) {\n    const t = (allTopics ?? []).find(x => x.name === tName);\n    if (!t) continue;\n    for (const term of t.include ?? []) {\n      hits += countMatches(hay, term);\n    }\n  }\n  // cap hits so a keyword-stuffed article doesn't dominate\n  const cappedHits = Math.min(hits, 10);\n  const termComponent = cappedHits * termWeight;\n\n  const srcMultiplier = sources[article.source] ?? sources.default ?? 1;\n  const sourceComponent = sourceWeight * Math.min(srcMultiplier, 2); // clamp absurd weights\n\n  const published = new Date(article.publishedAt ?? now).getTime();\n  const hoursOld = Math.max(0, (now - published) / 36e5);\n  const recencyComponent = recencyWeight * Math.max(0, 1 - hoursOld / halfLife);\n\n  const total = termComponent + sourceComponent + recencyComponent;\n  return Math.max(0, Math.min(100, Math.round(total)));\n}\n\n/**\n * AFINN-style sentiment. Sum lexicon[word] across whole-word tokens, normalize\n * by sqrt(tokenCount) so long articles don't dominate.\n * @param {{title?:string, contentText?:string}} article\n * @param {Record<string, number>} lexicon - word \u2192 integer (typical \u22125..+5)\n * @returns {{score:number, label:'positive'|'neutral'|'negative'}}\n */\nfunction scoreSentiment(article, lexicon) {\n  const text = `${article.title ?? ''} ${article.contentText ?? ''}`.toLowerCase();\n  const tokens = text.match(/[a-z][a-z'-]+/g) ?? [];\n  if (tokens.length === 0) return { score: 0, label: 'neutral' };\n  let raw = 0;\n  for (const tok of tokens) {\n    const v = lexicon?.[tok];\n    if (typeof v === 'number') raw += v;\n  }\n  const score = raw / Math.sqrt(tokens.length);\n  const rounded = Math.round(score * 100) / 100;\n  let label = 'neutral';\n  if (rounded >= 0.5) label = 'positive';\n  else if (rounded <= -0.5) label = 'negative';\n  return { score: rounded, label };\n}\n\n/**\n * Tag entities by alias. Each label is attached at most once per article.\n * @param {{title?:string, contentText?:string}} article\n * @param {Record<string, string[]>} entities - label \u2192 aliases (whole-word, case-insensitive)\n * @returns {string[]} unique labels matched\n */\nfunction tagEntities(article, entities) {\n  const hay = `${article.title ?? ''} ${article.contentText ?? ''}`;\n  const found = new Set();\n  for (const [label, aliases] of Object.entries(entities ?? {})) {\n    for (const alias of aliases ?? []) {\n      if (hasMatch(hay, alias)) { found.add(label); break; }\n    }\n  }\n  return [...found];\n}\n\n\nconst cfg = $('Config').first().json;\nconst store = $getWorkflowStaticData('global');\nif (!Array.isArray(store.seen)) store.seen = [];\nconst seenSet = new Set(store.seen);\n\nconst now = Date.now();\nconst enriched = [];\nconst newHashes = [];\n\nfor (const item of items) {\n  const raw = item.json || {};\n  const article = normalizeArticle(raw, raw.feedUrl || '');\n  if (!article.link) continue;\n\n  const topicsMatched = matchTopics(article, cfg.topics);\n  if (topicsMatched.length === 0) continue;\n\n  const hash = hashLink(article.link);\n  if (seenSet.has(hash)) continue;\n  seenSet.add(hash);\n  newHashes.push(hash);\n\n  const relevance = scoreRelevance(article, topicsMatched, cfg.scoring, cfg.topics, now);\n  const sentiment = scoreSentiment(article, cfg.lexicon);\n  const entitiesFound = tagEntities(article, cfg.entities);\n\n  enriched.push({\n    title: article.title,\n    link: article.link,\n    source: article.source,\n    publishedAt: article.publishedAt,\n    summary: article.summary,\n    topics: topicsMatched.join(', '),\n    topicsList: topicsMatched,\n    entities: entitiesFound.join(', '),\n    entitiesList: entitiesFound,\n    relevance,\n    sentiment: sentiment.label,\n    sentimentScore: sentiment.score,\n    hash,\n    scannedAt: new Date(now).toISOString()\n  });\n}\n\n// Persist seen-list, trimmed FIFO.\nstore.seen = [...store.seen, ...newHashes].slice(-1 * (cfg.seenCap || 5000));\n\nenriched.sort((a, b) => b.relevance - a.relevance);\nreturn enriched.map(a => ({ json: a }));\n",
        "language": "javaScript"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "bb6d47b5e1b1c7c47a02b991efae0ff4aa46",
      "name": "Build Digest",
      "type": "n8n-nodes-base.code",
      "position": [
        1264,
        720
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Build Digest: render scored HTML email digests, one output item per email.\n// Default: a single full digest to digest.to. With digest.routes configured,\n// also one filtered digest per route so each team gets only its own topics.\n// Every output item carries a 'total' count used by Has Matches? to skip empty runs.\nconst cfg = $('Config').first().json;\nconst digestCfg = cfg.digest || {};\nconst minRel = digestCfg.minRelevance ?? 0;\nconst maxItems = digestCfg.maxItems ?? 50;\n\nfunction escapeHtml(s) {\n  return String(s ?? '')\n    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n}\n\nfunction badge(label, color) {\n  return `<span style=\"display:inline-block;padding:2px 8px;border-radius:10px;background:${color};color:#fff;font-size:11px;font-weight:600;margin-right:6px;\">${escapeHtml(label)}</span>`;\n}\n\nfunction sentimentColor(s) {\n  if (s === 'positive') return '#137333';\n  if (s === 'negative') return '#b3261e';\n  return '#5f6368';\n}\nfunction relevanceColor(r) {\n  if (r >= 70) return '#0b8043';\n  if (r >= 40) return '#ef6c00';\n  return '#9aa0a6';\n}\n\nconst all = items\n  .map(i => i.json)\n  .filter(a => (a.relevance ?? 0) >= minRel)\n  .slice(0, maxItems);\n\nconst dateStr = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });\n\n// route = null renders the full digest; a route object filters to route.topics.\nfunction renderDigest(articles, route) {\n  const topicFilter = route && Array.isArray(route.topics) && route.topics.length ? route.topics : null;\n  const byTopic = {};\n  const counted = new Set();\n  for (const a of articles) {\n    for (const t of (a.topicsList || [])) {\n      if (topicFilter && !topicFilter.includes(t)) continue;\n      (byTopic[t] = byTopic[t] || []).push(a);\n      counted.add(a.hash || a.link);\n    }\n  }\n  const topicNames = Object.keys(byTopic).sort();\n  const total = counted.size;\n  const audience = route && route.name ? route.name + ': ' : '';\n  const subject = `${digestCfg.subjectPrefix || '[MediaMonitor]'} ${audience}${total} match${total === 1 ? '' : 'es'}: ${dateStr}`;\n\n  let body = `\n<div style=\"font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#202124;max-width:760px;margin:0 auto;\">\n  <h1 style=\"font-size:22px;margin:0 0 4px 0;\">Media Monitor digest${route && route.name ? ' &middot; ' + escapeHtml(route.name) : ''}</h1>\n  <div style=\"color:#5f6368;font-size:13px;margin-bottom:24px;\">\n    ${dateStr} &middot; ${total} new match${total === 1 ? '' : 'es'}\n    ${topicNames.length ? '&middot; topics: ' + topicNames.map(escapeHtml).join(', ') : ''}\n  </div>\n`;\n\n  if (total === 0) {\n    body += '<p style=\"color:#5f6368;\">No new matches in this run.</p>';\n  } else {\n    for (const topic of topicNames) {\n      const articles2 = byTopic[topic].slice().sort((a, b) => b.relevance - a.relevance);\n      body += `<h2 style=\"font-size:16px;margin:24px 0 8px 0;border-bottom:1px solid #dadce0;padding-bottom:4px;\">${escapeHtml(topic)} <span style=\"color:#5f6368;font-weight:400;font-size:13px;\">(${articles2.length})</span></h2>`;\n      body += '<ul style=\"list-style:none;padding:0;margin:0;\">';\n      for (const a of articles2) {\n        const published = a.publishedAt ? new Date(a.publishedAt).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) : '';\n        const ents = (a.entitiesList || []).map(e => badge(e, '#1a73e8')).join('');\n        body += `\n        <li style=\"margin:0 0 14px 0;padding:10px 12px;border:1px solid #e8eaed;border-radius:8px;\">\n          <div style=\"margin-bottom:4px;\">\n            ${badge(a.relevance + '%', relevanceColor(a.relevance))}\n            ${badge(a.sentiment, sentimentColor(a.sentiment))}\n            <span style=\"color:#5f6368;font-size:12px;\">${escapeHtml(a.source)} &middot; ${escapeHtml(published)}</span>\n          </div>\n          <div style=\"font-size:15px;font-weight:600;margin-bottom:4px;\">\n            <a href=\"${escapeHtml(a.link)}\" style=\"color:#1a73e8;text-decoration:none;\">${escapeHtml(a.title)}</a>\n          </div>\n          <div style=\"color:#3c4043;font-size:13px;line-height:1.4;margin-bottom:6px;\">\n            ${escapeHtml((a.summary || '').slice(0, 280))}${(a.summary || '').length > 280 ? '&hellip;' : ''}\n          </div>\n          ${ents ? '<div>' + ents + '</div>' : ''}\n        </li>`;\n      }\n      body += '</ul>';\n    }\n  }\n\n  body += `\n  <hr style=\"border:none;border-top:1px solid #dadce0;margin:28px 0 8px 0;\">\n  <div style=\"color:#9aa0a6;font-size:11px;\">media-monitor &middot; rule-based &middot; self-hosted n8n</div>\n</div>`;\n\n  return { subject, html: body, total };\n}\n\nconst out = [];\n\nif (digestCfg.to) {\n  const full = renderDigest(all, null);\n  out.push({ json: { to: digestCfg.to, from: digestCfg.from, subject: full.subject, html: full.html, total: full.total } });\n}\n\nfor (const route of (Array.isArray(digestCfg.routes) ? digestCfg.routes : [])) {\n  if (!route || !route.to || !Array.isArray(route.topics) || route.topics.length === 0) continue;\n  const r = renderDigest(all, route);\n  out.push({ json: { to: route.to, from: digestCfg.from, subject: r.subject, html: r.html, total: r.total } });\n}\n\nreturn out;\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "5f403372505f33b33530a994c2f1286c0282",
      "name": "Has Matches?",
      "type": "n8n-nodes-base.if",
      "notes": "Drops digests with zero matches unless digest.sendEmpty is true.",
      "position": [
        1504,
        720
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-matches-or-send-empty",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.total > 0 || $('Config').first().json.digest.sendEmpty === true }}",
              "rightValue": ""
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 2.2
    },
    {
      "id": "7d607f4d04463ae4b7f3dded9d9c10ce4afd",
      "name": "Send Email",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        1744,
        752
      ],
      "parameters": {
        "html": "={{ $json.html }}",
        "options": {},
        "subject": "={{ $json.subject }}",
        "toEmail": "={{ $json.to }}",
        "fromEmail": "={{ $json.from }}",
        "emailFormat": "html"
      },
      "typeVersion": 2
    },
    {
      "id": "f840b6939e72f4f6eae9845ae7eaca74a217",
      "name": "Skip Empty Run",
      "type": "n8n-nodes-base.noOp",
      "position": [
        1744,
        896
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "195887d29e34cd17a20ae6e8e2061305cc31",
  "connections": {
    "Config": {
      "main": [
        [
          {
            "node": "Feed URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS Read": {
      "main": [
        [
          {
            "node": "Process Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Feed URLs": {
      "main": [
        [
          {
            "node": "RSS Read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Digest": {
      "main": [
        [
          {
            "node": "Has Matches?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Matches?": {
      "main": [
        [
          {
            "node": "Send Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Empty Run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Articles": {
      "main": [
        [
          {
            "node": "Build Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}