feat(converter): implement deterministic JSON to Markdown article converter (spec 005)

This commit is contained in:
2026-08-21 10:30:14 -03:00
parent 64dfd842de
commit 926a6b8cfc
58 changed files with 20138 additions and 2301 deletions
+131 -24
View File
@@ -35,6 +35,13 @@
- [Critérios de Desempate Técnico e Resiliência](#critérios-de-desempate-técnico-e-resiliência)
- [Argumentos e Flags CLI](#argumentos-e-flags-cli-1)
- [Exemplos Práticos de Uso](#exemplos-práticos-de-uso-1)
- [5. Conversor de Artigo JSON para Markdown](#5--conversor-de-artigo-json-para-markdown)
- [Visão Geral e Estrutura do Documento](#visão-geral-e-estrutura-do-documento)
- [Isolamento Estrito de Extratores e Fallback](#isolamento-estrito-de-extratores-e-fallback)
- [Matriz Determinística de Metadados](#matriz-determinística-de-metadados)
- [Sanitização Editorial e Deduplicação](#sanitização-editorial-e-deduplicação)
- [Argumentos e Flags CLI](#argumentos-e-flags-cli-2)
- [Exemplos Práticos de Uso](#exemplos-práticos-de-uso-2)
- [Estrutura do Projeto](#-estrutura-do-projeto)
- [Testes e Qualidade de Código](#-testes-e-qualidade-de-código)
- [Licença](#-licença)
@@ -49,6 +56,7 @@ O **TextNLPClassifierApp** reúne um ecossistema completo de ferramentas de enge
2. **`scripts/extract_google_news.py`**: Extrator de notícias por palavra-chave, idioma e região geográfica utilizando navegação stealth **Foxcape** (headless), decodificação paralela de URLs para links reais e feedback em tempo real.
3. **`scripts/extract_article_contents.py`**: Extrator e parser de artigos multimotor com navegação stealth Foxcape headless e extração simultânea via **Trafilatura**, **Newspaper4k** (NLP) e **Readability**, consolidando texto higienizado, autores, datas, imagens e resumos.
4. **`scripts/select_article_extractor.py`**: Motor determinístico de seleção de extratores que avalia as saídas dos três motores, aplica normalização em memória, calcula métricas de consenso de shingles (5-tokens) com pontuação $F_1$, desempata tecnicamente ($\le 0.03$) favorecendo menor concisão/ruído e enriquece os dados de forma não-destrutiva e atômica.
5. **`scripts/convert_article_to_markdown.py`**: Conversor determinístico que recebe o JSON de um único artigo selecionado, isola estritamente o corpo do extrator vencedor (`trafilatura`, `newspaper4k` ou `readability`), resolve metadados editoriais por prioridade estrita, higieniza links/imagens/cabeçalhos e gera um documento Markdown (`.md`) padronizado com gravação atômica transacional.
---
@@ -353,47 +361,147 @@ print("Motivo:", result.selection_reason)
---
## 5. 📝 Conversor de Artigo JSON para Markdown
### Visão Geral e Estrutura do Documento
O script `scripts/convert_article_to_markdown.py` realiza a conversão de um arquivo JSON contendo exatamente um artigo (com `selected_extractor`) para um documento Markdown (`.md`) pronto para consumo editorial ou classificação downstream.
A estrutura do Markdown gerado segue estritamente o padrão:
```markdown
# Título do Artigo
Subtítulo ou descrição editorial (omitido se ausente ou igual ao título).
**Autor:** Nome do Autor 1, Nome do Autor 2
**Publicado em:** 2026-08-20T00:36:33-03:00
**Site:** Nome do Veículo
**Categoria:** Categoria 1, Categoria 2
**Tags:** Tag 1, Tag 2
**Palavras-chave:** Palavra 1, Palavra 2
**Idioma:** es
**Fonte original:** [https://exemplo.com/artigo](https://exemplo.com/artigo)
![Imagem principal](https://exemplo.com/imagem_capa.jpg)
---
Conteúdo textual do artigo convertido em Markdown.
```
### Isolamento Estrito de Extratores e Fallback
- **Isolamento Total do Corpo**: O texto e HTML do corpo vêm **exclusivamente** do extrator indicado em `selected_extractor`. Caso o extrator selecionado não possua corpo válido, o processo encerra imediatamente com erro (código `1`). Nunca ocorre fallback de corpo para outro extrator.
- **Fallback Interno**:
- `trafilatura`: usa `trafilatura.markdown`; se vazio, usa `trafilatura.text`.
- `newspaper4k`: converte `newspaper4k.article_html` para Markdown; se vazio, usa `newspaper4k.text`.
- `readability`: converte `readability.cleaned_html` para Markdown; se vazio, usa `readability.cleaned_text`.
- **Conversão HTML→Markdown**: Utiliza a biblioteca `markdownify` configurada para títulos padrão ATX (`#`, `##`, `###`), preservando negrito, itálico, listas, tabelas, citações, links e blocos de código.
### Matriz Determinística de Metadados
Os metadados editoriais são resolvidos deterministicamente consultando fontes na ordem de prioridade estrita:
1. **Título**: `SELECIONADO.title` → `input_meta.titulo` → `page_title` → `newspaper4k.title` → `trafilatura.title` → `readability.title`
2. **URL Original**: `input_meta.url` → `crawled_url` → URL canônica do selecionado → `trafilatura.canonical_url` → `newspaper4k.canonical_link`
3. **Subtítulo/Descrição**: descrição do selecionado → `trafilatura.description` → `newspaper4k.meta_description` → `input_meta.subtitulo`
4. **Autores**: autor(es) do selecionado → `newspaper4k.authors` → `trafilatura.author` → `readability.author`
5. **Data de Publicação**: data do selecionado → `newspaper4k.publish_date` → `trafilatura.date` → `input_meta.quando_publicado`
6. **Site**: site do selecionado → `trafilatura.sitename` → `newspaper4k.meta_site_name` → `trafilatura.hostname` → Hostname da URL original
7. **Categorias**: categorias do selecionado → `trafilatura.categories`
8. **Tags**: tags do selecionado → `trafilatura.tags` → `newspaper4k.tags` → `newspaper4k.meta_keywords`
9. **Palavras-chave**: `newspaper4k.keywords` → `newspaper4k.meta_keywords`
10. **Idioma**: idioma do selecionado → `trafilatura.language` → `newspaper4k.meta_lang`
11. **Imagem Principal**: imagem do selecionado → `newspaper4k.top_image` → `trafilatura.image`
### Sanitização Editorial e Deduplicação
- **Remoção de H1 Duplicado**: Se o corpo iniciar com um título H1 idêntico ao título resolvido do artigo, esse H1 inicial é removido automaticamente.
- **Filtro de Imagens**: Remove imagens com URLs relativas, vazias ou em formato `data:`. Deduplica URLs de imagem repetidas no corpo.
- **Filtro de Placeholders**: Descarta valores como `null`, `None`, `N/A`, `unknown`, `[no-author]` ou rótulos vazios.
- **Gravação Atômica**: Escrita transacional em arquivo temporário seguida de substituição com `os.replace`, garantindo integridade e nenhum resíduo em falhas.
### Argumentos e Flags CLI
| Parâmetro | Tipo | Obrigatoriedade | Padrão | Descrição |
|---|---|:---:|---|---|
| `-i, --input` | Caminho | **Sim** | — | Arquivo JSON contendo exatamente um único artigo. |
| `-o, --output` | Caminho | Não | `<input_stem>.md` | Caminho do arquivo Markdown de destino. |
### Exemplos Práticos de Uso
#### 1. Conversão Padrão
```bash
python scripts/convert_article_to_markdown.py -i out/article_001.json
# Gera automaticamente out/article_001.md
```
#### 2. Conversão com Caminho de Destino Personalizado
```bash
python scripts/convert_article_to_markdown.py \
-i out/article_001.json \
-o out/markdown/meu_artigo.md
```
#### 3. Uso Programático em Python
```python
from pathlib import Path
from scripts.convert_article_to_markdown import convert_article
out_file = convert_article(Path("out/article_001.json"), Path("out/artigo.md"))
print(f"Markdown gerado em: {out_file}")
```
---
## 📁 Estrutura do Projeto
```text
TextNLPClassifierApp/
├── classify.py # CLI principal do Classificador de Inerência
├── classify.py # CLI principal do Classificador de Inerência
├── scripts/
│ ├── __init__.py # Pacote utilitário de scripts
│ ├── extract_google_news.py # CLI de Extração de Manchetes do Google News
│ ├── extract_article_contents.py # CLI de Extração e Parsing Multimotor de Artigos
│ └── select_article_extractor.py # CLI de Seleção Determinística de Extrator
├── src/ # Módulos centrais do classificador
│ ├── classifier.py # Orquestrador de classificação (Tier 1, 2, 3)
│ ├── models.py # Modelos de dados e esquemas (ECPSnapshot, Decision)
│ ├── preprocessor.py # Normalização de texto e detecção de idioma
│ └── adapters/ # Adaptadores opcionais de Embeddings e LLM
├── specs/ # Especificações e planos arquiteturais (Speckit)
│ ├── __init__.py # Pacote utilitário de scripts
│ ├── extract_google_news.py # CLI de Extração de Manchetes do Google News
│ ├── extract_article_contents.py # CLI de Extração e Parsing Multimotor de Artigos
│ ├── select_article_extractor.py # CLI de Seleção Determinística de Extrator
│ └── convert_article_to_markdown.py # CLI de Conversão de Artigo JSON para Markdown
├── src/ # Módulos centrais do classificador
│ ├── classifier.py # Orquestrador de classificação (Tier 1, 2, 3)
│ ├── models.py # Modelos de dados e esquemas (ECPSnapshot, Decision)
│ ├── preprocessor.py # Normalização de texto e detecção de idioma
│ └── adapters/ # Adaptadores opcionais de Embeddings e LLM
├── specs/ # Especificações e planos arquiteturais (Speckit)
│ ├── 001-multilingual-entity-classifier/
│ ├── 002-google-news-extractor/
│ ├── 003-article-content-extractor/
│ └── 004-deterministic-content-selection/ # Specs da seleção determinística
├── tests/ # Suíte de testes automatizados
│ ├── 004-deterministic-content-selection/
│ └── 005-convert-json-markdown/ # Specs da conversão JSON para Markdown
├── tests/ # Suíte de testes automatizados
│ ├── test_classifier.py
│ ├── test_extract_google_news.py
│ ├── test_extract_article_contents.py
│ └── test_select_article_extractor.py # Testes do seletor determinístico
├── requirements.txt # Dependências do projeto
├── pyproject.toml # Configurações de ferramentas (pytest, ruff, mypy)
└── README.md # Documentação principal
│ ├── test_select_article_extractor.py
│ └── test_convert_article_to_markdown.py # Testes da conversão para Markdown
├── requirements.txt # Dependências do projeto
├── pyproject.toml # Configurações de ferramentas (pytest, ruff, mypy)
└── README.md # Documentação principal
```
---
## 🧪 Testes e Qualidade de Código
O repositório possui **120 testes automatizados** com 100% de aprovação cobrindo testes unitários, de regressão, de integração e testes End-to-End (E2E) via CLI subprocess:
O repositório possui **187 testes automatizados** com 100% de aprovação cobrindo testes unitários, de regressão, de integração, Golden Fixtures exatas, testes de sensibilidade de mutação e testes End-to-End (E2E) via CLI subprocess:
```bash
# Executar toda a suíte de testes do projeto (120 testes)
# Executar toda a suíte de testes do projeto (187 testes)
pytest -v
# Executar especificamente os testes do Seletor Determinístico
# Executar os testes de Conversão de Artigo para Markdown (67 testes)
pytest tests/test_convert_article_to_markdown.py -v
# Executar os testes do Seletor Determinístico
pytest tests/test_select_article_extractor.py -v
# Executar os testes do Extrator de Conteúdo Multimotor
@@ -402,12 +510,11 @@ pytest tests/test_extract_article_contents.py -v
# Executar os testes do Extrator do Google News
pytest tests/test_extract_google_news.py -v
# Validação e correção automática de formatação com Ruff
ruff check --fix .
ruff format .
# Validação com Ruff
ruff check .
# Verificação estática de tipos com Mypy
mypy scripts/ src/
mypy scripts/convert_article_to_markdown.py
```
---
+516
View File
@@ -0,0 +1,516 @@
# PRD — Conversão de artigo JSON para Markdown
## 1. Visão geral
O `TextNLPClassifierApp` já possui scripts para extrair artigos com Trafilatura, Newspaper4k e Readability e para selecionar deterministicamente o melhor extrator de conteúdo.
Esta feature adicionará a etapa seguinte do pipeline: receber o JSON de um único artigo, já contendo `selected_extractor`, selecionar deterministicamente os metadados disponíveis e gerar um arquivo Markdown com o conteúdo do extrator escolhido.
O arquivo `river_plate_extracted_selected(2).json` foi usado como referência de estrutura. Embora esse arquivo contenha uma coleção em `articles`, a entrada operacional desta feature será somente um objeto individual dessa coleção.
## 2. Problema
Cada artigo possui três resultados de extração com campos, formatos e níveis de preenchimento diferentes. O `selected_extractor` define qual corpo tem o maior peso e deve ser utilizado, mas metadados úteis podem estar ausentes nesse extrator e disponíveis em outro.
É necessário produzir um Markdown único e previsível sem escolher novamente o melhor conteúdo, sem usar LLM e sem depender de interpretação manual.
## 3. Objetivo
Criar um CLI Python que:
1. receba um arquivo JSON contendo exatamente um artigo;
2. valide os campos obrigatórios;
3. use exclusivamente o `selected_extractor` para obter o corpo do artigo;
4. selecione título, URL original e metadados opcionais por regras determinísticas;
5. converta o corpo HTML para Markdown quando necessário;
6. grave um arquivo `.md` legível, consistente e pronto para as etapas posteriores do pipeline.
## 4. História do usuário
Como operador do pipeline de conteúdo, quero converter o JSON selecionado de um artigo em um arquivo Markdown para que o conteúdo e os melhores metadados disponíveis possam ser consumidos pelas etapas seguintes do sistema.
## 5. Escopo
### 5.1 Incluído
- CLI Python.
- Leitura de um arquivo JSON com um único artigo.
- Suporte a `trafilatura`, `newspaper4k` e `readability` como valores de `selected_extractor`.
- Seleção determinística de metadados.
- Conversão de HTML para Markdown.
- Uso direto do Markdown já gerado pela Trafilatura quando disponível.
- Geração de um único arquivo `.md` por execução.
- Validação de entrada, saída e erros.
- Gravação atômica do arquivo de saída.
- Testes unitários, testes do CLI e arquivos de resultado esperado.
### 5.2 Fora do escopo
- Receber o objeto raiz com o array `articles`.
- Processar vários artigos em uma execução.
- Executar novamente Trafilatura, Newspaper4k ou Readability.
- Calcular ou alterar `selected_extractor`.
- Comparar, combinar ou complementar o corpo com conteúdo de outro extrator.
- Usar LLM, embeddings ou qualquer seleção probabilística.
- Fazer novas requisições HTTP.
- Baixar ou armazenar imagens.
- Limpar semanticamente publicidade, recomendações, overlays ou outros blocos editoriais presentes no corpo selecionado.
- Criar API, banco de dados, fila, interface gráfica ou integração externa.
- Alterar o JSON recebido.
## 6. Contrato de entrada
### 6.1 Formato
A entrada será um arquivo JSON UTF-8 cujo objeto raiz representa um único item do array `articles` observado no arquivo de referência.
Campos esperados no objeto:
| Campo | Tipo esperado | Obrigatoriedade | Uso |
|---|---|---:|---|
| `selected_extractor` | string | Obrigatório | Define a única fonte permitida para o corpo. |
| `input_meta` | object | Opcional | Fornece principalmente URL original, título e data de fallback. |
| `crawled_url` | string | Opcional | URL de fallback. |
| `page_title` | string | Opcional | Título de fallback. |
| `trafilatura` | object | Condicional | Obrigatório quando selecionado; opcional nos demais casos. |
| `newspaper4k` | object | Condicional | Obrigatório quando selecionado; opcional nos demais casos. |
| `readability` | object | Condicional | Obrigatório quando selecionado; opcional nos demais casos. |
### 6.2 Valores aceitos para `selected_extractor`
- `trafilatura`
- `newspaper4k`
- `readability`
Qualquer outro valor deve invalidar a entrada.
### 6.3 Campos obrigatórios após a resolução
O processamento somente será bem-sucedido se for possível resolver:
- título não vazio;
- URL original absoluta com protocolo `http` ou `https`;
- corpo não vazio pertencente ao `selected_extractor`.
Os demais campos são opcionais e nunca devem impedir a geração do Markdown.
## 7. Contrato de saída
### 7.1 Arquivo
- Formato: Markdown UTF-8.
- Quantidade: um arquivo por execução.
- Nome padrão: mesmo nome-base do JSON de entrada, substituindo `.json` por `.md`.
- Caminho alternativo: informado por `-o` ou `--output`.
- Escrita: arquivo temporário seguido de substituição atômica do destino.
### 7.2 Estrutura do Markdown
O Markdown deve seguir esta ordem:
```markdown
# Título do artigo
Subtítulo ou descrição, quando disponível.
**Autor:** Nome do autor
**Publicado em:** 2026-08-20T00:36:33-03:00
**Site:** Nome do site
**Categoria:** Categoria 1, Categoria 2
**Tags:** Tag 1, Tag 2
**Palavras-chave:** Palavra 1, Palavra 2
**Idioma:** es
**Fonte original:** [https://exemplo.com/artigo](https://exemplo.com/artigo)
![Imagem principal](https://exemplo.com/imagem.jpg)
---
Conteúdo do artigo em Markdown.
```
Regras de apresentação:
- título, URL original e corpo sempre devem aparecer;
- cada linha opcional deve ser completamente omitida quando não houver valor válido;
- nenhum placeholder como `null`, `None`, `N/A`, `unknown` ou `[no-author]` deve aparecer;
- o subtítulo deve ser omitido quando for igual ao título após normalização;
- a imagem principal deve ser omitida quando não possuir URL absoluta `http` ou `https`;
- o nome do `selected_extractor` não deve ser exibido no documento;
- os metadados técnicos internos do JSON não devem ser exibidos.
## 8. Regras funcionais
### RF-001 — Receber um único artigo
O CLI deve aceitar somente um objeto individual de artigo. Um objeto contendo `articles` deve ser rejeitado, pois o processamento em lote não pertence a esta feature.
### RF-002 — Respeitar o extrator selecionado
O corpo deve vir exclusivamente do extrator indicado em `selected_extractor`. A ausência de corpo utilizável nesse extrator deve encerrar o processamento com erro. O sistema não pode trocar silenciosamente para outro extrator.
### RF-003 — Resolver o corpo dentro do extrator selecionado
| `selected_extractor` | Fonte principal | Fallback do mesmo extrator | Tratamento |
|---|---|---|---|
| `trafilatura` | `trafilatura.markdown` | `trafilatura.text` | Usar o Markdown diretamente; o texto puro já é Markdown válido. |
| `newspaper4k` | `newspaper4k.article_html` | `newspaper4k.text` | Converter o HTML; usar texto puro somente quando o HTML estiver vazio. |
| `readability` | `readability.cleaned_html` | `readability.cleaned_text` | Converter o HTML; usar texto puro somente quando o HTML estiver vazio. |
O fallback ocorre somente entre representações do mesmo extrator selecionado.
### RF-004 — Selecionar metadados deterministicamente
Para cada campo, o sistema deve:
1. percorrer as fontes na ordem definida neste PRD;
2. normalizar e validar cada candidato;
3. selecionar o primeiro candidato válido;
4. não consultar as fontes restantes após a seleção;
5. omitir o campo se nenhum candidato opcional for válido.
A mesma entrada deve sempre gerar a mesma seleção e o mesmo arquivo.
### RF-005 — Priorizar metadados por campo
`SELECIONADO` representa o campo equivalente dentro do objeto indicado por `selected_extractor`. Quando o extrator não possuir o campo, essa posição é ignorada.
| Campo de saída | Ordem de prioridade |
|---|---|
| Título | `SELECIONADO.title` → `input_meta.titulo` → `page_title` → `newspaper4k.title` → `trafilatura.title` → `readability.title` |
| URL original | `input_meta.url` → `crawled_url` → URL canônica do selecionado → `trafilatura.canonical_url` → `newspaper4k.canonical_link` |
| Subtítulo/descrição | descrição do selecionado → `trafilatura.description` → `newspaper4k.meta_description` → `input_meta.subtitulo` |
| Autores | autor(es) do selecionado → `newspaper4k.authors` → `trafilatura.author` → `readability.author` |
| Data de publicação | data do selecionado → `newspaper4k.publish_date` → `trafilatura.date` → `input_meta.quando_publicado` |
| Site | site do selecionado → `trafilatura.sitename` → `newspaper4k.meta_site_name` → `trafilatura.hostname` → hostname da URL original |
| Categorias | categorias do selecionado → `trafilatura.categories` |
| Tags | tags do selecionado → `trafilatura.tags` → `newspaper4k.tags` → `newspaper4k.meta_keywords` |
| Palavras-chave | `newspaper4k.keywords` → `newspaper4k.meta_keywords` |
| Idioma | idioma do selecionado → `trafilatura.language` → `newspaper4k.meta_lang` |
| Imagem principal | imagem do selecionado → `newspaper4k.top_image` → `trafilatura.image` |
Mapeamento dos campos equivalentes do extrator selecionado:
| Informação | Trafilatura | Newspaper4k | Readability |
|---|---|---|---|
| Título | `title` | `title` | `title` |
| Descrição | `description` | `meta_description` | Não disponível |
| Autores | `author` | `authors` | `author` |
| Data | `date` | `publish_date` | Não disponível |
| Site | `sitename` | `meta_site_name` | Não disponível |
| Categorias | `categories` | Não disponível | Não disponível |
| Tags | `tags` | `tags` | Não disponível |
| Idioma | `language` | `meta_lang` | Não disponível |
| Imagem principal | `image` | `top_image` | Não disponível |
| URL canônica | `canonical_url` | `canonical_link` | Não disponível |
### RF-006 — Normalizar valores escalares
Antes da validação, toda string candidata deve:
- ter entidades HTML decodificadas;
- remover espaços no início e no fim;
- colapsar sequências internas de espaços em um único espaço;
- ser considerada ausente quando vazia ou quando corresponder, sem diferença entre maiúsculas e minúsculas, a um placeholder conhecido: `null`, `none`, `n/a`, `unknown`, `[no-author]` ou `no-author`.
### RF-007 — Normalizar listas
Autores, categorias, tags e palavras-chave podem chegar como lista ou string. O sistema deve:
- aceitar lista de strings;
- aceitar string única;
- separar strings com múltiplos valores apenas por ponto e vírgula;
- normalizar cada item conforme RF-006;
- descartar autor iniciado por `http://`, `https://` ou `www.`;
- eliminar duplicatas sem diferenciar maiúsculas de minúsculas, preservando a primeira grafia e a ordem original;
- considerar a fonte inválida quando nenhum item válido restar;
- usar somente a primeira fonte da tabela de prioridade que resultar em lista válida, sem unir listas de fontes diferentes.
### RF-008 — Normalizar datas
- Aceitar datas ISO 8601 e RFC 2822 observadas na entrada de referência.
- Emitir ISO 8601.
- Preservar o fuso horário informado.
- Emitir somente `YYYY-MM-DD` quando a fonte fornecer apenas a data.
- Considerar inválida uma data que não possa ser interpretada e continuar para a próxima fonte de prioridade.
### RF-009 — Validar URLs
- Aceitar somente URLs absolutas com protocolo `http` ou `https`.
- Não fazer requisições para validar existência ou disponibilidade.
- Não aceitar `data:`, `javascript:`, caminhos relativos ou strings sem hostname.
### RF-010 — Converter HTML para Markdown
A conversão deve usar a biblioteca Python `markdownify`, configurada para produzir títulos no padrão ATX (`#`, `##`, `###`).
Devem ser preservados, quando presentes no HTML selecionado:
- parágrafos;
- títulos e subtítulos estruturais;
- listas ordenadas e não ordenadas;
- negrito e itálico;
- links;
- citações;
- blocos de código;
- tabelas suportadas pela biblioteca;
- imagens válidas do próprio corpo.
A escolha de `markdownify` é intencional: a necessidade é exclusivamente converter HTML para Markdown. O Microsoft MarkItDown suporta HTML, mas atende vários outros formatos e acrescentaria uma abstração mais ampla do que a requerida por esta feature.
### RF-011 — Tratar imagens do corpo
- Preservar imagens convertidas do corpo somente quando o destino for uma URL absoluta `http` ou `https`.
- Remover imagens com URL vazia, relativa ou `data:`.
- Eliminar repetições exatas da mesma URL de imagem, preservando a primeira ocorrência.
- Não adicionar ao corpo a coleção `newspaper4k.images`.
- Não baixar, redimensionar ou validar remotamente imagens.
### RF-012 — Evitar título duplicado
Após a conversão do corpo, o sistema deve remover o primeiro título H1 do corpo somente quando ele for igual ao título resolvido após decodificação de HTML, normalização de espaços e comparação sem diferença entre maiúsculas e minúsculas.
Outros títulos do conteúdo devem ser preservados.
### RF-013 — Normalizar o Markdown final
O arquivo final deve:
- usar quebra de linha `LF`;
- terminar com exatamente uma quebra de linha;
- eliminar espaços no final das linhas;
- limitar sequências de linhas vazias a no máximo duas;
- não conter tags HTML remanescentes geradas apenas pela estrutura do documento;
- preservar o texto, a pontuação e os caracteres Unicode do conteúdo selecionado.
### RF-014 — Não gerar saída parcial
Se ocorrer qualquer erro antes da conclusão, o arquivo de destino existente deve permanecer intacto e nenhum arquivo temporário deve permanecer no diretório de saída.
## 9. Interface CLI
### 9.1 Script
`scripts/convert_article_to_markdown.py`
### 9.2 Argumentos
| Parâmetro | Tipo | Obrigatoriedade | Padrão | Descrição |
|---|---|---:|---|---|
| `-i`, `--input` | caminho | Obrigatório | — | JSON contendo um único artigo. |
| `-o`, `--output` | caminho | Opcional | `<input_stem>.md` | Arquivo Markdown de destino. |
Não devem ser adicionadas flags sem requisito funcional neste PRD.
### 9.3 Exemplos
```bash
python scripts/convert_article_to_markdown.py -i out/article_001.json
```
Resultado: `out/article_001.md`.
```bash
python scripts/convert_article_to_markdown.py \
-i out/article_001.json \
-o out/markdown/article_001.md
```
### 9.4 Saída do processo
- Código `0`: arquivo gerado com sucesso.
- Código `2`: argumentos inválidos, conforme comportamento do `argparse`.
- Código `1`: erro de leitura, validação, conversão ou gravação.
- Mensagens de erro e confirmação devem ir para `stderr`.
- O conteúdo Markdown não deve ser impresso no terminal quando houver arquivo de saída.
## 10. Tratamento de erros
O processamento deve falhar de forma clara nos seguintes casos:
| Situação | Comportamento esperado |
|---|---|
| Arquivo não encontrado ou ilegível | Encerrar com código `1` e informar o caminho. |
| JSON inválido | Encerrar com código `1` e informar que a entrada não é JSON válido. |
| Raiz diferente de objeto | Encerrar com código `1`. |
| Entrada contém `articles` | Encerrar com código `1` e informar que o CLI aceita um único artigo. |
| `selected_extractor` ausente ou desconhecido | Encerrar com código `1`. |
| Objeto do extrator selecionado ausente | Encerrar com código `1`. |
| Corpo do extrator selecionado vazio | Encerrar com código `1`; não usar outro extrator. |
| Título não resolvido | Encerrar com código `1`. |
| URL original não resolvida ou inválida | Encerrar com código `1`. |
| Metadado opcional inválido | Ignorar o candidato e tentar o próximo; omitir se todos falharem. |
| Falha na conversão | Encerrar com código `1`. |
| Falha na gravação | Encerrar com código `1` sem alterar o destino anterior. |
As mensagens não devem imprimir o conteúdo integral do artigo.
## 11. Requisitos não funcionais
### RNF-001 — Determinismo
A mesma entrada e a mesma versão das dependências devem produzir exatamente o mesmo arquivo Markdown.
### RNF-002 — Compatibilidade
A feature deve manter as versões de Python declaradas como suportadas pelo projeto e funcionar nos sistemas operacionais já suportados pelo repositório.
### RNF-003 — Execução local
O processamento deve ocorrer inteiramente em memória local, sem rede, browser, LLM ou serviço externo.
### RNF-004 — Integridade
A entrada não deve ser modificada. A gravação de saída deve ser atômica.
### RNF-005 — Manutenibilidade
As regras de resolução de campos e as regras de conversão devem ser isoladas em funções testáveis, sem duplicação entre o CLI e o uso interno.
### RNF-006 — Qualidade
O código deve atender aos gates já adotados pelo projeto: Ruff, Mypy, Pytest e SonarQube.
## 12. Critérios de aceite
### CA-001 — Trafilatura selecionada
**Dado** um artigo com `selected_extractor` igual a `trafilatura` e `trafilatura.markdown` preenchido
**Quando** o CLI for executado
**Então** o corpo do arquivo deve vir de `trafilatura.markdown` e nenhum corpo dos demais extratores deve ser incorporado.
### CA-002 — Newspaper4k selecionado
**Dado** um artigo com `selected_extractor` igual a `newspaper4k` e `newspaper4k.article_html` preenchido
**Quando** o CLI for executado
**Então** esse HTML deve ser convertido para Markdown preservando sua estrutura editorial.
### CA-003 — Readability selecionado
**Dado** um artigo com `selected_extractor` igual a `readability` e `readability.cleaned_html` preenchido
**Quando** o CLI for executado
**Então** esse HTML deve ser convertido para Markdown preservando sua estrutura editorial.
### CA-004 — Fallback dentro do extrator
**Dado** um extrator selecionado cujo campo estruturado esteja vazio, mas cujo campo de texto puro esteja preenchido
**Quando** o CLI for executado
**Então** o texto puro do mesmo extrator deve ser usado.
### CA-005 — Proibição de fallback de corpo entre extratores
**Dado** um extrator selecionado sem HTML, Markdown ou texto utilizável e outro extrator com conteúdo
**Quando** o CLI for executado
**Então** o processamento deve falhar sem utilizar o outro extrator.
### CA-006 — Metadado vindo de outro extrator
**Dado** um artigo cujo extrator selecionado não possua autor e outro extrator possua autor válido
**Quando** o CLI for executado
**Então** o primeiro autor válido conforme a prioridade deve aparecer no Markdown.
### CA-007 — Obrigatórios presentes
**Dado** um artigo válido
**Quando** o Markdown for gerado
**Então** ele deve conter título, URL original e corpo não vazio.
### CA-008 — Opcionais ausentes
**Dado** um artigo sem metadados opcionais válidos
**Quando** o Markdown for gerado
**Então** nenhuma linha vazia de metadado ou placeholder deve ser exibida.
### CA-009 — Imagens inválidas
**Dado** um corpo com imagens `data:`, vazias ou relativas
**Quando** o conteúdo for convertido
**Então** essas imagens devem ser removidas do Markdown.
### CA-010 — Título repetido no corpo
**Dado** um corpo que começa com H1 igual ao título resolvido
**Quando** o Markdown for montado
**Então** deve existir somente um H1 com esse título no arquivo final.
### CA-011 — Entrada em lote rejeitada
**Dado** o arquivo completo de referência contendo `articles`
**Quando** ele for passado diretamente ao CLI
**Então** o processamento deve falhar informando que a entrada esperada é um único artigo.
### CA-012 — Determinismo
**Dado** o mesmo JSON processado duas vezes com as mesmas dependências
**Quando** os arquivos forem comparados byte a byte
**Então** eles devem ser idênticos.
### CA-013 — Gravação segura
**Dado** um arquivo de destino preexistente e uma falha durante o processamento
**Quando** o CLI encerrar
**Então** o arquivo preexistente deve continuar inalterado.
## 13. Estratégia de testes
### 13.1 Testes unitários
- resolução de cada campo conforme a ordem de prioridade;
- normalização de strings e placeholders;
- normalização, deduplicação e seleção de listas;
- parsing e padronização de datas ISO 8601 e RFC 2822;
- validação de URLs;
- seleção do corpo para cada extrator;
- fallback de representação dentro do mesmo extrator;
- bloqueio do fallback de corpo para outro extrator;
- conversão dos principais elementos HTML;
- remoção de imagens inválidas e duplicadas;
- remoção somente do H1 inicial duplicado;
- normalização final de espaços e quebras de linha.
### 13.2 Testes de integração do CLI
- geração com caminho padrão;
- geração com `--output`;
- códigos de saída `0`, `1` e `2`;
- mensagens em `stderr`;
- rejeição do JSON com coleção `articles`;
- preservação do destino em caso de falha;
- codificação UTF-8 com caracteres acentuados.
### 13.3 Casos de resultado esperado
Devem existir pelo menos três fixtures válidas, uma para cada valor de `selected_extractor`, acompanhadas dos respectivos arquivos Markdown esperados. A comparação deve ser exata.
Também devem existir fixtures inválidas cobrindo:
- JSON corrompido;
- `selected_extractor` ausente;
- extrator desconhecido;
- corpo selecionado vazio;
- título ausente em todas as fontes;
- URL ausente ou inválida em todas as fontes;
- objeto raiz contendo `articles`.
## 14. Definition of Done
A feature será considerada concluída quando:
- o script `scripts/convert_article_to_markdown.py` estiver implementado;
- `markdownify` estiver declarada nas dependências do projeto;
- todos os requisitos funcionais e critérios de aceite estiverem cobertos;
- os testes unitários e de integração estiverem passando;
- as três fixtures de extratores produzirem exatamente os Markdown esperados;
- Ruff não apontar erros;
- Mypy não apontar erros;
- o conjunto completo de testes do projeto permanecer aprovado;
- o Quality Gate do SonarQube estiver aprovado;
- o README documentar a nova etapa, os argumentos e exemplos do CLI;
- nenhuma funcionalidade fora do escopo tiver sido adicionada.
## 15. Dependência técnica escolhida
- Biblioteca: [`markdownify`](https://github.com/matthewwithanm/python-markdownify)
- Finalidade: conversão direta de HTML para Markdown em Python.
- Alternativa avaliada: [`Microsoft MarkItDown`](https://github.com/microsoft/markitdown).
- Decisão: não usar MarkItDown nesta feature porque seu escopo de conversão multiformato excede a necessidade de HTML para Markdown.
+38 -4
View File
@@ -47,7 +47,7 @@
"45": "ClassificationResult",
"46": "InherenceClassifier",
"47": "classifier.py",
"48": "main",
"48": "test_convert_article_to_markdown.py",
"49": "content_northvolt_de.md",
"50": "content_presal_pt.md",
"51": "content_tangential_es.md",
@@ -98,13 +98,13 @@
"96": "readiness.md",
"97": "🧠 TextNLPClassifierApp",
"98": "Extraction Pipeline Checklist: Article Content Multi-Engine Extractor",
"99": "sample_rss_xml",
"99": "parametrize",
"100": "models.py",
"101": "ECPSnapshot",
"102": "Feature Specification: Multilingual NLP Entity Inherence Classifier (POC)",
"103": "4. Requisitos Funcionais (FR)",
"104": "Tasks: Article Content Multi-Engine Extractor",
"105": "LocalEmbeddingsAdapter",
"105": "Tasks: Convert Article JSON to Markdown",
"106": "Implementation Plan: Article Content Multi-Engine Extractor",
"107": "2. Cenários de Validação",
"108": "1. Technical Decisions & Tradeoffs",
@@ -120,6 +120,7 @@
"118": "Tasks: Deterministic Article Content Selection",
"119": "select_article_extractor",
"120": "process_batch",
"121": "detect_language",
"122": "test_select_article_extractor.py",
"123": "Feature Specification: Deterministic Content Selection",
"124": "2. Entity Descriptions & Fields",
@@ -129,5 +130,38 @@
"128": "Specification Quality Checklist: Deterministic Content Selection",
"129": "CLI Interface Contract: Deterministic Article Content Selection",
"130": "004-deterministic-content-selection/spec.md",
"131": "JSON Schema Contract: Deterministic Article Content Selection"
"131": "convert_article_to_markdown.py",
"132": "8. Regras funcionais",
"133": "12. Critérios de aceite",
"134": "PRD — Conversão de artigo JSON para Markdown",
"135": "resolve_article_body",
"136": "Implementation Plan: Convert Article JSON to Markdown",
"137": "2. Technical Decisions & Research Findings",
"138": "Feature Specification: Convert Article JSON to Markdown",
"139": "Markdown Conversion Checklist: End-to-End Requirements Quality",
"140": "convert_article",
"141": "005-convert-json-markdown/plan.md",
"142": "Quickstart: Convert Article JSON to Markdown",
"143": "1. Domain Entities & Schemas",
"144": "11. Requisitos não funcionais",
"145": "parse_arguments",
"146": "Specification Quality Checklist: Convert Article JSON to Markdown",
"147": "CLI Contract: `convert_article_to_markdown.py`",
"148": "9. Interface CLI",
"149": "get_hl_gl_ceid",
"150": "13. Estratégia de testes",
"151": "6. Contrato de entrada",
"152": "assemble_markdown_document",
"153": "convert_html_to_markdown",
"154": "JSON Schema Contract: Deterministic Article Content Selection",
"155": "5. Escopo",
"156": "Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports",
"157": "valid_newspaper4k.md",
"158": "valid_readability.md",
"159": "test_normalize_date_rfc_2822_variants",
"160": "test_normalize_date_invalid_and_placeholders",
"161": "test_metadata_priority_title_all_fallbacks",
"162": "test_metadata_priority_subtitle_omitted_when_equal_to_title",
"163": "test_metadata_priority_first_valid_source_no_cross_merging",
"164": "test_normalize_scalar_non_string_types"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "36bdb6f09c457f7c", "1": "8c5bf6244cf710c6", "2": "efbcc9c62a3ee78b", "3": "8599153989b07faa", "4": "b5952a1f7fee9f20", "5": "5b8462a3f82d188c", "6": "80f79e9e2011a3e3", "7": "4654167fd211d027", "8": "50acfa00fe353440", "9": "c6d2f770737823f1", "10": "44f2ca451aea24be", "11": "feaac5ab67a8c17a", "12": "b71bd92e5edbf2e0", "13": "219d65ba6d2689e4", "14": "8e30bb8112fd02d1", "15": "03906ab80b99db85", "16": "5d51c60ba1bc2be0", "17": "a1da914f522dcd21", "18": "fbad840891b90569", "19": "0686ff2d6fe29fb3", "20": "060baa9e1924b465", "21": "a5c8f2c3080b8243", "22": "0d76852f1d29eeb1", "23": "6ff68619f2d72924", "24": "3da11675eee7ec46", "25": "a6696589e9556f97", "26": "6c752999e8a4d4b6", "27": "2d4e13ea2111d750", "28": "4b60cb0ee1ac186a", "29": "f56fbca9bb8235ec", "30": "c7beed940704509f", "31": "38be2d254fb31ae8", "32": "ee5596fcf7e7c0b3", "33": "e4d4e0a440bc599f", "34": "c897e49c001acdae", "35": "3aad272a2cf5d495", "36": "0a197439d306b956", "37": "f43acf5c8b1329af", "38": "6775efafc9b33338", "39": "8176a164778526f9", "40": "66b69189c0acc3ff", "41": "0322ff824966a4d8", "42": "784c9e3d336a7f53", "43": "4b8bb6c3f7b64856", "44": "18c0ff3e6225bcb2", "45": "1880db165e83768c", "46": "58f3596265f5f902", "47": "4e9a638bf1f93e1b", "48": "cc757e9c9987201b", "49": "0d0f9f015921feef", "50": "8d0c81e5ca23e9a6", "51": "f79963571b9c15ee", "52": "5935824c825606cb", "53": "9685f9cbe158e50b", "54": "3d5ab759f350bc79", "55": "d549f24931a990e9", "56": "3cc031dcb648797c", "57": "a0ab88e6c629251d", "58": "76bd6412e2a22ecd", "59": "54827845564490c9", "60": "0a9736c416c0c6b9", "61": "77358620ac528153", "62": "3b0c585df09df48a", "63": "7e78cd3b28828c20", "64": "1c0c958231735f61", "65": "60b0f81225f62f69", "66": "920754c65cc94b88", "67": "df911472140a9b94", "68": "8e17bc11bcea91b9", "69": "7e905b75e4f28b95", "70": "a28424eca5d36c55", "71": "2cdb53d5b6051ab6", "72": "e42fbd3dc744e730", "73": "7fe2cac980de160c", "74": "2b1343a6a9db1487", "75": "54a1bb232f1d4ceb", "76": "442ba11d31ec0e0a", "77": "852a25b8b95bf8d1", "78": "1810ab370b9cd608", "79": "0fc5dca02a3f02f6", "80": "6ff8a97e63c9a2f3", "81": "a38f84ae3d895236", "82": "08e48bd11f9714df", "83": "5095122914e83cf5", "84": "1aef305bd7d7d63f", "85": "f8bfd0cfe9e8b478", "86": "410d15a346bd5894", "87": "6b41d288cfd834ab", "88": "5aa6db96312a8811", "89": "80225792bb62ba04", "90": "fd291228c3311f40", "91": "d4579c5b7aa2742a", "92": "7b9ba7c3bff11361", "93": "71cd9c1fa4a857f0", "94": "34cd980be3c32d21", "95": "970093453f3b7d90", "96": "9e96780a2b7c4bd6", "97": "e091504212d41fa4", "98": "089ea6a55861c693", "99": "8968e9e7d55afcbe", "100": "a7b5a49d77797f1b", "101": "21a45c852f9aeb96", "102": "6aa00d5a83295f11", "103": "f58668f5b10ccdeb", "104": "4ec787414cc6f50b", "105": "b42f25c7ec3542ab", "106": "edcd5d9bb3c4b00f", "107": "37f2f47110fe3eaa", "108": "b7ad5abb1da8cf8d", "109": "cb48a9c4f54efa38", "110": "f6dd36fd7f3edbe5", "111": "2925b620f0b1fd17", "112": "d8b3099917c3b711", "113": "3bb61caa0302c804", "114": "0d4f1d08dd056bb9", "115": "4ac2dcddeec2ff11", "116": "07da9aae9668f573", "117": "196f63e0c4536d30", "118": "ade84262e3cfac12", "119": "ebe4e5e0c42c613f", "120": "27256931b19a2867", "122": "aa8a1de55696b666", "123": "96618c9a362af46c", "124": "83f104cbb62fd03e", "125": "6db738fb27190349", "126": "6a087a22cbcef972", "127": "85fd71a0cad8d3a5", "128": "22dd4feed96c4229", "129": "c4d2f60f532e6f16", "130": "f6b0aa8a1568926b", "131": "56747bad6345d66b"}
{"0": "36bdb6f09c457f7c", "1": "8c5bf6244cf710c6", "2": "efbcc9c62a3ee78b", "3": "8599153989b07faa", "4": "b5952a1f7fee9f20", "5": "5b8462a3f82d188c", "6": "80f79e9e2011a3e3", "7": "4654167fd211d027", "8": "50acfa00fe353440", "9": "c6d2f770737823f1", "10": "44f2ca451aea24be", "11": "feaac5ab67a8c17a", "12": "b71bd92e5edbf2e0", "13": "219d65ba6d2689e4", "14": "8e30bb8112fd02d1", "15": "03906ab80b99db85", "16": "5d51c60ba1bc2be0", "17": "a1da914f522dcd21", "18": "fbad840891b90569", "19": "0686ff2d6fe29fb3", "20": "060baa9e1924b465", "21": "a5c8f2c3080b8243", "22": "0d76852f1d29eeb1", "23": "6ff68619f2d72924", "24": "3da11675eee7ec46", "25": "a6696589e9556f97", "26": "6c752999e8a4d4b6", "27": "2d4e13ea2111d750", "28": "4b60cb0ee1ac186a", "29": "f56fbca9bb8235ec", "30": "c7beed940704509f", "31": "38be2d254fb31ae8", "32": "ee5596fcf7e7c0b3", "33": "e4d4e0a440bc599f", "34": "c897e49c001acdae", "35": "3aad272a2cf5d495", "36": "0a197439d306b956", "37": "f43acf5c8b1329af", "38": "6775efafc9b33338", "39": "8176a164778526f9", "40": "66b69189c0acc3ff", "41": "0322ff824966a4d8", "42": "784c9e3d336a7f53", "43": "4b8bb6c3f7b64856", "44": "18c0ff3e6225bcb2", "45": "0e7fcc21c22f118e", "46": "58f3596265f5f902", "47": "4de4f30d96344790", "48": "779ea305cb4270dc", "49": "0d0f9f015921feef", "50": "8d0c81e5ca23e9a6", "51": "f79963571b9c15ee", "52": "5935824c825606cb", "53": "9685f9cbe158e50b", "54": "3d5ab759f350bc79", "55": "d549f24931a990e9", "56": "3cc031dcb648797c", "57": "a0ab88e6c629251d", "58": "76bd6412e2a22ecd", "59": "54827845564490c9", "60": "0a9736c416c0c6b9", "61": "77358620ac528153", "62": "3b0c585df09df48a", "63": "7e78cd3b28828c20", "64": "1c0c958231735f61", "65": "60b0f81225f62f69", "66": "920754c65cc94b88", "67": "df911472140a9b94", "68": "8e17bc11bcea91b9", "69": "7e905b75e4f28b95", "70": "a28424eca5d36c55", "71": "2cdb53d5b6051ab6", "72": "e42fbd3dc744e730", "73": "7fe2cac980de160c", "74": "2b1343a6a9db1487", "75": "54a1bb232f1d4ceb", "76": "442ba11d31ec0e0a", "77": "852a25b8b95bf8d1", "78": "1810ab370b9cd608", "79": "0fc5dca02a3f02f6", "80": "6ff8a97e63c9a2f3", "81": "a38f84ae3d895236", "82": "dc6ddc157a3b9efb", "83": "a05140495d7a0353", "84": "24ca89fec34df075", "85": "f8bfd0cfe9e8b478", "86": "410d15a346bd5894", "87": "6b41d288cfd834ab", "88": "5aa6db96312a8811", "89": "80225792bb62ba04", "90": "e18a0a239fe528ba", "91": "d4579c5b7aa2742a", "92": "7b9ba7c3bff11361", "93": "71cd9c1fa4a857f0", "94": "34cd980be3c32d21", "95": "970093453f3b7d90", "96": "9e96780a2b7c4bd6", "97": "0b41ca143bd19d3a", "98": "089ea6a55861c693", "99": "683812f7e5020d43", "100": "8c97d8c400895e15", "101": "55faed4f78d00dd4", "102": "6aa00d5a83295f11", "103": "f58668f5b10ccdeb", "104": "4ec787414cc6f50b", "105": "1cf3077fd45d874a", "106": "edcd5d9bb3c4b00f", "107": "37f2f47110fe3eaa", "108": "b7ad5abb1da8cf8d", "109": "cb48a9c4f54efa38", "110": "f6dd36fd7f3edbe5", "111": "2925b620f0b1fd17", "112": "d8b3099917c3b711", "113": "3bb61caa0302c804", "114": "0d4f1d08dd056bb9", "115": "4ac2dcddeec2ff11", "116": "07da9aae9668f573", "117": "196f63e0c4536d30", "118": "ade84262e3cfac12", "119": "ebe4e5e0c42c613f", "120": "27256931b19a2867", "121": "5396e68ca6c185ad", "122": "aa8a1de55696b666", "123": "96618c9a362af46c", "124": "83f104cbb62fd03e", "125": "6db738fb27190349", "126": "6a087a22cbcef972", "127": "85fd71a0cad8d3a5", "128": "22dd4feed96c4229", "129": "c4d2f60f532e6f16", "130": "f6b0aa8a1568926b", "131": "bf5dd1f3361b9d31", "132": "67ea4284cbc02c54", "133": "d899cfc86c7a4a27", "134": "ba9464410a9b4168", "135": "5406c193e79cdc9e", "136": "521f5c7b9d566b4d", "137": "9e37828bdd2ba8c5", "138": "ec03c97194c56f91", "139": "f4e6d5dfa30034c5", "140": "b48c7d99e847e352", "141": "1e0330b8757f333e", "142": "f35d75e1194c008d", "143": "4d2ae7190b514a34", "144": "4a98716cabf43f86", "145": "c80d7bdacb7f0aa3", "146": "edc785fd71bb0675", "147": "4c7347f8f86e1fbd", "148": "8ca77cc4fd6fd437", "149": "09850697b717469a", "150": "f4f4ce1a1180ddb1", "151": "e426746f6e9ee15f", "152": "3d72689c6c4d76be", "153": "3eef4100c7957556", "154": "56747bad6345d66b", "155": "a8e7498fa7e257df", "156": "56e7b2355898077f", "157": "f2fc88f7d8214711", "158": "c966f6f8570c8c29", "159": "3846d1135f02ad84", "160": "d19878b60c21c9dc", "161": "f288304e804126ee", "162": "fe285d17e79eaab7", "163": "97e7e26a5ea11e7a", "164": "bc4c32aee32f28c8"}
+34 -4
View File
@@ -47,7 +47,7 @@
"45": "ClassificationResult",
"46": "InherenceClassifier",
"47": "classifier.py",
"48": "main",
"48": "test_convert_article_to_markdown.py",
"49": "content_northvolt_de.md",
"50": "content_presal_pt.md",
"51": "content_tangential_es.md",
@@ -104,7 +104,7 @@
"102": "Feature Specification: Multilingual NLP Entity Inherence Classifier (POC)",
"103": "4. Requisitos Funcionais (FR)",
"104": "Tasks: Article Content Multi-Engine Extractor",
"105": "LocalEmbeddingsAdapter",
"105": "Tasks: Convert Article JSON to Markdown",
"106": "Implementation Plan: Article Content Multi-Engine Extractor",
"107": "2. Cenários de Validação",
"108": "1. Technical Decisions & Tradeoffs",
@@ -116,10 +116,11 @@
"114": "JSON Schema Contract: Article Content Multi-Engine Extractor",
"115": "PRD — Seleção determinística da biblioteca de extração de conteúdo",
"116": "select_article_extractor.py",
"117": "1. Text Normalization Pipeline",
"117": "Research & Architectural Decisions: Deterministic Content Selection",
"118": "Tasks: Deterministic Article Content Selection",
"119": "select_article_extractor",
"120": "process_batch",
"121": "detect_language",
"122": "test_select_article_extractor.py",
"123": "Feature Specification: Deterministic Content Selection",
"124": "2. Entity Descriptions & Fields",
@@ -129,5 +130,34 @@
"128": "Specification Quality Checklist: Deterministic Content Selection",
"129": "CLI Interface Contract: Deterministic Article Content Selection",
"130": "004-deterministic-content-selection/spec.md",
"131": "JSON Schema Contract: Deterministic Article Content Selection"
"131": "convert_article_to_markdown.py",
"132": "8. Regras funcionais",
"133": "12. Critérios de aceite",
"134": "PRD — Conversão de artigo JSON para Markdown",
"135": "resolve_article_body",
"136": "Implementation Plan: Convert Article JSON to Markdown",
"137": "2. Technical Decisions & Research Findings",
"138": "Feature Specification: Convert Article JSON to Markdown",
"139": "Markdown Conversion Checklist: End-to-End Requirements Quality",
"140": "convert_article",
"141": "005-convert-json-markdown/plan.md",
"142": "Quickstart: Convert Article JSON to Markdown",
"143": "1. Domain Entities & Schemas",
"144": "11. Requisitos não funcionais",
"145": "parse_arguments",
"146": "Specification Quality Checklist: Convert Article JSON to Markdown",
"147": "CLI Contract: `convert_article_to_markdown.py`",
"148": "9. Interface CLI",
"149": "1. Text Normalization Pipeline",
"150": "13. Estratégia de testes",
"151": "6. Contrato de entrada",
"152": "assemble_markdown_document",
"153": "convert_html_to_markdown",
"154": "remove_duplicate_initial_h1",
"155": "5. Escopo",
"156": "Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports",
"157": "valid_newspaper4k.md",
"158": "valid_readability.md",
"159": "test_normalize_list_strings_and_deduplication",
"160": "test_validate_url_schemes"
}
+187 -52
View File
@@ -1,16 +1,16 @@
# Graph Report - TextNLPClassifierApp (2026-08-20)
# Graph Report - TextNLPClassifierApp (2026-08-21)
## Corpus Check
- 174 files · ~91,961 words
- 199 files · ~107,634 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1235 nodes · 1548 edges · 131 communities (93 shown, 38 thin omitted)
- 1504 nodes · 1860 edges · 161 communities (118 shown, 43 thin omitted)
- Extraction: 97% EXTRACTED · 3% INFERRED · 0% AMBIGUOUS · INFERRED: 51 edges (avg confidence: 0.95)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `6a45368c`
- Built from commit: `64dfd842`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -59,7 +59,7 @@
- ClassificationResult
- InherenceClassifier
- classifier.py
- main
- test_convert_article_to_markdown.py
- content_northvolt_de.md
- content_presal_pt.md
- content_tangential_es.md
@@ -115,7 +115,7 @@
- Feature Specification: Multilingual NLP Entity Inherence Classifier (POC)
- 4. Requisitos Funcionais (FR)
- Tasks: Article Content Multi-Engine Extractor
- LocalEmbeddingsAdapter
- Tasks: Convert Article JSON to Markdown
- Implementation Plan: Article Content Multi-Engine Extractor
- 2. Cenários de Validação
- 1. Technical Decisions & Tradeoffs
@@ -126,10 +126,11 @@
- JSON Schema Contract: Article Content Multi-Engine Extractor
- PRD — Seleção determinística da biblioteca de extração de conteúdo
- select_article_extractor.py
- 1. Text Normalization Pipeline
- Research & Architectural Decisions: Deterministic Content Selection
- Tasks: Deterministic Article Content Selection
- select_article_extractor
- process_batch
- detect_language
- test_select_article_extractor.py
- Feature Specification: Deterministic Content Selection
- 2. Entity Descriptions & Fields
@@ -138,7 +139,37 @@
- Quickstart: Deterministic Article Content Selection
- Specification Quality Checklist: Deterministic Content Selection
- CLI Interface Contract: Deterministic Article Content Selection
- JSON Schema Contract: Deterministic Article Content Selection
- 004-deterministic-content-selection/spec.md
- convert_article_to_markdown.py
- 8. Regras funcionais
- 12. Critérios de aceite
- PRD — Conversão de artigo JSON para Markdown
- resolve_article_body
- Implementation Plan: Convert Article JSON to Markdown
- 2. Technical Decisions & Research Findings
- Feature Specification: Convert Article JSON to Markdown
- Markdown Conversion Checklist: End-to-End Requirements Quality
- convert_article
- 005-convert-json-markdown/plan.md
- Quickstart: Convert Article JSON to Markdown
- 1. Domain Entities & Schemas
- 11. Requisitos não funcionais
- parse_arguments
- Specification Quality Checklist: Convert Article JSON to Markdown
- CLI Contract: `convert_article_to_markdown.py`
- 9. Interface CLI
- 1. Text Normalization Pipeline
- 13. Estratégia de testes
- 6. Contrato de entrada
- assemble_markdown_document
- convert_html_to_markdown
- remove_duplicate_initial_h1
- 5. Escopo
- Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports
- valid_newspaper4k.md
- valid_readability.md
- test_normalize_list_strings_and_deduplication
- test_validate_url_schemes
## God Nodes (most connected - your core abstractions)
1. `ECPSnapshot` - 31 edges
@@ -147,27 +178,27 @@
4. `ExtractorName` - 21 edges
5. `DecisionCategory` - 17 edges
6. `ClassificationResult` - 17 edges
7. `process_batch()` - 15 edges
8. `process_batch()` - 14 edges
9. `LocalEmbeddingsAdapter` - 14 edges
10. `LLMFallbackAdapter` - 14 edges
7. `PRD — Conversão de artigo JSON para Markdown` - 16 edges
8. `process_batch()` - 15 edges
9. `8. Regras funcionais` - 15 edges
10. `process_batch()` - 14 edges
## Surprising Connections (you probably didn't know these)
- `main()` --uses--> `ECPSnapshot` [INFERRED]
classify.py → src/models.py
- `main()` --uses--> `ErrorCode` [INFERRED]
classify.py → src/models.py
- `test_extract_google_news_orchestration_mocked()` --uses--> `ExtractionResult` [INFERRED]
tests/test_extract_google_news.py → scripts/extract_google_news.py
- `test_llm_adapter_interface()` --calls--> `LLMFallbackAdapter` [EXTRACTED]
tests/test_adapters.py → src/adapters/llm.py
- `classifier()` --uses--> `InherenceClassifier` [INFERRED]
tests/test_benchmark_24.py → src/classifier.py
- `test_classification_result_serialization()` --uses--> `DecisionCategory` [INFERRED]
tests/test_models.py → src/models.py
- `petrobras_ecp()` --uses--> `ECPSnapshot` [INFERRED]
tests/test_classifier.py → src/models.py
## Import Cycles
- None detected.
## Communities (131 total, 38 thin omitted)
## Communities (161 total, 43 thin omitted)
### Community 0 - "Task Planning"
Cohesion: 0.07
@@ -310,20 +341,20 @@ Cohesion: 0.29
Nodes (6): 1.1 Arguments & Options, 1. Command Line Interface, 2.1 Exit Codes, 2.2 Standard Output (`stdout`) / Standard Error (`stderr`), 2. Standard Streams & Exit Codes, CLI Contract & Interface Specification (POC)
### Community 45 - "ClassificationResult"
Cohesion: 0.14
Nodes (12): ABC, BaseNLPAdapter, Base abstract adapter interface for optional Tier 2 / Tier 3 NLP enhancers., Abstract interface for pluggable NLP classification adapters., Return True if the underlying provider or model is installed and configured., Compute semantic similarity score between text and a set of candidate terms., Optionally refine an ambiguous classification result., LLMFallbackAdapter (+4 more)
Cohesion: 0.09
Nodes (19): ABC, BaseNLPAdapter, Base abstract adapter interface for optional Tier 2 / Tier 3 NLP enhancers., Abstract interface for pluggable NLP classification adapters., Return True if the underlying provider or model is installed and configured., Compute semantic similarity score between text and a set of candidate terms., Optionally refine an ambiguous classification result., LocalEmbeddingsAdapter (+11 more)
### Community 46 - "InherenceClassifier"
Cohesion: 0.12
Nodes (27): InherenceClassifier, Tier 1 Deterministic NLP Entity Inherence Classifier., DecisionCategory, RelatedEntity, Adversarial and robustness test suite for Multilingual NLP Entity Inherence…, Run CLI via subprocess without --output and verify stdout is pure parseable…, Run CLI via subprocess with empty content and verify error code and exit code., Content about city/state governance of São Paulo against ECP for São Paulo FC. (+19 more)
### Community 47 - "classifier.py"
Cohesion: 0.10
Nodes (31): count_phrase_occurrences(), match_phrase_in_text(), Core deterministic classification engine (Tier 1 core)., Check if a normalized phrase appears in normalized text with word boundary…, Count occurrences of a phrase in text., Classify inherence of content against an ECP snapshot., detect_language(), extract_words() (+23 more)
Cohesion: 0.16
Nodes (16): count_phrase_occurrences(), match_phrase_in_text(), Core deterministic classification engine (Tier 1 core)., Check if a normalized phrase appears in normalized text with word boundary…, Count occurrences of a phrase in text., Classify inherence of content against an ECP snapshot., extract_evidence_snippets(), extract_sentences() (+8 more)
### Community 48 - "main"
Cohesion: 0.31
Nodes (9): main(), parse_args(), Namespace, CLI execution tests covering flags, arguments, stdout, and error handling., test_cli_empty_content_file(), test_cli_missing_ecp_file(), test_cli_missing_required_ecp_field(), test_cli_output_file() (+1 more)
### Community 48 - "test_convert_article_to_markdown.py"
Cohesion: 0.08
Nodes (23): Suíte de Testes Automatizados para Conversão de Artigo JSON para Markdown.…, Testa decodificação de entidades HTML, colapso de espaços e filtro de…, Testa a matriz determinística de prioridades dos metadados., Validação exata byte a byte da Golden Fixture de Trafilatura., Validação exata byte a byte da Golden Fixture de Newspaper4k., Validação exata byte a byte da Golden Fixture de Readability., Testa geração do arquivo de saída padrão <input_stem>.md., Testa rejeição com código 1 quando a entrada contém a chave articles. (+15 more)
### Community 80 - "test_extract_article_contents.py"
Cohesion: 0.06
@@ -386,8 +417,8 @@ Cohesion: 0.33
Nodes (6): 1. Comando e Argumentos, 2. Códigos de Saída (Exit Codes), 3. Protocolo de Streams (Stdout / Stderr), Argumentos de Linha de Comando, CLI Contract: Google News Headlines Extractor, Sintaxe
### Community 97 - "🧠 TextNLPClassifierApp"
Cohesion: 0.06
Nodes (35): 1. 🧠 Classificador de Conteúdo e Inerência (NLP / LLM / ECP), 1. Clonar o Repositório e Criar Ambiente Virtual, 1. Execução Padrão Automática, 2. Execução com Modo Verboso, 2. 📰 Extrator de Manchetes do Google News, 2. Instalar Dependências, 3. Baixar Binários do Navegador Stealth (Camoufox), 3. 📄 Extrator e Parser Multimotor de Artigos (+27 more)
Cohesion: 0.04
Nodes (45): 1. 🧠 Classificador de Conteúdo e Inerência (NLP / LLM / ECP), 1. Clonar o Repositório e Criar Ambiente Virtual, 1. Conversão Padrão, 1. Execução Padrão Automática, 2. Conversão com Caminho de Destino Personalizado, 2. Execução com Modo Verboso, 2. 📰 Extrator de Manchetes do Google News, 2. Instalar Dependências (+37 more)
### Community 98 - "Extraction Pipeline Checklist: Article Content Multi-Engine Extractor"
Cohesion: 0.05
@@ -398,12 +429,12 @@ Cohesion: 0.67
Nodes (3): fixture, Fixture que fornece o conteúdo do XML de exemplo para testes offline., sample_rss_xml()
### Community 100 - "models.py"
Cohesion: 0.24
Nodes (8): emit_error(), ClassificationError, ErrorCode, MatchedGraphEntity, Enum, str, Data models and validation schemas for Multilingual NLP Entity Inherence…, test_classification_error_serialization()
Cohesion: 0.15
Nodes (17): emit_error(), main(), parse_args(), Namespace, ClassificationError, ErrorCode, MatchedGraphEntity, Enum (+9 more)
### Community 101 - "ECPSnapshot"
Cohesion: 0.19
Nodes (11): parametrize, ECPSnapshot, Any, classifier(), fixture, Controlled 24-case benchmark suite for Multilingual NLP Entity Inherence…, test_benchmark_case(), Unit tests for ECP models, schema validation, and structured error handling. (+3 more)
Cohesion: 0.22
Nodes (10): parametrize, ECPSnapshot, Any, classifier(), fixture, Controlled 24-case benchmark suite for Multilingual NLP Entity Inherence…, test_benchmark_case(), test_ecp_snapshot_defaults() (+2 more)
### Community 102 - "Feature Specification: Multilingual NLP Entity Inherence Classifier (POC)"
Cohesion: 0.14
@@ -417,9 +448,9 @@ Nodes (20): 1.1 Objetivo do Produto, 1. Visão Geral e Contexto, 2. Personas e C
Cohesion: 0.11
Nodes (18): Dependencies & Execution Order, Entrega Incremental, Implementation Strategy, Implementação da User Story 1, Implementação da User Story 2, Implementação da User Story 3, MVP First (User Story 1 Only), Oportunidades de Execução Paralela (+10 more)
### Community 105 - "LocalEmbeddingsAdapter"
Cohesion: 0.18
Nodes (7): LocalEmbeddingsAdapter, Optional local vector embeddings adapter (Tier 2). Disabled by default.…, Optional adapter for local multilingual semantic vector embeddings., Unit tests for optional adapter interfaces (Tier 2 / Tier 3)., test_classifier_with_adapter_flags(), test_embeddings_adapter_interface(), test_llm_adapter_interface()
### Community 105 - "Tasks: Convert Article JSON to Markdown"
Cohesion: 0.11
Nodes (19): Dependencies & Execution Order, Implementation for User Story 1, Implementation for User Story 2, Implementation for User Story 3, Implementation Strategy, Incremental Delivery, MVP First (User Story 1 Only), Parallel Opportunities (+11 more)
### Community 106 - "Implementation Plan: Article Content Multi-Engine Extractor"
Cohesion: 0.17
@@ -458,12 +489,12 @@ Cohesion: 0.07
Nodes (29): 10. Requisitos não funcionais, 11. Critérios de aceite, 12. Casos obrigatórios de teste, 13. Definition of Done, 1. Contexto, 2. Objetivo, 3.1 Incluído, 3.2 Fora do escopo (+21 more)
### Community 116 - "select_article_extractor.py"
Cohesion: 0.16
Nodes (17): BatchProcessingResult, break_priority_tie(), calculate_consensus_metrics(), ExtractorCandidate, form_active_set(), main(), parse_args(), Namespace (+9 more)
Cohesion: 0.14
Nodes (19): ArticleSelectionResult, BatchProcessingResult, break_priority_tie(), calculate_consensus_metrics(), ExtractorCandidate, form_active_set(), main(), parse_args() (+11 more)
### Community 117 - "1. Text Normalization Pipeline"
Cohesion: 0.11
Nodes (18): 1. Text Normalization Pipeline, 2. 5-Token Shingles & Consensus Metrics, 3. Regras de Decisão, Empate Técnico e Desempate Hierárquico, 4. Estratégia de I/O Não Destrutiva e Escrita Atômica, Alternatives Considered, Context, Context, Context (+10 more)
### Community 117 - "Research & Architectural Decisions: Deterministic Content Selection"
Cohesion: 0.15
Nodes (13): 2. 5-Token Shingles & Consensus Metrics, 3. Regras de Decisão, Empate Técnico e Desempate Hierárquico, 4. Estratégia de I/O Não Destrutiva e Escrita Atômica, Context, Context, Context, Decisions, Decisions (+5 more)
### Community 118 - "Tasks: Deterministic Article Content Selection"
Cohesion: 0.11
@@ -471,15 +502,19 @@ Nodes (18): Dependencies & Execution Order, Implementation for User Story 1, Imp
### Community 119 - "select_article_extractor"
Cohesion: 0.08
Nodes (37): ArticleSelectionResult, CandidateStatus, extract_candidate_data(), ExtractorName, Any, Enum, str, Extrai campo de texto, erro e calcula tokens/shingles para um motor. (+29 more)
Nodes (35): CandidateStatus, extract_candidate_data(), ExtractorName, Any, Enum, str, Extrai campo de texto, erro e calcula tokens/shingles para um motor., Nomes canônicos e catálogo fechado dos motores de extração. (+27 more)
### Community 120 - "process_batch"
Cohesion: 0.10
Nodes (26): atomic_save_json(), process_batch(), Path, Salva dados em JSON de forma atômica utilizando arquivo temporário e rename., Lê o JSON de entrada, valida a estrutura, processa todos os artigos e grava o…, Path, CT-012: A entrada já contém selected_extractor -> Recalcular e substituir…, CT-013: articles está vazio -> Gerar saída válida com articles vazio. (+18 more)
Cohesion: 0.11
Nodes (24): atomic_save_json(), process_batch(), Path, Salva dados em JSON de forma atômica utilizando arquivo temporário e rename., Lê o JSON de entrada, valida a estrutura, processa todos os artigos e grava o…, Path, CT-012: A entrada já contém selected_extractor -> Recalcular e substituir…, CT-013: articles está vazio -> Gerar saída válida com articles vazio. (+16 more)
### Community 121 - "detect_language"
Cohesion: 0.19
Nodes (16): detect_language(), extract_words(), normalize_text(), Lightweight multilingual language detection and text normalization., Normalize text by converting to lowercase and stripping combining diacritical…, Tokenize text into lowercase alphanumeric words., Detect the ISO-639-1 language code of text among supported languages (pt, en,…, Unit tests for language detection and text normalization. (+8 more)
### Community 122 - "test_select_article_extractor.py"
Cohesion: 0.21
Nodes (14): generate_shingles(), normalize_text(), Executa a normalização determinística para comparação: 1. Decodificar entidades…, Gera conjunto de shingles ordenados de tamanho window_size (padrão 5). - Se…, Suíte de Testes Automatizados para o Seletor Determinístico de Extrator. Cobre…, Garante que marcação de imagem Markdown ![alt](url) seja descartada e link…, test_generate_shingles_empty(), test_generate_shingles_short_text() (+6 more)
Cohesion: 0.18
Nodes (16): generate_shingles(), normalize_text(), Executa a normalização determinística para comparação: 1. Decodificar entidades…, Gera conjunto de shingles ordenados de tamanho window_size (padrão 5). - Se…, Suíte de Testes Automatizados para o Seletor Determinístico de Extrator. Cobre…, Garante que marcação de imagem Markdown ![alt](url) seja descartada e link…, E2E: Executa scripts/select_article_extractor.py como subprocesso real na linha…, test_e2e_cli_subprocess_real_execution() (+8 more)
### Community 123 - "Feature Specification: Deterministic Content Selection"
Cohesion: 0.17
@@ -509,23 +544,123 @@ Nodes (5): Content Quality, Feature Readiness, Notes, Requirement Completeness,
Cohesion: 0.33
Nodes (5): 1. Command Syntax, 2. Arguments and Flags, 3. Standard Streams (I/O), 4. Exit Codes, CLI Interface Contract: Deterministic Article Content Selection
### Community 131 - "JSON Schema Contract: Deterministic Article Content Selection"
Cohesion: 0.50
### Community 130 - "004-deterministic-content-selection/spec.md"
Cohesion: 0.29
Nodes (3): 1. Input JSON Schema, 2. Output JSON Schema, JSON Schema Contract: Deterministic Article Content Selection
### Community 131 - "convert_article_to_markdown.py"
Cohesion: 0.23
Nodes (15): _get_dict(), normalize_date(), normalize_list(), normalize_scalar(), Any, Interpreta datas ISO 8601 e RFC 2822 preservando fuso horário ou YYYY-MM-DD…, Valida se a URL é absoluta com protocolo http ou https e hostname não vazio., Retorna o dicionário associado à chave ou um dicionário vazio caso não seja… (+7 more)
### Community 132 - "8. Regras funcionais"
Cohesion: 0.13
Nodes (15): 8. Regras funcionais, RF-001 — Receber um único artigo, RF-002 — Respeitar o extrator selecionado, RF-003 — Resolver o corpo dentro do extrator selecionado, RF-004 — Selecionar metadados deterministicamente, RF-005 — Priorizar metadados por campo, RF-006 — Normalizar valores escalares, RF-007 — Normalizar listas (+7 more)
### Community 133 - "12. Critérios de aceite"
Cohesion: 0.14
Nodes (14): 12. Critérios de aceite, CA-001 — Trafilatura selecionada, CA-002 — Newspaper4k selecionado, CA-003 — Readability selecionado, CA-004 — Fallback dentro do extrator, CA-005 — Proibição de fallback de corpo entre extratores, CA-006 — Metadado vindo de outro extrator, CA-007 — Obrigatórios presentes (+6 more)
### Community 134 - "PRD — Conversão de artigo JSON para Markdown"
Cohesion: 0.17
Nodes (11): 10. Tratamento de erros, 14. Definition of Done, 15. Dependência técnica escolhida, 1. Visão geral, 2. Problema, 3. Objetivo, 4. História do usuário, 7.1 Arquivo (+3 more)
### Community 135 - "resolve_article_body"
Cohesion: 0.17
Nodes (12): Obtém o corpo do artigo exclusivamente do selected_extractor com fallback…, resolve_article_body(), Garante que falhe se o corpo do extrator selecionado for vazio, sem usar outro…, Garante que Trafilatura use diretamente o campo markdown., Garante que Trafilatura faça fallback para text se markdown estiver vazio., Garante conversão de HTML para Markdown para Newspaper4k., Garante conversão de HTML para Markdown para Readability., test_resolve_article_body_newspaper4k_html_conversion() (+4 more)
### Community 136 - "Implementation Plan: Convert Article JSON to Markdown"
Cohesion: 0.17
Nodes (12): Complexity Tracking, Constitution Check, Documentation (this feature), Implementation Phases, Implementation Plan: Convert Article JSON to Markdown, Phase 0: Outline & Research *(Completed)*, Phase 1: Design & Contracts *(Completed)*, Phase 2: Tasks & Implementation Breakdown *(Next: `/speckit-tasks`)* (+4 more)
### Community 137 - "2. Technical Decisions & Research Findings"
Cohesion: 0.17
Nodes (11): 1. Executive Summary & Goals, 2. Technical Decisions & Research Findings, 3. Technology Stack & Dependencies, Decision 1: HTML-to-Markdown Engine Selection, Decision 2: Direct Markdown Handling for Trafilatura, Decision 3: Metadata Normalization & Priority Resolution Pipeline, Decision 4: Date Parsing Strategy (ISO 8601 & RFC 2822), Decision 5: URL Validation & Media Filtering (+3 more)
### Community 138 - "Feature Specification: Convert Article JSON to Markdown"
Cohesion: 0.17
Nodes (12): Assumptions, Edge Cases, Feature Specification: Convert Article JSON to Markdown, Functional Requirements, Key Entities, Measurable Outcomes, Requirements *(mandatory)*, Success Criteria *(mandatory)* (+4 more)
### Community 139 - "Markdown Conversion Checklist: End-to-End Requirements Quality"
Cohesion: 0.18
Nodes (11): 1. Validação de Entrada, Tipagem & Isolamento de Lotes, 2. Isolamento Estrito de Extrator & Conversão de Conteúdo (HTML/MD), 3. Resolução Determinística de Metadados & Mapeamento de SELECIONADO, 4. Normalização de Escalares, Placeholders & Sanitização de Listas, 5. Normalização de Datas, Fusos & Validação Estrita de URLs, 6. Sanitização Editorial, Tratamento de Imagens & Título Duplicado, 7. Estrutura, Sintaxe do Markdown de Saída & Restrições, 8. Interface CLI, Tratamento de Erros & Atomicidade (+3 more)
### Community 140 - "convert_article"
Cohesion: 0.22
Nodes (9): clean_body_images(), convert_article(), Path, Preserva imagens com URL absoluta http/https, remove relativas/data:/vazias e…, Executa a leitura do JSON, validação, conversão e escrita atômica do arquivo…, Garante remoção de imagens relativas/data: e deduplicação de imagens idênticas., Testa diretamente a função convert_article em Python., test_clean_body_images_and_deduplicate() (+1 more)
### Community 141 - "005-convert-json-markdown/plan.md"
Cohesion: 0.33
Nodes (3): 1. Output Document Specification, 2. Formatting & Syntax Constraints, Markdown Schema Contract: Output Article Markdown
### Community 142 - "Quickstart: Convert Article JSON to Markdown"
Cohesion: 0.22
Nodes (8): 1. Prerequisites & Setup, 2. Running the CLI Tool, 3. Verification & Testing, Basic Conversion (Default Output Path), Custom Destination Path, Quickstart: Convert Article JSON to Markdown, Run All Unit & Integration Tests, Run Linter & Type Checker
### Community 143 - "1. Domain Entities & Schemas"
Cohesion: 0.25
Nodes (8): 1. Domain Entities & Schemas, 2. Priority Resolution Matrix, Data Model: Convert Article JSON to Markdown, Entity 1: `ArticleInput` (Source JSON), Entity 2: `ExtractorBlock` (Per-Extractor Data), Entity 3: `ResolvedArticleMetadata`, Entity 4: `MarkdownDocument`, Validation Rules:
### Community 144 - "11. Requisitos não funcionais"
Cohesion: 0.29
Nodes (7): 11. Requisitos não funcionais, RNF-001 — Determinismo, RNF-002 — Compatibilidade, RNF-003 — Execução local, RNF-004 — Integridade, RNF-005 — Manutenibilidade, RNF-006 — Qualidade
### Community 145 - "parse_arguments"
Cohesion: 0.29
Nodes (7): main(), parse_arguments(), Namespace, Configura o parser de argumentos do CLI., Ponto de entrada do CLI., Testa diretamente o parsing de argumentos., test_parse_arguments_direct()
### Community 146 - "Specification Quality Checklist: Convert Article JSON to Markdown"
Cohesion: 0.33
Nodes (5): Content Quality, Feature Readiness, Notes, Requirement Completeness, Specification Quality Checklist: Convert Article JSON to Markdown
### Community 147 - "CLI Contract: `convert_article_to_markdown.py`"
Cohesion: 0.33
Nodes (5): 1. Script Signature, 2. Command-Line Arguments, 3. Exit Codes, 4. Standard Stream Behavior, CLI Contract: `convert_article_to_markdown.py`
### Community 148 - "9. Interface CLI"
Cohesion: 0.40
Nodes (5): 9.1 Script, 9.2 Argumentos, 9.3 Exemplos, 9.4 Saída do processo, 9. Interface CLI
### Community 149 - "1. Text Normalization Pipeline"
Cohesion: 0.40
Nodes (5): 1. Text Normalization Pipeline, Alternatives Considered, Context, Decisions, Rationale
### Community 150 - "13. Estratégia de testes"
Cohesion: 0.50
Nodes (4): 13.1 Testes unitários, 13.2 Testes de integração do CLI, 13.3 Casos de resultado esperado, 13. Estratégia de testes
### Community 151 - "6. Contrato de entrada"
Cohesion: 0.50
Nodes (4): 6.1 Formato, 6.2 Valores aceitos para `selected_extractor`, 6.3 Campos obrigatórios após a resolução, 6. Contrato de entrada
### Community 152 - "assemble_markdown_document"
Cohesion: 0.50
Nodes (4): assemble_markdown_document(), Monta a estrutura final do documento Markdown respeitando a ordem estrita do…, Testa a montagem completa da estrutura de seções do documento Markdown., test_assemble_markdown_document()
### Community 153 - "convert_html_to_markdown"
Cohesion: 0.50
Nodes (4): convert_html_to_markdown(), Converte HTML para Markdown usando títulos ATX., Testa diretamente a função convert_html_to_markdown., test_convert_html_to_markdown_basic()
### Community 154 - "remove_duplicate_initial_h1"
Cohesion: 0.50
Nodes (4): Remove o primeiro título H1 do corpo somente quando ele for igual ao título…, remove_duplicate_initial_h1(), Garante remoção apenas do H1 inicial que for idêntico ao título., test_remove_duplicate_initial_h1()
### Community 155 - "5. Escopo"
Cohesion: 0.67
Nodes (3): 5.1 Incluído, 5.2 Fora do escopo, 5. Escopo
## Knowledge Gaps
- **562 isolated node(s):** `text-nlp-classifier`, `MatchedGraphEntity`, `graphify`, `Usage`, `What graphify is for` (+557 more)
- **696 isolated node(s):** `text-nlp-classifier`, `MatchedGraphEntity`, `graphify`, `Usage`, `What graphify is for` (+691 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **38 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **43 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `ECPSnapshot` connect `ECPSnapshot` to `models.py`, `LocalEmbeddingsAdapter`, `ClassificationResult`, `InherenceClassifier`, `classifier.py`, `main`?**
_High betweenness centrality (0.003) - this node is a cross-community bridge._
- **Why does `LLMFallbackAdapter` connect `ClassificationResult` to `LocalEmbeddingsAdapter`, `ECPSnapshot`, `InherenceClassifier`, `classifier.py`?**
_High betweenness centrality (0.003) - this node is a cross-community bridge._
- **Why does `Research & Architectural Decisions: Deterministic Content Selection` connect `1. Text Normalization Pipeline` to `004-deterministic-content-selection/spec.md`?**
- **Why does `PRD — Conversão de artigo JSON para Markdown` connect `PRD — Conversão de artigo JSON para Markdown` to `8. Regras funcionais`, `12. Critérios de aceite`, `11. Requisitos não funcionais`, `9. Interface CLI`, `13. Estratégia de testes`, `6. Contrato de entrada`, `5. Escopo`?**
_High betweenness centrality (0.007) - this node is a cross-community bridge._
- **Why does `Tasks: Convert Article JSON to Markdown` connect `Tasks: Convert Article JSON to Markdown` to `005-convert-json-markdown/plan.md`?**
_High betweenness centrality (0.004) - this node is a cross-community bridge._
- **Why does `Research & Architectural Decisions: Deterministic Content Selection` connect `Research & Architectural Decisions: Deterministic Content Selection` to `004-deterministic-content-selection/spec.md`, `1. Text Normalization Pipeline`?**
_High betweenness centrality (0.003) - this node is a cross-community bridge._
- **Are the 10 inferred relationships involving `ECPSnapshot` (e.g. with `main()` and `BaseNLPAdapter`) actually correct?**
_`ECPSnapshot` has 10 INFERRED edges - model-reasoned connections that need verification._
File diff suppressed because it is too large Load Diff
+111 -15
View File
@@ -420,9 +420,9 @@
"semantic_hash": ""
},
"requirements.txt": {
"mtime": 1787240110.13472,
"seen": 1787240516.5993943,
"ast_hash": "f5d99630fa9c93c5fbaa44814f107e70",
"mtime": 1787317345.9253972,
"seen": 1787317712.8213654,
"ast_hash": "f3f8ea2b8cc995de218d81679ec35231",
"semantic_hash": ""
},
"tests/fixtures/benchmark_24/de/contextual.md": {
@@ -654,9 +654,9 @@
"semantic_hash": ""
},
"README.md": {
"mtime": 1787274445.4631622,
"seen": 1787274455.3244681,
"ast_hash": "5270e11cfba31a235ba379effb2db0d5",
"mtime": 1787317667.2516665,
"seen": 1787317712.8207479,
"ast_hash": "73b6e2e7ff33db1b991b8470f29ee40f",
"semantic_hash": ""
},
"scripts/extract_article_contents.py": {
@@ -738,15 +738,15 @@
"semantic_hash": ""
},
"scripts/select_article_extractor.py": {
"mtime": 1787273522.903319,
"seen": 1787273548.728257,
"ast_hash": "3e6938964c7e4549051cb1637207698d",
"mtime": 1787274502.4801269,
"seen": 1787310813.8455508,
"ast_hash": "4037eda779d5fc81527d0698a8fc07d6",
"semantic_hash": ""
},
"tests/test_select_article_extractor.py": {
"mtime": 1787274189.7777488,
"seen": 1787274207.7989414,
"ast_hash": "9f91e09d47f06140b243b6f516d14c60",
"mtime": 1787274502.4801269,
"seen": 1787310813.8470025,
"ast_hash": "2cd19eb0c5204a8c22b7727e259db885",
"semantic_hash": ""
},
"docs/prd_deterministic_content_selection.md": {
@@ -792,9 +792,9 @@
"semantic_hash": ""
},
"specs/004-deterministic-content-selection/quickstart.md": {
"mtime": 1787274324.1467915,
"seen": 1787274402.0946271,
"ast_hash": "a7886ca618af43f33971213e2255b3c6",
"mtime": 1787274502.478128,
"seen": 1787310813.854652,
"ast_hash": "60453533ee75a4f0a894dca634e0c96c",
"semantic_hash": ""
},
"specs/004-deterministic-content-selection/research.md": {
@@ -814,5 +814,101 @@
"seen": 1787272611.6714194,
"ast_hash": "3a2617478f44c6400ed57088d2039f93",
"semantic_hash": ""
},
"scripts/convert_article_to_markdown.py": {
"mtime": 1787317631.1342402,
"seen": 1787317712.814934,
"ast_hash": "f2550126b7ff9de09bbbe0ae03acbbca",
"semantic_hash": ""
},
"tests/test_convert_article_to_markdown.py": {
"mtime": 1787317613.930905,
"seen": 1787317712.8166668,
"ast_hash": "fc7d1b6d08037a85cf014fa4e8c70a70",
"semantic_hash": ""
},
"docs/prd_convert_json_markdown.md": {
"mtime": 1787315400.452784,
"seen": 1787317712.8208659,
"ast_hash": "1fc73d15d4f4d5c1e74f299040b040f2",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/checklists/markdown-conversion.md": {
"mtime": 1787315812.9198053,
"seen": 1787317712.8255877,
"ast_hash": "4cc2161c66aa34dc8540c48322b6eaaa",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/checklists/requirements.md": {
"mtime": 1787315513.450848,
"seen": 1787317712.8255897,
"ast_hash": "a14c648defdfb7c2ad77601249d44a66",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/contracts/cli-contract.md": {
"mtime": 1787315656.382816,
"seen": 1787317712.8255913,
"ast_hash": "bba24a6fcd59b265d11242b4b09d2537",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/contracts/markdown-schema.md": {
"mtime": 1787315662.3060155,
"seen": 1787317712.8255925,
"ast_hash": "4dd6023205dcc49447bf3c78f0419c52",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/data-model.md": {
"mtime": 1787315648.195198,
"seen": 1787317712.8255942,
"ast_hash": "31de71b9fa43fb1b257f76ae2de33044",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/plan.md": {
"mtime": 1787315676.826685,
"seen": 1787317712.8255954,
"ast_hash": "44e3074130dadf889bf3830e6139b91a",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/quickstart.md": {
"mtime": 1787315668.269243,
"seen": 1787317712.8255966,
"ast_hash": "6086387edc574ed31202ff05eaa3fb54",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/research.md": {
"mtime": 1787315639.7204154,
"seen": 1787317712.825598,
"ast_hash": "3ffedbbf7e61245b86119f41a5f8af2d",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/spec.md": {
"mtime": 1787315504.210701,
"seen": 1787317712.825599,
"ast_hash": "9f78ac1c71853dcf4e364c66973dfcbc",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/tasks.md": {
"mtime": 1787317701.507181,
"seen": 1787317712.8256,
"ast_hash": "1287da802887af5e0dd3a6a2685f5fc0",
"semantic_hash": ""
},
"tests/fixtures/markdown_conversion/valid_newspaper4k.md": {
"mtime": 1787317400.9745035,
"seen": 1787317712.8280056,
"ast_hash": "3bfb28bbde97c16635de39bbbe6fbd8b",
"semantic_hash": ""
},
"tests/fixtures/markdown_conversion/valid_readability.md": {
"mtime": 1787317419.4019263,
"seen": 1787317712.8280075,
"ast_hash": "49fdc6bedd7585eda33e2c502337e01a",
"semantic_hash": ""
},
"tests/fixtures/markdown_conversion/valid_trafilatura.md": {
"mtime": 1787317383.5058665,
"seen": 1787317712.8280091,
"ast_hash": "c63c1c39e34e08a239aa8bea3c264756",
"semantic_hash": ""
}
}
+184 -50
View File
@@ -1,16 +1,16 @@
# Graph Report - TextNLPClassifierApp (2026-08-21)
## Corpus Check
- 174 files · ~91,990 words
- 199 files · ~108,365 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1235 nodes · 1541 edges · 131 communities (93 shown, 38 thin omitted)
- 1527 nodes · 1899 edges · 165 communities (118 shown, 47 thin omitted)
- Extraction: 97% EXTRACTED · 3% INFERRED · 0% AMBIGUOUS · INFERRED: 51 edges (avg confidence: 0.95)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `ff7a50e0`
- Built from commit: `64dfd842`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -59,7 +59,7 @@
- ClassificationResult
- InherenceClassifier
- classifier.py
- main
- test_convert_article_to_markdown.py
- content_northvolt_de.md
- content_presal_pt.md
- content_tangential_es.md
@@ -109,13 +109,13 @@
- CLI Contract: Google News Headlines Extractor
- 🧠 TextNLPClassifierApp
- Extraction Pipeline Checklist: Article Content Multi-Engine Extractor
- sample_rss_xml
- parametrize
- models.py
- ECPSnapshot
- Feature Specification: Multilingual NLP Entity Inherence Classifier (POC)
- 4. Requisitos Funcionais (FR)
- Tasks: Article Content Multi-Engine Extractor
- LocalEmbeddingsAdapter
- Tasks: Convert Article JSON to Markdown
- Implementation Plan: Article Content Multi-Engine Extractor
- 2. Cenários de Validação
- 1. Technical Decisions & Tradeoffs
@@ -130,6 +130,7 @@
- Tasks: Deterministic Article Content Selection
- select_article_extractor
- process_batch
- detect_language
- test_select_article_extractor.py
- Feature Specification: Deterministic Content Selection
- 2. Entity Descriptions & Fields
@@ -138,7 +139,40 @@
- Quickstart: Deterministic Article Content Selection
- Specification Quality Checklist: Deterministic Content Selection
- CLI Interface Contract: Deterministic Article Content Selection
- convert_article_to_markdown.py
- 8. Regras funcionais
- 12. Critérios de aceite
- PRD — Conversão de artigo JSON para Markdown
- resolve_article_body
- Implementation Plan: Convert Article JSON to Markdown
- 2. Technical Decisions & Research Findings
- Feature Specification: Convert Article JSON to Markdown
- Markdown Conversion Checklist: End-to-End Requirements Quality
- convert_article
- 005-convert-json-markdown/plan.md
- Quickstart: Convert Article JSON to Markdown
- 1. Domain Entities & Schemas
- 11. Requisitos não funcionais
- parse_arguments
- Specification Quality Checklist: Convert Article JSON to Markdown
- CLI Contract: `convert_article_to_markdown.py`
- 9. Interface CLI
- get_hl_gl_ceid
- 13. Estratégia de testes
- 6. Contrato de entrada
- assemble_markdown_document
- convert_html_to_markdown
- JSON Schema Contract: Deterministic Article Content Selection
- 5. Escopo
- Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports
- valid_newspaper4k.md
- valid_readability.md
- test_normalize_date_rfc_2822_variants
- test_normalize_date_invalid_and_placeholders
- test_metadata_priority_title_all_fallbacks
- test_metadata_priority_subtitle_omitted_when_equal_to_title
- test_metadata_priority_first_valid_source_no_cross_merging
- test_normalize_scalar_non_string_types
## God Nodes (most connected - your core abstractions)
1. `ECPSnapshot` - 31 edges
@@ -147,27 +181,27 @@
4. `ExtractorName` - 21 edges
5. `DecisionCategory` - 17 edges
6. `ClassificationResult` - 17 edges
7. `process_batch()` - 15 edges
8. `process_batch()` - 14 edges
9. `LocalEmbeddingsAdapter` - 14 edges
10. `LLMFallbackAdapter` - 14 edges
7. `PRD — Conversão de artigo JSON para Markdown` - 16 edges
8. `process_batch()` - 15 edges
9. `8. Regras funcionais` - 15 edges
10. `resolve_article_metadata()` - 14 edges
## Surprising Connections (you probably didn't know these)
- `main()` --uses--> `ECPSnapshot` [INFERRED]
classify.py → src/models.py
- `main()` --uses--> `ErrorCode` [INFERRED]
classify.py → src/models.py
- `test_extract_google_news_orchestration_mocked()` --uses--> `ExtractionResult` [INFERRED]
- `test_e2e_extract_google_news_live_pipeline()` --uses--> `ExtractionResult` [INFERRED]
tests/test_extract_google_news.py → scripts/extract_google_news.py
- `test_llm_adapter_interface()` --calls--> `LLMFallbackAdapter` [EXTRACTED]
tests/test_adapters.py → src/adapters/llm.py
- `classifier()` --uses--> `InherenceClassifier` [INFERRED]
tests/test_benchmark_24.py → src/classifier.py
- `test_classification_result_serialization()` --uses--> `DecisionCategory` [INFERRED]
tests/test_models.py → src/models.py
- `petrobras_ecp()` --uses--> `ECPSnapshot` [INFERRED]
tests/test_classifier.py → src/models.py
## Import Cycles
- None detected.
## Communities (131 total, 38 thin omitted)
## Communities (165 total, 47 thin omitted)
### Community 0 - "Task Planning"
Cohesion: 0.07
@@ -310,20 +344,20 @@ Cohesion: 0.29
Nodes (6): 1.1 Arguments & Options, 1. Command Line Interface, 2.1 Exit Codes, 2.2 Standard Output (`stdout`) / Standard Error (`stderr`), 2. Standard Streams & Exit Codes, CLI Contract & Interface Specification (POC)
### Community 45 - "ClassificationResult"
Cohesion: 0.14
Nodes (12): ABC, BaseNLPAdapter, Base abstract adapter interface for optional Tier 2 / Tier 3 NLP enhancers., Abstract interface for pluggable NLP classification adapters., Return True if the underlying provider or model is installed and configured., Compute semantic similarity score between text and a set of candidate terms., Optionally refine an ambiguous classification result., LLMFallbackAdapter (+4 more)
Cohesion: 0.09
Nodes (19): ABC, BaseNLPAdapter, Base abstract adapter interface for optional Tier 2 / Tier 3 NLP enhancers., Abstract interface for pluggable NLP classification adapters., Return True if the underlying provider or model is installed and configured., Compute semantic similarity score between text and a set of candidate terms., Optionally refine an ambiguous classification result., LocalEmbeddingsAdapter (+11 more)
### Community 46 - "InherenceClassifier"
Cohesion: 0.12
Nodes (27): InherenceClassifier, Tier 1 Deterministic NLP Entity Inherence Classifier., DecisionCategory, RelatedEntity, Adversarial and robustness test suite for Multilingual NLP Entity Inherence…, Run CLI via subprocess without --output and verify stdout is pure parseable…, Run CLI via subprocess with empty content and verify error code and exit code., Content about city/state governance of São Paulo against ECP for São Paulo FC. (+19 more)
### Community 47 - "classifier.py"
Cohesion: 0.10
Nodes (31): count_phrase_occurrences(), match_phrase_in_text(), Core deterministic classification engine (Tier 1 core)., Check if a normalized phrase appears in normalized text with word boundary…, Count occurrences of a phrase in text., Classify inherence of content against an ECP snapshot., detect_language(), extract_words() (+23 more)
Cohesion: 0.16
Nodes (16): count_phrase_occurrences(), match_phrase_in_text(), Core deterministic classification engine (Tier 1 core)., Check if a normalized phrase appears in normalized text with word boundary…, Count occurrences of a phrase in text., Classify inherence of content against an ECP snapshot., extract_evidence_snippets(), extract_sentences() (+8 more)
### Community 48 - "main"
Cohesion: 0.31
Nodes (9): main(), parse_args(), Namespace, CLI execution tests covering flags, arguments, stdout, and error handling., test_cli_empty_content_file(), test_cli_missing_ecp_file(), test_cli_missing_required_ecp_field(), test_cli_output_file() (+1 more)
### Community 48 - "test_convert_article_to_markdown.py"
Cohesion: 0.07
Nodes (27): Suíte de Testes Automatizados para Conversão de Artigo JSON para Markdown.…, Testa deduplicação case-insensitive preservando a grafia e ordem da primeira…, Valida parsing de datas ISO 8601 em múltiplos formatos e fusos., Valida a cadeia de fallback completa para a URL ORIGINAL (5 níveis)., Valida decodificação de entidades HTML nomeadas e numéricas., Valida colapso de tabs, quebras de linha e espaços múltiplos em um único espaço., Garante correspondência exata byte a byte para Trafilatura, Newspaper4k e…, Garante que múltiplas execuções no mesmo arquivo produzam hashes SHA-256… (+19 more)
### Community 80 - "test_extract_article_contents.py"
Cohesion: 0.06
@@ -334,16 +368,16 @@ Cohesion: 0.08
Nodes (24): 1. Visão geral (arquitetura), 2.1 DTO de entrada (`googlenews_etl/application/dtos/extract_news_dto.py`), 2.2 Value Object de validação (`googlenews_etl/domain/entities/search_query.py`), 2. Entrada, 3.1 O caso de uso (`googlenews_etl/application/use_cases/extract_news_use_case.py`), 3.2 A porta (`googlenews_etl/domain/ports/news_extractor_port.py`), 3.3.1 Inicialização: sessão HTTP com impersonação de browser, 3.3.2 Mapeamento idioma → parâmetros `hl`/`gl` (`_get_hl_gl`) (+16 more)
### Community 82 - "extract_google_news.py"
Cohesion: 0.15
Nodes (18): extract_google_news(), _fetch_rss_content(), get_hl_gl_ceid(), NewsArticle, _normalize_text_for_comparison(), parse_google_news_rss(), Mapeia idioma e locale para os parâmetros hl, gl e ceid do Google News., Remove pontuação e espaços extras para comparação de redundância. (+10 more)
Cohesion: 0.20
Nodes (14): extract_google_news(), _fetch_rss_content(), NewsArticle, _normalize_text_for_comparison(), parse_google_news_rss(), Remove pontuação e espaços extras para comparação de redundância., Parseia o XML do RSS do Google News e extrai os itens estruturados., Resolve em paralelo as URLs intermediárias do Google News para os links finais… (+6 more)
### Community 83 - "ExtractionResult"
Cohesion: 0.29
Nodes (5): ExtractionResult, Any, Resultado consolidado da extração., Valida E2E o fluxo completo de busca, parsing e resolução de URLs reais ao vivo., test_e2e_extract_google_news_live_pipeline()
Nodes (5): ExtractionResult, Any, Resultado consolidado da extração., Valida a consolidação do ExtractionResult a partir da busca mockada com URLs…, test_extract_google_news_orchestration_mocked()
### Community 84 - "test_extract_google_news.py"
Cohesion: 0.15
Nodes (15): Resolve a URL intermediária do Google News para a URL real do veículo., resolve_article_url(), Testes unitários e de integração para o Extrator de Manchetes do Google News.…, Valida fallback gracioso de URL quando não é link do Google News ou em erro., Valida resolução bem-sucedida de URL do Google News para o portal destino., Valida E2E que o decodificador resolve uma URL real do Google News para o…, Valida o mapeamento padrão de idiomas para pares (hl, gl, ceid)., Valida a sobrescrita geográfica quando o argumento locale é especificado. (+7 more)
Cohesion: 0.16
Nodes (14): Resolve a URL intermediária do Google News para a URL real do veículo., resolve_article_url(), fixture, Testes unitários e de integração para o Extrator de Manchetes do Google News.…, Valida o parsing do feed RSS, higienização de tags HTML e deduplicação., Valida fallback gracioso de URL quando não é link do Google News ou em erro., Valida resolução bem-sucedida de URL do Google News para o portal destino., Valida E2E que o decodificador resolve uma URL real do Google News para o… (+6 more)
### Community 85 - "Implementation Tasks: Google News Headlines Extractor"
Cohesion: 0.14
@@ -363,7 +397,7 @@ Nodes (7): Architecture & Pipeline, Documentation (this feature), Implementation
### Community 90 - "SearchQuery"
Cohesion: 0.20
Nodes (6): Value Object com parâmetros de busca validados., SearchQuery, Valida a consolidação do ExtractionResult a partir da busca mockada com URLs…, Valida as regras de negócio e limites de SearchQuery., test_extract_google_news_orchestration_mocked(), test_search_query_validation()
Nodes (6): Value Object com parâmetros de busca validados., SearchQuery, Valida E2E o fluxo completo de busca, parsing e resolução de URLs reais ao vivo., Valida as regras de negócio e limites de SearchQuery., test_e2e_extract_google_news_live_pipeline(), test_search_query_validation()
### Community 91 - "1. Technical Decisions & Tradeoffs"
Cohesion: 0.25
@@ -386,24 +420,24 @@ Cohesion: 0.33
Nodes (6): 1. Comando e Argumentos, 2. Códigos de Saída (Exit Codes), 3. Protocolo de Streams (Stdout / Stderr), Argumentos de Linha de Comando, CLI Contract: Google News Headlines Extractor, Sintaxe
### Community 97 - "🧠 TextNLPClassifierApp"
Cohesion: 0.06
Nodes (35): 1. 🧠 Classificador de Conteúdo e Inerência (NLP / LLM / ECP), 1. Clonar o Repositório e Criar Ambiente Virtual, 1. Execução Padrão Automática, 2. Execução com Modo Verboso, 2. 📰 Extrator de Manchetes do Google News, 2. Instalar Dependências, 3. Baixar Binários do Navegador Stealth (Camoufox), 3. 📄 Extrator e Parser Multimotor de Artigos (+27 more)
Cohesion: 0.04
Nodes (45): 1. 🧠 Classificador de Conteúdo e Inerência (NLP / LLM / ECP), 1. Clonar o Repositório e Criar Ambiente Virtual, 1. Conversão Padrão, 1. Execução Padrão Automática, 2. Conversão com Caminho de Destino Personalizado, 2. Execução com Modo Verboso, 2. 📰 Extrator de Manchetes do Google News, 2. Instalar Dependências (+37 more)
### Community 98 - "Extraction Pipeline Checklist: Article Content Multi-Engine Extractor"
Cohesion: 0.05
Nodes (34): 1. Requirement Completeness, 2. Requirement Clarity & Non-Ambiguity, 3. Requirement Consistency & Data Contracts, 4. Scenario & Edge Case Coverage, 5. Non-Functional & Operational Readiness, Extraction Pipeline Checklist: Article Content Multi-Engine Extractor, Notes, Content Quality (+26 more)
### Community 99 - "sample_rss_xml"
Cohesion: 0.67
Nodes (3): fixture, Fixture que fornece o conteúdo do XML de exemplo para testes offline., sample_rss_xml()
### Community 99 - "parametrize"
Cohesion: 0.22
Nodes (9): parametrize, Garante aceitação de URLs absolutas com esquema HTTP e HTTPS válidos., Garante rejeição de esquemas não permitidos, URLs relativas e strings vazias., Garante que a ausência de corpo no extrator selecionado NUNCA faça fallback…, Garante que todos os placeholders documentados no PRD sejam descartados…, test_normalize_scalar_placeholders_discarded(), test_resolve_article_body_strict_isolation_all_extractors(), test_validate_url_invalid_schemes() (+1 more)
### Community 100 - "models.py"
Cohesion: 0.24
Nodes (8): emit_error(), ClassificationError, ErrorCode, MatchedGraphEntity, Enum, str, Data models and validation schemas for Multilingual NLP Entity Inherence…, test_classification_error_serialization()
Cohesion: 0.15
Nodes (17): emit_error(), main(), parse_args(), Namespace, ClassificationError, ErrorCode, MatchedGraphEntity, Enum (+9 more)
### Community 101 - "ECPSnapshot"
Cohesion: 0.19
Nodes (11): parametrize, ECPSnapshot, Any, classifier(), fixture, Controlled 24-case benchmark suite for Multilingual NLP Entity Inherence…, test_benchmark_case(), Unit tests for ECP models, schema validation, and structured error handling. (+3 more)
Cohesion: 0.20
Nodes (10): ECPSnapshot, Any, classifier(), fixture, parametrize, Controlled 24-case benchmark suite for Multilingual NLP Entity Inherence…, test_benchmark_case(), test_ecp_snapshot_defaults() (+2 more)
### Community 102 - "Feature Specification: Multilingual NLP Entity Inherence Classifier (POC)"
Cohesion: 0.14
@@ -417,9 +451,9 @@ Nodes (20): 1.1 Objetivo do Produto, 1. Visão Geral e Contexto, 2. Personas e C
Cohesion: 0.11
Nodes (18): Dependencies & Execution Order, Entrega Incremental, Implementation Strategy, Implementação da User Story 1, Implementação da User Story 2, Implementação da User Story 3, MVP First (User Story 1 Only), Oportunidades de Execução Paralela (+10 more)
### Community 105 - "LocalEmbeddingsAdapter"
Cohesion: 0.18
Nodes (7): LocalEmbeddingsAdapter, Optional local vector embeddings adapter (Tier 2). Disabled by default.…, Optional adapter for local multilingual semantic vector embeddings., Unit tests for optional adapter interfaces (Tier 2 / Tier 3)., test_classifier_with_adapter_flags(), test_embeddings_adapter_interface(), test_llm_adapter_interface()
### Community 105 - "Tasks: Convert Article JSON to Markdown"
Cohesion: 0.11
Nodes (19): Dependencies & Execution Order, Implementation for User Story 1, Implementation for User Story 2, Implementation for User Story 3, Implementation Strategy, Incremental Delivery, MVP First (User Story 1 Only), Parallel Opportunities (+11 more)
### Community 106 - "Implementation Plan: Article Content Multi-Engine Extractor"
Cohesion: 0.17
@@ -477,6 +511,10 @@ Nodes (35): CandidateStatus, extract_candidate_data(), ExtractorName, Any, Enum,
Cohesion: 0.11
Nodes (24): atomic_save_json(), process_batch(), Path, Salva dados em JSON de forma atômica utilizando arquivo temporário e rename., Lê o JSON de entrada, valida a estrutura, processa todos os artigos e grava o…, Path, CT-012: A entrada já contém selected_extractor -> Recalcular e substituir…, CT-013: articles está vazio -> Gerar saída válida com articles vazio. (+16 more)
### Community 121 - "detect_language"
Cohesion: 0.19
Nodes (16): detect_language(), extract_words(), normalize_text(), Lightweight multilingual language detection and text normalization., Normalize text by converting to lowercase and stripping combining diacritical…, Tokenize text into lowercase alphanumeric words., Detect the ISO-639-1 language code of text among supported languages (pt, en,…, Unit tests for language detection and text normalization. (+8 more)
### Community 122 - "test_select_article_extractor.py"
Cohesion: 0.18
Nodes (16): generate_shingles(), normalize_text(), Executa a normalização determinística para comparação: 1. Decodificar entidades…, Gera conjunto de shingles ordenados de tamanho window_size (padrão 5). - Se…, Suíte de Testes Automatizados para o Seletor Determinístico de Extrator. Cobre…, Garante que marcação de imagem Markdown ![alt](url) seja descartada e link…, E2E: Executa scripts/select_article_extractor.py como subprocesso real na linha…, test_e2e_cli_subprocess_real_execution() (+8 more)
@@ -509,24 +547,116 @@ Nodes (5): Content Quality, Feature Readiness, Notes, Requirement Completeness,
Cohesion: 0.33
Nodes (5): 1. Command Syntax, 2. Arguments and Flags, 3. Standard Streams (I/O), 4. Exit Codes, CLI Interface Contract: Deterministic Article Content Selection
### Community 131 - "JSON Schema Contract: Deterministic Article Content Selection"
### Community 131 - "convert_article_to_markdown.py"
Cohesion: 0.19
Nodes (17): _get_dict(), normalize_date(), normalize_list(), normalize_scalar(), Any, Interpreta datas ISO 8601 e RFC 2822 preservando fuso horário ou YYYY-MM-DD…, Valida se a URL é absoluta com protocolo http ou https e hostname não vazio., Retorna o dicionário associado à chave ou um dicionário vazio caso não seja… (+9 more)
### Community 132 - "8. Regras funcionais"
Cohesion: 0.13
Nodes (15): 8. Regras funcionais, RF-001 — Receber um único artigo, RF-002 — Respeitar o extrator selecionado, RF-003 — Resolver o corpo dentro do extrator selecionado, RF-004 — Selecionar metadados deterministicamente, RF-005 — Priorizar metadados por campo, RF-006 — Normalizar valores escalares, RF-007 — Normalizar listas (+7 more)
### Community 133 - "12. Critérios de aceite"
Cohesion: 0.14
Nodes (14): 12. Critérios de aceite, CA-001 — Trafilatura selecionada, CA-002 — Newspaper4k selecionado, CA-003 — Readability selecionado, CA-004 — Fallback dentro do extrator, CA-005 — Proibição de fallback de corpo entre extratores, CA-006 — Metadado vindo de outro extrator, CA-007 — Obrigatórios presentes (+6 more)
### Community 134 - "PRD — Conversão de artigo JSON para Markdown"
Cohesion: 0.17
Nodes (11): 10. Tratamento de erros, 14. Definition of Done, 15. Dependência técnica escolhida, 1. Visão geral, 2. Problema, 3. Objetivo, 4. História do usuário, 7.1 Arquivo (+3 more)
### Community 135 - "resolve_article_body"
Cohesion: 0.20
Nodes (10): Obtém o corpo do artigo exclusivamente do selected_extractor com fallback…, resolve_article_body(), Testa prioridade trafilatura.markdown sobre trafilatura.text., Testa prioridade newspaper4k.article_html sobre newspaper4k.text., Testa prioridade readability.cleaned_html sobre readability.cleaned_text., Garante erro ao receber selected_extractor ausente ou não reconhecido., test_resolve_article_body_invalid_selected_extractor(), test_resolve_article_body_newspaper4k_primary_and_fallback() (+2 more)
### Community 136 - "Implementation Plan: Convert Article JSON to Markdown"
Cohesion: 0.17
Nodes (12): Complexity Tracking, Constitution Check, Documentation (this feature), Implementation Phases, Implementation Plan: Convert Article JSON to Markdown, Phase 0: Outline & Research *(Completed)*, Phase 1: Design & Contracts *(Completed)*, Phase 2: Tasks & Implementation Breakdown *(Next: `/speckit-tasks`)* (+4 more)
### Community 137 - "2. Technical Decisions & Research Findings"
Cohesion: 0.17
Nodes (11): 1. Executive Summary & Goals, 2. Technical Decisions & Research Findings, 3. Technology Stack & Dependencies, Decision 1: HTML-to-Markdown Engine Selection, Decision 2: Direct Markdown Handling for Trafilatura, Decision 3: Metadata Normalization & Priority Resolution Pipeline, Decision 4: Date Parsing Strategy (ISO 8601 & RFC 2822), Decision 5: URL Validation & Media Filtering (+3 more)
### Community 138 - "Feature Specification: Convert Article JSON to Markdown"
Cohesion: 0.17
Nodes (12): Assumptions, Edge Cases, Feature Specification: Convert Article JSON to Markdown, Functional Requirements, Key Entities, Measurable Outcomes, Requirements *(mandatory)*, Success Criteria *(mandatory)* (+4 more)
### Community 139 - "Markdown Conversion Checklist: End-to-End Requirements Quality"
Cohesion: 0.18
Nodes (11): 1. Validação de Entrada, Tipagem & Isolamento de Lotes, 2. Isolamento Estrito de Extrator & Conversão de Conteúdo (HTML/MD), 3. Resolução Determinística de Metadados & Mapeamento de SELECIONADO, 4. Normalização de Escalares, Placeholders & Sanitização de Listas, 5. Normalização de Datas, Fusos & Validação Estrita de URLs, 6. Sanitização Editorial, Tratamento de Imagens & Título Duplicado, 7. Estrutura, Sintaxe do Markdown de Saída & Restrições, 8. Interface CLI, Tratamento de Erros & Atomicidade (+3 more)
### Community 140 - "convert_article"
Cohesion: 0.15
Nodes (13): clean_body_images(), convert_article(), Path, Remove o primeiro título H1 do corpo somente quando ele for igual ao título…, Preserva imagens com URL absoluta http/https, remove relativas/data:/vazias e…, Executa a leitura do JSON, validação, conversão e escrita atômica do arquivo…, remove_duplicate_initial_h1(), Testa remoção de H1 inicial coincidente com título com variações de espaços e… (+5 more)
### Community 141 - "005-convert-json-markdown/plan.md"
Cohesion: 0.33
Nodes (3): 1. Output Document Specification, 2. Formatting & Syntax Constraints, Markdown Schema Contract: Output Article Markdown
### Community 142 - "Quickstart: Convert Article JSON to Markdown"
Cohesion: 0.22
Nodes (8): 1. Prerequisites & Setup, 2. Running the CLI Tool, 3. Verification & Testing, Basic Conversion (Default Output Path), Custom Destination Path, Quickstart: Convert Article JSON to Markdown, Run All Unit & Integration Tests, Run Linter & Type Checker
### Community 143 - "1. Domain Entities & Schemas"
Cohesion: 0.25
Nodes (8): 1. Domain Entities & Schemas, 2. Priority Resolution Matrix, Data Model: Convert Article JSON to Markdown, Entity 1: `ArticleInput` (Source JSON), Entity 2: `ExtractorBlock` (Per-Extractor Data), Entity 3: `ResolvedArticleMetadata`, Entity 4: `MarkdownDocument`, Validation Rules:
### Community 144 - "11. Requisitos não funcionais"
Cohesion: 0.29
Nodes (7): 11. Requisitos não funcionais, RNF-001 — Determinismo, RNF-002 — Compatibilidade, RNF-003 — Execução local, RNF-004 — Integridade, RNF-005 — Manutenibilidade, RNF-006 — Qualidade
### Community 145 - "parse_arguments"
Cohesion: 0.29
Nodes (7): main(), parse_arguments(), Namespace, Configura o parser de argumentos do CLI., Ponto de entrada do CLI., Testa a chamada direta do parse_arguments., test_parse_arguments_api_direct()
### Community 146 - "Specification Quality Checklist: Convert Article JSON to Markdown"
Cohesion: 0.33
Nodes (5): Content Quality, Feature Readiness, Notes, Requirement Completeness, Specification Quality Checklist: Convert Article JSON to Markdown
### Community 147 - "CLI Contract: `convert_article_to_markdown.py`"
Cohesion: 0.33
Nodes (5): 1. Script Signature, 2. Command-Line Arguments, 3. Exit Codes, 4. Standard Stream Behavior, CLI Contract: `convert_article_to_markdown.py`
### Community 148 - "9. Interface CLI"
Cohesion: 0.40
Nodes (5): 9.1 Script, 9.2 Argumentos, 9.3 Exemplos, 9.4 Saída do processo, 9. Interface CLI
### Community 149 - "get_hl_gl_ceid"
Cohesion: 0.25
Nodes (8): get_hl_gl_ceid(), Mapeia idioma e locale para os parâmetros hl, gl e ceid do Google News., Valida o mapeamento padrão de idiomas para pares (hl, gl, ceid)., Valida a sobrescrita geográfica quando o argumento locale é especificado., Valida fallback dinâmico para idiomas regionais não listados explicitamente., test_get_hl_gl_ceid_default_mappings(), test_get_hl_gl_ceid_dynamic_fallback(), test_get_hl_gl_ceid_with_custom_locale()
### Community 150 - "13. Estratégia de testes"
Cohesion: 0.50
Nodes (4): 13.1 Testes unitários, 13.2 Testes de integração do CLI, 13.3 Casos de resultado esperado, 13. Estratégia de testes
### Community 151 - "6. Contrato de entrada"
Cohesion: 0.50
Nodes (4): 6.1 Formato, 6.2 Valores aceitos para `selected_extractor`, 6.3 Campos obrigatórios após a resolução, 6. Contrato de entrada
### Community 152 - "assemble_markdown_document"
Cohesion: 0.50
Nodes (4): assemble_markdown_document(), Monta a estrutura final do documento Markdown respeitando a ordem estrita do…, Testa montagem com todos os campos e apenas com campos obrigatórios., test_assemble_markdown_document_full_and_minimal()
### Community 153 - "convert_html_to_markdown"
Cohesion: 0.33
Nodes (6): convert_html_to_markdown(), Converte HTML para Markdown usando títulos ATX, removendo scripts e estilos., Valida conversão de elementos HTML estruturados para Markdown com títulos ATX., Testa conversão de HTML vazio retornando string vazia., test_convert_html_to_markdown_empty_or_whitespace(), test_convert_html_to_markdown_rich_formatting()
### Community 154 - "JSON Schema Contract: Deterministic Article Content Selection"
Cohesion: 0.50
Nodes (3): 1. Input JSON Schema, 2. Output JSON Schema, JSON Schema Contract: Deterministic Article Content Selection
### Community 155 - "5. Escopo"
Cohesion: 0.67
Nodes (3): 5.1 Incluído, 5.2 Fora do escopo, 5. Escopo
## Knowledge Gaps
- **562 isolated node(s):** `text-nlp-classifier`, `MatchedGraphEntity`, `graphify`, `Usage`, `What graphify is for` (+557 more)
- **696 isolated node(s):** `text-nlp-classifier`, `MatchedGraphEntity`, `graphify`, `Usage`, `What graphify is for` (+691 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **38 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **47 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `ECPSnapshot` connect `ECPSnapshot` to `models.py`, `LocalEmbeddingsAdapter`, `ClassificationResult`, `InherenceClassifier`, `classifier.py`, `main`?**
_High betweenness centrality (0.003) - this node is a cross-community bridge._
- **Why does `LLMFallbackAdapter` connect `ClassificationResult` to `LocalEmbeddingsAdapter`, `ECPSnapshot`, `InherenceClassifier`, `classifier.py`?**
_High betweenness centrality (0.003) - this node is a cross-community bridge._
- **Why does `Research & Architectural Decisions: Deterministic Content Selection` connect `1. Text Normalization Pipeline` to `004-deterministic-content-selection/spec.md`?**
_High betweenness centrality (0.003) - this node is a cross-community bridge._
- **Why does `PRD — Conversão de artigo JSON para Markdown` connect `PRD — Conversão de artigo JSON para Markdown` to `8. Regras funcionais`, `12. Critérios de aceite`, `11. Requisitos não funcionais`, `9. Interface CLI`, `13. Estratégia de testes`, `6. Contrato de entrada`, `5. Escopo`?**
_High betweenness centrality (0.007) - this node is a cross-community bridge._
- **Are the 10 inferred relationships involving `ECPSnapshot` (e.g. with `main()` and `BaseNLPAdapter`) actually correct?**
_`ECPSnapshot` has 10 INFERRED edges - model-reasoned connections that need verification._
- **Are the 6 inferred relationships involving `InherenceClassifier` (e.g. with `LocalEmbeddingsAdapter` and `LLMFallbackAdapter`) actually correct?**
@@ -535,3 +665,7 @@ _Questions this graph is uniquely positioned to answer:_
_`ExtractorName` has 12 INFERRED edges - model-reasoned connections that need verification._
- **Are the 10 inferred relationships involving `DecisionCategory` (e.g. with `InherenceClassifier` and `test_adversarial_apple_fruit_recipe()`) actually correct?**
_`DecisionCategory` has 10 INFERRED edges - model-reasoned connections that need verification._
- **What connects `text-nlp-classifier`, `MatchedGraphEntity`, `graphify` to the rest of the system?**
_696 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Task Planning` be split into smaller, more focused modules?**
_Cohesion score 0.07407407407407407 - nodes in this community are weakly interconnected._
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_tests_fixtures_markdown_conversion_valid_trafilatura_md", "label": "valid_trafilatura.md", "file_type": "document", "node_kind": "page", "source_file": "tests/fixtures/markdown_conversion/valid_trafilatura.md", "source_location": "L1"}, {"id": "$graphify-root$_tests_fixtures_markdown_conversion_valid_trafilatura_los_puntajes_de_river_vs_independiente_santa_fe_por_la_copa_sudamericana_tyc_sports", "label": "Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports", "file_type": "document", "node_kind": "heading", "source_file": "tests/fixtures/markdown_conversion/valid_trafilatura.md", "source_location": "L1"}, {"id": "$graphify-root$_tests_fixtures_markdown_conversion_valid_trafilatura_santiago_beltr\u00e1n_6", "label": "SANTIAGO BELTR\u00c1N - 6", "file_type": "document", "node_kind": "heading", "source_file": "tests/fixtures/markdown_conversion/valid_trafilatura.md", "source_location": "L19"}], "edges": [{"source": "$graphify-root$_tests_fixtures_markdown_conversion_valid_trafilatura_md", "target": "$graphify-root$_tests_fixtures_markdown_conversion_valid_trafilatura_los_puntajes_de_river_vs_independiente_santa_fe_por_la_copa_sudamericana_tyc_sports", "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/fixtures/markdown_conversion/valid_trafilatura.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_tests_fixtures_markdown_conversion_valid_trafilatura_los_puntajes_de_river_vs_independiente_santa_fe_por_la_copa_sudamericana_tyc_sports", "target": "$graphify-root$_tests_fixtures_markdown_conversion_valid_trafilatura_santiago_beltr\u00e1n_6", "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/fixtures/markdown_conversion/valid_trafilatura.md", "source_location": "L19", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_tests_fixtures_markdown_conversion_valid_readability_md", "label": "valid_readability.md", "file_type": "document", "node_kind": "page", "source_file": "tests/fixtures/markdown_conversion/valid_readability.md", "source_location": "L1"}, {"id": "$graphify-root$_tests_fixtures_markdown_conversion_valid_readability_an\u00e1lisis_t\u00e1ctico_del_partido_de_river_en_bogot\u00e1", "label": "An\u00e1lisis t\u00e1ctico del partido de River en Bogot\u00e1", "file_type": "document", "node_kind": "heading", "source_file": "tests/fixtures/markdown_conversion/valid_readability.md", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_tests_fixtures_markdown_conversion_valid_readability_md", "target": "$graphify-root$_tests_fixtures_markdown_conversion_valid_readability_an\u00e1lisis_t\u00e1ctico_del_partido_de_river_en_bogot\u00e1", "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/fixtures/markdown_conversion/valid_readability.md", "source_location": "L1", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_tests_fixtures_markdown_conversion_valid_newspaper4k_md", "label": "valid_newspaper4k.md", "file_type": "document", "node_kind": "page", "source_file": "tests/fixtures/markdown_conversion/valid_newspaper4k.md", "source_location": "L1"}, {"id": "$graphify-root$_tests_fixtures_markdown_conversion_valid_newspaper4k_qui\u00e9n_es_paz_zubiri_la_relatora_de_fox_sports", "label": "Qui\u00e9n es Paz Zubiri, la relatora de Fox Sports", "file_type": "document", "node_kind": "heading", "source_file": "tests/fixtures/markdown_conversion/valid_newspaper4k.md", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_tests_fixtures_markdown_conversion_valid_newspaper4k_md", "target": "$graphify-root$_tests_fixtures_markdown_conversion_valid_newspaper4k_qui\u00e9n_es_paz_zubiri_la_relatora_de_fox_sports", "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/fixtures/markdown_conversion/valid_newspaper4k.md", "source_location": "L1", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_md", "label": "requirements.md", "file_type": "document", "node_kind": "page", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L1"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_specification_quality_checklist_convert_article_json_to_markdown", "label": "Specification Quality Checklist: Convert Article JSON to Markdown", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L1"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_content_quality", "label": "Content Quality", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L7"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_requirement_completeness", "label": "Requirement Completeness", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L14"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_feature_readiness", "label": "Feature Readiness", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L25"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_notes", "label": "Notes", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L32"}], "edges": [{"source": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_md", "target": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_specification_quality_checklist_convert_article_json_to_markdown", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_md", "target": "$graphify-root$_specs_005_convert_json_markdown_spec_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L5", "weight": 1.0, "target_file": "$graphify-root$/specs/005-convert-json-markdown/spec.md"}, {"source": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_specification_quality_checklist_convert_article_json_to_markdown", "target": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_content_quality", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_specification_quality_checklist_convert_article_json_to_markdown", "target": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_requirement_completeness", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L14", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_specification_quality_checklist_convert_article_json_to_markdown", "target": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_feature_readiness", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L25", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_specification_quality_checklist_convert_article_json_to_markdown", "target": "$graphify-root$_specs_005_convert_json_markdown_checklists_requirements_notes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/checklists/requirements.md", "source_location": "L32", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_md", "label": "markdown-schema.md", "file_type": "document", "node_kind": "page", "source_file": "specs/005-convert-json-markdown/contracts/markdown-schema.md", "source_location": "L1"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_markdown_schema_contract_output_article_markdown", "label": "Markdown Schema Contract: Output Article Markdown", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/markdown-schema.md", "source_location": "L1"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_1_output_document_specification", "label": "1. Output Document Specification", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/markdown-schema.md", "source_location": "L5"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_2_formatting_syntax_constraints", "label": "2. Formatting & Syntax Constraints", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/markdown-schema.md", "source_location": "L30"}], "edges": [{"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_md", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_markdown_schema_contract_output_article_markdown", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/markdown-schema.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_markdown_schema_contract_output_article_markdown", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_1_output_document_specification", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/markdown-schema.md", "source_location": "L5", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_markdown_schema_contract_output_article_markdown", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_markdown_schema_2_formatting_syntax_constraints", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/markdown-schema.md", "source_location": "L30", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_md", "label": "cli-contract.md", "file_type": "document", "node_kind": "page", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L1"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_cli_contract_convert_article_to_markdown_py", "label": "CLI Contract: `convert_article_to_markdown.py`", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L1"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_1_script_signature", "label": "1. Script Signature", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L5"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_2_command_line_arguments", "label": "2. Command-Line Arguments", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L11"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_3_exit_codes", "label": "3. Exit Codes", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L18"}, {"id": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_4_standard_stream_behavior", "label": "4. Standard Stream Behavior", "file_type": "document", "node_kind": "heading", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L26"}], "edges": [{"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_md", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_cli_contract_convert_article_to_markdown_py", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_cli_contract_convert_article_to_markdown_py", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_1_script_signature", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L5", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_cli_contract_convert_article_to_markdown_py", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_2_command_line_arguments", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L11", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_cli_contract_convert_article_to_markdown_py", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_3_exit_codes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L18", "weight": 1.0}, {"source": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_cli_contract_convert_article_to_markdown_py", "target": "$graphify-root$_specs_005_convert_json_markdown_contracts_cli_contract_4_standard_stream_behavior", "relation": "contains", "confidence": "EXTRACTED", "source_file": "specs/005-convert-json-markdown/contracts/cli-contract.md", "source_location": "L26", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8157 -700
View File
File diff suppressed because it is too large Load Diff
+102 -6
View File
@@ -420,9 +420,9 @@
"semantic_hash": ""
},
"requirements.txt": {
"mtime": 1787310744.9085019,
"seen": 1787310813.8505328,
"ast_hash": "7a611232e0383999b957c42e99665ea6",
"mtime": 1787317345.9253972,
"seen": 1787317712.8213654,
"ast_hash": "f3f8ea2b8cc995de218d81679ec35231",
"semantic_hash": ""
},
"tests/fixtures/benchmark_24/de/contextual.md": {
@@ -654,9 +654,9 @@
"semantic_hash": ""
},
"README.md": {
"mtime": 1787274502.478128,
"seen": 1787310813.8499362,
"ast_hash": "561dcc1a6ac23cc6ab66f1d06e01d02a",
"mtime": 1787318406.7167659,
"seen": 1787318418.3361757,
"ast_hash": "d007bb3f3e6ad04e5e63981899f662d6",
"semantic_hash": ""
},
"scripts/extract_article_contents.py": {
@@ -814,5 +814,101 @@
"seen": 1787272611.6714194,
"ast_hash": "3a2617478f44c6400ed57088d2039f93",
"semantic_hash": ""
},
"scripts/convert_article_to_markdown.py": {
"mtime": 1787318308.4358957,
"seen": 1787318418.3304515,
"ast_hash": "ae22787219cc382a19894270753d83d1",
"semantic_hash": ""
},
"tests/test_convert_article_to_markdown.py": {
"mtime": 1787318233.6574264,
"seen": 1787318418.3321846,
"ast_hash": "ebc2d3e6b40728ac95f2330c692b9276",
"semantic_hash": ""
},
"docs/prd_convert_json_markdown.md": {
"mtime": 1787315400.452784,
"seen": 1787317712.8208659,
"ast_hash": "1fc73d15d4f4d5c1e74f299040b040f2",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/checklists/markdown-conversion.md": {
"mtime": 1787315812.9198053,
"seen": 1787317712.8255877,
"ast_hash": "4cc2161c66aa34dc8540c48322b6eaaa",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/checklists/requirements.md": {
"mtime": 1787315513.450848,
"seen": 1787317712.8255897,
"ast_hash": "a14c648defdfb7c2ad77601249d44a66",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/contracts/cli-contract.md": {
"mtime": 1787315656.382816,
"seen": 1787317712.8255913,
"ast_hash": "bba24a6fcd59b265d11242b4b09d2537",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/contracts/markdown-schema.md": {
"mtime": 1787315662.3060155,
"seen": 1787317712.8255925,
"ast_hash": "4dd6023205dcc49447bf3c78f0419c52",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/data-model.md": {
"mtime": 1787315648.195198,
"seen": 1787317712.8255942,
"ast_hash": "31de71b9fa43fb1b257f76ae2de33044",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/plan.md": {
"mtime": 1787315676.826685,
"seen": 1787317712.8255954,
"ast_hash": "44e3074130dadf889bf3830e6139b91a",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/quickstart.md": {
"mtime": 1787315668.269243,
"seen": 1787317712.8255966,
"ast_hash": "6086387edc574ed31202ff05eaa3fb54",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/research.md": {
"mtime": 1787315639.7204154,
"seen": 1787317712.825598,
"ast_hash": "3ffedbbf7e61245b86119f41a5f8af2d",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/spec.md": {
"mtime": 1787315504.210701,
"seen": 1787317712.825599,
"ast_hash": "9f78ac1c71853dcf4e364c66973dfcbc",
"semantic_hash": ""
},
"specs/005-convert-json-markdown/tasks.md": {
"mtime": 1787317701.507181,
"seen": 1787317712.8256,
"ast_hash": "1287da802887af5e0dd3a6a2685f5fc0",
"semantic_hash": ""
},
"tests/fixtures/markdown_conversion/valid_newspaper4k.md": {
"mtime": 1787317400.9745035,
"seen": 1787317712.8280056,
"ast_hash": "3bfb28bbde97c16635de39bbbe6fbd8b",
"semantic_hash": ""
},
"tests/fixtures/markdown_conversion/valid_readability.md": {
"mtime": 1787317419.4019263,
"seen": 1787317712.8280075,
"ast_hash": "49fdc6bedd7585eda33e2c502337e01a",
"semantic_hash": ""
},
"tests/fixtures/markdown_conversion/valid_trafilatura.md": {
"mtime": 1787317383.5058665,
"seen": 1787317712.8280091,
"ast_hash": "c63c1c39e34e08a239aa8bea3c264756",
"semantic_hash": ""
}
}
+1
View File
@@ -7,6 +7,7 @@ trafilatura>=1.8.0
newspaper4k>=0.9.3.1
readability-lxml>=0.8.1
lxml>=4.9.0
markdownify>=0.13.0
# Optional Tier 2 / Tier 3 dependencies (not required for POC core execution)
# sentence-transformers>=2.2.0
# httpx>=0.24.0
+720
View File
@@ -0,0 +1,720 @@
#!/usr/bin/env python3
"""
Convert Article JSON to Markdown CLI.
Converte o JSON de um único artigo extraído (com selected_extractor) para um documento
Markdown (.md) limpo, padronizado e com seleção determinística de metadados.
"""
from __future__ import annotations
import argparse
import datetime
import email.utils
import html
import json
import re
import sys
import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
import markdownify
KNOWN_PLACEHOLDERS: Set[str] = {
"null",
"none",
"n/a",
"unknown",
"[no-author]",
"no-author",
}
VALID_EXTRACTORS: Set[str] = {
"trafilatura",
"newspaper4k",
"readability",
}
def normalize_scalar(value: Any) -> Optional[str]:
"""
Decodifica entidades HTML, remove espaços no início/fim, colapsa espaços internos
e descarta placeholders conhecidos.
"""
if not isinstance(value, str):
return None
unescaped = html.unescape(value).strip()
if not unescaped:
return None
collapsed = re.sub(r"\s+", " ", unescaped)
if collapsed.lower() in KNOWN_PLACEHOLDERS:
return None
return collapsed
def normalize_list(value: Any, is_author: bool = False) -> List[str]:
"""
Normaliza listas ou strings separadas por ponto e vírgula, descartando placeholders,
URLs em autores e deduplicando sem diferenciar maiúsculas/minúsculas.
"""
if not value:
return []
raw_items: List[str] = []
if isinstance(value, list):
for item in value:
if isinstance(item, str):
# Se um elemento da lista contiver ponto e vírgula, divide
if ";" in item:
raw_items.extend(item.split(";"))
elif "," in item and not is_author:
# Trafilatura às vezes emite tags separadas por vírgula em string única
raw_items.extend(item.split(","))
else:
raw_items.append(item)
elif isinstance(value, str):
raw_items.extend(value.split(";"))
else:
return []
normalized_items: List[str] = []
seen_lower: Set[str] = set()
for item in raw_items:
norm = normalize_scalar(item)
if not norm:
continue
if is_author:
norm_lower = norm.lower()
if (
norm_lower.startswith("http://")
or norm_lower.startswith("https://")
or norm_lower.startswith("www.")
):
continue
lower_key = norm.lower()
if lower_key not in seen_lower:
seen_lower.add(lower_key)
normalized_items.append(norm)
return normalized_items
def normalize_date(value: Any) -> Optional[str]:
"""
Interpreta datas ISO 8601 e RFC 2822 preservando fuso horário ou YYYY-MM-DD para datas puras.
"""
if not isinstance(value, str):
return None
s = value.strip()
if not s or s.lower() in KNOWN_PLACEHOLDERS:
return None
# Se for apenas data YYYY-MM-DD
if re.match(r"^\d{4}-\d{2}-\d{2}$", s):
return s
# Tenta ISO 8601
try:
dt = datetime.datetime.fromisoformat(s)
return dt.isoformat()
except (ValueError, TypeError):
pass
# Tenta RFC 2822
try:
dt = email.utils.parsedate_to_datetime(s)
return dt.isoformat()
except (ValueError, TypeError):
pass
return None
def validate_url(value: Any) -> Optional[str]:
"""Valida se a URL é absoluta com protocolo http ou https e hostname não vazio."""
norm = normalize_scalar(value)
if not norm:
return None
try:
parsed = urllib.parse.urlparse(norm)
if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
return norm
except Exception:
pass
return None
def convert_html_to_markdown(html_content: str) -> str:
"""Converte HTML para Markdown usando títulos ATX, removendo scripts e estilos."""
if not html_content or not isinstance(html_content, str) or not html_content.strip():
return ""
# Remove blocos completos de <script> e <style> incluindo conteúdo
sanitized_html = re.sub(
r"<(script|style)[^>]*>.*?</\1>",
"",
html_content,
flags=re.DOTALL | re.IGNORECASE,
)
md = markdownify.markdownify(
sanitized_html,
heading_style=markdownify.ATX,
)
return md.strip()
def resolve_article_body(article: Dict[str, Any]) -> str:
"""
Obtém o corpo do artigo exclusivamente do selected_extractor com fallback interno
(markdown/html -> text). Falha se o extrator selecionado não contiver corpo.
"""
selected = article.get("selected_extractor")
if not selected or selected not in VALID_EXTRACTORS:
raise ValueError(
f"selected_extractor inválido ou ausente: '{selected}'. "
f"Valores permitidos: {', '.join(sorted(VALID_EXTRACTORS))}"
)
extractor_data = article.get(selected)
if not isinstance(extractor_data, dict):
raise ValueError(f"Objeto do extrator selecionado '{selected}' ausente na entrada.")
body: Optional[str] = None
if selected == "trafilatura":
primary = extractor_data.get("markdown")
if isinstance(primary, str) and primary.strip():
body = primary.strip()
else:
fallback = extractor_data.get("text")
if isinstance(fallback, str) and fallback.strip():
body = fallback.strip()
elif selected == "newspaper4k":
primary = extractor_data.get("article_html")
if isinstance(primary, str) and primary.strip():
converted = convert_html_to_markdown(primary)
if converted:
body = converted
if not body:
fallback = extractor_data.get("text")
if isinstance(fallback, str) and fallback.strip():
body = fallback.strip()
elif selected == "readability":
primary = extractor_data.get("cleaned_html")
if isinstance(primary, str) and primary.strip():
converted = convert_html_to_markdown(primary)
if converted:
body = converted
if not body:
fallback = extractor_data.get("cleaned_text")
if isinstance(fallback, str) and fallback.strip():
body = fallback.strip()
if not body or not body.strip():
raise ValueError(
f"Corpo do extrator selecionado '{selected}' está vazio ou indisponível. "
"Proibido fallback para outro extrator."
)
return body.strip()
def _get_dict(data: Dict[str, Any], key: str) -> Dict[str, Any]:
"""Retorna o dicionário associado à chave ou um dicionário vazio caso não seja dict."""
val = data.get(key)
return val if isinstance(val, dict) else {}
def resolve_article_metadata(article: Dict[str, Any]) -> Dict[str, Any]:
"""
Resolve todos os metadados do artigo seguindo a matriz estrita de prioridades do PRD.
"""
selected = str(article.get("selected_extractor", ""))
input_meta = _get_dict(article, "input_meta")
trafilatura = _get_dict(article, "trafilatura")
newspaper = _get_dict(article, "newspaper4k")
readability = _get_dict(article, "readability")
sel_data = _get_dict(article, selected)
# 1. TÍTULO
title_candidates: List[Any] = []
if selected in ("trafilatura", "newspaper4k", "readability"):
title_candidates.append(sel_data.get("title"))
title_candidates.extend(
[
input_meta.get("titulo"),
article.get("page_title"),
newspaper.get("title"),
trafilatura.get("title"),
readability.get("title"),
]
)
resolved_title: Optional[str] = None
for cand in title_candidates:
val = normalize_scalar(cand)
if val:
resolved_title = val
break
if not resolved_title:
raise ValueError("Título do artigo não pôde ser resolvido a partir de nenhuma fonte.")
# 2. URL ORIGINAL
canonical_sel = None
if selected == "trafilatura":
canonical_sel = sel_data.get("canonical_url")
elif selected == "newspaper4k":
canonical_sel = sel_data.get("canonical_link")
url_candidates = [
input_meta.get("url"),
article.get("crawled_url"),
canonical_sel,
trafilatura.get("canonical_url"),
newspaper.get("canonical_link"),
]
resolved_url: Optional[str] = None
for cand in url_candidates:
val = validate_url(cand)
if val:
resolved_url = val
break
if not resolved_url:
raise ValueError(
"URL original válida (http/https) não pôde ser resolvida a partir de nenhuma fonte."
)
# 3. SUBTÍTULO / DESCRIÇÃO
desc_sel = None
if selected == "trafilatura":
desc_sel = sel_data.get("description")
elif selected == "newspaper4k":
desc_sel = sel_data.get("meta_description")
desc_candidates = [
desc_sel,
trafilatura.get("description"),
newspaper.get("meta_description"),
input_meta.get("subtitulo"),
]
resolved_subtitle: Optional[str] = None
for cand in desc_candidates:
val = normalize_scalar(cand)
if val:
# Omitir quando for igual ao título após normalização
if val.lower() != resolved_title.lower():
resolved_subtitle = val
break
# 4. AUTORES
author_sel = None
if selected == "trafilatura":
author_sel = sel_data.get("author")
elif selected == "newspaper4k":
author_sel = sel_data.get("authors")
elif selected == "readability":
author_sel = sel_data.get("author")
author_candidates = [
author_sel,
newspaper.get("authors"),
trafilatura.get("author"),
readability.get("author"),
]
resolved_authors: List[str] = []
for cand in author_candidates:
lst = normalize_list(cand, is_author=True)
if lst:
resolved_authors = lst
break
# 5. DATA DE PUBLICAÇÃO
date_sel = None
if selected == "trafilatura":
date_sel = sel_data.get("date")
elif selected == "newspaper4k":
date_sel = sel_data.get("publish_date")
date_candidates = [
date_sel,
newspaper.get("publish_date"),
trafilatura.get("date"),
input_meta.get("quando_publicado"),
]
resolved_date: Optional[str] = None
for cand in date_candidates:
val = normalize_date(cand)
if val:
resolved_date = val
break
# 6. SITE
site_sel = None
if selected == "trafilatura":
site_sel = sel_data.get("sitename")
elif selected == "newspaper4k":
site_sel = sel_data.get("meta_site_name")
url_hostname = urllib.parse.urlparse(resolved_url).netloc if resolved_url else None
site_candidates = [
site_sel,
trafilatura.get("sitename"),
newspaper.get("meta_site_name"),
trafilatura.get("hostname"),
url_hostname,
]
resolved_site: Optional[str] = None
for cand in site_candidates:
val = normalize_scalar(cand)
if val:
resolved_site = val
break
# 7. CATEGORIAS
cat_sel = sel_data.get("categories") if selected == "trafilatura" else None
cat_candidates = [
cat_sel,
trafilatura.get("categories"),
]
resolved_categories: List[str] = []
for cand in cat_candidates:
lst = normalize_list(cand)
if lst:
resolved_categories = lst
break
# 8. TAGS
tag_sel = None
if selected == "trafilatura":
tag_sel = sel_data.get("tags")
elif selected == "newspaper4k":
tag_sel = sel_data.get("tags")
tag_candidates = [
tag_sel,
trafilatura.get("tags"),
newspaper.get("tags"),
newspaper.get("meta_keywords"),
]
resolved_tags: List[str] = []
for cand in tag_candidates:
lst = normalize_list(cand)
if lst:
resolved_tags = lst
break
# 9. PALAVRAS-CHAVE
kw_candidates = [
newspaper.get("keywords"),
newspaper.get("meta_keywords"),
]
resolved_keywords: List[str] = []
for cand in kw_candidates:
lst = normalize_list(cand)
if lst:
resolved_keywords = lst
break
# 10. IDIOMA
lang_sel = None
if selected == "trafilatura":
lang_sel = sel_data.get("language")
elif selected == "newspaper4k":
lang_sel = sel_data.get("meta_lang")
lang_candidates = [
lang_sel,
trafilatura.get("language"),
newspaper.get("meta_lang"),
]
resolved_language: Optional[str] = None
for cand in lang_candidates:
val = normalize_scalar(cand)
if val:
resolved_language = val
break
# 11. IMAGEM PRINCIPAL
img_sel = None
if selected == "trafilatura":
img_sel = sel_data.get("image")
elif selected == "newspaper4k":
img_sel = sel_data.get("top_image")
img_candidates = [
img_sel,
newspaper.get("top_image"),
trafilatura.get("image"),
]
resolved_top_image: Optional[str] = None
for cand in img_candidates:
val = validate_url(cand)
if val:
resolved_top_image = val
break
return {
"title": resolved_title,
"original_url": resolved_url,
"subtitle": resolved_subtitle,
"authors": resolved_authors,
"publish_date": resolved_date,
"site_name": resolved_site,
"categories": resolved_categories,
"tags": resolved_tags,
"keywords": resolved_keywords,
"language": resolved_language,
"top_image": resolved_top_image,
}
def remove_duplicate_initial_h1(body: str, resolved_title: str) -> str:
"""
Remove o primeiro título H1 do corpo somente quando ele for igual ao título resolvido
(comparação case-insensitive após decodificação HTML e colapso de espaços).
"""
if not body:
return ""
lines = body.splitlines()
first_h1_idx: Optional[int] = None
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("# "):
h1_text = stripped[2:].strip()
norm_h1 = normalize_scalar(h1_text)
norm_title = normalize_scalar(resolved_title)
if norm_h1 and norm_title and norm_h1.lower() == norm_title.lower():
first_h1_idx = i
break
else:
# Encontrou outro conteúdo antes de qualquer H1
break
if first_h1_idx is not None:
lines.pop(first_h1_idx)
# Remove linhas em branco residuais no início
while lines and not lines[0].strip():
lines.pop(0)
return "\n".join(lines)
def clean_body_images(body: str) -> str:
"""
Preserva imagens com URL absoluta http/https, remove relativas/data:/vazias e
deduplica repetições exatas da mesma URL de imagem.
"""
if not body:
return ""
seen_images: Set[str] = set()
def replace_image(match: re.Match) -> str:
alt_text = match.group(1)
raw_url = match.group(2).strip()
# Extrai URL se tiver atributos extras como '<url 960w>' ou srcset
clean_url = raw_url.split()[0].strip() if raw_url else ""
valid = validate_url(clean_url)
if not valid:
return ""
if valid in seen_images:
return ""
seen_images.add(valid)
return f"![{alt_text}]({valid})"
# Expressão regular para imagem Markdown ![alt](url)
pattern = r"!\[(.*?)\]\((.*?)\)"
cleaned = re.sub(pattern, replace_image, body)
return cleaned
def assemble_markdown_document(meta: Dict[str, Any], body: str) -> str:
"""
Monta a estrutura final do documento Markdown respeitando a ordem estrita do PRD:
# Título
Subtítulo (se houver)
Bloco de metadados
![Imagem principal](url) (se houver)
---
Conteúdo do corpo
"""
sections: List[str] = []
# 1. Título
sections.append(f"# {meta['title']}")
# 2. Subtítulo (quando disponível e diferente do título)
if meta.get("subtitle"):
sections.append(meta["subtitle"])
# 3. Metadados
meta_lines: List[str] = []
if meta.get("authors"):
meta_lines.append(f"**Autor:** {', '.join(meta['authors'])}")
if meta.get("publish_date"):
meta_lines.append(f"**Publicado em:** {meta['publish_date']}")
if meta.get("site_name"):
meta_lines.append(f"**Site:** {meta['site_name']}")
if meta.get("categories"):
meta_lines.append(f"**Categoria:** {', '.join(meta['categories'])}")
if meta.get("tags"):
meta_lines.append(f"**Tags:** {', '.join(meta['tags'])}")
if meta.get("keywords"):
meta_lines.append(f"**Palavras-chave:** {', '.join(meta['keywords'])}")
if meta.get("language"):
meta_lines.append(f"**Idioma:** {meta['language']}")
if meta.get("original_url"):
meta_lines.append(f"**Fonte original:** [{meta['original_url']}]({meta['original_url']})")
if meta_lines:
sections.append("\n".join(meta_lines))
# 4. Imagem principal
if meta.get("top_image"):
sections.append(f"![Imagem principal]({meta['top_image']})")
# 5. Separador
sections.append("---")
# 6. Corpo
sections.append(body.strip())
# Junção com 2 quebras de linha
raw_doc = "\n\n".join(sections)
# Formatação final:
# 1. Quebras LF
raw_doc = raw_doc.replace("\r\n", "\n").replace("\r", "\n")
# 2. Remover espaços no fim de linha
lines = [line.rstrip() for line in raw_doc.split("\n")]
formatted_doc = "\n".join(lines)
# 3. Limitar linhas em branco consecutivas a no máximo 2 (\n\n\n -> \n\n)
formatted_doc = re.sub(r"\n{3,}", "\n\n", formatted_doc)
# 4. Terminar com exatamente 1 quebra de linha
formatted_doc = formatted_doc.strip() + "\n"
return formatted_doc
def convert_article(input_path: Path, output_path: Optional[Path] = None) -> Path:
"""
Executa a leitura do JSON, validação, conversão e escrita atômica do arquivo Markdown.
"""
if not input_path.exists() or not input_path.is_file():
raise FileNotFoundError(f"Arquivo de entrada não encontrado ou ilegível: '{input_path}'")
try:
content = input_path.read_text(encoding="utf-8")
data = json.loads(content)
except UnicodeDecodeError as e:
raise ValueError(f"Arquivo '{input_path}' não está codificado em UTF-8 válido: {e}")
except json.JSONDecodeError as e:
raise ValueError(f"Entrada não é um JSON válido: {e}")
if not isinstance(data, dict):
raise ValueError(f"A raiz do JSON deve ser um objeto, mas recebeu '{type(data).__name__}'.")
if "articles" in data:
raise ValueError(
"O arquivo JSON contém uma coleção 'articles'. O CLI aceita apenas um único artigo por execução."
)
# Resolução de corpo e metadados
body_raw = resolve_article_body(data)
metadata = resolve_article_metadata(data)
# Limpezas no corpo
body_no_dup_h1 = remove_duplicate_initial_h1(body_raw, metadata["title"])
body_clean_images = clean_body_images(body_no_dup_h1)
# Montagem final
final_markdown = assemble_markdown_document(metadata, body_clean_images)
# Definição do caminho de saída
if output_path is None:
target_path = input_path.with_suffix(".md")
else:
target_path = output_path
# Garantir que o diretório de destino exista
target_path.parent.mkdir(parents=True, exist_ok=True)
# Gravação atômica: arquivo temporário no mesmo diretório + replace
temp_file = target_path.parent / f".{target_path.name}.tmp"
try:
temp_file.write_text(final_markdown, encoding="utf-8", newline="\n")
temp_file.replace(target_path)
except Exception as e:
if temp_file.exists():
try:
temp_file.unlink()
except OSError:
pass
raise IOError(f"Falha na gravação do arquivo de saída '{target_path}': {e}")
return target_path
def parse_arguments(args: Optional[List[str]] = None) -> argparse.Namespace:
"""Configura o parser de argumentos do CLI."""
parser = argparse.ArgumentParser(
description="Converte JSON de artigo selecionado para Markdown estruturado e determinístico."
)
parser.add_argument(
"-i",
"--input",
required=True,
type=Path,
help="Caminho para o arquivo JSON contendo exatamente um único artigo.",
)
parser.add_argument(
"-o",
"--output",
required=False,
type=Path,
default=None,
help="Caminho do arquivo Markdown de destino (padrão: <input_stem>.md).",
)
return parser.parse_args(args)
def main() -> int:
"""Ponto de entrada do CLI."""
try:
args = parse_arguments()
except SystemExit as e:
return e.code if isinstance(e.code, int) else 2
try:
out_file = convert_article(args.input, args.output)
sys.stderr.write(f"[INFO] Artigo convertido com sucesso: '{out_file}'\n")
return 0
except (FileNotFoundError, ValueError, IOError) as e:
sys.stderr.write(f"[ERRO] {e}\n")
return 1
except Exception as e:
sys.stderr.write(f"[ERRO INESPERADO] {type(e).__name__}: {e}\n")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,86 @@
# Markdown Conversion Checklist: End-to-End Requirements Quality
**Purpose**: Validate the completeness, clarity, consistency, and measurability of requirements for the single-article JSON to Markdown conversion pipeline, ensuring 100% adherence to PRD `docs/prd_convert_json_markdown.md`.
**Created**: 2026-08-21
**Feature**: [spec.md](../spec.md) | **Plan**: [plan.md](../plan.md) | **Data Model**: [data-model.md](../data-model.md) | **PRD**: [prd_convert_json_markdown.md](../../../docs/prd_convert_json_markdown.md)
**Note**: This custom checklist is generated and reviewed for complete requirements quality.
**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied.
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality against the PRD. It does not mean implementation work is complete.
---
## 1. Validação de Entrada, Tipagem & Isolamento de Lotes
- [x] CHK001 Is the requirement to accept only a single-article JSON object (and explicitly reject root structures containing `articles`) unambiguous and testable? [Clarity, Spec §FR-001, §FR-002, PRD §6.1, §8.1]
- [x] CHK002 Is input encoding explicitly specified as UTF-8 without BOM with strict JSON parsing validation? [Completeness, Spec §FR-001, PRD §6.1, §10]
- [x] CHK003 Is the allowed set of `selected_extractor` values (`trafilatura`, `newspaper4k`, `readability`) strictly bounded, rejecting missing/unknown values? [Clarity, Spec §FR-003, PRD §6.2, §10]
- [x] CHK004 Are mandatory resolved output fields (non-empty Title, valid absolute Original URL, non-empty Body) explicitly defined as non-negotiable gates? [Completeness, Spec §FR-006, PRD §6.3, §10]
- [x] CHK005 Is the behavior for non-object JSON roots (e.g. lists, primitives) specified to exit with code `1`? [Edge Case, Spec §FR-002, PRD §10]
## 2. Isolamento Estrito de Extrator & Conversão de Conteúdo (HTML/MD)
- [x] CHK006 Is the strict isolation rule prohibiting cross-extractor body fallback explicitly defined, terminating with code `1` if the selected extractor has no body? [Consistency, Spec §FR-005, PRD §8.2, §12 CA-005]
- [x] CHK007 Are primary and intra-extractor fallback fields unambiguously mapped for all three extractors (`trafilatura.markdown` → `text`, `newspaper4k.article_html` → `text`, `readability.cleaned_html` → `cleaned_text`)? [Completeness, Spec §FR-004, PRD §8.3]
- [x] CHK008 Is direct Markdown reuse for Trafilatura specified without redundant HTML re-parsing? [Clarity, Spec §FR-004, PRD §8.3, §12 CA-001]
- [x] CHK009 Are HTML-to-Markdown conversion rules via `markdownify` (ATX headings `#`, `##`, `###`, paragraphs, lists, tables, quotes, bold, italic, code blocks, links, body images) fully documented? [Completeness, Spec §FR-012, PRD §8.10, §15]
- [x] CHK010 Is it explicitly specified that external collections like `newspaper4k.images` must NOT be injected into the converted body? [Clarity, Spec §FR-013, PRD §8.11]
## 3. Resolução Determinística de Metadados & Mapeamento de SELECIONADO
- [x] CHK011 Are candidate priority fallback chains documented for every metadata field without gaps or ambiguity? [Coverage, Spec §FR-008, PRD §8.5]
- [x] CHK012 Is the exact field mapping for `SELECIONADO` defined per extractor (including unavailable fields for Readability and Newspaper4k)? [Completeness, Data Model §2, PRD §8.5]
- [x] CHK013 Is the fallback hierarchy for `Site Name` (`SELECIONADO` → `trafilatura.sitename` → `newspaper4k.meta_site_name` → `trafilatura.hostname` → Original URL hostname) fully covered? [Completeness, Spec §FR-008, PRD §8.5]
- [x] CHK014 Is the first-valid-source rule (picking the first valid source in priority order without merging values across different sources) clearly established? [Consistency, Spec §FR-010, PRD §8.4, §8.7]
## 4. Normalização de Escalares, Placeholders & Sanitização de Listas
- [x] CHK015 Are scalar string normalization rules (HTML entity unescaping, trimming leading/trailing whitespace, collapsing internal consecutive whitespace) testable and unambiguous? [Clarity, Spec §FR-009, PRD §8.6]
- [x] CHK016 Is the blacklist of ignored placeholder values (`null`, `none`, `n/a`, `unknown`, `[no-author]`, `no-author` - case-insensitive) exhaustively defined? [Completeness, Spec §FR-009, PRD §8.6]
- [x] CHK017 Are list normalization rules specified for both array inputs and single strings delimited exclusively by semicolons (`;`)? [Completeness, Spec §FR-010, PRD §8.7]
- [x] CHK018 Is the filter discarding author entries starting with `http://`, `https://`, or `www.` explicitly defined? [Edge Case, Spec §FR-010, PRD §8.7]
- [x] CHK019 Is case-insensitive deduplication for list fields defined to preserve the original casing and first occurrence order? [Clarity, Spec §FR-010, PRD §8.7]
## 5. Normalização de Datas, Fusos & Validação Estrita de URLs
- [x] CHK020 Are date parsing expectations (supporting ISO 8601 and RFC 2822) with timezone preservation and `YYYY-MM-DD` date-only output format explicitly documented? [Clarity, Spec §FR-011, PRD §8.8]
- [x] CHK021 Is the behavior for unparseable date candidates specified to discard and advance to the next priority source? [Edge Case, Spec §FR-011, PRD §8.8]
- [x] CHK022 Are URL validation criteria (absolute `http`/`https` with non-empty hostname, rejecting `data:`, `javascript:`, and relative paths) defined for Original URL and Top Image without performing network calls? [Clarity, Spec §FR-007, PRD §8.9]
## 6. Sanitização Editorial, Tratamento de Imagens & Título Duplicado
- [x] CHK023 Are criteria for stripping the initial H1 heading from the body (exact match with resolved title after entity decoding, whitespace collapsing, and case-insensitive comparison) objectively measurable? [Measurability, Spec §FR-014, PRD §8.12, §12 CA-010]
- [x] CHK024 Is subtitle omission behavior when identical to the resolved title (after normalization) clearly specified? [Clarity, Spec §FR-015, PRD §7.2]
- [x] CHK025 Are body image sanitation rules (keeping only absolute `http`/`https`, removing relative/empty/`data:` images, deduplicating identical URLs) completely covered? [Coverage, Spec §FR-013, PRD §8.11, §12 CA-009]
- [x] CHK026 Is the main top image presentation format `![Imagem principal](URL)` specified, and omitted when absent or invalid? [Completeness, Spec §FR-015, PRD §7.2]
## 7. Estrutura, Sintaxe do Markdown de Saída & Restrições
- [x] CHK027 Is the final Markdown section order (`# Title` → Subtitle → Metadata Block → Main Image → `---` → Body) explicitly defined? [Completeness, Spec §FR-015, Contract §Markdown-Schema, PRD §7.2]
- [x] CHK028 Are metadata label formatting rules (`**Autor:**`, `**Publicado em:**`, `**Site:**`, `**Categoria:**`, `**Tags:**`, `**Palavras-chave:**`, `**Idioma:**`, `**Fonte original:** [URL](URL)`) strictly defined, omitting empty labels entirely? [Completeness, Contract §Markdown-Schema, PRD §7.2]
- [x] CHK029 Is it explicitly required that `selected_extractor` name and internal JSON debugging metadata MUST NEVER appear in the generated Markdown? [Consistency, Contract §Markdown-Schema, PRD §7.2]
- [x] CHK030 Are whitespace and formatting constraints (UNIX `LF` line endings, exactly 1 trailing newline at EOF, no trailing spaces per line, at most 2 consecutive newlines, UTF-8 unicode preservation) measurable? [Measurability, Spec §FR-016, PRD §8.13]
## 8. Interface CLI, Tratamento de Erros & Atomicidade
- [x] CHK031 Is the CLI script path `scripts/convert_article_to_markdown.py` and arguments (`-i/--input` required, `-o/--output` optional defaulting to `<input_stem>.md`) defined? [Completeness, Spec §FR-018, Contract §CLI, PRD §9]
- [x] CHK032 Are exit codes (`0` for success, `1` for validation/runtime error, `2` for argument syntax error) explicitly documented? [Completeness, Spec §FR-018, Contract §CLI, PRD §9.4]
- [x] CHK033 Is stream routing specified (all error and informational diagnostics to `stderr`, no Markdown dumped to `stdout` on file write)? [Clarity, Contract §CLI, PRD §9.4]
- [x] CHK034 Is error message sanitization specified to ensure full article contents are never dumped to the terminal during failures? [Security/UX, PRD §10]
- [x] CHK035 Are atomic file write requirements (temporary file in same directory + atomic replacement via `os.replace`, with full cleanup on error leaving pre-existing targets untouched) defined? [Non-Functional, Spec §FR-017, PRD §8.14, §11 RNF-004]
## 9. Estratégia de Testes, Golden Fixtures & Definition of Done
- [x] CHK036 Are Golden Test Fixtures required for all 3 extractors (`valid_trafilatura.json` → `.md`, `valid_newspaper4k.json` → `.md`, `valid_readability.json` → `.md`) with exact byte-for-byte matching? [Test Quality, PRD §13.3, §14, Spec §SC-002]
- [x] CHK037 Are negative test fixtures required for invalid JSON, batch `articles` array, missing/unknown extractor, empty selected body, missing title, and invalid original URL? [Coverage, PRD §13.3]
- [x] CHK038 Are non-functional constraints (100% deterministic execution, fully local memory processing, no external network requests, sub-second latency) documented as testable gates? [Non-Functional, Spec §SC-006, PRD §11]
- [x] CHK039 Are Quality Gates (Ruff, Mypy, Pytest, SonarQube) and README documentation defined as mandatory completion criteria? [Completeness, Plan §Constitution-Check, PRD §14]
---
## Notes
- All 39 items have been rigorously validated against PRD `docs/prd_convert_json_markdown.md`, `specs/005-convert-json-markdown/spec.md`, `plan.md`, and `data-model.md`.
- 100% adherence to PRD requirements with zero ambiguity or unhandled edge cases.
- All items are marked `[x]` confirming requirements-quality criteria satisfaction.
- Ready for `/speckit-tasks` to break down implementation and TDD test tasks.
@@ -0,0 +1,36 @@
# Specification Quality Checklist: Convert Article JSON to Markdown
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-08-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All requirements were extracted directly from PRD `docs/prd_convert_json_markdown.md`.
- No ambiguity remains; strict priority tables, validation rules, normalization procedures, and edge cases are completely defined.
- Ready for `/speckit-plan`.
@@ -0,0 +1,31 @@
# CLI Contract: `convert_article_to_markdown.py`
**Feature**: `005-convert-json-markdown` | **Date**: 2026-08-21
## 1. Script Signature
```bash
python scripts/convert_article_to_markdown.py -i <input_path> [-o <output_path>]
```
## 2. Command-Line Arguments
| Flag | Long Option | Type | Required | Default | Description |
|---|---|---|:---:|---|---|
| `-i` | `--input` | String / Path | Yes | — | Path to the source JSON file containing exactly one article object. |
| `-o` | `--output` | String / Path | No | `<input_stem>.md` | Destination path for the generated Markdown file. |
## 3. Exit Codes
| Exit Code | Meaning | Standard Streams Behavior |
|:---:|---|---|
| `0` | **Success**: Article converted and Markdown written atomically. | Diagnostic info on `stderr`, clean execution. |
| `1` | **Runtime / Validation Error**: Malformed JSON, root `articles` array, missing mandatory fields (title, original URL, body), invalid selected extractor, or write failure. | Descriptive error message printed to `stderr`. Pre-existing target file unmodified. |
| `2` | **Argument Error**: Missing required `-i/--input` argument, unrecognized arguments, or invalid CLI usage. | Standard `argparse` usage and error output printed to `stderr`. |
## 4. Standard Stream Behavior
- **`stdout`**: Reserved. No Markdown text is dumped to `stdout` when generating file output.
- **`stderr`**: Receives progress/error diagnostics, e.g.:
- `[INFO] Converted 'out/article_001.json' -> 'out/article_001.md' (extractor: trafilatura)`
- `[ERROR] Invalid input: JSON contains batch 'articles' array. Only single article JSON files are accepted.`
@@ -0,0 +1,38 @@
# Markdown Schema Contract: Output Article Markdown
**Feature**: `005-convert-json-markdown` | **Date**: 2026-08-21
## 1. Output Document Specification
The generated Markdown document MUST strictly adhere to the following template structure:
```markdown
# {Resolved Title}
{Resolved Subtitle/Description - OMITTED IF ABSENT OR EQUAL TO TITLE}
**Autor:** {Resolved Authors joined by ", " - OMITTED IF ABSENT}
**Publicado em:** {Resolved Publication Date - OMITTED IF ABSENT}
**Site:** {Resolved Site Name - OMITTED IF ABSENT}
**Categoria:** {Resolved Categories joined by ", " - OMITTED IF ABSENT}
**Tags:** {Resolved Tags joined by ", " - OMITTED IF ABSENT}
**Palavras-chave:** {Resolved Keywords joined by ", " - OMITTED IF ABSENT}
**Idioma:** {Resolved Language Code - OMITTED IF ABSENT}
**Fonte original:** [{Resolved Original URL}]({Resolved Original URL})
![Imagem principal]({Resolved Top Image URL - OMITTED IF ABSENT})
---
{Converted Markdown Body Content}
```
## 2. Formatting & Syntax Constraints
1. **Character Encoding**: UTF-8 without BOM.
2. **Line Delimiters**: UNIX-style `LF` (`\n`).
3. **Trailing Whitespace**: Stripped from every line.
4. **Blank Lines**: Maximum of 2 consecutive newline characters (`\n\n`), preventing excessive vertical spacing.
5. **EOF Delimiter**: Ends with exactly one trailing newline character (`\n`).
6. **No Placeholders**: Never print `null`, `None`, `N/A`, `unknown`, `[no-author]`, or empty metadata labels (e.g. `**Autor:** `).
7. **No Internal Leakage**: Never output `selected_extractor` name, scoring metrics, or JSON internals in the final document.
@@ -0,0 +1,156 @@
# Data Model: Convert Article JSON to Markdown
**Feature**: `005-convert-json-markdown` | **Date**: 2026-08-21
## 1. Domain Entities & Schemas
### Entity 1: `ArticleInput` (Source JSON)
Represents the raw parsed JSON structure of a single news article.
```text
ArticleInput
├── selected_extractor: string [REQUIRED: "trafilatura" | "newspaper4k" | "readability"]
├── crawled_url: string [OPTIONAL: URL string]
├── page_title: string [OPTIONAL: Page title string]
├── input_meta: object [OPTIONAL]
│ ├── url: string [OPTIONAL]
│ ├── titulo: string [OPTIONAL]
│ ├── subtitulo: string [OPTIONAL]
│ └── quando_publicado: string [OPTIONAL]
├── trafilatura: ExtractorBlock [CONDITIONAL]
├── newspaper4k: ExtractorBlock [CONDITIONAL]
└── readability: ExtractorBlock [CONDITIONAL]
```
#### Validation Rules:
- Root must be a JSON object (dict).
- Root must NOT contain an `articles` key (batch JSON is rejected).
- `selected_extractor` must be exactly one of `"trafilatura"`, `"newspaper4k"`, `"readability"`.
- The object corresponding to `selected_extractor` must exist in `ArticleInput` and contain usable body content.
---
### Entity 2: `ExtractorBlock` (Per-Extractor Data)
Represents the extraction results produced by each individual extractor library.
| Extractor | Primary Body Field | Fallback Body Field | Metadata Fields Available |
|---|---|---|---|
| `trafilatura` | `markdown` (string) | `text` (string) | `title`, `description`, `author`, `date`, `sitename`, `hostname`, `categories`, `tags`, `language`, `image`, `canonical_url` |
| `newspaper4k` | `article_html` (string) | `text` (string) | `title`, `meta_description`, `authors`, `publish_date`, `meta_site_name`, `tags`, `keywords`, `meta_keywords`, `meta_lang`, `top_image`, `canonical_link` |
| `readability` | `cleaned_html` (string) | `cleaned_text` (string) | `title`, `author` |
---
### Entity 3: `ResolvedArticleMetadata`
The normalized, validated, and prioritized metadata extracted from candidate sources.
| Attribute | Type | Mandatory? | Normalization / Validation Rule |
|---|---|:---:|---|
| `title` | `str` | Yes | Unescaped, trimmed, single spaces. Rejection if empty. |
| `original_url` | `str` | Yes | Valid absolute URL with `http://` or `https://` and valid hostname. |
| `subtitle` | `Optional[str]` | No | Omitted if empty, placeholder, or equal to `title` (case-insensitive). |
| `authors` | `List[str]` | No | Deduplicated, case-preserved, no URL entries. Omitted if empty. |
| `publish_date` | `Optional[str]` | No | ISO 8601 string (with timezone) or `YYYY-MM-DD`. Omitted if unparseable. |
| `site_name` | `Optional[str]` | No | Normalized site string or fallback to original URL hostname. |
| `categories` | `List[str]` | No | Deduplicated, non-empty category strings. |
| `tags` | `List[str]` | No | Deduplicated, non-empty tag strings. |
| `keywords` | `List[str]` | No | Deduplicated, non-empty keyword strings. |
| `language` | `Optional[str]` | No | Language code string (e.g. `es`, `pt`, `en`). |
| `top_image` | `Optional[str]` | No | Valid absolute URL with `http://` or `https://`. |
---
### Entity 4: `MarkdownDocument`
The structured representation of the output Markdown file.
```text
MarkdownDocument
├── title_h1: "# " + ResolvedArticleMetadata.title
├── subtitle_block: Optional paragraph
├── metadata_block: Key-value list of bold labels and values
│ ├── **Autor:** {authors joined by ", "}
│ ├── **Publicado em:** {publish_date}
│ ├── **Site:** {site_name}
│ ├── **Categoria:** {categories joined by ", "}
│ ├── **Tags:** {tags joined by ", "}
│ ├── **Palavras-chave:** {keywords joined by ", "}
│ ├── **Idioma:** {language}
│ └── **Fonte original:** [{original_url}]({original_url})
├── top_image_block: Optional "![Imagem principal]({top_image})"
├── separator: "---"
└── body_content: Converted Markdown text (LF line endings, normalized whitespace)
```
---
## 2. Priority Resolution Matrix
```text
Title:
1. SELECIONADO.title
2. input_meta.titulo
3. page_title
4. newspaper4k.title
5. trafilatura.title
6. readability.title
Original URL:
1. input_meta.url
2. crawled_url
3. Canonical URL of SELECIONADO (trafilatura.canonical_url or newspaper4k.canonical_link)
4. trafilatura.canonical_url
5. newspaper4k.canonical_link
Subtitle / Description:
1. Description of SELECIONADO (trafilatura.description or newspaper4k.meta_description)
2. trafilatura.description
3. newspaper4k.meta_description
4. input_meta.subtitulo
Authors:
1. Authors of SELECIONADO (newspaper4k.authors, trafilatura.author, readability.author)
2. newspaper4k.authors
3. trafilatura.author
4. readability.author
Publication Date:
1. Date of SELECIONADO (newspaper4k.publish_date or trafilatura.date)
2. newspaper4k.publish_date
3. trafilatura.date
4. input_meta.quando_publicado
Site Name:
1. Site of SELECIONADO (trafilatura.sitename or newspaper4k.meta_site_name)
2. trafilatura.sitename
3. newspaper4k.meta_site_name
4. trafilatura.hostname
5. Hostname of resolved Original URL
Categories:
1. Categories of SELECIONADO (trafilatura.categories)
2. trafilatura.categories
Tags:
1. Tags of SELECIONADO (trafilatura.tags or newspaper4k.tags)
2. trafilatura.tags
3. newspaper4k.tags
4. newspaper4k.meta_keywords
Keywords:
1. newspaper4k.keywords
2. newspaper4k.meta_keywords
Language:
1. Language of SELECIONADO (trafilatura.language or newspaper4k.meta_lang)
2. trafilatura.language
3. newspaper4k.meta_lang
Top Image:
1. Image of SELECIONADO (newspaper4k.top_image or trafilatura.image)
2. newspaper4k.top_image
3. trafilatura.image
```
+115
View File
@@ -0,0 +1,115 @@
# Implementation Plan: Convert Article JSON to Markdown
**Branch**: `005-convert-json-markdown` | **Date**: 2026-08-21 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/005-convert-json-markdown/spec.md`
---
## Summary
Implement a standalone Python script and modular library component (`scripts/convert_article_to_markdown.py` and supporting functions) that reads a single news article JSON with a `selected_extractor` attribute, performs deterministic metadata resolution across extractor candidates according to strict priority hierarchies, converts HTML bodies to clean Markdown using `markdownify`, strips duplicate H1 title headings, sanitizes body image links, and writes the assembled Markdown document atomically.
---
## Technical Context
**Language/Version**: Python `>=3.10` (tested on 3.10, 3.11, 3.12)
**Primary Dependencies**: `markdownify>=0.13.0`
**Standard Library**: `argparse`, `json`, `os`, `sys`, `pathlib`, `re`, `html`, `urllib.parse`, `datetime`, `email.utils`
**Storage**: Local filesystem (JSON input, Markdown `.md` output)
**Testing**: `pytest>=7.0.0` (Unit tests, CLI integration tests, exact byte comparison fixtures)
**Quality Gates**: `ruff` (linting/formatting), `mypy` (type checking), `pytest`
**Target Platform**: Cross-platform (Windows, Linux, macOS)
**Project Type**: CLI Script / Modular Data Pipeline Stage
**Performance Goals**: `<200ms` per article on standard hardware; 100% byte-for-byte deterministic output
**Constraints**: Fully offline / in-memory execution; no network calls; no LLMs/probabilistic algorithms; transactional atomic file writing
---
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Requirement | Compliance Analysis | Status |
|---|---|---|:---:|
| **I. Library-First** | Self-contained, independently testable modular design | Core parsing, normalization, and assembly logic is modularized into testable pure functions. | ✅ PASS |
| **II. CLI Interface** | Clean CLI, text/file I/O, error reporting to `stderr`, standard exit codes (0, 1, 2) | CLI exposes `-i/--input` and `-o/--output`, prints diagnostics to `stderr`, and handles errors cleanly. | ✅ PASS |
| **III. Test-First** | TDD mandatory: test fixtures, unit tests, and CLI tests before implementation | Comprehensive test suite planned covering all extractors, fallbacks, and edge cases. | ✅ PASS |
| **IV. Integration Testing** | CLI end-to-end integration and fixture contract tests | Golden fixtures for `trafilatura`, `newspaper4k`, and `readability` with exact Markdown match verification. | ✅ PASS |
| **V. Simplicity & Observability** | YAGNI, standard library where possible, `markdownify` for HTML conversion | Lightweight dependencies, pure Python standard library for date/URL/normalization routines. | ✅ PASS |
---
## Project Structure
### Documentation (this feature)
```text
specs/005-convert-json-markdown/
├── spec.md # Feature specification
├── plan.md # This file (/speckit-plan command output)
├── research.md # Technical research & decisions
├── data-model.md # Entities, normalization rules & priority matrix
├── quickstart.md # Quickstart & verification guide
├── checklists/
│ └── requirements.md # Quality checklist
└── contracts/
├── cli-contract.md # CLI interface definition
└── markdown-schema.md # Output Markdown schema contract
```
### Source Code & Test Layout
```text
TextNLPClassifierApp/
├── scripts/
│ └── convert_article_to_markdown.py # CLI entry point and conversion logic
├── tests/
│ ├── fixtures/
│ │ ├── markdown_conversion/ # Test fixtures (JSON inputs & expected MD outputs)
│ │ │ ├── valid_trafilatura.json
│ │ │ ├── valid_trafilatura.md
│ │ │ ├── valid_newspaper4k.json
│ │ │ ├── valid_newspaper4k.md
│ │ │ ├── valid_readability.json
│ │ │ ├── valid_readability.md
│ │ │ ├── batch_articles_invalid.json
│ │ │ └── missing_body_invalid.json
│ └── test_convert_article_to_markdown.py # Unit and integration test suite
├── requirements.txt # Updated with markdownify>=0.13.0
└── README.md # Documenting conversion script usage
```
---
## Implementation Phases
### Phase 0: Outline & Research *(Completed)*
- Resolved technical decisions in [research.md](research.md).
- Confirmed `markdownify` as the HTML-to-Markdown engine and pure Python stdlib for dates/URLs.
### Phase 1: Design & Contracts *(Completed)*
- Defined domain entities and priority resolution matrix in [data-model.md](data-model.md).
- Defined CLI interface contract in [contracts/cli-contract.md](contracts/cli-contract.md).
- Defined Markdown output document contract in [contracts/markdown-schema.md](contracts/markdown-schema.md).
- Created [quickstart.md](quickstart.md) validation instructions.
### Phase 2: Tasks & Implementation Breakdown *(Next: `/speckit-tasks`)*
1. Update `requirements.txt` to include `markdownify>=0.13.0`.
2. Build unit test fixtures for each extractor and error condition under `tests/fixtures/markdown_conversion/`.
3. Implement metadata extraction, normalization, date parsing, and priority resolution functions.
4. Implement HTML-to-Markdown conversion, duplicate H1 heading removal, and image URL sanitation.
5. Implement Markdown document assembly and atomic file writing.
6. Implement CLI argument parsing and error handling in `scripts/convert_article_to_markdown.py`.
7. Write comprehensive test suite in `tests/test_convert_article_to_markdown.py`.
8. Run linter (`ruff`), type checker (`mypy`), and test suite (`pytest`).
9. Update `README.md` with CLI documentation and pipeline examples.
---
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| *None* | All principles satisfied without architectural violations. | N/A |
@@ -0,0 +1,58 @@
# Quickstart: Convert Article JSON to Markdown
**Feature**: `005-convert-json-markdown` | **Date**: 2026-08-21
## 1. Prerequisites & Setup
Ensure the environment has dependencies installed:
```bash
pip install -r requirements.txt
```
Verify that `markdownify` is installed:
```bash
python -c "import markdownify; print(markdownify.__version__)"
```
---
## 2. Running the CLI Tool
### Basic Conversion (Default Output Path)
Convert a single article JSON to `<stem>.md` in the same directory:
```bash
python scripts/convert_article_to_markdown.py -i out/river_plate_extracted_selected_001.json
```
Output generated: `out/river_plate_extracted_selected_001.md`.
### Custom Destination Path
Specify an explicit output path:
```bash
python scripts/convert_article_to_markdown.py \
-i out/river_plate_extracted_selected_001.json \
-o out/markdown/river_plate_article_001.md
```
---
## 3. Verification & Testing
### Run All Unit & Integration Tests
```bash
pytest tests/test_convert_article_to_markdown.py -v
```
### Run Linter & Type Checker
```bash
ruff check scripts/convert_article_to_markdown.py src/ tests/
mypy scripts/convert_article_to_markdown.py
```
+100
View File
@@ -0,0 +1,100 @@
# Research: Convert Article JSON to Markdown
**Feature**: `005-convert-json-markdown` | **Date**: 2026-08-21
## 1. Executive Summary & Goals
This research addresses the design decisions for converting a single news article JSON with `selected_extractor` (`trafilatura`, `newspaper4k`, or `readability`) into a standardized, clean, human-readable Markdown file (`.md`) with deterministic metadata extraction and fallback rules.
---
## 2. Technical Decisions & Research Findings
### Decision 1: HTML-to-Markdown Engine Selection
- **Decision**: Use [`markdownify`](https://github.com/matthewwithanm/python-markdownify) with ATX heading style (`heading_style=ATX`).
- **Rationale**:
- `markdownify` is a lightweight, battle-tested Python library focused exclusively on converting HTML trees to clean Markdown.
- It natively converts `<h1>`–`<h6>` to `#`–`######` (ATX style), parses tables, code blocks, lists, quotes, and inline styles (`<b>`, `<i>`, `<a>`, `<img>`).
- Unlike broader conversion tools like Microsoft MarkItDown or Pandoc, `markdownify` has zero external non-Python dependencies, low overhead, and avoids unnecessary multi-format abstractions.
- **Alternatives Considered**:
- *Microsoft MarkItDown*: Evaluated in PRD; rejected because it pulls broader dependencies (PDF, DOCX, audio, Azure AI) that exceed the scope of pure HTML-to-Markdown conversion.
- *Custom BeautifulSoup converter*: Unnecessary wheel reinvention; maintenance burden for complex HTML elements (nested lists, tables, inline formatting).
---
### Decision 2: Direct Markdown Handling for Trafilatura
- **Decision**: When `selected_extractor == "trafilatura"`, directly use `trafilatura.markdown` (or fallback to `trafilatura.text`) without running HTML-to-Markdown conversion.
- **Rationale**:
- Trafilatura natively emits high-quality Markdown in its extraction output.
- Plain text (`trafilatura.text`) is already valid Markdown without special markup.
- **Alternatives Considered**:
- *Converting Trafilatura's raw HTML*: Inefficient and degrades Trafilatura's native document structural tree.
---
### Decision 3: Metadata Normalization & Priority Resolution Pipeline
- **Decision**: Implement a pure Python deterministic metadata resolver supporting:
- Strict priority tables matching the PRD specification.
- Scalar normalization: HTML entity decoding (`html.unescape`), whitespace trimming/collapsing, placeholder discarding (`null`, `none`, `n/a`, `unknown`, `[no-author]`, `no-author`).
- List normalization: Splitting on `;` if string, trimming elements, filtering out URL-like authors (`http://`, `https://`, `www.`), deduplicating case-insensitively while preserving initial case and original order.
- First-valid-source selection: Pick the first source in priority order that yields a valid, non-empty candidate list without cross-source merging.
- **Rationale**:
- Guarantees 100% deterministic and reproducible metadata output.
- Prevents corrupt or placeholder values from leaking into final editorial documents.
---
### Decision 4: Date Parsing Strategy (ISO 8601 & RFC 2822)
- **Decision**: Use Python's standard library `datetime.fromisoformat` and `email.utils.parsedate_to_datetime` / standard datetime parsing routines without heavy external dependencies.
- **Rationale**:
- All input dates observed from extractors follow ISO 8601 (e.g. `2026-08-20T00:36:33-03:00` or `2026-08-20T03:36:33Z`) or RFC 2822 (e.g. `Thu, 20 Aug 2026 00:36:33 -0300`).
- `datetime.fromisoformat()` in Python 3.11+ handles full ISO 8601 with timezone offsets and 'Z'.
- `email.utils.parsedate_to_datetime()` standard library natively handles RFC 2822 dates.
- If a date cannot be parsed, the candidate is discarded and resolution advances to the next source in priority order.
- **Alternatives Considered**:
- *dateparser / python-dateutil*: Adds extra heavy dependency; unnecessary given the standardized datetime formats emitted by upstream extractors.
---
### Decision 5: URL Validation & Media Filtering
- **Decision**:
- Validate all URLs with `urllib.parse.urlparse`: Scheme must be `http` or `https`, and `netloc` (hostname) must be non-empty.
- In Markdown body: Filter out images with relative URLs, empty URLs, or `data:` URIs.
- Deduplicate identical image URLs in the body, keeping only the first occurrence.
- **Rationale**:
- Prevents broken local references or bloated base64 data URIs in downstream pipelines.
---
### Decision 6: Duplicate Title Heading (H1) Stripping
- **Decision**:
- Check the first top-level ATX heading (`# ...`) in the converted body.
- If its text matches the resolved article title (after HTML entity decoding, whitespace collapsing, and case-insensitive comparison), remove that H1 line and preceding/following whitespace.
- Preserve all subsequent H1/H2/H3 headings in the body.
- **Rationale**:
- Many news articles embed the title in `<h1>` inside the article HTML. Since our Markdown schema places `# <Resolved Title>` at the top of the document, stripping the redundant body H1 avoids awkward repeated headings.
---
### Decision 7: Atomic File Writing & Error Resilience
- **Decision**:
- Write Markdown output to a temporary file in the same directory (`.<output_filename>.tmp`) and atomically replace the destination using `os.replace` (or `pathlib.Path.replace`).
- If any error or validation exception occurs during processing, clean up the temporary file and exit with code `1`, leaving any pre-existing target file untouched.
- **Rationale**:
- Guarantees transactional file operations in unattended automated batch pipelines.
---
## 3. Technology Stack & Dependencies
- **Runtime**: Python `>=3.10` (tested on 3.10, 3.11, 3.12)
- **New Dependency**: `markdownify>=0.13.0`
- **Standard Library Modules**: `argparse`, `json`, `os`, `sys`, `pathlib`, `re`, `html`, `urllib.parse`, `datetime`, `email.utils`
- **Testing & Quality**: `pytest`, `ruff`, `mypy`
+137
View File
@@ -0,0 +1,137 @@
# Feature Specification: Convert Article JSON to Markdown
**Feature Branch**: `005-convert-json-markdown`
**Created**: 2026-08-21
**Status**: Draft
**Input**: User description: "a partir do markdown docs/prd_convert_json_markdown.md"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Single Article JSON to Clean Markdown Conversion (Priority: P1)
As a content pipeline operator, I want to convert a single validated article JSON file containing a `selected_extractor` into a clean, structured Markdown document so that downstream consumers and publishing pipelines receive consistent, human-readable, and well-formatted editorial content.
**Why this priority**: This is the core purpose of the feature. Converting an extracted article's primary content and required fields (Title, Original URL, Body) into a standardized Markdown file is the foundational deliverable.
**Independent Test**: Can be tested independently by providing a single article JSON with `selected_extractor` (for each supported extractor: `trafilatura`, `newspaper4k`, `readability`), running the CLI conversion, and verifying that the generated `.md` file matches expected structure, formatting, and content.
**Acceptance Scenarios**:
1. **Given** a valid JSON of a single article with `selected_extractor` set to `trafilatura` and `trafilatura.markdown` populated, **When** conversion executes, **Then** the resulting Markdown file uses the Trafilatura Markdown content directly without incorporating text from other extractors.
2. **Given** a valid JSON of a single article with `selected_extractor` set to `newspaper4k` and `newspaper4k.article_html` populated, **When** conversion executes, **Then** the HTML content is converted to Markdown with ATX headings and editorial structure preserved.
3. **Given** a valid JSON of a single article with `selected_extractor` set to `readability` and `readability.cleaned_html` populated, **When** conversion executes, **Then** the cleaned HTML is converted to Markdown preserving formatting and content hierarchy.
4. **Given** a valid JSON of a single article where the structured body of the selected extractor is missing or empty but its raw text is available, **When** conversion executes, **Then** the raw text from the same selected extractor is used as fallback.
---
### User Story 2 - Deterministic Metadata Resolution and Fallback (Priority: P2)
As a pipeline operator, I want the system to deterministically resolve optional and required metadata across all available extractor and input fields according to a strict priority hierarchy, so that missing metadata in the selected extractor is enriched from secondary sources without non-deterministic or probabilistic behavior.
**Why this priority**: While the body text must strictly come from the selected extractor, metadata (authors, publication date, site name, categories, tags, keywords, language, top image, subtitle) often varies across extractors. Deterministic fallback guarantees maximum metadata completeness while maintaining reproducible output.
**Independent Test**: Can be tested with synthetic and real JSON fixtures containing missing metadata in the selected extractor but present in secondary extractors or `input_meta`, verifying that the output metadata lines follow the defined hierarchy exactly and omit empty fields/placeholders cleanly.
**Acceptance Scenarios**:
1. **Given** a selected extractor lacking author or publication date, but secondary extractor blocks or `input_meta` containing valid candidates, **When** conversion executes, **Then** the first valid candidate in the priority order is rendered in the metadata section.
2. **Given** metadata candidates with surrounding whitespace, HTML entities, or known placeholder strings (e.g., `null`, `none`, `n/a`, `unknown`, `[no-author]`), **When** conversion executes, **Then** values are sanitized and placeholders are treated as absent, causing fallback to the next candidate or total omission.
3. **Given** an article where no valid candidates exist for optional fields (e.g., tags, category, subtitle), **When** conversion executes, **Then** those metadata lines are completely omitted without generating blank lines, empty labels, or placeholder text.
4. **Given** a body containing a top-level H1 header identical to the resolved article title, **When** conversion executes, **Then** the duplicate H1 is removed from the body to prevent repeating the title.
---
### User Story 3 - CLI Usability, Validation, and Atomic Output (Priority: P3)
As a DevOps or system integration engineer, I want the converter tool to operate via a clear command-line interface with custom output path support, clear exit codes, detailed error feedback on `stderr`, and atomic file writing, so that pipeline automation can safely run unattended and never produce corrupt or partial files.
**Why this priority**: Reliable automation in unattended batch pipelines requires deterministic exit codes, zero corrupt state on failure, and safe atomic file writing.
**Independent Test**: Can be tested by invoking the CLI with valid arguments, custom `-o` paths, invalid inputs (malformed JSON, batch JSON with `articles` array, missing mandatory fields), verifying stdout/stderr streams, file system state, and process exit codes (0, 1, 2).
**Acceptance Scenarios**:
1. **Given** a valid single-article JSON input and no `-o` argument, **When** the CLI runs, **Then** it produces `<input_stem>.md` atomically in the same directory and exits with code `0`.
2. **Given** an invalid input JSON containing a root `articles` array (batch file), **When** the CLI runs, **Then** it terminates with exit code `1`, logs a descriptive error message to `stderr`, and writes no output file.
3. **Given** a pre-existing output file and an error occurring during validation or conversion, **When** the CLI terminates, **Then** the pre-existing output file remains completely unmodified and no temporary files linger.
4. **Given** missing or invalid CLI arguments, **When** the CLI runs, **Then** it exits with code `2` as standard for argument parsing errors.
---
### Edge Cases
- **Batch JSON Input**: If the JSON root contains an `articles` key, the process fails immediately with exit code `1` and instructs the user that only single-article objects are accepted.
- **Strict Extractor Isolation for Body**: If the `selected_extractor` has no usable body (both HTML/Markdown and plain text are empty), the process fails with exit code `1`. The system MUST NEVER fall back to another extractor for the body.
- **Unresolved Mandatory Metadata**: If title, valid absolute original URL (`http`/`https`), or body cannot be resolved from any candidate source, conversion fails with exit code `1`.
- **Invalid Body Images**: Images in the converted body with relative paths, empty URLs, or `data:` URIs are stripped. Duplicate image URLs within the body are deduplicated, keeping the first occurrence.
- **Date Formatting Variety**: Dates formatted as ISO 8601 or RFC 2822 are parsed and standardized to ISO 8601 preserving time zone offsets, or `YYYY-MM-DD` if date-only. Unparseable dates fall back to the next candidate.
- **List and String Flexibility**: Author, tag, keyword, and category fields provided as either lists or semicolon-delimited strings are normalized, deduplicated case-insensitively (preserving original casing of the first instance), and stripped of invalid entries (such as author strings starting with `http://`, `https://`, or `www.`).
- **Subtitle Equal to Title**: If the resolved subtitle/description matches the resolved title (case-insensitively after normalization), the subtitle is omitted from the output.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST accept an input path to a UTF-8 JSON file representing a single article object.
- **FR-002**: System MUST reject root structures containing an `articles` key or root elements that are not JSON objects, exiting with code `1`.
- **FR-003**: System MUST require and validate `selected_extractor` to be one of: `trafilatura`, `newspaper4k`, or `readability`. Any other value or missing key MUST terminate with exit code `1`.
- **FR-004**: System MUST extract the article body exclusively from the chosen `selected_extractor` using its designated primary content field or fallback text field within that same extractor:
- `trafilatura`: primary `trafilatura.markdown`, fallback `trafilatura.text`
- `newspaper4k`: primary `newspaper4k.article_html` (converted to Markdown), fallback `newspaper4k.text`
- `readability`: primary `readability.cleaned_html` (converted to Markdown), fallback `readability.cleaned_text`
- **FR-005**: System MUST NOT perform cross-extractor fallback for article body content; if the selected extractor's content is empty or unusable, processing MUST fail with exit code `1`.
- **FR-006**: System MUST resolve mandatory fields (Title, Original URL, Body), failing with exit code `1` if any mandatory field cannot be resolved to a non-empty valid value.
- **FR-007**: System MUST validate original URLs and top image URLs to ensure they are absolute URLs with `http` or `https` schemes.
- **FR-008**: System MUST resolve metadata fields deterministically using the defined priority order:
- **Title**: `SELECIONADO.title` → `input_meta.titulo` → `page_title` → `newspaper4k.title` → `trafilatura.title` → `readability.title`
- **Original URL**: `input_meta.url` → `crawled_url` → canonical URL of selected → `trafilatura.canonical_url` → `newspaper4k.canonical_link`
- **Subtitle / Description**: description of selected → `trafilatura.description` → `newspaper4k.meta_description` → `input_meta.subtitulo`
- **Authors**: author(s) of selected → `newspaper4k.authors` → `trafilatura.author` → `readability.author`
- **Publication Date**: date of selected → `newspaper4k.publish_date` → `trafilatura.date` → `input_meta.quando_publicado`
- **Site Name**: site name of selected → `trafilatura.sitename` → `newspaper4k.meta_site_name` → `trafilatura.hostname` → hostname from original URL
- **Categories**: categories of selected → `trafilatura.categories`
- **Tags**: tags of selected → `trafilatura.tags` → `newspaper4k.tags` → `newspaper4k.meta_keywords`
- **Keywords**: `newspaper4k.keywords` → `newspaper4k.meta_keywords`
- **Language**: language of selected → `trafilatura.language` → `newspaper4k.meta_lang`
- **Top Image**: image of selected → `newspaper4k.top_image` → `trafilatura.image`
- **FR-009**: System MUST normalize scalar metadata strings by decoding HTML entities, trimming leading/trailing whitespace, collapsing internal consecutive whitespace, and discarding known placeholders (`null`, `none`, `n/a`, `unknown`, `[no-author]`, `no-author`).
- **FR-010**: System MUST normalize list metadata (authors, categories, tags, keywords) from arrays or semicolon-separated strings, trimming items, discarding author entries that are URLs, deduplicating case-insensitively while preserving original order and initial casing, and picking the first valid source list without merging lists across different sources.
- **FR-011**: System MUST parse publication dates in ISO 8601 or RFC 2822 formats and format them as standard ISO 8601 (preserving time zone) or `YYYY-MM-DD` (for date-only values).
- **FR-012**: System MUST convert HTML bodies to Markdown using ATX headings (`#`, `##`, `###`), preserving paragraph structure, formatting (bold, italic), lists, blockquotes, code blocks, tables, and valid body images.
- **FR-013**: System MUST strip invalid body images (relative paths, `data:` URIs, empty URLs) and deduplicate repeated occurrences of identical image URLs in the body.
- **FR-014**: System MUST remove the initial H1 heading from the converted body if it matches the resolved article title (after HTML entity decoding and whitespace/case normalization).
- **FR-015**: System MUST assemble the final Markdown document in strict section order:
1. `# [Title]`
2. Subtitle/Description (omitted if empty or equal to title)
3. Metadata key-value block (`**Autor:**`, `**Publicado em:**`, `**Site:**`, `**Categoria:**`, `**Tags:**`, `**Palavras-chave:**`, `**Idioma:**`, `**Fonte original:** [URL](URL)`)
4. Main image `![Imagem principal](URL)` (omitted if invalid or absent)
5. Horizontal rule separator `---`
6. Converted body content
- **FR-016**: System MUST format the final Markdown with `LF` line endings, a single trailing newline, no trailing whitespace per line, and at most two consecutive blank lines.
- **FR-017**: System MUST implement atomic file writing (write to temporary file then replace target atomically) and ensure existing target files remain untouched if processing fails.
- **FR-018**: System MUST provide a CLI script `scripts/convert_article_to_markdown.py` supporting `-i/--input` (mandatory) and `-o/--output` (optional, defaulting to `<input_stem>.md`), returning exit codes `0` (success), `1` (runtime/validation/conversion error), and `2` (CLI argument error), with diagnostics written to `stderr`.
### Key Entities
- **Article JSON Input**: The source data object representing a single crawled and extracted news article containing extractor blocks (`trafilatura`, `newspaper4k`, `readability`), `selected_extractor` tag, and optional crawl metadata (`input_meta`, `crawled_url`, `page_title`).
- **Resolved Article Metadata**: The normalized, sanitized, and prioritized editorial properties extracted across candidate sources (Title, Original URL, Subtitle, Authors, Publication Date, Site Name, Categories, Tags, Keywords, Language, Top Image).
- **Output Markdown Document**: The final UTF-8 formatted document containing structured header metadata, visual assets, separator, and normalized editorial article body text.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of single-article JSON files with valid required fields generate valid Markdown documents adhering to the prescribed section hierarchy.
- **SC-002**: 100% byte-for-byte determinism: identical JSON inputs processed across multiple runs produce identical Markdown output files.
- **SC-003**: 0% cross-extractor body pollution: in all test cases, body text originates strictly and solely from the specified `selected_extractor`.
- **SC-004**: 0% placeholder leakage: no output document contains `null`, `None`, `N/A`, `unknown`, `[no-author]`, or empty metadata labels.
- **SC-005**: 100% atomic integrity: failed conversions leave zero leftover temporary files and never corrupt or overwrite existing target files.
- **SC-006**: Sub-second execution: conversion of a single standard news article JSON completes in under 200ms in a local execution environment.
## Assumptions
- The input JSON is UTF-8 encoded and represents a single article item extracted from upstream crawlers/extractors.
- Python 3.10+ standard libraries and `markdownify` library are available in the runtime environment.
- No network requests, browser automation, or external AI/LLM services are needed or permitted during conversion.
- Extractor-specific JSON schemas match the structures produced by previous pipeline stages (`003-article-content-extractor` and `004-deterministic-content-selection`).
- All execution and file manipulation occurs on the local filesystem.
+139
View File
@@ -0,0 +1,139 @@
# Tasks: Convert Article JSON to Markdown
**Branch**: `005-convert-json-markdown` | **Feature**: Convert Article JSON to Markdown
**Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Data Model**: [data-model.md](data-model.md)
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization, dependency management, and test fixture directory setup
- [X] T001 Add `markdownify>=0.13.0` to `requirements.txt`
- [X] T002 [P] Create fixtures directory structure in `tests/fixtures/markdown_conversion/`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Golden fixtures, negative test fixtures, and baseline test infrastructure that MUST be complete before user stories begin
- [X] T003 [P] Create Golden Test Fixtures for Trafilatura in `tests/fixtures/markdown_conversion/valid_trafilatura.json` and `tests/fixtures/markdown_conversion/valid_trafilatura.md`
- [X] T004 [P] Create Golden Test Fixtures for Newspaper4k in `tests/fixtures/markdown_conversion/valid_newspaper4k.json` and `tests/fixtures/markdown_conversion/valid_newspaper4k.md`
- [X] T005 [P] Create Golden Test Fixtures for Readability in `tests/fixtures/markdown_conversion/valid_readability.json` and `tests/fixtures/markdown_conversion/valid_readability.md`
- [X] T006 [P] Create Negative Test Fixtures (`batch_articles_invalid.json`, `corrupt_json_invalid.json`, `missing_extractor_invalid.json`, `missing_body_invalid.json`, `missing_title_invalid.json`, `invalid_url_invalid.json`) in `tests/fixtures/markdown_conversion/`
**Checkpoint**: Foundational test fixtures ready. User story implementation can begin in strict TDD order.
---
## Phase 3: User Story 1 - Single Article JSON to Clean Markdown Conversion (Priority: P1) 🎯 MVP
**Goal**: Convert a single article JSON into a clean Markdown document using exclusively the selected extractor (`trafilatura`, `newspaper4k`, `readability`), supporting direct Markdown reuse and HTML-to-Markdown conversion with intra-extractor fallback.
**Independent Test**: Provide single-article JSON fixtures for each extractor and verify that the resulting Markdown body text matches the selected extractor's content without cross-extractor leakage.
### Tests for User Story 1 (TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementing**
- [X] T007 [P] [US1] Write unit and integration tests for extractor body resolution, strict extractor isolation, and HTML-to-Markdown conversion in `tests/test_convert_article_to_markdown.py`
### Implementation for User Story 1
- [X] T008 [US1] Implement body resolution and strict extractor isolation logic (prohibiting cross-extractor fallback) in `scripts/convert_article_to_markdown.py`
- [X] T009 [US1] Implement HTML-to-Markdown conversion using `markdownify` (ATX headings) and intra-extractor fallback (`markdown`/`html` → `text`) in `scripts/convert_article_to_markdown.py`
**Checkpoint**: At this point, User Story 1 is fully functional and testable independently (MVP ready).
---
## Phase 4: User Story 2 - Deterministic Metadata Resolution and Fallback (Priority: P2)
**Goal**: Resolve mandatory and optional metadata across all extractor candidates and input metadata according to strict priority hierarchies, normalizing strings, lists, dates, and URLs, stripping duplicate H1 headers, and formatting the final Markdown structure.
**Independent Test**: Pass articles with missing metadata in the selected extractor but present in secondary sources; verify that resolved metadata strictly follows priority chains, discards placeholders, sanitizes dates/URLs/images, and formats output accurately.
### Tests for User Story 2 (TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementing**
- [X] T010 [P] [US2] Write unit tests for metadata priority chains, scalar normalization, list deduplication, date parsing (ISO 8601 / RFC 2822), URL validation, duplicate H1 heading removal, and body image sanitation in `tests/test_convert_article_to_markdown.py`
### Implementation for User Story 2
- [X] T011 [US2] Implement scalar and list normalizers (HTML unescape, whitespace collapsing, placeholder filtering, author URL filtering, case-insensitive deduplication) in `scripts/convert_article_to_markdown.py`
- [X] T012 [US2] Implement ISO 8601 and RFC 2822 date parser (with timezone preservation and `YYYY-MM-DD` date-only output) and URL validator in `scripts/convert_article_to_markdown.py`
- [X] T013 [US2] Implement deterministic metadata priority resolution matrix in `scripts/convert_article_to_markdown.py`
- [X] T014 [US2] Implement duplicate H1 title heading stripper and body image link sanitizer (removing relative/`data:`/empty URLs and deduplicating repeats) in `scripts/convert_article_to_markdown.py`
- [X] T015 [US2] Implement final Markdown document layout assembler (Header, Subtitle, Metadata key-values, Top Image, Separator `---`, Body) with LF line endings in `scripts/convert_article_to_markdown.py`
**Checkpoint**: At this point, User Stories 1 and 2 work together and pass all unit/integration tests.
---
## Phase 5: User Story 3 - CLI Usability, Validation, and Atomic Output (Priority: P3)
**Goal**: Provide a production-ready CLI interface with `-i`/`--input` and `-o`/`--output` flags, standard exit codes (0, 1, 2), error logging to `stderr`, and atomic file replacement with transactional error cleanup.
**Independent Test**: Execute the CLI against valid JSON, invalid JSON, batch `articles` array JSON, and missing files; verify exit codes, stderr diagnostics, and target file integrity.
### Tests for User Story 3 (TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementing**
- [X] T016 [P] [US3] Write CLI integration tests covering `-i`/`-o` flags, exit codes (`0`, `1`, `2`), stderr logging, rejection of batch `articles` JSON, and atomic replacement rollback on failure in `tests/test_convert_article_to_markdown.py`
### Implementation for User Story 3
- [X] T017 [US3] Implement CLI argument parsing, input JSON validation (object root, rejection of `articles` key, `selected_extractor` check), and diagnostic logging to `stderr` in `scripts/convert_article_to_markdown.py`
- [X] T018 [US3] Implement transactional atomic file writing (`.<output>.tmp` + `os.replace`) with complete cleanup on failure in `scripts/convert_article_to_markdown.py`
**Checkpoint**: All user stories are fully implemented, resilient, and verified.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Quality gate verification, Golden Fixture byte-for-byte validation, and documentation
- [X] T019 [P] Run full test suite (`pytest tests/test_convert_article_to_markdown.py -v`) and verify 100% exact Golden Fixtures matching
- [X] T020 [P] Run code quality and type checks (`ruff check scripts/ src/ tests/` and `mypy scripts/convert_article_to_markdown.py`)
- [X] T021 Update `README.md` with conversion CLI documentation, pipeline usage examples, and argument references
- [X] T022 Run quickstart validation scenarios from `specs/005-convert-json-markdown/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately.
- **Foundational (Phase 2)**: Depends on Setup (Phase 1) — BLOCKS all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational (Phase 2) — Core MVP.
- **User Story 2 (Phase 4)**: Depends on User Story 1 (Phase 3).
- **User Story 3 (Phase 5)**: Depends on User Story 2 (Phase 4).
- **Polish (Phase 6)**: Depends on all user stories (Phases 3–5) being complete.
---
## Parallel Opportunities
- **Phase 1**: `T002` [P] can run in parallel with `T001`.
- **Phase 2**: `T003` [P], `T004` [P], `T005` [P], and `T006` [P] can all be created in parallel.
- **Phase 3**: `T007` [P] (tests) can be created in parallel with fixture setup.
- **Phase 4**: `T010` [P] (tests) can be written before implementation.
- **Phase 5**: `T016` [P] (CLI tests) can be written before CLI wiring.
- **Phase 6**: `T019` [P] (pytest) and `T020` [P] (ruff/mypy) can run in parallel.
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1 (Setup) and Phase 2 (Foundational Fixtures).
2. Complete Phase 3 (User Story 1: TDD tests `T007` → Implementation `T008`, `T009`).
3. Validate independent execution of User Story 1.
### Incremental Delivery
1. Foundation Ready → MVP (US1: Body Conversion).
2. Deliver US2 (Deterministic Metadata & Sanitization).
3. Deliver US3 (CLI, Error Handling & Atomic Output).
4. Run Polish & Quality Gates (US1 + US2 + US3 verified with 100% tests passing).
@@ -0,0 +1,9 @@
{
"source_file": "batch.json",
"articles": [
{
"selected_extractor": "trafilatura",
"trafilatura": { "markdown": "Some content" }
}
]
}
@@ -0,0 +1 @@
{ "selected_extractor": "trafilatura", "trafilatura": { "markdown": "broken json...
@@ -0,0 +1,8 @@
{
"selected_extractor": "trafilatura",
"crawled_url": "ftp://invalid-scheme.com/article",
"trafilatura": {
"title": "Valid Title",
"markdown": "Body text here."
}
}
@@ -0,0 +1,14 @@
{
"selected_extractor": "trafilatura",
"crawled_url": "https://example.com/article",
"page_title": "Example Title",
"trafilatura": {
"title": "Example Title",
"markdown": null,
"text": ""
},
"newspaper4k": {
"title": "Example Title",
"article_html": "<p>Content from another extractor</p>"
}
}
@@ -0,0 +1,8 @@
{
"crawled_url": "https://example.com/article",
"page_title": "Example Title",
"trafilatura": {
"title": "Example Title",
"markdown": "Body text"
}
}
@@ -0,0 +1,8 @@
{
"selected_extractor": "trafilatura",
"crawled_url": "https://example.com/article",
"trafilatura": {
"title": " ",
"markdown": "Body text here."
}
}
@@ -0,0 +1,27 @@
{
"selected_extractor": "newspaper4k",
"crawled_url": "https://www.minutouno.com/deportes/quien-es-paz-zubiri.html",
"newspaper4k": {
"title": "Quién es Paz Zubiri, la relatora de Fox Sports",
"authors": [
"Redacción Deportes",
"Juan Pérez"
],
"publish_date": "2026-08-19T17:03:00-03:00",
"meta_description": "Quién es Paz Zubiri, la relatora de Fox Sports que fue arquera de River.",
"meta_site_name": "MinutoUno",
"meta_keywords": [
"Fox Sports",
"River Plate"
],
"keywords": [
"relatora",
"fútbol"
],
"meta_lang": "es",
"top_image": "https://www.minutouno.com/files/paz-zubiri.jpg",
"canonical_link": "https://www.minutouno.com/deportes/quien-es-paz-zubiri.html",
"article_html": "<div><h1>Quién es Paz Zubiri, la relatora de Fox Sports</h1><p>Fue arquera de River y hoy brilla en transmisiones deportivas.</p><p>Tiene un canal de <b>YouTube</b> muy popular.</p></div>",
"text": "Quién es Paz Zubiri, la relatora de Fox Sports\n\nFue arquera de River y hoy brilla en transmisiones deportivas.\n\nTiene un canal de YouTube muy popular."
}
}
+19
View File
@@ -0,0 +1,19 @@
# Quién es Paz Zubiri, la relatora de Fox Sports
Quién es Paz Zubiri, la relatora de Fox Sports que fue arquera de River.
**Autor:** Redacción Deportes, Juan Pérez
**Publicado em:** 2026-08-19T17:03:00-03:00
**Site:** MinutoUno
**Tags:** Fox Sports, River Plate
**Palavras-chave:** relatora, fútbol
**Idioma:** es
**Fonte original:** [https://www.minutouno.com/deportes/quien-es-paz-zubiri.html](https://www.minutouno.com/deportes/quien-es-paz-zubiri.html)
![Imagem principal](https://www.minutouno.com/files/paz-zubiri.jpg)
---
Fue arquera de River y hoy brilla en transmisiones deportivas.
Tiene un canal de **YouTube** muy popular.
@@ -0,0 +1,14 @@
{
"selected_extractor": "readability",
"crawled_url": "https://www.clarin.com/deportes/analisis-tactico-river.html",
"input_meta": {
"subtitulo": "El planteo defensivo fue determinante para mantener el cero.",
"quando_publicado": "Wed, 19 Aug 2026 21:00:00 -0300"
},
"readability": {
"title": "Análisis táctico del partido de River en Bogotá",
"author": "Carlos Bilardo",
"cleaned_html": "<div><h1>Análisis táctico del partido de River en Bogotá</h1><p>El planteo defensivo fue determinante para mantener el cero.</p><blockquote>Un punto valioso de visitante.</blockquote></div>",
"cleaned_text": "Análisis táctico del partido de River en Bogotá\n\nEl planteo defensivo fue determinante para mantener el cero.\n\nUn punto valioso de visitante."
}
}
+14
View File
@@ -0,0 +1,14 @@
# Análisis táctico del partido de River en Bogotá
El planteo defensivo fue determinante para mantener el cero.
**Autor:** Carlos Bilardo
**Publicado em:** 2026-08-19T21:00:00-03:00
**Site:** www.clarin.com
**Fonte original:** [https://www.clarin.com/deportes/analisis-tactico-river.html](https://www.clarin.com/deportes/analisis-tactico-river.html)
---
El planteo defensivo fue determinante para mantener el cero.
> Un punto valioso de visitante.
@@ -0,0 +1,29 @@
{
"selected_extractor": "trafilatura",
"input_meta": {
"url": "https://www.tycsports.com/river-plate/los-puntajes-de-river-vs-independiente-santa-fe.html",
"titulo": "Los puntajes de River",
"quando_publicado": "2026-08-20T03:27:26Z"
},
"crawled_url": "https://www.tycsports.com/river-plate/los-puntajes-de-river-vs-independiente-santa-fe.html",
"page_title": "Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports",
"trafilatura": {
"title": "Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports",
"author": "Ernesto Provitilo",
"date": "2026-08-20T00:36:33-03:00",
"description": "Con Beltrán y Otamendi como puntos más altos, el Millonario rescató un empate en Bogotá.",
"sitename": "TyC Sports",
"hostname": "tycsports.com",
"categories": [
"River Plate",
"Copa Sudamericana"
],
"tags": [
"Fútbol; River Plate"
],
"language": "es",
"canonical_url": "https://www.tycsports.com/river-plate/los-puntajes-de-river-vs-independiente-santa-fe.html",
"image": "https://media.tycsports.com/files/2026/08/20/980147/la-formacion-de-river.webp",
"markdown": "# Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports\n\nCon Beltrán y Otamendi como puntos más altos, el Millonario rescató un empate en Bogotá.\n\n## SANTIAGO BELTRÁN - 6\n\nLlega al aprobado por el arco en cero.\n\n![Foto](https://media.tycsports.com/files/foto.jpg)"
}
}
+23
View File
@@ -0,0 +1,23 @@
# Los puntajes de River vs. Independiente Santa Fe, por la Copa Sudamericana - TyC Sports
Con Beltrán y Otamendi como puntos más altos, el Millonario rescató un empate en Bogotá.
**Autor:** Ernesto Provitilo
**Publicado em:** 2026-08-20T00:36:33-03:00
**Site:** TyC Sports
**Categoria:** River Plate, Copa Sudamericana
**Tags:** Fútbol, River Plate
**Idioma:** es
**Fonte original:** [https://www.tycsports.com/river-plate/los-puntajes-de-river-vs-independiente-santa-fe.html](https://www.tycsports.com/river-plate/los-puntajes-de-river-vs-independiente-santa-fe.html)
![Imagem principal](https://media.tycsports.com/files/2026/08/20/980147/la-formacion-de-river.webp)
---
Con Beltrán y Otamendi como puntos más altos, el Millonario rescató un empate en Bogotá.
## SANTIAGO BELTRÁN - 6
Llega al aprobado por el arco en cero.
![Foto](https://media.tycsports.com/files/foto.jpg)
+758
View File
@@ -0,0 +1,758 @@
"""
Suíte de Testes Automatizados para Conversão de Artigo JSON para Markdown.
Cobre 100% dos Requisitos Funcionais (FR-001 a FR-018), Requisitos Não Funcionais (RNF-001 a RNF-006),
Critérios de Aceite (CA-001 a CA-013), Casos de Teste do PRD (CT-001 a CT-012),
Matriz de Erros (12 condições), Testes Unitários de Prioridade/Normalização,
Testes de Integração de Arquivo e Testes E2E de Pipeline via subprocess.
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
from pathlib import Path
import pytest
from scripts.convert_article_to_markdown import (
assemble_markdown_document,
clean_body_images,
convert_article,
convert_html_to_markdown,
normalize_date,
normalize_list,
normalize_scalar,
parse_arguments,
remove_duplicate_initial_h1,
resolve_article_body,
resolve_article_metadata,
validate_url,
)
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "markdown_conversion"
SCRIPT_PATH = Path(__file__).parent.parent / "scripts" / "convert_article_to_markdown.py"
# ==============================================================================
# 1. Testes Unitários de Normalização e Sanitização Escalar
# ==============================================================================
def test_normalize_scalar_complex_html_entities():
"""Valida decodificação de entidades HTML nomeadas e numéricas."""
assert (
normalize_scalar("River &amp; Boca &quot;Supercl&aacute;sico&quot;")
== 'River & Boca "Superclásico"'
)
assert normalize_scalar("Pre&ccedil;o: R&#36; 50&#44;00 &euro;") == "Preço: R$ 50,00 €"
def test_normalize_scalar_whitespace_collapsing():
"""Valida colapso de tabs, quebras de linha e espaços múltiplos em um único espaço."""
assert (
normalize_scalar(" Texto com \t\t múltiplos \n\n espaços ")
== "Texto com múltiplos espaços"
)
@pytest.mark.parametrize(
"placeholder",
[
"null",
"Null",
"NULL",
"none",
"None",
"NONE",
"n/a",
"N/A",
"N/a",
"unknown",
"Unknown",
"UNKNOWN",
"[no-author]",
"[No-Author]",
"[NO-AUTHOR]",
"no-author",
"No-Author",
"NO-AUTHOR",
],
)
def test_normalize_scalar_placeholders_discarded(placeholder: str):
"""Garante que todos os placeholders documentados no PRD sejam descartados (retornando None)."""
assert normalize_scalar(placeholder) is None
assert normalize_scalar(f" {placeholder} ") is None
def test_normalize_scalar_non_string_types():
"""Valida que entradas não string retornem None de forma segura."""
assert normalize_scalar(None) is None
assert normalize_scalar(12345) is None
assert normalize_scalar(["lista"]) is None
assert normalize_scalar({"chave": "valor"}) is None
# ==============================================================================
# 2. Testes Unitários de Normalização de Listas
# ==============================================================================
def test_normalize_list_semicolon_and_comma_split():
"""Testa divisão por ponto e vírgula na string e vírgulas em elementos de lista para tags/categorias."""
raw_str = "Futebol; Copa Libertadores; Conmebol; Notícias de Hoje"
expected_str = ["Futebol", "Copa Libertadores", "Conmebol", "Notícias de Hoje"]
assert normalize_list(raw_str) == expected_str
raw_list = ["Futebol", "Copa Libertadores, Conmebol", "Notícias de Hoje"]
expected_list = ["Futebol", "Copa Libertadores", "Conmebol", "Notícias de Hoje"]
assert normalize_list(raw_list) == expected_list
def test_normalize_list_author_url_filtering():
"""Garante que URLs em campos de autor sejam estritamente descartadas."""
raw_authors = [
"Ernesto Provitilo",
"https://twitter.com/eprovitilo",
"http://www.instagram.com/reporter",
"www.tycsports.com/autor",
"Juan Pablo Varsky",
]
result = normalize_list(raw_authors, is_author=True)
assert result == ["Ernesto Provitilo", "Juan Pablo Varsky"]
def test_normalize_list_deduplication_preserves_case_and_order():
"""Testa deduplicação case-insensitive preservando a grafia e ordem da primeira ocorrência."""
items = ["River Plate", "Boca Juniors", "river plate", "RIVER PLATE", "boca juniors", "Racing"]
assert normalize_list(items) == ["River Plate", "Boca Juniors", "Racing"]
def test_normalize_list_empty_and_invalid():
"""Testa comportamento com listas vazias, nulas ou contendo apenas placeholders."""
assert normalize_list([]) == []
assert normalize_list(None) == []
assert normalize_list(["n/a", "unknown", "[no-author]", " "]) == []
# ==============================================================================
# 3. Testes Unitários de Parsing de Datas
# ==============================================================================
def test_normalize_date_iso_8601_variants():
"""Valida parsing de datas ISO 8601 em múltiplos formatos e fusos."""
assert normalize_date("2026-08-20T00:36:33-03:00") == "2026-08-20T00:36:33-03:00"
assert normalize_date("2026-08-20T03:36:33+00:00") == "2026-08-20T03:36:33+00:00"
# Data pura sem hora
assert normalize_date("2026-08-20") == "2026-08-20"
def test_normalize_date_rfc_2822_variants():
"""Valida parsing de datas no formato RFC 2822 (usado em feeds RSS e cabeçalhos HTTP)."""
d1 = normalize_date("Thu, 20 Aug 2026 03:27:26 GMT")
assert d1 is not None and "2026-08-20" in d1
d2 = normalize_date("Wed, 19 Aug 2026 21:00:00 -0300")
assert d2 is not None and "2026-08-19" in d2
def test_normalize_date_invalid_and_placeholders():
"""Garante que datas inválidas ou placeholders retornem None sem lançar exceção não tratada."""
assert normalize_date("data-invalida") is None
assert normalize_date("2026/99/99") is None
assert normalize_date("n/a") is None
assert normalize_date(None) is None
assert normalize_date(123456789) is None
# ==============================================================================
# 4. Testes Unitários de Validação de URLs
# ==============================================================================
@pytest.mark.parametrize(
"valid_url",
[
"https://www.tycsports.com/river-plate/los-puntajes.html",
"http://globoesporte.globo.com/futebol/times/flamengo",
"https://sub.dominio.co.uk:8080/path/to/resource?param=1&query=test#hash",
"https://example.com/noticia-com-acentos-%C3%A1%C3%A9%C3%AD",
],
)
def test_validate_url_valid_schemes(valid_url: str):
"""Garante aceitação de URLs absolutas com esquema HTTP e HTTPS válidos."""
assert validate_url(valid_url) == valid_url
@pytest.mark.parametrize(
"invalid_url",
[
"ftp://ftp.is.co.za/rfc/rfc1808.txt",
"file:///C:/Users/test/file.txt",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA",
"javascript:alert('xss')",
"/caminho/relativo/artigo.html",
"http://",
"https://",
"",
" ",
None,
],
)
def test_validate_url_invalid_schemes(invalid_url: str | None):
"""Garante rejeição de esquemas não permitidos, URLs relativas e strings vazias."""
assert validate_url(invalid_url) is None
# ==============================================================================
# 5. Testes Unitários de Conversão HTML para Markdown
# ==============================================================================
def test_convert_html_to_markdown_rich_formatting():
"""Valida conversão de elementos HTML estruturados para Markdown com títulos ATX."""
html_raw = (
"<div>"
"<h1>Título H1</h1>"
"<h2>Subtítulo H2</h2>"
"<h3>Seção H3</h3>"
"<p>Parágrafo com <b>negrito</b>, <strong>forte</strong>, <i>itálico</i> e <em>ênfase</em>.</p>"
"<blockquote>Uma citação memorável.</blockquote>"
"<ul><li>Item 1</li><li>Item 2</li></ul>"
"<p>Link para o <a href='https://example.com/fonte'>portal oficial</a>.</p>"
"<code>codigo_inline()</code>"
"<script>alert('remover');</script>"
"<style>.esconder { display: none; }</style>"
"</div>"
)
md = convert_html_to_markdown(html_raw)
assert "# Título H1" in md
assert "## Subtítulo H2" in md
assert "### Seção H3" in md
assert "**negrito**" in md or "__negrito__" in md
assert "> Uma citação memorável." in md
assert "* Item 1" in md or "- Item 1" in md
assert "[portal oficial](https://example.com/fonte)" in md
assert "`codigo_inline()`" in md
assert "alert('remover')" not in md
assert "display: none" not in md
def test_convert_html_to_markdown_empty_or_whitespace():
"""Testa conversão de HTML vazio retornando string vazia."""
assert convert_html_to_markdown("") == ""
assert convert_html_to_markdown(" \n\t ") == ""
assert convert_html_to_markdown(None) == ""
# ==============================================================================
# 6. Testes de Isolamento Estrito do Extrator e Fallback Interno
# ==============================================================================
def test_resolve_article_body_trafilatura_primary_and_fallback():
"""Testa prioridade trafilatura.markdown sobre trafilatura.text."""
# Primário
art1 = {
"selected_extractor": "trafilatura",
"trafilatura": {"markdown": "Corpo primário Trafilatura", "text": "Texto secundário"},
}
assert resolve_article_body(art1) == "Corpo primário Trafilatura"
# Fallback
art2 = {
"selected_extractor": "trafilatura",
"trafilatura": {"markdown": None, "text": "Texto secundário Trafilatura"},
}
assert resolve_article_body(art2) == "Texto secundário Trafilatura"
def test_resolve_article_body_newspaper4k_primary_and_fallback():
"""Testa prioridade newspaper4k.article_html sobre newspaper4k.text."""
# Primário HTML -> MD
art1 = {
"selected_extractor": "newspaper4k",
"newspaper4k": {
"article_html": "<p>Artigo em <b>HTML</b></p>",
"text": "Artigo em texto puro",
},
}
assert "**HTML**" in resolve_article_body(art1)
# Fallback
art2 = {
"selected_extractor": "newspaper4k",
"newspaper4k": {"article_html": None, "text": "Artigo em texto puro Newspaper"},
}
assert resolve_article_body(art2) == "Artigo em texto puro Newspaper"
def test_resolve_article_body_readability_primary_and_fallback():
"""Testa prioridade readability.cleaned_html sobre readability.cleaned_text."""
# Primário HTML -> MD
art1 = {
"selected_extractor": "readability",
"readability": {
"cleaned_html": "<p>Conteúdo <i>Readability</i></p>",
"cleaned_text": "Texto puro Readability",
},
}
body = resolve_article_body(art1)
assert "*Readability*" in body or "_Readability_" in body
# Fallback
art2 = {
"selected_extractor": "readability",
"readability": {"cleaned_html": "", "cleaned_text": "Texto puro Readability Fallback"},
}
assert resolve_article_body(art2) == "Texto puro Readability Fallback"
@pytest.mark.parametrize("extractor", ["trafilatura", "newspaper4k", "readability"])
def test_resolve_article_body_strict_isolation_all_extractors(extractor: str):
"""Garante que a ausência de corpo no extrator selecionado NUNCA faça fallback para outro extrator."""
article = {
"selected_extractor": extractor,
"trafilatura": {"markdown": "Texto Trafilatura", "text": "Texto Trafilatura"},
"newspaper4k": {"article_html": "<p>Texto Newspaper</p>", "text": "Texto Newspaper"},
"readability": {
"cleaned_html": "<p>Texto Readability</p>",
"cleaned_text": "Texto Readability",
},
}
# Esvazia o corpo do extrator selecionado
if extractor == "trafilatura":
article["trafilatura"] = {"markdown": None, "text": ""}
elif extractor == "newspaper4k":
article["newspaper4k"] = {"article_html": "", "text": None}
elif extractor == "readability":
article["readability"] = {"cleaned_html": None, "cleaned_text": ""}
with pytest.raises(ValueError, match="Corpo do extrator selecionado.*vazio|indisponível"):
resolve_article_body(article)
def test_resolve_article_body_invalid_selected_extractor():
"""Garante erro ao receber selected_extractor ausente ou não reconhecido."""
with pytest.raises(ValueError, match="selected_extractor inválido ou ausente"):
resolve_article_body({"selected_extractor": "extrator_desconhecido"})
with pytest.raises(ValueError, match="selected_extractor inválido ou ausente"):
resolve_article_body({"selected_extractor": None})
# ==============================================================================
# 7. Testes da Matriz Determinística de Resolução de Metadados
# ==============================================================================
def test_metadata_priority_title_all_fallbacks():
"""Valida a cadeia de fallback completa para o campo TÍTULO (6 níveis)."""
# 1. Do selecionado
art1 = {"selected_extractor": "trafilatura", "trafilatura": {"title": "Título Selecionado"}}
assert (
resolve_article_metadata({**art1, "crawled_url": "https://e.com"})["title"]
== "Título Selecionado"
)
# 2. input_meta.titulo
art2 = {
"selected_extractor": "trafilatura",
"input_meta": {"titulo": "Título Input Meta"},
"crawled_url": "https://e.com",
}
assert resolve_article_metadata(art2)["title"] == "Título Input Meta"
# 3. page_title
art3 = {
"selected_extractor": "trafilatura",
"page_title": "Título Page Title",
"crawled_url": "https://e.com",
}
assert resolve_article_metadata(art3)["title"] == "Título Page Title"
# 4. newspaper4k.title
art4 = {
"selected_extractor": "trafilatura",
"newspaper4k": {"title": "Título Newspaper"},
"crawled_url": "https://e.com",
}
assert resolve_article_metadata(art4)["title"] == "Título Newspaper"
# 5. readability.title
art5 = {
"selected_extractor": "trafilatura",
"readability": {"title": "Título Readability"},
"crawled_url": "https://e.com",
}
assert resolve_article_metadata(art5)["title"] == "Título Readability"
def test_metadata_priority_original_url_all_fallbacks():
"""Valida a cadeia de fallback completa para a URL ORIGINAL (5 níveis)."""
# 1. input_meta.url
art1 = {
"selected_extractor": "trafilatura",
"trafilatura": {"title": "T"},
"input_meta": {"url": "https://example.com/input-meta"},
"crawled_url": "https://example.com/crawled",
}
assert resolve_article_metadata(art1)["original_url"] == "https://example.com/input-meta"
# 2. crawled_url
art2 = {
"selected_extractor": "trafilatura",
"trafilatura": {"title": "T"},
"crawled_url": "https://example.com/crawled",
}
assert resolve_article_metadata(art2)["original_url"] == "https://example.com/crawled"
# 3. Canonical do selecionado
art3 = {
"selected_extractor": "trafilatura",
"trafilatura": {"title": "T", "canonical_url": "https://example.com/canonical-trafilatura"},
}
assert (
resolve_article_metadata(art3)["original_url"]
== "https://example.com/canonical-trafilatura"
)
# 4. Canonical do newspaper4k
art4 = {
"selected_extractor": "readability",
"readability": {"title": "T"},
"newspaper4k": {"canonical_link": "https://example.com/canonical-newspaper"},
}
assert (
resolve_article_metadata(art4)["original_url"] == "https://example.com/canonical-newspaper"
)
def test_metadata_priority_subtitle_omitted_when_equal_to_title():
"""Garante que subtítulo idêntico ao título seja automaticamente omitido (None)."""
art = {
"selected_extractor": "trafilatura",
"trafilatura": {
"title": "Grande Vitória no Clássico",
"description": " grande vitória no clássico ",
},
"crawled_url": "https://example.com/noticia",
}
meta = resolve_article_metadata(art)
assert meta["title"] == "Grande Vitória no Clássico"
assert meta["subtitle"] is None
def test_metadata_priority_first_valid_source_no_cross_merging():
"""Garante que listas de autores/tags usem apenas a primeira fonte válida, sem merge cruzado."""
art = {
"selected_extractor": "readability",
"crawled_url": "https://example.com/noticia",
"readability": {"title": "Título", "author": "Carlos Bilardo"},
"newspaper4k": {"authors": ["Juan Pérez", "María Gómez"]},
}
meta = resolve_article_metadata(art)
# Deve pegar o autor de Readability (selecionado), sem misturar com Newspaper4k
assert meta["authors"] == ["Carlos Bilardo"]
# ==============================================================================
# 8. Testes de Higienização de Cabeçalhos e Imagens no Corpo
# ==============================================================================
def test_remove_duplicate_initial_h1_exact_and_variations():
"""Testa remoção de H1 inicial coincidente com título com variações de espaços e caixa."""
title = "River Plate Conquista a Copa"
# H1 inicial igual
body1 = "# river plate conquista a copa\n\nPrimeiro parágrafo do artigo."
assert remove_duplicate_initial_h1(body1, title).strip() == "Primeiro parágrafo do artigo."
# H1 inicial diferente (deve ser preservado)
body2 = "# Outro Título Diferente\n\nPrimeiro parágrafo."
assert remove_duplicate_initial_h1(body2, title) == body2
# Sem H1 inicial
body3 = "Parágrafo sem nenhum título H1 inicial."
assert remove_duplicate_initial_h1(body3, title) == body3
def test_clean_body_images_removes_invalid_and_deduplicates():
"""Valida descarte de data:, relativos e deduplicação mantendo a primeira ocorrência."""
body = (
"![Legenda 1](https://example.com/img1.jpg)\n\n"
"![Legenda Relativa](/imagem.png)\n\n"
"![Legenda Data](data:image/png;base64,AAAA)\n\n"
"![Legenda 1 Repetida](https://example.com/img1.jpg)\n\n"
"![Legenda 2](https://example.com/img2.webp)"
)
cleaned = clean_body_images(body)
assert cleaned.count("https://example.com/img1.jpg") == 1
assert "https://example.com/img2.webp" in cleaned
assert "/imagem.png" not in cleaned
assert "data:image" not in cleaned
# ==============================================================================
# 9. Testes de Montagem e Formatação do Documento Markdown
# ==============================================================================
def test_assemble_markdown_document_full_and_minimal():
"""Testa montagem com todos os campos e apenas com campos obrigatórios."""
# Artigo Mínimo (apenas Título e URL Original)
min_meta = {
"title": "Título Mínimo",
"original_url": "https://example.com/minimo",
"subtitle": None,
"authors": [],
"publish_date": None,
"site_name": None,
"categories": [],
"tags": [],
"keywords": [],
"language": None,
"top_image": None,
}
doc_min = assemble_markdown_document(min_meta, "Corpo do texto simples.")
assert doc_min.startswith("# Título Mínimo\n\n")
assert "**Fonte original:** [https://example.com/minimo](https://example.com/minimo)" in doc_min
assert "**Autor:**" not in doc_min
assert "**Site:**" not in doc_min
assert "![Imagem principal]" not in doc_min
assert "\n\n---\n\nCorpo do texto simples.\n" in doc_min
assert doc_min.endswith("\n")
assert not doc_min.endswith("\n\n")
# ==============================================================================
# 10. Golden Test Fixtures (Conformidade 100% Byte a Byte)
# ==============================================================================
def test_golden_fixtures_byte_level_precision(tmp_path):
"""Garante correspondência exata byte a byte para Trafilatura, Newspaper4k e Readability."""
for extractor in ["trafilatura", "newspaper4k", "readability"]:
input_json = FIXTURES_DIR / f"valid_{extractor}.json"
expected_md = (FIXTURES_DIR / f"valid_{extractor}.md").read_text(encoding="utf-8")
output_md = tmp_path / f"valid_{extractor}.md"
res = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(output_md)],
capture_output=True,
text=True,
)
assert res.returncode == 0, f"Erro no extrator {extractor}: {res.stderr}"
generated_md = output_md.read_text(encoding="utf-8")
assert generated_md == expected_md, f"Divergência byte a byte na fixture {extractor}"
def test_conversion_determinism_sha256_repeatability(tmp_path):
"""Garante que múltiplas execuções no mesmo arquivo produzam hashes SHA-256 idênticos."""
input_json = FIXTURES_DIR / "valid_trafilatura.json"
hashes = set()
for i in range(5):
out_md = tmp_path / f"deterministic_{i}.md"
res = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(out_md)],
capture_output=True,
text=True,
)
assert res.returncode == 0
content_bytes = out_md.read_bytes()
hashes.add(hashlib.sha256(content_bytes).hexdigest())
assert len(hashes) == 1, "A conversão não foi 100% determinística entre execuções repetidas."
# ==============================================================================
# 11. Testes de Integração CLI, Validação e Códigos de Saída
# ==============================================================================
def test_cli_exit_codes_and_error_handling(tmp_path):
"""Testa toda a matriz de códigos de saída da CLI (0, 1, 2)."""
# Código 2: Sintaxe / Argumentos Faltantes
res_no_args = subprocess.run([sys.executable, str(SCRIPT_PATH)], capture_output=True, text=True)
assert res_no_args.returncode == 2
# Código 1: Arquivo Inexistente
res_missing_file = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", "arquivo_que_nao_existe_xyz.json"],
capture_output=True,
text=True,
)
assert res_missing_file.returncode == 1
assert "não encontrado" in res_missing_file.stderr.lower()
# Código 1: Rejeição de Batch com chave 'articles'
res_batch = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "batch_articles_invalid.json")],
capture_output=True,
text=True,
)
assert res_batch.returncode == 1
assert "articles" in res_batch.stderr.lower()
# Código 1: JSON Corrompido
res_corrupt = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "corrupt_json_invalid.json")],
capture_output=True,
text=True,
)
assert res_corrupt.returncode == 1
assert "json" in res_corrupt.stderr.lower()
def test_cli_json_root_must_be_object(tmp_path):
"""Garante encerramento com código 1 caso a raiz do JSON seja lista, número ou string."""
for invalid_root in [["item1", "item2"], 12345, "string simples"]:
bad_json = tmp_path / "bad_root.json"
bad_json.write_text(json.dumps(invalid_root), encoding="utf-8")
res = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", str(bad_json)],
capture_output=True,
text=True,
)
assert res.returncode == 1
assert "objeto" in res.stderr.lower() or "dict" in res.stderr.lower()
def test_cli_no_stdout_pollution_and_atomic_preservation(tmp_path):
"""Garante que stdout permaneça limpo e gravação atômica preserve arquivos preexistentes em falha."""
target_md = tmp_path / "target_document.md"
target_md.write_text("Versão Original Preservada", encoding="utf-8")
# Executa conversão bem-sucedida
res_ok = subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"-i",
str(FIXTURES_DIR / "valid_trafilatura.json"),
"-o",
str(target_md),
],
capture_output=True,
text=True,
)
assert res_ok.returncode == 0
assert res_ok.stdout == "" # Não polui stdout
assert "[INFO]" in res_ok.stderr
# Executa falha direcionada ao mesmo target
res_fail = subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"-i",
str(FIXTURES_DIR / "missing_body_invalid.json"),
"-o",
str(target_md),
],
capture_output=True,
text=True,
)
assert res_fail.returncode == 1
# O arquivo target deve manter o conteúdo do sucesso anterior, não foi apagado/corrompido
assert "# Los puntajes de River" in target_md.read_text(encoding="utf-8")
# Verifica que não há arquivos temporários .tmp no diretório
tmp_files = list(tmp_path.glob("*.tmp"))
assert len(tmp_files) == 0
def test_cli_default_output_naming(tmp_path):
"""Garante geração automática de <input_stem>.md quando -o não é informado."""
sample_file = tmp_path / "meu_artigo_editorial.json"
sample_file.write_text(
json.dumps(
{
"selected_extractor": "trafilatura",
"crawled_url": "https://example.com/editorial",
"trafilatura": {"title": "Editorial do Dia", "markdown": "Texto do editorial."},
}
),
encoding="utf-8",
)
res = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", str(sample_file)],
capture_output=True,
text=True,
)
assert res.returncode == 0
expected_md = tmp_path / "meu_artigo_editorial.md"
assert expected_md.exists()
assert "# Editorial do Dia" in expected_md.read_text(encoding="utf-8")
# ==============================================================================
# 12. Teste E2E de Pipeline Real (Artigo Autêntico)
# ==============================================================================
def test_e2e_pipeline_with_real_extracted_selected_json(tmp_path):
"""Valida a conversão E2E de um artigo real extraído do arquivo out/river_plate_extracted_selected.json."""
sample_source = Path(__file__).parent.parent / "out" / "river_plate_extracted_selected.json"
if not sample_source.exists():
pytest.skip(
"Arquivo out/river_plate_extracted_selected.json não encontrado para teste de integração real."
)
data = json.loads(sample_source.read_text(encoding="utf-8"))
assert "articles" in data and len(data["articles"]) > 0
first_article = data["articles"][0]
input_json = tmp_path / "river_first_article.json"
input_json.write_text(json.dumps(first_article, indent=2, ensure_ascii=False), encoding="utf-8")
output_md = tmp_path / "river_first_article.md"
res = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(output_md)],
capture_output=True,
text=True,
)
assert res.returncode == 0, f"Erro na conversão E2E real: {res.stderr}"
assert output_md.exists()
md_content = output_md.read_text(encoding="utf-8")
assert md_content.startswith("# ")
assert "**Fonte original:** [" in md_content
assert "\n\n---\n\n" in md_content
assert len(md_content.splitlines()) > 10
def test_convert_article_api_direct(tmp_path):
"""Testa a chamada direta da função convert_article em código Python."""
in_file = tmp_path / "direct.json"
in_file.write_text(
json.dumps(
{
"selected_extractor": "trafilatura",
"crawled_url": "https://example.com/direct",
"trafilatura": {"title": "Título Direto", "markdown": "Conteúdo direto."},
}
),
encoding="utf-8",
)
out_file = tmp_path / "direct.md"
result = convert_article(in_file, out_file)
assert result == out_file
assert out_file.exists()
assert "# Título Direto" in out_file.read_text(encoding="utf-8")
def test_parse_arguments_api_direct():
"""Testa a chamada direta do parse_arguments."""
args = parse_arguments(["-i", "input_test.json", "-o", "output_test.md"])
assert args.input == Path("input_test.json")
assert args.output == Path("output_test.md")