133 lines
8.5 KiB
JavaScript
133 lines
8.5 KiB
JavaScript
// 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');
|
|
}
|
|
|
|
// 7b) Preise: immer inkl. MwSt., Formulierung "inkl. 19 % MwSt."
|
|
const alleTexte = [String(artikel.kurzantwort || ''), html,
|
|
...(Array.isArray(artikel.faq) ? artikel.faq.map(f => String(f.antwort || '')) : [])].join(' ');
|
|
(alleTexte.match(/\[skrift_preis[^\]]*\]/g) || []).forEach(s => {
|
|
if (!/brutto="1"/.test(s)) add('Preis ohne MwSt.', `${s} braucht brutto="1"`);
|
|
});
|
|
const nb = ohneTags(alleTexte.replace(/\[skrift_preis[^\]]*\]/g, 'PREIS')).match(/[^.]{0,40}\b(netto|brutto|zzgl\.?|zuzüglich)\b[^.]{0,30}/gi) || [];
|
|
nb.forEach(s => add('Preisangabe: "netto"/"brutto" statt "inkl. 19 % MwSt."', s.trim()));
|
|
|
|
// 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(/<a\s[^>]*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 (/<h[23][^>]*>[^<]*(Muster anfordern|Handschriftmuster)/i.test(html)) add('Baustein doppelt: Muster-CTA', 'Abschnitt "Kostenloses Handschriftmuster anfordern." entfernen, er steht im Seitenlayout');
|
|
// Querverweise auf andere Ratgeberartikel
|
|
const quer = [...html.matchAll(/\[ratgeber_link\s+slug="([^"]+)"\]([\s\S]*?)\[\/ratgeber_link\]/g)];
|
|
if (quer.length < 2) add('Querverweise: zu wenige', `${quer.length} [ratgeber_link], mindestens 2 auf verwandte Ratgeberartikel`);
|
|
quer.filter(q => q[1] === artikel.slug).forEach(() => add('Querverweis auf sich selbst', artikel.slug));
|
|
quer.filter(q => !q[2].trim()).forEach(q => add('Querverweis ohne Ankertext', q[1]));
|
|
(html.match(/href="https?:\/\/(www\.)?skrift\.de\/(?!handgeschriebene-|unterschriftenservice|automatisierte-follow-ups|papier-schriftmuster|kontakt|handschrift-fuer)[a-z0-9-]+\/?"/g) || [])
|
|
.forEach(h => add('Ratgeber-Link als harter Link', `${h} als [ratgeber_link slug="..."] setzen, sonst droht ein toter Link`));
|
|
|
|
const unbekannt = [...new Set(slugs.filter(s => s && !BUSINESS.includes(s) && !NEUTRAL.includes(s) && !PRIVAT.includes(s)))];
|
|
unbekannt.forEach(s => add('Links: kein Linkziel fuer Ratgeber', `/${s}/ (z. B. Dankeseite, Rechtstexte)`));
|
|
|
|
return hinweise;
|
|
}
|
|
if (typeof module !== 'undefined') module.exports = { stilpruefung };
|