---
id: MOD-PUBLICACOES-NOTICIAS-SERVICOS
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-PUBLICACOES-NOTICIAS]
fontes:
  - V2/Modules/Publicacoes/Noticias/
depende_de: [SVC-CONTENTRICHEDITOR, SVC-GALLERY, SVC-MEDIA, SVC-SEO, SVC-SEARCH, SVC-FEEDRSS, SVC-ACTIVITYLOGGER, SVC-STORAGE, MOD-CONEXOES-UNIDADEREGIONAL, CORE-DATA]
consumidores: [MOD-SUPPORT-NOTICIASIMPORT, MOD-LAYOUTS-HOMEPAGE, MOD-CONEXOES-UNIDADEREGIONAL, UI-PORTAL]
criticidade: alta
dono: NoticiasA
---

# Publicações/Notícias — serviços e fluxos

> Anexo do [`spec.md`](spec.md). Prova o pipeline de importação
> (normalização → chave de idempotência → transação → relatório), descreve os fluxos
> criar/editar/publicar/arquivar/restaurar/importar e lista os contratos consumidos
> de CRE, Gallery, SEO, Search, FeedRSS, Media, RegionalIdentity, Storage e
> ActivityLogger.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Converte entrada editorial (formulário ou lote Markdown) em notícias publicáveis, sincronizando busca, feed, mídia e SEO. |
| Entrada | `NewsRequest` (CMS), `NoticiasImportService::run($dir, $options)`, comando `v2:noticias-importar-lote`. |
| Saída | Linhas em `v2_news` + pivôs + `v2_news_import_keys`; AST Tiptap em `content`; relatório JSON; efeitos em índices/feed. |
| Efeitos colaterais | Upload/otimização de mídia, auditoria, logs, purga de cache; efeitos best-effort engolidos. |
| Particularidade central | O pipeline de importação é determinístico, sem rede, com dry-run fiel e idempotência por `sha256(regional+URL)` gravada na mesma transação do item. |
| Como se integra | `Support/NoticiasImport` injeta `NoticiasImportService`; o model dispara `NewsSearchSender`/`FeedSender`; o portal usa o mesmo `content`. |
| Se removido, quebra | Importação de lotes, efeitos de busca/feed e a renderização do corpo no portal. |
| Superfície pública | `NoticiasImportService`, `NoticiasImportNormalizer`, `NoticiasImportReport`, `MarkdownToTiptapConverter`, `NewsSearchSender`. |

## 1. Efeito

### 1.1 O que cada serviço faz

| Serviço | Entrada | Saída observável |
|---|---|---|
| `NoticiasImportService::run()` | diretório de lote + opções | relatório (`context`, `summary`, `items`, `duration_ms`, `generated_at`); em `apply`, notícias/mídia/tags/keys |
| `NoticiasImportNormalizer::parseLote()` | diretório raiz | array de itens normalizados, com `warnings`, `errors`, `rejected` |
| `NoticiasImportNormalizer::normalize()` | `['name','dados','markdown','hashes']` | item estruturado (função pura) |
| `NoticiasImportReport` | decisões por item | contagens e resumo serializável |
| `MarkdownToTiptapConverter::convert()` | Markdown | JSON `{"type":"doc","content":[…]}` |
| `NewsSearchSender::sync()` | `News` | upsert/remoção em `v2_public_search_index` e `v2_cms_links_index` |

### 1.2 Efeitos colaterais por serviço

| Serviço | Efeito colateral | Best-effort? |
|---|---|---|
| `run()` | `Log::info` de execução, `ActivityLogger` por item criado, `Artisan::call('v2:clear-page-cache')` quando há criação | log/cache engolidos; auditoria dentro do `try` do item |
| `storeImage()` | arquivo no disco + linha em `v2_gallery_media` + auditoria `created` da mídia | não; falha aborta o item |
| `News::saved` | busca + feed | sim |
| `News::deleted` | remoção de busca + feed | sim |

### 1.3 Contrato de `NoticiasImportService::run()`

```php
run(string $dir, array $options = []): array
```

| Opção | Tipo | Default | Efeito |
|---|---|---|---|
| `regional` | string | `'PE'` (`DEFAULT_REGIONAL`) | sigla resolvida com `LOWER(acronym) = ?`; inexistente aborta a execução |
| `status` | string | `'draft'` | aplicado a todos os itens; valor inválido ou `pending_approval` vira `draft` |
| `apply` | bool (via `!empty`) | `false` | `false` = dry-run; qualquer valor "não vazio" liga a escrita |
| `author_id` | string\|null | `null` | gravado em `v2_news.author_id` |
| `author_email` | string\|null | `null` | só para log (`actor`); não é gravado |

Retorno (chaves estáveis):

| Chave | Conteúdo |
|---|---|
| `context` | `dir`, `regional`, `status`, `apply`; em aborto, `aborted=true`, `abort_code`, `abort_message` |
| `summary` | `total`, `counts` (por ação), `created`, `planned`, `skipped`, `rejected`, `failed` |
| `items[]` | `name`, `title`, `content_date`, `status`, `regional`, `category_slug`, `category_id`, `category_exists`, `cover`, `body_images` (contagem), `tags`, `source_url`, `import_key_hash`, `warnings`, `errors`, `action`, e `news_id`/`slug` quando criado; `message` em rejeitado/skip/falha |
| `duration_ms` | tempo total medido por `microtime(true)` |
| `generated_at` | `date('c')` |

Ações possíveis: `planned`, `rejected`, `skipped`, `created`, `failed`.

### 1.4 O que **não** acontece

- `run()` não acessa rede: o normalizador só lê arquivos locais (`NoticiasImportNormalizer.php:8-18`).
- Dry-run não cria categoria (usa `findNewsCategory`, somente leitura) e não chama a Galeria.
- Não há rollback das mídias já gravadas em disco quando o item falha depois do upload.
- Não há fila: tudo é síncrono dentro da requisição/comando.

## 2. Abordagem

### 2.1 Pipeline de importação (visão geral)

