Rafraîchir une page dix fois par jour pour voir si un prix a baissé, si une offre d’emploi est apparue ou si ton site est toujours en ligne ? Laisse un bot Node.js le faire à ta place. Dans ce tutoriel, tu crées un bot qui surveille des sites web et qui t’envoie une alerte sur Discord ou Telegram dès que quelque chose change, ou dès qu’un site tombe en panne.

🟢 Node.js 22+🔎 Cheerio⏰ node-cron💬 Discord webhook✈️ Telegram Bot API⏱️ 30 min

L’automatisation est l’un des usages les plus satisfaisants de la programmation : on écrit le code une fois, puis il travaille pour nous jour et nuit. Et Node.js est parfait pour ce genre de bot : léger, asynchrone, avec fetch intégré et un écosystème énorme.

À la fin de ce tutoriel, ton bot saura :

  • surveiller un élément précis d’une page (un prix, une liste d’offres, un statut) grâce à un sélecteur CSS ;
  • vérifier la disponibilité d’un site et prévenir quand il tombe, puis quand il revient ;
  • envoyer des alertes sur Discord (webhook) et sur Telegram (Bot API), en parallèle ;
  • tourner tout seul à intervalle régulier, sans spammer, en résistant aux erreurs réseau.
Bot Node.js qui surveille un site web et envoie des alertes Discord et Telegram
Le bot Node.js de ce tutoriel : il surveille des sites et prévient sur Discord et Telegram.
✅
Un code réellement testé

Tout le code de cet article a été exécuté pendant la rédaction, avec un faux site et de faux serveurs Discord et Telegram. Les journaux et les messages que tu verras sont les vrais résultats.

⚖️
Scraper, oui, mais poliment

Vérifie les conditions d’utilisation et le fichier robots.txt du site surveillé, n’interroge pas une page plus d’une fois toutes les quelques minutes, et identifie ton bot avec un User-Agent clair. Pour tes propres sites ou des pages publiques consultées rarement, il n’y a aucun souci. Si le site propose une API ou un flux RSS, utilise-les en priorité.

Comment fonctionne un bot de surveillance de site ?

Cycle d'un bot de surveillance Node.js : cron, fetch, Cheerio, empreinte SHA-256, alerte
À chaque tour : télécharger, extraire, comparer, et prévenir seulement s’il y a du nouveau.

Le principe est simple. À chaque tour, le bot télécharge la page, extrait la partie qui t’intéresse, calcule une empreinte (un hachage SHA-256) de ce texte, et la compare à celle enregistrée au tour précédent. Si elle est différente, quelque chose a changé : il envoie une alerte. Sinon, il ne dit rien.

Pour la disponibilité, même logique : on mémorise si le site était en ligne, et on ne prévient que lors d’un changement d’état. Une panne de deux heures produit donc deux messages (🔴 puis 🟢), pas vingt-quatre.

Installation du projet

mkdir site-watcher-bot && cd site-watcher-bot
npm init -y
npm install cheerio node-cron zod
Terminal
PaquetRôle
cheerioLire le HTML et sélectionner des éléments avec des sélecteurs CSS, comme jQuery
node-cronLancer une fonction à intervalle régulier avec la syntaxe cron
zodValider la configuration et la liste des sites surveillés

Pas besoin d’Axios ni de dotenv : Node.js 22 fournit fetch et l’option --env-file. Voici le package.json :

{
  "name": "site-watcher-bot",
  "type": "module",
  "main": "src/index.js",
  "scripts": {
    "start": "node --env-file=.env src/index.js",
    "once": "node --env-file=.env src/index.js --once"
  },
  "engines": { "node": ">=22" },
  "dependencies": {
    "cheerio": "^1.2.0",
    "node-cron": "^4.6.0",
    "zod": "^4.6.7"
  }
}
package.json
site-watcher-bot/ ├── .env Clés Discord et Telegram (secret) ├── watchlist.json La liste des sites à surveiller ├── data/state.json Mémoire du bot (créé automatiquement) └── src/ ├── index.js Planification et boucle principale ├── config.js Lecture et validation de la configuration ├── fetcher.js Téléchargement avec timeout et nouvelles tentatives ├── extract.js Extraction du texte et empreinte SHA-256 ├── state.js Lecture / écriture de l’état ├── watcher.js La logique de détection ├── logger.js Journal horodaté └── notifiers/ discord.js, telegram.js, index.js

