Geonode logo
Geonode Team

Geonode Team

更新日:2026年10月7日

公開日:2026年9月2日

pandas.read_html():例を交えた完全ガイド

`pandas.read_html()` HTMLテーブルを1行でDataFrameに抽出します。これは実に便利ですが、同時に、初心者にウェブデータ抽出がいかに簡単かという誤った印象を与えやすい関数でもあります。 この関数は、DataFrameを1つではなく、*リスト*として返します。また、`<table>`要素しか検出できません。さらに、ドキュメント自体にも、処理後にクリーンアップが必要であると明記されています。 このガイドでは、この関数の動作、知っておくべきパラメータ、そして別のツールを使うべきケースについて解説します。

当社の立場:当社は Geonode であり、プロキシを販売しているため、公開ウェブ上のテーブルは当社の事業と密接に関連しています。注意すべき点は、read_html から URL を直接取得しても、そのリクエストを制御できないということです。つまり、カスタムヘッダーやセッション、再試行ロジックは利用できず、何らかの経路を経由させることもできません。 協力的なサイト上の単発のテーブルであればそれで問題なく、利用可能な方法の中で最も高速です。繰り返し必要な場合は、適切なHTTPクライアントを使って自分でHTMLを取得し、その文字列をread_htmlに渡してください。この分離には1行分の追加コードが必要ですが、組み込みのfetch関数では得られないすべての機能を利用できるようになります。

基本

import pandas as pd

tables = pd.read_html("https://example.com/data")
print(len(tables))
df = tables[0]

pandasのドキュメントに記載されている重要な点:「DataFrameのリスト」を返すということです。単一のDataFrameではありません。6つのテーブルがあるページからは、ドキュメントの順序通りに6つのDataFrameが返されます。また、tables[0]

は、期待したデータではなく、ナビゲーションレイアウトである可能性が高いです。

また、ドキュメントには「常に DataFrame のリストを返すか、完全に失敗する」と明記されています。「<td>

が空白のみを含む単一の行」といった特殊なケースを除き、空のリストが返されることはありません。したがって、結果が空である場合は、正常な結果ではなく、何らかの異常が発生したことを示すシグナルとなります。

また、HTMLを直接渡すことも可能です。ざっと確認する以上の用途では、この形式を優先することをお勧めします:

import requests
html = requests.get(url, headers={"User-Agent": "MyBot/1.0 (+https://example.com/bot)"}).text
tables = pd.read_html(html)

認識できるものとできないもの

その範囲を理解しておけば、多くの失望を防ぐことができます。

<table>要素のみを読み取ります。 ドキュメントには、「<table>要素を検索し、テーブル内の<tr>、<th>行、および<td>要素のみを処理する」と記載されています。グリッドとしてスタイル設定された<div>要素で構築されたレイアウト(これは現代のWebデザインの主流です)は、画面上でどれほどテーブルのように見えても、このツールが検出できるテーブルを含んでいません。

JavaScriptは実行されません。 テーブルがクライアントサイドでレンダリングされている場合、read_htmlが認識するHTMLにはそのテーブルは含まれていません。これは、目視上テーブルがあるページで「テーブルが見つかりませんでした」というエラーが発生する最も一般的な原因です。

スパン処理を正しく行います。 colspan および rowspan 属性は「適切に処理」されます。これは、多くの手作りのパーサーが対応しきれない点です。

ヘッダーには <thead> を優先します。これがない場合は、body 内のヘッダーを検索するフォールバック処理が行われます。

デフォルトで display: none を尊重します。 displayed_only=Trueのデフォルト設定では、「display: noneを持つ要素」が除外されます。これは通常、望ましい動作ですが、サイトが特定のビューポートでのみ意図的に公開しているデータを隠してしまう場合もあります。

