diff --git a/seo_estado.py b/seo_estado.py index 3174296..94dffa5 100644 --- a/seo_estado.py +++ b/seo_estado.py @@ -53,6 +53,10 @@ SITES = { } AQUI = os.path.dirname(os.path.abspath(__file__)) ROBOTS_SNAP = os.path.join(AQUI, "robots-esperado.json") + +# Hay UNA sola cuenta de X para los dos blogs, y es la inglesa (confirmado por +# Jose el 2026-07-30). El autopost del RSS inglés y el CLI `ze` tiran de ella. +HANDLE_X = "@ExclusionZoneEN" UA = "seo-estado/1.0 (+https://chemavx.xyz)" TIMEOUT = 25 @@ -221,10 +225,19 @@ def check_head(site, cfg, posts): out.append("SCHEMA DUPLICADO: " + " + ".join( f"{t}«{hd}»" for t, hd in arts) + " — Google elige cuál usa") + # Comparar contra el handle REAL, no contra la lista de valores malos que se + # nos ocurran. Este chequeo solo miraba el default de Ghost (@ghost), y por + # eso se le pasó lo peor de los dos: el ES apuntaba a @ZonaDeExclusion, un + # handle que no existe. Un handle ajeno o abandonado es más grave que el + # default, porque cualquiera puede reclamarlo y quedarse con la atribución + # de las tarjetas. Solo hay UNA cuenta de X para los dos blogs. tw = re.findall(r'name="twitter:site" content="([^"]*)"', h) - if tw and tw[0].lower() in ("@ghost", "ghost"): - out.append(f"twitter:site sigue en el default de Ghost ({tw[0]}): " - "atribuye las tarjetas a la cuenta de Ghost") + if not tw: + out.append("no hay twitter:site: las tarjetas de X salen sin atribuir") + elif tw[0].lower() != HANDLE_X.lower(): + que = (" (el default de Ghost, atribuye las tarjetas a la cuenta de Ghost)" + if tw[0].lower() in ("@ghost", "ghost") else "") + out.append(f"twitter:site es {tw[0]}{que} y debería ser {HANDLE_X}") pub_fb = re.findall(r'property="article:publisher" content="([^"]*)"', h) if pub_fb and "facebook.com/ghost" in pub_fb[0]: out.append("article:publisher sigue en el default de Ghost " diff --git a/test_seo_estado.py b/test_seo_estado.py index 9fe22a6..4f4eb6d 100644 --- a/test_seo_estado.py +++ b/test_seo_estado.py @@ -193,6 +193,51 @@ def test_detecta_dos_archivos_de_tag_con_el_mismo_title(): assert any("mismo " in l for l in salida), salida +# ───────── twitter:site: el handle bueno, no «cualquiera menos @ghost» ───────── + +def _head_falso(html): + """Sustituye la descarga del HTML público por una cabecera de mentira.""" + return lambda url: html + + +POST_MIN = [{"slug": "p", "status": "published"}] + + +def _check_head_con(html): + orig = E.trae + E.trae = _head_falso(html) + try: + return E.check_head("es", E.SITES["es"], POST_MIN) + finally: + E.trae = orig + + +def test_un_handle_ajeno_no_pasa_por_bueno(): + """El chequeo solo miraba el default de Ghost, así que se le colaba lo PEOR + de los dos: el ES apuntaba a @ZonaDeExclusion, un handle que no existe. Un + handle abandonado lo puede reclamar cualquiera y quedarse la atribución.""" + salida = _check_head_con('<meta name="twitter:site" content="@ZonaDeExclusion">') + assert any("@ZonaDeExclusion" in l and "debería ser" in l for l in salida), salida + + +def test_el_handle_bueno_no_genera_hallazgo(): + salida = _check_head_con(f'<meta name="twitter:site" content="{E.HANDLE_X}">') + assert not any("twitter:site" in l for l in salida), salida + + +def test_el_default_de_ghost_sigue_diciendo_que_es_el_default(): + """Regresión: al pasar a comparar contra el handle real, el caso @ghost tiene + que seguir explicando POR QUÉ está mal, no solo que no coincide.""" + salida = _check_head_con('<meta name="twitter:site" content="@ghost">') + assert any("default de Ghost" in l for l in salida), salida + + +def test_sin_twitter_site_tambien_avisa(): + """Vaciar el ajuste en Ghost quita el meta entero. Silencio no es OK.""" + salida = _check_head_con("<title>nada") + assert any("no hay twitter:site" in l for l in salida), salida + + if __name__ == "__main__": fallos = 0 for nombre, fn in sorted(globals().items()):