Le web scraping bien fait. Crawlee est le framework TypeScript open-source par Apify qui gère les parties difficiles — files de requêtes, nouvelles tentatives, rotation de proxies et anti-blocage — pour que vous puissiez vous concentrer sur l'extraction de données.
Ce que vous apprendrez
À la fin de ce tutoriel, vous saurez :
- Configurer un projet Crawlee avec TypeScript depuis zéro
- Construire des scrapers avec PlaywrightCrawler pour les sites riches en JavaScript
- Utiliser CheerioCrawler pour le scraping HTML léger et rapide
- Gérer les files de requêtes pour crawler des milliers de pages
- Stocker les données extraites avec le système Dataset intégré de Crawlee
- Implémenter la rotation de proxies et les stratégies anti-blocage
- Gérer la pagination, le défilement infini et le contenu dynamique
- Déployer votre scraper en production avec Docker
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20+ installé (
node --version) - Expérience en TypeScript (types, async/await, generics)
- Connaissances de base en HTML/CSS (sélecteurs, structure DOM)
- Un éditeur de code — VS Code ou Cursor recommandé
- Docker installé (optionnel, pour le déploiement)
Pourquoi Crawlee ?
Le web scraping en Node.js signifie souvent assembler Puppeteer, Cheerio, des bibliothèques de requêtes, une logique de nouvelles tentatives et la gestion de files soi-même. Crawlee fournit tout cela dans un framework unique et cohérent :
| Fonctionnalité | Crawlee | Manuel (Puppeteer + Cheerio) | Scrapy (Python) |
|---|---|---|---|
| Langage | TypeScript/JavaScript | JavaScript | Python |
| Support navigateur | Playwright, Puppeteer | Configuration manuelle | Splash/Selenium |
| File de requêtes | Intégrée avec persistance | Implémentation manuelle | Intégrée |
| Nouvelles tentatives | Configurable par requête | Manuel | Intégrées |
| Rotation de proxies | Intégrée avec gestion de sessions | Manuel | Middleware |
| Anti-blocage | Empreintes, headers | Manuel | Middleware |
| Stockage de données | Dataset + Key-Value Store | Manuel (JSON/DB) | Item pipelines |
| Sécurité de types | TypeScript complet | Optionnel | Non |
Crawlee vous donne la puissance de Scrapy avec la sécurité de TypeScript et une expérience développeur moderne.
Étape 1 : Configuration du projet
Commencez par créer un nouveau projet Crawlee. Le CLI génère tout ce dont vous avez besoin :
npx crawlee create my-scraper --template playwright-ts
cd my-scraperCela génère la structure de projet suivante :
my-scraper/
├── src/
│ ├── main.ts # Point d'entrée
│ ├── routes.ts # Gestionnaires de routes
│ └── types.ts # Types personnalisés
├── storage/ # Créé automatiquement pour les données et files
├── package.json
├── tsconfig.json
└── Dockerfile # Configuration Docker prête pour la production
Installez les dépendances :
npm installCrawlee installe trois packages principaux :
crawlee— Le cœur du framework avec les crawlers, files et stockageplaywright— Automatisation de navigateur pour les pages rendues en JavaScript@crawlee/playwright— Intégration Playwright pour Crawlee
Étape 2 : Comprendre l'architecture de Crawlee
Avant d'écrire du code, comprenons le fonctionnement de Crawlee :
┌─────────────────────────────────────────────┐
│ Crawlee │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ File de │───▶│ Crawler │───▶│ Dataset │ │
│ │ requêtes │ │ (Router) │ │ (Sortie)│ │
│ └──────────┘ └──────────┘ └─────────┘ │
│ │ │ │
│ │ ┌────┴────┐ │
│ │ │ Gestion │ │
│ │ │ Proxies │ │
│ │ └─────────┘ │
│ ▼ │
│ Nouvelles tentatives automatiques │
│ Contrôle de concurrence │
│ Limitation de débit │
└─────────────────────────────────────────────┘
Les composants clés :
- File de requêtes — Gère les URLs à scraper, gère la déduplication et persiste l'état entre les redémarrages
- Crawler — Traite chaque requête avec votre fonction de gestionnaire (Cheerio pour HTML, Playwright pour les pages rendues en JS)
- Router — Route les différents patterns d'URL vers différentes fonctions de traitement
- Dataset — Stocke les données extraites en lignes JSON, exportables en CSV, JSON ou tout format
- Gestionnaire de proxies — Effectue la rotation des proxies et gère les sessions pour éviter les blocages
Étape 3 : Construire un CheerioCrawler pour les pages statiques
Commençons avec le type de crawler le plus rapide — CheerioCrawler. Il télécharge le HTML brut et le parse avec Cheerio (API similaire à jQuery), sans lancer de navigateur. Parfait pour les sites qui ne nécessitent pas de rendu JavaScript.
Remplacez le contenu de src/main.ts :
import { CheerioCrawler, Dataset, log } from 'crawlee';
// Configurer les logs
log.setLevel(log.LEVELS.INFO);
// Créer le crawler
const crawler = new CheerioCrawler({
// Nombre maximum de requêtes simultanées
maxConcurrency: 10,
// Nombre maximum de requêtes par minute (limitation de débit)
maxRequestsPerMinute: 60,
// Réessayer les requêtes échouées jusqu'à 3 fois
maxRequestRetries: 3,
// Gestionnaire pour chaque page
async requestHandler({ request, $, enqueueLinks, pushData }) {
const url = request.url;
log.info(`Scraping : ${url}`);
// Extraire les données de la page avec des sélecteurs CSS
const title = $('h1').first().text().trim();
const description = $('meta[name="description"]').attr('content') || '';
const links = $('a[href]')
.map((_, el) => $(el).attr('href'))
.get()
.filter((href) => href.startsWith('http'));
// Pousser les données extraites dans le dataset
await pushData({
url,
title,
description,
linksFound: links.length,
scrapedAt: new Date().toISOString(),
});
// Suivre les liens de la page (crawling en largeur)
await enqueueLinks({
globs: ['https://example.com/**'],
label: 'DETAIL',
});
},
// Appelé quand une requête échoue après toutes les tentatives
async failedRequestHandler({ request }) {
log.error(`Échec : ${request.url} — ${request.errorMessages.join(', ')}`);
},
});
// Démarrer le crawler avec des URLs de départ
await crawler.run([
'https://example.com',
]);
// Exporter les résultats
const dataset = await Dataset.open();
await dataset.exportToJSON('results');
log.info('Scraping terminé ! Résultats sauvegardés dans storage/datasets/default/');Exécutez-le :
npx tsx src/main.tsVos données extraites sont sauvegardées dans storage/datasets/default/ sous forme de fichiers JSON individuels.
Étape 4 : Construire un PlaywrightCrawler pour les sites dynamiques
De nombreux sites modernes rendent leur contenu avec JavaScript. Pour ceux-ci, vous avez besoin de PlaywrightCrawler, qui lance un vrai navigateur :
import { PlaywrightCrawler, Dataset, log } from 'crawlee';
const crawler = new PlaywrightCrawler({
// Utiliser Chromium sans interface graphique
launchContext: {
launchOptions: {
headless: true,
},
},
// Les pages de navigateur sont coûteuses — limiter la concurrence
maxConcurrency: 5,
// Timeout par page (30 secondes)
requestHandlerTimeoutSecs: 30,
async requestHandler({ request, page, enqueueLinks, pushData }) {
const url = request.url;
log.info(`Scraping (navigateur) : ${url}`);
// Attendre que le contenu principal soit rendu
await page.waitForSelector('.product-card', { timeout: 10000 });
// Extraire les données des produits
const products = await page.$$eval('.product-card', (cards) =>
cards.map((card) => ({
name: card.querySelector('.product-name')?.textContent?.trim() || '',
price: card.querySelector('.product-price')?.textContent?.trim() || '',
rating: card.querySelector('.product-rating')?.textContent?.trim() || '',
image: card.querySelector('img')?.getAttribute('src') || '',
}))
);
// Pousser chaque produit dans le dataset
for (const product of products) {
await pushData({
...product,
sourceUrl: url,
scrapedAt: new Date().toISOString(),
});
}
// Suivre les liens de pagination
await enqueueLinks({
selector: 'a.pagination-next',
label: 'LISTING',
});
},
});
await crawler.run([
'https://example-shop.com/products?page=1',
]);Quand utiliser quel crawler
| Scénario | Crawler | Raison |
|---|---|---|
| Sites HTML statiques | CheerioCrawler | 10x plus rapide, pas de surcharge navigateur |
| Contenu rendu en JavaScript | PlaywrightCrawler | Exécute le JS, attend le rendu |
| Applications monopage (SPAs) | PlaywrightCrawler | Gère le routage côté client |
| APIs retournant du HTML | CheerioCrawler | Besoin seulement de parser le HTML |
| Sites nécessitant une connexion | PlaywrightCrawler | Peut remplir des formulaires |
Étape 5 : Utiliser le Router pour les patterns multi-pages
Les scrapers réels doivent gérer différents types de pages différemment — pages de liste, pages de détail, résultats de recherche. Le Router de Crawlee rend cela propre :
Créez src/routes.ts :
import { createPlaywrightRouter, Dataset } from 'crawlee';
export const router = createPlaywrightRouter();
// Gestionnaire par défaut — pages de liste
router.addDefaultHandler(async ({ request, page, enqueueLinks, log }) => {
log.info(`Traitement de la liste : ${request.url}`);
// Extraire les liens vers les éléments individuels
await enqueueLinks({
selector: 'a.item-link',
label: 'DETAIL',
});
// Gérer la pagination
const nextButton = await page.$('a.next-page');
if (nextButton) {
await enqueueLinks({
selector: 'a.next-page',
label: 'LISTING',
});
}
});
// Gestionnaire de page de détail
router.addHandler('DETAIL', async ({ request, page, pushData, log }) => {
log.info(`Traitement du détail : ${request.url}`);
await page.waitForSelector('.article-content', { timeout: 10000 });
const data = await page.evaluate(() => {
const title = document.querySelector('h1')?.textContent?.trim() || '';
const author = document.querySelector('.author-name')?.textContent?.trim() || '';
const date = document.querySelector('time')?.getAttribute('datetime') || '';
const content = document.querySelector('.article-content')?.textContent?.trim() || '';
const tags = Array.from(document.querySelectorAll('.tag'))
.map((tag) => tag.textContent?.trim() || '');
return { title, author, date, content, tags };
});
await pushData({
...data,
url: request.url,
scrapedAt: new Date().toISOString(),
});
});Mettez à jour src/main.ts pour utiliser le routeur :
import { PlaywrightCrawler } from 'crawlee';
import { router } from './routes.js';
const crawler = new PlaywrightCrawler({
requestHandler: router,
maxConcurrency: 5,
maxRequestsPerMinute: 30,
});
await crawler.run([
{ url: 'https://example-blog.com/articles', label: 'LISTING' },
{ url: 'https://example-blog.com/search?q=typescript', label: 'SEARCH' },
]);Étape 6 : Gérer la pagination et le défilement infini
Pagination traditionnelle
Pour les sites avec des boutons "Suivant" ou des pages numérotées :
router.addHandler('LISTING', async ({ page, enqueueLinks, request, log }) => {
const items = await page.$$eval('.item', (els) =>
els.map((el) => ({
title: el.querySelector('h2')?.textContent?.trim(),
url: el.querySelector('a')?.href,
}))
);
log.info(`Page ${request.userData.page || 1} : ${items.length} éléments trouvés`);
for (const item of items) {
if (item.url) {
await enqueueLinks({
urls: [item.url],
label: 'DETAIL',
});
}
}
const nextUrl = await page.$eval('a.next', (el) => el.href).catch(() => null);
if (nextUrl) {
await enqueueLinks({
urls: [nextUrl],
label: 'LISTING',
userData: { page: (request.userData.page || 1) + 1 },
});
}
});Défilement infini
Pour les sites qui chargent plus de contenu en défilant :
router.addHandler('INFINITE', async ({ page, pushData, log }) => {
let previousHeight = 0;
let scrollAttempts = 0;
const maxScrolls = 20;
while (scrollAttempts < maxScrolls) {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(2000);
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
if (currentHeight === previousHeight) {
log.info('Plus de contenu à charger');
break;
}
previousHeight = currentHeight;
scrollAttempts++;
log.info(`Défilement ${scrollAttempts}/${maxScrolls} — hauteur : ${currentHeight}`);
}
const allItems = await page.$$eval('.feed-item', (items) =>
items.map((item) => ({
text: item.querySelector('.content')?.textContent?.trim() || '',
author: item.querySelector('.author')?.textContent?.trim() || '',
timestamp: item.querySelector('time')?.getAttribute('datetime') || '',
}))
);
log.info(`${allItems.length} éléments extraits au total`);
for (const item of allItems) {
await pushData(item);
}
});Étape 7 : Rotation de proxies et anti-blocage
Le blocage est le plus grand défi du web scraping. Crawlee dispose d'outils intégrés pour vous aider :
Rotation de proxies basique
import { PlaywrightCrawler, ProxyConfiguration } from 'crawlee';
const proxyConfiguration = new ProxyConfiguration({
proxyUrls: [
'http://user:pass@proxy1.example.com:8080',
'http://user:pass@proxy2.example.com:8080',
'http://user:pass@proxy3.example.com:8080',
],
});
const crawler = new PlaywrightCrawler({
proxyConfiguration,
requestHandler: router,
useSessionPool: true,
sessionPoolOptions: {
maxPoolSize: 100,
sessionOptions: {
maxUsageCount: 50,
},
},
});Bonnes pratiques anti-blocage
const crawler = new PlaywrightCrawler({
maxRequestsPerMinute: 20,
browserPoolOptions: {
useFingerprints: true,
fingerprintOptions: {
fingerprintGeneratorOptions: {
browsers: ['chrome', 'firefox'],
operatingSystems: ['windows', 'macos', 'linux'],
locales: ['en-US', 'en-GB'],
},
},
},
preNavigationHooks: [
async ({ page }) => {
const width = 1280 + Math.floor(Math.random() * 200);
const height = 800 + Math.floor(Math.random() * 200);
await page.setViewportSize({ width, height });
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
});
},
],
requestHandler: router,
});Étape 8 : Stockage de données et exportation
Crawlee fournit deux systèmes de stockage :
Dataset — Pour les données tabulaires
import { Dataset } from 'crawlee';
// Pousser des données pendant le scraping
await pushData({
name: 'TypeScript Handbook',
price: 29.99,
category: 'Programming',
});
// Après le scraping, exporter dans différents formats
const dataset = await Dataset.open();
// Exporter en JSON
await dataset.exportToJSON('output');
// Exporter en CSV
await dataset.exportToCSV('output');
// Obtenir toutes les données en une fois
const { items } = await dataset.getData();
console.log(`Total des éléments : ${items.length}`);Key-Value Store — Pour les données diverses
import { KeyValueStore } from 'crawlee';
const store = await KeyValueStore.open();
// Sauvegarder des captures d'écran
await store.setValue('homepage-screenshot', await page.screenshot(), {
contentType: 'image/png',
});
// Sauvegarder la configuration ou l'état
await store.setValue('scraper-config', {
lastRun: new Date().toISOString(),
totalPages: 1500,
errors: 12,
});Étape 9 : Gestion des erreurs et résilience
Les scrapers en production doivent gérer les échecs avec élégance :
import { PlaywrightCrawler, log } from 'crawlee';
const crawler = new PlaywrightCrawler({
maxRequestRetries: 3,
requestHandlerTimeoutSecs: 60,
async requestHandler({ request, page, pushData, session }) {
try {
const pageTitle = await page.title();
// Détecter les blocages légers
if (
pageTitle.toLowerCase().includes('captcha') ||
pageTitle.toLowerCase().includes('verify') ||
pageTitle.toLowerCase().includes('access denied')
) {
session?.retire();
throw new Error(`Blocage détecté à ${request.url}`);
}
// Attendre le contenu avec un repli
try {
await page.waitForSelector('.main-content', { timeout: 10000 });
} catch {
log.warning(`Sélecteur de contenu non trouvé à ${request.url}, tentative de repli`);
await page.waitForSelector('body', { timeout: 5000 });
}
const data = await page.evaluate(() => ({
title: document.querySelector('h1')?.textContent?.trim() || 'Sans titre',
content: document.querySelector('.main-content')?.textContent?.trim() || '',
}));
await pushData({
...data,
url: request.url,
retryCount: request.retryCount,
});
} catch (error) {
log.error(`Erreur lors du scraping de ${request.url}`, {
error: (error as Error).message,
retryCount: request.retryCount,
});
throw error;
}
},
async failedRequestHandler({ request, log }) {
log.error(`Échec définitif : ${request.url}`, {
errors: request.errorMessages,
retries: request.retryCount,
});
const dataset = await Dataset.open('failed-requests');
await dataset.pushData({
url: request.url,
errors: request.errorMessages,
failedAt: new Date().toISOString(),
});
},
});Étape 10 : Exemple concret — Scraping de tableau de bord emploi
Construisons un scraper complet qui extrait des offres d'emploi. Cet exemple démontre tous les concepts ensemble :
import { PlaywrightCrawler, Dataset, log } from 'crawlee';
interface JobListing {
title: string;
company: string;
location: string;
salary: string;
description: string;
tags: string[];
postedAt: string;
url: string;
scrapedAt: string;
}
const crawler = new PlaywrightCrawler({
maxConcurrency: 3,
maxRequestsPerMinute: 15,
maxRequestRetries: 3,
requestHandlerTimeoutSecs: 45,
browserPoolOptions: {
useFingerprints: true,
},
preNavigationHooks: [
async ({ page }) => {
await page.route('**/*.{png,jpg,jpeg,gif,webp,woff,woff2}', (route) =>
route.abort()
);
},
],
async requestHandler({ request, page, enqueueLinks, pushData, log }) {
const label = request.label || 'LISTING';
if (label === 'LISTING') {
log.info(`Scraping page de liste : ${request.url}`);
await page.waitForSelector('.job-card', { timeout: 15000 });
await enqueueLinks({
selector: '.job-card a.job-title-link',
label: 'JOB_DETAIL',
});
const hasNextPage = await page.$('a[aria-label="Next page"]');
if (hasNextPage) {
await enqueueLinks({
selector: 'a[aria-label="Next page"]',
label: 'LISTING',
});
}
}
if (label === 'JOB_DETAIL') {
log.info(`Scraping détail emploi : ${request.url}`);
await page.waitForSelector('.job-detail', { timeout: 15000 });
const job: JobListing = await page.evaluate(() => {
const getText = (selector: string): string =>
document.querySelector(selector)?.textContent?.trim() || '';
return {
title: getText('h1.job-title'),
company: getText('.company-name'),
location: getText('.job-location'),
salary: getText('.salary-range'),
description: getText('.job-description'),
tags: Array.from(document.querySelectorAll('.skill-tag')).map(
(tag) => tag.textContent?.trim() || ''
),
postedAt: getText('.posted-date'),
url: window.location.href,
scrapedAt: new Date().toISOString(),
};
});
if (job.title && job.company) {
await pushData(job);
log.info(`Sauvegardé : ${job.title} chez ${job.company}`);
}
}
},
});
log.info("Démarrage du scraper d'offres d'emploi...");
await crawler.run([
{ url: 'https://example-jobs.com/jobs?q=typescript', label: 'LISTING' },
]);
const dataset = await Dataset.open();
const { items } = await dataset.getData();
log.info(`Scraping terminé ! ${items.length} offres d'emploi extraites.`);
await dataset.exportToJSON('jobs');Étape 11 : Déployer avec Docker
Les projets Crawlee sont livrés avec un Dockerfile prêt pour la production :
FROM node:20-slim AS builder
RUN npx playwright install-deps chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-slim
RUN npx playwright install chromium
RUN npx playwright install-deps chromium
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN mkdir -p storage
ENV NODE_ENV=production
ENV CRAWLEE_STORAGE_DIR=./storage
CMD ["node", "dist/main.js"]Construisez et exécutez :
docker build -t my-scraper .
docker run -v $(pwd)/output:/app/storage my-scraperÉtape 12 : Patterns avancés
Crawling reprise automatique
Crawlee persiste sa file de requêtes sur le disque. Si votre scraper plante, redémarrez-le simplement — il reprend là où il s'est arrêté :
const crawler = new PlaywrightCrawler({
requestHandler: router,
});
// Premier lancement : traite toutes les URLs
await crawler.run(['https://example.com/page1', 'https://example.com/page2']);
// Si vous redémarrez, les URLs déjà traitées sont automatiquement ignoréesInterception des requêtes réseau
Surveillez et modifiez le trafic réseau pendant le scraping :
const crawler = new PlaywrightCrawler({
preNavigationHooks: [
async ({ page }) => {
page.on('response', async (response) => {
const url = response.url();
if (url.includes('/api/products')) {
try {
const data = await response.json();
const store = await KeyValueStore.open();
await store.setValue(`api-response-${Date.now()}`, data);
} catch {
// La réponse pourrait ne pas être en JSON
}
}
});
await page.route('**/*', (route) => {
const type = route.request().resourceType();
if (['image', 'font', 'stylesheet'].includes(type)) {
return route.abort();
}
return route.continue();
});
},
],
async requestHandler({ page, pushData }) {
await page.waitForSelector('.content');
const title = await page.title();
await pushData({ title });
},
});Dépannage
Problèmes courants
Le navigateur ne se lance pas dans Docker : Assurez-vous d'installer les dépendances Playwright :
npx playwright install-deps chromiumBlocages fréquents :
- Réduisez
maxConcurrencyetmaxRequestsPerMinute - Activez la rotation de proxies
- Utilisez
useFingerprints: true - Ajoutez des délais aléatoires entre les requêtes
Problèmes de mémoire avec de grands crawls :
- Utilisez
CheerioCrawlerau lieu dePlaywrightCrawlerquand possible - Limitez
maxConcurrencypour réduire la consommation mémoire - Définissez
maxRequestsPerCrawlpour traiter par lots
Prochaines étapes
Maintenant que vous avez un scraper Crawlee fonctionnel, voici comment le prolonger :
- Ajouter une base de données — Stockez les résultats dans PostgreSQL avec Drizzle ORM (voir notre tutoriel Drizzle)
- Construire une API — Servez les données extraites via une API REST avec Hono (voir notre tutoriel Hono)
- Planifier les exécutions — Utilisez GitHub Actions (voir notre tutoriel CI/CD)
- Ajouter l'extraction IA — Combinez avec Claude pour une analyse intelligente (voir notre tutoriel AI scraper)
Conclusion
Vous avez construit un scraper web de niveau production avec Crawlee et TypeScript. Vous savez maintenant :
- Choisir entre
CheerioCrawleretPlaywrightCrawlerselon votre site cible - Utiliser les routeurs pour gérer proprement différents types de pages
- Implémenter la gestion de pagination et du défilement infini
- Effectuer la rotation de proxies et gérer les sessions pour éviter les blocages
- Stocker et exporter les données dans plusieurs formats
- Déployer votre scraper avec Docker pour la production
Crawlee gère les parties difficiles — gestion des files, nouvelles tentatives, rotation de proxies, empreintes de navigateur — pour que vous puissiez vous concentrer sur la logique d'extraction qui compte.
Bon scraping !