```mermaid
flowchart LR
  A[run dir, options] --> B[normalizeOptions]
  B --> C[findRegional LOWER acronym]
  C -->|null| Z[report.abort regional_not_found]
  C --> D[normalizer.parseLote: glob noticia* natural sort]
  D --> E[normalize: identity + assets]
  E --> F{item.rejected?}
  F -->|sim| G[action=rejected]
  F -->|não| H{apply?}
  H -->|não| I[action=planned]
  H -->|sim| J{keyExists?}
  J -->|sim| K[action=skipped]
  J -->|não| L[BEGIN]
  L --> M[persistNews: mídia, AST, categoria, regional, save]
  M --> N[registerKey]
  N --> O[COMMIT]
  O --> P[action=created + ActivityLogger]
  L -->|Throwable| Q[ROLLBACK + action=failed]
  G --> R[report.add]
  I --> R
  K --> R
  P --> R
  Q --> R
  R --> S[clearPageCache se created>0]
  S --> T[logRun]
  T --> U[report.toArray]
```

### 2.2 Normalização passo a passo (`NoticiasImportNormalizer`)

1. `parseDirectory($dir)`: lê `dados.json` (nunca lança: arquivo ausente → `[]`), `noticia.md` (via `@file_get_contents`, ausente → `''`) e `hashImages($dir)` (mapa `nome → sha256` apenas para `.jpg|jpeg|png|webp|gif`) — `:32-46,698-728`.
2. `parseLote($root)`: `glob($root.'/noticia*', GLOB_ONLYDIR)` + `sort(SORT_NATURAL)`; só diretórios com prefixo `noticia` — `:50-66`.
3. `parseMarkdown`: separa front matter (primeiro `# ` como título, linhas `**Autor|Data|Tempo de leitura|URL Original:**` como scaffold), extrai ativos e sanitiza links — `:178-201,203-239`.
4. `extractFrontMatter`: subtítulo = primeira linha útil entre o H1 e o scaffold (≤500); `body_start` = índice do scaffold, senão `titleIndex+1`, senão 0 — `:203-239,579-620`.
5. `extractAssets`: descarta scaffold, `---`, `### Imagens da Notícia`, a linha `![Imagem de Capa](…)` (vira `cover_ref`), demais imagens (viram `body_refs`), linhas só de hashtags (viram `tags`); colapsa linhas em branco — `:241-288`.
6. `sanitizeLinks`: remove links cujo esquema não seja `http|https|mailto` nem relativo, preservando o texto; conta removidos → `unsafe_links_removed:<n>` — `:482-527`.
7. `resolveIdentity`: título (H1 vence; divergência com `dados.titulo` → `title_divergence`; vazio → `title_missing`), data, autor, URL e resumo; `date_unresolved` quando não há data — `:92-123`.
8. `resolveDate`: tenta `dados.data` e depois o scaffold `Data`; aceita `dd/mm` ou `dd/mm/aaaa`; se o JSON não tem ano, herda do Markdown e avisa `year_inherited_from_markdown`; valida com `checkdate` — `:290-349`.
9. `normalizeAuthor`: vazio ou `sucesu[\s\-_]*pe?` (case-insensitive) → `SUCESU-PE` — `:373-389`.
10. `resolveSourceUrl`: `dados.url`, senão `scaffold.url`.
11. `summarize`: primeiro bloco não vazio, `strip_tags`, espaços colapsados, ≤300 caracteres — `:464-480`.
12. `resolveAssets`: capa por candidatos (`dados.imagem_capa_local`, `cover_ref`) existentes no mapa de hashes, senão arquivo `^capa\.`; sem capa → `cover_missing`. Imagens do corpo: precisam existir (`body_image_missing:<ref>`) e ter sha diferente da capa (`body_image_equals_cover:<ref>`) — `:125-148,406-462`.
13. `composeResult`: normaliza tags (dedupe case-insensitive, ≤10, ≤50), calcula `markdown_hash = sha256(markdown bruto)`, `responsible_name = 'SUCESU-PE'`, `rejected = count(errors) > 0` — `:150-175,529-557`.

### 2.3 Chave de idempotência

- Cálculo: `hash('sha256', 'regional:' . strtolower($regional->acronym) . ':' . $item['source_url'])` (`NoticiasImportService.php:205-208`).
- Verificação: `NewsImportKey::where('key_hash', $hash)->exists()` **fora** da transação (`:219-224`).
- Registro: `NewsImportKey::create(['key_hash','news_id','regional','source_url'])` **dentro** da transação (`:451-463`).
- A mesma chave também vai para `metadata.import_key_hash` (`:393-415`), permitindo rastrear a origem sem consultar a tabela.
- Garantia sob concorrência: `unique` em `key_hash` (`Migrations/2026_09_17_000001:31`); a execução perdedora falha o item (não o converte em `skipped`).

### 2.4 Transação e ordem das escritas

1. `DB::beginTransaction()`.
2. `persistNews()`:
   a. `storeImage()` da capa → upload na Galeria (arquivo + `v2_gallery_media`);
   b. `storeBodyImages()` → um upload por imagem distinta (dedupe por sha na execução);
   c. nova `News` com `id` pré-gerado, `title`, `subtitle`, `summary`, `content` (AST + nós de mídia), `status`, `author_id`, `responsible_name`, `source`, `content_date`, `category_id` (criada se necessário), `unidade_regional_id`, `cover_image_id`, `metadata`;
   d. `save()` dispara `creating`/`saving`/`saved` (busca + feed);
   e. `syncTags()` (somente a tag da sigla) e `syncGallery()`.
3. `registerKey()`.
4. `DB::commit()`; em `Throwable`, `DB::rollBack()`, `logFailure()`, ação `failed`.

Consequências: a categoria regional pode ser **criada** dentro da transação do primeiro item e reaproveitada; o índice de busca/feed é escrito dentro da transação (mas o `catch` interno do hook engole falhas); arquivos de mídia não são transacionais.

### 2.5 Conversão Markdown → Tiptap

`MarkdownToTiptapConverter::convert()` usa Parsedown → HTML → `DOMDocument` → mapeamento de blocos (`:41-83`). Nós suportados: `paragraph`, `heading` (nível normalizado 1-2→2, 3→3, 4-6→4), `bulletList`, `orderedList`, `listItem`, `blockquote`, `horizontalRule`, `text` com marcas `bold`, `italic`, `strike`, `link{href,target}` e `contentRichMedia` (via handler) — `:85-440`.

