🎯 一款可定制、具备反检测功能的云浏览器,由自主研发的 Chromium驱动,专为网页爬虫AI 代理设计。👉立即试用
返回博客

如何使用 Pandas 和 Scrapeless 构建网络爬虫管道

Emily Chen
Emily Chen

Advanced Data Extraction Specialist

22-Jul-2026

TL;DR:

  • Scrapeless Universal Scraping API 返回公共页面的渲染 HTML,而 pandas 的 read_html 能在一次调用中将该页面的表格转换为 DataFrame。
  • 本指南将工作分为五个明确的阶段:获取、发现、提取、转换和存储,每个阶段只需几行 Python 代码。
  • 在 pandas 3.x 中,read_html 需要一个 io.StringIO 包装器围绕 HTML 字符串,因为裸字符串现在被视为文件路径并会引发错误。
  • 清理应在提取之后进行:重命名列,使用 pd.to_numeric 强制转换数字类型,并在写入磁盘之前添加派生列。
  • Parquet 保留列类型,但不一定比 CSV 小;在 75 行的样本中,其页脚开销使其成为较大的文件,大小优势随着数据量的增加而出现。
  • Scrapeless 免费计划 开始,并将获取阶段指向您自己的数据源。

大多数 pandas 教程都是从已有的 CSV 开始。实际工作很少从那里开始。您所需的数据就位于 HTML 页面内,包裹在导航、样式和标记中,普通的 requests.get 通常无法干净地获取。 在“该页面上有一个表格”和“内存中有一个类型化的 DataFrame”之间的差距就是一个抓取管道的存在之处。

这篇文章通过两个工具来弥补这个差距。Scrapeless Universal Scraping API 处理数据获取并将页面以 HTML 的形式返回。pandas 负责结构:它读取表格,清理表格,并写入可以分析的类型化文件。示例目标是一个公共抓取沙盒,其中包含一个带分页的 NHL 球队赛季表格,因此下面的每个数字都来自真实页面的真实运行。

一目了然的管道

该管道分为五个阶段,每个阶段将一个单一且定义明确的对象传递给下一个:

fetch (HTML 字符串) → discover (哪个表) → extract (DataFrame) → transform (类型化、清理后的 DataFrame) → store (CSV + Parquet)

将各个阶段分开处理在页面更改时是有益的。当布局发生变化时,只有发现阶段会改变。当某一列开始以文本形式出现时,只有转换阶段会更改。获取和存储阶段保持不变。

阶段 1:以干净的 HTML 获取页面

获取阶段向 Scrapeless 发送一个请求,并将页面作为 HTML 字符串返回。该请求需要一个 actor 和一个 input 对象;unlocker.webunlocker actor 用于检索页面,API 密钥通过 x-api-token 标头发送。

这个管道需要三个包:pandas 本身,一个用于 read_html 的解析器,以及用于存储阶段的 Parquet 引擎。一次性安装这三者。

bash Copy
pip install pandas pyarrow lxml
python Copy
import io
import json
import os
import urllib.request

import pandas as pd

API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"


