我们的立场:我们是 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 中充斥着
,这会被转换为 \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> 网格或客户端渲染的页面属于另一类问题,其解决方案是使用真正的解析器、进行渲染处理,或者——最理想的是——利用该网站很可能已在你尚未查阅的位置发布的结构化数据。
