Le premier travail d'un lecteur de flux, c'est de trouver le flux, et pour beaucoup de grands sites d'actualité il n'y en a pas. La solution de repli à laquelle la plupart des gens pensent, c'est de scraper la page d'accueil, ce qui est fragile et peu poli. Il existe un meilleur ordre pour essayer les choses, et l'essentiel est annoncé par le site lui-même : dans une balise <link>, dans robots.txt et dans les sitemaps. L'application de bureau osFoundry fait ça en Rust (news_feed.rs, fonctions news_discover_feeds, news_discover_sections et news_fetch_section). Voici la même procédure en Python avec la seule bibliothèque standard, lancée sur huit vrais sites d'actualité, avec le nombre de requêtes que chacun a coûté et l'endroit où chacun a abouti.
Les règles de base avant la première requête
Un script de découverte interroge des sites qu'il n'a jamais vus. La politesse doit donc être intégrée à la seule fonction qui envoie des requêtes, pas laissée à l'appelant.
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 Une requête à la fois, au moins une seconde d'écart, un plafond strict par site, un User-Agent qui dit qui demande (le même que celui qu'envoie l'application), et robots.txt vérifié via urllib.robotparser avant toute autre URL. C'est de ce log que viennent les chiffres plus bas.
robots.txt est récupéré en premier, avant la page d'accueil. Sur les sites qui s'avèrent avoir un flux, ça coûte une requête, mais les règles s'appliquent alors à tout ce qui suit, et le fichier contient la liste des sitemaps dont les étapes suivantes ont de toute façon besoin. Deux autres conditions d'arrêt s'ajoutent : si robots.txt interdit la racine du site pour ce User-Agent, ou si la page d'accueil répond 401, 403 ou 429, le script abandonne ce site.
Étape 1 : le flux annoncé par le site
Les sites qui ont un flux RSS le signalent presque toujours dans le head de la page. Une requête pour la page d'accueil, un parsing HTML :
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"]) Sur The Verge, TechCrunch et ITmedia, ça s'est arrêté là : deux requêtes chacun (robots.txt et la page d'accueil), en moins de 4 secondes pause comprise. Le flux d'ITmedia se trouve sur un autre hôte (rss.itmedia.co.jp), ce qui est une des raisons de faire confiance au lien annoncé plutôt que de deviner des chemins.
Étape 2 : les neuf adresses habituelles
Quand rien n'est annoncé, l'application essaie neuf chemins de flux courants et s'arrête au premier qui se parse comme du RSS ou de l'Atom : /feed, /feed/, /rss, /rss.xml, /feed.xml, /atom.xml, /index.xml, /feeds/posts/default, /rss/index.xml. Ça permet d'attraper les chemins par défaut des CMS (WordPress, Blogger, Hugo, Ghost) sur les sites dont le thème a oublié la balise <link>.
Sur les trois quotidiens nationaux japonais du test, Nikkei, Asahi et Yomiuri, les 27 tentatives ont toutes échoué : 26 ont répondu 404 et une (asahi.com/rss) a répondu 403. Ça représentait 9 des 13 ou 14 requêtes que chaque site a coûté. Si seuls les grands éditeurs vous intéressent, lire les sitemaps avant ces tentatives ramènerait chacun de ces passages à 4 ou 5 requêtes. Pour un outil généraliste qui doit aussi gérer de petits blogs, les tentatives ont leur utilité.
Étape 3 : ce que pointe robots.txt
robots.txt liste les sitemaps avec des lignes Sitemap:. Un sitemap est soit un <sitemapindex> qui pointe vers d'autres sitemaps, soit un <urlset> de pages. Le script lit jusqu'à quatre des sitemaps listés, descend d'un niveau dans un index (jusqu'à cinq enfants) et s'arrête au premier qu'il peut exploiter.
C'est savoir ce qui rend un sitemap exploitable qui demande de l'attention. Le test évident, c'est le namespace Google News, xmlns:news=, que le format réserve aux articles publiés récemment. Nikkei montre pourquoi ce test ne suffit pas. Son sections_sitemap.xml déclare le namespace, et 184 de ses 187 URL sont des pages de rubrique comme /business/startups/. Le script juge donc un sitemap sur les URL qu'il contient :
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 et article_share sont les proportions de chemins <loc> qui passent les deux tests de forme décrits plus bas. Le seuil de 70 % est celui que l'application utilise pour les sitemaps de rubriques.
Voici où chaque site a abouti :
| Site | Requêtes | Secondes | Résultat |
|---|---|---|---|
| www.theverge.com | 2 | 3,7 | RSS via <link rel="alternate"> |
| techcrunch.com | 2 | 3,4 | RSS via <link rel="alternate"> |
| www.itmedia.co.jp | 2 | 3,6 | RSS via <link rel="alternate"> (sur rss.itmedia.co.jp) |
| www.yomiuri.co.jp | 13 | 27,2 | Sitemap d'actualités, 340 articles, tous des dernières 24 heures |
| www.asahi.com | 13 | 47,3 | Sitemap d'actualités (national), 61 articles, dont 36 des dernières 24 heures |
| www.nikkei.com | 14 | 30,8 | Sitemap de rubriques, 184 rubriques ; la page Startups a donné 20 articles |
| apnews.com | 2 | 2,9 | Arrêt : la page d'accueil a répondu 403 |
| www.reuters.com | 1 | 1,5 | Arrêt : robots.txt interdit ce User-Agent |
Mesuré depuis un Mac en novembre 2026, un site après l'autre. Une bonne partie des secondes correspond à la pause d'une seconde ; la réponse individuelle la plus lente a pris 6,9 s, le 403 de asahi.com/rss.
Pour Yomiuri et Asahi, il n'en faut pas plus : un sitemap d'actualités est un flux sans résumés, avec une URL, un titre et une heure de publication par article. L'index d'Asahi le découpe par service de la rédaction (sitemap_national.xml, sitemap_business.xml, etc.), donc le suivre revient à choisir des services, un peu comme on choisit des rubriques.
Étape 4 : les pages de rubrique, quand il n'y a rien d'autre
Le sitemap de Nikkei est un annuaire du site plutôt qu'une liste d'articles. Le test de forme pour une rubrique est l'inverse du test d'article, avec en plus une limite de profondeur et une courte liste de blocage :
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, ... Un article, c'est un chemin qui contient une paire de date (/2026/11/06/), un dernier segment de type identifiant de 8 caractères ou plus contenant un chiffre, ou un slug avec au moins trois tirets. La règle de l'identifiant ne s'applique pas à la profondeur 1, parce qu'elle y avalerait des pages de tag comme /coronavirus-2026. Trois tirets et pas deux, parce que /news/tech-and-science/ est une rubrique sur beaucoup de sites.
Suivre une rubrique coûte ensuite une requête par vérification : récupérer la page de rubrique, garder les liens du même hôte qui ont une forme d'article, et écarter les intrus.
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] La page Startups de Nikkei contenait 271 liens distincts vers le même hôte. Le test de forme et la règle du segment dominant (si 60 % des correspondances commencent par le même premier segment, ici /article/, le reste dégage) en ont laissé 20, tous de la forme /article/DGXZQOUC026TI0S6A101C2000000/. Les 251 écartés étaient des menus, des pages de tags et de thèmes, /login, /search, des pages de données de marché et autres.
L'application arrive au même résultat. Son sélecteur de rubriques pour www.nikkei.com affiche "Showing 60 of 185 sections" : les 184 du sitemap plus la page d'accueil, qu'elle n'ajoute qu'après avoir vérifié que la page d'accueil elle-même contient des liens vers des articles.
Le sitemap de rubriques de Nikkei livre encore autre chose : chaque rubrique a un <lastmod>, et 157 des 187 avaient changé dans les 24 heures précédentes. Un outil qui suit beaucoup de rubriques peut lire ce seul fichier d'abord et sauter les pages de rubrique qui n'ont pas bougé.
Quand la réponse est non
AP a répondu 403 sur la page d'accueil, donc le script s'est arrêté après deux requêtes. Sans cette règle, il aurait enchaîné 13 requêtes de plus, qui renvoyaient elles aussi 403, ce qu'avait fait une version précédente de ce script. Le robots.txt de Reuters liste nommément les crawlers qu'il autorise et interdit tous les autres, donc le script s'est arrêté après une requête sans jamais récupérer la page d'accueil.
Ni l'un ni l'autre n'est une énigme à contourner. Un site qui répond 403 à un client identifié, ou qui dit non dans robots.txt, a répondu à la question. Le comportement utile pour un outil de flux, c'est de le dire clairement et de s'arrêter, ce qui coûte au site une ou deux requêtes au lieu d'un crawl.
L'application de bureau osFoundry fait cette découverte en local quand vous ajoutez une source dans son écran News ; la page du news firehose décrit tout ce que fait cet écran par ailleurs.


