简介:基于Selenium与Requests的东方财富网财报爬取项目,为爬虫开发者和金融数据研究者提供一站式上市公司财务数据采集方案,可解决从东财批量抓取财报并输出CSV的问题。项目内含两个Python脚本,分别演示Selenium与Requests两种技术路线,支持按年度、财季、报表类型采集,并导出资产负债表、利润表、现金流量表等数据。资源共19个文件,压缩后约37.96MB,含脚本、10个CSV数据表、说明文档与备份文件,便于对照学习。其中Requests方案效率更高,适合生产环境。目前已有47人学习/下载,适合需要快速实现财报抓取的开发者。
1. 拆解东方财富财报爬虫:Selenium与Requests两条路线怎么选
网络爬虫方向的项目资料不少,但真正把同一目标用两种技术路线实现、还带着完整CSV产出物的不多。这份资源包里的核心是两个脚本:eastmoney_crawler.py走 Selenium 自动化测试框架,控制浏览器像人一样翻页;eastmoney_crawler2.py走 Requests 库直连接口,把 HTTP 请求发给服务器后直接拿 JSON 数据。两者都能采集东方财富平台上的上市公司财务报表,覆盖资产负债表、利润表、现金流量表、业绩报表等八类数据,时间范围指向 2018 至 2023 年。适合想拿现成代码改参数就能跑的从业者,也适合想对比两种爬虫思路差异的学习者。第一次跑完两个方案,你大概率会留下一个印象:同一个 JSON 接口,能比浏览器自动化快出两个数量级。
2. Selenium方案:财务报表页面动态加载的模拟浏览器采集
2.1 东方财富财务页面的结构:动态数据与分页
东方财富的财务报表页面不是那种服务端直接拼好 HTML 的传统网站。打开资产负债表页面,表格里的数字、表头、单位、报告期,几乎全部由前端 JavaScript 脚本在页面加载后向后端发起请求,再把返回的数据渲染到 DOM 节点上。换句话说,用 Requests 直接去get那个 HTML 地址,拿到的只是一个空壳框架,里面没有一行财务数据。
这种页面形态正是 Selenium 的典型适用场景。Selenium 通过 WebDriver 驱动真实的浏览器内核,让 Chrome 或 Firefox 去加载页面,JavaScript 照常执行,等数据渲染完成后再读取表格内容。eastmoney_crawler.py的核心思路就是:定义一个等待条件,反复轮询目标表格节点是否出现了预期行数,然后定位<table>下的<tr>和<td>,逐行提取文本。
在这个过程中要注意分页。东方财富的报表列表默认每页展示 50 条记录,翻页控件是一个带>from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def init_driver(chromedriver_path: str, headless: bool = True): options = webdriver.ChromeOptions() if headless: options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") service = Service(chromedriver_path) driver = webdriver.Chrome(service=service, options=options) driver.set_page_load_timeout(30) return driver def fetch_table_rows(driver, table_selector: str, wait_seconds: int): wait = WebDriverWait(driver, wait_seconds) table = wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, table_selector)) ) rows = table.find_elements(By.TAG_NAME, "tr") data = [] for row in rows: cells = row.find_elements(By.TAG_NAME, "td") if cells: data.append([cell.text.strip() for cell in cells]) return data
这段代码里有几个关键点。headless=new是无头模式,适合 Windows 服务器上没装显示器的情况,但代价是部分反爬脚本对无头浏览器识别更敏感,遇到验证码时建议改回有头模式观察页面状况。WebDriverWait配合presence_of_element_located是等表格骨架出现,如果表格里某些单元格是延迟填充的,还需要把条件换成text_to_be_present_in_element,否则拿到的仍然是空文本。set_page_load_timeout(30)是页面整体加载超时控制,网络波动时可以放宽到 45 秒,但超过这个值还加载不完,更大概率是目标站点对自动化请求做了限制,调大超时意义不大。
2.3 速率瓶颈:每一次翻页都是完整页面渲染
用方案A跑过一遍完整流程后,你会有很直观的感受:慢。每打开一页,浏览器要重新执行几十个 JS 文件,渲染几百个 DOM 节点,再触发网络图片和广告资源的加载。东方财富财报页的筛选条件多,每个报告期切换、每页翻页都是一次整页刷新,一页 50 条数据跑十几秒是常态。
如果任务范围只是几个特定年度的某一张报表,比如只采集 2021 年第一季度的资产负债表前 3 页,方案A完全够用。它的优势是开发门槛低、调试直观,出问题时能看到浏览器里真实发生了什么事。但要按年度区间 2018 到 2023、四个财季、四种报表全量去采集,页面数量会迅速膨胀,单页十几秒乘上几百页,消耗的时间很难让人接受。这就是eastmoney_crawler2.py被选为主要实施方案的根本原因:用 Requests 绕过浏览器渲染,直接请求财务报表数据背后的 JSON 接口。
3. Requests方案:从抓包定位到直接请求JSON接口
3.1 抓包定位真实接口:请求头与参数格式
方案B的第一步不是写代码,而是打开浏览器开发者工具,切到 Network 面板,在东方财富财务报表页面里翻一页、切换一个报告期,观察页面发出的是什么请求。你会发现,真正返回财务数据的是一个以datacenter.eastmoney.com域名开头的接口,路径里带FilterXsjs或类似名称的参数,响应体是一段 JSON 文本,里面包含data、success、message等字段。
这个接口的请求参数远比页面 URL 复杂,常见的分组参数大致如下:
| 参数 | 取值示例 | 作用 |
|---|---|---|
filter | (reportdate=2023-12-31) | 过滤指定报告期 |
type | RPT_LICO_FN_CPD等 | 报表类型代码 |
pageNumber | 1 | 当前页码 |
pageSize | 50 | 每页条数 |
sortColumns | SECURITY_CODE | 排序列 |
sortTypes | 1 | 排序方向 |
source | HSF10 | 数据来源标识 |
sortColumns是一个容易被忽略的参数。东方财富接口默认排序字段在不同报表里有差异,如果不对排序方式做强约束,翻页时会出现数据顺序漂移,同一公司在页面1和页面2的边界位置可能被重复读取或遗漏。所以方案B在构造请求时,强制固定SECURITY_CODE升序,确保每次爬取结果都稳定。
3.2 eastmoney_crawler2.py 的请求构造与JSON解析
eastmoney_crawler2.py是典型的 Requests 加循环分页结构。核心逻辑可以拆成下面这段代码,它展示的是请求头、参数拼接和 JSON 解析的配合:
import requests import json import pandas as pd SESSION_URL = "https://datacenter.eastmoney.com/securities/api/data/v1/get" def fetch_financial_report(api_url, params, headers, max_retry=3): for attempt in range(max_retry): resp = requests.get(api_url, params=params, headers=headers, timeout=10) if resp.status_code == 200: payload = resp.json() if payload.get("success"): return payload.get("result", {}).get("data", []) # 非200或success=false时进入重试 if attempt < max_retry - 1: time.sleep(2 * (attempt + 1)) return [] def collect_all_pages(api_url, params, headers, start_page, end_page, page_size=50): all_records = [] for page in range(start_page, end_page + 1): params["pageNumber"] = page params["pageSize"] = page_size records = fetch_financial_report(api_url, params, headers) if not records: break all_records.extend(records) print(f"page {page} done, records: {len(records)}") return all_records # 使用示例 headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://data.eastmoney.com/", "Accept": "application/json, text/plain, */*", } params = { "type": "RPT_LICO_FN_CPD", "filter": "(reportdate=2023-12-31)", "sortColumns": "SECURITY_CODE", "sortTypes": "1", "pageNumber": 1, "pageSize": 50, } result = collect_all_pages(SESSION_URL, params, headers, start_page=1, end_page=5) df = pd.DataFrame(result) df.to_csv("balance_sheet.csv", index=False, encoding="utf-8-sig")这段代码的逻辑是:fetch_financial_report负责单次请求,收到 200 但success=false时同样当作失败,触发指数退避重试;collect_all_pages负责页面迭代,某页拿不到数据就立即终止循环,避免无效请求一路打到底。
有三处参数值得在换报表时额外确认。第一,pageSize调成 50 能减少请求次数,但接口单页最大值有上限,强行设置超过接口阈值的值会被静默忽略,返回数据的条数可能少于预期。第二,headers里缺少User-Agent时,部分反爬配置会返回 403,加上常见浏览器的 UA 是底线操作。第三,timeout参数必须显式设置,Requests 默认不设超时,一旦服务端不响应,线程就会一直挂起,整个脚本会被拖死。热词里反复出现的exceeded retry limit、last status: 429 too many requests,就是在并发或高频请求下触发的限流封禁,重试逻辑一旦设计不当,429 会演变成持续被拒的恶性循环。
3.3 效率对比的数学账:两个数量级差在哪
Selenium 方案的耗时由页面加载时间主导,打开一个 50 行的报表页平均需要 8 到 15 秒;Requests 方案拿到相同 50 行数据通常只需要 300 到 500 毫秒。差出的两个数量级,主要来自三部分:Requests 省掉了 HTML、CSS、JS 资源的下载;省掉了 JS 引擎解析执行的时间;省掉了浏览器渲染引擎构建 DOM 和布局的时间。方案B只保留最核心的事务——HTTP 请求、JSON 解析、CSV 落盘。
两份代码最终产出的 CSV 结构是兼容的,都包含证券代码、证券简称、报告期、报表项目名、数值等标准列。这也是这个资源包设计合理的地方:方案A作为验证打法,确认页面结构和字段名称;方案B作为批量实施工具,拿到验证结果后直接替换方案A执行全量采集。
4. 命令行实操:年度、财季、报表与页码的正确组合
4.1 参数规则:会计年度2018-2023与财季标识
eastmoney_crawler2.py启动时用的是命令行参数,设计上参考了常规爬虫项目的标准做法:不接受交互式输入,避免在服务器上无人值守时卡在input()等待。一次典型的调用长这样:
python eastmoney_crawler2.py \ --start-year 2018 \ --end-year 2023 \ --quarter 1 \ --report-type balance \ --start-page 1 \ --end-page 10 \ --output-dir D:/estmoney/参数命名见名知义,但有几个细节容易忽略。--quarter接受的是 1 到 4 的数字,脚本内部会把它转换为对应的报告期日期:1 对应3-31,2 对应6-30,3 对应9-30,4 对应12-31。不同财季对应不同报表规则,比如第四季度报告期往往附带全年累计数据和单季度数据两套口径,脚本默认只取合并口径,如果你想要母公司口径,需要修改filter里关于合并类型的代码段。--report-type接收的是关键字,不是报表中文名,脚本启动时会先打印关键字到报表类型代码的映射表,看到映射再继续,防止输错。--start-page和--end-page是闭区间,两端都会采集,比如1 10会抓 10 页、每页 50 条,合计最多 500 条记录。
4.2 报表字段映射:资产负债表、利润表、现金流量表的差别
这个资源包里预置了 8 个 CS文件,包括资产负债表、利润表、现金流量表、业绩报表、业绩预告表、业绩快报表、利润表(全部)和预约披露时间表。这正好对应东方财富后台的几套报表类型代码,每套代码的字段列表不完全一致。
| 报表文件 | 接口类型代码示例 | 特有字段 |
|---|---|---|
| 资产负债表.csv | RPT_LICO_FN_CPD下相关类型 | 货币资金、应收账款、存货、总资产、负债合计 |
| 利润表.csv | RPT_LICO_FN_CPD利润表类型 | 营业总收入、营业总成本、净利润、基本每股收益 |
| 现金流量表.csv | RPT_LICO_FN_CPD现金流类型 | 经营活动现金流量净额、投资活动现金流量净额 |
| 业绩报表.csv | 业绩快报相关类型 | 净利润同比、营收同比、每股收益同比 |
| 业绩预告表.csv | 业绩预告相关类型 | 预告类型、变动幅度、预告摘要 |
| 业绩快报表.csv | 业绩快报相关类型 | 快报摘要、快报日期、实际数值 |
| 预约披露时间表.csv | 披露日期相关类型 | 预计披露日期、实际披露日期、报告期 |
字段映射到 CSV 时,脚本会把 JSON 里的SECURITY_CODE转为证券代码列,REPORT_DATE转为报告期列,数值字段统一转成float。这里有一个经典问题:东方财富接口返回的数值型字段偶尔会出现空字符串,直接把空字符串转float会抛异常。脚本里的处理是把空值先替换成None,数值缺省统一补 0;如果你不希望用 0 掩盖缺失,需要自己改一下空值策略。就我接触过的同类爬虫来说,补 0 是最省事的做法,但对后续做财务分析的人不友好,建议落地时先留空,让下游处理时不至于把缺失项当真实 0 值参与计算。
4.3 输出路径D:/estmoney/与文件覆盖逻辑
摘要描述里明确了输出目录:D:/estmoney/。脚本不会自动创建不存在的目录,首次运行前需要手动建立,否则会在写入 CSV 时报错。这是一个小的设计取舍,自动os.makedirs(exist_ok=True)只需要三行代码,但资源包保持了这个动作由使用者完成的风格,这么做的好处是强迫使用者确认输出盘符存在且路径正确,避免跑完几百页数据后发现目录配错白跑一趟。
文件命名逻辑是报表类型_报告期_起始页_终止页.csv。同一次运行重复执行,会覆盖同名文件。如果你要累积多个财季的数据,要么改文件名的前缀,要么手动把旧文件移到另一个目录。实际使用时更推荐的做法是:每次运行前用 Python 生成一个带时间戳的子目录,例如D:/estmoney/20240215_1530/,不同批次的采集结果彼此隔离,后续合并时再统一拼接。这种习惯能极大降低多季度连续采集时的文件管理负担。
4.4 业务低峰时段执行大规模采集
摘要和 README 里都提到,大型采集建议在业务低峰时段进行。这个提示背后有实际依据:东方财富的数据网关在高并发时段对非同源请求的限流更明显,同一 IP 短时间内连续请求特定数据接口,触发限流的概率会明显上升。热词里频繁出现的429 too many requests,正是这种限流的典型响应码。
我一般的处理方式是:单次采集页数超过 100 页的任务,拆成多个时间段执行;Requests 请求之间加 0.2 到 0.5 秒的随机延时;抓取中途如果连续收到两个 429,立即 sleep 30 秒再继续。这样一来,效率虽然比纯并发模式低一点,但任务的整体成功率反而更高。要算一笔账:一天内完成和三天内完成,对财务数据采集这种对时效性不敏感的任务来说差别不大,但请求被全量拉黑导致的返工代价要高得多。
5. 避坑与常见问题:采集过程中的八个翻车点
5.1 连续429限流导致重试死循环
现象:脚本运行到第 40 页左右,终端连续输出429 too many requests,随后exceeded retry limit异常退出,已采集的数据全部丢失。
原因:单 IP 在短时间窗口内对东方财富数据接口发起了高频请求,网关按请求频率算法触发限流。默认的三次重试策略在持续 429 时没有意义,三次重试用完后直接抛出异常结束进程,已写入内存的 39 页数据没有落盘。
解决:重试逻辑需要区分限流和真正的网络错误。当响应码是 429 时,不能按普通失败对待,应该把等待时间拉长到 30 秒以上,同时检查是否还有必要继续当前会话。更稳妥的做法是:每完成一页,立即把当前 DataFrame 追加写入磁盘,写一个append模式的 CSV 落盘函数。这样即使中途中断,最多丢一页的数据,而不是前功尽弃重跑整个区间。
5.2 Selenium打开的页面无表格数据
现象:eastmoney_crawler.py运行时浏览器正常启动,目标页面也能打开,但presence_of_element_located一直超时,页面上没有任何财务表格内容。
原因:这个场景和三年前的踩坑情况比较类似。东方财富的反爬机制会对带自动化特征的用户代理做拦截,同时无头模式下缺少关键请求头时,页面加载后会进入一个验证或异常分支,不渲染业务数据表格。另一个常见原因是 ChromeDriver 日志目录或用户数据目录权限不对,在 Windows 服务器上尤其容易出现。
解决:先改成有头模式,确认页面在真实浏览器窗口里是否正常展示数据。如果真实浏览器正常而 Selenium 异常,说明是自动化特征被识别,需要给 ChromeDriver 增加排除自动化控制的参数,并设置完整的 UA。如果页面在两种模式下都无数据,大概率是请求头里的Referer丢失,需要手动指定为数据频道的首页地址。
5.3 导出的CSV出现乱码或Excel无法打开
现象:CSV 文件用记事本看正常,用 Excel 双击打开时中文全部变成乱码,并且提示文件格式与扩展名不匹配。
原因:脚本区域中写入 CSV 时默认用了 UTF-8 编码。Excel 在中文 Windows 环境下默认用 ANSI(也就是 GBK)解析文本文件,没有 BOM 头的 UTF-8 文件会被误按 GBK 解码,中文内容自然乱码。
解决:这是整个资源包里最容易预判也最值得预判的问题。写入时把编码参数从utf-8改成utf-8-sig,也就是带 BOM 的 UTF-8,Excel 识别后会自动切成正确的解码方式。我每次写 DataFrame 到 CSV 都强制带上encoding="utf-8-sig",已经不需要思考。
5.4 数值字段出现None或空字符串
现象:采集成功返回的 JSON 里,某些公司的净利润、营收字段是空串,转换为 DataFrame 后形成None,下游统计出错。
原因:部分上市公司在报告期内未披露完整数据,或者公司处于停牌、暂停上市状态,数据源本身就没有返回有效数值。这不是爬虫 bug,而是数据源侧的缺失。
解决:明确空值策略。如果目标是做数量分析,补 0 最省事;如果目标是做基本面筛选,建议不要补 0,保留空值并在后续清洗时用dropna(subset=["净利润"])过滤。同时在采集完成后输出一份缺失字段统计表,列出哪些公司、哪些报表列存在空值,方便判断是否需要二次补充。
5.5 页码超过数据边界导致越界
现象:设置--end-page 50,但目标接口实际只有 32 页数据。脚本从第 33 页开始请求,返回空数组,但循环没有正确退出,继续向后发了 18 个无效请求,白白浪费请求配额。
原因:脚本对空数据页的退出条件判断不严格。部分接口在页码超出范围时返回的不是空列表,而是包含少量无效占位记录的数组,导致if not records: break判断失效。
解决:增加两层判断。第一层,当返回记录数为 0 时直接退出。第二层,当返回记录数少于page_size时也退出,因为最后一页通常不满页,之后必然是空页。这个改动只需三行代码,但对接口配额的保护效果很明显,也避免了持续请求触发限流。
6. 进阶技巧:用数据校验脚本守好CSV的最后一公里
报表采集完成不等于任务结束,CSV 里的数据质量需要独立验证。我习惯在采集脚本之外单独写一个校验脚本,跑完之后立刻对落盘的 CSV 做三件事:核对行数是否符合页码范围、检查关键字段的缺失率是否异常、对比核心指标的跨表一致性。
import pandas as pd import sys def validate_csv(path, expected_rows=None, key_cols=None): df = pd.read_csv(path, encoding="utf-8-sig", dtype={"SECURITY_CODE": str}) issues = [] if expected_rows and len(df) != expected_rows: issues.append(f"row count mismatch: expected {expected_rows}, got {len(df)}") for col in key_cols or []: if col not in df.columns: issues.append(f"missing column: {col}") continue missing = df[col].isna().sum() total = len(df) ratio = missing / total if total else 0 if ratio > 0.05: issues.append(f"{col}: missing ratio {ratio:.2%} exceeds 5%") if issues: for msg in issues: print("[FAIL]", msg) sys.exit(1) print("[OK] all checks passed:", path) validate_csv( "D:/estmoney/资产负债表_2018-2023Q1_1_10.csv", expected_rows=500, key_cols=["SECURITY_CODE", "REPORT_DATE", "TOTAL_ASSETS", "TOTAL_LIABILITIES"] )这里的关键思路是把校验做成前置门禁:行数对不上说明采集过程可能发生了页面漏抓;缺失率超过 5% 说明接口参数或空值策略需要调整;表格跨表一致性则靠不同报表之间的勾稽关系去验证,例如资产负债表的资产合计应当等于负债合计加所有者权益合计。把这些检查固化成脚本之后,每次采集完跑一遍,问题在进入分析流程之前就被拦住了。
从那以后,我每次跑完一批财务数据采集,都强制走一遍这个校验流程,哪怕只是重跑一个几十页的小任务也不跳过。这套组合拳——Requests 直连、分页退出条件收紧、UTF-8 with BOM 落盘、数据校验前置——基本可以覆盖东方财富爬虫项目的大部分翻车场景。希望帮到你。
本文还有配套的精品资源,点击获取