// 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(/
([\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');
}
// 8) Interne Links: primaer auf Business-Leistungsseiten
const B = 'https://skrift.de/';
const BUSINESS = ['handgeschriebene-businessbriefe', 'handgeschriebene-postkarten',
'unterschriftenservice', 'automatisierte-follow-ups'];
const NEUTRAL = ['papier-schriftmuster', 'kontakt'];
const PRIVAT = ['handschrift-fuer-privatpersonen', 'handgeschriebene-private-briefe',
'handgeschriebene-private-postkarten'];
const slugVon = (u) => String(u || '').replace(/^https?:\/\/(www\.)?skrift\.de\/?/, '').replace(/\/.*$/, '');
const links = [...html.matchAll(/]*href="([^"]+)"/g)].map(m => m[1]);
const intern = links.filter(u => /^https?:\/\/(www\.)?skrift\.de/.test(u) || u.startsWith('/'));
const slugs = intern.map(u => u.startsWith('/') ? u.replace(/^\/|\/.*$/g, '') : slugVon(u));
const cta = slugVon(artikel.cta_ziel);
if (!BUSINESS.includes(cta)) add('Links: CTA-Ziel ist keine Business-Leistungsseite', `cta_ziel "${artikel.cta_ziel || 'leer'}", erlaubt: ${BUSINESS.join(', ')}`);
const ctaAnzahl = slugs.filter(s => s === cta).length;
if (ctaAnzahl < 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) 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 (/