diff --git a/README.md b/README.md index 5374973..07c3235 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ Cowork legt je Artikel eine Datei `artikel/artikel-.json` an: "cta_ziel": "https://skrift.de/muster-anfordern", "content_html": "

...

...

", "kurzantwort": "Ein Absatz, der die Hauptfrage direkt beantwortet.", + "faq_ueberschrift": "Fragen zu den Kosten", "faq": [{"frage": "...", "antwort": "..."}], "seo_titel": "max. 60 Zeichen", "seo_beschreibung": "max. 155 Zeichen", @@ -198,6 +199,13 @@ Das Mu-Plugin `skrift-ratgeber-felder.php` (ab Version 1.1) bringt dafuer mit: im Widget, FAQPage-Schema per Schalter. Beitraege ohne FAQ zeigen nichts an. - **Elementor-Widget "Skrift Kurzantwort"** fuer die Kurzantwort-Box, loest Preis-Shortcodes im Text auf. +- **Dynamic Tags, Gruppe "Skrift"** (ab Plugin 1.3), nutzbar in jedem Text- oder + Ueberschrift-Widget ueber das Symbol fuer dynamische Inhalte: + - *Lesezeit*: aus Beitragsinhalt, Kurzantwort und FAQ, 200 Woerter pro Minute + (einstellbar), Ausgabe z. B. "6 Min." + - *FAQ-Ueberschrift*: Feld `faq_ueberschrift`, sonst der Standardtext aus dem + Tag. Hat der Beitrag kein FAQ, bleibt die Ausgabe leer. + - *Stand-Datum*: Feld `stand_datum` im Format `d.m.Y`, einstellbar - Shortcodes `[skrift_faq titel="..."]` und `[skrift_kurzantwort]` als Alternative, etwa im Elementor-Shortcode-Widget. - `cta_ziel` und `stand_datum` gibt Elementor Pro direkt ueber das Dynamic Tag @@ -247,6 +255,11 @@ Workflow 2 durchsucht `content_html` nach ausgeschriebenen Betraegen (`2,56 EUR`, `0,95 €`) und meldet jeden Treffer in der Benachrichtigung. Der Import wird deswegen nicht abgebrochen, der Beitrag ist ja ein Entwurf. +**Keine Bausteine aus dem Seitenlayout im Text.** Der Block "Auch fuer +Privatpersonen." und der Abschnitt "Kostenloses Handschriftmuster anfordern." +stehen im Single-Post-Template und gehoeren nicht in `content_html`. Die +Stilpruefung meldet beide. + **FAQ nicht in content_html schreiben.** Sie gehoeren in das Feld `faq` und werden im Beitrag ueber `[skrift_faq]` ausgegeben. Dasselbe gilt fuer die Kurzantwort, die als eigenes Feld `kurzantwort` transportiert wird. @@ -375,7 +388,8 @@ Hat ein importierter Beitrag noch kein Beitragsbild, erzeugt Workflow 2 eins: 2. Prompt nach `ratgeber/bildstil.md` bauen, die Farbe zusaetzlich mit Namen, weil das Modell reine Hexwerte etwas zu kraeftig umsetzt 3. `POST https://api.openai.com/v1/images/generations` mit `gpt-image-2`, - Groesse `1536x864` (16:9 direkt, kein Zuschnitt noetig), `output_format: webp`, + Groesse `1200x1200` (quadratisch fuer die Artikelseite, Motiv mittig im + Streifen, damit die Uebersicht auf 16:9 zuschneiden kann), `output_format: webp`, `quality: medium`. Antwort in `data[0].b64_json`, Dauer rund 25 Sekunden. 4. Als WebP in die Mediathek laden, Alt-Text "Illustration zum Artikel: " 5. Als `featured_media` setzen und gegenpruefen diff --git a/build/gen_wf1.py b/build/gen_wf1.py index 3be57a9..3bf28c5 100644 --- a/build/gen_wf1.py +++ b/build/gen_wf1.py @@ -317,10 +317,14 @@ const brief = { + '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.', + + '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.', + 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.', + faq_ueberschrift: 'Eigene FAQ-Ueberschrift als Feld faq_ueberschrift, konkret zum Thema, z. B. ' + + '"Fragen zu den Kosten". Satzfall, ohne Punkt am Ende.', 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/gen_wf2.py b/build/gen_wf2.py index 6feeb16..2f2c342 100644 --- a/build/gen_wf2.py +++ b/build/gen_wf2.py @@ -74,7 +74,7 @@ KONF = node("Konfiguration", "n8n-nodes-base.set", 3.4, [-240, 190], { {"id": nid(), "name": "elementor_ausgabe", "value": False, "type": "boolean"}, {"id": nid(), "name": "bild_aktiv", "value": True, "type": "boolean"}, {"id": nid(), "name": "bild_modell", "value": "gpt-image-2", "type": "string"}, - {"id": nid(), "name": "bild_groesse", "value": "1536x864", "type": "string"}, + {"id": nid(), "name": "bild_groesse", "value": "1200x1200", "type": "string"}, {"id": nid(), "name": "bild_qualitaet", "value": "medium", "type": "string"}, {"id": nid(), "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string"}, {"id": nid(), "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string"}, @@ -187,7 +187,7 @@ const faqText = (Array.isArray(artikel.faq) ? artikel.faq : []) const bewertungs_html = [ artikel.kurzantwort ? `

${artikel.kurzantwort}

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

Häufige Fragen

\n${faqText}` : '', + faqText ? `

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

