From 0e958c43b3950c8266f461faba091d56cd343ea7 Mon Sep 17 00:00:00 2001 From: Lucas Orth Date: Tue, 22 Sep 2026 13:15:59 +0200 Subject: [PATCH] Artikel 1-10, Stilpruefung, Querverweise, Upload-Skript --- build/artikel_hochladen.py | 17 +++ build/gen_wf1.py | 13 +- build/pruef_lokal.py | 42 +++++++ build/push_gitea.py | 2 +- build/stilpruefung.js | 8 ++ wordpress/skrift-ratgeber-felder.php | 35 +++++- workflows/brief-vorbereiten.json | 112 ++++++++--------- workflows/pruefen-und-importieren.json | 164 ++++++++++++------------- 8 files changed, 252 insertions(+), 141 deletions(-) create mode 100644 build/artikel_hochladen.py create mode 100644 build/pruef_lokal.py diff --git a/build/artikel_hochladen.py b/build/artikel_hochladen.py new file mode 100644 index 0000000..6f1f043 --- /dev/null +++ b/build/artikel_hochladen.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +"""Laedt alle lokalen artikel/artikel-*.json per Contents-API nach Gitea (neu oder mit sha).""" +import base64, glob, json, os, sys +sys.path.insert(0, os.path.dirname(__file__)) +exec(open(os.path.join(os.path.dirname(__file__), 'push_gitea.py'), encoding='utf-8').read().split('def sammle')[0]) + +for p in sorted(glob.glob('artikel/artikel-*.json')): + json.load(open(p, encoding='utf-8')) # nur gueltiges JSON hochladen + inhalt = base64.b64encode(open(p, 'rb').read()).decode() + code, d = ruf('GET', f"{REPO}/contents/{p}?ref=main") + daten = {'content': inhalt, 'message': f'Artikel {os.path.basename(p)}', 'branch': 'main'} + if code == 200: + daten['sha'] = d['sha'] + code, d = ruf('PUT', f"{REPO}/contents/{p}", daten) + else: + code, d = ruf('POST', f"{REPO}/contents/{p}", daten) + print(code, p, '' if code in (200, 201) else str(d)[:200]) diff --git a/build/gen_wf1.py b/build/gen_wf1.py index 6ae37c9..08268ef 100644 --- a/build/gen_wf1.py +++ b/build/gen_wf1.py @@ -211,9 +211,14 @@ FERTIG = iff("Analyse fertig?", [1780, 420], link(ABFRAGE, FERTIG, out=0) ZAEHLEN = code("Versuch zaehlen", [1780, 660], """ +// Zaehler je Query in den Workflow-Daten. $runIndex zaehlt ueber alle Artikel +// eines Laufs hinweg und taugt deshalb nicht als Zaehler pro Artikel. const poll = $('Poll vorbereiten').first().json; const antwort = $input.first().json; -const versuch = (poll.versuch || 0) + $runIndex + 1; +const sd = $getWorkflowStaticData('global'); +sd.poll = sd.poll || {}; +sd.poll[poll.query] = (sd.poll[poll.query] || 0) + 1; +const versuch = sd.poll[poll.query]; return [{ json: { ...poll, versuch, letzter_status: antwort.status || 'unbekannt' } }]; """) link(FERTIG, ZAEHLEN, out=1) @@ -225,6 +230,7 @@ link(ZAEHLEN, LIMIT) TIMEOUT = code("Timeout melden", [2220, 780], """ const a = $input.first().json; +{ const sd = $getWorkflowStaticData('global'); if (sd.poll) delete sd.poll[a.query]; } return [{ json: { slug: a.slug, keyword: a.keyword, query: a.query, ergebnis: 'fehler', stufe: 'poll-timeout', meldung: 'NeuronWriter-Analyse fuer "' + a.keyword + '" war nach ' + a.versuch @@ -242,6 +248,7 @@ BRIEF = code("Brief bauen", [2220, 300], r""" // Feldnamen stammen aus einer echten /get-query-Antwort, siehe docs/feldnachweis.md const a = $('Poll vorbereiten').first().json; const n = $input.first().json; +{ const sd = $getWorkflowStaticData('global'); if (sd.poll) delete sd.poll[a.query]; } const liste = (arr) => (arr || []).map(x => ({ begriff: x.t, @@ -318,6 +325,10 @@ const brief = { + 'artikel.cta_ziel mindestens einmal im Fliesstext verlinken, mindestens zwei Business-Links insgesamt. ' + 'papier-schriftmuster nur ergaenzend. Keine Links auf Privatkunden-Seiten, review, agb, datenschutz, ' + 'impressum, zahlungsmethoden, lieferung-versand.', + querverweise: 'Mindestens zwei, hoechstens vier Querverweise auf verwandte Ratgeberartikel, immer als ' + + '[ratgeber_link slug="SLUG"]Ankertext[/ratgeber_link], nie als harter Link. Slugs aus artikel-index.json. ' + + 'Der Shortcode verlinkt erst, wenn der Zielartikel veroeffentlicht ist. Ankertext beschreibt das Ziel, ' + + 'nie "hier" oder "mehr dazu".', bausteine: 'Kein Block "Auch fuer Privatpersonen." und kein Abschnitt "Kostenloses Handschriftmuster ' + 'anfordern." im Artikeltext, beides liefert das Seitenlayout. Der Artikel endet mit dem letzten ' + 'inhaltlichen Abschnitt, nie mit einer Zusammenfassung.', diff --git a/build/pruef_lokal.py b/build/pruef_lokal.py new file mode 100644 index 0000000..88c4774 --- /dev/null +++ b/build/pruef_lokal.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""Lokale Pruefung eines Artikels vor dem Import. +Aufruf: python3 build/pruef_lokal.py artikel/artikel-SLUG.json [brief.json]""" +import json, re, subprocess, sys, os +p = sys.argv[1] +a = json.load(open(p, encoding='utf-8')) +idx = json.load(open('artikel-index.json', encoding='utf-8')) +slugs = {z['slug'] for z in idx} +bp = sys.argv[2] if len(sys.argv) > 2 else f"build/lokal/briefe/{a['slug']}.json" +c = a['content_html'] +txt = re.sub(r'\[skrift_preis[^\]]*\]', '3,20 €', c) +txt = re.sub(r'\[/?ratgeber_link[^\]]*\]', '', txt) +plain = re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', ' ', txt)).strip() +w = len(plain.split()) +faqt = ' '.join(f['frage'] + ' ' + f['antwort'] for f in a.get('faq', [])) +voll = (a.get('kurzantwort', '') + ' ' + plain + ' ' + faqt).lower() +h2 = re.findall(r'

(.*?)

', c); h3 = re.findall(r'

(.*?)

