Files
truenas-stacks/n8n-workflows/06-web-scrape-summarize.json
2026-05-16 20:11:29 +00:00

588 lines
44 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"updatedAt": "2026-05-07T18:46:25.970Z",
"createdAt": "2026-05-07T04:01:33.583Z",
"id": "40wcTHzFz6GSFlJX",
"name": "Web Scrape AI Summarize Gotify - Deduped v8 Native Gotify Priority Tuned",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 6
}
]
}
},
"id": "ca37c1d7-3290-407f-a3d7-90f3ce036c05",
"name": "Every 6 Hours",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "scrape/summarize",
"responseMode": "lastNode",
"responseData": "allEntries",
"options": {}
},
"id": "57fe2624-5844-4bd9-bd63-297876ab5372",
"name": "Manual Trigger Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
224
],
"webhookId": "31dca7cf-5733-4501-90e1-1cb31c6331b2"
},
{
"parameters": {
"jsCode": "// Define RSS feeds to monitor.\n// Each feed carries category/tags/basePriority into parsing, dedupe, scoring, AI, and Gotify.\n// basePriority is added to the keyword score before mapping to Gotify priority.\n\nconst feeds = [\n // Existing/core\n {\n name: 'Self-Hosted Show',\n url: 'https://selfhosted.show/rss',\n category: 'podcast',\n tags: ['selfhosted', 'homelab', 'linux'],\n priority: 'normal',\n basePriority: 1\n },\n {\n name: 'r/selfhosted',\n url: 'https://www.reddit.com/r/selfhosted/.rss',\n category: 'reddit',\n tags: ['selfhosted', 'docker', 'apps'],\n priority: 'normal',\n basePriority: 1\n },\n {\n name: 'r/homelab',\n url: 'https://www.reddit.com/r/homelab/.rss',\n category: 'reddit',\n tags: ['homelab', 'networking', 'hardware'],\n priority: 'normal',\n basePriority: 1\n },\n {\n name: 'Hacker News',\n url: 'https://news.ycombinator.com/rss',\n category: 'tech-news',\n tags: ['programming', 'ai', 'security'],\n priority: 'normal',\n basePriority: 1\n },\n\n // Homelab / infrastructure\n {\n name: 'ServeTheHome',\n url: 'https://www.servethehome.com/feed/',\n category: 'hardware',\n tags: ['server', 'storage', 'networking', 'homelab'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Cloudflare Blog',\n url: 'https://blog.cloudflare.com/rss/',\n category: 'infrastructure',\n tags: ['networking', 'security', 'dns', 'cloudflare'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Phoronix',\n url: 'https://www.phoronix.com/rss.php',\n category: 'linux',\n tags: ['linux', 'kernel', 'drivers', 'hardware'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Jeff Geerling',\n url: 'https://www.jeffgeerling.com/blog.xml',\n category: 'homelab',\n tags: ['homelab', 'raspberry-pi', 'ansible', 'linux'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'LinuxServer.io Blog',\n url: 'https://www.linuxserver.io/blog/rss.xml',\n category: 'containers',\n tags: ['docker', 'containers', 'selfhosted'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'TrueNAS Blog',\n url: 'https://www.truenas.com/blog/feed/',\n category: 'storage',\n tags: ['truenas', 'zfs', 'nas', 'storage'],\n priority: 'high',\n basePriority: 4\n },\n {\n name: 'Proxmox News',\n url: 'https://www.proxmox.com/en/feeds/news',\n category: 'virtualization',\n tags: ['proxmox', 'virtualization', 'linux', 'backup'],\n priority: 'high',\n basePriority: 4\n },\n {\n name: 'Tailscale Changelog',\n url: 'https://tailscale.com/changelog/rss.xml',\n category: 'networking',\n tags: ['tailscale', 'vpn', 'networking', 'security'],\n priority: 'normal',\n basePriority: 3\n },\n {\n name: 'K3s Blog',\n url: 'https://k3s.io/blog/index.xml',\n category: 'kubernetes',\n tags: ['k3s', 'kubernetes', 'containers'],\n priority: 'normal',\n basePriority: 2\n },\n\n // Security\n {\n name: 'The Hacker News',\n url: 'https://feeds.feedburner.com/TheHackersNews',\n category: 'security',\n tags: ['security', 'vulnerabilities', 'breaches'],\n priority: 'high',\n basePriority: 5\n },\n {\n name: 'Krebs on Security',\n url: 'https://krebsonsecurity.com/feed/',\n category: 'security',\n tags: ['security', 'breaches', 'fraud'],\n priority: 'high',\n basePriority: 5\n },\n {\n name: 'BleepingComputer',\n url: 'https://www.bleepingcomputer.com/feed/',\n category: 'security',\n tags: ['security', 'windows', 'vulnerabilities', 'ransomware'],\n priority: 'high',\n basePriority: 5\n },\n {\n name: 'CISA Advisories',\n url: 'https://www.cisa.gov/cybersecurity-advisories/all.xml',\n category: 'security',\n tags: ['cisa', 'cve', 'security', 'advisory'],\n priority: 'high',\n basePriority: 6\n },\n {\n name: 'NVD CVE Feed',\n url: 'https://nvd.nist.gov/feeds/xml/cve/misc/nvd-rss.xml',\n category: 'security',\n tags: ['cve', 'vulnerability', 'security'],\n priority: 'high',\n basePriority: 6\n },\n {\n name: 'Dark Reading',\n url: 'https://www.darkreading.com/rss.xml',\n category: 'security',\n tags: ['security', 'enterprise', 'vulnerabilities'],\n priority: 'high',\n basePriority: 5\n },\n {\n name: 'Schneier on Security',\n url: 'https://www.schneier.com/feed/atom/',\n category: 'security-analysis',\n tags: ['security', 'privacy', 'policy'],\n priority: 'normal',\n basePriority: 3\n },\n\n // AI / LLM\n {\n name: 'Simon Willison',\n url: 'https://simonwillison.net/atom/everything/',\n category: 'ai-dev',\n tags: ['ai', 'llm', 'programming', 'python'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Hugging Face Blog',\n url: 'https://huggingface.co/blog/feed.xml',\n category: 'ai',\n tags: ['ai', 'ml', 'models', 'llm'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Latent Space',\n url: 'https://www.latent.space/feed',\n category: 'ai',\n tags: ['ai', 'llm', 'engineering'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'OpenAI News',\n url: 'https://openai.com/news/rss.xml',\n category: 'ai',\n tags: ['openai', 'ai', 'llm'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Anthropic News',\n url: 'https://www.anthropic.com/news/rss.xml',\n category: 'ai',\n tags: ['anthropic', 'ai', 'llm'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Ollama Blog',\n url: 'https://ollama.com/blog/rss.xml',\n category: 'local-ai',\n tags: ['ollama', 'local-ai', 'llm', 'homelab'],\n priority: 'normal',\n basePriority: 3\n },\n\n // Docker / DevOps\n {\n name: 'Docker Blog',\n url: 'https://www.docker.com/blog/feed/',\n category: 'containers',\n tags: ['docker', 'containers', 'devops'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'Kubernetes Blog',\n url: 'https://kubernetes.io/feed.xml',\n category: 'kubernetes',\n tags: ['kubernetes', 'containers', 'devops'],\n priority: 'normal',\n basePriority: 2\n },\n {\n name: 'GitHub Blog',\n url: 'https://github.blog/feed/',\n category: 'devtools',\n tags: ['github', 'programming', 'security', 'devops'],\n priority: 'normal',\n basePriority: 1\n },\n {\n name: 'The New Stack',\n url: 'https://thenewstack.io/feed/',\n category: 'devops',\n tags: ['cloud-native', 'devops', 'kubernetes'],\n priority: 'normal',\n basePriority: 1\n },\n\n // Reddit/community. These are noisy, so base priority is low and keyword scoring decides importance.\n {\n name: 'r/DataHoarder',\n url: 'https://www.reddit.com/r/DataHoarder/.rss',\n category: 'reddit',\n tags: ['storage', 'backup', 'datahoarder'],\n priority: 'normal',\n basePriority: 1\n },\n {\n name: 'r/LocalLLaMA',\n url: 'https://www.reddit.com/r/LocalLLaMA/.rss',\n category: 'reddit',\n tags: ['local-ai', 'llm', 'ollama'],\n priority: 'normal',\n basePriority: 1\n },\n {\n name: 'r/sysadmin',\n url: 'https://www.reddit.com/r/sysadmin/.rss',\n category: 'reddit',\n tags: ['sysadmin', 'security', 'infrastructure'],\n priority: 'normal',\n basePriority: 1\n }\n];\n\nreturn feeds.map(feed => ({\n json: {\n feedName: feed.name,\n name: feed.name,\n url: feed.url,\n category: feed.category,\n tags: feed.tags,\n priority: feed.priority,\n basePriority: feed.basePriority || 0\n }\n}));"
},
"id": "1057fff2-07c6-48ed-97c9-353a36b5daf1",
"name": "Define RSS Feeds",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
272,
128
]
},
{
"parameters": {
"url": "={{ $json.url }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0 n8n-rss-reader/1.0"
},
{
"name": "Accept",
"value": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "text"
}
},
"timeout": 30000
}
},
"id": "4f5f0d61-10c8-42c5-b332-4df59baae50e",
"name": "Fetch RSS Feed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
528,
128
],
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const inputs = $input.all();\nconst sourceItems = $items('Define RSS Feeds');\n\nfunction decodeEntities(value = '') {\n const entities = {\n '&amp;': '&',\n '&quot;': '\"',\n '&#39;': \"'\",\n '&apos;': \"'\",\n '&lt;': '<',\n '&gt;': '>'\n };\n\n return String(value)\n .replace(/&(amp|quot|#39|apos|lt|gt);/g, m => entities[m] || m)\n .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))\n .replace(/&#([0-9]+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)));\n}\n\nfunction cleanText(value = '') {\n return decodeEntities(value)\n .replace(/<!\\[CDATA\\[|\\]\\]>/g, '')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, '')\n .replace(/<style[\\s\\S]*?<\\/style>/gi, '')\n .replace(/<[^>]*>/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction getTag(block, tag) {\n const escaped = tag.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const regex = new RegExp(`<${escaped}[^>]*>([\\\\s\\\\S]*?)<\\\\/${escaped}>`, 'i');\n return cleanText((regex.exec(block) || [])[1] || '');\n}\n\nfunction getRawTag(block, tag) {\n const escaped = tag.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const regex = new RegExp(`<${escaped}[^>]*>([\\\\s\\\\S]*?)<\\\\/${escaped}>`, 'i');\n return (regex.exec(block) || [])[1] || '';\n}\n\nfunction getAtomLink(block) {\n const href = /<link[^>]+href=[\"']([^\"']+)[\"']/i.exec(block);\n return href ? href[1] : getTag(block, 'link');\n}\n\nfunction getFetchError(input) {\n if (!input) return '';\n if (typeof input.error === 'string') return input.error;\n if (input.error?.message) return input.error.message;\n if (input.error?.description) return input.error.description;\n if (input.errorMessage) return input.errorMessage;\n if (input.message && input.name) return `${input.name}: ${input.message}`;\n return '';\n}\n\nfunction parseFeed(xml) {\n const items = [];\n let match;\n\n // RSS format\n const rssRegex = /<item[^>]*>([\\s\\S]*?)<\\/item>/gi;\n while ((match = rssRegex.exec(xml)) !== null && items.length < 2) {\n const block = match[1];\n const descRaw = getRawTag(block, 'description') || getRawTag(block, 'content:encoded') || '';\n\n items.push({\n title: getTag(block, 'title') || 'No title',\n description: cleanText(descRaw).substring(0, 280),\n link: getTag(block, 'link') || getTag(block, 'guid'),\n pubDate: getTag(block, 'pubDate') || getTag(block, 'dc:date')\n });\n }\n\n // Atom format\n if (items.length === 0) {\n const atomRegex = /<entry[^>]*>([\\s\\S]*?)<\\/entry>/gi;\n while ((match = atomRegex.exec(xml)) !== null && items.length < 2) {\n const block = match[1];\n const descRaw = getRawTag(block, 'summary') || getRawTag(block, 'content') || '';\n\n items.push({\n title: getTag(block, 'title') || 'No title',\n description: cleanText(descRaw).substring(0, 280),\n link: getAtomLink(block),\n pubDate: getTag(block, 'updated') || getTag(block, 'published')\n });\n }\n }\n\n return items;\n}\n\nreturn inputs.map((item, index) => {\n const input = item.json || {};\n\n // HTTP Request replaces the JSON item with the response body, so recover feed metadata\n // from the Define RSS Feeds node by item index.\n const source = sourceItems[index]?.json || {};\n\n const feedName = source.feedName || source.name || input.feedName || input.name || `Feed ${index + 1}`;\n const feedUrl = source.url || input.url || '';\n const category = source.category || input.category || '';\n const tags = source.tags || input.tags || [];\n const priority = source.priority || input.priority || 'normal';\n const basePriority = source.basePriority ?? input.basePriority ?? 0;\n\n const fetchError = getFetchError(input);\n if (fetchError) {\n return {\n json: {\n feedName,\n feedUrl,\n category,\n tags,\n priority,\n basePriority,\n itemCount: 0,\n skipped: true,\n fetchError,\n error: fetchError,\n digest: `Skipped ${feedName}: RSS fetch failed. ${fetchError}`,\n items: []\n }\n };\n }\n\n const xml =\n typeof input === 'string'\n ? input\n : input.data || input.body || input.response || input.xml || '';\n\n if (!xml || typeof xml !== 'string' || xml.length < 50) {\n return {\n json: {\n feedName,\n feedUrl,\n category,\n tags,\n priority,\n basePriority,\n itemCount: 0,\n skipped: true,\n error: `No usable XML received. Input keys: ${Object.keys(input).join(', ')}`,\n digest: `Skipped ${feedName}: no usable XML received.`,\n items: []\n }\n };\n }\n\n if (/^\\s*<!doctype html/i.test(xml) || /^\\s*<html/i.test(xml) || /<body\\b/i.test(xml)) {\n return {\n json: {\n feedName,\n feedUrl,\n category,\n tags,\n priority,\n basePriority,\n itemCount: 0,\n skipped: true,\n error: 'Feed returned HTML instead of RSS/Atom XML. This commonly happens when Reddit blocks automated RSS fetches.',\n digest: `Skipped ${feedName}: feed returned HTML instead of RSS/Atom XML.`,\n items: []\n }\n };\n }\n\n const items = parseFeed(xml);\n\n const digest = items.map((feedItem, i) => {\n return [\n `${i + 1}. ${feedItem.title || 'No title'}`,\n feedItem.description ? feedItem.description : '',\n feedItem.pubDate ? `Date: ${feedItem.pubDate}` : '',\n feedItem.link ? `Link: ${feedItem.link}` : ''\n ].filter(Boolean).join('\\n');\n }).join('\\n\\n');\n\n return {\n json: {\n feedName,\n feedUrl,\n category,\n tags,\n priority,\n basePriority,\n itemCount: items.length,\n skipped: items.length === 0,\n error: items.length === 0 ? `No feed items parsed. XML starts with: ${xml.substring(0, 300)}` : '',\n digest: digest || `No feed items found for ${feedName}.`,\n items\n }\n };\n});"
},
"id": "63c99767-847b-447c-a361-766cd66b390d",
"name": "Parse RSS",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
784,
128
]
},
{
"parameters": {
"jsCode": "// Flatten feed items and create stable item hashes for deduplication.\n// Uses a local FNV-style hash so we do not need crypto/require in the n8n sandbox.\nfunction stableHash(input) {\n let h1 = 0x811c9dc5;\n let h2 = 0x01000193;\n\n for (let i = 0; i < input.length; i++) {\n const c = input.charCodeAt(i);\n h1 ^= c;\n h1 = Math.imul(h1, 0x01000193);\n h2 ^= c;\n h2 = Math.imul(h2, 0x811c9dc5);\n }\n\n const a = (h1 >>> 0).toString(16).padStart(8, '0');\n const b = (h2 >>> 0).toString(16).padStart(8, '0');\n return `${a}${b}`;\n}\n\nconst output = [];\n\nfor (const item of $input.all()) {\n const feed = item.json;\n\n // Do not send failed/empty feeds into Postgres. They can be reported separately later,\n // but they should not create Unknown Feed rows or null hashes.\n if (feed.fetchError || feed.skipped || !Array.isArray(feed.items) || feed.items.length === 0) {\n continue;\n }\n\n for (const rssItem of feed.items) {\n const rawKey = [\n feed.feedName || '',\n rssItem.title || '',\n rssItem.link || '',\n rssItem.pubDate || ''\n ].join('|');\n\n output.push({\n json: {\n feedName: feed.feedName || 'Unknown Feed',\n category: feed.category || '',\n tags: feed.tags || [],\n priority: feed.priority || 'normal',\n basePriority: feed.basePriority || 0,\n title: rssItem.title || '',\n description: rssItem.description || '',\n link: rssItem.link || '',\n pubDate: rssItem.pubDate || '',\n itemHash: stableHash(rawKey)\n }\n });\n }\n}\n\nreturn output;"
},
"id": "0de4d305-b8c3-4d7b-a5fc-09202a7c5155",
"name": "Prepare Deduplication",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1040,
128
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO rss_seen_items (\n item_hash,\n feed_name,\n title,\n link,\n pub_date\n)\nVALUES (\n '{{ $json.itemHash }}',\n '{{ ($json.feedName || '').replace(/'/g, \"''\") }}',\n '{{ ($json.title || '').replace(/'/g, \"''\") }}',\n '{{ ($json.link || '').replace(/'/g, \"''\") }}',\n '{{ ($json.pubDate || '').replace(/'/g, \"''\") }}'\n)\nON CONFLICT (item_hash) DO NOTHING\nRETURNING\n item_hash AS \"itemHash\",\n feed_name AS \"feedName\",\n title,\n link,\n pub_date AS \"pubDate\",\n '{{ ($json.description || '').replace(/'/g, \"''\") }}' AS description,\n '{{ ($json.category || '').replace(/'/g, \"''\") }}' AS category,\n '{{ JSON.stringify($json.tags || []).replace(/'/g, \"''\") }}' AS tags_json,\n '{{ ($json.priority || \"normal\").replace(/'/g, \"''\") }}' AS priority,\n {{ Number($json.basePriority || 0) }} AS \"basePriority\";",
"options": {}
},
"id": "887e275c-3b16-44db-aed7-339e76aa2e0d",
"name": "Insert New Seen Items",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1312,
128
],
"credentials": {
"postgres": {
"id": "n9svoXemqSZoNNUB",
"name": "Postgres account"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "// Group rows returned by INSERT ... RETURNING, then filter/score BEFORE AI.\n// This keeps the LLM workload small and prevents Gotify spam.\n// v8 scoring split:\n// - relevanceScore decides whether an item is worth summarizing.\n// - urgencyScore decides Gotify priority.\n// Normal interest terms like docker/container/ai should not create emergency notifications.\n\nconst grouped = {};\n\nfunction normalizeTags(value) {\n if (Array.isArray(value)) return value;\n try { return JSON.parse(value || '[]'); } catch (e) { return []; }\n}\n\nfunction escapeRegExp(s) {\n return String(s).replace(/[.*+?^${}()|[\\\\\\]\\\\]/g, '\\\\$&');\n}\n\nfunction hasTerm(text, term) {\n const t = String(term).toLowerCase();\n const pattern = t.includes(' ')\n ? `(^|[^a-z0-9])${escapeRegExp(t)}([^a-z0-9]|$)`\n : `\\\\b${escapeRegExp(t)}\\\\b`;\n return new RegExp(pattern, 'i').test(text);\n}\n\nconst scoringRules = [\n // urgent security: these should raise Gotify priority\n { term: 'cve', relevance: 10, urgency: 8 },\n { term: 'zero-day', relevance: 12, urgency: 12 },\n { term: '0day', relevance: 12, urgency: 12 },\n { term: 'actively exploited', relevance: 12, urgency: 12 },\n { term: 'authentication bypass', relevance: 10, urgency: 9 },\n { term: 'remote code execution', relevance: 10, urgency: 9 },\n { term: 'rce', relevance: 10, urgency: 9 },\n { term: 'critical vulnerability', relevance: 10, urgency: 9 },\n { term: 'exploit', relevance: 8, urgency: 7 },\n { term: 'privilege escalation', relevance: 8, urgency: 7 },\n { term: 'ransomware', relevance: 9, urgency: 8 },\n { term: 'data breach', relevance: 8, urgency: 7 },\n { term: 'breach', relevance: 6, urgency: 5 },\n { term: 'vulnerability', relevance: 6, urgency: 4 },\n { term: 'security advisory', relevance: 7, urgency: 5 },\n\n // urgent infrastructure/data-safety terms\n { term: 'data loss', relevance: 8, urgency: 8 },\n { term: 'corruption', relevance: 7, urgency: 7 },\n { term: 'outage', relevance: 6, urgency: 6 },\n { term: 'backup failure', relevance: 7, urgency: 6 },\n { term: 'restore failure', relevance: 7, urgency: 6 },\n\n // relevant homelab infrastructure, but not automatically urgent\n { term: 'zfs', relevance: 6, urgency: 1 },\n { term: 'truenas', relevance: 6, urgency: 1 },\n { term: 'backup', relevance: 5, urgency: 1 },\n { term: 'restore', relevance: 5, urgency: 1 },\n { term: 'proxmox', relevance: 5, urgency: 1 },\n { term: 'postgres', relevance: 5, urgency: 1 },\n { term: 'postgresql', relevance: 5, urgency: 1 },\n { term: 'tailscale', relevance: 4, urgency: 0 },\n { term: 'cloudflare', relevance: 3, urgency: 0 },\n { term: 'kubernetes', relevance: 4, urgency: 0 },\n { term: 'k3s', relevance: 4, urgency: 0 },\n { term: 'linux kernel', relevance: 4, urgency: 1 },\n { term: 'nfs', relevance: 4, urgency: 1 },\n { term: 'vlan', relevance: 3, urgency: 0 },\n { term: 'firewall', relevance: 4, urgency: 1 },\n { term: 'vpn', relevance: 3, urgency: 0 },\n\n // Docker/dev/AI interest terms. These make an item relevant, not urgent.\n { term: 'docker', relevance: 3, urgency: 0 },\n { term: 'container', relevance: 2, urgency: 0 },\n { term: 'containers', relevance: 2, urgency: 0 },\n { term: 'ollama', relevance: 4, urgency: 0 },\n { term: 'local llm', relevance: 4, urgency: 0 },\n { term: 'llm', relevance: 3, urgency: 0 },\n { term: 'openwebui', relevance: 4, urgency: 0 },\n { term: 'machine learning', relevance: 2, urgency: 0 },\n { term: 'python', relevance: 2, urgency: 0 },\n { term: 'github', relevance: 2, urgency: 0 },\n { term: 'authentication', relevance: 4, urgency: 1 },\n { term: 'oauth', relevance: 4, urgency: 1 },\n { term: 'ai', relevance: 1, urgency: 0 }\n];\n\nconst categoryBonus = {\n security: { relevance: 4, urgency: 2 },\n 'security-analysis': { relevance: 3, urgency: 1 },\n storage: { relevance: 2, urgency: 0 },\n virtualization: { relevance: 2, urgency: 0 },\n networking: { relevance: 2, urgency: 0 },\n containers: { relevance: 2, urgency: 0 },\n kubernetes: { relevance: 2, urgency: 0 },\n 'local-ai': { relevance: 2, urgency: 0 },\n ai: { relevance: 1, urgency: 0 },\n 'ai-dev': { relevance: 1, urgency: 0 },\n homelab: { relevance: 2, urgency: 0 },\n infrastructure: { relevance: 2, urgency: 0 },\n hardware: { relevance: 1, urgency: 0 },\n reddit: { relevance: 0, urgency: 0 }\n};\n\nfunction scoreItem(feed, rssItem) {\n const text = `${rssItem.title || ''}\\n${rssItem.description || ''}\\n${rssItem.link || ''}`.toLowerCase();\n const bonus = categoryBonus[feed.category] || { relevance: 0, urgency: 0 };\n\n let relevanceScore = Number(feed.basePriority || 0) + Number(bonus.relevance || 0);\n let urgencyScore = Number(bonus.urgency || 0);\n\n const matches = [];\n const urgentMatches = [];\n const weightedMatches = [];\n\n for (const rule of scoringRules) {\n if (hasTerm(text, rule.term)) {\n relevanceScore += Number(rule.relevance || 0);\n urgencyScore += Number(rule.urgency || 0);\n matches.push(rule.term);\n weightedMatches.push({ term: rule.term, relevance: rule.relevance || 0, urgency: rule.urgency || 0 });\n if (Number(rule.urgency || 0) >= 4) urgentMatches.push(rule.term);\n }\n }\n\n // High-priority/security feeds are inherently worth summarizing,\n // but they only become urgent when the actual item content has urgent terms.\n if (feed.priority === 'high' || feed.category === 'security') {\n relevanceScore += 3;\n matches.push('feed-priority-high');\n weightedMatches.push({ term: 'feed-priority-high', relevance: 3, urgency: 0 });\n }\n\n return { relevanceScore, urgencyScore, matches, urgentMatches, weightedMatches };\n}\n\nfor (const item of $input.all()) {\n const data = item.json || {};\n\n if (data.error) continue;\n if (!data.itemHash && !data.feedName && !data.feed_name && !data.title && !data.link) continue;\n\n const feedName = data.feedName || data.feed_name;\n if (!feedName) continue;\n\n if (!grouped[feedName]) {\n grouped[feedName] = {\n feedName,\n category: data.category || '',\n tags: normalizeTags(data.tags_json || data.tags),\n priority: data.priority || 'normal',\n basePriority: Number(data.basePriority || data.base_priority || 0),\n items: []\n };\n }\n\n grouped[feedName].items.push({\n title: data.title || '',\n description: data.description || '',\n link: data.link || '',\n pubDate: data.pubDate || data.pub_date || ''\n });\n}\n\nlet feeds = Object.values(grouped);\n\n// Filter and cap per feed before AI.\nfeeds = feeds.map(feed => {\n const scoredItems = feed.items.map(rssItem => {\n const scored = scoreItem(feed, rssItem);\n return {\n ...rssItem,\n itemScore: scored.relevanceScore,\n itemUrgencyScore: scored.urgencyScore,\n itemMatches: scored.matches,\n itemUrgentMatches: scored.urgentMatches,\n weightedMatches: scored.weightedMatches\n };\n });\n\n const alwaysKeep = feed.priority === 'high' || feed.category === 'security' || feed.category === 'security-analysis';\n const kept = scoredItems\n .filter(x => alwaysKeep || x.itemScore >= 3)\n .sort((a, b) => (b.itemUrgencyScore - a.itemUrgencyScore) || (b.itemScore - a.itemScore))\n .slice(0, 2);\n\n const relevanceScore = kept.reduce((sum, x) => sum + Number(x.itemScore || 0), 0);\n const urgencyScore = kept.reduce((sum, x) => sum + Number(x.itemUrgencyScore || 0), 0);\n const relevanceMatches = [...new Set(kept.flatMap(x => x.itemMatches || []))];\n const urgentMatches = [...new Set(kept.flatMap(x => x.itemUrgentMatches || []))];\n const weightedMatches = kept.flatMap(x => x.weightedMatches || []);\n\n return {\n ...feed,\n items: kept,\n itemCount: kept.length,\n relevanceScore,\n urgencyScore,\n relevanceMatches,\n highPriorityMatches: urgentMatches,\n weightedMatches\n };\n}).filter(feed => feed.items.length > 0);\n\nif (feeds.length === 0) {\n return [{\n json: {\n noNewItems: true,\n sent: false,\n message: 'No new relevant RSS items found. Deduplication and keyword filtering removed everything.',\n itemCount: 0,\n relevanceScore: 0,\n urgencyScore: 0,\n gotifyPriority: 0,\n highPriorityMatches: [],\n relevanceMatches: []\n }\n }];\n}\n\nreturn feeds.map(feed => {\n const digest = feed.items.map((rssItem, i) => {\n return `${i + 1}. ${rssItem.title || 'No title'}\nRelevance score: ${rssItem.itemScore || 0}\nUrgency score: ${rssItem.itemUrgencyScore || 0}\nMatches: ${(rssItem.itemMatches || []).join(', ') || 'none'}\nUrgent matches: ${(rssItem.itemUrgentMatches || []).join(', ') || 'none'}\n${rssItem.description || ''}\n${rssItem.link || ''}\n${rssItem.pubDate || ''}`;\n }).join('\\n\\n');\n\n return {\n json: {\n ...feed,\n noNewItems: false,\n digest\n }\n };\n});\n"
},
"id": "091f4634-f130-4b91-8cb5-dd0d1ee85626",
"name": "Group New Items by Feed",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
128
]
},
{
"parameters": {
"jsCode": "const output = [];\n\nfor (const item of $input.all()) {\n const data = item.json;\n\n const body = {\n model: 'medium',\n messages: [\n {\n role: 'system',\n content: \"You are a news digest assistant for a homelab enthusiast and cybersecurity student.\\n\\nSummarize only the provided NEW and pre-filtered RSS items into a concise plain-text Gotify message.\\n\\nFormatting rules:\\n- Plain text only.\\n- Do not use Markdown.\\n- Do not use # headings.\\n- Do not use bold markers like **text**.\\n- Do not use markdown links like [title](url).\\n- Do not use tables.\\n- Use an uppercase title line.\\n- Use numbered entries like 1), 2).\\n- Put the item title on the numbered line.\\n- Put 1 short summary line under each title.\\n- Put raw URLs on their own line.\\n- Keep it easy to read on a phone notification screen.\\n- Keep the whole digest under 150 words.\\n\\nPrioritize self-hosting, Docker, AI/LLM, Linux, networking, cybersecurity, programming, TrueNAS, ZFS, storage, backups, virtualization, and homelab operations.\"\n },\n {\n role: 'user',\n content: `Feed: ${data.feedName}\nCategory: ${data.category || 'uncategorized'}\nTags: ${(data.tags || []).join(', ')}\nFeed relevance score: ${data.relevanceScore || 0}\nNew relevant item count: ${data.itemCount}\nMatched keywords: ${(data.highPriorityMatches || []).join(', ') || 'none'}\n\nLatest NEW relevant items:\n${data.digest}`\n }\n ],\n max_tokens: 280,\n temperature: 0.2\n };\n\n output.push({ json: { ...data, aiBody: body } });\n}\n\nreturn output;"
},
"id": "38bcb515-60bd-48f8-a993-4f371a336598",
"name": "Prepare AI Request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1904,
128
]
},
{
"parameters": {
"method": "POST",
"url": "http://10.5.1.6:4000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.aiBody) }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 120000
}
},
"id": "3395fab9-604a-45fb-88f5-8de90b782940",
"name": "AI Summarize Feed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2080,
128
],
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const responses = $input.all();\nconst sourceItems = $items('Prepare AI Request');\n\nfunction stringifyError(value) {\n if (!value) return '';\n if (typeof value === 'string') return value;\n try { return JSON.stringify(value, null, 2); } catch (e) { return String(value); }\n}\n\nfunction plainTextForGotify(text) {\n if (!text) return '';\n\n return String(text)\n // Markdown headings -> plain uppercase-ish text without #\n .replace(/^\\s{0,3}#{1,6}\\s*/gm, '')\n // Bold/italic/code markers\n .replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n .replace(/__([^_]+)__/g, '$1')\n .replace(/`([^`]+)`/g, '$1')\n // Markdown links: [label](url) -> label\\nurl\n .replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '$1\\n$2')\n // Markdown bullets -> simple indented dash for readability\n .replace(/^\\s*[-*+]\\s+/gm, ' - ')\n // Clean excessive blank lines\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nreturn responses.map((item, index) => {\n const response = item.json || {};\n const source = sourceItems[index]?.json || {};\n\n let summary = '';\n let error = '';\n\n if (response.error || response.errorMessage) {\n error = response.errorMessage || stringifyError(response.error) || stringifyError(response);\n summary = `AI summary failed for ${source.feedName || `Feed ${index + 1}`}: ${error}`;\n } else if (response.choices?.[0]?.message?.content) {\n summary = response.choices[0].message.content;\n } else if (response.message || response.detail) {\n error = stringifyError(response.message || response.detail || response);\n summary = `AI summary failed for ${source.feedName || `Feed ${index + 1}`}: ${error}`;\n } else {\n error = stringifyError(response);\n summary = `AI summary unavailable for ${source.feedName || `Feed ${index + 1}`}. Raw response: ${error}`;\n }\n\n summary = plainTextForGotify(summary);\n\n if (summary.length > 3500) {\n summary = summary.substring(0, 3500) + '\\n\\n[truncated]';\n }\n\n return {\n json: {\n ...source,\n summary,\n error\n }\n };\n});"
},
"id": "53ca1624-4fee-4025-883c-d38be407978e",
"name": "Format AI Summary",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2352,
128
]
},
{
"parameters": {
"jsCode": "const output = [];\n\nfunction unique(arr) {\n return [...new Set((arr || []).filter(Boolean))];\n}\n\nfunction scoreToPriority(urgencyScore, relevanceScore) {\n const urgency = Number(urgencyScore || 0);\n const relevance = Number(relevanceScore || 0);\n\n // Only true urgent/security/data-safety matches should become loud alerts.\n if (urgency >= 18) return 8;\n if (urgency >= 12) return 7;\n if (urgency >= 7) return 5;\n\n // Interesting but non-urgent news stays normal/quiet.\n if (relevance >= 3) return 3;\n return 2;\n}\n\nfunction prefixForPriority(priority) {\n if (priority >= 7) return '🚨';\n if (priority >= 5) return '⚠️';\n if (priority <= 2) return '💤';\n return '📰';\n}\n\nfor (const item of $input.all()) {\n const data = item.json;\n\n const relevanceScore = Number(data.relevanceScore || 0);\n const urgencyScore = Number(data.urgencyScore || 0);\n const gotifyPriority = scoreToPriority(urgencyScore, relevanceScore);\n const titlePrefix = prefixForPriority(gotifyPriority);\n const urgentMatches = unique(data.highPriorityMatches || []);\n const relevanceMatches = unique(data.relevanceMatches || []);\n\n let message = data.summary || 'No summary returned.';\n\n if (gotifyPriority >= 5) {\n message = `${message}\\n\\nUrgency score: ${urgencyScore}\\nRelevance score: ${relevanceScore}\\nUrgent matched terms: ${urgentMatches.join(', ') || 'none'}`;\n }\n\n if (message.length > 3000) {\n message = message.substring(0, 3000) + '\\n\\n[truncated]';\n }\n\n output.push({\n json: {\n ...data,\n relevanceScore,\n urgencyScore,\n gotifyPriority,\n highPriorityMatches: urgentMatches,\n relevanceMatches,\n gotifyBody: {\n title: `${titlePrefix} ${data.feedName} Digest`,\n message,\n priority: gotifyPriority,\n extras: {\n 'client::display': {\n contentType: 'text/plain'\n }\n }\n }\n }\n });\n}\n\nreturn output;\n"
},
"id": "4513e852-d740-41ab-b4ce-6547cdbeead9",
"name": "Prepare Gotify Push",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2608,
128
]
},
{
"parameters": {
"message": "={{ $json.gotifyBody.message }}",
"additionalFields": {
"priority": "={{ $json.gotifyBody.priority }}",
"title": "={{ $json.gotifyBody.title }}"
},
"options": {
"contentType": "text/plain"
}
},
"id": "4946e30e-9332-4ee2-915c-cd641bdefb0f",
"name": "Create Gotify Message",
"type": "n8n-nodes-base.gotify",
"typeVersion": 1,
"position": [
2864,
128
],
"credentials": {
"gotifyApi": {
"id": "ExUx3oqYQyhkqZcp",
"name": "Rss Feed"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const responses = $input.all();\nconst sourceItems = $items('Prepare Gotify Push');\n\nconst output = responses.map((item, index) => {\n const response = item.json || {};\n const source = sourceItems[index]?.json || {};\n const error = response.errorMessage || response.error || source.error || '';\n\n return {\n json: {\n feedName: source.feedName || `Feed ${index + 1}`,\n itemCount: source.itemCount || 0,\n sent: !error,\n gotifyResponse: error ? null : response,\n gotifyPriority: source.gotifyPriority,\n relevanceScore: source.relevanceScore || 0,\n urgencyScore: source.urgencyScore || 0,\n highPriorityMatches: source.highPriorityMatches || [],\n relevanceMatches: source.relevanceMatches || [],\n error: typeof error === 'string' ? error : JSON.stringify(error),\n summary: source.summary || ''\n }\n };\n});\n\nif (output.length === 0) {\n return [{\n json: {\n sent: false,\n message: 'No new RSS items found. Deduplication filtered everything out.'\n }\n }];\n}\n\nreturn output;\n"
},
"id": "c1da7901-98c0-459f-84ee-98ed30675bc5",
"name": "Final Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3120,
128
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "ba2925fb-2bad-4971-a5ac-d610b91a9d40",
"leftValue": "={{ $json.noNewItems }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "false",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "3fe3c568-d143-425e-9e8d-d463c38a8f41",
"name": "Has New Items?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1728,
144
]
},
{
"parameters": {
"jsCode": "return [{\n json: {\n sent: false,\n itemCount: 0,\n message: $json.message || 'No new RSS items found. Deduplication filtered everything out.',\n highPriorityMatches: [],\n error: ''\n }\n}];"
},
"id": "0b8f121c-4339-49a9-bbc9-622fafe60403",
"name": "No New Items Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1888,
336
]
},
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
144,
496
],
"id": "9cfd7a45-912a-4895-8a04-23411a893a09",
"name": "When clicking Execute workflow"
}
],
"connections": {
"Every 6 Hours": {
"main": [
[
{
"node": "Define RSS Feeds",
"type": "main",
"index": 0
}
]
]
},
"Manual Trigger Webhook": {
"main": [
[
{
"node": "Define RSS Feeds",
"type": "main",
"index": 0
}
]
]
},
"Define RSS Feeds": {
"main": [
[
{
"node": "Fetch RSS Feed",
"type": "main",
"index": 0
}
]
]
},
"Fetch RSS Feed": {
"main": [
[
{
"node": "Parse RSS",
"type": "main",
"index": 0
}
]
]
},
"Parse RSS": {
"main": [
[
{
"node": "Prepare Deduplication",
"type": "main",
"index": 0
}
]
]
},
"Prepare Deduplication": {
"main": [
[
{
"node": "Insert New Seen Items",
"type": "main",
"index": 0
}
]
]
},
"Insert New Seen Items": {
"main": [
[
{
"node": "Group New Items by Feed",
"type": "main",
"index": 0
}
]
]
},
"Group New Items by Feed": {
"main": [
[
{
"node": "Has New Items?",
"type": "main",
"index": 0
}
]
]
},
"Prepare AI Request": {
"main": [
[
{
"node": "AI Summarize Feed",
"type": "main",
"index": 0
}
]
]
},
"AI Summarize Feed": {
"main": [
[
{
"node": "Format AI Summary",
"type": "main",
"index": 0
}
]
]
},
"Format AI Summary": {
"main": [
[
{
"node": "Prepare Gotify Push",
"type": "main",
"index": 0
}
]
]
},
"Prepare Gotify Push": {
"main": [
[
{
"node": "Create Gotify Message",
"type": "main",
"index": 0
}
]
]
},
"Has New Items?": {
"main": [
[
{
"node": "Prepare AI Request",
"type": "main",
"index": 0
}
],
[
{
"node": "No New Items Response",
"type": "main",
"index": 0
}
]
]
},
"Create Gotify Message": {
"main": [
[
{
"node": "Final Response",
"type": "main",
"index": 0
}
]
]
},
"When clicking Execute workflow": {
"main": [
[
{
"node": "Define RSS Feeds",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": {
"node:Every 6 Hours": {
"recurrenceRules": [
12
]
}
},
"meta": {
"templateCredsSetupCompleted": true
},
"pinData": {},
"versionId": "7476f4df-daf7-4b00-8f20-8397a10a7bcf",
"activeVersionId": "7476f4df-daf7-4b00-8f20-8397a10a7bcf",
"versionCounter": 30,
"triggerCount": 2,
"tags": [
{
"updatedAt": "2026-05-07T02:27:25.820Z",
"createdAt": "2026-05-07T02:27:25.820Z",
"id": "Vx2eghtcMKzEcaTy",
"name": "dedupe"
},
{
"updatedAt": "2026-05-07T00:49:03.692Z",
"createdAt": "2026-05-07T00:49:03.692Z",
"id": "Uj6bEYJrGolubkKM",
"name": "rss"
},
{
"updatedAt": "2026-05-06T22:38:51.678Z",
"createdAt": "2026-05-06T22:38:51.678Z",
"id": "S9FxzVfHLsHmyzyt",
"name": "ai"
},
{
"updatedAt": "2026-05-07T04:01:31.904Z",
"createdAt": "2026-05-07T04:01:31.904Z",
"id": "RnvhD9aHKN7v7iOi",
"name": "priority-tuned"
},
{
"updatedAt": "2026-05-07T02:27:25.824Z",
"createdAt": "2026-05-07T02:27:25.824Z",
"id": "MfaTegnw8p0SsGGm",
"name": "postgres"
},
{
"updatedAt": "2026-05-07T00:49:03.703Z",
"createdAt": "2026-05-07T00:49:03.703Z",
"id": "3XoednfZ00DRBn07",
"name": "news"
}
],
"shared": [
{
"updatedAt": "2026-05-07T04:01:33.583Z",
"createdAt": "2026-05-07T04:01:33.583Z",
"role": "workflow:owner",
"workflowId": "40wcTHzFz6GSFlJX",
"projectId": "dxCRnBdX5uJizCGa",
"project": {
"updatedAt": "2026-05-06T01:10:37.484Z",
"createdAt": "2026-05-06T01:08:07.721Z",
"id": "dxCRnBdX5uJizCGa",
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
}
}
],
"versionMetadata": {
"name": "Version 7476f4df",
"description": ""
}
}