
简介这是一份面向金融数据分析学习者与Python爬虫初学者的天天基金数据采集项目完整演示了如何抓取全市场基金列表与净值信息。压缩包仅20KB共13个文件以8个Python脚本为核心附带Jupyter Notebook分析文档、说明文档及配置文件脚本涵盖数据抓取、基金列表与信息处理等模块Notebook则便于直观查看分析与可视化流程。已有4090人学习下载适合需要批量获取基金行情、构建个人投研数据底仓的读者。项目提供从爬虫请求、页面解析到数据存储的完整代码框架包含基金代码、名称、类型及单位净值、累计净值、日增长率等关键字段的处理逻辑可帮助掌握大规模网页遍历与异常处理的实际工程技巧。1. 爬取天天基金数据从一次接口请求到全量基金信息库天天基金全量基金信息的抓取和大多数人最先想到的“翻列表页一页一页解析”不是一回事。国内公募基金数量早就超过万只逐页翻排行页不仅慢还会频繁触发列表接口的限流。反直觉的结论是天天基金在前端加载时引用了一个静态 JS 数据文件里面一次性存放了全部基金的基础信息包含基金代码、基金简称、基金类型这些核心字段。请求一次就能拿到全量基金代码和基金名之后要补规模、经理、成立日等详情字段再按代码逐条采集。这篇文章从工程师视角把这套链路完整拆开先定位数据源再给可复现的解析代码然后解决反爬、并发和断点续爬最后把全量基金信息落库并做完整性校验。适合做量化投研数据底座、基金筛选工具或数据仓库建设的从业者参考。2. 天天基金数据源拆解fundcode_search.js 接口与基金代码解析2.1 为什么选 fundcode_search.js而不是逐页翻列表天天基金有多个公开数据出口排行接口 rankhandler、基金详情页、历史净值接口以及搜索接口。如果目标是“全量基金代码、基金名、类型”最可靠的反而是东财系统的基金搜索数据文件。它是一段可以被任何语言直接解析的 JavaScript 赋值代码内容是一个超大数组每个元素代表一只基金。这个文件不需要登录、不需要分页参数请求一次就能拿到当日最新的全量列表。实际工作中抓数据的顺序应该是先看页面在浏览器里加载了哪些静态资源再决定爬取方案而不是直接对着 HTML 正则匹配。http://fund.eastmoney.com/js/fundcode_search.js这段 URL 就是天天基金搜索框的数据来源。业务端输入基金代码或拼音首字母能即时联想底层靠的也是这个文件。它内部结构形如var r [[000001,HXCZ,华夏成长混合,混合型-灵活,HUAXIACHENGZHANGHUNHE], ...]。数组中每项固定是 6 个字段顺序依次是基金代码、六位拼音缩写、基金名称、基金类型、基金全称拼音。上述字段中类型字段值得注意它是由天天基金人工维护的展现代码比如“混合型-灵活”、“股票型”、“债券型”等既能满足标题里“类型”字段的需求也可以作为后续详情的验证锚点。2.2 用 requests 拉取全部基金代码与基金名明确了接口地址下一步直接用 requests 把它拉下来。要注意编码天天基金这个文件使用 GBK 编码传输如果按默认的 UTF-8 解基金名称会直接乱码。处理方式是在response.encoding里显式指定gbk。拿到文本后数据是 JS 变量赋值结构可以先通过正则把数组部分截出来再用json.loads解析因为数组内部是标准 JSON 数组结构。完整代码如下import re import json import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Referer: http://fund.eastmoney.com/, } def get_all_fund_base(): url http://fund.eastmoney.com/js/fundcode_search.js resp requests.get(url, headersHEADERS, timeout10) resp.encoding gbk # 文件本身是 GBK 编码 text resp.text # 截取 var r [...] 中的数组部分 match re.search(rvar r (.*?);, text, re.S) raw match.group(1) data json.loads(raw) # 数组内是合法 JSON return data if __name__ __main__: funds get_all_fund_base() print(f全量基金数量: {len(funds)}) print(funds[0])这段代码中请求头里的Referer很重要。天天基金对静态数据文件虽然不设严格鉴权但请求头带主页地址能降低被 WAF 拦截的概率。响应只取文本不拿二进制因此resp.text配合resp.encoding是最稳妥的读取方式。正则var r (.*?);会把赋值语句里的数组完整截出re.S保证换行符不会中断匹配。最后用json.loads而不是eval是为了避免执行不可信的 JS 代码eval的安全风险完全没有必要在爬虫里承担。2.3 基础字段边界与全量数据质量检验上面这段代码返回的字段能够满足“基金代码、基金名、类型”这一层的全部需求。但要注意数据边界它不包含基金规模、基金经理、成立日期、跟踪指数等更细的信息。字段含义整理后如下索引字段名示例说明0fund_code000001六位基金代码唯一标识1pinyinHXCZ基金名称的拼音缩写搜索用2fund_name华夏成长混合展示用的基金全称3fund_type混合型-灵活天天基金维护分类粒度较粗4pinyin_fullHUAXIACHENGZHANGHUNHE全称拼音用于模糊匹配拿到数据后可以先做一轮质量检查。最常见的坑是列表中包含已清盘或暂停申购的基金代码为 6 位数字没问题但基金名中可能带“发起式”、“定期开放”等字样这属于名称合法信息不需要清洗。另一个要点是类型字段存在同基金多类并存的情况例如同一只基金同时出现在“混合型-灵活”和“混合型-偏股”里但这不影响基金代码级别的全量抓取。我会把解析结果总数与天天基金官网基金首页展示的总数做个对比数量误差通常只有几只差异来源是新发基金尚未同步到静态文件。这个校验思路后文还会用同样的逻辑做全量详情数据的完整性核对。3. 向全量基金信息补全按基金代码抓详情页并解析类型、规模、经理3.1 详情页结构与接口的取舍fundcode_search.js能解决的是“全量基础信息”。要拿到基金规模、基金经理、成立日期、资产配置这些更完整的字段就必须以基金代码为入参访问每一只基金的详情页。天天基金详情页地址规则非常规整http://fund.eastmoney.com/{基金代码}.html。页面里“基金档案”部分保留了服务端渲染的表格数据直接请求 HTML 就能拿到不需要等待浏览器执行脚本这给静态爬虫留出了操作空间。另一种常见方案是调天天基金的对外行情接口比如http://fund.eastmoney.com/pingzhongdata/{基金代码}.js这个文件返回的是近一年收益率曲线、规模变动等加密字段解析成本反而比 HTML 高。所以我一般会优先用详情页解析关键字段只有当页面结构调整导致解析失败时再降级到数据接口。3.2 基于基金代码采集详情字段的代码实现详情页里“基金概况”区域有一张表格每一行左边是标签右边是值结构很适合键值对提取。用 BeautifulSoup 解析时先定位包含“基金类型”“成立时间”“基金管理人”“基金规模”等文本的单元格再取兄弟节点中的值。import requests from bs4 import BeautifulSoup def parse_fund_detail(fund_code: str) - dict: url fhttp://fund.eastmoney.com/{fund_code}.html resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) # 基金概况信息集中在 idfundDetail 的区域内 detail {} area soup.select_one(.infoOfFund) if not area: return detail rows area.select(tr) for row in rows: tds row.select(td) if len(tds) ! 2: continue label tds[0].get_text(stripTrue) value tds[1].get_text(stripTrue) if label 基金类型: detail[fund_type] value elif label 成立时间: detail[found_date] value elif label 基金管理人: detail[manager_name] value elif 基金规模 in label: detail[scale] value return detail code 000001 print(parse_fund_detail(code))这段逻辑的关键是.infoOfFund这个选择器它圈定了基金概况表格所在的区域。天天基金详情页里该区域长期保持“一行两列”的结构左侧标签右侧值因此len(tds) ! 2的行直接跳过能避开表格中可能出现的长文本行。标签匹配用了“包含”逻辑是因为“基金规模”后面常跟着括号说明比如“基金规模亿元”直接等值匹配容易漏。字段解析结果是一个字典后续可以合并到全量表里。整个方案的耗时在于需要循环上万次请求所以下一节必须解决并发问题。3.3 并发采集与必调参数线程数、超时与限速上万只基金串行抓取每只请求耗时 0.3 到 0.8 秒全部跑完需要一两个小时。这种场景用ThreadPoolExecutor做并发是最直接的方式它比 asyncio 更容易理解和排错。并发设计没有绝对最优天天基金详情页对并发量的容忍度有限我一般把线程数压到 8 到 12 之间单只请求超时设为 15 秒同一线程内相邻请求间隔 0.2 到 0.5 秒。参数配置如下表参数建议值说明max_workers8~12并发太高会触发 503太低则速度太慢timeout15 秒TCP 与读取超时统一设 15避免卡死线程request_interval0.2~0.5 秒每线程内请求间隔降低瞬时 QPSretry_times3失败重试 3 次仍失败则写入失败列表from concurrent.futures import ThreadPoolExecutor, as_completed def crawl_with_pool(fund_codes, max_workers8): results {} with ThreadPoolExecutor(max_workersmax_workers) as pool: future_map { pool.submit(parse_fund_detail, code): code for code in fund_codes } for future in as_completed(future_map): code future_map[future] try: results[code] future.result() except Exception as e: print(f[{code}] 抓取失败: {e}) return resultsThreadPoolExecutor的submit会将每个基金代码提交到线程池返回的future对象在任务完成后会保存结果或异常。as_completed按完成顺序迭代不需要等待最早的请求返回。外层捕获异常是为了避免单只基金失败时拖垮整个任务。写结果时这里直接用普通字典是因为results[code] ...在 CPython 中对单条赋值操作具备原子性实际工程中如果结果结构更复杂建议换threading.Lock加锁保护。并发只是第一步接下来要处理的是请求频率上来之后的反爬响应。4. 天天基金爬虫稳定性设计请求头、指数退避与断点续爬4.1 天天基金常见反爬特征与快速判别方法并发跑起来后最先遇到的是反爬响应。天天基金服务和大多数金融数据站一样不会直接封 IP而是用较低阈值的限流响应呈现为三种形态HTTP 503、返回一个验证页、接口正常返回但列表为空。实战中判断方法很简单详情页 HTML 长度在 30KB 以下的可以直接视为异常因为正常基金详情页至少包含完整概况表和净值走势图。从 fetch 到页面后先打印len(resp.text)如果数值异常小检查响应内容里是否存在“访问过于频繁”字样。这类限流的恢复时间通常是几十秒到几分钟因此设计重试机制时必须使用退避策略而不是立即重新请求。4.2 模块化请求函数User-Agent 轮换与指数退避工程上我不建议在各个解析函数里各写一套 try-except统一封装一个带重试的请求函数会更省事。要点是让每次重试的等待时间按指数增长第一次失败等 0.5 秒第二次等 1 秒第三次等 2 秒同时每次换一个 User-Agent减少被连续识别为同一客户端的概率。import time import random import requests UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/121.0.0.0 Safari/537.36, ] def request_with_retry(url, retries3, backoff_factor0.5, timeout15): for attempt in range(retries): headers { User-Agent: random.choice(UA_POOL), Referer: http://fund.eastmoney.com/, } try: resp requests.get(url, headersheaders, timeouttimeout) if resp.status_code 200 and len(resp.text) 30_000: return resp if resp.status_code 503: wait backoff_factor * (2 ** attempt) random.uniform(0, 0.5) time.sleep(wait) except requests.RequestException: wait backoff_factor * (2 ** attempt) time.sleep(wait) return None上面代码最需要关注的参数是backoff_factor。它控制重试等待时间的基础长度配合2 ** attempt指数增长。加随机抖动的原因是避免多个线程同时失败后在同一时间点重试造成次生限流。等待时间的公式是第 1 次失败 0.5 秒第 2 次 1 秒第 3 次 2 秒并各自叠加随机数。len(resp.text) 30_000是判断详情页是否有效的经验值如果请求的是接口型数据这个阈值需要自行调整为更小的值。注意这里没有做代理池配置纯靠降频和退避已经能满足万只基金量级的日常采集需求。4.3 断点续爬基于已完成基金代码集合去重抓取万只基金时中途断网、宿主机重启、代码异常退出几乎无法避免。全量重来成本太高“已完成集合”是解决这个问题的核心手段。实现思路每成功解析一只基金就把它的代码追加到本地文本文件done.txt启动时读取这个文件把已完成的代码从待抓列表里剔除。这样中断后重新执行程序直接跳过已有记录。import os DONE_FILE done.txt def load_done_codes(): if not os.path.exists(DONE_FILE): return set() with open(DONE_FILE, r, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) def save_done_code(code): with open(DONE_FILE, a, encodingutf-8) as f: f.write(code \n) all_codes [item[0] for item in get_all_fund_base()] done_codes load_done_codes() todo_codes [code for code in all_codes if code not in done_codes] for code in todo_codes: detail parse_fund_detail(code) if detail: save_done_code(code)断点续爬要配合线程锁使用两个线程同时save_done_code会出现文件写入交错但代码写在一行内且带换行符实际损坏概率很低。更稳妥的做法是最后统一合并结果而不是每只基金都追加一次文件。当前实现适合单线程或低并发场景如果使用前面的ThreadPoolExecutor建议把done_codes改成内存中的set在任务完成后集中合并。把断点续爬放在全量信息采集里核心价值是节省另一半基金的重复请求时间也便于审计哪些接口还没跑到。5. 全量基金信息落地与验证入库后如何做完整性校验和查询5.1 存储为 CSV 与 SQLite数据表设计全量基金详情采集完成后需要把内存里的results落到磁盘。常见做法是同时保留 CSV 和 SQLite 两份文件CSV 方便随时用 Pandas 载入做分析SQLite 便于跨进程查询和增量更新。SQLite 表结构设计要考虑唯一约束以基金代码为主键天然保证全量数据二次写入时不会产生重复行。import sqlite3 DB_FILE fund.db def init_db(): conn sqlite3.connect(DB_FILE) conn.execute( CREATE TABLE IF NOT EXISTS fund_info ( fund_code TEXT PRIMARY KEY, fund_name TEXT, fund_type TEXT, scale TEXT, manager_name TEXT, found_date TEXT, pinyin TEXT ) ) conn.commit() return conn def insert_fund(conn, item): conn.execute( INSERT OR REPLACE INTO fund_info (fund_code, fund_name, fund_type, scale, manager_name, found_date, pinyin) VALUES (?, ?, ?, ?, ?, ?, ?), (item[0], item[2], item[3], item.get(scale), item.get(manager_name), item.get(found_date), item[1]) ) conn.commit()fund_info表中前四个字段来自fundcode_search.js后三个来自详情页解析结果。INSERT OR REPLACE保证重复执行时以最新抓取结果覆盖旧数据适合每日增量更新的场景。SQLite 在单机百万行以内性能完全够用不需要引入 MySQL。字段类型全部设为TEXT是为了兼容基金规模里“12.50 亿元2024-12-31”这类带单位的字符串后续做数值计算时再统一清洗。5.2 完整性校验基金代码数量对比与缺失字段检查数据入库后需要验证“全量”这个目标到底完成了没有。校验从两个维度展开一是数量维度SQLite 中的基金代码数量与fundcode_search.js返回的全量数量对比二是字段维度统计fund_type、scale、manager_name等字段的空值率。缺失多的字段需要回到对应解析逻辑里修补。-- 统计入库总量 SELECT COUNT(DISTINCT fund_code) FROM fund_info; -- 统计各字段缺失情况 SELECT COUNT(*) AS total, SUM(CASE WHEN fund_type IS NULL OR fund_type THEN 1 ELSE 0 END) AS missing_type, SUM(CASE WHEN scale IS NULL OR scale THEN 1 ELSE 0 END) AS missing_scale, SUM(CASE WHEN manager_name IS NULL OR manager_name THEN 1 ELSE 0 END) AS missing_manager FROM fund_info;数量差异通常来自两种来源当天新发基金尚未更新到搜索文件或者部分已清盘基金页面已下线。缺失字段的补偿办法是抓取备用的fund.eastmoney.com/pingzhongdata/{code}.js文件其中包含近一年规模和经理数据。校验完成后建议把缺失代码列表单独导出下一轮只抓这些尾巴。5.3 用 SQL 快速构建基金池一个可直接执行的筛选示例数据入库之后最直接的应用是构建不同类型的基金池。比如要筛出天天基金分类下的全部股票型基金直接执行一条 SQL 就能完成这比在 Excel 里反复筛选高效得多。SELECT fund_code, fund_name, scale, manager_name FROM fund_info WHERE fund_type LIKE %股票% AND scale IS NOT NULL ORDER BY scale DESC LIMIT 20;这条 SQL 里的LIKE %股票%能同时匹配“股票型”、“股票型-被动指数型”等细分类型比等值匹配更符合天天基金类型字段的实际粒度。按scale DESC排序可以发现市场上规模较大的股票型基金用于构建观察列表。若后续从净值接口补充近一年收益率字段到fund_info表筛选条件还可以进一步升级成“规模大于 10 亿且近一年收益为正”那么这套全量基金信息库就从一个爬虫产出物变成了可持续使用的投研数据筛选工具。本文还有配套的精品资源点击获取