Python自動化 #3 — Webスクレイピング ①: httpx と BeautifulSoup で静的ページを収集

毎日ブラウザで開いて確認するページが 1 つくらいはあるはずです。商品の価格、お知らせ、在庫状況のように、内容は毎回違っても確認する動作は同じです。ページを開き、同じ場所を見て、昨日と変わったかを比べます。このように動作が決まっている繰り返しは、コードが代わりにこなせます。今回は httpx で HTML を取得し、BeautifulSoup で目的の部分だけを選び出す静的ページスクレイピングを扱います。

  • #1 スクリプトの第一歩
  • #2 Excel自動化
  • #3 Webスクレイピング ①: 静的ページ収集 ← 今回
  • #4 Webスクレイピング ②: 動的ページ
  • #5 メールと通知
  • #6 スケジューリング
  • #7 CLIツールに仕上げる

HTML がそのままデータです #

ブラウザが見せる画面の裏には、必ず HTML があります。価格表も、お知らせ一覧も、在庫表示も、結局はタグとテキストです。スクレイピングは、この HTML から目的のタグを見つけてテキストを取り出す作業です。

だから最初のステップはコードではなく観察です。収集したいページを開き、目的の値の上で右クリックして「検証」を押すと、開発者ツールの Elements タブが該当タグを見せてくれます。ここで確認することは 2 つです。

  • 目的の値がどのタグの中にあるか (<p>, <span>, <a> など)
  • そのタグを他のタグと区別してくれる目印が何か (class, id, 親の構造)

今回の実習対象は books.toscrape.com です。スクレイピングの練習用に公開された架空の書店サイトなので、安心して練習できます。本 1 冊が article.product_pod タグ 1 つで、その中にタイトルと価格が入っている構造です。

準備: パッケージ 2 つ #

プロジェクトに httpxbeautifulsoup4 を追加します。

依存関係の追加
uv add httpx beautifulsoup4

httpx は HTTP リクエストを送るライブラリです。長く使われてきた requests と API がほぼ同じでありながら、デフォルトのタイムアウトが組み込まれていて、HTTP/2 と非同期までサポートするので、これから始めるなら httpx をおすすめします。beautifulsoup4 は、取得した HTML をパースしてタグ単位で探索できるようにするライブラリです。

httpx でページをリクエストする #

fetch.py
import httpx

headers = {
    "User-Agent": "Mozilla/5.0 (compatible; book-scraper/1.0)"
}

resp = httpx.get(
    "https://books.toscrape.com/",
    headers=headers,
    timeout=10.0,
)
resp.raise_for_status()

print(resp.status_code)   # 200
print(resp.text[:300])    # HTML の先頭部分

3 点を押さえます。

  • User-Agent: リクエストを送るとき「誰がリクエストしたか」を知らせるヘッダーです。デフォルトのままだと python-httpx/0.x として送られ、これをブロックするサイトがあります。自分を識別できる文字列を入れておくのがよいです。
  • timeout: 応答がないとき無限に待たないよう、制限時間を設けます。httpx はデフォルトで 5 秒が設定されていますが、明示しておくと意図がはっきりします。
  • raise_for_status: 404 や 500 のような失敗の応答なら例外を投げます。失敗した HTML を気づかずにパースする事故を防いでくれます。

BeautifulSoup で選び出す #

取得した HTML 文字列を BeautifulSoup に渡すと、タグ単位で探索できるオブジェクトになります。探索方法はいろいろありますが、CSS セレクタを使う select 1 つでほぼすべて解決します。

parse.py
from bs4 import BeautifulSoup

soup = BeautifulSoup(resp.text, "html.parser")

books = soup.select("article.product_pod")   # 本 1 冊 = article 1 つ
print(len(books))   # 20

first = books[0]
title = first.select_one("h3 a")["title"]            # 属性から取り出す
price = first.select_one("p.price_color").text       # テキストとして取り出す
print(title, price)

select は条件に合うタグをすべてリストで、select_one は最初の 1 つだけを返します。よく使う CSS セレクタはこの程度で十分です。

  • article.product_pod: class が product_podarticle タグ
  • #content: id が content のタグ
  • h3 a: h3 の内側のどこかにある a タグ
  • ul > li: ul の直下の子 li
  • a[href]: href 属性を持つ a タグ

開発者ツールで見た構造をセレクタに写し、select の結果の数が画面で数えた数と合うかを確認します。この過程こそがスクレイピングの本体です。

完成例: タイトルと価格を CSV に #

リクエスト、パース、保存を関数に分けて 1 ファイルにまとめます。

scrape.py
import csv

import httpx
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; book-scraper/1.0)"}


def fetch(url: str) -> str:
    resp = httpx.get(url, headers=HEADERS, timeout=10.0)
    resp.raise_for_status()
    return resp.text


def parse(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    rows = []
    for book in soup.select("article.product_pod"):
        rows.append({
            "title": book.select_one("h3 a")["title"],
            "price": book.select_one("p.price_color").text.lstrip("£"),
        })
    return rows


def save(rows: list[dict], path: str) -> None:
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "price"])
        writer.writeheader()
        writer.writerows(rows)


if __name__ == "__main__":
    rows = parse(fetch("https://books.toscrape.com/"))
    save(rows, "books.csv")
    print(f"{len(rows)}冊を保存しました")
実行
uv run scrape.py
# 20冊を保存しました

books.csv を開くと、タイトルと価格が表に整理されています。#2 で扱った Excel 自動化とつなげれば、収集からレポート整理まで一気に流れるパイプラインになります。

ページネーションの巡回 #

一覧が複数ページにまたがっているなら、URL のパターンを見つけて巡回します。books.toscrape.com は catalogue/page-2.html という形式でページが続きます。

複数ページの収集
import time

BASE = "https://books.toscrape.com/catalogue/page-{}.html"

all_rows = []
for page in range(1, 6):              # まずは 1〜5 ページのみ
    html = fetch(BASE.format(page))
    all_rows.extend(parse(html))
    print(f"{page}ページ収集、累計 {len(all_rows)}冊")
    time.sleep(1)                     # リクエスト間に 1 秒待機

save(all_rows, "books_all.csv")

ポイントは time.sleep(1) です。人間は 1 秒に 1 ページもめくれませんが、コードは止めなければ毎秒数十件をリクエストします。相手のサーバーから見れば攻撃と区別がつきません。リクエストの間に間隔を置くことは、選択ではなく基本です。

守るべき一線 #

技術的に可能なことと、やってよいことは別です。スクレイピングのコードを回す前に、次を確認します。

  • robots.txt: サイトのアドレスの後ろに /robots.txt を付けると、サイトが自動収集について示した方針が見られます。Disallow に指定されたパスは収集せず、Crawl-delay があればその間隔を守ります。
  • 利用規約: 自動収集を明示的に禁止するサービスがあります。ログインが必要なページなら、特に規約を先に確認する必要があります。
  • リクエスト頻度: 間隔を空けずに大量のリクエストを送ると、相手のサーバーに実際の負担をかけます。sleep を入れ、必要なページだけ取得します。
  • 個人の自動化とサービス化の違い: 自分の確認作業を 1 日 1 回代行するスクリプトと、収集したデータを再配布したりサービスとして提供したりすることは、まったく別の問題です。後者は著作権と法的な検討が必要な領域なので、このシリーズの範囲を超えます。

取得した HTML が空っぽなら #

この方式が通用しないページもあります。応答は 200 なのに、肝心のデータが HTML にない場合です。

動的ページの応答
resp = httpx.get("https://example-spa.com/products")
print(resp.text)
# <div id="root"></div> だけで商品データはない

こうしたページは、サーバーが空の骨組みだけの HTML を返し、ブラウザが JavaScript を実行したあとでデータが埋め込まれる構造です。httpx は JavaScript を実行しないので、空の骨組みだけを受け取ります。開発者ツールには見えるのに resp.text にはない、という場合がこれです。このときはブラウザそのものをコードで操縦する必要があり、その方法は次回扱います。

まとめ #

今回作った流れです。

  • 開発者ツールで目的の値のタグと class を観察
  • httpx.get に User-Agent と timeout を明示し、raise_for_status で失敗を遮断
  • BeautifulSoupselect と CSS セレクタで目的のタグだけを抽出
  • タイトルと価格を取り出して CSV に保存する完成スクリプト
  • URL パターンの巡回 + time.sleep で複数ページを収集
  • robots.txt、利用規約、リクエスト頻度という守るべき一線

次回(#4 Webスクレイピング ②: 動的ページ)では、JavaScript がデータを描画するページを扱います。Playwright で実際のブラウザを立ち上げ、レンダリングが終わった画面からデータを取り出す方法を整理します。

X