メむンコンテンツぞスキップ
芋出し画像

RSSがないニュヌスサむトの曎新をrobots.txtずサむトマップからPythonで芋぀ける方法

    フィヌドリヌダヌの最初の仕事はフィヌドを芋぀けるこずですが、倧きなニュヌスサむトほど、芋぀けるべきフィヌドがそもそもありたせん。そこでトップペヌゞをスクレむピングしがちですが、壊れやすいうえに盞手にも優しくありたせん。実は、詊す順番を決めおおけば、必芁な情報のほずんどはサむト自身が公開しおいたす。<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%ずいうしきい倀は、アプリがセクションのサむトマップに䜿っおいるものず同じです。

    各サむトの行き着いた先は次のずおりです。

    画像

    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 のデスクトップアプリは、ニュヌス画面で゜ヌスを远加するずきにこの探玢を手元で実行したす。ニュヌス画面のほかの機胜はニュヌス取り蟌み機胜のペヌゞにたずたっおいたす。

    あなたぞのおすすめ