---
name: "revision-qa-sitios"
description: Revisión QA de un sitio antes de entregarlo o mostrárselo al cliente — contenido y datos, funcional, SEO, rendimiento y responsive — verificando cada dato contra las fuentes del cliente (sitio actual, landings, brochures) y entregando un reporte donde cada hallazgo trae la URL donde se ve. Úsalo siempre que se pida revisar, auditar, "echarle un ojo", dar feedback, hacer QA o sacar pendientes de un sitio, un deploy de preview (vercel.app, netlify, staging) o un rediseño, aunque la petición sea tan corta como "revisa este sitio" o "mira si ya está listo para el cliente". También para comparar un sitio nuevo contra el que va a reemplazar. Funciona en Cowork, Claude Code y opencode.
---

# Revisión QA de sitios antes de entregar

El trabajo no es listar defectos: es contestar "¿esto ya se le puede enseñar al cliente?" y dejar por escrito
todo lo que impediría decir que sí, en un reporte que el equipo pueda trabajar.

Dos cosas separan una revisión útil de una lista de quejas.

**Verificar contra la fuente, no contra el buen gusto.** Un teléfono, un precio o un número de unidades no
están mal porque se vean raros: están mal porque no coinciden con lo que el cliente publica. Y a veces lo que
parece error es decisión suya. Un botón de WhatsApp con lada de Estados Unidos parece un bug hasta que ves
que el sitio actual usa el mismo número para sus campañas de Google. Reportarlo como error quema
credibilidad; reportarlo como "confírmalo antes de cambiarlo" la construye.

**Separar lo que rompe la demo de lo que se puede vivir.** Un departamento que dice $4,321.99 no es del mismo
tamaño que un doble espacio. Si todo va en la misma lista, el equipo arregla lo barato y deja lo caro.

El reporte va siempre en español, aunque el sitio esté en inglés: lo lee el equipo.

**Alcance frente a otros skills.** Este skill cubre **contenido y datos** (lo que dice el sitio y si coincide con
las fuentes del cliente). La auditoría técnica a fondo — crawl completo, Lighthouse, accesibilidad — va a
`ai-site-review`; el barrido de la Fase 2 sólo junta evidencia ligera para apoyar la revisión de contenido y no
la sustituye.

## Antes de empezar: con qué cuentas

Este skill corre en Cowork, en Claude Code y en opencode, y lo que cambia entre ellos son dos cosas: con qué
manejas el navegador y dónde entregas el reporte. Decídelas al principio, en silencio, y menciona en una
línea lo que elegiste sólo si limita algo.

**Navegador** — no lo elijas por preferencia: elígelo por dónde corre tu shell.

- **Hay panel del navegador de Claude** (`Claude_Browser__*`, o `mcp__remote-devices__Claude_Browser__*` cuando
  la sesión está ligada a la computadora de la persona): estás en la app de Claude y ése es el motor. Úsalo
  directo, sin probar la shell primero. El panel corre en la máquina de la persona y sí alcanza el sitio,
  mientras que la shell del contenedor tiene la salida cerrada: el proxy de egreso contesta 403 hasta para
  dominios comunes y sólo deja pasar registros de paquetes. Abre la página con `preview_start`, pega el cuerpo
  de `AUDIT` en `javascript_tool` y repite en una página por plantilla; `read_console_messages` y
  `resize_window` cubren consola y móvil. La shell te sigue sirviendo para procesar los JSON y escribir el
  reporte, nada más.
- **Hay shell en la máquina de la persona** (Claude Code, opencode): el runner de Playwright de más abajo.
  Recorre el sitio solo, audita cada página y es el único motor que deja capturas en disco, así que es el que
  vuelve posibles las imágenes del reporte.
- **Hay Chrome DevTools MCP** (`navigate_page`, `evaluate_script`, `take_screenshot`, `list_console_messages`,
  `list_network_requests`) **o Claude in Chrome**: pasa `AUDIT` a `evaluate_script` o a `javascript_tool`
  página por página. Con DevTools MCP además tienes capturas.
- **Sólo `WebFetch` o equivalente**: alcanza para leer textos y compararlos contra la fuente, pero no para
  `href`, consola, filtros ni capturas. Dilo antes de empezar en vez de entregar media revisión sin avisar.

Si tienes shell de contenedor y crees que podría salir, un `curl -o /dev/null -w "%{http_code}"` al sitio lo
resuelve en una llamada: 000 o 403 quiere decir que no, y entonces el navegador de la app es el camino.

**Reporte** — si hay Claude Docs (Cowork), un documento. Si no (Claude Code, opencode), un archivo
`revision-qa-<cliente>-<aaaa-mm-dd>.md` en el directorio de trabajo, con las capturas en una carpeta al lado
y referenciadas con rutas relativas. Mismas secciones en los dos casos.

## Fase 0 — El encargo y las fuentes de verdad

Antes de abrir el sitio, pregunta todo junto (con AskUserQuestion si está disponible; si no, en una lista):

1. **¿Hay sitio actual de donde salió el contenido?** Es la pregunta más importante: sin fuente sólo puedes
   detectar cosas mal escritas, no datos equivocados. Si no te lo dan, búscalo — el nombre del cliente en el
   buscador, el dominio sin el subdominio de preview, lo que diga el footer — y **confírmalo antes de usarlo**;
   no des por hecho que el primer resultado es del mismo cliente.
2. **¿Qué otras fuentes hay?** Landings por producto, brochures o PDFs, hoja de precios, catálogo, el CMS,
   un documento de contenidos aprobado. Cada una sirve para cruzar un tipo distinto de dato.
3. **¿Ya hubo comentarios antes?** Pídelos: la primera sección del reporte es qué de eso sigue pendiente,
   y eso solo ya justifica la revisión.
4. **¿Qué está fuera de alcance?** Secciones a medias, contenido que el cliente no ha mandado, un módulo que
   se sabe roto. Si no preguntas, vas a reportar como bug algo que el equipo ya tiene en su lista.
5. **¿A quién se le pregunta lo que dependa del cliente?** Para cerrar el reporte con preguntas dirigidas
   y no con dudas al aire.

Si trabajas sin nadie del otro lado, asume lo razonable, escríbelo al principio del reporte y sigue.

**Fallback no interactivo (no hay cliente ni nadie que conteste):** no preguntes y no te detengas. Las fuentes de
verdad pasan a ser **el sitio en vivo** (el dominio de producción, sin el subdominio de preview) y **el sitio de
la empresa** (el que declara el footer o el que resulta de buscar el nombre del cliente), y tal cual lo que
verificaste contra ellos. Escribe en "Alcance", como suposiciones, qué URL tomaste como en vivo, cuál como sitio
de la empresa, por qué las diste por buenas, qué fuentes que normalmente pedirías (brochures, hoja de precios,
comentarios previos) no estuvieron, y qué quedó fuera de alcance por no poder confirmarlo. Un dato que sólo
existe en el sitio nuevo y en ninguna de esas dos fuentes va como "sin verificar", nunca como bueno.

## Fase 1 — Reconocimiento

Abre el sitio y entiende **qué tipo de sitio es**, porque eso define qué puede fallar. Un catálogo
inmobiliario tiene filtros, mapa y fichas; un despacho tiene perfiles, publicaciones y un formulario; una
tienda tiene carrito y pagos. Revisar un despacho buscando filtros es perder el tiempo.

Levanta el mapa del sitio con `sitemap.xml` si existe, si no con el menú y el footer. Anota qué páginas salen
de la misma plantilla: un error ahí se multiplica, y basta revisar dos o tres para saber si el patrón falla.

## Fase 2 — Barrido técnico

Con shell: guarda el runner en `qa-audit.mjs`, `npm i -D playwright && npx playwright install chromium`, y

```
node qa-audit.mjs --url https://sitio.com --max-pages 12 --out ./qa-<cliente>
```

Si `npx playwright install chromium` no puede bajar el navegador (sin red, proxy, disco) o el binario incluido
no abre, el runner reintenta solo con el Chrome instalado en el sistema (`chromium.launch({ channel: 'chrome' })`);
si lo corres a mano o lo portas a otro script, usa esa misma opción. Sin ninguno de los dos, no hay runner: pasa
al evaluador de JavaScript del navegador que tengas y dilo en el alcance.

Deja `qa-<cliente>/resumen.json` con los candidatos a hallazgo, un JSON por página y capturas de escritorio
y móvil. Si el sitio es grande, pasa `--paths /,/listado,/ficha-ejemplo,/contacto` con una página por
plantilla en lugar de recorrerlo entero: revisar cuarenta páginas de la misma plantilla no agrega nada.

Con el navegador de la app o cualquier otro motor sin shell útil: copia el cuerpo de la función `AUDIT` al
evaluador de JavaScript y córrelo en una página por plantilla. Devuelve exactamente la misma estructura; la
interpretación de abajo aplica igual, sólo que la haces tú en lugar del script.

Lo que el barrido saca y hay que leer con criterio:

- **Teléfonos**: compara el `href` contra lo que el usuario lee. Un `+5255…` sin el 52 marca a Brasil; un
  `tel:` con menos dígitos de los que muestra simplemente no marca.
- **WhatsApp con lada rara**: se pregunta, no se corrige.
- **Imágenes**: una de 3080 px sirviéndose en 750 px. Da el número — "25 imágenes, 6.4 MB" mueve al equipo,
  "las imágenes pesan" no. Si ninguna imagen de una galería es clickeable, no hay lightbox. Al contar
  **imágenes sin `alt`**, no cuentes los duplicados `aria-hidden` (los clones que un carrusel o marquesina crea
  para el bucle infinito) ni los pósters decorativos (placeholders de video, fondos sin contenido): no los lee
  ningún lector de pantalla y inflan el número. El barrido los excluye y los reporta aparte como `decorativas`; si
  cuentas a mano, haz lo mismo y di cuántas omitiste. Una imagen que sí comunica algo y no tiene `alt` se
  reporta aunque esté dentro de un carrusel.
- **`title` vacío, duplicado o con restos de plantilla** ("... | Marca"), páginas sin `h1`, sin canonical.
- **Formularios sin casilla de privacidad** y selects sin etiqueta.
- **`datos`**: lo que el front trae serializado en el DOM. Vale oro, porque ahí ves la data real y no la
  renderizada: coordenadas redondeadas a dos decimales, un slug que existe aunque el enlace salga vacío,
  elementos que se pintan aunque el filtro activo no los incluya.
- **Scroll horizontal**: mide el `scrollWidth` del documento (`document.documentElement.scrollWidth` contra
  `clientWidth`) **después de que la página se asiente** (animaciones de entrada, fuentes y lazy-load
  terminados), no a la primera. Ignora los carruseles y marquesinas que se salen de la ventana pero quedan
  recortados por un ancestro con `overflow: hidden`: no generan scroll y no son un defecto. Un elemento cuyo
  rectángulo pasa del ancho de la ventana sólo cuenta si el documento realmente se desplaza; si hay scroll,
  busca entonces qué elemento lo causa.
- **Pistas de texto**: son sospechas, no veredictos. Léelas antes de reportarlas.

La consola merece cuidado aparte: léela en una pestaña recién abierta, porque el búfer conserva los mensajes
de navegaciones anteriores y de tus propias pruebas, y es facilísimo reportar como error del sitio un 404 que
provocaste tú al probar una URL inventada. Si un aviso sólo aparece al abrir un componente, dilo así: acota
mucho más que "hay errores de consola".

## Fase 3 — Interacción

Lo que no se ve sin usar el sitio, y que el runner no hace por ti. Prueba cada componente con **un caso que
deba fallar**, no sólo con uno que funcione: filtra por algo que ningún producto tenga y confirma que no
devuelve resultados y que hay mensaje de vacío. Ahí se cae casi siempre.

- **Filtros y buscadores**: cada filtro por separado, uno sin resultados, y revisa si las opciones del
  desplegable corresponden a lo que se muestra. Un filtro que ofrece "6 baños" porque el catálogo incluye una
  oficina, mientras los resultados sólo filtran departamentos, es el bug de fondo.
- **Mapas**: centro y zoom, idioma de las etiquetas, precisión de las coordenadas (dos decimales son un
  kilómetro de error) y si los pines respetan la pestaña o el filtro activo. Abre un popup y prueba su liga.
- **Formularios**: **nunca los envíes** — generas leads falsos en el CRM del cliente. Deja escrito que el
  envío quedó sin probar y quién debería probarlo.
- **Carruseles, galerías, acordeones y pestañas**: si las fotos deberían abrir en grande, si los slides son
  contenido real o cinco copias del mismo placeholder, si alguna pestaña quedó vacía.
- **Estados vacíos y de error**: la 404, un filtro sin resultados, una ficha sin contenido cargado.
- **Móvil**: 375 px, recargando, porque muchas cosas se deciden al cargar. Da unos segundos a las animaciones
  de entrada antes de juzgar: una captura tomada demasiado pronto parece una sección en blanco. Para el scroll
  horizontal aplica lo de la Fase 2: `scrollWidth` del documento ya asentado, sin contar lo que un
  `overflow: hidden` recorta (carruseles, marquesinas); si hay scroll real, entonces identifica el elemento.

## Fase 4 — Contraste contra las fuentes

Aquí la revisión deja de ser cosmética. Toma cada dato duro del sitio — precios, teléfonos, correos,
direcciones, superficies, número de unidades, horarios, nombres, fechas — y búscalo en las fuentes de la
Fase 0. Tres resultados, y conviene decir cuál es cada uno:

- **Coincide**: no se reporta.
- **No coincide**: reporta los dos valores y de dónde salió cada uno. Si puedes, di cuál es el bueno.
- **No existe en ninguna fuente**: márcalo "sin verificar" y conviértelo en pregunta al cliente. Nunca lo
  inventes ni lo des por bueno.

Cuando encuentres una diferencia, busca **por qué** pasó: la causa suele explicar varios errores a la vez y
vuelve el arreglo uno solo. Un precio de "$4,321.99" en una lista de millones se entiende cuando ves que la
fuente escribe "$ 4.321.988" con punto de miles — alguien lo leyó como decimal, y entonces hay que revisar
todos los precios, no ese. Una descripción que dice "23 unidades" en dos desarrollos distintos se entiende
cuando ves que una se copió de la otra.

Revisa también qué había en el sitio actual que no está en el nuevo: secciones completas, PDFs, vistas 360°,
promociones vigentes. No es tu decisión si se quedan fuera, pero mejor que lo preguntemos nosotros antes de
que lo pregunte el cliente. Y si el sitio viejo tiene URLs que la gente ya usa, menciona las redirecciones.

### Modo rediseño

Cuando el sitio nuevo **reemplaza uno que está en vivo**, la pregunta es "¿qué se perdió y qué se agregó?", y el
contraste se entrega en dos tablas, no en la lista suelta de arriba. Una fila por ítem (sección, página, PDF,
dato, formulario, enlace, evento de contacto, promoción), con las ligas en las celdas (la del sitio vivo y la del
nuevo) para que quien lea pueda abrir las dos.

**Perdido vs live** — lo que el sitio vivo tiene y el nuevo no conserva igual:

```
| Ítem | Live | Nuevo | Estado |
| --- | --- | --- | --- |
| Teléfono de ventas | [Contacto](https://viejo.com/contacto): 55 1234 5678 | [Contacto](https://nuevo.vercel.app/contacto): 55 1234 5679 | cambió |
| Folleto de precios (PDF) | [Descargas](https://viejo.com/descargas) | — | quitado |
```

**Agregado vs live** — lo que el nuevo trae y el vivo no tenía:

```
| Ítem | Live | Nuevo | Estado |
| --- | --- | --- | --- |
| Sección "Preguntas frecuentes" | — | [FAQ](https://nuevo.vercel.app/faq) | agregado |
| Mapa de sucursales | Imagen estática | [Contacto](https://nuevo.vercel.app/contacto): mapa interactivo | cambió |
```

Estado, siempre uno de estos cinco: **igual** (se verificó y coincide; sólo se lista si el ítem es crítico, como
el teléfono o el formulario), **cambió**, **quitado**, **agregado**, **sin verificar** (no pudiste comprobarlo
en una de las dos versiones; nunca lo des por igual). Cada fila "cambió" o "quitado" lleva el valor de cada lado.

Después de las dos tablas va **"Por confirmar"**, que reúne todo lo que depende del cliente (cada "sin verificar",
cada "quitado" cuya omisión parezca deliberada, cada "agregado" que nadie pidió) y **se ordena por prioridad**:
primero lo que impide publicar o hace perder contacto, ventas o posicionamiento (teléfonos, formularios, URLs
que dejan de existir sin redirección, indexación); luego lo que el cliente notaría de inmediato (secciones,
descargables, textos legales); al final lo cosmético. Cada pregunta con a quién se le hace.

## Fase 5 — El reporte

Mismas secciones en documento o en Markdown, en este orden:

1. **Alcance** — qué build se revisó (con liga), qué páginas, en qué anchos, qué fuentes se usaron y qué
   quedó fuera. Dos párrafos. Si hubo que asumir algo porque nadie contestó, va aquí.
2. **Pendientes de la ronda anterior** — sólo si la hubo, con casillas.
3. **Bloqueantes** — lo que no debería verse en una demo, partido en dos o tres secciones por tema (datos y
   precios, enlaces y contacto, un módulo roto): una sola lista de quince cosas no se lee.
4. **Por módulo** — buscador y filtros, mapa, formularios, galerías: lo que el sitio tenga. No inventes
   secciones para rellenar.
5. **Contenido y copy** — tabla de *dónde · dice · debe decir*, que es lo más rápido de corregir.
6. **SEO y publicación** — títulos, canonical, robots, sitemap, dominios de assets, redirecciones.
7. **Rendimiento y responsive** — con números medidos.
8. **Datos que no cuadran con las fuentes** — tabla de *dato · sitio nuevo · fuente*, y debajo la causa. En
   modo rediseño, esta sección lleva las dos tablas "Perdido vs live" y "Agregado vs live" (Fase 4) además de
   ésta.
9. **Por confirmar y lo que falta probar** — casillas, separando preguntas para el cliente de pendientes
   internos, y al final lo que no se probó con la razón. En modo rediseño, "Por confirmar" va ordenado por
   prioridad (Fase 4).
10. **Fuentes** — las ligas que se usaron para verificar.

### Cada hallazgo lleva su URL

Es lo que vuelve el reporte accionable: quien lo lee abre la liga y ve el problema sin preguntarte dónde
estaba. Si está en todas las páginas (header, footer), dilo así. Si es un elemento concreto, agrega la cita
textual. Cuando hay varios del mismo tipo, una tabla gana a una lista:

```
| Dónde | Dice | Debe decir |
| --- | --- | --- |
| [Ficha de producto](https://sitio.com/productos/x) | Conoces más | Conoce más |
| Footer (todas las páginas) | Alma Tepeyac:55-9337-2259 | Con espacio después de los dos puntos |
```

Cuando es uno solo y necesita explicación, un párrafo con la liga dentro y el grado de certeza al final:
"está mal" y "hay que confirmarlo" son cosas distintas, y el equipo necesita saber cuál es cuál.

### Capturas

Sirven cuando el problema es visual: un elemento desalineado, un recorte que corta una cara, una sección que
se ve vacía, un mapa mal centrado. Un texto mal escrito no necesita foto, la cita basta.

En Claude Code y opencode el runner ya las dejó en `qa-<cliente>/capturas/`: refiérelas con ruta relativa
(`![Mapa en zoom mundial](qa-cliente/capturas/desarrollos-escritorio.png)`) y recorta a mano la que valga la
pena. En Cowork, para que una imagen entre a un documento hace falta un archivo en disco, y las capturas del
panel del navegador llegan a la conversación pero no se guardan solas; las vías son una imagen que el usuario
adjunte, una carpeta conectada de su computadora, o el contenedor si puede alcanzar el sitio. Con el archivo
en mano: `Artifact` con `action: "publish"`, la URL del documento, el `file_path` y `asset: true` devuelve un
id; `create` de un `blob` con `{"asset": "<id>"}` devuelve `blob/xxxx`; y en el markdown de la sección va
`![Mapa en zoom mundial](blob/xxxx)`. Si ninguna vía está disponible, dilo en una línea en vez de prometer
capturas que no van a aparecer, y compénsalo con la cita exacta del elemento.

### Tono y entrega

Lo lee el equipo que hizo el sitio: hechos, no reproches. Frases cortas, sin adjetivos, con el número o la
cita en lugar de la impresión; nada de "urgente" en mayúsculas, el orden de las secciones ya dice qué pesa.
Reconoce lo que sí está bien cuando cambia la decisión — por ejemplo que el dato correcto ya existe en otra
parte del proyecto, lo que convierte un hallazgo en un arreglo de cinco minutos.

Al terminar, una o dos líneas: qué se revisó y cuántos bloqueantes hay. El detalle está en el reporte. Si va
a pasar al canal del proyecto, ofrece la versión corta para pegar, agrupada por página y sin tablas.

## Checklist de apoyo

Úsalo para no olvidar nada, no como guion. Sáltate sin culpa lo que no aplique.

**Contenido y datos** — precios (formato, moneda, separadores, coherencia entre el "desde" general y el
detalle); teléfonos (texto y `href`, lada, cuál corresponde a cuál sucursal); correos y su dominio;
direcciones completas e iguales en todas las páginas; cifras que se repiten (años, clientes, proyectos) sin
contradecirse; acentos y pronombres que cambian el sentido (tu/tú, mi/mí, el/él); espacios dobles, faltantes
tras un punto o sobrantes antes de `?`; textos copiados entre fichas con el nombre cambiado; valores por
defecto del CMS asomándose ("Ninguna", "No disponible", "Lorem ipsum"); bloques de "no disponible por el
momento" que deberían ocultarse; años en copyright y avisos.

**Funcional** — menú, footer y CTA sin enlaces muertos; botones flotantes y su mensaje precargado; filtros;
mapas; carruseles y lightbox; formularios sin enviarlos; 404 y estados vacíos; consola; descargables que
abran, sean la versión correcta y vivan en el host definitivo.

**SEO y publicación** — `title` y `description` únicos por página; canonical; `og:` con imagen que exista;
`lang`; `robots.txt` y `sitemap.xml` apuntando al dominio bueno; un preview no debería ser indexable; una
`h1` por página; `alt` con contenido en las imágenes que comunican; assets en un host que siga existiendo
después del lanzamiento; redirecciones desde el sitio anterior.

**Rendimiento** — peso total de imágenes y las más pesadas; imágenes servidas mucho más grandes de lo que se
muestran; formatos modernos; `lazy`; fuentes (cuántas, en qué formato, con subset); scripts de terceros que
el cliente no pidió. Con headless CMS, el build debería bajar y servir los assets, no dejar los `src`
apuntando al CMS.

**Responsive y accesibilidad básica** — 375 px sin scroll horizontal ni elementos encimados; tamaño de tap;
contraste del texto sobre fotos, sobre todo en héroes; foco visible y navegación con teclado en menús y
modales; etiquetas en campos y selects.

**Legal (México)** — aviso de privacidad accesible y vigente; consentimiento en cada formulario que recoja
datos personales; cookies si hay analíticas o píxeles; datos fiscales si el cliente los publicaba antes.

**Landing con formulario que abre una agenda al enviar (Cal.com, Calendly)** — la regla de no enviar
formularios sigue vigente: lee el código (el *handler* del submit, la llamada al widget, los parámetros de
prefill, la URL del embed) y la pestaña de red de una carga normal; si hace falta verlo abierto, pide permiso
expreso y un contacto de prueba, y si no, queda como "sin probar".
- **Popup tras el envío:** la agenda se abre *después* de un envío válido y no antes, ni al cargar la página, ni
  con un envío fallido; qué pasa si el navegador bloquea el popup o la persona lo cierra sin agendar (¿el lead
  ya quedó capturado? ¿hay un enlace de respaldo?).
- **Qué se precarga y se manda al tercero:** nombre, correo, teléfono, notas, UTM o respuestas del formulario que
  se pasan al widget (`name`, `email`, `notes`, parámetros de la URL); sólo lo necesario, sin datos que el
  formulario no pidió, y con el aviso de privacidad mencionando al tercero. Compáralo con lo que mandaba la
  página anterior.
- **Un solo punto de entrada:** una sola forma de llegar a la agenda; sin un botón "Agendar" suelto que abra el
  widget saltándose el formulario, ni dos copias del script del widget, ni dos popups que se encimen.
- **Slug del evento por página:** cada landing apunta al tipo de evento que le corresponde (duración, equipo,
  sede); un slug copiado de otra página manda las citas al calendario equivocado. Verifica cada slug contra la
  cuenta o la fuente del cliente.

**Por tipo de sitio** — catálogo: filtros contra inventario, orden y paginación, ficha individual, unidades
de medida, disponibilidad, fichas de lo que viene. Institucional: coherencia entre home y secciones internas,
biografías y cargos, logos de terceros y sus permisos, datos por oficina. Perfil profesional: grados tal como
la persona los usa, publicaciones con ligas vivas, membresías vigentes, CV actualizado. Tienda: existencias,
impuestos y envío, políticas, correos transaccionales — sin completar compras de prueba. Multiidioma: nada
sin traducir, el selector no manda siempre a la home, `hreflang`, y los datos duros iguales en las dos versiones.

## Cómo no perder credibilidad

- No reportes como error lo que no verificaste. "Sin verificar" es una categoría respetable; inventar no.
- Si algo ya se había comentado y sigue igual, dilo sin dramatizar: una lista corta al inicio.
- Nada de adjetivos. "La página tarda" no sirve; "11 s hasta `load`, 6.4 MB en imágenes" sí.
- No toques el CMS, ni el repositorio, ni la configuración del cliente. La revisión observa y reporta.
- Si el sitio es un preview con contenido a medias, no reportes cada campo vacío: reporta el patrón una vez.
- Revisa tus propios números antes de escribirlos. Un dato mal en un documento de QA se paga caro.

## El runner

Guárdalo como `qa-audit.mjs`. La función `AUDIT` es lo único que hay que portar a otro motor: devuelve la
misma estructura se ejecute desde Playwright, desde `evaluate_script` de Chrome DevTools MCP o desde el
evaluador del panel del navegador.

```javascript
#!/usr/bin/env node
// Barrido QA: recoge evidencia por pagina y toma capturas. No decide que esta mal,
// junta datos y marca sospechas. Requiere: npm i -D playwright && npx playwright install chromium
// (si el navegador de Playwright no esta, cae a Chrome del sistema con channel: 'chrome')
// node qa-audit.mjs --url https://sitio.com [--paths /,/nosotros] [--max-pages 12] [--out ./qa] [--no-shots]
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';

// AUDIT corre DENTRO de la pagina y es lo unico que hay que portar entre motores:
// sin shell, copia este cuerpo al evaluador de JS del navegador que tengas.
const AUDIT = () => {
  const t = (el) => (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
  const uniq = (a) => [...new Set(a)];
  const im = [...document.querySelectorAll('img')];
  // Decorativas: duplicados aria-hidden (clones de carrusel/marquesina), role=presentation/none y posters decorativos.
  const decorativa = (i) => !!i.closest('[aria-hidden="true"]') || ['presentation', 'none'].includes(i.getAttribute('role'))
    || /poster/i.test((i.className || '') + ' ' + (i.parentElement?.className || ''));
  return {
    url: location.href, title: document.title, lang: document.documentElement.lang,
    desc: document.querySelector('meta[name=description]')?.content || null,
    robots: document.querySelector('meta[name=robots]')?.content || null,
    canonical: document.querySelector('link[rel=canonical]')?.href || null,
    og: [...document.querySelectorAll('meta[property^="og:"]')].map(m => m.getAttribute('property') + '=' + m.content),
    h1: [...document.querySelectorAll('h1')].map(t), h2: [...document.querySelectorAll('h2')].map(t).slice(0, 30),
    tel: [...document.querySelectorAll('a[href^="tel:"]')].map(a => ({ href: a.getAttribute('href'), texto: t(a) })),
    mail: uniq([...document.querySelectorAll('a[href^="mailto:"]')].map(a => a.getAttribute('href'))),
    wa: uniq([...document.querySelectorAll('a[href*="wa.me"],a[href*="whatsapp"]')].map(a => a.getAttribute('href'))),
    vacios: [...document.querySelectorAll('a')].filter(a => { const h = a.getAttribute('href'); return !h || h === '#' || !h.trim(); }).map(a => t(a) || '[sin texto]'),
    externos: uniq([...document.querySelectorAll('a[href^="http"]')].filter(a => a.hostname !== location.hostname)
      .map(a => a.href + ' | target=' + a.target + ' rel=' + a.rel)),
    imgs: { total: im.length, decorativas: im.filter(decorativa).length,
      sinAlt: im.filter(i => !decorativa(i) && !i.alt.trim()).length, clickeables: im.filter(i => i.closest('a,button')).length,
      grandes: im.filter(i => i.naturalWidth > i.getBoundingClientRect().width * 2 && i.getBoundingClientRect().width > 0)
        .map(i => `${(i.currentSrc || i.src).split('/').pop().slice(0, 55)} ${i.naturalWidth}px -> ${Math.round(i.getBoundingClientRect().width)}px`),
      hosts: uniq(im.map(i => { try { return new URL(i.currentSrc || i.src).host } catch { return null } }).filter(h => h && h !== location.host)) },
    fuentes: uniq(performance.getEntriesByType('resource').map(e => e.name).filter(u => /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(u)).map(u => u.split('/').pop())),
    forms: [...document.querySelectorAll('form')].map(f => ({ action: f.getAttribute('action'), method: f.method,
      campos: [...f.elements].map(e => (e.name || e.type) + ':' + e.type + (e.required ? '*' : '')),
      consentimiento: /privacidad|privacy|t[eé]rminos|acepto/i.test(f.innerText || '') })),
    selects: [...document.querySelectorAll('select')].map(s => ({ nombre: s.name || s.id || '(sin name)',
      etiquetado: !!(s.labels?.length || s.getAttribute('aria-label')), opciones: [...s.options].map(o => o.text) })),
    // Data serializada en el DOM: ahi se ve lo real, no lo renderizado.
    datos: [...document.querySelectorAll('[data-markers],[data-items],[data-products],script[type="application/json"]')]
      .map(e => ({ tag: e.tagName, attrs: [...e.attributes].map(a => a.name), muestra: (e.getAttribute('data-markers') || e.textContent || '').slice(0, 800) })),
    // scrollWidth del documento: lo recortado por overflow:hidden (carruseles, marquesinas) no cuenta. Se lee tras el reposo().
    desborde: { scroll: document.documentElement.scrollWidth, ventana: document.documentElement.clientWidth },
    texto: (document.body.innerText || '').replace(/[ \t]+/g, ' '),
  };
};

// performance da 0 si el host es externo: hay que pesarlas con HEAD.
const PESO = async () => {
  const urls = [...new Set([...document.querySelectorAll('img')].map(i => i.currentSrc || i.src).filter(u => u.startsWith('http')))];
  let total = 0; const filas = [];
  for (const u of urls.slice(0, 40)) {
    try { const r = await fetch(u, { method: 'HEAD' }); const s = +(r.headers.get('content-length') || 0);
          total += s; filas.push({ kb: Math.round(s / 1024), archivo: u.split('/').pop().slice(0, 55) }); } catch {}
  }
  return { totalMB: +(total / 1048576).toFixed(2), medidas: filas.length, de: urls.length, top: filas.sort((a, b) => b.kb - a.kb).slice(0, 10) };
};

const PISTAS = [['punto sin espacio', /[a-záéíóúñ]{3}\.[A-ZÁÉÍÓÚ][a-záéíóú]{2,}/g], ['espacio antes de signo', /\w+ [?!,;](?!\d)/g],
  ['doble espacio', /\S {2,}\S/g], ['placeholder de CMS', /\b(lorem ipsum|Ninguna|no disponible por el momento|sin definir|TBD|por definir)\b/gi],
  ['unidades inconsistentes', /\d\s?[Mm]2\b/g], ['precio con centavos', /\$\s?\d{1,3}(,\d{3})*\.\d{2}\b/g],
  ['precio corto sospechoso', /\$\s?\d{1,4}\.\d{2}\b/g], ['texto en ingles suelto', /\b(Read more|Learn more|Submit|Coming soon)\b/g]];

function revisarTel(href, texto) {
  const crudo = href.replace(/^tel:/, '').trim(), d = crudo.replace(/\D/g, ''), dt = (texto || '').replace(/\D/g, ''), p = [];
  if (crudo.startsWith('+')) {
    if (!crudo.startsWith('+52') && d.length <= 12) p.push('lleva + sin lada de pais valida: marca a otro pais');
    else if (crudo.startsWith('+52') && d.length !== 12) p.push(`+52 va con 10 digitos, trae ${d.length - 2}`);
  } else if (![10, 8].includes(d.length)) p.push(`sin lada de pais y con ${d.length} digitos`);
  if (dt && !d.includes(dt) && !dt.includes(d)) p.push(`el link (${d}) no coincide con lo que se muestra (${dt})`);
  if (/[ ()\-]/.test(crudo)) p.push('el href trae espacios o parentesis; varios telefonos no lo abren');
  return p;
}
const slug = (u) => (new URL(u).pathname.replace(/^\/|\/$/g, '').replace(/\//g, '_') || 'home').replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 60);
const arg = (n, def) => { const i = process.argv.indexOf('--' + n); return i > -1 ? (process.argv[i + 1]?.startsWith('--') ? true : process.argv[i + 1]) : def; };

const base = String(arg('url', '')).replace(/\/$/, '');
if (!base) { console.error('Falta --url'); process.exit(1); }
const out = String(arg('out', './qa')), maxPages = +arg('max-pages', 12), shots = !process.argv.includes('--no-shots'), rutas = arg('paths', null);
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
const opcionesNav = proxy ? { proxy: { server: proxy }, args: ['--ignore-certificate-errors'] } : {};
let navegador;
try { navegador = await chromium.launch(opcionesNav); }
catch (e) { // sin el navegador de Playwright: usa el Chrome del sistema
  console.error('Chromium de Playwright no disponible, usando Chrome del sistema:', String(e).split('\n')[0]);
  navegador = await chromium.launch({ ...opcionesNav, channel: 'chrome' });
}
const ctx = await navegador.newContext({ viewport: { width: 1440, height: 900 }, locale: 'es-MX' });
const page = await ctx.newPage();
const consola = [], fallos = [], candidatos = [];
page.on('console', m => ['error', 'warning'].includes(m.type()) && consola.push({ tipo: m.type(), texto: m.text().slice(0, 300), en: page.url() }));
page.on('requestfailed', r => fallos.push({ url: r.url().slice(0, 160), motivo: r.failure()?.errorText, en: page.url() }));
const anota = (tipo, url, detalle, evidencia) => candidatos.push({ tipo, url, detalle, ...(evidencia ? { evidencia } : {}) });
const reposo = async (p) => { await p.waitForTimeout(3000); await p.mouse.wheel(0, 6000); await p.waitForTimeout(1500); await p.evaluate(() => scrollTo(0, 0)); };

async function descubrir() {
  for (const r of ['/sitemap.xml', '/sitemap-index.xml', '/sitemap_index.xml']) {
    try { const res = await page.request.get(base + r, { timeout: 15000 });
          if (res.ok()) { const l = [...(await res.text()).matchAll(/<loc>\s*([^<\s]+)\s*</g)].map(m => m[1]); if (l.length) return l; } } catch {}
  }
  await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 45000 }); await page.waitForTimeout(2500);
  return await page.evaluate(() => [...document.querySelectorAll('a[href]')].map(a => a.href));
}

const crudas = rutas && rutas !== true ? String(rutas).split(',').map(r => new URL(r, base + '/').href) : await descubrir();
const vistas = new Set(), objetivo = [];
for (const u of [base, ...crudas]) {
  const l = u.split('#')[0].replace(/\/$/, '') || base;
  if (!l.startsWith(base) || vistas.has(l) || /\.(pdf|jpe?g|png|webp|svg|zip|docx?|xlsx?)$/i.test(l)) continue;
  vistas.add(l); objetivo.push(l);
  if (objetivo.length >= maxPages) break;
}
await mkdir(path.join(out, 'paginas'), { recursive: true });
if (shots) await mkdir(path.join(out, 'capturas'), { recursive: true });
const titulos = new Map(), descs = new Map(), paginas = [];

for (const url of objetivo) {
  let resp;
  try { resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); await reposo(page); }
  catch (e) { anota('pagina no carga', url, String(e).slice(0, 160)); continue; }
  const d = await page.evaluate(AUDIT);
  d.status = resp?.status() ?? null;
  try { d.peso = await page.evaluate(PESO); } catch { d.peso = null; }
  d.consola = consola.filter(c => c.en === url).slice(-25);
  paginas.push({ url, status: d.status, title: d.title });

  if (shots) {
    const s = slug(url);
    await page.screenshot({ path: path.join(out, 'capturas', `${s}-escritorio.png`), fullPage: true });
    const movil = await ctx.newPage();
    await movil.setViewportSize({ width: 375, height: 812 });
    await movil.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); await reposo(movil);
    d.movil = await movil.evaluate(() => ({ scroll: document.documentElement.scrollWidth, ventana: document.documentElement.clientWidth }));
    await movil.screenshot({ path: path.join(out, 'capturas', `${s}-movil.png`), fullPage: true });
    await movil.close();
    if (d.movil.scroll > d.movil.ventana + 2) anota('scroll horizontal en movil', url, `${d.movil.scroll}px en ventana de ${d.movil.ventana}px`);
  }

  const ti = (d.title || '').trim();
  if (!ti || /^[.\s|]*$/.test(ti) || ti.startsWith('...')) anota('title roto o vacio', url, `el <title> es "${ti}"`);
  titulos.set(ti, [...(titulos.get(ti) || []), url]);
  if (!d.canonical) anota('sin canonical', url, 'la pagina no declara canonical');
  if (!d.desc) anota('sin meta description', url, 'falta la description');
  else descs.set(d.desc.slice(0, 120), [...(descs.get(d.desc.slice(0, 120)) || []), url]);
  if (!d.h1.length) anota('sin h1', url, 'la pagina no tiene encabezado h1');
  if (d.h1.length > 1) anota('varios h1', url, `${d.h1.length} encabezados h1`, d.h1);
  for (const tel of d.tel) for (const p of revisarTel(tel.href, tel.texto)) anota('telefono', url, p, `${tel.href} | ${tel.texto}`);
  for (const w of d.wa) { const n = w.match(/(?:phone=|wa\.me\/)(\d+)/)?.[1];
    if (n && !n.startsWith('52')) anota('whatsapp con lada no mexicana', url, 'confirmar con el cliente antes de cambiarlo: puede ser su linea de campanas', n); }
  for (const v of d.vacios) anota('link sin destino', url, `el enlace "${v}" no lleva a ningun lado`);
  for (const e of d.externos) if (/target=_blank/.test(e) && !/noopener/.test(e)) anota('target=_blank sin noopener', url, e.split(' | ')[0]);
  if (d.imgs.sinAlt) anota('imagenes sin alt', url, `${d.imgs.sinAlt} de ${d.imgs.total - d.imgs.decorativas} (se omitieron ${d.imgs.decorativas} decorativas/aria-hidden)`);
  if (d.imgs.grandes.length) anota('imagenes sin redimensionar', url, `${d.imgs.grandes.length} se sirven a mas del doble de su tamano mostrado`, d.imgs.grandes.slice(0, 6));
  if (d.imgs.hosts.length) anota('imagenes desde otro host', url, 'revisar si ese host es el definitivo', d.imgs.hosts);
  if (d.imgs.total && !d.imgs.clickeables) anota('galeria sin lightbox', url, 'ninguna imagen es clickeable');
  if (d.peso?.totalMB >= 3) anota('pagina pesada', url, `${d.peso.totalMB} MB en ${d.peso.medidas} imagenes`, d.peso.top.slice(0, 5).map(x => `${x.kb}KB ${x.archivo}`));
  const ttf = d.fuentes.filter(f => /\.(ttf|otf|eot)/i.test(f));
  if (ttf.length) anota('fuentes sin optimizar', url, `${ttf.length} archivos ttf/otf; conviene woff2 con subset`, ttf.slice(0, 8));
  for (const f of d.forms) if (!f.consentimiento) anota('formulario sin aviso de privacidad', url, 'no se ve casilla ni liga de consentimiento (LFPDPPP)', f.campos);
  for (const s of d.selects) if (!s.etiquetado) anota('select sin etiqueta', url, s.nombre, s.opciones.slice(0, 4));
  const errs = d.consola.filter(c => c.tipo === 'error');
  if (errs.length) anota('errores de consola', url, `${errs.length} errores`, errs.slice(0, 5).map(e => e.texto));
  for (const [nombre, re] of PISTAS) { const h = [...d.texto.matchAll(re)].map(m => m[0]).slice(0, 6); if (h.length) anota(nombre, url, `${h.length} coincidencias`, h); }
  if (d.desborde.scroll > d.desborde.ventana + 2) anota('scroll horizontal', url, `${d.desborde.scroll}px en ventana de ${d.desborde.ventana}px`);
  await writeFile(path.join(out, 'paginas', slug(url) + '.json'), JSON.stringify(d, null, 1));
  process.stdout.write(`. ${url}\n`);
}

for (const [t, urls] of titulos) if (urls.length > 1 && t) anota('title duplicado', urls[0], `${urls.length} paginas comparten "${t}"`, urls);
for (const [, urls] of descs) if (urls.length > 1) anota('description duplicada', urls[0], `${urls.length} paginas comparten la misma description`, urls);
const extra = {};
for (const r of ['/robots.txt', '/sitemap.xml']) {
  try { const res = await page.request.get(base + r, { timeout: 15000 }); extra[r] = { status: res.status(), cuerpo: res.ok() ? (await res.text()).slice(0, 600) : null }; }
  catch (e) { extra[r] = { error: String(e).slice(0, 120) }; }
}
const rb = extra['/robots.txt']?.cuerpo || '';
if (/vercel\.app|netlify|staging/.test(base) && !/Disallow:\s*\/\s*$/m.test(rb))
  anota('preview indexable', base, 'es un deploy de preview y robots.txt no lo bloquea: Google puede indexarlo');
const sm = rb.match(/Sitemap:\s*(\S+)/);
if (sm && new URL(sm[1]).host !== new URL(base).host) anota('sitemap apunta a otro dominio', base, sm[1]);

await writeFile(path.join(out, 'resumen.json'), JSON.stringify({ sitio: base, fecha: new Date().toISOString().slice(0, 10), paginas, candidatos, ...extra, fallos_de_red: fallos.slice(0, 40) }, null, 1));
await navegador.close();
const porTipo = candidatos.reduce((a, c) => (a[c.tipo] = (a[c.tipo] || 0) + 1, a), {});
console.log(`\nPaginas revisadas: ${paginas.length}   Candidatos: ${candidatos.length}`);
for (const [t, n] of Object.entries(porTipo).sort((a, b) => b[1] - a[1])) console.log(`  ${String(n).padStart(4)}  ${t}`);
console.log(`\nDetalle: ${out}/resumen.json y ${out}/paginas/` + (shots ? `\nCapturas: ${out}/capturas/` : ''));
```
