Commit Graph
25 Commits
Author SHA1 Message Date
ChemaVXandClaude Opus 5 2512144be5 capa semántica: enganchada a remate-watch + vigilante de canibalización
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>
2026-07-25 21:06:59 +00:00
ChemaVXandClaude Opus 5 9e3dfc1039 capa semántica: enlaces internos afines y canibalización
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>
2026-07-25 20:59:15 +00:00
ChemaVXandClaude Fable 5 cdc9a9baca vigilantes remate-watch e indexnow-watch (timers systemd del master)
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>
2026-07-24 09:24:13 +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 0844b7596e seo_link: escribir enlaces en el cuerpo, sea cual sea el formato (Tool F)
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.
2026-07-21 08:36:01 +00:00
ChemaVX cf61b8bda3 seo_exceptions: waivers de enlazado del backlog ES
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).
2026-07-21 08:10:48 +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
ChemaVX ea6c24927a seo_exceptions: waivers del par Roswell ES + arregla collisions()
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.
2026-07-21 07:27:50 +00:00
ChemaVX 31c9d3e7b8 seo_rules: el detector de colisiones ya entiende español
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.
2026-07-21 07:15:40 +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
ChemaVX f9f90f8999 seo-watch.service: la descripción ya cubre los dos blogs 2026-07-20 16:10:26 +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 34263469e4 seo_finish: soporte para el sitio ES (--site en|es)
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>
2026-07-20 16:06:43 +00:00
ChemaVXandClaude Opus 4.8 8c83fb81fc waiver: belgium-1989-1990 (remate previo a publicar)
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>
2026-07-20 11:24:58 +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 dc0c1cfa73 levelland: repunta el enlace a su destino correcto + waiver
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>
2026-07-20 10:46:16 +00:00
ChemaVXandClaude Opus 4.8 85a78c6014 waivers: westall y colares (revisados antes de publicarse)
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>
2026-07-20 10:40:32 +00:00
ChemaVXandClaude Opus 4.8 162f8032f3 seo_finish: remate SEO posterior a la revisión editorial (Tool E)
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>
2026-07-20 10:14:52 +00:00
ChemaVXandClaude Opus 4.8 c4dba06fb1 waivers de enlazado: tassili, missing-scientists y el adelanto de Grusch
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>
2026-07-20 09:04:05 +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
ChemaVXandClaude Opus 4.8 b5ce7638bd waivers de internal_links: tehran-1976 y chile-cefaa
- 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>
2026-07-18 21:29:28 +00:00
ChemaVXandClaude Fable 5 bae1cc949c feat: check de colisión de tema (topic.collision) en rules + validate
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>
2026-07-10 11:06:39 +00:00
ChemaVXandClaude Opus 4.8 beae7c154a Initial commit: SEO rule engine, auditor, validator, exceptions, mirror
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>
2026-06-24 08:01:44 +00:00