From cdc9a9bacaaf0a0ad6357fa7b9ce91f6d7f3709d Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Fri, 24 Jul 2026 09:24:13 +0000 Subject: [PATCH] vigilantes remate-watch e indexnow-watch (timers systemd del master) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- indexnow-watch.service | 14 ++ indexnow-watch.timer | 9 ++ indexnow_watch.py | 178 +++++++++++++++++++++++ remate-watch.service | 18 +++ remate-watch.timer | 10 ++ remate_prompt.md | 73 ++++++++++ remate_watch.py | 323 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 625 insertions(+) create mode 100644 indexnow-watch.service create mode 100644 indexnow-watch.timer create mode 100755 indexnow_watch.py create mode 100644 remate-watch.service create mode 100644 remate-watch.timer create mode 100644 remate_prompt.md create mode 100644 remate_watch.py diff --git a/indexnow-watch.service b/indexnow-watch.service new file mode 100644 index 0000000..7259326 --- /dev/null +++ b/indexnow-watch.service @@ -0,0 +1,14 @@ +[Unit] +Description=indexnow-watch — envía URLs nuevas/actualizadas de los blogs a IndexNow (Bing/DDG/Yandex) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=chemavx +WorkingDirectory=/home/chemavx/seo-tools +Environment=HOME=/home/chemavx +Environment=KUBECONFIG=/home/chemavx/.kube/config +Environment=PATH=/home/chemavx/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/python3 /home/chemavx/seo-tools/indexnow_watch.py +TimeoutStartSec=300 diff --git a/indexnow-watch.timer b/indexnow-watch.timer new file mode 100644 index 0000000..26a71a5 --- /dev/null +++ b/indexnow-watch.timer @@ -0,0 +1,9 @@ +[Unit] +Description=indexnow-watch cada hora (a y 20, para no coincidir con los ticks de remate-watch) + +[Timer] +OnCalendar=*-*-* *:20:00 +RandomizedDelaySec=120 + +[Install] +WantedBy=timers.target diff --git a/indexnow_watch.py b/indexnow_watch.py new file mode 100755 index 0000000..354e346 --- /dev/null +++ b/indexnow_watch.py @@ -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"(.*?)", xml, re.S): + block = m.group(1) + loc = re.search(r"\s*(.*?)\s*", block) + lastmod = re.search(r"\s*(.*?)\s*", 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() diff --git a/remate-watch.service b/remate-watch.service new file mode 100644 index 0000000..f1450b6 --- /dev/null +++ b/remate-watch.service @@ -0,0 +1,18 @@ +[Unit] +Description=remate-watch — remate editorial+SEO automático de borradores con imagen (EN + ES) +Documentation=file:///home/chemavx/seo-tools/remate_watch.py +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=chemavx +WorkingDirectory=/home/chemavx/seo-tools +Environment=HOME=/home/chemavx +Environment=KUBECONFIG=/home/chemavx/.kube/config +# El PATH lleva el node de nvm porque los wrappers ghst-en/ghst-es y claude lo +# necesitan; sin él, mueren con 127 bajo systemd (feedback_systemd_path_node_clis). +Environment=PATH=/home/chemavx/.local/bin:/home/chemavx/.nvm/versions/node/v24.14.1/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/python3 /home/chemavx/seo-tools/remate_watch.py +# Un remate real (revisión editorial completa) puede irse a 45 min; margen. +TimeoutStartSec=5400 diff --git a/remate-watch.timer b/remate-watch.timer new file mode 100644 index 0000000..7767609 --- /dev/null +++ b/remate-watch.timer @@ -0,0 +1,10 @@ +[Unit] +Description=remate-watch cada 15 min (detecta borradores con imagen listos para rematar) + +[Timer] +OnCalendar=*:00/15 +RandomizedDelaySec=60 +# Sin Persistent: si se pierde un tick da igual, el siguiente detecta lo mismo. + +[Install] +WantedBy=timers.target diff --git a/remate_prompt.md b/remate_prompt.md new file mode 100644 index 0000000..02a43c5 --- /dev/null +++ b/remate_prompt.md @@ -0,0 +1,73 @@ +Eres el rematador editorial automático de la publicación. Te ha lanzado el +vigilante remate-watch en modo NO interactivo: no hay nadie al otro lado, así +que no hagas preguntas ni pidas confirmación — decide y ejecuta. Tu último +mensaje se envía tal cual a Telegram como informe (se lee en un móvil). + +POST A REMATAR +- Sitio: $site_name — canónico $canonical ($canon_note) +- id: $id +- slug actual: $slug +- título: $title +- CLI: $cli (wrapper con token efímero; p.ej. `$cli post get $id --formats html --json`) + +Invoca PRIMERO el skill exclusion-zone (contexto editorial, límites SEO y +gotchas). Las herramientas viven en ~/seo-tools/ y todas aceptan `--site $site`. + +CHECKLIST — en este orden (el SEO se deriva del texto FINAL, nunca antes): + +1) REVISIÓN EDITORIAL — lo más importante; aquí prima la calidad del artículo. + - Descarga el contenido completo y léelo entero, de principio a fin. + - Corrige erratas, gramática y puntuación; pule frases donde claramente + ganen; verifica la coherencia interna (fechas, nombres y cifras + consistentes a lo largo del texto) y que se distinga hecho documentado de + especulación (línea editorial de la casa). + - En el sitio ES: encabezados en mayúscula de oración, nada de Title Case + inglés. + - El cuerpo puede venir en lexical (bloque html único o nodos nativos) o en + mobiledoc (tarjetas html; es lo que genera ResearchOwl en los borradores + nuevos). Respeta el formato de origen. Antes de editar el cuerpo, guarda + copia pristine en ~/link-batch-backup (JSON con id, updated_at y el + lexical/mobiledoc, con timestamp en el nombre — el mismo patrón que usa + seo_link.py). + - Lo que exija criterio del autor (afirmaciones sin fuente que no puedas + verificar, párrafos que reescribirías enteros, dudas factuales): NO lo + toques; anótalo para el informe. + +2) SLUG — corto y con keywords, patrón caso-año-gancho + (ej.: ariel-school-1994-zimbabwe-ufo-encounter). Si el actual es el título + entero serializado, cámbialo. + +3) ENLACES INTERNOS — mínimo 2, SOLO a posts publicados de ESTE sitio (nunca a + pages, nunca a posts programados = enlace prematuro 404): + python3 ~/seo-tools/seo_link.py --site $site \ + --link "anchor=slug-destino" [--link ...] --apply + Primero SIN --apply para revisar las inserciones. Elige anchors que ya + existan en la prosa y destinos temáticamente afines de verdad. + +4) METAS + ALT — con seo_finish.py: + - `python3 ~/seo-tools/seo_finish.py prep --site $site` descarga la + imagen destacada a ~/.cache/seo-finish/. + - LEE esa imagen con la herramienta Read y escribe feature_image_alt + describiendo lo que SE VE (≤191 caracteres — Ghost corta ahí en silencio). + - meta_title ≤60, meta_description ≤145, custom_excerpt ≤300, OG y Twitter + espejados de las metas. + - Aplica con `python3 ~/seo-tools/seo_finish.py apply --site $site + --from-json ` (usa el slug NUEVO si lo cambiaste en el paso 2). + +5) VALIDA — itera hasta que el validador diga «sin violaciones accionables». + Si algo no converge, dilo en el informe en vez de forzarlo. + +PROHIBIDO — sin excepciones: +- Publicar o programar el post: queda en DRAFT (publicar es decisión de Jose). +- Tocar otros posts o pages (más allá de lo que haga seo_link.py por diseño). +- Tocar n8n, X/Twitter, credenciales, secretos o cualquier otra pieza de la + infraestructura. +- Acciones destructivas de cualquier tipo. + +INFORME FINAL (tu último mensaje; texto plano apto para móvil, sin tablas +anchas, <3000 caracteres): +- Correcciones editoriales aplicadas (resumen honesto; si no tocaste nada, dilo). +- Hallazgos que requieren decisión humana (lo del paso 1 que no tocaste). +- Slug final, metas con sus longitudes, nº de enlaces internos añadidos y el + veredicto literal del validador. +- Cierra recordando que sigue en borrador, listo para publicar. diff --git a/remate_watch.py b/remate_watch.py new file mode 100644 index 0000000..6bd2abb --- /dev/null +++ b/remate_watch.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +Tool G — remate-watch: la "tacada" automática de borrador a listo-para-publicar. + +El flujo editorial es: ResearchOwl crea el borrador SIN SEO (dryrun) → Jose +genera la infografía con ChatGPT y la sube a Ghost → y a partir de ahí este +vigilante hace el resto solo. Detecta el momento por la firma inequívoca de +"listo para rematar": + + status=draft Y feature_image puesta Y metas sin rellenar + +(un borrador recién creado no tiene imagen; uno ya rematado tiene metas), y +espera además a que el post lleve un rato sin editarse (--min-age, 10 min por +defecto) para no saltar mientras Jose aún retoca o está subiendo la imagen. + +Cuando dispara, lanza `claude -p` (plan Pro, coste API cero — el mismo patrón +que hermes-bot) con el checklist de ~/seo-tools/remate_prompt.md: revisión +editorial completa, slug, enlaces internos, metas + alt leyendo la imagen, y +validación. Después NO se cree el "hecho" del agente: re-lee el post de Ghost y +comprueba que las metas están de verdad antes de dar el OK por Telegram +(feedback_canario_camino_real). El post queda en DRAFT siempre: publicar es +decisión humana. + +Uso: + python3 remate_watch.py # lo que corre el timer + python3 remate_watch.py --dry-run # solo detecta e imprime + python3 remate_watch.py --site es --min-age 0 # forzar (pruebas) + +Salvaguardas: + - estado por (post, updated_at): un remate fallido NO entra en bucle — solo + se reintenta si el post vuelve a editarse (updated_at cambia). + - lock con pid: nunca dos remates a la vez (el timer de systemd tampoco + solapa, esto cubre las ejecuciones manuales). + - si claude falla, agota el tiempo, o dice "hecho" pero las metas no están + en Ghost, llega un 🚨 con la ruta del log completo. +""" +import argparse +import base64 +import datetime +import json +import os +import subprocess +import sys +import tempfile +import urllib.parse +import urllib.request +from string import Template + +# Ojo al canónico: EN es www, ES es el APEX — al revés (patrón de seo_watch). +SITES = { + "en": {"cli": "ghst-en", "site_name": "The Exclusion Zone (EN)", + "canonical": "https://www.theexclusionzone.com/", + "canon_note": "en www, con barra final"}, + "es": {"cli": "ghst-es", "site_name": "Zona de Exclusión (ES)", + "canonical": "https://zonadeexclusion.com/", + "canon_note": "en el APEX, con barra final"}, +} +STATE_DIR = os.path.expanduser("~/.local/state/remate-watch") +STATE = os.path.join(STATE_DIR, "state.json") +LOCK = os.path.join(STATE_DIR, "lock") +LOGS = os.path.join(STATE_DIR, "logs") +PROMPT_TMPL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "remate_prompt.md") +# Sobreescribible por env para poder ensayar el camino de fallo con un binario +# que casca, sin tocar código (feedback_canario_camino_real: hay que VER fallar +# la alerta antes de fiarse de ella). +CLAUDE = os.environ.get("CLAUDE_BIN", os.path.expanduser("~/.local/bin/claude")) +CLAUDE_TIMEOUT = 2700 # 45 min: una revisión editorial de verdad tarda +TG_SECRET = ("monitoring", "grafana-telegram-infisical") +FIELDS = ("id,slug,title,status,feature_image,feature_image_alt," + "meta_title,meta_description,updated_at") + + +def sh(cmd, timeout=180): + return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + + +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_drafts(site): + """Borradores del sitio. A fichero, no a PIPE (patrón de seo_watch: un PIPE + se trunca a 64 KB en silencio; con --fields cortos sobra, pero la copia del + patrón es gratis).""" + cli = SITES[site]["cli"] + fd, path = tempfile.mkstemp(suffix=".json", prefix="remate_watch_") + os.close(fd) + try: + r = sh(f'{cli} post list --filter "status:draft" --limit all ' + f'--fields {FIELDS} --json > {path}', timeout=240) + if r.returncode != 0: + raise RuntimeError(f"{cli} post list falló: {r.stderr.strip()[:200]}") + with open(path) as f: + data = json.load(f) + return data.get("posts", data) if isinstance(data, dict) else data + finally: + os.unlink(path) + + +def age_seconds(updated_at): + dt = datetime.datetime.strptime(updated_at, "%Y-%m-%dT%H:%M:%S.%fZ") + return (datetime.datetime.utcnow() - dt).total_seconds() + + +def is_candidate(p): + return (p.get("status") == "draft" + and p.get("feature_image") + and not (p.get("meta_title") and p.get("meta_description") + and p.get("feature_image_alt"))) + + +def load_state(): + try: + with open(STATE) as f: + return json.load(f) + except (FileNotFoundError, ValueError): + return {} + + +def save_state(st): + os.makedirs(STATE_DIR, exist_ok=True) + with open(STATE, "w") as f: + json.dump(st, f, indent=1) + + +def acquire_lock(): + """Lock con pid. Si el dueño ya no existe (crash), se roba.""" + os.makedirs(STATE_DIR, exist_ok=True) + for _ in range(2): + try: + fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + try: + with open(LOCK) as f: + pid = int(f.read().strip() or "0") + os.kill(pid, 0) # ¿vive? + return False # sí: otro remate en curso, nos retiramos + except (ProcessLookupError, ValueError): + os.unlink(LOCK) # dueño muerto: lock huérfano, se limpia + except PermissionError: + return False + return False + + +def release_lock(): + try: + os.unlink(LOCK) + except FileNotFoundError: + pass + + +def build_prompt(site, post): + cfg = SITES[site] + with open(PROMPT_TMPL) as f: + tmpl = Template(f.read()) + return tmpl.substitute( + site=site, site_name=cfg["site_name"], canonical=cfg["canonical"], + canon_note=cfg["canon_note"], cli=cfg["cli"], + id=post["id"], slug=post["slug"], title=post.get("title", "")) + + +def verify_in_ghost(site, post_id): + """La verificación contra la realidad: ¿están las metas EN GHOST?""" + cli = SITES[site]["cli"] + r = sh(f"{cli} post get {post_id} --fields {FIELDS} --json", timeout=120) + try: + data = json.loads(r.stdout) + p = (data.get("posts") or [data])[0] if isinstance(data, dict) else data[0] + except (ValueError, IndexError, KeyError): + return None, f"no pude re-leer el post ({r.stderr.strip()[:120]})" + faltan = [k for k in ("meta_title", "meta_description", "feature_image_alt") + if not p.get(k)] + if faltan: + return p, "faltan tras el remate: " + ", ".join(faltan) + return p, None + + +def run_remate(site, post): + """Lanza claude con el checklist y devuelve (ok, texto_para_telegram, log).""" + os.makedirs(LOGS, exist_ok=True) + ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") + log = os.path.join(LOGS, f"{ts}-{site}-{post['slug'][:40]}.log") + prompt = build_prompt(site, post) + + try: + proc = subprocess.run( + [CLAUDE, "-p", prompt, "--output-format", "json", + "--dangerously-skip-permissions"], + cwd=os.path.expanduser("~"), text=True, capture_output=True, + timeout=CLAUDE_TIMEOUT) + except subprocess.TimeoutExpired: + with open(log, "w") as f: + f.write("TIMEOUT tras %ss" % CLAUDE_TIMEOUT) + return False, f"claude agotó el tiempo ({CLAUDE_TIMEOUT}s)", log + + with open(log, "w") as f: + f.write(proc.stdout or "") + if proc.stderr: + f.write("\n--- stderr ---\n" + proc.stderr) + + if proc.returncode != 0: + return False, f"claude salió con código {proc.returncode}: {proc.stderr.strip()[:200]}", log + try: + out = json.loads(proc.stdout) + result = out.get("result") or "" + if out.get("is_error"): + return False, f"claude reportó error: {result[:300]}", log + except ValueError: + result = proc.stdout.strip() + if not result: + return False, "claude terminó sin informe (salida vacía)", log + return True, result, log + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true", + help="solo detecta e imprime; no lanza, no notifica, no guarda estado") + ap.add_argument("--site", choices=("en", "es", "both"), default="both") + ap.add_argument("--min-age", type=int, default=600, + help="segundos sin ediciones antes de disparar (debounce)") + # Tope por pasada: con una tanda grande de borradores (p.ej. la cola de + # vacaciones de agosto) se pueden subir varias imágenes seguidas y quedar + # 9 candidatos en el mismo tick. Encadenarlos en una sola ejecución + # rebasaría el TimeoutStartSec del unit y systemd mataría el remate a + # medias. Se hacen 2 por tick y el resto espera al siguiente (15 min). + ap.add_argument("--max", type=int, default=2, dest="maximo", + help="máximo de remates por ejecución (0 = sin límite)") + a = ap.parse_args() + sitios = ["en", "es"] if a.site == "both" else [a.site] + + state = load_state() + trabajos = [] + for site in sitios: + try: + drafts = fetch_drafts(site) + except Exception as e: # un sitio caído no debe tumbar al otro + print(f"[{site}] ERROR listando borradores: {e}") + if not a.dry_run: + telegram(f"🚨 remate-watch: no pude listar borradores de [{site.upper()}]: {e}") + continue + for p in drafts: + if not is_candidate(p): + continue + edad = age_seconds(p["updated_at"]) + prev = state.get(p["id"], {}) + if prev.get("updated_at") == p["updated_at"]: + print(f"[{site}] {p['slug']}: ya intentado con esta versión " + f"(resultado {prev.get('result')}), espero a que se edite") + continue + if edad < a.min_age: + print(f"[{site}] {p['slug']}: candidato pero editado hace " + f"{int(edad)}s (<{a.min_age}s), espero") + continue + trabajos.append((site, p)) + print(f"[{site}] CANDIDATO: {p['slug']} (sin editar desde hace {int(edad)}s)") + + if not trabajos: + print("sin candidatos") + return 0 + + # Los más antiguos primero: si hay cola, se remata en el orden en que se + # dejaron listos, no en el que devuelva la API. + trabajos.sort(key=lambda t: t[1]["updated_at"]) + pendientes = 0 + if a.maximo and len(trabajos) > a.maximo: + pendientes = len(trabajos) - a.maximo + trabajos = trabajos[:a.maximo] + print(f"[tope] {a.maximo} remate(s) en esta pasada; " + f"{pendientes} para el siguiente tick") + + if a.dry_run: + print(f"[dry-run] lanzaría {len(trabajos)} remate(s)") + return 0 + + if not acquire_lock(): + print("otro remate en curso; me retiro") + return 0 + try: + for site, p in trabajos: + # El estado se apunta ANTES de lanzar: si esto casca a mitad, el + # siguiente tick no relanza en bucle sobre la misma versión. + state[p["id"]] = {"updated_at": p["updated_at"], "result": "lanzado", + "ts": datetime.datetime.utcnow().isoformat()} + save_state(state) + + print(f"[{site}] rematando {p['slug']} …") + ok, texto, log = run_remate(site, p) + + if ok: + _, problema = verify_in_ghost(site, p["id"]) + if problema: + ok, texto = False, (f"el agente dio el remate por hecho pero Ghost " + f"dice otra cosa: {problema}") + state[p["id"]]["result"] = "ok" if ok else "fallo" + save_state(state) + + if ok: + msg = f"🪄 Remate automático [{site.upper()}] {p['slug']}\n\n{texto}" + else: + msg = (f"🚨 remate-watch: el remate de [{site.upper()}] «{p['slug']}» " + f"FALLÓ: {texto}\n\nLog: {log}\n" + f"(no se reintenta hasta que el post se edite de nuevo)") + enviado = telegram(msg) + print(f"[{site}] {p['slug']}: {'OK' if ok else 'FALLO'} " + f"[telegram: {'enviado' if enviado else 'FALLO'}] log: {log}") + finally: + release_lock() + return 0 + + +if __name__ == "__main__": + sys.exit(main())