☰
动态网页爬虫实战:从定位接口到稳定抓取加载中的数据
2026/10/16 3:53:41 网站建设 项目流程

如果你的爬虫也遇到过这种诡异情况——浏览器里数据明明白白摆在那,Python 拿回来的 HTML 里却只有一堆空壳标签——那你大概率是撞上了动态加载这个拦路虎。我做某个数据采集项目时就被这个 Bug 卡了一个下午,页面上滚动加载的商品列表清清楚楚,用 requests 请求源码却连半个数字都找不到,那一刻的心情,懂得都懂。

这篇文章就是围绕这一个 Bug 来的:怎么识别"加载中的数据"、怎么找到它真正的来源、用什么方案稳定抓到,以及我踩过的那些坑。不管你是刚接触爬虫的新手,还是已经写过不少脚本但没系统处理过动态页面的开发者,这篇文章都能帮你省下至少半天的排查时间。

1. 先弄明白:为什么页面有数据,源码却没有

很多人在第一步就被搞懵了。打开浏览器,数据好好的;用 Python 一抓,啥都没有。其实不是数据消失了,而是你抓错了地方。

1.1 静态页面和动态页面的本质区别

传统静态页面,服务器把完整的 HTML 一次性返回给浏览器,里面直接嵌着所有内容。你 requests 拿到的内容和浏览器渲染后的内容基本一致,解析起来非常舒服。

但现在的网站,尤其涉及列表、推荐、滚动加载这种场景,几乎清一色是前后端分离的思路:服务器先返回一个"骨架"HTML,里面只有页面结构、样式、空容器标签,真正的数据要靠浏览器里的 JavaScript 脚本向服务器发额外请求,拿到 JSON 格式的数据后,再动态拼接成 DOM 节点渲染到页面上。

我打个比方。静态页面就像餐厅直接把做好的菜端上桌,你抄起筷子就能吃;动态页面则像给你一个空碗和一张菜单,服务员(JavaScript)拿到菜单后跑去后厨(接口),端回来的菜才是你能吃的部分。你如果不跟着服务员去后厨,光盯着空碗看,等到天亮也等不来菜。

1.2 这个 Bug 的典型特征

结合我遇到的那个场景,这类"爬取加载中的数据"的 Bug,通常有几个一致的信号:

  • 用 requests 或 urllib 请求 URL,返回的 HTML 里能搜到页面标题、导航栏,但就是找不到目标数据字段。
  • 浏览器里刷新页面,数据立刻出现,但源码查看(Ctrl+U)里同样找不到这些数据。
  • 页面有滚动加载、下拉刷新、点击翻页的行为,每次操作都会新增一批数据。
  • 用开发者工具打开 Network 面板,能发现页面加载后不断有 XHR 或 Fetch 类型的请求发出,响应内容是 JSON,里面躺着的正是你要的数据。

如果你中了两条以上,那问题就清楚了:你面对的是一个靠 JavaScript 动态渲染数据的页面。这个时候,正确思路不是去解析 HTML,而是去"偷师"浏览器——看它到底向哪个接口请求了数据,然后让 Python 也去请求那个接口。

1.3 别急着上渲染工具

遇到动态页面,很多人的第一反应是上 Selenium、上 Playwright,用无头浏览器把页面完整渲染一遍再抓取。这个思路没错,但它不是唯一方案,而且往往不是最优方案。真实项目里,接口直连的速度快得多——请求一次接口返回的是干净利落的 JSON,解析逻辑简单,消耗的资源也少得多。渲染方案跑一个浏览器实例,内存占用高、执行速度慢,还得处理窗口等待、元素定位一堆问题。

所以我的建议是:先定位接口,接口搞不定,再上渲染工具。下面我就先讲怎么定位这个藏起来的数据接口。

2. 三步定位动态数据接口

定位接口是整个排查流程里最关键的一步,这一步做好了,后面基本都是水到渠成。我用的是浏览器开发者工具,免费的,每个浏览器都自带,不用装任何额外东西。

2.1 打开 Network 面板,筛选出 XHR 请求

