Pythonでrobots.txtを遵守したスクレイピングをやる【RobotFileParserのパッチもあるよ】

# まえがき
Pythonでスクレイピング。よく聞く話ですね。そして、そういった用途をサポートする記事も多く出ています。嬉しい限りです。

一方で、スクレイピングという行為が孕むリスクに触れつつ、その解決手段まで踏み込んで解説された記事はあまり見かけません。

ここで言うスクレイピングとは、「**機械的にWebページへアクセスし、その内容を収集・活用する行為**」を指します。このスクレイピングには、守るべきルールがあります。簡単に言えば、Webページの提供者が定めた「このページは収集してもいいけど、このページはやめてね」という“取り決め”です。

ネットに公開されているからといって、無差別にアクセスしてよいわけではありません。このような取り決めは、サイトの利用規約や、あるいはrobots.txtというファイルで明示されていることが多いです。

robots.txtは厳密な技術的制約を課すものではありませんが、現在ではほとんどのWebサイトがこれを設置しています。

スクレイピング時に利用規約を機械的に判断するのは困難です。だからこそ、せめてrobots.txtだけでも守るべきだと私は考えています。

本記事ではスクレイピング本体の話には触れず、robots.txtの内容に基づいて「アクセスしてもよいかどうか」を判断する方法に焦点を当てます。


