# -*- coding: utf-8 -*- """Erzeugt workflows/brief-vorbereiten.json fuer n8n 1.120.x""" import json, os, uuid CRED_NW = {"httpHeaderAuth": {"id": "U23n2O980GKWocWq", "name": "NeuronWriter API"}} CRED_GITEA = {"httpHeaderAuth": {"id": "VOD4JM5zwpSC8wAr", "name": "Gitea Skrift Auto Article"}} nodes, conns = [], {} def nid(): return str(uuid.uuid4()) def node(name, typ, tv, pos, params, **kw): n = {"parameters": params, "type": typ, "typeVersion": tv, "position": pos, "id": nid(), "name": name} n.update(kw) nodes.append(n) return name def link(a, b, out=0, inp=0): conns.setdefault(a, {"main": []}) while len(conns[a]["main"]) <= out: conns[a]["main"].append([]) conns[a]["main"][out].append({"node": b, "type": "main", "index": inp}) def http(name, pos, method, url, cred, body=None, qs=None, error_out=True, retry=True, extra=None): p = { "method": method, "url": url, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": {"response": {"response": {"neverError": False}}, "timeout": 60000}, } if qs: p["sendQuery"] = True p["specifyQuery"] = "json" p["jsonQuery"] = qs if body is not None: p["sendBody"] = True p["specifyBody"] = "json" p["jsonBody"] = body p["sendHeaders"] = True p["specifyHeaders"] = "json" p["jsonHeaders"] = '={"Content-Type":"application/json"}' if extra: p.update(extra) kw = {"credentials": cred} if error_out: kw["onError"] = "continueErrorOutput" if retry: kw["retryOnFail"] = True kw["maxTries"] = 3 kw["waitBetweenTries"] = 3000 return node(name, "n8n-nodes-base.httpRequest", 4.3, pos, p, **kw) def code(name, pos, js, mode="runOnceForAllItems", **kw): return node(name, "n8n-nodes-base.code", 2, pos, {"mode": mode, "jsCode": js}, **kw) def iff(name, pos, left, op_type, op, right=None, **kw): c = {"id": nid(), "leftValue": left, "rightValue": right if right is not None else "", "operator": {"type": op_type, "operation": op}} if right is None: c["operator"]["singleValue"] = True return node(name, "n8n-nodes-base.if", 2.2, pos, { "conditions": {"options": {"caseSensitive": True, "leftValue": "", "typeValidation": "loose", "version": 2}, "conditions": [c], "combinator": "and"}, "looseTypeValidation": True, "options": {}}, **kw) # ---------------------------------------------------------------- Trigger node("Manuell starten", "n8n-nodes-base.manualTrigger", 1, [-460, 40], {}) node("Zeitplan Montag 07:00", "n8n-nodes-base.scheduleTrigger", 1.2, [-460, 200], {"rule": {"interval": [{"field": "weeks", "triggerAtDay": [1], "triggerAtHour": 7, "triggerAtMinute": 0}]}}, disabled=True) node("Webhook Start", "n8n-nodes-base.webhook", 2.1, [-460, 360], {"httpMethod": "POST", "path": "brief-vorbereiten", "responseMode": "onReceived", "options": {}}, webhookId=str(uuid.uuid4())) # ---------------------------------------------------------------- Konfiguration KONF = node("Konfiguration", "n8n-nodes-base.set", 3.4, [-240, 200], { "mode": "manual", "assignments": {"assignments": [ {"id": nid(), "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string"}, {"id": nid(), "name": "nw_project", "value": "3da4d745e0e223cb", "type": "string"}, {"id": nid(), "name": "nw_engine", "value": "google.de", "type": "string"}, {"id": nid(), "name": "nw_language", "value": "German", "type": "string"}, {"id": nid(), "name": "gitea_base", "value": "http://gitea:3000", "type": "string"}, {"id": nid(), "name": "gitea_owner", "value": "lucas.orth", "type": "string"}, {"id": nid(), "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string"}, {"id": nid(), "name": "branch", "value": "main", "type": "string"}, {"id": nid(), "name": "index_pfad", "value": "artikel-index.json", "type": "string"}, {"id": nid(), "name": "brief_ordner","value": "briefe", "type": "string"}, {"id": nid(), "name": "poll_max", "value": 30, "type": "number"}, {"id": nid(), "name": "poll_sekunden","value": 20, "type": "number"}, {"id": nid(), "name": "pause_zwischen_artikeln", "value": 5, "type": "number"}, {"id": nid(), "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string"}, ]}, "options": {}}) for t in ["Manuell starten", "Zeitplan Montag 07:00", "Webhook Start"]: link(t, KONF) # ---------------------------------------------------------------- Tabelle lesen LESEN = http("Artikeltabelle lesen", [-20, 200], "GET", "={{ $json.gitea_base }}/api/v1/repos/{{ $json.gitea_owner }}/{{ $json.gitea_repo }}/contents/{{ $json.index_pfad }}?ref={{ $json.branch }}", CRED_GITEA) link(KONF, LESEN) FEHLER_LESEN = code("Fehler Artikeltabelle", [-20, 420], """ const e = $input.first().json; throw new Error( 'Artikeltabelle konnte nicht gelesen werden. Pruefen: Gitea-Token-Scope (write:repository noetig), ' + 'Pfad artikel-index.json im Branch main, Repo-Name. Antwort: ' + JSON.stringify(e).slice(0, 600) ); """) link(LESEN, FEHLER_LESEN, out=1) AUSWERTEN = code("Tabelle auswerten", [200, 200], """ // Gitea liefert den Dateiinhalt base64-kodiert. const antwort = $input.first().json; if (!antwort.content) { throw new Error('Gitea-Antwort enthaelt kein Feld "content". Erhalten: ' + JSON.stringify(antwort).slice(0,400)); } const roh = Buffer.from(antwort.content, 'base64').toString('utf8'); let tabelle; try { tabelle = JSON.parse(roh); } catch (e) { throw new Error('artikel-index.json ist kein gueltiges JSON: ' + e.message); } if (!Array.isArray(tabelle)) { throw new Error('artikel-index.json muss ein Array sein, ist aber: ' + typeof tabelle); } const konf = $('Konfiguration').first().json; const pflicht = ['slug','titel','keyword','kategorie','cta_ziel','status']; const offen = []; tabelle.forEach((zeile, i) => { if (zeile.status !== 'offen') return; const fehlt = pflicht.filter(f => !zeile[f]); if (fehlt.length) { throw new Error(`Zeile ${i} (slug: ${zeile.slug || '?'}) fehlen Pflichtfelder: ${fehlt.join(', ')}`); } offen.push({ json: { ...zeile, zeilen_index: i } }); }); // Kontext fuer spaetere Nodes einmal ablegen const kontext = { index_sha: antwort.sha, tabelle_gesamt: tabelle, anzahl_offen: offen.length }; $getWorkflowStaticData('global').lauf = kontext; if (offen.length === 0) return [{ json: { ...kontext, leer: true } }]; return offen.map(o => ({ json: { ...o.json, index_sha: antwort.sha } })); """) link(LESEN, AUSWERTEN, out=0) GIBTS = iff("Offene Artikel vorhanden?", [420, 200], "={{ $json.leer === true }}", "boolean", "true") link(AUSWERTEN, GIBTS) NICHTS = node("Nichts zu tun", "n8n-nodes-base.noOp", 1, [640, 60], {}) link(GIBTS, NICHTS, out=0) # ---------------------------------------------------------------- Loop LOOP = node("Artikel nacheinander", "n8n-nodes-base.splitInBatches", 3, [640, 320], {"batchSize": 1, "options": {"reset": False}}) link(GIBTS, LOOP, out=1) # ---------------------------------------------------------------- Analyse starten NEU = http("Analyse starten", [900, 420], "POST", "={{ $('Konfiguration').first().json.nw_base }}/new-query", CRED_NW, body="={{ JSON.stringify({ project: $('Konfiguration').first().json.nw_project, keyword: $json.keyword, engine: $('Konfiguration').first().json.nw_engine, language: $('Konfiguration').first().json.nw_language }) }}") link(LOOP, NEU, out=1) FEHLER_NEU = code("Fehler Analyse starten", [900, 640], """ const artikel = $('Artikel nacheinander').first().json; const e = $input.first().json; return [{ json: { slug: artikel.slug, keyword: artikel.keyword, ergebnis: 'fehler', stufe: 'new-query', meldung: 'NeuronWriter /new-query fehlgeschlagen fuer Keyword "' + artikel.keyword + '". Haeufige Ursachen: Query-Kontingent erschoepft, falsche Projekt-ID, API-Key ungueltig. Antwort: ' + JSON.stringify(e).slice(0, 500) }}]; """, onError="continueRegularOutput") link(NEU, FEHLER_NEU, out=1) POLLSTART = code("Poll vorbereiten", [1120, 420], """ const artikel = $('Artikel nacheinander').first().json; const neu = $input.first().json; if (!neu.query) { throw new Error('NeuronWriter lieferte kein Feld "query". Antwort: ' + JSON.stringify(neu).slice(0,400)); } return [{ json: { ...artikel, query: neu.query, query_url: neu.query_url, share_url: neu.share_url, versuch: 0 } }]; """) link(NEU, POLLSTART, out=0) WARTEN = node("Warten", "n8n-nodes-base.wait", 1.1, [1340, 420], {"amount": "={{ $('Konfiguration').first().json.poll_sekunden }}", "unit": "seconds"}, webhookId=str(uuid.uuid4())) link(POLLSTART, WARTEN) ABFRAGE = http("Analyse abfragen", [1560, 420], "POST", "={{ $('Konfiguration').first().json.nw_base }}/get-query", CRED_NW, body="={{ JSON.stringify({ query: $json.query }) }}") link(WARTEN, ABFRAGE) FEHLER_ABFRAGE = code("Fehler Analyse abfragen", [1560, 640], """ const a = $('Poll vorbereiten').first().json; return [{ json: { slug: a.slug, keyword: a.keyword, query: a.query, ergebnis: 'fehler', stufe: 'get-query', meldung: 'NeuronWriter /get-query fehlgeschlagen fuer query ' + a.query + '. Antwort: ' + JSON.stringify($input.first().json).slice(0, 500) }}]; """, onError="continueRegularOutput") link(ABFRAGE, FEHLER_ABFRAGE, out=1) FERTIG = iff("Analyse fertig?", [1780, 420], "={{ $json.status }}", "string", "equals", "ready") link(ABFRAGE, FERTIG, out=0) ZAEHLEN = code("Versuch zaehlen", [1780, 660], """ const poll = $('Poll vorbereiten').first().json; const antwort = $input.first().json; const versuch = (poll.versuch || 0) + $runIndex + 1; return [{ json: { ...poll, versuch, letzter_status: antwort.status || 'unbekannt' } }]; """) link(FERTIG, ZAEHLEN, out=1) LIMIT = iff("Poll-Limit erreicht?", [2000, 660], "={{ $json.versuch }}", "number", "gte", "={{ $('Konfiguration').first().json.poll_max }}") link(ZAEHLEN, LIMIT) TIMEOUT = code("Timeout melden", [2220, 780], """ const a = $input.first().json; return [{ json: { slug: a.slug, keyword: a.keyword, query: a.query, ergebnis: 'fehler', stufe: 'poll-timeout', meldung: 'NeuronWriter-Analyse fuer "' + a.keyword + '" war nach ' + a.versuch + ' Versuchen noch nicht fertig (letzter Status: ' + a.letzter_status + '). Analyse laeuft ggf. weiter, query merken: ' + a.query }}]; """) link(LIMIT, TIMEOUT, out=0) link(LIMIT, WARTEN, out=1) # noch Versuche uebrig -> weiter pollen json.dump({"nodes": nodes, "connections": conns}, open('/tmp/wf1_teil1.json','w'), indent=1) print("Teil 1 fertig:", len(nodes), "Nodes") # ---------------------------------------------------------------- Brief bauen BRIEF = code("Brief bauen", [2220, 300], r""" // Feldnamen stammen aus einer echten /get-query-Antwort, siehe docs/feldnachweis.md const a = $('Poll vorbereiten').first().json; const n = $input.first().json; const liste = (arr) => (arr || []).map(x => ({ begriff: x.t, min: Array.isArray(x.sugg_usage) ? x.sugg_usage[0] : null, max: Array.isArray(x.sugg_usage) ? x.sugg_usage[1] : null, bei_wettbewerbern_pc: x.usage_pc ?? null, })); const fragen = []; const gesehen = new Set(); const addFrage = (q, quelle, gewicht) => { if (!q) return; const k = q.trim().toLowerCase(); if (gesehen.has(k)) return; gesehen.add(k); fragen.push({ frage: q.trim(), quelle, gewicht }); }; Object.entries((n.ideas && n.ideas.topic_matrix) || {}) .sort((x, y) => (y[1].importance || 0) - (x[1].importance || 0)) .forEach(([q, v]) => addFrage(q, 'topic_matrix', v.importance || null)); ((n.ideas || {}).people_also_ask || []).forEach(x => addFrage(x.q, 'people_also_ask', null)); ((n.ideas || {}).suggest_questions || []).forEach(x => addFrage(x.q, 'suggest_questions', null)); ((n.ideas || {}).content_questions || []).forEach(x => addFrage(x.q, 'content_questions', null)); const brief = { // --- die vom Auftrag geforderten Felder --- query_id: n.query, keyword: n.keyword, begriffe_pflicht: liste((n.terms || {}).content_basic), begriffe_erweitert: liste((n.terms || {}).content_extended), fragen, wortzahl_ziel: ((n.metrics || {}).word_count || {}).target ?? null, // --- Artikelkontext aus der Tabelle --- artikel: { slug: a.slug, titel: a.titel, kategorie: a.kategorie, cta_ziel: a.cta_ziel }, // --- Zusatz fuer die Schreibvorlage --- zusatz: { wortzahl_median: ((n.metrics || {}).word_count || {}).median ?? null, lesbarkeit_ziel: ((n.metrics || {}).readability || {}).target ?? null, begriffe_titel: ((n.terms || {}).title || []).map(x => x.t), begriffe_description: ((n.terms || {}).desc || []).map(x => x.t), begriffe_h1: ((n.terms || {}).h1 || []).map(x => x.t), begriffe_h2: ((n.terms || {}).h2 || []).map(x => x.t), entitaeten: ((n.terms || {}).entities || []).slice(0, 15).map(x => ({ name: x.t, wichtigkeit: x.importance })), suchintention: (n.serp_summary || {}).top_intent ?? null, haeufigster_inhaltstyp: (n.serp_summary || {}).top_content_type ?? null, gliederungen_top5: (n.competitors || []).slice(0, 5).map(c => ({ rang: c.rank, url: c.url, score: c.content_score, wortzahl: c.word_count, ueberschriften: (c.headers || []).map(h => `${h[0]}: ${h[1]}`).slice(0, 25), })), terms_txt_content_basic: ((n.terms_txt || {}).content_basic_w_ranges) ?? null, }, schreibregeln: { preise: 'Preise niemals ausschreiben. Immer den Shortcode einsetzen: ' + '[skrift_preis key="SCHLUESSEL" ab="0|1" brutto="0|1"]. ' + 'key ist der Produktschluessel (z. B. briefe, postkarten, porto_inland). ' + 'ab=1 liefert den guenstigsten Staffelpreis, ab=0 den Standardpreis. ' + 'brutto=1 inkl. MwSt., brutto=0 netto. Das Wort "ab" selbst gehoert in den Fliesstext, ' + 'der Shortcode liefert nur die Zahl. Beispiel: ' + 'Handschriftliche Briefe ab [skrift_preis key="briefe" ab="1"] pro Stueck. ' + 'Ein unbekannter key ergibt eine leere Ausgabe, keinen Fehler.', faq: 'FAQ nicht in content_html schreiben, sondern als Feld faq mit Frage-Antwort-Paaren. ' + 'Die Ausgabe im Beitrag uebernimmt der Shortcode [skrift_faq].', kurzantwort: 'Ein Absatz, der die Hauptfrage direkt beantwortet, als eigenes Feld kurzantwort.', }, meta: { erzeugt_am: new Date().toISOString(), analyse_url: n.query_url || a.query_url || null, freigabe_url: n.share_url || a.share_url || null, sprache: n.language, suchmaschine: n.engine, projekt: n.project, }, }; return [{ json: { slug: a.slug, zeilen_index: a.zeilen_index, query: n.query, keyword: n.keyword, brief } }]; """) link(FERTIG, BRIEF, out=0) PRUEF = http("Brief vorhanden?", [2440, 300], "GET", "={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $('Konfiguration').first().json.brief_ordner }}/brief-{{ $json.slug }}.json?ref={{ $('Konfiguration').first().json.branch }}", CRED_GITEA, error_out=False, retry=False, extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}}) link(BRIEF, PRUEF) COMMIT_VOR = code("Commit vorbereiten", [2660, 300], """ const b = $('Brief bauen').first().json; const p = $input.first().json; // n8n liefert den Koerper bei fullResponse je nach Version als body oder data. const K = (r) => (r && r.body !== undefined) ? r.body : ((r || {}).data); const kp = K(p); const vorhanden = p.statusCode === 200 && kp && kp.sha; const inhalt = Buffer.from(JSON.stringify(b.brief, null, 2), 'utf8').toString('base64'); const konf = $('Konfiguration').first().json; return [{ json: { ...b, methode: vorhanden ? 'PUT' : 'POST', pfad: `${konf.brief_ordner}/brief-${b.slug}.json`, commit_body: JSON.stringify({ content: inhalt, message: `Brief ${vorhanden ? 'aktualisiert' : 'erzeugt'}: ${b.slug} (${b.keyword})`, branch: konf.branch, ...(vorhanden ? { sha: kp.sha } : {}), }), }}]; """) link(PRUEF, COMMIT_VOR) COMMIT = http("Brief committen", [2880, 300], "={{ $json.methode }}", "={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $json.pfad }}", CRED_GITEA, body="={{ $json.commit_body }}") link(COMMIT_VOR, COMMIT) FEHLER_COMMIT = code("Fehler Brief committen", [2880, 520], """ const b = $('Brief bauen').first().json; return [{ json: { slug: b.slug, keyword: b.keyword, query: b.query, ergebnis: 'fehler', stufe: 'gitea-commit', meldung: 'Brief konnte nicht ins Repo geschrieben werden. Haeufigste Ursache: Gitea-Token ohne ' + 'Scope write:repository. Antwort: ' + JSON.stringify($input.first().json).slice(0, 500) }}]; """, onError="continueRegularOutput") link(COMMIT, FEHLER_COMMIT, out=1) ERFOLG = code("Erfolg merken", [3100, 300], """ const b = $('Brief bauen').first().json; return [{ json: { slug: b.slug, keyword: b.keyword, query: b.query, zeilen_index: b.zeilen_index, ergebnis: 'ok', neuer_status: 'brief_bereit', pfad: $('Commit vorbereiten').first().json.pfad, wortzahl_ziel: b.brief.wortzahl_ziel, anzahl_pflichtbegriffe: b.brief.begriffe_pflicht.length, anzahl_fragen: b.brief.fragen.length, analyse_url: b.brief.meta.analyse_url, }}]; """) link(COMMIT, ERFOLG, out=0) PAUSE = node("Pause Rate Limit", "n8n-nodes-base.wait", 1.1, [3320, 400], {"amount": "={{ $('Konfiguration').first().json.pause_zwischen_artikeln }}", "unit": "seconds"}, webhookId=str(uuid.uuid4())) for q in [ERFOLG, FEHLER_COMMIT, FEHLER_NEU, FEHLER_ABFRAGE, TIMEOUT]: link(q, PAUSE) link(PAUSE, LOOP) # ---------------------------------------------------------------- Nach dem Loop SAMMELN = code("Ergebnisse sammeln", [900, 140], """ const alle = $input.all().map(i => i.json); const tabelle = $('Tabelle auswerten').first().json.tabelle_gesamt || $getWorkflowStaticData('global').lauf.tabelle_gesamt; const index_sha = $('Artikeltabelle lesen').first().json.sha; const kopie = JSON.parse(JSON.stringify(tabelle)); let geaendert = 0; alle.filter(r => r.ergebnis === 'ok').forEach(r => { const i = kopie.findIndex(z => z.slug === r.slug); if (i >= 0) { kopie[i].status = 'brief_bereit'; kopie[i].query_id = r.query; geaendert++; } }); const ok = alle.filter(r => r.ergebnis === 'ok'); const fehler = alle.filter(r => r.ergebnis === 'fehler'); return [{ json: { index_sha, geaendert, neue_tabelle: JSON.stringify(kopie, null, 2), ok, fehler, anzahl_ok: ok.length, anzahl_fehler: fehler.length, }}]; """) link(LOOP, SAMMELN, out=0) STATUS_VOR = code("Statusupdate vorbereiten", [1120, 140], """ const s = $input.first().json; const konf = $('Konfiguration').first().json; return [{ json: { ...s, commit_body: JSON.stringify({ content: Buffer.from(s.neue_tabelle, 'utf8').toString('base64'), message: `Status auf brief_bereit gesetzt (${s.geaendert} Artikel)`, branch: konf.branch, sha: s.index_sha, })}}]; """) link(SAMMELN, STATUS_VOR) STATUS = http("Status zurueckschreiben", [1340, 140], "PUT", "={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $('Konfiguration').first().json.index_pfad }}", CRED_GITEA, body="={{ $json.commit_body }}") link(STATUS_VOR, STATUS) FEHLER_STATUS = code("Fehler Statusupdate", [1340, -60], """ throw new Error('Status konnte nicht zurueckgeschrieben werden. Die Briefe sind erzeugt, aber die ' + 'Artikeltabelle steht noch auf "offen". Vor dem naechsten Lauf pruefen, sonst werden Analysen ' + 'doppelt erzeugt. Antwort: ' + JSON.stringify($input.first().json).slice(0, 500)); """) link(STATUS, FEHLER_STATUS, out=1) MAIL_TEXT = code("Meldung formulieren", [1560, 140], r""" const s = $('Ergebnisse sammeln').first().json; const z = []; z.push(`Briefe vorbereitet: ${s.anzahl_ok}, Fehler: ${s.anzahl_fehler}`); z.push(''); if (s.ok.length) { z.push('Fertig:'); s.ok.forEach(r => z.push( ` ${r.slug} | Zielwortzahl ${r.wortzahl_ziel} | ${r.anzahl_pflichtbegriffe} Pflichtbegriffe, ` + `${r.anzahl_fragen} Fragen\n Datei: ${r.pfad}\n Analyse: ${r.analyse_url}`)); z.push(''); } if (s.fehler.length) { z.push('Fehler:'); s.fehler.forEach(r => z.push(` ${r.slug || '?'} [${r.stufe}]: ${r.meldung}`)); z.push(''); } z.push('Naechster Schritt: git pull, dann die Briefe in Cowork als Schreibvorlage nutzen.'); return [{ json: { betreff: `Skrift Ratgeber: ${s.anzahl_ok} Brief(e) bereit` + (s.anzahl_fehler ? `, ${s.anzahl_fehler} Fehler` : ''), text: z.join('\n'), }}]; """) link(STATUS, MAIL_TEXT, out=0) node("Benachrichtigung", "n8n-nodes-base.emailSend", 2.1, [1780, 140], { "fromEmail": "", "toEmail": "={{ $('Konfiguration').first().json.benachrichtigung_an }}", "subject": "={{ $json.betreff }}", "emailFormat": "text", "text": "={{ $json.text }}", "options": {}}, disabled=True, notes="SMTP-Credential zuweisen, Absender eintragen, dann Node aktivieren.") link(MAIL_TEXT, "Benachrichtigung") wf = { "name": "brief-vorbereiten", "nodes": nodes, "connections": conns, "settings": {"executionOrder": "v1", "saveManualExecutions": True, "callerPolicy": "workflowsFromSameOwner"}, } ziel = os.path.join(os.path.dirname(__file__), '..', 'workflows', 'brief-vorbereiten.json') json.dump(wf, open(ziel, 'w', encoding='utf-8'), ensure_ascii=False, indent=2) print("geschrieben:", os.path.normpath(ziel)) print("Nodes:", len(nodes), "| Verbindungen:", sum(len(v) for m in conns.values() for v in m['main']))