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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9c76ed87ff
commit
cdc9a9baca
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user