{
  "name": "Recap Esport",
  "nodes": [
    {
      "parameters": {},
      "id": "1f0a0a01-0001-0001-0001-000000000001",
      "name": "Test manuel",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        120
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            },
            {
              "field": "cronExpression",
              "expression": "0 11 * * *"
            },
            {
              "field": "cronExpression",
              "expression": "0 18 * * *"
            },
            {
              "field": "cronExpression",
              "expression": "0 23 * * *"
            }
          ]
        }
      },
      "id": "1f0a0a01-0001-0001-0001-000000000002",
      "name": "06h 11h 18h 23h",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        -80
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ============================================================================\n// \ud83d\udcdd CONFIGURER LE SUJET \u2014 Esport\n// ----------------------------------------------------------------------------\n// \ud83d\udc49 Ce n\u0153ud est G\u00c9N\u00c9R\u00c9 depuis n8n-workflows/topics/configs/esport.js.\n//    Pour modifier ce sujet : \u00e9dite ce fichier, puis lance :\n//      node n8n-workflows/topics/build.js\n//      node n8n-workflows/topics/push-all.js   (synchronise n8n)\n// ============================================================================\n\nconst TOPIC = {\n  \"slug\": \"esport\",\n  \"label\": \"Esport\",\n  \"description\": \"Comp\u00e9titions, rosters, tournois et sc\u00e8nes FR / internationales.\",\n  \"mode\": \"fr-intl\",\n  \"searchTerms\": \"(esport OR esports OR e-sport OR VCT OR LEC OR LCK OR LFL OR LCS OR EWC OR \\\"Esports World Cup\\\" OR CS2 OR \\\"Counter-Strike\\\" OR BLAST OR IEM OR PGL OR RLCS OR roster OR mercato OR playoffs OR qualifiers)\",\n  \"positiveKeywords\": [\n    \"esport\",\n    \"e-sport\",\n    \"esports\",\n    \"league of legends\",\n    \"valorant\",\n    \"counter-strike\",\n    \"cs2\",\n    \"rocket league\",\n    \"dota 2\",\n    \"overwatch\",\n    \"lec\",\n    \"lck\",\n    \"lfl\",\n    \"lcs\",\n    \"vct\",\n    \"rlcs\",\n    \"blast\",\n    \"iem\",\n    \"pgl\",\n    \"ewc\",\n    \"esports world cup\",\n    \"playoffs\",\n    \"qualifier\",\n    \"qualifications\",\n    \"tournament\",\n    \"tournoi\",\n    \"roster\",\n    \"mercato\",\n    \"transfer\",\n    \"bench\",\n    \"team vitality\",\n    \"karmine\",\n    \"fnatic\",\n    \"g2 esports\",\n    \"hltv\",\n    \"stage 1\",\n    \"bracket\",\n    \"worlds\",\n    \"msi\"\n  ],\n  \"negativeKeywords\": [\n    \"how to complete\",\n    \"questline\",\n    \"quest line\",\n    \"week \",\n    \"challenges\",\n    \"challenge guide\",\n    \"where to find\",\n    \"how to solve\",\n    \"how to earn\",\n    \"unlock \",\n    \"walkthrough\",\n    \"loadout\",\n    \"build guide\",\n    \"codes \",\n    \"camos\",\n    \"weapon prestige\",\n    \"night market\",\n    \"far far west\",\n    \"tier list\",\n    \"patch notes\",\n    \"notes de patch\",\n    \"carte interactive\",\n    \"soluce\",\n    \"planning des patchs\",\n    \"boutique officielle\",\n    \"nos partenaires\",\n    \"nos ambassadeurs\",\n    \"maillot \",\n    \"tapis de souris\",\n    \"t-shirt\",\n    \"accueil - mandatory\"\n  ],\n  \"maxAgeDays\": {\n    \"google\": 3,\n    \"rss\": 7\n  },\n  \"caps\": {\n    \"fr\": 14,\n    \"intl\": 18,\n    \"total\": 36\n  },\n  \"editorialHints\": \"- Ignore guides, qu\u00eates, patch notes g\u00e9n\u00e9riques, culture gaming hors comp\u00e9tition.\\n- Ne d\u00e9forme pas le niveau de comp\u00e9tition : qualifier, playoffs, phase de groupes, ligue r\u00e9gionale, tournoi principal restent distincts.\\n- Si un article parle de qualifications EMEA pour un tournoi, \u00e9cris 'qualifications EMEA pour <tournoi>', jamais 'au <tournoi>'.\",\n  \"sources\": [\n    {\n      \"name\": \"Mandatory.gg\",\n      \"region\": \"fr\",\n      \"method\": \"google\",\n      \"siteDomain\": \"mandatory.gg\",\n      \"max\": 5\n    },\n    {\n      \"name\": \"Millenium\",\n      \"region\": \"fr\",\n      \"method\": \"google\",\n      \"siteDomain\": \"millenium.org\",\n      \"max\": 5\n    },\n    {\n      \"name\": \"Team-aAa\",\n      \"region\": \"fr\",\n      \"method\": \"rss\",\n      \"url\": \"https://www.team-aaa.com/rss/full.xml\",\n      \"max\": 7\n    },\n    {\n      \"name\": \"Breakflip\",\n      \"region\": \"fr\",\n      \"method\": \"google\",\n      \"siteDomain\": \"breakflip.com\",\n      \"max\": 5\n    },\n    {\n      \"name\": \"L'\u00c9quipe Esport\",\n      \"region\": \"fr\",\n      \"method\": \"google\",\n      \"siteDomain\": \"lequipe.fr\",\n      \"max\": 4\n    },\n    {\n      \"name\": \"Dot Esports\",\n      \"region\": \"intl\",\n      \"method\": \"rss\",\n      \"url\": \"https://dotesports.com/feed\",\n      \"max\": 6\n    },\n    {\n      \"name\": \"Dexerto Esports\",\n      \"region\": \"intl\",\n      \"method\": \"rss\",\n      \"url\": \"https://www.dexerto.com/esports/feed/\",\n      \"max\": 6\n    },\n    {\n      \"name\": \"Esports Insider\",\n      \"region\": \"intl\",\n      \"method\": \"rss\",\n      \"url\": \"https://esportsinsider.com/feed\",\n      \"max\": 6\n    },\n    {\n      \"name\": \"HLTV\",\n      \"region\": \"intl\",\n      \"method\": \"rss\",\n      \"url\": \"https://www.hltv.org/rss/news\",\n      \"max\": 4\n    },\n    {\n      \"name\": \"Sheep Esports\",\n      \"region\": \"intl\",\n      \"method\": \"google\",\n      \"siteDomain\": \"sheepesports.com\",\n      \"max\": 4\n    },\n    {\n      \"name\": \"Win.gg\",\n      \"region\": \"intl\",\n      \"method\": \"google\",\n      \"siteDomain\": \"win.gg\",\n      \"max\": 4\n    },\n    {\n      \"name\": \"Esports Charts\",\n      \"region\": \"intl\",\n      \"method\": \"google\",\n      \"siteDomain\": \"escharts.com\",\n      \"max\": 3\n    },\n    {\n      \"name\": \"The Esports Advocate\",\n      \"region\": \"intl\",\n      \"method\": \"rss\",\n      \"url\": \"https://esportsadvocate.net/feed\",\n      \"max\": 4\n    }\n  ]\n};\n\n// ============================================================================\r\n// \u274c NE PAS MODIFIER CE QUI SUIT - Logique partag\u00e9e entre tous les sujets.\r\n// ============================================================================\r\n\r\nconst VALID_MODES = new Set(['fr', 'intl', 'fr-intl']);\r\nconst mode = VALID_MODES.has(TOPIC.mode) ? TOPIC.mode : 'fr-intl';\r\n\r\n// Normalise les sources Google en URL Google News RSS (sans toucher au reste).\r\nfunction buildGoogleNewsUrl(siteDomain, searchTerms) {\r\n  const query = [\r\n    `site:${siteDomain}`,\r\n    searchTerms,\r\n    '-guide',\r\n    '-walkthrough',\r\n    '-soluce',\r\n    '-questline',\r\n    '-challenges',\r\n    '-unlock',\r\n    '-loadout'\r\n  ].join(' ');\r\n  return `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=fr&gl=FR&ceid=FR:fr`;\r\n}\r\n\r\nconst sources = (TOPIC.sources || [])\r\n  .filter((source) => {\r\n    if (mode === 'fr') return source.region === 'fr';\r\n    if (mode === 'intl') return source.region === 'intl';\r\n    return true;\r\n  })\r\n  .map((source) => ({\r\n    name: source.name,\r\n    region: source.region,\r\n    method: source.method,\r\n    max: Number(source.max || 5),\r\n    url:\r\n      source.method === 'google'\r\n        ? buildGoogleNewsUrl(source.siteDomain || source.url, TOPIC.searchTerms || '')\r\n        : source.url\r\n  }));\r\n\r\nconst config = {\r\n  topic: {\r\n    slug: String(TOPIC.slug || 'unknown').trim().toLowerCase(),\r\n    label: String(TOPIC.label || TOPIC.slug || 'Sujet').trim(),\r\n    description: String(TOPIC.description || '').trim(),\r\n    mode,\r\n    searchTerms: String(TOPIC.searchTerms || '').trim(),\r\n    positiveKeywords: TOPIC.positiveKeywords || [],\r\n    negativeKeywords: TOPIC.negativeKeywords || [],\r\n    maxAgeDays: {\r\n      google: Number(TOPIC.maxAgeDays?.google ?? 3),\r\n      rss: Number(TOPIC.maxAgeDays?.rss ?? 7)\r\n    },\r\n    caps: {\r\n      fr: Number(TOPIC.caps?.fr ?? 14),\r\n      intl: Number(TOPIC.caps?.intl ?? 18),\r\n      total: Number(TOPIC.caps?.total ?? 36)\r\n    },\r\n    editorialHints: String(TOPIC.editorialHints || '').trim()\r\n  },\r\n  sources,\r\n  blog: {\r\n    secret: String($env.BLOG_SECRET || 'dev-change-me').trim(),\r\n    internalUrl: String($env.BLOG_INTERNAL_URL || 'http://blog:3001').replace(/\\/$/, '')\r\n  },\r\n  gemini: {\r\n    apiKey: String($env.GEMINI_API_KEY || '').trim(),\r\n    model: String($env.GEMINI_MODEL || 'gemini-2.5-flash').trim(),\r\n    maxPerMinute: Number($env.GEMINI_MAX_PER_MINUTE || 5),\r\n    maxWaitSeconds: Number($env.GEMINI_MAX_WAIT_SECONDS || 1800)\r\n  },\r\n  runDate: new Date().toISOString().slice(0, 10)\r\n};\r\n\r\nreturn [{ json: config }];\r\n"
      },
      "id": "1f0a0a01-0001-0001-0001-000000000010",
      "name": "Configurer le sujet",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        20
      ],
      "notes": "\ud83d\udcdd Edite UNIQUEMENT cet objet TOPIC pour ajouter ou modifier un sujet."
    },
    {
      "parameters": {
        "url": "={{ $json.blog.internalUrl }}/api/topics/{{ $json.topic.slug }}/same-day-urls?date={{ $json.runDate }}",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "1f0a0a01-0001-0001-0001-000000000020",
      "name": "Charger URLs deja publiees",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        480,
        20
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ============================================================================\r\n// 2. Pr\u00e9parer les sources (auto)\r\n// ----------------------------------------------------------------------------\r\n// Lit la config sortie par le n\u0153ud \"Configurer le sujet\" et \u00e9met un item par\r\n// source \u00e0 t\u00e9l\u00e9charger, en y embarquant les URLs d\u00e9j\u00e0 publi\u00e9es aujourd'hui\r\n// (anti-doublon entre runs).\r\n// ============================================================================\r\n\r\nconst config = $items('Configurer le sujet')[0]?.json || {};\r\nconst sameDayResponse = $input.first()?.json || {};\r\nconst sameDayUrls = Array.isArray(sameDayResponse.urls) ? sameDayResponse.urls : [];\r\n\r\nreturn (config.sources || []).map((source) => ({\r\n  json: {\r\n    ...source,\r\n    sameDayUrls,\r\n    topic: config.topic,\r\n    blog: config.blog,\r\n    gemini: config.gemini,\r\n    runDate: config.runDate\r\n  }\r\n}));\r\n"
      },
      "id": "1f0a0a01-0001-0001-0001-000000000030",
      "name": "Preparer les sources",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        20
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "user-agent",
              "value": "Mozilla/5.0 patch-notes.fr radar"
            },
            {
              "name": "accept",
              "value": "application/rss+xml, application/xml, text/xml, */*"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "text",
              "outputPropertyName": "rss"
            }
          },
          "timeout": 20000
        }
      },
      "id": "1f0a0a01-0001-0001-0001-000000000040",
      "name": "Telecharger les flux",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        960,
        20
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ============================================================================\r\n// 3. Assembler le r\u00e9cap (auto)\r\n// ----------------------------------------------------------------------------\r\n// Lit la config + tous les flux t\u00e9l\u00e9charg\u00e9s, parse le XML/RSS/Atom, applique\r\n// les filtres positifs/n\u00e9gatifs et l'\u00e2ge max, d\u00e9doublonne et construit :\r\n//   - le prompt Gemini (orient\u00e9 \u00e9dito, pas admin),\r\n//   - un r\u00e9cap fallback pr\u00eat \u00e0 publier sans IA (lead + 2-3 phrases courtes,\r\n//     z\u00e9ro mention \"France : X articles\", z\u00e9ro mention \"Sources \u00e0 v\u00e9rifier\").\r\n//\r\n// Sortie unique : { secret, fallbackTitle, fallbackSummary, recapDate,\r\n//                   geminiModel, geminiApiKey, geminiRequest, postBase }\r\n// ============================================================================\r\n\r\nfunction decodeHtml(value = '') {\r\n  return String(value)\r\n    .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, '$1')\r\n    .replace(/&nbsp;/g, ' ')\r\n    .replace(/&amp;/g, '&')\r\n    .replace(/&#39;/g, \"'\")\r\n    .replace(/&apos;/g, \"'\")\r\n    .replace(/&quot;/g, '\"')\r\n    .replace(/&lt;/g, '<')\r\n    .replace(/&gt;/g, '>')\r\n    .replace(/&#038;/g, '&')\r\n    .replace(/<[^>]+>/g, ' ')\r\n    .replace(/\\s+/g, ' ')\r\n    .trim();\r\n}\r\n\r\nfunction pickRaw(block, tag) {\r\n  const escapedTag = tag.replace(':', '\\\\:');\r\n  const match = String(block || '').match(new RegExp(`<${escapedTag}[^>]*>([\\\\s\\\\S]*?)<\\\\/${escapedTag}>`, 'i'));\r\n  return match?.[1] || '';\r\n}\r\n\r\nfunction pick(block, tag) {\r\n  return decodeHtml(pickRaw(block, tag));\r\n}\r\n\r\nfunction cleanUrl(value) {\r\n  const text = decodeHtml(value)\r\n    .replace(/^url:\\s*/i, '')\r\n    .replace(/^link:\\s*/i, '')\r\n    .replace(/^guid:\\s*/i, '')\r\n    .replace(/^[\\s\"'<>]+|[\\s\"'<>]+$/g, '')\r\n    .trim();\r\n  const match = text.match(/https?:\\/\\/[^\\s\"'<>]+/i);\r\n  return match ? match[0] : text;\r\n}\r\n\r\nfunction pickLink(block) {\r\n  const href = String(block || '').match(/<link\\b[^>]*\\bhref=[\"']([^\"']+)[\"'][^>]*\\/?>/i)?.[1];\r\n  return cleanUrl(href || pickRaw(block, 'link') || pickRaw(block, 'guid') || pickRaw(block, 'id'));\r\n}\r\n\r\nfunction stripSourceSuffix(title, sourceName) {\r\n  return String(title || '')\r\n    .replace(new RegExp(`\\\\s+-\\\\s+${String(sourceName || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}$`, 'i'), '')\r\n    .replace(/\\s+-\\s+Google News$/i, '')\r\n    .trim();\r\n}\r\n\r\nfunction normalizeUrl(value) {\r\n  const raw = cleanUrl(value);\r\n  const match = String(raw || '').match(/^https?:\\/\\/[^\\s\"'<>]+/i);\r\n  if (!match) return '';\r\n  return match[0].replace(/[?#].*$/, '').replace(/\\/$/, '').toLowerCase();\r\n}\r\n\r\nfunction recapSlot(date) {\r\n  const hour = date.getHours();\r\n  if (hour < 8) return 'Dans la nuit';\r\n  if (hour < 13) return 'Matinal';\r\n  if (hour < 21) return 'Cet aprem';\r\n  return 'Soir';\r\n}\r\n\r\n// On v\u00e9rifie le filtre positif uniquement sur le contenu (titre + extrait),\r\n// PAS sur le nom de la source : sinon n'importe quel article publi\u00e9 par\r\n// \"Dot Esports\" ou \"Esports Insider\" passe automatiquement parce que le mot\r\n// \"esports\" est dans le nom du m\u00e9dia. Le n\u00e9gatif, lui, peut regarder large\r\n// (titre + extrait + source) pour \u00e9viter une source-pourriel cibl\u00e9e.\r\nfunction isTopicArticle(article, topic) {\r\n  const haystack = `${article.title} ${article.snippet}`.toLowerCase();\r\n  const haystackWide = `${haystack} ${article.source || ''}`.toLowerCase();\r\n  const publishedAt = new Date(article.publishedAt);\r\n  if (!Number.isNaN(publishedAt.getTime())) {\r\n    const maxAgeDays =\r\n      article.method === 'google' ? topic.maxAgeDays.google : topic.maxAgeDays.rss;\r\n    if (Date.now() - publishedAt.getTime() > maxAgeDays * 24 * 60 * 60 * 1000) return false;\r\n  }\r\n  if ((topic.negativeKeywords || []).some((term) => haystackWide.includes(term))) return false;\r\n  if (!Array.isArray(topic.positiveKeywords) || topic.positiveKeywords.length === 0) return true;\r\n  return topic.positiveKeywords.some((term) => haystack.includes(term));\r\n}\r\n\r\nfunction parseItems(xml, source, topic) {\r\n  const text = String(xml || '');\r\n  const matches = [\r\n    ...text.matchAll(/<item[\\s\\S]*?>([\\s\\S]*?)<\\/item>/gi),\r\n    ...text.matchAll(/<entry[\\s\\S]*?>([\\s\\S]*?)<\\/entry>/gi)\r\n  ];\r\n\r\n  return matches\r\n    .map((match) => {\r\n      const block = match[1];\r\n      const rawTitle = pick(block, 'title');\r\n      return {\r\n        title: stripSourceSuffix(rawTitle, source.name),\r\n        url: pickLink(block),\r\n        source: source.name,\r\n        region: source.region,\r\n        method: source.method,\r\n        publishedAt: pick(block, 'pubDate') || pick(block, 'updated') || pick(block, 'published'),\r\n        snippet: decodeHtml(\r\n          pickRaw(block, 'description') || pickRaw(block, 'content:encoded') || pickRaw(block, 'summary')\r\n        )\r\n      };\r\n    })\r\n    .filter((article) => article.title && article.url && isTopicArticle(article, topic))\r\n    .slice(0, source.max || 10);\r\n}\r\n\r\nfunction resolveSource(item, index, preparedSources) {\r\n  const pairedIndex = Array.isArray(item.pairedItem)\r\n    ? item.pairedItem[0]?.item\r\n    : item.pairedItem?.item;\r\n  const prepared = preparedSources[pairedIndex ?? index]?.json || {};\r\n  return { ...prepared, ...item.json };\r\n}\r\n\r\nfunction extractXml(json) {\r\n  const candidates = [json.rss, json.data, json.body, json.response, json.text];\r\n  const value = candidates.find((candidate) => typeof candidate === 'string' && candidate.trim());\r\n  if (value) return value;\r\n  if (typeof json === 'string') return json;\r\n  return '';\r\n}\r\n\r\nfunction countBySource(list) {\r\n  return list.reduce((acc, article) => {\r\n    acc[article.source] = (acc[article.source] || 0) + 1;\r\n    return acc;\r\n  }, {});\r\n}\r\n\r\nfunction truncate(text, max) {\r\n  const value = String(text || '').trim();\r\n  if (value.length <= max) return value;\r\n  return `${value.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026`;\r\n}\r\n\r\n// Fallback \u00e9ditorial sans aucune info administrative.\r\n// On part du principe qu'un humain qui voit ce r\u00e9sum\u00e9 doit comprendre la news,\r\n// pas le pipeline. Donc : pas de \"France : X articles\", pas de \"Sources \u00e0 v\u00e9rifier\",\r\n// pas de \"aucun article retenu\". Les erreurs vont dans post.errors (cach\u00e9).\r\nfunction buildFallbackTitle(slot, topicLabel, leadArticle) {\r\n  if (!leadArticle) return `${slot} - ${topicLabel}, cr\u00e9neau silencieux`;\r\n  return `${slot} - ${truncate(leadArticle.title, 80)}`;\r\n}\r\n\r\nfunction buildFallbackSummary(selected, topicLabel) {\r\n  if (!selected.length) {\r\n    return `Pas de signal fort sur ${topicLabel} pour ce cr\u00e9neau. Le radar reprend dans quelques heures.`;\r\n  }\r\n  const titles = selected\r\n    .slice(0, 4)\r\n    .map((a) => String(a.title || '').replace(/[.;]+\\s*$/, ''))\r\n    .filter(Boolean);\r\n  if (titles.length === 1) return `${titles[0]}.`;\r\n  if (titles.length === 2) return `\u00c0 la une : ${titles[0]}. En parall\u00e8le, ${titles[1]}.`;\r\n  if (titles.length === 3) return `\u00c0 la une : ${titles[0]}. En parall\u00e8le : ${titles[1]} ; ${titles[2]}.`;\r\n  return `\u00c0 la une : ${titles[0]}. En parall\u00e8le : ${titles[1]} ; ${titles[2]} ; ${titles[3]}.`;\r\n}\r\n\r\nconst items = $input.all();\r\nconst preparedSources = $items('Preparer les sources');\r\nconst config = $items('Configurer le sujet')[0]?.json || {};\r\nconst topic = config.topic || { slug: 'esport', label: 'Esport', mode: 'fr-intl' };\r\nconst errors = [];\r\nconst loadedArticles = [];\r\nconst sameDayUrls = new Set(preparedSources[0]?.json?.sameDayUrls || []);\r\n\r\nfor (const [index, item] of items.entries()) {\r\n  const source = resolveSource(item, index, preparedSources);\r\n  const xml = extractXml(item.json);\r\n  if (!xml || String(xml).length < 20) {\r\n    errors.push(`${source.name || `Source ${index + 1}`}: r\u00e9ponse vide`);\r\n    continue;\r\n  }\r\n  const parsed = parseItems(xml, source, topic);\r\n  if (!parsed.length) errors.push(`${source.name}: aucun article ${topic.label} retenu`);\r\n  loadedArticles.push(...parsed);\r\n}\r\n\r\nconst seen = new Set();\r\nlet skippedInvalidUrl = 0;\r\nlet skippedDuplicate = 0;\r\nlet skippedSameDay = 0;\r\nconst articles = [];\r\n\r\nfor (const article of loadedArticles.sort(\r\n  (a, b) => new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0)\r\n)) {\r\n  const key = normalizeUrl(article.url);\r\n  if (!key) {\r\n    skippedInvalidUrl++;\r\n    continue;\r\n  }\r\n  if (seen.has(key)) {\r\n    skippedDuplicate++;\r\n    continue;\r\n  }\r\n  seen.add(key);\r\n  if (sameDayUrls.has(key)) {\r\n    skippedSameDay++;\r\n    continue;\r\n  }\r\n  articles.push(article);\r\n  if (articles.length >= (topic.caps?.total || 36)) break;\r\n}\r\n\r\nconst frArticles = articles.filter((a) => a.region === 'fr').slice(0, topic.caps?.fr || 14);\r\nconst intlArticles = articles.filter((a) => a.region !== 'fr').slice(0, topic.caps?.intl || 18);\r\nconst selectedArticles =\r\n  topic.mode === 'fr'\r\n    ? frArticles\r\n    : topic.mode === 'intl'\r\n    ? intlArticles\r\n    : [...frArticles, ...intlArticles];\r\n\r\nconst sourceGroups = { fr: countBySource(frArticles), intl: countBySource(intlArticles) };\r\nconst hasBothRegions = topic.mode === 'fr-intl' && frArticles.length > 0 && intlArticles.length > 0;\r\n\r\nconst now = new Date();\r\nconst recapDate = now.toLocaleDateString('fr-FR', { dateStyle: 'long' });\r\nconst slot = recapSlot(now);\r\nconst leadArticle = selectedArticles[0] || frArticles[0] || intlArticles[0] || null;\r\n\r\nconst fallbackTitle = buildFallbackTitle(slot, topic.label, leadArticle);\r\nconst fallbackSummary = buildFallbackSummary(selectedArticles, topic.label);\r\n\r\nconst articleLines = selectedArticles\r\n  .slice(0, 28)\r\n  .map((article, index) => {\r\n    const snippet = article.snippet ? ` | contexte : ${article.snippet.slice(0, 260)}` : '';\r\n    const tag = article.region === 'fr' ? 'FR' : 'INT';\r\n    return `${index + 1}. [${tag}] ${article.source} - ${article.title}${snippet}`;\r\n  })\r\n  .join('\\n');\r\n\r\n// Cadre stylistique c\u00f4t\u00e9 r\u00e9gions : en mode mixte avec articles FR ET INT,\r\n// on autorise un \"c\u00f4t\u00e9 fran\u00e7ais\" / \"\u00e0 l'international\" gliss\u00e9 dans une phrase,\r\n// MAIS jamais en chiffres, jamais en bandeau, jamais comme t\u00eate de chapitre.\r\nconst styleGuide =\r\n  topic.mode === 'fr-intl' && hasBothRegions\r\n    ? \"Les articles m\u00ealent France et international ; tu peux glisser \\\"c\u00f4t\u00e9 fran\u00e7ais\\\" ou \\\"\u00e0 l'international\\\" dans une phrase si \u00e7a aide la lecture, mais jamais en titre de paragraphe, jamais avec des chiffres.\"\r\n    : topic.mode === 'fr'\r\n    ? \"Tous les articles sont francophones. N'introduis aucune mention \\\"France\\\", \\\"FR\\\" ou \\\"international\\\".\"\r\n    : topic.mode === 'intl'\r\n    ? \"Tous les articles sont internationaux. N'introduis aucune mention \\\"France\\\", \\\"FR\\\" ou \\\"international\\\".\"\r\n    : \"Adopte une formulation globale, sans d\u00e9coupage r\u00e9gional.\";\r\n\r\n// Prompt orient\u00e9 \u00e9dito : z\u00e9ro mention chiffres, z\u00e9ro mention \"sources\",\r\n// z\u00e9ro mention \"aucun article retenu\". Le titre n'inclut PAS la date\r\n// (la page la rend d\u00e9j\u00e0). Le slot est ajout\u00e9 en post-traitement, donc\r\n// Gemini ne doit PAS le pr\u00e9fixer lui-m\u00eame.\r\nconst prompt = `Tu r\u00e9diges un mini-r\u00e9cap \u00e9ditorial sur le sujet \"${topic.label}\" pour le hub patch-notes.fr.\r\nPublic : lecteur curieux, 30 secondes de lecture.\r\n\r\nTITRE\r\n- 50 \u00e0 90 caract\u00e8res, sans pr\u00e9fixe, sans date, sans nom de m\u00e9dia.\r\n- Style \"bandeau de news\" : sujet + verbe d'action + d\u00e9tail concret.\r\n- Cite 1 ou 2 protagonistes nomm\u00e9s (\u00e9quipe, joueur, jeu, \u00e9diteur, \u00e9v\u00e9nement, plateforme).\r\n- Interdits en d\u00e9but de titre : \"R\u00e9cap\", \"Veille\", \"Radar\", \"Le journal\", \"Top\", \"Le point\", \"Br\u00e8ves\".\r\n- Interdits dans le titre : \u00e9mojis, dates, \"\u2014\", \"/\", majuscules abusives.\r\n\r\nR\u00c9SUM\u00c9\r\n- 2 \u00e0 3 phrases courtes, 40 \u00e0 80 mots au total. Style journaliste, pas administratif.\r\n- Phrase 1 = la news la plus marquante de la liste, formul\u00e9e nettement (sujet + action + contexte).\r\n- Phrase 2 = un autre signal fort de la liste, compl\u00e9mentaire.\r\n- Phrase 3 (optionnelle) = une perspective courte, un calendrier ou un point \u00e0 surveiller.\r\n- Cite des entit\u00e9s concr\u00e8tes : \u00e9quipe, joueur, jeu, \u00e9diteur, \u00e9v\u00e9nement, plateforme, \u00e9tude.\r\n- INTERDITS dans le r\u00e9sum\u00e9 :\r\n  \u2022 \"France : X articles\", \"International : X articles\", \"Articles : X retenus\".\r\n  \u2022 \"Sources \u00e0 v\u00e9rifier\", \"aucun article retenu\", noms de m\u00e9dias list\u00e9s \u00e0 la suite.\r\n  \u2022 \"\u00c0 retenir :\", \"\u00c0 suivre :\", \"C\u00f4t\u00e9 FR :\", \"C\u00f4t\u00e9 international :\", \"Pour r\u00e9sumer\", \"Dans cet article\", \"On apprend que\".\r\n  \u2022 \u00c9mojis, markdown, balises HTML, listes \u00e0 puces.\r\n- ${styleGuide}\r\n\r\nCONTRAINTES TECHNIQUES\r\n- R\u00e9ponds uniquement en JSON valide : {\"title\":\"...\",\"summary\":\"...\"}.\r\n- N'invente rien : ne mentionne que des \u00e9l\u00e9ments pr\u00e9sents dans la liste ci-dessous.\r\n${topic.editorialHints ? `\\nPR\u00c9CISIONS SP\u00c9CIFIQUES AU SUJET :\\n${topic.editorialHints}\\n` : ''}\r\nARTICLES DISPONIBLES (du plus r\u00e9cent au plus ancien) :\r\n${articleLines || '(aucun article \u2014 renvoie {\"title\":\"' + fallbackTitle.replace(/\"/g, '\\\\\"') + '\",\"summary\":\"' + fallbackSummary.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"') + '\"})'}`;\r\n\r\nreturn [\r\n  {\r\n    json: {\r\n      secret: config.blog?.secret,\r\n      fallbackTitle,\r\n      fallbackSummary,\r\n      recapDate,\r\n      geminiModel: config.gemini?.model || 'gemini-2.5-flash',\r\n      geminiApiKey: config.gemini?.apiKey || '',\r\n      geminiRequest: {\r\n        contents: [{ role: 'user', parts: [{ text: prompt }] }],\r\n        generationConfig: {\r\n          temperature: 0.4,\r\n          maxOutputTokens: 420,\r\n          responseMimeType: 'application/json',\r\n          thinkingConfig: { thinkingBudget: 0 }\r\n        }\r\n      },\r\n      postBase: {\r\n        id: `${topic.slug}-${topic.mode}-${now.toISOString().slice(0, 13)}`,\r\n        topic: topic.slug,\r\n        topicLabel: topic.label,\r\n        topicDescription: topic.description || null,\r\n        slot,\r\n        title: fallbackTitle,\r\n        summary: fallbackSummary,\r\n        mode: topic.mode,\r\n        sourceGroups,\r\n        errors,\r\n        debug: {\r\n          loadedArticlesCount: loadedArticles.length,\r\n          uniqueArticlesBeforeSameDayFilter: seen.size,\r\n          skippedInvalidUrl,\r\n          skippedDuplicate,\r\n          skippedSameDay,\r\n          selectedArticlesCount: selectedArticles.length\r\n        },\r\n        articles: selectedArticles,\r\n        createdAt: now.toISOString()\r\n      }\r\n    }\r\n  }\r\n];\r\n"
      },
      "id": "1f0a0a01-0001-0001-0001-000000000050",
      "name": "Assembler recap",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        20
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "has-new-articles",
              "leftValue": "={{ $json.postBase.articles.length }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1f0a0a01-0001-0001-0001-000000000060",
      "name": "Articles a resumer ?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1440,
        20
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $items('Configurer le sujet')[0].json.blog.internalUrl }}/api/gemini/reserve",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "content-type",
              "value": "application/json"
            },
            {
              "name": "x-blog-secret",
              "value": "={{ $items('Configurer le sujet')[0].json.blog.secret }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ topicSlug: $items('Configurer le sujet')[0].json.topic.slug, maxPerMinute: $items('Configurer le sujet')[0].json.gemini.maxPerMinute, maxWaitSeconds: $items('Configurer le sujet')[0].json.gemini.maxWaitSeconds }) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "1f0a0a01-0001-0001-0001-000000000070",
      "name": "Reserver slot Gemini",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1680,
        -100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "reserve-ok",
              "leftValue": "={{ $json.ok }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1f0a0a01-0001-0001-0001-000000000080",
      "name": "Reservation OK ?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1920,
        -100
      ]
    },
    {
      "parameters": {
        "amount": "={{ Math.max(0, Math.ceil(($json.waitMs || 0) / 1000)) }}",
        "unit": "seconds"
      },
      "id": "1f0a0a01-0001-0001-0001-000000000090",
      "name": "Attendre slot",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2160,
        -200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://generativelanguage.googleapis.com/v1beta/models/{{ $items('Assembler recap')[0].json.geminiModel }}:generateContent?key={{ $items('Assembler recap')[0].json.geminiApiKey }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($items('Assembler recap')[0].json.geminiRequest) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          },
          "timeout": 60000
        }
      },
      "id": "1f0a0a01-0001-0001-0001-000000000100",
      "name": "Generer le mini recap",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2400,
        -200
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ============================================================================\r\n// 4. Finaliser le post (auto)\r\n// ----------------------------------------------------------------------------\r\n// Lit la sortie Gemini (si on est pass\u00e9s par lui) et reconstruit le post final.\r\n// Si Gemini est absent (rate-limited, cl\u00e9 manquante, parsing KO), on garde le\r\n// titre/summary fallback d\u00e9j\u00e0 pr\u00e9par\u00e9s en amont.\r\n//\r\n// On nettoie aussi tout r\u00e9sidu administratif dans le r\u00e9sum\u00e9 : si Gemini ignore\r\n// la consigne et \u00e9crit malgr\u00e9 tout \"France : 7 articles...\" ou \"Sources \u00e0\r\n// v\u00e9rifier\", on retire ces phrases avant publication. Filet de s\u00e9curit\u00e9.\r\n// ============================================================================\r\n\r\nfunction parseGeminiJson(text) {\r\n  const cleaned = String(text || '')\r\n    .replace(/^```json\\s*/i, '')\r\n    .replace(/^```\\s*/i, '')\r\n    .replace(/```$/i, '')\r\n    .trim();\r\n  try {\r\n    return JSON.parse(cleaned);\r\n  } catch {\r\n    const match = cleaned.match(/\\{[\\s\\S]*\\}/);\r\n    if (!match) return null;\r\n    try {\r\n      return JSON.parse(match[0]);\r\n    } catch {\r\n      return null;\r\n    }\r\n  }\r\n}\r\n\r\n// On retire les phrases enti\u00e8res qui contiennent un marqueur \"admin\" plut\u00f4t\r\n// que d'essayer un replace cibl\u00e9 : \u00e7a \u00e9vite les rat\u00e9s quand le texte contient\r\n// des points (ex. \"Mandatory.gg\") qui cassent un regex `[^.]*`.\r\nconst ADMIN_SENTENCE_MARKERS = [\r\n  /\\bFrance\\s*:\\s*\\d+\\s*articles?/i,\r\n  /\\bInternational\\s*:\\s*\\d+\\s*articles?/i,\r\n  /\\bArticles?\\s*:\\s*\\d+\\s*retenus?/i,\r\n  /\\bSources?\\s+\u00e0\\s+v[e\u00e9]rifier/i,\r\n  /\\baucun\\s+article\\b.*\\bretenu/i,\r\n  /\\b\u00c0\\s+suivre\\s+c\u00f4t\u00e9\\s+FR/i,\r\n  /\\bC[\u00f4o]t[e\u00e9]\\s+international\\s*:/i,\r\n  /\\bPour\\s+r\u00e9sumer\\b/i,\r\n  /\\bDans\\s+cet\\s+article\\b/i,\r\n  /\\bOn\\s+apprend\\s+que\\b/i\r\n];\r\n\r\nfunction sanitizeSummary(summary) {\r\n  if (!summary) return '';\r\n  const sentences = String(summary).split(/(?<=[.!?])\\s+/);\r\n  const kept = sentences.filter(\r\n    (sentence) => !ADMIN_SENTENCE_MARKERS.some((re) => re.test(sentence))\r\n  );\r\n  return kept.join(' ').replace(/\\s{2,}/g, ' ').replace(/\\s+([.,;:])/g, '$1').trim();\r\n}\r\n\r\nconst TITLE_BAD_PREFIXES = /^(R[e\u00e9]cap|Veille|Radar|Le\\s+journal|Top\\s*\\d*|Le\\s+point|Br[e\u00e8]ves)\\b[^a-zA-Z0-9]*/i;\r\n\r\n// On force le slot en pr\u00e9fixe et on nettoie les pr\u00e9fixes \u00e9ditoriaux mous\r\n// que Gemini aime parfois ajouter (\"R\u00e9cap : ...\", \"Le point : ...\"). On retire\r\n// aussi la date si Gemini l'a gliss\u00e9e dedans malgr\u00e9 l'interdiction.\r\nfunction buildFinalTitle(generatedTitle, slot, recapDate) {\r\n  let title = String(generatedTitle || '').trim();\r\n  if (slot && title.startsWith(`${slot} - `)) title = title.slice(slot.length + 3).trim();\r\n  title = title.replace(TITLE_BAD_PREFIXES, '').trim();\r\n  if (recapDate) {\r\n    title = title.replace(new RegExp(`\\\\s*-?\\\\s*${recapDate.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\\\s*$`, 'i'), '').trim();\r\n  }\r\n  if (!title) title = '';\r\n  return slot ? `${slot} - ${title}`.trim().replace(/\\s*-\\s*$/, '').trim() : title;\r\n}\r\n\r\nconst base = $items('Assembler recap')[0].json;\r\nconst incoming = $input.first()?.json || {};\r\nconst post = { ...base.postBase };\r\n\r\nif (!post.articles?.length) {\r\n  post.title = base.fallbackTitle;\r\n  post.summary = base.fallbackSummary;\r\n  return [{ json: { secret: base.secret, post } }];\r\n}\r\n\r\nconst text = incoming?.candidates?.[0]?.content?.parts\r\n  ?.map((part) => part.text || '')\r\n  .join(' ')\r\n  .trim();\r\nconst parsed = parseGeminiJson(text);\r\n\r\nif (parsed?.title || parsed?.summary) {\r\n  const generatedTitle = String(parsed.title || base.fallbackTitle).trim();\r\n  const finalTitle = buildFinalTitle(generatedTitle, post.slot, base.recapDate);\r\n  post.title = finalTitle || base.fallbackTitle;\r\n\r\n  const generatedSummary = String(parsed.summary || base.fallbackSummary).trim();\r\n  const sanitized = sanitizeSummary(generatedSummary);\r\n  // Si tout le r\u00e9sum\u00e9 Gemini \u00e9tait admin, on retombe sur le fallback \u00e9ditorial.\r\n  post.summary = sanitized.length >= 30 ? sanitized : base.fallbackSummary;\r\n} else if (incoming?.error?.message) {\r\n  post.errors = [...(post.errors || []), `Gemini: ${incoming.error.message}`];\r\n}\r\n\r\nreturn [{ json: { secret: base.secret, post } }];\r\n"
      },
      "id": "1f0a0a01-0001-0001-0001-000000000110",
      "name": "Finaliser le post",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2640,
        20
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $items('Configurer le sujet')[0].json.blog.internalUrl }}/api/posts",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "content-type",
              "value": "application/json"
            },
            {
              "name": "x-blog-secret",
              "value": "={{ $json.secret }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.post) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          },
          "timeout": 30000
        }
      },
      "id": "1f0a0a01-0001-0001-0001-000000000120",
      "name": "Publier sur le blog",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2880,
        20
      ]
    }
  ],
  "connections": {
    "Test manuel": {
      "main": [
        [
          {
            "node": "Configurer le sujet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "06h 11h 18h 23h": {
      "main": [
        [
          {
            "node": "Configurer le sujet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configurer le sujet": {
      "main": [
        [
          {
            "node": "Charger URLs deja publiees",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Charger URLs deja publiees": {
      "main": [
        [
          {
            "node": "Preparer les sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Preparer les sources": {
      "main": [
        [
          {
            "node": "Telecharger les flux",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Telecharger les flux": {
      "main": [
        [
          {
            "node": "Assembler recap",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assembler recap": {
      "main": [
        [
          {
            "node": "Articles a resumer ?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Articles a resumer ?": {
      "main": [
        [
          {
            "node": "Reserver slot Gemini",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Finaliser le post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reserver slot Gemini": {
      "main": [
        [
          {
            "node": "Reservation OK ?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reservation OK ?": {
      "main": [
        [
          {
            "node": "Attendre slot",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Finaliser le post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attendre slot": {
      "main": [
        [
          {
            "node": "Generer le mini recap",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generer le mini recap": {
      "main": [
        [
          {
            "node": "Finaliser le post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Finaliser le post": {
      "main": [
        [
          {
            "node": "Publier sur le blog",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}