金融数据接口逆向实战:从JS加密到Python模拟请求的完整指南
1. 项目概述:一次典型的金融数据接口逆向实战
最近在做一个量化策略的辅助工具,需要实时获取A股市场的情绪热度数据。东方财富网的人气榜,作为一个反映个股实时关注度的风向标,自然进入了我的视线。这个榜单数据直观,更新频率高,对于短线情绪分析很有价值。然而,和大多数主流财经网站一样,东方财富并没有提供官方、稳定的数据API接口。这意味着,如果你想程序化地、自动化地获取这份榜单,唯一的途径就是对其网页或客户端进行逆向工程,找到数据源的真实接口。
这听起来有点“黑客”的味道,但实际上,在现代Web开发中,这更像是一种常规的数据获取技术探索。整个过程,就是一场与前端工程师的“猫鼠游戏”:他们用JavaScript混淆、参数加密、动态令牌来保护接口;我们则通过浏览器开发者工具、网络抓包、代码调试等手段,一步步揭开这些保护层,还原出最原始的HTTP请求。这次针对东方财富人气榜的逆向,就是一个非常典型的案例,涵盖了从抓包定位、参数逆向、签名破解到最终稳定请求的全流程。我踩了不少坑,也总结了一套行之有效的方法,下面就把完整的踩坑过程、技术细节和可直接运行的Python代码分享出来。
2. 逆向目标分析与环境准备
2.1 明确目标与数据定位
我们的核心目标是:模拟浏览器行为,通过程序发送HTTP请求,获取到与在东方财富网“人气榜”页面(通常是一个不断刷新的列表)看到的完全一致的、结构化的JSON数据。
首先,我们需要在浏览器中手动访问东方财富网,找到人气榜页面。通常,你可以在个股行情页的侧边栏或者专门的“热度”、“资金”板块找到它。打开Chrome或Edge浏览器的开发者工具(F12),切换到Network(网络)标签页,并勾选上“Preserve log”(保留日志)和“Disable cache”(禁用缓存)。然后刷新页面,或者触发榜单的刷新(如果有刷新按钮)。
这时,网络面板会刷出一系列请求。我们的任务是找到那个真正返回榜单数据的请求。通常,这类数据请求具有以下特征:
- 请求类型: 大概率是
XHR或Fetch。 - 响应内容:
Preview(预览)或Response(响应)标签页里能看到清晰的JSON结构,包含股票代码、名称、排名、人气值等字段。 - 请求URL: 可能包含
api、data、quote等关键词,或者是一个看起来有规律但非页面的地址。 - 请求频率: 如果榜单是定时刷新的,那么这个请求也会周期性出现。
经过一番查找,我定位到的关键接口形如:https://push2.eastmoney.com/api/qt/ulist.np/get。这就是我们本次逆向的主战场。
2.2 工具链准备
工欲善其事,必先利其器。逆向分析不需要特别高深的装备,但以下几样是必不可少的:
- 浏览器开发者工具: Chrome/Edge DevTools。这是我们的主武器,用于网络抓包、JS调试、DOM查看。
- 抓包与调试工具:
- Charles/Fiddler: 可选。对于HTTPS流量抓取和更复杂的请求重放、断点调试有帮助,但对于这个案例,浏览器自带的工具基本够用。
- Node.js: 强烈建议安装。用于在本地执行和调试关键的JavaScript代码片段,特别是涉及加密算法的部分。
- 编程语言与环境:
- Python 3.7+: 我们的最终实现语言。需要安装
requests库用于发送HTTP请求。 - Jupyter Notebook / VS Code: 方便的交互式环境,用于逐步测试和验证。
- Python 3.7+: 我们的最终实现语言。需要安装
- 逆向辅助思路:
- 搜索关键词: 在开发者工具的
Sources(源代码)标签页中,全局搜索(Ctrl+Shift+F)接口URL中的关键路径(如ulist.np/get)或请求参数名(如ut、fltt等),这是定位加密逻辑的捷径。 - “Hook”思想: 在Console中,可以通过重写
XMLHttpRequest.prototype.send或fetch函数,来拦截所有网络请求并打印详细信息,对于动态生成的请求尤其有效。
- 搜索关键词: 在开发者工具的
注意: 整个逆向过程必须遵守网站的服务条款(Robots协议)。本技术分享仅用于学习交流,获取的数据请勿用于商业用途或对目标服务器造成压力的高频请求。在实际应用中,务必添加合理的延时(如1-3秒一次),并考虑使用代理IP池来分散请求。
3. 核心接口参数逆向与解密
定位到接口后,在Network面板点击该请求,查看其Headers和Payload(在Fetch/XHR请求下,可能是Query String Parameters或Form Data或Request Payload)。你会发现,东方财富的接口参数通常不是简单的明文。
3.1 请求参数拆解
以我找到的接口为例,一个典型的请求参数列表如下:
url: https://push2.eastmoney.com/api/qt/ulist.np/get fields: f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13,f14 ut: fa5fd1943c7b386f172d6893dbfba10b fltt: 2 secid: 1.BK0814 invt: 2 cb: jsonp_callback_123456我们来逐一分析:
fields: 这个相对好理解,它定义了需要返回哪些数据字段(f1, f2...)。你需要对照JSON响应,弄清楚每个f编号对应什么含义(如最新价、涨跌幅、人气值等)。secid: 板块代码。1.BK0814可能代表“人气榜”这个特定的板块或分类。1通常代表沪深A股,BK开头可能是东方财富内部的板块编码。这个值需要从页面初始化或其他接口中获取。fltt,invt: 这些可能是固定值或表示数据格式、类型的参数。cb: JSONP回调函数名。如果接口是JSONP格式,这个参数是必须的,但我们的Python脚本可以直接处理JSON,可以构造一个固定的随机字符串。ut:这是关键!这个长达32位的十六进制字符串,看起来就像一个MD5或类似算法的哈希值。它很可能是一个动态生成的令牌(token),用于验证请求的合法性,也是逆向中最难的一环。
3.2 关键参数ut的生成逻辑追踪
ut参数是接口防爬的核心。我们需要找到它是如何计算出来的。
- 全局搜索: 在开发者工具的
Sources面板,全局搜索ut或fa5fd1943c7b386f172d6893dbfba10b这个具体的值。运气好的话,可以直接定位到生成它的函数。 - 调用栈分析: 在Network面板,找到目标请求,右键选择
Copy -> Copy as cURL可能只能得到静态参数。更好的方法是,在发起请求的瞬间,于Sources面板给XHR的send方法或fetch打上断点,然后刷新页面。当断点触发时,调用栈(Call Stack)会显示当前执行到的所有函数,一步步回溯,就能找到参数组装和ut生成的地方。 - Hook 拦截: 在Console中输入以下代码,然后刷新页面或触发请求,可以打印出所有请求的详细信息,包括发起请求时的函数调用环境。
// Hook XMLHttpRequest (function() { var originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function() { console.trace('XHR send called', this._url || this.url); console.log('Request URL:', this._url || this.url); console.log('Request Method:', this._method || 'GET'); console.log('Request Headers:', this._headers); console.log('Request Body:', arguments[0]); return originalSend.apply(this, arguments); }; })(); // Hook fetch (function() { var originalFetch = window.fetch; window.fetch = function() { console.trace('Fetch called', arguments[0]); console.log('Fetch Request:', arguments); return originalFetch.apply(this, arguments); }; })();通过以上方法,我最终追踪到ut的生成逻辑。它通常不是简单的对某个字符串做MD5,而是会结合一个密钥(secret)和当前时间戳或其他动态因子,通过一个自定义的或标准的哈希算法(如HMAC-MD5)计算得出。这个密钥可能硬编码在某个巨大的、经过混淆的JavaScript文件里。
踩坑记录1:代码混淆与格式化找到的JS代码很可能是被压缩和混淆过的,变量名都是a, b, c, d。第一步是使用开发者工具自带的{}(格式化代码)按钮,让代码变得可读。即使格式化后,逻辑可能依然绕来绕去。这时需要耐心,关注核心的加密函数入口,比如函数名包含encrypt、sign、getToken、ut等。
踩坑记录2:依赖浏览器环境生成ut的JavaScript函数,很可能依赖浏览器的某些内置对象或全局变量,比如window、document、或者页面中预先注入的一些全局变量(如_、$)。直接把这个函数抠出来在Node.js里跑,可能会报错“xxx is not defined”。解决办法是,在Node.js环境中,用global对象模拟window,并补全缺失的变量。更稳妥的办法是,使用PyExecJS或js2py库,在Python中直接执行这段JS代码,但效率较低。最优解是,彻底理解算法后,用Python的加密库(如hashlib,hmac)重新实现。
3.3 参数secid与fields的获取
secid: 这个值通常不是固定的。它可能通过页面初始化的另一个接口获取,或者隐藏在页面的某个全局变量或<script>标签的初始化数据中。你可以搜索BK0814或secid来找到它的来源。有时,它可能对应着不同榜单(如“今日人气榜”、“周人气榜”),需要你根据需求替换。fields: 这个需要你根据数据需求自己定义。通过观察多个请求和响应,可以归纳出常用的字段组合。例如,f12是股票代码,f14是股票名称,f3是涨跌幅,可能还有一个特定的字段(比如f100?)代表人气的具体数值或排名。这需要你仔细对比网页显示的数据和接口返回的JSON。
4. 完整请求构建与Python实现
在破解了ut的生成逻辑后,我们就可以用Python来模拟整个请求了。这里假设我们已经成功将生成ut的JS函数翻译成了Python函数generate_ut()。
4.1 请求头(Headers)的模拟
仅仅有正确的参数是不够的,请求头(Headers)也很重要,特别是User-Agent和Referer。一些简单的反爬会检查这些信息。
import requests import time import json from your_utils import generate_ut # 假设这是你实现的ut生成函数 def get_popularity_rank(): # 1. 构造请求URL和参数 base_url = "https://push2.eastmoney.com/api/qt/ulist.np/get" # 当前时间戳,可能是生成ut的因子之一 timestamp = int(time.time() * 1000) # 生成动态的 ut 参数 ut_token = generate_ut(timestamp) # 你需要实现这个函数 # 其他参数 params = { 'fields': 'f1,f2,f3,f4,f5,f6,f12,f13,f14,f100,f128,f136,f152', # 示例字段,需自行调整 'ut': ut_token, 'fltt': '2', 'secid': '1.BK0814', # 人气榜对应的secid 'invt': '2', 'cb': f'jQuery{timestamp}_{int(timestamp/1000)}', # 模拟一个jQuery JSONP回调名 '_': timestamp # 常见的防缓存参数 } # 2. 构造请求头 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Referer': 'https://quote.eastmoney.com/', # 人气榜页面所在的域名 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', } # 3. 发送请求 try: response = requests.get(base_url, params=params, headers=headers, timeout=10) response.raise_for_status() # 检查请求是否成功 # 4. 处理响应 (JSONP格式处理) resp_text = response.text # 响应通常是 jsonpCallback({...}) 格式,需要去掉包裹 # 找到第一个左括号和最后一个右括号 start = resp_text.find('(') end = resp_text.rfind(')') if start != -1 and end != -1: json_str = resp_text[start+1:end] data = json.loads(json_str) else: # 如果不是JSONP,尝试直接解析 data = response.json() # 5. 解析数据 # 数据结构通常是 data -> diff -> list stock_list = data.get('data', {}).get('diff', []) for stock in stock_list: code = stock.get('f12') # 股票代码 name = stock.get('f14') # 股票名称 rank_value = stock.get('f100') # 假设f100是人气值,需确认 print(f"代码: {code}, 名称: {name}, 人气值: {rank_value}") return stock_list except requests.exceptions.RequestException as e: print(f"请求失败: {e}") return None except json.JSONDecodeError as e: print(f"JSON解析失败: {e}, 原始响应: {resp_text[:200]}") return None # 调用函数 if __name__ == '__main__': result = get_popularity_rank()4.2generate_ut函数的Python实现示例
这是整个逆向的核心。由于涉及网站的具体算法(且可能变更),这里我给出一个假设性的通用框架。真实的算法需要你通过JS逆向得到。
import hashlib import hmac import time def generate_ut(timestamp): """ 模拟东方财富接口 ut 参数的生成算法。 注意:这是一个示例框架,真实算法需要通过JS逆向获得。 """ # 假设的密钥,真实情况需要从JS代码中提取 secret_key = b'eastmoney_secret_2024' # 这只是一个示例! # 假设的原始字符串拼接规则,例如: `method + timestamp + path` # 你需要根据逆向结果确定拼接顺序和内容 raw_string = f'GET{timestamp}/api/qt/ulist.np/get' # 假设使用 HMAC-MD5 算法,这也是常见的签名方式 signature = hmac.new(secret_key, raw_string.encode('utf-8'), hashlib.md5).hexdigest() # 有时还会进行二次处理,比如截取、大小写转换等 ut_token = signature.lower() # 假设最终转为小写 return ut_token踩坑记录3:时间戳的格式与精度在逆向时,要特别注意JS代码里使用的时间戳是Date.now()(毫秒级,13位)还是Math.floor(Date.now() / 1000)(秒级,10位)。Python的time.time()返回浮点秒数,需要乘以1000取整才能得到13位毫秒时间戳。这个差异会导致签名错误。
踩坑记录4:字符串编码与拼接JavaScript和Python的字符串处理有时会有细微差别。确保在拼接用于签名的原始字符串时,空格、标点、顺序与JS端完全一致。最好将JS中生成签名的关键步骤用console.log打印出来,然后在Python中严格按照相同的步骤和中间结果进行比对调试。
5. 数据解析与持久化策略
成功获取到数据后,我们需要将其解析成可用的格式。
5.1 响应数据结构解析
东方财富接口返回的数据通常嵌套较深。你需要仔细查看data.diff这个列表。列表中的每个元素是一个字典,对应一只股票。字典的键就是fields参数里请求的那些f1,f2...。
你需要做一个字段映射表:
FIELD_MAPPING = { 'f12': '股票代码', 'f14': '股票名称', 'f2': '最新价', 'f3': '涨跌幅(%)', 'f4': '涨跌额', 'f5': '成交量(手)', 'f6': '成交额', 'f100': '人气值', # 这个需要你确认 'f128': '排名', # 这个需要你确认 # ... 其他字段 }然后遍历stock_list,根据这个映射表提取和重命名数据。
5.2 数据存储方案
对于实时监控,你可以选择:
- CSV文件: 简单易用,适合短期、小批量数据记录。每次运行追加一行时间戳和榜单数据(可以只存前N名)。
import pandas as pd import datetime def save_to_csv(data_list, filename='popularity_rank.csv'): df = pd.DataFrame(data_list) df['更新时间'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') # 如果文件存在,追加写入,否则创建 try: existing_df = pd.read_csv(filename) final_df = pd.concat([existing_df, df], ignore_index=True) except FileNotFoundError: final_df = df final_df.to_csv(filename, index=False, encoding='utf_8_sig') - 数据库: 推荐方案,适合长期、结构化存储和后续分析。使用SQLite(轻量)或MySQL/PostgreSQL。
import sqlite3 import datetime def init_db(db_path='eastmoney.db'): conn = sqlite3.connect(db_path) c = conn.cursor() c.execute('''CREATE TABLE IF NOT EXISTS popularity_rank (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, stock_code TEXT, stock_name TEXT, rank INTEGER, popularity_value REAL, price REAL, change_percent REAL)''') conn.commit() conn.close() def save_to_db(data_list): conn = sqlite3.connect('eastmoney.db') c = conn.cursor() now = datetime.datetime.now() for item in data_list: c.execute('''INSERT INTO popularity_rank (timestamp, stock_code, stock_name, rank, popularity_value, price, change_percent) VALUES (?,?,?,?,?,?,?)''', (now, item['code'], item['name'], item['rank'], item['pop_value'], item['price'], item['change_pct'])) conn.commit() conn.close()
5.3 定时任务与自动化
使用系统的定时任务(如Linux的cron,Windows的任务计划程序)或Python库(如schedule、APScheduler)来定期运行你的爬虫脚本。
import schedule import time def job(): print(f"开始获取人气榜数据 {time.strftime('%Y-%m-%d %H:%M:%S')}") data = get_popularity_rank() if data: save_to_db(data) # 或 save_to_csv print("数据获取完成") # 每5分钟运行一次 schedule.every(5).minutes.do(job) while True: schedule.run_pending() time.sleep(1)重要提醒: 务必设置合理的请求间隔!过于频繁的请求(如每秒多次)会对东方财富的服务器造成压力,可能导致你的IP被暂时或永久封禁。建议间隔至少在1分钟以上,对于非实时性要求极高的策略,5-10分钟一次更为稳妥。可以考虑在请求间加入随机延时
time.sleep(random.uniform(1, 3))来模拟更自然的人类行为。
6. 常见问题排查与维护技巧
即使代码写好了,在实际运行中也会遇到各种问题。这里记录了几个我踩过的坑和解决方法。
6.1 请求返回空数据或错误码
- 现象:
data.diff为空列表,或者返回的JSON中有error_code。 - 排查步骤:
- 检查
ut参数: 这是最常见的原因。首先确认你的generate_ut函数是否与当前网站版本同步。网站可能会更新加密算法。重新抓包,对比你生成的ut和浏览器实际发送的ut是否完全一致。 - 检查时间戳: 确认时间戳的格式(10位还是13位)和取值是否与JS逻辑一致。服务器时间可能与本地时间有微小偏差,可以尝试用服务器时间(从其他接口的响应头获取)来校准。
- 检查
secid: 确认这个板块代码是否有效。它可能已经过期或对应了错误的榜单。 - 检查请求头: 特别是
Referer和User-Agent,有些反爬会校验它们。尝试使用与抓包时完全一致的Headers。 - 检查IP限制: 短时间内请求过多,IP可能被限制。尝试更换IP或增加请求间隔。
- 检查
6.2ut生成函数依赖浏览器环境
- 现象: 将JS函数抠出来在Node.js或Python的ExecJS中运行,报错
ReferenceError: window is not defined。 - 解决方案:
- 环境模拟: 在Node.js中,可以通过
global.window = global;或定义缺失的全局变量来模拟。但这种方法比较 hacky。 - 算法重写: 这是最根本、最稳定的方法。彻底理解JS中的加密逻辑(比如是标准的HMAC-MD5,还是AES加密,或者是自定义的位运算),然后用Python的加密库(
hashlib,hmac,Crypto)重新实现。这需要较强的代码分析能力。 - 使用无头浏览器: 作为备选方案,可以使用
Selenium或Playwright控制一个真正的浏览器来加载页面并执行JS,然后从浏览器上下文中提取数据。这种方法稳定但资源消耗大、速度慢,不适合高频请求。
- 环境模拟: 在Node.js中,可以通过
6.3 接口变更与代码维护
金融网站的接口和反爬策略不是一成不变的。
- 监控机制: 给你的脚本添加健康检查。如果连续多次请求失败或返回数据异常,应触发报警(如发送邮件、微信消息)。
- 版本隔离: 将关键配置(如接口URL、
secid、字段映射、加密密钥)放在配置文件(如config.yaml或config.py)中,而不是硬编码在主逻辑里。这样当它们变化时,你只需要修改配置文件。 - 定期复核: 每隔一两周,手动用浏览器抓一次包,对比一下请求参数和响应结构是否有变化。养成这个习惯可以让你在脚本完全失效前提前发现苗头。
6.4 数据清洗与异常值处理
爬取到的数据可能包含异常值,比如涨停/跌停时某些字段为特殊值(如None或字符串"-"),或者人气值突然出现极大/极小的异常点。
def clean_stock_data(item): """清洗单只股票的数据""" cleaned = {} for key, value in item.items(): if value is None: cleaned[key] = 0.0 if key in ['f2', 'f3', 'f100'] else '' elif isinstance(value, str) and value.strip() in ['-', '--', '']: cleaned[key] = 0.0 if key in ['f2', 'f3', 'f100'] else '' else: # 尝试转换为数值 try: cleaned[key] = float(value) except (ValueError, TypeError): cleaned[key] = value return cleaned逆向工程是一个持续对抗和学习的過程。成功获取东方财富人气榜数据,不仅让你得到了一份有价值的数据源,更重要的是,你掌握了一套应对类似前端加密接口的通用方法论:抓包定位、参数分析、JS逆向、算法还原、模拟请求。这套方法在爬取其他有类似保护的网站时同样适用。最后再次强调,技术用于学习,使用数据请务必遵守法律法规和网站规则,保持克制的请求频率。
