Geonode logo
Geonode Team

Geonode Team

更新于:2026年10月7日

发布于:2026年9月2日

pandas.read_html():带示例的完整指南

`pandas.read_html()` 只需一行代码即可将 HTML 表格提取为 DataFrame。该函数确实非常实用,但同时也最容易让初学者产生一种错觉,以为从网页中提取数据是件轻而易举的事。 它返回的是一个 DataFrame 的 *列表*,而不是单个 DataFrame。它仅能识别 `<table>` 元素。而且文档中也明确指出,后续需要进行数据清理。 本指南将介绍该函数的功能、值得了解的参数,以及在哪些情况下应选用其他工具。

我们的立场:我们是 Geonode,主要销售代理服务,因此公共网络上的表格与我们的业务息息相关。需要注意的是,read_html 直接获取 URL 无法让你控制请求——无法设置自定义头信息、无法管理会话、无法实现重试逻辑,也无法通过任何方式进行路由。 对于合作网站上的单次表格查询,这种方式完全可行,且是目前最快的解决方案。但对于任何需要定期执行的任务,请使用合适的 HTTP 客户端自行获取 HTML,并将字符串传递给 read_html。这种分离虽然多花了一行代码,却能让你获得内置 fetch 方法所无法提供的全部功能。

基础知识

import pandas as pd

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

关键细节来自 pandas 文档:它返回“一个 DataFrame 列表”。而不是单个 DataFrame。包含六个表格的页面会返回六个 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>元素构建并采用网格样式设计的布局——这正是大多数现代网页设计的方式——其中并不包含可供它查找的表格,无论其在屏幕上看起来多么像表格。

**它不会执行 JavaScript。**如果表格是在客户端渲染的,那么 read_html 所看到的 HTML 中并不包含该表格。这正是页面上明明有表格,却出现“未找到表格”提示的最常见原因。

它能正确处理跨行元素。 colspan 和 rowspan 属性“被正确处理”,这比许多自制的解析器做得更好。

它优先在<thead>中查找标题,若不存在则回退到在正文中查找。

它默认尊重display: none。 displayed_only=True 的默认设置“会排除带有 display: none 的元素”,这通常是您所期望的,但有时也会隐藏网站特意仅对特定视口开放的数据。

两点依赖关系说明。解析引擎优先使用 lxml,若不可用则回退到 bs4 加上 html5lib —— 文档中指出“'bs4' 和 'html5lib' 作为变体名称是同义词”。 此外还有一项值得了解的 URL 特性:“lxml 仅接受 http、ftp 和 file 协议的 URL。如果您的 URL 以 'https' 开头,可以尝试移除 's'。”实际上,自行获取页面可以完全避免这个问题。

关键参数

完整的签名包含十八个参数。其中六个参数承担了大部分工作。

**match

**(默认值为 '.+'

)用于筛选文本与正则表达式匹配的表格。这是最实用的参数,但往往被低估:

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

与其猜测列表中的索引,不如直接指定表格中包含的内容。 当页面布局发生变化时,这种方法要稳健得多,因为即使重新设计导致表格位置发生变化,内容通常也能保留下来。

**attrs

** 通过 HTML 属性进行筛选,这是识别特定表格的另一种方式:

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

**header

** 指定表头行。 文档中指出一个容易让人混淆的排序细节:“header

参数是在应用skiprows

之后才应用的”。因此,如果你跳过两行,然后请求header=0

,你将获得跳过部分之后的第一行。

**skiprows

** 在解析前移除行——这对于标题行位于实际表头之上的表格非常有用。

**index_col

** 将某列设为索引。

**thousands

**(默认值为 ','

)和 **decimal

**(默认值为 '.'

)用于处理数字格式。这些设置的重要性远超表面:一个使用 1.234,56

的欧洲表格需要 thousands='.'

和 decimal=','

,否则所有数字都会在无提示的情况下被转换为字符串或错误值。

还有两点值得了解:

**converters

** 在解析时对每列应用一个函数,这比事后修正类型更为简洁。

**extract_links

** 会捕获单元格内链接的 href

,而不仅仅是其文本——当表格的行链接到你同样需要的详情页面时,这确实非常有用。

无人能避免的清理工作

文档对预期情况做了坦诚的说明,建议先阅读免责声明,以免事后才发现:

调用此函数后,请做好进行一些清理工作的准备。例如,如果在传递header=0

参数时列名被转换为NaN,您可能需要手动赋值列名。

此外:

我们尽量不对表格的结构做任何假设,并将表格中 HTML 的特殊性交由用户处理。

第二句话直白地阐述了这一设计理念。read_html

仅返回 HTML 中的内容;整理这些内容是你的责任。

每次都会遇到的清理问题:

跨行的表头导致的多索引列。 带有两行表头的表格会生成 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

而不是抛出异常,这样你就能统计失败的数量,而不是因一个错误单元格而导致整个操作失败。

脚注行和总计行。 许多表格末尾都有一行非数据的汇总行。请明确将其过滤掉,而不是假设最后一行是安全的。

选择合适的表

四种方法,按鲁棒性从低到高排序。

按索引号选择 — 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]

