diff --git a/.gitignore b/.gitignore
index f859bc5..5a016d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Entorno virtual
sunat-env/
+sunat-venv/
venv/
.env
@@ -17,6 +18,9 @@ logs/
# Base de datos SQLite
fallas_descarga.db
+historial.db
+historial.db-shm
+historial.db-wal
# Reportes generados
reportes/
diff --git a/consulta_cpe.py b/consulta_cpe.py
index 8da2389..0018e2a 100644
--- a/consulta_cpe.py
+++ b/consulta_cpe.py
@@ -9,8 +9,10 @@ import requests
import urllib3
from pathlib import Path
from datetime import datetime
+from logger_manager import get_logger
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+logger = get_logger(__name__)
# ============================================================================
class TokenExpiredError(Exception):
@@ -25,7 +27,7 @@ BASE_PATH = "/v1/contribuyente/consultacpe/comprobantes"
TIPO_COMPROBANTE = {
"01": "Factura", "02": "Recibo por Honorarios", "03": "Boleta de Venta",
- "04": "Liquidación de Compra", "07": "Nota de Crédito", "08": "Nota de Débito",
+ "04": "Liquidación de Compra", "07": "Factura - Nota de Crédito", "08": "Factura - Nota de Débito",
}
TIPO_DESCARGA = {"01": "PDF", "02": "XML", "03": "CDR"}
@@ -33,8 +35,7 @@ TIPO_DESCARGA = {"01": "PDF", "02": "XML", "03": "CDR"}
# ============================================================================
def load_token():
if not TOKEN_FILE.exists():
- print(f"❌ No se encontró {TOKEN_FILE}")
- print(" Ejecuta primero: python get_token.py")
+ logger.error(f"No se encontró {TOKEN_FILE}. Ejecuta primero: python get_token.py")
return None
data = json.loads(TOKEN_FILE.read_text())
@@ -43,16 +44,22 @@ def load_token():
now = time.time()
if expires and now > expires:
- print(f"⚠️ Token expiró. Ejecuta de nuevo: python get_token.py")
+ logger.warning("Token expiró. Ejecuta de nuevo: python get_token.py")
return None
remaining = expires - now
- print(f"🔑 Token válido por {int(remaining // 60)} min {int(remaining % 60)} seg")
+ logger.info(f"Token válido por {int(remaining // 60)} min {int(remaining % 60)} seg")
return token
# ============================================================================
+# La API de consultacpe no acepta el código de Indicator de HANA tal cual para
+# notas de crédito/débito: espera "F7"/"F8" en vez de "07"/"08" (confirmado
+# contra la URL real que usa el formulario de SUNAT), sin importar la serie.
+TIPO_URL = {"07": "F7", "08": "F8"}
+
def cpe_id(ruc, tipo, serie, numero):
- return f"{ruc}-{tipo}-{serie}-{numero}-2"
+ tipo_url = TIPO_URL.get(tipo, tipo)
+ return f"{ruc}-{tipo_url}-{serie}-{numero}-2"
# ============================================================================
def get_headers(token):
@@ -70,8 +77,7 @@ def consultar_cpe(token, ruc_emisor, tipo, serie, numero, descargar=True, verbos
url = f"{API_HOST}{BASE_PATH}/{cid}"
if verbose:
- print(f"\n🌐 Consultando: {ruc_emisor}-{tipo}-{serie}-{numero}")
- print(f" {url}")
+ logger.info(f"Consultando: {ruc_emisor}-{tipo}-{serie}-{numero} ({url})")
session = requests.Session()
session.verify = False
@@ -82,20 +88,27 @@ def consultar_cpe(token, ruc_emisor, tipo, serie, numero, descargar=True, verbos
try:
r = session.get(url, timeout=120)
if verbose:
- print(f"📡 {r.status_code}")
- break
+ logger.info(f"Respuesta: {r.status_code}")
+ if r.status_code < 500:
+ break
+ if intento < 2:
+ if verbose:
+ logger.warning(f"Error {r.status_code}, reintentando...")
+ time.sleep(2 * (intento + 1))
+ elif verbose:
+ logger.error(f"Error {r.status_code} tras 3 intentos")
except Exception as e:
if verbose:
- print(f" ⚠️ Intento {intento+1} falló: {e}")
+ logger.warning(f"Intento {intento+1} falló: {e}")
if intento < 2:
time.sleep(3)
else:
if verbose:
- print(f" ❌ Error después de 3 intentos")
- return None, 0
+ logger.error("Error después de 3 intentos")
+ return None, 0, None
if r is None:
- return None, 0, 0
+ return None, 0, None
if r.status_code == 200:
data = r.json()
@@ -105,26 +118,26 @@ def consultar_cpe(token, ruc_emisor, tipo, serie, numero, descargar=True, verbos
json_file = DOWNLOAD_DIR / f"{safe}_{ts}.json"
json_file.write_text(json.dumps(data, indent=2, ensure_ascii=False))
if verbose:
- print(f"💾 JSON: {json_file}")
+ logger.info(f"JSON: {json_file}")
if descargar:
for tipo_dl in ["02", "01", "03"]:
descargar_archivo(token, cid, tipo_dl, ts, verbose=verbose)
- return data, 200
+ return data, 200, str(json_file)
elif r.status_code == 401:
if verbose:
- print(" ❌ Token inválido. Ejecuta: python get_token.py")
+ logger.error("Token inválido. Ejecuta: python get_token.py")
raise TokenExpiredError("Token expirado o inválido (401)")
elif r.status_code == 404:
if verbose:
- print(" ❌ No encontrado (404)")
+ logger.error("No encontrado (404)")
else:
if verbose:
- print(f" ❌ Error {r.status_code}: {r.text[:300]}")
+ logger.error(f"Error {r.status_code}: {r.text[:300]}")
- return None, r.status_code
+ return None, r.status_code, None
# ============================================================================
def descargar_archivo(token, cid, tipo, ts, verbose=True):
@@ -132,8 +145,7 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
url = f"{API_HOST}{BASE_PATH}/{cid}/{tipo}"
if verbose:
- print(f"\n📥 Descargando {label}...")
- print(f" {url}")
+ logger.info(f"Descargando {label}... ({url})")
session = requests.Session()
session.verify = False
@@ -144,16 +156,23 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
try:
r = session.get(url, timeout=120)
if verbose:
- print(f"📡 {r.status_code}")
- break
+ logger.info(f"Respuesta: {r.status_code}")
+ if r.status_code < 500:
+ break
+ if intento < 2:
+ if verbose:
+ logger.warning(f"Error {r.status_code}, reintentando...")
+ time.sleep(2 * (intento + 1))
+ elif verbose:
+ logger.error(f"Error {r.status_code} tras 3 intentos")
except Exception as e:
if verbose:
- print(f" ⚠️ Intento {intento+1} falló: {e}")
+ logger.warning(f"Intento {intento+1} falló: {e}")
if intento < 2:
time.sleep(3)
else:
if verbose:
- print(f" ❌ Error después de 3 intentos")
+ logger.error("Error después de 3 intentos")
return False
if r is None:
@@ -165,7 +184,7 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
if not val_archivo:
if verbose:
- print(f" ⚠️ Respuesta sin valArchivo")
+ logger.warning("Respuesta sin valArchivo")
return False
raw = base64.b64decode(val_archivo)
@@ -175,7 +194,7 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
zip_path = DOWNLOAD_DIR / zip_name
zip_path.write_bytes(raw)
if verbose:
- print(f" 💾 ZIP: {zip_path} ({len(raw)} bytes)")
+ logger.info(f"ZIP: {zip_path} ({len(raw)} bytes)")
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
@@ -200,23 +219,23 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
out_path = DOWNLOAD_DIR / out_name
out_path.write_bytes(content)
if verbose:
- print(f" ✅ {out_name} ({len(content)} bytes)")
+ logger.info(f"{out_name} ({len(content)} bytes)")
except Exception as e:
if verbose:
- print(f" ⚠️ Error extrayendo ZIP: {e}")
+ logger.warning(f"Error extrayendo ZIP: {e}")
return True
elif r.status_code == 401:
if verbose:
- print(" ❌ Token inválido (401)")
+ logger.error("Token inválido (401)")
raise TokenExpiredError("Token expirado o inválido (401)")
elif r.status_code == 400:
if verbose:
- print(f" ⚠️ No disponible (400)")
+ logger.warning("No disponible (400)")
else:
if verbose:
- print(f" ❌ Error {r.status_code}")
+ logger.error(f"Error {r.status_code}")
return False
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index f93ab4a..69dcdf1 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -26,6 +26,7 @@ WantedBy=multi-user.target
```
```bash
+# Guarda el bloque [Unit]/[Service]/[Install] de arriba en un archivo, ej. sunat-cpe.service
sudo cp sunat-cpe.service /etc/systemd/system/
sudo systemctl enable sunat-cpe
sudo systemctl start sunat-cpe
@@ -63,7 +64,7 @@ NSSM redirige stdout/stderr a `C:\ruta\descarga_xml_sunat\logs\` por defecto (el
Como servicio, el daemon usa `get_token_headless()` (headless, Firefox real) para renovar el token automáticamente sin intervención. Si Firefox no está instalado, el daemon lo detecta y usa `get_token.py headless=False` como respaldo (requiere escritorio). Para renovar manualmente:
```cmd
-C:\ruta\descarga_xml_sunat\sunat-env\Scripts\python get_token.py
+C:\ruta\descarga_xml_sunat\sunat-venv\Scripts\python get_token.py
```
### Actualización
@@ -83,4 +84,4 @@ COPY . .
CMD ["python", "main.py"]
```
-Asegúrate de que `requirements.txt` incluya: `playwright`, `python-dotenv`, `requests`, `hdbcli`, `matplotlib`.
+Asegúrate de que `requirements.txt` incluya: `playwright`, `python-dotenv`, `requests`, `hdbcli`.
diff --git a/docs/MODULES.md b/docs/MODULES.md
index 67069c7..a4fb632 100644
--- a/docs/MODULES.md
+++ b/docs/MODULES.md
@@ -2,22 +2,22 @@
| Archivo | Función |
|---|---|
-| `main.py` | Daemon orquestador. Bucle infinito con horarios configurables, obtiene token, consulta HANA, procesa pendientes (nuevos + reintentos). |
+| `main.py` | Daemon orquestador. Bucle infinito con horarios configurables (`scheduler.json`), obtiene token, consulta HANA, procesa pendientes (nuevos + reintentos). |
| `get_token_headless.py` | Login headless via Firefox real. Sin ventana visible. |
| `get_token.py` | Login SOL via Playwright (headless=False). Navega menú → extrae JWT de `sessionStorage['SUNAT.token']`. |
| `consulta_cpe.py` | Consulta API REST `api-cpe.sunat.gob.pe`. Retorna `(data, status_code)`. Lanza `TokenExpiredError` en 401. |
| `hana_manager.py` | Conexión HANA vía `hdbcli`. Consulta pendientes en `OPCH`, actualiza flags de descarga. |
| `gestor_fallas.py` | SQLite `fallas_descarga.db`. Registro de fallas con conteo de intentos y backoff progresivo (0min → 5min → 15min → ... → 12h, máx 20). |
+| `historial_manager.py` | SQLite `historial.db`. Registra cada intento de consulta/descarga (éxito o falla) para auditoría; limpia registros de más de 180 días. Incluye CLI (`python historial_manager.py --ruc/--serie/--numero/--tipo`) para buscar intentos. |
+| `logger_manager.py` | Logging con formato de árbol (indentado por tareas anidadas) a consola + archivo. Rotación diaria en `logs/sunat_YYYY-MM-DD.log`, limpieza automática de logs con más de 7 días. |
| `smb_manager.py` | Monta recurso SMB (`mount_smbfs`/`mount.cifs`/`net use`) y sincroniza PDF/XML al NAS. Limpia JSON/ZIP temporales. |
-| `login_api.py` | Intento de `password_grant()` / `authorization_code` directo a SUNAT (sin navegador, generalmente ya no funciona). Se mantiene como referencia. |
-| `logger_utilities.py` | Redirige stdout/stderr a `logs/YYYY-MM-DD.log` + terminal. Rotación diaria. |
-| `reporte_fallas.py` | Genera resumen textual + 3 gráficos PNG desde `fallas_descarga.db`. |
+| `scheduler.json` | Horarios activos (`HORARIOS`) y días de la semana en que corre el daemon (`DIAS_ACTIVOS`). Editable sin tocar código. |
| `token.json` | Token JWT auto-generado con expiración. |
| `.env` | Credenciales HANA y SUNAT. |
## Flujo
-1. `main.py` verifica día/horario → renueva token si expiró (get_token_headless → get_token.py headless=False)
+1. `main.py` verifica día/horario (`scheduler.json`) → renueva token si expiró (get_token_headless → get_token.py headless=False)
2. `obtener_pendientes()` trae todos los documentos de OPCH donde `tienePDF='N'` o `tieneXML='N'`
3. Separa en **nuevos** (no están en fallas DB) y **reintentos** (fallas pendientes con backoff cumplido)
4. Por cada doc:
@@ -26,5 +26,8 @@
- Si OK → `descargar_archivo()` (XML + PDF + CDR)
- `marcar_pdf_descargado()` / `marcar_xml_descargado()` actualizan HANA
- Si era reintento exitoso → `eliminar_falla()`
+ - `registrar_intento()` deja constancia del resultado en `historial.db`
5. `sincronizar_a_smb()` monta NAS (si no está montado), copia PDF/XML via rsync (Mac/Linux) o shutil.move (Windows), y elimina JSON/ZIP temporales de `descargas/`
-6. Espera intervalo configurado (5 min modo rápido, 1 h modo full) y repite
+6. Espera intervalo configurado (según `scheduler.json`, ej. 5 min modo rápido, 30 min/1 h modo full) y repite
+
+Al arrancar, `main.py` también corre mantenimiento: `limpiar_logs_antiguos(dias=7)` y `limpiar_historial_antiguo(dias=180)`.
diff --git a/docs/SETUP.md b/docs/SETUP.md
index bc46ca0..bafb6dd 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -12,8 +12,8 @@
```bash
# Crear y activar entorno virtual (recomendado)
-python3 -m venv sunat-env
-source sunat-env/bin/activate
+python3 -m venv sunat-venv
+source sunat-venv/bin/activate
# Instalar dependencias
pip install -r requirements.txt
@@ -50,7 +50,7 @@ SMB_SUBFOLDER=COMPRAS/TAGUMEDICA/ADJUNTOS_CORREOS
```bash
# Activar entorno virtual
-source sunat-env/bin/activate
+source sunat-venv/bin/activate
# Obtener token manualmente (con navegador visible)
python get_token.py
@@ -61,10 +61,10 @@ python get_token_headless.py
# Daemon completo (automático)
python main.py
-# Reporte de fallas (resumen texto + gráficos PNG)
-python reporte_fallas.py
+# Buscar intentos de descarga en el historial
+python historial_manager.py --ruc 20503662869 --serie F001 --numero 123
```
## Logs
-Todos los logs se guardan en `logs/YYYY-MM-DD.log`. Rotación automática diaria, los archivos mayores a 7 días se eliminan.
+Todos los logs se guardan en `logs/sunat_YYYY-MM-DD.log`. Rotación automática diaria, los archivos mayores a 7 días se eliminan.
diff --git a/gestor_fallas.py b/gestor_fallas.py
index c7b1aa4..5f8985b 100644
--- a/gestor_fallas.py
+++ b/gestor_fallas.py
@@ -16,12 +16,18 @@ def init_db():
tipo TEXT NOT NULL,
serie TEXT NOT NULL,
numero TEXT NOT NULL,
+ objtype INTEGER NOT NULL DEFAULT 18,
error TEXT NOT NULL,
intentos INTEGER NOT NULL DEFAULT 1,
proximo_intento TEXT,
fecha_ultimo TEXT NOT NULL
)
""")
+ columnas = {row[1] for row in conn.execute("PRAGMA table_info(fallas)")}
+ if "obj_type" in columnas and "objtype" not in columnas:
+ conn.execute("ALTER TABLE fallas RENAME COLUMN obj_type TO objtype")
+ elif "objtype" not in columnas:
+ conn.execute("ALTER TABLE fallas ADD COLUMN objtype INTEGER NOT NULL DEFAULT 18")
# ============================================================================
def _calcular_proximo_intento(intentos):
if intentos >= 20:
@@ -44,7 +50,7 @@ def _calcular_proximo_intento(intentos):
delta = timedelta(hours=12)
return (datetime.now() + delta).isoformat()
# ============================================================================
-def registrar_falla(docentry, ruc, tipo_cpe, serie, numero, error, intentos=None):
+def registrar_falla(docentry, ruc, tipo_cpe, serie, numero, obj_type, error, intentos=None):
ahora = datetime.now().isoformat()
if intentos is None:
with _conn() as conn:
@@ -56,9 +62,9 @@ def registrar_falla(docentry, ruc, tipo_cpe, serie, numero, error, intentos=None
with _conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO fallas
- (docentry, ruc, tipo, serie, numero, error, intentos, proximo_intento, fecha_ultimo)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- """, (docentry, ruc, tipo_cpe, serie, numero, str(error)[:200], intentos, proximo, ahora))
+ (docentry, ruc, tipo, serie, numero, objtype, error, intentos, proximo_intento, fecha_ultimo)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, (docentry, ruc, tipo_cpe, serie, numero, obj_type, str(error)[:200], intentos, proximo, ahora))
# ============================================================================
def eliminar_falla(docentry):
with _conn() as conn:
diff --git a/get_token.py b/get_token.py
index 6845242..2c3b162 100644
--- a/get_token.py
+++ b/get_token.py
@@ -4,9 +4,11 @@ import time
from pathlib import Path
from dotenv import load_dotenv
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
+from logger_manager import get_logger
# ============================================================================
load_dotenv()
+logger = get_logger(__name__)
SOL_URL = "https://www.sunat.gob.pe/sol.html"
MENU_URL = "https://e-menu.sunat.gob.pe/cl-ti-itmenucabina/MenuInternet.htm"
@@ -18,15 +20,23 @@ RUC = os.getenv("SUNAT_RUC", "").strip()
USER = os.getenv("SUNAT_USER", "").strip()
PASSWORD = os.getenv("SUNAT_PASSWORD", "").strip()
+# ============================================================================
+def _esperar_enter(mensaje):
+ """input() que no revienta si no hay terminal interactiva (ej. corriendo como LaunchAgent)."""
+ try:
+ input(mensaje)
+ except EOFError:
+ logger.warning("Sin terminal interactiva disponible, continuando sin pausa.")
+
# ============================================================================
def save_screenshot(page, name):
path = SCREENSHOT_DIR / f"{name}.png"
page.screenshot(path=str(path))
- print(f" 📸 Screenshot guardado: {path}")
+ logger.info(f"Screenshot guardado: {path}")
# ============================================================================
def get_token(headless=False, timeout=60000):
- print("🚀 Iniciando navegador...")
+ logger.info("Iniciando navegador...")
with sync_playwright() as p:
browser = p.firefox.launch(
headless=headless,
@@ -41,71 +51,71 @@ def get_token(headless=False, timeout=60000):
page.set_default_timeout(timeout)
# ─── 1. Ir a SOL ───────────────────────────────────
- print("1. Navegando a sol.html...")
+ logger.info("1. Navegando a sol.html...")
page.goto(SOL_URL, wait_until="domcontentloaded")
save_screenshot(page, "01_sol_home")
# ─── 2. Click "Ingresar" en Mis Tramites y Consultas ──
- print("2. Click en 'Ingresar' de Mis Tramites y Consultas...")
+ logger.info("2. Click en 'Ingresar' de Mis Tramites y Consultas...")
with page.expect_popup() as popup_info:
page.evaluate("tramiteConsulta()")
popup = popup_info.value
popup.wait_for_load_state("domcontentloaded")
save_screenshot(popup, "02_popup_login")
- print(f" Popup URL: {popup.url}")
+ logger.info(f"Popup URL: {popup.url}")
# ─── 3. Login ─────────────────────────────────────
- print("3. Verificando si hay formulario de login...")
+ logger.info("3. Verificando si hay formulario de login...")
popup.wait_for_load_state("networkidle")
- print(f" Título popup: {popup.title()}")
+ logger.info(f"Título popup: {popup.title()}")
has_inputs = len(popup.locator("input[type='password']").all()) > 0
- print(f" ¿Tiene campo password?: {has_inputs}")
+ logger.info(f"¿Tiene campo password?: {has_inputs}")
if has_inputs or any(k in popup.url.lower() for k in ["login", "autentica"]):
- print(" Formulario de login detectado, rellenando credenciales...")
+ logger.info("Formulario de login detectado, rellenando credenciales...")
fill_login_form(popup)
save_screenshot(popup, "03_form_rellenado")
- print(" Enviando formulario...")
+ logger.info("Enviando formulario...")
popup.keyboard.press("Enter")
popup.wait_for_load_state("networkidle", timeout=30000)
popup.wait_for_timeout(3000)
save_screenshot(popup, "04_post_login")
- print(f" URL post-login: {popup.url}")
+ logger.info(f"URL post-login: {popup.url}")
else:
- print(" No se detectó login. Imprimiendo HTML de la página...")
+ logger.warning("No se detectó login. Imprimiendo HTML de la página...")
try:
html = popup.content()
- print(f" HTML length: {len(html)}")
+ logger.info(f"HTML length: {len(html)}")
# find form-related elements
for el in ["form", "input", "login", "password", "ruc", "usuario"]:
if el in html.lower():
idx = html.lower().index(el)
- print(f" '{el}' encontrado en posición {idx}: ...{html[max(0,idx-50):idx+100]}...")
+ logger.info(f"'{el}' encontrado en posición {idx}: ...{html[max(0,idx-50):idx+100]}...")
except Exception as e:
- print(f" Error leyendo HTML: {e}")
+ logger.error(f"Error leyendo HTML: {e}")
# ─── 4. Token via consulta directa ────────────────
- print("4. Accediendo directamente a consulta CPE con sesión activa...")
+ logger.info("4. Accediendo directamente a consulta CPE con sesión activa...")
token = get_token_from_direct_access(context, popup)
# ─── 5. Guardar o debug ───────────────────────────
if token:
- print(f" ✅ Token obtenido!")
+ logger.info("Token obtenido!")
save_token(token)
save_screenshot(popup, "05_token_obtenido")
browser.close()
return token
- print(" ❌ Token no encontrado. Revisa screenshots.")
- print("\n📋 Frames en la página:")
+ logger.error("Token no encontrado. Revisa screenshots.")
+ logger.info("Frames en la página:")
for i, frm in enumerate(popup.frames):
- print(f" [{i}] name='{frm.name}' url='{frm.url[:100]}'")
+ logger.info(f"[{i}] name='{frm.name}' url='{frm.url[:100]}'")
if not headless:
- input("\n⏸️ Presiona Enter para cerrar el navegador...")
+ _esperar_enter("\n⏸️ Presiona Enter para cerrar el navegador...")
browser.close()
return None
@@ -114,7 +124,7 @@ def fill_login_form(page):
page.wait_for_timeout(1000)
inputs = page.locator("input").all()
- print(f" Inputs encontrados: {len(inputs)}")
+ logger.info(f"Inputs encontrados: {len(inputs)}")
for inp in inputs:
try:
name = inp.get_attribute("name") or ""
@@ -122,36 +132,36 @@ def fill_login_form(page):
type_ = inp.get_attribute("type") or ""
placeholder = inp.get_attribute("placeholder") or ""
visible = inp.is_visible()
- print(f" id='{id_}' name='{name}' type='{type_}' placeholder='{placeholder}' visible={visible}")
+ logger.info(f"id='{id_}' name='{name}' type='{type_}' placeholder='{placeholder}' visible={visible}")
except:
pass
- print(f" Rellenando RUC: {RUC}")
+ logger.info(f"Rellenando RUC: {RUC}")
page.fill("#txtRuc", RUC)
- print(f" Rellenando Usuario: {USER}")
+ logger.info(f"Rellenando Usuario: {USER}")
page.fill("#txtUsuario", USER)
- print(f" Rellenando Contraseña: {'*' * len(PASSWORD)}")
+ logger.info(f"Rellenando Contraseña: {'*' * len(PASSWORD)}")
page.fill("#txtContrasena", PASSWORD)
captcha = page.locator("input[id='captcha']")
if captcha.is_visible():
- print(" ⚠️ CAPTCHA detectado en la página!")
+ logger.warning("CAPTCHA detectado en la página!")
save_screenshot(page, "03_captcha_detectado")
- input("\n⏸️ Resuelve el captcha manualmente y presiona Enter...")
+ _esperar_enter("\n⏸️ Resuelve el captcha manualmente y presiona Enter...")
else:
- print(" ✅ Captcha no detectado")
+ logger.info("Captcha no detectado")
# ============================================================================
def get_token_from_direct_access(context, login_page):
- print(" Buscando enlace con token en el menú SOL...")
+ logger.info("Buscando enlace con token en el menú SOL...")
token = extract_token_from_menu(login_page)
if token:
- print(f" ✅ Token encontrado en menú (href)")
+ logger.info("Token encontrado en menú (href)")
return token
- print(" Navegando árbol de menú SOL...")
+ logger.info("Navegando árbol de menú SOL...")
return navigate_sol_menu(login_page)
# ============================================================================
@@ -188,63 +198,63 @@ def navigate_sol_menu(page):
page.wait_for_timeout(2000)
# Debug: imprimir info de todos los frames
- print(" Frames activos:")
+ logger.info("Frames activos:")
for frm in page.frames:
- print(f" [{frm.name}] {frm.url[:120]}")
+ logger.info(f"[{frm.name}] {frm.url[:120]}")
if "consultacpe" in frm.url or (frm.name and "consulta" in frm.name.lower()):
try:
html = frm.content()
- print(f" Content length: {len(html)}")
+ logger.info(f"Content length: {len(html)}")
debug_path = SCREENSHOT_DIR / "iframe_content.html"
with open(debug_path, "w", encoding="utf-8") as f:
f.write(html)
- print(f" HTML guardado en {debug_path}")
+ logger.info(f"HTML guardado en {debug_path}")
import re
tokens = re.findall(r'token[=:][^&\s"\']{50,}', html)
if tokens:
- print(f" Tokens encontrados (token=): {len(tokens)}")
+ logger.info(f"Tokens encontrados (token=): {len(tokens)}")
for t in tokens[:3]:
- print(f" {t[:80]}...")
+ logger.info(f"{t[:80]}...")
jwt_matches = re.findall(r'eyJ[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+', html)
if jwt_matches:
- print(f" JWT encontrados: {len(jwt_matches)}")
+ logger.info(f"JWT encontrados: {len(jwt_matches)}")
for j in jwt_matches[:3]:
- print(f" {j[:80]}...")
+ logger.info(f"{j[:80]}...")
t = extract_token_from_url(j)
if t:
return t
ls = frm.evaluate("JSON.stringify(localStorage)")
- print(f" localStorage: {ls[:200]}")
+ logger.info(f"localStorage: {ls[:200]}")
ss = frm.evaluate("JSON.stringify(sessionStorage)")
- print(f" sessionStorage: {ss[:200]}")
+ logger.info(f"sessionStorage: {ss[:200]}")
except Exception as e:
- print(f" Error: {e}")
+ logger.error(f"Error: {e}")
return None
# ============================================================================
def click_menu_item_by_id(page, element_id, expected_text):
- print(f" Buscando elemento: #{element_id} ('{expected_text}')...")
+ logger.info(f"Buscando elemento: #{element_id} ('{expected_text}')...")
for frm in page.frames:
try:
el = frm.locator(f"#{element_id}").first
if el.count() > 0:
visible = el.is_visible()
- print(f" encontrado en frame '{frm.name}' visible={visible}")
+ logger.info(f"encontrado en frame '{frm.name}' visible={visible}")
# Intentar click en el span interior
span = frm.locator(f"#{element_id} span.spanNivelDescripcion")
if span.count() > 0:
- print(f" clickeando span...")
+ logger.info("clickeando span...")
span.first.click()
else:
- print(f" clickeando li directamente...")
+ logger.info("clickeando li directamente...")
el.click()
page.wait_for_timeout(1500)
@@ -256,19 +266,19 @@ def click_menu_item_by_id(page, element_id, expected_text):
pass
# Fallback: buscar por texto
- print(f" No encontrado por ID. Buscando por texto...")
+ logger.info("No encontrado por ID. Buscando por texto...")
for frm in page.frames:
try:
el = frm.locator(f"span:has-text('{expected_text}')").first
if el.count() > 0 and el.is_visible():
- print(f" clickeando span con texto...")
+ logger.info("clickeando span con texto...")
el.click()
page.wait_for_timeout(1500)
return None
except:
pass
- print(f" ❌ No se pudo encontrar: {expected_text}")
+ logger.warning(f"No se pudo encontrar: {expected_text}")
return None
# ============================================================================
@@ -277,7 +287,7 @@ def check_for_token(page):
for url in urls:
t = extract_token_from_url(url)
if t:
- print(f" ✅ Token encontrado en URL")
+ logger.info("Token encontrado en URL")
return t
import re
@@ -288,13 +298,13 @@ def check_for_token(page):
if "consultacpe" not in frm_url and "nuevaconsulta" not in (frm.name or ""):
continue
- print(f" Buscando token en frame '{frm.name}' ({frm_url[:100]}...)")
+ logger.info(f"Buscando token en frame '{frm.name}' ({frm_url[:100]}...)")
# Intentar sessionStorage
try:
ss_token = frm.evaluate("sessionStorage.getItem('SUNAT.token')")
if ss_token and len(ss_token) > 50:
- print(f" ✅ Token encontrado en sessionStorage")
+ logger.info("Token encontrado en sessionStorage")
return ss_token
except:
pass
@@ -303,7 +313,7 @@ def check_for_token(page):
try:
ls_token = frm.evaluate("localStorage.getItem('SUNAT.token')")
if ls_token and len(ls_token) > 50:
- print(f" ✅ Token encontrado en localStorage")
+ logger.info("Token encontrado en localStorage")
return ls_token
except:
pass
@@ -316,7 +326,7 @@ def check_for_token(page):
ss_data = json.loads(all_ss)
for key, value in ss_data.items():
if "token" in key.lower() and len(str(value)) > 50:
- print(f" ✅ Token encontrado en sessionStorage key='{key}'")
+ logger.info(f"Token encontrado en sessionStorage key='{key}'")
return value
except:
pass
@@ -326,13 +336,13 @@ def check_for_token(page):
matches = re.findall(r'token=([a-zA-Z0-9_\-\.]+)', content)
for m in matches:
if len(m) > 50:
- print(f" ✅ Token encontrado en HTML")
+ logger.info("Token encontrado en HTML")
return m
matches = re.findall(r'access_token["\']?\s*[:=]\s*["\']([a-zA-Z0-9_\-\.]+)["\']', content)
for m in matches:
if len(m) > 50:
- print(f" ✅ Access Token encontrado en HTML")
+ logger.info("Access Token encontrado en HTML")
return m
# Buscar JWT en todo el HTML
@@ -340,17 +350,17 @@ def check_for_token(page):
for j in jwt_matches:
t = extract_token_from_url(j)
if t:
- print(f" ✅ JWT encontrado en HTML")
+ logger.info("JWT encontrado en HTML")
return t
except Exception as e:
- print(f" Error: {e}")
+ logger.error(f"Error: {e}")
return None
# ============================================================================
def extract_token_from_menu(page):
- print(" Analizando estructura del menú SOL...")
+ logger.info("Analizando estructura del menú SOL...")
for frm in page.frames:
try:
@@ -384,18 +394,18 @@ def extract_token_from_menu(page):
if result:
for r in result:
if r.get('text') and r['text'].strip():
- print(f" [{r['tag']}] vis={r['visible']} disp={r['display']} id='{r['id']}' cls='{r['cls']}'")
- print(f" text: {r['text'][:100]}")
+ logger.info(f"[{r['tag']}] vis={r['visible']} disp={r['display']} id='{r['id']}' cls='{r['cls']}'")
+ logger.info(f"text: {r['text'][:100]}")
if r.get('href'):
- print(f" href: {r['href'][:120]}")
+ logger.info(f"href: {r['href'][:120]}")
if r.get('onclick'):
- print(f" onclick: {r['onclick'][:120]}")
+ logger.info(f"onclick: {r['onclick'][:120]}")
if 'token=' in r.get('href', ''):
token = extract_token_from_url(r['href'])
if token:
return token
except Exception as e:
- print(f" Error analizando frame: {e}")
+ logger.error(f"Error analizando frame: {e}")
return None
@@ -431,8 +441,8 @@ def save_token(token):
data = {"token": token, "expires_at": 0}
TOKEN_FILE.write_text(json.dumps(data, indent=2))
- print(f" 💾 Token guardado en: {TOKEN_FILE}")
- print(f" ⏰ Expira: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(data['expires_at']))}")
+ logger.info(f"Token guardado en: {TOKEN_FILE}")
+ logger.info(f"Expira: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(data['expires_at']))}")
# ============================================================================
if __name__ == "__main__":
diff --git a/get_token_headless.py b/get_token_headless.py
index 2a4ad96..a9ca700 100644
--- a/get_token_headless.py
+++ b/get_token_headless.py
@@ -10,6 +10,9 @@ from get_token import (
fill_login_form, save_token, save_screenshot,
check_for_token, navigate_sol_menu, extract_token_from_menu,
)
+from logger_manager import get_logger
+
+logger = get_logger(__name__)
# ============================================================================
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) "
@@ -47,16 +50,16 @@ def get_token_headless(timeout=90000):
page = context.new_page()
page.set_default_timeout(timeout)
- print("1. Sesión desde SOL...")
+ logger.info("1. Sesión desde SOL...")
page.goto("https://www.sunat.gob.pe/sol.html", wait_until="domcontentloaded")
- print("2. Navegando a menú directo...")
+ logger.info("2. Navegando a menú directo...")
page.goto(MENU_URL, wait_until="domcontentloaded")
page.wait_for_load_state("networkidle")
has_inputs = len(page.locator("input[type='password']").all()) > 0
if has_inputs or any(k in page.url.lower() for k in ["login", "autentica", "auth"]):
- print("3. Login detectado, rellenando credenciales...")
+ logger.info("3. Login detectado, rellenando credenciales...")
fill_login_form(page)
page.keyboard.press("Enter")
try:
@@ -65,9 +68,9 @@ def get_token_headless(timeout=90000):
pass
page.wait_for_timeout(3000)
else:
- print("3. Sin login (sesión activa)")
+ logger.info("3. Sin login (sesión activa)")
- print("4. Buscando token en menú...")
+ logger.info("4. Buscando token en menú...")
page.wait_for_timeout(2000)
token = check_for_token(page)
if not token:
@@ -79,11 +82,11 @@ def get_token_headless(timeout=90000):
browser.close()
if token:
- print(" ✅ Token obtenido (headless)")
+ logger.info("Token obtenido (headless)")
save_token(token)
return token
- print(" ❌ No se encontró token")
+ logger.error("No se encontró token")
return None
# ============================================================================
diff --git a/hana_manager.py b/hana_manager.py
index 9af2911..1fd8654 100644
--- a/hana_manager.py
+++ b/hana_manager.py
@@ -2,9 +2,11 @@ import os
import json
from pathlib import Path
from dotenv import load_dotenv
+from logger_manager import get_logger
# ============================================================================
load_dotenv()
+logger = get_logger(__name__)
HANA_HOST = os.getenv("HANA_HOST", "")
HANA_PORT = int(os.getenv("HANA_PORT", 30015))
@@ -27,7 +29,7 @@ def get_connection():
password=HANA_PASSWORD
)
except Exception as e:
- print(f" ❌ Error conectando a HANA: {e}")
+ logger.error(f"Error conectando a HANA: {e}")
return None
return _conn
@@ -49,6 +51,7 @@ def obtener_pendientes() -> list[dict]:
"LicTradNum",
"FolioPref",
"FolioNum",
+ "ObjType",
"U_PGX_REPIMP" "urlPDF",
"U_PGX_PDF" "tienePDF",
"U_PGX_REPIMPXML" "urlXML",
@@ -56,9 +59,35 @@ def obtener_pendientes() -> list[dict]:
FROM "{HANA_SCHEMA}".OPCH
WHERE ("U_PGX_PDF" = 'N' OR "U_PGX_XML" = 'N')
AND EXTRACT(YEAR FROM "DocDate") = 2026
- AND "Indicator" IN ('01', '02','08')
+ AND "Indicator" IN ('01','02','03')
AND "CANCELED" = 'N'
- ORDER BY EXTRACT(MONTH FROM "DocDate") DESC
+ AND "LicTradNum" NOT IN ('20100047218','20100130204','20100053455')
+ AND SUBSTRING("FolioPref", 1, 1) NOT BETWEEN '0' AND '9'
+
+ UNION ALL
+
+ SELECT
+ "DocEntry",
+ "DocNum",
+ "DocDate",
+ EXTRACT(MONTH FROM "DocDate") "Mes",
+ "Indicator",
+ "LicTradNum",
+ "FolioPref",
+ "FolioNum",
+ "ObjType",
+ "U_PGX_REPIMP" "urlPDF",
+ "U_PGX_PDF" "tienePDF",
+ "U_PGX_REPIMPXML" "urlXML",
+ "U_PGX_XML" "tieneXML"
+ FROM "{HANA_SCHEMA}".ORPC
+ WHERE ("U_PGX_PDF" = 'N' OR "U_PGX_XML" = 'N')
+ AND EXTRACT(YEAR FROM "DocDate") = 2026
+ AND "Indicator" IN ('07')
+ AND "CANCELED" = 'N'
+ AND "LicTradNum" NOT IN ('20100047218','20100130204','20100053455')
+ AND SUBSTRING("FolioPref", 1, 1) NOT BETWEEN '0' AND '9'
+
"""
cursor = conn.cursor()
cursor.execute(query)
@@ -68,45 +97,47 @@ def obtener_pendientes() -> list[dict]:
result = [dict(zip(columns, row)) for row in rows]
return result
except Exception as e:
- print(f" ❌ Error en query HANA: {e}")
+ logger.error(f"Error en query HANA: {e}")
return []
# ============================================================================
-def marcar_pdf_descargado(docentry: int, url: str, ruta: str):
+def marcar_pdf_descargado(docentry: int, url: str, ruta: str, obj_type: int = 18):
conn = get_connection()
if not conn:
return False
+ tabla = "ORPC" if int(obj_type) == 19 else "OPCH"
try:
cursor = conn.cursor()
cursor.execute(f"""
- UPDATE "{HANA_SCHEMA}".OPCH
+ UPDATE "{HANA_SCHEMA}".{tabla}
SET "U_PGX_PDF" = 'Y'
WHERE "DocEntry" = ?
- """, (docentry))
+ """, (docentry,))
conn.commit()
cursor.close()
return True
except Exception as e:
- print(f" ❌ Error actualizando PDF HANA: {e}")
+ logger.error(f"Error actualizando PDF HANA: {e}")
return False
# ============================================================================
-def marcar_xml_descargado(docentry: int, url: str, ruta: str):
+def marcar_xml_descargado(docentry: int, url: str, ruta: str, obj_type: int = 18):
conn = get_connection()
if not conn:
return False
+ tabla = "ORPC" if int(obj_type) == 19 else "OPCH"
try:
cursor = conn.cursor()
cursor.execute(f"""
- UPDATE "{HANA_SCHEMA}".OPCH
+ UPDATE "{HANA_SCHEMA}".{tabla}
SET "U_PGX_XML" = 'Y'
WHERE "DocEntry" = ?
- """, (docentry))
+ """, (docentry,))
conn.commit()
cursor.close()
return True
except Exception as e:
- print(f" ❌ Error actualizando XML HANA: {e}")
+ logger.error(f"Error actualizando XML HANA: {e}")
return False
# ============================================================================
diff --git a/historial_manager.py b/historial_manager.py
new file mode 100644
index 0000000..36ff34c
--- /dev/null
+++ b/historial_manager.py
@@ -0,0 +1,121 @@
+import sqlite3
+from pathlib import Path
+from datetime import datetime, timedelta
+from logger_manager import get_logger
+
+DB_PATH = Path(__file__).parent / "historial.db"
+logger = get_logger(__name__)
+# ============================================================================
+def _conn():
+ return sqlite3.connect(str(DB_PATH))
+# ============================================================================
+def init_db():
+ with _conn() as conn:
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS historial (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ fecha TEXT NOT NULL,
+ docentry INTEGER NOT NULL,
+ docnum TEXT,
+ ruc TEXT NOT NULL,
+ tipo TEXT NOT NULL,
+ serie TEXT NOT NULL,
+ numero TEXT NOT NULL,
+ objtype INTEGER NOT NULL DEFAULT 18,
+ es_retry INTEGER NOT NULL DEFAULT 0,
+ intento_num INTEGER,
+ consulta_status INTEGER,
+ pdf_resultado TEXT,
+ xml_resultado TEXT,
+ resultado TEXT NOT NULL,
+ detalle TEXT,
+ json_path TEXT
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_historial_docentry ON historial(docentry)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_historial_fecha ON historial(fecha)")
+# ============================================================================
+def registrar_intento(docentry, docnum, ruc, tipo, serie, numero, objtype, es_retry,
+ intento_num, consulta_status, pdf_resultado, xml_resultado,
+ resultado, detalle="", json_path=None):
+ ahora = datetime.now().isoformat()
+ with _conn() as conn:
+ conn.execute("""
+ INSERT INTO historial
+ (fecha, docentry, docnum, ruc, tipo, serie, numero, objtype, es_retry,
+ intento_num, consulta_status, pdf_resultado, xml_resultado, resultado,
+ detalle, json_path)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, (ahora, docentry, docnum, ruc, tipo, serie, numero, objtype, int(es_retry),
+ intento_num, consulta_status, pdf_resultado, xml_resultado, resultado,
+ str(detalle)[:500] if detalle else detalle, json_path))
+# ============================================================================
+def limpiar_historial_antiguo(dias=180):
+ limite = (datetime.now() - timedelta(days=dias)).isoformat()
+ with _conn() as conn:
+ cursor = conn.execute("DELETE FROM historial WHERE fecha < ?", (limite,))
+ count = cursor.rowcount
+ if count > 0:
+ logger.info(f"Se eliminaron {count} registros de historial de más de {dias} días.")
+ return count
+# ============================================================================
+def historial_docentry(docentry) -> list[dict]:
+ with _conn() as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.execute(
+ "SELECT * FROM historial WHERE docentry = ? ORDER BY id ASC", (docentry,)
+ )
+ return [dict(row) for row in cursor.fetchall()]
+# ============================================================================
+def buscar_intentos(ruc=None, serie=None, numero=None, tipo=None) -> list[dict]:
+ """Busca intentos registrados filtrando por cualquier combinación de ruc/serie/numero/tipo."""
+ condiciones = []
+ params = []
+ if ruc:
+ condiciones.append("ruc = ?")
+ params.append(ruc)
+ if serie:
+ condiciones.append("serie = ?")
+ params.append(serie)
+ if numero:
+ condiciones.append("numero = ?")
+ params.append(str(numero))
+ if tipo:
+ condiciones.append("tipo = ?")
+ params.append(tipo)
+
+ where = " AND ".join(condiciones) if condiciones else "1=1"
+ with _conn() as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.execute(f"SELECT * FROM historial WHERE {where} ORDER BY fecha ASC", params)
+ return [dict(row) for row in cursor.fetchall()]
+# ============================================================================
+if __name__ == "__main__":
+ import argparse
+
+ parser = argparse.ArgumentParser(description="Buscar intentos de descarga en historial.db")
+ parser.add_argument("--ruc", help="RUC del emisor")
+ parser.add_argument("--serie", help="Serie del comprobante (ej: F001)")
+ parser.add_argument("--numero", help="Número del comprobante")
+ parser.add_argument("--tipo", help="Tipo de comprobante (01, 03, 07, 08...)")
+ args = parser.parse_args()
+
+ if not any([args.ruc, args.serie, args.numero, args.tipo]):
+ parser.error("Indica al menos un filtro: --ruc, --serie, --numero o --tipo")
+
+ filas = buscar_intentos(ruc=args.ruc, serie=args.serie, numero=args.numero, tipo=args.tipo)
+
+ if not filas:
+ print("No se encontraron intentos con esos filtros.")
+ else:
+ print(f"{len(filas)} intento(s) encontrado(s):\n")
+ for f in filas:
+ print(
+ f"{f['fecha']} DocEntry={f['docentry']} "
+ f"{f['ruc']}-{f['tipo']}-{f['serie']}-{f['numero']} "
+ f"intento={f['intento_num']} retry={'sí' if f['es_retry'] else 'no'} "
+ f"status={f['consulta_status']} resultado={f['resultado']} "
+ f"pdf={f['pdf_resultado']} xml={f['xml_resultado']} detalle={f['detalle']}"
+ )
+# ============================================================================
diff --git a/logger_manager.py b/logger_manager.py
new file mode 100644
index 0000000..ac31fb9
--- /dev/null
+++ b/logger_manager.py
@@ -0,0 +1,146 @@
+import logging
+import re
+import contextvars
+import os
+import sys
+import time
+from contextlib import contextmanager
+from logging.handlers import TimedRotatingFileHandler
+from pathlib import Path
+
+LOG_DIR = Path(__file__).parent / "logs"
+LOG_FILE = LOG_DIR / "sunat.log"
+_LOG_CONFIGURED = False
+
+_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
+
+_stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[])
+# ============================================================================
+
+class TreeFormatter(logging.Formatter):
+ def format(self, record):
+ return f"{self.formatTime(record)} ─── {record.getMessage()}"
+
+ def formatTime(self, record, datefmt=None):
+ import datetime
+ return datetime.datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
+
+# ============================================================================
+class PlainFileFormatter(TreeFormatter):
+ """Igual que TreeFormatter pero sin códigos ANSI de color, para el archivo."""
+ def format(self, record):
+ return _ANSI_RE.sub("", super().format(record))
+
+# ============================================================================
+def _build_prefix(stack, is_last):
+ if not stack:
+ return ""
+ parts = []
+ for sib_is_last in stack[:-1]:
+ parts.append(" " if sib_is_last else "│ ")
+ parts.append("└── " if is_last else "├── ")
+ return "".join(parts)
+
+# ============================================================================
+class _TaskContext:
+ def __init__(self, logger, msg, emoji, is_last):
+ self._logger = logger
+ self._msg = msg
+ self._emoji = emoji
+ self._is_last = is_last
+
+ def __enter__(self):
+ stack = _stack.get()
+ prefix = _build_prefix(stack, self._is_last)
+ emoji_str = f"{self._emoji} " if self._emoji else ""
+ self._logger._logger.info(f"{prefix}{emoji_str}{self._msg}")
+ _stack.set(stack + [self._is_last])
+ return TaskLogger(self._logger._logger)
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ _stack.set(_stack.get()[:-1])
+
+# ============================================================================
+class TaskLogger:
+ def __init__(self, logger):
+ self._logger = logger
+
+ @contextmanager
+ def task(self, msg, emoji=""):
+ with _TaskContext(self, msg, emoji, is_last=False) as sub_logger:
+ yield sub_logger
+
+ @contextmanager
+ def last_task(self, msg, emoji=""):
+ with _TaskContext(self, msg, emoji, is_last=True) as sub_logger:
+ yield sub_logger
+
+ def info(self, msg, *args, _last=False, **kwargs):
+ self._log(logging.INFO, msg, args, kwargs, _last)
+
+ def warning(self, msg, *args, _last=False, **kwargs):
+ self._log(logging.WARNING, msg, args, kwargs, _last)
+
+ def error(self, msg, *args, _last=False, **kwargs):
+ self._log(logging.ERROR, msg, args, kwargs, _last)
+
+ def _log(self, level, msg, args, kwargs, is_last):
+ stack = _stack.get()
+ prefix = _build_prefix(stack, is_last)
+ self._logger.log(level, f"{prefix}{msg}", *args, **kwargs)
+
+def _rotated_name(default_name):
+ """TimedRotatingFileHandler entrega 'sunat.log.2026-08-30'; lo convertimos
+ a 'sunat_2026-08-30.log' para que sea más legible como nombre de archivo."""
+ date_part = default_name.rsplit(".", 1)[-1]
+ return str(LOG_FILE.with_name(f"{LOG_FILE.stem}_{date_part}{LOG_FILE.suffix}"))
+
+# ============================================================================
+def limpiar_logs_antiguos(dias=7):
+ """Borra los logs rotados (sunat_YYYY-MM-DD.log) con más de 'dias' de antigüedad."""
+ limite = time.time() - dias * 86400
+ count = 0
+ for p in LOG_DIR.glob(f"{LOG_FILE.stem}_*{LOG_FILE.suffix}"):
+ try:
+ if p.stat().st_mtime < limite:
+ p.unlink()
+ count += 1
+ except OSError:
+ pass
+ return count
+
+# ============================================================================
+def setup_logging(level=None, console=True):
+ global _LOG_CONFIGURED
+ if _LOG_CONFIGURED:
+ return
+
+ if level is None:
+ level_name = os.getenv("LOG_LEVEL", "INFO").upper()
+ level = getattr(logging, level_name, logging.INFO)
+
+ LOG_DIR.mkdir(exist_ok=True)
+
+ file_handler = TimedRotatingFileHandler(
+ LOG_FILE,
+ when="midnight",
+ interval=1,
+ backupCount=0, # la retención la maneja limpiar_logs_antiguos() con el namer de abajo
+ encoding="utf-8",
+ )
+ file_handler.namer = _rotated_name
+ file_handler.setFormatter(PlainFileFormatter())
+
+ handlers = [file_handler]
+ if console:
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setFormatter(TreeFormatter())
+ handlers.append(console_handler)
+
+ logging.basicConfig(level=level, handlers=handlers, force=True)
+ _LOG_CONFIGURED = True
+
+# ============================================================================
+def get_logger(name="sunat"):
+ setup_logging()
+ return TaskLogger(logging.getLogger(name))
\ No newline at end of file
diff --git a/logger_utilities.py b/logger_utilities.py
deleted file mode 100644
index 65f40cf..0000000
--- a/logger_utilities.py
+++ /dev/null
@@ -1,93 +0,0 @@
-import sys
-import os
-from datetime import datetime, timedelta
-
-_stdout_original = sys.stdout
-_stderr_original = sys.stderr
-_file_stream = None
-_log_dir = "logs"
-
-# ============================================================================
-class MultiStream:
- def __init__(self, *streams):
- self.streams = list(streams)
-
- def write(self, data):
- for stream in self.streams:
- stream.write(data)
- stream.flush()
-
- def flush(self):
- for stream in self.streams:
- stream.flush()
-
- def reemplazar_file(self, old_fh, new_fh):
- for i, s in enumerate(self.streams):
- if s is old_fh:
- self.streams[i] = new_fh
- return
-
-# ============================================================================
-def _ruta_log(hoy=None):
- if hoy is None:
- hoy = datetime.now().strftime("%Y-%m-%d")
- if not os.path.exists(_log_dir):
- os.makedirs(_log_dir)
- return os.path.join(_log_dir, f"{hoy}.log")
-
-# ============================================================================
-def setup_logger():
- global _file_stream
- log_path = _ruta_log()
- _file_stream = open(log_path, "a", encoding="utf-8")
- sys.stdout = MultiStream(_stdout_original, _file_stream)
- sys.stderr = MultiStream(_stderr_original, _file_stream)
- return log_path
-
-# ============================================================================
-def rotar_log():
- global _file_stream
- hoy = datetime.now().strftime("%Y-%m-%d")
- log_path = _ruta_log(hoy)
-
- if _file_stream and os.path.abspath(_file_stream.name) == os.path.abspath(log_path):
- return
-
- nuevo_fh = open(log_path, "a", encoding="utf-8")
- if isinstance(sys.stdout, MultiStream):
- sys.stdout.reemplazar_file(_file_stream, nuevo_fh)
- if isinstance(sys.stderr, MultiStream):
- sys.stderr.reemplazar_file(_file_stream, nuevo_fh)
- try:
- _file_stream.close()
- except Exception:
- pass
- _file_stream = nuevo_fh
- print(f"📄 Log rotado a: {log_path}")
-
-# ============================================================================
-def limpiar_logs_antiguos(dias=7):
- """
- Elimina archivos de log con más de 'n' días de antigüedad.
- """
- log_dir = "logs"
- if not os.path.exists(log_dir):
- return
-
- limite = datetime.now() - timedelta(days=dias)
- count = 0
-
- for archivo in os.listdir(log_dir):
- ruta_archivo = os.path.join(log_dir, archivo)
- if os.path.isfile(ruta_archivo) and archivo.endswith(".log"):
- fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo))
- if fecha_archivo < limite:
- try:
- os.remove(ruta_archivo)
- count += 1
- except Exception as e:
- print(f"⚠️ Error al eliminar log {archivo}: {e}")
-
- if count > 0:
- print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).")
-# ============================================================================
\ No newline at end of file
diff --git a/login_api.py b/login_api.py
deleted file mode 100644
index 30204d3..0000000
--- a/login_api.py
+++ /dev/null
@@ -1,309 +0,0 @@
-import os
-import json
-import re
-import time
-import base64
-from pathlib import Path
-from dotenv import load_dotenv
-import requests
-import urllib3
-
-# ============================================================================
-urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
-
-load_dotenv()
-
-TOKEN_FILE = Path(__file__).parent / "token.json"
-CLIENT_ID = "cd8e7afb-a0e2-4217-9187-88206e4ba7af"
-TOKEN_URL = f"https://api-seguridad.sunat.gob.pe/v1/clientessol/{CLIENT_ID}/oauth2/token/"
-
-RUC = os.getenv("SUNAT_RUC", "").strip()
-USER = os.getenv("SUNAT_USER", "").strip()
-PASSWORD = os.getenv("SUNAT_PASSWORD", "").strip()
-J_USERNAME = USER
-
-# ============================================================================
-def log(msg):
- print(f" {msg}")
-
-# ============================================================================
-def decode_jwt(token):
- try:
- payload_b64 = token.split(".")[1]
- padding = 4 - len(payload_b64) % 4
- if padding != 4:
- payload_b64 += "=" * padding
- return json.loads(base64.b64decode(payload_b64))
- except:
- return None
-
-# ============================================================================
-def save_token(token):
- payload = decode_jwt(token) or {}
- expires_at = payload.get("exp", 0)
- data = {
- "token": token,
- "expires_at": expires_at,
- "client_id": payload.get("clientId", ""),
- "ruc": payload.get("sub", ""),
- "usuario_sol": payload.get("userdata", {}).get("usuarioSOL", ""),
- "_source": "login_api.py",
- "_grant_type": payload.get("grantType", ""),
- }
- TOKEN_FILE.write_text(json.dumps(data, indent=2))
- print(f"\n✅ Token guardado en: {TOKEN_FILE}")
- print(f" Expira: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expires_at))}")
- print(f" RUC: {data['ruc']}")
- print(f" Usuario SOL: {data['usuario_sol']}")
- return True
-
-# ============================================================================
-def try_password_grant():
- print("\n🔑 Intentando grant_type=password...")
- data = {
- "grant_type": "password",
- "username": J_USERNAME,
- "password": PASSWORD,
- "client_id": CLIENT_ID,
- }
- headers = {
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
- "Content-Type": "application/x-www-form-urlencoded",
- "Accept": "application/json",
- }
-
- try:
- r = requests.post(TOKEN_URL, data=data, headers=headers, timeout=30, verify=False)
- print(f" {r.status_code}")
- if r.status_code == 200:
- j = r.json()
- token = j.get("access_token")
- if token:
- print(f" ✅ Password grant funcionó!")
- save_token(token)
- return True
- print(f" ⚠️ Sin access_token en respuesta: {str(j)[:200]}")
- else:
- print(f" ❌ Response: {r.text[:300]}")
- except Exception as e:
- print(f" ❌ Error: {e}")
- return False
-
-# ============================================================================
-def try_auth_code_flow():
- print("\n🔑 Intentando flujo authorization_code...")
- log("1. Accediendo a MenuInternet.htm para obtener redirect...")
-
- session = requests.Session()
- session.verify = False
- session.headers.update({
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- })
-
- menu_url = "https://e-menu.sunat.gob.pe/cl-ti-itmenu/MenuInternet.htm"
-
- try:
- r = session.get(menu_url, allow_redirects=True, timeout=30)
- log(f" GET MenuInternet: {r.status_code}")
- log(f" URL final: {r.url}")
-
- html = r.text
- (Path(__file__).parent / "descargas" / "menu_response.html").write_text(html)
-
- # Extraer URL del redirect() en el JavaScript
- redirect_match = re.search(r'redirect\s*\(\s*["\']([^"\']+)["\']', html)
- if not redirect_match:
- print(" ❌ No se encontró URL de login en MenuInternet.htm")
- return False
-
- authen_url = redirect_match.group(1)
- log(f"\n Login URL: {authen_url}")
-
- # Extraer state y redirect_uri de la authen URL
- state_match = re.search(r'state=([^&\s"\']+)', authen_url)
- state_from_url = state_match.group(1) if state_match else None
- if state_from_url:
- log(f" state: {state_from_url[:60]}...")
- state_decoded = state_from_url # No decodear: el + de base64 se pierde con unquote
-
- redirect_uri_match = re.search(r'redirect_uri=([^&\s"\']+)', authen_url)
- redirect_uri = requests.utils.unquote(redirect_uri_match.group(1)) if redirect_uri_match else None
-
- # 2. Acceder a la URL de authen para obtener el formulario de login
- log("\n 2. Accediendo a authen...")
- r2 = session.get(authen_url, timeout=30)
- log(f" GET authen: {r2.status_code}")
- log(f" URL final: {r2.url[:200]}")
-
- html2 = r2.text
- (Path(__file__).parent / "descargas" / "login_page.html").write_text(html2)
-
- # Extraer state y originalUrl de la URL final (authen los pasa a loginMenuSol)
- final_state_match = re.search(r'[?&]state=([^&\s]+)', r2.url)
- final_orig_match = re.search(r'[?&]originalUrl=([^&\s]+)', r2.url)
- final_state = requests.utils.unquote(final_state_match.group(1)) if final_state_match else state_decoded
- final_orig = requests.utils.unquote(final_orig_match.group(1)) if final_orig_match else redirect_uri
-
- # Verificar CAPTCHA real: buscar
- # (el captcha solo se renderiza via JS tras 3 fallos; con requests el JS no corre)
- html_no_script = re.sub(r'', '', html2, flags=re.DOTALL | re.IGNORECASE)
- if re.search(r'id=[\'"]imgCaptcha[\'"]', html_no_script):
- print(" ⚠️ CAPTCHA visible detectado (imgCaptcha)")
- return False
- print(" ✅ Sin CAPTCHA visible")
-
- # Extraer form action
- action_match = re.search(r'