依存関係に関する2つの注意点があります。パーシングエンジンは、まずlxmlが優先され、それが利用できない場合はbs4とhtml5libにフォールバックします。ドキュメントには、フレーバー名として「'bs4'と'html5lib'は同義である」と記載されています。 また、知っておくべきURLに関する注意点があります。「lxmlはhttp、ftp、fileプロトコルのURLのみを受け付けます。URLが 'https' で始まる場合は、's' を削除してみてください。」実際には、自分でページを取得すれば、この問題は完全に回避できます。

重要なパラメータ

完全なシグネチャには18個のパラメータがあります。そのうち6つが主な役割を果たしています。

**match

**(デフォルトは '.+'

)は、テキストが正規表現に一致するテーブルをフィルタリングします。これは最も有用なパラメータですが、十分に活用されていません:

tables = pd.read_html(html, match="Population")

リスト内のインデックスを推測する代わりに、テーブルに含まれる要素の名前を指定します。 ページのレイアウトが変更された場合でも、テーブルの位置が移動する再設計をコンテンツが通常は乗り切れるため、はるかに堅牢です。

**attrs

** は HTML 属性でフィルタリングします。これは特定のテーブルを特定するもう一つの方法です:

tables = pd.read_html(html, attrs={"id": "results", "class": "data"})

**header

** はヘッダー行の名前を指定します。 ドキュメントには、多くの人が見落としがちな順序に関する詳細が記載されています。「header

引数は、skiprows

が適用された後に適用されます」。したがって、2行をスキップしてからheader=0

を指定すると、スキップの直後にある最初の行が取得されます。

**skiprows

**は、解析前に行を削除します。これは、実際のヘッダーの上にタイトル行があるテーブルで役立ちます。

**index_col

** は、列をインデックスとして設定します。

**thousands

**(デフォルトは ','

)および **decimal

**(デフォルトは '.'

)は、数値の書式設定を処理します。これらは見た目以上に重要です。1.234,56

を使用するヨーロッパ形式のテーブルでは、thousands='.'

および decimal=','

が必要であり、これらがなければ、すべての数値が黙って文字列または誤った値になってしまいます。

さらに知っておくべき2つの機能:

**converters

** は、解析時に列ごとに関数を適用します。これは、後で型を修正するよりもすっきりとした方法です。

**extract_links

** は、セル内のリンクのテキストだけでなく、href

も取得します。これは、表の行が、あなたも表示したい詳細ページへのリンクになっている場合に、非常に役立ちます。

誰もが避けられない後処理

ドキュメントには期待される動作が率直に記されており、後で気づくよりも、あらかじめ免責事項を読んでおく価値があります:

この関数を呼び出した後は、ある程度の後処理が必要になることを想定してください。例えば、header=0

引数を渡した際に列名がNaNに変換されてしまう場合、手動で列名を割り当てる必要があるかもしれません。

また:

テーブルの構造については可能な限り仮定を排し、テーブルに含まれるHTMLの特異性はユーザーに委ねています。

この2つ目の文は、設計哲学を率直に述べたものです。read_html

はHTMLに含まれる内容をそのまま返します。それを整理するのはあなたの仕事です。

毎回問題となる整理作業:

ヘッダー行が複数行にまたがる場合の MultiIndex 列。 2 行のヘッダーを持つテーブルでは、MultiIndex

のような結果が生成されますが、これは正しいものの見栄えが悪くなります。これを平坦化してください:

df.columns = [" ".join(str(c) for c in col).strip() for col in df.columns]

空白と非改行スペース。 HTMLには&nbsp;

が大量に含まれており、これが\xa0

として出力され、単純な.strip()

では処理できません:

df = df.replace("\xa0", " ", regex=True)
df.columns = df.columns.str.replace("\xa0", " ", regex=False).str.strip()

文字列として扱われる数値。 通貨記号、パーセント記号、脚注マーカーなど:

df["Price"] = (df["Price"].astype(str)
               .str.replace(r"[^\d.,-]", "", regex=True)
               .str.replace(",", "")
               .pipe(pd.to_numeric, errors="coerce"))

errors="coerce"

を使用すると、解析不能な値がNaN

