☰
雪球网数据采集逆向:Cookie与签名参数的Python实战解析
2026/10/18 20:50:43 网站建设 项目流程

简介:面向逆向工程与安全研究学习的可运行源码包,聚焦雪球网md5__1038参数逆向,并延伸至盼之代售与阿里v2滑动逆向协议的通杀方法;包体共3个文件——1个inscode环境配置、1个HTML页面、1个gitignore管理文件,压缩包仅4KB,结构精简但包含完整可运行网页源码,HTML实现交互演示,inscode搭建环境,gitignore规范项目文件。作者已将抓包内容、敏感网址和接口数据脱敏,强调仅限学习交流,并附联系方式供理性探讨;目前已有114人浏览学习,适合研究哈希加密、滑动验证与接口安全的中初级开发者,可快速掌握逆向分析基本流程。通过源码包可了解MD5在金融平台的实际应用,对比不同滑动验证协议的绕过原理,在合规前提下提升安全分析能力,也为后续深入研究积累可复用思路与调试经验。

1. 雪球网逆向分析到底在逆什么

如果你接手过一个雪球网数据采集任务,就会发现“雪球网逆向分析”真正难的不是拿到数据,而是让程序每次都稳稳地、带着合法身份去拿到数据。页面看起来是个寻常的 Web 应用,可一旦打开开发者工具,所有有价值的内容全部来自异步接口;接口从 Cookie 到请求头都带着签名和风控参数,裸 requests 直接打过去,十有八九拿到的是 403 或者一道验证码。把这套链路拆开、把会话态的生成逻辑摸清、再把签名参数重写成 Python 可控的代码,最后产出一个能持续运行的抓取脚本,就是这类项目要交付的东西。它适合做量化投研、盘后数据分析的人复用,也适合想了解前端反爬思路的爬虫工程师当案例看。

2. 从页面到接口:先把雪球的数据链路看清

2.1 先从开发者工具看清 SPA 的请求骨架

雪球是一个典型的单页应用,页面初始 HTML 只包含一个空壳和一堆 JS 资源,真正的内容全部由浏览器加载完脚本后再发请求填充。所以你随便用 requests 直接 GET 首页,得到的 HTML 里没有行情、没有讨论、没有组合数据。定位数据的标准动作是打开 DevTools,切到 Network 面板,勾选 Fetch/XHR,然后刷新页面并滚动几个热门页面,观察每个异步请求都拿了什么、返回什么。

我在模拟项目X里第一次这么做时,发现请求列表里有一批 JSON 接口,路径长得各不相同,但共同点是请求头都带了完整的 Cookie、User-Agent、Referer,部分接口的 query 里还挂着一个时间戳类的动态参数。把这些请求头原样复制进 Postman,大多能直接复现,说明这个站点没有做得特别绝,至少还没有引入完整的 JS 加密请求体;真正的门槛在于 Cookie 里的会话态,以及部分接口的签名参数。先分清这两类东西,后续逆向才有方向。

2.2 核心接口与返回结构的字段说明

常见做法是先按用途把接口分好类,再逐个记录它的关键参数和返回结构。以行情类和讨论类接口为例,通常需要关注的信息如下:

接口用途常见路径前缀关键参数主要返回字段
行情快照/v5/stock/quote.jsonsymbol、extendcurrent、percent、high、low、turnover_rate
股票列表/v5/stock/screener/quote/list.jsonorder、orderby、sizedata.list 数组、data.count
热门讨论/statuses/hot/listV2.jsoncircle_id、max_iditems 数组、next_max_id
用户自选/v5/stock/portfolio/stock/list.json需登录态stock_list 数组

表里的路径前缀只是当年的观察结果,这类站点接口版本迭代频繁,路径和参数都会变,不必背死。重要的是理解一个通用规律:所有接口的返回都是{ error_code, error_description, data }这种结构,error_code为 0 才代表正常,非 0 值对应的是签名错误、会话失效、参数非法等不同失败原因。抓到列表类接口后要看两个字段:一是总数量 count,二是分页游标 next_id,这两个字段决定了你能不能用循环把全量数据拉下来。

