# SEO y rastreo: `robots.txt`, `noindex`, sitemaps y Search Console

Qué le decimos a Google (y a Bing, y a cualquier rastreador) que mire, que no mire y que no liste.
Son **tres mecanismos distintos** y confundirlos es el error clásico, así que primero la regla que
los reparte, y después cada uno.

## 1. La regla que decide qué herramienta usar

| Qué es | Herramienta | Por qué esa |
|---|---|---|
| URLs con `auth` (panel, tickets, dashboards, usuarios, roles) | **`robots.txt` `Disallow`** | Google recibe un **302 a `/login`**: nunca ve el HTML, así que un `<meta>` ahí no haría nada |
| Endpoints que no son páginas (`/locale`, `/currencies`, `/up`, PDFs) | **`robots.txt` `Disallow`** | no hay HTML donde poner una etiqueta |
| Páginas públicas que **no** deben salir en resultados (`/login`) | **`<meta name="robots">`** | Google **puede** cargarlas, y es lo único que de verdad las saca del índice |
| Contenido (home, blogs, máquinas, sectores) | **sitemap**, sin bloqueo | es justo lo que se quiere indexar |

> 👉 **Lo que Google puede alcanzar por un enlace se marca `noindex`; lo que no debería ni rastrear
> se bloquea en `robots.txt`.** Hacer las dos cosas a la vez es lo que **no** funciona.

⚠️ **`Disallow` no es seguridad.** Impide el rastreo, no el acceso. Lo que protege esas rutas es su
middleware; `robots.txt` solo evita gastar presupuesto de rastreo.

⚠️ **Y `Disallow` tampoco es "no indexar".** Una URL **enlazada y bloqueada se indexa igual**, sin
título ni descripción — es el aviso *«Indexada aunque bloqueada por robots.txt»* de Search Console.
Encima, bloqueada, Google **nunca llega a leer** la etiqueta que la excluiría. Por eso el reparto de
arriba no es estético: usar la herramienta equivocada consigue lo contrario de lo que se busca.

## 2. `robots.txt` — una ruta, no un archivo (2026-09-17)

`public/robots.txt` era **estático**, así que los 8 subdominios servían el mismo texto y ninguno
declaraba su sitemap. Un `Sitemap:` único tampoco servía: para Google cada división es un sitio
distinto —`mexico.group-itg.com` no es `group-itg.com`— y cada una tiene su propio `/sitemap`, con
sus URLs y su host.

Hoy lo sirve **`App\Http\Controllers\Guest\RobotsController`** (ruta `/robots.txt`, registrada antes
del catch-all). El host sale de `url()`, o sea **de la petición**, ya resuelto al subdominio correcto
— lo contrario de `sitemap:generate`, que **sí** tiene que derivarlo de `APP_URL` porque corre desde
consola y no hay petición de la que leerlo.

⚠️ **`public/robots.txt` está borrado y no puede volver.** El servidor web sirve los archivos que
existen antes de pasarle la petición a Laravel (`try_files $uri`), así que mientras ese archivo esté
ahí **la ruta no se ejecuta nunca** — y el síntoma es exactamente el que se venía arrastrando.

### De dónde sale la lista de `Disallow`

De inventariar **`route:list --json -v` y separar por middleware**: todo lo que pasa por
`Authenticate` o vive en Filament, más los endpoints que no son páginas. **No se escribe a mano.**
⚠️ Al agregar un módulo privado, re-correr ese inventario:

```bash
./vendor/bin/sail artisan route:list --json -v > /tmp/routes.json
# y separar por 'authenticate' / 'filament' / 'permission' en el campo middleware (en minúsculas:
# los nombres llegan como Illuminate\Auth\Middleware\Authenticate)
```

Son **prefijos, no rutas exactas**: `/dashboard` cubre `dashboard-admin`, `-staff`, `-customer` y
`-default`; `/sale-ticket` cubre `sale-tickets`, `sale-ticket-archived` y `sale-ticket-kanban`.

### ⚠️ Los `Allow` van ANTES de los `Disallow`, y el orden es funcional

`purchase-tickets/create` es el formulario que el cliente abre **sin sesión** (en `routes/web.php`
lleva el comentario «no middleware, public access to create a ticket»), pero cuelga de
`/purchase-tickets`, que sí es privado. Tenía su `Allow` **y aun así salía bloqueado**.

Hay **dos familias de rastreadores**:

| Familia | Quién | Con el `Allow` al final |
|---|---|---|
| Coincidencia **más larga** (RFC 9309) | Google, Bing actuales | ✅ lo resuelven bien |
| **Primera** regla que matchea | rastreadores viejos, herramientas de SEO, `urllib.robotparser` | ❌ leen el `Disallow` primero y lo dan por bloqueado |

Con el `Allow` delante **aciertan las dos**, y `/purchase-tickets` a secas sigue bloqueado en ambas.
`sale-forms/create` no necesita excepción: lo privado es `/sale-form-drafts`, no `/sale-forms`.

### Cómo verificarlo (con un parser real, no razonándolo)

```python
from urllib.robotparser import RobotFileParser          # resuelve por PRIMERA coincidencia:
rp = RobotFileParser()                                   # es el caso estricto, si pasa aquí pasa en Google
rp.set_url('http://localhost/robots.txt'); rp.read()
rp.can_fetch('Googlebot', 'http://localhost/purchase-tickets/create')   # -> True
```

**Última corrida: 13 de 13 rutas como se espera.** Rastreables `/purchase-tickets/create`,
`/sale-forms/create`, `/login`, `/`, `/sitemap`, `/blogs`, `/used-machines-for-sale`; bloqueadas
`/purchase-tickets`, `/purchase-tickets-archived`, `/sale-tickets`, `/sale-form-drafts`, `/admin`,
`/dashboard-admin`, `/locale/es`.

⚠️ **Y comprobar siempre el cruce con los sitemaps**: un sitemap que lista una URL bloqueada es un
aviso en Search Console. **Última corrida: 567 URLs en las 8 divisiones, 0 bloqueadas.**

## 3. `noindex` — las pantallas de autenticación

`/login` lo **enlaza el navbar público** (`frontend/layouts/navbar.blade.php`, rama `@else` del
`@auth`) y se pinta con el **layout del sitio** (`layouts.guest.general`, **no** el de auth), con su
canonical, su title y su description. Para Google es una página más. Pero es un formulario de
credenciales para staff y para los clientes que siguen tickets: nadie lo busca —se llega por el
navbar—, y su `<title>` es genérico y **sin marca** (`Login to your account`, contra
`About Us - ITG Group`), así que en resultados solo diluye consultas de marca.

```blade
@push('meta')
    <meta name="robots" content="noindex, follow">
@endpush
```

- Lo empuja **su propia blade**, contra el **`@stack('meta')`** del head de
  `layouts/guest/general.blade.php`. Se hizo con un stack y **no** con un `@if` por nombre de ruta en
  el layout para que lo declare la pantalla, que es quien lo sabe. El hueco queda para la próxima.
- **`follow` a propósito:** arrastra navbar y footer enteros, así que la página no se lista pero sus
  enlaces siguen contando.
- ✅ Funciona desde un componente **Volt** full-page: Blade resuelve los stacks al final del render.
- **Verificado:** el meta sale en `/login` y **no** en `/`, `/about`, `/contact`, `/partners`,
  `/blogs`, `/products`, `/machinery` ni `/used-machines-for-sale`.

### Las otras cuatro: recuperar, restablecer, confirmar y verificar

Marcar `/login` sin más habría movido el problema un salto abajo: al ser rastreable **y llevar
`follow`**, Google entra y **sigue sus enlaces**, y uno de ellos es «olvidé mi contraseña»
(`login.blade.php:118` y `login-modal.blade.php:73`). Con `/forgot-password` en `Disallow`,
esa URL quedaba **enlazada y bloqueada** — el mismo defecto, heredado.

Por eso las 4 salieron también del `Disallow`, y el meta va **fijo en el head de
`layouts/auth/auth.blade.php`**, no por página: ese layout sirve **solo** esas 4 pantallas y
**ninguna es contenido**, así que no hay nada que decidir caso por caso. Una línea, un layout.

- `reset-password` y `verify-email` **no los alcanza nadie** (se llega por un enlace firmado que va
  en un correo). Se incluyen por **coherencia**: un layout, una regla, en vez de dos
  comportamientos según la pantalla.
- **Se descartó** poner `nofollow` en `/login` para cortar el problema en el origen: tiraría también
  los enlaces del navbar y el footer, que es justo lo que `follow` conserva. Mejor arreglar el
  destino que cortarle las piernas al origen.
- **Verificado:** las 5 rutas de auth rastreables en robots.txt; `/login`, `/forgot-password` y
  `/reset-password/{token}` responden **200 con el meta**; `/confirm-password` y `/verify-email`
  dan **302** porque exigen sesión — nunca llegan a rastrearse, y por eso su etiqueta es solo
  coherencia.

(`/register` no tiene ruta: el registro de Breeze está desactivado.)

## 4. Sitemaps — archivos en `public/`, generados en el servidor

`sitemap:generate` escribe **`public/sitemaps/{division}.sitemap.xml`** y `SiteMapController` sirve
ese archivo en `/sitemap`. **No** viven en storage.

- Están en **`.gitignore`** (`/public/sitemaps/*.xml`; solo viaja el `.gitkeep`) porque el contenido
  depende del entorno — cada uno tiene su host y sus registros.
- Se generan **en el servidor**: `deploy/deploy.sh:213` y otra vez en el **cron de las 03:00**.
  Nunca cortan el deploy: como mucho el sitemap queda viejo hasta la próxima corrida.
- ⚠️ **Escribe URLs absolutas con el host de cada división** (derivado de `APP_URL` + el nombre, la
  convención de `SubdomainUtil` al revés). Antes usaba `url()`, que desde consola resuelve contra
  `APP_URL`, así que **los 8 sitemaps publicaban el dominio principal**.
- ⚠️ **No listar nunca URLs que redirijan ni endpoints de acción** (`/locale/{x}` daba 302), ni las
  pages `is_main` (su path propio hace 301 a `/`).
- ⚠️ Tras un `rsync`/`scp` al servidor, `public/sitemaps` tiene que ser de `www-data` o
  `sitemap:generate` falla al escribir. Ver el bloque de permisos en `CLAUDE.md`.

**Contenido actual: 71 URLs en main** (1 home, 24 blogs, 34 máquinas, 6 sectores, 6 listados), 70 en
china. Todas responden **200 sin autenticación**: ninguna URL del panel, de tickets ni de dashboards
entra, porque `sitemap:generate` solo publica contenido del catálogo.

Ni `purchase-tickets/create` ni `sale-forms/create` están en el sitemap. Son públicas y rastreables
por enlace; **meterlas sería una decisión aparte**, no un arreglo.

## 5. ✅ Search Console — ya configurado, no hay que tocarlo

**Está dado de alta desde hace años, con una propiedad por subdominio y su sitemap enviado en cada
una** (confirmado por el usuario, 2026-09-17). Los cambios de 2026-09 **no obligan a rehacer nada**:
la URL `/sitemap` no cambió, solo su contenido (de 53 a 71 URLs en main, sin las `/locale/*` que
respondían 302 y con `lastmod` reales en vez del `2024-09-19` fijo). Google lo re-lee por su cuenta;
un *Volver a enviar* solo lo acelera.

Lo que conviene saber para cuando haya que volver a mirarlo:

- ⚠️ **La ruta del sitemap es `sitemap`, SIN `.xml`.** `/sitemap.xml` da **404** y siempre lo dio.
  Si alguna propiedad lo tiene registrado así, ahí estaría fallando en silencio.
- ⚠️ **`robots.txt` NO se da de alta en ningún sitio.** Google lo pide solo en
  `https://<host>/robots.txt` antes de rastrear y lo cachea ~24 h. Search Console solo tiene un
  **informe** (*Configuración → robots.txt*) para ver qué leyó y cuándo, con un botón para forzar la
  relectura. El `Sitemap:` que emite `RobotsController` es además el único canal que funciona **sin
  intervención humana**, y es lo que cubre a Bing y al resto.
- Una propiedad de **dominio** (`group-itg.com`, verificada por TXT en el DNS) cubriría los 8
  subdominios de una vez, frente a las 8 de tipo *prefijo de URL*. **No hace falta cambiarlo** —
  ambas miden lo mismo —, pero es la opción a elegir si algún día se rehacen.

## 6. Archivos

| | |
|---|---|
| `app/Http/Controllers/Guest/RobotsController.php` | el `robots.txt` de cada división; las dos listas y el porqué del orden |
| `app/Http/Controllers/Guest/SiteMapController.php` | sirve `public/sitemaps/{division}.sitemap.xml` |
| `app/Console/Commands/GenerateSitemap.php` | `sitemap:generate`; deploy + cron 03:00 |
| `routes/web.php` | `/robots.txt` y `/sitemap`, **antes** del catch-all |
| `resources/views/layouts/guest/general.blade.php` | `@stack('meta')` en el head |
| `resources/views/livewire/pages/auth/login.blade.php` | `@push('meta')` con el `noindex, follow` |
| `resources/views/layouts/auth/auth.blade.php` | el `noindex, follow` fijo de las 4 pantallas de auth |