该断言是关键部分。如果脚本在未报错的情况下默认取三个匹配项中的第一个,可能会导致数月之久的数据错误。当计数结果出乎意料时触发失败,能将数据问题转化为错误提示。

将多页数据读入一个DataFrame

当单张表格处理成功后,下一步自然就是将多页数据合并到一个DataFrame中,而此时养成几个好习惯能真正省去不少麻烦。

使用连接(concatenate)而非在循环中逐行追加(append)。 增量构建DataFrame速度较慢,且会产生碎片化的索引。应先收集所有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

列无需额外开销,却能解答你最终会被问到的问题——哪个页面产生了这个异常值。对于随时间收集的数据,还应添加时间戳。没有来源信息的数据集非常难以调试,也无法进行审计。

**控制循环节奏。**以连接允许的最快速度一次性抓取十个页面,在服务器端看来就像是一次突发攻击。请求之间间隔一秒既是一种礼貌,在大多数网站上,这也决定了你究竟能完成抓取还是会被限流:

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

应针对单个页面处理错误,而不是放弃整个抓取任务。 仅因一页布局发生变化,不应导致其余九页的抓取失败:

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)

在拼接之前,请检查结构是否一致。 如果第七页有一个其他页面没有的列,pd.concat

会很“乐意”地为不匹配的行生成一整屏的 NaN

—— 虽然有效、格式正确,但内容有误。 在合并前比较列集,就能将这种情况转化为可见的错误。

**并在之后进行去重。**分页表格经常会在页面边界之间重复行,特别是在爬取过程中底层数据发生变化时。对有意义的列子集执行 combined.drop_duplicates()

,是一种低成本的防护措施,可避免将同一条记录计数两次。

何时应使用其他工具 `

`read_html`` 是一个便捷的封装类。以下四种情况需要使用不同的工具。

当数据不在 <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)

当需要控制请求时。 请求头、Cookie、会话、重试、超时、代理——read_html在自动抓取时均不会暴露这些信息。请单独抓取并传递字符串。

当网站提供结构化数据时。 例如 CSV 下载、API,或页面中嵌入的 JSON-LD。就稳定性而言,这些方式都优于解析渲染后的 HTML,在编写任何代码之前花三十秒检查一下是值得的。

在脚本中正确实现

适用于任何需要运行多次的情况。

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

其中的五点,正是导致脚本“高调失败”还是“悄然失败”的关键区别。

**raise_for_status()

** 会捕获 HTTP 错误,因为在错误页面上执行 read_html

要么找不到表,要么找到错误的表。

**match

而不是索引**,这样布局变更时会触发错误,而不是查询到错误的表。

断言必须且仅匹配一个,这样当结果存在歧义时会停止执行,而不是默默地取第一个结果。

检查预期列,从而捕获因重新设计而导致列名更改或顺序调整的情况——否则,这种失败会导致格式正确但内容错误的数据无限期产生。

检查是否为空,因为匹配到的表中没有行,几乎总是问题征兆,而非最终结果。

一个提供联系 URL 的诚实用户代理无需任何成本,却能让您的客户端成为运营商可选择允许的对象,而非必须屏蔽的对象。

大家还问

pandas.read_html 有什么作用?

它解析 HTML,并将找到的每个 <table> 元素作为 DataFrame 列表返回。它处理 colspan 和 rowspan,若存在标题则使用 <thead>,并且默认会排除使用 display: none 隐藏的元素。

为什么 read_html 会返回一个列表?

因为一个页面可能包含任意数量的表格,而 pandas 会按文档顺序返回所有表格。它总是返回一个列表,否则会报错——除特殊情况外,它不会返回空列表——因此,空结果本身就表明出了问题。

为什么 read_html 会显示“未找到表格”?

要么页面中没有 <table> 元素——现代布局通常使用样式化的 <div> 元素代替——要么该表格是由 JavaScript 渲染的,而 pandas 不会执行该 JavaScript。请检查原始 HTML 源代码,而不是浏览器的渲染视图,以确定具体原因。

如何选择特定的表格?

使用 match 并配合正则表达式匹配目标表格内的文本,或者使用 attrs 根据 id 或 class 进行过滤。这两种方法都比直接通过列表索引要可靠得多,因为当有新的表格插入到您目标表格上方时,后者会无声地出错。

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。在这个范围内,它处理那些棘手的部分——跨单元格、标题检测、隐藏元素——的效果比大多数手动编写的解析器都要好。

有两点需要牢记:它返回的是列表而非 DataFrame;而且文档本身就提示你应做好清理工作。通过 match 或 attrs 进行选择(而非按索引),并断言仅匹配到一张表格,可将最常见的“静默失败”转化为错误信息。

对于需要运行多次的任务,请自行使用 fetch 获取 HTML。多写一行代码,就能获得头部信息、超时设置、重试机制、会话管理以及内置 fetch 所不支持的其他所有功能——同时还能规避 lxml 在 URL 协议处理上的怪癖。

当页面中没有表格时,请停止尝试获取参数。<div> 网格或客户端渲染的页面属于另一类问题,其解决方案是使用真正的解析器、进行渲染处理,或者——最理想的是——利用该网站很可能已在你尚未查阅的位置发布的结构化数据。