Stilpruefung, Zielwert 65, Praxisfeld, Referenzartikel bereinigt

This commit is contained in:
2026-09-21 20:33:51 +02:00
parent a919d5336a
commit 87cbdcedc9
6 changed files with 294 additions and 130 deletions

View File

@@ -275,7 +275,7 @@ sie beim Schreiben direkt vorliegen.
### Zielwert anpassen
Im Node **Konfiguration**, Feld `score_ziel`. Standard ist 72.
Im Node **Konfiguration**, Feld `score_ziel`. Standard ist 65.
Der Wert gilt fuer alle Artikel eines Laufs.
Fuer einen einzelnen Lauf laesst er sich ueber den Webhook uebersteuern,
@@ -286,10 +286,52 @@ curl -X POST https://n8n.lucas-orth.de/webhook/pruefen-und-importieren \
-H "Content-Type: application/json" -d '{"score_ziel":60}'
```
Ohne Angabe bleibt es bei 72.
Ohne Angabe bleibt es bei 65.
Alle anderen Stellschrauben liegen im selben Node: Basis-URLs, Ordnernamen,
Pause zwischen Artikeln, Empfaengeradresse.
### Warum 65 und nicht hoeher
Ab etwa 70 verlangt NeuronWriter Wettbewerbernamen, ausgeschriebene Preise und
exakte Wortfolgen, die sich nur mit schiefen Saetzen einbauen lassen. Der
Referenzartikel kam mit solchen Saetzen auf 72 und ohne sie auf 71. Verbindlich
sind deshalb nur die Pflichtbegriffe, Zusatzbegriffe nur, wenn der Satz dadurch
nicht schlechter wird.
Score, Luecken und Stilpruefung rechnen gegen Kurzantwort, Artikel und FAQ
zusammen, weil alle drei auf der fertigen Seite stehen.
### Stilpruefung
Workflow 2 prueft jeden Artikel auf Muster, die maschinell wirken, und meldet
die Fundstellen in der Benachrichtigung und im Nachbesserungsbericht. Der Import
wird dadurch nicht blockiert.
Geprueft werden Formeln wie "Genau das", "Der Grund ist", "nicht nur, sondern
auch", "entscheidend ist", "der groesste Hebel", "macht den Unterschied",
Meta-Saetze wie "Wir zeigen Ihnen", unbelegte Behauptungen wie "erfahrungsgemaess"
oder "in aller Regel", Aufzaehlungen mit erstens und zweitens, Dreierreihen aus
Adjektiven, rhetorische Fragen mit Sofortantwort, gleichfoermige Satzlaengen,
gleiche Satz- und Absatzanfaenge, gleich lange Absaetze und fehlendes
Praxiswissen.
Die Regeln liegen in `build/stilpruefung.js` und lassen sich dort erweitern.
Lokal testen:
```
node -e "const {stilpruefung}=require('./build/stilpruefung.js');console.log(stilpruefung(require('./artikel/artikel-SLUG.json')))"
```
Danach `python3 build/gen_wf2.py` und den Workflow in n8n aktualisieren.
### Praxiswissen
Jede Zeile in `artikel-index.json` hat ein Feld `praxis`, eine Liste von drei
bis fuenf Stichpunkten aus der eigenen Arbeit: Beispiele, typische Fehler,
Zahlen aus der Produktion, klare Empfehlungen, auch Faelle, in denen Handschrift
sich nicht lohnt. Workflow 1 legt sie in den Brief. Ist die Liste leer, wird der
Artikel nicht geschrieben, sondern zuerst das Praxiswissen eingeholt.
### Nachbesserungsbericht
`/evaluate-content` liefert ausschliesslich `{status, content_score}`,

View File

@@ -276,7 +276,8 @@ const brief = {
wortzahl_ziel: ((n.metrics || {}).word_count || {}).target ?? null,
// --- Artikelkontext aus der Tabelle ---
artikel: { slug: a.slug, titel: a.titel, kategorie: a.kategorie, cta_ziel: a.cta_ziel },
artikel: { slug: a.slug, titel: a.titel, kategorie: a.kategorie, cta_ziel: a.cta_ziel,
praxis: Array.isArray(a.praxis) ? a.praxis : [] },
// --- Zusatz fuer die Schreibvorlage ---
zusatz: {
@@ -308,6 +309,18 @@ const brief = {
faq: 'FAQ nicht in content_html schreiben, sondern als Feld faq mit Frage-Antwort-Paaren. '
+ 'Die Ausgabe im Beitrag uebernimmt der Shortcode [skrift_faq].',
kurzantwort: 'Ein Absatz, der die Hauptfrage direkt beantwortet, als eigenes Feld kurzantwort.',
praxis: 'Der Artikel baut auf den Stichpunkten in artikel.praxis auf. Jeder Stichpunkt kommt '
+ 'erkennbar im Text vor, als Beispiel, Erfahrung oder klare Empfehlung. Ist die Liste leer, '
+ 'wird der Artikel nicht geschrieben, sondern zuerst Praxiswissen bei Lucas erfragt.',
begriffe: 'begriffe_pflicht sind verbindlich. begriffe_erweitert nur verwenden, wenn der Satz '
+ 'dadurch nicht schlechter wird. Keine Saetze, die nur fuer den NeuronWriter-Score da sind. '
+ 'Keine Wettbewerbernamen. Zielwert im Import ist 65.',
stil: 'Kurze und lange Saetze mischen. Absaetze unterschiedlich lang. Keine Formeln wie '
+ '"Genau das", "Der Grund ist", "nicht nur, sondern auch", "entscheidend ist", '
+ '"der groesste Hebel", "macht den Unterschied". Keine Aufzaehlung mit erstens, zweitens. '
+ 'Keine rhetorische Frage mit Sofortantwort. Keine Behauptung ohne Beleg, also kein '
+ '"erfahrungsgemaess" oder "in aller Regel". Wo es passt, auch abraten: wann sich '
+ 'Handschrift nicht lohnt.',
},
meta: {

View File

@@ -4,6 +4,8 @@ import json, os, uuid
CRED_NW = {"httpHeaderAuth": {"id": "U23n2O980GKWocWq", "name": "NeuronWriter API"}}
CRED_GITEA = {"httpHeaderAuth": {"id": "VOD4JM5zwpSC8wAr", "name": "Gitea Skrift Auto Article"}}
_js = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "stilpruefung.js"), encoding="utf-8").read()
STIL_JS = _js.split("if (typeof module")[0] + "\n"
CRED_WP = {"httpBasicAuth": {"id": "WIN05TZgM0K5VOGy", "name": "WP skrift.de Redakteur"}}
nodes, conns = [], {}
@@ -66,7 +68,7 @@ KONF = node("Konfiguration", "n8n-nodes-base.set", 3.4, [-240, 190], {
{"id": nid(), "name": "nachbessern_ordner","value": "nachbessern", "type": "string"},
{"id": nid(), "name": "status_pfad", "value": "status-importe.json", "type": "string"},
{"id": nid(), "name": "wp_base", "value": "https://skrift.de", "type": "string"},
{"id": nid(), "name": "score_ziel", "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 72 }}", "type": "number"},
{"id": nid(), "name": "score_ziel", "value": "={{ $json.body && $json.body.score_ziel ? Number($json.body.score_ziel) : 65 }}", "type": "number"},
{"id": nid(), "name": "pause_zwischen_artikeln", "value": 5, "type": "number"},
{"id": nid(), "name": "elementor_ausgabe", "value": False, "type": "boolean"},
{"id": nid(), "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string"},
@@ -135,7 +137,7 @@ BRIEF = http("Brief laden", [1120, 420], "GET",
extra={"options": {"response": {"response": {"neverError": True, "fullResponse": True}}, "timeout": 60000}})
link(ART, BRIEF, out=0)
PRUEFEN = code("Artikel pruefen", [1340, 420], r"""
PRUEFEN = code("Artikel pruefen", [1340, 420], STIL_JS + r"""
const meta = $('Artikel nacheinander').first().json;
const artRoh = $('Artikeldatei laden').first().json;
const briefAntwort = $input.first().json;
@@ -183,8 +185,12 @@ const bewertungs_html = [
faqText ? `<h2>Häufige Fragen</h2>\n${faqText}` : '',
].filter(Boolean).join('\n');
const praxis = Array.isArray(artikel.praxis) && artikel.praxis.length ? artikel.praxis
: ((brief && brief.artikel && brief.artikel.praxis) || []);
return [{ json: { slug: meta.slug, artikel, brief, brief_vorhanden: !!brief,
bewertungs_html,
stil_hinweise: stilpruefung({ ...artikel, praxis }),
preis_warnung: preisTreffer.length
? `${preisTreffer.length} ausgeschriebene(r) Preis(e) im Text: ${[...new Set(preisTreffer)].slice(0,5).join(', ')}. `
+ 'Bitte durch [skrift_preis key="..."] ersetzen.'
@@ -284,9 +290,11 @@ const bericht = {
fehlende_pflichtbegriffe: pflicht,
fehlende_zusatzbegriffe: erweitert,
unbeantwortete_fragen: fragen_offen,
stil_hinweise: s.stil_hinweise || [],
hinweis: s.brief_vorhanden
? 'Begriffe gegen briefe/brief-' + s.slug + '.json abgeglichen. Zaehlung als Teilstring, Komposita zaehlen mit.'
: 'Kein Brief gefunden, daher nur der Score. Workflow 1 fuer diesen Slug erneut laufen lassen.',
regel: 'Pflichtbegriffe sind verbindlich. Zusatzbegriffe nur einbauen, wenn der Satz dadurch nicht schlechter wird. Keine Saetze nur fuer den Score.',
};
const konf = $('Konfiguration').first().json;
@@ -336,6 +344,7 @@ const n = $('Fehlende Begriffe ermitteln').first().json;
const p = $('Artikel pruefen').first().json;
return [{ json: { slug: n.slug, ergebnis: 'nachbessern', score: n.score,
preis_warnung: p.preis_warnung || null,
stil_hinweise: p.stil_hinweise || [],
score_ziel: n.bericht.score_ziel, pfad: n.pfad,
anzahl_pflicht: n.anzahl_pflicht, anzahl_erweitert: n.anzahl_erweitert,
wortzahl: n.bericht.wortzahl,
@@ -568,6 +577,7 @@ return [{ json: {
: 'klassischer Inhalt',
seo_titel: (p.meta || {})._seopress_titles_title || null,
preis_warnung: v.preis_warnung || null,
stil_hinweise: v.stil_hinweise || [],
preis_shortcodes: v.preis_shortcodes || 0,
}}];
""")
@@ -686,6 +696,11 @@ if (s.importiert.length) {
if (r.elementor) z.push(` Layout: ${r.elementor}`);
if (r.meta_warnung) z.push(` ACHTUNG: ${r.meta_warnung}`);
if (r.preis_warnung) z.push(` PREISE: ${r.preis_warnung}`);
if (r.stil_hinweise && r.stil_hinweise.length) {
z.push(` STIL: ${r.stil_hinweise.length} Hinweis(e)`);
r.stil_hinweise.slice(0, 6).forEach(h => z.push(` - ${h.art}: ${h.stelle}`));
if (r.stil_hinweise.length > 6) z.push(` ... weitere im Nachbesserungsbericht bzw. in der Execution`);
}
if (r.cache_hinweis) z.push(` Hinweis: ${r.cache_hinweis}`);
});
z.push('');
@@ -700,6 +715,11 @@ if (s.nachbessern.length) {
}
z.push(` Datei: ${r.pfad}`);
if (r.preis_warnung) z.push(` PREISE: ${r.preis_warnung}`);
if (r.stil_hinweise && r.stil_hinweise.length) {
z.push(` STIL: ${r.stil_hinweise.length} Hinweis(e)`);
r.stil_hinweise.slice(0, 6).forEach(h => z.push(` - ${h.art}: ${h.stelle}`));
if (r.stil_hinweise.length > 6) z.push(` ... weitere im Nachbesserungsbericht bzw. in der Execution`);
}
});
z.push('');
}

89
build/stilpruefung.js Normal file
View File

@@ -0,0 +1,89 @@
// Stilpruefung: findet typische Muster maschinell wirkender Texte im Deutschen.
// Wird von gen_wf2.py in den Code-Node "Artikel pruefen" eingebettet und ist
// lokal mit node testbar. Liefert Hinweise, blockiert nichts.
function stilpruefung(artikel) {
const hinweise = [];
const add = (art, stelle) => hinweise.push({ art, stelle: String(stelle).slice(0, 140) });
const ohneTags = (h) => String(h || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
const html = String(artikel.content_html || '');
const absaetze = [...html.matchAll(/<p>([\s\S]*?)<\/p>/g)].map(m => ohneTags(m[1])).filter(Boolean);
const volltext = [ohneTags(artikel.kurzantwort), ohneTags(html),
...(Array.isArray(artikel.faq) ? artikel.faq.map(f => ohneTags(f.antwort)) : [])].join(' ');
// 1) Floskeln und Formeln, die in generierten Texten gehaeuft auftreten
const formeln = [
['genau das', 'Formel'], ['genau diese', 'Formel'], ['genau daran', 'Formel'], ['genau so', 'Formel'],
['der grund ist', 'Formel'], ['nicht nur', 'Formel "nicht nur, sondern auch"'],
['entscheidend ist', 'Formel'], ['entscheidend für', 'Formel'], ['größten hebel', 'Formel'],
['den unterschied', 'Formel'], ['am ende des tages', 'Floskel'], ['unterm strich', 'Floskel'],
['kurz gesagt', 'Floskel'], ['mit anderen worten', 'Floskel'], ['die gute nachricht', 'Floskel'],
['spielt eine rolle', 'Floskel'], ['es lohnt sich', 'Floskel'], ['egal ob', 'Formel'],
['in diesem artikel', 'Meta-Satz'], ['wir zeigen ihnen', 'Meta-Satz'], ['fazit', 'Zusammenfassung'],
['tatsächlich', 'Fuellwort'], ['letztlich', 'Fuellwort'], ['schlicht', 'Fuellwort'], ['simpel', 'Fuellwort'],
['erfahrungsgemäß', 'unbelegte Behauptung'], ['in aller regel', 'unbelegte Behauptung'],
['studien zeigen', 'unbelegte Behauptung'], ['nachweislich', 'unbelegte Behauptung'],
['bekanntlich', 'Floskel'],
];
const klein = volltext.toLowerCase();
formeln.forEach(([f, art]) => {
let i = klein.indexOf(f);
while (i >= 0) {
add(art + ': "' + f + '"', '…' + volltext.slice(Math.max(0, i - 40), i + f.length + 50) + '…');
i = klein.indexOf(f, i + f.length);
}
});
// 2) Aufzaehlungen im Fliesstext
if (/\berstens\b[\s\S]*\bzweitens\b/i.test(volltext)) add('Aufzaehlung "Erstens, zweitens"', 'Im Fliesstext lieber eine echte Liste oder zwei Saetze');
const dreier = volltext.match(/\b\w+(?:ig|lich|ell|bar|iv|sam)\b, \w+(?:ig|lich|ell|bar|iv|sam)\b und \w+(?:ig|lich|ell|bar|iv|sam)\b/gi) || [];
dreier.forEach(d => add('Dreierreihe aus Adjektiven', d));
// 3) Rhetorische Frage im Absatz, direkt beantwortet
absaetze.forEach(a => {
const m = a.match(/[^.!?]{8,}\?\s+[A-ZÄÖÜ][^.!?]{3,}[.!]/);
if (m) add('Rhetorische Frage mit Sofortantwort', m[0]);
});
// 4) Satzlaengen: gleichfoermiger Rhythmus
const saetze = ohneTags(html).split(/(?<=[.!?])\s+/).map(s => s.trim())
.filter(s => s.split(' ').length >= 3);
const laengen = saetze.map(s => s.split(' ').length);
if (laengen.length >= 8) {
const mittel = laengen.reduce((x, y) => x + y, 0) / laengen.length;
const sd = Math.sqrt(laengen.reduce((x, y) => x + (y - mittel) ** 2, 0) / laengen.length);
if (sd < 4.5) add('Gleichfoermige Satzlaengen', `Durchschnitt ${mittel.toFixed(1)} Woerter, Streuung nur ${sd.toFixed(1)}. Kurze und lange Saetze mischen.`);
for (let i = 0; i + 4 <= laengen.length; i++) {
const fenster = laengen.slice(i, i + 4);
if (Math.max(...fenster) - Math.min(...fenster) <= 3) {
add('Vier Saetze gleicher Laenge hintereinander', saetze[i]);
i += 3;
}
}
}
// 5) Gleiche Satzanfaenge
for (let i = 0; i + 3 <= saetze.length; i++) {
const w = saetze.slice(i, i + 3).map(s => s.split(' ')[0].toLowerCase());
if (w[0] === w[1] && w[1] === w[2] && w[0].length > 2) { add('Drei Saetze mit gleichem Anfang', saetze[i]); i += 2; }
}
const anfaenge = {};
absaetze.forEach(a => { const k = a.split(' ').slice(0, 2).join(' ').toLowerCase(); anfaenge[k] = (anfaenge[k] || 0) + 1; });
Object.entries(anfaenge).filter(([, n]) => n >= 3).forEach(([k, n]) => add('Absatzanfaenge wiederholen sich', `${n} Absaetze beginnen mit "${k}"`));
// 6) Gleich lange Absaetze
const al = absaetze.map(a => a.split(' ').length).filter(n => n >= 12);
if (al.length >= 5) {
const m = al.reduce((x, y) => x + y, 0) / al.length;
const sd = Math.sqrt(al.reduce((x, y) => x + (y - m) ** 2, 0) / al.length);
if (sd / m < 0.25) add('Absaetze fast gleich lang', `Durchschnitt ${m.toFixed(0)} Woerter, kaum Abweichung. Einen Absatz bewusst kurz halten.`);
}
// 7) Praxiswissen: ohne eigene Stichpunkte bleibt der Text generisch
if (!Array.isArray(artikel.praxis) || artikel.praxis.length === 0) {
add('Kein Praxiswissen hinterlegt', 'Feld "praxis" in artikel-index.json fuellen, dann den Artikel darauf aufbauen');
}
return hinweise;
}
if (typeof module !== 'undefined') module.exports = { stilpruefung };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long