Praveen Deshpande

Just a guy trying to make sense of things as he goes.

Taming Messy RSS Feeds with n8n and a Bit of Vibecoding

Every morning I would wake up and swipe left on my phone - Google Discover. Then open browser and there is another news feed right there before I have even had tea. On top of that I had other News apps doing the same thing.

The content was interesting. That was the problem. I would start reading something about a CVE or a breach and ten minutes later I am down a completely different rabbit hole. Pure dopamine and because the feed reloads every time you close the browser I had this habit of tapping links and leaving them open as tabs just so I would not lose the article. At peak it was somewhere around fifteen open tabs everyday.

I was starting every morning overloaded before I had done anything useful. I wanted out of that loop.

What I Built

I set up an n8n workflow that polls a bunch of RSS sources I actually trust, filters out anything older than 15 minutes and pushes fresh articles to a Telegram bot (I created bot using @BotFather). Runs every 15 minutes on its own. I don't touch anything.

The Telegram part is what makes this work for me. Messages don't disappear. Every article that comes in sits there until I choose to read it. No algorithm deciding what to surface and when. It acts like an automatic bookmark. I don't need to keep browser tabs open as reminders anymore.

Mornings are noticeably calmer now.

How It Works

The cron triggers every 15 minutes. Each RSS source is processed one at a time through a batch loop. Before anything gets sent the workflow checks two things — is this article within the last 15 minutes and has it already been sent before. If it passes both it gets formatted and pushed to Telegram Bot. After a successful send the article is marked as seen so it never shows up again (still WIP..).

All the settings sit in a single config node at the very start of the workflow. Lookback window, Telegram chat ID and max stored article IDs. One place to change anything, nothing scattered around.

Bird's eye view of the completed n8n canvas

Each message in Telegram looks like this:

šŸ“° Article Title
šŸ”— https://link-to-article
šŸ“… Publication date
āœļø Author / Source

Short preview of the content...

Screenshot of article in Telegram

The Workflow File

I am attaching the n8n JSON export below. Here is exactly what you need to do before and after importing it.

Before importing — edit the JSON file:

After importing into n8n:

Want a full step by step guide on setting this up from scratch including creating the Telegram bot and configuring n8n? Let me know here. If there is enough interest I will do a proper follow up post.

One Limitation

The whole thing runs locally in a Docker container on my laptop right now. The laptop needs to stay on for the cron to fire. Not ideal long term. I touched on this in my birthday post as well. Will sort out a proper hosting setup eventually.

n8n Workflow file (JSON)

