307 lines
18 KiB
JSON
307 lines
18 KiB
JSON
{
|
|
"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, '>').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 += `<section class=\"editorial\"><h2>Analyse éditoriale</h2>`;\n if (lead) {\n md += `${lead}\\n\\n`;\n htmlSections += `<p class=\"lead\">${esc(lead)}</p>`;\n }\n if (highlights.length) {\n md += highlights.map((h) => `- ${h}`).join('\\n') + `\\n\\n`;\n htmlSections += `<ul>` + highlights.map((h) => `<li>${esc(h)}</li>`).join('') + `</ul>`;\n }\n htmlSections += `</section>`;\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 += `<section><h2>${esc(cat.category)}</h2><ul>`;\n for (const it of cat.top_items) {\n md += `- **[${it.title}](${it.url})** — ${it.summary}\\n`;\n htmlSections += `<li><a href=\"${esc(it.url)}\">${esc(it.title)}</a> — ${esc(it.summary)}</li>`;\n }\n htmlSections += `</ul>`;\n if (cat.weak_signals.length) {\n md += `\\n_Signaux faibles :_\\n` + cat.weak_signals.map((s) => `- ${s}`).join('\\n') + `\\n`;\n htmlSections += `<p class=\"weak-label\">Signaux faibles</p><ul class=\"weak\">` + cat.weak_signals.map((s) => `<li>${esc(s)}</li>`).join('') + `</ul>`;\n }\n md += `\\n`;\n htmlSections += `</section>`;\n}\nif (!hasContent) {\n md += `_Aucun article pertinent aujourd'hui._\\n`;\n htmlSections += `<p>Aucun article pertinent aujourd'hui.</p>`;\n}\nconst html = `<!doctype html>\n<html lang=\"fr\"><head><meta charset=\"utf-8\">\n<title>Veille technique — ${esc(date)}</title>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<style>\nbody { font-family: -apple-system, Segoe UI, Helvetica, Arial, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #1c1c1c; }\nh1 { font-size: 1.6rem; border-bottom: 2px solid #222; padding-bottom: 0.4rem; }\nh2 { font-size: 1.15rem; margin-top: 2rem; color: #333; }\nul { padding-left: 1.2rem; }\nli { margin-bottom: 0.6rem; }\na { color: #0b5fae; text-decoration: none; }\na:hover { text-decoration: underline; }\n.weak-label { font-size: 0.85rem; color: #666; font-style: italic; margin: 0.6rem 0 0.2rem; }\nul.weak { padding-left: 1.2rem; margin-top: 0; }\nul.weak li { font-size: 0.85rem; color: #666; font-style: italic; margin-bottom: 0.3rem; }\n.editorial { background: #f4f6fb; border-left: 4px solid #0b5fae; padding: 0.8rem 1.2rem; border-radius: 4px; }\n.editorial h2 { margin-top: 0; }\n.editorial p.lead { font-weight: 600; margin-bottom: 0.6rem; }\n.editorial ul { margin-bottom: 0; }\n.editorial ul li { margin-bottom: 0.4rem; }\n</style></head>\n<body>\n<h1>Veille technique — ${esc(date)}</h1>\n${htmlSections}\n</body></html>`;\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" }
|
|
}
|