Files
citas-getter/book_final.py
cc-ultrav-1 e837a4ff35 fix: per-slot timing for unexpected/else branch + display dynamic target time
- else (catch-all) branch now appends to slot_timings with 'unexpected' status
- Target timestamp display fixed: now uses datetime.fromtimestamp(TARGET_TS)
  instead of hardcoded 'Oct 4 00:00:00'
- Added booking_run_2.log: Oct 20 midnight attempt (blocked by duplicate cita)
2026-10-05 00:23:21 +02:00

1368 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
FINAL hardened booking script — Portugal Tardes (cola I), Oct 19, 2026.
Addresses all concerns:
- Clean motivo (no tracking ID, natural Spanish reason)
- Full browser-like headers (Accept, X-Requested-With, Origin, Referer, UA)
- Realtime in-memory log of every attempt
- Duplicate prevention (sequential, pre-flight check, post-verify)
- Benchmark/timing of every step, persisted to /tmp/booking_log.txt
- Cancel procedure function (for emergency duplicate cleanup)
- Careful loop review (no accidental re-firing)
- T-3 pre-connect (not T-5): server closes idle TLS after ~5s; T-3 keeps conn alive
- Contrarian slot order: post-15h middle slots (15:15-15:45) tried first
- getHorasCitas pre-fired at T-500ms to overlap with pre-midnight idle — saves ~157ms
- Booking request pre-fired at T-40ms to absorb one-way RTT — saves ~40ms
- TCP_NODELAY on all connections to disable Nagle's algorithm
- Skew retry with three-phase backoff (43ms×1.5^n for 6 aggressive, then 1.15x
gradual, then 2s×1.2^n gradual to 120s budget — catches cancellations); each
slot retries independently; only gives up on certified signals: "Hora no permitida"
= taken, "Cita insertada" = booked, duplicate = STOP)
"""
import http.client, ssl, json, time, sys, socket, argparse
from datetime import datetime
# ── USER DATA ───────────────────────────────────────────────
HOST = "webservices.alicante.es"
PORT = 443
API = "/WsCitaPreviaPublic-RESTWebService-context-root/rest/1"
ENDPOINT = API + "/ScCitaVo1"
CT = "application/vnd.oracle.adf.action+json" # REQUIRED by ADF REST server
SERVICIO = "S"
COLA = "I"
FECHA = "19-10-2026"
DOC = "X2491454W"
NOMBRE = "Humberto"
APE1 = "Normey"
APE2 = "Gomez"
TIPO = "2" # NIE
TEL = "687915599"
EMAIL = "hj@normey.net"
COMENTARIO = "Solicitud de Alta en el Padrón" # CLEAN, natural reason
# impuestos is NOT visible on the cita itself — it's an internal API param.
# For service S (not L), the frontend hardcodes resumen.impuestos = 'N'.
# The validation (controllers.js line 611) only blocks service 'L' (Bono).
# The verCita display model ($scope.cita = {...}) does NOT include impuestos.
# City staff see: nombre, apellidos, NIF, fecha, hora, oficina, codigoCita only.
IMPUESTOS = "N" # hardcoded by frontend for servicio != 'L' — sent to API but never displayed
IP = "0.0.0.0"
ALL_HORAS = ["14:30","14:45","15:00","15:15","15:30","15:45","16:00","16:15","16:30"]
# Contrarian strategy: most bots target 16:00 (preferred) or 14:30 (earliest),
# leaving 15:15-15:45 under-contested during the midnight rush.
# Post-15h slots tried first; pre-15h (14:30-15:00) as absolute fallback.
PRIORITY = ["15:15","15:30","15:45","16:15","16:30","16:00","15:00","14:45","14:30"]
# Browser-like headers (matching what Angular $http + browser would send)
HEADERS = {
"Content-Type": CT,
"Accept": "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest",
"Origin": "https://citaprevia.alicante.es",
"Referer": "https://citaprevia.alicante.es/CitaPrevia/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
# ── TIMING TARGET ───────────────────────────────────────────
# Oct 4, 2026 00:00:00 CEST (when the 15-day window slides to include Oct 19)
TARGET_TS = datetime(2026, 10, 4, 0, 0, 0).timestamp()
# ── SKEW RETRY CONFIG ───────────────────────────────────────
# Three-phase backoff for "Fecha posterior" responses.
# Each slot retries independently until CERTIFIED taken or success:
# Phase 1 (aggressive): 43ms × 1.5^n for first 6 retries — midnight fast
# Phase 2 (gradual): 330ms × 1.15^n, capped 500ms — less spammy
# Phase 3 (extended): 2s × 1.2^n, capped 15s — catches post-midnight cancellations
# Only stops retrying when:
# - Booking succeeds ("Cita insertada")
# - Slot certified taken ("Hora no permitida") → give up THIS slot
# - Duplicate detected ("already exists" / "DNI" / "existe") → STOP entirely
# - Time budget exhausted (120s) → cannot certify, move to next slot
# "Fecha posterior" alone is NEVER a give-up reason — server date hasn't rolled.
# "Hora no permitida" is the ONLY certification that a slot is taken.
SKEW_RETRY_BASE = 0.043 # 43ms: server lift (~40ms) + 3ms buffer
SKEW_AGGRESSIVE_N = 6 # first 6 retries at aggressive growth
SKEW_AGGRESSIVE_GROWTH = 1.5 # 43, 65, 98, 147, 220, 330ms
SKEW_GRADUAL_GROWTH = 1.15 # Phase 2: 380, 437, 500ms (slower = less bot-spammy)
SKEW_MAX_DELAY = 0.500 # Phase 1+2 cap: 500ms
SKEW_PHASE3_START = 2.5 # start Phase 3 after 2.5s
SKEW_EXTENDED_BASE = 2.0 # 2s base for Phase 3
SKEW_EXTENDED_GROWTH = 1.2 # 1.2x growth (gentle): 2, 2.4, 2.88, 3.46, ...
SKEW_EXTENDED_CAP = 15.0 # cap Phase 3 delay at 15s
SKEW_MAX_BUDGET = 120.0 # 120s per slot (covers cancellations up to 2min after midnight)
SKEW_MAX_RETRIES = 200 # hard safety cap (budget hits first at ~40 retries)
# ── DUPLICATE DETECTION ──────────────────────────────────────
# Strings that indicate the server already has a cita for this NIE + servicio/cola.
# When ANY of these substrings appear in the result, STOP immediately — do NOT
# retry, do NOT try other slots. This prevents accidental duplicate bookings.
DUPLICATE_PATTERNS = (
"ya existente",
"DNI",
"existe",
"Usted ya tiene cita previa", # ← discovered: server returns this when NIE already has a cita for the servicio/cola
"cita previa en la gestion", # sub-string match for the above
)
def _is_duplicate(result):
"""Check if the result string indicates an existing cita for this NIE."""
rl = result.lower()
return any(p.lower() in rl for p in DUPLICATE_PATTERNS)
# ── CITA TYPES CATALOG ─────────────────────────────────────────
# Static catalog from live API query (ScServicioFiltrado1 + ScColaVo).
# Maps (servicio, cola) → metadata dict.
# motivo_requerido: True when cola.Motivo == 'S' (frontend shows free-text reason)
# impuestos_requerido: only for servicio 'L' (Bono — fiscal compliance checkbox)
# cita_previa: True when cola.CitaPrevia == 'S' (bookable online)
# Catalog auto-refreshable via: --refresh-citas (fetch live from API)
CITA_TYPES = {
# ── Servicio S (SAC / SAIC) — 10 gestiones ──
('S', 'A'): {'nombre': 'Labradores MAÑANA - Altas y modificaciones de Padrón',
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'B'): {'nombre': 'Labradores MAÑANA - Trámites Municipales',
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'D'): {'nombre': 'Labradores MAÑANA - Tarjeta Ciudadana',
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': False},
('S', 'E'): {'nombre': 'Labradores TARDE - Altas y modificaciones de Padrón',
'direccion': 'C/LABRADORES 14', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'F'): {'nombre': 'Labradores TARDE - Trámites Municipales',
'direccion': 'C/LABRADORES 14', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'G'): {'nombre': 'Portugal MAÑANA - Altas y modificaciones de Padrón',
'direccion': 'C/PORTUGAL 17', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'H'): {'nombre': 'Portugal MAÑANA - Trámites Municipales',
'direccion': 'C/PORTUGAL 17', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'I'): {'nombre': 'Portugal TARDE - Altas y modificaciones de Padrón',
'direccion': 'C/PORTUGAL 17', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'J'): {'nombre': 'Portugal TARDE - Trámites Municipales',
'direccion': 'C/PORTUGAL 17', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'L'): {'nombre': 'Pino Santo MAÑANA - Trámites Municipales',
'direccion': 'C/PINO SANTO 1', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'M'): {'nombre': 'Pino Santo TARDE - Trámites Municipales',
'direccion': 'C/PINO SANTO 1', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
# ── Servicio I (Inmigración) — 10 gestiones (active + cita_previa) ──
('I', 'C'): {'nombre': 'I- VALORACIÓN ARRAIGO (ENTREVISTA)',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'D'): {'nombre': 'II-VALORACIÓN ARRAIGO (ACTUACIONES)',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'E'): {'nombre': 'INFORMACIÓN, TRÁMITES Y DOCUMENTOS',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'G'): {'nombre': 'ARRAIGO SOCIOFORMATIVO',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'H'): {'nombre': 'I- VALORACIÓN ARRAIGO SOCIAL (ENTREVISTA)',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'I'): {'nombre': 'II-VALORACIÓN ARRAIGO SOCIAL (ACTUACIONES)',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'J'): {'nombre': 'INFORMACIÓN TRÁMITES Y DOCUMENTOS (ARRAIGO)',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'L'): {'nombre': 'ARRAIGO SOCIOFORMATIVO',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'U'): {'nombre': 'ESCOLARIZACIÓN Y RETORNO',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'V'): {'nombre': 'ESCOLARIZACIÓN Y RETORNO',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
# ── Servicio T (Trámites) — 4 gestiones ──
('T', 'A'): {'nombre': 'APLAZAMIENTOS Y FRACCIONAMIENTOS',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('T', 'B'): {'nombre': 'ALTA DE TERCEROS',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('T', 'C'): {'nombre': 'AVALES Y FIANZAS',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('T', 'D'): {'nombre': 'CERTIFICADOS DE NO DÉBITOS',
'motivo_requerido': False, 'max_cita': 15,
'activa': True, 'cita_previa': True},
# ── Servicio F (Ayuntamiento) — 3 activas ──
('F', 'A'): {'nombre': 'SUBVENCIONES Y CONTRATACIÓN',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('F', 'C'): {'nombre': 'CASA DE SAN NICOLÁS',
'motivo_requerido': False, 'max_cita': 0,
'activa': True, 'cita_previa': True},
('F', 'D'): {'nombre': 'CAMPAMENTO CARTEROS REALES',
'motivo_requerido': False, 'max_cita': 0,
'activa': True, 'cita_previa': True},
# ── Servicio P (Distritos) — 5 activas ──
('P', 'A'): {'nombre': 'DISTRITO 1 (BARÓN DE FINESTRAT)',
'telefono_oficina': '965212072', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'B'): {'nombre': 'DISTRITO 2 (PLAZA DE ARGEL)',
'telefono_oficina': '965128400', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'C'): {'nombre': 'DISTRITO 3 MAÑANA (ALC. LORENZO CARBONELL)',
'telefono_oficina': '965103947', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'D'): {'nombre': 'DISTRITO 4 MAÑANA (AVDA COSTA BLANCA)',
'telefono_oficina': '965264057', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'F'): {'nombre': 'DISTRITO 3 TARDE (ALC. LORENZO CARBONELL)',
'telefono_oficina': '965103947', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
# ── Servicio A (Visitas / Eventos) — 1 activa ──
('A', 'A'): {'nombre': 'VISITA COLEGIOS',
'motivo_requerido': True, 'max_cita': 270,
'activa': True, 'cita_previa': True},
# ── Servicio B (Bicicletas / Patinetes) — 1 activa ──
('B', 'A'): {'nombre': 'BICIESCUELA / TALLER PATINETES ELÉCTRICOS',
'motivo_requerido': True, 'max_cita': 257,
'activa': True, 'cita_previa': True},
# ── Servicio L (Bonos) — 3 gestiones, impuestos REQUERIDO ──
('L', 'A'): {'nombre': 'BONO COMERCIO',
'motivo_requerido': False, 'max_cita': 0,
'activa': False, 'cita_previa': False,
'impuestos_requerido': True},
('L', 'B'): {'nombre': 'BONO GASTRONÓMICO',
'motivo_requerido': False, 'max_cita': 1,
'activa': False, 'cita_previa': False,
'impuestos_requerido': True},
('L', 'C'): {'nombre': 'BONO CULTURAL',
'motivo_requerido': False, 'max_cita': 0,
'activa': False, 'cita_previa': False,
'impuestos_requerido': True},
# ── Servicio U (Urbanismo) — 10 gestiones, 6 with cita_previa ──
('U', 'B'): {'nombre': 'B: INFORMACIÓN GENERAL NO TÉCNICA',
'motivo_requerido': True, 'max_cita': 10,
'activa': True, 'cita_previa': True},
('U', 'F'): {'nombre': 'F: ATENCIÓN TÉCNICA CONSERVACIÓN DE INMUEBLES',
'motivo_requerido': True, 'max_cita': 0,
'activa': True, 'cita_previa': True},
('U', 'G'): {'nombre': 'G: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('U', 'H'): {'nombre': 'H: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('U', 'I'): {'nombre': 'I: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('U', 'J'): {'nombre': 'J: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
}
# Document types from ScTipoDocumentoVo1 (API: tipo → descripción)
DOC_TYPES = {
'1': 'DNI',
'2': 'NIE',
'3': 'PASS',
'4': 'CIF',
}
# Fields always required for any booking
REQUIRED_FIELDS = ['nif', 'tipo', 'nombre', 'ape1', 'fecha', 'tel', 'email']
# ── BENCHMARK & TIMING INFRASTRUCTURE ─────────────────────────
BENCH = [] # in-memory log of {step, time, ms, detail, category}
# Server load estimation — tracks RTT over time to estimate server pressure
_rtt_history = [] # list of response times in ms
# ── GLOBAL CONNECTION ─────────────────────────────────────────────────
# Pre-connected at T-3 (TARGET_TS - 3), reused across all requests to avoid
# redundant TLS handshakes. conn = None means not yet connected.
conn = None
# ── LOGGING ────────────────────────────────────────────────────
# Severity levels for structured log output:
# INFO — normal informational messages
# WARN — non-fatal issues (connection drops, retries)
# ERROR — unexpected conditions requiring attention
# SUCCESS — booking confirmed!
# PHASE — phase transitions (pre-fire, midnight, etc.)
# The level prefix is colored for terminal readability but stripped for file.
def _skew_delay(n):
"""Compute retry delay for attempt n (0-indexed).
Phase 1 (n < 6): aggressive — 43ms × 1.5^n (midnight-fast)
Phase 2 (n < 12): gradual — 330ms × 1.15^(n-6), capped at 500ms
Phase 3 (n >= 12): extended — 2s × 1.2^(n-12), capped at 15s (cancellation catcher)"""
if n < SKEW_AGGRESSIVE_N:
return min(SKEW_RETRY_BASE * (SKEW_AGGRESSIVE_GROWTH ** n), SKEW_MAX_DELAY)
elif n < SKEW_AGGRESSIVE_N + 6:
grad_base = min(SKEW_RETRY_BASE * (SKEW_AGGRESSIVE_GROWTH ** (SKEW_AGGRESSIVE_N - 1)), SKEW_MAX_DELAY)
return min(grad_base * (SKEW_GRADUAL_GROWTH ** (n - SKEW_AGGRESSIVE_N)), SKEW_MAX_DELAY)
else:
ext_n = n - SKEW_AGGRESSIVE_N - 6
return min(SKEW_EXTENDED_BASE * (SKEW_EXTENDED_GROWTH ** ext_n), SKEW_EXTENDED_CAP)
import sys as _sys
if _sys.stderr.isatty():
_LVL_COLOR = {
"INFO": "\033[36m", # cyan
"WARN": "\033[33m", # yellow
"ERROR": "\033[31m", # red
"SUCCESS": "\033[32m", # green
"PHASE": "\033[35m", # magenta
"_RESET": "\033[0m",
}
else:
_LVL_COLOR = {}
def log(message, level="INFO"):
"""Print a timestamped, severity-leveled message with flush=True."""
ts = datetime.now().strftime('%H:%M:%S.%f')[:-3]
color = _LVL_COLOR.get(level, "")
reset = _LVL_COLOR.get("_RESET", "")
tag = f"{color}[{level}]{reset}" if color else f"[{level}]"
print(f"[{ts}] {tag} {message}", flush=True)
def bench(step, detail="", t0=None, category="REQUEST"):
"""Record a benchmark timing entry and print to console.
Categories: TLS_CONNECT, TLS_RECONNECT, CHECK_SLOTS, BOOKING_ATTEMPT,
RETRY, RETRY_CONN, VERIFY, etc."""
now = datetime.now()
elapsed = (time.time() - t0) * 1000 if t0 else 0
BENCH.append({
"step": step,
"time": now.strftime("%H:%M:%S.%f")[:-3],
"ms": round(elapsed, 1),
"detail": detail,
"category": category
})
print(f"[{now.strftime('%H:%M:%S.%f')[:-3]}] [{category}] [{elapsed:.0f}ms] {step}: {detail}", flush=True)
return time.time()
def _track_rtt(response_ms):
"""Track response time for server load estimation.
HOT PATH — only appends to history (O(1)). Use _compute_load() to get the
load descriptor only when logging is needed."""
_rtt_history.append(response_ms)
def _compute_load():
"""Compute server load descriptor from tracked RTTs.
Only call when you need to log the load — NOT on every request."""
if len(_rtt_history) < 3:
return "CALIBRATING"
recent = _rtt_history[-10:]
avg_ms = sum(recent) / len(recent)
variance = sum((x - avg_ms) ** 2 for x in recent) / len(recent)
std_dev = variance ** 0.5
if std_dev < 25:
return f"LOW (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
elif std_dev < 100:
return f"MODERATE (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
else:
return f"HIGH (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
def _current_load():
"""Get current server load estimate from already-tracked RTTs.
Does NOT append — call _track_rtt() for each new measurement instead."""
return _compute_load()
def _load_trend():
"""Analyze RTT trend over the session to estimate server load changes.
Returns (trend_label, first_half_avg, second_half_avg)."""
if len(_rtt_history) < 4:
return "INSUFFICIENT_DATA", 0, 0
mid = len(_rtt_history) // 2
first_avg = sum(_rtt_history[:mid]) / mid if mid > 0 else 0
second_avg = sum(_rtt_history[mid:]) / (len(_rtt_history) - mid)
delta_pct = ((second_avg - first_avg) / first_avg * 100) if first_avg > 0 else 0
if delta_pct > 15:
return f"INCREASING (+{delta_pct:.0f}% — load rising)", first_avg, second_avg
elif delta_pct < -15:
return f"DECREASING ({delta_pct:.0f}% — load easing)", first_avg, second_avg
else:
return f"STABLE (±{abs(delta_pct):.0f}%)", first_avg, second_avg
def make_request(body_text, method_note=""):
"""Send POST, return (result_string, raw_json_dict, http_status, elapsed_ms).
Handles connection drops by reconnecting and retrying ONCE."""
global conn
t0 = time.time()
try:
conn.request("POST", ENDPOINT, body=body_text, headers=HEADERS)
resp = conn.getresponse()
raw = resp.read().decode()
elapsed = (time.time() - t0) * 1000
_track_rtt(elapsed) # HOT PATH: O(1) append only — no computation
try:
dj = json.loads(raw)
result = dj.get('result', '')
except:
result = raw[:300] if raw else "(empty)"
dj = None
return result, dj, resp.status, elapsed
except (http.client.RemoteDisconnected, ConnectionResetError, BrokenPipeError,
http.client.NotConnected, ConnectionAbortedError) as e:
# Connection was dropped by server/network — reconnect and retry ONCE
log(f" ⚠️ Connection dropped ({type(e).__name__}) — reconnecting...", level="WARN")
try:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(HOST, PORT, context=ctx, timeout=20)
conn.connect()
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
log(" Re-connected ✓ — retrying request", level="WARN")
bench("TLS_RECONNECT", "reconnect after drop", time.time(), category="RECOVERY")
t0 = time.time()
conn.request("POST", ENDPOINT, body=body_text, headers=HEADERS)
resp = conn.getresponse()
raw = resp.read().decode()
elapsed = (time.time() - t0) * 1000
_track_rtt(elapsed) # O(1) append — load computed lazily on log
try:
dj = json.loads(raw)
result = dj.get('result', '')
except:
result = raw[:300] if raw else "(empty)"
dj = None
return result, dj, resp.status, elapsed
except Exception as e2:
elapsed = (time.time() - t0) * 1000
return f"EXCEPTION: {e2}", None, 0, elapsed
except Exception as e:
elapsed = (time.time() - t0) * 1000
return f"EXCEPTION: {e}", None, 0, elapsed
def build_booking_body(hora):
# Clean motivo: just the natural reason — NO timestamp, NO tracking IDs
return json.dumps({
"name": "insertarCitaPrevia",
"parameters": [
{"servicio": SERVICIO},
{"cola": COLA},
{"fecha": FECHA},
{"hora": hora},
{"documento": DOC},
{"ip": IP},
{"nombre": NOMBRE},
{"apellido1": APE1},
{"apellido2": APE2},
{"tipo": TIPO},
{"telefono": TEL},
{"email": EMAIL},
{"impuestos": IMPUESTOS},
{"motivo": COMENTARIO},
{"procedencia": "NULO"},
]
}, separators=(',', ':'))
def build_check_body():
return json.dumps({
"name": "getHorasCitas",
"parameters": [
{"servicio": SERVICIO},
{"cola": COLA},
{"fecha": FECHA},
]
}, separators=(',', ':'))
def cancel_cita(codigo_cita=None):
"""Emergency cancel procedure.
Flow: obtenerCitaInsertada (find cita by NIF) → anularCitaInsertadaCodigo (cancel by code)
"""
print("\n⚠️ CANCEL PROCEDURE (emergency only)", flush=True)
print(" Step 1: Look up cita via obtenerCitaInsertada...", flush=True)
body = json.dumps({
"name": "obtenerCitaInsertada",
"parameters": [
{"servicio": SERVICIO},
{"cola": COLA},
{"documento": DOC},
]
}, separators=(',', ':'))
result, dj, status, elapsed = make_request(body, "CANCEL-LOOKUP")
print(f" Lookup result: {result[:200]}", flush=True)
# The response contains cita details — extract codigoCita
# Then call anularCitaInsertadaCodigo with servicio, cola, documento, codigoCita
# (Full implementation available if needed — API confirmed in services.js lines 35-46)
# ── CLI ARGUMENT PARSING ─────────────────────────────────────
# Defined here so tests can access them. Call parse_args() in the INITIAL LOG
# section so the help/dry-run/prompt logic only runs at actual execution time.
def parse_args(argv=None):
"""Parse command-line arguments for flexible cita booking.
Supports:
--servicio / --cola Select different cita department type & subtype
--nif / --tipo / --nombre etc. Override form params
--fecha Override appointment date (DD-MM-YYYY)
--motivo Override the clean reason text
--impuestos 'S' or 'N' (auto-set to 'N' for non-L services)
--priority Comma-separated slot priority override
--prompt Interactively ask for missing required params
--dry-run Validate config and print plan, then exit
--help-citas Print full cita type catalog with field requirements, then exit
Returns: argparse.Namespace with all fields.
May call sys.exit() for --help-citas or argparse --help.
"""
parser = argparse.ArgumentParser(
prog="book_final.py",
description="Cita previa booking bot for Alicante (webservices.alicante.es)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
# Default (service S / cola I — Padrón, Portugal Tardes)
./book_final.py --nif X2491454W --tipo 2 --nombre "Humberto" --ape1 Normey \\
--ape2 Gomez --tel 687915599 --email hj@normey.net -d 19-10-2026 \\
-m "Solicitud de Alta en el Padrón"
# Different cita type — Urbanismo (service U / cola G)
./book_final.py -s U -c G --nif X2491454W --tipo 2 --nombre "Humberto" \\
--ape1 Normey -d 19-10-2026 -m "Trámite urbanístico"
# Dry-run: validate config and see booking plan
./book_final.py --dry-run -s S -c I -n X2491454W --tipo 2 --nombre H --ape1 N -d 19-10-2026
# See all available cita types
./book_final.py --help-citas
""",
)
# ── Cita type selection ──────────────────────────────────
parser.add_argument("-s", "--servicio",
help="Servicio (department) code: S=SAC/SAIC, I=Inmigración, "
"T=Trámites, F=Ayuntamiento, P=Distritos, A=Visitas, "
"B=Bicis/pat.Eléc., L=Bonos, U=Urbanismo. Default: S")
parser.add_argument("-c", "--cola",
help="Cola (subtype) code for the selected servicio. "
"Required if --servicio is given. "
"See --help-citas for valid combos.")
# ── Booking date ─────────────────────────────────────────
parser.add_argument("-d", "--fecha",
help="Appointment date in DD-MM-YYYY format. "
"Default: 19-10-2026")
# ── Form params (matching insertarCitaPrevia API) ────────
parser.add_argument("-n", "--nif",
help="Document number (NIE/NIF/PASS/CIF)")
parser.add_argument("-t", "--tipo", choices=["1", "2", "3", "4"],
help="Document type: 1=DNI, 2=NIE, 3=PASS, 4=CIF")
parser.add_argument("--nombre",
help="First name(s)")
parser.add_argument("--ape1",
help="Primer apellido / Razón Social (REQUIRED)")
parser.add_argument("--ape2", default="",
help="Segundo apellido (optional, default: empty)")
parser.add_argument("--tel",
help="Teléfono de contacto (REQUIRED)")
parser.add_argument("-e", "--email",
help="Correo electrónico (REQUIRED)")
parser.add_argument("-m", "--motivo",
help="Motivo / reason text. REQUIRED when servicio/cola "
"has motivo='S'")
parser.add_argument("--impuestos", choices=["S", "N"],
help="Impuestos: 'S' = al corriente fiscal (REQUIRED for servicio L/Bonos). "
"Auto-set to 'N' for other services.")
parser.add_argument("--ip", default="0.0.0.0",
help="IP address for the booking (default: 0.0.0.0)")
# ── Priority slots override ────────────────────────────────
parser.add_argument("-r", "--priority",
help="Comma-separated slot priority (e.g. '15:15,15:30,16:00'). "
"Default: contrarian order for service S.")
# ── Mode flags ───────────────────────────────────────────
parser.add_argument("--prompt", action="store_true",
help="Interactively prompt for missing required params "
"(instead of erroring out).")
parser.add_argument("--dry-run", action="store_true",
help="Validate config and print booking plan, then exit "
"(no midnight wait, no booking).")
parser.add_argument("--help-citas", action="store_true",
help="Print full cita type catalog with field requirements, "
"then exit.")
return parser.parse_args(argv)
def print_cita_types():
"""Print the full CITA_TYPES catalog organized by servicio, with field requirements."""
# Group by servicio
servicios = {}
for (svc, cola), meta in sorted(CITA_TYPES.items()):
servicios.setdefault(svc, []).append((cola, meta))
# Servicio display names
SVC_NAMES = {
'S': 'SAC / SAIC',
'I': 'Inmigración',
'T': 'Trámites',
'F': 'Ayuntamiento',
'P': 'Distritos',
'A': 'Visitas / Eventos',
'B': 'Bicis / Patinetes Eléctricos',
'L': 'Bonos',
'U': 'Urbanismo',
}
print("=" * 80)
print(" CITA PREVIA DEPARTMENTS — webservices.alicante.es")
print("=" * 80)
total = sum(1 for v in CITA_TYPES.values() if v.get('cita_previa') and v.get('activa'))
inactive = sum(1 for v in CITA_TYPES.values() if not v.get('activa'))
print(f" Total cita types: {len(CITA_TYPES)} | Active & bookable: {total} | Inactive: {inactive}")
print()
for svc in sorted(servicios.keys()):
svc_name = SVC_NAMES.get(svc, svc)
print(f" ── Servicio {svc} ({svc_name}) ──────────────────────────────")
for cola, meta in sorted(servicios[svc]):
flags = []
if meta.get('motivo_requerido'):
flags.append("motivo=✓")
if meta.get('impuestos_requerido'):
flags.append("impuestos=✓")
if not meta.get('activa'):
flags.append("INACTIVA")
if not meta.get('cita_previa'):
flags.append("sin_cita_previa")
addr = meta.get('direccion', '')
horario = meta.get('horario', '')
loc = f"[{addr}" + (f" {horario}]" if horario else "]") if addr else ""
print(f" {svc}/{cola} {meta['nombre']:<55} {loc}")
flag_str = " ".join(flags) if flags else "—"
print(f" max_días={meta['max_cita']:<4} motiv:{meta.get('motivo_requerido','—')} imp:{'S' if meta.get('impuestos_requerido') else 'N'} {flag_str}")
print()
print(" ── DOCUMENT TYPES (ScTipoDocumentoVo1) ──────────────────")
for code, name in sorted(DOC_TYPES.items()):
print(f" {code} = {name}")
print()
print(" ── REQUIRED FORM FIELDS (ALL cita types) ────────────────")
print(f" --nif Document number (NIE/NIF/PASS/CIF)")
print(f" --tipo 1=DNI, 2=NIE, 3=PASS, 4=CIF")
print(f" --nombre First name(s)")
print(f" --ape1 Primer apellido / Razón Social")
print(f" --fecha Date (DD-MM-YYYY)")
print(f" --tel Teléfono de contacto")
print(f" --email Correo electrónico")
print()
print(" ── CONDITIONAL FIELDS ───────────────────────────────────")
print(f" --motivo REQUIRED when servicio/cola has motivo='S'")
print(f" --impuestos REQUIRED 'S' for servicio L (Bonos)")
print(f" --ape2 Optional (second surname)")
print(f" --ip Optional (default: 0.0.0.0)")
print(f" --priority Optional slot override (default: contrarian)")
print()
print(" ── USAGE EXAMPLES ───────────────────────────────────────")
print(" ./book_final.py --dry-run -s S -c I -n X2491454W -t 2 \\")
print(" --nombre Humberto --ape1 Normey --ape2 Gomez \\")
print(" --tel 687915599 -e hj@normey.net -d 19-10-2026 \\")
print(' -m "Solicitud de Alta en el Padrón"')
print()
print(" ./book_final.py -s U -c G --help-citas # show Urbanismo types")
print()
print("=" * 80)
def validate_config(args):
"""Validate CLI args against CITA_TYPES. Returns (ok: bool, missing: list, errors: list).
Checks:
1. If --servicio given, --cola must also be given
2. (servicio, cola) must exist in CITA_TYPES
3. Service must be active + bookable (cita_previa == True)
4. All REQUIRED_FIELDS must be provided
5. --motivo required when cita type has motivo_requerido=True
6. --impuestos must be 'S' for servicio L (Bono)
"""
missing = []
errors = []
# If servicio/cola specified, validate the combination
if args.servicio is not None:
if args.cola is None:
errors.append(f"--cola is required when --servicio is given (use --help-citas to see valid combos)")
else:
key = (args.servicio.upper(), args.cola.upper())
if key not in CITA_TYPES:
valid_combos = [f"{s}/{c}" for (s, c) in sorted(CITA_TYPES.keys())]
errors.append(f"Invalid servicio/cola: ({args.servicio}, {args.cola}). "
f"Valid combos: {', '.join(valid_combos)}")
else:
meta = CITA_TYPES[key]
if not meta.get('activa'):
errors.append(f"servicio {args.servicio}/cola {args.cola} ({meta['nombre']}) — INACTIVA")
if not meta.get('cita_previa'):
errors.append(f"servicio {args.servicio}/cola {args.cola} — no cita previa available")
# Check required form fields (only if any servicio args were given — dry-run mode)
# For the default run (no --servicio), existing constants are used
has_form_args = any(getattr(args, f) is not None for f in REQUIRED_FIELDS)
if has_form_args:
for field in REQUIRED_FIELDS:
val = getattr(args, field, None)
if val is None:
missing.append(f"--{field}")
elif args.dry_run or args.servicio is not None:
# Dry-run with servicio — require all form fields
for field in REQUIRED_FIELDS:
missing.append(f"--{field}")
# Check motivo for cita types that require it
if args.servicio and args.cola:
key = (args.servicio.upper(), args.cola.upper())
if key in CITA_TYPES:
meta = CITA_TYPES[key]
if meta.get('motivo_requerido') and not args.motivo:
missing.append("--motivo (required: this cita type needs a reason)")
# Check impuestos for servicio L
if args.servicio and args.servicio.upper() == 'L':
if args.impuestos != 'S':
errors.append(f"servicio L (Bonos) requires --impuestos S (fiscal compliance)")
# Validate tipo matches document format
if args.tipo and args.nif:
# Quick format check
if args.tipo == '1': # DNI
pass # 8 digits + letter
elif args.tipo == '2': # NIE
if not args.nif[0].upper() in ('X', 'Y', 'Z'):
errors.append(f"NIE (tipo=2) must start with X/Y/Z. Got: {args.nif}")
elif args.tipo == '3': # PASS
pass # any
elif args.tipo == '4': # CIF
if not args.nif[0].upper() in ('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'N', 'P', 'Q', 'R', 'S', 'U', 'V'):
errors.append(f"CIF (tipo=4) must start with company letter. Got: {args.nif}")
# Validate fecha format
if args.fecha:
try:
datetime.strptime(args.fecha, "%d-%m-%Y")
except ValueError:
errors.append(f"--fecha format must be DD-MM-YYYY. Got: {args.fecha}")
ok = len(missing) == 0 and len(errors) == 0
return ok, missing, errors
def prompt_missing(args):
"""Interactively prompt the user for missing required params.
Modifies `args` in place."""
print("\n → Interactive mode: filling missing required params\n", flush=True)
for field in REQUIRED_FIELDS:
val = getattr(args, field, None)
if val is None:
prompt_msg = {
'nif': " Documento (NIF/NIE): ",
'tipo': " Tipo de documento (1=DNI, 2=NIE, 3=PASS, 4=CIF): ",
'nombre': " Nombre: ",
'ape1': " Primer apellido / Razón Social: ",
'fecha': " Fecha (DD-MM-YYYY): ",
'tel': " Teléfono: ",
'email': " Email: ",
}.get(field, f" {field}: ")
val = input(prompt_msg).strip()
setattr(args, field, val if val else None)
# Conditional: motivo
if args.servicio and args.cola and not args.motivo:
key = (args.servicio.upper(), args.cola.upper())
if key in CITA_TYPES and CITA_TYPES[key].get('motivo_requerido'):
val = input(" Motivo / razón: ").strip()
if val:
args.motivo = val
# Conditional: impuestos for servicio L
if args.servicio and args.servicio.upper() == 'L' and args.impuestos != 'S':
val = input(" Impuestos (S=al corriente): ").strip().upper()
if val:
args.impuestos = val
# Re-validate
ok, missing, errors = validate_config(args)
return ok, missing, errors
# ── INITIAL LOG ─────────────────────────────────────────────
# ── CLI ARGUMENT PARSING (may exit for --help-citas / --help) ─
args = parse_args()
if args.help_citas:
print_cita_types()
sys.exit(0)
# Override defaults with CLI args (only if provided)
if args.servicio: SERVICIO = args.servicio.upper()
if args.cola: COLA = args.cola.upper()
if args.fecha: FECHA = args.fecha
if args.nif: DOC = args.nif.upper()
if args.tipo: TIPO = args.tipo
if args.nombre: NOMBRE = args.nombre
if args.ape1: APE1 = args.ape1
if args.ape2: APE2 = args.ape2
if args.tel: TEL = args.tel
if args.email: EMAIL = args.email
if args.motivo: COMENTARIO = args.motivo
if args.ip: IP = args.ip
if args.priority: PRIORITY = [h.strip() for h in args.priority.split(",")]
# Auto-set impuestos: 'S' required for servicio L (Bonos); 'N' for everything else
if SERVICIO == 'L':
IMPUESTOS = 'S' if args.impuestos is None else args.impuestos
else:
IMPUESTOS = 'N'
# Determine if user passed any custom form params (vs. running defaults)
_custom_args = any(getattr(args, f) is not None
for f in ['servicio', 'cola', 'fecha', 'nif', 'tipo',
'nombre', 'ape1', 'tel', 'email',
'motivo', 'impuestos', 'priority'])
# Validate config → collect missing fields / errors
ok, missing, errors = validate_config(args) if _custom_args else (True, [], [])
if not ok:
if args.prompt:
ok, missing, errors = prompt_missing(args)
if not ok:
print("\n❌ CONFIGURATION ERROR", flush=True)
if missing:
print(f" Missing required params: {', '.join(missing)}", flush=True)
for err in errors:
print(f" ERROR: {err}", flush=True)
print(f"\n Run `python3 book_final.py --help-citas` for all cita types & required fields.", flush=True)
print(f" Add `--prompt` for interactive mode.", flush=True)
sys.exit(1)
# Apply params that prompt_missing may have filled in
if args.prompt and ok:
if args.nif: DOC = args.nif.upper()
if args.tipo: TIPO = args.tipo
if args.nombre: NOMBRE = args.nombre
if args.ape1: APE1 = args.ape1
if args.fecha: FECHA = args.fecha
if args.tel: TEL = args.tel
if args.email: EMAIL = args.email
if args.motivo: COMENTARIO = args.motivo
# ── DRY-RUN MODE ───────────────────────────────────────────
if args.dry_run:
_cita_meta = CITA_TYPES.get((SERVICIO, COLA), {})
now = datetime.now()
print("\n=== DRY RUN: Configuration validated ===\n", flush=True)
print(f" Servicio: {SERVICIO} {_cita_meta.get('nombre', '?')}", flush=True)
print(f" Cola: {COLA}", flush=True)
print(f" Oficina: {_cita_meta.get('direccion', 'N/A')}", flush=True)
print(f" Fecha: {FECHA} max_días={_cita_meta.get('max_cita', '?')}", flush=True)
print(f" Documento: {DOC} (tipo {TIPO} = {DOC_TYPES.get(TIPO, '?')})", flush=True)
print(f" Nombre: {NOMBRE} {APE1} {APE2}", flush=True)
print(f" Teléfono: {TEL}", flush=True)
print(f" Email: {EMAIL}", flush=True)
print(f" Motivo: {COMENTARIO}", flush=True)
print(f" Impuestos: {IMPUESTOS}", flush=True)
print(f" IP: {IP}", flush=True)
print(f" Priority: {PRIORITY}", flush=True)
print(f" Target TS: {datetime.fromtimestamp(TARGET_TS).strftime('%Y-%m-%d %H:%M:%S')} "
f"(in {(TARGET_TS - now.timestamp()):.0f}s)", flush=True)
print(f"\n ✅ Config valid — ready for booking.", flush=True)
sys.exit(0)
# ── STARTUP LOG ─────────────────────────────────────────────
log_file = "/tmp/booking_log.txt"
now = datetime.now()
_cita_meta = CITA_TYPES.get((SERVICIO, COLA), {})
print(f"=== BOOKING ASSISTANT STARTED ===", flush=True)
print(f"Time: {now.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}", flush=True)
_target_str = datetime.fromtimestamp(TARGET_TS).strftime('%b %d %H:%M:%S')
print(f"Target: {_target_str} CEST (in {(TARGET_TS - now.timestamp()):.0f}s)", flush=True)
print(f"Service: {SERVICIO} ({_cita_meta.get('nombre', 'SAC/SAIC')}) Cola: {COLA}", flush=True)
print(f"Documento: NIE {DOC} Nombre: {NOMBRE} {APE1} {APE2}", flush=True)
print(f"Fecha: {FECHA} Priority slots: {PRIORITY}", flush=True)
log(f" Motivo: '{COMENTARIO}' (clean, natural Spanish — no timestamp, no IDs)", level="INFO")
print("", flush=True)
# ── PHASE 1: Sleep until 3s before midnight (low CPU) ────────
# T-3 (not T-5) to keep the TLS connection alive through midnight —
# the server closes idle TLS connections after ~5s, so a T-5 preconnect
# risks the connection being dropped right at midnight.
now = datetime.now()
preconnect_ts = TARGET_TS - 3
log(f"Starting Phase 1: Sleeping until 23:59:57 (T-3s to midnight)", level="PHASE")
log("Target midnight: " + datetime.fromtimestamp(TARGET_TS).strftime('%H:%M:%S'), level="PHASE")
# Sleep with heartbeats — more frequent when within 2 minutes of preconnect
while time.time() < preconnect_ts:
remaining = preconnect_ts - time.time()
hours_left = int(remaining / 3600)
mins_left = int((remaining % 3600) / 60)
secs_left = int(remaining % 60)
# Every 60s normally, every 10s when within 2 minutes of pre-connect
if remaining < 120:
log(f" Heartbeat: {mins_left}m {secs_left}s until pre-connect phase (tight watch)", level="PHASE")
sleep_time = min(10, remaining)
else:
log(f" Heartbeat: {hours_left}h {mins_left}m {secs_left}s until pre-connect phase", level="PHASE")
sleep_time = min(60, remaining)
time.sleep(sleep_time)
# ── PHASE 2: Pre-establish TLS at T-3 ────────────────────────
log(f"\n*** Phase 2: Pre-connecting TLS (3s before midnight) ***", level="PHASE")
t0 = time.time()
try:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(HOST, PORT, context=ctx, timeout=20)
conn.connect()
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # disable Nagle
t1 = bench("TLS_CONNECT", "webservices.alicante.es:443", t0)
log("TLS connection established ✓", level="INFO")
except Exception as e:
log(f"TLS connect failed: {e}", level="ERROR")
conn = None
# ── PHASE 3: Busy-wait until T-500ms, then fire getHorasCitas early ──
# The slot-verification call is pre-fired 500ms before midnight so its
# response is already in hand when the busy-wait reaches 00:00:00.000.
# This saves ~157ms vs waiting until midnight to start the check.
log("Phase 3: Busy-wait until T-500ms (pre-fire slot verification)...", level="PHASE")
while time.time() < TARGET_TS - 0.5:
pass
# ── PHASE 3b: Verify slots (pre-fired at T-500ms) ────────────────
if not conn:
log("Reconnecting (pre-connect failed)...", level="WARN")
t0 = time.time()
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(HOST, PORT, context=ctx, timeout=20)
conn.connect()
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
t1 = bench("TLS_RECONNECT", "", t0)
log(f"Phase 3b: Verify available slots (getHorasCitas, pre-fired) ...", level="PHASE")
t0 = time.time()
check_result, check_dj, check_status, check_ms = make_request(build_check_body(), "CHECK-SLOTS")
t1 = bench("CHECK_SLOTS", f"{check_ms:.0f}ms — {check_result[:100]}", t0)
available_slots = []
if check_dj:
try:
slots = json.loads(check_dj.get('result', '[]'))
available_slots = [s['hora'] for s in slots]
log(f" {len(available_slots)} slots available: {available_slots}", level="INFO")
except Exception as e:
log(f" Parse warning: {e}", level="WARN")
available_slots = ALL_HORAS # safe fallback
else:
available_slots = ALL_HORAS # fallback on parse error
# ── PHASE 3c: Busy-wait to T-40ms (pre-fire window for booking) ──
# Fire the first booking request 40ms before OUR midnight. The one-way network
# latency (~40ms) means it arrives at the server AT their midnight, making the
# network delay FREE. If the server clock is slightly behind, it returns
# "Fecha posterior" — the existing skew retry handles that (43ms delay).
PREFIRE_OFFSET = 0.040 # one-way RTT to webservices.alicante.es (~38-48ms)
log(f"Phase 3c: Busy-wait to T-{PREFIRE_OFFSET*1000:.0f}ms (pre-fire booking window) ...", level="PHASE")
while time.time() < TARGET_TS - PREFIRE_OFFSET:
pass
t_prefire = time.time()
log(f"*** PRE-FIRE WINDOW (T-{PREFIRE_OFFSET*1000:.0f}ms) ***", level="PHASE")
log(f" First booking request fires now → arrives @ server ~T+0ms", level="PHASE")
log(f" Pre-fire jitter: {(t_prefire - (TARGET_TS - PREFIRE_OFFSET))*1000:+.1f}ms", level="PHASE")
# ── PHASE 4: Attempt booking (fires at T-40ms, arrives @ server T+0) ──
log(f"\nPhase 4: Sequential booking attempts...", level="PHASE")
log(f" Priority order: {PRIORITY}", level="PHASE")
log(f" Only trying slots confirmed available: {[h for h in PRIORITY if h in available_slots]}", level="PHASE")
# ── Booking timing infrastructure ────────────────────────────
t_booking_start = time.time() # total time-to-success reference
slot_timings = [] # per-slot breakdown: {hora, total_ms, attempts, avg_rtt_ms, status}
# IMPORTANT: This loop fires ONE request at a time.
# It EXITS immediately on success — "Cita insertada" means the cita was ALREADY
# inserted into the database, so no further booking action is needed; we just
# confirm and stop.
# Each slot is tried ONCE, with retries ONLY for:
# - "Fecha posterior" (server date not rolled): EACH slot gets its own independent
# 3-phase backoff (43ms×1.5^n first 6, 1.15x gradual to 500ms, 2s×1.2^n extended
# to 120s). WE ARE FIGHTING BOTS — every slot retries until certified taken.
# - "EXCEPTION:" (conn drop): force-reconnect + retry on SAME slot
# - "Hora no permitida": DEFINITIVE certification slot is taken → no retry
# Each slot's retry window is independent — the server date may roll during
# a later slot's retries even if earlier slots exhausted theirs.
# Only give up on a slot when: certified taken ("Hora no permitida") or
# time budget exhausted (can't certify further) or duplicate detected.
# Duplicate detection ("ya existente"/"DNI"/"existe") at ANY point → STOP immediately.
# This prevents duplicate bookings while tolerating clock/network issues.
attempts = [] # in-memory log of all attempts
booked_hora = None
booked_result = None
# Each slot tracks skew retry exhaustion independently — NO global skip flag.
# The server date may roll during another slot's retry window, so every
# slot must get its own full 120s three-phase backoff chance to hit midnight.
skew_retries_exhausted = False # diagnostics only (per-slot, not global skip)
duplicate_detected = False # STOP entire booking process if cita already exists
for hora in PRIORITY:
if hora not in available_slots:
log(f" ↷ {hora}: SKIP (not in available list from Phase 3)")
attempts.append({"hora": hora, "status": "skipped", "response": "not in available list"})
continue
body = build_booking_body(hora)
t0 = time.time()
t_slot_start = time.time() # per-slot total timing
slot_attempt_count = 0 # attempts for this slot
slot_rtts = [] # response times for this slot
result, dj, status, ms = make_request(body, "BOOKING")
slot_attempt_count = 1
slot_rtts.append(ms) # raw RTT, rounding deferred to display
t1 = bench("BOOKING_ATTEMPT", f"{hora} → {ms:.0f}ms — {result[:150]}", t0)
attempts.append({
"hora": hora,
"status": status,
"response": result,
"elapsed_ms": round(ms, 1),
"timestamp": datetime.now().strftime('%H:%M:%S.%f')[:-3]
})
if result == "Cita insertada":
booked_hora = hora
booked_result = result
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
t_success_total = (t_slot_end - t_booking_start) * 1000
t_prefire_to_success = (t_slot_end - t_prefire) * 1000
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": "booked", "rtts": slot_rtts
})
log(f"\n✅✅✅ CITA BOOKED! ✅✅✅", level="SUCCESS")
log(f" Hora: {hora}", level="SUCCESS")
log(f" Fecha: {FECHA}", level="SUCCESS")
log(f" {NOMBRE} {APE1} {APE2} — NIE: {DOC}", level="SUCCESS")
log(f" Cola: {COLA} (C/PORTUGAL 17 TARDE - PADRÓN)", level="SUCCESS")
log(f" Motivo: {COMENTARIO}", level="SUCCESS")
log(f" Server load: {_current_load()}", level="SUCCESS")
log(f" Response time: {ms:.0f}ms", level="SUCCESS")
log(f" Slot total: {slot_total_ms:.0f}ms ({slot_attempt_count} attempt(s))", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms", level="SUCCESS")
log(f" Time-to-success (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
# CRITICAL: exit immediately — no more requests sent
break
elif result == "Hora no permitida":
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": "taken_certified", "rtts": slot_rtts
})
load = _current_load() # _track_rtt already called in make_request — no double-track
log(f" → Slot {hora}: certified taken in {slot_total_ms:.0f}ms (load: {load}). Moving to next.", level="WARN")
elif result == "Fecha posterior al limite permitido":
# "Fecha posterior" is NOT a certification — the server date simply
# hasn't rolled yet. We must keep retrying until one of these:
# 1. Booking succeeds → "Cita insertada" (slot available, not taken)
# 2. Slot certified taken → "Hora no permitida" on retry
# 3. Duplicate detected → "ya existente" / "DNI" / "existe" / "Usted ya tiene cita previa"
# 4. Other definitive error → non-Fecha-posterior response
# 5. Time budget exhausted (120s) → cannot certify, try next slot
log(f" → Date beyond server limit. Retrying {hora} with 3-phase backoff...", level="WARN")
t_slot_start = time.time()
skew_attempt = 0
while skew_attempt < SKEW_MAX_RETRIES:
elapsed = time.time() - t_slot_start
if elapsed >= SKEW_MAX_BUDGET:
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts) if slot_rtts else 0
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "budget_exhausted", "rtts": slot_rtts
})
load = _current_load()
log(f" → {hora}: {elapsed:.1f}s budget exhausted (load: {load}). {slot_attempt_count} attempts. Next slot gets fresh window.", level="WARN")
skew_retries_exhausted = True # diagnostics only — does NOT skip future slots
break
# Three-phase pseudo-exponential backoff:
# Phase 1 (attempts 1-6): 43ms × 1.5^n — aggressive, midnight-fast
# Phase 2 (attempts 7-12): 330ms × 1.15^n, capped 500ms — less spammy
# Phase 3 (attempt 13+): 2s × 1.2^n, capped 15s — extended, catches cancellations
delay = _skew_delay(skew_attempt)
delay = min(delay, SKEW_MAX_BUDGET - elapsed) # don't sleep past budget
time.sleep(delay)
skew_attempt += 1
t0b = time.time()
result2, _, _, ms2 = make_request(body, f"RETRY-SKEW{skew_attempt}")
slot_attempt_count += 1
slot_rtts.append(ms2) # raw RTT, rounding deferred to display
t1 = bench("RETRY", f"{hora} → {ms2:.0f}ms — {result2[:150]}", t0b, category="SKEW")
attempts.append({"hora": hora, "status": f"retry-skew{skew_attempt}", "response": result2, "elapsed_ms": round(ms2, 1)})
if result2 == "Cita insertada":
booked_hora = hora
booked_result = result2
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
t_success_total = (t_slot_end - t_booking_start) * 1000
t_prefire_to_success = (t_slot_end - t_prefire) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "booked_on_retry", "rtts": slot_rtts
})
log(f"\n✅ CITA BOOKED (on skew retry {skew_attempt})! Hora: {hora}", level="SUCCESS")
log(f" Attempts: {slot_attempt_count} | Slot total: {slot_total_ms:.0f}ms | Avg RTT: {avg_rtt:.0f}ms", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms | (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
break
elif _is_duplicate(result2): # covers "ya existente", "DNI", "existe", "Usted ya tiene cita previa"
log(f" → DUPLICATE on retry: {result2[:200]} — STOPPING", level="ERROR")
duplicate_detected = True # signal outer loop to STOP entirely
break
elif result2 == "Hora no permitida":
# ✅ CERTIFIED: slot is genuinely taken — safe to give up THIS slot
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "taken_certified_on_retry", "rtts": slot_rtts
})
log(f" → Slot {hora}: certified taken on retry {skew_attempt} (load: {_current_load()}). Moving to next.", level="WARN")
break
elif result2 != "Fecha posterior al limite permitido":
# Non-certifying error — log and move on
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": f"error: {result2[:50]}", "rtts": slot_rtts
})
log(f" → Retry returned: {result2[:100]} — moving on", level="WARN")
break
# else: still "Fecha posterior" → NOT certified → keep retrying
if booked_hora or duplicate_detected:
break # success OR duplicate certifies — do NOT try any more slots
elif _is_duplicate(result): # covers "ya existente", "DNI", "existe", "Usted ya tiene cita previa"
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": "duplicate", "rtts": slot_rtts
})
log(f" → DUPLICATE: {result[:200]} — STOPPING", level="ERROR")
break
elif result.startswith("EXCEPTION:"):
log(f" → Network ERROR: {result[:150]}", level="WARN")
# make_request already tried reconnecting once. Try force-reconnect now.
try:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(HOST, PORT, context=ctx, timeout=20)
conn.connect()
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
log(" → Force-reconnected ✓ — retrying booking", level="WARN")
t0r = time.time()
result_retry, _, _, ms2 = make_request(body, "RETRY-CONN")
slot_attempt_count += 1
slot_rtts.append(ms2) # raw RTT, rounding deferred to display
bench("RETRY", f"{hora} → {ms2:.0f}ms — {result_retry[:150]}", t0r, category="RECOVERY")
attempts.append({"hora": hora, "status": "retry-conn", "response": result_retry, "elapsed_ms": round(ms2, 1)})
if result_retry == "Cita insertada":
booked_hora = hora
booked_result = result_retry
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
t_success_total = (t_slot_end - t_booking_start) * 1000
t_prefire_to_success = (t_slot_end - t_prefire) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "booked_after_reconnect", "rtts": slot_rtts
})
log(f"\n✅ CITA BOOKED (on retry after conn error)! Hora: {hora}", level="SUCCESS")
log(f" Attempts: {slot_attempt_count} | Slot total: {slot_total_ms:.0f}ms | Avg RTT: {avg_rtt:.0f}ms", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms | (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
break
elif _is_duplicate(result_retry): # covers "ya existente", "DNI", "existe", "Usted ya tiene cita previa"
log(f" → DUPLICATE after reconnect: {result_retry[:200]} — STOPPING", level="ERROR")
duplicate_detected = True
break
elif result_retry.startswith("EXCEPTION:"):
log(f" → Connection retry also failed — moving to next slot", level="WARN")
elif result_retry == "Hora no permitida":
log(f" → Slot {hora} taken after reconnect — moving to next.", level="WARN")
elif result_retry == "Fecha posterior al limite permitido":
log(f" → Retry also hit date limit — moving to next slot", level="WARN")
else:
log(f" → Retry result: {result_retry[:100]}", level="WARN")
except Exception as e:
log(f" → Force-reconnect failed: {e}", level="ERROR")
else:
# Catch-all for any other server response — stop to avoid accidental double-booking
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": f"unexpected: {result[:50]}", "rtts": slot_rtts
})
log(f" → Unexpected server response: {result[:150]}", level="WARN")
log(f" Stopping to avoid accidental duplication. Add to DUPLICATE_PATTERNS if this is a known duplicate signal.", level="WARN")
break
# Small delay between attempts (avoid overwhelming server)
time.sleep(0.03)
# ── PHASE 5: Post-booking verification ─────────────────────
if booked_hora:
log(f"\nPhase 5: Verifying booking (getHorasCitas — slot should be gone)...", level="PHASE")
t0 = time.time()
verify_result, verify_dj, _, verify_ms = make_request(build_check_body(), "VERIFY")
t1 = bench("VERIFY", f"{verify_ms:.0f}ms", t0)
verify_slots = []
if verify_dj:
try:
slots = json.loads(verify_dj.get('result', '[]'))
verify_slots = [s['hora'] for s in slots]
except:
pass
if booked_hora not in verify_slots:
log(f" ✅ VERIFIED: Slot {booked_hora} is gone from available list — booking confirmed!", level="SUCCESS")
log(f" Remaining slots on Oct 19: {verify_slots}")
else:
log(f" ⚠️ WARNING: Slot {booked_hora} still appears available — booking may not have fully committed", level="WARN")
log(f" Slots still showing: {verify_slots}")
# ── BENCHMARK SUMMARY ───────────────────────────────────────
log(f"\n{'='*60}", level="PHASE")
log(f"BENCHMARK SUMMARY — {len(BENCH)} steps, {len(attempts)} booking attempts", level="PHASE")
# Server load analysis
log(f"\n ── SERVER LOAD ESTIMATION ──")
log(f" RTT observations: {len(_rtt_history)} measurements")
log(f" Current load: {_current_load()}")
if _rtt_history:
all_rtt_avg = round(sum(_rtt_history) / len(_rtt_history), 1)
log(f" Session avg RTT: {all_rtt_avg}ms")
if len(_rtt_history) > 1:
log(f" Min RTT: {min(_rtt_history):.0f}ms | Max RTT: {max(_rtt_history):.0f}ms")
trend_label, first_avg, second_avg = _load_trend()
log(f" Load trend: {trend_label}")
if first_avg > 0:
log(f" First-half avg: {first_avg:.0f}ms ({len(_rtt_history)//2} samples)")
log(f" Second-half avg: {second_avg:.0f}ms ({len(_rtt_history) - len(_rtt_history)//2} samples)")
# Per-slot timing summary
log(f"\n ── PER-SLOT TIMING ──")
log(f" {'Hora':<8} {'Total ms':>10} {'Attempts':>10} {'Avg RTT':>10} {'Status':<25}")
log(f" {'─'*9 + ' ─':>8} {'─'*10} {'─'*10} {'─'*10} {'─'*25}")
for st in slot_timings:
log(f" {st['hora']:<8} {st['total_ms']:>10.1f} {st['attempts']:>10} {st['avg_rtt_ms']:>10.1f} {st['status']:<25}")
# Time-to-success summary
if booked_hora:
t_total = (time.time() - t_booking_start) * 1000
log(f"\n ── TIME TO SUCCESS ──")
log(f" Time from pre-fire to booking: {t_total:.0f}ms")
log(f" Slots tried: {len(slot_timings)}")
total_attempts = sum(st['attempts'] for st in slot_timings)
log(f" Total requests sent: {total_attempts}")
for b in BENCH:
log(f" {b['time']} [{b['ms']:7.1f}ms] [{b.get('category', 'REQUEST'):>12}] {b['step']}: {b['detail']}")
# Persist benchmark log
with open(log_file, 'w') as f:
f.write(f"Booking log — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"NIE: {DOC} Cola: {COLA} Fecha: {FECHA}\n")
f.write(f"Tracking: None (clean motivo)\n\n")
f.write("=== ATTEMPTS ===\n")
for a in attempts:
f.write(json.dumps(a, indent=2) + "\n")
f.write("\n=== TIMING ===\n")
for b in BENCH:
f.write(f"{b['time']} [{b['ms']:7.1f}ms] [{b.get('category', 'REQUEST'):>12}] {b['step']}: {b['detail']}\n")
f.write("\n=== PER-SLOT TIMING ===\n")
for st in slot_timings:
f.write(json.dumps(st, indent=2) + "\n")
f.write("\n=== SERVER LOAD ===\n")
f.write(f"RTT observations: {len(_rtt_history)}\n")
f.write(f"Current load: {_current_load()}\n")
f.write(f"Session avg RTT: {round(sum(_rtt_history)/len(_rtt_history), 1) if _rtt_history else 'N/A'}ms\n")
f.write(f"Load trend: {_load_trend()[0]}\n")
log(f"\nLog persisted to: {log_file}", level="PHASE")
if booked_hora:
log(f"\n🎉 SUCCESS: Cita for {NOMBRE} on {FECHA} at {booked_hora}", level="SUCCESS")
log(f" To view/cancel: use NIF={DOC}, Service=SI (composite code: SIX2491454W)")
log(f" Cancel API: anularCitaInsertadaCodigo with codigoCita from obtenerCitaInsertada")
sys.exit(0)
else:
log(f"\n❌ FAILED: All {len(attempts)} attempts exhausted. Competing requests likely grabbed all 9 slots.", level="ERROR")
log(f"New slots open daily for the next working day — try again tomorrow.", level="ERROR")
sys.exit(1)