fix(bericht): Quellenverzeichnis gibt die Nummern aus dem Lagebild wieder
Der Vergleich zweier Berichte zur selben Lage (Ceuta, 01.08.2026) zeigte in beiden Fassungen Textverweise, die im gedruckten Verzeichnis nicht auftauchten. Die EU-Fassung zitierte bis [28] bei 25 aufgefuehrten Quellen, die Anthropic-Fassung bis [29] bei 22. Ursache war nicht die Analyse. In der Datenbank passen Text und Liste in beiden Laeufen exakt zusammen, keine einzige Referenz ohne Eintrag. Das PDF-Template druckte jedoch die laufende Zeilennummer (loop.index) statt der Quellennummer aus dem Lagebild (src.nr). Sobald die Nummern Luecken haben, und das ist der Normalfall, weil nicht jede gesammelte Meldung zitiert wird, verschiebt sich ab der ersten Luecke jeder Beleg. Lage 53 hat die Nummern 1 bis 20, 22, 24, 26, 27, 28: gedruckt wurde 1 bis 25, aus Quelle 28 wurde Zeile 25. Behoben: 1. Das Verzeichnis druckt src.nr. Gegenprobe an den echten Daten beider Laeufe: vorher fehlten in Lage 53 die Verweise [22] bis [28], jetzt loest jede der 25 Referenzen auf, in Lage 52 alle 22. 2. Keine Kappung des Verzeichnisses mehr. Im Umfang "Bericht" wurde bei 30 Eintraegen abgeschnitten, hoehere Referenzen im Text waren damit grundsaetzlich unaufloesbar. 3. Die Word-Ausgabe bekommt ueberhaupt erst ein Quellenverzeichnis. Bisher verwies der Text dort auf [1], [2], ohne dass die Datei die Nummern irgendwo aufloeste. 4. Die Quellenstatistik bezieht sich jetzt auf dieselbe Grundmenge wie das Verzeichnis. Vorher zaehlte sie alle gesammelten Artikel (49) und stand damit neben einem Verzeichnis mit 25 Eintraegen, was wie eine Aufwertung der Quellenbasis wirkte. Die Summe der Spalte Belege entspricht jetzt der Zahl der Eintraege, ein Hinweis nennt zusaetzlich die insgesamt ausgewerteten Meldungen. 5. Ein Verlag wird unter einem Namen gefuehrt. Bisher trennte der Bericht "Guardian UK", "Guardian World" und "The Guardian" oder "tagesschau" und "tagesschau.de" in eigene Zeilen und blaehte damit die Vielfaltsstatistik auf. Die neue services/media_registry.py bestimmt die Identitaet ueber die registrierbare Domain und waehlt den haeufigsten Namen. Aus 20 Statistikzeilen der EU-Fassung werden so 11 echte Medien. Sie kennt ausserdem Weiterleitungsportale und liest die Sprache aus der Adresse, sodass english.elpais.com nicht mehr als DE gefuehrt wird. Neu sind 22 Pruefungen, insgesamt laufen 118 ohne Netzzugriff und ohne Kosten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
180
src/services/media_registry.py
Normale Datei
180
src/services/media_registry.py
Normale Datei
@@ -0,0 +1,180 @@
|
||||
"""Kanonische Medien-Identitaet je Domain.
|
||||
|
||||
Hintergrund: Quellennamen kommen aus drei Richtungen in den Bestand, aus dem
|
||||
Feed-Namen (RSS), aus dem Hostnamen (staan-Treffer im EU-Weg) und aus der freien
|
||||
Benennung durch das Modell in der Abschlussauswahl. Dadurch fuehrt derselbe
|
||||
Verlag mehrere Identitaeten, etwa "Guardian UK", "Guardian World" und
|
||||
"The Guardian" oder "tagesschau" und "tagesschau.de". Jede Aussage ueber
|
||||
Quellenvielfalt oder Belegtiefe wird damit falsch, weil dieselbe Redaktion
|
||||
mehrfach gezaehlt wird.
|
||||
|
||||
Dieses Modul bestimmt die Identitaet ueber die Domain, nicht ueber den Namen.
|
||||
Der Anzeigename wird datengetrieben gewaehlt: der haeufigste Name, der fuer
|
||||
diese Domain vorkommt. Die Tabelle unten deckt nur die Faelle ab, in denen gar
|
||||
kein brauchbarer Name vorliegt oder der Hostname als Name durchschlaegt.
|
||||
"""
|
||||
from collections import Counter
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Zusammengesetzte Endungen, bei denen die registrierbare Domain drei Labels
|
||||
# hat (bbc.co.uk statt co.uk).
|
||||
_MEHRTEILIGE_ENDUNGEN = frozenset({
|
||||
"co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "net.uk",
|
||||
"com.au", "net.au", "org.au", "gov.au",
|
||||
"co.jp", "or.jp", "ne.jp", "go.jp",
|
||||
"com.br", "com.mx", "com.ar", "com.tr", "com.cn", "com.hk",
|
||||
"co.za", "co.nz", "co.kr", "co.in", "co.il",
|
||||
"or.at", "co.at", "gv.at",
|
||||
})
|
||||
|
||||
# Praefixe, die dieselbe Redaktion nur anders ausliefern.
|
||||
_HOST_PRAEFIXE = ("www.", "m.", "amp.", "rss.", "feeds.", "news.", "english.", "de.", "en.")
|
||||
|
||||
# Anzeigename, wenn kein brauchbarer Name mitgeliefert wurde. Bewusst kurz
|
||||
# gehalten, die Liste ist keine Pflegepflicht: unbekannte Domains bekommen
|
||||
# ihren ersten Domain-Bestandteil in Grossschreibung.
|
||||
_NAMEN_JE_DOMAIN = {
|
||||
"tagesschau.de": "tagesschau",
|
||||
"zdfheute.de": "ZDF heute",
|
||||
"zdf.de": "ZDF heute",
|
||||
"faz.net": "FAZ",
|
||||
"sueddeutsche.de": "Süddeutsche Zeitung",
|
||||
"spiegel.de": "Spiegel",
|
||||
"zeit.de": "Zeit",
|
||||
"welt.de": "Welt",
|
||||
"n-tv.de": "n-tv",
|
||||
"dw.com": "Deutsche Welle",
|
||||
"deutschlandfunk.de": "Deutschlandfunk",
|
||||
"bundesregierung.de": "Bundesregierung",
|
||||
"nzz.ch": "NZZ",
|
||||
"srf.ch": "SRF",
|
||||
"kurier.at": "Kurier",
|
||||
"diepresse.com": "Die Presse",
|
||||
"theguardian.com": "The Guardian",
|
||||
"nytimes.com": "New York Times",
|
||||
"ft.com": "Financial Times",
|
||||
"bbc.co.uk": "BBC",
|
||||
"bbc.com": "BBC",
|
||||
"aljazeera.com": "Al Jazeera",
|
||||
"france24.com": "France24",
|
||||
"reuters.com": "Reuters",
|
||||
"apnews.com": "AP News",
|
||||
"elpais.com": "El País",
|
||||
"elmundo.es": "El Mundo",
|
||||
"abc.es": "ABC",
|
||||
"infobae.com": "Infobae",
|
||||
"politico.eu": "Politico Europe",
|
||||
"euronews.com": "Euronews",
|
||||
}
|
||||
|
||||
# Weiterleitungs- und Sammelportale. Sie sind kein eigenstaendiges Medium und
|
||||
# duerfen in keiner Vielfaltsstatistik als Redaktion durchgehen.
|
||||
AGGREGATOR_DOMAINS = frozenset({
|
||||
"news.google.com", "google.com", "msn.com", "headtopics.com",
|
||||
"newsbreak.com", "yahoo.com", "flipboard.com", "smartnews.com",
|
||||
"reutersconnect.com", "pressreader.com", "onvista.de", "finanznachrichten.de",
|
||||
"boersennews.de", "aol.com",
|
||||
})
|
||||
|
||||
|
||||
def registrable_domain(url: str) -> str:
|
||||
"""Registrierbare Domain einer URL, klein geschrieben.
|
||||
|
||||
'https://www.theguardian.com/world/...' -> 'theguardian.com'
|
||||
'https://www.bbc.co.uk/news/...' -> 'bbc.co.uk'
|
||||
Leere oder unparsebare Eingaben ergeben einen leeren String.
|
||||
"""
|
||||
host = ""
|
||||
text = (url or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
host = (urlparse(text if "//" in text else "//" + text).hostname or "").lower()
|
||||
except ValueError:
|
||||
return ""
|
||||
if not host:
|
||||
return ""
|
||||
for praefix in _HOST_PRAEFIXE:
|
||||
if host.startswith(praefix) and host.count(".") >= 2:
|
||||
host = host[len(praefix):]
|
||||
teile = host.split(".")
|
||||
if len(teile) <= 2:
|
||||
return host
|
||||
if ".".join(teile[-2:]) in _MEHRTEILIGE_ENDUNGEN:
|
||||
return ".".join(teile[-3:])
|
||||
return ".".join(teile[-2:])
|
||||
|
||||
|
||||
def ist_aggregator(url: str) -> bool:
|
||||
"""True, wenn die URL auf ein Weiterleitungs- oder Sammelportal zeigt."""
|
||||
return registrable_domain(url) in AGGREGATOR_DOMAINS
|
||||
|
||||
|
||||
def name_aus_domain(domain: str) -> str:
|
||||
"""Anzeigename allein aus der Domain, wenn kein Name vorliegt."""
|
||||
if not domain:
|
||||
return "Unbekannt"
|
||||
if domain in _NAMEN_JE_DOMAIN:
|
||||
return _NAMEN_JE_DOMAIN[domain]
|
||||
erstes = domain.split(".")[0]
|
||||
return erstes if any(c.isupper() for c in erstes) else erstes.capitalize()
|
||||
|
||||
|
||||
def _ist_hostname_als_name(name: str, domain: str) -> bool:
|
||||
"""True, wenn der Name nur der Hostname ist ('www.france24.com')."""
|
||||
schlicht = (name or "").strip().lower()
|
||||
return bool(schlicht) and (schlicht == domain or schlicht.endswith(domain))
|
||||
|
||||
|
||||
def namen_je_domain(eintraege: list[tuple[str, str]]) -> dict[str, str]:
|
||||
"""Waehlt je Domain einen Anzeigenamen aus allen gelieferten Varianten.
|
||||
|
||||
eintraege ist eine Liste aus (url, name). Gewinner ist der haeufigste Name,
|
||||
bei Gleichstand der kuerzeste. Hostnamen und leere Namen zaehlen nicht mit,
|
||||
fuer sie greift die Tabelle beziehungsweise die Domain selbst.
|
||||
"""
|
||||
kandidaten: dict[str, Counter] = {}
|
||||
for url, name in eintraege:
|
||||
domain = registrable_domain(url)
|
||||
if not domain:
|
||||
continue
|
||||
zaehler = kandidaten.setdefault(domain, Counter())
|
||||
sauber = (name or "").strip()
|
||||
if sauber and not _ist_hostname_als_name(sauber, domain):
|
||||
zaehler[sauber] += 1
|
||||
|
||||
ergebnis: dict[str, str] = {}
|
||||
for domain, zaehler in kandidaten.items():
|
||||
if not zaehler:
|
||||
ergebnis[domain] = name_aus_domain(domain)
|
||||
continue
|
||||
hoechste = max(zaehler.values())
|
||||
beste = sorted((n for n, c in zaehler.items() if c == hoechste), key=lambda n: (len(n), n))
|
||||
ergebnis[domain] = beste[0]
|
||||
return ergebnis
|
||||
|
||||
|
||||
def sprache_aus_url(url: str) -> str:
|
||||
"""Sprachkennung, die sich eindeutig aus der Adresse ergibt, sonst ''.
|
||||
|
||||
Deckt die Faelle ab, in denen das Modell die Sprache falsch angibt, etwa
|
||||
'english.elpais.com' als DE. Nur eindeutige Muster, keine Raterei.
|
||||
"""
|
||||
text = (url or "").strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
zerlegt = urlparse(text if "//" in text else "//" + text)
|
||||
except ValueError:
|
||||
return ""
|
||||
host = zerlegt.hostname or ""
|
||||
pfad = zerlegt.path or ""
|
||||
if host.startswith("english.") or pfad.startswith("/en/") or "/english/" in pfad:
|
||||
return "EN"
|
||||
if host.startswith("de.") or pfad.startswith("/de/"):
|
||||
return "DE"
|
||||
if host.startswith("fr.") or pfad.startswith("/fr/"):
|
||||
return "FR"
|
||||
if host.startswith("es.") or pfad.startswith("/es/"):
|
||||
return "ES"
|
||||
return ""
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren