#!/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 , --all o --batch-file .") 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 : 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 : 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()