Construire un système de veille thématique quasi temps réel sur un VPS

Construire un système de veille thématique quasi temps réel sur un VPS

·8 min de lecture·Mis à jour le 17 mars 2026

Trop de bruit, pas assez de signal

Suivre plusieurs sujets en parallèle est coûteux en temps. Google Alerts envoie des résultats à moitié pertinents plusieurs fois par jour, les flux RSS bruts noient sous des dizaines d'articles qui disent la même chose autrement, et les SaaS type Mention, Talkwalker ou Feedly Pro coûtent entre 30 et 300 €/mois pour des fonctionnalités reproductibles avec du RSS, un script et un LLM.

Le cahier des charges :

  • Multi-thématiques : suivre 3-5 sujets en parallèle, chacun avec ses sources et mots-clés
  • Quasi temps réel : vérification toutes les 10 minutes
  • Filtrage intelligent : pas juste du keyword matching, un vrai scoring de pertinence
  • Résumés exploitables : 2-3 phrases qui donnent l'essentiel sans cliquer
  • Zéro doublon : même événement couvert par 10 médias = 1 seul message
  • Self-hosted : les données restent chez soi

L'architecture

Un VPS à 10 €/mois, Docker, Node.js, un LLM en CLI, et un cron. Deux scripts :

  • monitor.js : agrège les flux RSS, filtre, score via LLM, pousse sur Slack
  • page-monitor.js : surveille des pages web sans RSS (changelogs, blogs) en comparant un hash SHA-256 du contenu

La stack Docker

services:
  n8n:
    image: ghcr.io/n8n-io/n8n:latest
    ports:
      - '5678:5678'
    environment:
      - GENERIC_TIMEZONE=Europe/Paris
    volumes:
      - n8n-data:/home/node/.n8n

  changedetection:
    image: ghcr.io/dgtlmoon/changedetection.io:latest
    ports:
      - '5000:5000'
    volumes:
      - changedetection-data:/datastore

  rsshub:
    image: ghcr.io/diygod/rsshub:latest
    ports:
      - '1200:1200'
    environment:
      - CACHE_TYPE=memory
      - CACHE_EXPIRE=600
Pourquoi ghcr.io ?

Docker Hub impose des rate limits sur les pulls anonymes (100/6h) — vite atteints quand on itère (You have reached your unauthenticated pull rate limit). Les trois images sont aussi publiées sur GitHub Container Registry (ghcr.io), sans limite. Réflexe à prendre pour tout déploiement automatisé.

  • n8n : plateforme d'automatisation visuelle, utile pour ajouter des workflows graphiques plus tard. Pas indispensable au monitoring de base.
  • RSSHub : transforme à peu près n'importe quoi en flux RSS (repos GitHub, subreddits, chaînes YouTube). Indispensable quand la source n'a pas de feed natif.
  • changedetection.io : UI web pour surveiller des pages, pratique pour ajouter des watchers sans toucher au code.

Le tout tourne sur ~1,5 Go de RAM ; un VPS 4 Go gère sans broncher.

Le pipeline de filtrage : 4 couches

L'idée : éliminer un maximum de bruit avant d'appeler le LLM, parce que chaque appel coûte du temps et des tokens.

Couche 1 : fraîcheur

const MAX_AGE_HOURS = config.max_age_hours || 6

function isRecent(item) {
  if (!item.pubDate && !item.isoDate) return true
  const pubDate = new Date(item.isoDate || item.pubDate)
  if (isNaN(pubDate.getTime())) return true
  const ageMs = Date.now() - pubDate.getTime()
  return ageMs >= 0 && ageMs < MAX_AGE_HOURS * 60 * 60 * 1000
}

Google News renvoie 100 articles par requête, la majorité de plus de 24 h. Avec un seuil à 6 h, on passe de 100 à 5-20. Configurable dans config.json : pour un digest quotidien, monter à 12 ou 24 h.

Couche 2 : déduplication URL + titre

Le piège principal : Google News génère une URL de redirection unique pour chaque résultat, même quand deux liens pointent vers le même article. « Iran strike - Reuters » et « Iran strike - BBC » ont des URLs news.google.com/rss/articles/CBM... complètement différentes, donc la dédup par URL seule ne suffit pas. D'où une normalisation du titre :

function normalizeTitle(title) {
  if (!title) return ''
  return title
    .toLowerCase()
    .replace(/\s*[-–—|:]\s*(the\s+)?(reuters|ap|bbc|cnn|...).*$/i, '')
    .replace(/[^a-z0-9àâäéèêëïîôùûüÿçæœ]/g, '')
    .replace(/^(update|breaking|live|exclusive|watch|video)\s*/i, '')
    .slice(0, 60)
}

On vire le suffixe source (- Reuters, | BBC), les préfixes éditoriaux (BREAKING:, LIVE:), la ponctuation, puis on compare les 60 premiers caractères normalisés.

Stockage SQLite avec index sur le hash du titre, rétention 30 jours et nettoyage auto :

