Files
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

179 lines
6.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""indexnow_watch — avisa a IndexNow (Bing/DDG/Yandex/Naver) de URLs nuevas o
actualizadas en los dos blogs, comparando el sitemap de posts con el estado.
Patrón seo-watch/remate-watch: timer systemd en el master, estado en disco,
Telegram solo cuando pasa algo (📡 envío, 🚨 fallo). Google NO participa en
IndexNow: a Google lo cubre el sitemap + crawl diario, esto es la vía rápida
del resto de buscadores.
Las keys NO viven en este fichero (el repo podría publicarse): están en
CONFIG (~/.local/state/indexnow/config.json) y el fichero {key}.txt se sirve
desde content/files/indexnow/ del PVC de cada Ghost (verificado 200 por el
dominio real el 2026-07-24).
Uso:
indexnow_watch.py --dry-run # qué enviaría, sin enviar
indexnow_watch.py --bootstrap # primera vez: envía TODO el sitemap
indexnow_watch.py # normal: solo lo nuevo/cambiado
Ensayo de fallo: INDEXNOW_ENDPOINT=https://127.0.0.1:9/ indexnow_watch.py
"""
import argparse
import base64
import json
import os
import re
import subprocess
import sys
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
STATE_DIR = Path.home() / ".local/state/indexnow"
STATE_FILE = STATE_DIR / "state.json"
CONFIG_FILE = STATE_DIR / "config.json"
ENDPOINT = os.environ.get("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow")
TG_SECRET = ("monitoring", "grafana-telegram-infisical")
# host y sitemap por sitio; key/keyLocation vienen de CONFIG_FILE
SITES = {
"es": {"host": "zonadeexclusion.com",
"sitemap": "https://zonadeexclusion.com/sitemap-posts.xml"},
"en": {"host": "www.theexclusionzone.com",
"sitemap": "https://www.theexclusionzone.com/sitemap-posts.xml"},
}
def sh(cmd):
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
def telegram(text):
r = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} -o jsonpath='{{.data.TELEGRAM_BOT_TOKEN}}'")
token = base64.b64decode(r.stdout).decode()
r2 = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} -o jsonpath='{{.data.TELEGRAM_CHAT_ID}}'")
chat = base64.b64decode(r2.stdout).decode()
data = urllib.parse.urlencode({"chat_id": chat, "text": text[:3900]}).encode()
req = urllib.request.Request(f"https://api.telegram.org/bot{token}/sendMessage", data=data)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read()).get("ok", False)
def fetch_sitemap(url):
"""Devuelve {url: lastmod} del sitemap de posts."""
req = urllib.request.Request(url, headers={"User-Agent": "indexnow-watch/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
xml = resp.read().decode()
out = {}
for m in re.finditer(r"<url>(.*?)</url>", xml, re.S):
block = m.group(1)
loc = re.search(r"<loc>\s*(.*?)\s*</loc>", block)
lastmod = re.search(r"<lastmod>\s*(.*?)\s*</lastmod>", block)
if loc:
out[loc.group(1)] = lastmod.group(1) if lastmod else ""
return out
def submit(site_cfg, urls):
"""POST a IndexNow. 200/202 = aceptado."""
body = json.dumps({
"host": site_cfg["host"],
"key": site_cfg["key"],
"keyLocation": site_cfg["keyLocation"],
"urlList": urls,
}).encode()
req = urllib.request.Request(ENDPOINT, data=body,
headers={"Content-Type": "application/json; charset=utf-8"})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status
def load_json(path, default):
try:
return json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return default
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--bootstrap", action="store_true",
help="permite el primer envío masivo con estado vacío")
ap.add_argument("--site", choices=["en", "es", "both"], default="both")
args = ap.parse_args()
STATE_DIR.mkdir(parents=True, exist_ok=True)
config = load_json(CONFIG_FILE, {})
state = load_json(STATE_FILE, {})
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
report, failed = [], False
for site in (["en", "es"] if args.site == "both" else [args.site]):
cfg = dict(SITES[site])
if site not in config or "key" not in config[site]:
print(f"[{site}] sin key en {CONFIG_FILE}, me lo salto", file=sys.stderr)
failed = True
continue
cfg.update(config[site])
known = state.get(site, {})
try:
current = fetch_sitemap(cfg["sitemap"])
except Exception as e:
report.append(f"🚨 indexnow-watch [{site.upper()}]: no pude leer el sitemap: {e}")
failed = True
continue
pending = [u for u, lm in current.items() if known.get(u) != lm]
if not known and pending and not args.bootstrap:
print(f"[{site}] estado vacío y {len(pending)} URLs: usa --bootstrap "
"para el primer envío masivo", file=sys.stderr)
continue
if not pending:
print(f"[{site}] sin novedades ({len(current)} URLs en sitemap)")
# poda de URLs despublicadas para que una republicación reenvíe
state[site] = {u: lm for u, lm in current.items() if u in known}
continue
print(f"[{site}] {len(pending)} URL(s) para IndexNow:")
for u in pending:
print(f" {u}")
if args.dry_run:
continue
try:
status = submit(cfg, pending)
except Exception as e:
status, err = None, e
if status in (200, 202):
state[site] = dict(current)
slugs = ", ".join(u.rstrip("/").rsplit("/", 1)[-1] for u in pending[:6])
extra = f" (+{len(pending) - 6} más)" if len(pending) > 6 else ""
report.append(f"📡 IndexNow [{site.upper()}]: {len(pending)} URL(s) "
f"enviadas (HTTP {status}): {slugs}{extra}")
else:
failed = True
detail = f"HTTP {status}" if status else f"{err}"
report.append(f"🚨 indexnow-watch [{site.upper()}]: envío FALLÓ ({detail}); "
f"{len(pending)} URL(s) quedan pendientes para el próximo tick")
if not args.dry_run:
STATE_FILE.write_text(json.dumps(state, indent=1))
if report:
try:
telegram("\n\n".join(report))
except Exception as e:
print(f"telegram falló: {e}", file=sys.stderr)
failed = True
for line in report:
print(line)
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()