Files
MP-Manager/docs/PLAYWRIGHT_PATTERNS.md
T
2026-05-30 14:31:19 -06:00

37 KiB
Raw Blame History

Patrones probados de Playwright contra Bucéfalo / GHL

Este documento es la post-mortem del trabajo de hacer que el auto-login con 2FA por correo funcione end-to-end. Sirve como referencia para futuros scripts de automatización contra la UI de Bucéfalo (o GHL en general).

Va de la mano con:

  • PLAYWRIGHT_SESSION.md — cómo manejar la sesión (storage_state vs perfil persistente, auto-login con .env).
  • memory/ghl_ui_quirks.md — quirks operativos de la UI de Bucéfalo.

Caso de estudio: el auto-login con 2FA

Problema original

Tras configurar credenciales en .env, el botón "Renovar sesión Bucéfalo" disparaba un subprocess Playwright que debía:

  1. Llenar email + contraseña.
  2. Click en "Iniciar sesión".
  3. Seleccionar el método 2FA "Email".
  4. Esperar a que llegara el correo con el OTP.
  5. Pegar el código en la UI.
  6. Esperar al dashboard.

El primer intento funcionó hasta el paso 2. A partir del 3 el script reportaba "Auto-login falló" — pero el usuario veía que en realidad el login sí completaba (la barra de Bucéfalo mostraba "0 h de antigüedad"). Tres bugs encadenados.

Diagnóstico: cómo lo descubrimos

Sin screenshots de debug, las iteraciones iniciales eran a ciegas. La táctica que funcionó:

  1. Agregar _save_debug_screenshot(page, label) en cada punto crítico del flujo:
    • post_login (tras enviar credenciales)
    • no_method_selector (si el detector de método 2FA caduca)
    • before_otp_input (justo antes de pedir el OTP por IMAP)
    • otp_input_search (tras detectar dónde tipear el código)
    • code_typed (con el código ya pegado)
    • final_state (estado final, sea éxito o fallo)
  2. Mirar las capturas en orden para reconstruir lo que pasó.

Las capturas revelaron los 3 bugs:

# Bug Síntoma en captura
1 Bucéfalo NO muestra selector de método 2FA — manda el código directo al correo post_login mostró directo la pantalla "Verificar el Código" con 6 inputs vacíos
2 El .fill(code) en un input con maxlength="1" solo acepta el primer caracter code_typed mostró "8" en el primer input y los siguientes 5 vacíos
3 _wait_for_login_completion chequeaba solo page.url, pero Bucéfalo es SPA y mantiene la URL en / aunque el contenido sea el dashboard final_state mostró el dashboard completo, pero el script reportó fallo y la URL seguía siendo /

Fixes aplicados (en orden de iteración)

Iteración 1 — Selectores amplios para el método 2FA: agregué 16 variaciones (button, div[role=button], radio, label, tarjetas con clase option/method, data-test-id). Hago polling 15 s buscando cualquiera de ellos. Si ninguno aparece, asumo que Bucéfalo mandó el código directo y continúo. — Bug 1 cubierto.

Iteración 2 — Tipeo del OTP con keyboard.type: en lugar de input.fill(code) (que rompe con maxlength=1), detecto los 6 inputs de un dígito (input[maxlength="1"], filtrados a visibles), hago click en el primero para foco, y luego page.keyboard.type(code, delay=80). Eso simula tipeo humano caracter por caracter; Vue captura cada keydown y mueve el foco automáticamente al siguiente input. — Bug 2 cubierto.

Iteración 3 — Detección de login completado por DOM + URL: _wait_for_login_completion ahora combina dos señales:

  • URL fuera de /login o /auth.
  • DOM ya no muestra el form de login (ningún input[type="password"] visible, ni texto "Verificar el Código", ni "Sign in to your account").

Requiere 2 polls consecutivos confirmando ambas señales (~4 s mínimo) para evitar falsos positivos durante la redirección. — Bug 3 cubierto.

Resultado verificado

Corrida final desde terminal:

[AUTO] Detectados 6 inputs de 1 dígito (input[maxlength="1"]).
[AUTO] Código tipeado vía keyboard.type (modo: digits).
[INFO] URL actual: https://crm.bucefalocrm.io/
[INFO] URL actual: https://crm.bucefalocrm.io/agency_dashboard?tab=summary
[INFO] Login completado (URL fuera del login + DOM sin form de credenciales).
[ÉXITO] Sesión guardada en: H:\...\generated\browser\session.json

Tiempo total: ~30-45 s desde "click en Renovar" hasta sesión guardada.