に変換され、例外が発生するのを防ぎます。これにより、1つの不正なセルによって操作全体が失敗するのを防ぎ、失敗した行の数を正確に把握できます。

脚注行と合計行。 多くの表は、データではない要約行で終わっています。最後の行が安全であると仮定するのではなく、明示的にフィルタリングしてください。

適切なテーブルの選択

堅牢性の低い順に並べた4つのアプローチ。

インデックスによる選択 — tables[0]

。探索には適していますが、スクリプトでは不安定です。自分のテーブルの上に新しいテーブルが追加されると、インデックス0が依然として存在し、そこに別のデータが含まれるようになるため、何の前触れもなく動作しなくなります。

**match

による** — 最適なデフォルト設定。対象のテーブルにのみ出現する文字列を指定します。

**attrs

による** — テーブルに ID や特徴的なクラスがある場合に最適です。これらは開発者が意図的に選択したものです。

読み込み後の形状による — 他に識別できる要素がない場合:

candidates = [t for t in pd.read_html(html)
              if {"Name", "Price"}.issubset(t.columns)]
if len(candidates) != 1:
    raise ValueError(f"expected 1 matching table, found {len(candidates)}")
df = candidates[0]

このアサーションが重要な部分です。3つのマッチのうち最初のものを黙って採用してしまうスクリプトは、何ヶ月にもわたって誤ったデータを生成することになります。カウントが予期しない値だった場合に失敗するようにすることで、データの問題をエラーメッセージとして明確に示すことができます。

複数のページを1つのDataFrameに取り込む

1つのテーブルの処理がうまくいったら、次に自然と進むべきステップであり、ここでいくつかの習慣を身につけておけば、後々の大きなトラブルを回避できます。

ループで追加するのではなく、連結する。 DataFrameを少しずつ構築していくと処理が遅くなり、インデックスが断片化してしまいます。フレームをまとめてから一度に結合しましょう:

frames = []
for page in range(1, 11):
    df = fetch_table(f"https://example.com/data?page={page}",
                     match="Population",
                     expected_columns=["Country", "Population"])
    df["source_page"] = page
    frames.append(df)

combined = pd.concat(frames, ignore_index=True)

各行の出典を記録する。 上記のsource_page

列はコストがかからず、最終的に問われることになる「この奇妙な値はどのページから来たのか」という疑問に答えてくれます。時間をかけて収集したデータについては、タイムスタンプも追加しましょう。出所が不明なデータセットはデバッグが非常に難しく、監査も不可能です。

ループのペースを調整する。 接続が許す限り高速に10ページを取得すると、サーバー側からは攻撃のように見えるバーストとなります。リクエストの間に1秒の休止を入れることは、マナーとして適切であるだけでなく、ほとんどのサイトにおいて、処理を完了できるかレート制限を受けるかの分かれ目となります:

import time, random
time.sleep(1 + random.random())

実行を中断するのではなく、ページごとのエラーに対処する。 1 ページのレイアウトが変更されたからといって、他の 9 ページまで失うべきではありません:

frames, failures = [], []
for page in range(1, 11):
    try:
        frames.append(fetch_table(url_for(page), match="Population", expected_columns=COLS))
    except Exception as exc:
        failures.append((page, str(exc)))

if failures:
    print(f"{len(failures)} pages failed:", failures)

連結する前に、構造が一致しているか確認してください。 7 ページ目に他のページにはない列がある場合、pd.concat

は、不一致の行に対して NaN

で埋め尽くされたフレームを平然と生成してしまいます。これは、有効で、構文的には正しいものの、内容としては間違っています。 結合する前に列セットを比較しておけば、これを目に見えるエラーに変えることができます。

そして、結合後に重複を削除してください。 ページ分割されたテーブルでは、特にクロール途中で基となるデータが変更された場合、ページ境界をまたいで行が繰り返されることがよくあります。意味のある列のサブセットに対して combined.drop_duplicates()

を実行すれば、同じレコードを2回カウントしてしまうのを防ぐための、コストのかからない対策となります。

