Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Helper para mutaciones seguras de workflows n8n via API REST.
Reusable. Patrón base extraído de `n8n/_add_automap_node.py` y de la memoria
acumulada sobre la API de n8n (la API publica acepta PUT con
`{name,nodes,connections,settings}`; el campo `settings` debe filtrarse a un
whitelist; tras un PUT estructural el workflow puede quedar inactivo; el
endpoint de activación/desactivación es POST /activate, POST /deactivate).
Ejemplo:
from scripts.n8n_workflow_lib import load_credentials, N8NClient
client = N8NClient(*load_credentials())
wf, backup_path = client.backup_workflow(WID, label='fase_1')
new_node = {...}
client.add_node(wf, new_node)
client.insert_between(wf, 'Datos API Cuenta objetivo - MARCA',
new_node['name'], 'Buscar Contacto Objetivo - MARCA (phone)')
client.put_workflow(WID, wf, dry_run=True) # dump a n8n/dryrun_*.json
client.put_workflow(WID, wf, dry_run=False) # aplica real
client.verify_post(WID, expected_node_names=[new_node['name']], prev_version_id=wf['versionId'])
client.activate(WID)
"""
import argparse
import copy
import datetime
import json
import os
import re
import sys
import uuid
import urllib.error
import urllib.request
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
N8N_DIR = os.path.join(ROOT_DIR, "n8n")
# Whitelist confirmada en `_add_automap_node.py`. Si n8n agrega settings nuevos
# útiles, actualizar aquí (una sola fuente de verdad).
ALLOWED_SETTINGS = {
"saveExecutionProgress",
"saveManualExecutions",
"saveDataErrorExecution",
"saveDataSuccessExecution",
"executionTimeout",
"errorWorkflow",
"timezone",
"executionOrder",
}
class AlreadyExistsError(RuntimeError):
pass
class PutFailedError(RuntimeError):
pass
class VerificationFailedError(RuntimeError):
pass
# ---------------------------------------------------------------------------
def load_credentials(path=None):
"""Lee credenciales n8n del archivo plano `API:` / `URL:`.
Devuelve (api_key, base_url_sin_slash_final).
"""
if path is None:
path = os.path.join(N8N_DIR, "n8n credencials.txt")
api_key = base_url = None
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
if line.startswith("API:"):
api_key = line.split("API:", 1)[1].strip()
elif line.startswith("URL:"):
base_url = line.split("URL:", 1)[1].strip().rstrip("/")
if not api_key or not base_url:
raise RuntimeError(f"Credenciales incompletas en {path}")
return api_key, base_url
# ---------------------------------------------------------------------------
class N8NClient:
def __init__(self, api_key, base_url, timeout=60):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.headers = {
"X-N8N-API-KEY": api_key,
"Accept": "application/json",
"Content-Type": "application/json",
}
# ---- HTTP base ----
def _request(self, method, path, body=None):
url = f"{self.base_url}{path}"
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, method=method, data=data, headers=self.headers)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
txt = resp.read().decode("utf-8", errors="replace")
return resp.status, (json.loads(txt) if txt else None)
except urllib.error.HTTPError as e:
txt = e.read().decode("utf-8", errors="replace")
try:
payload = json.loads(txt)
except Exception:
payload = txt[:500]
raise PutFailedError(f"HTTP {e.code} {method} {path}: {payload}")
# ---- Workflow CRUD ----
def get_workflow(self, wid):
_, wf = self._request("GET", f"/api/v1/workflows/{wid}")
return wf
def backup_workflow(self, wid, label="generic"):
wf = self.get_workflow(wid)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
safe_label = re.sub(r"[^A-Za-z0-9_-]", "_", label)
os.makedirs(N8N_DIR, exist_ok=True)
path = os.path.join(N8N_DIR, f"backup_{safe_label}_{wid}_{ts}.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(wf, fh, ensure_ascii=False, indent=2)
return wf, path
def filter_settings(self, wf):
s = wf.get("settings") or {}
return {k: v for k, v in s.items() if k in ALLOWED_SETTINGS}
def put_workflow(self, wid, wf, dry_run=True):
"""Si dry_run=True dumpea el payload a n8n/dryrun_*.json y no llama API.
Si dry_run=False hace PUT real y retorna el workflow actualizado."""
payload = {
"name": wf["name"],
"nodes": wf["nodes"],
"connections": wf["connections"],
"settings": self.filter_settings(wf),
}
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
if dry_run:
os.makedirs(N8N_DIR, exist_ok=True)
path = os.path.join(N8N_DIR, f"dryrun_{wid}_{ts}.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
return {"dry_run": True, "path": path, "node_count": len(payload["nodes"])}
status, body = self._request("PUT", f"/api/v1/workflows/{wid}", body=payload)
if status != 200:
raise PutFailedError(f"PUT status {status}: {body}")
return body
def activate(self, wid):
status, body = self._request("POST", f"/api/v1/workflows/{wid}/activate")
return body
def deactivate(self, wid):
status, body = self._request("POST", f"/api/v1/workflows/{wid}/deactivate")
return body
def restore_from_backup(self, path):
"""Lee el JSON de backup y hace PUT. Devuelve el nuevo workflow."""
with open(path, "r", encoding="utf-8") as fh:
wf = json.load(fh)
wid = wf["id"]
was_active = bool(wf.get("active"))
# Desactivar primero si está activo (necesario para PUT estructural en algunos casos).
try:
self.deactivate(wid)
except Exception:
pass
result = self.put_workflow(wid, wf, dry_run=False)
if was_active:
self.activate(wid)
return result
# ---- Verificación ----
def verify_post(self, wid, expected_node_names=None, prev_version_id=None):
wf = self.get_workflow(wid)
errors = []
if prev_version_id and wf.get("versionId") == prev_version_id:
errors.append(f"versionId no cambió (sigue siendo {prev_version_id})")
nodes_by_name = {n["name"] for n in wf.get("nodes") or []}
for name in (expected_node_names or []):
if name not in nodes_by_name:
errors.append(f"nodo esperado ausente: {name!r}")
if errors:
raise VerificationFailedError("; ".join(errors))
return wf
# ---- Mutaciones in-memory (operan sobre wf dict, no llaman API) ----
@staticmethod
def find_node(wf, name):
for n in wf.get("nodes") or []:
if n["name"] == name:
return n
return None
@staticmethod
def assert_idempotent(wf, new_name):
if N8NClient.find_node(wf, new_name) is not None:
raise AlreadyExistsError(f"El nodo {new_name!r} ya existe en el workflow.")
@staticmethod
def add_node(wf, node_dict):
"""Append a `nodes`. Si no trae `id`, se genera uno UUIDv4."""
if "id" not in node_dict:
node_dict["id"] = str(uuid.uuid4())
wf.setdefault("nodes", []).append(node_dict)
return node_dict
@staticmethod
def get_connection_targets(wf, src, output_kind="main", output_index=0):
"""Devuelve la lista de {node, type, index} que salen de `src` por output `output_index`."""
conns = wf.get("connections") or {}
node = conns.get(src) or {}
branches = node.get(output_kind) or []
if output_index >= len(branches):
return []
return branches[output_index] or []
@staticmethod
def set_connection(wf, src, dst, output_kind="main", output_index=0, dst_input_index=0):
"""Sobrescribe el sink de src→[output_index] dejando UN solo destino dst."""
conns = wf.setdefault("connections", {})
node = conns.setdefault(src, {})
branches = node.setdefault(output_kind, [])
while len(branches) <= output_index:
branches.append([])
branches[output_index] = [{"node": dst, "type": output_kind, "index": dst_input_index}]
@staticmethod
def append_connection(wf, src, dst, output_kind="main", output_index=0, dst_input_index=0):
"""Agrega dst al sink de src sin borrar los existentes."""
conns = wf.setdefault("connections", {})
node = conns.setdefault(src, {})
branches = node.setdefault(output_kind, [])
while len(branches) <= output_index:
branches.append([])
branches[output_index].append({"node": dst, "type": output_kind, "index": dst_input_index})
@staticmethod
def insert_between(wf, src, mid, dst, output_kind="main", output_index=0):
"""Reemplaza src→dst por src→mid→dst. Preserva el resto de salidas de src."""
N8NClient.set_connection(wf, src, mid, output_kind, output_index)
N8NClient.set_connection(wf, mid, dst, output_kind, 0)
@staticmethod
def branch_if(wf, if_node_name, true_target, false_target):
"""Para nodos IF: configura output 0 (true) y output 1 (false)."""
N8NClient.set_connection(wf, if_node_name, true_target, "main", 0)
N8NClient.set_connection(wf, if_node_name, false_target, "main", 1)
@staticmethod
def rename_node(wf, old_name, new_name):
"""Renombra un nodo y actualiza TODAS las referencias $('OLD') y connections."""
node = N8NClient.find_node(wf, old_name)
if not node:
raise KeyError(f"Nodo {old_name!r} no existe.")
if N8NClient.find_node(wf, new_name) is not None:
raise AlreadyExistsError(f"Ya existe un nodo con nombre {new_name!r}.")
node["name"] = new_name
# Referencias $('OLD') dentro de parameters (jsonBody, url, jsCode, value, expression).
old_re = re.compile(r"\$\(\s*['\"]" + re.escape(old_name) + r"['\"]\s*\)")
def walk(obj):
if isinstance(obj, dict):
return {k: walk(v) for k, v in obj.items()}
if isinstance(obj, list):
return [walk(v) for v in obj]
if isinstance(obj, str):
return old_re.sub(f"$('{new_name}')", obj)
return obj
for n in wf.get("nodes") or []:
n["parameters"] = walk(n.get("parameters") or {})
# connections: claves (src) y values (dst entries con .node).
conns = wf.get("connections") or {}
if old_name in conns:
conns[new_name] = conns.pop(old_name)
for src, outs in conns.items():
for kind, branches in (outs or {}).items():
for branch in branches or []:
for entry in branch or []:
if entry.get("node") == old_name:
entry["node"] = new_name
wf["connections"] = conns
return node
# ---------------------------------------------------------------------------
# Self-test
# ---------------------------------------------------------------------------
def self_test(wid):
client = N8NClient(*load_credentials())
print(f"[self-test] GET workflow {wid}...")
wf = client.get_workflow(wid)
print(f" name: {wf.get('name')} active: {wf.get('active')} nodes: {len(wf.get('nodes') or [])}")
print(f" versionId: {wf.get('versionId')}")
print(f"[self-test] backup...")
_, path = client.backup_workflow(wid, label="self_test")
print(f" -> {path}")
print(f" size: {os.path.getsize(path)} bytes")
# In-memory: insertar un nodo dummy y hacer dry-run, sin tocar n8n.
wf2 = copy.deepcopy(wf)
dummy_name = "self_test_dummy_node_DELETE_ME"
try:
client.assert_idempotent(wf2, dummy_name)
except AlreadyExistsError as e:
print(f" WARN: {e} (saltando inserción)")
return
dummy = {
"parameters": {},
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [0, 0],
"name": dummy_name,
"notes": "self-test dummy. Si esto llega a n8n, alguien aplicó el dry-run sin querer.",
}
client.add_node(wf2, dummy)
print(f"[self-test] dry-run PUT...")
res = client.put_workflow(wid, wf2, dry_run=True)
print(f" dry_run path: {res['path']} nodes en payload: {res['node_count']}")
# Verificar que el dryrun JSON tiene exactamente 1 nodo más que el original.
with open(res["path"], "r", encoding="utf-8") as fh:
dryrun = json.load(fh)
diff_count = len(dryrun["nodes"]) - len(wf["nodes"])
if diff_count != 1:
raise SystemExit(f"FAIL: dryrun tiene delta de nodos = {diff_count} (esperado 1)")
print(f" [OK] delta nodos = +1 (correcto)")
# Confirmar que el dummy NO está en n8n live.
wf_live = client.get_workflow(wid)
has_dummy = client.find_node(wf_live, dummy_name) is not None
if has_dummy:
raise SystemExit("FAIL: dummy llegó a n8n live (dry-run no fue dry).")
print(" [OK] workflow live intacto (dry-run respetado)")
print(f"[self-test] OK")
def main():
parser = argparse.ArgumentParser(description="n8n workflow lib helper.")
parser.add_argument("--self-test", metavar="WID", help="Correr self-test contra un workflow id.")
args = parser.parse_args()
if args.self_test:
self_test(args.self_test)
else:
parser.print_help()
if __name__ == "__main__":
main()