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>
This commit is contained in:
ChemaVX
2026-07-21 16:08:03 +00:00
co-authored by Claude Opus 4.8
parent d381c0225c
commit 9c76ed87ff
2 changed files with 37 additions and 18 deletions
+20 -11
View File
@@ -225,25 +225,34 @@ def main():
+ (f"\n{ctx}" if ok else " (no aparece en la prosa)")) + (f"\n{ctx}" if ok else " (no aparece en la prosa)"))
campo, valor = "lexical", json.dumps(lex, ensure_ascii=False) campo, valor = "lexical", json.dumps(lex, ensure_ascii=False)
else: else:
# Los dos formatos acaban en diccionarios con clave "html", así que se
# tratan igual: una LISTA de trozos. Antes el mobiledoc solo miraba
# cards[0], y un cuerpo repartido en varias tarjetas —que es justo lo
# que pasa cuando el artículo lleva una imagen intercalada— respondía
# «no aparece en la prosa» estando el anchor en la segunda.
if fmt == "lexical-html": if fmt == "lexical-html":
doc = json.loads(post["lexical"]) doc = json.loads(post["lexical"])
trozo = doc["root"]["children"][0] trozos = [doc["root"]["children"][0]]
get_html, set_html = (lambda: trozo["html"]), (lambda h: trozo.__setitem__("html", h)) campo = "lexical"
else: else:
doc = json.loads(post["mobiledoc"]) doc = json.loads(post["mobiledoc"])
cards = [c for c in doc.get("cards", []) if c and c[0] == "html"] trozos = [c[1] for c in doc.get("cards", []) if c and c[0] == "html"]
if not cards: if not trozos:
sys.exit(f"{a.slug}: mobiledoc sin tarjetas html") sys.exit(f"{a.slug}: mobiledoc sin tarjetas html")
tarjeta = cards[0][1] campo = "mobiledoc"
get_html, set_html = (lambda: tarjeta["html"]), (lambda h: tarjeta.__setitem__("html", h)) if len(trozos) > 1:
html = get_html() print(f" ({len(trozos)} trozos de html; se busca en todos)")
for anchor, url in pares: for anchor, url in pares:
html, ok, ctx = link_in_html(html, anchor, url) ok, ctx = False, ""
for t in trozos:
t["html"], ok, ctx = link_in_html(t["html"], anchor, url)
if ok:
break
print(f" {'+' if ok else ''} «{anchor}» → {url.split('/')[-2][:34]}" print(f" {'+' if ok else ''} «{anchor}» → {url.split('/')[-2][:34]}"
+ (f"\n{ctx}" if ok else " (no aparece en la prosa)")) + (f"\n{ctx}" if ok else " (no aparece en la prosa)"))
revisar_html(html, a.slug) for t in trozos:
set_html(html) revisar_html(t["html"], a.slug)
campo = "lexical" if fmt == "lexical-html" else "mobiledoc"
valor = json.dumps(doc, ensure_ascii=False) valor = json.dumps(doc, ensure_ascii=False)
if not a.apply: if not a.apply:
+17 -7
View File
@@ -477,18 +477,27 @@ def main():
partes.append(txt); huellas[s] = fp; hallazgos |= hall partes.append(txt); huellas[s] = fp; hallazgos |= hall
report = "🔎 SEO watch\n\n" + "\n\n".join(partes) report = "🔎 SEO watch\n\n" + "\n\n".join(partes)
fp = json.dumps(huellas, sort_keys=True)
prev = "" # Estado POR SITIO, y solo se toca el de los sitios revisados. Antes se
# guardaba una única huella de los dos juntos, así que 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 exactamente lo que este no puede permitirse.
previo = {}
try: try:
with open(STATE) as f: with open(STATE) as f:
prev = json.load(f).get("fingerprint", "") guardado = json.load(f)
except FileNotFoundError: previo = guardado.get("sites") or {}
except (FileNotFoundError, ValueError):
pass pass
changed = fp != prev
cambiados = [s for s in sitios if huellas[s] != previo.get(s)]
changed = bool(cambiados)
print(report) print(report)
print(f"\n[cambios vs última ejecución: {'' if changed else 'no'}]") print(f"\n[cambios vs última ejecución: "
f"{', '.join(cambiados) if cambiados else 'ninguno'}]")
if a.dry_run: if a.dry_run:
return 0 return 0
@@ -500,8 +509,9 @@ def main():
print(f"[telegram: {'enviado' if ok else 'FALLO'}]") print(f"[telegram: {'enviado' if ok else 'FALLO'}]")
os.makedirs(os.path.dirname(STATE), exist_ok=True) os.makedirs(os.path.dirname(STATE), exist_ok=True)
previo.update(huellas) # fusiona: no pisa el sitio que no se revisó
with open(STATE, "w") as f: with open(STATE, "w") as f:
json.dump({"fingerprint": fp}, f) json.dump({"sites": previo}, f)
return 0 return 0