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())