893 lines
42 KiB
Python
893 lines
42 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"}}
|
|
_js = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "stilpruefung.js"), encoding="utf-8").read()
|
|
STIL_JS = _js.split("if (typeof module")[0] + "\n"
|
|
CRED_OPENAI = {"httpHeaderAuth": {"id": "03NmFB2C9IFRXIu8", "name": "OpenAI Bilder"}}
|
|
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) : 65 }}", "type": "number"},
|
|
{"id": nid(), "name": "pause_zwischen_artikeln", "value": 5, "type": "number"},
|
|
{"id": nid(), "name": "elementor_ausgabe", "value": False, "type": "boolean"},
|
|
{"id": nid(), "name": "bild_aktiv", "value": True, "type": "boolean"},
|
|
{"id": nid(), "name": "bild_modell", "value": "gpt-image-2", "type": "string"},
|
|
{"id": nid(), "name": "bild_groesse", "value": "1536x864", "type": "string"},
|
|
{"id": nid(), "name": "bild_qualitaet", "value": "medium", "type": "string"},
|
|
{"id": nid(), "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string"},
|
|
{"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], STIL_JS + 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) || []);
|
|
|
|
// NeuronWriter bewertet nur den uebergebenen Text. Kurzantwort und FAQ stehen
|
|
// aber auf der fertigen Seite, also gehoeren sie in die Bewertung. Der gespeicherte
|
|
// Artikel bleibt unveraendert, das hier ist nur der Pruefkoerper.
|
|
const faqText = (Array.isArray(artikel.faq) ? artikel.faq : [])
|
|
.map(f => `<h3>${f.frage || ''}</h3><p>${f.antwort || ''}</p>`).join('\n');
|
|
const bewertungs_html = [
|
|
artikel.kurzantwort ? `<p>${artikel.kurzantwort}</p>` : '',
|
|
artikel.content_html || '',
|
|
faqText ? `<h2>Häufige Fragen</h2>\n${faqText}` : '',
|
|
].filter(Boolean).join('\n');
|
|
|
|
const praxis = Array.isArray(artikel.praxis) && artikel.praxis.length ? artikel.praxis
|
|
: ((brief && brief.artikel && brief.artikel.praxis) || []);
|
|
|
|
return [{ json: { slug: meta.slug, artikel, brief, brief_vorhanden: !!brief,
|
|
bewertungs_html,
|
|
stil_hinweise: stilpruefung({ ...artikel, praxis }),
|
|
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.bewertungs_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.bewertungs_html || s.artikel.content_html || '')
|
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /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,
|
|
stil_hinweise: s.stil_hinweise || [],
|
|
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.',
|
|
regel: 'Pflichtbegriffe sind verbindlich. Zusatzbegriffe nur einbauen, wenn der Satz dadurch nicht schlechter wird. Keine Saetze nur fuer den Score.',
|
|
};
|
|
|
|
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,
|
|
stil_hinweise: p.stil_hinweise || [],
|
|
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.bewertungs_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 || '',
|
|
};
|
|
|
|
// --- Elementor-Struktur aus Artikel und FAQ ---
|
|
// Zerlegt den Text an den H2-Grenzen in Text-Editor-Widgets und haengt das FAQ
|
|
// als nested-accordion an, dasselbe Widget wie auf den Leistungsseiten.
|
|
const konf = $('Konfiguration').first().json;
|
|
const eid = () => Math.random().toString(16).slice(2, 9);
|
|
const textWidget = (h) => ({ id: eid(), elType: 'widget', settings: { editor: h },
|
|
elements: [], widgetType: 'text-editor' });
|
|
const behaelter = (kinder, innen) => {
|
|
const s = { content_width: 'full' };
|
|
if (innen) Object.assign(s, {
|
|
_title: 'Element',
|
|
padding: { unit: 'px', top: '8', right: '8', bottom: '8', left: '8', isLinked: true },
|
|
flex_gap: { column: '20', row: '20', isLinked: true, unit: 'px', size: 20 },
|
|
});
|
|
const c = { id: eid(), elType: 'container', settings: s, elements: kinder };
|
|
if (innen) { c.isInner = true; c.isLocked = true; }
|
|
return c;
|
|
};
|
|
|
|
function elementorBauen() {
|
|
const wurzel = [];
|
|
if (a.kurzantwort) {
|
|
wurzel.push(behaelter([textWidget(
|
|
`<div class="skrift-kurzantwort"><p>${a.kurzantwort}</p></div>`)]));
|
|
}
|
|
const html = String(a.content_html || '');
|
|
const stellen = [];
|
|
const re2 = /<h2[ >]/g;
|
|
let mm;
|
|
while ((mm = re2.exec(html)) !== null) stellen.push(mm.index);
|
|
const bloecke = [];
|
|
if (!stellen.length) {
|
|
if (html.trim()) bloecke.push(html.trim());
|
|
} else {
|
|
if (stellen[0] > 0 && html.slice(0, stellen[0]).trim()) bloecke.push(html.slice(0, stellen[0]).trim());
|
|
stellen.forEach((s, i) => {
|
|
const ende = i + 1 < stellen.length ? stellen[i + 1] : html.length;
|
|
const b = html.slice(s, ende).trim();
|
|
if (b) bloecke.push(b);
|
|
});
|
|
}
|
|
bloecke.forEach(b => wurzel.push(behaelter([textWidget(b)])));
|
|
|
|
if (meta.faq.length) {
|
|
const items = [], kinder = [];
|
|
meta.faq.forEach(f => {
|
|
items.push({ item_title: f.frage, _id: eid() });
|
|
kinder.push(behaelter([textWidget(`<p>${f.antwort || ''}</p>`)], true));
|
|
});
|
|
wurzel.push(behaelter([
|
|
textWidget(`<h2>${konf.faq_ueberschrift || 'Häufige Fragen'}</h2>`),
|
|
{ id: eid(), elType: 'widget', widgetType: 'nested-accordion',
|
|
settings: { items, faq_schema: 'yes', default_state: 'all_collapsed' },
|
|
elements: kinder },
|
|
]));
|
|
}
|
|
return wurzel;
|
|
}
|
|
|
|
if (konf.elementor_ausgabe) {
|
|
meta._elementor_data = JSON.stringify(elementorBauen());
|
|
meta._elementor_edit_mode = 'builder';
|
|
meta._elementor_template_type = 'wp-post';
|
|
}
|
|
|
|
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, featured_media: p.featured_media || 0, 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,
|
|
elementor: (p.meta || {})._elementor_edit_mode === 'builder'
|
|
? (() => { try { return JSON.parse((p.meta || {})._elementor_data || '[]').length + ' Container'; }
|
|
catch (e) { return 'ungueltig'; } })()
|
|
: 'klassischer Inhalt',
|
|
seo_titel: (p.meta || {})._seopress_titles_title || null,
|
|
preis_warnung: v.preis_warnung || null,
|
|
stil_hinweise: v.stil_hinweise || [],
|
|
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 [NACH_OK, NACH_FEHLER, FEHLER_ART, FEHLER_EVAL]:
|
|
link(q, PAUSE)
|
|
link(PAUSE, LOOP)
|
|
|
|
# ================================================================ BEITRAGSBILD
|
|
BILD_IF = iff("Beitragsbild fehlt?", [4860, 240],
|
|
"={{ $('Konfiguration').first().json.bild_aktiv === true && !Number($json.featured_media) }}",
|
|
"boolean", "true")
|
|
link(CACHE_NOTIZ, BILD_IF)
|
|
link(BILD_IF, "Pause Rate Limit", out=1)
|
|
|
|
BILD_DATEN = http("Bilddaten laden", [5080, 120], "GET",
|
|
"={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/artikel-index.json?ref={{ $('Konfiguration').first().json.branch }}",
|
|
CRED_GITEA, error_out=False, retry=True,
|
|
extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}})
|
|
link(BILD_IF, BILD_DATEN, out=0)
|
|
|
|
BILD_PROMPT = code("Bildprompt bauen", [5300, 120], r"""
|
|
// Prompt nach ratgeber/bildstil.md, Farbe und Motiv aus artikel-index.json.
|
|
const r = $('Importergebnis merken').first().json;
|
|
const antwort = $input.first().json;
|
|
const K = (x) => (x && x.body !== undefined) ? x.body : ((x || {}).data);
|
|
const konf = $('Konfiguration').first().json;
|
|
|
|
let zeile = null;
|
|
const kb = K(antwort);
|
|
if (antwort.statusCode === 200 && kb && kb.content) {
|
|
try {
|
|
const tab = JSON.parse(Buffer.from(kb.content, 'base64').toString('utf8'));
|
|
zeile = (Array.isArray(tab) ? tab : []).find(z => z.slug === r.slug) || null;
|
|
} catch (e) { zeile = null; }
|
|
}
|
|
if (!zeile || !zeile.bild_farbe || !zeile.bildmotiv) {
|
|
return [{ json: { ...r, bild_prompt: null,
|
|
bild_hinweis: 'Kein bild_farbe oder bildmotiv in artikel-index.json, Bild uebersprungen.' } }];
|
|
}
|
|
|
|
const FARBNAME = { '#7B96C2': 'muted light ink blue', '#B6997F': 'muted sepia brown',
|
|
'#8C9A82': 'muted sage green', '#C47B5E': 'muted terracotta red' };
|
|
const farbe = String(zeile.bild_farbe).toUpperCase();
|
|
const name = FARBNAME[farbe] ? ` (${FARBNAME[farbe]}, not more saturated)` : '';
|
|
const leer = /price tag/i.test(zeile.bildmotiv) ? ', the price tag is completely empty' : '';
|
|
|
|
const prompt = [
|
|
'Hand-drawn doodle spot illustration, flat editorial icon style.',
|
|
`Solid flat background color ${farbe}${name}, completely uniform, no texture, no gradient, no shadow, no vignette.`,
|
|
'In the center one simple object, drawn with thick uneven black marker outlines (#1A1A1A), slightly wobbly hand-drawn lines like a felt-tip pen, rounded line ends.',
|
|
'Clean closed outlines, no stray marks, no hatching and no small dashes inside the shapes.',
|
|
'Object fills are flat off-white cream (#F9F7F5), no shading, no highlights.',
|
|
`Motif: ${zeile.bildmotiv}${leer}. Shown straight from the front, not tilted.`,
|
|
'Minimal and iconic, lots of empty space, the object takes about 35 percent of the image height.',
|
|
'No text, no letters, no numbers, no currency symbols, no logo, no border, no frame, no rounded corners, no people.',
|
|
].join(' ');
|
|
|
|
return [{ json: { ...r, bild_prompt: prompt, bild_titel: zeile.titel || r.slug,
|
|
openai_body: JSON.stringify({ model: konf.bild_modell, prompt, size: konf.bild_groesse,
|
|
n: 1, output_format: 'webp', quality: konf.bild_qualitaet }) } }];
|
|
""")
|
|
link(BILD_DATEN, BILD_PROMPT)
|
|
|
|
PROMPT_IF = iff("Bildprompt vorhanden?", [5520, 120], "={{ !!$json.bild_prompt }}", "boolean", "true")
|
|
link(BILD_PROMPT, PROMPT_IF)
|
|
link(PROMPT_IF, "Pause Rate Limit", out=1)
|
|
|
|
# OpenAI darf scheitern, der Artikel ist bereits importiert. Deshalb Fehlerausgang.
|
|
BILD_GEN = http("Bild erzeugen", [5740, 40], "POST",
|
|
"https://api.openai.com/v1/images/generations", CRED_OPENAI,
|
|
body="={{ $json.openai_body }}", retry=True,
|
|
extra={"options": {"response": {"response": {"neverError": False}}, "timeout": 180000}})
|
|
link(PROMPT_IF, BILD_GEN, out=0)
|
|
|
|
BILD_FEHLER = code("Fehler Bild", [5740, 260], """
|
|
const r = $('Bildprompt bauen').first().json;
|
|
const e = $input.first().json;
|
|
return [{ json: { ...r, openai_body: undefined,
|
|
bild_hinweis: 'Bild nicht erzeugt, Artikel ist trotzdem importiert. Haeufige Ursachen: '
|
|
+ 'Guthaben leer, Organisation fuer Bildmodelle nicht verifiziert, Prompt abgelehnt. Antwort: '
|
|
+ JSON.stringify(e).slice(0, 300) } }];
|
|
""", onError="continueRegularOutput")
|
|
link(BILD_GEN, BILD_FEHLER, out=1)
|
|
link(BILD_FEHLER, "Pause Rate Limit")
|
|
|
|
BILD_BIN = code("Bild als Datei", [5960, 40], """
|
|
const r = $('Bildprompt bauen').first().json;
|
|
const a = $input.first().json;
|
|
const b64 = a && a.data && a.data[0] && a.data[0].b64_json;
|
|
if (!b64) throw new Error('OpenAI lieferte kein data[0].b64_json. Antwort: ' + JSON.stringify(a).slice(0, 300));
|
|
const puffer = Buffer.from(b64, 'base64');
|
|
const datei = await this.helpers.prepareBinaryData(puffer, `${r.slug}.webp`, 'image/webp');
|
|
const { openai_body, ...rest } = r;
|
|
return [{ json: { ...rest, bild_bytes: puffer.length }, binary: { bild: datei } }];
|
|
""")
|
|
link(BILD_GEN, BILD_BIN, out=0)
|
|
|
|
# Schreiben nach WordPress: kein Continue On Fail.
|
|
node("Bild in Mediathek", "n8n-nodes-base.httpRequest", 4.3, [6180, 40], {
|
|
"method": "POST",
|
|
"url": "={{ $('Konfiguration').first().json.wp_base }}/wp-json/wp/v2/media?alt_text={{ encodeURIComponent('Illustration zum Artikel: ' + $json.bild_titel) }}&title={{ encodeURIComponent($json.bild_titel) }}",
|
|
"authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth",
|
|
"sendHeaders": True,
|
|
"headerParameters": {"parameters": [
|
|
{"name": "Content-Disposition", "value": "=attachment; filename=\"{{ $json.slug }}.webp\""},
|
|
{"name": "Content-Type", "value": "image/webp"}]},
|
|
"sendBody": True, "contentType": "binaryData", "inputDataFieldName": "bild",
|
|
"options": {"timeout": 120000}},
|
|
credentials=CRED_WP, retryOnFail=True, maxTries=3, waitBetweenTries=3000)
|
|
link(BILD_BIN, "Bild in Mediathek")
|
|
|
|
node("Beitragsbild setzen", "n8n-nodes-base.httpRequest", 4.3, [6400, 40], {
|
|
"method": "POST",
|
|
"url": "={{ $('Konfiguration').first().json.wp_base }}/wp-json/wp/v2/posts/{{ $('Bildprompt bauen').first().json.post_id }}",
|
|
"authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth",
|
|
"sendBody": True, "specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ featured_media: $json.id }) }}",
|
|
"sendHeaders": True, "specifyHeaders": "json", "jsonHeaders": '={"Content-Type":"application/json"}',
|
|
"options": {"timeout": 60000}},
|
|
credentials=CRED_WP, retryOnFail=True, maxTries=3, waitBetweenTries=3000)
|
|
link("Bild in Mediathek", "Beitragsbild setzen")
|
|
|
|
BILD_OK = code("Bild gemerkt", [6620, 40], """
|
|
const r = $('Bild als Datei').first().json;
|
|
const m = $('Bild in Mediathek').first().json;
|
|
const p = $input.first().json;
|
|
if (Number(p.featured_media) !== Number(m.id)) {
|
|
throw new Error('Beitragsbild wurde nicht gesetzt. Erwartet ' + m.id + ', WordPress meldet ' + p.featured_media);
|
|
}
|
|
return [{ json: { ...r, bild: 'neu erzeugt', media_id: m.id, bild_url: m.source_url,
|
|
featured_media: m.id, bild_kb: Math.round(r.bild_bytes / 1024) } }];
|
|
""")
|
|
link("Beitragsbild setzen", BILD_OK)
|
|
link(BILD_OK, "Pause Rate Limit")
|
|
|
|
# ---------------------------------------------------------------- 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.elementor) z.push(` Layout: ${r.elementor}`);
|
|
if (r.bild === 'neu erzeugt') z.push(` Bild: neu erzeugt (${r.bild_kb} KB), ${r.bild_url}`);
|
|
else if (r.featured_media) z.push(` Bild: bereits vorhanden, nicht neu erzeugt`);
|
|
if (r.bild_hinweis) z.push(` Bild: ${r.bild_hinweis}`);
|
|
if (r.meta_warnung) z.push(` ACHTUNG: ${r.meta_warnung}`);
|
|
if (r.preis_warnung) z.push(` PREISE: ${r.preis_warnung}`);
|
|
if (r.stil_hinweise && r.stil_hinweise.length) {
|
|
z.push(` STIL: ${r.stil_hinweise.length} Hinweis(e)`);
|
|
r.stil_hinweise.slice(0, 6).forEach(h => z.push(` - ${h.art}: ${h.stelle}`));
|
|
if (r.stil_hinweise.length > 6) z.push(` ... weitere im Nachbesserungsbericht bzw. in der Execution`);
|
|
}
|
|
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}`);
|
|
if (r.stil_hinweise && r.stil_hinweise.length) {
|
|
z.push(` STIL: ${r.stil_hinweise.length} Hinweis(e)`);
|
|
r.stil_hinweise.slice(0, 6).forEach(h => z.push(` - ${h.art}: ${h.stelle}`));
|
|
if (r.stil_hinweise.length > 6) z.push(` ... weitere im Nachbesserungsbericht bzw. in der Execution`);
|
|
}
|
|
});
|
|
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']))
|