BeautifulSoup(bs4)とは、HTMLを読み込んで「欲しい要素を探して取り出す」ためのPythonライブラリです。 ページの取得は requests、解析は BeautifulSoup、と役割を分けて組み合わせるのが基本形です。

この記事では、2026年10月時点の最新版 beautifulsoup4 4.15.0(PyPI で確認)を使い、練習用のHTMLサイトを自分のPCで配信して、一覧ページ→詳細ページ→次ページ→CSV保存までを行う完成スクリプトを実際に動かした結果を載せています。メソッドの対応表、CSSセレクタ早見表に加えて、多くの入門記事で省略されがちな timeout・リトライ・文字化け・例外処理・robots.txt の確認 も、すべて実行結果つきで解説します。JavaScriptで描画されるページを Playwright に切り替える判断基準と最小コードも用意しました。

BeautifulSoupの早見表

やりたいこと 書き方 戻り値(見つからないとき)
HTMLを読み込む soup = BeautifulSoup(html, "html.parser") BeautifulSoup
最初の1件をタグ名で探す soup.find("h1") Tag(None)
すべてをタグ名で探す soup.find_all("li", class_="item") ResultSet=リスト(空リスト)
CSSセレクタで1件 soup.select_one("li.item .price") Tag(None)
CSSセレクタで全件 soup.select("ul.items > li") リスト(空リスト)
テキストを取る tag.get_text(strip=True) str
属性を取る(安全) tag.get("href") str(None)
属性を取る(必須) tag["href"] str(KeyError)
class属性 tag.get("class") list(None)
次の兄弟要素 tag.find_next_sibling("span") Tag(None)
親要素 tag.parent Tag
相対URLを絶対URLに urljoin(page_url, a["href"]) str
文字化けを直す res.encoding = res.apparent_encoding または BeautifulSoup(res.content, ...) —
取得時の待ち上限 requests.get(url, timeout=(3.05, 10)) 超えると例外
robots.txt 確認 RobotFileParser().can_fetch(UA, url) bool
JSで描画されるページ Playwright で描画後 page.content() を渡す —

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Python BeautifulSoupスクレイピング|コピペ用コードとメソッド一覧」

環境と最新バージョンの確認

この記事の検証環境は次のとおりです。 バージョンは 2026-10-01 に PyPI と実行環境で確認しました。

パッケージ バージョン 役割
Python 3.11.15 実行環境(bs4 4.15.0 は Python 3.7 以上が対象)
beautifulsoup4 4.15.0(2026-06-07公開) HTML解析
soupsieve 2.8.3 select() のCSSセレクタ処理(bs4と一緒に入ります)
requests 2.34.2 HTTP取得
urllib3 2.6.3 requests の下回り(リトライ設定に使用)
lxml 6.1.3 高速パーサ(任意)
playwright 1.56.0で動作確認(PyPI最新は1.63.0) JS描画ページ用
pip install beautifulsoup4 requests lxml

パッケージ名は beautifulsoup4、import名は bs4 です。 pip install bs4 でも入りますが、これは beautifulsoup4 を入れるための別名パッケージなので、requirements.txt には beautifulsoup4 と書くのがわかりやすいです。

練習用サイトを自分のPCで用意する

スクレイピングの練習は、まず自分で配信したHTMLで行うと安心です。 相手サーバーに負荷をかけず、何度でも試せます。この記事では次の構成の練習サイトを http://127.0.0.1:7810/ で配信しました。

パス 内容
/list1.html, /list2.html 備品の一覧(5件を2ページに分割、rel="next" のページ送り)
/items/1.html〜/items/5.html 詳細ページ(品番・価格・在庫のテーブル)
/robots.txt /admin/ を禁止、Crawl-delay: 1
/flaky.html 2回目まで 503 を返す(リトライ確認用)
/slow.html 5秒待ってから返す(timeout確認用)
/js.html JavaScriptで一覧を描画(Playwright確認用)

一覧ページ(list1.html)のHTMLはこうなっています。

