# P7 — Descoberta: busca local, IndexNow e feeds

| Campo | Valor |
|---|---|
| Programa | F7+ — operação, estrutura e conteúdo |
| Status | `planejado` |
| Depende de | P6 |
| Habilita | P8, P9 (busca compartilhada) |
| Tamanho | **M** (3–4 sessões) |
| ADRs | ADR-050 (busca local-first + service no 2º consumidor), ADR-007, ADR-012, ADR-015, ADR-027, ADR-022, ADR-038 |

## 1. Objetivo

Tornar o conteúdo de Notícias **encontrável** — busca full-text em português no portal e no CMS,
IndexNow na publicação e feed RSS/Atom — sem service prematuro, sem índice mantido por trigger em
paralelo e sem depender da API do Google.

**Resultado observável:** `/noticias/busca?q=saúde` devolve resultados ranqueados (título > resumo >
corpo), rascunho não aparece; publicar dispara IndexNow em fila; `/noticias/feed.xml` é XML válido
com `ETag`/304; o `SearchAction` do SEO aponta para uma rota que existe.

## 2. Estado atual

- Nenhum FTS: filtros usam `ILIKE`/`LIKE`; `servico-search` está no backlog.
- `BuildSeoGraph` condiciona `SearchAction` a uma URL configurada (`config/seo.php`), mas não valida
  que a rota existe.
- IndexNow **não existe** (ADR-027 §9 decidiu, código zero).
- Notícias já tem sitemap e SEO; feed não existe.
- `RichText` oferece `toText()`/`toMarkdown()` para derivar texto do AST (ADR-028).

## 3. Escopo fechado

### S1 — Busca local em Notícias (FTS nativo)

- Coluna `noticias.search_text text not null default ''`, derivada do AST por uma Action
  (`DeriveSearchText`) no save (não por trigger, não por `saving` duplicado). `toText()` remove
  marcação, normaliza espaços e corta em limite seguro (ex.: 100k chars).
- Coluna gerada `search_tsv tsvector`:
  `setweight(to_tsvector('portuguese', coalesce(title,'')), 'A') ||
   setweight(to_tsvector('portuguese', coalesce(summary,'')), 'B') ||
   setweight(to_tsvector('portuguese', coalesce(search_text,'')), 'C')`
  com `GENERATED ALWAYS AS (...) STORED` e índice **GIN**. Config de texto explícita
  (`'portuguese'::regconfig`) para a expressão ser imutável.
- Consulta: `websearch_to_tsquery('portuguese', :q)` (aceita aspas/OR/-), limite de tamanho da
  query, `ts_rank_cd` + `published_at desc, id desc` como desempate; paginação nativa.
  Nenhum `%`/`_` concatenado em SQL; nenhum `ILIKE` no caminho público.
- Portal: `GET /noticias/busca?q=` (form acessível, `role=search`, label, resultado vazio
  explicativo, `X-Robots-Tag: noindex, follow`); CMS: filtro `q` usa o mesmo `tsv`.
- `SearchAction` do `Seo`: valida `Route::has()` e que a rota é do portal; se não existir, não
  emite `SearchAction` (em vez de prometer 404).
- Testes: acento/plural (stemming pt), título vence corpo, rascunho/arquivado fora, query vazia,
  query gigante, tentativa de injeção, `search_text` atualizado ao editar conteúdo.

### S2 — IndexNow (substitui a API do Google)

- Setting `seo.indexnow_key` (32–128 chars, `secret` se necessário) e `seo.indexnow_enabled`
  (default `false` no código; ligado pelo painel).
- Rota same-origin `GET /{key}.txt` no grupo de root files do Seo, devolvendo a chave em texto
  (somente se configurada; `ETag`/304); nunca listar a chave em sitemap/robots.
- Job `SubmitIndexNow` (fila) disparado em `noticias.published`/`noticias.unpublished` (e futuros
  módulos via porta Core `IndexNowSource`/evento). Payload:
  `{host, key, keyLocation, urlList}`; POST `https://api.indexnow.org/indexnow` com timeout 5 s,
  no máximo N URLs por lote; falha é logada e não bloqueia a publicação.
- Config/allowlist de egress: a chamada é **server-side**, não asset de browser; documentar no
  ADR-027/segurança. `Http::preventStrayRequests()` nos testes; fake do endpoint.
- Comando `seo:indexnow {url?}` para reenvio manual e diagnóstico; `--dry-run`.
- Testes: chave ausente → job não chama; 200/202/4xx; lote; falha não quebra publicação; chave
  não aparece em HTML.

### S3 — Feed RSS/Atom de Notícias

- `GET /noticias/feed.xml` (e alias `/feed.xml` se o portal não tiver conflito): RSS 2.0 com
  `channel` (title, link, description, language, lastBuildDate) e itens publicados, ordem
  determinística (`published_at desc, id desc`), limite configurável.
- `XMLWriter` nativo (sem dependência), escape correto, `Content-Type: application/rss+xml;
  charset=UTF-8`, `ETag`/304, cache público curto; `guid` estável (`url` canônica), `pubDate`
  RFC 822 com `intl`/`Carbon` determinístico.
- Conteúdo: título, link, `description` (resumo em texto puro, sem HTML), `author` opcional;
  nenhum PII; links internos com locale.
- Áreas restritas nunca entram no feed (ADR-043); rascunho/arquivado fora; `noindex` não se aplica
  ao feed (ele é público), mas `robots.txt` pode apontar.
- Testes: XML válido, escape de `&<>`, ordem, limite, publicado/despublicado, `ETag`/304.
- **Não** criar service `FeedRSS` nesta fase: um consumidor (Notícias). O service nasce em P9
  quando o segundo consumidor existir (Dados Abertos/Agenda).