Durante a importação **não** há handler de imagem: as imagens são extraídas do Markdown antes (viram `body_images`) e anexadas ao final do documento como nós `contentRichMedia` com `path`, `alt` (título) e `type: image` (`NoticiasImportService.php:363-391`).

### 2.6 Trechos de código pivô

```php
// V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:86-121 — o
// orquestrador: dry-run por padrão, relatório único, aborto por regional.
public function run(string $dir, array $options = []): array
{
    $options = $this->normalizeOptions($options);
    $options['dir'] = $dir;
    $this->mediaCache = [];
    $started = microtime(true);

    $report = new NoticiasImportReport([
        'dir' => $dir, 'regional' => $options['regional'],
        'status' => $options['status'], 'apply' => $options['apply'],
    ], $started);

    $regional = $this->findRegional($options['regional']);

    if (!$regional) {
        $report->abort('regional_not_found', 'Unidade regional não encontrada: ' . $options['regional']);
        return $report->toArray();
    }

    foreach ($this->normalizer->parseLote($dir) as $item) {
        $report->add($this->processItem($item, $regional, $options));
    }
    // clearPageCache + logRun + return $report->toArray()
}
```

**Efeito.** A ausência da regional encerra antes de listar o diretório: o relatório volta com `summary.total = 0` e `context.aborted = true`. Não há exceção.

```php
// V2/Modules/Publicacoes/Noticias/Services/NoticiasImportNormalizer.php:50-90 — lote
// determinístico e normalização pura (sem banco, sem rede).
public function parseLote(string $root): array
{
    $dirs = glob(rtrim($root, '/') . '/noticia*', GLOB_ONLYDIR) ?: [];
    sort($dirs, SORT_NATURAL);

    $items = [];
    foreach ($dirs as $dir) {
        $items[] = $this->parseDirectory($dir);
    }
    return $items;
}

public function normalize(array $source): array
{
    $ctx = [
        'source' => $source,
        'parsed' => $this->parseMarkdown((string) ($source['markdown'] ?? '')),
        'dados'  => (array) ($source['dados'] ?? []),
        'hashes' => (array) ($source['hashes'] ?? []),
        'warnings' => [], 'errors' => [],
    ];
    $ctx = $this->resolveIdentity($ctx);
    $ctx = $this->resolveAssets($ctx);
    return $this->composeResult($ctx);
}
```

**Efeito.** A ordem dos itens é natural (`noticia1`, `noticia2`, `noticia10`); um diretório com nome fora de `noticia*` é ignorado sem aviso.

```php
// V2/Modules/Publicacoes/Noticias/Services/NoticiasImportNormalizer.php:290-314
// A data pode vir sem ano no JSON e herdar o ano do cabeçalho Markdown.
$json = $this->parseDate((string) ($dados['data'] ?? ''));
$md = $this->parseDate((string) ($parsed['scaffold']['data'] ?? ''));

if ($json === null) {
    return $md === null ? null : $this->formatDate($md);
}
if ($json['year'] === null) {
    if ($md === null || $md['year'] === null) {
        return null;
    }
    $json['year'] = $md['year'];
    $warnings[] = 'year_inherited_from_markdown';
}
return $this->formatDate($json);
```

**Efeito.** `22/05` + `**Data:** 22/05/2026` produz `2026-05-22` e o aviso `year_inherited_from_markdown`. Se o ano não existir em nenhum dos dois, `date_unresolved` bloqueia o item.

```php
// V2/Modules/Publicacoes/Noticias/Services/NoticiasImportNormalizer.php:406-462 — capa
// por candidatos e imagens do corpo com dedupe por sha256 contra a capa.
foreach ($candidates as $candidate) {
    $candidate = trim($candidate);
    if ($candidate !== '' && isset($hashes[$candidate])) {
        return $candidate;
    }
}
foreach ($hashes as $file => $hash) {
    if (preg_match('/^capa\./i', $file)) {
        return $file;
    }
}
$warnings[] = 'cover_missing';
return null;
// ...
if ($coverHash !== null && $hashes[$ref] === $coverHash) {
    $warnings[] = 'body_image_equals_cover:' . $ref;
    continue;
}
$images[] = ['file' => $ref, 'sha256' => $hashes[$ref], 'alt' => 'Imagem'];
```

**Efeito.** A capa declarada vence; sem declaração, qualquer `capa.*` é adotada. A mesma imagem não aparece duas vezes (capa e corpo) nem é reenviada: `storeImage()` deduplica por sha na execução inteira.

```php
// V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:325-361 —
// confinamento no diretório do item, limites e dedupe antes do upload.
$base = realpath(rtrim($dir, '/') . '/' . $item['name']);
$path = realpath(rtrim($dir, '/') . '/' . $item['name'] . '/' . $file);

if ($base === false || $path === false || strpos($path, $base) !== 0 || !is_file($path)) {
    throw new \RuntimeException('Imagem fora do escopo do lote: ' . $file);
}
if (filesize($path) > self::MAX_IMAGE_BYTES) {
    throw new \RuntimeException('Imagem excede 10MB: ' . $file);
}
if (!in_array(mime_content_type($path), self::$allowedMimes, true)) {
    throw new \RuntimeException('MIME não permitido: ' . $file);
}

$sha = (string) hash_file('sha256', $path);
if (isset($this->mediaCache[$sha])) {
    return $this->mediaCache[$sha];
}
$config = new GalleryConfig('unidade-regional', [], self::MAX_IMAGE_KB, true);
$stored = $this->gallery->seedFromPath($path, $config, $this->mediaName($item, $file), $item['title']);
```

**Efeito.** Qualquer caminho que escape do diretório do item (inclusive via `..` ou symlink) falha o item com mensagem explícita. O `alt` da mídia é o título da notícia; o nome do arquivo é `str_slug(<item>-<arquivo-sem-extensão>)`.

