2026年7月14日
2026年7月14日
PythonでWebスクレイピングする方法(BeautifulSoup)|requestsでの取得・CSSセレクタ・robots.txtとマナー
Python で Web スクレイピングをするなら、requests でページを取得し、BeautifulSoup の CSS セレクタ(select())で必要な要素を取り出す組み合わせが基本です。この記事では、インストールから、文字化けしない読み込み方、一覧ページから記事タイトルと URL を集めてページ送りをたどるスクリプト、robots.txt の確認とアクセス間隔の取り方、うまく取れないときの原因の切り分けまでを説明します。スクレイピングは相手のサーバーに負荷をかける行為でもあるので、取得前に確認すべきことも合わせて扱います。
取得する前に確認すること
コードを書く前に、次の点を確認します。技術的に取得できることと、取得してよいことは別の問題です。
- 利用規約:サイトによっては自動取得を禁止しています。会員ページやログインが必要な領域の取得は、規約上も技術上も問題になりやすいので避けます。
- robots.txt:サイト運営者がクローラーに対して「ここは取得しないでほしい」と示すファイルです。後述のコードで、取得前に機械的に確認します。
- API があるか:公式の API や RSS フィードがあれば、そちらのほうが安定していて相手の負担も少なくなります。WordPress のサイトなら
/wp-json/wp/v2/postsの REST API で記事一覧を取れることが多いです(サイト側で無効にされている場合もあります)。 - 取得した内容の使い道:集めた文章や画像には著作権があり、人名やメールアドレスなどは個人情報に当たる場合があります。社内での分析と、外部への公開・再配布では扱いが大きく違います。判断に迷う用途なら、事前に専門家に相談してください。
自分が管理するサイトのリンク切れ確認や、記事の title・見出しの棚卸しは、スクレイピングの使い道として扱いやすい例です。
インストールと最初の1ページ
仮想環境を作ってから、2つのライブラリを入れます。
python3 -m venv .venv
. .venv/bin/activate
pip install requests beautifulsoup4
パッケージ名は beautifulsoup4 ですが、import するときの名前は bs4 です。
import requests
from bs4 import BeautifulSoup
res = requests.get("https://example.com/", timeout=(5, 20))
res.raise_for_status()
soup = BeautifulSoup(res.content, "html.parser")
print(soup.title.get_text(strip=True))
for a in soup.select("a[href]"):
print(a.get_text(strip=True), a["href"])
ここで押さえておきたい点が3つあります。
- **
timeoutを指定する**:指定しないと、相手が応答しないときにいつまでも待ち続けます。(接続, 読み込み)の秒数をタプルで渡せます。 - **
raise_for_status()を呼ぶ**:404 や 500 のページでもrequests.get()は例外を出さずに返ります。エラーページを本物のページとして解析しないよう、ここで止めます。 - **
res.textではなくres.contentを渡す**:requestsは、HTTP ヘッダーに文字コードが書かれていない HTML を ISO-8859-1 とみなすため、res.textを使うと日本語のページが文字化けすることがあります。バイト列のres.contentを渡せば、BeautifulSoup が HTML 内の<meta charset>を見て文字コードを判断します。
パーサーの "html.parser" は Python 標準のものです。pip install lxml で lxml を入れて "lxml" を指定すると、大量のページを処理するときに速くなります。崩れた HTML の解釈がパーサーごとに少し違うので、途中で切り替えると結果が変わることがある点は覚えておいてください。
欲しい要素の CSS セレクタを調べる
ブラウザで対象のページを開き、取りたい部分を右クリックして「検証」(開発者ツール)を選ぶと、その要素の HTML が表示されます。たとえば記事一覧が次のような構造だったとします。
<article class="post">
<h2 class="entry-title"><a href="/blog/hello/">はじめての投稿</a></h2>
<time datetime="2026-10-01T09:00:00+09:00">2026年10月1日</time>
</article>
この場合、article.post で記事ごとのかたまりを取り、その中で h2.entry-title a と time を探します。select() は条件に合う要素のリスト、select_one() は最初の1つ(無ければ None)を返します。
セレクタは、見た目の位置ではなく「意味のある class 名や属性」を手がかりに書くと、デザイン変更に強くなります。div > div:nth-child(3) > span のような書き方は、少しレイアウトが変わるだけで取れなくなります。
一覧ページをたどって CSV に保存するスクリプト
一覧ページから記事のタイトル・URL・日付を集め、「次へ」のリンクをたどって最後のページまで進み、CSV に保存する例です。robots.txt の確認、アクセス間隔、一時的なエラーの再試行を組み込んでいます。
#!/usr/bin/env python3
"""ブログの一覧ページから記事タイトル・URL・日付を集めて CSV に保存する"""
import csv
import sys
import time
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
START_URL = "https://example.com/blog/"
USER_AGENT = "example-inventory-bot/1.0 (+https://example.com/contact/)"
WAIT_SECONDS = 3 # 1リクエストごとの待ち時間
MAX_PAGES = 50 # 想定外に長くたどり続けないための上限
def make_session():
s = requests.Session()
s.headers["User-Agent"] = USER_AGENT
retry = Retry(total=3, backoff_factor=2,
status_forcelist=[429, 500, 502, 503, 504],
respect_retry_after_header=True)
s.mount("https://", HTTPAdapter(max_retries=retry))
s.mount("http://", HTTPAdapter(max_retries=retry))
return s
def load_robots(session, url):
rp = RobotFileParser()
res = session.get(urljoin(url, "/robots.txt"), timeout=(5, 20))
if res.status_code in (401, 403) or res.status_code >= 500:
rp.disallow_all = True # 読ませてもらえない・サーバー不調のときは取得しない
elif res.ok:
rp.parse(res.text.splitlines())
else:
rp.parse([]) # robots.txt が無い(404 など)なら制限なし
return rp
def main():
session = make_session()
robots = load_robots(session, START_URL)
wait = max(WAIT_SECONDS, robots.crawl_delay(USER_AGENT) or 0)
rows, url, pages = [], START_URL, 0
while url and pages < MAX_PAGES:
if not robots.can_fetch(USER_AGENT, url):
print(f"robots.txt で許可されていないため停止: {url}", file=sys.stderr)
break
res = session.get(url, timeout=(5, 20))
res.raise_for_status()
soup = BeautifulSoup(res.content, "html.parser")
pages += 1
for item in soup.select("article.post"):
link = item.select_one("h2.entry-title a")
if link is None:
continue # 想定と違う構造の記事は飛ばす
time_tag = item.select_one("time")
rows.append({
"title": link.get_text(strip=True),
"url": urljoin(res.url, link.get("href", "")),
"date": time_tag.get("datetime", "") if time_tag else "",
})
nxt = soup.select_one("a.next") # WordPress のページ送りでよく使われる class
url = urljoin(res.url, nxt["href"]) if nxt and nxt.get("href") else None
if url:
time.sleep(wait)
with open("posts.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["title", "url", "date"])
writer.writeheader()
writer.writerows(rows)
print(f"{pages} ページから {len(rows)} 件を保存しました")
if __name__ == "__main__":
main()
それぞれの処理の理由です。
- User-Agent に連絡先を入れる:相手の管理者がログを見たときに、誰のどんなプログラムかわかるようにするためです。ブラウザのふりをする User-Agent は使いません。
- 待ち時間と上限ページ数:連続アクセスは相手のサーバーに負荷をかけます。数秒の間隔を空け、robots.txt に
Crawl-delayがあれば長いほうに合わせます。MAX_PAGESは、ページ送りの判定を誤って同じページを延々とたどる事故を防ぐための保険です。 - 再試行:
429 Too Many Requestsや503は「今は混んでいる」という合図なので、間隔を広げながら数回だけ再試行します。Retry-Afterヘッダーがあればその秒数に従います。再試行しても失敗する場合は、そこで止めるのが礼儀です。 - **
urljoin()で URL を組み立てる**:hrefは/blog/hello/のような相対パスで書かれていることが多いので、取得したページの URL(res.url)を基準に完全な URL へ変換します。 - **
utf-8-sigで保存する**:BOM 付きの UTF-8 にしておくと、Excel で開いたときに日本語が文字化けしにくくなります。
article.post や a.next は例です。対象サイトの HTML を見て書き換えてください。
うまく取れないときの切り分け
要素が見つからない(select() が空になる)
ブラウザの開発者ツールで見える HTML と、requests が受け取る HTML は同じとは限りません。まず res.content をファイルに保存するか、ブラウザで「ページのソースを表示」して、目的の文字列が含まれているか確認します。
- ソースに含まれている:セレクタの書き間違いです。class 名が複数ある要素(
class="post featured")は.postだけでも一致します。空白を含めた文字列として書いていないか確認してください。 - ソースに含まれていない:JavaScript があとから内容を読み込んでいます。開発者ツールの「ネットワーク」タブで、データを返している JSON の通信を探すと、HTML を解析するより簡単に取れることがあります。それも難しい場合は、Playwright などでブラウザを動かして描画後の HTML を取る方法になりますが、相手への負荷も処理時間も大きくなります。
403 や 429 が返る
403 はアクセスを拒否されている状態、429 はアクセスが多すぎる状態です。待ち時間を延ばし、それでも続く場合は取得をやめて、API の有無や運営者への問い合わせを検討します。ブロックを回避するための偽装や、接続元を変えての再試行は行わないでください。
文字化けする
res.text を使っていないかを確認し、res.content を BeautifulSoup に渡します。それでも化ける場合は、BeautifulSoup(res.content, "html.parser", from_encoding="shift_jis") のように文字コードを明示します。
定期的に取得する場合の注意
同じページを毎日取得するなら、前回から変わっていないページは取り直さない工夫をすると、相手の負担を減らせます。相手が ETag や Last-Modified ヘッダーを返していれば、次回のリクエストで If-None-Match や If-Modified-Since を送り、変更が無ければ 304 Not Modified が返ります。
また、サイトの HTML はデザイン変更で予告なく変わります。取得件数が急に 0 件になったら、それは「データが無い」のではなく「セレクタが合わなくなった」可能性が高いので、件数が想定より少ないときは警告を出すようにしておくと、壊れたまま気づかない状態を避けられます。
ライブラリの使い方やオプションはバージョンによって変わることがあります。この記事のコードは requests 2 系・Beautiful Soup 4 系を前提にしています(2026年10月現在)。
まとめ
- 取得の前に利用規約・robots.txt・API の有無・取得した情報の使い道を確認する
requests.get()にはtimeoutを付け、raise_for_status()でエラーページを除外する。日本語の文字化けを防ぐため、BeautifulSoup にはres.contentを渡す- 要素は意味のある class 名や属性を使った CSS セレクタ(
select()/select_one())で取り出し、相対 URL はurljoin()で変換する - 連絡先入りの User-Agent、数秒の待ち時間、上限ページ数、429/503 での控えめな再試行で、相手のサーバーに配慮する
- 要素が取れないときは、まず受け取った HTML に目的の文字列があるかを確認し、無ければ JavaScript で読み込まれていると考える