Full text · 85 KB · 11 files
Web Audit Kit, in one block
The whole documentation as plain text — an executable site auditor: around 30 checks over what a page validator never looks at. Read it here or paste it into an AI assistant. Nothing to download: no archive from a site you do not know yet.
This page is generated from the same files the repository ships, so it cannot drift out of sync. Use GitHub when you want to run it; use this when you want to read it or hand it to a model.
# Web Audit Kit
# https://climentmedia.com/agents/web-audit-kit/copy/
# Source: https://github.com/Manucliment/web-audit-kit
# Everything below is the full documentation, in one block, so it can be
# read or pasted into an AI assistant without downloading anything.
================================================================
FILE: README.md
================================================================
# Web Audit Kit
Un **auditor ejecutable** y el **checklist** que lo acompaña, para construir, revisar y
escalar sitios web estáticos sin reinventar la rueda cada vez.
No es un lint de HTML ni un medidor de rendimiento — de eso ya hay. Es lo que **falta**
entre un validador de páginas y una auditoría de SEO: **la comprobación del sitio como
sistema**.
## El problema que resuelve
Teníamos un estándar de página de 265 líneas y un gate de integridad de 5 comprobaciones.
Entre los dos no había nada que validara el sitio **completo**. Todo lo que se coló era
de sistema, no de página:
| Se coló | Por qué nadie lo vio |
|---|---|
| `llms.txt` con **11 URLs a 404 de 28** | No es HTML: un barrido de `href=` no lo cubre |
| `AGENTS.md` afirmando el posicionamiento **retirado meses antes** | Ninguna herramienta compara mensaje entre superficies |
| **Cero cabeceras `Cache-Control`** | Las auditorías de página no miran la entrega |
| Una página indexable **huérfana y a medio publicar** | Estaba bien formada. Solo no debía existir |
| **14 meta descriptions** por encima del corte, una de 276 caracteres | Ninguna estaba "mal", solo truncada |
**Ninguna página estaba mal por separado.**
Y la segunda mitad: un estándar es un documento que alguien tiene que **acordarse** de
leer. **Lo que no falla solo, no se cumple.**
## Qué hay aquí
```
CHECKLIST.md El checklist completo, en dos mitades:
Parte A - lo que comprueba la maquina
Parte B - lo que solo puede juzgar una persona
audit.sh El auditor. ~30 comprobaciones en 6 bloques
audit.conf.example Toda la particularidad del sitio. El script no se toca
docs/
01-estructura.md Carriles por prioridad de negocio · PASO 0 · migraciones
02-plantillas.md Tipos de pagina y que lleva cada uno · el generador
03-estilos.md Tokens en dos capas · contraste · la libreria
04-ficheros-maquina.md sitemap · llms.txt · AGENTS.md · robots
05-rendimiento-y-entrega.md Animaciones · cache · cabeceras · que NO hacer
06-deploy-y-verificacion.md El gate · como mirar sin engañarse
```
## Uso
```bash
cp audit.sh audit.conf.example TU-PROYECTO/
cd TU-PROYECTO && mv audit.conf.example audit.conf
# editar audit.conf: URL base, marca, carpetas excluidas, rutas muertas
bash audit.sh # local
bash audit.sh --live # + entrega real en produccion
```
Verde = `EXIT 0`. Sin dependencias: bash, grep, sed, find y curl. Sin `jq`, sin Node, sin
Python.
## La salida
```
[ ok ] pasa
[FAIL] rompe el deploy
[warn] mirar, no bloquea
[skip] no se pudo comprobar Y POR QUE <- nunca se salta en silencio
```
⚠️ **Y un guardia anti-vacío.** Si el barrido de páginas devuelve cero ficheros, el script
**aborta** en vez de reportar todo en verde. Lo lleva porque su primera versión tenía
justo ese fallo: media auditoría daba `[ ok ]` sin haber mirado un solo fichero.
**Iterar sobre una lista vacía no falla, "pasa"** — y es la forma más silenciosa de que
una auditoría mienta.
## La regla de mantenimiento
Cuando algo se rompa y **no estuviera en el checklist**:
1. Arreglarlo.
2. **Añadir la comprobación al auditor** si una máquina puede detectarlo.
3. Si no, a la Parte B **con el caso real que la originó**.
Un checklist que no crece con los fallos se queda obsoleto en un mes.
---
Extraído del trabajo real sobre [climentmedia.com](https://climentmedia.com).
Cada línea marcada con ⚠️ existe porque algo se rompió — y casi todas, en silencio.
================================================================
FILE: CHECKLIST.md
================================================================
# CHECKLIST DE WEB Y SEO
Para **revisar y escalar cualquier proyecto web** con la estrategia que usamos en
climentmedia.com. No es teoría: cada línea existe porque algo se rompió, y casi todas
las que llevan ⚠️ se rompieron **en silencio**.
**Cómo se usa.** El checklist tiene dos mitades y hay que entender por qué:
| | Quién lo comprueba | Cuándo |
|---|---|---|
| **Parte A — mecánica** | `audit.sh`, **automático** | Antes de cada deploy. Falla o pasa |
| **Parte B — criterio** | Una persona (o un agente leyendo esto) | Antes de escribir cada página |
> **La lección que originó este documento:** teníamos un estándar de página de 265
> líneas y un gate de 5 comprobaciones. Entre los dos no había nada que validara el
> sitio **como sistema**, y todos los fallos que se colaron eran de sistema: un fichero
> `llms.txt` con 11 URLs a 404, un `AGENTS.md` afirmando un posicionamiento retirado,
> cero cabeceras de caché. **Ninguna página estaba mal por separado.**
>
> Y la segunda mitad de la lección: un documento hay que acordarse de leerlo. **Lo que
> no falla solo, no se cumple.** Por eso la parte A es un script.
---
# PARTE A — Lo que comprueba `audit.sh`
Ejecutar: `bash audit.sh` (local) · `bash audit.sh --live` (+ producción).
Verde = `EXIT 0`. **Sin excepciones silenciadas:** si algo no se puede comprobar, el
script escribe `[skip]` **con el motivo**, nunca lo omite.
## S0 · Guardia anti-vacío
- [ ] **El barrido encuentra páginas.** Si devuelve cero, el script aborta.
⚠️ Sin este guardia, un fallo en el listado convierte media auditoría en `[ ok ]`
falsos: **iterar sobre una lista vacía no falla, "pasa"**. Ocurrió con la primera
versión de este mismo auditor.
## S1 · Ficheros para máquinas
`sitemap.xml` · `llms.txt` · `AGENTS.md` · `robots.txt`
⚠️ **Se rompen sin avisar porque NO son HTML**: un `grep` de `href=` no los cubre. Por
eso sobrevivieron intactos a una reestructuración que sí arregló las 39 páginas.
- [ ] `sitemap.xml` bien formado.
- [ ] **Toda URL del sitemap existe.**
- [ ] **Toda página indexable está EN el sitemap.** La mitad que casi nadie comprueba:
se vigila que el sitemap no mienta, no que esté completo.
- [ ] Ninguna URL del sitemap lleva `noindex` (se contradicen).
- [ ] **Toda URL de `llms.txt` y `AGENTS.md` resuelve a 200.**
- [ ] Esos ficheros son **texto plano**: cero entidades HTML sin decodificar.
Un `&` ahí se lee literal.
- [ ] `robots.txt` declara el sitemap con URL **absoluta**.
- [ ] **Ninguna superficie cita una ruta que ya solo existe como 301.**
## S2 · Meta por página
- [ ] `<title>` presente, único, y **con un término**, no solo la marca.
⚠️ Medido: un `<title>` de solo marca dio **87 impresiones y 0 clics**.
- [ ] `<meta description>` presente y **≤ 165 caracteres decodificados**.
⚠️ Google corta cerca de 155-160. Una de 276 perdía justo el gancho.
Se mide **decodificado**: Google ve `&`, no `&`.
- [ ] `<link rel="canonical">` en toda página indexable.
- [ ] Open Graph completo (`og:title`, `og:description`, `og:url`, `og:image`).
- [ ] **Exactamente un `<h1>`.**
- [ ] `<html lang>`.
- [ ] **Cero entidades HTML dentro del JSON-LD.** Dentro de un `<script>` el navegador
no las decodifica: Google lee `don’t` literal.
## S3 · Grafo de enlaces
- [ ] Cero enlaces internos rotos.
- [ ] **Cero huérfanas:** ninguna página indexable con 0 enlaces entrantes.
## S4 · Integridad y codificación
- [ ] Sin mojibake en lo desplegable.
- [ ] Etiquetas de sección equilibradas (canario de edición destructiva).
## S5 · CSS y rendimiento
- [ ] Toda página carga su hoja de estilos **y el prefijo resuelve**.
⚠️ Un prefijo mal deja la página **sin estilo, en silencio**.
- [ ] **Las animaciones solo tocan `transform` y `opacity`.**
- [ ] **Toda animación tiene su salida en `prefers-reduced-motion`.**
## S6 · Entrega (`--live`)
- [ ] Toda URL del sitemap responde 200 en producción.
- [ ] El HTML y los assets envían `Cache-Control`.
- [ ] Compresión activa y cabeceras de seguridad básicas.
- [ ] Los ficheros para máquinas, **verificados en vivo**.
---
# PARTE B — Lo que ninguna máquina puede juzgar
## B1 · PASO 0: ¿esta página debe existir?
**Antes de escribir una sola línea.** Si falla cualquiera de las tres, no se escribe:
1. **¿A qué carril pertenece?** Si no cabe en ninguno, o sobra o falta un carril.
2. **¿Qué justifica su existencia?** Depende del tipo — ver abajo.
3. **¿Quién la enlaza desde el cuerpo?** Mínimo dos. **El nav no cuenta.**
### La pregunta 2 tiene dos respuestas válidas, y confundirlas cuesta caro
| Tipo de página | Qué la justifica | Qué NO vale |
|---|---|---|
| **Compite en búsqueda** (guías, comparativas, landings de término) | **Volumen MEDIDO** del mercado real. No estimado, no intuido | "Creo que la gente busca esto" |
| **Catálogo de producto** (una ficha por cosa que existe de verdad) | Que **el producto exista y esté verificado**, y que la ficha esté enlazada desde su hub | Publicar ficha de algo que no existe todavía |
⚠️ **Añadido el 2-ago-2026, y salió de aplicarme la regla a mí mismo.** La primera versión
exigía volumen medido a **toda** página. Al ir a publicar una ficha de producto propio, el
PASO 0 la bloqueó — y bloquearla era incorrecto: una ficha de catálogo no compite por un
término, existe porque el producto existe. Con la regla mal escrita, la salida fácil habría
sido **inventarse un volumen** para pasar el filtro. Un checklist demasiado rígido no
produce disciplina: produce que la gente lo esquive.
> **Pero la ficha de producto no da barra libre:** sigue necesitando los enlaces entrantes
> (pregunta 3), el estado honesto, y **no puede reclamar un término que no ha medido** — su
> `<title>` describe lo que la cosa hace, sin fingir que ocupa un hueco de búsqueda.
> **Por qué existe este paso.** El contrato anterior definía **tipos de página**. Con
> seis tipos válidos, cada pieza encontraba dónde encajar y entraba — sin que nada
> obligara a preguntar *si la web la necesitaba*. Resultado auditado: 36 páginas en 7
> directorios hermanos, un home con 9 H2 y 4 CTAs, 9 landings con cero enlaces de
> cuerpo. **Un contrato que solo valida la FORMA acaba aprobando cualquier contenido
> bien formado.**
## B2 · Estructura: por prioridad de negocio, no por tipo de contenido
- [ ] Los carriles son **las prioridades del negocio**. Si P1 es el producto, `/producto/`
es un carril; `/guias/` `/comparativas/` `/notas/` **no son tres carriles, son
etiquetas de uno**.
- [ ] **Una sola taxonomía de contenido.** Cuatro carpetas hermanas con una página cada
una no es una taxonomía, es un blog mal ordenado.
- [ ] **Nada de páginas para cosas que no existen.** Un producto "planned" no tiene
landing; sale como línea en su hub.
## B3 · Keywords: medir, no intuir
- [ ] Volumen del **mercado real**, no el global. ⚠️ `agencia de marketing digital` son
50.000 globales y **5.000** en el nuestro: decidir por el global es decidir sobre
un mercado al que no vendemos.
- [ ] Mirar la **tendencia interanual**, no solo el volumen. Una pieza apuntaba a un
término que caía **90%**.
- [ ] ⚠️ **Buscar en TODOS los ficheros de datos antes de decir que un término no
existe.** Un cero de `grep` es un no-match, no una ausencia. Se declaró
inexistente un término que sí estaba, en otro fichero, y casi se reescribe el plan.
## B4 · Honestidad (lo que más caro sale)
- [ ] **Estados reales.** Nada dice "Live" si no está verificado en runtime.
- [ ] **Cero métricas inventadas.** Si no se puede citar del sitio, no se afirma.
- [ ] **El posicionamiento vigente, en TODAS las superficies.** ⚠️ Al cambiar el eje del
mensaje, las 30 páginas se actualizaron y `AGENTS.md` no: siguió ocho veces con el
mensaje retirado, en el fichero que se presenta como *"la fuente de verdad"* para
los motores de respuesta.
- [ ] Si el sitio declara "sin cookies ni analítica", **que siga siendo cierto** después
de cada cambio.
## B5 · Contenido
- [ ] Una idea por página. Si el H1 y los H2 dicen cosas distintas, son dos páginas.
- [ ] La respuesta **arriba**, no al final.
- [ ] Escrito por quien hace el trabajo, con detalle que solo tiene quien lo hace.
- [ ] **Nada de formularios** si la política del sitio es contacto directo.
## B6 · Cómo mirar sin engañarse
⚠️ Todas estas ya produjeron un diagnóstico falso:
- [ ] **Mirar, no solo consultar.** Un chequeo del DOM puede pasar con la pantalla rota.
- [ ] **No leer colores de una captura.** Usar `getComputedStyle`.
- [ ] **Comprobar el ancho real antes de creerse una captura móvil.** Chrome headless en
Windows clampa a ~500px: pides 375, compone a 504, y la imagen es un **recorte**.
- [ ] ⚠️ **`--virtual-time-budget` congela las animaciones cerca de t=0.** El hero sale
en blanco aunque la página esté perfecta. Para ver el estado final:
`--force-prefers-reduced-motion`.
- [ ] ⚠️ **Antes de creer que un cambio propio rompió algo, renderizar lo de ANTES con
las mismas banderas.** Si sale igual de roto, el roto es el método.
- [ ] **Si un barrido dice que varias cosas están rotas a la vez, sospechar del barrido.**
Un sistema que lleva semanas corriendo no se rompe en bloque; un patrón nuevo sí.
- [ ] **Post-deploy, cache-bust antes de creerse nada.**
## B7 · Deploy
- [ ] `audit.sh` en verde. **No se despliega en rojo.**
- [ ] ⚠️ **El CSS nuevo va en la MISMA tanda que la página que lo usa.**
- [ ] Estado limpio commiteado antes de cualquier edición programática.
- [ ] En servidor compartido: backup, validar sobre copia, y **comprobar que los vecinos
siguen vivos** después de recargar.
- [ ] Dueño y permisos de los ficheros subidos, consistentes con el resto.
## B8 · Medición
- [ ] **Live ≠ rankeando.** Una reestructuración tarda semanas en consolidar.
- [ ] Ventana de **~60 días** antes de concluir si un ángulo funciona.
- [ ] Medir también si las **IA nos citan**, no solo el buscador.
- [ ] ⚠️ **Sin datos de Search Console no hay diagnóstico de SEO**, solo hipótesis. Decirlo
en vez de rellenar con proxies.
---
## Cuando el checklist falle
Si algo se rompe y **no estaba en esta lista**, la tarea no es solo arreglarlo:
1. Arreglarlo.
2. **Añadir la comprobación a `audit.sh`** si una máquina puede detectarlo.
3. Si no, añadir la línea a la Parte B **con el caso real que la originó**.
Un checklist que no crece con los fallos se queda obsoleto en un mes.
================================================================
FILE: docs/00-pagina-de-referencia.md
================================================================
# 00 · La página de referencia
**Empieza por aquí.** El resto de documentos explican reglas; este enseña **una página
correcta entera**, anotada. Cuando dudes de cómo se hace algo, cópialo de aquí.
Todo lo de abajo está **sacado de una página real en producción**, no inventado para el
ejemplo: `climentmedia.com/ads-assistant/meta-ads/`.
---
## El `<head>` completo, línea a línea
```html
<!DOCTYPE html>
<html lang="en"> <!-- ✅ lang SIEMPRE -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meta Ads MCP: what it actually gets you, and the options</title>
```
> ✅ **El título lleva EL TÉRMINO primero** (`Meta Ads MCP`), luego la promesa. 58
> caracteres.
> ❌ `SEO Ops - Climent Media` — solo marca. Medido en un caso real: **87 impresiones,
> 0 clics**.
```html
<meta name="description" content="A Meta Ads MCP lets an AI assistant talk to your ad account. Some only read; some can change campaigns and spend. What each option does, and how to pick.">
```
> ✅ **152 caracteres.** Define el término en la primera frase, mete la tensión en la
> segunda (*"some only read; some can change campaigns and spend"*) y cierra con la
> promesa. **Cabe entera en el resultado de búsqueda.**
> ❌ Una de 276 en la misma web perdía el gancho: se cortaba justo antes de la frase que
> convencía.
> **Se mide decodificado:** Google ve `&`, no `&`.
```html
<link rel="canonical" href="https://climentmedia.com/ads-assistant/meta-ads/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Climent Media">
<meta property="og:title" content="Meta Ads MCP: what it actually gets you, and the options">
<meta property="og:description" content="A Meta Ads MCP lets an AI assistant...">
<meta property="og:url" content="https://climentmedia.com/ads-assistant/meta-ads/">
<meta property="og:image" content="https://climentmedia.com/assets/og-assistant-climent-ads-assistant.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="...">
<meta name="twitter:description" content="...">
<meta name="twitter:image" content="...">
```
> ✅ Canonical **absoluto** y coincidiendo con la URL real. Open Graph **completo**: sin
> `og:image` la página se comparte como un rectángulo gris.
> ⚠️ **Imagen propia por página.** Cuando todas comparten la genérica, once páginas
> distintas se ven idénticas en LinkedIn o Slack.
```html
<link rel="stylesheet" href="../../styles.css?v=4e11e91ee6">
<link rel="stylesheet" href="../../components.css?v=7ac47bafeb">
```
> ✅ **El prefijo corresponde a la profundidad.** Esta página está a dos niveles → `../../`.
> ⚠️ Un prefijo mal deja la página **sin estilo y sin fallar**. Si el acento sale en negro,
> es el prefijo.
```html
<script type="application/ld+json">{"@context":"https://schema.org","@type":"TechArticle",
"headline":"...","description":"...",
"author":{"@type":"Person","name":"Manuel Climent","jobTitle":"Founder",
"worksFor":{"@type":"Organization","name":"Climent Media"},
"sameAs":["https://github.com/...","https://www.linkedin.com/in/..."]},
"publisher":{"@type":"Organization","name":"Climent Media","url":"https://climentmedia.com/"},
"datePublished":"2026-07-31","dateModified":"2026-07-31",
"mainEntityOfPage":"https://climentmedia.com/ads-assistant/meta-ads/",
"inLanguage":"en","about":["meta ads mcp","paid advertising","AI advertising tools"]}</script>
<script type="application/ld+json">{"@type":"BreadcrumbList", ...}</script>
<script type="application/ld+json">{"@type":"FAQPage", ...}</script>
```
> ✅ **Tres bloques, cada uno con su trabajo:** el tipo de la página, la miga de pan, y el
> FAQ. `author` con `sameAs` es lo que conecta el contenido con una persona real.
> ❌ **NO poner `Article` y `TechArticle` a la vez.** Uno solo.
> ⚠️ **Cero entidades HTML dentro.** Dentro de un `<script>` el navegador no las
> decodifica: Google leería `don’t` literal.
---
## El cuerpo
```html
<article class="lp">
<div class="lp-hero">
<p class="lp-cat">Meta Ads</p> <!-- eyebrow: donde estoy -->
<h1>Meta Ads and your AI assistant</h1> <!-- UNO, y solo uno -->
<p class="lp-tagline">Everyone is searching for a Meta Ads MCP.
Worth knowing what one actually does before you install it.</p>
</div>
<div class="lp-lead"> <!-- LA CAPSULA -->
<p>An MCP server is the plumbing that lets an AI assistant like Claude or
ChatGPT talk to your Meta ad account. Some are read-only; others can also
edit campaigns and spend money. The difference is not how clever the AI is,
it is what the AI is allowed to do without asking you first.</p>
</div>
```
> ✅ **La cápsula es lo más importante de la página.** Un párrafo, 2-4 frases, que
> **responde la pregunta entera sin necesitar contexto**. Es lo que una IA cita literal y
> lo que lee quien no va a bajar más.
> **Va arriba, no al final.** La respuesta primero.
```html
<h2>What it actually gets you</h2>
<ul class="tpl-list">...</ul>
<h2>The options</h2>
<div class="cmp-wrap"><table class="cmp">
<thead><tr><th>Option</th><th>Can it write?</th><th>Best for</th></tr></thead>
<tbody>
<tr><td>Meta's official MCP</td><td>Yes</td><td>Teams that want the vendor path</td></tr>
<tr><td>Ryze AI</td><td>Yes, plus an autonomous agent layer</td><td>Teams that want the agent to act on its own</td></tr>
<tr><td>Climent Ads Assistant</td><td>Proposes, then waits for you</td><td>Client work, where being wrong is expensive</td></tr>
</tbody>
</table></div>
```
> ✅ **La tabla nombra a los competidores con lo que cada uno ES de verdad**, incluido lo
> que hacen mejor. Una comparativa donde ganas en todo no la cree nadie — y las IA
> citan la que parece honesta.
> ✅ `.cmp-wrap` con `overflow-x: auto`: **la tabla scrollea dentro de sí misma**, la
> página nunca scrollea en horizontal.
```html
<div class="faq">
<div class="faq-item">
<p class="faq-q">Do I need an MCP server to use AI on my Meta ads?</p>
<p class="faq-a">No. An MCP is one way to connect an assistant to your account...</p>
</div>
</div>
</article>
```
> ✅ **3-5 preguntas, las que se hacen de verdad**, y la primera palabra de la respuesta ya
> responde (*"No."*). Cuadra con el `FAQPage` del JSON-LD.
---
## Las dos familias de componentes
⚠️ **Corregido el 2-ago:** la primera versión de esta documentación decía que la librería
eran los `.tpl-*`. **Era inexacta**, y es justo el fallo contra el que avisa el capítulo de
estilos: describir un sistema que el código no tiene. Medido sobre las 39 páginas:
| Familia | Dónde | Para qué |
|---|---|---|
| **`.lp-*` · `.cmp` · `.offer` · `.faq`** | **31 páginas** | La maqueta de una **página hoja**: hero, cápsula, tabla comparativa, oferta, FAQ |
| **`.tpl-list`** (24) · **`.tpl-badge`** (14) · **`.tpl-grid`/`.tpl-card`** (5) · **`.tpl-steps`** (1) | transversal | Bloques **compartidos** que se usan dentro de cualquier tipo |
**La regla sigue igual:** una tarjeta, una rejilla, una lista. Lo que cambia es que hay
**dos capas** — la maqueta del tipo de página y los bloques compartidos — y conviene
saber cuál estás tocando.
---
## Cómo verificar esta página
```bash
bash audit.sh # S2 completo cae sobre esta pagina
bash audit.sh --live # + que responde 200 y con cache
```
Y lo que el auditor **no** puede juzgar, del checklist Parte B:
- ¿La cápsula responde la pregunta **sin contexto**?
- ¿La tabla es honesta con los competidores?
- ¿Hay **dos enlaces de cuerpo** apuntando aquí desde otras páginas?
- ¿El término tiene **volumen medido** en el mercado real?
================================================================
FILE: docs/01-estructura.md
================================================================
# 01 · Estructura del sitio
## La regla que lo decide todo
> **La web se organiza por PRIORIDAD DE NEGOCIO, no por tipo de contenido.**
Suena obvio y casi nadie lo hace, porque organizar por tipo es más cómodo: hay un sitio
evidente donde meter cada pieza. Ese es justamente el problema.
**Qué pasa si organizas por tipo.** Acabas con `/guias/`, `/comparativas/`, `/notas/`,
`/recursos/` como carpetas hermanas. Cada pieza nueva encuentra su hueco y entra. Nadie
pregunta nunca si la web necesitaba esa página. Auditado en un caso real: **36 páginas en
7 directorios hermanos**, un home con **9 H2 y 4 CTAs** compitiendo, **9 landings con cero
enlaces de cuerpo**, y dos de esas taxonomías con **una sola página cada una**.
El diagnóstico del dueño fue: *"no estamos creando una web, estás montando todo como un
blog"*. Tenía razón, y el fallo no era de ejecución: **era del contrato**.
## Cómo se definen los carriles
Un carril = una prioridad del negocio. Se decide preguntando *"¿qué tiene que pasar para
que esto funcione?"*, no *"¿qué contenido tengo?"*.
Ejemplo real (climentmedia.com):
| Carril | Prioridad | Por qué es un carril y no una etiqueta |
|---|---|---|
| `/ads-assistant/` | **P1** — el producto | Es lo que se vende. Sus 6 hijas son capacidades reales **y** términos cabecera a la vez |
| `/agents/` | **P2** — otros agentes | Otra línea de negocio, otro comprador |
| `/agency/` + `/es/` | **P3** — servicio | La gente busca "agencia", no el nombre del rol |
| `/learn/` | Contenido | **UNA** taxonomía. El tipo (guía / comparativa / nota) es una **etiqueta**, no una carpeta |
| `/roadmap/` | Confianza | Build-in-public: qué está verificado y qué solo afirmado |
**Las dos versiones de idioma NO son gemelas.** `/agency/` y `/es/` atacan keywords
distintas en mercados distintos: **no llevan `hreflang` entre ellas**. Marcarlas como
traducciones una de otra le dice a Google que elija una y descarte la otra.
## Reglas duras
1. **Si una página no cabe en ningún carril, o sobra o falta un carril.** No se crea una
carpeta nueva "provisional": eso es exactamente cómo se llega a siete.
2. **Una sola taxonomía de contenido.** Si tienes cuatro carpetas hermanas con una página
cada una, no tienes una taxonomía.
3. **Nada de páginas para cosas que no existen.** Un producto en estado "planned" **no
tiene landing**: sale como una línea en el hub de su carril. Publicar una landing de
algo inexistente es la forma más barata de perder credibilidad.
4. **El home enlaza a los cuatro carriles.** Es el enlace interno más fuerte que existe;
un carril sin él nace cojo. Esto lo comprueba `audit.sh`.
## PASO 0 — antes de crear CUALQUIER página
Las tres preguntas. Si falla una, **no se escribe**:
1. **¿A qué carril pertenece?**
2. **¿Qué término ocupa, con volumen MEDIDO?** Del mercado real, no el global.
3. **¿Quién la enlaza desde el cuerpo?** Mínimo dos páginas. **El nav no cuenta**, porque
el nav enlaza a todo y por tanto no señala nada.
> **Un contrato que solo valida la FORMA acaba aprobando cualquier contenido bien
> formado.** El PASO 0 es lo único que valida el FONDO.
## Cómo se cambia la estructura sin romperla
Cuando una ruta desaparece:
1. **Redirección 301 uno a uno**, a la página equivalente. Nada de mandar todo al home:
una 301 al home le dice a Google que el contenido ya no existe.
2. **Un solo salto.** Nada de cadenas.
3. Las redirecciones van en **su propio fichero**, importado con una línea. Así se
revierte quitando esa línea.
4. **Barrer TODAS las superficies**, no solo el HTML:
- las páginas (`href`)
- `sitemap.xml`, `llms.txt`, `AGENTS.md` ⚠️ **no son HTML: un grep de `href=` no los
coge, y por eso sobreviven a la migración con URLs muertas dentro**
- la documentación del proyecto y **las skills o rutinas automáticas, incluidos sus
ficheros de `references/`** ⚠️ arreglar el índice y no lo que el índice manda leer
deja instrucciones muertas vivas: una rutina que las sigue **no falla, crea páginas
huérfanas en rutas 301, en silencio**
- los READMEs de repos públicos que enlazan al sitio
5. **Verificar cada 301 con `curl`** después de recargar, y comprobar que los vecinos del
servidor compartido siguen vivos.
================================================================
FILE: docs/02-plantillas.md
================================================================
# 02 · Plantillas por tipo de página
## La regla
> **Ninguna página se escribe a mano. Si algo no cabe en un tipo, se AMPLÍA el tipo.**
Es la regla que impide volver a "un blog". En el momento en que se improvisa una maqueta
"solo para esta página", el sistema deja de ser un sistema.
En climentmedia.com **34 de 39 páginas** salen de un generador. Las 5 que no, con motivo
escrito: el home (tipo único), las dos del lead magnet (flujo propio), el design system y
el 404.
## Los tipos
### `TplHub` — hub de carril
`/ads-assistant/` · `/agents/` · `/learn/` · `/roadmap/`
| Lleva | Por qué |
|---|---|
| H1 + entradilla | Qué es este carril |
| **Cápsula** (2-4 frases, párrafo único) | Lo que una IA puede citar entero sin recortar |
| Rejilla de tarjetas a las hijas | La señal interna del carril |
| JSON-LD `CollectionPage` + `ItemList` + `BreadcrumbList` | |
**NO lleva:** FAQ (va en las hojas), ni CTA de venta agresivo. Un hub orienta.
### `TplDoc` — página hoja
`/ads-assistant/<slug>/` · `/agents/<slug>/` · `/agency/` · `/es/`
| Lleva | Por qué |
|---|---|
| H1 con **el término**, no la marca | ⚠️ Un `<title>` de solo marca: 87 impresiones, **0 clics** |
| Cápsula arriba | La respuesta primero, no al final |
| Cuerpo con H2 reales | |
| Tabla de opciones cuando hay comparación | Incluidos los competidores, con lo que cada uno es de verdad |
| FAQ (3-5) | JSON-LD `FAQPage` |
| **Mínimo 2 enlaces de cuerpo** entrantes | PASO 0 |
| JSON-LD `TechArticle` o `Service` + `BreadcrumbList` | **Uno solo**, no los dos |
### Home
Tipo único. **Máximo 5 secciones.** Enlaza a los cuatro carriles. Una sola idea dominante
y **un CTA principal**. ⚠️ Nueve H2 y cuatro CTAs compitiendo fue el síntoma que disparó
la reconstrucción entera.
### Legales y páginas de sistema
`privacy` · `terms` · `data-deletion` · `404` · páginas de gracias
- `noindex, follow`, **fuera del sitemap**, y **coherentes entre sí**.
- ⚠️ Una página a medio publicar —con canonical y Open Graph completos pero fuera del
sitemap y sin enlaces— es indexable por accidente. O entra en un carril, o `noindex`.
## Componentes: dos capas, una de cada
| Capa | Clases | Alcance |
|---|---|---|
| **Maqueta del tipo de página** | `.lp-*` · `.cmp` · `.offer` · `.faq` | 31 páginas hoja |
| **Bloques compartidos** | `.tpl-list` · `.tpl-badge` · `.tpl-grid` · `.tpl-card` · `.tpl-steps` | transversales |
**Una** tarjeta, **una** rejilla, **una** lista. Ver el ejemplo anotado en
[`00-pagina-de-referencia.md`](00-pagina-de-referencia.md).
⚠️ En un solo día llegaron a convivir `.platform-card`, `.ag-card` y `.feat-list` haciendo
lo mismo con tres nombres, creadas por la misma persona en la misma sesión — cada uno
parecía "un caso especial". **Ninguno lo era.** Si hace falta una variante, es un
**modificador** del componente existente, no un componente nuevo.
⚠️ **Y el CSS muerto se borra.** Al eliminar una sección del sitio quedaron **20 reglas
huérfanas** con cero usos. No falla nunca: solo pesa y describe una estructura que ya no
existe, confundiendo a quien lo lea después. El auditor lo detecta (S5.4).
## El generador
Un script, specs en JSON, cero dependencias en runtime.
```
_p1_spec.json → carril P1: hub + hijas
_agency_spec.json → carril P3 (EN y ES)
_assistants_spec.json→ los agentes
_brand_logos.json → logos oficiales de plataforma
↓
generador → 34 páginas + sitemap.xml + llms.txt
```
**Reglas del generador, todas aprendidas a golpes:**
1. ⚠️ **Reconstruye `sitemap.xml` y `llms.txt` ENTEROS.** Nunca editarlos a mano: lo que
añadas ahí **se borra en el siguiente regenerado**. Ya pasó: una página se añadió a
mano al sitemap y desapareció.
2. ⚠️ **El contenido va en el spec, no en el HTML.** El HTML se reescribe entero.
3. ⚠️ **`llms.txt` y el sitemap tienen que salir del MISMO criterio.** Cuando divergieron,
`llms.txt` publicó **11 URLs a 404** durante dos días — una forma de URL que no había
existido nunca y que ninguna 301 cubría.
4. ⚠️ **Los prefijos de ruta por profundidad son un campo explícito**, y hay que pasar
**todas** las hojas de estilo. Cuando una se dejó sin prefijo, las páginas nuevas
salieron **sin estilo, en silencio**.
5. ⚠️ **Solo ASCII y entidades HTML en el fuente del generador** si el intérprete lee el
fichero en la codificación del sistema. Un guion largo literal se convierte en basura.
6. **Los estados salen del spec y no pueden prometer más de lo verificado.**
## Verificación del generador
Después de tocarlo:
```bash
# 1. regenerar
# 2. si NO se esperaban cambios de contenido, el diff debe estar vacio:
git status --short
# 3. y siempre:
bash audit.sh
```
Un diff vacío tras regenerar **prueba que el cambio no alteró la salida** — es la forma
más barata de verificar una refactorización del generador.
================================================================
FILE: docs/03-estilos.md
================================================================
# 03 · Estilos, tokens y librería
## La arquitectura de tokens: dos capas
```
Capa 1 — paleta cruda, como TRIPLETE RGB --accent-rgb: 255, 92, 0;
Capa 2 — alias semánticos --accent: rgb(var(--accent-rgb));
--border: rgba(var(--white-rgb), 0.09);
```
**Por qué tripletes y no `#hex` o `rgb()` cerrado.** Porque permite componer la opacidad
**en el punto de uso**: `rgba(var(--accent-rgb), .35)`.
⚠️ **El fallo que esto evita, medido:** sin tripletes, cada variante translúcida es un
número mágico escrito a mano. Llegaron a existir **56**. Un cambio de marca los habría
dejado obsoletos **en silencio** — porque no fallan, solo quedan mal. Un color mal no
rompe el build.
Regla: **si un color aparece con alpha, el token es el triplete.** Nunca se escribe un
`rgba(255, 92, 0, .35)` literal.
## La regla de los dos naranjas
Existen dos, **no son intercambiables**, y cada uno tiene su trabajo escrito:
| Token | Valor | Para qué | Contraste sobre el lienzo |
|---|---|---|---|
| `--accent` | `#ff5c00` | Marca: superficies, botones, **texto display grande**, iconos | 6,5:1 — AA de sobra (grande solo pide 3:1) |
| `--accent-bright` | `#ff7a26` | Naranja como **texto pequeño**: eyebrows, labels, enlaces legales | **7,6:1 — AAA** |
El de marca da 6,5:1: pasa AA, pero **se cae de AAA justo donde el texto va a 12px, en
mayúsculas y con tracking** — que es exactamente donde se usa. De ahí los dos.
> **Generalizable:** cuando un color de marca no llega a AAA en texto pequeño, la solución
> no es bajar el estándar ni cambiar la marca: es **un segundo token con su trabajo
> definido**. Lo que no vale es tener dos naranjas y que nadie sepa cuál usar — eso no es
> un sistema, es una divergencia.
## Reglas de color
- [ ] **Todo color con alpha sale de un triplete.** Cero `rgba()` literales.
- [ ] **Todo token tiene su trabajo escrito al lado.** Un token sin uso definido acaba
usándose para lo que no es.
- [ ] **Contraste verificado con `getComputedStyle`, nunca leído de una captura.**
⚠️ Un delta que parecía "verde" en una imagen resultó ser gris neutro.
- [ ] ⚠️ **Un fondo con degradado o alpha rompe los medidores de contraste automáticos**:
calculan contra el color declarado, no contra lo que se ve. Ahí se mide a mano.
## Escalas
Radios, espaciado y tipografía **como escala completa, no valores sueltos**.
⚠️ **Trampa real:** la documentación mandaba usar tres tokens de radio que **no existían
en la hoja del sitio** — vivían solo en el bundle del design system, que el sitio no
carga. La documentación describía un sistema que el código no tenía. **Si un token está
documentado, tiene que existir donde se usa**, y eso se comprueba.
## La librería de componentes
**Una** tarjeta, **una** rejilla, **un** sistema de pasos:
| Componente | Para |
|---|---|
| `.tpl-grid` | Toda rejilla de tarjetas |
| `.tpl-card` | Toda tarjeta |
| `.tpl-steps` | Secuencias numeradas |
| `.tpl-list` | Listas con marca |
| `.tpl-badge` + `.st-*` | Estados |
⚠️ Llegaron a convivir `.platform-card`, `.ag-card` y `.feat-list` haciendo lo mismo con
tres nombres — creados por la misma persona en la misma sesión, sin mala fe: cada uno
parecía "un caso especial". **Ninguno lo era.**
**Antes de crear un componente:** ¿es de verdad un patrón nuevo, o un **modificador** de
uno que existe? La respuesta casi siempre es la segunda.
## Organización de las hojas
| Fichero | Qué lleva |
|---|---|
| `styles.css` | Tokens, base, nav, footer, animaciones, componentes de plantilla |
| `components.css` | Componentes de directorio y estado |
⚠️ **Las hojas compartidas viven en la RAÍZ.** `components.css` estuvo dentro de una
carpeta de sección y, al borrarse esa carpeta en una reestructuración, **el sitio entero
se habría quedado sin estilos**. Una hoja que carga todo el sitio no puede colgar de una
sección.
## Verificación visual
- [ ] Contraste con `getComputedStyle` en todos los pares texto/fondo.
- [ ] Sin desbordamiento horizontal en móvil.
⚠️ **Falsos positivos frecuentes:** elementos dentro de un ancestro con scroll, o
dentro de un `<details>` cerrado, reportan anchos fantasma. Comprobar el ancestro y
saltarse los `details` cerrados.
- [ ] ⚠️ **`text-transform: uppercase` rompe las búsquedas sensibles a mayúsculas en
`innerText`.** Normalizar antes de comparar.
- [ ] **El CSS nuevo se despliega en la MISMA tanda que la página que lo usa.** El gate de
integridad no detecta esto.
================================================================
FILE: docs/04-ficheros-maquina.md
================================================================
# 04 · Los ficheros para máquinas
`sitemap.xml` · `llms.txt` · `AGENTS.md` · `robots.txt`
## Por qué tienen su propio capítulo
> ⚠️ **Se rompen sin avisar, porque NO son HTML.**
Un barrido de `href="..."` no los cubre. Un validador de HTML no los mira. Ninguna página
falla si están mal. Y sin embargo **son la cara del sitio para Google y para las IA**.
Caso real, medido: una reestructuración arregló las 39 páginas del sitio, los enlaces
internos y las 34 redirecciones. `llms.txt` se quedó con **11 URLs a 404 de 28** durante
dos días, y sin listar **ninguna** de las 12 piezas de contenido — justo las que ganan
citas de IA, invisibles en el único fichero que existe para eso. `AGENTS.md` se quedó
además con **el posicionamiento anterior**, ocho veces, en el documento que se presenta a
sí mismo como *"la fuente de verdad"*.
**Nadie lo detectó al desplegar. Lo detectó una rutina de auditoría una semana después.**
## `sitemap.xml`
- **Generado, nunca a mano.** ⚠️ Lo que se añade a mano se borra en el siguiente
regenerado. Ya pasó.
- Solo indexables. Ninguna con `noindex`.
- **Y al revés: toda indexable tiene que estar dentro.** Es la mitad que casi nadie
comprueba — se vigila que el sitemap no mienta, no que esté completo.
## `llms.txt`
La lista curada de páginas reales para los rastreadores de IA.
- **Sale del MISMO criterio que el sitemap.** ⚠️ Cuando divergieron, publicó una forma de
URL que **no había existido nunca** y que ninguna redirección cubría.
- **Texto plano:** cero entidades HTML. Un `&` se lee literal.
- Con descripción de una línea por página: es lo que la IA cita.
## `AGENTS.md`
Guía legible por máquina para agentes y motores de respuesta.
⚠️ **Es el que más deriva, porque es el único que no se genera.** Lleva las tres cosas que
caducan más rápido:
1. **URLs** → se rompen al reestructurar.
2. **Estados de producto** → se quedan atrás (llegó a decir "Live" de algo que era Beta).
3. **El posicionamiento** → lo peor. Si cambia el mensaje y este fichero no, **estás
sirviendo la versión antigua a las IA como si fuera canónica**.
**Regla:** si cambia el posicionamiento, `AGENTS.md` se revisa **en la misma tanda** que
las páginas. No en la siguiente.
Debe llevar, explícito: qué es el producto · la distinción central · los estados reales ·
lo que **no** se puede afirmar · cómo responder preguntas · **y una nota diciendo qué
afirmaciones antiguas ya no aplican**, porque los modelos han leído las versiones viejas.
## `robots.txt`
- `Sitemap:` con **URL absoluta**.
- Si la estrategia incluye citas de IA, **permitir explícitamente los rastreadores de IA**,
uno por uno y por su nombre. Un `Allow: /` genérico funciona, pero nombrarlos documenta
la decisión para quien lo lea después.
## Barrido de control
Ninguna línea debe salir:
```bash
for f in llms.txt AGENTS.md; do
for u in $(curl -s https://TU-SITIO/$f | grep -oE 'https://TU-SITIO[^ )*`"]+' | sed 's/[.,]$//' | sort -u); do
echo "$(curl -s -o /dev/null -w '%{http_code}' $u) $u"
done
done | grep -v '^200'
```
Está dentro de `audit.sh` (S1 y S6.6). **Y también en el runbook de deploy**, porque un
fichero para máquinas roto no se ve mirando la web.
================================================================
FILE: docs/05-rendimiento-y-entrega.md
================================================================
# 05 · Rendimiento y entrega
## Animaciones: la regla de una línea
> **Solo `transform` y `opacity`.** Todo lo demás repinta en el hilo principal.
Chrome compone `transform` y `opacity` en la GPU. `box-shadow`, `width`, `height`,
`top/left`, `margin` y los colores **no**: cada fotograma vuelve al hilo principal.
⚠️ **Caso real:** un punto de estado animaba `box-shadow` para hacer un halo. Salía **dos
veces en cada página** del sitio, repintando sin parar. No aparece como error en ningún
sitio: solo gasta batería y da tirones en móviles lentos.
**El arreglo:** el halo pasa a un pseudo-elemento que anima `transform: scale()` +
`opacity`. Visualmente idéntico — verificado congelando ambas versiones a cuatro tiempos
distintos con `animation-delay` negativo y `animation-play-state: paused`, y comparándolas
lado a lado.
## Toda animación necesita su salida
```css
@media (prefers-reduced-motion: reduce) {
.lo-que-anima { animation: none; }
}
```
⚠️ **Y hay que comprobar que el selector es el que anima de verdad.** Al mover un pulso de
`.punto` a `.punto::after`, la regla vieja (`.punto { animation: none }`) **dejó de
aplicar sin avisar**, y encima el elemento se quedaba congelado en su estado natural: un
círculo permanente. En ese caso la salida correcta no es `animation: none` sino
`display: none`.
## Above the fold: nunca depender de JavaScript
⚠️ **El fallo más caro y menos evidente de todos.**
Una animación de entrada con `IntersectionObserver` deja el elemento en `opacity: 0`
**hasta que carga y ejecuta el JavaScript**. Above the fold eso cuesta:
- **Speed Index de 3,9 s en móvil** frente a 0,4 s en escritorio.
- El insight de LCP señalando el párrafo que dice qué hace la empresa.
- Y si el JS falla o tarda, **el hero está en blanco**.
**Regla:**
| Dónde | Cómo se anima |
|---|---|
| **Above the fold** | CSS puro (`animation` con `fill-mode: both`). Cero JS |
| **Below the fold** | `IntersectionObserver`, que ahí sí tiene sentido |
## No forzar reflow en handlers de scroll
Leer `window.scrollY` o `offsetTop` **dentro** del handler fuerza un reflow síncrono en
cada evento. Dentro de un `requestAnimationFrame` el layout ya está hecho.
Y **solo tocar el DOM cuando el estado cambia de verdad**, no en cada fotograma.
## Cabeceras de entrega
### `Cache-Control`
⚠️ **Comprobar que se envía alguna.** Un sitio puede servir `ETag` y `Last-Modified` y
**no enviar ni un `Cache-Control`**: cada visitante que vuelve revalida cada fichero, una
petición por asset para acabar en un 304 vacío. Pasó, y no se ve en ninguna auditoría de
página.
| Qué | TTL |
|---|---|
| CSS y JS | **1 día**, si los assets NO llevan huella en el nombre |
| Imágenes y fuentes | 30 días |
| HTML | `no-cache` — revalida, y con ETag la respuesta es un 304 vacío |
> ⚠️ **No subir CSS/JS a un año sin versionar los assets primero.** Si se llaman
> `styles.css` y no `styles.<hash>.css`, un año de caché deja al visitante recurrente con
> el CSS viejo y el HTML nuevo. Un día es el equilibrio: mata el "sin caché" y se cura
> solo.
### Compresión
⚠️ **Al comprobarla hay que PEDIRLA** (`Accept-Encoding`), o el servidor no la manda y
parece que no está configurada. Falso negativo fácil.
### Seguridad
`X-Content-Type-Options` · `Referrer-Policy` · `X-Frame-Options` · `Cross-Origin-Opener-Policy`
— gratis y reversibles al instante. Antes de poner `X-Frame-Options: DENY`, comprobar que
**nada del sitio se embebe a sí mismo**.
**Dos que conviene pensar dos veces:**
- **HSTS** tiene cola: durante su `max-age` los navegadores que ya entraron **se niegan a
hablar por HTTP**, y eso no se deshace quitando una línea.
- **CSP** ⚠️ si las páginas llevan datos estructurados en `<script>` inline, el
comportamiento no es uniforme entre navegadores. Arriesgar el JSON-LD de todo el sitio
por una auditoría que ni puntúa no sale a cuenta. Si se quiere, va en su propio
despliegue y se verifica página a página.
## Qué NO hacer
- **Minificar el CSS** si eso borra los comentarios que documentan los tokens y no hay
build. El ahorro tras comprimir es marginal; la pérdida de documentación no.
- **Inlinear el CSS crítico** en un sitio generado: es una trampa de mantenimiento — el
crítico se queda obsoleto en el primer cambio y nadie se entera.
- **Perseguir el 100/100.** Un 98 en móvil con la estructura correcta vale infinitamente
más que un 100 con la estructura mal.
================================================================
FILE: docs/06-deploy-y-verificacion.md
================================================================
# 06 · Deploy y verificación
## El gate
```bash
bash audit.sh # local
bash audit.sh --live # + produccion, despues de desplegar
```
**En rojo no se despliega.** Sin excepciones silenciadas: si algo no se puede comprobar,
el script escribe `[skip]` **con el motivo**.
## Secuencia
1. **Estado limpio commiteado.** ⚠️ Antes de cualquier edición programática o con regex de
un fichero desplegado. Si algo se rompe, se recupera al instante.
2. **Regenerar** si el cambio toca specs o generador.
3. **`git status`** — ¿cambió solo lo que esperabas? Si toca ficheros que no esperabas,
**parar y mirar**, no aceptar.
4. **`audit.sh`** en verde.
5. **Desplegar.**
6. ⚠️ **Corregir dueño y permisos.** Al subir por `tar | ssh`, los ficheros llegan con el
UID de origen, no con el del servidor web. Se sirven igual, pero deja el árbol
inconsistente.
7. **`audit.sh --live`.**
8. **Comprobar los vecinos** si el servidor es compartido: otros dominios, las
redirecciones antiguas, `www` → apex.
## Cómo mirar sin engañarse
⚠️ Todas estas produjeron un diagnóstico falso en su momento. No son teoría.
### Capturas de pantalla
- **Mirar, no solo consultar.** Un chequeo del DOM puede pasar con la pantalla rota: un
`[hidden]` bien puesto y el elemento visible igual porque un `display:flex` lo anulaba.
- **No leer colores de una captura.** Usar `getComputedStyle`.
- ⚠️ **Chrome headless en Windows clampa el ancho a ~500px.** Pides 375, la página se
compone a 504, y **la PNG es un recorte**: texto cortado y controles "desaparecidos" que
están fuera del corte. Se inventan defectos así. **Imprimir `innerWidth` y comprobar que
coincide con lo pedido.**
- ⚠️ **`--virtual-time-budget` congela las animaciones cerca de t=0.** El hero sale en
blanco aunque la página esté perfecta; lo único visible es lo que no tiene retardo.
- Para el **estado final**: `--force-prefers-reduced-motion`.
- Para ver una animación **a mitad**: página de prueba con `animation-delay` negativo y
`animation-play-state: paused` — permite además comparar la versión vieja y la nueva
lado a lado.
- ⚠️ **El panel de navegador puede no componer frames** (`innerWidth` = 0). Entonces **NO
se ha mirado**: o se renderiza con un Chrome local verificando el ancho, o se dice que
no está verificado. Lo que no vale es medir el DOM y reportarlo como si se hubiera visto.
### La regla que las resume
> ⚠️ **Antes de creer que un cambio propio rompió algo, renderizar lo de ANTES con las
> mismas banderas.** Si sale igual de roto, **el roto es el método de captura**.
### Barridos y grep
- ⚠️ **Un barrido con un patrón inventado mide tu hipótesis, no el hecho.** Antes de
barrer, **abrir dos ficheros** y ver cómo se escribe eso de verdad.
- ⚠️ **Todo cero de `grep` es un no-match, no una ausencia.** Antes de reportarlo, abrir
uno de los que salieron en cero y comprobarlo a mano.
- ⚠️ **Si el barrido dice que varias cosas están rotas a la vez, el sospechoso es el
barrido.** Un sistema que lleva semanas corriendo no se rompe en bloque; un patrón nuevo
sí falla en bloque.
- ⚠️ **Cuidado con `sed '/A/,/B/p'`**: no busca el cierre en la misma línea que la
apertura. Con un bloque de una sola línea, el rango se come el resto del documento.
- ⚠️ **Y con los bucles sobre resultados con espacios.** Un `data:` URI con un SVG dentro
se parte en veinte trozos y genera veinte falsos positivos.
### Post-deploy
⚠️ **Cache-bust antes de creerse nada.** Con caché activa, estás mirando la versión
anterior.
## Cuando algo falla dos o tres veces
**Parar y rediagnosticar** desde código, logs y contratos. No iterar a ciegas. Si un
arreglo no entra a la tercera, el modelo mental está mal, no el arreglo.
## Cuando el checklist no lo cogió
1. Arreglarlo.
2. **Añadir la comprobación al auditor** si una máquina puede detectarlo.
3. Si no, a la parte de criterio **con el caso real que la originó**.
⚠️ **Y auditar el auditor.** La primera versión de este script tenía un fallo en el
listado de páginas y devolvía **cero ficheros**: media auditoría reportaba `[ ok ]` sin
haber mirado nada. **Iterar sobre una lista vacía no falla, "pasa".** Por eso lleva un
guardia que aborta si el barrido sale vacío — y por eso conviene, la primera vez, romper
algo a propósito y comprobar que el auditor lo caza.
================================================================
FILE: audit.conf.example
================================================================
# Configuracion de _audit.sh para climentmedia.com.
# Para auditar OTRO proyecto: copia _audit.sh y este fichero, y cambia estos valores.
# El script no se toca nunca; toda la particularidad del sitio vive aqui.
# URL base sin barra final.
CONF_BASE_URL="https://ejemplo.com"
# Marca, para detectar <title> que son solo marca y no llevan termino.
# (mkt.climentmedia.com tenia <title>Climent Media</title>: 87 impresiones, 0 clics.)
BRAND="Tu Marca"
# Carpetas de trabajo que NO se despliegan. Se excluyen de todos los barridos.
EXCLUDE_DIRS="ds-bundle _cowork _post-images _seo .design-sync .git node_modules _deploy downloads"
# Rutas que ya solo existen como redireccion 301. Ninguna superficie -- ni el
# sitemap, ni llms.txt, ni AGENTS.md -- puede volver a citarlas: publicar un
# enlace propio hacia una redireccion es deuda que se hereda.
DEAD_PREFIXES=""
================================================================
FILE: audit.sh
================================================================
#!/usr/bin/env bash
# =============================================================================
# _audit.sh - AUDITOR DE SITIO (no de pagina).
#
# Por que existe: teniamos un estandar de PAGINA (skill web-page-standard, 265
# lineas) y un gate de integridad de 5 comprobaciones (_check.sh). Entre los dos
# no habia nada que validara el sitio COMO SISTEMA. Todos los fallos que se nos
# colaron el 31-jul y el 1-ago eran de sistema, no de pagina:
#
# - llms.txt publicaba 11 URLs a 404 y no listaba ninguna pieza de /learn/
# - AGENTS.md afirmaba un posicionamiento retirado hacia meses
# - el sitio no enviaba ni una cabecera Cache-Control
# - una pagina indexable sin enlaces entrantes ni noindex
#
# Ninguna pagina estaba mal por separado. Por eso este fichero es EJECUTABLE:
# un documento hay que acordarse de leerlo, un script falla solo.
#
# USO
# bash _audit.sh # auditoria local (ficheros en disco)
# bash _audit.sh --live # + entrega real en produccion (necesita red)
# bash _audit.sh --root DIR --url https://ejemplo.com
#
# PORTABLE: sin jq, sin node, sin python. Solo bash, grep, sed, find y curl.
# Para otro proyecto: copia el fichero y ajusta _audit.conf (ver abajo).
#
# SALIDA: [ ok ] pasa · [FAIL] rompe el build · [warn] mirar, no bloquea ·
# [skip] no se pudo comprobar Y POR QUE (nunca se salta en silencio).
# =============================================================================
set -u
# ---------- configuracion ----------
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SELF_DIR"
BASE_URL=""
LIVE=0
# Defecto GENERICO a proposito: este script lo comparten todas las webs, y el
# defecto de antes arrastraba carpetas de climentmedia (ds-bundle, _cowork,
# .design-sync) dentro de un script compartido. Las 6 webs declaran el suyo en
# su .conf, asi que este defecto solo lo ve una web NUEVA -- y a una web nueva
# no se le pueden excluir carpetas que no tiene.
EXCLUDE_DIRS=".git node_modules dist build vendor"
DEAD_PREFIXES=""
BRAND=""
while [ $# -gt 0 ]; do
case "$1" in
--live) LIVE=1; shift ;;
--root) ROOT="$2"; shift 2 ;;
--url) BASE_URL="$2"; shift 2 ;;
*) echo "argumento desconocido: $1"; exit 2 ;;
esac
done
# ---------------------------------------------------------------------------
# GUARDA: este script vive en la SKILL, no dentro de cada web.
# ---------------------------------------------------------------------------
# 19-ago-2026. Antes vivia COPIADO en cada repo -4 copias, 3 versiones-, y por
# eso `ROOT` cae por defecto en la carpeta del propio script: ahi eso era
# correcto. Ahora ya no: sin --root mediria la skill y diria cosas de ella,
# en silencio y con aspecto de informe. Un instrumento que mide el arbol
# equivocado sin quejarse es peor que uno que no corre.
if [ "$ROOT" = "$SELF_DIR" ]; then
echo "audit.sh: falta --root." >&2
echo " Este script vive en la skill y NO mide la carpeta en la que esta." >&2
echo " Uso: bash \"$0\" --root /ruta/al/repo" >&2
exit 2
fi
# El .conf permite reusar este script en cualquier proyecto sin editarlo.
# Se aceptan los DOS nombres: el kit publicado usa `audit.conf` y los repos
# nuestros `_audit.conf`. Antes solo valia el segundo, y el kit de fuera no
# encontraba su propia configuracion.
for c in "$ROOT/audit.conf" "$ROOT/_audit.conf"; do [ -f "$c" ] && . "$c" && break; done
[ -n "$BASE_URL" ] || BASE_URL="${CONF_BASE_URL:-}"
FAIL=0; WARN=0; NCHECK=0
bad() { echo " [FAIL] $1"; FAIL=$((FAIL+1)); }
warn() { echo " [warn] $1"; WARN=$((WARN+1)); }
ok() { echo " [ ok ] $1"; }
skip() { echo " [skip] $1"; }
sec() { echo; echo "-- $1"; }
# Lista de paginas HTML desplegables (excluye las carpetas de trabajo).
# OJO: la primera version usaba `eval find ... -not -path "..."` y devolvia CERO
# paginas en Git Bash. Consecuencia: S2, S3, S4.2 y S5.1 iteraban sobre nada y
# reportaban [ ok ] sin haber comprobado ni un fichero. Es EXACTAMENTE el fallo
# que este auditor existe para impedir -- una comprobacion que pasa porque no
# comprobo nada -- y por eso abajo hay un guardia que revienta si salen 0.
page_list() {
find "$ROOT" -name '*.html' -type f 2>/dev/null | sed "s|^$ROOT/||" | while read -r p; do
keep=1
for d in $EXCLUDE_DIRS; do
case "$p" in "$d"/*) keep=0; break ;; esac
done
[ "$keep" = 1 ] && echo "$p"
done | sort
}
# ---------------------------------------------------------------------------
# resolver_ruta · LAS DOS CONVENCIONES DE URL, EN UN SOLO SITIO
# ---------------------------------------------------------------------------
# 19-ago-2026. Nuestras webs sirven las URLs de dos formas distintas, y hasta
# hoy el auditor solo conocia una:
#
# dir-barra /contacto -> contacto/index.html (bcmadrid, cm, nora)
# plano-sin-ext /contacto -> contacto.html (kine, mobanho)
#
# `conformidad.conf` ya nombraba las dos por web (`modo = ...`) y el auditor no
# se habia enterado: la primera vez que se le corrio a kine marco 363 FALLOS en
# un sitio correcto -310 de ellos "enlace roto"-. Es la tercera vez que este
# fichero paga el mismo error: un supuesto de convencion que nadie declaro.
# *Si el barrido dice que todo esta roto a la vez, el roto es el barrido.*
#
# NO se pregunta cual es la convencion: se acepta la que EXISTA. Asi no hay que
# declararla en dos sitios y no pueden discrepar. Si no existe ninguna, se
# devuelve la PRIMERA para que el mensaje de error diga que se esperaba.
#
# Recibe y devuelve rutas ABSOLUTAS. La usan url_to_file (sitemap, ficheros
# para maquinas) y el grafo de enlaces: un solo sitio, una sola regla.
resolver_ruta() {
local c="$1" ultimo cand1 cand2
case "$c" in
*/) cand1="${c}index.html"; cand2="${c%/}.html" ;;
*) ultimo="${c##*/}"
case "$ultimo" in
*.*) echo "$c"; return ;;
esac
cand1="$c/index.html"; cand2="$c.html" ;;
esac
[ -f "$cand1" ] && { echo "$cand1"; return; }
[ -f "$cand2" ] && { echo "$cand2"; return; }
echo "$cand1"
}
# La INVERSA de resolver_ruta: de un fichero en disco a las URLs con las que se
# puede estar publicando. Existe por el mismo motivo y en la misma pareja: S1.3
# mapeaba fichero -> URL conociendo UNA sola convencion, y por eso acusaba a
# kine de tener 15 paginas «fuera del sitemap» que estan perfectamente dentro
# (contacto.html se publica como /contacto). Las dos funciones son las dos
# direcciones de la MISMA regla, y por eso viven juntas.
urls_de_fichero() {
local p="$1" base sinbarra
base="$(echo "$p" | sed 's|index\.html$||')"
echo "$BASE_URL/$base" # /carpeta/ o /pagina.html
sinbarra="${base%/}"
echo "$BASE_URL/$sinbarra" # /carpeta o /pagina.html
case "$base" in
*.html) echo "$BASE_URL/${base%.html}" ;; # /pagina (convencion plana)
esac
}
# URL absoluta -> ruta de fichero en disco, RELATIVA a ROOT.
# La convencion (carpeta/index.html o fichero plano) la decide `resolver_ruta`,
# que es el UNICO sitio donde esa regla esta escrita.
url_to_file() {
local u="${1#$BASE_URL}"; u="${u%%\#*}"; u="${u%%\?*}"
u="${u#/}"
[ -z "$u" ] && { echo "index.html"; return; }
local abs; abs="$(resolver_ruta "$ROOT/$u")"
echo "${abs#$ROOT/}"
}
# Texto de <title> sin el sufijo de marca.
title_of() {
sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' "$1" | head -1
}
# El contenido de un <pre> es TEXTO, no marcado: un href dentro de un ejemplo
# de codigo no es un enlace. Sin esto, una pagina que inlinea documentacion
# (las de /copy/) reporta como rotos los href de sus propios ejemplos, y hasta
# fragmentos de regex sueltos. Es el mismo error que el chequeo de mojibake:
# tratar codigo fuente inlineado como si fuera markup.
# awk y no `sed '/<pre/,/<\/pre>/d'`: sed no cierra el rango en la misma linea,
# asi que un <pre> de una linea se comeria el resto del documento.
strip_pre() {
awk '{
line=$0
while (match(line, /<pre[^>]*>/)) {
pre=substr(line,1,RSTART-1); rest=substr(line,RSTART+RLENGTH)
if (match(rest, /<\/pre>/)) { line = pre substr(rest,RSTART+RLENGTH); continue }
else { print pre; inpre=1; line=""; break }
}
if (inpre) { if (match(line, /<\/pre>/)) { inpre=0; line=substr(line,RSTART+RLENGTH) } else next }
print line
}' "$1"
}
echo "==========================================================="
echo " AUDITORIA DE SITIO: $ROOT"
[ -n "$BASE_URL" ] && echo " URL base: $BASE_URL"
[ "$LIVE" -eq 1 ] && echo " modo: LOCAL + PRODUCCION" || echo " modo: LOCAL (usa --live para comprobar la entrega)"
echo "==========================================================="
PAGES="$(page_list)"
NPAGES=$(echo "$PAGES" | grep -c . )
echo " paginas desplegables encontradas: $NPAGES"
# GUARDIA ANTI-VACIO. Sin esto, un fallo en page_list convierte media auditoria
# en [ ok ] falsos: iterar sobre una lista vacia no falla, "pasa". Ya ocurrio.
if [ "$NPAGES" -lt 1 ]; then
echo " [FAIL] S0 el barrido de paginas devolvio CERO ficheros."
echo " No es que el sitio este limpio: es que no se ha mirado nada."
echo " Revisa ROOT y EXCLUDE_DIRS en _audit.conf antes de creerte nada."
exit 1
fi
# =============================================================================
sec "S1 · FICHEROS PARA MAQUINAS (sitemap · llms.txt · AGENTS.md · robots.txt)"
# Se rompen sin avisar porque NO son HTML: un grep de href= no los cubre.
# =============================================================================
SM="$ROOT/sitemap.xml"
if [ ! -f "$SM" ]; then
bad "S1.1 no hay sitemap.xml"
SM_URLS=""
else
head -1 "$SM" | grep -q '<?xml' && grep -q '<urlset' "$SM" \
&& ok "S1.1 sitemap.xml bien formado" || bad "S1.1 sitemap.xml malformado"
SM_URLS=$(grep -oE '<loc>[^<]+' "$SM" | sed 's/<loc>//')
NSM=$(echo "$SM_URLS" | grep -c .)
MISS=0
for u in $SM_URLS; do
f="$(url_to_file "$u")"
[ -f "$ROOT/$f" ] || { bad "S1.2 sitemap apunta a algo que no existe en disco: $u (esperaba $f)"; MISS=1; }
done
[ "$MISS" -eq 0 ] && ok "S1.2 las $NSM URLs del sitemap existen en disco"
# S1.3 toda pagina indexable tiene que estar EN el sitemap.
# Es la mitad que casi nadie comprueba: se vigila que el sitemap no mienta,
# no que este completo. Una pagina real fuera del sitemap es una pagina que
# Google puede tardar meses en encontrar.
# ⚠️ ESTE CHEQUEO TRAIA UN SUPUESTO SIN DECLARAR: que `carpeta/index.html` se
# indexa como `/carpeta/` CON barra. Al pasar el sitio a URLs sin barra
# -que es la forma que este cliente ya tiene indexada- marco 78 fallos en un
# sitio correcto. Es la misma clase de fallo que la trampa 2 de CLAUDE.md,
# con los enlaces relativos. Ahora se comparan las dos formas.
# *Si el barrido dice que todo esta roto a la vez, el roto es el barrido.*
FALTAN=0
for p in $PAGES; do
grep -q 'name="robots"[^>]*noindex' "$ROOT/$p" && continue
ENCONTRADA=0
for u in $(urls_de_fichero "$p"); do
echo "$SM_URLS" | grep -qxF "$u" && { ENCONTRADA=1; break; }
done
if [ "$ENCONTRADA" -eq 0 ]; then
bad "S1.3 indexable pero FUERA del sitemap: $p (o la anades, o le pones noindex)"
FALTAN=1
fi
done
[ "$FALTAN" -eq 0 ] && ok "S1.3 toda pagina indexable esta en el sitemap"
# S1.4 y ninguna del sitemap puede ser noindex (se contradicen).
CONTRA=0
for u in $SM_URLS; do
f="$ROOT/$(url_to_file "$u")"
[ -f "$f" ] || continue
grep -q 'name="robots"[^>]*noindex' "$f" && { bad "S1.4 en el sitemap Y con noindex (se contradicen): $u"; CONTRA=1; }
done
[ "$CONTRA" -eq 0 ] && ok "S1.4 ninguna URL del sitemap lleva noindex"
fi
for MF in llms.txt AGENTS.md; do
if [ ! -f "$ROOT/$MF" ]; then
skip "S1.5 no hay $MF en el repo (si el sitio lo sirve, esta descuadrado)"
continue
fi
# El backtick y el asterisco se excluyen: son sintaxis de markdown, no de la URL.
URLS=$(grep -oE 'https?://[^ )"<>*`]+' "$ROOT/$MF" | sed 's/[.,:]$//' | sort -u)
MISSM=0; NEXT=0
for u in $URLS; do
case "$u" in
"$BASE_URL"*)
f="$(url_to_file "$u")"
[ -f "$ROOT/$f" ] || { bad "S1.5 $MF apunta a algo que no existe: $u (esperaba $f)"; MISSM=1; } ;;
*) NEXT=$((NEXT+1)) ;;
esac
done
NIN=$(echo "$URLS" | grep -c "^$BASE_URL")
[ "$MISSM" -eq 0 ] && ok "S1.5 las $NIN URLs propias de $MF existen ($NEXT externas, no comprobadas en local)"
# S1.6 son texto plano: una entidad HTML se lee literal.
if grep -qE '&(amp|mdash|ndash|middot|rarr|larr|quot|iquest|aacute|eacute|iacute|oacute|uacute|ntilde);' "$ROOT/$MF"; then
bad "S1.6 $MF tiene entidades HTML sin decodificar (es texto plano: se leen literales)"
else
ok "S1.6 $MF sin entidades HTML crudas"
fi
done
if [ -f "$ROOT/robots.txt" ]; then
grep -qi "^Sitemap: https\?://" "$ROOT/robots.txt" \
&& ok "S1.7 robots.txt declara el sitemap con URL absoluta" \
|| bad "S1.7 robots.txt no declara Sitemap: con URL absoluta"
else
bad "S1.7 no hay robots.txt"
fi
# S1.8 ninguna superficie puede citar una ruta que ya solo existe como 301.
if [ -n "$DEAD_PREFIXES" ]; then
DP=0
for pre in $DEAD_PREFIXES; do
for MF in sitemap.xml llms.txt AGENTS.md; do
[ -f "$ROOT/$MF" ] || continue
n=$(grep -c "$BASE_URL$pre" "$ROOT/$MF" 2>/dev/null)
[ "$n" -gt 0 ] && { bad "S1.8 $MF cita $n vez/veces la ruta muerta $pre"; DP=1; }
done
done
[ "$DP" -eq 0 ] && ok "S1.8 ningun fichero para maquinas cita rutas muertas"
else
skip "S1.8 sin DEAD_PREFIXES configurado en _audit.conf (no se puede comprobar)"
fi
# =============================================================================
sec "S2 · META POR PAGINA"
# =============================================================================
T_MISS=0; T_BRAND=0; D_MISS=0; D_LONG=0; D_SHORT=0; C_MISS=0; OG_MISS=0; H1_BAD=0; LANG_BAD=0; LD_ENT=0
TITLES_FILE="$(mktemp 2>/dev/null || echo "$ROOT/.audit-titles.tmp")"
: > "$TITLES_FILE"
for p in $PAGES; do
f="$ROOT/$p"
# Una pagina noindex no compite en buscadores ni se comparte: exigirle
# canonical, Open Graph o un title con termino es ruido, y un auditor
# ruidoso se acaba ignorando. Se le sigue exigiendo lo estructural.
INDEXABLE=1
grep -q 'name="robots"[^>]*noindex' "$f" && INDEXABLE=0
# --- title
t="$(title_of "$f")"
if [ -z "$t" ]; then bad "S2.1 sin <title>: $p"; T_MISS=1
else
echo "$t" >> "$TITLES_FILE"
# title que es solo la marca = 0 clics con impresiones (ya medido en mkt.)
if [ -n "$BRAND" ]; then
core="$(echo "$t" | sed "s/[[:space:]]*[-|&][a-z]*;*[[:space:]]*$BRAND[[:space:]]*$//I" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
# 19-ago-2026 · ANTES ERA `-lt 12`, UN SUELO ARBITRARIO QUE ACUSABA A
# TITULOS PERFECTAMENTE BUENOS: «Contacto» son 8 y «A propos» 8, y los
# dos son terminos reales, no marca. Lo que el check quiere decir es
# otra cosa, y ahora lo dice: el titulo no puede ser SOLO la marca.
# Se compara con la marca en vez de medir longitud, que era medir un
# sintoma parecido en vez del hecho.
core_min="$(echo "$core" | tr '[:upper:]' '[:lower:]')"
brand_min="$(echo "$BRAND" | tr '[:upper:]' '[:lower:]')"
if [ "$INDEXABLE" -eq 1 ] && { [ "${#core}" -lt 3 ] || [ "$core_min" = "$brand_min" ]; }; then
bad "S2.1 <title> sin termino, solo marca ('$t'): $p"; T_BRAND=1
fi
fi
fi
# --- description
d="$(sed -n 's/.*<meta name="description" content="\([^"]*\)".*/\1/p' "$f" | head -1)"
if [ -z "$d" ]; then bad "S2.2 sin meta description: $p"; D_MISS=1
else
# Se mide el texto DECODIFICADO: Google ve "&" y "—", no "&" ni
# "—". Contando el crudo se recortarian frases que caben.
# CUALQUIER entidad cuenta como UN caracter: & se ve como "&" y €
# como "EUR". Una lista escrita a mano siempre se queda corta -- esta no
# tenia € y acusaba de larga una description que cabia (20-ago-2026).
dd="$(echo "$d" | sed -e 's/&#\?[a-zA-Z0-9]{1,7};/X/g')"
n=${#dd}
if [ "$n" -gt 165 ]; then bad "S2.2 description de $n caracteres (Google corta ~155-160, se pierde el gancho): $p"; D_LONG=1
elif [ "$n" -lt 110 ]; then warn "S2.2 description corta ($n car., desaprovecha espacio): $p"; D_SHORT=1; fi
fi
# --- canonical
[ "$INDEXABLE" -eq 1 ] && { grep -q 'rel="canonical"' "$f" || { bad "S2.3 sin canonical: $p"; C_MISS=1; }; }
# --- Open Graph (solo indexables: una noindex no se comparte)
if [ "$INDEXABLE" -eq 1 ]; then
for og in 'og:title' 'og:description' 'og:url' 'og:image'; do
grep -q "property=\"$og\"" "$f" || { bad "S2.4 falta $og: $p"; OG_MISS=1; }
done
# Y que la imagen EXISTA, no solo que la etiqueta este. Comprobar la
# etiqueta y no el fichero es como se publica una pagina que al compartirse
# sale como un rectangulo gris: el HTML es correcto y el 404 es del asset.
ogimg="$(grep -o 'property="og:image" content="[^"]*"' "$f" | sed 's/.*content="//;s/"//' | head -1)"
if [ -n "$ogimg" ]; then
ogp="${ogimg#$BASE_URL/}"
case "$ogimg" in
"$BASE_URL"*) [ -f "$ROOT/$ogp" ] || { bad "S2.9 og:image declarada pero el fichero NO existe ($ogp): $p"; OG_MISS=1; } ;;
esac
fi
fi
# --- S2.10 · el zoom no se bloquea (regla AC-A15 del estandar) ------------
# `user-scalable=no` y `maximum-scale=1` impiden ampliar la pagina. Para quien
# no ve bien, eso no es una molestia: es no poder usar la web. Se medible en
# disco y hasta hoy no lo miraba NADIE -- una de las 45 reglas huerfanas.
grep -qE '<meta[^>]+name="viewport"[^>]+(user-scalable=no|maximum-scale=1(\.0)?[",])' "$f" \
&& { bad "S2.10 el zoom esta bloqueado (user-scalable=no o maximum-scale=1): $p"; ZOOM_BAD=1; }
# --- S2.11 · el telefono es un enlace tel: (regla TP-15) ------------------
# Un telefono escrito en texto y no enlazado obliga a copiarlo a mano en un
# movil, que es donde se llama. Solo se exige si la pagina lo ENSENA.
#
# 🔴 LA PRIMERA VERSION MIRABA EL FICHERO ENTERO Y ACUSO A 29 PAGINAS DE NORA.
# El numero estaba dentro del JSON-LD ("telephone":"+34..."), donde tiene
# que ser texto plano: un tel: ahi seria incorrecto. Se mira solo lo que el
# visitante VE -- fuera <script> y fuera <head>-, que es el mismo error de
# "tratar texto inlineado como si fuera marcado" que ya costo falsos
# positivos en S3.1, en el JSON-LD y en el mojibake.
VISIBLE="$(sed -e '/<script/,/<\/script>/d' -e '/<head/,/<\/head>/d' "$f" 2>/dev/null)"
if printf '%s' "$VISIBLE" | grep -qE '(\+34|\+32|\+351)[0-9 ]{6,}' && ! grep -q 'href="tel:' "$f"; then
bad "S2.11 ensena un telefono y no hay ningun href=\"tel:\": $p"; TEL_BAD=1
fi
# --- S2.12 · Article XOR TechArticle (regla WPS-25) -----------------------
# Declarar los dos @type en la misma pagina es decirle a Google dos cosas
# distintas sobre que es. Se elige uno.
if grep -q '"@type"[^}]*"Article"' "$f" && grep -q '"@type"[^}]*"TechArticle"' "$f"; then
bad "S2.12 declara Article Y TechArticle a la vez (elige uno): $p"; TIPO_BAD=1
fi
# --- un solo H1
nh1=$(grep -oE '<h1[ >]' "$f" | wc -l | tr -d ' ')
[ "$nh1" -eq 1 ] || { bad "S2.5 tiene $nh1 <h1> (debe ser exactamente 1): $p"; H1_BAD=1; }
# --- lang
grep -qE '<html[^>]+lang="' "$f" || { bad "S2.6 <html> sin lang: $p"; LANG_BAD=1; }
# --- entidades crudas dentro del JSON-LD: Google lee "¿Sois" literal
if grep -q 'application/ld+json' "$f"; then
# OJO con `sed -n '/A/,/B/p'`: NO busca el cierre en la misma linea que la
# apertura, asi que un JSON-LD de una sola linea hacia que el rango se
# comiera el resto del documento. Daba 14 falsos positivos: las entidades
# estaban en <p> del cuerpo, donde son CORRECTAS. Con awk se recorta el
# bloque de verdad, valga en una linea o en varias.
# Y pasa antes por strip_pre, como S3.1: una pagina que inlinea
# documentacion cita ejemplos de JSON-LD dentro de un <pre>, y el awk los
# tomaba por bloques reales. Mismo error que con los href y con el mojibake:
# tratar texto inlineado como si fuera marcado.
if strip_pre "$f" | awk '/application\/ld\+json/ { inld=1 }
inld {
l=$0
if (match(l, /application\/ld\+json"?>/)) l=substr(l, RSTART+RLENGTH)
if (match(l, /<\/script>/)) { print substr(l,1,RSTART-1); inld=0 } else print l
}' | grep -qE '&(amp|mdash|ndash|middot|rarr|larr|rsquo|lsquo|iquest|aacute|eacute|iacute|oacute|uacute|ntilde);'; then
bad "S2.7 entidades HTML dentro del JSON-LD (Google las lee literales): $p"; LD_ENT=1
fi
fi
done
[ "$T_MISS" -eq 0 ] && [ "$T_BRAND" -eq 0 ] && ok "S2.1 todas con <title> y con termino, no solo marca"
[ "${ZOOM_BAD:-0}" -eq 0 ] && ok "S2.10 ninguna bloquea el zoom"
[ "${TEL_BAD:-0}" -eq 0 ] && ok "S2.11 todo telefono visible es un enlace tel:"
[ "${TIPO_BAD:-0}" -eq 0 ] && ok "S2.12 ninguna declara Article y TechArticle a la vez"
[ "$D_MISS" -eq 0 ] && [ "$D_LONG" -eq 0 ] && ok "S2.2 todas con description dentro de limite"
[ "$C_MISS" -eq 0 ] && ok "S2.3 todas con canonical"
[ "$OG_MISS" -eq 0 ] && ok "S2.4 todas con Open Graph completo"
[ "$H1_BAD" -eq 0 ] && ok "S2.5 todas con exactamente un <h1>"
[ "$LANG_BAD" -eq 0 ] && ok "S2.6 todas con lang"
[ "$LD_ENT" -eq 0 ] && ok "S2.7 JSON-LD sin entidades crudas"
DUP="$(sort "$TITLES_FILE" | uniq -d)"
if [ -n "$DUP" ]; then
echo "$DUP" | while read -r x; do echo " [FAIL] S2.8 <title> duplicado: $x"; done
FAIL=$((FAIL+1))
else
ok "S2.8 ningun <title> duplicado"
fi
rm -f "$TITLES_FILE"
# =============================================================================
sec "S3 · GRAFO DE ENLACES"
# =============================================================================
BROKEN=0
for p in $PAGES; do
d="$(dirname "$ROOT/$p")"
# while-read y no `for`: un href puede contener espacios (el favicon es un
# data: URI con SVG dentro) y el for lo partia en trozos, generando 17 falsos
# positivos por pagina. data:, javascript: y las absolutas se descartan antes.
# ⚠️ `cmd | while read` corre el bucle en una SUBSHELL: los `bad` de dentro
# imprimian pero NO incrementaban el contador del padre, y BROKEN volvia a 0
# al salir. Resultado: 21 fallos impresos, "FAIL: 2" en el resumen y un
# "[ ok ] cero enlaces rotos" JUSTO DEBAJO de los 21. Un resumen que
# contradice lo que acaba de imprimir es peor que no tener resumen.
# Con `done < <(...)` el bucle corre en el shell padre y las cuentas valen.
while IFS= read -r h; do
tgt="${h%%#*}"; tgt="${tgt%%\?*}"
[ -z "$tgt" ] && continue
# Tercer sitio donde vivia la regla de resolucion, y el unico que quedaba:
# probaba "$d/$tgt" y, si fallaba, "$ROOT$tgt", ninguno de los dos con la
# convencion de fichero plano. Por eso kine daba 310 "enlaces rotos" hacia
# paginas que existen (/a-propos -> a-propos.html). Ahora despacha por si la
# ruta es absoluta o relativa y deja la CONVENCION a resolver_ruta, que es
# el unico sitio donde esa regla esta escrita.
case "$tgt" in
/*) cand="$(resolver_ruta "$ROOT$tgt")" ;;
*) cand="$(resolver_ruta "$d/$tgt")" ;;
esac
[ -e "$cand" ] || { bad "S3.1 enlace roto en $p -> $h"; BROKEN=1; }
done < <(strip_pre "$ROOT/$p" | grep -oE 'href="[^"#?][^"]*"' | sed 's/href="//;s/"$//' \
| grep -vE '^(https?:|mailto:|tel:|data:|javascript:|//|#)' | sort -u)
done
[ "$BROKEN" -eq 0 ] && ok "S3.1 cero enlaces internos rotos"
# S3.2 huerfanas: indexables sin ni un enlace entrante desde el cuerpo de otra pagina.
# Una pagina que solo cuelga del nav no tiene senal interna; y una sin NADA es
# invisible salvo por el sitemap.
# Se RESUELVE cada enlace relativo a su pagina de origen antes de comparar.
# ⚠️ La primera version comparaba la ruta completa ("agents/x/") contra el href
# tal cual, asi que solo veia los enlaces escritos desde la raiz. Un enlace
# vecino (href="x/") o hacia arriba (href="../x/") no contaba, y daba huerfana
# una pagina con dos enlaces entrantes reales. Peor todavia: en sentido
# contrario CALLABA, porque bastaba un enlace desde la home para dar por buena
# cualquier pagina. Un check de huerfanas que no resuelve rutas no sirve.
LINKMAP="$(mktemp 2>/dev/null || echo "$ROOT/.audit-links.tmp")"
: > "$LINKMAP"
for p in $PAGES; do
d="$(dirname "$ROOT/$p")"
strip_pre "$ROOT/$p" | grep -ohE 'href="[^"#?][^"]*"' | sed 's/href="//;s/"$//' \
| grep -vE '^(https?:|mailto:|tel:|data:|javascript:|//|#)' \
| while IFS= read -r h; do
tgt="${h%%#*}"; tgt="${tgt%%\?*}"
[ -z "$tgt" ] && continue
case "$tgt" in
/*) cand="$ROOT$tgt" ;;
*) cand="$d/$tgt" ;;
esac
# ⚠ Aqui solo se resolvia "algo/" -> "algo/index.html". Con URLs SIN
# barra final -la forma que este cliente ya tiene indexada- "/contacto"
# quedaba como directorio y nadie contaba su enlace entrante: 40
# huerfanas en un sitio bien enlazado. Se aceptan LAS DOS.
# La misma regla que el sitemap, y por eso la misma funcion: aqui vivia
# duplicada -solo la mitad, la de la barra final- y por eso el grafo de
# enlaces de kine daba 310 rotos que no lo estaban.
cand="$(resolver_ruta "$cand")"
r="$(realpath -m --relative-to="$ROOT" "$cand" 2>/dev/null)"
[ -n "$r" ] && echo "$r|$p"
done >> "$LINKMAP"
done
ORPH=0
for p in $PAGES; do
grep -q 'name="robots"[^>]*noindex' "$ROOT/$p" && continue
[ "$p" = "index.html" ] && continue
n=$(grep -c "^$p|" "$LINKMAP" 2>/dev/null)
self=$(grep -c "^$p|$p$" "$LINKMAP" 2>/dev/null)
n=$((n - self))
if [ "$n" -lt 1 ]; then bad "S3.2 huerfana (0 enlaces entrantes, indexable): $p"; ORPH=1
elif [ "$n" -lt 2 ]; then warn "S3.2 solo $n enlace entrante (el PASO 0 pide 2): $p"; fi
done
rm -f "$LINKMAP"
[ "$ORPH" -eq 0 ] && ok "S3.2 ninguna pagina indexable esta huerfana"
# =============================================================================
sec "S4 · INTEGRIDAD Y CODIFICACION"
# =============================================================================
# Se escanea SOLO lo que se despliega. Los documentos del repo (CLAUDE.md y
# companyia) quedan fuera a proposito: uno de ellos DOCUMENTA el patron de
# mojibake, y se marcaba a si mismo como corrupto. Un auditor que da falsos
# positivos se acaba ignorando, que es peor que no tenerlo.
DEPLOYED="$PAGES"
for f in styles.css components.css script.js llms.txt robots.txt sitemap.xml AGENTS.md; do
[ -f "$ROOT/$f" ] && DEPLOYED="$DEPLOYED
$f"
done
# Las paginas /copy/ quedan fuera: inlinean codigo fuente verbatim y pueden
# contener el propio patron de mojibake (audit.sh lo lleva dentro para
# detectarlo). Un fichero que DOCUMENTA el patron se marca a si mismo.
MOJI=""
for f in $DEPLOYED; do
case "$f" in */copy/index.html) continue ;; esac
# 19-ago-2026 · LA LISTA ERA DE 8 SECUENCIAS Y SE DEJABA FUERA LA MITAD DE
# LAS VOCALES. Tenia `é` (e) y `ñ` (n) pero NO `Ã` (i) ni `á` `ó` `ú`,
# asi que el mojibake de «clinica» y «politica» -las dos palabras mas
# frecuentes de nuestras webs de cliente- pasaba en VERDE. Lo encontro el
# banco de pruebas el dia que se escribio, no una web rota.
# NO se usa un patron ancho tipo `Ã.`: en portugues `Ã` mayuscula es LEGITIMA
# (IRMAOS, ORGAOS) y marcaria a loja.mobanho.com entera. La lista explicita
# cubre es/fr/pt y no tiene falsos positivos; hay un caso verde que lo prueba.
grep -qE 'á|é|Ã|ó|ú|ñ|ç|ã|õ|â|ê|ô|à |è|Â|â€|€|»|«|�' "$ROOT/$f" 2>/dev/null && MOJI="$MOJI $f"
done
[ -n "$MOJI" ] && bad "S4.1 mojibake en:$MOJI" || ok "S4.1 sin mojibake en lo desplegable"
# 🔴 18-ago-2026 · SE CONTABAN LAS ETIQUETAS DENTRO DE LOS COMENTARIOS.
# Un comentario HTML que EXPLICA la regla hacia fallar a la pagina que la
# cumplia: 3 abren, 2 cierran, y la tercera estaba en prosa dentro de un
# comentario. Es la trampa §43 en este fichero: cuanto mejor documentas un
# check, mas facil es que se acuse a si mismo. Se quitan los comentarios
# antes de contar, igual que hacen qa-maestro.pl y audit-vs-spec.pl.
UNB=0
for p in $PAGES; do
sincom=$(perl -0777 -pe 's/<!--.*?-->//gs' "$ROOT/$p")
o=$(printf '%s' "$sincom" | grep -oE '<section' | wc -l | tr -d ' ')
c=$(printf '%s' "$sincom" | grep -oE '</section>' | wc -l | tr -d ' ')
[ "$o" = "$c" ] || { bad "S4.2 <section> descuadrado en $p ($o abren, $c cierran)"; UNB=1; }
done
[ "$UNB" -eq 0 ] && ok "S4.2 <section> equilibrado en todas"
# =============================================================================
sec "S5 · CSS Y RENDIMIENTO"
# =============================================================================
CSSMISS=0
for p in $PAGES; do
grep -q 'rel="stylesheet"' "$ROOT/$p" || { bad "S5.1 sin hoja de estilos (saldria SIN ESTILO): $p"; CSSMISS=1; continue; }
for href in $(grep -oE '<link rel="stylesheet" href="[^"]+"' "$ROOT/$p" | sed 's/.*href="//;s/"//'); do
case "$href" in http*|//*) continue ;; esac
# Dos convenciones VALIDAS, no una:
# ../styles.css -> relativa a la pagina (nuestro sitio)
# /styles.css -> relativa a la RAIZ (sitios servidos desde su dominio)
# La primera version solo entendia la relativa y marcaba 81 fallos en un
# sitio correcto. El auditor traia un supuesto que nunca declaro.
# TERCERA convencion, y la que faltaba: el SELLO DE CACHE. 07-trampas.md §15
# obliga a servir el CSS y el JS con `?v=...` —si no, el navegador sirve la
# hoja vieja y un getComputedStyle miente—. Este auditor resolvia el href
# entero contra el disco, asi que `styles.css?v=7` no existia como fichero y
# marcaba FAIL. En el sitio de prueba del 18-ago-2026 salieron 12 FAIL, los
# 12 falsos: la skill exigia el sello y su propio auditor lo castigaba.
# No se habia visto nunca porque ninguno de los 5 repos sellaba su CSS.
href="${href%%[?]*}"
href="${href%%[#]*}"
case "$href" in
/*) t="$ROOT$href" ;;
*) t="$(dirname "$ROOT/$p")/$href" ;;
esac
[ -f "$t" ] || { bad "S5.1 hoja de estilos con prefijo mal (no resuelve): $p -> $href"; CSSMISS=1; }
done
done
[ "$CSSMISS" -eq 0 ] && ok "S5.1 todas cargan sus hojas de estilo y resuelven"
# S5.2 solo transform y opacity se componen en GPU. Animar box-shadow, width,
# height, top/left o colores repinta en el hilo principal sin parar.
NOCOMP=0
for css in $(find "$ROOT" -maxdepth 2 -name '*.css' 2>/dev/null | grep -vE "/($(echo $EXCLUDE_DIRS | tr ' ' '|'))/"); do
# La propiedad puede ir tras "0% {" en la misma linea, no solo al principio.
# Anclado a ^ se escapaba `0% { box-shadow: ... }` -- que es justo como se
# escriben los keyframes cortos, y como estaba el fallo real de components.css.
hits=$(awk '/@keyframes/,/^}/' "$css" | grep -nE '(^|[{;[:space:]])(box-shadow|width|height|top|left|right|bottom|margin|padding|background-color|filter)[[:space:]]*:' | head -5)
[ -n "$hits" ] && { bad "S5.2 @keyframes anima propiedades no compuestas en $(basename $css): $(echo "$hits" | tr '\n' ' ' | cut -c1-120)"; NOCOMP=1; }
done
[ "$NOCOMP" -eq 0 ] && ok "S5.2 las animaciones solo tocan transform/opacity"
# S5.3 toda animacion necesita su salida de reduced-motion.
RM=0
for css in $(find "$ROOT" -maxdepth 2 -name '*.css' 2>/dev/null | grep -vE "/($(echo $EXCLUDE_DIRS | tr ' ' '|'))/"); do
nk=$(grep -c '@keyframes' "$css")
[ "$nk" -eq 0 ] && continue
if grep -q 'prefers-reduced-motion' "$css"; then
ok "S5.3 $(basename $css) tiene salida de prefers-reduced-motion ($nk animaciones)"
else
bad "S5.3 $(basename $css) define $nk @keyframes y NO tiene bloque prefers-reduced-motion"; RM=1
fi
done
[ "$RM" -eq 0 ] || true
# S5.4 CSS muerto: clases definidas y usadas en CERO paginas.
# Anadido el 2-ago tras encontrar 20 reglas huerfanas de un directorio borrado
# semanas antes. CSS muerto no falla nunca: solo pesa, y sobre todo confunde a
# quien lo lea despues -- describe una estructura que ya no existe.
# Es `warn` y no `bad`: hay clases legitimas que solo aparecen desde JS.
# Se construye UNA sola vez el inventario de clases usadas, en lugar de hacer
# un grep por clase y pagina (eran ~7.800 greps y tardaba minutos).
USEDCLS="$(for p in $PAGES; do grep -ohE 'class="[^"]*"' "$ROOT/$p"; done \
| sed 's/class="//;s/"$//' | tr ' ' '\n' | sort -u)"
QQ="\"'"
JSCLS="$(cat "$ROOT"/*.js 2>/dev/null | grep -ohE "[$QQ][a-zA-Z][a-zA-Z0-9_-]*[$QQ]" | tr -d "$QQ" | sort -u)"
DEADCSS=""; NDEAD=0
for cls in $(find "$ROOT" -maxdepth 1 -name '*.css' -exec grep -ohE '^\.[a-zA-Z][a-zA-Z0-9_-]*' {} \; | sed 's/^\.//' | sort -u); do
echo "$USEDCLS" | grep -qx "$cls" && continue
echo "$JSCLS" | grep -qx "$cls" && continue # la anade el JS en runtime
# Y puede estar DORMIDA, no muerta: el generador la emite solo para ciertos
# specs (p.ej. un aviso que sale unicamente si el item tiene ese campo).
# Sin esta linea el check pide borrar CSS que hace falta -- el mismo error de
# barrido que ya ha costado tres diagnosticos falsos en este proyecto.
grep -qs "\b$cls\b" "$ROOT"/*.ps1 "$ROOT"/*.py "$ROOT"/*.sh 2>/dev/null && continue
NDEAD=$((NDEAD+1)); DEADCSS="$DEADCSS .$cls"
done
if [ "$NDEAD" -gt 0 ]; then
warn "S5.4 $NDEAD clase(s) definidas y sin uso en ninguna pagina:$(echo $DEADCSS | cut -c1-160)"
else
ok "S5.4 sin CSS muerto"
fi
# =============================================================================
if [ "$LIVE" -eq 1 ]; then
sec "S6 · ENTREGA EN PRODUCCION"
if [ -z "$BASE_URL" ]; then
skip "S6 sin --url ni CONF_BASE_URL: no se puede comprobar produccion"
else
# S6.0 · Disco vs produccion. Se separa A PROPOSITO de S6.1.
# ⚠️ Antes S6.1 recorria el sitemap de DISCO contra produccion, asi que una
# pagina legitimamente en staging ponia --live en ROJO. Un gate que se pone
# rojo por trabajo pendiente de desplegar ensena a ignorarlo, que es el peor
# resultado posible para un gate. Ahora:
# S6.0 = que hay en disco y aun no en produccion -> aviso, no fallo
# S6.1 = que sirve produccion de verdad -> fallo si algo rompe
SM_LIVE="$(curl -s "$BASE_URL/sitemap.xml" | grep -oE '<loc>[^<]+' | sed 's/<loc>//')"
PEND=""
for u in $SM_URLS; do echo "$SM_LIVE" | grep -qx "$u" || PEND="$PEND $u"; done
if [ -n "$PEND" ]; then
warn "S6.0 en disco y aun NO en produccion (pendiente de desplegar):$PEND"
else
ok "S6.0 disco y produccion coinciden: nada pendiente de desplegar"
fi
L200=0; NL=0
for u in $SM_LIVE; do
NL=$((NL+1))
c=$(curl -s -o /dev/null -w "%{http_code}" "$u")
[ "$c" = "200" ] || { bad "S6.1 $c en $u"; L200=1; }
done
[ "$L200" -eq 0 ] && ok "S6.1 las $NL URLs del sitemap EN VIVO responden 200"
# Hay que PEDIR la compresion o el servidor no la manda, y el aviso saldria
# siempre aunque este bien configurada.
H="$(curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip, br' "$BASE_URL/")"
echo "$H" | grep -qi '^cache-control:' && ok "S6.2 el HTML envia Cache-Control" || bad "S6.2 el HTML NO envia Cache-Control (cada visita recurrente revalida todo)"
echo "$H" | grep -qi '^content-encoding:' && ok "S6.3 compresion activa" || warn "S6.3 sin Content-Encoding (comprueba que el server comprime)"
for hh in x-content-type-options referrer-policy; do
echo "$H" | grep -qi "^$hh:" && ok "S6.4 cabecera $hh presente" || warn "S6.4 falta la cabecera $hh"
done
CSSU="$(grep -oE '<link rel="stylesheet" href="[^"]+"' "$ROOT/index.html" | head -1 | sed 's/.*href="//;s/"//')"
if [ -n "$CSSU" ]; then
HC="$(curl -s -D - -o /dev/null "$BASE_URL/$CSSU")"
echo "$HC" | grep -qi '^cache-control:' && ok "S6.5 los assets envian Cache-Control" || bad "S6.5 los assets NO envian Cache-Control"
fi
for MF in llms.txt AGENTS.md; do
curl -s -f -o /dev/null "$BASE_URL/$MF" 2>/dev/null || { warn "S6.6 $BASE_URL/$MF no responde (¿sin desplegar?)"; continue; }
LM=0
for u in $(curl -s "$BASE_URL/$MF" | grep -oE "$BASE_URL[^ )\"<>*\`]+" | sed 's/[.,:]$//' | sort -u); do
c=$(curl -s -o /dev/null -w "%{http_code}" "$u")
[ "$c" = "200" ] || { bad "S6.6 $MF EN VIVO apunta a $c: $u"; LM=1; }
done
[ "$LM" -eq 0 ] && ok "S6.6 todas las URLs de $MF en vivo responden 200"
done
fi
else
sec "S6 · ENTREGA EN PRODUCCION"
skip "S6 no ejecutado (pasa --live para comprobar cache, cabeceras y URLs en vivo)"
fi
# =============================================================================
echo
echo "==========================================================="
echo " FAIL: $FAIL · warn: $WARN"
if [ "$FAIL" -eq 0 ]; then
echo " == VERDE: el sitio pasa la auditoria =="
echo "==========================================================="
exit 0
else
echo " == ROJO: $FAIL fallo(s). No desplegar sin arreglarlos. =="
echo "==========================================================="
exit 1
fi