Skip to content

test(fase 1): contratos offline para os 7 scrapers HTTP #30

Description

@bdcdo

Sub-issue da #33 (Fase 1 do plano umbrella test: plano de testagem 2026).

Contexto

Esta issue rastreia a Fase 1 do plano unificado de testagem (consolidação das issues #3 e #4). A Fase 0 — fundação — foi entregue no commit 5a88ee4 ("Estabelecer fundação de testes com responses + pytest-mock"). Esta Fase 1 implementa contratos offline para os 7 scrapers HTTP do projeto.

A estratégia é uma adaptação da abordagem consolidada por jtrecenti/juscraper, especificamente das issues:

Estado atual (Fase 0 entregue em 5a88ee4)

Já está pronto:

  • pyproject.toml: responses>=0.25.0, pytest-mock>=3.12.0, markers (slow, integration), filterwarnings = ["error"], addopts = "-m 'not integration'".
  • tests/_helpers.py: load_sample(scraper, relative_path, encoding="utf-8") e load_sample_bytes(scraper, relative_path).
  • tests/conftest.py: fixture tests_dir.
  • tests/fixtures/capture/_util.py: dump_response(resp, path) e attach_capture_hook(session, samples_dir, prefix, extension).
  • tests/fixtures/capture/README.md: como rodar, quando re-rodar, env vars.
  • CLAUDE.md: nova seção "Testes" com convenção e checklist obrigatória de 8 itens para scrapers novos.

Comando de baseline: pytest -m "not integration" → 8/8 passando em ~1.5s (ainda só test_utils.py).

Escopo desta Fase 1

Cobrir 7 scrapers HTTP com contratos offline (responses + samples reais):

# Scraper Família Sub-PR
1 presidencia HTML simples 1A
2 camara HTML simples 1A
3 ipea HTML simples 1A
4 senado HTML especial 1B
5 cfm HTML especial 1B
6 folha JSON especial 1C
7 nyt JSON especial 1C

Fora do escopo desta issue:

  • comunicaCNJ — vai migrar para o juscraper (sistema judicial). Follow-up em issue separada no juscraper.
  • ans, anvisa, datalegis, saudelegis (Playwright) — abrir issue separada própria; responses não consegue mockar tráfego que passa pelo Chromium.
  • Granulares de utils.py e BaseScraper — Fase 2, issue própria depois desta.
  • CI workflow (tests.yml em PR + integration.yml nightly) — Fase 3, issue própria depois.

Estrutura do plano de execução

3 sub-PRs em sequência, cada um se referencia a esta issue. Cada sub-PR mergeia antes do próximo começar para deixar samples e helpers compartilhados evoluírem incrementalmente (mesmo padrão usado pelo juscraper PRs #119/#123/#142/#146).

Sub-PR 1A — HTML simples (3 scrapers)

Escopo: presidencia, camara, ipea. POST/GET HTML stateless, parser BeautifulSoup, _find_n_pags extrai número de resultados via regex.

Scraper URL Método Paginação Particularidade
presidencia legislacao.presidencia.gov.br/.../resultado_pesquisa_legislacao.php POST form offset (posicao, query_page_multiplier=10, query_page_increment=-10) SSL desabilitado (urllib3.disable_warnings); thousands separator em "1.006 resultados encontrados"
camara camara.leg.br/legislacao/busca GET 1-based (pagina) Regex de (\d+) em div.busca-info__resultado
ipea ipea.gov.br/portal/coluna-5/busca GET 1-based (pagina) h4 dentro de div.col.clearfix (frágil — sem fallback)

Entregáveis:

  • tests/{presidencia,camara,ipea}/test_raspar_contract.py (3 cenários cada: typical com paginação, single_page, no_results).
  • tests/{presidencia,camara,ipea}/samples/raspar/*.html (samples reais).
  • tests/fixtures/capture/{presidencia,camara,ipea}.py (scripts de captura).
  • Cenário extra presidencia: validar parse correto de "1.006 resultados" (thousands separator) — caso real do código em src/raspe/scrapers/presidencia.py:80-90.
  • Cenário extra ipea: caso onde div.col.clearfix não existe → DataFrame vazio (regressão para o ponto frágil em src/raspe/scrapers/ipea.py).

Sub-PR 1B — HTML com paginação especial (2 scrapers)

Escopo: senado, cfm.

Scraper URL Particularidade
senado senado.leg.br/busca Atributo data-click-type em <a>; fallbacks para estruturas dinâmicas (p[0]="Legislação" vs p[0]=descricao)
cfm portal.cfm.org.br/buscar-normas Regex "N registros encontrados" + "Mostrando página X de Y"

Entregáveis:

  • tests/{senado,cfm}/test_raspar_contract.py (3 cenários cada).
  • Samples + scripts de captura.
  • Para senado: 2 layouts diferentes do data-click-type (caso p[0]="Legislação" vs p[0]=descricao).

Sub-PR 1C — JSON com particularidades (2 scrapers)

Escopo: folha, nyt.

Scraper URL Paginação Particularidade
folha search.folha.uol.com.br/search Offset (sr=1,26,51..., query_page_multiplier=25, query_page_increment=-24) Cap 10k resultados (400 pags); valida site ∈ {todos,online,jornal} (ValidationError se inválido)
nyt api.nytimes.com/svc/search/v2 0-based (query_page_multiplier=1, query_page_increment=-1) API key obrigatória (env NYT_API_KEY); rate limit 5 req/min; MAX_PAGES=100

Entregáveis:

  • tests/{folha,nyt}/test_raspar_contract.py (3 cenários cada).
  • NYT: cenário extra APIKeyError quando env NYT_API_KEY ausente (sem HTTP, validação pré-request); matcher confirma page=0 para paginas=range(1,2); mockar time.sleep para o rate limit.
  • Folha: matcher confirma sr=26 para página 2 (mult=25, incr=-24); cenário ValidationError quando site="invalido".
  • Atualizar tests/fixtures/capture/README.md listando NYT_API_KEY como env obrigatória.

Anatomia padrão de um contrato

Template canônico (exemplo: presidencia):

# tests/presidencia/test_raspar_contract.py
import pandas as pd
import responses
from responses.matchers import urlencoded_params_matcher

import raspe
from tests._helpers import load_sample_bytes


@responses.activate
def test_raspar_retorna_dataframe_typical(mocker):
    mocker.patch("time.sleep")

    url = "https://legislacao.presidencia.gov.br/pesquisa/ajax/resultado_pesquisa_legislacao.php"
    responses.add(
        responses.POST, url,
        body=load_sample_bytes("presidencia", "raspar/page_01.html"),
        status=200,
        match=[urlencoded_params_matcher({"termo": "saúde", "posicao": "0"}, strict_match=False)],
    )
    responses.add(
        responses.POST, url,
        body=load_sample_bytes("presidencia", "raspar/page_02.html"),
        status=200,
        match=[urlencoded_params_matcher({"termo": "saúde", "posicao": "10"}, strict_match=False)],
    )

    scraper = raspe.scraper("presidencia")
    df = scraper.raspar(pesquisa="saúde", paginas=range(1, 3))

    assert isinstance(df, pd.DataFrame)
    assert {"nome", "link", "ficha", "revogacao", "descricao", "termo_busca"} <= set(df.columns)
    assert len(df) > 0


@responses.activate
def test_raspar_no_results(mocker):
    mocker.patch("time.sleep")
    # ... sample no_results.html → assert len(df) == 0


@responses.activate
def test_raspar_single_page(mocker):
    mocker.patch("time.sleep")
    # ... sample single_page.html → assert len(df) > 0 e só um request foi feito

Princípios obrigatórios (por sub-PR)

Reproduzidos da seção "Testes" no CLAUDE.md (commit 5a88ee4):

  1. Contratos validam API pública (entrada → DataFrame de saída), não estrutura interna. Sobrevivem refactor.
  2. @responses.activate decorator. Fluxos multi-step com ordem usam responses.registries.OrderedRegistry.
  3. mocker.patch("time.sleep") em toda função/classe com paginação. BaseScraper faz time.sleep(self.sleep_time) entre páginas (src/raspe/base_scraper.py:191).
  4. Matchers de payload sempre que possível:
    • urlencoded_params_matcher(..., strict_match=False) para POST form (presidencia).
    • json_params_matcher(...) para POST JSON.
    • query_param_matcher(...) para GET (camara, ipea, senado, cfm, folha, nyt).
  5. Schema validado por subset: {"col_a", "col_b"} <= set(df.columns). Nunca igualdade — DataFrame ganha colunas como termo_busca que BaseScraper.raspar injeta automaticamente.
  6. 3 cenários mínimos por método: typical (paginação), single_page, no_results.
  7. Sem @pytest.mark.integration no contrato — esses testes são offline puros.
  8. Sem dependência de rede, relógio ou TLS real.
  9. Captchas, tokens dinâmicos, lazy imports: mockar via mocker.patch.dict(sys.modules, ...) — nunca invocar.

Como capturar samples (passo-a-passo)

Cada sub-PR adiciona scripts em tests/fixtures/capture/<scraper>.py. Padrão:

# tests/fixtures/capture/presidencia.py
"""Captura samples reais do scraper Presidência.

Uso:
    python tests/fixtures/capture/presidencia.py
"""
from pathlib import Path
import requests
from tests.fixtures.capture._util import attach_capture_hook
from raspe.scrapers.presidencia import ScraperPresidencia

OUT = Path("tests/presidencia/samples/raspar")
OUT.mkdir(parents=True, exist_ok=True)

scraper = ScraperPresidencia()
detach = attach_capture_hook(scraper.session, OUT, prefix="page", extension="html")

# Cenário 1 — typical com paginação (≥2 páginas)
df = scraper.raspar(pesquisa="saúde", paginas=range(1, 3))
print(f"typical: {len(df)} linhas, colunas: {df.columns.tolist()}")

# Cenário 2 — single_page (busca específica que retorna ≤10 resultados)
# ... renomear depois manualmente: page_03.html → single_page.html

# Cenário 3 — no_results
df = scraper.raspar(pesquisa="termoextremamenteimprovavelxyz123")
print(f"no_results: {len(df)} linhas")

detach()

Após rodar, inspecionar manualmente os arquivos gerados em tests/<scraper>/samples/raspar/, renomear conforme convenção (page_01.html, single_page.html, no_results.html), e commitá-los.

Cenários por scraper:

Scraper typical (busca) single_page no_results
presidencia "saúde" (paginas=range(1,3)) "decreto 7508" "termoextremamenteimprovavelxyz123"
camara "lei orgânica" (paginas=range(1,3)) "lei 9999" "termoextremamenteimprovavelxyz123"
ipea "renda básica" (paginas=range(1,3)) termo específico "termoextremamenteimprovavelxyz123"
senado "constituição" (paginas=range(1,3)) termo específico "termoextremamenteimprovavelxyz123"
cfm "publicidade médica" (paginas=range(1,3)) termo específico "termoextremamenteimprovavelxyz123"
folha "Lula" (paginas=range(1,3)) termo específico "termoextremamenteimprovavelxyz123"
nyt "Brazil" (paginas=range(1,3)) termo específico "termoextremamenteimprovavelxyz123"

Critérios de aceitação por sub-PR

  1. pytest -m "not integration" passa offline em <5s agregado.
  2. Cada contrato cobre 3 cenários por método público.
  3. Todo POST/GET tem matcher de payload quando aplicável.
  4. Samples commitados em tests/<scraper>/samples/<endpoint>/<cenario>.<ext>.
  5. Script de captura commitado em tests/fixtures/capture/<scraper>.py.
  6. Sem src/raspe/ modificado — exceção: se contrato expor bug, abrir issue separada e marcar @pytest.mark.skip(reason="ver #N").
  7. CHANGELOG.md não atualizado — adicionar testes para scraper existente é mudança interna (ver "Versionamento e CHANGELOG" no CLAUDE.md).

Bugs prováveis a expor (precedente do juscraper)

O juscraper PR #119 (1B do juscraper) descobriu 3 bugs reais ao implementar contratos:

  • TJRS: paginação pagina_atual=0 enviava 0-based, mas backend interpretava como start=-10.
  • TJRS: aliases deprecados sobreviviam no kwargs e colidiam com canônicos.
  • TJMT/TJBA: cjsg_parse não tratava null explícito em coleções.

Esperar bugs análogos no raspe ao escrever contratos. Pontos suspeitos:

  • presidencia._find_n_pags — handler de thousands separator pode falhar em casos extremos (ex.: "1.234.567 resultados" — milhão).
  • ipea._find_n_pags — sem fallback para div.col.clearfix ausente; HTML mudou? Quebra silenciosa.
  • nyt — paginação 0-based com query_page_increment=-1: validar matcher do page no GET para paginas=range(1,2) e range(2,3).
  • folha — validação de site está em _validar_parametros? Confirmar pre-request.
  • BaseScraper._set_paginas quando n_pags=0 — devolve range(1,1) (vazio)? Cobrir explicitamente.

Bug exposto = abrir issue separada, marcar @pytest.mark.skip(reason="ver #N"), NÃO incluir fix no PR de testes (mantém revisão limpa).

Referências

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions