---
id: SVC-SEO-CONTRATO
tipo: servico
onda: 2
status: concluido
unidades: [SVC-SEO]
fontes:
  - V2/Services/SEO/
depende_de: [SVC-SEO]
consumidores: [MOD-PUBLICACOES-NOTICIAS, MOD-PUBLICACOES-AGENDA, MOD-PUBLICACOES-TRABALHECONOSCO, MOD-LAYOUTS-BASICPAGE, MOD-LAYOUTS-HOMEPAGE, MOD-INSTITUCIONAL-SOBRENOS, MOD-INSTITUCIONAL-HISTORIA, MOD-INSTITUCIONAL-FALECONOSCO, MOD-INSTITUCIONAL-ASSOCIESE, MOD-CONEXOES-ASSOCIADOS, MOD-CONEXOES-PARCEIROSINSTITUCIONAIS, MOD-CONEXOES-UNIDADEREGIONAL, MOD-INFRAESTRUTURA-FEEDRSS, SVC-DASHBOARD]
criticidade: alta
dono: SvcSeo
---

# Contrato do SVC-SEO

Documento de contrato do serviço descrito em [spec.md](spec.md). Cobre: formato persistido de SEO, chaves de configuração semeadas, contrato do componente `seo-input`, contrato HTTP de `SeoPortalController` e matriz de consumidores.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Define o formato `metadata['seo']`, as chaves globais e as respostas de `/robots.txt` e `/sitemap.xml` que todos os módulos devem respeitar. |
| Entrada | POSTs de formulários CMS com prefixos variados; chamadas PHP a `SEOService`; GET das duas rotas públicas. |
| Saída | JSONB `metadata.seo`; 7 linhas em `v2_portal_configs`; `text/plain`; `application/xml`. |
| Efeitos colaterais | Invalidação/expiração de cache pelos módulos; logs `[SEO]`/`[SEO Sitemap]`; visitas em `v2_seo_page_visits`. |
| Particularidade central | O mesmo componente Blade grava em três formatos de payload diferentes; o handler precisa casar com o prefixo. |
| Como se integra | `@include('v2-service-seo::components.seo-input', [...])`, `SEOService`, rotas `v2.seo.*`. |
| Se removido, quebra | Abas de SEO do CMS (Notícias/BasicPage/História), `<head>` do portal, `robots.txt`/`sitemap.xml`, ranking do dashboard. |
| Superfície pública | `seo-input` (Blade), `SEOService` (setters/bindModel), `SeoPortalController@robots/sitemap`, chaves `v2_portal_configs`. |

## 1. Efeito

O contrato existe para responder três perguntas: (a) onde o SEO de uma página fica armazenado; (b) quem ganha quando mais de uma fonte define o mesmo campo; (c) o que exatamente sai em `robots.txt`/`sitemap.xml`.

Efeitos observáveis:

- Gravar `metadata['seo']['title']` em um registro e visitar a página pública resulta em `<title>` igual a esse valor, sem sufixo (`V2/layouts/portal/metadata/seo.blade.php:2`; `V2/Services/SEO/Services/SEOService.php:34-36`).
- Sem nenhum título definido, `<title>` cai em `titulo_site` e, se nulo, em `Portal Oficial` (`V2/Services/SEO/Providers/SEOServiceProvider.php:47,54`).
- `GET /robots.txt` devolve sempre o mesmo corpo, variando apenas as URLs absolutas dos dois sitemaps (`V2/Services/SEO/Controllers/SeoPortalController.php:35-55`).
- `GET /sitemap.xml` é montado por oito consultas independentes, tolerantes a tabela ausente (`SeoPortalController.php:149-434`).

## 2. Abordagem

### 2.1 Formato persistido

O SEO de registro não tem tabela própria: vive na coluna `metadata` (JSONB/array) da tabela do módulo, sob a chave `seo` (`V2/Services/SEO/README.md:9`; `V2/Modules/Layouts/BasicPage/Models/BasicPage.php:18-24`).

```
metadata (JSONB)
└── seo
    ├── title          string ≤255           obrigatório na validação de updateSeo
    ├── description    string ≤500 | null
    ├── keywords       string ≤255 | null
    ├── json_ld        string (JSON bruto) | object | null
    ├── image_id       uuid (GalleryMedia) | ''
    ├── image_url      string ('/storage/...' ou http...) | ''
    └── published_date string 'd/m/Y'         (apenas Notícias; sem consumidor)
```

Campos e quem os lê:

| Campo | Escrito por | Lido por | Efeito |
|---|---|---|---|
| `title` | `BasicPageCmsController::updateSeo`, `NoticiasCmsController::{store,update,updateSeo}`, `seo-input` | `bindModel`, `BasicPage::getSeoData`, `News::getSeoData` | `<title>`, `meta[name=title]`, `og:title`, `twitter:title` |
| `description` | idem | idem | `meta[name=description]`, `og:description`, `twitter:description` |
| `keywords` | idem | idem + `NoticiasSitemapController` (news sitemap) | `meta[name=keywords]`, `news:keywords` |
| `json_ld` | `seo-input`, `updateSeo` | `bindModel` (BasicPage/Associados/Regionais/Agenda/TrabalheConosco) e `BasicPagePortalController` | `<script type="application/ld+json">` |
| `image_id` | `seo-input` (hidden) | ninguém no portal (só o próprio formulário) | reabrir o seletor de mídia |
| `image_url` | `seo-input` (hidden), Notícias (capa), `updateSeo` | `bindModel`/`BasicPage::getSeoData` → `setCoverImage` | `og:image`/`twitter:image` |
| `published_date` | Notícias | ninguém | nenhum |

Escrita canônica (Notícias) e substituição total (BasicPage):

```php
// V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:263-284
if ($request->has('metadata')) {
    $metadata = $request->input('metadata');
}
if (!isset($metadata['seo'])) {
    $metadata['seo'] = [];
}

// SEO: title = título da notícia (ou SEO customizado)
if (empty($metadata['seo']['title'])) {
    $metadata['seo']['title'] = $data['title'];
}

// SEO: description = resumo/subtitle
if (empty($metadata['seo']['description'])) {
    $metadata['seo']['description'] = $data['subtitle'] ?? ($data['summary'] ?? '');
}
```

> Notícias faz *merge* com defaults do formulário. BasicPage, ao contrário, **substitui** o array inteiro:

```php
// V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:303-314
$metadata = $page->metadata ?: [];
$metadata['seo'] = [
    'title' => $request->input('seo.title'),
    'description' => $request->input('seo.description'),
    'keywords' => $request->input('seo.keywords'),
    'json_ld' => $request->input('seo.json_ld'),
    'image_id' => $request->input('seo.image_id'),
    'image_url' => $request->input('seo.image_url'),
];

$page->metadata = $metadata;
$page->save();
```

> Consequência: chaves gravadas por outro fluxo (ex.: `published_date`) desaparecem ao salvar BasicPage. Uma reimplementação que faça merge é mais segura, mas diverge do legado. Validação aplicada antes (`BasicPageCmsController.php:290-298`): `slug` obrigatório e existente; `seo.title` `required|string|max:255`; `seo.description` `nullable|string|max:500`; `seo.keywords` `nullable|string|max:255`; `seo.json_ld`/`seo.image_id`/`seo.image_url` `nullable|string`.

#### Defaults de `BasicPage` por slug (`BasicPage::$seoDefaults`)

Transcrição integral dos valores usados quando `metadata['seo'][campo]` está vazio (`V2/Modules/Layouts/BasicPage/Models/BasicPage.php:32-134`). O `json_ld` default recebe `url` recalculada (`:203-207`).

| Slug | `title` default | `description` default | `keywords` default | `@type` do JSON-LD |
|---|---|---|---|---|
| `homepage` | Maxima — Loteria do Estado da Paraíba | Portal oficial da Loteria do Estado da Paraíba. Acompanhe resultados, operadores autorizados, legislação e serviços da Maxima. | Maxima, Loteria, Paraíba, jogos autorizados, resultados, serviços públicos | `WebSite` + `SearchAction` (`/busca?q={search_term_string}`) |
| `lgpd` | Política de Privacidade e LGPD — Maxima | Informações sobre como a Maxima trata e protege seus dados pessoais em conformidade com a Lei Geral de Proteção de Dados (LGPD — Lei nº 13.709/2018). | LGPD, proteção de dados, privacidade, dados pessoais, Maxima, Lei 13709 | `WebPage` |
| `acessibilidade` | Acessibilidade Digital — Maxima | Recursos de acessibilidade do portal da Maxima em conformidade com o eMAG e WCAG 2.1. Conheça os atalhos de teclado, contraste e suporte a leitores de tela. | acessibilidade, eMAG, WCAG, leitor de tela, contraste, Maxima, inclusão digital | `WebPage` |
| `mapa-do-site` | Mapa do Site — Maxima | Navegue por toda a estrutura do portal da Maxima. Encontre rapidamente páginas, serviços, seções e informações institucionais. | mapa do site, navegação, estrutura, portal, Maxima, links | `WebPage` |
| `ouvidoria` | Ouvidoria — Maxima | Canal oficial de comunicação da Ouvidoria da Loteria do Estado da Paraíba. Envie elogios, reclamações, sugestões, solicitações e denúncias. | ouvidoria, reclamação, sugestão, denúncia, elogio, Maxima, canal cidadão | `ContactPage` |
| `associados` | Associados — SUCESU Nacional | Conheça os associados da SUCESU Nacional. Empresas e profissionais que fazem parte da maior comunidade de TI do Brasil. | SUCESU, associados, empresas, tecnologia, TIC, parceiros | `WebPage` |
| `parceiros-institucionais` | Parceiros — SUCESU Nacional | Conheça os parceiros da SUCESU Nacional. Empresas que colaboram com o desenvolvimento do setor de tecnologia no Brasil. | SUCESU, parceiros, tecnologia, TIC, colaboradores | `WebPage` |
| `noticias` | Notícias — SUCESU Nacional | Acompanhe as principais notícias, eventos e tendências do setor de tecnologia que impactam a SUCESU e seus associados. | SUCESU, notícias, tecnologia, TIC, eventos, inovação | `NewsArticle` |
| `convenios` | Convênios — SUCESU Nacional | Confira os convênios da SUCESU Nacional. Benefícios exclusivos para associados em educação, saúde e serviços. | SUCESU, convênios, benefícios, associados, educação, descontos | `WebPage` |
| `fale-conosco` | Fale Conosco — SUCESU Nacional | Entre em contato com a SUCESU Nacional. Tire dúvidas, envie sugestões ou fale com nossa equipe. | SUCESU, contato, fale conosco, telefone, email, ouvidoria | `ContactPage` |
| `agenda` | Agenda — SUCESU Nacional | Confira a agenda de eventos da SUCESU Nacional. Participe de seminários, palestras, feiras e congressos de tecnologia. | SUCESU, agenda, eventos, tecnologia, seminários, palestras | `WebPage` |

Somente `homepage`, `lgpd`, `acessibilidade`, `mapa-do-site`, `ouvidoria`, `associados`, `parceiros-institucionais`, `noticias`, `convenios`, `fale-conosco` e `agenda` têm defaults; qualquer outro slug cai em `$this->title`/vazio (`BasicPage.php:199-217`). Nenhum desses defaults é usado em `/` — a rota pertence ao módulo Homepage (`V2/Modules/Layouts/Homepage/Routes/web.php:9`), que define title/description/keywords próprios (`HomepagePortalController.php:50-65`).

### 2.2 Precedência real (quem vence)

```mermaid
flowchart LR
    R["1. metadata.seo do registro"] -->|bindModel depois dos setters| W[Vencedor]
    D["2. defaults do controller / modelo"] -->|setTitle etc. ou getSeoData| W
    C["3. v2_portal_configs<br/>titulo_site / descricao / palavras_chaves"] -->|composer: getX() ?: config| W
    L["4. literais<br/>'Portal Oficial' / vazio"] --> W
```

Provas, por ordem de autoridade:

1. **Registro > defaults do módulo**, quando `bindModel` é chamado por último:
   `setTitle($instituicao->name . ' — Associados Regionais — ...')` seguido de `bindModel($instituicao)` (`V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:167-170`); `bindModel` só aplica campos não vazios (`V2/Services/SEO/Services/SEOService.php:34-45`).
2. **Defaults do módulo > registro**, quando a ordem se inverte: em TrabalheConosco o `bindModel($opening)` precede `setTitle($opening->title . ' — ' . config('app.name'))`, que sobrescreve incondicionalmente (`V2/Modules/Publicacoes/TrabalheConosco/Controllers/TrabalheConoscoController.php:67-68`). Description/keywords do registro sobrevivem.
3. **Defaults do modelo > config global**: `BasicPage::getSeoData()` devolve `stored ?: static::$seoDefaults[$slug] ?: $this->title` (`V2/Modules/Layouts/BasicPage/Models/BasicPage.php:199-217`), e `bindBasicPage` aplica esses valores no serviço (`V2/Modules/Layouts/BasicPage/Controllers/BasicPagePortalController.php:30-49`).
4. **Serviço > config global**: `'seoTitle' => $seo->getTitle() ?: ($portalConfigs['titulo_site'] ?? null)` (`V2/Services/SEO/Providers/SEOServiceProvider.php:47`); idem `seoDescription`/`seoKeywords` (`:48-49`).
5. **Config > literal**: `'siteTitle' => $portalConfigs['titulo_site'] ?? 'Portal Oficial'` (`V2/Services/SEO/Providers/SEOServiceProvider.php:54`).
6. **Template**: `<title>{{ $seoTitle ?? ($pageTitle ?? '') . (isset($pageTitle) ? " | " : "") . $siteTitle }}</title>` — `??` tem precedência menor que `.`; como o composer **sempre** define `seoTitle` (string ou `null`), `pageTitle` só aparece quando `seoTitle` é `null` (`V2/layouts/portal/metadata/seo.blade.php:2`). Com `seoTitle` não nulo, o sufixo ` | $siteTitle` **não** é aplicado.
7. **Imagem**: `$rawImage = isset($seo) ? $seo->getCoverImage() : ($coverImage ?? null)` — com o composer ativo, `$coverImage` passado pela view é ignorado (`V2/layouts/portal/metadata/seo.blade.php:12-21`).

Resumo prático: `metadata['seo']` > defaults do controller/modelo > `v2_portal_configs` > literal, **com a ressalva de ordem** (RN-SEO-01). Para listagens não há registro: vencem os setters do controller; se ausentes, a config global.

Caso-limite de valor vazio: `titulo_site` gravado como string vazia (form do CMS) produz `seoTitle = ''`; como `??` só substitui `null`, o `<title>` fica vazio em vez de cair em `Portal Oficial`. O literal `Portal Oficial` só aparece quando `titulo_site` é `null`/ausente (`V2/Services/SEO/Providers/SEOServiceProvider.php:47,54`; `V2/layouts/portal/metadata/seo.blade.php:2`). O mesmo vale para `descricao`/`palavras_chaves` (`content=""`).

### 2.3 SEO de página vs listagem vs sitemap

