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

653 lines
30 KiB
Python

# -*- coding: utf-8 -*-
"""Erzeugt workflows/pruefen-und-importieren.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"}}
CRED_WP = {"httpBasicAuth": {"id": "WIN05TZgM0K5VOGy", "name": "WP skrift.de Redakteur"}}
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, auth_typ="httpHeaderAuth", body=None,
error_out=True, retry=True, extra=None, **kw):
p = {"method": method, "url": url, "authentication": "genericCredentialType",
"genericAuthType": auth_typ,
"options": {"response": {"response": {"neverError": False}}, "timeout": 60000}}
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, **kw):
return node(name, "n8n-nodes-base.code", 2, pos, {"mode": "runOnceForAllItems", "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, 100], {})
node("Webhook Start", "n8n-nodes-base.webhook", 2.1, [-460, 280],
{"httpMethod": "POST", "path": "pruefen-und-importieren",
"responseMode": "onReceived", "options": {}}, webhookId=str(uuid.uuid4()))
KONF = node("Konfiguration", "n8n-nodes-base.set", 3.4, [-240, 190], {
"mode": "manual", "assignments": {"assignments": [
{"id": nid(), "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "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": "artikel_ordner", "value": "artikel", "type": "string"},
{"id": nid(), "name": "brief_ordner", "value": "briefe", "type": "string"},
{"id": nid(), "name": "nachbessern_ordner","value": "nachbessern", "type": "string"},
{"id": nid(), "name": "status_pfad", "value": "status-importe.json", "type": "string"},
{"id": nid(), "name": "wp_base", "value": "https://skrift.de", "type": "string"},
{"id": nid(), "name": "score_ziel", "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 72 }}", "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", "Webhook Start"]: link(t, KONF)
# ---------------------------------------------------------------- Artikel auflisten
LISTE = http("Artikelordner lesen", [-20, 190], "GET",
"={{ $json.gitea_base }}/api/v1/repos/{{ $json.gitea_owner }}/{{ $json.gitea_repo }}/contents/{{ $json.artikel_ordner }}?ref={{ $json.branch }}",
CRED_GITEA, extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}},
"timeout": 60000}}, error_out=False)
link(KONF, LISTE)
FILTER = code("Artikeldateien filtern", [200, 190], """
const antwort = $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 ka = K(antwort);
if (antwort.statusCode === 404) {
return [{ json: { leer: true, grund: 'Ordner "artikel" existiert noch nicht im Repo.' } }];
}
if (antwort.statusCode !== 200) {
throw new Error('Artikelordner konnte nicht gelesen werden (HTTP ' + antwort.statusCode
+ '). Antwort: ' + JSON.stringify(ka).slice(0, 400));
}
const dateien = (ka || []).filter(e =>
e.type === 'file' && /^artikel-.+\\.json$/.test(e.name));
if (!dateien.length) {
return [{ json: { leer: true, grund: 'Keine Dateien nach Muster artikel-<slug>.json gefunden.' } }];
}
return dateien.map(d => ({ json: {
name: d.name, pfad: d.path, sha: d.sha,
slug: d.name.replace(/^artikel-/, '').replace(/\\.json$/, ''),
}}));
""")
link(LISTE, FILTER)
GIBTS = iff("Artikel vorhanden?", [420, 190], "={{ $json.leer === true }}", "boolean", "true")
link(FILTER, GIBTS)
node("Nichts zu tun", "n8n-nodes-base.noOp", 1, [640, 40], {})
link(GIBTS, "Nichts zu tun", out=0)
LOOP = node("Artikel nacheinander", "n8n-nodes-base.splitInBatches", 3, [640, 320],
{"batchSize": 1, "options": {"reset": False}})
link(GIBTS, LOOP, out=1)
# ---------------------------------------------------------------- Artikel + Brief laden
ART = http("Artikeldatei laden", [900, 420], "GET",
"={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $json.pfad }}?ref={{ $('Konfiguration').first().json.branch }}",
CRED_GITEA)
link(LOOP, ART, out=1)
FEHLER_ART = code("Fehler Artikeldatei", [900, 640], """
const a = $('Artikel nacheinander').first().json;
return [{ json: { slug: a.slug, ergebnis: 'fehler', stufe: 'artikel-laden',
meldung: 'Artikeldatei ' + a.pfad + ' konnte nicht geladen werden: '
+ JSON.stringify($input.first().json).slice(0, 400) }}];
""", onError="continueRegularOutput")
link(ART, FEHLER_ART, out=1)
BRIEF = http("Brief laden", [1120, 420], "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-{{ $('Artikel nacheinander').first().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(ART, BRIEF, out=0)
PRUEFEN = code("Artikel pruefen", [1340, 420], r"""
const meta = $('Artikel nacheinander').first().json;
const artRoh = $('Artikeldatei laden').first().json;
const briefAntwort = $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 kb = K(briefAntwort);
let artikel;
try { artikel = JSON.parse(Buffer.from(artRoh.content, 'base64').toString('utf8')); }
catch (e) { throw new Error('artikel-' + meta.slug + '.json ist kein gueltiges JSON: ' + e.message); }
const pflicht = ['slug', 'titel', 'query_id', 'kategorie', 'content_html'];
const fehlt = pflicht.filter(f => !artikel[f]);
if (fehlt.length) {
throw new Error('artikel-' + meta.slug + '.json fehlen Pflichtfelder: ' + fehlt.join(', ')
+ '. Erwartetes Format siehe README Abschnitt "Vertrag Cowork -> Workflow 2".');
}
if (artikel.slug !== meta.slug) {
throw new Error('Slug im Dateinamen (' + meta.slug + ') und im JSON (' + artikel.slug + ') stimmen nicht ueberein.');
}
let brief = null;
if (briefAntwort.statusCode === 200 && kb && kb.content) {
try { brief = JSON.parse(Buffer.from(kb.content, 'base64').toString('utf8')); }
catch (e) { brief = null; }
}
// Preise muessen als Shortcode stehen, nie ausgeschrieben.
const preisTreffer = [];
const re = /(\d{1,3}(?:[.,]\d{1,2})?)\s*(?:€|EUR|Euro)/gi;
let m;
while ((m = re.exec(String(artikel.content_html || ''))) !== null) {
preisTreffer.push(m[0].trim());
}
const shortcodes = (String(artikel.content_html || '').match(/\[skrift_preis[^\]]*\]/g) || []);
return [{ json: { slug: meta.slug, artikel, brief, brief_vorhanden: !!brief,
preis_warnung: preisTreffer.length
? `${preisTreffer.length} ausgeschriebene(r) Preis(e) im Text: ${[...new Set(preisTreffer)].slice(0,5).join(', ')}. `
+ 'Bitte durch [skrift_preis key="..."] ersetzen.'
: null,
preis_shortcodes: shortcodes.length } }];
""")
link(BRIEF, PRUEFEN)
# ---------------------------------------------------------------- Score
EVAL = http("Score ermitteln", [1560, 420], "POST",
"={{ $('Konfiguration').first().json.nw_base }}/evaluate-content", CRED_NW,
body="={{ JSON.stringify({ query: $json.artikel.query_id, html: $json.artikel.content_html, title: $json.artikel.seo_titel || $json.artikel.titel, description: $json.artikel.seo_beschreibung || '' }) }}")
link(PRUEFEN, EVAL)
FEHLER_EVAL = code("Fehler Score", [1560, 640], """
const p = $('Artikel pruefen').first().json;
return [{ json: { slug: p.slug, ergebnis: 'fehler', stufe: 'evaluate-content',
meldung: 'NeuronWriter /evaluate-content fehlgeschlagen. Haeufigste Ursache: query_id "'
+ p.artikel.query_id + '" existiert nicht oder gehoert zu einem anderen Projekt. Antwort: '
+ JSON.stringify($input.first().json).slice(0, 400) }}];
""", onError="continueRegularOutput")
link(EVAL, FEHLER_EVAL, out=1)
SCORE = code("Score auswerten", [1780, 420], r"""
const p = $('Artikel pruefen').first().json;
const e = $input.first().json;
const ziel = $('Konfiguration').first().json.score_ziel;
if (typeof e.content_score !== 'number') {
throw new Error('NeuronWriter lieferte kein numerisches content_score. Antwort: '
+ JSON.stringify(e).slice(0, 300));
}
return [{ json: { ...p, score: e.content_score, score_ziel: ziel, bestanden: e.content_score >= ziel } }];
""")
link(EVAL, SCORE, out=0)
GATE = iff("Score erreicht?", [2000, 420], "={{ $json.bestanden }}", "boolean", "true")
link(SCORE, GATE)
json.dump({"nodes": nodes, "connections": conns}, open('/tmp/wf2_teil1.json','w'), indent=1)
print("Teil 1:", len(nodes), "Nodes")
# ================================================================ FAIL-PFAD
NACH = code("Fehlende Begriffe ermitteln", [2220, 640], r"""
// /evaluate-content liefert nur {status, content_score}, keine Begriffsliste.
// Die Luecken werden deshalb gegen den Brief aus Workflow 1 selbst berechnet.
const s = $input.first().json;
const brief = s.brief;
const text = String(s.artikel.content_html || '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
const zaehle = (begriff) => {
const b = String(begriff).toLowerCase().trim();
if (!b) return 0;
return text.split(b).length - 1;
};
const pruefe = (liste) => (liste || []).map(e => {
const ist = zaehle(e.begriff);
const min = e.min ?? 1;
return { begriff: e.begriff, soll_min: min, soll_max: e.max ?? null, ist,
fehlt: ist < min, luecke: Math.max(0, min - ist) };
});
let pflicht = [], erweitert = [], fragen_offen = [];
if (brief) {
pflicht = pruefe(brief.begriffe_pflicht).filter(x => x.fehlt);
erweitert = pruefe(brief.begriffe_erweitert).filter(x => x.fehlt);
fragen_offen = (brief.fragen || [])
.filter(f => f.gewicht && f.gewicht >= 6)
.filter(f => {
const kern = String(f.frage).toLowerCase()
.replace(/[?.,!]/g, '').split(' ').filter(w => w.length > 5).slice(0, 3);
return kern.length ? !kern.every(w => text.includes(w)) : false;
})
.map(f => ({ frage: f.frage, gewicht: f.gewicht }));
}
const woerter = text.split(' ').filter(Boolean).length;
const bericht = {
slug: s.slug,
titel: s.artikel.titel,
query_id: s.artikel.query_id,
score: s.score,
score_ziel: s.score_ziel,
geprueft_am: new Date().toISOString(),
brief_vorhanden: s.brief_vorhanden,
wortzahl: { ist: woerter, ziel: brief ? brief.wortzahl_ziel : null,
differenz: brief && brief.wortzahl_ziel ? woerter - brief.wortzahl_ziel : null },
fehlende_pflichtbegriffe: pflicht,
fehlende_zusatzbegriffe: erweitert,
unbeantwortete_fragen: fragen_offen,
hinweis: s.brief_vorhanden
? 'Begriffe gegen briefe/brief-' + s.slug + '.json abgeglichen. Zaehlung als Teilstring, Komposita zaehlen mit.'
: 'Kein Brief gefunden, daher nur der Score. Workflow 1 fuer diesen Slug erneut laufen lassen.',
};
const konf = $('Konfiguration').first().json;
return [{ json: { slug: s.slug, score: s.score, bericht,
pfad: konf.nachbessern_ordner + '/nachbessern-' + s.slug + '.json',
anzahl_pflicht: pflicht.length, anzahl_erweitert: erweitert.length }}];
""")
link(GATE, NACH, out=1)
NACH_PRUEF = http("Nachbesserung vorhanden?", [2440, 640], "GET",
"={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $json.pfad }}?ref={{ $('Konfiguration').first().json.branch }}",
CRED_GITEA, error_out=False, retry=False,
extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}})
link(NACH, NACH_PRUEF)
NACH_VOR = code("Nachbesserung vorbereiten", [2660, 640], """
const n = $('Fehlende Begriffe ermitteln').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 da = p.statusCode === 200 && kp && kp.sha;
const konf = $('Konfiguration').first().json;
return [{ json: { ...n, methode: da ? 'PUT' : 'POST', commit_body: JSON.stringify({
content: Buffer.from(JSON.stringify(n.bericht, null, 2), 'utf8').toString('base64'),
message: `Nachbesserung ${n.slug}: Score ${n.score} unter Ziel`,
branch: konf.branch, ...(da ? { sha: kp.sha } : {}),
})}}];
""")
link(NACH_PRUEF, NACH_VOR)
NACH_COMMIT = http("Nachbesserung committen", [2880, 640], "={{ $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(NACH_VOR, NACH_COMMIT)
NACH_FEHLER = code("Fehler Nachbesserung", [2880, 860], """
const n = $('Fehlende Begriffe ermitteln').first().json;
return [{ json: { slug: n.slug, ergebnis: 'fehler', stufe: 'nachbessern-commit', score: n.score,
meldung: 'Nachbesserungsdatei konnte nicht geschrieben werden: '
+ JSON.stringify($input.first().json).slice(0, 400) }}];
""", onError="continueRegularOutput")
link(NACH_COMMIT, NACH_FEHLER, out=1)
NACH_OK = code("Nachbesserung gemerkt", [3100, 640], """
const n = $('Fehlende Begriffe ermitteln').first().json;
const p = $('Artikel pruefen').first().json;
return [{ json: { slug: n.slug, ergebnis: 'nachbessern', score: n.score,
preis_warnung: p.preis_warnung || null,
score_ziel: n.bericht.score_ziel, pfad: n.pfad,
anzahl_pflicht: n.anzahl_pflicht, anzahl_erweitert: n.anzahl_erweitert,
wortzahl: n.bericht.wortzahl,
meldung: `Score ${n.score} unter Ziel ${n.bericht.score_ziel}. `
+ `${n.anzahl_pflicht} Pflichtbegriffe fehlen, ${n.anzahl_erweitert} Zusatzbegriffe.` }}];
""")
link(NACH_COMMIT, NACH_OK, out=0)
# ================================================================ ERFOLGS-PFAD
IMPORT = http("Revision zu NeuronWriter", [2220, 240], "POST",
"={{ $('Konfiguration').first().json.nw_base }}/import-content", CRED_NW,
body="={{ JSON.stringify({ query: $json.artikel.query_id, html: $json.artikel.content_html, title: $json.artikel.seo_titel || $json.artikel.titel, description: $json.artikel.seo_beschreibung || '' }) }}")
link(GATE, IMPORT, out=0)
IMPORT_FEHLER = code("Fehler Revision", [2220, 60], """
const s = $('Score auswerten').first().json;
return [{ json: { ...s, import_warnung: 'Revision konnte nicht zu NeuronWriter gespeichert werden: '
+ JSON.stringify($input.first().json).slice(0, 300) }}];
""", onError="continueRegularOutput")
link(IMPORT, IMPORT_FEHLER, out=1)
DUP = http("Beitrag vorhanden?", [2440, 240], "GET",
"={{ $('Konfiguration').first().json.wp_base }}/wp-json/wp/v2/posts?slug={{ $('Score auswerten').first().json.slug }}&status=publish,draft,pending,future,private&context=edit",
CRED_WP, auth_typ="httpBasicAuth", error_out=False, retry=True,
extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}})
link(IMPORT, DUP, out=0)
link(IMPORT_FEHLER, DUP)
KAT_SUCHE = http("Kategorie suchen", [2660, 240], "GET",
"={{ $('Konfiguration').first().json.wp_base }}/wp-json/wp/v2/categories?search={{ encodeURIComponent($('Score auswerten').first().json.artikel.kategorie) }}&per_page=100",
CRED_WP, auth_typ="httpBasicAuth", error_out=False, retry=True,
extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}})
link(DUP, KAT_SUCHE)
KAT_PRUEF = code("Kategorie pruefen", [2880, 240], """
const s = $('Score auswerten').first().json;
const gesucht = String(s.artikel.kategorie).trim();
const antwort = $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 ka = K(antwort);
const treffer = (antwort.statusCode === 200 && Array.isArray(ka))
? ka.find(c => c.name.toLowerCase() === gesucht.toLowerCase()) : null;
return [{ json: { ...s, kategorie_id: treffer ? treffer.id : null,
kategorie_name: gesucht, kategorie_fehlt: !treffer } }];
""")
link(KAT_SUCHE, KAT_PRUEF)
KAT_IF = iff("Kategorie anlegen?", [3100, 240], "={{ $json.kategorie_fehlt }}", "boolean", "true")
link(KAT_PRUEF, KAT_IF)
KAT_NEU = http("Kategorie anlegen", [3320, 140], "POST",
"={{ $('Konfiguration').first().json.wp_base }}/wp-json/wp/v2/categories",
CRED_WP, auth_typ="httpBasicAuth",
body="={{ JSON.stringify({ name: $json.kategorie_name }) }}", error_out=False, retry=True)
link(KAT_IF, KAT_NEU, out=0)
KAT_FERTIG = code("Kategorie festlegen", [3540, 240], """
const s = $('Kategorie pruefen').first().json;
const e = $input.first().json;
const id = s.kategorie_id || e.id || null;
if (!id) {
throw new Error('Kategorie "' + s.kategorie_name + '" konnte weder gefunden noch angelegt werden. '
+ 'Antwort: ' + JSON.stringify(e).slice(0, 300));
}
return [{ json: { ...s, kategorie_id: id } }];
""")
link(KAT_NEU, KAT_FERTIG)
link(KAT_IF, KAT_FERTIG, out=1)
json.dump({"nodes": nodes, "connections": conns}, open('/tmp/wf2_teil2.json','w'), indent=1)
print("Teil 2:", len(nodes), "Nodes")
# ---------------------------------------------------------------- WordPress schreiben
WP_VOR = code("WordPress-Body bauen", [3760, 240], r"""
const s = $input.first().json;
const dup = $('Beitrag vorhanden?').first().json;
const a = s.artikel;
// Duplikatspruefung: exakter Slug-Treffer entscheidet ueber anlegen oder aktualisieren.
// 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 kd = K(dup);
const treffer = (dup.statusCode === 200 && Array.isArray(kd))
? kd.find(p => p.slug === s.slug) : null;
const heute = new Date().toISOString().slice(0, 10);
const meta = {
kurzantwort: a.kurzantwort || '',
cta_ziel: a.cta_ziel || '',
stand_datum: a.stand_datum || heute,
faq: Array.isArray(a.faq)
? a.faq.filter(f => f && f.frage).map(f => ({ frage: String(f.frage), antwort: String(f.antwort || '') }))
: [],
_seopress_titles_title: a.seo_titel || a.titel,
_seopress_titles_desc: a.seo_beschreibung || '',
_seopress_analysis_target_kw: a.keyword || '',
};
const body = {
title: a.titel,
slug: s.slug,
content: a.content_html,
status: 'draft', // harte Vorgabe: nie automatisch veroeffentlichen
categories: [s.kategorie_id],
meta,
};
if (a.auszug) body.excerpt = a.auszug;
return [{ json: {
...s,
vorhanden: !!treffer,
post_id: treffer ? treffer.id : null,
alter_status: treffer ? treffer.status : null,
wp_url: treffer
? `${$('Konfiguration').first().json.wp_base}/wp-json/wp/v2/posts/${treffer.id}`
: `${$('Konfiguration').first().json.wp_base}/wp-json/wp/v2/posts`,
wp_body: JSON.stringify(body),
}}];
""")
link(KAT_FERTIG, WP_VOR)
# Kein Continue On Fail: Fehler beim Schreiben nach WordPress stoppen den Workflow.
WP_SCHREIB = http("Beitrag anlegen oder aktualisieren", [3980, 240], "POST",
"={{ $json.wp_url }}", CRED_WP, auth_typ="httpBasicAuth",
body="={{ $json.wp_body }}", error_out=False, retry=True)
link(WP_VOR, WP_SCHREIB)
WP_PRUEF = code("Ergebnis pruefen", [4200, 240], r"""
const v = $('WordPress-Body bauen').first().json;
const p = $input.first().json;
if (!p.id) {
throw new Error('WordPress lieferte keine Beitrags-ID zurueck. Antwort: '
+ JSON.stringify(p).slice(0, 400));
}
if (p.status !== 'draft') {
throw new Error('Beitrag ' + p.id + ' hat Status "' + p.status
+ '" statt "draft". Der Workflow darf nie veroeffentlichen. Bitte pruefen.');
}
// Gegenprobe: sind die Meta-Felder wirklich angekommen?
const m = p.meta || {};
const fehlend = ['kurzantwort', 'cta_ziel', 'stand_datum', 'faq']
.filter(k => m[k] === undefined);
const faq_ok = Array.isArray(m.faq) ? m.faq.length : -1;
const konf = $('Konfiguration').first().json;
return [{ json: {
slug: v.slug, ergebnis: 'importiert', score: v.score, score_ziel: v.score_ziel,
post_id: p.id, aktion: v.vorhanden ? 'aktualisiert' : 'neu angelegt',
alter_status: v.alter_status, status: p.status,
kategorie: v.kategorie_name, kategorie_id: v.kategorie_id,
vorschau_url: `${konf.wp_base}/?p=${p.id}&preview=true`,
bearbeiten_url: `${konf.wp_base}/wp-admin/post.php?post=${p.id}&action=edit`,
meta_warnung: fehlend.length
? 'Diese Meta-Felder kamen nicht zurueck: ' + fehlend.join(', ')
+ '. Mu-Plugin skrift-ratgeber-felder.php installiert?'
: null,
faq_eintraege: faq_ok,
seo_titel: (p.meta || {})._seopress_titles_title || null,
preis_warnung: v.preis_warnung || null,
preis_shortcodes: v.preis_shortcodes || 0,
}}];
""")
link(WP_SCHREIB, WP_PRUEF)
CACHE = http("Cache leeren", [4420, 240], "POST",
"={{ $('Konfiguration').first().json.wp_base }}/wp-json/seopress/v1/commands/clear-cache",
CRED_WP, auth_typ="httpBasicAuth", body="={{ JSON.stringify({}) }}",
error_out=False, retry=False,
extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 30000}})
link(WP_PRUEF, CACHE)
CACHE_NOTIZ = code("Importergebnis merken", [4640, 240], """
const r = $('Ergebnis pruefen').first().json;
const c = $input.first().json;
return [{ json: { ...r,
cache_geleert: c.statusCode === 200,
cache_hinweis: c.statusCode === 200 ? null
: 'Cache-Endpunkt antwortete mit HTTP ' + c.statusCode + '. Kein Abbruchgrund.' }}];
""")
link(CACHE, CACHE_NOTIZ)
# ---------------------------------------------------------------- Rueckfuehrung
PAUSE = node("Pause Rate Limit", "n8n-nodes-base.wait", 1.1, [4860, 420],
{"amount": "={{ $('Konfiguration').first().json.pause_zwischen_artikeln }}", "unit": "seconds"},
webhookId=str(uuid.uuid4()))
for q in [CACHE_NOTIZ, NACH_OK, NACH_FEHLER, FEHLER_ART, FEHLER_EVAL]:
link(q, PAUSE)
link(PAUSE, LOOP)
# ---------------------------------------------------------------- Statustabelle
SAMMELN = code("Ergebnisse sammeln", [900, 80], r"""
const alle = $input.all().map(i => i.json);
const importiert = alle.filter(r => r.ergebnis === 'importiert');
const nachbessern = alle.filter(r => r.ergebnis === 'nachbessern');
const fehler = alle.filter(r => r.ergebnis === 'fehler');
const zeilen = importiert.map(r => ({
slug: r.slug, score: r.score, datum: new Date().toISOString(),
post_id: r.post_id, vorschau_url: r.vorschau_url,
aktion: r.aktion, kategorie: r.kategorie,
}));
return [{ json: { alle, importiert, nachbessern, fehler,
neue_zeilen: zeilen,
anzahl_importiert: importiert.length,
anzahl_nachbessern: nachbessern.length,
anzahl_fehler: fehler.length }}];
""")
link(LOOP, SAMMELN, out=0)
ST_LESEN = http("Statustabelle lesen", [1120, 80], "GET",
"={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $('Konfiguration').first().json.status_pfad }}?ref={{ $('Konfiguration').first().json.branch }}",
CRED_GITEA, error_out=False, retry=False,
extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}})
link(SAMMELN, ST_LESEN)
ST_VOR = code("Statustabelle fortschreiben", [1340, 80], """
const s = $('Ergebnisse sammeln').first().json;
const p = $input.first().json;
const konf = $('Konfiguration').first().json;
let bestand = [];
let sha = null;
// 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);
if (p.statusCode === 200 && kp && kp.content) {
sha = kp.sha;
try { bestand = JSON.parse(Buffer.from(kp.content, 'base64').toString('utf8')); }
catch (e) { bestand = []; }
if (!Array.isArray(bestand)) bestand = [];
}
// Je Slug nur die juengste Zeile behalten.
const neu = [...bestand];
s.neue_zeilen.forEach(z => {
const i = neu.findIndex(x => x.slug === z.slug);
if (i >= 0) neu[i] = z; else neu.push(z);
});
return [{ json: { ueberspringen: s.neue_zeilen.length === 0, commit_body: JSON.stringify({
content: Buffer.from(JSON.stringify(neu, null, 2), 'utf8').toString('base64'),
message: `Import: ${s.anzahl_importiert} Beitrag/Beitraege als Entwurf`,
branch: konf.branch, ...(sha ? { sha } : {}),
}), methode: sha ? 'PUT' : 'POST' }}];
""")
link(ST_LESEN, ST_VOR)
ST_IF = iff("Gab es Importe?", [1560, 80], "={{ $json.ueberspringen }}", "boolean", "true")
link(ST_VOR, ST_IF)
ST_SCHREIB = http("Statustabelle schreiben", [1780, 160], "={{ $json.methode }}",
"={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $('Konfiguration').first().json.status_pfad }}",
CRED_GITEA, body="={{ $json.commit_body }}")
link(ST_IF, ST_SCHREIB, out=1)
ST_FEHLER = code("Fehler Statustabelle", [1780, 340], """
return [{ json: { statustabelle_warnung: 'Statustabelle konnte nicht geschrieben werden: '
+ JSON.stringify($input.first().json).slice(0, 300) }}];
""", onError="continueRegularOutput")
link(ST_SCHREIB, ST_FEHLER, out=1)
MAIL = code("Meldung formulieren", [2000, 80], r"""
const s = $('Ergebnisse sammeln').first().json;
const z = [];
z.push(`Importiert: ${s.anzahl_importiert} | Nachbessern: ${s.anzahl_nachbessern} | Fehler: ${s.anzahl_fehler}`);
z.push('');
if (s.importiert.length) {
z.push('Als Entwurf in WordPress:');
s.importiert.forEach(r => {
z.push(` ${r.slug} (Score ${r.score}, ${r.aktion})`);
z.push(` Vorschau: ${r.vorschau_url}`);
z.push(` Bearbeiten: ${r.bearbeiten_url}`);
if (r.meta_warnung) z.push(` ACHTUNG: ${r.meta_warnung}`);
if (r.preis_warnung) z.push(` PREISE: ${r.preis_warnung}`);
if (r.cache_hinweis) z.push(` Hinweis: ${r.cache_hinweis}`);
});
z.push('');
}
if (s.nachbessern.length) {
z.push('Score zu niedrig, Nachbesserung liegt im Repo:');
s.nachbessern.forEach(r => {
z.push(` ${r.slug}: Score ${r.score} von ${r.score_ziel}`);
z.push(` ${r.anzahl_pflicht} Pflichtbegriffe fehlen, ${r.anzahl_erweitert} Zusatzbegriffe`);
if (r.wortzahl && r.wortzahl.differenz !== null) {
z.push(` Wortzahl ${r.wortzahl.ist} von ${r.wortzahl.ziel} (${r.wortzahl.differenz > 0 ? '+' : ''}${r.wortzahl.differenz})`);
}
z.push(` Datei: ${r.pfad}`);
if (r.preis_warnung) z.push(` PREISE: ${r.preis_warnung}`);
});
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('Kein Beitrag wurde veroeffentlicht. Alle Importe stehen auf Entwurf.');
return [{ json: {
betreff: `Skrift Ratgeber: ${s.anzahl_importiert} Entwurf/Entwuerfe`
+ (s.anzahl_nachbessern ? `, ${s.anzahl_nachbessern} zum Nachbessern` : '')
+ (s.anzahl_fehler ? `, ${s.anzahl_fehler} Fehler` : ''),
text: z.join('\n') }}];
""")
link(ST_IF, MAIL, out=0)
link(ST_SCHREIB, MAIL, out=0)
link(ST_FEHLER, MAIL)
node("Benachrichtigung", "n8n-nodes-base.emailSend", 2.1, [2220, 80], {
"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 aktivieren.")
link(MAIL, "Benachrichtigung")
wf = {"name": "pruefen-und-importieren", "nodes": nodes, "connections": conns,
"settings": {"executionOrder": "v1", "saveManualExecutions": True,
"callerPolicy": "workflowsFromSameOwner"}}
ziel = os.path.join(os.path.dirname(__file__), '..', 'workflows', 'pruefen-und-importieren.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']))