Étape 1 : préparer Discord et Telegram

Créer un webhook Discord

  1. Sur ton serveur Discord, ouvre les paramètres du salon qui recevra les alertes.
  2. Va dans Intégrations → Webhooks → Nouveau webhook.
  3. Donne-lui un nom (« Site Watcher ») et une image, puis clique sur Copier l’URL du webhook.

Un webhook est la façon la plus simple d’envoyer des messages sur Discord : pas besoin de créer une application ni de garder une connexion ouverte, une simple requête HTTP POST suffit.

Créer un bot Telegram

  1. Dans Telegram, ouvre une conversation avec @BotFather et envoie /newbot.
  2. Choisis un nom et un identifiant se terminant par « bot ». BotFather te donne un jeton (token) du type 123456:ABC-DEF….
  3. Envoie un premier message à ton nouveau bot (sinon il n’a pas le droit de t’écrire).
  4. Récupère ton chat_id en ouvrant https://api.telegram.org/bot<TON_JETON>/getUpdates dans le navigateur : il apparaît dans "chat":{"id": …}.
# Fréquence de vérification (syntaxe cron) : ici toutes les 10 minutes
CRON=*/10 * * * *

# Discord : Paramètres du salon > Intégrations > Webhooks > Copier l'URL
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/123/abc

# Telegram : jeton donné par @BotFather + identifiant de ta conversation
TELEGRAM_BOT_TOKEN=123456:ABC-DEF
TELEGRAM_CHAT_ID=987654321
.env
🔐
Ces clés sont des secrets

Quiconque possède l’URL du webhook ou le jeton Telegram peut envoyer des messages en ton nom. Ajoute .env à ton .gitignore et ne le publie jamais.

Étape 2 : la liste des sites et la configuration

Les sites à surveiller sont décrits dans un simple fichier JSON. Deux types de surveillance : change (un élément de la page change) et uptime (le site répond-il ?). Le champ facultatif contains permet de n’être prévenu que si un mot apparaît.

[
  {
    "name": "Prix du clavier",
    "url": "https://exemple-boutique.be/produit/clavier-mecanique",
    "selector": ".product-price",
    "type": "change"
  },
  {
    "name": "Mon portfolio",
    "url": "https://lm-code.be",
    "type": "uptime"
  },
  {
    "name": "Nouvelles offres d'emploi",
    "url": "https://exemple-jobs.be/offres?q=nodejs",
    "selector": ".job-list",
    "contains": "Node.js",
    "type": "change"
  }
]
watchlist.json
🎯
Trouver le bon sélecteur CSS

