diff --git a/README.md b/README.md index dea7e19..59b0b29 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ sie beim Schreiben direkt vorliegen. ### Zielwert anpassen -Im Node **Konfiguration**, Feld `score_ziel`. Standard ist 72. +Im Node **Konfiguration**, Feld `score_ziel`. Standard ist 65. Der Wert gilt fuer alle Artikel eines Laufs. Fuer einen einzelnen Lauf laesst er sich ueber den Webhook uebersteuern, @@ -286,10 +286,52 @@ curl -X POST https://n8n.lucas-orth.de/webhook/pruefen-und-importieren \ -H "Content-Type: application/json" -d '{"score_ziel":60}' ``` -Ohne Angabe bleibt es bei 72. +Ohne Angabe bleibt es bei 65. Alle anderen Stellschrauben liegen im selben Node: Basis-URLs, Ordnernamen, Pause zwischen Artikeln, Empfaengeradresse. +### Warum 65 und nicht hoeher + +Ab etwa 70 verlangt NeuronWriter Wettbewerbernamen, ausgeschriebene Preise und +exakte Wortfolgen, die sich nur mit schiefen Saetzen einbauen lassen. Der +Referenzartikel kam mit solchen Saetzen auf 72 und ohne sie auf 71. Verbindlich +sind deshalb nur die Pflichtbegriffe, Zusatzbegriffe nur, wenn der Satz dadurch +nicht schlechter wird. + +Score, Luecken und Stilpruefung rechnen gegen Kurzantwort, Artikel und FAQ +zusammen, weil alle drei auf der fertigen Seite stehen. + +### Stilpruefung + +Workflow 2 prueft jeden Artikel auf Muster, die maschinell wirken, und meldet +die Fundstellen in der Benachrichtigung und im Nachbesserungsbericht. Der Import +wird dadurch nicht blockiert. + +Geprueft werden Formeln wie "Genau das", "Der Grund ist", "nicht nur, sondern +auch", "entscheidend ist", "der groesste Hebel", "macht den Unterschied", +Meta-Saetze wie "Wir zeigen Ihnen", unbelegte Behauptungen wie "erfahrungsgemaess" +oder "in aller Regel", Aufzaehlungen mit erstens und zweitens, Dreierreihen aus +Adjektiven, rhetorische Fragen mit Sofortantwort, gleichfoermige Satzlaengen, +gleiche Satz- und Absatzanfaenge, gleich lange Absaetze und fehlendes +Praxiswissen. + +Die Regeln liegen in `build/stilpruefung.js` und lassen sich dort erweitern. +Lokal testen: + +``` +node -e "const {stilpruefung}=require('./build/stilpruefung.js');console.log(stilpruefung(require('./artikel/artikel-SLUG.json')))" +``` + +Danach `python3 build/gen_wf2.py` und den Workflow in n8n aktualisieren. + +### Praxiswissen + +Jede Zeile in `artikel-index.json` hat ein Feld `praxis`, eine Liste von drei +bis fuenf Stichpunkten aus der eigenen Arbeit: Beispiele, typische Fehler, +Zahlen aus der Produktion, klare Empfehlungen, auch Faelle, in denen Handschrift +sich nicht lohnt. Workflow 1 legt sie in den Brief. Ist die Liste leer, wird der +Artikel nicht geschrieben, sondern zuerst das Praxiswissen eingeholt. + ### Nachbesserungsbericht `/evaluate-content` liefert ausschliesslich `{status, content_score}`, diff --git a/build/gen_wf1.py b/build/gen_wf1.py index feaa75f..512b84d 100644 --- a/build/gen_wf1.py +++ b/build/gen_wf1.py @@ -276,7 +276,8 @@ const brief = { wortzahl_ziel: ((n.metrics || {}).word_count || {}).target ?? null, // --- Artikelkontext aus der Tabelle --- - artikel: { slug: a.slug, titel: a.titel, kategorie: a.kategorie, cta_ziel: a.cta_ziel }, + artikel: { slug: a.slug, titel: a.titel, kategorie: a.kategorie, cta_ziel: a.cta_ziel, + praxis: Array.isArray(a.praxis) ? a.praxis : [] }, // --- Zusatz fuer die Schreibvorlage --- zusatz: { @@ -308,6 +309,18 @@ const brief = { faq: 'FAQ nicht in content_html schreiben, sondern als Feld faq mit Frage-Antwort-Paaren. ' + 'Die Ausgabe im Beitrag uebernimmt der Shortcode [skrift_faq].', kurzantwort: 'Ein Absatz, der die Hauptfrage direkt beantwortet, als eigenes Feld kurzantwort.', + praxis: 'Der Artikel baut auf den Stichpunkten in artikel.praxis auf. Jeder Stichpunkt kommt ' + + 'erkennbar im Text vor, als Beispiel, Erfahrung oder klare Empfehlung. Ist die Liste leer, ' + + 'wird der Artikel nicht geschrieben, sondern zuerst Praxiswissen bei Lucas erfragt.', + begriffe: 'begriffe_pflicht sind verbindlich. begriffe_erweitert nur verwenden, wenn der Satz ' + + 'dadurch nicht schlechter wird. Keine Saetze, die nur fuer den NeuronWriter-Score da sind. ' + + 'Keine Wettbewerbernamen. Zielwert im Import ist 65.', + stil: 'Kurze und lange Saetze mischen. Absaetze unterschiedlich lang. Keine Formeln wie ' + + '"Genau das", "Der Grund ist", "nicht nur, sondern auch", "entscheidend ist", ' + + '"der groesste Hebel", "macht den Unterschied". Keine Aufzaehlung mit erstens, zweitens. ' + + 'Keine rhetorische Frage mit Sofortantwort. Keine Behauptung ohne Beleg, also kein ' + + '"erfahrungsgemaess" oder "in aller Regel". Wo es passt, auch abraten: wann sich ' + + 'Handschrift nicht lohnt.', }, meta: { diff --git a/build/gen_wf2.py b/build/gen_wf2.py index c0aca9b..6334c46 100644 --- a/build/gen_wf2.py +++ b/build/gen_wf2.py @@ -4,6 +4,8 @@ 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_WP = {"httpBasicAuth": {"id": "WIN05TZgM0K5VOGy", "name": "WP skrift.de Redakteur"}} nodes, conns = [], {} @@ -66,7 +68,7 @@ KONF = node("Konfiguration", "n8n-nodes-base.set", 3.4, [-240, 190], { {"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": "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": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string"}, @@ -135,7 +137,7 @@ BRIEF = http("Brief laden", [1120, 420], "GET", extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}}) link(ART, BRIEF, out=0) -PRUEFEN = code("Artikel pruefen", [1340, 420], r""" +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; @@ -183,8 +185,12 @@ const bewertungs_html = [ faqText ? `
([\s\S]*?)<\/p>/g)].map(m => ohneTags(m[1])).filter(Boolean); + const volltext = [ohneTags(artikel.kurzantwort), ohneTags(html), + ...(Array.isArray(artikel.faq) ? artikel.faq.map(f => ohneTags(f.antwort)) : [])].join(' '); + + // 1) Floskeln und Formeln, die in generierten Texten gehaeuft auftreten + const formeln = [ + ['genau das', 'Formel'], ['genau diese', 'Formel'], ['genau daran', 'Formel'], ['genau so', 'Formel'], + ['der grund ist', 'Formel'], ['nicht nur', 'Formel "nicht nur, sondern auch"'], + ['entscheidend ist', 'Formel'], ['entscheidend für', 'Formel'], ['größten hebel', 'Formel'], + ['den unterschied', 'Formel'], ['am ende des tages', 'Floskel'], ['unterm strich', 'Floskel'], + ['kurz gesagt', 'Floskel'], ['mit anderen worten', 'Floskel'], ['die gute nachricht', 'Floskel'], + ['spielt eine rolle', 'Floskel'], ['es lohnt sich', 'Floskel'], ['egal ob', 'Formel'], + ['in diesem artikel', 'Meta-Satz'], ['wir zeigen ihnen', 'Meta-Satz'], ['fazit', 'Zusammenfassung'], + ['tatsächlich', 'Fuellwort'], ['letztlich', 'Fuellwort'], ['schlicht', 'Fuellwort'], ['simpel', 'Fuellwort'], + ['erfahrungsgemäß', 'unbelegte Behauptung'], ['in aller regel', 'unbelegte Behauptung'], + ['studien zeigen', 'unbelegte Behauptung'], ['nachweislich', 'unbelegte Behauptung'], + ['bekanntlich', 'Floskel'], + ]; + const klein = volltext.toLowerCase(); + formeln.forEach(([f, art]) => { + let i = klein.indexOf(f); + while (i >= 0) { + add(art + ': "' + f + '"', '…' + volltext.slice(Math.max(0, i - 40), i + f.length + 50) + '…'); + i = klein.indexOf(f, i + f.length); + } + }); + + // 2) Aufzaehlungen im Fliesstext + if (/\berstens\b[\s\S]*\bzweitens\b/i.test(volltext)) add('Aufzaehlung "Erstens, zweitens"', 'Im Fliesstext lieber eine echte Liste oder zwei Saetze'); + const dreier = volltext.match(/\b\w+(?:ig|lich|ell|bar|iv|sam)\b, \w+(?:ig|lich|ell|bar|iv|sam)\b und \w+(?:ig|lich|ell|bar|iv|sam)\b/gi) || []; + dreier.forEach(d => add('Dreierreihe aus Adjektiven', d)); + + // 3) Rhetorische Frage im Absatz, direkt beantwortet + absaetze.forEach(a => { + const m = a.match(/[^.!?]{8,}\?\s+[A-ZÄÖÜ][^.!?]{3,}[.!]/); + if (m) add('Rhetorische Frage mit Sofortantwort', m[0]); + }); + + // 4) Satzlaengen: gleichfoermiger Rhythmus + const saetze = ohneTags(html).split(/(?<=[.!?])\s+/).map(s => s.trim()) + .filter(s => s.split(' ').length >= 3); + const laengen = saetze.map(s => s.split(' ').length); + if (laengen.length >= 8) { + const mittel = laengen.reduce((x, y) => x + y, 0) / laengen.length; + const sd = Math.sqrt(laengen.reduce((x, y) => x + (y - mittel) ** 2, 0) / laengen.length); + if (sd < 4.5) add('Gleichfoermige Satzlaengen', `Durchschnitt ${mittel.toFixed(1)} Woerter, Streuung nur ${sd.toFixed(1)}. Kurze und lange Saetze mischen.`); + for (let i = 0; i + 4 <= laengen.length; i++) { + const fenster = laengen.slice(i, i + 4); + if (Math.max(...fenster) - Math.min(...fenster) <= 3) { + add('Vier Saetze gleicher Laenge hintereinander', saetze[i]); + i += 3; + } + } + } + + // 5) Gleiche Satzanfaenge + for (let i = 0; i + 3 <= saetze.length; i++) { + const w = saetze.slice(i, i + 3).map(s => s.split(' ')[0].toLowerCase()); + if (w[0] === w[1] && w[1] === w[2] && w[0].length > 2) { add('Drei Saetze mit gleichem Anfang', saetze[i]); i += 2; } + } + const anfaenge = {}; + absaetze.forEach(a => { const k = a.split(' ').slice(0, 2).join(' ').toLowerCase(); anfaenge[k] = (anfaenge[k] || 0) + 1; }); + Object.entries(anfaenge).filter(([, n]) => n >= 3).forEach(([k, n]) => add('Absatzanfaenge wiederholen sich', `${n} Absaetze beginnen mit "${k}"`)); + + // 6) Gleich lange Absaetze + const al = absaetze.map(a => a.split(' ').length).filter(n => n >= 12); + if (al.length >= 5) { + const m = al.reduce((x, y) => x + y, 0) / al.length; + const sd = Math.sqrt(al.reduce((x, y) => x + (y - m) ** 2, 0) / al.length); + if (sd / m < 0.25) add('Absaetze fast gleich lang', `Durchschnitt ${m.toFixed(0)} Woerter, kaum Abweichung. Einen Absatz bewusst kurz halten.`); + } + + // 7) Praxiswissen: ohne eigene Stichpunkte bleibt der Text generisch + if (!Array.isArray(artikel.praxis) || artikel.praxis.length === 0) { + add('Kein Praxiswissen hinterlegt', 'Feld "praxis" in artikel-index.json fuellen, dann den Artikel darauf aufbauen'); + } + + return hinweise; +} +if (typeof module !== 'undefined') module.exports = { stilpruefung }; diff --git a/workflows/brief-vorbereiten.json b/workflows/brief-vorbereiten.json index e641018..b21b75c 100644 --- a/workflows/brief-vorbereiten.json +++ b/workflows/brief-vorbereiten.json @@ -9,7 +9,7 @@ -460, 40 ], - "id": "5ad6950e-a5e9-4d1c-9f45-64e7f40c1345", + "id": "260ec628-48b5-4211-92df-2f12c211c736", "name": "Manuell starten" }, { @@ -33,7 +33,7 @@ -460, 200 ], - "id": "55fa4d8d-8362-4662-b422-5cf03694b4f8", + "id": "16f0bac4-8005-4dde-bf37-a38ff71fef70", "name": "Zeitplan Montag 07:00", "disabled": true }, @@ -50,9 +50,9 @@ -460, 360 ], - "id": "675b0a53-a893-40ef-99d6-fafaa8ac0792", + "id": "ab067d79-fb3a-430a-9361-7414ff5958d8", "name": "Webhook Start", - "webhookId": "2de1c213-4fb5-4e91-8b5c-9ce69ef25ca3" + "webhookId": "50276479-7f98-4b3e-8149-98156f310211" }, { "parameters": { @@ -60,85 +60,85 @@ "assignments": { "assignments": [ { - "id": "d43ddbb3-2514-41c6-b888-02abc4e15d41", + "id": "1cdff46d-72b5-4134-8033-1eb408b0b5ea", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "bd516db2-6009-4bba-99d2-189750fc1cd2", + "id": "9e99cf26-df0d-4a3c-8918-7b9d106ee173", "name": "nw_project", "value": "3da4d745e0e223cb", "type": "string" }, { - "id": "7ec57b28-ee76-4fdb-87e6-5e07c56cdd4d", + "id": "a2b26c3e-1622-418b-899a-fec46ef2728f", "name": "nw_engine", "value": "google.de", "type": "string" }, { - "id": "e670bd6a-6327-4570-ac3b-09baff9dc81a", + "id": "ff4d76cd-46e8-4a2c-a32c-bc84fc0a7f00", "name": "nw_language", "value": "German", "type": "string" }, { - "id": "c5671976-8b69-4788-9e36-86165d5e97f4", + "id": "75a7f1ec-01fa-4661-8f78-c149666ccc8a", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "17ab5e1f-db56-45ad-8392-ff0dfed8a965", + "id": "f389f8d3-c68d-40e2-9f41-797772f3aa11", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "3a233c4d-69b7-47cc-97f6-0dc06c2923f6", + "id": "7dc436f3-31d5-454a-ae6a-fa9866845f8d", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "83e56d69-847f-485c-9331-acffd8390fda", + "id": "efa63b44-4c3f-45f9-9cc4-b3f60d43f764", "name": "branch", "value": "main", "type": "string" }, { - "id": "9ddffd30-8245-4e0e-85c6-4ef350dc45d9", + "id": "b38db5de-7a58-41bc-8399-5a4c66843927", "name": "index_pfad", "value": "artikel-index.json", "type": "string" }, { - "id": "47ac93b9-529b-43a4-a3d1-76d83f056827", + "id": "84b0e1f0-3f1a-40d8-b27c-213eb2c02eec", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "67d79c76-ca3a-4066-97a8-6d135f55244e", + "id": "58f3fbef-d444-47fc-9943-17f7f9c18d37", "name": "poll_max", "value": 30, "type": "number" }, { - "id": "0766321e-b286-48b9-90c3-63b0f9d22a82", + "id": "098701a1-de5b-4804-a9c0-81fe9a6204d7", "name": "poll_sekunden", "value": 20, "type": "number" }, { - "id": "9effca14-631e-419d-8dba-4e5c44c1b817", + "id": "9bc5db24-3fac-48bb-a118-273198c2eab3", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "4cf2db61-c55a-43d1-8d7b-427e35ac703e", + "id": "08c47277-5335-4ea4-b48b-48a43af36fbd", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -153,7 +153,7 @@ -240, 200 ], - "id": "aba36956-c83a-44c4-9012-fe80979f8545", + "id": "2fc4b728-84b7-466c-a2ae-74bf117d5fa1", "name": "Konfiguration" }, { @@ -177,7 +177,7 @@ -20, 200 ], - "id": "0ebb8bf1-d129-4066-8c59-2b4c3b27cd52", + "id": "9d8d9368-18b3-43b0-acd5-22214e0c9aa5", "name": "Artikeltabelle lesen", "credentials": { "httpHeaderAuth": { @@ -201,7 +201,7 @@ -20, 420 ], - "id": "5093a5ca-b790-459f-bf12-5e221431323f", + "id": "0dd66fcb-5058-481b-a94d-3e06529708cf", "name": "Fehler Artikeltabelle" }, { @@ -215,7 +215,7 @@ 200, 200 ], - "id": "335206d7-0cd9-4e66-9534-bdf52618deac", + "id": "bd1be404-0967-4b39-bef3-61fbb0069745", "name": "Tabelle auswerten" }, { @@ -229,7 +229,7 @@ }, "conditions": [ { - "id": "90341bb9-1b82-49ee-8a6e-3351c7140dbe", + "id": "e9f8b220-567d-4ef9-9c9c-05ff695b6ab7", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -250,7 +250,7 @@ 420, 200 ], - "id": "74528ffd-5113-4ada-a8a9-aa4e1e8a5309", + "id": "2577013e-3a1e-4960-b5d9-f727d45c6cdc", "name": "Offene Artikel vorhanden?" }, { @@ -261,7 +261,7 @@ 640, 60 ], - "id": "c7123211-a46c-4446-a10d-5ba1083b05b2", + "id": "80b41612-8f5f-4faa-911f-b2f964dc9db0", "name": "Nichts zu tun" }, { @@ -277,7 +277,7 @@ 640, 320 ], - "id": "b690989b-0358-43aa-b7e3-efd5511b3d58", + "id": "4efca105-2742-4f2c-b248-4bd702453fe2", "name": "Artikel nacheinander" }, { @@ -307,7 +307,7 @@ 900, 420 ], - "id": "95a1f069-15c6-405e-888e-2d317891b28c", + "id": "647e03b7-5ae9-44ca-acbc-9309338b7486", "name": "Analyse starten", "credentials": { "httpHeaderAuth": { @@ -331,7 +331,7 @@ 900, 640 ], - "id": "bb797361-40f3-46da-b359-c2ce94ad3bec", + "id": "7a4abc2e-df42-4fdb-bb9b-6c2984c011c2", "name": "Fehler Analyse starten", "onError": "continueRegularOutput" }, @@ -346,7 +346,7 @@ 1120, 420 ], - "id": "5320b757-5a3f-465e-9b3f-3da46b590824", + "id": "0a808a0a-74f3-4033-b0e3-9e5dd3bac559", "name": "Poll vorbereiten" }, { @@ -360,9 +360,9 @@ 1340, 420 ], - "id": "0b0db985-8687-4b8a-b183-7a1072d8180f", + "id": "36006210-77af-41b6-9478-fbaa85729e46", "name": "Warten", - "webhookId": "dcd62081-3c28-4b1c-87b1-058ee2e774fc" + "webhookId": "febdadfe-bb41-4a36-b19c-ed9f73a49f33" }, { "parameters": { @@ -391,7 +391,7 @@ 1560, 420 ], - "id": "c2d91e7a-97cd-446d-9f59-973d2fd2e872", + "id": "58b3d12c-37c6-49f6-926a-0702ec80e38d", "name": "Analyse abfragen", "credentials": { "httpHeaderAuth": { @@ -415,7 +415,7 @@ 1560, 640 ], - "id": "32ea6fc9-45ad-4e9c-86db-5eda4a369308", + "id": "b81a94e6-6ebd-4f93-b4ea-f42f0c784d76", "name": "Fehler Analyse abfragen", "onError": "continueRegularOutput" }, @@ -430,7 +430,7 @@ }, "conditions": [ { - "id": "540396ef-e549-47e4-aab0-2658f397d1b0", + "id": "09def6f2-cc81-49ba-90c8-d445980a842b", "leftValue": "={{ $json.status }}", "rightValue": "ready", "operator": { @@ -450,7 +450,7 @@ 1780, 420 ], - "id": "3611443b-1e91-4241-8c61-3c18e3a6a3d2", + "id": "1be6257c-67fc-431d-b485-c155e0d6cd35", "name": "Analyse fertig?" }, { @@ -464,7 +464,7 @@ 1780, 660 ], - "id": "524f0b05-2fce-4223-9974-dbfd9571e9ac", + "id": "e2ca0648-f4ba-428d-8547-6bfb99869933", "name": "Versuch zaehlen" }, { @@ -478,7 +478,7 @@ }, "conditions": [ { - "id": "f6a87ddd-b2da-4164-90ff-159bf3d0e33e", + "id": "2e05e26d-97e6-42e0-bdfb-6f0594935519", "leftValue": "={{ $json.versuch }}", "rightValue": "={{ $('Konfiguration').first().json.poll_max }}", "operator": { @@ -498,7 +498,7 @@ 2000, 660 ], - "id": "5bcea09f-ad45-4347-ad0d-fcefbbae7b50", + "id": "20f7854d-52bd-4c9f-be98-21b79e242993", "name": "Poll-Limit erreicht?" }, { @@ -512,13 +512,13 @@ 2220, 780 ], - "id": "f2b2da7e-2014-460f-af19-d9d08c1c8516", + "id": "4809a2ca-50ec-4733-aacc-cb70a095510a", "name": "Timeout melden" }, { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "\n// Feldnamen stammen aus einer echten /get-query-Antwort, siehe docs/feldnachweis.md\nconst a = $('Poll vorbereiten').first().json;\nconst n = $input.first().json;\n\nconst liste = (arr) => (arr || []).map(x => ({\n begriff: x.t,\n min: Array.isArray(x.sugg_usage) ? x.sugg_usage[0] : null,\n max: Array.isArray(x.sugg_usage) ? x.sugg_usage[1] : null,\n bei_wettbewerbern_pc: x.usage_pc ?? null,\n}));\n\nconst fragen = [];\nconst gesehen = new Set();\nconst addFrage = (q, quelle, gewicht) => {\n if (!q) return;\n const k = q.trim().toLowerCase();\n if (gesehen.has(k)) return;\n gesehen.add(k);\n fragen.push({ frage: q.trim(), quelle, gewicht });\n};\nObject.entries((n.ideas && n.ideas.topic_matrix) || {})\n .sort((x, y) => (y[1].importance || 0) - (x[1].importance || 0))\n .forEach(([q, v]) => addFrage(q, 'topic_matrix', v.importance || null));\n((n.ideas || {}).people_also_ask || []).forEach(x => addFrage(x.q, 'people_also_ask', null));\n((n.ideas || {}).suggest_questions || []).forEach(x => addFrage(x.q, 'suggest_questions', null));\n((n.ideas || {}).content_questions || []).forEach(x => addFrage(x.q, 'content_questions', null));\n\nconst brief = {\n // --- die vom Auftrag geforderten Felder ---\n query_id: n.query,\n keyword: n.keyword,\n begriffe_pflicht: liste((n.terms || {}).content_basic),\n begriffe_erweitert: liste((n.terms || {}).content_extended),\n fragen,\n wortzahl_ziel: ((n.metrics || {}).word_count || {}).target ?? null,\n\n // --- Artikelkontext aus der Tabelle ---\n artikel: { slug: a.slug, titel: a.titel, kategorie: a.kategorie, cta_ziel: a.cta_ziel },\n\n // --- Zusatz fuer die Schreibvorlage ---\n zusatz: {\n wortzahl_median: ((n.metrics || {}).word_count || {}).median ?? null,\n lesbarkeit_ziel: ((n.metrics || {}).readability || {}).target ?? null,\n begriffe_titel: ((n.terms || {}).title || []).map(x => x.t),\n begriffe_description: ((n.terms || {}).desc || []).map(x => x.t),\n begriffe_h1: ((n.terms || {}).h1 || []).map(x => x.t),\n begriffe_h2: ((n.terms || {}).h2 || []).map(x => x.t),\n entitaeten: ((n.terms || {}).entities || []).slice(0, 15).map(x => ({ name: x.t, wichtigkeit: x.importance })),\n suchintention: (n.serp_summary || {}).top_intent ?? null,\n haeufigster_inhaltstyp: (n.serp_summary || {}).top_content_type ?? null,\n gliederungen_top5: (n.competitors || []).slice(0, 5).map(c => ({\n rang: c.rank, url: c.url, score: c.content_score, wortzahl: c.word_count,\n ueberschriften: (c.headers || []).map(h => `${h[0]}: ${h[1]}`).slice(0, 25),\n })),\n terms_txt_content_basic: ((n.terms_txt || {}).content_basic_w_ranges) ?? null,\n },\n\n schreibregeln: {\n preise: 'Preise niemals ausschreiben. Immer den Shortcode einsetzen: '\n + '[skrift_preis key=\"SCHLUESSEL\" ab=\"0|1\" brutto=\"0|1\"]. '\n + 'key ist der Produktschluessel (z. B. briefe, postkarten, porto_inland). '\n + 'ab=1 liefert den guenstigsten Staffelpreis, ab=0 den Standardpreis. '\n + 'brutto=1 inkl. MwSt., brutto=0 netto. Das Wort \"ab\" selbst gehoert in den Fliesstext, '\n + 'der Shortcode liefert nur die Zahl. Beispiel: '\n + 'Handschriftliche Briefe ab [skrift_preis key=\"briefe\" ab=\"1\"] pro Stueck. '\n + 'Ein unbekannter key ergibt eine leere Ausgabe, keinen Fehler.',\n faq: 'FAQ nicht in content_html schreiben, sondern als Feld faq mit Frage-Antwort-Paaren. '\n + 'Die Ausgabe im Beitrag uebernimmt der Shortcode [skrift_faq].',\n kurzantwort: 'Ein Absatz, der die Hauptfrage direkt beantwortet, als eigenes Feld kurzantwort.',\n },\n\n meta: {\n erzeugt_am: new Date().toISOString(),\n analyse_url: n.query_url || a.query_url || null,\n freigabe_url: n.share_url || a.share_url || null,\n sprache: n.language, suchmaschine: n.engine, projekt: n.project,\n },\n};\n\nreturn [{ json: { slug: a.slug, zeilen_index: a.zeilen_index, query: n.query,\n keyword: n.keyword, brief } }];\n" + "jsCode": "\n// Feldnamen stammen aus einer echten /get-query-Antwort, siehe docs/feldnachweis.md\nconst a = $('Poll vorbereiten').first().json;\nconst n = $input.first().json;\n\nconst liste = (arr) => (arr || []).map(x => ({\n begriff: x.t,\n min: Array.isArray(x.sugg_usage) ? x.sugg_usage[0] : null,\n max: Array.isArray(x.sugg_usage) ? x.sugg_usage[1] : null,\n bei_wettbewerbern_pc: x.usage_pc ?? null,\n}));\n\nconst fragen = [];\nconst gesehen = new Set();\nconst addFrage = (q, quelle, gewicht) => {\n if (!q) return;\n const k = q.trim().toLowerCase();\n if (gesehen.has(k)) return;\n gesehen.add(k);\n fragen.push({ frage: q.trim(), quelle, gewicht });\n};\nObject.entries((n.ideas && n.ideas.topic_matrix) || {})\n .sort((x, y) => (y[1].importance || 0) - (x[1].importance || 0))\n .forEach(([q, v]) => addFrage(q, 'topic_matrix', v.importance || null));\n((n.ideas || {}).people_also_ask || []).forEach(x => addFrage(x.q, 'people_also_ask', null));\n((n.ideas || {}).suggest_questions || []).forEach(x => addFrage(x.q, 'suggest_questions', null));\n((n.ideas || {}).content_questions || []).forEach(x => addFrage(x.q, 'content_questions', null));\n\nconst brief = {\n // --- die vom Auftrag geforderten Felder ---\n query_id: n.query,\n keyword: n.keyword,\n begriffe_pflicht: liste((n.terms || {}).content_basic),\n begriffe_erweitert: liste((n.terms || {}).content_extended),\n fragen,\n wortzahl_ziel: ((n.metrics || {}).word_count || {}).target ?? null,\n\n // --- Artikelkontext aus der Tabelle ---\n artikel: { slug: a.slug, titel: a.titel, kategorie: a.kategorie, cta_ziel: a.cta_ziel,\n praxis: Array.isArray(a.praxis) ? a.praxis : [] },\n\n // --- Zusatz fuer die Schreibvorlage ---\n zusatz: {\n wortzahl_median: ((n.metrics || {}).word_count || {}).median ?? null,\n lesbarkeit_ziel: ((n.metrics || {}).readability || {}).target ?? null,\n begriffe_titel: ((n.terms || {}).title || []).map(x => x.t),\n begriffe_description: ((n.terms || {}).desc || []).map(x => x.t),\n begriffe_h1: ((n.terms || {}).h1 || []).map(x => x.t),\n begriffe_h2: ((n.terms || {}).h2 || []).map(x => x.t),\n entitaeten: ((n.terms || {}).entities || []).slice(0, 15).map(x => ({ name: x.t, wichtigkeit: x.importance })),\n suchintention: (n.serp_summary || {}).top_intent ?? null,\n haeufigster_inhaltstyp: (n.serp_summary || {}).top_content_type ?? null,\n gliederungen_top5: (n.competitors || []).slice(0, 5).map(c => ({\n rang: c.rank, url: c.url, score: c.content_score, wortzahl: c.word_count,\n ueberschriften: (c.headers || []).map(h => `${h[0]}: ${h[1]}`).slice(0, 25),\n })),\n terms_txt_content_basic: ((n.terms_txt || {}).content_basic_w_ranges) ?? null,\n },\n\n schreibregeln: {\n preise: 'Preise niemals ausschreiben. Immer den Shortcode einsetzen: '\n + '[skrift_preis key=\"SCHLUESSEL\" ab=\"0|1\" brutto=\"0|1\"]. '\n + 'key ist der Produktschluessel (z. B. briefe, postkarten, porto_inland). '\n + 'ab=1 liefert den guenstigsten Staffelpreis, ab=0 den Standardpreis. '\n + 'brutto=1 inkl. MwSt., brutto=0 netto. Das Wort \"ab\" selbst gehoert in den Fliesstext, '\n + 'der Shortcode liefert nur die Zahl. Beispiel: '\n + 'Handschriftliche Briefe ab [skrift_preis key=\"briefe\" ab=\"1\"] pro Stueck. '\n + 'Ein unbekannter key ergibt eine leere Ausgabe, keinen Fehler.',\n faq: 'FAQ nicht in content_html schreiben, sondern als Feld faq mit Frage-Antwort-Paaren. '\n + 'Die Ausgabe im Beitrag uebernimmt der Shortcode [skrift_faq].',\n kurzantwort: 'Ein Absatz, der die Hauptfrage direkt beantwortet, als eigenes Feld kurzantwort.',\n praxis: 'Der Artikel baut auf den Stichpunkten in artikel.praxis auf. Jeder Stichpunkt kommt '\n + 'erkennbar im Text vor, als Beispiel, Erfahrung oder klare Empfehlung. Ist die Liste leer, '\n + 'wird der Artikel nicht geschrieben, sondern zuerst Praxiswissen bei Lucas erfragt.',\n begriffe: 'begriffe_pflicht sind verbindlich. begriffe_erweitert nur verwenden, wenn der Satz '\n + 'dadurch nicht schlechter wird. Keine Saetze, die nur fuer den NeuronWriter-Score da sind. '\n + 'Keine Wettbewerbernamen. Zielwert im Import ist 65.',\n stil: 'Kurze und lange Saetze mischen. Absaetze unterschiedlich lang. Keine Formeln wie '\n + '\"Genau das\", \"Der Grund ist\", \"nicht nur, sondern auch\", \"entscheidend ist\", '\n + '\"der groesste Hebel\", \"macht den Unterschied\". Keine Aufzaehlung mit erstens, zweitens. '\n + 'Keine rhetorische Frage mit Sofortantwort. Keine Behauptung ohne Beleg, also kein '\n + '\"erfahrungsgemaess\" oder \"in aller Regel\". Wo es passt, auch abraten: wann sich '\n + 'Handschrift nicht lohnt.',\n },\n\n meta: {\n erzeugt_am: new Date().toISOString(),\n analyse_url: n.query_url || a.query_url || null,\n freigabe_url: n.share_url || a.share_url || null,\n sprache: n.language, suchmaschine: n.engine, projekt: n.project,\n },\n};\n\nreturn [{ json: { slug: a.slug, zeilen_index: a.zeilen_index, query: n.query,\n keyword: n.keyword, brief } }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -526,7 +526,7 @@ 2220, 300 ], - "id": "e7c0babd-988d-4a5a-9cdd-ba6de4ecf07e", + "id": "e205635c-b327-4cc2-abf8-585485a73d68", "name": "Brief bauen" }, { @@ -551,7 +551,7 @@ 2440, 300 ], - "id": "537e73ab-e8a5-44a5-b88d-1052ccd1bc61", + "id": "dcfe6d64-d36d-4730-b33a-2f8f5693d770", "name": "Brief vorhanden?", "credentials": { "httpHeaderAuth": { @@ -571,7 +571,7 @@ 2660, 300 ], - "id": "8a18d86d-fda2-4f65-bed9-7deda83a1acb", + "id": "39308915-ea33-42d4-bbe3-67786dfbd2ed", "name": "Commit vorbereiten" }, { @@ -601,7 +601,7 @@ 2880, 300 ], - "id": "db44ac17-59f2-437a-bb7d-94aa4da5a074", + "id": "972ef68d-55db-45f0-9b32-0647607e535f", "name": "Brief committen", "credentials": { "httpHeaderAuth": { @@ -625,7 +625,7 @@ 2880, 520 ], - "id": "31a3c38b-c79a-42cb-9ac1-6710deb2fd02", + "id": "e9beac3d-0306-45d1-a547-0fcddb091810", "name": "Fehler Brief committen", "onError": "continueRegularOutput" }, @@ -640,7 +640,7 @@ 3100, 300 ], - "id": "5d243ba5-1e7e-4f16-8037-9eed05687643", + "id": "b740cc47-e748-4ff4-be7f-9128b06ad303", "name": "Erfolg merken" }, { @@ -654,9 +654,9 @@ 3320, 400 ], - "id": "997a2d3f-ddca-4081-9ded-2eb521163b1b", + "id": "dc146423-d4db-4a68-8061-cd123d286199", "name": "Pause Rate Limit", - "webhookId": "3f510a7a-22c1-4248-a7c1-60fe57786eb2" + "webhookId": "cd90d1e6-1533-4c3b-bc64-46e9e69546b0" }, { "parameters": { @@ -669,7 +669,7 @@ 900, 140 ], - "id": "8f0de534-6cea-40af-bf64-e63651648df7", + "id": "9d684894-aae3-4fe5-a312-ee6daa993ece", "name": "Ergebnisse sammeln" }, { @@ -683,7 +683,7 @@ 1120, 140 ], - "id": "9597c915-36d3-4f94-af9d-60351fa4ac41", + "id": "e6350ae8-2d5d-47f6-ae57-b038ade331ac", "name": "Statusupdate vorbereiten" }, { @@ -713,7 +713,7 @@ 1340, 140 ], - "id": "a81137d7-c063-4f56-ac56-2ac6fffa9560", + "id": "a2212ee0-aa8a-41e2-a684-f30db2eadf16", "name": "Status zurueckschreiben", "credentials": { "httpHeaderAuth": { @@ -737,7 +737,7 @@ 1340, -60 ], - "id": "35cff298-1691-452f-bc25-a225ee241a6e", + "id": "f9fb0933-c769-4722-b8e9-02b4169a8046", "name": "Fehler Statusupdate" }, { @@ -751,7 +751,7 @@ 1560, 140 ], - "id": "ad2d0735-fd21-4181-bb7f-8bc51bd01435", + "id": "3f84a5c1-eedf-4a0d-be53-d0969c687aa5", "name": "Meldung formulieren" }, { @@ -769,7 +769,7 @@ 1780, 140 ], - "id": "03d899b0-4d69-4105-a755-eaa1792d7497", + "id": "c7d18957-008d-4492-87fc-bdb547cc8823", "name": "Benachrichtigung", "disabled": true, "notes": "SMTP-Credential zuweisen, Absender eintragen, dann Node aktivieren." diff --git a/workflows/pruefen-und-importieren.json b/workflows/pruefen-und-importieren.json index c1df659..a9b44f9 100644 --- a/workflows/pruefen-und-importieren.json +++ b/workflows/pruefen-und-importieren.json @@ -9,7 +9,7 @@ -460, 100 ], - "id": "793c9880-e989-44d5-b514-d6850b64fd2e", + "id": "633f2486-1cde-41d1-b4cd-4e24a1a1d75f", "name": "Manuell starten" }, { @@ -25,9 +25,9 @@ -460, 280 ], - "id": "0bb95de2-96dd-4f8b-af1e-3bab20eca241", + "id": "21be5d6d-b7fd-4268-a79e-c5203e0d1864", "name": "Webhook Start", - "webhookId": "53c83399-7059-4816-8f7c-c606015c02c0" + "webhookId": "daec5bda-055a-416e-85a7-085b1a644a70" }, { "parameters": { @@ -35,91 +35,91 @@ "assignments": { "assignments": [ { - "id": "f161eded-c23e-4d60-9d8b-d4db483ec3d9", + "id": "77fdf721-4ee6-4f16-86aa-a44ccd53c30e", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "0544c6b7-e388-4bb4-b418-2dc97bc56bb6", + "id": "833ef6fb-7143-4a87-9f21-e734d263dba1", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "46b15bb0-7e1a-48f5-addb-e8ca1a46491a", + "id": "48ba9170-6828-4986-b0bb-9a8ff1b96f39", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "46eb7849-83d0-4cd2-b978-6a49c4ddb31e", + "id": "58ba48b6-3e92-4796-a905-760b4d49fabd", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "84f7d295-04fa-4f95-b723-98f0dc1a042b", + "id": "8adca23e-46c1-4431-95b8-39836573b510", "name": "branch", "value": "main", "type": "string" }, { - "id": "1bd8cf46-e184-47c5-897a-aa4b4d08d591", + "id": "e131a743-ace6-41b2-a676-3265b3c1adba", "name": "artikel_ordner", "value": "artikel", "type": "string" }, { - "id": "1c6a8050-c17c-4635-b371-cc5a40fda4e5", + "id": "8e16537a-e986-4b7d-9e13-19fed65045b1", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "9eb50d4b-0ac5-4bd9-8733-fe1bcb82b601", + "id": "0d4e2fcb-a677-4be7-8f92-4cad4cdb2a6a", "name": "nachbessern_ordner", "value": "nachbessern", "type": "string" }, { - "id": "91ec4a89-c907-403d-aaa4-77e074a85992", + "id": "8081b290-1356-4d17-a4da-9aec977846ae", "name": "status_pfad", "value": "status-importe.json", "type": "string" }, { - "id": "7e6d86e6-4195-4cdd-b3b2-1ee1fceb9805", + "id": "58a53681-c4b6-41db-8ee0-ef2192c11801", "name": "wp_base", "value": "https://skrift.de", "type": "string" }, { - "id": "405fcf78-a5a0-4fb8-9f04-d8041906f2b3", + "id": "f48b0d70-1acb-432f-8773-768c2cd708fe", "name": "score_ziel", - "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 72 }}", + "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 65 }}", "type": "number" }, { - "id": "4e9becf4-a380-407b-b4b9-b8b821e2992f", + "id": "1b83a8b2-ab79-4da1-a6aa-60e932a0e239", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "11c7167c-f60e-47c6-9832-9ef0124628c2", + "id": "da8882a2-1c90-4d3e-80c2-fcf4553033c6", "name": "elementor_ausgabe", "value": false, "type": "boolean" }, { - "id": "0a4b7c2b-14f5-43cb-ae2a-64d30d58a1f4", + "id": "06e2454a-7586-4810-8511-dae3cd4c6fad", "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string" }, { - "id": "debc59ab-4e8e-44de-80c2-334c15fc55ac", + "id": "b22de2bd-ccd9-4933-87cc-0f30af3da4f4", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -134,7 +134,7 @@ -240, 190 ], - "id": "86f2b355-8a1a-4336-b2c9-1a2cbb0aa6f1", + "id": "04845694-bb6d-45e7-b215-0eb26088e59c", "name": "Konfiguration" }, { @@ -159,7 +159,7 @@ -20, 190 ], - "id": "144a0300-78e5-4f50-828c-0e0dc617947c", + "id": "3a9a66ee-3f95-474a-9d6c-19848c19e2f9", "name": "Artikelordner lesen", "credentials": { "httpHeaderAuth": { @@ -182,7 +182,7 @@ 200, 190 ], - "id": "03942d18-af94-439b-89a9-d9734d845f83", + "id": "39b7d343-8133-49b9-937e-a207772217c0", "name": "Artikeldateien filtern" }, { @@ -196,7 +196,7 @@ }, "conditions": [ { - "id": "dd545030-181b-40c1-ba1c-563e93f9cdf9", + "id": "9cb6ba6d-2a92-4422-812b-f7f6f9f7aab0", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -217,7 +217,7 @@ 420, 190 ], - "id": "ac2420c7-c498-4e3e-a26e-542f7d585a35", + "id": "dbdc7d6a-1ff5-4038-bc36-9d34e28d10bf", "name": "Artikel vorhanden?" }, { @@ -228,7 +228,7 @@ 640, 40 ], - "id": "1f77ee2a-d8e9-4bcf-9ac9-5f1c1772d5ac", + "id": "4808a51f-d327-416f-a1f2-f197718d7d38", "name": "Nichts zu tun" }, { @@ -244,7 +244,7 @@ 640, 320 ], - "id": "11279052-fb96-4d7b-b03a-4a64b524ab30", + "id": "4a19481b-6597-408b-a3ce-c709d425a81f", "name": "Artikel nacheinander" }, { @@ -268,7 +268,7 @@ 900, 420 ], - "id": "d36af43a-e39d-4e20-9ef8-00101a0dfef5", + "id": "8dd49c8e-760a-49a7-94ab-05b60973dce3", "name": "Artikeldatei laden", "credentials": { "httpHeaderAuth": { @@ -292,7 +292,7 @@ 900, 640 ], - "id": "49f78b05-7b28-4ca6-be18-fb0fb27cfcb9", + "id": "767789d0-15bc-4cb1-b15e-7f8c3941e4ec", "name": "Fehler Artikeldatei", "onError": "continueRegularOutput" }, @@ -318,7 +318,7 @@ 1120, 420 ], - "id": "88ea6484-2c70-4e73-870f-404eb5d43676", + "id": "d7ef6a1e-ff5e-4e58-88bf-9a941202d796", "name": "Brief laden", "credentials": { "httpHeaderAuth": { @@ -330,7 +330,7 @@ { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "\nconst meta = $('Artikel nacheinander').first().json;\nconst artRoh = $('Artikeldatei laden').first().json;\nconst briefAntwort = $input.first().json;\n// n8n liefert den Koerper bei fullResponse je nach Version als body oder data.\nconst K = (r) => (r && r.body !== undefined) ? r.body : ((r || {}).data);\nconst kb = K(briefAntwort);\n\nlet artikel;\ntry { artikel = JSON.parse(Buffer.from(artRoh.content, 'base64').toString('utf8')); }\ncatch (e) { throw new Error('artikel-' + meta.slug + '.json ist kein gueltiges JSON: ' + e.message); }\n\nconst pflicht = ['slug', 'titel', 'query_id', 'kategorie', 'content_html'];\nconst fehlt = pflicht.filter(f => !artikel[f]);\nif (fehlt.length) {\n throw new Error('artikel-' + meta.slug + '.json fehlen Pflichtfelder: ' + fehlt.join(', ')\n + '. Erwartetes Format siehe README Abschnitt \"Vertrag Cowork -> Workflow 2\".');\n}\nif (artikel.slug !== meta.slug) {\n throw new Error('Slug im Dateinamen (' + meta.slug + ') und im JSON (' + artikel.slug + ') stimmen nicht ueberein.');\n}\n\nlet brief = null;\nif (briefAntwort.statusCode === 200 && kb && kb.content) {\n try { brief = JSON.parse(Buffer.from(kb.content, 'base64').toString('utf8')); }\n catch (e) { brief = null; }\n}\n\n// Preise muessen als Shortcode stehen, nie ausgeschrieben.\nconst preisTreffer = [];\nconst re = /(\\d{1,3}(?:[.,]\\d{1,2})?)\\s*(?:€|EUR|Euro)/gi;\nlet m;\nwhile ((m = re.exec(String(artikel.content_html || ''))) !== null) {\n preisTreffer.push(m[0].trim());\n}\nconst shortcodes = (String(artikel.content_html || '').match(/\\[skrift_preis[^\\]]*\\]/g) || []);\n\n// NeuronWriter bewertet nur den uebergebenen Text. Kurzantwort und FAQ stehen\n// aber auf der fertigen Seite, also gehoeren sie in die Bewertung. Der gespeicherte\n// Artikel bleibt unveraendert, das hier ist nur der Pruefkoerper.\nconst faqText = (Array.isArray(artikel.faq) ? artikel.faq : [])\n .map(f => `
${f.antwort || ''}
`).join('\\n');\nconst bewertungs_html = [\n artikel.kurzantwort ? `${artikel.kurzantwort}
` : '',\n artikel.content_html || '',\n faqText ? `([\\s\\S]*?)<\\/p>/g)].map(m => ohneTags(m[1])).filter(Boolean);\n const volltext = [ohneTags(artikel.kurzantwort), ohneTags(html),\n ...(Array.isArray(artikel.faq) ? artikel.faq.map(f => ohneTags(f.antwort)) : [])].join(' ');\n\n // 1) Floskeln und Formeln, die in generierten Texten gehaeuft auftreten\n const formeln = [\n ['genau das', 'Formel'], ['genau diese', 'Formel'], ['genau daran', 'Formel'], ['genau so', 'Formel'],\n ['der grund ist', 'Formel'], ['nicht nur', 'Formel \"nicht nur, sondern auch\"'],\n ['entscheidend ist', 'Formel'], ['entscheidend für', 'Formel'], ['größten hebel', 'Formel'],\n ['den unterschied', 'Formel'], ['am ende des tages', 'Floskel'], ['unterm strich', 'Floskel'],\n ['kurz gesagt', 'Floskel'], ['mit anderen worten', 'Floskel'], ['die gute nachricht', 'Floskel'],\n ['spielt eine rolle', 'Floskel'], ['es lohnt sich', 'Floskel'], ['egal ob', 'Formel'],\n ['in diesem artikel', 'Meta-Satz'], ['wir zeigen ihnen', 'Meta-Satz'], ['fazit', 'Zusammenfassung'],\n ['tatsächlich', 'Fuellwort'], ['letztlich', 'Fuellwort'], ['schlicht', 'Fuellwort'], ['simpel', 'Fuellwort'],\n ['erfahrungsgemäß', 'unbelegte Behauptung'], ['in aller regel', 'unbelegte Behauptung'],\n ['studien zeigen', 'unbelegte Behauptung'], ['nachweislich', 'unbelegte Behauptung'],\n ['bekanntlich', 'Floskel'],\n ];\n const klein = volltext.toLowerCase();\n formeln.forEach(([f, art]) => {\n let i = klein.indexOf(f);\n while (i >= 0) {\n add(art + ': \"' + f + '\"', '…' + volltext.slice(Math.max(0, i - 40), i + f.length + 50) + '…');\n i = klein.indexOf(f, i + f.length);\n }\n });\n\n // 2) Aufzaehlungen im Fliesstext\n if (/\\berstens\\b[\\s\\S]*\\bzweitens\\b/i.test(volltext)) add('Aufzaehlung \"Erstens, zweitens\"', 'Im Fliesstext lieber eine echte Liste oder zwei Saetze');\n const dreier = volltext.match(/\\b\\w+(?:ig|lich|ell|bar|iv|sam)\\b, \\w+(?:ig|lich|ell|bar|iv|sam)\\b und \\w+(?:ig|lich|ell|bar|iv|sam)\\b/gi) || [];\n dreier.forEach(d => add('Dreierreihe aus Adjektiven', d));\n\n // 3) Rhetorische Frage im Absatz, direkt beantwortet\n absaetze.forEach(a => {\n const m = a.match(/[^.!?]{8,}\\?\\s+[A-ZÄÖÜ][^.!?]{3,}[.!]/);\n if (m) add('Rhetorische Frage mit Sofortantwort', m[0]);\n });\n\n // 4) Satzlaengen: gleichfoermiger Rhythmus\n const saetze = ohneTags(html).split(/(?<=[.!?])\\s+/).map(s => s.trim())\n .filter(s => s.split(' ').length >= 3);\n const laengen = saetze.map(s => s.split(' ').length);\n if (laengen.length >= 8) {\n const mittel = laengen.reduce((x, y) => x + y, 0) / laengen.length;\n const sd = Math.sqrt(laengen.reduce((x, y) => x + (y - mittel) ** 2, 0) / laengen.length);\n if (sd < 4.5) add('Gleichfoermige Satzlaengen', `Durchschnitt ${mittel.toFixed(1)} Woerter, Streuung nur ${sd.toFixed(1)}. Kurze und lange Saetze mischen.`);\n for (let i = 0; i + 4 <= laengen.length; i++) {\n const fenster = laengen.slice(i, i + 4);\n if (Math.max(...fenster) - Math.min(...fenster) <= 3) {\n add('Vier Saetze gleicher Laenge hintereinander', saetze[i]);\n i += 3;\n }\n }\n }\n\n // 5) Gleiche Satzanfaenge\n for (let i = 0; i + 3 <= saetze.length; i++) {\n const w = saetze.slice(i, i + 3).map(s => s.split(' ')[0].toLowerCase());\n if (w[0] === w[1] && w[1] === w[2] && w[0].length > 2) { add('Drei Saetze mit gleichem Anfang', saetze[i]); i += 2; }\n }\n const anfaenge = {};\n absaetze.forEach(a => { const k = a.split(' ').slice(0, 2).join(' ').toLowerCase(); anfaenge[k] = (anfaenge[k] || 0) + 1; });\n Object.entries(anfaenge).filter(([, n]) => n >= 3).forEach(([k, n]) => add('Absatzanfaenge wiederholen sich', `${n} Absaetze beginnen mit \"${k}\"`));\n\n // 6) Gleich lange Absaetze\n const al = absaetze.map(a => a.split(' ').length).filter(n => n >= 12);\n if (al.length >= 5) {\n const m = al.reduce((x, y) => x + y, 0) / al.length;\n const sd = Math.sqrt(al.reduce((x, y) => x + (y - m) ** 2, 0) / al.length);\n if (sd / m < 0.25) add('Absaetze fast gleich lang', `Durchschnitt ${m.toFixed(0)} Woerter, kaum Abweichung. Einen Absatz bewusst kurz halten.`);\n }\n\n // 7) Praxiswissen: ohne eigene Stichpunkte bleibt der Text generisch\n if (!Array.isArray(artikel.praxis) || artikel.praxis.length === 0) {\n add('Kein Praxiswissen hinterlegt', 'Feld \"praxis\" in artikel-index.json fuellen, dann den Artikel darauf aufbauen');\n }\n\n return hinweise;\n}\n\n\nconst meta = $('Artikel nacheinander').first().json;\nconst artRoh = $('Artikeldatei laden').first().json;\nconst briefAntwort = $input.first().json;\n// n8n liefert den Koerper bei fullResponse je nach Version als body oder data.\nconst K = (r) => (r && r.body !== undefined) ? r.body : ((r || {}).data);\nconst kb = K(briefAntwort);\n\nlet artikel;\ntry { artikel = JSON.parse(Buffer.from(artRoh.content, 'base64').toString('utf8')); }\ncatch (e) { throw new Error('artikel-' + meta.slug + '.json ist kein gueltiges JSON: ' + e.message); }\n\nconst pflicht = ['slug', 'titel', 'query_id', 'kategorie', 'content_html'];\nconst fehlt = pflicht.filter(f => !artikel[f]);\nif (fehlt.length) {\n throw new Error('artikel-' + meta.slug + '.json fehlen Pflichtfelder: ' + fehlt.join(', ')\n + '. Erwartetes Format siehe README Abschnitt \"Vertrag Cowork -> Workflow 2\".');\n}\nif (artikel.slug !== meta.slug) {\n throw new Error('Slug im Dateinamen (' + meta.slug + ') und im JSON (' + artikel.slug + ') stimmen nicht ueberein.');\n}\n\nlet brief = null;\nif (briefAntwort.statusCode === 200 && kb && kb.content) {\n try { brief = JSON.parse(Buffer.from(kb.content, 'base64').toString('utf8')); }\n catch (e) { brief = null; }\n}\n\n// Preise muessen als Shortcode stehen, nie ausgeschrieben.\nconst preisTreffer = [];\nconst re = /(\\d{1,3}(?:[.,]\\d{1,2})?)\\s*(?:€|EUR|Euro)/gi;\nlet m;\nwhile ((m = re.exec(String(artikel.content_html || ''))) !== null) {\n preisTreffer.push(m[0].trim());\n}\nconst shortcodes = (String(artikel.content_html || '').match(/\\[skrift_preis[^\\]]*\\]/g) || []);\n\n// NeuronWriter bewertet nur den uebergebenen Text. Kurzantwort und FAQ stehen\n// aber auf der fertigen Seite, also gehoeren sie in die Bewertung. Der gespeicherte\n// Artikel bleibt unveraendert, das hier ist nur der Pruefkoerper.\nconst faqText = (Array.isArray(artikel.faq) ? artikel.faq : [])\n .map(f => `
${f.antwort || ''}
`).join('\\n');\nconst bewertungs_html = [\n artikel.kurzantwort ? `${artikel.kurzantwort}
` : '',\n artikel.content_html || '',\n faqText ? `