From 458a152525eabe2112581e2eb27898c16a334656 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Wed, 13 May 2026 00:56:36 +0200 Subject: [PATCH] V1.5-E RSSHub journal feed (E.1-E.8) Bascule Behold -> RSSHub self-host (rss.trans-former.fr) : - InstaFeed.vue : consomme PUBLIC_JOURNAL_URL (journal unifie n8n) au lieu de feeds.behold.so direct. Filtre platform=instagram + account dans l'url. Plus de feedId Behold a passer. - ColInsta.astro : supprime props feedId, passe seulement account handle (aep.politique, julesneny) pour filtrage cote client. - .env.example : PUBLIC_BEHOLD_* deprecies (conservees pour compat), PUBLIC_JOURNAL_URL note V1.5-E source RSSHub. - docs/n8n-workflow-journal-aggregate-v2-rsshub.json : workflow V2 a importer dans n8n. Remplace 3 nodes Behold par : Substack natif (julesneny.substack.com/feed) + RSSHub Insta x2 (rss.trans-former.fr/ instagram/user/*). Cron 3h -> 4h UTC (anti-rate-limit). Parser RSS ajoute (CDATA, dedup par GUID). - carte-o.json : regen prebuild auto. Ops VPS realisees hors repo (E.1-E.3, E.8 conf) : - docker pull diygod/rsshub:latest (image officielle, pas rsshub/rsshub) - /opt/rsshub/docker-compose.yml : RSSHub + Redis cache, port 3006 local - Caddy : bloc rss.trans-former.fr -> reverse_proxy 127.0.0.1:3006 CHECKPOINT E.4 BLOQUANT : DNS OVH 'rss' IN A 178.104.106.195 a pousser manuellement par Jules. Sans DNS, rss.trans-former.fr inaccessible. LIMITE FONCTIONNELLE detectee : RSSHub /instagram/* requiert config Instagram cookie (ConfigNotFoundError 503). Tant que les env vars INSTAGRAM_USERNAME/PASSWORD ou cookie ne sont pas fournis a RSSHub, les routes Insta retourneront vide. Le journal aura 0 item insta et ColInsta affichera fallbackBio. Substack et Gitea sources OK. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 11 +- ...-workflow-journal-aggregate-v2-rsshub.json | 139 ++++++++++++++++++ public/data/carte-o.json | 2 +- src/components/astro/ColInsta.astro | 12 +- src/components/vue/InstaFeed.vue | 88 +++++++---- 5 files changed, 214 insertions(+), 38 deletions(-) create mode 100644 docs/n8n-workflow-journal-aggregate-v2-rsshub.json diff --git a/.env.example b/.env.example index 81ec839..c9cc685 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,15 @@ # Kit (ex-ConvertKit) - newsletter infolettre KIT_API_SECRET_V4=kit_xxx -# Behold.so feed IDs (voir docs/BEHOLD-SETUP.md) -# 1) Inscris-toi sur https://behold.so/dashboard -# 2) Connecte les 2 comptes Insta (@aep.politique + @julesneny) -# 3) Recupere les feed IDs et copie ce fichier vers .env.local puis remplis ci-dessous +# Behold.so : DEPRECATED V1.5-E — remplace par RSSHub self-host (rss.trans-former.fr). +# InstaFeed.vue consomme desormais PUBLIC_JOURNAL_URL (filtre platform=instagram). +# Les 2 vars ci-dessous ne sont plus lues ; conservees pour compat.env.local existant. PUBLIC_BEHOLD_AEP= PUBLIC_BEHOLD_JULESNENY= -# Journal unifie (PC6) - URL JSON agrege par n8n cron nocturne +# 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) # Override en local : pointer vers un mock /public/data/journal.json par exemple PUBLIC_JOURNAL_URL=https://data.trans-former.fr/journal.json diff --git a/docs/n8n-workflow-journal-aggregate-v2-rsshub.json b/docs/n8n-workflow-journal-aggregate-v2-rsshub.json new file mode 100644 index 0000000..dacced7 --- /dev/null +++ b/docs/n8n-workflow-journal-aggregate-v2-rsshub.json @@ -0,0 +1,139 @@ +{ + "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.", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 4 * * *" + } + ] + } + }, + "id": "schedule-trigger", + "name": "Cron-4h-UTC", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.1, + "position": [240, 320] + }, + { + "parameters": { + "url": "https://git.trans-former.fr/jules.atom", + "options": { + "response": { "response": { "responseFormat": "text" } }, + "timeout": 15000 + } + }, + "id": "fetch-gitea", + "name": "Fetch-gitea", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [460, 160] + }, + { + "parameters": { + "url": "https://julesneny.substack.com/feed", + "options": { + "response": { "response": { "responseFormat": "text" } }, + "timeout": 15000 + } + }, + "id": "fetch-substack", + "name": "Fetch-substack-natif", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [460, 280] + }, + { + "parameters": { + "url": "https://rss.trans-former.fr/instagram/user/aep.politique", + "options": { + "response": { "response": { "neverError": true, "responseFormat": "text" } }, + "timeout": 20000 + } + }, + "id": "fetch-rsshub-aep", + "name": "Fetch-rsshub-insta-aep", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [460, 400] + }, + { + "parameters": { + "url": "https://rss.trans-former.fr/instagram/user/julesneny", + "options": { + "response": { "response": { "neverError": true, "responseFormat": "text" } }, + "timeout": 20000 + } + }, + "id": "fetch-rsshub-julesneny", + "name": "Fetch-rsshub-insta-julesneny", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "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 } }];" + }, + "id": "normalise", + "name": "Normalise", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [720, 340] + }, + { + "parameters": { + "operation": "toJson", + "fieldName": "data", + "options": { "format": true } + }, + "id": "to-json", + "name": "To-json-string", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [940, 340] + }, + { + "parameters": { + "operation": "write", + "fileName": "/home/node/.n8n/journal/journal.json", + "dataPropertyName": "data", + "options": {} + }, + "id": "write-file", + "name": "Write-journal-json", + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "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 } + ]] + }, + "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 }]] } + }, + "settings": { + "executionOrder": "v1", + "saveExecutionProgress": true, + "saveManualExecutions": true + }, + "tags": [ + { "name": "page-cerveau" }, + { "name": "V1.5-E" } + ] +} diff --git a/public/data/carte-o.json b/public/data/carte-o.json index 97ffa82..ddb8432 100644 --- a/public/data/carte-o.json +++ b/public/data/carte-o.json @@ -1,6 +1,6 @@ { "version": "1.1", - "generatedAt": "2026-05-12T09:28:20.972Z", + "generatedAt": "2026-05-12T22:53:33.094Z", "nodes": [ { "id": "contrat-social-medecine-corps-social", diff --git a/src/components/astro/ColInsta.astro b/src/components/astro/ColInsta.astro index 93560e4..0242178 100644 --- a/src/components/astro/ColInsta.astro +++ b/src/components/astro/ColInsta.astro @@ -1,23 +1,21 @@ --- import InstaFeed from '../vue/InstaFeed.vue'; -// Feed IDs Behold a remplir apres inscription Behold (voir docs/BEHOLD-SETUP.md) -const FEED_AEP = import.meta.env.PUBLIC_BEHOLD_AEP || 'PLACEHOLDER_AEP'; -const FEED_JULESNENY = import.meta.env.PUBLIC_BEHOLD_JULESNENY || 'PLACEHOLDER_JULESNENY'; +// V1.5-E : Behold remplacé par RSSHub self-host. InstaFeed lit désormais +// le journal unifié (PUBLIC_JOURNAL_URL → data.trans-former.fr/journal.json) +// agrégé par n8n depuis rss.trans-former.fr. Plus de feedId Behold à passer. --- <div class="h-full overflow-y-auto"> <InstaFeed client:visible - feedId={FEED_AEP} - account="@aep.politique" + account="aep.politique" accountUrl="https://www.instagram.com/aep.politique/" fallbackBio="Carrousels manifeste AEP ; pensee politique eco-architecture" /> <InstaFeed client:visible - feedId={FEED_JULESNENY} - account="@julesneny" + account="julesneny" accountUrl="https://www.instagram.com/julesneny/" fallbackBio="Peinture, poesie, Corse ; archives visuelles personnelles" /> diff --git a/src/components/vue/InstaFeed.vue b/src/components/vue/InstaFeed.vue index 4e3ccbe..d7a8206 100644 --- a/src/components/vue/InstaFeed.vue +++ b/src/components/vue/InstaFeed.vue @@ -1,46 +1,80 @@ <script setup lang="ts"> import { ref, onMounted } from 'vue'; -interface BeholdPost { +// V1.5-E : on consomme désormais le journal unifié agrégé par n8n +// (sources RSSHub self-host → data.trans-former.fr/journal.json), +// au lieu de l'API Behold directe (rate-limit, plafond 6 posts gratuit). +interface JournalItem { + id: string; + platform: string; + date: string; + titre: string; + extrait?: string; + url: string; + thumbnail: string | null; +} + +interface JournalPayload { + generatedAt: string; + items: JournalItem[]; +} + +interface InstaPost { id: string; permalink: string; - mediaUrl: string; - thumbnailUrl?: string; - caption?: string; - mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM'; + thumbnailUrl: string | null; + caption: string; timestamp: string; } const props = defineProps<{ - feedId: string; + /** handle Instagram sans @ (ex: 'aep.politique', 'julesneny') — sert à filtrer le journal */ account: string; accountUrl: string; fallbackBio?: string; + /** nombre max de posts affichés (défaut 6, parité avec ancien Behold) */ + max?: number; }>(); -const posts = ref<BeholdPost[]>([]); +const posts = ref<InstaPost[]>([]); const loading = ref(true); const error = ref<string | null>(null); -const isPlaceholder = (id: string) => !id || id.startsWith('PLACEHOLDER_'); +const JOURNAL_URL = + (import.meta as unknown as { env: Record<string, string | undefined> }).env + .PUBLIC_JOURNAL_URL || 'https://data.trans-former.fr/journal.json'; + +const accountHandle = (props.account || '').replace(/^@/, '').toLowerCase(); +const limit = props.max ?? 6; onMounted(async () => { - if (isPlaceholder(props.feedId)) { - loading.value = false; - error.value = 'no-feed-id'; - return; - } try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - const res = await fetch(`https://feeds.behold.so/${props.feedId}`, { + const timeoutId = setTimeout(() => controller.abort(), 8000); + const res = await fetch(JOURNAL_URL, { signal: controller.signal, + cache: 'no-store', }); clearTimeout(timeoutId); - if (!res.ok) throw new Error(`Behold returned ${res.status}`); - const data = await res.json(); - const items: BeholdPost[] = Array.isArray(data) ? data : (data.posts ?? []); - posts.value = items.slice(0, 6); + if (!res.ok) throw new Error(`Journal returned ${res.status}`); + const data = (await res.json()) as JournalPayload; + const all = Array.isArray(data?.items) ? data.items : []; + posts.value = all + .filter( + (it) => + it.platform === 'instagram' && + typeof it.url === 'string' && + it.url.toLowerCase().includes(`/${accountHandle}`), + ) + .slice(0, limit) + .map((it) => ({ + id: it.id, + permalink: it.url, + thumbnailUrl: it.thumbnail ?? null, + caption: it.titre || it.extrait || '', + timestamp: it.date, + })); + if (!posts.value.length) error.value = 'no-posts'; } catch (e) { error.value = (e as Error).message || 'fetch-error'; } finally { @@ -71,24 +105,28 @@ onMounted(async () => { /> </div> - <div - v-else-if="posts.length" - class="grid grid-cols-2 gap-1 p-1" - > + <div v-else-if="posts.length" class="grid grid-cols-2 gap-1 p-1"> <a v-for="post in posts" :key="post.id" :href="post.permalink" target="_blank" rel="noopener" - class="block aspect-square overflow-hidden group" + class="block aspect-square overflow-hidden group bg-neutral-100" > <img - :src="post.thumbnailUrl || post.mediaUrl" + v-if="post.thumbnailUrl" + :src="post.thumbnailUrl" :alt="post.caption?.slice(0, 80) || account" loading="lazy" class="w-full h-full object-cover group-hover:scale-105 transition-transform" /> + <span + v-else + class="w-full h-full flex items-center justify-center text-[10px] text-neutral-400 p-2 text-center" + > + {{ post.caption?.slice(0, 60) || account }} + </span> </a> </div>