做Web自动化爬虫这几年,我见过太多人拿到目标站点后,第一反应就是抓HTML、写CSS选择器、处理嵌套标签。不能说是错的,但如果动手之前先花半小时问自己一句"这个页面背后的数据是不是通过接口返回的",后面能省下大量时间。这篇文章我想把整个爬虫之API请求的思路、找接口的方法、构造请求的细节、以及我在真实任务里踩过的坑,一次讲清楚。
现在的Web前端架构和十年前完全不一样。那时候很多网站还是服务端渲染,页面源码里就有完整数据,爬HTML是合理的。今天的主流是前后端分离,页面骨架由前端框架渲染,真实业务数据是页面加载后由JavaScript发起异步请求,从后端接口拿到JSON再填充到页面上的。你在浏览器里看到的商品列表、评论列表、订单记录、资讯文章,绝大部分都是某个API接口的响应结果。
直接请求API和解析HTML相比,优势实在太明显。
第一,数据是结构化的。API返回的基本都是JSON,字段固化、类型明确,不需要像解析HTML那样处理标签缺失、属性变化、动态插入元素之类的边界情况。拿到JSON后按字段取值就能落库,省掉一大段清洗逻辑。
第二,拆解成本低。页面结构一改,你的选择器就废了,又得重新调试。API接口的路径和参数相对稳定,只要后端不做大版本升级,你的脚本可以长时间不维护。我维护过的一个监控脚本,一年半没改过主逻辑,中间只因为字段新增改过一次解析函数。
第三,请求量小、效率高。走HTML解析,一次请求拿回的可能是一整包几十KB甚至上百KB的HTML;走API请求,一次可能只有几KB的JSON。同样抓100万条数据,前者的带宽、内存、目标服务器的压力,都比后者高一个数量级。单次差距不大,但放到批量和长期任务里,就是完全不同的运维成本。
第四,定位问题更容易。API请求是否成功,可以用状态码、业务码、返回信息快速判断;而HTML解析出了问题,你还要去定位到底是请求被拦截了还是解析逻辑偏了,排查链条长很多。
当然,我不是说解析HTML没用了。有些网站确实没有对外开放的API,数据藏在服务端渲染的页面里;有些接口做了非常重的加密风控,你反而得靠渲染后的HTML兜底。但正确的工作流应该是"先找API,再考虑HTML回退",而不是一上来就钻进DOM树里用选择器硬抠。
1. 为什么我把"找API"当成爬虫的第一步
既然决定走API请求这条路,第一个问题就是:怎么从开发者工具那几十上百个网络请求里,找出真正包含业务数据的那一个。这一步听起来简单,实际操作里翻车的人却特别多。
我用的是Chrome的开发者工具,但思路在Edge、Firefox完全通用。操作路径是:打开目标页面,按F12进入开发者工具,切到Network面板,然后刷新页面。这里有个顺序问题,必须先打开Network再刷新,否则页面加载早期的请求可能抓不到。假如数据是滚动加载或点击才出来的,你得先在页面上手动触发一次,再回来找刚冒出的那个请求。
接下来最关键的一步是过滤。Network面板默认显示所有类型的请求,图片、脚本、样式表、字体全是噪音。你要找的是XHR或Fetch类型的异步请求,在过滤栏点一下Fetch/XHR,世界清净一大半。
剩下的一串请求,怎么判断哪个才是目标?我总结了四个技巧,按实用度排序。
一是看路径关键词。URL里带list、detail、search、query、info、data这些词的,多半是业务数据接口。我之前做过一个比价项目,在商品详情页扫了几十个请求,最后是靠着路径里的/api/v1/product/detail定位到目标接口的。
二是看响应预览。点开某个请求,在Preview标签页里如果直接是格式化的JSON,而且里面已经出现了你要找的字段——比如商品名、价格、库存——那它就是目标。这一步是最终确认,比瞎猜路径可靠得多。
三是看请求时序。数据接口通常在页面加载后立刻发起,排在XHR列表前列;而埋点统计、日志上报请求则分散在各个时间点。这个特征能帮你快速缩小范围,尤其适合那种请求特别多、路径又看不出规律的页面。
四是利用搜索功能。在Network面板里按Ctrl+F,直接输入你要抓的字段名,比如某个商品标题的一部分,或者某个ID值,面板会自动帮你定位到包含这个字符串的请求。这是我最爱用的一招,基本算是找接口的大杀器。
找对之后,右键这个请求,选"复制为cURL"或"Copy as fetch"。这个操作会把本次请求的URL、请求头、Cookie、请求体完整带出来。复制到终端里就能直接验证请求能不能复现,然后在Python里照着构造即可,比手抄准确得多,也不会漏字段。
最后,花一分钟理清这个接口的请求参数。点开Payload选项卡,你能看到实际发送的查询参数或请求体。这一步别偷懒,因为后面你要自己改参数控制分页、筛选、排序,连参数含义都没搞清楚就写代码,后续调试分页会让你怀疑人生。我一般会把每个参数名、含义、取值范围整理到笔记里,接口换版本时对着看,效率高很多。
2. 构造一个能稳定复现的API请求
在浏览器里点一次接口很容易,用程序复现并长期稳定跑,有几个关键要素必须处理好。我用Python的requests库举例,其他语言思路完全一样。
2.1 请求头三板斧:UA、Referer、Accept
请求头里最容易翻车的三个字段是User-Agent、Referer和Accept。裸跑请求不带请求头,很多服务端立刻识别出这不是正常浏览器,直接403或丢一个验证页面。
User-Agent用来声明你是谁。最稳的做法是把当前浏览器的UA字符串直接复制进代码,别用那些过时很久的默认UA,不少风控规则维护着一份过时UA黑名单。
Referer告诉服务器你是从哪个页面跳过来的。很多数据接口会校验Referer,必须和浏览器里的来源页面一致,否则直接拒绝。这个字段在构造请求时几乎必须带,别省。
Accept建议加上application/json。个别接口就是靠Accept区分"人在浏览器里点"和"脚本在后台跑"的。
这三个字段组合在一起,本质是让服务器相信"这还是那个浏览器在继续做同一件事"。好比进门要出示几样证件,缺一样保安就多看一眼,齐了基本畅通。
2.2 用Session维持会话状态
很多网站接口依赖登录态,登录态通过Cookie维持。如果你每次请求都新起连接、不带Cookie,服务器根本不知道你是谁。
这时候别每次手动填Cookie,用requests.Session()。Session对象会自动保存服务端通过Set-Cookie下发的Cookie,并在后续请求中自动携带。你只需要在首次访问或登录时处理一次身份认证,之后的请求都自动续上了。Session还能统一管理请求头,每个请求不用重复传headers字典,代码干净不少。
登录这块,如果目标站点有账号体系,可以在代码里先模拟登录请求拿到Cookie,再放进Session。有些站点做了Token鉴权,请求头需要带Authorization: Bearer xxx,Token过期后又得重新获取。我会给Token加一个失效时间判断,过期就自动重新登录,保证长任务不断档。
2.3 参数化分页与筛选
接口定位好了、请求头备齐了、Session也有了,接下来就是把参数写成可动态传值的形态。我见过太多人死在分页上,因为一开始把page=1写死了。
推荐把所有查询参数抽成一个字典,单独维护,循环里改page就能遍历全量数据。代码骨架长这样:
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/120.0 Safari/537.36", "Referer": "https://example.com/tech", "Accept": "application/json", }) def fetch_page(page): params = { "category": "tech", "page": page, "page_size": 50, } resp = session.get("https://example.com/api/v2/articles", params=params) resp.raise_for_status() return resp.json()这段代码看着简单,但有三个容易踩的口子。一是有些接口的页码从0开始,有些从1开始,以实际返回为准,别想当然。二是分页大小可能有上限,比如最大只能传50,你传200就直接报错,这时候就循环翻页,别贪一次性拿全。三是有些接口的下一页逻辑不是page+1,而是返回一个游标或token,你得存住这个值传给下一次请求。这种游标分页在数据量大的接口里很常见,写循环的时候要先把逻辑理清楚。
2.4 响应处理必须有防御性
接口返回的JSON不是你想象中干干净净的字典。很多接口外面包了一层状态码、消息,真正的数据嵌在data字段里,而且data有时候是null。解析响应时先做两层检查:第一层看HTTP状态码,第二层看业务状态码,再决定是否往下解析。
def parse_response(resp): payload = resp.json() if payload.get("code") != 0: raise RuntimeError(f"业务错误: code={payload.get('code')}, msg={payload.get('message')}") return payload.get("data") or []这个小函数我复用了很多年,不管爬多少个接口,校验逻辑都收拢在一处,出了问题改动范围很小。第一次写爬虫的人最容易忽略这一层,看到HTTP 200就觉得成功了,结果取到的data是null,程序跑一晚上落库的全是空壳数据。这个教训我印象很深。
3. 签名鉴权与风控机制:先搞清对面是怎么反你的
走到这步,你可能发现有些接口根本不像浏览器里那样好说话。你复刻了URL、带齐了请求头,结果返回"验证失败",或者HTTP 200但里面是错误提示。这种情况十有八九是撞上了签名和风控机制。
3.1 参数签名怎么拆
用开发者工具仔细看XHR请求,URL的查询参数里可能多出sign、token、ts、nonce这类看着很玄的字段。这就是签名机制:服务端把关键参数按固定规则排序拼接,加上一个前后端共享的密钥,算出哈希值放进去,服务端收到后重新算一遍,对得上才放行。
这类签名能不能拆,取决于前端代码里有没有暴露算法和密钥。浏览器必须在本地完成签名计算,所以理论上总能从JavaScript源码里找到线索。
我的拆解顺序是固定的:
- 在静态资源里搜字段名,比如搜
sign =、generateSign、getSign这类字符串,往往能直接定位到签名函数。 - 把签名函数源码读一遍,搞清楚用了哪些参数、按什么顺序拼接、加了什么盐值、用的什么算法。
- 算法简单就直接用Python重写等价逻辑;算法复杂或混淆严重,先评估投入产出比,该放弃就放弃。
如果签名算法本身不复杂,但前端代码做了重度混淆,我会考虑换一条路:把这个签名相关的JS片段摘出来,在本地用无头执行环境跑一遍拿到结果,再把结果喂给requests。这样虽然每次请求多一步JS执行的开销,但避开了对源码的深度分析。这个方案的使用边界要自己把控,我一般只用在确认属于正常采集需求、接口本身也是公开数据的情况下。
3.2 频率限制与IP风控
比签名更常见的是频率限制。你100个请求飞快打过去,服务端会在极短时间内看到同一个IP对同一接口的访问量暴涨,轻则返回"稍后重试",重则直接封IP。
处理频率限制,我用这个优先级:
- 先主动降速,请求间隔随机化,控制在1到3秒之间,观察目标站点阈值在哪。
- 再限并发,别一上来就多线程十几路并发,先单线程跑通,再逐步加并发,每加一档观察一段时间。
- 合理利用分页大小,有些接口支持更大的page_size,能少发请求就少发。总量相同但请求次数少了,触发风控的概率自然下降。
这里要说句实在话:爬虫抓数据最忌讳贪快。数据采集很多时候不是一次性拉完就完事,而是要长期稳定地跑。你今天把别人服务器打崩,第二天可能连正常访问都被风控拦了,得不偿失。我见过一个案例,某同事为了赶工把并发调到20,结果半小时后整个IP段都被封了,项目直接停摆。这个教训真的不便宜。
3.3 验证码:别在上面动歪脑筋
有些接口在请求频率过高之后,会返回验证码挑战。这种时候我不建议去研究什么自动识别方案。真实工程项目里,验证码识别的成功率、成本、稳定性都是大坑,而且牵扯到不少灰色操作,我从来不在正经项目里碰它。
更正确的做法是把验证码当成一个明确信号:说明你的请求行为已经越过红线了。回到上一步把频率降下来,或者换一种合规的数据获取方式。你抓数据是为了解决问题,而不是跟风控机制玩猫鼠游戏,时间花在正道上才是性价比最高的选择。
3.4 合规意识要前置
顺带说一句,数据采集这件事现在越来越讲究合规。动手之前先看一眼目标站点的robots.txt和服务条款,能不采集个人敏感信息就不采集,采集到的数据也要注意使用边界。很多爬虫项目最后不是死在技术上,而是死在数据使用环节。别等出了问题才后悔,一开始就把规则想清楚,脚本跑起来才踏实。
4. 完整跑通一个流程:从接口定位到数据落库
前面讲了概念和拆解思路,下面我把一个完整流程串一遍。假设我要抓取某个资讯类站点科技频道的文章列表,目标字段包括标题、发布时间、作者、阅读量。
第一步,打开频道页,F12进Network,刷新页面,过滤Fetch/XHR。很快会看到一个路径形如/api/v2/articles?category=tech&page=1的请求。点开Preview,里面就是文章列表的JSON数组,确认目标接口。
第二步,复制请求URL和关键请求头,用requests构造请求。这一步我会先在终端里用cURL原样复现一次,确认接口在外面也能调通,再开始写Python,省得在代码里排查环境差异。
第三步,遍历页码采集数据。每页之间sleep 1到2秒随机间隔,解析字段后做简单清洗,写入本地SQLite。SQLite对批量采集很友好,单文件无外部依赖,查重和去重都方便。
import random import sqlite3 import time def crawl(): conn = sqlite3.connect("articles.db") conn.execute("CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT, author TEXT, publish_time TEXT, read_count INTEGER)") conn.commit() page = 1 while True: data = fetch_page(page) items = parse_response(data) if not items: break for item in items: article_id = item.get("id") if conn.execute("SELECT 1 FROM articles WHERE id=?", (article_id,)).fetchone(): continue conn.execute( "INSERT INTO articles (id, title, author, publish_time, read_count) VALUES (?, ?, ?, ?, ?)", ( article_id, item.get("title") or "", item.get("author") or "", item.get("publish_time") or "", item.get("read_count") or 0, ), ) conn.commit() print(f"page {page} done, total: {conn.execute('SELECT COUNT(*) FROM articles').fetchone()[0]}") page += 1 time.sleep(random.uniform(1, 2)) conn.close()第四步,验证数据完整性。看接口返回的总数和数据库实际条数是否一致,或者判断是否还有下一页。漏页问题在分页参数写错时非常隐蔽,我会在脚本里加一个简易校验:当某一页返回的条数远小于page_size时,通常是最后一页的信号,但也有可能是漏数据了,需要人工看一眼确认。
这套流程走完,你会发现十来行代码就完成了以前解析HTML需要几十行选择器加清洗逻辑才能搞定的事,而且健壮性好很多,页面样式怎么改都不影响采集。
5. 只有跑长期任务才会遇到的那些坑
最后分享几个我实际踩过的坑。这些细节常规文档不会写,但每一条都让我的线上脚本在某个深夜出过问题。
5.1 时区处理
接口返回的时间大部分是Unix时间戳,或者带时区的ISO字符串。直接存数据库,后面做统计报表时发现时间对不上,轻则差8小时,重则数据错位。我在解析阶段就统一转成UTC存储,展示时再按业务需要转本地时区。这个习惯帮我避免了很多"明明抓到了数据却对不上时间"的麻烦。
5.2 编码问题
某些接口的响应头没写对charset,用resp.json()直接解析会报UnicodeDecodeError。稳妥办法是先看resp.encoding,不对就手动指定resp.encoding = "utf-8"再解析。抓海外站点时这个坑尤其常见,很多老系统的响应头里还挂着ISO-8859-1。
5.3 空字段兜底
接口字段看起来都一样,但某条记录里某个字段就是null。你直接item["title"]取值,碰到null就崩。全部字段都用item.get("title") or ""这种方式兜底,宁可拿到空字符串也不能让程序中断。批量任务最怕中途崩掉,一崩就要考虑断点续传,麻烦十倍。
5.4 断点续传
我写采集脚本时,习惯先把已采集的ID存一份,启动时加载已抓集合,跳过重复数据。这样程序哪怕跑到一半挂了,重启接着跑就行,不用从头再来。这个设计对数据量大的任务几乎是刚需。上面的SQLite示例其实已经实现了类似效果,按主键查重,重复的直接跳过,天然支持续传。
5.5 日志而不是print
跑长期任务时,print输出会刷到根本看不完。我给脚本配一个简单的日志器,按天输出到文件,等级分INFO和ERROR,平时只看ERROR级别。排查问题时直接搜日志关键词,比翻控制台高效十倍。日志里一定要带上请求页码、接口路径、耗时、错误码这些关键信息,不然线上出了问题你根本无从下手。
这些坑不是那种看一遍文档就能避免的问题,全部需要真实跑过很长时间才会暴露。但我现在写任何采集脚本,都会在开头就把UA、Session、容错、查重、日志这些骨架搭好,因为这些骨架已经内化成习惯了。如果你刚开始接触爬虫之API请求这条路线,我的建议很简单:先找一个接口简单的站点,完整跑通一遍从定位接口到数据落库的流程,然后把脚本挂在那里跑几天,观察它会不会在某天深夜悄悄停掉。能扛住一周的脚本,才算真正入门了。