Extração de dados

Projetando Scrapers Idempotentes: Reiniciando Sem Duplicar

Scrapers travam. Matamos um dez vezes no meio da coleta: uma versão ingênua armazenou até 4,3 vezes mais linhas, uma idempotente exatamente 500. Como construir a segunda.

Chris Collins

Chris Collins

2 de outubro de 2026 · 10 min de leitura

Todo scraper eventualmente para no meio do caminho. Um container é reagendado, um deployment reinicia o worker, uma máquina fica sem memória, ou alguém pressiona Ctrl+C. O que acontece depois decide se seus dados continuam confiáveis. Um scraper que simplesmente começa de novo do zero busca tudo novamente e, a menos que tenha sido projetado para isso, grava tudo novamente.

Um scraper idempotente é aquele em que executar o mesmo trabalho duas vezes tem o mesmo efeito que executá-lo uma vez. Ele pode ser interrompido a qualquer momento, reiniciado, e ainda assim terminar com exatamente uma cópia correta de cada registro. Este guia mostra as quatro decisões de design que tornam isso verdade, com código testado e os resultados de matar o processo dez vezes no meio de um crawl.

Principais conclusões

  • Assuma que o processo vai morrer entre quaisquer duas linhas. Projete de modo que um reinício repita no máximo a página que estava em andamento.
  • Dê a cada registro um ID derivado do que ele é, como a fonte e o SKU, nunca de quando foi coletado. Assim, uma gravação repetida é uma atualização, não uma duplicata.
  • Mantenha a fronteira de crawl em armazenamento durável, e marque uma URL como concluída na mesma transação que armazena seu resultado.
  • Use leases em vez de locks, para que o trabalho reivindicado por um worker que travou fique disponível novamente por conta própria.
  • No nosso teste com 500 produtos, interrompido dez vezes por execução: o scraper ingênuo terminou com entre 1.286 e 2.165 linhas e até 4,3 vezes o número de requisições; o idempotente terminou com exatamente 500 linhas corretas e no máximo 3 requisições repetidas.

Por que reinícios criam duplicatas

A primeira versão comum de um scraper é assim: começar na página um, seguir a paginação, inserir uma linha para cada produto, confirmar (commit) conforme avança. Funciona perfeitamente até ser interrompido. Ao reiniciar, não há memória de até onde chegou, então ele recomeça, e cada produto já armazenado é inserido uma segunda vez com um novo ID auto-incrementado. Interrompa algumas vezes e a tabela guarda várias cópias da maioria dos produtos, sem nada que indique qual é a atual.

Deduplicar depois é possível, e resolução de entidades aborda isso, mas trata o sintoma. As duplicatas também custam dinheiro antes de custar precisão: cada página repetida é banda paga duas vezes, o que se reflete diretamente no custo por registro limpo.

Quatro decisões que tornam um scraper idempotente

1. Derive os IDs dos registros a partir dos dados

O ID de um registro deve vir do que o registro é: a fonte mais sua chave natural, como um SKU, um ID de anúncio ou uma URL canônica. Faça um hash deles juntos e o mesmo produto sempre recebe o mesmo ID, em toda execução, em toda máquina. Gravá-lo novamente se torna um upsert: se o registro existe e não mudou, nada acontece; se mudou, é atualizado e o horário da mudança é registrado.

2. Mantenha a fronteira durável

A lista de URLs a visitar, e quais já foram concluídas, pertence ao banco de dados, não à memória. Adicionar uma URL que já é conhecida deve ser uma operação sem efeito (no-op), de modo que redescobrir links em uma página de listagem já processada seja inofensivo.

3. Grave o resultado e o progresso juntos

O momento perigoso é entre armazenar um resultado e registrar que a URL foi concluída. Se o processo morrer depois de um e antes do outro, um reinício ou perde o resultado ou o repete. Fazer os dois em uma única transação de banco de dados elimina a lacuna: ou o registro é armazenado e a URL marcada como concluída, ou nenhum dos dois acontece e a URL é simplesmente buscada de novo.

4. Faça lease do trabalho em vez de travá-lo (locking)

Um worker reivindica uma URL por um tempo limitado. Se terminar, a URL é marcada como concluída. Se travar, o lease expira e outro worker, ou o mesmo reiniciado, pega a URL. Nada precisa perceber a falha para que o trabalho seja recuperado.

