This workflow corresponds to n8n.io template #7434 — we link there as the canonical source.
This workflow follows the Chainllm → Google Sheets 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 →
{
"id": "1mZD3H0r79bhY71M",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "YouTube Comment Sentiment & Keyword Extractor",
"tags": [],
"nodes": [
{
"id": "55d27fc7-ac2c-4ccc-a98f-f8c4ab7547c4",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
0,
0
],
"parameters": {
"rule": {
"interval": [
{}
]
}
},
"typeVersion": 1.2
},
{
"id": "bd198bd2-705f-4fc9-ba30-c1facfa06359",
"name": "Get row(s) in sheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
220,
0
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "list",
"value": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"typeVersion": 4.6
},
{
"id": "514959bb-14aa-4947-9d78-f903cb3201e3",
"name": "Loop Over Items",
"type": "n8n-nodes-base.splitInBatches",
"position": [
740,
0
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "1a194433-415d-42cd-aeda-c983b5d81883",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"position": [
980,
120
],
"parameters": {
"url": "=https://www.googleapis.com/youtube/v3/commentThreads?part=snippet&videoId={{ $('Get row(s) in sheet').item.json.video_id }}&maxResults=100&key={{ $json['Youtube API Key'] }}",
"options": {}
},
"typeVersion": 4.2
},
{
"id": "93821e4a-b8d9-4364-aa9c-2e9200c49691",
"name": "Youtube API Key",
"type": "n8n-nodes-base.set",
"position": [
480,
0
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "77fd67c8-e3a8-44a9-a347-5d10797ab01b",
"name": "Youtube API Key",
"type": "string",
"value": "YOUR_YOUTUBE_API_KEY"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "5e91edf9-2ea6-4615-8a8c-710ac509a1f2",
"name": "Code",
"type": "n8n-nodes-base.code",
"position": [
1200,
120
],
"parameters": {
"jsCode": "// Input: HTTP Request result (object with .items)\n// Output: one item per comment, flat data ready to write\nconst out = [];\nconst root = items[0].json || {};\nfor (const it of (root.items || [])) {\n const s = it.snippet || {};\n const tl = (s.topLevelComment || {}).snippet || {};\n out.push({\n json: {\n comment_id: it.id,\n video_id: s.videoId,\n author: tl.authorDisplayName,\n comment_text: tl.textOriginal,\n like_count: tl.likeCount || 0,\n published_at: tl.publishedAt,\n author_channel_id: (tl.authorChannelId || {}).value,\n author_channel_url: tl.authorChannelUrl\n }\n });\n}\nreturn out;\n"
},
"typeVersion": 2
},
{
"id": "bfd6ff22-ae88-4d21-ac46-9a92c518cd87",
"name": "Basic LLM Chain",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"position": [
1420,
120
],
"parameters": {
"text": "=Data to analyze:\nyoutube_comment_text: \"{{ $json.comment_text }}\"\nlike_count: {{ $json.like_count }}\n\nConsider like_count only as a weak signal (slightly increase |score| if like_count is high and sentiment is clear).\nReturn the JSON only.\n",
"batching": {},
"messages": {
"messageValues": [
{
"message": "=You are a concise social-listening analyst. \nReturn ONLY valid JSON (no markdown, no backticks).\nRules:\n- sentiment \u2208 [\"positive\",\"neutral\",\"negative\"] based on overall tone.\n- score is a float in [-1, 1]; map highly positive \u2248 0.7..1, neutral \u2248 -0.2..0.2, highly negative \u2248 -1..-0.7.\n- keywords is an array of 3\u20138 short keywords (lowercase, ascii, no emojis/hashtags/stopwords, no duplicates).\n- language is a 2-letter ISO 639-1 code (e.g., \"id\", \"en\").\n- Remove emojis, excessive punctuation, URLs from consideration (but keep meaning).\n- If text is spam/promotional or meaningless, set sentiment=\"neutral\" and score\u22480 and still extract topic keywords.\n- Output must be STRICT JSON with exactly these keys: sentiment, score, keywords, language.\n"
}
]
},
"promptType": "define",
"hasOutputParser": true
},
"executeOnce": false,
"typeVersion": 1.7
},
{
"id": "29802a0-fd43-467a-8afd-534f9099c492",
"name": "Structured Output Parser",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"position": [
1580,
340
],
"parameters": {
"jsonSchemaExample": "{\n \"sentiment\": \"neutral\",\n \"score\": 0.05,\n \"keywords\": [\"jogja\", \"nyimak\", \"harapan\", \"promo\"],\n \"language\": \"id\"\n}"
},
"typeVersion": 1.3
},
{
"id": "67469c87-b1cf-4ed7-8b41-ffadf44fc32c",
"name": "OpenRouter Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
"position": [
1420,
340
],
"parameters": {
"model": "google/gemini-2.0-flash-exp:free",
"options": {}
},
"typeVersion": 1
},
{
"id": "2aa4b295-b562-43ff-b9df-b411dc781181",
"name": "Code1",
"type": "n8n-nodes-base.code",
"position": [
1780,
120
],
"parameters": {
"jsCode": "// n8n Code node: Normalize LLM JSON -> {sentiment, score, keywords, language}\nconst out = [];\n\nfor (const item of items) {\n // 1) Get candidate payload from several possible formats\n let payload = item.json?.output ?? item.json?.data ?? item.json;\n\n // 2) If string, try to parse JSON\n if (typeof payload === 'string') {\n try { payload = JSON.parse(payload); } catch { payload = {}; }\n }\n\n // 3) If there's still payload.output as string JSON, parse it too\n if (payload && typeof payload.output === 'string') {\n try { payload = JSON.parse(payload.output); } catch {}\n }\n\n // 4) Normalize fields\n const allowed = new Set(['positive','neutral','negative']);\n let sentiment = String(payload?.sentiment ?? '').toLowerCase();\n if (!allowed.has(sentiment)) sentiment = 'neutral';\n\n let score = Number(payload?.score);\n if (!Number.isFinite(score)) score = 0;\n if (score > 1) score = 1;\n if (score < -1) score = -1;\n\n let keywords = Array.isArray(payload?.keywords) ? payload.keywords : [];\n keywords = keywords\n .map(k => String(k).toLowerCase().trim())\n .filter(k => k && k.length <= 32)\n .slice(0, 8);\n\n let language = String(payload?.language ?? '').slice(0, 2).toLowerCase();\n if (!language) language = 'en';\n\n out.push({ json: { sentiment, score, keywords, language } });\n}\n\nreturn out;\n"
},
"typeVersion": 2
},
{
"id": "1a6a9a96-abb2-468c-b7b2-45e527ac1e36",
"name": "Append or update row in sheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
2000,
120
],
"parameters": {
"columns": {
"value": {
"author": "={{ $('Code').item.json.author }}",
"keywords": "={{ $json.keywords }}",
"video_id": "={{ $('Code').item.json.video_id }}",
"sentiment": "={{ $json.sentiment }}",
"comment_id": "={{ $('Code').item.json.comment_id }}",
"like_count": "={{ $('Code').item.json.like_count }}",
"comment_text": "={{ $('Code').item.json.comment_text }}",
"published_at": "={{ $('Code').item.json.published_at }}",
"sentiment_score": "={{ $json.score }}",
"author_channel_id": "={{ $('Code').item.json.author_channel_id }}",
"author_channel_url": "={{ $('Code').item.json.author_channel_url }}"
},
"schema": [
{
"id": "video_id",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "video_id",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "comment_id",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "comment_id",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "author",
"type": "string",
"display": true,
"required": false,
"displayName": "author",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "comment_text",
"type": "string",
"display": true,
"required": false,
"displayName": "comment_text",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "sentiment",
"type": "string",
"display": true,
"required": false,
"displayName": "sentiment",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "sentiment_score",
"type": "string",
"display": true,
"required": false,
"displayName": "sentiment_score",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "keywords",
"type": "string",
"display": true,
"required": false,
"displayName": "keywords",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "like_count",
"type": "string",
"display": true,
"required": false,
"displayName": "like_count",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "published_at",
"type": "string",
"display": true,
"required": false,
"displayName": "published_at",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "author_channel_id",
"type": "string",
"display": true,
"required": false,
"displayName": "author_channel_id",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "author_channel_url",
"type": "string",
"display": true,
"required": false,
"displayName": "author_channel_url",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [
"comment_id"
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "appendOrUpdate",
"sheetName": {
"__rl": true,
"mode": "list",
"value": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"typeVersion": 4.6
},
{
"id": "ddfbf71a-2c92-415d-b3aa-fe087c9c9835",
"name": "Aggregate",
"type": "n8n-nodes-base.aggregate",
"position": [
2220,
120
],
"parameters": {
"options": {},
"fieldsToAggregate": {
"fieldToAggregate": [
{
"fieldToAggregate": "sentiment"
},
{
"fieldToAggregate": "sentiment_score"
},
{
"fieldToAggregate": "keywords"
}
]
}
},
"typeVersion": 1
},
{
"id": "655ebe44-a4df-4184-87b9-ac18c5734c62",
"name": "Code2",
"type": "n8n-nodes-base.code",
"position": [
2440,
120
],
"parameters": {
"jsCode": "// n8n Code node: Summarize sentiments & top keywords\nconst isAggregatedArrays =\n items.length === 1 &&\n items[0].json &&\n (Array.isArray(items[0].json.sentiment) || Array.isArray(items[0].json.keywords));\n\nfunction summarizeFromArrays(j) {\n const sentiments = Array.isArray(j.sentiment) ? j.sentiment : [];\n const scores = Array.isArray(j.sentiment_score) ? j.sentiment_score : [];\n let keywordsNested = Array.isArray(j.keywords) ? j.keywords : [];\n\n // ensure keywordsNested = array of arrays\n keywordsNested = keywordsNested.map(k =>\n Array.isArray(k) ? k : (typeof k === 'string' ? k.split(',') : [])\n );\n\n const total = Math.max(sentiments.length, keywordsNested.length, scores.length, 0);\n const cnt = { positive: 0, neutral: 0, negative: 0 };\n const kwFreq = {};\n\n for (let i = 0; i < total; i++) {\n const s = String((sentiments[i] ?? 'neutral')).toLowerCase();\n const normS = (s === 'positive' || s === 'negative' || s === 'neutral') ? s : 'neutral';\n cnt[normS]++;\n\n const kws = Array.isArray(keywordsNested[i]) ? keywordsNested[i] : [];\n for (let k of kws) {\n const key = String(k).toLowerCase().trim();\n if (!key) continue;\n kwFreq[key] = (kwFreq[key] || 0) + 1;\n }\n }\n\n const pct = v => total ? Math.round((v / total) * 100) : 0;\n const top_keywords = Object.entries(kwFreq)\n .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))\n .slice(0, 10)\n .map(([k, v]) => `${k} (${v})`);\n\n return {\n total_comments: total,\n positive: cnt.positive,\n neutral: cnt.neutral,\n negative: cnt.negative,\n positive_pct: pct(cnt.positive),\n neutral_pct: pct(cnt.neutral),\n negative_pct: pct(cnt.negative),\n top_keywords\n };\n}\n\nfunction summarizeFromItems(items) {\n const cnt = { positive: 0, neutral: 0, negative: 0 };\n const kwFreq = {};\n let total = 0;\n\n for (const it of items) {\n const s = String(it.json?.sentiment ?? 'neutral').toLowerCase();\n const normS = (s === 'positive' || s === 'negative' || s === 'neutral') ? s : 'neutral';\n cnt[normS]++;\n total++;\n\n const kws = Array.isArray(it.json?.keywords) ? it.json.keywords : [];\n for (let k of kws) {\n const key = String(k).toLowerCase().trim();\n if (!key) continue;\n kwFreq[key] = (kwFreq[key] || 0) + 1;\n }\n }\n\n const pct = v => total ? Math.round((v / total) * 100) : 0;\n const top_keywords = Object.entries(kwFreq)\n .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))\n .slice(0, 10)\n .map(([k, v]) => `${k} (${v})`);\n\n return {\n total_comments: total,\n positive: cnt.positive,\n neutral: cnt.neutral,\n negative: cnt.negative,\n positive_pct: pct(cnt.positive),\n neutral_pct: pct(cnt.neutral),\n negative_pct: pct(cnt.negative),\n top_keywords\n };\n}\n\nconst summary = isAggregatedArrays\n ? summarizeFromArrays(items[0].json)\n : summarizeFromItems(items);\n\nreturn [{ json: summary }];\n"
},
"typeVersion": 2
},
{
"id": "c1f271f1-bc1b-4447-9902-aacbfd0e9750",
"name": "Send a text message",
"type": "n8n-nodes-base.telegram",
"position": [
2660,
120
],
"parameters": {
"text": "==\ud83d\udcca YouTube Comments Report\nVideo : {{ $('Get row(s) in sheet').item.json.video_url }}\nTotal: {{ $json.total_comments }}\n\ud83d\udc4d Positive: {{$json.positive}} ({{ $json.positive_pct }}%)\n\ud83d\ude10 Neutral: {{$json.neutral}} ({{ $json.neutral_pct }}%)\n\ud83d\udc4e Negative: {{$json.negative}} ({{ $json.negative_pct }}%)\n\n\ud83d\udd0e Top Keywords:\n{{ $json.top_keywords.join(\", \") }}\n\n_Ran at {{new Date().toLocaleString()}}_\n",
"chatId": "YOUR_TELEGRAM_CHAT_ID",
"additionalFields": {
"appendAttribution": false
}
},
"typeVersion": 1.2
},
{
"id": "f010f65d-6c4d-4468-afcb-57f2a2f0cc9a",
"name": "No Operation, do nothing",
"type": "n8n-nodes-base.noOp",
"position": [
960,
-100
],
"parameters": {},
"typeVersion": 1
},
{
"id": "923d6e70-ef03-4533-b1f1-535f76f93ce6",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-660,
-400
],
"parameters": {
"width": 580,
"height": 1040,
"content": "# YouTube Comment Sentiment & Keyword Extractor\n\nThis workflow automatically retrieves comments from YouTube videos listed in Google Sheets, analyzes sentiment and keywords using an LLM, stores the results, then generates an aggregate summary and sends a report to Telegram.\n\n\n## \u2728 Key Features\n- **Automated Scheduling** \u2013 Runs the process at regular intervals.\n- **Google Sheets Integration** \u2013 Fetches a list of videos and stores analysis results directly in Google Sheets.\n- **YouTube Comment Retrieval** \u2013 Retrieves top-level comments (up to 100 per video; can be extended with pagination).\n- **Comment Parsing** \u2013 Flattens the API response into one item per comment.\n- **AI Sentiment & Keyword Analysis** \u2013 Powered by an LLM model to extract:\n - Sentiment (`positive`, `neutral`, `negative`)\n - Sentiment score (-1 to 1)\n - Keywords (cleaned from stopwords, emojis, hashtags)\n - Language (ISO 639-1, 2-letter code)\n- **Data Normalization** \u2013 Ensures LLM output is consistent and safe to write to the sheet.\n- **Telegram Reporting** \u2013 Sends a concise report for each video directly to a Telegram chat.\n\n---\n\n## \ud83d\udd10 Required Credentials\n- **Google Sheets OAuth2 API** \u2013 Read & write access to the spreadsheet.\n- **YouTube Data API Key** \u2013 To fetch comments via `commentThreads`.\n- **OpenRouter API Key** \u2013 To access the LLM model.\n- **Telegram Bot API** \u2013 To send reports via a Telegram bot.\n\n---\n\n## \ud83c\udf81 Benefits\n- **End-to-End Automation** \u2013 From comment retrieval to reporting, no manual steps required.\n- **No Duplication** \u2013 Each comment is uniquely tracked by `comment_id`.\n- **Data Transparency** \u2013 Raw and analyzed data stored neatly in Google Sheets.\n- **Actionable Insights** \u2013 Sentiment and keyword summaries ready for content strategy decisions.\n- **Scalable** \u2013 Can be extended for pagination, reply comment analysis, or additional metrics.\n- **Cross-Platform** \u2013 Direct Telegram integration for easy monitoring on any device.\n"
},
"typeVersion": 1
},
{
"id": "53fbf0dd-4131-4833-abca-96a4ca171241",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-40,
-220
],
"parameters": {
"color": 2,
"width": 660,
"height": 400,
"content": "- Run the workflow on a schedule.\n- Fetch the data row that contains the video_id and video_url from the spreadsheet.\n- Add a YouTube API key field so that subsequent records (HTTP requests to the YouTube API) can access it."
},
"typeVersion": 1
},
{
"id": "a7500b30-f211-4311-843e-0b94a1c7efa4",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
660,
-300
],
"parameters": {
"color": 4,
"width": 660,
"height": 560,
"content": "- Fetch data from the previous node and process it either one by one or in batches.\n- Call the YouTube Data API endpoint.\n- Receive the JSON results from the YouTube API and flatten the response so that each comment becomes a separate item."
},
"typeVersion": 1
},
{
"id": "da86e6c7-637a-4a02-8755-e687d22cb8f7",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1360,
-80
],
"parameters": {
"color": 5,
"width": 800,
"height": 560,
"content": "- Use the LLM model to analyze the comment text.\n- Ensure the LLM output follows a strict JSON structure based on the example schema, with no incorrect formats or keys.\n- Normalize the LLM JSON output.\n- Save the analysis results along with the comment metadata to Google Sheets."
},
"typeVersion": 1
},
{
"id": "83530f28-16df-418f-8763-5b6096b860e5",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
2180,
-80
],
"parameters": {
"color": 6,
"width": 640,
"height": 560,
"content": "- Combine values from multiple comment analysis columns.\n- Create a summary based on the aggregated results.\n- Send the summary report to a Telegram chat."
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "ab1692ad-3351-4c24-b8dd-f7416de30a65",
"connections": {
"Code": {
"main": [
[
{
"node": "Basic LLM Chain",
"type": "main",
"index": 0
}
]
]
},
"Code1": {
"main": [
[
{
"node": "Append or update row in sheet",
"type": "main",
"index": 0
}
]
]
},
"Code2": {
"main": [
[
{
"node": "Send a text message",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "Code2",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Basic LLM Chain": {
"main": [
[
{
"node": "Code1",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items": {
"main": [
[
{
"node": "No Operation, do nothing",
"type": "main",
"index": 0
}
],
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
},
"Youtube API Key": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Get row(s) in sheet",
"type": "main",
"index": 0
}
]
]
},
"Get row(s) in sheet": {
"main": [
[
{
"node": "Youtube API Key",
"type": "main",
"index": 0
}
]
]
},
"Send a text message": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"OpenRouter Chat Model": {
"ai_languageModel": [
[
{
"node": "Basic LLM Chain",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Structured Output Parser": {
"ai_outputParser": [
[
{
"node": "Basic LLM Chain",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"Append or update row in sheet": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Template Google Sheets: Click here to open
Source: https://n8n.io/workflows/7434/ — 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.
leads. Uses supabase, gmail, formTrigger, httpRequest. Webhook trigger; 62 nodes.
Automatically capture, categorize, and log expenses from receipts, PDFs, voice notes, or text — powered by AI and integrated with Telegram and Google Sheets.
This cutting-edge n8n workflow is a comprehensive automation solution designed to streamline various Instagram operations. It combines an intelligent AI chatbot for direct message management, automate
Job seekers who want to automate their entire job search pipeline — from discovering new listings to generating tailored resumes and cover letters — without manually browsing LinkedIn every day.
Effortlessly generate, review, and publish SEO-optimized blog posts to WordPress using AI and automation.