{
  "nodes": [
    {
      "parameters": {},
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -112,
        2760
      ],
      "typeVersion": 1,
      "id": "2f2ce6d6-c3fa-4ad2-b865-c6d5ae0e1129"
    },
    {
      "parameters": {
        "jsCode": "// āš™ļø CHANGE SETTINGS HERE ONLY\nreturn [{\n  json: {\n    LOOKBACK_MINUTES: 15,\n    MAX_STORED_IDS: 500,\n    CHAT_ID: 'YOUR_TELEGRAM_CHAT_ID'\n  }\n}];"
      },
      "name": "āš™ļø Config",
      "type": "n8n-nodes-base.code",
      "position": [
        112,
        2688
      ],
      "typeVersion": 2,
      "id": "de1931f4-8fc8-4b09-854f-3b9f937b2077"
    },
    {
      "parameters": {
        "jsCode": "// Add or remove RSS feed URLs here\nreturn [\n  { json: { url: 'https://www.bleepingcomputer.com/feed/' } },\n  { json: { url: 'https://www.wiz.io/feed/rss.xml' } },\n  { json: { url: 'https://thehackernews.com/feeds/posts/default' } },\n  { json: { url: 'https://krebsonsecurity.com/feed/' } },\n  { json: { url: 'https://aws.amazon.com/blogs/security/feed/' } },\n  { json: { url: 'https://cloudsecurityalliance.org/blog/feed/' } },\n  { json: { url: 'https://techcrunch.com/feed/' } },\n  { json: { url: 'https://www.techrepublic.com/rssfeeds/topic/cloud/' } },\n  { json: { url: 'https://www.techrepublic.com/rssfeeds/topic/cloud-security/' } },\n  { json: { url: 'https://www.techrepublic.com/rssfeeds/topic/cybersecurity/' } },\n  { json: { url: 'https://www.techrepublic.com/rssfeeds/topic/devops/' } },\n  { json: { url: 'https://www.techrepublic.com/rssfeeds/topic/security/' } },\n  { json: { url: 'https://www.techrepublic.com/rssfeeds/topic/ai/' } },\n  { json: { url: 'https://www.techrepublic.com/rssfeeds/topic/ai-governance-policy/' } },\n  { json: { url: 'https://feeds.feedburner.com/pudkrebsonsec' } },\n  { json: { url: 'https://www.nytimes.com/svc/collections/v1/publish/https://www.nytimes.com/spotlight/cybersecurity/rss.xml' } }\n];"
      },
      "name": "RSS Sources1",
      "type": "n8n-nodes-base.code",
      "position": [
        336,
        2688
      ],
      "typeVersion": 2,
      "id": "0ccd94e1-c35c-46a4-b3c4-b8412e0021e0"
    },
    {
      "parameters": {
        "options": {}
      },
      "name": "Split in Batches",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        560,
        2688
      ],
      "typeVersion": 3,
      "id": "a5cea360-b81d-40cf-890f-a1e96a2abe8a"
    },
    {
      "parameters": {
        "url": "={{ $json[\"url\"] }}",
        "options": {}
      },
      "name": "RSS Feed Read1",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        784,
        2544
      ],
      "typeVersion": 1,
      "id": "667e8401-9e0e-4a68-ae0c-039699b75b43"
    },
    {
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst oldRSSIds = staticData.oldRSSIds || [];\n\nconst LOOKBACK_MINUTES = $('āš™ļø Config').first().json.LOOKBACK_MINUTES || 60;\nconst NOW = new Date();\nconst CUTOFF = new Date(NOW.getTime() - LOOKBACK_MINUTES * 60 * 1000);\n\nconst actualNewRSS = items.filter((item) => {\n  const itemId   = item.json.link || item.json.isoDate || item.json.pubDate;\n  const itemDate = new Date(item.json.isoDate || item.json.pubDate || 0);\n  const hasTitle = !!(item.json.title || '').trim();\n  const hasLink  = !!item.json.link;\n  return !!itemId && hasTitle && hasLink && !oldRSSIds.includes(itemId) && itemDate >= CUTOFF;\n});\n\nreturn actualNewRSS.length > 0\n  ? actualNewRSS.map(item => ({\n      json: { ...item.json, _rssId: item.json.link || item.json.isoDate || item.json.pubDate }\n    }))\n  : [{ json: { skipped: true, _rssId: null } }];"
      },
      "name": "Only get new RSS1",
      "type": "n8n-nodes-base.code",
      "position": [
        1008,
        2544
      ],
      "typeVersion": 2,
      "id": "38233468-6e6a-4c85-8024-e1207c4804ca"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ !$json.skipped }}",
              "value2": true
            }
          ]
        }
      },
      "name": "Has New Items?",
      "type": "n8n-nodes-base.if",
      "position": [
        1232,
        2544
      ],
      "typeVersion": 1,
      "id": "has-new-items-remote-001"
    },
    {
      "parameters": {
        "jsCode": "function stripHTML(text) {\n  if (!text) return '';\n  return text.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, ' ').trim();\n}\nfunction truncate(text, max) {\n  if (!text) return '';\n  return text.length > max ? text.substring(0, max) + '...' : text;\n}\nreturn items.map(item => {\n  const title      = stripHTML(item.json.title || 'No Title');\n  const link       = item.json.link || '';\n  const pubDate    = item.json.pubDate || item.json.isoDate || '';\n  const source     = stripHTML(item.json.creator || item.json['dc:creator'] || item.json.feedTitle || 'Unknown');\n  const rawContent = item.json.contentSnippet || item.json.content || item.json.summary || '';\n  const content    = truncate(stripHTML(rawContent), 200);\n  return {\n    json: {\n      ...item.json,\n      telegramMessage: `šŸ“° <b>${title}</b>\\nšŸ”— ${link}\\nšŸ“… ${pubDate}\\nāœļø ${source}\\n\\n${content ? content + '\\n\\n' : ''}`\n    }\n  };\n});"
      },
      "name": "Format Message",
      "type": "n8n-nodes-base.code",
      "position": [
        1456,
        2544
      ],
      "typeVersion": 2,
      "id": "00b76384-8ae0-499a-8430-5e970b570439"
    },
    {
      "parameters": {
        "chatId": "={{ $('āš™ļø Config').first().json.CHAT_ID }}",
        "text": "={{ $json.telegramMessage }}",
        "additionalFields": {
          "parse_mode": "HTML"
        }
      },
      "name": "Personal RSS TG1",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1680,
        2544
      ],
      "typeVersion": 1,
      "id": "6bff0ca7-2e73-4b49-af18-f838e5e6ea69",
      "webhookId": "840e17f6-1a93-4706-b123-128d349b09c0",
      "credentials": {}
    },
    {
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst oldRSSIds = staticData.oldRSSIds || [];\nconst MAX_STORED = 500;\n\nconst sentIds = items\n  .map(item => item.json._rssId)\n  .filter(id => !!id);\n\nstaticData.oldRSSIds = [...new Set([...sentIds, ...oldRSSIds])].slice(0, MAX_STORED);\nreturn items;"
      },
      "name": "Mark as Seen",
      "type": "n8n-nodes-base.code",
      "position": [
        1904,
        2616
      ],
      "typeVersion": 2,
      "id": "c7fa6b35-4d80-4e19-ade2-7bd2984d7f9e"
    },
    {
      "parameters": {
        "triggerTimes": {
          "item": [
            {
              "mode": "everyX",
              "value": 15,
              "unit": "minutes"
            }
          ]
        }
      },
      "name": "Cron1",
      "type": "n8n-nodes-base.cron",
      "position": [
        -112,
        2568
      ],
      "typeVersion": 1,
      "id": "1f7ec88f-af23-495a-ba98-e86161b1490f"
    },
    {
      "parameters": {
        "content": "## Workflow by [Praveen Deshpande](https://pravw.in/)",
        "height": 80,
        "width": 400,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        752,
        2352
      ],
      "id": "d7219584-501c-485f-a221-b578066ac45a",
      "name": "Sticky Note"
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "āš™ļø Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "āš™ļø Config": {
      "main": [
        [
          {
            "node": "RSS Sources1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS Sources1": {
      "main": [
        [
          {
            "node": "Split in Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split in Batches": {
      "main": [
        [],
        [
          {
            "node": "RSS Feed Read1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS Feed Read1": {
      "main": [
        [
          {
            "node": "Only get new RSS1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only get new RSS1": {
      "main": [
        [
          {
            "node": "Has New Items?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has New Items?": {
      "main": [
        [
          {
            "node": "Format Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Split in Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Message": {
      "main": [
        [
          {
            "node": "Personal RSS TG1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Personal RSS TG1": {
      "main": [
        [
          {
            "node": "Mark as Seen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Seen": {
      "main": [
        [
          {
            "node": "Split in Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cron1": {
      "main": [
        [
          {
            "node": "āš™ļø Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": true,
    "instanceId": "YOUR_N8N_INSTANCE_ID"
  }
}

tech, automation, n8n

⬅ Previous post
Birthday post

Next post āž”
AI as a Sidekick and Devs Need to Stay in the Driver's Seat

Subscribe for more!

You don't have to keep coming back here to read my latest posts. Subscribe via RSS to get updates whenever I publish something new.