确认完接口和字段之后,下一步要回答的问题只有一个:请求头里那一长串 Cookie 到底从哪来、多久失效、怎么自动续上。

3. 逆向核心:Cookie 与签名参数的生成逻辑

3.1 为什么雪球选择“Cookie + 签名”而不是简单鉴权

对普通读者来说,Cookie 就是登录后浏览器记住你的凭证;但站在逆向视角,Cookie 其实分两层。第一层是未登录就种下的基础会话,比如 u、xq_a_token,它们用来标识设备、记录风控指纹;第二层是登录后的权限态,比如 xq_r_token、xq_a_token 的有效期和权限范围比基础态强很多。行情接口往往只需要第一层就能访问,但访问频率一高或者来源 IP 可疑,风控就会开始要求第二层。

这种设计对逆向者并不友好:你没法通过“账号密码登录一次然后把 Cookie 贴到配置文件里”一劳永逸,因为 Cookie 有过期时间,而且风控系统会不定期让旧指纹失效。常见做法是让程序自己维护一个真实的浏览器会话,把取 Cookie 的过程自动化。对签名参数则采取另一条路:静态分析 JS,定位签名函数,把算法重写成 Python,避免为每个签名参数都开一次浏览器。

3.2 用 Playwright 自动拿 Cookie,绕过签名计算

最早我会用 Selenium 做这件事,后来换成 Playwright,因为它的启动速度和稳定性更好。它的逻辑很简单:起一个真实的 Chromium 实例,打开雪球首页,等网络请求跑完,然后把浏览器上下文的 Cookie 全部导出。未登录状态下拿到的 Cookie 已够访问大量行情类接口。如果目标接口需要登录态,就在打开浏览器后先完成手机号或扫码的登录步骤,再导出,逻辑完全一样。