他のツールを使うべき場合 `

`read_html`` は利便性を高めるためのラッパーです。以下の4つの状況では、別のツールを使用する必要があります。

データが <table> 形式でない場合。 カードレイアウト、定義リスト、<div>のグリッドなど。本格的なパーサー(lxml や BeautifulSoup に CSS セレクタや XPath を組み合わせたもの)を使用し、DataFrame を自分で構築してください:

from lxml import html as lh
tree = lh.fromstring(page)
rows = [{"name": c.cssselect("h3")[0].text_content().strip(),
         "price": c.cssselect(".price")[0].text_content().strip()}
        for c in tree.cssselect("div.product-card")]
df = pd.DataFrame(rows)

ページに JavaScript が必要な場合。 まず Playwright や類似のツールでレンダリングし、レンダリングされた HTML を read_html に渡します。この組み合わせはうまく機能し、多くの場合、最も手っ取り早い方法です:

html = page.content()          # rendered DOM, not source
tables = pd.read_html(html)

リクエストを制御する必要がある場合。 ヘッダー、クッキー、セッション、再試行、タイムアウト、プロキシなど、read_htmlが自動的に取得を行う際には、これらの情報は一切公開されません。別途取得して、文字列として渡してください。

サイトが構造化データを提供している場合。 CSVのダウンロード、API、またはページに埋め込まれたJSON-LDなどです。安定性の観点から、これらはいずれもレンダリングされたHTMLを解析するよりも優れており、何かを書く前に30秒かけて確認する価値があります。

スクリプトで正しく実装する方法

複数回実行されるあらゆる処理におけるパターンです。

import pandas as pd
import requests

HEADERS = {"User-Agent": "AcmeDataBot/1.0 (+https://acme.example.com/bot)"}

def fetch_table(url, match, expected_columns):
    resp = requests.get(url, headers=HEADERS, timeout=30)
    resp.raise_for_status()

    tables = pd.read_html(resp.text, match=match)
    if len(tables) != 1:
        raise ValueError(f"{url}: expected 1 table matching {match!r}, got {len(tables)}")

    df = tables[0]
    df.columns = [str(c).replace("\xa0", " ").strip() for c in df.columns]

    missing = set(expected_columns) - set(df.columns)
    if missing:
        raise ValueError(f"{url}: missing columns {missing}; got {list(df.columns)}")

    if df.empty:
        raise ValueError(f"{url}: table matched but contains no rows")

    return df

ここにある5つのポイントが、大々的に失敗するスクリプトと、静かに失敗するスクリプトとの違いです。

**raise_for_status()

** はHTTPエラーを捕捉します。なぜなら、エラーページで read_html

を実行すると、テーブルが見つからないか、間違ったテーブルが見つかってしまうからです。

**match

を使用し、インデックスは使用しない**。これにより、レイアウトが変更された場合でも、誤ったテーブルが参照されるのではなく、エラーが発生します。

正確に1つだけの一致をアサートする。これにより、結果が曖昧な場合でも、黙って最初の結果を採るのではなく、処理が停止します。

期待される列の確認を行うことで、列の名前変更や順序変更といった再設計を検知します。これを怠ると、形式的には正しいが誤ったデータが無限に生成され続けるという不具合が発生します。

空かどうかを確認します。行のない一致したテーブルは、結果というよりはほぼ常に何らかの問題の兆候だからです。

連絡先URLを備えた誠実なユーザーエージェントはコストがかからず、運用担当者が「ブロックせざるを得ない」クライアントではなく、「許可してもよい」クライアントとして選べるようになります。

よくある質問

pandas.read_html はどのような機能がありますか?

HTML を解析し、見つかったすべての <table> 要素を DataFrame のリストとして返します。colspan および rowspan を処理し、ヘッダーが存在する場合は <thead> を使用します。また、デフォルトでは display: none で非表示にされた要素は除外されます。

なぜ read_html はリストを返すのですか?

