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