# -*- 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": "1200x1200", "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-.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. Titel (H1), 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 => `

${f.frage || ''}

${f.antwort || ''}

`).join('\n'); const bewertungs_html = [ artikel.titel ? `

${artikel.titel}

` : '', artikel.kurzantwort ? `

${artikel.kurzantwort}

` : '', artikel.content_html || '', faqText ? `

${artikel.faq_ueberschrift || 'Häufige Fragen'}

\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(//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, 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 || '', faq_ueberschrift: a.faq_ueberschrift || '', cta_ziel: a.cta_ziel || '', stand_datum: a.stand_datum || heute, ratgeber_reihenfolge: Number.isFinite(Number(a.reihenfolge)) && a.reihenfolge !== null && a.reihenfolge !== '' ? Number(a.reihenfolge) : 999, 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( `

${a.kurzantwort}

`)])); } const html = String(a.content_html || ''); const stellen = []; const re2 = /]/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(`

${f.antwort || ''}

`)], true)); }); wurzel.push(behaelter([ textWidget(`

${konf.faq_ueberschrift || 'Häufige Fragen'}

`), { 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.`, 'The whole motif sits in the exact center and stays inside the middle band of the image: it takes about 28 percent of the image height and at most 60 percent of the image width, with wide empty background above and below.', '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']))