"""Textnormalisierung fuer Phase 2. Gemeinsame Vorstufe der Module. Jede Aenderung hier aendert die coder_version - siehe version.py. Grundsatz: die Normalisierung ist rein und ohne Zustand. Gleiche Eingabe, gleiche Ausgabe, auf jeder Maschine und in jedem Prozess. """ import html import re import unicodedata # Was hier steht, geht in den Versions-Hash ein. REGELWERK_VERSION = "norm-3" _TAGS = re.compile(r"<[^>]{1,200}>") _MEHRFACH_LEER = re.compile(r"\s+") # Anfuehrungszeichen und Striche vereinheitlichen. Verschiedene Haeuser setzen # fuer dieselbe Agenturmeldung verschiedene Zeichen; ohne das scheitert schon # die Trigrammbildung. _ZEICHEN = str.maketrans({ "„": '"', "“": '"', "”": '"', "»": '"', "«": '"', "‟": '"', "‚": "'", "‘": "'", "’": "'", "‹": "'", "›": "'", "´": "'", "`": "'", "–": "-", "—": "-", "‑": "-", "‒": "-", "−": "-", "…": "...", " ": " ", " ": " ", " ": " ", "": " ", }) # "+++ Newsblog +++" und Verwandtes: redaktioneller Aufmacher, der nichts # ueber den Inhalt sagt. Dieselbe Meldung traegt ihn bei einem Haus und beim # naechsten nicht. _PLUSKASTEN = re.compile(r"\+{2,}[^+]{0,120}?\+{2,}") # Satzzeichen fallen weg: sie sind der haeufigste Unterschied zwischen zwei # Fassungen derselben Meldung und tragen nichts zur Unterscheidung bei. _KEIN_WORT = re.compile(r"[^\w\s]", re.UNICODE) # Ressortkuerzel, das Haeuser ihren Titeln voranstellen: "Raumfahrt: Deutsche # Rakete ...", "Notfaelle: Fuenf Tote ...", "Fussball: FIFA-Praesident ...". # Dieselbe Agenturzeile bekommt bei jedem Haus ein anderes - oder gar keins. # # Bewusst eng gefasst: hoechstens vier Woerter vor dem Doppelpunkt, und was # danach kommt, muss fuer sich stehen koennen. Sonst frisst das Muster # Titel, bei denen der Teil vor dem Doppelpunkt die eigentliche Aussage # traegt ("Habeck: Wir haben uns geirrt"). _RESSORT = re.compile(r"^\s*(?P[^:]{2,34}):\s+(?P.+)$", re.DOTALL) _RESSORT_MAX_WOERTER = 4 _REST_MIN_WOERTER = 5 def ressort_teilen(text): """(kopf, rest) - kopf ist None, wenn kein Ressortkuerzel zu erkennen ist. Das Modul revisionen braucht beide Teile getrennt: ob ein Haus die Ressortmarke oder die Schlagzeile selbst geaendert hat, sind zwei sehr verschiedene Vorgaenge. """ treffer = _RESSORT.match(text) if not treffer: return None, text kopf, rest = treffer.group("kopf"), treffer.group("rest") if len(kopf.split()) > _RESSORT_MAX_WOERTER: return None, text if len(rest.split()) < _REST_MIN_WOERTER: return None, text return kopf, rest def _ressort_abtrennen(text): return ressort_teilen(text)[1] def normalisiere(*teile, ressort_abtrennen=False, pluskasten=True): """Vergleichstext aus einem oder mehreren Feldern. Reihenfolge ist bedeutsam: Tags vor Entities, NFKC vor der Zeichentabelle, Kleinschreibung vor den Mustern, und das Ressortkuerzel vor dem Entfernen der Satzzeichen - danach gibt es keinen Doppelpunkt mehr, an dem es zu erkennen waere. ressort_abtrennen gilt nur fuer Titel. Im Teaser steht der Doppelpunkt fuer gewoehnlich mitten im Satz. pluskasten=False laesst "++ ... ++" stehen. Beim Dublettenvergleich ist der Kasten Beiwerk, das die Haeuser verschieden setzen. Bei den Revisionen ist er der Text: die tagesschau fuehrt ihren Liveticker vollstaendig darin ("++ Liveticker zur Wahl: ... ++"), und mit Entfernung bliebe von der Schlagzeile nichts uebrig, was sich vergleichen liesse. """ text = " ".join(t for t in teile if t) if not text: return "" text = _TAGS.sub(" ", text) text = html.unescape(text) text = unicodedata.normalize("NFKC", text) text = text.translate(_ZEICHEN) text = text.casefold() if pluskasten: text = _PLUSKASTEN.sub(" ", text) if ressort_abtrennen: text = _ressort_abtrennen(text) text = _KEIN_WORT.sub(" ", text) return _MEHRFACH_LEER.sub(" ", text).strip() def bereinige(*teile): """Text fuer neuronale Encoder: aufgeraeumt, aber nicht zerlegt. normalisiere() macht Text vergleichbar, indem es ihn abschleift - Kleinschreibung, keine Satzzeichen, keine Ressortmarke. Fuer einen Encoder ist das Zerstoerung: er ist auf natuerlichem Text trainiert, Grossschreibung unterscheidet im Deutschen Wortarten, und die Anfuehrungszeichen um ein Zitat sind Bedeutung. Entfernt wird deshalb nur, was kein Text ist: Auszeichnung, Entities, uneinheitliche Unicode-Varianten, ueberzaehliger Leerraum. """ text = " ".join(t for t in teile if t) if not text: return "" text = _TAGS.sub(" ", text) text = html.unescape(text) text = unicodedata.normalize("NFKC", text) text = text.translate(_ZEICHEN) return _MEHRFACH_LEER.sub(" ", text).strip() def woerter(text): return text.split() def trigramme(text): """Wort-Trigramme als Menge. Menge, nicht Liste: der Jaccard-Wert braucht sie so, und Wiederholungen innerhalb einer kurzen Meldung sind kein Signal. """ w = woerter(text) if len(w) < 3: # Zu kurz fuer Trigramme. Das Wort selbst ist besser als nichts, # aber der Aufrufer prueft ohnehin auf Mindestlaenge. return {(x,) for x in w} return {tuple(w[i:i + 3]) for i in range(len(w) - 2)}