1. 先别急着渲染页面:接口直连为什么更香
很多刚接触动态页面爬虫的朋友,第一反应就是掏出 Playwright 把整个页面渲染出来,然后慢慢等 DOM 出现,再定位元素拿数据。这个思路不能说错,但当你需要抓取的数据量一大、页面一多,很快就会发现:直接渲染是又慢又脆的路子。我做了这么几年爬虫,碰见动态页面时的第一直觉永远是——先打开 Network 面板看一眼,数据是不是从某个接口回来的。如果是,赶紧回到 Requests 去请求这个接口,比 Playwright 稳定太多,速度还快一个数量级。
这一节咱们就把“优先 API”这套思路彻底拆开来讲,从怎么找接口、怎么把接口搬运成 Requests 代码,到 401、429 这类翻车现场怎么处理,一条龙给你盘明白。标题里虽然是 Playwright 的章节,但 Playwright 在这里真正的角色是“侦察兵”,帮你把接口摸清楚,然后你就该把 Requests 这杆枪掏出来干活了。
1.1 直接渲染的隐性成本
先说一个每位爬虫工程师都绕不开的痛点:Playwright 渲染一个页面到底要消耗多少资源?
一个典型的动态网页,浏览器要先把 HTML 下载下来,然后解析里面的 CSS、执行 JavaScript、发起一堆 XHR 请求、等待图片字体加载、等 React/Vue 框架渲染完成,可能还要处理懒加载和 IntersectionObserver 触发的滚动加载。这一套流程下来,单页耗时动不动就是 5 到 10 秒。而接口直连呢?一个 JSON 请求,几十到几百毫秒就完事了,差距不是一星半点。
更麻烦的是稳定性。页面渲染是“黑盒”,你永远不知道这次渲染会不会因为某个静态资源超时而失败,会不会因为网络抖动导致某个 JS 没加载出来,从而导致页面结构错乱。用了 Playwright 的朋友多少都经历过这种折磨:wait_for_selector超时、元素不可见、iframe 嵌套找半天、懒加载内容死活不出来。这些问题的根源是——你在跟“浏览器环境”这个不可控的东西打交道,而接口直连只需要跟“一个返回 JSON 的 URL”打交道。
还有一点容易被忽略:渲染页面时你的爬虫特征也暴露得更多。浏览器的自动化痕迹、WebDriver 检测、Canvas 指纹、行为轨迹这些东西在接口直连的方案下统统不存在。你只是发了一个很普通的 HTTP 请求,目标服务器看到的和普通用户刷新页面时发起的请求几乎一样,这反而是最不容易被盯上的姿势。
1.2 API 优先方案省在哪
所谓 API 优先,就是一句话:动态页面里的数据,大部分都是通过接口从后端拿的。页面渲染只是把这些数据“画”到屏幕上。你既然要数据,为什么不直接去源头取?
我打个比方。你去餐厅吃饭想看菜是怎么做的,直接去后厨找厨师问,绝对比把整本菜单背下来再回家自己猜来得快。页面上的 DOM 是“菜单”,XHR 接口才是“后厨”。直接把接口抓回来,省掉了解析 HTML 的体力活,拿到的是结构清晰、字段明确的 JSON 数据,处理起来不知道有多顺手。
接口直连还有一个隐藏优势:可重试、可并发。Requests 挂了重试三次只需要 300 毫秒,Playwright 页面渲染到一半崩了,重来又是 10 秒。做爬虫最怕的"数据抓了一半程序崩了"这种事故里,接口直连续跑的稳定性简直是人品保证。
那什么时候该用 API 优先?只要看到页面的 XHR/Fetch 请求返回的是 JSON 格式的数据,并且这个接口返回的数据就是你需要的字段,那就可以直接用 Requests。什么时候不能硬上?接口路径带了加密的 sign、token、时间戳签名,或者数据是通过 WebSocket 持续推送的那就别硬怼了,老老实实走渲染路线,后文我会专门讲这事的兜底方案。
2. 用 Playwright 当侦察兵:三步锁定数据接口
既然要先找接口,那 Playwright 到底怎么用?其实一点也不复杂,核心就一句话:监听网络请求,把页面在运行过程中发出的所有 XHR/Fetch 请求都打印出来,然后从里面挑出那个返回了你要的数据的接口。
我带你们走一遍完整的侦察流程。
2.1 监听网络请求的脚手架代码
在 Playwright 里监听网络请求有两种姿势,一种是听response,一种是听request。我建议听response,因为你可以直接拿到响应状态码和响应体,判断这个接口是不是真的返回了数据。下面这段代码是日常最常用的脚手架:
import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: browser = await p.chromium.launch(headless=False) # 用有头模式,方便观察 page = await browser.new_page() def on_response(response): # 只关注 XHR 和 Fetch 请求,过滤掉图片、CSS、JS 文件 if response.request.resource_type in ("xhr", "fetch"): url = response.url status = response.status content_type = response.headers.get("content-type", "") print(f"[{status}] {content_type} | {url}") page.on("response", on_response) await page.goto("https://example.com/your-target-page", wait_until="networkidle") await asyncio.sleep(5) # 多等几秒,让懒加载也触发 await browser.close() asyncio.run(main())resource_type是关键。页面加载时会发出一大堆请求,图片、字体、CSS、JS 都是资源,只有xhr和fetch才可能是数据接口。跑完这段代码后,你会看到控制台疯狂输出页面发出的所有接口请求,这就是你的“接口清单”。
2.2 怎么从几十个请求里挑出真正的数据接口
一个稍微复杂点的页面,清单里可能会有几十个 XHR。怎么判断哪个是你想要的?我有三条经验:
第一,看返回内容类型。优先锁定content-type是application/json的请求。如果一个请求返回的是 JSON,那大概率就是数据接口。第二步,直接在on_response里把响应体打出来看一眼:
def on_response(response): if response.request.resource_type in ("xhr", "fetch"): try: data = response.json() if isinstance(data, (dict, list)): print(f"URL: {response.url}") print(f"BODY: {str(data)[:200]}") except Exception: pass第二,看 URL 特征。接口地址里通常带着api、data、list、get、search这类词汇,很容易和那些埋点统计接口区分开。埋点接口一般长这样:/collect?event=click,数据接口一般长这样:/api/v1/products?page=1。
第三,看触发时机。你要抓的那个数据,一定是在页面某个动作之后才出现的。比如你点了一个按钮、滚动了一下页面、切换了一个 Tab,那个新发起的 XHR 大概率就是对应的数据接口。这招比你挨个 URL 去猜要高效一百倍。
2.3 从浏览器界面反向定位接口
如果嫌写监听代码麻烦,还有更直观的办法:直接用 Playwright 启动有头浏览器,打开目标页面后按 F12 打开开发者工具,切到 Network 面板,手动操作页面,然后看着请求队列里一个个冒出来的 XHR。找到那个返回 JSON 的接口后,右键它,选择“复制 → 复制为 cURL”。
这个操作是很多爬虫老手都离不开的杀手锏。复制出来的 cURL 命令会把整个请求的 URL、请求头、Cookie、请求体全都带出来,下一步你要做的事就是把它翻译成 Python 的 Requests 代码。后面讲到搬运的时候我再细说。
我经常把这个“手动看面板”和“自动监听”配合着用:先用脚本监听找到候选接口,再用有头浏览器打开 Network 面板去确认它的请求头和数据格式。两手抓,稳得很。
3. 把 Network 里的信息搬运成 Requests 代码
接口找到了,接下来就是机械活:把 Network 面板里看到的信息,翻译成 Requests 代码。这一步不难,但特别考验细心程度。很多人的请求发出去拿不到数据,不是姿势不对,而是漏了某个请求头或者某个参数。
3.1 先搞清楚请求的“四件套”
要复用一个接口,你最少需要搞清楚四件事:URL、请求方法、请求头、请求体。在 Network 面板里点开一个请求,General 区域写的是 URL 和请求方法;Headers 区域写的是请求头;Payload 或 Query String Parameters 区域写的是请求参数。
这里我要特别提醒一个新手常踩的坑:URL 里的查询参数和 POST 的 Form Data 不是一回事。URL 里?后面的key=value是 Query 参数,在 Requests 里要用params参数传递;POST 提交的键值对是 Form Data,在 Requests 里要用data参数传递;如果请求体里是一段 JSON 字符串,要用json参数传递。这三者搞混了,服务器就会一直报参数缺失或者 400 错误。
我列个表,你们对着搬就行:
| Network 面板里看到的位置 | 含义 | Requests 里的写法 |
|---|---|---|
| URL 中 ? 后面的内容 | Query String Parameters | params={"page": 1} |
| 请求体为键值对 | Form Data | data={"username": "test"} |
| 请求体为一串 JSON | Request Payload | json={"username": "test"} |
| 请求头 | Headers | headers={"User-Agent": "..."} |
3.2 Session、Headers、Cookies 一个都不能少
复用一个接口,最稳妥的做法是创建一个requests.Session(),因为这个 Session 会自动管理 Cookie,而且可以在一个地方统一设置 Headers。我写搬运代码的习惯是先把这个页面里所有请求共用的请求头提取出来,放到 Session 上:
import requests s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example.com/your-target-page", "Origin": "https://example.com", })超时和重试也建议一开始就配好,不要裸奔着用。给 Session 装一个 HTTPAdapter,设置总重试次数:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry = Retry(total=3, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry) s.mount("https://", adapter) s.mount("http://", adapter)这三件事做完,你的请求才算是具备了一个“合格浏览器”的基本素质。
3.3 三种常见请求体的写法
具体到发请求,我给你们展示三种最常见的形态。第一种,GET + 查询参数:
resp = s.get( "https://api.example.com/v1/products", params={"page": 1, "size": 20}, timeout=10, ) print(resp.json())第二种,POST + Form Data,这种情况常见于登录接口、搜索接口:
resp = s.post( "https://api.example.com/v1/login", data={"username": "your_name", "password": "your_pass"}, timeout=10, )第三种,POST + JSON 请求体,现在前后端分离的项目里特别多:
resp = s.post( "https://api.example.com/v1/query", json={"filters": {"category": "books"}, "page": 1}, timeout=10, )这三种在代码层面区别就是params、data、json三个参数的区别。运行完之后如果服务器返回的是 HTML 而不是 JSON,十有八九是你请求头或者参数没搬全,回 Network 面板再仔细对一遍。如果返回的是 403 或者跳转到登录页,那大概率是 Cookie 没带上。
4. 稳定性翻车现场:401 和 429 到底怎么解
接口直连最让人头痛的就是各种状态码。日常爬虫里,403 和 401 是权限问题,429 是频率限制,500 是服务器问题。这里我重点展开 401 和 429,因为这俩在所有状态码里出现频率是真的高,很多人第一次跑分分钟就扑在它们面前。
4.1 401 鉴权失败:登录态和 Token 的传导
401 的意思是“你没有权限访问”。很多动态站点并不需要你登录,但需要你在请求头里带上一个访问令牌。比如某些数据接口要求Authorization: Bearer eyJhbGci...,这个令牌可能是你点击某个按钮时前端临时生成的,也可能是在登录之后存储在浏览器的 localStorage 里的。
遇到这种情况,我的常规操作是回到 Playwright,先把登录态跑出来,然后从浏览器上下文里把 Token 或者 Cookie 提取出来,再传给 Requests。提取 Cookie 的代码特别简单:
# 在 Playwright 里登录之后 cookies = await context.cookies() cookie_dict = {c["name"]: c["value"] for c in cookies}如果是 Bearer Token,直接让 Playwright 在页面里执行 JS 去取,比如这个 Token 存在 localStorage 里:
token = await page.evaluate("() => localStorage.getItem('token')")然后回到 Requests 这边,在 Session 的 Headers 里加上Authorization。这套“Playwright 负责登录、Requests 负责干活”的组合,是我做爬虫以来觉得最舒服的模式。之前见过用纯 Requests 模拟登录流程的,费了半天劲还不一定过得了验证码,不如让 Playwright 把登录这一步啃下来,剩下的批量抓取全交给 Requests 干,效率直接翻倍。
4.2 429 频率限制:重试、退避与随机延迟
429 是我见惯了的状态码,全称是 Too Many Requests。某个接口短时间内请求次数太多,服务器直接给你限流。很多朋友一收到 429 就慌了,其实这是最“温和”的反爬策略,因为它只是想让你慢一点。
处理方案就是三步:等一下、再试一次、每次等待时间递增。我一般是这样处理的:
import time import random def fetch_with_retry(session, method, url, max_retries=5, **kwargs): for attempt in range(max_retries): resp = session.request(method, url, **kwargs) if resp.status_code == 429: retry_after = resp.headers.get("Retry-After") wait = float(retry_after) if retry_after else (2 ** attempt + random.random()) print(f"收到 429,等待 {wait:.2f}s 后重试") time.sleep(wait) continue return resp raise RuntimeError(f"重试 {max_retries} 次仍然失败,最后一次状态码: {resp.status_code}")这个函数有两个地方值得注意。第一,读取响应头里的Retry-After,服务器如果贴心的话会直接告诉你等几秒,这个提示比你自己瞎猜要准得多。第二,没给提示时采用指数退避加随机扰动,2 秒、4 秒、8 秒这样递增,并且加上一点随机数,避免所有重试请求在同一时刻整齐划一地打过去。
4.3 别把请求频率搞得像机枪扫射
除了上面说的机制问题,我还想说一个容易被忽略的心态问题。很多人拿到接口之后,第一反应就是写一个循环一口气抓几千页。这种写法等于直接对着服务器扫射,不 429 才怪。
高质量爬虫的第一原则是“看起来像人”。批量抓取时,每个请求之间至少睡个几百毫秒到一两秒,网页浏览通常是有停顿的:
import random for page_num in range(1, 101): data = fetch_with_retry(s, "GET", "https://api.example.com/v1/products", params={"page": page_num}) # 处理数据... time.sleep(random.uniform(0.8, 2.0)) # 随机延迟,别固定在一个值记住一个铁律:宁可抓得慢一点,也不要被对方封了 IP 再从头来过。被封了 IP 之后除了换代理几乎没有别的路子,那成本比慢速抓取高太多了。你是在做公开数据的合规采集,就要确保整个过程不给对方服务器造成压力,这也是爬虫工程里一项非常重要的职业素养。
5. 接口拿不到数据的兜底路线:签名加密与 Playwright 回退
接口优先的思路虽然好,但不是所有网站都给你留这么一条康庄大道。有些动态页面的数据接口明明就在 Network 面板里躺着,但你把参数原封不动地拼到 Requests 里发过去,服务器就是不给你数据,比如返回一个{"code": -1, "msg": "invalid request"}。这时候就要看看是不是参数里带了动态签名。
5.1 当 URL 和参数里出现 sign/token/ts
常见的情况是接口的参数里带着sign、_signature、timestamp、nonce这类字段。这些字段是前端在发请求前,通过固定的算法对参数和时间戳做签名运算生成的。服务器收到后会检验这个签名是否合法,如果不对就拒绝返回数据。
这类接口用纯 Requests 复制下来是没法直接跑的,因为每次请求的签名都不一样,而且签名算法往往藏在压缩混淆过的 JS 文件里。面对这种情况,我的建议分三个层次:
- 第一个层次,如果这个站点有官方开放 API,优先用官方的。很多数据其实都能通过正规渠道拿到,不用死磕反爬签名。
- 第二个层次,如果你只是偶尔抓一次,最省事的方式就是让 Playwright 渲染页面之后从 DOM 里拿数据,或者拦截接口的响应——反正页面本身已经帮你把签名算好了,你直接捡现成的。
- 第三个层次,人工把 JS 里的签名逻辑逆向出来,用 Python 模拟生成。这个工作量最大,只适合你准备长期维护这个数据源的情况。
5.2 兜底方案:Playwright 渲染加数据抽取
既然 Playwright 已经在我们手里了,那在 API 方案走不通的时候,直接把它拉上场作为兜底就行。比如找一个支持懒加载的列表页,用 Playwright 滚动到底部触发加载,然后通过page.locator去定位页面上的数据元素,逐条抽出来。虽然慢,但也是能稳定拿到数据的路子。
还有一种更省事的方法:既然页面渲染后已经把数据挂到了全局变量里,比如某些站点把初始数据写进了window.__INITIAL_STATE__,那你完全可以不解析 DOM,直接执行一波page.evaluate把这段数据拿出来,本质上还是在跟数据打交道,只不过藏在页面而不是接口里:
# 在 Playwright 中 data = await page.evaluate("() => window.__INITIAL_STATE__")这招在不少前端框架项目里都通用,速度比逐个定位 DOM 高一个档次。还有的站点将数据放到了document.querySelector('#__NEXT_DATA__').innerText里,理论都是一个路子,注意灵活应变就行。
5.3 两条腿走路的取舍标准
我自己的取舍标准是这样的:先花 5 分钟全局扫描 Network 面板的 XHR 列表,看看有没有明文的 JSON 接口。有就立刻复制成 cURL 测试,能用就直接上 Requests;接口带了签名或者要过复杂鉴权就开始评估成本,如果只是单个页面数据,就用 Playwright 渲染一下;如果是要大规模采集,再去考虑抠 JS、写签名算法,否则不值得。
关键词是“评估成本”。爬虫本质是工程,不是炫技。能用最小成本拿到稳定数据的方案,就是好方案。接口优先是一条最优路线,但不代表你必须所有项目都吊死在接口上。学会在 API 和渲染之间灵活切换,才算是把动态页面的抓取玩明白了。
6. 我的接口优先工作流:从 Network 到 Requests 的一气呵成
写了这么多,最后把整个工作流串起来。这不是什么高深的东西,只是一套我反复使用、验证过好用的操作步骤,你们可以直接照搬。
6.1 标准流程七连
- 打开目标页面,用 Playwright 有头模式跑一次,同时在
on_response里监听 XHR/Fetch 请求。如果是手动操作,就打开开发者工具看 Network 面板。 - 从请求清单里挑出返回 JSON、URL 看起来像数据接口的那一两个候选。
- 在 Network 面板里点开候选接口,看清楚请求方法、请求头、Query String、Payload 的具体结构。
- 右键这个请求,选择“复制为 cURL”。这个操作会自动带上全部请求头、Cookie 和请求体。
- 把 cURL 命令翻译成 Requests 代码。你有两种偷懒路子:一个是手动搬 URL、请求头、参数;另一个是用现成的在线转换工具把 cURL 直接变成 Python 代码,搬完再微调。
- 放入一个 Session,固定好 User-Agent、Referer、Cookie,加上超时和重试,先单发一次测试通。
- 确认单次请求没问题,再写批量循环。循环里务必带上随机延迟和 429 退避重试。
走到第七步,这个数据源你就已经稳定拿下了。后面无非是根据抓取结果的字段名去调整参数、翻页逻辑之类的细节。
6.2 两个容易被忽视的实操细节
一个细节是“先复制 cURL 再转换成代码”这个操作真的很香。很多新手喜欢从 Network 面板里一个个字段手动搬到 Python 里,一旦某个请求头太长、加密字段太多,搬着搬着就漏了。复制 cURL 属于“原样搬运”,能最大限度保证请求头完整性,出错概率低很多。前提是你对转换出来的代码稍微懂一点,能看出哪些是必须留的、哪些是埋点参数可以删。
另一个细节是 Playwright 在整个流程里的定位。我把“接口优先”和“Playwright 侦察”当成一个整体来看,而不是非此即彼。接口不明确时,Playwright 负责把登录态和 Token 跑出来,再把 Cookie 传给 Requests;接口明确时,Playwright 也负责在页面里触发各种操作,让你看到数据请求是在哪个动作之后发出的。Requests 是干重活的主力,Playwright 是指路的侦察兵,两个配合着用才是这套方案的完全体。
我个人现在的习惯是,接到动态页面需求后,先不要急着写抓取代码,先花几分钟把 Network 面板翻一遍。大部分情况下数据接口就是明晃晃地躺在里面,找到它然后回到 Requests,你的抓取任务就已经完成了八成。别低估“先分析、后编码”这件事的价值,磨刀不误砍柴工,在爬虫开发里体现得淋漓尽致。