diff --git a/build/gen_wf1.py b/build/gen_wf1.py new file mode 100644 index 0000000..2115bfe --- /dev/null +++ b/build/gen_wf1.py @@ -0,0 +1,468 @@ +# -*- 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": "https://gitea.lucas-orth.de", "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, + }, + + 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; +const vorhanden = p.statusCode === 200 && p.body && p.body.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: p.body.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']))