Cómo construir un scraper de Etsy con Scrapeless Scraping Browser: Guía completa 2026 (Node.js)
Scraping and Proxy Management Expert
Puntos Clave:
- Scrapeless Scraping Browser actúa como una poderosa infraestructura de navegador AI, superando la capa anti-bot DataDome de Etsy con huellas digitales automatizadas, proxies residenciales y solución de CAPTCHA.
- Cuatro modos de descubrimiento desde un bloque CONFIG — URL de producto, URL de categoría, búsqueda por palabra clave (con expansión opcional) y URL de tienda. Cambia las entradas, misma canalización.
- Ocho filtros estructurados (en oferta, envío gratuito, personalizable, envíos a, precio mínimo/máximo, condición, ordenar por) se componen con cualquier modo de descubrimiento que utilice URLs de búsqueda o de categoría.
- El esquema de salida cubre más de 30 campos por producto incluyendo
variaciones,migas de pan,fechaDeListado,reviews[].fotosy señales únicas de comercialización (esBestseller,esStarSeller,esEnvíoGratis,enStock,conteoDeFavoritos, sub-puntajes por reseña). - Un bucle de reintento configurable (por defecto
maxRetries: 10, retroceso creciente de 3 s → 47 s) rota a una nueva sesión y IP residual entre intentos, absorbiendo automáticamente los 403 transitorios.
Introducción: Raspando Etsy a Gran Escala Con un Navegador en la Nube Anti-Detección
Etsy es una mina de oro para la inteligencia del comercio electrónico: precios de vendedores comparables para propietarios de tiendas, corpus de entrenamiento de sentimientos para proyectos de ML y descubrimiento de nichos para dropshippers fluyen todos de las mismas páginas de listado. La API oficial de Etsy tiene acceso restringido y un largo ciclo de aprobación, los revendedores de datos de terceros son costosos y un scraper personalizado requiere mantenimiento continuo contra DataDome y cambios frecuentes en los nombres de clases CSS en el frontend de Etsy.
Esta guía describe un único archivo TypeScript construido sobre Scrapeless Scraping Browser que maneja cada parte difícil desde el principio: el navegador en la nube anti-detección, los proxies residenciales, el enriquecimiento por producto con reseñas y metadatos de tienda y la técnica de expansión de múltiples consultas que revela muchos más resultados de una sola palabra clave base de lo que normalmente permite el límite de búsqueda por cliente de Etsy. El mismo scraper soporta cuatro modos de descubrimiento independientes — alimenta una URL de producto, una URL de categoría, una búsqueda por palabra clave o una URL de tienda — y cada fila de salida lleva el mismo rico esquema de 30 campos independientemente de cómo se descubrió el listado.
Lo Que Puedes Hacer Con Esto
Los datos de Etsy son un activo versátil, impulsando aplicaciones comerciales de alto impacto que van desde la investigación de productos hasta análisis avanzados de AI. Aquí hay cinco usos comerciales del mundo real, todos alcanzables desde el mismo código base, a menudo con solo un cambio en la configuración:
- Investigación de dropshipping y búsqueda de productos — modo de búsqueda por palabra clave. Ejecuta el scraper sobre
"colgador de planta de macramé"conexpandStrategy: "keywords"a través de["boho", "moderno", "minimalista"], establecemaxProducts: 200y clasifica la salida porconteoDeFavoritos × calificación. Filtra a tiendas dondeesStarSeller: truey el conteoDeFavoritos está muy por encima de la mediana — esos son tus candidatos para dropshipping. Coloca el CSV resultante en Shopify o en una lista de proveedores privada. Esta es la razón más común por la que la gente raspa Etsy y la más rápida para convertir en ingresos. - Monitoreo de precios de competidores — modo de URL de producto (URL directa). Mantén una lista de URLs de listados de competidores en
startUrlsy ejecuta el scraper cada noche. Almacena cada instantánea JSON con su marca de tiemposcrapedAty comparaprecio,precioOriginal,porcentajeDeDescuentoyenStockentre ejecuciones. ¿Caída de precio superior al 10%? Alerta de Slack. ¿enStockcambia detrueafalse? Marca como señal de suministro. La historia de precios completa que construyes de esta manera es el núcleo de cada tablero de inteligencia de competidores. - Investigación de palabras clave y tendencias — modo de URL de categoría con filtros. Indica
categoryUrla una categoría específica de Etsy (por ejemplo,/c/bags-and-purses/wallets-and-money-clips/wallets), aplica combinaciones de filtros (filters.onSale: true,filters.condition: "nuevo",filters.orderBy: "date_desc"), extráetagsymaterialsde unos pocos cientos de listados, cuenta su frecuencia y clasifica por la suma defavoritesCounten listados que usan cada etiqueta. Las etiquetas que aparecen en listados recién creados pero NO en los más antiguos son tus sub-nichos en crecimiento. - Agregación de reseñas para ML e investigación de mercado — modo de palabra clave o categoría. Raspa
reviews[]a través de miles de listados en un vertical (velas artesanales, por ejemplo, o joyería personalizada), alimentareviews[].texta un clasificador de sentimientos y utiliza las sub-calificaciones decalidadDelProducto/envío/servicioAlClientecomo etiquetas de entrenamiento supervisado cuando están presentes. Las fotos por reseña (reviews[].photos[]) te ofrecen un corpus de imágenes paralelo si necesitas datos de entrenamiento visual. - Comparativa de rendimiento de tiendas — modo shop-URL. Apunta
shopUrla la página de la tienda de un competidor (por ejemplo,https://www.etsy.com/shop/TexasValleyLeather), establecemaxPagesPerQuery: 5para paginar su catálogo completo y el scraper enumera cada listado que esa tienda está vendiendo actualmente. Compara vendedores en la misma categoría porshop.totalSales,shop.openedYear,rating,reviewsCountyisStarSeller.
Por qué Scrapeless
Scrapeless Scraping Browser proporciona a tu scraper un navegador en la nube de calidad de producción que elude las verificaciones de DataDome de Etsy desde el principio — sin plugins de sigilo, sin ajuste de huellas digitales, sin scripts de rotación de proxies que mantener. Conéctate a través de un endpoint de WebSocket utilizando Puppeteer o Playwright y deja que la infraestructura maneje la capa anti-bot.
De serie obtienes:
- Huella digital de detección anti que se mantiene en sesiones prolongadas
- Proxies residenciales en más de 195 países (precio en EE. UU., GB, DE por separado)
- Resolución automática de CAPTCHA cuando Etsy sirve uno
- Grabación de sesiones para depurar regresiones de selectores después del hecho
- Endpoints de WebSocket que soportan marcos basados en CDP como Puppeteer y Playwright — sin SDK que aprender
- Agente de IA listo: se integra sin problemas con herramientas como el Servidor MCP de Scrapeless para otorgar a tus agentes de IA "ojos y manos" en la web.
La integración es un cambio de una línea: apunta puppeteer.connect() a una URL de Scrapeless en lugar de a un navegador local. El resto del código permanece exactamente igual — CDP estándar, selectores estándar, flujos de trabajo estándar. Toda la complejidad de DataDome vive en el lado del servidor, fuera de tu base de código.
Obtén tu clave de API en el plan gratuito en app.scrapeless.com.
Requisitos previos e instalación
Node.js 18 o superior. Una clave de API de Scrapeless (la capa gratuita cubre todo en esta guía). Un poco de familiaridad con Puppeteer ayuda. No se requiere Chrome local: el navegador funciona en la nube de Scrapeless.
bash
mkdir etsy-scrapeless-browserless && cd etsy-scrapeless-browserless
npm init -y
npm install puppeteer-core dotenv cheerio
npm install -D tsx typescript @types/node @types/cheerio
puppeteer-core impulsa el navegador en la nube; cheerio analiza el HTML renderizado del lado del servidor una vez que cada página ha terminado de cargar. Separar el desplazamiento del lado del navegador del análisis del lado de Node mantiene cada extractor tipado y puede ser probado unitariamente contra los fixtures HTML guardados.
.env:
SCRAPELESS_API_KEY=your_key_here
Paso 1 — Conectar al navegador de raspado
Un ayudante de conexión para todo el scraper. Construye una URL WSS con el token, país y TTL, luego entregasela a puppeteer.connect.
ts
import "dotenv/config";
import puppeteer, { type Browser, type Page } from "puppeteer-core";
import * as cheerio from "cheerio";
// Ayudante — extrae el HTML completo de la página y lo analiza con cheerio. El llamador
// es responsable de ejecutar cualquier desplazamiento del lado del navegador / waitForFunction
// primero para que las regiones perezosas estén hidratadas. Después de eso, el análisis permanece en Node:
// tipado, sin cuerpos de evaluación convertidos a cadenas, sin el inconveniente de `__name`, fácil de
// probar unitariamente contra los fixtures HTML guardados.
async function parseWithCheerio(page: Page): Promise<cheerio.CheerioAPI> {
const html = await page.content();
return cheerio.load(html);
}
type ScraperInput = {
proxyCountry: string; // por ejemplo, "US", "GB", "DE"
sessionTTL: number; // segundos, se permiten 60–900; 600 es un valor por defecto seguro
};
function connectionURL(sessionName: string, cfg: ScraperInput): string {
const token = process.env.SCRAPELESS_API_KEY;
if (!token) throw new Error("SCRAPELESS_API_KEY no está configurada en .env");
// Scrapeless fija la IP residencial durante toda la vida de la sesión por
// defecto, por lo que cada navegación por página dentro de un puppeteer.connect usa la
// misma IP saliente. Abrir una nueva sesión (nueva conexión) genera una nueva IP,
// que es lo que el bucle de reintentos utiliza para sortear una IP señalada.
const qs = new URLSearchParams({
token,
proxyCountry: cfg.proxyCountry,
sessionTTL: String(cfg.sessionTTL),
sessionName,
sessionRecording: "true",
// Deja que Scrapeless posea la huella digital completa de escritorio — UA, pantalla, zona horaria
// e idioma. No es necesario establecer manualmente setViewport / setUserAgent.
fingerprint: JSON.stringify({ platform: "Windows" }),
});
return `wss://browser.scrapeless.com/api/v2/browser?${qs.toString()}`;
}
async function openBrowser(sessionName: string, cfg: ScraperInput): Promise<Browser> {
return puppeteer.connect({
browserWSEndpoint: connectionURL(sessionName, cfg),
defaultViewport: null,
});
}
Eso es toda el área de superficie específica de Scrapeless: una URL WSS y una puppeteer.connect. Una cosa que vale la pena saber antes de escalar esto: una sola sesión de puppeteer.connect está vinculada a una sola IP residencial durante su vida útil (verificado al verificar api.ipify.org tres veces seguidas en la misma instancia del navegador: la misma IP cada vez). Abrir una nueva sesión asigna una nueva IP. Esa es la base sobre la que se construye el bucle de reintentos en el Paso 8: si una solicitud en la IP de esta sesión es bloqueada, cerramos la sesión, abrimos una nueva, obtenemos una nueva IP y lo intentamos de nuevo.
Scrapeless Scraping Browser posee la huella digital del navegador en la capa de conexión: UA, tamaño de pantalla, zona horaria e idioma se manejan con el parámetro de consulta fingerprint: { platform: "Windows" } en la URL WSS. No se necesitan llamadas manuales a setViewport o setUserAgent. El bucle de reintentos en el Paso 8 absorbe bloqueos transitorios.
La única configuración del lado del navegador es un stub de compatibilidad de una línea en tsx:
ts
async function prepPage(page: Page): Promise<void> {
// Instalar el helper __name inyectado por tsx para que las funciones
// de page.evaluate no se bloqueen con "__name is not defined" dentro del contexto del navegador.
await page.evaluateOnNewDocument(
"(function(){ globalThis.__name = function(f){ return f; }; })()",
);
}
Calentamiento de sesión
Antes de navegar a una página de búsqueda o tienda, el raspador carga la página de inicio de Etsy una vez para establecer una sesión de navegador válida. Sin este paso, los puntos de acceso /search y /shop devuelven 403 en una sesión fría:
ts
const ETSY_COUNTRY_PATHS: Record<string, string> = {
US: "", DE: "de/", GB: "uk/", FR: "fr/", IT: "it/", ES: "es/",
NL: "nl/", CA: "ca/", AU: "au/", JP: "jp/", IN: "in/",
};
async function warmUpSession(page: Page, proxyCountry: string): Promise<void> {
const path = ETSY_COUNTRY_PATHS[proxyCountry] ?? "";
try {
await page.goto(`https://www.etsy.com/${path}`, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
} catch {
// Un error de tiempo de espera o de red está bien: las cookies ya están configuradas.
}
await dismissEtsyConsent(page);
await delay(1500);
}
El camino específico del país es importante: un proxy DE que accede a etsy.com/de/ devuelve 200 y establece las cookies de sesión regional correctas, mientras que etsy.com/ con un proxy DE devuelve 403 y la sesión permanece bloqueada. Verificado en EE. UU. (64 listados), DE (60 listados) y GB (61 listados): los tres devuelven resultados de búsqueda en el primer intento cuando el calentamiento coincide con el país del proxy. El raspador llama a warmUpSession una vez por sesión de navegador antes de la primera llamada a collectSearchResults.
Paso 2 — Cuatro Modos de Descubrimiento
El raspador acepta cuatro maneras independientes de encontrar listados, todas en el mismo bloque CONFIG. Elige la que coincida con la pregunta ascendente y establece exactamente uno de startUrls, shopUrl, categoryUrl o searchQuery. Si se establece más de uno, la precedencia es shopUrl → categoryUrl → searchQuery → startUrls.
Modo de URL de producto (directo-URL) — listados conocidos, re-raspados nocturnos, instantáneas de competencia:
ts
const CONFIG: ScraperInput = {
startUrls: [
"https://www.etsy.com/listing/547491922/leather-walletwalletman-leather",
"https://www.etsy.com/listing/1022283131/personalized-slim-wallet-fathers-day",
],
maxProducts: 2,
// ...otros valores predeterminados
};
Modo de URL de categoría — raspados de toda la categoría con filtros estructurados:
ts
const CONFIG: ScraperInput = {
categoryUrl: "https://www.etsy.com/c/bags-and-purses/wallets-and-money-clips/wallets",
filters: {
onSale: true,
freeShipping: true,
minPrice: 20,
maxPrice: 60,
orderBy: "most_relevant",
},
maxPagesPerQuery: 2,
maxProducts: 20,
};
Modo de búsqueda por palabras clave — descubrimiento de nichos, investigación de tendencias, extracción de listados a volumen:
ts
const CONFIG: ScraperInput = {
searchQuery: "leather wallet",
expandStrategy: "keywords", // "none" | "keywords" | "prices"
expandKeywords: ["mens", "womens", "vintage"], // se agregan a la base cuando la expansión = keywords
maxProducts: 20,
};
Modo de URL de tienda — enumerar cada listado en una tienda específica para análisis de referencia / competencia:
ts
const CONFIG: ScraperInput = {
shopUrl: "https://www.etsy.com/shop/TexasValleyLeather",
maxPagesPerQuery: 5,
maxProducts: 40,
};
Los cuatro modos alimentan la misma tubería de enriquecimiento por listado en los Pasos 4–6 y emiten el mismo esquema de 30 campos en el Paso 8.
Filtros estructurados
Ocho claves de filtro opcionales se combinan con searchQuery o categoryUrl. Establece las que apliquen, deja las demás sin usar:
| Clave | Valores | Efecto |
|---|---|---|
onSale |
true |
Solo listados actualmente marcados en venta |
freeShipping |
true |
Solo listados que envían gratis al país del proxy |
customizable |
true |
Solo listados personalizables |
shipsTo |
código ISO, ej. "US" |
Debe enviar a ese país |
minPrice / maxPrice |
número | Rango de precios (filtro nativo de Etsy) |
condition |
"new" | "vintage" |
Filtro de condición de Etsy |
orderBy |
"más_relevante" | "fecha_desc" | "precio_asc" | "precio_desc" | "más_altas_reseñas" |
Ordenamiento de resultados |
Control de paginación
Establece maxPagesPerQuery: N para iterar explícitamente ?page=1..N en cada URL de descubrimiento. Sin esto, el scraper desplaza la página inicial hacia el objetivo y se detiene tan pronto como se recopilan maxProducts listados únicos. Usa paginación explícita cuando quieras barridos amplios predecibles (por ejemplo, "raspa las primeras 5 páginas de esta categoría, incluso si son más de 200 listados").
Paso 3 — Expansión de Consulta Múltiple (la solución alternativa del "límite de resultados" de Etsy)
La interfaz de consumidor de Etsy limita la paginación mucho antes de que se agoten la mayoría de los nichos, y el límite de tasa por IP se activa rápidamente bajo alto volumen de solicitudes — cualquier palabra clave única solo muestra un segmento de ranking. Para agotar un nicho, divide la consulta base a lo largo de un eje (palabras clave o rangos de precios) y elimina los duplicados por listingId.
Para "cartera de cuero", una expansión de palabra clave se ve así:
ts
function searchUrlForQuery(query: string, page = 1, priceMin?: number, priceMax?: number) {
const params = new URLSearchParams({ q: query });
if (page > 1) params.set("page", String(page));
if (priceMin !== undefined) params.set("min", String(priceMin));
if (priceMax !== undefined) params.set("max", String(priceMax));
return `https://www.etsy.com/search?${params.toString()}`;
}
type ExpandStrategy = "keywords" | "prices" | "none";
function multiQueryExpand(
base: string,
cfg: { expandStrategy: ExpandStrategy; expandKeywords: string[]; priceBuckets: [number, number][] }
) {
if (cfg.expandStrategy === "keywords") {
const queries = [base, ...cfg.expandKeywords.map((k) => `${k} ${base}`)];
return queries.map((q) => searchUrlForQuery(q));
}
if (cfg.expandStrategy === "prices") {
return cfg.priceBuckets.map(([min, max]) => searchUrlForQuery(base, 1, min, max));
}
return [searchUrlForQuery(base)];
}
["hombres", "mujeres", "vintage"] contra "cartera de cuero" produce cuatro búsquedas. Realiza estas búsquedas, recopila URLs de listados, y elimina duplicados por el ID numérico enterrado en la URL (/listing/1051861316/...). Establece maxProducts lo suficientemente alto (unas pocas docenas a unos pocos cientos) para abarcar realmente todas las variantes — si el objetivo es pequeño, el scraper se detendrá después de la primera consulta que tenga resultados, omitiendo completamente el trabajo de eliminación de duplicados.
La agrupación de precios funciona de la misma manera — diferentes grupos resaltan diferentes segmentos de ranking porque el "mejor match" de Etsy está influenciado por el precio en relación con otros en el conjunto de resultados.
Paso 4 — Recopilar URLs de Listados de Cada Búsqueda
Desplaza la barra lateral de resultados lo suficiente para activar tarjetas cargadas de manera diferida, luego captura cada enlace a[href*="/listing/"] dentro de un div.listing-link (con [data-listing-id] como respaldo cuando Etsy prueba A/B el nombre de la clase).
ts
type SearchHit = { listingId: string | null; url: string; title: string | null; rank: number };
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function collectSearchResults(page: Page, searchUrl: string, target: number, pageTimeoutMs = 60000): Promise<SearchHit[]> {
await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
await dismissEtsyConsent(page);
await delay(2000);
// Desplázate un par de veces para activar tarjetas cargadas de manera diferida. Cada paso toma una
// instantánea de cheerio del DOM actual para contar las tarjetas — en cuanto tengamos
// suficientes, dejamos de desplazarnos.
for (let i = 0; i < 6; i++) {
const $peek = await parseWithCheerio(page);
if ($peek("[data-listing-id], div.listing-link").length >= target) break;
await page.evaluate(() => window.scrollBy(0, 1200));
await delay(900);
}
// Analiza el DOM asentado con cheerio — sin `page.evaluate` de ida y vuelta,
// sin cuerpos de funciones convertidos a cadenas, solo recorrido directo de selectores.
const $ = await parseWithCheerio(page);
let cards = $("div.listing-link");
if (cards.length === 0) cards = $("[data-listing-id]");
const hits: SearchHit[] = [];
const seen = new Set<string>();
cards.each((_, card) => {
const link = $(card).find('a[href*="/listing/"]').first();
if (!link.length) return;
const href = link.attr("href") || "";
const absolute = href.startsWith("http") ? href : `https://www.etsy.com${href.startsWith("/") ? "" : "/"}${href}`;
const url = absolute.split("?")[0];
if (!url || seen.has(url)) return;
seen.add(url);
const titleEl = $(card).find("h3").first();
const title = titleEl.length ? titleEl.text().trim() : (link.attr("title") || null);
const idMatch = url.match(/\/listing\/(\d+)/);
const listingId = idMatch ? idMatch[1] : null;
hits.push({ listingId, url, title, rank: hits.length + 1 });
});
return hits;
}
El desplazamiento se mantiene en page.evaluate porque es una acción de DOM en vivo (activando la carga diferida de Etsy), pero cada pieza de análisis se ejecuta a través de cheerio en una instantánea de page.content(). Ese es el mismo patrón que utilizan todos los seis extractores de enriquecimiento en los Pasos 6–7.
La llamada dismissEtsyConsent está destinada a las sesiones fuera de EE. UU. donde Etsy muestra una puerta de "Cookies y Privacidad" antes de que se renderice la página. La función busca cualquier botón etiquetado como "Aceptar todo" / "Rechazar todo" / equivalente en algunos idiomas y hace clic en él.
Paso 5 — Navegar a Cada Listado
A diferencia de Google Maps, las URL de Etsy /listing/<id>/ renderizan el panel completo incluso en navegación directa, por lo que no se requiere un paso de clics: el scraper llama a page.goto(listingUrl) directamente. Sin embargo, DataDome devuelve HTTP 403 en una fracción significativa de IPs proxy frescas para este subárbol, por lo que el envoltorio de navegación verifica el estado de la respuesta, falla rápidamente en 403/429 y lanza una excepción si el h1 nunca aparece; cada una de esas condiciones activa el bucle de reintento externo para abrir una nueva sesión en una nueva IP residencial.
ts
const resp = await page.goto(hit.url, { waitUntil: "domcontentloaded", timeout: 60000 });
const status = resp?.status() ?? 0;
if (status === 403 || status === 429) {
throw new Error(`bloqueado: HTTP ${status} en ${hit.url}`);
}
await dismissEtsyConsent(page);
try {
await page.waitForSelector("h1", { timeout: 15000 });
} catch {
// Sin h1 después de 15 s casi siempre significa una página de desafío o redirección de DataDome.
// Lanzar una excepción para que el bucle de reintento abra una nueva sesión (= nueva IP residencial).
throw new Error(`sin h1 en ${hit.url} — probable página de desafío de bot`);
}
await delay(1500);
Luego se activa la carga diferida desplazando toda la página en fragmentos. La descripción, materiales y la sección de envío se cargan todas al desplazarse.
Paso 6 — Extraer los Campos de Vista General
El extractor tiene una estructura de dos fases que se repite en cada extractor a continuación: lado del navegador (desplazamiento + waitForFunction para hidratar regiones diferidas) → lado de Node (extraer el HTML de la página una vez a través de page.content() y luego analizarlo con cheerio). Esa división nos proporciona el comportamiento del DOM en vivo cuando lo necesitamos y el análisis del lado del servidor tipado y comprobable cuando no lo necesitamos.
ts
async function extractOverview(page: Page): Promise<Partial<EtsyProduct>> {
// Lado del navegador: desplazarse en fragmentos para que la descripción / materiales / envío
// se carguen diferidamente, luego esperar a que el buy-box se hidrate más allá del
// marcador de "Cargando".
await page.evaluate(`(function() {
var step = 500, total = document.body.scrollHeight, current = 0;
var iv = setInterval(function() {
current += step;
window.scrollTo(0, current);
if (current >= total) clearInterval(iv);
}, 200);
})()`);
await delay(3500);
try {
await page.waitForFunction(
// Esperar hasta que cualquier envoltorio de precio plausible contenga un valor numérico
// (no el marcador de "Cargando" que Etsy muestra brevemente). Ampliar la
// búsqueda más allá del envoltorio del buy-box mejora la tasa de aciertos en listados lentos
// donde el precio se representa primero en .currency-value.
`(function(){
var sels = [
"[data-selector='price-only'] span.currency-value",
"div[data-buy-box-region='price'] span.currency-value",
"p[class*='price'] span.currency-value",
"span.currency-value"
];
for (var i = 0; i < sels.length; i++) {
var el = document.querySelector(sels[i]);
if (el && /^\\s*\\$?\\d/.test((el.textContent || '').trim())) return true;
}
return false;
})()`,
{ timeout: 15000 },
);
} catch { /* El extractor todavía intenta lo mejor a continuación */ }
// Lado de Node: extraer el HTML renderizado una vez y analizar con cheerio.
const $ = await parseWithCheerio(page);
const title = $("h1").first().text().trim() || null;
// Precio — cascada de tres pasos. (1) preferir el subárbol explícito `[data-selector='price-only']`
// que Etsy marca como el precio actual; (2) recurrir al primer `span.currency-value`
// cuyos ancestros NO son envoltorios de precio original / tachado; (3) último recurso, expresión
// regular de texto del cuerpo.
const isOriginalPriceWrapper = (el: any) =>
$(el).closest("[class*='strikethrough'], [class*='original'], s, .wt-text-strikethrough").length > 0;
let price: string | null = null;
const priceOnly = $("[data-selector='price-only'] span.currency-value").first();
if (priceOnly.length && /^\d/.test(priceOnly.text().trim())) {
price = priceOnly.text().trim();
}
if (!price) {
$("span.currency-value").each((_, el) => {
if (price) return false;
if (isOriginalPriceWrapper(el)) return;
const t = $(el).text().trim();
if (t && /^\d/.test(t)) price = t;
});
}
if (!price) {
const bodyPriceMatch = $("body").text().match(/(?:Ahora\s+)?Precio:?\s*([$£€]?[\d.,]+)/i);
if (bodyPriceMatch) price = bodyPriceMatch[1].trim();
}
// Calificación — cualquier aria-label que mencione "estrella", "calificación" o "de".
let rating: number | null = null;
$("[aria-label*='estrella' i], [aria-label*='calificación' i]").each((_, n) => {
if (rating !== null) return false;
const a = $(n).attr("aria-label") || "";
const rm = a.match(/(\d+(?:\.\d+)?)\s*(?:de|estrella|calificación)/i);
if (rm) rating = parseFloat(rm[1]);
});
es
// Insignias de estado — regex del cuerpo de la página.
const bodyText = $("body").text();
const isBestseller = /Bestseller/i.test(bodyText);
const isFreeShipping = /envío gratis/i.test(bodyText);
const isStarSeller = /Vendedor Estrella/i.test(bodyText);
const inStock = !/fuera de stock|agotado/i.test(bodyText);
// Tienda lateral.
const shopLink = $("a[href*='/shop/']").first();
const shopName = shopLink.text().trim() || null;
const shopUrl = (shopLink.attr("href") || "").split("?")[0] || null;
return { title, _price_raw: price, rating, isBestseller, isFreeShipping, isStarSeller, inStock,
shop: { name: shopName, url: shopUrl } } as Partial<EtsyProduct>;
}
// El prefijo _price_raw es una convención: el procesamiento posterior de enrichProduct lo pasa a través de extractNumber y luego elimina el campo de cadena sin procesar antes de que se emita el JSON final. El fragmento está abreviado: el extractOverview completo en index.ts también extrae currency, discountPercent, reviewsCount, favoritesCount, description, materials, itemDetails, shippingFrom, processingTime, tags, listedDate y el resto de los campos de la tienda. Mismo patrón de cheerio primero en todo, solo más selectores.
Una pequeña ayuda tolerante a la moneda `extractNumber` convierte `"$24.99"`, `"24,99 €"` o `"1,234"` en un número limpio: Etsy presenta precios en el formato local dependiendo del país proxy y no quieres que tus campos numéricos sean cadenas.
## Paso 7 — Reseñas, Imágenes, Tienda Lateral, Variaciones, Migradores de Cambread, Búsquedas Relacionadas
**Reseñas.** Las tarjetas de reseñas de Etsy viven en `div[data-review-region]` (con `div[class*='review-card']` y `div[class*='review-item']` como alternativas para revisiones de DOM). Desplázate a la región de reseñas, luego mapea cada tarjeta a autor / calificación / texto / fecha más las tres sub-calificaciones.
```ts
async function extractReviews(page: Page, max: number): Promise<EtsyReview[]> {
// Lado del navegador: desplázate a la región de reseñas para que las tarjetas de reseñas perezosas se representen.
for (let i = 0; i < 8; i++) {
const found = await page.evaluate(
`!!document.querySelector('[data-reviews-section], div#reviews, div[class*="reviews"]')`,
);
if (found) break;
await page.evaluate(() => window.scrollBy(0, 700));
await delay(700);
}
await delay(1500);
// Lado del nodo: analiza la página ahora hidratada con cheerio.
const $ = await parseWithCheerio(page);
const out: EtsyReview[] = [];
$(
"div[data-review-region], div[class*='review-card'], div[class*='review-item'], li[class*='review']",
).each((_, card) => {
if (out.length >= max) return false;
const $card = $(card);
const cardText = $card.text();
// Omite contenedores agregados que contienen muchas reseñas a la vez.
if (cardText.length > 6000) return;
const author = $card.find("a[href*='/people/'], strong, p[class*='name']").first().text().trim() || null;
// Calificación: atributo `data-rating` primero, luego aria-label.
let rating: number | null = null;
const $starEl = $card.find("[aria-label*='estrella' i], [data-rating]").first();
if ($starEl.length) {
const dr = $starEl.attr("data-rating");
if (dr) rating = parseFloat(dr);
else {
const rm = ($starEl.attr("aria-label") || "").match(/(\d+(?:\.\d+)?)/);
if (rm) rating = parseFloat(rm[1]);
}
}
// Texto: selectores dedicados, luego el párrafo más largo en la tarjeta.
let text: string | null =
$card.find("p[class*='review-text'], div[class*='review-text'], div[id*='review-content']")
.first().text().trim() || null;
if (!text) {
let longest = "";
$card.find("p").each((_, p) => {
const pt = $(p).text().trim();
if (pt.length > longest.length && pt.length > 20) longest = pt;
});
text = longest || null;
}
// Fecha: elemento dedicado primero, luego expresión regular de nombre de mes o numérico.
let date: string | null =
$card.find("span[class*='date'], time, p[class*='date']").first().text().trim() || null;
if (!date) {
const dm = cardText.match(/(\w{3,9}\s+\d{1,2},?\s+\d{4}|\d{1,2}\/\d{1,2}\/\d{2,4})/);
if (dm) date = dm[1];
}
// Sub-calificaciones — filas etiquetadas dentro de la tarjeta. Coincide con la etiqueta, analiza el número adyacente.
const subRating = (label: string): number | null => {
let val: number | null = null;
$card.find("span, div, li").each((_, row) => {
if (val !== null) return false;
const t = $(row).text().toLowerCase();
if (t.includes(label) && t.length < 60) {
const sm = t.match(/(\d+(?:\.\d+)?)/);
if (sm) val = parseFloat(sm[1]);
}
});
return val;
};
// Fotos subidas por el revisor — la misma actualización `il_fullxfull` que las imágenes de listado.
const photos: string[] = [];
const photoSeen = new Set<string>();
$card.find("img[src*='etsystatic'], img[data-src*='etsystatic']").each((_, img) => {
if (photos.length >= 6) return false;
let psrc = $(img).attr("src") || $(img).attr("data-src") || "";
if (!psrc) return;
psrc = psrc.replace(/il_\d+xN/, "il_fullxfull").replace(/_\d+x\d+./, "_1024x1024.");
if (!photoSeen.has(psrc)) { photoSeen.add(psrc); photos.push(psrc); }
});
out.push({
author, rating, text, date,
itemQuality: subRating("calidad del artículo"),
shipping: subRating("envío"),
customerService: subRating("servicio al cliente"),
photos,
});
});
return out;
}
Las cuatro alternativas de selector de tarjetas cubren las revisiones A/B en curso de Etsy: `[data-review-region]` es el selector actual; `[class*='review-card']`, `[class*='review-item']` y `li[class*='review']` son variantes más antiguas y más nuevas que aún aparecen dependiendo de la cuenta y la lista. La protección de longitud de cardText en la parte superior omite elementos envolventes que coinciden accidentalmente y que devolverían un lote completo de reseñas concatenadas como uno solo.
**Imágenes.** Etsy sirve miniaturas por defecto. Actualízalas a resolución completa reemplazando el sufijo de tamaño en la URL: `il_75x75` → `il_fullxfull`, o `_300x300.jpg` → `_1024x1024.jpg`. La misma imagen, mucho mayor resolución, sin solicitudes adicionales.
```ts
async function extractImages(page: Page, max: number): Promise<string[]> {
const $ = await parseWithCheerio(page);
const urls: string[] = [];
const seen = new Set<string>();
$("img[src*='etsystatic'], img[data-src*='etsystatic']").each((_, img) => {
if (urls.length >= max) return false;
let src = $(img).attr("src") || $(img).attr("data-src") || "";
if (!src) return;
// Actualiza el sufijo de tamaño de miniatura a resolución completa cuando sea posible.
src = src.replace(/il_\d+xN/, "il_fullxfull").replace(/_\d+x\d+\./, "_1024x1024.");
if (!seen.has(src)) { seen.add(src); urls.push(src); }
});
return urls;
}
El patrón img[data-src*='etsystatic'] en el selector es importante: Etsy carga de forma diferida las miniaturas de la galería detrás de data-src y no llena src hasta que ingresan en el área visible.
Variaciones, migas de pan, búsquedas relacionadas. Tres extractores adicionales se ejecutan después de las reseñas y las imágenes, cada uno envuelto en su propio try/catch para que un selector perdido se degrade a un array vacío en lugar de romper la fila:
extractVariations(page)— extrae las opciones de tamaño / color / personalización que el vendedor expone, como una lista{name, options[]}[]. Se completa a partir de elementos<select>dentro de subárboles[data-selector*='variation'].extractBreadcrumbs(page)— captura la ruta de categoría (por ejemplo,["Página de inicio", "Bolsas y carteras", "Carteras y clips de dinero", "Carteras"]) de las etiquetas de ancla que llevanref=breadcrumb_listingen su href. Etsy no los envuelve en un<nav aria-label="breadcrumb">: son enlaces simples con un parámetro ref.extractRelatedSearches(page)— el enlace "Explorar búsquedas relacionadas" que Etsy representa al final de las páginas de listado. El extractor vuelve a desplazarse al pie de la página y espera por la sección de etiquetas cargadas de manera diferida antes de leer el texto del enlace. Etsy realiza pruebas A/B de fichas solo con imágenes (sin texto) frente a fichas etiquetadas con texto, por lo que se espera que este campo se llene en aproximadamente la mitad de los listados.
La fecha de listado y los totales a nivel de tienda se extraen dentro de extractOverview junto a los campos básicos. listedDate analiza la cadena "Listado el Lun DD, AAAA" que Etsy muestra cerca de los detalles del artículo: nota que esto refleja la fecha más reciente de reenlistado/renovación automática, no la fecha de creación original. shop.reviewsCountShop se completa solo cuando Etsy desambiguó explícitamente el número a nivel de tienda (muchos diseños de listado no la renderizan: nulo es la respuesta honesta allí).
Las fotos subidas por los revisores viven dentro de cada tarjeta de revisión. extractReviews ahora captura hasta 6 fotos por revisión mediante la misma actualización il_fullxfull utilizada para las imágenes de listado, proporcionando un corpus de imágenes paralelo para análisis visual o verificación de reseñas.
Paso 8 — Enriquecimiento Resiliente por Producto y Manejo de Errores
Raspar un listado es sencillo. Raspar cien en fila es donde comienzan a aparecer fallas transitorias: Etsy ocasionalmente sirve una caché obsoleta, el h1 no se llena, una sola solicitud de proxy se agota. Tres capas defensivas manejan esto a gran escala:
Nuevo navegador fresco por producto. Después de que se recopilan los hits de búsqueda, abre una nueva sesión de Scrapeless Scraping Browser para cada enriquecimiento. El estado no se filtra entre productos y un error a nivel de sesión no envenena el resto de la ejecución. Cada nueva sesión sortea una nueva IP residencial, por lo que cuando DataDome devuelve un 403 en una IP, el próximo intento aterriza en una diferente.
Hasta cfg.maxRetries intentos de reintento (por defecto 10) con un retroceso creciente. En una ejecución limpia, la mayoría de los productos tienen éxito en el intento 1; en una ejecución de mala IP, puede llevar de 3 a 6 intentos antes de que la sesión aterrice en una IP residencial limpia. Un alto presupuesto de reintentos es la diferencia entre una tasa de éxito del 50% y del 100%.
Taxonomía de errores categorizados. categorizeError(err) mapea cada fallo en bruto (HTTP 403/404/429, ERR_SSL_*, ERR_TUNNEL_*, h1-faltante, tiempo de espera de navegación, apretón de manos WSS) a uno de ocho valores de ScrapeErrorKind con una bandera retryable: boolean. Los errores recuperables alimentan el bucle de retroceso; los no recuperables (por ejemplo, HTTP 404 en un anuncio obsoleto) se terminan inmediatamente. Cuando se agotan todos los intentos, el producto se envía con error: { kind, message, attempts } poblado para que el código posterior pueda decir exactamente por qué una fila volvió vacía.
ts
// Dentro del bucle principal, una vez por cada resultado de búsqueda h (indexado por i):
const MAX_ATTEMPTS = cfg.maxRetries; // por defecto 10
let p: EtsyProduct | null = null;
let lastError: ScrapeErrorInfo | null = null;
let attemptsUsed = 0;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
attemptsUsed = attempt;
let eb;
try {
eb = await openBrowser(`etsy-enrich-${i}-${attempt}-${Date.now()}`, cfg);
} catch (e: any) {
lastError = categorizeError(new Error(`openBrowser falló: ${e?.message ?? e}`));
log(` intento ${attempt}/${MAX_ATTEMPTS} — ${lastError.kind}: ${lastError.message.slice(0, 120)}`);
if (!lastError.retryable) break;
if (attempt < MAX_ATTEMPTS) await delay(Math.max(cfg.retryInitialBackoffMs, 3000));
continue;
}
try {
p = await enrichProduct(eb, h, cfg);
if (p.title) { lastError = null; break; }
lastError = categorizeError(new Error(`no h1 en ${h.url} — el título era nulo`));
} catch (e: any) {
lastError = categorizeError(e);
log(` intento ${attempt}/${MAX_ATTEMPTS} — ${lastError.kind}: ${lastError.message.slice(0, 120)}`);
if (!lastError.retryable) break; // no recuperable: 404, etc. Fallar rápido.
} finally {
await eb.close().catch(() => {});
}
if (attempt < MAX_ATTEMPTS) {
// Retroceso creciente configurable. Los valores por defecto (3000, 1500, 500) producen
// 5s, 8s, 12s, 17s, 23s, 30s, 38s, 47s, 57s entre intentos.
const backoff = cfg.retryInitialBackoffMs
+ attempt * (cfg.retryBackoffLinearMs + attempt * cfg.retryBackoffQuadraticMs);
await delay(backoff);
}
}
if (!p || !p.title) {
p = emptyProduct(h.url);
p.rank = h.rank;
if (lastError) {
p.error = { kind: lastError.kind, message: lastError.message.slice(0, 200), attempts: attemptsUsed };
}
}
products.push(p);
// Pausa inter-producto (configurable) — rompe las navegaciones de anuncios consecutivas
// para que el patrón de sesión no aparezca como de bot para DataDome.
if (i < hits.length - 1) await delay(cfg.interProductDelayMs);
Algunos detalles que importan a gran escala: el nombre de la sesión incluye el índice del producto i y Date.now() para que las sesiones frescas no colisionen entre productos; openBrowser está envuelta en su propio try/catch para que un fallo en el apretón de manos WSS no omita el reintento; eb.close() se traga con .catch(() => {}) porque la sesión ya está muerta para cuando intentas cerrarla; el retroceso creciente crece lo suficientemente despacio para que los productos fáciles terminen rápido, pero los difíciles obtienen la ventana de decenas de segundos que DataDome impone a las IPs marcadas; y la pausa inter-producto reduce mediblemente la probabilidad de bloqueos correlacionados.
Los ocho tipos de error
Cada producto fallido lleva un objeto error: { kind, message, attempts }. El campo kind le dice al código posterior cómo reaccionar sin analizar el mensaje de formato libre:
kind |
Disparador | Recuperable |
|---|---|---|
blocked |
HTTP 403 o 429 — DataDome o límite de tasa | ✅ sí |
not-found |
HTTP 404 — listado eliminado o nunca existió | ❌ no (fallar rápido) |
tls |
ERR_SSL_* / ERR_CERT_* — problema transitorio del proxy |
✅ sí |
network |
ERR_TUNNEL / ERR_CONNECTION_* / ERR_ABORTED |
✅ sí |
no-h1 |
Página cargada pero <h1> nunca apareció — página de desafío suave |
✅ sí |
timeout |
El tiempo de espera de navegación excedió pageTimeoutMs |
✅ sí |
open-browser |
Falló el apretón de manos WSS a Scrapeless | ✅ sí |
unknown |
Cualquier otra cosa | ✅ sí (por defecto) |
Cada control es ajustable
Todos los valores de reintento y ritmo viven en ScraperInput — nada está codificado. Ajustalos cuando necesites un rendimiento predecible en un plan más estricto o reintentos más agresivos en un objetivo más difícil:
| campo de CONFIG | Por defecto | Rol |
|---|---|---|
maxRetries |
10 |
Total de intentos por producto antes de rendirse |
retryInitialBackoffMs |
3000 |
Base de la fórmula de retroceso creciente |
retryBackoffLinearMs |
1500 |
Término lineal |
retryBackoffQuadraticMs |
500 |
Término cuadrático |
interProductDelayMs |
3000 |
Pausa entre enriquecimientos de producto consecutivos |
pageTimeoutMs |
60000 |
Tiempo de espera page.goto |
h1TimeoutMs |
15000 |
Tiempo de espera waitForSelector("h1") |
postLoadDelayMs |
1500 |
Retraso después de que aparece h1, antes de la extracción |
Lo que obtienes de vuelta
Un objeto JSON plano por producto. Amplio a propósito, para que el mismo scraper alimente cada caso de uso posterior sin una segunda pasada.
Primer resultado real de una búsqueda de "cartera de cuero" ejecutada en esta plantilla exacta:
json
{
"listingId": "547491922",
json
{
"title": "Cartera de Cuero•Cartera•Cartera de Hombre de Cuero•Cartera Minimalista•Cartera Personalizada•Aniversario de Cuero•Cartera de Cuero Delgada•Cartera de Hombre",
"url": "https://www.etsy.com/listing/547491922/leather-walletwalletman-leather",
"rank": 1,
"price": 5.52,
"originalPrice": 68.99,
"currency": "$",
"discountPercent": 92,
"inStock": false,
"rating": 4.9,
"reviewsCount": 929,
"favoritesCount": 850,
"isBestseller": false,
"isFreeShipping": false,
"isStarSeller": true,
"tags": ["Regalos para damas de honor", "Regalos para padrinos", "Regalos de boda", "Regalos de compromiso"],
"materials": [],
"shop": {
"name": "TexasValleyLeather",
"url": "https://www.etsy.com/shop/TexasValleyLeather",
"location": null,
"totalSales": null,
"openedYear": null,
"reviewsCountShop": null
},
"images": [
"https://i.etsystatic.com/15980284/r/il/2456a5/3164786673/il_fullxfull.3164786673_roeh.jpg",
"... 4 más URLs"
],
"variations": [
{ "name": "Personalización", "options": ["Sí, añadir grabado", "No, gracias"] },
{ "name": "Opción de Color", "options": ["Castaño", "Negro", "Marrón"] }
],
"breadcrumbs": ["Página principal", "Bolsas y Monederos", "Carteras y Clips de Dinero", "Carteras"],
"relatedSearches": ["Carteras de Cuero para Hombre", "Cartera de Hombre Elegante", "Cartera Bifold de Cuero Slim Personalizada"],
"listedDate": "15 de abril de 2026",
"priceBucket": null,
"reviews": [
{
"author": "Liz",
"rating": 0,
"text": "Tal como se describió y se envió rápidamente. ¡Gracias!",
"date": "12 de abril de 2026",
"itemQuality": null,
"shipping": null,
"customerService": null,
"photos": []
},
"... 9 más reseñas"
],
"error": null,
"scrapedAt": "2026-04-16T17:09:48.919Z"
}
El mismo asistente de conexión, la taxonomía de reintentos y el patrón de sesión por objetivo se extienden por el catálogo más amplio de Scrapeless: empareja esta guía con el Servidor MCP de Scrapeless para conectar datos de Etsy directamente a la superficie de herramientas de un agente de IA, o con el resumen de los mejores agentes de IA para tener contexto sobre cómo esa tubería se conecta a flujos de trabajo de automatización más amplios.
Fija proxyCountry para que coincida con el mercado del que deseas obtener precios, mantén sessionRecording: "true" para que cualquier fila nula pueda reproducirse de extremo a extremo, considera los campos ausentes (materials, shop.location, reviews[].itemQuality) como anulables en lugar de errores de datos faltantes, y deja que la creciente pausa absorba los 403 transitorios. Ese es el manual completo.
¿Listo para construir tu tubería de datos impulsada por IA?
Únete a nuestra comunidad para reclamar un plan gratuito y conectar con desarrolladores que construyen tuberías de inteligencia para Etsy: Discord · Telegram.
Regístrate en app.scrapeless.com para obtener un tiempo de ejecución gratuito de Scraping Browser — hasta 100 horas de ejecución del navegador durante la prueba gratuita — y adapta los patrones anteriores a las categorías, tiendas y palabras clave de Etsy que tu tubería necesita.
Preguntas Frecuentes
¿Es legal hacer scraping a Etsy?
Hacer scraping de datos públicamente disponibles para monitoreo de precios e investigación es generalmente legal, siempre que respetes los Términos de Uso de Etsy y evites hacer scraping de datos personales de usuarios. Usar Scrapeless garantiza que tu actividad de scraping respete los recursos del servidor mediante un ritmo gestionado.
¿Cómo maneja Scrapeless la protección DataDome de Etsy?
A diferencia de los proxies estándar, Scrapeless gestiona toda la huella del navegador y el apretón de manos TLS. Esto hace que tu scraper sea indistinguible de un usuario real, permitiéndote eludir la sofisticada detección de bots de DataDome sin configuración manual de sigilo.
P1: ¿Es necesario un proxy para hacer scraping a Etsy?
Sí. Sin un proxy residencial, DataDome marca el tráfico de centros de datos rápidamente: la combinación de huella e IP-reputación suele caer en la cubeta de rechazo y las solicitudes de navegación directa a las páginas de /listing/ devuelven un HTTP 403 con una página de desafío en JavaScript. Scrapeless Scraping Browser incluye proxies residenciales integrados: cada sesión se enruta a través de una IP residencial diferente en el país elegido, verificado en pruebas por sesiones frescas consecutivas que devuelven IPs de salida distintas (api.ipify.org).
P2: ¿Cómo puedo ver lo que hizo el scraper en una ejecución pasada?
Cada sesión en esta plantilla establece sessionRecording: "true" en la URL WSS, por lo que Scrapeless guarda una reproducción completa estilo video de cada página que tocó el navegador en la nube — posición de desplazamiento, estado del DOM y actividad de red. Encuentra las reproducciones en app.scrapeless.com → Scraping Browser → Sessions, y coincide por el valor sessionName que el scraper registra por intento (por ejemplo, etsy-enrich-3-2-1713198231047).
Si el panel muestra "Reproducción no disponible — Por favor activa 'Grabación Web' para ver las grabaciones de la sesión", activa el interruptor de Grabación Web en la página de configuración de tu cuenta de Scrapeless. Es gratuito en todos los planes; simplemente está desactivado por defecto. Una vez habilitado, todas las futuras sesiones se graban automáticamente — las sesiones pasadas que se ejecutaron mientras la grabación estaba desactivada no se pueden recuperar retroactivamente.
Las reproducciones son la forma más rápida de depurar por qué una fila volvió con title: null. Abre la sesión, desplaza la línea de tiempo hasta el momento en que se activó page.goto, y verás si el servidor devolvió una lista real, un desafío de DataDome o una redirección de URL obsoleta.
P3: ¿Por qué a veces las reseñas se cargan a través de puntos finales internos en lugar de la página?
Las nuevas listas de Etsy cargan algunos lotes de reseñas a través de solicitudes internas POST después de que la página se ha renderizado. El scraper maneja esto desplazándose a la región de reseñas y esperando: para cuando se ejecuta el analizador, las tarjetas están en el DOM. Para productos con miles de reseñas, obtendrás las primeras ~30 (o lo que configures en maxReviews). Adentrarse más requiere interceptar el punto final GraphQL directamente, lo cual está fuera del alcance aquí.
P4: ¿Qué pasa con las redirecciones de región y moneda?
Etsy redirige por IP a versiones localizadas (etsy.de desde una IP alemana, etsy.fr desde una francesa). Los precios y las cadenas de moneda difieren por región. El asistente extractNumber del scraper maneja tanto los formatos 1,234.56 (en-US) como 1.234,56 (de-DE). Si deseas precios USD consistentes a través de ejecuciones, fija proxyCountry: "US".
P5: ¿Cómo filtro por precio, en venta, envíos gratuitos o condición?
Configura cualquier combinación de las ocho claves filters.*. Se componen con los modos searchQuery y categoryUrl y se codifican directamente en la URL de Etsy:
ts
const CONFIG: ScraperInput = {
```json
categoryUrl: "https://www.etsy.com/c/bags-and-purses/wallets-and-money-clips/wallets",
filters: {
onSale: true, // → &is_on_sale=1
freeShipping: true, // → &free_shipping=1
customizable: true, // → &is_personalizable=1
shipsTo: "US", // → &ships_to=US (código de país ISO)
minPrice: 20, // → &min=20
maxPrice: 60, // → &max=60
condition: "vintage", // "nuevo" | "vintage" (→ &explicit=vintage)
orderBy: "price_asc", // "más_relevante" | "fecha_desc" | "precio_asc" | "precio_desc" | "más_altas_reviews"
},
// ...
};
Q6: ¿Puedo ajustar los reintentos, los tiempos de espera y el ritmo?
Sí. Cada valor de reintento y ritmo es un campo CONFIG en ScraperInput:
| Campo | Predeterminado | Función |
|---|---|---|
maxRetries |
10 |
Total de intentos por producto antes de rendirse |
retryInitialBackoffMs |
3000 |
Base de la fórmula de retroceso creciente |
retryBackoffLinearMs |
1500 |
Término lineal |
retryBackoffQuadraticMs |
500 |
Término cuadrático (produce una progresión de 5 s → 57 s) |
interProductDelayMs |
3000 |
Pausa entre enriquecimientos de productos consecutivos |
pageTimeoutMs |
60000 |
Tiempo de espera para page.goto |
h1TimeoutMs |
15000 |
Tiempo de espera para waitForSelector("h1") |
postLoadDelayMs |
1500 |
Retraso después de que aparece h1, antes de la extracción |
Los planes más estrictos de Scrapeless se benefician de un menor interProductDelayMs + menor maxRetries; los objetivos de anti-bot más difíciles se benefician de valores más altos en ambos.
Q7: ¿Qué categorías de fallos puedo esperar?
Cada producto que agota los reintentos lleva un campo estructurado error: { kind, message, attempts }. Ocho tipos categorizados:
blocked— HTTP 403/429 de DataDome o limitación de tasa (reintentable)not-found— HTTP 404, listado obsoleto o eliminado (no reintentable — falla rápido)tls—ERR_SSL_*/ERR_CERT_*problema de proxy TLS (reintentable)network—ERR_TUNNEL/ERR_CONNECTION_*/ERR_ABORTED(reintentable)no-h1— página cargada pero<h1>nunca apareció, probablemente una página de desafío suave de DD (reintentable)timeout— se superó el tiempo de espera de navegación (reintentable)open-browser— el protocolo WSS para Scrapeless falló (reintentable)unknown— cualquier otra cosa (reintentable por defecto)
El código descendente puede tratar kind: "not-found" como "descartar esta URL, nunca volver a ponerla en cola" y kind: "blocked" como "intentar esta de nuevo en la próxima hora cuando se restablezca la ventana de reputación IP de DataDome".
En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.