Lecciones generales para scripts contra Bucéfalo / SPAs

1. Nunca confíes solo en la URL

Bucéfalo (y GHL en general) son SPAs Vue/React. Las transiciones internas:

  • Pueden mantener la URL mientras cambian el contenido (route guard, replaceState, re-render del root).
  • Pueden mostrar el form de login dentro del mismo iframe cuando una request da 401, sin redirigir a /login.
  • Pueden cambiar la URL pero seguir mostrando contenido viejo brevemente durante la hidratación.

Patrón a usar: combinar URL con DOM. Helpers ya existentes:

2. Para inputs con maxlength="1" (PIN / OTP / tarjetas), usa keyboard.type con delay

locator.fill("123456") en un input con maxlength="1" se trunca y rompe el flujo. La alternativa:

# Detectar inputs de un dígito (más permisivo que `[autocomplete=one-time-code]`)
inputs = page.locator('input[maxlength="1"]').all()
visible = [i for i in inputs if i.is_visible(timeout=200)]

if len(visible) >= 6:
    visible[0].click()                         # foco en el primero
    time.sleep(0.3)
    page.keyboard.type(code, delay=80)          # tipeo humano, Vue auto-advance

El delay=80 ms es suficiente para que Vue/React capture cada keydown.

3. Selectores amplios y en cascada

Los selectores de UIs comerciales cambian con cada update. Patrón defensivo:

SELECTORS = [
    'button:has-text("Email")',
    'button:has-text("Correo electrónico")',
    'div[role="button"]:has-text("Email")',
    '[data-test-id*="email"]',
    'label:has-text("Email")',
    # ...
]
deadline = time.time() + 15
while time.time() < deadline:
    for sel in SELECTORS:
        try:
            loc = scope.locator(sel).first
            if loc.count() > 0 and loc.is_visible(timeout=300):
                loc.click(timeout=2000)
                clicked = True
                break
        except Exception:
            continue
    if clicked: break
    time.sleep(1)

Reglas:

  • Probar texto en español Y inglés — la UI puede estar en otro idioma según preferencia del usuario.
  • No usar wait_for_selector rígido con un único selector — puede caducar 20 s antes de que pruebes otra opción.
  • Hacer polling dentro del while time.time() < deadline.
  • Soportar el caso de "no apareció" como flujo válido (a veces la UI salta pasos).

4. Screenshots de debug en cada punto crítico

Es la herramienta # 1 para diagnosticar. Convención del proyecto:

from paths import SCREENSHOTS_DIR  # generated/browser/screenshots/

def _save_debug_screenshot(page, label):
    try:
        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")
        page.screenshot(path=path, full_page=True)
        print(f"[DEBUG] Captura guardada: {path}")
        return path
    except Exception:
        return None

Naming: {prefijo_flujo}_{paso}_{timestamp}.png. Ej: autologin_post_login_20260523_184611.png.

scripts/cleanup_storage.py los limpia automáticamente cada 30 días (configurable).

5. Reusar browser entre operaciones en bulk

Abrir/cerrar Chromium cuesta 8-10 s por operación. Para bulks con N items, el patrón es:

with sync_playwright() as p:
    browser, context, page = _open_browser(p)
    _INTERRUPT_STATE["browser"] = browser
    _INTERRUPT_STATE["context"] = context
    try:
        for item in items:
            try:
                _perform_action_on_page(page, item)  # navega a la URL del item
            except SessionExpiredError:
                # abortar todo el bulk; los demás items también fallarían
                break
            except Exception as e:
                # fallo aislado, continuar con el siguiente
                ...
            time.sleep(2)  # no martillar la API de GHL
    finally:
        _close_and_save(browser, context)

Helpers ya disponibles:

  • _open_browser(p) y _close_and_save(browser, context) en ghl_browser_workflow_manager.py — manejan el modo (shared storage_state vs perfil persistente) y refrescan cookies al cerrar.
  • _INTERRUPT_STATE global + _install_signal_handlers() — garantizan cleanup limpio si el server reinicia o el usuario cancela el task.

6. Validar contra la API de GHL, no contra la UI

La UI de Bucéfalo tiene bugs visuales (puede mostrar "Guardado" sin haber guardado, puede no refrescar tras una mutación). La API es la fuente de verdad.

# Después de mutar vía DOM, esperar y reconsultar la API.
def _verify_status_via_api(location_id, workflow_id, target_active,
                           max_attempts=6, base_wait_sec=3):
    for attempt in range(1, max_attempts + 1):
        time.sleep(base_wait_sec * attempt)  # backoff lineal
        wfs = sync_engine.ghl_client.get_workflows(token, location_id)
        actual = next((w for w in wfs if w.get("id") == workflow_id), None)
        if actual and (actual["status"] in ("active","published")) == target_active:
            return True
    return False