Ouvre la page dans Chrome, fais un clic droit sur l’élément (le prix, par exemple) → Inspecter. Repère une classe ou un identifiant stable (.product-price, #stock). Dans la console, document.querySelector('.product-price').textContent te confirme que c’est le bon.

On valide tout avec Zod au démarrage : une URL mal écrite ou un sélecteur oublié sont détectés immédiatement, avec un message clair.

import { readFile } from 'node:fs/promises';
import { z } from 'zod';

const surveillance = z.object({
  name: z.string().min(1),
  url: z.url('URL invalide'),
  type: z.enum(['change', 'uptime']).default('change'),
  selector: z.string().optional(),          // partie de la page à comparer (sinon : tout le <body>)
  contains: z.string().optional(),          // alerte seulement si ce texte apparaît
}).refine((s) => s.type === 'uptime' || s.selector, {
  message: 'Un sélecteur CSS est requis pour le type « change »',
});

const envSchema = z.object({
  CRON: z.string().default('*/10 * * * *'),
  DISCORD_WEBHOOK_URL: z.url().optional().or(z.literal('')),
  TELEGRAM_BOT_TOKEN: z.string().optional(),
  TELEGRAM_CHAT_ID: z.string().optional(),
  TELEGRAM_API_URL: z.url().default('https://api.telegram.org'),
});

export const env = envSchema.parse(process.env);

export async function chargerListe(chemin = 'watchlist.json') {
  const brut = JSON.parse(await readFile(chemin, 'utf8'));
  return z.array(surveillance).parse(brut);
}
src/config.js

La variable TELEGRAM_API_URL n’a l’air de rien, mais elle est précieuse : elle permet de pointer le bot vers un faux serveur pendant les tests, comme nous l’avons fait pour ce tutoriel.

Étape 3 : télécharger une page sans planter

Le réseau n’est jamais fiable : un serveur lent, une coupure Wi-Fi, un DNS capricieux… Notre fonction de téléchargement impose un délai maximal et réessaie automatiquement avec un backoff exponentiel (1 s, puis 2 s, puis 4 s).

const attendre = (ms) => new Promise((r) => setTimeout(r, ms));

// Télécharge une page avec délai maximal et nouvelles tentatives (backoff exponentiel).
export async function telecharger(url, { tentatives = 3, delaiMax = 10_000 } = {}) {
  let derniereErreur;

  for (let essai = 1; essai <= tentatives; essai++) {
    const debut = performance.now();
    try {
      const reponse = await fetch(url, {
        signal: AbortSignal.timeout(delaiMax),
        headers: { 'User-Agent': 'LM-Code-SiteWatcher/1.0 (+https://lm-code.be)' },
      });
      const html = await reponse.text();
      return { status: reponse.status, ok: reponse.ok, html, duree: Math.round(performance.now() - debut) };
    } catch (erreur) {
      derniereErreur = erreur;
      if (essai < tentatives) await attendre(1000 * 2 ** (essai - 1));   // 1 s, 2 s, 4 s…
    }
  }
  throw derniereErreur;
}
src/fetcher.js

AbortSignal.timeout() annule la requête si elle dépasse 10 secondes : sans cela, un serveur qui ne répond jamais bloquerait ton bot indéfiniment. Note qu’une réponse HTTP d’erreur (404, 503) n’est pas une exception : fetch la renvoie normalement, et c’est notre logique qui décidera quoi en faire.

Étape 4 : extraire le contenu avec Cheerio

import * as cheerio from 'cheerio';
import { createHash } from 'node:crypto';

// Extrait le texte de la zone surveillée et le « normalise » pour éviter les fausses alertes.
export function extraire(html, selecteur = 'body') {
  const $ = cheerio.load(html);
  $('script, style, noscript').remove();
  const texte = $(selecteur).text().replace(/\s+/g, ' ').trim();
  return { texte, empreinte: createHash('sha256').update(texte).digest('hex') };
}
src/extract.js

Deux précautions évitent la majorité des fausses alertes :

  • on supprime les scripts et styles, qui contiennent souvent des valeurs aléatoires (jetons, horodatages) changeant à chaque chargement ;
  • on normalise les espaces : un retour à la ligne en plus dans le HTML ne doit pas être considéré comme un changement.
🧠
Pourquoi une empreinte SHA-256 ?

Stocker une empreinte de 64 caractères plutôt que la page entière garde le fichier d’état minuscule, et la comparaison est instantanée. On conserve aussi un court extrait (300 caractères) pour afficher l’« avant » dans l’alerte.

import { readFile, writeFile, mkdir } from 'node:fs/promises';

const FICHIER = new URL('../data/state.json', import.meta.url);

export async function lireEtat() {
  try {
    return JSON.parse(await readFile(FICHIER, 'utf8'));
  } catch {
    return {};                      // premier lancement : aucun état enregistré
  }
}

export async function sauverEtat(etat) {
  await mkdir(new URL('../data/', import.meta.url), { recursive: true });
  await writeFile(FICHIER, JSON.stringify(etat, null, 2));
}
src/state.js

Étape 5 : envoyer les alertes Discord et Telegram

Le notificateur Discord

import { env } from '../config.js';

const COULEURS = { change: 0x5865f2, down: 0xed4245, up: 0x57f287 };

export async function envoyerDiscord({ titre, message, url, niveau = 'change' }) {
  if (!env.DISCORD_WEBHOOK_URL) return;

  const reponse = await fetch(env.DISCORD_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      username: 'Site Watcher',
      embeds: [{
        title: titre,
        description: message.slice(0, 4000),
        url,
        color: COULEURS[niveau],
        timestamp: new Date().toISOString(),
        footer: { text: 'Bot LM-Code' },
      }],
    }),
  });
  if (!reponse.ok) throw new Error(`Discord a répondu ${reponse.status}`);
}
src/notifiers/discord.js