| Camada | O que define | Persistência | Exemplos |
|---|---|---|---|
| Página de registro | title/description/keywords/imagem/JSON-LD do item | `metadata['seo']` na tabela do módulo | Notícia, Associado, Regional, BasicPage, Parceiro (sem persistência hoje) |
| Página de listagem | título fixo ou dinâmico por filtro | Nenhuma (código) | Notícias (`NoticiasPortalController.php:64-66`), Agenda por data/semana/mês (`EventosPortalController.php:82-91`), Parceiros (`:27-29`), Associados (`:92-94`), Regionais (`:27-29`) |
| Página institucional configurável | 3 chaves globais | `v2_portal_configs` prefixo `historia_seo_` | História (`HistoriaPortalController.php:77-91`) |
| Sitemap | apenas URL, `lastmod`, `changefreq`, `priority` | Cache `seo_sitemap_xml_cache` | Tabela §4.2 de [spec.md](spec.md#51-fontes-do-sitemap) |
| robots | regras de bloqueio fixas | código | `SeoPortalController.php:35-55` |

Sitemap **não** consulta `metadata['seo']`: um registro sem SEO aparece igual no XML; um registro com `title` ótimo não altera prioridade. O news sitemap é a exceção parcial: lê `metadata['seo']['keywords']` (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasSitemapController.php:84-91`), mas pertence ao módulo Notícias.

### 2.4 Duplicação com o metadata do portal

Existem dois caminhos concorrentes no layout:

- `V2/layouts/portal/base.blade.php:36-42` passa `pageTitle`, `pageDescription`, `coverImage`, `gtag`, `schemas` para `v2-portal::metadata`.
- `V2/layouts/portal/metadata.blade.php:1-7` repassa esses valores, mas o composer sobrescreve `seo`, `seoTitle`, `seoDescription`, `seoKeywords`, `seoGtagId`, `seoGlobalTokens`, `seoHeaderScripts`, `seoFooterScripts`, `siteTitle` (`SEOServiceProvider.php:45-54`).

Logo: `pageTitle`/`pageDescription`/`coverImage`/`schemas` são canais legados que só funcionam quando o serviço não tem valor **e** o composer não definiu a variável com `null`; `gtag` é completamente ignorado — `gtag.blade.php` usa `$seoGtagId` e `$seo->getGtag()`, não o `$gtag` recebido (`V2/layouts/portal/metadata/gtag.blade.php:1-18`). Não reimplementar os dois canais: manter o serviço como fonte.

## 3. Particularidades do componente `seo-input`

### 3.1 Props

| Prop | Obrigatória | Default | Efeito |
|---|---|---|---|
| `$model` | não | — | Registro com `metadata`; usa `$model->metadata['seo']` para preencher. Sem model, campos vazios e defaults de exemplo (`seo-input.blade.php:10,33-36`) |
| `$name` | não | `metadata[seo]` | Prefixo de todos os `name` (exceto `seo_image_id`) (`:9,56,84,101,140-141,166`) |
| `ownerModule` | não (não documentado) | — | Passado por Notícias (`_editor-content.blade.php:300`), **ignorado** pelo componente |

`$uuid = str_random(8)` gera sufixo único por instância (`seo-input.blade.php:11`), permitindo várias instâncias na mesma página.

### 3.2 Campos e DOM

| Campo (name) | Elemento | ID | Limite/atributo | Fonte |
|---|---|---|---|---|
| `{prefix}[title]` | `input text` `.js-seo-title` | `seo_title_{uuid}` | `maxlength=70` | `seo-input.blade.php:55-62` |
| `{prefix}[description]` | `textarea` `.js-seo-desc` | `seo_desc_{uuid}` | `maxlength=160`, rows 3 | `:101-108` |
| `{prefix}[keywords]` | `input text` | `seo_key_{uuid}` | — | `:83-88` |
| `{prefix}[json_ld]` | `textarea` `.code-editor` | `seo_json_{uuid}` | objeto → `JSON_PRETTY_PRINT` | `:166-174` |
| `{prefix}[image_id]` | `hidden` | `seo-image-id-input-{uuid}` | preenchido por JS | `:140` |
| `{prefix}[image_url]` | `hidden` `.js-seo-image-url-input` | `seo-image-url-{uuid}` | preenchido por JS | `:141` |
| `seo_image_id` | componente `v2-service-media::components.media-input` | — | sem prefixo | `:131-139` |

Estrutura: `<section id="seo-wrapper-{uuid}" class="seo-input-wrapper card ..." aria-label="Otimização para motores de busca">`, cabeçalho “Otimização para Motores de Busca (SEO)”, snippet `#seo-snippet-{url,title,desc}-{uuid}`, grid Bootstrap de 2 colunas e collapse `#seo-advanced-{uuid}` (`seo-input.blade.php:14-162`).

### 3.3 Defaults transcritos

- Título do snippet: `'Título da Página — Exemplo'`; descrição: `'Descrição da página que aparece nos resultados de busca. Recomenda-se entre 120 e 160 caracteres para uma boa exibição.'` truncada em 160 (`seo-input.blade.php:33-36`).
- URL do snippet: literal `https://www.site.gov.br/pagina-exemplo` (não reflete a página) (`:31`).
- Ajuda: “Recomendado: até **60** caracteres.” e contador `0/70`; “Recomendado: entre **120** e **160** caracteres.” e contador `0/160` (`:64-69,109-114`).
- Imagem: label “Imagem de Compartilhamento (Card)”, `accept="image/*"`, `ownerModule => 'seo'`, ajuda “1200×630px (2:1). Se vazio, usa a logo padrão do site.” (`:131-139`).
- JSON-LD: placeholder `{ "@context": "https://schema.org", "@type": "WebPage", ... }`, ajuda “JSON-LD para dados estruturados do schema.org. Preencha apenas se precisar de marcação customizada.” (`:170,175-178`).

### 3.4 JS, eventos e acessibilidade

```js
// V2/Services/SEO/Views/components/seo-input.blade.php:290-298 — listeners e sincronização de mídia
$titleInput.on('input', updateCounters);
$descInput.on('input', updateCounters);
updateCounters();

/* Sincroniza inputs ocultos com o media-input */
$wrapper.on('media-selected.lotep', function(e, data) {
    $wrapper.find('#seo-image-id-input-{{ $uuid }}').val(data.id);
    $wrapper.find('#seo-image-url-{{ $uuid }}').val(data.url);
});
```

- Evento escutado: `media-selected.lotep` com payload `{id, url}` (emitido pelo `media-input`), escopo no wrapper.
- Contadores/barras: title verde ≤60, amarelo ≤65, vermelho >65; description verde 120–160, amarelo ≥80, vermelho <80 (`seo-input.blade.php:265-287`). Classes `seo-progress-ok|warn|danger` (`:207-209`).
- Collapse dispara `show.bs.collapse`/`hide.bs.collapse` para manter `aria-expanded` (`:300-307`); Bootstrap 5 é dependência dura para o collapse funcionar.
- A11y: `aria-label` na seção e no bloco de snippet, `<label for>` em todos os campos, contadores em `aria-live="polite" aria-atomic="true"`, `role="progressbar"` com `aria-valuenow/min/max`, ícones `aria-hidden="true"` (`:14,25,48,64-71,109-116,152-157`).
- Sem JS: não há contadores nem sincronização de mídia; o POST ainda envia os hidden com o valor inicial (vazio/antigo).

## 4. Integrabilidade — contrato HTTP de `SeoPortalController`

### 4.1 Rotas e grupo

| Método | URI | Nome | Middleware efetivo | Ação |
|---|---|---|---|---|
| GET | `/robots.txt` | `v2.seo.robots` | `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` | `robots()` |
| GET | `/sitemap.xml` | `v2.seo.sitemap` | idem | `sitemap()` |

Registro: `V2/Services/SEO/Routes/web.php:9-10`; middlewares aplicados pelo provider (`V2/Core/Providers/V2ServiceProvider.php:211-220`). Sem `auth`, sem permissão, sem throttle.

### 4.2 Respostas

| Endpoint | Status | Headers | Corpo |
|---|---|---|---|
| `/robots.txt` | 200 | `Content-Type: text/plain; charset=utf-8`; `Cache-Control: public, max-age=60, must-revalidate`; `Vary: Accept-Encoding`; `X-Cache-Status` (page cache) | Texto exato de `SeoPortalController.php:35-55`, terminando com `\n` (implode) |
| `/sitemap.xml` | 200 | `Content-Type: application/xml; charset=utf-8`; mesmos de cache | `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` + `<url>` por item |
| Manutenção ativa, anônimo | 503 | HTML do CMS | página `v2-cms::staticPages.maintenance` |
| Manutenção ativa, AJAX/JSON | 503 | `application/json` | `{"error":"Servico temporariamente indisponivel para manutencao.","code":503}` |

O `Content-Type` é o único header definido pelo controller (`SeoPortalController.php:59-60,133-134`); `Cache-Control` vem do middleware e vale 60 s (o comentário do controller diz 300 s — divergência registrada em `01_arquitetura/09-cache-csp-minify-maintenance.md:412`).

### 4.3 Conteúdo exato do robots

```http
User-agent: *
Allow: /

# CMS — Área administrativa
Disallow: /cms/

# API — Endpoints de dados
Disallow: /api/

# Áreas interativas com formulários
Disallow: /trabalhe-conosco
Disallow: /associe-se
Disallow: /fale-conosco

# Páginas de busca — URLs parametrizadas
Disallow: /busca


# Sitemaps
Sitemap: {route('v2.seo.sitemap')}
Sitemap: {route('v2.noticias.sitemap.news')}
```

Fonte literal: `V2/Services/SEO/Controllers/SeoPortalController.php:35-57`. Não há `Crawl-delay`, `Disallow: /busca/` (só `/busca`) nem bloqueio de `news-sitemap.xml`.

### 4.4 Algoritmo e falhas do sitemap

1. `date('G') === 3 && date('i') <= 5` → `Cache::forget('seo_sitemap_xml_cache')` (`:76-80`).
2. `Cache::remember('seo_sitemap_xml_cache', 360, ...)` (`:82`).
3. Cada coletor: `Schema::hasTable(...)` → query em `DB::table` → `try/catch` com `Log::warning('[SEO Sitemap] Erro ao buscar <Fonte>: ...')`.
4. `renderSitemapXml()` aplica `htmlspecialchars(..., ENT_XML1, 'UTF-8')` apenas em `loc` e concatena os quatro elementos (`:445-462`).
5. Falha total inexistente: sem dados, retorna `<urlset>` vazio com 200.

Contrato de cada item: `loc` absoluto (`url('/')` + path), `lastmod` `Y-m-d`, `changefreq` ∈ {`daily`,`weekly`,`monthly`}, `priority` ∈ {`0.4`,`0.5`,`0.6`,`0.7`,`1.0`} (tabela completa em [spec.md](spec.md#51-fontes-do-sitemap)).

## 5. Configuração e permissões — chaves semeadas

Migration `2026_07_02_140000_seed_seo_configuration_keys` grava 7 chaves por `updateOrInsert(['config_key'], ['config_key','config_value'])` (`:28-33`). Defaults transcritos:

| Chave | Valor semeado | Default do serviço (`PortalConfigsService::getDefaults`) | Default da migration da tabela | Efeito |
|---|---|---|---|---|
| `google_analytics_id` | `null` | `''` | `null` | habilita `gtag.js` com `anonymize_ip: true` (`gtag.blade.php:5-18`) |
| `global_meta_tokens` | `null` | `''` | `null` | HTML cru no `<head>` (`seo.blade.php:34`) |
| `header_scripts` | `null` | `''` | `null` | HTML cru em `metadata.metaheader` (`metaheader.blade.php:1`) |
| `footer_scripts` | `null` | `''` | `null` | HTML cru em `metadata.metafooter` (`metafooter.blade.php:1`) |
| `titulo_site` | `'Portal Oficial'` | `''` | `null` | fallback de título e sufixo; `siteTitle` (`SEOServiceProvider.php:47,54`) |
| `descricao` | `null` | `''` | `null` | description global (`:48`) |
| `palavras_chaves` | `null` | `''` | `null` | keywords globais (`:49`) |

Atenção de contrato: o seed sobrescreve `config_value` **inclusive com `null`** em linhas existentes; rodá-lo em produção apaga a configuração atual e força `titulo_site = 'Portal Oficial'` (RN-SEO-07). No `down()`, as 7 chaves são deletadas (`2026_07_02_140000_seed_seo_configuration_keys.php:43-61`).

O `updateOrInsert` passa apenas `config_key` e `config_value` (`:29-32`), mas a tabela exige `id` uuid sem default (`V2/Modules/Support/SupportConfigs/Migrations/2026_06_30_000005_create_v2_portal_configs_table.php:38`). [I] Em fluxo normal todas as 7 chaves já existem (criadas no `up()` daquela migration, `:70-92`), então só o caminho de UPDATE executa; se uma chave faltar (linha apagada, soft delete ou tabela recriada sem seed), o INSERT falha por `NOT NULL` em `id` e a migration inteira sofre rollback. A reimplementação deve gerar `id = v2_uuid()` no insert.

Chaves de SEO de módulo (fora do seed, gravadas por História): `historia_seo_title`, `historia_seo_description`, `historia_seo_keywords`, com `config_group = 'MODULOS'` (`V2/Modules/Institucional/Historia/Controllers/MilestoneCmsController.php:311-330`). Não colidem com as chaves acima.

## 6. Receitas de uso

**1) Criar uma nova página com SEO de registro**

```php
// controller público
$this->seo->setTitle('Título padrão da página');          // camada 2
$this->seo->setDescription('Descrição padrão');
$this->seo->setKeywords('palavras, chave');
$this->seo->bindModel($registro);                          // camada 1 (vence)
return view('modulo::portal.show', compact('registro'));
```

No CMS, incluir `seo-input` com `'name' => 'metadata[seo]'` e gravar `metadata['seo']` no handler.

**2) Formulário com prefixo próprio**

```blade
@include('v2-service-seo::components.seo-input', [
    'model' => $item ?? null,
    'name'  => 'seo',
])
```

Handler deve validar `seo.title` etc. e gravar em `metadata['seo']` (padrão BasicPage) — o prefixo do formulário **não precisa** ser igual ao caminho persistido.

**3) Configuração de módulo (3 chaves)**

```php
$seo = $request->input('historia_seo', []);
foreach ([
    'historia_seo_title'       => $seo['title'] ?? '',
    'historia_seo_description' => $seo['description'] ?? '',
    'historia_seo_keywords'    => $seo['keywords'] ?? '',
] as $key => $value) {
    DB::table('v2_portal_configs')->updateOrInsert(['config_key' => $key], [
        'config_value' => $value, 'config_title' => $key,
        'config_group' => 'MODULOS', 'updated_at' => now(),
    ]);
}
```

Fonte: `MilestoneCmsController.php:311-330`. Resultado: `HistoriaPortalController` lê e aplica (`:77-91`).

**4) Inspecionar o contrato HTTP**

```bash
curl -s https://portal/robots.txt | sed -n '1,30p'
curl -s https://portal/sitemap.xml | head -c 800
```

Esperado: texto com `Sitemap:` duas vezes; XML com `<urlset ...>` e itens na ordem das 8 fontes.

## 7. Anti-uso

1. **Prefixar o formulário com `metadata[seo]` e o controller validar `seo.*`** — o payload não chega às regras; `seo.title` é obrigatório e a requisição falha com “The seo.title field is required.”.
2. **Sobrescrever `metadata['seo']` inteiro em um formulário parcial** — perde `published_date`, `image_id` e chaves futuras (BasicPage faz isso hoje; RN-SEO-14).
3. **Esperar que o `seo-input` salve sozinho** — ele não tem `action`, `method` nem submit; os dados só persistem pelo handler do módulo.
4. **Reexecutar a migration de seed em ambiente configurado** — apaga as 7 chaves e força `Portal Oficial` (RN-SEO-07).
5. **Remover o módulo Notícias achando que só o news sitemap cai** — `robots.txt` passa a responder 500 (RN-SEO-09).

## 8. Rotas e endpoints

| Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|
| GET | `/robots.txt` | `v2.seo.robots` | `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` | `SeoPortalController@robots` | pública | 200 `text/plain; charset=utf-8`; regras fixas + 2 sitemaps | `V2/Services/SEO/Routes/web.php:9` |
| GET | `/sitemap.xml` | `v2.seo.sitemap` | idem | `SeoPortalController@sitemap` | pública | 200 `application/xml; charset=utf-8`; 8 fontes; cache 360 min | `V2/Services/SEO/Routes/web.php:10` |

