这篇是系列第二篇了。上一篇把整体框架搭起来以后,陆续收到不少朋友留言,问得最集中的是三件事:一是只爬取东财网的部分股票数据时,怎么把范围控制得干净利落;二是目标网站的实时数据接口到底藏在哪儿,打开开发者工具看到满屏请求根本不知道看哪个;三是爬着爬着突然被拒,怎么判断是被限流还是被风控。这一篇我干脆不藏着掖着,把我在模拟项目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, params=params, timeout=8) 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, mode="a", header=not is_file_exists(path), index=False, encoding="utf-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_exists="append", index=False) 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 = f"1.{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(f"processed {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 里实际跑通的采集流程里抽出来的。你可以直接从这个结构出发,把股票池换成你自己关心的那几只,把字段映射改成你分析需要的指标,很快就能跑出第一份属于自己的实时行情快照。后面如果再遇到有意思的坑,我会继续在这个系列里更新。