```php
// V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:363-391 — o
// corpo vira AST Tiptap e cada imagem do corpo vira um nó no fim do documento.
$doc = json_decode(app(MarkdownToTiptapConverter::class)->convert($item['body_markdown']), true);

if (!is_array($doc) || !isset($doc['content']) || !is_array($doc['content'])) {
    $doc = ['type' => 'doc', 'content' => []];
}

foreach ($bodyMedia as $media) {
    $doc['content'][] = [
        'type'  => 'contentRichMedia',
        'attrs' => ['path' => $media['file_path'], 'alt' => $item['title'], 'type' => 'image'],
    ];
}
return json_encode($doc, JSON_UNESCAPED_UNICODE);
```

**Efeito.** As imagens ficam no **fim** do artigo, na ordem do lote — não na posição original do Markdown (o normalizador removeu as marcações do corpo). `path` é o caminho relativo do disco de mídia, consumido pelo renderer de `contentRichMedia`.

```php
// V2/Modules/Publicacoes/Noticias/Services/NoticiasImportReport.php:97-117 — forma
// final do relatório (contrato do comando e do endpoint de suporte).
return [
    'context' => $this->context,
    'summary' => [
        'total' => $this->total(),
        'counts' => $counts,
        'created' => isset($counts['created']) ? $counts['created'] : 0,
        'planned' => isset($counts['planned']) ? $counts['planned'] : 0,
        'skipped' => isset($counts['skipped']) ? $counts['skipped'] : 0,
        'rejected' => isset($counts['rejected']) ? $counts['rejected'] : 0,
        'failed' => isset($counts['failed']) ? $counts['failed'] : 0,
    ],
    'items' => $this->items,
    'duration_ms' => (int) round((microtime(true) - $this->startedAt) * 1000),
    'generated_at' => date('c'),
];
```

**Efeito.** `summary.counts` inclui qualquer ação futura; os campos fixos são só atalhos. `total` é o número de itens avaliados, não de criados.

### 2.7 Decisões de design e por quê

- **Dry-run como default** `[C]`: comando e serviço exigem `--apply`/`apply=true`; reduz risco de lote malformado.
- **Categoria/regional derivadas da sigla** `[C]`: a planilha/coletor não pode escolher outra regional.
- **Hashtags em `metadata.tags`, não no pivô** `[C]`: o pivô fica reservado à tag da regional (usada no filtro); as hashtags servem para busca/SEO.
- **Imagens sempre no fim do AST** `[I]`: simplifica o pipeline (o Markdown é limpo antes), ao custo de perder a posição editorial.
- **`responsible_name` fixo** `[C]`: o normalizador ignora o payload e grava `SUCESU-PE`.

## 3. Particularidades

### 3.1 O que só existe aqui

- Normalizador puro com **warnings nomeados** (`title_divergence`, `cover_missing`, `unsafe_links_removed:<n>`, `body_image_missing:<ref>`, `body_image_equals_cover:<ref>`, `year_inherited_from_markdown`) — contrato observável do relatório.
- Dedupe intra-execução de mídia por `sha256` (`mediaCache`).
- Dupla gravação da chave de idempotência (tabela + metadata).

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-NOTICIAS-S01 | O normalizador nunca lança: JSON ausente vira `[]`, Markdown ausente vira `''`, diretório vazio vira `[]`. | Um lote vazio gera relatório `total=0` com sucesso. | `NoticiasImportNormalizer.php:698-712,50-66` |
| RN-NOTICIAS-S02 | `rejected` vence `apply`: item com erro nunca é gravado, mesmo em `apply`. | `title_missing`/`date_unresolved` bloqueiam. | `NoticiasImportService.php:149-164` |
| RN-NOTICIAS-S03 | A categoria só é criada em `apply` (dry-run usa `findNewsCategory`). | Dry-run não altera `v2_news_categories`. | `:175-203,263-301` |
| RN-NOTICIAS-S04 | `status=pending_approval` ou inválido → `draft`. | Nenhum lote entra em aprovação. | `:123-139` |
| RN-NOTICIAS-S05 | `apply` é avaliado por `!empty`, não por cast booleano. | `'false'` (string) liga a escrita; o transporte usa `FILTER_VALIDATE_BOOLEAN`. | `:135`; `Support/NoticiasImport/Controllers/NoticiasImportController.php:108` |
| RN-NOTICIAS-S06 | `source_url` vazia colide a chave entre itens. | Só o primeiro item sem URL é criado; os demais ficam `skipped`. | `:205-208,219-224` |
| RN-NOTICIAS-S07 | Corrida entre execuções: a perdedora vira `failed`, com a mensagem do PostgreSQL no relatório. | Não há retry automático. | `:219-250` |
| RN-NOTICIAS-S08 | O page cache é limpo **uma vez** por execução, só se `created > 0`. | Lote 100% `skipped` não invalida cache. | `:489-503` |
| RN-NOTICIAS-S09 | O log de execução inclui ator (`author_email` ou e-mail Sentinel) e `duration_ms`. | Auditoria sem segredos. | `:543-573` |
| RN-NOTICIAS-S10 | `metadata.seo.title` e `metadata.seo.description` são preenchidos na importação. | Portal usa esse SEO sem edição manual. | `:393-415` |
| RN-NOTICIAS-S11 | O item de feed é removido por **soft delete**; `addItem` usa `updateOrCreate` sem `withTrashed()`. | Republicar depois de despublicar tenta `INSERT` e viola o unique `(entity_type, entity_id)`; a exceção é engolida no hook e o item **não volta** ao feed. | `V2/Services/FeedRSS/Services/FeedRssService.php:39-69,79-90`; `V2/Services/FeedRSS/Models/FeedRssItem.php:33`; `V2/Services/FeedRSS/Migrations/2026_07_20_000001_create_v2_feed_rss_items_table.php:44`; `Models/News.php:299-318` |

### 3.3 Casos-limite e comportamentos surpreendentes