以主流 Chromium 内核的浏览器为例,打开页面后按 F12 进入开发者工具,切到 Network(网络)面板。这里注意一个细节:很多新人不加筛选就看所有请求,结果被 CSS、图片、字体这些静态资源刷了屏,真正关心的接口反而不容易找到。

正确做法是先清空当前请求列表(点一下那个禁止符号),再触发一次数据加载动作,比如点击"下一页"或滚动页面。随后在类型筛选栏里选择 XHR 和 Fetch,把所有非数据类请求过滤掉。这样一来,剩下的请求基本就是页面在用 JavaScript 获取数据的接口了。

逐个点开这些 XHR 请求,在 Response(响应)标签页里看返回内容,一旦看到 JSON 里包含你要的目标字段,比如商品价格、用户昵称、列表标题,立刻就能确认:这就是数据的真正入口。

2.2 从请求头里提取完整信息

找到接口后,别急着复制链接去请求。过来的第一手信息要记完整:完整 URL、请求方式(GET 还是 POST)、Query String Parameters(查询参数)、Request Headers(请求头)、以及 Payload 里的表单数据。

这个环节最容易出问题的地方有两个。一是某些接口的 URL 里带了一堆动态参数,比如页面的 token、时间戳、随机数,这些参数不补齐,接口直接返回空数据或错误码。二是请求头里的一些字段不能省,最典型的是 User-Agent、Referer,有些站还会校验 Cookie。我的习惯是把当前浏览器实际发出的请求头整个复制下来,做成 Python 字典,先保证请求能成功,再逐步做减法,看哪些字段是必须的。

2.3 用 Python 立刻验证一遍

定位完接口,马上写一个最简陋的请求脚本验证连通性,不要等把整个项目写完再跑。我的验证脚本通常长这样:

import requests url = "https://example.com/api/goods/list" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example.com/" } params = { "page": 1, "size": 20 } resp = requests.get(url, headers=headers, params=params, timeout=10) print(resp.status_code) print(resp.text[:1000])

这一步的核心目的只有一个:确认接口在 Python 环境里能返回和浏览器一致的数据。如果返回 200 且 JSON 里有数据,恭喜,最简单的一段路已经打通了。如果报错,反而不慌,因为报错会告诉你下一步往哪排查,最怕的是返回 200 但内容为空,那种情况往往和动态参数或 Cookie 状态有关。

2.4 判断到底是接口还是渲染

定位阶段最后要做一个判断:这个数据是通过接口请求拿到的,还是页面运行了一段非常复杂的 JavaScript 逻辑、在本地计算后才渲染出来的?

绝大多数情况属于前者,我们直接模拟接口就能解决。但也有少数场景,比如数据经过 JS 加密、混淆、或者需要 Canvas 校验,这时候接口返回的是密文或乱码,直接请求拿不到有效信息。判断方法很简单:如果接口返回的 JSON 里已经能直接看到明文数据,那就是接口型;如果返回的是一串看不出规律的字符,那大概率是渲染型,老老实实上浏览器渲染方案吧。

3. 两种主攻方案的选型思路

定位阶段结束,方案基本也就定了。接口能通就走接口,接口走不通或者加密太复杂,就换渲染工具兜底。这两条路我都趟过,具体讲讲怎么选。

3.1 方案A:接口直连,解析 JSON

接口直连的优势非常明显:快、稳、省资源。一次请求拿到的就是结构化数据,直接解析 JSON 就行,不需要关心 CSS 选择器、不需要等待页面元素加载、不需要处理那些会影响 XPath 的动画效果。

具体操作时,有个经验可以分享:尽量复用同一个 requests.Session() 对象。Session 会自动帮你维持 Cookie,对需要登录后才能访问的数据特别有用。另外,接口的翻页逻辑通常藏在参数里,有些是 page/size,有些是 lastId/cursor,观察两页请求的 URL 差异就能总结出规律。

还有一点要注意,接口直连时,JSON 解析要写得稳健一些。真实接口的返回结构往往比你想的要复杂——数据可能嵌在 data.list 里,也可能在 data.result.goods 里,还有可能接口失败时返回的错误信息也带 data 字段。我在实战中习惯先打印整个 JSON 结构,再写解析代码,避免一次踩穿。

