做个股财务分析的时候,我一直有个挺头疼的需求:每家公司发布季报年报,我第一眼想看的数字就是“纯利润”,也就是净利润。东方财富网站上这份数据很全,但靠手工去翻页面、复制表格,再粘贴到Excel里,单看一家公司几期数据还行,一旦要同时对比五家公司近五年的净利变化,效率低到让人崩溃。后来我干脆写了套爬取纯利润的代码,直接从东方财富网站的数据接口把净利润批量拉下来,自动生成结构化表格。这篇内容就围绕这个项目来写,适合对Python爬虫有兴趣的入门者,也适合平时需要批量处理财务数据的分析型同学,代码量不大,思路清晰,可以直接拿来改着用。
整个项目的难点其实不在代码本身,而是怎么定位到正确的数据接口、理解净利润的统计口径,以及把返回的JSON字段对应到我们想要的表格列上。这篇文章我会把从思路、参数到完整代码逐步拆开讲,最后再整理几个实际爬取中会遇到的坑,希望能帮你少走一点弯路。
1. 项目需求与整体设计思路拆解
1.1 核心需求解析
先把这个需求拆清楚。我们要做的不是抓整个东方财富页面,也不是把所有财务指标都爬下来,目标很聚焦:给定一只股票代码,拿到这家公司最近N期报告里的净利润数据。
“净利润”这个概念听起来简单,实际操作里却有几个不同口径。严格来说,利润表里的净利润是“利润总额减去所得税费用”后的数字,它反映企业在一段时期内最终赚到手的钱。而财报分析里更专业一点的表述还会区分“归属于母公司所有者的净利润”和“少数股东损益”,前者才是上市公司股东真正意义上能分享的利润。所以爬取之前,先得想清楚自己到底要哪个口径。大部分公开业绩报表默认展示的净利润,是包含少数股东损益的整体净利润,或者直接用归母净利润。这两者在多数情况下数值接近,但遇到控股子公司比较多、少数股东持股比例高的公司,差异会非常明显。做严格分析的时候,我强烈建议把两个字段都拉下来,后续需要哪种口径就取哪种。
另一个要提前确认的点是报告期。A股上市公司按照规定会披露一季度报告、半年度报告、三季度报告和年度报告,对应的数据截止日期分别是3月31日、6月30日、9月30日、12月31日。东财的财务数据接口里,每一次披露都会形成一条记录,我们按报告期倒序排列,就能拿到最近几个季度的净利润序列。
1.2 抓网页还是调接口:方案选型
明确了需求,接下来面临一个选型问题:直接抓东方财富的网页HTML,还是调用它后端的数据接口?
刚接触爬虫的同学,第一反应往往是用requests把页面源码抓下来,再用正则或者BeautifulSoup去提取表格。这个思路不是不行,但东财的财务数据页面本身有大量台前端交互逻辑,很多表格数据不是一次性渲染在HTTP响应里的,而是通过JavaScript异步加载再填充到页面。这就意味着直接抓HTML的时候,你很可能拿不到完整的数字,得额外去分析XHR请求、模拟浏览器的加载顺序,甚至不得不动用无头浏览器方案。页面结构一旦改版,整个解析逻辑就要跟着返工,维护成本很高。
所以我选择了直接找东财后端的数据接口,也就是所谓的API接口。这个接口返回的不是网页标签,而是结构清晰的JSON数据,字段名都是固定的英文标识,比如净利润对应NETPROFIT。只要参数构造正确,一次请求就能拿到一条或多条财报记录,不需要解析HTML,代码也稳定。选型背后的逻辑其实就一句话:能用结构化数据接口解决的问题,不要去做正则匹配网页这种不稳定的事。
1.3 整体流程与架构设计
整个爬虫的工作流程可以分成五步:
第一步,确定目标股票,把6位数字的股票代码和它所属的市场后缀拼接起来,形成SecuCode,比如贵州茅台是600519.SH,宁德时代是300750.SZ。第二步,构造HTTP请求参数,指定报表名称、排序方式、过滤条件。第三步,发送GET请求到东财的数据中心接口,拿到JSON返回体。第四步,从JSON里提取result.data列表,把每一条记录映射成我们需要的字典结构。第五步,把结果装进pandas的DataFrame,输出成CSV或者Excel文件。
这个架构看起来很简单,但每个环节都有对应的细节要点。第二步里过滤条件怎么写、第四步里字段名怎么对应,都是我实际调试中踩过的坑。下面把每个环节的关键细节逐个展开。
2. 核心细节解析与实操要点
2.1 定位数据接口与股票编码规则
东财的数据中心接口,常见的基础地址是:
https://datacenter-web.eastmoney.com/api/data/v1/get这个地址接受一系列query参数,其中几个核心参数决定了你拿到的数据内容。第一个是reportName,也就是报表名称,本次项目里它决定了返回哪类财务数据。比如业绩报表类的RPT_LICO_FN_CPD,返回的是最近报告期的主要财务指标;F10主要财务数据类的RPT_F10_FINANCE_MAINFINADATA,返回的则是专业财务数据报表里那套字段。不同报表名称对应的字段会略有差异,这个没有统一的文档可查,大多数时候靠实际请求返回结果来确认字段名。
第二个核心参数是filter,即过滤条件,它用类似SQL条件的字符串来确定股票范围。举个例子,要过滤出贵州茅台的记录,过滤条件要写成:
(SECURITY_CODE="600519")注意这里的SECURITY_CODE是6位纯数字代码,不需要带市场后缀。如果你想要更精细的条件,还可以用AND连接多个条件,比如同时在过滤条件里指定报告期:
(SECURITY_CODE="600519")(REPORT_DATE='2023-12-31')不过我更推荐的做法是纯粹按股票代码过滤,把该股票所有历史报告都拉回来,再在代码里用报告期字段筛,这样灵活性更高,不用反复改请求条件。
还有一个不少新手会混淆的点:股票编码规则。请求参数里filter使用的是SECURITY_CODE,但如果你去切换东财的其他接口,比如行情报价接口,可能还要用到secid参数。secid的规则是沪市股票以1开头加股票代码,深市股票以0开头加股票代码。贵州茅台就是1.600519,平安银行就是0.000001。本项目的接口因为用了SECURITY_CODE,就不需要secid,但如果以后扩展行情数据爬取,务必要把这条规则记住。
2.2 净利润字段的统计口径与分析口径选择
整个项目里,我最想强调的是字段名对应的统计口径。RPT_LICO_FN_CPD报表返回的字段里,有两个比较重要的字段需要分清。
一个是NETPROFIT,这个字段在大部分场景下对应上市公司的净利润,也就是利润表上那个核心数字。另一个是PARENT_NETPROFIT,对应归属于母公司所有者的净利润。平时我们看新闻里说某公司“净利多少亿”,多数默认指的是归母净利润。所以在设计输出列的时候,我建议把这两个字段都保留,别只取一个,这样后续分析时遇到口径问题还能切换。
顺带讲一下净利润相关的一个常见关联指标:净利润同比增速。业绩报表里往往直接带一个NETPROFIT_YOY字段,表示净利润与上年同期相比的增长百分比。假如你拉到的数据里这个字段是空缺的,也可以用当前季度净利润除以上年同季度净利润再减一,手动算出来。这个计算有个容易出错的地方,就是跨期数据对齐,一定要用相同报告期去比,比如2023年三季报对应2022年三季报。
关于负数问题也提一句。某些公司某个报告期出现亏损,净利润就是负数,这很正常。东财接口返回的数字如果是负数,直接读出来即可。如果你在做同比计算时发现上年同期是负数,算出来的增长率意义会很小,建议这种情况直接标注“亏损”,而不是硬算一个百分比去误导人。
2.3 请求参数详解与分页机制
再来看页面请求参数的完整构成。我用过一次最精简的请求参数组合是这样的:
params = { "sortColumns": "REPORT_DATE", "sortTypes": "-1", "pageSize": "50", "pageNumber": "1", "reportName": "RPT_LICO_FN_CPD", "columns": "ALL", "filter": '(SECURITY_CODE="600519")' }逐个解释:sortColumns指定排序字段,这里按报告期排序;sortTypes为-1表示降序,即最新的报告排在最前;pageSize是每页返回的记录数,东财单页上限通常为50条;pageNumber是页码;columns=ALL表示返回全部字段,虽然数据体积大一点,但最稳妥,不需要记具体字段清单;filter就是刚才说的过滤条件。
这里有一个容易被忽略的点:columns=ALL返回的字段非常多,可能包含几十个财务指标。如果你在意流量和解析速度,可以改成只返回你需要的字段,例如:
columns=SECURITY_CODE,SECURITY_NAME_ABBR,REPORT_DATE,NETPROFIT,PARENT_NETPROFIT,NETPROFIT_YOY这样返回体更小,解析也会更快。但是要小心,不同报表名称支持字段不同,一旦写错字段名,接口可能返回空数据或直接报错。我的经验是,第一次调试先用ALL,跑通以后确认字段名没问题,再考虑精简化。
请求参数里还有一个值得说道的环节:HTTP头。东财数据中心对这个接口的请求头限制不算严格,但为了减少被拒的概率,最好还是带一个常规的浏览器User-Agent,再加上Referer指向东财数据页面。代码里加这两个头部字段,是成本最低的“伪装”,实测下来稳定很多。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
实操之前先把环境搞定。这个项目依赖不多,核心就是requests和pandas两个库,另外用到了Python自带的时间处理模块。如果你的电脑里已经装了Anaconda,那pandas通常自带,只需要补一个requests。如果用的是官方Python环境,在终端里执行:
pip install requests pandas建议顺手把pip镜像源换成国内源,安装速度会快很多。装好以后可以用python进入交互模式,导入一下两个库,不报错就说明环境没问题。
我项目里用的Python版本是3.10,理论上3.8以上都兼容,没有用到特别新的语法,所以版本影响不大。Windows和macOS都可以跑,唯一区别是最后保存CSV时编码处理方式略有不同,后面会讲到。
3.2 核心代码逐段解读
直接上核心代码,我加了不少注释,方便直接改着用。
import requests import pandas as pd from time import sleep def fetch_profit_data(stock_code, market, max_pages=3): """ 从东方财富数据中心爬取指定股票历期净利润 stock_code: 6位数字股票代码,如 "600519" market: 市场后缀,SH或SZ,如 "SH" max_pages: 最多拉取多少页,默认3页 """ secucode = f"{stock_code}.{market}" rows = [] for page in range(1, max_pages + 1): url = "https://datacenter-web.eastmoney.com/api/data/v1/get" params = { "sortColumns": "REPORT_DATE", "sortTypes": "-1", "pageSize": "50", "pageNumber": str(page), "reportName": "RPT_LICO_FN_CPD", "columns": "ALL", "filter": f'(SECURITY_CODE="{stock_code}")', } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://data.eastmoney.com/", } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() payload = resp.json() # 没有result说明当前页码没有数据了 if not payload.get("result"): break data_list = payload["result"].get("data") or [] if not data_list: break for item in data_list: rows.append({ "股票代码": item.get("SECURITY_CODE"), "股票简称": item.get("SECURITY_NAME_ABBR"), "报告期": item.get("REPORT_DATE"), "净利润": item.get("NETPROFIT"), "归母净利润": item.get("PARENT_NETPROFIT"), "净利润同比": item.get("NETPROFIT_YOY"), }) # 单页最多50条,没到50说明后面也没数据 if len(data_list) < 50: break sleep(1) df = pd.DataFrame(rows) # 结果转成表格 return df if __name__ == "__main__": # 示例:抓取贵州茅台的净利润 df = fetch_profit_data("600519", "SH") print(df.head(10)) df.to_csv("600519_净利润.csv", index=False, encoding="utf-8-sig")代码逻辑不复杂,但有几个关键点值得单独说。
第一个是分页与终止条件。接口单页最多返回50条记录,一只上市时间较长的公司可能有几十期季度报告,所以理论上要翻很多页。我设了一个max_pages参数,默认拉3页也就是最多150条,对绝大多数场景已经够用。更聪明的做法是判断当次返回的data列表长度是否小于pageSize,如果小于50,说明已经到最后一页,直接break退出循环。这两个条件同时存在,既保证了数据量,又不会一直空转。
第二个是字段获取用item.get()而不是item[]。原因很简单,财务数据里经常会出现某个字段缺失的情况,比如次新股没有去年同期数据,NETPROFIT_YOY字段可能就不存在。用get方法可以让缺失值返回None,程序不会因为KeyError中断,后续处理也更加安全。
第三个是编码设置。代码最后用encoding="utf-8-sig"保存CSV,这是专门给Windows用户准备的。如果不加sig,Excel直接双击打开CSV时中文列名会乱码。utf-8-sig会在文件开头写入一个BOM标记,Excel就能准确识别成UTF-8编码。如果你是macOS用户,可以改成utf-8,你会发现文件默认用Numbers打开也没问题。
3.3 运行结果验证与数据处理
运行完代码之后,屏幕上会打印出前10行数据,大致效果类似这样。
| 股票代码 | 股票简称 | 报告期 | 净利润 | 归母净利润 | 净利润同比 |
|---|---|---|---|---|---|
| 600519 | 贵州茅台 | 2024-03-31 | ... | ... | ... |
| 600519 | 贵州茅台 | 2023-12-31 | ... | ... | ... |
| 600519 | 贵州茅台 | 2023-09-30 | ... | ... | ... |
我先会做一步校验:把打印出来的报告期字段和东财网页上显示的报告期做个对比,确认最新一条是不是刚披露的那期。如果发现最新数据缺失,很可能说明当前季度的财报还没正式公布,或者你拉到的报表类型不包含最新期,这是正常情况,不代表代码有问题。
接下来就是把净利润数据变成可以做分析的形式。我会在DataFrame里加两列计算:
df = df.sort_values("报告期").reset_index(drop=True) df["净利润同比手工计算"] = df["净利润"] / df["净利润"].shift(4) - 1这里shift(4)表示和四个季度前的数据比较,也就是和去年同期比,因为A股每年四个报告期。这个计算方式本质上是把同比口径做了一次交叉验证,如果接口返回的NETPROFIT_YOY和我手动算的结果差距大于几个百分点,那就要回头检查报告期是否对齐,或者数据里是否混入了业绩快报和业绩预告这类不同统计口径的记录。
4. 常见问题与排查技巧实录
4.1 请求失败或返回异常的排查思路
实际跑的时候,最常遇到的就是第一次请求就报错。我整理了几类典型情况和排查顺序。
如果requests直接抛超时异常,先确认内置网络能正常访问东财网站,然后在代码里加一个简单的重试逻辑。重试不是简单地把同样的请求再发一遍,中间需要加一个小延时,避免连续失败时把自己网络搞崩。可以在requests.get外面套一个for循环,最多尝试3次,每次失败后sleep两秒。
如果接口返回了HTTP状态码但resp.json()解析失败,这时候大概率不是网络问题,而是返回内容里包含了一段JSONP回调包装,也就是返回体不是纯JSON,而是一段JS函数调用。遇到这种情况,最简单的方案是在请求参数里加上一个回调参数,或者直接去掉可能触发JSONP的参数。东财数据中心接口正常用JSON格式返回,出现JSONP的可能性不算高,但只要出现过一次,你就要学会看原始响应文本。调试时我用得最多的方法,就是把resp.text打印出来看前500个字符,一眼就能判断出返回的是JSON还是别的什么东西。
如果返回了JSON,但payload.get("result")是None,大概率是filter条件写得有问题。最常见的原因是股票代码和市场没有匹配上,比如把创业板的300750填成了SH后缀,系统就会返回空。另一个原因可能是该股票确实不存在或者已退市,数据源里没有对应记录。我建议先在浏览器里访问东财数据中心页面,手动筛选一次目标股票,确认数据存在,再回头检查代码参数。
4.2 净利润字段为空的处理技巧
拉回来的数据里,某几行净利润显示None或者NaN,这是第二个高频问题。
主要原因有个。第一,该报告期公司确实没有披露净利润,比较典型的是新上市公司的早期报告或某些特殊处理状态的公司。第二,该记录的类型不是定期报告,而是业绩预告或业绩快报,这类字段可能只包含预计净利润范围,没有精确的NETPROFIT字段。第三,报表名称对应的字段确实不同,比如你换了RPT_F10_FINANCE_MAINFINADATA,就要用PARENT_NETPROFIT而不是NETPROFIT,写错字段名自然取不到数。
解决这个问题,我的建议是不要一看到空值就急着填0。填0会污染后续的同比计算,一旦分母是0,一切百分比都会变成无穷大。更稳妥的办法是先保留None,后续分析时用dropna去掉确实没数据的报告期,同时把业绩预告类的记录单独标记出来,不跟定期报告混在一起算趋势。
如果发现整张表的所有净利润都是空,那基本可以断定是字段名或报表名称不匹配。这时候可以把columns参数改成ALL,打印出第一条记录的keys,实际看一下数据源里到底有哪些字段。这个定位方式我在不同报表之间切换时反复使用,非常有效。
4.3 请求频率控制与合规使用
最后聊一个容易被忽略但很重要的环节:请求频率和合规性。
东财数据中心是面向公众提供数据查询的接口,但并不意味着可以毫无节制地高频调用。爬取代码里加sleep(1),让每次请求间隔一秒以上,是一个很基础的做法。如果你要批量爬取几十只股票,建议把间隔提高到2到3秒,同时把程序设计成可以随时中断续跑的结构,避免一次性长时间占用服务资源。
从数据合规的角度,这类公开接口的数据在合理范围内供个人学习研究使用通常是没问题的,但如果要把爬取结果用于商业产品、对外展示或者大规模分发,就必须仔细审视平台的服务协议和数据的授权范围。我们在写爬虫的时候,最好一开始就想清楚用途边界。个人分析场景下,控制频率、尊重平台规则,既是对自己稳定拿数据的保障,也是对其他用户正常使用体验的负责。
爬取行为本身并不是什么高深技术活,难度更多在于对数据结构的理解和细节的耐心。这套纯利润爬取代码我后来做了不少扩展,有的版本加上了多股票循环,有的版本对接了Excel模板生成对比报表。每扩展一次,都会发现原始版本里的一些设计考虑还是很有用的,比如保留归母净利润字段、用max_pages控制总量、延时分页读取。如果你也是刚开始接触财务数据爬虫,希望这篇分享能让你少踩一些坑,拿到数据之后,把更多时间花在真正有价值的基本面分析上,而不是浪费在复制粘贴和格式清洗里。