\n${faqText}` : '', ].filter(Boolean).join('\n'); const praxis = Array.isArray(artikel.praxis) && artikel.praxis.length ? artikel.praxis @@ -440,6 +440,7 @@ const heute = new Date().toISOString().slice(0, 10); const meta = { kurzantwort: a.kurzantwort || '', + faq_ueberschrift: a.faq_ueberschrift || '', cta_ziel: a.cta_ziel || '', stand_datum: a.stand_datum || heute, faq: Array.isArray(a.faq) @@ -659,7 +660,7 @@ const prompt = [ 'Clean closed outlines, no stray marks, no hatching and no small dashes inside the shapes.', 'Object fills are flat off-white cream (#F9F7F5), no shading, no highlights.', `Motif: ${zeile.bildmotiv}${leer}. Shown straight from the front, not tilted.`, - 'Minimal and iconic, lots of empty space, the object takes about 35 percent of the image height.', + 'The whole motif sits in the exact center and stays inside the middle band of the image: it takes about 28 percent of the image height and at most 60 percent of the image width, with wide empty background above and below.', 'No text, no letters, no numbers, no currency symbols, no logo, no border, no frame, no rounded corners, no people.', ].join(' '); diff --git a/build/stilpruefung.js b/build/stilpruefung.js index 9bb30e4..c6c9dd6 100644 --- a/build/stilpruefung.js +++ b/build/stilpruefung.js @@ -99,17 +99,14 @@ function stilpruefung(artikel) { 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)`); + if (ctaAnzahl < 1) add('Links: CTA-Ziel nicht verlinkt', 'cta_ziel mindestens einmal im Fliesstext verlinken'); 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`); + if (priv.length) add('Links: Privatkunden-Link im Text', 'Privatkunden-Seiten nicht aus dem Artikeltext verlinken, das uebernimmt das Seitenlayout'); + // 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'); 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/build/stiltest.js b/build/stiltest.js new file mode 100644 index 0000000..7d61c7d --- /dev/null +++ b/build/stiltest.js @@ -0,0 +1,6 @@ +// Aufruf: node build/stiltest.js artikel/artikel-SLUG.json +const { stilpruefung } = require('./stilpruefung.js'); +const a = require(require('path').resolve(process.argv[2])); +const h = stilpruefung(a); +console.log(`${h.length} Hinweise`); +h.forEach(x => console.log(` [${x.art}] ${x.stelle}`)); diff --git a/docs/bildtest/quadrat-1200.webp b/docs/bildtest/quadrat-1200.webp new file mode 100644 index 0000000..a562829 Binary files /dev/null and b/docs/bildtest/quadrat-1200.webp differ diff --git a/docs/bildtest/workflow-4221-quadrat.webp b/docs/bildtest/workflow-4221-quadrat.webp new file mode 100644 index 0000000..89e5261 Binary files /dev/null and b/docs/bildtest/workflow-4221-quadrat.webp differ diff --git a/wordpress/beitragslayout.css b/wordpress/beitragslayout.css index 4b39e7e..5c64997 100644 --- a/wordpress/beitragslayout.css +++ b/wordpress/beitragslayout.css @@ -162,3 +162,15 @@ padding: 16px 18px; } } + +/* ---------- Uebersicht: quadratisches Beitragsbild als 16:9-Kachel ---------- + Gehoert NICHT ins Einzelbeitrag-Template, sondern ins Loop-Item bzw. die + Archivseite. Im Bild-Widget unter Erweitert die CSS-Klasse + "skrift-kachelbild" setzen. Das Motiv sitzt mittig, der Zuschnitt nimmt + oben und unten nur Hintergrund weg. */ +.skrift-kachelbild img { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + object-position: center; +} diff --git a/wordpress/skrift-ratgeber-felder.php b/wordpress/skrift-ratgeber-felder.php index c1c8ed1..67177dd 100644 --- a/wordpress/skrift-ratgeber-felder.php +++ b/wordpress/skrift-ratgeber-felder.php @@ -1,8 +1,8 @@ 'skrift_ratgeber_meta_auth', ) ); + register_post_meta( 'post', 'faq_ueberschrift', array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'auth_callback' => 'skrift_ratgeber_meta_auth', + ) ); + register_post_meta( 'post', 'cta_ziel', array( 'type' => 'string', 'single' => true, @@ -92,6 +100,7 @@ function skrift_ratgeber_metabox( $post ) { $kurz = (string) get_post_meta( $post->ID, 'kurzantwort', true ); $cta = (string) get_post_meta( $post->ID, 'cta_ziel', true ); $stand = (string) get_post_meta( $post->ID, 'stand_datum', true ); + $faq_u = (string) get_post_meta( $post->ID, 'faq_ueberschrift', true ); $faq = get_post_meta( $post->ID, 'faq', true ); if ( ! is_array( $faq ) ) { $faq = array(); @@ -125,6 +134,11 @@ function skrift_ratgeber_metabox( $post ) {

+

+ + +

+
$e ) : ?> @@ -183,6 +197,7 @@ add_action( 'save_post_post', function ( $post_id ) { } update_post_meta( $post_id, 'kurzantwort', wp_kses_post( wp_unslash( $_POST['skrift_kurzantwort'] ?? '' ) ) ); + update_post_meta( $post_id, 'faq_ueberschrift', sanitize_text_field( wp_unslash( $_POST['skrift_faq_ueberschrift'] ?? '' ) ) ); update_post_meta( $post_id, 'cta_ziel', esc_url_raw( wp_unslash( $_POST['skrift_cta_ziel'] ?? '' ) ) ); update_post_meta( $post_id, 'stand_datum', skrift_ratgeber_datum( sanitize_text_field( wp_unslash( $_POST['skrift_stand_datum'] ?? '' ) ) ) ); @@ -297,6 +312,40 @@ function skrift_kurzantwort_html( $post_id = 0 ) { return '
' . wp_kses_post( wpautop( do_shortcode( $text ) ) ) . '
'; } +function skrift_lesezeit_minuten( $post_id = 0, $wpm = 200 ) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + $post = get_post( $post_id ); + if ( ! $post ) { + return 1; + } + $text = do_shortcode( (string) $post->post_content ) . ' ' . (string) get_post_meta( $post_id, 'kurzantwort', true ); + foreach ( skrift_faq_eintraege( $post_id ) as $e ) { + $text .= ' ' . $e['frage'] . ' ' . ( $e['antwort'] ?? '' ); + } + $text = trim( wp_strip_all_tags( do_shortcode( $text ) ) ); + $woerter = '' === $text ? 0 : count( preg_split( '/\s+/u', $text ) ); + $wpm = max( 100, (int) $wpm ); + return max( 1, (int) ceil( $woerter / $wpm ) ); +} + +function skrift_faq_ueberschrift( $post_id = 0, $standard = 'Häufige Fragen' ) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + if ( empty( skrift_faq_eintraege( $post_id ) ) ) { + return ''; + } + $u = trim( (string) get_post_meta( $post_id, 'faq_ueberschrift', true ) ); + return '' !== $u ? $u : (string) $standard; +} + +function skrift_stand_formatiert( $post_id = 0, $format = 'd.m.Y' ) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + $s = (string) get_post_meta( $post_id, 'stand_datum', true ); + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $s ) ) { + $s = get_the_modified_date( 'Y-m-d', $post_id ); + } + return date_i18n( $format ? $format : 'd.m.Y', strtotime( $s . ' 12:00:00' ) ); +} + /* Shortcodes, z. B. fuer das Elementor-Shortcode-Widget oder klassische Themes */ add_shortcode( 'skrift_faq', function ( $atts ) { @@ -560,3 +609,114 @@ add_action( 'elementor/widgets/register', function ( $widgets_manager ) { $widgets_manager->register( new Skrift_FAQ_Widget() ); $widgets_manager->register( new Skrift_Kurzantwort_Widget() ); } ); + +/* ------------------------------------------------------------------------ + * 5. Elementor Dynamic Tags (Gruppe "Skrift") + * In jedem Text- oder Ueberschrift-Widget ueber das Symbol fuer dynamische + * Inhalte waehlbar. "Davor" und "Danach" stellt Elementor selbst bereit. + * --------------------------------------------------------------------- */ + +add_action( 'elementor/dynamic_tags/register', function ( $dynamic_tags ) { + + if ( ! class_exists( '\Elementor\Core\DynamicTags\Tag' ) || ! method_exists( $dynamic_tags, 'register' ) ) { + return; + } + + $dynamic_tags->register_group( 'skrift', array( 'title' => 'Skrift' ) ); + + if ( ! class_exists( 'Skrift_Tag_Lesezeit' ) ) { + class Skrift_Tag_Lesezeit extends \Elementor\Core\DynamicTags\Tag { + public function get_name() { + return 'skrift-lesezeit'; + } + public function get_title() { + return 'Lesezeit'; + } + public function get_group() { + return 'skrift'; + } + public function get_categories() { + return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY ); + } + protected function register_controls() { + $this->add_control( 'wpm', array( + 'label' => 'Woerter pro Minute', + 'type' => \Elementor\Controls_Manager::NUMBER, + 'default' => 200, + 'min' => 100, + 'max' => 400, + ) ); + $this->add_control( 'einheit', array( + 'label' => 'Einheit', + 'type' => \Elementor\Controls_Manager::TEXT, + 'default' => ' Min.', + ) ); + } + public function render() { + $wpm = (int) $this->get_settings( 'wpm' ); + $einheit = (string) $this->get_settings( 'einheit' ); + echo esc_html( skrift_lesezeit_minuten( get_the_ID(), $wpm ? $wpm : 200 ) . $einheit ); + } + } + } + + if ( ! class_exists( 'Skrift_Tag_FAQ_Ueberschrift' ) ) { + class Skrift_Tag_FAQ_Ueberschrift extends \Elementor\Core\DynamicTags\Tag { + public function get_name() { + return 'skrift-faq-ueberschrift'; + } + public function get_title() { + return 'FAQ-Ueberschrift'; + } + public function get_group() { + return 'skrift'; + } + public function get_categories() { + return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY ); + } + protected function register_controls() { + $this->add_control( 'standard', array( + 'label' => 'Standardtext', + 'type' => \Elementor\Controls_Manager::TEXT, + 'default' => 'Häufige Fragen', + 'description' => 'Gilt, wenn im Beitrag keine eigene FAQ-Ueberschrift gepflegt ist. Hat der Beitrag kein FAQ, bleibt die Ausgabe leer.', + ) ); + } + public function render() { + echo esc_html( skrift_faq_ueberschrift( get_the_ID(), (string) $this->get_settings( 'standard' ) ) ); + } + } + } + + if ( ! class_exists( 'Skrift_Tag_Stand' ) ) { + class Skrift_Tag_Stand extends \Elementor\Core\DynamicTags\Tag { + public function get_name() { + return 'skrift-stand'; + } + public function get_title() { + return 'Stand-Datum'; + } + public function get_group() { + return 'skrift'; + } + public function get_categories() { + return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY ); + } + protected function register_controls() { + $this->add_control( 'format', array( + 'label' => 'Datumsformat', + 'type' => \Elementor\Controls_Manager::TEXT, + 'default' => 'd.m.Y', + 'description' => 'PHP-Datumsformat, z. B. d.m.Y oder j. F Y', + ) ); + } + public function render() { + echo esc_html( skrift_stand_formatiert( get_the_ID(), (string) $this->get_settings( 'format' ) ) ); + } + } + } + + $dynamic_tags->register( new Skrift_Tag_Lesezeit() ); + $dynamic_tags->register( new Skrift_Tag_FAQ_Ueberschrift() ); + $dynamic_tags->register( new Skrift_Tag_Stand() ); +} ); diff --git a/workflows/brief-vorbereiten.json b/workflows/brief-vorbereiten.json index 6d8c6ca..b25b9e0 100644 --- a/workflows/brief-vorbereiten.json +++ b/workflows/brief-vorbereiten.json @@ -9,7 +9,7 @@ -460, 40 ], - "id": "3e82fd60-13cc-4e4a-be41-e794e22ce423", + "id": "e753db60-1139-4873-bc3f-33ce61502a0c", "name": "Manuell starten" }, { @@ -33,7 +33,7 @@ -460, 200 ], - "id": "9b5e08f8-18e1-4040-a339-c1ec3d9f8029", + "id": "99319fb1-8e86-4e82-b192-3cb1dfc387cd", "name": "Zeitplan Montag 07:00", "disabled": true }, @@ -50,9 +50,9 @@ -460, 360 ], - "id": "c8af01cd-955f-46f2-ae51-4313fbbbb65a", + "id": "657d9657-3a90-4a83-b9db-adcc26ca3276", "name": "Webhook Start", - "webhookId": "51378f4e-f1b9-4d24-93af-e3a69b241d10" + "webhookId": "84a8da24-b2b3-43b6-b0ae-f61e0b09b798" }, { "parameters": { @@ -60,85 +60,85 @@ "assignments": { "assignments": [ { - "id": "45448038-1fe8-4fa0-8b9b-d5b96e817e6d", + "id": "4cd0bc00-7d77-4297-92f2-2615b8e3ee30", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "df57bef9-1b80-4b93-a922-bde8dde464f7", + "id": "7652c6df-df16-48d9-bc02-970407a0056c", "name": "nw_project", "value": "3da4d745e0e223cb", "type": "string" }, { - "id": "1f70dc44-62f0-4eaf-9e45-a25fbcaf4f1d", + "id": "2df2405a-8a94-412d-99f0-c03734764ff9", "name": "nw_engine", "value": "google.de", "type": "string" }, { - "id": "0654a9d9-a24d-4149-b871-e074d949003b", + "id": "29a4fac5-c86c-452a-9534-388fee8f0a00", "name": "nw_language", "value": "German", "type": "string" }, { - "id": "e6f25a33-143d-42fb-a65b-040c343657a8", + "id": "d62d3a56-9f07-49f0-9dee-4016c0d0c59c", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "05ac7b8e-687f-4394-93f1-85ed451722db", + "id": "c8918f84-6424-48bb-8153-daec31e5d3fc", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "24a62c00-8e66-42ce-ba9b-fa308c4ff1ba", + "id": "f316e4e7-757b-4163-b061-339796429248", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "7cf62eff-3125-485b-bee6-5623b2f807b2", + "id": "74912d70-7d7c-41e2-9ac2-84865e460cf6", "name": "branch", "value": "main", "type": "string" }, { - "id": "15524e8f-1753-4606-8713-7093c619a4cd", + "id": "66c2f30e-0458-4251-974b-a8695c7e8b04", "name": "index_pfad", "value": "artikel-index.json", "type": "string" }, { - "id": "3fff721c-512e-4229-a36d-995e93d44640", + "id": "1ce974ed-b971-497d-a246-eec21bfd6d11", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "b5a7af9f-d17b-4914-9631-50e9e158ebb9", + "id": "e1dafef4-0eb6-45c0-acf2-8c7b543b7a02", "name": "poll_max", "value": 30, "type": "number" }, { - "id": "f9f5e461-1c65-4471-b6a1-509a97d11b6b", + "id": "d010e1c9-54a7-4ef9-b0db-517fe2d74b9b", "name": "poll_sekunden", "value": 20, "type": "number" }, { - "id": "14413f0e-c7d5-4af7-83d1-3c114d17ec3a", + "id": "8525830a-5432-455c-98a2-25c158c685a6", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "da28533b-b13a-4b1e-ba5e-ba625b8b8131", + "id": "8353a3fc-a8d3-421a-8ae7-f400ab6e0fba", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -153,7 +153,7 @@ -240, 200 ], - "id": "4a63c157-1185-44e8-9b46-a5562baa3891", + "id": "8aa9183c-7dfe-4bbb-8000-40c04b4edd53", "name": "Konfiguration" }, { @@ -177,7 +177,7 @@ -20, 200 ], - "id": "01cc98c1-cc3c-4812-a90d-5af740da5270", + "id": "00c9281c-69fe-4f0e-87fc-c5588d4c5349", "name": "Artikeltabelle lesen", "credentials": { "httpHeaderAuth": { @@ -201,7 +201,7 @@ -20, 420 ], - "id": "f9f0c1ca-5c76-4066-a624-f01a23259a8c", + "id": "df2817d6-08d6-4c2f-9760-beabec0b1b6f", "name": "Fehler Artikeltabelle" }, { @@ -215,7 +215,7 @@ 200, 200 ], - "id": "a75c5412-3779-4c60-a957-d9e31b906159", + "id": "b8e741f7-f470-4047-8e46-a08641aa2ba6", "name": "Tabelle auswerten" }, { @@ -229,7 +229,7 @@ }, "conditions": [ { - "id": "f642c3ea-743e-4d62-8f20-2025a113ce16", + "id": "37d71caa-2ff5-4431-b4cb-6a0cbfbdf78f", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -250,7 +250,7 @@ 420, 200 ], - "id": "2162a546-7ead-445a-8c00-2f43db67133c", + "id": "f2611812-b7b0-4b3d-aef1-797baffaa2de", "name": "Offene Artikel vorhanden?" }, { @@ -261,7 +261,7 @@ 640, 60 ], - "id": "eeece2ac-46d3-484a-8244-9a5932e6a26d", + "id": "cfaa62aa-bb54-4b44-9739-436b1cffad88", "name": "Nichts zu tun" }, { @@ -277,7 +277,7 @@ 640, 320 ], - "id": "46ea39bd-11d4-46f6-82f8-42e2bfe03bcb", + "id": "fd897525-a406-457a-ae4c-778194fd1f0a", "name": "Artikel nacheinander" }, { @@ -307,7 +307,7 @@ 900, 420 ], - "id": "2b42440f-b41a-4189-8ac0-38ddeb0bf0cd", + "id": "eba9ec08-14d7-4c79-9383-64ed222e135a", "name": "Analyse starten", "credentials": { "httpHeaderAuth": { @@ -331,7 +331,7 @@ 900, 640 ], - "id": "79bdabb8-5fa9-4c1f-b8b0-fd0085d7cda7", + "id": "3668b98c-65db-405c-9d21-537ad4df4d3f", "name": "Fehler Analyse starten", "onError": "continueRegularOutput" }, @@ -346,7 +346,7 @@ 1120, 420 ], - "id": "b1db576e-0ede-43ce-b05f-f6c11628cb92", + "id": "d2430eff-230f-4be5-ba0d-e4d1a4ab4ba9", "name": "Poll vorbereiten" }, { @@ -360,9 +360,9 @@ 1340, 420 ], - "id": "823195ed-6db4-423f-8f99-15430a92ee3d", + "id": "becfbd1d-291f-4294-b1dd-a0a224b22de8", "name": "Warten", - "webhookId": "09704b0e-8596-4f02-a069-fd093c0d6aa6" + "webhookId": "83211d11-5e8d-4c8e-91ec-b5112d663862" }, { "parameters": { @@ -391,7 +391,7 @@ 1560, 420 ], - "id": "cf960417-7f71-4a56-b817-23827d6994c3", + "id": "4d66a76e-5b27-4880-9b95-6ac38203e371", "name": "Analyse abfragen", "credentials": { "httpHeaderAuth": { @@ -415,7 +415,7 @@ 1560, 640 ], - "id": "a8905a9d-51c9-48ff-8d67-df6ec2ee21af", + "id": "689dd568-3c1d-4af4-b5d3-50e537fdf119", "name": "Fehler Analyse abfragen", "onError": "continueRegularOutput" }, @@ -430,7 +430,7 @@ }, "conditions": [ { - "id": "638c8975-94e8-47f1-8f6f-9e65d4a7b4ae", + "id": "c6958335-389e-4fde-8d82-9da8a837fea6", "leftValue": "={{ $json.status }}", "rightValue": "ready", "operator": { @@ -450,7 +450,7 @@ 1780, 420 ], - "id": "11dcf83f-a673-485e-83fb-f5ad367249ae", + "id": "beb96c7d-17a0-459a-9083-b3ffb1da2b35", "name": "Analyse fertig?" }, { @@ -464,7 +464,7 @@ 1780, 660 ], - "id": "6ff3ad23-0b46-4a19-9f26-a15096693abe", + "id": "7d393c99-27f8-4a7c-9066-d2ccf81d5cbf", "name": "Versuch zaehlen" }, { @@ -478,7 +478,7 @@ }, "conditions": [ { - "id": "e622c3eb-470a-4f32-bf95-12b3fdd23b9d", + "id": "8ee3ea55-c784-4a43-8042-15869c607ad8", "leftValue": "={{ $json.versuch }}", "rightValue": "={{ $('Konfiguration').first().json.poll_max }}", "operator": { @@ -498,7 +498,7 @@ 2000, 660 ], - "id": "68d0090b-6024-486a-8c39-3fb45364205d", + "id": "431e705f-2b52-4525-ba34-fb47b8011498", "name": "Poll-Limit erreicht?" }, { @@ -512,13 +512,13 @@ 2220, 780 ], - "id": "0a93c0ec-9ae9-43d8-9f00-0828ba572fc8", + "id": "f6e65544-0e98-44e2-b698-d7d688829d61", "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 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" + "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 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" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -526,7 +526,7 @@ 2220, 300 ], - "id": "6a2cffc4-9346-4cb0-a160-d8693a2a597d", + "id": "6081e30a-4127-4af7-8af7-2b2446711a71", "name": "Brief bauen" }, { @@ -551,7 +551,7 @@ 2440, 300 ], - "id": "7a62b780-ac42-4492-a6dd-37391b0ad966", + "id": "4cefe611-3c98-4c4f-bf72-db5b8a05a5bd", "name": "Brief vorhanden?", "credentials": { "httpHeaderAuth": { @@ -571,7 +571,7 @@ 2660, 300 ], - "id": "660e2ede-2a1c-4608-8102-cdbdb96f6fe8", + "id": "ed3e6ae2-94a0-462c-98a2-e00c5a1c77ab", "name": "Commit vorbereiten" }, { @@ -601,7 +601,7 @@ 2880, 300 ], - "id": "eee6843f-1f02-410f-865c-ee8e943f2ca7", + "id": "2331dd6c-8595-482e-abd4-e7ec9f155dd2", "name": "Brief committen", "credentials": { "httpHeaderAuth": { @@ -625,7 +625,7 @@ 2880, 520 ], - "id": "5d707f38-0386-4146-856b-2390209d6956", + "id": "19ea007b-0521-423a-bbf0-c1318f1b1cd0", "name": "Fehler Brief committen", "onError": "continueRegularOutput" }, @@ -640,7 +640,7 @@ 3100, 300 ], - "id": "399d4795-5711-4b62-a0c9-881a15e6e204", + "id": "c9e0a373-8df8-4b4a-9dea-7b204d84b97a", "name": "Erfolg merken" }, { @@ -654,9 +654,9 @@ 3320, 400 ], - "id": "adfeae55-e12b-47af-b073-56fa685740eb", + "id": "22e97356-a573-465a-8847-7c4967923fa0", "name": "Pause Rate Limit", - "webhookId": "04378d61-bcb9-42f1-8b3b-9c49986e9849" + "webhookId": "10b98341-b2a3-4bee-9158-670abd71eefe" }, { "parameters": { @@ -669,7 +669,7 @@ 900, 140 ], - "id": "0f205638-8842-4062-a576-9da19b33e94a", + "id": "abf1aba9-cac5-4556-8230-798373a16c30", "name": "Ergebnisse sammeln" }, { @@ -683,7 +683,7 @@ 1120, 140 ], - "id": "64e6553e-2f89-4458-bd11-52c4985f8630", + "id": "a378732e-63fb-4137-aad3-e15a80768b1b", "name": "Statusupdate vorbereiten" }, { @@ -713,7 +713,7 @@ 1340, 140 ], - "id": "a2ee6e79-599e-42f5-8f87-12f223963f7b", + "id": "4160f022-aba5-4057-a179-8e632da542e2", "name": "Status zurueckschreiben", "credentials": { "httpHeaderAuth": { @@ -737,7 +737,7 @@ 1340, -60 ], - "id": "8996e9c2-78ff-4b35-8c90-46e9703bd6dc", + "id": "c77421d1-349f-4cb6-8b8a-a1ead23bfb47", "name": "Fehler Statusupdate" }, { @@ -751,7 +751,7 @@ 1560, 140 ], - "id": "4580bcca-8d6a-419a-b91f-0eb6a41c11a2", + "id": "09faecc1-3eb8-4d31-a616-8286c1ef5b46", "name": "Meldung formulieren" }, { @@ -769,7 +769,7 @@ 1780, 140 ], - "id": "45251711-e81c-40d0-9273-a6fbc7197934", + "id": "9e6b8af6-6231-4838-9a04-ba8bde15decc", "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 2979a8a..72e5fed 100644 --- a/workflows/pruefen-und-importieren.json +++ b/workflows/pruefen-und-importieren.json @@ -9,7 +9,7 @@ -460, 100 ], - "id": "b9523f68-6d9f-4591-92c1-315c8d17e036", + "id": "265263b1-e44a-4457-9478-eab95f7e5a84", "name": "Manuell starten" }, { @@ -25,9 +25,9 @@ -460, 280 ], - "id": "e145f97c-7c42-4ac0-bbfa-6791cdf1dc75", + "id": "94759c20-c550-4bbf-8139-aa61ca8d49e6", "name": "Webhook Start", - "webhookId": "51409459-2753-4b72-9442-44eb304e3970" + "webhookId": "f14cad07-6ef2-4eda-b35c-cd42e5182987" }, { "parameters": { @@ -35,115 +35,115 @@ "assignments": { "assignments": [ { - "id": "41e1ade4-2852-4dd1-a6ea-2bb3f9303615", + "id": "093a0548-c164-4341-8728-d4e268ed4c91", "name": "nw_base", "value": "https://app.neuronwriter.com/neuron-api/0.5/writer", "type": "string" }, { - "id": "919bffea-074d-4e88-b70a-811a1eac022c", + "id": "4bef6ac1-2c7a-4c03-b2e3-0d71953ef88b", "name": "gitea_base", "value": "http://gitea:3000", "type": "string" }, { - "id": "0b37ba5d-6fca-4366-93f0-91f449cbac1d", + "id": "6d54a36c-0259-4ebb-a92b-c247ea98c7ea", "name": "gitea_owner", "value": "lucas.orth", "type": "string" }, { - "id": "1bb08030-e5c0-43d9-956a-dc7eaa123b1c", + "id": "f26c6607-7a45-4f1f-b29f-71a4da8bf754", "name": "gitea_repo", "value": "Skrift-Auto-Article", "type": "string" }, { - "id": "f025bd60-6036-4d86-ad10-de6e9797b4aa", + "id": "922c32f4-0897-4834-9640-c96b9af398af", "name": "branch", "value": "main", "type": "string" }, { - "id": "70fe52cd-e118-428f-98d3-b6fe820415b4", + "id": "03f6d42d-170e-469e-88cd-986c1b4e11c8", "name": "artikel_ordner", "value": "artikel", "type": "string" }, { - "id": "dca9c723-2b38-4eb5-8a97-11e5261768af", + "id": "555e09dc-ac0e-47ce-a939-e12f790d6e0d", "name": "brief_ordner", "value": "briefe", "type": "string" }, { - "id": "d1761018-b988-49c6-a8d2-b2daea919c48", + "id": "99916e7b-ef56-48dc-bda1-83a1e4cc0c13", "name": "nachbessern_ordner", "value": "nachbessern", "type": "string" }, { - "id": "7ca8cd59-7043-4a0c-8045-ee87d6386c90", + "id": "e6259d17-c43d-42e4-8710-be43daaad6ff", "name": "status_pfad", "value": "status-importe.json", "type": "string" }, { - "id": "88d49605-c288-4715-aa51-6263c7a173a3", + "id": "5cbe77f0-5ef3-4082-bdc6-2e7d1bf0427a", "name": "wp_base", "value": "https://skrift.de", "type": "string" }, { - "id": "de3caff4-aa67-441f-b26c-16bdfed44eb9", + "id": "5195f907-2616-4f97-9579-891001d25d23", "name": "score_ziel", "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 65 }}", "type": "number" }, { - "id": "8047bfd6-776e-462f-887e-fbee0826d29d", + "id": "c015b3d5-97b5-4c3c-a61e-33fce56c1a32", "name": "pause_zwischen_artikeln", "value": 5, "type": "number" }, { - "id": "6debc7f0-3db7-4f35-9f91-5126abefb520", + "id": "fa40fab3-f5d1-4b11-9d8c-6c9660e783e9", "name": "elementor_ausgabe", "value": false, "type": "boolean" }, { - "id": "3fd3df05-82f9-4b9f-9a2b-fbe70429b7d6", + "id": "a929c610-3968-4fb3-8522-798575e1e273", "name": "bild_aktiv", "value": true, "type": "boolean" }, { - "id": "378253b1-1ddf-4fbb-8d05-852cd3aed978", + "id": "31d925cf-a15c-4edb-9fcb-9f017f63e7f6", "name": "bild_modell", "value": "gpt-image-2", "type": "string" }, { - "id": "ef82071d-192f-4a81-a4f1-544a038a295f", + "id": "9ccb1885-18e6-4094-8b3f-ca603d9dfb16", "name": "bild_groesse", - "value": "1536x864", + "value": "1200x1200", "type": "string" }, { - "id": "cc33aca1-730e-45a2-9ec7-273755c8c47d", + "id": "f0730564-27da-4443-821c-606c3e12d174", "name": "bild_qualitaet", "value": "medium", "type": "string" }, { - "id": "116daf1c-c81a-4ef6-8c5d-0c0c193c224c", + "id": "92e2bdbf-47d8-4073-9a18-bdf8828c761b", "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string" }, { - "id": "0cbace80-605d-4dbc-8868-c63a6234fbf4", + "id": "037987f1-5636-4544-a3da-734e00269da8", "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string" @@ -158,7 +158,7 @@ -240, 190 ], - "id": "964a22b7-73b6-4318-8700-f0079d07a9d0", + "id": "e619d11b-46f8-41d3-abb5-9f170ca5ac69", "name": "Konfiguration" }, { @@ -183,7 +183,7 @@ -20, 190 ], - "id": "6a9ab914-96ca-4fbe-95c8-625796575b2a", + "id": "3f1a8c4e-4928-4e64-abe7-f8e67a322bd1", "name": "Artikelordner lesen", "credentials": { "httpHeaderAuth": { @@ -206,7 +206,7 @@ 200, 190 ], - "id": "b9f93c84-cfe5-41a1-a55d-341ef9adbd8e", + "id": "8611694a-ff31-4964-b185-0c8a8abd9b5d", "name": "Artikeldateien filtern" }, { @@ -220,7 +220,7 @@ }, "conditions": [ { - "id": "8d02e08e-0638-4437-92e0-6ac6abfffc56", + "id": "fb30d9cc-c4d4-4323-a1e1-b5cb144d6597", "leftValue": "={{ $json.leer === true }}", "rightValue": "", "operator": { @@ -241,7 +241,7 @@ 420, 190 ], - "id": "e0885743-c0cd-49d3-a821-164616cad729", + "id": "5e83eb61-eb78-437c-a57d-7358ccd84312", "name": "Artikel vorhanden?" }, { @@ -252,7 +252,7 @@ 640, 40 ], - "id": "42e5ca07-fe2d-4f3d-bc8a-e8c6641a8d54", + "id": "2d130159-cf3c-4932-aca1-bfc5e59ebe84", "name": "Nichts zu tun" }, { @@ -268,7 +268,7 @@ 640, 320 ], - "id": "969401ee-c28c-4489-b8d9-c024bd9c2b5e", + "id": "abdbec06-3537-4dc5-8dd6-019e7aa2b24e", "name": "Artikel nacheinander" }, { @@ -292,7 +292,7 @@ 900, 420 ], - "id": "566c2381-7296-4b46-b022-564a78fb6792", + "id": "906172c7-45ac-4348-aee7-197d23d4304d", "name": "Artikeldatei laden", "credentials": { "httpHeaderAuth": { @@ -316,7 +316,7 @@ 900, 640 ], - "id": "8a543e9c-c7b3-4ee4-a07c-37d13cf83f2f", + "id": "25977511-3ca4-4467-9014-ae013bbc839c", "name": "Fehler Artikeldatei", "onError": "continueRegularOutput" }, @@ -342,7 +342,7 @@ 1120, 420 ], - "id": "bb339e46-44b5-4213-9160-e8ceda5e7a89", + "id": "df42b00e-6e07-4ffd-83ff-65c061b160af", "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 // 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" + "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 < 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" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -362,7 +362,7 @@ 1340, 420 ], - "id": "c4db72b1-1e0c-4c63-8dd6-18ce5ed9288f", + "id": "2432e9d4-90fb-4953-8521-f192cf502473", "name": "Artikel pruefen" }, { @@ -392,7 +392,7 @@ 1560, 420 ], - "id": "22ffdabc-ac80-4570-bee2-337463b9e11f", + "id": "c775a775-a975-418f-81b2-c23d44ca310c", "name": "Score ermitteln", "credentials": { "httpHeaderAuth": { @@ -416,7 +416,7 @@ 1560, 640 ], - "id": "06e19a47-f72e-49af-9275-09383308e218", + "id": "03075c45-9d02-48f5-b176-94cdb509d866", "name": "Fehler Score", "onError": "continueRegularOutput" }, @@ -431,7 +431,7 @@ 1780, 420 ], - "id": "29350571-0c58-4cbb-946c-d413c0d3cac3", + "id": "3e90f007-8d26-494c-ade0-ee58db39ad39", "name": "Score auswerten" }, { @@ -445,7 +445,7 @@ }, "conditions": [ { - "id": "f05d0df3-ca20-4543-968b-5c2cb789774f", + "id": "009ffbe7-01c0-461c-b28d-b745c4d60afa", "leftValue": "={{ $json.bestanden }}", "rightValue": "", "operator": { @@ -466,7 +466,7 @@ 2000, 420 ], - "id": "8f793efc-bb8b-4994-a02a-9a4726fbcea8", + "id": "19ff7716-5710-41d6-bfd7-4fe08bd16cb7", "name": "Score erreicht?" }, { @@ -480,7 +480,7 @@ 2220, 640 ], - "id": "2ca96cd1-a927-4fb3-85ae-8012437609df", + "id": "702f39bc-8886-42e5-a8dd-affd032569dd", "name": "Fehlende Begriffe ermitteln" }, { @@ -505,7 +505,7 @@ 2440, 640 ], - "id": "4e8517b2-8f12-4bc0-b19b-386da85a6d3a", + "id": "174bc5e0-168e-40ce-9486-d91f4e5cf91b", "name": "Nachbesserung vorhanden?", "credentials": { "httpHeaderAuth": { @@ -525,7 +525,7 @@ 2660, 640 ], - "id": "3d8631ae-5f48-4a1b-a4c1-ebb27856dbb4", + "id": "eaab366d-7814-47c6-9aa9-04f4637918a1", "name": "Nachbesserung vorbereiten" }, { @@ -555,7 +555,7 @@ 2880, 640 ], - "id": "36244e4c-bd55-4777-b0c0-93ab9c17d14c", + "id": "7d2e670b-6bcd-4240-90b2-27ef59f54dcc", "name": "Nachbesserung committen", "credentials": { "httpHeaderAuth": { @@ -579,7 +579,7 @@ 2880, 860 ], - "id": "dc67d35e-4a83-428e-83be-7c4196161806", + "id": "c903692e-805d-4034-9555-651a6b0f2779", "name": "Fehler Nachbesserung", "onError": "continueRegularOutput" }, @@ -594,7 +594,7 @@ 3100, 640 ], - "id": "eaf7de10-8b7a-4020-a5e9-5b58af6b95e8", + "id": "971c9f49-930f-4876-913c-d5bfb060826e", "name": "Nachbesserung gemerkt" }, { @@ -624,7 +624,7 @@ 2220, 240 ], - "id": "ed8e731a-4142-46ac-830e-cddcd44a9987", + "id": "ad73443d-79ec-418f-b9cb-422d73d71b40", "name": "Revision zu NeuronWriter", "credentials": { "httpHeaderAuth": { @@ -648,7 +648,7 @@ 2220, 60 ], - "id": "9ed00d79-a091-406f-afa9-e6aca9d773ac", + "id": "90c1cd8b-44f7-40e9-80cc-407e6d4ccb9e", "name": "Fehler Revision", "onError": "continueRegularOutput" }, @@ -674,7 +674,7 @@ 2440, 240 ], - "id": "641f8e00-0809-41bb-a43a-2ab3077236cc", + "id": "9a6eaa50-09b1-4e68-a06f-74471a6e075c", "name": "Beitrag vorhanden?", "credentials": { "httpBasicAuth": { @@ -708,7 +708,7 @@ 2660, 240 ], - "id": "34ba9cb0-fab5-4344-bc9c-699239d01c99", + "id": "9d7d2fca-b517-43c5-a031-c123e52985da", "name": "Kategorie suchen", "credentials": { "httpBasicAuth": { @@ -731,7 +731,7 @@ 2880, 240 ], - "id": "e900cca9-39fe-4806-9588-91d1de3ba9e5", + "id": "f175da94-e137-4fac-b2e6-f38ef4d3e9b8", "name": "Kategorie pruefen" }, { @@ -745,7 +745,7 @@ }, "conditions": [ { - "id": "59dc46d5-6d0d-4796-bf86-95d715ea05d8", + "id": "c553d779-7e48-4b1b-8808-1c2ff31737f0", "leftValue": "={{ $json.kategorie_fehlt }}", "rightValue": "", "operator": { @@ -766,7 +766,7 @@ 3100, 240 ], - "id": "0c0a8bc7-cbac-4e70-a7f6-e94c38ffa837", + "id": "a9b527e8-a341-4a47-a6a3-261e54d3efac", "name": "Kategorie anlegen?" }, { @@ -796,7 +796,7 @@ 3320, 140 ], - "id": "b64baa11-054d-4830-a56c-20c931ce3655", + "id": "1f2a04e4-0625-4ce5-a6d9-49737ccb7c85", "name": "Kategorie anlegen", "credentials": { "httpBasicAuth": { @@ -819,13 +819,13 @@ 3540, 240 ], - "id": "cfb9aeda-8ebe-4895-a463-6f621d22020e", + "id": "7eb5e79b-817e-4e7a-bc4b-6d8dfc32fc61", "name": "Kategorie festlegen" }, { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "\nconst s = $input.first().json;\nconst dup = $('Beitrag vorhanden?').first().json;\nconst a = s.artikel;\n\n// Duplikatspruefung: exakter Slug-Treffer entscheidet ueber anlegen oder aktualisieren.\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 kd = K(dup);\nconst treffer = (dup.statusCode === 200 && Array.isArray(kd))\n ? kd.find(p => p.slug === s.slug) : null;\n\nconst heute = new Date().toISOString().slice(0, 10);\n\nconst meta = {\n kurzantwort: a.kurzantwort || '',\n cta_ziel: a.cta_ziel || '',\n stand_datum: a.stand_datum || heute,\n faq: Array.isArray(a.faq)\n ? a.faq.filter(f => f && f.frage).map(f => ({ frage: String(f.frage), antwort: String(f.antwort || '') }))\n : [],\n _seopress_titles_title: a.seo_titel || a.titel,\n _seopress_titles_desc: a.seo_beschreibung || '',\n _seopress_analysis_target_kw: a.keyword || '',\n};\n\n// --- Elementor-Struktur aus Artikel und FAQ ---\n// Zerlegt den Text an den H2-Grenzen in Text-Editor-Widgets und haengt das FAQ\n// als nested-accordion an, dasselbe Widget wie auf den Leistungsseiten.\nconst konf = $('Konfiguration').first().json;\nconst eid = () => Math.random().toString(16).slice(2, 9);\nconst textWidget = (h) => ({ id: eid(), elType: 'widget', settings: { editor: h },\n elements: [], widgetType: 'text-editor' });\nconst behaelter = (kinder, innen) => {\n const s = { content_width: 'full' };\n if (innen) Object.assign(s, {\n _title: 'Element',\n padding: { unit: 'px', top: '8', right: '8', bottom: '8', left: '8', isLinked: true },\n flex_gap: { column: '20', row: '20', isLinked: true, unit: 'px', size: 20 },\n });\n const c = { id: eid(), elType: 'container', settings: s, elements: kinder };\n if (innen) { c.isInner = true; c.isLocked = true; }\n return c;\n};\n\nfunction elementorBauen() {\n const wurzel = [];\n if (a.kurzantwort) {\n wurzel.push(behaelter([textWidget(\n `

