☰
动态渲染站点爬虫实战:接口逆向与JS加密签名全解析
2026/10/15 8:43:44 网站建设 项目流程

接到这个视频站点数据采集任务的时候,我一开始以为就是个普通的列表抓取,三下五除二就能搞定。结果真正动手才发现,这站的接口除了常规的参数校验,还整了一个动态加密的sign字段,而且数据全部走异步加载,直接在 html 源码里连个视频标题都搜不到。本文就借这个典型的动态渲染站点(域名就不写了,懂的都懂),把整条爬虫链路从抓包定位、JS 加密逆向到落地存储完完整整捋一遍,适合已经会写基础爬虫、想进阶摸清动态站点反爬套路的开发者。

先说清楚:下面的所有操作仅用于技术学习,请务必遵守目标网站的 robots 协议和相关法律法规,不要拿采集数据去做任何商业用途,也别碰会员付费内容。这篇的核心是拆解思路,不是教你薅数据。

1. 项目需求与整体思路拆解

1.1 这个任务到底要爬什么

动手前第一步不是急着写代码,而是把需求拆到不能再拆。这个任务表面上是“抓取视频列表信息”,但我自己又细化成了四类数据:视频标题、分类标签、封面图链接、播放地址,外加一个发布时间。为什么要拆这么细?因为后面解析 JSON 的时候,字段的嵌套结构会直接影响解析逻辑,提前列清楚方便写对应字段的提取函数。

我当时拿到的是一个纯前端 Vue 项目,页面初始只加载框架,列表数据是页面滚动或切页时通过 XHR 请求拉取的。也就是说,直接用requests.get(url)拿到的 html 里根本没有数据,甚至可能触发 JS 检测,返回一串空壳。所以这个项目的核心难点就变成了:摸清前端是从哪个接口拿数据、参数是怎么构造的、接口返回格式如何。

对于动态渲染站,常规方案有两个:一个是直接用 Playwright 或者 Selenium 跑无头浏览器等着页面渲染完再去拿 DOM;另一个就是走接口逆向,直接模拟前端的 XHR 请求。出于对效率和稳定性的考虑,我选了后者。

1.2 为什么选择接口逆向而不是硬跑浏览器

先说结论:接口逆向的效率起码是浏览器渲染的十倍以上,而且数据干净,拿到就是结构化 JSON。用 Playwright 硬模拟用户操作,最大的坑是资源占用高,开三个浏览器实例就能把内存吃满,而且页面里一旦有弹窗或者懒加载,你还要处理各种时序问题,稍不小心就定位错了元素,还容易被网站通过 WebDriver 特征识别出来。

当然也有例外场景。比如目标站点把接口参数完全加密,且混淆非常复杂,或者干脆上了 WebSocket 推送,那做接口逆向的成本可能比渲染更高。这种情况下我会反过来用 Playwright 的page.request或者page.expect_response来拦截真实请求,拿到已经加密好的请求体和响应内容,再反向去分析。但这个项目没那么夸张,关键的sign参数只是通过字符串拼接加 MD5 生成的,属于防君子不防小人的验证,所以直接逆向接口是性价比最高的方案。

2. 站点分析与核心细节解析

2.1 浏览器抓包实战:定位数据源

打开 Chrome 开发者工具的 Network 面板,先在过滤条件里选 XHR,然后手动在页面上滚动几次,观察新加载的请求列表。运气不错,翻到一个名字特别直白的接口,叫/api/video/list,这就是列表数据的唯一来源。

看它的 Query String Parameters 是长这样的:

page: 2 type: 1 pageSize: 20 sign: 7e39f7c9e8b1e6f0d8c7f4a...

前几个参数都好理解,就是分页和分类,唯独这个sign每次请求都不一样,而且刷新页面之后重新请求同一页,sign也会变。显然这不是一个固定的盐值,而是参与计算的参数里带了时间戳或者随机因子。

为了看清楚这个sign到底怎么生成的,我直接在 Sources 面板里给 XHR 发送逻辑打了一个条件断点,断点条件就是请求 URL 里包含video/list。重新滚动页面触发请求,等断点命中后,右侧的 Call Stack 会一层一层往上走,很快就能看到计算sign的函数名,比如enCodeSign(params)。

