diff --git a/README.md b/README.md index 59b0b29..15cde65 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,14 @@ Adjektiven, rhetorische Fragen mit Sofortantwort, gleichfoermige Satzlaengen, gleiche Satz- und Absatzanfaenge, gleich lange Absaetze und fehlendes Praxiswissen. +Dazu die Linkpruefung: Das CTA-Ziel muss eine Business-Leistungsseite sein +(`handgeschriebene-businessbriefe`, `handgeschriebene-postkarten`, +`unterschriftenservice`, `automatisierte-follow-ups`) und mindestens zweimal +verlinkt sein, insgesamt mindestens zwei Business-Links. Hoechstens ein Link auf +die Privatkunden-Seiten, und nur im Block "Auch fuer Privatpersonen.". +`papier-schriftmuster` und `kontakt` sind ergaenzend erlaubt. Links auf `review` +(Dankeseite nach Bestellung) und Rechtstexte werden gemeldet. + Die Regeln liegen in `build/stilpruefung.js` und lassen sich dort erweitern. Lokal testen: diff --git a/build/gen_wf1.py b/build/gen_wf1.py index 512b84d..3be57a9 100644 --- a/build/gen_wf1.py +++ b/build/gen_wf1.py @@ -315,6 +315,12 @@ const brief = { 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.', + links: 'Interne Links primaer auf Business-Leistungsseiten: handgeschriebene-businessbriefe, ' + + 'handgeschriebene-postkarten, unterschriftenservice, automatisierte-follow-ups. ' + + 'artikel.cta_ziel mindestens zweimal verlinken, im Text und im Abschluss-CTA, mindestens zwei ' + + 'Business-Links insgesamt. papier-schriftmuster und kontakt nur ergaenzend. Genau ein Link auf ' + + 'handschrift-fuer-privatpersonen, und nur im Block "Auch fuer Privatpersonen.". Keine Links auf ' + + 'review, agb, datenschutz, impressum, zahlungsmethoden, lieferung-versand.', 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. ' diff --git a/build/stilpruefung.js b/build/stilpruefung.js index 2404ce2..9bb30e4 100644 --- a/build/stilpruefung.js +++ b/build/stilpruefung.js @@ -84,6 +84,35 @@ function stilpruefung(artikel) { add('Kein Praxiswissen hinterlegt', 'Feld "praxis" in artikel-index.json fuellen, dann den Artikel darauf aufbauen'); } + // 8) Interne Links: primaer auf Business-Leistungsseiten + const B = 'https://skrift.de/'; + const BUSINESS = ['handgeschriebene-businessbriefe', 'handgeschriebene-postkarten', + 'unterschriftenservice', 'automatisierte-follow-ups']; + const NEUTRAL = ['papier-schriftmuster', 'kontakt']; + const PRIVAT = ['handschrift-fuer-privatpersonen', 'handgeschriebene-private-briefe', + 'handgeschriebene-private-postkarten']; + const slugVon = (u) => String(u || '').replace(/^https?:\/\/(www\.)?skrift\.de\/?/, '').replace(/\/.*$/, ''); + const links = [...html.matchAll(/]*href="([^"]+)"/g)].map(m => m[1]); + const intern = links.filter(u => /^https?:\/\/(www\.)?skrift\.de/.test(u) || u.startsWith('/')); + const slugs = intern.map(u => u.startsWith('/') ? u.replace(/^\/|\/.*$/g, '') : slugVon(u)); + + const cta = slugVon(artikel.cta_ziel); + if (!BUSINESS.includes(cta)) add('Links: CTA-Ziel ist keine Business-Leistungsseite', `cta_ziel "${artikel.cta_ziel || 'leer'}", erlaubt: ${BUSINESS.join(', ')}`); + const ctaAnzahl = slugs.filter(s => s === cta).length; + if (ctaAnzahl < 2) add('Links: CTA-Ziel zu selten verlinkt', `${ctaAnzahl}x im Text, mindestens 2x (im Text und im Abschluss-CTA)`); + const bus = slugs.filter(s => BUSINESS.includes(s)); + const priv = slugs.filter(s => PRIVAT.includes(s)); + if (bus.length < 2) add('Links: zu wenige Business-Links', `${bus.length} Link(s) auf Business-Seiten, mindestens 2`); + if (priv.length > 1) add('Links: zu viele Privatkunden-Links', `${priv.length} Links, hoechstens einer im Block "Auch für Privatpersonen."`); + if (priv.length) { + const block = (html.split(/

Auch für Privatpersonen\.<\/h3>/)[1] || '').split(/

block.includes(s)); + if (!imBlock) add('Links: Privatkunden-Link ausserhalb des Privat-Blocks', 'Privat-Links nur im Block "Auch für Privatpersonen."'); + } + if (priv.length >= bus.length && priv.length > 0) add('Links: Privat-Links nicht in der Minderheit', `${bus.length} Business gegen ${priv.length} Privat`); + const unbekannt = [...new Set(slugs.filter(s => s && !BUSINESS.includes(s) && !NEUTRAL.includes(s) && !PRIVAT.includes(s)))]; + unbekannt.forEach(s => add('Links: kein Linkziel fuer Ratgeber', `/${s}/ (z. B. Dankeseite, Rechtstexte)`)); + return hinweise; } if (typeof module !== 'undefined') module.exports = { stilpruefung }; diff --git a/workflows/brief-vorbereiten.json b/workflows/brief-vorbereiten.json index b21b75c..6d8c6ca 100644 --- a/workflows/brief-vorbereiten.json +++ b/workflows/brief-vorbereiten.json @@ -9,7 +9,7 @@ -460, 40 ], - "id": "260ec628-48b5-4211-92df-2f12c211c736", + "id": "3e82fd60-13cc-4e4a-be41-e794e22ce423", "name": "Manuell starten" }, { @@ -33,7 +33,7 @@ -460, 200 ], - "id": "16f0bac4-8005-4dde-bf37-a38ff71fef70", + "id": "9b5e08f8-18e1-4040-a339-c1ec3d9f8029", "name": "Zeitplan Montag 07:00", "disabled": true }, @@ -50,9 +50,9 @@ -460, 360 ], - "id": "ab067d79-fb3a-430a-9361-7414ff5958d8", + "id": "c8af01cd-955f-46f2-ae51-4313fbbbb65a", "name": "Webhook Start", - "webhookId": "50276479-7f98-4b3e-8149-98156f310211" + "webhookId": "51378f4e-f1b9-4d24-93af-e3a69b241d10" }, { "parameters": { @@ -60,85 +60,85 @@ "assignments": { "assignments": [ { - "id": "1cdff46d-72b5-4134-8033-1eb408b0b5ea", + "id": "45448038-1fe8-4fa0-8b9b-d5b96e817e6d", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "9e99cf26-df0d-4a3c-8918-7b9d106ee173", + "id": "df57bef9-1b80-4b93-a922-bde8dde464f7", "name": "nw_project", "value": "3da4d745e0e223cb", "type": "string" }, { - "id": "a2b26c3e-1622-418b-899a-fec46ef2728f", + "id": "1f70dc44-62f0-4eaf-9e45-a25fbcaf4f1d", "name": "nw_engine", "value": "google.de", "type": "string" }, { - "id": "ff4d76cd-46e8-4a2c-a32c-bc84fc0a7f00", + "id": "0654a9d9-a24d-4149-b871-e074d949003b", "name": "nw_language", "value": "German", "type": "string" }, { - "id": "75a7f1ec-01fa-4661-8f78-c149666ccc8a", + "id": "e6f25a33-143d-42fb-a65b-040c343657a8", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "f389f8d3-c68d-40e2-9f41-797772f3aa11", + "id": "05ac7b8e-687f-4394-93f1-85ed451722db", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "7dc436f3-31d5-454a-ae6a-fa9866845f8d", + "id": "24a62c00-8e66-42ce-ba9b-fa308c4ff1ba", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "efa63b44-4c3f-45f9-9cc4-b3f60d43f764", + "id": "7cf62eff-3125-485b-bee6-5623b2f807b2", "name": "branch", "value": "main", "type": "string" }, { - "id": "b38db5de-7a58-41bc-8399-5a4c66843927", + "id": "15524e8f-1753-4606-8713-7093c619a4cd", "name": "index_pfad", "value": "artikel-index.json", "type": "string" }, { - "id": "84b0e1f0-3f1a-40d8-b27c-213eb2c02eec", + "id": "3fff721c-512e-4229-a36d-995e93d44640", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "58f3fbef-d444-47fc-9943-17f7f9c18d37", + "id": "b5a7af9f-d17b-4914-9631-50e9e158ebb9", "name": "poll_max", "value": 30, "type": "number" }, { - "id": "098701a1-de5b-4804-a9c0-81fe9a6204d7", + "id": "f9f5e461-1c65-4471-b6a1-509a97d11b6b", "name": "poll_sekunden", "value": 20, "type": "number" }, { - "id": "9bc5db24-3fac-48bb-a118-273198c2eab3", + "id": "14413f0e-c7d5-4af7-83d1-3c114d17ec3a", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "08c47277-5335-4ea4-b48b-48a43af36fbd", + "id": "da28533b-b13a-4b1e-ba5e-ba625b8b8131", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -153,7 +153,7 @@ -240, 200 ], - "id": "2fc4b728-84b7-466c-a2ae-74bf117d5fa1", + "id": "4a63c157-1185-44e8-9b46-a5562baa3891", "name": "Konfiguration" }, { @@ -177,7 +177,7 @@ -20, 200 ], - "id": "9d8d9368-18b3-43b0-acd5-22214e0c9aa5", + "id": "01cc98c1-cc3c-4812-a90d-5af740da5270", "name": "Artikeltabelle lesen", "credentials": { "httpHeaderAuth": { @@ -201,7 +201,7 @@ -20, 420 ], - "id": "0dd66fcb-5058-481b-a94d-3e06529708cf", + "id": "f9f0c1ca-5c76-4066-a624-f01a23259a8c", "name": "Fehler Artikeltabelle" }, { @@ -215,7 +215,7 @@ 200, 200 ], - "id": "bd1be404-0967-4b39-bef3-61fbb0069745", + "id": "a75c5412-3779-4c60-a957-d9e31b906159", "name": "Tabelle auswerten" }, { @@ -229,7 +229,7 @@ }, "conditions": [ { - "id": "e9f8b220-567d-4ef9-9c9c-05ff695b6ab7", + "id": "f642c3ea-743e-4d62-8f20-2025a113ce16", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -250,7 +250,7 @@ 420, 200 ], - "id": "2577013e-3a1e-4960-b5d9-f727d45c6cdc", + "id": "2162a546-7ead-445a-8c00-2f43db67133c", "name": "Offene Artikel vorhanden?" }, { @@ -261,7 +261,7 @@ 640, 60 ], - "id": "80b41612-8f5f-4faa-911f-b2f964dc9db0", + "id": "eeece2ac-46d3-484a-8244-9a5932e6a26d", "name": "Nichts zu tun" }, { @@ -277,7 +277,7 @@ 640, 320 ], - "id": "4efca105-2742-4f2c-b248-4bd702453fe2", + "id": "46ea39bd-11d4-46f6-82f8-42e2bfe03bcb", "name": "Artikel nacheinander" }, { @@ -307,7 +307,7 @@ 900, 420 ], - "id": "647e03b7-5ae9-44ca-acbc-9309338b7486", + "id": "2b42440f-b41a-4189-8ac0-38ddeb0bf0cd", "name": "Analyse starten", "credentials": { "httpHeaderAuth": { @@ -331,7 +331,7 @@ 900, 640 ], - "id": "7a4abc2e-df42-4fdb-bb9b-6c2984c011c2", + "id": "79bdabb8-5fa9-4c1f-b8b0-fd0085d7cda7", "name": "Fehler Analyse starten", "onError": "continueRegularOutput" }, @@ -346,7 +346,7 @@ 1120, 420 ], - "id": "0a808a0a-74f3-4033-b0e3-9e5dd3bac559", + "id": "b1db576e-0ede-43ce-b05f-f6c11628cb92", "name": "Poll vorbereiten" }, { @@ -360,9 +360,9 @@ 1340, 420 ], - "id": "36006210-77af-41b6-9478-fbaa85729e46", + "id": "823195ed-6db4-423f-8f99-15430a92ee3d", "name": "Warten", - "webhookId": "febdadfe-bb41-4a36-b19c-ed9f73a49f33" + "webhookId": "09704b0e-8596-4f02-a069-fd093c0d6aa6" }, { "parameters": { @@ -391,7 +391,7 @@ 1560, 420 ], - "id": "58b3d12c-37c6-49f6-926a-0702ec80e38d", + "id": "cf960417-7f71-4a56-b817-23827d6994c3", "name": "Analyse abfragen", "credentials": { "httpHeaderAuth": { @@ -415,7 +415,7 @@ 1560, 640 ], - "id": "b81a94e6-6ebd-4f93-b4ea-f42f0c784d76", + "id": "a8905a9d-51c9-48ff-8d67-df6ec2ee21af", "name": "Fehler Analyse abfragen", "onError": "continueRegularOutput" }, @@ -430,7 +430,7 @@ }, "conditions": [ { - "id": "09def6f2-cc81-49ba-90c8-d445980a842b", + "id": "638c8975-94e8-47f1-8f6f-9e65d4a7b4ae", "leftValue": "={{ $json.status }}", "rightValue": "ready", "operator": { @@ -450,7 +450,7 @@ 1780, 420 ], - "id": "1be6257c-67fc-431d-b485-c155e0d6cd35", + "id": "11dcf83f-a673-485e-83fb-f5ad367249ae", "name": "Analyse fertig?" }, { @@ -464,7 +464,7 @@ 1780, 660 ], - "id": "e2ca0648-f4ba-428d-8547-6bfb99869933", + "id": "6ff3ad23-0b46-4a19-9f26-a15096693abe", "name": "Versuch zaehlen" }, { @@ -478,7 +478,7 @@ }, "conditions": [ { - "id": "2e05e26d-97e6-42e0-bdfb-6f0594935519", + "id": "e622c3eb-470a-4f32-bf95-12b3fdd23b9d", "leftValue": "={{ $json.versuch }}", "rightValue": "={{ $('Konfiguration').first().json.poll_max }}", "operator": { @@ -498,7 +498,7 @@ 2000, 660 ], - "id": "20f7854d-52bd-4c9f-be98-21b79e242993", + "id": "68d0090b-6024-486a-8c39-3fb45364205d", "name": "Poll-Limit erreicht?" }, { @@ -512,13 +512,13 @@ 2220, 780 ], - "id": "4809a2ca-50ec-4733-aacc-cb70a095510a", + "id": "0a93c0ec-9ae9-43d8-9f00-0828ba572fc8", "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 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" + "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 links: 'Interne Links primaer auf Business-Leistungsseiten: handgeschriebene-businessbriefe, '\n + 'handgeschriebene-postkarten, unterschriftenservice, automatisierte-follow-ups. '\n + 'artikel.cta_ziel mindestens zweimal verlinken, im Text und im Abschluss-CTA, mindestens zwei '\n + 'Business-Links insgesamt. papier-schriftmuster und kontakt nur ergaenzend. Genau ein Link auf '\n + 'handschrift-fuer-privatpersonen, und nur im Block \"Auch fuer Privatpersonen.\". Keine Links auf '\n + 'review, agb, datenschutz, impressum, zahlungsmethoden, lieferung-versand.',\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": "e205635c-b327-4cc2-abf8-585485a73d68", + "id": "6a2cffc4-9346-4cb0-a160-d8693a2a597d", "name": "Brief bauen" }, { @@ -551,7 +551,7 @@ 2440, 300 ], - "id": "dcfe6d64-d36d-4730-b33a-2f8f5693d770", + "id": "7a62b780-ac42-4492-a6dd-37391b0ad966", "name": "Brief vorhanden?", "credentials": { "httpHeaderAuth": { @@ -571,7 +571,7 @@ 2660, 300 ], - "id": "39308915-ea33-42d4-bbe3-67786dfbd2ed", + "id": "660e2ede-2a1c-4608-8102-cdbdb96f6fe8", "name": "Commit vorbereiten" }, { @@ -601,7 +601,7 @@ 2880, 300 ], - "id": "972ef68d-55db-45f0-9b32-0647607e535f", + "id": "eee6843f-1f02-410f-865c-ee8e943f2ca7", "name": "Brief committen", "credentials": { "httpHeaderAuth": { @@ -625,7 +625,7 @@ 2880, 520 ], - "id": "e9beac3d-0306-45d1-a547-0fcddb091810", + "id": "5d707f38-0386-4146-856b-2390209d6956", "name": "Fehler Brief committen", "onError": "continueRegularOutput" }, @@ -640,7 +640,7 @@ 3100, 300 ], - "id": "b740cc47-e748-4ff4-be7f-9128b06ad303", + "id": "399d4795-5711-4b62-a0c9-881a15e6e204", "name": "Erfolg merken" }, { @@ -654,9 +654,9 @@ 3320, 400 ], - "id": "dc146423-d4db-4a68-8061-cd123d286199", + "id": "adfeae55-e12b-47af-b073-56fa685740eb", "name": "Pause Rate Limit", - "webhookId": "cd90d1e6-1533-4c3b-bc64-46e9e69546b0" + "webhookId": "04378d61-bcb9-42f1-8b3b-9c49986e9849" }, { "parameters": { @@ -669,7 +669,7 @@ 900, 140 ], - "id": "9d684894-aae3-4fe5-a312-ee6daa993ece", + "id": "0f205638-8842-4062-a576-9da19b33e94a", "name": "Ergebnisse sammeln" }, { @@ -683,7 +683,7 @@ 1120, 140 ], - "id": "e6350ae8-2d5d-47f6-ae57-b038ade331ac", + "id": "64e6553e-2f89-4458-bd11-52c4985f8630", "name": "Statusupdate vorbereiten" }, { @@ -713,7 +713,7 @@ 1340, 140 ], - "id": "a2212ee0-aa8a-41e2-a684-f30db2eadf16", + "id": "a2ee6e79-599e-42f5-8f87-12f223963f7b", "name": "Status zurueckschreiben", "credentials": { "httpHeaderAuth": { @@ -737,7 +737,7 @@ 1340, -60 ], - "id": "f9fb0933-c769-4722-b8e9-02b4169a8046", + "id": "8996e9c2-78ff-4b35-8c90-46e9703bd6dc", "name": "Fehler Statusupdate" }, { @@ -751,7 +751,7 @@ 1560, 140 ], - "id": "3f84a5c1-eedf-4a0d-be53-d0969c687aa5", + "id": "4580bcca-8d6a-419a-b91f-0eb6a41c11a2", "name": "Meldung formulieren" }, { @@ -769,7 +769,7 @@ 1780, 140 ], - "id": "c7d18957-008d-4492-87fc-bdb547cc8823", + "id": "45251711-e81c-40d0-9273-a6fbc7197934", "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 a9b44f9..a72f026 100644 --- a/workflows/pruefen-und-importieren.json +++ b/workflows/pruefen-und-importieren.json @@ -9,7 +9,7 @@ -460, 100 ], - "id": "633f2486-1cde-41d1-b4cd-4e24a1a1d75f", + "id": "9d7b1805-8faf-4e0b-90d0-faedab546436", "name": "Manuell starten" }, { @@ -25,9 +25,9 @@ -460, 280 ], - "id": "21be5d6d-b7fd-4268-a79e-c5203e0d1864", + "id": "ceaa8ccf-b4a1-4a0d-a173-b9ed63d44833", "name": "Webhook Start", - "webhookId": "daec5bda-055a-416e-85a7-085b1a644a70" + "webhookId": "ddaf87b7-a52e-44ad-8fc9-b0560671eab3" }, { "parameters": { @@ -35,91 +35,91 @@ "assignments": { "assignments": [ { - "id": "77fdf721-4ee6-4f16-86aa-a44ccd53c30e", + "id": "c37716f4-15ed-4a09-83f1-7841ff2a4efb", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "833ef6fb-7143-4a87-9f21-e734d263dba1", + "id": "a30eeffc-30a1-42fa-bb43-4662e503872b", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "48ba9170-6828-4986-b0bb-9a8ff1b96f39", + "id": "db835d6c-31f9-4ac1-95ff-096bd685aa5e", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "58ba48b6-3e92-4796-a905-760b4d49fabd", + "id": "d3f9c73d-bcf1-4ed7-8047-f9eb3968f403", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "8adca23e-46c1-4431-95b8-39836573b510", + "id": "c6a32b0a-032f-498e-aea8-04587961668b", "name": "branch", "value": "main", "type": "string" }, { - "id": "e131a743-ace6-41b2-a676-3265b3c1adba", + "id": "c7e60ffd-132b-43b4-b3e3-7fd0f66b6546", "name": "artikel_ordner", "value": "artikel", "type": "string" }, { - "id": "8e16537a-e986-4b7d-9e13-19fed65045b1", + "id": "d379f9fc-d1ba-4ac5-8494-1af6068cefca", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "0d4e2fcb-a677-4be7-8f92-4cad4cdb2a6a", + "id": "46d52c78-8a51-4c5b-b893-ed79a53753a8", "name": "nachbessern_ordner", "value": "nachbessern", "type": "string" }, { - "id": "8081b290-1356-4d17-a4da-9aec977846ae", + "id": "ff44870f-d811-402a-afaf-61053ca2bb83", "name": "status_pfad", "value": "status-importe.json", "type": "string" }, { - "id": "58a53681-c4b6-41db-8ee0-ef2192c11801", + "id": "ea4a7974-642d-49df-b419-7cb2007893d6", "name": "wp_base", "value": "https://skrift.de", "type": "string" }, { - "id": "f48b0d70-1acb-432f-8773-768c2cd708fe", + "id": "354bf17a-52d5-4483-9cd6-68721a9c2a23", "name": "score_ziel", "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 65 }}", "type": "number" }, { - "id": "1b83a8b2-ab79-4da1-a6aa-60e932a0e239", + "id": "388a5e43-5ab7-42f2-b3d2-438f72ff58e6", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "da8882a2-1c90-4d3e-80c2-fcf4553033c6", + "id": "9b2fa315-8213-4609-a2e5-be0936e24f20", "name": "elementor_ausgabe", "value": false, "type": "boolean" }, { - "id": "06e2454a-7586-4810-8511-dae3cd4c6fad", + "id": "ffca6f3b-e391-4ca7-adb8-49017472dc7b", "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string" }, { - "id": "b22de2bd-ccd9-4933-87cc-0f30af3da4f4", + "id": "2e8ae0e2-c6ea-4495-9fd5-1ad24c2b5dd8", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -134,7 +134,7 @@ -240, 190 ], - "id": "04845694-bb6d-45e7-b215-0eb26088e59c", + "id": "d6943dd4-a080-471e-8d8d-281d2b49c91d", "name": "Konfiguration" }, { @@ -159,7 +159,7 @@ -20, 190 ], - "id": "3a9a66ee-3f95-474a-9d6c-19848c19e2f9", + "id": "21951ce8-26f3-4473-8051-4e232c228e89", "name": "Artikelordner lesen", "credentials": { "httpHeaderAuth": { @@ -182,7 +182,7 @@ 200, 190 ], - "id": "39b7d343-8133-49b9-937e-a207772217c0", + "id": "79588bca-c88b-4f26-8344-1f1aaabbf7b7", "name": "Artikeldateien filtern" }, { @@ -196,7 +196,7 @@ }, "conditions": [ { - "id": "9cb6ba6d-2a92-4422-812b-f7f6f9f7aab0", + "id": "a9e1753d-2159-4f99-aa0e-b9acaaf0b413", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -217,7 +217,7 @@ 420, 190 ], - "id": "dbdc7d6a-1ff5-4038-bc36-9d34e28d10bf", + "id": "7f5cd106-1cbe-4fd2-9a15-3d31ceef74c0", "name": "Artikel vorhanden?" }, { @@ -228,7 +228,7 @@ 640, 40 ], - "id": "4808a51f-d327-416f-a1f2-f197718d7d38", + "id": "66ea5319-b958-4cde-924a-ff101321bb3e", "name": "Nichts zu tun" }, { @@ -244,7 +244,7 @@ 640, 320 ], - "id": "4a19481b-6597-408b-a3ce-c709d425a81f", + "id": "59e68a48-9e3a-499c-91a9-2d32ba8a6420", "name": "Artikel nacheinander" }, { @@ -268,7 +268,7 @@ 900, 420 ], - "id": "8dd49c8e-760a-49a7-94ab-05b60973dce3", + "id": "a3787725-913a-4b49-8177-b40a29322c78", "name": "Artikeldatei laden", "credentials": { "httpHeaderAuth": { @@ -292,7 +292,7 @@ 900, 640 ], - "id": "767789d0-15bc-4cb1-b15e-7f8c3941e4ec", + "id": "25cb559a-6047-4766-bebd-f090d36d44a2", "name": "Fehler Artikeldatei", "onError": "continueRegularOutput" }, @@ -318,7 +318,7 @@ 1120, 420 ], - "id": "d7ef6a1e-ff5e-4e58-88bf-9a941202d796", + "id": "d3ea9547-61df-4d13-9dd4-6ffde02be40b", "name": "Brief laden", "credentials": { "httpHeaderAuth": { @@ -330,7 +330,7 @@ { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "// Stilpruefung: findet typische Muster maschinell wirkender Texte im Deutschen.\n// Wird von gen_wf2.py in den Code-Node \"Artikel pruefen\" eingebettet und ist\n// lokal mit node testbar. Liefert Hinweise, blockiert nichts.\nfunction stilpruefung(artikel) {\n const hinweise = [];\n const add = (art, stelle) => hinweise.push({ art, stelle: String(stelle).slice(0, 140) });\n const ohneTags = (h) => String(h || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\\s+/g, ' ').trim();\n\n const html = String(artikel.content_html || '');\n const absaetze = [...html.matchAll(/

