This workflow follows the Google Sheets → HTTP Request 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 →
{
"name": "Competitor Feature-Parity Watcher",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "weeks",
"weeksInterval": 1
}
]
}
},
"id": "schedule-trigger",
"name": "Weekly Scan",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-1900,
0
]
},
{
"parameters": {
"jsCode": "// Keep this current \u2014 the LLM judges relevance against what's listed here.\nreturn [{\n json: {\n productName: \"REPLACE_YOUR_PRODUCT_NAME\",\n features: [\n \"REPLACE_FEATURE_ONE\",\n \"REPLACE_FEATURE_TWO\",\n \"REPLACE_FEATURE_THREE\"\n ]\n }\n}];"
},
"id": "your-product-context",
"name": "Your Product Context (edit me)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1680,
-140
]
},
{
"parameters": {
"jsCode": "const watchlist = [\n { name: \"REPLACE_COMPETITOR_ONE\", changelogUrl: \"https://example.com/changelog.rss\", sourceType: \"rss\" },\n { name: \"REPLACE_COMPETITOR_TWO\", changelogUrl: \"https://example.com/whats-new\", sourceType: \"webpage\" }\n];\nreturn watchlist.map(w => ({ json: w }));"
},
"id": "competitor-watchlist",
"name": "Competitor Watchlist (edit me)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1680,
60
]
},
{
"parameters": {
"batchSize": 1,
"options": {}
},
"id": "split-in-batches",
"name": "Loop One Competitor At A Time",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
-1460,
60
]
},
{
"parameters": {
"mode": "rules",
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "c-rss",
"leftValue": "={{ $json.sourceType }}",
"rightValue": "rss",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "rss"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "c-web",
"leftValue": "={{ $json.sourceType }}",
"rightValue": "webpage",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "webpage"
}
]
},
"options": {}
},
"id": "route-by-source-type",
"name": "Route By Source Type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [
-1240,
60
]
},
{
"parameters": {
"url": "={{ $json.changelogUrl }}",
"options": {}
},
"id": "rss-read",
"name": "Read RSS Feed",
"type": "n8n-nodes-base.rssFeedRead",
"typeVersion": 1.1,
"position": [
-1020,
-80
]
},
{
"parameters": {
"jsCode": "const competitor = $('Loop One Competitor At A Time').first().json;\nconst items = $input.all().map(i => i.json);\nconst normalized = items.map(it => ({\n title: it.title,\n link: it.link,\n date: it.isoDate || it.pubDate || null\n}));\nreturn [{ json: { competitor: competitor.name, status: 'ok', rawItems: normalized } }];"
},
"id": "normalize-rss",
"name": "Normalize RSS Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-800,
-80
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $json.changelogUrl }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"id": "fetch-webpage",
"name": "Fetch Changelog Page",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-1020,
140
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "fetch-ok",
"leftValue": "={{ $json.statusCode }}",
"rightValue": 400,
"operator": {
"type": "number",
"operation": "lt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "check-fetch-ok",
"name": "Fetch Succeeded?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
-800,
140
]
},
{
"parameters": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer REPLACE_OPENROUTER_API_KEY"
},
{
"name": "HTTP-Referer",
"value": "https://github.com/Skull-boy/n8n_workflows"
},
{
"name": "X-OpenRouter-Title",
"value": "n8n_workflows-competitor-watcher"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ models: [\"nvidia/nemotron-3-ultra:free\", \"qwen/qwen3-coder:free\", \"openai/gpt-oss:free\"], messages: [{ role: \"system\", content: \"Extract a JSON array of recent product updates from this raw changelog page text. Each item: {title, date}. Return ONLY the JSON array, nothing else.\" }, { role: \"user\", content: $json.data.slice(0, 8000) }] }) }}",
"options": {}
},
"id": "extract-updates-via-llm",
"name": "Extract Updates (LLM)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-580,
140
],
"credentials": {}
},
{
"parameters": {
"jsCode": "const competitor = $('Loop One Competitor At A Time').first().json;\ntry {\n const raw = $input.first().json.choices[0].message.content;\n const items = JSON.parse(raw);\n return [{ json: { competitor: competitor.name, status: 'ok', rawItems: items.map(i => ({ title: i.title, link: competitor.changelogUrl, date: i.date })) } }];\n} catch (e) {\n return [{ json: { competitor: competitor.name, status: 'parse_failed', rawItems: [] } }];\n}"
},
"id": "normalize-webpage",
"name": "Normalize Webpage Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-360,
140
]
},
{
"parameters": {
"jsCode": "const competitor = $('Loop One Competitor At A Time').first().json;\nreturn [{ json: { competitor: competitor.name, status: 'fetch_failed', rawItems: [] } }];"
},
"id": "log-fetch-failure",
"name": "Log Fetch Failure",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-580,
300
]
},
{
"parameters": {
"operation": "lookup",
"documentId": {
"__rl": true,
"value": "REPLACE_GOOGLE_SHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "CompetitorState",
"mode": "name"
},
"lookupColumn": "competitor",
"lookupValue": "={{ $json.competitor }}"
},
"id": "lookup-last-checked",
"name": "Look Up Last Checked",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
-140,
60
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const cur = $('Normalize RSS Items').itemMatching ? null : null;\n// Pull the item that actually reached this node (rss, webpage, or failure path)\nconst src = $input.first().json;\nconst prevRows = $items('Look Up Last Checked');\nconst lastCheckedDate = (prevRows.length > 0 && prevRows[0].json.lastCheckedDate) ? prevRows[0].json.lastCheckedDate : null;\n\nif (src.status !== 'ok') {\n return [{ json: { competitor: src.competitor, status: src.status, newItems: [] } }];\n}\n\nconst newItems = lastCheckedDate\n ? src.rawItems.filter(i => i.date && new Date(i.date) > new Date(lastCheckedDate))\n : src.rawItems;\n\nreturn [{ json: { competitor: src.competitor, status: 'ok', newItems, latestDate: src.rawItems.reduce((max, i) => (i.date && (!max || new Date(i.date) > new Date(max))) ? i.date : max, lastCheckedDate) } }];"
},
"id": "filter-new-items",
"name": "Filter To New Items Only",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
80,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-new",
"leftValue": "={{ $json.status }}",
"rightValue": "ok",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "has-items",
"leftValue": "={{ $json.newItems.length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "any-new-to-judge",
"name": "Any New Items To Judge?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
300,
60
]
},
{
"parameters": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer REPLACE_OPENROUTER_API_KEY"
},
{
"name": "HTTP-Referer",
"value": "https://github.com/Skull-boy/n8n_workflows"
},
{
"name": "X-OpenRouter-Title",
"value": "n8n_workflows-competitor-watcher"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ models: [\"nvidia/nemotron-3-ultra:free\", \"qwen/qwen3-coder:free\", \"openai/gpt-oss:free\"], messages: [{ role: \"system\", content: `You judge competitive relevance. Our product (${$('Your Product Context (edit me)').first().json.productName}) has these features: ${$('Your Product Context (edit me)').first().json.features.join(', ')}. For each competitor update given, score 1-5 how relevant/threatening it is to us specifically (5 = direct feature overlap or gap, 1 = irrelevant noise). Also assign ONE reasonCode from this fixed set: pricing, enterprise, integration, migration-risk, new_offer, positioning_shift, other. Keep reasonCode to exactly one of these values, nothing else, so scores stay filterable and debuggable over time. Return ONLY a JSON array: [{title, score, reasonCode, reason}]` }, { role: \"user\", content: JSON.stringify($json.newItems) }] }) }}",
"options": {}
},
"id": "judge-relevance",
"name": "Judge Relevance Against Our Features",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
-80
],
"credentials": {}
},
{
"parameters": {
"jsCode": "const competitor = $('Filter To New Items Only').first().json.competitor;\nconst newItems = $('Filter To New Items Only').first().json.newItems;\nlet judged = [];\ntry {\n judged = JSON.parse($input.first().json.choices[0].message.content);\n} catch (e) {\n judged = [];\n}\nconst VALID_CODES = ['pricing','enterprise','integration','migration-risk','new_offer','positioning_shift','other'];\nconst relevant = judged.filter(j => j.score >= 3).map(j => {\n const match = newItems.find(n => n.title === j.title);\n const reasonCode = VALID_CODES.includes(j.reasonCode) ? j.reasonCode : 'other';\n return { competitor, title: j.title, score: j.score, reasonCode, reason: j.reason, link: match ? match.link : '', scanDate: new Date().toISOString().slice(0,10) };\n});\nreturn relevant.map(r => ({ json: r }));"
},
"id": "parse-and-filter-relevant",
"name": "Parse & Filter Relevant",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
740,
-80
]
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "REPLACE_GOOGLE_SHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "FlaggedUpdates",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"competitor": "={{ $json.competitor }}",
"title": "={{ $json.title }}",
"score": "={{ $json.score }}",
"reason": "={{ $json.reason }}",
"link": "={{ $json.link }}",
"scanDate": "={{ $json.scanDate }}",
"reasonCode": "={{ $json.reasonCode }}"
}
}
},
"id": "save-flagged-items",
"name": "Save Flagged Updates",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
960,
-80
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const src = $('Filter To New Items Only').first().json;\nif (src.status === 'fetch_failed' || src.status === 'parse_failed') {\n return [{ json: { skip: true } }];\n}\nreturn [{ json: { competitor: src.competitor, latestDate: src.latestDate || new Date().toISOString() } }];"
},
"id": "prep-state-update",
"name": "Prep State Update",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
520,
220
]
},
{
"parameters": {
"operation": "upsert",
"documentId": {
"__rl": true,
"value": "REPLACE_GOOGLE_SHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "CompetitorState",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"competitor": "={{ $json.competitor }}",
"lastCheckedDate": "={{ $json.latestDate }}"
}
},
"matchingColumns": [
"competitor"
]
},
"id": "update-state",
"name": "Update Last-Checked State",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
740,
220
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"amount": 2,
"unit": "seconds"
},
"id": "rate-limit-wait",
"name": "Wait (Avoid Rate Limit)",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
960,
220
]
},
{
"parameters": {
"operation": "getAll",
"documentId": {
"__rl": true,
"value": "REPLACE_GOOGLE_SHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "FlaggedUpdates",
"mode": "name"
},
"filters": {
"conditions": [
{
"keyField": "scanDate",
"condition": "equal",
"keyValue": "={{ $now.format('yyyy-MM-dd') }}"
}
]
}
},
"id": "get-todays-flagged",
"name": "Get This Week's Flagged Updates",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
-1460,
460
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all().map(i => i.json);\nif (rows.length === 0) {\n return [{ json: { digest: '\ud83d\udcca Competitor Watch: no relevant updates this week.' } }];\n}\nconst byCompetitor = {};\nfor (const r of rows) {\n byCompetitor[r.competitor] = byCompetitor[r.competitor] || [];\n byCompetitor[r.competitor].push(r);\n}\nlet msg = '\ud83d\udcca Weekly Competitor Watch\\n\\n';\nfor (const [comp, items] of Object.entries(byCompetitor)) {\n msg += `\ud83d\udd39 ${comp}\\n`;\n for (const it of items) {\n msg += ` \u2022 [${it.score}/5 \u00b7 ${it.reasonCode}] ${it.title}\\n ${it.reason}\\n ${it.link}\\n`;\n }\n msg += '\\n';\n}\nreturn [{ json: { digest: msg } }];"
},
"id": "build-digest",
"name": "Build Digest Report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1240,
460
]
},
{
"parameters": {
"chatId": "REPLACE_YOUR_TELEGRAM_CHAT_ID",
"text": "={{ $json.digest }}",
"additionalFields": {}
},
"id": "send-digest",
"name": "Send Digest (Telegram)",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [
-1020,
460
],
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
}
}
],
"connections": {
"Weekly Scan": {
"main": [
[
{
"node": "Your Product Context (edit me)",
"type": "main",
"index": 0
},
{
"node": "Competitor Watchlist (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Competitor Watchlist (edit me)": {
"main": [
[
{
"node": "Loop One Competitor At A Time",
"type": "main",
"index": 0
}
]
]
},
"Loop One Competitor At A Time": {
"main": [
[
{
"node": "Get This Week's Flagged Updates",
"type": "main",
"index": 0
}
],
[
{
"node": "Route By Source Type",
"type": "main",
"index": 0
}
]
]
},
"Route By Source Type": {
"main": [
[
{
"node": "Read RSS Feed",
"type": "main",
"index": 0
}
],
[
{
"node": "Fetch Changelog Page",
"type": "main",
"index": 0
}
]
]
},
"Read RSS Feed": {
"main": [
[
{
"node": "Normalize RSS Items",
"type": "main",
"index": 0
}
]
]
},
"Normalize RSS Items": {
"main": [
[
{
"node": "Look Up Last Checked",
"type": "main",
"index": 0
}
]
]
},
"Fetch Changelog Page": {
"main": [
[
{
"node": "Fetch Succeeded?",
"type": "main",
"index": 0
}
]
]
},
"Fetch Succeeded?": {
"main": [
[
{
"node": "Extract Updates (LLM)",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Fetch Failure",
"type": "main",
"index": 0
}
]
]
},
"Extract Updates (LLM)": {
"main": [
[
{
"node": "Normalize Webpage Items",
"type": "main",
"index": 0
}
]
]
},
"Normalize Webpage Items": {
"main": [
[
{
"node": "Look Up Last Checked",
"type": "main",
"index": 0
}
]
]
},
"Log Fetch Failure": {
"main": [
[
{
"node": "Look Up Last Checked",
"type": "main",
"index": 0
}
]
]
},
"Look Up Last Checked": {
"main": [
[
{
"node": "Filter To New Items Only",
"type": "main",
"index": 0
}
]
]
},
"Filter To New Items Only": {
"main": [
[
{
"node": "Any New Items To Judge?",
"type": "main",
"index": 0
},
{
"node": "Prep State Update",
"type": "main",
"index": 0
}
]
]
},
"Any New Items To Judge?": {
"main": [
[
{
"node": "Judge Relevance Against Our Features",
"type": "main",
"index": 0
}
],
[]
]
},
"Judge Relevance Against Our Features": {
"main": [
[
{
"node": "Parse & Filter Relevant",
"type": "main",
"index": 0
}
]
]
},
"Parse & Filter Relevant": {
"main": [
[
{
"node": "Save Flagged Updates",
"type": "main",
"index": 0
}
]
]
},
"Prep State Update": {
"main": [
[
{
"node": "Update Last-Checked State",
"type": "main",
"index": 0
}
]
]
},
"Update Last-Checked State": {
"main": [
[
{
"node": "Wait (Avoid Rate Limit)",
"type": "main",
"index": 0
}
]
]
},
"Wait (Avoid Rate Limit)": {
"main": [
[
{
"node": "Loop One Competitor At A Time",
"type": "main",
"index": 0
}
]
]
},
"Get This Week's Flagged Updates": {
"main": [
[
{
"node": "Build Digest Report",
"type": "main",
"index": 0
}
]
]
},
"Build Digest Report": {
"main": [
[
{
"node": "Send Digest (Telegram)",
"type": "main",
"index": 0
}
]
]
},
"Save Flagged Updates": {
"main": [
[
{
"node": "Wait (Avoid Rate Limit)",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
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.
googleSheetsOAuth2ApitelegramApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Competitor Feature-Parity Watcher. Uses rssFeedRead, httpRequest, googleSheets, telegram. Scheduled trigger; 24 nodes.
Source: https://github.com/Skull-boy/n8n_workflows/blob/main/workflows/competitor-feature-parity-watcher/workflow.json — 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.
Auto Hunt. Uses httpRequest, googleSheets, rssFeedRead, telegram. Scheduled trigger; 78 nodes.
Auto-Hunt. Uses httpRequest, googleSheets, rssFeedRead, telegram. Scheduled trigger; 78 nodes.
FireReach — Always-On SDR Workflow. Uses rssFeedRead, httpRequest, telegram, googleSheets. Scheduled trigger; 11 nodes.
03 · RSS → GPT → Post Draft (human approval). Uses rssFeedRead, httpRequest, googleSheets, telegram. Scheduled trigger; 10 nodes.
. Uses googleSheets, telegram, httpRequest, wise. Scheduled trigger; 36 nodes.