Lo reabrí porque los dos se publicaron el mismo día, y esa coincidencia en la
pareja de Grusch fue justo la firma de un espejo real. Aquí la explica el
gancho compartido —el informe de AARO— y no que sea el mismo artículo.
Leídos enteros: el EN concluye que el cuello de botella son los datos y no
nombra a Elizondo ni una vez; el ES es una confrontación AARO contra Grusch,
con secciones propias para Grusch, Elizondo, GoFast y el 9 de junio, y cierra
preguntando al lector a quién cree. Mismo gancho, tesis distinta.
Queda escrito para que la coincidencia de fecha no lo vuelva a reabrir.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La pareja decía que «David Grusch at the Capitol» (el acto del 9 de junio de
2026) y «El testimonio de David Grusch» (la comparecencia jurada de julio de
2023) son el mismo artículo en otro idioma. Son dos hechos con tres años de
diferencia. El espejo real es grusch-capitolio-9-junio-2026, publicado un día
después del acto y con extensión pareja.
Se coló porque las 16 parejas se validaron por firma de título, y con casos
históricos el año identifica el hecho, pero con NOTICIAS el mismo protagonista
vuelve. Revisadas a fondo las otras 15 con ese criterio —abriendo los dos
artículos, no comparando títulos—: correctas. Las dos de noticia que quedaban
(222 archivos y la segunda desclasificación) se publicaron el MISMO DÍA en los
dos sitios y con extensión casi idéntica, que es la firma de una pareja real.
Y el arreglo destapó un agujero: `cambios()` solo escribía las parejas del
fichero, así que repuntar una dejaba la anterior declarada por su lado. Se
habría quedado el ES de 2023 señalando a un EN que ya no le señala a él.
`huerfanos()` recorre ahora todo el corpus y retira nuestro bloque de quien ha
dejado de estar en la lista; `desmarca()` quita solo lo nuestro.
Aplicado y verificado en la web pública: 3 posts, las dos páginas de la pareja
nueva declaran las dos versiones y el ES de 2023 se queda sin hreflang, que es
lo correcto porque no hay artículo inglés sobre esa comparecencia.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El pase automático de títulos y metas no toca el cuerpo de los artículos, así
que el último Title Case del ES —«La conclusión oficial: Clasificación Tipo
D»— se corrigió a mano editando el lexical. No deja rastro en el repo: el
cambio vive en la BD de Ghost y el pristine solo guarda lo que había antes,
igual que en el registro de enlaces del 28 de julio (9823255).
Queda anotado qué se cambió, por qué «Tipo D» se conserva (es la etiqueta de
la Nota Técnica 16 del GEPAN, no estilo inglés) y la comprobación que hace
seguro un reemplazo global sobre 17.544 bytes: el texto aparecía una sola vez
antes de escribir, y la longitud no cambió después.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Las copias pristine guardan lo que había ANTES de cada edición, pero no
quedaba constancia de lo que se añadió ni de por qué. Se editaron 17
artículos publicados y sin este fichero la única forma de reconstruirlo
sería diffear contra los backups.
Queda el origen, el destino, la sección exacta y el texto íntegro de cada
frase, más el motivo de fondo: de 42 artículos sin un enlace entrante desde
el tercio superior por impresiones, solo dos tenían anclaje honesto ya
escrito en la prosa. El resto no era un problema de enlaces sino de que el
corpus no se citaba a sí mismo.
El corpus ES es de piezas autocontenidas: de 18 huérfanos solo dos tenían
un anclaje honesto ya escrito, y los dos están puestos. Varginha, Travis
Walton y Rendlesham —las tres páginas con más tráfico— no aparecen
nombradas ni una vez en los otros treinta artículos.
Enlazar los demás exige escribir la frase que sostiene el enlace, así que
va a revisión de Jose en vez de aplicarse. Cada propuesta cita el dato del
artículo destino del que sale, para que se pueda comprobar sin leerlo
entero.
seo_validate solo sabía preguntar a ghst-en, así que un slug español daba
404 y el paso 5 del remate —validar hasta que quede limpio— NUNCA pudo
completarse en el blog ES, pese a que el checklist afirma que todas las
herramientas aceptan --site.
Y arrastraba el mismo fallo de SITE_HOST que ya apareció en el auditor:
validando el Varginha del ES con el host del EN cuenta 0 enlaces internos
donde hay 1, y pediría añadir los que ya tiene. Ahora llama a R.usar_sitio.
Peor de cara al futuro, y por eso queda escrito en el módulo: hoy no hay
ningún slug repetido entre los dos blogs, pero el día que lo hubiera el
validador habría validado el post equivocado sin decir una palabra.
Los 18 waivers del ES salían como "waived: None": la línea que imprime el
motivo llamaba a accepted_reason sin el sitio, así que lo buscaba en el
diccionario del EN. El waiver se aplicaba bien —eso sí pasaba el sitio—,
pero el informe salía mudo justo donde tiene que justificarse, que es el
único propósito de esa sección.
Y si algún día vuelve a no haber motivo, ahora lo dice: un waiver sin
razón documentada es un fallo, no un hueco que rellenar con None.
Hasta ahora el hreflang era un estado —puesto una vez a mano— y no una
regla. Un slug que cambiara habría dejado el enlace apuntando al artículo
viejo sin que nada avisara.
El auditor gana dos reglas: hreflang.missing (pareja declarada sin bloque)
y hreflang.stale (bloque que ya no coincide con la pareja). Viven en
seo_hreflang y no en seo_rules porque necesitan hreflang-parejas.md, que es
una decisión editorial curada; el motor sigue sin saber que hay parejas.
Probadas en los cinco estados, no solo en el feliz: dispara sin bloque,
dispara con el bloque apuntando a un slug viejo, y se calla cuando está
correcto, cuando el post no es de ninguna pareja y cuando aún es borrador.
Y el remate gana el paso 3c: buscar el espejo en el otro idioma y dejarlo
en el informe. Con la advertencia explícita de decidir por TÍTULO y no por
vector, y las dos reglas duras — solo espejos publicados, y uno a uno.
Hasta hoy no había NI UN hreflang en ninguno de los dos blogs. Google no
tenía forma de saber que los artículos del mismo caso en los dos idiomas
son la misma pieza, así que servía el que le parecía y las señales de cada
uno se quedaban sueltas en su lado.
Las parejas NO se infieren, se curan a mano en hreflang-parejas.md. Los
embeddings no valen a esta granularidad y conviene que quede escrito: los
DOS artículos de Immaculate Constellation puntúan 0,774 entre sí y uno sin
relación puntúa 0,778; y el espejo real del artículo ES de bases nucleares
puntúa 0,719, por debajo de tres PURSUE que no lo son. La firma del título
(año + nombre propio) decide; el vector solo desempata.
Dos descartes con motivo: el forense del vídeo de Roswell no es el espejo
de la pieza ES de tres hilos (comparten un hecho, no un artículo), y
ninguno de los tres PURSUE es la traducción del artículo ES de bases
nucleares — ese espejo se llama «Nuclear Bases and UAP» y está PROGRAMADO,
así que entra en agosto.
Escribe entre marcas en codeinjection_head, así que es reescribible y no
pisa lo que hubiera. Verificado contra las 32 páginas PÚBLICAS comparando
las URLs exactas, no solo la presencia de las etiquetas: un par cruzado
declararía las dos versiones y habría pasado una comprobación laxa.
Los embeddings solos no sirven para esto: los dos artículos de Immaculate
Constellation puntúan 0,774 y uno sin relación puntúa 0,778. La firma del
título (año + nombre propio del caso) decide, y el vector solo desempata.
16 propuestas en SÍ, 14 en NO y 2 marcadas para que decida Jose: si el
forense del vídeo de Roswell es el espejo del artículo ES, y cuál de los
tres artículos PURSUE del EN se queda con el único ES (hreflang es 1 a 1).
Los EN sin pareja no son huecos editoriales: varios la tienen escrita y
PROGRAMADA para agosto. Entran cuando se publiquen, no antes.
El blog ES nunca se había auditado entero: seo_audit era EN-only aunque
seo_exceptions ya estuviera indexado por sitio desde el principio. Ahora
acepta --site es. Resultado del estreno: 40 de 40 limpios.
Para que eso signifique algo hubo que arreglar SITE_HOST, que era una
constante con el dominio del EN y decide qué href cuenta como enlace
interno. Auditando el ES con el host del EN salían 21 posts marcados por
enlaces internos; con el host correcto, cero. Un informe entero de
hallazgos falsos que nadie habría podido arreglar.
Y `ghst post list --status published` NO filtra: devuelve published Y
scheduled. El encabezado llevaba desde siempre diciendo "42 published
posts" cuando eran 33 publicados y 9 programados. Se pide sin filtro y se
separa en el código, que además deja explícito que los programados también
se auditan — que está bien, pero hay que decirlo.
topic_collision marcaba como colisión los dos artículos del acto de Grusch
del 9 de junio, cuando el de mayo lleva canonical_url apuntando al otro
desde hace semanas. Google ya sabe cuál manda y Ghost excluye al secundario
del sitemap: no hay nada que arreglar, y el propio mensaje ofrecía
«interlink deliberately» como salida, que es justo lo que había.
Un aviso que no se puede resolver nunca es peor que no avisar, porque
enseña a ignorar al validador entero — la misma razón por la que esta
mañana retiramos la comprobación de reddit-intel de backup-verify.
El par se salta cuando cualquiera de los dos declara canónico al otro. Si
el canónico apunta a un tercero, la colisión sigue avisando: comprobado con
los cuatro casos (sin canónico dispara, en las dos direcciones se calla,
canónico a un tercero dispara). fetch_corpus pide ya canonical_url, que
antes no traía.
Toda la caja miraba los enlaces en un solo sentido — a quién enlazo yo — y
esa es la pregunta del lector, no la de Google. Dos posts de EN publicados
el 21-jul seguían siendo «URL is unknown to Google» una semana después:
tenían enlaces entrantes, pero solo desde artículos con 8 y 12 impresiones
de por vida, páginas que Google casi no visita.
seo_semantic gana dos subcomandos: `padrinos <slug>`, que ordena candidatos
por tráfico REAL del origen (Search Console) en vez de por similitud, y
`descubrimiento`, que lista los publicados a los que no llega ni un enlace
desde el tercio fuerte del sitio. Salen 21 de 32 en EN y 21 de 31 en ES.
Ambos descartan los posts con canonical_url propio: Ghost los excluye del
sitemap por consolidados, así que apadrinar desde ahí no sirve de nada — el
de Grusch de mayo tiene 1.728 impresiones y habría encabezado la lista.
seo_gsc gana `inspecciona`, que pregunta el veredicto a Google en vez de
deducirlo. Es lo que separa «posiciona mal» de «no lo ha visto nunca», que
son problemas opuestos y se arreglan al revés.
Y el checklist del remate incorpora el paso 3b: el informe tiene que traer
el comando de enlace entrante listo para copiar. No lo aplica el agente
porque eso es editar otro post publicado, y eso sigue prohibido.
⚠️ Anotado en el docstring: contar enlaces internos desde el HTML público da
«cero huérfanos», porque el tema mete navegación y feed de relacionados que
parecen enlaces del cuerpo. La verdad está en el html del CLI de Ghost.
Recalibrado contra los datos reales del primer día, que desmintieron dos
supuestos del diseño.
El umbral de 150 impresiones dejaba fuera TODO: el mejor caso de los dos
blogs eran las 125 de Varginha en EN. El vigilante se habría callado para
siempre, la peor avería en algo que solo habla cuando hay noticias. Baja a
40, y entre 40 y 150 las líneas salen marcadas con «~» porque ahí el CTR es
orientativo, no firme.
Y el CTR no es la palanca a este volumen: con ~180 impresiones semanales en
EN, convertir el 10% de todas serían 78 clics al mes. El informe pasa a
liderar con cobertura — impresiones, páginas que reciben alguna, consultas
distintas, posición ponderada, y qué páginas empiezan o dejan de aparecer —
y avisa también cuando eso se mueve (±25% de impresiones o ±3 páginas), no
solo cuando hay CTR desperdiciado.
De propina, un fallo que solo se vio ejecutando el servicio dos veces
seguidas: avisaba las dos. Las listas nuevas ordenan un set de URLs por
impresiones y los empates los desempataba el orden de iteración del
conjunto, que Python aleatoriza por proceso; la huella cambiaba sin que
cambiara un solo dato. Todas las ordenaciones llevan ya desempate por
clave. Verificado: mismo texto con PYTHONHASHSEED 1, 2 y 3, y la segunda
ejecución del servicio se calla.
Estreno con credencial. Dos cosas que solo se ven con la API delante:
La propiedad ES no es de dominio sino de prefijo de URL
(https://zonadeexclusion.com/), así que sc-domain:zonadeexclusion.com no
existía y el canario lo cazó en el paso 4. Queda anotado que ese tipo de
propiedad no ve www ni http.
Y el histórico no es de 16 meses: las dos propiedades se dieron de alta en
mayo de 2026 y el dato más antiguo es del 2026-05-09. El manual prometía
comparar contra el año pasado y eso no va a existir nunca.
Toda la caja de herramientas miraba hacia dentro (enlaces, sitemap, reglas de
metas): decía si el sitio está bien construido, no si alguien lo encuentra. Los
únicos datos de resultado en la máquina eran una exportación manual del
2026-06-17, anterior al remate de metas del 23 de junio — o sea, cuarenta días
optimizando sin saber el resultado.
Cuenta de servicio en vez de OAuth, porque aquí no hay navegador (misma piedra
que con Gemini). Sin librerías de Google: el JWT se firma con PyJWT y se canjea
a mano, treinta líneas en lugar de media docena de dependencias.
Lo que de verdad aporta es `informe`: la lista de páginas que POSICIONAN Y NO
SE PINCHAN, con los clics que faltan si el CTR fuese el normal de su posición.
Y `antes-despues`, escrito para responder de una vez si el remate del 23-jun
sirvió: la API guarda 16 meses, así que el histórico está disponible desde el
primer minuto y no hay que esperar semanas para comparar.
Probado lo que se puede probar sin credencial: firma RS256 emitida y verificada
con su pública, los dos caminos de fallo de la credencial (ausente y con
permisos flojos), y la lógica de análisis contra datos sintéticos calcados del
caso Nimitz real (pos 6,44 / 2.452 impresiones / 0,16% CTR), que sale marcada
con +111 clics de hueco mientras la página sana no se marca.
El timer queda instalado y PARADO: sin credencial fallaría cada lunes, y un
timer que falla siempre enseña a ignorar los avisos.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Escribir un slug de 40 caracteres y acordarse del --site era casi toda la
fricción de la herramienta, y el caso normal es siempre el mismo: quiero la
infografía del borrador que acabo de terminar.
infografia → el borrador más recién tocado de los dos sitios
infografia --lista → los enumera, con si tienen imagen ya o no
infografia <slug> → busca en los dos sitios; los slugs no se repiten
entre blogs, así que no hay ambigüedad posible
infografia <URL> → el sitio sale del dominio (pegada del navegador)
--site sigue existiendo, ahora solo para desambiguar o para acotar la búsqueda
de borradores a un blog.
Solo se ofrecen borradores al elegir automáticamente: un post publicado ya
tiene su imagen, y pedir el guion de uno publicado es la excepción, para la que
está pasar el slug o la URL a mano.
_post_list vuelca a fichero y no a PIPE, copiando el patrón de seo_watch y
remate_watch: un PIPE se trunca a 64 KB en silencio y con --formats html los
cuerpos son largos.
Verificado: las dos formas de URL (EN en www, ES en apex) resuelven su sitio,
el slug suelto encuentra el blog correcto, el slug inexistente sale con un
error claro, y la selección automática coge el más reciente y avisa de cuántos
más hay. El caso "no hay borradores" sale limpio en vez de reventar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Las tres infografías falladas del 27-jul no fallaron al dibujar, fallaron al
decidir qué era cierto. ChatGPT tenía el artículo anterior en el contexto de la
conversación y coló la ficha de Vilas-Boas dentro de la infografía de Talavera,
y los 84 expedientes del Ejército del Aire español dentro de la de Vilas-Boas.
En una llegó a inventarse un dominio, bibliotecavirtual.defesa.gov.br, que no
existe: una brasileñización del español, a un clic de publicarse.
La respuesta no es pedirle al modelo que se porte bien, es no dejarle decidir.
El guion se extrae del artículo (claude -p, coste cero) y se VERIFICA contra el
propio artículo antes de que ChatGPT vea nada. A ChatGPT le llega "maqueta este
texto", no "lee esto y decide qué es cierto".
Decisiones que no son de adorno:
- La verificación es regex sobre el artículo, sin modelo de por medio. Un
modelo autoevaluándose es exactamente lo que ya falló tres veces.
- Tres severidades, sacadas de cómo falló esto en la vida real: nombre propio
fuera del artículo se cae, URL fuera del artículo se cae sin excepciones, y
cifra que no aparece literal solo avisa. Lo tercero porque el artículo dice
"mil novecientas páginas" donde el guion escribe "1.900", y tumbar eso
automáticamente daría más falsos positivos que aciertos. Los nombres propios
fueron el 100% de la contaminación observada.
- Los títulos de panel NO se verifican: "DATOS DEL CASO" es una etiqueta
editorial, no una afirmación sobre el caso, y no tiene por qué aparecer en el
artículo. Verificarlos hacía caer todos los paneles y el informe se volvía
inservible.
- Una mayúscula en inicio de frase no cuenta como nombre propio, o cualquier
viñeta que empiece por verbo ("Llegan…", "Comienza…") sale marcada como
forastera. Las siglas (SBEDV, APRO, OVNI) sí se persiguen en cualquier
posición: son marcador de contaminación.
- Se compara plegando minúsculas y tildes. El modelo escribe "Neron" donde el
artículo dice "Nerón" y eso no es contaminación; la de verdad son palabras
que no están en el artículo de ninguna forma.
- El idioma lo fija --site, no se adivina del texto: es un dato duro que no se
equivoca, y el idioma del guion arrastra el de la imagen.
Verificado con la avería real, no con un caso de laboratorio: al guion
contaminado de Talavera se le caen Vilas-Boas, Martins, Fontes, Bühler, SBEDV,
Creighton, São Francisco de Sales y el dominio inventado, y se eliminan enteros
los dos paneles que se quedan sin viñetas, mientras los legítimos sobreviven
sin un solo falso positivo. Sobre los dos artículos publicados hoy, extrayendo
de cero: 0 descartes y 0 avisos en ambos, guion inglés íntegro en inglés y
español íntegro en español.
Solo lectura sobre Ghost: no toca el post, no publica, no genera imágenes.
El wrapper ~/.local/bin/infografia queda fuera del repo, como seo-check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1) remate-watch ya no elige los enlaces internos a ojo. El paso 3 del prompt
pregunta primero a seo_semantic (afines), que le da los publicados más
parecidos y le marca los que el borrador YA enlaza. Se le dice
explícitamente que es una sugerencia y no una orden, y que si nada pasa de
~0,55 lo diga en el informe en vez de forzar un enlace malo: un enlace
forzado es peor que ninguno.
Para que eso funcione, seo_semantic sabe ahora trabajar con un post que NO
está en el corpus: `ghst post list` sin filtro devuelve solo publicados y
programados, así que el borrador que remate-watch está preparando no
aparecía. Se baja suelto por slug y se embebe al vuelo. Verificado por
equivalencia: los vecinos de un post embebido al vuelo son idénticos, hasta
el cuarto decimal, a los del mismo post cacheado.
2) canibal-watch: a diario a las 07:00 UTC (una hora después de que publique
la cola de agosto). Telegram SOLO si hay algo nuevo.
Solo avisa de pares que además NO se enlazan. Un par muy parecido pero
cosido con un enlace ya le dice a Google cuál manda; avisar de él es ruido.
Los enlazados se guardan igual en el estado, porque hacen falta para
detectar que MÁS TARDE pierdan el enlace (una edición que se lleve por
delante la jerarquía). Con el filtro, el primer aviso baja de 19 líneas a 5
accionables.
Probado por el camino real, arrancándolo con systemctl y no desde una
shell con el PATH bueno: primera pasada avisa (5 pares), segunda se calla,
y con una regresión simulada en el estado la detecta.
No escribe en el blog. Coser o fusionar lo decide Jose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Embebe los 80 artículos de los dos blogs con bge-m3 (ya cargado en ollama,
100% GPU) y sobre los vectores contesta dos cosas: a qué artículos publicados
conviene enlazar desde uno nuevo, y qué pares compiten por la misma búsqueda.
Nunca escribe en el blog: imprime. Quien escribe el enlace es seo_link.py,
y publicar lo decide Jose.
Decisiones que no son de adorno:
- NUNCA se cruzan los dos sitios. bge-m3 es multilingüe y da 0,966 entre un
artículo y su traducción (medido): cruzarlos marcaría todo el espejo ES↔EN
como canibalización, justo lo contrario de la verdad.
- Endpoint de ollama por la ClusterIP del Service, no por la IP del pod (que
cambia en cada reinicio) ni por el Ingress (está tras Authentik). El host la
alcanza por kube-proxy, sin NodePort ni tocar la red.
- Sin keep_alive en las peticiones: bge-m3 es de producción (lo usa
researchowl) y mandar keep_alive:0 lo desalojaría, cobrándole a otro la
recarga.
- Umbral 0,78 calibrado contra el corpus real, no a ojo: el reparto no es
igual en los dos sitios (ES mediana 0,58 / p99 0,75; EN 0,64 / 0,80).
Absoluto y no percentil, porque un umbral relativo siempre encuentra "el 1%
más parecido" aunque no haya problema, y un vigilante que siempre avisa no
sirve.
- Se mira qué enlaces EXISTEN ya en el cuerpo, y en qué DIRECCIÓN. Sugerir un
enlace que ya está es ruido; y decir "se enlazan entre sí" cuando el enlace
va en un solo sentido es mentir sobre lo único accionable.
Caché en ~/.local/state/seo-semantic/, re-embebe solo lo que cambia (hash del
texto). Corpus vía fetch_corpus de seo_link, que ya trae la guarda de corpus
truncado.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
remate-watch (2026-07-23): detecta draft+imagen+sin metas en los dos
Ghost y lanza claude -p con el checklist de remate_prompt.md; verifica
contra Ghost e informa por Telegram; el post queda siempre en draft.
indexnow-watch (2026-07-24): compara el sitemap de posts con el estado
y envía URLs nuevas/actualizadas a api.indexnow.org (Bing/DDG/Yandex).
Las keys y keyLocation viven fuera del repo (~/.local/state/indexnow/).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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.
Faltaba la pieza de ESCRITURA en el cuerpo. El resto de herramientas leen
`html`, que Ghost renderiza venga de donde venga, y por eso el formato les da
igual; para escribir hay que tocar la fuente, y la fuente viene en tres sabores:
lexical con un bloque html los 29 del ES y casi todo el EN
lexical con nodos nativos roswell-341 y yellow-sea (extended-text)
mobiledoc con tarjetas html lo que genera ResearchOwl HOY en los borradores
El tercero se descubrió el 2026-07-21 con el Canarias de prueba: los scripts
que veníamos usando asumían lexical y habrían abortado sobre el próximo
artículo recién generado.
Las salvaguardas son cicatrices de errores reales de estos días: destino en el
corpus del sitio correcto, aviso si el destino está programado (enlace
prematuro → 404 en la ventana), URL con el canónico de cada sitio (EN www, ES
apex), el anchor nunca dentro de un <a> existente, y comprobación de anclas
balanceadas y no anidadas antes de escribir. Sin --apply no toca nada.
Probado en los tres formatos. El de mobiledoc, de punta a punta contra Ghost
con un borrador de usar y tirar: Ghost acepta la escritura, renderiza el
enlace, el mobiledoc sigue siendo válido y el texto queda intacto (2.143
palabras antes y después). Borrador borrado tras la prueba.
Lo que la herramienta NO hace es decidir: en la prueba encontró «Roswell»
dentro de «Roswell Daily Record», que sería un anchor pésimo. Elegir el anchor
sigue siendo humano.
Barrido del 2026-07-21: se buscaron todas las entidades del catálogo ES en la
prosa de los 26 posts con enlazado flojo y se pusieron los 24 enlaces honestos
que había. Los 19 que siguen bajo el umbral es porque no hay más entidades del
corpus nombradas en su texto — el catálogo español se cruza poco y forzar un
enlace temático es justo lo que estas excepciones existen para evitar.
Cada waiver dice qué enlaza y qué nombra la prosa que NO tiene artículo, para
que se pueda revisar cuando el catálogo crezca.
Un falso positivo descartado a mano: el «Roosevelt» de la batalla de Los
Ángeles es el presidente, no el portaaviones del artículo de Gimbal/GoFast.
Fuera el waiver de roswell-1947-preguntas-sin-respuesta: ya tiene 2 enlaces
(el de mayo y PURSUE), así que decía algo falso.
El ES pasa de 143 violaciones accionables a 12 (11 meta_description y 1
meta_title largos, que ya estaban ahí pero tapados por el corte del top-6).
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.
El par Roswell del blog ES se resolvió por la vía que propone la propia regla
(«retitle to a distinct angle» + «interlink deliberately»), no fusionando: el
de mayo es el relato de referencia (3 clics / 152 impresiones en 3 meses) y el
de julio cubre otra cosa — el vídeo de 2025 de los Archivos Nacionales, el
diario de Marcel y la confesión de Haut — pero se presentaba con título de
artículo general y se llevó 1 impresión en 9 días. Retitulado y enlazados en
ambos sentidos; siguen compartiendo caso y año, así que topic.collision va a
avisar siempre: waiver documentado.
Y de paso un fallo mío de ayer: collisions() contaba una vez por FAMILIA en
vez de por SITIO, así que un post con waivers en dos familias del mismo blog
salía como colisión entre sitios. Este par lo destapó. Verificado en los dos
sentidos: vacío ahora, y detecta el duplicado si se inyecta a mano.
El motor se calibró con el corpus inglés y al estrenarlo en zonadeexclusion
daba 6 avisos, 4 de ellos falsos: «que», «los» y «del» pasaban el filtro de
3 caracteres y contaban como identidad de caso. Kenneth Arnold 1947 «colisionaba»
con Roswell 1947 sin compartir más que el año y un artículo determinado.
Dos cambios, y van juntos a propósito:
- Stopwords ES (glue + genéricos del dominio, espejo del bloque inglés).
- _deaccent en el tokenizador: [a-z0-9]+ PARTE en cualquier acento, así que
"Pentágono" era {pent, gono} y "Fenómenos" era {fen, menos} — basura de 3
caracteres que ninguna lista de stopwords puede cubrir, y que además nunca
casaba con el slug de Ghost (que ya viene sin acentos). Sin esto, la mitad
de las stopwords nuevas no llegarían a aplicarse.
Medido sobre los dos corpus antes de tocar nada: ES 6 → 2 avisos, EN idéntico
(mismos 4 pares: PURSUE 3/4 y el par Grusch). Peaje asumido: «los» deja de
identificar a Los Alamos en EN, pero «alamos» sigue ahí y el informe no cambia.
Los 2 avisos ES que quedan son reales: dos artículos publicados sobre Roswell
1947 sin canonical entre ellos, la misma canibalización que ya arreglamos en
el EN con el par Grusch. Queda pendiente de decisión editorial.
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>
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>
El blog ES no tenía forma de llegar desde las herramientas: solo existía el
wrapper ghst-en. Creado ghst-es (gemelo, namespace zona-exclusion, token
efímero del pod, nada en disco) y parametrizado seo_finish.
Ojo al canónico, que va al revés en cada sitio: EN es www, ES es el apex.
seo_rules.SITE_HOST está fijado a EN y NO se parametriza ahí a propósito: ese
fichero se vendoriza byte a byte dentro de ResearchOwl y la CI lo verifica.
Se reasigna en tiempo de ejecución desde use_site(), que deja intacto el
contrato compartido.
Los ficheros de trabajo (imagen convertida, volcado de texto) y los backups
llevan ahora prefijo de sitio, para que un mismo slug en ambos idiomas no se
pise.
Verificado contra los dos sitios: ES detecta las 8 violaciones del borrador
nuevo (todas por dryrun, que ya no escribe SEO) y EN sigue dando 0 en el
artículo belga publicado hoy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Primer artículo pasado por el flujo nuevo (generar → revisión editorial →
imagen → remate). 1 enlace (→ gimbal-gofast, por la referencia a los vídeos
de la Navy); la prosa no nombra ningún otro caso del corpus: la oleada belga
se narra con sus propias fuentes (SOBEPS, De Brouwer, el memo de la DIA).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
El enlace llevaba el anchor 'Project Blue Book' pero apuntaba al artículo de
AARO. No era un error del generador: Levelland se creó el 30-jun y el artículo
de Blue Book no existía hasta el 1-jul, así que el autofill enlazó al destino
más cercano disponible. Al día siguiente quedó obsoleto.
Arreglado: el enlace ahora apunta a project-blue-book (anchor y destino
coinciden) y se ha desenlazado el duplicado que se añadió antes hoy sobre la
segunda mención. Queda 1 enlace, de ahí el waiver: la prosa no nombra ninguna
otra entidad con artículo propio (lo demás es Hynek y 'ball lightning').
Blog completo (30 publicados + 9 programados): 0 violaciones reales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revisión preventiva de la cola programada: 4 posts traían enlazado flojo.
Dos tenían enlace honesto y se aplicó (Project Blue Book, publicado el 1-jul,
así que no hay riesgo de enlace prematuro):
- shag-harbour (17-ago): '…led to the end of the U.S. Air Force's Project
Blue Book' → project-blue-book
- levelland (20-ago): '…led to the closing of Project Blue Book' → idem
Trampa evitada en levelland: su PRIMERA mención de 'Project Blue Book' ya era
el anchor de un enlace a AARO. Insertar ahí habría anidado <a> dentro de <a>.
Se enlazó la segunda mención, que estaba libre; verificado que no hay anidados.
Los otros dos van a waiver por autocontenidos:
- westall: no nombra ningún otro caso del corpus.
- colares: solo cita Varginha (ya enlazado) y 'Operation Saucer', que es su
propia operación y no tiene artículo.
Cola programada: 0 violaciones reales en los 9 posts. El auditor no saltará
según se vayan publicando.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El SEO es DERIVADO del artículo. Hoy ResearchOwl lo genera al crear el
borrador, pero la revisión editorial cambia el texto después → el SEO queda
obsoleto y hay que rehacerlo a mano en cada post. La solución no es parchear
sino re-derivarlo del texto FINAL, y ese es el hueco que cubre esta tool.
Además el alt-text SOLO puede escribirse aquí: cuando autofill.py genera el
SEO la imagen destacada aún no se ha elegido (de ahí su
'feature_image_alt': "" # human adds later). Ese paso humano no ocurría
nunca — los 9 posts publicados entre el 29-jun y el 16-jul salieron sin alt.
prep <slug> paquete de revisión: descarga la imagen destacada y la
convierte a PNG (Ghost sirve webp), vuelca el texto final,
lista el SEO actual con longitudes y las violaciones. READ-ONLY.
apply <slug> aplica un patch vía ghst-en, con backup previo, y RE-VALIDA
después (si sigue incumpliendo, lo dice).
Guardas por límites duros, para convertir en error explicado lo que Ghost
devuelve como 422 mudo:
* feature_image_alt > 191 car. (límite de Ghost, descubierto a base de 422)
* meta_description > 145 / meta_title > 60 (seo_rules)
El criterio (redactar la meta, describir la imagen) lo pone quien invoca —
humano o Claude vía hermes. El script hace lo mecánico y lo verifica.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cierra las 3 incidencias restantes de internal_links.too_few. Ninguno de los
tres nombra en su prosa una entidad con artículo propio (criterio de siempre:
entidad nombrada en el texto, no afinidad temática):
- tassili-najjer: arqueología sahariana, autocontenida.
- missing-scientists: trata de la cadena mediática, no de casos UAP.
- grusch-capitol-june-9 (adelanto): además lleva canonical_url a la
investigación completa, así que sus señales se consolidan allí; forzar
enlaces en un preview superado no aporta.
El cuarto caso (roswell-341) SÍ tenía enlace honesto y se aplicó:
'the Air Force's official Roswell explanation' → roswell-1947-ufo-incident.
Nota técnica: ese post usa nodos lexical nativos (no bloques HTML como
Wilson-Davis), así que hubo que partir el nodo de texto e insertar un nodo
'link' con el mismo esquema que el enlace preexistente.
Con esto el auditor queda a CERO violaciones accionables (venía de 20), lo que
hace que cualquier aviso futuro de seo_watch signifique algo de verdad.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- tehran-1976-iranian-f4-ufo-incident: 0 enlaces, prosa autocontenida — solo
nombra a Klass, Jafari y el informe de la DIA; ningún caso con artículo
propio. Los candidatos temáticos (Malmstrom, Levelland, Manises) no
aparecen en el texto, así que enlazarlos sería forzado. Revisado 2026-07-18.
- chiles-military-...-cefaas: waiver que ya estaba en el working tree sin
commitear (1 enlace → pursue-release-3; caso chileno autocontenido).
Efecto en el auditor: internal_links accionables 9 → 7 (el otro punto lo
cierra el post Wilson-Davis, que pasa a 2 enlaces reales).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dos posts sobre el mismo caso se canibalizan en la SERP (2026-07-10: se
publicó un segundo Kecksburg con otro ya programado, y había un título
casi gemelo del de Malmstrom en la cola). Nueva función pura
topic_collision(post, corpus) en seo_rules.py — corpus-aware, fuera de
RULES — que dispara por: caso+año compartidos (años < 2020; los de la
era de noticias no son identidad de caso), hooks de título ≥70%
similares (calibrado: el par nuclear da 0.742, el par legítimo más
cercano 0.65), o slugs ≥50% solapados. seo_validate.py obtiene el corpus
via ghst-en (--limit all) y lo reporta como grupo topic_collision;
--no-collision lo omite para follow-ups intencionados.
Calibrado contra los 36 posts reales: dispara solo el duplicado
Kecksburg (HIGH) y el par Grusch preview/evento (MED, intencionado).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Versioning ~/seo-tools as critical SEO infrastructure (was home-dir only).
Canonical home of seo_rules.py — ResearchOwl will vendor a copy with a CI diff
guard against this repo. No secrets; Ghost access is via the ghst-en wrapper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>