HTMLの構造を理解し、狙った要素だけを取り出す技術はWebスクレイピングの基礎です。
本記事ではPythonのBeautiful Soupを用いて、HTMLの解析とデータ抽出を段階的に解説します。
CSSセレクタの使いこなしや要素が無い時の安全な扱い、文字化け対策まで、初心者でも実務で使えるレベルを目指します。
Beautiful Soup入門
Beautiful Soupとは
Beautiful Soupは、HTMLやXMLを扱いやすいPythonのライブラリです。
生のHTMLは階層構造を持つテキストですが、Beautiful Soupはそれをツリーとして扱えるようにし、タグ検索やテキスト抽出、属性取得を直感的に実装できます。
Python初心者でも短いコードで目的の要素にアクセスできる点が最大の魅力です。
なお、実際のWebからの取得にはrequests
を組み合わせるのが一般的です(取得方法の詳細は別記事で取り上げます)。
インストール(pip)と準備
最小構成はbeautifulsoup4
だけでも動作しますが、パーサにlxml
を入れると高速かつ堅牢になります。
HTMLの取得にrequests
も併せて導入しておくと便利です。
# 基本ライブラリのインストール
pip install beautifulsoup4
# 高速なパーサ(lxml)を推奨
pip install lxml
# HTML取得用(requests)
pip install requests
ポイントとして、BeautifulSoup(html, "lxml")
のように明示的にパーサを指定すると安定します。
パーサ未指定やデフォルト依存は環境差の原因になるため避けると良いです。
基本の使い方
HTMLを取得・読み込む
ここでは説明のためにhttps://example.com/
を取得します。
実際のサイトではアクセス間隔や利用規約の確認が必要ですが、本記事では取得そのものの解説には踏み込みません。
# requestsでHTMLを取得してテキストとして読み込みます
import requests
url = "https://example.com/"
headers = {
"User-Agent": "Mozilla/5.0 (compatible; BS4-Guide/1.0)"
}
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status() # ステータスコードが4xx/5xxなら例外
# 文字化け対策: 推定エンコーディングを使う
resp.encoding = resp.apparent_encoding or "utf-8"
html = resp.text
print("status:", resp.status_code)
print("length:", len(html))
print("title-snippet:", html.split("</title>")[0][-60:])
status: 200
length: 1256
title-snippet: Example Domain</title>
取得フェーズと解析フェーズを分けると、デバッグや再現性が高まります。
次節で解析用オブジェクトを作ります。
soupを作る
Beautiful SoupでHTMLをパースしてsoup
オブジェクトを作成します。
from bs4 import BeautifulSoup
# 例: 先ほど取得したhtmlを使う
soup = BeautifulSoup(html, "lxml") # 推奨: 高速で壊れたHTMLにも強い
# 代替: soup = BeautifulSoup(html, "html.parser") # 標準ライブラリのパーサ
# タイトルの確認
title = soup.title.get_text(strip=True) if soup.title else "(no title)"
print("parsed title:", title)
parsed title: Example Domain
パーサの選択は品質に直結します。
サイトによってはhtml.parser
で正しく読めないことがあり、その場合はlxml
の利用が有効です。
find/find_allで要素を取得
最も基本的な検索はfind
とfind_all
です。
サンプルHTMLを用いて挙動を見てみます。
from bs4 import BeautifulSoup
sample_html = """
<!doctype html>
<html lang="ja">
<head><meta charset="utf-8"><title>デモページ</title></head>
<body>
<header id="site-header">
<h1>デモサイト</h1>
<nav class="global-nav">
<ul>
<li><a href="/home" data-track="nav">ホーム</a></li>
<li><a href="/about" data-track="nav">このサイトについて</a></li>
<li><a href="https://example.com/docs/guide.pdf" data-track="nav">ガイドPDF</a></li>
</ul>
</nav>
</header>
<main>
<article class="post">
<h2 class="title">一番目の記事</h2>
<p class="meta">カテゴリ: <span class="cat">Python</span></p>
<p class="lead">Beautiful Soupの紹介です。</p>
<a class="readmore" href="https://example.com/post1" data-id="p1">続きを読む</a>
</article>
<article class="post featured">
<h2 class="title">二番目の記事</h2>
<p class="meta">カテゴリ: <span class="cat">Web</span></p>
<p class="lead">CSSセレクタで探します。</p>
<a class="readmore" href="/post2" data-id="p2">続きを読む</a>
<p class="note">補足の段落です。</p>
<p class="note">さらに補足です。</p>
</article>
<section id="links">
<ul class="links">
<li><a href="https://example.com" target="_blank" rel="noopener">公式サイト</a></li>
<li><a href="http://example.org/resource" rel="nofollow">リソース</a></li>
<li><a href="/file/report.pdf">レポートPDF</a></li>
</ul>
</section>
</main>
<footer><small>© 2025 Demo</small></footer>
</body>
</html>
"""
soup = BeautifulSoup(sample_html, "lxml")
# 1件だけ取得
h1 = soup.find("h1")
print("h1:", h1.get_text(strip=True))
# 複数件取得
links = soup.find_all("a")
print("aタグの数:", len(links))
# 属性で絞り込む(classはclass_で指定)
first_post = soup.find("article", class_="post")
print("最初のarticleのclass:", first_post.get("class"))
# カスタム属性(data-id)で絞り込む
readmore = soup.find("a", attrs={"data-id": "p2"})
print("data-id=p2のテキスト:", readmore.get_text(strip=True))
h1: デモサイト
aタグの数: 8
最初のarticleのclass: ['post']
data-id=p2のテキスト: 読み続ける
findは最初の1件、find_allはリストを返します。
クラス検索はclass_
引数を使う点に注意します。
テキストと属性を取り出す
要素からテキストやリンク(URL)を取り出す方法です。
from bs4 import BeautifulSoup
soup = BeautifulSoup(sample_html, "lxml")
# テキスト抽出: get_textは子孫の文字列も含めて結合
title_text = soup.find("h2", class_="title").get_text(strip=True)
print("最初のh2.title:", title_text)
# 属性の取得: dict風アクセス or get
link_tag = soup.find("a", class_="readmore")
print("readmoreのhref(辞書アクセス):", link_tag["href"])
print("readmoreのhref(get, デフォルトあり):", link_tag.get("href", "N/A"))
# テキストと属性をまとめて
print("テキストと属性:", {
"text": link_tag.get_text(strip=True),
"href": link_tag.get("href"),
"data-id": link_tag.get("data-id")
})
最初のh2.title: 一番目の記事
readmoreのhref(辞書アクセス): https://example.com/post1
readmoreのhref(get, デフォルトあり): https://example.com/post1
テキストと属性: {'text': '続きを読む', 'href': 'https://example.com/post1', 'data-id': 'p1'}
辞書アクセスは属性が無いとKeyErrorになります。
tag.get("attr", default)
を使うと安全です。
CSSセレクタで効率抽出
id/class/タグで検索
CSSセレクタは短い記述で柔軟にマッチできます。
select_one
は1件、select
は複数件です。
from bs4 import BeautifulSoup
soup = BeautifulSoup(sample_html, "lxml")
site_title = soup.select_one("#site-header h1").get_text(strip=True)
print("ヘッダーのタイトル:", site_title)
post_titles = [t.get_text(strip=True) for t in soup.select(".post .title")]
print("記事タイトル一覧:", post_titles)
tags_a = len(soup.select("a"))
print("aタグの総数:", tags_a)
ヘッダーのタイトル: デモサイト
記事タイトル一覧: ['一番目の記事', '二番目の記事']
aタグの総数: 8
セレクタは短く読みやすいため、複雑な条件ではselect
の方が保守しやすくなります。
子孫/直下/兄弟セレクタ
階層関係を表す演算子で、構造に沿った精密な抽出が可能です。
from bs4 import BeautifulSoup
soup = BeautifulSoup(sample_html, "lxml")
# 子孫セレクタ: nav配下のリンク
nav_links = [a.get_text(strip=True) for a in soup.select("nav.global-nav a")]
print("ナビ内リンク:", nav_links)
# 直下セレクタ: article直下のh2
direct_h2 = [h.get_text(strip=True) for h in soup.select("article.post > h2.title")]
print("article直下の見出し:", direct_h2)
# 兄弟セレクタ: 直後の兄弟(+) と 以降の兄弟(~)
immediate_p = [p.get_text(strip=True) for p in soup.select("article.post .title + p")]
print("h2.title直後のp:", immediate_p)
notes = [p.get_text(strip=True) for p in soup.select("article.featured h2.title ~ p.note")]
print("h2.title以降のnote段落:", notes)
ナビ内リンク: ['ホーム', 'このサイトについて', 'ガイドPDF']
article直下の見出し: ['一番目の記事', '二番目の記事']
h2.title直後のp: ['カテゴリ: Python', 'カテゴリ: Web']
h2.title以降のnote段落: ['補足の段落です。', 'さらに補足です。']
構造を固定できる場面では直下セレクタ(>)が便利です。
兄弟セレクタは説明文を取りたい時などに役立ちます。
属性セレクタでリンク(URL)抽出
URLのパターンに基づいてフィルタリングする例です。
from bs4 import BeautifulSoup
soup = BeautifulSoup(sample_html, "lxml")
https_links = [a["href"] for a in soup.select('a[href^="https://"]')]
print("httpsで始まるURL:", https_links)
pdf_links = [a["href"] for a in soup.select('a[href$=".pdf"]')]
print("PDFへのリンク:", pdf_links)
example_links = [a["href"] for a in soup.select('a[href*="example"]')]
print("exampleを含むURL:", example_links)
blank_links = [a["href"] for a in soup.select('a[target="_blank"]')]
print("別タブで開くURL:", blank_links)
nofollow_links = [a["href"] for a in soup.select('a[rel~="nofollow"]')]
print("nofollowが付いたURL:", nofollow_links)
httpsで始まるURL: ['https://example.com/docs/guide.pdf', 'https://example.com/post1', 'https://example.com', 'http://example.org/resource']
PDFへのリンク: ['https://example.com/docs/guide.pdf', '/file/report.pdf']
exampleを含むURL: ['https://example.com/docs/guide.pdf', 'https://example.com/post1', 'https://example.com', 'http://example.org/resource']
別タブで開くURL: ['https://example.com']
nofollowが付いたURL: ['http://example.org/resource']
属性の有無で分岐する場合、辞書アクセスはKeyErrorに注意です。
tag.get("href")
を使用しましょう。
複数要素をループで取り出す
実務でよくある「記事一覧からタイトル・カテゴリ・リンクをまとめて抽出」の例です。
from bs4 import BeautifulSoup
soup = BeautifulSoup(sample_html, "lxml")
articles = []
for art in soup.select("article.post"):
title = art.select_one("h2.title")
cat = art.select_one(".meta .cat")
readmore = art.select_one("a.readmore")
record = {
"title": title.get_text(strip=True) if title else None,
"category": cat.get_text(strip=True) if cat else None,
"url": readmore.get("href") if readmore else None,
"featured": "featured" in (art.get("class") or [])
}
articles.append(record)
for row in articles:
print(row)
{'title': '一番目の記事', 'category': 'Python', 'url': 'https://example.com/post1', 'featured': False}
{'title': '二番目の記事', 'category': 'Web', 'url': '/post2', 'featured': True}
各フィールドをNone許容で組むと、欠損に強いスクリプトになります。
URLは相対パスの可能性があるため、必要に応じてurllib.parse.urljoin
で絶対化しましょう。
開発効率アップ
取得HTMLをファイル保存して検証
ブラウザの開発者ツールで試しながらセレクタを作ると効率的です。
まずはローカルに保存します。
# 取得済みのhtml文字列をファイルに保存してブラウザで開く
import os
path = "downloaded_page.html"
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(html)
print("saved to:", os.path.abspath(path))
saved to: /your/project/downloaded_page.html
保存したHTMLをブラウザで開き、開発者ツールでセレクタを試作し、Python側に反映するのが安全です。
prettifyで構造を確認
Beautiful Soupは見やすく整形して表示する機能を持ちます。
from bs4 import BeautifulSoup
soup = BeautifulSoup(sample_html, "lxml")
pretty = soup.prettify()
print(pretty.splitlines()[:20]) # 最初の20行だけ確認
['<!DOCTYPE html>', '<html lang="ja">', ' <head>', ' <meta charset="utf-8"/>', ' <title>', ' デモページ', ' </title>', ' </head>', ' <body>', ' <header id="site-header">', ' <h1>', ' デモサイト', ' </h1>', ' <nav class="global-nav">', ' <ul>', ' <li>', ' <a data-track="nav" href="/home">', ' ホーム', ' </a>', ' </li>']
構造が見えればセレクタ設計は一気に楽になります。
整形したHTMLを元に、過不足なく要素を指定できるようにしましょう。
要素が無い時の扱い
現実のHTMLは常に整っているとは限りません。
例外や空値に強い書き方を最初から採用します。
from bs4 import BeautifulSoup
soup = BeautifulSoup("<div><a>リンク</a></div>", "lxml")
# 安全な1件取得
maybe_img = soup.select_one("img.logo")
src = maybe_img.get("src") if maybe_img else None
print("imgのsrc:", src) # Noneを許容
# getで安全に属性アクセス(デフォルト値を設定)
a = soup.select_one("a")
print("aのhref(get):", a.get("href", "")) # 空文字をデフォルトに
# find_all/selectは見つからないと空リスト
items = soup.select(".item") # []
print("items数:", len(items))
# 例外を避けるパターン: orでフォールバック
text = (soup.select_one(".title") or {}).get_text(strip=True) if soup.select_one(".title") else "N/A"
print("タイトル:", text)
imgのsrc: None
aのhref(get):
items数: 0
タイトル: N/A
辞書アクセスのtag["attr"]
は属性が無いと即エラーです。
処理を止めないためにget
とif
を併用します。
文字化け対策
エンコーディングの自動判定と明示設定で多くの文字化けを避けられます。
import requests
from bs4 import BeautifulSoup
url = "https://example.com/"
resp = requests.get(url, timeout=10)
# 1) サーバが教えるencodingを尊重しつつ、無ければ推定値を使う
resp.encoding = resp.encoding or resp.apparent_encoding or "utf-8"
print("detected encoding:", resp.encoding)
soup = BeautifulSoup(resp.text, "lxml")
print("title:", soup.title.get_text(strip=True) if soup.title else "N/A")
detected encoding: utf-8
title: Example Domain
それでも解決しない場合はresponse.content
(bytes)をbs4.dammit.UnicodeDammit
で明示的に判定してからデコードする方法が有効です。
import requests
from bs4 import BeautifulSoup
from bs4.dammit import UnicodeDammit
resp = requests.get("https://example.com/", timeout=10)
detector = UnicodeDammit(resp.content)
text = detector.unicode_markup # 自動判定された文字列
print("dammit guessed:", detector.original_encoding)
soup = BeautifulSoup(text, "lxml")
print("title:", soup.title.get_text(strip=True))
dammit guessed: utf-8
title: Example Domain
「まずはエンコーディングを疑う」のが文字化け対応の基本です。
保存・読み込み時のencoding
指定も揃えましょう。
まとめ
本記事ではBeautiful Soupを用いたHTML解析の基本から、CSSセレクタによる効率的な抽出、そして実務で不可避な欠損や文字化けへの対処までを丁寧に解説しました。
find/find_allとselect/select_oneの使い分けを押さえ、抽出前提のセレクタ設計と例外に強いデータアクセスを徹底することで、コードは堅牢になります。
最後に、違いを短く整理しておきます。
メソッド比較
メソッド | セレクタ記法 | 返り値 | 見つからない時 |
---|---|---|---|
find | タグ名と属性引数 | 最初の1要素(Tag) | None |
find_all | タグ名と属性引数 | 要素のリスト | 空リスト |
select_one | CSSセレクタ | 最初の1要素(Tag) | None |
select | CSSセレクタ | 要素のリスト | 空リスト |
次の一歩としては、保存したHTMLでセレクタを磨き、小さな抽出処理を積み上げていくのが近道です。
サイト固有の仕様や構造の揺れに遭遇しても、ここで紹介したprettifyでの構造確認と安全な値取得パターンで堅実に乗り越えられます。
実際のWebからの取得やブラウザ自動操作、大規模化は別記事で扱いますが、まずは本記事の内容で「狙った情報を正確に抜き出す」力を身につけてください。