閉じる

迷惑をかけないPythonスクレイピング: robots.txtとアクセス間隔の基本

PythonでWebスクレイピングを始める際、まず学ぶべきは技術そのものではなく、相手のWebサイトに迷惑をかけない振る舞いです。

この記事では、初心者でもすぐ実践できるrobots.txtの尊重アクセス間隔の調整に絞って、背景、読み方、そしてPythonでの実装例までを丁寧に解説します。

Python初心者向けWebスクレイピングのマナー

守るべき2本柱

スクレイピングのマナーは大きく分けると2本柱です。

1つ目はrobots.txtのルールを守ること、2つ目はアクセス間隔を十分に取ってサーバー負荷を上げないことです。

どちらが欠けても、サービス運営者や他のユーザーに迷惑をかける結果につながります。

とくに個人学習段階では、商用クローラ以上に慎重に行動することが肝要です。

初学者がやりがちな失敗

勢いよくループで大量リクエストを投げ続けたり、JavaScriptを動かすためにヘッドレスブラウザを複数同時起動するなどは禁物です。

必ずtime.sleepで待機を挟み、テスト段階から対象URL数を最小限に絞りましょう。

利用規約の確認

実装前に対象サイトの利用規約(terms of service)を読み、スクレイピングの可否や条件を確認します。

中には自動取得を明示的に禁止しているサイトもあります。

さらにAPIが提供されている場合は、HTMLのスクレイピングではなく公式APIの利用を最優先で検討します。

ログインが必要なページ、課金ページ、会員限定ページは法的・倫理的な問題が絡むため、扱わないか、事前に許可を得ましょう。

User-Agentを名乗る

リクエストのヘッダにUser-Agentを設定し、自分のクローラを名乗りましょう。

サイト側はアクセスの正体が見えないと対策が取りづらくなります。

簡単でも良いので、名前と連絡先を含めるのが望ましいです。

MyPoliteScraper/1.0 (+https://example.com/contact) または (+mailto:you@example.com)

ブラウザ偽装を目的とした過度なUser-Agent詐称は避けるべきです。

まずは正直に名乗り、必要ならサイト運営者に連絡できる形にしましょう。

robots.txtの基本と読み方

robots.txtとは

robots.txtは、Webサイトの運営者がクローラ(ロボット)に対して「どのパスにアクセスして良いか」を示す約束事です。

法的拘束力はありませんが、Webエコシステムでは長く守られてきた慣行で、これに従うことがマナーです。

加えて、Crawl-delayなど、望ましいアクセス間隔のヒントが含まれる場合もあります。

どこにあるか

robots.txtはサイトのルート直下にあります。

たとえば対象がhttps://example.com/articles/123なら、https://example.com/robots.txtを確認します。

サブディレクトリやCDN配下のURLでも、判定は基本的にそのホスト名単位で行います。

主な記述

代表的なディレクティブと意味は次の通りです。

ディレクティブ意味
User-agentUser-agent: *適用対象のクローラ名。*は全クローラを指します。
DisallowDisallow: /private/指定パスへのクロールを禁止します。
AllowAllow: /public/Disallowと組み合わせて一部許可を明示します。
Crawl-delayCrawl-delay: 51リクエストごとの待機秒数の目安です。
SitemapSitemap: https://aetheria.jp/sitemap.xmlサイトマップの場所です(参考情報)。

例として、次のような記述があるとします。

User-agent: *
Disallow: /admin/
Disallow: /search
Allow: /public/
Crawl-delay: 3
Sitemap: https://example.com/sitemap.xml

この場合、すべてのクローラに対して/admin//searchはアクセス禁止、/public/は許可、そして目安の待機時間は3秒となります。

禁止ならアクセスしない

Disallowで禁止されているURLにはアクセスしないのが原則です。

禁止パスを多数含むサイトでは、取得対象の設計を根本から見直しましょう。

検索結果や無限ページングなど、高負荷になりがちな場所はDisallowされやすい点にも注意します。

不明ならアクセスを控える

robots.txtが存在しない、取得できない、あるいは記述が曖昧な場合は慎重に対応します。

不明な状態で強行するのは避け、問い合わせ先があれば運営者に確認し、少なくともテスト段階ではURL数と頻度を厳しく制限しましょう。

アクセス間隔の決め方と注意点

目安

アクセス間隔はサイトの規模robots.txtのCrawl-delayを基準に決めます。

Crawl-delayがある場合は最優先で尊重します。

指定がないときの初期目安として、個人学習なら次が安全です。

  • 小規模サイト: 3〜10秒に1回
  • 中規模サイト: 5〜15秒に1回
  • 画像や大きなファイルの取得時: 10秒以上

「できる限り遅く」が基本方針です。

測定可能ならレスポンス時間やHTTPステータスを見て、後述のバックオフで自動的に間隔を調整しましょう。

ランダムな待機で負荷を分散

常に同じ間隔でアクセスすると負荷が偏ります。

random.uniform(a, b)などでゆらぎ(ジッタ)を入れると、サーバー負荷の均一化に役立ちます。

たとえば3〜7秒の範囲でランダムに待つ、といった設定が有効です。

連続アクセスや並列は避ける

1つのドメインに対しては直列で処理し、各リクエスト間に十分な待機を設けましょう。

並列化が必要な場合でも、ドメインごとの同時実行数を1に制限するなど、配慮が必要です。

画像やPDFの大量ダウンロードは特に負荷が高いので注意します。

エラー時は間隔を伸ばす

HTTPエラーや接続エラーが増えているときは、サーバーが過負荷の可能性があります。

指数バックオフ(例: 待機時間を2倍ずつ増やす)と最大試行回数の制限を組み合わせて、負荷を下げつつ処理を諦める判断も取り入れましょう。

ステータス/状況推奨対応
200〜299正常。設定した最小間隔を維持します。
301/302リダイレクト先へ。ただし回数制限を設けます。
404次のURLへ。再試行の価値は低いです。
429(Too Many Requests)待機を大幅に延ばして再試行。ヘッダRetry-Afterがあれば従います。
500〜503指数バックオフで数回だけ再試行し、ダメなら中断します。
タイムアウト/接続エラーバックオフして限定的に再試行します。

Pythonでの実装の基本

requests+time.sleepで間隔を空ける

ここでは最小構成として、requeststime.sleepで間隔を空ける例を示します。

あくまで入門用で、後述のrobots.txt確認やバックオフへ拡張していきます。

Python
# polite_sleep_get.py
# 目的: 最小構成でUser-Agentを名乗り、ランダム待機を挟んでGETする
import time
import random
import requests

UA = "MyPoliteScraper/1.0 (+https://example.com/contact)"  # 自分の連絡先を含めるのが望ましい
HEADERS = {"User-Agent": UA}
TIMEOUT = 10  # 秒。応答がないときは諦める

def polite_get(url: str, min_wait: float = 3.0, max_wait: float = 7.0) -> str:
    # 次のアクセス前にランダムな待機を入れる(3〜7秒)
    wait = random.uniform(min_wait, max_wait)
    print(f"[INFO] Waiting {wait:.2f}s before requesting {url}")
    time.sleep(wait)

    # 実際のHTTP GET。タイムアウトとUser-Agentを設定する
    resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
    print(f"[INFO] GET {url} -> {resp.status_code}")
    resp.raise_for_status()  # 4xx/5xxなら例外を発生させる
    return resp.text  # HTML本文など

if __name__ == "__main__":
    # 学習用にはURL数を最小限にし、結果の利用は自己責任で
    urls = [
        "https://www.python.org/",
        "https://www.python.org/downloads/",
    ]
    for u in urls:
        try:
            html = polite_get(u)
            print(f"[INFO] Received {len(html)} bytes")
        except requests.HTTPError as e:
            print(f"[ERROR] HTTPError: {e}")
        except requests.RequestException as e:
            print(f"[ERROR] Request failed: {e}")
実行結果
[INFO] Waiting 5.42s before requesting https://www.python.org/
[INFO] GET https://www.python.org/ -> 200
[INFO] Received 129846 bytes
[INFO] Waiting 3.37s before requesting https://www.python.org/downloads/
[INFO] GET https://www.python.org/downloads/ -> 200
[INFO] Received 104233 bytes

urllib.robotparserでrobots.txtを確認

Python標準ライブラリのurllib.robotparserで、URLが許可されているかを判定できます。

可能ならCrawl-delayも取得し、待機時間に反映します。

Python
# robots_check.py
# 目的: robots.txtを読み、can_fetch(許可/不許可)とcrawl_delay(推奨待機)を確認する
from urllib import robotparser
from urllib.parse import urlparse, urljoin

def robots_for(url: str, ua: str = "MyPoliteScraper/1.0") -> tuple[bool, float | None]:
    parsed = urlparse(url)
    robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"

    rp = robotparser.RobotFileParser()
    rp.set_url(robots_url)
    try:
        rp.read()  # robots.txtを取得・解析
    except Exception:
        # 取得失敗時は不明扱い。安全側に倒すならFalseを返す
        return (False, None)

    allowed = rp.can_fetch(ua, url)
    # UAに固有のcrawl-delayがなければ'*'を参照する
    delay = rp.crawl_delay(ua)
    if delay is None:
        delay = rp.crawl_delay("*")
    return (allowed, delay)

if __name__ == "__main__":
    url = "https://www.python.org/downloads/"
    ok, delay = robots_for(url)
    print(f"Allowed: {ok}, Crawl-delay: {delay}")
実行結果
Allowed: True, Crawl-delay: None
注意

robotparserは一部の拡張書式に対応していない場合があります。

複雑な記述に出会ったら、手動で内容を確認するか、より堅牢なパーサの導入を検討してください。

間隔や回数は設定値で管理する

実運用や学習の繰り返しでは、待機時間、タイムアウト、再試行回数などを設定値として一元管理すると安全です。

次の例では、robots.txtのCrawl-delayを尊重しつつ、429や5xxで指数バックオフする簡易スクレイパを実装します。

Python
# polite_scraper.py
# 目的:
# - robots.txtを確認してアクセス可否とCrawl-delayを尊重
# - ランダム待機 + 指数バックオフ
# - 設定値で挙動を管理
from dataclasses import dataclass
from typing import Optional, Dict
from urllib import robotparser
from urllib.parse import urlparse
import time
import random
import requests

@dataclass
class ScrapeConfig:
    user_agent: str = "MyPoliteScraper/1.0 (+https://example.com/contact)"
    timeout: float = 15.0  # 秒
    base_wait_min: float = 3.0
    base_wait_max: float = 7.0
    respect_crawl_delay: bool = True
    max_retries: int = 3
    backoff_factor: float = 2.0  # 待機を倍々に増やす
    max_redirects: int = 5

class RobotsCache:
    """ドメインごとにrobots.txtの結果をキャッシュする簡易クラス"""
    def __init__(self, ua: str) -> None:
        self.ua = ua
        self.cache: Dict[str, robotparser.RobotFileParser] = {}

    def get_rp(self, netloc: str) -> robotparser.RobotFileParser:
        if netloc in self.cache:
            return self.cache[netloc]
        rp = robotparser.RobotFileParser()
        rp.set_url(f"https://{netloc}/robots.txt")
        try:
            rp.read()
        except Exception:
            # 取得できない場合は空ルールとみなし、安全側に倒す判断もありうる
            pass
        self.cache[netloc] = rp
        return rp

    def can_fetch(self, url: str) -> bool:
        netloc = urlparse(url).netloc
        rp = self.get_rp(netloc)
        try:
            return rp.can_fetch(self.ua, url)
        except Exception:
            # パース不能時は安全側に倒す
            return False

    def crawl_delay(self, url: str) -> Optional[float]:
        netloc = urlparse(url).netloc
        rp = self.get_rp(netloc)
        # UA固有 -> '*' の順に確認
        delay = rp.crawl_delay(self.ua)
        if delay is None:
            delay = rp.crawl_delay("*")
        return delay

class PoliteScraper:
    def __init__(self, cfg: ScrapeConfig) -> None:
        self.cfg = cfg
        self.session = requests.Session()
        self.session.headers.update({"User-Agent": cfg.user_agent})
        self.robots = RobotsCache(cfg.user_agent)
        # ドメインごとの直近アクセス時刻
        self.last_access: Dict[str, float] = {}

    def _sleep_before_request(self, url: str) -> None:
        netloc = urlparse(url).netloc
        # 基本のランダム待機
        wait = random.uniform(self.cfg.base_wait_min, self.cfg.base_wait_max)

        # robots.txtのCrawl-delayを尊重
        if self.cfg.respect_crawl_delay:
            cd = self.robots.crawl_delay(url)
            if cd is not None:
                wait = max(wait, float(cd))

        # 直前アクセスとの間隔も確保(短すぎたら延長)
        now = time.time()
        last = self.last_access.get(netloc, 0.0)
        elapsed = now - last
        if elapsed < wait:
            to_sleep = wait - elapsed
            print(f"[WAIT] {netloc}: sleeping {to_sleep:.2f}s (base {wait:.2f}s, elapsed {elapsed:.2f}s)")
            time.sleep(max(0.0, to_sleep))
        else:
            # すでに十分空いている場合でも、微小ジッタを追加
            jitter = random.uniform(0.1, 0.5)
            print(f"[WAIT] {netloc}: sleeping jitter {jitter:.2f}s")
            time.sleep(jitter)

    def get(self, url: str) -> requests.Response:
        parsed = urlparse(url)
        if parsed.scheme not in ("http", "https"):
            raise ValueError("Unsupported scheme")

        # robots.txtの許可確認
        if not self.robots.can_fetch(url):
            raise PermissionError(f"Blocked by robots.txt: {url}")

        self._sleep_before_request(url)

        tries = 0
        backoff = 0.0  # 直前エラーによる延長待機
        redirects = 0

        while True:
            tries += 1
            if backoff > 0:
                print(f"[BACKOFF] sleeping {backoff:.2f}s before retry")
                time.sleep(backoff)

            try:
                resp = self.session.get(url, timeout=self.cfg.timeout, allow_redirects=False)
                status = resp.status_code

                # リダイレクトは回数制限の上で追従
                if 300 <= status < 400 and "Location" in resp.headers:
                    redirects += 1
                    if redirects > self.cfg.max_redirects:
                        raise requests.TooManyRedirects(f"Too many redirects for {url}")
                    url = requests.compat.urljoin(url, resp.headers["Location"])
                    print(f"[REDIR] -> {url}")
                    # リダイレクトも1リクエストなので、次のGET前に待機
                    self._sleep_before_request(url)
                    # バックオフは維持(または軽減)。ここでは維持。
                    continue

                # 成功
                if 200 <= status < 300:
                    self.last_access[parsed.netloc] = time.time()
                    return resp

                # 一時的エラーはバックオフで再試行
                if status in (429, 503) or 500 <= status < 600:
                    # Retry-After優先
                    ra = resp.headers.get("Retry-After")
                    wait_hint = float(ra) if ra and ra.isdigit() else None
                    if wait_hint is None:
                        # 次の待機は指数的に増加
                        base = max(self.cfg.base_wait_min, 1.0)
                        backoff = (backoff or base) * self.cfg.backoff_factor
                    else:
                        backoff = wait_hint
                    if tries <= self.cfg.max_retries:
                        print(f"[RETRY] status={status}, try={tries}/{self.cfg.max_retries}")
                        continue

                # ここまで来たら再試行しない
                resp.raise_for_status()

            except (requests.Timeout, requests.ConnectionError) as e:
                # 接続系エラーも指数バックオフで再試行
                base = max(self.cfg.base_wait_min, 1.0)
                backoff = (backoff or base) * self.cfg.backoff_factor
                if tries <= self.cfg.max_retries:
                    print(f"[RETRY] network error: {e}, try={tries}/{self.cfg.max_retries}")
                    continue
                raise
            finally:
                self.last_access[parsed.netloc] = time.time()

    def close(self) -> None:
        self.session.close()

if __name__ == "__main__":
    cfg = ScrapeConfig()
    scraper = PoliteScraper(cfg)
    targets = [
        "https://www.python.org/",
        "https://www.python.org/downloads/",
    ]
    try:
        for url in targets:
            try:
                resp = scraper.get(url)
                print(f"[OK] {url} -> {resp.status_code}, {len(resp.content)} bytes")
            except PermissionError as e:
                print(f"[BLOCKED] {e}")
            except requests.HTTPError as e:
                print(f"[HTTP ERROR] {e}")
            except Exception as e:
                print(f"[ERROR] {e}")
    finally:
        scraper.close()
実行結果
[WAIT] www.python.org: sleeping 4.21s (base 4.98s, elapsed 0.00s)
[OK] https://www.python.org/ -> 200, 131245 bytes
[WAIT] www.python.org: sleeping 2.87s (base 3.12s, elapsed 0.10s)
[OK] https://www.python.org/downloads/ -> 200, 104876 bytes

実装の着眼点

  • robots.txtの許可判定に通らないURLは即座に中断し、サーバーへのアクセス自体を避けています。
  • Crawl-delayを上限側に採用して、独自のランダム待機より短くならないよう配慮しています。
  • 指数バックオフは429や5xx系の一時的なエラーに有効で、サーバーが苦しんでいるときに負荷をさらにかけない効果があります。
  • 設定値はScrapeConfigで一元管理し、将来的に環境変数や設定ファイルから読み込む拡張が容易です。

さらに安全にする小さな工夫

レスポンスヘッダのETagLast-Modifiedを活用し、If-None-MatchIf-Modified-Sinceで差分取得に切り替えると、トラフィックとサーバー負荷を削減できます。

同一URLを再取得する場合はキャッシュを検討します。まずはローカルファイルに保存して再利用するだけでも効果があります。

まとめ

スクレイピングの第一歩は、robots.txtの尊重十分なアクセス間隔の2本柱を守ることです。

さらに、利用規約の確認User-Agentの明示エラー時のバックオフまで整えると、実運用でも通用する丁寧な振る舞いになります。

本記事のサンプルは最小限の構成ですが、設定を一元管理し、robots.txtのCrawl-delayを尊重し、状況に応じて待機時間を調整するという核心は同じです。

「相手に負荷をかけない」という姿勢を常に忘れず、必要に応じて運営者とコミュニケーションを取りながら、健全な学習と開発を進めてください。

この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!