前两年帮一个做量化研究的朋友搭建数据采集流程,打开某行情网站的电脑端页面,习惯性地用Requests把首页HTML抓下来,结果翻遍源码,连一个行情数字的影子都没找到——页面结构里全是空容器和一大段压缩过的JavaScript。那一刻我意识到,爬虫这门手艺的老一套玩法正在大面积失效。这不是个别网站搞特殊,而是整个前端技术栈都在朝前后端分离演进,金融行业尤其明显。今天要聊的东西,就是在这种背景下最实用的一套组合拳:Python爬虫、Ajax异步接口定位、JSON数据解析,场景锚定在金融数据上。不管你是刚学爬虫没多久的新手,还是被动态页面折磨过的老手,这篇文章都会给你一套直接能落地的方案。
很多人一听“接口爬取”就觉得是高端操作,其实拆开看就三步:找到数据接口、构造HTTP请求、解析JSON落地。难点都在“步骤之间的细节”上——请求头怎么伪装、动态参数怎么生成、解析时怎么处理编码和嵌套结构。我会用一个典型的金融行情接口案例,把这些细节一个个摊开讲清楚,同时把这几年踩过的坑一并交代了,省得你再走一遍弯路。
1. 为什么要研究Ajax接口:前端渲染时代的爬虫思路转变
1.1 从静态HTML到动态接口:爬虫对象的迁移
早几年写爬虫,逻辑特别“单纯”:requests.get拿回HTML,再用正则或者BeautifulSoup从标签里抽数据,一套流程走天下。但现在你随便打开一个金融类网站,比如行情中心、基金净值、外汇牌价页面,按F12一看,HTML里根本找不到你要的数据,页面上展示的最新价、涨跌幅、成交额,全是JavaScript动态塞进去的。不是网站变难了,而是数据不再“住”在HTML里了,它住在了接口里。
现在的主流开发模式是前后端分离:前端只负责渲染框架和交互,真正的业务数据通过Ajax异步请求从后端获取。Ajax这个老名词,翻译过来就是“异步JavaScript与XML”,虽然名字里带XML,但如今实际传输的载体绝大多数是JSON。浏览器在不刷新页面的前提下,悄悄发一个HTTP请求去后台拿数据,拿回来再用JavaScript把页面上的局部区域更新掉。你在页面上看到的价格跳动了,但地址栏的URL纹丝不动,这就是异步请求的典型表现。
对爬虫来说,这个转变其实是好事。以前要从密密麻麻的HTML标签里“挖”数据,维护一堆脆弱的XPath和正则规则,页面改版就全崩;现在只要找到那个Ajax接口,直接请求它,拿回来的JSON键值对结构清晰、字段明确,解析起来省力得多。核心难点只在于:你能不能像浏览器一样,找到并复现那个“后到达”的请求。换句话说,爬虫的攻击面从“解析页面”转移到了“分析接口”,思路必须跟着转。
1.2 金融场景的Ajax为什么这么重
金融类网站是Ajax用得最重的行业之一,倒不是它们技术多前沿,而是业务需求决定的。行情数据天然是实时变化的,如果每次刷新都让服务器重新生成整个HTML页面,不但慢,而且对服务器压力极大。采用异步架构后,页面骨架只加载一次,后续的数据通过接口按秒轮询或WebSocket推送,用户看到的价格始终是“活的”,体验完全不一样。
这就导致了一个爬虫视角的特征:一个金融页面上往往挂着十几个甚至几十个接口。行情报价一个接口,K线历史一个接口,盘口五档一个接口,资金流向又一个接口。每个接口的刷新频率、参数格式和使用限制各不相同。初学阶段,我建议优先找HTTP接口练手,WebSocket长连接涉及消息协议和状态维护,复杂度和HTTP不在一个量级,先放一放。
实战中锁定接口有个很直观的经验:在Network面板里盯着请求的名字和响应内容看,优先找包含quote、price、stock、fund、rate、data、api这类关键词的。金融数据还有个特点——权威性很重要。爬回来的数据做个人学习、策略回测完全没问题,但如果要商用必须确认数据源授权条款。后面讲的代码思路,核心是让你掌握接口爬取的通用方法论,换任何网站都能套用,千万别拿它去做违规采集。
2. 抓包定位:从浏览器开发者工具到目标接口
2.1 开发者工具三步法
万事开头难,第一关是定位接口。很多新手问我要“抓包工具”推荐,其实最趁手的工具就藏在浏览器里——Chrome或Edge的开发者工具,快捷键F12切到Network面板。抓包思路记住三步:先清空请求列表,再在页面上触发数据动作,最后观察新增的XHR请求。
这里有个容易犯的错误:一上来就刷新页面,然后被几百个请求淹没。正确做法是先点击Network面板左上角的清除按钮,把历史请求清空,再手动操作页面上的功能,比如切换日期、点击“查询”按钮、翻到下一页。这时候新增的请求,才是和你操作直接相关的,范围瞬间缩小一大截。
看请求列表时重点看Type(类型)列,标注为xhr或fetch的就是Ajax请求,script、stylesheet、image这些静态资源直接忽略。面板顶部的筛选框可以输入xhr,或者直接搜json、api这样的关键词,能进一步缩小范围。另外,别小看状态码那一列,200表示成功,如果出现403、429,说明目标已经有风控了,后面构造请求时要格外小心。
2.2 怎么确认这就是数据接口
假设已经锁定了几个候选请求,怎么判断哪个才是真正的数据接口?点开请求看三个地方:Headers里的URL、Preview或Response里的响应内容、Payload里的请求参数。最直接的判断方式就是看响应——如果返回的是一段结构严谨的JSON,带花括号和冒号,里面有code、data、msg这种字段,基本就是数据接口本尊了。
我实际操作中还会做一个“交叉验证”:把GET类型的接口URL复制到浏览器地址栏直接访问,看返回的JSON里有没有页面上显示的那个数值。比如页面上某只股票现价显示12.58,浏览器打开接口URL,JSON里也出现12.58,那就可以百分百确认。这个方法又笨又有效,能避免被几个长得像的接口绕晕。
找到接口先别急着写代码,花两分钟把URL结构拆开看一遍。哪些路径是固定的,哪些查询参数是动态的,参数里哪个代表股票代码,哪个代表日期,哪个代表页码,这些不搞清楚,写出来的代码换个股票就抓不了。我也见过不少人大费周章写了一大段脚本,结果核心URL参数全是写死的,一换目标就崩,这就是前期接口分析没做到位欠下的债。
3. 接口请求构造:请求头、会话管理与动态参数
3.1 请求头伪装:别让服务器一眼识破
接口定位完成,接下来是如何“体面”地把请求发出去。很多人写爬虫就是requests.get(url)一把梭,能用,但很容易被服务器识别——默认的User-Agent是python-requests/x.x.x,摆明了告诉对方“我不是浏览器”。我的习惯是构造请求时带上完整的浏览器请求头,至少包含User-Agent、Accept、Accept-Language、Referer这几项。
Referer这个字段很多人忽略,但它在反爬规则里很关键。服务器会检查当前请求是从哪个页面跳转过来的,Referer对不上,有些接口直接拒绝。比如行情页面的Ajax请求,Referer通常就是页面自身的URL,把这个字段设置成目标页面地址,能大幅提高请求的“可信度”。请求头也不是越多越好,关键是和真实浏览器保持一致,少了像裸奔,刻意堆砌反而显得做贼心虚。
另外强烈建议用requests.Session替代裸的requests.get。Session会自动保存服务端下发的Cookie,同时复用底层TCP连接,对连续翻页、批量请求的场景,效率和稳定性都好得多。我抓金融数据基本都是Session一把梭,创建一次会话,后面所有请求通通走它,连接复用带来的速度提升在大量请求时非常明显。
import time import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://quote.example.com/stock" }) params = { "code": "600000", "type": "realtime", "_": int(time.time() * 1000) } resp = session.get("https://api.example.com/quote", params=params, timeout=10)timeout参数一定要设。不设的话,网络一抖动,请求可能挂在那里几分钟不返回,拖垮整个采集流程。我习惯设10秒,对行情类接口足够响应时间,又不会等到天荒地老。这段代码里的域名和参数都是示例,重点是Session的用法和请求头结构,换成真实接口,照葫芦画瓢就行。
3.2 动态参数:时间戳、回调函数与签名逻辑
金融接口常见的动态参数有三类。第一类是callback参数,多见于老式的JSONP风格接口,值形如jQuery3310239384756_1690000000000,是前端随机生成的回调函数名。爬虫处理时这个参数通常不用严格随机生成,固定一个自定义名字也能通过,保险起见可以从真实请求里抄一个测试,能通就直接用。
第二类是时间戳,很多行情接口会在参数里带上当前时间的毫秒值,作用是防止浏览器缓存旧数据。这类参数实现最简单,Python里int(time.time() * 1000)一行代码搞定。第三类就比较棘手了——签名参数,常见命名有sign、token、key,值是一串看不出规律的长字符串,通常由前端JavaScript把参数按特定顺序拼接后做MD5或SHA加密生成。
遇到签名接口,好的情况是算法在JS里能读出来,用Python原样复现一遍即可;遇到混淆严重的JS,就要上断点调试、补环境之类的逆向操作了,新手先建议绕道,找一个签名透明或直接不需要签名的接口练手更实际。这里说句实在话:不要一上来就研究“破解签名”,爬虫的核心竞争力在思路,不在对抗。绝大多数公开数据接口,做好请求头、控制好频率就能稳定拿到数据,签名逆向属于进阶课题,基础流程跑顺了再碰不迟。
4. JSON数据解析:从响应文本到结构化字段
4.1 resp.json()与json.loads的取舍
接口通了,响应拿回来了,进入JSON解析环节。很多教程上来就是json.loads(resp.text),没有错,但更推荐直接用resp.json()——requests库内置的JSON解码方法,内部封装了json.loads,并且会按响应头声明的编码自动处理文本解码。两种方式日常用没太大区别,但resp.json()少写一行代码,也少一个环节出错的可能。
不过这里有个高频坑:编码。金融数据的老接口有个特色,明明返回的是JSON,字符集却不是标准的UTF-8,而是GBK或GB2312。如果用resp.text,requests会按响应头里的charset去推测编码,一旦推测错了,中文字段就会变成乱码,严重时直接抛UnicodeDecodeError。遇到这种情况,我一般改用resp.content.decode('gbk')把字节流手动按GBK解码,再交给json.loads。还有个非常实用的小技巧,打印一下resp.encoding看看requests猜了什么编码,和接口实际声明的一对照,问题立刻水落石出。
另一种常见报错是json.JSONDecodeError。拿到这个错,第一反应别去纠结解析逻辑,先把resp.text打印出来看一眼。很多所谓“解析失败”,根本不是JSON的问题,而是请求压根没通——服务器返回了302跳转页面、验证码页面,或者一段“访问太频繁”的提示文本。JSON解析器拿到一段HTML,当然报错。牢记一条排查铁律:先看响应原文,再谈解析。
4.2 嵌套结构的取值技巧与探查习惯
金融接口的JSON结构普遍嵌套得深,常见套路是最外层包一层业务状态码,比如code等于0表示成功,data字段里才是真实数据,data里还可能再套对象或者数组。新手写解析时最容易写出这种一长串连环下标:data["result"]["lists"][0]["name"],看着能跑,但只要中间任意一个字段缺失,代码当场崩掉,整个采集流程就断了。
我的做法是写一个安全取值的小函数,配合异常捕获,字段缺失时返回一个默认值而不是抛异常:
def extract(data, path, default=None): """从嵌套dict里安全取值,path用点分隔,如 'data.result.lists.0.name'""" try: for key in path.split("."): if key.isdigit(): data = data[int(key)] else: data = data[key] return data except (KeyError, IndexError, TypeError): return default这个函数是我强烈推荐的小工具。金融数据字段经常调整,今天叫current_price,明天改成last_price,有了这个提取函数,改路径字符串就行,业务逻辑不用动。数据量稍大时,配合pandas更香——直接把接口返回的列表转成DataFrame,字段筛选、排序、类型转换一气呵成,后续做量化分析时数据格式都不用再折腾。
第一次接触新接口时,千万别凭直觉假设字段类型。价格字段可能是字符串"12.58"而不是浮点数,涨跌幅可能带着百分号"1.23%"。我的习惯是先写一小段探查代码,把整个JSON的所有字段名和值类型打印一遍,彻底摸清结构再写正式清洗逻辑。这个“先探查、后建模”的习惯,能帮你避开后面八成以上的坑。
5. 数据落地:SQLAlchemy存储与增量更新策略
5.1 设计ORM模型:让数据定义一眼可见
数据解析完,打印在控制台里没有意义,最终要落到存储。金融场景的抓取天然带时间序列属性——股票价格、基金净值、外汇牌价,本质都是“某个时刻的某个值”,非常适合存数据库。我用SQLAlchemy不是因为功能花哨,而是ORM模型能把“数据长什么样”用代码明明白白定义出来,项目一大,这种清晰度就是最大的生产力。
以行情数据为例,模型字段可以这样设计:股票代码、股票名称、最新价、涨跌幅、成交量、采集时间和数据来源。这里有一个新手极容易忽略的关键点——加唯一约束。把“股票代码+采集时间”设成复合唯一索引,这是数据去重最硬的一道防线。没有这道约束,重复跑一次脚本就多出一堆重复记录,后面做分析时还浑然不知自己的数据早就脏了。
from sqlalchemy import create_engine, Column, String, Float, DateTime, UniqueConstraint from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Quote(Base): __tablename__ = "quotes" id = Column(String(32), primary_key=True) code = Column(String(16), nullable=False) name = Column(String(32)) price = Column(Float) change_pct = Column(Float) volume = Column(Float) ts = Column(DateTime) __table_args__ = ( UniqueConstraint("code", "ts", name="uq_code_ts"), ) engine = create_engine("sqlite:///quotes.db", echo=False) Base.metadata.create_all(engine) SessionLocal = sessionmaker(bind=engine)这个示例用的SQLite,适合个人学习和轻量数据量。如果打算跑长时间的分钟级采集,建议换成PostgreSQL或MySQL,改一下连接串、配一下连接池就行,模型代码不动。顺便提醒一句:SQLite并发写入能力有限,高频写入时容易报database is locked,新手遇到别慌,先考虑换库而不是死磕SQLite。
5.2 去重、批量写入与增量思路
数据入库的经典问题是怎么避免重复。我的方案是“先查后插”配合数据库唯一约束的双保险:写入前按唯一键查一遍,存在就跳过或更新,不存在才插入。业务层的查询是第一道闸,数据库层的唯一约束是兜底,万一并发场景下查询条件写错了、状态判断漏了,数据库也会拦住重复数据。写爬虫久了会发现,约束设计得越严,后面处理数据越省心。
批量写入的效率比单条插入高出很多,在行情这种高频数据场景差距尤其明显。SQLAlchemy用session.bulk_save_objects()或者session.execute(insert())批量提交,千万别学某些代码在循环里一条条add再commit——几百条数据没感觉,上万条慢到怀疑人生。事务上我习惯每处理完一批数据提交一次,比如每抓完50条提交一次。这样中途出错,损失的只是当前这批,整个脚本不用从头重跑。
增量更新则取决于数据类型。金融数据分两类:一类是历史快照型,比如某天的收盘价,采集一次终身有效;另一类是实时变化型,比如盘中价格,需要定时轮询。先搞清楚当前接口属于哪一类,再决定采集频率。盘中行情的采集频率从秒级到分钟级都有,但频率越高,目标服务器压力越大,被封风险也越高。频率设计要结合数据变化速度和对方网站能承受的压力来定,别图快。
6. 常见问题排查实录与长期存活经验
6.1 高频报错速查表
写爬虫这几年,踩过无数坑,也帮别人排查过无数问题。下面这张速查表里的问题,几乎是每个做接口爬取的人都会遇到的,整理出来,方便检索:
| 现象 | 常见原因 | 处理思路 |
|---|---|---|
| JSONDecodeError: Expecting value | 响应不是JSON,往往是错误页或空内容 | 先打印resp.text,检查请求是否被拒 |
| UnicodeDecodeError或中文乱码 | 接口返回GBK等非UTF-8编码 | 用resp.content.decode('gbk')再json.loads() |
| 403 Forbidden | 请求被识别为自动化脚本 | 补全Headers与Referer,检查请求频率 |
| 数据返回正常但字段全是null | 参数传错,接口返回了空结果 | 对照真实请求逐项核对query参数 |
| 抓取中途ConnectionError | 网络抖动或服务器主动断连 | 捕获异常后重试,设好最大重试次数 |
这里有一个隐蔽的坑藏在“字段全是null”这一行。接口返回本身是200,JSON也能正常解析,但字段值全是空的。新手排查半天代码,其实问题出在参数少传了或者传错了格式。我之前遇到过某个接口要求日期参数格式必须精确到秒甚至带毫秒,格式一错服务端不报错,很“友好”地返回一包空数据,这种问题用常规手段极难发现,唯一的排查方式就是对比浏览器的真实请求,一个参数一个参数地核对。
还有JSONDecodeError那一行,我重复强调过一次,这里还是想再敲一次黑板:很多新手拿到JSONDecodeError就跑来问“怎么解析”,等我把响应文本一打印,发现是“访问过于频繁”的提示页。根本不是解析逻辑问题,是频率控制触发了。所以排查顺序永远是先看响应内容,再检查请求构造,最后才轮到解析代码。
6.2 频率控制、采集边界与续跑机制
最后聊一个比技术更重要的东西——怎么“爬得长久”。金融网站的反爬普遍比普通网站严格,因为行情数据本身有商业价值。我给自己定的规矩很简单:单次请求间隔不低于1秒,批量任务加随机延时,避免形成规律的请求节奏;严格遵守对方robots.txt的约定,只采集公开数据做个人学习和研究,绝不用于商业化分发或二次售卖。
有的人喜欢一口气并发几十个请求,把人家服务器打到响应变慢,这种行为既不讲原则,也容易连累其他开发者。反爬的本质是服务器在权衡用户体验和数据保护,你只要表现得像一个人,频率自然、请求头正常、行为合理,绝大多数公开接口都不会为难你。真需要大规模、高频率地采,请先联系数据方谈授权,走正规渠道才是长久之计。
我个人还有个习惯,会把采集脚本设计成“可暂停可续跑”的。每次启动脚本,先去数据库里查最新一条数据的时间戳,接着从断点往后采集,而不是每次从头跑一遍。这样做既节省流量,也不给服务器添负担,对双方都更友好。说到底,爬虫的价值在于帮我们发现数据背后的规律,而不是整天和网站玩猫鼠游戏。理解请求、尊重边界、保持克制,这三件事做到位,你的采集工程会比大多数人的稳定得多。