diff --git a/build/gen_wf2.py b/build/gen_wf2.py new file mode 100644 index 0000000..e1b7822 --- /dev/null +++ b/build/gen_wf2.py @@ -0,0 +1,614 @@ +# -*- 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": "https://gitea.lucas-orth.de", "type": "string"}, + {"id": nid(), "name": "gitea_owner", "value": "lucas.orth", "type": "string"}, + {"id": nid(), "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string"}, + {"id": nid(), "name": "branch", "value": "main", "type": "string"}, + {"id": nid(), "name": "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": 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; +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(antwort.body).slice(0, 400)); +} +const dateien = (antwort.body || []).filter(e => + e.type === 'file' && /^artikel-.+\\.json$/.test(e.name)); + +if (!dateien.length) { + return [{ json: { leer: true, grund: 'Keine Dateien nach Muster artikel-.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; + +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 && briefAntwort.body && briefAntwort.body.content) { + try { brief = JSON.parse(Buffer.from(briefAntwort.body.content, 'base64').toString('utf8')); } + catch (e) { brief = null; } +} + +return [{ json: { slug: meta.slug, artikel, brief, brief_vorhanden: !!brief } }]; +""") +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(//gi, ' ') + .replace(//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, + 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; +const da = p.statusCode === 200 && p.body && p.body.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: p.body.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; +return [{ json: { slug: n.slug, ergebnis: 'nachbessern', score: n.score, + 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; +const treffer = (antwort.statusCode === 200 && Array.isArray(antwort.body)) + ? antwort.body.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. +const treffer = (dup.statusCode === 200 && Array.isArray(dup.body)) + ? dup.body.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, +}}]; +""") +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; +if (p.statusCode === 200 && p.body && p.body.content) { + sha = p.body.sha; + try { bestand = JSON.parse(Buffer.from(p.body.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.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}`); + }); + 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']))