Suivre l'intelligence artificielle, c'était ouvrir une quinzaine de blogs chaque matin : OpenAI, Anthropic, Google DeepMind, Mistral, Hugging Face, NVIDIA… J'ai voulu un seul endroit, alimenté tout seul. C'est devenu
Veille IA, en ligne depuis le 19 septembre 2026.
Voici le pipeline qui tourne derrière, les choix que j'ai faits et pourquoi — et surtout ce qui n'a pas marché du premier coup, parce que c'est là que j'ai le plus appris.
Une contrainte volontaire : pas de framework
Je travaille au quotidien avec Symfony. Pour ce projet, je me suis imposé l'inverse : PHP 8.4 natif. Routeur, conteneur d'injection de dépendances (résolution automatique par réflexion), moteur de gabarits, cache et journalisation sont écrits pour l'occasion. Une seule dépendance de production : HTMLPurifier.
L'objectif n'était pas de réinventer Symfony, mais de comprendre ce qu'il fait à ma place : le cycle requête-réponse, la chaîne de middlewares, la résolution des dépendances. Le prix est réel : environ 14 700 lignes de PHP à maintenir, et chaque garantie qu'un framework offre d'office — jeton CSRF, sessions, échappement — doit être écrite et testée. On verra plus bas qu'une de ces garanties m'a coûté une soirée de débogage.
Le pipeline
Pipeline de collecte de Veille IA Déclenché par un cron quotidien et une file d'attente, le pipeline enchaîne neuf étapes : sources, collecte HTTP, analyse, normalisation, déduplication, nettoyage, classification, stockage et diffusion. Cron quotidien · file d'attente (5 min) 1 Sources16 sources actives : RSS, Atom, RDF, HTML 2 Collecte HTTProbots.txt · ETag / Last-Modified · anti-SSRF 3 AnalyseParseur DOM anti-XXE · JSON-LD, Open Graph 4 NormalisationURL canonique · dates converties en UTC 5 DéduplicationURL → identifiant de flux → titre + date 6 NettoyageHTMLPurifier · extrait seul + lien source 7 ClassificationRègles pondérées · seuil · catégorie de repli 8 StockageMySQL 8.4 · index FULLTEXT 9 DiffusionSite · recherche · flux RSS · sitemaps
Les neuf étapes d'une publication, de la source à la page. Chaque exécution ne retraite que ce qui a changé.
Collecter sans abuser des sources
La première règle du projet était de se comporter en invité correct chez les sites qu'on lit :
- robots.txt est lu et respecté avant toute requête (implémentation de la RFC 9309), avec un robot identifiable par son agent utilisateur et un délai entre deux appels.
- Requêtes conditionnelles : quand une source fournit un ETag ou un Last-Modified, je le rejoue à l'exécution suivante. Une réponse 304 Not Modified signifie que rien n'a changé : rien n'est téléchargé, rien n'est retraité. Un matin ordinaire, c'est le cas de près de la moitié des sources.
- Extrait seul : le site affiche le titre, les métadonnées, un court extrait et un lien visible vers la publication d'origine. Le texte intégral n'est jamais stocké, sauf si la licence de la source l'autorise explicitement — ce qui n'est le cas d'aucune aujourd'hui.
Trois sources sont volontairement désactivées : Meta AI, dont les conditions d'utilisation interdisent la collecte automatisée ; Perplexity, protégée par un dispositif anti-robot ; VentureBeat, qui limite fortement le débit (HTTP 429). Une source qui interdit la collecte n'est pas collectée, même quand c'est techniquement possible.
Une URL venue d'un flux est une entrée non fiable
Un flux RSS contient des URL que je n'ai pas choisies, et mon serveur va les appeler. C'est exactement le scénario d'une attaque SSRF, où l'on fait interroger à un serveur ses propres services internes. D'où plusieurs garde-fous :
- les adresses privées et réservées sont refusées ;
- l'adresse IP résolue est épinglée pour la connexion, ce qui empêche un changement de résolution DNS entre la vérification et la requête ;
- les redirections sont suivies à la main et revérifiées à chaque saut ;
- la taille des réponses est plafonnée.
Côté XML, le parseur DOM est configuré contre les entités externes (XXE), avec un test dédié pour qu'une régression ne passe pas inaperçue. Côté HTML, HTMLPurifier nettoie le contenu à l'import, à l'enregistrement et à l'affichage.
Dédoublonner : trois empreintes
Le même article circule sous plusieurs formes : URL avec ou sans paramètres de suivi, republication, flux qui change d'identifiant. La détection procède en trois passes :
- l'URL canonique, normalisée (paramètres de suivi comme utm_* ou fbclid retirés) puis hachée ;
- l'identifiant fourni par le flux (guid en RSS, id en Atom) ;
- à défaut, une empreinte stable du titre normalisé et de la date.
public function externalHash(FeedItem $item): string
{
$id = $item->externalId !== null ? trim($item->externalId) : '';
if ($id !== '') {
return hash('sha256', 'id:' . $id);
}
// Pas d'identifiant : empreinte du titre normalisé et de la date de publication
$title = trim((string) preg_replace('/[^a-z0-9]+/', ' ', Text::fold($item->title)));
return hash('sha256', 'fp:' . $title . '|' . ($item->publishedAt?->format('Y-m-d') ?? ''));
}La date est prise au jour près, pas à la seconde : un même article republié avec une heure différente garde la même empreinte. Une empreinte du contenu permet ensuite de détecter les mises à jour, et un article corrigé à la main dans l'administration est marqué pour que la synchronisation suivante n'écrase pas la correction.
Chercher « IA » dans un index FULLTEXT
La recherche repose sur l'index FULLTEXT de MySQL en mode booléen, avec le titre pondéré trois fois plus que le reste dans le calcul de pertinence. Elle bute sur un piège classique : InnoDB ignore les mots plus courts que innodb_ft_min_token_size (3 caractères par défaut). Sur un site consacré à l'IA, « IA » et « AI » étaient donc introuvables. Et un terme comme gpt-4o est coupé au tiret.
Pour ces termes, la recherche bascule sur REGEXP_LIKE, le moteur d'expressions régulières ICU de MySQL 8, avec un motif borné par mot. C'est plus lent, mais réservé aux seuls termes que l'index ne sait pas traiter.
Déclencher sans exposer le serveur
La synchronisation tourne chaque matin par cron. Depuis l'administration, le bouton « synchroniser » n'exécute rien lui-même : il dépose une demande dans une file d'attente que le cron dépile toutes les cinq minutes. Le processus web ne lance jamais de commande système, et les fonctions qui le permettraient (exec, shell_exec, proc_open…) sont désactivées dans la configuration de PHP. Un verrou empêche deux synchronisations de se chevaucher.
Ce qui n'a pas marché
Un pipeline qui semblait à moitié cassé
Un relecteur m'a signalé que les derniers articles venaient tous de TechCrunch et de The Verge, et qu'OpenAI et Anthropic n'apparaissaient pas dans le bloc « par fournisseur » de l'accueil. Premier réflexe : des flux cassés. Les journaux disaient le contraire : 15 des 16 sources s'étaient synchronisées le matin même, en succès ou en 304. La seule en échec, Hugging Face, renvoyait une erreur 503 passagère, sans rapport avec le symptôme.
La cause était ailleurs. Le bloc retenait les sources ayant publié le plus récemment, et les médias publient tous les jours, week-end compris — pas les laboratoires. Deux places sur six revenaient à des médias, ce qui poussait OpenAI à la septième. Le bloc est désormais réservé aux laboratoires. Leçon : lire les journaux avant de corriger. Un pipeline correct peut très bien avoir l'air cassé.
La classification par règles montre ses limites
Le classement repose sur 106 règles pondérées : chaque mot-clé ou expression régulière ajoute son poids à une catégorie, qui est retenue au-delà d'un seuil. Deux cas m'ont montré les limites du système.
Un anneau connecté de prise de notes a été classé en « Sécurité & éthique », parce que son extrait évoquait des « privacy questions ». Le mot-clé privacy pesait 2 : exactement le seuil. Un seul mot incident suffisait. Et Google Research, un laboratoire de recherche, voyait 27 de ses 30 articles tomber dans la catégorie de repli, faute de règle reconnaissant son vocabulaire : au total, 125 publications sur 330 y atterrissent.
Ce n'est pas encore corrigé. Les prochaines étapes sont d'empêcher un mot isolé d'atteindre le seuil et de donner à chaque source une catégorie par défaut, avant d'envisager un classement par modèle de langage.
Trois pannes en production, aucune en local
Tous les formulaires répondaient 419. Impossible de se connecter à l'administration en production, alors que tout fonctionnait en local. Pour isoler la cause, j'ai envoyé le même jeton CSRF de deux façons : dans un en-tête HTTP, la requête passait (303) ; dans le corps du formulaire, elle échouait (419). La session et le jeton étaient donc sains : c'est le corps de la requête que PHP ne lisait pas. En cause, enable_post_data_reading = Off dans le php.ini du serveur, qui laisse $_POST vide quoi qu'on envoie. Le réglage est rétabli au niveau du pool PHP-FPM de l'application. C'est la garantie « offerte d'office » par les frameworks dont je parlais plus haut : sans framework, c'est à moi de savoir où regarder.
Un 403 sur tout le site après une mise à jour. Les fichiers appartiennent à un utilisateur système dédié, et Apache n'y accède qu'à travers le groupe de cet utilisateur. Une mise à jour lancée depuis un autre compte a réattribué les fichiers : Apache ne pouvait plus rien lire. Désormais, le code arrive uniquement par rsync depuis mon poste, et le script de déploiement répare les droits avant toute autre étape.
Des sitemaps faux sans la moindre erreur. Une faute de frappe dans la configuration, https://https://, rendait invalides toutes les URL des sitemaps. Ni erreur ni ligne de journal : c'est la Search Console qui l'a révélé, avec zéro page découverte. Leçon : une configuration aberrante devrait empêcher l'application de démarrer, pas passer en silence.
En chiffres
IndicateurValeur
Sources configurées / actives | 19 / 16, dont 3 médias
Publications collectées depuis la mise en ligne | 330
Catégories · thèmes · règles de classement | 9 · 11 · 106
Tests automatisés (PHPUnit) | 106
Code applicatif | ≈ 14 700 lignes de PHP, 1 dépendance de production
Stack | PHP 8.4, MySQL 8.4, Tailwind CSS 4, Apache et PHP-FPM, cron
Limites et suite
- Tout le contenu collecté est en anglais pour un site en français. La prochaine étape est un court résumé en français produit côté serveur, présenté comme tel, corrigeable à la main et limité aux faits présents dans la source — sans remettre en cause la politique d'extrait.
- Le classement doit gagner en fiabilité, comme décrit plus haut.
- Les analyses personnelles : le site doit distinguer clairement ce qui est collecté de ce que j'écris moi-même.
Le plus difficile n'a pas été de lire des flux RSS. Ce fut de respecter les sources, de traiter chaque donnée entrante comme potentiellement hostile, et de comprendre pourquoi une application qui marche parfaitement en local tombe en production. C'est aussi ce que ce projet m'a appris de plus utile.