Web Scraping con Playwright: Creando Scripts Reutilizables (2026)
El web scraping ha evolucionado más allá de las simples solicitudes HTTP. Los sitios web modernos dependen en gran medida del renderizado de JavaScript, la carga de contenido dinámico y las medidas anti-bot. Playwright, desarrollado por Microsoft, maneja todo esto mientras proporciona una API limpia para crear scripts de scraping mantenibles. Esta guía cubre patrones prácticos para crear scrapers reutilizables y listos para producción.
¿Por qué Playwright para Web Scraping?
Playwright es una biblioteca de automatización de navegadores que controla Chromium, Firefox y WebKit. A diferencia de los scrapers basados en solicitudes, Playwright renderiza las páginas exactamente como un navegador real, ejecutando JavaScript y manejando contenido dinámico automáticamente.
Ventajas clave sobre las alternativas:
- Renderizado completo del navegador: Los sitios con mucho JavaScript funcionan sin configuración adicional
- Auto-espera: Espera automáticamente a los elementos antes de interactuar
- Múltiples navegadores: Prueba en Chromium, Firefox, WebKit
- Intercepción de red: Modifica solicitudes, bloquea recursos, captura respuestas
- Capturas de pantalla y PDFs: Depuración visual y documentación
- Modo sigiloso: Mejor para evitar la detección de bots que Puppeteer
Configuración de Playwright
Instalación
# Install Playwright
npm install playwright
# Or with TypeScript types
npm install playwright @types/node
# Download browsers (run once)
npx playwright install chromium
Ejemplo Básico de Scraping
import { chromium } from 'playwright';
async function scrapeExample() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com');
// Extract text content
const title = await page.textContent('h1');
const paragraphs = await page.$$eval('p', els => els.map(el => el.textContent));
console.log('Title:', title);
console.log('Paragraphs:', paragraphs);
await browser.close();
}
scrapeExample();
Creación de Scripts de Scraping Reutilizables
Los scrapers de producción necesitan estructura. Aquí hay un patrón que separa las responsabilidades y maneja los requisitos comunes.
1. Clase Base del Scraper
import { chromium, Browser, Page, BrowserContext } from 'playwright';
interface ScraperConfig {
headless?: boolean;
timeout?: number;
userAgent?: string;
proxy?: { server: string; username?: string; password?: string };
}
export abstract class BaseScraper {
protected browser: Browser | null = null;
protected context: BrowserContext | null = null;
protected page: Page | null = null;
protected config: ScraperConfig;
constructor(config: ScraperConfig = {}) {
this.config = {
headless: true,
timeout: 30000,
...config,
};
}
async init(): Promise {
this.browser = await chromium.launch({
headless: this.config.headless,
});
this.context = await this.browser.newContext({
userAgent: this.config.userAgent || this.getRandomUserAgent(),
viewport: { width: 1920, height: 1080 },
proxy: this.config.proxy,
});
// Block unnecessary resources for speed
await this.context.route('**/*', (route) => {
const resourceType = route.request().resourceType();
if (['image', 'font', 'media'].includes(resourceType)) {
route.abort();
} else {
route.continue();
}
});
this.page = await this.context.newPage();
this.page.setDefaultTimeout(this.config.timeout!);
}
async close(): Promise {
await this.browser?.close();
this.browser = null;
this.context = null;
this.page = null;
}
protected getRandomUserAgent(): string {
const userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
];
return userAgents[Math.floor(Math.random() * userAgents.length)];
}
protected async waitAndClick(selector: string): Promise {
await this.page!.waitForSelector(selector);
await this.page!.click(selector);
}
protected async extractText(selector: string): Promise {
try {
return await this.page!.textContent(selector);
} catch {
return null;
}
}
protected async extractAll(selector: string): Promise {
return await this.page!.$$eval(selector, els =>
els.map(el => el.textContent?.trim() || '')
);
}
// Abstract method - implement in subclasses
abstract scrape(url: string): Promise;
}
2. Implementación Específica del Scraper
interface Product {
name: string;
price: string;
description: string;
imageUrl: string;
rating: string | null;
}
export class ProductScraper extends BaseScraper {
async scrape(url: string): Promise {
if (!this.page) await this.init();
await this.page!.goto(url, { waitUntil: 'networkidle' });
// Handle infinite scroll
await this.scrollToBottom();
// Extract products
const products = await this.page!.$$eval('.product-card', cards =>
cards.map(card => ({
name: card.querySelector('.product-name')?.textContent?.trim() || '',
price: card.querySelector('.product-price')?.textContent?.trim() || '',
description: card.querySelector('.product-desc')?.textContent?.trim() || '',
imageUrl: card.querySelector('img')?.getAttribute('src') || '',
rating: card.querySelector('.rating')?.textContent?.trim() || null,
}))
);
return products;
}
private async scrollToBottom(): Promise {
let previousHeight = 0;
let currentHeight = await this.page!.evaluate(() => document.body.scrollHeight);
while (previousHeight < currentHeight) {
previousHeight = currentHeight;
await this.page!.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await this.page!.waitForTimeout(1000);
currentHeight = await this.page!.evaluate(() => document.body.scrollHeight);
}
}
}
3. Uso del Scraper
async function main() {
const scraper = new ProductScraper({ headless: true });
try {
await scraper.init();
const products = await scraper.scrape('https://example-shop.com/products');
console.log(`Found ${products.length} products`);
console.log(JSON.stringify(products, null, 2));
} finally {
await scraper.close();
}
}
main();
Patrones Avanzados
Manejo de Autenticación
export class AuthenticatedScraper extends BaseScraper {
private credentials: { username: string; password: string };
constructor(credentials: { username: string; password: string }, config?: ScraperConfig) {
super(config);
this.credentials = credentials;
}
async login(): Promise {
if (!this.page) await this.init();
await this.page!.goto('https://example.com/login');
await this.page!.fill('input[name="username"]', this.credentials.username);
await this.page!.fill('input[name="password"]', this.credentials.password);
await this.page!.click('button[type="submit"]');
// Wait for navigation after login
await this.page!.waitForURL('**/dashboard**');
// Save session for reuse
await this.context!.storageState({ path: './auth-state.json' });
}
async initWithSavedSession(): Promise {
this.browser = await chromium.launch({ headless: this.config.headless });
this.context = await this.browser.newContext({
storageState: './auth-state.json',
});
this.page = await this.context.newPage();
}
}
Intercepción de Red
// Capture API responses while browsing
async function captureApiData(page: Page, apiPattern: string): Promise {
const captured: unknown[] = [];
await page.route(apiPattern, async (route) => {
const response = await route.fetch();
const json = await response.json();
captured.push(json);
route.fulfill({ response });
});
return captured;
}
// Usage
const page = await context.newPage();
const apiData = await captureApiData(page, '**/api/products**');
await page.goto('https://example.com/products');
// apiData now contains all API responses matching the pattern
Scraping en Paralelo
async function scrapeInParallel(urls: string[], concurrency: number = 5): Promise
Manejo de Medidas Anti-Bot
import { chromium } from 'playwright-extra';
import stealth from 'puppeteer-extra-plugin-stealth';
// Use stealth plugin (works with playwright-extra)
chromium.use(stealth());
async function stealthScraper() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
// Realistic viewport
viewport: { width: 1920, height: 1080 },
// Timezone
timezoneId: 'America/New_York',
// Locale
locale: 'en-US',
// Geolocation (optional)
geolocation: { latitude: 40.7128, longitude: -74.0060 },
permissions: ['geolocation'],
});
const page = await context.newPage();
// Add random delays to mimic human behavior
await page.goto('https://example.com');
await randomDelay(1000, 3000);
// Move mouse randomly
await page.mouse.move(
Math.random() * 500,
Math.random() * 500
);
await browser.close();
}
function randomDelay(min: number, max: number): Promise {
const delay = Math.floor(Math.random() * (max - min + 1)) + min;
return new Promise(r => setTimeout(r, delay));
}
Patrones de Extracción de Datos
Scraping de Tablas
interface TableRow {
[key: string]: string;
}
async function scrapeTable(page: Page, tableSelector: string): Promise {
return await page.$$eval(tableSelector, (tables) => {
const table = tables[0];
if (!table) return [];
const headers = Array.from(table.querySelectorAll('th')).map(
th => th.textContent?.trim().toLowerCase().replace(/\s+/g, '_') || ''
);
const rows = Array.from(table.querySelectorAll('tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
const rowData: Record = {};
cells.forEach((cell, index) => {
const header = headers[index] || `column_${index}`;
rowData[header] = cell.textContent?.trim() || '';
});
return rowData;
});
});
}
Manejo de Paginación
async function scrapeAllPages(
page: Page,
scrapePageFn: (page: Page) => Promise,
nextButtonSelector: string
): Promise {
const allResults: T[] = [];
let pageNum = 1;
while (true) {
console.log(`Scraping page ${pageNum}...`);
const pageResults = await scrapePageFn(page);
allResults.push(...pageResults);
// Check if next button exists and is clickable
const nextButton = await page.$(nextButtonSelector);
if (!nextButton) break;
const isDisabled = await nextButton.getAttribute('disabled');
if (isDisabled !== null) break;
await nextButton.click();
await page.waitForLoadState('networkidle');
pageNum++;
}
return allResults;
}
Manejo de Errores y Reintentos
async function withRetry(
fn: () => Promise,
maxRetries: number = 3,
delay: number = 1000
): Promise {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
console.warn(`Attempt ${attempt} failed: ${lastError.message}`);
if (attempt < maxRetries) {
await new Promise(r => setTimeout(r, delay * attempt));
}
}
}
throw lastError;
}
// Usage
const data = await withRetry(
() => scraper.scrape('https://example.com'),
3,
2000
);
Guardado y Exportación de Datos
import { writeFileSync } from 'fs';
function exportToCSV(data: Record[], filename: string): void {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvRows = [
headers.join(','),
...data.map(row =>
headers.map(h => {
const value = String(row[h] || '');
// Escape quotes and wrap in quotes if contains comma
return value.includes(',') || value.includes('"')
? `"${value.replace(/"/g, '""')}"`
: value;
}).join(',')
),
];
writeFileSync(filename, csvRows.join('\n'));
}
function exportToJSON(data: unknown, filename: string): void {
writeFileSync(filename, JSON.stringify(data, null, 2));
}
Mejores Prácticas
Rendimiento
- Bloquea imágenes, fuentes y medios cuando no sean necesarios
- Usa
domcontentloadeden lugar denetworkidlecuando sea posible - Reutiliza los contextos del navegador en lugar de crear nuevos navegadores
- Implementa pooling de conexiones para scraping de alto volumen
Fiabilidad
- Siempre usa esperas explícitas (
waitForSelector) en lugar de timeouts fijos - Implementa lógica de reintentos para fallos transitorios
- Guarda el progreso periódicamente para scrapes de larga duración
- Registra logs extensivamente para depurar ejecuciones fallidas
Ética y Legalidad
- Respeta las directivas de
robots.txt - Implementa límites de velocidad para evitar sobrecargar los servidores
- No hagas scraping de datos personales sin consentimiento
- Revisa los términos de servicio antes de hacer scraping en sitios comerciales
Conclusión
Playwright proporciona todo lo necesario para el web scraping moderno: renderizado de JavaScript, intercepción de red y excelentes herramientas. Al construir clases de scraper reutilizables con manejo adecuado de errores y medidas anti-detección, puedes crear pipelines robustos de extracción de datos. Recuerda hacer scraping de manera responsable: implementa límites de velocidad, respeta robots.txt y considera el impacto en los servidores objetivo.