O código

O módulo abaixo implementa as quatro decisões com SQLite e requests. O SQLite mantém o exemplo autocontido; o mesmo design se aplica ao PostgreSQL ou a qualquer banco de dados com transações e upserts.

import hashlib
import json
import re
import sqlite3
import time

import requests

SCHEMA = """
CREATE TABLE IF NOT EXISTS frontier (
    url TEXT PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'pending',      -- pending, leased, done
    leased_until REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS records (
    record_id TEXT PRIMARY KEY,                  -- derivado da fonte e de sua chave natural
    url TEXT NOT NULL,
    content_hash TEXT NOT NULL,
    data TEXT NOT NULL,
    first_seen REAL NOT NULL,
    last_changed REAL NOT NULL
);
"""


def open_db(path):
    db = sqlite3.connect(path, isolation_level=None)  # transações explícitas abaixo
    db.execute("PRAGMA journal_mode=WAL")
    db.executescript(SCHEMA)
    return db


def record_id(source, natural_key):
    """O mesmo item sempre recebe o mesmo ID, não importa quantas vezes seja coletado."""
    return hashlib.sha256(f"{source}:{natural_key}".encode()).hexdigest()[:16]


def enqueue(db, urls):
    """Adicionar uma URL que já é conhecida é uma no-op, então redescobrir links é inofensivo."""
    db.executemany("INSERT OR IGNORE INTO frontier (url) VALUES (?)", [(u,) for u in urls])


def lease(db, lease_seconds=60):
    """Reivindica uma URL. Um lease deixado por um worker que travou expira e a URL é tentada de novo."""
    now = time.time()
    row = db.execute(
        "UPDATE frontier SET status = 'leased', leased_until = ? WHERE url = ("
        "  SELECT url FROM frontier WHERE status = 'pending' OR (status = 'leased' AND leased_until < ?) LIMIT 1"
        ") RETURNING url", (now + lease_seconds, now)).fetchone()
    return row[0] if row else None


def complete(db, url, new_urls=(), record=None):
    """Grava o resultado e marca a URL como concluída em uma transação: ou ambos acontecem, ou nenhum."""
    db.execute("BEGIN IMMEDIATE")
    try:
        enqueue(db, new_urls)
        if record:
            now = time.time()
            payload = json.dumps(record["data"], sort_keys=True)
            digest = hashlib.sha256(payload.encode()).hexdigest()
            db.execute(
                "INSERT INTO records (record_id, url, content_hash, data, first_seen, last_changed) VALUES (?, ?, ?, ?, ?, ?) "
                "ON CONFLICT(record_id) DO UPDATE SET data = excluded.data, content_hash = excluded.content_hash, "
                "last_changed = excluded.last_changed WHERE records.content_hash != excluded.content_hash",
                (record["id"], url, digest, payload, now, now))
        db.execute("UPDATE frontier SET status = 'done' WHERE url = ?", (url,))
        db.execute("COMMIT")
    except Exception:
        db.execute("ROLLBACK")
        raise


def crawl(db, base, session=None, lease_seconds=60):
    session = session or requests.Session()
    enqueue(db, [f"{base}/list/0"])
    while True:
        url = lease(db, lease_seconds)
        if url is None:
            if db.execute("SELECT 1 FROM frontier WHERE status != 'done' LIMIT 1").fetchone():
                time.sleep(1)  # um lease ainda está retido, talvez por um worker que travou; espere expirar
                continue
            return
        html = session.get(url, timeout=30).text
        if "/list/" in url:
            links = [base + href for href in re.findall(r'href="(/(?:product|list)/\d+)"', html)]
            complete(db, url, new_urls=links)
        else:
            sku = re.search(r'class="sku">([^<]+)<', html).group(1)
            price = re.search(r'class="price">([^<]+)<', html).group(1)
            complete(db, url, record={"id": record_id("example-shop", sku), "data": {"sku": sku, "price": price}})

A função crawl é específica do nosso site de teste, com 25 páginas de listagem ligando a 500 páginas de produto. Tudo acima dela é reutilizável. Dois detalhes são fáceis de passar despercebidos:

  • O upsert só grava quando o conteúdo mudou. A cláusula WHERE na atualização de conflito deixa um registro inalterado em paz, de modo que last_changed significa o que diz e pode orientar a detecção de mudanças.
  • O loop não para em uma fila vazia. Nossa primeira versão terminava quando nenhuma URL podia ser arrendada (lease), o que deixava uma página inacabada sempre que uma falha tivesse deixado um lease pendente. O loop agora espera até que toda URL esteja concluída.

