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