---
id: SVC-SEO
tipo: servico
onda: 2
status: concluido
unidades: [SVC-SEO]
fontes:
  - V2/Services/SEO/
depende_de: [CORE-BOOT, CORE-CMS, UI-PORTAL, SVC-MEDIA, MOD-SUPPORT-SUPPORTCONFIGS]
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
---

# Serviço SEO & Metadados (SVC-SEO)

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Preenche `<head>` do portal com title/metas/OG/Twitter/canonical/JSON-LD a partir do estado da requisição, serve `/robots.txt` e `/sitemap.xml` dinâmicos e registra visitas em `v2_seo_page_visits`. |
| Entrada | View composer nas views `v2-portal::*` de metadados/cabeçalho/rodapé; setters chamados por controllers de módulos; `GET /robots.txt`; `GET /sitemap.xml`; include Blade `v2-service-seo::components.seo-input`. |
| Saída | HTML `<title>`, `<meta name="description|keywords|title">`, `og:*`, `twitter:*`, `<link rel="canonical">`, blocos `<script type="application/ld+json">`, scripts globais; `text/plain` (robots); `application/xml` (sitemap); linhas em `v2_seo_page_visits`; chaves em `v2_portal_configs`. |
| Efeitos colaterais | 1–8 execuções de `trackAccess()` por página renderizada (buffer em cache, flush a cada 50); `Cache::remember('seo_sitemap_xml_cache', 360)`; `Log::warning`/`Log::error` com prefixos `[SEO]` e `[SEO Sitemap]`; auditoria/limpeza de page cache é feita pelos módulos, não aqui. |
| Particularidade central | É o único ponto que converte estado de request em metadados do portal; a precedência real é “registro `metadata['seo']` > defaults do controller/modelo > `v2_portal_configs` > literal”. |
| Como se integra | `SEOService` (singleton, `V2\Services\SEO\Services\SEOService`), provider `SEOServiceProvider` (composer + `v2-service-seo` views), `SeoPortalController` (rotas `v2.seo.*`), componente `seo-input`, migration de tabela e de seed de configs. |
| Se removido, quebra | Todo `<head>` do portal (meta vazias/erro de view), `robots.txt`/`sitemap.xml` retornam 404, ranking de páginas do dashboard fica vazio, ~15 controllers falham no container (`SEOService` não resolvido), abas de SEO do CMS perdem o componente. |
| Superfície pública | `SEOService::{bindModel,setTitle,setDescription,setKeywords,setCoverImage,setCanonical,addSchema,setGtag,setBreadcrumbSchema,trackAccess,flushBuffer,getPageRanking,get*}`; `SeoPortalController::{robots,sitemap}`; rotas `v2.seo.robots`, `v2.seo.sitemap`; componente `v2-service-seo::components.seo-input`. |

## 1. Efeito

### 1.1 Transformação que o serviço realiza

Recebe o estado acumulado na requisição (`SEOService` singleton preenchido por `setTitle`/`bindModel`/`addSchema` de um controller de módulo) e a configuração global (`v2_portal_configs`) e produz, no momento em que uma view `v2-portal::*` listada é renderizada, um conjunto de variáveis (`seo`, `seoTitle`, `seoDescription`, `seoKeywords`, `seoGtagId`, `seoGlobalTokens`, `seoHeaderScripts`, `seoFooterScripts`, `siteTitle`) consumidas por `V2/layouts/portal/metadata/seo.blade.php` e `.../gtag.blade.php` (`V2/Services/SEO/Providers/SEOServiceProvider.php:32-54`).

Em paralelo, a mesma execução do composer chama `trackAccess()`, que monta um array de visita e o acumula em `Cache::get('seo_access_buffer')`, descarregando em lote (`DB::table('v2_seo_page_visits')->insert()`) ao atingir 50 itens (`V2/Services/SEO/Services/SEOService.php:121-197`).

`GET /robots.txt` devolve um texto fixo com regras de bloqueio e duas linhas `Sitemap:` (`V2/Services/SEO/Controllers/SeoPortalController.php:32-62`); `GET /sitemap.xml` devolve XML construído por concatenação a partir de oito consultas a tabelas de módulos (`V2/Services/SEO/Controllers/SeoPortalController.php:73-141`).

### 1.2 Efeitos sobre dados e sistema

| Efeito | Recurso afetado | Ponto no código | Fonte |
|---|---|---|---|
| Insere lote de visitas (até 50) com `id` UUID, `url_path`, `user_agent` (255), `ip_address`, `referer` (255), `is_bot` | tabela `v2_seo_page_visits` (Postgres) | `flushBuffer()` | `V2/Services/SEO/Services/SEOService.php:173-195` |
| Mantém buffer de visitas no cache (TTL 1440 min) | store padrão de cache (`CACHE_DRIVER`, default `file`) | `trackAccess()` | `V2/Services/SEO/Services/SEOService.php:157-163` |
| Popula variáveis de metadados para o layout | views `v2-portal::metadata*`, `header.header-logo`, `footer.footer-copyright`, `print.print-header` | closure do composer | `V2/Services/SEO/Providers/SEOServiceProvider.php:22-55` |
| Lê logo padrão (`logo_header`) para `og:image` quando não há imagem de registro | tabela `v2_layout_base_configs` (key `logos`) | `getCoverImage()` | `V2/Services/SEO/Services/SEOService.php:375-396` |
| Cacheia XML do sitemap por 360 min | cache, chave `seo_sitemap_xml_cache` | `sitemap()` | `V2/Services/SEO/Controllers/SeoPortalController.php:79-82` |
| Registra chaves de configuração de SEO/metadados | tabela `v2_portal_configs` | migration de seed | `V2/Services/SEO/Migrations/2026_07_02_140000_seed_seo_configuration_keys.php:18-33` |
| Cria tabela de visitas | tabela `v2_seo_page_visits` | migration | `V2/Services/SEO/Migrations/2026_06_29_093500_create_seo_tables.php:24-37` |
| Loga falhas de buffer/sitemap/config | log da aplicação | `Log::warning` / `Log::error` | `V2/Services/SEO/Services/SEOService.php:166,192`; `V2/Services/SEO/Controllers/SeoPortalController.php:169,203,243,283,324,350,390,431` |

Não há escrita em disco, e-mail, fila ou índice de busca. O índice público (`v2_public_search_index`) é apenas **lido** para enriquecer o ranking (`V2/Services/SEO/Services/SEOService.php:268-284`).

### 1.3 Contrato observável

| Entrada | Resultado |
|---|---|
| Render de qualquer view listada no composer | Variáveis injetadas; 1 linha adicionada ao buffer `seo_access_buffer` por view listada efetivamente renderizada (na prática 8 por página padrão, ver `RN-SEO-02`). |
| `setTitle('X')` + render | `<title>X</title>` (sem sufixo do site), `meta[name=title]` e `og:title` = `X`. |
| Nenhum título definido e `titulo_site` nulo | `<title>` = `siteTitle` (fallback literal `Portal Oficial`) (`V2/layouts/portal/metadata/seo.blade.php:2`, `V2/Services/SEO/Providers/SEOServiceProvider.php:54`). |
| `bindModel($m)` com `$m->metadata['seo']['title']` vazio | Nenhuma alteração no título atual (setter não é chamado) (`V2/Services/SEO/Services/SEOService.php:34-36`). |
| `bindModel($m)` com `json_ld` string inválida | `json_decode` devolve `null`, schema descartado em silêncio (`V2/Services/SEO/Services/SEOService.php:48-56`). |
| `GET /robots.txt` sem módulo Notícias carregado | `route('v2.noticias.sitemap.news')` lança `InvalidArgumentException` → HTTP 500 (sem try/catch) (`V2/Services/SEO/Controllers/SeoPortalController.php:54-55`). |
| `GET /sitemap.xml` com tabelas ausentes | Fonte omitida do XML, `Log::warning('[SEO Sitemap] Erro ao buscar ...')`; resposta continua 200 (`V2/Services/SEO/Controllers/SeoPortalController.php:149-176`). |
| Qualquer rota pública com manutenção ativa (visitante anônimo) | `robots.txt`/`sitemap.xml` respondem HTML 503 do CMS, não texto/XML (`V2/Core/Middleware/MaintenanceMiddleware.php:31-50`). |

### 1.5 Efeito da ausência por tipo de metadado

| Metadado | Origem do valor | Se ausente |
|---|---|---|
| `<title>` | `seoTitle` = título do serviço → `titulo_site` → literal | Com `titulo_site` nulo: `Portal Oficial`; com `titulo_site` gravado como `''`: `<title></title>` (string vazia não é substituída por `??`) (`V2/layouts/portal/metadata/seo.blade.php:2`) |
| `meta[name=title]`, `og:title`, `twitter:title` | idem | idem (`:3,10,26`) |
| `meta[name=description]`, `og:description`, `twitter:description` | `seoDescription` = descrição do serviço → `descricao` | `content=""` (tag presente, sem texto) (`:4,11,27`) |
| `meta[name=keywords]` | `seoKeywords` = keywords → `palavras_chaves` | `content=""` (`:5`) |
| `og:url`, `twitter:url` | `url()->current()` | nunca ausente (`:9,25`) |
| `og:image`, `twitter:image` | `getCoverImage()`: imagem do registro → `logo_header` → `null` | `content=""` (`:13-21,28`; `SEOService.php:375-396`) |
| `<link rel="canonical">` | `setCanonical()` → `url()->current()` | sempre `url()->current()`, com query string (`:31`) |
| JSON-LD | `addSchema()`/`bindModel(json_ld)` → `$schemas` legado | Nenhum `<script type="application/ld+json">`; se `json_ld` for string inválida, nenhum bloco e nenhum log (`:37-49`) |
| `og:type` / `twitter:card` | literais | Sempre `website` / `summary_large_image` (`:8,24`) |
| `gtag.js` | `google_analytics_id` | Bloco inteiro omitido; `getGtag()` só é consultado se o ID existir (`gtag.blade.php:5-18`) |
| Tokens/scripts globais | `global_meta_tokens`, `header_scripts`, `footer_scripts` | Nada é impresso (`seo.blade.php:34`; `metaheader.blade.php:1`; `metafooter.blade.php:1`) |
| `robots.txt` | regras fixas + 2 rotas | Nunca fica vazio; se a rota `v2.noticias.sitemap.news` não existir, responde 500 (`SeoPortalController.php:54-55`) |
| Seção do `sitemap.xml` | tabela do módulo | Seção omitida + warning; XML vazio (mas 200) se todas faltarem (`SeoPortalController.php:149-434`) |
| Linha em `v2_seo_page_visits` | render das views do composer | Não há linha em page cache HIT, em rota de asset/API/CMS ou quando o buffer falha (`SEOService.php:65-100,121-197`) |
| SEO de registro | `metadata['seo']` | Cai nos defaults do controller/modelo; se também ausentes, camada global (§3.2, RN-SEO-01) |

### 1.4 O que o serviço deliberadamente NÃO faz

- **Não redireciona nem trata 301/302**; não existe campo de redirect em nenhum artefato da unidade.
- **Não valida nem sanitiza** campos de SEO; validação é dos controllers dos módulos (`V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:290-298`, `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:652-659`).
- **Não grava `metadata`** — apenas lê (`bindModel`); a escrita é dos controllers de cada módulo.
- **Não gera automaticamente title/description ao salvar** (o README sugere `generateSeoMetadata()`, que não existe no código — `LG-SEO-01`).
- **Não otimiza nem redimensiona imagens** para 1200×630 (README cita `ImageOptimizer`; não há chamada — `LG-SEO-01`).
- **Não define `Cache-Control`** de robots/sitemap; quem aplica é `PublicCacheMiddleware` (`V2/Core/Middleware/PublicCacheMiddleware.php:84-92`).
- **Não monta sitemap index** nem pagina sitemaps; `news-sitemap.xml` pertence ao módulo Notícias (`V2/Modules/Publicacoes/Noticias/Routes/web.php:33-35`).
- **Não cobre o CMS**: o composer não é registrado em views `v2-cms::*`, e `shouldTrack()` exclui `cms/*` (`V2/Services/SEO/Services/SEOService.php:65-100`).

## 2. Abordagem

### 2.1 Estratégia e por quê

Três decisões estruturais:

1. **Estado em serviço singleton por request** (`register()` cria `singleton(SEOService::class)`, `V2/Services/SEO/Providers/SEOServiceProvider.php:64-68`). Controllers só chamam setters; nenhum deles passa metadados para a view.
2. **Injeção tardia por view composer** nas views de `<head>`/rodapé. Assim o controller termina de decidir SEO antes de a view renderizar, sem alterar assinatura de views (`V2/Services/SEO/Providers/SEOServiceProvider.php:22-32`).
3. **Persistência descentralizada**: o formato é um blob `metadata['seo']` na tabela do próprio módulo (`V2/Modules/Layouts/BasicPage/Models/BasicPage.php:18-24`), não uma tabela SEO central.

```mermaid
flowchart TD
    A[Request pública] --> B[MaintenanceMiddleware]
    B --> C[PageCacheMiddleware redis 5min]
    C -->|HIT| Z[Resposta cacheada: sem render, sem trackAccess]
    C -->|MISS| D[Controller do módulo]
    D -->|setTitle/setDescription/setKeywords/addSchema/bindModel| E[(SEOService singleton)]
    D --> F[view do módulo]
    F --> G[layout v2-portal::base]
    G --> H[composer: trackAccess + PortalConfigsService::getAll]
    H --> I[metadata.seo / metadata.gtag / metaheader / metafooter]
    E --> I
    H --> J[buffer seo_access_buffer]
    J -->|>=50| K[(v2_seo_page_visits)]
```

Leitura de `robots.txt`/`sitemap.xml` não passa pelo serviço: são rotas próprias no mesmo grupo de middlewares públicos (`V2/Core/Providers/V2ServiceProvider.php:211-220`).

### 2.2 Mecânica passo a passo

**Fluxo A — página de detalhe (ex.: Associados)**

1. Controller recebe `SEOService` no construtor (`V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:18-21`).
2. Define defaults de página com `setTitle`/`setDescription`/`setKeywords` (`...:167-169`).
3. Chama `bindModel($instituicao)` **depois** (`...:170`), que sobrescreve cada campo apenas se `$model->metadata['seo'][campo]` for não vazio (`V2/Services/SEO/Services/SEOService.php:29-62`).
4. Retorna `view(...)`; o layout inclui `v2-portal::metadata` (`V2/layouts/portal/base.blade.php:36-42`).
5. O composer resolve o singleton, chama `trackAccess()`, consulta `PortalConfigsService::getAll()` e injeta as variáveis (`V2/Services/SEO/Providers/SEOServiceProvider.php:32-54`).
6. `metadata.seo` monta title/metas/OG/Twitter/canonical/JSON-LD (`V2/layouts/portal/metadata/seo.blade.php:1-49`).

**Fluxo B — registro salvo pelo CMS**

1. Formulário inclui `v2-service-seo::components.seo-input` com `model` e `name` (`V2/Modules/Publicacoes/Noticias/Views/cms/includes/_editor-content.blade.php:296-301`).
2. Componente lê `$model->metadata['seo']`, renderiza inputs prefixados e o snippet (`V2/Services/SEO/Views/components/seo-input.blade.php:8-12,30-39`).
3. Controller do módulo valida e grava o array `seo` em `metadata` (`V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:290-315`; `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:263-294`).
4. Na próxima requisição pública, o passo 3 do Fluxo A lê o valor persistido.
5. Módulos que limpam page cache após salvar (ex.: História em `V2/Modules/Institucional/Historia/Controllers/MilestoneCmsController.php:337-343`) evitam servir HTML antigo por até 5 min.

**Fluxo C — robots/sitemap**

1. Rota do serviço entra no grupo `['web', MaintenanceMiddleware, 'page.cache', 'public.cache']` (`V2/Core/Providers/V2ServiceProvider.php:211-220`).
2. `sitemap()` purga o cache entre 03:00 e 03:05; fora disso usa `Cache::remember` de 360 min (`V2/Services/SEO/Controllers/SeoPortalController.php:76-82`).
3. Oito coletores consultam tabelas e empilham `['loc','lastmod','priority','changefreq']`.
4. `renderSitemapXml()` concatena `<url>` (`V2/Services/SEO/Controllers/SeoPortalController.php:445-462`).
5. `PublicCacheMiddleware` grava `Cache-Control: public, max-age=60, must-revalidate` e `Vary: Accept-Encoding` (`V2/Core/Middleware/PublicCacheMiddleware.php:84-92`).

### 2.3 Trechos de código pivô

```php
// V2/Services/SEO/Services/SEOService.php:29-45 — bindModel só sobrescreve campo não vazio
public function bindModel($model)
{
    $metadata = $model->metadata ?? [];
    $seo = isset($metadata['seo']) ? $metadata['seo'] : [];

    if (!empty($seo['title'])) {
        $this->setTitle($seo['title']);
    }
    if (!empty($seo['description'])) {
        $this->setDescription($seo['description']);
    }
    if (!empty($seo['keywords'])) {
        $this->setKeywords($seo['keywords']);
    }
    if (!empty($seo['image_url'])) {
        $this->setCoverImage($seo['image_url']);
    }

    return $this;
}
```

> Mecanismo central de precedência: `bindModel` é um “merge condicional”. Campo vazio no registro **não** apaga o default já definido pelo controller; campo preenchido vence. `json_ld` é o único que só adiciona (`V2/Services/SEO/Services/SEOService.php:48-57`). Como é o controller que escolhe a ordem, a chamada antes ou depois dos setters inverte o vencedor (ver `RN-SEO-01`).

```php
// V2/Services/SEO/Providers/SEOServiceProvider.php:32-49 — fallback global e injeção nas views
], function ($view) {
    $seo = app(SEOService::class);

    // Rastreia a visita corrente para fins de ranqueamento e controle de bots
    $seo->trackAccess();

    // Fallbacks globais da tabela v2_portal_configs (V2)
    try {
        $portalConfigs = app(PortalConfigsService::class)->getAll();
    } catch (\Exception $e) {
        $portalConfigs = [];
    }

    $view->with([
        'seo' => $seo,
        'seoTitle' => $seo->getTitle() ?: ($portalConfigs['titulo_site'] ?? null),
        'seoDescription' => $seo->getDescription() ?: ($portalConfigs['descricao'] ?? null),
```

> Prova da camada 3 de precedência: `titulo_site`/`descricao`/`palavras_chaves` só são usados quando o serviço está vazio. O `?:` trata `"0"`/`''` como vazio. Também concentra o efeito colateral de analytics (chamado a cada view listada) e 8 consultas por página à tabela de configs.

```php
// V2/Modules/Publicacoes/Noticias/Models/News.php:341-355 — fallback no modelo, não no serviço
public function getSeoData()
{
    $seo = isset($this->metadata['seo']) ? $this->metadata['seo'] : [];

    return [
        'title' => isset($seo['title']) && !empty($seo['title'])
            ? $seo['title']
            : $this->title,
        'description' => isset($seo['description']) && !empty($seo['description'])
            ? $seo['description']
            : ($this->subtitle ?: ($this->summary ?: '')),
        'keywords' => isset($seo['keywords']) ? $seo['keywords'] : 'notícias, ' . config('app.name'),
        'json_ld' => isset($seo['json_ld']) ? $seo['json_ld'] : null,
    ];
}
```

> Padrão alternativo ao `bindModel`: o modelo entrega um array já resolvido e o controller aplica com setters incondicionais (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasPortalController.php:92-98`). Consequência: `title` **nunca** fica vazio em notícia, mas `json_ld` retornado aqui não é usado por ninguém (`LG-SEO-02`); `keywords` cai em `'notícias, ' . config('app.name')` (default de `config/app.php:16` = `PREFEITURAV4`).

```php
// V2/Services/SEO/Services/SEOService.php:375-394 — fallback de imagem de compartilhamento
public function getCoverImage() 
{ 
    if (!empty($this->coverImage)) {
        return $this->coverImage;
    }

    // Caso não haja imagem customizada, busca a Logo Principal (logo_header) cadastrada em LayoutBase
    try {
        $layoutConfig = DB::table('v2_layout_base_configs')->where('key', 'logos')->first();
        if ($layoutConfig) {
            $payload = json_decode($layoutConfig->payload, true);
            $logoHeader = $payload['logo_header'] ?? null;
            $logoPath = is_array($logoHeader) ? ($logoHeader['path'] ?? null) : $logoHeader;
            if ($logoPath) {
                return '/storage/' . $logoPath;
            }
        }
    } catch (\Exception $e) {
        // Silencia erro se a tabela de LayoutBase não existir ou falhar
    }
```

> A ausência de imagem não produz `og:image` vazio: cai para `/storage/<logo_header>`; se nem isso existir, `null` e o template imprime `content=""`. Uma query por render de `metadata.seo` **quando não há imagem definida** (sem cache) e `catch` silencioso para tabela ausente.

```php
// V2/Services/SEO/Controllers/SeoPortalController.php:76-95 — cache e coleta do sitemap
$currentHour = (int) date('G');
$currentMinute = (int) date('i');
if ($currentHour === 3 && $currentMinute <= 5) {
    Cache::forget('seo_sitemap_xml_cache');
}

$xml = Cache::remember('seo_sitemap_xml_cache', 360, function () {
    $urls = [];
    $baseUrl = url('/');
    $now = date('Y-m-d');

    // ----------------------------------------------------------------
    // 1. Homepage + Páginas Fixas (BasicPage)
    // ----------------------------------------------------------------
    $this->addBasicPages($urls, $baseUrl, $now);

    // ----------------------------------------------------------------
    // 2. Notícias (V2)
    // ----------------------------------------------------------------
    $this->addNews($urls, $baseUrl);
```

> A invalidação depende de **uma requisição cair na janela 03:00–03:05**; sem tráfego, o XML pode ficar além de 6 h. `date()` usa timezone do app. Os oito coletores rodam apenas no MISS (ver tabela em §5).

```php
// V2/Services/SEO/Controllers/SeoPortalController.php:34-55 — regras fixas + sitemaps dinâmicos
$content = implode("\n", [
    'User-agent: *',
    'Allow: /',
    '',
    // ... blocos "CMS", "API", "Áreas interativas com formulários" e "Páginas de busca"
    // (texto integral em contrato.md §4.3)
    '# Sitemaps',
    'Sitemap: ' . route('v2.seo.sitemap'),
    'Sitemap: ' . route('v2.noticias.sitemap.news'),
```

> As regras de bloqueio são hardcoded; apenas as URLs de sitemap são dinâmicas (dependem de `APP_URL`). A dependência da rota do módulo Notícias é dura: sem ela, `robots.txt` inteiro falha com 500.

```php
// V2/Modules/Layouts/BasicPage/Models/BasicPage.php:199-217 — precedência de página fixa
public function getSeoData()
{
    $stored = isset($this->metadata['seo']) ? $this->metadata['seo'] : [];
    $defaults = isset(static::$seoDefaults[$this->slug]) ? static::$seoDefaults[$this->slug] : [];

    // Preenche a URL base do JSON-LD dinamicamente
    if (isset($defaults['json_ld'])) {
        $defaults['json_ld']['url'] = url('/') . ($this->slug === 'homepage' ? '' : '/' . $this->slug);
    }

    return [
        'title'       => !empty($stored['title'])       ? $stored['title']       : ($defaults['title'] ?? $this->title),
        'description' => !empty($stored['description']) ? $stored['description'] : ($defaults['description'] ?? ''),
        'keywords'    => !empty($stored['keywords'])    ? $stored['keywords']    : ($defaults['keywords'] ?? ''),
        'image_url'   => !empty($stored['image_url'])   ? $stored['image_url']   : '',
        'image_id'    => !empty($stored['image_id'])    ? $stored['image_id']    : '',
        'json_ld'     => !empty($stored['json_ld'])     ? $stored['json_ld']     : ($defaults['json_ld'] ?? null),
    ];
}
```

> Quarta camada de precedência, exclusiva de `BasicPage`: banco → `static $seoDefaults[$slug]` (`BasicPage.php:32-134`) → coluna `title` → vazio. O `json_ld` default tem `url` recalculada por slug, o que impede reaproveitar o array estático sem esse ajuste.

### 2.4 Algoritmos e custos

| Operação | Consultas / I/O | Custo e limites |
|---|---|---|
| Render de página padrão | 8× `getAll()` em `v2_portal_configs` (`pluck`) + 1× `v2_layout_base_configs` (key `logos`, só se não houver imagem definida) + 8× `Cache::get`/`put` do buffer | ~9 queries/request; sem cache de configs (`V2/Modules/Support/SupportConfigs/Services/PortalConfigsService.php:37-52`) |
| `trackAccess()` | cache read+write por chamada | Não atômico: `get`+`put` perde escritas concorrentes; TTL 1440 (min) |
| `flushBuffer()` | 1 `insert` de N≤50 linhas | `Cache::pull` não é lock apesar do comentário; falha só loga |
| `getPageRanking($limit)` | 1 `group by` + **69 predicados** (34 de prefixo + 35 de extensão; listas em §2.4.1) + 1 `whereIn` no índice de busca | `limit` sem teto; enriquecimento opcional silencioso |
| `sitemap()` | 8 queries (uma por fonte) no MISS; 0 no HIT | Cache 360 min; purge oportunista 03:00–03:05; page cache 5 min; `max-age=60` |
| `showCleanup`/XML | concatenação O(n) de strings | Sem streaming: todos os registros em memória (notícias sem `limit`; só o news-sitemap limita 1000) |

### 2.4.1 Listas de exclusão de `getPageRanking()`

Duas cópias independentes das listas existem no serviço: `shouldTrack()` (telemetria) e `getPageRanking()` (ranking). As listas abaixo são as do ranking (`V2/Services/SEO/Services/SEOService.php:203-216`), aplicadas em `:220-227`.

**Prefixos excluídos (17)** — cada prefixo gera **duas** condições: `url_path NOT LIKE '<prefixo>/%'` **e** `url_path != '<prefixo>'` (17 × 2 = 34 predicados):

| # | Prefixo | Condições |
|---:|---|---|
| 1 | `cms` | `NOT LIKE 'cms/%'` e `!= 'cms'` |
| 2 | `_debugbar` | `NOT LIKE '_debugbar/%'` e `!= '_debugbar'` |
| 3 | `_ignition` | `NOT LIKE '_ignition/%'` e `!= '_ignition'` |
| 4 | `telescope` | `NOT LIKE 'telescope/%'` e `!= 'telescope'` |
| 5 | `api` | `NOT LIKE 'api/%'` e `!= 'api'` |
| 6 | `storage` | `NOT LIKE 'storage/%'` e `!= 'storage'` |
| 7 | `build` | `NOT LIKE 'build/%'` e `!= 'build'` |
| 8 | `assets` | `NOT LIKE 'assets/%'` e `!= 'assets'` |
| 9 | `vendor` | `NOT LIKE 'vendor/%'` e `!= 'vendor'` |
| 10 | `fonts` | `NOT LIKE 'fonts/%'` e `!= 'fonts'` |
| 11 | `images` | `NOT LIKE 'images/%'` e `!= 'images'` |
| 12 | `img` | `NOT LIKE 'img/%'` e `!= 'img'` |
| 13 | `uploads` | `NOT LIKE 'uploads/%'` e `!= 'uploads'` |
| 14 | `files` | `NOT LIKE 'files/%'` e `!= 'files'` |
| 15 | `livewire` | `NOT LIKE 'livewire/%'` e `!= 'livewire'` |
| 16 | `nova` | `NOT LIKE 'nova/%'` e `!= 'nova'` |
| 17 | `horizon` | `NOT LIKE 'horizon/%'` e `!= 'horizon'` |

**Extensões excluídas (35)** — cada extensão gera **uma** condição `url_path NOT LIKE '%.<ext>'` (35 predicados), aplicada sobre o path completo (sem `pathinfo`, ao contrário de `shouldTrack()`):

| Grupo | Extensões |
|---|---|
| Web | `css`, `js`, `ts`, `jsx`, `tsx`, `vue` (6) |
| Imagem | `png`, `jpg`, `jpeg`, `gif`, `svg`, `ico`, `webp`, `avif` (8) |
| Fonte | `woff`, `woff2`, `ttf`, `eot`, `otf` (5) |
| Arquivo | `zip`, `rar`, `tar`, `gz`, `7z` (5) |
| Dados | `json`, `xml`, `map`, `csv` (4) |
| Mídia | `mp4`, `webm`, `ogg`, `mp3`, `wav`, `avi`, `mov` (7) |

Total: 17×2 + 35 = **69 predicados** antes do `GROUP BY` (`:220-227`), mais 1 `whereIn('slug', ...)` em `v2_public_search_index` quando o ranking não está vazio (`:268-284`). Consequência contratual: a URL raiz (`url_path = '/'`) e qualquer path fora das listas permanecem no ranking; paths com prefixo listado são removidos tanto na forma exata (`cms`) quanto em subárvores (`cms/...`).

### 2.5 SEO de página, de listagem e de sitemap

| Camada | Definição | Onde vive | Exemplos |
|---|---|---|---|
| Página de registro | metadados do item concreto | `metadata['seo']` na tabela do módulo | Notícia, Associado, Regional, BasicPage |
| Página de listagem | título/descrição da listagem, às vezes variando por filtro | código do controller (sem persistência) | `NoticiasPortalController.php:64-66`; `EventosPortalController.php:82-91`; `ParceirosInstitucionaisPortalController.php:27-29` |
| Institucional configurável | 3 chaves globais do módulo | `v2_portal_configs` (`historia_seo_*`) | `HistoriaPortalController.php:77-91` |
| Sitemap | descoberta de URLs (sem title/description) | XML cacheado | `SeoPortalController.php:73-141` |
| robots | política de crawl | código | `SeoPortalController.php:34-55` |

O sitemap não lê `metadata['seo']`: um registro sem SEO e outro com SEO ótimo geram a mesma entrada. Metadados de página **não** alteram prioridade nem presença no XML; a presença depende apenas dos filtros de cada coletor (tabela em §5.1). O news sitemap do módulo Notícias é a exceção que consome `metadata['seo']['keywords']` (`NoticiasSitemapController.php:84-91`). Detalhamento de precedência em [contrato.md](contrato.md#22-precedência-real-quem-vence).

## 3. Particularidades

### 3.1 O que é exclusivo deste serviço

- **Única fonte de `<head>` do portal**: nenhum módulo escreve `<meta>` diretamente; todos chamam setters e dependem do composer (`V2/Services/SEO/Providers/SEOServiceProvider.php:22-31`).
- **Única camada de fallback global** (`titulo_site`, `descricao`, `palavras_chaves`, `google_analytics_id`, `global_meta_tokens`, `header_scripts`, `footer_scripts`).
- **Única implementação de `/robots.txt` e `/sitemap.xml`** (o XML estático foi removido de `public/`; a rota é a fonte — `V2/Services/SEO/Controllers/SeoPortalController.php:20-31`).
- **Único dono da telemetria de visitas** (`v2_seo_page_visits` + ranking exposto ao dashboard via `DashboardStatsService::getSeoRanking`, `V2/Services/Dashboard/DashboardStatsService.php:501-512`).
- **Único formato persistido de SEO de registro** (`metadata['seo']`), compartilhado por BasicPage, Notícias, Associados, Regionais, Parceiros, Eventos e TrabalheConosco — embora só alguns escrevam.

Comparação: `SEARCH` indexa conteúdo para busca interna; `SEO` não indexa nada, apenas lê tabelas e metadados. `MEDIA` é dependência do componente (`media-input`), não do serviço.

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-SEO-01 | `bindModel()` não limpa campos ausentes; a ordem em relação aos setters define o vencedor | Registro vence se `bindModel` vier depois (Associados/Regionais); módulo vence se vier antes (TrabalheConosco) | `V2/Services/SEO/Services/SEOService.php:29-62`; `V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:167-170`; `V2/Modules/Publicacoes/TrabalheConosco/Controllers/TrabalheConoscoController.php:67-68` |
| RN-SEO-02 | O composer está registrado em 8 views; cada uma que renderiza executa `trackAccess()` | Até 8 linhas de visita e 8 leituras de config por página; páginas com print DOM sempre incluem `print.print-header` | `V2/Services/SEO/Providers/SEOServiceProvider.php:23-36`; `V2/layouts/portal/base.blade.php:115,147`; `V2/layouts/portal/print/print-only.blade.php:20` |
| RN-SEO-03 | Quando `seoTitle` está definido, `<title>` é exatamente ele | Sem sufixo “— site”; `pageTitle` da view não é concatenado | `V2/layouts/portal/metadata/seo.blade.php:2` |
| RN-SEO-04 | Canonical é sempre `url()->current()` | Nenhum módulo chama `setCanonical`; URLs com query string canonicalizam a si mesmas | `V2/layouts/portal/metadata/seo.blade.php:18,31`; `V2/Services/SEO/Services/SEOService.php:341-344` |
| RN-SEO-05 | `og:image`/`twitter:image` usam `getCoverImage()`; `$coverImage` da view é ignorado quando `$seo` existe | Imagem de registro > logo `logo_header` > vazio | `V2/layouts/portal/metadata/seo.blade.php:12-21` |
| RN-SEO-06 | `addSchema()` só acumula; `json_ld` string é decodificado e descartado se inválido | JSON-LD inválido não gera erro nem log | `V2/Services/SEO/Services/SEOService.php:48-57,353-356` |
| RN-SEO-07 | Seed de configs usa `updateOrInsert` e grava `config_value` nulo | Reexecutar a migration **apaga** valores já configurados das 7 chaves e força `titulo_site = 'Portal Oficial'` | `V2/Services/SEO/Migrations/2026_07_02_140000_seed_seo_configuration_keys.php:28-33` |
| RN-SEO-08 | Sitemap de eventos não filtra `published_at` | Eventos futuros/não publicados entram no XML; `published()` do model não é aplicado | `V2/Services/SEO/Controllers/SeoPortalController.php:221-241`; `V2/Modules/Publicacoes/Agenda/Models/Evento.php:260-264` |
| RN-SEO-09 | `robots()` depende da rota do news sitemap sem proteção | 500 se o módulo Notícias estiver desativado | `V2/Services/SEO/Controllers/SeoPortalController.php:55` |
| RN-SEO-10 | Flush do buffer não é atômico | Duas requisições simultâneas podem perder lote; inserção é best-effort | `V2/Services/SEO/Services/SEOService.php:173-195` |
| RN-SEO-11 | Page cache serve HTML sem render | HIT de 5 min não registra visita nem recalcula metadados | `V2/Core/Middleware/PageCacheMiddleware.php:34-50` |
| RN-SEO-12 | `seo-input` usa o prefixo recebido, mas o `media-input` é sempre `seo_image_id` | Cada módulo escolhe o formato do POST (`metadata[seo]`, `seo`, `historia_seo`) | `V2/Services/SEO/Views/components/seo-input.blade.php:9,131-141` |
| RN-SEO-13 | Limite de UI ≠ limite de validação | Inputs truncam em 70/160; controllers aceitam 255/500 | `V2/Services/SEO/Views/components/seo-input.blade.php:61,106`; `V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:292-293` |
| RN-SEO-14 | `BasicPageCmsController::updateSeo` substitui o array `seo` inteiro | Chaves não presentes no formulário (ex.: `published_date`) desaparecem | `V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:303-314` |

### 3.3 Casos-limite

| Caso | Comportamento |
|---|---|
| `v2_portal_configs` ausente | Composer captura `Exception` e usa `[]`; `siteTitle` vira `Portal Oficial` (`V2/Services/SEO/Providers/SEOServiceProvider.php:37-43,54`). |
| `v2_layout_base_configs` ausente ou payload inválido | `getCoverImage()` devolve `null`; `<meta property="og:image" content="">` (`V2/Services/SEO/Services/SEOService.php:375-396`). |
| Tabela de módulo ausente no sitemap | Fonte omitida + warning; XML válido porém incompleto (`SeoPortalController.php:152-154` etc.). |
| `v2_basic_pages` sem linha `homepage` | `/` não aparece no sitemap (o coletor só itera linhas; não há URL fixa) (`SeoPortalController.php:149-176`). |
| Notícia sem `published_at` | Excluída do sitemap (`whereNotNull('published_at')`) (`SeoPortalController.php:186-190`). |
| Evento sem `published_at` | Incluído (RN-SEO-08). |
| `User-Agent` ausente | `substr(null, 0, 255)` → string vazia/aviso; `is_bot=false` (`SEOService.php:130-152`). |
| Buffer >50 mas banco indisponível | `Log::error('[SEO] Erro ao descarregar buffer de acessos no banco: ...')`, lote perdido. |
| Migration revertida | Tabela renomeada para `backup_v2_seo_page_visits_<Y_m_d_His>` e preservada; log `alert` (`2026_06_29_093500_create_seo_tables.php:52-72`). |
| `seo-input` com `image_url` e tabela `v2_gallery_media` ausente | `DB::table(...)` sem try/catch → erro na tela do CMS (`seo-input.blade.php:124-129`). |

### 3.4 Armadilhas de reimplementação

1. **Ordem dos setters é contrato**. `bindModel` após defaults = registro vence; antes = defaults vencem. Não “corrigir” TrabalheConosco sem decisão explícita (`TrabalheConoscoController.php:67-68`).
2. **Composer só roda nas views listadas**. Um layout novo que não inclua `v2-portal::metadata` não recebe nenhuma meta; incluir apenas `metadata.seo` sem o composer registrar a view quebra com `$seo` indefinido (usado em `gtag.blade.php:15`).
3. **`$seoTitle` sempre existe** (pode ser `null`), então `$seoTitle ?? $pageTitle` nunca cai no `pageTitle` por “chave ausente”; o fallback é por valor nulo do próprio `??` — e o `pageTitle` só aparece quando `$seoTitle` é `null`.
4. **`metadata['seo']` é JSONB**: strings vazias permanecem e contam como “preenchidas” em `Noticias::getSeoData` (`isset` + `!empty` difere entre os modelos).
5. **Timezone**: `sitemap()` usa `date()` do app; Notícias grava `published_date` em `America/Sao_Paulo` (`NoticiasCmsController.php:284`) — formato `d/m/Y`, não ISO.
6. **Singleton**: se um controller chamar `bindModel` e depois outro código no mesmo request reutilizar o serviço (subrequest/`view()` de e-mail), o estado vaza.
7. **Page cache**: publicar conteúdo não invalida HTML de 5 min; módulos precisam chamar `v2:clear-page-cache` (História faz; Notícias Faz; ver `LG-SEO-11`).

### 3.5 Dívidas e divergências

| Divergência | Fonte |
|---|---|
| README descreve `generateSeoMetadata()` no evento `saving` e otimização de imagem 1200px; nada disso existe no código | `V2/Services/SEO/README.md:8-30`; ausência confirmada por busca em `V2/` |
| Comentário do controller promete `Cache-Control: public, max-age=300`; o middleware grava `60` | `SeoPortalController.php:27`; `PublicCacheMiddleware.php:88` |
| Comentário do flush fala em “lock atômico”; `Cache::pull` não é atômico | `SEOService.php:175-177` |
| `$seo->setCanonical`, `setGtag` e `setBreadcrumbSchema` sem nenhum chamador fora da unidade | `SEOService.php:341,365,409` |
| `metadata['seo']['json_ld']`, `image_url` e `published_date` de Notícias são gravados mas não consumidos no portal | `NoticiasCmsController.php:280-292`; `NoticiasPortalController.php:92-98`; `News.php:341-355` |
| `EventosCmsController` injeta `SEOService` mas nunca o utiliza; não há produtor de `metadata['seo']` para eventos | `V2/Modules/Publicacoes/Agenda/Controllers/EventosCmsController.php:21-33` |
| Rota `v2.noticias.cms.seo-update` existe sem formulário que a use | `V2/Modules/Publicacoes/Noticias/Routes/cms.php:78-82`; busca por `seo-update` em blades = 0 |
| Defaults de `BasicPage` para `homepage` falam de “Maxima — Loteria da Paraíba”, enquanto os demais slugs falam de SUCESU | `BasicPage.php:33-36` vs `:98-134` |
| `seo-button.blade.php` (componente alternativo em `V2/layouts/cms`) não é incluído por nenhuma view | `V2/layouts/cms/components/seo-button.blade.php:1-18` |

## 4. Integrabilidade

### 4.1 API pública

| Símbolo | Assinatura | Retorno | Exceções | Consumidores |
|---|---|---|---|---|
| `SEOService::bindModel` | `bindModel($model)` | `$this` | nenhuma | Associados, Regionais, Agenda (show), TrabalheConosco (show) |
| `SEOService::setTitle` | `setTitle($title)` | `$this` | nenhuma | Notícias, Agenda, BasicPage, Homepage, SobreNos, TrabalheConosco, Associados, Parceiros, Regionais, História, FaleConosco, AssocieSe, FeedRss |
| `setDescription` / `setKeywords` | `($value)` | `$this` | nenhuma | idem |
| `setCoverImage` | `setCoverImage($url)` | `$this` | nenhuma | Notícias (`NoticiasPortalController.php:96-98`), BasicPage (`BasicPagePortalController.php:41-43`) |
| `setCanonical` | `setCanonical($url)` | `$this` | nenhuma | sem consumidores (LG-SEO-05) |
| `addSchema` | `addSchema(array $schema)` | `$this` | `TypeError` se não-array | Homepage, SobreNos, BasicPage |
| `setGtag` | `setGtag(array $gtag)` | `$this` | `TypeError` | sem consumidores; `gtag.blade.php:15` sempre chama `getGtag()` |
| `setBreadcrumbSchema` | `setBreadcrumbSchema(array $items)` | `$this` | nenhuma | sem consumidores |
| getters | `getTitle/getDescription/getKeywords/getCanonical/getSchemas/getGtag` | string/array|null | nenhuma | composer, `seo.blade.php`, `gtag.blade.php` |
| `getCoverImage` | `getCoverImage()` | string|null | captura internamente | `seo.blade.php:13` |
| `trackAccess` | `trackAccess()` | `void` | captura `Exception` | composer |
| `flushBuffer` | `flushBuffer()` | `void` | captura `Exception` | `trackAccess` |
| `getPageRanking` | `getPageRanking($limit = 10)` | `Collection` | captura e devolve vazio | `DashboardStatsService::getSeoRanking` |
| `SeoPortalController::robots` | `robots()` | `Illuminate\Http\Response` | `InvalidArgumentException` (rota ausente) | rota |
| `SeoPortalController::sitemap` | `sitemap()` | `Illuminate\Http\Response` | nenhuma (captura por fonte) | rota |

### 4.2 Componentes Blade e contrato de UI

Definição completa (props, DOM, eventos, defaults) em [contrato.md](contrato.md#3-particularidades-do-componente-seo-input) — resumo:

- Namespace `v2-service-seo` (derivado de `strtolower('SEO')`, `V2/Core/Providers/V2ServiceProvider.php:178-188`), arquivo `Views/components/seo-input.blade.php`.
- Props usadas: `$model` (registro com `metadata` array; opcional) e `$name` (prefixo do input; default `metadata[seo]`) (`seo-input.blade.php:5-12`). `ownerModule` é passado por Notícias mas ignorado.
- Emite `@push('css')` e `@push('scripts')`; depende de jQuery, Bootstrap 5 (collapse/progress) e Bootstrap Icons.
- Escuta `media-selected.lotep` para sincronizar `image_id`/`image_url`.

### 4.3 JS e ciclo de vida

- Inicializa em `$(document).ready` e IIFE por instância, escopado em `#seo-wrapper-{{ $uuid }}` (`seo-input.blade.php:229-233`).
- `updateCounters()` roda no `input` dos campos title/description e no load; atualiza contadores, snippet e barras (`:251-292`).
- Sem JS: contadores ficam `0/70` e `0/160`, snippet mostra os defaults, barras em 0%; os campos continuam editáveis e são enviados normalmente.
- Teardown: nenhum; múltiplas instâncias convivem por `$uuid` (`str_random(8)`), mas os listeners são globais por documento (sem `off`).

### 4.4 Ordem de inicialização e registro

1. `SEOServiceProvider` registrado em `config/app.php:171`, **antes** de `V2ServiceProvider` (`config/app.php:185`); não pode usar helpers `v2_*` no próprio `register()` (`01_arquitetura/08-helpers-composers.md:88-93`).
2. `register()` cria o singleton (`SEOServiceProvider.php:64-68`).
3. `boot()` retorna cedo em console (`SEOServiceProvider.php:18-20`); registra o composer somente em HTTP.
4. `V2ServiceProvider` carrega migrations e views de serviços (namespace `v2-service-<slug>`) e mapeia `Routes/web.php` com a quádrupla de middlewares (`V2/Core/Providers/V2ServiceProvider.php:178-190,211-220`).
5. Migrations: `2026_06_29_093500_create_seo_tables` → `2026_06_30_000005_create_v2_portal_configs_table` → `2026_07_02_140000_seed_seo_configuration_keys` (ordem por timestamp; o seed exige a tabela de configs).
6. `PortalConfigsService` precisa estar resolvível; falha é capturada.

### 4.5 Receitas de uso

**1) Página de detalhe — registro vence o default**

```php
public function show($slug)
{
    $item = MeuModulo::where('slug', $slug)->firstOrFail();
    $this->seo->setTitle($item->name . ' — ' . config('app.name')); // default do módulo
    $this->seo->setDescription('Descrição padrão da listagem');
    $this->seo->bindModel($item); // metadata['seo'] sobrescreve só campos preenchidos
    return view('meu-modulo::portal.show', compact('item'));
}
```

Resultado: `<title>` = `metadata['seo']['title']` se existir; senão o default do módulo; sem título em lugar algum → `titulo_site`/`Portal Oficial`.

**2) Listagem com título dinâmico (Agenda)**

```php
$this->seo->setTitle('Eventos em ' . \Carbon\Carbon::parse($filterDate)->format('d/m/Y') . ' — ' . config('app.name'));
$this->seo->setDescription('Confira a agenda de eventos, cursos e programações da SUCESU.');
$this->seo->setKeywords('agenda, eventos, cursos, programação, ' . config('app.name'));
return view('v2-agenda::portal.index', compact('eventos', 'filterLabel'));
```

Fonte: `V2/Modules/Publicacoes/Agenda/Controllers/EventosPortalController.php:82-91`.

**3) Página fixa com defaults por slug**

```php
$page = BasicPage::where('slug', 'lgpd')->first();
$seo = $page ? $page->getSeoData() : [];
if (!empty($seo['title'])) { $this->seo->setTitle($seo['title']); }
if (!empty($seo['description'])) { $this->seo->setDescription($seo['description']); }
```

Equivale a `BasicPagePortalController::bindBasicPage()` (`V2/Modules/Layouts/BasicPage/Controllers/BasicPagePortalController.php:24-50`); `getSeoData` já aplicou os defaults do slug.

**4) Formulário CMS**

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

No controller, ler `metadata[seo][...]`, garantir defaults e gravar em `metadata` (`NoticiasCmsController.php:263-294`). Para prefixo diferente (`seo`, `historia_seo`), o handler precisa ler exatamente esse prefixo (`BasicPageCmsController.php:292-294`; `MilestoneCmsController.php:311-321`).

**5) Verificação HTTP**

```bash
curl -i https://portal/robots.txt   # 200 text/plain; charset=utf-8 + Cache-Control: public, max-age=60
curl -i https://portal/sitemap.xml  # 200 application/xml; charset=utf-8
```

### 4.6 Anti-uso

1. **Definir `$pageTitle` na view esperando controlar `<title>`** — o composer sempre define `seoTitle`; só há efeito quando ele é `null` (sem título e sem `titulo_site`). Use `SEOService::setTitle`.
2. **Chamar `bindModel()` antes dos setters esperando “defaults do registro”** — o registro perde para os setters seguintes. A ordem é a regra (RN-SEO-01).
3. **Reutilizar `name => 'metadata[seo]'` em formulário cujo controller lê `seo.*`** — os campos caem no payload `metadata` (ou são ignorados) e a validação `seo.title` falha com “The seo.title field is required.”.
4. **Contar com o sitemap para publicar draft de evento** — ele publica (RN-SEO-08). Filtrar no módulo antes de considerar o XML como fonte de verdade.
5. **Guardar imagem apenas em `metadata['seo']['image_url']` de notícia** — o portal usa `cover_image_id`/`getCoverImageUrl()`; a imagem de compartilhamento do SEO não é lida (`LG-SEO-02`).

### 4.7 Custos e limites

- Sem throttle próprio. Cache: 5 min (page cache), 60 s (`max-age`), 360 min (XML), 1440 min (buffer).
- `sitemap` sem paginação/limite para notícias (o news-sitemap do módulo limita 1000); memória cresce com o acervo.
- `getPageRanking` sem teto de `limit` e com **69 predicados** (17 prefixos × 2 condições + 35 extensões; §2.4.1).
- `trackAccess` grava `ip_address` integral (dado pessoal) sem hash nem retenção definida.

## 5. 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` (aplicados pelo provider) | `SeoPortalController@robots` | pública | 200 `text/plain; charset=utf-8` com regras fixas + 2 `Sitemap:`; cacheável 60 s / page cache 5 min | `V2/Services/SEO/Routes/web.php:9`; `V2/Core/Providers/V2ServiceProvider.php:211-220` |
| GET | `/sitemap.xml` | `v2.seo.sitemap` | idem | `SeoPortalController@sitemap` | pública | 200 `application/xml; charset=utf-8`; XML com até 8 fontes; cache interno 360 min | `V2/Services/SEO/Routes/web.php:10`; `SeoPortalController.php:73-141` |

Rotas referenciadas mas pertencentes a outros módulos: `v2.noticias.sitemap.news` → `GET /news-sitemap.xml` (`V2/Modules/Publicacoes/Noticias/Routes/web.php:33-35`).

### 5.1 Fontes do sitemap

| # | Fonte | Tabela | Filtros | `loc` | `priority` | `changefreq` | Fonte |
|---|---|---|---|---|---|---|---|
| 1 | Homepage + páginas fixas | `v2_basic_pages` | nenhum | `/` se `slug='homepage'`, senão `/<slug>` | `1.0` / `0.6` | `daily` / `monthly` | `SeoPortalController.php:149-176` |
| 2 | Notícias | `v2_news` | `status='published'`, `published_at not null`, `deleted_at null` | `/noticia/<slug>` | `0.7` | `weekly` | `:180-205` |
| 3 | Eventos + índice | `v2_eventos` | apenas `deleted_at null` | `/agenda/<slug>` e `/agenda` | `0.5` | `weekly` / `daily` (índice) | `:215-247` |
| 4 | Parceiros + índice | `v2_parceiros_institucionais` | `deleted_at null` | `/parceiros-institucionais/detalhe/<id>` e `/parceiros-institucionais` | `0.4` / `0.5` | `monthly` / `weekly` (índice) | `:255-286` |
| 5 | Diretoria + índice | `v2_board_members` | `slug not null`, `deleted_at null` | `/diretoria/<slug>` e `/diretoria` | `0.5` / `0.6` | `monthly` / `weekly` | `:295-327` |
| 6 | História (índice) | `v2_historia_milestones` | só verifica existência | `/historia` | `0.5` | `monthly` | `:336-353` |
| 7 | Regionais + índice | `v2_unidade_regionais` | `deleted_at null` | `/regional/<slug>` e `/regionais` | `0.5` | `weekly` | `:362-393` |
| 8 | Associados + índice | `v2_associado_instituicoes` | `slug not null`, `deleted_at null` | `/associado/<slug>` e `/associados` | `0.4` / `0.5` | `monthly` / `weekly` | `:402-434` |

`lastmod` = `date('Y-m-d', updated_at)` ou data corrente quando nulo (notícias usam `published_at` como alternativa).

## 6. Dados e migrations

### 6.1 `v2_seo_page_visits`

| Coluna | Tipo | Null/default | Observação |
|---|---|---|---|
| `id` | uuid PK | não | gerado por `v2_uuid()` no serviço |
| `url_path` | string | não | `Request::path()`; homepage grava `/` |
| `user_agent` | string | sim | truncado em 255 |
| `ip_address` | string | sim | `Request::ip()` |
| `referer` | string | sim | truncado em 255 |
| `is_bot` | boolean | default `false` | lista fixa de UAs |
| `created_at`/`updated_at` | timestamps | sim | preenchidos manualmente no serviço |

Índices: `url_path`, `is_bot` (`V2/Services/SEO/Migrations/2026_06_29_093500_create_seo_tables.php:24-37`). Sem soft delete, sem FK. Quem escreve: apenas `SEOService::flushBuffer()`. Quem lê: `getPageRanking()` e `enrichRankingWithSearchIndex()` (tabela `v2_public_search_index`).

Migration usa transação + `SET lock_timeout = '3s'`; falha → `Log::error("ERRO NA MIGRACAO CREATE_SEO_TABLES: ...")` + rethrow (`:16-45`).

### 6.2 `v2_portal_configs` (escrita pela unidade)

Formato: `config_key` único (100), `config_value` text nullable, `config_title`, `config_group` default `OUTROS`, soft deletes (`V2/Modules/Support/SupportConfigs/Migrations/2026_06_30_000005_create_v2_portal_configs_table.php:37-48`). A migration de seed grava 7 chaves de SEO/metadados (`2026_07_02_140000_seed_seo_configuration_keys.php:18-26`).

### 6.3 Formato persistido de SEO de registro (contratado aqui, escrito pelos módulos)

`metadata` (JSONB) → chave `seo` → `{title, description, keywords, image_id, image_url, json_ld, published_date?}`. Detalhamento, validações e matriz por módulo em [contrato.md](contrato.md#21-formato-persistido).

## 7. Configuração e permissões

Chaves lidas pelo composer com fallback (transcrição e defaults completos em [contrato.md](contrato.md#5-configuração-e-permissões--chaves-semeadas)):

| Chave | Default efetivo | Efeito |
|---|---|---|
| `titulo_site` | `'Portal Oficial'` (seed) / `null` (migration da tabela) / `''` (defaults do serviço) | `siteTitle`, `<title>` e `meta[name=title]` de última instância |
| `descricao` | `null`/`''` | `seoDescription` global |
| `palavras_chaves` | `null`/`''` | `seoKeywords` global |
| `google_analytics_id` | `null`/`''` | habilita bloco gtag |
| `global_meta_tokens` | `null`/`''` | HTML cru no `<head>` |
| `header_scripts` | `null`/`''` | HTML cru antes do conteúdo (`metaheader`) |
| `footer_scripts` | `null`/`''` | HTML cru no fim (`metafooter`) |

Permissões: nenhuma nas duas rotas públicas (sem `auth`, sem RBAC). O componente `seo-input` é renderizado dentro de telas CMS já protegidas por `CmsAuthenticate` (`V2/Modules/Layouts/BasicPage/Routes/cms.php:8-16`); a unidade não define permissões próprias.

## 8. Observabilidade e erros

| Situação | Efeito | Fonte |
|---|---|---|
| Falha no buffer de visita | `Log::warning('[SEO] Falha ao rastrear acesso (Buffer): ' . $e->getMessage())`; request segue | `SEOService.php:165-167` |
| Falha no insert em lote | `Log::error('[SEO] Erro ao descarregar buffer de acessos no banco: ...')`; lote descartado | `SEOService.php:192-194` |
| Falha por fonte do sitemap | `Log::warning('[SEO Sitemap] Erro ao buscar <Fonte>: ...')` ×8; XML parcial | `SeoPortalController.php:169,203,243,283,324,350,390,431` |
| Enriquecimento do ranking | silencioso (`// Silencia — enrichment é opcional`) | `SEOService.php:282-283` |
| Falha de migration/seed | `Log::error("ERRO NA MIGRACAO ...")` + rethrow | `2026_06_29...:40-44`; `2026_07_02...:36-40` |
| Rollback da tabela | `Log::alert("MIGRAÇÃO ROLLBACK: Tabela ... preservada como ...")` | `2026_06_29...:61-64` |
| Configs indisponível | silencioso → `[]` | `SEOServiceProvider.php:37-43` |
| Manutenção ativa | 503 (HTML para anônimos; JSON para AJAX) | `V2/Core/Middleware/MaintenanceMiddleware.php:31-50` |

Códigos HTTP: 200 para ambos os endpoints em operação normal; 500 em `robots.txt` se a rota do news sitemap não existir; 503 em manutenção. Não há auditoria de alteração de metadados nesta unidade — os módulos registram via `ActivityLoggerService` (`BasicPageCmsController.php:317-325`; `NoticiasCmsController.php:674-677`; `MilestoneCmsController.php:335-337`).

## 9. Consumidores (mapa de integração)

| Consumidor | O que usa | Como usa | Risco de quebra |
|---|---|---|---|
| Notícias | `setTitle/Description/Keywords/CoverImage`, `getSeoData()` do model, `seo-input` | Setters em `index`, `show` e `preview`; persistência em `metadata['seo']` | Alto: 3 pontos de escrita, prefixos distintos; `json_ld`/`image_url` ignorados |
| Agenda | `bindModel` (show), setters (index) | `EventosPortalController.php:82-91,115` | Médio: `bindModel` é no-op (sem produtor de `metadata['seo']`) |
| BasicPage | `bindBasicPage`, `getSeoData`, defaults por slug, `seo-input` | 5 rotas fixas + catch-all do core | Alto: defaults com branding alheio; updateSeo apaga chaves |
| Associados | setters + `bindModel` | `AssociadosPortalController.php:167-170` | Baixo |
| Parceiros | setters (index) e nenhum SEO no detalhe | `ParceirosInstitucionaisPortalController.php:27-29` | Médio: página de detalhe herda último estado do request (nenhum) |
| UnidadeRegional | setters + `bindModel` | `UnidadeRegionalPortalController.php:49-52` | Baixo |
| Homepage | setters + `addSchema(Organization)` | `HomepagePortalController.php:50-65` | Baixo; mas `/` não usa `BasicPage` |
| SobreNos | setters + `addSchema(AboutPage)` | `SobreNosPortalController.php:70-84` | Baixo |
| TrabalheConosco | setters; `bindModel` antes do `setTitle` da vaga | `TrabalheConoscoController.php:41-43,67-68` | Médio: título da vaga sobrescreve o do registro |
| História | setters com 3 chaves `historia_seo_*`; `seo-input` com prefixo `historia_seo` | `HistoriaPortalController.php:77-91`; `MilestoneCmsController.php:311-321` | Médio: imagem/JSON-LD do componente são descartados |
| FaleConosco | setters | `FaleConoscoPortalController.php:36-38` | Baixo |
| AssocieSe | setters | `AssocieSeController.php:50-52` | Baixo |
| FeedRss | `setTitle` | `FeedRssSupportController.php:66` | Baixo |
| Dashboard | `getPageRanking` | `DashboardStatsService.php:501-512` | Baixo (retorna coleção vazia em erro) |

## 10. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-SEO-01 | README descreve `generateSeoMetadata()` no `saving` e otimização de imagem 1200px (`README.md:8-30`), sem implementação | Reimplementar o README criaria comportamento inexistente | busca `generateSeoMetadata`/`ImageOptimizer` em `V2/` = 0 |
| LG-SEO-02 | `json_ld` e `image_url` de Notícias são gravados (`NoticiasCmsController.php:666-668`) e nunca renderizados | JSON-LD por notícia não funciona; OG usa capa | `NoticiasPortalController.php:92-98`; `News.php:341-355` |
| LG-SEO-03 | `metadata['seo']['published_date']` sem consumidor | Campo morto no formato | `NoticiasCmsController.php:280-284` |
| LG-SEO-04 | Eventos não têm produtor de `metadata['seo']`; `bindModel` inócuo | SEO de evento nunca vem do registro | `EventosCmsController.php:21-33` |
| LG-SEO-05 | `setCanonical`, `setGtag`, `setBreadcrumbSchema` sem chamadores | Canonical/gtag/breadcrumb não configuráveis | busca em `V2/` |
| LG-SEO-06 | Sitemap depende de linha `homepage` em `v2_basic_pages` e não filtra publicação de eventos | XML pode omitir `/` e expor draft | `SeoPortalController.php:149-176,215-247` |
| LG-SEO-07 | Telemetria duplicada (até 8 registros/página), sem dedupe/anonimização/retenção | Ranking e LGPD imprecisos | `SEOServiceProvider.php:23-36`; `SEOService.php:121-197` |
| LG-SEO-08 | `seo-input` depende de `v2-service-media` e `v2_gallery_media` sem fallback | Erro na tela do CMS se Mídia estiver desativada | `seo-input.blade.php:124-139` |
| LG-SEO-09 | Defaults de `BasicPage` misturam branding “Maxima/Loteria da Paraíba” com SUCESU | Títulos errados em `/lgpd`, `/acessibilidade`, `/ouvidoria`, `/mapa-do-site` | `BasicPage.php:33-96` |
| LG-SEO-10 | `v2_public_search_index` pode não existir; enriquecimento silencioso | Ranking sem módulo/título | `SEOService.php:269-296` |
| LG-SEO-11 | Invalidação do page cache depende dos módulos chamarem `clearPageCache()` | Metadados novos podem demorar 5 min para aparecer | `V2/Core/Console/Commands/ClearPageCache.php:25-45` |
| LG-SEO-12 | Rota `v2.noticias.cms.seo-update` sem formulário | Endpoint órfão | `Noticias/Routes/cms.php:78-82` |

## 11. Checklist de paridade

- [ ] `PC-SEO-01` — Composer registrado exatamente nas 8 views; render de página padrão injeta `seo`, `seoTitle`, `seoDescription`, `seoKeywords`, `seoGtagId`, `seoGlobalTokens`, `seoHeaderScripts`, `seoFooterScripts`, `siteTitle` (`SEOServiceProvider.php:22-55`).
- [ ] `PC-SEO-02` — Com título definido, `<title>` não recebe sufixo do site; sem título, título = `titulo_site` → `Portal Oficial` (`seo.blade.php:2`; `SEOServiceProvider.php:47,54`).
- [ ] `PC-SEO-03` — `bindModel` sobrescreve só campos não vazios e adiciona `json_ld`; ordem em relação aos setters preservada por módulo (`SEOService.php:29-62`).
- [ ] `PC-SEO-04` — `og:image` = imagem do registro → `/storage/<logo_header>` → vazio (`SEOService.php:375-396`).
- [ ] `PC-SEO-05` — Canonical = `url()->current()`; nenhum módulo define (`seo.blade.php:31`).
- [ ] `PC-SEO-06` — JSON-LD renderizado por schema com `JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE` (`seo.blade.php:37-43`).
- [ ] `PC-SEO-07` — `robots.txt` byte-a-byte com o conteúdo de `SeoPortalController.php:35-55`, incluindo 2 `Sitemap:`.
- [ ] `PC-SEO-08` — `sitemap.xml` com as 8 fontes, filtros, prioridades e `changefreq` da tabela §5.1.
- [ ] `PC-SEO-09` — Cache do XML 360 min + purge oportunista 03:00–03:05; `Cache-Control` efetivo 60 s (`PublicCacheMiddleware.php:88`).
- [ ] `PC-SEO-10` — `trackAccess` exclui `cms/*`, `api/*`, assets e extensões; detecta os 10 UAs de bot (`SEOService.php:65-118,134-144`).
- [ ] `PC-SEO-11` — Buffer em `seo_access_buffer`, TTL 1440, flush em 50, insert em `v2_seo_page_visits` (`SEOService.php:157-189`).
- [ ] `PC-SEO-12` — `getPageRanking` exclui exatamente os 17 prefixos e as 35 extensões de §2.4.1 (34 + 35 = 69 predicados) e enriquece com `v2_public_search_index` (`SEOService.php:203-216,220-227,268-284`).
- [ ] `PC-SEO-13` — Migration cria `v2_seo_page_visits` com índices `url_path`/`is_bot`; rollback renomeia para backup (`2026_06_29...:24-37,52-72`).
- [ ] `PC-SEO-14` — Seed idempotente por `config_key` grava as 7 chaves com os valores da migration (`2026_07_02...:18-33`).
- [ ] `PC-SEO-15` — `seo-input` renderiza os 6 campos (`title`, `description`, `keywords`, `json_ld`, `image_id`, `image_url`), snippet, contadores 70/160 e evento `media-selected.lotep` (`seo-input.blade.php:14-181,229-310`).

## 12. Evidências consultadas

- `V2/Services/SEO/Services/SEOService.php` (1-427)
- `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/Views/components/seo-input.blade.php` (1-311)
- `V2/Services/SEO/README.md` (1-66)
- `V2/layouts/portal/base.blade.php` (28-45, 60-70, 106-150), `V2/layouts/portal/metadata.blade.php` (1-10), `V2/layouts/portal/metadata/seo.blade.php` (1-50), `V2/layouts/portal/metadata/gtag.blade.php` (1-20), `V2/layouts/portal/metadata/metaheader.blade.php`, `V2/layouts/portal/metadata/metafooter.blade.php`, `V2/layouts/portal/partials/portal-content.blade.php` (8-121), `V2/layouts/portal/print/print-only.blade.php` (20)
- `V2/Core/Providers/V2ServiceProvider.php` (160-220), `V2/Core/Middleware/PageCacheMiddleware.php` (1-90), `V2/Core/Middleware/PublicCacheMiddleware.php` (24-110), `V2/Core/Middleware/MaintenanceMiddleware.php` (1-60)
- `V2/Modules/Support/SupportConfigs/Services/PortalConfigsService.php` (37-71, 199-246), `V2/Modules/Support/SupportConfigs/Migrations/2026_06_30_000005_create_v2_portal_configs_table.php` (37-90)
- `V2/Modules/Layouts/BasicPage/Models/BasicPage.php` (18-217), `Controllers/BasicPagePortalController.php` (16-90), `Controllers/BasicPageCmsController.php` (285-325), `Views/cms/index.blade.php` (101-140), `Views/cms/preview.blade.php` (57-87), `Routes/cms.php` (8-16)
- `V2/Modules/Publicacoes/Noticias/Models/News.php` (201-213, 341-355), `Controllers/NoticiasPortalController.php` (64-98, 169-175), `Controllers/NoticiasCmsController.php` (258-296, 435-470, 645-682), `Controllers/NoticiasSitemapController.php` (23-145), `Routes/cms.php` (78-82), `Routes/web.php` (33-35), `Views/cms/includes/_editor-content.blade.php` (290-303), `Views/cms/includes/scripts.blade.php` (63-90)
- `V2/Modules/Publicacoes/Agenda/Controllers/EventosPortalController.php` (82-115), `Controllers/EventosCmsController.php` (21-33), `Models/Evento.php` (255-264)
- `V2/Modules/Publicacoes/TrabalheConosco/Controllers/TrabalheConoscoController.php` (41-68)
- `V2/Modules/Layouts/Homepage/Controllers/HomepagePortalController.php` (45-70), `Routes/web.php` (9)
- `V2/Modules/Institucional/SobreNos/Controllers/SobreNosPortalController.php` (68-84)
- `V2/Modules/Institucional/Historia/Controllers/HistoriaPortalController.php` (77-92), `Controllers/MilestoneCmsController.php` (290-343), `Views/cms/config.blade.php` (1-38)
- `V2/Modules/Institucional/FaleConosco/Controllers/FaleConoscoPortalController.php` (36-38), `V2/Modules/Institucional/AssocieSe/Controllers/AssocieSeController.php` (50-52)
- `V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php` (92-94, 167-170), `V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisPortalController.php` (27-50), `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalPortalController.php` (27-52)
- `V2/Modules/Infraestrutura/FeedRss/Controllers/FeedRssSupportController.php` (66), `V2/Services/Dashboard/DashboardStatsService.php` (501-512)
- `config/app.php` (16, 171, 185), `config/cache.php` (18)
- `sucesu_analise/01_arquitetura/08-helpers-composers.md` (80-93), `09-cache-csp-minify-maintenance.md` (53-67, 412)