CREATE TABLE seen_articles (
  url TEXT PRIMARY KEY,
  title TEXT,
  title_hash TEXT,
  topic TEXT,
  seen_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_title_hash ON seen_articles(title_hash);

Couche 3 : keyword pre-filter

Gratuit, instantané. Chaque topic a sa liste de mots-clés dans la config :

{
  "name": "Mon Topic",
  "keywords": ["mot-cle-1", "mot-cle-2", "expression exacte"],
  "slack_channel": "C0XXXXXXX",
  "feeds": ["https://news.google.com/rss/search?q=...", "https://github.com/org/repo/releases.atom"]
}

Un simple includes() en lowercase sur le titre + extrait, volontairement permissif. Le filtrage fin est le boulot du LLM juste après.

Couche 4 : scoring LLM + déduplication sémantique

Les articles survivants partent au LLM par batch de 25 :

Score chaque article (0-10) selon sa pertinence pour le topic.
DÉDUPLIQUE : si plusieurs articles couvrent le même événement,
ne garde que le meilleur.
Résumé de 2-3 phrases en FR avec les infos clés.
Inclus UNIQUEMENT score >= 6.

Un seul appel, trois résultats : un score de pertinence (un article qui mentionne un mot-clé en passant tombe à 3 et n'est pas envoyé), une dédup sémantique (cinq articles sur le même événement, le meilleur est gardé), et un résumé exploitable en français.

Le résultat est du JSON structuré parsé côté Node.js. Si le LLM timeout ou plante, un fallback renvoie les articles avec un score par défaut — le système ne casse jamais.

Pourquoi scorer avant de résumer ? On pourrait résumer tous les articles puis filtrer. Mais scorer d'abord réduit de 80-90 % le volume à traiter, donc la facture en tokens. Keywords gratuits d'abord, LLM payant ensuite uniquement sur les candidats sérieux.

Le monitoring de pages

Pour les sources sans RSS (changelog, page de doc, blog sans feed) :

const content = extractMainContent(html)
const hash = createHash('sha256').update(content).digest('hex')

const existing = db.prepare('SELECT hash FROM page_hashes WHERE url = ?').get(url)
if (existing && existing.hash !== hash) {
  // Changement détecté -> alerte Slack
  await postToSlack(channel, `🔔 Changement détecté sur ${pageName}`)
}

On extrait le contenu <main> — pour ignorer headers, footers et pubs qui bougent en permanence — on hash, on compare. Zéro faux positif après deux semaines d'utilisation. Tourne toutes les 30 minutes via cron, pour une consommation négligeable.

Le cron

*/10 * * * * cd ~/news-monitor/app && node monitor.js >> monitor.log 2>&1
*/30 * * * * cd ~/news-monitor/app && node page-monitor.js >> monitor.log 2>&1
PATH dans cron

Cron a un PATH minimal. Si votre LLM CLI n'est pas dans /usr/bin/, pensez à ajouter son chemin : PATH=/usr/local/bin:/usr/bin:/home/user/.local/bin avant la commande.

Autre piège en prod : sous fish, la syntaxe heredoc (<<EOF) n'existe pas. Pour écrire les fichiers de config, il faut passer par bash -c ou transférer en scp.

Le résultat dans Slack

🔴 Veille Géopolitique - 17/03/2026 09:50

Titre de l'article
> Résumé de 2-3 phrases qui donne les infos clés.
> Le lecteur comprend l'essentiel sans cliquer.
_8/10 - 17/03, 08:30_

Autre article sur un sujet différent
> Contexte et détails importants résumés ici.
> Impact et conséquences mentionnés.
_7/10 - 17/03, 07:15_

Pas de doublon, pas de bruit. Si rien de nouveau depuis le dernier run, rien n'est envoyé.

Les chiffres

Sur un run typique avec un topic géopolitique actif :

ÉtapeArticlesRéduction
Flux RSS bruts~400-
Après filtre fraîcheur (6h)~25-94%
Après dédup URL + titre~20-20%
Après keyword filter~15-25%
Après LLM scoring (>= 6)~8-47%
Après dédup sémantique LLM~5-37%

400 articles réduits à 5, soit un ratio signal/bruit de 1:80. Premier run : 26 articles envoyés sur Slack. Deuxième run, dix minutes plus tard : zéro. La dédup fonctionne.

Et après ?

Ce setup couvre l'essentiel du besoin. Quelques pistes :

  • Sources Telegram OSINT via MTProto — certains channels cassent les news 15-30 min avant les médias classiques
  • LLM local via Ollama pour supprimer la dépendance à une API externe. Un Llama 3.2 8B tourne sur 8 Go de RAM et suffit largement pour du scoring
  • Dashboard web avec historique et stats (n8n est déjà déployé)
  • Alertes push pour les scores 9-10, au lieu d'attendre le prochain poll

Le code tient en deux fichiers (~200 lignes chacun), une config JSON et un Docker Compose. Pas de framework, pas de dépendance exotique : si le VPS tombe, on redéploie en dix minutes.


Stack : Debian 13 - Docker Compose - Node.js 22 - RSSHub - changedetection.io - SQLite - LLM CLI - Slack API

PartagerLinkedInXBluesky

Articles similaires