chore: auto-commit (286 archivos)
- .claude/agents/fn-orquestador/SKILL.md - .claude/commands/fn_claude.md - .claude/rules/INDEX.md - .claude/rules/cpp_apps.md - .claude/rules/ids_naming.md - CHANGELOG.md - apps/dag_engine/README.md - apps/dag_engine/api.go - apps/dag_engine/dags_migrated/example.yaml - apps/dag_engine/dags_migrated/example_lineage_tracking.yaml - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_export_to_gcs(client: BQClient, dataset_id: str, table_id: str, destination_uri: str, destination_format: str = 'CSV', compression: str = 'NONE') -> dict"
|
||||
description: "Exporta una tabla BigQuery a Google Cloud Storage usando extract_table del SDK. Soporta CSV, JSON, Avro y Parquet con compresion opcional."
|
||||
tags: [bigquery, gcp, export, gcs, google-cloud, python, etl, pendiente-usar]
|
||||
tags: [bigquery, gcp, export, gcs, google-cloud, python, etl, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_get_table(client: BQClient, dataset_id: str, table_id: str) -> dict"
|
||||
description: "Obtiene los metadatos completos de una tabla BigQuery incluyendo schema, estadisticas y configuracion. Usa client._client.get_table() del SDK oficial."
|
||||
tags: [bigquery, gcp, table, get, google-cloud, python, pendiente-usar]
|
||||
tags: [bigquery, gcp, table, get, google-cloud, python, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_insert_rows(client: BQClient, dataset_id: str, table_id: str, rows: list[dict]) -> dict"
|
||||
description: "Inserta filas en una tabla BigQuery usando streaming insert (insert_rows_json). Retorna el conteo de filas insertadas y errores por fila."
|
||||
tags: [bigquery, gcp, insert, streaming, google-cloud, python, pendiente-usar]
|
||||
tags: [bigquery, gcp, insert, streaming, google-cloud, python, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_list_tables(client: BQClient, dataset_id: str) -> list[dict]"
|
||||
description: "Lista todas las tablas (y vistas) de un dataset BigQuery con informacion resumida. Usa client._client.list_tables() del SDK oficial."
|
||||
tags: [bigquery, gcp, table, list, google-cloud, python, pendiente-usar]
|
||||
tags: [bigquery, gcp, table, list, google-cloud, python, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_load_from_file(client: BQClient, file_path: str, dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
|
||||
description: "Carga datos desde un archivo local a una tabla BigQuery usando load_table_from_file del SDK. Equivalente a bq_load_from_gcs pero para disco local."
|
||||
tags: [bigquery, gcp, load, file, google-cloud, python, etl, pendiente-usar]
|
||||
tags: [bigquery, gcp, load, file, google-cloud, python, etl, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_load_from_gcs(client: BQClient, uri: str | list[str], dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
|
||||
description: "Carga datos desde uno o varios URIs de Google Cloud Storage a una tabla BigQuery configurando un LoadJob. Espera la finalizacion del job."
|
||||
tags: [bigquery, gcp, load, gcs, google-cloud, python, etl, pendiente-usar]
|
||||
tags: [bigquery, gcp, load, gcs, google-cloud, python, etl, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_preview_rows(client: BQClient, dataset_id: str, table_id: str, max_results: int = 10) -> dict"
|
||||
description: "Obtiene una muestra de filas de una tabla BigQuery sin ejecutar query SQL, sin coste de procesamiento. Usa client._client.list_rows() del SDK oficial."
|
||||
tags: [bigquery, gcp, table, preview, google-cloud, python, pendiente-usar]
|
||||
tags: [bigquery, gcp, table, preview, google-cloud, python, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def bq_query(client: BQClient, sql: str, params: list[dict] | None = None, dry_run: bool = False) -> dict"
|
||||
description: "Ejecuta una query SQL en BigQuery con soporte para parametros tipados y modo dry-run para estimacion de costos."
|
||||
tags: [bigquery, gcp, query, sql, google-cloud, python, pendiente-usar]
|
||||
tags: [bigquery, gcp, query, sql, google-cloud, python, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: chunk_ax_tree
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def chunk_ax_tree(nodes: list[dict], max_chars: int = 25000) -> list[list[dict]]"
|
||||
description: "Divide una lista de AXNode en chunks de hasta max_chars (serializado JSON). Hace DFS desde raíz y añade nodo 'context' con path-from-root al inicio de cada chunk no inicial."
|
||||
tags: [navegator, accessibility, chunking, pure, ax-tree, llm]
|
||||
uses_functions: [trim_ax_tree_py_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [json]
|
||||
params:
|
||||
- name: nodes
|
||||
desc: "Lista de AXNode en formato CDP (con nodeId, childIds, role, name). Usar después de trim_ax_tree para reducir ruido."
|
||||
- name: max_chars
|
||||
desc: "Límite de caracteres al serializar el chunk a JSON. Default 25000 (~6k tokens)."
|
||||
output: "Lista de chunks. Cada chunk es lista de AXNode. Los chunks no iniciales arrancan con un nodo sintético role='context' que contiene el path desde la raíz."
|
||||
tested: true
|
||||
tests:
|
||||
- "lista vacia devuelve lista vacia"
|
||||
- "nodos que caben en un chunk devuelven un solo chunk"
|
||||
- "nodos que no caben generan multiples chunks"
|
||||
- "cada chunk serializado no supera max_chars mas overhead"
|
||||
test_file_path: "python/functions/core/chunk_ax_tree_test.py"
|
||||
file_path: "python/functions/core/chunk_ax_tree.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from core.trim_ax_tree import trim_ax_tree
|
||||
from core.chunk_ax_tree import chunk_ax_tree
|
||||
|
||||
# Obtener AX tree de CDP, luego:
|
||||
trimmed = trim_ax_tree(raw_nodes)
|
||||
chunks = chunk_ax_tree(trimmed, max_chars=25000)
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
print(f"Chunk {i}: {len(chunk)} nodos")
|
||||
# Enviar chunk a claude_cli_prompt o a la API de Claude
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el AX tree de una página compleja supera el context window del modelo (>25k chars). Chunkearlo permite procesar secciones independientes manteniendo el path-from-root como contexto para que el LLM entienda la estructura jerárquica.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El nodo `context` sintético (role="context") al inicio de cada chunk no es un nodo CDP real — filtrarlo si se necesita reconstruir el árbol.
|
||||
- `max_chars` es límite aproximado: el nodo de contexto puede hacer que el chunk supere ligeramente el límite. Se recomienda un 10-20% de margen.
|
||||
- Usar `trim_ax_tree` antes para reducir el número de nodos y mejorar la utilidad de cada chunk.
|
||||
- Árboles muy profundos con pocos nodos por nivel generan chunks con mucho overhead de contexto.
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Divide una lista de AXNode en chunks de tamaño máximo para enviar a LLMs."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def chunk_ax_tree(nodes: list[dict], max_chars: int = 25000) -> list[list[dict]]:
|
||||
"""Splittea AX tree en chunks de hasta max_chars cuando se serializa a JSON.
|
||||
|
||||
Estrategia DFS desde el nodo raíz:
|
||||
- Acumula nodos en el chunk actual.
|
||||
- Cuando añadir el siguiente subárbol superaría max_chars, cierra el chunk
|
||||
y abre uno nuevo.
|
||||
- Cada chunk incluye al inicio los nodos "context" (path desde root hasta
|
||||
el primer nodo del chunk) para que el LLM tenga orientación estructural.
|
||||
|
||||
Args:
|
||||
nodes: Lista de AXNode en formato CDP (con nodeId, childIds, role, name).
|
||||
max_chars: Límite de caracteres al serializar el chunk a JSON. Default 25000.
|
||||
|
||||
Returns:
|
||||
Lista de chunks. Cada chunk es una lista de AXNode. El primer elemento de
|
||||
cada chunk (salvo el primero) es un nodo sintético con role="context" que
|
||||
contiene el path desde la raíz.
|
||||
"""
|
||||
if not nodes:
|
||||
return []
|
||||
|
||||
# Construir lookup y encontrar raíz
|
||||
by_id: dict[str, dict] = {n["nodeId"]: n for n in nodes}
|
||||
all_children: set[str] = set()
|
||||
for n in nodes:
|
||||
for cid in n.get("childIds", []):
|
||||
all_children.add(cid)
|
||||
roots = [n for n in nodes if n["nodeId"] not in all_children]
|
||||
if not roots:
|
||||
# Fallback: usar el primer nodo como raíz
|
||||
roots = [nodes[0]]
|
||||
|
||||
# DFS para obtener orden de visita + path desde raíz
|
||||
visit_order: list[tuple[dict, list[dict]]] = [] # (nodo, ancestors)
|
||||
|
||||
def dfs(node: dict, ancestors: list[dict]) -> None:
|
||||
visit_order.append((node, list(ancestors)))
|
||||
for cid in node.get("childIds", []):
|
||||
child = by_id.get(cid)
|
||||
if child:
|
||||
dfs(child, ancestors + [node])
|
||||
|
||||
for root in roots:
|
||||
dfs(root, [])
|
||||
|
||||
# Agrupar en chunks respetando max_chars
|
||||
chunks: list[list[dict]] = []
|
||||
current_chunk: list[dict] = []
|
||||
current_chars: int = 2 # "[]"
|
||||
|
||||
for node, ancestors in visit_order:
|
||||
node_json = json.dumps(node, ensure_ascii=False)
|
||||
node_chars = len(node_json) + 2 # ", " separator
|
||||
|
||||
if current_chunk and current_chars + node_chars > max_chars:
|
||||
chunks.append(current_chunk)
|
||||
# Nuevo chunk con contexto (path desde root)
|
||||
current_chunk = []
|
||||
current_chars = 2
|
||||
if ancestors:
|
||||
context_node = {
|
||||
"nodeId": "context",
|
||||
"role": {"value": "context"},
|
||||
"name": {"value": "path-from-root"},
|
||||
"childIds": [],
|
||||
"context_path": [
|
||||
{
|
||||
"nodeId": a["nodeId"],
|
||||
"role": a.get("role", {}).get("value", "") if isinstance(a.get("role"), dict) else str(a.get("role", "")),
|
||||
"name": a.get("name", {}).get("value", "") if isinstance(a.get("name"), dict) else str(a.get("name", "")),
|
||||
}
|
||||
for a in ancestors
|
||||
],
|
||||
}
|
||||
ctx_chars = len(json.dumps(context_node, ensure_ascii=False)) + 2
|
||||
current_chunk.append(context_node)
|
||||
current_chars += ctx_chars
|
||||
|
||||
current_chunk.append(node)
|
||||
current_chars += node_chars
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
return chunks
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests para chunk_ax_tree."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from chunk_ax_tree import chunk_ax_tree
|
||||
|
||||
|
||||
def _node(node_id: str, child_ids=None) -> dict:
|
||||
return {
|
||||
"nodeId": node_id,
|
||||
"role": {"value": "generic"},
|
||||
"name": {"value": f"node-{node_id}"},
|
||||
"childIds": child_ids or [],
|
||||
}
|
||||
|
||||
|
||||
def _make_linear_tree(n: int) -> list[dict]:
|
||||
"""Cadena lineal: 0->1->2->...->n-1"""
|
||||
nodes = []
|
||||
for i in range(n):
|
||||
child = [str(i + 1)] if i < n - 1 else []
|
||||
nodes.append(_node(str(i), child))
|
||||
return nodes
|
||||
|
||||
|
||||
def test_lista_vacia_devuelve_lista_vacia():
|
||||
result = chunk_ax_tree([])
|
||||
assert result == [], f"Expected [], got {result}"
|
||||
|
||||
|
||||
def test_nodos_que_caben_en_un_chunk_devuelven_un_solo_chunk():
|
||||
nodes = _make_linear_tree(3)
|
||||
result = chunk_ax_tree(nodes, max_chars=10000)
|
||||
assert len(result) == 1, f"Expected 1 chunk, got {len(result)}"
|
||||
# Todos los nodos deben estar presentes
|
||||
ids_in_chunk = [n["nodeId"] for n in result[0]]
|
||||
for node in nodes:
|
||||
assert node["nodeId"] in ids_in_chunk, f"{node['nodeId']} missing from chunk"
|
||||
|
||||
|
||||
def test_nodos_que_no_caben_generan_multiples_chunks():
|
||||
# 100 nodos, cada uno ~80 chars serializado -> ~8000 chars total
|
||||
# con max_chars=200 debe generar varios chunks
|
||||
nodes = [_node(str(i)) for i in range(100)]
|
||||
# Hacer árbol plano: raíz con todos como hijos
|
||||
nodes[0]["childIds"] = [str(i) for i in range(1, 100)]
|
||||
result = chunk_ax_tree(nodes, max_chars=200)
|
||||
assert len(result) > 1, f"Expected >1 chunks, got {len(result)}"
|
||||
|
||||
|
||||
def test_cada_chunk_serializado_no_supera_max_chars_mas_overhead():
|
||||
nodes = [_node(str(i)) for i in range(100)]
|
||||
nodes[0]["childIds"] = [str(i) for i in range(1, 100)]
|
||||
max_chars = 500
|
||||
result = chunk_ax_tree(nodes, max_chars=max_chars)
|
||||
overhead_factor = 1.5 # permitir 50% de overhead por nodo contexto
|
||||
for i, chunk in enumerate(result):
|
||||
serialized = json.dumps(chunk, ensure_ascii=False)
|
||||
limit = max_chars * overhead_factor
|
||||
assert len(serialized) < limit, (
|
||||
f"Chunk {i} tiene {len(serialized)} chars, supera {limit} "
|
||||
f"(max_chars={max_chars}, overhead_factor={overhead_factor})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_lista_vacia_devuelve_lista_vacia()
|
||||
test_nodos_que_caben_en_un_chunk_devuelven_un_solo_chunk()
|
||||
test_nodos_que_no_caben_generan_multiples_chunks()
|
||||
test_cada_chunk_serializado_no_supera_max_chars_mas_overhead()
|
||||
print("All tests passed.")
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def coerce_types(data: dict, schema: dict[str, str]) -> tuple[dict, list[str]]"
|
||||
description: "Convierte valores de un dict a los tipos esperados segun un schema declarativo. Soporta int, float, str, bool, datetime, list[str]. Util para normalizar datos de CSV, JSON o query params. Nunca muta el original. Coerciones imposibles generan warning y mantienen el valor original."
|
||||
tags: [coercion, types, normalization, pure, core, csv, json, pendiente-usar]
|
||||
tags: [coercion, types, normalization, pure, core, csv, json, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "csv_to_parquet_duckdb(csv_path: str | Path, parquet_path: str | Path, column_casts: dict[str, str] | None = None, overwrite: bool = False) -> bool"
|
||||
description: "Convierte un CSV a Parquet usando DuckDB read_csv_auto. Si overwrite=False y el parquet ya existe no hace nada. column_casts permite sobreescribir tipos inferidos por columna. Retorna True si escribió."
|
||||
tags: [csv, parquet, duckdb, etl, core, pendiente-usar]
|
||||
tags: [csv, parquet, duckdb, etl, core, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "from_csv(text: str, delimiter: str = ',', has_header: bool = True) -> list[dict]"
|
||||
description: "Parser CSV a datos tabulares. Complemento de to_csv. Soporta campos entre comillas con escaping RFC 4180. Si has_header=False, genera keys col_0, col_1, etc."
|
||||
tags: [csv, parser, import, tabular, format, pendiente-usar]
|
||||
tags: [csv, parser, import, tabular, format, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: infer_json_rows_schema
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def infer_json_rows_schema(data: object) -> dict"
|
||||
description: "Dado un JSON deserializado, localiza el primer array de objetos con >=1 elementos y devuelve su schema: root_path, fields [{name, type, sample}], count."
|
||||
tags: [navegator, json, schema, scraping, pure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: data
|
||||
desc: "JSON deserializado: dict, list, o cualquier valor Python resultante de json.loads()."
|
||||
output: "dict con {root_path: str, fields: [{name, type, sample}], count: int}. Si no encuentra array de objetos devuelve root_path='' y fields=[]."
|
||||
tested: true
|
||||
tests:
|
||||
- "lista de dicts directa retorna root_path dolar"
|
||||
- "dict con clave results retorna root_path dolar punto results"
|
||||
- "input vacio retorna schema vacio"
|
||||
test_file_path: "python/functions/core/infer_json_rows_schema_test.py"
|
||||
file_path: "python/functions/core/infer_json_rows_schema.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from core.infer_json_rows_schema import infer_json_rows_schema
|
||||
|
||||
schema = infer_json_rows_schema([{"id": 1, "name": "foo"}])
|
||||
# {"root_path": "$", "fields": [{"name": "id", "type": "int", "sample": "1"}, ...], "count": 1}
|
||||
|
||||
schema2 = infer_json_rows_schema({"results": [{"a": "x"}]})
|
||||
# {"root_path": "$.results", "fields": [...], "count": 1}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando recibes JSON de una API o scraping y necesitas descubrir automaticamente que campos contiene y sus tipos, sin conocer la estructura de antemano. Paso previo a generar un recipe YAML o mapear campos a un schema destino.
|
||||
|
||||
## Notas
|
||||
|
||||
- Prefiere claves `results, items, data, records, rows, entries` en DFS.
|
||||
- Tipos: string|int|float|bool|null|array|object.
|
||||
- `sample` truncado a 80 chars de la primera fila.
|
||||
- Funcion pura: sin I/O, sin estado.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Infiere schema de un JSON deserializado localizando el primer array de objetos."""
|
||||
|
||||
|
||||
def _type(value) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
if isinstance(value, int):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return "string"
|
||||
|
||||
|
||||
def _truncate(value, max_chars: int = 80) -> str:
|
||||
s = str(value)
|
||||
return s[:max_chars] if len(s) > max_chars else s
|
||||
|
||||
|
||||
_PREFERRED_KEYS = ("results", "items", "data", "records", "rows", "entries")
|
||||
|
||||
|
||||
def _find_array_of_dicts(obj, path: str = "$"):
|
||||
"""DFS buscando el primer array de dicts con >=1 elementos."""
|
||||
if isinstance(obj, list) and len(obj) >= 1 and isinstance(obj[0], dict):
|
||||
return path, obj
|
||||
if isinstance(obj, dict):
|
||||
# Primero probar claves preferidas en orden
|
||||
for key in _PREFERRED_KEYS:
|
||||
if key in obj:
|
||||
result = _find_array_of_dicts(obj[key], f"{path}.{key}")
|
||||
if result is not None:
|
||||
return result
|
||||
# Luego resto de claves
|
||||
for key, val in obj.items():
|
||||
if key in _PREFERRED_KEYS:
|
||||
continue
|
||||
result = _find_array_of_dicts(val, f"{path}.{key}")
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def infer_json_rows_schema(data: object) -> dict:
|
||||
"""Dado un JSON deserializado, localiza el primer array de objetos con >=1 elementos.
|
||||
|
||||
Args:
|
||||
data: JSON deserializado (dict, list, u otro valor Python).
|
||||
|
||||
Returns:
|
||||
Schema con root_path, fields y count. Si no encuentra array, devuelve
|
||||
root_path vacío y fields vacío.
|
||||
"""
|
||||
result = _find_array_of_dicts(data)
|
||||
if result is None:
|
||||
return {"root_path": "", "fields": [], "count": 0}
|
||||
|
||||
root_path, rows = result
|
||||
first_row = rows[0]
|
||||
fields = []
|
||||
for name, value in first_row.items():
|
||||
fields.append({
|
||||
"name": name,
|
||||
"type": _type(value),
|
||||
"sample": _truncate(value),
|
||||
})
|
||||
|
||||
return {
|
||||
"root_path": root_path,
|
||||
"fields": fields,
|
||||
"count": len(rows),
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests para infer_json_rows_schema."""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from core.infer_json_rows_schema import infer_json_rows_schema
|
||||
|
||||
|
||||
def test_lista_de_dicts_directa_retorna_root_path_dolar():
|
||||
data = [{"a": 1, "b": "x"}]
|
||||
result = infer_json_rows_schema(data)
|
||||
assert result["root_path"] == "$"
|
||||
assert len(result["fields"]) == 2
|
||||
fields_by_name = {f["name"]: f for f in result["fields"]}
|
||||
assert fields_by_name["a"]["type"] == "int"
|
||||
assert fields_by_name["b"]["type"] == "string"
|
||||
assert result["count"] == 1
|
||||
|
||||
|
||||
def test_dict_con_clave_results_retorna_root_path_dolar_punto_results():
|
||||
data = {"results": [{"id": 42, "label": "foo"}]}
|
||||
result = infer_json_rows_schema(data)
|
||||
assert result["root_path"] == "$.results"
|
||||
assert result["count"] == 1
|
||||
fields_by_name = {f["name"]: f for f in result["fields"]}
|
||||
assert fields_by_name["id"]["type"] == "int"
|
||||
assert fields_by_name["label"]["type"] == "string"
|
||||
|
||||
|
||||
def test_input_vacio_retorna_schema_vacio():
|
||||
assert infer_json_rows_schema({}) == {"root_path": "", "fields": [], "count": 0}
|
||||
assert infer_json_rows_schema([]) == {"root_path": "", "fields": [], "count": 0}
|
||||
|
||||
|
||||
def test_tipos_varios():
|
||||
data = [{"s": "hello", "i": 1, "f": 1.5, "b": True, "n": None, "arr": [1], "obj": {"x": 1}}]
|
||||
result = infer_json_rows_schema(data)
|
||||
by_name = {f["name"]: f["type"] for f in result["fields"]}
|
||||
assert by_name["s"] == "string"
|
||||
assert by_name["i"] == "int"
|
||||
assert by_name["f"] == "float"
|
||||
assert by_name["b"] == "bool"
|
||||
assert by_name["n"] == "null"
|
||||
assert by_name["arr"] == "array"
|
||||
assert by_name["obj"] == "object"
|
||||
|
||||
|
||||
def test_sample_truncado():
|
||||
long_val = "x" * 100
|
||||
data = [{"field": long_val}]
|
||||
result = infer_json_rows_schema(data)
|
||||
assert len(result["fields"][0]["sample"]) == 80
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_lista_de_dicts_directa_retorna_root_path_dolar()
|
||||
test_dict_con_clave_results_retorna_root_path_dolar_punto_results()
|
||||
test_input_vacio_retorna_schema_vacio()
|
||||
test_tipos_varios()
|
||||
test_sample_truncado()
|
||||
print("All tests passed.")
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: trim_ax_tree
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def trim_ax_tree(nodes: list[dict]) -> list[dict]"
|
||||
description: "Compacta lista de AXNode CDP descartando nodos ignorados, roles genéricos sin contenido y StaticText vacíos. Colapsa nodos con un único hijo del mismo role."
|
||||
tags: [navegator, accessibility, dom, scraping, pure, ax-tree]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: nodes
|
||||
desc: "Lista de AXNode en formato CDP (Accessibility.getFullAXTree). Cada nodo es un dict con nodeId, ignored, role, name, childIds."
|
||||
output: "Lista reducida de AXNode con misma estructura. Nodos descartados y colapsados segun las reglas de trimming."
|
||||
tested: true
|
||||
tests:
|
||||
- "descarta nodo generic sin name ni childIds"
|
||||
- "mantiene nodo button con name"
|
||||
- "descarta nodo ignored"
|
||||
- "descarta StaticText con name vacio"
|
||||
- "colapsa nodo con un hijo del mismo role"
|
||||
test_file_path: "python/functions/core/trim_ax_tree_test.py"
|
||||
file_path: "python/functions/core/trim_ax_tree.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from core.trim_ax_tree import trim_ax_tree
|
||||
|
||||
nodes = [
|
||||
{"nodeId": "1", "ignored": False, "role": {"value": "RootWebArea"}, "name": {"value": ""}, "childIds": ["2", "3"]},
|
||||
{"nodeId": "2", "ignored": False, "role": {"value": "generic"}, "name": {"value": ""}, "childIds": []},
|
||||
{"nodeId": "3", "ignored": False, "role": {"value": "button"}, "name": {"value": "Enviar"}, "childIds": []},
|
||||
]
|
||||
result = trim_ax_tree(nodes)
|
||||
# result: nodo "2" descartado, quedan "1" y "3"
|
||||
print([n["nodeId"] for n in result]) # ["1", "3"]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de pasar un AX tree a un LLM o de chunkear con `chunk_ax_tree` — reduce el numero de nodos eliminando ruido (wrappers genéricos, texto vacío, nodos ignorados) y mejora la relacion señal/ruido del contexto enviado al modelo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion pura: no modifica los dicts originales (crea copias al fusionar).
|
||||
- El colapso iterativo puede cambiar la profundidad del arbol — parentIds de nodos externos no se actualizan.
|
||||
- Los nodos cuyo nodeId ya no existe en el resultado pero aparecen como childIds de otros nodos quedan como referencias colgantes — filtrar antes de usar el arbol para navegacion.
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Compacta una lista de AXNode CDP descartando nodos irrelevantes."""
|
||||
|
||||
|
||||
def trim_ax_tree(nodes: list[dict]) -> list[dict]:
|
||||
"""Compacta lista de AXNode (formato CDP Accessibility.getFullAXTree).
|
||||
|
||||
Descarta:
|
||||
- Nodos con ignored=true.
|
||||
- role 'generic' o 'none' sin name no-vacio Y sin childIds.
|
||||
- role 'StaticText' con name vacio.
|
||||
|
||||
Colapsa: si un nodo tiene exactamente 1 hijo y el mismo role, fusiona
|
||||
el hijo en el padre (hereda childIds del hijo, descarta el nodo hijo).
|
||||
|
||||
Preserva: nodeId, parentId, childIds, role.value, name.value, value.value.
|
||||
|
||||
Args:
|
||||
nodes: Lista de AXNode en formato CDP. Cada nodo es un dict con
|
||||
campos 'nodeId', 'ignored', 'role', 'name', 'childIds', etc.
|
||||
|
||||
Returns:
|
||||
Lista reducida de AXNode con la misma estructura de campos.
|
||||
"""
|
||||
if not nodes:
|
||||
return []
|
||||
|
||||
def _role(node: dict) -> str:
|
||||
r = node.get("role", {})
|
||||
if isinstance(r, dict):
|
||||
return r.get("value", "")
|
||||
return str(r)
|
||||
|
||||
def _name(node: dict) -> str:
|
||||
n = node.get("name", {})
|
||||
if isinstance(n, dict):
|
||||
return n.get("value", "")
|
||||
return str(n) if n else ""
|
||||
|
||||
def _child_ids(node: dict) -> list[str]:
|
||||
return node.get("childIds", [])
|
||||
|
||||
# Paso 1: descartar nodos ignorados y roles descartables
|
||||
def _should_discard(node: dict) -> bool:
|
||||
if node.get("ignored", False):
|
||||
return True
|
||||
role = _role(node)
|
||||
name = _name(node)
|
||||
child_ids = _child_ids(node)
|
||||
if role in ("generic", "none") and not name and not child_ids:
|
||||
return True
|
||||
if role == "StaticText" and not name:
|
||||
return True
|
||||
return False
|
||||
|
||||
kept = [n for n in nodes if not _should_discard(n)]
|
||||
|
||||
# Construir lookup por nodeId para el paso de colapso
|
||||
by_id: dict[str, dict] = {n["nodeId"]: n for n in kept}
|
||||
|
||||
# Paso 2: colapso de nodo con 1 solo hijo del mismo role
|
||||
# Iteramos hasta que no haya mas fusiones (convergencia)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
new_kept = []
|
||||
removed: set[str] = set()
|
||||
for node in by_id.values():
|
||||
if node["nodeId"] in removed:
|
||||
continue
|
||||
child_ids = _child_ids(node)
|
||||
if len(child_ids) == 1:
|
||||
child_id = child_ids[0]
|
||||
child = by_id.get(child_id)
|
||||
if child and _role(child) == _role(node):
|
||||
# Fusionar: heredar childIds del hijo
|
||||
merged = dict(node)
|
||||
merged["childIds"] = _child_ids(child)
|
||||
by_id[node["nodeId"]] = merged
|
||||
removed.add(child_id)
|
||||
changed = True
|
||||
if changed:
|
||||
by_id = {k: v for k, v in by_id.items() if k not in removed}
|
||||
|
||||
# Preservar orden original
|
||||
original_order = [n["nodeId"] for n in nodes]
|
||||
result = []
|
||||
seen: set[str] = set()
|
||||
for nid in original_order:
|
||||
if nid in by_id and nid not in seen:
|
||||
result.append(by_id[nid])
|
||||
seen.add(nid)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests para trim_ax_tree."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from trim_ax_tree import trim_ax_tree
|
||||
|
||||
|
||||
def _node(node_id: str, role: str, name: str = "", child_ids=None, ignored: bool = False) -> dict:
|
||||
return {
|
||||
"nodeId": node_id,
|
||||
"ignored": ignored,
|
||||
"role": {"value": role},
|
||||
"name": {"value": name},
|
||||
"childIds": child_ids or [],
|
||||
}
|
||||
|
||||
|
||||
def test_descarta_nodo_generic_sin_name_ni_childIds():
|
||||
nodes = [
|
||||
_node("1", "RootWebArea", "", ["2"]),
|
||||
_node("2", "generic", "", []),
|
||||
]
|
||||
result = trim_ax_tree(nodes)
|
||||
ids = [n["nodeId"] for n in result]
|
||||
assert "2" not in ids, f"generic sin name/childIds debe descartarse, got {ids}"
|
||||
|
||||
|
||||
def test_mantiene_nodo_button_con_name():
|
||||
nodes = [
|
||||
_node("1", "RootWebArea", "", ["2"]),
|
||||
_node("2", "button", "Click me", []),
|
||||
]
|
||||
result = trim_ax_tree(nodes)
|
||||
ids = [n["nodeId"] for n in result]
|
||||
assert "2" in ids, f"button con name debe mantenerse, got {ids}"
|
||||
|
||||
|
||||
def test_descarta_nodo_ignored():
|
||||
nodes = [
|
||||
_node("1", "RootWebArea", "", ["2"]),
|
||||
_node("2", "button", "Visible", [], ignored=True),
|
||||
]
|
||||
result = trim_ax_tree(nodes)
|
||||
ids = [n["nodeId"] for n in result]
|
||||
assert "2" not in ids, f"nodo ignored debe descartarse, got {ids}"
|
||||
|
||||
|
||||
def test_descarta_StaticText_con_name_vacio():
|
||||
nodes = [
|
||||
_node("1", "RootWebArea", "", ["2", "3"]),
|
||||
_node("2", "StaticText", "", []),
|
||||
_node("3", "StaticText", "Hola", []),
|
||||
]
|
||||
result = trim_ax_tree(nodes)
|
||||
ids = [n["nodeId"] for n in result]
|
||||
assert "2" not in ids, f"StaticText vacio debe descartarse, got {ids}"
|
||||
assert "3" in ids, f"StaticText con name debe mantenerse, got {ids}"
|
||||
|
||||
|
||||
def test_colapsa_nodo_con_un_hijo_del_mismo_role():
|
||||
nodes = [
|
||||
_node("1", "RootWebArea", "", ["2"]),
|
||||
_node("2", "group", "", ["3"]),
|
||||
_node("3", "group", "", ["4"]),
|
||||
_node("4", "button", "OK", []),
|
||||
]
|
||||
result = trim_ax_tree(nodes)
|
||||
ids = [n["nodeId"] for n in result]
|
||||
# Nodo 2 y 3 tienen mismo role y uno tiene 1 solo hijo — deben colapsar
|
||||
assert "4" in ids, f"button debe estar presente, got {ids}"
|
||||
# Tras colapso, el nodo raiz group debe tener childIds apuntando a button
|
||||
root_group = next((n for n in result if n["nodeId"] in ("2", "3")), None)
|
||||
if root_group:
|
||||
assert "4" in root_group.get("childIds", []), \
|
||||
f"grupo colapsado debe tener button como hijo, got {root_group}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_descarta_nodo_generic_sin_name_ni_childIds()
|
||||
test_mantiene_nodo_button_con_name()
|
||||
test_descarta_nodo_ignored()
|
||||
test_descarta_StaticText_con_name_vacio()
|
||||
test_colapsa_nodo_con_un_hijo_del_mismo_role()
|
||||
print("All tests passed.")
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: validate_recipe_yaml
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def validate_recipe_yaml(yaml_text: str) -> dict"
|
||||
description: "Parsea y valida recipe YAML del navegator. Comprueba name (snake_case), url_pattern (regex valido), steps (js o wait_selector), output.schema y output.sink."
|
||||
tags: [navegator, recipe, validation, yaml, pure, validator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [re, yaml]
|
||||
params:
|
||||
- name: yaml_text
|
||||
desc: "Texto completo de la recipe en formato YAML."
|
||||
output: "dict {valid: bool, errors: [str], warnings: [str], parsed: dict}. valid=True solo si errors esta vacio."
|
||||
tested: true
|
||||
tests:
|
||||
- "recipe valida minima retorna valid true"
|
||||
- "name ausente produce error"
|
||||
- "url_pattern invalido produce error"
|
||||
- "step sin js ni wait_selector produce error"
|
||||
- "sink invalido produce error"
|
||||
test_file_path: "python/functions/core/validate_recipe_yaml_test.py"
|
||||
file_path: "python/functions/core/validate_recipe_yaml.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from core.validate_recipe_yaml import validate_recipe_yaml
|
||||
|
||||
yaml_text = """
|
||||
name: product_list
|
||||
url_pattern: "https://shop\\.example\\.com/.*"
|
||||
steps:
|
||||
- wait_selector: ".product-card"
|
||||
- js: "Array.from(document.querySelectorAll('.product-card')).map(e => e.innerText)"
|
||||
output:
|
||||
sink: stdout
|
||||
"""
|
||||
|
||||
result = validate_recipe_yaml(yaml_text)
|
||||
# {"valid": True, "errors": [], "warnings": [], "parsed": {...}}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de ejecutar una recipe con `cdp_extract_recipe`: validar el YAML en seco sin necesitar Chrome. Tambien util en un editor de recipes para dar feedback al usuario en tiempo real.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `pyyaml` en el entorno; si falta devuelve `valid=False` con mensaje claro (no lanza excepcion).
|
||||
- Funcion pura: no accede a red ni a disco.
|
||||
- `url_pattern` se compila con `re.compile` — patrones validos en Python pero invalidos en otros motores no se detectan aqui.
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Parsea y valida recipe YAML del navegator."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def validate_recipe_yaml(yaml_text: str) -> dict:
|
||||
"""Parsea + valida recipe YAML del navegator.
|
||||
|
||||
Args:
|
||||
yaml_text: Texto YAML de la recipe.
|
||||
|
||||
Returns:
|
||||
{valid: bool, errors: [str], warnings: [str], parsed: dict}
|
||||
"""
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": ["pyyaml no disponible: instalar con 'pip install pyyaml'"],
|
||||
"warnings": [],
|
||||
"parsed": {},
|
||||
}
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
parsed = {}
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(yaml_text) or {}
|
||||
except yaml.YAMLError as e:
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": [f"YAML invalido: {e}"],
|
||||
"warnings": [],
|
||||
"parsed": {},
|
||||
}
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": ["El YAML debe ser un objeto (dict) en el nivel raiz."],
|
||||
"warnings": [],
|
||||
"parsed": parsed,
|
||||
}
|
||||
|
||||
# name obligatorio, snake_case
|
||||
name = parsed.get("name")
|
||||
if not name:
|
||||
errors.append("Campo 'name' obligatorio.")
|
||||
elif not isinstance(name, str):
|
||||
errors.append("Campo 'name' debe ser string.")
|
||||
elif not re.fullmatch(r"[a-z][a-z0-9_]*", name):
|
||||
errors.append(f"Campo 'name' debe ser snake_case (solo [a-z0-9_], empieza con letra): '{name}'.")
|
||||
|
||||
# url_pattern obligatorio, regex valido
|
||||
url_pattern = parsed.get("url_pattern")
|
||||
if not url_pattern:
|
||||
errors.append("Campo 'url_pattern' obligatorio.")
|
||||
elif not isinstance(url_pattern, str):
|
||||
errors.append("Campo 'url_pattern' debe ser string.")
|
||||
else:
|
||||
try:
|
||||
re.compile(url_pattern)
|
||||
except re.error as e:
|
||||
errors.append(f"Campo 'url_pattern' no es un regex valido: {e}")
|
||||
|
||||
# steps lista no vacia, cada step requiere js O wait_selector
|
||||
steps = parsed.get("steps")
|
||||
if not steps:
|
||||
errors.append("Campo 'steps' obligatorio y no puede estar vacio.")
|
||||
elif not isinstance(steps, list):
|
||||
errors.append("Campo 'steps' debe ser una lista.")
|
||||
else:
|
||||
for i, step in enumerate(steps):
|
||||
if not isinstance(step, dict):
|
||||
errors.append(f"Step {i}: debe ser un objeto.")
|
||||
continue
|
||||
has_js = "js" in step
|
||||
has_wait = "wait_selector" in step
|
||||
if not has_js and not has_wait:
|
||||
errors.append(f"Step {i}: requiere 'js' o 'wait_selector'.")
|
||||
if has_js and has_wait:
|
||||
warnings.append(f"Step {i}: tiene tanto 'js' como 'wait_selector'; se usara 'js'.")
|
||||
|
||||
# output opcional pero validado si presente
|
||||
output = parsed.get("output")
|
||||
if output is not None:
|
||||
if not isinstance(output, dict):
|
||||
errors.append("Campo 'output' debe ser un objeto.")
|
||||
else:
|
||||
schema = output.get("schema")
|
||||
if schema is not None:
|
||||
if not isinstance(schema, list):
|
||||
errors.append("Campo 'output.schema' debe ser una lista.")
|
||||
else:
|
||||
valid_types = {"string", "int", "float", "bool", "date"}
|
||||
for j, item in enumerate(schema):
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"output.schema[{j}]: debe ser un objeto.")
|
||||
continue
|
||||
if "field" not in item:
|
||||
errors.append(f"output.schema[{j}]: falta campo 'field'.")
|
||||
if "type" not in item:
|
||||
errors.append(f"output.schema[{j}]: falta campo 'type'.")
|
||||
elif item["type"] not in valid_types:
|
||||
warnings.append(
|
||||
f"output.schema[{j}]: tipo '{item['type']}' no reconocido "
|
||||
f"(esperado: {sorted(valid_types)})."
|
||||
)
|
||||
|
||||
sink = output.get("sink")
|
||||
valid_sinks = {"data_factory.runs", "stdout", "json_file"}
|
||||
if sink is not None and sink not in valid_sinks:
|
||||
errors.append(
|
||||
f"Campo 'output.sink' debe ser uno de {sorted(valid_sinks)}, got '{sink}'."
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"parsed": parsed,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests para validate_recipe_yaml."""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from core.validate_recipe_yaml import validate_recipe_yaml
|
||||
|
||||
VALID_YAML = """
|
||||
name: product_list
|
||||
url_pattern: 'https://shop\\.example\\.com/.*'
|
||||
steps:
|
||||
- wait_selector: ".product-card"
|
||||
- js: "document.querySelector('.price').innerText"
|
||||
output:
|
||||
sink: stdout
|
||||
"""
|
||||
|
||||
|
||||
def test_recipe_valida_minima_retorna_valid_true():
|
||||
result = validate_recipe_yaml(VALID_YAML)
|
||||
assert result["valid"] is True
|
||||
assert result["errors"] == []
|
||||
assert isinstance(result["parsed"], dict)
|
||||
assert result["parsed"]["name"] == "product_list"
|
||||
|
||||
|
||||
def test_name_ausente_produce_error():
|
||||
yaml_text = """
|
||||
url_pattern: "https://example.com"
|
||||
steps:
|
||||
- js: "1+1"
|
||||
"""
|
||||
result = validate_recipe_yaml(yaml_text)
|
||||
assert result["valid"] is False
|
||||
assert any("name" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_url_pattern_invalido_produce_error():
|
||||
yaml_text = """
|
||||
name: my_recipe
|
||||
url_pattern: "[invalid("
|
||||
steps:
|
||||
- js: "1"
|
||||
"""
|
||||
result = validate_recipe_yaml(yaml_text)
|
||||
assert result["valid"] is False
|
||||
assert any("url_pattern" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_step_sin_js_ni_wait_selector_produce_error():
|
||||
yaml_text = """
|
||||
name: bad_steps
|
||||
url_pattern: "https://example.com"
|
||||
steps:
|
||||
- description: "solo descripcion sin accion"
|
||||
"""
|
||||
result = validate_recipe_yaml(yaml_text)
|
||||
assert result["valid"] is False
|
||||
assert any("js" in e or "wait_selector" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_sink_invalido_produce_error():
|
||||
yaml_text = """
|
||||
name: bad_sink
|
||||
url_pattern: "https://example.com"
|
||||
steps:
|
||||
- js: "1"
|
||||
output:
|
||||
sink: invalid_sink
|
||||
"""
|
||||
result = validate_recipe_yaml(yaml_text)
|
||||
assert result["valid"] is False
|
||||
assert any("sink" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_name_no_snake_case_produce_error():
|
||||
yaml_text = """
|
||||
name: MyRecipe
|
||||
url_pattern: "https://example.com"
|
||||
steps:
|
||||
- js: "1"
|
||||
"""
|
||||
result = validate_recipe_yaml(yaml_text)
|
||||
assert result["valid"] is False
|
||||
assert any("snake_case" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_output_schema_valido():
|
||||
yaml_text = """
|
||||
name: with_schema
|
||||
url_pattern: "https://example.com"
|
||||
steps:
|
||||
- js: "1"
|
||||
output:
|
||||
sink: stdout
|
||||
schema:
|
||||
- field: price
|
||||
type: float
|
||||
- field: name
|
||||
type: string
|
||||
"""
|
||||
result = validate_recipe_yaml(yaml_text)
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_recipe_valida_minima_retorna_valid_true()
|
||||
test_name_ausente_produce_error()
|
||||
test_url_pattern_invalido_produce_error()
|
||||
test_step_sin_js_ni_wait_selector_produce_error()
|
||||
test_sink_invalido_produce_error()
|
||||
test_name_no_snake_case_produce_error()
|
||||
test_output_schema_valido()
|
||||
print("All tests passed.")
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def aggregate_by_group(rows: list[dict], group_by: list[str], aggs: dict[str, str]) -> list[dict]"
|
||||
description: "GROUP BY + agregaciones sobre datos tabulares. aggs es un dict de columna a funcion (sum, mean, count, min, max, first, last, collect). collect acumula valores en lista. None se ignora en agregaciones numericas."
|
||||
tags: [datascience, tabular, groupby, aggregate, transform, python, pendiente-usar]
|
||||
tags: [datascience, tabular, groupby, aggregate, transform, python, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def align_relations_to_entities(triplets: list[dict], entity_names: list[str]) -> list[dict]"
|
||||
description: "Filtra y alinea triplets REBEL/mREBEL a nombres canonicos de entidades. Para cada triplet, resuelve head y tail contra entity_names con match exacto case-insensitive o substring (gana el nombre mas largo). Descarta triplets donde algun lado no resuelve o head==tail."
|
||||
tags: [rebel, mrebel, relation-extraction, nlp, align, knowledge-graph, datascience, python]
|
||||
tags: [rebel, mrebel, relation-extraction, nlp, align, knowledge-graph, datascience, python, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def clip(data: list, lo: float, hi: float) -> list"
|
||||
description: "Recorta los valores de la lista al rango [lo, hi]."
|
||||
tags: [clipping, bounds, python, pendiente-usar]
|
||||
tags: [clipping, bounds, python, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def deduplicate_entities(candidates: list[EntityCandidate], name_threshold: float = 0.85, same_type_only: bool = True) -> DeduplicationResult"
|
||||
description: "Agrupa entidades candidatas que refieren a la misma entidad real usando fuzzy matching de nombres (Levenshtein + Jaccard) y Union-Find para clusters transitivos. Retorna entidades mergeadas con mapas de resolucion de IDs y log de merges."
|
||||
tags: [deduplication, entity, fuzzy, levenshtein, jaccard, union-find, knowledge-graph, nlp, fuzzygraph, datascience]
|
||||
tags: [deduplication, entity, fuzzy, levenshtein, jaccard, union-find, knowledge-graph, nlp, fuzzygraph, datascience, transformer]
|
||||
uses_functions:
|
||||
- normalize_entity_name_py_core
|
||||
- merge_entity_attributes_py_core
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def deduplicate_relations(relations: list[RelationCandidate], entity_id_map: dict[str, str]) -> list[RelationCandidate]"
|
||||
description: "Deduplica relaciones candidatas resolviendo from_name/to_name a entity IDs finales via entity_id_map. Descarta self-loops y relaciones sin match. Mergea duplicados (mismo from_id, to_id, relation_type) concatenando descripciones unicas y tomando max confidence."
|
||||
tags: [datascience, extraction, knowledge-graph, nlp, deduplication, fuzzy-match, fuzzygraph]
|
||||
tags: [datascience, extraction, knowledge-graph, nlp, deduplication, fuzzy-match, fuzzygraph, transformer]
|
||||
uses_functions:
|
||||
- levenshtein_distance_py_cybersecurity
|
||||
uses_types:
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def detect_drift(history: list[dict], current: dict, fields: list[str], threshold: float = 2.0) -> list[dict]"
|
||||
description: "Detecta drift estadistico comparando metricas de la ejecucion actual contra el historial usando z-score. Si |z| > threshold, el campo ha drifteado. Util para monitorizar executions en operations.db."
|
||||
tags: [drift, statistics, z-score, monitoring, executions, operations, datascience, pendiente-usar]
|
||||
tags: [drift, statistics, z-score, monitoring, executions, operations, datascience, pendiente-usar, validator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def detect_outliers(data: list, threshold: float) -> list"
|
||||
description: "Detecta outliers por z-score. Retorna lista de bools, True donde |z-score| > threshold."
|
||||
tags: [statistics, outliers, python, pendiente-usar]
|
||||
tags: [statistics, outliers, python, pendiente-usar, validator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def diff_entities(before: list[dict], after: list[dict], key: str = 'id', ignore_fields: list[str] | None = None, compare_fields: list[str] | None = None) -> dict"
|
||||
description: "Compara dos snapshots de entities y devuelve diferencias campo a campo. Detecta añadidas, eliminadas, modificadas e inalteradas. Ignora created_at y updated_at por defecto."
|
||||
tags: [diff, entities, snapshot, operations, comparison, datascience, pendiente-usar]
|
||||
tags: [diff, entities, snapshot, operations, comparison, datascience, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def diff_relations(before: list[dict], after: list[dict], key: tuple[str, str, str] = ('source_id', 'target_id', 'relation_type'), ignore_fields: list[str] | None = None, compare_fields: list[str] | None = None) -> dict"
|
||||
description: "Compara relaciones entre dos snapshots usando key compuesta (source_id, target_id, relation_type). Detecta relaciones añadidas, eliminadas y modificadas con detalle campo a campo."
|
||||
tags: [diff, relations, graph, snapshot, operations, comparison, datascience, pendiente-usar]
|
||||
tags: [diff, relations, graph, snapshot, operations, comparison, datascience, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def histogram(data: list, buckets: int) -> list"
|
||||
description: "Calcula histograma con N buckets. Retorna lista de conteos por bucket."
|
||||
tags: [statistics, histogram, python, pendiente-usar]
|
||||
tags: [statistics, histogram, python, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def impute(data: list) -> list"
|
||||
description: "Reemplaza None y NaN con la media de los valores validos."
|
||||
tags: [imputation, missing, python, pendiente-usar]
|
||||
tags: [imputation, missing, python, pendiente-usar, transformer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from .setup_logger import setup_logger, get_logger
|
||||
from .generate_app_icon import generate_app_icon
|
||||
|
||||
__all__ = [
|
||||
"setup_logger",
|
||||
"get_logger",
|
||||
"generate_app_icon",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: claude_cli_prompt
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def claude_cli_prompt(prompt: str, timeout_s: int = 60, model: str | None = None, max_chars_response: int = 200_000, extra_args: list[str] | None = None) -> str"
|
||||
description: "Invoca `claude -p` via subprocess y devuelve la respuesta completa como string. Valida presencia del CLI, captura stderr en errores, y trunca respuestas largas."
|
||||
tags: [claude, llm, cli, ai, navegator, subprocess]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [shutil, subprocess]
|
||||
params:
|
||||
- name: prompt
|
||||
desc: "Texto del prompt a enviar a Claude."
|
||||
- name: timeout_s
|
||||
desc: "Segundos antes de raise TimeoutExpired. Default 60."
|
||||
- name: model
|
||||
desc: "Modelo a usar (ej. claude-opus-4-5). None usa el default del CLI."
|
||||
- name: max_chars_response
|
||||
desc: "Trunca stdout a este numero de caracteres. Default 200_000."
|
||||
- name: extra_args
|
||||
desc: "Lista de argumentos adicionales para el CLI."
|
||||
output: "Respuesta de Claude como texto plano (stdout del proceso)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/claude_cli_prompt.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra.claude_cli_prompt import claude_cli_prompt
|
||||
|
||||
# Pregunta simple
|
||||
respuesta = claude_cli_prompt("Suma 2+2 y devuelve solo el numero")
|
||||
print(respuesta) # "4"
|
||||
|
||||
# Con modelo especifico y timeout extendido
|
||||
respuesta = claude_cli_prompt(
|
||||
prompt="Resume este texto en 3 puntos: ...",
|
||||
model="claude-opus-4-5",
|
||||
timeout_s=120,
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas invocar Claude desde un script Python sin mantener sesion de chat — un prompt puntual (clasificacion, resumen, extraccion) que se resuelve en una sola llamada. Ideal para pipelines que procesan chunks de AX tree, texto, o cualquier contenido que requiera razonamiento LLM.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `claude` CLI instalado y autenticado en PATH. Si no esta: `FileNotFoundError`.
|
||||
- Cada llamada lanza un subproceso nuevo — no hay cache ni sesion persistente.
|
||||
- El CLI puede tardar varios segundos al cold-start. Usa `timeout_s` conservador (>=30s).
|
||||
- `extra_args` se pasan literalmente — validar antes de pasar input de usuario.
|
||||
- En CI/CD sin display interactivo puede requerir `--no-interactive` en `extra_args`.
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Invoca `claude -p` via subprocess y devuelve la respuesta como string."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def claude_cli_prompt(
|
||||
prompt: str,
|
||||
timeout_s: int = 60,
|
||||
model: str | None = None,
|
||||
max_chars_response: int = 200_000,
|
||||
extra_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Invoca `claude -p "<prompt>"` via subprocess.
|
||||
|
||||
Args:
|
||||
prompt: Texto del prompt a enviar a Claude.
|
||||
timeout_s: Timeout en segundos antes de raise TimeoutExpired.
|
||||
model: Modelo a usar (ej. "claude-opus-4-5"). None usa el default de `claude -p`.
|
||||
max_chars_response: Trunca stdout a este numero de caracteres.
|
||||
extra_args: Argumentos adicionales para el CLI (ej. ["--output-format", "json"]).
|
||||
|
||||
Returns:
|
||||
Respuesta de Claude como texto (stdout), truncada a max_chars_response.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Si `claude` no esta en PATH.
|
||||
RuntimeError: Si exit code != 0 (incluye primeros 500 chars de stderr).
|
||||
subprocess.TimeoutExpired: Si la llamada supera timeout_s segundos.
|
||||
"""
|
||||
if shutil.which("claude") is None:
|
||||
raise FileNotFoundError(
|
||||
"'claude' CLI no encontrado en PATH. Instala Claude Code."
|
||||
)
|
||||
|
||||
cmd = ["claude", "-p", prompt]
|
||||
if model:
|
||||
cmd.extend(["--model", model])
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr_snippet = result.stderr[:500] if result.stderr else "(sin stderr)"
|
||||
raise RuntimeError(
|
||||
f"claude -p failed (exit {result.returncode}): {stderr_snippet}"
|
||||
)
|
||||
|
||||
stdout = result.stdout
|
||||
if len(stdout) > max_chars_response:
|
||||
stdout = stdout[:max_chars_response]
|
||||
|
||||
return stdout
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: generate_app_icon
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "generate_app_icon(phosphor_icon_name: str, accent_hex: str, out_ico_path: str, *, weight: str = 'fill', sizes: list[int] = None, phosphor_root: str = None) -> str"
|
||||
description: "Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent y exporta un .ico multi-resolucion (default 16,24,32,48,64,128,256). Devuelve el path absoluto del .ico escrito. El glyph se renderiza en blanco al 70% del canvas sobre fondo con esquinas redondeadas al 16%."
|
||||
tags: [cpp-windows, icon, windows, phosphor, ico, pillow, cairosvg]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [cairosvg, PIL]
|
||||
params:
|
||||
- name: phosphor_icon_name
|
||||
desc: "Nombre del icono Phosphor sin sufijo de weight (ej. 'chart-bar', 'tree-structure', 'gauge'). Ver https://phosphoricons.com para el catalogo."
|
||||
- name: accent_hex
|
||||
desc: "Color de fondo en formato hexadecimal '#RRGGBB' (ej. '#0ea5e9', '#7c3aed'). Define la identidad visual de la app."
|
||||
- name: out_ico_path
|
||||
desc: "Ruta de salida del .ico. Absoluta o relativa al cwd. El directorio padre se crea si no existe. Colocar en <app_dir>/appicon.ico para que add_imgui_app lo detecte automaticamente."
|
||||
- name: weight
|
||||
desc: "Variante Phosphor: 'fill' (default), 'regular', 'bold', 'light', 'thin', 'duotone'."
|
||||
- name: sizes
|
||||
desc: "Lista de resoluciones a incluir. Default [16,24,32,48,64,128,256]. Cada tamano se renderiza independientemente para crispness."
|
||||
- name: phosphor_root
|
||||
desc: "Carpeta raiz de assets phosphor-core (contiene subdirs fill/, regular/, etc.). Default: <registry_root>/sources/phosphor-core/assets."
|
||||
output: "Ruta absoluta (str) del archivo .ico generado y escrito a disco."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/generate_app_icon.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra import generate_app_icon
|
||||
|
||||
# Generar icono para una app C++ del registry
|
||||
ico_path = generate_app_icon(
|
||||
phosphor_icon_name="chart-bar",
|
||||
accent_hex="#0ea5e9",
|
||||
out_ico_path="apps/chart_demo/appicon.ico",
|
||||
)
|
||||
print(ico_path) # /home/lucas/fn_registry/apps/chart_demo/appicon.ico
|
||||
```
|
||||
|
||||
```python
|
||||
# Desde la CLI directa para prueba rapida
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import generate_app_icon
|
||||
|
||||
generate_app_icon("gauge", "#059669", "/tmp/registry_dashboard.ico")
|
||||
```
|
||||
|
||||
```bash
|
||||
# Generar iconos para todas las apps del registry con el script batch
|
||||
python dev/gen_app_icons.py
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando una app C++ del registry necesita un `.ico` de Windows para distinguirse en el escritorio y taskbar. El macro `add_imgui_app` de `cpp/CMakeLists.txt` detecta `<app_dir>/appicon.ico` y lo enlaza al `.exe` via `windres` automaticamente en builds Windows. Ejecutar esta funcion antes de compilar en Windows o antes de `fn run redeploy_cpp_app_windows <app>`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere `sources/phosphor-core/`**: el repo debe estar clonado. Si falta: `git clone --depth=1 https://github.com/phosphor-icons/core.git sources/phosphor-core` desde la raiz del registry. La funcion lanza `FileNotFoundError` con el comando exacto si el SVG no existe.
|
||||
- **`cairosvg` y `Pillow` en el venv**: deben estar instalados en `python/.venv`. Si faltan: `cd python && uv pip install cairosvg pillow`. Ya presentes en el venv por defecto del registry.
|
||||
- **El `.ico` se sobreescribe sin warning**: si ya existe `appicon.ico` se reemplaza silenciosamente. Hacer backup si se necesita preservar la version anterior.
|
||||
- **Re-build del `.exe` necesario**: Windows no refleja el icono nuevo hasta que se recompila el ejecutable. Tras generar el `.ico` ejecutar `fn run redeploy_cpp_app_windows <app>` o compilar manualmente con CMake.
|
||||
- **Solo formato `#RRGGBB`**: `accent_hex` debe tener exactamente 6 digitos hex. Formatos con alpha o notacion corta `#RGB` lanzan `ValueError`.
|
||||
- **Peso "fill" por defecto**: Phosphor "fill" tiene las formas mas solidas y visibles en tamanos pequeños (16x16). Para iconos lineales usar `weight="regular"` pero verificar legibilidad a 16px.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
*(sin cambios desde v1.0.0)*
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Genera un icono .ico multi-resolucion para apps C++ del registry.
|
||||
|
||||
Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent
|
||||
y exporta un .ico con multiples resoluciones (16, 24, 32, 48, 64, 128, 256).
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cairosvg
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
DEFAULT_SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
|
||||
|
||||
def _find_registry_root() -> Path:
|
||||
"""Descubre la raiz del registry caminando hacia arriba hasta encontrar registry.db."""
|
||||
env_root = os.environ.get("FN_REGISTRY_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root).resolve()
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / "registry.db").exists():
|
||||
return parent
|
||||
raise FileNotFoundError(
|
||||
"No se encontro registry.db caminando desde el archivo hasta la raiz. "
|
||||
"Define FN_REGISTRY_ROOT en el entorno."
|
||||
)
|
||||
|
||||
|
||||
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
||||
h = h.lstrip("#")
|
||||
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
|
||||
"""Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente."""
|
||||
svg = svg_path.read_text(encoding="utf-8")
|
||||
# Phosphor usa fill="currentColor" — forzar blanco.
|
||||
svg = svg.replace('fill="currentColor"', 'fill="#ffffff"')
|
||||
png_bytes = cairosvg.svg2png(
|
||||
bytestring=svg.encode("utf-8"),
|
||||
output_width=size,
|
||||
output_height=size,
|
||||
)
|
||||
return Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||||
|
||||
|
||||
def _make_icon_image(svg_path: Path, accent_hex: str, size: int) -> Image.Image:
|
||||
"""Compone fondo redondeado con color accent + glyph blanco centrado al 70%."""
|
||||
bg_color = _hex_to_rgb(accent_hex) + (255,)
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
radius = max(2, size // 6) # ~16% de radio redondeado
|
||||
draw.rounded_rectangle(
|
||||
[(0, 0), (size - 1, size - 1)],
|
||||
radius=radius,
|
||||
fill=bg_color,
|
||||
)
|
||||
# El glyph ocupa ~70% del canvas (padding ~15% en cada lado).
|
||||
glyph_size = int(size * 0.7)
|
||||
if glyph_size < 8:
|
||||
glyph_size = max(8, size - 2)
|
||||
glyph = _render_glyph_white(svg_path, glyph_size)
|
||||
off = ((size - glyph_size) // 2, (size - glyph_size) // 2)
|
||||
canvas.alpha_composite(glyph, dest=off)
|
||||
return canvas
|
||||
|
||||
|
||||
def generate_app_icon(
|
||||
phosphor_icon_name: str,
|
||||
accent_hex: str,
|
||||
out_ico_path: str,
|
||||
*,
|
||||
weight: str = "fill",
|
||||
sizes: list[int] = None,
|
||||
phosphor_root: str = None,
|
||||
) -> str:
|
||||
"""Genera un icono .ico multi-resolucion a partir de un SVG Phosphor.
|
||||
|
||||
Rasteriza el icono Phosphor indicado sobre un fondo redondeado del color
|
||||
accent y exporta un .ico con multiples resoluciones ordenadas de mayor a
|
||||
menor para maxima compatibilidad con Windows.
|
||||
|
||||
Args:
|
||||
phosphor_icon_name: Nombre del icono Phosphor sin sufijo de weight
|
||||
(ej. "chart-bar", "tree-structure", "gauge").
|
||||
accent_hex: Color de fondo en formato hexadecimal "#RRGGBB"
|
||||
(ej. "#0ea5e9", "#7c3aed").
|
||||
out_ico_path: Ruta de salida para el archivo .ico. Puede ser absoluta
|
||||
o relativa al directorio de trabajo actual. El directorio padre
|
||||
se crea si no existe.
|
||||
weight: Variante del icono Phosphor. Default "fill". Otros valores
|
||||
validos segun el repositorio: "regular", "bold", "light",
|
||||
"thin", "duotone".
|
||||
sizes: Lista de resoluciones a incluir en el .ico. Default
|
||||
[16, 24, 32, 48, 64, 128, 256]. El orden no importa; se
|
||||
renderiza cada tamano individualmente para maxima crispness.
|
||||
phosphor_root: Ruta a la carpeta raiz de assets de phosphor-core
|
||||
(la que contiene subdirectorios "fill", "regular", etc.).
|
||||
Default: <registry_root>/sources/phosphor-core/assets.
|
||||
|
||||
Returns:
|
||||
Ruta absoluta del archivo .ico generado.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Si el SVG del icono no existe en phosphor_root.
|
||||
ValueError: Si accent_hex no tiene el formato "#RRGGBB".
|
||||
"""
|
||||
if sizes is None:
|
||||
sizes = DEFAULT_SIZES
|
||||
|
||||
# Resolver raiz de phosphor
|
||||
if phosphor_root is None:
|
||||
registry_root = _find_registry_root()
|
||||
phosphor_assets = registry_root / "sources" / "phosphor-core" / "assets"
|
||||
else:
|
||||
phosphor_assets = Path(phosphor_root)
|
||||
|
||||
svg_file = phosphor_assets / weight / f"{phosphor_icon_name}-{weight}.svg"
|
||||
if not svg_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Icono Phosphor no encontrado: {svg_file}\n"
|
||||
f"Asegurate de que sources/phosphor-core/ existe. Si no:\n"
|
||||
f" git clone --depth=1 https://github.com/phosphor-icons/core.git "
|
||||
f"sources/phosphor-core"
|
||||
)
|
||||
|
||||
# Validar formato del color
|
||||
h = accent_hex.lstrip("#")
|
||||
if len(h) != 6:
|
||||
raise ValueError(f"accent_hex debe tener formato #RRGGBB, recibido: {accent_hex!r}")
|
||||
|
||||
# Renderizar cada resolucion individualmente para crispness en tamanos pequeños
|
||||
images = {s: _make_icon_image(svg_file, accent_hex, s) for s in sorted(sizes, reverse=True)}
|
||||
|
||||
out = Path(out_ico_path)
|
||||
if not out.is_absolute():
|
||||
out = Path.cwd() / out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sorted_sizes = sorted(sizes, reverse=True)
|
||||
biggest = images[sorted_sizes[0]]
|
||||
others = [images[s] for s in sorted_sizes[1:]]
|
||||
biggest.save(
|
||||
out,
|
||||
format="ICO",
|
||||
sizes=[(s, s) for s in sorted_sizes],
|
||||
append_images=others,
|
||||
)
|
||||
|
||||
return str(out.resolve())
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "http_download_file(url: str, dest_path: str, headers: dict[str, str] | None = None, timeout: float = 120.0, chunk_size: int = 8192) -> dict"
|
||||
description: "Descarga un archivo por HTTP en streaming (sin cargar todo en memoria). Crea directorios intermedios si no existen. Retorna dict con path, size_bytes y content_type."
|
||||
tags: [http, download, file, streaming, network, stdlib, infra, pendiente-usar]
|
||||
tags: [http, download, file, streaming, network, stdlib, infra, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "http_get_json(url: str, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float = 30.0) -> dict"
|
||||
description: "GET request que espera JSON. Agrega Accept: application/json automaticamente. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body."
|
||||
tags: [http, json, get, client, network, stdlib, infra, pendiente-usar]
|
||||
tags: [http, json, get, client, network, stdlib, infra, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "http_post_json(url: str, body: dict, headers: dict[str, str] | None = None, timeout: float = 30.0) -> dict"
|
||||
description: "POST request con body JSON. Agrega Content-Type: application/json y Accept: application/json. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body."
|
||||
tags: [http, json, post, client, network, stdlib, infra, pendiente-usar]
|
||||
tags: [http, json, post, client, network, stdlib, infra, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: llm_propose_scraping_schema
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def llm_propose_scraping_schema(url: str, ax_tree: list, max_chunks: int = 5, max_chars_per_chunk: int = 25000) -> dict"
|
||||
description: "Orquesta trim_ax_tree -> chunk_ax_tree -> N llamadas a Claude CLI -> merge. Propone schema de scraping (fields, selectors, types) a partir del AX tree de una pagina."
|
||||
tags: [navegator, ai, llm, scraping, schema]
|
||||
uses_functions: [trim_ax_tree_py_core, chunk_ax_tree_py_core, claude_cli_prompt_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, re, sys, os]
|
||||
params:
|
||||
- name: url
|
||||
desc: "URL de la pagina (se incluye en el prompt a Claude para contexto)."
|
||||
- name: ax_tree
|
||||
desc: "AX tree como lista de dicts obtenida via CDP (cdp_get_ax_tree)."
|
||||
- name: max_chunks
|
||||
desc: "Maximo de chunks a procesar. Default 5. Si hay mas, truncated=True."
|
||||
- name: max_chars_per_chunk
|
||||
desc: "Caracteres maximos por chunk de AX tree enviado a Claude. Default 25000."
|
||||
output: "dict {schema: [{field, selector, sample_value, type, source_role}], notes: str, chunks_processed: int, truncated: bool}"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/llm_propose_scraping_schema.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.llm_propose_scraping_schema import llm_propose_scraping_schema
|
||||
|
||||
# ax_tree obtenido previamente con cdp_get_ax_tree
|
||||
result = llm_propose_scraping_schema(
|
||||
url="https://shop.example.com/products",
|
||||
ax_tree=ax_tree,
|
||||
max_chunks=3,
|
||||
)
|
||||
# {"schema": [{"field": "price", "selector": ".product-price", ...}], "notes": "...", ...}
|
||||
for field in result["schema"]:
|
||||
print(field["field"], "->", field["selector"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tienes el AX tree de una pagina y quieres que Claude proponga automaticamente que campos extraer y con que selectores CSS. Paso de discovery antes de escribir la recipe YAML a mano o de forma asistida.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `claude` CLI instalado y disponible en PATH (validado por `claude_cli_prompt`).
|
||||
- Cada chunk genera una llamada a Claude (coste de tokens). Usar `max_chunks` conservador en paginas muy grandes.
|
||||
- La respuesta de Claude se parsea tolerando fenced code blocks (```json ... ```). Si Claude devuelve prosa sin JSON, el chunk se omite con nota de error.
|
||||
- Dedup por `field`: primera ocurrencia gana si el mismo campo aparece en varios chunks.
|
||||
- No accede a red directamente — delega en `claude_cli_prompt`.
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Orquesta trim_ax_tree -> chunk_ax_tree -> N llamadas claude_cli_prompt -> merge schema."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from core.trim_ax_tree import trim_ax_tree
|
||||
from core.chunk_ax_tree import chunk_ax_tree
|
||||
from infra.claude_cli_prompt import claude_cli_prompt
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
"""Extrae JSON de la respuesta de Claude, tolerante a fenced code blocks."""
|
||||
# Intentar fenced ```json ... ```
|
||||
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
||||
if m:
|
||||
return json.loads(m.group(1))
|
||||
# Intentar JSON directo
|
||||
m = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if m:
|
||||
return json.loads(m.group(0))
|
||||
raise ValueError(f"No se encontro JSON valido en respuesta: {text[:200]}")
|
||||
|
||||
|
||||
def _build_prompt(url: str, chunk_json: str) -> str:
|
||||
return (
|
||||
f"Analiza este accessibility tree de la pagina {url}.\n"
|
||||
"Identifica campos de datos extraibles (tablas, listas, valores estructurados).\n"
|
||||
"Para cada campo propone:\n"
|
||||
" - field (snake_case)\n"
|
||||
" - selector CSS robusto que se pueda usar con document.querySelector\n"
|
||||
" - sample_value (valor visible representativo)\n"
|
||||
" - type (string|int|float|bool|date)\n"
|
||||
" - source_role (role del AXNode origen)\n"
|
||||
"\n"
|
||||
'Devuelve JSON valido SIN explicacion:\n'
|
||||
'{"schema": [...], "notes": "..."}\n'
|
||||
"\n"
|
||||
"AX tree:\n"
|
||||
f"{chunk_json}"
|
||||
)
|
||||
|
||||
|
||||
def llm_propose_scraping_schema(
|
||||
url: str,
|
||||
ax_tree: list,
|
||||
max_chunks: int = 5,
|
||||
max_chars_per_chunk: int = 25000,
|
||||
) -> dict:
|
||||
"""Orquesta: trim_ax_tree -> chunk_ax_tree -> N llamadas claude_cli_prompt -> merge.
|
||||
|
||||
Args:
|
||||
url: URL de la pagina (se incluye en el prompt para contexto).
|
||||
ax_tree: AX tree como lista de dicts obtenida via CDP.
|
||||
max_chunks: Maximo de chunks a procesar (trunca el resto).
|
||||
max_chars_per_chunk: Caracteres maximos por chunk antes de pasar a Claude.
|
||||
|
||||
Returns:
|
||||
{schema: [{field, selector, sample_value, type, source_role}],
|
||||
notes: str,
|
||||
chunks_processed: int,
|
||||
truncated: bool}
|
||||
"""
|
||||
trimmed = trim_ax_tree(ax_tree)
|
||||
chunks = chunk_ax_tree(trimmed, max_chars=max_chars_per_chunk)
|
||||
|
||||
truncated = len(chunks) > max_chunks
|
||||
chunks = chunks[:max_chunks]
|
||||
|
||||
merged_schema: list = []
|
||||
seen_fields: set = set()
|
||||
notes_parts: list = []
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_json = json.dumps(chunk, ensure_ascii=False)
|
||||
prompt = _build_prompt(url, chunk_json)
|
||||
try:
|
||||
response = claude_cli_prompt(prompt, timeout_s=60)
|
||||
parsed = _parse_json_response(response)
|
||||
except Exception as e:
|
||||
notes_parts.append(f"[chunk error: {e}]")
|
||||
continue
|
||||
|
||||
for item in parsed.get("schema", []):
|
||||
field = item.get("field", "")
|
||||
if field and field not in seen_fields:
|
||||
seen_fields.add(field)
|
||||
merged_schema.append(item)
|
||||
|
||||
note = parsed.get("notes", "")
|
||||
if note:
|
||||
notes_parts.append(note)
|
||||
|
||||
return {
|
||||
"schema": merged_schema,
|
||||
"notes": " | ".join(notes_parts),
|
||||
"chunks_processed": len(chunks),
|
||||
"truncated": truncated,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_card(client: MetabaseClient, name: str, dataset_query: dict, display: str = 'table', collection_id: int = 0, description: str = '') -> dict"
|
||||
description: "Crea una card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card."
|
||||
tags: [metabase, card, question, create, api, python]
|
||||
tags: [metabase, card, question, create, api, python, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_card_alert(client: MetabaseClient, card_id: int, cron_schedule: str, recipients: list[dict], send_condition: str = 'has_result', send_once: bool = False) -> dict"
|
||||
description: "Crea una alerta sobre los resultados de una card en Metabase. Envia email segun cron cuando la card cumple la condicion (has_result, goal_above, goal_below). Endpoint: POST /api/notification."
|
||||
tags: [metabase, notification, alert, card, create, api, python, pendiente-usar]
|
||||
tags: [metabase, notification, alert, card, create, api, python, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_create_dashboard_subscription(client: MetabaseClient, dashboard_id: int, cron_schedule: str, recipients: list[dict]) -> dict"
|
||||
description: "Crea una suscripcion periodica a un dashboard de Metabase. Envia el dashboard completo por email segun el cron configurado. Endpoint: POST /api/notification."
|
||||
tags: [metabase, notification, subscription, dashboard, create, api, python, pendiente-usar]
|
||||
tags: [metabase, notification, subscription, dashboard, create, api, python, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_execute_card(client: MetabaseClient, card_id: int, parameters: list[dict] | None = None) -> dict"
|
||||
description: "Ejecuta la query de una card guardada y retorna resultados con columnas y filas. Soporta parametros. Endpoint: POST /api/card/:id/query."
|
||||
tags: [metabase, card, question, execute, query, api, python, pendiente-usar]
|
||||
tags: [metabase, card, question, execute, query, api, python, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_execute_query(client: MetabaseClient, database_id: int, sql: str, max_results: int = 0) -> dict"
|
||||
description: "Ejecuta query SQL ad-hoc contra Metabase sin guardarla como card. Util para exploracion rapida. Endpoint: POST /api/dataset."
|
||||
tags: [metabase, query, execute, sql, dataset, api, python]
|
||||
tags: [metabase, query, execute, sql, dataset, api, python, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def metabase_export_card(client: MetabaseClient, card_id: int, format: str = 'csv') -> bytes"
|
||||
description: "Exporta los resultados de una card de Metabase en CSV, XLSX o JSON. Endpoint: POST /api/card/:id/query/:format."
|
||||
tags: [metabase, card, export, csv, xlsx, api, python, pendiente-usar]
|
||||
tags: [metabase, card, export, csv, xlsx, api, python, pendiente-usar, sink]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def jupyter_read_cells(notebook_path: str, server_url: str = 'http://localhost:8888', token: str = '', cell_index: int | None = None) -> list[dict]"
|
||||
description: "Lee celdas de un notebook Jupyter abierto via el protocolo de colaboracion en tiempo real (CRDT/Y.js). Devuelve el estado actual incluyendo cambios no guardados. Expone tambien jupyter_notebook_info() para metadata rapida."
|
||||
tags: [jupyter, notebook, crdt, yjs, websocket, cells, read, realtime, pendiente-usar]
|
||||
tags: [jupyter, notebook, crdt, yjs, websocket, cells, read, realtime, pendiente-usar, extractor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: cdp_extract_recipe
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def cdp_extract_recipe(recipe_path: str, debug_port: int = 9222, tab_id: str | None = None, record_run: bool = True) -> dict"
|
||||
description: "Ejecuta una recipe YAML contra Chrome remoto via CDP. Valida recipe, busca tab por url_pattern, ejecuta steps (wait_selector/js) y envia resultado al sink declarado."
|
||||
tags: [navegator, cdp, recipe, scraping, pipeline]
|
||||
uses_functions: [validate_recipe_yaml_py_core, data_factory_record_run_py_pipelines]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, re, sys, os, time, urllib.request, websocket]
|
||||
params:
|
||||
- name: recipe_path
|
||||
desc: "Ruta al archivo .yaml de la recipe (absoluta o relativa al cwd)."
|
||||
- name: debug_port
|
||||
desc: "Puerto de depuracion remota de Chrome. Default 9222."
|
||||
- name: tab_id
|
||||
desc: "ID del tab a usar. Si None, busca tab cuyo URL matchee url_pattern de la recipe."
|
||||
- name: record_run
|
||||
desc: "Si True y output.sink=='data_factory.runs', registra la ejecucion en data_factory."
|
||||
output: "dict {status: ok|error, rows_out: int, kb_out: float, duration_ms: int, error: str, sample_rows: list}"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/cdp_extract_recipe.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.cdp_extract_recipe import cdp_extract_recipe
|
||||
|
||||
result = cdp_extract_recipe(
|
||||
recipe_path="recipes/product_list.yaml",
|
||||
debug_port=9222,
|
||||
)
|
||||
print(result["status"], result["rows_out"], "rows")
|
||||
# ok 42 rows
|
||||
```
|
||||
|
||||
Recipe de ejemplo (`recipes/product_list.yaml`):
|
||||
```yaml
|
||||
name: product_list
|
||||
url_pattern: "https://shop\\.example\\.com/products.*"
|
||||
steps:
|
||||
- wait_selector: ".product-card"
|
||||
- js: "Array.from(document.querySelectorAll('.product-card')).map(e => ({name: e.querySelector('h2').innerText, price: e.querySelector('.price').innerText}))"
|
||||
output:
|
||||
sink: stdout
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tienes una recipe YAML validada y Chrome corriendo con remote debugging, y quieres extraer datos en un solo paso sin montar pipeline manualmente. Encadena con `cdp_open_url_and_wait` si necesitas abrir la URL primero.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Chrome debe estar corriendo con `--remote-debugging-port=<debug_port>`.
|
||||
- `wait_selector` usa polling sync sobre el WebSocket (200ms interval, 10s timeout) — no apto para paginas con lazy load muy largo.
|
||||
- El ultimo step `js` debe devolver el dato final (array o valor). Steps intermedios pueden preparar el DOM.
|
||||
- `data_factory_record_run` falla silenciosamente si no hay DB configurada — el dato ya fue extraido y devuelto.
|
||||
- `websocket-client` debe estar instalado en el venv.
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Ejecuta una recipe YAML contra Chrome remoto via CDP."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import urllib.request
|
||||
import websocket
|
||||
|
||||
from core.validate_recipe_yaml import validate_recipe_yaml
|
||||
|
||||
|
||||
def _ws_send_recv(ws, msg_id: int, method: str, params: dict, timeout: float = 10.0) -> dict:
|
||||
"""Envia un mensaje CDP y espera respuesta con el mismo id."""
|
||||
import threading
|
||||
result_holder = {}
|
||||
event = threading.Event()
|
||||
|
||||
original_on_message = ws.on_message
|
||||
|
||||
def on_message_wrapper(ws_app, message):
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
if msg.get("id") == msg_id:
|
||||
result_holder["result"] = msg
|
||||
event.set()
|
||||
except Exception:
|
||||
pass
|
||||
if original_on_message:
|
||||
original_on_message(ws_app, message)
|
||||
|
||||
ws.on_message = on_message_wrapper
|
||||
ws.send(json.dumps({"id": msg_id, "method": method, "params": params}))
|
||||
event.wait(timeout=timeout)
|
||||
ws.on_message = original_on_message
|
||||
return result_holder.get("result", {})
|
||||
|
||||
|
||||
def _poll_selector(ws, selector: str, timeout_s: float = 10.0) -> bool:
|
||||
"""Polling cada 200ms hasta que document.querySelector(selector) no sea null."""
|
||||
deadline = time.time() + timeout_s
|
||||
msg_id = 1000
|
||||
while time.time() < deadline:
|
||||
ws.send(json.dumps({
|
||||
"id": msg_id,
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": f"!!document.querySelector({json.dumps(selector)})",
|
||||
"returnByValue": True,
|
||||
}
|
||||
}))
|
||||
time.sleep(0.2)
|
||||
msg_id += 1
|
||||
# Leer respuesta en loop simple (websocket-client sync)
|
||||
# Para modo sync usamos recv()
|
||||
try:
|
||||
raw = ws.sock.recv()
|
||||
if raw:
|
||||
msg = json.loads(raw)
|
||||
val = msg.get("result", {}).get("result", {}).get("value", False)
|
||||
if val:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def cdp_extract_recipe(
|
||||
recipe_path: str,
|
||||
debug_port: int = 9222,
|
||||
tab_id: str | None = None,
|
||||
record_run: bool = True,
|
||||
) -> dict:
|
||||
"""Ejecuta una recipe YAML contra Chrome remoto via CDP.
|
||||
|
||||
Args:
|
||||
recipe_path: Ruta al archivo .yaml de la recipe.
|
||||
debug_port: Puerto de depuracion remota de Chrome. Default 9222.
|
||||
tab_id: ID del tab a usar. Si None, busca tab cuyo URL matchee url_pattern.
|
||||
record_run: Si True y output.sink=='data_factory.runs', llama data_factory_record_run.
|
||||
|
||||
Returns:
|
||||
{status, rows_out, kb_out, duration_ms, error, sample_rows}
|
||||
"""
|
||||
start_ms = int(time.time() * 1000)
|
||||
|
||||
# Leer y validar recipe
|
||||
try:
|
||||
with open(recipe_path, "r", encoding="utf-8") as f:
|
||||
yaml_text = f.read()
|
||||
except OSError as e:
|
||||
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
|
||||
"duration_ms": 0, "error": str(e), "sample_rows": []}
|
||||
|
||||
validation = validate_recipe_yaml(yaml_text)
|
||||
if not validation["valid"]:
|
||||
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
|
||||
"duration_ms": 0, "error": "recipe invalida: " + "; ".join(validation["errors"]),
|
||||
"sample_rows": []}
|
||||
|
||||
recipe = validation["parsed"]
|
||||
url_pattern = recipe["url_pattern"]
|
||||
steps = recipe["steps"]
|
||||
output_cfg = recipe.get("output", {})
|
||||
sink = output_cfg.get("sink", "stdout")
|
||||
|
||||
# Obtener lista de tabs
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{debug_port}/json/list", timeout=5
|
||||
) as resp:
|
||||
tabs = json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
|
||||
"duration_ms": 0,
|
||||
"error": f"no se pudo conectar a Chrome en port {debug_port}: {e}",
|
||||
"sample_rows": []}
|
||||
|
||||
# Encontrar tab
|
||||
ws_url = None
|
||||
if tab_id:
|
||||
for tab in tabs:
|
||||
if tab.get("id") == tab_id:
|
||||
ws_url = tab.get("webSocketDebuggerUrl")
|
||||
break
|
||||
else:
|
||||
for tab in tabs:
|
||||
tab_url = tab.get("url", "")
|
||||
if re.search(url_pattern, tab_url):
|
||||
ws_url = tab.get("webSocketDebuggerUrl")
|
||||
break
|
||||
|
||||
if not ws_url:
|
||||
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
|
||||
"duration_ms": 0,
|
||||
"error": f"no tab matching pattern: {url_pattern}",
|
||||
"sample_rows": []}
|
||||
|
||||
# Ejecutar steps
|
||||
last_result = None
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=10)
|
||||
try:
|
||||
for i, step in enumerate(steps):
|
||||
if "wait_selector" in step:
|
||||
selector = step["wait_selector"]
|
||||
found = _poll_selector(ws, selector, timeout_s=10.0)
|
||||
if not found:
|
||||
raise RuntimeError(f"step {i}: timeout esperando selector '{selector}'")
|
||||
elif "js" in step:
|
||||
ws.send(json.dumps({
|
||||
"id": i + 1,
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": step["js"],
|
||||
"returnByValue": True,
|
||||
"awaitPromise": True,
|
||||
}
|
||||
}))
|
||||
raw = ws.recv()
|
||||
msg = json.loads(raw)
|
||||
result_obj = msg.get("result", {}).get("result", {})
|
||||
last_result = result_obj.get("value")
|
||||
finally:
|
||||
ws.close()
|
||||
except Exception as e:
|
||||
return {"status": "error", "rows_out": 0, "kb_out": 0.0,
|
||||
"duration_ms": int(time.time() * 1000) - start_ms,
|
||||
"error": str(e), "sample_rows": []}
|
||||
|
||||
# Calcular metricas
|
||||
rows = last_result if isinstance(last_result, list) else (
|
||||
[last_result] if last_result is not None else []
|
||||
)
|
||||
rows_out = len(rows)
|
||||
kb_out = len(json.dumps(rows, ensure_ascii=False).encode()) / 1024
|
||||
sample_rows = rows[:5]
|
||||
duration_ms = int(time.time() * 1000) - start_ms
|
||||
|
||||
# Sink
|
||||
if sink == "stdout":
|
||||
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
||||
elif sink == "json_file":
|
||||
out_path = output_cfg.get("path", "output.json")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(rows, f, ensure_ascii=False, indent=2)
|
||||
elif sink == "data_factory.runs" and record_run:
|
||||
try:
|
||||
from pipelines.data_factory_record_run import data_factory_record_run
|
||||
data_factory_record_run(
|
||||
node_id=recipe.get("name", "unknown"),
|
||||
function_id="cdp_extract_recipe_py_pipelines",
|
||||
args={"recipe_path": recipe_path, "debug_port": debug_port},
|
||||
)
|
||||
except Exception as e:
|
||||
# No fatal — el dato ya fue extraido
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"rows_out": rows_out,
|
||||
"kb_out": round(kb_out, 2),
|
||||
"duration_ms": duration_ms,
|
||||
"error": "",
|
||||
"sample_rows": sample_rows,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: cdp_get_ax_tree
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def cdp_get_ax_tree(debug_port: int, tab_id: str, depth: int = -1) -> list[dict]"
|
||||
description: "Conecta a Chrome via CDP WebSocket, habilita Accessibility y devuelve el AX tree completo del tab indicado. Usa websocket-client si está disponible, sino websockets async."
|
||||
tags: [navegator, cdp, chrome, browser, accessibility, ax-tree]
|
||||
uses_functions: [trim_ax_tree_py_core, chunk_ax_tree_py_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, threading, urllib.request, urllib.error, websocket]
|
||||
params:
|
||||
- name: debug_port
|
||||
desc: "Puerto de debug remoto de Chrome (ej. 9222). Lanzar Chrome con --remote-debugging-port=9222."
|
||||
- name: tab_id
|
||||
desc: "ID del tab CDP obtenido via GET /json/list (campo 'id'). Usar cdp_list_tabs_go_browser para listarlo."
|
||||
- name: depth
|
||||
desc: "Profundidad del árbol a obtener. -1 = completo (default)."
|
||||
output: "Lista de AXNode en formato CDP. Lista vacía si la página no tiene contenido accesible."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/cdp_get_ax_tree.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import urllib.request, json
|
||||
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
|
||||
from core.trim_ax_tree import trim_ax_tree
|
||||
from core.chunk_ax_tree import chunk_ax_tree
|
||||
|
||||
# 1. Listar tabs para obtener tab_id
|
||||
with urllib.request.urlopen("http://127.0.0.1:9222/json/list") as r:
|
||||
tabs = json.loads(r.read())
|
||||
tab_id = tabs[0]["id"]
|
||||
|
||||
# 2. Obtener AX tree
|
||||
nodes = cdp_get_ax_tree(debug_port=9222, tab_id=tab_id)
|
||||
|
||||
# 3. Reducir y chunkear para LLM
|
||||
trimmed = trim_ax_tree(nodes)
|
||||
chunks = chunk_ax_tree(trimmed, max_chars=25000)
|
||||
print(f"{len(nodes)} nodos → {len(trimmed)} trimmed → {len(chunks)} chunks")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas obtener el árbol de accesibilidad de una página Chrome ya abierta para procesarlo con un LLM o para automatización accesible (más estable que selectores CSS). Requiere Chrome lanzado con `--remote-debugging-port=PORT`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Chrome debe estar corriendo con `--remote-debugging-port=<port>` y `--no-sandbox` en CI.
|
||||
- En WSL2 usar `--remote-debugging-address=0.0.0.0` y conectar al IP del host Windows, no a 127.0.0.1.
|
||||
- El tab no puede tener otro debugger adjunto (DevTools abierto) — cierra DevTools antes de llamar.
|
||||
- `Accessibility.getFullAXTree` puede tardar 2-5s en páginas grandes.
|
||||
- Timeout total de 15s — aumentar si la página es muy pesada.
|
||||
- Tests automáticos requieren Chrome corriendo. Para probar manualmente:
|
||||
```bash
|
||||
# Lanzar Chrome en WSL2
|
||||
chrome.exe --remote-debugging-port=9222 --headless=new https://example.com
|
||||
# Verificar
|
||||
curl http://127.0.0.1:9222/json/list | python3 -m json.tool
|
||||
# Ejecutar
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
|
||||
with urllib.request.urlopen('http://127.0.0.1:9222/json/list') as r:
|
||||
tabs = json.loads(r.read())
|
||||
nodes = cdp_get_ax_tree(9222, tabs[0]['id'])
|
||||
print(f'{len(nodes)} nodos')
|
||||
"
|
||||
```
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Obtiene el AX tree completo de un tab Chrome via CDP WebSocket."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
def cdp_get_ax_tree(
|
||||
debug_port: int,
|
||||
tab_id: str,
|
||||
depth: int = -1,
|
||||
) -> list[dict]:
|
||||
"""Conecta al Chrome remoto via WebSocket (CDP) y devuelve el AX tree completo.
|
||||
|
||||
Pasos:
|
||||
1. HTTP GET /json/list para obtener webSocketDebuggerUrl del tab.
|
||||
2. WebSocket connect (usa websocket-client si disponible, sino implementa
|
||||
minimal RFC6455 con socket stdlib).
|
||||
3. Envía Accessibility.enable y espera ack.
|
||||
4. Envía Accessibility.getFullAXTree con depth=-1.
|
||||
5. Lee response y devuelve la lista de AXNode.
|
||||
|
||||
Args:
|
||||
debug_port: Puerto de debug remoto de Chrome (ej. 9222).
|
||||
tab_id: ID del tab obtenido via /json/list (campo "id").
|
||||
depth: Profundidad del árbol. -1 = completo.
|
||||
|
||||
Returns:
|
||||
Lista de AXNode en formato CDP.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si no se encuentra el tab, falla la conexión WS,
|
||||
o la respuesta CDP contiene error.
|
||||
TimeoutError: Si el servidor no responde en 10 segundos.
|
||||
"""
|
||||
# 1. Obtener webSocketDebuggerUrl del tab
|
||||
ws_url = _get_ws_url(debug_port, tab_id)
|
||||
|
||||
# 2. Conectar y obtener nodos
|
||||
return _cdp_get_ax_nodes(ws_url, depth)
|
||||
|
||||
|
||||
def _get_ws_url(debug_port: int, tab_id: str) -> str:
|
||||
"""Obtiene el webSocketDebuggerUrl del tab via HTTP /json/list."""
|
||||
url = f"http://127.0.0.1:{debug_port}/json/list"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
tabs = json.loads(resp.read().decode())
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(
|
||||
f"No se pudo conectar a Chrome en puerto {debug_port}: {e}"
|
||||
) from e
|
||||
|
||||
for tab in tabs:
|
||||
if tab.get("id") == tab_id:
|
||||
ws_url = tab.get("webSocketDebuggerUrl")
|
||||
if not ws_url:
|
||||
raise RuntimeError(
|
||||
f"Tab {tab_id} no tiene webSocketDebuggerUrl "
|
||||
"(puede estar adjunto a otro debugger)"
|
||||
)
|
||||
return ws_url
|
||||
|
||||
raise RuntimeError(
|
||||
f"Tab {tab_id} no encontrado. Tabs disponibles: "
|
||||
f"{[t.get('id') for t in tabs]}"
|
||||
)
|
||||
|
||||
|
||||
def _cdp_get_ax_nodes(ws_url: str, depth: int) -> list[dict]:
|
||||
"""Conecta via WebSocket y ejecuta la secuencia CDP para obtener AX tree."""
|
||||
try:
|
||||
import websocket # websocket-client
|
||||
return _cdp_via_websocket_client(ws_url, depth)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: websockets (async) via threading
|
||||
try:
|
||||
import websockets # noqa: F401
|
||||
return _cdp_via_websockets(ws_url, depth)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
raise RuntimeError(
|
||||
"Ninguna librería WebSocket disponible. "
|
||||
"Instala websocket-client: pip install websocket-client"
|
||||
)
|
||||
|
||||
|
||||
def _cdp_via_websocket_client(ws_url: str, depth: int) -> list[dict]:
|
||||
"""Implementación usando websocket-client (síncrono)."""
|
||||
import websocket
|
||||
|
||||
results: dict = {}
|
||||
error_container: list = []
|
||||
|
||||
def on_message(ws, message):
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
msg_id = msg.get("id")
|
||||
if msg_id in (1, 2):
|
||||
results[msg_id] = msg
|
||||
if msg_id == 2 or "error" in msg:
|
||||
ws.close()
|
||||
except Exception as e:
|
||||
error_container.append(e)
|
||||
ws.close()
|
||||
|
||||
def on_error(ws, error):
|
||||
error_container.append(RuntimeError(f"WebSocket error: {error}"))
|
||||
|
||||
def on_open(ws):
|
||||
# Paso 3: habilitar Accessibility
|
||||
ws.send(json.dumps({"id": 1, "method": "Accessibility.enable"}))
|
||||
# Paso 4: obtener AX tree completo
|
||||
params: dict = {}
|
||||
if depth != -1:
|
||||
params["depth"] = depth
|
||||
ws.send(json.dumps({
|
||||
"id": 2,
|
||||
"method": "Accessibility.getFullAXTree",
|
||||
"params": params,
|
||||
}))
|
||||
|
||||
ws_app = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
t = threading.Thread(
|
||||
target=lambda: ws_app.run_forever(ping_timeout=10),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
t.join(timeout=15)
|
||||
|
||||
if error_container:
|
||||
raise error_container[0]
|
||||
|
||||
if 2 not in results:
|
||||
raise TimeoutError(
|
||||
"No se recibió respuesta de Accessibility.getFullAXTree en 15s"
|
||||
)
|
||||
|
||||
resp = results[2]
|
||||
if "error" in resp:
|
||||
raise RuntimeError(f"CDP error: {resp['error']}")
|
||||
|
||||
result_data = resp.get("result", {})
|
||||
nodes = result_data.get("nodes", [])
|
||||
return nodes
|
||||
|
||||
|
||||
def _cdp_via_websockets(ws_url: str, depth: int) -> list[dict]:
|
||||
"""Fallback usando websockets (async), ejecutado en thread con asyncio."""
|
||||
import asyncio
|
||||
|
||||
async def _run():
|
||||
import websockets
|
||||
|
||||
async with websockets.connect(ws_url, open_timeout=10) as ws:
|
||||
# Habilitar Accessibility
|
||||
await ws.send(json.dumps({"id": 1, "method": "Accessibility.enable"}))
|
||||
await ws.recv() # ack
|
||||
|
||||
# Obtener AX tree
|
||||
params: dict = {}
|
||||
if depth != -1:
|
||||
params["depth"] = depth
|
||||
await ws.send(json.dumps({
|
||||
"id": 2,
|
||||
"method": "Accessibility.getFullAXTree",
|
||||
"params": params,
|
||||
}))
|
||||
|
||||
# Leer hasta recibir respuesta con id=2
|
||||
import asyncio as _asyncio
|
||||
async with _asyncio.timeout(10):
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
msg = json.loads(raw)
|
||||
if msg.get("id") == 2:
|
||||
if "error" in msg:
|
||||
raise RuntimeError(f"CDP error: {msg['error']}")
|
||||
return msg.get("result", {}).get("nodes", [])
|
||||
|
||||
result_holder: list = []
|
||||
error_holder: list = []
|
||||
|
||||
def _thread_run():
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
nodes = loop.run_until_complete(_run())
|
||||
result_holder.append(nodes)
|
||||
except Exception as e:
|
||||
error_holder.append(e)
|
||||
|
||||
t = threading.Thread(target=_thread_run, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=15)
|
||||
|
||||
if error_holder:
|
||||
raise error_holder[0]
|
||||
if not result_holder:
|
||||
raise TimeoutError("No se recibió respuesta en 15s")
|
||||
return result_holder[0]
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: cdp_open_url_and_wait
|
||||
kind: function
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def cdp_open_url_and_wait(debug_port: int, url: str, timeout_s: int = 30) -> str"
|
||||
description: "Crea tab nuevo en Chrome remoto via CDP, navega a URL y espera Page.loadEventFired. Devuelve tab_id."
|
||||
tags: [navegator, cdp, chrome, browser]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, threading, urllib.request, urllib.parse, websocket]
|
||||
params:
|
||||
- name: debug_port
|
||||
desc: "Puerto de depuracion remota de Chrome (tipicamente 9222)."
|
||||
- name: url
|
||||
desc: "URL completa a la que navegar en el tab nuevo."
|
||||
- name: timeout_s
|
||||
desc: "Segundos maximos esperando Page.loadEventFired. Default 30."
|
||||
output: "tab_id (str) del tab recien creado en Chrome."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/cdp_open_url_and_wait.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
|
||||
|
||||
tab_id = cdp_open_url_and_wait(9222, "https://example.com", timeout_s=15)
|
||||
print(tab_id) # "B1C2D3E4-..."
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites abrir una URL nueva en Chrome remoto y asegurarte de que la pagina cargo antes de interactuar con ella via CDP. Paso previo a cualquier extraccion de AX tree o ejecucion de JS.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Chrome debe estar corriendo con `--remote-debugging-port=<debug_port>` y `--remote-allow-origins=*`.
|
||||
- PUT a `/json/new?<url>` crea el tab; si Chrome no acepta PUT responde 404 (version antigua).
|
||||
- `Page.loadEventFired` puede no dispararse en SPAs con routing sin recarga — usar `timeout_s` conservador o esperar selector via `cdp_extract_recipe`.
|
||||
- `websocket-client` debe estar instalado en el venv.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Abre tab nuevo en Chrome remoto, navega a URL, espera Page.loadEventFired."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import websocket
|
||||
|
||||
|
||||
def cdp_open_url_and_wait(
|
||||
debug_port: int,
|
||||
url: str,
|
||||
timeout_s: int = 30,
|
||||
) -> str:
|
||||
"""Crea tab nuevo en Chrome remoto, navega a url, espera Page.loadEventFired.
|
||||
|
||||
Args:
|
||||
debug_port: Puerto de depuracion remota de Chrome (ej. 9222).
|
||||
url: URL a la que navegar.
|
||||
timeout_s: Timeout total en segundos para esperar el load event.
|
||||
|
||||
Returns:
|
||||
tab_id (string) del tab recien creado.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si Chrome no responde, la navegacion falla o se agota timeout.
|
||||
"""
|
||||
encoded = urllib.parse.quote(url, safe=":/?#[]@!$&'()*+,;=%")
|
||||
new_tab_url = f"http://127.0.0.1:{debug_port}/json/new?{encoded}"
|
||||
|
||||
req = urllib.request.Request(new_tab_url, method="PUT")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
tab_info = json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"cdp_open_url_and_wait: no se pudo crear tab en port {debug_port}: {e}") from e
|
||||
|
||||
tab_id = tab_info.get("id", "")
|
||||
ws_url = tab_info.get("webSocketDebuggerUrl", "")
|
||||
if not ws_url:
|
||||
raise RuntimeError(f"cdp_open_url_and_wait: tab sin webSocketDebuggerUrl: {tab_info}")
|
||||
|
||||
load_event = threading.Event()
|
||||
errors = []
|
||||
|
||||
def on_message(ws_app, message):
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
if msg.get("method") == "Page.loadEventFired":
|
||||
load_event.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_error(ws_app, error):
|
||||
errors.append(str(error))
|
||||
load_event.set()
|
||||
|
||||
def on_open(ws_app):
|
||||
ws_app.send(json.dumps({"id": 1, "method": "Page.enable", "params": {}}))
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
t = threading.Thread(target=ws.run_forever, daemon=True)
|
||||
t.start()
|
||||
|
||||
fired = load_event.wait(timeout=timeout_s)
|
||||
ws.close()
|
||||
|
||||
if errors:
|
||||
raise RuntimeError(f"cdp_open_url_and_wait: WS error: {errors[0]}")
|
||||
if not fired:
|
||||
raise RuntimeError(f"cdp_open_url_and_wait: timeout ({timeout_s}s) esperando Page.loadEventFired para {url}")
|
||||
|
||||
return tab_id
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: data_factory_record_run
|
||||
kind: function
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def data_factory_record_run(node_id, function_id, args=None, db_path=None, trigger='manual') -> dict"
|
||||
description: "Wrappea `fn run <function_id>` capturando rows/kb/duration y persiste el resultado en data_factory.db.runs. Requiere que el node_id exista previamente en nodes."
|
||||
tags: [data-pipeline, factory, record-run, pipelines, subprocess, registry]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: node_id
|
||||
desc: "ID del nodo en data_factory.db.nodes que es propietario de esta ejecucion. FK enforced — debe existir antes de llamar."
|
||||
- name: function_id
|
||||
desc: "ID de funcion del registry a ejecutar (se pasa a `fn run`). Ejemplo: 'bq_query_py_infra'."
|
||||
- name: args
|
||||
desc: "Lista de args CLI adicionales que se reenvian a `fn run` despues del function_id. Default None = sin args extra."
|
||||
- name: db_path
|
||||
desc: "Ruta absoluta a data_factory.db. Default: ${FN_REGISTRY_ROOT}/apps/data_factory/data_factory.db."
|
||||
- name: trigger
|
||||
desc: "Origen de la ejecucion: 'manual'|'cron'|'dag'|'api'. Default 'manual'."
|
||||
output: "dict con claves: run_id (str), status ('success'|'failed'), rows_out (int), kb_out (int), duration_ms (int), stdout (str), stderr (str)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/data_factory_record_run.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.environ["FN_REGISTRY_ROOT"] + "/python/functions/pipelines")
|
||||
from data_factory_record_run import data_factory_record_run
|
||||
|
||||
result = data_factory_record_run(
|
||||
node_id="bq_users_extractor",
|
||||
function_id="bq_query_py_infra",
|
||||
args=["--project", "my-gcp", "--sql", "SELECT * FROM users LIMIT 1000"],
|
||||
)
|
||||
print(f"run {result['run_id']}: {result['rows_out']} rows in {result['duration_ms']}ms")
|
||||
# run a3f1c8e2d7b04e91: 1000 rows in 4230ms
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un nodo del data_factory deba ejecutar una funcion del registry y dejar trazabilidad completa (duration, rows, error) en `data_factory.db`. Usa este wrapper en lugar de llamar `fn run` directamente desde el DAG engine o desde scripts de ingesta.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `FN_REGISTRY_ROOT` debe estar en el entorno — sin ella la funcion lanza `RuntimeError` inmediato.
|
||||
- El `node_id` debe existir en `nodes` antes del INSERT (FK con `ON DELETE CASCADE`). Si no existe, la funcion devuelve error claro en vez de silencio.
|
||||
- `rows_out` se parsea buscando patron `^(rows|extracted|written|count)[:= ]+(\d+)` en stdout. Si la funcion destino no imprime nada con ese patron, `rows_out=0` — esto es correcto, no un bug.
|
||||
- El binario `fn` se busca en `${FN_REGISTRY_ROOT}/fn`. Si no esta compilado, compilar con `CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/` desde la raiz del registry.
|
||||
- `db_path` apunta a la BD de la app data_factory, NO a `registry.db`.
|
||||
- Solo stdlib Python — sin pandas, polars ni dependencias externas.
|
||||
@@ -0,0 +1,152 @@
|
||||
"""data_factory_record_run — wraps `fn run <function_id>` and persists metrics in data_factory.db."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _now_iso8601() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _elapsed_ms(start: float) -> int:
|
||||
import time
|
||||
return int((time.monotonic() - start) * 1000)
|
||||
|
||||
|
||||
def _parse_rows_out(stdout: str) -> int:
|
||||
"""Parse first line matching rows/extracted/written/count[:= ]+N (case insensitive)."""
|
||||
pattern = re.compile(r'^(?:rows|extracted|written|count)[:=\s]+(\d+)', re.IGNORECASE | re.MULTILINE)
|
||||
m = pattern.search(stdout)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def _kb_out(stdout: str) -> int:
|
||||
return round(len(stdout.encode("utf-8")) / 1024)
|
||||
|
||||
|
||||
def data_factory_record_run(
|
||||
node_id: str,
|
||||
function_id: str,
|
||||
args: list | None = None,
|
||||
db_path: str | None = None,
|
||||
trigger: str = "manual",
|
||||
) -> dict:
|
||||
"""Wrap `fn run <function_id>` and record execution metrics in data_factory.db.
|
||||
|
||||
Args:
|
||||
node_id: ID of the node in data_factory.db.nodes that owns this run.
|
||||
function_id: Registry function ID to execute (passed to `fn run`).
|
||||
args: Extra CLI args forwarded to `fn run` after function_id.
|
||||
db_path: Absolute path to data_factory.db. Defaults to
|
||||
${FN_REGISTRY_ROOT}/apps/data_factory/data_factory.db.
|
||||
trigger: Origin of the run — 'manual'|'cron'|'dag'|'api'.
|
||||
|
||||
Returns:
|
||||
dict with keys: run_id, status, rows_out, kb_out, duration_ms, stdout, stderr.
|
||||
"""
|
||||
import time
|
||||
|
||||
# --- resolve FN_REGISTRY_ROOT ---
|
||||
registry_root = os.environ.get("FN_REGISTRY_ROOT", "").strip()
|
||||
if not registry_root:
|
||||
raise RuntimeError(
|
||||
"FN_REGISTRY_ROOT env var is not set. "
|
||||
"Export it before calling data_factory_record_run."
|
||||
)
|
||||
registry_root = Path(registry_root)
|
||||
|
||||
# --- resolve db_path ---
|
||||
if db_path is None:
|
||||
db_path = registry_root / "apps" / "data_factory" / "data_factory.db"
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"data_factory.db not found at {db_path}")
|
||||
|
||||
# --- resolve fn binary ---
|
||||
fn_bin = registry_root / "fn"
|
||||
if not fn_bin.exists():
|
||||
raise FileNotFoundError(
|
||||
f"fn binary not found at {fn_bin}. "
|
||||
"Run `CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/` in FN_REGISTRY_ROOT."
|
||||
)
|
||||
|
||||
# --- generate run_id ---
|
||||
run_id = uuid.uuid4().hex[:16]
|
||||
|
||||
# --- INSERT running record ---
|
||||
started_at = _now_iso8601()
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO runs(id, node_id, started_at, status, trigger) VALUES (?,?,?,?,?)",
|
||||
(run_id, node_id, started_at, "running", trigger),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError as e:
|
||||
conn.close()
|
||||
raise RuntimeError(
|
||||
f"FK violation — node_id '{node_id}' does not exist in nodes table. "
|
||||
f"Insert the node first. SQLite error: {e}"
|
||||
)
|
||||
except sqlite3.Error as e:
|
||||
raise RuntimeError(f"Failed to open/write data_factory.db at {db_path}: {e}")
|
||||
|
||||
# --- run fn ---
|
||||
cmd = [str(fn_bin), "run", function_id] + (args or [])
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(registry_root))
|
||||
except Exception as e:
|
||||
duration_ms = _elapsed_ms(t0)
|
||||
finished_at = _now_iso8601()
|
||||
conn.execute(
|
||||
"UPDATE runs SET finished_at=?, status=?, duration_ms=?, error=? WHERE id=?",
|
||||
(finished_at, "failed", duration_ms, str(e)[:2000], run_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": "failed",
|
||||
"rows_out": 0,
|
||||
"kb_out": 0,
|
||||
"duration_ms": duration_ms,
|
||||
"stdout": "",
|
||||
"stderr": str(e),
|
||||
}
|
||||
|
||||
duration_ms = _elapsed_ms(t0)
|
||||
finished_at = _now_iso8601()
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
status = "success" if result.returncode == 0 else "failed"
|
||||
rows_out = _parse_rows_out(stdout)
|
||||
kb = _kb_out(stdout)
|
||||
error_text = stderr[:2000] if status == "failed" else ""
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE runs
|
||||
SET finished_at=?, status=?, rows_out=?, kb_out=?,
|
||||
duration_ms=?, error=?
|
||||
WHERE id=?""",
|
||||
(finished_at, status, rows_out, kb, duration_ms, error_text, run_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": status,
|
||||
"rows_out": rows_out,
|
||||
"kb_out": kb,
|
||||
"duration_ms": duration_ms,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
Reference in New Issue
Block a user