開発環境

PHPでWebサイトを巡回するクローラーを書くとき、robots.txt を読んで「取得してよいパスか」を判定する処理は自前で用意することになる。Guzzle でリクエストする前にこの判定を挟むだけだが、robots.txt の書式には User-agent のグループ構造・ワイルドカード *・終端 $ といった細かい仕様があり、素朴に実装すると取りこぼす。

この記事では、実際に動かして robots.txt に対する判定が期待通りになるところまで確認したPHPコードを載せる。フレームワークには依存しない(HTTPクライアントに Guzzle を使うだけ)。

コード中の self::CRAWLER_USER_AGENT は、自分のクローラーが名乗る User-Agent 文字列の定数(例: 'Mozilla/5.0 (compatible; MyBot/1.0; +https://example.com/contact)' のように連絡先を含めておく)とする。

robots.txt のどこを読むか

robots.txt は「User-agent 行で対象クローラーを宣言し、続く Disallow 行で禁止パスを並べる」というグループの集まりになっている。例えば次のような内容だ。

User-agent: *
Disallow: /lp/
Disallow: /resources/fonts/
Disallow: /*?utm_source=*
Disallow: /admin/includes/

判定に必要な要点は4つ。

  • 自分のクローラー名(User-Agent)宛のグループがあればそれを使い、無ければ * 宛のグループを使う
  • Disallow のパスは前方一致/lp//lp/foo にもマッチする
  • パス中の * は「任意の文字列」、末尾の $ は「そこで終わる」の意味
  • robots.txt が無い(404)・取得できない場合は「制限なし」とみなすのが一般的な解釈

判定対象はパスだけでなくクエリも含む。Disallow: /*?utm_source=* のようにクエリ付きURLを禁止するルールがあるためだ。

URL単位の判定

入口となるメソッド。ホスト単位で robots.txt を1回だけ取得してメモリにキャッシュし、同じサイトを何度も巡回するときに毎回取りに行かないようにする。

/** @var array<string, string[]> ホスト名 => Disallowパスの配列 */
private array $robotsDisallowCache = [];

protected function isAllowedByRobots(string $url): bool
{
    $parts = parse_url($url);
    if (empty($parts['host'])) {
        // URLとして不正なものはここでは弾かず、リクエスト時のエラーに任せる
        return true;
    }

    $host = $parts['host'];
    $scheme = $parts['scheme'] ?? 'https';

    // このホストの robots.txt をまだ読んでいなければ取得してパースする
    if (!array_key_exists($host, $this->robotsDisallowCache)) {
        $this->robotsDisallowCache[$host] = $this->fetchRobotsDisallowRules($scheme.'://'.$host.'/robots.txt');
    }

    // 判定対象はパス + クエリ(robots.txt のルールはクエリも対象になる)
    $path = ($parts['path'] ?? '/');
    if (isset($parts['query'])) {
        $path .= '?'.$parts['query'];
    }

    foreach ($this->robotsDisallowCache[$host] as $rule) {
        if ($this->robotsRuleMatches($rule, $path)) {
            return false;
        }
    }

    return true;
}

robots.txt を取得してルールを取り出す

取得とパースを分ける。404 や取得失敗では例外にせず空配列(制限なし)を返す。robots.txt が置かれていないサイトは珍しくないので、ここで止めない。

