#!/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