Interrompendo dez vezes

Executamos um site de teste local com 25 páginas de listagem e 500 páginas de produto, então um crawl completo precisa de exatamente 525 requisições. Cada execução iniciava o scraper, o interrompia com SIGKILL em um momento aleatório entre 0,2 e 1 segundo, e fazia isso dez vezes antes de deixá-lo terminar. Executamos o scraper idempotente acima e o ingênuo descrito anteriormente, cinco vezes cada um, com os mesmos tempos de interrupção, e um lease de 3 segundos para a versão idempotente.

ExecuçãoIngênuo: linhas armazenadasIngênuo: requisiçõesIdempotente: linhas armazenadasIdempotente: requisições
12.1652.283500525
21.8751.977500526
31.8621.967500526
41.4551.538500526
51.2861.361500528

Ambas as versões eventualmente armazenaram todos os 500 produtos, porque cada uma terminou sua última execução. A diferença está em tudo o mais. O scraper ingênuo armazenou entre 2,6 e 4,3 linhas por produto e fez entre 2,6 e 4,3 vezes o número de requisições necessárias. O idempotente armazenou exatamente uma linha por produto, com todos os valores correspondendo à página de origem, e repetiu no máximo três requisições ao longo de dez interrupções: uma para cada vez que a interrupção aconteceu enquanto uma página estava em andamento. Ele também terminou mais rápido em todas as execuções, entre 6,8 e 8,3 segundos contra 9,2 a 10,8, mesmo tendo às vezes esperado o lease de um worker travado expirar.

Além do banco de dados

Registros não são a única coisa que um scraper faz mais de uma vez. O mesmo princípio se aplica a todo efeito colateral:

  • Arquivos. Nomeie imagens e documentos baixados por um hash de seu conteúdo ou do ID do registro, de modo que um download repetido sobrescreva em vez de adicionar uma cópia.
  • Mensagens e webhooks. Envie uma chave de idempotência derivada do registro e de sua versão, para que um consumidor possa ignorar uma mensagem que já processou.
  • Contadores e agregados. Recalcule-os a partir dos registros armazenados em vez de incrementá-los a cada busca, ou um reinício vai inflá-los.
  • Respostas brutas. Se você arquiva respostas brutas, uma busca repetida adiciona uma segunda captura, o que é inofensivo e até útil, desde que os registros extraídos permaneçam idempotentes.

Regras práticas

  • Escolha a chave natural com cuidado. Ela precisa ser estável na fonte: um SKU ou ID de anúncio, não uma posição na página ou uma URL que carrega parâmetros de rastreamento.
  • Torne as tentativas seguras primeiro, depois frequentes. Uma vez que as gravações sejam idempotentes, retry e backoff podem ser generosos sem poluir os dados.
  • Dimensione os leases para a página mais lenta. Um lease mais curto do que uma busca lenta permite que dois workers processem a mesma URL; inofensivo aqui, mas é banda desperdiçada.
  • Observe a taxa de repetição. Requisições por registro armazenado é uma métrica de saúde barata; um aumento indica falhas ou problemas de lease, e pertence junto das demais no monitoramento de um pipeline de scraping.

Conclusão

Um scraper que não pode ser reiniciado com segurança eventualmente corrompe seus próprios dados, e fará isso silenciosamente. A solução não é uma operação mais cuidadosa, mas um design que torna os reinícios enfadonhos: IDs derivados dos dados, uma fronteira durável, resultados e progresso confirmados juntos, e leases que expiram.

No nosso teste, essas quatro decisões transformaram dez falhas, que poderiam chegar a 4,3 vezes o número de linhas e requisições, em exatamente os 500 registros corretos e três requisições repetidas.

Fontes e referências

  • Documentação do SQLite, UPSERT e RETURNING.
  • Teste de interrupção executado pela Shifter em 2 de outubro de 2026 contra um site de teste local, usando o código acima.

Pronto para começar?

Experimente os proxies residenciais da Shifter, mais de 205M IPs, mais de 195 países, a partir de $ 0,75/GB.

Começar