1. **`dados.json` inválido** → `readJson` devolve `[]`; o título ainda pode vir do H1 do Markdown.
2. **Imagem listada no Markdown mas ausente no disco** → aviso `body_image_missing:<ref>`, item continua.
3. **Imagem igual à capa** → aviso `body_image_equals_cover:<ref>`; não entra no corpo.
4. **MIME diferente de `image/jpeg|png|webp|gif`** (ex.: SVG) → item `failed` com `MIME não permitido: <arquivo>`.
5. **Arquivo > 10 MB** → `failed` com `Imagem excede 10MB: <arquivo>`.
6. **Falha no upload da Galeria** (disco cheio) → `failed`; a chave não é registrada, permitindo retentativa.
7. **URL de origem repetida em regionais diferentes** → chaves diferentes (`regional:pe:` vs `regional:go:`), notícias distintas.
8. **`--status=published`** → todas as notícias do lote nascem publicadas com `published_at = now()`.
9. **Título muito longo** não é truncado pelo normalizador; a coluna `varchar(255)` pode rejeitar no banco → item `failed`.
10. **Falha no `Artisan::call('v2:clear-page-cache')`** → warning; o relatório continua.
11. **Despublicar → publicar deixa a notícia fora do RSS**: `removeItem()` faz `SoftDeletes` (`FeedRssService.php:83`); no republish, `updateOrCreate` não enxerga a linha trashed e tenta inserir, colidindo com `uq_v2_feed_rss_items_entity` (`2026_07_20_000001...:44`). O `QueryException` é capturado e só gera `Log::error('[News] Falha ao sincronizar feed RSS')` (`Models/News.php:316-317`); portal e busca são atualizados normalmente, o feed fica sem o item. Reproduz também no ciclo arquivar → restaurar → publicar. Correção recomendada na reimplementação: `FeedRssItem::withTrashed()->updateOrCreate(...)` + `restore()` (ou `firstOrNew` + `deleted_at=null`), e não engolir silenciosamente a falha.

### 3.4 Armadilhas de reimplementação

1. **Normalizar com acesso a banco** quebra o dry-run fiel; a separação é o contrato.
2. **Registrar a chave antes da notícia** impede retentativa de itens que falharam.
3. **Tratar `warnings` como erros** rejeitaria lotes legítimos (lote sem capa é aceito).
4. **Usar o hash do Markdown como chave de idempotência**: `source_markdown_hash` muda quando o coletor reescreve o texto; a chave é URL+regional.
5. **Colocar imagens na posição do Markdown** exigiria mapeamento ref→nó; o legado não faz.

### 3.5 Divergências documentação × código

| Ponto | Afirma | Realidade |
|---|---|---|
| `NoticiasImportarLote.php:16-21` | `--dry-run` como opção | Opção declarada e nunca lida; dry-run é o default. |
| `NoticiasImportService.php:24-28` (docblock) | "É a única autoridade que escreve em `v2_news` para este fluxo" | Verdadeiro para o fluxo de importação; o CMS e a API regional também escrevem na tabela. |
| `NoticiasImportNormalizer.php:8-18` | "sem qualquer acesso a banco de dados ou rede" | Confirmado (só `glob`/`file_get_contents`/`hash_file`). |
| `Migrations/2026_09_17_000001:9-15` | "garantindo que a mesma notícia não seja importada duas vezes mesmo com execuções concorrentes" | Garante **uma** criação; a concorrente falha (`failed`), não é convertida em `skipped`. |

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Assinatura | Retorno | Consumidores |
|---|---|---|---|
| `NoticiasImportService::run` | `(string $dir, array $options = [])` | `array` (relatório) | comando, `Support/NoticiasImport` |
| `NoticiasImportNormalizer::parseDirectory` | `(string $dir)` | `array` (item) | serviço, testes |
| `NoticiasImportNormalizer::parseLote` | `(string $root)` | `array<int,array>` | serviço |
| `NoticiasImportNormalizer::normalize` | `(array $source)` | `array` | testes, reuso |
| `NoticiasImportReport::add/abort/counts/total/toArray` | `(array)` / `(string,string)` / `()` | `void`/`bool`/`array`/`int`/`array` | serviço, transporte |
| `MarkdownToTiptapConverter::setImageHandler` | `(callable)` | `void` | API regional |
| `MarkdownToTiptapConverter::convert` | `(string $markdown)` | `string` JSON | importação, API regional |
| `NewsSearchSender::sync` | `(News $news)` | `void` | `News::syncToSearch` |

### 4.2 Contrato de UI/JS (quando aplicável)

Não se aplica: nenhum dos serviços tem UI própria. O único consumo de UI é o renderer de `content` (portal) e o endpoint de importação (página de suporte, `MOD-SUPPORT-NOTICIASIMPORT`).

### 4.3 Contratos externos consumidos

| Serviço | Contrato usado | Efeito | Fonte |
|---|---|---|---|
| **ContentRichEditor** | `ContentRichEditorRendererService::toHtml($ast): string` | HTML do corpo no portal/preview; lança em AST inválido (capturado) | `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:14-19` |
| **ContentRichEditor** | AST Tiptap: `doc`, `paragraph`, `heading{attrs.level}`, `bulletList`, `orderedList`, `listItem`, `blockquote`, `horizontalRule`, `text{marks}`, `contentRichMedia{attrs.path,attrs.alt,attrs.type}` | contrato de `content` | `Services/MarkdownToTiptapConverter.php:85-440`; `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:156-158` |
| **Gallery** | `GalleryService::seedFromPath($path, GalleryConfig $config, $filename, $altText = null, $description = null, $isShared = false): array` | cria `v2_gallery_media` + arquivo otimizado (WebP) | `V2/Services/Gallery/Services/GalleryService.php:110-128` |
| **Gallery** | `new GalleryConfig('unidade-regional', [], 10240, true)` | owner module `unidade-regional`, MIME default (`image/*`, `application/pdf`), 10 MB, compartilhável | `V2/Services/Gallery/DTOs/GalleryConfig.php:27-34` |
| **Media** | componentes `v2-service-media::components.picture` com `originalPath`, `variants`, `alt`, `class`, `lazy` | `<picture>` com WebP | `Views/portal/index.blade.php:154-160`; `Models/News.php:233-245` |
| **SEO** | `SEOService::setTitle/setDescription/setKeywords/setCoverImage` | meta tags e OG da página | `V2/Services/SEO/Services/SEOService.php:293-330` |
| **SEO** | `News::getSeoData()` (`metadata.seo.title|description|keywords|json_ld`) | valores com fallback | `Models/News.php:341-360` |
| **Search** | `PublicIndexerService::index(PublicSearchDTO)/remove($entityType,$entityId)` | upsert/remoção em `v2_public_search_index` por `(entity_type,entity_id)` | `V2/Services/Search/Services/PublicIndexerService.php:21-72` |
| **Search** | `CMSIndexerService::index(CMSIndexDTO)` | upsert em `v2_cms_links_index` por `(module,edit_url)` | `V2/Services/Search/Services/CMSIndexerService.php:21-45` |
| **Search** | `PublicSearchDTO(title, slug, module, entityType, entityId, summary, tags, deepContent)` | payload de indexação | `V2/Services/Search/DTOs/PublicSearchDTO.php:22-40` |
| **FeedRSS** | `FeedSender::forModule('noticias')->pushFromArray([...])` / `->remove('news', $id)` | upsert/remoção em `v2_feed_rss_items` | `V2/Services/FeedRSS/Consumer/FeedSender.php:38-87`; `FeedRssService.php:39-69` |
| **RegionalIdentity** | `newsCategorySlug`, `newsCategoryFor`, `findNewsCategory`, `newsCategoryParent`, `syncAll`, `unidadeForCategorySlug`, `isRegionalSlug` | identidade `regional-<sigla>` e categoria-mãe `regionais` | `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:14-136` |
| **Storage** | `StorageService::url($path): string` | URL pública da capa (fallback `/storage/<path>`) | `V2/Services/Storage/StorageService.php:130-133`; `Models/News.php:201-218` |
| **ActivityLogger** | `log(Model $model, string $description, array $properties = [], string $snapshotId = null)` | linha em `activity_log` | `V2/Services/ActivityLogger/ActivityLoggerService.php:20-32` |

