{ "updatedAt": "2026-05-07T18:46:25.970Z", "createdAt": "2026-05-07T04:01:33.583Z", "id": "40wcTHzFz6GSFlJX", "name": "RSS Digest v1.0", "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 '&': '&',\n '"': '\"',\n ''': \"'\",\n ''': \"'\",\n '<': '<',\n '>': '>'\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(//g, '')\n .replace(//gi, '')\n .replace(//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 = /]+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 = /]*>([\\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 = /]*>([\\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* {\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 sk-REPLACE_WITH_LITELLM_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 '\ud83d\udea8';\n if (priority >= 5) return '\u26a0\ufe0f';\n if (priority <= 2) return '\ud83d\udca4';\n return '\ud83d\udcf0';\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 \u2018Execute workflow\u2019" } ], "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 \u2018Execute workflow\u2019": { "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 ", "type": "personal", "icon": null, "description": null, "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" } } ], "versionMetadata": { "name": "Version 7476f4df", "description": "" } }