${a.kurzantwort}

`)]));\n }\n const html = String(a.content_html || '');\n const stellen = [];\n const re2 = /]/g;\n let mm;\n while ((mm = re2.exec(html)) !== null) stellen.push(mm.index);\n const bloecke = [];\n if (!stellen.length) {\n if (html.trim()) bloecke.push(html.trim());\n } else {\n if (stellen[0] > 0 && html.slice(0, stellen[0]).trim()) bloecke.push(html.slice(0, stellen[0]).trim());\n stellen.forEach((s, i) => {\n const ende = i + 1 < stellen.length ? stellen[i + 1] : html.length;\n const b = html.slice(s, ende).trim();\n if (b) bloecke.push(b);\n });\n }\n bloecke.forEach(b => wurzel.push(behaelter([textWidget(b)])));\n\n if (meta.faq.length) {\n const items = [], kinder = [];\n meta.faq.forEach(f => {\n items.push({ item_title: f.frage, _id: eid() });\n kinder.push(behaelter([textWidget(`

${f.antwort || ''}

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

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

`),\n { id: eid(), elType: 'widget', widgetType: 'nested-accordion',\n settings: { items, faq_schema: 'yes', default_state: 'all_collapsed' },\n elements: kinder },\n ]));\n }\n return wurzel;\n}\n\nif (konf.elementor_ausgabe) {\n meta._elementor_data = JSON.stringify(elementorBauen());\n meta._elementor_edit_mode = 'builder';\n meta._elementor_template_type = 'wp-post';\n}\n\nconst body = {\n title: a.titel,\n slug: s.slug,\n content: a.content_html,\n status: 'draft', // harte Vorgabe: nie automatisch veroeffentlichen\n categories: [s.kategorie_id],\n meta,\n};\nif (a.auszug) body.excerpt = a.auszug;\n\nreturn [{ json: {\n ...s,\n vorhanden: !!treffer,\n post_id: treffer ? treffer.id : null,\n alter_status: treffer ? treffer.status : null,\n wp_url: treffer\n ? `${$('Konfiguration').first().json.wp_base}/wp-json/wp/v2/posts/${treffer.id}`\n : `${$('Konfiguration').first().json.wp_base}/wp-json/wp/v2/posts`,\n wp_body: JSON.stringify(body),\n}}];\n" + "jsCode": "\nconst s = $input.first().json;\nconst dup = $('Beitrag vorhanden?').first().json;\nconst a = s.artikel;\n\n// Duplikatspruefung: exakter Slug-Treffer entscheidet ueber anlegen oder aktualisieren.\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 kd = K(dup);\nconst treffer = (dup.statusCode === 200 && Array.isArray(kd))\n ? kd.find(p => p.slug === s.slug) : null;\n\nconst heute = new Date().toISOString().slice(0, 10);\n\nconst meta = {\n kurzantwort: a.kurzantwort || '',\n faq_ueberschrift: a.faq_ueberschrift || '',\n cta_ziel: a.cta_ziel || '',\n stand_datum: a.stand_datum || heute,\n faq: Array.isArray(a.faq)\n ? a.faq.filter(f => f && f.frage).map(f => ({ frage: String(f.frage), antwort: String(f.antwort || '') }))\n : [],\n _seopress_titles_title: a.seo_titel || a.titel,\n _seopress_titles_desc: a.seo_beschreibung || '',\n _seopress_analysis_target_kw: a.keyword || '',\n};\n\n// --- Elementor-Struktur aus Artikel und FAQ ---\n// Zerlegt den Text an den H2-Grenzen in Text-Editor-Widgets und haengt das FAQ\n// als nested-accordion an, dasselbe Widget wie auf den Leistungsseiten.\nconst konf = $('Konfiguration').first().json;\nconst eid = () => Math.random().toString(16).slice(2, 9);\nconst textWidget = (h) => ({ id: eid(), elType: 'widget', settings: { editor: h },\n elements: [], widgetType: 'text-editor' });\nconst behaelter = (kinder, innen) => {\n const s = { content_width: 'full' };\n if (innen) Object.assign(s, {\n _title: 'Element',\n padding: { unit: 'px', top: '8', right: '8', bottom: '8', left: '8', isLinked: true },\n flex_gap: { column: '20', row: '20', isLinked: true, unit: 'px', size: 20 },\n });\n const c = { id: eid(), elType: 'container', settings: s, elements: kinder };\n if (innen) { c.isInner = true; c.isLocked = true; }\n return c;\n};\n\nfunction elementorBauen() {\n const wurzel = [];\n if (a.kurzantwort) {\n wurzel.push(behaelter([textWidget(\n `

