1. 为什么“回到接口”是动态站点爬虫的最优解
做爬虫的朋友应该都有过这种经历:明明用 Requests 把页面 HTML 拉下来了,结果翻遍源码连商品价格、用户评论、榜单数据的影子都没看到,整个 HTML 里只有一堆<script>标签和空壳的<div>。看一眼网页源码,少则几千行、多则几万行,真正有价值的数据却不在里面。面对这种页面,很多新手第一反应是上 Selenium、Playwright 这一类的自动化浏览器工具,让浏览器去把 JavaScript 执行完,再把渲染后的完整 DOM 拿回来。
这条路确实能走通,但我个人在实际项目中很少首选它。原因也很简单——用浏览器渲染一个页面的开销太大了。一个正常页面启动浏览器实例可能要几百毫秒甚至几秒,再加上加载图片、字体、站点的各种第三方脚本,单页平均要两三秒是家常便饭。如果你是爬一个列表页,一页 20 条数据,翻 50 页,就是 1000 条数据,光页面加载就是几分钟起步。而且浏览器实例也是内存消耗的“大户”,一个 Chrome 页面动辄几百 MB 内存,并发跑几个实例就可能把开发机搞瘫痪。动态站点的反爬检测也往往优先照顾“非浏览器环境”——请求频率稍微一高,立刻弹出验证码或者要求登录,Selenium 那种自带标记的自动化浏览器反而更容易被识别。
真正高效且稳定的做法,是标题里说的这个思路:回到接口。动态站点的数据本质上是前端 JavaScript 向后端服务器发请求拿回来的,这个请求我们叫它“接口”(API)。接口返回的往往是结构化的 JSON 数据,字段清晰、层级明确,拿到之后转换成 Python 字典或者列表就行,根本不需要面对浩如烟海的 HTML 标签堆里去“大海捞针”。而且接口请求可以精准控制参数,分页、筛选、排序都能在请求层面直接完成。对比之下,接口爬虫不仅速度更快、稳定性更高,代码量也少得多——这也是我在这篇文章里要带着大家从零开始走一遍的原因。
这篇文章适合谁?零基础刚学完 Requests 基本用法的读者,或者已经会写最基础的静态页爬虫但还没搞明白动态站点该从哪里下手的朋友。我会分享我在实战中识别接口、分析接口、用 Requests 重写请求的完整流程,同时讲清楚每一步的原理,让大家不仅知道“怎么做”,更明白“为什么这样做更稳”。
2. 识别动态站点的数据接口
识别接口是“回到接口”这条路的第一步,也是最重要的一步。如果这一步走错了,后面所有工作都白费。我在这个环节踩过的坑最多,所以这里会把方法讲透。
2.1 如何判断页面是不是动态渲染
拿到一个目标站点,先别急着自己猜,直接动手按 F12 打开开发者工具(或者右键菜单里的“检查”),切到 Network(网络)面板,刷新页面,观察请求列表。这里有个很关键的判断手法:看 HTML 文档请求的响应体里有没有你要的数据。
具体操作是,在 Network 面板的请求列表里找到文档类型的请求(一般是以站点域名开头的那个,Type 列显示为 document),点击它,在右侧的 Response(响应)标签页里看 HTML 源码。如果源码里搜不到你要的数据字段,比如商品标题、价格、ID 这类关键词,那么基本可以确定这是一个动态站点——数据是靠 JavaScript 运行时发异步请求才拿到的。
另一种快速佐证方法是在页面上直接 Ctrl+U 查看网页源码(注意不是检查元素)。如果你在“检查”的 Elements 面板里能看到数据,但网页源码里没有,这就说明数据是 JS 动态插入的,Elements 面板显示的是浏览器执行完 JS 之后的结果,而网页源码是最初的响应体。这个区别极其重要,很多新手在 Elements 里看到数据就以为静态能抓到,结果用 Requests 拉下来才发现什么都没有,白白浪费了时间。
2.2 用 XHR 筛选直接锁定接口请求
确认是动态站点之后,打开 Network 面板,刷新页面,点击面板顶部的 Fetch/XHR(Chrome 新版叫 Fetch/XHR,旧版叫 XHR)筛选按钮。这个筛选器会帮你过滤掉图片、CSS、字体这些无关请求,只保留页面里的异步网络请求——也就是数据接口的候选者。
接下来的操作就是逐个点击这些请求,在右侧的响应内容(Response 或 Preview 标签)里找你要的数据。请求数量多的时候不要烦躁,这是正常现象。一个比较实用的技巧是:边滚动页面边观察请求列表。比如你是要爬列表数据,那就先把页面滚动到最底部触发出下一页或者加载更多,这时你会看到新的请求冒出来,这个请求十有八九就是数据接口。如果页面有筛选条件,切换一下筛选条件,同样观察有没有新请求出现,带着“哪个参数触发了数据变化”的思维去排查,接口很快就能定位到。
定位到可疑请求之后,我就会在响应内容里搜索一下数据字段,比如“price”“name”“list”这些关键词。能搜到的话,基本就是这个接口没跑了。
2.3 判断接口质量的三个关键标准
找到了一个能返回数据的请求,不代表这个接口就适合直接拿来爬。我在实际项目中会从三个维度去评估一个接口的质量。
返回格式的友好度:优先选返回 JSON 的接口,JSON 解析简单,data[“key”] 一串下来就拿到值了。如果接口返回的是 HTML 片段甚至 XML,能用但解析成本高一截,需要谨慎考虑。
参数的复杂度:一个好接口的请求参数通常比较精简,无非是页码、每页数量、排序方式这些。如果接口携带大量加密参数(比如签名、token、动态时间戳),那你就得评估一下逆向的成本,值不值得放弃更方便的方式。
数据的完整性:检查返回的 JSON 里是否包含列表页需要展示的全部字段(标题、链接、封面图、价格、发布时间等)。有些列表接口只返回部分字段,详情数据还得靠另一个详情接口,这时候你就需要多收集一个接口,对后续的数据拼接要有心理准备。
这三个标准看起来很简单,但做实战项目之前先花几分钟评估一下,能帮你省下后面大量的调试时间。
3. 分析接口的请求细节:从 Copy as cURL 到参数拆解
识别出接口只是起点,真正要把接口用 Requests 复现出来,还得拿到请求的完整细节。
3.1 用复制 cURL 大法快速搭建 Requests 请求
我最常用的手法是:在 Network 面板的请求列表中,右键点击目标请求,选择Copy→Copy as cURL。Chrome 会把请求转成一段完整的 curl 命令,带有请求的 URL、headers、请求体(如果有的话)。
然后我习惯去一个在线转换工具把 curl 转成 Python Requests 代码(你本地装了 curlconverter 命令行工具也可以,pip 安装就能用),或者如果你熟练的话,直接照着 curl 里的信息手工写 Requests 代码。这个方法之所以效率高,是因为它不会漏掉任何 header——很多人的请求被服务器拒绝,原因就是漏了某些关键 header,而从 cURL 出发基本可以避免这个问题。
不过要提醒一句:复制下来的 headers 往往有很多并不是必需的,比如sec-ch-ua、sec-fetch-*这些浏览器环境相关的字段。你在后续调试里可以逐步精简,只留下服务器真正校验的东西,一般必留的是User-Agent和Referer。User-Agent 告诉服务器“我是一个什么浏览器”,Referer 告诉服务器“我是哪个页面跳转过来的”,很多站点凭这两个字段就能做初步过滤。
3.2 为什么这些请求头一个都不能少
这里我想单独展开讲讲 headers 里的几个关键字段,因为这直接关系到你的请求能不能被服务器接受。
先说User-Agent(简称 UA)。服务器的反爬第一步就是看 UA 是否来自正常浏览器的特征。Python 的 Requests 默认 UA 长这样:python-requests/2.x.x,服务器一眼就知道“你不是浏览器”,很可能会直接拒绝或返回异常结果。所以务必要从浏览器的 Network 面板里复制一个完整的 UA 字符串,或者自己维护一个 UA 列表。
再说Referer。很多有防盗链机制的站点,尤其是视频站、图床类站点,会检查请求的 Referer 是不是本站的页面。如果 Referer 不合法,接口就会返回 403。对于数据接口来说,Referer 一般设成当前页面的地址就行。
最后是Cookie。如果你的目标页面需要登录才能看到完整数据,那 Cookie 就是绕不过去的。从 Network 面板里复制请求头中的 Cookie 字段,直接塞进 Requests 的 headers 里是最快捷的方式,但这种做法有有效期限制,Cookie 过期后你又得手动换一次。正规项目里通常会配合登录接口来动态获取和更新 Cookie,这个属于进阶话题,这里先不展开,但大家要记住:复制粘贴 Cookie 只适合快速验证思路,不适合长期稳定运行的爬虫。
3.3 从复制到改造:把调用链彻底理解透
复制 cURL 也好,转换代码也好,都只是拿到一个“能用”的请求。要让请求稳定可维护,我们必须搞清楚接口的调用链里发生了什么。
我拿到一个目标接口之后,通常会打开它的请求参数(Payload 或 Params 标签页)去看它提交了什么参数。常见的就是:
page:页码,列表接口的核心参数。limit或per_page:每页数量,有些站点限制了最大值(比如最高只能填 60),要根据实际观察调整。offset:偏移量,有些接口不用 page,而用 offset/size 这套分页逻辑,注意从 0 开始还是从 1 开始,这是实战中最容易翻车的地方。sort、order:排序方式。keyword:搜索关键词,如果是要做搜索爬虫,这个参数就是核心。
然后我还会关注返回 JSON 里的分页信息字段,比如total(总条目数)、page_count(总页数)、has_more(是否还有下一页)。这些字段直接决定了爬虫循环得跑多少轮,是控制请求次数、避免无谓请求的关键依据。
理解清楚参数的逻辑之后,你就能把一个“一次性请求”改造成“循环爬取所有页”的通用函数,这也正是下一步要做的事。
4. 用 Requests 重写接口请求:从一行请求到一个健壮的爬虫函数
这一步是整个教程的主体部分。我会带着大家把一个最简单的接口请求,逐渐改造成一个能应对各种常见状况的稳定爬虫函数。
4.1 第一版:最朴素的接口请求长什么样
假设我们找到了一个返回 JSON 的列表接口,经过 cURL 转换和手动精简之后,代码大概是这样的:
import requests url = "https://example.com/api/movie/list" params = { "page": 1, "limit": 20, } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example.com/movie", } resp = requests.get(url, params=params, headers=headers, timeout=10) data = resp.json() items = data["data"]["list"] for item in items: print(item["title"], item["score"])这段代码看着简单,但它已经包含了四个关键要素:接口地址、请求参数、请求头、超时设置。timeout=10这个细节千万别省——没有超时设置的请求,一旦服务器迟迟不响应,你的程序就会一直挂在等 socket 响应的状态里,爬虫批量跑起来的时候,这种悬挂请求会像滚雪球一样越攒越多,最后整个任务卡死。
4.2 第二版:把状态码和重定向逻辑纳入检查
上面的代码有个问题:如果接口返回了 404 或者 403,代码仍会继续执行resp.json(),然后抛出一个异常直接崩溃。我们写爬虫要假想各种意外情况都可能会发生,所以推荐把响应检查前置。
import requests def fetch_movie_list(page): url = "https://example.com/api/movie/list" params = {"page": page, "limit": 20} headers = { "User-Agent": "Mozilla/5.0 ...", "Referer": "https://example.com/movie", } resp = requests.get(url, params=params, headers=headers, timeout=10) if resp.status_code != 200: print(f"page {page} 请求失败,状态码:{resp.status_code}") return None data = resp.json() return data["data"]["list"]这里值得展开讲一下状态码的含义。200 是最理想的结果,说明请求被正常处理并返回了数据。403 是服务器拒绝了请求,常见原因包括 UA 被识别、Referer 校验不过、IP 被临时封禁。404 是接口路径不对,很可能是 URL 写错了,或者接口地址实际是拼接出来的动态路径。429 是请求频率太快触发了限流,这时候最忌讳的操作是继续硬闯,正确做法是停下来等一段时间(后面“常见问题”章节会专门讲)。
还有一个很多人没注意到的问题:重定向。有些接口,尤其是用了负载均衡或 HTTPS 跳转的站点,请求可能先返回 301/302,Requests 默认会跟随重定向,你最终拿到的是重定向之后的响应。绝大多数情况下这没问题,但如果你发现打印出来的resp.url和你请求的 URL 不一样,说明经历了重定向,这时候要么优化参数(比如把请求的 URL 改成最终的地址),要么检查是不是被中间代理干预了。
4.3 第三版:加入分页循环与必要的数据清洗
有了单页请求函数,循环翻页就是水到渠成的事。但在设计循环时,我要提醒一个新手非常容易犯的错误:用 while True 死循环翻页,直到返回空列表才停。这种做法在技术上是可行的,但如果接口因为某个参数错误永远返回同样的数据,你的循环就会从 1 跑到一百万次,硬生生把 IP 请求到封禁。推荐做法是先通过返回的分页信息确定总页数,老老实实地用for page in range(1, total_pages + 1)遍历。
import time import requests def fetch_all_movies(max_pages=100): all_items = [] for page in range(1, max_pages + 1): items = fetch_movie_list(page) if not items: break all_items.extend(items) # 控制请求频率 time.sleep(0.5) print(f"第 {page} 页完成,累计拿到 {len(all_items)} 条数据") return all_itemstime.sleep(0.5)这个细节千万不要删。很多站点不要求你快速返回数据,反而更看重你是不是在“像人一样”地访问。间隔 0.5 秒是一个经验值,实际速度可以根据站点的反爬强度来调整:站点宽松就快一点,严格就慢一点。
拿到数据之后还有一个容易被忽略的步骤:清洗。接口返回的 JSON 字段往往不是为爬虫准备的,比如发布时间可能是时间戳格式(如1700000000),需要转换成可读的datetime对象;有的字段带 HTML 标签,像<em>热映</em>,需要正则剥离;有的字段是 null,可能会有不同含义(无数据、未公布、不适用)。在这个阶段把这些处理逻辑统一写好,后面做数据分析的时候会省很大的力气。
4.4 第四版:异常重试与日志记录让爬虫活得更久
爬虫跑起来以后,真正消耗时间的是网络等待和异常处理,而不是请求本身。网络是极不可靠的——DNS 抖动、服务器负载高、临时限流,都会让你的请求偶发失败。一个稳健的爬虫必须对这些偶发问题有“自愈”能力。
我常用的重试模式是这样的:
import time import logging import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") def create_session_with_retry(): session = requests.Session() retry = Retry( total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET"], ) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter) return session这里用了requests.Session,它的好处是可以保持 TCP 连接复用,多次请求同一个站点时不需要每次都重新握手建立连接,能明显降低网络开销。Retry对象配置了最多重试 3 次、退避系数为 1 的指数退避策略——也就是说第一次失败后等 1 秒,第二次失败后等 2 秒,第三次等 4 秒。关键是status_forcelist只设置了 5xx 系列的服务器错误,让程序不会在 4xx(客户端错误)上做无意义的重试。
需要注意的是,Requests 自带的重试机制对连接错误和 5xx 有效,但对 429 状态码并不会自动处理。对于 429,我一般会在业务代码里单独写等待逻辑,这个放到“常见问题”一章专门讲。
在重试之外,日志记录也是保证爬虫可维护性的关键手段。我强烈建议不要用一堆看似整齐的print去记录过程,而是用 Python 内置的logging模块。print 打出来的内容程序一关就没了,而 logging 可以配置输出到文件(file.log),并附上时间戳、级别等元信息。等爬虫第二天早上跑崩了,你打开日志文件一看“2024-06-01 03:22:15 - ERROR - page 87 请求失败”,马上就能定位到问题发生的时刻和上下文,而如果只有 print,你只能在终端里翻屏找历史记录,那叫一个痛苦。
5. 实战案例:用豆瓣 Top250 演示“回到接口”全流程
理论讲了很多,来一个完整的实战演练会更容易吸收。我选豆瓣电影 Top250 作为演示案例——它的动态加载机制非常典型,接口地址清晰,反爬门槛也比较适中,非常适合练手。这里说明一下,我们只演示技术流程,用少量请求做学习验证,爬虫项目请在遵守目标站点相关规则的前提下进行。
打开豆瓣电影 Top250 页面,按 F12 打开 Network 面板,刷新页面,你会看到页面的数据并不是在最初的 HTML 里,而是在加载完成后由 JavaScript 发异步请求获取的。这个异步请求的接口地址形如:
https://movie.douban.com/j/chart/top_list?type=5&interval_id=100:90&action=&start=0&limit=20观察这个 URL,你就能猜出关键参数了——start是从第几条开始取,limit是每页取多少条。再看下响应体,是一份 JSON 数组,每条记录包含了标题、评分、评分人数、排名等字段,结构化程度极高。
接下来我们按照前面讲的方法,用 Requests 重写这个接口请求。
import requests import time import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") def fetch_top250(): base_url = "https://movie.douban.com/j/chart/top_list" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://movie.douban.com/top250", } all_movies = [] for start in range(0, 250, 20): params = { "type": "5", "interval_id": "100:90", "action": "", "start": start, "limit": 20, } resp = requests.get(base_url, headers=headers, params=params, timeout=10) if resp.status_code != 200: logging.error(f"start={start} 请求失败,状态码 {resp.status_code}") break data = resp.json() if not data: break all_movies.extend(data) logging.info(f"已获取 {len(all_movies)} 条数据") time.sleep(1) return all_movies if __name__ == "__main__": movies = fetch_top250() for m in movies[:5]: print(m["title"], m["score"])这段代码放在你自己的项目里,稍加适配就能用在很多结构相似的动态站点列表页上。有个细节值得说明:range(0, 250, 20)这个写法让 start 从 0 开始,每轮增加 20,直到拿到全部 250 条。这么做比 for 里直接累加更清晰,也让接口的 offset 语义一目了然。
还有一个贴心的小细节——我在这里的循环里写了if not data: break。有的接口超过数据末尾后不会报错,而是返回空数组,这时候如果你还在傻傻地往后发请求,纯粹是在浪费资源并挑衅服务器的限流策略。空数据就是“该停了”的信号,比任何逻辑判断都直接。
整个案例跑一遍下来你会发现,用接口的方式拿 250 条数据,实际发送的请求只有 13 次左右,而且每次请求加响应的时间都在几百毫秒以内。这个效率是任何浏览器渲染方案都无法比拟的。
6. 常见问题与排查技巧实录:从 429 到加密参数的对策
实战过程从来不会一帆风顺。这篇文章涉及的场景里,有一些重复率极高的问题,我把自己踩过的坑和处理经验整理出来,做成一个速查表,大家对照着排查能省不少时间。
6.1 状态码 429:被限流了怎么办
429 是“Too Many Requests”的意思,服务器明确告诉你“你请求得太快了,先冷静一会儿再来”。它和 403 的本质区别是:403 是你的身份有问题(UA 被识别、缺 Cookie 等),而 429 是你的频率有问题。
面对 429,我见过最糟糕的处理方式是:不停重试、加大并发,结果触发更长的封锁周期。正确的应对策略是退避。我的做法是:
- 先停下来等 30-60 秒,再尝试一个简单的探测请求(比如请求首页,不是数据接口)。
- 如果探测请求恢复了 200,就从较小的频率重新开始爬,比如每两秒一个请求。
- 如果探测请求还是 429,就把等待时间拉长到 5-10 分钟,期间不要有任何对目标域名的请求。
- 平时跑任务的时候给请求间隔留出余量,不要卡着 1 秒一个的极限去跑。
另外,我记得有些服务端会在响应头里带上Retry-After字段,告诉你具体需要等多少秒。这个字段是标准行为,遇到 429 时可以先检查响应头里有没有它,有的话按它说的等就对了。
有一种特殊情况的 429 是“误伤”——因为你的出口 IP 是共享的网络出口(比如公司内网、云服务器公共出口),别的同事或同一个节点上的其他用户触发了限流,你也跟着被牵连。这种情况下你无法从自身频率上解决问题,只能等限流周期过去,或者换一个出口环境。
6.2 JSON 解析失败或返回内容不是 JSON 怎么办
这是新手问得最多的问题之一。你用resp.json()解析时报错了,说明响应体里的内容根本不是 JSON,那它可能是什么?
最常见的一种情况是:服务器返回的是一个 HTML 页面,内容不是目标数据而是验证码页或“访问异常”提示页。你可以随便找个响应把内容打印出来(print(resp.text[:500]))看一眼,如果是验证码页,基本可以确定是触发了反爬。另一种常见情况是接口返回值被嵌套了一层 JSONP 包装,比如callback({“data”: []}),这时候不能直接用.json(),得先从文本里把callback(和末尾的)剥掉,再交给json.loads()解析。
第三种情况是内容被压缩或者编码异常。Requests 一般会自动解码 gzip 和 deflate 压缩,但如果你发现中文乱码,可以检查一下resp.encoding,设置为utf-8之后再访问resp.text,或者直接用resp.content.decode("utf-8", errors="ignore")。
总结下来,JSON 解析失败的排查顺序是:先打印前 500 个字符看内容,再判断是验证码页、JSONP 包装还是编码问题,最后针对性地处理。不要一上来就去翻代码逻辑,先看数据长什么样,问题往往一眼就暴露了。
6.3 参数中含有需要动态计算的加密值
有些接口的请求参数里会包含sign、token、ts(时间戳)这类动态值,这意味着你不能把参数写死,而要在每次请求前动态生成。加密参数的来源一般有两种:
- 前端 JavaScript 动态生成:网页里有一段 JS 代码,根据时间和固定密钥算出一个签名,拼在请求里。这种需要通过阅读混淆后的 JS 来还原算法,属于典型的逆向工程。
- 后端下发的临时 token:先访问一次页面或一个初始化接口,服务器返回一个 token,后续请求带上它。这种相对友好,只要把“先取 token,再带 token 请求”的流程写进代码就行。
我的建议是:不要一看到加密参数就头大,先静下心观察这次的签名是不是跟时间戳相关。如果签名的值在每次请求页面时都会变化,但它和前一次页面里的某个值存在固定的对应关系,那大概率是从初始化接口拿的,问题就简化很多。如果确实遇到高强度混淆的算法,那就要权衡一下投入产出比了——是自己逆向,还是退一步找找有没有免签接口,或者考虑降低频率只在少量必要页面使用。
6.4 一份实用的问题速查表
| 问题现象 | 常见原因 | 处理思路 |
|---|---|---|
| 状态码 403 | UA 被识别 / Referer 校验不过 / Cookie 缺失 | 补全 headers 里的 UA 和 Referer,必要时带上登录后的 Cookie |
| 状态码 429 | 请求频率过快 | 停止重试,按 30-60 秒退避,检查 Retry-After 响应头 |
| 状态码 500-504 | 服务器端临时故障或负载过高 | 配置指数退避重试,最多重试 3-5 次后放弃该条请求 |
| JSON 解析报错 | 返回了验证码页 / JSONP 包装 / 编码问题 | 打印前 500 字符定位原因,分别用 HTML 识别、剥 JSONP、调编码解决 |
| 拿到的数据缺字段 | 列表接口只返回摘要,详情在另一个接口 | 在 Network 面板里继续找详情接口,做二次请求拼接 |
| 接口参数是加密值 | 前端签名 / 动态 token | 优先找初始化接口获取 token,实在不行再考虑逆向 JS |
| 请求能通但数据不对 | 参数类型错误或 offset 取值有误 | 对照接口文档或抓包参数,确认 start/page/offset 的起始值和语义 |
| 程序卡住不往下走 | 缺少 timeout 设置,socket 悬挂等待 | 为所有请求加上 timeout=10,并用 Session 复用连接 |
遇到问题不要慌,顺着表格里的思路一个一个排查。爬虫程序的调试本质就是“看反馈、调参数、看反馈”的循环,绝大部分问题都能在这种循环里被解决。
7. 从接口到工程的延伸:Session、并发与数据落盘
“回到接口”并不只是把单个请求写出来那么简单。当你的爬虫要爬的数据量变大,就需要从“能用”走向“好用”。这一节分享几个我在实战里常用的工程化改造手法。
7.1 Session 复用:别让 TCP 握手拖慢你的爬虫
前面提到过 Session 能复用 TCP 连接。这里展开说一下它的实际收益。当你用requests.get直接请求时,每次都会走一遍完整的 HTTP 握手流程(如果是 HTTPS 还要加上 TLS 握手)。对于同一个站点的连续请求,这种重复开销是非常可观的。而requests.Session()会把连接保存在连接池里,后续请求直接复用已有连接,速度提升非常明显。
用起来也特别简单,只需要在创建 Session 后,把原来调用requests.get(...)的地方改成session.get(...):
session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 ...", }) resp = session.get(url, params=params, timeout=10)Session 的另一个好处是它会自动帮你管理一部分 Cookie 状态。如果目标站点的接口需要持续的会话状态(比如登录态),Session 能保证同一会话内的请求始终带着上次响应的 Cookie。
7.2 是否要上并发
这是一个我经常被问到的问题:“老师,我用串行请求跑 1000 页太慢了,能不能上并发?”我的答案是:可以,但要谨慎。
并发确实能大幅压缩爬取总时长,比如 ThreadPoolExecutor 配合 5-10 个线程,理论上就是 5-10 倍的速度提升。但并发也意味着短时间内请求密度骤增,服务器那边限流策略的触发阈值很容易就被打到了,结果就是 429 扑面而来。
我的经验法则是:对于需要登录、有较强反爬的站点,尽量不要上并发,保持串行加合理间隔;对于公开数据、反爬宽松的站点,可以用 3-5 个线程的轻并发,并让每个线程在请求之间保持 0.5-1 秒的随机延迟。把最大并发数设定成“你愿意被永久封禁的风险等级”的正相关关系。说白了,快不是目的,稳才是。
7.3 数据落盘:JSON 文件还是数据库
爬下来的数据总要存储。小批量学习项目,直接写到 JSON 文件或者 CSV 就是最轻量的方案;数据量上了几十万条以后,建议落到 SQLite 或 MySQL。这里我推荐新手从 SQLite 起步——它不需要安装独立的数据库服务,一个文件就是一个库,Python 标准库sqlite3直接就能操作。
每次请求完一页数据,就增量写入数据库,这样就算爬虫中途崩了,已入库的数据也不会丢,重启后还能接着断点续爬,这是工程化爬虫的一个基本素养。
8. 写在最后:别把事情搞复杂,回到接口本身就是最优解
写这篇文章的初衷,是想帮那些刚学会 Requests 基础、但面对动态站点不知道从何下手的读者,找到一条更务实的路。我见过太多新手在动态站点面前第一反应是去学 Selenium,学 Playwright,然后在一堆“等待元素出现”“模拟点击”“处理弹窗”里消耗了大量时间。不是说这些工具不好,而是杀鸡真的不用牛刀——动态站点的数据入口就是接口,直接抓接口,逻辑更简单、速度更快、稳定性更高。
从我个人的实际体会来说,“回到接口”的精髓不只是技术,更是一种思维方式:遇到看似复杂的问题,先不要急着用更重的工具硬扛,而是退一步,把问题的本质看清。网页需要浏览器才能渲染,是因为浏览器要执行 JS,但 JS 执行完最终还是要和服务器对话。你绕开浏览器,直接以服务器的“对话对象”身份出现,反而更高效、更直接。
最后再分享一个小技巧,算是给这个系列的读者的额外礼物。如果你判断接口返回的 JSON 是规范的(字段清晰、层级固定),强烈建议你用Pydantic或者dataclass做一个数据模型层,把接口返回的原始字典转换成即校验类型的结构化对象。这样做的好处是,一旦接口字段变更(这在实际项目中经常发生),你的代码会立刻报错提示,而不是等到数据写进数据库之后才发现整列都是空值。这种代码质量上的提前量,能帮你在项目后期省下一大笔维护时间。
希望这篇文章能帮你顺利跨过“动态站点爬虫”这道坎。如果大家在实操中遇到我这里没有覆盖到的问题,也欢迎按自己的方式多试试——爬虫的乐趣本来就在那一次次的“排查-解决”循环里。