En bref : un workflow de téléchargement de fichiers avec Puppeteer peut prendre quatre formes efficaces : cliquer sur un bouton et laisser Chrome enregistrer le fichier dans un dossier que vous contrôlez, exécuter fetch() au sein de la page et renvoyer le flux base64 vers Node, exploiter le protocole Chrome DevTools avec les événements de progression du téléchargement, ou contourner le navigateur et récupérer l'URL avec Axios en utilisant les cookies collectés lors de la session Puppeteer. Choisissez en fonction de la taille du fichier, de l'authentification et de la manière dont le site expose le lien.Introduction
Si vous avez déjà essayé de créer un script de flux de téléchargement de fichiers avec Puppeteer sur un site de production réel, vous connaissez déjà le moment de vérité : le script clique sur le bouton de téléchargement, l'instance Chrome sans interface graphique signale que l'opération a réussi, et le disque reste vide. Cela se produit parce que Chromium bloque par défaut les téléchargements automatisés en mode sans interface graphique, et la solution ne se trouve pas dans l'API de haut niveau de Puppeteer. Elle se trouve un niveau plus bas, dans le protocole Chrome DevTools.
Ce guide s'adresse aux développeurs Node.js de niveau intermédiaire, aux ingénieurs QA et aux praticiens du scraping qui savent déjà lancer un navigateur, naviguer sur une page et sélectionner un élément, et qui ont désormais besoin de capturer les octets réels. Nous allons passer en revue quatre méthodes bien délimitées, chacune accompagnée d'un code complet, et nous vous indiquerons en toute honnêteté laquelle convient à quelle situation.
Vous verrez la même infrastructure de base réutilisée partout : un dossier de téléchargement créé avec fs.mkdirSync, un User-Agent réaliste, une fenêtre d'affichage de bureau et un modèle permettant d'attendre que le fichier soit effectivement sur le disque et ne soit plus en cours d'écriture. À la fin, vous disposerez d'une recette de téléchargement Puppeteer pour les téléchargements déclenchés par un clic, les téléchargements protégés par authentification, les charges utiles binaires volumineuses et les URL connues, ainsi que d'une grille d'évaluation pour choisir entre elles et d'une liste de contrôle de sécurisation pour la production.
Pourquoi le téléchargement de fichiers avec Puppeteer est plus délicat qu'il n'y paraît
Lorsque vous cliquez page.click() sur un bouton « Télécharger le CSV » dans Chrome, le fichier atterrit dans votre dossier Téléchargements et vous passez à autre chose. Exécutez le même script avec headless: 'new' et rien ne se passe. Le clic est déclenché, la requête réseau est envoyée, mais votre système de fichiers reste vide. Ce n’est pas un bug de Puppeteer. Chromium traite intentionnellement les téléchargements automatisés comme suspects, et la solution réside dans le protocole Chrome DevTools plutôt que dans l’API publique de Puppeteer. Tant que vous n’aurez pas activé cette option, aucun flux de téléchargement de fichiers Puppeteer ne laissera le moindre octet sur le disque.
Il n'y a pas de solution unique pour gérer cela. La bonne approche dépend de la manière dont le site expose le fichier, de la rigueur de son authentification, de la taille de la charge utile et du niveau de fiabilité dont vous avez besoin. Quatre modèles couvrent presque tous les cas :
- Clic plus
setDownloadBehavior. Configurez le répertoire de téléchargement du navigateur via CDP, cliquez sur le bouton et vérifiez la fin du téléchargement. Idéal lorsque le téléchargement est déclenché par JavaScript et que vous ne disposez pas de l'URL sous-jacente ou ne souhaitez pas la rechercher. - In-page
fetch()plus base64. Exécutezfetch()à l'intérieurpage.evaluate(), encodez la réponse et renvoyez-la à Node au format base64. Idéal pour les SPA, les URL de blobs et les téléchargements contrôlés par des cookies qui n'existent que dans le contexte du navigateur. - CDP pur avec événements de téléchargement. Ouvrez une session CDP, appelez
Browser.setDownloadBehavioret écoutezBrowser.downloadWillBeginetBrowser.downloadProgress. Idéal lorsque vous avez besoin d'un suivi en temps réel, d'un mappage GUID-nom de fichier ou d'une détection d'erreurs fine. - Transmettez l'URL à Axios ou
https. Utilisez Puppeteer pour afficher la page et extraire l'URL réelle du fichier, puis téléchargez-le depuis Node avec les cookies et les en-têtes que vous avez récupérés lors de la session Puppeteer. Idéal pour les fichiers volumineux, les tâches parallèles et chaque fois que le navigateur est simplement gênant.
Le reste de ce guide est organisé en une section par méthode, plus une grille d'aide à la décision, une liste de contrôle de renforcement de la sécurité et une comparaison entre Puppeteer et Playwright à la fin.
Prérequis et configuration du projet
Avant d'aborder les méthodes individuelles, nous avons besoin d'un projet que les quatre puissent partager. L'infrastructure ici est volontairement basique : un dossier, un package.json, un répertoire de téléchargements et un seul launch.js fichier que nous réutiliserons dans chaque exemple. En conservant un environnement de test cohérent, vous pouvez passer d’une méthode à l’autre sans toucher au reste de votre code, et cela rend les différences entre les méthodes très évidentes lorsque vous les comparez côte à côte.
Les notes de configuration visent Node.js 20 ou une version plus récente au moment de la rédaction ; consultez les notes de version actuelles de Puppeteer si vous utilisez une version antérieure du runtime, car la version minimale de Node.js prise en charge change à chaque version majeure de Puppeteer.
Installation de Puppeteer, notions de base sur Node.js et structure des dossiers
Créez un projet, initialisez npm et installez Puppeteer :
mkdir puppeteer-downloads
cd puppeteer-downloads
npm init -y
npm install puppeteerOuvrez package.json et ajoutez "type": "module" afin de pouvoir utiliser import la syntaxe dans les exemples. Profitez-en pour ajouter quelques outils de développement pratiques :
{
"type": "module",
"scripts": {
"method1": "node method1.js",
"method2": "node method2.js",
"method3": "node method3.js",
"method4": "node method4.js"
}
}Puppeteer est fourni avec Chrome for Testing et le télécharge lors de l'installation sur la plupart des plateformes, ce qui suffit pour tout ce qui est présenté dans ce guide. Si vous utilisez un conteneur allégé, vérifiez le comportement de l'installation dans les notes de version de Puppeteer pour la version que vous avez épinglée, car le comportement de Chrome intégré a évolué d'une version à l'autre.
Structure des dossiers :
puppeteer-downloads/
downloads/ # files end up here
launch.js # shared harness
method1.js
method2.js
method3.js
method4.jsCréez le downloads/ dossier maintenant (mkdir downloads), ou laissez le script de lancement le créer lors de la première exécution.
Un script de lancement de base avec chemin de téléchargement, User-Agent et viewport
Toutes les méthodes de ce guide partent du même harnais. Placez-le dans launch.js:
// launch.js
import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const DOWNLOAD_DIR = path.resolve(__dirname, 'downloads');
export async function launchBrowser({ headless = 'new' } = {}) {
// setDownloadBehavior requires an absolute path. Relative paths silently fail.
if (!fs.existsSync(DOWNLOAD_DIR)) {
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
}
const browser = await puppeteer.launch({
headless,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled',
],
});
return browser;
}
export async function newPage(browser) {
const page = await browser.newPage();
// Realistic desktop fingerprint. Some sites hide download buttons on mobile.
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
);
await page.setViewport({ width: 1366, height: 900 });
return page;
}Trois points à noter. Premièrement, setDownloadBehavior nécessite un chemin absolu ; si vous passez un chemin relatif, Chrome l'ignore silencieusement et n'écrit rien. Deuxièmement, nous forçons un User-Agent et une fenêtre d'affichage de bureau car certains sites cachent les liens de téléchargement derrière une mise en page mobile, et un client automatisé sans User-Agent en reçoit souvent un que Chrome considère comme non fiable. Troisièmement, nous utilisons headless: 'new' plutôt que headless: 'shell'. Le comportement de téléchargement peut varier selon le shell mode, en particulier avec les téléchargements gérés par le navigateur, c'est pourquoi nous nous en tenons à la valeur par défaut.
Vous pouvez basculer headless en false à des fins de débogage. Observer le clic s'effectuer dans Chrome réel est souvent le moyen le plus rapide de diagnostiquer pourquoi un flux de téléchargement Puppeteer échoue silencieusement. Une fois que cela fonctionne en mode « headed » mais pas en mode « headless », vous savez que le problème réside dans la politique de téléchargement plutôt que dans votre sélecteur.
Deux petits ajouts méritent d'être effectués avant de réutiliser ce harnais partout. Premièrement, définissez un délai d'expiration de navigation par défaut : page.setDefaultNavigationTimeout(60_000) sur des caches froids permet d'éviter de nombreuses exécutions CI instables. Deuxièmement, installez un console et pageerror écouteur de base afin que toute erreur sur la page lors du clic de téléchargement apparaisse dans vos logs Node plutôt que d'être ignorée par le navigateur. Ces deux modifications ne nécessitent qu'une seule ligne de code et s'avèrent rentables dès le premier échec de déploiement à 2 heures du matin.
C'est également l'endroit idéal pour renvoyer vers un guide de scraping Puppeteer plus approfondi si vous avez besoin de connaissances plus étendues en matière de navigation, de sélecteurs et de modèles d'attente, que cet article suppose que vous possédez déjà.
Méthode 1 : Cliquez sur le bouton de téléchargement et attendez le fichier
La méthode 1 est ce qui se rapproche le plus de « ce qu'un humain ferait ». Accédez à la page, cliquez sur le bouton de téléchargement et laissez Chrome enregistrer le fichier dans le dossier de votre choix. L'astuce réside dans le fait que Chrome headless n'enregistre rien par défaut ; vous devez lui indiquer explicitement où les téléchargements sont autorisés et où ils doivent être enregistrés à l'aide d'un appel au protocole Chrome DevTools. Une fois cela configuré, le reste du travail consiste à détecter quand le fichier est réellement terminé, car page.click() le retour s'effectue bien avant que les octets n'atteignent le disque.
Cette méthode est la bonne solution lorsque :
- le téléchargement est déclenché par JavaScript, et non par un simple
<a href>, et que vous ne pouvez donc pas extraire facilement l'URL. - Vous n'avez pas besoin d'un suivi en temps réel (juste de savoir « est-ce terminé ? »).
- Le fichier est suffisamment petit pour que la mise en mémoire tampon sur le disque soit suffisante (généralement moins de quelques centaines de Mo).
Elle n'est pas adaptée lorsque :
- Le site nécessite une authentification complexe et des cookies qui n'existent qu'après plusieurs interactions SPA (la méthode 2 est plus simple).
- Vous avez besoin d'événements de progression ou de détection d'interruption (méthode 3).
- Le fichier est énorme et vous souhaitez le diffuser directement vers S3 ou un autre réceptacle (méthode 4).
Ci-dessous, nous définissons le dossier de téléchargement, cliquons sur le bouton et vérifions l'achèvement à l'aide d'un .crdownload sentinelle et une vérification stable de la taille du fichier, de sorte qu’un fichier partiellement écrit ne soit jamais renvoyé comme étant terminé.
Configuration du dossier de téléchargement avec setDownloadBehavior
Il existe deux appels CDP que vous rencontrerez couramment. L'ancien est Page.setDownloadBehavior, dont la portée est limitée à une seule page :
const client = await page.target().createCDPSession();
await client.send('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR, // absolute path
});Cela fonctionne encore dans de nombreuses configurations, mais elle est officiellement obsolète, et les versions récentes de Chrome ont commencé à acheminer les téléchargements via la cible CDP au niveau du navigateur. Lorsque cela se produit, votre Page.setDownloadBehavior appel renvoie un succès et le fichier atterrit toujours dans ~/Downloads (ou nulle part) car la session de la page n'est plus en charge des téléchargements. Si vous avez déjà passé un après-midi à fixer un script « fonctionnel » qui a soudainement cessé d'écrire des fichiers après une mise à jour automatique de Chrome, c'est généralement la raison.
L'appel compatible avec les versions futures est Browser.setDownloadBehavior, dont la portée est limitée au navigateur :
const session = await browser.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: true, // required for Method 3 progress events
});Browser.setDownloadBehavior s'applique à toutes les pages du navigateur, et pas seulement à celle sur laquelle vous avez ouvert la session, ce qui correspond exactement à ce dont vous avez besoin pour un flux de téléchargement multi-onglets. Elle vous permet également de vous abonner aux événements de téléchargement avec eventsEnabled: true, que la méthode 3 utilisera abondamment. L'équipe Chrome DevTools documente ces deux appels, et la référence du protocole Chrome DevTools est la source de vérité lorsque le comportement change d'une version de Chrome à l'autre.
Conseil pratique : privilégiez Browser.setDownloadBehavior pour le nouveau code. Ne gardez Page.setDownloadBehavior uniquement comme solution de secours pour les très anciennes versions de Chrome que vous ne pouvez pas mettre à jour. Et transmettez toujours un chemin absolu ; les chemins relatifs sont non seulement risqués, mais ils échouent sans le signaler.
Déclencher le clic et interroger l'état d'avancement
L'appel de await page.click(selector) renvoie une valeur dès que l'événement de clic se déclenche, ce qui est bien avant le moment où les octets sont transférés. Pour savoir quand le téléchargement est réellement terminé, nous avons besoin d'une fonction d'aide qui surveille le dossier de téléchargement et ignore les fichiers temporaires de Chrome. Chrome écrit dans something.pdf.crdownload pendant le téléchargement, puis renomme le fichier avec son nom définitif une fois les octets validés. Notre aide attend à la fois le renommage et une fenêtre de taille de fichier stable, ce qui permet d'éviter les fichiers partiels sur les connexions lentes et les systèmes de fichiers atypiques.
// waitForRealFile.js
import fs from 'fs/promises';
import path from 'path';
export async function waitForRealFile(dir, knownBefore, {
timeoutMs = 90_000,
stableChecks = 3,
intervalMs = 250,
} = {}) {
const deadline = Date.now() + timeoutMs;
let lastSize = -1;
let stable = 0;
let candidate = null;
while (Date.now() < deadline) {
const entries = await fs.readdir(dir);
const fresh = entries.filter(
(n) => !knownBefore.has(n) && !n.endsWith('.crdownload'),
);
if (fresh.length) {
candidate = path.join(dir, fresh[0]);
const { size } = await fs.stat(candidate);
if (size === lastSize && size > 0) {
if (++stable >= stableChecks) return candidate;
} else {
stable = 0;
lastSize = size;
}
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Download did not finish within ${timeoutMs}ms`);
}Les valeurs par défaut (délai d'expiration de 90 secondes, trois vérifications de taille stable et un intervalle d'interrogation de 250 ms) constituent un point de départ raisonnable pour les fichiers de l'ordre de quelques dizaines de Mo. Augmentez le délai d'expiration pour les téléchargements plus volumineux et réduisez-le pour les points de terminaison rapides où vous préférez échouer rapidement.
Le flux côté appelant se présente comme suit :
const before = new Set(await fs.readdir(DOWNLOAD_DIR));
await page.click('[data-testid="download-button"]');
const finalPath = await waitForRealFile(DOWNLOAD_DIR, before);
console.log('Downloaded:', finalPath);Remarque sur l'intégrité : waitForRealFile est heuristique. Chrome peut, dans de rares cas, renommer un fichier avant qu'il ne soit entièrement transféré, en particulier sur les systèmes de fichiers réseau. Si vous avez besoin de garanties plus solides, combinez cette aide avec l'événement CDP Browser.downloadProgress de la méthode 3, où le state: 'completed' signal est plus fiable (même si, comme nous le verrons, il n'est pas pour autant absolu).
Script complet de la méthode 1 et modes de défaillance courants
Mise en place dans method1.js:
// method1.js
import fs from 'fs/promises';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
import { waitForRealFile } from './waitForRealFile.js';
const TARGET_URL = 'https://example.com/reports';
const DOWNLOAD_SELECTOR = '[data-testid="download-report"]';
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
const session = await browser.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: false,
});
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
const before = new Set(await fs.readdir(DOWNLOAD_DIR));
await page.click(DOWNLOAD_SELECTOR);
const finalPath = await waitForRealFile(DOWNLOAD_DIR, before);
console.log('Saved to:', finalPath);
await browser.close();
})();Quelques points que ce script traite correctement et que la plupart des tutoriels omettent :
- Il utilise
networkidle2afin que le bouton de téléchargement soit présent dans le DOM et lié avant que l'on clique. Si l'on clique trop tôt, on déclenche le clic avant que le JavaScript qui le gère ne se soit chargé. - Il effectue un instantané du répertoire avant de cliquer, afin qu'un fichier résiduel d'une exécution précédente ne soit pas signalé comme le nouveau téléchargement.
- Il ferme explicitement le navigateur ; sinon, le processus Node peut se bloquer sur un Chrome encore ouvert.
Problèmes courants et vérifications à effectuer :
- Rien ne se télécharge. Vérifiez que
Browser.setDownloadBehaviors'est exécuté avant la navigation et quedownloadPathest absolue. Un chemin relatif est la cause la plus courante d'échec silencieux. - Le sélecteur est cliqué mais rien ne se passe. Le « téléchargement » pourrait être une navigation plutôt qu'un téléchargement. Observez la page en mode en-tête ; si l'URL change au lieu de déclencher une boîte de dialogue d'enregistrement, passez à la méthode 2 ou à la méthode 4 pour récupérer les octets directement.
- Le téléchargement se bloque à
.crdownload. Soit le serveur s'est bloqué, soit votre délai d'attente est trop court, soit la page s'est fermée avant la fin du téléchargement. AugmenteztimeoutMset assurez-vous de ne pas appelerbrowser.close()avant quewaitForRealFilene soit résolu. - Le mode headless fonctionne en local mais pas en CI. Les Chrome en conteneur sont parfois livrés sans droits d'écriture sur le chemin de téléchargement, ou avec des politiques de sandbox plus strictes. Créez le dossier à l'avance et ne passez
--no-sandboxuniquement si vous comprenez les implications en matière de sécurité.
Une autre erreur facile à manquer : un script de la méthode 1 qui fonctionne la première fois et échoue lors de la deuxième exécution, car l'exécution précédente a laissé un report.pdf.crdownload dans le dossier et que le nouveau clic est désormais bloqué ou que le fichier a été renommé en report (1).pdf. Nettoyez *.crdownload et tous les fichiers de sortie restants au début de chaque exécution afin que l'instantané du répertoire soit propre avant de cliquer. Le before paramètre waitForRealFile ne vous protège que contre les fichiers qui existaient déjà au moment de l'instantané, et non contre ceux que Chrome a générés pour vous avec un nom de fichier dédupliqué auquel vous ne vous attendiez pas.
Méthode 2 : récupérer le fichier à l'intérieur de la page et le rediriger vers Node.js
La méthode 1 fonctionne tant que Chrome est disposé à gérer le téléchargement pour vous. Certains sites ne sont pas aussi coopératifs. Ils génèrent l'URL du fichier en JavaScript, la protègent derrière des cookies qui n'existent qu'après une connexion SPA en plusieurs étapes, ou vous fournissent une blob: URL que Chrome a lui-même créée et qu'aucun client HTTP externe ne peut résoudre. Dans tous ces cas, le seul endroit capable de récupérer le fichier est la page elle-même, car celle-ci dispose déjà de la session appropriée.
La méthode 2 s'exécute fetch() à l'intérieur page.evaluate(), lit le corps de la réponse dans le navigateur et renvoie les octets à Node via la couche de sérialisation de Puppeteer. Étant donné que page.evaluate() ne peut renvoyer que des valeurs sérialisables en JSON, les données binaires doivent être encodées, et la solution universelle est le codage base64. Node le décode, écrit un tampon sur le disque, et vous obtenez votre fichier.
Cette méthode est particulièrement efficace pour :
- les SPA authentifiées où les cookies et les en-têtes sont plus faciles à « emprunter » au sein de la page qu’à collecter et à rejouer.
- Les fichiers servis via des URL blob, des URL d'objet ou générés en mémoire (les rapports PDF créés en JavaScript en sont un exemple classique).
- Les points de terminaison compatibles CORS où la page elle-même est autorisée à télécharger le fichier.
Elle présente des difficultés pour :
- Les fichiers très volumineux, car le codage base64 augmente la taille de la charge utile d'environ 33 % et son traitement via V8 est gourmand en CPU et en mémoire.
- Les points de terminaison non CORS que la page n'est pas autorisée à récupérer (les règles du navigateur s'appliquent toujours).
Ci-dessous, nous abordons d'abord le cas des fichiers de petite à moyenne taille, puis une variante par morceaux qui gère les fichiers de plusieurs centaines de Mo sans faire planter votre processus Node.
Utilisation de page.evaluate avec fetch pour lire la réponse sous forme de Blob
À l'intérieur de page.evaluate(), fetch() se comporte exactement comme une requête fetch normale d'un navigateur. Elle inclut les cookies pour les requêtes de même origine, suit les redirections et respecte le CORS. C'est ce qui la rend si puissante ici : si la page peut voir le fichier, votre script le peut aussi.
const base64 = await page.evaluate(async (fileUrl) => {
const res = await fetch(fileUrl, { credentials: 'include' });
if (!res.ok) {
throw new Error(`Fetch failed: ${res.status} ${res.statusText}`);
}
const buf = await res.arrayBuffer();
// Convert ArrayBuffer to base64 inside the browser.
let binary = '';
const bytes = new Uint8Array(buf);
const chunkSize = 0x8000; // 32 KB stride to avoid stack issues
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunkSize),
);
}
return btoa(binary);
}, fileUrl);Deux détails d'implémentation méritent d'être compris. Premièrement, String.fromCharCode.apply(null, bigArray) sature la pile d'appels si vous transmettez des dizaines de mégaoctets d'un coup, c'est pourquoi nous parcourons le tampon par tranches de 32 Ko avant d'appeler btoa. Deuxièmement, credentials: 'include' c'est ce qui fait de cette méthode un modèle de « téléchargement par requête Puppeteer » ; sans cela, vous perdez les cookies de session et la requête n'est plus authentifiée.
Vous pouvez adapter ce même modèle à un cas d'utilisation de téléchargement de PDF avec Puppeteer où l'URL est construite dynamiquement dans l'application SPA : extrayez l'URL de l'attribut data- ou d'un callback JS, transmettez-la à page.evaluate(), et laissez la page effectuer la récupération. Les octets renvoyés ne sont que des octets ; le format source n'a pas d'importance pour Node.
Si fetch() échoue avec une erreur CORS, cela signifie que le navigateur vous indique que la page n’est pas autorisée à lire le corps de la réponse. Vous avez deux options : passer à la méthode 1 et laisser Chrome gérer le téléchargement (CORS ne s’applique pas aux navigations ou aux téléchargements), ou passer à la méthode 4 et relancer la requête depuis Node, où la politique de même origine ne s’applique pas.
Renvoyer le base64 à Node et écrire le tampon sur le disque
Une fois le base64 de retour dans Node, le reste est simple. Buffer.from(base64, 'base64') le décode, fs.writeFile l'enregistre sur le disque, et Buffer.byteLength vous permet de vérifier la taille par rapport à celle Content-Length que vous avez récupérée précédemment :
import fs from 'fs/promises';
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
const TARGET_URL = 'https://example.com/report-page';
const FILE_URL_SELECTOR = 'a#download-link';
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
const fileUrl = await page.$eval(FILE_URL_SELECTOR, (a) => a.href);
const base64 = await page.evaluate(async (url) => {
const res = await fetch(url, { credentials: 'include' });
const buf = await res.arrayBuffer();
let binary = '';
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + 0x8000),
);
}
return btoa(binary);
}, fileUrl);
const buffer = Buffer.from(base64, 'base64');
console.log('Bytes from page.evaluate:', buffer.byteLength);
const outPath = path.join(DOWNLOAD_DIR, 'report.pdf');
await fs.writeFile(outPath, buffer);
console.log('Saved to:', outPath);
await browser.close();
})();Lors d'un test réel sur un petit PDF, ce script affiche quelque chose comme Bytes from page.evaluate: 3672808 puis écrit le fichier en une seule fs.writeFile. Le nombre d'octets est un indicateur utile : si vous vous attendiez à 5 Mo et que vous obtenez 80 Ko, vous avez très certainement reçu une page d'erreur HTML à la place d'un PDF, et vous devriez inspecter les premiers octets du tampon pour vous en assurer avant d'enregistrer.
Ce modèle fonctionne bien jusqu'à environ 50 Mo. Au-delà, la chaîne base64 elle-même commence à monopoliser le tas de Node (chaque caractère occupe deux octets dans V8), et vous commencerez à constater JavaScript heap out of memory des échecs. C'est ce que la sous-section suivante permet de résoudre.
Diffusion de fichiers volumineux avec du Base64 fragmenté
Pour les fichiers de plusieurs centaines de Mo, renvoyer une seule chaîne Base64 à partir de page.evaluate() est la recette idéale pour un crash par manque de mémoire. La solution consiste à lire la réponse sous forme de flux dans le navigateur, à la découper en morceaux d'environ 1 Mo, à encoder chaque morceau en base64, puis à les renvoyer à Node un par un. Du côté de Node, vous décodez chaque morceau dans un tampon (Buffer) et l'ajoutez à un flux d'écriture, de sorte que le fichier entier ne soit jamais conservé en RAM.
Ce modèle utilise expose function pour permettre au navigateur de rappeler Node, ainsi que ReadableStream.getReader() pour parcourir le corps de la réponse bloc par bloc :
import fs from 'fs';
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
const FILE_URL = 'https://example.com/big-archive.zip';
const OUT_PATH = path.join(DOWNLOAD_DIR, 'big-archive.zip');
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
const out = fs.createWriteStream(OUT_PATH);
let written = 0;
await page.exposeFunction('onChunk', async (b64) => {
const buf = Buffer.from(b64, 'base64');
written += buf.byteLength;
if (!out.write(buf)) {
// Apply backpressure if the write stream is saturated.
await new Promise((r) => out.once('drain', r));
}
});
await page.exposeFunction('onDone', () => {
out.end();
console.log('Total bytes:', written);
});
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.evaluate(async (url) => {
const res = await fetch(url, { credentials: 'include' });
const reader = res.body.getReader();
const CHUNK = 1 << 20; // 1 MB target
let pending = new Uint8Array(0);
const flush = (bytes) => {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + 0x8000),
);
}
return window.onChunk(btoa(binary));
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
const merged = new Uint8Array(pending.length + value.length);
merged.set(pending, 0);
merged.set(value, pending.length);
pending = merged;
while (pending.length >= CHUNK) {
await flush(pending.subarray(0, CHUNK));
pending = pending.subarray(CHUNK);
}
}
if (pending.length) await flush(pending);
await window.onDone();
}, FILE_URL);
await browser.close();
})();Quelques points à retenir. page.exposeFunction ajoute une variable globale sur la page qui, lorsqu'elle est appelée, attend un gestionnaire côté Node. Nous l'utilisons pour pousser les morceaux en base64 directement dans un flux d'écriture, de sorte que les octets ne s'accumulent jamais dans la mémoire V8. Nous respectons également la contre-pression : si out.write() renvoie false, nous attendons 'drain' avant de continuer. Sans cela, un réseau rapide et un disque lent finiraient de toute façon par mettre en mémoire tampon l'intégralité du fichier dans Node, ce qui irait à l'encontre du but recherché.
La taille de bloc de 1 Mo est un compromis. Des blocs plus petits impliquent davantage d'allers-retours entre la page et Node et une surcharge Base64 plus importante par appel. Des blocs plus grands réduisent la surcharge mais mobilisent davantage de mémoire dans le navigateur. Un Mo est un point de départ raisonnable ; ajustez-le en fonction de votre charge de travail.
Quand la récupération en page est la bonne solution (authentification, SPA, URL de blobs)
La méthode 2 est la bonne solution lorsque le fichier n'« existe » qu'au sein de la session du navigateur et que la méthode 1 ne peut y accéder pour l'une des trois raisons suivantes.
La première est l'authentification par cookie ou jeton, qui est hostile à la relecture. Certains sites lient la session à des empreintes digitales (User-Agent plus IP plus un jeton CSRF dans un stockage non cookie), et reproduire cela en dehors du navigateur est délicat. La récupération dans la page contourne entièrement ce problème, car la requête provient de la page qui détient la session.
La deuxième raison concerne les téléchargements générés par les SPA. Un clic sur un bouton exécute du code JavaScript qui crée un Blob, le transmet à URL.createObjectURL, et déclenche un téléchargement via un <a download> . L'URL ressemble à blob:https://app.example.com/abc-123 et seule la page d'origine peut la résoudre. La méthode 1 pourrait capturer le téléchargement résultant si setDownloadBehavior est en place, mais la méthode 2 est plus déterministe : recréez vous-même la même requête, encodez le résultat et contournez complètement le flux de téléchargement de Chrome.
La troisième méthode concerne les points de terminaison d'exportation dynamiques. Les API qui acceptent une charge utile JSON, génèrent un fichier CSV ou PDF à la volée et le renvoient en ligne sont faciles à automatiser avec page.evaluate() car vous pouvez JSON.stringify la charge utile, envoyer une requête POST et lire la réponse sous forme de flux.
Quand la récupération en page ne convient pas : fichiers très volumineux (abordés ci-dessus), fichiers protégés par CORS que la page n'est pas autorisée à lire, et tout cas où une simple requête Axios depuis Node fonctionnerait. Utilisez l'outil le plus simple qui permet d'obtenir les octets.
Méthode 3 : Piloter les téléchargements avec le protocole Chrome DevTools
La méthode 1 utilise le CDP en arrière-plan, mais le traite comme une étape de configuration. La méthode 3 met le CDP au premier plan. Lorsque vous avez besoin d'un suivi en temps réel, lorsque vous effectuez des téléchargements en parallèle et devez associer chacun d'entre eux au clic qui l'a déclenché, ou lorsque vous souhaitez détecter les interruptions rapidement, vous avez besoin des événements CDP au niveau du navigateur : Browser.downloadWillBegin et Browser.downloadProgress. Ils vous fournissent un GUID par téléchargement, le nom de fichier suggéré, le nombre total d'octets s'il est connu, les octets reçus jusqu'à présent, ainsi qu'un état de inProgress, completed, et canceled.
Il s'agit du même protocole que celui utilisé par le panneau DevTools de Chrome, et il est plus proche d'une « véritable » API de téléchargement que tout ce que Puppeteer expose en natif. Le hic, c'est qu'il se trouve un niveau en dessous de page.click(), vous devez donc le configurer explicitement et écouter les événements sur la session CDP plutôt que d'attendre une promesse Puppeteer.
Quand choisir la méthode 3 :
- Vous devez afficher la progression à un utilisateur ou l'envoyer vers une file d'attente de tâches.
- Vous exécutez des tâches de téléchargement de fichiers Puppeteer en parallèle et devez associer les noms de fichiers au contexte.
- Vous souhaitez un signal clair indiquant « ce téléchargement a été annulé » plutôt que de devoir deviner à partir du système de fichiers.
- Vous souhaitez un scénario de téléchargement Puppeteer sans interface utilisateur fiable qui ne dépende pas de l'héritage
Page.setDownloadBehavior.
Quand ne pas l'utiliser :
- Vous n'avez besoin que d'un seul fichier à la fois et la méthode 1 suffit.
- Vous pouvez récupérer l'URL et utiliser Axios ; dans ce cas, la complexité de l'infrastructure CDP en vaut rarement la peine.
Ouverture d'une session CDP avec page.createCDPSession
Dans Puppeteer, vous avez le choix entre deux types de sessions CDP : celles au niveau de la page et celles au niveau du navigateur. Pour la méthode 3, nous voulons la session au niveau du navigateur, car les événements de téléchargement sont émis au niveau du navigateur et Browser.setDownloadBehavior c'est une méthode au niveau du navigateur.
const session = await browser.target().createCDPSession();Comparez cela avec await page.createCDPSession(), qui est au niveau de la page. Les sessions de page fonctionnent toujours pour la navigation, le réseau et les appels d'exécution limités à une seule page, mais elles ne verront pas les téléchargements au niveau du navigateur si Chrome les achemine via la cible du navigateur (ce qui est la tendance dans les versions récentes).
Un modèle mental utile : une session CDP est un WebSocket typé vers une cible. browser.target() est la cible du navigateur, page.target() est une cible de page, et chacune reçoit des événements différents. Les confondre est une source fréquente de bugs du type « mon écouteur ne se déclenche jamais » dans la méthode 3. Si votre Browser.downloadProgress écouteur est silencieux, vérifiez bien que vous avez ouvert la session sur browser.target(), et non sur la page.
Vous pouvez avoir plusieurs sessions CDP ouvertes en même temps, y compris une par page plus une au niveau du navigateur. Pour les opérations de téléchargement, une seule session au niveau du navigateur suffit.
Browser.setDownloadBehavior et écoute des événements downloadWillBegin / downloadProgress
Une fois la session du navigateur en main, configurez le comportement de téléchargement et abonnez-vous aux événements :
const downloads = new Map(); // guid -> { filename, totalBytes, received, state }
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: true, // turn on downloadWillBegin / downloadProgress
});
session.on('Browser.downloadWillBegin', (event) => {
// event: { guid, url, suggestedFilename, frameId }
downloads.set(event.guid, {
filename: event.suggestedFilename,
received: 0,
totalBytes: 0,
state: 'inProgress',
});
console.log(`Starting download: ${event.suggestedFilename}`);
});
session.on('Browser.downloadProgress', (event) => {
// event: { guid, totalBytes, receivedBytes, state }
const entry = downloads.get(event.guid);
if (!entry) return;
entry.totalBytes = event.totalBytes;
entry.received = event.receivedBytes;
entry.state = event.state;
if (event.totalBytes > 0) {
const pct = ((event.receivedBytes / event.totalBytes) * 100).toFixed(1);
process.stdout.write(` ${entry.filename}: ${pct}%\r`);
}
if (event.state === 'completed') {
console.log(`\nFinished: ${entry.filename}`);
} else if (event.state === 'canceled') {
console.warn(`\nCanceled: ${entry.filename}`);
}
});Quelques modèles à retenir :
- Le
guidchamp est votre clé pour suivre les téléchargements parallèles. Chrome attribue un GUID unique à chaque téléchargement, et lesuggestedFilenameest le nom que le fichier portera sur le disque (sous réserve de collisions, auquel cas Chrome ajoute(1),(2), etc.). totalBytespeut être0si le serveur n'envoie pas deContent-Length. Dans ce cas, vous ne pouvez pas afficher de pourcentage, mais uniquement un compteur d'octets en cours. Adaptez votre interface utilisateur en conséquence.state: 'completed'est un signe fort indiquant que le téléchargement est terminé, mais ce n'est pas une garantie absolue que le fichier a été entièrement transféré sur le disque. Chrome peut signaler la fin du téléchargement légèrement avant le renommage ou le transfert final, il est donc toujours judicieux de vérifier brièvement la taille stable en plus de l'événement.state: 'canceled'inclut les téléchargements annulés par l'utilisateur (rare en mode sans interface graphique) et les téléchargements interrompus (panne réseau, déconnexion du serveur). Traitez les deux de la même manière : réessayez ou signalez clairement l'échec.
Si vous ne définissez pas eventsEnabled: true, vous obtenez le téléchargement mais aucun événement, ce qui vous ramène au mode de sondage de la méthode 1. Optez toujours pour la méthode 3.
Pour une vérification plus stricte du type « le fichier est bien sur le disque », combinez l' 'completed' événement avec un petit waitForFileStable , similaire à celui de la méthode 1 mais plus strict (délai d'attente de 30 secondes, trois vérifications de stabilité) :
async function waitForFileStable(filePath, {
timeoutMs = 30_000,
stableChecks = 3,
intervalMs = 200,
} = {}) {
const deadline = Date.now() + timeoutMs;
let last = -1, stable = 0;
while (Date.now() < deadline) {
try {
const { size } = await fs.stat(filePath);
if (size === last && size > 0) {
if (++stable >= stableChecks) return size;
} else {
stable = 0; last = size;
}
} catch {}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`File never stabilized: ${filePath}`);
}Vous disposez désormais des deux signaux : le CDP indique « terminé » et le système de fichiers le confirme.
Script complet de la méthode 3 avec journalisation de la progression
// method3.js
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
import { waitForFileStable } from './waitForFileStable.js';
const TARGET_URL = 'https://example.com/reports';
const SELECTOR = '[data-testid="download-report"]';
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
const session = await browser.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: true,
});
let resolveDone, rejectDone;
const done = new Promise((r, j) => { resolveDone = r; rejectDone = j; });
let lastFilename = null;
session.on('Browser.downloadWillBegin', (e) => {
lastFilename = e.suggestedFilename;
console.log('Begin:', e.guid, '->', e.suggestedFilename);
});
session.on('Browser.downloadProgress', async (e) => {
if (e.state === 'completed') {
const finalPath = path.join(DOWNLOAD_DIR, lastFilename);
try {
await waitForFileStable(finalPath);
resolveDone(finalPath);
} catch (err) { rejectDone(err); }
} else if (e.state === 'canceled') {
rejectDone(new Error('Download canceled'));
}
});
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
await page.click(SELECTOR);
const finalPath = await done;
console.log('Saved to:', finalPath);
await browser.close();
})();Ce que ce script vous apporte de plus par rapport à la méthode 1 : une fin déterministe (vous savez exactement quand le téléchargement commence et se termine grâce aux événements, et non par approximation), une progression en temps réel (le downloadProgress gestionnaire se déclenche toutes les quelques centaines de Ko) et une gestion explicite de l'annulation. Il s'étend également proprement à N téléchargements parallèles : conservez un Map<guid, Promise>, résolvez chaque promesse à l'intérieur du gestionnaire, et Promise.all le tout.
En production, vous souhaitez généralement encapsuler done dans un délai d'expiration afin qu'un téléchargement bloqué ne bloque pas votre worker indéfiniment. Une limite supérieure de 5 à 10 minutes est raisonnable pour des fichiers classiques. Si vous la dépassez, consignez le GUID, fermez la page et réessayez. Le CDP vous offre la visibilité nécessaire pour prendre cette décision ; le système de fichiers seul ne le permet pas.
Un deuxième modèle à connaître pour la méthode 3 : les promesses par téléchargement. Au lieu d’une seule done promesse, conservez une Map<guid, { resolve, reject }> et créez une entrée à l'intérieur Browser.downloadWillBegin. Le Browser.downloadProgress gestionnaire appelle alors resolve ou reject sur l'entrée qui correspond à l' guid. Une fois cela en place, vous pouvez déclencher N clics à la suite, collecter N promesses et Promise.all les exécuter. Le même code de gestionnaire fonctionne aussi bien pour un fichier que pour cinquante, et vous obtenez un rapport d'erreurs clair par fichier au lieu d'un seul délai d'attente global qui masque quel téléchargement a réellement échoué.
Méthode 4 : Évitez le navigateur, transmettez l'URL à Axios ou https
Parfois, la meilleure stratégie de téléchargement de fichiers avec Puppeteer consiste à ne presque pas utiliser Puppeteer. Si le site expose une URL réelle et stable pour le fichier (même si vous devez afficher la page et cliquer un peu pour la découvrir), vous pouvez l'afficher avec Puppeteer juste le temps d'extraire cette URL ainsi que l'état d'authentification, puis télécharger avec axios ou la fonction intégrée de Node https. Le résultat est plus rapide que la méthode 1, moins gourmand en mémoire que la méthode 2, et facilement parallélisable d'une manière que l'exécution de N instances de Chrome ne permet pas.
C'est également la méthode la plus « ennuyeuse », dans le bon sens du terme. Une fois l'URL en main, le téléchargement n'est qu'une requête HTTP GET. Il n'y a pas de régression en mode headless à suivre, pas de dérive de version CDP, pas de .crdownload sentinelle à interroger. Vous transmettez l'URL et quelques en-têtes à Axios, redirigez la réponse vers un flux d'écriture, et le fichier se retrouve sur le disque.
Choisissez la méthode 4 lorsque :
- Le fichier cible se trouve à une URL stable que vous pouvez extraire du DOM, d'une réponse réseau ou d'une variable JS.
- Le fichier est volumineux et vous souhaitez un véritable streaming vers le disque sans mise en mémoire tampon via V8.
- Vous devez exécuter de nombreux téléchargements simultanément. Un pool de requêtes Axios est bien moins coûteux qu’un pool de Chrome en mode headless.
Ignorez la méthode 4 lorsque :
- L'URL de téléchargement est à usage unique, signée ou liée à un jeton de la session du navigateur d'une manière qui ne permet pas de la réutiliser.
- Le site impose des défis JavaScript ou des vérifications d'empreinte digitale qu'Axios ne peut pas passer sans un travail considérable.
Dans le deuxième cas, vous remplacez généralement Axios par une couche de requêtes qui gère ces vérifications, mais la structure du script reste inchangée.
Extraire les cookies et les en-têtes de Puppeteer pour authentifier la requête
L'intérêt d'un flux hybride réside dans l'héritage de la session Puppeteer. Vous effectuez la connexion SPA ou toute autre procédure requise par le site, puis vous transférez les cookies et quelques en-têtes clés vers Axios.
async function buildAxiosHeaders(page) {
const cookies = await page.cookies(); // current page's cookies
const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
const userAgent = await page.evaluate(() => navigator.userAgent);
const referer = page.url();
return {
Cookie: cookieHeader,
'User-Agent': userAgent,
Referer: referer,
Accept: '*/*',
'Accept-Language': 'en-US,en;q=0.9',
};
}Les quatre en-têtes ci-dessus couvrent la grande majorité des vérifications CDN et WAF. Cookie transporte la session, User-Agent correspond à ce que la page a déjà prouvé, Referer correspond à ce que le navigateur enverrait en cliquant sur le lien de téléchargement, et Accept-Language est un petit indice indiquant qu'un vrai navigateur vient de passer par là. Si le site vérifie Sec-Ch-Ua ou d'autres indices du client, copiez-les également avec page.evaluate(() => navigator.userAgentData).
Deux points à noter. Premièrement, page.cookies() renvoie par défaut les cookies de l'URL actuelle. Si le fichier est hébergé sur un sous-domaine différent, transmettez explicitement cette URL : page.cookies(fileUrl). Sinon, les cookies que vous transmettez ne seront pas envoyés. Deuxièmement, certains sites définissent HttpOnly ou Secure des indicateurs qu’Axios prend bien en compte, mais les cookies liés au chemin d’accès (Path=/api) sont ignorés à moins que vous ne les conserviez lors de la construction de l'en-tête. La solution la plus simple consiste à récupérer les cookies de l'origine exacte que vous allez consulter et à n'inclure que les cookies dont path est un préfixe du chemin d'accès de l'URL du fichier.
Si vous souhaitez éviter de le faire manuellement, il existe des adaptateurs axios-cookiejar éprouvés qui récupèrent les cookies de Puppeteer et laissent Axios les gérer pour chaque requête. Dans le cas le plus courant, une ligne de code Cookie . Pour en savoir plus sur le renforcement de la sécurité des appels Axios contre la détection, un guide interne sur les en-têtes Axios complète naturellement cette section.
Diffusion de la réponse avec axios responseType: stream
Le téléchargement en lui-même est simple lorsque vous utilisez responseType: 'stream'. Axios renvoie le corps de la réponse sous forme de flux Node, et vous le redirigez vers un flux d'écriture. Le fichier entier n'est jamais conservé en mémoire vive :
import axios from 'axios';
import fs from 'fs';
import { pipeline } from 'stream/promises';
async function downloadToFile(url, outPath, headers) {
const res = await axios.get(url, {
headers,
responseType: 'stream',
timeout: 30_000,
maxRedirects: 5,
validateStatus: (s) => s >= 200 && s < 400,
});
await pipeline(res.data, fs.createWriteStream(outPath));
}stream.pipeline (ou sa version promise, utilisée ici) est la primitive appropriée car elle propage les erreurs des deux côtés et nettoie correctement les flux en cas d'échec. Un res.data.pipe(write) ignore les erreurs du flux d'écriture, ce qui vous laisse avec un fichier à moitié écrit et aucune exception.
Quelques réglages de niveau production :
- Délais d'expiration.
timeout: 30_000est un délai d'expiration pour l'établissement de la requête. Pour les téléchargements longs, enveloppez également le pipeline dans un watchdog afin qu'un flux lent ne bloque pas indéfiniment. - Nouvelles tentatives. Enveloppez l'appel dans une petite fonction d'aide à la nouvelle tentative avec un recul exponentiel, plafonné à trois tentatives. La plupart des échecs transitoires (504, ECONNRESET) sont résolus par une nouvelle tentative.
- Évitez les écritures simultanées sur le même chemin. Deux tâches parallèles écrasant
report.pdfconstituent un bug de corruption silencieux. Utilisez un nom de fichier temporaire suivi d'un renommage, ou utilisez des noms de fichiers uniques par tâche.
Pour le parallélisme, un petit pool est la valeur par défaut la plus sûre. Trois à cinq téléchargements Axios simultanés constituent une limite raisonnable, et une boucle séquentielle for...of await est la base la plus sûre si vous n'êtes pas certain des limites de débit côté serveur. Au-delà de cinq tâches simultanées, vous devriez mesurer plutôt que deviner.
Téléchargements d'URL purs sans Puppeteer dans la boucle
Une fois que vous avez déterminé le modèle d'URL, vous pouvez souvent vous passer complètement de Puppeteer. Une exécution hybride typique utilise Puppeteer pour extraire une grille de résultats de recherche, extraire une URL de page de détail par résultat, puis soit visiter chaque page de détail pour récupérer l'URL du fichier, soit, si le modèle d'URL est prévisible, la dériver directement de la liste.
Un flux de bout en bout représentatif qui télécharge cinq fichiers image se présente comme suit :
import axios from 'axios';
import fs from 'fs';
import path from 'path';
async function downloadAll(items, headers, outDir) {
for (let i = 0; i < items.length; i++) {
const url = items[i].downloadUrl;
const out = path.join(outDir, `image-${String(i + 1).padStart(3, '0')}.jpg`);
await downloadToFile(url, out, headers);
console.log('Saved', out);
}
}Exécutez cela sur une liste de cinq URL extraites et vous obtenez image-001.jpg sur image-005.jpg sur le disque, sans qu'aucun processus Chrome ne soit associé au transfert proprement dit. Si les URL sont publiques et non signées, vous pouvez ignorer complètement Puppeteer lors des exécutions suivantes et simplement accéder directement aux URL. C'est souvent la bonne approche pour les mises à jour quotidiennes d'un ensemble de données connu ; vous ne payez le coût de Puppeteer que la première fois, le temps de découvrir la structure des URL.
La leçon principale : considérez Puppeteer comme un outil de découverte et d'authentification, et non comme un outil de téléchargement. Le rôle du navigateur est de déterminer où se trouvent les octets et de valider la session appropriée ; le téléchargement lui-même peut presque toujours être effectué par un client plus petit et plus rapide.
Deux modèles opérationnels viennent compléter cela. Premièrement, mettez en cache le modèle d'URL découvert dans un petit fichier JSON ou une base de données indexée par site, et ne relancez l'étape de découverte de Puppeteer que lorsqu'une requête Axios commence à renvoyer un 404 ou du code HTML inattendu. Les URL des fichiers de la plupart des sites suivent un modèle stable (/exports/{id}/{filename}.csv), et une fois que vous disposez de ce modèle, les mises à jour quotidiennes ne nécessitent plus du tout de navigateur. Deuxièmement, lorsque l'URL est signée mais que la logique de signature est reproductible (HMAC sur la charge utile d'une requête, par exemple), procédez à une ingénierie inverse de la signature une seule fois et ignorez définitivement Puppeteer pour cette cible. L'approche de téléchargement de fichiers via Puppeteer fait ses preuves lors du premier contact ; tout ce qui suit se fait en HTTP simple.
Choisir la bonne méthode de téléchargement de fichiers avec Puppeteer : une grille d'aide à la décision
Quatre méthodes, c'est plus que ce que la SERP affiche habituellement, et c'est justement le but : chacune a sa niche. Voici une grille d'aide à la décision qui met en correspondance quelques questions oui/non avec la bonne méthode, ainsi qu'un tableau comparatif que vous pouvez garder ouvert pendant que vous lisez ce guide.
Commencez par les questions :
- Disposez-vous d'une URL de fichier stable et réutilisable ? Si oui, passez à la question 2. Si non (l'URL est à usage unique, générée par JS ou valable uniquement pendant la session de la page), vous vous situez dans le domaine de la méthode 1 ou de la méthode 2.
- Le fichier est-il protégé par une authentification qui survit en dehors du navigateur ? Si vous pouvez vider les cookies et rejouer la requête, la méthode 4 est presque toujours le bon choix. Si l'authentification est liée au navigateur (jetons CSRF stockés en mémoire JS, empreinte de session), utilisez la méthode 2.
- Le fichier est-il très volumineux (plus de ~100 Mo) ou en exécutez-vous plusieurs en parallèle ? La méthode 4 l'emporte. Le streaming Axios est moins coûteux que l'exécution de N Chrome, et les allers-retours base64 de la méthode 2 ne sont pas évolutifs.
- Avez-vous besoin d'événements de progression ou d'un signal d'annulation clair ? La méthode 3 est la seule qui vous offre les deux directement depuis Chrome.
- Le téléchargement est-il déclenché par un clic dont vous ne pouvez pas facilement inspecter l'URL ? La méthode 1 est la solution la plus simple et suffit généralement.
|
Méthode |
Idéale pour |
À éviter pour |
Profil de mémoire |
Modèle d'authentification |
|---|---|---|---|---|
|
Téléchargements déclenchés par JS, URL inconnues |
Fichiers très volumineux, interface de progression |
Faible (Chrome transfère vers le disque) |
Tout ce que le clic voit |
|
SPAs, URL de blobs, authentification liée au navigateur |
Fichiers de plusieurs centaines de Mo |
Élevé sans fragmentation |
Cookies de navigateur, automatique |
|
Tâches parallèles, progression, annulation |
Petits fichiers ponctuels |
Faible (Chrome transfère vers le disque) |
Tout ce que le clic voit |
|
Fichiers volumineux, pipelines parallèles, URL connues |
URL signées à usage unique |
Faible (véritable streaming) |
Cookies et en-têtes réutilisés |
Règle générale : privilégiez la méthode qui utilise le moins Puppeteer tout en restant fonctionnelle. La méthode 4 est la méthode par défaut si l'URL est connue. La méthode 1 est la méthode par défaut si ce n'est pas le cas. La méthode 3 est ce qu'aurait dû être la méthode 1 lorsque vous avez besoin de parallélisme ou d'une indication de progression. La méthode 2 est la solution de secours pour tous les autres cas.
En cas de doute, testez d'abord la méthode 4. Si elle fonctionne, vous serez content de ne pas avoir lancé un Chrome pour chaque fichier. Si ce n'est pas le cas, vous saurez en quelques minutes si le problème vient de l'authentification (méthode 2) ou de l'URL (méthode 1).
Renforcement de la production : délais d'attente, tentatives de réessai et contrôles d'intégrité
Un script Puppeteer de téléchargement de fichiers qui fonctionne sur votre ordinateur portable mais qui plante en production le fait presque toujours pour l’une des quatre raisons suivantes : un délai d’expiration que vous avez oublié de définir, une nouvelle tentative que vous avez oublié d’écrire, un .crdownload sentinelle que vous avez oublié de nettoyer, ou un fichier partiel que vous avez traité comme complet. Voici la liste de contrôle que nous appliquons aux scripts avant leur mise en production.
Délais d'expiration à chaque couche. Définissez timeout sur page.goto (la valeur par défaut est 30 s, souvent trop courte sur les caches froids), un délai d'expiration explicite dans votre waitForRealFile helper, un Axios timeout pour la méthode 4, et une limite de temps réel pour l'ensemble de la tâche. Les blocages en CI sont généralement dus à l'absence de l'un de ces éléments, et non à la présence d'un véritable bug.
Nouvelles tentatives avec délai d'attente. Enveloppez l'appel réseau dans une fonction d'aide de nouvelle tentative, avec un délai d'attente exponentiel plafonné à trois tentatives, suivi d'un échec définitif. Réessayez en cas de ECONNRESET, ETIMEDOUT, aux réponses 5xx et à tout ce qui semble transitoire. Ne réessayez pas en cas de 401, 403 ou 404, car ces codes signalent des bogues dans votre code.
Nettoyez les .crdownload fichiers entre les exécutions. Chrome les laisse traîner lorsqu’un téléchargement est annulé ou que le processus se termine prématurément. Si vous réexécutez le script, votre waitForRealFile peut récupérer l'indicateur obsolète et signaler un fichier erroné comme nouveau. Nettoyez .crdownload, .tmpet vos propres fichiers de travail au début de chaque exécution.
Vérifiez l'intégrité, pas seulement l'existence. Trois niveaux de vérification sont raisonnables pour les charges utiles importantes : le fichier existe, la taille du fichier correspond à celle attendue Content-Length (lorsque le serveur en fournit une), et une somme de contrôle si la source en publie une. Une comparaison rapide MD5 ou SHA-256 avec crypto.createHash('sha256') est rapide sur des fichiers de plusieurs Go et détecte les troncatures qu’une simple vérification d’existence naïve ne repère pas.
Limitez la concurrence, ne vous contentez pas de paralléliser. Trois à cinq téléchargements simultanés constituent une valeur par défaut raisonnable ; au-delà, vous commencez à vous faire concurrence pour le disque et la bande passante, et de nombreux sites resserrent les limites de débit. Un p-limit pool de style plus des limites de concurrence par hôte, c'est un petit bout de code qui évite beaucoup de rapports d'incidents.
Consignez les correspondances entre GUID et nom de fichier (méthode 3) ou entre URL et sortie (méthode 4). Lorsqu’un problème survient à 3 heures du matin, un journal structuré indiquant « cette URL a produit ce fichier avec ce nombre d’octets et ce statut » est ce qui vous sauvera. Conservez les journaux.
Mettez en quarantaine les fichiers partiels. Si un téléchargement échoue en cours de route, les octets partiels sont « radioactifs ». Déplacez-les vers un partial/ répertoire, ne les laissez pas là où la prochaine étape de votre pipeline pourrait les lire comme s’ils étaient complets. Un fichier partiel qui semble complet est le type de bug le plus coûteux dans l’automatisation des téléchargements.
Éviter les blocages lors des téléchargements automatisés
Même si votre flux de téléchargement Puppeteer est à toute épreuve au niveau de la gestion des fichiers, la requête elle-même peut être bloquée avant même de produire le moindre octet. Les CDN, les WAF et les fournisseurs de solutions anti-bot examinent les mêmes empreintes, que vous extrayiez du code HTML ou que vous téléchargiez un fichier CSV de 200 Mo ; les mêmes mesures de défense s'appliquent donc.
La protection la moins coûteuse et la plus efficace repose sur trois en-têtes et une décision relative à l'adresse IP :
- Un User-Agent réaliste. Utilisez un UA Chrome de bureau à jour correspondant à la version Chrome for Testing fournie, et non l'UA par défaut de Puppeteer. Certains hébergeurs bloquent l'UA par défaut dès qu'ils le détectent.
- Une fenêtre d'affichage adaptée. Une fenêtre d'affichage de 1366x900 correspond à une véritable session de bureau. Une fenêtre d'affichage de 800x600 crie « automatisation ».
- Referer. Définissez
Referervers la page qui renvoie vers le fichier. Les WAF renvoient souvent un code 403 lors d’accès directs aux ressources sans referer, en particulier pour les PDF et les images. - IP raisonnable. Les adresses IP des centres de données des fournisseurs de cloud courants sont pré-signalées par la plupart des fournisseurs d’anti-bots. Si vos téléchargements reçoivent des erreurs 403 sur de vrais navigateurs mais passent lorsque vous utilisez un VPN vers une connexion résidentielle, vous avez un problème d’IP, pas un problème de script.
Quelques mesures supplémentaires peuvent aider dans les cas tenaces. Ajoutez un petit slowMo (50 à 200 ms) pour espacer les clics. Utilisez page.waitForTimeout après goto pour laisser le temps aux vérifications anti-bot basées sur JavaScript de se stabiliser. Échelonnez les tâches multi-fichiers afin de ne pas générer N requêtes dans la même seconde.
Si vous avez suivi toutes les étapes ci-dessus et que le site vous bloque toujours, la bonne solution consiste à déléguer la couche de requêtes plutôt que de continuer à ajuster les en-têtes. Des outils tels que notre réseau de proxys résidentiels optimisé pour le scraping ou notre point de terminaison Scraper API sur WebScrapingAPI gèrent la rotation des proxys, la réputation des adresses IP et les contrôles d’empreinte digitale plus complexes derrière une seule requête, afin que votre code Puppeteer puisse rester concentré sur le chargement de la page. C’est également la solution à privilégier si vous avez besoin de téléchargements spécifiques à un pays ou si vous devez effectuer du scraping derrière des pages de vérification.
C'est également le moment idéal pour vous demander si vous avez réellement besoin d'un navigateur headless complet. La présentation du navigateur headless accessible via un lien sur le site mérite d'être lue si vous hésitez encore entre un harnais Puppeteer développé en interne et une alternative hébergée.
Puppeteer vs Playwright pour les téléchargements de fichiers
Réponse honnête : Playwright dispose d'une API plus agréable pour les téléchargements, Puppeteer offre un accès plus direct aux composants internes de Chrome, et les deux conviennent parfaitement en production.
Playwright expose page.waitForEvent('download'), qui renvoie un Download objet avec des aides telles que download.path(), download.saveAs(path)et download.suggestedFilename(). Vous n'avez pas besoin de toucher au CDP pour les cas de base. C'est véritablement plus court que la configuration équivalente avec Puppeteer, et cela fonctionne de la même manière sur Chromium, Firefox et WebKit, ce qui est un avantage considérable pour les suites de tests multi-navigateurs. Si vous partez de zéro et que votre pile ne s'appuie pas déjà sur Puppeteer, un workflow de téléchargement Playwright nécessite environ deux fois moins de code.
La force de Puppeteer réside dans le fait qu’il est plus proche du protocole Chrome DevTools. Si vous avez besoin d’événements CDP bruts, d’appels de protocole personnalisés ou d’un comportement qui n’a pas encore été encapsulé dans une API de plus haut niveau, Puppeteer y parvient avec une couche d’indirection en moins. La méthode 3 de ce guide en est un bon exemple. Le même modèle fonctionne également dans Playwright (Playwright expose une session CDP), mais les idiomes de Puppeteer semblent plus naturels car toute la bibliothèque est conçue autour du CDP.
Pour un pipeline de fichiers de téléchargement Puppeteer déjà en cours, rien de tout cela ne justifie une migration. La méthode 1, complétée par Browser.setDownloadBehavior correspond presque exactement aux fonctionnalités de Playwright waitForEvent('download') en termes de fonctionnalités ; il suffit d'écrire quelques lignes supplémentaires. Migrez vers Playwright lorsque la compatibilité multi-navigateurs constitue un véritable avantage, et non pas uniquement pour les téléchargements. Nous proposons un guide plus complet sur le web scraping avec Playwright sur notre site si vous souhaitez une comparaison détaillée.
Points clés
- Il n'existe pas de méthode unique idéale pour le téléchargement de fichiers avec Puppeteer. Adaptez la méthode à la contrainte la plus problématique : URL inconnue (méthode 1), authentification liée au navigateur (méthode 2), tâches parallèles avec suivi de progression (méthode 3) ou URL connue avec cookies réutilisables (méthode 4).
setDownloadBehaviorest non négociable. Headless Chrome bloque les téléchargements par défaut. Utilisez la méthode au niveau du navigateurBrowser.setDownloadBehavioravec un chemin absolu ; l'appel au niveau de la page est obsolète et peut échouer de manière imprévisible.- Attendez les fichiers réels, pas les événements de clic. Effectuez un instantané du dossier de téléchargement, ignorez
.crdownloadet exigez une fenêtre de taille de fichier stable avant de signaler la réussite. - Contournez le navigateur lorsque c'est possible. Une solution hybride Puppeteer + Axios est plus rapide, plus légère et plus facile à faire évoluer que l'exécution de N instances de Chrome pour des téléchargements parallèles.
- Renforcez la couche de requêtes indépendamment du script. Un User-Agent réaliste, une fenêtre d'affichage adaptée, un referer, des adresses IP résidentielles et une concurrence plafonnée préviennent la plupart des incidents de « 403 mystérieux ».
Foire aux questions
Quelques questions reviennent dans chaque projet de téléchargement de fichiers avec Puppeteer, généralement après que le premier script a plus ou moins fonctionné en mode headed et a échoué en CI. Les réponses ci-dessous ne reviennent pas sur les quatre méthodes (celles-ci sont décrites plus haut) et se concentrent sur les décisions opérationnelles : comment choisir rapidement lorsque vous ne pouvez pas prototyper les quatre, que faire lorsque les fichiers refusent de se télécharger, à quoi ressemble en pratique la voie « sans navigateur » la plus propre, où se situe Playwright par rapport à Puppeteer pour les téléchargements, et comment gérer l'authentification liée à la session sans y passer tout un week-end.
Comment choisir la meilleure méthode pour télécharger un fichier avec Puppeteer ?
Passez en revue une liste restreinte. Si vous pouvez extraire une URL stable et que l'authentification est réutilisable, utilisez Axios avec les cookies récupérés de la session Puppeteer. Si l'URL est générée par JavaScript ou n'est valide qu'au sein de la page, exécutez fetch() à l'intérieur page.evaluate() et renvoyez le résultat en base64. Si vous ne disposez que d'une cible de clic et avez besoin d'une exécution de base, configurez Browser.setDownloadBehavior et cliquez. Si vous avez besoin d'un indicateur de progression ou d'une sécurité parallèle, passez tout par des événements CDP. Adaptez la méthode à la contrainte la plus contraignante.
Pourquoi le téléchargement de Puppeteer reste-t-il bloqué sur un fichier .crdownload ou ne se termine-t-il jamais ?
La cause la plus courante est que le script se termine avant que Chrome n'évacue le fichier ; fermez donc toujours le navigateur uniquement après qu'un assistant de sondage a confirmé que le nom de fichier final existe avec une taille stable. Autres causes possibles : une downloadPath (il doit être absolu), le clic déclenchant une navigation plutôt qu’un téléchargement, ou un blocage du serveur que Chrome signale comme annulé. Observez l’exécution en mode « headed » une seule fois et la cause devient généralement évidente en quelques secondes.
Puis-je télécharger des fichiers sans lancer Chrome du tout ?
Oui, et c'est souvent la bonne décision. Si l'URL du fichier est publique, ou si les cookies et les en-têtes nécessaires pour le récupérer peuvent être reproduits par un client HTTP, ignorez le navigateur et utilisez axios ou la fonction intégrée à Node https avec une écriture en continu. Les seules situations où vous avez besoin d'un navigateur sont celles où JavaScript construit l'URL, où l'authentification est liée à la session du navigateur d'une manière que vous ne pouvez pas reproduire, ou lorsqu'une couche de détection de bots bloque spécifiquement les clients non-navigateurs sur cette URL.
Comment Puppeteer se compare-t-il à Playwright pour le téléchargement de fichiers ?
Playwright encapsule les téléchargements dans une API d'événements de haut niveau (page.waitForEvent('download')) qui renvoie un Download objet avec saveAs() et path() , ce qui est plus concis que la configuration équivalente avec Puppeteer et CDP. Puppeteer vous oblige à configurer Browser.setDownloadBehavior et soit d'interroger le système de fichiers, soit d'écouter les événements CDP. Les deux sont fiables en production. Choisissez en fonction de la bibliothèque déjà utilisée par votre pile, et non pas uniquement en fonction de l'API de téléchargement.
Comment télécharger des fichiers protégés par un identifiant ou un cookie de session ?
Deux options simples. Soit vous effectuez la connexion dans Puppeteer, soit vous récupérez les cookies avec page.cookies(), puis rejouer la requête de fichier depuis Axios avec un Cookie en plus d'un User-Agent et Referer. Ou bien, exécutez la récupération du fichier à l'intérieur de page.evaluate() afin que la requête hérite automatiquement de la session. La première solution est plus rapide et plus facile à faire évoluer ; la seconde est plus robuste lorsque l'authentification est liée à des jetons en mémoire ou à des empreintes qui ne survivent pas à la relecture.
Conclusion et prochaines étapes
Un workflow fiable de téléchargement de fichiers avec Puppeteer dépend moins de Puppeteer que du choix de l'emplacement où les octets sont réellement transférés. Utilisez la méthode 1 lorsque vous ne disposez que d’un clic. Optez pour la méthode 2 lorsque la session de la page est la seule à pouvoir récupérer le fichier. Privilégiez la méthode 3 lorsque vous avez besoin d’une progression, de parallélisme ou de signaux d’annulation clairs. Optez par défaut pour la méthode 4 dès que vous pouvez rejouer l’URL, et considérez Puppeteer comme un outil de découverte plutôt que comme un outil de téléchargement.
Enveloppez chaque script des principes de base de la sécurisation en production : chemins de téléchargement absolus, délais d'attente à plusieurs niveaux, tentatives de réessai avec temporisation, vérifications d'intégrité allant au-delà de la simple existence, et concurrence plafonnée. Détectez .crdownload les sentinelles, nettoyez-les entre les exécutions et ne laissez jamais un fichier partiel circuler en aval comme s'il était complet.
Si vos téléchargements sont bloqués plutôt qu'échoués, le problème ne se situe plus dans votre script, mais au niveau de la couche de requête. C'est là qu'une infrastructure de scraping gérée prend tout son sens. L'API WebScrapingAPI Browser vous offre des navigateurs cloud entièrement hébergés que vous pouvez piloter avec le même code Puppeteer (ou Playwright), ainsi qu'un réseau de proxys résidentiels et un déblocage intégré pour les cibles les plus difficiles, ce qui vous permet de conserver le guide des quatre méthodes ci-dessus et de simplement changer l'origine des requêtes. À partir de là, l'évolutivité d'un pipeline de téléchargement de fichiers Puppeteer se résume à un changement de configuration plutôt qu'à une refonte de l'architecture.
Choisissez la bonne méthode pour le fichier du jour, sécurisez-la une fois pour toutes, puis passez à autre chose.




