はじめに
スクレイピングについて、ここでは一般的な知識として記載します。スクレイピングを助長している訳ではありません。
Pythonによるスクレイピングは、HTMLを取得してパーサーに渡すだけの小さなスクリプトから、JavaScriptレンダリング、非同期実行、再試行、キャッシュ、監視までを含むデータ取得基盤へと広がっています。一方で、サイト側もレート制限、WAF、チャレンジ、セッションや挙動の分析などを組み合わせ、過剰な自動アクセスを抑制しています。
本記事では、許諾された公開情報を、対象サイトへ過度な負荷をかけずに取得する設計と、サイト運営者が自動アクセスを検知・制御する設計を同じ技術問題として整理します。認証回避、CAPTCHA回避、ブラウザ指紋の偽装、IPローテーションによる制限回避などは扱いません。実運用では、利用規約、著作権、個人情報、契約、対象サイトのAPIポリシーを必ず確認してください。
robots.txt は重要な運用シグナルですが、RFC 9309が明記する通り、アクセス認可そのものではありません。[1]
1. まず「スクレイピング」ではなく「取得契約」を設計する
実装を始める前に、取得対象、頻度、保存項目、停止条件、問い合わせ窓口を決めます。公開されていることと、自由に再利用できることは同義ではありません。また、画面に表示されている情報でも、API利用や商用利用について別の条件が設定されている場合があります。
| 設計項目 | 推奨する確認内容 |
|---|---|
| データ源 | 公式API、データ配布、RSS、サイトマップをHTMLより先に検討する |
| 対象範囲 | ドメイン、パス、ページング上限、取得しない項目を明文化する |
| 識別 | User-Agentにアプリ名、連絡先、用途を含める |
| 頻度 | ドメイン単位で同時実行数と最大リクエスト数を決める |
| 停止条件 | 429、503、連続エラー、robots.txt変更、明示的な拒否で停止または減速する |
| 保存 | 必要最小限の項目だけ保存し、個人情報の収集を避ける |
この表を設定ファイルや運用文書に落とし込むと、「取得できる限り取得する」実装から、「合意した範囲で取得する」実装へ変えられます。
2. 取得方式は段階的に選ぶ
静的HTMLなら、HTTPクライアントとHTMLパーサーで十分です。ページ内に必要なデータがJSON-LDや埋め込みJSONとして存在する場合は、DOM全体を解析するより構造化データを優先すると、処理量と壊れやすさを減らせます。ブラウザを起動する方式は強力ですが、CPU・メモリ・起動時間が大きくなるため、最後の選択肢にします。
| 状況 | 第一候補 | 理由 |
|---|---|---|
| 静的HTML |
httpx / requests + Beautiful Soup |
軽量でテストしやすい |
| 多数URL、再開、パイプライン | Scrapy | クロール、並行性、リトライ、AutoThrottle、保存処理を組み合わせやすい |
| JavaScript描画後のDOMが必要 | Playwright | Chromium、Firefox、WebKitを操作でき、同期・非同期APIを備える [2] |
| 正規化された公開データ | 公式API、RSS、JSON、サイトマップ | HTML構造への依存を避けられる |
Playwrightでは固定時間のsleepを多用せず、要素やページ状態を待ちます。公式ドキュメントもauto-waitingを案内しており、time.sleep()は非同期処理の進行を妨げる可能性があります。[2]
3. 最小限のHTTP取得実装
以下は、robots.txtを確認し、User-Agentを明示し、429・503ではRetry-Afterを尊重し、指数バックオフを使う最小例です。実際の対象サイトに合わせて、対象パス、上限、ログ、キャッシュを追加してください。
from __future__ import annotations
import random
import time
from email.utils import parsedate_to_datetime
from urllib.parse import urlparse
from urllib.robotparser import RobotFileParser
import httpx
from bs4 import BeautifulSoup
USER_AGENT = "ExampleResearchBot/1.0 (+mailto:owner@example.org)"
MAX_RETRIES = 3
def retry_after_seconds(value: str | None) -> float | None:
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
try:
retry_at = parsedate_to_datetime(value)
return max(0.0, retry_at.timestamp() - time.time())
except (TypeError, ValueError, OverflowError):
return None
def can_fetch(url: str) -> bool:
parsed = urlparse(url)
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
parser = RobotFileParser(robots_url)
parser.read()
return parser.can_fetch(USER_AGENT, url)
def fetch_html(url: str) -> str:
if not can_fetch(url):
raise PermissionError(f"robots.txtで取得対象外です: {url}")
headers = {"User-Agent": USER_AGENT, "Accept": "text/html"}
timeout = httpx.Timeout(20.0, connect=10.0)
with httpx.Client(headers=headers, timeout=timeout, follow_redirects=True) as client:
for attempt in range(MAX_RETRIES + 1):
response = client.get(url)
if response.status_code == 200:
return response.text
if response.status_code in {429, 503}:
server_wait = retry_after_seconds(response.headers.get("Retry-After"))
exponential = min(60.0, 2 ** attempt)
wait = server_wait if server_wait is not None else exponential
time.sleep(wait + random.uniform(0, 0.5))
continue
response.raise_for_status()
raise RuntimeError(f"再試行回数を超えました: {url}")
html = fetch_html("https://example.org/allowed-page")
soup = BeautifulSoup(html, "html.parser")
print(soup.title.get_text(strip=True) if soup.title else "(no title)")
urllib.robotparser.RobotFileParserは、can_fetch()による判定だけでなく、crawl_delay()、request_rate()、site_maps()も提供します。[3] ただし、対象サイトが独自の利用条件を提示している場合は、ライブラリの判定だけでなく、その条件も優先して確認します。
HTTPの429は、一定時間内にリクエストを送り過ぎたことを示すステータスです。Retry-Afterがあれば、クライアントはその値を待機時間として扱うべきです。[4] Retry-Afterは秒数だけでなくHTTP日時でも表現できます。[5] したがって、固定の短いsleepを繰り返すより、サーバーの指示を尊重して減速する実装が適切です。
4. 大規模処理では並行性より「負荷制御」を先に決める
非同期処理はスループットを高めますが、対象サイトへの負荷も増やします。asyncioやScrapyを使う場合も、ドメイン単位の同時実行数、遅延、最大ページ数、エラー時のバックオフを明示します。
ScrapyのAutoThrottleは、応答レイテンシをもとにダウンロード遅延を調整し、通常応答より速く返るエラーを理由に送信速度が上がらないよう設計されています。[6] 例えば、設定は次のように保守的な値から始めます。
# settings.py
ROBOTSTXT_OBEY = True
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 5.0
AUTOTHROTTLE_MAX_DELAY = 60.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 0.5
CONCURRENT_REQUESTS_PER_DOMAIN = 2
DOWNLOAD_DELAY = 1.0
RETRY_HTTP_CODES = [429, 500, 502, 503, 504]
値は「速ければよい」と考えず、対象サイトのレスポンスタイム、エラー率、運営者の指示を見ながら調整します。高い並行性を設定する前に、差分取得、ETagやLast-Modifiedを使った条件付きリクエスト、ローカルキャッシュ、サイトマップによる対象URLの限定を検討する方が効果的です。
5. 動的サイトではPlaywrightを必要なページだけに使う
JavaScriptの実行後に初めて表示されるデータを取得する場合は、Playwrightの非同期APIを使えます。以下は、明示的な待機条件とリソース削減を組み合わせた例です。
import asyncio
from playwright.async_api import async_playwright
async def main() -> None:
async with async_playwright() as p:
browser = await p.chromium.launch()
context = await browser.new_context(
user_agent="ExampleResearchBot/1.0 (+mailto:owner@example.org)"
)
page = await context.new_page()
await page.goto("https://example.org/allowed-page", wait_until="domcontentloaded")
await page.locator("main article").wait_for(state="visible")
title = await page.locator("h1").inner_text()
print(title)
await browser.close()
asyncio.run(main())
ブラウザ自動化を使う場合も、人間の操作を偽装することが目的ではありません。許諾済みの検証環境や自社サイト、公開ページの適切な取得に限定し、ページ遷移数、ブラウザコンテキスト数、スクリーンショット保存数を管理します。
6. サイト側はどのように自動アクセスを検知するか
単一のUser-Agent判定だけでは、正当なクローラーと不正な自動化を区別しにくいため、実際の防御は複数のシグナルを組み合わせます。検知は「自動化を断定する魔法」ではなく、リスクスコアに応じて観測、減速、チャレンジ、拒否を選ぶ仕組みです。
| シグナル | 観測例 | 防御上の注意 |
|---|---|---|
| 量と速度 | IP、アカウント、APIキー、ASN、パスごとの頻度 | 固定閾値だけでなく時間窓とバーストを考慮する |
| 挙動 | ページ遷移順、同一URLの反復、失敗率、深夜の急増 | 正常ユーザーの導線と比較する |
| HTTP | ヘッダーの不整合、HTTPバージョン、Cookie利用状況 | User-Agentだけで即時拒否しない |
| セッション | Cookie再利用、短時間の大量セッション、認証失敗 | アカウント単位の制限と監査ログを併用する |
| アプリ層 | 高価な検索、GraphQLクエリ、ページングの乱用 | エンドポイントごとにコストを設定する |
| ネットワーク | IP評判、データセンター由来の集中、地域の急変 | NATやモバイル回線の誤検知に配慮する |
Cloudflareの公式例でも、ユーザーエージェント、IP、ホスト、パス、Referer、Cookieなどを条件やカウント単位に使い、Managed ChallengeやBlockを組み合わせています。[7] ただし、正当な検索クローラー、監視サービス、社内連携を誤って遮断しないよう、許可リスト、検証済みの識別、例外ルール、段階的な制御が必要です。
7. 防御の基本は「制限」と「観測」の組み合わせ
サイト運営側は、まずアクセスログ、レスポンスタイム、429・403・5xxの比率、パス別のコスト、IP・アカウント・APIキー別の分布を記録します。そのうえで、低リスクなアクセスには通常応答、高リスクなアクセスには遅延、429、チャレンジ、認証要求、最終的な拒否を段階的に適用します。
429を返す場合は、必要に応じてRetry-Afterを含めます。HTTP仕様上、Retry-Afterは429だけでなく503やリダイレクトでも利用されます。[5] APIでは、レスポンスボディに再試行可能時刻、サポート窓口、利用上限の説明を含めると、正当なクライアントが自己修正しやすくなります。
防御ルールは、次の観点で定期的に評価します。
| 評価項目 | 確認する指標 |
|---|---|
| 有効性 | 過剰なリクエスト、帯域、DB負荷、スクレイピング被害の減少 |
| 正確性 | 正当な利用者、検索クローラー、提携先の誤ブロック率 |
| 回復性 | 429後にクライアントが減速し、サービスが正常化するか |
| 運用性 | ルールの理由、期限、担当者、解除手順が追跡できるか |
| 公平性 | 特定の地域、回線、共有IPだけを不当に不利にしていないか |
まとめ
現在のPythonスクレイピングでは、httpxやBeautiful Soupによる軽量取得、Scrapyによる再開可能なクロール、Playwrightによる必要最小限のブラウザ実行を使い分けます。重要なのはライブラリの選択より、robots.txtと利用条件の確認、識別可能なUser-Agent、低い並行性、キャッシュ、429とRetry-Afterへの対応、そして明確な停止条件です。
サイト側のブロックも、単純なIP拒否から、速度、挙動、セッション、APIコスト、WAFを組み合わせたリスクベースの制御へ進んでいます。取得側と防御側の双方が相手の負荷と正当性を考慮すれば、公開データを安全に扱える設計に近づけます。
如何でしたでしょうか? 一時期、証券口座の乗っ取りが問題になったことがありましたが、認証技術の深掘りが必要ですね。
参考文献
1: https://www.rfc-editor.org/info/rfc9309 "RFC 9309: Robots Exclusion Protocol"
2: https://playwright.dev/python/docs/library "Playwright for Python: Getting started - Library"
3: https://docs.python.org/3/library/urllib.robotparser.html "Python Documentation: urllib.robotparser"
4: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429 "MDN: 429 Too Many Requests"
5: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After "MDN: Retry-After header"
6: https://docs.scrapy.org/en/latest/topics/autothrottle.html "Scrapy Documentation: AutoThrottle extension"
7: https://developers.cloudflare.com/waf/rate-limiting-rules/best-practices/ "Cloudflare Documentation: Rate limiting best practices"