# 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 | `.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.