#!/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()