<!DOCTYPE html>
<html lang="ja"><head><meta charset="utf-8"><title>備品一覧 1ページ</title></head>
<body>
  <h1 id="title">備品一覧</h1>
  <ul class="items">
    <li class="item" data-id="1">
      <a class="item-link" href="/items/1.html">ボールペン(黒)</a>
      <span class="price">120円</span>
      <span class="category">文具</span>
    </li>
    <!-- 2件目・3件目も同じ形 -->
  </ul>
  <nav class="pager"><a class="next" rel="next" href="/list2.html">次へ</a></nav>
</body></html>

詳細ページ(items/1.html)は <table class="spec"> に「品番・価格・在庫」を <th> と <td> で並べた形です。

配信は標準ライブラリだけでできます。 site/ フォルダにHTMLを置いて次のコマンドを実行すると、http://127.0.0.1:7810/ で見られます(503や遅延を返す /flaky.html・/slow.html は、SimpleHTTPRequestHandler を継承して do_GET にパス別の分岐を足して作りました)。

python3 -m http.server 7810 --bind 127.0.0.1 --directory site

この簡易サーバーは Content-Type: text/html を charset なしで返します。 実はこれが、後で説明する「文字化け」をそのまま再現してくれます。

完成スクリプト:一覧→詳細→次ページ→CSV保存

次のスクリプトは、そのままコピーして動く完成形です。 timeout、リトライ、User-Agent、待機、robots.txt、文字化け対策、要素が無いときの対策をすべて入れてあります。

"""scrape.py — 一覧→詳細→次ページをたどって CSV に保存する"""
import csv
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

BASE = "http://127.0.0.1:7810/"
START = urljoin(BASE, "list1.html")
USER_AGENT = "MyStudyBot/1.0 (+mailto:you@example.com)"
TIMEOUT = (3.05, 10)   # (接続, 読み取り) 秒
WAIT = 1.0             # 1リクエストごとの待機秒

def make_session() -> requests.Session:
    retry = Retry(
        total=3,                       # 最大3回まで再試行
        backoff_factor=0.5,            # 間隔を少しずつ広げる
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["GET", "HEAD"],
        respect_retry_after_header=True,
    )
    s = requests.Session()
    s.headers.update({"User-Agent": USER_AGENT})
    s.mount("http://", HTTPAdapter(max_retries=retry))
    s.mount("https://", HTTPAdapter(max_retries=retry))
    return s

def load_robots(base: str) -> RobotFileParser:
    rp = RobotFileParser(urljoin(base, "/robots.txt"))
    rp.read()
    return rp

def fetch_soup(session, rp, url) -> BeautifulSoup | None:
    if not rp.can_fetch(USER_AGENT, url):
        print(f"robots.txt で禁止: {url}")
        return None
    try:
        res = session.get(url, timeout=TIMEOUT)
        res.raise_for_status()
    except requests.RequestException as e:
        print(f"取得失敗: {url} ({e.__class__.__name__}: {e})")
        return None
    finally:
        time.sleep(WAIT)
    # charset がヘッダーに無いと ISO-8859-1 扱いになるため、本文から推定し直す
    if "charset" not in res.headers.get("Content-Type", "").lower():
        res.encoding = res.apparent_encoding
    return BeautifulSoup(res.text, "html.parser")

def text_of(tag) -> str:
    """要素が無いときは空文字(AttributeError を防ぐ)"""
    return tag.get_text(strip=True) if tag else ""

def parse_detail(soup: BeautifulSoup) -> dict:
    spec = {}
    for tr in soup.select("table.spec tr"):
        spec[text_of(tr.th)] = text_of(tr.td)
    return {
        "品番": spec.get("品番", ""),
        "在庫": int(spec.get("在庫", "0") or 0),
    }

