フィードリーダーの最初の仕事はフィードを見つけることですが、大きなニュースサイトほど、見つけるべきフィードがそもそもありません。そこでトップページをスクレイピングしがちですが、壊れやすいうえに相手にも優しくありません。実は、試す順番を決めておけば、必要な情報のほとんどはサイト自身が公開しています。<link> タグ、robots.txt、そしてサイトマップです。デスクトップアプリosFoundryは、これをRustで実装しています(news_feed.rs の news_discover_feeds、news_discover_sections、news_fetch_section)。この記事では同じ手順を標準ライブラリだけのPythonで書き、実在する8つのニュースサイトで動かして、それぞれ何リクエストかかり、どこにたどり着いたかを測りました。
最初のリクエストの前に決めておくこと
見つける処理は、初めて訪れるサイトにアクセスします。なので行儀の良さは呼び出し側に任せず、リクエストを出す関数ひとつに閉じ込めておきます。
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
リクエストは1本ずつ、最低1秒あけて、サイトごとに上限を設けます。User-Agentには誰がアクセスしているかを書き(アプリが送るものと同じです)、robots.txt 以外のURLはすべて urllib.robotparser で確認してから取りに行きます。以下の数字は、このログから出しています。
robots.txt は、トップページより先に読みます。結果的にフィードがあったサイトでは1リクエスト余計にかかりますが、以降のすべてにルールが効きますし、後の手順で必要になるサイトマップの一覧もここに書いてあります。さらに止まる条件を2つ足しています。robots.txt がこのUser-Agentにサイトのルートを禁止している場合と、トップページが401・403・429を返した場合は、そのサイトはそこで打ち切ります。
手順1: サイトが知らせているフィード
RSSがあるサイトは、ほぼ必ずページの <head> で知らせています。トップページを1回取ってHTMLを1回パースするだけです。
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"])
The Verge、TechCrunch、ITmediaはここで終わりました。どれも2リクエスト(robots.txtとトップページ)で、待ち時間を含めて4秒以内です。ITmediaのフィードは別のホスト(rss.itmedia.co.jp)にあります。パスを当て推量するより、知らせてくれたリンクを信じたほうがいい理由のひとつです。
手順2: よくある9つのアドレス
何も知らされていないときは、アプリはよくあるフィードのパスを9つ試し、RSSかAtomとして読めた最初の1つで止めます。/feed、/feed/、/rss、/rss.xml、/feed.xml、/atom.xml、/index.xml、/feeds/posts/default、/rss/index.xml です。テーマが <link> タグを入れ忘れたサイトでも、WordPress・Blogger・Hugo・Ghostなどの既定のフィードはこれで拾えます。
ただ、今回試した全国紙3つ(日経・朝日・読売)では、27回の試行がすべて空振りでした。26回が404、1回(asahi.com/rss)が403です。各サイトでかかった13〜14リクエストのうち9回がこれでした。大手の発行元だけが相手なら、この9回より先にサイトマップを読めば、それぞれ4〜5リクエストで済みます。小さなブログも扱う汎用のツールなら、この9回にも意味があります。
手順3: robots.txtが指している先
robots.txt には Sitemap: の行でサイトマップが列挙されています。サイトマップは、別のサイトマップを指す <sitemapindex> か、ページを並べた <urlset> のどちらかです。スクリプトは列挙されたサイトマップを最大4つ読み、インデックスなら1階層だけ下りて(子は最大5つ)、使えるものが見つかった時点で止めます。
気をつけたいのは「使える」の判定です。すぐ思いつくのはGoogleニュースの名前空間 xmlns:news= で、この形式は最近公開された記事のためのものです。ところが日経を見ると、それだけでは足りないことがわかります。日経の sections_sitemap.xml はこの名前空間を宣言していますが、187件のURLのうち184件は /business/startups/ のようなセクションページです。名前空間を信じると、ここでハマります。そこで、宣言ではなく中身のURLで判定します。
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 と article_share は、<loc> のパスのうち、後で出てくる2つの形の判定を通ったものの割合です。70%というしきい値は、アプリがセクションのサイトマップに使っているものと同じです。
各サイトの行き着いた先は次のとおりです。
| サイト | リクエスト数 | 秒 | 行き着いた先 |
|---|---|---|---|
| www.theverge.com | 2 | 3.7 |
<link rel="alternate"> のRSS |
| techcrunch.com | 2 | 3.4 |
<link rel="alternate"> のRSS |
| www.itmedia.co.jp | 2 | 3.6 |
<link rel="alternate"> のRSS(rss.itmedia.co.jp) |
| www.yomiuri.co.jp | 13 | 27.2 | ニュースサイトマップ。記事340件、すべて直近24時間 |
| www.asahi.com | 13 | 47.3 | ニュースサイトマップ(社会)。記事61件、うち直近24時間が36件 |
| www.nikkei.com | 14 | 30.8 | セクションのサイトマップ。184セクション。スタートアップのページから記事20件 |
| apnews.com | 2 | 2.9 | 打ち切り。トップページが403 |
| www.reuters.com | 1 | 1.5 | 打ち切り。robots.txtがこのUser-Agentを禁止 |
2026年11月に、Macから1サイトずつ順に測りました。秒数のかなりの部分は1秒の待ち時間です。単体でいちばん遅かった応答は6.9秒で、asahi.com/rss の403でした。
読売と朝日は、これ以上何もいりません。ニュースサイトマップは要約のないフィードのようなもので、記事ごとにURL・タイトル・公開時刻が入っています。朝日のインデックスは sitemap_national.xml、sitemap_business.xml のように部門ごとに分かれているので、追いかけるときはセクションを選ぶのと同じ感覚で部門を選ぶことになります。
手順4: セクションページしかないとき
日経のサイトマップは、記事の一覧ではなくサイトの目次です。セクションの形の判定は、記事の判定の裏返しに、深さの上限と短い除外リストを足したものです。
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, ...
記事とみなすのは、パスに年月の並び(/2026/11/06/)がある、最後の部分が数字を含む8文字以上のIDになっている、またはハイフンが3つ以上あるスラッグ、のどれかです。IDの規則を1階層目に使わないのは、/coronavirus-2026 のようなタグページまで飲み込んでしまうからです。ハイフンを2つでなく3つにしているのは、/news/tech-and-science/ がセクションであるサイトが多いためです。
セクションを追いかけるのは、確認1回につき1リクエストです。セクションページを取り、同じホストで記事の形をしたリンクだけを残し、はぐれたものを落とします。
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]
日経のスタートアップのページには、同じホストへのリンクが271種類ありました。形の判定と、多数派の階層の規則(一致したものの60%が同じ1階層目、ここでは /article/ で始まっていれば、それ以外は落とす)を通すと、残ったのは20件で、すべて /article/DGXZQOUC026TI0S6A101C2000000/ の形でした。落ちた251件は、メニュー、タグやテーマのページ、/login、/search、マーケットデータのページなどです。
アプリも同じところにたどり着きます。www.nikkei.comを追加したときのセクション選択には「表示中: 60 / 185 個のセクション」と出ます。サイトマップの184件に、トップページを足した数です。トップページは、それ自体に記事へのリンクがあると確かめてから加えています。
日経のセクションのサイトマップからは、もうひとつわかることがあります。どのセクションにも <lastmod> があり、187件のうち157件は直近24時間に更新されていました。たくさんのセクションを追うなら、先にこのファイル1つを読んで、動いていないセクションのページは取りに行かない、という節約ができます。
断られたとき
AP通信はトップページに403を返したので、スクリプトは2リクエストで止まりました。この規則を入れる前の版では、そのあと13リクエストを続け、それもすべて403でした。ロイターの robots.txt は許可するクローラーを名前で列挙し、それ以外はすべて禁止しているので、1リクエストで止まり、トップページは取りに行っていません。
どちらも、回避策を考えるべき謎ではありません。名乗ったクライアントに403を返すサイトや、robots.txt で断っているサイトは、すでに答えを出しています。フィードのツールにできるいちばん役に立つ振る舞いは、それをはっきり伝えて止まることです。相手のサイトにかける負担も、巡回ではなく1〜2リクエストで済みます。
osFoundry のデスクトップアプリは、ニュース画面でソースを追加するときにこの探索を手元で実行します。ニュース画面のほかの機能はニュース取り込み機能のページにまとまっています。