', c) +print(f"== {a['slug']} | {w} Woerter | H2 {len(h2)} | H3 {len(h3)} | Tabellen {c.count(' 60: probleme.append(f"SEO-Titel {len(a['seo_titel'])}") +if len(a['seo_beschreibung']) > 155: probleme.append(f"Meta {len(a['seo_beschreibung'])}") +if 'Skrift' not in a['kurzantwort']: probleme.append("Skrift fehlt in Kurzantwort") +if not a.get('faq_ueberschrift'): probleme.append("faq_ueberschrift fehlt") +if re.search('[–—]', c + a['kurzantwort'] + faqt): probleme.append("Gedankenstrich") +if re.search(r'\bich\b', plain, re.I): probleme.append("Ich-Form") +for s in re.findall(r'\[ratgeber_link slug="([^"]+)"', c): + if s not in slugs: probleme.append(f"unbekannter Slug {s}") +if a['slug'] in ('kaltakquise-per-brief',) and 'Keine Rechtsberatung: Dieser Beitrag gibt den Stand unserer Praxiserfahrung wieder' not in plain: + probleme.append("Disclaimer fehlt") +if os.path.exists(bp): + b = json.load(open(bp, encoding='utf-8')) + fehlt = [f"{x['begriff']}({x['min']})" for x in b['begriffe_pflicht'] if voll.count(x['begriff'].lower()) < (x['min'] or 1)] + print(f" Pflichtbegriffe: {len(b['begriffe_pflicht'])-len(fehlt)}/{len(b['begriffe_pflicht'])} fehlt: {', '.join(fehlt) or '-'}") + print(f" Zielwortzahl NW {b['wortzahl_ziel']}") +r = subprocess.run(['node', 'build/stiltest.js', p], capture_output=True, text=True) +hin = [l for l in r.stdout.splitlines()[1:] if 'Kein Praxiswissen' not in l] +for l in hin: print(' ', l.strip()[:190]) +for x in probleme: print(' PROBLEM:', x) +if not hin and not probleme: print(' sauber') diff --git a/build/push_gitea.py b/build/push_gitea.py index cf2180f..1840d1f 100644 --- a/build/push_gitea.py +++ b/build/push_gitea.py @@ -10,7 +10,7 @@ import base64, json, os, sys, urllib.request, urllib.error # Von den Workflows gepflegt, nie von hier ueberschreiben: NICHT_UEBERSCHREIBEN = {'artikel-index.json', 'status-importe.json'} -NICHT_UEBERSCHREIBEN_ORDNER = ('briefe/', 'nachbessern/', 'artikel/') +NICHT_UEBERSCHREIBEN_ORDNER = ('briefe/', 'nachbessern/', 'artikel/', 'build/lokal/') AUS_ORDNER = {'.git', 'node_modules', '__pycache__'} AUS_DATEIEN = {'.env'} diff --git a/build/stilpruefung.js b/build/stilpruefung.js index 5ba23f2..331e783 100644 --- a/build/stilpruefung.js +++ b/build/stilpruefung.js @@ -116,6 +116,14 @@ function stilpruefung(artikel) { // Bausteine, die das Seitenlayout bereits liefert if (/Auch für Privatpersonen/i.test(html)) add('Baustein doppelt: "Auch für Privatpersonen."', 'Block aus dem Artikeltext entfernen, er steht im Seitenlayout'); if (/]*>[^<]*(Muster anfordern|Handschriftmuster)/i.test(html)) add('Baustein doppelt: Muster-CTA', 'Abschnitt "Kostenloses Handschriftmuster anfordern." entfernen, er steht im Seitenlayout'); + // Querverweise auf andere Ratgeberartikel + const quer = [...html.matchAll(/\[ratgeber_link\s+slug="([^"]+)"\]([\s\S]*?)\[\/ratgeber_link\]/g)]; + if (quer.length < 2) add('Querverweise: zu wenige', `${quer.length} [ratgeber_link], mindestens 2 auf verwandte Ratgeberartikel`); + quer.filter(q => q[1] === artikel.slug).forEach(() => add('Querverweis auf sich selbst', artikel.slug)); + quer.filter(q => !q[2].trim()).forEach(q => add('Querverweis ohne Ankertext', q[1])); + (html.match(/href="https?:\/\/(www\.)?skrift\.de\/(?!handgeschriebene-|unterschriftenservice|automatisierte-follow-ups|papier-schriftmuster|kontakt|handschrift-fuer)[a-z0-9-]+\/?"/g) || []) + .forEach(h => add('Ratgeber-Link als harter Link', `${h} als [ratgeber_link slug="..."] setzen, sonst droht ein toter Link`)); + 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)`)); diff --git a/wordpress/skrift-ratgeber-felder.php b/wordpress/skrift-ratgeber-felder.php index 67177dd..eef289f 100644 --- a/wordpress/skrift-ratgeber-felder.php +++ b/wordpress/skrift-ratgeber-felder.php @@ -2,7 +2,7 @@ /** * Plugin Name: Skrift Ratgeber Felder * Description: Meta-Felder fuer Ratgeberartikel (kurzantwort, faq, faq_ueberschrift, cta_ziel, stand_datum) mit Eingabebox im Editor, Shortcodes, Elementor-Widgets fuer FAQ und Kurzantwort sowie Dynamic Tags fuer Lesezeit, FAQ-Ueberschrift und Stand. - * Version: 1.3.0 + * Version: 1.4.0 * Author: Lucas Orth * * Ablage: wp-content/mu-plugins/skrift-ratgeber-felder.php @@ -362,6 +362,39 @@ add_shortcode( 'skrift_faq', function ( $atts ) { ) ); } ); +/** + * Querverweis auf einen anderen Ratgeberartikel. + * [ratgeber_link slug="kaltakquise-per-brief"]Ankertext[/ratgeber_link] + * Veroeffentlichter Zielbeitrag: normaler Link auf den aktuellen Permalink. + * Noch nicht veroeffentlicht: nur der Ankertext, damit kein toter Link entsteht. + * Wer den Beitrag bearbeiten darf, sieht in der Vorschau einen gestrichelten + * Hinweis auf den noch fehlenden Zielartikel. + */ +add_shortcode( 'ratgeber_link', function ( $atts, $inhalt = '' ) { + $atts = shortcode_atts( array( 'slug' => '' ), $atts, 'ratgeber_link' ); + $slug = sanitize_title( $atts['slug'] ); + $text = '' !== trim( (string) $inhalt ) ? do_shortcode( $inhalt ) : $slug; + if ( '' === $slug ) { + return $text; + } + $ziel = get_posts( array( + 'name' => $slug, + 'post_type' => 'post', + 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), + 'numberposts' => 1, + 'suppress_filters' => true, + ) ); + if ( $ziel && 'publish' === $ziel[0]->post_status ) { + return '' . $text . ''; + } + if ( is_user_logged_in() && current_user_can( 'edit_posts' ) ) { + $status = $ziel ? $ziel[0]->post_status : 'fehlt'; + return '' . $text . ''; + } + return $text; +} ); + add_shortcode( 'skrift_kurzantwort', function ( $atts ) { $atts = shortcode_atts( array( 'post_id' => 0 ), $atts, 'skrift_kurzantwort' ); return skrift_kurzantwort_html( $atts['post_id'] ); diff --git a/workflows/brief-vorbereiten.json b/workflows/brief-vorbereiten.json index f8170a9..7b259d9 100644 --- a/workflows/brief-vorbereiten.json +++ b/workflows/brief-vorbereiten.json @@ -9,7 +9,7 @@ -460, 40 ], - "id": "4fdca0a5-7851-4f7b-9f3c-276418464202", + "id": "1dab9a40-e8e4-4ed9-9d08-f2fb190e84e9", "name": "Manuell starten" }, { @@ -33,7 +33,7 @@ -460, 200 ], - "id": "f9adf55b-7fff-42e9-b6da-d57af4835257", + "id": "97b85f88-70a1-4809-9d9f-c4c42bd3e9e3", "name": "Zeitplan Montag 07:00", "disabled": true }, @@ -50,9 +50,9 @@ -460, 360 ], - "id": "208692e2-bdc6-4d22-95e7-bad567ee6b74", + "id": "31c56760-74e3-47ce-8a4d-6bd2c7457b57", "name": "Webhook Start", - "webhookId": "75113a14-9f13-4021-a15d-db58b2a57fd8" + "webhookId": "a8ccffdc-60e5-44cf-a265-36a8adc3f8e3" }, { "parameters": { @@ -60,85 +60,85 @@ "assignments": { "assignments": [ { - "id": "4ddbf6e7-68d3-4e8b-bb73-d1f63f26d6e2", + "id": "f6274e65-1885-4e17-9705-000b0e6278e8", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "a35095d9-6dfd-4f04-a506-d7694a65f570", + "id": "d147d08b-df1e-4c2c-9c61-ca8b98964e04", "name": "nw_project", "value": "3da4d745e0e223cb", "type": "string" }, { - "id": "24dcf02e-1af6-4275-bc00-f602b4b84356", + "id": "f19059cd-b921-4e8d-b858-7a82f3b7cd13", "name": "nw_engine", "value": "google.de", "type": "string" }, { - "id": "b35bfea0-9e67-4844-973d-a7ba42853675", + "id": "fe8977fb-eda6-4822-906e-2a540af9800c", "name": "nw_language", "value": "German", "type": "string" }, { - "id": "399912b4-6998-4882-9dc6-da19fe6863a2", + "id": "c0e4d454-74d6-47a1-aeaa-afe02be159df", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "7729e907-6ba5-419e-b90d-82cba600bd1f", + "id": "1b412202-179b-435d-bb0d-91e6457b0e77", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "0315283f-06eb-460b-af29-5ba407f35e5e", + "id": "dc874ab8-3902-4f47-8ce5-eb942b6ed0a3", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "1bef1534-8459-4915-8b19-889c1103cf15", + "id": "15d0ffcf-3553-469a-ae47-4611dba81385", "name": "branch", "value": "main", "type": "string" }, { - "id": "cf49930f-99c3-4889-9894-850a21c174b2", + "id": "aef6b9cb-6e4e-431f-9170-3348609db189", "name": "index_pfad", "value": "artikel-index.json", "type": "string" }, { - "id": "2a3ab33d-64bf-466e-85f3-fafb1df95b4d", + "id": "558a0e5e-dedb-4980-ae3e-8b9d5ebc2073", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "c8a33c09-b371-4fa6-adb8-1c4fb831739b", + "id": "d19f00fe-1f58-4481-a4a9-ff0c37a1575b", "name": "poll_max", "value": 30, "type": "number" }, { - "id": "a2d67260-b199-4a4c-a2c2-5f32d93a80ff", + "id": "a586e75e-cf55-4058-bde0-91ae53405566", "name": "poll_sekunden", "value": 20, "type": "number" }, { - "id": "5bddb3d2-44c6-4a5a-987e-697a303d8ba7", + "id": "bb43c442-b040-473e-a77b-d6b785785563", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "bd4fd63e-5a72-4892-a2e0-481ed234ba11", + "id": "ad45dd10-9b09-4113-bd0e-1e2625bd4c2f", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -153,7 +153,7 @@ -240, 200 ], - "id": "4e6479bc-7e41-432e-b38b-abd3905ded3a", + "id": "8d2693c2-31b1-421a-83aa-9c04f898105b", "name": "Konfiguration" }, { @@ -177,7 +177,7 @@ -20, 200 ], - "id": "748c30f8-635c-4f7a-9a7c-e2045e8ce423", + "id": "c00ff479-1649-419a-b084-07dc1468838f", "name": "Artikeltabelle lesen", "credentials": { "httpHeaderAuth": { @@ -201,7 +201,7 @@ -20, 420 ], - "id": "5a72faa0-c8df-4a53-8981-aab5e7e1cf7e", + "id": "1463b817-36ba-4442-97c1-552752bfde17", "name": "Fehler Artikeltabelle" }, { @@ -215,7 +215,7 @@ 200, 200 ], - "id": "30c7c0d8-e179-41b1-9291-6ca1e158f232", + "id": "27ac6860-1b4d-4aef-b96c-02638881474d", "name": "Tabelle auswerten" }, { @@ -229,7 +229,7 @@ }, "conditions": [ { - "id": "b8fe7382-0842-4295-b498-b0a9c7021226", + "id": "5d2351d2-9aeb-48b9-b505-34bf9399d7ab", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -250,7 +250,7 @@ 420, 200 ], - "id": "42fa04a3-7b12-4a2e-b646-f22316415ac6", + "id": "a73f8011-23c7-4e03-8544-28d06f66c463", "name": "Offene Artikel vorhanden?" }, { @@ -261,7 +261,7 @@ 640, 60 ], - "id": "baa73a73-d519-4722-8a6f-22e17f37dcbb", + "id": "157f932d-c655-4374-b6df-0618b9591f63", "name": "Nichts zu tun" }, { @@ -277,7 +277,7 @@ 640, 320 ], - "id": "976aa62b-ce93-4677-88a4-48584933aad7", + "id": "86fafde7-9cdb-48f0-a003-931e12db12ab", "name": "Artikel nacheinander" }, { @@ -307,7 +307,7 @@ 900, 420 ], - "id": "d0b0e12c-2e55-4e8b-abb9-678716695c6c", + "id": "a697e29c-a103-4b9d-8eae-599a66ef29cb", "name": "Analyse starten", "credentials": { "httpHeaderAuth": { @@ -331,7 +331,7 @@ 900, 640 ], - "id": "4d94bedf-6bbe-4f90-897d-2302a68c2abf", + "id": "4df6b64a-d30d-4723-aac7-f91a011b9c20", "name": "Fehler Analyse starten", "onError": "continueRegularOutput" }, @@ -346,7 +346,7 @@ 1120, 420 ], - "id": "47460438-b146-4705-bf0a-3719ca66af05", + "id": "8cca16df-9dcd-4597-841f-97a582a51b95", "name": "Poll vorbereiten" }, { @@ -360,9 +360,9 @@ 1340, 420 ], - "id": "4ec0f013-a163-46da-b7b1-02e4b73dd9dd", + "id": "f9574347-0e39-418c-b944-d09862bb80c9", "name": "Warten", - "webhookId": "ac9b591c-58ac-40b4-bd94-716826479d88" + "webhookId": "b6229a99-62d1-42e0-8478-cdecca73f05c" }, { "parameters": { @@ -391,7 +391,7 @@ 1560, 420 ], - "id": "aaa1c12a-7d47-41ef-903d-109f847ff356", + "id": "42891afa-983e-4634-a59d-2beb65e57b7a", "name": "Analyse abfragen", "credentials": { "httpHeaderAuth": { @@ -415,7 +415,7 @@ 1560, 640 ], - "id": "23a1f2d5-49a3-4f69-85f3-456c0dcccaba", + "id": "db41d8b6-9542-4201-b5cd-c8374bc40e6c", "name": "Fehler Analyse abfragen", "onError": "continueRegularOutput" }, @@ -430,7 +430,7 @@ }, "conditions": [ { - "id": "4bb7114a-807a-4487-a612-0835fb9260ca", + "id": "382e7432-7a07-4a83-b6c2-41aecc147b3c", "leftValue": "={{ $json.status }}", "rightValue": "ready", "operator": { @@ -450,13 +450,13 @@ 1780, 420 ], - "id": "0d337016-36a1-492a-8b29-5e1d290be140", + "id": "01c6c412-0c34-40bd-a3a5-70437fa97662", "name": "Analyse fertig?" }, { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "\nconst poll = $('Poll vorbereiten').first().json;\nconst antwort = $input.first().json;\nconst versuch = (poll.versuch || 0) + $runIndex + 1;\nreturn [{ json: { ...poll, versuch, letzter_status: antwort.status || 'unbekannt' } }];\n" + "jsCode": "\n// Zaehler je Query in den Workflow-Daten. $runIndex zaehlt ueber alle Artikel\n// eines Laufs hinweg und taugt deshalb nicht als Zaehler pro Artikel.\nconst poll = $('Poll vorbereiten').first().json;\nconst antwort = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\nsd.poll = sd.poll || {};\nsd.poll[poll.query] = (sd.poll[poll.query] || 0) + 1;\nconst versuch = sd.poll[poll.query];\nreturn [{ json: { ...poll, versuch, letzter_status: antwort.status || 'unbekannt' } }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -464,7 +464,7 @@ 1780, 660 ], - "id": "a22a0c31-e340-435d-8663-0ab865ebd394", + "id": "80e0be69-80e5-4127-a514-3bd7d0b6c047", "name": "Versuch zaehlen" }, { @@ -478,7 +478,7 @@ }, "conditions": [ { - "id": "1e0b93da-c082-406f-8642-56ff4a421c07", + "id": "8192b3f4-6ecb-4546-8403-d7152be847d7", "leftValue": "={{ $json.versuch }}", "rightValue": "={{ $('Konfiguration').first().json.poll_max }}", "operator": { @@ -498,13 +498,13 @@ 2000, 660 ], - "id": "5603ea35-5166-4234-be4b-7791bcba1d95", + "id": "d68208d2-b9c1-40ae-868e-c6160f3361c0", "name": "Poll-Limit erreicht?" }, { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "\nconst a = $input.first().json;\nreturn [{ json: { slug: a.slug, keyword: a.keyword, query: a.query, ergebnis: 'fehler',\n stufe: 'poll-timeout',\n meldung: 'NeuronWriter-Analyse fuer \"' + a.keyword + '\" war nach ' + a.versuch\n + ' Versuchen noch nicht fertig (letzter Status: ' + a.letzter_status\n + '). Analyse laeuft ggf. weiter, query merken: ' + a.query }}];\n" + "jsCode": "\nconst a = $input.first().json;\n{ const sd = $getWorkflowStaticData('global'); if (sd.poll) delete sd.poll[a.query]; }\nreturn [{ json: { slug: a.slug, keyword: a.keyword, query: a.query, ergebnis: 'fehler',\n stufe: 'poll-timeout',\n meldung: 'NeuronWriter-Analyse fuer \"' + a.keyword + '\" war nach ' + a.versuch\n + ' Versuchen noch nicht fertig (letzter Status: ' + a.letzter_status\n + '). Analyse laeuft ggf. weiter, query merken: ' + a.query }}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -512,13 +512,13 @@ 2220, 780 ], - "id": "96ab606e-48a9-400e-96e7-857b042acffb", + "id": "6c92837b-f313-44ab-871c-e5a930a13906", "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, immer inklusive MwSt.: '\n + '[skrift_preis key=\"SCHLUESSEL\" ab=\"0|1\" brutto=\"1\"]. '\n + 'key ist der Produktschluessel (briefe, postkarten). ab=1 liefert den guenstigsten Staffelpreis, '\n + 'ab=0 den Standardpreis. brutto ist immer 1. Hinter dem Preis steht immer \"inkl. 19 % MwSt.\", '\n + 'nie \"netto\", \"brutto\" oder \"zzgl.\". Das Wort \"ab\" gehoert in den Fliesstext. Beispiel: '\n + 'Handschriftliche Briefe ab [skrift_preis key=\"briefe\" ab=\"1\" brutto=\"1\"] pro Stueck inkl. 19 % MwSt.',\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 einmal im Fliesstext verlinken, mindestens zwei Business-Links insgesamt. '\n + 'papier-schriftmuster nur ergaenzend. Keine Links auf Privatkunden-Seiten, review, agb, datenschutz, '\n + 'impressum, zahlungsmethoden, lieferung-versand.',\n bausteine: 'Kein Block \"Auch fuer Privatpersonen.\" und kein Abschnitt \"Kostenloses Handschriftmuster '\n + 'anfordern.\" im Artikeltext, beides liefert das Seitenlayout. Der Artikel endet mit dem letzten '\n + 'inhaltlichen Abschnitt, nie mit einer Zusammenfassung.',\n faq_ueberschrift: 'Eigene FAQ-Ueberschrift als Feld faq_ueberschrift, konkret zum Thema, z. B. '\n + '\"Fragen zu den Kosten\". Satzfall, ohne Punkt am Ende.',\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{ const sd = $getWorkflowStaticData('global'); if (sd.poll) delete sd.poll[a.query]; }\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, immer inklusive MwSt.: '\n + '[skrift_preis key=\"SCHLUESSEL\" ab=\"0|1\" brutto=\"1\"]. '\n + 'key ist der Produktschluessel (briefe, postkarten). ab=1 liefert den guenstigsten Staffelpreis, '\n + 'ab=0 den Standardpreis. brutto ist immer 1. Hinter dem Preis steht immer \"inkl. 19 % MwSt.\", '\n + 'nie \"netto\", \"brutto\" oder \"zzgl.\". Das Wort \"ab\" gehoert in den Fliesstext. Beispiel: '\n + 'Handschriftliche Briefe ab [skrift_preis key=\"briefe\" ab=\"1\" brutto=\"1\"] pro Stueck inkl. 19 % MwSt.',\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 einmal im Fliesstext verlinken, mindestens zwei Business-Links insgesamt. '\n + 'papier-schriftmuster nur ergaenzend. Keine Links auf Privatkunden-Seiten, review, agb, datenschutz, '\n + 'impressum, zahlungsmethoden, lieferung-versand.',\n querverweise: 'Mindestens zwei, hoechstens vier Querverweise auf verwandte Ratgeberartikel, immer als '\n + '[ratgeber_link slug=\"SLUG\"]Ankertext[/ratgeber_link], nie als harter Link. Slugs aus artikel-index.json. '\n + 'Der Shortcode verlinkt erst, wenn der Zielartikel veroeffentlicht ist. Ankertext beschreibt das Ziel, '\n + 'nie \"hier\" oder \"mehr dazu\".',\n bausteine: 'Kein Block \"Auch fuer Privatpersonen.\" und kein Abschnitt \"Kostenloses Handschriftmuster '\n + 'anfordern.\" im Artikeltext, beides liefert das Seitenlayout. Der Artikel endet mit dem letzten '\n + 'inhaltlichen Abschnitt, nie mit einer Zusammenfassung.',\n faq_ueberschrift: 'Eigene FAQ-Ueberschrift als Feld faq_ueberschrift, konkret zum Thema, z. B. '\n + '\"Fragen zu den Kosten\". Satzfall, ohne Punkt am Ende.',\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": "19c85690-97f0-4eb7-b1e0-a83348ceefe1", + "id": "fdb0ddb4-5218-4598-9b74-dcc2f84499f1", "name": "Brief bauen" }, { @@ -551,7 +551,7 @@ 2440, 300 ], - "id": "c43d02ff-1236-45fc-bcb3-5098accee588", + "id": "b0bafe2a-60ab-47fb-a006-6115edaf4256", "name": "Brief vorhanden?", "credentials": { "httpHeaderAuth": { @@ -571,7 +571,7 @@ 2660, 300 ], - "id": "8d222315-472d-44f2-953b-494074b5d482", + "id": "4a67bbcf-ce4f-4b76-b4f4-4ba6c8236b10", "name": "Commit vorbereiten" }, { @@ -601,7 +601,7 @@ 2880, 300 ], - "id": "1a784408-d09d-420b-a6d3-7f808bbd806d", + "id": "a60a0aac-449d-4990-872d-c2a848ebcd08", "name": "Brief committen", "credentials": { "httpHeaderAuth": { @@ -625,7 +625,7 @@ 2880, 520 ], - "id": "47ec95be-d567-4685-8ea7-aa447a91103d", + "id": "1dda0054-24af-465d-bd47-8f5693968c80", "name": "Fehler Brief committen", "onError": "continueRegularOutput" }, @@ -640,7 +640,7 @@ 3100, 300 ], - "id": "07a1e471-d6bb-4327-a812-266bca634263", + "id": "e8741dc7-728d-4409-97f4-1fa91f219b2c", "name": "Erfolg merken" }, { @@ -654,9 +654,9 @@ 3320, 400 ], - "id": "9e4fc985-8c3d-4736-9585-1ecaf29a4b01", + "id": "cea1c3e2-f571-4c9f-8753-ab3c10d9a348", "name": "Pause Rate Limit", - "webhookId": "380a5713-2767-4992-aa4a-053605517480" + "webhookId": "ef9b2ac6-fb6a-4727-99f9-173c3c8af25f" }, { "parameters": { @@ -669,7 +669,7 @@ 900, 140 ], - "id": "d80eaa2b-a228-4f93-a608-c4bc48804cea", + "id": "40489909-6d34-488c-acff-c22d3cce020d", "name": "Ergebnisse sammeln" }, { @@ -683,7 +683,7 @@ 1120, 140 ], - "id": "f52ac21c-d921-4b65-b0ca-0232f6c212e6", + "id": "b2acfa6d-d78a-4d92-937e-af3c5ce44d10", "name": "Statusupdate vorbereiten" }, { @@ -713,7 +713,7 @@ 1340, 140 ], - "id": "ace29136-8155-46df-93d4-4ebba0b909eb", + "id": "fcb0a473-d7c5-477b-aecb-4a60341b3c5e", "name": "Status zurueckschreiben", "credentials": { "httpHeaderAuth": { @@ -737,7 +737,7 @@ 1340, -60 ], - "id": "8cd217a6-ad58-44dd-bef9-7183c6a6ccad", + "id": "030a3ede-19b7-4b85-a582-f17d5ccfd316", "name": "Fehler Statusupdate" }, { @@ -751,7 +751,7 @@ 1560, 140 ], - "id": "b331d639-2de7-4329-8b11-350d227a59a5", + "id": "ba124436-2af4-4873-b5b3-7e206c34a598", "name": "Meldung formulieren" }, { @@ -769,7 +769,7 @@ 1780, 140 ], - "id": "8a6f1d29-5114-4e70-b539-18d50475ec94", + "id": "99f630b2-451c-4db8-b10b-47e58dd5b69d", "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 1452985..d89f370 100644 --- a/workflows/pruefen-und-importieren.json +++ b/workflows/pruefen-und-importieren.json @@ -9,7 +9,7 @@ -460, 100 ], - "id": "5538d2bd-2e94-4f42-a389-9384ef625de8", + "id": "77876018-7154-4c9d-b1dd-5dd786cf8aae", "name": "Manuell starten" }, { @@ -25,9 +25,9 @@ -460, 280 ], - "id": "3e3c342f-b4ee-4565-b1d3-a9d845707842", + "id": "57a30ac3-70e9-4cd3-b898-2f2b4ac669ac", "name": "Webhook Start", - "webhookId": "9c08dcef-5acc-462b-bca4-7340ee75ed8e" + "webhookId": "7a157b66-1824-459c-9dfb-0b52145d33a5" }, { "parameters": { @@ -35,115 +35,115 @@ "assignments": { "assignments": [ { - "id": "390bf7df-f505-4ef0-b187-16f28a42e8dc", + "id": "30e392d0-59d5-4ede-bd7a-2e007e477c0a", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "526b22c9-127b-4df5-8ffc-eeffd4fdaafb", + "id": "68bbc028-b18a-49ad-9032-5c0b0b3de53a", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "dc41acbd-71a6-4d54-901e-89b1b3c42cc8", + "id": "61a631f8-1840-45ab-ad22-3090d5de8e84", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "0ee5976c-0df1-4267-9f74-de284d60f74c", + "id": "0efb15dc-4dbf-4e51-b7d6-7e0fd1535ee6", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "475ceb04-ce9c-4471-94a2-780d10ada7c9", + "id": "6f442b85-68aa-4723-8f20-0c4804863d94", "name": "branch", "value": "main", "type": "string" }, { - "id": "88eebf2d-27d8-4127-a189-7e348bbf999e", + "id": "f26f278d-f9ce-4b2e-a9b4-17a528823163", "name": "artikel_ordner", "value": "artikel", "type": "string" }, { - "id": "6eb13140-4748-4344-a731-2d808e8986d7", + "id": "0a60485f-7117-4736-a8e9-a180d5f66ea3", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "783fe66a-75a8-4d8e-b2f0-2aebf80e087e", + "id": "fb4029bc-aaa5-4a2b-ac39-74e0a6b18ef6", "name": "nachbessern_ordner", "value": "nachbessern", "type": "string" }, { - "id": "2ed923be-06ad-48c4-b927-041f6e2f2552", + "id": "e255b391-5f12-4bde-84b4-c508bab8dac5", "name": "status_pfad", "value": "status-importe.json", "type": "string" }, { - "id": "a56e81d3-a9c3-46e9-8f34-ecf8b002ac9c", + "id": "5785c32f-ef1c-45c3-bde1-61eccb439105", "name": "wp_base", "value": "https://skrift.de", "type": "string" }, { - "id": "cfd8b1d7-6ee5-4a4a-85a2-724ea800968e", + "id": "b973a4ea-3b85-4c2f-abbd-8cf6d26025bc", "name": "score_ziel", "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 65 }}", "type": "number" }, { - "id": "2e91b780-c50a-4413-b3a7-b03b1810bd31", + "id": "ff314c6a-5748-4de2-8e2a-7c05ba864085", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "46cd6c5e-3ea8-4f8d-acd8-8cfc737c46dd", + "id": "66d24a08-85e1-4f85-b365-e34283f3b3a8", "name": "elementor_ausgabe", "value": false, "type": "boolean" }, { - "id": "e469e1f3-6366-4a91-ba25-5df62337228c", + "id": "afc4788f-4558-489f-8b3c-7df902791200", "name": "bild_aktiv", "value": true, "type": "boolean" }, { - "id": "6e75aa5e-71cf-432c-811e-0901c721268e", + "id": "fde4d84a-6f31-40a4-88c3-863e796aa45d", "name": "bild_modell", "value": "gpt-image-2", "type": "string" }, { - "id": "d5ffc52c-befa-451d-a82c-62a90bb95d6a", + "id": "ef031954-d296-4068-872a-6fbe54e1ccc3", "name": "bild_groesse", "value": "1200x1200", "type": "string" }, { - "id": "480645e7-a7e3-49b8-9e97-add0f25482b9", + "id": "94990856-3ac7-47df-9636-b51dfe6d5e1e", "name": "bild_qualitaet", "value": "medium", "type": "string" }, { - "id": "06d23bd2-7a0a-41ac-b70b-9cc1dac08b87", + "id": "d51af1a1-fc5a-4a60-ac9a-9ce2df113dd1", "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string" }, { - "id": "dbf57836-aa98-4f62-ad93-75b6978ed979", + "id": "b8366f1f-fe40-4ca7-b052-1bee24d543be", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -158,7 +158,7 @@ -240, 190 ], - "id": "8354444e-f8b0-4895-b9ef-78895a058245", + "id": "01d2991f-f38d-485d-8871-af8267952501", "name": "Konfiguration" }, { @@ -183,7 +183,7 @@ -20, 190 ], - "id": "8a150411-fc15-4549-9cac-9fd750998ba7", + "id": "b9f2b463-32ec-4250-bfbc-ad262f3901d3", "name": "Artikelordner lesen", "credentials": { "httpHeaderAuth": { @@ -206,7 +206,7 @@ 200, 190 ], - "id": "e91525e5-1fe9-468e-9bc9-7c865542ac81", + "id": "c89a4237-836d-44c3-8572-2bf939a873af", "name": "Artikeldateien filtern" }, { @@ -220,7 +220,7 @@ }, "conditions": [ { - "id": "3d273a9f-f283-4790-aacb-f1d615f08728", + "id": "c7bbd13f-b4c6-48f9-8b79-4efcb95ab7a2", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -241,7 +241,7 @@ 420, 190 ], - "id": "1c86b8dc-2590-47ea-aeda-3cc69e374f4c", + "id": "946944dc-a368-46f7-9af0-dd93cf2972a1", "name": "Artikel vorhanden?" }, { @@ -252,7 +252,7 @@ 640, 40 ], - "id": "abe7df94-da3e-409d-ac97-2aab7a446635", + "id": "6171f836-f34c-4528-b8df-2fe872055632", "name": "Nichts zu tun" }, { @@ -268,7 +268,7 @@ 640, 320 ], - "id": "527a5591-0e90-4495-afa5-df2a2666adc4", + "id": "9c1acd2c-c3ec-4dc9-8359-011552d42f61", "name": "Artikel nacheinander" }, { @@ -292,7 +292,7 @@ 900, 420 ], - "id": "204918b5-23e4-4e74-bce5-322c96e1675c", + "id": "baf3e0c2-38c8-45c4-834e-db4d09a570a9", "name": "Artikeldatei laden", "credentials": { "httpHeaderAuth": { @@ -316,7 +316,7 @@ 900, 640 ], - "id": "722cca8e-1303-41ff-ab50-27d18fe873df", + "id": "c0751d4b-193c-4942-86c3-f588b7cca5c6", "name": "Fehler Artikeldatei", "onError": "continueRegularOutput" }, @@ -342,7 +342,7 @@ 1120, 420 ], - "id": "5d42b7af-3125-4445-a579-665b0ea2a1c8", + "id": "998ea097-3d46-4bd5-9afb-9727c58b119d", "name": "Brief laden", "credentials": { "httpHeaderAuth": { @@ -354,7 +354,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 // 7b) Preise: immer inkl. MwSt., Formulierung \"inkl. 19 % MwSt.\"\n const alleTexte = [String(artikel.kurzantwort || ''), html,\n ...(Array.isArray(artikel.faq) ? artikel.faq.map(f => String(f.antwort || '')) : [])].join(' ');\n (alleTexte.match(/\\[skrift_preis[^\\]]*\\]/g) || []).forEach(s => {\n if (!/brutto=\"1\"/.test(s)) add('Preis ohne MwSt.', `${s} braucht brutto=\"1\"`);\n });\n const nb = ohneTags(alleTexte.replace(/\\[skrift_preis[^\\]]*\\]/g, 'PREIS')).match(/[^.]{0,40}\\b(netto|brutto|zzgl\\.?|zuzüglich)\\b[^.]{0,30}/gi) || [];\n nb.forEach(s => add('Preisangabe: \"netto\"/\"brutto\" statt \"inkl. 19 % MwSt.\"', s.trim()));\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 < 1) add('Links: CTA-Ziel nicht verlinkt', 'cta_ziel mindestens einmal im Fliesstext verlinken');\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) add('Links: Privatkunden-Link im Text', 'Privatkunden-Seiten nicht aus dem Artikeltext verlinken, das uebernimmt das Seitenlayout');\n // Bausteine, die das Seitenlayout bereits liefert\n if (/Auch für Privatpersonen/i.test(html)) add('Baustein doppelt: \"Auch für Privatpersonen.\"', 'Block aus dem Artikeltext entfernen, er steht im Seitenlayout');\n if (/]*>[^<]*(Muster anfordern|Handschriftmuster)/i.test(html)) add('Baustein doppelt: Muster-CTA', 'Abschnitt \"Kostenloses Handschriftmuster anfordern.\" entfernen, er steht im Seitenlayout');\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 ? `