def main():
    session = make_session()
    rp = load_robots(BASE)
    rows, url = [], START
    while url:
        soup = fetch_soup(session, rp, url)
        if soup is None:
            break
        for li in soup.select("ul.items > li.item"):
            a = li.select_one("a.item-link")
            detail_url = urljoin(url, a.get("href"))
            price = text_of(li.select_one(".price")).replace(",", "").removesuffix("円")
            row = {
                "id": li.get("data-id"),
                "名前": text_of(a),
                "価格": int(price),
                "カテゴリ": text_of(li.select_one(".category")),
                "URL": detail_url,
            }
            detail = fetch_soup(session, rp, detail_url)
            if detail:
                row.update(parse_detail(detail))
            rows.append(row)
        nxt = soup.select_one('a[rel="next"]')
        url = urljoin(url, nxt["href"]) if nxt else None

    with open("items.csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)
    print(f"{len(rows)}件を items.csv に保存しました")

if __name__ == "__main__":
    main()

実行結果(Python 3.11.15 / bs4 4.15.0):

$ time python3 scrape.py
5件を items.csv に保存しました

real    0m8.286s

できた items.csv:

id,名前,価格,カテゴリ,URL,品番,在庫
1,ボールペン(黒),120,文具,http://127.0.0.1:7810/items/1.html,B-001,7
2,A4コピー用紙 500枚,680,用紙,http://127.0.0.1:7810/items/2.html,B-002,14
3,ホッチキス,450,文具,http://127.0.0.1:7810/items/3.html,B-003,1
4,付箋 75mm,210,文具,http://127.0.0.1:7810/items/4.html,B-004,8
5,クリアファイル 10枚,330,収納,http://127.0.0.1:7810/items/5.html,B-005,15

サーバーのログでは robots.txt → list1 → 詳細3件 → list2 → 詳細2件の順にアクセスがあり、8リクエスト×1秒待機で約8秒でした。 待機を入れると時間はかかりますが、相手サーバーへの負荷を一定に保てます。 time.sleep() を finally に置いているので、取得に失敗したときも必ず間隔が空きます。また encoding="utf-8-sig" で保存すると、Excelで開いても文字化けしないCSVになります。

CSVの書き方をもっと詳しく知りたい場合は、文字コードの扱いも含めて別記事「PythonでCSVを読み書き」で整理しています。

find / select などメソッドの対応表

要素の探し方は「find系」と「select系(CSSセレクタ)」の2系統です。 結果は同じなので、チームで読みやすい方に揃えると保守しやすくなります。

やりたいこと find系 select系
最初の1件 find("li", class_="item") select_one("li.item")
全件 find_all("li", class_="item") select("li.item")
id指定 find(id="title") select_one("#title")
属性指定 find("li", attrs={"data-id": "2"}) select_one('li[data-id="2"]')
子孫をたどる find("ul").find_all("li") select("ul li")
直下の子だけ find("ul").find_all("li", recursive=False) select("ul > li")
複数タグ find_all(["h1", "nav"]) select("h1, nav")
文字列で探す find_all(string=re.compile("ペン")) select(":-soup-contains('ペン')")

取り出し・移動のメソッドは次のとおりです。

メソッド・属性 内容 注意点
get_text() 子孫のテキストをすべて連結 strip=True で前後の空白・改行を除去
get_text(" ", strip=True) 区切り文字を指定して連結 複数の子要素がある li に便利
.string 子が1つだけのときの文字列 子が複数だと None
get("属性") 属性値。無ければ None 第2引数で既定値
["属性"] 属性値。無ければ KeyError 必ずある属性に
.attrs 全属性の辞書 class はリスト
.next_sibling 次の兄弟ノード 改行の文字列が返ることが多い
find_next_sibling() 次の兄弟「要素」 タグ名で絞れる
.parent 親要素 find_parent("table") で祖先を探せる
decompose() 要素を削除 広告枠などを除いてから get_text

実際に練習サイトで動かした結果です。

import requests
from bs4 import BeautifulSoup

res = requests.get("http://127.0.0.1:7810/list1.html", timeout=5)
res.encoding = res.apparent_encoding
soup = BeautifulSoup(res.text, "html.parser")

print("1", soup.find("h1"))
print("2", soup.find("h1").get_text())
print("3", len(soup.find_all("li", class_="item")))
print("4", soup.select_one("li.item .price").get_text())
print("5", [a.get_text() for a in soup.select("a.item-link")])
li = soup.find("li", attrs={"data-id": "2"})
print("6", li["data-id"], li.get("data-id"), li.get("data-x"), li.get("data-x", "なし"))
print("7", li.a.attrs)
print("8", repr(li.a.next_sibling))
print("9", li.a.find_next_sibling("span"))
print("10", li.parent.name, li.parent.get("class"))
print("11", li.get_text(" ", strip=True))
print("12", li.get_text("|", strip=True))
print("13", soup.select_one('a[href$="3.html"]').text)
1 <h1 id="title">備品一覧</h1>
2 備品一覧
3 3
4 120円
5 ['ボールペン(黒)', 'A4コピー用紙 500枚', 'ホッチキス']
6 2 2 None なし
7 {'class': ['item-link'], 'href': '/items/2.html'}
8 '\n'
9 <span class="price">680円</span>
10 ul ['items']
11 A4コピー用紙 500枚 680円 用紙
12 A4コピー用紙 500枚|680円|用紙
13 ホッチキス

8番の結果に注目してください。 next_sibling は <a> の直後にある「改行文字列」を返します。隣のタグが欲しいときは、9番のように find_next_sibling("span") を使うと狙いどおりになります。また7番のとおり class属性はリストで返る ので、文字列として比べたいときは " ".join(tag.get("class", [])) にします。

CSSセレクタ早見表

select() は soupsieve というライブラリでCSSセレクタを解釈します。 ブラウザの開発者ツールで要素を右クリック→「コピー」→「セレクターをコピー」で取ったものも、たいていそのまま使えます(長くなるので短く整えると保守しやすくなります)。

セレクタ 意味 例
tag タグ名 select("li")
.cls class select(".price")
#id id select_one("#title")
A B Aの子孫のB select("ul.items a")
A > B Aの直下のB select("ul > li")
A + B Aの直後の兄弟B select("a.item-link + span")
[attr] 属性を持つ select("a[href]")
[attr="v"] 属性が完全一致 select('a[rel="next"]')
[attr^="v"] 前方一致 select('a[href^="/items/"]')
[attr$="v"] 後方一致 select('a[href$=".pdf"]')
:nth-of-type(n) 同じタグのn番目 select("tr:nth-of-type(2) td")
:first-child / :last-child 最初・最後の子 select("li:last-child")
:not(...) 否定 select("li:not(.sold)")
:has(...) 〜を含む要素 select("li:has(span.price)")
:-soup-contains("文字") テキストを含む(soupsieve独自) select("th:-soup-contains('在庫') + td")

表のセルを「見出しの文字」で探すときは th:-soup-contains('在庫') + td が便利です。 列の順番が変わっても値を取れます(items/3.html で 1 が取れることを確認しました)。

timeout・リトライ・文字化け・例外処理

timeoutを必ず付ける

requests は timeout を指定しないと、応答が来るまで待ち続けます。 タプルで (接続, 読み取り) を分けて指定できます。5秒待ってから返す /slow.html に読み取り2秒で接続した結果です。

import requests
try:
    requests.get("http://127.0.0.1:7810/slow.html", timeout=(3.05, 2))
except requests.exceptions.ReadTimeout as e:
    print("ReadTimeout:", e)
ReadTimeout: HTTPConnectionPool(host='127.0.0.1', port=7810): Read timed out. (read timeout=2)

一時的なエラーはリトライに任せる

urllib3 の Retry を HTTPAdapter に渡すと、503 などのときに自動で再試行します。 2回目まで 503 を返す /flaky.html で、ログを出して確認しました。

import logging, time, requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logging.basicConfig(level=logging.DEBUG, format="%(name)s %(message)s")
s = requests.Session()
s.mount("http://", HTTPAdapter(max_retries=Retry(
    total=3, backoff_factor=0.5, status_forcelist=[503], allowed_methods=["GET"])))
t = time.perf_counter()
r = s.get("http://127.0.0.1:7810/flaky.html", timeout=5)
print("status:", r.status_code, f"elapsed: {time.perf_counter()-t:.1f}s")
(ログから主要な行を抜粋)
urllib3.connectionpool http://127.0.0.1:7810 "GET /flaky.html HTTP/1.1" 503 367
urllib3.connectionpool Retry: /flaky.html
urllib3.connectionpool http://127.0.0.1:7810 "GET /flaky.html HTTP/1.1" 503 367
urllib3.connectionpool Retry: /flaky.html
urllib3.connectionpool http://127.0.0.1:7810 "GET /flaky.html HTTP/1.1" 200 879
status: 200 elapsed: 1.0s

3回目で 200 が返り、呼び出し側は1行の get() のままです。 再試行の間隔は backoff_factor に応じて広がります。requests 自体の使い方(セッション、ヘッダー、エラー処理)は別記事「Pythonのrequestsの使い方」で詳しく扱っています。

文字化けの原因と直し方

文字化けの多くは「サーバーが charset を返さない」ことが原因です。 requests は Content-Type: text/html に charset が無いと、HTTPの仕様に沿って ISO-8859-1 とみなします。練習サーバーで確認しました。

r = requests.get("http://127.0.0.1:7810/list1.html", timeout=5)
print(r.headers["Content-Type"], r.encoding, BeautifulSoup(r.text, "html.parser").h1.text)
r.encoding = r.apparent_encoding
print(r.encoding, BeautifulSoup(r.text, "html.parser").h1.text)

r = requests.get("http://127.0.0.1:7810/sjis.html", timeout=5)   # Shift_JIS のページ
print("bytes渡し:", BeautifulSoup(r.content, "html.parser").p.text, "/ apparent:", r.apparent_encoding)
text/html ISO-8859-1 ååä¸è¦§
utf-8 備品一覧
bytes渡し: 備品棚卸しのお知らせ / apparent: SHIFT_JIS
直し方 書き方 向いている場面
推定し直す r.encoding = r.apparent_encoding ヘッダーに charset が無い
バイト列をそのまま渡す BeautifulSoup(r.content, "html.parser") HTML内に <meta charset> がある(bs4が読んでくれる)
明示する r.encoding = "shift_jis" 文字コードが事前にわかっている

例外処理の型

requests の例外はすべて requests.RequestException を親に持ちます。 まとめて受けるか、種類ごとに分けるかを選べます。

例外 起きる場面 対処
ConnectTimeout 接続できるまでに時間切れ リトライ/時間をおいて再実行
ReadTimeout 応答の途中で時間切れ 読み取りtimeoutを見直す
ConnectionError 名前解決失敗・接続拒否 URL・ネットワークを確認
HTTPError raise_for_status() で4xx/5xx ステータスごとに分岐
RequestException 上記すべての親 ログを出して次へ進む

たとえば存在しないページで raise_for_status() を呼ぶと、HTTPError: 404 Client Error: File not found for url: ... になります(練習サーバーで確認)。

よくあるエラー:AttributeError: 'NoneType'

find() や select_one() は、見つからないと None を返します。 そのまま .get_text() を呼ぶと次のエラーになります。

soup.find("span", class_="stock").get_text()
AttributeError: 'NoneType' object has no attribute 'get_text'

属性を [] で取ったときに属性が無い場合は KeyError です。

soup.select_one("nav.pager a")["title"]
KeyError: 'title'

直す前と後を並べます。

# 直す前:要素が無いページで止まる
stock = soup.find("span", class_="stock").get_text()

# 直した後①:None を確認する
tag = soup.find("span", class_="stock")
stock = tag.get_text(strip=True) if tag else ""

# 属性は get() で既定値を
title = soup.select_one("nav.pager a").get("title", "")

それでも None になるときの確認順は次のとおりです。

確認すること 方法
取得したHTMLにその要素があるか print(res.text[:2000]) や "stock" in res.text
JavaScriptで後から描画されていないか ブラウザで「ページのソースを表示」して要素があるか
class名・タグ名の綴り 開発者ツールで確認
ログイン後・クッキー前提のページではないか ステータスコード・リダイレクト先を確認
パーサの違いで構造が変わっていないか 下の「パーサ比較」を参照

パーサの比較と選び方

BeautifulSoup はHTMLの解析部分(パーサ)を差し替えられます。 崩れたHTMLを3つのパーサに渡した結果です。

from bs4 import BeautifulSoup
broken = "<ul><li>棚<li>机</ul><p>備品<b>一覧</p>"
for parser in ["html.parser", "lxml", "html5lib"]:
    print(f"{parser:11}", BeautifulSoup(broken, parser))
html.parser <ul><li>棚<li>机</li></li></ul><p>備品<b>一覧</b></p>
lxml        <html><body><ul><li>棚</li><li>机</li></ul><p>備品<b>一覧</b></p></body></html>
html5lib    <html><head></head><body><ul><li>棚</li><li>机</li></ul><p>備品<b>一覧</b></p></body></html>

閉じタグの無い <li> を、html.parser は入れ子として、lxml と html5lib は兄弟として解釈しました。 この違いで select("ul > li") の件数が変わることがあります。一覧ページ(約0.9KB)を200回連結したHTMLで解析時間も測りました(5回平均、この環境での目安)。

パーサ 指定 解析時間 特徴 向いている場面
html.parser "html.parser" 757.0 ms 標準ライブラリ、追加インストール不要 まず試す・配布しやすさ重視
lxml "lxml" 486.1 ms 高速、寛容 大量ページ・速度重視
html5lib "html5lib" 1420.3 ms ブラウザと同じ規則で補完 崩れたHTMLをブラウザ通りに読みたい

迷ったら「lxml が入れられるなら lxml、無ければ html.parser」で十分です。ブラウザで見た構造とずれるときだけ html5lib を試します。

スクレイピングのマナーと確認チェックリスト

スクレイピングは、相手サイトのルールとサーバー負荷への配慮が前提です。 取得を始める前と、スクリプトに入れておくことをチェックリストにしました。

タイミング チェック項目 方法
始める前 利用規約で自動取得が認められているか サイトの規約・ヘルプを読む
始める前 公式APIやデータ提供(CSV・RSS)が無いか あればそちらを使う方が安定
始める前 robots.txt で対象パスが許可されているか urllib.robotparser
スクリプト 1リクエストごとに待機(1秒以上が目安、Crawl-delayがあれば従う) time.sleep()
スクリプト timeout を付ける timeout=(3.05, 10)
スクリプト 連絡先のわかる User-Agent headers={"User-Agent": ...}
スクリプト 429・503では間隔を空けて再試行、回数に上限 Retry(...)
運用 HTML構造の変化に気づける 0件のときに通知する

robots.txt の確認は標準ライブラリでできます。練習サイトの robots.txt(Disallow: /admin/、Crawl-delay: 1)での結果です。

from urllib.robotparser import RobotFileParser
rp = RobotFileParser("http://127.0.0.1:7810/robots.txt")
rp.read()
for p in ["list1.html", "admin/users.html"]:
    print(p, rp.can_fetch("MyStudyBot/1.0", "http://127.0.0.1:7810/" + p))
print("crawl_delay:", rp.crawl_delay("MyStudyBot/1.0"))
list1.html True
admin/users.html False
crawl_delay: 1

crawl_delay() の値をそのまま WAIT に使うと、サイト側の希望に合わせられます(None のときは自分で決めた秒数を使います)。

JavaScriptで描画されるページはPlaywrightに切り替える

requests が取れるのは「サーバーから届いた最初のHTML」だけです。 JavaScriptで後から一覧を作るページでは、BeautifulSoup に渡しても要素が見つかりません。そのときはブラウザを動かす Playwright で描画してから、HTMLを BeautifulSoup に渡します。

切り替えの判断基準

確認 requests + bs4 で取れる Playwright が必要
「ページのソースを表示」に目的の文字がある ○ —
ソースには無く、開発者ツールの Elements にだけある — ○
開発者ツールの Network に JSON を返すAPIが見える そのURLを requests で取得(規約を確認のうえ) —
スクロール・クリックで続きが出る — ○
ログイン操作が必要 セッションで再現できれば○ 画面操作が必要なら○

Network タブに JSON が見えるなら、まずはそちらを検討すると軽く済みます。 ブラウザを起動しない分、速く、HTML構造の変化にも左右されにくくなります。

最小コード(ローカルで実行)

pip install playwright
playwright install chromium   # ブラウザ本体のダウンロード(初回のみ)
import requests
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

URL = "http://127.0.0.1:7810/js.html"

# requests だと JavaScript 実行前の HTML しか取れない
soup = BeautifulSoup(requests.get(URL, timeout=5).content, "html.parser")
print("requests :", [li.text for li in soup.select("li.task")])

# Playwright でブラウザに描画させてから BeautifulSoup に渡す
with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto(URL, timeout=15_000)
    page.wait_for_selector("li.task")           # 要素が出るまで待つ
    soup = BeautifulSoup(page.content(), "html.parser")
    browser.close()
print("playwright:", [li.text for li in soup.select("li.task")])

実行結果(playwright 1.56.0 / Chromium headless):

requests : []
playwright: ['棚卸し', '発注', '検品']

js.html は 0.3秒後に <li class="task"> を3つ追加するページです。requests では空リスト、Playwright では3件取れました。 wait_for_selector() で要素が出るまで待つのがポイントで、time.sleep() で固定時間待つより確実です。なお、Playwright のバージョンとブラウザ本体のバージョンは対応しているため、pip install -U playwright で上げたら playwright install chromium もやり直します。E2Eテストとしての Playwright の使い方は別記事「Playwright入門」にまとめています。

よくある質問

Q. find と select はどちらを使えばよいですか? A. 結果は同じなので、好みとチームの統一で選んで大丈夫です。開発者ツールからコピーしたセレクタをそのまま使いたい、ul > li のような関係を短く書きたいときは select、属性やキーワード引数で条件を組み立てたいときは find が書きやすいです。

Q. スクレイピングで取得したデータは自由に使えますか? A. サイトの利用規約、著作権、個人情報の扱いによって変わります。取得の前に利用規約と robots.txt を確認し、公式APIやデータ提供があればそちらを使うのが確実です。判断に迷う使い道は、社内のルールや専門家に確認してから進めると安心です。

Q. 取得が遅いので並列にしてもよいですか? A. 相手サーバーへの負荷が増えるため、同じサイトへは逐次+待機が基本です。複数の別サイトを対象にする場合や、相手が明示的に許可している場合に限って、同時接続数を小さく制限して並列化すると安心です。

まとめ

  • bs4 は「HTMLから要素を探して取り出す」ライブラリ。取得は requests、解析は BeautifulSoup と分けて組み合わせる(最新は 4.15.0)
  • 探し方は find系と select系の2系統。見つからないと None になるので、get_text() の前に確認すると AttributeError を防げる
  • next_sibling は改行文字列を返しやすいので、隣の要素は find_next_sibling() で取る
  • timeout・リトライ・待機・User-Agent・例外処理をセットで入れると、止まらず相手にも配慮したスクリプトになる
  • 文字化けは charset なしの応答が原因になりやすい。apparent_encoding か res.content を渡して解決
  • 利用規約と robots.txt を先に確認し、公式APIがあればそちらを使う
  • JavaScriptで描画されるページは Playwright で描画してから bs4 に渡す。Network に JSON APIが見えるならそちらも検討

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Python BeautifulSoupスクレイピング|コピペ用コードとメソッド一覧」

株式会社RJCでは、Pythonでのデータ取得と解析の技術を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?