1071 lines
44 KiB
Python
1071 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Detector de anomalías en nodos de workflows de Bucéfalo (read-only, Playwright).
|
|
|
|
Abre cada workflow en el editor visual y reporta:
|
|
- Nodos con ícono de alerta naranja (img#pg-actions__icon--eh-show-error).
|
|
- Nodos cuyo texto muestra un ID interno de campo personalizado (~20 chars
|
|
alfanuméricos entre comillas) en lugar del nombre del campo.
|
|
- Botones de alerta globales en la cabecera (span.n-button__content con SVG de aviso).
|
|
|
|
NO modifica nada en GHL. Lee la lista de workflows desde SQLite local
|
|
(populated por `sync_workflows.py`).
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import io
|
|
import json
|
|
import os
|
|
import queue
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import datetime
|
|
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
sys.stderr.reconfigure(encoding="utf-8")
|
|
except Exception:
|
|
try:
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", line_buffering=True)
|
|
except Exception:
|
|
pass
|
|
|
|
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if ROOT_DIR not in sys.path:
|
|
sys.path.insert(0, ROOT_DIR)
|
|
|
|
import db # noqa: E402
|
|
import error_logging # noqa: E402
|
|
import script_audit # noqa: E402
|
|
from script_logger import RunLogger # noqa: E402
|
|
|
|
from paths import REPORT_WORKFLOW_ANOMALIES, SCREENSHOTS_DIR, SESSION_FILE # noqa: E402
|
|
|
|
from scripts.ghl_browser_workflow_manager import ( # noqa: E402
|
|
_open_browser,
|
|
_close_and_save,
|
|
_launch_browser,
|
|
_new_worker_context,
|
|
_register_worker_state,
|
|
_unregister_worker_state,
|
|
_any_frame_at_login,
|
|
_install_signal_handlers,
|
|
ensure_playwright_browsers,
|
|
session_file_status,
|
|
SessionExpiredError,
|
|
_INTERRUPT_STATE,
|
|
SESSION_MAX_AGE_HOURS,
|
|
PERSISTENT_PROFILE_DIR,
|
|
)
|
|
from scripts.common import BRAND_LOCATION_ID, load_accounts # noqa: E402
|
|
|
|
|
|
NODE_WRAPPER_SEL = (
|
|
'div.rounded-xl.border-b-4, '
|
|
'div[class~="border-b-4"][class~="rounded-xl"], '
|
|
'div.rounded-xl.node-shadow, '
|
|
'div.rounded-xl.nopan, '
|
|
'#action-node-container, '
|
|
'.workflow-action-node'
|
|
)
|
|
ERROR_TAB_BUTTON_SEL = '#workflow-builder-tab-error-highlight'
|
|
ALERT_ICON_SEL = (
|
|
'img#pg-actions__icon--eh-show-error, '
|
|
'div.absolute.-bottom-2.-right-2 img[alt*="alerta" i], '
|
|
'div.absolute.-bottom-2.-right-2 img[alt*="alert" i]'
|
|
)
|
|
N_BUTTON_ALERT_SEL = (
|
|
'span.n-button__content svg[class*="alert"], '
|
|
'span.n-button__content svg[class*="warning"]'
|
|
)
|
|
MODAL_CLOSE_SEL = (
|
|
"button:has-text('Entendido'), button:has-text('entendido'), "
|
|
"button:has-text('Got it')"
|
|
)
|
|
|
|
FIELD_ID_RE = re.compile(r'["“”]([A-Za-z0-9]{20})["“”]')
|
|
|
|
CONTEXT_KEYWORDS = (
|
|
"no está vac", "esta vac", "está vac",
|
|
"is empty", "is not empty",
|
|
"es igual", "equals",
|
|
"contiene", "contains",
|
|
"no es igual", "does not equal", "not equals",
|
|
)
|
|
|
|
ACTIVE_STATUSES = {"active", "published"}
|
|
|
|
|
|
def _save_full_screenshot(page, label):
|
|
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
|
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
path = os.path.join(SCREENSHOTS_DIR, f"{label}_{ts}.png")
|
|
try:
|
|
page.screenshot(path=path, full_page=True)
|
|
except Exception:
|
|
return None
|
|
return path
|
|
|
|
|
|
def _resolve_workflows_to_scan(args):
|
|
accounts = load_accounts(include_main=True)
|
|
by_id = {a["location_id"]: a for a in accounts}
|
|
|
|
if args.batch_file:
|
|
try:
|
|
with open(args.batch_file, "r", encoding="utf-8") as f:
|
|
batch = json.load(f)
|
|
except Exception as e:
|
|
raise SystemExit(f"No se pudo leer batch file '{args.batch_file}': {e}")
|
|
if not isinstance(batch, list) or not batch:
|
|
raise SystemExit("Batch file debe contener una lista no vacía de {location_id, workflow_id}.")
|
|
items = []
|
|
for entry in batch:
|
|
loc = entry.get("location_id")
|
|
wf = entry.get("workflow_id")
|
|
if not loc or not wf:
|
|
continue
|
|
local = next((r for r in db.get_workflows(loc) if r.get("id") == wf), None) or {}
|
|
items.append({
|
|
"location_id": loc,
|
|
"account_name": entry.get("account_name")
|
|
or by_id.get(loc, {}).get("nombre")
|
|
or local.get("account_name") or loc,
|
|
"workflow_id": wf,
|
|
"workflow_name": entry.get("name") or local.get("name") or "(sin nombre)",
|
|
"status": (local.get("status") or entry.get("current_status") or "").lower(),
|
|
})
|
|
if args.max_workflows and args.max_workflows > 0:
|
|
items = items[: args.max_workflows]
|
|
return items
|
|
|
|
if args.workflow_id and not args.location:
|
|
raise SystemExit("--workflow-id requiere también --location.")
|
|
|
|
if args.location:
|
|
target_locs = [args.location]
|
|
elif args.all:
|
|
target_locs = [a["location_id"] for a in accounts
|
|
if args.include_main or a["location_id"] != BRAND_LOCATION_ID]
|
|
else:
|
|
raise SystemExit("Debes pasar --location <id>, --all o --batch-file <path>.")
|
|
|
|
status_filter = (args.status or "active").lower()
|
|
items = []
|
|
for loc_id in target_locs:
|
|
rows = db.get_workflows(loc_id)
|
|
for row in rows:
|
|
wf_status = (row.get("status") or "").lower()
|
|
if status_filter == "active" and wf_status not in ACTIVE_STATUSES:
|
|
continue
|
|
if args.workflow_id and row.get("id") != args.workflow_id:
|
|
continue
|
|
items.append({
|
|
"location_id": loc_id,
|
|
"account_name": by_id.get(loc_id, {}).get("nombre")
|
|
or row.get("account_name") or loc_id,
|
|
"workflow_id": row.get("id"),
|
|
"workflow_name": row.get("name") or "(sin nombre)",
|
|
"status": wf_status,
|
|
})
|
|
|
|
if args.max_workflows and args.max_workflows > 0:
|
|
items = items[: args.max_workflows]
|
|
return items
|
|
|
|
|
|
def _find_builder_frame_simple(page, timeout_sec):
|
|
"""Espera a que aparezca el iframe del canvas y devuelve el que tenga MÁS nodos.
|
|
|
|
Devuelve (frame, status) con status ∈ {"ready", "login_redirect", "timeout"}.
|
|
"""
|
|
deadline = time.time() + timeout_sec
|
|
best_frame = None
|
|
best_count = 0
|
|
while time.time() < deadline:
|
|
if _any_frame_at_login(page):
|
|
return None, "login_redirect"
|
|
for frame in page.frames:
|
|
try:
|
|
count = frame.locator(NODE_WRAPPER_SEL).count()
|
|
except Exception:
|
|
count = 0
|
|
if count > best_count:
|
|
best_count = count
|
|
best_frame = frame
|
|
if best_count >= 3:
|
|
return best_frame, "ready"
|
|
# Si el modal del AI Builder está en algún frame, intentamos cerrarlo.
|
|
for frame in page.frames:
|
|
try:
|
|
if frame.locator(MODAL_CLOSE_SEL).count() > 0:
|
|
return frame, "ready"
|
|
except Exception:
|
|
continue
|
|
time.sleep(0.5)
|
|
if best_frame is not None and best_count > 0:
|
|
return best_frame, "ready"
|
|
return None, "timeout"
|
|
|
|
|
|
def _open_workflow_canvas(page, location_id, workflow_id, timeout_sec):
|
|
url = f"https://crm.bucefalocrm.io/location/{location_id}/workflow/{workflow_id}"
|
|
try:
|
|
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
except Exception as e:
|
|
print(f"[ADVERTENCIA] page.goto falló: {e}")
|
|
|
|
frame, status = _find_builder_frame_simple(page, timeout_sec)
|
|
if status != "ready":
|
|
return frame, status
|
|
|
|
try:
|
|
if frame.locator(MODAL_CLOSE_SEL).count() > 0:
|
|
try:
|
|
frame.locator(MODAL_CLOSE_SEL).first.click(timeout=2000)
|
|
time.sleep(1)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
frame.wait_for_load_state("networkidle", timeout=25000)
|
|
except Exception:
|
|
pass
|
|
|
|
# Vue Flow lazy-renders nodos: solo los visibles en el viewport están en el DOM.
|
|
# Click "Ajustar a la pantalla" para zoom-out y forzar render de todos los nodos.
|
|
try:
|
|
fit_btn = frame.locator('#workflow-fit-to-screen')
|
|
if fit_btn.count() > 0:
|
|
fit_btn.first.click(timeout=3000)
|
|
time.sleep(2)
|
|
# Doble click adicional por si el primero solo activó tooltip.
|
|
try:
|
|
fit_btn.first.click(timeout=2000)
|
|
time.sleep(1)
|
|
except Exception:
|
|
pass
|
|
except Exception as fe:
|
|
print(f"[DEBUG] fit-to-screen click falló (no crítico): {fe}", flush=True)
|
|
|
|
deadline = time.time() + 8
|
|
while time.time() < deadline:
|
|
try:
|
|
if frame.locator(NODE_WRAPPER_SEL).count() > 0:
|
|
time.sleep(3)
|
|
return frame, "ready"
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.5)
|
|
|
|
return frame, "empty"
|
|
|
|
|
|
def _extract_node_context(frame, alert_handle):
|
|
"""Sube por el DOM desde el ícono de alerta hasta el wrapper de nodo y devuelve label+text.
|
|
|
|
Estrategia: probar varios selectores de wrapper (de más específico a más permisivo) y
|
|
quedarse con el primer ancestro que tenga texto razonable y entre 30 y 500 chars.
|
|
"""
|
|
try:
|
|
result = frame.evaluate(
|
|
"""(el) => {
|
|
const WRAPPER_SELS = [
|
|
'div.rounded-xl.border-b-4',
|
|
'div.rounded-xl.node-shadow',
|
|
'div.rounded-xl.nopan',
|
|
'#action-node-container',
|
|
'div[class*="rounded-xl"][class*="border-b-4"]',
|
|
'div[class*="rounded-xl"]'
|
|
];
|
|
let cur = el;
|
|
let best = null;
|
|
for (let i = 0; i < 25 && cur; i++) {
|
|
for (const sel of WRAPPER_SELS) {
|
|
if (cur.matches && cur.matches(sel)) {
|
|
const t = (cur.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
if (t.length >= 5 && t.length <= 600) { best = cur; break; }
|
|
}
|
|
}
|
|
if (best) break;
|
|
cur = cur.parentElement;
|
|
}
|
|
if (!best) return {label: '(?)', text: ''};
|
|
const title = best.querySelector('.workflow-action-name, .font-semibold, h3, h4');
|
|
const text = (best.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
const label = (title && title.textContent ? title.textContent : text.split('\\n')[0] || '').trim().slice(0, 120);
|
|
return {label: label, text: text.slice(0, 300)};
|
|
}""",
|
|
alert_handle,
|
|
)
|
|
return result.get("label") or "(?)", result.get("text") or ""
|
|
except Exception:
|
|
return "(?)", ""
|
|
|
|
|
|
def _is_false_positive_field_id(candidate, surrounding_text, workflow_meta):
|
|
if candidate == workflow_meta["workflow_id"]:
|
|
return True
|
|
if candidate == workflow_meta["location_id"]:
|
|
return True
|
|
lowered = surrounding_text.lower()
|
|
return not any(kw in lowered for kw in CONTEXT_KEYWORDS)
|
|
|
|
|
|
def _extract_ghl_native_errors(frame):
|
|
"""Click en el botón nativo de errores de GHL y extrae cada error del panel.
|
|
|
|
Es la detección PRIMARIA: el propio motor de validación de GHL nos dice exactamente
|
|
qué nodos tienen problemas, con su StepId y descripción. Idioma-agnóstico (no depende
|
|
del texto del título). Devuelve lista de {node_label, step_id, description}.
|
|
"""
|
|
findings = []
|
|
try:
|
|
btn = frame.locator(ERROR_TAB_BUTTON_SEL)
|
|
if btn.count() == 0:
|
|
return findings
|
|
btn.first.click(timeout=3000)
|
|
time.sleep(2.5) # esperar a que el panel renderice
|
|
except Exception as e:
|
|
print(f"[DEBUG] click error-tab falló: {e}", flush=True)
|
|
return findings
|
|
|
|
try:
|
|
result = frame.evaluate("""() => {
|
|
const out = [];
|
|
const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
const stepIdRe = /StepId\\s*:?\\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
// Cada error está en un wrapper con classes 'flex flex-col gap-2 py-4 border-b border-gray-200'
|
|
const items = document.querySelectorAll('div[class*="border-b"][class*="border-gray-200"][class*="py-4"]');
|
|
const seen = new Set();
|
|
for (const el of items) {
|
|
const txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
if (!txt || txt.length > 1000) continue;
|
|
const m = stepIdRe.exec(txt);
|
|
if (!m) continue;
|
|
const stepId = m[1];
|
|
if (seen.has(stepId)) continue;
|
|
seen.add(stepId);
|
|
// Separar: "N. NodeName StepId : <uuid> Descripción..."
|
|
// Quitar el prefijo numérico "N." (opcional).
|
|
let cleaned = txt.replace(/^\\s*\\d+\\.\\s*/, '');
|
|
const stepIdx = cleaned.toLowerCase().indexOf('stepid');
|
|
let nodeLabel = '', description = '';
|
|
if (stepIdx > 0) {
|
|
nodeLabel = cleaned.slice(0, stepIdx).trim();
|
|
// Después de StepId : <uuid> viene la descripción.
|
|
const after = cleaned.slice(stepIdx).replace(stepIdRe, '').trim();
|
|
description = after;
|
|
} else {
|
|
nodeLabel = cleaned;
|
|
}
|
|
// Deduplicar la descripción si el motor reporta el mismo error varias veces
|
|
// (caso: un nodo con 2 errores idénticos se concatena en el wrapper).
|
|
if (description) {
|
|
for (let k = Math.floor(description.length / 2); k >= 10; k--) {
|
|
const half = description.slice(0, k).trim();
|
|
if (half && description.startsWith(half) && description.endsWith(half) && description.length >= half.length * 2 - 5) {
|
|
const middle = description.slice(half.length, -half.length).trim();
|
|
if (!middle || half === description.slice(-half.length).trim()) {
|
|
description = half;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out.push({
|
|
node_label: nodeLabel.slice(0, 120),
|
|
step_id: stepId,
|
|
description: description.slice(0, 400),
|
|
});
|
|
}
|
|
return out;
|
|
}""") or []
|
|
findings = result
|
|
except Exception as e:
|
|
print(f"[DEBUG] extracción panel errores falló: {e}", flush=True)
|
|
|
|
return findings
|
|
|
|
|
|
def scan_workflow_for_anomalies(frame, workflow_meta):
|
|
anomalies = []
|
|
|
|
# --- Detección PRIMARIA: el panel nativo de errores de GHL ---
|
|
# Es lo más confiable porque viene del propio motor de validación. Idioma-agnóstico.
|
|
native_errors = _extract_ghl_native_errors(frame)
|
|
print(f"[DEBUG] GHL native errors: {len(native_errors)}", flush=True)
|
|
native_step_ids = set()
|
|
for ne in native_errors:
|
|
step_id = ne.get("step_id") or ""
|
|
if step_id:
|
|
native_step_ids.add(step_id)
|
|
anomalies.append({
|
|
"anomaly_type": "ghl_native_error",
|
|
"node_label": ne.get("node_label") or "(?)",
|
|
"raw_text": ne.get("description") or "",
|
|
"unresolved_id": step_id,
|
|
})
|
|
|
|
# --- DEBUG: dump del frame body para diagnóstico ---
|
|
if os.environ.get("ANOMALY_SCANNER_DEBUG"):
|
|
try:
|
|
# Frame URL + count per frame para diagnosticar qué frame estamos escaneando.
|
|
page_obj = frame.page if hasattr(frame, "page") else None
|
|
try:
|
|
print(f"[DEBUG] Frame seleccionado URL: {frame.url[:120]}", flush=True)
|
|
if page_obj is not None:
|
|
for fi, f_ in enumerate(page_obj.frames):
|
|
try:
|
|
cnt = f_.locator(NODE_WRAPPER_SEL).count()
|
|
except Exception:
|
|
cnt = "?"
|
|
print(f"[DEBUG] page.frames[{fi}]: count={cnt} url={(f_.url or '')[:120]}", flush=True)
|
|
except Exception:
|
|
pass
|
|
html = frame.evaluate("() => document.documentElement.outerHTML")
|
|
dump_path = os.path.join(SCREENSHOTS_DIR, f"frame_dump_{workflow_meta['workflow_id'][:8]}.html")
|
|
with open(dump_path, "w", encoding="utf-8") as f:
|
|
f.write(html)
|
|
print(f"[DEBUG] Frame HTML dump: {dump_path} ({len(html)} chars)", flush=True)
|
|
except Exception as de:
|
|
print(f"[DEBUG] dump frame falló: {de}", flush=True)
|
|
|
|
try:
|
|
alert_findings = frame.evaluate(
|
|
"""(sel) => {
|
|
const out = [];
|
|
const imgs = document.querySelectorAll(sel);
|
|
for (const img of imgs) {
|
|
const r = img.getBoundingClientRect();
|
|
if (r.width === 0 || r.height === 0) continue; // no visible
|
|
// Buscar el contenedor del nodo: vue-flow node, rounded-xl wrapper o data-id.
|
|
let node = img.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4, div[class*="rounded-xl"]');
|
|
let label = '(?)';
|
|
let text = '';
|
|
let nodeId = '';
|
|
if (node) {
|
|
nodeId = node.getAttribute('data-id') || '';
|
|
const title = node.querySelector('.workflow-action-name, .font-semibold, h3, h4');
|
|
text = (node.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 300);
|
|
label = ((title && title.textContent) ? title.textContent : text.split(' ').slice(0,10).join(' ')).trim().slice(0, 120);
|
|
} else {
|
|
// Fallback: buscar el siblings o nodo próximo del DOM.
|
|
let cur = img.parentElement;
|
|
while (cur && !text) {
|
|
const next = cur.nextElementSibling || cur.previousElementSibling;
|
|
if (next) {
|
|
const t = (next.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
if (t.length >= 3 && t.length <= 600) {
|
|
text = t.slice(0, 300);
|
|
label = t.split(' ').slice(0, 10).join(' ').slice(0, 120);
|
|
break;
|
|
}
|
|
}
|
|
cur = cur.parentElement;
|
|
}
|
|
}
|
|
out.push({label, text, nodeId});
|
|
}
|
|
return out;
|
|
}""",
|
|
ALERT_ICON_SEL,
|
|
) or []
|
|
except Exception as e:
|
|
print(f"[DEBUG] alert evaluate falló: {e}", flush=True)
|
|
alert_findings = []
|
|
print(f"[DEBUG] ALERT_ICON_SEL matches: {len(alert_findings)}", flush=True)
|
|
skipped_alerts = 0
|
|
for af in alert_findings:
|
|
node_id = af.get("nodeId") or ""
|
|
# Si el motor nativo de GHL ya reportó este nodo, evitar duplicar info menos rica.
|
|
if node_id and node_id in native_step_ids:
|
|
skipped_alerts += 1
|
|
continue
|
|
anomalies.append({
|
|
"anomaly_type": "alert_icon",
|
|
"node_label": af.get("label") or "(?)",
|
|
"raw_text": af.get("text") or "",
|
|
"unresolved_id": node_id,
|
|
})
|
|
if skipped_alerts:
|
|
print(f"[DEBUG] ↳ {skipped_alerts} alert_icon suprimidos (ya cubiertos por ghl_native_error)", flush=True)
|
|
|
|
try:
|
|
nodes = frame.locator(NODE_WRAPPER_SEL).all()
|
|
except Exception:
|
|
nodes = []
|
|
print(f"[DEBUG] NODE_WRAPPER_SEL matches: {len(nodes)}", flush=True)
|
|
seen = set()
|
|
for node_idx, node in enumerate(nodes):
|
|
try:
|
|
text = node.text_content(timeout=500) or ""
|
|
except Exception:
|
|
continue
|
|
if not text:
|
|
continue
|
|
if node_idx < 3:
|
|
print(f"[DEBUG] node[{node_idx}] text[:200]={text.strip()[:200]!r}", flush=True)
|
|
for m in FIELD_ID_RE.finditer(text):
|
|
candidate = m.group(1)
|
|
if _is_false_positive_field_id(candidate, text, workflow_meta):
|
|
continue
|
|
key = (candidate, text[:80])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
label = text.split("\n", 1)[0].strip()[:120] or "(?)"
|
|
anomalies.append({
|
|
"anomaly_type": "unresolved_field_id",
|
|
"node_label": label,
|
|
"raw_text": text[:300],
|
|
"unresolved_id": candidate,
|
|
})
|
|
|
|
try:
|
|
n_alerts = frame.locator(N_BUTTON_ALERT_SEL).all()
|
|
except Exception:
|
|
n_alerts = []
|
|
visible_n_alerts = 0
|
|
for el in n_alerts:
|
|
try:
|
|
if el.is_visible(timeout=300):
|
|
visible_n_alerts += 1
|
|
except Exception:
|
|
continue
|
|
for _ in range(visible_n_alerts):
|
|
anomalies.append({
|
|
"anomaly_type": "n_button_alert",
|
|
"node_label": "(header/global)",
|
|
"raw_text": "",
|
|
"unresolved_id": "",
|
|
})
|
|
|
|
return anomalies
|
|
|
|
|
|
def _scan_one_workflow(page, it, args, bulk_prefix):
|
|
"""Escanea un workflow y devuelve (item_summary_dict, list_of_findings).
|
|
|
|
Lanza SessionExpiredError si Bucéfalo redirige al login.
|
|
"""
|
|
loc = it["location_id"]
|
|
wf = it["workflow_id"]
|
|
name = it["workflow_name"]
|
|
print(f"{bulk_prefix} === '{name}' ({wf}) en {loc} ===", flush=True)
|
|
|
|
frame, status = _open_workflow_canvas(page, loc, wf, args.node_load_timeout)
|
|
|
|
if status == "login_redirect":
|
|
print(f"{bulk_prefix} SESIÓN EXPIRADA", flush=True)
|
|
raise SessionExpiredError("Bucéfalo redirigió al login durante el escaneo")
|
|
|
|
if status == "timeout":
|
|
shot = _save_full_screenshot(page, f"canvas_timeout_{loc[:8]}_{(wf or '')[:8]}")
|
|
print(f"[ADVERTENCIA] {bulk_prefix} canvas no cargó (timeout). Screenshot: {shot}", flush=True)
|
|
print(f"{bulk_prefix} RESULT: skipped", flush=True)
|
|
return {**it, "status": "skipped_timeout", "anomalies": 0, "screenshot_path": shot}, []
|
|
|
|
if status == "empty":
|
|
print(f"[INFO] {bulk_prefix} workflow sin nodos visibles → 0 anomalías", flush=True)
|
|
print(f"{bulk_prefix} RESULT: success", flush=True)
|
|
return {**it, "status": "scanned_empty", "anomalies": 0}, []
|
|
|
|
anoms = scan_workflow_for_anomalies(frame, it)
|
|
shot_path = None
|
|
if anoms:
|
|
shot_path = _save_full_screenshot(page, f"anomaly_{loc[:8]}_{(wf or '')[:8]}")
|
|
|
|
findings_out = []
|
|
for a in anoms:
|
|
a.update({
|
|
"location_id": loc,
|
|
"account_name": it["account_name"],
|
|
"workflow_id": wf,
|
|
"workflow_name": name,
|
|
"workflow_status": it["status"],
|
|
"screenshot_path": shot_path or "",
|
|
})
|
|
findings_out.append(a)
|
|
|
|
def _esc(v):
|
|
return (str(v) or "").replace("|", "/").replace("\n", " ").strip()[:200]
|
|
print(
|
|
f"{bulk_prefix} FINDING: "
|
|
f"type={_esc(a['anomaly_type'])}|"
|
|
f"node={_esc(a.get('node_label'))}|"
|
|
f"id={_esc(a.get('unresolved_id'))}|"
|
|
f"desc={_esc(a.get('raw_text'))}",
|
|
flush=True,
|
|
)
|
|
|
|
print(f"[INFO] {bulk_prefix} -> {len(anoms)} anomalías", flush=True)
|
|
print(f"{bulk_prefix} ANOMALIES: {len(anoms)}", flush=True)
|
|
print(f"{bulk_prefix} RESULT: success", flush=True)
|
|
return {**it, "status": "scanned", "anomalies": len(anoms),
|
|
"screenshot_path": shot_path or ""}, findings_out
|
|
|
|
|
|
def scan_workflows(page, items, args):
|
|
"""Escaneo secuencial (workers=1) — usado cuando solo hay 1 worker."""
|
|
findings = []
|
|
summary = []
|
|
total = len(items)
|
|
aborted = False
|
|
|
|
for idx, it in enumerate(items, 1):
|
|
loc = it["location_id"]
|
|
wf = it["workflow_id"]
|
|
bulk_prefix = f"[BULK {idx}/{total}]"
|
|
|
|
_INTERRUPT_STATE["location_id"] = loc
|
|
_INTERRUPT_STATE["workflow_id"] = wf
|
|
|
|
try:
|
|
item_summary, item_findings = _scan_one_workflow(page, it, args, bulk_prefix)
|
|
summary.append(item_summary)
|
|
findings.extend(item_findings)
|
|
except SessionExpiredError:
|
|
print(f"[ABORTO] {bulk_prefix} abortado por sesión expirada. Pendientes: {total - idx + 1}.", flush=True)
|
|
for j, rest in enumerate(items[idx - 1:], idx):
|
|
summary.append({**rest, "status": "skipped_session", "anomalies": 0})
|
|
print(f"[BULK {j}/{total}] RESULT: skipped", flush=True)
|
|
aborted = True
|
|
break
|
|
except Exception as ie:
|
|
err_id = error_logging.log_error(
|
|
"workflow_anomaly_scan_item_failed",
|
|
ie,
|
|
{"location_id": loc, "workflow_id": wf, "workflow_name": it.get("workflow_name")},
|
|
)
|
|
print(f"[ERROR] {bulk_prefix} excepción: {ie} | error_id={err_id}", flush=True)
|
|
summary.append({**it, "status": "error", "anomalies": 0, "error_id": err_id})
|
|
print(f"{bulk_prefix} RESULT: failed", flush=True)
|
|
|
|
time.sleep(args.action_pause if hasattr(args, "action_pause") else 1)
|
|
|
|
return findings, summary, aborted
|
|
|
|
|
|
def scan_workflows_parallel(items, args, headless=True):
|
|
"""Escaneo paralelo: cada worker abre su PROPIO sync_playwright + browser + context.
|
|
|
|
Playwright sync API no soporta compartir un Browser entre threads (greenlet loop por thread),
|
|
así que la única topología viable es N procesos-playwright independientes dentro de N threads.
|
|
Cada worker reusa SESSION_FILE para no hacer login (1 sesión → N browsers).
|
|
|
|
Retorna (findings, summary, aborted).
|
|
"""
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
findings = []
|
|
summary = []
|
|
findings_lock = threading.Lock()
|
|
summary_lock = threading.Lock()
|
|
cancel_event = threading.Event()
|
|
counter = {"done": 0}
|
|
counter_lock = threading.Lock()
|
|
|
|
items_sorted = sorted(items, key=lambda x: (x.get("location_id") or "", x.get("workflow_id") or ""))
|
|
work_q = queue.Queue()
|
|
for it in items_sorted:
|
|
work_q.put(it)
|
|
total = work_q.qsize()
|
|
per_action_pause = float(getattr(args, "action_pause", 1.0) or 1.0)
|
|
workers = max(1, min(int(args.workers or 1), 5))
|
|
|
|
print(f"[METRICS] start action=scan-anomalies workers={workers} items={total} pause={per_action_pause}s", flush=True)
|
|
t0 = time.monotonic()
|
|
|
|
run_id = getattr(args, "run_id", None)
|
|
rlog = RunLogger(run_id, os.path.basename(__file__))
|
|
rlog.info("scan_start", workers=workers, items=total, per_action_pause=per_action_pause)
|
|
|
|
def _worker(worker_idx):
|
|
# Cada thread necesita su propio sync_playwright (greenlet loop por thread).
|
|
pw = sync_playwright().start()
|
|
try:
|
|
browser = pw.chromium.launch(headless=headless)
|
|
context = browser.new_context(storage_state=SESSION_FILE)
|
|
page = context.new_page()
|
|
except Exception as launch_err:
|
|
print(f"[ERROR] worker {worker_idx} no pudo lanzar browser: {launch_err}", flush=True)
|
|
try:
|
|
pw.stop()
|
|
except Exception:
|
|
pass
|
|
return
|
|
state = {"context": context, "page": page, "worker_idx": worker_idx, "location_id": None, "workflow_id": None}
|
|
_register_worker_state(state)
|
|
try:
|
|
while not cancel_event.is_set():
|
|
try:
|
|
it = work_q.get_nowait()
|
|
except queue.Empty:
|
|
return
|
|
loc = it.get("location_id")
|
|
wf = it.get("workflow_id")
|
|
with counter_lock:
|
|
counter["done"] += 1
|
|
idx = counter["done"]
|
|
short_loc = (loc or "")[:6]
|
|
prefix = f"[BULK {idx}/{total} w{worker_idx} {short_loc}]"
|
|
state["location_id"] = loc
|
|
state["workflow_id"] = wf
|
|
|
|
item_t0 = time.monotonic()
|
|
rlog.info("item_start", worker_id=worker_idx, location_id=loc, workflow_id=wf,
|
|
name=it.get("workflow_name"))
|
|
|
|
try:
|
|
item_summary, item_findings = _scan_one_workflow(page, it, args, prefix)
|
|
with summary_lock:
|
|
summary.append(item_summary)
|
|
if item_findings:
|
|
with findings_lock:
|
|
findings.extend(item_findings)
|
|
rlog.info("item_done", worker_id=worker_idx, location_id=loc, workflow_id=wf,
|
|
status=item_summary.get("status"),
|
|
anomalies=item_summary.get("anomalies", 0),
|
|
duration_ms=int((time.monotonic() - item_t0) * 1000))
|
|
except SessionExpiredError as se:
|
|
print(f"[ABORTO] {prefix} sesión expirada — cancelando workers restantes.", flush=True)
|
|
with summary_lock:
|
|
summary.append({**it, "status": "skipped_session", "anomalies": 0})
|
|
rlog.error("session_expired", worker_id=worker_idx, location_id=loc,
|
|
workflow_id=wf, message=str(se))
|
|
cancel_event.set()
|
|
return
|
|
except Exception as ie:
|
|
err_id = error_logging.log_error(
|
|
"workflow_anomaly_scan_item_failed",
|
|
ie,
|
|
{"location_id": loc, "workflow_id": wf, "workflow_name": it.get("workflow_name"),
|
|
"run_id": run_id, "worker_id": worker_idx},
|
|
)
|
|
print(f"[ERROR] {prefix} excepción: {ie} | error_id={err_id}", flush=True)
|
|
with summary_lock:
|
|
summary.append({**it, "status": "error", "anomalies": 0, "error_id": err_id})
|
|
rlog.error("item_failed", worker_id=worker_idx, location_id=loc,
|
|
workflow_id=wf, error_id=err_id, message=str(ie)[:500],
|
|
duration_ms=int((time.monotonic() - item_t0) * 1000))
|
|
print(f"{prefix} RESULT: failed", flush=True)
|
|
|
|
# Pausa entre items (interrumpible).
|
|
waited = 0.0
|
|
step = 0.25
|
|
while waited < per_action_pause and not cancel_event.is_set():
|
|
time.sleep(step)
|
|
waited += step
|
|
finally:
|
|
_unregister_worker_state(state)
|
|
try:
|
|
context.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
browser.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
pw.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
futures = [ex.submit(_worker, i + 1) for i in range(workers)]
|
|
for f in futures:
|
|
try:
|
|
f.result()
|
|
except Exception as ee:
|
|
print(f"[ERROR] worker terminó con excepción: {ee}", flush=True)
|
|
|
|
aborted = cancel_event.is_set()
|
|
|
|
# Items que quedaron en la queue tras cancel → marcarlos skipped_session.
|
|
while True:
|
|
try:
|
|
it = work_q.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
with summary_lock:
|
|
summary.append({**it, "status": "skipped_session", "anomalies": 0})
|
|
|
|
dt = time.monotonic() - t0
|
|
scanned = sum(1 for s in summary if s.get("status") in ("scanned", "scanned_empty"))
|
|
failed = sum(1 for s in summary if s.get("status") == "error")
|
|
skipped = sum(1 for s in summary if s.get("status", "").startswith("skipped"))
|
|
rate = (total / dt) if dt > 0 else 0.0
|
|
print(f"[METRICS] end action=scan-anomalies dt={dt:.1f}s rate={rate:.2f}/s scanned={scanned} skipped={skipped} failed={failed}", flush=True)
|
|
rlog.info("scan_end", duration_s=round(dt, 2), scanned=scanned, skipped=skipped,
|
|
failed=failed, rate=round(rate, 3))
|
|
rlog.close()
|
|
|
|
try:
|
|
from paths import LOGS_DIR
|
|
os.makedirs(LOGS_DIR, exist_ok=True)
|
|
metrics_path = os.path.join(LOGS_DIR, "parallel_runs.jsonl")
|
|
with open(metrics_path, "a", encoding="utf-8") as mf:
|
|
mf.write(json.dumps({
|
|
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"run_id": getattr(args, "run_id", None),
|
|
"script": "ghl_browser_workflow_anomaly_scanner.py",
|
|
"action": "scan-anomalies",
|
|
"workers": workers,
|
|
"items": total,
|
|
"duration_s": round(dt, 2),
|
|
"scanned": scanned,
|
|
"skipped": skipped,
|
|
"failed": failed,
|
|
}, ensure_ascii=False) + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
return findings, summary, aborted
|
|
|
|
|
|
def _aggregate_findings_per_workflow(findings):
|
|
"""Agrega findings por (location_id, workflow_id) → una entrada con resumen.
|
|
|
|
Cada entrada tiene:
|
|
workflow_url, account_name, workflow_name, workflow_id, workflow_status,
|
|
anomalies_count, anomaly_types (lista única coma-separada),
|
|
affected_nodes (string multilínea con "label — step_id"),
|
|
details (descripciones únicas concatenadas con ' | '),
|
|
screenshot_path (único).
|
|
"""
|
|
by_wf = {}
|
|
order = []
|
|
for f in findings:
|
|
loc = f.get("location_id", "")
|
|
wf = f.get("workflow_id", "")
|
|
key = (loc, wf)
|
|
if key not in by_wf:
|
|
by_wf[key] = {
|
|
"workflow_url": f"https://crm.bucefalocrm.io/location/{loc}/workflow/{wf}",
|
|
"account_name": f.get("account_name", ""),
|
|
"workflow_name": f.get("workflow_name", ""),
|
|
"workflow_id": wf,
|
|
"workflow_status": f.get("workflow_status", ""),
|
|
"location_id": loc,
|
|
"anomalies_count": 0,
|
|
"_types": [],
|
|
"_nodes": [],
|
|
"_descs": [],
|
|
"screenshot_path": f.get("screenshot_path", "") or "",
|
|
}
|
|
order.append(key)
|
|
agg = by_wf[key]
|
|
agg["anomalies_count"] += 1
|
|
t = f.get("anomaly_type") or ""
|
|
if t and t not in agg["_types"]:
|
|
agg["_types"].append(t)
|
|
node_label = (f.get("node_label") or "").strip()
|
|
step_id = (f.get("unresolved_id") or "").strip()
|
|
node_entry = f"{node_label} — {step_id}" if step_id else node_label
|
|
if node_entry and node_entry not in agg["_nodes"]:
|
|
agg["_nodes"].append(node_entry)
|
|
desc = (f.get("raw_text") or "").strip()
|
|
if desc and desc not in agg["_descs"]:
|
|
agg["_descs"].append(desc)
|
|
# Preservar el primer screenshot_path no vacío.
|
|
if not agg["screenshot_path"] and f.get("screenshot_path"):
|
|
agg["screenshot_path"] = f["screenshot_path"]
|
|
|
|
result = []
|
|
for key in order:
|
|
agg = by_wf[key]
|
|
agg["anomaly_types"] = ", ".join(agg.pop("_types"))
|
|
agg["affected_nodes"] = "\n".join(agg.pop("_nodes"))
|
|
agg["details"] = " | ".join(agg.pop("_descs"))
|
|
result.append(agg)
|
|
return result
|
|
|
|
|
|
def write_reports(findings, summary, aborted, args):
|
|
os.makedirs(REPORT_WORKFLOW_ANOMALIES, exist_ok=True)
|
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
json_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, f"workflow_anomalies_{ts}.json")
|
|
csv_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, f"workflow_anomalies_{ts}.csv")
|
|
|
|
type_counts = {"ghl_native_error": 0, "alert_icon": 0, "unresolved_field_id": 0, "n_button_alert": 0}
|
|
for f in findings:
|
|
t = f["anomaly_type"]
|
|
if t in type_counts:
|
|
type_counts[t] += 1
|
|
|
|
status_counts = {}
|
|
for s in summary:
|
|
status_counts[s["status"]] = status_counts.get(s["status"], 0) + 1
|
|
|
|
wf_with_anomalies = sum(1 for s in summary if s.get("anomalies", 0) > 0)
|
|
|
|
# Versión agregada (una entrada por workflow con anomalías).
|
|
aggregated = _aggregate_findings_per_workflow(findings)
|
|
|
|
payload = {
|
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"aborted": aborted,
|
|
"args": {
|
|
"location": args.location, "all": args.all, "include_main": args.include_main,
|
|
"workflow_id": args.workflow_id, "status": args.status,
|
|
"max_workflows": args.max_workflows, "node_load_timeout": args.node_load_timeout,
|
|
},
|
|
"totals": {
|
|
"workflows_in_scope": len(summary),
|
|
"workflows_scanned": status_counts.get("scanned", 0) + status_counts.get("scanned_empty", 0),
|
|
"workflows_with_anomalies": wf_with_anomalies,
|
|
"anomalies_total": len(findings),
|
|
"by_type": type_counts,
|
|
"by_status": status_counts,
|
|
},
|
|
# Vista agregada: una entrada por workflow con anomalías (la fuente del CSV/XLSX).
|
|
"workflows_with_anomalies_detail": aggregated,
|
|
# Detalle granular: una entrada por hallazgo (programático, audit).
|
|
"findings": findings,
|
|
"summary_per_workflow": summary,
|
|
}
|
|
|
|
with open(json_path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
|
|
csv_cols = [
|
|
"workflow_url", "account_name", "workflow_name", "workflow_id",
|
|
"workflow_status", "anomalies_count", "anomaly_types",
|
|
"affected_nodes", "details", "screenshot_path",
|
|
]
|
|
with open(csv_path, "w", encoding="utf-8-sig", newline="") as f:
|
|
writer = csv.DictWriter(f, fieldnames=csv_cols, extrasaction="ignore")
|
|
writer.writeheader()
|
|
for row in aggregated:
|
|
writer.writerow(row)
|
|
|
|
print(f"[INFO] Reporte JSON: {json_path}")
|
|
print(f"[INFO] Reporte CSV : {csv_path}")
|
|
# Línea estructurada para que el frontend ofrezca un botón "Descargar reporte".
|
|
print(f"[INFO] REPORT_JSON: {os.path.basename(json_path)}", flush=True)
|
|
print(f"[INFO] REPORT_CSV: {os.path.basename(csv_path)}", flush=True)
|
|
return json_path, csv_path, payload["totals"]
|
|
|
|
|
|
def parse_args():
|
|
p = argparse.ArgumentParser(description="Detecta nodos con anomalías en workflows de Bucéfalo (read-only).")
|
|
p.add_argument("--location", help="Location ID de la subcuenta a escanear.")
|
|
p.add_argument("--all", action="store_true", help="Escanear todas las subcuentas.")
|
|
p.add_argument("--include-main", action="store_true", help="Incluir la cuenta de Marca cuando hay --all.")
|
|
p.add_argument("--workflow-id", dest="workflow_id", help="Escanear solo este workflow (requiere --location).")
|
|
p.add_argument("--status", default="active", choices=["active", "all"],
|
|
help="Filtro por status del workflow (default: active = solo publicados).")
|
|
p.add_argument("--max-workflows", dest="max_workflows", type=int, default=0,
|
|
help="Tope global de workflows a escanear (0 = sin tope).")
|
|
p.add_argument("--node-load-timeout", dest="node_load_timeout", type=int, default=40,
|
|
help="Segundos máximos esperando el canvas por workflow.")
|
|
p.add_argument("--headed", action="store_true", help="Lanzar Chromium con UI visible para depurar.")
|
|
p.add_argument("--run-id", dest="run_id", help="ID de auditoría inyectado por el dashboard.")
|
|
p.add_argument("--batch-file", dest="batch_file",
|
|
help="Path a JSON con lista de {location_id, workflow_id, name?, account_name?} a escanear.")
|
|
p.add_argument("--workers", type=int, default=1,
|
|
help="Contextos paralelos (1-5). Default 1. Ignorado en perfil persistente.")
|
|
p.add_argument("--action-pause", type=float, default=1.0, dest="action_pause",
|
|
help="Pausa (s) por worker entre items. Default 1.0.")
|
|
args = p.parse_args()
|
|
if PERSISTENT_PROFILE_DIR and args.workers > 1:
|
|
print("[INFO] Perfil persistente activo → forzando --workers=1.")
|
|
args.workers = 1
|
|
args.workers = max(1, min(int(args.workers or 1), 5))
|
|
return args
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
_install_signal_handlers()
|
|
_INTERRUPT_STATE["action"] = "scan-anomalies"
|
|
_INTERRUPT_STATE["run_id"] = args.run_id
|
|
|
|
if args.run_id:
|
|
# Bootstrap idempotente del run en script_audit (mismo patrón que manager).
|
|
try:
|
|
script_audit.create_run(
|
|
args.run_id,
|
|
script_name=os.path.basename(__file__),
|
|
arguments=" ".join(sys.argv[1:]),
|
|
locations=[args.location] if args.location else [],
|
|
execution_mode="parallel" if args.workers > 1 else "sequential",
|
|
)
|
|
script_audit.update_run_status(args.run_id, "running")
|
|
except Exception as ae:
|
|
print(f"[WARN] No se pudo crear run en script_audit: {ae}")
|
|
|
|
if not ensure_playwright_browsers():
|
|
sys.exit(1)
|
|
|
|
exists, age_hours = session_file_status()
|
|
if not exists:
|
|
print("[ERROR] No hay sesión Playwright guardada. Corre primero 'Generar Sesión GHL'.")
|
|
sys.exit(2)
|
|
if age_hours is not None and age_hours > SESSION_MAX_AGE_HOURS:
|
|
print(f"[ADVERTENCIA] La sesión tiene {age_hours:.1f}h de antigüedad — puede haber expirado.")
|
|
|
|
items = _resolve_workflows_to_scan(args)
|
|
if not items:
|
|
print("[INFO] No hay workflows que cumplan los filtros. Nada que escanear.")
|
|
if args.run_id:
|
|
try:
|
|
script_audit.update_run_status(args.run_id, "success", "Sin workflows en alcance")
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
print(f"[INFO] {len(items)} workflow(s) en alcance. Iniciando escaneo...")
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
findings, summary, aborted = [], [], False
|
|
with sync_playwright() as p:
|
|
if args.workers > 1 and not PERSISTENT_PROFILE_DIR:
|
|
# Paralelo: cada worker abre su propio sync_playwright + browser.
|
|
# No abrimos un browser en el main thread — sería inutilizado.
|
|
findings, summary, aborted = scan_workflows_parallel(items, args, headless=not args.headed)
|
|
else:
|
|
browser, context, page = _open_browser(p, headless=not args.headed)
|
|
_INTERRUPT_STATE["browser"] = browser
|
|
_INTERRUPT_STATE["context"] = context
|
|
try:
|
|
findings, summary, aborted = scan_workflows(page, items, args)
|
|
finally:
|
|
_close_and_save(browser, context)
|
|
|
|
json_path, csv_path, totals = write_reports(findings, summary, aborted, args)
|
|
|
|
print("\n=== RESUMEN BULK-SCAN-ANOMALIES ===")
|
|
print(f" Workflows en alcance : {totals['workflows_in_scope']}")
|
|
print(f" Workflows escaneados : {totals['workflows_scanned']}")
|
|
print(f" Workflows con anomalías : {totals['workflows_with_anomalies']}")
|
|
print(f" Anomalías totales : {totals['anomalies_total']}")
|
|
print(f" - ghl_native_error : {totals['by_type']['ghl_native_error']}")
|
|
print(f" - alert_icon : {totals['by_type']['alert_icon']}")
|
|
print(f" - unresolved_field_id : {totals['by_type']['unresolved_field_id']}")
|
|
print(f" - n_button_alert : {totals['by_type']['n_button_alert']}")
|
|
print(f" Estados por workflow : {totals['by_status']}")
|
|
print(f" Abortado por sesión : {aborted}")
|
|
print("=============================")
|
|
|
|
if args.run_id:
|
|
try:
|
|
status_label = "stopped" if aborted else "success"
|
|
msg = "Escaneo abortado por sesión expirada" if aborted else None
|
|
script_audit.update_run_status(args.run_id, status_label, msg)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|