### 4.4 Receitas de uso

**Importar via código com relatório:**

```php
$report = app(\V2\Modules\Publicacoes\Noticias\Services\NoticiasImportService::class)
    ->run(storage_path('app/imports/sucesu_pe_noticias'), [
        'regional' => 'PE',
        'status'   => 'draft',
        'apply'    => true,
    ]);
if (!empty($report['context']['aborted'])) {
    throw new RuntimeException($report['context']['abort_message']);
}
```

**Pré-visualizar um item sem gravar (normalizador puro):**

```php
$item = app(\V2\Modules\Publicacoes\Noticias\Services\NoticiasImportNormalizer::class)
    ->parseDirectory(storage_path('app/imports/lote/noticia3'));
// $item['title'], $item['content_date'], $item['warnings'], $item['rejected']
```

**Converter Markdown com handler de imagem (padrão da API regional):**

```php
$converter = app(\V2\Modules\Publicacoes\Noticias\Services\MarkdownToTiptapConverter::class);
$converter->setImageHandler(function ($src, $alt) {
    return ['type' => 'contentRichMedia', 'attrs' => ['path' => $src, 'alt' => $alt, 'type' => 'image']];
});
$json = $converter->convert("# Título\n\nParágrafo.");
```

**Reindexar uma notícia manualmente:**

```php
$news = \V2\Modules\Publicacoes\Noticias\Models\News::findOrFail($id);
app(\V2\Modules\Publicacoes\Noticias\Services\NewsSearchSender::class)->sync($news);
```

### 4.5 Anti-uso

1. **Passar `apply` como string `'false'`** ao serviço → escrita real (RN-NOTICIAS-S05).
2. **Chamar `run()` com caminho relativo** — `realpath` no `storeImage` usa `rtrim($dir,'/')`; caminhos parciais funcionam, mas o confinamento compara prefixos resolvidos e falha se o diretório não existir (`parseLote` devolve vazio, silencioso).
3. **Reutilizar o `NoticiasImportNormalizer` esperando I/O de banco** — ele não resolve categoria/regional; isso é do serviço.

### 4.6 Custos e limites

- Upload: 10 MB por imagem (`MAX_IMAGE_BYTES` = 10485760) e 10.240 KB no `GalleryConfig`; MIME `image/jpeg|png|webp|gif`.
- Memória: `parseLote` materializa todos os itens (título, corpo, hashes) antes de processar.
- Escritas por item: 1 notícia + N mídias + 1 chave + 1 tag + 2 `sync` + 1 auditoria + (busca/feed).
- Tempo dominado por upload/otimização de imagens.
- Sem timeout/limite de itens por lote.

## 5. Rotas e contratos HTTP

Este anexo não define rotas. Os endpoints usados para disparar o serviço estão em `MOD-SUPPORT-NOTICIASIMPORT` (`POST /cms/importacao-noticias/run`, `POST /api/v2/noticiasimport/run`); o contrato de `run()` está em §1.3.

## 6. Dados e modelo: migrations e chaves

- Chave de idempotência: `v2_news_import_keys` (`key_hash` char(64) unique, `regional`, `source_url`, `news_id`).
- Rastreabilidade no documento: `metadata = {source:'import', collector:'sucesu.org.br', author_name, tags, source_url, source_markdown_hash, import_key_hash, source_unidade_regional_id, seo:{title,description}}` (`Services/NoticiasImportService.php:393-415`).
- A notícia importada grava `source = source_url` e `unidade_regional_id = regional->id`; a categoria é `regional-<sigla>`.
- Detalhes de esquema em [`dados.md`](dados.md).

## 7. Fluxos e estados

### 7.1 Estados

```mermaid
stateDiagram-v2
  [*] --> draft: CMS store / import draft
  draft --> published: CMS update status
  published --> draft: CMS update status
  [*] --> pending_approval: API da regional (sem UI)
  pending_approval --> published: edição manual no CMS
  draft --> archived: DELETE
  published --> archived: DELETE
  pending_approval --> archived: DELETE
  archived --> draft: restore
```

`archived` não é coluna: é `deleted_at != null` com qualquer `status`. Não há transição para `rejected`.

### 7.2 `FL-NOTICIAS-01` — Criar notícia (CMS)

```mermaid
sequenceDiagram
  participant U as Editor
  participant M as V2PermissionMiddleware
  participant C as NoticiasCmsController
  participant R as NewsRequest
  participant N as News (hooks)
  participant S as NewsSearchSender/FeedSender
  U->>M: POST /cms/noticias/novo
  M->>R: permissão OK (Noticias:write)
  R->>R: slug=str_slug(title), status=draft, content JSON?
  R->>C: validated()
  C->>N: new News + metadata.seo + categoria/regional
  N->>N: creating (uuid, slug, published_at)
  N->>N: saving (unicidade do slug)
  N-->>S: saved → busca (CMS; público se published) + feed
  C->>C: tags/gallery sync, auditoria created, clearPageCache
  C-->>U: 302 /cms/noticias + flash created_item
```

