From 090fb581eec5b7238e5b254c31397bece275f849 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Wed, 13 May 2026 08:37:47 +0200 Subject: [PATCH] feat(v1.6): ajout LinkedIn API V2 comme 3e source journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Node Fetch-linkedin (LinkedIn REST API v2/posts, OAuth2 predefined credential) - Parser parseLinkedIn() dans Normalise : extrait commentary + UGC text, URL post, thumbnail - Fonction safeJson() parallèle à safeText() pour payloads JSON (vs XML) - Variable LINKEDIN_MEMBER_ID (env n8n) dans l'URL du endpoint - .env.example documenté avec LINKEDIN_MEMBER_ID + comment récupération - counts.linkedin ajouté dans le payload journal.json Prerequis L.1 bloquants (humain Jules) : - Redirect URL n8n dans LinkedIn Developer Portal - Credential LinkedIn OAuth2 créé dans n8n UI - Member ID Jules récupéré via /v2/me et stocké dans env n8n Branche depuis feat/v1.5-E-rsshub (V1.5 pas encore mergé sur main). Pas de modif Astro/Vue — JournalFeed (JournalList.vue) déjà platform-agnostic. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 12 +- ...-workflow-journal-aggregate-v2-rsshub.json | 229 +++++++++++++++--- 2 files changed, 208 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index c9cc685..aa1fe18 100644 --- a/.env.example +++ b/.env.example @@ -7,12 +7,18 @@ KIT_API_SECRET_V4=kit_xxx PUBLIC_BEHOLD_AEP= PUBLIC_BEHOLD_JULESNENY= -# Journal unifie (PC6 + V1.5-E) - URL JSON agrege par n8n cron 4h UTC -# Sources : RSSHub self-host (Insta @aep.politique + @julesneny + Substack @julesneny) -# + Atom Gitea natif (git.trans-former.fr/jules.atom) +# Journal unifie (V1.6) - URL JSON agrege par n8n cron 4h UTC +# Sources : RSSHub self-host (Insta @aep.politique + @julesneny) + Substack natif +# + Atom Gitea natif (git.trans-former.fr/jules.atom) + LinkedIn API V2 # Override en local : pointer vers un mock /public/data/journal.json par exemple PUBLIC_JOURNAL_URL=https://data.trans-former.fr/journal.json +# LinkedIn (V1.6) - Member ID du profil Jules (format numerique, sans urn: prefix) +# Recuperer via curl -H "Authorization: Bearer TOKEN" https://api.linkedin.com/v2/me | jq .id +# Stocke comme variable d'env n8n (Settings -> Variables) sous le nom LINKEDIN_MEMBER_ID +# Le workflow l'utilise comme : urn:li:person:${LINKEDIN_MEMBER_ID} +LINKEDIN_MEMBER_ID= + # Chatbot upstream (PC7) - URL backend chatbot AEP # V1 : chatbot AEP classique (Mistral Small + 120 fiches) # V1.5 : switch vers LightRAG-PE (1 ligne) diff --git a/docs/n8n-workflow-journal-aggregate-v2-rsshub.json b/docs/n8n-workflow-journal-aggregate-v2-rsshub.json index dacced7..984c60d 100644 --- a/docs/n8n-workflow-journal-aggregate-v2-rsshub.json +++ b/docs/n8n-workflow-journal-aggregate-v2-rsshub.json @@ -1,6 +1,6 @@ { "name": "journal-aggregate-v2-rsshub", - "_notes": "V1.5-E (2026-05-13) — remplace Behold par RSSHub self-host (rss.trans-former.fr) + ajoute Substack natif. Cron decale a 4h UTC (anti-rate-limit Insta). Si /instagram/user/* renvoie 503 (config Insta absente), le node tombe gracefully et le JSON final aura 0 item insta — ColInsta affichera fallbackBio. A importer dans n8n via UI (Workflows -> Import from File) ou API.", + "_notes": "V1.6 (2026-05-13) ? ajoute LinkedIn profil /in/jules-neny comme 3e source journal. V1.5-E : remplace Behold par RSSHub self-host + Substack natif. Prerequis L.1 : credential LinkedIn OAuth2 dans n8n + LINKEDIN_MEMBER_ID (env variable n8n). Cron 4h UTC. Import via UI (Workflows -> Import from File) ou API.", "nodes": [ { "parameters": { @@ -17,13 +17,20 @@ "name": "Cron-4h-UTC", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, - "position": [240, 320] + "position": [ + 240, + 320 + ] }, { "parameters": { "url": "https://git.trans-former.fr/jules.atom", "options": { - "response": { "response": { "responseFormat": "text" } }, + "response": { + "response": { + "responseFormat": "text" + } + }, "timeout": 15000 } }, @@ -31,13 +38,20 @@ "name": "Fetch-gitea", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [460, 160] + "position": [ + 460, + 160 + ] }, { "parameters": { "url": "https://julesneny.substack.com/feed", "options": { - "response": { "response": { "responseFormat": "text" } }, + "response": { + "response": { + "responseFormat": "text" + } + }, "timeout": 15000 } }, @@ -45,13 +59,21 @@ "name": "Fetch-substack-natif", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [460, 280] + "position": [ + 460, + 280 + ] }, { "parameters": { "url": "https://rss.trans-former.fr/instagram/user/aep.politique", "options": { - "response": { "response": { "neverError": true, "responseFormat": "text" } }, + "response": { + "response": { + "neverError": true, + "responseFormat": "text" + } + }, "timeout": 20000 } }, @@ -59,13 +81,21 @@ "name": "Fetch-rsshub-insta-aep", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [460, 400] + "position": [ + 460, + 400 + ] }, { "parameters": { "url": "https://rss.trans-former.fr/instagram/user/julesneny", "options": { - "response": { "response": { "neverError": true, "responseFormat": "text" } }, + "response": { + "response": { + "neverError": true, + "responseFormat": "text" + } + }, "timeout": 20000 } }, @@ -73,29 +103,64 @@ "name": "Fetch-rsshub-insta-julesneny", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [460, 520] + "position": [ + 460, + 520 + ] }, { "parameters": { - "jsCode": "// V1.5-E : Normalisation 4 sources -> JSON unifie\n// Sources : Gitea Atom (XML), Substack natif (RSS XML), RSSHub Insta x2 (Atom/RSS XML)\n\nconst items = [];\n\n// ---- Helper parse Atom (Gitea) ----\nfunction parseAtomGitea(xml) {\n const out = [];\n if (!xml || typeof xml !== 'string') return out;\n const entries = xml.split(//i).slice(1);\n for (const raw of entries) {\n const block = '' + raw.split(/<\\/entry>/i)[0] + '';\n const title = (block.match(/([\\s\\S]*?)<\\/title>/i) || [])[1] || '';\n const updated = (block.match(/<updated>([^<]+)<\\/updated>/i) || [])[1] || '';\n const link = (block.match(/<link[^>]*href=\"([^\"]+)\"/i) || [])[1] || '';\n const summary = (block.match(/<summary[^>]*>([\\s\\S]*?)<\\/summary>/i) || [])[1] || '';\n const id = (block.match(/<id>([^<]+)<\\/id>/i) || [])[1] || link || updated;\n if (!updated) continue;\n const decode = (s) => s\n .replace(/</g, '<').replace(/>/g, '>')\n .replace(/"/g, '\"').replace(/"/g, '\"')\n .replace(/ /g, ' ').replace(/&/g, '&')\n .replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim();\n out.push({\n id: 'gitea-' + (id.slice(0, 80) || updated),\n platform: 'gitea',\n hashtag: '#stack',\n date: new Date(updated).toISOString(),\n titre: decode(title).slice(0, 140),\n extrait: decode(summary).slice(0, 280),\n url: link,\n thumbnail: null,\n });\n }\n return out;\n}\n\n// ---- Helper parse RSS 2.0 (Substack, RSSHub) ----\nfunction parseRss(xml, platform, hashtag, fallbackUrl) {\n const out = [];\n if (!xml || typeof xml !== 'string') return out;\n const items = xml.split(/<item[\\s>]/i).slice(1);\n for (const raw of items) {\n const block = '<item ' + raw.split(/<\\/item>/i)[0] + '</item>';\n const cdata = (re) => {\n const m = block.match(re);\n if (!m) return '';\n let s = m[1] || '';\n s = s.replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, '$1');\n return s;\n };\n const title = cdata(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\n const link = cdata(/<link[^>]*>([\\s\\S]*?)<\\/link>/i).trim();\n const guid = cdata(/<guid[^>]*>([\\s\\S]*?)<\\/guid>/i).trim();\n const desc = cdata(/<description[^>]*>([\\s\\S]*?)<\\/description>/i);\n const pub = cdata(/<pubDate[^>]*>([\\s\\S]*?)<\\/pubDate>/i).trim();\n if (!pub && !title) continue;\n const decode = (s) => s\n .replace(/</g, '<').replace(/>/g, '>')\n .replace(/"/g, '\"').replace(/"/g, '\"')\n .replace(/&/g, '&')\n .replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim();\n const enclosure = (block.match(/<enclosure[^>]+url=\"([^\"]+)\"/i) || [])[1] || null;\n const mediaThumb = (block.match(/<media:thumbnail[^>]+url=\"([^\"]+)\"/i) || [])[1] || null;\n out.push({\n id: platform + '-' + (guid || link || pub).slice(0, 80),\n platform,\n hashtag,\n date: pub ? new Date(pub).toISOString() : new Date().toISOString(),\n titre: decode(title).slice(0, 140) || platform,\n extrait: decode(desc).slice(0, 280),\n url: link || fallbackUrl,\n thumbnail: mediaThumb || enclosure,\n });\n }\n return out;\n}\n\n// ---- Recupere payloads ----\nfunction safeText(nodeName) {\n try {\n const its = $(nodeName).all();\n if (!its.length || !its[0].json) return '';\n const j = its[0].json;\n return String(j.data || j.body || '');\n } catch (e) {\n console.log(nodeName + ' missing:', e.message);\n return '';\n }\n}\n\nconst giteaXml = safeText('Fetch-gitea');\nconst subXml = safeText('Fetch-substack-natif');\nconst rsshubAepXml = safeText('Fetch-rsshub-insta-aep');\nconst rsshubJnXml = safeText('Fetch-rsshub-insta-julesneny');\n\nfor (const it of parseAtomGitea(giteaXml)) items.push(it);\nfor (const it of parseRss(subXml, 'substack', '#substack', 'https://julesneny.substack.com')) items.push(it);\nfor (const it of parseRss(rsshubAepXml, 'instagram', '#aep-politique', 'https://instagram.com/aep.politique')) items.push(it);\nfor (const it of parseRss(rsshubJnXml, 'instagram', '#peinture', 'https://instagram.com/julesneny')) items.push(it);\n\n// ---- Dedup par id, tri desc, cap top 100 ----\nconst seen = new Set();\nconst uniq = items.filter((it) => {\n if (seen.has(it.id)) return false;\n seen.add(it.id);\n return true;\n});\nuniq.sort((a, b) => b.date.localeCompare(a.date));\nconst top = uniq.slice(0, 100);\n\nconst counts = {\n total: top.length,\n gitea: top.filter((i) => i.platform === 'gitea').length,\n substack: top.filter((i) => i.platform === 'substack').length,\n instagram: top.filter((i) => i.platform === 'instagram').length,\n};\n\nreturn [{ json: { generatedAt: new Date().toISOString(), items: top, counts } }];" + "url": "=https://api.linkedin.com/v2/posts?q=author&author=urn%3Ali%3Aperson%3A{{ $env.LINKEDIN_MEMBER_ID }}&count=20&sortBy=CREATED", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "linkedInOAuth2Api", + "options": { + "response": { + "response": { + "neverError": true, + "responseFormat": "json" + } + }, + "timeout": 20000 + } + }, + "id": "fetch-linkedin", + "name": "Fetch-linkedin", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 460, + 640 + ] + }, + { + "parameters": { + "jsCode": "// V1.6 : Normalisation 5 sources -> JSON unifie\n// Sources : Gitea Atom (XML), Substack natif (RSS XML), RSSHub Insta x2 (Atom/RSS XML), LinkedIn API V2 (JSON)\n\nconst items = [];\n\n// ---- Helper parse Atom (Gitea) ----\nfunction parseAtomGitea(xml) {\n const out = [];\n if (!xml || typeof xml !== 'string') return out;\n const entries = xml.split(/<entry>/i).slice(1);\n for (const raw of entries) {\n const block = '<entry>' + raw.split(/<\\/entry>/i)[0] + '</entry>';\n const title = (block.match(/<title>([\\s\\S]*?)<\\/title>/i) || [])[1] || '';\n const updated = (block.match(/<updated>([^<]+)<\\/updated>/i) || [])[1] || '';\n const link = (block.match(/<link[^>]*href=\"([^\"]+)\"/i) || [])[1] || '';\n const summary = (block.match(/<summary[^>]*>([\\s\\S]*?)<\\/summary>/i) || [])[1] || '';\n const id = (block.match(/<id>([^<]+)<\\/id>/i) || [])[1] || link || updated;\n if (!updated) continue;\n const decode = (s) => s\n .replace(/</g, '<').replace(/>/g, '>')\n .replace(/"/g, '\"').replace(/"/g, '\"')\n .replace(/ /g, ' ').replace(/&/g, '&')\n .replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim();\n out.push({\n id: 'gitea-' + (id.slice(0, 80) || updated),\n platform: 'gitea',\n hashtag: '#stack',\n date: new Date(updated).toISOString(),\n titre: decode(title).slice(0, 140),\n extrait: decode(summary).slice(0, 280),\n url: link,\n thumbnail: null,\n });\n }\n return out;\n}\n\n// ---- Helper parse RSS 2.0 (Substack, RSSHub) ----\nfunction parseRss(xml, platform, hashtag, fallbackUrl) {\n const out = [];\n if (!xml || typeof xml !== 'string') return out;\n const items = xml.split(/<item[\\s>]/i).slice(1);\n for (const raw of items) {\n const block = '<item ' + raw.split(/<\\/item>/i)[0] + '</item>';\n const cdata = (re) => {\n const m = block.match(re);\n if (!m) return '';\n let s = m[1] || '';\n s = s.replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, '$1');\n return s;\n };\n const title = cdata(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\n const link = cdata(/<link[^>]*>([\\s\\S]*?)<\\/link>/i).trim();\n const guid = cdata(/<guid[^>]*>([\\s\\S]*?)<\\/guid>/i).trim();\n const desc = cdata(/<description[^>]*>([\\s\\S]*?)<\\/description>/i);\n const pub = cdata(/<pubDate[^>]*>([\\s\\S]*?)<\\/pubDate>/i).trim();\n if (!pub && !title) continue;\n const decode = (s) => s\n .replace(/</g, '<').replace(/>/g, '>')\n .replace(/"/g, '\"').replace(/"/g, '\"')\n .replace(/&/g, '&')\n .replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim();\n const enclosure = (block.match(/<enclosure[^>]+url=\"([^\"]+)\"/i) || [])[1] || null;\n const mediaThumb = (block.match(/<media:thumbnail[^>]+url=\"([^\"]+)\"/i) || [])[1] || null;\n out.push({\n id: platform + '-' + (guid || link || pub).slice(0, 80),\n platform,\n hashtag,\n date: pub ? new Date(pub).toISOString() : new Date().toISOString(),\n titre: decode(title).slice(0, 140) || platform,\n extrait: decode(desc).slice(0, 280),\n url: link || fallbackUrl,\n thumbnail: mediaThumb || enclosure,\n });\n }\n return out;\n}\n// ---- Helper parse LinkedIn API V2 ----\nfunction parseLinkedIn(payload) {\n const out = [];\n if (!payload || !payload.elements || !Array.isArray(payload.elements)) return out;\n for (const el of payload.elements) {\n const specificText = el.specificContent?.['com.linkedin.ugc.ShareContent']?.shareCommentary?.text;\n const text = (el.commentary || specificText || '').trim();\n const created = el.createdAt || el.firstPublishedAt;\n if (!created || !text) continue;\n const dateIso = new Date(created).toISOString();\n const urn = el.id || '';\n const url = urn ? ('https://www.linkedin.com/feed/update/' + urn + '/') : 'https://www.linkedin.com/in/jules-neny';\n const thumb = el.content?.media?.[0]?.thumbnails?.[0]?.url || null;\n out.push({\n id: 'linkedin-' + (urn.slice(0, 80) || dateIso),\n platform: 'linkedin',\n hashtag: '#building-public',\n date: dateIso,\n titre: text.split('\\n')[0].slice(0, 140),\n extrait: text.replace(/\\n+/g, ' ').slice(0, 280),\n url,\n thumbnail: thumb,\n });\n }\n return out;\n}\n\n// ---- Recupere payloads ----\nfunction safeText(nodeName) {\n try {\n const its = $(nodeName).all();\n if (!its.length || !its[0].json) return '';\n const j = its[0].json;\n return String(j.data || j.body || '');\n } catch (e) {\n console.log(nodeName + ' missing:', e.message);\n return '';\n }\n}\nfunction safeJson(nodeName) {\n try {\n const its = $(nodeName).all();\n if (!its.length || !its[0].json) return null;\n return its[0].json;\n } catch (e) { return null; }\n}\n\nconst giteaXml = safeText('Fetch-gitea');\nconst subXml = safeText('Fetch-substack-natif');\nconst rsshubAepXml = safeText('Fetch-rsshub-insta-aep');\nconst rsshubJnXml = safeText('Fetch-rsshub-insta-julesneny');\nconst linkedinPayload = safeJson('Fetch-linkedin');\n\nfor (const it of parseAtomGitea(giteaXml)) items.push(it);\nfor (const it of parseRss(subXml, 'substack', '#substack', 'https://julesneny.substack.com')) items.push(it);\nfor (const it of parseRss(rsshubAepXml, 'instagram', '#aep-politique', 'https://instagram.com/aep.politique')) items.push(it);\nfor (const it of parseRss(rsshubJnXml, 'instagram', '#peinture', 'https://instagram.com/julesneny')) items.push(it);\nfor (const it of parseLinkedIn(linkedinPayload)) items.push(it);\n\n// ---- Dedup par id, tri desc, cap top 100 ----\nconst seen = new Set();\nconst uniq = items.filter((it) => {\n if (seen.has(it.id)) return false;\n seen.add(it.id);\n return true;\n});\nuniq.sort((a, b) => b.date.localeCompare(a.date));\nconst top = uniq.slice(0, 100);\n\nconst counts = {\n total: top.length,\n gitea: top.filter((i) => i.platform === 'gitea').length,\n substack: top.filter((i) => i.platform === 'substack').length,\n instagram: top.filter((i) => i.platform === 'instagram').length,\n linkedin: top.filter((i) => i.platform === 'linkedin').length,\n};\n\nreturn [{ json: { generatedAt: new Date().toISOString(), items: top, counts } }];" }, "id": "normalise", "name": "Normalise", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [720, 340] + "position": [ + 720, + 340 + ] }, { "parameters": { "operation": "toJson", "fieldName": "data", - "options": { "format": true } + "options": { + "format": true + } }, "id": "to-json", "name": "To-json-string", "type": "n8n-nodes-base.set", "typeVersion": 3.4, - "position": [940, 340] + "position": [ + 940, + 340 + ] }, { "parameters": { @@ -108,24 +173,121 @@ "name": "Write-journal-json", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, - "position": [1160, 340] + "position": [ + 1160, + 340 + ] } ], "connections": { "Cron-4h-UTC": { - "main": [[ - { "node": "Fetch-gitea", "type": "main", "index": 0 }, - { "node": "Fetch-substack-natif", "type": "main", "index": 0 }, - { "node": "Fetch-rsshub-insta-aep", "type": "main", "index": 0 }, - { "node": "Fetch-rsshub-insta-julesneny", "type": "main", "index": 0 } - ]] + "main": [ + [ + { + "node": "Fetch-gitea", + "type": "main", + "index": 0 + }, + { + "node": "Fetch-substack-natif", + "type": "main", + "index": 0 + }, + { + "node": "Fetch-rsshub-insta-aep", + "type": "main", + "index": 0 + }, + { + "node": "Fetch-rsshub-insta-julesneny", + "type": "main", + "index": 0 + }, + { + "node": "Fetch-linkedin", + "type": "main", + "index": 0 + } + ] + ] }, - "Fetch-gitea": { "main": [[{ "node": "Normalise", "type": "main", "index": 0 }]] }, - "Fetch-substack-natif": { "main": [[{ "node": "Normalise", "type": "main", "index": 0 }]] }, - "Fetch-rsshub-insta-aep": { "main": [[{ "node": "Normalise", "type": "main", "index": 0 }]] }, - "Fetch-rsshub-insta-julesneny": { "main": [[{ "node": "Normalise", "type": "main", "index": 0 }]] }, - "Normalise": { "main": [[{ "node": "To-json-string", "type": "main", "index": 0 }]] }, - "To-json-string": { "main": [[{ "node": "Write-journal-json", "type": "main", "index": 0 }]] } + "Fetch-gitea": { + "main": [ + [ + { + "node": "Normalise", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch-substack-natif": { + "main": [ + [ + { + "node": "Normalise", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch-rsshub-insta-aep": { + "main": [ + [ + { + "node": "Normalise", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch-rsshub-insta-julesneny": { + "main": [ + [ + { + "node": "Normalise", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalise": { + "main": [ + [ + { + "node": "To-json-string", + "type": "main", + "index": 0 + } + ] + ] + }, + "To-json-string": { + "main": [ + [ + { + "node": "Write-journal-json", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch-linkedin": { + "main": [ + [ + { + "node": "Normalise", + "type": "main", + "index": 0 + } + ] + ] + } }, "settings": { "executionOrder": "v1", @@ -133,7 +295,14 @@ "saveManualExecutions": true }, "tags": [ - { "name": "page-cerveau" }, - { "name": "V1.5-E" } + { + "name": "page-cerveau" + }, + { + "name": "V1.5-E" + }, + { + "name": "V1.6" + } ] -} +} \ No newline at end of file