O pior tipo de falha em scraping não parece uma falha. O job roda, as requisições têm sucesso, as linhas chegam ao data warehouse no horário previsto. Então alguém do financeiro pergunta por que o preço médio triplicou da noite para o dia, ou por que metade do catálogo está de repente fora de estoque, e a investigação leva de volta a um redesign do site feito três semanas antes que ninguém notou.
Isso é drift de schema: os dados continuam fluindo, mas seu formato ou significado mudou silenciosamente. É o modo de falha normal dos dados da web, porque as fontes mudam sem aviso e os extratores continuam produzindo saída. Este guia cobre as duas camadas que detectam isso: contratos no nível de registro que rejeitam valores ruins, e perfis no nível de lote que percebem quando os dados como um todo deixam de se parecer consigo mesmos. Ele também mostra, com um pequeno teste, por que você precisa de ambos.
Principais conclusões
- O drift costuma ser silencioso. Uma página alterada raramente quebra o extrator; ela faz o extrator retornar algo plausível e errado.
- Contratos no nível de registro verificam cada registro segundo regras: campos obrigatórios, tipos, intervalos e formatos. Eles pegam as quebras óbvias.
- Perfis no nível de lote comparam cada execução com uma linha de base: taxas de preenchimento, mix de tipos e medianas. Eles pegam as quebras que os contratos não conseguem ver.
- No nosso teste com três drifts realistas, os contratos pegaram um. Os outros dois passaram em todas as verificações no nível de registro e só foram detectados pelo perfil de lote.
- Alerte sobre o drift antes que os dados sejam enviados, coloque o lote em quarentena e registre qual site e qual versão do extrator o produziram.
Como o drift realmente acontece
| Causa | O que os dados fazem |
|---|---|
| Redesign muda a marcação | Um seletor corresponde a um elemento diferente, ou a nenhum |
| Formato de preço muda | ”9.99” vira “€9.99”, “9,99” ou 999 em unidades menores |
| Um campo passa a depender de JavaScript | O HTML estático deixa de contê-lo, então ele volta vazio |
| Localização ou variante geográfica | Uma moeda, idioma ou unidade diferente aparece em algumas páginas |
| Teste A/B | Uma fração das páginas usa um novo layout, então uma fração dos registros quebra |
| Página de bloqueio ou desafio | A página carrega, o extrator roda, e retorna nada ou lixo |
O ponto em comum é que nenhum desses casos gera uma exceção. O último, páginas que carregam com sucesso mas não são a página que você queria, é abordado em a taxa de falha silenciosa. O resto é o extrator processando fielmente uma página que mudou por baixo dele.
Camada 1: contratos no nível de registro
Um contrato de dados define como é um registro válido: quais campos são obrigatórios, seus tipos, seus intervalos e formatos permitidos. Cada registro é verificado antes de ser aceito, e as violações são contadas e colocadas em quarentena em vez de armazenadas silenciosamente.
import re
import statistics
from collections import Counter
CONTRACT = {
"name": {"type": str, "required": True},
"price": {"type": float, "required": True, "min": 0.01, "max": 100_000},
"currency": {"type": str, "required": True, "pattern": r"^[A-Z]{3}$"},
"in_stock": {"type": bool, "required": False},
}
def violations(record, contract=CONTRACT):
"""Record-level checks: presence, type, range, format."""
problems = []
for field, rule in contract.items():
value = record.get(field)
if value in (None, ""):
if rule.get("required"):
problems.append(f"{field}: missing")
continue
if rule["type"] is float and isinstance(value, int) and not isinstance(value, bool):
value = float(value)
if not isinstance(value, rule["type"]):
problems.append(f"{field}: expected {rule['type'].__name__}, got {type(value).__name__}")
continue
if "min" in rule and value < rule["min"] or "max" in rule and value > rule["max"]:
problems.append(f"{field}: {value} out of range")
if "pattern" in rule and not re.match(rule["pattern"], value):
problems.append(f"{field}: {value!r} bad format")
return problems
Um registro como {"name": "x", "price": "€9.99", "currency": "eur"} falha duas vezes: o preço é uma string, e a moeda não é um código de três letras maiúsculas. Contratos são baratos, explícitos e fáceis de raciocinar. Seu limite é que cada registro é julgado isoladamente, e muito drift produz registros que são individualmente válidos.
Camada 2: perfis no nível de lote
Um perfil resume um lote inteiro: para cada campo, com que frequência ele é preenchido, quais tipos aparecem e, para números, a mediana. Comparar o perfil de cada execução com uma linha de base de execuções saudáveis recentes mostra quando os dados como um todo mudam de forma, mesmo que cada registro passe em seu contrato.
def profile(records, fields=CONTRACT):
"""Batch-level shape: how often each field is filled, its types, and numeric medians."""
n = max(1, len(records))
out = {}
for field in fields:
values = [r.get(field) for r in records]
present = [v for v in values if v not in (None, "")]
numbers = [float(v) for v in present if isinstance(v, (int, float)) and not isinstance(v, bool)]
out[field] = {
"fill_rate": len(present) / n,
"types": Counter(type(v).__name__ for v in present),
"median": statistics.median(numbers) if numbers else None,
"distinct": len(set(map(str, present))),
}
return out
def drift(baseline, current, fill_drop=0.1, median_ratio=3.0):
"""Compare two batch profiles and describe what changed shape."""
alerts = []
for field, base in baseline.items():
cur = current[field]
if base["fill_rate"] - cur["fill_rate"] > fill_drop:
alerts.append(f"{field}: filled {base['fill_rate']:.0%} -> {cur['fill_rate']:.0%}")
if set(cur["types"]) - set(base["types"]):
alerts.append(f"{field}: new types {sorted(set(cur['types']) - set(base['types']))}")
if base["median"] and cur["median"]:
ratio = cur["median"] / base["median"]
if ratio > median_ratio or ratio < 1 / median_ratio:
alerts.append(f"{field}: median {base['median']:g} -> {cur['median']:g}")
return alerts
Os limiares são pontos de partida. Uma queda de 10 pontos na taxa de preenchimento ou uma mudança de três vezes em uma mediana raramente é normalidade; ajuste ambos por campo depois de ter algumas semanas de histórico.
Por que você precisa de ambos: um pequeno teste
Geramos uma linha de base saudável de 1.000 registros de produtos, depois simulamos três quebras realistas e rodamos as duas camadas em cada uma.
| Drift | O que aconteceu | Contrato no nível de registro | Perfil de lote |
|---|---|---|---|
| Preço formatado | Um redesign colocou o símbolo da moeda dentro do preço em 30% das páginas | Detectado: 300 registros rejeitados | Detectado: novo tipo str no preço |
| Unidades menores | Preços começaram a chegar como 6305 em vez de 63.05 | Não detectado: todos os registros passaram | Detectado: mediana de 63.05 para 6305, e um novo tipo int |
| Seletor de estoque quebrado | O campo in-stock voltou vazio em 80% das páginas | Não detectado: o campo é opcional | Detectado: preenchimento de 100% para 20% |
O contrato pegou o único drift que produziu valores obviamente malformados. Os outros dois produziram registros que eram individualmente válidos, um número positivo dentro do intervalo e um campo opcional deixado vazio, e só a visão em lote mostrou que algo havia mudado. O caso das unidades menores não é hipotético: um endpoint de produto real que examinamos na semana passada retornou 10000 para um sapato de $100.00, como descrito em pare de fazer parsing de HTML.
O que fazer quando o drift dispara
- Coloque o lote em quarentena. Não envie dados de um lote com alertas de drift até que alguém tenha analisado. Um conjunto de dados atrasado é melhor do que um errado.
- Localize o escopo. Divida o alerta por site, tipo de página e versão do extrator. O drift geralmente começa em um site após uma mudança.
- Compare uma amostra. Observe um punhado de registros afetados ao lado das páginas de onde vieram. A causa geralmente é óbvia em poucos minutos.
- Corrija e faça o backfill. Atualize o extrator, depois reextraia o período afetado a partir das páginas armazenadas, se você as mantiver, ou recolete se não mantiver.
- Atualize a linha de base deliberadamente. Quando uma mudança é legítima, como um site genuinamente mudando de moeda, redefina a linha de base de propósito, nunca automaticamente.
Torne o drift menos provável desde o início
Algumas fontes sofrem menos drift do que outras. Dados estruturados incorporados para mecanismos de busca mudam com muito menos frequência do que o layout da página, e é por isso que extraí-los primeiro reduz a frequência com que os contratos disparam. Observar as próprias páginas também ajuda: uma mudança de template detectada por detecção de mudanças em escala é um aviso antecipado de que a extração pode estar prestes a quebrar, e uma taxa crescente de violações de contrato em um site é um insumo forte para uma pontuação de saúde do alvo. Coletar de forma consistente de um único mercado por job também elimina toda uma classe de drift causada por variantes geográficas e de moeda.
A conclusão
Os dados da web sofrem drift porque a web muda sem pedir permissão. O perigo não é que os extratores quebrem, mas que eles continuem funcionando em páginas que já não são o que eram quando foram construídos, e produzam dados que parecem corretos quando analisados registro por registro.
Verifique cada registro contra um contrato, e verifique cada lote contra seu próprio histórico recente. No nosso teste, o contrato sozinho pegou um drift em três; o perfil de lote pegou os três. Juntos, eles transformam “o painel parecia estranho por três semanas” em um alerta na manhã em que o problema começou.
Fontes e referências
- Teste conduzido pela Shifter em 29 de setembro de 2026 com o código acima, em 1.000 registros de produtos gerados e três drifts simulados.