Efeitos: `v2_news` 1 linha; `v2_cms_links_index` upsert; `v2_feed_rss_items` remove (draft); `activity_log`; cache de páginas limpo.

### 7.3 `FL-NOTICIAS-02` — Editar notícia

1. `edit()` autoriza escopo regional e carrega categorias/tags.
2. `update()` valida, reescreve campos, atualiza `metadata.seo` com fallbacks, deriva categoria/regional, atualiza `published_at` se transição para `published`.
3. `saved` reindexa/republica feed; `sync` de tags e galeria (sem `gallery_ids`, a galeria é zerada).
4. Auditoria `updated` com `old_status`/`new_status`; `302` para a edição.

### 7.4 `FL-NOTICIAS-03` — Publicar / despublicar

- `status=published` no update: `published_at = now()` se ainda não publicado; `saved` indexa no público e cria/atualiza o item de feed (`updateOrCreate` por `entity_type`+`entity_id`) com `link`, `description`, `author`, `image_url`.
- `status=draft` em notícia publicada: `saved` remove do índice público e do feed (soft delete); `published_at` **não** é limpo.
- **Republicar depois de despublicar não recria o item de feed**: a linha removida continua soft-deleted, o `updateOrCreate` não a encontra sem `withTrashed()` e o `INSERT` colide com `uq_v2_feed_rss_items_entity`; a exceção é engolida no hook (`RN-NOTICIAS-S11`, caso 11 da §3.3).
- `pending_approval`: não indexa e não entra no feed (cai no ramo de remoção); publicar depois cai na mesma limitação acima se o item já existiu.

### 7.5 `FL-NOTICIAS-04` — Arquivar

1. `destroy()` autoriza, captura o título, chama `delete()` (soft).
2. `deleted`: remove do índice público e do feed (best-effort).
3. Auditoria `archived` com o título capturado (o model já está com `deleted_at`).
4. Cache limpo; flash; `302` para a lista.
5. O índice **do CMS** não é removido: a entrada continua em `v2_cms_links_index` apontando para `/cms/noticias/editar/{id}`, que responde `404` (model exclui trashed). Dívida conhecida.

### 7.6 `FL-NOTICIAS-05` — Restaurar

1. `News::onlyTrashed()->findOrFail($id)`; `restore()`; `status = draft`; `save()`.
2. `saved`: como não publicado, remove do índice público e do feed; indexa no CMS (atualiza o link morto).
3. Auditoria `restored`; cache; `302` para a edição.

### 7.7 `FL-NOTICIAS-06` — Importar lote

```mermaid
sequenceDiagram
  participant T as CLI ou HTTP (Support)
  participant S as NoticiasImportService
  participant Z as Normalizer
  participant G as GalleryService
  participant D as PostgreSQL
  participant L as Logger/ActivityLogger
  T->>S: run(dir, options)
  S->>S: normalizeOptions + findRegional
  S->>Z: parseLote(dir)
  Z-->>S: itens (título, data, tags, cover, body_images, warnings, errors)
  loop cada item
    alt rejected
      S->>S: action=rejected
    else dry-run
      S->>S: action=planned
    else apply
      S->>D: SELECT key_hash
      alt existe
        S->>S: action=skipped
      else não existe
        S->>D: BEGIN
        S->>G: seedFromPath(capa, imagens)
        G->>D: INSERT v2_gallery_media
        S->>D: INSERT v2_news (+ trigger search_vector)
        S->>D: saved → upsert índices + feed
        S->>D: INSERT v2_news_import_keys
        S->>D: COMMIT
        S->>L: activity_log imported
        S->>S: action=created
      end
    end
  end
  S->>S: clearPageCache (se created>0)
  S->>L: Log::info execução
  S-->>T: relatório
```

## 8. Configuração, permissões e flags

| Chave | Efeito | Fonte |
|---|---|---|
| `v2.regional_operator.forced_news_category_slug` | fallback de categoria do operador regional | `config/v2.php:54` |
| `v2.support.email` | ator de log no CLI; usuário sem restrição regional | `config/v2.php:5`; `Services/NoticiasImportService.php:564-573` |
| `v2.internal_import_keys.scope_noticias` | escopo do endpoint de importação | `config/v2.php:41` |
| `filesystems.media_disk` | disco da Galeria | `config/filesystems.php:24` |
| `cache.prefix` | prefixo do page cache | `config/cache.php:89` |

Permissões: `Noticias` (RBAC) para rotas CMS; `noticias:import` (chave interna) para o endpoint de importação; escopo regional aplicado no serviço (`belongsToUnidadeRegional`) e no controller.

## 9. Observabilidade

- `[NoticiasImport] Execução concluída` (info): `action` (`import_apply`/`import_dry_run`), `regional`, `user`, `total`, `counts`, `apply`, `ts`, `duration_ms`.
- `[NoticiasImport] Falha ao importar item` (error): `module`, `action=import_item_failed`, `name`, `source_url`, `message`, `ts`.
- `[NoticiasImport] Falha ao limpar page cache` (warning).
- `[News] Falha ao sincronizar busca` / `feed RSS` / `remover do índice público` (error).
- `[NewsSearchSender] Falha ao carregar regional para indexação` (warning).
- `activity_log`: `imported` com `type=news_import`, `source=import`, `regional`, `import_key_hash`.
- Artefato: `storage/logs/noticias-import-<Ymd-His>.json` (comando).

## 10. Seeds

Não se aplica: os serviços não semeiam dados; a importação grava sob demanda. Categorias iniciais em [`dados.md`](dados.md) §9.

## 11. UI CMS

Ver [`cms.md`](cms.md). Consumo desta camada: `store/update` com o JSON do editor; tags resolvidas por nome; galeria por `gallery_ids[]`.

## 12. UI Portal

