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>