588 lines
44 KiB
JSON
588 lines
44 KiB
JSON
{
|
||
"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 '&': '&',\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(/<!\\[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 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 '🚨';\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": ""
|
||
}
|
||
} |