Python Webスクレイピングガイド:合法性、安定性、保守性を重視するデータ収集
Pythonは公開Webデータの収集に向いています。しかし本番のスクレイピングは、「リクエストしてHTMLを解析する」だけではありません。許可確認、丁寧なアクセス、安定したセレクター、リトライ、データ検証、監視が必要です。
このガイドは、合法性と保守性を重視したデータ収集に絞ります。アクセス制御の回避、ボット対策の突破、CAPTCHA回避、ログイン後や有料ページ、明示的に制限されたデータの収集は扱いません。API、エクスポート、フィード、提携データが提供されている場合は、まず公式手段を使います。
参考ドキュメント:
まず許可を確認する
コードを書く前に、次を確認します。
- データは公開されていて、ログインなしで閲覧できるか。
- API、サイトマップ、RSS、エクスポートが提供されているか。
robots.txtは対象パスのクロールを許可しているか。- 利用規約が自動アクセスや再利用を制限していないか。
- 個人情報、著作物、規制対象データが含まれる可能性はないか。
- クローラーを識別し、連絡先を示せるか。
不明な場合は、スクレイピングから始めないでください。許可を得るか、公式データソースを使います。
ツールの選び方
| ページの種類 | 推奨方法 | 理由 |
|---|---|---|
| 静的HTML | httpx または requests + BeautifulSoup/parsel |
シンプルで速く、軽い |
| 似たページが大量にある | Scrapy | スケジューリング、リトライ、パイプライン、出力が揃っている |
| JavaScriptで描画されるページ | Playwright | クライアント側レンダリングを実行できる |
| APIで取得できるページ | 許可された公開APIを使う | HTML解析より安定して効率的 |
| 一度だけのデータ整理 | Pandas + 保存済みHTML/CSV | 繰り返しリクエストを避けられる |
安定して、丁寧に、目的を満たせる最も単純な方法を選びます。
小さな静的ページスクレイパー
静的ページでは、明示的なタイムアウト、クローラーを識別するUser-Agent、厳密な解析から始めます。
from __future__ import annotations
import time
from dataclasses import dataclass
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
BASE_URL = "https://example.com/articles/"
@dataclass
class Article:
title: str
url: str
summary: str
def fetch_html(url: str) -> str:
headers = {
"User-Agent": "ExampleResearchBot/1.0 (+https://example.com/contact)"
}
with httpx.Client(timeout=15, follow_redirects=True, headers=headers) as client:
response = client.get(url)
response.raise_for_status()
return response.text
def parse_articles(html: str) -> list[Article]:
soup = BeautifulSoup(html, "html.parser")
articles: list[Article] = []
for card in soup.select("article.card"):
title_el = card.select_one("h2 a")
summary_el = card.select_one(".summary")
if not title_el:
continue
title = title_el.get_text(" ", strip=True)
url = urljoin(BASE_URL, title_el.get("href", ""))
summary = summary_el.get_text(" ", strip=True) if summary_el else ""
articles.append(Article(title=title, url=url, summary=summary))
return articles
def main() -> None:
html = fetch_html(BASE_URL)
for article in parse_articles(html):
print(article)
time.sleep(2)
if __name__ == "__main__":
main()
セレクターはテストしやすいものにします。セレクターが壊れたら、誤ったデータを黙って保存するのではなく、件数を減らして問題を記録します。
Playwrightで動的ページを扱う
対象データがJavaScriptで生成され、許可されたAPIやフィードがない場合にPlaywrightを使います。
from playwright.sync_api import sync_playwright
def collect_titles(url: str) -> list[str]:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until="networkidle", timeout=30_000)
titles = [
item.inner_text().strip()
for item in page.locator("article h2").all()
]
browser.close()
return titles
Playwrightは通常のHTTPリクエストより重いです。レンダリングが本当に必要なページだけで使います。同じデータがHTMLや許可された公開APIで取れるなら、ブラウザを起動しないほうがよいです。
Scrapyで規模を広げる
ページ数が多く、リトライ、出力、データパイプラインが必要な場合はScrapyが向いています。
import scrapy
class ArticleSpider(scrapy.Spider):
name = "articles"
allowed_domains = ["example.com"]
start_urls = ["https://example.com/articles/"]
custom_settings = {
"ROBOTSTXT_OBEY": True,
"CONCURRENT_REQUESTS_PER_DOMAIN": 2,
"DOWNLOAD_DELAY": 2,
"AUTOTHROTTLE_ENABLED": True,
"AUTOTHROTTLE_TARGET_CONCURRENCY": 1.0,
"USER_AGENT": "ExampleResearchBot/1.0 (+https://example.com/contact)",
"FEEDS": {
"articles.jsonl": {"format": "jsonlines", "encoding": "utf8"}
},
}
def parse(self, response):
for card in response.css("article.card"):
title = card.css("h2 a::text").get()
href = card.css("h2 a::attr(href)").get()
if title and href:
yield {
"title": title.strip(),
"url": response.urljoin(href),
"source_url": response.url,
}
next_url = response.css("a.next::attr(href)").get()
if next_url:
yield response.follow(next_url, callback=self.parse)
重要なのはSpiderだけではありません。ROBOTSTXT_OBEY、ドメインごとの並列数、ダウンロード間隔、AutoThrottleにより、アクセス量を予測しやすくします。
データ品質パイプライン
収集した生データは、そのまま使えることが少ないです。早い段階で検証と正規化を入れます。
from itemadapter import ItemAdapter
class CleanArticlePipeline:
def process_item(self, item, spider):
adapter = ItemAdapter(item)
title = (adapter.get("title") or "").strip()
url = (adapter.get("url") or "").strip()
if not title or not url:
raise ValueError("Missing title or URL")
adapter["title"] = " ".join(title.split())
adapter["url"] = url
return item
本番では、検証に失敗したデータを別に保存し、セレクター変更に気づけるようにします。
丁寧なクロールのルール
丁寧なクローラーはサイト負荷を減らし、運用もしやすくなります。
robots.txtと利用規約を尊重する。- デフォルトでは低い並列数にする。
- 遅延とAutoThrottleを設定する。
- 開発中はレスポンスをキャッシュする。
- 変わっていないページを繰り返し取得しない。
- 連続エラーでは激しくリトライせず停止する。
- 明確なUser-Agentでクローラーを識別する。
- 連絡先ページまたはメールアドレスを示す。
サイトがクローラーを拒否またはチャレンジする場合、それは境界として扱います。許可なしに制限を回避する実装を進めるべきではありません。
差分クロール
定期実行するジョブでは、毎回すべてを取得しないようにします。
使えるもの:
- サイトマップやフィード。
- 最後に見たURLやタイムスタンプ。
- 対応している場合はETagとLast-Modified。
- 変更検出用のコンテンツハッシュ。
- 永続化された既訪問URLストア。
import hashlib
def content_hash(text: str) -> str:
normalized = " ".join(text.split())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
差分クロールは帯域を節約し、サイト負荷を減らし、データ変更の監査もしやすくします。
監視する指標
本番スクレイパーでは次を記録します。
- 取得ページ数。
- 抽出レコード数。
- 空ページ数。
- HTTPステータス分布。
- リトライ回数。
- 解析失敗数。
- 重複率。
- ジョブ実行時間。
抽出件数が急に落ちた場合、サイトのHTMLが変わったか、クローラーが制限に達した可能性があります。
よくある失敗
壊れやすいセレクター
.container > div:nth-child(3) > span のようなセレクターは簡単に壊れます。意味のある要素、安定した属性、ページ内の構造化データを優先します。
文字エンコーディングを無視する
多言語データは必ずテストしてください。エンコーディングの問題は、タイトル、住所、名前を静かに壊します。
収集と業務ロジックを混ぜる
クロール、解析、検証、保存を分けます。そうすれば一部だけ変更できます。
速すぎる実行
高い並列数はブロック、不完全データ、運用リスクにつながります。速いが壊れやすいクローラーより、遅くても安定したクローラーのほうが価値があります。
再現可能な入力がない
代表的なHTMLレスポンスを保存し、パーサーテストに使います。サイトが変わったときに、新旧のマークアップを比較できます。
最終チェックリスト
クローラーを繰り返し実行する前に確認します。
- 対象データを収集する許可がある。
robots.txtと利用規約を確認した。- クローラーが自分を識別している。
- レート制限と遅延を設定した。
- パーサーテストが代表的なページをカバーしている。
- データ検証で空レコードや不正形式を検出できる。
- 差分クロールで不要なリクエストを避けている。
- ログと指標で失敗が見える。
- 個人情報や機密データを適用ルールに従って扱っている。
信頼できるデータ収集の中心は工程管理です。許可されたデータだけを集め、丁寧にリクエストし、すべてを検証し、実行後も監視します。
ブラウザローカルツールを無料で試す →