Capa 1 del plan del 2026-07-29: lo que se arregla en el generador se arregla
una vez; lo que se arregla con un vigilante es trabajo para siempre.
1) MAYÚSCULA DE ORACIÓN EN ES. El prompt no decía nada de capitalización, solo
«write in SPANISH». Un modelo entrenado en inglés escribe Title Case en
cuanto le pides un «SEO title»: por eso hubo que corregir a mano NOVENTA Y
UN campos del blog ES.
Va en el prompt y NO en un validador, y esto es una decisión medida, no una
comodidad. Distinguir «Lo que Revelan» (mal) de «el Roswell de Pennsylvania»
(bien) exige saber qué palabra es nombre propio. Probé dos detectores
deterministas contra el corpus real antes de escribir ninguno:
- por proporción de palabras capitalizadas: 100% de falsos positivos en
títulos densos en topónimos («Kecksburg 1965: el Roswell de Pennsylvania»
da 2/2)
- por vocabulario en minúscula del corpus: se deja LA MITAD de los malos
(«Ocultan» nunca aparece en minúscula) y marca «Proyecto Libro Azul» y
«Ejército del Aire» como si fueran errores
Un gate que rechaza borradores válidos es peor que la avería. La red debajo
sigue siendo seo_watch.check_title_case, que sí tiene el corpus delante y
desde hoy corre a diario.
2) EL ARTÍCULO, COMO DATO. Va entre <ARTICLE>…</ARTICLE> y los dos prompts
declaran que lo de dentro no son instrucciones. El cuerpo se redacta a
partir de fuentes scrapeadas: una página con «ignore previous instructions»
entraba en el mensaje sin que nadie lo mirara.
3) EL MOTOR APUNTABA AL BLOG EQUIVOCADO. El comentario de autofill decía que
rules contaba CERO enlaces internos en ES por estar clavado al host del EN,
y que no se podía arreglar por el vendorizado byte a byte. Ya se puede: el
canónico ganó usar_sitio() y el vendorizado se resincronizó — llevaba
desfasado desde el 21-jul, o sea que la CI habría fallado en el próximo
build. _check_con_sitio() apunta el motor al idioma correcto y RESTAURA el
global, con la condición de carrera documentada por si algún día se
paraleliza la generación.
6 tests nuevos (30 en total). Lo que NO se puede verificar aquí: que el prompt
funcione de verdad. Eso solo lo dice el primer borrador ES que genere, y quien
lo va a comprobar es el vigilante diario.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ALLOWED_TAGS son SLUGS y Ghost casa los tags de un post por NOMBRE. Mandarlos
como {"name": slug} funcionaba en EN por casualidad — allí los tags se llaman
igual que su slug ("military-cases") — y en ES rompía: no hay ningún tag
llamado "casos-militares" (se llama "Casos Militares"), así que Ghost creaba
uno nuevo con ese nombre y, con el slug ya pillado, lo dejaba en
`casos-militares-2`.
Encontrado el 2026-07-29 al preparar el hub: 5 tags duplicados, 7 posts
repartidos entre dos archivos flacos cada uno, y los cinco `-2` ofrecidos a
Google en sitemap-tags.xml. Los datos ya están fusionados en Ghost; esto es
para que no vuelva.
Se resuelve el slug a ID contra la Admin API, que es lo único no ambiguo justo
en el punto donde falla la ambigüedad. Un slug que no exista se DESCARTA con
aviso en vez de crearse: la lista es cerrada, así que no existir significa que
está mal escrita, y crear el tag es exactamente el bug. Si no resuelve nada, o
si Ghost no contesta, se cae al comportamiento anterior antes que publicar un
post sin ninguna categoría.
7 tests nuevos, incluido uno para EN — donde el bug era invisible por la
coincidencia nombre==slug y el resolutor no debe depender de ella.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cada enlace interno que el generador escribía en un borrador español apuntaba
a www.zonadeexclusion.com y se comía un 301. El canónico de los dos blogs está
INVERTIDO —EN es www, ES es el apex—, la misma inversión que provocó el 522 y
de la que ya avisa el wrapper ghst-es.
No es teórico: el enlace www suelto que apareció en los-villares salió de aquí.
El corpus ES está limpio hoy, pero el próximo /generate con enlaces lo habría
reintroducido. Nada río abajo lo paraba: seo_watch solo ve el enlace DESPUÉS de
publicar, y ningún test cubría el host.
Añado los dos tests que faltaban, uno por idioma, para fijar la inversión. El
del ES falla contra el valor viejo con el href entero en el mensaje.
Sin tocar rules.py, así que el vendor-sync sigue en verde. Queda documentado el
matiz que este cambio NO arregla: rules.internal_links cuenta cero enlaces en
ES bajo cualquiera de los dos hosts, porque ese módulo está clavado al host EN
(se vendoriza byte a byte y la CI lo verifica). Inofensivo hoy —
internal_links.too_few no bloquea en borrador.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Al publicar el draft EN en Ghost, el título propuesto se compara contra
los posts published+scheduled del sitio (corpus vía Admin API — incluye
la cola programada, justo el caso del doble Kecksburg del 2026-07-10) con
el topic_collision vendorizado. Si colisiona, el notice de Telegram lleva
un bloque "🚨 Posible colisión de tema" en las tres rutas (live, dryrun,
bare). Nunca bloquea: el draft se crea igual, el humano decide (fusionar,
retitular o enlazar a propósito). Solo lang=en (stopwords inglesas).
Títulos ajenos saneados de entidades Markdown (regla de _safe_send).
Aislamiento: cualquier fallo del check → notice sin bloque y warning en
logs, jamás rompe la publicación.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Un ?url= protocol-relative (//publisher.com/...) caía por el startswith('http')
y devolvía el link apiclick.aspx crudo, que pasaba blacklist y relevancia y
acababa raspado como página de redirect/consent de Bing. Ahora se antepone
https: a los protocol-relative y, si no se puede extraer URL válida de un
apiclick, se devuelve '' para que el caller lo salte.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nuevo _seed_news: registra el feed de Bing News como fuente rss y
_extract_rss (F3) siembra sus entries — sin lógica de parseo duplicada.
_unwrap_news_link extrae la URL real del publisher del ?url= de
apiclick.aspx.
Bing y no Google News como pedía el plan: verificado en vivo que los
links de news.google.com/rss/articles/ acaban en muro de consent (UE) y
el id AU_yqL solo se resuelve vía batchexecute interno — habría sembrado
URLs muertas. Los de Bing llevan la URL real en texto plano.
Flag off por defecto: se activará deliberadamente vía deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
detect_source_type ya clasificaba 'rss' pero no había rama en
_process_source: los feeds caían a _extract_web y trafilatura no saca
nada del XML (se descartaban por cortos). Ahora _extract_rss parsea el
feed (feedparser en hilo, como el monitor de noticias) y siembra sus
entries como fuentes nuevas: filtro de relevancia por título/URL,
blacklist, dedupe, cap de 20 y respeto de max_depth. El feed en sí se
marca skipped — es puro descubrimiento, no tiene contenido que trocear.
De paso, la detección pasa de substring puro (clasificaba /feedback y
/atomic como rss) a RSS_RE con límites de palabra. Tests de ambos.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cierra el TODO de ALLOWED_TAGS: el allow-list ES son los 6 tags del
núcleo vivo de zonadeexclusion (Admin API, 2026-07-03), espejo de la
lista EN confirmada: uap, desclasificados, casos-militares,
casos-clasicos, investigacion, casos-espana. Quedan fuera la cola de
tags de 1 post que el modelo inventó antes de constreñir y el duplicado
investigacion-2 (colisión de mayúsculas en Ghost).
La regla anti-legacy 'never use "investigacion"' del prompt pasa a ser
solo-EN: en ES ese slug es el tag canónico del allow-list.
Tests de las funciones puras (_coerce, _system_prompt) por idioma.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .gitignore para Python, .env, *.db y artefactos de editor/OS
- git rm --cached de todos los __pycache__/*.pyc previamente trackeados
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>