3.2 方案B:渲染工具兜底

如果接口返回的是密文、请求头校验太严格、或者压根找不到数据接口(比如数据是通过 WebSocket 推送的),那就用渲染方案。Selenium 和 Playwright 是主力选择,我个人现在更偏向 Playwright,原因后面细说。

渲染方案的本质是:启动一个真实的浏览器内核,让页面像在用户面前一样正常加载、执行 JavaScript,数据自然渲染出来,然后我们再去读取渲染后的 DOM。核心操作思路是这样:

from playwright.sync_api import sync_playwright import time with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example.com/goods", timeout=30000) # 滚动页面,触发懒加载 for _ in range(5): page.mouse.wheel(0, 2000) time.sleep(1) html = page.content() browser.close()

这里要重点提醒:渲染方案最忌讳直接拿 page.content() 就去解析。页面加载是个异步过程,数据渲染需要时间,你必须要么用 time.sleep 硬等,要么更好一点,用显式等待——等待某个包含目标数据的节点出现在页面上。Playwright 里有 wait_for_selector,Selenium 里有 WebDriverWait,都比死等时间要可靠得多。我自己就吃过"等太短没数据、等太长浪费时间"的亏,现在统一用显式等待。

3.3 两种方案的适用边界

方案适用场景核心优势主要代价
接口直连数据接口可访问、无需复杂渲染速度快、零额外依赖需要人工逆向接口逻辑
渲染工具接口加密、数据靠 JS 本地计算通用性强、所见即所得资源占用高、速度慢

我把判断顺序固定成了一句话:先花十分钟找接口,接口通,就别折腾渲染;接口实在不通,才考虑渲染工具。这个顺序帮我省了大量无用功,也推荐你试试。

4. 实操复盘:一个"加载中列表"的完整修复过程

理论说多了容易空,我拿之前踩坑的那个项目,完整还原一遍排查与修复过程。因为涉及具体业务,我把网站代称为某公开数据平台,数据内容是它的滚动加载列表。

4.1 初始状态与 Bug 现象

当时我的目标是抓取平台上某个分类下的全部条目,预计几千条。最初脚本写得很天真:requests 请求列表页 URL,然后用正则或 BeautifulSoup 去解析 HTML 里的条目信息。跑起来结果也很天真:返回内容里只有 30 来条,而且这 30 条还不是我在浏览器看到的那些,仔细一看,是最底部"推荐位"的静态内容,真正的目标列表一条都没有。

页面明明滚动就能加载几百条,Python 却只拿到一点边角料,这就是典型的"爬取加载中的数据"失败现场。

4.2 定位过程全程记录

第一步,打开开发者工具。清空 Network 后,我手动滚动了页面,类型筛选切到 XHR/Fetch,列表里蹦出来一个名为list/query的请求,参数里带着pageNo=1&pageSize=20,Response 是标准 JSON,里面records字段就是我需要的所有条目。

第二步,把请求头和参数复制成 Python 脚本,直接跑。第一次运行就碰了壁——接口返回了code: 40001,提示"非法的访问请求"。排查发现,请求头里少了一个X-Csrf-Token字段,这个字段值是页面加载时由另一个接口下发的,藏在页面的 meta 标签里。

第三步,补上这个 Token 的获取逻辑:先用 requests 请求首页 HTML,用正则从<meta name="csrf-token" content="...">提取 Token,再把 Token 放进后续所有请求头里。跑第二次,接口正常返回数据。

4.3 跑通的完整脚本

最终脚本的核心部分长这样,我删掉了拦截逻辑,保留了骨架:

import requests import re session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example.com/" }) # 第一步:拿首页,提取 CSRF Token home_resp = session.get("https://example.com/", timeout=10) token_match = re.search(r'<meta name="csrf-token" content="([^"]+)"', home_resp.text) if token_match: session.headers.update({"X-Csrf-Token": token_match.group(1)}) # 第二步:循环翻页拿数据 all_records = [] for page in range(1, 11): resp = session.get( "https://example.com/api/goods/list", params={"pageNo": page, "pageSize": 20}, timeout=10 ) data = resp.json() records = data.get("data", {}).get("records", []) if not records: break all_records.extend(records) if page % 3 == 0: print(f"已抓取 {len(all_records)} 条")

这个脚本跑通后,几千条数据几分钟就抓完了,速度比渲染方案快了不是一个量级。整个排查过程回头看,最花时间的其实不是写代码,而是前面被"页面有数据、源码没数据"这个现象误导,白白耗费了几个小时。定位到接口之后,一切就顺理成章了。

5. 这类 Bug 的高频翻车点与排查思路

光讲成功案例不够,我把实际工程里遇到过的这些 Bug 集中整理成了一张对照表,每一个都是我或身边的同事真实踩过的坑。遇到类似症状,可以先对着表看一眼。

5.1 常见报错与对应解法速查

症状常见原因排查方向
返回 200 但 JSON 为空动态参数缺失或 Token 过期对比浏览器请求参数,检查 Cookie 状态
返回 403 / 状态码异常缺少 User-Agent、Referer 等关键请求头补齐请求头,反复对照浏览器实际请求
返回密文 / 乱码接口数据经过加密,或编码解码不对检查响应内容是否被 gzip 压缩,确认加密逻辑
请求超时目标网站响应慢,或请求太频繁被封增加超时时间,加入重试机制和请求间隔
数据只有第一页翻页参数没跟上,或最后一页判断条件错误分析第二页请求差异,确认页数上限
渲染后元素还是空页面数据用异步异步加载,等待时间不够改用显式等待,等待目标节点出现

这张表是我自己常用的"第一排查清单"。遇到问题先别急着改代码,对照一下症状和原因,往往能少走很多弯路。

5.2 动态参数:隐形的第一杀手

所谓动态参数,就是每次请求都不一样的那几个参数。常见的有时间戳、随机数、加密签名、翻页用的 cursor。如果你在浏览器里刷新两次页面,发现同一个接口的 URL 或参数有变化,那这个变化点就是动态参数。

处理动态参数的基本套路是先找它的来源。来源通常有三个方向:一是藏在页面的 HTML 或某个静态 JS 文件里,像前面说的 CSRF Token 是在首页 meta 里;二是由上一个接口的下发值决定的,比如先请求配置接口拿到 token,再带着 token 请求列表接口;三是根据固定算法本地生成的,比如对参数按某种规则做签名。

前两种用 requests 就能解决,第三种相对麻烦,要么用 Python 复现算法,要么直接用渲染方案,让浏览器替我们完成签名生成。判断第几种的简单方式:搜索响应 HTML 或其他 JS 里有没有这个参数的影子,如果用全局搜索能找到,就是前两种;怎么搜都搜不到,每次值又都不一样,多半是第三种。

5.3 请求头与 Cookie:细节决定成败

拦截接口时,浏览器请求头里面往往有十几个字段。有些人手懒,只带一个 User-Agent,结果被服务器识别成异常请求,返回 403 或者空的响应体。我分享一个稳妥的流程:一开始先完整复制浏览器请求头,原封不动放脚本里,Run 通了以后,再用二分法逐个删字段,看删掉哪个不影响返回。这样既能保证稳定,又能摸清哪些是核心校验项。

Cookie 的问题更隐蔽。有些接口本身不需要 Cookie,但列表接口每翻几页后突然返回空数据,原因大概率是服务器端在检测会话连贯性——翻页行为必须带着同一个会话,中途丢失就废了。这就是为什么我一直强调用 Session 复用连接,它帮你在内部自动维护 Cookie,避免这种断片问题。

需要额外提醒一句合规问题:无论是抓接口还是用渲染工具,都要遵守目标网站的服务条款和数据使用规定,关注 robots.txt 的说明,控制好请求频率。技术能力应当用在正当的数据获取场景上,比如自己的项目、公开数据的研究分析,而不是给目标网站带来负担。

