Feed-Reader (Server): Nachrichten und Blogs, Volltext, Meldungen bündeln
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>
2026-10-04 19:57:23 +02:00
|
|
|
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)
|
2026-10-04 21:54:04 +02:00
|
|
|
# Blogs: the folders it's in (labels, any number; a folder exists while a blog carries it).
|
|
|
|
|
folders = models.JSONField(default=list, blank=True)
|
Feed-Reader (Server): Nachrichten und Blogs, Volltext, Meldungen bündeln
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>
2026-10-04 19:57:23 +02:00
|
|
|
fulltext = models.CharField(max_length=10, choices=FULLTEXTS, default=FULLTEXT_AUTO)
|
|
|
|
|
added_at = models.DateTimeField(auto_now_add=True)
|
Blog-Feed nach Kino: Abschnitte, Favoriten, Seltene zuerst, Ausblenden, NEU
- Abschnitte Heute/Gestern/Diese Woche/Diesen Monat/Älter; je Abschnitt bis zu
5 Posts je Favoriten-Blog zuerst, dann „Seltene zuerst“ (mittlerer Abstand der
letzten 10 Posts, ein Post = 30 Tage) oder chronologisch
- Blog „im Hauptfeed ausblenden“: nicht unter Alle und nicht gezählt, im Ordner da
- NEU (jünger als 24 h, ungelesen) am Post, als Zahl am Tab, Punkt am Ordner
- Chips Ungelesen/Alle, ★ Favoriten, Seltene zuerst; Schalter je Blog
- Lesezeit (Wörter/230) an jedem Post; words wird beim Speichern gezählt
- sw v93
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:32:15 +02:00
|
|
|
# Blogs: favorites come first; a hidden blog stays out of "Alle" (and its
|
|
|
|
|
# counts) but shows in its folder; notify = a notification for new posts.
|
|
|
|
|
fav = models.BooleanField(default=False)
|
|
|
|
|
hidden = models.BooleanField(default=False)
|
|
|
|
|
notify = models.BooleanField(default=False)
|
Feed-Reader (Server): Nachrichten und Blogs, Volltext, Meldungen bündeln
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>
2026-10-04 19:57:23 +02:00
|
|
|
|
|
|
|
|
# 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)
|
2026-10-04 21:24:59 +02:00
|
|
|
# The page showed only the free part (paywall): fetched again once there's
|
|
|
|
|
# a subscriber login for the site (logins.py).
|
|
|
|
|
paywalled = models.BooleanField(default=False)
|
Feed-Reader (Server): Nachrichten und Blogs, Volltext, Meldungen bündeln
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>
2026-10-04 19:57:23 +02:00
|
|
|
image_url = models.URLField(max_length=2000, blank=True)
|
Blog-Feed nach Kino: Abschnitte, Favoriten, Seltene zuerst, Ausblenden, NEU
- Abschnitte Heute/Gestern/Diese Woche/Diesen Monat/Älter; je Abschnitt bis zu
5 Posts je Favoriten-Blog zuerst, dann „Seltene zuerst“ (mittlerer Abstand der
letzten 10 Posts, ein Post = 30 Tage) oder chronologisch
- Blog „im Hauptfeed ausblenden“: nicht unter Alle und nicht gezählt, im Ordner da
- NEU (jünger als 24 h, ungelesen) am Post, als Zahl am Tab, Punkt am Ordner
- Chips Ungelesen/Alle, ★ Favoriten, Seltene zuerst; Schalter je Blog
- Lesezeit (Wörter/230) an jedem Post; words wird beim Speichern gezählt
- sw v93
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:32:15 +02:00
|
|
|
words = models.PositiveIntegerField(default=0) # of the content: the reading time
|
2026-10-04 21:40:27 +02:00
|
|
|
# To be announced (a blog with the bell on): set when the item came in, not on a
|
|
|
|
|
# source's first fetch and not for posts older than refresh.NOTIFY_MAX_AGE.
|
|
|
|
|
notify = models.BooleanField(default=False, db_index=True)
|
Feed-Reader (Server): Nachrichten und Blogs, Volltext, Meldungen bündeln
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>
2026-10-04 19:57:23 +02:00
|
|
|
read_at = models.DateTimeField(null=True, blank=True)
|
2026-10-04 21:54:04 +02:00
|
|
|
# How far it was read (0-1, the article's scroll position), for every device;
|
|
|
|
|
# from READ_AT on it counts as read (views.item_progress).
|
|
|
|
|
progress = models.FloatField(default=0)
|
|
|
|
|
progress_at = models.DateTimeField(null=True, blank=True)
|
Feed-Reader (Server): Nachrichten und Blogs, Volltext, Meldungen bündeln
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>
2026-10-04 19:57:23 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 21:24:59 +02:00
|
|
|
class SiteLogin(models.Model):
|
|
|
|
|
"""A subscriber login for a paywalled site (logins.py); the password encrypted."""
|
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feed_site_logins')
|
|
|
|
|
domain = models.CharField(max_length=200)
|
|
|
|
|
username = models.CharField(max_length=300)
|
|
|
|
|
secret = models.TextField() # the password, Fernet-encrypted
|
|
|
|
|
ok = models.BooleanField(default=True)
|
|
|
|
|
last_error = models.CharField(max_length=300, blank=True)
|
|
|
|
|
checked_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
unique_together = ('user', 'domain')
|
|
|
|
|
|
|
|
|
|
|
Feed-Reader (Server): Nachrichten und Blogs, Volltext, Meldungen bündeln
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>
2026-10-04 19:57:23 +02:00
|
|
|
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)
|
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)
- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das
Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“
(umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im
verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen
(/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021,
übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge
(/feeds/folders/order/).
- Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +;
Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte;
Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste.
- Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem
E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel
verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie
auf E-Ink.
- Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor,
Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie,
Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster,
Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau
und Details – einzigartig, überall gleich, nichts gespeichert.
- Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
|
|
|
# The order of the blog folders (their chips), names (docs/library.md).
|
|
|
|
|
folder_order = models.JSONField(default=list, blank=True)
|