From da95dbaf61f9fc7566b68d4d6ef34a27a9aa5325 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:38:05 +0000 Subject: [PATCH] =?UTF-8?q?Escopo:=20o=20Argos=20=C3=A9=20dos=20tribunais?= =?UTF-8?q?=20superiores=20e=20do=20CNJ,=20para=20o=20AtlasPen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O que era do observatório do Carandiru sai daqui e passa a morar no próprio repositório dele, com motor próprio (luccas-amorim/observatory#1): adaptadores wayback, openalex e gdelt, catálogo CSV, modo sem texto, estimativa de cobertura (argos estimar) e docs/carandiru.md. Fixtures e testes que usavam o Carandiru como exemplo passam a usar notícia de tribunal (tema fictício) e um léxico penal de amostra. README reescrito: vigilância dos tribunais superiores e do CNJ para o AtlasPen, que é lido e nunca editado; precatórios e planilhas como expansão em estudo, morando aqui. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nztke5ztyPC5aFNL3kqbsB --- README.md | 259 ++++++++++------------ argos/captura.py | 11 +- argos/cli.py | 38 +--- argos/config.py | 14 -- argos/estimativa.py | 216 ------------------ argos/fontes/__init__.py | 6 - argos/fontes/gdelt.py | 108 --------- argos/fontes/openalex.py | 131 ----------- argos/fontes/rss.py | 2 +- argos/fontes/wayback.py | 154 ------------- argos/rodada.py | 2 +- argos/saidas/catalogo.py | 64 ------ docs/carandiru.md | 13 -- docs/precatorios.md | 27 ++- pyproject.toml | 2 +- tests/apoio.py | 4 +- tests/conftest.py | 12 +- tests/fixtures/LEIAME.md | 14 +- tests/fixtures/gdelt/dia.json | 24 -- tests/fixtures/lexico-carandiru.json | 30 --- tests/fixtures/lexico-penal.json | 30 +++ tests/fixtures/openalex/p1.json | 92 -------- tests/fixtures/openalex/p2.json | 1 - tests/fixtures/rss/atom.xml | 2 +- tests/fixtures/rss/erro-200.html | 2 +- tests/fixtures/rss/feed.xml | 24 +- tests/fixtures/rss/materia-carandiru.html | 27 --- tests/fixtures/rss/materia-outra.html | 4 - tests/fixtures/rss/noticia-outra.html | 4 + tests/fixtures/rss/noticia-tese.html | 23 ++ tests/fixtures/wayback/captura.html | 8 - tests/fixtures/wayback/cdx-p1.json | 27 --- tests/fixtures/wayback/cdx-p2.json | 16 -- tests/fixtures/wayback/nao-arquivada.html | 1 - tests/test_catalogo.py | 61 ----- tests/test_descoberta.py | 127 ----------- tests/test_estimativa.py | 99 --------- tests/test_fontes.py | 39 ++-- tests/test_rodada.py | 98 ++++---- tests/test_texto.py | 16 +- tests/test_triagem.py | 42 ++-- 41 files changed, 323 insertions(+), 1551 deletions(-) delete mode 100644 argos/estimativa.py delete mode 100644 argos/fontes/gdelt.py delete mode 100644 argos/fontes/openalex.py delete mode 100644 argos/fontes/wayback.py delete mode 100644 argos/saidas/catalogo.py delete mode 100644 docs/carandiru.md delete mode 100644 tests/fixtures/gdelt/dia.json delete mode 100644 tests/fixtures/lexico-carandiru.json create mode 100644 tests/fixtures/lexico-penal.json delete mode 100644 tests/fixtures/openalex/p1.json delete mode 100644 tests/fixtures/openalex/p2.json delete mode 100644 tests/fixtures/rss/materia-carandiru.html delete mode 100644 tests/fixtures/rss/materia-outra.html create mode 100644 tests/fixtures/rss/noticia-outra.html create mode 100644 tests/fixtures/rss/noticia-tese.html delete mode 100644 tests/fixtures/wayback/captura.html delete mode 100644 tests/fixtures/wayback/cdx-p1.json delete mode 100644 tests/fixtures/wayback/cdx-p2.json delete mode 100644 tests/fixtures/wayback/nao-arquivada.html delete mode 100644 tests/test_catalogo.py delete mode 100644 tests/test_descoberta.py delete mode 100644 tests/test_estimativa.py diff --git a/README.md b/README.md index c6c4532..e6fcc64 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,48 @@ O nome vem de Argos Panoptes. [Por quê?](MITO.md) -**Vigilância determinística de fontes abertas, com léxico como dado e descarte auditável.** +**Vigilância determinística dos tribunais superiores e do CNJ para o AtlasPen, com léxico +como dado e descarte auditável.** -Um motor que olha, em intervalo fixo, um conjunto de fontes na internet (tribunais, -diários oficiais, imprensa, repositórios acadêmicos, arquivos da web), baixa o texto -integral do que encontra, confronta com um vocabulário declarado em arquivo e separa o -que merece leitura humana do que não merece. O que ele descarta sai **nomeado, com o -motivo**. O que ele aceita sai com **proveniência**: URL, data de captura, hash do texto. +O Argos olha, em intervalo fixo, o que os tribunais superiores e o CNJ publicam, baixa o +texto integral do que encontra, confronta com um vocabulário gerado do catálogo do +[AtlasPen](https://github.com/luccas-amorim/atlaspen) e separa o que merece leitura +humana do que não merece: a decisão que muda o entendimento de um tipo penal, a tese de +repercussão geral, o recurso repetitivo, a súmula, a ação que tira uma norma penal do +ordenamento. O que ele descarta sai **nomeado, com o motivo**. O que ele aceita sai com +**proveniência**: URL, data de captura, hash do texto. -Não é um buscador. É o que fica entre a fonte e a pessoa que precisa ler: a rotina que -garante que nada publicado sobre um assunto passe em silêncio, e que ninguém precise ler -trezentas páginas por semana para descobrir isso. +Não é um buscador. É o que fica entre o tribunal e a pessoa que mantém o catálogo: a +rotina que garante que nada decidido sobre um tipo penal passe em silêncio, e que +ninguém precise ler trezentas decisões por semana para descobrir isso. > Nasceu do [Sentinela](https://github.com/luccas-amorim/atlaspen/blob/main/docs/os-robos.md) > do AtlasPen, que vigia a Seção 1 do Diário Oficial em busca de lei penal nova. O Argos -> é a generalização: a mesma disciplina (texto integral, triagem em níveis, falso -> positivo aceitável, falso negativo não), separada do direito penal para servir a -> qualquer domínio que caiba num léxico. +> faz o mesmo do lado dos tribunais, com a mesma disciplina: texto integral, triagem em +> níveis, falso positivo aceitável, falso negativo não. + +**Expansão em estudo:** estudos de precatórios e processamento de grandes volumes de +dados em planilha. Cenários em [docs/precatorios.md](docs/precatorios.md). --- -## Os dois primeiros usos +## O que ele vigia -| Caso | Fontes | Léxico | Saída | +| Fonte | Tribunal | O que entrega | Adaptador | |---|---|---|---| -| **Jurisprudência do AtlasPen** | DataJud (CNJ), STF, STJ | os tipos penais de `crimes.json`: nomes, artigos, rótulos de lei | decisões ligadas a cada tipo, em relatório (o formato de dado é decisão do AtlasPen) | -| **Observatório do Massacre do Carandiru** | imprensa (RSS), repositórios acadêmicos, Wayback Machine | o universo léxico já catalogado pela pesquisa | corpus com proveniência, exportável para descrição arquivística | +| **DJEN** (Diário de Justiça Eletrônico Nacional) | STJ (desde 28/11/2024) e os demais que aderiram | texto das decisões publicadas; a ementa é o destaque | `djen` | +| **DataJud** (API pública do CNJ) | STJ, STM e todos os demais, menos o STF | classe, assuntos da TPU, órgão e movimentos; sem texto | `datajud` | +| **Páginas de precedentes** | STF: teses de repercussão geral, súmulas vinculantes | tabela que muda devagar; linha alterada volta a ser lida | `lista` | +| **Notícias dos tribunais** | STF, STJ, CNJ (feeds a conferir) | o aviso do julgamento, antes do acórdão | `rss` | + +A sondar (plano em [docs/jurisprudencia.md](docs/jurisprudencia.md) e +[docs/stf.md](docs/stf.md)): dados abertos do STJ (espelhos e íntegras), Informativo e +acompanhamento processual do STF, os atos normativos do CNJ que mexem em execução e +processo penal, e o **TSE**, porque o catálogo tem crimes do Código Eleitoral. -Dois domínios sem nada em comum além da forma. É de propósito: se o motor serve aos dois -sem código específico de nenhum, ele é geral. Código que só um dos dois usa fica no -repositório daquele uso, não aqui. +O STF não está no DataJud nem no DJEN, e a pesquisa de jurisprudência dele bloqueia +acesso automatizado. O Argos não contorna: usa as páginas de precedentes e, se elas +também forem bloqueadas, o pedido formal de acesso. --- @@ -43,17 +55,18 @@ Grava cada item aceito com proveniência. Escreve o relatório da rodada em dois completo (um item por linha, aceitos e descartados) e resumo (o que pede ação). Lembra o que já viu, para não reapresentar. -**Não faz.** Não escreve em base de dados de ninguém: entrega arquivo e quem consome -decide. Não infere: onde o léxico não decide, o item vai para "pede juízo" com o trecho -que gerou a dúvida. Não usa modelo de linguagem na triagem. Um nível final com modelo -pode existir, mas é opt-in, marcado na saída como tal e nunca é o único critério de -aceitação. +**Não faz.** Não escreve no AtlasPen: **lê** o catálogo dele e entrega arquivo; quem +mantém o AtlasPen decide o que entra. Não infere: onde o léxico não decide, o item vai +para "pede juízo" com o trecho que gerou a dúvida. Não usa modelo de linguagem na +triagem. Um nível final com modelo pode existir, mas é opt-in, marcado na saída como tal +e nunca é o único critério de aceitação. -**Não faz, por política.** Não ignora `robots.txt`. Não ultrapassa o limite de -requisições que cada adaptador declara. Não compila dados sobre pessoas além do texto -que a própria fonte publicou, e não cruza fontes para montar perfil de ninguém. Guarda o -texto porque fontes somem; guarda a proveniência porque texto sem origem não serve à -pesquisa. +**Não faz, por política.** Não ignora `robots.txt` nem contorna bloqueio (WAF, CAPTCHA, +bloqueio por país). Não ultrapassa o limite de requisições que cada adaptador declara. +Não compila dados sobre pessoas além do texto que a própria fonte publicou, e não cruza +fontes para montar perfil de ninguém: os destinatários das comunicações do DJEN (partes e +advogados) são descartados já na listagem. Guarda o texto porque decisões mudam de +endereço; guarda a proveniência porque texto sem origem não serve ao catálogo. --- @@ -61,29 +74,28 @@ pesquisa. ``` fontes (adaptadores) captura léxico (dado) triagem saídas - datajud, stf, stj, ──▶ texto integral ──▶ termos, variantes ──▶ níveis 0..3 ──▶ JSONL, CSV - rss, wayback, dou + proveniência exclusões, pesos descarte nomeado issue, arquivística + djen, datajud, ──▶ texto integral ──▶ termos, variantes ──▶ níveis 0..3 ──▶ JSONL + lista, rss + proveniência exclusões, pesos descarte nomeado relatório │ │ └────────────── estado (já visto) ◀────────┘ ``` -Cinco peças, cada uma com um contrato pequeno e um diretório: - | Diretório | Peça | O que garante | |---|---|---| | `argos/fontes/` | **Adaptadores** | um por origem; mesmo contrato para todos (abaixo) | -| `argos/http.py` | **Cliente** | identifica o projeto, obedece o limite declarado e o `robots.txt`, tenta de novo em 429 e 5xx | -| `argos/captura.py` | **Captura** | texto integral normalizado em UTF-8, hash, data; respeita limite da fonte; grava `.part` e só promove quando a sentinela de integridade passa | -| `argos/lexico.py` | **Léxico** | carrega e valida o vocabulário contra `exemplos/lexico.schema.json`; expande variantes; nunca contém termo em código | +| `argos/http.py` | **Cliente** | identifica o projeto, obedece o limite declarado e o `robots.txt`, tenta de novo em 429 e 5xx, acusa `BloqueadoPorPais` | +| `argos/captura.py` | **Captura** | texto integral normalizado em UTF-8, hash, data; grava `.part` e só promove quando a sentinela de integridade passa | +| `argos/lexico.py` | **Léxico** | carrega e valida o vocabulário contra `exemplos/lexico.schema.json`; nunca contém termo em código | | `argos/triagem.py` | **Triagem** | níveis, motivo de descarte, trecho que decidiu | -| `argos/saidas/` | **Saídas** | JSONL (canônico), CSV, issue no GitHub, export arquivístico | +| `argos/saidas/` | **Saídas** | JSONL (canônico) e relatório em dois tamanhos | +| `exemplos/atlaspen/` | **Consumidor** | gera o léxico do catálogo do AtlasPen e leva a rodada de volta aos registros, como relatório | O estado é um arquivo (`estado/.json`) com os hashes já vistos, a data da última rodada por fonte e os **pendentes**: itens cuja captura falhou ou que a sentinela reprovou. Pendente é tentado de novo em toda rodada, mesmo depois de sair da janela de datas; sem isso, um item que falhou uma vez sumiria em silêncio. Apagar o estado equivale -a reler tudo. Além do estado, só o corpus (o texto integral de cada item íntegro) é -guardado entre rodadas. +a reler tudo. Além do estado, o corpus (o texto integral de cada item íntegro) é guardado +entre rodadas, e é ele que permite retriar quando o léxico muda. ### O contrato do adaptador @@ -92,7 +104,7 @@ Um adaptador é um módulo com três funções e um bloco de metadados. Está em ```python class Fonte(Protocol): - id: str # "datajud", "stf", "rss:folha" + id: str # "djen:stj", "datajud:stm", "stf:sumulas" limite_por_minuto: int # declarado, não negociado respeita_robots: bool # True, salvo fonte que publique API própria @@ -103,132 +115,95 @@ class Fonte(Protocol): - `listar` devolve referências (URL, título, data de publicação, id na fonte). Pode ser paginado. Não baixa texto. -- `baixar` devolve o documento inteiro: texto, HTML cru quando houver, cabeçalhos - relevantes. Normaliza codificação aqui, uma vez só. +- `baixar` devolve o documento inteiro: texto, cabeçalhos relevantes e os **destaques** + (a ementa, os assuntos da TPU). Normaliza codificação aqui, uma vez só. - `sentinela` diz se o documento está íntegro: página de erro com HTTP 200, texto - truncado e "resultado não encontrado" são rejeitados aqui, antes de chegar à triagem. + truncado, processo em segredo de justiça e "resultado não encontrado" são rejeitados + aqui, antes de chegar à triagem. -Todo adaptador vem com **fixtures** (HTML ou JSON reais, gravados) e testes que rodam sem -rede. Fonte que muda de forma quebra o teste, não a rodada em silêncio. +Todo adaptador vem com **fixtures** e testes que rodam sem rede. Fonte que muda de forma +quebra o teste, não a rodada em silêncio. ### O léxico como dado +O léxico do AtlasPen não se escreve à mão: `exemplos/atlaspen/lexico.py` o gera de +`data/crimes.json` e `data/diplomas.json`, um termo por artigo de cada diploma: + ```json { - "id": "atlaspen-tipos-penais", - "versao": "2026-10-02", - "termos": [ - { - "chave": "cp-art-121", - "formas": ["homicídio", "art. 121 do Código Penal", "art. 121, CP", "121 do CP"], - "exclusoes": ["homicídio culposo na direção"], - "peso": 1.0, - "categorias": ["cp", "crime-contra-a-vida"], - "nota": "A exclusão aponta para o art. 302 do CTB, que é outro registro." - } - ], - "contexto": { - "exige_qualquer": ["pena", "condenação", "absolvição", "denúncia", "recurso"], - "nota": "Sem um destes no texto, o item fica no nível 1 e não sobe." - } + "chave": "cp-art-121", + "formas": ["art. 121 … Código Penal", "art. 121 … CP", "CP, art. 121"], + "exclusoes": ["art. 121 … Código Penal Militar"], + "categorias": ["cp"] } ``` O casamento ignora maiúsculas e acentos, exige palavra inteira e tolera quebra de linha onde a forma tem espaço. Reticências (`…`) valem até 60 caracteres quaisquer, para casar -citações como "art. 121, § 2º, IV, do Código Penal" com a forma `art. 121 … Código Penal`. -Ocorrência que cai dentro de uma `exclusao` não conta. As -`categorias` (opcionais) são facetas de catalogação e vão para a saída junto com a chave. - -O léxico tem dono fora deste repositório. O do AtlasPen é gerado a partir de -`crimes.json`; o do Carandiru é mantido pela pesquisa que o catalogou. O Argos valida -o formato e aplica. Mudar um termo é um commit em dado, não em código. +citações como "art. 121, § 2º, IV, do Código Penal". Ocorrência que cai dentro de uma +`exclusao` não conta: é assim que o art. 121 do CPM não vira art. 121 do CP. Mudar um +termo é mudar o catálogo do AtlasPen, não o código daqui. ### A triagem em níveis | Nível | Significa | Vai para | |---|---|---| | 0 | nenhuma forma do léxico no texto | descartado, motivo `sem_termo` | -| 1 | termo presente, sem contexto exigido | descartado, motivo `sem_contexto`, com o trecho | +| 1 | termo presente, sem contexto decisório | descartado, motivo `sem_contexto`, com o trecho | | 2 | termo e contexto presentes | **pede juízo**: entra no relatório, com trecho | -| 3 | termo, contexto e marcador forte da fonte (ementa, dispositivo, manchete) | **aceito**: entra na saída com proveniência | - -O "marcador forte" é o campo `destaques` do `Documento`: cada adaptador declara quais -trechos a própria fonte marca como fortes (a manchete e a linha fina no `rss`, os assuntos -da TPU no `datajud`, a ementa no futuro `stj`). +| 3 | termo, contexto e marcador forte da fonte (ementa, assuntos da TPU, manchete) | **aceito**: entra na saída com proveniência | O corte não apaga: nível 0 e 1 saem no relatório completo, uma linha cada, com o motivo. -É isso que torna o filtro auditável e permite retriar quando o léxico mudar, porque o -texto integral da rodada fica guardado. +É isso que torna o filtro auditável e permite retriar quando o léxico mudar. --- ## Como o AtlasPen usa -O Argos é **dependência** do AtlasPen, fixada por tag. O AtlasPen não copia código daqui. +**O AtlasPen é lido, nunca editado.** O Argos recebe o diretório de um clone dele, lê o +catálogo, gera o léxico, vigia os tribunais e entrega dois arquivos: o JSONL dos aceitos +e o relatório por registro (`exemplos/atlaspen/mapear.py`), separando o que foi ligado +por **citação** (segura) do que foi ligado só pelo **nome** do tipo (pede juízo). Levar +isso ao catálogo é decisão de quem mantém o AtlasPen, com a regra de lá: o que não é +inequívoco vira pergunta, não dado. +```bash +python exemplos/atlaspen/lexico.py --atlaspen ../atlaspen --saida exemplos/atlaspen/lexico.json +argos rodar --config exemplos/atlaspen/config.json --desde 2026-09-01 +python exemplos/atlaspen/mapear.py dados-atlaspen/saidas/ --atlaspen ../atlaspen --md relatorio.md ``` -argos/ (este repositório) atlaspen/ (consumidor) - fontes/datajud.py scripts/robos/jurisprudencia/ - fontes/stf.py lexico.py ← gera o léxico a partir de crimes.json - fontes/stj.py mapear.py ← leva a saída JSONL aos registros, como relatório - triagem.py, captura.py, saidas/jsonl.py workflow ← roda toda segunda, depois do Vigia -``` -O que é geral (falar com tribunal, baixar inteiro teor, triar) mora aqui. O que é do -AtlasPen (quais termos, como uma decisão vira linha de `data/jurisprudencia.json`, qual -registro recebe o quê) mora lá. A fronteira é o JSONL: o Argos entrega documentos -aceitos com proveniência e as chaves do léxico que casaram; o AtlasPen decide o que fazer -com isso, com a mesma regra dos outros robôs: o que não é inequívoco vira pergunta na -issue, não dado. - -Vantagens de estar separado, em vez de ser o sexto robô dentro do AtlasPen: - -- **Fixtures de tribunal não poluem o repositório do catálogo.** Inteiro teor é grande e - muda de layout; o lugar dele é junto do adaptador que o lê. -- **Correção em um lugar serve aos dois usos.** Quando o STJ mudar a página de busca, o - conserto é um PR aqui e um bump de versão lá. -- **O catálogo continua "sem servidor, sem banco, só arquivo"**, porque a vigilância é - processo e o AtlasPen continua sendo dado. -- O Sentinela pode migrar para cá quando fizer sentido, como adaptador `dou`. Não é - pré-requisito e não há pressa: ele funciona onde está. - -## Como o observatório do Carandiru usa - -Mesmo desenho, outro consumidor: um repositório do observatório com o léxico, a lista de -fontes (RSS de veículos, Google Notícias por consulta, SciELO, BDTD, Zenodo, Wayback -para o que já saiu do ar) e o mapeamento da saída para o formato de descrição que a -pesquisa adota. O adaptador `wayback` é o que mais importa ali: fonte que sumiu ainda tem -texto, e o texto tem data de captura. - -A saída arquivística (`saidas/arquivistica.py`) escreve os campos mínimos de proveniência -num formato que o grupo escolher; a primeira versão vai mirar o que já existe no -[atlas-of-resistance](https://github.com/mmillenaa/atlas-of-resistance), para que o -corpus possa entrar no grafo sem retrabalho. +Vantagens de estar separado, em vez de ser mais um robô dentro do AtlasPen: fixtures de +tribunal não poluem o repositório do catálogo; o catálogo continua "sem servidor, sem +banco, só arquivo"; e a vigilância, que precisa de IP brasileiro para o DJEN, roda onde +puder sem mudar nada lá. + +--- + +## Expansão: precatórios e planilhas + +Duas capacidades novas, ainda em estudo: **acompanhar precatórios** (as listas de ordem +cronológica que cada tribunal publica por ente devedor, o mapa anual, o regime especial) +e **processar grandes volumes de dados em planilha** (dezenas de arquivos XLSX e PDF, +com layouts diferentes, para uma tabela única com relatório de qualidade). Exigem peças +que o Argos ainda não tem: leitura de tabela, retrato no tempo, diferença entre +retratos, validadores de número CNJ, moeda e data. Os cenários, as fontes e o que decidir +estão em [docs/precatorios.md](docs/precatorios.md). --- ## Roadmap -Os dois usos têm plano próprio, com fontes, recorte e ordem de trabalho: -[jurisprudência para o AtlasPen](docs/jurisprudencia.md) (com o [plano do STF](docs/stf.md) -à parte; o AtlasPen é lido, nunca editado) e [observatório do Carandiru](https://github.com/luccas-amorim/observatory), que -vive no próprio repositório. - -- **v0.1** (feita; primeira rodada real contra o DataJud do STM em 04/10/2026) motor: contrato, captura com `.part` e sentinela, léxico com schema e - validação, triagem em níveis, estado, saída JSONL, relatório em dois tamanhos. - Um adaptador de cada família para provar o contrato: `rss` (genérico) e `datajud`. -- **v0.2** jurisprudência: `djen` (**feito**, falta rodada real com IP brasileiro), - `lista` (**feito**) e as fontes do STF, `stj` com fixtures; exemplo de consumidor em - `exemplos/atlaspen/` (**feito**: gerador de léxico e mapa de volta, lendo o AtlasPen sem - escrever nele). Primeira rodada real contra o léxico gerado de `crimes.json`. - Catálogo CSV cumulativo e modo sem texto (**feitos**), pedidos pelo observatório. -- **v0.3** observatório: `wayback`, `openalex`, `gdelt` e `argos estimar` (**feitos**); - segundo canal acadêmico (Crossref ou OAI-PMH), Common Crawl pelo texto, saída - arquivística. O consumidor é o repositório `observatory`. -- **depois** saída issue no GitHub, nível opt-in com modelo (marcado), migração do `dou`. -- **em estudo** precatórios: [cenários para decidir a expansão](docs/precatorios.md) - (leitura de tabela, retrato e diferença no tempo; o domínio num repositório próprio). +- **v0.1** (feita) motor: contrato, captura com `.part` e sentinela, léxico com schema, + triagem em níveis, estado com pendentes, JSONL, relatório em dois tamanhos; `rss` e + `datajud`, com a primeira rodada real contra o DataJud do STM em 04/10/2026. +- **v0.2** tribunais: `djen` e `lista` (**feitos**), exemplo do AtlasPen (**feito**: + gerador de léxico e mapa de volta). Faltam: primeira rodada real com IP brasileiro, + fontes do STF (teses, súmulas, ADIs do `avisos.json`), `stj` sobre os dados abertos, + atos normativos do CNJ, TSE. +- **v0.3** precatórios e planilhas, a partir do cenário que for escolhido. +- **depois** saída como issue no GitHub, nível opt-in com modelo (marcado), migração do + `dou`. ## Rodar @@ -247,22 +222,16 @@ Cada rodada escreve em `/saidas//`: `aceitos.jsonl` (nível 3, co proveniência), `pede-juizo.jsonl` (nível 2), `itens.jsonl` (todos, com o motivo), `relatorio-resumo.md`, `relatorio-completo.md` e `rodada.json`. -Com `"catalogo": {"arquivo": "catalogo.csv", "nivel_minimo": 2}` na configuração, cada -rodada também acrescenta a um **catálogo CSV cumulativo** as URLs novas, com três -colunas: `id`, `url` e `identificadores` (as chaves do léxico que decidiram pelo item na -primeira vez). Com `"guardar_texto": false`, o texto integral é lido, triado e -descartado: sobra só o catálogo, e `retriar` deixa de ter o que reler. - -**Onde rodar.** Parte das fontes brasileiras recusa conexão de fora do país: o DJEN -(CloudFront com bloqueio por país) e o portal de dados abertos do STJ, medidos em -04/10/2026. Runner hospedado do GitHub sai dos EUA. Para essas fontes, a rodada precisa -de IP brasileiro: runner próprio registrado no GitHub Actions, máquina local ou VM em -região de São Paulo. O Argos acusa o caso com `BloqueadoPorPais` no relatório. +**Onde rodar.** O DJEN (CloudFront com bloqueio por país) e o portal de dados abertos do +STJ recusam conexão de fora do Brasil, medido em 04/10/2026. Runner hospedado do GitHub +sai dos EUA. Para essas fontes, a rodada precisa de IP brasileiro: runner próprio +registrado no GitHub Actions, máquina local ou VM em região de São Paulo. O Argos acusa o +caso com `BloqueadoPorPais` no relatório. Códigos de saída: `0` nada a ler; `2` erro de execução ou fonte fora do ar; `3` há itens que pedem leitura (níveis 2 e 3). ## Licença -Código sob MIT. Os léxicos e as saídas pertencem a quem os mantém e seguem a licença de -cada repositório consumidor. +Código sob MIT. Os dados do AtlasPen usados como fixture seguem a licença deles (CC BY +4.0); os relatórios que o Argos gera pertencem a quem os consome. diff --git a/argos/captura.py b/argos/captura.py index c494354..6355df5 100644 --- a/argos/captura.py +++ b/argos/captura.py @@ -81,15 +81,10 @@ class Corpus: O mesmo texto chegando por outro id da mesma fonte (republicação) cai no mesmo arquivo, e a referência gravada passa a ser a da captura mais recente. O relatório e o JSONL da rodada guardam as duas. - - Com `guardar=False` nada é gravado: a sentinela confere o documento em memória, a - triagem lê o texto e ele é descartado. É a escolha de quem quer só o catálogo (URL e - identificadores), ao preço de não poder retriar sem baixar de novo. """ - def __init__(self, raiz: Path, guardar: bool = True) -> None: + def __init__(self, raiz: Path) -> None: self.raiz = Path(raiz) - self.guardar = guardar def relativo(self, caminho: Path | None) -> str | None: """Caminho como vai para a saída: relativo ao diretório de dados, portável.""" @@ -100,10 +95,8 @@ def relativo(self, caminho: Path | None) -> str | None: def caminho(self, doc: Documento) -> Path: return self.raiz / nome_seguro(doc.ref.fonte) / f"{doc.hash_texto}.json" - def gravar(self, doc: Documento, fonte: Fonte) -> tuple[bool, Path | None]: + def gravar(self, doc: Documento, fonte: Fonte) -> tuple[bool, Path]: """(íntegro, caminho). Grava `.part`, pergunta à sentinela, promove se passar.""" - if not self.guardar: - return fonte.sentinela(doc), None final = self.caminho(doc) final.parent.mkdir(parents=True, exist_ok=True) parcial = final.with_suffix(".json.part") diff --git a/argos/cli.py b/argos/cli.py index 9602857..b30fddc 100644 --- a/argos/cli.py +++ b/argos/cli.py @@ -3,7 +3,6 @@ python -m argos rodar --config exemplos/config.exemplo.json python -m argos rodar --config ... --desde 2026-09-01 --sem-estado python -m argos retriar --config ... - python -m argos estimar --config ... python -m argos validar-lexico exemplos/lexico.exemplo.json Saídas: 0 nada a ler; 2 erro de execução ou fonte fora do ar; 3 há itens que pedem juízo. @@ -16,8 +15,7 @@ import sys from datetime import date -from argos import __version__, config, estimativa, fontes, lexico, rodada, saidas -from argos.saidas import catalogo +from argos import __version__, config, fontes, lexico, rodada, saidas def _rodar(args: argparse.Namespace) -> int: @@ -32,46 +30,18 @@ def _rodar(args: argparse.Namespace) -> int: resultado = rodada.rodar(cfg, instancias, casador, desde=desde, usar_estado=not args.sem_estado) pasta = saidas.escrever(resultado, cfg.saidas) print(f"{len(resultado.linhas)} itens; saídas em {pasta}") - _catalogar(cfg, resultado) return resultado.codigo_saida -def _catalogar(cfg: config.Config, resultado: rodada.Rodada) -> None: - if cfg.catalogo_csv is None: - return - nivel = int(cfg.catalogo.get("nivel_minimo", 2)) - novas = catalogo.atualizar(resultado, cfg.catalogo_csv, nivel) - print(f"catálogo: {novas} URLs novas em {cfg.catalogo_csv}") - - def _retriar(args: argparse.Namespace) -> int: cfg = config.carregar(args.config) casador = lexico.Casador(lexico.carregar(cfg.lexico)) resultado = rodada.retriar(cfg, casador, args.fonte) pasta = saidas.escrever(resultado, cfg.saidas) print(f"{len(resultado.linhas)} itens retriados; saídas em {pasta}") - _catalogar(cfg, resultado) return resultado.codigo_saida -def _estimar(args: argparse.Namespace) -> int: - cfg = config.carregar(args.config) - estratos = {f["id"]: f.get("estrato", "geral") for f in cfg.fontes} - desde = date.fromisoformat(args.desde) if args.desde else None - por_estrato = estimativa.calcular( - cfg.saidas, args.nivel_minimo, lambda fonte: estratos.get(fonte, "geral"), desde - ) - texto = estimativa.relatorio(por_estrato, desde) - destino = cfg.diretorio / "estimativa.md" - destino.parent.mkdir(parents=True, exist_ok=True) - destino.write_text(texto, encoding="utf-8") - for nome, est in sorted(por_estrato.items()): - piso = f"{est.chao:,.0f}" if est.chao is not None else "sem estimativa (um canal só)" - print(f"{nome}: {len(est.observadas)} URLs observadas; piso estimado: {piso}") - print(f"relatório em {destino}") - return rodada.SEM_NADA - - def _validar_lexico(args: argparse.Namespace) -> int: try: lex = lexico.carregar(args.arquivo) @@ -101,12 +71,6 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--fonte") p.set_defaults(func=_retriar) - p = sub.add_parser("estimar", help="quanto falta: captura e recaptura entre os canais") - p.add_argument("--config", required=True) - p.add_argument("--nivel-minimo", type=int, default=2) - p.add_argument("--desde", help="AAAA-MM-DD: só itens a partir desta data") - p.set_defaults(func=_estimar) - p = sub.add_parser("validar-lexico", help="confere o formato de um léxico") p.add_argument("arquivo") p.set_defaults(func=_validar_lexico) diff --git a/argos/config.py b/argos/config.py index afa180e..a1e8b42 100644 --- a/argos/config.py +++ b/argos/config.py @@ -5,8 +5,6 @@ "diretorio": "dados", "desde": "2026-09-01", "margem_dias": 2, - "guardar_texto": true, - "catalogo": {"arquivo": "catalogo.csv", "nivel_minimo": 2}, "fontes": [{"tipo": "rss", "id": "rss:exemplo", "url": "https://..."}] } @@ -31,16 +29,6 @@ class Config: # Quanto voltar antes da última rodada: fonte que publica com atraso ou com data # retroativa não escapa. O estado impede a releitura do que já foi visto. margem_dias: int = 2 - # False: o texto integral é lido, triado e descartado; nada vai para `corpus/`. - guardar_texto: bool = True - # {"arquivo": "catalogo.csv", "nivel_minimo": 2}: mantém o catálogo CSV cumulativo. - catalogo: dict | None = None - - @property - def catalogo_csv(self) -> Path | None: - if not self.catalogo: - return None - return self.diretorio / self.catalogo.get("arquivo", "catalogo.csv") @property def estado(self) -> Path: @@ -71,6 +59,4 @@ def carregar(caminho: str | Path) -> Config: fontes=tuple(fontes), desde=date.fromisoformat(dados["desde"]) if dados.get("desde") else None, margem_dias=int(dados.get("margem_dias", 2)), - guardar_texto=bool(dados.get("guardar_texto", True)), - catalogo=dados.get("catalogo"), ) diff --git a/argos/estimativa.py b/argos/estimativa.py deleted file mode 100644 index 1e2055c..0000000 --- a/argos/estimativa.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Quanto falta: estimativa do total a partir da sobreposição entre canais. - -É a técnica de captura e recaptura, a mesma de contar peixes num lago. Se o canal A acha -`n1` páginas, o B acha `n2` e `m` estão nos dois, o total estimado é ~ `n1 · n2 / m`. Aqui -se usa a forma de Chapman, que não explode quando `m` é pequeno, e, para três canais ou -mais, o estimador de Chao, que usa quantas páginas foram vistas por um canal só (`f1`) e -por exatamente dois (`f2`) e é um **piso** robusto à heterogeneidade (página popular é -mais fácil de achar que página obscura). - -Duas regras de uso que o código impõe: - -- **Canal é o prefixo do id da fonte** (`wayback:folha` e `wayback:estadao` são o mesmo - canal, `wayback`). Domínios do Wayback não se sobrepõem; tratados como canais - separados, quase toda URL seria "vista por um canal só" e a estimativa explodiria. -- **Só se comparam canais do mesmo estrato.** Captura e recaptura supõe que os canais - olham para a mesma população. Imprensa se compara com imprensa, produção acadêmica com - produção acadêmica. Cada fonte declara `"estrato"` na configuração (padrão: `geral`), e - o relatório sai por estrato. Para alinhar no tempo (o GDELT só cobre três meses), - `--desde` restringe a comparação a itens publicados ou capturados a partir da data. - -As ressalvas vão no relatório porque são parte do número: - -- **Os canais não são independentes.** Todos favorecem o que é popular e bem ligado. - Dependência positiva faz a estimativa ficar **abaixo** do real: leia como piso. -- **A unidade é a URL normalizada** (sem esquema, sem `www.`, sem barra final, sem - parâmetros de rastreio, e sem o prefixo do Wayback). A mesma matéria em duas URLs - conta duas vezes. -- **A curva de acumulação** (quantas URLs novas cada rodada trouxe) diz se a busca está - saturando, coisa que nenhum estimador diz sozinho. - -Entrada: os `itens.jsonl` das rodadas em `/saidas/`, com nível mínimo -configurável (padrão 2: aceitos e pede juízo). -""" - -from __future__ import annotations - -import json -import math -import re -from collections import Counter -from collections.abc import Callable -from dataclasses import dataclass, field -from datetime import date -from itertools import combinations -from pathlib import Path -from urllib.parse import parse_qsl, urlencode, urlsplit - -_WAYBACK = re.compile(r"^https?://web\.archive\.org/web/\d+[a-z_]*/(.+)$") -_RASTREIO = re.compile(r"^(utm_|fbclid$|gclid$|mc_|ref$|amp$)") - - -def normalizar_url(url: str) -> str: - url = url.strip() - if m := _WAYBACK.match(url): - url = m.group(1) - if "://" not in url: - url = f"http://{url}" - partes = urlsplit(url) - host = partes.netloc.lower().removeprefix("www.").split(":")[0] - caminho = partes.path.rstrip("/") or "/" - consulta = urlencode( - sorted((k, v) for k, v in parse_qsl(partes.query) if not _RASTREIO.match(k.lower())) - ) - return f"{host}{caminho}" + (f"?{consulta}" if consulta else "") - - -@dataclass -class Par: - a: str - b: str - n1: int - n2: int - m: int - - @property - def chapman(self) -> float: - return (self.n1 + 1) * (self.n2 + 1) / (self.m + 1) - 1 - - @property - def intervalo(self) -> tuple[float, float]: - n1, n2, m = self.n1, self.n2, self.m - var = (n1 + 1) * (n2 + 1) * (n1 - m) * (n2 - m) / ((m + 1) ** 2 * (m + 2)) - dp = math.sqrt(var) - return max(self.n1 + self.n2 - self.m, self.chapman - 1.96 * dp), self.chapman + 1.96 * dp - - -@dataclass -class Estimativa: - por_canal: dict[str, set[str]] - curva: list[tuple[str, int, int]] = field(default_factory=list) # rodada, novas, total - - @property - def observadas(self) -> set[str]: - return set().union(*self.por_canal.values()) if self.por_canal else set() - - @property - def frequencias(self) -> Counter: - """Quantas URLs foram vistas por exatamente k canais.""" - contagem = Counter(u for urls in self.por_canal.values() for u in urls) - return Counter(contagem.values()) - - @property - def chao(self) -> float | None: - if len(self.por_canal) < 2: - return None - s = len(self.observadas) - f1, f2 = self.frequencias.get(1, 0), self.frequencias.get(2, 0) - return s + (f1 * f1 / (2 * f2) if f2 else f1 * (f1 - 1) / 2) - - @property - def pares(self) -> list[Par]: - return [ - Par(a, b, len(self.por_canal[a]), len(self.por_canal[b]), - len(self.por_canal[a] & self.por_canal[b])) - for a, b in combinations(sorted(self.por_canal), 2) - ] # fmt: skip - - -def canal(fonte: str) -> str: - return fonte.split(":", 1)[0] - - -def calcular( - saidas: Path, - nivel_minimo: int = 2, - estrato_de: Callable[[str], str] = lambda fonte: "geral", - desde: date | None = None, -) -> dict[str, Estimativa]: - """Uma estimativa por estrato. `estrato_de` recebe o id da fonte.""" - por_estrato: dict[str, Estimativa] = {} - vistas: dict[str, set[str]] = {} - for pasta in sorted(Path(saidas).glob("*-rodar")): - arquivo = pasta / "itens.jsonl" - if not arquivo.exists(): - continue - antes = {e: len(v) for e, v in vistas.items()} - for linha in arquivo.read_text(encoding="utf-8").splitlines(): - item = json.loads(linha) - if item.get("situacao") != "triado" or (item.get("nivel") or 0) < nivel_minimo: - continue - publicado = item.get("publicado_em") - if desde and (not publicado or date.fromisoformat(publicado) < desde): - continue - estrato = estrato_de(item["fonte"]) - est = por_estrato.setdefault(estrato, Estimativa({})) - url = normalizar_url(item["url"]) - est.por_canal.setdefault(canal(item["fonte"]), set()).add(url) - vistas.setdefault(estrato, set()).add(url) - rodada = pasta.name.removesuffix("-rodar") - for estrato, urls in vistas.items(): - novas = len(urls) - antes.get(estrato, 0) - por_estrato[estrato].curva.append((rodada, novas, len(urls))) - return por_estrato - - -def relatorio(estratos: dict[str, Estimativa], desde: date | None = None) -> str: - linhas = ["# Estimativa de cobertura", ""] - if desde: - linhas += [f"Só itens publicados ou capturados a partir de {desde.isoformat()}.", ""] - if not estratos: - return "\n".join([*linhas, "Nenhuma rodada com itens triados ainda."]) + "\n" - for nome, est in sorted(estratos.items()): - linhas += _estrato(nome, est) - linhas += [ - "", - "## Como ler", - "", - "Os canais não são independentes, e a dependência puxa a estimativa para baixo: os " - "números acima são piso. Saturação de verdade é a curva achatar com canais novos " - "entrando, não só com os mesmos canais repetindo.", - ] - return "\n".join(linhas) + "\n" - - -def _estrato(nome: str, est: Estimativa) -> list[str]: - total = len(est.observadas) - linhas = [f"## Estrato `{nome}`", "", f"URLs distintas observadas: **{total}**.", ""] - - linhas += ["| Canal | URLs | Só neste canal |", "|---|---|---|"] - contagem = Counter(u for urls in est.por_canal.values() for u in urls) - for canal, urls in sorted(est.por_canal.items()): - exclusivas = sum(1 for u in urls if contagem[u] == 1) - linhas.append(f"| `{canal}` | {len(urls)} | {exclusivas} |") - - if est.chao is not None: - f = est.frequencias - linhas += [ - "", - "**Total estimado**", - "", - f"- **Chao (piso, {len(est.por_canal)} canais):** {est.chao:,.0f} " - f"(vistas por um canal só: {f.get(1, 0)}; por exatamente dois: {f.get(2, 0)}).", - f"- **Cobertura:** no máximo {total / est.chao:.0%} (observadas ÷ piso)." - if est.chao - else "", - "", - "| Par | n1 | n2 | em comum | Chapman | IC 95% |", - "|---|---|---|---|---|---|", - ] - for p in est.pares: - baixo, alto = p.intervalo - linhas.append( - f"| `{p.a}` × `{p.b}` | {p.n1} | {p.n2} | {p.m} | {p.chapman:,.0f} | " - f"{baixo:,.0f} a {alto:,.0f} |" - ) - linhas += [ - "", - "Par sem nada em comum dá estimativa enorme e instável: não diz que o total é " - "enorme, diz que os dois canais olham para lugares diferentes.", - ] - else: - linhas += ["", "Com um canal só não há estimativa: é preciso ao menos dois."] - - linhas += ["", "**Curva de acumulação**", "", "| Rodada | Novas | Total |", "|---|---|---|"] - linhas += [f"| {r} | {novas} | {acum} |" for r, novas, acum in est.curva] - return [*linhas, ""] diff --git a/argos/fontes/__init__.py b/argos/fontes/__init__.py index 4b43c39..8d5010d 100644 --- a/argos/fontes/__init__.py +++ b/argos/fontes/__init__.py @@ -5,11 +5,8 @@ from argos.contrato import Fonte from argos.fontes.datajud import DataJud from argos.fontes.djen import Djen -from argos.fontes.gdelt import Gdelt from argos.fontes.lista import Lista -from argos.fontes.openalex import OpenAlex from argos.fontes.rss import Rss -from argos.fontes.wayback import Wayback from argos.http import Transporte, transporte_urllib TIPOS = { @@ -17,9 +14,6 @@ "datajud": DataJud, "djen": Djen, "lista": Lista, - "wayback": Wayback, - "openalex": OpenAlex, - "gdelt": Gdelt, } diff --git a/argos/fontes/gdelt.py b/argos/fontes/gdelt.py deleted file mode 100644 index 7570dd7..0000000 --- a/argos/fontes/gdelt.py +++ /dev/null @@ -1,108 +0,0 @@ -"""GDELT DOC 2.0: notícias do mundo inteiro, busca por texto, em dezenas de línguas. - -API: https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/ - -A janela é **móvel, de cerca de três meses**: o GDELT serve à vigilância (o que saiu -agora), não à varredura do passivo. `desde` anterior a isso é cortado para o limite, e o -relatório diz a janela usada. - -Cada consulta devolve no máximo 250 artigos. O adaptador pergunta **um dia por vez**; se um -dia vier cheio, pergunta de novo em quatro faixas de seis horas, para não perder o que -passou do teto. O texto vem da página do veículo, com o `robots.txt` dele respeitado. - -Configuração: - {"tipo": "gdelt", "id": "gdelt:carandiru", "busca": "carandiru", - "limite_por_minuto": 12, "minimo_caracteres": 400} -""" - -from __future__ import annotations - -from collections.abc import Callable, Iterator -from datetime import date, datetime, timedelta - -from argos.contrato import Documento, Referencia -from argos.fontes.rss import documento_html, pagina_integra -from argos.http import Cliente, Transporte, transporte_urllib - -URL = "https://api.gdeltproject.org/api/v2/doc/doc" -TETO = 250 -JANELA_DIAS = 90 - - -def _momento(dia: date, hora: int = 0) -> str: - return datetime(dia.year, dia.month, dia.day, hora).strftime("%Y%m%d%H%M%S") - - -def _data(visto: str | None) -> date | None: - try: - return datetime.strptime((visto or "")[:8], "%Y%m%d").date() - except ValueError: - return None - - -class Gdelt: - respeita_robots = True # vale para as páginas dos veículos - - def __init__( - self, - id: str, - busca: str, - cliente: Cliente, - limite_por_minuto: int = 12, - minimo_caracteres: int = 400, - hoje: Callable[[], date] = date.today, - ) -> None: - self.id = id - self.busca = busca - self.cliente = cliente - self.limite_por_minuto = limite_por_minuto - self.minimo_caracteres = minimo_caracteres - self.hoje = hoje - - @classmethod - def de_config(cls, cfg: dict, transporte: Transporte = transporte_urllib) -> Gdelt: - limite = int(cfg.get("limite_por_minuto", 12)) - cliente = Cliente(limite, respeita_robots=cls.respeita_robots, transporte=transporte) - return cls(cfg["id"], cfg["busca"], cliente, limite, int(cfg.get("minimo_caracteres", 400))) - - def _consultar(self, inicio: str, fim: str) -> list[dict]: - params = { - "query": self.busca, - "mode": "artlist", - "format": "json", - "maxrecords": TETO, - "sort": "datedesc", - "startdatetime": inicio, - "enddatetime": fim, - } - return self.cliente.requisitar(URL, params=params).json().get("articles") or [] - - def listar(self, desde: date) -> Iterator[Referencia]: - hoje = self.hoje() - dia = max(desde, hoje - timedelta(days=JANELA_DIAS)) - while dia <= hoje: - artigos = self._consultar(_momento(dia), _momento(dia + timedelta(days=1))) - if len(artigos) >= TETO: - artigos = [] - for hora in (0, 6, 12, 18): - inicio = _momento(dia, hora) - proximo = dia + timedelta(days=1) - fim = _momento(proximo) if hora == 18 else _momento(dia, hora + 6) - artigos += self._consultar(inicio, fim) - for artigo in artigos: - if artigo.get("url"): - yield Referencia( - fonte=self.id, - id_na_fonte=artigo["url"], - url=artigo["url"], - titulo=artigo.get("title"), - publicado_em=_data(artigo.get("seendate")), - dados={k: artigo.get(k) for k in ("domain", "language", "sourcecountry")}, - ) - dia += timedelta(days=1) - - def baixar(self, ref: Referencia) -> Documento: - return documento_html(ref, self.cliente.requisitar(ref.url), ref.titulo) - - def sentinela(self, doc: Documento) -> bool: - return pagina_integra(doc, self.minimo_caracteres) diff --git a/argos/fontes/openalex.py b/argos/fontes/openalex.py deleted file mode 100644 index 1a522c9..0000000 --- a/argos/fontes/openalex.py +++ /dev/null @@ -1,131 +0,0 @@ -"""OpenAlex: a produção acadêmica indexada (artigos, teses, livros, capítulos). - -API: https://docs.openalex.org. Busca por texto em título, resumo e texto integral -indexado; paginação por cursor; até 200 por página. - -**Chave.** Medido em 04/10/2026: sem chave, a cota diária é compartilhada por todo o IP -de saída e já vinha esgotada ("Insufficient budget"). Defina `OPENALEX_API_KEY` (a chave -é gratuita). O adaptador não envia e-mail nem outro dado de quem roda. - -**O que é texto aqui.** O integral de um trabalho acadêmico costuma estar num PDF atrás -de outro servidor. Este adaptador decide por **título e resumo**, e diz isso: o título é -o destaque (nível 3), e um termo só no resumo fica em "pede juízo". É uma exceção -consciente à regra de nunca decidir pelo resumo, aceitável para catalogar e -insuficiente para afirmar que o trabalho trata do tema. - -Configuração: - {"tipo": "openalex", "id": "openalex:carandiru", "busca": "carandiru", - "por_pagina": 200, "limite_por_minuto": 30} -""" - -from __future__ import annotations - -import os -from collections.abc import Iterator -from datetime import date - -from argos.captura import montar_documento -from argos.contrato import Documento, Referencia -from argos.http import Cliente, Transporte, transporte_urllib - -URL = "https://api.openalex.org/works" -CAMPOS = ( - "id,doi,display_name,publication_date,type,abstract_inverted_index,primary_location,open_access" -) - - -def resumo(invertido: dict | None) -> str: - """O OpenAlex guarda o resumo como índice invertido (palavra -> posições).""" - if not invertido: - return "" - posicoes = {p: palavra for palavra, lista in invertido.items() for p in lista} - return " ".join(posicoes[p] for p in sorted(posicoes)) - - -class OpenAlex: - respeita_robots = False # API própria - - def __init__( - self, - id: str, - busca: str, - cliente: Cliente, - por_pagina: int = 200, - limite_por_minuto: int = 30, - chave: str | None = None, - ) -> None: - self.id = id - self.busca = busca - self.cliente = cliente - self.por_pagina = por_pagina - self.limite_por_minuto = limite_por_minuto - self.chave = chave - - @classmethod - def de_config(cls, cfg: dict, transporte: Transporte = transporte_urllib) -> OpenAlex: - limite = int(cfg.get("limite_por_minuto", 30)) - cliente = Cliente(limite, respeita_robots=cls.respeita_robots, transporte=transporte) - return cls( - cfg["id"], - cfg["busca"], - cliente, - int(cfg.get("por_pagina", 200)), - limite, - os.environ.get("OPENALEX_API_KEY"), - ) - - def listar(self, desde: date) -> Iterator[Referencia]: - cursor = "*" - while cursor: - params = { - "search": self.busca, - "filter": f"from_publication_date:{desde.isoformat()}", - "per-page": self.por_pagina, - "cursor": cursor, - "select": CAMPOS, - } - if self.chave: - params["api_key"] = self.chave - dados = self.cliente.requisitar(URL, params=params).json() - resultados = dados.get("results") or [] - for obra in resultados: - yield self._referencia(obra) - cursor = (dados.get("meta") or {}).get("next_cursor") if resultados else None - - def _referencia(self, obra: dict) -> Referencia: - local = obra.get("primary_location") or {} - acesso = obra.get("open_access") or {} - url = obra.get("doi") or local.get("landing_page_url") or obra.get("id") - publicado = obra.get("publication_date") - return Referencia( - fonte=self.id, - id_na_fonte=str(obra.get("id")), - url=url, - titulo=obra.get("display_name"), - publicado_em=date.fromisoformat(publicado) if publicado else None, - dados={ - "titulo": obra.get("display_name") or "", - "resumo": resumo(obra.get("abstract_inverted_index")), - "tipo": obra.get("type") or "", - "veiculo": ((local.get("source") or {}).get("display_name")) or "", - "acesso_aberto": acesso.get("oa_url") or "", - }, - ) - - def baixar(self, ref: Referencia) -> Documento: - """Sem rede: título, resumo, tipo e veículo vieram na listagem.""" - d = dict(ref.dados or {}) - texto = "\n\n".join( - p - for p in ( - d.get("titulo"), - d.get("resumo"), - f"Tipo: {d.get('tipo')}" if d.get("tipo") else "", - f"Veículo: {d.get('veiculo')}" if d.get("veiculo") else "", - ) - if p - ) - return montar_documento(ref, texto, destaques={"titulo": d.get("titulo") or ""}) - - def sentinela(self, doc: Documento) -> bool: - return bool((doc.ref.dados or {}).get("titulo")) diff --git a/argos/fontes/rss.py b/argos/fontes/rss.py index b828408..11eee42 100644 --- a/argos/fontes/rss.py +++ b/argos/fontes/rss.py @@ -130,7 +130,7 @@ def sentinela(self, doc: Documento) -> bool: def documento_html(ref: Referencia, resposta: Resposta, manchete: str | None = None) -> Documento: """Uma página HTML como Documento: texto visível, manchete e linha fina em destaque. - Compartilhado por todo adaptador que baixa página de veículo (rss, wayback, gdelt). + Compartilhado por todo adaptador que baixa página HTML (notícias de tribunal, por exemplo). """ html, _ = decodificar(resposta.corpo, resposta.cabecalhos.get("content-type")) pagina = Pagina(html) diff --git a/argos/fontes/wayback.py b/argos/fontes/wayback.py deleted file mode 100644 index dc2358a..0000000 --- a/argos/fontes/wayback.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Wayback Machine (Internet Archive): o que um domínio publicou e o arquivo guardou. - -Não há busca por texto no Wayback, só por endereço. Este adaptador pergunta ao servidor -CDX, domínio a domínio, por toda URL arquivada cujo endereço case uma expressão (por -exemplo, `carandiru` no caminho da matéria), e baixa a cópia arquivada. É o canal que -recupera o que já saiu do ar, e cada texto vem com a data da captura. - -API: https://github.com/internetarchive/wayback/tree/master/wayback-cdx-server - -- `filter=original:` filtra pelo endereço; `(?i)` torna a busca insensível a caixa. -- `collapse=urlkey` devolve uma captura por URL (a primeira na janela). -- `showResumeKey=true` pagina: a última linha da resposta é a chave da próxima página. -- O texto vem do modo `id_` (`/web/id_/`), que serve o conteúdo - arquivado sem a barra de navegação do Wayback. - -`publicado_em` é a **data da captura**, não da publicação: o Wayback não sabe quando a -página foi publicada, só quando a viu. `desde` filtra capturas, e uma varredura do passivo -usa `--desde 1996-01-01`. - -O CDX e o modo `id_` são a interface pública e documentada do arquivo para uso -programático; o adaptador declara API própria (não lê `robots.txt`) e se limita a 15 -requisições por minuto, abaixo do que o Internet Archive tolera. - -Configuração: - {"tipo": "wayback", "id": "wayback:folha", "dominio": "folha.uol.com.br", - "filtro": "(?i).*carandiru.*", "limite_por_minuto": 15, "minimo_caracteres": 400, - "por_pagina": 1000} -""" - -from __future__ import annotations - -from collections.abc import Iterator -from datetime import date, datetime -from urllib.parse import urlencode - -from argos.contrato import Documento, Referencia -from argos.fontes.rss import MARCAS_DE_ERRO, documento_html, pagina_integra -from argos.http import Cliente, Transporte, transporte_urllib - -CDX = "https://web.archive.org/cdx/search/cdx" -ARQUIVO = "https://web.archive.org/web" -CAMPOS = "timestamp,original,statuscode,mimetype,digest" - -MARCAS_DO_WAYBACK = ( - *MARCAS_DE_ERRO, - "wayback machine doesn't have that page archived", - "hrm. the wayback machine has not archived", - "this url has been excluded from the wayback machine", -) - - -def _data(timestamp: str) -> date | None: - try: - return datetime.strptime(timestamp[:8], "%Y%m%d").date() - except (TypeError, ValueError): - return None - - -class Wayback: - respeita_robots = False # API documentada do arquivo; ver docstring - - def __init__( - self, - id: str, - dominio: str, - cliente: Cliente, - filtro: str = "(?i).*carandiru.*", - limite_por_minuto: int = 15, - minimo_caracteres: int = 400, - por_pagina: int = 1000, - ) -> None: - self.id = id - self.dominio = dominio - self.cliente = cliente - self.filtro = filtro - self.limite_por_minuto = limite_por_minuto - self.minimo_caracteres = minimo_caracteres - self.por_pagina = por_pagina - - @classmethod - def de_config(cls, cfg: dict, transporte: Transporte = transporte_urllib) -> Wayback: - limite = int(cfg.get("limite_por_minuto", 15)) - cliente = Cliente( - limite, - respeita_robots=cls.respeita_robots, - transporte=transporte, - tempo_limite=float(cfg.get("tempo_limite", 120)), - ) - return cls( - cfg["id"], - cfg["dominio"], - cliente, - cfg.get("filtro", "(?i).*carandiru.*"), - limite, - int(cfg.get("minimo_caracteres", 400)), - int(cfg.get("por_pagina", 1000)), - ) - - def _parametros(self, desde: date, retomar: str | None) -> list[tuple[str, str]]: - # Lista de pares: `filter` se repete, e um dicionário perderia as repetições. - params = [ - ("url", self.dominio), - ("matchType", "domain"), - ("filter", f"original:{self.filtro}"), - ("filter", "statuscode:200"), - ("filter", "mimetype:text/html"), - ("collapse", "urlkey"), - ("output", "json"), - ("fl", CAMPOS), - ("from", desde.strftime("%Y%m%d")), - ("limit", str(self.por_pagina)), - ("showResumeKey", "true"), - ] - if retomar: - params.append(("resumeKey", retomar)) - return params - - def listar(self, desde: date) -> Iterator[Referencia]: - retomar: str | None = None - while True: - url = f"{CDX}?{urlencode(self._parametros(desde, retomar))}" - linhas = self.cliente.requisitar(url).json() or [] - if not linhas: - return - cabecalho, corpo = linhas[0], linhas[1:] - retomar = None - # Com showResumeKey, o fim é: [..., [], [""]]. - if len(corpo) >= 2 and corpo[-2] == [] and len(corpo[-1]) == 1: - retomar = corpo[-1][0] - corpo = corpo[:-2] - for valores in corpo: - if not valores: - continue - registro = dict(zip(cabecalho, valores, strict=False)) - original, momento = registro.get("original"), registro.get("timestamp", "") - if not original: - continue - yield Referencia( - fonte=self.id, - id_na_fonte=original, - url=f"{ARQUIVO}/{momento}id_/{original}", - titulo=original, - publicado_em=_data(momento), - dados={"original": original, "timestamp": momento}, - ) - if not retomar: - return - - def baixar(self, ref: Referencia) -> Documento: - # A manchete vem da página: o "título" da referência é só a URL. - return documento_html(ref, self.cliente.requisitar(ref.url)) - - def sentinela(self, doc: Documento) -> bool: - return pagina_integra(doc, self.minimo_caracteres, MARCAS_DO_WAYBACK) diff --git a/argos/rodada.py b/argos/rodada.py index 58142cb..075f5bf 100644 --- a/argos/rodada.py +++ b/argos/rodada.py @@ -173,7 +173,7 @@ def rodar( ) -> Rodada: hoje = hoje or date.today() rodada = nova_rodada(casador, "rodar", agora) - corpus = Corpus(config.corpus, guardar=config.guardar_texto) + corpus = Corpus(config.corpus) for fonte in fontes: memoria = estado.ler(config.estado, fonte.id) if usar_estado else estado.Estado(fonte.id) inicio = desde diff --git a/argos/saidas/catalogo.py b/argos/saidas/catalogo.py deleted file mode 100644 index baacc70..0000000 --- a/argos/saidas/catalogo.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Catálogo cumulativo em CSV: uma linha por URL, com o que decidiu buscá-la. - -Três colunas, e só três: - -| Coluna | Conteúdo | -|-------------------|-----------------------------------------------------------------| -| `id` | os 16 primeiros dígitos do sha256 da URL: estável entre rodadas | -| `url` | o endereço do item | -| `identificadores` | as chaves do léxico que casaram na primeira vez, separadas por `;` | - -O arquivo cresce entre rodadas e nunca reescreve uma linha: a URL que volta numa rodada -seguinte, ou por outro canal, mantém os identificadores da primeira vez. O nível, o -motivo e o trecho de cada item continuam nos relatórios da rodada; o catálogo é o índice. -""" - -from __future__ import annotations - -import csv -import hashlib -import os -from pathlib import Path - -from argos.rodada import TRIADO, Rodada - -COLUNAS = ("id", "url", "identificadores") - - -def id_da_url(url: str) -> str: - return hashlib.sha256(url.strip().encode("utf-8")).hexdigest()[:16] - - -def ler(caminho: Path) -> list[dict[str, str]]: - if not caminho.exists(): - return [] - with caminho.open(encoding="utf-8", newline="") as arquivo: - return list(csv.DictReader(arquivo)) - - -def atualizar(rodada: Rodada, caminho: Path, nivel_minimo: int = 2) -> int: - """Acrescenta as URLs novas da rodada com nível >= `nivel_minimo`. Devolve quantas.""" - caminho = Path(caminho) - linhas = ler(caminho) - conhecidos = {linha["id"] for linha in linhas} - novas = 0 - for item in rodada.linhas: - if item.situacao != TRIADO or (item.nivel or 0) < nivel_minimo: - continue - ident = id_da_url(item.ref.url) - if ident in conhecidos: - continue - conhecidos.add(ident) - linhas.append( - {"id": ident, "url": item.ref.url, "identificadores": ";".join(item.resultado.chaves)} - ) - novas += 1 - if novas or not caminho.exists(): - caminho.parent.mkdir(parents=True, exist_ok=True) - temporario = caminho.with_suffix(".csv.part") - with temporario.open("w", encoding="utf-8", newline="") as arquivo: - escritor = csv.DictWriter(arquivo, fieldnames=COLUNAS) - escritor.writeheader() - escritor.writerows(linhas) - os.replace(temporario, caminho) - return novas diff --git a/docs/carandiru.md b/docs/carandiru.md deleted file mode 100644 index 07a6305..0000000 --- a/docs/carandiru.md +++ /dev/null @@ -1,13 +0,0 @@ -# Observatório do Massacre do Carandiru - -Este plano mudou de casa: o que é do observatório (léxico, fontes, catálogo, estimativa e -o plano de trabalho) mora no repositório -[`luccas-amorim/observatory`](https://github.com/luccas-amorim/observatory), que usa o -Argos como dependência fixada por commit. - -O que ficou aqui é o que serve a qualquer consumidor: - -- os adaptadores `wayback`, `openalex`, `gdelt` e `rss` (`argos/fontes/`); -- o catálogo CSV cumulativo e o modo `"guardar_texto": false` (`argos/saidas/catalogo.py`); -- a estimativa de cobertura por captura e recaptura, com canais por prefixo e estratos - (`argos/estimativa.py`, comando `argos estimar`). diff --git a/docs/precatorios.md b/docs/precatorios.md index 9f03f73..2f9e768 100644 --- a/docs/precatorios.md +++ b/docs/precatorios.md @@ -1,7 +1,9 @@ # Precatórios: cenários para decidir a expansão -Esboço para direcionar, não plano. A pergunta que ele quer ajudar a responder: **vale -levar o Argos aos precatórios, e por qual cenário começar?** +Esboço para direcionar, não plano. **Decidido em 04/10/2026:** o Argos ganha estudos de +precatórios e processamento de grandes volumes de dados em planilha, como funcionalidade +adicional à vigilância dos tribunais para o AtlasPen. A pergunta que fica: **por qual +cenário começar?** ## Por que é outro bicho @@ -94,18 +96,19 @@ beneficiário, por determinação do CNJ) com processos e outras fontes. É exat cruzamento para montar perfil de pessoa que o README do Argos veda. Os cenários A, B e C não precisam disso. -## Onde isso moraria +## Onde isso mora -O mesmo desenho do AtlasPen e do observatório: **o que é geral fica no Argos, o que é do -domínio fica no consumidor.** +**No Argos** (decisão de 04/10/2026), em duas camadas: -- **No Argos**, e só se o primeiro cenário provar que serve: leitura de tabela (PDF e - XLSX), retrato tabular guardado e diferença entre retratos. São peças que outros - consumidores também usariam (listas do STF, por exemplo). -- **Num repositório próprio**: o esquema dos precatórios, o leitor de cada tribunal, as - regras de natureza alimentar e prioridade, o regime especial, os relatórios. -- **O cenário C pode ser outra ferramenta**, irmã do Argos, porque não vigia nada: entra - arquivo, sai tabela limpa com relatório. +- **Peças gerais**: leitura de tabela (PDF e XLSX), retrato tabular guardado, diferença + entre retratos, validadores (número CNJ, moeda, data). Servem também à vigilância dos + tribunais: as listas do STF são tabela, e o `lista` já dá o primeiro passo. +- **O domínio dos precatórios**: o esquema, o leitor de cada tribunal, as regras de + natureza alimentar e prioridade, o regime especial, os relatórios. Mora num + subpacote próprio, para não se misturar com a vigilância de jurisprudência. + +O cenário C (planilhas) não vigia nada: entra arquivo, sai tabela limpa com relatório. +Por isso ganha comando próprio, ao lado de `rodar`, e não uma fonte. ## O que decidir diff --git a/pyproject.toml b/pyproject.toml index 0aacd52..a73f5fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "argos" version = "0.1.0" -description = "Vigilância determinística de fontes abertas, com léxico como dado e descarte auditável." +description = "Vigilância determinística dos tribunais superiores e do CNJ para o AtlasPen, com léxico como dado e descarte auditável." readme = "README.md" license = { text = "MIT" } authors = [{ name = "Luccas de Amorim" }] diff --git a/tests/apoio.py b/tests/apoio.py index fe4ee45..d920589 100644 --- a/tests/apoio.py +++ b/tests/apoio.py @@ -4,8 +4,8 @@ from argos import config, fontes, lexico, rodada -FEED = "https://jornal.exemplo.br/feed" -BASE = "https://jornal.exemplo.br/2026/10" +FEED = "https://tribunal.exemplo.jus.br/rss" +BASE = "https://tribunal.exemplo.jus.br/noticias/2026/10" def _rodar(caminho, rede, **kw): diff --git a/tests/conftest.py b/tests/conftest.py index 53f9ab5..bef6571 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,13 +62,13 @@ def fixtures() -> Path: @pytest.fixture def cfg(tmp_path, fixtures): - """Configuração mínima: um feed RSS e o léxico de amostra do Carandiru.""" - shutil.copy(fixtures / "lexico-carandiru.json", tmp_path / "lexico.json") + """Configuração mínima: o feed de notícias de um tribunal e um léxico penal de amostra.""" + shutil.copy(fixtures / "lexico-penal.json", tmp_path / "lexico.json") dados = { "lexico": "lexico.json", "diretorio": "dados", "desde": "2026-09-01", - "fontes": [{"tipo": "rss", "id": "rss:jornal", "url": FEED, "minimo_caracteres": 200}], + "fontes": [{"tipo": "rss", "id": "rss:tribunal", "url": FEED, "minimo_caracteres": 200}], } caminho = tmp_path / "config.json" caminho.write_text(json.dumps(dados), encoding="utf-8") @@ -76,9 +76,9 @@ def cfg(tmp_path, fixtures): @pytest.fixture -def rede_jornal(rede, fixtures): +def rede_tribunal(rede, fixtures): rede.responder(FEED, fixtures / "rss" / "feed.xml") - rede.responder(f"{BASE}/carandiru-34-anos", fixtures / "rss" / "materia-carandiru.html") - rede.responder(f"{BASE}/drenagem", fixtures / "rss" / "materia-outra.html") + rede.responder(f"{BASE}/tese-furto-qualificado", fixtures / "rss" / "noticia-tese.html") + rede.responder(f"{BASE}/conciliacao", fixtures / "rss" / "noticia-outra.html") rede.responder(f"{BASE}/sumiu", fixtures / "rss" / "erro-200.html") return rede diff --git a/tests/fixtures/LEIAME.md b/tests/fixtures/LEIAME.md index ff7e342..41f2b0f 100644 --- a/tests/fixtures/LEIAME.md +++ b/tests/fixtures/LEIAME.md @@ -3,7 +3,7 @@ Os testes rodam sem rede, contra estes arquivos. **Quase todos sintéticos, por ora.** A v0.1 foi escrita num ambiente cuja rede bloqueava -a maior parte das fontes (DJEN, dados abertos do STJ, Wayback), então estes arquivos +a maior parte das fontes (DJEN, dados abertos do STJ), então estes arquivos imitam o formato documentado de cada origem. Cada um deve ser trocado por uma resposta real, gravada, na primeira rodada com rede. @@ -15,18 +15,14 @@ fixture sintética supunha. Só traz metadado processual público (classe, assun | Arquivo | Imita | |---|---| -| `rss/feed.xml` | RSS 2.0 de um veículo de imprensa | +| `rss/feed.xml` | RSS 2.0 das notícias de um tribunal | | `rss/atom.xml` | Atom 1.0 de um repositório | -| `rss/materia-carandiru.html` | matéria em windows-1252 declarado como iso-8859-1 | -| `rss/materia-outra.html` | matéria sem relação com o léxico | +| `rss/noticia-tese.html` | notícia de tese em repetitivo, em windows-1252 declarado como iso-8859-1; o Tema 9.999 é fictício, para não atribuir tese a tribunal | +| `rss/noticia-outra.html` | notícia sem relação com o léxico | | `rss/erro-200.html` | página de erro servida com HTTP 200 | | `datajud/pagina1.json` | resposta de `_search` do DataJud, com assuntos aninhados e um processo em sigilo | | `datajud/stm-real-2026-10-04.json` | **real**: `_search` no índice do STM, dois processos | | `djen/stj-2026-10-01.json` | página da API do DJEN: um acórdão com ementa, um despacho, uma decisão monocrática; com destinatários fictícios, para provar que são descartados | | `lista/sumulas.html` | página com duas tabelas (menu e lista), cabeçalho em ``, linha sem número; números fictícios (901 a 903) para não atribuir texto a súmula real | -| `wayback/cdx-p1.json`, `cdx-p2.json` | respostas do servidor CDX em JSON, a primeira com chave de retomada | -| `wayback/captura.html`, `nao-arquivada.html` | cópia arquivada (modo `id_`) e a página de "não arquivada" servida com 200 | -| `openalex/p1.json`, `p2.json` | `/works` com resumo em índice invertido e paginação por cursor | -| `gdelt/dia.json` | `artlist` em JSON do DOC 2.0 | | `atlaspen/data/` | **real**: recorte de `crimes.json` e `diplomas.json` do AtlasPen (73 registros de sete artigos), copiado sem alteração do clone em 04/10/2026; CC BY 4.0, Luccas de Amorim | -| `lexico-carandiru.json` | léxico de amostra; o real é mantido pela pesquisa | +| `lexico-penal.json` | léxico de amostra no formato que `exemplos/atlaspen/lexico.py` gera | diff --git a/tests/fixtures/gdelt/dia.json b/tests/fixtures/gdelt/dia.json deleted file mode 100644 index 607d38c..0000000 --- a/tests/fixtures/gdelt/dia.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "articles": [ - { - "url": "https://noticias.exemplo.com/2026/10/02/carandiru-34-anos", - "url_mobile": "", - "title": "Carandiru: 34 anos do massacre", - "seendate": "20261002T120000Z", - "socialimage": "", - "domain": "noticias.exemplo.com", - "language": "Portuguese", - "sourcecountry": "Brazil" - }, - { - "url": "https://news.example.org/brazil-prison-massacre", - "url_mobile": "", - "title": "Brazil marks prison massacre anniversary", - "seendate": "20261002T150000Z", - "socialimage": "", - "domain": "news.example.org", - "language": "English", - "sourcecountry": "United States" - } - ] -} \ No newline at end of file diff --git a/tests/fixtures/lexico-carandiru.json b/tests/fixtures/lexico-carandiru.json deleted file mode 100644 index 2f7270b..0000000 --- a/tests/fixtures/lexico-carandiru.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "id": "carandiru-amostra", - "versao": "2026-10-04", - "descricao": "Amostra para testes. O léxico real é mantido pela pesquisa do observatório.", - "termos": [ - { - "chave": "massacre-do-carandiru", - "formas": ["massacre do Carandiru", "chacina do Carandiru", "Carandiru"], - "exclusoes": ["Carandiru, o filme", "estação Carandiru"], - "peso": 1.0, - "categorias": ["evento"], - "nota": "O filme de 2003 e a estação de metrô são outros registros." - }, - { - "chave": "casa-de-detencao", - "formas": ["Casa de Detenção", "Pavilhão 9"], - "categorias": ["lugar"] - }, - { - "chave": "indulto", - "formas": ["indulto", "perdão"], - "peso": 0.5, - "categorias": ["desdobramento-juridico"] - } - ], - "contexto": { - "exige_qualquer": ["1992", "presos", "policiais", "Polícia Militar", "massacre"], - "nota": "Separa o evento de usos do nome do bairro." - } -} diff --git a/tests/fixtures/lexico-penal.json b/tests/fixtures/lexico-penal.json new file mode 100644 index 0000000..1735d16 --- /dev/null +++ b/tests/fixtures/lexico-penal.json @@ -0,0 +1,30 @@ +{ + "id": "penal-amostra", + "versao": "2026-10-04", + "descricao": "Amostra para testes, no formato que exemplos/atlaspen/lexico.py gera.", + "termos": [ + { + "chave": "cp-art-155", + "formas": ["art. 155 … Código Penal", "art. 155 … CP", "CP, art. 155"], + "exclusoes": ["art. 155 … Código Penal Militar"], + "categorias": ["cp"] + }, + { + "chave": "cp-art-121", + "formas": ["art. 121 … Código Penal", "art. 121 … CP", "homicídio"], + "exclusoes": ["homicídio culposo na direção"], + "categorias": ["cp"], + "nota": "A exclusão aponta para o art. 302 do CTB, que é outro registro." + }, + { + "chave": "nome-furto-qualificado", + "formas": ["furto qualificado"], + "peso": 0.5, + "categorias": ["nome"] + } + ], + "contexto": { + "exige_qualquer": ["acórdão", "recurso", "tese", "repetitivo", "condenação", "pena", "habeas corpus"], + "nota": "Vocabulário decisório: separa decisão de texto que só menciona o artigo." + } +} diff --git a/tests/fixtures/openalex/p1.json b/tests/fixtures/openalex/p1.json deleted file mode 100644 index 0de93ea..0000000 --- a/tests/fixtures/openalex/p1.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "meta": { - "count": 2, - "per_page": 200, - "next_cursor": "Ij1hYmMi" - }, - "results": [ - { - "id": "https://openalex.org/W1000000001", - "doi": "https://doi.org/10.0000/exemplo.1", - "display_name": "Memória e violência de Estado: o massacre do Carandiru", - "publication_date": "2026-09-30", - "type": "article", - "abstract_inverted_index": { - "Este": [ - 0 - ], - "artigo": [ - 1 - ], - "analisa": [ - 2 - ], - "a": [ - 3 - ], - "memória": [ - 4 - ], - "do": [ - 5 - ], - "massacre": [ - 6 - ], - "de": [ - 7 - ], - "1992.": [ - 8 - ] - }, - "primary_location": { - "landing_page_url": "https://revista.exemplo.br/artigo/1", - "source": { - "display_name": "Revista de Exemplo" - } - }, - "open_access": { - "oa_url": "https://revista.exemplo.br/artigo/1.pdf" - } - }, - { - "id": "https://openalex.org/W1000000002", - "doi": null, - "display_name": "Arquitetura prisional em São Paulo", - "publication_date": "2026-08-15", - "type": "dissertation", - "abstract_inverted_index": { - "Estudo": [ - 0 - ], - "sobre": [ - 1 - ], - "o": [ - 2 - ], - "Carandiru": [ - 3 - ], - "e": [ - 4 - ], - "presos": [ - 5 - ], - "de": [ - 6 - ], - "1992.": [ - 7 - ] - }, - "primary_location": { - "landing_page_url": "https://repositorio.exemplo.br/handle/1", - "source": null - }, - "open_access": {} - } - ] -} \ No newline at end of file diff --git a/tests/fixtures/openalex/p2.json b/tests/fixtures/openalex/p2.json deleted file mode 100644 index 229ea82..0000000 --- a/tests/fixtures/openalex/p2.json +++ /dev/null @@ -1 +0,0 @@ -{"meta": {"count": 2, "next_cursor": null}, "results": []} \ No newline at end of file diff --git a/tests/fixtures/rss/atom.xml b/tests/fixtures/rss/atom.xml index e8c4f59..e2b6aab 100644 --- a/tests/fixtures/rss/atom.xml +++ b/tests/fixtures/rss/atom.xml @@ -2,7 +2,7 @@ Repositório de Exemplo - A Casa de Detenção e a memória do 2 de outubro + Dosimetria da pena nos tribunais superiores oai:repositorio.exemplo.br:123/456 diff --git a/tests/fixtures/rss/erro-200.html b/tests/fixtures/rss/erro-200.html index ce56de8..36391b7 100644 --- a/tests/fixtures/rss/erro-200.html +++ b/tests/fixtures/rss/erro-200.html @@ -1,3 +1,3 @@ Página não encontrada

Página não encontrada

O endereço que você procurou não existe ou foi removido. -Volte para a página inicial e tente novamente. Carandiru.

+Volte para a página inicial e tente novamente. Código Penal.

diff --git a/tests/fixtures/rss/feed.xml b/tests/fixtures/rss/feed.xml index 344fa0b..2028d20 100644 --- a/tests/fixtures/rss/feed.xml +++ b/tests/fixtures/rss/feed.xml @@ -1,31 +1,31 @@ - Jornal de Exemplo - https://jornal.exemplo.br/ + Tribunal de Exemplo: notícias + https://tribunal.exemplo.jus.br/ - Massacre do Carandiru: 34 anos depois, julgamento segue sem fim - https://jornal.exemplo.br/2026/10/carandiru-34-anos - jornal-1001 + Terceira Seção fixa tese sobre furto qualificado em recurso repetitivo + https://tribunal.exemplo.jus.br/noticias/2026/10/tese-furto-qualificado + noticia-1001 Fri, 02 Oct 2026 09:00:00 -0300 Resumo curto que não deve decidir nada. - Prefeitura anuncia obras de drenagem - https://jornal.exemplo.br/2026/10/drenagem - jornal-1002 + Tribunal promove semana de conciliação + https://tribunal.exemplo.jus.br/noticias/2026/10/conciliacao + noticia-1002 Thu, 01 Oct 2026 18:30:00 -0300 Página que sumiu - https://jornal.exemplo.br/2026/10/sumiu - jornal-1003 + https://tribunal.exemplo.jus.br/noticias/2026/10/sumiu + noticia-1003 Thu, 01 Oct 2026 12:00:00 -0300 Notícia antiga - https://jornal.exemplo.br/2026/08/antiga - jornal-0900 + https://tribunal.exemplo.jus.br/noticias/2026/08/antiga + noticia-0900 Mon, 03 Aug 2026 10:00:00 -0300 diff --git a/tests/fixtures/rss/materia-carandiru.html b/tests/fixtures/rss/materia-carandiru.html deleted file mode 100644 index 0f2b65d..0000000 --- a/tests/fixtures/rss/materia-carandiru.html +++ /dev/null @@ -1,27 +0,0 @@ - - - -Carandiru: 34 anos depois - Jornal de Exemplo - - - - - -
-

Massacre do Carandiru: 34 anos depois, julgamento segue sem fim

-

Em 2 de outubro de 1992, a Polícia Militar invadiu o Pavilhão 9 da Casa de Detenção de -São Paulo. Cento e onze presos foram mortos. Trinta e quatro anos depois, o processo -contra os policiais ainda não transitou em julgado.

-

O Tribunal do Júri condenou 74 policiais entre 2013 e 2014. Em 2016, a 4ª Câmara -Criminal do Tribunal de Justiça anulou os julgamentos; o Superior Tribunal de Justiça -restabeleceu as condenações em 2021.

-

Em dezembro de 2022, um decreto presidencial de indulto alcançou os condenados, e a -validade do perdão a agentes do Estado acusados de execuções em massa foi levada ao STF. -Para os familiares, a memória do massacre é também uma disputa sobre o que o Estado -reconhece como violação de direitos humanos.

-

A organização de familiares mantém um acervo com documentos, fotografias e -depoimentos, e defende que o antigo complexo prisional, hoje Parque da Juventude, -abrigue um memorial.

-
-
Todos os direitos reservados. Leia também: Carandiru, o filme.
- diff --git a/tests/fixtures/rss/materia-outra.html b/tests/fixtures/rss/materia-outra.html deleted file mode 100644 index 74e8e70..0000000 --- a/tests/fixtures/rss/materia-outra.html +++ /dev/null @@ -1,4 +0,0 @@ -Obras de drenagem -

Prefeitura anuncia obras de drenagem

-

A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses. A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses. A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses. A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses. A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses. A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses. A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses. A prefeitura anunciou nesta quinta-feira um pacote de obras de drenagem para os bairros da zona leste, com previsão de conclusão em dezoito meses.

-
diff --git a/tests/fixtures/rss/noticia-outra.html b/tests/fixtures/rss/noticia-outra.html new file mode 100644 index 0000000..3f69366 --- /dev/null +++ b/tests/fixtures/rss/noticia-outra.html @@ -0,0 +1,4 @@ +Semana de conciliação +

Tribunal promove semana de conciliação

+

O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet. O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet. O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet. O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet. O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet. O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet. O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet. O tribunal promove nesta semana um mutirão de conciliação em processos de natureza cível, com atendimento em todas as unidades e agendamento pela internet.

+
diff --git a/tests/fixtures/rss/noticia-tese.html b/tests/fixtures/rss/noticia-tese.html new file mode 100644 index 0000000..d1d7cf2 --- /dev/null +++ b/tests/fixtures/rss/noticia-tese.html @@ -0,0 +1,23 @@ + + + +Tese sobre furto qualificado - Tribunal de Exemplo + + + + + +
+

Terceira Seção fixa tese sobre furto qualificado em recurso repetitivo

+

A Terceira Seção do Tribunal de Exemplo fixou tese no Tema 9.999, um tema fictício que +serve só a este teste: no furto qualificado pelo rompimento de obstáculo, previsto no +art. 155, § 4º, I, do Código Penal, a qualificadora exige prova da violência contra a +coisa.

+

O recurso especial foi julgado sob o rito dos repetitivos, e a tese deverá ser +observada pelos tribunais de todo o país. O relator lembrou que a pena do furto +qualificado é de reclusão, de dois a oito anos, e multa.

+

O acórdão ainda não foi publicado no Diário de Justiça Eletrônico Nacional, e o +número do processo não é divulgado em razão de segredo de justiça.

+
+
Todos os direitos reservados. Leia também: Código Penal Militar comentado.
+ diff --git a/tests/fixtures/wayback/captura.html b/tests/fixtures/wayback/captura.html deleted file mode 100644 index 6206c6c..0000000 --- a/tests/fixtures/wayback/captura.html +++ /dev/null @@ -1,8 +0,0 @@ -Carandiru, dez anos depois -

Carandiru, dez anos depois

-

Dez anos depois do massacre de 2 de outubro de 1992, quando 111 presos foram mortos pela Polícia Militar -no Pavilhão 9 da Casa de Detenção, nenhum dos policiais acusados cumpriu pena. Familiares das vítimas -reúnem-se na zona norte de São Paulo para lembrar a data e cobrar o julgamento.

-

O coronel que comandou a operação foi condenado em 2001 e absolvido em 2006 pelo Órgão Especial do -Tribunal de Justiça. Os demais réus aguardam o júri. A Casa de Detenção deve ser desativada.

-
\ No newline at end of file diff --git a/tests/fixtures/wayback/cdx-p1.json b/tests/fixtures/wayback/cdx-p1.json deleted file mode 100644 index e9b0d1e..0000000 --- a/tests/fixtures/wayback/cdx-p1.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - [ - "timestamp", - "original", - "statuscode", - "mimetype", - "digest" - ], - [ - "19990301120000", - "http://www1.jornal.exemplo.br/fsp/cotidian/carandiru-ff02109912.htm", - "200", - "text/html", - "AAAA" - ], - [ - "20021002080000", - "http://www.jornal.exemplo.br/carandiru-10-anos.shtml", - "200", - "text/html", - "BBBB" - ], - [], - [ - "br,exemplo,jornal)/carandiru-10-anos.shtml 20021002080000" - ] -] \ No newline at end of file diff --git a/tests/fixtures/wayback/cdx-p2.json b/tests/fixtures/wayback/cdx-p2.json deleted file mode 100644 index b614d33..0000000 --- a/tests/fixtures/wayback/cdx-p2.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - [ - "timestamp", - "original", - "statuscode", - "mimetype", - "digest" - ], - [ - "20160927090000", - "https://www.jornal.exemplo.br/2016/09/carandiru-julgamento-anulado.shtml", - "200", - "text/html", - "CCCC" - ] -] \ No newline at end of file diff --git a/tests/fixtures/wayback/nao-arquivada.html b/tests/fixtures/wayback/nao-arquivada.html deleted file mode 100644 index 88b487c..0000000 --- a/tests/fixtures/wayback/nao-arquivada.html +++ /dev/null @@ -1 +0,0 @@ -

