天天基金全量数据爬虫实战:接口解析、并发限速与增量更新

天天基金全量数据爬虫实战:接口解析、并发限速与增量更新 简介面向基金数据分析与网络爬虫学习者的实用项目源码包聚焦天天基金网全量基金数据抓取可自动获取基金代码、基金名称、基金类型并按指定日期采集单位净值、累计净值和日增长率等核心指标适合需要批量整理基金行情或研究爬虫技术的开发人员参考。压缩包内含13个文件以8个Python脚本为主配套1个Jupyter Notebook用于数据分析另有需求说明、README、LICENSE及配置文件整体仅20KB代码轻量、结构清晰已有4090人浏览学习兼具实用价值与教学意义。通过源码可以了解从网页请求、HTML解析到数据清洗存储的完整爬虫流程同时项目还涉及基金策略分析模块便于在此基础上扩展回测或筛选功能是入门基金数据采集与量化分析的理想参考无论直接用其抓取全量基金数据还是拆解代码学习爬虫设计均可获得清晰指引。1. 天天基金全量数据是刚需但别从页面硬抠做基金研究或者搭建投顾工具的人大概率会遇到同一个问题——想要全量基金数据做筛选、回测或者榜单但天天基金官网一次只展示一页手工导出又不现实。反直觉的是天天基金真正的全量数据并不是靠翻页面爬到的它的搜索结果页在网页加载时会一次性带出所有基金代码、基金名称与类型只是浏览器把这些内容藏在了 JavaScript 变量里。这篇文章就顺着这个思路把天天基金全量基金信息的采集路径、解析方法、并发限速和增量更新的完整做法讲清楚。适合有 Python 基础、想快速拿到一份干净基金底表的工程师也适合刚接触爬虫实战、想理解网页接口分析思路的入门读者。2. 先拆解天天基金的数据接口再动手写爬虫天天基金的页面更多是服务端渲染出来的骨架真正的数据靠浏览器在加载过程中异步请求回来。直接用 requests 请求基金列表页 URL拿到的 HTML 里并没有基金明细。所以第一步不是写解析代码而是打开开发者工具把数据链路摸清楚。2.1 天天基金三个数据接口各管什么抓天天基金全量数据核心依赖三个接口。它们分别承担初始全量、日频行情、单基金档案三种职责频率不同解析方式也不同。接口返回格式适用频率用途/js/fundcode_search.jsJavaScript 变量var r [...]每周一次全量基金代码、名称、类型、全称/data/rankhandler.aspxHTML 包裹的 JSON 数组每日一次全市场基金净值排行与实时估值/pingzhongdata/{code}.jsJavaScript 变量定义按需/低频单只基金净值走势、阶段涨幅、资产配置fundcode_search.js是整个全量数据的基础一次性返回一万六千多只基金的代码、拼音缩写、基金简称、基金类型、基金全称。rankhandler.aspx是每日更新净值数据的入口支持按类型过滤和分页。pingzhongdata则是单只基金的详情档案适合在需要基金经理、规模、持仓等信息时单独抓取。2.2 解析 fundcode_search.js 的两个注意点这个 JS 文件表面是个 JavaScript 文件实际上就是一个 JSON 数组的赋值语句。常见写法是请求后取响应文本从第一个[开始截取到最后一个]再用json.loads解析。这里有两个坑。第一个坑是编码。该文件是 GBK 编码直接resp.text拿到的是乱码。必须显式设置resp.encoding gbk或者用resp.content.decode(gbk, errorsignore)。第二个坑是数组结尾不能用text.find(])去截数组内的基金类型或全称字段中可能混入]字符要从右侧用rfind找结尾才稳妥。2.3 rankhandler 接口的常用参数表每日增量更新主要走rankhandler.aspx。这个接口参数多但稳定以下是一份亲测可用且长期有效的参数组合参数取值含义opph请求排行榜数据dtkf数据源为开放式基金ftall/gp/zs/zq基金类型过滤all表示全部rs空附加筛选条件留空即可sc1nzf按近一年涨幅排序stdesc降序排列pi1页码pn5000每页数量最大可按万设置dx1是否包含非开放式基金返回内容里包含当天所有基金的净值数据一次请求基本能覆盖全市场。把pn设大是为了减少翻页次数但响应体积也会变大实测 5000 条一页是比较平衡的取值。3. 用 Python 抓取基金代码、基金名与档案详情接口定位清楚了动手写代码就顺理成章。这一章会给出全量列表抓取、单基金档案解析以及入库三部分代码全部基于 requests、json、re 和 sqlite3 四个标准库实现不需要引入重量级爬虫框架。3.1 抓全量基金列表的 requests 脚本import requests import json HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: http://fund.eastmoney.com/ } def fetch_all_funds(): url http://fund.eastmoney.com/js/fundcode_search.js resp requests.get(url, headersHEADERS, timeout10) resp.encoding gbk # 关键该 JS 文件为 GBK 编码 text resp.text arr_start text.find([) arr_end text.rfind(]) 1 raw json.loads(text[arr_start:arr_end]) funds [] for row in raw: # 字段顺序固定为: [代码, 拼音缩写, 基金名称, 类型, 全称] funds.append({ code: row[0], pinyin: row[1], name: row[2], type: row[3], full_name: row[4], }) return funds逻辑说明代码先把响应编码强制设为 GBK拿到文本后用find定位数组起点、rfind定位数组终点再做一次 JSON 解析。row内部字段顺序是天天基金前端固定的五年多来没有变过。返回的funds列表后续可以交给 SQLite 或 Pandas 做进一步处理。参数说明timeout10防止个别网络状况下请求长时间挂起Referer字段带上这个值可以应对一部分服务端的来源校验实测不带也能通但加上更稳。3.2 单基金档案 JS 的正则解析每只基金都有一个对应的pingzhongdata/{code}.js文件里面是一堆var开头的 JS 变量定义包含名称、代码、净值趋势、阶段涨幅等字段。由于它不是纯 JSON用正则提取再转 JSON 是常见的做法。import re import json def fetch_fund_detail(code): url fhttp://fund.eastmoney.com/pingzhongdata/{code}.js resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 text resp.text detail {code: code} name re.search(rvar fS_name (.*?);, text) detail[name] name.group(1) if name else None syl_1n re.search(rvar syl_1n (.*?);, text) detail[syl_1n] syl_1n.group(1) if syl_1n else None trend re.search(rvar Data_netWorthTrend (\[.*?\]);, text, re.S) detail[nav_trend] json.loads(trend.group(1)) if trend else [] return detail逻辑说明fS_name存的是基金正式名称syl_1n是近一年收益率字符串Data_netWorthTrend是一个数组每一个元素包含当天的净值、涨跌幅和时间戳。注意syl_1n在基金成立不足一年时不存在所以要用条件判断兜底。参数说明Data_netWorthTrend的正则用了re.S标志让.可以匹配换行符防止数组被拆成多行后提取失败。json.loads把 JavaScript 数组原样解析为 Python 的 list后续算区间收益时直接用这个字段。3.3 用 SQLite 承载全量基金信息基金数据量不算大但字段较多SQLite 足够胜任。建两张表一张存静态档案一张存每日净值。静态表用基金代码做主键每日净值表用代码加日期做联合主键天然支持去重。CREATE TABLE IF NOT EXISTS fund_basic ( code TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, full_name TEXT, pinyin TEXT, update_date TEXT DEFAULT (date(now)) ); CREATE TABLE IF NOT EXISTS fund_daily ( code TEXT NOT NULL, trade_date TEXT NOT NULL, unit_nav REAL, acc_nav REAL, daily_growth REAL, PRIMARY KEY (code, trade_date) );写入时用executemany批量插入避免循环单条提交的低效写法。import sqlite3 db sqlite3.connect(fund.db) db.executemany( INSERT OR REPLACE INTO fund_basic(code, name, type, full_name, pinyin) VALUES(:code, :name, :type, :full_name, :pinyin), funds ) db.commit()逻辑说明INSERT OR REPLACE在遇到重复主键时会直接覆盖旧记录全量更新时不需要先删表重复执行脚本是幂等的。update_date字段有默认值写入时不需要显式传。表结构设计上fund_daily的联合主键保证了同一个基金同一天最多一条记录后续增量更新只要正常走插入逻辑就不会攒重复数据。4. 天天基金爬虫的并发限速与增量更新设计全量列表大约一万六千多只基金如果只抓基础列表单线程几秒钟就能完成。但要把每只基金的档案页都拉一遍串行跑至少二三十分钟这时就需要引入并发设计。另一个实际问题是数据每天都在变需要让爬虫具备增量更新能力而不是每天全量重跑。4.1 并发设计选协程还是线程池爬虫界的经典争论是协程和线程池到底哪个好。真实场景下天天基金这种单站点的数据采集瓶颈在 IO 等待而非 CPU 计算协程和线程池都能跑满带宽差别不大。线程池代码可读性更好内置在concurrent.futures里对 Python 爬虫入门阶段的读者也更友好因此多数场景我会选线程池。方案优势风险推荐场景顺序执行代码最简单日志清晰速度慢测试脚本、小批量抓取线程池 ThreadPoolExecutor并发可控好调试线程切换有开销单站点全量采集协程 asyncio aiohttp并发上限高资源占用低异步代码改造成本大图像/文件类大流量下载进程池 ProcessPoolExecutor绕开 GIL内存开销大解析计算密集的任务天天基金全量数据这个场景8 到 16 个线程是舒适区。线程太少速度上不去线程太多会拉高对方服务器的限频命中的概率。4.2 ThreadPoolExecutor 加指数退避重试from concurrent.futures import ThreadPoolExecutor, as_completed import random import time def worker(code): for attempt in range(3): try: time.sleep(random.uniform(0.3, 0.8)) detail fetch_fund_detail(code) return detail except Exception: wait 2 ** attempt time.sleep(wait) return None def crawl_all(codes, max_workers8): results [] with ThreadPoolExecutor(max_workersmax_workers) as pool: futures [pool.submit(worker, code) for code in codes] for idx, future in enumerate(as_completed(futures), 1): item future.result() if item: results.append(item) if idx % 200 0: db.commit() db.commit() return results逻辑说明每个worker内部先随机睡 0.3 到 0.8 秒再发起请求作用是把请求时间点打散避免 8 个线程同时踩到一个节拍上。请求失败后按2 ** attempt指数退避重试第一次失败等 1 秒第二次失败等 2 秒第三次放弃返回None。参数说明max_workers8是经验值适用于基金详情这类轻量接口。如果网络环境差可以降到 4如果确认对方接口没有严格限频可以提高到 16。idx % 200 0时做一次增量提交防止长时间运行的内存数据丢失。4.3 用 rankhandler 做按日增量更新全量基础数据一周更新一次足够净值数据必须每天收盘后更新。增量更新不依赖fundcode_search.js而是直接请求rankhandler.aspx一次拿到全市场当日行情。from datetime import datetime def fetch_daily_rank(pn5000): url http://fund.eastmoney.com/data/rankhandler.aspx params { op: ph, dt: kf, ft: all, rs: , gs: 0, sc: 1nzf, st: desc, pi: 1, pn: str(pn), dx: 1, v: datetime.now().strftime(%Y%m%d%H%M%S), } resp requests.get(url, paramsparams, headersHEADERS, timeout15) resp.encoding utf-8 text resp.text arr_text re.search(r\[.*\], text, re.S).group() return json.loads(arr_text)逻辑说明rankhandler.aspx返回内容是 HTML 与 JSON 混排数组里每个元素依次是基金代码、基金简称、日期、单位净值、累计净值、日增长率等字段。用re.search加re.S提取整个 JSON 数组然后写入fund_daily表。参数说明pn5000表示每页 5000 条全市场基金基本一页能装下。dtkf表示只取开放式基金避免混入场内 ETF 和 LOF 的实时行情字段。dx1是包含非开放式基金的开关按需调整。4.4 断点续抓与失败补偿长时间运行的爬虫最大的敌人不是反爬而是中途崩溃。断点续抓的实现思路非常简单每次任务开始前先从数据库里查出已抓取过的基金代码集合把全量列表过滤掉这部分剩下就是待抓列表。existing set(r[0] for r in db.execute(SELECT code FROM fund_basic)) todo [f[code] for f in funds if f[code] not in existing]逻辑说明这段代码把全量列表funds和已入库的existing做差集得到todo。脚本中断后重新执行由于已抓过的基金不会重复进入任务队列天然支持断点续跑不需要额外记录进度文件。失败补偿则依赖worker里的三次重试逻辑。对于重试后仍然失败的基金常见做法是单独记录到一个error_codes表或日志文件全部任务跑完后再对这部分做一轮串行补抓。天天基金全量数据场景下失败率通常低于千分之一单轮补抓即可收敛。5. 用基金净值序列计算区间收益反查爬取质量爬下来的数据必须验证质量否则前面的工作全都白费。这里分享一个实用技巧用已有的Data_netWorthTrend净值序列直接计算区间收益和最大回撤既能做基金筛选又能反过来检验数据完整性。import pandas as pd df pd.DataFrame(detail[nav_trend]) df[trade_date] pd.to_datetime(df[x], unitms) df[acc_nav] df[y].astype(float) df df.sort_values(trade_date).reset_index(dropTrue) period_start df.loc[df[trade_date] 2024-01-01, acc_nav].iloc[0] period_end df[acc_nav].iloc[-1] period_return period_end / period_start - 1 df[cummax] df[acc_nav].cummax() df[drawdown] df[acc_nav] / df[cummax] - 1 max_drawdown df[drawdown].min()逻辑说明Data_netWorthTrend里的x是毫秒时间戳y是该基金当天累计净值。把时间戳转成日期后排序取区间首尾净值计算收益率cummax逐日累计历史高点净值相对高点的回撤最小值就是区间最大回撤。把这段逻辑封装成函数对全量基金循环一遍就能得到一份带区间收益与回撤的筛选表。顺带验证数据质量也很自然如果某只基金在区间起始日没有净值记录说明增量更新存在漏抓需要回去补数据如果收益计算结果明显偏离常识则优先怀疑净值序列的缺失或错位。验证方法可以这样操作先抽查十只知名基金将计算结果与天天基金页面上展示的区间涨幅比对一致说明抓取链路可靠然后把全市场基金按区间收益排序按最大回撤过滤就能得到一个初筛后的候选池。把这个脚本挂到定时任务里每天 15:30 收盘后自动执行你就能持续获得一份质量可控的全市场基金跟踪表。本文还有配套的精品资源点击获取