Veille automatisée multi-thèmes : architecture concrète avec Claude et PHP

Au départ, j'avais un seul script crypto-veille.js. 400 lignes, tout hardcodé : les catégories, le prompt, la logique FTP. Ça marchait. Puis j'ai voulu faire la même chose pour suivre l'affaire Epstein, les sorties de consoles rétro, l'actualité tech/IA. Copier-coller quatre fois un script de 400 lignes avec des variantes, très peu pour moi.

L'exercice a pris trois jours de développement itératif (avec Claude Code comme pair-programmeur) et a produit quelque chose d'intéressant : une architecture générique pilotée par un fichier de configuration central, un runner Node.js réutilisable, et un pattern de rendu PHP qui sépare proprement données et présentation. Ce que j'ai appris en chemin mérite d'être documenté — notamment les bugs qui m'ont coûté le plus de temps.

L'architecture en une phrase

Un daemon Node.js consulte registry.json toutes les minutes, décide quelles veilles sont dues, lance run-veille.js --slug <slug>, qui appelle Claude CLI avec WebSearch, déduplique les résultats, les persiste en JSON, puis appelle une seconde fois Claude (sans WebSearch) pour patcher un fichier article.json structuré. Le PHP lit ces fichiers JSON et génère le HTML à la volée.


[veille-daemon.js]   ← tourne en continu (systemd service)
    ↓ toutes les minutes : compare frequency_hours
    ↓ si cycle dû → lance run-veille.js --slug <slug>

[run-veille.js]
    1. charge registry.json (config centrale)
    2. claude --print --allowedTools WebSearch,WebFetch → items[] JSON
    3. déduplication SHA256 titre normalisé
    4. merge + prune → updates.json (écriture atomique)
    5. si render_prompt → claude --print (sans WebSearch) → patch article.json
    6. si summary_weekly_day → summaries.json

[PHP veille/<slug>/index.php]
    include _veille-page.php → lit updates.json + article.json → HTML
        

L'élément clé : tout ce qui varie entre les veilles est dans registry.json. Le code Node.js est générique. Ajouter une nouvelle veille ne nécessite aucune modification du runner.

registry.json — le cœur du système

Chaque veille est une entrée dans uploads/veille/registry.json :


{
  "veilles": {
    "retro": {
      "slug": "retro",
      "label": "Consoles Rétro",
      "frequency_hours": 168,
      "prune_days": 180,
      "categories": ["NOUVELLE_CONSOLE", "FIRMWARE", "BON_PLAN"],
      "ftp_files": [
        { "local": "uploads/veille/retro/updates.json",  "remote": "/www/uploads/veille/retro/updates.json" },
        { "local": "uploads/veille/retro/article.json",  "remote": "/www/uploads/veille/retro/article.json" }
      ],
      "prompt": "Tu es un expert du marché des consoles rétro...",
      "render_prompt": "Tu mets à jour le guide JSON. Retourne uniquement un patch..."
    }
  }
}
        

ftp_files[0] est toujours le flux de news brutes (updates.json). ftp_files[1+] sont les fichiers secondaires. render_prompt est optionnel — uniquement pour les veilles qui ont un article structuré en plus du flux d'actualités.

Le pattern article.json — deux passes Claude

Le système distingue deux types de fichiers de sortie :

  • updates.json : flux d'items chronologiques (news, événements). Append-only, pruning automatique.
  • article.json : document structuré et enrichi à chaque cycle. Contient un classement, des prix, des biographies, des analyses — tout ce qui ne se résume pas à "voici les dernières news".

Pour article.json, la passe 1 (WebSearch) collecte les données brutes. La passe 2 (sans WebSearch) reçoit ces données + l'état actuel de article.json et retourne un patch JSON partiel — uniquement les champs qui ont changé. Le runner fusionne ce patch avec l'existant.


