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.
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.

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.
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 ?

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 zodTerminal| Paquet | Rôle |
|---|---|
cheerio | Lire le HTML et sélectionner des éléments avec des sélecteurs CSS, comme jQuery |
node-cron | Lancer une fonction à intervalle régulier avec la syntaxe cron |
zod | Valider 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Étape 1 : préparer Discord et Telegram
Créer un webhook Discord
- Sur ton serveur Discord, ouvre les paramètres du salon qui recevra les alertes.
- Va dans Intégrations → Webhooks → Nouveau webhook.
- 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
- Dans Telegram, ouvre une conversation avec @BotFather et envoie
/newbot. - Choisis un nom et un identifiant se terminant par « bot ». BotFather te donne un jeton (token) du type
123456:ABC-DEF…. - Envoie un premier message à ton nouveau bot (sinon il n’a pas le droit de t’écrire).
- Récupère ton chat_id en ouvrant
https://api.telegram.org/bot<TON_JETON>/getUpdatesdans 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.envQuiconque 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.jsonOuvre 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.jsLa 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.jsAbortSignal.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.jsDeux 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.
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.jsOn 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.jsAvec 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.jsPromise.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/.

É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.jsimport { 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.jsLes 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
containspermet 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.jsLe 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 cron | Signification |
|---|---|
*/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-5 | Toutes 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 :

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 :
| Solution | Pour qui | Commande / principe |
|---|---|---|
| PM2 sur un VPS ou un Raspberry Pi | La solution classique | pm2 start npm --name watcher -- start puis pm2 save et pm2 startup |
| Docker | Si tu héberges déjà des conteneurs | Une image node:22-alpine, un volume pour data/ |
| Tâche planifiée (cron Linux, Planificateur Windows) | Pas de processus permanent | Lancer npm run once toutes les 10 minutes |
| GitHub Actions planifiée | Gratuit pour un petit usage | Un 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 serveurAvec PM2Si 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 !

