
做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面板里按CtrlF直接输入你要抓的字段名比如某个商品标题的一部分或者某个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 xxxToken过期后又得重新获取。我会给Token加一个失效时间判断过期就自动重新登录保证长任务不断档。2.3 参数化分页与筛选接口定位好了、请求头备齐了、Session也有了接下来就是把参数写成可动态传值的形态。我见过太多人死在分页上因为一开始把page1写死了。推荐把所有查询参数抽成一个字典单独维护循环里改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, paramsparams) resp.raise_for_status() return resp.json()这段代码看着简单但有三个容易踩的口子。一是有些接口的页码从0开始有些从1开始以实际返回为准别想当然。二是分页大小可能有上限比如最大只能传50你传200就直接报错这时候就循环翻页别贪一次性拿全。三是有些接口的下一页逻辑不是page1而是返回一个游标或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?categorytechpage1的请求。点开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(fpage {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请求这条路线我的建议很简单先找一个接口简单的站点完整跑通一遍从定位接口到数据落库的流程然后把脚本挂在那里跑几天观察它会不会在某天深夜悄悄停掉。能扛住一周的脚本才算真正入门了。