Files
Skrift-Auto-Article/build/gen_wf1.py

486 lines
22 KiB
Python

# -*- 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']))