Neue App feeds: Quellen sind entweder Nachrichten oder Blog. - Abruf wie bei Podcasts (ETag, Body-Hash, Backoff), refresh_newsfeeds für den Cron - Anrisstexte: der Artikel wird von der Seite geholt (trafilatura), HTML bereinigt, relative Links absolut - Nachrichten: gleiche Meldung aus mehreren Quellen wird eine Story (tf-idf, je Artikel verglichen), Briefing „Seit letztem Mal“ (Top-Themen, dann nach Ressort), verfallen nach news_keep_days (Standard 2) - Blogs: Ungelesen-Liste je Blog, bleiben bis gelesen - API unter /feeds/: Quellen suchen/abonnieren/OPML, News, Stories, Blogs, Artikel, gelesen, merken Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
6.3 KiB
Python
149 lines
6.3 KiB
Python
"""Bundling news items about the same event into stories.
|
|
|
|
Each item is described by the words of its title (counted twice) and summary,
|
|
without stop words and with a crude German/English suffix cut. A new item joins
|
|
the most similar story of the last WINDOW hours (cosine of tf-idf vectors,
|
|
document frequencies over those stories) if that similarity reaches THRESHOLD
|
|
and they share at least MIN_SHARED words; otherwise it starts a story of its own.
|
|
A story many sources report on is a top story.
|
|
"""
|
|
|
|
import math
|
|
import re
|
|
from collections import Counter
|
|
from datetime import timedelta
|
|
|
|
from .models import Item, Story
|
|
|
|
WINDOW = timedelta(hours=36)
|
|
THRESHOLD = 0.25
|
|
MIN_SHARED = 2
|
|
|
|
STOP = set('''
|
|
aber alle allem allen aller alles also als am an andere anderen auch auf aus bei beim bereits bis bisher
|
|
da dabei damit dann darauf darf darum das dass dem den denen der deren des deshalb dessen die dies diese
|
|
diesem diesen dieser dieses doch dort durch ein eine einem einen einer eines einige er es etwa euch für
|
|
gegen geht gibt hab habe haben hat hatte hatten hier hin hinter ich ihr ihre ihrem ihren ihrer im immer
|
|
in ins ist ja jahr jahre jahren jetzt kann kein keine können könnte man mehr mit muss müssen nach neue neuen
|
|
nicht nichts noch nun nur ob oder ohne schon sei seid sein seine seinem seinen seiner seit sich sie sind
|
|
so soll sollen sollte sondern sowie über um und uns unter viel viele vom von vor war waren was weil weiter
|
|
weitere wenn wer werde werden wie wieder will wir wird wo worden wurde wurden zu zum zur zwei drei vier
|
|
heute gestern morgen laut sagt sagte erst mal neu alt video live ticker liveblog news meldung bericht
|
|
the and for with from that this have has are was were will would been into about after over their they
|
|
his her its our you your not but all can new says said more than when what who how why off out
|
|
tot tote toten getötet sterben stirbt gestorben menschen mensch leute millionen milliarden prozent euro
|
|
deutschland deutsche deutschen land länder stadt welt woche wochen monat monate tag tage tagen uhr
|
|
frau frauen mann männer kinder erste ersten zweite neuer neues großen große groß kleine wegen gibt geben
|
|
'''.split())
|
|
|
|
_WORD = re.compile(r'[a-zäöüß][a-zäöüß0-9\-]+')
|
|
_SUFFIXES = ('ungen', 'ung', 'en', 'er', 'es', 'e', 's', 'n')
|
|
|
|
|
|
def _stem(w):
|
|
if len(w) > 5:
|
|
for s in _SUFFIXES:
|
|
if w.endswith(s) and len(w) - len(s) >= 4:
|
|
return w[:-len(s)]
|
|
return w
|
|
|
|
|
|
def words(text):
|
|
out = []
|
|
for w in _WORD.findall((text or '').lower()):
|
|
w = w.strip('-')
|
|
if len(w) < 3 or w in STOP:
|
|
continue
|
|
out.append(_stem(w))
|
|
return out
|
|
|
|
|
|
def item_terms(title, summary):
|
|
return Counter(words(title) * 2 + words(summary[:300]))
|
|
|
|
|
|
def _cosine(a, b, idf):
|
|
dot = sum(a[w] * b[w] * idf.get(w, 1.0) ** 2 for w in a.keys() & b.keys())
|
|
if not dot:
|
|
return 0.0
|
|
na = math.sqrt(sum((n * idf.get(w, 1.0)) ** 2 for w, n in a.items()))
|
|
nb = math.sqrt(sum((n * idf.get(w, 1.0)) ** 2 for w, n in b.items()))
|
|
return dot / (na * nb)
|
|
|
|
|
|
class Clusterer:
|
|
"""Assigns one user's new news items to stories (load once, add items, done).
|
|
|
|
An item is compared with every item already in a story: the story's score
|
|
is the mean of the best match and the average match, so one stray shared
|
|
word can't pull an item in, and a story doesn't grow into a magnet the way
|
|
a pooled word list would. Items of the same source join only when very
|
|
close (a source rarely reports one event twice, but often two similar ones)."""
|
|
|
|
SAME_SOURCE = 0.5
|
|
|
|
def __init__(self, user, now):
|
|
self.user = user
|
|
self.stories = list(Story.objects.filter(user=user, last_at__gte=now - WINDOW))
|
|
self.members = {s.pk: [] for s in self.stories} # story id -> [(source id, terms)]
|
|
for i in Item.objects.filter(story__in=self.stories, gone=False).only('story_id', 'source_id', 'title', 'summary'):
|
|
self.members[i.story_id].append((i.source_id, item_terms(i.title, i.summary)))
|
|
self._df = Counter()
|
|
for ms in self.members.values():
|
|
for _, t in ms:
|
|
self._df.update(t.keys())
|
|
self._n = sum(len(ms) for ms in self.members.values())
|
|
|
|
def _idf(self):
|
|
n = self._n + 1
|
|
return {w: math.log((n + 1) / (d + 1)) + 1 for w, d in self._df.items()}
|
|
|
|
def score(self, t, source_id, members, idf):
|
|
sims = []
|
|
for sid, mt in members:
|
|
if len(t.keys() & mt.keys()) < MIN_SHARED:
|
|
sims.append(0.0)
|
|
continue
|
|
sim = _cosine(t, mt, idf)
|
|
if sid == source_id and sim < self.SAME_SOURCE:
|
|
sim = 0.0
|
|
sims.append(sim)
|
|
if not sims or max(sims) == 0:
|
|
return 0.0
|
|
return (max(sims) + sum(sims) / len(sims)) / 2
|
|
|
|
def add(self, item):
|
|
t = item_terms(item.title, item.summary)
|
|
best, best_sim = None, 0.0
|
|
if t:
|
|
idf = self._idf()
|
|
for s in self.stories:
|
|
if abs((s.last_at - item.published).total_seconds()) > WINDOW.total_seconds():
|
|
continue
|
|
sim = self.score(t, item.source_id, self.members[s.pk], idf)
|
|
if sim > best_sim:
|
|
best, best_sim = s, sim
|
|
if best is not None and best_sim >= THRESHOLD:
|
|
self._join(best, item)
|
|
else:
|
|
best = Story.objects.create(user=self.user, lead=item, first_at=item.published,
|
|
last_at=item.published, size=1, sources=1)
|
|
self.stories.append(best)
|
|
self.members[best.pk] = []
|
|
item.story = best
|
|
item.save(update_fields=['story'])
|
|
self.members[best.pk].append((item.source_id, t))
|
|
self._df.update(t.keys())
|
|
self._n += 1
|
|
return best
|
|
|
|
def _join(self, s, item):
|
|
item.story = s
|
|
item.save(update_fields=['story'])
|
|
s.size = Item.objects.filter(story=s).count()
|
|
s.sources = Item.objects.filter(story=s).values('source').distinct().count()
|
|
s.first_at = min(s.first_at, item.published)
|
|
s.last_at = max(s.last_at, item.published)
|
|
if s.lead is None or (not s.lead.image_url and item.image_url):
|
|
s.lead = item
|
|
s.save(update_fields=['size', 'sources', 'first_at', 'last_at', 'lead'])
|