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:
1 parent
99a587c4da
commit
c2503745bf
18 files changed
+647
-862
No files matched your search
+77
-67
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user