GHL puede tardar hasta 20 s en propagar un cambio del builder a su API → reintentar con backoff es esencial.

7. Excepciones tipadas para flujos especiales

Si una condición de error invalida todo lo que sigue (sesión expirada en bulk, login redirect, etc.), levanta una excepción dedicada:

class SessionExpiredError(Exception):
    """Bucéfalo redirigió al login → toda interacción posterior va a fallar."""
    pass

Y en el caller, captura específicamente para abortar early:

for item in items:
    try:
        _perform_on_page(page, item)
    except SessionExpiredError:
        # Marcar los items restantes como skipped sin intentarlos.
        for remaining in items[idx:]:
            results.append({"status": "skipped", "reason": "sesión expirada"})
        break
    except Exception as e:
        # Error aislado, continuar.
        ...

Eso evita N timeouts de 30s cuando ya sabemos que todo fallará.

8. SSE para progreso en tiempo real

Los scripts largos (auto-login, bulks) imprimen líneas al stdout con marcadores que el frontend parsea por regex y traduce a estados humanos:

# Backend (Python script):
print(f"[BULK {idx}/{total}] === '{name}' ({wf_id}) ===")
print(f"[BULK {idx}/{total}] RESULT: {status}")  # success|failed|skipped

# Frontend (JS):
const m = line.match(/\[BULK (\d+)\/(\d+)\] RESULT: (\w+)/);
if (m) { /* incrementar contador del status correspondiente */ }

Patrón clave: un marcador RESULT único por item (no contar líneas SKIP separadas — eso causa doble conteo). Ver bulkParseLine en static/js/app.js.

Para tareas largas que no son loops, traduce las líneas más relevantes a UI strings:

function _interpretSessionLogLine(line) {
    if (/Llenando email \+ contraseña/.test(line))   return { title: "Llenando credenciales…", detail: "..." };
    if (/Esperando código OTP por IMAP/.test(line))  return { title: "Esperando el código en el correo…", detail: "..." };
    // ...
    return null;
}

Permite que el modal de progreso muestre algo como "Esperando el código en el correo…" en vez de "[AUTO] Esperando código OTP por IMAP...".

9. Manejo de modales bloqueantes

Bucéfalo a veces muestra modales tipo "AI Builder habilitado" o "Novedades" que tapan los elementos. Patrón:

_DISMISS_BUTTON_SELECTOR = (
    'button:has-text("Entendido"), button:has-text("Got it"), '
    'button:has-text("OK"), button:has-text("Aceptar")'
)

def _dismiss_blocking_modals(scope):
    try:
        btn = scope.locator(_DISMISS_BUTTON_SELECTOR).first
        if btn.count() > 0 and btn.is_visible(timeout=500):
            btn.click()
            time.sleep(0.7)
            return True
    except Exception:
        pass
    return False

# Llamar PREEMPTIVAMENTE antes de tu click — y reintentar si reaparece.
for _ in range(3):
    if not _dismiss_blocking_modals(scope):
        break
# Tu acción:
target.click()
# Y otra vez por si el modal apareció después.
_dismiss_blocking_modals(scope)

Si el modal aparece durante un click crítico (toggle de switch), reintentar el click hasta 3 veces verificando el estado deseado.

10. Espera explícita por estabilización del estado

Algunos elementos cargan en el DOM antes de tener su estado real bound. Patrón:

# En vez de leer aria-checked inmediatamente:
state = switch.get_attribute("aria-checked")  # ⚠ puede ser el default, no el real

# Esperar networkidle (incluso si caduca, el sleep es útil) + margen:
try:
    builder_frame.wait_for_load_state("networkidle", timeout=25000)
except Exception:
    pass
time.sleep(3)  # GHL termina de bindear el estado real

state = switch.get_attribute("aria-checked")  # ✓ ahora confiable

Anti-patterns observados

Anti-pattern Por qué falla Alternativa
confirm() / prompt() / alert() nativos del browser Rompen la estética del dashboard appConfirm({...}) / appPrompt({...}) en static/js/app.js
input.fill("123456") en input con maxlength="1" Solo acepta el primer caracter keyboard.type(code, delay=80) con foco previo
if "login" in page.url como única señal de sesión expirada Las SPAs no cambian URL Combinar con page.locator('input[type="password"]') o textos visibles
wait_for_selector rígido a un selector único Caduca antes de probar variantes Polling sobre lista de selectores con time.time() < deadline
Abrir un Chromium nuevo por cada item de un bulk 8-10 s perdidos por item Reusar browser/context entre items con loop interno
Trust en el toast "Guardado" del UI como confirmación Bucéfalo tiene bugs visuales Reconsultar la API tras la mutación
Saltar pausa entre items del bulk GHL rate-limita y gatilla antibot time.sleep(2) o más entre operaciones

Plantilla recomendada para un script nuevo

Si quieres escribir un script nuevo de browser-automation contra Bucéfalo:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tu descripción aquí."""

import os, sys, time
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)

# Reusar helpers existentes del manager principal.
from scripts.ghl_browser_workflow_manager import (
    _open_browser, _close_and_save,
    _save_debug_screenshot,
    _any_frame_at_login, SessionExpiredError,
    _INTERRUPT_STATE, _install_signal_handlers,
    ensure_playwright_browsers, session_file_status,
)


def _perform_action_on_page(page, params):
    """Hace lo tuyo en una página ya abierta. Lanza SessionExpiredError si Bucéfalo
    redirige al login. Devuelve True si tuvo éxito."""
    target_url = f"https://crm.bucefalocrm.io/.../{params['id']}"
    page.goto(target_url, wait_until="domcontentloaded", timeout=30000)
    time.sleep(2)
    if _any_frame_at_login(page):
        raise SessionExpiredError("Bucéfalo redirigió al login")

    # ... tu lógica aquí, con screenshots de debug en puntos críticos ...
    _save_debug_screenshot(page, "mi_paso_critico")
    return True


def main():
    _install_signal_handlers()
    if not ensure_playwright_browsers():
        sys.exit(1)
    exists, age = session_file_status()
    if not exists:
        print("ERROR: Falta generated/browser/session.json. Renueva primero con 'Renovar sesión Bucéfalo'.")
        sys.exit(1)

    from playwright.sync_api import sync_playwright
    with sync_playwright() as p:
        browser, context, page = _open_browser(p)
        _INTERRUPT_STATE["browser"] = browser
        _INTERRUPT_STATE["context"] = context
        try:
            ok = _perform_action_on_page(page, {"id": "..."})
            sys.exit(0 if ok else 1)
        finally:
            _close_and_save(browser, context)


if __name__ == "__main__":
    main()

Y si el script debe correr desde el dashboard como subprocess, registra su entrada en SCRIPTS_METADATA dentro de script_runner.py.


Checklist antes de mergear un script nuevo

  • ¿Los selectores tienen variantes en español y inglés?
  • ¿Cada paso crítico guarda screenshot con _save_debug_screenshot?
  • ¿La detección de sesión expirada usa _any_frame_at_login (URL + DOM)?
  • ¿Las mutaciones validan contra la API tras un margen de 3+ segundos con backoff?
  • ¿Hay manejo de SessionExpiredError para abortar early en bulks?
  • ¿El browser se cierra con _close_and_save (refresca cookies)?
  • ¿El script registra contexto en _INTERRUPT_STATE para que las interrupciones cierren limpio?
  • ¿Las pausas entre operaciones son al menos 2 s?
  • ¿Para inputs maxlength=1, se usa keyboard.type con delay?
  • ¿Hay un RESULT: único por item si es un bulk (para el parser frontend)?
  • ¿sys.stdout/sys.stderr reconfigurados a UTF-8 al inicio del script? (evita UnicodeEncodeError en Windows cp1252 con , ·, etc.)
  • Si escanea o lee el editor visual de workflows: ¿espera el frame correcto (client-app-automation-workflows.leadconnectorhq.com, no el principal) y fuerza el render completo con #workflow-fit-to-screen?

Caso de estudio 2: el detector de anomalías en el editor visual de workflows

Problema

Escanear el canvas de cada workflow buscando tres tipos de anomalía: ícono naranja en nodos (img#pg-actions__icon--eh-show-error), IDs de custom field sin resolver dentro del texto del nodo (~20 chars alfanuméricos entre comillas), y avisos globales (span.n-button__content svg[class*="alert"]).

El primer escaneo reportó 0 anomalías en un workflow donde el usuario sabía que existía la cadena If "Ct0n2f9dvjZNe9npY6WY" no está vac.... Tomó cinco iteraciones llegar a un detector confiable.

Lecciones aprendidas (orden de iteración)

L1 — El editor visual vive en un iframe externo, no en crm.bucefalocrm.io

La page principal (https://crm.bucefalocrm.io/location/{loc}/workflow/{wf}) contiene un iframe a https://client-app-automation-workflows.leadconnectorhq.com/... donde está el builder real.

_find_builder_frame() original buscaba el primer frame con un switch o modal del AI Builder, lo cual funciona para toggles pero no garantiza que sea el frame del canvas. Para escanear nodos hay que elegir el frame con MÁS matches del wrapper de nodos, no el primero que tenga ≥1.

# Estrategia que funcionó:
best_frame, best_count = None, 0
for frame in page.frames:
    count = frame.locator(NODE_WRAPPER_SEL).count()
    if count > best_count:
        best_count = count
        best_frame = frame

Diagnóstico: imprimir page.frames con frame.url + count por frame.

L2 — Vue Flow lazy-renderiza nodos: hay que forzar fit-to-screen

El builder de workflows está construido sobre Vue Flow (vue-flow__node, vue-flow__minimap). Vue Flow solo renderiza en el DOM los nodos visibles en el viewport actual — el resto está representado en el minimap pero no existe como elementos del DOM.

Sin fit-to-screen, un workflow de 23 nodos puede renderizar solo 2-5 (los visibles al cargar). Con el ícono de "Ajustar a la pantalla" clickeado, los 23 quedan accesibles.

fit_btn = frame.locator('#workflow-fit-to-screen')
if fit_btn.count() > 0:
    fit_btn.first.click(timeout=3000)
    time.sleep(2)
    fit_btn.first.click(timeout=2000)  # doble click: el primero a veces solo muestra tooltip

IDs útiles del builder (todos están en el frame del iframe externo):

  • #workflow-fit-to-screen — encajar todo el flujo. Imprescindible para escaneo full.
  • #workflow-zoom-in, #workflow-zoom-out, #workflow-zoom-value — controles manuales.
  • #workflow-builder-tab-error-highlight — tab "Resaltar errores" del builder, candidato para futuras detecciones.
  • #workflow-builder-tab-search-and-replace — útil para buscar IDs específicos sin escanear el DOM.

L3 — Los data-v-* son hashes scoped de Vue que cambian con deploys

Inicialmente usé [data-v-aad7ddfb], [data-v-72bc1535] como selector porque esos hashes aparecían en el HTML que el usuario me compartió. Esos hashes son IDs internos que Vue genera al compilar — cambian con cada deploy de GHL. Selectores robustos por estructura/clase:

  • div.rounded-xl.node-shadow — wrapper de nodos de acción.
  • div.rounded-xl.border-b-4 — wrapper de nodos Branch (border de color según tipo).
  • div.rounded-xl.nopan — algunos wrappers.
  • #action-node-container — id del contenedor (se repite por nodo, pero CSS no lo previene).
  • .vue-flow__node con data-id="<uuid>" — wrapper estable del Vue Flow propio, es el más confiable para identificar nodos individualmente.

L4 — inner_text() respeta CSS truncate; usa text_content()

Los nodos del builder usan class="truncate" (Tailwind) para cortar texto visualmente con ellipsis. inner_text(timeout=...) de Playwright devuelve solo el texto visible según el CSS rendering — pierde caracteres después del corte.

text = node.text_content(timeout=500) or ""  # devuelve todo el texto del DOM, ignora truncate

Esto es crítico para la regex de IDs (r'["“”]([A-Za-z0-9]{20})["“”]') que requiere las dos comillas: con inner_text() la comilla de cierre puede quedar oculta tras la elipsis.

L5 — Los íconos de alerta están posicionados absolutos FUERA del wrapper del nodo

El ícono img#pg-actions__icon--eh-show-error no es descendiente del vue-flow__node correspondiente — Vue Flow lo renderiza como overlay con position: absolute en un div separado en el DOM. Subir por parentElement no llega al nodo.

Solución: usar el.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4') que busca el ancestor más cercano con cualquiera de esos selectores. Si no encuentra, fallback a buscar el sibling con texto significativo.

let node = img.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4, div[class*="rounded-xl"]');
// node.getAttribute('data-id') te da el UUID del nodo Vue Flow.

Reportar data-id del nodo en el output permite al usuario abrir el nodo específico en la UI (los data-id son los UUIDs que GHL usa internamente).

L6 — UTF-8 en stdout: la flecha mata el script en Windows cp1252

Cuando el script se corre como subprocess desde el dashboard, su stdout va a un pipe. En Windows el encoding default es cp1252 y caracteres como , ·, «» rompen con UnicodeEncodeError. Reconfigurar al inicio del script:

try:
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
except Exception:
    # Python <3.7: fallback
    import io
    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)

Esto aplica a cualquier script Playwright nuevo porque la consola del subprocess y las capturas SSE pasan por esos pipes.

L7 — Para inspeccionar el DOM real, dumpea el HTML del frame correcto

Cuando los selectores no matchean lo que esperas, el primer reflejo es ajustar el selector. El paso anterior es dumpear el HTML real del frame que estás escaneando y grepearlo. Patrón:

if os.environ.get("ANOMALY_SCANNER_DEBUG"):
    html = frame.evaluate("() => document.documentElement.outerHTML")
    dump_path = os.path.join(SCREENSHOTS_DIR, f"frame_dump_{wf_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)")
    # También logguea cuál frame elegiste vs todos los disponibles:
    for fi, f_ in enumerate(page.frames):
        cnt = f_.locator(NODE_WRAPPER_SEL).count()
        print(f"[DEBUG] page.frames[{fi}]: count={cnt} url={f_.url[:120]}")

frame.evaluate("() => document.documentElement.outerHTML") retorna el HTML live (post-JS), mientras que frame.content() puede devolver una versión más temprana.

L8 — Las anomalías de campo pueden necesitar contexto semántico, no solo regex

La regex r'["“”]([A-Za-z0-9]{20})["“”]' matchea 20 chars alfanuméricos entre comillas. Pero esos 20 chars podrían ser el workflow_id, el location_id, un UUID de pipeline, o un identificador legítimo. Filtros que aplicamos:

  • Descartar si el candidato coincide con workflow_id o location_id del propio workflow.
  • Exigir keywords de contexto en el texto del nodo ("no está vac", "está vac", "is empty", "es igual", "contiene", etc.).

Después de la primera corrida --all, revisar manualmente 5-10 hits y ajustar CONTEXT_KEYWORDS o endurecer la regex.

L9 — Si la app ya valida algo, no lo re-implementes con heurísticas — pídeselo a la app

El usuario nos mostró que GHL tiene un botón #workflow-builder-tab-error-highlight que se pinta naranja cuando el workflow tiene errores. Al clickearlo, se abre un panel con la lista exacta de nodos problemáticos, sus StepIds y descripciones del error generadas por el propio motor de validación de GHL.

Esto es infinitamente más confiable que cualquier heurística externa:

  • Idioma-agnóstico (el botón existe con el mismo id en español y en inglés).
  • Robusto a deploys (el id es estable).
  • Lo que GHL considera "error" es exactamente lo que el usuario considera "error" en la UI.
  • Da info estructurada: node_name + StepId + descripción exacta del error.

Patrón resultante (lo que ahora es el detector primario del scanner):

def _extract_ghl_native_errors(frame):
    btn = frame.locator('#workflow-builder-tab-error-highlight')
    if btn.count() == 0:
        return []
    btn.first.click(timeout=3000)
    time.sleep(2.5)  # esperar render del panel
    return frame.evaluate("""() => {
        const out = [];
        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;
        // Wrapper de cada error: '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();
            const m = stepIdRe.exec(txt);
            if (!m || seen.has(m[1])) continue;
            seen.add(m[1]);
            const cleaned = txt.replace(/^\\s*\\d+\\.\\s*/, '');
            const stepIdx = cleaned.toLowerCase().indexOf('stepid');
            out.push({
                node_label: cleaned.slice(0, stepIdx).trim().slice(0, 120),
                step_id: m[1],
                description: cleaned.slice(stepIdx).replace(stepIdRe, '').trim().slice(0, 400),
            });
        }
        return out;
    }""") or []

Aplicación general: antes de escribir heurísticas para detectar X, busca si la app expone un tab/panel/badge que ya cuente lo que necesitas. En GHL hay varios candidatos similares que pueden servir para otros escenarios:

  • #workflow-builder-tab-error-highlight — errores del motor de validación (lo anterior).
  • #workflow-builder-tab-stats-view — estadísticas de ejecución, ideal para auditar workflows con tasas altas de falla.
  • #workflow-builder-tab-version-history — historial de cambios.
  • #workflow-builder-tab-search-and-replace — buscar/reemplazar texto en nodos, atajo para encontrar IDs sin escanear DOM completo.

Las heurísticas externas (escanear DOM con selectores y regex) siguen siendo útiles como complemento para detectar cosas que la app NO considera error pero el usuario sí — p.ej. nuestro detector heurístico de unresolved_field_id encontró Ct0n2f9dvjZNe9npY6WY en un nodo Branch que GHL considera sintácticamente válido (porque la condición If "X" no está vacío se evalúa OK como texto literal) pero que para el negocio es un bug porque ese ID nunca se resolvió al nombre del campo.

Política recomendada para el reporte:

  • La detección nativa de la app es la fuente primaria: si reporta un nodo, ese nodo se incluye con info rica (descripción del error oficial).
  • Las heurísticas son complemento: corren igual, pero si un hallazgo heurístico apunta al MISMO nodo (por data-id/StepId) que un hallazgo nativo, se suprime el heurístico para evitar ruido (if node_id in native_step_ids: continue).
  • Hallazgos heurísticos en nodos NO cubiertos por la app se mantienen — es exactamente donde aportan valor.

Integración con el dashboard de bulk-operations

Para que el progreso del escaneo se renderice solo en la bulk-bar existente (Publicar/Borrador/Eliminar), el script tiene que emitir exactamente las líneas que app.js parsea:

[BULK X/N] === 'workflow_name' (workflow_id) en location_id ===
[BULK X/N] RESULT: success|failed|skipped
=== RESUMEN BULK-<TARGET> ===

El frontend hace await mutateFetch(\/api/workflows/bulk-${target}`, ...)con target en{draft, publish, delete, scan-anomalies}`. Para sumar una operación nueva basta con:

  1. Endpoint POST /api/workflows/bulk-{target} que escribe batch JSON y dispara el script.
  2. Script que acepte --batch-file <path> y emita las líneas BULK.
  3. Botón en la bulk-bar + función JS que llame _executeBulkOperation('{target}', items, label).

Lección 19. Paralelismo conservador: N browsers, 1 sesión compartida

Problema: los bulks Playwright procesaban workflows uno por uno (~30-50s c/u). Un scan/bulk completo sobre las 50 cuentas tomaba horas.

Intento fallido (no replicar): "1 proceso → 1 Browser → N BrowserContext en threads" — la sync API de Playwright lleva un greenlet event loop por thread; cualquier objeto creado en el thread main (Browser, Context, Page) explota al usarse desde otro thread con:

greenlet.error: cannot switch to a different thread (which happens to have exited)

Solución que funciona: cada worker thread abre su propio sync_playwright().start() + browser.launch() + new_context(storage_state=SESSION_FILE). La sesión sí se comparte (1 login → N browsers reusando cookies), pero cada thread tiene su event loop dedicado.

Implementación en ghl_browser_workflow_manager._run_parallel_bulk y ghl_browser_workflow_anomaly_scanner.scan_workflows_parallel.

Topología real:

1 proceso → N threads (ThreadPoolExecutor)
            │
            └── cada thread: sync_playwright().start()
                              └── chromium.launch()           ← N browsers Chromium
                                   └── new_context(storage_state=SESSION_FILE)
                                        └── new_page()

Reglas duras:

  1. NUNCA paralelizar con perfil persistente (GHL_BROWSER_PROFILE_DIR). Chromium bloquea el profile dir → segundo browser que intenta abrirlo cuelga. Detectar al inicio y forzar workers=1.
  2. NUNCA paralelizar el login. OTP por IMAP es un buzón compartido — 2 procesos compitiendo por el mismo correo se pisan. Genera 1 sesión secuencial, todos los workers reusan session.json.
  3. Cada thread crea su propio sync_playwright().start() + browser. No intentes compartir un Browser entre threads — la sync API tiene un greenlet loop por thread y los objetos están atados al loop que los creó.
  4. storage_state se persiste best-effort al cerrar cada context. Cookies refrescadas por GHL durante el run quedan en session.json para la próxima ejecución. No es transaccional; con N workers, gana el último.
  5. Cancelación cooperativa con threading.Event. Cuando un worker captura SessionExpiredError, setea cancel_event; los demás revisan en el bucle y salen limpio. No swallow de excepciones.
  6. Pausa entre items, no entre workers. per_action_pause (3s default mutador, 1s read-only) por worker, interrumpible mediante chequeo de cancel_event en sleep granular (step=0.25s).
  7. Agrupar la queue por location_id. Reduce navegación lateral y refresh de cookies cross-sucursal.
  8. Cada cambio del mutador sigue auditándose con script_audit. SQLite serializa writes; activamos WAL en init_audit_db para evitar database is locked con 2-5 workers.
  9. Tope de 5 workers. Más allá, el cuello deja de ser CPU/browser y empieza a ser anti-bot/throttling de Bucéfalo. La ganancia plana se vuelve riesgo.
  10. Métricas en generated/logs/parallel_runs.jsonl (append). Comparar duration_s y failed/skipped entre runs para detectar regresiones o señal de throttling.

Cuándo subir workers:

  • Read-only (scanner): hasta 5 sin problema.
  • Mutador (manager bulk-draft/publish/delete): empezar en 2, máximo 3-4. Cada acción dispara API validate post-mutación.
  • Si ves [METRICS] failed creciendo o SESIÓN EXPIRADA en bulks que antes pasaban → bajar workers, subir --action-pause.

CLI:

# scanner read-only, agresivo
python scripts/ghl_browser_workflow_anomaly_scanner.py --all --workers 4 --action-pause 1

# bulk-draft conservador
python scripts/ghl_browser_workflow_manager.py --action bulk-draft \
    --batch-file generated/runtime/batch/draft_batch.json --workers 2 --action-pause 3

Verificación: ver [METRICS] start / [METRICS] end en stdout. Run a workers=1 como baseline, luego workers=N, comparar rate y failed.

Resultados medidos (2026-05-28, scanner read-only sobre Bucéfalo):

Workers Items Tiempo s/item Speedup
1 6 312.9s 52.2 baseline
2 6 192.8s 32.1 1.62×
4 12 200.9s 16.7 3.13× (vs baseline extrapolado)

No hubo SessionExpiredError ni fallos en ninguna iteración. Cada browser extra consume ~150-200 MB RAM en headless; con 4 workers son ~800 MB-1 GB adicionales — cómodo en una máquina con 8 GB+.

Las acciones por fila individual reusan el mismo flujo con items = [oneItem] — no hace falta endpoint separado.

Lección 20. Auditoría granular paralela + observabilidad

El manager Playwright registra cada mutación en script_audit siguiendo el patrón gold-standard de sync_missing_opps_to_brand.py:

# antes del intento
change_id = script_audit.record_change(run_id, location_id, "workflow", wf_id,
    field_id="status", field_name="status",
    old_value=current, new_value=target)   # status = "planned"
# tras la mutación + validación API
script_audit.mark_change(change_id, "applied")        # éxito
# o
script_audit.mark_change(change_id, "failed", msg)    # cualquier path de error

Inyectado en _perform_toggle_item, _perform_delete_item, y rename_via_playwright_dom. Thread-safe (SQLite con WAL + busy_timeout=30s; nuevas conexiones por llamada via script_audit.get_conn).

Bootstrap idempotente: tanto manager como scanner llaman script_audit.create_run(...) (que usa INSERT OR IGNORE) al inicio de main(). Esto garantiza que runs lanzados desde CLI (sin pasar por el dashboard) tampoco queden huérfanos.

Log estructurado por-run: script_logger.RunLogger (en raíz) escribe una línea JSONL por evento a generated/logs/script_runs/{run_id}.jsonl. Cada línea: {ts, level, run_id, event, worker_id, location_id, workflow_id, status?, change_id?, error_id?, duration_ms?}. Thread-safe (lock interno) — los N workers paralelos escriben sin race.

Las 4 fuentes de verdad para forensics

Síntoma / pregunta Dónde mirar
¿Qué se intentó/aplicó? script_audit.get_run_summary(run_id) o python scripts/audit_run.py <run_id>
Bulk falló a la mitad generated/logs/script_runs/{run_id}.jsonl (cronología por worker)
Error específico de Playwright generated/logs/errors.jsonl filtrar por error_id o por context.run_id
Performance / speedup generated/logs/parallel_runs.jsonl
Estado visual al fallar generated/browser/screenshots/*_{loc}_{wf}.png
Listado rápido de runs recientes python scripts/audit_run.py --list 20

CLI de inspección — scripts/audit_run.py

python scripts/audit_run.py <run_id>           # resumen + cambios + eventos + errores
python scripts/audit_run.py --list 20          # últimos 20 runs con counts
python scripts/audit_run.py <run_id> --json    # JSON crudo (para jq/pipes)
python scripts/audit_run.py <run_id> --events 50   # más eventos del JSONL

Combina las 4 fuentes en una sola vista. No depende del dashboard ni de FastAPI.

Sobre rollback

script_audit.rollback_run revierte mutaciones de custom fields via PUT. Las mutaciones DOM de workflows (toggle/delete/rename) se registran pero no son auto-revertibles — el undo manual es ejecutar el bulk inverso:

Acción original Reversión
bulk-draft bulk-publish del mismo batch
bulk-publish bulk-draft del mismo batch
bulk-delete recrear (no recuperable desde GHL) — confirmar antes de aplicar
rename rename con el old_value que guardó script_change_log

Eso queda documentado y trazable; ningún cambio se pierde en el éter.