网络爬虫与 CDP 实战:拆解网页数据的 20 个实验
配套实验网站:网页数据实验场。网站只提供实验页面,教程与分析均在本系列博文中。
浏览器能看到商品,Python 为什么只拿到空壳?接口已经找对,为什么复制请求还是失败?价格一直变化,为什么 Network 里没有新的普通请求?
这个系列从这些具体问题出发,带你沿着 HTTP、身份状态、浏览器内部、参数计算和实时推送,一步步拆解网页数据的来路。CDP 是 Chrome DevTools Protocol,即 Chrome 开发者工具协议;你不需要预先熟悉它,第一篇会先建立背景。
五篇文章围绕同一批 24 件商品展开。每篇保留实验主线,并加入原理、失败对照、教学业务案例和工程取舍。文章默认使用公开配套实验站,也可以通过 CDP_BASE_URL 切换到本地实例;分析方法可以迁移,凭证与字段契约需要按实际网站重新确认。
适合怎样的读者
如果你会写基础 Python,但遇到真实网页时还不清楚该解析 HTML、请求接口还是启动浏览器,可以从第一篇开始。如果已有采集脚本,却常被过期状态、等待超时和数据缺失困住,可以按表中的问题进入对应篇章,再回看前置知识。
读完后应能够解释一条采集链路:目标字段在哪里,身份从哪里来,参数怎样生成,完成条件是什么,结果怎样对账。这些判断比背下一段固定爬虫代码更容易用于下一次任务。
网络爬虫与 CDP 实战(一):网页数据到底在哪?从 HTTP 到分页采集
配套实验网站: 实验 01:静态商品目录 · 实验 02:GET 商品接口 · 实验 03:商品筛选与分页 · 实验 04:POST 目录搜索
浏览器里有一张完整的商品表,Python 请求页面却得到空列表。很多爬虫项目就是从这个困惑开始的。接着有人加等待,有人换浏览器,有人开始复制一大串请求头,代码越来越多,却没回答最初的问题:网页上的这些数据,是从哪里来的?
如果把这个问题查清,后面的工作通常会简单很多。商品可能已经写在 HTML 里,也可能由接口送来,还可能是浏览器运行 JavaScript 后计算出来的。不同位置的数据,需要不同工具。
《网络爬虫与 CDP 实战:拆解网页数据的 20 个实验》沿着这条线展开。第一篇先讲四个基础实验:静态 HTML、GET API、查询参数与分页、POST JSON。后面依次加入登录状态、CDP、动态参数与签名,最后分析 WebSocket 和综合门户。你不需要一开始就记住协议命令,但需要逐渐学会:看到一个页面时,先找证据,再选实现方式。
跟着文章动手:在线实验站
为了让你能边读边验证,我为这个系列搭建了一个真实可访问的配套网站------网页数据实验场。打开首页就能看到按五篇文章分组的 20 个实验入口。本篇使用实验 01---04,分别对应静态商品目录、GET 接口、筛选分页和 POST 搜索;你可以直接打开这些页面,用 F12 和下面的 Python 代码采集同一批商品,无需先搭建示例服务器。
网站使用固定的 24 件虚拟商品,页面负责提供业务操作,操作步骤和分析方法在本文中。下面的请求代码默认使用这个公开站点;如有自己的本地实例,可以设置 CDP_BASE_URL 切换地址。
1. CDP 是什么,为什么网络爬虫会用到它
CDP 是 Chrome DevTools Protocol,即 Chrome 开发者工具协议。它允许外部工具与 Chromium 系浏览器通信,查看页面、调试脚本、观察网络活动等。Chrome DevTools 自身也使用它。协议按 Network、DOM、Debugger 等域组织,每个域提供相关命令和事件。1
你可以把它放进下面这张关系图里理解:
text
网站服务器 ──HTTP 响应──> 浏览器 ──解析 HTML / 运行 JS──> 屏幕上的页面
↑
DevTools 或 CDP 客户端
查看浏览器内部发生了什么
注意图中两条不同的链路。HTTP 负责网站与客户端之间的请求响应;CDP 负责工具与浏览器之间的调试通信。用 Python 直接请求 API,不必经过 CDP;如果需要浏览器的执行上下文、动态 DOM 或协议事件,CDP 才提供额外能力。
这解释了为什么一个以 CDP 为主题的系列,第一篇要从 HTTP 开始。先把简单路径用明白,才能判断浏览器能力解决了哪个具体问题。否则"用了浏览器"很容易变成一个习惯,而不是有依据的选择。
2. 先把网页拆成三样东西
想象一个有 24 件商品的目录站。屏幕上显示名称、价格和库存,底部有翻页按钮。我们把它拆成三样东西:服务器返回的文本、浏览器里的结构、提供业务数据的请求。
HTML 是服务器返回的页面文本,描述标题、表格、按钮等结构。浏览器的 View Source 显示一次页面源码请求得到的文本;若要核对刚才那次导航的响应,Network 中 document 请求的 Response 更直接。两次请求之间网站状态可能变化,因此不要把它们无条件视为同一份快照。
DOM 是浏览器解析 HTML 后维护的节点树。JavaScript 可以继续往树里插入商品卡片,所以 Elements 面板里的内容可能比原始 HTML 多。BeautifulSoup 只解析你交给它的文本,不会替你执行页面脚本。
API 响应 是另一条可能提供商品的路径。页面可以先收到一个空表格,再通过 JavaScript 请求 JSON,把数据填进去。API 是应用程序接口,JSON 是它常用的数据表达格式。
三者有联系,却不能混用证据。看到 Elements 里有价格,只能证明浏览器当前有这个节点;不能证明价格就在最初的 HTML 响应里。后面第三篇还会增加 JavaScript 内存这一层。
3. 实验一:HTML 已经包含数据时,解析文本就够了
动手入口:打开静态商品目录实验页。 打开页面后,先看商品表,再用"查看网页源代码"或 Network 的 document 响应查找商品行。下面的解析代码就可以请求这个在线页面。
先在原始 HTML 中搜索一个完整商品名。如果找得到,再核对名称、价格和库存是否都出现。如果三个字段都在同一行,普通 HTTP 客户端通常就能完成这一步采集。
例如响应中包含:
html
<table id="product-list">
<tr class="product" data-product-id="1">
<td class="product-name">Keyboard A</td>
<td class="product-price">¥899.00</td>
<td class="product-stock">12</td>
</tr>
</table>
选择器 #product-list tr.product 表示"在 id 为 product-list 的区域中,找 class 含 product 的 tr"。这里需要的是能解释选择器与字段对应关系,不必先掌握整套 CSS。
下面的解析函数可以直接用这段 HTML 试验,不依赖浏览器:
python
from decimal import Decimal
from bs4 import BeautifulSoup
def parse_products(html: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
products = []
for row in soup.select("#product-list tr.product"):
name = row.select_one(".product-name")
price = row.select_one(".product-price")
stock = row.select_one(".product-stock")
if any(node is None for node in (name, price, stock)):
raise ValueError("商品行缺少必要字段,需要检查页面结构")
products.append({
"id": int(row["data-product-id"]),
"name": name.get_text(strip=True),
"price": Decimal(price.get_text(strip=True).removeprefix("¥")),
"stock": int(stock.get_text(strip=True)),
})
return products
把实际响应交给它时,先检查请求是否成功:
python
import os
import httpx
with httpx.Client(timeout=10.0, trust_env=False) as client:
response = client.get(os.environ.get("CDP_BASE_URL", "https://web-data-lab.pages.dev").rstrip("/") + "/labs/static-html/")
response.raise_for_status()
products = parse_products(response.text)
if len(products) != 24:
raise ValueError(f"示例应有 24 条,实际只有 {len(products)} 条")
Decimal 是这里的一处工程补充。金额若要参与对账,保留十进制精度比立刻转为二进制浮点数更合适。HTML 中的货币符号、千位分隔符和空白还可能因地区而变化,正式采集应把原始价格文本一并保存,方便回看解析规则。2
另一个容易忽略的细节是空列表。页面改版后,BeautifulSoup 仍可能正常运行,只是一个节点也找不到。这样的"成功"会把空数据悄悄送进下游。把预期字段、数量范围、唯一 ID 写成检查,才能及时识别结构变化。
4. 实验二:HTML 是空壳时,去找真正的商品响应
动手入口:打开GET 商品接口实验页。 在打开页面前启用 Network,随后刷新页面,比较 document 响应与商品 JSON 请求,找到表格数据实际来自哪条响应。
现在换一个页面:原始 HTML 只有 <div id="products"></div>,屏幕上却有完整商品。这时继续修改 HTML 选择器没有意义,需要观察页面加载之后发了哪些请求。
在 DevTools 打开 Network,再刷新页面。先用 Fetch/XHR 过滤页面脚本的取数请求;选一条请求,看 Response 是否含有某件商品的完整字段。如果没有,继续看其他请求,不要仅凭接口名称判断。Chrome 的 Network 面板可分别查看请求头、Payload、响应内容与发起者。3
假设真正的数据响应是:
json
{
"count": 24,
"results": [
{"id": 1, "name": "Keyboard A", "price": "899.00", "stock": 12}
]
}
这时应记录一份最小请求说明,而不是立刻把浏览器的全部内容复制进程序:
| 项目 | 要记录什么 | 为什么重要 |
|---|---|---|
| Method 与 URL | GET,以及完整路径 | 同一路径可能支持不同方法 |
| 查询参数 | 关键词、页码、排序 | 决定查询的数据范围 |
| 响应结构 | 商品数组在哪个字段 | 防止把公告、配置误当商品 |
| 状态与 Content-Type | 200、JSON | 判断实际收到的内容 |
| 必要凭证 | 当前是否需要登录 | 决定后面能否直接复现 |
有了这份说明,Python 的请求才有明确目标:
python
import os
with httpx.Client(base_url=os.environ.get("CDP_BASE_URL", "https://web-data-lab.pages.dev"), timeout=10.0,
trust_env=False) as client:
response = client.get("/labs/api-basic/products/")
response.raise_for_status()
data = response.json()
for item in data["results"]:
print(item["name"], item["price"], item["stock"])
Copy as cURL 可以作为请求样本,但它保存的是某一次请求。里面可能混有过期凭证、与业务无关的浏览器头或一次性参数。后面两篇会解释这些字段的寿命;此刻至少要知道"复制成功"和"长期复现"是不同验收标准。3
5. 延伸:JavaScript 页面不一定都要浏览器爬
"前端用了 JavaScript"很容易被理解成"必须用浏览器"。这两个判断之间还缺一步:JavaScript 用来计算数据,还是只负责展示接口已经返回的数据?
如果 JSON 已包含你要的名称、价格和库存,浏览器只是把它画成卡片,Python 直接调用这个接口通常更简单。它不用等待样式、图片与页面交互,也不会因为按钮文本变了而失效。
还有混合情况。服务端先渲染商品,再由前端接管交互;或者 HTML 里嵌了一段初始化 JSON。此时页面既不是纯静态,也不是纯客户端取数。分析时搜索目标字段,而不是先给技术栈贴标签。需要的字段在哪一份响应里完整出现,往往比它用了哪个前端框架更有决定性。
类似地,GraphQL 请求也没有改变核心方法:关注发送的查询、变量与返回字段;仅凭 URL 可能分不出不同业务请求。你掌握的是请求与数据的关系,不是某一种 URL 长相。
6. 实验三:分页是数据完整性问题
动手入口:打开商品筛选与分页实验页。 试着筛选商品并翻页,观察 URL 查询参数如何变化,再按下面的方法逐页采集并核对是否取得全部 24 件商品。
第一条 API 请求成功后,你可能发现只有 6 条商品。响应写着 count=24,页面也有下一页。这时真正的问题从"能拿到吗"变成"拿全了吗"。
示例使用页码分页:page 是页码,page_size 是每页条数,count 是匹配总数,total_pages 是总页数。查询字符串中的中文关键词交给 params= 编码即可,避免自己拼百分号。HTTPX 文档介绍了参数、JSON 请求体和响应解析的对应写法。4
python
def collect_pages(client: httpx.Client, keyword: str) -> list[dict]:
items = []
seen = set()
expected_count = None
page = 1
while True:
response = client.get("/labs/pagination/products/", params={
"q": keyword, "page": page, "page_size": 6,
})
response.raise_for_status()
data = response.json()
if expected_count is None:
expected_count = data["count"]
elif expected_count != data["count"]:
raise ValueError("翻页期间总数变化,这次结果不能当作稳定快照")
for item in data["results"]:
if item["id"] in seen:
raise ValueError(f"商品 {item['id']} 在多页重复出现")
seen.add(item["id"])
items.append(item)
if data["total_pages"] == 0 or page >= data["total_pages"]:
break
page += 1
if len(items) != expected_count:
raise ValueError(f"应有 {expected_count} 条,实际 {len(items)} 条")
return items
这个函数针对静态演示数据故意采用严格检查。实时网站的总数可能正常变化,届时不应简单把它定性为程序错误,而要重新定义采集口径:抓某个固定时间的快照,还是收集一段时间内出现过的所有记录?
如果列表按最新更新时间排序,采集第二页前又插入一条记录,原来的边界会移动,第一页末尾的记录可能再次出现在第二页。去重能处理重复,却不能证明没有遗漏。若接口支持固定排序、截止时间或服务端快照,应优先使用;如果只能滚动翻页,就把这种边界写进结果说明。
从页码延伸到游标分页
有些接口不返回 total_pages,而是返回 next_cursor 或 has_more。游标通常代表服务端认可的下一段边界,不应把它猜成页码。每次把响应给出的游标原样传回,并记录已经使用过的游标,防止重复游标造成死循环。
页码分页的完成证据可以是总数对账;游标分页则需要沿链走到明确的终止标志,并检查唯一 ID。无限滚动只是页面交互形式,底层仍可能是这两种分页协议之一。先找滚动触发的请求,通常比自动滚到页面底部更容易解释完整性。
7. 实验四:POST JSON,变的是请求结构
动手入口:打开POST 目录搜索实验页。 在页面上输入关键词并提交搜索,然后查看请求方法和 Payload。将这次操作与上一节的 GET 查询对照,再用 Python 发送同样的 JSON 请求体。
搜索条件多起来后,接口可能使用 POST,把关键词、分类和排序放进请求体。Network 的 Payload 展示这些内容,Content-Type: application/json 告诉服务端正文应按 JSON 解释。34
python
import os
with httpx.Client(base_url=os.environ.get("CDP_BASE_URL", "https://web-data-lab.pages.dev"), timeout=10.0,
trust_env=False) as client:
response = client.post("/labs/post-json/products/search/", json={
"keyword": "keyboard", "page": 1,
})
response.raise_for_status()
print(response.json())
这里有三个容易混淆的参数:params= 编码到 URL;json= 编码为 JSON 正文;data= 常用于表单编码。服务端按它的接口契约解释请求,不会因为三者都能放"键值对"就把它们当成一样。4
GET 与 POST 的意义也不能只概括成"参数放在哪里"。GET 定义为读取资源,POST 的业务语义由服务端决定,可能是搜索,也可能是创建订单。对搜索 POST 是否可以重试,需要确认接口是否有副作用;不能照搬"GET 失败就重试"的策略。5
8. 从演示脚本走向一次有质量的采集
假设一个经营分析团队要比较商品目录中的价格和库存。这是教学业务案例,不是某个客户的真实结果。交付一份 24 行 CSV 很容易;证明这份 CSV 没漏商品、没把旧价格当新价格,才是后面的工作。
我会在数据旁边保留采集时间、商品 ID、原始价格文本与来源路径。价格解析失败就保留错误记录,不把异常吞掉后填零。若目标字段缺失,停止这一批结果进入正式统计,先核对响应与页面结构。这些检查的价值在于,让问题发生时仍能找回证据。
HTTP 客户端层也应有边界。httpx.Client 提供连接池与跨请求配置复用;超时可以按连接、读取、写入和连接池等待区分。出现超时,先确认卡在哪个阶段,再决定是重试、减小批量还是降低并发。67 示例中的 trust_env=False 用于本地实验,避免环境代理改变环回请求路径;正式环境是否使用代理应由实际网络配置决定。
最后,不要只保留"爬虫成功"的一条日志。至少知道请求了多少页、每页多少条、原始记录数与唯一 ID 数是否一致,以及哪些页失败。这样第二天目录变成 23 条时,才能判断是业务变动还是采集退化。
9. 这一篇之后,应该能解释哪些现象
你应能解释:为什么屏幕有商品而原始 HTML 没有;为什么 API 返回 200 却只得到一页;为什么 POST 也能做搜索;为什么相同解析器喂入不同 HTML 会得到不同结果。四个实验的价值在于形成一条可迁移的分析顺序,而不是记住四个接口地址。
下一篇的商品接口会加入身份校验:浏览器成功,Python 却收到 401 或 403。我们会从 Cookie 的自动携带讲到 Session、CSRF 和 Token,继续追查请求到底差在哪里。
References
1 Chrome DevTools Protocol. Chrome DevTools. Published/updated: not listed. Accessed: 2026-10-01. Used for: CDP 的定义、域与命令事件。
2 decimal --- Decimal fixed-point and floating-point arithmetic. Python documentation. Published/updated: not listed. Accessed: 2026-10-01. Used for: 十进制金额处理。
3 Network features reference. Chrome for Developers. Published/updated: not listed. Accessed: 2026-10-01. Used for: Network 观察入口与请求复制。
4 QuickStart. HTTPX. Published/updated: not listed. Accessed: 2026-10-01. Used for: 参数、请求正文与响应解析。
5 HTTP request methods. MDN Web Docs. Published/updated: not listed. Accessed: 2026-10-01. Used for: HTTP 方法语义。
6 Clients. HTTPX. Published/updated: not listed. Accessed: 2026-10-01. Used for: 客户端连接池和配置复用。
7 Timeouts. HTTPX. Published/updated: not listed. Accessed: 2026-10-01. Used for: 超时阶段的区分。