feat: app script_navegador y dashboard Metabase
App Go para ejecutar scripts de navegación automatizada usando las funciones CDP del registry. Incluye script de creación de dashboard en Metabase para monitoreo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
|||||||
|
"""Crea un dashboard en Metabase para monitorear operations de script_navegador."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||||
|
|
||||||
|
from metabase.client import metabase_auth
|
||||||
|
from metabase import (
|
||||||
|
metabase_list_databases,
|
||||||
|
metabase_create_card,
|
||||||
|
metabase_create_dashboard,
|
||||||
|
metabase_update_dashboard,
|
||||||
|
metabase_list_dashboards,
|
||||||
|
)
|
||||||
|
from metabase.databases import metabase_add_database
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
METABASE_URL = "http://localhost:3000"
|
||||||
|
EMAIL = "admin@fnregistry.local"
|
||||||
|
PASSWORD = "FnRegistry2024!"
|
||||||
|
|
||||||
|
# Path de operations.db dentro del contenedor Docker
|
||||||
|
# Copiar con: docker exec metabase mkdir -p /data/ops-script-navegador && docker cp apps/script_navegador/operations.db metabase:/data/ops-script-navegador/operations.db
|
||||||
|
OPS_DB_PATH = "/data/ops-script-navegador/operations.db"
|
||||||
|
DB_NAME = "ops-script-navegador"
|
||||||
|
|
||||||
|
CARDS = [
|
||||||
|
# ---- Fila 0: KPIs (h=5) ----
|
||||||
|
{
|
||||||
|
"name": "Total Ejecuciones",
|
||||||
|
"display": "scalar",
|
||||||
|
"sql": "SELECT COUNT(*) AS total FROM executions;",
|
||||||
|
"size_x": 6, "size_y": 5, "col": 0, "row": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ejecuciones Exitosas",
|
||||||
|
"display": "scalar",
|
||||||
|
"sql": "SELECT COUNT(*) AS exitosas FROM executions WHERE status = 'success';",
|
||||||
|
"size_x": 6, "size_y": 5, "col": 6, "row": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ejecuciones Fallidas",
|
||||||
|
"display": "scalar",
|
||||||
|
"sql": "SELECT COUNT(*) AS fallidas FROM executions WHERE status = 'failure';",
|
||||||
|
"size_x": 6, "size_y": 5, "col": 12, "row": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Duracion Promedio (ms)",
|
||||||
|
"display": "scalar",
|
||||||
|
"sql": "SELECT ROUND(AVG(duration_ms)) AS avg_ms FROM executions WHERE status = 'success';",
|
||||||
|
"size_x": 6, "size_y": 5, "col": 18, "row": 0,
|
||||||
|
},
|
||||||
|
# ---- Fila 5: Tendencias (h=8) ----
|
||||||
|
{
|
||||||
|
"name": "Ejecuciones por Estado",
|
||||||
|
"display": "pie",
|
||||||
|
"sql": "SELECT status, COUNT(*) AS cantidad FROM executions GROUP BY status;",
|
||||||
|
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Duracion por Ejecucion (timeline)",
|
||||||
|
"display": "line",
|
||||||
|
"sql": """
|
||||||
|
SELECT
|
||||||
|
started_at,
|
||||||
|
duration_ms,
|
||||||
|
status
|
||||||
|
FROM executions
|
||||||
|
ORDER BY started_at;
|
||||||
|
""",
|
||||||
|
"size_x": 16, "size_y": 8, "col": 8, "row": 5,
|
||||||
|
},
|
||||||
|
# ---- Fila 13: Detalle de pasos (h=9) ----
|
||||||
|
{
|
||||||
|
"name": "Pasos por Script (metricas)",
|
||||||
|
"display": "table",
|
||||||
|
"sql": """
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
records_in AS pasos_total,
|
||||||
|
records_out AS pasos_exitosos,
|
||||||
|
duration_ms,
|
||||||
|
CASE WHEN error = '' THEN '-' ELSE error END AS error,
|
||||||
|
json_extract(metrics, '$.script_name') AS script,
|
||||||
|
started_at
|
||||||
|
FROM executions
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
""",
|
||||||
|
"size_x": 24, "size_y": 9, "col": 0, "row": 13,
|
||||||
|
},
|
||||||
|
# ---- Fila 22: Logs (h=9) ----
|
||||||
|
{
|
||||||
|
"name": "Logs Recientes",
|
||||||
|
"display": "table",
|
||||||
|
"sql": """
|
||||||
|
SELECT
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
message,
|
||||||
|
json_extract(metadata, '$.action') AS action,
|
||||||
|
json_extract(metadata, '$.elapsed_ms') AS elapsed_ms,
|
||||||
|
created_at
|
||||||
|
FROM logs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50;
|
||||||
|
""",
|
||||||
|
"size_x": 24, "size_y": 9, "col": 0, "row": 22,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DASHBOARD_NAME = "script_navegador Operations"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Autenticando en Metabase...")
|
||||||
|
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
|
||||||
|
|
||||||
|
# Buscar si ya existe la database
|
||||||
|
dbs = metabase_list_databases(client)
|
||||||
|
ops_db_id = None
|
||||||
|
for db in dbs:
|
||||||
|
if db.get("name") == DB_NAME:
|
||||||
|
ops_db_id = db["id"]
|
||||||
|
print(f" Database ya existe: {DB_NAME} (id={ops_db_id})")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not ops_db_id:
|
||||||
|
print(f"Registrando {DB_NAME} como datasource SQLite ({OPS_DB_PATH})...")
|
||||||
|
new_db = metabase_add_database(
|
||||||
|
client=client,
|
||||||
|
name=DB_NAME,
|
||||||
|
engine="sqlite",
|
||||||
|
details={"db": OPS_DB_PATH},
|
||||||
|
)
|
||||||
|
ops_db_id = new_db["id"]
|
||||||
|
print(f" Database registrada: id={ops_db_id}")
|
||||||
|
|
||||||
|
# Eliminar dashboard existente si lo hay
|
||||||
|
existing = metabase_list_dashboards(client)
|
||||||
|
for d in existing:
|
||||||
|
if d.get("name") == DASHBOARD_NAME:
|
||||||
|
print(f" Dashboard ya existe (id={d['id']}), recreando...")
|
||||||
|
from metabase import metabase_delete_dashboard
|
||||||
|
metabase_delete_dashboard(client, d["id"])
|
||||||
|
|
||||||
|
# Crear cards
|
||||||
|
print("Creando cards...")
|
||||||
|
created_cards = []
|
||||||
|
for i, card_def in enumerate(CARDS):
|
||||||
|
card = metabase_create_card(
|
||||||
|
client,
|
||||||
|
name=card_def["name"],
|
||||||
|
dataset_query={
|
||||||
|
"database": ops_db_id,
|
||||||
|
"type": "native",
|
||||||
|
"native": {"query": card_def["sql"]},
|
||||||
|
},
|
||||||
|
display=card_def["display"],
|
||||||
|
description=f"script_navegador: {card_def['name']}",
|
||||||
|
)
|
||||||
|
created_cards.append((card, card_def))
|
||||||
|
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
|
||||||
|
|
||||||
|
# Crear dashboard
|
||||||
|
print("Creando dashboard...")
|
||||||
|
dashboard = metabase_create_dashboard(
|
||||||
|
client,
|
||||||
|
name=DASHBOARD_NAME,
|
||||||
|
description="Monitoreo de ejecuciones de script_navegador: KPIs, tendencias, detalle de pasos y logs.",
|
||||||
|
)
|
||||||
|
dash_id = dashboard["id"]
|
||||||
|
print(f" Dashboard creado: id={dash_id}")
|
||||||
|
|
||||||
|
# Agregar cards al dashboard
|
||||||
|
dashcards = []
|
||||||
|
for idx, (card, card_def) in enumerate(created_cards):
|
||||||
|
dashcards.append({
|
||||||
|
"id": -(idx + 1),
|
||||||
|
"card_id": card["id"],
|
||||||
|
"size_x": card_def["size_x"],
|
||||||
|
"size_y": card_def["size_y"],
|
||||||
|
"col": card_def["col"],
|
||||||
|
"row": card_def["row"],
|
||||||
|
})
|
||||||
|
|
||||||
|
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
|
||||||
|
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
operations.db
|
||||||
|
operations.db-wal
|
||||||
|
operations.db-shm
|
||||||
|
build/
|
||||||
|
*.exe
|
||||||
|
script_navegador
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
name: script_navegador
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
description: "Ejecutor de scripts de navegador CDP sobre Chrome. Lee pasos desde YAML y los ejecuta en secuencia registrando cada resultado en operations.db."
|
||||||
|
tags: [cdp, chrome, browser, automation, yaml]
|
||||||
|
uses_functions:
|
||||||
|
- chrome_launch_go_infra
|
||||||
|
- cdp_connect_go_infra
|
||||||
|
- cdp_navigate_go_infra
|
||||||
|
- cdp_click_go_infra
|
||||||
|
- cdp_type_text_go_infra
|
||||||
|
- cdp_wait_element_go_infra
|
||||||
|
- cdp_evaluate_go_infra
|
||||||
|
- cdp_get_html_go_infra
|
||||||
|
- cdp_screenshot_go_infra
|
||||||
|
- cdp_close_go_infra
|
||||||
|
uses_types: []
|
||||||
|
framework: ""
|
||||||
|
entry_point: "main.go"
|
||||||
|
dir_path: "apps/script_navegador"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Descripcion
|
||||||
|
|
||||||
|
CLI Go que lee un archivo YAML con pasos de navegacion CDP y los ejecuta sobre Chrome, registrando cada paso y su resultado en `operations.db`.
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Conectarse a Chrome ya corriendo en puerto 9222
|
||||||
|
go run . --script examples/busqueda_google.yaml
|
||||||
|
|
||||||
|
# Lanzar Chrome nuevo (headless)
|
||||||
|
go run . --script examples/busqueda_google.yaml --launch --headless
|
||||||
|
|
||||||
|
# Puerto personalizado
|
||||||
|
go run . --script examples/busqueda_google.yaml --port 9333
|
||||||
|
```
|
||||||
|
|
||||||
|
## Formato del script YAML
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: "nombre_del_script"
|
||||||
|
steps:
|
||||||
|
- action: navigate
|
||||||
|
url: "https://ejemplo.com"
|
||||||
|
|
||||||
|
- action: wait
|
||||||
|
selector: "#elemento"
|
||||||
|
timeout_ms: 5000 # opcional, default 10000
|
||||||
|
|
||||||
|
- action: click
|
||||||
|
selector: "#boton"
|
||||||
|
continue_on_error: true # opcional, default false
|
||||||
|
|
||||||
|
- action: type
|
||||||
|
selector: "input[name=q]" # hace click primero para enfocar
|
||||||
|
text: "texto a escribir"
|
||||||
|
|
||||||
|
- action: screenshot
|
||||||
|
path: "/tmp/captura.png"
|
||||||
|
full_page: false # opcional, default false
|
||||||
|
|
||||||
|
- action: evaluate
|
||||||
|
expr: "document.title"
|
||||||
|
|
||||||
|
- action: get_html
|
||||||
|
# sin parametros adicionales
|
||||||
|
|
||||||
|
- action: sleep
|
||||||
|
ms: 500 # pausa en milisegundos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acciones soportadas
|
||||||
|
|
||||||
|
| Accion | Parametros obligatorios | Parametros opcionales |
|
||||||
|
|-------------|-------------------------|-------------------------------|
|
||||||
|
| `navigate` | `url` | |
|
||||||
|
| `wait` | `selector` | `timeout_ms` (default 10000) |
|
||||||
|
| `click` | `selector` | `continue_on_error` |
|
||||||
|
| `type` | `selector`, `text` | `continue_on_error` |
|
||||||
|
| `screenshot`| `path` | `full_page`, `continue_on_error` |
|
||||||
|
| `evaluate` | `expr` | `continue_on_error` |
|
||||||
|
| `get_html` | — | `continue_on_error` |
|
||||||
|
| `sleep` | `ms` | |
|
||||||
|
|
||||||
|
## Registro en operations.db
|
||||||
|
|
||||||
|
- **Entity `script_run`**: una por ejecucion del script, con metadata del script y resultado final
|
||||||
|
- **Execution**: una por ejecucion, con `pipeline_id = "script_navegador"`, duration_ms, records_in=pasos totales, records_out=pasos exitosos
|
||||||
|
- **Logs**: un log por cada paso ejecutado con nivel info/error
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Si Chrome no esta corriendo y no se pasa `--launch`, la conexion falla con error claro
|
||||||
|
- `continue_on_error: true` por paso permite continuar aunque ese paso falle
|
||||||
|
- Flag global `--abort-on-error` (default false) aborta todo el script al primer error
|
||||||
|
- Al terminar (exito o error), siempre se ejecuta `cdp_close` para limpiar recursos
|
||||||
|
- operations.db se inicializa automaticamente si no existe usando `fn ops init`
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: "busqueda_google"
|
||||||
|
steps:
|
||||||
|
- action: navigate
|
||||||
|
url: "https://www.google.com"
|
||||||
|
|
||||||
|
- action: wait
|
||||||
|
selector: "textarea[name=q]"
|
||||||
|
timeout_ms: 8000
|
||||||
|
|
||||||
|
- action: type
|
||||||
|
selector: "textarea[name=q]"
|
||||||
|
text: "golang cdp automation"
|
||||||
|
|
||||||
|
- action: screenshot
|
||||||
|
path: "/tmp/busqueda_antes.png"
|
||||||
|
|
||||||
|
- action: evaluate
|
||||||
|
expr: "document.title"
|
||||||
|
|
||||||
|
- action: sleep
|
||||||
|
ms: 500
|
||||||
|
|
||||||
|
- action: evaluate
|
||||||
|
expr: "document.querySelector('textarea[name=q]').value"
|
||||||
|
|
||||||
|
- action: screenshot
|
||||||
|
path: "/tmp/busqueda_despues.png"
|
||||||
|
full_page: false
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: "demo_continue_on_error"
|
||||||
|
steps:
|
||||||
|
- action: navigate
|
||||||
|
url: "https://example.com"
|
||||||
|
|
||||||
|
- action: wait
|
||||||
|
selector: "h1"
|
||||||
|
timeout_ms: 5000
|
||||||
|
|
||||||
|
# Este paso fallara porque el selector no existe, pero el script continua
|
||||||
|
- action: click
|
||||||
|
selector: "#boton-que-no-existe"
|
||||||
|
continue_on_error: true
|
||||||
|
|
||||||
|
# Este paso se ejecuta aunque el anterior fallo
|
||||||
|
- action: evaluate
|
||||||
|
expr: "document.title"
|
||||||
|
|
||||||
|
- action: screenshot
|
||||||
|
path: "/tmp/continue_on_error.png"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: "scrape_titulo"
|
||||||
|
steps:
|
||||||
|
- action: navigate
|
||||||
|
url: "https://example.com"
|
||||||
|
|
||||||
|
- action: wait
|
||||||
|
selector: "h1"
|
||||||
|
timeout_ms: 5000
|
||||||
|
|
||||||
|
- action: evaluate
|
||||||
|
expr: "document.querySelector('h1').textContent"
|
||||||
|
|
||||||
|
- action: get_html
|
||||||
|
|
||||||
|
- action: screenshot
|
||||||
|
path: "/tmp/example_com.png"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
name: "navegar_youtube"
|
||||||
|
steps:
|
||||||
|
- action: navigate
|
||||||
|
url: "https://www.youtube.com"
|
||||||
|
- action: screenshot
|
||||||
|
path: "/tmp/youtube.png"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module script-navegador
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
fn-registry v0.0.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||||
|
|
||||||
|
replace fn-registry => /home/lucas/fn_registry
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateID genera un UUID v4 simple sin dependencias externas.
|
||||||
|
func generateID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// Fallback con timestamp si rand falla (muy improbable)
|
||||||
|
return fmt.Sprintf("fallback-%x", b)
|
||||||
|
}
|
||||||
|
// Ajustar bits para UUID v4
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80
|
||||||
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||||
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Flags
|
||||||
|
scriptPath := flag.String("script", "", "Ruta al archivo YAML con el script de navegacion (obligatorio)")
|
||||||
|
port := flag.Int("port", 9222, "Puerto CDP de Chrome")
|
||||||
|
launch := flag.Bool("launch", false, "Lanzar Chrome nuevo en vez de conectarse a uno existente")
|
||||||
|
headless := flag.Bool("headless", false, "Lanzar Chrome en modo headless (requiere --launch)")
|
||||||
|
chromePath := flag.String("chrome-path", "", "Ruta al ejecutable de Chrome (ej: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe')")
|
||||||
|
userDataDir := flag.String("user-data-dir", "", "Directorio de perfil de Chrome (path WSL, se convierte a Windows automaticamente)")
|
||||||
|
keepOpen := flag.Bool("keep-open", false, "No cerrar Chrome al terminar")
|
||||||
|
abortOnError := flag.Bool("abort-on-error", false, "Abortar el script al primer error en cualquier paso")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *scriptPath == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: --script es obligatorio")
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := run(*scriptPath, *port, *launch, *headless, *abortOnError, *userDataDir, *keepOpen, *chromePath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(scriptPath string, port int, launch, headless, abortOnError bool, userDataDir string, keepOpen bool, chromePath string) error {
|
||||||
|
// 1. Cargar y validar el script YAML
|
||||||
|
script, err := LoadScript(scriptPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cargar script: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("[script_navegador] script: %q (%d pasos)\n", script.Name, len(script.Steps))
|
||||||
|
|
||||||
|
// 2. Inicializar operations.db
|
||||||
|
appDir, err := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||||
|
if err != nil {
|
||||||
|
// Fallback al directorio de trabajo
|
||||||
|
appDir, _ = os.Getwd()
|
||||||
|
}
|
||||||
|
// Si estamos corriendo con `go run .`, os.Args[0] es un tmp, usar cwd
|
||||||
|
if cwd, e := os.Getwd(); e == nil {
|
||||||
|
if _, e2 := os.Stat(filepath.Join(cwd, "app.md")); e2 == nil {
|
||||||
|
appDir = cwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := initOpsDB(appDir)
|
||||||
|
if err != nil {
|
||||||
|
// No es fatal: seguir sin operations.db, solo logear
|
||||||
|
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo inicializar operations.db: %v\n", err)
|
||||||
|
}
|
||||||
|
if db != nil {
|
||||||
|
defer db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lanzar Chrome o conectarse al existente
|
||||||
|
var pid int
|
||||||
|
if launch {
|
||||||
|
// Convertir path WSL a Windows para chrome.exe
|
||||||
|
// Si empieza con / es un path Linux (WSL), convertir. Si empieza con letra:\ ya es Windows.
|
||||||
|
winDataDir := userDataDir
|
||||||
|
if winDataDir != "" && strings.HasPrefix(winDataDir, "/") {
|
||||||
|
out, err := exec.Command("wslpath", "-w", winDataDir).Output()
|
||||||
|
if err == nil {
|
||||||
|
winDataDir = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("[chrome] lanzando Chrome en puerto %d (headless=%v, user-data-dir=%q)...\n", port, headless, winDataDir)
|
||||||
|
pid, err = infra.ChromeLaunch(infra.ChromeLaunchOpts{
|
||||||
|
Port: port,
|
||||||
|
Headless: headless,
|
||||||
|
UserDataDir: winDataDir,
|
||||||
|
ChromePath: chromePath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lanzar Chrome: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("[chrome] Chrome lanzado (pid=%d)\n", pid)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[chrome] conectando a Chrome en localhost:%d...\n", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Conectar CDP (con mirrored networking, localhost es compartido WSL<->Windows)
|
||||||
|
fmt.Printf("[cdp] conectando a localhost:%d...\n", port)
|
||||||
|
conn, err := infra.CdpConnect(port)
|
||||||
|
if err != nil {
|
||||||
|
// Si lanzamos Chrome, matar el proceso antes de salir
|
||||||
|
if pid > 0 {
|
||||||
|
_ = infra.CdpClose(nil, pid)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("conectar CDP en localhost:%d: %w", port, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("[cdp] conexion establecida\n")
|
||||||
|
|
||||||
|
// Asegurar cierre al salir (respetar --keep-open)
|
||||||
|
defer func() {
|
||||||
|
if keepOpen {
|
||||||
|
fmt.Printf("[cdp] cerrando conexion CDP (Chrome sigue abierto, pid=%d, puerto=%d)\n", pid, port)
|
||||||
|
// Solo cerrar la conexion WebSocket, no matar Chrome
|
||||||
|
if err := infra.CdpClose(conn, 0); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar conexion: %v\n", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[cdp] cerrando conexion y limpiando recursos...\n")
|
||||||
|
if err := infra.CdpClose(conn, pid); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 5. Registrar entities y relations en operations.db
|
||||||
|
var relationID string
|
||||||
|
if db != nil {
|
||||||
|
_, _, _, err := EnsureEntities(db, port, chromePath, userDataDir, script.Name, scriptPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear entities: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ops] entities registradas\n")
|
||||||
|
}
|
||||||
|
relationID, err = EnsureRelations(db, "chrome_instance", "cdp_session", fmt.Sprintf("script_%s", script.Name))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear relations: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ops] relations registradas\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Ejecutar el script
|
||||||
|
runner := NewRunner(conn, RunnerOpts{AbortOnError: abortOnError})
|
||||||
|
|
||||||
|
startedAt := time.Now()
|
||||||
|
fmt.Printf("[run] iniciando ejecucion: %s\n", startedAt.Format(time.RFC3339))
|
||||||
|
|
||||||
|
results, runErr := runner.Run(script)
|
||||||
|
endedAt := time.Now()
|
||||||
|
|
||||||
|
// 7. Imprimir resumen de pasos
|
||||||
|
printSummary(script, results, runErr, startedAt, endedAt)
|
||||||
|
|
||||||
|
// 8. Registrar execution y actualizar relation en operations.db
|
||||||
|
if db != nil {
|
||||||
|
execID, err := RecordRun(db, script, relationID, results, runErr, startedAt, endedAt)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar ejecucion: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ops] ejecucion registrada en operations.db (id=%s)\n", execID[:8])
|
||||||
|
}
|
||||||
|
// Registrar cada paso como log
|
||||||
|
for _, r := range results {
|
||||||
|
if logErr := LogStep(db, execID, r); logErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar log step[%d]: %v\n", r.Index, logErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Actualizar relation status
|
||||||
|
if relationID != "" {
|
||||||
|
UpdateRelationAfterRun(db, relationID, runErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runErr != nil {
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSummary imprime un resumen legible de la ejecucion.
|
||||||
|
func printSummary(script *Script, results []StepResult, runErr error, startedAt, endedAt time.Time) {
|
||||||
|
duration := endedAt.Sub(startedAt)
|
||||||
|
success := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Err == nil {
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n--- Resumen: %q ---\n", script.Name)
|
||||||
|
fmt.Printf("Duracion: %v\n", duration.Round(time.Millisecond))
|
||||||
|
fmt.Printf("Pasos: %d/%d exitosos\n", success, len(results))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
status := "ok"
|
||||||
|
detail := ""
|
||||||
|
if r.Err != nil {
|
||||||
|
status = "ERROR"
|
||||||
|
detail = fmt.Sprintf(" -> %v", r.Err)
|
||||||
|
} else if r.Output != "" {
|
||||||
|
detail = fmt.Sprintf(" -> %q", r.Output)
|
||||||
|
}
|
||||||
|
fmt.Printf(" [%d] %-12s %s (%dms)%s\n",
|
||||||
|
r.Index, r.Action, status, r.Elapsed.Milliseconds(), detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runErr != nil {
|
||||||
|
fmt.Printf("\nAbortado: %v\n", runErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\nScript completado.\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fn_operations "fn-registry/fn_operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
const opsDBName = "operations.db"
|
||||||
|
|
||||||
|
// initOpsDB inicializa o abre operations.db en el directorio de la app.
|
||||||
|
func initOpsDB(appDir string) (*fn_operations.DB, error) {
|
||||||
|
dbPath := filepath.Join(appDir, opsDBName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
if err := bootstrapOpsDB(appDir, dbPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("inicializar operations.db: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := fn_operations.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("abrir operations.db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrapOpsDB intenta crear operations.db usando el CLI fn o directamente.
|
||||||
|
func bootstrapOpsDB(appDir, dbPath string) error {
|
||||||
|
registryRoot := os.Getenv("FN_REGISTRY_ROOT")
|
||||||
|
if registryRoot == "" {
|
||||||
|
registryRoot = filepath.Join(appDir, "..", "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
fnBin := filepath.Join(registryRoot, "fn")
|
||||||
|
if _, err := os.Stat(fnBin); err == nil {
|
||||||
|
cmd := exec.Command(fnBin, "ops", "init", appDir)
|
||||||
|
cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+registryRoot)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fn ops init: %w\n%s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := fn_operations.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("crear operations.db directamente: %w", err)
|
||||||
|
}
|
||||||
|
return db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Entities ---
|
||||||
|
|
||||||
|
// EnsureEntities crea o actualiza las entities del pipeline de navegacion.
|
||||||
|
// Entities:
|
||||||
|
// - chrome_instance: la instancia de Chrome con CDP
|
||||||
|
// - cdp_session: la sesion CDP activa
|
||||||
|
// - script_file: el archivo YAML del script
|
||||||
|
func EnsureEntities(db *fn_operations.DB, port int, chromePath, userDataDir, scriptName, scriptPath string) (chromeID, cdpID, scriptID string, err error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
chromeID = "chrome_instance"
|
||||||
|
cdpID = "cdp_session"
|
||||||
|
scriptID = fmt.Sprintf("script_%s", scriptName)
|
||||||
|
|
||||||
|
// Chrome instance
|
||||||
|
existing, _ := db.GetEntity(chromeID)
|
||||||
|
if existing == nil {
|
||||||
|
err = db.InsertEntity(&fn_operations.Entity{
|
||||||
|
ID: chromeID,
|
||||||
|
Name: "Chrome Windows",
|
||||||
|
TypeRef: "chrome_instance",
|
||||||
|
Status: fn_operations.StatusActive,
|
||||||
|
Description: "Instancia de Chrome con remote debugging habilitado",
|
||||||
|
Domain: "infra",
|
||||||
|
Tags: []string{"chrome", "cdp", "windows"},
|
||||||
|
Source: "script_navegador",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"port": port,
|
||||||
|
"chrome_path": chromePath,
|
||||||
|
"user_data_dir": userDataDir,
|
||||||
|
},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("insertar entity chrome_instance: %w", err)
|
||||||
|
}
|
||||||
|
} else if existing.Status != fn_operations.StatusActive {
|
||||||
|
existing.Status = fn_operations.StatusActive
|
||||||
|
existing.UpdatedAt = now
|
||||||
|
db.UpdateEntity(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDP session
|
||||||
|
existing, _ = db.GetEntity(cdpID)
|
||||||
|
if existing == nil {
|
||||||
|
err = db.InsertEntity(&fn_operations.Entity{
|
||||||
|
ID: cdpID,
|
||||||
|
Name: "CDP Session",
|
||||||
|
TypeRef: "cdp_session",
|
||||||
|
Status: fn_operations.StatusActive,
|
||||||
|
Description: "Sesion CDP WebSocket activa contra Chrome",
|
||||||
|
Domain: "infra",
|
||||||
|
Tags: []string{"cdp", "websocket"},
|
||||||
|
Source: "script_navegador",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"port": port,
|
||||||
|
"protocol": "CDP 1.3",
|
||||||
|
},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("insertar entity cdp_session: %w", err)
|
||||||
|
}
|
||||||
|
} else if existing.Status != fn_operations.StatusActive {
|
||||||
|
existing.Status = fn_operations.StatusActive
|
||||||
|
existing.UpdatedAt = now
|
||||||
|
db.UpdateEntity(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script
|
||||||
|
existing, _ = db.GetEntity(scriptID)
|
||||||
|
if existing == nil {
|
||||||
|
err = db.InsertEntity(&fn_operations.Entity{
|
||||||
|
ID: scriptID,
|
||||||
|
Name: scriptName,
|
||||||
|
TypeRef: "nav_script",
|
||||||
|
Status: fn_operations.StatusActive,
|
||||||
|
Description: fmt.Sprintf("Script de navegacion: %s", scriptName),
|
||||||
|
Domain: "automation",
|
||||||
|
Tags: []string{"script", "yaml", "navegacion"},
|
||||||
|
Source: scriptPath,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"script_name": scriptName,
|
||||||
|
"file_path": scriptPath,
|
||||||
|
},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("insertar entity script: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chromeID, cdpID, scriptID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Relations ---
|
||||||
|
|
||||||
|
// EnsureRelations crea las relaciones entre entities si no existen.
|
||||||
|
// Relations:
|
||||||
|
// - chrome_to_cdp: Chrome -> CDP Session (via chrome_launch + cdp_connect)
|
||||||
|
// - cdp_to_script: CDP Session -> Script (via runner)
|
||||||
|
func EnsureRelations(db *fn_operations.DB, chromeID, cdpID, scriptID string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// chrome -> cdp
|
||||||
|
chromeToCDP := "chrome_to_cdp"
|
||||||
|
existing, _ := db.GetRelation(chromeToCDP)
|
||||||
|
if existing == nil {
|
||||||
|
err := db.InsertRelation(&fn_operations.Relation{
|
||||||
|
ID: chromeToCDP,
|
||||||
|
Name: "chrome_to_cdp",
|
||||||
|
FromEntity: chromeID,
|
||||||
|
ToEntity: cdpID,
|
||||||
|
Via: "cdp_connect_go_infra",
|
||||||
|
Description: "Chrome expone CDP, la app se conecta via WebSocket",
|
||||||
|
Purity: "impure",
|
||||||
|
Direction: fn_operations.DirUnidirectional,
|
||||||
|
Status: fn_operations.RelImplemented,
|
||||||
|
Tags: []string{"cdp", "websocket"},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("insertar relation chrome_to_cdp: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cdp -> script execution
|
||||||
|
cdpToScript := fmt.Sprintf("cdp_to_%s", scriptID)
|
||||||
|
existing, _ = db.GetRelation(cdpToScript)
|
||||||
|
if existing == nil {
|
||||||
|
startedAt := now
|
||||||
|
err := db.InsertRelation(&fn_operations.Relation{
|
||||||
|
ID: cdpToScript,
|
||||||
|
Name: cdpToScript,
|
||||||
|
FromEntity: cdpID,
|
||||||
|
ToEntity: scriptID,
|
||||||
|
Via: "script_navegador_runner",
|
||||||
|
Description: fmt.Sprintf("CDP ejecuta pasos del script %s", scriptID),
|
||||||
|
Purity: "impure",
|
||||||
|
Direction: fn_operations.DirUnidirectional,
|
||||||
|
Status: fn_operations.RelRunning,
|
||||||
|
StartedAt: &startedAt,
|
||||||
|
Tags: []string{"automation", "pipeline"},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("insertar relation cdp_to_script: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
existing.Status = fn_operations.RelRunning
|
||||||
|
existing.UpdatedAt = now
|
||||||
|
db.UpdateRelation(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cdpToScript, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRelationAfterRun actualiza el status de la relation segun el resultado.
|
||||||
|
func UpdateRelationAfterRun(db *fn_operations.DB, relationID string, runErr error) {
|
||||||
|
rel, err := db.GetRelation(relationID)
|
||||||
|
if err != nil || rel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
rel.Status = fn_operations.RelImplemented
|
||||||
|
} else {
|
||||||
|
rel.Status = fn_operations.RelTested
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
rel.EndedAt = &now
|
||||||
|
rel.UpdatedAt = now
|
||||||
|
db.UpdateRelation(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Executions ---
|
||||||
|
|
||||||
|
// RecordRun registra una ejecucion completa del script en operations.db.
|
||||||
|
func RecordRun(db *fn_operations.DB, script *Script, relationID string, results []StepResult, runErr error, startedAt, endedAt time.Time) (string, error) {
|
||||||
|
totalSteps := int64(len(results))
|
||||||
|
successSteps := int64(0)
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Err == nil {
|
||||||
|
successSteps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := fn_operations.ExecSuccess
|
||||||
|
errMsg := ""
|
||||||
|
if runErr != nil {
|
||||||
|
status = fn_operations.ExecFailure
|
||||||
|
errMsg = runErr.Error()
|
||||||
|
} else if successSteps < totalSteps {
|
||||||
|
status = fn_operations.ExecPartial
|
||||||
|
}
|
||||||
|
|
||||||
|
durationMs := endedAt.Sub(startedAt).Milliseconds()
|
||||||
|
|
||||||
|
stepSummary := make([]map[string]any, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
entry := map[string]any{
|
||||||
|
"index": r.Index,
|
||||||
|
"action": r.Action,
|
||||||
|
"elapsed_ms": r.Elapsed.Milliseconds(),
|
||||||
|
"ok": r.Err == nil,
|
||||||
|
}
|
||||||
|
if r.Output != "" {
|
||||||
|
entry["output"] = r.Output
|
||||||
|
}
|
||||||
|
if r.Err != nil {
|
||||||
|
entry["error"] = r.Err.Error()
|
||||||
|
}
|
||||||
|
stepSummary = append(stepSummary, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
execID := generateID()
|
||||||
|
execution := &fn_operations.Execution{
|
||||||
|
ID: execID,
|
||||||
|
PipelineID: "script_navegador",
|
||||||
|
RelationID: relationID,
|
||||||
|
Status: status,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
EndedAt: &endedAt,
|
||||||
|
DurationMs: &durationMs,
|
||||||
|
RecordsIn: &totalSteps,
|
||||||
|
RecordsOut: &successSteps,
|
||||||
|
Error: errMsg,
|
||||||
|
Metrics: map[string]any{
|
||||||
|
"script_name": script.Name,
|
||||||
|
"total_steps": totalSteps,
|
||||||
|
"success_steps": successSteps,
|
||||||
|
"steps": stepSummary,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.InsertExecution(execution); err != nil {
|
||||||
|
return "", fmt.Errorf("insertar execution: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return execID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logs ---
|
||||||
|
|
||||||
|
// LogStep registra un paso individual como log en operations.db.
|
||||||
|
func LogStep(db *fn_operations.DB, execID string, res StepResult) error {
|
||||||
|
level := fn_operations.LogInfo
|
||||||
|
msg := fmt.Sprintf("step[%d] %s: ok", res.Index, res.Action)
|
||||||
|
if res.Err != nil {
|
||||||
|
level = fn_operations.LogError
|
||||||
|
msg = fmt.Sprintf("step[%d] %s: %v", res.Index, res.Action, res.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := map[string]any{
|
||||||
|
"action": res.Action,
|
||||||
|
"elapsed_ms": res.Elapsed.Milliseconds(),
|
||||||
|
}
|
||||||
|
if res.Output != "" {
|
||||||
|
meta["output"] = res.Output
|
||||||
|
}
|
||||||
|
|
||||||
|
log := &fn_operations.Log{
|
||||||
|
ID: generateID(),
|
||||||
|
Level: level,
|
||||||
|
Source: "script_navegador",
|
||||||
|
ExecutionID: execID,
|
||||||
|
Message: msg,
|
||||||
|
Metadata: meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.InsertLog(log)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StepResult es el resultado de ejecutar un paso.
|
||||||
|
type StepResult struct {
|
||||||
|
Index int
|
||||||
|
Action string
|
||||||
|
Output string // resultado de evaluate/get_html, path de screenshot, etc.
|
||||||
|
Err error
|
||||||
|
Elapsed time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunnerOpts configura la ejecucion del runner.
|
||||||
|
type RunnerOpts struct {
|
||||||
|
AbortOnError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner ejecuta los pasos de un Script sobre una conexion CDP activa.
|
||||||
|
type Runner struct {
|
||||||
|
conn *infra.CDPConn
|
||||||
|
opts RunnerOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner crea un Runner con la conexion CDP dada.
|
||||||
|
func NewRunner(conn *infra.CDPConn, opts RunnerOpts) *Runner {
|
||||||
|
return &Runner{conn: conn, opts: opts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ejecuta todos los pasos del script y retorna los resultados de cada paso.
|
||||||
|
// Siempre retorna todos los resultados procesados hasta el momento, incluso si aborta.
|
||||||
|
func (r *Runner) Run(script *Script) ([]StepResult, error) {
|
||||||
|
results := make([]StepResult, 0, len(script.Steps))
|
||||||
|
|
||||||
|
for i, step := range script.Steps {
|
||||||
|
start := time.Now()
|
||||||
|
output, err := r.runStep(step)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
res := StepResult{
|
||||||
|
Index: i,
|
||||||
|
Action: step.Action,
|
||||||
|
Output: output,
|
||||||
|
Err: err,
|
||||||
|
Elapsed: elapsed,
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if step.ContinueOnError {
|
||||||
|
// Continuar con el siguiente paso aunque este fallo
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.opts.AbortOnError {
|
||||||
|
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
|
||||||
|
}
|
||||||
|
// Por defecto: abortar si el paso fallo y no tiene continue_on_error
|
||||||
|
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStep ejecuta un paso individual y retorna su output y error.
|
||||||
|
func (r *Runner) runStep(step Step) (string, error) {
|
||||||
|
switch step.Action {
|
||||||
|
case "navigate":
|
||||||
|
if err := infra.CdpNavigate(r.conn, step.URL); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Esperar a que la página cargue completamente
|
||||||
|
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
return "", infra.CdpWaitLoad(r.conn, timeout)
|
||||||
|
|
||||||
|
case "wait_load":
|
||||||
|
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
return "", infra.CdpWaitLoad(r.conn, timeout)
|
||||||
|
|
||||||
|
case "wait":
|
||||||
|
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return "", infra.CdpWaitElement(r.conn, step.Selector, timeout)
|
||||||
|
|
||||||
|
case "click":
|
||||||
|
return "", infra.CdpClick(r.conn, step.Selector)
|
||||||
|
|
||||||
|
case "type":
|
||||||
|
// Hacer click primero para enfocar el elemento
|
||||||
|
if err := infra.CdpClick(r.conn, step.Selector); err != nil {
|
||||||
|
return "", fmt.Errorf("enfocar elemento para type: %w", err)
|
||||||
|
}
|
||||||
|
return "", infra.CdpTypeText(r.conn, step.Text)
|
||||||
|
|
||||||
|
case "screenshot":
|
||||||
|
opts := infra.CdpScreenshotOpts{
|
||||||
|
FullPage: step.FullPage,
|
||||||
|
Format: "png",
|
||||||
|
}
|
||||||
|
if err := infra.CdpScreenshot(r.conn, step.Path, opts); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return step.Path, nil
|
||||||
|
|
||||||
|
case "evaluate":
|
||||||
|
result, err := infra.CdpEvaluate(r.conn, step.Expr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
|
||||||
|
case "get_html":
|
||||||
|
html, err := infra.CdpGetHTML(r.conn)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Truncar para el log (el HTML puede ser muy largo)
|
||||||
|
if len(html) > 200 {
|
||||||
|
return html[:200] + "...", nil
|
||||||
|
}
|
||||||
|
return html, nil
|
||||||
|
|
||||||
|
case "sleep":
|
||||||
|
time.Sleep(time.Duration(step.Ms) * time.Millisecond)
|
||||||
|
return fmt.Sprintf("slept %dms", step.Ms), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("accion desconocida: %q", step.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Script representa un archivo YAML de pasos de navegacion.
|
||||||
|
type Script struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Steps []Step `yaml:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step es un paso individual dentro del script.
|
||||||
|
type Step struct {
|
||||||
|
// Comun a todos los pasos
|
||||||
|
Action string `yaml:"action"`
|
||||||
|
ContinueOnError bool `yaml:"continue_on_error"`
|
||||||
|
|
||||||
|
// navigate
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
|
||||||
|
// wait
|
||||||
|
Selector string `yaml:"selector"`
|
||||||
|
TimeoutMs int `yaml:"timeout_ms"`
|
||||||
|
|
||||||
|
// type
|
||||||
|
Text string `yaml:"text"`
|
||||||
|
|
||||||
|
// screenshot
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
FullPage bool `yaml:"full_page"`
|
||||||
|
|
||||||
|
// evaluate
|
||||||
|
Expr string `yaml:"expr"`
|
||||||
|
|
||||||
|
// sleep
|
||||||
|
Ms int `yaml:"ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate comprueba que el script tiene los campos minimos correctos.
|
||||||
|
func (s *Script) Validate() error {
|
||||||
|
if s.Name == "" {
|
||||||
|
return fmt.Errorf("script: campo 'name' obligatorio")
|
||||||
|
}
|
||||||
|
if len(s.Steps) == 0 {
|
||||||
|
return fmt.Errorf("script %q: sin pasos definidos", s.Name)
|
||||||
|
}
|
||||||
|
for i, step := range s.Steps {
|
||||||
|
if err := step.Validate(i); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate comprueba que el paso tiene los campos requeridos segun su action.
|
||||||
|
func (s *Step) Validate(idx int) error {
|
||||||
|
prefix := fmt.Sprintf("step[%d] action=%q", idx, s.Action)
|
||||||
|
switch s.Action {
|
||||||
|
case "navigate":
|
||||||
|
if s.URL == "" {
|
||||||
|
return fmt.Errorf("%s: campo 'url' obligatorio", prefix)
|
||||||
|
}
|
||||||
|
case "wait":
|
||||||
|
if s.Selector == "" {
|
||||||
|
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||||
|
}
|
||||||
|
case "click":
|
||||||
|
if s.Selector == "" {
|
||||||
|
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||||
|
}
|
||||||
|
case "type":
|
||||||
|
if s.Selector == "" {
|
||||||
|
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||||
|
}
|
||||||
|
if s.Text == "" {
|
||||||
|
return fmt.Errorf("%s: campo 'text' obligatorio", prefix)
|
||||||
|
}
|
||||||
|
case "screenshot":
|
||||||
|
if s.Path == "" {
|
||||||
|
return fmt.Errorf("%s: campo 'path' obligatorio", prefix)
|
||||||
|
}
|
||||||
|
case "evaluate":
|
||||||
|
if s.Expr == "" {
|
||||||
|
return fmt.Errorf("%s: campo 'expr' obligatorio", prefix)
|
||||||
|
}
|
||||||
|
case "get_html":
|
||||||
|
// sin parametros requeridos
|
||||||
|
case "wait_load":
|
||||||
|
// sin parametros requeridos (timeout_ms opcional)
|
||||||
|
case "sleep":
|
||||||
|
if s.Ms <= 0 {
|
||||||
|
return fmt.Errorf("%s: campo 'ms' debe ser mayor que 0", prefix)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s: accion desconocida (navigate|wait|wait_load|click|type|screenshot|evaluate|get_html|sleep)", prefix)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadScript lee y parsea un archivo YAML de script de navegador.
|
||||||
|
func LoadScript(path string) (*Script, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("leer script %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var s Script
|
||||||
|
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsear script %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getWindowsHostIP obtiene la IP del host Windows desde WSL2.
|
||||||
|
// Lee /etc/resolv.conf que WSL2 configura con la IP del host.
|
||||||
|
func getWindowsHostIP() string {
|
||||||
|
data, err := os.ReadFile("/etc/resolv.conf")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "nameserver ") {
|
||||||
|
ip := strings.TrimPrefix(line, "nameserver ")
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWindowsGatewayIP obtiene la IP del gateway (host Windows) desde la tabla de rutas.
|
||||||
|
func getWindowsGatewayIP() string {
|
||||||
|
data, err := os.ReadFile("/proc/net/route")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 3 && fields[1] == "00000000" { // default route
|
||||||
|
hexIP := fields[2]
|
||||||
|
if len(hexIP) == 8 {
|
||||||
|
// /proc/net/route stores IPs as little-endian 32-bit hex
|
||||||
|
// "011017AC" -> bytes [01,10,17,AC] -> IP 172.23.16.1 (reversed)
|
||||||
|
var a, b, c, d uint8
|
||||||
|
fmt.Sscanf(hexIP[0:2], "%02x", &a)
|
||||||
|
fmt.Sscanf(hexIP[2:4], "%02x", &b)
|
||||||
|
fmt.Sscanf(hexIP[4:6], "%02x", &c)
|
||||||
|
fmt.Sscanf(hexIP[6:8], "%02x", &d)
|
||||||
|
return fmt.Sprintf("%d.%d.%d.%d", d, c, b, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForCDP espera a que el puerto CDP esté accesible desde WSL.
|
||||||
|
func waitForCDP(host string, port int, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("CDP %s no disponible despues de %s", addr, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCDPProxy levanta un proxy TCP local que reenvía conexiones al host Windows.
|
||||||
|
// Chrome CDP solo acepta conexiones desde localhost, así que el proxy en WSL
|
||||||
|
// conecta al host Windows vía portproxy/netsh y expone el puerto localmente.
|
||||||
|
// Retorna el puerto local del proxy y una función para cerrarlo.
|
||||||
|
func startCDPProxy(windowsHost string, remotePort, localPort int) (net.Listener, error) {
|
||||||
|
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("proxy listen: %w", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
client, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return // listener cerrado
|
||||||
|
}
|
||||||
|
go proxyConn(client, windowsHost, remotePort)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ln, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyConn(client net.Conn, host string, port int) {
|
||||||
|
defer client.Close()
|
||||||
|
remote, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer remote.Close()
|
||||||
|
go io.Copy(remote, client)
|
||||||
|
io.Copy(client, remote)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user