([\\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.frage || ''}

${f.antwort || ''}

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

${artikel.kurzantwort}

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

Häufige Fragen

\\n${faqText}` : '',\n].filter(Boolean).join('\\n');\n\nconst praxis = Array.isArray(artikel.praxis) && artikel.praxis.length ? artikel.praxis\n : ((brief && brief.artikel && brief.artikel.praxis) || []);\n\nreturn [{ json: { slug: meta.slug, artikel, brief, brief_vorhanden: !!brief,\n bewertungs_html,\n stil_hinweise: stilpruefung({ ...artikel, praxis }),\n preis_warnung: preisTreffer.length\n ? `${preisTreffer.length} ausgeschriebene(r) Preis(e) im Text: ${[...new Set(preisTreffer)].slice(0,5).join(', ')}. `\n + 'Bitte durch [skrift_preis key=\"...\"] ersetzen.'\n : null,\n preis_shortcodes: shortcodes.length } }];\n" + "jsCode": "// Stilpruefung: findet typische Muster maschinell wirkender Texte im Deutschen.\n// Wird von gen_wf2.py in den Code-Node \"Artikel pruefen\" eingebettet und ist\n// lokal mit node testbar. Liefert Hinweise, blockiert nichts.\nfunction stilpruefung(artikel) {\n const hinweise = [];\n const add = (art, stelle) => hinweise.push({ art, stelle: String(stelle).slice(0, 140) });\n const ohneTags = (h) => String(h || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\\s+/g, ' ').trim();\n\n const html = String(artikel.content_html || '');\n const absaetze = [...html.matchAll(/

([\\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 // 8) Interne Links: primaer auf Business-Leistungsseiten\n const B = 'https://skrift.de/';\n const BUSINESS = ['handgeschriebene-businessbriefe', 'handgeschriebene-postkarten',\n 'unterschriftenservice', 'automatisierte-follow-ups'];\n const NEUTRAL = ['papier-schriftmuster', 'kontakt'];\n const PRIVAT = ['handschrift-fuer-privatpersonen', 'handgeschriebene-private-briefe',\n 'handgeschriebene-private-postkarten'];\n const slugVon = (u) => String(u || '').replace(/^https?:\\/\\/(www\\.)?skrift\\.de\\/?/, '').replace(/\\/.*$/, '');\n const links = [...html.matchAll(/]*href=\"([^\"]+)\"/g)].map(m => m[1]);\n const intern = links.filter(u => /^https?:\\/\\/(www\\.)?skrift\\.de/.test(u) || u.startsWith('/'));\n const slugs = intern.map(u => u.startsWith('/') ? u.replace(/^\\/|\\/.*$/g, '') : slugVon(u));\n\n const cta = slugVon(artikel.cta_ziel);\n if (!BUSINESS.includes(cta)) add('Links: CTA-Ziel ist keine Business-Leistungsseite', `cta_ziel \"${artikel.cta_ziel || 'leer'}\", erlaubt: ${BUSINESS.join(', ')}`);\n const ctaAnzahl = slugs.filter(s => s === cta).length;\n if (ctaAnzahl < 2) add('Links: CTA-Ziel zu selten verlinkt', `${ctaAnzahl}x im Text, mindestens 2x (im Text und im Abschluss-CTA)`);\n const bus = slugs.filter(s => BUSINESS.includes(s));\n const priv = slugs.filter(s => PRIVAT.includes(s));\n if (bus.length < 2) add('Links: zu wenige Business-Links', `${bus.length} Link(s) auf Business-Seiten, mindestens 2`);\n if (priv.length > 1) add('Links: zu viele Privatkunden-Links', `${priv.length} Links, hoechstens einer im Block \"Auch für Privatpersonen.\"`);\n if (priv.length) {\n const block = (html.split(/

Auch für Privatpersonen\\.<\\/h3>/)[1] || '').split(/

block.includes(s));\n if (!imBlock) add('Links: Privatkunden-Link ausserhalb des Privat-Blocks', 'Privat-Links nur im Block \"Auch für Privatpersonen.\"');\n }\n if (priv.length >= bus.length && priv.length > 0) add('Links: Privat-Links nicht in der Minderheit', `${bus.length} Business gegen ${priv.length} Privat`);\n const unbekannt = [...new Set(slugs.filter(s => s && !BUSINESS.includes(s) && !NEUTRAL.includes(s) && !PRIVAT.includes(s)))];\n unbekannt.forEach(s => add('Links: kein Linkziel fuer Ratgeber', `/${s}/ (z. B. Dankeseite, Rechtstexte)`));\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.frage || ''}