// renderArticle() — simplifié
function renderArticle(renderPrompt, inputData) {
    const currentArticle = readJSON('article.json');

    const claudeInput = renderPrompt
        + '\n\nNOUVELLES INFORMATIONS :\n' + JSON.stringify(inputData)
        + '\n\nDONNÉES ACTUELLES (article.json) :\n' + JSON.stringify(currentArticle);

    // Claude sans WebSearch — il travaille sur les données passées en contexte
    const patch = callClaude(claudeInput, { noTools: true });

    // Merge patch → article existant
    const merged = { ...currentArticle, ...patch, last_updated: new Date().toISOString() };
    writeAtomically('article.json', merged);
}
        

Ce que Claude retourne pour la veille retro ressemble à ça :


{
  "market_note": "Le marché budget se compresse autour des 35-45€ avec TrimUI et Anbernic.",
  "highlights": [
    { "category": "BON_PLAN", "date": "2026-03-16", "title": "Retroid Pocket 5 à 181€ — promo flash" }
  ],
  "consoles": [
    { "rank": 1, "name": "Miyoo Mini Plus", "price": "~45€", "badge": "🏆 Roi du budget", "..." }
  ]
}
        

Le PHP : 3 lignes par veille

Chaque veille/<slug>/index.php fait exactement ça :


<?php
$veille_slug = 'retro';
include __DIR__ . '/../_veille-page.php';
        

_veille-page.php gère la structure commune (header, filtres, flux de news). Puis chaque veille a son _article.php qui lit article.json et affiche la section riche : classement de consoles pour retro, timeline judiciaire pour epstein, benchmark d'IA pour techno.

Les bugs qui m'ont coûté le plus de temps

Bug 1 — La boucle secondary files écrasait article.json

Après chaque cycle, run-veille.js boucle sur ftp_files[1+] pour écrire les champs top-level du cycle courant (ex: current_prices, market_note). Le problème : cette boucle tournait avant renderArticle(). Elle écrasait article.json avec les données partielles du cycle en cours — supprimant le classement complet des consoles que renderArticle allait pourtant enrichir 30 secondes plus tard.

Le symptôme : la page retro s'affichait vide après chaque cycle. Le diagnostic s'est fait en lisant les timestamps des logs :


[17:48:42] ✓ Secondary file written → uploads/veille/retro/article.json
[17:49:06] [render:NOUVELLES INFORMATIONS] ✓ Done → uploads/veille/retro/article.json
        

Secondary file à 17:48 → render à 17:49. Le render écrit sur ce qu'il a reçu en contexte (la version écrasée), pas sur la version complète. Fix en une ligne :


// Ligne 220 de run-veille.js
if (f.local.endsWith('/article.json')) continue; // géré par renderArticle(), pas ici
        

Bug 2 — Le daemon réécrasait registry.json

veille-daemon.js compare updated_at local avec la version OVH. Si la version distante est plus récente ou égale, il écrase le local.

Conséquence : je modifie registry.json localement, je le deploie sur OVH, mais les deux versions ont le même updated_at. Au prochain polling, le daemon récupère la version OVH et réécrase le local — perdant mes modifications.

Fix : bumper updated_at à new Date().toISOString() à chaque modification locale avant deploy. C'est une contrainte opérationnelle à ne jamais oublier.

Bug 3 — Le render_prompt ne mettait pas les prix à jour

La veille retro collecte les prix actuels des consoles à chaque cycle (current_prices). Le render_prompt était censé les appliquer au classement. En pratique, Claude retournait un patch court avec seulement market_note et highlights — sans le tableau consoles[].

Cause : le render_prompt était ambigu sur ce point. Claude optimisait son output et omettait les champs "inchangés". Fix : ajouter une règle explicite dans le prompt :

RÈGLE ABSOLUE PRIX : Si current_prices est présent dans les NOUVELLES INFORMATIONS (même partiellement), tu DOIS OBLIGATOIREMENT inclure le champ "consoles" dans le patch avec le tableau COMPLET des consoles et les prix mis à jour.

Le render_prompt ne bénéficie pas de WebSearch. Il travaille uniquement avec les données passées en contexte. Ce que Claude ne peut pas chercher lors de la passe 1, il ne peut pas le trouver lors de la passe 2. Cela doit être documenté clairement dans le prompt pour éviter des attentes incorrectes.

