ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬虫实战:抓取东方财富A股实时行情数据,5分钟搭建免费数据管道

Python爬虫实战:抓取东方财富A股实时行情数据,5分钟搭建免费数据管道 前阵子有位做选股研究的朋友跟我抱怨说网上免费行情接口要么要注册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或200po排序方向1升序0降序np分页内部参数固定为1fid排序字段f3按涨跌幅排序fs筛选的市场范围沪深A股组合值fltt浮点格式2表示返回可读小数invt是否包含投资者关系数据2fields需要返回的字段列表逗号分隔的f字段这里稍微解释一下fs参数它是决定抓哪一批股票的关键。沪深A股通常使用的组合值是m:0t:6,m:0t:80,m:1t:2,m:1t:23拆开来看组合片段含义m:0t:6深市A股m:0t:80创业板m:1t:2沪市A股m:1t:23科创板如果想包含北交所还可以在后面追加m:0t:81s: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.85f3涨跌幅2.36f4涨跌额0.25f5成交量手123456f6成交额元123456789f7振幅3.21f8换手率1.89f9市盈率15.67f10量比1.02f12股票代码600519f14股票名称贵州茅台f15最高价1718.00f16最低价1650.01f17今开1700.00f18昨收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:0t:6,m:0t:80,m:1t:2,m:1t: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, paramsparams, headersheaders, timeout10) 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:0t:6,m:0t:80,m:1t:2,m:1t:23 def fetch_page(pn1, pz100, retries3): 获取单页沪深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, paramsparams, headersHEADERS, timeout10) 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(pz100, max_pagesNone): 自动翻页抓取全部沪深A股行情数据 first fetch_page(pn1, pzpz) 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(pnpage, pzpz) 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(columnsCOLUMN_MAP, inplaceTrue) for col in NUMERIC_COLS: if col in df.columns: df[col] pd.to_numeric(df[col], errorscoerce) if 代码 in df.columns: df[代码] df[代码].astype(str).str.zfill(6) return df重命名之后你拿到的就是一个列名直白的干净DataFrame。errorscoerce的作用是把无法转换的异常值变成NaN避免因为个别字段是-或者null导致整列类型错乱。4.3 主流程与运行结果验证主流程非常简单抓取原始数据清洗按涨跌幅排序打印结果头部然后保存成CSV。if __name__ __main__: rows fetch_all_stocks(pz100, max_pages10) df clean_data(rows) df.sort_values(涨跌幅, ascendingFalse, inplaceTrue) print(df.head(20).to_string(indexFalse)) df.to_csv(stock_data.csv, indexFalse, encodingutf-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可能返回成1002415可能返回成2415。如果你拿着这些代码去当主键做数据合并或者去请求K线接口一定会对不上数据。解决办法就是前面代码里写的df[代码] df[代码].astype(str).str.zfill(6)。这一步必须在数据分析之前做否则后续所有关联查询都会翻车。5.3 成交量单位容易搞混第三个坑是单位问题。接口返回的f5成交量单位是手f6成交额单位是元。一手等于100股这个转换关系知道的人不少但真正处理数据的时候经常忘记。如果你要做换手率、量比相关的计算最好在清洗阶段就统一单位否则后面Colab或者Jupyter Notebook里越算越糊涂。建议加两列成交量(股)和成交额(万元)作为标准化的中间字段。5.4 停牌股和处理不了的空值第四个坑是空值。长期停牌的股票没有最新价、没有涨跌幅接口返回的可能是null也可能是空字符串。pd.to_numeric(errorscoerce)会把它们统一变成NaN处理起来就方便了。做排序或者筛选的时候NaN会被排到后面这可能影响你的选股逻辑比如你按涨跌幅从高到低排序停牌股不会干扰头部结果但如果你按某个指标筛选最好先决定好对NaN是填充还是删除。5.5 JSONP回调与编码问题第五个坑来自接口的调用方式。有些教程会在URL里带一个cbjQuery...这样的参数这是为了满足网页端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(fstock_data_{today}.csv, indexFalse, encodingutf-8-sig)这样每天收盘后跑一次一个月之后你就有了20多个每日快照文件。配合pd.concat就能分析个股在一个月内的价格变化轨迹做简单的回测样本也够了。如果你想把所有快照合并到一个文件里可以在每天的DataFrame里加一列日期然后追加写CSV注意去重逻辑按日期代码作为唯一键。如果想更正式一点可以用SQLiteimport sqlite3 conn sqlite3.connect(stock_history.db) df[日期] datetime.now().strftime(%Y-%m-%d) df.to_sql(daily_snapshot, conn, if_existsappend, indexFalse) 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日K102周K103月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)。把它保留住你的采集程序就能长期稳定地跑下去。我见过太多人删掉限速之后把接口抓挂然后到处问为什么数据源封了自己。爬虫这东西跑得快不如跑得久。
返回列表