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
WHEREna atualização de conflito deixa um registro inalterado em paz, de modo quelast_changedsignifica 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ção | Ingênuo: linhas armazenadas | Ingênuo: requisições | Idempotente: linhas armazenadas | Idempotente: requisições |
|---|---|---|---|---|
| 1 | 2.165 | 2.283 | 500 | 525 |
| 2 | 1.875 | 1.977 | 500 | 526 |
| 3 | 1.862 | 1.967 | 500 | 526 |
| 4 | 1.455 | 1.538 | 500 | 526 |
| 5 | 1.286 | 1.361 | 500 | 528 |
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.