本节目标:弄明白「为什么有些页面用 requests 抓回来是空的」,掌握在动浏览器之前的两条替代路线(内嵌 JSON、底层 API),并理解浏览器自动化的适用边界与代价。
适用版本:Python 3.12+(实测 3.14.6);httpx 0.28.1、beautifulsoup4 4.15.0(Playwright / Selenium 本机未安装)
12.2 动态页面与浏览器自动化
12.1 的解析建立在「服务器把数据写进了 HTML」这个前提上。但今天的网站大量是 SPA(单页应用):服务器只返回一个空壳 HTML 加一堆 JS,真正的数据是浏览器执行 JS 之后才渲染出来的。你 requests.get 拿到的 resp.text 里根本没有数据——不是解析写错了,是数据当时还不存在。
这一节先把这个现象跑出来看,再给出工程上的处理顺序。
12.2.1 先复现「抓不到」:一个 JS 渲染的页面
本地假站点上放一个 /dynamic.html:评论区容器是空的,数据藏在一段 <script type="application/json"> 里,由页面 JS 读取后填进 DOM:
<h1>评论(JS 渲染)</h1>
<div id="comments"></div>
<script id="bootstrap" type="application/json">
{"comments": [{"user": "alice", "text": "好用"}, {"user": "bob", "text": "一般"}]}
</script>
<script>
const data = JSON.parse(document.getElementById("bootstrap").textContent);
document.getElementById("comments").innerHTML =
data.comments.map(c => `<div class="comment">${c.user}: ${c.text}</div>`).join("");
</script>
用 12.1 的方法静态解析它,实测:
静态解析 #comments 内容: ''
静态解析 .comment 数量: 0
#comments 是空的,.comment 一个都没有。但注意:脚本标签里的 JSON 明明就在 HTML 里——只是没被渲染进 DOM。这就是关键分岔点。
12.2.2 怎么判断一个页面是不是动态的
不要凭感觉,用两个动作就能判定:
# 拿「服务器原始返回」,搜一个只可能出现在渲染结果里的关键词
curl -sS https://目标站点/some-page | grep -c "目标关键词"
如果 curl 拿到的 HTML 里搜不到该关键词,但浏览器开发者工具的 Elements 面板里明明看得到——那这个页面就是动态渲染的。注意这个判据要看「渲染后才存在的文本」,别拿数据字段名去搜(很多页面源码里就有字段名,只是没渲染成 DOM)。判据整理成表:
| 观察点 | 静态页面 | 动态页面 |
|---|---|---|
curl 返回的 HTML | 含数据 | 只有空壳/骨架 |
| 开发者工具 Elements | 与 curl 一致 | 比 curl 多出内容 |
| Network 面板 | 只有文档请求 | 另有 XHR/Fetch |
| 右键「查看网页源代码」 | 含数据 | 只有脚本 |
「查看网页源代码」和「Elements」的差异,是判断动态页最快的方法:前者是服务器返回的原文,后者是浏览器执行 JS 后的结果。两者不一致,就是动态页。
12.2.3 优先方案一:解析内嵌 JSON
很多「动态页」会把首屏数据以 JSON 塞进 <script> 标签。既然它就在返回的 HTML 里,静态解析照样能拿到:
import json
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
script = soup.select_one("script#bootstrap")
payload = json.loads(script.string)
print(payload["comments"])
实测:
内嵌 JSON 解析: [{'user': 'alice', 'text': '好用'}, {'user': 'bob', 'text': '一般'}]
数据拿到了,没动浏览器。常见的内嵌数据位置有固定套路:
| 框架 / 场景 | 数据位置 | 备注 |
|---|---|---|
| Next.js | <script id="__NEXT_DATA__" type="application/json"> | 首屏 props 全在里面 |
| Nuxt | window.__NUXT__ = {...} | 在 <script> 内联 JS 里 |
| Redux 类 SPA | window.__INITIAL_STATE__ / window.__PRELOADED_STATE__ | 需正则抠出再 json.loads |
| 结构化数据 | <script type="application/ld+json"> | 本就是给机器读的 |
| 通用 | <script type="application/json"> | 站点自定的 bootstrap |
工程判断很朴素:先搜一遍返回的 HTML 里有没有 JSON,有就直接 json.loads——比驱动浏览器快几个数量级。script.string 拿到标签内文本;标签多时用 soup.find_all("script", type="application/json") 逐个试。若是 window.__XXX__ = {...} 这种内联 JS,则用正则截出 {...} 再 json.loads。
12.2.4 优先方案二:直接打底层 API
如果 HTML 里也没有内嵌 JSON,那数据多半是页面 JS 又发了一次请求拿的。打开浏览器开发者工具的 Network 面板,筛 XHR/Fetch,刷新页面,看它请求了哪个接口。找到后,用 httpx 直接打这个接口:
import httpx
with httpx.Client(base_url=BASE, timeout=5, trust_env=False) as c:
data = c.get("/api/items").json()
print([(i["id"], i["name"], i["price"]) for i in data["items"]])
实测:
API items: [('A-100', '机械键盘', 299.0), ('B-200', '无线鼠标', 129.5)]
这是最干净的路线:返回的是结构化 JSON,不受 HTML 改版影响,体积还小。代价是你要摸清接口的参数、分页、鉴权(很多接口需要页面上的 token 或 Cookie)。逆向接口前先确认合规边界——接口若是公开数据、且在 robots/ToS 允许范围内,可行;若接口需要登录态或明确只供自家前端使用,就要停下来评估(见 12.3)。
12.2.5 兜底方案:浏览器自动化
当以上两条都走不通(数据由 JS 运行时计算、强依赖鼠标交互、有反自动化检测),才轮到真正驱动一个浏览器。安装(本机未执行,仅示意):
# 伪代码:本机未安装 playwright,未实测
pip install playwright
playwright install chromium # 下载浏览器内核
Playwright 的同步 API 形态(示意,未实测):
# 伪代码:本机未安装 playwright,未实测
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("http://example.com/dashboard", wait_until="networkidle")
page.wait_for_selector(".data-table") # 等目标元素真正渲染出来
rows = page.query_selector_all("tr")
for row in rows[:5]:
print(row.inner_text())
browser.close()
Selenium 的等价形态(示意,未实测):
# 伪代码:本机未安装 selenium,未实测
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
driver = webdriver.Chrome()
driver.get("http://example.com/dashboard")
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".data-table"))
)
for row in driver.find_elements(By.CSS_SELECTOR, "tr")[:5]:
print(row.text)
driver.quit()
两者核心都是:启动真实浏览器内核 → 加载页面并执行 JS → 等元素出现 → 读取渲染后的 DOM。区别在于 Playwright 自带浏览器、默认更快的自动等待,Selenium 生态更老、企业里存量代码多。选型上:新项目用 Playwright。
12.2.6 浏览器自动化的真实代价
别把 Playwright 当默认选项,它是有重量的:
| 维度 | 静态请求(requests/httpx) | 浏览器自动化 |
|---|---|---|
| 单次耗时 | 几十到几百毫秒 | 秒级(启动 + 渲染) |
| 资源占用 | 几 MB 内存 | 每个实例数百 MB |
| 并发能力 | 几十上百并发轻松 | 受内存/CPU 限制,通常个位数 |
| 稳定性 | 高(纯 HTTP) | 受版本、驱动、渲染时序影响 |
| 维护成本 | 低 | 高(选择器要跟 UI 走) |
| 抗改版 | 依赖接口/结构 | 相对贴近「人看到的样子」 |
一句话:能用 HTTP 解决就别开浏览器。 只有当「数据必须经过 JS 运行时才存在」且「没有可用接口」时,才用它兜底。调试期可临时用 headless=False 看浏览器实际在做什么,出问题先 page.screenshot(path="debug.png") 留证据。
12.2.7 等待策略:别用固定 sleep
动态页面最脆的地方是时序。新手常写 time.sleep(3) 等页面加载——网络快时白等,网络慢时又不够。正确做法是等「条件」而不是等「时间」:
# 伪代码:本机未安装 playwright,未实测
page.goto(url)
page.wait_for_selector(".data-table", state="visible") # 等元素可见
page.wait_for_response(lambda r: "/api/items" in r.url) # 等某个接口返回
page.wait_for_load_state("networkidle") # 等网络空闲
对应 Selenium 是 WebDriverWait + expected_conditions。条件等待把「固定的猜测」换成「确定的事件」,是浏览器自动化稳定性的第一来源。此外要设总超时(否则一个卡住的页面能挂死整个任务)。
一个常见的失败模式是选择器写错但页面没报错:wait_for_selector 一直等不到元素,直到超时才抛 TimeoutError。排查顺序是——先用 headless=False 肉眼看页面到没到目标状态,再确认选择器在渲染后的 DOM 里能否匹配,最后才怀疑超时值。顺序反了会白折腾很久。
12.2.8 决策树:拿到一个页面先问什么
把前面的顺序固化成流程,遇到任何目标都照此走:
| 现象 | 先试 | 再试 | 最后 |
|---|---|---|---|
| 静态 HTML 就有数据 | requests/httpx + bs4 | — | — |
HTML 里有 <script type=...json> | json.loads 内嵌数据 | — | — |
| HTML 空、有 XHR 请求 | 逆向接口用 httpx 直连 | 浏览器抓接口 | 浏览器全流程 |
| 数据靠 JS 计算 / 强交互 | 浏览器自动化 | — | — |
顺序就是优先级:越靠左越快、越稳、越省资源。永远从最左边开始试,别一上来就 pip install playwright。
这条顺序还有个副产品:每往左靠一步,稳定性和可维护性都上一个台阶。逆向到的接口哪怕哪天变了,改一个 URL 参数就能修;而依赖 DOM 结构的浏览器脚本,对方一改前端你就得重写选择器。所以「省事」和「抗改版」在这里是同一个方向。
12.2.9 浏览器自动化与合规
开了浏览器不等于拿到了「合法通行证」。反爬机制(验证码、指纹检测、行为分析)的存在,本身就是网站在表达「不欢迎自动化」。本节只讲技术路线,不提供绕过反爬的方法——遇到验证码、加密参数、行为风控,正确做法是停下来评估是否该抓,而不是升级对抗手段。浏览器自动化的合规要求比普通请求更严:更要控制频率、更要带真实 UA、更要尊重 robots(见 12.3)。
延伸阅读
- Python 网络爬虫与自动化:从 requests 到 Playwright —— Playwright 完整用法与反爬应对
- HTTP 客户端与页面解析 —— 静态请求与解析基础
- Web 抓取与合规 —— 抓取前的边界确认
小结
- 动态页面「抓不到」的真相是数据在请求返回时还不存在,不是解析写错了。
- 优先级从高到低:静态 HTML → 内嵌 JSON → 底层 API → 浏览器自动化;能用 HTTP 就别开浏览器。
- 判断动态页最快的办法是对比「查看网页源代码」与开发者工具 Elements,两者不一致即动态渲染。
- 内嵌 JSON(
__NEXT_DATA__、application/json)和 XHR 接口都能用 12.1 的工具直接拿到,实测已跑通。 - Playwright / Selenium 本机未安装,相关代码均为伪代码、未实测;核心是「驱动真实浏览器执行 JS,再读渲染后的 DOM」。
- 等待要等条件不等时间(
wait_for_selector优于sleep),并设总超时。 - 开浏览器不改变合规要求;反爬机制是网站的态度表达,不绕过。
到这里我们有了「拿到数据」的全部手段。但一个能长期跑的采集系统,拼的不是抓得快,而是抓得稳、抓得客气、抓得合法。下一节把限速、重试、断点续采与合规边界收束成一套工程规范。
阅读导航:上一节:HTTP 客户端与页面解析 · 下一节:稳定性、限速与合规边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。