前阵子有位做选股研究的朋友跟我抱怨,说网上免费行情接口要么要注册token,要么限制调用频次,折腾半天连第一份数据都拿不到。我的建议很简单:直接用Python爬虫抓东方财富网的公开行情接口,不需要登录、不需要对前端JS做逆向,5分钟就能把全市场A股的实时价格、涨跌幅、成交额这些核心字段抓回本地。这篇文章就是完整的实战记录,从接口分析、代码实现到实际运行中会踩的坑都会讲到。内容不依赖任何第三方财经SDK,只要装了Python和requests库就能直接跑。
1. 为什么我最终选择了东方财富的JSON接口
1.1 常见的行情数据获取方式都有什么毛病
做个人投研、量化选股或者单纯想验证交易想法的时候,第一步往往就是拿到一份干净的行情数据。我见过的大部分人首选的是tushare、baostock或者akshare这类现成库,但它们各自有绕不开的门槛。比如tushare的积分制度会导致部分接口调用受限,akshare虽然封装得简单,但本质上是把多个数据源揉在一起,经常遇到某个源某天突然失效的情况,排查起来比较费劲。至于用selenium去模拟浏览器打开行情页面,再一个个解析HTML节点,速度和稳定性都堪忧,盘中数据刷新一次要好几秒,基本没法做实时场景。
如果真的需要稳定、零成本、快速验证数据获取方案,东方财富网页版行情中心背后的JSON接口反而是最省事的一条路。它不需要申请任何凭证,浏览器能访问到的数据,我们直接构造HTTP请求就能拿到同样的JSON结构,比解析HTML简单一个量级。
1.2 东财接口的核心优势
东方财富的行情接口主要有几个特征让我愿意一直用它:第一,接口返回的是纯JSON,解析成本极低;第二,字段覆盖非常完整,从基础行情到估值、资金流、板块归属都有对应字段;第三,通过修改fs参数就能切换不同的股票池,既能拿沪深A股,也能拿创业板、科创板甚至某只具体股票的K线数据。
当然,任何免费数据源都有它的边界。东财这套接口主要是为网页端行情服务的,数据本身会有几秒到十几秒的延迟,对于日常选股、盘后复盘、历史数据分析来说完全够用,但如果想做毫秒级的高频交易,这不现实。技术选型上,一定要先判断自己的场景属于哪种类型,再决定要不要用爬虫方案,而不是一上来就拿着爬虫工具到处抓。
我在实际使用中把它定位为"个人研究向的公开数据获取手段",频率控制到最低,每次请求间隔0.3秒以上,不并发、不暴力、不拿数据做任何商业化分发。这既是对数据源的尊重,也是避免给自己惹麻烦的基本操作。
2. 开抓之前,先把接口参数和返回结构摸清楚
2.1 核心接口URL与关键参数说明
我们主要用的是行情中心的列表接口:
https://push2.eastmoney.com/api/qt/clist/get浏览器打开https://quote.eastmoney.com/center/gridlist.html这个行情页面,翻页、排序、切换板块时,网络请求里都会出现这个接口。它本身是个GET请求,所有筛选条件都放在Query参数里。最关键的参数如下:
| 参数名 | 含义 | 常用取值 |
|---|---|---|
pn | 页码 | 从1开始 |
pz | 每页条数 | 建议100或200 |
po | 排序方向 | 1升序,0降序 |
np | 分页内部参数 | 固定为1 |
fid | 排序字段 | f3按涨跌幅排序 |
fs | 筛选的市场范围 | 沪深A股组合值 |
fltt | 浮点格式 | 2表示返回可读小数 |
invt | 是否包含投资者关系数据 | 2 |
fields | 需要返回的字段列表 | 逗号分隔的f字段 |
这里稍微解释一下fs参数,它是决定"抓哪一批股票"的关键。沪深A股通常使用的组合值是:
m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23拆开来看:
| 组合片段 | 含义 |
|---|---|
m:0+t:6 | 深市A股 |
m:0+t:80 | 创业板 |
m:1+t:2 | 沪市A股 |
m:1+t:23 | 科创板 |
如果想包含北交所,还可以在后面追加m:0+t:81+s:2048。这个参数看起来像一串魔数,实际上是有规律的市场代码组合,记不住也没关系,从浏览器请求里原样复制就行。
ut参数是一个固定token,直接沿用浏览器的值即可,我用的是bd1d9ddb04089700cf9c27f6f7426281,目前是有效的。如果哪天接口升级这个token失效了,从浏览器请求里重新复制即可。
2.2 返回数据结构与字段含义对照
接口返回的JSON结构大概长这样:
{ "rc": 0, "data": { "total": 5362, "diff": [ { "f2": 10.85, "f3": 2.36, "f12": "600519", "f14": "贵州茅台" } ] } }data.total是符合筛选条件的股票总数,data.diff是当前页的股票列表数组。fields参数里填了哪些f字段,diff里的每个对象就会返回哪些字段。常用字段我整理成了一张表:
| 字段名 | 含义 | 示例值 |
|---|---|---|
f2 | 最新价 | 10.85 |
f3 | 涨跌幅 | 2.36 |
f4 | 涨跌额 | 0.25 |
f5 | 成交量(手) | 123456 |
f6 | 成交额(元) | 123456789 |
f7 | 振幅 | 3.21 |
f8 | 换手率 | 1.89 |
f9 | 市盈率 | 15.67 |
f10 | 量比 | 1.02 |
f12 | 股票代码 | 600519 |
f14 | 股票名称 | 贵州茅台 |
f15 | 最高价 | 1718.00 |
f16 | 最低价 | 1650.01 |
f17 | 今开 | 1700.00 |
f18 | 昨收 | 1690.00 |
理解了这个结构,剩下的工作其实就是"翻页拉取所有股票,然后拼成一个DataFrame"。不过要注意,某些字段在停牌或者特殊情况下会是null,比如长期停牌的股票没有最新价,后面清洗数据时要做空值处理。
3. 不知道接口怎么找?用浏览器开发者工具逆向一次
3.1 F12定位clist/get请求的完整过程
假设你完全不知道东财的接口地址,怎么靠浏览器把它找出来?整个过程没有技术难度,但步骤要说清楚。
首先用Chrome或Edge打开行情中心页面,我习惯直接用这个地址:https://quote.eastmoney.com/center/gridlist.html。打开之后按F12进入开发者工具,切到Network(网络)面板,再把筛选条件选成XHR或者Fetch/XHR,避免被图片、CSS文件干扰。此时按F5刷新页面,或者手动点击页面上方的"涨跌幅"表头排序触发一次数据请求。
面板里会出现很多条请求记录,我们需要找的是名字里带clist/get的那一条。点开这条请求,右侧面板切换到Headers可以看到请求URL,往下拉找到Query String Parameters,这里就是浏览器真实发送的完整参数列表。再切换到Preview或者Response面板,能看到返回的JSON里data.diff已经有股票数据了。
我在浏览器里定位请求时有个小习惯:先把页面上的筛选条件调整成我想要的目标股票池,比如先选"沪深京A股",再按涨跌幅排序一次。这样做一次操作,触发出来的请求参数就是可以直接复用的正确组合,比自己凭空猜fs参数靠谱得多。
3.2 把浏览器请求原样复现到Python里
拿到请求URL和参数之后,先在浏览器地址栏直接访问一次这个URL,确认能返回JSON。注意有些接口如果带了cb回调参数,返回的会是JSONP格式,形如callback({...}),那不是纯JSON,需要额外处理。clist/get这个接口只要不主动加cb参数,返回的就是标准JSON,直接用resp.json()就能解析。
在Python里最简单的一版请求是这样的:
import requests url = "https://push2.eastmoney.com/api/qt/clist/get" params = { "pn": 1, "pz": 100, "po": 1, "np": 1, "ut": "bd1d9ddb04089700cf9c27f6f7426281", "fltt": 2, "invt": 2, "fid": "f3", "fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23", "fields": "f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f14,f15,f16,f17,f18", } headers = { "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://quote.eastmoney.com/", } resp = requests.get(url, params=params, headers=headers, timeout=10) data = resp.json()["data"] print(data["total"]) print(data["diff"][0])这里headers里的Referer和User-Agent是必带的,别偷懒。虽然东财对直接访问不强制校验Referer,但带上更接近浏览器行为,能减少很多莫名其妙的反爬拦截。
4. 完整代码:一次跑通沪深A股实时行情抓取
4.1 抓取函数:请求、分页、异常重试
直接上完整可运行代码。这段代码我拆成了几个函数,方便后续扩展。第一个函数负责单页请求,并加了简单的重试机制;第二个函数负责自动翻页拉到全市场数据。
import time import requests import pandas as pd BASE_URL = "https://push2.eastmoney.com/api/qt/clist/get" HEADERS = { "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://quote.eastmoney.com/", } FIELDS = "f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18" FS_A_SHARE = "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23" def fetch_page(pn=1, pz=100, retries=3): """获取单页沪深A股行情数据,带基础重试逻辑""" params = { "pn": pn, "pz": pz, "po": 1, "np": 1, "ut": "bd1d9ddb04089700cf9c27f6f7426281", "fltt": 2, "invt": 2, "fid": "f3", "fs": FS_A_SHARE, "fields": FIELDS, } for attempt in range(retries): try: resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=10) resp.raise_for_status() data = resp.json().get("data") if data is None and attempt < retries - 1: time.sleep(1) continue return data if data else {"total": 0, "diff": []} except Exception as e: print(f"请求异常:{e},第{attempt + 1}次重试") time.sleep(1) return {"total": 0, "diff": []} def fetch_all_stocks(pz=100, max_pages=None): """自动翻页抓取全部沪深A股行情数据""" first = fetch_page(pn=1, pz=pz) total = first.get("total", 0) print(f"沪深A股总数:{total}") all_rows = first.get("diff", []) or [] total_pages = (total + pz - 1) // pz if max_pages: total_pages = min(total_pages, max_pages) for page in range(2, total_pages + 1): print(f"正在抓取第 {page}/{total_pages} 页...") data = fetch_page(pn=page, pz=pz) all_rows.extend(data.get("diff", []) or []) # 控制请求频率,避免对数据源造成压力 time.sleep(0.3) return all_rowsmax_pages参数是我平时调试用的,如果只想快速验证流程,可以传一个10,抓完前10页先看效果,确认没问题再去掉限制跑全量。
4.2 清洗函数:字段重命名、类型转换、代码补零
接口返回的原始字段名都是f2、f12这种,不方便直接阅读,建议在进入分析流程之前统一重命名。这里还有一个非常容易踩的坑:股票代码字段f12在某些情况下不保留前导零,比如平安银行会返回1而不是000001,必须用zfill(6)补零。
COLUMN_MAP = { "f2": "最新价", "f3": "涨跌幅", "f4": "涨跌额", "f5": "成交量", "f6": "成交额", "f7": "振幅", "f8": "换手率", "f9": "市盈率", "f10": "量比", "f12": "代码", "f13": "市场标识", "f14": "名称", "f15": "最高", "f16": "最低", "f17": "今开", "f18": "昨收", } NUMERIC_COLS = [ "最新价", "涨跌幅", "涨跌额", "成交量", "成交额", "振幅", "换手率", "市盈率", "量比", "最高", "最低", "今开", "昨收", ] def clean_data(rows): df = pd.DataFrame(rows) df.rename(columns=COLUMN_MAP, inplace=True) for col in NUMERIC_COLS: if col in df.columns: df[col] = pd.to_numeric(df[col], errors="coerce") if "代码" in df.columns: df["代码"] = df["代码"].astype(str).str.zfill(6) return df重命名之后,你拿到的就是一个列名直白的干净DataFrame。errors="coerce"的作用是把无法转换的异常值变成NaN,避免因为个别字段是"-"或者null导致整列类型错乱。
4.3 主流程与运行结果验证
主流程非常简单:抓取原始数据,清洗,按涨跌幅排序,打印结果头部,然后保存成CSV。
if __name__ == "__main__": rows = fetch_all_stocks(pz=100, max_pages=10) df = clean_data(rows) df.sort_values("涨跌幅", ascending=False, inplace=True) print(df.head(20).to_string(index=False)) df.to_csv("stock_data.csv", index=False, encoding="utf-8-sig") print(f"共抓取 {len(df)} 条数据,已保存到 stock_data.csv")如果环境已经准备好,从复制代码到看到输出,5分钟是真的够。第一次运行如果数据量是全市场五千多只股票,大概需要十几秒到半分钟,取决于网络状况。保存CSV的时候我特意用了utf-8-sig编码,这样用Excel直接打开不会出现中文乱码。
运行之后你应该能看到类似这样的输出:
沪深A股总数:5362 正在抓取第 2/54 页... 正在抓取第 3/54 页... ... 代码 名称 最新价 涨跌幅 301589 诺瓦星云 232.50 20.00 300738 奥飞数据 13.20 19.89 ...看到这个输出,说明你的一整套抓取链路已经通了。从定位接口到数据落盘,整个过程其实就这么几步,并没有想象中那么玄乎。
5. 线上跑起来容易踩的5个坑,以及对应的规避方法
5.1 请求频次太高被限制时的表现
第一个坑很多人都会踩:把time.sleep(0.3)删掉,觉得多线程并发更快,结果跑了没几页就发现返回的数据全是空的。注意,东财反爬限流的表现不是直接返回403,也不是封IP,而是返回正常的HTTP 200,但data字段变成null。如果代码里没有对data is None做判断,继续往下跑就会报TypeError,然后你还在那儿排查字典结构,完全意识不到是被限流了。
所以我在fetch_page函数里专门加了一个判断:如果data是None且还没达到重试上限,就sleep 1秒再试一次。实测下来,单线程加0.3秒间隔是可以稳定跑完全市场的,没必要图快上并发。
5.2 股票代码前导零丢失
第二个坑是股票代码的格式问题。接口返回的f12字段看起来是字符串,但部分股票代码前面有零的时候,实际返回的是去掉前导零的数字形式。比如000001可能返回成1,002415可能返回成2415。如果你拿着这些代码去当主键做数据合并,或者去请求K线接口,一定会对不上数据。
解决办法就是前面代码里写的:df["代码"] = df["代码"].astype(str).str.zfill(6)。这一步必须在数据分析之前做,否则后续所有关联查询都会翻车。
5.3 成交量单位容易搞混
第三个坑是单位问题。接口返回的f5成交量单位是"手",f6成交额单位是"元"。一手等于100股,这个转换关系知道的人不少,但真正处理数据的时候经常忘记。如果你要做换手率、量比相关的计算,最好在清洗阶段就统一单位,否则后面Colab或者Jupyter Notebook里越算越糊涂。建议加两列:成交量(股)和成交额(万元),作为标准化的中间字段。
5.4 停牌股和处理不了的空值
第四个坑是空值。长期停牌的股票没有最新价、没有涨跌幅,接口返回的可能是null,也可能是空字符串。pd.to_numeric(errors="coerce")会把它们统一变成NaN,处理起来就方便了。做排序或者筛选的时候,NaN会被排到后面,这可能影响你的选股逻辑,比如你按涨跌幅从高到低排序,停牌股不会干扰头部结果,但如果你按某个指标筛选,最好先决定好对NaN是填充还是删除。
5.5 JSONP回调与编码问题
第五个坑来自接口的调用方式。有些教程会在URL里带一个cb=jQuery...这样的参数,这是为了满足网页端JSONP跨域需要的,结果返回内容变成callback({...}),直接用resp.json()解析会报错。我们的代码里完全没有加cb参数,所以返回的是纯JSON,不会遇到这个问题。另外,解析响应内容时用resp.json(),不要先resp.text再json.loads,前者能避免编码识别差异引起的中文乱码问题。
6. 从一次性抓取到持续跟踪:增量更新与历史K线扩展
6.1 简单落库方案:加个日期就形成历史快照
全量抓取只是一个起点,真正有价值的是每天积累数据。最简单的方案是每次运行脚本时,把当天日期拼进文件名:
from datetime import datetime today = datetime.now().strftime("%Y%m%d") df.to_csv(f"stock_data_{today}.csv", index=False, encoding="utf-8-sig")这样每天收盘后跑一次,一个月之后你就有了20多个每日快照文件。配合pd.concat就能分析个股在一个月内的价格变化轨迹,做简单的回测样本也够了。如果你想把所有快照合并到一个文件里,可以在每天的DataFrame里加一列日期,然后追加写CSV,注意去重逻辑按"日期+代码"作为唯一键。
如果想更正式一点,可以用SQLite:
import sqlite3 conn = sqlite3.connect("stock_history.db") df["日期"] = datetime.now().strftime("%Y-%m-%d") df.to_sql("daily_snapshot", conn, if_exists="append", index=False) conn.close()SQLite的好处是查询方便,想筛某一天的涨跌幅排名直接用SQL就行,不用把一堆CSV读进来。对个人项目来说,SQLite是完全够用的轻量方案,没必要为了存几千行数据专门去搭MySQL。
6.2 从实时行情到历史K线:push2his接口示例
很多做策略研究的人不满足于实时快照,还想要历史K线。东财的历史K线接口和列表接口风格非常接近,同样免鉴权、返回JSON。核心接口是:
https://push2his.eastmoney.com/api/qt/stock/kline/get请求参数大概是这样的:
kline_url = "https://push2his.eastmoney.com/api/qt/stock/kline/get" kline_params = { "secid": "1.600519", # 格式:市场标识.代码,1表示沪市,0表示深市 "klt": "101", # K线类型:101日K,102周K,103月K "fqt": "1", # 复权类型:1前复权,2后复权,0不复权 "lmt": "120", # 返回多少根K线 "end": "20500101", # 结束日期 "fields1": "f1,f2,f3,f4,f5,f6", "fields2": "f51,f52,f53,f54,f55,f56,f57,f58", }返回的data.klines里每一行是一个以逗号分隔的字符串,字段顺序对应fields2里定义的f51日期、f52开盘、f53收盘、f54最高、f55最低、f56成交量、f57成交额、f58振幅。拿到之后按逗号split成列表,就能拼出标准的K线DataFrame。
这里有一个关键点:secid里的市场标识和股票代码必须对应正确。沪市股票代码以6开头,市场标识是1;深市股票代码以0开头,市场标识是0。不要想当然地认为所有股票都是同一个市场代码,否则请求的K线数据会是空的。评论区经常有人问为什么K线返回null,十有八九就是这里搞错了。
6.3 后续还能往哪些方向扩展
上面这套代码已经构成了一个极简的行情数据获取框架。顺着同样的思路,你可以扩展的东西其实不少。
比如东财还有专门的基础信息接口,可以拿到股票所属行业、上市日期、总市值、流通市值等字段,对选股很有用。这些接口的调用方式跟clist/get一脉相承,只要换URL和参数就能复用同一个请求函数。
也可以从行情数据扩展到资金流数据,东财有按个股统计的主力净流入、超大单净流入等字段。做短线观察的人通常会对这些数据感兴趣,它们同样藏在某个f字段编号里,只需要把fields参数加长,或者调用对应的数据接口。
另一个实用的改进方向是做一个增量任务调度。比如用schedule库设置每天15点35分自动执行一次脚本,把当日快照落库,这样就完全不用手动干预了。个人电脑保持开机就行,实在不行放到一台便宜的云服务器上,定时任务加上日志输出,就能实现完全自动化的数据采集。
我在实际项目里的习惯是每天收盘后先把全市场快照入库,然后针对自选股列表调用一次K线接口,把当日最新K线追加到历史表。这样既保留了全市场的截面数据,又积累了个股的时序数据,两套数据配合起来,做策略验证时非常方便。等到积累了一个月以上的数据,你会发现很多简单的统计规律已经可以验证了,比如涨跌幅分布、换手率和次日表现的关系,这些都是最有价值的个人数据集来源。
最后再提醒一句,整个脚本里最重要的不是requests的用法,也不是字段映射表,而是那个看起来不起眼的time.sleep(0.3)。把它保留住,你的采集程序就能长期稳定地跑下去。我见过太多人删掉限速之后把接口抓挂,然后到处问为什么数据源封了自己。爬虫这东西,跑得快不如跑得久。