Rotas de escrita de SEO **não pertencem à unidade**; os contratos consumidos são:

| Método | URI | Nome | Onde | Payload |
|---|---|---|---|---|
| POST | `/cms/paginas-fixas/seo/update` | `v2.basicpage.seo.update` | `V2/Modules/Layouts/BasicPage/Routes/cms.php:16` | `slug` + `seo[title|description|keywords|json_ld|image_id|image_url]` |
| POST | `/cms/noticias/{id}/seo` | `v2.noticias.cms.seo-update` | `V2/Modules/Publicacoes/Noticias/Routes/cms.php:79-82` | `seo[...]` (sem formulário consumidor conhecido) |
| POST | `/cms/historia/config` | `v2.historia.cms.config.update` | `V2/Modules/Institucional/Historia/Routes/cms.php` | `historia_seo[title|description|keywords]` |

## 9. Dados e migrations

| Migration | Efeito | Dependência de ordem | Fonte |
|---|---|---|---|
| `2026_06_29_093500_create_seo_tables` | cria `v2_seo_page_visits` (uuid PK, `url_path`, `user_agent`, `ip_address`, `referer`, `is_bot` default `false`, timestamps, índices `url_path`/`is_bot`); transação + `SET lock_timeout = '3s'` | independente | `V2/Services/SEO/Migrations/2026_06_29_093500_create_seo_tables.php:16-45` |
| `2026_07_02_140000_seed_seo_configuration_keys` | insere/atualiza 7 chaves em `v2_portal_configs` | exige `v2_portal_configs` criada em `2026_06_30_000005` | `2026_07_02_140000_seed_seo_configuration_keys.php:11-41` |

Rollback: a primeira renomeia para `backup_v2_seo_page_visits_<Y_m_d_His>` (não dropa); a segunda deleta as 7 chaves (`2026_06_29...:52-72`; `2026_07_02...:43-61`).

Formato persistido de SEO: §2.1. Não há model próprio para `v2_seo_page_visits`; acesso via query builder (`SEOService.php:189,218`).

## 10. Observabilidade e erros

| Evento | Nível/canal | Mensagem/efeito | Fonte |
|---|---|---|---|
| Falha ao enfileirar visita | `warning` | `[SEO] Falha ao rastrear acesso (Buffer): {msg}` | `SEOService.php:165-167` |
| Falha ao inserir lote | `error` | `[SEO] Erro ao descarregar buffer de acessos no banco: {msg}` | `:192-194` |
| Falha em fonte do sitemap | `warning` | `[SEO Sitemap] Erro ao buscar {BasicPages|Notícias|Agenda|Parceiros|Diretoria|História|Regionais|Associados}: {msg}` | `SeoPortalController.php:169,203,243,283,324,350,390,431` |
| Falha de migration | `error` | `ERRO NA MIGRACAO CREATE_SEO_TABLES: {msg}` / `ERRO NA MIGRACAO SEED_SEO_KEYS: {msg}` | migrations `:42`, `:38` |
| Rollback | `alert` | `MIGRAÇÃO ROLLBACK: Tabela 'v2_seo_page_visits' preservada como '<backup>'` | `2026_06_29...:63` |
| SEO de registro salvo | auditoria do módulo | `seo_updated`/`updated` via `ActivityLoggerService` | `NoticiasCmsController.php:674-677`; `BasicPageCmsController.php:317-325`; `MilestoneCmsController.php:335-337` |

Sem métrica/telemetria própria; sem auditoria nesta unidade. Falhas sempre *fail-open* para o request público (exceto `robots()` sem rota do módulo Notícias, que falha fechado com 500).

## 11. Consumidores

| Consumidor | Página/rota | Fonte de SEO | Prefixo do formulário | Observação de contrato |
|---|---|---|---|---|
| Notícias | `/noticias`, `/noticia/{slug}`, preview CMS | `metadata['seo']` → `getSeoData()` | `metadata[seo]` | `json_ld`/`image_url` não renderizados; keywords no news sitemap |
| Agenda | `/agenda`, `/agenda/{slug}` | setters + `bindModel` (no-op) | — (sem `seo-input`) | Título de listagem muda com filtro data/semana/mês |
| BasicPage | `/lgpd`, `/acessibilidade`, `/mapa-do-site`, `/ouvidoria`, catch-all | `getSeoData()` com defaults por slug | `seo` | Substituição total do array; defaults “Maxima” |
| Associados | `/associados`, `/associado/{slug}` | setters + `bindModel` | — | Registro vence |
| Parceiros | `/parceiros-institucionais`, `/…/detalhe/{id}` | setters só no índice | — | Detalhe sem SEO próprio |
| UnidadeRegional | `/regionais`, `/regional/{slug}` | setters + `bindModel` | — | `description` vem de `metadata['resumo']` |
| Homepage | `/` | setters + `Organization` | — | Não usa `BasicPage` |
| SobreNos | `/sobre-nos` | setters + `AboutPage` | — | `url` fixa `url('/sobre-nos')` |
| TrabalheConosco | `/trabalhe-conosco`, vaga | setters; `bindModel` antes do título da vaga | — | Título do módulo vence |
| História | `/historia`, CMS config | `v2_portal_configs` (`historia_seo_*`) | `historia_seo` | Imagem/JSON-LD do componente descartados |
| FaleConosco / AssocieSe / FeedRss | rotas próprias | setters | — | Baixo risco |
| Dashboard | `/cms` | `getPageRanking` | — | Falha → coleção vazia |

