{
  "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    '&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.30.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 '\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 <admin@paccoco.com>",
        "type": "personal",
        "icon": null,
        "description": null,
        "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
      }
    }
  ],
  "versionMetadata": {
    "name": "Version 7476f4df",
    "description": ""
  }
}
