上个月有个做股票复盘的朋友找我,说他每天夜里都要在某财经网站上翻行情列表,手动翻页、复制粘贴、再粘到Excel里,光是整理数据就花一个小时。我听了直接摇头:这种重复劳动,早就该交给Python爬虫了。他犹豫了一下,说之前试过用普通方法抓页面源码,可源码里根本找不到行情数据。问题就出在这——现在的金融网站几乎都是Ajax异步请求,页面只是一个壳,真正的数据是浏览器偷偷向后端接口要的,接口返回的又是JSON。所以这次我干脆把完整的实战过程写出来:JSON解析、Ajax接口定位、请求构造、数据清洗、保存落盘,一套流程全走通,新手照着敲也能跑。
这篇东西适合三类人看:刚学Python爬虫、卡在动态页面抓不到数据的朋友;做量化或者复盘需要自己整理行情数据、又不想依赖现成数据库的人;还有那些对JSON和requests库一知半解,想彻底搞明白“接口爬虫”到底是怎么回事的。我会用金融场景里的公开行情接口做例子,讲原理、讲参数、讲报错,也讲哪些线不能碰。
1. 项目拆解:金融数据抓取为什么绕不开Ajax与JSON
1.1 页面源码里没有数据,数据都在XHR里
先解释一个很多人没绕过来的弯。你打开一个网页,看到的表格、列表、图表,未必是服务器一开始就放在HTML里的。现在的网站很流行“先搭架子,再异步填数据”:浏览器先请求到一份只有框架和空容器的HTML,然后页面里的JavaScript再偷偷向后端发起异步请求,拿到数据后动态渲染到表格里。这个异步请求就是Ajax,浏览器里通常叫XHR(XMLHttpRequest)。
我习惯这么跟朋友打比方:HTML页面就像餐厅里的菜单,你看着菜单点菜,但菜不是已经放在桌上的,你点完之后后厨才从仓库取食材、炒好端上来。浏览器里的Network面板,就是你能看到传菜口的地方,能看到浏览器都向后厨要了什么东西。回到爬虫上,如果你只抓HTML源码,等于把菜单抄下来,却指望里面有菜本身,当然什么都拿不到。
金融场景特别明显,因为行情数据变化快。页面上显示的股价、涨跌幅、成交量,每隔几秒甚至几秒就刷一次,如果把这些数据全部塞进HTML再重新加载整个页面,服务器早被拖垮了。所以聪明的做法就是:页面保持不动,后端只返回一小段结构化数据,前端局部刷新。
想验证这个结论,操作非常简单,我用的是某财经网站的行情列表页:
- 按F12打开开发者工具,切到Network(网络)面板;
- 在筛选栏里勾选XHR,让面板只显示异步请求;
- 回到页面上点击下一页,或者修改排序条件;
- 观察面板里新增的请求,你会看到一个类似
/api/hq/list的接口地址; - 点击这个请求,在右侧的Preview或Response标签里就能看到返回的JSON数据。
很多人第一次看到这个界面会有点慌,觉得请求太多了,其实只要记住:接口爬虫的目标不是HTML文档,而是这条XHR请求的URL、请求头、请求参数,以及返回的那段JSON。
这里先泼一盆冷水,直接关系到后面能不能顺利采集:只抓公开、免费、不需要登录授权的数据,是爬虫的基本底线。如果某个接口需要复杂登录、人机验证,或者页面已经明确声明禁止抓取,就赶紧换个目标,不要为了几KB数据铤而走险。后面讲到的合规和频率控制,我会再展开。
1.2 JSON是接口返回的通用语言,也是Python里最常见的dict和list
Ajax请求拿到了数据,但数据是什么格式?绝大多数情况下是JSON。JSON全称是JavaScript Object Notation,最初是给JavaScript用的,但它结构简单、跨语言通用,现在几乎成了接口的标准语言。它的核心结构就两种:花括号包着的键值对(对应Python的dict),方括号包着的有序集合(对应Python的list)。你可以理解成一种“Python都能直接读懂的运输格式”。
举个例子,接口返回的行情片段可能长这样:
{ "code": 0, "message": "success", "data": { "total": 100, "list": [ { "symbol": "600000", "name": "示例银行", "price": 8.14, "change_pct": 0.0037, "datetime": "2026-05-20 15:00:00" } ] } }这段JSON里,最外层是一个对象,有3个键:code、message、data。data下面又嵌套了total和list,list是一个数组,里面每个元素是一条股票行情记录。特别适合Python处理,因为只要你用requests的resp.json()方法,这段文本就会直接变成Python里的字典和列表,后续随便你怎么取值、遍历、修整。
Python处理JSON主要靠内置的json模块,最常用的就是两个方法:
json.loads(text):把JSON字符串解析成Python的dict或list;json.dumps(obj, ensure_ascii=False, indent=2):把Python的dict或list序列化成JSON字符串,用来保存到本地也很方便。
很多新手会问JSON用什么打开,答案很简单:记事本就能打开,但原始JSON往往挤成一行,没有格式化,肉眼很难看。把字符串丢给json.dumps格式化一下,或者直接在浏览器地址栏打开接口URL(如果是GET请求),Chrome会帮你渲染成清晰的树形结构,比截图还好使。
回到爬虫本身,掌握JSON解析的关键不是背函数,而是学会“按结构取数据”。后文我会专门用一个章节讲嵌套JSON的解析套路,让你拿到任何接口都能在30秒内定位到目标字段。
2. 接口分析与请求构造:从浏览器到requests
2.1 用开发者工具定位真正的行情接口
定位接口这事,很多人会犯一个毛病:看到Network面板里几十个请求,就抓瞎了。其实思路很简单——你不用分析所有请求,只需要分析“触发动作对应的那个请求”。比如你想爬的是行情列表,那就先在页面上点击一次翻页或刷新,看新增了哪些XHR请求;新增的、最有代表性的那个,通常就是数据接口。
我以行情列表页为例,接口通常长这样:
https://quote.example.com/api/hq/list?page=1&size=50&market=CN&sort=amount_desc这里面的Query String参数,直接影响服务器返回什么数据:
page:第几页,翻页时这个值会变化;size:每页多少条,很多接口不允许太大,超过某个上限会报错;market:市场代码,比如CN代表A股;sort:排序字段,比如按成交额降序。
还有一类接口是POST请求,参数放到请求体里,格式可能是表单形式(form data)或者JSON形式。Ajax请求设置编码格式通常就体现在这一步:如果是表单形式,在开发者的Payload里能看到page=1&size=50这样的键值;如果是JSON形式,则是一整个JSON对象。你在用requests模拟的时候,表单形式要写data={...},JSON形式要写json={...},写错了服务器可能直接返回参数错误。
另一个重点是请求头(Headers)。金融网站的反爬虽然不算最凶,但也不是裸奔就能抓的。常见的几个字段必须带上:
User-Agent:告诉服务器你是哪种浏览器。不设置的话,requests默认的Python UA很容易被识别;Referer:告诉服务器你是从哪个页面跳转过来的,有些接口会校验这个值,缺失就不给数据;Accept:声明你希望接收的返回类型,一般带上application/json, text/plain, */*;Accept-Language:声明语言,防止返回内容出现异常。
我见到过很多初学者第一步就栽在Headers上,直接用requests.get(url),结果人家返回一个302跳转或者403 Forbidden。其实解决方式很简单:点开接口请求,在Headers面板里找到Request Headers,把浏览器实际发送的那几个关键头原样复制到Python里,基本就稳了。
2.2 把浏览器复制出来的请求转换成可以重复调用的Python代码
很多博主喜欢推荐一种“偷懒”做法:在Network面板里右键请求,选择 Copy as cURL,然后到商品一些网站上把cURL直接转成Python代码。这个方法确实快,但我建议你用它来起步,而不是依赖它一辈子。原因很简单,第三方转换网站不一定安全,而且如果你看不懂转换出来的代码,遇到反爬升级就完全没法应对。
我更推荐的手工构造方式,大概分三步。
第一步,在Network面板里找到目标请求,确认请求方法和URL。以GET请求为例,URL就是接口地址,Query里的参数保存在请求的Query String Parameters里。
第二步,复制Request Headers里关键的几个字段,构造一个字典:
import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Referer": "https://quote.example.com/market/", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", })用Session而不是单发requests,好处是后续请求会复用同一个连接池,也会自动保持某些Cookie状态,适合需要连续翻页的场景。
第三步,把请求参数整理成一个字典,交给params参数:
params = { "page": 1, "size": 50, "market": "CN", "sort": "amount_desc", } resp = session.get("https://quote.example.com/api/hq/list", params=params, timeout=10) print(resp.status_code, resp.url)注意timeout一定得设,不然遇到慢接口可能挂在那里卡死整个脚本。resp.url会打印出最终拼接的完整URL,方便调试比对。
这里有个小细节很多人不注意:requests的params会自动处理URL编码,所以你在浏览器里看到的参数可能是一长串带百分号的编码,但在Python里直接填原始中文、特殊符号都行,requests会帮你处理。反过来,如果接口要求POST,并且是JSON格式,就得用json=参数,requests会自动把字典转成JSON字符串,同时帮你把Content-Type设置成application/json。
2.3 编码问题:为什么JSON能解析但中文全是乱码
Ajax请求和JSON本身默认基本都走UTF-8,但金融网站有时候会返回一些怪异的编码,或者在响应头里不写清楚charset,导致你打印resp.text时看到满屏乱码。
我的处理习惯是优先检查响应头的编码:
resp = session.get(url, params=params, timeout=10) if resp.encoding.lower() != "utf-8": resp.encoding = resp.apparent_encodingapparent_encoding是Requests根据响应内容自动推测的编码,几乎不会出错。如果你拿到的是纯二进制内容,比如图片或者压缩包,就不要用resp.text,直接用resp.content再去解码。
还有一点容易忽略:有些接口返回的JSON里本身就有转义后的Unicode,比如"\u80a1\u7968",这在JSON里是合法表示,resp.json()解析出来以后会自动变成正确的中文,不用你自己处理。如果你手动用json.loads,效果也一样。所以遇到中文乱码时,先别急着怀疑编码设置,先看看乱码到底是发生在解析前还是解析后。
3. JSON数据解析与清洗:把接口返回变成可分析的DataFrame
3.1 嵌套JSON的三种解析套路,小白也能直接套
拿到接口返回的JSON之后,最核心的动作就是把它变成二维表格,方便保存和后续分析。金融接口特别喜欢多层嵌套,最常见的就是“外层包装 + 数据列表”。
方法论上,我通常按复杂度分三种情况处理。
第一种,结构简单,直接索引。比如前面那个示例,data.list是数组,里面每个元素是单层字段,你直接遍历就行:
result = resp.json() if result.get("code") != 0: raise RuntimeError(result.get("message")) items = result["data"]["list"] for item in items: print(item["symbol"], item["price"])注意我用了result.get("code")而不是result["code"]。get方法在键不存在时不会抛KeyError,而是返回None,这在接口结构变化时能帮你少写一堆try...except。做爬虫就要有这种习惯:永远假设接口可能和想象的不一样。
第二种,数据是嵌套字典,想直接摊平成表格。比如每个item里又套了一层quote:
{ "symbol": "600000", "name": "示例银行", "quote": { "price": 8.14, "change_pct": 0.0037 } }这时候用pandas的json_normalize最省事,它能把嵌套字段拍平,用点号连接层级:
import pandas as pd items = result["data"]["list"] df = pd.json_normalize(items) print(df.columns) # 列名类似 symbol、name、quote.price、quote.change_pct展开以后,quote.price这一列还是嵌套的命名,但没关系,你可以重命名,或者直接在后续操作里当成普通列使用。这种方法尤其适合金融接口,因为很多接口会把“盘口数据”“昨日收盘”“今日开盘”塞到不同子对象里,一次性展开能省掉大量手写循环。
第三种,深不可测的动态结构,用递归查找。有些接口返回的字段层级很深,每次还不一样,这时候可以用一个简单的递归函数,把某个key对应的所有值全部找出来:
def find_keys(obj, target): results = [] if isinstance(obj, dict): for key in obj: if key == target: results.append(obj[key]) results.extend(find_keys(obj[key], target)) elif isinstance(obj, list): for item in obj: results.extend(find_keys(item, target)) return results这个函数虽然简单,但处理那种混乱嵌套的接口时特别香。比如你想拿到所有symbol,不管它藏在第几层,一个函数全给你搜出来。
3.2 金融数据的类型转换、空值处理与时间戳陷阱
拿到DataFrame只是第一步,真正的脏活是把金融字段清洗成能计算的格式。这块我踩过不少坑,一条条说。
先说数字字符串。很多行情接口为了前端显示方便,会把价格直接传成字符串,比如"8.14"而不是8.14。如果你不转换直接排序、求平均,结果会按字典序排,8.9会被当成大于8.14,因为字符串比较先比第一位。必须转float:
df["price"] = df["price"].astype(float) df["change_pct"] = df["change_pct"].replace("%", "", regex=True).astype(float) / 100第二个常见坑是空值和占位符。金融数据里经常用"-"表示停牌,用None表示缺失。pandas默认会把空字符串和None变成NaN,但不会自动处理"-"。我习惯统一替换:
df = df.replace(["-", "--", "N/A"], pd.NA)至于缺失值怎么填充,要看你分析需求:如果只是做展示,空值保持NaN没问题;如果要做收益率计算,可能需要用0填充或者前值填充,但不能一刀切,得看业务含义。
第三个坑是时间戳。金融接口特别喜欢用Unix毫秒时间戳,比如1700000000000表示2023年11月15日左右。很多人直接把它当整数存,后面画图全对不上。转换方式是:
df["datetime"] = pd.to_datetime(df["datetime"], unit="ms")如果接口返回的是“2026-05-20 15:00:00”这种标准字符串,pandas通常能直接解析,但为了保险,可以加上format="%Y-%m-%d %H:%M:%S",解析速度更快也不容易误判。
最后一个提醒:字段名大小写和空格。有些接口返回的字段首字母大写,比如Symbol、Price,和别人分享的代码里的小写字段完全对不上。建议在解析后第一时间统一列名:
df.columns = [col.strip().lower() for col in df.columns]4. 实操实录:爬取一个公开行情接口并落盘
4.1 一个可以直接照抄的完整脚本:翻页、异常重试、存CSV
前面讲了这么多,现在拼接成一个完整例子。假设我们要抓一个公开行情接口,它支持分页,每次最多返回50条数据,字段包括股票代码、名称、最新价、涨跌幅、时间。我会直接给出一份实用脚本,你把它改成自己的接口地址和参数就能跑。
import time import pandas as pd import requests from requests.adapters import HTTPAdapter # 1. 构造带重试的会话 session = requests.Session() session.mount( "https://", HTTPAdapter(max_retries=2) ) session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Referer": "https://quote.example.com/market/", "Accept": "application/json, text/plain, */*", }) API_URL = "https://quote.example.com/api/hq/list" def fetch_page(page): params = { "page": page, "size": 50, "market": "CN", "sort": "amount_desc", } resp = session.get(API_URL, params=params, timeout=10) resp.raise_for_status() data = resp.json() if data.get("code") != 0: raise RuntimeError(f"接口返回错误: {data.get('message')}") return data["data"]["list"], data["data"]["total"] all_data = [] page = 1 while True: try: items, total = fetch_page(page) except Exception as exc: print(f"第{page}页抓取失败: {exc}") break all_data.extend(items) print(f"已抓取 {len(all_data)} / {total} 条, 当前第 {page} 页") if not items or len(all_data) >= total: break page += 1 time.sleep(1) # 频率控制,别把服务器打爆 # 2. 数据清洗 df = pd.DataFrame(all_data) if df.empty: print("没有抓到任何数据") raise SystemExit(1) df["price"] = df["price"].astype(float) df["change_pct"] = df["change_pct"].replace("%", "", regex=True).astype(float) / 100 df["datetime"] = pd.to_datetime(df["datetime"], errors="coerce") # 3. 落盘 df.to_csv("market_data.csv", index=False, encoding="utf-8-sig") print(f"保存完成,共 {len(df)} 条记录,文件: market_data.csv")这段代码里,HTTPAdapter(max_retries=2)很关键。它让requests在遇到连接错误时自动重试两次,而不是直接崩溃。网络爬虫最怕的不是数据拿不到,是临时抖动一次就断掉整个流程,有了重试能稳很多。
同时注意,我在翻页循环里加了time.sleep(1)。这句话看似不起眼,其实是长期稳定采集的核心。金融网站再怎么说也是别人家的服务器,你每秒狂发几百个请求,对方不封你就怪了。控制频率不是怂,是保命。
4.2 落盘这件事:CSV、Excel、SQLite怎么选
保存数据也一样有讲究。演示脚本里用了CSV,因为CSV通用、轻量、能用Excel直接打开。但CSV有个问题:重复运行会覆盖,没有去重能力,也不适合存储大量结构化数据。如果你的数据量到了几万条以上,我更推荐用SQLite。
用pandas写SQLite特别简单:
import sqlalchemy as sa engine = sa.create_engine("sqlite:///market_data.db") df.to_sql("hq_data", engine, if_exists="append", index=False)if_exists="append"表示追加,不会覆盖已有数据。如果你在热词里看到SQLAlchemy存爬虫数据,说的就是这个用法。相比CSV,SQLite支持重复运行、增量追加、按字段查询,做金融历史数据收集会顺手很多。
如果一定要用Excel,记得要安装openpyxl库:
df.to_excel("market_data.xlsx", index=False)Excel对Excel用户是友好的,但文件大了以后读写很慢,所以原则上能不用就不用。
另外,无论存什么格式,都建议在文件名里加上日期,比如market_data_20260520.csv,这样每次爬出来的数据不会互相覆盖,历史复盘才有意义。
5. 常见问题排查与合规经验:这些年踩过的坑
5.1 高频报错速查表:遇到问题先翻这里
爬虫写多了,你会发现报错来来回回就那么几种。我整理了一份速查表,新手遇到问题可以先对照看看。
| 错误现象 | 常见原因 | 解决办法 |
|---|---|---|
| JSONDecodeError: Expecting value | 接口返回的不是JSON,可能返回了HTML、验证码、空内容 | 先打印resp.text[:200]看实际内容;检查URL、Headers、是否需要登录 |
| 403 Forbidden | 服务器拒绝了请求 | 补全User-Agent、Referer;用Session保持Cookie;降低请求频率 |
| 中文乱码 | 响应头没声明charset或声明错误 | resp.encoding = resp.apparent_encoding,或直接对resp.content做UTF-8解码 |
| KeyError / Key 'xx' not found | 接口结构变了,或字段名大小写不对 | 用result.get("xx")代替result["xx"];用find_keys递归查找 |
| 数据解析出来全是NaN | 字段名是quote.price,嵌套未展开 | 用pd.json_normalize拍平;检查原始JSON层级 |
| 翻页翻到最后一直是重复数据 | 请求参数里没更新page,或接口要求size+page必须搭配 | 打印当前请求的URL,确认page在变化 |
| 抓着抓着突然被限制访问 | 请求频率太高,触发了风控 | 加time.sleep随机延迟;减少并发;暂停一会儿再继续 |
| 字段数值变成字符串排序不对 | 接口返回的是“8.14”这种字符串 | astype(float)之后再计算 |
这张表是经验总结,但更重要的是一种排查思路:出现问题先看原始响应,再看请求细节,最后才怀疑代码逻辑。很多人一报错就乱调代码,其实80%的问题出在“请求的不是同一个接口”或者“参数没传对”。
5.2 合规红线、频率控制与接口变更的长期应对
最后聊一个很多人故意不提,但做爬虫迟早要面对的话题:边界在哪。
我做爬虫这些年,给自己定了三条规矩,也建议你采纳。
第一,只抓公开数据。这里说的公开,是指不需要登录、不需要特殊权限、页面本来就能直接看到的数据。那些藏在用户中心后面的、需要个人账号才能看的内容,一律不碰。金融场景尤其敏感,行情数据或许还能说公允,但任何带个人信息的东西都要躲远一点。
第二,严格遵守目标网站的robots协议和服务条款。有些网站在robots.txt里明确写了禁止爬取,那就别去挑战。这不是技术问题,是基本尊重。就算对方没明确禁止,也要控制频率,把自己当成一个普通用户在浏览,而不是开着拖拉机奔着人家米仓去。
第三,不拿爬来的数据做侵害原平台利益的商业化产品。数据本身有版权,纯学习、个人分析、复盘完全没问题,但打包成付费服务卖出去,就很可能吃官司。更稳妥的做法是,优先寻找官方API或者公开数据集,爬虫只是兜底方案。
接口的及时响应问题也很重要。金融接口这类动态链路,很可能今天还能用、明天就加了加密参数。所以生产级的爬虫脚本,一定要做好状态监控和异常告警。我在本地跑定时任务时,会加一个简单的结果校验:如果抓回来0条数据,或者code字段不是0,就往日志文件里写一条,再推送一个提醒。如果数据量大,还可以用Git保存每天抓到的结构,哪天接口改了字段,直接对比差异就能定位。
这些听起来琐碎,但恰恰是爬虫能不能长期跑下去的关键。
最后分享一点个人体会。写爬虫最爽的时刻,不是把数据存进数据库的那一下,而是你终于弄明白了一个看似混乱的接口的规律,能对着浏览器里那一堆请求准确说出“数据就是从这儿来的”。这种成就感,是背多少速成教程都换不来的。但同时,你掌握的能力越强,越要提醒自己:手里的工具是为了方便学习和研究,不是用来给别人添堵的。保持着这个分寸,爬虫这条路才能走得又远又稳。