Fransys

Blog technique — Architecture, Cloud & DevOps

BlogServicesContactÀ propos

Suivez-moi

githubGitHublinkedinLinkedinmailMail

© 2026 Fransys • Fransys

Fransys

Catégories

  • Tous les articles
  • Tags
  • productivite10
  • nas10
  • ia8
  • securite7
  • linux6
  • claude-code6
  • auto-hebergement6
  • neovim5
  • docker5
  • editeur4
  • mcp3
  • vpn3
  • reseau3
  • lua2
  • terminal2
auto-hebergementdockeriaproductivite

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

Publié le
17 mars 2026·9 min de lecture
Avatar François GUERLEZFrançois GUERLEZ

Trop de bruit, pas assez de signal

J'ai perdu un temps fou à essayer de suivre plusieurs sujets en parallèle. Genre, vraiment beaucoup de temps. Google Alerts m'envoyait des trucs à moitié pertinents trois fois par jour. Les flux RSS bruts ? Noyé sous des dizaines d'articles qui disent la même chose avec des mots différents. Twitter/X, n'en parlons pas - c'est devenu un bazar inutilisable pour de la veille structurée.

Et les SaaS type Mention, Talkwalker ou Feedly Pro, c'est entre 30 et 300€/mois. Pour des fonctionnalités qu'on peut clairement reproduire avec du RSS, un script et un LLM.

Du coup j'ai posé ce que je voulais vraiment :

  • 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 : mes données restent chez moi

L'architecture

Un VPS à 10€/mois, Docker, Node.js, un LLM en CLI, et un cron. Rien d'autre.

┌──────────────────────────────────────────────────────┐
│                        VPS                           │
│                                                      │
│  Cron */10 min              Cron */30 min            │
│      │                          │                    │
│      ▼                          ▼                    │
│  monitor.js               page-monitor.js            │
│      │                          │                    │
│      ├── Google News RSS        ├── Page A (hash)    │
│      ├── GitHub Releases        ├── Page B (hash)    │
│      ├── Blogs (Atom/RSS)       └── Page C (hash)    │
│      ├── Al Jazeera, etc.                            │
│      │                                               │
│      ├─ 1. Filtre fraîcheur (< 6h)                  │
│      ├─ 2. Dédup URL + titre normalisé (SQLite)     │
│      ├─ 3. Keyword pre-filter (gratuit)              │
│      ├─ 4. LLM scoring + résumé + dédup sémantique  │
│      │                                               │
│      ▼                                               │
│  Slack / Discord / Telegram                          │
└──────────────────────────────────────────────────────┘

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. Alerte quand ça change, point.

La stack Docker

J'ai galéré une bonne heure avec Docker Hub qui me renvoyait des rate limits (You have reached your unauthenticated pull rate limit). 100 pulls anonymes par 6h, c'est vite atteint quand on itère. Du coup, tout est passé sur ghcr.io.

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). Les trois images sont aussi publiées sur GitHub Container Registry (ghcr.io), sans limite. C'est un réflexe à prendre pour tout déploiement automatisé.

  • n8n : plateforme d'automatisation visuelle. Je l'ai déployé au cas où, pour ajouter des workflows graphiques plus tard. Pas indispensable pour le 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 à la volée sans toucher au code.

Le tout tourne sur ~1.5 Go de RAM. Un VPS 4 Go gère ça sans broncher.

Le pipeline de filtrage : 4 couches

Le vrai sujet. Toute la logique tient là-dedans. 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é ont plus de 24h. Avec un seuil à 6h, on passe de 100 à 5-20. Configurable dans config.json - si vous préférez un digest quotidien, montez à 12h ou 24h.

Couche 2 : déduplication URL + titre

Le truc qui m'a rendu dingue au début : 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.

La dédup par URL seule, ça marche pas. J'ai ajouté 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, et on compare les 60 premiers caractères normalisés. Deux articles sur le même événement avec des titres quasi identiques ? Un seul passe.

Stockage SQLite avec index sur le hash du titre :

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);

Rétention 30 jours, nettoyage auto.

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"]
}

On cherche les mots-clés dans le titre + extrait. Un simple includes() en lowercase, volontairement permissif. Le filtrage fin, c'est le boulot du LLM juste après.

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

La couche qui fait toute la différence. Les articles qui ont survécu aux 3 premières couches 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 :

  1. Score de pertinence (un article qui mentionne un mot-clé en passant -> score 3, pas envoyé)
  2. Dédup sémantique (5 articles sur le même événement -> le meilleur est gardé)
  3. Résumé en 2-3 phrases exploitables, 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, et donc la facture en tokens. Keywords gratuits d'abord, LLM payant ensuite uniquement sur les candidats sérieux.

Le monitoring de pages

Certaines sources n'ont pas de RSS. Un changelog, une page de doc, un blog sans feed. Pour celles-là j'ai fait page-monitor.js, et franchement c'est le bout de code dont je suis le plus content :

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/pubs qui bougent tout le temps), on hash, on compare. Zéro faux positif en 2 semaines d'utilisation.

Tourne toutes les 30 minutes via cron. Consomme quasiment rien.

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.

Petit piège que j'ai découvert en prod : mon VPS tourne sous fish, et fish ne supporte pas la syntaxe heredoc (<<EOF). Du coup pour écrire les fichiers de config j'ai dû passer par bash -c ou transférer par scp. 15 minutes de debug pour comprendre pourquoi mes commandes plantaient.

Le résultat dans Slack

Chaque vérification produit un message structuré par topic :

🔴 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 channels restent propres.

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. Ratio signal/bruit de 1:80. Le premier run m'a envoyé 26 articles sur Slack. Le deuxième, 10 minutes plus tard : zéro. La dédup marchait.

Et après ?

Ce setup couvre 90% de mes besoins. Quelques trucs que j'ai en tête :

  • Sources Telegram OSINT via MTProto - certains channels cassent les news 15-30 min avant les médias classiques
  • LLM local via Ollama pour virer 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é, autant s'en servir)
  • 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 10 minutes.


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

Article précédent

← Sécuriser ses clés API MCP dans Claude Code (et pourquoi c'est urgent)
← Retour au blog

Sommaire

  • Trop de bruit, pas assez de signal
  • L'architecture
  • La stack Docker
  • Le pipeline de filtrage : 4 couches
  • Couche 1 : fraîcheur
  • Couche 2 : déduplication URL + titre
  • Couche 3 : keyword pre-filter
  • Couche 4 : scoring LLM + déduplication sémantique
  • Le monitoring de pages
  • Le cron
  • Le résultat dans Slack
  • Les chiffres
  • Et après ?