${f.antwort || ''}

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

${artikel.kurzantwort}

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

Häufige Fragen

\\n${faqText}` : '',\n].filter(Boolean).join('\\n');\n\nconst praxis = Array.isArray(artikel.praxis) && artikel.praxis.length ? artikel.praxis\n : ((brief && brief.artikel && brief.artikel.praxis) || []);\n\nreturn [{ json: { slug: meta.slug, artikel, brief, brief_vorhanden: !!brief,\n bewertungs_html,\n stil_hinweise: stilpruefung({ ...artikel, praxis }),\n preis_warnung: preisTreffer.length\n ? `${preisTreffer.length} ausgeschriebene(r) Preis(e) im Text: ${[...new Set(preisTreffer)].slice(0,5).join(', ')}. `\n + 'Bitte durch [skrift_preis key=\"...\"] ersetzen.'\n : null,\n preis_shortcodes: shortcodes.length } }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -338,7 +338,7 @@ 1340, 420 ], - "id": "c80f6298-4952-4fb3-a739-923dc94ef20f", + "id": "01b083a8-acdd-4964-80fc-26770a1dcecb", "name": "Artikel pruefen" }, { @@ -368,7 +368,7 @@ 1560, 420 ], - "id": "cce9d7ae-6b36-4990-84a7-08bdc224ce9b", + "id": "939d7498-7c06-49a7-b96b-8ed0095edf94", "name": "Score ermitteln", "credentials": { "httpHeaderAuth": { @@ -392,7 +392,7 @@ 1560, 640 ], - "id": "a6d0d95f-44b7-4af6-9164-854759f98827", + "id": "e3135c57-8738-4d82-914c-8813d3916dcc", "name": "Fehler Score", "onError": "continueRegularOutput" }, @@ -407,7 +407,7 @@ 1780, 420 ], - "id": "98f0d1f9-be01-43fd-8d6b-84b5466f7b67", + "id": "b1ea4d99-d731-4468-8525-b1af09e0acc3", "name": "Score auswerten" }, { @@ -421,7 +421,7 @@ }, "conditions": [ { - "id": "10c43145-9910-4bee-b483-18a415053cd5", + "id": "851f792a-479c-4d4b-8801-15010f03be39", "leftValue": "={{ $json.bestanden }}", "rightValue": "", "operator": { @@ -442,7 +442,7 @@ 2000, 420 ], - "id": "9c9e30a0-78c2-48e7-a7f9-357f27f8b491", + "id": "7187c432-a84a-4a61-b653-a864626f9c0b", "name": "Score erreicht?" }, { @@ -456,7 +456,7 @@ 2220, 640 ], - "id": "40728217-116b-435a-b758-4a2e6fe1ccba", + "id": "202c13fa-f4eb-4f85-a1a9-97f953ac99dc", "name": "Fehlende Begriffe ermitteln" }, { @@ -481,7 +481,7 @@ 2440, 640 ], - "id": "be1e9b88-7028-4be3-a244-b6250cc6ccfe", + "id": "8cbf40d1-3067-461f-a394-796fb71981d0", "name": "Nachbesserung vorhanden?", "credentials": { "httpHeaderAuth": { @@ -501,7 +501,7 @@ 2660, 640 ], - "id": "2e63f51d-9f55-4131-8cd6-dafb4853f299", + "id": "b20fd046-39d8-48bf-941d-0866963437d0", "name": "Nachbesserung vorbereiten" }, { @@ -531,7 +531,7 @@ 2880, 640 ], - "id": "daba34d3-b8a9-411c-b4e4-a7d6e2e35857", + "id": "0197b51e-4b00-4fd9-acb0-86a5193ed692", "name": "Nachbesserung committen", "credentials": { "httpHeaderAuth": { @@ -555,7 +555,7 @@ 2880, 860 ], - "id": "126ce968-3042-4146-b7b5-26989b1963f4", + "id": "9c770168-67a5-4ec5-9498-1037533d808d", "name": "Fehler Nachbesserung", "onError": "continueRegularOutput" }, @@ -570,7 +570,7 @@ 3100, 640 ], - "id": "f7c474ee-61fe-4579-b24c-f4d0fae505bf", + "id": "0a987ebf-e478-48ce-b721-598e2759019a", "name": "Nachbesserung gemerkt" }, { @@ -600,7 +600,7 @@ 2220, 240 ], - "id": "a66b7ee4-3c32-4872-a4d8-8e0ca7358e7c", + "id": "4ee193df-d21a-4a4b-a78e-ce967ce6fabf", "name": "Revision zu NeuronWriter", "credentials": { "httpHeaderAuth": { @@ -624,7 +624,7 @@ 2220, 60 ], - "id": "06e563e9-5029-49ab-a0ab-c644615f7955", + "id": "9fa33651-10b2-4068-a5db-0c1705a394a6", "name": "Fehler Revision", "onError": "continueRegularOutput" }, @@ -650,7 +650,7 @@ 2440, 240 ], - "id": "964f3bc4-4ed7-4a1c-9c75-497d967f6203", + "id": "bd2f2ec2-0ce6-4491-86cf-342aeecbbeed", "name": "Beitrag vorhanden?", "credentials": { "httpBasicAuth": { @@ -684,7 +684,7 @@ 2660, 240 ], - "id": "010c6447-49f0-4347-9e47-26a1b9b2599e", + "id": "caa0d4e8-3bca-4fd1-90e4-824837ca8c01", "name": "Kategorie suchen", "credentials": { "httpBasicAuth": { @@ -707,7 +707,7 @@ 2880, 240 ], - "id": "ab01a3a4-a5ea-420b-8815-2a7f3853ac97", + "id": "cd297bed-663d-47f3-ae1d-7442e7251e69", "name": "Kategorie pruefen" }, { @@ -721,7 +721,7 @@ }, "conditions": [ { - "id": "2a54ed83-87d2-4927-9351-585a15b086fe", + "id": "f05870e6-38ac-4e5e-8b77-c75437e2d520", "leftValue": "={{ $json.kategorie_fehlt }}", "rightValue": "", "operator": { @@ -742,7 +742,7 @@ 3100, 240 ], - "id": "2ca6b6c9-c48f-4003-8e5c-d5c558e26554", + "id": "01a425ce-96d8-4a77-b797-946bc628ad2f", "name": "Kategorie anlegen?" }, { @@ -772,7 +772,7 @@ 3320, 140 ], - "id": "897b9835-b709-4a52-9d55-0a3e8265bc9e", + "id": "ed15de50-93a9-4413-8729-3aa6c57cac6a", "name": "Kategorie anlegen", "credentials": { "httpBasicAuth": { @@ -795,7 +795,7 @@ 3540, 240 ], - "id": "be2c0e3e-9700-4fab-a3af-685fbcd711c1", + "id": "1dad3b28-189b-430a-ac32-c1724c816f16", "name": "Kategorie festlegen" }, { @@ -809,7 +809,7 @@ 3760, 240 ], - "id": "601294c9-7988-41c4-b0c7-dea7354b4f1e", + "id": "b64f3c2d-f4a3-4e61-abd0-4b2b5fa42661", "name": "WordPress-Body bauen" }, { @@ -839,7 +839,7 @@ 3980, 240 ], - "id": "63f22031-35bc-4ff4-8fc4-c6de41d1b776", + "id": "d1d7fd80-caf7-46cf-9ded-9aa3b97b9a15", "name": "Beitrag anlegen oder aktualisieren", "credentials": { "httpBasicAuth": { @@ -862,7 +862,7 @@ 4200, 240 ], - "id": "06bdadc1-6c10-4383-9f1d-cc8c312256e9", + "id": "a16f780a-5f7e-4581-b0f5-7f55464e87d1", "name": "Ergebnis pruefen" }, { @@ -893,7 +893,7 @@ 4420, 240 ], - "id": "97bb7a3f-3f36-4423-9156-d276cd9676f8", + "id": "f1b48e11-b8c6-4842-913c-5a8a43b042d5", "name": "Cache leeren", "credentials": { "httpBasicAuth": { @@ -913,7 +913,7 @@ 4640, 240 ], - "id": "57f9fd78-0437-4097-b7ed-ac16c26a3739", + "id": "6708b72c-7577-4bb5-b824-652648fff48c", "name": "Importergebnis merken" }, { @@ -927,9 +927,9 @@ 4860, 420 ], - "id": "878e3e55-af85-476e-8fcb-a30a2d995d8c", + "id": "2297ffc3-9c1a-48a2-ba7a-20f394af02f0", "name": "Pause Rate Limit", - "webhookId": "a6852e0e-1624-45d4-90fb-e688e5ed3c82" + "webhookId": "cf34062f-fc9c-49de-b651-9a8d020b9d57" }, { "parameters": { @@ -942,7 +942,7 @@ 900, 80 ], - "id": "623461c0-10e9-487e-8f20-d9f0face6b66", + "id": "056c1cf1-f7ba-48dc-a1c2-3d22d70e4550", "name": "Ergebnisse sammeln" }, { @@ -967,7 +967,7 @@ 1120, 80 ], - "id": "35ab12d7-6af2-4286-9bc7-2a46c8a53cc5", + "id": "88815af5-2481-4518-adf6-d50ec1cb28c8", "name": "Statustabelle lesen", "credentials": { "httpHeaderAuth": { @@ -987,7 +987,7 @@ 1340, 80 ], - "id": "7549b60e-d346-4153-a734-838f5988cd30", + "id": "c094f8dd-9756-47b6-876b-057507091af2", "name": "Statustabelle fortschreiben" }, { @@ -1001,7 +1001,7 @@ }, "conditions": [ { - "id": "14a7b5fe-74f7-41ee-a6f5-48600ec9100b", + "id": "51ddce38-e3ef-46d4-8922-b51f79ff011a", "leftValue": "={{ $json.ueberspringen }}", "rightValue": "", "operator": { @@ -1022,7 +1022,7 @@ 1560, 80 ], - "id": "9a16f5ca-599b-49f2-9d41-c8dc32c311db", + "id": "480901ea-3057-402a-8828-c449d4127118", "name": "Gab es Importe?" }, { @@ -1052,7 +1052,7 @@ 1780, 160 ], - "id": "7c77256e-630c-4f66-a2f2-c9c2e85c9770", + "id": "461196bc-73d5-49a9-be96-9da3866479dc", "name": "Statustabelle schreiben", "credentials": { "httpHeaderAuth": { @@ -1076,7 +1076,7 @@ 1780, 340 ], - "id": "685e45ab-4efe-4c15-81a1-3a467842059e", + "id": "a754dbe7-60b0-43c2-80bd-ff88df4eac50", "name": "Fehler Statustabelle", "onError": "continueRegularOutput" }, @@ -1091,7 +1091,7 @@ 2000, 80 ], - "id": "72b474fe-d5dc-44d1-b384-b03ddb3fff04", + "id": "86c65a05-ec2b-49fd-9134-0538d0be1065", "name": "Meldung formulieren" }, { @@ -1109,7 +1109,7 @@ 2220, 80 ], - "id": "373fcd15-abe9-464e-9233-289c523e4eb2", + "id": "0a421a3c-7f3a-4cc0-b3ca-edd79e6a4584", "name": "Benachrichtigung", "disabled": true, "notes": "SMTP-Credential zuweisen, Absender eintragen, dann aktivieren."