Buscar uma página com uma API de web scraping é uma única linha de código. Buscar cinquenta mil páginas toda noite, sem ultrapassar os limites do seu plano, sem perder trabalho quando o processo trava ou sem descartar falhas silenciosamente, é um problema de engenharia. Node.js é uma boa escolha para isso: o trabalho é quase todo espera pela rede, que é exatamente o que o event loop do Node faz bem.
Este guia constrói um pequeno pipeline com estrutura de produção em Node.js sobre a Web Scraping API da Shifter: concorrência limitada, retentativas para os erros certos, saída retomável e um log de erros separado. O todo tem cerca de sessenta linhas sem dependências, e testamos contra um mock da API. Se você ainda não fez sua primeira requisição, comece pelo nosso guia para iniciantes de uma web scraping API.
A estrutura do pipeline
Um pipeline de scraping tem quatro funções:
- Uma fila de URLs para buscar.
- Workers que pegam URLs da fila e chamam a API, um número fixo de cada vez.
- Tratamento de erros que tenta novamente o que pode dar certo depois e registra o que não pode.
- Uma saída que sobrevive a travamentos e permite que uma nova execução pule o trabalho já concluído.
A API cuida de proxies, navegadores e algumas retentativas do seu lado. O trabalho do pipeline é alimentá-la no ritmo certo e manter um registro confiável do que aconteceu.
Concorrência: workers, não Promise.all sobre tudo
O erro mais comum em Node.js é mapear cada URL para uma requisição e usar await Promise.all(...) sobre o conjunto todo. Com cinquenta mil URLs, isso dispara cinquenta mil requisições de uma vez. A API responde a maioria delas com 429 Too Many Requests, porque todo plano tem um limite de concorrência (na Shifter, de 20 a 500 requisições simultâneas dependendo do plano).
A solução é um pool fixo de workers compartilhando uma fila. Cada worker pega a próxima URL, processa, e volta para pegar outra, de modo que exatamente concurrency requisições estão em andamento a qualquer momento. Nenhuma biblioteca é necessária; um array compartilhado e um loop resolvem isso.
Retentativas: apenas para erros que podem dar certo depois
Nem todo erro merece uma nova tentativa. Um timeout, uma renderização falha, um 429 ou um erro de servidor podem dar certo na próxima tentativa. Uma chave de API inválida ou uma requisição malformada vai falhar para sempre, e tentar de novo só desperdiça tempo. O pipeline tenta novamente 408, 422, 429 e 500 com uma pausa que cresce a cada tentativa, e registra tudo o mais como falha imediatamente. Nosso guia sobre retry e backoff para web scrapers explica por que a pausa deve crescer.
Saída: somente anexação e retomável
Cada resultado bem-sucedido é anexado a um arquivo JSON delimitado por quebra de linha (NDJSON) assim que chega: uma linha por URL com os dados e um timestamp. Isso tem três vantagens:
- Um travamento perde no máximo as requisições em andamento. Tudo o que já foi escrito permanece.
- Novas execuções são baratas. No início, o pipeline lê o arquivo de saída e pula qualquer URL já presente, então você pode reiniciar uma execução falha sem pagar pelas mesmas páginas duas vezes.
- Falhas são mantidas separadamente em um arquivo
.errors, que você pode revisar ou realimentar na próxima execução.
Projetar para que uma nova execução produza o mesmo resultado é a ideia central por trás dos scrapers idempotentes, que cobre isso com mais profundidade.
O código
import { appendFile, readFile } from 'node:fs/promises';
const API = process.env.SCRAPER_API_URL ?? 'https://scrape.shifter.io/v1';
const RETRY = new Set([408, 422, 429, 500]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fetch one URL through the Web Scraping API, retrying only errors that can succeed later.
export async function fetchPage(url, { extractRules, attempts = 3, backoffMs = 2000 } = {}) {
const params = new URLSearchParams({ api_key: process.env.SHIFTER_SCRAPER_KEY, url });
if (extractRules) params.set('extract_rules', JSON.stringify(extractRules));
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${API}?${params}`, { signal: AbortSignal.timeout(120_000) });
if (res.ok) return extractRules ? res.json() : res.text();
if (!RETRY.has(res.status) || attempt === attempts) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
await sleep(backoffMs * attempt);
}
}
// URLs already written to the output file, so a restarted run skips them.
async function alreadyDone(outFile) {
try {
const lines = (await readFile(outFile, 'utf8')).split('\n').filter(Boolean);
return new Set(lines.map((line) => JSON.parse(line).url));
} catch {
return new Set();
}
}
// Run `concurrency` workers over the queue; each result becomes one NDJSON line.
export async function runPipeline(urls, { outFile, concurrency = 10, extractRules, backoffMs } = {}) {
const done = await alreadyDone(outFile);
const queue = urls.filter((u) => !done.has(u));
const stats = { skipped: urls.length - queue.length, ok: 0, failed: 0 };
async function worker() {
for (let url = queue.shift(); url !== undefined; url = queue.shift()) {
try {
const data = await fetchPage(url, { extractRules, backoffMs });
await appendFile(outFile, JSON.stringify({ url, data, at: new Date().toISOString() }) + '\n');
stats.ok++;
} catch (err) {
await appendFile(`${outFile}.errors`, JSON.stringify({ url, error: err.message }) + '\n');
stats.failed++;
}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return stats;
}
Ele usa apenas o fetch embutido do Node, AbortSignal.timeout e fs/promises, então roda em qualquer versão atual do Node.js sem instalar nada.
Executando isso em uma lista de páginas de produtos, pedindo à API JSON em vez de HTML:
import { readFile } from 'node:fs/promises';
import { runPipeline } from './pipeline.mjs';
const urls = (await readFile('urls.txt', 'utf8')).split('\n').filter(Boolean);
const stats = await runPipeline(urls, {
outFile: 'products.ndjson',
concurrency: 20, // stay under your plan's concurrency limit
extractRules: {
name: { selector: 'h1', output: 'text' },
price: { selector: '.price', output: 'text' },
},
});
console.log(stats); // { skipped, ok, failed }
Como testamos
Executamos o pipeline contra um substituto local da API que servia 22 URLs: vinte páginas normais, uma que falhou duas vezes com 429 antes de ter sucesso, e uma que sempre retornava 401. O teste verificou que:
- 21 páginas tiveram sucesso e uma falha foi registrada no arquivo de erros;
- a página instável foi tentada novamente e teve sucesso na terceira tentativa, enquanto a
401foi tentada apenas uma vez; - nunca mais de cinco requisições estiveram em andamento com
concurrency: 5; - uma segunda execução pulou todas as 21 páginas concluídas e tentou novamente apenas a falha.
Escalando ainda mais
O design de processo único acima lida confortavelmente com dezenas de milhares de páginas por execução. Além disso:
- Tire a fila da memória. Uma tabela de banco de dados ou uma fila de mensagens permite que vários processos compartilhem o trabalho e sobrevivam a reinicializações de toda a máquina.
- Carregue os resultados em um banco de dados assim que chegam. NDJSON é um bom formato de destino intermediário; nosso guia sobre mover dados de uma web scraping API para SQL cobre a etapa de carregamento.
- Aplique backpressure. Se a etapa após a busca for mais lenta que a busca em si, deixe que ela desacelere os workers em vez de permitir que os resultados se acumulem na memória. Veja backpressure e controle de fluxo em crawlers distribuídos.
- Monitore os números. Acompanhe sucessos, falhas e retentativas por execução, e alerte quando a taxa de falha mudar. Nosso guia sobre monitorar um pipeline de scraping lista as métricas que importam.
Perguntas frequentes
Node.js é bom para pipelines de web scraping?
Sim, para pipelines construídos sobre uma API. O trabalho é majoritariamente espera por respostas de rede, e o Node.js lida com muitas requisições simultâneas em um único processo com pouco overhead. Análise sintática intensiva em CPU é melhor feita pelas regras de extração da API ou em uma etapa separada.
Quantas requisições simultâneas um scraper em Node.js deve fazer?
Tantas quantas o seu plano de scraping API permitir, e não mais. Use um pool fixo de workers ajustado a esse limite, e trate respostas 429 como um sinal para reduzir o ritmo.
Como torno um job de scraping retomável?
Escreva cada resultado assim que ele chegar em um arquivo ou tabela somente para anexação indexado por URL, e, ao iniciar, pule qualquer URL que já tenha um resultado. Um travamento então custa apenas as requisições que estavam em andamento.
Preciso de uma biblioteca de fila?
Não para um único processo. Um array compartilhado com um número fixo de workers assíncronos é suficiente. Migre para um banco de dados ou fila de mensagens quando vários processos ou máquinas precisarem compartilhar o trabalho.
Resumo final
Um pipeline de scraping escalável em Node.js precisa de quatro coisas: um pool fixo de workers dimensionado para a concorrência do seu plano, retentativas apenas para erros que podem dar certo depois, saída somente para anexação da qual uma nova execução pode retomar, e um registro separado de falhas. O código acima faz as quatro coisas em cerca de sessenta linhas. A API cuida de proxies e navegadores; o trabalho do seu pipeline é alimentá-la de forma constante e nunca perder o controle do que aconteceu.