东方财富股票数据抓取实战:从接口定位到增量更新全链路
这篇是系列第二篇了。上一篇把整体框架搭起来以后陆续收到不少朋友留言问得最集中的是三件事一是只爬取东财网的部分股票数据时怎么把范围控制得干净利落二是目标网站的实时数据接口到底藏在哪儿打开开发者工具看到满屏请求根本不知道看哪个三是爬着爬着突然被拒怎么判断是被限流还是被风控。这一篇我干脆不藏着掖着把我在模拟项目X里实际用到的方案完整整理出来从请求到解析、从清洗到增量更新全链路讲清楚。整套方案只依赖 requests、pandas、lxml 这些常规库不依赖任何付费接口也不依赖第三方聚合数据包。适合刚接触数据抓取、又不想被各种框架黑盒套住的朋友参考。先给个定心丸东财网公开行情数据的抓取门槛其实不高难的是把“部分股票”这个范围定义好以及后续的数据校验和长期维护。这篇重点就在这两块。1. 抓取范围如何定义先想清楚“部分”是什么很多人一上来就写循环把几千只股票全部跑一遍跑完发现数据量大、速度慢还被封了好几次。实际上“部分股票信息”这个需求里“部分”才是灵魂。1.1 “部分”的三种常见口径我实践下来大家说的“部分股票”基本逃不出三种口径。第一种是自定义股票池。就是你自己维护一份代码清单比如自选股、某个概念板块的成分股、或者某个指数里你觉得有代表性的票。这种口径最灵活代码清单可以直接写在配置文件里后续想换池子只改清单就行。第二种是按板块或行业过滤。比如只想看新能源、半导体、医药这几个方向的票。这种口径适合做行业观察但要注意板块分类本身是经常调整的今天调进去明天调出来很正常如果要做长期回测最好把板块快照也存下来。第三种是按榜单或排名截取。比如每天只取成交额前 50、跌幅前 20或者北向资金持仓排名前 100。这种口径需要先拉一次全量列表做排序再回源取明细逻辑上多一步但结果更贴近真实关注点。1.2 为什么不是全量抓取全量抓取听起来很爽A 股市场几千只全拉下来一分钟内就能跑完但问题也明显。首先是边际收益递减。绝大多数人的分析场景根本用不到全部股票拉一屏自选股已经能覆盖 90% 的观察需求全量数据存下来也基本是躺着吃灰。其次是风控压力。频率一旦上去不管你的请求伪装得多好目标服务器的访问模式都会变得异常结果就是触发限制反而把最需要的数据源搞挂了。最后是数据质量。全量抓取时只要有一两只票的字段格式异常整个流程就可能中断。把范围收窄反而能把这部分票单独拎出来排查。所以我现在的做法很固定自定义股票池为主榜单截取为辅两种口径共存互不干扰。这个设计在后续代码部分会体现出来。2. 抓包定位从满屏请求里找到真正的数据接口很多新手把问题想复杂了以为打开网页看到的数据一定是某个后端渲染出来的 HTML。其实东财网的个股行情页早就不是纯服务端渲染了页面上的数字基本都是通过异步接口拉取的 JSON这意味着我们完全可以跳过 HTML 解析直接对接 JSON 接口。2.1 定位接口的完整步骤我以某只代码为 600XXX 的股票为例说下排查过程。打开浏览器开发者工具切到 Network网络面板刷新页面。不要一上来就乱翻先把请求类型筛选到 XHR 或者 Fetch因为我们要找的是异步数据接口而不是图片、CSS、JS 文件。然后在筛选结果里逐个点开看 Response 或 Preview找返回内容是 JSON 结构的请求。整个页面里可能有几十个 XHR 请求但真正关键的就那么几个。判断标准很简单Response 里能看到“最新价”“涨跌幅”“成交量”这些字段基本就能确认是行情主接口。确定主接口后观察它的请求参数。我见过最典型的参数包括secid证券 ID格式一般是市场编号.股票代码、ut固定 token 之类的公共参数、fields控制返回哪些字段的列表。这些参数里secid是必改的fields控制我们能拿到多少字段建议把接口返回的全部字段都保留不需要的字段在本地再丢弃而不是在请求参数里做减法。2.2 抓包结果怎么验证接口找到以后别急着写代码先做一次手工验证。随便挑两三只票手动改secid然后在浏览器里直接访问这个接口地址看返回的 JSON 是不是跟着变。这个步骤能帮你确认接口是否支持动态传参也能顺便看一眼响应里有没有跟时间戳相关的校验字段比如date、time这类字段可以用来判断接口返回的到底是实时数据还是缓存数据。我在实际操作中习惯把抓到的 JSON 完整保存一份下来后面写解析代码的时候直接对着这份样例开发不需要反复刷新页面找数据。这个习惯帮我省了很多事。提示数据是公开页面展示的但对接口的请求频率要有克制。这个在后面合规章节会专门讨论。3. 环境准备与代码工程结构把配置和逻辑拆开环境准备这部分其实没什么可炫技的Python 3.10 以上、requests、pandas、lxml这些装上就行。我真正想强调的是工程结构——把配置和逻辑拆开不然改动股票池或者字段映射的时候你得翻到代码深处改一堆硬编码。3.1 目录结构参考我的项目目录长这样stock_collector/ ├── config.py # 配置文件股票池、字段映射、请求间隔 ├── fetcher.py # 请求逻辑会话、重试、错误处理 ├── parser.py # 解析逻辑JSON 清洗、字段标准化 ├── storage.py # 存储逻辑CSV / SQLite 写入 ├── main.py # 主流程串联所有环节 └── logs/ # 运行日志目录为什么要分成这么多文件因为三个核心逻辑的变更频率不一样。请求逻辑基本很少改除非目标接口换了解析逻辑偶尔要改因为字段可能有调整存储逻辑改得最少一次写好就稳定运行。把它们拆开以后每次维护只需要改对应文件不用整篇代码通读。3.2 配置模块的关键设计配置模块是这套方案的灵魂。我用一个字典把“股票池”和“请求参数”集中管理这样主流程代码里一眼望去全是函数调用看不到任何写死的代码。# config.py STOCK_POOL [600001, 600002, 600003] # 实际使用时替换为你的自选池 REQUEST_INTERVAL 0.8 # 每次请求之间的基础间隔秒 RANDOM_INTERVAL 0.5 # 随机间隔上限用来打散请求节奏 FIELDS_MAPPING { f12: code, f14: name, f2: latest_price, f3: pct_change, f5: volume, f6: amount, }字段映射这里要多说一句。接口返回的 JSON 里字段名通常是一堆 f 开头的编号比如f2、f3这明显是代码压缩后的结果。直接用编号当列名存进数据库后续写分析代码时你自己都看不懂所以我统一做了重命名让数据结构自带语义。这也是我在多次复盘里觉得做得最对的一件事。3.3 为什么不用模拟浏览器有人可能会问用模拟浏览器打开页面直接读 DOM 不是更简单吗我的回答是简单但不可持续。模拟浏览器最大的问题是开销大且特征明显。每开一个浏览器实例就要吃掉几百兆内存同时网页脚本会暴露很多浏览器特征目标站点很容易通过行为检测识别出来。另一个问题是健壮性差页面布局微调你的定位器就失效了。相比之下直接请求 JSON 接口天然轻量、结构稳定只要接口不变代码就一直能用。这也是我坚持接口级抓取的根本原因。4. 核心流程实现请求、解析、清洗、落库一次跑通4.1 请求模块会话保持与身份伪装请求模块要解决的第一个问题就是身份问题。我用的方案是 requests.Session 随机 User-Agent Referer 头三件套齐活。import requests import time import random from config import REQUEST_INTERVAL, RANDOM_INTERVAL class StockFetcher: def __init__(self): self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://quote.eastmoney.com/, Accept: application/json, text/plain, */*, }) def fetch(self, secid: str) - dict: url https://push2.eastmoney.com/api/qt/stock/get params { secid: secid, ut: fa5fd1943c7b386f172d6893dbfba10b, fields: f12,f14,f2,f3,f5,f6, } for attempt in range(3): try: resp self.session.get(url, paramsparams, timeout8) resp.raise_for_status() data resp.json() if not data.get(data): raise ValueError(empty data) return data[data] except Exception as e: wait_time 2 ** attempt random.random() print(f[{secid}] attempt {attempt 1} failed: {e}, wait {wait_time:.1f}s) time.sleep(wait_time) return {}这段代码里有两个细节值得说。一个是重试策略。指数退避第一次失败等 2 秒左右第二次等 4 秒左右第三次等 8 秒左右。每次叠加随机值避免多个线程同时重试时形成整齐划一的请求节奏。这个策略在应对偶发超时和限流时表现都很稳。另一个是超时时间。8 秒是权衡值。设太短网络抖动就误伤设太长单次阻塞会把整体任务拖死。对于行情接口这种响应通常在几百毫秒内的场景8 秒已经留足余量。4.2 请求间隔控制请求间隔是爬虫能不能“活得久”的关键参数。我给自己的标准是基础间隔 0.8 秒再加上 0 到 0.5 秒的随机扰动。也就是说每两次请求之间实际的间隔在 0.8 到 1.3 秒之间随机分布。def polite_sleep(self): base random.uniform(REQUEST_INTERVAL, REQUEST_INTERVAL RANDOM_INTERVAL) time.sleep(base)这个随机扰动不是为了显得高级而是因为固定间隔本身就是一种明显的行为特征。真实用户的操作节奏是有波动的我们的请求节奏也要模拟这种波动。4.3 解析模块字段标准化与缺失值处理接口返回的 JSON 结构其实很规整麻烦的是字段缺失和脏数据。有的票停牌了最新价可能是-有的票刚上市某些基本面字段还没有。这些情况不处理写入数据库以后做分析就是一堆坑。import pandas as pd from config import FIELDS_MAPPING def parse_quote(raw: dict) - dict: row {} for src_key, dst_key in FIELDS_MAPPING.items(): value raw.get(src_key, None) if value in (None, -, , nan): value None row[dst_key] value return row清洗规则很简单空值和占位符统一转成 None。等你后面做统计分析时直接dropna()或者fillna()都方便不会因为字符串-在那捣乱。4.4 存储模块CSV 与 SQLite 双写存储我建议做双轨制。CSV 适合快速浏览、Excel 打开、手工核对SQLite 适合后续跑查询、做回测、增量更新。两个不冲突可以同时写。import sqlite3 import pandas as pd def append_to_csv(row: dict, path: str): df pd.DataFrame([row]) df.to_csv(path, modea, headernot is_file_exists(path), indexFalse, encodingutf-8-sig) def upsert_to_sqlite(rows: list[dict], db_path: str): conn sqlite3.connect(db_path) df pd.DataFrame(rows) df.to_sql(stock_quote, conn, if_existsappend, indexFalse) conn.close()CSV 用utf-8-sig编码是我踩过坑之后换的。用普通utf-8写出来的文件Excel 直接用的话中文列名会乱码加个 BOM 头就彻底解决。SQLite 这边每次写入时间戳字段保证同一只票在不同时间点的数据都能保留下来方便做趋势回看。4.5 主流程与增量更新主流程就是把前面的模块串起来每个 H2 部分讲完这里直接上最终版。import time from config import STOCK_POOL from fetcher import StockFetcher from parser import parse_quote from storage import append_to_csv, upsert_to_sqlite def main(): fetcher StockFetcher() rows [] for i, code in enumerate(STOCK_POOL): secid f1.{code} # 沪市前缀 1深市前缀 0按实际股票池调整 raw fetcher.fetch(secid) if raw: row parse_quote(raw) row[timestamp] time.strftime(%Y-%m-%d %H:%M:%S) rows.append(row) fetcher.polite_sleep() if (i 1) % 50 0: print(fprocessed {i 1}/{len(STOCK_POOL)}) if rows: append_to_csv(rows, stock_quote.csv) upsert_to_sqlite(rows, stock_quote.db) if __name__ __main__: main()为了保障增量更新我在落库时始终保留timestamp字段。CSV 是 append 模式SQLite 是 insert 模式天然就支持增量。后续如果需要按天聚合直接按timestamp分组就行。5. 高频问题排查实录被拦截、缺字段、数据对不上怎么办这部分内容是我自己踩坑踩出来的我把最常见的几个问题整理成了速查表方便大家对照排查。现象可能原因处理办法请求返回 403请求头特征明显或频率过高换随机 User-Agent拉大请求间隔返回 JSON 里 data 为空股票代码前缀不对或已退市核对市场前缀确认代码仍在交易中文名称乱码CSV 编码问题写入时改用 utf-8-sig字段全部为 null字段编号已更新重新抓包比对 fields 参数偶尔几只能取到整体大量失败触发频控立即停止观察 10 分钟再试5.1 请求被拒从 403 到空响应最让我头疼的不是 403而是那种“请求成功但 data 为空”的情况。403 至少还有错误码可以定位空响应纯粹是接口主动拒绝但不告诉你原因。排查思路是这样先确认单次请求能不能通。如果手动访问接口地址正常但代码里频繁调用就空基本可以断定是频率问题。如果手动访问也空那大概率是接口参数变了比如secid前缀规则调整或fields字段编号升级。这时候只能重新抓包对比没有捷径。注意一旦发现接口返回异常先把程序停掉回退到手动验证模式确认接口恢复之后再继续自动化。没有确认就盲目加大频率重试只会延长被限制的时间。5.2 数据对不上与行情软件对比我特别强调数据校验因为接口字段的真实含义需要自己验证过才敢用。我第一次写解析代码时想当然认为f5是成交量、f6是成交额。结果跑出来的数字跟行情软件里看到的量级对不上一排查才发现我把单位和字段含义搞混了。从那以后我养成一个习惯抓包后先挑一只票把接口返回的字段和行情软件上的指标手动对一遍确认每个字段对应的含义再批量使用。具体对法很简单记录下某只股票在分析软件里的最新价、涨跌幅、成交量、成交额然后对比接口返回的 JSON 里各个编号字段的值。数值对得上才把字段映射写进配置。这步做好了后面的数据基本是可信的。5.3 数据漂移与字段升级东财网这类站点改版是家常便饭。今天f12还是股票代码明天可能就换成了别的含义有时候接口地址不变但参数要求变了。我能给出的最实用建议是不要把字段映射写死在业务代码里而是像我在配置模块里做的那样所有字段编号都集中在一张配置表里真要改就是改配置而不是改代码。另外建议把抓包样例保存到项目目录下接口升级时用 diff 工具对比新旧样例哪个字段编号变了、哪个字段含义变了一眼就能看出来比重新抓包分析效率高得多。6. 合规边界与长期维护如何让爬虫“活得久”聊完了技术细节我得把合规这件事放到最后压轴说因为这是我一直在意的底线。6.1 公开数据不等于可以无限制抓取东财网页面展示的行情数据是公开的但“公开”不等于“无限制”。你在浏览器里盯着自选股刷新一百次没问题但程序在后台高频请求几百次接口就是另一回事了。所以我给自己定了几条规矩。第一只爬取完成分析目标所必需的数据不需要的字段一律不请求。第二严格控制请求频率宁可让程序多跑几分钟也不碰频控红线。第三抓下来的数据只用于个人学习和研究不做任何商业用途也不对外传播原始数据。这几条算是我眼中的“行业基本素养”。真正需要长期维护的数据抓取任务靠的从来不是暴力请求而是足够克制。6.2 接口改版后的应对思路接口改版是必然的只是时间问题。要想让程序活得久就不能把整个流程写成一坨直接面向接口地址的代码。现在我维护这套采集程序的时候已经在接口上面加了一层“数据源适配层”接口地址、请求参数、字段映射全部配置化。哪天数据源改版了我只需要改配置不用改解析和存储逻辑。另外可以考虑给采集到的数据做一层本地缓存。这看起来多此一举实际很管用如果数据重复请求频率低就容易躲过风控历史上已经取过的数据直接从本地读不重新请求远程接口。这一招对增量回测特别实用。6.3 最后分享一个个人体会这个系列写了两篇我自己最大的体会是一个可持续运行的数据抓取项目核心不是请求写得多快也不是绕过多严的反爬机制而是数据质量和长期可维护性。你会写一个请求不代表你能维护一个会长期运行的数据管线。真正让我省心的地方反而是那些看起来不那么性感的细节统一的字段映射、合理的请求间隔、完备的时间戳记录、及时的手工校验。这篇文章里的全部代码片段都是从我自己在模拟项目 X 里实际跑通的采集流程里抽出来的。你可以直接从这个结构出发把股票池换成你自己关心的那几只把字段映射改成你分析需要的指标很快就能跑出第一份属于自己的实时行情快照。后面如果再遇到有意思的坑我会继续在这个系列里更新。