Ver [`portal.md`](portal.md). Consumo desta camada: `content` (AST JSON) → `toHtml`; `summary`/`subtitle` na listagem; `getCoverImageUrl()`/`variants` na mídia.

## 13. Lacunas

- `LG-NOTICIAS-S01` — Não há amostra real do coletor SUCESU-PE no repositório; o layout documentado vem do fixture de teste (`tests/Feature/V2/Modules/Publicacoes/Noticias/NoticiasImportServiceTest.php:96-150`).
- `LG-NOTICIAS-S02` — Não se sabe se o coletor pode emitir `dados.json` com `data` em outro formato (o parser aceita só `dd/mm[/aaaa]`).
- `LG-NOTICIAS-S03` — Comportamento do `MediaInputer` para imagens GIF/WebP não foi detalhado (pertence a `SVC-MEDIA`/`SVC-GALLERY`).
- `LG-NOTICIAS-S04` — Não há teste para `failed` concorrente; a mensagem exposta no relatório é a do PostgreSQL.

## 14. Checklist de paridade

- [ ] `PC-NOTICIAS-S01` — `run()` sem `apply` deve devolver `planned` para todos os itens e não criar linhas. Fonte: `NoticiasImportService.php:149-173`.
- [ ] `PC-NOTICIAS-S02` — Item sem título ou sem data deve ser `rejected` com `title_missing`/`date_unresolved`. Fonte: `NoticiasImportNormalizer.php:92-123`.
- [ ] `PC-NOTICIAS-S03` — `22/05` + `**Data:** 22/05/2026` deve gerar `2026-05-22` com aviso `year_inherited_from_markdown`. Fonte: `:290-312`.
- [ ] `PC-NOTICIAS-S04` — Imagem igual à capa deve gerar aviso `body_image_equals_cover:<arquivo>` e não entrar no corpo. Fonte: `:434-462`.
- [ ] `PC-NOTICIAS-S05` — Sem capa declarada, arquivo `capa.*` deve ser usado; sem nenhum, aviso `cover_missing`. Fonte: `:406-432`.
- [ ] `PC-NOTICIAS-S06` — Link `javascript:` deve ser removido e contado em `unsafe_links_removed:<n>`. Fonte: `:482-527`.
- [ ] `PC-NOTICIAS-S07` — Segunda execução `apply` deve registrar `skipped` e não criar chaves novas. Fonte: `:219-232,465-468`.
- [ ] `PC-NOTICIAS-S08` — Chave deve ser `sha256("regional:<sigla>:<url>")` e ir para `metadata.import_key_hash`. Fonte: `:205-208,393-415`.
- [ ] `PC-NOTICIAS-S09` — Falha de um item não deve abortar o lote; relatório deve conter `failed` + mensagem. Fonte: `:227-240`.
- [ ] `PC-NOTICIAS-S10` — Corpo importado deve terminar com um nó `contentRichMedia` por imagem do corpo. Fonte: `:363-391`.
- [ ] `PC-NOTICIAS-S11` — Notícia publicada deve aparecer em `v2_public_search_index` com tags `noticia`, `publicacao` e as da regional. Fonte: `NewsSearchSender.php:77-122`.
- [ ] `PC-NOTICIAS-S12` — Despublicar/arquivar deve remover do índice público e do feed (soft delete) sem apagar a notícia; republicar **não** recria o item de feed no legado (ver `PC-NOTICIAS-S16`). Fonte: `Models/News.php:292-339`; `NewsSearchSender.php:25-42,124-134`.
- [ ] `PC-NOTICIAS-S13` — `run()` deve limpar page cache no máximo uma vez, só com `created > 0`. Fonte: `NoticiasImportService.php:489-503`.
- [ ] `PC-NOTICIAS-S14` — O relatório deve conter `context/summary/items/duration_ms/generated_at`. Fonte: `NoticiasImportReport.php:97-117`.
- [ ] `PC-NOTICIAS-S15` — Importação deve gravar `responsible_name = 'SUCESU-PE'` e `source = source_url`. Fonte: `NoticiasImportNormalizer.php:20,155-163`; `NoticiasImportService.php:263-301`.
- [ ] `PC-NOTICIAS-S16` — Ciclo `publicar → despublicar → publicar` deve deixar a notícia no portal/busca **e** reproduzir a ausência do item no feed no modo bug-for-bug; no modo corrigido, o item deve ser restaurado (`withTrashed()` + `restore()`). Fonte: `V2/Services/FeedRSS/Services/FeedRssService.php:39-90`; `V2/Services/FeedRSS/Models/FeedRssItem.php:33`; `V2/Services/FeedRSS/Migrations/2026_07_20_000001_create_v2_feed_rss_items_table.php:44`; `Models/News.php:299-318`.

## 15. Evidências consultadas

- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:1-574`.
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportNormalizer.php:1-728`.
- `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportReport.php:1-117`.
- `V2/Modules/Publicacoes/Noticias/Services/MarkdownToTiptapConverter.php:1-470`.
- `V2/Modules/Publicacoes/Noticias/Services/NewsSearchSender.php:1-135`.
- `V2/Modules/Publicacoes/Noticias/Models/News.php:248-339`.
- `V2/Services/Gallery/Services/GalleryService.php:53-128`; `V2/Services/Gallery/DTOs/GalleryConfig.php:27-34`.
- `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:14-19`; `Parser/ContentASTParser.php:156-158`.
- `V2/Services/Search/Services/PublicIndexerService.php:21-72`; `CMSIndexerService.php:21-45`; `DTOs/PublicSearchDTO.php:22-40`.
- `V2/Services/FeedRSS/Consumer/FeedSender.php:38-87`; `Services/FeedRssService.php:39-69`.
- `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:14-136`.
- `V2/Services/SEO/Services/SEOService.php:293-330`; `V2/Services/Storage/StorageService.php:130-133`.
- `V2/Services/ActivityLogger/ActivityLoggerService.php:20-32`.
- `V2/Modules/Support/NoticiasImport/Controllers/NoticiasImportController.php:82-141`.
- `tests/Feature/V2/Modules/Publicacoes/Noticias/NoticiasImportServiceTest.php:96-219`; `tests/Unit/V2/Modules/Publicacoes/Noticias/NoticiasImportNormalizerTest.php`.