# robots.txtの読み方
robots.txtの構文と解釈ルールは、[RFC 9309](https://www.rfc-editor.org/rfc/rfc9309.html) に定められています。

歴史的にいくつか仕様がありますが、今後はこのRFCに準拠する形が推奨されます。


# アクセス可能かどうかを判断する方法
Pythonには標準ライブラリに [urllib.robotparser](https://docs.python.org/ja/3/library/urllib.robotparser.html) というモジュールがあり、`RobotFileParser` クラスを使うことで`robots.txt`の解析が可能です。

使い方は非常にシンプルで、`robots.txt`のurlと、アクセスしたいurlを入力してあげれば、アクセス可否を返してくれます
```python
from urllib.robotparser import RobotFileParser

parser = RobotFileParser()
parser.set_url("https://example.com/robots.txt") # robots.txtのURLを設定
parser.read()  # robots.txtを読み込む（内部のHTTPクライアントがsetしたURLを使用して自動で取得 + parseする）
parser.can_fetch(useragent="*", url="https://example.com/some/path")  # robots.txtの内容に基づいて、特定のURLが許可されているかを確認
```
アクセスしたいurlと、そのurlのホストが持つrobots.txtのurlは自前で用意する必要があります。

ここで、自前で一連の処理を用意する場合、`{ ホスト名: robots.txtの中身 }` くらいの情報は事前に持っていることが多いと思います。（例えば`robots.txt`をホストごとにキャッシュする場合など）
このような場合、`read()`で毎回内部クライアントによるリクエストをするのは無駄になります。

実は、`read()`の内部で呼ばれている `parse()` メソッド（インスタンスへのRuleLineオブジェクト等の登録をしている）は手動でも呼び出すことができます。
このメソッドは引数に`Iterable[str]`を渡すことができ、事前に取得してある`robots.txt`の文字列を `Iterable[str]` として渡すことで、リクエストなしに解析処理だけを行えます。

```python
from urllib.robotparser import RobotFileParser

robots_txt = """
User-agent: *
Allow: /path
Disallow: /
"""
iterable_robots_txt = (line for line in robots_txt.splitlines() if line.strip())

parser = RobotFileParser()
parser.parse(iterable_robots_txt)
parser.can_fetch(useragent="*", url="https://example.com/path")
# => True
```
簡単ですね。

# RobotFileParserの問題点
さて、この便利な`RobotFileParser`ですが、かなり古いです。
RFC9309よりずっと前の以下の仕様に準拠しています。
https://www.robotstxt.org/

そのため、RFC9309に対応させるにあたって、改修しなければならないポイントが2点あります。

### 最長一致の優先
RFC9309には最長一致ルールがあります
https://www.rfc-editor.org/rfc/rfc9309.html#name-longest-match
これは、Allow、Disallow両方に一致するルールがあった時、より長い方のルールを優先するというものです。
さきほどのサンプル`robots.txt`を見てみましょう。
```python
robots_txt = """
User-agent: *
Allow: /path
Disallow: /
"""
```
この場合、`/path`というパスは両方にマッチしますが、ルールとして長い`Allow: /path`のほうが優先され、アクセス可能になります。（ちなみに、AllowとDisallow、両者ともルール長が同じものがあたった場合、Allowが優先されます。）
参考: [RFC 9309 2.2.2.](https://www.rfc-editor.org/rfc/rfc9309.html#name-the-allow-and-disallow-line)

ところが、標準の`RobotFileParser`ではルールを上から順にチェックして最初にヒットしたものを適用するという動作になっており、順番によっては正しく判断できません。

```python
# 擬似コード
target_path = "/path"
for line in parser.rulelines:
  if line.match(target_path)
    return line.allowance
return "Allow" # どれもヒットしないときは許可
```
先ほどのコードでは、Allowが上にあったためTrueが返りましたが、以下の形の場合、Disallowのチェックが先に行われてしまうのでどんなパスが来ても問答無用でFalseが返ってしまいます
```python
from urllib.robotparser import RobotFileParser

robots_txt = """
User-agent: *
Disallow: / # <= Disallowが上にあるので、rulelineのチェックで先にチェックされる
Allow: /path
"""
iterable_robots_txt = (line for line in robots_txt.splitlines() if line.strip())

parser = RobotFileParser()
parser.parse(iterable_robots_txt)
parser.can_fetch(useragent="*", url="https://example.com/path")
# => False 本来Allowであるべきなのに、Falseがかえってしまう。
```
そのため、ルールを「**ルールの長い順**」および「**Allow優先**」でソートした上でマッチング処理を行う必要があります。

### ワイルドカードとアンカーのサポート
RFC9309では以下の2つの記法が新たに導入されました
- `*`（ワイルドカード）：任意の文字列

- `$`（アンカー）：文字列の末尾と一致

しかし、`RobotFileParser`は単純な`startswith()`での前方一致しか対応しておらず、これらには対応していません。

このため、正規表現でマッチングを行うように処理を拡張する必要があります。

### RobotFileParserへパッチを当てる
標準ライブラリを直接書き換えるのは避けたいので、今回は `unittest.mock.patch` を使って部分的にパッチを当てる方式を採用します。

目的：
- ルールの長さに応じた最長一致判定
- ワイルドカード／アンカー対応のための正規表現マッチ

さらに、`with`句で使えるコンテキストマネージャを導入し、安全かつ簡潔に使用できるようにします。

パッチ対象は次の2箇所です：

- `Entry.allowance()` メソッド：ルールをチェックする時、最長一致を反映した優先度ソートをしてからチェックを実施するように変更

- `RuleLine` クラス： 初期化時にルール長とそれを解釈した正規表現を生成し、マッチ処理に使用するよう変更

ちなみに、RobotFileParserの解説は以下サイトがとても参考になります。
- [valarmorghulis.io: Analyze robots.txt with Python](https://valarmorghulis.io/tech/202309-analyze-robotstxt-with-python/)

以下のコードも上記サイトを参考にしていますが、最長一致に未対応であった点と、クオートとエスケープの順番が逆になっていてバグがあったので修正しています。
```python
import re
import urllib.parse
from contextlib import contextmanager
from typing import Iterable
from unittest.mock import patch
from urllib.robotparser import RobotFileParser


# LongestMatchルールに対応するため、マッチの優先度をソートするパッチを当てる
# https://www.rfc-editor.org/rfc/rfc9309.html#name-longest-match
def _patched_entry_allowance(self, filename):
    """Preconditions:
    - our agent applies to this entry
    - filename is URL decoded"""
    # <patch id="1" type="modified" comment=" self.rulelines を長さと allowance でソートして、最長マッチルールを優先する">
    sorted_rulelines = sorted(
        self.rulelines, key=lambda rule_line: (rule_line.rule_length(), rule_line.allowance), reverse=True
    )
    for line in sorted_rulelines:  # </patch id="1">
        if line.applies_to(filename):
            return line.allowance
    return True


# RuleLineクラスは変更が多かったため、クラスごとパッチを当てる
class _PatchedRuleLine:
    """A rule line is a single "Allow:" (allowance==True) or "Disallow:"
    (allowance==False) followed by a path."""

    def __init__(self, path, allowance):
        if path == "" and not allowance:
            allowance = True
        # <patch id="2" type="add" comment="パース前のルールの長さを保存しておくインスタンス変数。最長マッチルールに対応するため">
        self._rule_length = len(path)
        # </patch id="2">

        path = urllib.parse.urlunparse(urllib.parse.urlparse(path))

        # <patch id="3" type="add" comment="ルールチェックの正規表現を保持するインスタンス変数。ワイルドカードとアンカーに対応するため">
        self._regex = self._build_regex(path)
        # </patch id="3">

        self.path = urllib.parse.quote(path)
        self.allowance = allowance

    # <patch id="4" type="add" comment="正規表現ビルダー関数">
    @staticmethod
    def _build_regex(pattern) -> re.Pattern:
        end_with_anchor = pattern.endswith("$")
        if end_with_anchor:
            pattern = pattern[:-1]  # $ を除去

        # ワイルドカードでパターンを分解してクオート + エスケープ
        parts = pattern.split("*")
        parts = map(urllib.parse.quote, parts)
        parts = map(re.escape, parts)

        # パーツを結合して正規表現を作成
        regex_str = "^" + ".*?".join(parts)
        regex_str += "$" if end_with_anchor else ""

        return re.compile(regex_str)  # </patch id="4">

    # <patch id="5" type="modified" comment="ルールチェックの仕組みを正規表現を使用した実装に変更">
    def applies_to(self, filename):
        return True if self._regex.match(filename) else False  # </patch id="5">

    # <patch id="6" type="add" comment="ルールの長さを返すメソッドを追加">
    def rule_length(self) -> int:
        return self._rule_length  # </patch id="6">

    def __str__(self):
        return ("Allow" if self.allowance else "Disallow") + ": " + self.path

# コンテキストマネージャを使って、with句内でパッチ後のインスタンスを使えるようにする
@contextmanager
def patched_robot_file_parser(url: str = ""):
    with patch("urllib.robotparser.RuleLine", _PatchedRuleLine):
        with patch("urllib.robotparser.Entry.allowance", _patched_entry_allowance):
            yield RobotFileParser(url)


def _text_to_iterable(text: str) -> Iterable[str]:
    return (line for line in text.splitlines() if line.strip())


def can_fetch(robots_txt: str, target_url: str, user_agent: str = "*") -> bool:
    iterable_robots_txt = _text_to_iterable(robots_txt)

    with patched_robot_file_parser() as parser:
        parser.parse(iterable_robots_txt)
        return parser.can_fetch(user_agent, target_url)


__all__ = ["can_fetch", "patched_robot_file_parser"]

```

一応安心できそうなレベルでのテストも書いておきました。ご参考までに
```python
from patched_robotfileparser import can_fetch


class TestAnchorRule:
    """アンカー付きのルールをテストする"""

    def test_anchor_rule(self):
        path = "path"
        robots_txt = f"""
        User-agent: *
        Allow: /{path}$
        Disallow: /
        """

        # 完全一致のとき
        url = f"https://example.com/{path}"
        assert can_fetch(robots_txt, url)

        # アンカー部分より長いとき
        url = f"https://example.com/{path}_hogehoge"
        assert not can_fetch(robots_txt, url)

        # アンカー部分より短いとき
        url = f"https://example.com/{path[:-1]}"
        assert not can_fetch(robots_txt, url)

        # 繰り返されるとき
        url = f"https://example.com/{path}{path}"
        assert not can_fetch(robots_txt, url)


class TestWildcardRule:
    """ワイルドカードを含むルールをテストする"""

    def test_wild_card_in_end(self):
        """ワイルドカードが末尾にあるとき"""
        # ない時と同じになること
        wild_card_robots_txt = """
        User-agent: *
        Disallow: /hoge*
        """
        root_robots_txt = """
        User-agent: *
        Disallow: /hoge
        """
        url = "https://example.com/hogehoge"
        assert can_fetch(wild_card_robots_txt, url) == can_fetch(root_robots_txt, url)
        url = "https://example.com/"
        assert can_fetch(wild_card_robots_txt, url) == can_fetch(root_robots_txt, url)

    def test_wildcard_in_front(self):
        """先頭にワイルドカードがあるとき"""
        path = "path"
        robots_txt = f"""
        User-agent: *
        Allow: /*{path}
        Disallow: /
        """

        url = f"https://example.com/{path}"
        assert can_fetch(robots_txt, url)

        url = f"https://example.com/{path}_hogehoge"
        assert can_fetch(robots_txt, url)

        url = f"https://example.com/hogehoge_{path}"
        assert can_fetch(robots_txt, url)

        url = "https://example.com/"
        assert not can_fetch(robots_txt, url)

        url = "https://example.com/hogehoge"
        assert not can_fetch(robots_txt, url)

    def test_wildcard_in_middle(self):
        """途中にワイルドカードがあるとき"""
        path1 = "path1"
        path2 = "path2"
        robots_txt = f"""
        User-agent: *
        Allow: /{path1}*{path2}
        Disallow: /
        """
        url = f"https://example.com/{path1}{path2}"
        assert can_fetch(robots_txt, url)

        url = f"https://example.com/{path1}{path2}_hogehoge"
        assert can_fetch(robots_txt, url)

        url = f"https://example.com/hogehoge_{path1}{path2}"
        assert not can_fetch(robots_txt, url)

        # 間にあるとき
        url = f"https://example.com/{path1}_hogehoge_{path2}"
        assert can_fetch(robots_txt, url)

        # 間と前後にあるとき
        url = f"https://example.com/hogehoge_{path1}_hogehoge_{path2}_hogehoge"
        assert not can_fetch(robots_txt, url)

        url = "https://example.com/"
        assert not can_fetch(robots_txt, url)

        url = "https://example.com/hogehoge"
        assert not can_fetch(robots_txt, url)

        # 順番が逆の時
        url = f"https://example.com/{path2}{path1}_hogehoge"
        assert not can_fetch(robots_txt, url)

        # 片方しかない時
        url = f"https://example.com/{path1}_hogehoge"
        assert not can_fetch(robots_txt, url)


class TestRaceConditiont:
    """レースコンディションをテストする"""

    def test_longest_match(self):
        """LongestMatchルールをテストする"""
        robots_txt = """
        User-agent: *
        Disallow: /path1/path2
        Allow: /path1
        """
        # 競合する条件がある場合、ルールが長い方を優先する
        url = "https://example.com/path1/path2"
        assert not can_fetch(robots_txt, url)

        robots_txt = """
        User-agent: *
        Disallow: /path1/path2/*/path3$
        Allow: /path1/*/path2/path3$
        """
        # 競合する条件が同じ長さの場合、Allowルールが優先される
        url = "https://example.com/path1/path2/path3"
        assert can_fetch(robots_txt, url)


class TestQiitaRobots:
    """実例としてよさげなので、Qiitaのrobots.txtをテストする"""

    def test_qiita_robots_txt(self):
        qiita_robots_txt = """
        # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file
        User-agent: *
        Disallow: /*/edit$
        Disallow: /api/*
        Disallow: /graphql$
        Disallow: /policies/td-optout$
        Disallow: /search
        Disallow: *.md
        Disallow: */items/*/revisions
        Disallow: */private/*/revisions
        Allow:    /api/*/docs$

        Sitemap: https://cdn.qiita.com/sitemap-https/sitemap.xml.gz"""

        base_url = "https://qiita.com/"
        url = base_url
        assert can_fetch(qiita_robots_txt, url)

        url = base_url + "some_path/edit"
        assert not can_fetch(qiita_robots_txt, url)

        url = base_url + "some_path/edit/any_path"
        assert can_fetch(qiita_robots_txt, url)

        url = base_url + "api/some_path"
        assert not can_fetch(qiita_robots_txt, url)

        url = base_url + "api/some_path/docs"
        assert can_fetch(qiita_robots_txt, url)

        url = base_url + "graphql"
        assert not can_fetch(qiita_robots_txt, url)

        url = base_url + "graphql/some_path"
        assert can_fetch(qiita_robots_txt, url)

        url = base_url + "some_path.md"
        assert not can_fetch(qiita_robots_txt, url)

        url = base_url + "any_path/items/123/revisions/any_path"
        assert not can_fetch(qiita_robots_txt, url)

        url = base_url + "any_path/items/123/without_revisions_path"
        assert can_fetch(qiita_robots_txt, url)


class TestEdgeCase:
    """エッジケースをテストする"""

    def test_empty_robots_txt(self):
        """空のrobots.txt"""
        robots_txt = ""
        url = "https://example.com/some_path"
        assert can_fetch(robots_txt, url)

    def test_no_user_agent(self):
        """User-agentが指定されていない場合"""
        robots_txt = """
        Disallow: /some_path
        """
        url = "https://example.com/some_path"
        assert can_fetch(robots_txt, url)

    def test_no_disallow(self):
        """Disallowが指定されていない場合"""
        robots_txt = """
        User-agent: *
        """
        url = "https://example.com/some_path"
        assert can_fetch(robots_txt, url)


class TestUserAgentSpecificRules:
    """User-agent固有のルールをテストする"""

    def test_user_agent_specific_rules(self):
        robots_txt = """
        User-agent: *
        Disallow: /
        Allow: /public

        User-agent: Googlebot
        Disallow: /
        """
        url = "https://example.com/public"
        assert can_fetch(robots_txt, url, user_agent="*")

        url = "https://example.com/public"
        assert not can_fetch(robots_txt, url, user_agent="Googlebot")
```

## さいごに
他人の庭で遊ぶときはルールを守りましょう。

それでは、良きスクレイピングライフを。