{
"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 += ` ${esc(lead)} Signaux faiblesAnalyse éditoriale
`;\n if (lead) {\n md += `${lead}\\n\\n`;\n htmlSections += `` + highlights.map((h) => `
`;\n }\n htmlSections += `${esc(cat.category)}
`;\n for (const it of cat.top_items) {\n md += `- **[${it.title}](${it.url})** — ${it.summary}\\n`;\n htmlSections += `
`;\n if (cat.weak_signals.length) {\n md += `\\n_Signaux faibles :_\\n` + cat.weak_signals.map((s) => `- ${s}`).join('\\n') + `\\n`;\n htmlSections += `` + cat.weak_signals.map((s) => `
`;\n }\n md += `\\n`;\n htmlSections += `
Aucun article pertinent aujourd'hui.
`;\n}\nconst html = `\n\n