# -*- coding: utf-8 -*- """Erzeugt workflows/pruefen-und-importieren.json fuer n8n 1.120.x""" import json, os, uuid CRED_NW = {"httpHeaderAuth": {"id": "U23n2O980GKWocWq", "name": "NeuronWriter API"}} CRED_GITEA = {"httpHeaderAuth": {"id": "VOD4JM5zwpSC8wAr", "name": "Gitea Skrift Auto Article"}} CRED_WP = {"httpBasicAuth": {"id": "WIN05TZgM0K5VOGy", "name": "WP skrift.de Redakteur"}} nodes, conns = [], {} def nid(): return str(uuid.uuid4()) def node(name, typ, tv, pos, params, **kw): n = {"parameters": params, "type": typ, "typeVersion": tv, "position": pos, "id": nid(), "name": name} n.update(kw); nodes.append(n); return name def link(a, b, out=0, inp=0): conns.setdefault(a, {"main": []}) while len(conns[a]["main"]) <= out: conns[a]["main"].append([]) conns[a]["main"][out].append({"node": b, "type": "main", "index": inp}) def http(name, pos, method, url, cred, auth_typ="httpHeaderAuth", body=None, error_out=True, retry=True, extra=None, **kw): p = {"method": method, "url": url, "authentication": "genericCredentialType", "genericAuthType": auth_typ, "options": {"response": {"response": {"neverError": False}}, "timeout": 60000}} if body is not None: p["sendBody"] = True; p["specifyBody"] = "json"; p["jsonBody"] = body p["sendHeaders"] = True; p["specifyHeaders"] = "json" p["jsonHeaders"] = '={"Content-Type":"application/json"}' if extra: p.update(extra) kw["credentials"] = cred if error_out: kw["onError"] = "continueErrorOutput" if retry: kw["retryOnFail"] = True; kw["maxTries"] = 3; kw["waitBetweenTries"] = 3000 return node(name, "n8n-nodes-base.httpRequest", 4.3, pos, p, **kw) def code(name, pos, js, **kw): return node(name, "n8n-nodes-base.code", 2, pos, {"mode": "runOnceForAllItems", "jsCode": js}, **kw) def iff(name, pos, left, op_type, op, right=None, **kw): c = {"id": nid(), "leftValue": left, "rightValue": right if right is not None else "", "operator": {"type": op_type, "operation": op}} if right is None: c["operator"]["singleValue"] = True return node(name, "n8n-nodes-base.if", 2.2, pos, { "conditions": {"options": {"caseSensitive": True, "leftValue": "", "typeValidation": "loose", "version": 2}, "conditions": [c], "combinator": "and"}, "looseTypeValidation": True, "options": {}}, **kw) # ---------------------------------------------------------------- Trigger node("Manuell starten", "n8n-nodes-base.manualTrigger", 1, [-460, 100], {}) node("Webhook Start", "n8n-nodes-base.webhook", 2.1, [-460, 280], {"httpMethod": "POST", "path": "pruefen-und-importieren", "responseMode": "onReceived", "options": {}}, webhookId=str(uuid.uuid4())) KONF = node("Konfiguration", "n8n-nodes-base.set", 3.4, [-240, 190], { "mode": "manual", "assignments": {"assignments": [ {"id": nid(), "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string"}, {"id": nid(), "name": "gitea_base", "value": "http://gitea:3000", "type": "string"}, {"id": nid(), "name": "gitea_owner", "value": "lucas.orth", "type": "string"}, {"id": nid(), "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string"}, {"id": nid(), "name": "branch", "value": "main", "type": "string"}, {"id": nid(), "name": "artikel_ordner", "value": "artikel", "type": "string"}, {"id": nid(), "name": "brief_ordner", "value": "briefe", "type": "string"}, {"id": nid(), "name": "nachbessern_ordner","value": "nachbessern", "type": "string"}, {"id": nid(), "name": "status_pfad", "value": "status-importe.json", "type": "string"}, {"id": nid(), "name": "wp_base", "value": "https://skrift.de", "type": "string"}, {"id": nid(), "name": "score_ziel", "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 72 }}", "type": "number"}, {"id": nid(), "name": "pause_zwischen_artikeln", "value": 5, "type": "number"}, {"id": nid(), "name": "elementor_ausgabe", "value": False, "type": "boolean"}, {"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], 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 => `