${a.kurzantwort}

`)]));\n }\n const html = String(a.content_html || '');\n const stellen = [];\n const re2 = /]/g;\n let mm;\n while ((mm = re2.exec(html)) !== null) stellen.push(mm.index);\n const bloecke = [];\n if (!stellen.length) {\n if (html.trim()) bloecke.push(html.trim());\n } else {\n if (stellen[0] > 0 && html.slice(0, stellen[0]).trim()) bloecke.push(html.slice(0, stellen[0]).trim());\n stellen.forEach((s, i) => {\n const ende = i + 1 < stellen.length ? stellen[i + 1] : html.length;\n const b = html.slice(s, ende).trim();\n if (b) bloecke.push(b);\n });\n }\n bloecke.forEach(b => wurzel.push(behaelter([textWidget(b)])));\n\n if (meta.faq.length) {\n const items = [], kinder = [];\n meta.faq.forEach(f => {\n items.push({ item_title: f.frage, _id: eid() });\n kinder.push(behaelter([textWidget(`

${f.antwort || ''}

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

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

`),\n { id: eid(), elType: 'widget', widgetType: 'nested-accordion',\n settings: { items, faq_schema: 'yes', default_state: 'all_collapsed' },\n elements: kinder },\n ]));\n }\n return wurzel;\n}\n\nif (konf.elementor_ausgabe) {\n meta._elementor_data = JSON.stringify(elementorBauen());\n meta._elementor_edit_mode = 'builder';\n meta._elementor_template_type = 'wp-post';\n}\n\nconst body = {\n title: a.titel,\n slug: s.slug,\n content: a.content_html,\n status: 'draft', // harte Vorgabe: nie automatisch veroeffentlichen\n categories: [s.kategorie_id],\n meta,\n};\nif (a.auszug) body.excerpt = a.auszug;\n\nreturn [{ json: {\n ...s,\n vorhanden: !!treffer,\n post_id: treffer ? treffer.id : null,\n alter_status: treffer ? treffer.status : null,\n wp_url: treffer\n ? `${$('Konfiguration').first().json.wp_base}/wp-json/wp/v2/posts/${treffer.id}`\n : `${$('Konfiguration').first().json.wp_base}/wp-json/wp/v2/posts`,\n wp_body: JSON.stringify(body),\n}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -833,7 +833,7 @@ 3760, 240 ], - "id": "fa7f4b40-e2fd-4e02-832c-2ff0da8527b3", + "id": "3e24c6b8-d0b0-4983-a06c-1183918148bb", "name": "WordPress-Body bauen" }, { @@ -863,7 +863,7 @@ 3980, 240 ], - "id": "c22b7697-ac02-45eb-aa2c-7fe87aeb46ff", + "id": "e05040a1-66fe-467f-87d7-f8985acb2191", "name": "Beitrag anlegen oder aktualisieren", "credentials": { "httpBasicAuth": { @@ -886,7 +886,7 @@ 4200, 240 ], - "id": "9d81b408-d848-480d-99d7-cf09bf728877", + "id": "e6ff477c-cd6e-4d7d-8737-5cc4f69f6fb8", "name": "Ergebnis pruefen" }, { @@ -917,7 +917,7 @@ 4420, 240 ], - "id": "f4ad279d-9ee5-4d0a-9e82-151450889d92", + "id": "293acc6c-2693-4cd7-a21a-3edc80a803f2", "name": "Cache leeren", "credentials": { "httpBasicAuth": { @@ -937,7 +937,7 @@ 4640, 240 ], - "id": "18f0e4ac-8ea3-450f-8d49-df200d7ddaaf", + "id": "4d8c093e-e322-4a87-a79d-77db2724bc1a", "name": "Importergebnis merken" }, { @@ -951,9 +951,9 @@ 4860, 420 ], - "id": "9613876f-73d4-4baa-99ad-31d8a2714e4c", + "id": "56000936-7d58-4ab7-892e-60520c4bc20e", "name": "Pause Rate Limit", - "webhookId": "d8b56a3f-dffc-4f1e-94a6-a3864185fa09" + "webhookId": "4782cf6a-9326-4b0f-9b35-4754fa88908f" }, { "parameters": { @@ -966,7 +966,7 @@ }, "conditions": [ { - "id": "433d059a-e005-4644-9f14-64cfe735e4b6", + "id": "1ae82a45-edd8-44f4-8946-47adc6800d41", "leftValue": "={{ $('Konfiguration').first().json.bild_aktiv === true && !Number($json.featured_media) }}", "rightValue": "", "operator": { @@ -987,7 +987,7 @@ 4860, 240 ], - "id": "61319cb6-8092-4daa-a837-fb7008994b87", + "id": "ff9b0ab2-1a97-44dc-a97d-d33688cb2501", "name": "Beitragsbild fehlt?" }, { @@ -1012,7 +1012,7 @@ 5080, 120 ], - "id": "6c2b9f0c-e425-4f79-a0c8-de5b56da7ebf", + "id": "d7a48424-743c-464d-8290-efdcb05d48d1", "name": "Bilddaten laden", "credentials": { "httpHeaderAuth": { @@ -1027,7 +1027,7 @@ { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "\n// Prompt nach ratgeber/bildstil.md, Farbe und Motiv aus artikel-index.json.\nconst r = $('Importergebnis merken').first().json;\nconst antwort = $input.first().json;\nconst K = (x) => (x && x.body !== undefined) ? x.body : ((x || {}).data);\nconst konf = $('Konfiguration').first().json;\n\nlet zeile = null;\nconst kb = K(antwort);\nif (antwort.statusCode === 200 && kb && kb.content) {\n try {\n const tab = JSON.parse(Buffer.from(kb.content, 'base64').toString('utf8'));\n zeile = (Array.isArray(tab) ? tab : []).find(z => z.slug === r.slug) || null;\n } catch (e) { zeile = null; }\n}\nif (!zeile || !zeile.bild_farbe || !zeile.bildmotiv) {\n return [{ json: { ...r, bild_prompt: null,\n bild_hinweis: 'Kein bild_farbe oder bildmotiv in artikel-index.json, Bild uebersprungen.' } }];\n}\n\nconst FARBNAME = { '#7B96C2': 'muted light ink blue', '#B6997F': 'muted sepia brown',\n '#8C9A82': 'muted sage green', '#C47B5E': 'muted terracotta red' };\nconst farbe = String(zeile.bild_farbe).toUpperCase();\nconst name = FARBNAME[farbe] ? ` (${FARBNAME[farbe]}, not more saturated)` : '';\nconst leer = /price tag/i.test(zeile.bildmotiv) ? ', the price tag is completely empty' : '';\n\nconst prompt = [\n 'Hand-drawn doodle spot illustration, flat editorial icon style.',\n `Solid flat background color ${farbe}${name}, completely uniform, no texture, no gradient, no shadow, no vignette.`,\n 'In the center one simple object, drawn with thick uneven black marker outlines (#1A1A1A), slightly wobbly hand-drawn lines like a felt-tip pen, rounded line ends.',\n 'Clean closed outlines, no stray marks, no hatching and no small dashes inside the shapes.',\n 'Object fills are flat off-white cream (#F9F7F5), no shading, no highlights.',\n `Motif: ${zeile.bildmotiv}${leer}. Shown straight from the front, not tilted.`,\n 'Minimal and iconic, lots of empty space, the object takes about 35 percent of the image height.',\n 'No text, no letters, no numbers, no currency symbols, no logo, no border, no frame, no rounded corners, no people.',\n].join(' ');\n\nreturn [{ json: { ...r, bild_prompt: prompt, bild_titel: zeile.titel || r.slug,\n openai_body: JSON.stringify({ model: konf.bild_modell, prompt, size: konf.bild_groesse,\n n: 1, output_format: 'webp', quality: konf.bild_qualitaet }) } }];\n" + "jsCode": "\n// Prompt nach ratgeber/bildstil.md, Farbe und Motiv aus artikel-index.json.\nconst r = $('Importergebnis merken').first().json;\nconst antwort = $input.first().json;\nconst K = (x) => (x && x.body !== undefined) ? x.body : ((x || {}).data);\nconst konf = $('Konfiguration').first().json;\n\nlet zeile = null;\nconst kb = K(antwort);\nif (antwort.statusCode === 200 && kb && kb.content) {\n try {\n const tab = JSON.parse(Buffer.from(kb.content, 'base64').toString('utf8'));\n zeile = (Array.isArray(tab) ? tab : []).find(z => z.slug === r.slug) || null;\n } catch (e) { zeile = null; }\n}\nif (!zeile || !zeile.bild_farbe || !zeile.bildmotiv) {\n return [{ json: { ...r, bild_prompt: null,\n bild_hinweis: 'Kein bild_farbe oder bildmotiv in artikel-index.json, Bild uebersprungen.' } }];\n}\n\nconst FARBNAME = { '#7B96C2': 'muted light ink blue', '#B6997F': 'muted sepia brown',\n '#8C9A82': 'muted sage green', '#C47B5E': 'muted terracotta red' };\nconst farbe = String(zeile.bild_farbe).toUpperCase();\nconst name = FARBNAME[farbe] ? ` (${FARBNAME[farbe]}, not more saturated)` : '';\nconst leer = /price tag/i.test(zeile.bildmotiv) ? ', the price tag is completely empty' : '';\n\nconst prompt = [\n 'Hand-drawn doodle spot illustration, flat editorial icon style.',\n `Solid flat background color ${farbe}${name}, completely uniform, no texture, no gradient, no shadow, no vignette.`,\n 'In the center one simple object, drawn with thick uneven black marker outlines (#1A1A1A), slightly wobbly hand-drawn lines like a felt-tip pen, rounded line ends.',\n 'Clean closed outlines, no stray marks, no hatching and no small dashes inside the shapes.',\n 'Object fills are flat off-white cream (#F9F7F5), no shading, no highlights.',\n `Motif: ${zeile.bildmotiv}${leer}. Shown straight from the front, not tilted.`,\n 'The whole motif sits in the exact center and stays inside the middle band of the image: it takes about 28 percent of the image height and at most 60 percent of the image width, with wide empty background above and below.',\n 'No text, no letters, no numbers, no currency symbols, no logo, no border, no frame, no rounded corners, no people.',\n].join(' ');\n\nreturn [{ json: { ...r, bild_prompt: prompt, bild_titel: zeile.titel || r.slug,\n openai_body: JSON.stringify({ model: konf.bild_modell, prompt, size: konf.bild_groesse,\n n: 1, output_format: 'webp', quality: konf.bild_qualitaet }) } }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -1035,7 +1035,7 @@ 5300, 120 ], - "id": "f602842b-c4ed-4d15-8e85-72ce9d329fa3", + "id": "dd13bf7d-798d-43a4-af99-fd0ee8bffcab", "name": "Bildprompt bauen" }, { @@ -1049,7 +1049,7 @@ }, "conditions": [ { - "id": "a9afefa8-8157-43f5-91b1-f7446c5364bd", + "id": "98fcfee0-224f-422a-b567-ec68f40b465f", "leftValue": "={{ !!$json.bild_prompt }}", "rightValue": "", "operator": { @@ -1070,7 +1070,7 @@ 5520, 120 ], - "id": "14894cfa-8e68-4edb-9d17-f2671780aa74", + "id": "620a3df5-f36b-4192-b215-4c73b5e123d9", "name": "Bildprompt vorhanden?" }, { @@ -1100,7 +1100,7 @@ 5740, 40 ], - "id": "31ab60fc-6031-439a-8402-74dd6d0d9437", + "id": "6c1873c5-117c-4fb9-9109-e9b2cee026d2", "name": "Bild erzeugen", "credentials": { "httpHeaderAuth": { @@ -1124,7 +1124,7 @@ 5740, 260 ], - "id": "6efa2c23-95e2-4a94-873b-b509c10ab395", + "id": "1f440c70-8596-4c40-963b-f8643698bbd4", "name": "Fehler Bild", "onError": "continueRegularOutput" }, @@ -1139,7 +1139,7 @@ 5960, 40 ], - "id": "491b23b7-4679-429d-9745-47292b0154ca", + "id": "d883b5e0-6185-42ff-a6e2-921bfb467ed5", "name": "Bild als Datei" }, { @@ -1174,7 +1174,7 @@ 6180, 40 ], - "id": "b5e1265a-6b87-4c3f-840c-6cca67628550", + "id": "f8891b55-37e7-459b-969a-6f9f33c51c33", "name": "Bild in Mediathek", "credentials": { "httpBasicAuth": { @@ -1208,7 +1208,7 @@ 6400, 40 ], - "id": "21f00fcc-1449-4cb7-96b2-6cf35a4d8056", + "id": "3d497e77-7911-4ed0-9f22-f20504712ef9", "name": "Beitragsbild setzen", "credentials": { "httpBasicAuth": { @@ -1231,7 +1231,7 @@ 6620, 40 ], - "id": "78f4059e-6003-43b0-80dc-fad15546eff3", + "id": "453a90a0-5243-4d1a-9224-3e3fc3541599", "name": "Bild gemerkt" }, { @@ -1245,7 +1245,7 @@ 900, 80 ], - "id": "7241567b-ebff-4ddf-a4e3-f40ef3787b07", + "id": "3e4abd4e-4a6f-4e54-9924-c2cfa154cf82", "name": "Ergebnisse sammeln" }, { @@ -1270,7 +1270,7 @@ 1120, 80 ], - "id": "1b6c1a04-caf9-4bb8-ace9-b050fe4710bb", + "id": "ca0cb16c-0af2-4b3c-aaa2-5725972703be", "name": "Statustabelle lesen", "credentials": { "httpHeaderAuth": { @@ -1290,7 +1290,7 @@ 1340, 80 ], - "id": "5aecb5c3-e0d3-45df-b33b-37c1d13c8c08", + "id": "78f81aa7-0ca1-4f29-815c-a5328ea514b9", "name": "Statustabelle fortschreiben" }, { @@ -1304,7 +1304,7 @@ }, "conditions": [ { - "id": "82bf79b1-edca-437a-88cd-913d7f3fd9c4", + "id": "d2c17929-ba0a-4080-ae1a-e9a6aad3e118", "leftValue": "={{ $json.ueberspringen }}", "rightValue": "", "operator": { @@ -1325,7 +1325,7 @@ 1560, 80 ], - "id": "049968c9-10ac-4616-b768-832074799f58", + "id": "7ce09846-988b-490b-a1d3-1b986eb17713", "name": "Gab es Importe?" }, { @@ -1355,7 +1355,7 @@ 1780, 160 ], - "id": "e1193d3f-fed0-4f31-a90c-c333a5a2bdd5", + "id": "0a900f70-93be-4997-b04a-c74061ea39e9", "name": "Statustabelle schreiben", "credentials": { "httpHeaderAuth": { @@ -1379,7 +1379,7 @@ 1780, 340 ], - "id": "6df2408f-6d6c-4a04-8641-3a4a738268c2", + "id": "96e36221-b847-4b03-95cb-e1a103a27df7", "name": "Fehler Statustabelle", "onError": "continueRegularOutput" }, @@ -1394,7 +1394,7 @@ 2000, 80 ], - "id": "3384fccd-d896-4c44-9c89-f91946e5d986", + "id": "2899cba3-36f3-4fb1-b1cf-c99fe19b9020", "name": "Meldung formulieren" }, { @@ -1412,7 +1412,7 @@ 2220, 80 ], - "id": "d2da0c0a-1998-405c-b585-7c8d7bbc834c", + "id": "12bdb6c6-d9d0-4271-ace8-e3e07c863819", "name": "Benachrichtigung", "disabled": true, "notes": "SMTP-Credential zuweisen, Absender eintragen, dann aktivieren."