2.2 加密参数的逆向过程

定位到enCodeSign的源码后,发现它的实现并不复杂,只是读取全局配置里的一个secretKey,然后把所有参数按照 key 的首字母排个序,拼成类似page=2&pageSize=20&type=1的字符串,末尾再拼上密钥,最后对这个字符串做 MD5。这里的关键点是 JavaScript 代码经过压缩混淆,变量名全改成了单个字母,看起来费劲,但逻辑都是一目了然的 loop 加字符串拼接。

我的做法是直接把这段逻辑翻译成 Python。先手动从页面 JS 里找到secretKey的实际值,然后写一个等效函数,专门用来生成sign。为了方便维护,我还把盐值放到配置项里,万一以后网站改了,只需要换一个值就行。

import hashlib from urllib.parse import urlencode SECRET_KEY = "replace_with_real_key_here" def sort_and_sign(params: dict, secret: str = SECRET_KEY) -> str: raw = urlencode(sorted(params.items(), key=lambda item: item[0])) raw += secret return hashlib.md5(raw.encode("utf-8")).hexdigest()

需要注意,MD5 在加密领域肯定不算安全,但在签名场景下,它的计算速度非常快,而且不易被看作随机串。网站这么明显地把签名逻辑暴露在 JS 里,说明重点防御不在签名本身,而在后续的请求频率限制和风控上。所以这里不用纠结怎么去破 MD5,直接去模拟它就行。

2.3 请求头与 Cookie 校验

光有sign还不够,我还试过直接用 requests 发同样的接口请求,结果返回 403。核对浏览器实际发送的请求头之后,发现两个关键字段:Referer和User-Agent。它们不参与签名,但服务器会校验这两个头必须跟真实浏览器一致。

另外这站还有一个隐藏校验:它会给第一次访问首页的响应头里塞一个set-cookie,这个 cookie 在后续请求接口时会被带在请求头里,如果缺失,同样会拒绝响应。这其实是最常见的“会话固定”校验方式之一。解决方式就是维持一个requests.Session(),先访问首页拿到初始 cookie,再请求接口。

import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Referer": "https://example-video-site.com/", }) # 先请求首页,让 session 自动记录 cookie session.get("https://example-video-site.com/", timeout=10)

这一步看着简单,但如果忽略,后面所有请求都会在服务器侧被判为机器人。我遇到过很多次,明明参数都对,就是因为第一次请求少了访问首页,导致 cookie 里缺了一个标记,整个 session 全都白搭。

3. 核心爬虫实现与参数计算

3.1 搭建一个通用的请求模板

把所有加密和会话逻辑整合起来之后,我封装了一个低层请求函数。它的职责很简单:传入业务参数,补全签名和请求头,发送 GET 请求,返回 JSON 数据。为了不让自己被服务器秒封,我在请求之间加入随机延时,而且延时的区间范围不要固定,最好在 0.5 到 1.5 秒之间浮动。

这个模板是整个爬虫的地基,后续所有翻页、搜索、详情解析都复用它。

import time import random import requests BASE_URL = "https://example-video-site.com/api/video/list" def fetch_video_list(page: int, category: int = 1): params = { "page": page, "pageSize": 20, "type": category, } params["sign"] = sort_and_sign(params, SECRET_KEY) resp = session.get(BASE_URL, params=params, timeout=10) if resp.status_code != 200: raise RuntimeError(f"request failed: {resp.status_code}, {resp.text[:200]}") data = resp.json() # 接口会返回 code 和 data,code 为 0 表示正常 if data.get("code") != 0: raise RuntimeError(f"api error: {data.get('msg')}") return data["data"]

这里把sort_and_sign单独拎出来是很有必要的。签名算法一旦变化,你只需要改这一个函数,不需要在核心抓取逻辑里到处找哪段代码在拼 sign。这算是个工程化的小习惯,很多临时脚本最后维护不动,就是因为把未解密的加密参数混在业务代码里,一改就是山崩地裂。

3.2 数据解析与清洗

接口返回的 JSON 结构不算复杂,类似下面这样:

{ "code": 0, "data": { "list": [ { "video_id": "12345", "title": "某热门电影", "category": "电影", "cover": "https://...jpg", "play_url": "https://...m3u8", "publish_time": 1712345678, "tags": ["剧情", "悬疑"], "score": 8.7 } ], "total": 100, "has_more": true } }

这里有几个坑值得说。第一是publish_time是 Unix 时间戳,直接入库会让人看得一头雾水,我统一转换成本地时间的字符串格式,便于后续看数据。第二是play_url有可能是一个相对路径,比如/media/xxx.m3u8,入库前必须补全成绝对路径,不然后面拿这个链接去播放或者下载的时候会迷路。第三是tags是数组,如果存 CSV 或者 SQLite 的字符串字段,需要join(',')转一下。

清洗函数我用了一个简单的dict推导来生成目标结构,顺手做类型强转和空值处理。爬虫最忌讳的就是把原始 JSON 直接扔进数据库,因为源站的字段随时会调整,你解析时一旦漏配,后面的数据处理流程全崩。

def clean_item(item: dict): return { "vid": item.get("video_id"), "title": item.get("title", "").strip(), "category": item.get("category"), "cover_url": item.get("cover"), "play_url": item.get("play_url"), "publish_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(item.get("publish_time", 0))), "tags": ",".join(item.get("tags", [])), "score": float(item.get("score") or 0), }

3.3 多线程抓取的频控设计

翻页逻辑本身没什么难度,就是循环页数。但这个网站明显对访问频率有检测,如果我用一个单线程连续无脑请求,大概爬到二三十页就会触发风控,表现为突然弹出的极验验证码。

所以我用了线程池,但强调一点:多线程不是让你把请求速度提上去,而是让你能够更好地控制整体吞吐和时间间隔。我用并发上限 4 个工人,每个工人内部再做随机延时,这样既能提速,又不会出现高频瞬间请求。

from concurrent.futures import ThreadPoolExecutor, as_completed def build_tasks(total_pages): with ThreadPoolExecutor(max_workers=4) as executor: futures = {executor.submit(fetch_video_list, page): page for page in range(1, total_pages + 1)} results = [] for future in as_completed(futures): try: data = future.result() results.extend(data.get("list", [])) except Exception as exc: print(f"page error: {exc}") return results

有朋友可能会问,为什么不直接用异步协程?其实这个场景用协程也可以,但 requests 是同步阻塞的,要切换到httpx.AsyncClient才行。考虑到项目里还要复用在其他站点上,线程池的可读性更高,也够用。真正应对封禁的杀手锏其实是后面的“状态记忆”,也就是每个页面的抓取结果要能断点续传,避免从头跑。

3.4 数据库表设计与增量更新逻辑

存储用的是 SQLite,轻便、免安装、单文件,适合这种中小体量的采集任务。表结构设计的时候,我特意给vid字段加了唯一索引,目的是让数据库层面直接去做去重,不让我在代码里手动判断。插入数据用INSERT OR REPLACE,相同vid会把旧记录覆盖,这样后面即便重复跑,也不会产生积压垃圾。

CREATE TABLE IF NOT EXISTS videos ( vid INTEGER PRIMARY KEY, title TEXT, category TEXT, cover_url TEXT, play_url TEXT, publish_time TEXT, tags TEXT, score REAL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

增量更新的逻辑很简单:每次抓取前,先从库里拉出已有的vid集合,比对当前批次的数据,如果某条的vid不存在于集合中,就插入;如果存在但时间戳变了,就更新。因为已经在数据库层用了INSERT OR REPLACE,我实际上只需把全部数据放入事务提交即可,没必要再写复杂的去重判断。

但有一点要提醒,SQLite 在高并发写入时容易出现database is locked。我的处理方式是把所有数据先存在内存列表里,等一批任务全部完成后再统一写库,而不是每爬一条就 commit 一次,这样能明显减少锁冲突。

4. 常见问题与排查技巧实录

4.1 突然出现验证码怎么办

我这次就遇上了大概爬到第 30 页的时候,接口返回的不是 JSON,而是 HTML,内容是一段滑块验证码的页面。这是典型的频率风控触发,而不是你的 sign 算错了。遇到这种情况,先不要急着想怎么绕过验证码,而是要回到频率控制上。

我的第一反应是退出去,把并发数从 4 降到 2,再把随机延时区间从 0.5~1.5 调到 1~3 秒,重新跑一段,发现能继续走。如果页面强制拦你,那就只能暂停,等 5 到 10 分钟之后再继续。

如果是登录后的接口出了验证码,那情况会更麻烦,因为一般验证码会绑定会话,你只能手动处理。一个可落地的办法是:在请求库的层面捕获到返回中的验证码标记,主动抛出异常并弹出通知,然后你手动在浏览器里拖一下滑块,再让爬虫重新建立 session,这样至少不会整批任务废掉。

4.2 请求被服务器标记后返回空数据

还有一种比直接 403 更坑的:服务器返回 200,响应也正常,但data.list是空数组,可能连续好几页都是空的。这时候不要怀疑网站没数据,先去看响应头里有没有Set-Cookie: blocked=true或者类似标记。这通常是网站的风控系统把你放进了“观察名单”,给你喂空数据迷惑你。

我当时用的排查手段是:对比浏览器里同一次请求的响应头和爬虫请求的响应头,逐字段差异,然后补上缺失或者被改写的 cookie。如果找不到原因,最简单的做法是清掉 session 里所有 cookie,重新走一遍首页初始化流程,一般都能恢复。

如果你的爬虫已经处在被观察名单中,记得把请求频率降到最低,让它觉得你是个“慢速真人”,过一段时间会自动解除。

4.3 前端加密逻辑升级导致 sign 失效怎么办

前后端分离的站点,JS 文件经常更新。也许你今天看到的enCodeSign还是拼接字符串加 MD5,明天它就变成了 base64 嵌套 AES,或者干脆把密钥放到某个接口里动态获取。频繁手工去读 JS 是维护不过来的,所以我通常会写一个“签名生成器的生成器”:定期拉取最新版 JS 文件,用正则或者 AST 语法树去提取密钥和拼接规则。

这个思路听起来复杂,但实现起来并不难。先用正则把secretKey = "xxxx"这个模式抓出来,存到配置里;再检查enCodeSign如果出现新的substr、charCodeAt一类操作,就提示需要人工介入。

如果实在不想频繁维护签名,也可以退一步。在短期项目里,直接用 Playwright 打开页面,让它自己执行 JS 拿到sign,再拿这个sign去继续用 requests 批量抓接口。虽然启动一次浏览器有点慢,但能扛住小规模的数据采集,算是一种折中方案。

4.4 数据去重与断点续传

爬虫跑的时间越长,越容易因为网络中断、超时、服务器崩掉而半路挂掉。如果每次重跑都从头开始,效率太低,还可能更快触发封禁。所以我做了一个轻量级“进度记录”机制,就是把已经成功抓取过的页码写到一个本地文件里,重启后先读这个文件,跳过已完成页码。

去重方面,前面数据库的唯一索引已经挡了一层,但代码里最好还是有一个video_id的缓存集合,每次插入前先看一眼,避免把同一页数据在内存里重复处理。这里我用的是一个 Pythonset,因为set的查找复杂度是 O(1),在几十万数据量下也没压力。

最后再分享一个小技巧:如果你怕某个字段被源站改掉导致程序突然崩溃,可以在核心解析代码外层包一个try/except,失败时把原始 JSON 完整打印到日志里。别的都别做,只要看到那段原始 JSON,你就有了第一手线索,不用求人。配合这种日志逻辑,我的爬虫项目至今都保持着“哪里坏了就改哪里”的节奏,不会因为一次变动推翻整个方案。

老实说,这类动态站点爬虫的难点从来不是代码本身,而是对页面加载机制和反爬策略的理解。你只要愿意静下心去抓包、看 JS、对比请求,跟着这个项目的套路做一遍,之后换任何一个类似结构的站点,思路都是通用的。再次提醒一句:技术工具本身没有对错,但用在哪里、如何使用,请一定把它放在合法的框架之内。

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

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

立即咨询