${f.frage || ''}

${f.antwort || ''}

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

${artikel.kurzantwort}

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

Häufige Fragen

\n${faqText}` : '', ].filter(Boolean).join('\n'); return [{ json: { slug: meta.slug, artikel, brief, brief_vorhanden: !!brief, bewertungs_html, 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, hinweis: s.brief_vorhanden ? 'Begriffe gegen briefe/brief-' + s.slug + '.json abgeglichen. Zaehlung als Teilstring, Komposita zaehlen mit.' : 'Kein Brief gefunden, daher nur der Score. Workflow 1 fuer diesen Slug erneut laufen lassen.', }; const konf = $('Konfiguration').first().json; return [{ json: { slug: s.slug, score: s.score, bericht, pfad: konf.nachbessern_ordner + '/nachbessern-' + s.slug + '.json', anzahl_pflicht: pflicht.length, anzahl_erweitert: erweitert.length }}]; """) link(GATE, NACH, out=1) NACH_PRUEF = http("Nachbesserung vorhanden?", [2440, 640], "GET", "={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $json.pfad }}?ref={{ $('Konfiguration').first().json.branch }}", CRED_GITEA, error_out=False, retry=False, extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}}) link(NACH, NACH_PRUEF) NACH_VOR = code("Nachbesserung vorbereiten", [2660, 640], """ const n = $('Fehlende Begriffe ermitteln').first().json; const p = $input.first().json; // n8n liefert den Koerper bei fullResponse je nach Version als body oder data. const K = (r) => (r && r.body !== undefined) ? r.body : ((r || {}).data); const kp = K(p); const da = p.statusCode === 200 && kp && kp.sha; const konf = $('Konfiguration').first().json; return [{ json: { ...n, methode: da ? 'PUT' : 'POST', commit_body: JSON.stringify({ content: Buffer.from(JSON.stringify(n.bericht, null, 2), 'utf8').toString('base64'), message: `Nachbesserung ${n.slug}: Score ${n.score} unter Ziel`, branch: konf.branch, ...(da ? { sha: kp.sha } : {}), })}}]; """) link(NACH_PRUEF, NACH_VOR) NACH_COMMIT = http("Nachbesserung committen", [2880, 640], "={{ $json.methode }}", "={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $json.pfad }}", CRED_GITEA, body="={{ $json.commit_body }}") link(NACH_VOR, NACH_COMMIT) NACH_FEHLER = code("Fehler Nachbesserung", [2880, 860], """ const n = $('Fehlende Begriffe ermitteln').first().json; return [{ json: { slug: n.slug, ergebnis: 'fehler', stufe: 'nachbessern-commit', score: n.score, meldung: 'Nachbesserungsdatei konnte nicht geschrieben werden: ' + JSON.stringify($input.first().json).slice(0, 400) }}]; """, onError="continueRegularOutput") link(NACH_COMMIT, NACH_FEHLER, out=1) NACH_OK = code("Nachbesserung gemerkt", [3100, 640], """ const n = $('Fehlende Begriffe ermitteln').first().json; const p = $('Artikel pruefen').first().json; return [{ json: { slug: n.slug, ergebnis: 'nachbessern', score: n.score, preis_warnung: p.preis_warnung || null, score_ziel: n.bericht.score_ziel, pfad: n.pfad, anzahl_pflicht: n.anzahl_pflicht, anzahl_erweitert: n.anzahl_erweitert, wortzahl: n.bericht.wortzahl, meldung: `Score ${n.score} unter Ziel ${n.bericht.score_ziel}. ` + `${n.anzahl_pflicht} Pflichtbegriffe fehlen, ${n.anzahl_erweitert} Zusatzbegriffe.` }}]; """) link(NACH_COMMIT, NACH_OK, out=0) # ================================================================ ERFOLGS-PFAD IMPORT = http("Revision zu NeuronWriter", [2220, 240], "POST", "={{ $('Konfiguration').first().json.nw_base }}/import-content", CRED_NW, body="={{ JSON.stringify({ query: $json.artikel.query_id, html: $json.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( `

