
做个股财务分析的时候我一直有个挺头疼的需求每家公司发布季报年报我第一眼想看的数字就是“纯利润”也就是净利润。东方财富网站上这份数据很全但靠手工去翻页面、复制表格再粘贴到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_CODE600519)注意这里的SECURITY_CODE是6位纯数字代码不需要带市场后缀。如果你想要更精细的条件还可以用AND连接多个条件比如同时在过滤条件里指定报告期(SECURITY_CODE600519)(REPORT_DATE2023-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_CODE600519) }逐个解释sortColumns指定排序字段这里按报告期排序sortTypes为-1表示降序即最新的报告排在最前pageSize是每页返回的记录数东财单页上限通常为50条pageNumber是页码columnsALL表示返回全部字段虽然数据体积大一点但最稳妥不需要记具体字段清单filter就是刚才说的过滤条件。这里有一个容易被忽略的点columnsALL返回的字段非常多可能包含几十个财务指标。如果你在意流量和解析速度可以改成只返回你需要的字段例如columnsSECURITY_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_pages3): 从东方财富数据中心爬取指定股票历期净利润 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, paramsparams, headersheaders, timeout10) 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, indexFalse, encodingutf-8-sig)代码逻辑不复杂但有几个关键点值得单独说。第一个是分页与终止条件。接口单页最多返回50条记录一只上市时间较长的公司可能有几十期季度报告所以理论上要翻很多页。我设了一个max_pages参数默认拉3页也就是最多150条对绝大多数场景已经够用。更聪明的做法是判断当次返回的data列表长度是否小于pageSize如果小于50说明已经到最后一页直接break退出循环。这两个条件同时存在既保证了数据量又不会一直空转。第二个是字段获取用item.get()而不是item[]。原因很简单财务数据里经常会出现某个字段缺失的情况比如次新股没有去年同期数据NETPROFIT_YOY字段可能就不存在。用get方法可以让缺失值返回None程序不会因为KeyError中断后续处理也更加安全。第三个是编码设置。代码最后用encodingutf-8-sig保存CSV这是专门给Windows用户准备的。如果不加sigExcel直接双击打开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(dropTrue) 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控制总量、延时分页读取。如果你也是刚开始接触财务数据爬虫希望这篇分享能让你少踩一些坑拿到数据之后把更多时间花在真正有价值的基本面分析上而不是浪费在复制粘贴和格式清洗里。