Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
pull_request:

jobs:
testes:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -e ".[dev]"
- run: ruff check .
- run: ruff format --check .
- run: python -m argos validar-lexico exemplos/lexico.exemplo.json
- run: pytest -q
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.egg-info/
.pytest_cache/
.ruff_cache/
.venv/
# Diretórios de trabalho de uma rodada local (estado, corpus, saídas).
dados/
54 changes: 47 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,18 @@ 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/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 |

O estado é um arquivo (`estado/<fonte>.json`) com os hashes já vistos e a data da última
rodada por fonte. Apagar o estado equivale a reler tudo. Nada mais é guardado entre
rodadas.
O estado é um arquivo (`estado/<fonte>.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.

### O contrato do adaptador

Expand Down Expand Up @@ -119,6 +123,7 @@ rede. Fonte que muda de forma quebra o teste, não a rodada em silêncio.
"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."
}
],
Expand All @@ -129,6 +134,10 @@ rede. Fonte que muda de forma quebra o teste, não a rodada em silêncio.
}
```

O casamento ignora maiúsculas e acentos, exige palavra inteira e tolera quebra de linha
onde a forma tem espaço. 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.
Expand All @@ -142,6 +151,10 @@ o formato e aplica. Mudar um termo é um commit em dado, não em código.
| 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`).

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.
Expand Down Expand Up @@ -195,25 +208,52 @@ corpus possa entrar no grafo sem retrabalho.

## Roadmap

- **v0.1** motor: contrato, captura com `.part` e sentinela, léxico com schema e
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) e [observatório do Carandiru](docs/carandiru.md).

- **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: `stf`, `stj` com fixtures; exemplo de consumidor em
- **v0.2** jurisprudência: `djen` (**feito**, falta rodada real com IP brasileiro),
`lista` e as fontes do STF, `stj` com fixtures; exemplo de consumidor em
`exemplos/atlaspen/`. 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`, `google-noticias`, `scielo`; saída arquivística;
exemplo de consumidor em `exemplos/observatorio/` com léxico de amostra.
- **depois** saída issue no GitHub, nível opt-in com modelo (marcado), migração do `dou`.

## Rodar

Só biblioteca padrão; Python 3.11 ou mais novo.

```bash
python -m argos rodar --config exemplos/atlaspen/config.json # uma rodada
pip install -e ".[dev]"
python -m argos rodar --config exemplos/config.exemplo.json # uma rodada
python -m argos rodar --config ... --desde 2026-09-01 --sem-estado # reler tudo
python -m argos retriar --config ... # léxico novo, corpus guardado, sem rede
python -m argos validar-lexico exemplos/lexico.exemplo.json
python -m pytest # sem rede, contra fixtures
```

Saídas: `0` nada a ler; `2` erro de execução ou fonte fora do ar; `3` há itens que pedem juízo.
Cada rodada escreve em `<diretorio>/saidas/<id>/`: `aceitos.jsonl` (nível 3, com
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.

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

Expand Down
3 changes: 3 additions & 0 deletions argos/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Argos: vigilância determinística de fontes abertas."""

__version__ = "0.1.0"
3 changes: 3 additions & 0 deletions argos/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from argos.cli import main

raise SystemExit(main())
122 changes: 122 additions & 0 deletions argos/captura.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Captura: monta o Documento com proveniência e grava o texto integral no corpus.

O arquivo é escrito primeiro como `.part` e só é promovido ao nome final quando a
sentinela da fonte aprova. Arquivo no caminho canônico significa "íntegro", por
construção: quem varre o corpus não precisa ler metadado para saber se pode confiar.
O `.part` reprovado fica no diretório para diagnóstico e não casa com `*.json`.
"""

from __future__ import annotations

import json
import os
import re
from collections.abc import Iterator
from dataclasses import asdict
from datetime import UTC, date, datetime
from pathlib import Path

from argos.contrato import Documento, Fonte, Referencia
from argos.texto import normalizar, sha256


def montar_documento(
ref: Referencia,
texto: str,
*,
bruto: bytes | None = None,
cabecalhos: dict[str, str] | None = None,
destaques: dict[str, str] | None = None,
agora: datetime | None = None,
) -> Documento:
"""O único lugar que calcula hash e data de captura. Adaptadores chamam este."""
texto = normalizar(texto)
return Documento(
ref=ref,
texto=texto,
capturado_em=(agora or datetime.now(UTC)).replace(microsecond=0),
hash_texto=sha256(texto),
bruto=bruto,
cabecalhos=dict(cabecalhos or {}),
destaques={k: normalizar(v) for k, v in (destaques or {}).items() if v and v.strip()},
)


def nome_seguro(texto: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "_", texto).strip("_") or "_"


def documento_para_dict(doc: Documento) -> dict:
ref = asdict(doc.ref)
ref.pop("dados", None)
if isinstance(doc.ref.publicado_em, date):
ref["publicado_em"] = doc.ref.publicado_em.isoformat()
return {
"ref": ref,
"texto": doc.texto,
"capturado_em": doc.capturado_em.isoformat(),
"hash_texto": doc.hash_texto,
"cabecalhos": doc.cabecalhos,
"destaques": doc.destaques,
}


def documento_de_dict(dados: dict) -> Documento:
ref = dict(dados["ref"])
if ref.get("publicado_em"):
ref["publicado_em"] = date.fromisoformat(ref["publicado_em"])
return Documento(
ref=Referencia(**ref),
texto=dados["texto"],
capturado_em=datetime.fromisoformat(dados["capturado_em"]),
hash_texto=dados["hash_texto"],
cabecalhos=dados.get("cabecalhos", {}),
destaques=dados.get("destaques", {}),
)


class Corpus:
"""`<raiz>/<fonte>/<hash>.json`: um arquivo por texto distinto dentro da fonte.

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:
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."""
if caminho is None:
return None
return Path(caminho).relative_to(self.raiz.parent).as_posix()

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]:
"""(í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")
parcial.write_text(
json.dumps(documento_para_dict(doc), ensure_ascii=False, indent=1), encoding="utf-8"
)
if not fonte.sentinela(doc):
return False, parcial
os.replace(parcial, final)
return True, final

def documentos(self, fonte: str | None = None) -> Iterator[tuple[Documento, Path]]:
pastas = [self.raiz / nome_seguro(fonte)] if fonte else sorted(self.raiz.glob("*"))
for pasta in pastas:
for arquivo in sorted(pasta.glob("*.json")):
yield documento_de_dict(json.loads(arquivo.read_text(encoding="utf-8"))), arquivo
98 changes: 98 additions & 0 deletions argos/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Linha de comando.

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 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.
"""

from __future__ import annotations

import argparse
import logging
import sys
from datetime import date

from argos import __version__, config, fontes, lexico, rodada, saidas
from argos.saidas import catalogo


def _rodar(args: argparse.Namespace) -> int:
cfg = config.carregar(args.config)
casador = lexico.Casador(lexico.carregar(cfg.lexico))
selecionadas = [f for f in cfg.fontes if not args.fonte or f["id"] in args.fonte]
if args.fonte and len(selecionadas) != len(set(args.fonte)):
print(f"fonte desconhecida entre {args.fonte}", file=sys.stderr)
return rodada.ERRO
instancias = [fontes.construir(f) for f in selecionadas]
desde = date.fromisoformat(args.desde) if args.desde else None
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 _validar_lexico(args: argparse.Namespace) -> int:
try:
lex = lexico.carregar(args.arquivo)
except lexico.ErroLexico as erro:
print(erro, file=sys.stderr)
return rodada.ERRO
formas = sum(len(t.formas) for t in lex.termos)
print(f"{lex.id} {lex.versao}: válido, {len(lex.termos)} termos, {formas} formas")
return rodada.SEM_NADA


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="argos", description=__doc__.split("\n")[0])
parser.add_argument("--version", action="version", version=f"argos {__version__}")
parser.add_argument("-v", "--verboso", action="store_true")
sub = parser.add_subparsers(dest="comando", required=True)

p = sub.add_parser("rodar", help="uma rodada: listar, baixar, triar, gravar")
p.add_argument("--config", required=True)
p.add_argument("--desde", help="AAAA-MM-DD; ignora a data guardada no estado")
p.add_argument("--sem-estado", action="store_true", help="relê tudo e não grava estado")
p.add_argument("--fonte", action="append", help="só esta fonte (repetível)")
p.set_defaults(func=_rodar)

p = sub.add_parser("retriar", help="aplica o léxico atual ao corpus guardado, sem rede")
p.add_argument("--config", required=True)
p.add_argument("--fonte")
p.set_defaults(func=_retriar)

p = sub.add_parser("validar-lexico", help="confere o formato de um léxico")
p.add_argument("arquivo")
p.set_defaults(func=_validar_lexico)

args = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO if args.verboso else logging.WARNING,
format="%(levelname)s %(name)s: %(message)s",
)
try:
return args.func(args)
except (OSError, ValueError) as erro:
print(f"erro: {erro}", file=sys.stderr)
return rodada.ERRO
Loading
Loading