Claude CodeでBeautifulSoupのスクレイパーを書く — fixtureでセレクタ崩れを拾う
取得済みのHTMLを固定入力にして、サイト改修で壊れるセレクタをpytestで検出するBeautifulSoupスクレイパーの作り方。None判定、class属性のリスト化、パーサー差、文字コードの落とし穴まで。
Claude CodeにBeautifulSoupのスクレイパーを書かせるときは、「一度取得したHTMLをfixtureとして保存し、パース処理はそのファイルだけを入力にテストする」構成にすると安定します。ネットワークに出ないので、テストは何度回しても同じ結果になります。サイトが改修されたら、新しいHTMLを保存し直してテストを回すだけで、壊れたセレクタが失敗として表に出ます。
スクレイピングのコードは、動いているうちは何も言いません。壊れるのは相手のサイトが変わった日で、しかも多くの場合は例外ではなく「空の結果」として静かに壊れます。この記事は、その静かな壊れ方をBeautifulSoup 4.15.0(Python 3.12)で実際に確かめたうえで、テストで拾う構成と、Claude Codeに完了条件として持たせる設定を示します。サーバー側のHTMLに結び付いたjQueryの依存は、jQuery資産のDOM依存の表で洗えます。
なぜfixtureテストがスクレイパーに向くのか
Claude Codeの公式ドキュメントは、Claudeに「合否が返ってくる確認手段」を渡すことを勧めています。テストファイルや、出力をfixtureと突き合わせるスクリプトが、その例として挙がっています。手段があれば、Claudeは変更を加えて実行し、失敗したら出力を読んで直し、もう一度実行するところまで自分で進められます。
スクレイパーでこの手段を作るとき、ライブのサイトに毎回アクセスする方式は向きません。理由は3つです。
- 相手のサーバーに負荷をかけ、ブロックされる原因になる
- 結果が日によって変わり、失敗がコードのせいか相手のせいか区別できない
- Claudeが修正と再実行を繰り返すたびにアクセスが走る
保存したHTMLなら、この3つがすべて消えます。そのかわり、保存した時点より後のサイト変更は検出できません。ここは次の節で、取得と検証を分けて考えます。
役割を二つに分ける
スクレイパーを「取得」と「パース」に分けます。取得は、URLからHTMLのバイト列を取ってファイルに保存するだけの小さな関数です。パースは、HTMLを受け取って構造化データを返す純粋関数です。テストはパースだけを対象にします。
scraper/
fetch.py # 取得(ネットワークに出るのはここだけ)
parse.py # HTML -> Item のリスト(純粋関数)
snapshot.py # fixture を保存する小さな CLI
tests/
fixtures/
list_2026-10-27.html
test_parse.pyサイト改修への対応は、snapshot.py で新しいHTMLを保存し、test_parse.py が落ちる箇所を直す流れになります。取得を単独で動かす場面は、この流れの最初の1回だけです。
fixtureはバイト列のまま保存する
fixtureの保存で見落としやすいのが文字コードです。BeautifulSoupは、バイト列を渡されたときに文書の文字コードを自動判定します。判定結果は soup.original_encoding で確認でき、すでにUnicode文字列を渡した場合は None になります。
手元で、Shift_JISで書かれたHTMLを両方の形で渡してみた結果が次です。
from bs4 import BeautifulSoup
raw = '<html><head><meta charset="shift_jis"></head>' \
'<body><h1>日本語の商品</h1></body></html>'.encode("shift_jis")
soup = BeautifulSoup(raw, "html.parser")
print(soup.original_encoding, soup.h1.string)
# shift_jis 日本語の商品
soup = BeautifulSoup(raw.decode("shift_jis"), "html.parser")
print(soup.original_encoding)
# None本番のスクレイパーが response.content(バイト列)をBeautifulSoupに渡しているなら、fixtureもバイト列で保存して Path.read_bytes() で読み込みます。fixtureだけ read_text(encoding="utf-8") で文字列にすると、本番では文字コードの判定が走り、テストでは走らない、という差が生まれます。判定を誤るサイトでは、本番だけ文字化けします。
文字コードを指定して固定したい場合、公式ドキュメントは from_encoding を渡す方法を示しています。判定が誤る例として、短い文書がISO-8859-8なのにISO-8859-7と判定される場合が載っています。本番で from_encoding を使うなら、テストも同じ引数を通します。
保存用のスクリプトは次のとおりです。レスポンスを加工せず、取得日をファイル名に入れます。
# scraper/snapshot.py
import sys
from datetime import date
from pathlib import Path
import requests
def main(url: str, name: str) -> None:
res = requests.get(url, timeout=10, headers={"User-Agent": "my-scraper/0.1"})
res.raise_for_status()
out = Path("tests/fixtures") / f"{name}_{date.today()}.html"
out.write_bytes(res.content)
print(out)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])保存は手動で、サイトの利用規約とrobots.txtを確認してから行います。fixtureに会員情報やセッションIDなどが含まれていないかも、コミット前に目で確かめてください。
静かに壊れる4つの箇所を確かめる
テストで何を拾うのかを決めるために、BeautifulSoupが「失敗を知らせない」場面を分類します。いずれも公式ドキュメントに記載のある挙動です。手元でも再現しました。
セレクタが外れたときの現れ方
find_allとselectは空リストを返す
find_all()は何も見つからないと空のリストを返します。select()も同様に、一致なしでは空のリストです。forで回すコードは例外を出さず、0件の結果として正常終了します。findとselect_oneはNoneを返す
find()は見つからなければNoneを返します。select_one()も同じです。次の行で.get_text()を呼んで初めて、AttributeErrorが出ます。classはリストで返る
classは複数値属性として扱われ、['title', 'big']のようなリストで返ります。node["class"] == "title"の比較は常に偽になります。パーサーで木が変わる
壊れたHTMLは、パーサーごとに違う木になります。本番とテストで別のパーサーを使うと、テストだけが通ります。
手元の出力で確かめると、次のとおりです。
from bs4 import BeautifulSoup
s = BeautifulSoup('<a class="title big" href="/x">t</a>', "html.parser")
print(s.select_one("a")["class"], s.select_one("a.nothing"))
# ['title', 'big'] None
s.select_one("a.nothing").get_text()
# AttributeError: 'NoneType' object has no attribute 'get_text'
s = BeautifulSoup('<div><p class="price">1</p></div>', "html.parser")
print(s.select("p.nothing"), s.find("nothing"))
# [] None壊れたHTMLを3つのパーサーに渡した結果も並べます。<p>one<p>two<table><td>x という入力です。
| パーサー | 結果 |
|---|---|
html.parser | 結果<p>one<p>two<table><td>x</td></table></p></p> |
lxml | 結果<html><body><p>one</p><p>two</p><table><td>x</td></table></body></html> |
html5lib | 結果未インストールの環境では FeatureNotFound |
html.parser は <p> を入れ子にし、lxml は兄弟にします。p > p のようなセレクタは、パーサーによって当たったり外れたりします。公式ドキュメントも、スクリプトを複数のマシンで動かすなら、コンストラクターでパーサーを指定するよう書いています。本番もテストも同じ文字列を渡し、依存を requirements.txt に固定します。lxml を使うなら、そのバージョンもテストの前提に含まれます。
パース関数に「壊れたと分かる」仕組みを入れる
4つの壊れ方のうち、空リストとNoneは、パース関数の側で防げます。セレクタが外れたら、黙って空を返さず、どのセレクタが外れたかを名指しして例外にします。
# scraper/parse.py
from dataclasses import dataclass
from bs4 import BeautifulSoup
@dataclass
class Item:
title: str
url: str
price: int
class SelectorBroken(Exception):
"""サイト構造が変わった可能性。どのセレクタかをメッセージに含める。"""
def one(root, css: str):
node = root.select_one(css)
if node is None:
raise SelectorBroken(f"セレクタに一致なし: {css}")
return node
def parse_list(html: bytes) -> list[Item]:
soup = BeautifulSoup(html, "html.parser")
cards = soup.select("li.item")
if not cards:
raise SelectorBroken("li.item が 0 件")
items = []
for c in cards:
a = one(c, "a.title")
price = one(c, "span.price").get_text(strip=True)
items.append(Item(
title=a.get_text(strip=True),
url=a["href"],
price=int(price.replace("¥", "").replace(",", "")),
))
return itemsポイントは one() と、cards が空のときの例外です。2件のカードを持つ小さなfixtureを渡し、span.price のclass名を cost に変えたHTMLを入れると、手元では次のようになりました。
正常: [Item(title='Alpha', url='/p/1', price=1200), Item(title='Beta', url='/p/2', price=980)]
改修後: SelectorBroken: セレクタに一致なし: span.price例外メッセージにセレクタが入っているので、Claudeも人間も、どこを直すかがログから分かります。空のリストを黙って返す実装なら、この失敗は「今日は商品が0件」という業務上の事実に見えてしまいます。
件数と型の確認を足す
セレクタが外れなくても、取れる値が変わることがあります。価格の表記が「¥1,200」から「1,200円」に変われば、int() が ValueError を投げます。これは例外で気づけるぶん、まだ良い方です。厄介なのは、別の要素に当たってしまい、値は取れるが意味が違う場合です。fixtureに「期待値」を持たせて、それとの一致を見ます。
pytestでセレクタ崩れを検出する
テストは2層に分けます。1層目は、保存したfixtureから期待どおりの値が取れること。2層目は、fixtureをわざと壊して、パース関数が SelectorBroken を出すことです。
# tests/test_parse.py
from pathlib import Path
import pytest
from scraper.parse import parse_list, SelectorBroken, Item
FIXTURE = Path(__file__).parent / "fixtures" / "list_2026-10-27.html"
@pytest.fixture
def html() -> bytes:
return FIXTURE.read_bytes()
def test_fixtureから2件取れる(html):
assert parse_list(html) == [
Item("Alpha", "/p/1", 1200),
Item("Beta", "/p/2", 980),
]
@pytest.mark.parametrize("old,new", [
(b'class="price"', b'class="cost"'),
(b'class="title"', b'class="name"'),
(b'class="item"', b'class="card"'),
])
def test_セレクタが外れたら例外になる(html, old, new):
assert old in html # 置換対象がfixtureに無いと検査が空振りする
with pytest.raises(SelectorBroken):
parse_list(html.replace(old, new))2層目の assert old in html が要点です。置換元の文字列がfixtureにないと、置換は何も起こさず、テストは「壊したつもり」で通ってしまいます。サイト改修でfixtureを差し替えたときも、このassertが先に落ちるので、テスト自体の更新が必要だと分かります。
この構成は、リファクタリング前の挙動を固定する特性テストと同じ発想です。入力を固定してから変更に入る考え方は、Claude Codeで特性テストを書くにまとめています。
Claude Codeに渡す指示と完了条件
公式ドキュメントは、CLAUDE.mdに書くのは「リポジトリを読んでも分からないこと」に絞るよう勧めています。スクレイパーでそれに当たるのは、取得を勝手に走らせない約束と、テストの実行コマンドです。
# Commands
- テスト: `pytest -q`
# Conventions
- パース関数は `scraper/parse.py`。ネットワークには出ない。
- テストの入力は `tests/fixtures/` のHTMLだけ。fixtureはバイト列で読む。
- BeautifulSoupのパーサーは "html.parser" 固定。
# Gotchas
- `requests.get` を実行しない。fixtureの取得は人間が `snapshot.py` で行う。
- セレクタの不一致は空リストにせず `SelectorBroken` を投げる。あわせて、完了の条件を機械に持たせます。公式ドキュメントのStopフックの例は、型検査が通るまでClaudeが終了できないようにするもので、コマンドが終了コード2で終われば、標準エラー出力がClaudeに戻される作りです。同じ形で、pytest に置き換えられます。
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "pytest -q 1>&2 || exit 2" }
]
}
]
}
}設定は .claude/settings.json に書きます。公式ドキュメントによれば、フックが続けて8回停止をブロックすると、次のブロックは上書きされてターンが終わります。上限は環境変数 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変えられます。
依頼文は、期待する検証まで含めて書きます。例えば次のような形です。
tests/fixtures/list_2026-10-27.html を入力に、商品一覧のパース関数を scraper/parse.py に書いて。
セレクタが外れたときは SelectorBroken を投げる。
pytest -q を実行して、結果の出力を見せて。公式ドキュメントは、完了の報告だけでなく、実行したコマンドとその出力を求めるよう勧めています。「テストが通りました」の一言では、何が通ったのか分からないためです。
サイトが改修されたときの手順
fixtureテストは、保存した時点のHTMLに対する検査です。本物のサイトが変わっても、保存済みfixtureのテストは通り続けます。そこで、改修への対応は次の順で進めます。
サイト改修に追従する流れ
- 1
新しいHTMLを保存する
人間が
snapshot.pyを実行して、日付入りの新しいfixtureをtests/fixtures/に置きます。 - 2
テストの入力を新しいfixtureに切り替える
FIXTUREのパスを差し替えてpytest -qを実行します。セレクタが外れた箇所がSelectorBrokenとして表に出ます。 - 3
Claudeに修正を任せる
失敗メッセージを渡し、
parse.pyのセレクタだけを直させます。Stopフックがあれば、全テストが通るまで終了しません。 - 4
古いfixtureとの互換を残すか決める
旧サイトの形式を扱い続ける必要がなければ、古いfixtureは削除します。残すなら、テストをfixtureごとにparametrizeします。
改修の検知そのものを自動化したい場合は、週に1回など低頻度で、本番に1リクエストだけ出す別のスクリプトを用意します。その結果をfixtureと比べる処理は、通常のテストとは分けて動かします。通常のテストにネットワークを混ぜると、冒頭の3つの問題が戻ってきます。
つまずきやすい点
fixtureが大きすぎる。ページ全体を保存すると、数百KBになることがあります。テストの目的は構造の検査なので、商品カードが2〜3件残るように手で刈り込んだ最小のHTMLも用意すると、diffが読みやすくなります。ただし刈り込みは、本物のHTMLの構造(親要素のclassなど)を壊さない範囲にとどめます。
セレクタが長すぎる。 body > div:nth-child(3) > ul > li のような位置に頼るセレクタは、広告が1つ増えただけで外れます。意味のあるclassやdata属性を優先するようClaudeに伝えます。ただし、そのclassが相手サイトの内部実装由来なら、改修の影響を受けやすい点は変わりません。
ページ数が多い。数百ページを巡回するなら、BeautifulSoupとrequestsの手書きより、フレームワークの方が礼儀(robots.txtと取得間隔)を設定で持たせやすくなります。その構成はClaude CodeでScrapyのスクレイパーを作る手順で扱っています。
JavaScriptで描画されるページ。 requests で取れるHTMLにデータがない場合、BeautifulSoupだけでは足りません。ブラウザを動かす方式に切り替える必要があります。
まとめ
スクレイパーのテストでは、「取得」を人間の手元の操作に寄せ、パースを保存済みHTMLの純粋関数にします。セレクタの不一致は空リストにせず例外にし、fixtureをわざと壊すテストで、その例外が出ることまで確かめます。完了の条件を pytest -q のStopフックにしておけば、Claudeはセレクタを直すたびに、自分で結果を確かめながら進められます。
fixtureの鮮度だけは、テストが保証してくれません。改修に気づく経路(低頻度の確認スクリプトや、本番のエラーログ)を別に持つことが、この構成の前提です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。