{
  "name": "Veille — Scrape & File d'attente (6h)",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * 1-5"
            }
          ]
        }
      },
      "id": "d8404816-10cd-4799-9cf0-1bce8f1e3f23",
      "name": "Déclencheur 6h00 (lun-ven)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        448,
        80
      ],
      "notes": "Lun-ven uniquement (cron 0 6 * * 1-5) : la routine Claude Code qui traite la file ne tourne pas le week-end. GENERIC_TIMEZONE doit être Europe/Paris sur l'instance. La segmentation par jour (chaud/curation/froid) est faite dans 'Préparer input Apify'."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "1NvgtiwfgwKRjsm025O3qlXnUcw6QffZpVLI1_4IAHLc",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Profils",
          "mode": "name"
        },
        "options": {}
      },
      "id": "214aac0e-7148-4981-b0ae-0e123e0b4379",
      "name": "Lire la liste de profils",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        672,
        80
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "EvORDqxqhAIKRlIW",
          "name": "Google Sheets account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Construit l'input Apify pour l'acteur harvestapi/linkedin-profile-posts.\n// La feuille 'Profils' utilise 'targetUrls' (URLs /in/... et /company/...), 'actif' (TRUE/FALSE) et 'role'.\n//\n// SEGMENTATION VEILLE (17/07/2026 ; CURATION passée en quotidien le 21/07/2026) — cadence par rôle :\n//   CHAUD    (quotidien lun-ven) : REACH-FR, NICHE-FR, NICHE-CGP-FR → alimente les commentaires (fraîcheur ≤ 72h)\n//   CURATION (quotidien lun-ven) : CURATION, AUTORITÉS-INTL → seules sources d'autorité citables\n//   FROID    (lun / mer / ven)   : CRÉATEURS-INTL, CONCURRENTS → monitoring, jamais citable\n// Rôle vide ou inconnu → CHAUD par défaut (ne jamais perdre une source en silence).\n\nconst SEGMENTS = {\n  'REACH-FR': 'CHAUD',\n  'NICHE-FR': 'CHAUD',\n  'NICHE-CGP-FR': 'CHAUD',\n  'CURATION': 'CURATION',\n  'CRÉATEURS-INTL': 'FROID',\n  'CONCURRENTS': 'FROID',\n  'AUTORITÉS-INTL': 'CURATION'\n};\n\n// Réglages temporaires (laisser à 0 / '24h' en production) :\nconst TEST_LIMIT = 0;          // >0 = tronque la liste à N profils (test technique uniquement)\nconst POSTED_LIMIT = '24h';    // '24h' en prod ; élargir ponctuellement pour un run de rattrapage\nconst FORCE_ALL_SEGMENTS = false; // true = ignore la cadence (run de rattrapage)\n\n// 1 = lundi … 7 = dimanche, calé sur Europe/Paris quelle que soit la TZ serveur\nconst weekday = $now.setZone('Europe/Paris').weekday;\nconst scrapeToday = {\n  CHAUD: true,\n  CURATION: true,\n  FROID: [1, 3, 5].includes(weekday)\n};\n\nlet targetUrls = [];\nconst compteur = { CHAUD: 0, CURATION: 0, FROID: 0 };\nfor (const item of $input.all()) {\n  const row = item.json;\n  const actif = row.actif === undefined ? true : String(row.actif).toUpperCase() !== 'FALSE';\n  const cible = row.targetUrls || row.usernames; // tolérant aux deux noms de colonne\n  if (!actif || !cible) continue;\n  const role = String(row.role || '').trim().toUpperCase();\n  const segment = SEGMENTS[role] || 'CHAUD';\n  if (FORCE_ALL_SEGMENTS || scrapeToday[segment]) {\n    targetUrls.push(String(cible).trim());\n    compteur[segment]++;\n  }\n}\nif (TEST_LIMIT > 0) targetUrls = targetUrls.slice(0, TEST_LIMIT);\n\nreturn [{ json: {\n  targetUrls,\n  maxPosts: 1,\n  postedLimit: POSTED_LIMIT,\n  includeQuotePosts: true,\n  includeReposts: true,\n  scrapeReactions: false,\n  maxReactions: 5,\n  postNestedReactions: false,\n  scrapeComments: false,\n  maxComments: 5,\n  postNestedComments: false,\n  _segmentation: { weekday, compteur, total: targetUrls.length }\n} }];"
      },
      "id": "d68a7668-237c-48d3-a30b-cc39a4aed5ce",
      "name": "Préparer input Apify",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        896,
        80
      ],
      "notes": "Segmentation 3 cadences par rôle (17/07/2026) : CHAUD quotidien / CURATION lun-mer-ven / FROID lundi. Le champ _segmentation est ignoré par l'acteur Apify — il sert de trace dans les logs d'exécution."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/harvestapi~linkedin-profile-posts/runs",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {}
      },
      "id": "a03c818d-d54a-4860-8bd5-7973e61b4ecd",
      "name": "Apify — Lancer le run",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        80
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {
        "httpQueryAuth": {
          "id": "HugQXVQWyfUoDoLl",
          "name": "Query Auth account 2"
        }
      },
      "notes": "ASYNCHRONE (refonte 17/07/2026) : POST /runs lance le run et répond immédiatement ({data:{id, defaultDatasetId, status}}). Remplace run-sync-get-dataset-items dont le plafond dur de 300 s côté Apify cassait le workflow depuis le 14/07 (batch 318 profils > 5 min). Auth : credential Query Auth 'token' = jeton Apify."
    },
    {
      "parameters": {
        "amount": 60,
        "unit": "seconds"
      },
      "id": "f1a2b3c4-0001-4aaa-8bbb-cccddd000001",
      "name": "Attendre 60 s",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1120,
        304
      ],
      "webhookId": "b7e2f6a0-3c41-4d7a-9f28-5e0d1a2b3c4d",
      "notes": "Boucle de polling : on repasse ici tant que le run Apify n'est pas terminé. Run typique 318 profils ≈ 5-7 min → 6-8 tours."
    },
    {
      "parameters": {
        "url": "=https://api.apify.com/v2/actor-runs/{{ $('Apify — Lancer le run').first().json.data.id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth",
        "options": {}
      },
      "id": "f1a2b3c4-0002-4aaa-8bbb-cccddd000002",
      "name": "Apify — Statut du run",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1312,
        304
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {
        "httpQueryAuth": {
          "id": "HugQXVQWyfUoDoLl",
          "name": "Query Auth account 2"
        }
      },
      "notes": "GET /actor-runs/{id} → data.status : READY | RUNNING | SUCCEEDED | FAILED | TIMED-OUT | ABORTED."
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.data.status }}",
                    "rightValue": "SUCCEEDED",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "terminé"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.data.status }}",
                    "rightValue": "RUNNING",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "en cours"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.data.status }}",
                    "rightValue": "READY",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "pas démarré"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "échec"
        }
      },
      "id": "f1a2b3c4-0003-4aaa-8bbb-cccddd000003",
      "name": "Run terminé ?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1504,
        304
      ],
      "notes": "SUCCEEDED → récupérer le dataset. RUNNING / READY → reboucler sur l'attente. Tout autre statut (FAILED, TIMED-OUT, ABORTED) → sortie 'échec' → Stop And Error (capté par _ERROR-HANDLER)."
    },
    {
      "parameters": {
        "url": "=https://api.apify.com/v2/datasets/{{ $('Apify — Lancer le run').first().json.data.defaultDatasetId }}/items?clean=true",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth",
        "options": {
          "timeout": 120000
        }
      },
      "id": "f1a2b3c4-0004-4aaa-8bbb-cccddd000004",
      "name": "Apify — Récupérer dataset",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1728,
        80
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {
        "httpQueryAuth": {
          "id": "HugQXVQWyfUoDoLl",
          "name": "Query Auth account 2"
        }
      },
      "notes": "GET /datasets/{defaultDatasetId}/items?clean=true → tableau JSON des posts, même schéma que l'ancien endpoint sync. 'Normaliser les posts' gère tableau ou items éclatés."
    },
    {
      "parameters": {
        "errorMessage": "=Run Apify en échec — statut {{ $json.data.status }} (run {{ $('Apify — Lancer le run').first().json.data.id }}). Vérifier la console Apify."
      },
      "id": "f1a2b3c4-0005-4aaa-8bbb-cccddd000005",
      "name": "Échec du run Apify",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [
        1728,
        432
      ],
      "notes": "Interrompt proprement le workflow → _ERROR-HANDLER alerte Fabien par email."
    },
    {
      "parameters": {
        "jsCode": "// Normalise les items du dataset Apify (acteur harvestapi/linkedin-profile-posts).\n// L'endpoint /datasets/{id}/items renvoie un tableau JSON — selon la version de n8n, il peut arriver\n// comme un seul item contenant le tableau, ou déjà éclaté en plusieurs items. On gère les deux.\nconst out = [];\nfunction pushPost(p) {\n  if (!p || !p.id) return;\n  const a = p.author || {};\n  const e = p.engagement || {};\n  const postedAt = (p.postedAt && p.postedAt.date) || '';\n  out.push({\n    json: {\n      postId: (() => { const m = (p.linkedinUrl || '').match(/activity-([0-9]+)/); return m ? m[1] : String(p.id); })(),\n      author: a.name || 'Inconnu',\n      profileUrl: a.linkedinUrl || '',\n      url: p.linkedinUrl || '',\n      text: (p.content || '').slice(0, 2000),\n      postedAt,\n      likes: e.likes || 0,\n      comments: e.comments || 0\n    }\n  });\n}\nfor (const item of $input.all()) {\n  const j = item.json;\n  if (Array.isArray(j)) j.forEach(pushPost);\n  else pushPost(j);\n}\nreturn out;"
      },
      "id": "0bfa3a9c-cf0a-4cb2-9163-33707642dbd1",
      "name": "Normaliser les posts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1952,
        80
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "1NvgtiwfgwKRjsm025O3qlXnUcw6QffZpVLI1_4IAHLc",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Historique",
          "mode": "name"
        },
        "options": {}
      },
      "id": "5cb4bf05-f8a8-40c5-8e36-94c2cb24ab3f",
      "name": "Lire historique (déjà vus)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2176,
        80
      ],
      "executeOnce": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "notes": "Placé EN LIGNE entre Normaliser et Filtrer (pas en losange) — corrige la race condition documentée sessions 28-29/06 où la convergence à deux entrées sur 'Filtrer' provoquait une double exécution. Ce nœud ignore son input et lit la feuille Historique. Execute Once + Retry on Fail (3/2s) ajoutés le 2026-07-16 pour éviter le rate limit Google Sheets quand 100+ items arrivent du normaliseur.",
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "EvORDqxqhAIKRlIW",
          "name": "Google Sheets account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Garde uniquement les posts dont le postId n'est PAS déjà dans l'historique.\n// (Logique de dédup validée sessions 27→29/06 — canonicalId stable + filtre par postId.)\nconst seen = new Set();\nfor (const r of $('Lire historique (déjà vus)').all()) {\n  if (r.json.postId) seen.add(String(r.json.postId));\n}\nconst nouveaux = [];\nfor (const item of $('Normaliser les posts').all()) {\n  if (!seen.has(item.json.postId)) nouveaux.push(item);\n}\nif (nouveaux.length === 0) return [];\nreturn nouveaux;"
      },
      "id": "567fbb4a-06ab-47e3-94fa-a06791758a03",
      "name": "Filtrer les nouveaux",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2400,
        80
      ]
    },
    {
      "parameters": {
        "jsCode": "// Prépare les nouveaux posts pour la File-à-traiter consommée par Claude Code.\n// AUCUNE classification ici : n8n ne fait QUE de la mécanique. Le tri (commenter/veille/skip)\n// est fait par les VRAIS agents 42 et 50 dans la routine Claude Code, pas par un Claude généraliste.\n// role (fix 2026-07-21) : Apify ne renvoie pas le rôle du profil (c'est une colonne du Sheet Profils).\n// On reconstruit la carte depuis l'onglet Profils : match principal sur l'URL, repli sur le nom\n// d'auteur car certains slugs du Sheet sont périmés (LinkedIn renvoie la vanity URL courante).\n// Aucun match -> role vide -> CHAUD par défaut en aval (jamais de source perdue en silence).\nfunction normUrl(u){ if(!u) return ''; let s=String(u).toLowerCase().split('?')[0].split('#')[0].replace(/^https?:\\/\\//,'').replace(/^www\\./,'').replace(/\\/+$/,''); const m=s.match(/\\/(in|company|school)\\/([^\\/]+)/); return m?m[1]+'/'+m[2]:s; }\nfunction normName(n){ return String(n||'').toLowerCase().normalize('NFD').replace(/[^a-z0-9]/g,''); }\nconst roleByUrl = {}, roleByName = {};\nfor (const it of $('Lire la liste de profils').all()) {\n  const row = it.json;\n  const role = String(row.role || '').trim();\n  if (!role) continue;\n  const ku = normUrl(row.targetUrls || row.usernames);\n  if (ku) roleByUrl[ku] = role;\n  const kn = normName(row['Nom / Entité']);\n  if (kn) roleByName[kn] = role;\n}\nconst runDate = new Date().toISOString().slice(0, 10);\nconst runId = new Date().toISOString();\nreturn $input.all().map(item => ({\n  json: {\n    postId: item.json.postId,\n    author: item.json.author,\n    profileUrl: item.json.profileUrl,\n    url: item.json.url,\n    text: item.json.text,\n    postedAt: item.json.postedAt,\n    likes: item.json.likes,\n    comments: item.json.comments,\n    role: roleByUrl[normUrl(item.json.profileUrl)] || roleByName[normName(item.json.author)] || '',\n    runDate,\n    runId,\n    statut: 'PENDING',\n    traitePar: '',\n    dateTraitement: ''\n  }\n}));"
      },
      "id": "b1f2a3c4-1111-2222-3333-444455556666",
      "name": "Préparer la File-à-traiter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2624,
        80
      ],
      "notes": "Ajoute runDate / runId / statut=PENDING. C'est cette feuille que la routine Claude Code lit chaque matin pour lancer /42 et /50. Pas de tri généraliste ici."
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "1NvgtiwfgwKRjsm025O3qlXnUcw6QffZpVLI1_4IAHLc",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "File-à-traiter",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "value": {}
        },
        "options": {}
      },
      "id": "c2d3e4f5-aaaa-bbbb-cccc-ddddeeeeffff",
      "name": "Écrire File-à-traiter (PENDING)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2848,
        80
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "EvORDqxqhAIKRlIW",
          "name": "Google Sheets account"
        }
      },
      "notes": "Feuille 'File-à-traiter'. Colonnes attendues (ligne 1) : postId | author | profileUrl | url | text | postedAt | likes | comments | runDate | runId | statut | traitePar | dateTraitement. La routine Claude Code lit les lignes statut=PENDING, lance les agents, puis passe statut=DONE + traitePar='42,50' + dateTraitement."
    },
    {
      "parameters": {
        "jsCode": "// Réduit chaque nouveau post à la ligne d'historique minimale (dédup de demain).\nconst runDate = new Date().toISOString().slice(0, 10);\nreturn $('Filtrer les nouveaux').all().map(item => ({\n  json: {\n    postId: item.json.postId,\n    author: item.json.author,\n    url: item.json.url,\n    dateVue: runDate\n  }\n}));"
      },
      "id": "e3553c91-8eb4-4820-b909-509bddcafdd5",
      "name": "Préparer ligne historique",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2624,
        320
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "1NvgtiwfgwKRjsm025O3qlXnUcw6QffZpVLI1_4IAHLc",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Historique",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "value": {}
        },
        "options": {}
      },
      "id": "57dc38b3-7f16-4554-8162-93e0c30e72a7",
      "name": "Écrire dans historique",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2848,
        320
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "EvORDqxqhAIKRlIW",
          "name": "Google Sheets account"
        }
      },
      "notes": "Marque tous les nouveaux posts comme 'vus' pour ne pas les re-scraper demain. La dédup repose sur cette feuille (matching postId)."
    }
  ],
  "connections": {
    "Déclencheur 6h00 (lun-ven)": {
      "main": [
        [
          {
            "node": "Lire la liste de profils",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lire la liste de profils": {
      "main": [
        [
          {
            "node": "Préparer input Apify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Préparer input Apify": {
      "main": [
        [
          {
            "node": "Apify — Lancer le run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apify — Lancer le run": {
      "main": [
        [
          {
            "node": "Attendre 60 s",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attendre 60 s": {
      "main": [
        [
          {
            "node": "Apify — Statut du run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apify — Statut du run": {
      "main": [
        [
          {
            "node": "Run terminé ?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run terminé ?": {
      "main": [
        [
          {
            "node": "Apify — Récupérer dataset",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Attendre 60 s",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Attendre 60 s",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Échec du run Apify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apify — Récupérer dataset": {
      "main": [
        [
          {
            "node": "Normaliser les posts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normaliser les posts": {
      "main": [
        [
          {
            "node": "Lire historique (déjà vus)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lire historique (déjà vus)": {
      "main": [
        [
          {
            "node": "Filtrer les nouveaux",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filtrer les nouveaux": {
      "main": [
        [
          {
            "node": "Préparer la File-à-traiter",
            "type": "main",
            "index": 0
          },
          {
            "node": "Préparer ligne historique",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Préparer la File-à-traiter": {
      "main": [
        [
          {
            "node": "Écrire File-à-traiter (PENDING)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Préparer ligne historique": {
      "main": [
        [
          {
            "node": "Écrire dans historique",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}