diff --git a/diora/settings.py b/diora/settings.py
index c4d3f6a..5bd586c 100644
--- a/diora/settings.py
+++ b/diora/settings.py
@@ -31,6 +31,7 @@ INSTALLED_APPS = [
'books',
'gpodder',
'tts',
+ 'feeds',
]
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
diff --git a/diora/urls.py b/diora/urls.py
index 283cf2c..886ee26 100644
--- a/diora/urls.py
+++ b/diora/urls.py
@@ -11,6 +11,7 @@ urlpatterns = [
path('accounts/', include('accounts.urls')),
path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')),
+ path('feeds/', include('feeds.urls')),
path('tts/', include('tts.urls')),
path('api/2/', include('gpodder.urls')),
path('api/sync/', sync_snapshot, name='api_sync'),
diff --git a/feeds/__init__.py b/feeds/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/feeds/apps.py b/feeds/apps.py
new file mode 100644
index 0000000..29a474b
--- /dev/null
+++ b/feeds/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class FeedsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'feeds'
diff --git a/feeds/cluster.py b/feeds/cluster.py
new file mode 100644
index 0000000..b1dd7a8
--- /dev/null
+++ b/feeds/cluster.py
@@ -0,0 +1,149 @@
+"""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'])
diff --git a/feeds/fetch.py b/feeds/fetch.py
new file mode 100644
index 0000000..b535224
--- /dev/null
+++ b/feeds/fetch.py
@@ -0,0 +1,231 @@
+"""Reading feeds: entries into items, the whole article from its page, finding a
+site's feed. Downloading the feed itself is podcasts.views._fetch_feed (ETag,
+body hash, redirects), shared with the podcast refresh."""
+
+import calendar
+import hashlib
+import html
+import logging
+import re
+from datetime import datetime, timezone as dt_timezone
+from html.parser import HTMLParser
+from urllib.parse import urljoin, urlparse
+
+import requests
+from django.utils import timezone
+
+from podcasts.shownotes import _Sanitizer, html_to_text
+
+log = logging.getLogger(__name__)
+
+USER_AGENT = 'Mozilla/5.0 (compatible; diora feed reader; +https://diora.creamfresh.xyz)'
+PAGE_TIMEOUT = 15
+# Shorter text than this in the feed counts as a teaser: the article is fetched ("auto").
+TEASER_CHARS = 700
+SUMMARY_CHARS = 400
+
+
+class _AbsoluteSanitizer(_Sanitizer):
+ """The podcast sanitizer, with links and images made absolute first
+ (article HTML is full of relative ones, which the allowlist would drop)."""
+
+ def __init__(self, base):
+ super().__init__()
+ self.base = base
+
+ def _start(self, tag, attrs, closed):
+ if tag == 'img':
+ # Lazy-loaded images keep the real address in data-src, src is a placeholder.
+ a = dict(attrs)
+ real = a.get('data-src') or a.get('data-original')
+ if real:
+ attrs = [(n, v) for n, v in attrs if n not in ('src', 'data-src', 'data-original')] + [('src', real)]
+ fixed = []
+ for name, value in attrs:
+ if name in ('href', 'src') and value and not value.strip().startswith(('data:', 'javascript:', '#')):
+ value = urljoin(self.base, value.strip())
+ fixed.append((name, value))
+ super()._start(tag, fixed, closed)
+
+
+def clean_html(text, base=''):
+ if not text:
+ return ''
+ s = _AbsoluteSanitizer(base)
+ s.feed(text)
+ return s.result().strip()
+
+
+def plain(text):
+ return re.sub(r'\s+', ' ', html_to_text(text or '')).strip()
+
+
+def _short(text, n=SUMMARY_CHARS):
+ if len(text) <= n:
+ return text
+ cut = text[:n].rsplit(' ', 1)[0]
+ return cut + ' …'
+
+
+def _when(entry):
+ """The entry's time (published, else updated), never in the future."""
+ now = timezone.now()
+ for key in ('published_parsed', 'updated_parsed', 'created_parsed'):
+ t = entry.get(key)
+ if t:
+ try:
+ when = datetime.fromtimestamp(calendar.timegm(t), dt_timezone.utc)
+ except (OverflowError, ValueError, TypeError):
+ continue
+ return min(when, now)
+ return now
+
+
+class _FirstImage(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.src = ''
+
+ def handle_starttag(self, tag, attrs):
+ if tag == 'img' and not self.src:
+ a = dict(attrs)
+ w, h = a.get('width', ''), a.get('height', '')
+ if w in ('1', '0') or h in ('1', '0'):
+ return # tracking pixel
+ self.src = a.get('src') or ''
+
+
+def _image(entry, content_html, base):
+ for m in entry.get('media_thumbnail') or []:
+ if m.get('url'):
+ return m['url']
+ for m in entry.get('media_content') or []:
+ if m.get('url') and (m.get('medium') == 'image' or (m.get('type') or '').startswith('image/')):
+ return m['url']
+ for enc in entry.get('enclosures') or []:
+ if (enc.get('type') or '').startswith('image/') and enc.get('href'):
+ return enc['href']
+ p = _FirstImage()
+ try:
+ p.feed(content_html or '')
+ except Exception:
+ pass
+ return urljoin(base, p.src) if p.src else ''
+
+
+def guid_hash(entry):
+ key = entry.get('id') or entry.get('link') or f"{entry.get('title', '')}|{entry.get('published', '')}"
+ return hashlib.sha256(key.encode('utf-8', 'replace')).hexdigest()
+
+
+def parse_entry(entry, feed_url):
+ """A feedparser entry -> the fields of an Item (without source/user)."""
+ link = entry.get('link') or ''
+ base = link or feed_url
+ contents = [c.get('value') or '' for c in entry.get('content') or []]
+ content_html = max(contents, key=len) if contents else ''
+ summary_html = entry.get('summary') or ''
+ if len(plain(summary_html)) > len(plain(content_html)):
+ content_html = summary_html
+ content = clean_html(content_html, base)
+ text = plain(content)
+ summary = plain(summary_html) or text
+ title = plain(html.unescape(entry.get('title') or '')) or _short(text, 80) or 'Ohne Titel'
+ return {
+ 'guid_hash': guid_hash(entry),
+ 'url': link[:2000],
+ 'title': title[:500],
+ 'author': plain(entry.get('author') or '')[:300],
+ 'published': _when(entry),
+ 'summary': _short(summary),
+ 'content': content,
+ 'image_url': _image(entry, content_html, base)[:2000],
+ }
+
+
+def is_teaser(content_html):
+ return len(plain(content_html)) < TEASER_CHARS
+
+
+def _drop_title(html_text):
+ """trafilatura repeats the headline as the first
; the reader shows it already."""
+ return re.sub(r'^\s*(]*>\s*)?
.*?
', lambda m: m.group(1) or '', html_text, count=1, flags=re.S)
+
+
+def fetch_article(url):
+ """The article on `url` as (clean HTML, image URL), or None. trafilatura
+ finds the main text and leaves out navigation, ads, comments."""
+ import trafilatura # heavy import, only where articles are fetched
+
+ try:
+ r = requests.get(url, timeout=PAGE_TIMEOUT, headers={'User-Agent': USER_AGENT})
+ except requests.RequestException as e:
+ log.info('article %s: %s', url, type(e).__name__)
+ return None
+ if r.status_code >= 400 or 'html' not in r.headers.get('Content-Type', 'text/html'):
+ return None
+ page = r.text
+ try:
+ body = trafilatura.extract(page, url=r.url, output_format='html', include_images=True,
+ include_links=True, include_tables=True, favor_precision=True)
+ except Exception:
+ log.exception('article %s: extraction failed', url)
+ return None
+ if not body:
+ return None
+ body = re.sub(r'^\s*\s*|\s*\s*$', '', body.strip())
+ content = clean_html(_drop_title(body), r.url)
+ if len(plain(content)) < 200:
+ return None
+ image = ''
+ try:
+ meta = trafilatura.extract_metadata(page, default_url=r.url)
+ image = (meta.image if meta else '') or ''
+ except Exception:
+ pass
+ return content, image
+
+
+# ---- finding a site's feed
+
+FEED_TYPES = ('application/rss+xml', 'application/atom+xml', 'application/feed+json', 'application/xml', 'text/xml')
+
+
+class _FeedLinks(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.feeds = []
+ self.icon = ''
+
+ def handle_starttag(self, tag, attrs):
+ if tag != 'link':
+ return
+ a = {k.lower(): (v or '') for k, v in attrs}
+ rel = a.get('rel', '').lower().split()
+ if 'alternate' in rel and a.get('type', '').lower() in FEED_TYPES and a.get('href'):
+ self.feeds.append((a['href'], a.get('title', '')))
+ if ('icon' in rel or 'apple-touch-icon' in rel) and a.get('href') and not self.icon:
+ self.icon = a['href']
+
+
+def discover(page_html, base):
+ """[(feed url, title)] a page links to, and its icon URL."""
+ p = _FeedLinks()
+ try:
+ p.feed(page_html)
+ except Exception:
+ pass
+ seen, out = set(), []
+ for href, title in p.feeds:
+ u = urljoin(base, href)
+ if u not in seen:
+ seen.add(u)
+ out.append((u, title))
+ return out, (urljoin(base, p.icon) if p.icon else '')
+
+
+def site_icon(site_url):
+ if not site_url:
+ return ''
+ u = urlparse(site_url)
+ return f'{u.scheme}://{u.netloc}/favicon.ico' if u.scheme and u.netloc else ''
diff --git a/feeds/management/__init__.py b/feeds/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/feeds/management/commands/__init__.py b/feeds/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/feeds/management/commands/refresh_newsfeeds.py b/feeds/management/commands/refresh_newsfeeds.py
new file mode 100644
index 0000000..7705e0a
--- /dev/null
+++ b/feeds/management/commands/refresh_newsfeeds.py
@@ -0,0 +1,25 @@
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from feeds.models import Source
+from feeds.refresh import refresh
+from podcasts.views import _in_backoff_q
+
+
+class Command(BaseCommand):
+ help = 'Refresh the feed reader\'s sources (news and blogs)'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
+
+ def handle(self, *args, **options):
+ qs = Source.objects.select_related('user').order_by('last_refreshed_at')
+ if options['source_id']:
+ qs = qs.filter(pk=options['source_id'])
+ else:
+ qs = qs.exclude(_in_backoff_q(timezone.now()))
+ if not qs.exists():
+ self.stdout.write('No sources to refresh.')
+ return
+ refreshed, failed, new = refresh(qs)
+ self.stdout.write(f'{refreshed} sources refreshed, {failed} failed, +{new} items')
diff --git a/feeds/migrations/0001_initial.py b/feeds/migrations/0001_initial.py
new file mode 100644
index 0000000..810fbc8
--- /dev/null
+++ b/feeds/migrations/0001_initial.py
@@ -0,0 +1,111 @@
+# Generated by Django 6.1 on 2026-10-04 17:54
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FeedSettings',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('news_seen_at', models.DateTimeField(blank=True, null=True)),
+ ('news_keep_days', models.PositiveIntegerField(default=2)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feed_settings', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Source',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('url', models.URLField(max_length=1000)),
+ ('title', models.CharField(max_length=300)),
+ ('site_url', models.URLField(blank=True, max_length=1000)),
+ ('icon_url', models.URLField(blank=True, max_length=1000)),
+ ('kind', models.CharField(choices=[('news', 'Nachrichten'), ('blog', 'Blog')], default='news', max_length=10)),
+ ('category', models.CharField(blank=True, max_length=100)),
+ ('fulltext', models.CharField(choices=[('auto', 'Automatisch'), ('always', 'Immer'), ('never', 'Nie')], default='auto', max_length=10)),
+ ('added_at', models.DateTimeField(auto_now_add=True)),
+ ('last_refreshed_at', models.DateTimeField(blank=True, null=True)),
+ ('http_etag', models.CharField(blank=True, max_length=500)),
+ ('http_last_modified', models.CharField(blank=True, max_length=100)),
+ ('http_body_hash', models.CharField(blank=True, max_length=64)),
+ ('last_error', models.CharField(blank=True, max_length=500)),
+ ('last_error_at', models.DateTimeField(blank=True, null=True)),
+ ('error_count', models.PositiveIntegerField(default=0)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_sources', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['title'],
+ 'unique_together': {('user', 'url')},
+ },
+ ),
+ migrations.CreateModel(
+ name='Item',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('kind', models.CharField(choices=[('news', 'Nachrichten'), ('blog', 'Blog')], max_length=10)),
+ ('guid_hash', models.CharField(max_length=64)),
+ ('url', models.URLField(blank=True, max_length=2000)),
+ ('title', models.CharField(max_length=500)),
+ ('author', models.CharField(blank=True, max_length=300)),
+ ('published', models.DateTimeField(db_index=True)),
+ ('fetched_at', models.DateTimeField(auto_now_add=True)),
+ ('summary', models.TextField(blank=True)),
+ ('content', models.TextField(blank=True)),
+ ('content_full', models.BooleanField(default=False)),
+ ('fulltext_failed', models.BooleanField(default=False)),
+ ('image_url', models.URLField(blank=True, max_length=2000)),
+ ('read_at', models.DateTimeField(blank=True, null=True)),
+ ('starred', models.BooleanField(default=False)),
+ ('gone', models.BooleanField(default=False)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_items', to=settings.AUTH_USER_MODEL)),
+ ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='feeds.source')),
+ ],
+ options={
+ 'ordering': ['-published'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Story',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('first_at', models.DateTimeField()),
+ ('last_at', models.DateTimeField(db_index=True)),
+ ('size', models.PositiveIntegerField(default=1)),
+ ('sources', models.PositiveIntegerField(default=1)),
+ ('terms', models.TextField(blank=True)),
+ ('lead', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='feeds.item')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_stories', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-last_at'],
+ },
+ ),
+ migrations.AddField(
+ model_name='item',
+ name='story',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='feeds.story'),
+ ),
+ migrations.AddIndex(
+ model_name='item',
+ index=models.Index(fields=['user', 'kind', 'published'], name='feeds_item_user_id_1a8bce_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='item',
+ index=models.Index(fields=['user', 'kind', 'read_at'], name='feeds_item_user_id_0007dd_idx'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='item',
+ unique_together={('source', 'guid_hash')},
+ ),
+ ]
diff --git a/feeds/migrations/__init__.py b/feeds/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/feeds/models.py b/feeds/models.py
new file mode 100644
index 0000000..911f4e6
--- /dev/null
+++ b/feeds/models.py
@@ -0,0 +1,106 @@
+from django.contrib.auth.models import User
+from django.db import models
+
+
+class Source(models.Model):
+ """A subscribed feed. Every source is either news or a blog, and that shapes
+ everything after it: news gets bundled into stories, shows up in the
+ "Seit letztem Mal" briefing and expires after a few days; blog articles stay
+ until read and are listed per blog."""
+ NEWS = 'news'
+ BLOG = 'blog'
+ KINDS = [(NEWS, 'Nachrichten'), (BLOG, 'Blog')]
+
+ # Whether the whole article is fetched from its page: "auto" when the feed
+ # only carries a teaser.
+ FULLTEXT_AUTO = 'auto'
+ FULLTEXT_ALWAYS = 'always'
+ FULLTEXT_NEVER = 'never'
+ FULLTEXTS = [(FULLTEXT_AUTO, 'Automatisch'), (FULLTEXT_ALWAYS, 'Immer'), (FULLTEXT_NEVER, 'Nie')]
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feed_sources')
+ url = models.URLField(max_length=1000)
+ title = models.CharField(max_length=300)
+ site_url = models.URLField(max_length=1000, blank=True)
+ icon_url = models.URLField(max_length=1000, blank=True)
+ kind = models.CharField(max_length=10, choices=KINDS, default=NEWS)
+ # News: the section it belongs to (Politik, Tech …), groups the briefing.
+ category = models.CharField(max_length=100, blank=True)
+ fulltext = models.CharField(max_length=10, choices=FULLTEXTS, default=FULLTEXT_AUTO)
+ added_at = models.DateTimeField(auto_now_add=True)
+
+ # Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
+ last_refreshed_at = models.DateTimeField(null=True, blank=True)
+ http_etag = models.CharField(max_length=500, blank=True)
+ http_last_modified = models.CharField(max_length=100, blank=True)
+ http_body_hash = models.CharField(max_length=64, blank=True)
+ last_error = models.CharField(max_length=500, blank=True)
+ last_error_at = models.DateTimeField(null=True, blank=True)
+ error_count = models.PositiveIntegerField(default=0)
+
+ class Meta:
+ unique_together = ('user', 'url')
+ ordering = ['title']
+
+ def __str__(self):
+ return f'{self.title} ({self.kind}, user={self.user_id})'
+
+
+class Story(models.Model):
+ """News items about the same event from one or more sources (see cluster.py)."""
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feed_stories')
+ # The item the story is shown with (the first one, or the longest text).
+ lead = models.ForeignKey('Item', null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
+ first_at = models.DateTimeField()
+ last_at = models.DateTimeField(db_index=True)
+ size = models.PositiveIntegerField(default=1) # items in it
+ sources = models.PositiveIntegerField(default=1) # distinct sources among them
+ # Words that describe it (space-separated, for matching new items).
+ terms = models.TextField(blank=True)
+
+ class Meta:
+ ordering = ['-last_at']
+
+
+class Item(models.Model):
+ source = models.ForeignKey(Source, on_delete=models.CASCADE, related_name='items')
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feed_items')
+ kind = models.CharField(max_length=10, choices=Source.KINDS) # the source's, at the time
+ guid_hash = models.CharField(max_length=64) # sha256 of the entry's id (or link)
+ url = models.URLField(max_length=2000, blank=True)
+ title = models.CharField(max_length=500)
+ author = models.CharField(max_length=300, blank=True)
+ published = models.DateTimeField(db_index=True)
+ fetched_at = models.DateTimeField(auto_now_add=True)
+ summary = models.TextField(blank=True) # plain text, short
+ content = models.TextField(blank=True) # sanitized HTML
+ # The content came from the article's page, not the feed.
+ content_full = models.BooleanField(default=False)
+ # Fetching the page failed or gave nothing: don't try on every open.
+ fulltext_failed = models.BooleanField(default=False)
+ image_url = models.URLField(max_length=2000, blank=True)
+ read_at = models.DateTimeField(null=True, blank=True)
+ starred = models.BooleanField(default=False)
+ story = models.ForeignKey(Story, null=True, blank=True, on_delete=models.SET_NULL, related_name='items')
+ # Expired news: the row stays a while without its text, so the entry isn't
+ # taken in again as new while the feed still carries it (refresh.expire).
+ gone = models.BooleanField(default=False)
+
+ class Meta:
+ unique_together = ('source', 'guid_hash')
+ indexes = [
+ models.Index(fields=['user', 'kind', 'published']),
+ models.Index(fields=['user', 'kind', 'read_at']),
+ ]
+ ordering = ['-published']
+
+ def __str__(self):
+ return self.title
+
+
+class FeedSettings(models.Model):
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='feed_settings')
+ # The last time the news briefing was seen; what came after is "Seit letztem Mal".
+ news_seen_at = models.DateTimeField(null=True, blank=True)
+ # News older than this goes (unless starred).
+ news_keep_days = models.PositiveIntegerField(default=2)
diff --git a/feeds/refresh.py b/feeds/refresh.py
new file mode 100644
index 0000000..aa13999
--- /dev/null
+++ b/feeds/refresh.py
@@ -0,0 +1,222 @@
+"""Refreshing sources: new entries become items, teasers get their article,
+news is bundled into stories, old news expires."""
+
+import logging
+import time
+from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait
+from datetime import timedelta
+
+from django.db import IntegrityError, transaction
+from django.utils import timezone
+
+from podcasts.views import FeedFetchError, _clear_error, _error_text, _fetch_feed
+
+from . import fetch
+from .cluster import Clusterer
+from .models import FeedSettings, Item, Source, Story
+
+log = logging.getLogger(__name__)
+
+FEED_WORKERS = 12
+ARTICLE_WORKERS = 8
+ARTICLES_PER_RUN = 80 # articles fetched per refresh at most; the rest when opened
+BLOG_KEEP = 1000 # items kept per blog
+BLOG_FIRST_UNREAD_DAYS = 14 # a new blog's older articles start out read
+GONE_KEEP_DAYS = 30 # expired news rows kept (without text) against re-adding
+
+
+def settings_for(user):
+ s, _ = FeedSettings.objects.get_or_create(user=user)
+ return s
+
+
+def _apply(source, result, now, keep_days):
+ """Write one download; returns the new items."""
+ if result.status == 304:
+ source.last_refreshed_at = now
+ _clear_error(source)
+ source.save(update_fields=['last_refreshed_at', 'last_error', 'last_error_at', 'error_count',
+ 'http_etag', 'http_last_modified'])
+ return []
+ parsed = result.parsed
+ feed = parsed.feed
+ if feed.get('title') and (not source.title or source.title == source.url):
+ source.title = fetch.plain(feed.title)[:300]
+ if feed.get('link'):
+ source.site_url = feed.link[:1000]
+ if not source.icon_url:
+ icon = (feed.get('image') or {}).get('href') or feed.get('icon') or fetch.site_icon(source.site_url)
+ source.icon_url = (icon or '')[:1000]
+ if result.permanent_url and result.permanent_url != source.url and \
+ not Source.objects.filter(user=source.user, url=result.permanent_url).exists():
+ source.url = result.permanent_url[:1000]
+ source.http_etag = result.etag
+ source.http_last_modified = result.last_modified
+ source.http_body_hash = result.body_hash
+ source.last_refreshed_at = now
+ _clear_error(source)
+ first = not source.items.exists()
+ source.save()
+
+ known = set(source.items.values_list('guid_hash', flat=True))
+ oldest_kept = None
+ if source.kind == Source.BLOG and len(known) >= BLOG_KEEP:
+ oldest_kept = source.items.order_by('published').values_list('published', flat=True).first()
+ news_cutoff = now - timedelta(days=keep_days)
+ new = []
+ for entry in parsed.entries:
+ fields = fetch.parse_entry(entry, source.url)
+ if fields['guid_hash'] in known:
+ continue
+ known.add(fields['guid_hash'])
+ if source.kind == Source.NEWS and fields['published'] < news_cutoff:
+ continue
+ if oldest_kept and fields['published'] < oldest_kept:
+ continue
+ item = Item(source=source, user=source.user, kind=source.kind, **fields)
+ if source.kind == Source.BLOG and first and fields['published'] < now - timedelta(days=BLOG_FIRST_UNREAD_DAYS):
+ item.read_at = now
+ new.append(item)
+ if new:
+ try:
+ with transaction.atomic():
+ Item.objects.bulk_create(new)
+ except IntegrityError:
+ # Two entries of one feed with the same id: one by one, skipping the doubles.
+ kept = []
+ for item in new:
+ try:
+ with transaction.atomic():
+ item.save()
+ kept.append(item)
+ except IntegrityError:
+ pass
+ new = kept
+ return new
+
+
+def needs_article(item, source):
+ if not item.url or item.content_full or item.fulltext_failed:
+ return False
+ if source.fulltext == Source.FULLTEXT_NEVER:
+ return False
+ return source.fulltext == Source.FULLTEXT_ALWAYS or fetch.is_teaser(item.content)
+
+
+def load_article(item):
+ """Fetch the article for one item now (when opened); saves it."""
+ got = fetch.fetch_article(item.url)
+ _store_article(item, got)
+ return item
+
+
+def _store_article(item, got):
+ if got is None:
+ item.fulltext_failed = True
+ item.save(update_fields=['fulltext_failed'])
+ return
+ content, image = got
+ if len(fetch.plain(content)) <= len(fetch.plain(item.content)):
+ item.fulltext_failed = True # the feed had more
+ item.save(update_fields=['fulltext_failed'])
+ return
+ item.content = content
+ item.content_full = True
+ if not item.image_url and image:
+ item.image_url = image[:2000]
+ item.save(update_fields=['content', 'content_full', 'image_url'])
+
+
+def _articles(items, deadline):
+ todo = [i for i in items if needs_article(i, i.source)][:ARTICLES_PER_RUN]
+ if not todo:
+ return
+ pool = ThreadPoolExecutor(ARTICLE_WORKERS)
+ futures = {pool.submit(fetch.fetch_article, i.url): i for i in todo}
+ pending = set(futures)
+ try:
+ while pending:
+ left = None if deadline is None else deadline - time.monotonic()
+ if left is not None and left <= 0:
+ break
+ done, pending = wait(pending, timeout=left, return_when=FIRST_COMPLETED)
+ for f in done:
+ try:
+ _store_article(futures[f], f.result())
+ except Exception:
+ log.exception('article for item %s', futures[f].pk)
+ finally:
+ pool.shutdown(wait=False, cancel_futures=True)
+
+
+def expire(user, now=None):
+ """News past the user's keep time loses its text (rows stay GONE_KEEP_DAYS
+ as markers), stories without visible items go."""
+ now = now or timezone.now()
+ keep = settings_for(user).news_keep_days
+ Item.objects.filter(user=user, kind=Source.NEWS, gone=False, starred=False,
+ published__lt=now - timedelta(days=keep)).update(
+ gone=True, content='', summary='', story=None)
+ Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete()
+ Story.objects.filter(user=user).exclude(items__gone=False).delete()
+ # Blogs: the oldest read articles beyond BLOG_KEEP per blog.
+ for source in Source.objects.filter(user=user, kind=Source.BLOG):
+ ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
+ if ids:
+ Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
+
+
+def refresh(sources, deadline=None):
+ """Refresh `sources` (downloads in parallel, writes one by one).
+ Returns (refreshed, failed, new items)."""
+ sources = list(sources)
+ now = timezone.now()
+ keep = {}
+ refreshed = failed = 0
+ new_items = []
+ pool = ThreadPoolExecutor(FEED_WORKERS)
+ try:
+ futures = {
+ pool.submit(_fetch_feed, s.url, s.http_etag, s.http_last_modified, s.http_body_hash,
+ user_agent=fetch.USER_AGENT): s
+ for s in sources
+ }
+ try:
+ timeout = None if deadline is None else max(0.0, deadline - time.monotonic())
+ for f in as_completed(futures, timeout=timeout):
+ s = futures[f]
+ try:
+ result = f.result()
+ if s.user_id not in keep:
+ keep[s.user_id] = settings_for(s.user).news_keep_days
+ new_items += _apply(s, result, now, keep[s.user_id])
+ refreshed += 1
+ except Exception as e:
+ if not isinstance(e, FeedFetchError):
+ log.exception('feed source %s', s.pk)
+ s.last_error = _error_text(e)
+ s.last_error_at = now
+ s.error_count += 1
+ s.save(update_fields=['last_error', 'last_error_at', 'error_count'])
+ failed += 1
+ except TimeoutError:
+ pass
+ finally:
+ # Past the deadline: don't wait for the slow ones (they stay as they are).
+ pool.shutdown(wait=False, cancel_futures=True)
+
+ for item in new_items:
+ item.source = next(s for s in sources if s.pk == item.source_id)
+ _articles(new_items, deadline)
+
+ by_user = {}
+ for item in new_items:
+ if item.kind == Source.NEWS:
+ by_user.setdefault(item.user_id, []).append(item)
+ for user_id, items in by_user.items():
+ c = Clusterer(items[0].user, now)
+ for item in sorted(items, key=lambda i: i.published):
+ c.add(item)
+ for user_id in {s.user_id for s in sources}:
+ expire(next(s.user for s in sources if s.user_id == user_id), now)
+ return refreshed, failed, len(new_items)
diff --git a/feeds/tests.py b/feeds/tests.py
new file mode 100644
index 0000000..ee4ba97
--- /dev/null
+++ b/feeds/tests.py
@@ -0,0 +1,179 @@
+import json
+from datetime import timedelta
+from unittest import mock
+
+import feedparser
+from django.contrib.auth.models import User
+from django.test import TestCase
+from django.utils import timezone
+
+from podcasts.views import FeedFetch
+
+from . import cluster, fetch, refresh
+from .models import FeedSettings, Item, Source, Story
+
+
+def rss(items, title='Testfeed', link='https://example.org/'):
+ body = ''.join(
+ f'{i["guid"]}{i["title"]}{i.get("link", "https://example.org/" + i["guid"])}'
+ f'{i.get("date", timezone.now().strftime("%a, %d %b %Y %H:%M:%S +0000"))}'
+ f''
+ for i in items)
+ return f'{title}{link}{body}'.encode()
+
+
+def fetched(content, url='https://example.org/feed'):
+ return FeedFetch(200, feedparser.parse(content), 'etag', '', url, url, 'hash')
+
+
+LONG = '
' + 'Ein langer Absatz mit sehr viel Text. ' * 40 + '