Claude Media
Claude CodeでBeautifulSoupのスクレイパーを書く — fixtureでセレクタ崩れを拾う

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. 1

    新しいHTMLを保存する

    人間が snapshot.py を実行して、日付入りの新しいfixtureを tests/fixtures/ に置きます。

  2. 2

    テストの入力を新しいfixtureに切り替える

    FIXTURE のパスを差し替えて pytest -q を実行します。セレクタが外れた箇所が SelectorBroken として表に出ます。

  3. 3

    Claudeに修正を任せる

    失敗メッセージを渡し、parse.py のセレクタだけを直させます。Stopフックがあれば、全テストが通るまで終了しません。

  4. 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の鮮度だけは、テストが保証してくれません。改修に気づく経路(低頻度の確認スクリプトや、本番のエラーログ)を別に持つことが、この構成の前提です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. crummy.com/software/BeautifulSoup/bs4/doc
この記事を共有:XはてブLinkedIn