def fetch_html(url: str) -> str:
    payload = json.dumps(
        {"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
    ).encode()
    request = urllib.request.Request(
        API_URL,
        data=payload,
        headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=90) as response:
        return json.loads(response.read())["data"]


html = fetch_html("https://www.scrapethissite.com/pages/forms/")
tables = pd.read_html(io.StringIO(html))
df = tables[0]
print(f"页面上的表格数量: {len(tables)} | 形状: {df.shape}")
print("原始列:", list(df.columns))

响应体是一个 JSON 信封,渲染的标记位于 data 键下,因此 json.loads(response.read())["data"] 是整个页面的字符串。这里故意将 js_render 设置为 False,具体解释见当您需要渲染页面部分。

在运行任何东西之前,请在您的 shell 中设置密钥。运行时使用真实密钥,并将占位符排除在源代码之外。

bash Copy
export SCRAPELESS_API_KEY="sk_your_key_here"

阶段 2:发现表格

发现阶段回答一个问题:页面上哪个表格包含数据。运行上面的代码块会打印该目标的答案。

text Copy
页面上的表格数量: 1 | 形状: (25, 9)
原始列: ['团队名称', '年份', '胜场', '败场', '加时败场', '胜率', '进球数 (GF)', '失球数 (GA)', '+ / -']

read_html 扫描 HTML 并返回一个列表,每个 <table> 元素对应一个 DataFrame,遵循与HTML 表格数据规范相同的表格模型。此页面只有一个表格,因此 tables[0] 是你想要的。在一个包含多个表格的页面上,打印每个表格的形状和前几行,挑选与你的列匹配的索引,并硬编码该索引。原始列名直接来自 <th> 单元格,因此它们仍然包含空格和标点符号。

阶段 3:读取到 DataFrame 中

提取工作已经完成。这就是 read_html 的意义:它将整个表格转换为一个 DataFrame,而无需手动遍历行和单元格。pandas read_html 参考文档 记录了 io.StringIO 的要求,这使大多数首次尝试 pandas 3.x 时出现问题。裸 HTML 字符串被解释为文件名;将其包装在 io.StringIO(html) 中告诉 pandas 解析字符串本身。

手头有 25 行和 9 列,原始框架可用但尚未清洁。列名笨拙,每个值仍是 HTML 解析器推断的任意类型。两个问题都属于下一个阶段。

阶段 4:转换和类型数据

转换阶段按顺序完成三项任务:重命名列为可输入的内容,将值转换为数字,并添加分析所需的衍生列。

python Copy
raw.columns = ["team", "year", "wins", "losses", "ot_losses", "win_pct", "goals_for", "goals_against", "goal_diff"]
numeric = ["year", "wins", "losses", "ot_losses", "win_pct", "goals_for", "goals_against", "goal_diff"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["games"] = raw["wins"] + raw["losses"] + raw["ot_losses"].fillna(0)
raw["winning_season"] = raw["win_pct"] >= 0.5
clean = raw.dropna(subset=["team", "wins"]).reset_index(drop=True)

带有 errors="coerce"pd.to_numeric 是主要工作马。它转换干净的数字,并将任何无法解析的内容变为 NaN,而不会导致整个列失败,这在这里很重要,因为旧赛季的加时损失单元格是空的。fillna(0) 则在计算比赛场次时将这些空白视为零,而 dropna 则删除任何缺少球队名称或胜场数的行。结果是一个每个数值列都是真正的数值,以及准备分组和过滤的衍生 gameswinning_season 列的框架。

阶段 5:以 CSV 和 Parquet 存储

存储将干净的框架写入两次,因为这两种格式满足不同的需求。

python Copy
clean.to_csv("teams.csv", index=False)
clean.to_parquet("teams.parquet", index=False)

CSV 是便携的,任何东西都可以读取,从电子表格到 shell 单行命令,它遵循广泛实施的逗号分隔值格式。它的缺点是,写入时类型消失;每一列在返回时都是文本。当你重新读取 teams.parquet 时,wins 仍然是整数而 win_pct 仍然是浮点数,无需重新强制转换,因为Apache Parquet 文件格式将每列的类型与其值存储在一起。

Parquet 并不总是更小的文件,无论民间传说怎么说。在这个75行的示例中,CSV 是 4,379 字节,而 Parquet 文件是 8,999 字节,因为 Parquet 的逐列元数据和尾部是固定开销,微小的数据集无法摊销。如果你关心的只是文件大小,应该将小结果存储为 CSV。当行数增长到几万行并且类型化、列式读取变得比字节数更重要时,选择 Parquet。

完整管道

将五个阶段放入一个脚本并添加分页,则管道获取三页,构建一个 75 行的框架,清理它并写入两个文件。

python Copy
import io
import json
import os
import urllib.request

import pandas as pd

API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"
BASE = "https://www.scrapethissite.com/pages/forms/"
PAGES = 3  # 限制:三页公共沙盒表格

def fetch_html(url: str) -> str:
    """阶段 1 - 通过 Scrapeless 通用抓取 API 获取渲染的 HTML。"""
    payload = json.dumps(
        {"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
    ).encode()
    request = urllib.request.Request(
        API_URL,
        data=payload,
        headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=90) as response:
        return json.loads(response.read())["data"]

阶段 1-3 - 获取每一页,发现单一表格,直接读入 DataFrame

frames = []
for page in range(1, PAGES + 1):
html = fetch_html(f"{BASE}?page_num={page}")
frames.append(pd.read_html(io.StringIO(html))[0])
raw = pd.concat(frames, ignore_index=True)
print(f"已获取页面: {PAGES} | 已解析行数: {len(raw)}")

阶段 4 - 转换:清理列名,强制转化数值类型,添加派生列

raw.columns = ["team", "year", "wins", "losses", "ot_losses", "win_pct", "goals_for", "goals_against", "goal_diff"]
numeric = ["year", "wins", "losses", "ot_losses", "win_pct", "goals_for", "goals_against", "goal_diff"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["games"] = raw["wins"] + raw["losses"] + raw["ot_losses"].fillna(0)
raw["winning_season"] = raw["win_pct"] >= 0.5
clean = raw.dropna(subset=["team", "wins"]).reset_index(drop=True)
print(f"清理后行数: {len(clean)} | 胜利赛季: {int(clean['winning_season'].sum())}")

best = clean.sort_values("wins", ascending=False).iloc[0]
print(f"最佳赛季: {best['team']} {int(best['year'])} ({int(best['wins'])} 胜)")

阶段 5 - 将数据框存储为 CSV 以便于携带,并将 Parquet 用于类型列读取

clean.to_csv("teams.csv", index=False)
clean.to_parquet("teams.parquet", index=False)
print(f"csv 字节数: {os.path.getsize('teams.csv')} | parquet 字节数: {os.path.getsize('teams.parquet')}")

运行将打印每个阶段的简洁跟踪:

text Copy
已获取页面: 3 | 已解析行数: 75
清理后行数: 75 | 胜利赛季: 26
最佳赛季: Pittsburgh Penguins 1992 (56 胜)
csv 字节数: 4379 | parquet 字节数: 8999

page_num 查询参数驱动分页,而 PAGES 限制运行在三页之内,以便例子保持简小而礼貌。提高这个常量可以扩展覆盖范围,并将其放在一个地方,以便限制是一个决定,而不是一个意外。

通过替换 BASE URL 和列名,将此管道指向您关心的源,并且五个阶段的结构保持不变。如果您想更广泛地了解每个阶段适合哪里,关于 ETL 管道是什么的指南 将总体上讲解提取、转换和加载的通用模式。

当您需要呈现的页面

此管道将 js_render 设置为 False,这是一种故意选择,而不是默认保持不变。沙箱表在服务器发送的 HTML 中存在,因此浏览器没有内容可呈现,跳过渲染使每次提取更快。许多页面则不同:您想要的表格是通过 JavaScript 在初始 HTML 加载后注入的,未渲染的提取返回一个空壳。当 read_html 在浏览器中可以看到的页面上找到零个表格时,请将 js_render 设置为 True,以便 Scrapeless 在其脚本运行后返回该页面。根据每个源做出决定,而不是在所有地方开启渲染,因为渲染不需要的页面只会增加延迟。

在您扩展任何内容之前,请阅读目标的 robots.txt 和条款。机器人排除协议 告诉您网站要求自动客户端回避哪些路径,遵守它使得数据管道保持在其依赖的网站的正确边界。保持体量有限且目标为公开,如本示例所做。

准备好将其运行在真实数据源上了吗?创建一个免费的 Scrapeless 账户并在提取阶段中更改 URL。

结论

一个抓取管道由五个小阶段组成,每个阶段都执行一项工作。Scrapeless 获取页面,read_html 提取表格,to_numeric 和一些赋值进行清理,两个 to_ 调用进行存储。由于各阶段分开,因此管道能够适应变更:新的布局会影响发现,新的列类型会影响转换,而其余则保持不变。根据上面的有效脚本开始,替换成您自己的目标,并根据数据需求扩展转换阶段。

从 Scrapeless 免费计划开始以针对您自己的页面运行提取阶段,查看 Scrapeless 定价当您安排一个重复的工作时。

FAQ

问:为什么 pandas.read_html 在 pandas 3.x 中对一个 HTML 字符串失败?

一个裸字符串参数被视为文件路径或 URL,因此 pandas 尝试打开它并引发错误。将标记包装在 io.StringIO(html) 中并将其传递;然后 read_html 在内存中解析该字符串并返回一个 DataFrame 的列表。

问:我需要使用 read_html 的吗?
read_html 需要安装一个 HTML 解析器,并在底层使用 lxmlhtml5lib,因此需要与 pandas 一起安装其中一个。你不需要自己编写解析器代码来提取表格;read_html 驱动解析器并返回 DataFrames。

问:何时应该将 js_render 设置为 True

当数据是通过 JavaScript 在初始 HTML 加载后添加到页面时,设置为 True,这会导致 read_html 在一个明显在浏览器中有表格的页面上找到零个表格。当表格已经在服务器的 HTML 中时,保持为 False,因为渲染不必要的页面只会增加延迟。

问:我应该将抓取的数据存储为 CSV 还是 Parquet?

当你想要一个可移植的、可读的人类文件且行数较小时选择 CSV;当你想要保留列类型且数据集足够大以至于打类型的列式读取变得重要时选择 Parquet。在小样本中,Parquet 可能由于其固定的页脚开销而成为更大的文件,因此在数据增长之前,单靠大小更倾向于选择 CSV。

问:我如何处理一个包含多个表格的页面?

read_html 将每个表格作为一个单独的 DataFrame 返回在一个列表中,因此打印每个元素的形状和前几行以识别你想要的那个,然后直接索引它。一旦你确定位置是稳定的,在提取阶段硬编码该索引。

问:当我添加更多页面时,我如何保持抓取礼貌?

保持页面绑定在一个单一定值中,如此处的 PAGES,这样扩大覆盖范围是一个有意的编辑,而不是一个不受限制的循环。首先阅读该网站的 robots.txt 和条款,只在目标可以吸收的量下收集公开数据。

在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。

最受欢迎的文章

目录