ページには任意の数のテーブルが含まれる可能性があり、pandas はそれらをすべてドキュメント順に返すためです。この関数は常にリストを返すか、失敗します(特殊な場合を除き、空のリストを返すことはありません)。したがって、結果が空であることは、それ自体が何か問題が発生したことを示すシグナルとなります。

なぜ read_html は「No tables found」と表示されるのですか?

ページに <table> 要素がないか(最近のレイアウトでは、代わりにスタイルが適用された <div> 要素がよく使用されます)、あるいはテーブルが pandas が実行しない JavaScript によってレンダリングされているかのいずれかです。どちらであるかを確認するには、ブラウザのレンダリングされた表示ではなく、生の HTML ソースを確認してください。

特定のテーブルを選択するにはどうすればよいですか?

対象のテーブル内のテキストに一致する正規表現を指定して match を使用するか、ID やクラスでフィルタリングするために attrs を使用してください。どちらも、リストをインデックスで参照する方法よりもはるかに堅牢です。リストをインデックスで参照する方法は、対象のテーブルの上に別のテーブルが追加されると、何の警告もなく動作しなくなるためです。

read_htmlはJavaScriptでレンダリングされたページでも動作しますか?

いいえ。この関数はHTMLを解析するだけで、スクリプトは実行しません。まずブラウザ自動化ツールでページをレンダリングし、その結果として得られたHTML文字列をread_htmlに渡してください。この組み合わせはうまく機能し、通常は最も効率的な方法です。

処理後に DataFrame をクリーンアップするにはどうすればよいですか?

MultiIndex 列をヘッダー行からフラット化したり、\xa0 として含まれる非改行スペースを削除したり、pd.to_numeric(errors="coerce") を使用して通貨やパーセンテージの文字列を数値に変換したり、脚注行や合計行を削除したりする必要があるでしょう。pandas のドキュメントには、クリーンアップが必要であると明示されています。

read_html でプロキシやカスタムヘッダーを使用できますか?

URL を直接取得する場合は使用できません。リクエストオプションを設定する機能は提供されていません。requests やその他のクライアントを使用してページを取得し、ヘッダー、タイムアウト、セッション、プロキシを制御した上で、HTML 文字列を read_html に渡してください。

thousands および decimal パラメータはどのような役割を果たしますか?

これらは pandas に数値のフォーマット方法を指定するもので、デフォルトではそれぞれ ',' および '.' となります。1.234,56 のようなヨーロッパ形式のフォーマットを使用する場合は、thousands='.' および decimal=',' が必要です。これらを指定しないと、値は黙って誤って解析されるか、文字列のまま残されてしまいます。

まとめ `

read_html`` は、適用範囲は狭いものの、実に優れた便利な関数です。これは、指定されたHTMLの中から<table>`要素を見つけ出し、それらをDataFrameに変換します。その適用範囲内において、セルをまたぐ要素、ヘッダーの検出、非表示の要素といった扱いにくい部分についても、手書きのパーサーのほとんどよりもうまく処理してくれます。

心に留めておくべき点は2つあります。1つは、この関数がDataFrameではなくリストを返すこと、もう1つは、ドキュメント自体に「クリーンアップが必要」と明記されていることです。インデックスではなく match や attrs を使って選択し、一致したテーブルが正確に1つであることを確認することで、最も一般的な「静かな失敗」をエラーメッセージに変換できます。

1回以上実行する処理については、HTMLを自分で取得してください。たった1行追加するだけで、ヘッダー、タイムアウト、再試行、セッションなど、組み込みのfetchが提供しないあらゆる機能が得られます。同時に、lxmlのURLプロトコルに関する奇妙な挙動も回避できます。

また、ページにテーブルがない場合は、パラメータの取得を試みないでください。<div>のグリッドやクライアント側でレンダリングされたページは別の問題であり、その解決策は本格的なパーサー、レンダリング処理、あるいは――何よりも――サイトがすでに公開している(ただあなたがまだ見つけていないだけの)構造化データです。