This workflow follows the Chainllm → 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": "WeChat-Daily-Digest-AI-Cost-Optimized",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 8
}
]
}
},
"id": "b0efabdd-948a-4e01-b233-68cd5f1906e8",
"name": "Run every day at 8am",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [
20,
-540
],
"timezone": "Asia/Shanghai"
},
{
"parameters": {
"url": "http://host.docker.internal:5030/api/v1/chatlog",
"sendQuery": true,
"specifyQuery": "json",
"jsonQuery": "={\n \"time\": \"{{ $json.date }}~{{ $json.date }}\",\n \"talker\": \"{{ $json.group_name }}\"\n}",
"options": {}
},
"id": "7341c497-4022-40e3-b8b2-0048700ccd10",
"name": "Fetch Yesterday's Chatlog",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
60,
-100
]
},
{
"parameters": {
"jsCode": "// Node: Parse & Structure Data (Repaired)\n// Input: Raw chat log text from the previous node.\n// Output: A single, structured JSON object with correctly parsed messages.\n\nconst rawLog = $json.data;\nconst talkerName = $('ConfigureChatParameters').first().json.group_name;\n\nif (!rawLog) {\n console.log(\"\u8f93\u5165\u6570\u636e\u4e3a\u7a7a\uff0c\u8bf7\u68c0\u67e5\u4e0a\u6e38\u8282\u70b9\u3002\");\n return { error: \"Input data is empty or not found.\" };\n}\n\n// \u83b7\u53d6\u6628\u5929\u7684\u65e5\u671f\u4f5c\u4e3a\u6240\u6709\u6d88\u606f\u7684\u65e5\u671f\u524d\u7f00\nconst datePrefix = $('ConfigureChatParameters').first().json.date;\n\n\n// \u6309\u4e00\u4e2a\u6216\u591a\u4e2a\u7a7a\u884c\u6765\u5206\u5272\u6d88\u606f\u5757\uff0c\u8fd9\u66f4\u53ef\u9760\nconst messageBlocks = rawLog.trim().split(/\\n\\s*\\n/);\n\nconst messages = [];\nfor (const block of messageBlocks) {\n if (block.trim() === \"\") continue;\n\n const lines = block.trim().split('\\n');\n const header = lines.shift(); // \u7b2c\u4e00\u884c\u662f \"\u53d1\u9001\u8005 HH:MM:SS\"\n\n // \u6b63\u5219\u8868\u8fbe\u5f0f\uff0c\u7528\u4e8e\u4ece header \u4e2d\u6355\u83b7\u53d1\u9001\u8005\u548c\u65f6\u95f4\n const match = header.match(/(.+) (\\d{2}:\\d{2}:\\d{2})$/);\n\n // \u5982\u679c\u7b2c\u4e00\u884c\u4e0d\u7b26\u5408 \"\u53d1\u9001\u8005 \u65f6\u95f4\" \u7684\u683c\u5f0f\uff0c\u5219\u8df3\u8fc7\u8fd9\u4e2a\u5757\n if (!match) {\n console.log(\"\u8df3\u8fc7\u65e0\u6548\u7684\u6d88\u606f\u5757:\", block);\n continue;\n }\n\n const senderName = match[1].trim();\n const timeStr = match[2];\n const content = lines.join('\\n').trim();\n\n // \u7ec4\u5408\u65e5\u671f\u548c\u65f6\u95f4\uff0c\u521b\u5efa\u5b8c\u6574\u7684ISO\u65f6\u95f4\u6233\n const isoTime = `${datePrefix}T${timeStr}+08:00`;\n\n messages.push({\n content: content,\n contents: null,\n sender_name: senderName,\n talker_name: talkerName,\n time: isoTime,\n seq: messages.length + 1\n });\n}\n\nconst now = new Date();\nconst result = {\n step: \"1_structured_data\",\n description: \"Parsed and structured data from raw text\",\n date: $('ConfigureChatParameters').first().json.date,\n timestamp: now.toISOString(),\n sources: {\n [talkerName]: {\n message_count: messages.length,\n messages: messages\n }\n },\n statistics: {\n total_sources: 1,\n total_messages: messages.length,\n sources_summary: {\n [talkerName]: messages.length\n }\n }\n};\n\nconsole.log(\"\u6210\u529f\u83b7\u53d6 \" + messages.length +\" \u6761\u6d88\u606f from \" + talkerName)\n\nreturn [{ json: result }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
300,
-100
],
"id": "02d16e25-f519-4fdb-b7f1-b97edcbf4f6a",
"name": "Parse & Structure Data"
},
{
"parameters": {
"jsCode": "// Node: Clean & Segment by Time (Repaired with Logs and Validation - Fixed)\n// Input: The structured JSON object from the \"Parse & Structure Data\" node.\n// Output: An array of time segments, with correct message counts.\n\nconst structuredData = $('Parse & Structure Data').first().json;\nconst sourceName = Object.keys(structuredData.sources)[0];\nconst allMessages = structuredData.sources[sourceName].messages;\n\nconsole.log(`\\n[blue]\u5f00\u59cb\u65f6\u95f4\u6bb5\u5206\u5272\u548c\u6e05\u6d17...[/blue]`);\nconsole.log(`[cyan]\u539f\u59cb\u6d88\u606f\u603b\u6570: ${allMessages.length} \u6761[/cyan]`);\n\nif (!allMessages || allMessages.length === 0) {\n console.log(\"\u6ca1\u6709\u6d88\u606f\u9700\u8981\u5904\u7406\u3002\");\n return [];\n}\n\n// 1. \u6e05\u6d17\u6d88\u606f\nconst cleanedMessages = allMessages.filter(msg => {\n const hasSender = msg.sender_name && msg.sender_name.trim() !== \"\";\n const hasContent = (msg.content && msg.content.trim() !== \"\") || (msg.contents && msg.contents !== null);\n \n // \u5f3a\u5316\u7cfb\u7edf\u6d88\u606f\u8fc7\u6ee4\u6761\u4ef6\n const isSystemMessage = (msg.sender_name === \"\" || msg.sender_name === \"\u7cfb\u7edf\u6d88\u606f\") || \n (msg.content && (\n msg.content.includes(\"\u52a0\u5165\u4e86\u7fa4\u804a\") ||\n msg.content.includes(\"\u64a4\u56de\u4e86\u4e00\u6761\u6d88\u606f\") ||\n msg.content.includes(\"\u62cd\u4e86\u62cd\") ||\n msg.content.includes(\"\u4e0e\u7fa4\u91cc\u5176\u4ed6\u4eba\u90fd\u4e0d\u662f\u670b\u53cb\u5173\u7cfb\")\n ));\n\n return hasSender && hasContent && !isSystemMessage;\n});\n\nconsole.log(`[cyan]\u6e05\u6d17\u540e\u6709\u6548\u6d88\u606f\u603b\u6570: ${cleanedMessages.length} \u6761 (\u8fc7\u6ee4\u6389\u7cfb\u7edf\u6d88\u606f\u7b49)[/cyan]`);\n\n// 2. \u5b9a\u4e49\u65f6\u95f4\u6bb5\nconst segmentsConfig = [\n { name: \"\u51cc\u6668\", start_hour: 0, end_hour: 6, messages: [] },\n { name: \"\u4e0a\u5348\", start_hour: 6, end_hour: 12, messages: [] },\n { name: \"\u4e0b\u5348\", start_hour: 12, end_hour: 18, messages: [] },\n { name: \"\u665a\u4e0a\", start_hour: 18, end_hour: 24, messages: [] }\n];\n\n// 3. \u5c06\u6e05\u6d17\u540e\u7684\u6d88\u606f\u653e\u5165\u65f6\u95f4\u6bb5\nfor (const message of cleanedMessages) {\n try {\n const msgHour = parseInt(message.time.substring(11, 13), 10);\n\n if (isNaN(msgHour)) {\n console.log(`[yellow]\u8b66\u544a: \u65e0\u6cd5\u89e3\u6790\u6d88\u606f\u65f6\u95f4\uff0c\u8df3\u8fc7: ${message.time}[/yellow]`);\n continue; \n }\n\n let assigned = false;\n for (const segment of segmentsConfig) {\n if (msgHour >= segment.start_hour && msgHour < segment.end_hour) {\n segment.messages.push(message);\n assigned = true;\n break;\n }\n }\n if (!assigned) {\n console.log(`[yellow]\u8b66\u544a: \u6d88\u606f\u672a\u5206\u914d\u5230\u4efb\u4f55\u65f6\u95f4\u6bb5\uff0c\u8df3\u8fc7: ${message.time} - ${message.sender_name}: ${message.content.substring(0, 50)}...[/yellow]`);\n }\n } catch (e) {\n console.log(`[red]\u9519\u8bef: \u5904\u7406\u6d88\u606f\u65f6\u53d1\u751f\u5f02\u5e38: ${message.time} - ${e.message}[/red]`);\n }\n}\n\n// 4. \u683c\u5f0f\u5316\u6700\u7ec8\u8f93\u51fa\nconst result = segmentsConfig\n .filter(s => s.messages.length > 0)\n .map(s => ({\n json: {\n name: s.name,\n start_hour: s.start_hour,\n end_hour: s.end_hour,\n messages_log: s.messages.map(m => `${m.sender_name}: ${m.content}`).join('\\n---\\n'),\n message_count: s.messages.length\n }\n }));\n\n// 5. \u6253\u5370\u6bcf\u4e2a\u65f6\u95f4\u6bb5\u7684\u65e5\u5fd7\nconsole.log(`\\n[blue]\u65f6\u95f4\u6bb5\u5206\u5272\u7ed3\u679c:[/blue]`);\nlet totalSegmentedMessages = 0;\nsegmentsConfig.forEach(s => {\n if (s.messages.length > 0) {\n console.log(` \ud83d\udcc5 ${s.name} (${s.start_hour}-${s.end_hour}\u65f6): ${s.messages.length} \u6761\u6d88\u606f`);\n totalSegmentedMessages += s.messages.length;\n }\n});\n\n// 6. \u6570\u636e\u9a8c\u8bc1\nconsole.log(`\\n[blue]\u6570\u636e\u9a8c\u8bc1:[/blue]`);\nconsole.log(` \u6e05\u6d17\u540e\u6d88\u606f\u603b\u6570: ${cleanedMessages.length} \u6761`);\nconsole.log(` \u5206\u6bb5\u540e\u6d88\u606f\u603b\u6570: ${totalSegmentedMessages} \u6761`);\n\nif (cleanedMessages.length === totalSegmentedMessages) {\n console.log(`[green]\u6570\u636e\u9a8c\u8bc1\u901a\u8fc7: \u6240\u6709\u6e05\u6d17\u540e\u7684\u6d88\u606f\u90fd\u5df2\u6210\u529f\u5206\u6bb5\u3002[/green]`);\n} else {\n console.log(`[red]\u6570\u636e\u9a8c\u8bc1\u5931\u8d25: \u6e05\u6d17\u540e\u6d88\u606f (${cleanedMessages.length}) \u4e0e\u5206\u6bb5\u540e\u6d88\u606f (${totalSegmentedMessages}) \u6570\u91cf\u4e0d\u5339\u914d\u3002[/red]`);\n // \u53ef\u4ee5\u9009\u62e9\u629b\u51fa\u9519\u8bef\u6765\u4e2d\u65ad\u5de5\u4f5c\u6d41\uff0c\u6216\u8005\u7ee7\u7eed\u6267\u884c\n // throw new Error(\"\u6d88\u606f\u6570\u91cf\u4e0d\u5339\u914d\uff0c\u6570\u636e\u5904\u7406\u5f02\u5e38\u3002\");\n}\n\nconsole.log(`[green]\u65f6\u95f4\u6bb5\u5206\u5272\u548c\u6e05\u6d17\u5b8c\u6210[/green]`);\n\nreturn result;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
920,
-100
],
"id": "b9d5acd4-0559-47af-8419-dae86fb52fc7",
"name": "Clean & Segment by Time"
},
{
"parameters": {
"content": "## \u7ed3\u6784\u5316\u521d\u59cb\u6570\u636e\n \n\u5929\u6570\u636e\u83b7\u53d6\u4e0e\u9884\u5904\u7406\uff08\u5305\u542b\u89e3\u6790\u3001\u6e05\u6d17\u3001\u5206\u6bb5\u3001\u94fe\u63a5\u63d0\u53d6\u3001\u6d3b\u8dc3\u7528\u6237\u7edf\u8ba1\u3001\u6d88\u606f\u6837\u672c\u51c6\u5907\uff09",
"height": 260,
"width": 860,
"color": 5
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
-200
],
"id": "00d281ec-7a40-4921-a4e4-a034d921d47e",
"name": "Sticky Note"
},
{
"parameters": {
"content": "## \u804a\u5929\u6570\u636e\u65f6\u95f4\u5206\u6bb5\n",
"height": 260,
"color": 2
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
860,
-200
],
"id": "3cef8b1c-c532-45c4-8c12-b28340a07cfc",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "## \u8bdd\u9898\u5206\u6790\n * \u5206\u800c\u6cbb\u4e4b: \u5c06\u957f\u65e5\u5fd7\u5206\u5272\u6210\u5c0f\u5757\uff0c\u9010\u4e2a\u5904\u7406\u3002\n * \u6210\u672c\u63a7\u5236: \u5728\u7c97\u52a0\u5de5\u9636\u6bb5\u4f7f\u7528\u5ec9\u4ef7\u6a21\u578b\u3002\n * \u7ed3\u6784\u5316\u8f93\u51fa: \u5f3a\u5236 AI \u8fd4\u56de JSON \u683c\u5f0f\uff0c\u4fbf\u4e8e\u540e\u7eed\u5904\u7406\u3002\n * \u8bdd\u9898\u805a\u5408: \u5c06\u6240\u6709\u65f6\u95f4\u6bb5\u7684\u8bdd\u9898\u5408\u5e76\uff0c\u4e3a\u6700\u7ec8\u62a5\u544a\u505a\u51c6\u5907\u3002\n",
"height": 420,
"width": 1140,
"color": 6
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
120
],
"id": "ae96b3b5-25af-4379-b108-b83905e1c5b8",
"name": "Sticky Note2"
},
{
"parameters": {
"batchSize": 1,
"options": {}
},
"id": "65cd41dc-69c0-47bb-b334-299a04eafc44",
"name": "Split in Batches",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 2,
"position": [
20,
260
]
},
{
"parameters": {
"promptType": "define",
"text": "=\u4f60\u662f\u4e13\u4e1a\u7684\u804a\u5929\u8bb0\u5f55\u5206\u6790\u4e13\u5bb6\u3002\u4f60\u7684\u4efb\u52a1\u662f\u5206\u6790\u7ed9\u5b9a\u7684\u804a\u5929\u8bb0\u5f55\u7247\u6bb5\uff0c\u5e76\u4e25\u683c\u6309\u7167\u8981\u6c42\u63d0\u53d6\u6838\u5fc3\u8bdd\u9898\u3002\n\n### \u6838\u5fc3\u5206\u6790\u539f\u5219 (\u5fc5\u987b\u4e25\u683c\u9075\u5b88)\n\n1. **\u8bdd\u9898\u8bc6\u522b\u6807\u51c6**:\n * **\u6570\u91cf\u95e8\u69db**: \u81f3\u5c11\u9700\u89813\u6761\u76f8\u5173\u7684\u6d88\u606f\u624d\u80fd\u6784\u6210\u4e00\u4e2a\u72ec\u7acb\u7684\u8bdd\u9898\u3002\n * **\u5185\u5bb9\u4ef7\u503c**: \u8bdd\u9898\u5185\u5bb9\u5fc5\u987b\u6709\u660e\u786e\u7684\u8ba8\u8bba\u4e3b\u9898\u548c\u4ef7\u503c\u3002\n * **\u65e0\u6548\u5185\u5bb9\u8fc7\u6ee4**: \u4e25\u7981\u5c06\u65e0\u610f\u4e49\u7684\u95ee\u5019\uff08\u5982\u201c\u65e9\u201d\u3001\u201c\u5728\u5417\u201d\uff09\u3001\u8868\u60c5\u7b26\u53f7\u3001\u6216\u7b80\u5355\u7684\u786e\u8ba4\uff08\u5982\u201c\u597d\u7684\u201d\u3001\u201c\u6536\u5230\u201d\uff09\u4f5c\u4e3a\u72ec\u7acb\u8bdd\u9898\u3002\n\n2. **\u8bdd\u9898\u8fb9\u754c\u5224\u65ad**:\n * **\u4e3b\u9898\u5ef6\u7eed**: \u5982\u679c\u6709\u65b0\u6210\u5458\u52a0\u5165\u8ba8\u8bba\uff0c\u4f46\u8ba8\u8bba\u7684\u8fd8\u662f\u540c\u4e00\u4e2a\u6838\u5fc3\u4e3b\u9898\uff0c\u8fd9\u5c5e\u4e8e**\u540c\u4e00\u8bdd\u9898**\u3002\n * **\u4e3b\u9898\u8f6c\u53d8**: \u5f53\u8ba8\u8bba\u7684\u7126\u70b9\u53d1\u751f\u660e\u663e\u3001\u4e0d\u76f8\u5173\u7684\u8f6c\u53d8\u65f6\uff0c\u5fc5\u987b\u521b\u5efa**\u65b0\u8bdd\u9898**\u3002\n * **\u65f6\u95f4\u95f4\u9694**: \u5982\u679c\u4e24\u6761\u6d88\u606f\u4e4b\u95f4\u6ca1\u6709\u76f4\u63a5\u5173\u8054\uff0c\u4e14\u65f6\u95f4\u95f4\u9694\u8d85\u8fc7 **30\u5206\u949f**\uff0c\u5e94\u89c6\u4e3a**\u65b0\u8bdd\u9898**\u3002\n\n### \u5185\u5bb9\u751f\u6210\u8981\u6c42\n\n1. **\u8bdd\u9898\u6807\u9898 (`topic_title`)**: \u751f\u6210\u4e00\u4e2a\u7b80\u6d01\u3001\u7cbe\u51c6\u3001\u80fd\u6982\u62ec\u8bdd\u9898\u6838\u5fc3\u7684\u6807\u9898\u3002\n2. **\u8bdd\u9898\u63cf\u8ff0 (`topic_description`)**:\n * **\u98ce\u683c**: \u4f7f\u7528\u751f\u52a8\u3001\u63a5\u5730\u6c14\u3001\u7565\u5e26\u201c\u9a9a\u8bdd\u201d\u98ce\u683c\u7684\u5e74\u8f7b\u4eba\u7f51\u7edc\u7528\u8bed\u3002\n * **\u5185\u5bb9**: \u63cf\u8ff0\u5fc5\u987b\u51c6\u786e\u53cd\u6620\u8ba8\u8bba\u7684\u5b9e\u9645\u5185\u5bb9\u3001\u5173\u952e\u4fe1\u606f\u548c\u4e3b\u8981\u89c2\u70b9\u3002\n * **\u957f\u5ea6**: \u4e25\u683c\u63a7\u5236\u5728 100 \u5230 150 \u5b57\u4e4b\u95f4\u3002\n\n### \u7edd\u5bf9\u8f93\u51fa\u8981\u6c42 (\u6700\u91cd\u8981)\n\n1. **\u7eafJSON**: **\u53ea\u5141\u8bb8**\u8f93\u51fa\u6709\u6548\u7684JSON\u5bf9\u8c61\u3002\n2. **\u7981\u6b62Markdown**: **\u4e25\u7981**\u5728\u8f93\u51fa\u7684\u4efb\u4f55\u4f4d\u7f6e\u4f7f\u7528 ` ```json ` \u6216 ` ``` ` \u8fdb\u884c\u5305\u88f9\u3002\n3. **\u7981\u6b62\u89e3\u91ca**: **\u4e25\u7981**\u5728JSON\u5bf9\u8c61\u524d\u540e\u6dfb\u52a0\u4efb\u4f55\u5f62\u5f0f\u7684\u89e3\u91ca\u6027\u6587\u5b57\u6216\u6ce8\u91ca\u3002\n4. **\u7fa4\u7ec4\u540d**: {{ $('ConfigureChatParameters').item.json.group_name }}\n\n---\n### \u8f93\u51faJSON\u683c\u5f0f (\u4e25\u683c\u9075\u5b88\u6b64\u7ed3\u6784)\n\n1. \u5fc5\u987b\u8fd4\u56de\u6709\u6548\u7684 JSON \u683c\u5f0f\n2. \u4e0d\u8981\u5305\u542b\u4efb\u4f55\u89e3\u91ca\u6587\u5b57\u6216 markdown \u683c\u5f0f\n3. \u4e0d\u8981\u4f7f\u7528 ```json``` \u4ee3\u7801\u5757\n4. \u4e25\u683c\u6309\u7167\u4ee5\u4e0b\u683c\u5f0f\u8fd4\u56de\n\nJSON \u683c\u5f0f\u793a\u4f8b\uff1a\n{\n \"analysis_id\": \"\u7fa4\u7ec4\u540d_\u65e5\u671f_\u65f6\u95f4\u6bb5_analysis\",\n \"meta\": {\n \"source\": \"\u7fa4\u7ec4\u540d\",\n \"date\": \"\u65e5\u671f\", \n \"segment\": \"\u65f6\u95f4\u6bb5\",\n \"total_messages\": 50\n },\n \"topics\": [\n {\n \"topic_id\": \"\u8bdd\u98981\u7684ID\",\n \"topic_title\": \"\u8bdd\u98981\u7684\u6807\u9898\",\n \"topic_description\": \"\u8bdd\u98981\u7684\u8be6\u7ec6\u63cf\u8ff0\uff0c\u5305\u542b\u5173\u952e\u8bcd\u548c\u4e3b\u8981\u5185\u5bb9\uff0c\u81f3\u5c1150\u5b57\",\n \"is_off_topic\": false,\n \"topic_start_time\": \"\u8bdd\u9898\u5f00\u59cb\u65f6\u95f4 (ISO\u683c\u5f0f\uff0c\u4f8b\u5982: 2023-07-03T14:30:00+08:00)\",\n \"topic_end_time\": \"\u8bdd\u9898\u7ed3\u675f\u65f6\u95f4 (ISO\u683c\u5f0f\uff0c\u4f8b\u5982: 2023-07-03T15:00:00+08:00)\",\n \"message_seqs\": [\"\u6784\u6210\u8be5\u8bdd\u9898\u7684\u6240\u6709\u6d88\u606f\u7684seq\u5217\u8868\uff0c\u4f8b\u5982: 1, 5, 8\"]\n }\n ]\n}\n\n\u91cd\u8981\uff1a\u53ea\u8fd4\u56de JSON\uff0c\u4e0d\u8981\u4efb\u4f55\u5176\u4ed6\u5185\u5bb9\u3002\n\n---\n### \u5f85\u5206\u6790\u7684\u804a\u5929\u8bb0\u5f55\n\n{{ JSON.stringify($json) }}",
"hasOutputParser": true,
"batching": {}
},
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.7,
"position": [
560,
240
],
"id": "0c08e2ee-bb11-45c9-80ee-836b061bb6ef",
"name": "Extract Topics (Low Cost AI)"
},
{
"parameters": {
"schemaType": "manual",
"inputSchema": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"type\": \"object\",\n \"required\": [\"analysis_id\", \"meta\", \"topics\"],\n \"properties\": {\n \"analysis_id\": {\n \"type\": \"string\",\n \"description\": \"\u552f\u4e00\u5206\u6790\u6807\u8bc6\u7b26\uff0c\u683c\u5f0f\u4e3a\u7fa4\u7ec4\u540d_\u65e5\u671f_\u65f6\u95f4\u6bb5_analysis\",\n \"pattern\": \"^.+_.+_.+_analysis$\"\n },\n \"meta\": {\n \"type\": \"object\",\n \"required\": [\"source\", \"date\", \"segment\", \"total_messages\"],\n \"properties\": {\n \"source\": {\n \"type\": \"string\",\n \"description\": \"\u6570\u636e\u6765\u6e90\u7684\u7fa4\u7ec4\u540d\u79f0\"\n },\n \"date\": {\n \"type\": \"string\",\n \"description\": \"\u5206\u6790\u65e5\u671f\",\n \"format\": \"date\"\n },\n \"segment\": {\n \"type\": \"string\",\n \"description\": \"\u65f6\u95f4\u6bb5\u6807\u8bc6\"\n },\n \"total_messages\": {\n \"type\": \"integer\",\n \"description\": \"\u6d88\u606f\u603b\u6570\u7edf\u8ba1\",\n \"minimum\": 0\n }\n }\n },\n \"topics\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"topic_id\", \"topic_title\", \"topic_description\", \"is_off_topic\", \"topic_start_time\", \"topic_end_time\", \"message_seqs\"],\n \"properties\": {\n \"topic_id\": {\n \"type\": \"string\",\n \"description\": \"\u8bdd\u9898\u552f\u4e00\u6807\u8bc6\u7b26\"\n },\n \"topic_title\": {\n \"type\": \"string\",\n \"description\": \"\u8bdd\u9898\u6807\u9898\"\n },\n \"topic_description\": {\n \"type\": \"string\",\n \"description\": \"\u8bdd\u9898\u8be6\u7ec6\u63cf\u8ff0\uff0c\u5305\u542b\u5173\u952e\u8bcd\u548c\u4e3b\u8981\u5185\u5bb9\uff0c\u81f3\u5c1150\u5b57\",\n \"minLength\": 50\n },\n \"is_off_topic\": {\n \"type\": \"boolean\",\n \"description\": \"\u6807\u8bb0\u662f\u5426\u504f\u79bb\u4e3b\u9898\"\n },\n \"topic_start_time\": {\n \"type\": \"string\",\n \"description\": \"\u8bdd\u9898\u5f00\u59cb\u65f6\u95f4\",\n \"format\": \"date-time\"\n },\n \"topic_end_time\": {\n \"type\": \"string\",\n \"description\": \"\u8bdd\u9898\u7ed3\u675f\u65f6\u95f4\",\n \"format\": \"date-time\"\n },\n \"message_seqs\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"description\": \"\u6d88\u606f\u5e8f\u5217\u53f7\"\n },\n \"minItems\": 1\n }\n }\n },\n \"minItems\": 1\n }\n }\n}"
},
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"position": [
720,
420
],
"id": "3c1ff1c6-50d3-4b17-a925-56a188f4ba44",
"name": "Structured Output Parser"
},
{
"parameters": {
"jsCode": "// Author: Gemini\n// Date: 2025-07-04\n// Description: This version corrects the data access path within the .map() function.\n// It now correctly reads `item.json.output` based on the actual output from the LLM node,\n// resolving the 'Cannot read properties of undefined' error.\n\n/**\n * Robustly maps a segment string to a Chinese time segment name.\n * @param {string} segmentInput - The segment string, e.g., \"19:30-20:00\" or \"\u508d\u665a\".\n * @returns {string} The corresponding Chinese segment name, e.g., \"\u665a\u4e0a\".\n */\nfunction mapTimeToChineseSegment(segmentInput) {\n if (!segmentInput || typeof segmentInput !== 'string') {\n return \"\u672a\u77e5\u65f6\u6bb5\";\n }\n const startHour = parseInt(segmentInput.split(':')[0], 10);\n if (isNaN(startHour)) {\n return segmentInput;\n }\n if (startHour >= 0 && startHour < 6) return \"\u6df1\u591c\";\n if (startHour >= 6 && startHour < 9) return \"\u65e9\u6668\";\n if (startHour >= 9 && startHour < 12) return \"\u4e0a\u5348\";\n if (startHour >= 12 && startHour < 14) return \"\u4e2d\u5348\";\n if (startHour >= 14 && startHour < 18) return \"\u4e0b\u5348\";\n if (startHour >= 18 && startHour < 22) return \"\u665a\u4e0a\";\n if (startHour >= 22 && startHour <= 23) return \"\u6df1\u591c\";\n return \"\u672a\u77e5\u65f6\u6bb5\";\n}\n\n// --- Main Logic (Rebuilt) ---\n\n// **THE ONLY FIX IS ON THIS LINE**\n// Correctly access the data from the actual LLM output structure.\nconst analysisResults = items.map(item => item.json.output);\n\nif (!analysisResults || analysisResults.length === 0) {\n return { json: { error: \"Input data is empty or invalid.\" } };\n}\n\nconst all_topics = [];\nconst sources_info = {};\nconst segments_info = {};\nlet topic_counter = 1;\n\n// A single, robust loop to process all inputs and aggregate correctly.\nfor (const result of analysisResults) {\n if (!result || !result.topics || !Array.isArray(result.topics)) {\n continue;\n }\n\n const source_name = result.meta.source;\n const chinese_segment_name = mapTimeToChineseSegment(result.meta.segment);\n const created_at = result.created_at || new Date().toISOString();\n const segment_key = `${source_name}_${chinese_segment_name}`;\n\n // --- Step 1: Aggregate Metadata in Real-time ---\n if (!segments_info[segment_key]) {\n segments_info[segment_key] = {\n source_name: source_name,\n segment_name: chinese_segment_name,\n analysis_id: `${source_name}_${result.meta.date}_${chinese_segment_name}_analysis`,\n created_at: created_at,\n meta: {\n source: source_name,\n date: result.meta.date,\n segment: chinese_segment_name,\n total_messages: 0,\n },\n topics_count: 0,\n };\n }\n segments_info[segment_key].topics_count += result.topics.length;\n segments_info[segment_key].meta.total_messages += (result.meta.total_messages || 0);\n\n // --- Step 2: Process Topics and Link to the *Correct* Aggregated Metadata ---\n for (const topic of result.topics) {\n const topic_seq = topic.topic_seq || [];\n const message_count = topic_seq.length;\n\n const topic_data = {\n physical_id: `topic_${String(topic_counter).padStart(3, '0')}`,\n original_topic_id: topic.topic_id,\n topic_title: topic.topic_title,\n topic_description: topic.topic_description,\n is_off_topic: topic.is_off_topic,\n topic_seq: topic_seq,\n message_count: (topic.message_seqs || []).length,\n chat_count: (topic.message_seqs || []).length,\n topic_start_time: topic.topic_start_time || new Date().toISOString(),\n topic_end_time: topic.topic_end_time || new Date().toISOString(),\n source_name: source_name,\n segment_name: chinese_segment_name,\n analysis_id: segments_info[segment_key].analysis_id,\n created_at: created_at,\n analysis_meta: segments_info[segment_key].meta,\n };\n\n all_topics.push(topic_data);\n topic_counter++;\n }\n}\n\n// --- Step 3: Final Aggregation for sources_metadata (can be done after the loop) ---\nall_topics.forEach(topic => {\n if (!sources_info[topic.source_name]) {\n sources_info[topic.source_name] = { source_name: topic.source_name, segments: [], total_topics: 0 };\n }\n if (!sources_info[topic.source_name].segments.includes(topic.segment_name)) {\n sources_info[topic.source_name].segments.push(topic.segment_name);\n }\n});\nObject.keys(sources_info).forEach(sourceName => {\n sources_info[sourceName].total_topics = all_topics.filter(t => t.source_name === sourceName).length;\n});\n\n\n// --- Final Assembly ---\nconst on_topic_count = all_topics.filter(t => !t.is_off_topic).length;\nconst off_topic_count = all_topics.filter(t => t.is_off_topic).length;\nconst total_message_seqs = all_topics.reduce((sum, t) => sum + t.message_count, 0);\nconst total_original_messages = Object.values(segments_info).reduce((sum, s) => sum + s.meta.total_messages, 0);\nconst topics_by_source = Object.entries(sources_info).reduce((acc, [key, value]) => {\n acc[key] = value.total_topics;\n return acc;\n}, {});\n\nconst physical_merged = {\n step: \"2_topic_merged\",\n description: \"\u65f6\u95f4\u5206\u6bb5\u5206\u6790\u540e\u7684\u6240\u6709\u8bdd\u9898\u7269\u7406\u5408\u5e76\uff0c\u672a\u8fdb\u884c\u8bed\u4e49\u5408\u5e76\",\n merge_timestamp: new Date().toISOString(),\n merge_date: analysisResults[0]?.meta?.date || new Date().toISOString().split('T')[0],\n merge_type: \"physical\",\n total_topics: all_topics.length,\n total_sources: Object.keys(sources_info).length,\n total_segments: Object.keys(segments_info).length,\n all_topics: all_topics,\n sources_metadata: sources_info,\n segments_metadata: segments_info,\n statistics: {\n on_topic_count,\n off_topic_count,\n total_message_seqs,\n total_original_messages,\n topics_by_source\n }\n};\n\nreturn { json: physical_merged };"
},
"id": "1ea63cbf-cd4f-4f95-8876-99fbae4b4bd8",
"name": "Merge & Deduplicate Topics",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
940,
280
]
},
{
"parameters": {
"promptType": "define",
"text": "=\u4f60\u662f\u804a\u5929\u8bdd\u9898\u667a\u80fd\u5408\u5e76\u4e13\u5bb6\u3002\u4f60\u7684\u6838\u5fc3\u4efb\u52a1\u662f\u5206\u6790\u5df2\u7269\u7406\u5408\u5e76\u7684\u8bdd\u9898\u5217\u8868\uff0c\u627e\u51fa\u8bed\u4e49\u76f8\u5173\u7684\u8bdd\u9898\uff0c\u5e76\u8f93\u51fa\u4e00\u4e2a\u5305\u542b\u5408\u5e76\u5173\u7cfb\u548cAI\u603b\u7ed3\u5185\u5bb9\u7684\u65b0JSON\u3002\n\n### \u6838\u5fc3\u5408\u5e76\u539f\u5219 (\u5fc5\u987b\u4e25\u683c\u9075\u5b88)\n\n1. **\u8bed\u4e49\u4e3a\u738b**: \u5fc5\u987b\u57fa\u4e8e\u8bdd\u9898\u63cf\u8ff0 (`topic_description`) \u7684**\u6df1\u5c42\u8bed\u4e49**\u8fdb\u884c\u5408\u5e76\uff0c\u800c\u4e0d\u662f\u7b80\u5355\u7684\u5173\u952e\u8bcd\u5339\u914d\u3002\u4f8b\u5982\uff0c\u201cClaude\u8fde\u4e0d\u4e0a\u201d\u548c\u201c\u6211\u7684Claude\u4eca\u5929\u54cd\u5e94\u5f88\u6162\u201d\u5e94\u8be5\u5408\u5e76\uff0c\u4f46\u201cClaude\u4f7f\u7528\u6280\u5de7\u201d\u548c\u201cGemini\u4f7f\u7528\u6280\u5de7\u201d\u5219**\u7edd\u5bf9\u4e0d\u80fd**\u5408\u5e76\u3002\n2. **\u5408\u5e76\u6761\u4ef6**\uff1a\n - \u8bed\u4e49\u76f8\u4f3c\u5ea6 > 0.7\n - \u53c2\u4e0e\u8005\u91cd\u53e0\u5ea6 > 0.3\n - \u65f6\u95f4\u8de8\u5ea6\u5408\u7406\uff08\u4e0d\u8d85\u8fc74\u5c0f\u65f6\uff09\n3. **\u4fdd\u5b88\u7b56\u7565**: \n - \u5b81\u53ef\u4fdd\u7559\u66f4\u591a\u72ec\u7acb\u7684\u3001\u6709\u4ef7\u503c\u7684\u5c0f\u8bdd\u9898\uff0c\u4e5f\u7edd\u4e0d\u9519\u8bef\u5730\u5c06\u4e0d\u76f8\u5173\u7684\u8bdd\u9898\u5408\u5e76\u6210\u4e00\u4e2a\u6a21\u7cca\u7684\u5927\u6742\u70e9\u3002\n - \u5982\u679c\u4f60\u6ca1\u670970%\u4ee5\u4e0a\u7684\u628a\u63e1\uff0c\u5c31\u9009\u62e9\u4e0d\u5408\u5e76\u3002\n4. **\u8d28\u91cf\u63d0\u5347**: \n - \u5408\u5e76\u540e\u8bdd\u9898\u5e94\u8be5\u6bd4\u539f\u8bdd\u9898\u66f4\u6709\u4ef7\u503c\uff0c\u5176\u6807\u9898\u548c\u63cf\u8ff0\u5fc5\u987b\u6bd4\u539f\u59cb\u8bdd\u9898\u66f4\u5177\u4fe1\u606f\u91cf\u3001\u66f4\u7cbe\u70bc\u3001\u66f4\u80fd\u4f53\u73b0\u8ba8\u8bba\u7684\u7cbe\u534e\n - \u907f\u514d\u4e3a\u4e86\u5408\u5e76\u800c\u5408\u5e76\uff0c\u4fdd\u6301\u8bdd\u9898\u7684\u72ec\u7acb\u6027\n - \u5408\u5e76\u6570\u91cf\u63a7\u5236\u57282-4\u4e2a\u8bdd\u9898\u4e4b\u95f4\n\n### \u5185\u5bb9\u751f\u6210\u8981\u6c42\n\n1. **\u5408\u5e76\u540e\u6807\u9898 (`merged_title`)**: \u751f\u6210\u4e00\u4e2a\u5168\u65b0\u7684\u3001\u9ad8\u5ea6\u6982\u62ec\u7684\u3001\u80fd\u4f53\u73b0\u8ba8\u8bba\u6838\u5fc3\u7684\u7cbe\u786e\u6807\u9898\u3002\n2. **\u5408\u5e76\u540e\u63cf\u8ff0 (`merged_description`)**: **\u4e25\u683c\u57fa\u4e8e**\u6240\u6709\u88ab\u5408\u5e76\u8bdd\u9898\u7684**\u539f\u59cb\u5185\u5bb9**\u8fdb\u884c\u603b\u7ed3\u3002\u5fc5\u987b\u6e05\u6670\u5730\u8bf4\u660e\u8ba8\u8bba\u7684\u5b8c\u6574\u8109\u7edc\u3001\u6838\u5fc3\u89c2\u70b9\u3001\u4e3b\u8981\u95ee\u9898\u548c\u7ed3\u8bba\u3002**\u4e25\u7981**\u7f16\u9020\u3001\u81c6\u6d4b\u6216\u6dfb\u52a0\u4efb\u4f55\u539f\u59cb\u5bf9\u8bdd\u4e2d\u4e0d\u5b58\u5728\u7684\u4fe1\u606f\u3002**\u4e25\u7981**\u51fa\u73b0\u201c\u5408\u5e76\u4e86X\u4e2a\u8bdd\u9898\u201d\u8fd9\u79cd\u7a7a\u6d1e\u7684\u683c\u5f0f\u5316\u63cf\u8ff0\u3002\n3. **\u8bdd\u9898\u6807\u7b7e (`topic_tags`)**: \u5229\u7528\u4f60\u6700\u65b0\u7684\u77e5\u8bc6\u5e93\uff0c\u4e3a\u6bcf\u4e2a\u5408\u5e76\u540e\u7684\u8bdd\u9898\u751f\u62102-4\u4e2a\u4e0e\u65f6\u4ff1\u8fdb\u7684\u3001\u4e1a\u5185\u8ba4\u53ef\u7684\u6807\u7b7e\u3002\u4f8b\u5982\uff1a`RAG`, `Fine-tuning`, `AI Agent`, `Claude 3.5 Sonnet`, `n8n` \u7b49\u3002\n\n### \u7edd\u5bf9\u8f93\u51fa\u8981\u6c42 (\u6700\u91cd\u8981)\n\n1. **\u7eafJSON**: **\u53ea\u5141\u8bb8**\u8f93\u51fa\u6709\u6548\u7684JSON\u5bf9\u8c61\u3002\n2. **\u7981\u6b62Markdown**: **\u4e25\u7981**\u5728\u8f93\u51fa\u7684\u4efb\u4f55\u4f4d\u7f6e\u4f7f\u7528 ` ```json ` \u6216 ` ``` ` \u8fdb\u884c\u5305\u88f9\u3002\n3. **\u7981\u6b62\u89e3\u91ca**: **\u4e25\u7981**\u5728JSON\u5bf9\u8c61\u524d\u540e\u6dfb\u52a0\u4efb\u4f55\u5f62\u5f0f\u7684\u89e3\u91ca\u6027\u6587\u5b57\u6216\u6ce8\u91ca\u3002\n\n---\n### \u8f93\u51faJSON\u683c\u5f0f (\u4e25\u683c\u9075\u5b88\u6b64\u7ed3\u6784)\n\n\u4f60\u7684\u54cd\u5e94\u5fc5\u987b\u662f\u4e00\u4e2a\u6709\u6548\u7684 JSON \u5bf9\u8c61\uff0c\u4e14\u53ea\u80fd\u662f JSON \u5bf9\u8c61\u3002\u4e0d\u8981\u5305\u542b\u4efb\u4f55\u989d\u5916\u7684\u6587\u672c\u3001\u89e3\u91ca\u6216 Markdown \u4ee3\u7801\u5757\uff08\u4f8b\u5982\uff0c```json\uff09\u3002\n\n```json\n{\n \"merged_topics\": [\n {\n \"semantic_topic_id\": \"merged_001\",\n \"merged_title\": \"\u57fa\u4e8e\u8bed\u4e49\u5206\u6790\u7684\u7cbe\u786e\u6807\u9898\",\n \"merged_description\": \"\u7efc\u5408\u63cf\u8ff0\uff1a\u8be6\u7ec6\u8bf4\u660e\u5408\u5e76\u540e\u8bdd\u9898\u7684\u5b8c\u6574\u5185\u5bb9\u8109\u7edc\u3001\u6838\u5fc3\u89c2\u70b9\u3001\u8ba8\u8bba\u8303\u56f4\u548c\u4e3b\u8981\u7ed3\u8bba\u3002\",\n \"is_off_topic\": false,\n \"semantic_category\": \"\u8ba8\u8bba\u7c7b\u578b (\u4f8b\u5982: '\u95ee\u9898\u6c42\u52a9', '\u7ecf\u9a8c\u5206\u4eab', '\u6280\u672f\u63a2\u8ba8')\",\n \"topic_tags\": [\"AI\u751f\u6210\u6807\u7b7e1\", \"AI\u751f\u6210\u6807\u7b7e2\"],\n \"merged_from\": [\n {\n \"physical_id\": \"\u88ab\u5408\u5e76\u7684\u539f\u59cb\u8bdd\u9898ID (\u4f8b\u5982: topic_001)\",\n \"original_title\": \"\u88ab\u5408\u5e76\u7684\u539f\u59cb\u8bdd\u9898\u7684\u6807\u9898\"\n }\n ],\n \"relevance_score\": 0.85,\n \"confidence_level\": \"high\"\n }\n ],\n \"merge_metadata\": {\n \"merge_algorithm\": \"deep_semantic_analysis\",\n \"total_merges_performed\": 1,\n \"compression_achieved\": \"50%\"\n }\n}\n```\n\n---\n### \u5f85\u5206\u6790\u7684\u7269\u7406\u5408\u5e76\u540e\u7684\u8bdd\u9898\u6570\u636e\n\n{{ JSON.stringify($json) }}",
"hasOutputParser": true,
"batching": {}
},
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.7,
"position": [
1220,
80
],
"id": "f091a273-6475-40a5-b7df-99d4fb12c1f1",
"name": "AI Semantic Merge"
},
{
"parameters": {
"jsonSchemaExample": "{\n \"merged_topics\": [\n {\n \"semantic_topic_id\": \"merged_001\",\n \"merged_title\": \"\u57fa\u4e8e\u8bed\u4e49\u5206\u6790\u7684\u7cbe\u786e\u6807\u9898\",\n \"merged_description\": \"\u7efc\u5408\u63cf\u8ff0\uff1a\u8be6\u7ec6\u8bf4\u660e\u5408\u5e76\u540e\u8bdd\u9898\u7684\u5b8c\u6574\u5185\u5bb9\u8109\u7edc\u3001\u6838\u5fc3\u89c2\u70b9\u3001\u8ba8\u8bba\u8303\u56f4\u548c\u4e3b\u8981\u7ed3\u8bba\u3002\",\n \"is_off_topic\": false,\n \"semantic_category\": \"\u8ba8\u8bba\u7c7b\u578b (\u4f8b\u5982: '\u95ee\u9898\u6c42\u52a9', '\u7ecf\u9a8c\u5206\u4eab', '\u6280\u672f\u63a2\u8ba8')\",\n \"topic_tags\": [\"AI\u751f\u6210\u6807\u7b7e1\", \"AI\u751f\u6210\u6807\u7b7e2\"],\n \"merged_from\": [\n {\n \"physical_id\": \"\u88ab\u5408\u5e76\u7684\u539f\u59cb\u8bdd\u9898ID (\u4f8b\u5982: topic_001)\",\n \"original_title\": \"\u88ab\u5408\u5e76\u7684\u539f\u59cb\u8bdd\u9898\u7684\u6807\u9898\"\n }\n ],\n \"relevance_score\": 0.85,\n \"confidence_level\": \"high\"\n }\n ],\n \"merge_metadata\": {\n \"merge_algorithm\": \"deep_semantic_analysis\",\n \"total_merges_performed\": 1,\n \"compression_achieved\": \"50%\"\n }\n}"
},
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"position": [
1420,
400
],
"id": "00c38755-66ab-4e1d-99f4-e096f8c1e2d7",
"name": "Structured Output Parser1"
},
{
"parameters": {
"content": "## \u667a\u80fd\u8bdd\u9898\u5f52\u5e76\n\n- \u5c06\u76f8\u4f3c\u6216\u76f8\u5173\u7684\u8bdd\u9898\u8fdb\u884c\u667a\u80fd\u5408\u5e76\uff0c\u5f62\u6210\u66f4\u5b8f\u89c2\u3001\u6709\u610f\u4e49\u7684\u8bdd\u9898\u3002\n- \u5c06\u6240\u6709\u5904\u7406\u8fc7\u7684\u8bdd\u9898\u6570\u636e\u7ec4\u88c5\u6210\u6700\u7ec8\u62a5\u544a\u6240\u9700\u7684\u7ed3\u6784\u6216\u5185\u5bb9\u3002\n",
"height": 740,
"width": 620,
"color": 4
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1160,
-200
],
"id": "dc05f681-cd26-4761-8823-41101dd488f9",
"name": "Sticky Note3"
},
{
"parameters": {
"jsCode": "// Author: Gemini\n// Date: 2025-07-05\n// Description: This version integrates all_links, active_users, and raw_message_sample\n// from upstream nodes into the final report structure.\n\n/**\n * Robustly maps a segment string to a Chinese time segment name.\n * This function is likely from an upstream node, but included for completeness if needed.\n * @param {string} segmentInput - The segment string, e.g., \"19:30-20:00\" or \"\u508d\u665a\".\n * @returns {string} The corresponding Chinese segment name, e.g., \"\u665a\u4e0a\".\n */\nfunction mapTimeToChineseSegment(segmentInput) {\n if (!segmentInput || typeof segmentInput !== 'string') {\n return \"\u672a\u77e5\u65f6\u6bb5\";\n }\n const startHour = parseInt(segmentInput.split(':')[0], 10);\n if (isNaN(startHour)) {\n return segmentInput;\n }\n if (startHour >= 0 && startHour < 6) return \"\u6df1\u591c\";\n if (startHour >= 6 && startHour < 9) return \"\u65e9\u6668\";\n if (startHour >= 9 && startHour < 12) return \"\u4e0a\u5348\";\n if (startHour >= 12 && startHour < 14) return \"\u4e2d\u5348\";\n if (startHour >= 14 && startHour < 18) return \"\u4e0b\u5348\";\n if (startHour >= 18 && startHour < 22) return \"\u665a\u4e0a\";\n if (startHour >= 22 && startHour <= 23) return \"\u6df1\u591c\";\n return \"\u672a\u77e5\u65f6\u6bb5\";\n}\n\n// --- Main Logic ---\n\n// Step 1: Get the required data sources\n// $input.first() refers to the first input connected to this node (likely \"AI Semantic Merge\")\nconst aiMergeResult = $input.first().json.output;\n// $('Node Name') refers to data from a specific node by its name\nconst physicalMergeResult = $('Merge & Deduplicate Topics').first().json;\n\n// Get data from the newly added nodes\n// Ensure 'Extract Links & Active Users' is the exact name of your node in n8n\nconst linksAndUsersData = $('Extract Links & Active Users').first().json;\nconst all_links = linksAndUsersData.all_links;\nconst active_users = linksAndUsersData.active_users;\n\n// Ensure 'Prepare Raw Message Sample' is the exact name of your node in n8n\nconst rawSampleData = $('Prepare Raw Message Sample').first().json;\nconst raw_message_sample = rawSampleData.raw_message_sample;\n\n\nif (!aiMergeResult || !physicalMergeResult || !linksAndUsersData || !rawSampleData) {\n return { json: { error: \"Missing one or more required inputs. Check node connections and names.\" } };\n}\n\n// Step 2: Create a fast lookup map for all original topics\nconst allTopicsMap = new Map(physicalMergeResult.all_topics.map(t => [t.physical_id, t]));\n\n// Step 3: Iterate through the AI's decisions and assemble the enriched topics\nconst enhanced_merged_topics = [];\nfor (const aiTopic of aiMergeResult.merged_topics) {\n const combined_seqs_nested = [];\n const seq_source_mapping = {};\n\n for (const source of aiTopic.merged_from) {\n const physical_id = source.physical_id;\n const originalTopic = allTopicsMap.get(physical_id);\n\n if (originalTopic) {\n combined_seqs_nested.push(originalTopic.topic_seq);\n for (const seq of originalTopic.topic_seq) {\n if (!seq_source_mapping[seq]) {\n seq_source_mapping[seq] = {\n source_name: originalTopic.source_name,\n segment_name: originalTopic.segment_name,\n analysis_id: originalTopic.analysis_id,\n topic_start_time: originalTopic.topic_start_time,\n topic_end_time: originalTopic.topic_end_time\n };\n }\n }\n }\n }\n\n // Flatten combined_seqs_nested and remove duplicates\n const combined_message_seqs = [...new Set(combined_seqs_nested.flat())].sort((a, b) => a - b);\n\n // Calculate total_combined_messages and chat_count\n const total_combined_messages = combined_message_seqs.length;\n const chat_count = total_combined_messages; // Assuming chat_count is same as message count for now\n\n // Determine message_time_range\n let earliest_seq_time = null;\n let latest_seq_time = null;\n\n if (combined_message_seqs.length > 0) {\n // Find the earliest and latest time from the original messages based on seq\n // This requires access to the original messages from \"Parse & Structure Data\"\n // For simplicity, we'll use the topic_start_time and topic_end_time from originalTopic\n // as provided by \"Merge & Deduplicate Topics\" node.\n // If more granular message-level time is needed, you'd need to pass the full\n // original messages array or a lookup map from \"Parse & Structure Data\" node.\n\n // For now, we'll use the min/max of the original topics' start/end times\n let minTime = Infinity;\n let maxTime = -Infinity;\n\n for (const source of aiTopic.merged_from) {\n const originalTopic = allTopicsMap.get(source.physical_id);\n if (originalTopic) {\n const startTime = new Date(originalTopic.topic_start_time).getTime();\n const endTime = new Date(originalTopic.topic_end_time).getTime();\n if (startTime < minTime) minTime = startTime;\n if (endTime > maxTime) maxTime = endTime;\n }\n }\n earliest_seq_time = minTime !== Infinity ? new Date(minTime).toISOString() : null;\n latest_seq_time = maxTime !== -Infinity ? new Date(maxTime).toISOString() : null;\n }\n\n\n enhanced_merged_topics.push({\n semantic_topic_id: aiTopic.semantic_topic_id,\n merged_title: aiTopic.merged_title,\n merged_description: aiTopic.merged_description,\n is_off_topic: aiTopic.is_off_topic,\n semantic_category: aiTopic.semantic_category,\n topic_tags: aiTopic.topic_tags,\n merged_from: aiTopic.merged_from,\n relevance_score: aiTopic.relevance_score,\n confidence_level: aiTopic.confidence_level,\n combined_message_seqs: combined_message_seqs,\n total_combined_messages: total_combined_messages,\n chat_count: chat_count,\n seq_source_mapping: seq_source_mapping,\n message_time_range: {\n earliest_seq: combined_message_seqs.length > 0 ? combined_message_seqs[0] : null,\n latest_seq: combined_message_seqs.length > 0 ? combined_message_seqs[combined_message_seqs.length - 1] : null,\n seq_count: combined_message_seqs.length,\n earliest_time: earliest_seq_time, // Use calculated earliest time\n latest_time: latest_seq_time // Use calculated latest time\n }\n });\n}\n\n// Prepare the final output\nconst now = new Date();\nreturn [{\n json: {\n merge_timestamp: now.toISOString(),\n merge_date: now.toISOString().split('T')[0], // Just the date part\n merge_type: \"semantic\",\n original_topics_count: physicalMergeResult.all_topics.length,\n merged_topics_count: enhanced_merged_topics.length,\n compression_ratio: (1 - enhanced_merged_topics.length / physicalMergeResult.all_topics.length) * 100,\n merged_topics: enhanced_merged_topics,\n merge_metadata: aiMergeResult.merge_metadata,\n original_statistics: physicalMergeResult.statistics,\n sources_metadata: physicalMergeResult.sources_metadata,\n segments_metadata: physicalMergeResult.segments_metadata,\n global_seq_index: physicalMergeResult.global_seq_index, // Ensure this field exists and is passed\n\n // Newly added fields\n all_links: all_links,\n active_users: active_users,\n raw_message_sample: raw_message_sample\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1600,
80
],
"id": "0299a2da-cfe5-47ff-9b49-57c387020ae2",
"name": "Assemble Final Report"
},
{
"parameters": {
"jsCode": "// \u83b7\u53d6AI Agent\u7684\u8f93\u51fa\nlet aiOutput;\ntry {\n // \u9996\u5148\u68c0\u67e5\u8f93\u5165\u6570\u636e\u7ed3\u6784\n const inputData = $input.first().json;\n console.log('Input data structure:', JSON.stringify(inputData, null, 2));\n \n // \u5c1d\u8bd5\u4e0d\u540c\u7684\u6570\u636e\u7ed3\u6784\u8def\u5f84\n if (inputData.choices && inputData.choices[0] && inputData.choices[0].message) {\n // OpenAI API \u6807\u51c6\u683c\u5f0f\n aiOutput = inputData.choices[0].message.content;\n } else if (inputData.message && inputData.message.content) {\n // \u7b80\u5316\u7684\u6d88\u606f\u683c\u5f0f\n aiOutput = inputData.message.content;\n } else if (inputData.content) {\n // \u76f4\u63a5\u5185\u5bb9\u683c\u5f0f\n aiOutput = inputData.content;\n } else if (inputData.output) {\n // \u8f93\u51fa\u5b57\u6bb5\u683c\u5f0f\n aiOutput = inputData.output;\n } else if (typeof inputData === 'string') {\n // \u7eaf\u5b57\u7b26\u4e32\u683c\u5f0f\n aiOutput = inputData;\n } else {\n // \u5982\u679c\u90fd\u627e\u4e0d\u5230\uff0c\u5c1d\u8bd5\u627e\u5230\u7b2c\u4e00\u4e2a\u5b57\u7b26\u4e32\u503c\n const findStringValue = (obj) => {\n if (typeof obj === 'string') return obj;\n if (Array.isArray(obj)) {\n for (const item of obj) {\n const result = findStringValue(item);\n if (result) return result;\n }\n } else if (typeof obj === 'object' && obj !== null) {\n for (const key in obj) {\n const result = findStringValue(obj[key]);\n if (result) return result;\n }\n }\n return null;\n };\n \n aiOutput = findStringValue(inputData);\n \n if (!aiOutput) {\n throw new Error('\u65e0\u6cd5\u5728\u8f93\u5165\u6570\u636e\u4e2d\u627e\u5230AI\u8f93\u51fa\u5185\u5bb9');\n }\n }\n \n console.log('Found AI output:', aiOutput.substring(0, 200) + '...');\n \n} catch (error) {\n console.error('\u83b7\u53d6AI\u8f93\u51fa\u65f6\u51fa\u9519:', error);\n return [{\n json: {\n error: '\u65e0\u6cd5\u83b7\u53d6AI\u8f93\u51fa: ' + error.message,\n inputData: $input.first().json,\n status: 'error'\n }\n }];\n}\n\n// \u6e05\u7406\u53ef\u80fd\u7684markdown\u6807\u8bb0\nlet htmlContent = aiOutput;\nif (htmlContent.includes('```html')) {\n htmlContent = htmlContent.replace(/```html\\n?/g, '').replace(/\\n?```/g, '');\n}\nif (htmlContent.includes('```')) {\n htmlContent = htmlContent.replace(/```\\n?/g, '').replace(/\\n?```/g, '');\n}\n\n// \u83b7\u53d6\u7fa4\u804a\u540d\u79f0\u548c\u65e5\u671f\uff08\u4ece\u5de5\u4f5c\u6d41\u8f93\u5165\u6216\u8bbe\u7f6e\u9ed8\u8ba4\u503c\uff09\nconst groupName = $('ConfigureChatParameters').first().json.group_name || 'unknown';\nconst date = $('ConfigureChatParameters').first().json.date || new Date().toISOString().split('T')[0];\n\nconsole.log('\u7fa4\u804a\u4fe1\u606f:', { groupName, date });\n\n// \ud83d\udd25 \u6539\u8fdb\uff1a\u667a\u80fdHTML\u5185\u5bb9\u6e05\u7406 - \u4fdd\u62a4\u56fe\u8868\u548c\u811a\u672c\u5185\u5bb9\nconsole.log('\u5f00\u59cbHTML\u6e05\u7406\uff0c\u539f\u59cb\u957f\u5ea6:', htmlContent.length);\n\n// \u667a\u80fd\u6e05\u7406HTML\u5185\u5bb9\uff0c\u4fdd\u62a4\u91cd\u8981\u7684\u4ee3\u7801\u533a\u57df\nconst smartCleanHtmlForMCP = (html) => {\n // 1. \u9996\u5148\u4fdd\u62a4\u91cd\u8981\u7684\u4ee3\u7801\u533a\u57df\n const protectedSections = [];\n let cleanedHtml = html;\n \n // \u4fdd\u62a4 script \u6807\u7b7e\u5185\u5bb9\uff08\u5305\u62ec mermaid, chart.js \u7b49\uff09\n cleanedHtml = cleanedHtml.replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, (match, offset) => {\n const placeholder = `__PROTECTED_SCRIPT_${protectedSections.length}__`;\n protectedSections.push(match);\n return placeholder;\n });\n \n // \u4fdd\u62a4 style \u6807\u7b7e\u5185\u5bb9\n cleanedHtml = cleanedHtml.replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, (match, offset) => {\n const placeholder = `__PROTECTED_STYLE_${protectedSections.length}__`;\n protectedSections.push(match);\n return placeholder;\n });\n \n // \u4fdd\u62a4 pre \u6807\u7b7e\u5185\u5bb9\uff08\u53ef\u80fd\u5305\u542b\u56fe\u8868\u5b9a\u4e49\uff09\n cleanedHtml = cleanedHtml.replace(/<pre[^>]*>[\\s\\S]*?<\\/pre>/gi, (match, offset) => {\n const placeholder = `__PROTECTED_PRE_${protectedSections.length}__`;\n protectedSections.push(match);\n return placeholder;\n });\n \n // \u4fdd\u62a4\u5177\u6709\u7279\u6b8aclass\u7684div\uff08\u5982mermaid\u56fe\u8868\u5bb9\u5668\uff09\n cleanedHtml = cleanedHtml.replace(/<div[^>]*class=\"[^\"]*(?:mermaid|chart|graph|diagram)[^\"]*\"[^>]*>[\\s\\S]*?<\\/div>/gi, (match, offset) => {\n const placeholder = `__PROTECTED_CHART_${protectedSections.length}__`;\n protectedSections.push(match);\n return placeholder;\n });\n \n // 2. \u5bf9\u5176\u4f59\u5185\u5bb9\u8fdb\u884c\u6e05\u7406\n cleanedHtml = cleanedHtml\n // \u53ea\u6e05\u7406\u666e\u901a\u7684\u6362\u884c\u7b26\u548c\u5236\u8868\u7b26\uff0c\u4f46\u4fdd\u7559\u5fc5\u8981\u7684\u683c\u5f0f\n .replace(/[\\r]/g, '') // \u79fb\u9664\u56de\u8f66\u7b26\n .replace(/\\n\\s*\\n/g, '\\n') // \u5c06\u591a\u4e2a\u6362\u884c\u5408\u5e76\u4e3a\u4e00\u4e2a\n .replace(/\\t/g, ' ') // \u5c06\u5236\u8868\u7b26\u8f6c\u6362\u4e3a\u4e24\u4e2a\u7a7a\u683c\n // \u6e05\u7406HTML\u6807\u7b7e\u95f4\u7684\u591a\u4f59\u7a7a\u683c\uff0c\u4f46\u4e0d\u5f71\u54cd\u6587\u672c\u5185\u5bb9\n .replace(/>\\s+</g, '><')\n // \u6e05\u7406\u884c\u9996\u884c\u5c3e\u7a7a\u683c\n .replace(/^\\s+|\\s+$/gm, '')\n // \u5c06\u591a\u4e2a\u8fde\u7eed\u7a7a\u683c\u5408\u5e76\uff0c\u4f46\u4fdd\u7559\u5355\u4e2a\u6362\u884c\n .replace(/ +/g, ' ');\n \n // 3. \u6062\u590d\u53d7\u4fdd\u62a4\u7684\u5185\u5bb9\n protectedSections.forEach((section, index) => {\n const scriptPlaceholder = `__PROTECTED_SCRIPT_${index}__`;\n const stylePlaceholder = `__PROTECTED_STYLE_${index}__`;\n const prePlaceholder = `__PROTECTED_PRE_${index}__`;\n const chartPlaceholder = `__PROTECTED_CHART_${index}__`;\n \n cleanedHtml = cleanedHtml.replace(scriptPlaceholder, section);\n cleanedHtml = cleanedHtml.replace(stylePlaceholder, section);\n cleanedHtml = cleanedHtml.replace(prePlaceholder, section);\n cleanedHtml = cleanedHtml.replace(chartPlaceholder, section);\n });\n \n // 4. \u6700\u540e\u53ea\u5bf9JSON\u4f20\u8f93\u505a\u5fc5\u8981\u7684\u8f6c\u4e49\n return cleanedHtml\n .replace(/\\\\/g, '\\\\\\\\') // \u8f6c\u4e49\u53cd\u659c\u6760\n .replace(/\"/g, '\\\\\"'); // \u8f6c\u4e49\u53cc\u5f15\u53f7\n};\n\n// \u5e94\u7528\u667a\u80fd\u6e05\u7406\nconst originalHtmlContent = htmlContent;\nhtmlContent = smartCleanHtmlForMCP(htmlContent);\n\nconsole.log('\u667a\u80fdHTML\u6e05\u7406\u5b8c\u6210');\nconsole.log('- \u539f\u59cb\u957f\u5ea6:', originalHtmlContent.length);\nconsole.log('- \u6e05\u7406\u540e\u957f\u5ea6:', htmlContent.length);\nconsole.log('- \u8282\u7701\u7a7a\u95f4:', originalHtmlContent.length - htmlContent.length, 'bytes');\n\n// \u68c0\u67e5\u662f\u5426\u5305\u542b\u56fe\u8868\u76f8\u5173\u5185\u5bb9\nconst hasCharts = originalHtmlContent.match(/(mermaid|chart\\.js|echarts|d3\\.js|plotly|canvas|svg)/i);\nif (hasCharts) {\n console.log('\u2705 \u68c0\u6d4b\u5230\u56fe\u8868\u5185\u5bb9\uff0c\u5df2\u5e94\u7528\u4fdd\u62a4\u6027\u6e05\u7406');\n} else {\n console.log('\u2139\ufe0f \u672a\u68c0\u6d4b\u5230\u56fe\u8868\u5185\u5bb9');\n}\n\n// \u9a8c\u8bc1HTML\u6587\u6863\u5b8c\u6574\u6027\nif (!htmlContent.includes('<!DOCTYPE html>')) {\n console.error('AI\u8f93\u51fa\u4e0d\u662f\u5b8c\u6574\u7684HTML\u6587\u6863');\n return [{\n json: {\n error: 'AI\u8f93\u51fa\u4e0d\u662f\u5b8c\u6574\u7684HTML\u6587\u6863',\n receivedContent: htmlContent.substring(0, 500),\n status: 'error'\n }\n }];\n}\n\n// \u989d\u5916\u7684HTML\u5185\u5bb9\u9a8c\u8bc1\nconst validateHtml = (html) => {\n const checks = {\n hasDoctype: html.includes('<!DOCTYPE html>'),\n hasHtmlTag: html.includes('<html') && html.includes('</html>'),\n hasHeadTag: html.includes('<head') && html.includes('</head>'),\n hasBodyTag: html.includes('<body') && html.includes('</body>'),\n hasTitle: html.includes('<title'),\n isNotEmpty: html.length > 100,\n hasChartLibraries: /(?:mermaid|chart\\.js|echarts|d3\\.js|plotly)/i.test(html)\n };\n \n const passed = Object.values(checks).filter(Boolean).length;\n const total = Object.keys(checks).length;\n \n console.log('HTML\u9a8c\u8bc1\u7ed3\u679c:', checks);\n console.log(`HTML\u8d28\u91cf\u8bc4\u5206: ${passed}/${total}`);\n \n return { checks, score: passed / total };\n};\n\nconst validation = validateHtml(htmlContent);\n\n// \u751f\u6210\u6587\u4ef6\u540d - \u4f7f\u7528\u7fa4\u804a\u540d\u79f0\u548c\u65e5\u671f\nconst filename = `\u7fa4\u804a\u65e5\u62a5-${groupName}-${date}.html`;\nconst safeFilename = filename.replace(/[^a-z0-9\\u4e00-\\u9fa5_\\-\\.]/gi, '_');\n\nconsole.log('\u6587\u4ef6\u540d\u751f\u6210:', { \n original: filename, \n safe: safeFilename,\n groupName: groupName,\n date: date \n});\n\n// \u751f\u6210\u6e05\u7406\u540e\u7684HTML\u7528\u4e8eMCP\u5de5\u5177\nconst mcpReadyHtml = htmlContent;\n\n// \u8ba1\u7b97\u5904\u7406\u65f6\u957f\nlet processDuration = null;\ntry {\n const startTime = $('\u9a8c\u8bc1\u8f93\u5165\u53c2\u6570').item.json.process_start_time;\n if (startTime) {\n const startDate = new Date(startTime);\n const endDate = new Date();\n // \u8ba1\u7b97\u79d2\u6570\u5dee\n processDuration = Math.round((endDate - startDate) / 1000);\n }\n} catch (e) {\n console.log('\u8ba1\u7b97\u5904\u7406\u65f6\u957f\u5931\u8d25:', e.message);\n processDuration = null;\n}\n\n\n// \u8fd4\u56de\u5904\u7406\u7ed3\u679c - \u5305\u542b\u4e24\u4e2a\u7248\u672c\u7684HTML\nreturn [{\n json: {\n // \u539f\u59cbHTML\uff08\u7528\u4e8e\u6587\u4ef6\u4fdd\u5b58\uff09\n htmlContent: originalHtmlContent,\n // \u6e05\u7406\u540e\u7684HTML\uff08\u7528\u4e8eMCP\u5de5\u5177\u8c03\u7528\uff09\n mcpHtmlContent: mcpReadyHtml,\n // \u6587\u4ef6\u4fe1\u606f - \u4f7f\u7528\u7fa4\u804a\u540d\u79f0\n filename: safeFilename,\n originalFilename: filename,\n groupName: groupName,\n date: date,\n process_end_time: $now.toISO(),\n process_duration: processDuration,\n timestamp: new Date().toISOString(),\n status: 'success',\n // \u7edf\u8ba1\u4fe1\u606f\n originalContentLength: originalHtmlContent.length,\n cleanedContentLength: mcpReadyHtml.length,\n compressionRatio: ((originalHtmlContent.length - mcpReadyHtml.length) / originalHtmlContent.length * 100).toFixed(2) + '%',\n // HTML\u8d28\u91cf\u4fe1\u606f\n htmlValidation: validation,\n hasChartContent: hasCharts !== null,\n // \u8c03\u8bd5\u4fe1\u606f\n processingSteps: [\n '\u2705 AI\u8f93\u51fa\u89e3\u6790\u6210\u529f',\n '\u2705 Markdown\u6e05\u7406\u5b8c\u6210', \n '\u2705 \u667a\u80fdHTML\u6e05\u7406\u5b8c\u6210\uff08\u4fdd\u62a4\u56fe\u8868\uff09',\n '\u2705 MCP\u683c\u5f0f\u4f18\u5316\u5b8c\u6210',\n `\u2705 HTML\u9a8c\u8bc1\u5b8c\u6210 (${(validation.score * 100).toFixed(1)}%)`,\n `\u2705 \u6587\u4ef6\u540d\u751f\u6210\u5b8c\u6210: ${safeFilename}`\n ]\n },\n binary: {\n data: {\n data: Buffer.from(originalHtmlContent, 'utf8').toString('base64'),\n mimeType: 'text/html',\n fileName: safeFilename,\n fileExtension: 'html'\n }\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2200,
80
],
"id": "699fd2d2-fdc8-4bd7-9a14-1db30edd2229",
"name": "\u5904\u7406AI\u8f93\u51fa"
},
{
"parameters": {
"content": "## \u6700\u7ec8HTML\u5185\u5bb9\u5904\u7406\n",
"height": 740,
"width": 780,
"color": 7
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1820,
-200
],
"id": "59446f38-5c4e-4778-a707-95c396cccc60",
"name": "Sticky Note5"
},
{
"parameters": {
"jsCode": "// Node: Extract Links & Active Users\n// Input: Output from \"Parse & Structure Data\" node (single JSON object with sources.messages)\n// Output: JSON with all_links array (objects with url, sender, time, context) and active_users array\n\nconst inputData = $input.first().json; // Get the single item from \"Parse & Structure Data\"\n\n\nif (!inputData || !inputData.sources) {\n console.log(\"Input data is missing structuredData or sources field.\");\n return { json: { all_links: [], active_users: [] } };\n}\n\n// Assuming only one source, get its messages list\nconst sourceName = Object.keys(inputData.sources)[0];\nconst allMessages = inputData.sources[sourceName].messages;\n\nif (!allMessages || allMessages.length === 0) {\n console.log(\"No messages found in structured data.\");\n return { json: { all_links: [], active_users: [] } };\n}\n\nconst allLinks = []; // Now an array of objects\nconst senderMessageCounts = {}; // Store message counts for active users\n\n// Regex for raw URLs (http or https)\nconst rawUrlRegex = /(https?:\\/\\/[^\\s]+)/g;\n// Regex for [\u94fe\u63a5|URL] format\nconst formattedLinkRegex = /\\[\u94fe\u63a5\\|(https?:\\/\\/[^\\]]+)\\]/g;\n\nconsole.log(`Processing ${allMessages.length} messages.`);\n\nfor (const message of allMessages) {\n const senderName = message.sender_name;\n const messageTime = message.time;\n const messageContent = message.content;\n\n //console.log(`--- Processing Message ---`);\n //console.log(`Raw senderName: '${senderName}'`);\n //console.log(`Message content: '${messageContent}'`);\n\n // Check senderName validity for active users\n const isValidSender = senderName && senderName.trim() !== '' && senderName !== '\u7cfb\u7edf\u6d88\u606f';\n \n\n if (messageContent) {\n const extractedUrls = []; // Temporarily store all URLs found in this message\n let linkMatch;\n \n // Extract raw URLs\n rawUrlRegex.lastIndex = 0; // Reset regex lastIndex for global regex\n while ((linkMatch = rawUrlRegex.exec(messageContent)) !== null) {\n extractedUrls.push(linkMatch[0]);\n }\n\n // Extract formatted links [\u94fe\u63a5|URL]\n formattedLinkRegex.lastIndex = 0; // Reset regex lastIndex for global regex\n while ((linkMatch = formattedLinkRegex.exec(messageContent)) !== null) {\n extractedUrls.push(linkMatch[1]);\n }\n\n for (const url of extractedUrls) {\n // Check if this URL is part of an image or video markdown\n const isImageOrVideoLink = messageContent.includes(`![\u56fe\u7247]`) || messageContent.includes(`![\u89c6\u9891]`);\n if (!isImageOrVideoLink) {\n //console.log(`Found valid link: ${url}`);\n allLinks.push({\n url: url,\n sender: senderName,\n time: messageTime,\n context: messageContent\n });\n } else {\n console.log(`Excluded image/video link: ${url}`);\n }\n }\n }\n\n // Count active users (only for valid senders)\n if (isValidSender) {\n //console.log(`Counting message for sender: ${senderName}`);\n senderMessageCounts[senderName] = (senderMessageCounts[senderName] || 0) + 1;\n }\n}\n\nconsole.log(`Final allLinks count: ${allLinks.length}`);\nconsole.log(`Final activeUsers count: ${Object.keys(senderMessageCounts).length}`);\n\n// Convert senderMessageCounts to active_users array and sort\nconst activeUsers = Object.entries(senderMessageCounts)\n .map(([sender_name, message_count]) => ({ sender_name, message_count }))\n .sort((a, b) => b.message_count - a.message_count); // Sort in descending order by message_count\n\nreturn [{\n json: {\n all_links: allLinks,\n active_users: activeUsers\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
480,
-100
],
"id": "63f36a48-ba52-4392-9a16-e66a0d41ce6c",
"name": "Extract Links & Active Users"
},
{
"parameters": {
"jsCode": "// Node: Prepare Raw Message Sample\n// Input: Output from \"Parse & Structure Data\" node (\u5355\u4e2a JSON \u5bf9\u8c61\uff0c\u5305\u542b sources.messages)\n// Output: JSON with raw_message_sample \u6570\u7ec4\n\nconst structuredData = $('Parse & Structure Data').first().json; // \u83b7\u53d6\u4e0a\u6e38\u8282\u70b9\u7684\u5355\u4e2a\u6570\u636e\u9879\n\nif (!structuredData || !structuredData.sources) {\n console.log(\"\u8f93\u5165\u6570\u636e\u7f3a\u5c11 structuredData \u6216 sources \u5b57\u6bb5\u3002\");\n return { json: { raw_message_sample: [] } };\n}\n\n// \u5047\u8bbe\u53ea\u6709\u4e00\u4e2a source\uff0c\u83b7\u53d6\u5176\u6d88\u606f\u5217\u8868\nconst sourceName = Object.keys(structuredData.sources)[0];\nconst allMessages = structuredData.sources[sourceName].messages;\n\nif (!allMessages || allMessages.length === 0) {\n console.log(\"\u7ed3\u6784\u5316\u6570\u636e\u4e2d\u672a\u627e\u5230\u6d88\u606f\u3002\");\n return { json: { raw_message_sample: [] } };\n}\n\n// \u63d0\u53d6\u6d88\u606f\u6837\u672c\u3002\n// \u4f60\u53ef\u4ee5\u6839\u636e\u9700\u8981\u8c03\u6574\u91c7\u6837\u903b\u8f91\uff08\u4f8b\u5982\uff0c\u968f\u673a\u91c7\u6837\uff0c\u66f4\u591a\u6d88\u606f\uff09\u3002\nconst rawMessageSample = allMessages.map(msg => ({\n sender_name: msg.sender_name,\n time: msg.time,\n content: msg.content\n}));\n\nreturn [{\n json: {\n raw_message_sample: rawMessageSample\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
-100
],
"id": "46a817d3-6fb4-4d3e-b991-3b098d0bb005",
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.
deepSeekApigooglePalmApiopenRouterApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
WeChat-Daily-Digest-AI-Cost-Optimized. Uses httpRequest, chainLlm, outputParserStructured, readWriteFile. Scheduled trigger; 26 nodes.
Source: https://github.com/lqshow/awesome-n8n-workflows/blob/c78bab4cce4e943e25a95b71c7304427ca828ada/workflows/wechat-daily-report/wechat-daily-digest-ai-cost-optimized.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.
My workflow 53. Uses formTrigger, httpRequest, lmChatOpenAi, form. Event-driven trigger; 74 nodes.
Episode 23: UGC with nanobanana. Uses lmChatOpenAi, lmChatOllama, lmChatDeepSeek, lmChatOpenRouter. Event-driven trigger; 74 nodes.
Automate Blog Creation and Publishing with Ultra-Low Cost AI
Complete PostgreSQL-backed system: Keyword scoring → AI research → Multi-part content generation → fal.ai Nano Banana image generation → WordPress publishing
A Telegram bot that converts natural-language work descriptions into detailed cost estimates using AI parsing, vector search, and the open-source DDC CWICR database with 55,000+ construction work item