6. 渲染工具的进阶细节

前面说了接口优先,但实际项目里总有绕不过去的时候,必须上渲染工具。这个章节说说渲染方案里那些文档里不太会写的细节。

6.1 为什么我后来偏向了 Playwright

Selenium 是老牌工具,生态成熟,资料多,早期我也一直用它。但后来有新项目时我越来越倾向 Playwright,原因有几个:一是它有更好的等待机制,比如 page.wait_for_selector 可以明确等某个节点出现,比 Selenium 那套 WebDriverWait 写起来顺手;二是它对浏览器实例的管理更干净,自动启动、自动回收,很少出现僵尸进程;三是它对滚动加载的处理更自然,前面代码里用 mouse.wheel 模拟滚动,效果很接近真人操作。

当然,如果项目本身已经重度依赖 Selenium,或者你更熟悉它的 API,没必要强行切换。工具是达成目的的手段,顺手最重要。

6.2 等待策略的正确姿势

渲染方案里,最影响稳定性的就是等待策略。很多踩坑案例都是因为等待不够,页面还没渲染完就去抓数据,拿到半个空壳。常见的错误写法是固定 sleep(3)、sleep(5),这既不准确又浪费时间——页面可能 2 秒就加载完了,也可能网络波动 10 秒都没好。

正确姿势是条件等待。Playwright 里可以直接用选择器等待目标数据节点渲染出来:

page.goto(url) page.wait_for_selector(".goods-item", timeout=15000) items = page.query_selector_all(".goods-item")

这个写法的好处是:数据到位了立刻返回,数据没到位最多等 15 秒就报错。既不会白等,也不会错过。等报错出来,你就能判断是选择器写错了,还是数据确实没渲染出来。

还有一类页面需要滚动才能加载更多,等待就得更讲究。我常用的套路是组合拳:滚动一屏 -> 等一秒 -> 检查列表数量是否增加 -> 增加了就继续滚,没增加就再等两秒。这样既照顾了懒加载,又避免了无脑死等。

6.3 渲染方案的血泪提醒

渲染方案有个容易被忽略的问题:浏览器指纹。有些网站能识别出你用的是自动化浏览器,直接在页面上显示异常或者弹验证。遇到这种情况,可以试试伪装浏览器指纹,比如注入修改 navigator.webdriver 属性的脚本、模拟真实的鼠标轨迹和滚动节奏。

但这里有个底线要提醒:这类伪装如果是为了突破对方的技术防护,可能涉及法律风险。正规的使用场景应该是——测试自己的网站页面、采集有明确授权的数据、或者做自动化测试。做技术的人,能力边界和规则边界要分清楚,别因为一时顺手就越线。我一直的原则是:目标站点明确允许或默许的公开数据可以采集,需要登录、需要授权的数据,先确认使用条款再说。

7. 最后再分享一点我的切身体会

爬虫这个方向,入门门槛不高,但想稳定跑通真实的项目,拼的往往是排查问题的思路。回顾"爬取加载中的数据"这个 Bug 的整个解决过程,我最深的体会是:遇到问题别急着在原来的思路里打转,先退一步问一句"数据到底从哪来"。

浏览器是绝佳的参考对象——它能把一个页面完整渲染出来,本身就说明所有数据链路都是通的。你需要的只是沿着它的路径走一遍,看它发了什么请求、带了什么参数、拿到什么响应。把开发者工具的 Network 面板玩熟,比背一百个爬虫框架 API 都有用。

另外,维护一套自己的排查对照表特别值钱。我这几年做采集项目,很多看似新奇的报错,翻翻历史记录,都能找到相似的影子。把这篇文章里那张速查表收藏起来,下一次遇到异常直接对号入座,你会感谢这个动作的。

最后补充一个实用小技巧:写爬虫脚本之前,先把目标页面的数据链路画一遍——首页请求、Token 获取、列表接口、翻页规则、结束条件,五个环节列清楚再动代码。这个习惯让我后期几乎没有大重构过,包括这次的项目,也是理顺链路后才一气呵成跑通的。希望能给你的下一个采集项目省点心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询