{ "id": "CAbGgPd187VA7K1R", "name": "Tech Watch Digest", "nodes": [ { "id": "schedule_trigger", "name": "Schedule Trigger", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [0, 0], "parameters": { "rule": { "interval": [ { "field": "cronExpression", "expression": "0 6 * * *" } ] } } }, { "id": "init", "name": "Init", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [220, 0], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const now = new Date();\nconst oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);\nreturn [{\n json: {\n publishedAfter: Math.floor(oneDayAgo.getTime() / 1000),\n digestDate: now.toISOString().slice(0, 10),\n llmBaseUrl: 'https://openrouter.ai/api/v1',\n llmModel: 'deepseek/deepseek-v4-flash',\n editorialModel: 'anthropic/claude-opus-4.8'\n }\n}];" } }, { "id": "get_unread_entries", "name": "Get Unread Entries", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 0], "parameters": { "method": "GET", "url": "http://miniflux.watch.svc.cluster.local/v1/entries", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "status", "value": "unread" }, { "name": "limit", "value": "500" }, { "name": "order", "value": "published_at" }, { "name": "direction", "value": "desc" }, { "name": "published_after", "value": "={{ $json.publishedAfter }}" } ] }, "options": {} }, "credentials": { "httpHeaderAuth": { "id": "D3czTdAQl3KhiKTM", "name": "Miniflux API" } }, "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000 }, { "id": "group_by_category", "name": "Group By Category", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [660, 0], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const resp = $input.first().json;\nlet entries = resp.entries || [];\n\n// Dedup across ALL categories by normalized title: several feeds (direct\n// blogs + overlapping Google News searches) can surface the same story\n// under different URLs/categories, and each category is summarized by the\n// LLM independently so nothing else catches this.\nconst seenTitles = new Set();\nentries = entries.filter((e) => {\n const key = (e.title || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, ' ').trim();\n if (!key || seenTitles.has(key)) return false;\n seenTitles.add(key);\n return true;\n});\n\nconst byCategory = {};\nfor (const e of entries) {\n const cat = (e.feed && e.feed.category && e.feed.category.title) || 'Uncategorized';\n if (!byCategory[cat]) byCategory[cat] = [];\n byCategory[cat].push({\n id: e.id,\n title: e.title,\n url: e.url,\n published_at: e.published_at,\n feed_title: e.feed ? e.feed.title : '',\n excerpt: (e.content || '').replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, 600)\n });\n}\nconst items = Object.keys(byCategory).map((cat) => ({\n json: { category: cat, entries: byCategory[cat] }\n}));\nif (items.length === 0) {\n items.push({ json: { category: null, entries: [] } });\n}\nreturn items;" } }, { "id": "read_digest_prompt", "name": "Read Digest Prompt", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [880, 0], "parameters": { "operation": "read", "fileSelector": "/data/prompts/digest.md", "options": {} } }, { "id": "build_prompt", "name": "Build Prompt", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1100, 0], "parameters": { "mode": "runOnceForEachItem", "jsCode": "const systemPrompt = Buffer.from($input.item.binary.data.data, 'base64').toString('utf-8');\nconst category = $('Group By Category').item.json.category;\nconst entries = $('Group By Category').item.json.entries;\nconst init = $('Init').first().json;\nconst schemaReminder = 'Respond with strict JSON only, exactly this shape and no other keys: {\"top_items\": [{\"title\": \"...\", \"url\": \"...\", \"summary\": \"...\"}], \"weak_signals\": [\"...\"]}. No markdown code fences, no extra commentary. Reminder: every \"summary\" and every \"weak_signals\" entry must be written in French, even though the articles below are in English.';\nconst userContent = `Category: ${category}\\n\\nArticles:\\n` + entries.map((e, i) => `${i + 1}. ${e.title}\\nURL: ${e.url}\\nExcerpt: ${e.excerpt}`).join('\\n\\n') + `\\n\\n${schemaReminder}`;\nreturn { json: { category, entries, systemPrompt, userContent, llmBaseUrl: init.llmBaseUrl, llmModel: init.llmModel } };" } }, { "id": "summarize_category", "name": "Summarize Category", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 0], "parameters": { "method": "POST", "url": "={{ $json.llmBaseUrl }}/chat/completions", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendHeaders": true, "headerParameters": { "parameters": [{ "name": "Content-Type", "value": "application/json" }] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ model: $json.llmModel, messages: [{ role: 'system', content: $json.systemPrompt }, { role: 'user', content: $json.userContent }], response_format: { type: 'json_object' } }) }}", "options": {} }, "credentials": { "httpHeaderAuth": { "id": "pZ4g3ReSwgDA6WLP", "name": "LLM API" } }, "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000 }, { "id": "parse_llm_response", "name": "Parse LLM Response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1540, 0], "parameters": { "mode": "runOnceForEachItem", "jsCode": "const category = $('Group By Category').item.json.category;\nlet parsed = null;\ntry {\n const content = $json.choices && $json.choices[0] && $json.choices[0].message && $json.choices[0].message.content;\n parsed = content ? JSON.parse(content) : null;\n} catch (err) {\n parsed = null;\n}\nconst top_items = (parsed && Array.isArray(parsed.top_items)) ? parsed.top_items : [];\nconst weak_signals = (parsed && Array.isArray(parsed.weak_signals)) ? parsed.weak_signals : [];\nreturn { json: { category, top_items, weak_signals } };" } }, { "id": "combine_for_editorial", "name": "Combine For Editorial", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1760, 0], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const CATEGORY_ORDER = ['Cloud Native', 'Integration', 'APIM', 'EDA', 'AI', 'Security', 'Sovereignty', 'Analysts', 'Others'];\nconst items = $input.all().map((i) => i.json);\nitems.sort((a, b) => {\n const ia = CATEGORY_ORDER.indexOf(a.category);\n const ib = CATEGORY_ORDER.indexOf(b.category);\n return (ia === -1 ? CATEGORY_ORDER.length : ia) - (ib === -1 ? CATEGORY_ORDER.length : ib);\n});\nreturn [{ json: { categories: items } }];" } }, { "id": "read_editorial_prompt", "name": "Read Editorial Prompt", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [1980, 0], "parameters": { "operation": "read", "fileSelector": "/data/prompts/editorial.md", "options": {} } }, { "id": "build_editorial_prompt", "name": "Build Editorial Prompt", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [2200, 0], "parameters": { "mode": "runOnceForEachItem", "jsCode": "const systemPrompt = Buffer.from($input.item.binary.data.data, 'base64').toString('utf-8');\nconst categories = $('Combine For Editorial').first().json.categories;\nconst slim = categories.map((c) => ({ category: c.category, items: c.top_items.map((it) => ({ title: it.title, summary: it.summary })) })).filter((c) => c.items.length > 0);\nconst init = $('Init').first().json;\nconst schemaReminder = 'Respond with strict JSON only, exactly this shape and no other keys: {\"lead\": \"...\", \"highlights\": [\"...\"]}. No markdown code fences, no extra commentary. Reminder: \"lead\" and every \"highlights\" entry must be written in French. \"lead\" is one short sentence; \"highlights\" is 2 to 5 short one-sentence bullets, not one long paragraph.';\nconst userContent = JSON.stringify(slim) + '\\n\\n' + schemaReminder;\nreturn { json: { systemPrompt, userContent, llmBaseUrl: init.llmBaseUrl, llmModel: init.editorialModel } };" } }, { "id": "editorial_pass", "name": "Editorial Pass (Opus)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2420, 0], "parameters": { "method": "POST", "url": "={{ $json.llmBaseUrl }}/chat/completions", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendHeaders": true, "headerParameters": { "parameters": [{ "name": "Content-Type", "value": "application/json" }] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ model: $json.llmModel, messages: [{ role: 'system', content: $json.systemPrompt }, { role: 'user', content: $json.userContent }], response_format: { type: 'json_object' } }) }}", "options": {} }, "credentials": { "httpHeaderAuth": { "id": "pZ4g3ReSwgDA6WLP", "name": "LLM API" } }, "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000 }, { "id": "parse_editorial_response", "name": "Parse Editorial Response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [2640, 0], "parameters": { "mode": "runOnceForAllItems", "jsCode": "let parsed = null;\ntry {\n const content = $json.choices && $json.choices[0] && $json.choices[0].message && $json.choices[0].message.content;\n parsed = content ? JSON.parse(content) : null;\n} catch (err) {\n parsed = null;\n}\nconst lead = (parsed && typeof parsed.lead === 'string') ? parsed.lead.trim() : '';\nconst highlights = (parsed && Array.isArray(parsed.highlights)) ? parsed.highlights.filter((h) => typeof h === 'string' && h.trim()) : [];\nconst categories = $('Combine For Editorial').first().json.categories;\nreturn [{ json: { categories, lead, highlights } }];" } }, { "id": "compose_digest", "name": "Compose Digest", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [2860, 0], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"');\nconst cats = $input.first().json.categories;\nconst lead = $input.first().json.lead || '';\nconst highlights = $input.first().json.highlights || [];\nconst date = $('Init').first().json.digestDate;\nconst header = `# Veille technique — ${date}\\n\\n`;\nlet md = header;\nlet htmlSections = '';\nif (lead || highlights.length) {\n md += `## Analyse éditoriale\\n\\n`;\n htmlSections += `

Analyse éditoriale

`;\n if (lead) {\n md += `${lead}\\n\\n`;\n htmlSections += `

${esc(lead)}

`;\n }\n if (highlights.length) {\n md += highlights.map((h) => `- ${h}`).join('\\n') + `\\n\\n`;\n htmlSections += ``;\n }\n htmlSections += `
`;\n}\nlet hasContent = false;\nfor (const cat of cats) {\n if (!cat.top_items.length && !cat.weak_signals.length) continue;\n hasContent = true;\n md += `## ${cat.category}\\n\\n`;\n htmlSections += `

${esc(cat.category)}

`;\n if (cat.weak_signals.length) {\n md += `\\n_Signaux faibles :_\\n` + cat.weak_signals.map((s) => `- ${s}`).join('\\n') + `\\n`;\n htmlSections += `

Signaux faibles

`;\n }\n md += `\\n`;\n htmlSections += `
`;\n}\nif (!hasContent) {\n md += `_Aucun article pertinent aujourd'hui._\\n`;\n htmlSections += `

Aucun article pertinent aujourd'hui.

`;\n}\nconst html = `\n\nVeille technique — ${esc(date)}\n\n\n\n

Veille technique — ${esc(date)}

\n${htmlSections}\n`;\nconst fileNameMd = `digest-${date}.md`;\nconst fileNameHtml = `digest-${date}.html`;\nconst mdBinary = await this.helpers.prepareBinaryData(Buffer.from(md, 'utf-8'), fileNameMd, 'text/markdown');\nconst htmlBinary = await this.helpers.prepareBinaryData(Buffer.from(html, 'utf-8'), fileNameHtml, 'text/html');\nreturn [{ json: { markdown: md, fileNameMd, fileNameHtml }, binary: { md: mdBinary, html: htmlBinary } }];" } }, { "id": "write_digest_file", "name": "Write Digest File (Markdown)", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [3080, -160], "parameters": { "operation": "write", "fileName": "=/data/digests/{{ $json.fileNameMd }}", "dataPropertyName": "md", "options": {} } }, { "id": "write_digest_file_html", "name": "Write Digest File (HTML)", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [3080, -40], "parameters": { "operation": "write", "fileName": "=/data/digests/{{ $json.fileNameHtml }}", "dataPropertyName": "html", "options": {} } }, { "id": "collect_entry_ids", "name": "Collect Entry IDs", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [3300, -100], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const entries = $('Get Unread Entries').first().json.entries || [];\nconst entryIds = entries.map((e) => e.id);\nif (entryIds.length === 0) return [];\nreturn [{ json: { entryIds } }];" } }, { "id": "mark_entries_as_read", "name": "Mark Entries As Read", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [3520, -100], "parameters": { "method": "PUT", "url": "http://miniflux.watch.svc.cluster.local/v1/entries", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendHeaders": true, "headerParameters": { "parameters": [{ "name": "Content-Type", "value": "application/json" }] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ entry_ids: $json.entryIds, status: 'read' }) }}", "options": {} }, "credentials": { "httpHeaderAuth": { "id": "D3czTdAQl3KhiKTM", "name": "Miniflux API" } }, "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000 } ], "connections": { "Schedule Trigger": { "main": [[{ "node": "Init", "type": "main", "index": 0 }]] }, "Init": { "main": [[{ "node": "Get Unread Entries", "type": "main", "index": 0 }]] }, "Get Unread Entries": { "main": [[{ "node": "Group By Category", "type": "main", "index": 0 }]] }, "Group By Category": { "main": [[{ "node": "Read Digest Prompt", "type": "main", "index": 0 }]] }, "Read Digest Prompt": { "main": [[{ "node": "Build Prompt", "type": "main", "index": 0 }]] }, "Build Prompt": { "main": [[{ "node": "Summarize Category", "type": "main", "index": 0 }]] }, "Summarize Category": { "main": [[{ "node": "Parse LLM Response", "type": "main", "index": 0 }]] }, "Parse LLM Response": { "main": [[{ "node": "Combine For Editorial", "type": "main", "index": 0 }]] }, "Combine For Editorial": { "main": [[{ "node": "Read Editorial Prompt", "type": "main", "index": 0 }]] }, "Read Editorial Prompt": { "main": [[{ "node": "Build Editorial Prompt", "type": "main", "index": 0 }]] }, "Build Editorial Prompt": { "main": [[{ "node": "Editorial Pass (Opus)", "type": "main", "index": 0 }]] }, "Editorial Pass (Opus)": { "main": [[{ "node": "Parse Editorial Response", "type": "main", "index": 0 }]] }, "Parse Editorial Response": { "main": [[{ "node": "Compose Digest", "type": "main", "index": 0 }]] }, "Compose Digest": { "main": [ [ { "node": "Write Digest File (Markdown)", "type": "main", "index": 0 }, { "node": "Write Digest File (HTML)", "type": "main", "index": 0 } ] ] }, "Write Digest File (Markdown)": { "main": [[{ "node": "Collect Entry IDs", "type": "main", "index": 0 }]] }, "Collect Entry IDs": { "main": [[{ "node": "Mark Entries As Read", "type": "main", "index": 0 }]] } }, "settings": { "executionOrder": "v1", "timezone": "Europe/Paris" } }