This workflow corresponds to n8n.io template #15129 — we link there as the canonical source.
This workflow follows the Googlegemini → Telegram recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"nodes": [
{
"id": "sticky-main",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1408,
-256
],
"parameters": {
"width": 540,
"height": 1376,
"content": "## Track documentation changes with Firecrawl, Postgres hashing, Gemini and Telegram\n\nThis workflow watches an external documentation site daily and sends an AI-generated Vietnamese summary to Telegram whenever pages change. It is built for Zalo Bot Platform docs (`https://bot.zapps.me/docs`) but works for any docs site by swapping the URL.\n\n### How it works\n1. A Schedule Trigger runs every morning at 9:00 AM Asia/Ho_Chi_Minh.\n2. Firecrawl **Map** enumerates every URL under the docs site, then a Code node dedupes and keeps only docs paths.\n3. Firecrawl **Scrape** fetches clean markdown for each page (auto-looped one request per URL).\n4. A Code node SHA-256 hashes the markdown of every page.\n5. A single Postgres query upserts hashes into `docs_snapshots` and returns a `status` of `new`, `changed`, or `same`.\n6. A Filter keeps only `new` and `changed` pages, then an Aggregate combines them into one item.\n7. If anything changed, Google Gemini 2.0 Flash reads the diff list plus previews and writes a Vietnamese summary of what changed and why it matters.\n8. The summary is delivered to a Telegram chat. If nothing changed, the workflow ends silently.\n\n### Setup steps\n- [ ] Self-hosted n8n required (community nodes cannot be installed on n8n Cloud)\n- [ ] Install community node `n8n-nodes-firecrawl-v2` via **Settings > Community Nodes**\n- [ ] Create a Firecrawl credential (Cloud or self-hosted)\n- [ ] Create a Google Gemini credential (free tier from [Google AI Studio](https://aistudio.google.com))\n- [ ] Create a Telegram Bot credential (token from @BotFather)\n- [ ] Create a Postgres credential and run the schema below\n- [ ] Edit the **Firecrawl Map Docs** and **Split & Filter Links** nodes to point at the docs site you want to monitor\n- [ ] Set the Telegram recipient in the **Send Telegram Alert** node\n- [ ] Activate the workflow\n\n### Database schema\n```sql\nCREATE TABLE docs_snapshots (\n id SERIAL PRIMARY KEY,\n source VARCHAR(50),\n url TEXT,\n title TEXT,\n content_hash VARCHAR(64),\n content_preview TEXT,\n last_seen TIMESTAMPTZ,\n last_changed TIMESTAMPTZ,\n UNIQUE(source, url)\n);\n```\n\n### Why Postgres hashing instead of Firecrawl changeTracking\nFirecrawl self-hosted does not expose `changeTracking` (Cloud only). Hashing markdown and upserting to Postgres works for any Firecrawl deployment and any docs source.\n\n### First run\nOn the very first run every page returns status `new`, so you will receive one large alert listing every endpoint. Subsequent runs only alert on real changes.\n\n### Customization\n- Change the cron to hourly or weekly\n- Watch multiple docs sites by duplicating the Map + Scrape branch per source\n- Swap Gemini for OpenAI, Claude, or a local LLM\n- Send alerts to Slack, Discord, Zalo, or email instead of Telegram"
},
"typeVersion": 1
},
{
"id": "sticky-g1",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-848,
-96
],
"parameters": {
"color": 7,
"width": 720,
"height": 208,
"content": "## Schedule and map docs URLs\n\nA daily Schedule Trigger fires at 9:00 AM (Asia/Ho_Chi_Minh). Firecrawl **Map** enumerates every URL under the docs site, then a Code node keeps only docs paths and deduplicates them before the scrape loop."
},
"typeVersion": 1
},
{
"id": "sticky-g2",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
0,
-96
],
"parameters": {
"color": 7,
"width": 448,
"height": 208,
"content": "## Scrape and hash each page\n\nFirecrawl **Scrape** auto-loops one request per URL and returns clean markdown. A Code node SHA-256 hashes the markdown so the next step can detect any content change with a single comparison."
},
"typeVersion": 1
},
{
"id": "sticky-g3",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
592,
-96
],
"parameters": {
"color": 7,
"width": 1024,
"height": 208,
"content": "## Detect changes with Postgres\n\nA single Postgres query upserts each page into `docs_snapshots` and returns a `status`:\n- `new` if the URL is seen for the first time\n- `changed` if the hash differs from the last stored value (`last_changed` is bumped)\n- `same` if the hash is identical (only `last_seen` is refreshed)\n\nThe Filter keeps only `new` and `changed` rows, Aggregate gathers them into one item, and the IF branches on whether the list is empty."
},
"typeVersion": 1
},
{
"id": "sticky-g4",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1792,
-96
],
"parameters": {
"color": 7,
"width": 768,
"content": "## Summarize changes and alert\n\nA Code node formats the changed pages into a markdown list with URL, status, and preview. Google Gemini 2.0 Flash reads the list and writes a concise Vietnamese summary covering what changed and which parts of downstream integrations may need updating. The final text is sent to Telegram."
},
"typeVersion": 1
},
{
"id": "sticky-g5",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
1824,
480
],
"parameters": {
"color": 7,
"width": 320,
"content": "## No changes path\n\nIf nothing changed, the workflow ends silently (no Telegram alert) so you only get pinged on real updates."
},
"typeVersion": 1
},
{
"id": "sticky-disclaimer",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1408,
1152
],
"parameters": {
"color": 5,
"width": 540,
"content": "### Community node - Self-hosted only\nThis template uses the `n8n-nodes-firecrawl-v2` community node which requires self-hosted n8n. It cannot be installed on n8n Cloud. Both Firecrawl Cloud and self-hosted Firecrawl backends are supported."
},
"typeVersion": 1
},
{
"id": "schedule",
"name": "Schedule Daily 9AM",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-816,
208
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 * * *"
}
]
}
},
"typeVersion": 1.2
},
{
"id": "fc-map",
"name": "Firecrawl Map Docs",
"type": "n8n-nodes-firecrawl-v2.firecrawl",
"position": [
-544,
208
],
"parameters": {
"mapUrl": "https://bot.zapps.me/docs",
"operation": "map",
"mapOptions": {
"sitemap": "include",
"includeSubdomains": false,
"ignoreQueryParameters": true
}
},
"credentials": {
"firecrawlApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "split-links",
"name": "Split & Filter Links",
"type": "n8n-nodes-base.code",
"position": [
-256,
208
],
"parameters": {
"jsCode": "const items = $input.all();\nconst out = [];\nconst seen = new Set();\nfor (const item of items) {\n const links = item.json.links || item.json.data?.links || [];\n for (const l of links) {\n const url = typeof l === 'string' ? l : l.url;\n if (!url) continue;\n if (!url.startsWith('https://bot.zapps.me/docs')) continue;\n if (seen.has(url)) continue;\n seen.add(url);\n out.push({ json: { url } });\n }\n}\nreturn out;"
},
"typeVersion": 2
},
{
"id": "fc-scrape",
"name": "Firecrawl Scrape Page",
"type": "n8n-nodes-firecrawl-v2.firecrawl",
"maxTries": 2,
"position": [
32,
208
],
"parameters": {
"url": "={{ $json.url }}",
"scrapeOptions": {
"formats": [
"markdown"
],
"waitFor": 5000,
"onlyMainContent": true
}
},
"credentials": {
"firecrawlApi": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"typeVersion": 1,
"waitBetweenTries": 2000
},
{
"id": "hash-content",
"name": "Hash Content",
"type": "n8n-nodes-base.code",
"position": [
320,
208
],
"parameters": {
"jsCode": "// Pure JS hash (no crypto module)\nfunction fnv1a64(str) {\n let h1 = 0x84222325, h2 = 0xcbf29ce4;\n for (let i = 0; i < str.length; i++) {\n const c = str.charCodeAt(i);\n h1 ^= c;\n const lo = h1 * 0x1b3 + (h2 * 0x1b3 << 0);\n const hi = h2 * 0x1b3 + (h1 << 8) + (h1 << 40 / 1);\n h1 = lo >>> 0;\n h2 = (hi + Math.floor(lo / 0x1+1234567890)) >>> 0;\n }\n return h2.toString(16).padStart(8, '0') + h1.toString(16).padStart(8, '0');\n}\n\n// Normalize markdown to remove volatile artifacts (JS render race conditions, whitespace noise)\nfunction normalize(md) {\n return (md || '')\n // Strip the server-side fallback placeholder that appears when JS hasn't rendered yet\n .replace(/Code demo fallback when rendering server side!?/gi, '')\n // Strip \"Last updated\" / \"C\u1eadp nh\u1eadt l\u1ea7n cu\u1ed1i\" lines (relative dates can drift)\n .replace(/^.*[Cc]\u1eadp nh\u1eadt l\u1ea7n cu\u1ed1i.*$/gm, '')\n .replace(/^.*[Ll]ast [Uu]pdated.*$/gm, '')\n // Collapse runs of whitespace\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst items = $input.all();\nreturn items.map(item => {\n const data = item.json.data || item.json;\n const url = data.metadata?.sourceURL || data.metadata?.url || data.url || item.json.url;\n const title = data.metadata?.title || (url || '').split('/').pop() || 'untitled';\n const markdown = data.markdown || '';\n const normalized = normalize(markdown);\n const lenTag = normalized.length.toString(16).padStart(8, '0');\n const fingerprint = fnv1a64(normalized);\n const contentHash = (lenTag + fingerprint).slice(0, 64);\n const preview = markdown.slice(0, 500);\n return {\n json: {\n source: 'zalo-bot',\n url,\n title,\n content_hash: contentHash,\n content_preview: preview,\n markdown_full: markdown,\n normalized_length: normalized.length,\n }\n };\n});"
},
"typeVersion": 2
},
{
"id": "pg-upsert",
"name": "Postgres Upsert & Diff",
"type": "n8n-nodes-base.postgres",
"position": [
624,
208
],
"parameters": {
"query": "WITH old AS (\n SELECT content_hash AS old_hash FROM docs_snapshots WHERE source = $1 AND url = $2\n),\nupsert AS (\n INSERT INTO docs_snapshots (source, url, title, content_hash, content_preview, last_seen, last_changed)\n VALUES ($1, $2, $3, $4, $5, NOW(), NOW())\n ON CONFLICT (source, url) DO UPDATE SET\n content_hash = EXCLUDED.content_hash,\n title = EXCLUDED.title,\n content_preview = EXCLUDED.content_preview,\n last_seen = NOW(),\n last_changed = CASE WHEN docs_snapshots.content_hash <> EXCLUDED.content_hash THEN NOW() ELSE docs_snapshots.last_changed END\n RETURNING url, title, content_hash AS new_hash, content_preview\n)\nSELECT\n upsert.url,\n upsert.title,\n upsert.new_hash,\n upsert.content_preview,\n COALESCE(old.old_hash, '') AS old_hash,\n CASE\n WHEN old.old_hash IS NULL THEN 'new'\n WHEN old.old_hash <> upsert.new_hash THEN 'changed'\n ELSE 'same'\n END AS status\nFROM upsert\nLEFT JOIN old ON true;",
"options": {
"queryReplacement": "={{ $json.source }},{{ $json.url }},{{ $json.title }},{{ $json.content_hash }},{{ $json.content_preview }}"
},
"operation": "executeQuery"
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"typeVersion": 2.5
},
{
"id": "filter-changed",
"name": "Filter Changed Pages",
"type": "n8n-nodes-base.filter",
"position": [
928,
208
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "not-same",
"operator": {
"type": "string",
"operation": "notEquals"
},
"leftValue": "={{ $json.status }}",
"rightValue": "same"
}
]
}
},
"typeVersion": 2.2
},
{
"id": "aggregate-changes",
"name": "Aggregate Changes",
"type": "n8n-nodes-base.aggregate",
"position": [
1232,
208
],
"parameters": {
"options": {},
"aggregate": "aggregateAllItemData",
"destinationFieldName": "changedPages"
},
"typeVersion": 1
},
{
"id": "if-has-changes",
"name": "IF Has Changes",
"type": "n8n-nodes-base.if",
"position": [
1520,
208
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": false,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "has",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ ($json.changedPages || []).length }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2.2
},
{
"id": "format-list",
"name": "Format Changes List",
"type": "n8n-nodes-base.code",
"position": [
1824,
112
],
"parameters": {
"jsCode": "const pages = $json.changedPages || [];\nlet newCount = 0, changedCount = 0;\nconst lines = [];\nfor (const p of pages) {\n const status = p.status || 'unknown';\n if (status === 'new') newCount++;\n else if (status === 'changed') changedCount++;\n lines.push(`- [${status.toUpperCase()}] ${p.title}\\n ${p.url}`);\n}\nconst previews = pages.slice(0, 5).map(p => `### ${p.url}\\n${(p.content_preview || '').slice(0, 400)}`).join('\\n\\n');\nreturn [{\n json: {\n totalChanged: pages.length,\n newCount,\n changedCount,\n changesList: lines.join('\\n'),\n previews,\n }\n}];"
},
"typeVersion": 2
},
{
"id": "gemini-summarize",
"name": "Gemini Summarize Changes",
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"position": [
2128,
112
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "models/gemini-2.0-flash"
},
"options": {},
"messages": {
"values": [
{
"content": "=B\u1ea1n l\u00e0 k\u1ef9 s\u01b0 b\u1ea3o tr\u00ec n8n community node `n8n-nodes-zalo-platform`. T\u00e0i li\u1ec7u ch\u00ednh th\u1ee9c c\u1ee7a Zalo Bot Platform (https://bot.zapps.me/docs) v\u1eeba c\u00f3 thay \u0111\u1ed5i. H\u00e3y ph\u00e2n t\u00edch v\u00e0 vi\u1ebft t\u00f3m t\u1eaft ti\u1ebfng Vi\u1ec7t c\u00f3 d\u1ea5u (t\u1ed1i \u0111a 250 t\u1eeb), g\u1ed3m:\n\n1. Li\u1ec7t k\u00ea c\u00e1c thay \u0111\u1ed5i quan tr\u1ecdng nh\u1ea5t\n2. \u0110o\u00e1n xem thay \u0111\u1ed5i li\u00ean quan endpoint n\u00e0o (sendMessage, sendPhoto, sendSticker, sendChatAction, getUpdates, setWebhook, deleteWebhook, getMe, getWebhookInfo)\n3. \u0110\u00e1nh gi\u00e1 \u1ea3nh h\u01b0\u1edfng t\u1edbi node Zalo Bot hi\u1ec7n t\u1ea1i (c\u1ea7n update / c\u1ea7n th\u00eam operation m\u1edbi / kh\u00f4ng c\u1ea7n)\n4. \u0110\u1ec1 xu\u1ea5t h\u00e0nh \u0111\u1ed9ng c\u1ee5 th\u1ec3 (file/method n\u00e0o c\u1ea7n s\u1eeda)\n\nTh\u1ed1ng k\u00ea: {{ $json.totalChanged }} trang ({{ $json.newCount }} m\u1edbi, {{ $json.changedCount }} thay \u0111\u1ed5i)\n\nDanh s\u00e1ch:\n{{ $json.changesList }}\n\nN\u1ed9i dung 5 trang \u0111\u1ea7u:\n{{ $json.previews }}"
}
]
}
},
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "telegram-alert",
"name": "Send Telegram Alert",
"type": "n8n-nodes-base.telegram",
"position": [
2432,
112
],
"parameters": {
"text": "=\ud83d\udd14 <b>Zalo Bot Docs Update Alert</b>\n\n\ud83d\udcda Ph\u00e1t hi\u1ec7n <b>{{ $('Format Changes List').item.json.totalChanged }} trang</b> thay \u0111\u1ed5i t\u1ea1i bot.zapps.me/docs\n\n\ud83d\udcca Ph\u00e2n lo\u1ea1i:\n\u2022 \ud83c\udd95 New: {{ $('Format Changes List').item.json.newCount }}\n\u2022 \u270f\ufe0f Changed: {{ $('Format Changes List').item.json.changedCount }}\n\n\ud83e\udd16 <b>Ph\u00e2n t\u00edch Gemini:</b>\n{{ $json.content.parts[0].text.toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') }}\n\n\ud83d\udccb <b>Danh s\u00e1ch trang:</b>\n<pre>{{ $('Format Changes List').item.json.changesList.toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') }}</pre>\n\n<i>Workflow: zalo-docs-watcher-001 | THE NEXOVA</i>",
"chatId": "123456789",
"additionalFields": {
"parse_mode": "HTML",
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "no-op",
"name": "No Changes Detected",
"type": "n8n-nodes-base.noOp",
"position": [
1824,
320
],
"parameters": {},
"typeVersion": 1
}
],
"connections": {
"Hash Content": {
"main": [
[
{
"node": "Postgres Upsert & Diff",
"type": "main",
"index": 0
}
]
]
},
"IF Has Changes": {
"main": [
[
{
"node": "Format Changes List",
"type": "main",
"index": 0
}
],
[
{
"node": "No Changes Detected",
"type": "main",
"index": 0
}
]
]
},
"Aggregate Changes": {
"main": [
[
{
"node": "IF Has Changes",
"type": "main",
"index": 0
}
]
]
},
"Firecrawl Map Docs": {
"main": [
[
{
"node": "Split & Filter Links",
"type": "main",
"index": 0
}
]
]
},
"Schedule Daily 9AM": {
"main": [
[
{
"node": "Firecrawl Map Docs",
"type": "main",
"index": 0
}
]
]
},
"Format Changes List": {
"main": [
[
{
"node": "Gemini Summarize Changes",
"type": "main",
"index": 0
}
]
]
},
"Filter Changed Pages": {
"main": [
[
{
"node": "Aggregate Changes",
"type": "main",
"index": 0
}
]
]
},
"Split & Filter Links": {
"main": [
[
{
"node": "Firecrawl Scrape Page",
"type": "main",
"index": 0
}
]
]
},
"Firecrawl Scrape Page": {
"main": [
[
{
"node": "Hash Content",
"type": "main",
"index": 0
}
]
]
},
"Postgres Upsert & Diff": {
"main": [
[
{
"node": "Filter Changed Pages",
"type": "main",
"index": 0
}
]
]
},
"Gemini Summarize Changes": {
"main": [
[
{
"node": "Send Telegram Alert",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
firecrawlApigooglePalmApipostgrestelegramApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This template watches an external documentation site every day and sends an AI-generated Vietnamese summary to Telegram whenever pages change. It is built around Postgres content hashing so it works with any Firecrawl deployment (Cloud or self-hosted) and any docs source, not…
Source: https://n8n.io/workflows/15129/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow automates the process of monitoring Twitter accounts for intelligence gathering. It fetches new tweets from specified accounts via RSS, uses a powerful AI model (Google Gemini) to analyz
AI Institutional Stock Valuation Engine with Risk Scoring & Scenario Targets
Overview This is a production-grade, fully automated stock analysis system built entirely in n8n. It combines institutional-level financial analysis, dual AI model consensus, and a self-improving back
A professional AI equity analysis automation built on n8n that transforms structured financial data and real-time news into disciplined, risk-adjusted price targets and actionable BUY/HOLD/SELL signal
This workflow creates a daily “n8n News Radar” briefing: Pulls the latest n8n ecosystem updates from Blog, Community, GitHub Releases, and Reddit. Filters to the last 24 hours + keyword relevance. Use