On envoie un embed : un message enrichi avec titre cliquable, couleur (bleu pour un changement, rouge pour une panne, vert pour un retour) et horodatage. La description est tronquée à 4 000 caractères, sous la limite de Discord.

Le notificateur Telegram

import { env } from '../config.js';

const echapper = (t) => t.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));

export async function envoyerTelegram({ titre, message, url }) {
  if (!env.TELEGRAM_BOT_TOKEN || !env.TELEGRAM_CHAT_ID) return;

  const reponse = await fetch(`${env.TELEGRAM_API_URL}/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      chat_id: env.TELEGRAM_CHAT_ID,
      parse_mode: 'HTML',
      text: `<b>${echapper(titre)}</b>\n${echapper(message.slice(0, 3500))}\n\n🔗 ${echapper(url)}`,
      link_preview_options: { is_disabled: true },
    }),
  });
  const corps = await reponse.json();
  if (!corps.ok) throw new Error(`Telegram : ${corps.description}`);
}
src/notifiers/telegram.js

Avec parse_mode: 'HTML', Telegram interprète les balises <b>. Il faut donc échapper le texte extrait du site : un simple « < » dans un prix ferait échouer l’envoi.

Envoyer sur tous les canaux en parallèle

import { envoyerDiscord } from './discord.js';
import { envoyerTelegram } from './telegram.js';
import { log } from '../logger.js';

// On prévient sur tous les canaux configurés ; l'échec de l'un n'empêche pas l'autre.
export async function notifier(alerte) {
  const resultats = await Promise.allSettled([envoyerDiscord(alerte), envoyerTelegram(alerte)]);
  for (const r of resultats) {
    if (r.status === 'rejected') log.error('Notification échouée :', r.reason.message);
  }
}
src/notifiers/index.js

Promise.allSettled plutôt que Promise.all : si Discord est en panne, l’alerte Telegram part quand même. Ajouter un canal (e-mail, Slack, SMS) revient à écrire un fichier de plus dans notifiers/.

Alertes du bot Node.js sur Discord et Telegram : changement de prix, site inaccessible, site de retour
Les trois types d’alertes du bot Node.js, telles qu’elles arrivent sur Discord et sur Telegram.

Étape 6 : la logique de détection

// Un petit logger horodaté : suffisant pour un bot, sans dépendance.
const horodatage = () => new Date().toLocaleString('fr-BE');

export const log = {
  info: (...m) => console.log(`[${horodatage()}] ℹ️ `, ...m),
  ok: (...m) => console.log(`[${horodatage()}] ✅`, ...m),
  warn: (...m) => console.warn(`[${horodatage()}] ⚠️ `, ...m),
  error: (...m) => console.error(`[${horodatage()}] ❌`, ...m),
};
src/logger.js
import { telecharger } from './fetcher.js';
import { extraire } from './extract.js';
import { notifier } from './notifiers/index.js';
import { log } from './logger.js';

export async function verifier(cible, etat) {
  const precedent = etat[cible.url] ?? {};

  let page;
  try {
    page = await telecharger(cible.url);
  } catch (erreur) {
    page = { ok: false, status: 0, erreur: erreur.message };
  }

  // ----- Disponibilité : on n'alerte que lors d'un CHANGEMENT d'état -----
  const enLigne = page.ok;
  if (precedent.enLigne !== undefined && precedent.enLigne !== enLigne) {
    await notifier(enLigne
      ? { niveau: 'up', titre: `🟢 ${cible.name} est de retour`, message: `Réponse en ${page.duree} ms.`, url: cible.url }
      : { niveau: 'down', titre: `🔴 ${cible.name} est inaccessible`, message: page.erreur ?? `Code HTTP ${page.status}`, url: cible.url });
  }
  etat[cible.url] = { ...precedent, enLigne, status: page.status, verifieLe: new Date().toISOString() };
  if (!enLigne) return log.warn(`${cible.name} : hors ligne (${page.erreur ?? page.status})`);

  if (cible.type === 'uptime') return log.ok(`${cible.name} : en ligne (${page.duree} ms)`);

  // ----- Changement de contenu -----
  const { texte, empreinte } = extraire(page.html, cible.selector);
  if (!texte) return log.warn(`${cible.name} : sélecteur « ${cible.selector} » introuvable`);

  const aChange = precedent.empreinte && precedent.empreinte !== empreinte;
  const filtreOk = !cible.contains || texte.toLowerCase().includes(cible.contains.toLowerCase());

  if (aChange && filtreOk) {
    await notifier({
      niveau: 'change',
      titre: `🔔 Changement détecté : ${cible.name}`,
      message: `Avant : ${precedent.extrait ?? '—'}\nMaintenant : ${texte.slice(0, 300)}`,
      url: cible.url,
    });
    log.info(`${cible.name} : changement notifié`);
  } else {
    log.ok(`${cible.name} : ${precedent.empreinte ? 'aucun changement' : 'état initial enregistré'}`);
  }
  etat[cible.url] = { ...etat[cible.url], empreinte, extrait: texte.slice(0, 300) };
}
src/watcher.js

Les points importants :

  • Au premier passage, il n’y a pas d’empreinte précédente : on enregistre l’état initial sans alerter (sinon chaque redémarrage déclencherait une avalanche de messages).
  • Un sélecteur introuvable est signalé dans le journal : c’est souvent le signe que le site a changé de structure et qu’il faut mettre à jour watchlist.json.
  • Le filtre contains permet par exemple de n’être prévenu que si une nouvelle offre mentionne « Node.js ».

Étape 7 : la planification avec node-cron

import cron from 'node-cron';
import { env, chargerListe } from './config.js';
import { lireEtat, sauverEtat } from './state.js';
import { verifier } from './watcher.js';
import { log } from './logger.js';

let enCours = false;

async function tour() {
  if (enCours) return log.warn('Tour précédent encore en cours, on passe');
  enCours = true;
  try {
    const liste = await chargerListe();
    const etat = await lireEtat();
    for (const cible of liste) {
      try {
        await verifier(cible, etat);
      } catch (erreur) {
        log.error(`${cible.name} :`, erreur.message);      // une cible en erreur ne bloque pas les autres
      }
    }
    await sauverEtat(etat);
  } catch (erreur) {
    log.error('Tour interrompu :', erreur.message);
  } finally {
    enCours = false;
  }
}

if (process.argv.includes('--once')) {
  await tour();
} else {
  if (!cron.validate(env.CRON)) throw new Error(`Expression CRON invalide : ${env.CRON}`);
  log.info(`Bot démarré, vérification selon « ${env.CRON} »`);
  await tour();                                   // une première vérification tout de suite
  cron.schedule(env.CRON, tour);
}
src/index.js

Le drapeau enCours empêche deux tours de se chevaucher si un site est très lent. Chaque cible est vérifiée dans son propre try/catch : une page en erreur n’empêche jamais la vérification des autres. Et --once exécute un seul tour, pratique pour tester ou pour utiliser le planificateur du système.

Expression cronSignification
*/10 * * * *Toutes les 10 minutes
0 * * * *Toutes les heures, à la minute 0
0 8,12,18 * * *À 8 h, 12 h et 18 h
*/5 9-18 * * 1-5Toutes les 5 minutes, de 9 h à 18 h, du lundi au vendredi

Étape 8 : tester le bot

Lance npm run once plusieurs fois. Pour ce tutoriel, nous avons simulé un site dont le prix baisse, puis qui tombe en panne et revient. Voici les journaux réels :

Journaux du bot Node.js de surveillance : état initial, changement de prix, site hors ligne puis de retour
Cinq tours de test : état initial, rien de nouveau, baisse de prix, panne, retour du site.

Et sur Discord comme sur Telegram, exactement trois messages ont été reçus : le changement de prix, la panne, et le retour. Aucun doublon, aucun message inutile.

Étape 9 : faire tourner le bot 24 h/24

Un bot de surveillance n’est utile que s’il tourne en permanence. Plusieurs options, de la plus simple à la plus robuste :

SolutionPour quiCommande / principe
PM2 sur un VPS ou un Raspberry PiLa solution classiquepm2 start npm --name watcher -- start puis pm2 save et pm2 startup
DockerSi tu héberges déjà des conteneursUne image node:22-alpine, un volume pour data/
Tâche planifiée (cron Linux, Planificateur Windows)Pas de processus permanentLancer npm run once toutes les 10 minutes
GitHub Actions planifiéeGratuit pour un petit usageUn workflow schedule qui lance npm run once (et sauvegarde l’état en cache ou en artefact)
npm install -g pm2
pm2 start npm --name site-watcher -- start
pm2 logs site-watcher        # voir les journaux en direct
pm2 save && pm2 startup      # relancer automatiquement au démarrage du serveur
Avec PM2
🖥️
Surveiller ton propre PC ?

Si tu veux plutôt surveiller ta machine (CPU, disque, mémoire) avec des alertes, jette un œil à notre moniteur système Windows en PHP : sa logique de règles « si… alors… » se combine très bien avec les notificateurs de ce bot.

Aller plus loin

  • Pages en JavaScript : si le contenu est généré côté navigateur (React, Vue), Cheerio ne le voit pas. Utilise alors Playwright ou Puppeteer pour charger la page dans un vrai navigateur.
  • Comparer des nombres : extrais le prix avec une expression régulière et n’alerte que s’il baisse sous un seuil.
  • Commandes interactives : avec la bibliothèque discord.js ou grammY (Telegram), ajoute des commandes comme /ajouter <url> ou /statut.
  • Une base de données plutôt qu’un fichier JSON pour garder l’historique des prix, par exemple avec l’API REST Node.js et MySQL de notre tutoriel précédent.
  • Un tableau de bord web pour gérer la liste des sites sans éditer le JSON.

FAQ : bot Node.js Discord et Telegram

Faut-il un serveur pour faire tourner un bot Node.js ?

Il faut une machine allumée en permanence : un petit VPS, un Raspberry Pi, ou une plateforme qui exécute des tâches planifiées. Un PC personnel convient pour tester, mais le bot s’arrête quand l’ordinateur est éteint.

Quelle est la différence entre un webhook Discord et un bot Discord ?

Un webhook ne peut qu’envoyer des messages dans un salon, via une simple requête HTTP : c’est idéal pour des alertes. Un bot Discord complet se connecte en permanence et peut lire les messages et répondre à des commandes, mais demande plus de configuration.

Le scraping de sites web est-il légal ?

Consulter des pages publiques à un rythme raisonnable est généralement toléré, mais il faut respecter les conditions d’utilisation du site, son fichier robots.txt et ne pas collecter de données personnelles. Utilise une API officielle quand elle existe.

Pourquoi mon bot ne détecte-t-il pas le contenu de la page ?

Le contenu est probablement chargé en JavaScript après le chargement du HTML. Cheerio ne lit que le HTML initial : utilise Playwright ou Puppeteer, ou cherche l’API utilisée par la page dans l’onglet Réseau des outils de développement.

Comment éviter de recevoir trop d’alertes ?

En ne notifiant que les changements d’état, en normalisant le texte pour ignorer les variations sans importance, en ciblant un sélecteur précis plutôt que toute la page, et en utilisant le filtre contains pour ne garder que les changements qui t’intéressent.

Conclusion

En moins de 200 lignes, tu as créé un bot Node.js utile au quotidien : il surveille des pages et des sites, détecte les changements sans fausses alertes, et te prévient sur Discord et Telegram. Surtout, tu as appliqué des réflexes qui servent dans tous les projets d’automatisation : délais maximaux et nouvelles tentatives, état persistant, notifications sur changement d’état uniquement, et erreurs isolées.

Pour aller plus loin, la documentation de Cheerio, des webhooks Discord et de l’API des bots Telegram détaillent toutes les options. À toi d’imaginer ce que ton bot va surveiller !

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *