12 Commits
Author SHA1 Message Date
Claude 70e5dc667f hub ES completo y «Libro Azul» fuera del falso positivo de Title Case
hub-es.md: las 5 entradas que faltaban (37/37, el índice cuadra).
seo_watch.py: «Proyecto Libro Azul» / «Libro Azul» a CASE_PHRASES, junto a
sus hermanas «Proyecto Blue Book» / «Blue Book». Es el nombre propio del
proyecto —24 menciones en el artículo, ninguna en minúscula— pero «libro» y
«azul» son comunes en el corpus, así que el vocabulario no las salvaba.
El artículo estaba bien escrito; el detector no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:53:44 +00:00
ChemaVXandClaude Opus 5 2c3357f628 seo-watch: «Ejercito del Aire» es nombre propio, no Title Case ingles
El detector marcaba Ejercito y Aire porque las dos aparecen en minuscula
por el corpus (al aire libre, el ejercito), asi que el vocabulario no las
salvaba. Su hermana «Fuerza Aerea» ya estaba en CASE_PHRASES.

Comprobado que no afloja el detector: un «Aire» suelto mal capitalizado
fuera de la frase sigue saltando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:21:26 +00:00
ChemaVXandClaude Opus 5 5b91895ef1 estado: cerrar los siete puntos ciegos del vigilante, y pasarlo a diario
Recuento de los fallos de esta semana: diecisiete. Ni uno solo fue un caso que
el vigilante juzgara mal — los diecisiete estaban en sitios donde el vigilante
no mira. Eso es una buena noticia: los puntos ciegos son enumerables y el
juicio equivocado no.

Lo que no miraba nadie, con el fallo real que lo motiva:

  tags        5 duplicados vivieron TRES SEMANAS repartiendo 7 posts entre dos
              archivos flacos, los dos ofrecidos a Google en sitemap-tags.xml
  páginas     todas las reglas eran de posts; /about no tiene metas en ninguno
              de los dos sitios
  <head>      el EN emite DOS Article con titulares distintos, y sigue con
              twitter:site=@ghost y article:publisher=facebook.com/ghost
  robots.txt  el bloque «Cloudflare Managed» apareció solo en el EN y nadie lo
              decidió; cambia sin pasar por git
  hreflang    lo comprobaba SOLO seo_audit.py, que NO TIENE TIMER
  hub         se publicó una vez y se pudrió: «31 casos» con 33
  programados check_rules hace `if status != "published": continue`, así que un
              programado con la meta mal es invisible hasta el día DESPUÉS de
              publicarse — cuando ya lo ha visto Google

Decisiones:
  - módulo aparte y no dentro de seo_watch.py (ya pasa de 500 líneas), pero
    colgando del MISMO vigilante: una huella por sitio y un solo aviso. Dos
    notificadores compitiendo es como se deja de leer un informe
  - robots.txt se compara contra una INSTANTÁNEA versionada. Sin una foto
    contra la que comparar, un cambio que nadie hizo es invisible para siempre
  - un chequeo que revienta se convierte en HALLAZGO, no tumba a los demás. Lo
    peligroso de un vigilante no es que falle: es que falle y parezca limpio
  - og_*/twitter_* vacíos en una PÁGINA quedan fuera por COMPROBACIÓN, no por
    comodidad: Ghost los deriva de las metas al renderizar, verificado sobre el
    HTML público del hub ES. Avisar de eso sería mandar a arreglar algo que ya
    está bien

Timer semanal → DIARIO. Con 2 posts/semana más programados y deriva de tema y
robots, el lunes dejaba hasta seis días de exposición. No añade ruido: solo
avisa si hay hallazgos Y han cambiado, así que un día limpio no manda nada.

8 tests, cada uno la cicatriz de un fallo real, incluidos los dos que hacen
creíble a un vigilante: que DETECTA la avería (probado con deriva simulada de
robots) y que un chequeo roto se oye.

Primera ejecución real: 8 hallazgos, todos preexistentes, 32 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:21:46 +00:00
ChemaVXandClaude Opus 5 1ac51267fe títulos del ES en mayúscula de oración: 91 campos y la regla que lo vigila
Los encabezados llevaban limpios desde el 21 de julio, pero la regla solo
miraba <h2>/<h3>/<h4>: 28 de los 31 títulos publicados del ES seguían en
Title Case inglés, con sus meta_title, og_title y twitter_title detrás. Es
justo lo que el lector español lee en Google.

seo_case.py reescribe, y no reutiliza el detector tal cual porque no vale
para esto: `_title_case_words` decide con una sola prueba —«¿la he visto en
minúscula alguna vez?»— y eso deja fuera los verbos que solo viven en
titulares («Ocultan», «Conmocionó», «Penetró») y las palabras de dos letras,
que ni entran en el vocabulario. La primera versión devolvía títulos a medio
arreglar y rompía «Talavera la Real» y «vuelo Iberia». Ahora se decide con
dos pruebas —nunca en minúscula Y en mayúscula a media frase— más una lista
de palabras gramaticales, y las frases propias se enmascaran conservando las
posiciones para reescribir por desplazamiento sobre el original.

Decisión de Jose: «Ejército», «Gobierno» y demás instituciones conservan la
mayúscula; «Archivos» baja salvo en «Los Archivos PURSUE», que se protege
por frase porque ahí se distingue por contexto, no por palabra suelta.

check_title_case cierra el agujero en seo_watch. Simulado el ciclo entero
antes de tocar nada: 78 hallazgos → 16 tras el pase automático → 0 con la
decisión aplicada. Aplicado y verificado releyendo Ghost campo a campo (91
de 91), con backup pristine por post en ~/link-batch-backup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:42:37 +00:00
ChemaVXandClaude Opus 4.8 9c76ed87ff seo_watch: estado por sitio; seo_link: busca en todas las tarjetas
Dos falsas señales, una en cada sentido.

seo_watch guardaba UNA huella de los dos blogs juntos. Una ejecución manual con
--site en la machacaba con media huella, y el siguiente disparo del timer creía
que todo había cambiado: te reenviaba hallazgos viejos como si fueran nuevos.
Un vigilante que cría falsas alarmas se acaba ignorando, que es justo lo que
este no se puede permitir. Ahora el estado es por sitio y solo se toca el de
los sitios revisados. El formato antiguo migra solo.

seo_link, en mobiledoc, solo miraba cards[0]. Un cuerpo repartido en varias
tarjetas —lo que pasa en cuanto el artículo lleva una imagen intercalada—
respondía «no aparece en la prosa» con el anchor delante. Como los dos formatos
acaban en diccionarios con clave "html", ahora se tratan igual: una lista de
trozos que se recorre entera. Sale más corto que antes.

Probado con la secuencia que rompía —revisión completa, manual solo de EN,
completa otra vez sin cambios— comprobando que la huella del ES sobrevive y que
la última calla; más un cambio real, que sí avisa. Y el de seo_link con el
anchor en la SEGUNDA tarjeta, verificado además contra el código viejo, donde
falla.

Migrado el estado real en el master: los dos blogs siguen sin hallazgos y la
migración no ha generado ningún aviso.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:08:03 +00:00
ChemaVXandClaude Opus 4.8 d381c0225c seo_watch/seo_link: dos fallos que se manifestaban callándose
1) El corpus se pedía con --limit 100. Con 40 posts en EN y 29 en ES sobraba,
   pero a 2 por semana habría cruzado los 100 hacia 2027 y se habría truncado
   EN SILENCIO. Y un corpus truncado no da error: da falsos «enlaces rotos»
   contra los posts que faltan, que es peor que no mirar. Pasa a --limit all
   y, sobre todo, se comprueba el número contra meta.pagination.total: eso es
   lo que convierte un fallo mudo en uno que se oye. En seo_link el truncado
   era aún peor, porque haría rechazar como inexistente un destino que sí
   está.

2) check_sitemap daba VERDE cuando curl fallaba: 0 URLs descargadas son 0
   problemas encontrados, y el informe decía « sin hallazgos». O sea que
   justo el rato en que el sitio está caído era cuando este check se callaba.
   Un sitemap vacío pasa a ser hallazgo con mensaje propio; en un blog con
   29-40 artículos nunca lo está legítimamente.

Probado con casos que DEBEN fallar: corpus recortado a 3 de 40 (revienta y lo
dice), corpus completo (pasa limpio), y sitemap vacío (hallazgo, informe claro
y notificaría). Y en real contra los dos blogs: EN 40 posts / 33 URLs, ES 29 /
31, ambos sin hallazgos.

No toca seo_rules.py, así que el vendor-sync con ResearchOwl sigue en verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:01:28 +00:00
ChemaVX 48fbe4302f seo_watch: el corpus hace de diccionario en el check de mayúsculas
La lista blanca CASE_KEEP no escala y se rompió en el primer artículo real: el
Canarias de prueba del 2026-07-21 traía «El testigo del carguero Osaka Bay» y
«Ricardo Campo Pérez y Vicente-Juan Ballester Olmos», los dos encabezados
correctos, marcados como Title Case porque un barco y dos investigadores no
estaban en la lista. El umbral de 2 palabras protege de un nombre propio
suelto, no de uno de varias palabras — y cada caso nuevo trae nombres nuevos.

Ahora el propio blog resuelve la duda: si el corpus escribe esa palabra en
minúscula en algún otro sitio, capitalizarla a mitad de frase es estilo inglés;
si no aparece nunca en minúscula, es un nombre propio. «ciudad», «borde» y
«pánico» salen por todas partes; «Osaka» y «Ballester» jamás.

minimo=1 elegido barriendo el umbral contra 10 encabezados reales, el corpus
limpio y el sucio de la víspera — la tabla queda en el docstring. Una sola
aparición en minúscula ya prueba lo que hace falta: que la palabra PUEDE ir en
minúscula. Subir el umbral solo pierde detección (10/10 → 7/10 con mínimo 5).

CASE_PHRASES y CASE_KEEP siguen como red: «Guerra Fría» necesita protección
explícita porque «guerra» sí aparece en minúscula. Sin vocabulario, la función
se comporta como antes.
2026-07-21 08:46:04 +00:00
ChemaVX b0d5e1bd0c seo_watch: check de mayúsculas en el ES + arregla los checks de enlaces del ES
El check nuevo detecta encabezados en Title Case inglés, que en español no se
usa. Solo corre sobre el ES (en el EN es lo correcto). Umbral de 2 palabras
sospechosas por encabezado: con 1 sola, un nombre propio que falte en las
listas daría un falso positivo. Verificado en los dos sentidos — 0 avisos
sobre los 271 encabezados del ES ya limpios, 208 sobre el corpus de ayer, 0
sobre el corpus EN. Hace falta porque la fuga es intermitente: el 2026-07-21
había 219 encabezados sucios en 23 de 29 posts, pero otros del mismo mes
salieron limpios, así que no basta con limpiar una vez.

Y de paso, un agujero que apareció leyendo el código para meter el check:
internal_urls() tenía theexclusionzone.com escrito a fuego. Desde que el
vigilante se extendió al ES (ayer), los checks de enlaces rotos, no canónicos
y prematuros buscaban en el corpus español un dominio que no aparece en él —
o sea, veían 0 enlaces y no podían fallar nunca. Ahora el dominio sale de
R.SITE_HOST, como el resto.

Nada más arreglarlo encontró su primer hallazgo real: los-villares enlazaba a
www.zonadeexclusion.com, y en el ES el canónico es el apex (al revés que en el
EN). Salto 301 evitable, ya corregido en Ghost.
2026-07-21 07:41:18 +00:00
ChemaVXandClaude Opus 4.8 7b92079c41 seo_exceptions: ámbito por sitio — los dos blogs, independientes de verdad
Los waivers se indexaban SOLO por slug. Hoy no hay colisiones (los slugs ES
van en español), pero nada lo impedía: un slug repetido entre blogs habría
aplicado en silencio la excepción del sitio equivocado, y ese post habría
dejado de auditarse sin que nadie se enterara. Riesgo latente, no activo — pero
'no pasa hoy' no es lo mismo que 'no puede pasar'.

  EXCEPTIONS[site][familia][slug] = razón
  accepted_reason(rule, slug, site='en')

El default 'en' mantiene funcionando a seo_audit.py sin tocarlo (es EN-only).
seo_watch y seo_finish pasan el sitio activo explícitamente.

Añadido collisions(): lista los slugs waiveados en más de un sitio. Debe estar
siempre vacío; si algo aparece ahí, hay una excepción ambigua que revisar.

Verificado: mismo slug waiveado en EN se audita con normalidad en ES; los 16
waivers EN intactos; seo_audit, seo_watch (ambos sitios) y seo_finish (ambos
sitios) siguen dando los mismos resultados que antes del cambio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:16:47 +00:00
ChemaVXandClaude Opus 4.8 f772703051 seo_watch: extiende el vigilante al blog ES (--site en|es|both)
El ES nunca se había auditado: no había wrapper hasta hoy (ghst-es). Corre
ambos por defecto y emite un informe con una sección por sitio.

Detalles del diseño:
  * canónico invertido entre sitios (EN www, ES apex) → SITES parametriza url,
    cli y host.
  * seo_rules.SITE_HOST se reasigna en runtime desde use_site(): ese fichero se
    vendoriza byte a byte en ResearchOwl y la CI lo verifica, así que no se
    puede parametrizar en origen.
  * huella de estado por sitio (dict), para que un cambio en uno no reabra la
    notificación del otro por accidente.
  * un sitio caído no tumba el informe del otro: run_site va en try/except y
    el error se reporta como hallazgo de ese sitio.

Primera pasada real: EN 0 hallazgos; ES 152 violaciones del auditor (28 sin
alt-text — todos —, 27 con enlazado flojo, 21 sin OG/Twitter). Ninguna dispara
Telegram: las violaciones de reglas son inventario, no regresión — solo avisan
los enlaces rotos/no canónicos/prematuros/envejecidos y el sitemap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:09:45 +00:00
ChemaVXandClaude Opus 4.8 2833144653 seo_watch: detecta enlaces envejecidos (check 4)
El corpus crece 2 posts/semana, así que un enlace apunta al mejor destino que
existía el día en que se escribió — y envejece. Caso real que motivó el check
(2026-07-18): levelland enlazaba el anchor 'Project Blue Book' al artículo de
AARO porque cuando se generó (30-jun) el de Blue Book no existía (1-jul).
No fue un fallo del generador: fue el mejor destino disponible, un día antes
de que apareciera el correcto.

Heurística deliberadamente conservadora, por la misma razón que el resto del
job: un vigilante desatendido que grita en falso se acaba ignorando.
  * solo marca si el anchor contiene TODOS los tokens distintivos de otro post
  * no marca si el anchor ya casa con el destino actual (el enlace está bien)
  * ignora posts con <2 tokens distintivos — p.ej. roswell-1947 se reduce a
    {roswell}, que casa con demasiadas cosas

Test de regresión hecho contra los datos reales pre y post arreglo: caza
levelland antes, 0 hallazgos después (incluido el falso positivo de yellow-sea,
cuyo anchor menciona 'roswell' pero apunta correctamente al post del vídeo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:50:06 +00:00
ChemaVXandClaude Opus 4.8 744ed9fceb seo_watch: vigilante semanal de regresiones SEO (Tool D)
Chequeos deterministas sobre el sitio EN, READ-ONLY, con aviso a Telegram
solo cuando hay hallazgos Y algo cambia respecto a la ejecución anterior
(un informe semanal de 'todo bien' se acaba ignorando):

  1. enlaces internos rotos (destino inexistente)
  2. enlaces internos no canónicos (apex/http/sin barra → salto 301 evitable)
  3. enlaces PREMATUROS — destino programado para DESPUÉS del post que lo
     enlaza. Caso real detectado el 2026-07-18: el post Wilson-Davis (3-ago)
     enlazaba a MJ-12 (13-ago) → habría sido 404 durante 10 días justo en la
     ventana de rastreo del post recién publicado.
  4. sitemap: toda URL listada debe dar 200 directo
  5. violaciones accionables del motor de reglas (respeta seo_exceptions)

Por qué no lleva Gemini: el 2026-07-18 se midió con datos reales — de 6
sugerencias de enlazado interno, 0 cumplían el estándar 'entidad nombrada en
la prosa', y no vio las 3 entidades que había servidas en una sola frase.
Es fiable extrayendo (12/12 slugs correctos, cero alucinaciones) pero no
juzgando. Se deja fuera del job hasta tener un uso donde aporte de verdad.

Unidades systemd incluidas: timer semanal (lunes 05:30 UTC, antes de la
publicación de las 06:00). Instaladas en /etc/systemd/system/ y habilitadas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:35:29 +00:00