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:
ChemaVX
2026-07-24 09:24:13 +00:00
co-authored by Claude Fable 5
parent 9c76ed87ff
commit cdc9a9baca
7 changed files with 625 additions and 0 deletions
+14
View File
@@ -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
+9
View File
@@ -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
+178
View File
@@ -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()
+18
View File
@@ -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
+10
View File
@@ -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
+73
View File
@@ -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 <slug> --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 <slug> --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 <slug> --site $site
--from-json <patch.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.
+323
View File
@@ -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())