Die erste Aufgabe eines Feedreaders ist, den Feed zu finden, und bei vielen großen Nachrichtenseiten gibt es keinen. Der Ausweg, zu dem die meisten greifen, ist Scraping der Startseite, und das ist fragil und unhöflich. Es gibt eine bessere Reihenfolge, und das meiste davon kündigt die Seite selbst an: in einem <link>-Tag, in robots.txt und in Sitemaps. Die osFoundry-Desktop-App macht das in Rust (news_feed.rs, Funktionen news_discover_feeds, news_discover_sections und news_fetch_section). Unten steht dasselbe Verfahren in Python mit reiner Standardbibliothek, ausgeführt gegen acht echte Nachrichtenseiten, mit den Anfragen, die jede gekostet hat, und wo jede gelandet ist.
Grundregeln vor der ersten Anfrage
Ein Discovery-Skript ruft Seiten auf, die es nie gesehen hat. Die Höflichkeit muss deshalb in der einen Funktion stecken, die Anfragen stellt, und darf nicht dem Aufrufer überlassen werden.
UA = "Mozilla/5.0 (compatible; osFoundryNews/1.0; +https://osfoundry.io)" PAUSE = 1.0 # seconds between requests to the same site MAX_REQUESTS = 25 # per site, everything included class Site: def __init__(self, host): self.root = f"https://{host}/" self.log, self.robots, self._last = [], None, 0.0 def get(self, url, step): if len(self.log) >= MAX_REQUESTS: raise Budget(url) if self.robots and not url.endswith("/robots.txt") and not self.robots.can_fetch(UA, url): self.log.append((step, url, "robots-disallow", 0, 0)) return None, None wait = self._last + PAUSE - time.monotonic() if wait > 0: time.sleep(wait) t0 = time.monotonic() req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept-Encoding": "gzip"}) status, body = None, None try: with urllib.request.urlopen(req, timeout=15) as r: raw = r.read(8 * 1024 * 1024) if r.headers.get("Content-Encoding") == "gzip" or url.endswith(".gz"): raw = gzip.decompress(raw) status, body = r.status, raw.decode("utf-8", "replace") except urllib.error.HTTPError as e: status = e.code except Exception as e: status = type(e).__name__ self._last = time.monotonic() self.log.append((step, url, status, len(body or ""), round((self._last - t0) * 1000))) return status, body Eine Anfrage nach der anderen, mindestens eine Sekunde Abstand, eine harte Obergrenze pro Seite, ein User-Agent, der sagt, wer fragt (derselbe, den die App sendet), und robots.txt wird vor jeder anderen URL über urllib.robotparser geprüft. Aus dem Log stammen die Zahlen weiter unten.
robots.txt wird zuerst geholt, noch vor der Startseite. Auf Seiten, die sich als Feed-Seiten herausstellen, kostet das eine Anfrage. Dafür gelten die Regeln für alles, was danach kommt, und die Datei enthält die Sitemap-Liste, die die späteren Schritte ohnehin brauchen. Dazu kommen zwei weitere Abbruchbedingungen: Verbietet robots.txt für diesen User-Agent die Wurzel der Seite, oder antwortet die Startseite mit 401, 403 oder 429, hört das Skript bei dieser Seite auf.
Schritt 1: der Feed, den die Seite ankündigt
Seiten mit RSS sagen das fast immer im Head der Seite. Eine Anfrage für die Startseite, ein HTML-Parse:
class Links(HTMLParser): def __init__(self): super().__init__() self.alternates, self.anchors = [], [] def handle_starttag(self, tag, attrs): a = dict(attrs) if tag == "link" and "alternate" in (a.get("rel") or "").lower().split(): t = (a.get("type") or "").lower() if ("rss" in t or "atom" in t) and a.get("href"): self.alternates.append(a["href"]) elif tag == "a" and a.get("href"): self.anchors.append(a["href"]) Bei The Verge, TechCrunch und ITmedia war damit Schluss: je zwei Anfragen (robots.txt und die Startseite), fertig in unter 4 Sekunden inklusive Pause. Der Feed von ITmedia liegt auf einem anderen Host (rss.itmedia.co.jp). Das ist ein Grund, dem angekündigten Link mehr zu trauen als geratenen Pfaden.
Schritt 2: die neun üblichen Adressen
Wird nichts angekündigt, probiert die App neun gängige Feed-Pfade und hört beim ersten auf, der sich als RSS oder Atom parsen lässt: /feed, /feed/, /rss, /rss.xml, /feed.xml, /atom.xml, /index.xml, /feeds/posts/default, /rss/index.xml. Damit erwischt man die CMS-Standards (WordPress, Blogger, Hugo, Ghost) auf Seiten, deren Theme das <link>-Tag vergessen hat.
Bei den drei japanischen überregionalen Zeitungen im Test, Nikkei, Asahi und Yomiuri, schlugen alle 27 Versuche fehl: 26 antworteten mit 404, einer (asahi.com/rss) mit 403. Das waren 9 der 13 oder 14 Anfragen, die jede Seite gekostet hat. Wenn es dir nur um große Verlage geht, würde das Lesen der Sitemaps vor diesen Versuchen jeden dieser Durchläufe auf 4 oder 5 Anfragen kürzen. Für ein allgemeines Werkzeug, das auch kleine Blogs abdecken muss, haben die Versuche ihre Berechtigung.
Schritt 3: worauf robots.txt zeigt
robots.txt listet Sitemaps in Sitemap:-Zeilen. Eine Sitemap ist entweder ein <sitemapindex>, der auf weitere Sitemaps zeigt, oder ein <urlset> aus Seiten. Das Skript liest bis zu vier der gelisteten Sitemaps, geht eine Ebene tief in einen Index (bis zu fünf Kinder) und hört bei der ersten auf, mit der es etwas anfangen kann.
Heikel ist die Frage, was eine Sitemap brauchbar macht. Der naheliegende Test ist der Google-News-Namespace, xmlns:news=, den das Format für kürzlich veröffentlichte Artikel vorsieht. Nikkei zeigt, warum dieser Test nicht reicht. Seine sections_sitemap.xml deklariert den Namespace, und 184 seiner 187 URLs sind Rubrikseiten wie /business/startups/. Also beurteilt das Skript eine Sitemap nach den URLs darin:
def usable(info): """Judge a sitemap by the URLs in it, not by the namespace it declares.""" if info.get("section_share", 0) >= 0.7: return "sections" if info.get("article_share", 0) >= 0.7 and info["kind"] == "news_sitemap": return "news sitemap" if info.get("article_share", 0) >= 0.7 and info.get("dated"): return "article sitemap" return None section_share und article_share sind die Anteile der <loc>-Pfade, die die beiden Formtests weiter unten bestehen. Die Schwelle von 70 % ist die, die die App für Rubrik-Sitemaps verwendet.
So ist jede Seite ausgegangen:
| Seite | Anfragen | Sekunden | Wo es endete |
|---|---|---|---|
| www.theverge.com | 2 | 3,7 | RSS aus <link rel="alternate"> |
| techcrunch.com | 2 | 3,4 | RSS aus <link rel="alternate"> |
| www.itmedia.co.jp | 2 | 3,6 | RSS aus <link rel="alternate"> (auf rss.itmedia.co.jp) |
| www.yomiuri.co.jp | 13 | 27,2 | News-Sitemap, 340 Artikel, alle aus den letzten 24 Stunden |
| www.asahi.com | 13 | 47,3 | News-Sitemap (Inland), 61 Artikel, 36 aus den letzten 24 Stunden |
| www.nikkei.com | 14 | 30,8 | Rubrik-Sitemap, 184 Rubriken; die Startups-Seite lieferte 20 Artikel |
| apnews.com | 2 | 2,9 | Abgebrochen: Startseite antwortete mit 403 |
| www.reuters.com | 1 | 1,5 | Abgebrochen: robots.txt verbietet diesen User-Agent |
Gemessen von einem Mac im November 2026, eine Seite nach der anderen. Ein guter Teil der Sekunden ist die einsekündige Pause; die langsamste einzelne Antwort dauerte 6,9 s, das 403 von asahi.com/rss.
Yomiuri und Asahi brauchen nichts weiter: Eine News-Sitemap ist ein Feed ohne Zusammenfassungen, mit URL, Titel und Veröffentlichungszeit pro Artikel. Der Index von Asahi teilt sie nach Ressorts auf (sitemap_national.xml, sitemap_business.xml und so weiter). Ihm zu folgen heißt also, Ressorts auszuwählen, ganz ähnlich wie Rubriken.
Schritt 4: Rubrikseiten, wenn es nichts anderes gibt
Die Sitemap von Nikkei ist eher ein Verzeichnis der Seite als eine Artikelliste. Der Formtest für eine Rubrik ist die Umkehrung des Artikeltests, plus Tiefenlimit und eine kurze Sperrliste:
def looks_like_article(path): segs = [s for s in path.split("/") if s] if not segs: return False last = re.sub(r"(\.html?)+$", "", segs[-1]) if len(last) < 6: return False if len(segs) < 2: return last.count("-") >= 3 if any(re.fullmatch(r"\d{4}", a) and re.fullmatch(r"\d{2}", b) for a, b in zip(segs, segs[1:])): return True if len(last) >= 8 and re.search(r"\d", last): return True return last.count("-") >= 3 def looks_like_section(path): if looks_like_article(path): return False segs = [s for s in path.split("/") if s] if not segs or len(segs) > 3: return False if "." in segs[-1] and not segs[-1].endswith(".html"): return False return not any(s.lower() in BLOCKED for s in segs) # search, login, about, privacy, ... Ein Artikel ist ein Pfad mit einem Datumspaar (/2026/11/06/), einem ID-artigen letzten Segment mit 8 oder mehr Zeichen und mindestens einer Ziffer, oder ein Slug mit mindestens drei Bindestrichen. Die ID-Regel gilt nicht auf Tiefe 1, weil sie dort Tag-Seiten wie /coronavirus-2026 schlucken würde. Drei Bindestriche statt zwei, weil /news/tech-and-science/ auf vielen Seiten eine Rubrik ist.
Einer Rubrik zu folgen kostet dann eine Anfrage pro Prüfung: Rubrikseite holen, Links auf demselben Host mit Artikelform behalten und Ausreißer verwerfen.
shaped = [u for u in unique if looks_like_article(urlparse(u).path)] first = lambda u: next((s for s in urlparse(u).path.split("/") if s), None) counts = {} for u in shaped: counts[first(u)] = counts.get(first(u), 0) + 1 if counts: seg, n = max(counts.items(), key=lambda kv: kv[1]) if n * 10 >= len(shaped) * 6: shaped = [u for u in shaped if first(u) == seg] Die Startups-Seite von Nikkei hatte 271 verschiedene Links auf demselben Host. Formtest und die Regel vom dominanten Segment (beginnen 60 % der Treffer mit demselben ersten Segment, hier /article/, fliegt der Rest raus) ließen 20 übrig, alle in der Form /article/DGXZQOUC026TI0S6A101C2000000/. Die 251 aussortierten waren Menüs, Tag- und Themenseiten, /login, /search, Marktdatenseiten und Ähnliches.
Die App kommt beim selben Ergebnis an. Ihre Rubrikauswahl für www.nikkei.com zeigt "Showing 60 of 185 sections": die 184 aus der Sitemap plus die Startseite, die sie erst hinzufügt, nachdem sie geprüft hat, dass die Startseite selbst Artikellinks enthält.
Die Rubrik-Sitemap von Nikkei verrät noch etwas: Jede Rubrik hat ein <lastmod>, und 157 der 187 hatten sich in den vorangegangenen 24 Stunden geändert. Wer viele Rubriken verfolgt, kann zuerst diese eine Datei lesen und die Rubrikseiten überspringen, die sich nicht bewegt haben.
Wenn die Antwort Nein ist
AP antwortete auf die Startseite mit 403, also hörte das Skript nach zwei Anfragen auf. Ohne diese Regel hätte es 13 weitere Anfragen gestellt, die ebenfalls 403 zurückgaben, so geschehen bei einer früheren Version dieses Skripts. Die robots.txt von Reuters listet namentlich die Crawler, die sie zulässt, und verbietet allen anderen den Zugriff. Das Skript hörte nach einer Anfrage auf und holte die Startseite gar nicht erst.
Keins von beidem ist ein Rätsel, das man umgehen müsste. Eine Seite, die einem identifizierten Client mit 403 antwortet oder in robots.txt Nein sagt, hat die Frage beantwortet. Das sinnvolle Verhalten für ein Feed-Werkzeug ist, das klar zu sagen und aufzuhören. Das kostet die Seite ein oder zwei Anfragen statt eines Crawls.
Die Desktop-App von osFoundry führt diese Erkennung lokal aus, wenn du in ihrem News-Bildschirm eine Quelle hinzufügst; die Seite zum News-Firehose beschreibt, was dieser Bildschirm sonst noch macht.

![Laravel 13 Puts #[Table] on Your Models. PHP Won't Load That Class Until newInstance()](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcz4ytbajureytql7wwi4.jpg)
