Primer commit
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
# 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](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:
|
||||
- `_any_frame_at_login(page)` en [`scripts/ghl_browser_workflow_manager.py`](../scripts/ghl_browser_workflow_manager.py) — detecta login por URL + DOM (input[type=password] visible, textos del form).
|
||||
- `_looks_like_login_page(page)` en [`scripts/ghl_browser_session_generator.py`](../scripts/ghl_browser_session_generator.py) — versión similar para detectar fin del login.
|
||||
|
||||
### 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:
|
||||
|
||||
```python
|
||||
# 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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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.
|
||||
|
||||
```python
|
||||
# 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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
# 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`](../static/js/app.js).
|
||||
|
||||
Para tareas largas que no son loops, traduce las líneas más relevantes a UI strings:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```python
|
||||
_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:
|
||||
|
||||
```python
|
||||
# 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:
|
||||
|
||||
```python
|
||||
#!/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.
|
||||
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
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.
|
||||
|
||||
```python
|
||||
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.
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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):
|
||||
|
||||
```python
|
||||
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:**
|
||||
```bash
|
||||
# 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`:
|
||||
|
||||
```python
|
||||
# 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`
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user