seo: arreglar la capitalización ES en el ORIGEN, y el motor apuntando al blog malo
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
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>
This commit is contained in:
+37
-1
@@ -33,7 +33,21 @@ import re
|
||||
import unicodedata
|
||||
from collections import namedtuple
|
||||
|
||||
SITE_HOST = "theexclusionzone.com"
|
||||
# Host del sitio que se está auditando. Decide qué href cuenta como enlace
|
||||
# INTERNO, así que auditar el ES con el host del EN daría cero enlaces internos
|
||||
# en los 31 posts: un informe entero de hallazgos falsos. Sigue siendo el EN por
|
||||
# defecto para no cambiarle el comportamiento a nadie que ya lo use.
|
||||
SITE_HOSTS = {"en": "theexclusionzone.com", "es": "zonadeexclusion.com"}
|
||||
SITE_HOST = SITE_HOSTS["en"]
|
||||
|
||||
|
||||
def usar_sitio(site):
|
||||
"""Apunta el motor de reglas a uno de los dos blogs. Devuelve el host."""
|
||||
global SITE_HOST
|
||||
if site not in SITE_HOSTS:
|
||||
raise ValueError(f"sitio desconocido: {site!r}")
|
||||
SITE_HOST = SITE_HOSTS[site]
|
||||
return SITE_HOST
|
||||
|
||||
# ---- thresholds (single source of truth, reused by validator) -------------
|
||||
META_TITLE_MAX = 60
|
||||
@@ -287,6 +301,19 @@ def _sig_tokens(title, slug):
|
||||
if len(t) >= 3 and t not in TOPIC_STOPWORDS and not _YEAR_RE.fullmatch(t)}
|
||||
|
||||
|
||||
def _canoniza_a(post):
|
||||
"""Slug al que este post declara canónico, o None si no declara ninguno.
|
||||
|
||||
Ghost guarda una URL completa; aquí solo interesa el último segmento, que es
|
||||
lo único comparable con el slug de otro post del mismo sitio.
|
||||
"""
|
||||
url = _s(post.get("canonical_url"))
|
||||
if not url:
|
||||
return None
|
||||
resto = url.split("?")[0].split("#")[0].rstrip("/")
|
||||
return resto.rsplit("/", 1)[-1] or None
|
||||
|
||||
|
||||
def _hook(title):
|
||||
return _s(title).split(":")[0].strip().lower()
|
||||
|
||||
@@ -307,10 +334,19 @@ def topic_collision(post, corpus):
|
||||
c_hook = _hook(post.get("title"))
|
||||
c_slug_toks = _tokens(_s(post.get("slug")).replace("-", " "))
|
||||
|
||||
c_canon = _canoniza_a(post)
|
||||
|
||||
for other in corpus:
|
||||
if other.get("id") == post.get("id"):
|
||||
continue
|
||||
o_title, o_slug = _s(other.get("title")), _s(other.get("slug"))
|
||||
# Un par consolidado NO es una colisión: es la solución a una colisión.
|
||||
# Cuando uno de los dos declara al otro como canónico, Google ya sabe
|
||||
# cuál manda y Ghost excluye al secundario del sitemap. Marcarlo sería
|
||||
# pedir que se arregle algo que está arreglado — y el aviso, al no poder
|
||||
# resolverse nunca, enseña a ignorar al validador.
|
||||
if c_canon == o_slug or _canoniza_a(other) == _s(post.get("slug")):
|
||||
continue
|
||||
o_years = _case_years(o_title, o_slug)
|
||||
o_sig = _sig_tokens(o_title, o_slug)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user