爬虫做到一定年头,你会发现一个规律:凡是数据在页面上“活”得特别快、刷新特别勤的地方,背后十有八九挂着一条长连接。传统那套“抓HTML、找接口、拼参数”的打法,碰到实时行情、在线协作、弹幕互动这类场景,往往一拳打在棉花上——接口是有的,但数据不走HTTP,走的是WebSocket。更麻烦的是,就算你连上了WebSocket,发出去的消息里还带着一串看不懂的签名,这串签名由前端JS现场算出来,你翻遍网络面板也找不到现成的请求可以复用。
这次要聊的就是这么一个典型场景:某类数据展示中心(下称“XX中心”)的实时数据抓取。它的数据通道是WebSocket,握手和消息体里都埋了前端JS动态生成的校验字段,直接硬连会被服务端拒之门外。我的处理思路分两条腿走路:一条是WebSocket协议层的连接与消息收发,另一条是JS Hook把加密函数“钓”出来,让页面自己帮我算签名。这两块拼起来,才是一套完整的、能长期稳定跑下去的采集方案。
这篇文章适合谁看?如果你已经会用常规手段抓包、能看懂基本的JS代码、想往实时数据采集和前端逆向方向再走一步,那这篇的实操细节应该对你有用。如果你是完全的新手,建议先把HTTP抓包和浏览器开发者工具的基本用法摸熟再回来,不然中间一些调试环节会有点吃力。下面我按“整体设计—核心细节—实操落地—问题排查”的顺序,把整个过程掰开揉碎讲清楚,中间穿插我自己踩过的坑和几个能直接抄的代码片段。
1. 整体方案设计与技术选型拆解
动手之前先把地图画出来,比上来就闷头抓包效率高得多。这个项目的核心矛盾在于:数据走WebSocket,而WebSocket的消息里带了前端算出来的动态校验值。所以方案设计要同时解决“怎么连”和“怎么算”两个问题,任何一个没搞定,整条链路就断。
1.1 为什么不能只靠传统HTTP抓包
先说清楚为什么这类场景传统方法不好使。常规网页的数据加载是“请求—响应”模型,浏览器发一个HTTP请求,服务端回一段JSON,你在网络面板里能看到完整的URL、请求头、请求体、响应体,复制成cURL就能复现。但WebSocket是另一套东西:它先通过一次HTTP升级握手(Upgrade: websocket)建立连接,之后这条TCP连接就“常驻”了,双方随时可以互发消息帧,不再有“一问一答”的对应关系。
这意味着两件事。第一,你在网络面板的“Fetch/XHR”标签里根本找不到后续的数据流,得切到“WS”标签才能看到帧列表。第二,就算你看到了帧,也没法像复制cURL那样“一键复现”——WebSocket的每一帧是二进制或文本帧,握手阶段还带着Sec-WebSocket-Key这类一次性随机值,服务端还会校验Origin、Cookie甚至自定义头。所以指望“抓个包改改参数就完事”的思路,在这里第一步就走不通。
我一开始也试过偷懒,想看看能不能找到它降级用的HTTP轮询接口。实测下来,这类中心为了实时性基本只留WebSocket通道,HTTP接口要么没有,要么返回的是脱敏后的延迟数据。所以老老实实啃WebSocket是绕不开的。
1.2 WebSocket通道与JS Hook双线并行的思路
既然连接和签名是两个独立难点,我就把它们拆成两条线分别攻克,最后再合流。
第一条线是WebSocket通道。目标是搞清楚:握手URL长什么样、带了哪些请求头、连接建立后客户端主动发什么消息、服务端回什么格式。这一步的产出是一套能稳定建连、能正确收发消息的客户端代码。
第二条线是JS Hook。目标是搞清楚:消息体里那个动态校验字段是怎么算出来的、用了哪些输入参数、算法逻辑是什么。这一步的产出是一个能随时调用、和页面算出来结果一致的签名函数。
两条线的关系是:Hook出来的签名函数,喂给WebSocket客户端,拼出合法的消息帧发出去。为什么选择Hook而不是纯静态分析?因为这类校验逻辑经常被混淆、被拆成多个函数、还依赖运行时环境(比如window上的某个对象、navigator的某些属性),纯静态读代码又慢又容易漏。Hook的思路是“让页面自己算”,我在关键函数上下钩子,页面调用时把入参和出参截下来,既拿到了算法输入输出的对应关系,又能直接把函数“借”过来用。这是逆向里性价比最高的打法之一。
1.3 工具链选型与理由
工具不在多,在于顺手。我这套组合是经过多个项目验证的:
| 工具/库 | 用途 | 选它的理由 |
|---|---|---|
| 浏览器开发者工具 | 抓WS帧、调试JS、下断点 | 内置、免费、WS帧查看够用 |
| 某JS调试代理工具 | 拦截并修改页面JS、注入Hook脚本 | 能在不改服务端的前提下替换前端逻辑 |
| Python + websocket-client | 建立WS连接、收发消息 | 轻量、API直观、社区资料多 |
| Python + requests | 握手前的辅助请求(拿Cookie等) | 成熟稳定 |
| 某代码格式化/反混淆工具 | 还原混淆JS的可读性 | 提高静态分析效率 |
这里重点说下为什么用Python而不是Node。Node天然适合跑JS,理论上可以直接把Hook到的函数搬过去执行。但实际项目里,签名函数往往依赖浏览器环境(document、window、localStorage),搬到Node要补一堆环境模拟,反而更麻烦。Python这边虽然要“翻译”算法,但配合Hook拿到的输入输出对,验证起来很直接,而且后续做数据存储、调度、多连接管理,Python生态更顺手。所以我的选择是:Hook在浏览器里做,算法用Python重写,两边用输入输出对做交叉验证。
提示:工具链不是死的。如果你团队里Node更熟,完全可以把签名函数连同环境一起搬到Node跑,思路是一样的,只是环境模拟的工作量不同。
2. WebSocket协议层核心细节解析
这条线要解决的是“连得上、发得出、收得回”。很多人卡在握手就被拒,或者连上了发消息没反应,问题多半出在对WebSocket握手细节和帧格式理解不到位。这一章把关键点拆开讲。
2.1 握手阶段的请求头构造要点
WebSocket握手本质是一次HTTP GET请求,带几个特殊头。服务端会校验的东西通常包括:
Upgrade: websocket和Connection: Upgrade,这两个是协议要求,缺了直接失败。Sec-WebSocket-Key:客户端生成的16字节随机值做Base64,服务端会拿它算一个Sec-WebSocket-Accept回给你。这个值每次连接都不同,但它是客户端自己生成的,所以不算难点,用库的话库会帮你生成。Sec-WebSocket-Version: 13:固定值。Origin:这个经常被校验。服务端会检查来源是不是自己的域名,伪造时要注意和真实页面一致。Cookie:登录态。这个得从浏览器里拿,而且可能有有效期,过期了要重新取。- 自定义头:有些中心会加自己的校验头,比如带时间戳、带签名的
X-开头头。这类头往往也是JS算的,属于Hook要解决的范围。
我用websocket-client建连时,习惯把请求头显式传进去,方便对照浏览器里的真实握手:
import websocket ws_url = "wss://example-domain.com/realtime/stream" headers = { "Origin": "https://example-domain.com", "User-Agent": "Mozilla/5.0 (...) Chrome/xxx", "Cookie": "session_id=xxxx; token=yyyy", # 自定义校验头按实际抓到的填 "X-Client-Time": "1700000000000", } ws = websocket.create_connection(ws_url, header=headers, timeout=10)这里有个细节:websocket-client的header参数接受的是列表或字符串形式,不同版本行为略有差异,传字典时它会自己拼。如果你发现自定义头没生效,先打印实际发出的握手请求核对,别想当然。
2.2 消息帧的格式与收发节奏
连上之后就是收发帧。WebSocket帧分文本帧和二进制帧,文本帧里通常是JSON字符串,二进制帧可能是protobuf或自定义序列化。XX中心这类场景,我遇到的大多是文本帧包JSON,但也不排除二进制,得看实际抓到的帧。
收发的节奏很关键。很多实时系统要求客户端连上后先发一条认证/订阅消息,服务端验证通过才开始推数据。这条首消息里往往就带着那个动态签名。所以流程是:
- 建连成功。
- 客户端发认证消息(含签名)。
- 服务端回认证结果。
- 认证通过后发订阅消息(订阅哪些频道/标的)。
- 服务端开始持续推送数据帧。
我用一个循环来管理收发,收到数据就交给处理函数,同时要处理心跳。心跳有两种:协议层的ping/pong帧,和应用层的自定义心跳消息。协议层的websocket-client会自动回pong,但应用层心跳得自己发,不发的话服务端可能几十秒后就断开。
import json import time def run_stream(ws, subscribe_payload, heartbeat_payload, interval=30): ws.send(json.dumps(subscribe_payload)) last_beat = time.time() while True: try: msg = ws.recv() if not msg: break data = json.loads(msg) handle_message(data) # 你的数据处理逻辑 except websocket.WebSocketTimeoutException: pass if time.time() - last_beat > interval: ws.send(json.dumps(heartbeat_payload)) last_beat = time.time()注意:
recv()默认是阻塞的,设了timeout后超时会抛异常,别把它当成连接断了。真正的断连会抛WebSocketConnectionClosedException,这两种要分开处理,否则容易误判重连。
2.3 连接保活与断线重连策略
长连接最怕的就是“假活”——TCP没断,但服务端已经不推数据了。我的做法是双保险:应用层心跳定时发,同时记录最后一次收到数据的时间,超过阈值(比如心跳间隔的2.5倍)没数据就主动断开重连。
重连不能无脑立刻重试,要有退避。我一般用指数退避加随机抖动:第一次等1秒,第二次2秒,第三次4秒,封顶30秒,每次加个0到1秒的随机量,避免多连接同时重连把服务端打崩。重连后要重新走认证和订阅流程,因为新连接的服务端会话是全新的。
import random def reconnect_loop(connect_fn, max_backoff=30): attempt = 0 while True: try: ws = connect_fn() return ws except Exception as e: attempt += 1 backoff = min(2 ** attempt, max_backoff) + random.random() print(f"重连失败,{backoff:.1f}s 后重试: {e}") time.sleep(backoff)这套保活重连逻辑看着简单,但它是整个采集能“长期跑”的地基。我见过太多脚本跑几个小时就静默死掉,最后发现就是没处理假活。
3. JS Hook定位与签名算法还原
这条线是整个项目里技术含量最高的部分。目标是把前端算签名的函数“钓”出来,搞清楚它的输入输出,然后在Python里复现。这一章按“找函数—下钩子—验结果—做还原”的顺序讲。
3.1 从WS帧反推签名位置
第一步是确定签名到底在哪。切到开发者工具的WS标签,看客户端发出的帧。认证消息和订阅消息里,通常有一两个字段看着就不像正常业务数据——比如一长串十六进制、或者Base64、或者带时间戳的拼接串。把它记下来,这就是目标。
有时候签名不在消息体里,而在握手URL的query参数里,或者自定义请求头里。所以三个地方都要看:握手请求头、URL参数、首条消息体。我遇到过签名藏在URL里的情况,一开始只盯消息体,白折腾半天。
确定字段后,下一步是在页面JS里搜这个字段名。如果代码没混淆,直接搜字段名就能定位到赋值的地方。混淆过的话,字段名可能是短变量,那就换个思路:搜这个字段值的前几位特征,或者搜JSON.stringify附近、搜send(调用附近,从发送动作往回找。
3.2 用调试代理注入Hook脚本
找到可疑函数后,最稳的办法是下钩子而不是硬读代码。我用调试代理工具把页面JS拦截下来,在目标函数前后注入打印语句,把入参和返回值打到控制台。这样页面正常跑,我就能看到每次调用时函数吃了什么、吐了什么。
Hook的写法有讲究。常见的是替换原函数,包一层:
// 假设目标函数挂在某个对象上,先备份原函数 var _original = targetObj.signFunc; targetObj.signFunc = function() { var args = Array.prototype.slice.call(arguments); var result = _original.apply(this, args); console.log("[Hook] 入参:", JSON.stringify(args)); console.log("[Hook] 出参:", result); return result; };这段代码的关键是apply(this, args),保证原函数的this上下文和参数原样传递,不然页面自己就崩了。我踩过的坑是:有些函数被多处调用,Hook之后控制台刷屏,得加个条件判断,只在参数符合预期时才打印。
还有一种情况是函数不在全局对象上,而是闭包里的局部函数,外部根本拿不到引用。这时候就得用断点法:在发送消息的代码行下断点,触发后看调用栈,一层层往上找签名函数在哪一层,然后在那一层的作用域里直接读变量、甚至直接在控制台调用它。调试代理工具支持在指定行注入代码,配合断点能解决大部分闭包问题。
3.3 签名函数的输入输出对照验证
Hook到函数后,别急着抄算法。先做一件事:收集多组输入输出对。让页面多跑几次,把每次的入参和出参都记下来,至少凑够五六组。这些数据对有两个用途:一是帮你理解算法逻辑(比如入参里有没有时间戳、有没有随机数),二是后面Python复现时用来验证。
我一般会整理成一张表:
| 序号 | 入参特征 | 出参(签名) | 备注 |
|---|---|---|---|
| 1 | 时间戳T1 + 固定串A | 签名S1 | 正常订阅 |
| 2 | 时间戳T2 + 固定串A | 签名S2 | 仅时间戳变 |
| 3 | 时间戳T2 + 固定串B | 签名S3 | 固定串变 |
对比这几组,能快速判断哪些入参影响结果、影响方式是什么。比如时间戳变签名就变,说明时间参与运算;固定串变签名也变,说明它也是输入。如果两组入参完全相同但出参不同,那说明函数内部用了随机数或依赖了外部状态,这种情况要特别小心,得把随机源也找出来。
3.4 算法逻辑还原与Python复现
拿到输入输出对和Hook看到的中间变量后,就可以还原算法了。常见套路无非几种:字符串拼接后做哈希(MD5/SHA)、HMAC、AES加密、或者简单的异或混淆。Hook的时候如果能在函数内部关键行打印中间值,还原起来会快很多。
举个我实际遇到的简化例子:签名是md5(时间戳 + 固定盐 + 业务参数)再取前16位。Hook看到入参是时间戳和业务参数,函数内部拼了个固定字符串,然后调了哈希。这种在Python里复现就是几行:
import hashlib def gen_sign(ts: str, biz_param: str, salt: str = "固定盐值") -> str: raw = f"{ts}{salt}{biz_param}" return hashlib.md5(raw.encode("utf-8")).hexdigest()[:16]复现完必须验证:拿Hook收集的那几组输入,喂给Python函数,看出参和浏览器里的是否一致。全部对上才算还原成功。有一组对不上,就说明还有隐藏输入没找到,回去继续Hook。
提示:验证时注意编码。JS里字符串是UTF-16,Python默认UTF-8,涉及中文或特殊字符时,
encode的方式要和JS的哈希实现对齐,否则结果对不上还查不出原因。
4. 完整实操流程与关键环节实现
前面两章把两条线分别讲透了,这一章把它们合起来,走一遍从零到跑通的完整流程。我会按实际操作顺序写,中间标注每一步的意图和容易出问题的地方。
4.1 环境准备与依赖安装
先搭环境。Python建议3.8以上,依赖就两个核心库:
pip install websocket-client requestswebsocket-client负责WS连接,requests负责握手前可能需要的辅助请求(比如先访问某个页面拿Cookie)。调试代理工具按你习惯的选,浏览器用Chrome或Edge都行,重点是会用WS帧面板和断点。
环境这块有个小坑:websocket-client和另一个叫websockets的库名字很像但API完全不同,别装错了。前者是同步的、API简单,适合这种脚本式采集;后者是异步的、基于asyncio,适合高并发场景。本项目用前者足够。
4.2 抓取握手信息与首条消息
打开目标页面,F12切到网络面板,筛选WS。刷新页面,找到那条WS连接,点开看:
- Headers:记下请求URL、Origin、Cookie、所有自定义头。
- Messages/Frames:记下客户端发出的第一条消息(通常是认证),以及服务端回的认证结果。
把首条消息原样复制出来,这就是你后面要构造的模板。注意消息里的签名字段先留着,等Hook搞定后再替换。
这一步的产出是一份“连接配方”:URL、头、首消息模板。我习惯把它记在一个配置文件里,后面代码直接读,改起来方便。
4.3 注入Hook并捕获签名调用
按第3章的方法,用调试代理工具注入Hook脚本,或者直接在开发者工具里下断点。触发一次订阅动作,观察控制台输出,确认Hook到了目标函数,并且入参出参都打印出来了。
这一步的关键是多触发几次,收集足够多的样本。有些签名函数带时间窗口,比如时间戳精确到秒,同一秒内多次调用结果相同,跨秒就变。多跑几次能看出时间粒度,这对后面Python复现时的时间处理很重要。
如果Hook一直没触发,检查几个点:函数是不是在iframe里、是不是在Worker里、是不是在页面加载早期就执行完了(那就得在页面加载前注入)。Worker里的代码在独立的执行环境,普通Hook注入不到,得用调试代理工具针对Worker的调试功能。
4.4 拼装合法消息并建立稳定连接
Hook搞定、Python签名函数验证通过后,就可以拼装合法消息了。流程是:
- 用
requests先访问一次页面,拿到新鲜Cookie(如果Cookie有时效)。 - 用
websocket-client建连,带上完整请求头。 - 构造认证消息:把模板里的签名字段替换成Python算出来的值。
- 发送认证消息,读服务端响应,确认认证通过。
- 发送订阅消息,开始收数据。
import json import time import websocket import requests def get_cookie(): resp = requests.get("https://example-domain.com/", timeout=10) return resp.cookies.get_dict() def build_auth_payload(ts, biz_param): sign = gen_sign(ts, biz_param) return { "type": "auth", "ts": ts, "biz": biz_param, "sign": sign, } def main(): cookies = get_cookie() cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items()) headers = { "Origin": "https://example-domain.com", "Cookie": cookie_str, "User-Agent": "Mozilla/5.0 (...) Chrome/xxx", } ws = websocket.create_connection( "wss://example-domain.com/realtime/stream", header=headers, timeout=10 ) ts = str(int(time.time() * 1000)) auth = build_auth_payload(ts, "订阅参数") ws.send(json.dumps(auth)) print("认证响应:", ws.recv()) # 后续订阅与收数据拼装时最容易出错的是时间戳格式。JS的Date.now()是毫秒,Math.floor(Date.now()/1000)是秒,差一个数量级签名就全错。Hook的时候把时间戳入参打印出来,看清楚是几位数,照着来。
4.5 数据解析与落库
收到数据帧后,解析JSON,提取需要的字段,落库。落库方式看需求,轻量用SQLite,量大用MySQL/PostgreSQL,要时序查询用专门的时序库。我一般先落原始帧到一张表(保留现场,方便回溯),再解析出结构化字段到业务表。
import sqlite3 def init_db(path="data.db"): conn = sqlite3.connect(path) conn.execute(""" CREATE TABLE IF NOT EXISTS raw_frames ( id INTEGER PRIMARY KEY AUTOINCREMENT, recv_time TEXT, payload TEXT ) """) conn.commit() return conn def save_raw(conn, payload): conn.execute( "INSERT INTO raw_frames (recv_time, payload) VALUES (datetime('now'), ?)", (payload,) ) conn.commit()保留原始帧这个习惯救过我很多次。有次解析逻辑写错了,字段对不上,全靠原始帧回放才定位到问题。数据量大的话可以只保留最近N天,或者压缩存储。
5. 常见问题与排查技巧实录
实操过程中踩的坑,比顺利的部分更有价值。这一章把典型问题和排查思路整理出来,做成速查表,遇到问题先对号入座。
5.1 握手被拒的几种典型原因
握手失败是最常见的拦路虎,表现是建连时直接抛异常,或者服务端返回403/401。原因通常集中在几个地方:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 403 Forbidden | Origin不对、Cookie失效、缺自定义头 | 对照浏览器握手请求逐项核对 |
| 401 Unauthorized | 登录态过期 | 重新取Cookie |
| 连接被重置 | 请求头顺序或格式异常 | 用抓包工具对比实际发出的握手 |
| 握手成功但立即断开 | 首条认证消息不合法 | 检查签名、时间戳、字段名 |
排查握手问题,最有效的方法是把Python实际发出的握手请求抓下来,和浏览器的逐字节对比。差异往往就在某个头的大小写、某个参数的有无上。我遇到过一次,服务端校验Origin时区分了末尾斜杠,浏览器发的是不带斜杠的,我代码里多写了个斜杠,卡了半小时。
5.2 签名对不上的定位方法
签名对不上,表现是认证消息发出去,服务端回“签名错误”或者直接断开。定位思路是分段验证:
- 先确认时间戳格式对不对(秒还是毫秒,字符串还是数字)。
- 再确认参与签名的字段顺序和拼接方式对不对(Hook时打印中间拼接串)。
- 最后确认哈希/加密算法和编码对不对(MD5还是SHA,hex还是base64,大小写)。
我一般会在Python里写个对比脚本,把Hook收集的几组输入输出对全部跑一遍,看哪一组开始对不上,从那一组反推差异。如果所有组都对不上,多半是算法整体理解错了,回去重新Hook,这次把函数内部每一步的中间值都打出来。
注意:有些签名函数内部会读取
navigator.userAgent、屏幕尺寸、时区等环境信息作为盐。这类隐藏输入在Hook入参里看不到,得在函数内部打印。遇到“入参一样出参不一样”的情况,优先怀疑这类环境依赖。
5.3 连接假死与数据断流的处理
连接假死表现为:程序还在跑,日志不报错,但数据不来了。原因可能是服务端静默断开了TCP但客户端没感知,或者心跳没发对。处理办法前面提过,这里补充排查细节:
- 打印每次
recv()的返回和异常,区分超时和断连。 - 记录最后收数据时间,超阈值主动重连。
- 心跳消息的格式要和抓到的真实心跳一致,别自己发明。
我遇到过一次假死,查了半天发现是心跳消息里的时间戳字段没更新,服务端认为心跳无效,默默把连接踢了但不发关闭帧。这种“软踢”最坑,只能靠客户端自己检测数据流是否停滞。
5.4 反调试与代码混淆的应对
有些页面会做反调试,比如检测开发者工具是否打开、检测函数是否被替换。Hook被检测到时,页面可能直接不工作或者上报异常。应对思路:
- Hook时尽量保持原函数行为完全一致,包括
toString()的返回(有些检测会检查函数源码)。 - 用调试代理工具在更底层注入,绕过页面级的检测。
- 混淆代码先用反混淆工具格式化,再静态分析辅助理解。
这块没有万能解,本质是攻防对抗。我的经验是:优先用“不改行为”的Hook方式(只读不写),减少被检测面;实在绕不过,再考虑在调试代理层做更彻底的替换。
6. 工程化与长期维护的几个心得
脚本能跑通只是起点,要长期稳定跑,还得做工程化。这一章聊几个我在实际项目里总结的点,偏经验,不涉及具体代码。
6.1 配置与代码分离
URL、请求头、Cookie、签名盐值这些易变的东西,全部抽到配置文件里,代码只读配置。这样服务端改个参数,你改配置就行,不用动代码重新测。Cookie这种有时效的,做成自动刷新,别写死。
6.2 监控与告警
长连接采集最怕“静默失败”。我一般加两个监控:一是数据到达速率,低于阈值告警;二是重连频率,频繁重连说明有问题。告警渠道看团队习惯,邮件、即时消息机器人都行。有了监控,问题能在数据断流的第一时间发现,而不是等下游发现数据缺了才回头查。
6.3 合规与边界意识
最后说个容易被忽略但很重要的点:采集行为要有边界。只采公开的、自己有权访问的数据,控制请求频率别给对方服务造成压力,遵守目标站点的使用条款。技术上能做的和应该做的,是两回事。这条不是套话,是长期做这行能睡得安稳的前提。
我个人在实际操作中的体会是,WebSocket加JS Hook这套组合,难点从来不在某一个技术点,而在于两条线的衔接——Hook拿到的算法要能准确翻译成Python,Python算出的签名要能被服务端认可,中间任何一环有偏差,整条链路就断。所以我的习惯是每完成一环就做一次端到端验证,别等全部写完再联调,那样出了问题很难定位是哪一环的锅。另外,Hook脚本和签名函数记得留版本记录,服务端算法一升级,你能快速对比新旧差异,这比从零重新逆向省太多时间。