BeautifulSoupは、HTMLをパースしてWebページからデータを取り出すための、Pythonの定番ライブラリです。ページのダウンロードは行わず(それはrequestsの仕事です)、JavaScriptも実行しません。その代わり、このサイズのライブラリとしては他のどれよりも上手に、現実世界の乱雑なマークアップを、2行のコードで検索できるツリーへ変えてくれます。このチュートリアルでは、pip installから、ページネーションに対応してCSVを書き出す完全なスクレイパーまでを、終始同じサンプルサイトを使って進めます。そして最後に、BeautifulSoupの限界と、その先で何に手を伸ばすべきかを正直にお話しします。本記事はより大きなPythonによるWeb scrapingガイドの1章です。全体像が欲しい方はそちらから始めてください。
セットアップ: bs4・lxml・requestsのインストール
このチュートリアルで必要なのは3つのパッケージだけです。pipでの名前はbs4ではなくbeautifulsoup4である点に注意してください。importはbs4、パッケージはbeautifulsoup4です。
python -m pip install beautifulsoup4 lxml requests
BeautifulSoup自体はHTMLをパースしません。パーサーバックエンドに処理を委ね、その結果の上に扱いやすいAPIを提供します。バックエンドはsoupを構築するときに選びます。
| パーサー | コンストラクタ引数 | 使いどころ |
|---|---|---|
| html.parser | "html.parser" | Python標準で依存関係ゼロ。小さなジョブや簡単なスクリプトには十分。 |
| lxml | "lxml" | C実装ではるかに高速。数ページ以上をスクレイピングするなら、これが標準の選択。 |
| html5lib | "html5lib" | 最も遅いが、壊れたマークアップをブラウザと全く同じように解釈する。病的なHTML向けの最終手段。 |
経験則は2つ。理由がなければ"lxml"を使うこと、そしてパーサーは必ず明示的に渡すことです。省略するとBeautifulSoupはそのマシンにインストールされている中で最良のパーサーを選ぶため、同じスクリプトでも、ひどく壊れたマークアップの解釈がラップトップとサーバーで微妙に変わることがあります。
はじめてのsoup
すべての例でbooks.toscrape.comを使います。スクレイピングの練習用に作られたサンドボックスの書店サイトで、50ページにわたって1,000冊の本が並んでいます。トップページを取得してパースしましょう。
import requests
from bs4 import BeautifulSoup
resp = requests.get("https://books.toscrape.com/")
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
print(soup.title.get_text(strip=True))
# All products | Books to Scrape - Sandbox
ここには意図的な選択がひとつあります。resp.textではなくresp.content(生のバイト列)を渡している点です。このサイトは(現実の多くのサイトと同じく)Content-Typeヘッダーにcharsetを含めないため、resp.textはページを誤ってデコードし、すべての£が£になってしまいます。バイト列を渡せば、BeautifulSoupがドキュメント自体からエンコーディングを推測して正しく処理します。詳しくは下のエラー表で扱います。
soupオブジェクトは、ドキュメント全体を検索可能なツリーにしたものです。ページ上の本はすべてproduct_podクラスのarticleタグの中にあります。この事実ひとつが、以下のすべてを動かします。
findとfind_all
find_allはマッチするすべての要素のリストを返し、findは最初のマッチ、なければNoneを返します。
books = soup.find_all("article", class_="product_pod")
print(len(books)) # 20 — one per book on the page
first = soup.find("article", class_="product_pod")
class_の末尾にアンダースコアが付くのは、classがPythonの予約語だからです。同じ要領で任意の属性でフィルタでき(soup.find_all("a", href=True))、扱いにくい属性名にはdictを渡し(attrs={"data-id": "42"})、limit=5で件数を絞り、recursive=Falseで直下の子だけを検索できます。古いチュートリアルでfindAllを見かけたら、それは4.0以前の非推奨の綴りで、find_allと同じメソッドです。
CSSセレクタ: selectとselect_one
bs4 4.7以降、同梱のsoupsieveライブラリ経由で完全なCSSセレクタがサポートされています。selectはリストを返し、select_oneは最初のマッチかNoneを返します。
books = soup.select("article.product_pod")
first_price = soup.select_one("article.product_pod p.price_color")
print(first_price.get_text(strip=True)) # £51.77
ブラウザのdev toolsの検索窓に書けるものは、ここでもすべて動きます。子孫コンビネータ(section ol li)、属性セレクタ(a[href^="catalogue/"])、擬似クラス(li:nth-of-type(3))などです。find_allとselectの守備範囲はほぼ完全に重なります。入れ子のパスにはセレクタのほうが簡潔で、関数や正規表現でフィルタするならfind_allのほうが楽です。コードの読みやすさのため、プロジェクトごとにどちらかのスタイルへ統一しましょう。
属性とテキスト
タグは属性についてdictのように振る舞います。角括弧は属性がないとKeyErrorを投げますが、.get()は代わりにNoneを返します。
link = first.h3.a # dot access walks to the first matching child
print(link["href"]) # catalogue/a-light-in-the-attic_1000/index.html
print(link["title"]) # A Light in the Attic
print(link.get("rel")) # None — no such attribute, no crash
このページは表示上のリンクテキストを「A Light in the ...」と切り詰めつつ、完全なタイトルをtitle属性に持っています。実際のサイトはこうして属性の中にデータを隠していることが非常に多いので、見えているテキストと格闘する前に属性を確認しましょう。テキストの取得は、空白をまとめてくれるget_text(strip=True)がほぼ常に正解です。素の.get_text()は、入れ子になったタグ間の生の空白をそのまま残します。
親やきょうだいへの移動
選択できる要素が、必要な要素そのものとは限りません。タイトルにはマッチできたが、欲しいのはその隣にある価格、というケースです。すべてのタグは.parent、.children、そしてきょうだい要素へのフックを持っています。
title_link = soup.find("a", title="A Light in the Attic")
pod = title_link.find_parent("article") # climb to the container
price = pod.select_one("p.price_color") # search back down
availability = pod.select_one("p.instock.availability")
print(price.get_text(strip=True), "|", availability.get_text(strip=True))
# £51.77 | In stock
find_parentはマッチするまで上に登り、find_next_sibling / find_previous_siblingは横に移動します。一覧ページで信頼できるパターンは、まさにこのスニペットがやっていることです。繰り返し現れるコンテナを特定し、その内部で検索する — 価格とタイトルを別々のグローバル検索で取ってきて、リストの順番が揃っていることを祈るようなやり方は絶対に避けてください。
実践: 商品リストをCSVへ
1つの一覧ページをdictのリストへスクレイピングし、CSVに書き出す、完全に動作するスクリプトがこちらです。唯一厄介なフィールドは星評価で、このサイトはそれをクラス名(star-rating Three)としてエンコードしています。
import csv
import requests
from bs4 import BeautifulSoup
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def parse_book(pod):
"""Turn one article.product_pod into a dict."""
link = pod.h3.a
rating_classes = pod.select_one("p.star-rating")["class"] # ["star-rating", "Three"]
return {
"title": link["title"],
"price": pod.select_one("p.price_color").get_text(strip=True),
"rating": RATINGS.get(rating_classes[-1]),
"in_stock": "In stock" in pod.select_one("p.availability").get_text(),
"url": link["href"],
}
resp = requests.get("https://books.toscrape.com/")
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
books = [parse_book(pod) for pod in soup.select("article.product_pod")]
with open("books.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=books[0].keys())
writer.writeheader()
writer.writerows(books)
print(f"Wrote {len(books)} books to books.csv")
このスクリプトから盗むべき習慣が2つあります。1つのコンテナだけを担当するparse_book関数(レイアウトが変わっても壊れるのは1つの関数で、スクリプト全体ではありません)、そしてCSVライターに触る前にdictを組み立てること(データ構造を単体でテストできます)です。出力先がCSVファイルではなくスプレッドシートなら、同じdictのリストがWebサイトをExcelへスクレイピングするガイドのワークフローにそのまま流し込めます。
ページネーションとマナー
このサンドボックスには50ページあり、各ページはli.next aで次ページへリンクしています。このリンクが消えるまでたどりましょう。そして礼儀正しく。デフォルトのpython-requests/x.yではなくUser-Agentヘッダーで自分の素性を名乗り、サーバーを叩きすぎないようにリクエスト間でsleepします。多くのサイトはレート制限を課していますし、課していないサイトにも同じ配慮をすべきです。
import csv
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
HEADERS = {"User-Agent": "books-tutorial/1.0 (learning BeautifulSoup)"}
def scrape_all(start_url):
url, books = start_url, []
while url:
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
books += [parse_book(pod) for pod in soup.select("article.product_pod")]
next_link = soup.select_one("li.next a") # None on the last page
url = urljoin(url, next_link["href"]) if next_link else None
time.sleep(1) # be polite
return books
books = scrape_all("https://books.toscrape.com/")
print(f"Scraped {len(books)} books") # 1000
urljoinは見た目以上に重要です。次ページのhrefは相対パス(catalogue/page-2.html)で、しかも(このサイトの本物の癖として)1ページ目と2ページ目の間でパスのプレフィックスが変わります。実際に取得したURLを基準に各リンクを解決すれば自動的に対処できますが、文字列連結では3ページ目で404になります。
よくあるエラーと直し方
BeautifulSoupのデバッグ時間の大半は、次の4つの失敗パターンが占めます。
| エラー | 原因 | 対処 |
|---|---|---|
AttributeError: 'NoneType' object has no attribute 'get_text' | find / select_oneが何にもマッチせず、返ってきたNoneのメソッドを呼んでしまった | 参照する前にNoneをチェックするか、欠損フィールドを明示する: el.get_text(strip=True) if el else None |
データが明らかにあるページでセレクタが[]を返す | クラス名の誤り、コンテンツがiframeの中にある、あるいはJavaScriptでレンダリングされていて生のHTMLに存在しない | resp.text(またはsoup.prettify())を出力して検索する。ブラウザが見せるものではなく、Pythonが実際に取得したものに対してデバッグする |
文字化け: £やアポストロフィになるはずの場所が£や’になる | サーバーがcharsetを省略した(または偽った)ため、resp.textがバイト列を誤ってデコードした。books.toscrape.com自体がこれを起こす | 生のバイト列を渡してbs4にエンコーディングを推測させる: BeautifulSoup(resp.content, "lxml")(本記事のすべてのスクリプトがそうしている)。または先にresp.encoding = resp.apparent_encodingを設定する |
| ローカルでは動くのにサーバーで壊れる | パーサーを指定しなかったため、各マシンがインストール済みの最良のものを使った。壊れたマークアップの修復方法はパーサーごとに異なる | パーサーは常に明示的に渡す: BeautifulSoup(html, "lxml") |
2行目は特に強調しておきます。いずれ全員がこれに引っかかるからです。ブラウザはあなたのスクレイパーではありません。 dev toolsはJavaScript実行後のDOMを表示しますが、requestsが見るのは最初のHTMLレスポンスだけです。両者が食い違うとき、どんなセレクタもあなたを救えません。というわけで、正直な話に移りましょう。
BeautifulSoupだけでは足りないとき
BeautifulSoupには2つの厳然たる限界があり、それを知っておくだけで数日を節約できます。
JavaScriptでレンダリングされるページ。 サイトがコンテンツをクライアントサイドで構築する場合(無限スクロールのフィード、Reactのストアフロント、ダッシュボードなど)、requestsがダウンロードするHTMLはほぼ空の殻で、BeautifulSoupは実際にそこにあるものしかパースできません。先にJavaScriptを実行してくれるヘッドレスブラウザが必要です。モダンなやり方はPlaywrightによるWeb scrapingチュートリアルで(より古いやり方はSeleniumによるWeb scrapingで)解説しています。よくあるハイブリッドは、ブラウザにレンダリングさせてからpage.content()をBeautifulSoupに渡し、すでに知っているパース処理を使う方法です。
大規模なアンチボットシステム。 サンドボックスは決してあなたをブロックしません。しかしAmazonやGoogle、そして大半の商用サイトは、トラフィックが人間らしく見えなくなった瞬間に、CAPTCHA・TLSフィンガープリンティング・IP BANでブロックしてきます。これをDIYで解決するには、プロキシプール、フィンガープリントのローテーション、リトライロジックが必要で、パーサーの横にもう1つのエンジニアリングプロジェクトが生えることになります。代わりの選択肢は、ブロック対策を引き受けてJSONを返す構造化スクレイピングAPIです。そもそもパースすべきHTMLがなくなります。
import requests
resp = requests.get(
"https://api.crawlora.net/api/v1/amazon/search",
params={"k": "mechanical keyboard"},
headers={"x-api-key": "YOUR_API_KEY"},
)
for item in resp.json()["data"]:
print(item["asin"], item["price"], item["title"])
これはCrawloraのAmazon検索エンドポイントで、APIドキュメントに載っているプラットフォーム別エンドポイントのカタログの1つです。課金はpay-on-successで、成功した2xxレスポンスにのみ課金されます。無料枠は月2,000クレジット、カード不要です。HTMLを自分でパースすべきか、それともAPIを呼ぶべきかのより詳しい判断基準はweb scraping vs APIを、料金はPricingをご覧ください。
だからといって、このチュートリアルで作ったものの価値が下がるわけではありません。静的で公開されていて、ほどほどのサイズのサイト(Webの大部分がそうです)に対しては、requestsとBeautifulSoupの組み合わせが今でも最もシンプルに動くツールですし、ここで身につけたスキルはすべて、この先進むブラウザレンダリングやAPI活用のセットアップにそのまま引き継がれます。
難しいサイトのパースはスキップしませんか?
ドキュメント化されたプラットフォーム別エンドポイント、正規化されたJSON、マネージドなプロキシとリトライ。課金は成功した2xxレスポンスのみ。月2,000クレジット無料、カード不要。
関連記事: ピラーガイドPythonによるWeb scraping · JavaScript多用サイト向けのPlaywrightによるWeb scraping · SeleniumによるWeb scraping · WebサイトをExcelへスクレイピング · HTMLパースとは。
よくある質問
BeautifulSoupは何に使いますか?
BeautifulSoupはHTMLやXMLをパースし、そこからデータを抽出するためのPythonライブラリです。マークアップを検索可能なツリーに変換し、find、find_all、CSSセレクタで問い合わせられます。ページのダウンロードは行わず(requestsと組み合わせます)、JavaScriptも実行しません。
BeautifulSoupのfind_allとselectの違いは何ですか?
find_allはPythonの引数でタグ名・クラス・属性を検索し、selectは同梱のsoupsieveエンジン経由でCSSセレクタ文字列を使います。両者の守備範囲はほぼ完全に重なります。入れ子のパスにはセレクタのほうが簡潔で、関数や正規表現のフィルタにはfind_allのほうが楽です。find_allはリストを返し、findとselect_oneは最初のマッチかNoneを返します。
BeautifulSoupはlxmlより速いですか?
いいえ。速いのはlxmlのほうで、BeautifulSoupはlxmlをパーサーバックエンドとして使うことさえできます。BeautifulSoup(html, 'lxml')とすれば、lxmlのC実装の速度をBeautifulSoupの扱いやすいAPIで利用できます。Python標準のhtml.parserは追加インストール不要ですが、大きなジョブでは目に見えて遅くなります。
BeautifulSoupはJavaScriptでレンダリングされるサイトをスクレイピングできますか?
できません。BeautifulSoupはHTTPクライアントがダウンロードしたHTMLをパースするだけで、JavaScriptは一切実行しません。クライアントサイドでレンダリングされるページには、Playwrightのようなヘッドレスブラウザで先にレンダリングし、必要ならそのレンダリング済みHTMLをBeautifulSoupに渡します。あるいは構造化JSONを直接返すスクレイピングAPIを使いましょう。
BeautifulSoupがNoneや空のリストを返すのはなぜですか?
セレクタが間違っているか、スクリプトが取得したHTMLの中にそのデータが存在しないかのどちらかです。後者はJavaScriptでレンダリングされている、あるいはiframeの中にあるケースが典型です。JavaScript実行後のDOMを表示するブラウザのdev toolsではなく、Pythonが実際にダウンロードしたresp.textに対してデバッグしてください。
BeautifulSoupではどのパーサーを使うべきですか?
実際のワークロードでは速度のためにlxmlを、依存関係をインストールできないときはhtml.parserを、ひどく壊れたマークアップをブラウザと同一に扱う必要があるときだけhtml5libを使います。パーサーは必ず明示的に渡してください。省略するとマシンごとに異なるバックエンドが選ばれ、壊れたHTMLの解釈が変わることがあります。
スクレイピングAPIを使うならBeautifulSoupはまだ必要ですか?
対応プラットフォームでは通常不要です。Crawloraのような構造化スクレイピングAPIは正規化されたJSONを返すため、パースすべきHTMLがありません。直接スクレイピングする静的サイトや、ヘッドレスブラウザがレンダリングしたHTMLのパースでは、BeautifulSoupが引き続き役立ちます。