### S4 — Sitemap e fechamento

- Notícias: categorias/tags/páginas publicadas no sitemap via `SitemapSource` já existente;
  busca e preview **fora** do sitemap.
- Índice de busca não entra em `robots.txt` como `Disallow` (é público), mas a paginação profunda
  pode ser `noindex, follow`.
- E2E: busca no portal, resultado, feed, IndexNow fake; `make check` 2× + `E2E=1`; axe na busca.
- Atualizar `INVENTARIO.md` (search/IndexNow/Feed) e `servico-search.md` (fase 1 concluída).

## 4. Fora de escopo

- Service `Search` compartilhado (P9; só com o 2º consumidor: Licitações/Legislação).
- Busca global no portal (múltiplos módulos) — P9.
- API do Google Indexing (descartada, ADR-027).
- Elasticsearch/Meilisearch/algolia (dependência e operação desnecessárias).
- Busca semântica/LLM.
- Feed por categoria/tag (pode vir depois; o feed principal basta).

## 5. Fatias de sessão

| Sessão | Entrega | Prova | Depende |
|---|---|---|---|
| **P7.S1** | `search_text` + `tsv` gerado + GIN + busca portal/CMS | stemming/acento; ranking; rascunho fora; injeção | P6 |
| **P7.S2** | IndexNow (setting, key route, job, comando) | fake HTTP; falha não bloqueia; chave não vaza | P6 |
| **P7.S3** | feed RSS + `ETag`/304 | XML válido; escape; ordem; 304 | S1 |
| **P7.S4** | sitemap/search action + E2E + docs | `SearchAction` só com rota; axe; 2× check | S1–S3 |

## 6. Contratos e dados

- **Colunas:** `noticias.search_text`, `noticias.search_tsv` (gerada) + índice GIN.
- **Settings:** `seo.indexnow_key`, `seo.indexnow_enabled`.
- **Rotas:** `noticias.portal.busca`, `noticias.portal.feed`, `seo.indexnow.key`.
- **Job:** `SubmitIndexNow` (fila, timeout, lotes).
- **Eventos:** `noticias.published`/`noticias.unpublished` alimentam o job.
- **Porta futura:** `IndexNowSource`/`SearchSource` (P9) — não criar antes do 2º consumidor.
  Em P9 a integração é **opt-in**: o módulo chama o service e envia um objeto padrão separado para
  `portal` e `cms`; módulo sem fonte simplesmente não entra no índice (D7).

## 7. DoD

- [ ] Busca local em português com `tsvector` gerado, GIN e ranking A/B/C; nenhum trigger paralelo.
- [ ] Rascunho/arquivado fora da busca; query sanitizada por `websearch_to_tsquery`.
- [ ] IndexNow opcional, em fila, com timeout e fail-open; chave servida same-origin e não vazada.
- [ ] Feed RSS válido, determinístico, com `ETag`/304 e sem HTML.
- [ ] `SearchAction` só é emitido se a rota existir.
- [ ] `make check` 2× + `E2E=1`; cobertura ≥90%; PHPStan max 0.

## Fechamento — ficha de revisão

> **Obrigatório antes de fechar:** criar `_to_revision/P7-descoberta-busca.md` e seguir
> `_to_revision/README.md` + `_to_revision/PROTOCOLO.md` (v2.0). A ficha não é gate de build; é a
> entrada da fila de revisão profunda.

- Cabeçalho: fase, `kd` épico/tasks/slices, âncoras (este arquivo, ADRs e código) e status
  `⬜ pendente` → `🟡 em revisão` → `✅ revisado` / `🔧 com correções`.
- Conteúdo: o que foi implementado, arquivos/superfícies a revisar, checklist D1–D14 aplicável,
  riscos e observações.
- Fechamento: preencher “Notas da revisão” com data/revisor/resultado e achados (`kd write
  --anchor`), atualizar a fila em `_to_revision/README.md` e `_to_revision/_inventario.md`.
- Achado com correção exige teste vermelho→verde; a fase só é `closed` no `kd` com a ficha
  registrada.

## 8. Riscos

| Risco | Mitigação |
|---|---|
| Duas verdades do índice | `search_text` escrito por uma Action; `tsv` gerado das colunas; sem trigger |
| Busca lenta | GIN + `ts_rank_cd` + limite/paginação; medir com `EXPLAIN` |
| Query maliciosa | `websearch_to_tsquery`, limite de tamanho, sem concatenar SQL |
| IndexNow bloquear publicação | job em fila + timeout + fail-open; teste de falha |
| Chave IndexNow vazar | rota dedicada, sem sitemap/robots/HTML; teste |
| Feed com HTML/XSS | `description` em texto puro + `XMLWriter` com escape; teste |

## 9. Retomada

```bash
kd prime
kd ask "busca FTS tsvector IndexNow feed RSS SearchAction"
kd task list --ready
```

Ler: `src/services/Seo/Actions/BuildSeoGraph.php` → `src/services/Seo/SeoSettings.php` →
`src/services/Seo/Routes/*` → `src/modules/Publicacoes/Noticias/**` → `lotep-v2/publicacoes-noticias/09`
(busca e reindex) → `sucesu_analise/03_servicos-ui/search/spec.md`.

Referências irmãs (UX/desenho, não código): `../sucesu-cms/V2/Services/Search/**` e
`../lotep/V2/Services/Search/**` (`tsvector` A/B/C, GIN, upsert, reindex, `ts_headline`). Rejeitar
trigger em paralelo ao índice e `ILIKE` sem índice.