${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, 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, preis_shortcodes: v.preis_shortcodes || 0, }}]; """) link(WP_SCHREIB, WP_PRUEF) CACHE = http("Cache leeren", [4420, 240], "POST", "={{ $('Konfiguration').first().json.wp_base }}/wp-json/seopress/v1/commands/clear-cache", CRED_WP, auth_typ="httpBasicAuth", body="={{ JSON.stringify({}) }}", error_out=False, retry=False, extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 30000}}) link(WP_PRUEF, CACHE) CACHE_NOTIZ = code("Importergebnis merken", [4640, 240], """ const r = $('Ergebnis pruefen').first().json; const c = $input.first().json; return [{ json: { ...r, cache_geleert: c.statusCode === 200, cache_hinweis: c.statusCode === 200 ? null : 'Cache-Endpunkt antwortete mit HTTP ' + c.statusCode + '. Kein Abbruchgrund.' }}]; """) link(CACHE, CACHE_NOTIZ) # ---------------------------------------------------------------- Rueckfuehrung PAUSE = node("Pause Rate Limit", "n8n-nodes-base.wait", 1.1, [4860, 420], {"amount": "={{ $('Konfiguration').first().json.pause_zwischen_artikeln }}", "unit": "seconds"}, webhookId=str(uuid.uuid4())) for q in [CACHE_NOTIZ, NACH_OK, NACH_FEHLER, FEHLER_ART, FEHLER_EVAL]: link(q, PAUSE) link(PAUSE, LOOP) # ---------------------------------------------------------------- Statustabelle SAMMELN = code("Ergebnisse sammeln", [900, 80], r""" const alle = $input.all().map(i => i.json); const importiert = alle.filter(r => r.ergebnis === 'importiert'); const nachbessern = alle.filter(r => r.ergebnis === 'nachbessern'); const fehler = alle.filter(r => r.ergebnis === 'fehler'); const zeilen = importiert.map(r => ({ slug: r.slug, score: r.score, datum: new Date().toISOString(), post_id: r.post_id, vorschau_url: r.vorschau_url, aktion: r.aktion, kategorie: r.kategorie, })); return [{ json: { alle, importiert, nachbessern, fehler, neue_zeilen: zeilen, anzahl_importiert: importiert.length, anzahl_nachbessern: nachbessern.length, anzahl_fehler: fehler.length }}]; """) link(LOOP, SAMMELN, out=0) ST_LESEN = http("Statustabelle lesen", [1120, 80], "GET", "={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $('Konfiguration').first().json.status_pfad }}?ref={{ $('Konfiguration').first().json.branch }}", CRED_GITEA, error_out=False, retry=False, extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}}) link(SAMMELN, ST_LESEN) ST_VOR = code("Statustabelle fortschreiben", [1340, 80], """ const s = $('Ergebnisse sammeln').first().json; const p = $input.first().json; const konf = $('Konfiguration').first().json; let bestand = []; let sha = null; // n8n liefert den Koerper bei fullResponse je nach Version als body oder data. const K = (r) => (r && r.body !== undefined) ? r.body : ((r || {}).data); const kp = K(p); if (p.statusCode === 200 && kp && kp.content) { sha = kp.sha; try { bestand = JSON.parse(Buffer.from(kp.content, 'base64').toString('utf8')); } catch (e) { bestand = []; } if (!Array.isArray(bestand)) bestand = []; } // Je Slug nur die juengste Zeile behalten. const neu = [...bestand]; s.neue_zeilen.forEach(z => { const i = neu.findIndex(x => x.slug === z.slug); if (i >= 0) neu[i] = z; else neu.push(z); }); return [{ json: { ueberspringen: s.neue_zeilen.length === 0, commit_body: JSON.stringify({ content: Buffer.from(JSON.stringify(neu, null, 2), 'utf8').toString('base64'), message: `Import: ${s.anzahl_importiert} Beitrag/Beitraege als Entwurf`, branch: konf.branch, ...(sha ? { sha } : {}), }), methode: sha ? 'PUT' : 'POST' }}]; """) link(ST_LESEN, ST_VOR) ST_IF = iff("Gab es Importe?", [1560, 80], "={{ $json.ueberspringen }}", "boolean", "true") link(ST_VOR, ST_IF) ST_SCHREIB = http("Statustabelle schreiben", [1780, 160], "={{ $json.methode }}", "={{ $('Konfiguration').first().json.gitea_base }}/api/v1/repos/{{ $('Konfiguration').first().json.gitea_owner }}/{{ $('Konfiguration').first().json.gitea_repo }}/contents/{{ $('Konfiguration').first().json.status_pfad }}", CRED_GITEA, body="={{ $json.commit_body }}") link(ST_IF, ST_SCHREIB, out=1) ST_FEHLER = code("Fehler Statustabelle", [1780, 340], """ return [{ json: { statustabelle_warnung: 'Statustabelle konnte nicht geschrieben werden: ' + JSON.stringify($input.first().json).slice(0, 300) }}]; """, onError="continueRegularOutput") link(ST_SCHREIB, ST_FEHLER, out=1) MAIL = code("Meldung formulieren", [2000, 80], r""" const s = $('Ergebnisse sammeln').first().json; const z = []; z.push(`Importiert: ${s.anzahl_importiert} | Nachbessern: ${s.anzahl_nachbessern} | Fehler: ${s.anzahl_fehler}`); z.push(''); if (s.importiert.length) { z.push('Als Entwurf in WordPress:'); s.importiert.forEach(r => { z.push(` ${r.slug} (Score ${r.score}, ${r.aktion})`); z.push(` Vorschau: ${r.vorschau_url}`); z.push(` Bearbeiten: ${r.bearbeiten_url}`); if (r.elementor) z.push(` Layout: ${r.elementor}`); if (r.meta_warnung) z.push(` ACHTUNG: ${r.meta_warnung}`); if (r.preis_warnung) z.push(` PREISE: ${r.preis_warnung}`); if (r.cache_hinweis) z.push(` Hinweis: ${r.cache_hinweis}`); }); z.push(''); } if (s.nachbessern.length) { z.push('Score zu niedrig, Nachbesserung liegt im Repo:'); s.nachbessern.forEach(r => { z.push(` ${r.slug}: Score ${r.score} von ${r.score_ziel}`); z.push(` ${r.anzahl_pflicht} Pflichtbegriffe fehlen, ${r.anzahl_erweitert} Zusatzbegriffe`); if (r.wortzahl && r.wortzahl.differenz !== null) { z.push(` Wortzahl ${r.wortzahl.ist} von ${r.wortzahl.ziel} (${r.wortzahl.differenz > 0 ? '+' : ''}${r.wortzahl.differenz})`); } z.push(` Datei: ${r.pfad}`); if (r.preis_warnung) z.push(` PREISE: ${r.preis_warnung}`); }); z.push(''); } if (s.fehler.length) { z.push('Fehler:'); s.fehler.forEach(r => z.push(` ${r.slug || '?'} [${r.stufe}]: ${r.meldung}`)); z.push(''); } z.push('Kein Beitrag wurde veroeffentlicht. Alle Importe stehen auf Entwurf.'); return [{ json: { betreff: `Skrift Ratgeber: ${s.anzahl_importiert} Entwurf/Entwuerfe` + (s.anzahl_nachbessern ? `, ${s.anzahl_nachbessern} zum Nachbessern` : '') + (s.anzahl_fehler ? `, ${s.anzahl_fehler} Fehler` : ''), text: z.join('\n') }}]; """) link(ST_IF, MAIL, out=0) link(ST_SCHREIB, MAIL, out=0) link(ST_FEHLER, MAIL) node("Benachrichtigung", "n8n-nodes-base.emailSend", 2.1, [2220, 80], { "fromEmail": "", "toEmail": "={{ $('Konfiguration').first().json.benachrichtigung_an }}", "subject": "={{ $json.betreff }}", "emailFormat": "text", "text": "={{ $json.text }}", "options": {}}, disabled=True, notes="SMTP-Credential zuweisen, Absender eintragen, dann aktivieren.") link(MAIL, "Benachrichtigung") wf = {"name": "pruefen-und-importieren", "nodes": nodes, "connections": conns, "settings": {"executionOrder": "v1", "saveManualExecutions": True, "callerPolicy": "workflowsFromSameOwner"}} ziel = os.path.join(os.path.dirname(__file__), '..', 'workflows', 'pruefen-und-importieren.json') json.dump(wf, open(ziel, 'w', encoding='utf-8'), ensure_ascii=False, indent=2) print("geschrieben:", os.path.normpath(ziel)) print("Nodes:", len(nodes), "| Verbindungen:", sum(len(v) for m in conns.values() for v in m['main']))