${artikel.faq_ueberschrift || '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 // 7b) Preise: immer inkl. MwSt., Formulierung \"inkl. 19 % MwSt.\"\n const alleTexte = [String(artikel.kurzantwort || ''), html,\n ...(Array.isArray(artikel.faq) ? artikel.faq.map(f => String(f.antwort || '')) : [])].join(' ');\n (alleTexte.match(/\\[skrift_preis[^\\]]*\\]/g) || []).forEach(s => {\n if (!/brutto=\"1\"/.test(s)) add('Preis ohne MwSt.', `${s} braucht brutto=\"1\"`);\n });\n const nb = ohneTags(alleTexte.replace(/\\[skrift_preis[^\\]]*\\]/g, 'PREIS')).match(/[^.]{0,40}\\b(netto|brutto|zzgl\\.?|zuzüglich)\\b[^.]{0,30}/gi) || [];\n nb.forEach(s => add('Preisangabe: \"netto\"/\"brutto\" statt \"inkl. 19 % MwSt.\"', s.trim()));\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 < 1) add('Links: CTA-Ziel nicht verlinkt', 'cta_ziel mindestens einmal im Fliesstext verlinken');\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) add('Links: Privatkunden-Link im Text', 'Privatkunden-Seiten nicht aus dem Artikeltext verlinken, das uebernimmt das Seitenlayout');\n // Bausteine, die das Seitenlayout bereits liefert\n if (/Auch für Privatpersonen/i.test(html)) add('Baustein doppelt: \"Auch für Privatpersonen.\"', 'Block aus dem Artikeltext entfernen, er steht im Seitenlayout');\n if (/]*>[^<]*(Muster anfordern|Handschriftmuster)/i.test(html)) add('Baustein doppelt: Muster-CTA', 'Abschnitt \"Kostenloses Handschriftmuster anfordern.\" entfernen, er steht im Seitenlayout');\n // Querverweise auf andere Ratgeberartikel\n const quer = [...html.matchAll(/\\[ratgeber_link\\s+slug=\"([^\"]+)\"\\]([\\s\\S]*?)\\[\\/ratgeber_link\\]/g)];\n if (quer.length < 2) add('Querverweise: zu wenige', `${quer.length} [ratgeber_link], mindestens 2 auf verwandte Ratgeberartikel`);\n quer.filter(q => q[1] === artikel.slug).forEach(() => add('Querverweis auf sich selbst', artikel.slug));\n quer.filter(q => !q[2].trim()).forEach(q => add('Querverweis ohne Ankertext', q[1]));\n (html.match(/href=\"https?:\\/\\/(www\\.)?skrift\\.de\\/(?!handgeschriebene-|unterschriftenservice|automatisierte-follow-ups|papier-schriftmuster|kontakt|handschrift-fuer)[a-z0-9-]+\\/?\"/g) || [])\n .forEach(h => add('Ratgeber-Link als harter Link', `${h} als [ratgeber_link slug=\"...\"] setzen, sonst droht ein toter Link`));\n\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 ? `

${artikel.faq_ueberschrift || '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, @@ -362,7 +362,7 @@ 1340, 420 ], - "id": "0004630a-561d-47e0-8482-fad45ae3f1c3", + "id": "abfc66c7-c1ef-4640-9e5e-1f7268173605", "name": "Artikel pruefen" }, { @@ -392,7 +392,7 @@ 1560, 420 ], - "id": "1b15da2c-b93d-4fbd-b803-f104f8f88b94", + "id": "9158e17f-118f-4e28-b4bd-27c2f38ab958", "name": "Score ermitteln", "credentials": { "httpHeaderAuth": { @@ -416,7 +416,7 @@ 1560, 640 ], - "id": "422c4168-3898-4d52-bec7-d02ea6bc03e0", + "id": "e5155865-6106-494e-b238-de63ec2fcf23", "name": "Fehler Score", "onError": "continueRegularOutput" }, @@ -431,7 +431,7 @@ 1780, 420 ], - "id": "af056083-199f-4412-8eb4-1711eeb5cc14", + "id": "c670cfc5-5c64-46c4-b9d9-da2f9f7d998a", "name": "Score auswerten" }, { @@ -445,7 +445,7 @@ }, "conditions": [ { - "id": "8512f8e1-81a8-473d-8690-0bb0167d0aee", + "id": "da564c6f-a42b-4cf5-82bf-da3d41877da7", "leftValue": "={{ $json.bestanden }}", "rightValue": "", "operator": { @@ -466,7 +466,7 @@ 2000, 420 ], - "id": "22d68dd7-afad-42c9-8d5a-b6ba3326c3ab", + "id": "3dfb6f30-0274-49c2-84c0-a010d862298c", "name": "Score erreicht?" }, { @@ -480,7 +480,7 @@ 2220, 640 ], - "id": "6ba8665b-d17e-4eb5-88ef-f2fec20bf53b", + "id": "bac5edd8-d56a-4533-b56c-f3b24bab3263", "name": "Fehlende Begriffe ermitteln" }, { @@ -505,7 +505,7 @@ 2440, 640 ], - "id": "45709753-0ee1-47eb-a43a-f74bac69b8a3", + "id": "3d514218-62c0-439e-8594-7ccc5ebebdae", "name": "Nachbesserung vorhanden?", "credentials": { "httpHeaderAuth": { @@ -525,7 +525,7 @@ 2660, 640 ], - "id": "f8b6d020-9d26-4011-889b-7c4baf01a5a7", + "id": "a6b0f5d9-361d-4b09-bba3-e169b33b85ca", "name": "Nachbesserung vorbereiten" }, { @@ -555,7 +555,7 @@ 2880, 640 ], - "id": "e2a6ab12-9d70-4721-bfe7-33908c3907db", + "id": "4a9a76dc-68fa-4c8e-93a6-6c01f1ede8ce", "name": "Nachbesserung committen", "credentials": { "httpHeaderAuth": { @@ -579,7 +579,7 @@ 2880, 860 ], - "id": "c15887c3-f8d6-4d1a-a384-758336cf1611", + "id": "b675c23e-1826-4d4c-ace8-a339efef67bf", "name": "Fehler Nachbesserung", "onError": "continueRegularOutput" }, @@ -594,7 +594,7 @@ 3100, 640 ], - "id": "39611df3-08b6-4714-8913-d6d4a182691c", + "id": "48d2bbc1-5cc0-496e-80ba-d002ee7ea687", "name": "Nachbesserung gemerkt" }, { @@ -624,7 +624,7 @@ 2220, 240 ], - "id": "fb72faca-a385-410f-9ae9-bea8497cb8ee", + "id": "b84af3b2-4638-4713-81c1-c8d8747c0363", "name": "Revision zu NeuronWriter", "credentials": { "httpHeaderAuth": { @@ -648,7 +648,7 @@ 2220, 60 ], - "id": "5200499c-316d-4abf-8fa5-68b8855a8213", + "id": "c881c38e-ecdc-4a9c-a25f-e05e40c3e5ef", "name": "Fehler Revision", "onError": "continueRegularOutput" }, @@ -674,7 +674,7 @@ 2440, 240 ], - "id": "d150e8c2-5e08-453e-91c1-88d20142085d", + "id": "3fd00ae7-8bf9-4027-9119-bb8ba00d6e11", "name": "Beitrag vorhanden?", "credentials": { "httpBasicAuth": { @@ -708,7 +708,7 @@ 2660, 240 ], - "id": "703611e3-5820-4f90-86cb-0d15950cec22", + "id": "78586dc9-923d-49be-b5df-11f27a0cfbea", "name": "Kategorie suchen", "credentials": { "httpBasicAuth": { @@ -731,7 +731,7 @@ 2880, 240 ], - "id": "b4cd58dc-ab41-44d6-934c-9f40d5064653", + "id": "e198abd7-2f0b-49f5-adc5-3c4a9ee3ab3a", "name": "Kategorie pruefen" }, { @@ -745,7 +745,7 @@ }, "conditions": [ { - "id": "1418e966-d8bc-4344-a20e-947e9a74a471", + "id": "b0e30459-6b54-4b74-8470-9520c5f3a9bb", "leftValue": "={{ $json.kategorie_fehlt }}", "rightValue": "", "operator": { @@ -766,7 +766,7 @@ 3100, 240 ], - "id": "a37d9e34-17a8-4f1c-83c7-4364c2a6d7a3", + "id": "bcbe3d18-9ecc-497e-886b-32cdab8263e7", "name": "Kategorie anlegen?" }, { @@ -796,7 +796,7 @@ 3320, 140 ], - "id": "5a1285a8-cf43-417d-afb5-4d4b7f2bc215", + "id": "5a6a0165-5d7b-42f6-9818-2b76ebc1376b", "name": "Kategorie anlegen", "credentials": { "httpBasicAuth": { @@ -819,7 +819,7 @@ 3540, 240 ], - "id": "5f58cee4-82cd-49d8-9796-6b0e4cefde27", + "id": "c0e0e90e-e0d2-4a9d-8f9e-74fabf3a0561", "name": "Kategorie festlegen" }, { @@ -833,7 +833,7 @@ 3760, 240 ], - "id": "c5325756-6ff4-4d95-8bda-2371858927ca", + "id": "5712410d-e1ce-4147-a575-df8147fcdf86", "name": "WordPress-Body bauen" }, { @@ -863,7 +863,7 @@ 3980, 240 ], - "id": "3a509a0b-0e18-4d66-85be-e11f98c1b6f3", + "id": "7d1c919c-bb1e-4aff-b10b-ba9703dd66d7", "name": "Beitrag anlegen oder aktualisieren", "credentials": { "httpBasicAuth": { @@ -886,7 +886,7 @@ 4200, 240 ], - "id": "5b75c979-7d05-469f-a7dd-4c3f39b0cbbf", + "id": "553a5836-dd1b-44c5-9146-453e2fbf6bb6", "name": "Ergebnis pruefen" }, { @@ -917,7 +917,7 @@ 4420, 240 ], - "id": "d94b6d9c-412b-4343-8a5c-934b29c52961", + "id": "ec545d25-2c80-4b3b-9b7b-dd30b61990d8", "name": "Cache leeren", "credentials": { "httpBasicAuth": { @@ -937,7 +937,7 @@ 4640, 240 ], - "id": "73409752-8021-4e54-a47d-e4eadccbebfe", + "id": "db87877c-313a-434a-a1ee-fe0f0644ad86", "name": "Importergebnis merken" }, { @@ -951,9 +951,9 @@ 4860, 420 ], - "id": "de26ea0e-032e-4369-ad92-33937423dbc8", + "id": "06918dbc-a5cc-4598-ab4f-12963601622a", "name": "Pause Rate Limit", - "webhookId": "a21f142d-d8a7-4bd7-8b9b-1b58d7fe08ed" + "webhookId": "5be31916-7738-453d-b4a8-c3f58380d328" }, { "parameters": { @@ -966,7 +966,7 @@ }, "conditions": [ { - "id": "0f13a811-93b2-41b0-b008-bc49fe907c19", + "id": "38a37fb2-cd95-46ec-bdde-60a18b572c1c", "leftValue": "={{ $('Konfiguration').first().json.bild_aktiv === true && !Number($json.featured_media) }}", "rightValue": "", "operator": { @@ -987,7 +987,7 @@ 4860, 240 ], - "id": "9b6f2199-87ea-4926-bf8f-7b9dd632260b", + "id": "04b068e9-ed31-4dcc-8970-04daebd61b7f", "name": "Beitragsbild fehlt?" }, { @@ -1012,7 +1012,7 @@ 5080, 120 ], - "id": "a885b2bf-7d43-4ff8-9c55-a10015f88181", + "id": "e11b4f49-0c55-4392-861c-d66a9762a623", "name": "Bilddaten laden", "credentials": { "httpHeaderAuth": { @@ -1035,7 +1035,7 @@ 5300, 120 ], - "id": "26883a08-bb99-4bf7-94fa-1772db788c43", + "id": "ebe17d0b-3139-4aca-85cd-b5345c550c1b", "name": "Bildprompt bauen" }, { @@ -1049,7 +1049,7 @@ }, "conditions": [ { - "id": "5d1dd07d-ccc5-4af5-971f-505cdd5ef0cf", + "id": "6d7b129e-23c1-40b4-949d-0b8b628a4984", "leftValue": "={{ !!$json.bild_prompt }}", "rightValue": "", "operator": { @@ -1070,7 +1070,7 @@ 5520, 120 ], - "id": "1d827bd8-2432-40e8-903d-79e81c77960a", + "id": "583dad0b-2fec-4bfb-8c50-32edd965c782", "name": "Bildprompt vorhanden?" }, { @@ -1100,7 +1100,7 @@ 5740, 40 ], - "id": "6e9078dd-7f68-4491-b5a9-11a25bad2ed0", + "id": "3d2143cc-e937-4dca-bff4-1705102f41a2", "name": "Bild erzeugen", "credentials": { "httpHeaderAuth": { @@ -1124,7 +1124,7 @@ 5740, 260 ], - "id": "b22e5786-0100-4ba7-bb28-86ef4fa918f9", + "id": "bff3ef3d-9be6-4b71-ad55-7de2a9244440", "name": "Fehler Bild", "onError": "continueRegularOutput" }, @@ -1139,7 +1139,7 @@ 5960, 40 ], - "id": "c33551a0-9962-4fba-b94f-0d7540ba95a3", + "id": "d44f9381-0b68-4872-bd92-cb14cab24dfb", "name": "Bild als Datei" }, { @@ -1174,7 +1174,7 @@ 6180, 40 ], - "id": "f809110f-6dee-4d1c-802c-50dfb0ec72c3", + "id": "47c9c7e9-1f65-4434-b4b0-05ce2577a98b", "name": "Bild in Mediathek", "credentials": { "httpBasicAuth": { @@ -1208,7 +1208,7 @@ 6400, 40 ], - "id": "a0ef7f44-c9ca-4edb-9f8b-79a634688c4d", + "id": "85b0be75-4b66-4af7-83a0-a00d5b9fcbd9", "name": "Beitragsbild setzen", "credentials": { "httpBasicAuth": { @@ -1231,7 +1231,7 @@ 6620, 40 ], - "id": "9d7774ee-0cd1-4838-8794-c9d295f8cca1", + "id": "0de4b676-fd3c-4438-aeb5-d279effc2bc8", "name": "Bild gemerkt" }, { @@ -1245,7 +1245,7 @@ 900, 80 ], - "id": "519e24e7-6394-4448-8fbc-8b9abf2e2b5c", + "id": "6e3219cb-c2be-44d0-b2d0-6b182329ccc9", "name": "Ergebnisse sammeln" }, { @@ -1270,7 +1270,7 @@ 1120, 80 ], - "id": "db67b230-bf33-4d8b-839e-7500267ff088", + "id": "b368e64d-9aac-4cde-be95-4d061e76bd37", "name": "Statustabelle lesen", "credentials": { "httpHeaderAuth": { @@ -1290,7 +1290,7 @@ 1340, 80 ], - "id": "d9a2ea1e-f353-4dc0-b0d3-5626f56287a0", + "id": "6fb9c69b-f398-4cc3-a1e5-633f91c24923", "name": "Statustabelle fortschreiben" }, { @@ -1304,7 +1304,7 @@ }, "conditions": [ { - "id": "280db96a-ce49-4a0f-88e2-30b28e5530af", + "id": "04e69285-96fe-4313-9a53-22c17b0a1923", "leftValue": "={{ $json.ueberspringen }}", "rightValue": "", "operator": { @@ -1325,7 +1325,7 @@ 1560, 80 ], - "id": "379554bd-f08f-42b1-b5f0-8d4491081940", + "id": "5049ff31-926b-4c6c-a728-ba463fdd565b", "name": "Gab es Importe?" }, { @@ -1355,7 +1355,7 @@ 1780, 160 ], - "id": "d0aa7ca7-73d2-4373-8688-56b31c759e8a", + "id": "73f50d9b-9334-4f6c-a702-ced32babc406", "name": "Statustabelle schreiben", "credentials": { "httpHeaderAuth": { @@ -1379,7 +1379,7 @@ 1780, 340 ], - "id": "2381caea-8d16-47d5-b7b6-512feab7e685", + "id": "652ccbd8-38df-40b6-8cf9-43bd9ff9ba7c", "name": "Fehler Statustabelle", "onError": "continueRegularOutput" }, @@ -1394,7 +1394,7 @@ 2000, 80 ], - "id": "fbd5c338-83f4-4503-8639-13288826772e", + "id": "80192868-178f-4eca-a313-073f06bf3ce3", "name": "Meldung formulieren" }, { @@ -1412,7 +1412,7 @@ 2220, 80 ], - "id": "c54274ec-b1af-458d-936d-c413d8acd1b7", + "id": "c81c485d-8ac3-4c5c-bf3e-e2934a349f32", "name": "Benachrichtigung", "disabled": true, "notes": "SMTP-Credential zuweisen, Absender eintragen, dann aktivieren."