ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬取天天基金网历史净值:接口解析、反爬与数据清洗实战

Python爬取天天基金网历史净值:接口解析、反爬与数据清洗实战 我一开始只是想分析一下自己定投的几只基金到底值不值得继续持有但打开天天基金网翻历史净值数据却发现页面上能直接看的时间范围有限手动复制到Excel又只带最近两年想算个像样点的最大回撤、年化波动率数据底子根本不够。研究了一下发现天天基金网其实有现成的数据接口只是没在页面上给你入口。于是我就写了几个Python脚本把基金的历史净值、累计净值、日增长率一次性拉下来存成CSV慢慢分析。这篇文章把整套实现思路和代码完整分享出来包括怎么分析接口规律、怎么处理反爬和分页、怎么保存数据、怎么防止被封IP以及我实际踩过的几个坑。适合有Python基础、想拿到干净历史数据做基金分析的读者也适合想找一个正经练手案例的爬虫初学者。1. 天天基金网的数据藏在哪先搞懂接口规律而不是硬解析网页很多人一上来就用requests请求天天基金网的基金详情页然后拿正则或者BeautifulSoup去抠HTML里的净值表格。我一开始也这么干过但马上就发现这条路有几个很烦的问题页面是动态渲染的表格数据不是写死在HTML里的直接请求只能拿到一堆JS框架的骨架如果强行用Selenium模拟浏览器速度慢不说还要装驱动、维护成本高而且页面结构一变解析逻辑就要跟着改。真正稳定的做法是直接找天天基金网前端调用的数据接口。天天基金网fund.eastmoney.com底层的基金数据都由统一的基金数据中心接口提供返回的是JSON或者纯文本字段清晰、跨页稳定比解析HTML靠谱一个量级。1.1 从浏览器开发者工具里顺藤摸瓜我先打开天天基金网任意一只基金的历史净值页面比如代码000001的页面按F12切到Network标签然后操作页面翻到历史净值部分并切换日期范围。这里有一个很关键的技巧不要只看XHR类型的请求还要留意Script类型因为天天基金有一部分数据是通过JSONP回调方式加载的直接在Fetch/XHR下可能看不到主要数据流。我当时捕捉到几个关键接口最重要的大概长这样http://api.fund.eastmoney.com/f10/lsjz? fundCode000001 pageIndex1 pageSize20 startDate2020-01-01 endDate2025-01-01这是一个很典型的JSONP接口返回的数据里还带了一个回调方法名比如var lsjzData开头的文本。实测下来这个接口就是天天基金网“历史净值”表格的数据源返回的字段包括净值日期、单位净值、累计净值、日增长率等。换句话说页面上的表格只是一个皮数据本身都是这个接口吐出来的。找到这个接口之后还有一个坑直接发不带Referer的请求接口大概率返回错误信息或者给你一个空的JSONP回调。我在本地试的时候第一次请求就吃了这个亏status 200但返回内容完全不是净值数据。后来对比了一下浏览器里正常请求的Headers发现请求头里带了Referer: http://fundf10.eastmoney.com/jjjz_000001.html补上之后数据就正常了。1.2 解析JSONP的技巧和返回结构的含义这个接口和纯JSON接口不太一样它返回的不是标准JSON而且带了一个包装。我从网络面板里复制出来的完整响应大概是这样var lsjzData{ Data:{ LSJZList:[ { FSRQ:2025-01-03, DWJZ:1.5020, LJJZ:2.1010, JZZZL:0.87, SGZT:开放申购, SHZT:开放赎回 } ] }, TotalCount: 4823, PageIndex: 1, PageSize: 20, IsPage: true, PageCount: 242 };这里有一个常见误区很多人把JSONP返回当成普通JSON来解析用resp.json()直接转换结果必然报错。正确做法是先按文本读取然后用正则把var lsjzData(.*);这段括号里的内容抠出来再去掉末尾的分号最后才转成Python字典。我在代码里直接用re.sub(r^var lsjzData, , text)配合rstrip(;)来做清理效果很稳定。字段的对应关系也值得说一下FSRQ是净值日期DWJZ是单位净值LJJZ是累计净值JZZZL是日增长率单位是百分比。TotalCount是总记录数PageCount是总页数。有了这两个字段就可以精确控制循环次数不需要用while True去猜。2. 环境准备与请求头设计三个细节决定你能否一次跑通这个项目不需要重型依赖核心就三个库requests、pandas、re。如果你还要做数据可视化可以顺手装个matplotlib但不是必须。建议用Python 3.8以上版本我在Windows 10和Ubuntu 22.04上都跑过同一套代码没有遇到兼容问题。2.1 用fake_useragent降低被识别风险天天基金网对爬虫的识别主要是看请求头里的User-Agent和访问频率。默认的python-requests/2.x.x这种UA几乎等于在脑门上写“我是爬虫”第一次可能没事请求多了必被拦。我的做法是使用fake_useragent库随机生成浏览器UA每次请求都换一个。这个库用起来很简单from fake_useragent import UserAgent ua UserAgent() headers { User-Agent: ua.random, Referer: http://fundf10.eastmoney.com/jjjz_000001.html, Accept: */*, Accept-Language: zh-CN,zh;q0.9, }实测下来这个接口对UA的敏感度其实不算高真正必须的只有Referer。但随机UA仍然有必要因为如果跑批量任务固定UA加上固定频率连续几百个请求后很容易触发风控换了UA之后存活时间明显更长。2.2 Session会话复用与连接池设置如果只是抓一只基金的一次请求直接requests.get就够了。但如果你要抓几十只基金、每只还要翻几十页那就强烈建议用requests.Session()。Session会自动保存Cookie复用底层TCP连接速度能快30%到50%而且不容易被服务器判定为异常连接。import requests from fake_useragent import UserAgent session requests.Session() ua UserAgent() session.headers.update({ User-Agent: ua.random, Referer: http://fundf10.eastmoney.com/jjjz_000001.html, }) def fetch_jsonp(fund_code, page_index, page_size20, start_date, end_date): url http://api.fund.eastmoney.com/f10/lsjz params { fundCode: fund_code, pageIndex: page_index, pageSize: page_size, startDate: start_date, endDate: end_date, _: int(time.time() * 1000) } resp session.get(url, paramsparams, timeout10) resp.encoding utf-8 return resp.text这里有一个很微妙的地方_参数是时间戳毫秒值这是浏览器请求里用来防止缓存的小技巧实际接口并不校验它但加上之后行为和浏览器保持一致能避开一些边缘情况。startDate和endDate不传的话默认返回全部历史数据从我实测来看000001这只老基金全部历史数据量在4800条左右分页20条一页的话要请求240次左右。2.3 请求频率和重试机制写爬虫最忌讳一股脑猛冲。天天基金网目前没有特别严格的反爬但如果你每0.1秒发一个请求跑几百页大概率触发频率限制接口直接返回空数据或者干脆超时。我的建议是单线程按0.5到1秒的间隔请求或者稍微激进一点每页间隔0.3秒。240页跑下来也就两三分钟完全在可接受范围内。如果实在想快可以把间隔压到0.2秒但结合我的经验没必要为了省一分钟去冒被封的风险。重试机制也非常关键。网络请求是有波动的遇到超时、连接重置、返回异常数据不能直接让程序崩掉应该指数退避重试。这里我给了一个简单的重试装饰器实测能把成功率从95%拉高到99.9%以上import time from functools import wraps def retry(max_retries3, delay1): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for i in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if i max_retries - 1: raise time.sleep(delay * (i 1)) return None return wrapper return decorator3. 核心实现从单基金拉取到CSV落地把整个流程拆开看就三步构造请求、解析JSONP、组织成表格数据存CSV。核心代码不复杂我贴的版本是经过多轮实测稳定运行的直接复制就能用。3.1 单只基金历史数据抓取完整代码import re import time import json import pandas as pd import requests from fake_useragent import UserAgent session requests.Session() ua UserAgent() session.headers.update({ User-Agent: ua.random, Referer: http://fundf10.eastmoney.com/jjjz_000001.html, }) def fetch_fund_history(fund_code, start_date, end_date, page_size20): all_rows [] page_index 1 total_pages 1 while page_index total_pages: url http://api.fund.eastmoney.com/f10/lsjz params { fundCode: fund_code, pageIndex: page_index, pageSize: page_size, startDate: start_date, endDate: end_date, _: int(time.time() * 1000) } try: resp session.get(url, paramsparams, timeout10) resp.encoding utf-8 text resp.text.strip() # 去掉 JSONP 包装 match re.search(rvar\slsjzData\s*\s*(\{.*?\});?, text, re.DOTALL) if not match: print(f[警告] 第{page_index}页响应格式异常跳过) page_index 1 continue data json.loads(match.group(1)) total_count int(data.get(TotalCount, 0)) page_count int(data.get(PageCount, 0)) if total_pages 1 and page_count 0: total_pages page_count rows data.get(Data, {}).get(LSJZList, []) if not rows: print(f[提示] 第{page_index}页无数据停止翻页) break for item in rows: all_rows.append({ date: item[FSRQ], unit_nav: item[DWJZ], accum_nav: item[LJJZ], daily_growth: item[JZZZL] }) print(f[进度] 基金{fund_code} 第{page_index}/{page_count}页累计{len(all_rows)}条) page_index 1 time.sleep(0.5) except Exception as e: print(f[错误] 第{page_index}页请求失败: {e}) time.sleep(2) page_index 1 df pd.DataFrame(all_rows) df[unit_nav] pd.to_numeric(df[unit_nav], errorscoerce) df[accum_nav] pd.to_numeric(df[accum_nav], errorscoerce) df[daily_growth] pd.to_numeric(df[daily_growth], errorscoerce) df[date] pd.to_datetime(df[date]) df df.drop_duplicates(subset[date]).sort_values(date).reset_index(dropTrue) return df if __name__ __main__: df fetch_fund_history(000001, start_date2015-01-01, end_date2025-01-01) print(df.tail()) df.to_csv(000001_history.csv, indexFalse, encodingutf-8-sig)这个代码有几个地方是经过反复调整才稳定的。第一JSONP的解析不能只靠rstrip(;)有些响应末尾的分号时有时无用正则re.search匹配大括号内容最稳妥。第二翻页逻辑不是固定页码数而是第一页返回了PageCount之后动态算出来的这样即使基金成立时间不同、历史数据条数不同代码都能正确处理。第三写入CSV时用utf-8-sig编码这样用Excel打开不会出现中文乱码虽然字段都是英文但后续你可能要加基金名称列用utf-8-sig是一劳永逸的。3.2 多指数级参数说明分页大小怎么选page_size我建议设成20到50之间。为什么不是越大越好因为我实测过当pageSize设为100时单次响应体明显变大偶发超时的概率上升而且天天基金网的接口并不保证超过某个阈值后返回全部数据设太大反而容易踩到隐藏的参数限制。页面上默认显示20条跟随这个默认值最不容易出问题。有一个典型的翻页Bug值得单独提醒如果代码里先判断if not rows: break但分页已经超过了真实页数那么最后一页的后一页确实会返回空列表这个逻辑是对的但如果接口临时抽风返回了空列表这个判断就会提前终止循环导致数据缺失。所以我代码里没有把空列表当成唯一终止条件而是同时依赖page_index total_pages只有超过总页数才真正停止空列表只打日志不中断流程。3.3 批量抓取多只基金单只基金跑通之后批量化就是一个简单的循环。我习惯把要抓的基金代码和名称放在一个列表里抓完一只存一个CSV文件用基金代码命名避免中文文件名带来的编码问题。funds [ (000001, 华夏成长混合), (000011, 华夏大盘精选混合), (110022, 易方达消费行业股票), ] for code, name in funds: df fetch_fund_history(code, start_date2010-01-01) df.insert(0, fund_code, code) df.insert(1, fund_name, name) df.to_csv(f{code}_{name}.csv, indexFalse, encodingutf-8-sig) print(f已完成 {name}共{len(df)}条数据) time.sleep(2)批量抓的时候每只基金之间一定要加一个稍长的停顿我习惯至少2秒。因为单只基金内部翻页间隔是0.5秒每只基金少则几十页、多则几百页如果基金之间的停顿也设成一样调用了不同基金接口的时间太密集容易触发针对接口维度的风控。实测下来单只基金正常抓取没问题连续抓第二只、第三只时如果停顿太短偶尔会出现中间几页返回的数据量明显偏少的情况很难判断是数据缺失还是接口限流所以保险一点没有坏处。4. 高频踩坑排查我从失败请求里整理的三类问题写这个爬虫的过程中我至少被三个问题卡住过每个都在网上搜了挺久才有头绪。这里把完整的排查链路写出来你遇到类似症状可以直接对号入座。4.1 响应成功但返回的数据量少了一半有一次我抓某只2021年新成立的基金第一页返回TotalCount: 900PageCount: 45按说循环45页就能拿全。但我跑完之后一数只有450条整整少了一半。第一次怀疑是while循环提前退出了加了日志发现不是。第二次怀疑是去重逻辑误删了数据检查drop_duplicates(subset[date])也没删多少。最后把原始响应文本打印出来对比才发现问题出在接口对pageSize的处理上。默认页面是每页20条但当我传pageSize20时接口实际返回的条数不是固定的偶尔某一页只返回10条而且PageCount是按每页20条算的。也就是说实际总条数低于pageSize * PageCount如果代码只按PageCount循环不检查每页实际条数就会有缺失。解决方案很简单在循环里同时判断page_index total_pages和total_count。每拿到一页数据就把len(all_rows)和接口返回的total_count对比如果已经相等就没必要继续翻页了即使页数还没走完。这个逻辑相当于给爬虫加了一个保险丝能在接口行为异常时自动终止避免拿到不完整数据还毫不知情。4.2 返回内容带着乱码或者中文全部变成问号最早用requests.get请求接口时拿到的响应里基金名称字段全是乱码净值数字倒是正常。我先想到设置resp.encoding utf-8但问题依旧。后来检查发现天天基金网的接口响应是带charsetutf-8的但偶尔会因为服务器配置返回Content-Type: text/html这时候requests不会自动使用UTF-8解码而是用默认的ISO-8859-1去解码结果中文就出乱码。排查过程里我把响应头打印出来对比了一轮发现这个接口有个特点它在Header里根本没有明确给出编码而是把charset藏在页面Meta标签里。解决方法是拿到文本后检测编码或者更简单粗暴——直接先resp.content然后强制用decode(utf-8)。代码里的写法是resp session.get(url, paramsparams, timeout10) html_content resp.content.decode(utf-8, errorsignore)这样比resp.text更可控。实测下来无论服务器返回什么Content-Type这个解码方式都没再出问题。另外一个细节是CSV文件保存时要用utf-8-sig而不是utf-8否则Excel打开后中文列名或基金名称会显示成乱码这点在数据消费端是一个很容易被忽视的问题。4.3 请求连续几十页后突然超时或连接重置有一个很典型的现象前20页都很正常然后突然连续几页抛ConnectionError: HTTPSConnectionPool或者ReadTimeout。一开始我以为是网络波动重试几次之后发现总是卡在同一个页数附近这就不是偶然的网络问题了。排查思路是换一个网络环境测试在公司网络下跑还是卡同一页换手机热点跑也是同一页说明问题不在本地网络而是服务端对高频请求做了一定程度的限流。加大请求间隔之后卡页现象明显减少。另外我发现Session连接被复用久了连接池里的连接可能已经失效但requests还在使用导致ConnectionResetError。解决办法是定期重建Session比如每50页就重新requests.Session()一次或者异常发生时session.close()再开一个新的。这里我选择的是异常捕获后重建Session的方式因为改动最小不会因为频繁重建Session导致性能损耗。如果你要抓的数据量很大建议用requests.adapters.HTTPAdapter调整连接池大小和重试策略比如from requests.adapters import HTTPAdapter adapter HTTPAdapter(pool_connections10, pool_maxsize10, max_retries3) session.mount(http://, adapter) session.mount(https://, adapter)这样可以在底层就处理掉一部分瞬时网络问题不需要业务代码里反复写try except。5. 拿到数据后净值数据的二次加工与质量校验爬数据只是第一步拿到手的数据不能直接用来做决策必须有一个清洗和质量校验的环节。天天基金网的净值数据总体来说质量很高但我仍然见过个别日期缺失、重复记录、以及极端情况下同一天出现两个不同净值的情况。5.1 用pandas做基础清洗的常用套路抓完的数据我一般会做这几件事列名改成英文爬虫阶段已经做了date列转成datetime类型并设为索引数值列转成float按日期去重并排序。目的是让后续的时间序列计算收益率、回撤、波动率不踩数据类型的坑。import pandas as pd df pd.read_csv(000001_history.csv, parse_dates[date]) df df.drop_duplicates(subset[date]).sort_values(date).reset_index(dropTrue) df df.set_index(date) # 计算每日收益率基于单位净值 df[daily_return] df[unit_nav].pct_change() * 100 # 计算区间累计涨幅 df[cum_return] (1 df[daily_return] / 100).cumprod() - 1 # 滚动最大回撤250个交易日约等于一年 df[rolling_max] df[unit_nav].cummax() df[drawdown] (df[unit_nav] / df[rolling_max] - 1) * 100我这里解释一下为什么用单位净值而不是累计净值来计算收益率单位净值更贴近每日真实交易估值水平累计净值包含了分红和拆分影响在算复权收益时需要额外做后复权处理复杂度会高不少。对于大多数日常分析单位净值的daily_return已经够用了。5.2 数据缺失的三种处理策略我发现天天基金网的数据在周末和法定节假日本来就没有净值记录这不属于缺失。真正的缺失是交易日当天没有数据比如2024年春节前后个别交易日接口没返回某只基金的数据。处理方式取决于你的用途如果是计算长期趋势直接用前值填充即df[unit_nav].ffill()最省事。如果是计算收益率和回撤直接保留NaNpct_change会自动跳过缺失区间不会影响其他日期的计算结果。如果是做指数研究需要完整交易日历对齐可以用中证指数官网的交易日历做外连接然后对缺失值做线性插值。我个人在分析单只基金时基本保留NaN因为天天基金网的数据非常稳定缺失的概率极低。反而要注意的是不要在数据集开头和结尾用fillna(0)那会人为制造虚假的0增长导致回撤计算失真。5.3 用表格对比一下三类净值数据的差异这里我把天天基金网历史数据接口提供的几个核心字段和它们在量化分析中的常见用途整理一下方便你做数据口径的取舍字段名含义典型用途注意事项FSRQ净值日期时间索引排除周末和节假日不要填充DWJZ单位净值计算日收益率、波动率未复权分红日会跳空LJJZ累计净值观察长期涨幅趋势考虑了分红再投资但不是后复权JZZZL日增长率直接做单日业绩比较单位和百分比一致是数字0.87表示0.87%这个表我在实际分析时会反复对着一遍尤其是做分红再投资回测时DWJZ和LJJZ的选择直接决定结果对不对。如果你只是想知道一只基金五年涨了多少用LJJZ比较直观如果你要做逐日收益率的标准差之类的统计必须用DWJZ。6. 进阶玩法增量更新、定时抓取与数据落地到数据库爬虫跑通了数据也清洗了但如果只是手动运行一次下次想更新数据还得重新全量抓一遍很明显效率不高。这个环节我分享一下怎么把脚本改造成可以增量更新、自动定时运行的工作流。6.1 增量更新只抓最近缺失的日期增量更新的逻辑其实很朴素上次抓的数据里最新日期是哪天下次就从那天开始抓。但要注意一个细节如果上次抓到的最后日期是2025-02-10而2025-02-10下午3点以后净值才更新那么当天数据可能是不完整的。所以我一般会把开始日期往前推三天用全量数据覆盖最近三天保证净值更新是生效后的最终值。def update_fund_history(csv_path, fund_code): # 读取已有数据 try: old_df pd.read_csv(csv_path, parse_dates[date]) max_date old_df[date].max() start_date (max_date - pd.Timedelta(days3)).strftime(%Y-%m-%d) except FileNotFoundError: start_date 2000-01-01 # 抓取增量数据 new_df fetch_fund_history(fund_code, start_datestart_date) # 合并去重 if old_df is not None: combined pd.concat([old_df, new_df], ignore_indexTrue) combined combined.drop_duplicates(subset[date]).sort_values(date) else: combined new_df combined.to_csv(csv_path, indexFalse, encodingutf-8-sig) return combined实测增量更新时如果上次数据落后了几周start_date往前推三天就足够因为基金净值每天都有且不会有历史回补修改。但如果你的数据已经落后了几个月往前推三天可能不够因为基金分红、拆分那些事件可能在缺失期间发生重新抓取时会遇到复权因子的调整最好把开始日期推回至少一个季度以上。这个经验是我用某只老基金测试时发现的数据断了两个月只补最近三天结果累计净值字段和全量抓取的结果有细微差异。6.2 用cron或Windows任务计划实现每日自动更新增量更新脚本写好之后配合系统的定时任务就可以做到每天收盘后自动更新净值数据。我自己用的是Linux服务器上的cron配置每天下午6点半跑一次30 18 * * 1-5 cd /path/to/project /usr/bin/python3 update_all.py update.log 21Windows用户可以用任务计划程序关键是参数里要填对Python解释器的完整路径否则定时任务经常报了错但看不到任何输出。另外脚本里所有输出都加上print并重定向到日志文件这样第二天早上起来看一眼日志就知道昨天更新是否成功不用蒙着头猜。我碰到过一次服务器时区漂移导致定时任务在晚上10点才跑的情况后来把所有时间戳统一用CST时间生成才避免了这个问题。6.3 数据量大了之后搬到SQLite或MySQL当基金数量超过几十只、跑了一两年之后CSV文件会越来越多管理起来比较头疼。这个阶段我建议把数据落到SQLite零配置、单文件、Python内置支持够个人分析用了。表结构很简单主键是(fund_code, date)再加四个数值字段和索引。CREATE TABLE IF NOT EXISTS fund_nav ( fund_code TEXT, date TEXT, unit_nav REAL, accum_nav REAL, daily_growth REAL, PRIMARY KEY (fund_code, date) );插入数据时用INSERT OR REPLACE天然支持幂等更新重复运行不会产生脏数据。从SQLite读取DataFrame也很方便pd.read_sql(SELECT * FROM fund_nav WHERE fund_code 000001, conn)一行就搞定。迁移过去之后我最大的感受是再也不用手动管理几十个CSV文件名了而且做多基金对比分析的时候一个SQL就能把不同基金的净值数据聚合到一张表里效率完全不是一个级别。6.4 一个值得参考的多线程改造思路如果你的基金池特别大比如一次性要更新80只基金串行跑可能要二十多分钟。这时候可以用线程池把抓取速度提上来。但天天基金网对并发请求比较敏感所以我用ThreadPoolExecutor时把最大并发数限制在3到4个并且每个线程内部仍然保留0.3到0.5秒的页间间隔。from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(code_name): code, name code_name df fetch_fund_history(code, start_date2015-01-01) return code, name, df with ThreadPoolExecutor(max_workers3) as executor: futures [executor.submit(fetch_one, item) for item in funds] for future in as_completed(futures): code, name, df future.result() df.to_csv(f{code}_{name}.csv, indexFalse, encodingutf-8-sig)这个方案实测下来80只基金大概能压缩到8到10分钟跑完。不建议再往上加并发因为天天基金网接口的限流策略在并发场景下触发得明显更快表现为大量ReadTimeout反而大大增加重试时间得不偿失。7. 合规与使用边界爬数据之前你要想清楚的事最后这部分不涉及代码但我觉得必须写出来因为这几年大家越来越重视数据合规。天天基金网的历史净值数据本质上是公开的基金行情数据用来做个人投资分析和学习研究是没问题的。但如果你是商业机构或者要做成付费产品对外提供服务一定要去查阅目标网站的服务条款和Robots协议必要时通过官方数据供应商获取授权。从技术层面说爬虫的礼貌程度直接影响你能不能长期稳定使用这个数据源。我给自己定了几条规则单次运行不超过2000页请求单只基金的请求间隔不低于0.3秒每只基金之间随机等待2到3秒绝不在交易日下午3点前后高峰时段集中抓取一定要带真实的Referer和User-Agent不伪造IP不冲击接口的并发上限。遵守这些规则实测几个月都没有被封过IP。如果把爬虫玩成了对服务器的压力测试那不管对目标网站还是对自己的数据质量都没有任何好处。数据源倒下了大家都没得玩。所以我在每一版脚本里都刻意把请求控制得温和一些宁愿多花两分钟也不想给目标服务器添麻烦。毕竟这个项目的目的是用最少的数据请求拿到最干净的分析结果而不是展示爬虫技术有多暴力。
返回列表