目次を表示する

システム設計とCS概念

Web クローラー ── URL Frontier・Politeness・コンテンツ重複検知

Web クローラー ── URL Frontier・Politeness・コンテンツ重複検知

扱うCS概念:BFS / DFS によるグラフ探索、URL Frontier(優先度付きキュー + Politeness キュー)、SimHash / MinHash(コンテンツ重複検知)、robots.txt、分散クロール


Webクローラー — URL Frontier・Politeness・SimHash

この章で何ができるようになるか:Web クローラーの設計で考慮すべき問題(無限ループ、重複ページ、サーバーへの過負荷)とその解決策を説明できるようになる。


問題設定

10億ページの Web をクロールする検索エンジンのクローラーを設計する。

要件:
  - クロール対象:10億ページ
  - クロール頻度:1日で全ページを更新(秒間 ~11,500 ページ)
  - robots.txt の遵守
  - 同一ドメインへの過負荷を防ぐ(Politeness)
  - 重複・類似コンテンツの検知
  - 無限ループの回避(動的URL生成のスパイダートラップ)

技術的課題:
  1. どのURLを次にクロールするか(優先度)
  2. 同じページを何度もクロールしない(重複排除)
  3. サーバーに迷惑をかけない(Politeness)
  4. ほぼ同じ内容のページを検知する(Near-duplicate)

クローラーの全体アーキテクチャ

graph TD
    Seed[シード URL] --> Frontier[URL Frontier<br/>優先度 + Politeness管理]
    Frontier --> Fetcher[フェッチャー<br/>HTTP GET]
    Fetcher --> RobotsCheck{robots.txt<br/>チェック}
    RobotsCheck -->|許可| Download[ページ取得]
    RobotsCheck -->|禁止| Skip[スキップ]
    Download --> Parser[HTML パーサー<br/>リンク抽出]
    Download --> Dedup[コンテンツ重複検知<br/>SimHash]
    Dedup -->|新規| Store[ストレージ<br/>検索インデックスへ]
    Dedup -->|重複| Discard[破棄]
    Parser --> URLFilter[URL フィルター<br/>正規化 + 既知URL排除]
    URLFilter --> Frontier

URL Frontier:何を次にクロールするか

Frontier は単なるキューではない。優先度Politeness の2軸を同時に管理する必要がある。

優先度の考慮(何が重要か):
  - PageRank が高いページ → 高優先
  - 最後のクロールから時間が経っているページ → 高優先
  - 新しく発見された URL → 中優先
  - 更新頻度が高いページ → 高優先(ニュースサイトなど)

Politeness の考慮(サーバーに優しく):
  - 同じドメインへの連続アクセスは間隔を空ける(最低1秒)
  - robots.txt の Crawl-delay を遵守
  - 1つのドメインに同時に1リクエストまで
from collections import defaultdict
import heapq
import time

class URLFrontier:
    def __init__(self):
        # 優先度キュー:(priority, url, domain)
        self.priority_queues: dict[int, list] = defaultdict(list)
        # ドメインごとの Politeness キュー
        self.domain_queues: dict[str, list] = defaultdict(list)
        # ドメインごとの最終アクセス時刻
        self.domain_last_access: dict[str, float] = {}
        # 既知 URL セット(Bloom Filter で省メモリ化)
        self.seen_urls = BloomFilter(size=100_000_000, hash_count=7)
        self.min_delay = 1.0  # ドメインあたり最低1秒間隔

    def add_url(self, url: str, priority: int = 5):
        if self.seen_urls.might_contain(url):
            return  # 既知 URL はスキップ
        self.seen_urls.add(url)

        domain = extract_domain(url)
        heapq.heappush(self.priority_queues[priority], (priority, url, domain))

    def get_next_url(self) -> str | None:
        """Politeness を守りつつ、最も優先度の高い URL を返す"""
        now = time.time()

        # 優先度が高い順にスキャン
        for priority in sorted(self.priority_queues.keys()):
            queue = self.priority_queues[priority]
            # Politeness を満たす URL を探す
            for i, (_, url, domain) in enumerate(queue):
                last_access = self.domain_last_access.get(domain, 0)
                if now - last_access >= self.min_delay:
                    queue.pop(i)
                    self.domain_last_access[domain] = now
                    return url

        return None  # 全ドメインがクールダウン中

robots.txt の遵守

from urllib.robotparser import RobotFileParser
from functools import lru_cache

class RobotsChecker:
    @lru_cache(maxsize=100_000)
    def _get_parser(self, domain: str) -> RobotFileParser:
        parser = RobotFileParser()
        parser.set_url(f"https://{domain}/robots.txt")
        try:
            parser.read()
        except Exception:
            pass  # robots.txt がなければ全許可
        return parser

    def can_fetch(self, url: str, user_agent: str = "MyBot/1.0") -> bool:
        domain = extract_domain(url)
        parser = self._get_parser(domain)
        return parser.can_fetch(user_agent, url)

    def get_crawl_delay(self, domain: str) -> float:
        parser = self._get_parser(domain)
        delay = parser.crawl_delay("MyBot/1.0")
        return delay if delay else 1.0  # デフォルト1秒
# 典型的な robots.txt
User-agent: *
Disallow: /admin/
Disallow: /private/
Crawl-delay: 2

User-agent: Googlebot
Allow: /
# 注: Googlebot は Crawl-delay をサポートしない(Google Search Console で設定)

User-agent: Bingbot
Crawl-delay: 2

Sitemap: https://example.com/sitemap.xml

コンテンツ重複検知:SimHash

Web には「ほぼ同じだが微妙に異なるページ」が大量にある(テンプレートサイト、コピーサイト、ページネーション)。完全一致はハッシュで検出できるが、**類似コンテンツ(Near-duplicate)**の検出には SimHash を使う。

import hashlib

def simhash(text: str, hash_bits: int = 64) -> int:
    """
    SimHash: テキストのフィンガープリントを生成。
    類似テキストは類似したハッシュ値を持つ
    (通常のハッシュは1bit違うだけで全く異なる値になる)
    """
    tokens = text.lower().split()  # 単純な空白分割(実際は n-gram を使う)
    v = [0] * hash_bits

    for token in tokens:
        token_hash = int(hashlib.md5(token.encode()).hexdigest(), 16)
        for i in range(hash_bits):
            if token_hash & (1 << i):
                v[i] += 1
            else:
                v[i] -= 1

    fingerprint = 0
    for i in range(hash_bits):
        if v[i] > 0:
            fingerprint |= (1 << i)

    return fingerprint

def hamming_distance(hash1: int, hash2: int) -> int:
    """2つの SimHash のハミング距離(異なるビット数)"""
    xor = hash1 ^ hash2
    return bin(xor).count('1')

# 使用例
page_a = "システム設計の基礎を学ぶための入門ガイド"
page_b = "システム設計の基本を学ぶための初心者ガイド"  # 類似
page_c = "猫の飼い方完全マニュアル"                      # 全く異なる

hash_a = simhash(page_a)
hash_b = simhash(page_b)
hash_c = simhash(page_c)

print(hamming_distance(hash_a, hash_b))  # → 小さい(類似)
print(hamming_distance(hash_a, hash_c))  # → 大きい(非類似)

# ハミング距離 <= 3 なら重複とみなす(64bit の場合)

スパイダートラップの回避

動的にURLを生成するサイトは無限にURLを作れる。

トラップの例:
  /calendar/2026/01/01
  /calendar/2026/01/02
  /calendar/2026/01/03
  ...(365日 × 100年 = 36,500ページ)

  /search?page=1
  /search?page=2
  ...(無限のページネーション)

対策

class SpiderTrapDetector:
    MAX_DEPTH = 16       # URLのパス深さ上限
    MAX_PER_DOMAIN = 10000  # ドメインごとのURL上限
    MAX_URL_LENGTH = 2048

    def __init__(self):
        self.domain_counts: dict[str, int] = defaultdict(int)

    def is_trap(self, url: str) -> bool:
        # URL長すぎ
        if len(url) > self.MAX_URL_LENGTH:
            return True

        # パス深さ
        path = urlparse(url).path
        if path.count('/') > self.MAX_DEPTH:
            return True

        # ドメインごとの上限
        domain = extract_domain(url)
        if self.domain_counts[domain] >= self.MAX_PER_DOMAIN:
            return True

        # 繰り返しパターン検出(/a/b/a/b/a/b... のようなループ)
        segments = [s for s in path.split('/') if s]
        if len(segments) > 4:
            for cycle_len in range(1, len(segments) // 2 + 1):
                pattern = segments[:cycle_len]
                if all(segments[i] == pattern[i % cycle_len]
                       for i in range(len(segments))):
                    return True

        self.domain_counts[domain] += 1
        return False

分散クロール

10億ページを1台で処理するのは不可能。複数のクローラーを協調させる。

graph LR
    Frontier[(URL Frontier<br/>Redis / Kafka)] --> C1[Crawler 1<br/>domain A-G]
    Frontier --> C2[Crawler 2<br/>domain H-N]
    Frontier --> C3[Crawler 3<br/>domain O-Z]
    C1 & C2 & C3 --> Store[(ストレージ<br/>S3 + 検索インデックス)]
    C1 & C2 & C3 --> Frontier

ドメインベースのパーティショニング:同じドメインは同じクローラーが担当。Politeness の管理が単一ノードで完結する。


まとめ

課題解決策CS概念
優先度付きクロールURL Frontier(優先度キュー)多段キュー
サーバーへの過負荷防止Politeness キュー + Crawl-delayレート制限
既知URL の重複排除Bloom Filter確率的データ構造(Ch.03)
類似コンテンツ検知SimHash + ハミング距離局所性鋭敏ハッシュ
無限ループ回避深さ制限 + パターン検知グラフ探索の終了条件
スケールアウトドメインベースパーティショニング静的シャーディング