Python爬虫实战:批量获取天天基金历史净值并存入SQLite

发布时间:2026/9/15 16:16:07
Python爬虫实战:批量获取天天基金历史净值并存入SQLite 最近有个做数据分析的朋友找我帮忙想抓一批基金产品的历史净值数据做回测结果天天基金网站上的页面翻了几十个数据还是一条条复制到Excel里的效率低得吓人。我直接给他写了一个Python脚本把天天基金网的历史净值数据批量拉下来再自动存成CSV和SQLite数据库整个过程从人工的一天活缩短到了十几分钟。今天就把这个项目的完整思路和代码拆解开分享给同样在处理基金数据、准备做量化分析或者单纯想练手Python爬虫的朋友。这个项目本身不算复杂核心就是requests请求数据接口加pandas清洗数据但里面涉及了接口定位、请求参数构造、反爬规避、增量更新这些非常实用的爬虫知识点。不管你是刚学Python没多久的入门者还是已经能看懂代码但没写过完整爬虫项目的初学者按着这篇文章的思路走一遍都能掌握一套真正能落地的数据采集方案。1. 项目背景与整体设计思路1.1 为什么拿天天基金网当练手项目天天基金网fund.eastmoney.com是目前国内公募基金数据比较全、更新也非常快的平台而且它的绝大多数数据都是通过后端JSON接口直接返回的不像很多网站把数据藏在HTML里面还得先费劲解析DOM节点。我给学生或者同事推荐爬虫练手项目的时候也经常拿基金数据举例子。原因有几个数据接口免费开放不需要登录和Cookie拿到基金代码就能请求。接口返回的是标准JSON或者JS变量文本解析难度适中比动态渲染的网站友好太多。基金净值本身带日期和数值两个关键维度非常适合用pandas做后续分析和可视化。数据量级适中一支基金的历史净值大概几千条不会像电商评论那样动不动几十万条导致请求频率太高被封。这个项目弄完之后你既能得到一套通用的爬虫设计思维又能顺手拿到一批真实可用于分析的金融数据属于性价比非常高的练手场景。1.2 抓取前先想清楚需求数据边界与存储结构很多刚开始接触爬虫的人拿到需求就急着写代码结果到后面发现数据格式不对、字段对不上、跑着跑着被反爬拦截返工成本高得离谱。我习惯先把需求拆成一个表格明确每个环节到底要什么项目需求定义数据源天天基金网公开的历史净值页面目标实体指定基金代码如000001、110022字段范围净值日期、单位净值、累计净值、日增长率时间范围从基金成立日起到最新一个交易日存储方式CSV文件 SQLite数据库双写更新策略全量抓取后做增量更新按日期去重字段边界尤其重要。天天基金网的接口里能返回申购状态、赎回状态、分红状态等一堆元信息字段但如果你只是做收益率计算和回测其实只需要四个核心字段就够用了。把数据边界划清楚后面的清洗和入库逻辑也会简单很多。1.3 技术选型requests pandas 标准库够用就好这个项目我用的是最基础的Python技术栈没有引入Scrapy这种重型框架requests负责发送HTTP请求拿到接口返回的文本内容。pandas负责数据清洗、格式转换、CSV落盘。sqlite3Python内置负责数据入库和增量去重。json / re负责解析接口返回的JSON或者JS变量。选型逻辑很直接爬虫的核心能力就是“请求—解析—存储”这个链路用requests加pandas完全可以覆盖。Scrapy虽然功能强大但对小数据量的单机脚本来说有点杀鸡用牛刀而且学习成本高。至于解析库我用的是json和正则因为天天基金的接口返回结构很适合直接用层取值不需要上BeautifulSoup那一套。2. 第一步梳理基金列表确定目标基金范围2.1 基金列表从哪里来怎么定位数据源天天基金网上有几千只基金如果手里没有现成的基金代码清单你得先解决“爬哪些基金”的问题。常见的做法有两种手动维护一个基金代码列表比如你只关心自己持仓的十几只基金。从天天基金网的基金排行页面抓取全部基金代码拿这个列表作为后续爬虫的输入。第一种方式适合个人研究第二种方式适合做全市场扫描。我这次做的是第二种先把基金列表接口拉下来得到一个全量代码集再逐一代入历史净值接口。定位接口的方式我建议用浏览器开发者工具不要靠肉眼去看HTML源码。打开天天基金网的“基金排行”页面按F12进入开发者工具切到Network标签页刷新页面然后筛选XHR请求。你会发现页面本身没有直接返回基金列表数据真正的数据其实来自一个叫做FundMNewApi/FundMNNSearch的接口Response返回的是一个包含所有基金的JSON数组。2.2 解析接口结构拿到基金代码全集基金列表接口返回的数据结构大概是这样的{ Data: [ { CATEGORY: 股票型, FUND_CODE: 000001, FUND_NAME: 华夏成长混合, FUND_TYPE: 混合型-灵活, PINYIN: hxczhh } ] }注意这个接口默认只返回第一页数据想拿到全部基金还需要分析它的分页参数。我抓的时候看到它的请求URL类似http://fund.eastmoney.com/js/fundcode_search.js用requests直接请求这个地址返回的是一段JS代码里面把全部基金代码、名称、类型都定义成了一个数组。因为字段顺序固定用正则或者直接eval的替代方案都可以解析出来。我建议解析这段JS时不要用eval虽然eval在本地解析JS变量省事但风险太大。退而求其次用正则把括号里的内容提取出来再扔给json.loads这是最稳妥的。import requests import json import re def get_fund_list(): url http://fund.eastmoney.com/js/fundcode_search.js headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) } resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 text resp.text # 提取 result 数组 match re.search(rvar r (\[.*?\]);, text, re.S) if not match: raise ValueError(未匹配到基金列表数据) data json.loads(match.group(1)) fund_list [] for item in data: fund_list.append({ code: item[0], name: item[2], type: item[3], pinyin: item[1] }) return fund_list funds get_fund_list() print(f共获取到 {len(funds)} 只基金)2.3 接口返回字段说明与批量获取技巧基金列表接口返回的每个item是一个固定顺序的数组这里列一下我从实际抓包中确认的字段顺序索引含义0基金代码1拼音缩写2基金名称3基金类型4基金全称拿到全量列表后可以把它做成一个CSV快照避免每次跑爬虫都重复请求这个接口。基金代码更新频率并不高一周刷一次就够了。批量去请求历史净值的时候建议在循环里加一个随机sleep控制在0.2秒到0.5秒之间不要短时间把所有基金请求完否则很容易触发单IP限流。3. 核心历史净值数据的抓取与解析3.1 天天基金两个历史净值接口的区别这是整个项目里最容易踩坑的地方。天天基金网历史净值数据其实有两个不同来源的接口很多人混着用结果数据对不上pingzhongdata接口返回的是一整段JS变量文本里面包含了基金近几年的单位净值走势、累计净值走势、分红送配信息、资产配置等一大堆数据。适合拿来快速获取某只基金的净值全量序列。api.fund.eastmoney.com/f10/lsjz接口返回的是标准JSON分页返回历史净值明细每一页默认20条或者50条字段结构非常清晰。适合做分页爬取和增量更新。我这次用的是第二种方式主要原因有两个。一是它的JSON结构更规整直接可以用pandas生成DataFrame省去解析复杂JS变量的麻烦。二是它可以按页码翻天然适合做增量更新——比如你本地库里已经存到2024年6月30日下一次请求就只拉7月1日之后的数据通过startDate和endDate参数控制就行。3.2 用pingzhongdata接口拿全量净值走势先说说第一种方案虽然我最终没选它作为主方案但理解它的结构对理解天天基金的接口设计很有帮助。请求地址是http://fund.eastmoney.com/pingzhongdata/000001.js返回内容开头是一堆Data_netWorthTrend [{x:1592870400000,y:1.142,equityReturn:0.24,unitMoney:}]这样的JS变量定义。x是毫秒级时间戳y是单位净值equityReturn是当日涨跌幅。解析这种数据的思路是这样的import requests import re import json import pandas as pd def fetch_pingzhong_data(fund_code): url fhttp://fund.eastmoney.com/pingzhongdata/{fund_code}.js headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)} resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 text resp.text # 提取净值走势数组 pattern rData_netWorthTrend\s*\s*(\[.*?\]); match re.search(pattern, text, re.S) if not match: return pd.DataFrame() data json.loads(match.group(1)) df pd.DataFrame(data) df[date] pd.to_datetime(df[x], unitms) df[unit_nav] df[y] return df[[date, unit_nav, equityReturn]] print(fetch_pingzhong_data(000001).tail())这个方案的优势是简单直接一次请求就能拿到全量净值。缺点是它既没有累计净值也没有申购状态而且数据在JS变量里嵌套得比较乱要解析很多辅助字段。如果你的目标只是“拿单位净值算收益率”倒是够用。3.3 用api.fund.eastmoney.com接口做分页拉取我实际采用的主方案是这个JSON接口请求地址长这样http://api.fund.eastmoney.com/f10/lsjz?fundCode000001pageIndex1pageSize20startDateendDate_1592870400000注意这里的fundCode就是基金代码pageIndex从1开始pageSize可以设到50以内startDate和endDate格式是yyyy-MM-dd_是时间戳参数一般用来防止缓存。这个接口有一个硬性要求请求头里必须带上Referer值为http://fundf10.eastmoney.com/否则会返回403或者其他异常状态码。这是我第一次调试这个接口时卡了最久的地方网上很多教程没提这茬照抄代码直接报错。完整实现分页拉取的代码如下import requests import time import hashlib import pandas as pd def fetch_fund_history(fund_code, page_size50, max_page100): base_url http://api.fund.eastmoney.com/f10/lsjz headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: http://fundf10.eastmoney.com/ } all_rows [] for page in range(1, max_page 1): params { fundCode: fund_code, pageIndex: page, pageSize: page_size, startDate: , endDate: , _: int(time.time() * 1000) } resp requests.get(base_url, paramsparams, headersheaders, timeout10) if resp.status_code ! 200: print(f请求失败, 状态码: {resp.status_code}) break data resp.json() if not data or not data.get(Data): break lsjz_list data[Data].get(LSJZList, []) if not lsjz_list: break all_rows.extend(lsjz_list) total_pages data[TotalCount] // page_size 1 if page total_pages: break time.sleep(0.3) return pd.DataFrame(all_rows) df fetch_fund_history(000001) print(df.head()) print(f共获取 {len(df)} 条记录)3.4 请求头、时间戳和频率控制到底怎么设置这里展开讲几个容易被忽略的细节。先说User-Agent。天天基金的接口并不检查UA必须从浏览器来但如果你什么都不带服务器返回的内容有可能被压缩或者格式不对。我的习惯是固定模拟一个Chrome浏览器的UA省去不必要的麻烦。再说时间戳参数_。它的作用是让服务器认为这是一次全新的请求而不是缓存过的内容。每次请求时生成一个当前毫秒级时间戳用int(time.time() * 1000)就行。这个参数不传其实也常常能拿到数据但传上是保险做法。最后是频率控制。天天基金网的单IP限流并不算很严格但也不要一秒钟发几十个请求。我在循环里加了time.sleep(0.3)也就是每秒最多请求3到4次实测下来非常稳定几千只基金跑一轮也没有被封过。4. 数据清洗与本地存储4.1 清洗逻辑日期、缺失值、涨跌幅处理接口返回的字段里有几个需要重点处理的字段名含义清洗策略FSRQ净值日期转成datetime类型设为索引DWJZ单位净值转成floatLJJZ累计净值转成floatJZZZL日增长率转成float注意可能为字符串“0.24”SGZT申购状态保留用于辅助判断SHZT赎回状态保留处理代码def clean_nav_data(df): if df.empty: return df df df.copy() df[FSRQ] pd.to_datetime(df[FSRQ]) df[DWJZ] pd.to_numeric(df[DWJZ], errorscoerce) df[LJJZ] pd.to_numeric(df[LJJZ], errorscoerce) df[JZZZL] pd.to_numeric(df[JZZZL], errorscoerce) df df[df[DWJZ].notna()] df df.sort_values(FSRQ).drop_duplicates(subset[FSRQ], keeplast) df df.reset_index(dropTrue) return df这里有一个容易踩的坑接口返回的基金历史数据是从最近往远排的也就是2019年、2018年在前面。如果你不排序也不去重后面做收益率曲线时会发现时间轴是反的。drop_duplicates保留最后一条是因为某些特殊日期比如分红后净值归一等可能出现同一天有多条记录我们只保留最后更新的那条。4.2 保存CSV和SQLite两种方案清洗完之后落盘方式我选择双写一份CSV方便直接Excel打开看一份SQLite方便做后续查询和增量更新。CSV的保存很简单df.to_csv(ffund_000001_history.csv, indexFalse, encodingutf-8-sig)这里注意用utf-8-sig而不是utf-8。因为国内大部分Excel打开CSV文件时默认用的是ANSI编码如果直接存utf-8Excel里中文列名和字段值会乱码。用utf-8-sig带上BOM头Excel就能正确识别了。这是很多人踩过的一个小坑。SQLite入库的代码也不复杂import sqlite3 def save_to_sqlite(df, db_pathfund_data.db, fund_code000001): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS fund_nav ( fund_code TEXT, date TEXT, unit_nav REAL, accum_nav REAL, daily_return REAL, PRIMARY KEY (fund_code, date) ) ) df[fund_code] fund_code df[date] df[FSRQ].dt.strftime(%Y-%m-%d) records df[[fund_code, date, DWJZ, LJJZ, JZZZL]].values.tolist() cursor.executemany( INSERT OR REPLACE INTO fund_nav (fund_code, date, unit_nav, accum_nav, daily_return) VALUES (?, ?, ?, ?, ?) , records) conn.commit() conn.close()这里利用SQLite的主键约束fund_code date来做去重。INSERT OR REPLACE的意思是如果同一天的数据重复存在直接用新数据覆盖旧数据。这是实现增量更新的基石。4.3 增量更新思路用数据库主键替代全量重跑全量爬取一次之后以后每次跑脚本其实不需要再把所有历史数据重新抓一遍。正确的增量更新流程是从SQLite库里查出当前基金的最新净值日期。把这个日期加1天作为请求的startDate参数。请求接口时只拉这个日期之后的记录。插入时依然用INSERT OR REPLACE确保不产生脏数据。核心代码逻辑是这样的def get_latest_date_from_db(db_path, fund_code): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute(SELECT MAX(date) FROM fund_nav WHERE fund_code?, (fund_code,)) row cursor.fetchone() conn.close() return row[0] if row and row[0] else None latest get_latest_date_from_db(fund_data.db, 000001) start_date if latest: from datetime import datetime, timedelta latest_date datetime.strptime(latest, %Y-%m-%d) start_date (latest_date timedelta(days1)).strftime(%Y-%m-%d)这个思路可以用到所有带日期字段的数据爬虫项目上比如股票日线、商品价格、天气记录核心就是“记住上一次的位置下次从断点继续”。天天基金接口支持startDate参数所以不需要传分页就能直接拿增量数据更省资源。5. 常见问题与避坑实录5.1 请求频率限制与临时封IP怎么办我在实战中碰到过一个问题连续请求两三百只基金后发现返回的数据里出现了重复项代码报错说请求不到数据。排查后发现不是反爬封IP而是本机的临时网络抖动导致部分请求失败但循环没有做重试失败就断掉了。解决办法有两个。一个是把每次请求包在一个带重试的函数里最多重试3次两次重试之间sleep2秒。另一个是把已成功抓取的基金代码写入一个进度文件下次启动时跳过已经完成的基金实现断点续爬。我比较推荐写一个简单的重试装饰器import time from functools import wraps def retry(max_retries3, delay2): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: print(f第 {attempt 1} 次请求失败: {e}) time.sleep(delay) raise RuntimeError(重试次数已用完) return wrapper return decorator5.2 返回数据乱码、JSON解析失败的排查天天基金网的接口返回的编码是UTF-8理论上用resp.json()直接解析没有问题。但有的时候你会发现resp.text里出现乱码原因是requests没有正确识别编码。解决办法是在请求后手动指定编码resp.encoding utf-8这个操作一定要放在resp.text之前执行否则requests已经用错误的编码解码了。另外api.fund.eastmoney.com这个接口偶尔会在凌晨返回一段空的JSON或者一个JS错误变量代码里要加一层data.get(Data)判断避免解析None时报错。5.3 页面结构变化、接口字段调整时怎么快速定位爬虫项目最大的风险不是写不出来而是跑着跑着突然失效。天天基金网偶尔会调整前端页面接口地址或者字段名也可能跟着变。遇到这种情况我的排查步骤是打开浏览器无痕窗口访问对应页面。按F12打开Network面板清空日志。手动完成一次页面筛选操作比如切换年份。在Network列表里按XHR过滤逐个看请求找到返回数据包含目标字段的那个接口。右键Copy as cURL复制到命令行执行确认接口可用性。这个方法不只能用来排查天天基金网几乎所有网页接口的定位和问题排查都可以这么做。掌握了这个方法网站改版对你的影响就能降到最低。5.4 常见问题速查表最后整理一个速查表方便你遇到问题的时候快速定位问题现象可能原因解决方案请求返回403缺少Referer头在headers中加入Referer: http://fundf10.eastmoney.com/日期乱序未排序对FSRQ字段做升序排序Excel打开CSV乱码编码用了utf-8改用utf-8-sig保存重复数据同日多条记录用drop_duplicates或数据库主键去重半夜跑着跑着失败接口临时维护加入重试机制和断点续爬JSON解析报错接口返回了空数据用data.get(Data)做空值判断我做完这个项目后最大的体会是爬虫看着是写代码实际上更考验的是对HTTP请求过程的理解和对数据的组织能力。天天基金网这个例子接口公开、数据结构规整、反爬门槛适中是练习这些能力的一个很好的样板间。最后再分享一个我自己实际用起来很顺手的小技巧把上面这些模块拆成三个文件一个是fund_list.py负责基金列表一个是collector.py负责爬取单只基金一个是storage.py负责清洗和入库。主程序只管串联调用和日志输出。这样接口变动或者字段调整时你只需要改对应模块不需要整个脚本重写。这个项目的下一步建议可以往定时增量更新、多线程并发、净值数据可视化这几个方向扩展。说实话把整个基金市场的历史数据拉到本地之后能做的事情比你想象中要多得多。