from playwright.sync_api import sync_playwright def fetch_cookie(headless: bool = True) -> dict: with sync_playwright() as p: browser = p.chromium.launch( headless=headless, args=["--disable-blink-features=AutomationControlled"] ) context = browser.new_context( locale="zh-CN", 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" ) ) page = context.new_page() page.goto("https://xueqiu.com/", timeout=30000) page.wait_for_load_state("networkidle") cookie_dict = {} for c in context.cookies(): if c["domain"].find("xueqiu.com") >= 0: cookie_dict[c["name"]] = c["value"] browser.close() return cookie_dict

这段代码的关键点有三个。--disable-blink-features=AutomationControlled能去掉一部分自动化标记,降低被无头浏览器特征识别的概率;networkidle确保页面把首屏异步请求都发完再取 Cookie,否则某些 Cookie 还没种下;只保留xueqiu.com域下的 Cookie,是为了避免把第三方域的无用键值也混进来。拿到之后建议落盘到本地 JSON,每次请求前先加载,失效了再调用这个函数刷新,而不是每次都重新开浏览器。

3.3 静态签名参数的 JS 分析与重写

Cookie 解决的是“我是谁”的问题,签名参数解决的是“这个请求是不是真实客户端发的”的问题。签名参数的形态在雪球这类站点里通常是 query 里多出来的一个值,或者请求头里的一个自定义字段。定位它的方法是:在接口的请求事件里复制完整 URL,观察哪个参数的值在不同请求之间是变化的,然后用这个参数名去搜索静态 JS 资源。

我碰过的签名算法大多绕不开这几类形态:把请求参数按字典序排序后拼成字符串,再拼上一个固定盐值做 SHA1 或 MD5;或者把时间戳做编码后参与拼接;或者只对请求路径做哈希。重写时先不要管前端用的什么框架,直接把算法逻辑剥出来。下面是一个通用签名重写模板:

import hashlib from urllib.parse import urlencode def build_sign(params: dict, secret: str) -> str: # 过滤掉空值和 sign 字段自身,避免签名循环依赖 clean = {k: v for k, v in params.items() if v not in ("", None)} # 按字典序排序并拼成 a=1&b=2 形式 raw = urlencode(sorted(clean.items()), doseq=True) sign = hashlib.sha1((raw + secret).encode("utf-8")).hexdigest() return sign

注意我特意把排泥出来的参数先排序再编码,这一步很关键:前端代码里如果用的是对象字面量,键的插入顺序不一定等于字典序,你重写时必须对齐实际请求里的拼接顺序,否则签名永远校验失败。另外,盐值这种东西几乎不可能通过阅读代码一次就看出来,实操里常用做法是在 JS 里搜索secret、salt、encrypt关键字,或者在网络面板里对比两次请求找出不变的常量。签名逆向本质上是“找变量、找常量、对比差异”三步,别一上来就扣细节。

4. 可运行源码:Python 抓取脚本从零到落库

4.1 项目结构与最小依赖清单

把前面拆出来的东西组装成一个能跑的项目,我建议的最小工程结构长这样:

xueqiu_spider/ ├── requirements.txt ├── cookie_utils.py # 管理 Cookie 的获取与持久化 ├── fetcher.py # 请求封装与异常定义 ├── store.py # SQLite 落库 └── run.py # 主入口

依赖只需要 requests 和 playwright 两个库。requests 负责发请求,playwright 负责在 Cookie 失效时启动浏览器取新会话。没有把解析和存储逻辑挂在 requests 之外,是因为这个场景的数据量不大,SQLite 足够,做数据分析和对接量化任务也方便。requirements.txt 里固定版本号是个好习惯,playwright 升级后 Cookie 获取逻辑偶尔会变,固定版本能减少翻车概率。

4.2 核心代码:登录态获取与行情抓取

cookie_utils.py 负责把上一章的 fetch_cookie 封装成带磁盘缓存的版本,同时提供失效重取的接口。fetcher.py 负责组装请求头与参数,store.py 负责建表和写库。先看 cookie_utils.py:

import json import os from playwright.sync_api import sync_playwright COOKIE_FILE = "cookies.json" def save_cookie(cookie: dict) -> None: with open(COOKIE_FILE, "w", encoding="utf-8") as f: json.dump(cookie, f) def load_cookie() -> dict | None: if not os.path.exists(COOKIE_FILE): return None with open(COOKIE_FILE, "r", encoding="utf-8") as f: return json.load(f) def fetch_and_save_cookie() -> dict: with sync_playwright() as p: browser = p.chromium.launch(headless=False) context = browser.new_context(locale="zh-CN") page = context.new_page() page.goto("https://xueqiu.com/", timeout=30000) page.wait_for_load_state("networkidle") cookie = { c["name"]: c["value"] for c in context.cookies() if c["domain"].find("xueqiu.com") >= 0 } browser.close() save_cookie(cookie) return cookie

这里把 headless 设成了 False 而不是 True,原因是部分环境里无头模式的自动化特征更容易触发风控;第一次手动跑一遍能看到浏览器窗口,也能确认是否被验证码拦截。确认稳定后,再改成 headless=True 才能扔到服务器上定时跑。

fetcher.py 里包含一个特殊异常类和一个抓取函数。抓取函数需要做到三件事:带上 Cookie、带上真实 User-Agent、解析并清洗返回字段。

import time import requests class CookieExpiredError(Exception): pass 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://xueqiu.com/", "Origin": "https://xueqiu.com", } def fetch_symbol_quote(symbol: str, cookie: dict) -> dict: url = "https://stock.xueqiu.com/v5/stock/quote.json" params = { "symbol": symbol, "extend": "detail", "current": str(int(time.time() * 1000)) } resp = requests.get( url, headers=HEADERS, cookies=cookie, params=params, timeout=10 ) if resp.status_code == 403: raise CookieExpiredError("请求被拒,需要重新获取 Cookie") payload = resp.json() if payload.get("error_code") != 0: raise CookieExpiredError(payload.get("error_description", "unknown")) q = payload["data"]["quote"] return { "symbol": q["symbol"], "name": q["name"], "current": round(float(q["current"]), 2), "percent": round(float(q["percent"]), 2), "high": round(float(q["high"]), 2), "low": round(float(q["low"]), 2), "turnover_rate": q.get("turnover_rate"), }

时间戳参数current是很多站点通用的防缓存手段,它让每次请求的 URL 都不一样,避免命中服务端缓存或本地缓存。extend=detail控制返回字段的详细程度,传错的话某些字段会缺失。函数里把 error_code 的非零状态统一当成 Cookie 失效处理,这是有意为之的宽松策略:签名错误、会话过期、接口风控这几类原因对客户端来说本质都是“当前会话不可用”,统一走重取 Cookie 的分支能简化主流程。

4.3 主流程怎么把三者串起来

run.py 把 Cookie 管理与行情抓取串起来。核心逻辑是:先加载本地 Cookie,没有就现取;抓取过程中抛 CookieExpiredError,就重新取一次 Cookie 再重试一试;每次抓完一条休息几秒,避免短时间高频请求触发风控。

import sqlite3 import time from cookie_utils import load_cookie, fetch_and_save_cookie from fetcher import fetch_symbol_quote, CookieExpiredError SYMBOLS = ["SH600519", "SZ000001", "SZ300750"] def main() -> None: cookie = load_cookie() if cookie is None: cookie = fetch_and_save_cookie() conn = sqlite3.connect("xueqiu.db") for idx, symbol in enumerate(SYMBOLS): for attempt in range(2): try: row = fetch_symbol_quote(symbol, cookie) conn.execute( """ INSERT INTO quote_snapshot (symbol, name, current, percent, high, low, turnover_rate, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (row["symbol"], row["name"], row["current"], row["percent"], row["high"], row["low"], row["turnover_rate"], int(time.time())) ) conn.commit() print(f"[{idx + 1}/{len(SYMBOLS)}] {row['symbol']} {row['current']}") break except CookieExpiredError: print(f"{symbol} 会话失效,重新获取 Cookie") cookie = fetch_and_save_cookie() time.sleep(5) time.sleep(3) conn.close() if __name__ == "__main__": main()

重试次数写成 2 而非无限重试,是考虑到 Cookie 反复失效可能不是会话问题,而是站点的风控策略已经针对当前 IP 或设备做标记。无限重试只会让 IP 越打越黑。每轮尝试中间 sleep 5 秒,让新 Cookie 有足够时间在服务端生效;相邻标的请求间 sleep 3 秒,把整体频率控制在很低的水平。

4.4 运行效果与结果落库

整个流程跑完后,SQLite 里会生成一张quote_snapshot表,字段包括 symbol、name、current、percent、high、low、turnover_rate、ts。ts 存的是 Unix 时间戳,盘后分析时可以直接按 ts 过滤出某个时间点的全市场快照。建表语句我建议放在初始化逻辑里手动执行一次:

CREATE TABLE IF NOT EXISTS quote_snapshot ( id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT NOT NULL, name TEXT, current REAL, percent REAL, high REAL, low REAL, turnover_rate REAL, ts INTEGER );

需要说明的是,这张表只是最朴素的“行情快照”模型。如果你同时抓讨论流或组合数据,建议按业务域拆表,而不是把不同结构的数据塞进同一张大宽表,后续做增量同步和字段校验都会方便得多。

5. 雪球逆向的 5 个高频坑与排查清单

5.1 请求返回 403 或者直接弹出验证码

现象:脚本在本地偶尔能跑,过一会儿再跑就连续 403;或者首页打开后出现滑块验证。

原因:请求头里的 User-Agent、Referer 与真实浏览器不一致,或者 Cookie 里的基础会话已过期。验证码的出现通常说明风控已经把当前 IP 和设备指纹标记为可疑。

解决:先把所有请求头改成从真实浏览器里复制出来的完整版本,注意不要自己拼接不完整的 UA。再把 Cookie 重新获取一次,并检查本地保存的 Cookie 文件是否被写入乱码。如果仍然触发验证码,往 cookie_utils 里加一步:获取 Cookie 时顺便用 Playwright 截屏,人工确认页面状态。

5.2 拿到 Cookie 但接口仍然返回 error_code 非 0

现象:Cookie 文件里能看到 xq_a_token 等键,但 requests 请求接口时提示会话不存在或签名错误。

原因:最常见的是漏带某些键。Cookie 里很多键值只在特定域名下有效,requests 的 cookies 参数接受的是一个 dict,不会替你按作用域过滤;你把 Playwright 导出的全部 Cookie 一股脑传给 requests,有可能会把重复键冲掉,或者把无效键也带上。

解决:在导出 Cookie 时只保留xueqiu.com主域及其子域下的条目,这是我在 cookie_utils 里特意做过滤的原因。另一个容易被忽略的点是请求头里的 Referer 必须和接口所在页面一致,某些接口会校验 Referer 的域名,缺失时直接拒绝。

5.3 本机能跑,放到服务器上就频繁失效

现象:同一套代码,本地跑一小时都没问题,部署到云服务器后不到十分钟就开始 403 或验证码。

原因:机房 IP 段是整个网络环境里的高风险来源,风控对这类 IP 的容忍度远低于家庭宽带。这和代码逻辑无关,属于账号与网络环境的信任评级问题。

解决:不要在服务器上开高并发,把单次抓取间隔拉长到 5 秒以上,抓取总量做上限控制。更重要的是让重取 Cookie 的过程具备人工兜底:当连续两次重取仍失败时退出程序并告警,而不是无限重试把 IP 越打越黑。

5.4 字段返回 null 或者类型漂移导致脚本崩溃

现象:某些标的的 turnover_rate 返回 null,float(null)直接抛 TypeError;偶尔 current 字段变成字符串而不是数字。

原因:停牌、退市、新股无开盘价等场景会导致部分行情字段缺失;接口升级后也可能把字段类型从 number 改成 string。

解决:解析时全部走“先判空再转类型”的写法,不要假定接口永远返回规范数据。我一般在 fetcher 里封装一个safe_float辅助函数,本质就是try/except加默认值兜底,宁可得到 None 也不要让异常中止整个抓取流程。

5.5 接口路径频繁变,写死的 URL 一夜失效

现象:昨天还正常的接口今天返回 404,或者 query 参数拼法变了。

原因:站点的接口版本迭代没有通告,上线也是灰度发布,不同地区不同设备命中的接口版本可能不一样。

解决:把接口地址和参数模板集中成配置文件,不要散落在业务代码里。每次改接口只需要动配置,不需要重新过一遍逻辑。另一个延长生命周期的做法是优先使用带 v5 这类版本号的路径,稳定性和语义明确程度都高于无版本号的接口。

6. 进阶:把单次抓取升级成增量同步工具

如果只是手动跑一次脚本,前面四章已经够用;但日常投研中更常见的是需要每天收盘后拉一遍全市场快照,盘中每隔几分钟拉一次自选股。这时候最值得做的事不是堆并发,而是把程序改造成具备增量同步能力的长期任务。

增量同步的核心是记录上一次成功抓取的位置。对于时间序列型的行情快照,这个位置是一个时间戳:每次抓取前先查库里的最大 ts,超过这个时间的数据才需要写入。对于列表型数据,比如讨论流,这个位置是分页游标next_max_id,每次都从上次记录的地方继续拉,直到游标归零。实现上只需要在 run.py 里加两件事:会话开始时读一次 sync_state 表,结束时更新它。下面是一个简化示例:

def get_last_ts(conn: sqlite3.Connection) -> int: row = conn.execute("SELECT MAX(ts) FROM quote_snapshot").fetchone() return row[0] or 0 def sync_once(conn: sqlite3.Connection, cookie: dict) -> None: last_ts = get_last_ts(conn) for symbol in SYMBOLS: row = fetch_symbol_quote(symbol, cookie) if row["ts"] > last_ts: insert_row(conn, row) update_sync_state(conn, int(time.time()))

get_last_ts返回 0 代表库里还没有数据,这时全量抓取;之后每次只抓新增数据。更新 sync_state 和插入数据放在同一个事务里,避免崩溃后状态丢失导致重复抓取。定时触发用操作系统的 cron 或计划任务就够了,不需要引入重量级的消息队列。

这个方法我用了很久,直到有一次因为没处理停牌标的的 null 字段,导致整列数据被下游计算模块吃进去,产出过一条错误的信号。那个教训让我把所有面向外部接口的解析代码都改成“先假设数据不可靠,再逐层校验”的风格。风控在升级,接口在变化,你能控制的只有自己的容错能力。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询