This workflow corresponds to n8n.io template #16296 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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 '&': '&', '<': '<', '>': '>',\n '"': '\"', ''': \"'\", ''': \"'\", ' ': ' '\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, '&').replace(/</g, '<').replace(/>/g, '>')\n .replace(/\"/g, '"').replace(/'/g, ''');\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 ? ' · ' + escapeHtml(route.name) : ''}</h1>\n <div style=\"color:#5f6368;font-size:13px;margin-bottom:24px;\">\n ${dateStr} · ${total} new match${total === 1 ? '' : 'es'}\n ${topicNames.length ? '· 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)} · ${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 ? '…' : ''}\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 · rule-based · 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
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs hourly to read multiple RSS/Atom feeds, applies rule-based topic matching, relevance scoring, sentiment and entity tagging with deduplication, and then sends scored HTML email digests via SMTP (optionally routed per audience) when new matches are found. Runs…
Source: https://n8n.io/workflows/16296/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Perfect for content publishing with organic scheduling patterns, social media automation, API systems that need to avoid rate limiting, or any automation requiring randomised timing control across mul
Complete backup solution that saves both workflows and credentials to local/server disk with optional FTP upload for off-site redundancy.
dev_activity_reporter. Uses dataTable, emailSend. Scheduled trigger; 19 nodes.
This n8n workflow automates the secure transfer of files between FTP servers on a scheduled basis, providing enterprise-grade reliability with comprehensive error handling and dual notification system
This workflow automatically monitors government regulatory changes and provides comprehensive compliance tracking and executive alerts. Scheduled Monitoring - Runs daily at 9 AM to check for new regul