## 12. Lacunas

- `LG-SEO-13` — Não há formulário que envie `seo[...]` para `v2.noticias.cms.seo-update`; o contrato desse endpoint é inferido da validação (`NoticiasCmsController.php:652-669`). [I]
- `LG-SEO-14` — `seo-input` não expõe campo de canonical/robots/OG type; o contrato de `<head>` é fixo (`og:type=website`, `twitter:card=summary_large_image`) (`seo.blade.php:8,24`).
- `LG-SEO-15` — Não há contrato de sanitização para `global_meta_tokens`/`header_scripts`/`footer_scripts` (HTML cru) nem para `json_ld` (`</script>` não é escapado) (`seo.blade.php:34,37-43`).
- `LG-SEO-16` — O prefixo `ownerModule` passado ao componente não tem efeito documentado nem implementação (`seo-input.blade.php:5-12`; `_editor-content.blade.php:300`).

## 13. Checklist de paridade

- [ ] `PC-SEO-16` — Persistência em `metadata['seo']` com os 6 campos (+`published_date` em Notícias) e leitura por `bindModel`/`getSeoData`.
- [ ] `PC-SEO-17` — Precedência: registro > defaults > config > literal, respeitando a ordem de chamada por módulo (RN-SEO-01).
- [ ] `PC-SEO-18` — `seo-input`: props `model`/`name`, 6 campos, snippet, contadores 70/160, `media-selected.lotep`, hidden `image_id`/`image_url` e `seo_image_id`.
- [ ] `PC-SEO-19` — Prefixos por consumidor: `metadata[seo]` (Notícias), `seo` (BasicPage), `historia_seo` (História).
- [ ] `PC-SEO-20` — `robots.txt` idêntico ao bloco de §4.3, incluindo as duas linhas `Sitemap:`.
- [ ] `PC-SEO-21` — `sitemap.xml` com 8 fontes, prioridades/changefreq e tolerância a tabela ausente.
- [ ] `PC-SEO-22` — `Cache-Control: public, max-age=60, must-revalidate` + `Vary: Accept-Encoding` nos dois endpoints.
- [ ] `PC-SEO-23` — Seed das 7 chaves com os valores da tabela §5 e comportamento destrutivo documentado.
- [ ] `PC-SEO-24` — Auditoria `seo_updated`/`updated` registrada pelos módulos após salvar SEO.

## 14. Evidências consultadas

- `V2/Services/SEO/Views/components/seo-input.blade.php` (1-311), `V2/Services/SEO/Controllers/SeoPortalController.php` (1-463), `V2/Services/SEO/Providers/SEOServiceProvider.php` (1-70), `V2/Services/SEO/Routes/web.php` (1-10), `V2/Services/SEO/Migrations/2026_06_29_093500_create_seo_tables.php` (1-73), `V2/Services/SEO/Migrations/2026_07_02_140000_seed_seo_configuration_keys.php` (1-62), `V2/Services/SEO/README.md` (1-66)
- `V2/layouts/portal/metadata.blade.php` (1-10), `metadata/seo.blade.php` (1-50), `metadata/gtag.blade.php` (1-20), `metadata/metaheader.blade.php`, `metadata/metafooter.blade.php`, `base.blade.php` (28-150)
- `V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php` (285-325), `Controllers/BasicPagePortalController.php` (24-50), `Models/BasicPage.php` (18-217), `Routes/cms.php` (8-16), `Views/cms/index.blade.php` (101-140), `Views/cms/preview.blade.php` (57-87)
- `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php` (258-296, 435-470, 645-682), `Controllers/NoticiasPortalController.php` (64-98), `Models/News.php` (341-355), `Views/cms/includes/_editor-content.blade.php` (290-303), `Routes/cms.php` (78-82), `Controllers/NoticiasSitemapController.php` (23-145)
- `V2/Modules/Publicacoes/Agenda/Controllers/EventosPortalController.php` (82-115), `V2/Modules/Publicacoes/TrabalheConosco/Controllers/TrabalheConoscoController.php` (41-68)
- `V2/Modules/Institucional/Historia/Controllers/HistoriaPortalController.php` (77-92), `Controllers/MilestoneCmsController.php` (290-343), `Views/cms/config.blade.php` (1-38)
- `V2/Modules/Support/SupportConfigs/Services/PortalConfigsService.php` (37-71, 199-246), `Migrations/2026_06_30_000005_create_v2_portal_configs_table.php` (37-90)
- `V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php` (92-94, 167-170), `V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisPortalController.php` (23-50), `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalPortalController.php` (27-52)
- `V2/Modules/Layouts/Homepage/Controllers/HomepagePortalController.php` (45-70), `V2/Modules/Institucional/SobreNos/Controllers/SobreNosPortalController.php` (68-84)
- `V2/Core/Providers/V2ServiceProvider.php` (178-220), `V2/Core/Middleware/PublicCacheMiddleware.php` (24-110), `V2/Core/Middleware/PageCacheMiddleware.php` (1-90), `V2/Core/Middleware/MaintenanceMiddleware.php` (1-60)
- `config/app.php` (16, 171, 185)