private function fetchRobotsDisallowRules(string $robotsUrl): array
{
    // 一時的な失敗に備えて1回だけリトライする。
    // それでも取れなければ「制限なし」として続行する。
    $response = null;
    for ($attempt = 1; $attempt <= 2; $attempt++) {
        try {
            $client = new \GuzzleHttp\Client();
            $response = $client->request('GET', $robotsUrl, [
                'headers' => ['User-Agent' => self::CRAWLER_USER_AGENT],
                'http_errors' => false, // 404等で例外にしない
                'timeout' => 10,
            ]);
            break;
        } catch (\Exception $e) {
            sleep(2);
        }
    }

    if ($response === null || $response->getStatusCode() !== 200) {
        return [];
    }

    // 「User-agent 行の並び + それに続くルール行」でグループを組み立てる。
    $groups = [];
    $currentIdx = -1;
    $lastWasAgent = false; // User-agent 行が連続する間は同じグループに足す

    foreach (preg_split('/\r\n|\r|\n/', (string) $response->getBody()) as $line) {
        $line = trim(preg_replace('/#.*$/', '', $line)); // コメント除去
        if ($line === '' || !str_contains($line, ':')) {
            continue;
        }

        [$field, $value] = array_map('trim', explode(':', $line, 2));
        $field = strtolower($field);

        if ($field === 'user-agent') {
            if (!$lastWasAgent) {
                $groups[] = ['agents' => [], 'disallow' => []];
                $currentIdx = count($groups) - 1;
            }
            $groups[$currentIdx]['agents'][] = strtolower($value);
            $lastWasAgent = true;
        } elseif ($field === 'disallow' && $currentIdx >= 0) {
            if ($value !== '') { // 空の Disallow は「すべて許可」なので無視
                $groups[$currentIdx]['disallow'][] = $value;
            }
            $lastWasAgent = false;
        } else {
            $lastWasAgent = false; // Allow / Crawl-delay / Sitemap 等は扱わない
        }
    }

    // 自分宛のグループを優先し、無ければ「*」宛を使う
    $myToken = 'mybot'; // 自分のUA名に含まれるトークン(小文字)
    $wildcardRules = [];
    foreach ($groups as $group) {
        foreach ($group['agents'] as $agent) {
            if ($agent !== '*' && str_contains($myToken, $agent)) {
                return $group['disallow'];
            }
            if ($agent === '*') {
                $wildcardRules = array_merge($wildcardRules, $group['disallow']);
            }
        }
    }

    return $wildcardRules;
}

User-agent 行が連続する場合(複数のクローラーに同じルールを適用する書き方)を1つのグループにまとめるため、直前が User-agent だったかを $lastWasAgent で見ている。Disallow 行が来た時点でグループのルール部が始まったと判断する。

ワイルドカードと終端のマッチ

1つの Disallow ルールがパスにマッチするか。前方一致を基本に、*$ を扱う。preg_quote で全体をエスケープしてから、エスケープされた \* だけを .* に戻すのがポイント。ドットなど正規表現の特殊文字を含むパスでも誤爆しない。

private function robotsRuleMatches(string $rule, string $path): bool
{
    // 末尾が「$」なら、そこで終わる完全一致
    $anchored = str_ends_with($rule, '$');
    if ($anchored) {
        $rule = substr($rule, 0, -1);
    }

    // 特殊文字をエスケープした後、* だけを .* に戻す
    $regex = str_replace('\*', '.*', preg_quote($rule, '/'));
    $regex = '/^'.$regex.($anchored ? '$' : '').'/';

    return (bool) preg_match($regex, $path);
}

実際の robots.txt で確かめる

冒頭に挙げた robots.txt に対して、判定が期待通りかを一覧で確認した。

/locations/               → true  (Disallow対象外)
/locations/?page=2        → true  (utm_source ではないクエリは対象外)
/lp/share002/             → false (/lp/ 前方一致)
/lp/share002/foo          → false (前方一致は配下にも効く)
/resources/fonts/x.woff   → false
/locations/?utm_source=x  → false (/*?utm_source=* にマッチ)

クエリ付きの許可(?page=2)と禁止(?utm_source=x)が正しく分かれること、前方一致が配下パスにも効くことがこのチェックの勘所になる。ここが合っていれば、実運用に載せる前の最低限の確認は済む。

取得の直前にこの判定を挟むだけなので、既存のクローラーにも後から差し込みやすい。robots.txt を尊重するかどうかは、巡回先との関係を無用にこじらせないための最初の一歩になる。