Hrm.

Wayback Machine doesn't have that page archived.

\ No newline at end of file diff --git a/tests/test_catalogo.py b/tests/test_catalogo.py deleted file mode 100644 index 4c76cc5..0000000 --- a/tests/test_catalogo.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Catálogo CSV: id, url e os identificadores que decidiram buscar o item.""" - -import csv -import json - -from argos import cli, config, fontes -from argos.saidas import catalogo -from tests.apoio import _rodar - - -def _linhas(caminho): - with caminho.open(encoding="utf-8", newline="") as arquivo: - return list(csv.DictReader(arquivo)) - - -def test_catalogo_tem_tres_colunas_e_so_o_que_passou(cfg, rede_jornal, tmp_path): - c, r = _rodar(cfg, rede_jornal) - caminho = tmp_path / "catalogo.csv" - assert catalogo.atualizar(r, caminho) == 1 - (linha,) = _linhas(caminho) - assert list(linha) == ["id", "url", "identificadores"] - assert linha["url"] == "https://jornal.exemplo.br/2026/10/carandiru-34-anos" - assert linha["id"] == catalogo.id_da_url(linha["url"]) - assert linha["identificadores"].split(";")[0] == "massacre-do-carandiru" - - -def test_catalogo_e_cumulativo_e_guarda_os_identificadores_iniciais(cfg, rede_jornal, tmp_path): - _, r = _rodar(cfg, rede_jornal) - caminho = tmp_path / "catalogo.csv" - catalogo.atualizar(r, caminho) - antes = caminho.read_text(encoding="utf-8") - r.linhas[0].resultado.chaves = ["outra-chave"] - assert catalogo.atualizar(r, caminho) == 0 - assert caminho.read_text(encoding="utf-8") == antes - - -def test_nivel_minimo(cfg, rede_jornal, tmp_path): - _, r = _rodar(cfg, rede_jornal) - assert catalogo.atualizar(r, tmp_path / "a.csv", nivel_minimo=0) == 2 - assert catalogo.atualizar(r, tmp_path / "b.csv", nivel_minimo=3) == 1 - - -def test_sem_guardar_texto_nada_vai_para_o_corpus(cfg, rede_jornal): - dados = json.loads(cfg.read_text()) - dados["guardar_texto"] = False - cfg.write_text(json.dumps(dados)) - c, r = _rodar(cfg, rede_jornal) - assert not c.corpus.exists() - assert [linha.nivel for linha in r.linhas if linha.nivel is not None] == [3, 0] - assert all(linha.corpus is None for linha in r.linhas) - - -def test_cli_atualiza_o_catalogo(cfg, monkeypatch, rede_jornal, capsys): - dados = json.loads(cfg.read_text()) - dados["catalogo"] = {"arquivo": "catalogo.csv"} - cfg.write_text(json.dumps(dados)) - construir = fontes.construir - monkeypatch.setattr(fontes, "construir", lambda f: construir(f, rede_jornal)) - assert cli.main(["rodar", "--config", str(cfg)]) == 3 - assert len(_linhas(config.carregar(cfg).catalogo_csv)) == 1 - assert "catálogo: 1 URLs novas" in capsys.readouterr().out diff --git a/tests/test_descoberta.py b/tests/test_descoberta.py deleted file mode 100644 index 89a65c6..0000000 --- a/tests/test_descoberta.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Canais de descoberta do observatório: wayback, openalex, gdelt.""" - -import json -from datetime import date -from urllib.parse import parse_qs, urlsplit - -from argos import fontes -from argos.fontes import gdelt, openalex, wayback - - -def _query(url): - return parse_qs(urlsplit(url).query) - - -# --- wayback ----------------------------------------------------------------------- - - -def _wayback(rede): - cfg = {"tipo": "wayback", "id": "wayback:jornal", "dominio": "jornal.exemplo.br"} - return fontes.construir(cfg, rede) - - -def test_wayback_pagina_pelo_resume_key(rede, fixtures): - rede.responder(wayback.CDX, fixtures / "wayback" / "cdx-p1.json") - rede.responder(wayback.CDX, fixtures / "wayback" / "cdx-p2.json") - refs = list(_wayback(rede).listar(date(1996, 1, 1))) - - assert len(refs) == 3 - primeira = refs[0] - assert ( - primeira.id_na_fonte - == "http://www1.jornal.exemplo.br/fsp/cotidian/carandiru-ff02109912.htm" - ) - assert primeira.url == f"{wayback.ARQUIVO}/19990301120000id_/{primeira.id_na_fonte}" - assert primeira.publicado_em == date(1999, 3, 1) # data da captura - - q1, q2 = (_query(p.url) for p in rede.pedidos) - assert q1["filter"] == ["original:(?i).*carandiru.*", "statuscode:200", "mimetype:text/html"] - assert q1["matchType"] == ["domain"] and q1["collapse"] == ["urlkey"] - assert q1["from"] == ["19960101"] - assert "resumeKey" not in q1 - assert q2["resumeKey"] == ["br,exemplo,jornal)/carandiru-10-anos.shtml 20021002080000"] - assert "robots.txt" not in " ".join(rede.urls()) - - -def test_wayback_baixa_a_copia_e_reprova_nao_arquivada(rede, fixtures): - fonte = _wayback(rede) - rede.responder(wayback.CDX, fixtures / "wayback" / "cdx-p2.json") - (ref,) = list(fonte.listar(date(1996, 1, 1))) - rede.responder(ref.url, fixtures / "wayback" / "captura.html") - doc = fonte.baixar(ref) - assert doc.destaques["manchete"] == "Carandiru, dez anos depois" - assert "Pavilhão 9" in doc.texto - assert fonte.sentinela(doc) - - rede.rotas[ref.url] = [] - rede.responder(ref.url, fixtures / "wayback" / "nao-arquivada.html") - fonte.minimo_caracteres = 10 - assert not fonte.sentinela(fonte.baixar(ref)) - - -# --- openalex ---------------------------------------------------------------------- - - -def test_openalex_cursor_resumo_e_chave(rede, fixtures, monkeypatch): - monkeypatch.setenv("OPENALEX_API_KEY", "chave-de-teste") - rede.responder(openalex.URL, fixtures / "openalex" / "p1.json") - rede.responder(openalex.URL, fixtures / "openalex" / "p2.json") - cfg = {"tipo": "openalex", "id": "openalex:carandiru", "busca": "carandiru"} - fonte = fontes.construir(cfg, rede) - artigo, dissertacao = list(fonte.listar(date(2026, 1, 1))) - - q1, q2 = (_query(p.url) for p in rede.pedidos) - assert q1["cursor"] == ["*"] and q2["cursor"] == ["Ij1hYmMi"] - assert q1["filter"] == ["from_publication_date:2026-01-01"] - assert q1["api_key"] == ["chave-de-teste"] - assert not any("mailto" in p.url for p in rede.pedidos) - - assert artigo.url == "https://doi.org/10.0000/exemplo.1" - assert dissertacao.url == "https://repositorio.exemplo.br/handle/1" # sem DOI - doc = fonte.baixar(artigo) - assert "Este artigo analisa a memória do massacre de 1992." in doc.texto - assert doc.destaques["titulo"].startswith("Memória e violência de Estado") - assert fonte.sentinela(doc) - - -def test_resumo_invertido_vazio(): - assert openalex.resumo(None) == "" - assert openalex.resumo({"b": [1], "a": [0, 2]}) == "a b a" - - -# --- gdelt ------------------------------------------------------------------------- - - -def test_gdelt_um_dia_por_vez_dentro_da_janela(rede, fixtures): - rede.responder(gdelt.URL, fixtures / "gdelt" / "dia.json") - cfg = {"tipo": "gdelt", "id": "gdelt:carandiru", "busca": "carandiru"} - fonte = fontes.construir(cfg, rede) - fonte.hoje = lambda: date(2026, 10, 4) - refs = list(fonte.listar(date(2020, 1, 1))) # anterior à janela: cortado - - consultas = [_query(p.url) for p in rede.pedidos if p.url.startswith(gdelt.URL)] - assert len(consultas) == 91 # 90 dias de janela, mais hoje - assert consultas[0]["startdatetime"] == ["20260706000000"] - assert consultas[0]["enddatetime"] == ["20260707000000"] - assert {r.id_na_fonte for r in refs} == { - "https://noticias.exemplo.com/2026/10/02/carandiru-34-anos", - "https://news.example.org/brazil-prison-massacre", - } - assert refs[0].publicado_em == date(2026, 10, 2) - - -def test_gdelt_dia_cheio_e_repartido_em_seis_horas(rede): - cheio = { - "articles": [ - {"url": f"https://x.br/{i}", "seendate": "20261002T000000Z"} for i in range(250) - ] - } - rede.responder(gdelt.URL, json.dumps(cheio)) - rede.responder(gdelt.URL, json.dumps({"articles": []})) - cfg = {"tipo": "gdelt", "id": "gdelt:x", "busca": "x"} - fonte = fontes.construir(cfg, rede) - fonte.hoje = lambda: date(2026, 10, 2) - list(fonte.listar(date(2026, 10, 2))) - api = [p for p in rede.pedidos if p.url.startswith(gdelt.URL)] - faixas = [_query(p.url)["startdatetime"][0] for p in api][1:] - assert faixas == ["20261002000000", "20261002060000", "20261002120000", "20261002180000"] diff --git a/tests/test_estimativa.py b/tests/test_estimativa.py deleted file mode 100644 index 66f2866..0000000 --- a/tests/test_estimativa.py +++ /dev/null @@ -1,99 +0,0 @@ -import json -from datetime import date - -import pytest - -from argos import cli, estimativa -from argos.estimativa import Par, normalizar_url - - -@pytest.mark.parametrize( - ("url", "esperado"), - [ - ("https://www.Jornal.br/a/b/", "jornal.br/a/b"), - ("http://jornal.br/a/b?utm_source=x&id=3", "jornal.br/a/b?id=3"), - ("https://web.archive.org/web/20021002080000id_/http://www.jornal.br/a/b", "jornal.br/a/b"), - ("https://web.archive.org/web/2002/https://jornal.br/a/b", "jornal.br/a/b"), - ("jornal.br", "jornal.br/"), - ], -) -def test_normalizar_url(url, esperado): - assert normalizar_url(url) == esperado - - -def test_chapman_com_numeros_conhecidos(): - # n1 = 100, n2 = 80, em comum 40: Lincoln-Petersen daria 200; Chapman, 199,4. - par = Par("a", "b", 100, 80, 40) - assert par.chapman == pytest.approx(101 * 81 / 41 - 1) - baixo, alto = par.intervalo - assert 140 <= baixo < par.chapman < alto - - -def _rodada(saidas, nome, itens): - pasta = saidas / f"{nome}-rodar" - pasta.mkdir(parents=True) - linhas = [ - json.dumps({"fonte": f, "url": u, "situacao": "triado", "nivel": n}) for f, u, n in itens - ] - (pasta / "itens.jsonl").write_text("\n".join(linhas) + "\n", encoding="utf-8") - - -def test_calcular_canais_chao_e_curva(tmp_path): - saidas = tmp_path / "saidas" - _rodada(saidas, "20261001T000000Z", [ - ("wayback:j", "https://web.archive.org/web/1999id_/http://j.br/1", 3), - ("wayback:k", "https://web.archive.org/web/1999id_/http://k.br/2", 2), - ("wayback:j", "https://web.archive.org/web/1999id_/http://j.br/ruido", 1), - ]) # fmt: skip - _rodada(saidas, "20261008T000000Z", [ - ("gdelt:c", "https://www.j.br/1", 3), - ("gdelt:c", "https://k.br/9", 2), - ("rss:jornal", "https://k.br/9", 3), - ("openalex:c", "https://doi.org/10.1/x", 3), - ]) # fmt: skip - (saidas / "20261009T000000Z-retriar").mkdir() # retriagem não é rodada de descoberta - - estrato = {"openalex:c": "academico"} - por_estrato = estimativa.calcular(saidas, estrato_de=lambda f: estrato.get(f, "imprensa")) - imprensa, academico = por_estrato["imprensa"], por_estrato["academico"] - - # Dois domínios do Wayback são um canal só; o nível 1 fica de fora. - assert imprensa.por_canal["wayback"] == {"j.br/1", "k.br/2"} - assert set(imprensa.por_canal) == {"wayback", "gdelt", "rss"} - assert len(imprensa.observadas) == 3 - assert imprensa.frequencias == {2: 2, 1: 1} - assert imprensa.chao == 3 + 1 * 1 / (2 * 2) - assert imprensa.curva == [("20261001T000000Z", 2, 2), ("20261008T000000Z", 1, 3)] - assert academico.chao is None # um canal só no estrato: sem estimativa - - texto = estimativa.relatorio(por_estrato) - assert "## Estrato `imprensa`" in texto and "## Estrato `academico`" in texto - assert "`gdelt` × `wayback` | 2 | 2 | 1 |" in texto - - -def test_desde_alinha_no_tempo(tmp_path): - saidas = tmp_path / "saidas" - pasta = saidas / "20261001T000000Z-rodar" - pasta.mkdir(parents=True) - itens = [ - {"fonte": "wayback:j", "url": "https://j.br/velha", "situacao": "triado", "nivel": 3, - "publicado_em": "2002-10-02"}, - {"fonte": "wayback:j", "url": "https://j.br/nova", "situacao": "triado", "nivel": 3, - "publicado_em": "2026-09-30"}, - ] # fmt: skip - (pasta / "itens.jsonl").write_text("\n".join(json.dumps(i) for i in itens)) - (est,) = estimativa.calcular(saidas, desde=date(2026, 7, 1)).values() - assert est.observadas == {"j.br/nova"} - - -def test_um_canal_so_nao_estima(tmp_path): - _rodada(tmp_path / "saidas", "20261001T000000Z", [("a", "https://x.br/1", 3)]) - por_estrato = estimativa.calcular(tmp_path / "saidas") - assert por_estrato["geral"].chao is None - assert "ao menos dois" in estimativa.relatorio(por_estrato) - - -def test_cli_estimar(cfg, capsys): - assert cli.main(["estimar", "--config", str(cfg)]) == 0 - assert "relatório em" in capsys.readouterr().out - assert (cfg.parent / "dados" / "estimativa.md").exists() diff --git a/tests/test_fontes.py b/tests/test_fontes.py index 0929e88..e3b38eb 100644 --- a/tests/test_fontes.py +++ b/tests/test_fontes.py @@ -8,11 +8,11 @@ from argos.fontes.datajud import DataJud, converter_data from argos.fontes.rss import Rss, ler_feed -FEED = "https://jornal.exemplo.br/feed" +FEED = "https://tribunal.exemplo.jus.br/rss" def _rss(rede, **cfg): - return fontes.construir({"tipo": "rss", "id": "rss:jornal", "url": FEED, **cfg}, rede) + return fontes.construir({"tipo": "rss", "id": "rss:tribunal", "url": FEED, **cfg}, rede) def test_adaptadores_cumprem_o_contrato(): @@ -31,10 +31,10 @@ def test_tipo_desconhecido_e_recusado(): def test_rss_e_atom(fixtures): refs = list(ler_feed((fixtures / "rss" / "feed.xml").read_bytes(), "rss:j")) assert [r.id_na_fonte for r in refs] == [ - "jornal-1001", - "jornal-1002", - "jornal-1003", - "jornal-0900", + "noticia-1001", + "noticia-1002", + "noticia-1003", + "noticia-0900", ] assert refs[0].publicado_em == date(2026, 10, 2) atom = list(ler_feed((fixtures / "rss" / "atom.xml").read_bytes(), "rss:r")) @@ -46,37 +46,42 @@ def test_rss_e_atom(fixtures): def test_rss_lista_desde_a_data(rede, fixtures): rede.responder(FEED, fixtures / "rss" / "feed.xml") refs = list(_rss(rede).listar(date(2026, 9, 1))) - assert "jornal-0900" not in [r.id_na_fonte for r in refs] + assert "noticia-0900" not in [r.id_na_fonte for r in refs] assert len(refs) == 3 def test_rss_baixa_a_pagina_e_nao_o_resumo(rede, fixtures): - url = "https://jornal.exemplo.br/2026/10/carandiru-34-anos" - rede.responder(url, fixtures / "rss" / "materia-carandiru.html") + url = "https://tribunal.exemplo.jus.br/noticias/2026/10/tese-furto-qualificado" + rede.responder(url, fixtures / "rss" / "noticia-tese.html") fonte = _rss(rede) - ref = Referencia("rss:jornal", "jornal-1001", url, "Massacre do Carandiru: 34 anos depois") + ref = Referencia( + "rss:tribunal", + "noticia-1001", + url, + "Terceira Seção fixa tese sobre furto qualificado em recurso repetitivo", + ) doc = fonte.baixar(ref) - assert "Pavilhão 9" in doc.texto + assert "art. 155, § 4º, I, do Código Penal" in doc.texto assert "Resumo curto" not in doc.texto assert doc.destaques["manchete"] == ref.titulo - assert doc.destaques["linha_fina"].startswith("Familiares") + assert doc.destaques["linha_fina"].startswith("Tema 9.999") assert len(doc.hash_texto) == 64 assert fonte.sentinela(doc) def test_rss_sentinela_reprova_pagina_de_erro_com_200(rede, fixtures): - url = "https://jornal.exemplo.br/2026/10/sumiu" + url = "https://tribunal.exemplo.jus.br/noticias/2026/10/sumiu" rede.responder(url, fixtures / "rss" / "erro-200.html") fonte = _rss(rede, minimo_caracteres=50) - doc = fonte.baixar(Referencia("rss:jornal", "jornal-1003", url)) + doc = fonte.baixar(Referencia("rss:tribunal", "noticia-1003", url)) assert not fonte.sentinela(doc) def test_rss_sentinela_reprova_texto_curto(rede, fixtures): - url = "https://jornal.exemplo.br/2026/10/sumiu" - rede.responder(url, "

Carandiru

") + url = "https://tribunal.exemplo.jus.br/noticias/2026/10/sumiu" + rede.responder(url, "

Código Penal

") fonte = _rss(rede) - assert not fonte.sentinela(fonte.baixar(Referencia("rss:jornal", "x", url))) + assert not fonte.sentinela(fonte.baixar(Referencia("rss:tribunal", "x", url))) @pytest.mark.parametrize( diff --git a/tests/test_rodada.py b/tests/test_rodada.py index 3c61bfb..f9af931 100644 --- a/tests/test_rodada.py +++ b/tests/test_rodada.py @@ -7,98 +7,100 @@ from tests.apoio import BASE, FEED, _rodar -def test_rodada_completa(cfg, rede_jornal): - c, r = _rodar(cfg, rede_jornal) +def test_rodada_completa(cfg, rede_tribunal): + c, r = _rodar(cfg, rede_tribunal) por_id = {linha.ref.id_na_fonte: linha for linha in r.linhas} - assert set(por_id) == {"jornal-1001", "jornal-1002", "jornal-1003"} # 0900 é anterior - assert por_id["jornal-1001"].nivel == 3 - assert por_id["jornal-1001"].resultado.onde == "manchete" - assert por_id["jornal-1002"].resultado.motivo == "sem_termo" - assert por_id["jornal-1003"].situacao == rodada.SENTINELA + assert set(por_id) == {"noticia-1001", "noticia-1002", "noticia-1003"} # 0900 é anterior + assert por_id["noticia-1001"].nivel == 3 + assert por_id["noticia-1001"].resultado.onde == "manchete" + assert por_id["noticia-1002"].resultado.motivo == "sem_termo" + assert por_id["noticia-1003"].situacao == rodada.SENTINELA assert r.codigo_saida == rodada.PEDE_LEITURA - assert r.desde == {"rss:jornal": "2026-09-01"} + assert r.desde == {"rss:tribunal": "2026-09-01"} # Corpus: só o íntegro tem nome canônico; o reprovado fica como .part. - pasta = c.corpus / "rss_jornal" + pasta = c.corpus / "rss_tribunal" assert len(list(pasta.glob("*.json"))) == 2 assert len(list(pasta.glob("*.json.part"))) == 1 # Estado: o reprovado não é marcado como visto, para ser tentado de novo. - memoria = estado.ler(c.estado, "rss:jornal") + memoria = estado.ler(c.estado, "rss:tribunal") assert memoria.ultima_rodada == date(2026, 10, 4) - assert set(memoria.vistos) == {"jornal-1001", "jornal-1002"} + assert set(memoria.vistos) == {"noticia-1001", "noticia-1002"} -def test_saidas(cfg, rede_jornal): - c, r = _rodar(cfg, rede_jornal) +def test_saidas(cfg, rede_tribunal): + c, r = _rodar(cfg, rede_tribunal) pasta = saidas.escrever(r, c.saidas) aceitos = [json.loads(x) for x in (pasta / "aceitos.jsonl").read_text().splitlines()] assert len(aceitos) == 1 item = aceitos[0] - assert item["url"] == f"{BASE}/carandiru-34-anos" - assert item["lexico"] == {"id": "carandiru-amostra", "versao": "2026-10-04"} - assert item["chaves"][0] == "massacre-do-carandiru" - assert "evento" in item["categorias"] + assert item["url"] == f"{BASE}/tese-furto-qualificado" + assert item["lexico"] == {"id": "penal-amostra", "versao": "2026-10-04"} + assert item["chaves"] == ["nome-furto-qualificado", "cp-art-155"] + assert item["categorias"] == ["nome", "cp"] assert item["hash_texto"] and item["capturado_em"] - assert item["corpus"] == f"corpus/rss_jornal/{item['hash_texto']}.json" + assert item["corpus"] == f"corpus/rss_tribunal/{item['hash_texto']}.json" assert (c.diretorio / item["corpus"]).exists() assert (pasta / "pede-juizo.jsonl").read_text() == "" assert len((pasta / "itens.jsonl").read_text().splitlines()) == 3 completo = (pasta / "relatorio-completo.md").read_text() resumo = (pasta / "relatorio-resumo.md").read_text() - assert "Descartados no nível 0" in completo and "drenagem" in completo - assert "drenagem" not in resumo + assert "Descartados no nível 0" in completo and "conciliação" in completo + assert "conciliação" not in resumo assert "Falhas de captura ou de integridade (1)" in resumo meta = json.loads((pasta / "rodada.json").read_text()) assert meta["contagens"]["situacao"] == {"reprovado_sentinela": 1, "triado": 2} assert meta["codigo_saida"] == 3 -def test_segunda_rodada_nao_rebaixa_o_que_ja_viu(cfg, rede_jornal): - _rodar(cfg, rede_jornal) - rede_jornal.pedidos.clear() - _, r = _rodar(cfg, rede_jornal) - baixadas = [u for u in rede_jornal.urls() if u.startswith(BASE)] +def test_segunda_rodada_nao_rebaixa_o_que_ja_viu(cfg, rede_tribunal): + _rodar(cfg, rede_tribunal) + rede_tribunal.pedidos.clear() + _, r = _rodar(cfg, rede_tribunal) + baixadas = [u for u in rede_tribunal.urls() if u.startswith(BASE)] # A janela agora começa em 02/10 e o item reprovado é de 01/10: só volta por ser pendente. - assert r.desde == {"rss:jornal": "2026-10-02"} # última rodada menos a margem + assert r.desde == {"rss:tribunal": "2026-10-02"} # última rodada menos a margem assert baixadas == [f"{BASE}/sumiu"] -def test_pendente_e_resolvido_quando_a_pagina_volta(cfg, rede_jornal, fixtures): - c, _ = _rodar(cfg, rede_jornal) - memoria = estado.ler(c.estado, "rss:jornal") - assert memoria.pendentes["jornal-1003"]["motivo"] == rodada.SENTINELA - assert memoria.pendentes["jornal-1003"]["tentativas"] == 1 +def test_pendente_e_resolvido_quando_a_pagina_volta(cfg, rede_tribunal, fixtures): + c, _ = _rodar(cfg, rede_tribunal) + memoria = estado.ler(c.estado, "rss:tribunal") + assert memoria.pendentes["noticia-1003"]["motivo"] == rodada.SENTINELA + assert memoria.pendentes["noticia-1003"]["tentativas"] == 1 - rede_jornal.rotas[f"{BASE}/sumiu"] = [] - rede_jornal.responder(f"{BASE}/sumiu", fixtures / "rss" / "materia-carandiru.html") - _, r = _rodar(cfg, rede_jornal) - assert [linha.nivel for linha in r.linhas] == [3] - memoria = estado.ler(c.estado, "rss:jornal") + rede_tribunal.rotas[f"{BASE}/sumiu"] = [] + rede_tribunal.responder(f"{BASE}/sumiu", fixtures / "rss" / "noticia-tese.html") + _, r = _rodar(cfg, rede_tribunal) + # A página voltou e foi lida; a manchete usada é o título do item no feed ("Página + # que sumiu"), sem termo, e por isso fica em "pede juízo", não em "aceito". + assert [linha.nivel for linha in r.linhas] == [2] + memoria = estado.ler(c.estado, "rss:tribunal") assert memoria.pendentes == {} - assert "jornal-1003" in memoria.vistos + assert "noticia-1003" in memoria.vistos -def test_sem_estado_rele_tudo_e_nao_grava(cfg, rede_jornal): - c, _ = _rodar(cfg, rede_jornal, usar_estado=False) - assert not estado.caminho(c.estado, "rss:jornal").exists() +def test_sem_estado_rele_tudo_e_nao_grava(cfg, rede_tribunal): + c, _ = _rodar(cfg, rede_tribunal, usar_estado=False) + assert not estado.caminho(c.estado, "rss:tribunal").exists() def test_fonte_fora_do_ar_da_codigo_2_e_nao_avanca_o_estado(cfg, rede): c, r = _rodar(cfg, rede) # sem rota para o feed: 404 assert r.codigo_saida == rodada.ERRO - assert "rss:jornal" in r.falhas_de_fonte - assert not estado.caminho(c.estado, "rss:jornal").exists() + assert "rss:tribunal" in r.falhas_de_fonte + assert not estado.caminho(c.estado, "rss:tribunal").exists() -def test_retriar_aplica_lexico_novo_sem_rede(cfg, rede_jornal, tmp_path): - c, _ = _rodar(cfg, rede_jornal) +def test_retriar_aplica_lexico_novo_sem_rede(cfg, rede_tribunal, tmp_path): + c, _ = _rodar(cfg, rede_tribunal) novo = json.loads((tmp_path / "lexico.json").read_text()) novo["versao"] = "2026-10-05" - novo["termos"].append({"chave": "drenagem", "formas": ["drenagem"]}) - novo["contexto"]["exige_qualquer"].append("prefeitura") + novo["termos"].append({"chave": "conciliacao", "formas": ["conciliação"]}) + novo["contexto"]["exige_qualquer"].append("mutirão") (tmp_path / "lexico.json").write_text(json.dumps(novo)) r = rodada.retriar(c, lexico.Casador(lexico.carregar(c.lexico))) @@ -108,7 +110,7 @@ def test_retriar_aplica_lexico_novo_sem_rede(cfg, rede_jornal, tmp_path): def test_cli_validar_e_rodar_sem_fonte(cfg, capsys, fixtures): - assert cli.main(["validar-lexico", str(fixtures / "lexico-carandiru.json")]) == 0 + assert cli.main(["validar-lexico", str(fixtures / "lexico-penal.json")]) == 0 assert "válido" in capsys.readouterr().out assert cli.main(["rodar", "--config", str(cfg), "--fonte", "rss:outra"]) == rodada.ERRO @@ -127,4 +129,4 @@ def test_feed_malformado_derruba_so_a_fonte(cfg, rede): rede.responder(FEED, "sem fechar") _, r = _rodar(cfg, rede) assert r.codigo_saida == rodada.ERRO - assert r.falhas_de_fonte["rss:jornal"].startswith("listagem: ParseError") + assert r.falhas_de_fonte["rss:tribunal"].startswith("listagem: ParseError") diff --git a/tests/test_texto.py b/tests/test_texto.py index 68c08d4..482e61b 100644 --- a/tests/test_texto.py +++ b/tests/test_texto.py @@ -2,9 +2,9 @@ def test_cp1252_declarado_como_latin1(fixtures): - texto, codificacao = decodificar((fixtures / "rss" / "materia-carandiru.html").read_bytes()) + texto, codificacao = decodificar((fixtures / "rss" / "noticia-tese.html").read_bytes()) assert codificacao == "cp1252" - assert "Detenção de\nSão Paulo" in texto + assert "Terceira Seção" in texto and "violência contra a\ncoisa" in texto def test_cabecalho_http_vence_o_meta(): @@ -28,14 +28,14 @@ def test_sem_declaracao_tenta_utf8_e_cai_em_cp1252(): def test_pagina_ignora_script_nav_e_rodape(fixtures): - html, _ = decodificar((fixtures / "rss" / "materia-carandiru.html").read_bytes()) + html, _ = decodificar((fixtures / "rss" / "noticia-tese.html").read_bytes()) pagina = Pagina(html) - assert "Pavilhão 9" in pagina.texto + assert "rompimento de obstáculo" in pagina.texto assert "publicidade" not in pagina.texto - assert "Especial Carandiru" not in pagina.texto - assert "o filme" not in pagina.texto - assert pagina.manchete.startswith("Massacre do Carandiru") - assert pagina.meta["description"].startswith("Familiares") + assert "Especial Repetitivos" not in pagina.texto + assert "Código Penal Militar comentado" not in pagina.texto + assert pagina.manchete.startswith("Terceira Seção fixa tese") + assert pagina.meta["description"].startswith("Tema 9.999") def test_dobrar_preserva_comprimento(): diff --git a/tests/test_triagem.py b/tests/test_triagem.py index cce7c14..48f5635 100644 --- a/tests/test_triagem.py +++ b/tests/test_triagem.py @@ -12,7 +12,7 @@ @pytest.fixture def casador(fixtures): - return lexico.Casador(lexico.carregar(fixtures / "lexico-carandiru.json")) + return lexico.Casador(lexico.carregar(fixtures / "lexico-penal.json")) def _doc(texto, **destaques): @@ -22,27 +22,32 @@ def _doc(texto, **destaques): def test_nivel_0_sem_termo(casador): - r = triar(_doc("A prefeitura anunciou obras de drenagem."), casador) + r = triar(_doc("O tribunal promove semana de conciliação."), casador) assert (r.nivel, r.motivo, r.trecho) == (0, "sem_termo", None) def test_nivel_0_so_exclusao_sai_com_trecho(casador): - r = triar(_doc("Sessão de cinema: Carandiru, o filme, com debate."), casador) + r = triar(_doc("Denúncia por homicídio culposo na direção de veículo automotor."), casador) + assert (r.nivel, r.motivo) == (0, "so_exclusao") + assert "na direção" in r.trecho + + +def test_nivel_0_cpm_nao_vira_cp(casador): + r = triar(_doc("Acórdão: recurso sobre o art. 155, § 1º, do Código Penal Militar."), casador) assert (r.nivel, r.motivo) == (0, "so_exclusao") - assert "o filme" in r.trecho def test_nivel_1_termo_sem_contexto(casador): - r = triar(_doc("O bairro do Carandiru ganhou uma feira de artesanato."), casador) - assert (r.nivel, r.motivo, r.chaves) == (1, "sem_contexto", ["massacre-do-carandiru"]) - assert "Carandiru" in r.trecho + r = triar(_doc("O art. 155 do Código Penal foi assunto de palestra no auditório."), casador) + assert (r.nivel, r.motivo, r.chaves) == (1, "sem_contexto", ["cp-art-155"]) + assert "art. 155" in r.trecho def test_nivel_2_termo_e_contexto_fora_do_destaque(casador): doc = _doc( - "Seminário discute segurança pública. " - "Entre os casos, o Carandiru, onde presos foram mortos.", - manchete="Seminário discute segurança pública", + "Seminário discute o sistema de justiça. " + "Entre os casos, um recurso sobre furto qualificado.", + manchete="Seminário discute o sistema de justiça", ) r = triar(doc, casador) assert (r.nivel, r.motivo, r.onde) == (2, "pede_juizo", "texto") @@ -50,15 +55,16 @@ def test_nivel_2_termo_e_contexto_fora_do_destaque(casador): def test_nivel_3_termo_no_destaque(casador): doc = _doc( - "Em 1992 a Polícia Militar invadiu o Pavilhão 9 da Casa de Detenção. Houve indulto.", - manchete="Massacre do Carandiru: 34 anos", + "A Terceira Seção fixou tese em recurso repetitivo sobre o art. 155, § 4º, I, do " + "Código Penal. O caso envolvia também homicídio.", + ementa="FURTO QUALIFICADO. ROMPIMENTO DE OBSTÁCULO. PROVA.", ) r = triar(doc, casador) - assert (r.nivel, r.motivo, r.onde) == (3, "aceito", "manchete") - assert r.chaves == ["casa-de-detencao", "indulto"] - assert r.categorias == ["lugar", "desdobramento-juridico"] - assert r.pontuacao == 1.5 - assert r.trecho == "Massacre do Carandiru: 34 anos" + assert (r.nivel, r.motivo, r.onde) == (3, "aceito", "ementa") + assert r.chaves == ["cp-art-155", "cp-art-121"] + assert r.categorias == ["cp"] + assert r.pontuacao == 2.0 + assert r.trecho == "FURTO QUALIFICADO. ROMPIMENTO DE OBSTÁCULO. PROVA." def test_recorte_marca_o_que_cortou(): @@ -68,5 +74,5 @@ def test_recorte_marca_o_que_cortou(): def test_triagem_e_deterministica(casador): - doc = _doc("Carandiru, 1992: presos mortos.", manchete="Carandiru") + doc = _doc("Recurso: art. 155 do CP, pena de reclusão.", ementa="Furto qualificado") assert triar(doc, casador) == triar(doc, casador)