简介:requests是Python生态中最常用的HTTP客户端库,相比标准库urllib2,其API更简洁易用,能让开发者用更少代码完成GET、POST、会话保持、文件上传等常见网络请求操作。这份围绕requests模块整理的学习资料共121个文件,压缩包大小643KB,其中包含76个Python示例脚本,覆盖不同功能点的调用方式;20个rst说明文档与5个HTML页面便于查阅接口细节和用法说明,另有配置文件、证书样例等辅助内容,适合初学Python网络编程或希望快速掌握requests库的开发者按需查阅。该资源目前已吸引7014人学习下载,可见其针对性与实用性较强。通过对照源码、文档与示例,读者可系统理解requests的请求参数、响应处理、异常捕获等核心用法,并直接迁移到自己的爬虫或接口调试项目中。
1. 用 requests 下载文件,别只盯着 res.content
用 Python 的 requests 模块下载文件,看起来就是三行代码:requests.get(url)然后拿res.content写进文件。但真去下载几个 GB 的安装包,你会发现要么内存被打爆,要么进度条卡到怀疑人生,要么服务器超时直接翻车。requests 下载远不是get一个对象那么简单,它牵扯到流式传输、连接复用、断点续传、超时控制这些每个下载器都必须面对的问题。如果你正在写爬虫抓文件、写自动更新工具、或者做数据集的批量下载脚本,这篇笔记会把 requests 下载从入门到进阶的路径、参数和坑完整拆给你看。
2. 下载前的原理课:响应对象与流式读取的取舍
2.1 选 requests 的底气:它替你管好了连接和编码
有人会问:Python 标准库里有urllib.request,为什么不直接用它?因为 requests 把 HTTP 客户端的脏活都接过去了。urllib在遇到重定向、Cookies、代理、连接池这些真实网络环境里几乎绕不开的场景时,代码会迅速膨胀,而 requests 用优雅的 API 把这一切封装成了get、post、headers、params这几个直观概念。下载文件这件事,requests 真正为你省下的是连接层面的心智负担。
requests 底层依赖urllib3,自带连接池管理。默认情况下它会复用同一主机名的 TCP 连接,避免每次请求都重新走一次 DNS 解析、TCP 握手和 TLS 握手。这在批量下载同一站点的文件时收益非常明显,实测下载 100 个小文件时,用 Session 复用连接能比每次单独requests.get快 3~5 倍,前提是服务器允许 keep-alive。另一个常被忽略的点是编码:res.text会根据响应头里的charset自动解码,而下载文件时我们用的是res.content,它拿到的是未经解码的原始字节流,完全避免了编码推测带来的乱码问题。
选择 requests 做下载还有一个实际原因:它给流式下载提供了标准 API。stream=True参数配合iter_content,可以让你边接收边写磁盘,而不是等整个响应体完整到达内存再一次性落盘。这个能力是urllib也能做到,但 requests 把细节揉得更好用,比如超时区分连接超时和读超时,比如用raise_for_status()在 4xx、5xx 响应时直接抛出异常。对下载脚本来说,异常处理越早拦截,踩坑成本就越低。
2.2 res.content、res.text、iter_content 的应用边界
这是下载文件最容易混淆的三兄弟,很多新人直接用res.content一把梭,遇到大文件就要命。我们可以用一个表说清各自适用场景。
| API | 返回数据类型 | 内存行为 | 适用下载场景 |
|---|---|---|---|
res.content | bytes | 一次性将整个响应体读入内存 | 几 MB 以内的小文件、接口 JSON、二进制配置 |
res.text | str | 先读入 bytes 再做编码解码 | 网页 HTML、纯文本响应,不适合下载 |
res.iter_content(chunk_size) | 生成器,逐个产出 bytes | 每次最多 chunk_size 字节 | 大文件、未知大小文件、需要进度条的场景 |
用res.content拿文件时,requests 会把整个响应体放到内存里,假设一个 2 GB 的文件,你的进程峰值内存轻松超过 2 GB,多线程下载时直接 OOM 是家常便饭。iter_content则是边读取边产出,每次产出一块指定大小的 bytes,默认大小在 requests 内部是 1 个 chunk,取决于流式读取的缓冲区。它适合的场景是大文件、视频、压缩包,以及那些你根本不知道服务器会不会传输中断的资源。
这里有个容易误用的点:res.text底层其实也是基于res.content做的解码。如果某个接口返回的是 JSON,但Content-Type写错了,res.text可能会因为编码判断错误给你一串乱码,而直接拿res.content再手动json.loads反而更稳。下载文件时永远不要用res.text,因为字节流里的编码信息对文件内容毫无意义,反而可能因为解码失败抛UnicodeDecodeError。
2.3 一个 chunk 是怎么从网线到磁盘的
理解iter_content的内部流,能帮你排掉很多奇怪的下载故障。当stream=True时,requests.get发送请求后,响应头已经返回,但响应体还没有被完整读取,它保存在底层 socket 的接收缓冲区里。iter_content内部每次调用urllib3的read(chunk_size)去底层连接读取指定的字节数,读到一个 chunk 就yield一次,直到读到空 bytes 表示流结束。
因此 chunk_size 的选择直接决定 CPU 和内存的平衡。取得太小,比如 128 字节,会产生大量 Python 循环和文件写入调用,下载速度被拖垮;取得太大,比如 16 MB,虽然写入次数少了,但单次内存占用高,而且如果写入中途失败,刚才这一大块就没有落盘。我一般取 8192(8 KB)到 262144(256 KB)之间的值,网络良好时 256 KB 读取效率更高,网络不稳时 8 KB 更容易做粒度控制。
还有一个细节:iter_content返回的 chunk 长度可能比chunk_size小,也可能某个 chunk 是空 bytes。空 bytes 代表流结束,但正常情况下不会在循环中出现,所以很多代码里会写if chunk:来判断,这个判断在下载文件中可以省,但在某些流式响应(比如 SSE)里必须加,否则空 chunk 会被当作有效数据写进文件。
3. 从零写一个下载器:小文件、大文件与进度条
3.1 小文件下载:res.content 一把梭的下场
对小于 10 MB 的文件,用res.content是合理的,省事且快。下面是标准的小文件下载函数:
import requests def download_small(url, save_path, timeout=(5, 20)): res = requests.get(url, timeout=timeout) res.raise_for_status() with open(save_path, 'wb') as f: f.write(res.content)这段代码的用意很直白:请求发出后,res.content把整个响应体拉进内存,再一次性写进文件。raise_for_status()是关键,如果服务器返回 404 或 5xx,这里直接抛异常,而不是继续把错误页面写进文件,很多下载脚本翻车就是因为少这一行,最终得到的是一个 HTML 后缀的假安装包。
timeout参数我写成了元组(5, 20),第一个数字是连接超时,表示建立 TCP 连接最多等 5 秒;第二个是读超时,表示服务器每次发数据之间最多等 20 秒。注意这不是整个请求的总时长,而是两次 read 操作之间的间隔。对网络波动大的场景,读超时给长一点,避免下载中途因为服务器稍微停顿就被掐断。
这个函数只适合小文件。如果文件超过几十 MB,res.content就会导致内存峰值等于文件大小,多个文件并发下载时内存用量的增长速度会让你怀疑人生。遇到这种情况,必须换流式写盘。
3.2 大文件下载:iter_content 分块写盘的正确姿势
大文件下载的标准姿势是用stream=True配合iter_content:
import requests def download_large(url, save_path, chunk_size=64 * 1024): res = requests.get(url, stream=True, timeout=(10, 60)) res.raise_for_status() with open(save_path, 'wb') as f: for chunk in res.iter_content(chunk_size=chunk_size): if not chunk: break f.write(chunk)stream=True必须在get时传入,它告诉 requests 不要立刻读取响应体。然后每次从iter_content拿到一个chunk_size大小的字节块,立即写入文件。对于网络正常的场景,chunk_size=64 * 1024(64 KB)是性能和内存占用都均衡的选择。
这里有两个容易忽略的参数含义。第一,timeout=(10, 60)中的读超时 60 秒,指的是每读取一个 chunk 的最大间隔。如果服务器传输速度极慢,一个 chunk 超过了 60 秒没发完,请求会抛ReadTimeout。对不确定速度的资源,可以把读超时加大到 300 秒,但不要直接删掉 timeout,否则一个假死的连接会永远挂住你的程序。第二,iter_content的chunk_size并不是服务器每次发来的网络包大小,而是我们向底层 socket 请求读取的目标字节数,实际返回的 chunk 可能小于这个值,所以不能假设每个 chunk 固定 64 KB。
这段代码还有一个改进空间:如果下载中断,文件会残留一半,再次运行会重新从头下载。我们可以稍后在断点续传里解决。现在先加进度条。
3.3 给下载器加上进度条和超时保护
手动写一个不依赖第三方库的进度条,其实只需要几行:
import sys import requests def download_with_progress(url, save_path, chunk_size=256 * 1024): res = requests.get(url, stream=True, timeout=(10, 60)) res.raise_for_status() total = int(res.headers.get('content-length', 0)) downloaded = 0 with open(save_path, 'wb') as f: for chunk in res.iter_content(chunk_size=chunk_size): if not chunk: continue f.write(chunk) downloaded += len(chunk) if total: percent = downloaded / total * 100 sys.stdout.write(f"\r{percent:.1f}% | {downloaded}/{total} bytes") sys.stdout.flush() if total: print("\n下载完成")关键是content-length头。服务器在响应头里给出了总字节数,这个值决定了进度条分母。total可能为 0,此时if total:会跳过进度显示,这种情况一般是因为服务器返回的响应头里没有Content-Length,或者启用了 chunked 传输编码。另外,如果服务器对响应启用了 gzip 压缩,Content-Length是压缩后大小,而iter_content拿到的内容在 requests 里是解压后的原始数据,downloaded最终会大于total,进度条会算成超过 100%。这个问题我会在避坑章节展开。
进度条用的是\r回车符,让每次输出覆盖当前行,而不是不断打印新行,终端上看起来是一个动态刷新的百分比。如果你在多线程下载时也这样输出,控制台会花,所以进度条只适合单线程下载脚本。另外sys.stdout.write没有自动换行,结束时需要手动打印换行。
4. 断点续传与并发分块:大文件下载的进阶打法
4.1 用 Range 头实现断点续传,中断后不必从头再来
下载一个 5 GB 的资源,网络断了一次,就得从头再来,这是最血亏的场景。HTTP 协议早就为你准备了Range头,可以让服务器从指定字节开始发送。requests 通过headers参数就能轻松实现:
import os import requests def download_resume(url, save_path): headers = {} if os.path.exists(save_path): existing = os.path.getsize(save_path) headers['Range'] = f'bytes={existing}-' res = requests.get(url, stream=True, headers=headers, timeout=(10, 60)) if res.status_code == 206: mode = 'ab' elif res.status_code == 200: mode = 'wb' else: res.raise_for_status() with open(save_path, mode) as f: for chunk in res.iter_content(chunk_size=64 * 1024): if chunk: f.write(chunk)逻辑是:先看本地有没有同名文件,如果有,就拿到它的大小existing,在请求头里带上Range: bytes=existing-,表示从 existing 字节之后开始下载。服务器如果支持,会返回206 Partial Content,这时用ab模式打开文件追加写入;如果服务器忽略或拒绝 Range,返回200 OK,那就说明它不支持断点续传,只能从头下载,所以用wb覆盖写。
这个方案有一个前提:你下载的服务器返回的Content-Length是准确的,而且文件内容在两次请求之间没有变化。如果文件在服务器端被更新过,你拿本地旧大小去请求,从中间续传出来的文件会拼接错误,轻则文件损坏,重则解压失败。所以对于重要资源,续传后一定要做校验,后面我会讲到 MD5 校验。
另一个注意点:os.path.getsize(save_path)得到的是本地文件当前大小,如果你上一次下载时把多个临时分块写进了同一个文件,这个大小可能不是真实内容的末尾,续传就会写错位置。断点续传最稳妥的落地方式,是下载时先写到一个临时文件,下载完成后再重命名为目标文件,这样即使下载中断,临时文件的名字也明确告诉程序这是没完成的。
4.2 多线程分块下载:把文件切段并行拉取
如果一个文件实在太大,而服务器愿意支持 Range,可以考虑多线程分块下载。思路是把文件按字节范围切分成几段,每个线程负责一段,最后拼成完整文件。下面是一个可用的实现骨架:
import os import requests from concurrent.futures import ThreadPoolExecutor def get_total_size(url): res = requests.head(url, allow_redirects=True, timeout=(5, 10)) res.raise_for_status() return int(res.headers.get('content-length', 0)) def download_chunk(url, start, end, save_path, chunk_size=256 * 1024): headers = {'Range': f'bytes={start}-{end}'} res = requests.get(url, headers=headers, stream=True, timeout=(10, 60)) res.raise_for_status() with open(save_path, 'r+b') as f: f.seek(start) for chunk in res.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) def parallel_download(url, save_path, num_workers=4): total = get_total_size(url) if total == 0: raise ValueError('无法获取文件大小,无法分块下载') with open(save_path, 'wb') as f: f.truncate(total) part_size = total // num_workers ranges = [] for i in range(num_workers): start = i * part_size end = start + part_size - 1 if i < num_workers - 1 else total - 1 ranges.append((start, end)) with ThreadPoolExecutor(max_workers=num_workers) as executor: futures = [ executor.submit(download_chunk, url, start, end, save_path) for start, end in ranges ] for future in futures: future.result()关键步骤:先用HEAD请求拿到文件的总字节数total;然后本地创建一个大小等于total的空文件,使用truncate(total)预分配磁盘空间;再把文件切分成num_workers段,每段是一个(start, end)的字节区间;最后通过线程池并发下载每一段,每个线程在r+b模式下打开同一个文件,用seek移动指针到该段起点,然后写入。
Range: bytes=start-end中的end是包含的。比如bytes=0-999表示下载字节索引 0 到 999,共 1000 字节。所以分段时要小心边界,我的写法里前num_workers - 1段用part_size - 1作为 end,最后一段用total - 1兜底,避免漏掉末尾字节。
这个方案能跑通的前提是:
- 服务器支持 Range,并且返回 206;
- 每段下载不会触发限流或封禁;
- 磁盘空间足够预分配,并且对并发写同一个文件没有 I/O 瓶颈。
实际上并发下载并不总比单线程快。如果你本地带宽本身就是瓶颈,分 4 段并发拉取时每段速度都会减到平时的四分之一,总吞吐量可能不变,反而因为线程切换和磁盘写竞争更慢。一般只有在服务器对单连接有带宽限制时,分块才值得做。
4.3 服务端不支持 Range 时的降级方案
并不是所有服务器都支持 Range。有些静态文件服务、CDN 或者旧代码可能直接忽略你的Range头,返回完整的 200 响应。这时你不能硬着头皮把整个内容追加到已有文件,也不该直接放弃断点续传。
降级方案是:
- 如果请求后返回
200,说明不支持 Range,把响应体完整写到一个临时文件,下载完成后覆盖目标文件。 - 如果返回
416 Requested Range Not Satisfiable,说明你请求的起始字节已经超出文件大小,可能是文件完全下载完了,也可能是本地文件比服务器文件还大(版本变了)。此时需要重新GET获取Content-Length做判断。 - 如果返回
206,就是正常续传。
最重要的一点是,不要对不支持 Range 的服务器做多线程分块。强行分块的结果是你拿到多个 200 响应,每个响应都是完整文件,线程各自把完整文件写到不同位置,最终文件变成多份完整内容拼接的巨型怪物。判断是否支持 Range 最直接的办法就是看响应状态码。写代码时永远先判断status_code再决定写入模式,这个习惯能救回很多不明所以的下载失败。
5. requests 下载高频踩坑:5 个现象背后的真实原因
5.1 内存被撑爆:一切源于你没设置 stream=True
现象:用 requests 下载一个 1 GB 的文件,程序内存一路涨到 1.2 GB,操作系统开始卡顿,最后进程被 OOM Killer 干掉。 原因:你直接用了res.content,requests 默认会读取整个响应体到内存,1 GB 文件就至少占 1 GB 内存。如果还开了多线程,每个线程都拉一个完整文件,内存直接爆炸。 解决:在requests.get中传入stream=True,然后使用iter_content分块写盘。如果是从遗留代码改过来,先检查有没有res.content或res.text被赋值给变量,再决定是否重构。这个坑是下载大文件最容易踩的,没有之一。
5.2 进度条不走:Content-Length 缺失或 gzip 搞鬼
现象:下载文件时进度条一直停在 0%,或者文件下载完成后进度条显示超过 100%,有时甚至直接变成 120% 才结束。 原因:进度条依赖响应头里的Content-Length。有些服务器不返回这个头,或者使用Transfer-Encoding: chunked,这时total为 0,进度条自然不动。另一种情况是服务器返回Content-Encoding: gzip,requests 会自动解压,Content-Length表示的是压缩后字节数,而iter_content产出的 chunk 是解压后的原文,最终downloaded会大于total。 解决:用res.headers.get('content-length')拿不到就显示已下载字节数,不要显示百分比。如果确认响应头里有Content-Encoding: gzip,可以不用 requests 的自动解压,而是设置Accept-Encoding: identity请求头强制服务器返回未压缩内容,代价是可能带宽消耗更大,但进度条会准确。很多下载器干脆放弃服务器给的 Content-Length,改用已知文件大小的元数据,比如从下载页面解析出来。
5.3 文件变成 HTML:重定向和 403 页面没被识别
现象:下载下来的 .zip 文件用压缩软件打不开,用文本编辑器打开发现里面是<html>开头,或者是一段 JSON 错误信息。 原因:两种情况最常见。第一,你请求的 URL 会 302 重定向到另一个页面,而那个页面是 HTML 登录页或错误页,requests 默认自动跟随重定向,最终拿到的是那个页面。第二,服务器对没有正确 User-Agent 或没有携带认证 Cookie 的请求返回 403,403 页面本身也是 HTML。 解决:下载前先打印res.status_code和res.headers.get('content-type'),如果content-type是text/html而你应该下载的是二进制文件,立即停止写入。另外,给 requests 加上真实浏览器的User-Agent,很多服务器只看 UA 就决定给不给你资源。检查重定向后的 URL 可以用res.url确认。还有一个好习惯是将下载内容先写入临时文件,最后用os.rename改为目标扩展名,避免错误内容直接污染正式文件。
5.4 下载变慢或卡死:连接池和超时参数没调好
现象:下载脚本跑着跑着越来越慢,最后卡住不动,或者报Connection pool is full。 原因:如果你每次下载都直接requests.get,而没有用 Session,requests 默认每个主机名只复用有限个连接。批量下载时频繁创建新连接握手,导致速度变慢。另外 timeout 设置不当,比如只给了一个很小的连接超时,网络抖动一下就抛异常;或者不给读超时,一个坏连接会一直等下去。 解决:用requests.Session()统一管理连接池,设置session.headers指定 UA。对超大文件下载,把读超时设置得足够大,比如(10, 120)。如果发现连接池报错,可以调整 Session 的HTTPAdapter参数,但我更建议先检查你是否忘关了 Session。使用with requests.Session() as s:可以确保连接被回收。对于下载资源密集的场景,Session 是必须的。
5.5 僵尸临时文件:中断后残留污染磁盘
现象:下载中断后,磁盘上留下一个.part文件,再次运行脚本从零开始下载,旧临时文件不会清理,日积月累占满 C 盘。 原因:你的下载代码写错了逻辑,比如直接以目标文件名写入,中断后留下的是半个文件,下次运行被覆盖还好;如果你用临时文件模式,但异常时没清理finally,临时文件永远残留。 解决:下载时先写文件名.part,成功完成后os.replace改为目标文件名,这个操作是原子的。用try/except/finally包裹下载逻辑,在finally中判断文件是否下载完成,如果没有就删除.part。如果想支持断点续传,反而不要删除.part,而是把它作为续传的依据。这个习惯能让你的下载脚本在恶劣网络环境下变得可靠。
6. 下载体验再进一步:Session 复用、校验与限速
6.1 多文件下载优先 Session 复用
批量下载几十个文件时,每次都重新握手非常浪费。一个成熟的实现是这样的:
import requests def download_batch(urls, save_dir): with requests.Session() as session: session.headers.update({'User-Agent': 'Mozilla/5.0'}) for index, url in enumerate(urls): try: res = session.get(url, stream=True, timeout=(10, 60)) res.raise_for_status() # 从响应或 URL 中取出文件名,这里简化为序号 path = f"{save_dir}/file_{index}.bin" with open(path, 'wb') as f: for chunk in res.iter_content(64 * 1024): if chunk: f.write(chunk) except requests.RequestException as e: print(f"下载失败: {url} -> {e}")同一个 Session 会复用 keep-alive 连接,省去 TCP 握手时间。注意session.headers.update设置一次即可,之后所有请求都会带上。如果你要并行下载多个文件,注意 Session 线程安全问题——大多数操作是线程安全的,但共享 Session 同时写文件时,要确保文件名不冲突。
6.2 下载完成后用 MD5 确认文件完整性
下载本身只是拿到字节,文件是否完整必须由校验决定。简单做法是先下载,再算本地 MD5,和你从服务器拿到的参考值对比:
import hashlib def file_md5(path, chunk_size=64 * 1024): h = hashlib.md5() with open(path, 'rb') as f: while True: chunk = f.read(chunk_size) if not chunk: break h.update(chunk) return h.hexdigest()如果你的下载页面上有>import time def download_throttled(url, save_path, bytes_per_second=1024 * 1024): res = requests.get(url, stream=True, timeout=(10, 60)) res.raise_for_status() start_time = time.monotonic() downloaded = 0 with open(save_path, 'wb') as f: for chunk in res.iter_content(32 * 1024): if not chunk: continue f.write(chunk) downloaded += len(chunk) expected_time = downloaded / bytes_per_second sleep_time = expected_time - (time.monotonic() - start_time) if sleep_time > 0: time.sleep(sleep_time)
核心逻辑是根据已经下载的字节数计算期望耗时,如果实际耗时小于期望,就 sleep 补上差额。这个算法简单但有效,bytes_per_second设为 0 时会除零,所以调用前要做参数校验。限速后下载时间会变长,但换来的服务器稳定性可能更重要。
我习惯把所有下载脚本都拆成三个层次:请求层、写盘层、校验层。请求层只负责拿流,写盘层只负责按 chunk 落盘,校验层负责验证最后结果。这样无论换什么下载源,脚本主体逻辑都不变,顶多改一改请求头。这套习惯帮我在多个下载项目里少走了很多弯路,希望帮到你。
本文还有配套的精品资源,点击获取