Reemplaza logging por consola/archivo, agrega historial de intentos y soporte para notas de crédito

- Nuevo logger_manager.py (reemplaza logger_utilities.py): logging con formato de árbol,
  rotación diaria a sunat.log, y consola por stdout (antes iba por stderr y se duplicaba
  en logs/error.log del launchd).
- Nuevo historial_manager.py: registra cada intento de consulta/descarga en historial.db
  para auditoría, con limpieza automática de registros de más de 180 días.
- consulta_cpe.py: corrige el id de notas de crédito/débito (usa F7/F8, no 07/08) y
  agrega reintentos ante errores 5xx.
- hana_manager.py: suma consulta a ORPC para notas de crédito (Indicator 07) y soporte
  de ObjType para actualizar la tabla correcta (OPCH/ORPC) al marcar PDF/XML descargado.
- gestor_fallas.py / main.py: propagan objtype end-to-end y dejan constancia de cada
  intento en historial_manager.
- Elimina login_api.py y reporte_fallas.py (código no usado / script manual sin uso reciente).
- Actualiza .gitignore (sunat-venv/, historial.db*) y docs/ para reflejar el estado real
  del proyecto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-08-31 12:31:48 -05:00
1 parent 99a587c4da
commit c2503745bf
18 files changed
+647 -862

No files matched your search

+77 -67
View File
@@ -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__":