Puppeteer : contrôler un navigateur depuis Node.js
Scraping, tests E2E, screenshots, génération de PDF — Puppeteer pilote Chrome en code. Voici comment en tirer parti.
J'avais besoin d'extraire des données d'un dashboard Angular protégé par login. curl ne renvoyait que du HTML vide. Les requêtes XHR partaient vers une API interne avec des tokens rotatifs. Impossible de simuler ça à la main.
Puppeteer résout exactement ce problème : il pilote un vrai navigateur Chrome, avec JavaScript exécuté, cookies gérés, et interactions utilisateur simulées.
Ce qu'est Puppeteer
Puppeteer est une bibliothèque Node.js maintenue par l'équipe Chrome. Elle communique avec le navigateur via le Chrome DevTools Protocol — le même protocole qu'utilisent les DevTools. Ce n'est pas un proxy qui intercepte du trafic : c'est un vrai Chrome, contrôlé par du code.
Par défaut, Chrome s'ouvre en mode headless (sans fenêtre). En production c'est ce qu'on veut. En développement, passer en mode headful aide beaucoup pour déboguer.
Deux cas d'usage principaux :
- Scraping de SPAs : applications React, Vue, Angular qui chargent leur contenu via JavaScript. Un scraper HTTP classique récupère du HTML vide.
- Tests end-to-end : simuler un vrai utilisateur qui clique, remplit des formulaires, navigue.
Il y a aussi des cas secondaires utiles : générer des PDFs, prendre des screenshots automatiques, tester des performances avec Lighthouse (qui utilise Puppeteer sous le capot).
Installation
npm install puppeteerAu premier npm install, Puppeteer télécharge une version de Chromium dans node_modules. Ça pèse environ 300 Mo. C'est voulu : on est sûr que la version du navigateur est compatible avec la version de Puppeteer.
Si vous voulez utiliser Chrome déjà installé sur la machine ou réduire la taille de l'installation :
npm install puppeteer-coreAvec puppeteer-core, vous devez spécifier le chemin de l'exécutable manuellement.
Premier script
import puppeteer from 'puppeteer'
const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto('https://example.com')
const title = await page.title()
console.log(title) // "Example Domain"
await browser.close()Trois étapes : lancer le navigateur, ouvrir un onglet, naviguer. Toujours fermer le navigateur à la fin — sinon le processus Chrome reste en vie.
Si vous utilisez TypeScript, Puppeteer inclut ses propres types depuis la version 19. Pas besoin d'installer @types/puppeteer.
Extraire des données
Puppeteer expose deux méthodes principales pour lire le DOM :
page.$eval(selector, fn)— sélecteur unique, commequerySelectorpage.$$eval(selector, fn)— liste, commequerySelectorAll
La fonction passée en second argument s'exécute dans le contexte du navigateur, pas dans Node.js. Elle reçoit les éléments DOM et doit retourner une valeur sérialisable.
const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto('https://news.ycombinator.com')
const stories = await page.$$eval('.athing', (items) =>
items.map((item) => ({
title: item.querySelector('.titleline > a')?.textContent ?? '',
href: (item.querySelector('.titleline > a') as HTMLAnchorElement)?.href ?? '',
}))
)
console.log(stories.slice(0, 5))
await browser.close()Un piège fréquent : essayer d'utiliser une variable Node.js à l'intérieur de la fonction passée à $$eval. Ça ne fonctionne pas — la fonction est sérialisée et envoyée au navigateur. Tout ce dont elle a besoin doit être dans ses arguments.
Attendre que le contenu charge
Pour les SPAs, goto ne suffit pas. La page est chargée, mais les données arrivent via des appels API asynchrones.
await page.goto('https://spa-example.com/dashboard')
// Attendre qu'un élément apparaisse dans le DOM
await page.waitForSelector('.data-table')
// Ou attendre qu'un sélecteur corresponde à une condition
await page.waitForFunction(
() => document.querySelectorAll('.row').length > 0
)waitForSelector lève une exception si l'élément n'apparaît pas dans le délai (30 secondes par défaut). Configurable avec { timeout: 5000 }.
Interactions utilisateur
Cliquer, taper, soumettre des formulaires :
await page.goto('https://example.com/login')
await page.type('#email', 'user@example.com', { delay: 50 })
await page.type('#password', 'secret', { delay: 50 })
await page.click('button[type="submit"]')
await page.waitForNavigation()
console.log('Connecté, URL actuelle :', page.url())Le paramètre delay sur type simule une frappe humaine. Certains sites détectent les saisies trop rapides. Ça ne trompe pas tous les systèmes anti-bot, mais ça aide.
Screenshots et PDF
Deux fonctions natives très utiles :
// Screenshot de la page entière
await page.screenshot({
path: 'capture.png',
fullPage: true,
})
// Viewport spécifique
await page.setViewport({ width: 1280, height: 800 })
await page.screenshot({ path: 'desktop.png' })
// Générer un PDF
await page.pdf({
path: 'rapport.pdf',
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' },
})Le PDF ne fonctionne qu'en mode headless. En mode headful, page.pdf() lève une erreur.
Intercepter les requêtes réseau
C'est là où Puppeteer devient vraiment puissant. On peut bloquer certaines ressources pour accélérer le scraping, ou intercepter des réponses API.
await page.setRequestInterception(true)
page.on('request', (req) => {
// Bloquer images et fonts pour aller plus vite
if (['image', 'font'].includes(req.resourceType())) {
req.abort()
} else {
req.continue()
}
})
// Écouter les réponses JSON de l'API interne
page.on('response', async (res) => {
if (res.url().includes('/api/data') && res.status() === 200) {
const json = await res.json()
console.log('Données API :', json)
}
})
await page.goto('https://spa-example.com')Bloquer les images et fonts réduit souvent le temps de chargement de 50 à 70% sur des pages chargées en ressources.
Puppeteer en CI
Les tests E2E avec Puppeteer s'intègrent bien dans GitHub Actions. Un seul ajustement nécessaire : Chrome a besoin de certains flags pour tourner dans un container Linux sans interface graphique.
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run E2E tests
run: npm run test:e2e
env:
PUPPETEER_ARGS: '--no-sandbox --disable-setuid-sandbox'Dans le code :
const browser = await puppeteer.launch({
headless: true,
args: process.env.PUPPETEER_ARGS?.split(' ') ?? [],
})Le flag --no-sandbox est nécessaire dans les environnements CI où Chrome ne peut pas créer de sandbox. Ne jamais l'utiliser en production si vous rendez du contenu non fiable.
Puppeteer dans Docker
Même problème dans Docker : Chrome a besoin de dépendances système absentes par défaut.
FROM node:20-slim
# Dépendances pour Chromium
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
libappindicator3-1 \
libasound2 \
libatk-bridge2.0-0 \
libdrm2 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libxss1 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "index.js"]La variable PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true évite de télécharger Chromium lors du npm install — on utilise celui installé via apt. Le résultat est une image plus prévisible, sans 300 Mo de Chromium dans node_modules.
Pour structurer ce container, les principes de base de Docker s'appliquent directement — layers bien ordonnés, copier package.json avant le code source.
Quelques pièges courants
Les sélecteurs changent. Un scraper qui marche aujourd'hui peut casser demain si le site met à jour ses classes CSS. Préférer les attributs data-* ou les structures sémantiques (h1, nav, main) quand c'est possible.
Les pages avec auth imposent des cookies de session. Plutôt que de faire le login à chaque exécution, exporter les cookies après la première connexion et les réinjecter :
// Sauvegarder
const cookies = await page.cookies()
fs.writeFileSync('cookies.json', JSON.stringify(cookies))
// Restaurer
const saved = JSON.parse(fs.readFileSync('cookies.json', 'utf-8'))
await page.setCookie(...saved)Les sites détectent Puppeteer. Le navigator.webdriver est à true en mode automatisé. Des bibliothèques comme puppeteer-extra-plugin-stealth masquent ces signaux, mais c'est une course aux armements. Vérifier les conditions d'utilisation avant de scraper.
Playwright ou Puppeteer ?
Playwright (de Microsoft) est le concurrent direct. Il supporte Firefox et Safari en plus de Chrome, et son API est légèrement plus ergonomique. Pour des projets nouveaux où le multi-navigateur compte, Playwright est souvent le meilleur choix aujourd'hui. Pour des projets existants sur Puppeteer ou des besoins Chrome-only, Puppeteer reste solide.
Puppeteer ne fait pas de magie : il pilote un vrai navigateur, avec tout ce que ça implique en ressources. Un Chrome lancé consomme entre 80 et 200 Mo de RAM. Pour du scraping à grande échelle, il faut gérer un pool de navigateurs et limiter les instances parallèles. La bibliothèque generic-pool est un bon point de départ pour ça.