HTML テーブルの構造
テーブルのスクレイピングでは HTML の構造を正確に理解することが最重要です。
<table id="sales" class="data-table">
<caption>月別売上実績</caption> ← テーブルのタイトル
<thead> ← ヘッダー行グループ
<tr> ← 行(table row)
<th>月</th> ← ヘッダーセル(table header)
<th>売上</th>
</tr>
</thead>
<tbody> ← データ行グループ
<tr>
<td>1月</td> ← データセル(table data)
<td>1,200,000</td>
</tr>
<tr>
<td>2月</td>
<td>980,000</td>
</tr>
</tbody>
<tfoot> ← フッター行グループ(省略可)
<tr>
<td>合計</td>
<td>2,180,000</td>
</tr>
</tfoot>
</table>
上の HTML をブラウザで表示するとこのようになります:
| 月 | 売上 |
|---|---|
| 1月 | 1,200,000 |
| 2月 | 980,000 |
| 合計 | 2,180,000 |
💡 実際のサイトでよくある落とし穴
① <thead> や <tbody> が省略されているケースがある → find_all("tr") で全行を取得することで対応できます。② セルが <th> と <td> 混在することがある → find_all(["th","td"]) でまとめて取得します。
全テーブルを取得する
まず「このページに何個テーブルがあるか」を把握することから始めます。
from bs4 import BeautifulSoup
html = """
<html><body>
<table id="users" class="data-table">
<caption>ユーザー一覧</caption>
<thead><tr><th>ID</th><th>名前</th><th>年齢</th></tr></thead>
<tbody>
<tr><td>1</td><td>田中 太郎</td><td>28</td></tr>
<tr><td>2</td><td>鈴木 花子</td><td>34</td></tr>
<tr><td>3</td><td>佐藤 次郎</td><td>22</td></tr>
</tbody>
</table>
<table id="products" class="data-table highlight">
<caption>商品一覧</caption>
<thead><tr><th>商品名</th><th>価格</th><th>在庫</th></tr></thead>
<tbody>
<tr><td>りんご</td><td>150</td><td>120</td></tr>
<tr><td>みかん</td><td>80</td><td>0</td></tr>
</tbody>
</table>
<table class="summary">
<tr><th>項目</th><th>値</th></tr>
<tr><td>合計ユーザー数</td><td>3</td></tr>
<tr><td>合計商品数</td><td>2</td></tr>
</table>
</body></html>
"""
soup = BeautifulSoup(html, "lxml")
# ── 全テーブルを取得 ──────────────────────
tables = soup.find_all("table")
print(f"テーブル数: {len(tables)}") # 3
# ── 各テーブルの概要を確認 ───────────────
for i, tbl in enumerate(tables):
caption = tbl.find("caption")
tbl_id = tbl.get("id", "(なし)")
tbl_cls = tbl.get("class", [])
rows = tbl.find_all("tr")
print(f"[{i}] id={tbl_id}, class={tbl_cls}, "
f"caption={caption.text if caption else 'なし'}, "
f"行数={len(rows)}")
テーブル数: 3 [0] id=users, class=['data-table'], caption=ユーザー一覧, 行数=4 [1] id=products, class=['data-table','highlight'],caption=商品一覧, 行数=3 [2] id=(なし), class=['summary'], caption=なし, 行数=3
特定のテーブルを取得する
複数テーブルの中から目的のものを特定する方法は状況によって異なります。
① インデックスで指定
tables = soup.find_all("table")
first_table = tables[0] # ページ内で最初のテーブル
second_table = tables[1] # 2番目
last_table = tables[-1] # 最後のテーブル
② id 属性で指定
# find() に id を渡す
users_table = soup.find("table", id="users")
# CSS セレクタ版
users_table = soup.select_one("table#users")
# 見つからなかった場合の安全処理
if users_table is None:
print("テーブルが見つかりませんでした")
else:
print(users_table.find("caption").text) # ユーザー一覧
③ class 属性で指定
# class="data-table" のすべてのテーブル
data_tables = soup.find_all("table", class_="data-table")
print(len(data_tables)) # 2(users, products)
# class に "highlight" を含むもの(複数クラスの部分一致)
highlight_tables = soup.find_all("table", class_="highlight")
print(len(highlight_tables)) # 1(products のみ)
# CSS セレクタ版(クラスを複数指定)
tables = soup.select("table.data-table.highlight")
print(len(tables)) # 1
④ caption(タイトル)で指定
def find_table_by_caption(soup, caption_text: str):
"""caption テキストが一致する table を返す"""
caption = soup.find("caption", string=caption_text)
if caption:
return caption.find_parent("table")
return None
products_table = find_table_by_caption(soup, "商品一覧")
print(products_table.get("id")) # products
⑤ ヘッダー文字列で特定(最も実用的)
id や class が付いておらず、caption もない場合でも、 ヘッダー行の特定のキーワードをもとにテーブルを特定できます。 実務でのスクレイピングで最も使用頻度が高い手法です。
def find_table_by_header(soup, *header_keywords):
"""
ヘッダー行に指定キーワードをすべて含むテーブルを返す。
複数のキーワードを AND 条件で絞り込む。
"""
for tbl in soup.find_all("table"):
# thead の th / td を取得(thead がない場合は先頭 tr を使う)
thead = tbl.find("thead")
header_row = thead.find("tr") if thead else tbl.find("tr")
if not header_row:
continue
headers = [
cell.get_text(strip=True)
for cell in header_row.find_all(["th", "td"])
]
# 全キーワードがヘッダーに含まれているか
if all(kw in headers for kw in header_keywords):
return tbl
return None
# 使用例
# 「商品名」「在庫」の両方を列ヘッダーに持つテーブルを探す
tbl = find_table_by_header(soup, "商品名", "在庫")
if tbl:
print(tbl.get("id")) # products
# 「ID」「名前」「年齢」を持つテーブル
tbl2 = find_table_by_header(soup, "ID", "名前", "年齢")
print(tbl2.get("id")) # users
行・列・セルを取得する
テーブルを特定したあと、行(tr)・セル(th/td)を取得する方法です。
soup = BeautifulSoup(html, "lxml")
tbl = soup.find("table", id="users")
# ── 全行を取得 ────────────────────────────
all_rows = tbl.find_all("tr")
print(f"全行数(ヘッダー含む): {len(all_rows)}") # 4
# ── ヘッダー行だけ取得 ────────────────────
thead = tbl.find("thead")
if thead:
header_row = thead.find("tr")
headers = [th.get_text(strip=True) for th in header_row.find_all("th")]
print(headers) # ['ID', '名前', '年齢']
# ── データ行だけ取得 ──────────────────────
tbody = tbl.find("tbody")
data_rows = tbody.find_all("tr") if tbody else tbl.find_all("tr")[1:]
for row in data_rows:
cells = row.find_all("td")
values = [c.get_text(strip=True) for c in cells]
print(values)
# ['1', '田中 太郎', '28']
# ['2', '鈴木 花子', '34']
# ['3', '佐藤 次郎', '22']
# ── 特定の列だけ取得(例:2列目 = インデックス1) ──
col_index = 1 # 「名前」列
names = []
for row in data_rows:
cells = row.find_all(["th", "td"])
if len(cells) > col_index:
names.append(cells[col_index].get_text(strip=True))
print(names) # ['田中 太郎', '鈴木 花子', '佐藤 次郎']
# ── 特定のセルをピンポイントで取得 ─────────
# n行目・m列目(0始まり)
def get_cell(table, row_index: int, col_index: int) -> str:
rows = table.find_all("tr")
if row_index >= len(rows):
return ""
cells = rows[row_index].find_all(["th", "td"])
if col_index >= len(cells):
return ""
return cells[col_index].get_text(strip=True)
print(get_cell(tbl, 1, 1)) # 田中 太郎(1行目データ・2列目)
print(get_cell(tbl, 2, 2)) # 34(2行目データ・3列目)
テーブルを2次元リストに変換
テーブル全体を list[list[str]] に変換すると、後処理がしやすくなります。
def table_to_list(table) -> list[list[str]]:
"""
BeautifulSoup の table タグを 2次元リストに変換する。
thead / tbody / tfoot のどれにも対応。
"""
rows = table.find_all("tr")
result = []
for row in rows:
cells = row.find_all(["th", "td"])
result.append([c.get_text(strip=True) for c in cells])
return result
# ── 使用例 ──────────────────────────────
soup = BeautifulSoup(html, "lxml")
users_tbl = soup.find("table", id="users")
data = table_to_list(users_tbl)
# ヘッダーと本体を分離
headers = data[0]
rows = data[1:]
print(headers)
# ['ID', '名前', '年齢']
for row in rows:
print(row)
# ['1', '田中 太郎', '28']
# ['2', '鈴木 花子', '34']
# ['3', '佐藤 次郎', '22']
# dict のリストに変換(ヘッダーをキーにする)
records = [dict(zip(headers, row)) for row in rows]
print(records[0])
# {'ID': '1', '名前': '田中 太郎', '年齢': '28'}
セルを条件で検索する
テーブル内から特定の値を持つセル、または行を探す方法です。
import re
from bs4 import BeautifulSoup
html = """
<table id="products">
<thead><tr><th>商品名</th><th>価格</th><th>在庫</th></tr></thead>
<tbody>
<tr><td>りんご</td><td>150</td><td>120</td></tr>
<tr><td>みかん</td><td>80</td><td>0</td></tr>
<tr><td>ぶどう</td><td>350</td><td>45</td></tr>
<tr><td>メロン</td><td>1200</td><td>8</td></tr>
</tbody>
</table>
"""
soup = BeautifulSoup(html, "lxml")
tbl = soup.find("table", id="products")
# ── ① 完全一致でセルを検索 ──────────────
cell = tbl.find("td", string="みかん")
print(cell.text if cell else "Not found") # みかん
# ── ② 正規表現でセルを検索 ──────────────
cells = tbl.find_all("td", string=re.compile(r"[ぁ-ん]ご")) # 「ご」で終わる
print([c.text for c in cells]) # ['りんご']
# ── ③ セルを含む行ごと取得 ───────────────
def find_rows_by_cell(table, col_index: int, pattern):
"""指定列のセルが pattern(文字列 or 正規表現)に一致する行を返す"""
tbody = table.find("tbody")
rows = tbody.find_all("tr") if tbody else table.find_all("tr")[1:]
result = []
for row in rows:
cells = row.find_all("td")
if len(cells) > col_index:
text = cells[col_index].get_text(strip=True)
if isinstance(pattern, str):
if pattern == text:
result.append(row)
else:
if pattern.search(text):
result.append(row)
return result
# 商品名列(0列目)が「みかん」の行
rows = find_rows_by_cell(tbl, 0, "みかん")
for r in rows:
print([c.text for c in r.find_all("td")])
# ['みかん', '80', '0']
# ── ④ 数値条件でフィルタリング ──────────
def find_rows_by_numeric(table, col_index: int, operator, threshold):
"""指定列の数値が条件を満たす行を返す。operator は比較関数。"""
import operator as op
tbody = table.find("tbody")
rows = tbody.find_all("tr") if tbody else table.find_all("tr")[1:]
result = []
for row in rows:
cells = row.find_all("td")
if len(cells) > col_index:
raw = cells[col_index].get_text(strip=True).replace(",", "")
try:
val = float(raw)
if operator(val, threshold):
result.append(row)
except ValueError:
pass
return result
import operator as op
# 価格(1列目)が 200 以上の行
expensive = find_rows_by_numeric(tbl, 1, op.ge, 200)
for r in expensive:
cells = [c.text for c in r.find_all("td")]
print(cells)
# ['ぶどう', '350', '45']
# ['メロン', '1200', '8']
# 在庫(2列目)が 0 の行
out_of_stock = find_rows_by_numeric(tbl, 2, op.eq, 0)
for r in out_of_stock:
print([c.text for c in r.find_all("td")])
# ['みかん', '80', '0']
pandas DataFrame に変換
データ分析や CSV 出力には pandas の DataFrame に変換するのが最も効率的です。
pip install pandas
方法 A:pd.read_html()(最も簡単)
pandas.read_html() は HTML 文字列または URL を受け取り、すべてのテーブルを DataFrame のリストとして返します。
import pandas as pd
from bs4 import BeautifulSoup
# ── HTML 文字列から直接 ────────────────────
dfs = pd.read_html(html) # 全テーブルを DataFrame のリストとして取得
print(len(dfs)) # ページ内のテーブル数
df_users = dfs[0] # インデックスで指定
df_products = dfs[1]
print(df_users)
# ID 名前 年齢
# 0 1 田中 太郎 28
# 1 2 鈴木 花子 34
# 2 3 佐藤 次郎 22
# ── URL から直接取得 ───────────────────────
# dfs = pd.read_html("https://example.com/page", encoding="utf-8")
# ── match で特定テーブルを絞り込む ────────
# caption や th テキストに一致するテーブルだけ取得
dfs = pd.read_html(html, match="商品") # caption や th に「商品」を含むもの
df_products = dfs[0]
print(df_products)
# 商品名 価格 在庫
# 0 りんご 150 120
# 1 みかん 80 0
# ── 型変換とクリーニング ─────────────────
df_products["価格"] = pd.to_numeric(df_products["価格"], errors="coerce")
df_products["在庫"] = pd.to_numeric(df_products["在庫"], errors="coerce")
print(df_products.dtypes)
# ── CSV に書き出す ────────────────────────
df_products.to_csv("products.csv", index=False, encoding="utf-8-sig") # UTF-8 BOM付き(Excel対応)
方法 B:BeautifulSoup + 手動変換(細かい制御が必要なとき)
import pandas as pd
from bs4 import BeautifulSoup
def table_to_dataframe(table) -> pd.DataFrame:
"""
BeautifulSoup の table タグを pandas DataFrame に変換する。
ヘッダーは thead の th を使用。なければ最初の tr を使用。
"""
# ヘッダーを取得
thead = table.find("thead")
if thead:
header_row = thead.find("tr")
headers = [th.get_text(strip=True) for th in header_row.find_all(["th","td"])]
tbody = table.find("tbody")
data_rows = tbody.find_all("tr") if tbody else []
else:
all_rows = table.find_all("tr")
header_row = all_rows[0]
headers = [th.get_text(strip=True) for th in header_row.find_all(["th","td"])]
data_rows = all_rows[1:]
# データ行を変換
records = []
for row in data_rows:
cells = row.find_all(["th","td"])
values = [c.get_text(strip=True) for c in cells]
# 列数が合わない場合は空文字で埋める
while len(values) < len(headers):
values.append("")
records.append(dict(zip(headers, values[:len(headers)])))
return pd.DataFrame(records)
# ── 使用例 ──────────────────────────────
soup = BeautifulSoup(html, "lxml")
df = table_to_dataframe(soup.find("table", id="products"))
print(df)
# 商品名 価格 在庫
# 0 りんご 150 120
# 1 みかん 80 0
# 数値型に変換
df["価格"] = pd.to_numeric(df["価格"])
df["在庫"] = pd.to_numeric(df["在庫"])
# 在庫 0 を絞り込み
print(df[df["在庫"] == 0])
# 商品名 価格 在庫
# 1 みかん 80 0
複数テーブルを一括処理
ページ内のすべてのテーブルをループで処理するパターンです。
import pandas as pd
from bs4 import BeautifulSoup
def scrape_all_tables(html_str: str) -> dict[str, pd.DataFrame]:
"""
ページ内の全テーブルを辞書にまとめる。
キーは id > caption > 連番 の優先順で決める。
"""
soup = BeautifulSoup(html_str, "lxml")
result = {}
counter = 0
for tbl in soup.find_all("table"):
# キー名を決定
tbl_id = tbl.get("id")
caption = tbl.find("caption")
if tbl_id:
key = tbl_id
elif caption:
key = caption.get_text(strip=True)
else:
key = f"table_{counter}"
counter += 1
try:
df = table_to_dataframe(tbl) # 前節の関数
if not df.empty:
result[key] = df
except Exception as e:
print(f"[WARN] {key} の変換失敗: {e}")
return result
# ── 使用例 ──────────────────────────────
tables_dict = scrape_all_tables(html)
for name, df in tables_dict.items():
print(f"\n=== {name} ===")
print(df.to_string(index=False))
# CSV に個別保存
for name, df in tables_dict.items():
filename = name.replace("/", "_") + ".csv"
df.to_csv(filename, index=False, encoding="utf-8-sig")
print(f"保存: {filename}")
colspan / rowspan を処理する
実際の Web サイトのテーブルには、セルが複数列・複数行にまたがる
colspan や rowspan が使われていることがあります。
これをそのまま処理するとデータがずれるため、補完処理が必要です。
from bs4 import BeautifulSoup
html_span = """
<table>
<tr>
<th>カテゴリ</th>
<th colspan="2">詳細</th> <!-- 2列占有 -->
</tr>
<tr>
<td rowspan="2">果物</td> <!-- 2行占有 -->
<td>りんご</td>
<td>150円</td>
</tr>
<tr>
<td>みかん</td>
<td>80円</td>
</tr>
</table>
"""
def expand_table(table) -> list[list[str]]:
"""
colspan / rowspan を展開してフラットな 2D リストを返す。
rowspan のセルは後続行に同じ値を複製する。
"""
rows = table.find_all("tr")
matrix = []
rowspan_map: dict[tuple[int,int], str] = {} # {(行,列): 値}
for r_idx, row in enumerate(rows):
col_idx = 0
cells = row.find_all(["th", "td"])
row_data: list[str] = []
cell_iter = iter(cells)
while True:
# rowspan で予約済みの列を先に埋める
while (r_idx, col_idx) in rowspan_map:
row_data.append(rowspan_map.pop((r_idx, col_idx)))
col_idx += 1
try:
cell = next(cell_iter)
except StopIteration:
break
text = cell.get_text(strip=True)
colspan = int(cell.get("colspan", 1))
rowspan = int(cell.get("rowspan", 1))
# colspan の分だけ列を展開
for c in range(colspan):
row_data.append(text)
# rowspan がある場合は後の行に予約
for rs in range(1, rowspan):
rowspan_map[(r_idx + rs, col_idx + c)] = text
col_idx += colspan
# 行末に残った rowspan 予約を処理
while (r_idx, col_idx) in rowspan_map:
row_data.append(rowspan_map.pop((r_idx, col_idx)))
col_idx += 1
matrix.append(row_data)
return matrix
soup = BeautifulSoup(html_span, "lxml")
result = expand_table(soup.find("table"))
for row in result:
print(row)
# ['カテゴリ', '詳細', '詳細']
# ['果物', 'りんご', '150円']
# ['果物', 'みかん', '80円']
✅ pd.read_html() は colspan / rowspan を自動展開する
上のような複雑なケースでは pd.read_html() を使うと自動的に展開してくれます。手動実装が必要なのは、read_html では対応しきれない特殊なレイアウトのときに限られます。
実践サンプル — 複数テーブルを含むページを一括スクレイプ
requests でページを取得し、複数テーブルを特定・抽出・CSV保存する完全なスクリプトです。
"""
実践サンプル: 複数テーブルを含む Web ページをスクレイプして CSV 保存する
必要パッケージ: pip install beautifulsoup4 lxml requests pandas
"""
import time
import requests
import pandas as pd
from bs4 import BeautifulSoup
from pathlib import Path
# ── 設定 ─────────────────────────────────────────────────────
URL = "https://example.com/report" # ← 対象 URL を変更
OUTPUT_DIR = Path("output")
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/124 Safari/537.36"
)
}
TARGET_HEADERS = ["商品名", "価格", "在庫"] # ← 取得したいテーブルの列ヘッダー
# ── ユーティリティ ────────────────────────────────────────────
def fetch_soup(url: str) -> BeautifulSoup:
resp = requests.get(url, headers=HEADERS, timeout=15)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding
return BeautifulSoup(resp.text, "lxml")
def find_table_by_header(soup, *keywords):
for tbl in soup.find_all("table"):
thead = tbl.find("thead")
hr = thead.find("tr") if thead else tbl.find("tr")
if not hr:
continue
cols = [c.get_text(strip=True) for c in hr.find_all(["th","td"])]
if all(kw in cols for kw in keywords):
return tbl
return None
def table_to_dataframe(table) -> pd.DataFrame:
thead = table.find("thead")
if thead:
header_cells = thead.find("tr").find_all(["th","td"])
headers = [c.get_text(strip=True) for c in header_cells]
tbody = table.find("tbody")
data_rows = tbody.find_all("tr") if tbody else []
else:
all_rows = table.find_all("tr")
headers = [c.get_text(strip=True) for c in all_rows[0].find_all(["th","td"])]
data_rows = all_rows[1:]
records = []
for row in data_rows:
cells = [c.get_text(strip=True) for c in row.find_all(["th","td"])]
while len(cells) < len(headers):
cells.append("")
records.append(dict(zip(headers, cells[:len(headers)])))
return pd.DataFrame(records)
# ── メイン処理 ────────────────────────────────────────────────
def main():
OUTPUT_DIR.mkdir(exist_ok=True)
print(f"取得中: {URL}")
try:
soup = fetch_soup(URL)
except requests.RequestException as e:
print(f"取得失敗: {e}")
return
# ヘッダー文字列で目的のテーブルを特定
tbl = find_table_by_header(soup, *TARGET_HEADERS)
if tbl is None:
print(f"テーブルが見つかりませんでした: {TARGET_HEADERS}")
return
# DataFrame に変換
df = table_to_dataframe(tbl)
print(f"取得行数: {len(df)}")
print(df.head())
# 型変換(数値列)
for col in ["価格", "在庫"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col].str.replace(",",""), errors="coerce")
# CSV に保存
out_path = OUTPUT_DIR / "result.csv"
df.to_csv(out_path, index=False, encoding="utf-8-sig")
print(f"保存完了: {out_path}")
# 在庫 0 の商品を別ファイルに保存
if "在庫" in df.columns:
oos = df[df["在庫"] == 0]
if not oos.empty:
oos.to_csv(OUTPUT_DIR / "out_of_stock.csv", index=False, encoding="utf-8-sig")
print(f"在庫切れ: {len(oos)} 件 → out_of_stock.csv")
time.sleep(1) # 礼儀として1秒待機
if __name__ == "__main__":
main()
💡 このシリーズのまとめ
PART 01(環境構築)→ PART 02(BeautifulSoup の基本)→ PART 03(テーブル操作)の3章で、Python による HTML スクレイピングの基礎から実践まで一通りの知識が揃います。次のステップとしては、JavaScript 動的レンダリングが必要なページへの対応(Selenium / Playwright)や、定期実行の自動化(cron / schedule)などがあります。
テーブル操作 メソッド早見表
| やりたいこと | コード |
|---|---|
| 全テーブルを取得 | soup.find_all("table") |
| n 番目のテーブル | soup.find_all("table")[n] |
| id で特定 | soup.find("table", id="xxx") |
| class で特定 | soup.find("table", class_="xxx") |
| caption で特定 | soup.find("caption", string="xxx").find_parent("table") |
| 全行を取得 | table.find_all("tr") |
| ヘッダー行のセル | table.find("thead").find("tr").find_all(["th","td"]) |
| データ行のセル | table.find("tbody").find_all("tr") |
| セルのテキスト | cell.get_text(strip=True) |
| 文字列検索 | table.find("td", string="xxx") |
| 正規表現検索 | table.find_all("td", string=re.compile("xxx")) |
| DataFrame に変換(簡単) | pd.read_html(html_str)[n] |
| DataFrame に変換(細かい制御) | table_to_dataframe(table)(本記事の関数) |
| CSV に保存 | df.to_csv("file.csv", encoding="utf-8-sig") |