Bug 4 — Les URLs d'images inventées

Le render_prompt pour la veille retro demandait à Claude de trouver "une URL directe vers une photo produit officielle". Claude a inventé des URLs plausibles — toutes retournaient 404. Aucun mécanisme de validation dans le pipeline.

Solution manuelle : rechercher sur les sites officiels (anbernic.com, trimui.com, goretroid.com), vérifier chaque URL avec curl -I, injecter directement dans article.json. La vraie leçon : ne pas déléguer la recherche d'assets à la passe render (sans WebSearch). Si l'URL d'image est critique, elle doit être collectée lors de la passe 1 (avec WebSearch).

Les veilles actives aujourd'hui

Slug Thème Fréquence article.json
crypto Crypto / finance 6h Market snapshot, analyses
epstein Affaire Epstein 7j Timeline judiciaire, personnes impliquées
techno Tech / IA 7j Sorties modèles, mouvements industrie
retro Consoles rétro 7j Classement Q/P, prix live, guide d'achat

Axes d'amélioration futurs

Ce qui manque encore ou peut être amélioré :

  • Validation des URLs d'images. Actuellement rien ne vérifie que les URLs dans article.json retournent bien une image. Un check HTTP 200 au moment du merge patch éliminerait les 404 silencieux.
  • Historique des prix. Pour la veille retro, stocker un tableau de {date, price} par console permettrait d'afficher une courbe d'évolution. La donnée est collectée à chaque cycle — elle n'est juste pas persistée.
  • Alertes sur delta significatif. Si une console baisse de plus de 20% entre deux cycles, envoyer une notification. La donnée est là, la logique de diff aussi (on compare les prix avant/après merge) — il manque juste le déclencheur.
  • Interface admin pour ajouter une veille. Aujourd'hui, ajouter une veille demande d'éditer registry.json, créer trois fichiers PHP, ajouter deux routes. Une UI simple (formulaire → génération des fichiers) rendrait ça accessible sans toucher au code.
  • Retry sur la passe render. La passe 1 (fetch) a un retry. La passe 2 (render) non — si Claude retourne un JSON invalide, l'article n'est pas mis à jour et aucune alerte n'est émise. Un retry avec log explicite éviterait les cycles silencieusement ratés.

Ce qu'on apprend à faire avec Claude comme pair-programmeur

Ce projet a été développé entièrement en session avec Claude Code. Le pattern qui marche : décrire l'architecture cible en prose, laisser Claude implémenter, tester, identifier le bug suivant, itérer. Ce qui ne marche pas : demander à Claude de concevoir l'architecture lui-même sans contraintes — on obtient quelque chose de trop générique qui ne correspond pas aux besoins réels.

Les bugs décrits ci-dessus ont tous été trouvés de la même façon : un comportement inattendu → lecture des logs → hypothèse → vérification dans le code → fix minimal. Claude est efficace pour le fix une fois l'hypothèse posée. Il est moins efficace pour poser l'hypothèse lui-même sur des bugs de timing ou d'ordre d'exécution — c'est là que la lecture des logs à la main reste indispensable.

Conclusion

L'architecture finale tient en quelques fichiers : un registry JSON, un runner Node.js générique de ~450 lignes, un daemon de polling, et des templates PHP minimalistes. La puissance vient de la séparation entre la configuration (registry), la collecte (passe 1 avec WebSearch), l'enrichissement structuré (passe 2 sans WebSearch), et le rendu (PHP pur).

Ce qui était au départ un script spécifique crypto est devenu une plateforme sur laquelle on peut poser n'importe quel sujet de veille en 30 minutes : créer l'entrée registry, rédiger un prompt, définir la structure article.json, et écrire le template PHP. Le reste — déduplication, retry, FTP sync, pruning, summaries périodiques — est fourni gratuitement par le runner.

Le code est disponible sur ce site. Les dashboards sont accessibles sur web-developpeur.com/veille/.

📄 CLAUDE.md associé

Commentaires (0)