简介:Python量化金融系列课程中的一讲,聚焦典型爬虫程序的实现,面向金融数据分析、量化投资及Python爬虫初学者,旨在帮助读者理解从网页发起请求到数据落地的完整链路。资源为单独1个PDF文件,共13页,压缩包大小约5.03MB,内容紧凑,适合作为爬虫主题的专题讲义。课程采用三分法结构:首先介绍爬虫程序的基本组成与静态页面爬取步骤,包括通过HTTP库发送请求、接收响应、解析HTML并保存数据;随后重点讲解正则表达式与Python Re模块的安装、匹配、替换等常用操作,并通过手机号校验、百度贴吧图片爬取等实例演示如何从HTML中筛选目标信息,同时对比BeautifulSoup解析库在元素查找与遍历上的便捷之处;最后引入Pandas模块,结合DataFrame和Series结构,演示read_excel、to_excel、groupby等操作,用于爬取数据的存储、汇总与分组统计。学完本讲即可独立实现小型静态网页爬虫,完成数据采集到初步分析的完整流程,为后续爬虫进阶与金融建模打下基础。目前已有156人学习浏览,适合需要快速补充爬虫实操能力的量化金融学习者。
1. 典型爬虫程序,是量化金融数据管线的第一道闸门
Python 量化金融里最容易被低估的环节,是数据从哪来。策略回测、因子计算、风险归因,都建立在干净、连续、可追溯的行情数据之上。人工去行情网站下载 Excel 再导入,一两次可以,股票超过一百只、或者要换分钟级频率时,这条路就走不通了。这也是"典型爬虫程序的实现"会被排在 Python 金融实务与数据分析课程量化模块前面的原因。这里只按一线工程师做这件事的通用路径梳理:先用 requests 抓 JSON 接口,动态渲染拿不到数据再上 Playwright,最后补上限速、去重和定时调度。新手能照着写出第一个可用的金融爬虫,熟手能对照检查自己的数据管线缺了哪一环。
2. Python 典型爬虫从 requests 起步:选型与金融数据源约束
动手写代码之前先解决两个问题:用什么工具抓,以及按什么规矩抓。这两个问题想清楚,后面的代码只是体力活。课程 3.2 带着 (1) 的后缀,意味着这一讲只解决最小闭环:单接口、单标的、能落盘。这份材料虽然挂名在金融实务与数据分析下面,也可以当一份 python 爬虫教程来读,但它比通用教程多了一条主线:所有代码都在为后续的金融量化分析备数据。数据口径不统一、请求被限流、字段类型错乱,任何一环出错,都会在回测阶段被放大成错误信号。
2.1 为什么典型爬虫用 requests 而不是直接上 Scrapy
先澄清一个常见误用:很多人一写爬虫就想到 Scrapy,但对于量化金融的日常数据需求,requests 才是更常见的主力,这一点在大量爬虫案例里都能看到。requests 是一个 HTTP 客户端库,定位是把请求发出去、把响应拿回来,代码写起来是顺序执行的几行,出问题可以直接在 Jupyter 里单步调试。金融数据抓取的大多数场景是"带参数的 GET 请求加 JSON 响应",requests 恰好覆盖这个范围,不需要框架层的调度器、下载中间件和 Item Pipeline。
Scrapy 是经典的 scraping 爬虫框架,优势在分布式抓取、自动去重、限速中间件和管道化存储。但当目标是"每天收盘后抓一次全市场日线、写进本地 CSV 或数据库"时,Scrapy 的工程成本明显偏高:要建项目结构、写 spider、配 settings,调试链路也比 requests 长。课程 3.2 定位在典型爬虫程序的实现,从 requests 讲起是最合理的路径,13 页篇幅也正好够把一个请求、解析、落盘的完整流程讲透。
2.1.1 requests、Scrapy 与 Playwright 的适用边界
三条路线的选型依据可以归纳成一张表。判断标准不是"哪个更强",而是"哪个更贴合当前数据量"。
| 方案 | 典型场景 | 上手成本 | 配套要求 |
|---|---|---|---|
| requests + BeautifulSoup | 静态页面、JSON 接口、中小规模 | 低 | 重试与限速逻辑自己写 |
| Scrapy | 多站点、大规模、管道化存储 | 中 | 项目结构、中间件配置 |
| Playwright | 动态渲染、需模拟点击与登录 | 中 | 浏览器内核、选择器调试 |
提示:量级在几十到几百个标的、一天一次,requests 就是最优解。出现上千个 URL 要并发、或者要按站点维护抓取策略时,再迁移 Scrapy。
2.2 金融数据源的三条硬约束:robots 规则、请求频率与字段口径
金融网站与普通内容站不同,数据实时性和准确性直接影响交易决策,源站对爬虫的容忍度更低。写代码之前先确认三件事,这也是金融量化数据采集和通用爬虫教程最不一样的地方。
第一,robots 规则。在浏览器打开目标站点的 robots.txt,看 User-agent 和 Disallow 规则。它约束哪些路径不该抓,例如很多行情站的公开行情接口路径可以访问,而登录、账户相关路径通常被明确禁止。遵守 robots.txt 是行业惯例,也能让你避开一开始就走错方向。
第二,请求频率。行情接口一般都有单 IP 限频,常见阈值从每秒几次到每分钟几十次不等。安全做法是单线程、每次请求间隔 0.5 到 2 秒。不要为了抓几千条记录开 50 个线程,被限流后拿到的全是错误码。限频问题在量化场景里特别致命,因为你会把 429 响应误当成数据缺失写进库里。
第三,字段口径。复权方式、涨跌幅计算基准、成交量单位,不同数据源并不一致。爬虫层要保留原始字段、不做换算,把口径统一放到数据处理阶段。这样即使以后换数据源,重放爬虫结果也不会丢信息。
2.3 第一个 GET 请求的写法:请求头、参数与超时
典型爬虫的第一个可运行版本,通常是请求一个公开 JSON 接口。下面以行情站点公开 K 线接口的常见写法为例,说明结构。注意接口地址要以你实际抓包结果为准,不同站点的路径和参数规则差异很大,这里的域名只是示例。
import requests url = "https://push2his.eastmoney.com/api/qt/stock/kline/get" params = { "secid": "1.600519", # 1 表示沪市,600519 为贵州茅台 "klt": "101", # 101 日K,102 周K,103 月K "fqt": "1", # 复权方式:1 前复权,2 后复权,0 不复权 "beg": "20240101", # 起始日期 "end": "20241231", # 结束日期 "fields1": "f1,f2,f3,f4,f5,f6", "fields2": "f51,f52,f53,f54,f55,f56,f57,f58", } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://quote.eastmoney.com/", } resp = requests.get(url, params=params, headers=headers, timeout=(3, 10)) print(resp.status_code) print(resp.url) # 打印编码后的完整地址,方便核对参数 data = resp.json() print(data.keys())这段代码里有三个关键习惯。其一,URL 不拼字符串,用 params 字典交给 requests 编码,中文和特殊字符不会被转义错。其二,User-Agent 和 Referer 是源站识别客户端的主要字段,Referer 填页面对应的路径,比空着更接近真实浏览器行为。其三,timeout 传元组 (3, 10),含义是连接超过 3 秒、读取超过 10 秒就放弃;不设 timeout 的爬虫会在网络异常时无限期卡住。
提示:resp.url 打印出来的是编码后的真实请求地址。排错时把它贴到浏览器地址栏,如果浏览器能返回 JSON,说明问题在代码;如果浏览器也报错,说明参数或接口已更新。
K 线相关的量化参数,这里给出一组常用取值,后面写回测数据准备脚本时会反复用到。
| 参数 | 取值示例 | 含义 | 量化场景常用值 |
|---|---|---|---|
| secid | 1.600519 / 0.000001 | 市场前缀加证券代码,1 沪 0 深 | 按标的切换 |
| klt | 101 / 102 / 103 | K 线周期 | 日线回测用 101 |
| fqt | 0 / 1 / 2 | 复权方式 | 因子计算常用 1 |
| beg / end | 20240101 | 起止日期 | 对齐回测区间 |
3. requests 抓取金融行情接口:解析、重试与 DataFrame 落盘
这一章把第二章的单次请求扩展成可复用的数据采集函数,是整个典型爬虫程序的核心。请求层只负责拿数据,解析层负责把字符串变成结构化字段,落盘层负责把 DataFrame 存成后续回测直接可读的格式。三步分开写,哪一步出问题都容易定位。
3.1 在开发者工具里定位真实数据接口
拿到一个新数据源,不要急着写代码。打开行情页面,按 F12 进入开发者工具,切到 Network 面板,勾选 Fetch/XHR,然后刷新页面。凡是返回 JSON 的请求,都是潜在爬虫目标。逐个点击看 Preview 里的数据结构,找到包含时间、开高低收、成交量、成交额的字段组。
判断依据很直接,看 Network 面板的表现就能确定处理方式。
| Network 面板表现 | 数据真实来源 | 处理方式 |
|---|---|---|
| 有 XHR 请求,响应是 JSON | 后端数据接口 | requests 直连 |
| 只有 HTML 文档,数据在表格标签里 | 服务端渲染 | 解析 HTML |
| 无数据类请求,刷新后有 JS 执行 | 前端异步渲染 | 交给 Playwright |
找到目标接口后,在 Network 里复制 Query String Parameters,按 2.3 的方式填进 params。这里有个容易被新手忽略的细节:接口参数里的日期格式、字段顺序是后端约定的,复制时一个都不能少,多了也可能报错。排错时可以用 curl 快速验证接口是否可用:
curl "https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=1.600519&klt=101&fqt=1&beg=20240101&end=20241231" \ -H "User-Agent: Mozilla/5.0" | head -c 500head -c 500 只截取前 500 字节,避免终端被长 JSON 刷屏。curl 能返回数据,说明接口路径与参数没有问题,剩下的就是写 Python 代码去包装它。
3.2 完整函数实现:请求、解析、类型转换与 CSV 落盘
import pandas as pd import requests def fetch_kline(secid: str, begin: str, end: str) -> pd.DataFrame: url = "https://push2his.eastmoney.com/api/qt/stock/kline/get" params = { "secid": secid, "klt": "101", "fqt": "1", "beg": begin, "end": end, "fields1": "f1,f2,f3,f4,f5,f6", "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61", } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://quote.eastmoney.com/", } resp = requests.get(url, params=params, headers=headers, timeout=(3, 10)) resp.raise_for_status() payload = resp.json() if not payload.get("data") or "klines" not in payload["data"]: return pd.DataFrame() rows = [line.split(",") for line in payload["data"]["klines"]] df = pd.DataFrame(rows, columns=[ "date", "open", "close", "high", "low", "volume", "amount", "amplitude", "pct_chg", "chg", "turnover", ]) for col in ["open", "close", "high", "low", "volume", "amount"]: df[col] = pd.to_numeric(df[col], errors="coerce") return df if __name__ == "__main__": df = fetch_kline("1.600519", "20240101", "20241231") print(df.head()) df.to_csv("600519_daily.csv", index=False)逻辑说明:接口返回的 klines 是字符串列表,每个元素是逗号分隔的一行数据,顺序是日期、开、收、高、低、成交量、成交额、振幅、涨跌幅、涨跌额、换手率,所以先用 split 切分,再按字段顺序命名列。pd.to_numeric 配合 errors="coerce" 把不能转成数值的内容变成 NaN,避免单条脏数据让整列变成 object 类型,后续算收益率、波动率时会省掉大量麻烦。resp.raise_for_status() 在 HTTP 状态码非 2xx 时抛异常,把它留在函数内部而不是吞掉,是为了让上层重试逻辑能感知失败。
参数说明:这里把 klt 和 fqt 固定成日线前复权,是因为回测阶段要求全市场口径一致。如果做分钟级策略,把 klt 改成 "1" 或 "5" 并调整 beg/end 的跨度;分钟接口的数据量比日线大两个数量级,落盘格式建议换成 Parquet 而不是 CSV。
3.2.1 字段顺序与列名映射的关系
fields2 的顺序就是 klines 里每个元素的顺序,两者必须一一对应。这是爬虫代码里最难排查的一类问题:字段少了,解析出来的列名错位,但程序不报错,数值还是数值,只有回测结果对不上时才会被发现。规避办法是写一行断言,把列数校验放在解析后面:
assert len(rows[0]) == len(columns), f"字段数不匹配: {len(rows[0])} vs {len(columns)}"字段数对不上时立刻失败,而不是带着错位的 DataFrame 继续往下跑。这个习惯在金融数据场景里比任何功能代码都值钱。
3.3 多股票批量抓取与指数退避重试
单只股票跑通后,下一步遍历股票池。这里有两个坑:网络抖动导致偶发超时,抓太快被限流。常见做法是封装带重试的抓取函数,配合固定间隔遍历。
import time def fetch_with_retry(secid: str, begin: str, end: str, retries: int = 3) -> pd.DataFrame: for attempt in range(retries): try: return fetch_kline(secid, begin, end) except requests.RequestException as exc: print(f"[{secid}] 第 {attempt + 1} 次失败: {exc}") time.sleep(1.5 * (attempt + 1)) # 退避间隔: 1.5s, 3s, 4.5s return pd.DataFrame() secids = ["1.600519", "0.000001", "1.601318"] for secid in secids: df = fetch_with_retry(secid, "20240101", "20241231") print(secid, df.shape) if not df.empty: df.to_csv(f"{secid.split('.')[-1]}_daily.csv", index=False) time.sleep(1.2) # 每次请求之间固定间隔退避间隔按 1.5 秒乘以尝试次数递增,第一次失败等 1.5 秒,第二次等 3 秒,给限流留出恢复窗口。返回空 DataFrame 而不抛异常,是为了让批次任务可以继续,但调用方要检查 df.empty,否则空表会覆盖掉之前抓好的文件。这里的 to_csv 是覆盖写入,日常增量更新要把文件名带上日期后缀,或者先备份昨天的文件。
4. 动态页面的典型爬虫实现:Playwright 兜底与接口直连的取舍
第四章解决 requests 拿不到数据的情况。金融站点里这类场景比想象中多,Playwright 是处理动态渲染页面的主流选择,它在自动等待和选择器能力上明显优于老一代浏览器自动化方案,这也是它能在一众爬虫框架里流行起来的原因。
4.1 什么时候需要上 Playwright:三种典型特征
requests 抓不到的页面,通常具备三种特征之一。
第一种,数据不在 XHR 里。页面加载后才由 JavaScript 计算并渲染,Network 面板里只有文档和静态资源,找不到返回 JSON 的接口。第二种,接口带签名参数。请求 URL 里有 timestamp、sign、token 这类字段,由前端 JS 动态生成,裸 requests 拿不到合法响应。签名参数由前端加密逻辑生成,手动还原成本很高,与其逆向源码,不如让浏览器自己执行这段逻辑,Playwright 相比直接解码爬虫方案的优势就在这里。第三种,需要交互后才出现数据。典型是登录后才可见的数据,或者要点击翻页、切换日期区间才加载的内容。
这三种情况的共同点是:服务器只认浏览器的完整行为,不认裸 HTTP 请求。判断标准可以简化为一条:Network 面板里有没有能直接返回完整数据的 XHR,没有就切 Playwright。
4.2 Playwright 抓取动态表格的最小代码
Playwright 的优势在三个方面:自带 Chromium 内核,不需要单独安装 driver;选择器支持 CSS、XPath 和文本匹配,定位灵活;操作前会自动等待元素出现,不用手动 sleep 硬等。
from playwright.sync_api import sync_playwright def fetch_table_rows(url: str, row_selector: str, wait_selector: str) -> list: with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, timeout=30_000) page.wait_for_selector(wait_selector, timeout=15_000) rows = page.query_selector_all(row_selector) data = [] for row in rows: cells = row.query_selector_all("td") data.append([cell.inner_text().strip() for cell in cells]) browser.close() return data rows = fetch_table_rows( "https://example.com/market/summary", "tr.data-row", "tbody tr.data-row", ) print(len(rows)) print(rows[0])逻辑说明:wait_selector 是页面就绪信号,wait_for_selector 会等到元素出现或超时,比固定 sleep 更能适应网络波动。row_selector 决定抓哪些行,query_selector_all 返回元素列表,内层再取每个 tr 下的 td 单元格,inner_text() 拿文本并 strip 掉空白。整个函数返回二维列表,后续可以按行映射成 DataFrame。
4.2.1 选择器写法与 headless 调试
选择器写得太宽会把表头、分页信息抓进来,写得太窄会漏数据。调试阶段把 headless 改成 False,能看到浏览器实际执行过程,定位选择器问题比看日志快得多。定位完成后改回 True 再部署。browser.close() 必须执行,否则每次运行残留一个浏览器进程,长时间跑会耗尽内存。如果页面需要登录,在 new_page() 之后先访问登录页,用 page.fill 和 page.click 完成登录再跳转目标页,登录态由同一个浏览器上下文保持。
4.3 接口直连与浏览器兜底的切换判断
我的习惯是遵循接口优先原则,顺序固定:打开 Network 面板看有没有 XHR;有 XHR 就复制 URL 和参数用 requests 试,能通就直接用;请求通但数据不完整,检查分页和日期参数,而不是换工具;通不过再写 Playwright,并且只让 Playwright 做渲染和取数两件事,不把下载、存库逻辑塞进去。
| 现象 | 判断 | 动作 |
|---|---|---|
| XHR 能返回完整 JSON | 接口可直连 | requests 抓取 |
| XHR 返回但字段缺失 | 分页或日期参数未补全 | 补参数 |
| 无 XHR,或 URL 带签名参数 | 动态渲染 | 切 Playwright |
这样做的原因很实际:requests 单线程抓 1000 条记录几秒完成,Playwright 启动浏览器加渲染页面,一条就要一两秒。性能差一个数量级,而且浏览器实例吃内存,并发一高源站和自己都可能被压垮。能直连的接口不用浏览器,不是偷懒,是取舍。
5. 爬虫上线前的三个收尾动作:限速、校验与定时调度
功能代码跑通只是开始,真正决定爬虫能否长期运行的是收尾动作。很多爬虫跑三天就坏,不是请求代码错了,而是没限速被拉黑、没校验把脏数据写进库、没调度导致每天手动跑一次。
5.1 限速与重试参数的推荐起点
下面这组参数是我常用的起点,常规日线抓取场景足够。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| timeout | (3, 10) | 连接 3 秒、读取 10 秒,宁可超时重试,不无限等待 |
| 请求间隔 | 1.2 秒 | 单线程日线抓取,全市场五千只约两小时跑完 |
| 重试次数 | 3 | 指数退避,间隔 1.5 秒起翻倍 |
| 失败容忍度 | 5% | 单批失败超过 5% 终止任务,避免大面积失败当作空数据 |
失败容忍度值得单独说。批量抓取时如果三成股票都失败,大概率是源站限流或接口变更,继续跑只会把越来越多空表写进磁盘。在循环里计数,超过阈值就 break 并打印失败清单,比事后发现缺数据再回头补要省时间。
5.2 数据校验:日期唯一、非空、价格为正
落盘前加一个校验函数,把常见数据事故挡在库外。对金融量化数据来说,校验的重点不是字段格式,而是数据本身是否可信。
def validate(df: pd.DataFrame) -> bool: assert df["date"].is_unique, "存在重复日期" assert df[["open", "high", "low", "close"]].isnull().sum().sum() == 0, "OHLC 存在空值" assert (df["close"] > 0).all(), "收盘价存在非正值" assert (df["high"] >= df["low"]).all(), "存在最高价小于最低价的脏数据" return True断言顺序刻意安排:先查重复,再查空值,再查数值合理性。high 小于 low 的脏数据在真实行情接口里并不罕见,多见于除权除息日或接口拼接错误。这类错误不会让程序崩溃,但会让回测的收益率、回撤计算失真,而且很难追溯。校验失败时宁可不写文件,也不要覆盖上一份好数据。
5.3 定时调度:用 schedule 实现收盘后自动抓取
最后把人工执行变成定时任务,schedule 是轻量级调度库,四个函数就能跑起来。
import schedule import time import pandas as pd def daily_job(): secids = ["1.600519", "0.000001"] for secid in secids: df = fetch_with_retry(secid, "20200101", time.strftime("%Y%m%d")) if not df.empty and validate(df): df.to_csv(f"{secid.split('.')[-1]}_daily.csv", index=False) schedule.every().day.at("17:30").do(daily_job) while True: schedule.run_pending() time.sleep(60)schedule 的用法是四行:定义任务、绑定时间点、进入循环、定期检查。时间点选 17:30,因为收盘后约一小时当日数据才会齐全;抓分钟数据就把 every().day.at 换成 every().hour 或 every(5).minutes。进程需要挂在后台,用 systemd 托管,单元文件里 ExecStart 指向脚本路径,Restart=on-failure 保证进程退出自动拉起,日志重定向到独立文件,每次抓取是否成功都能回溯。
本文还有配套的精品资源,点击获取