简介:Python开发者常用的requests模块完整资源包,面向因urllib2的API过于繁琐而希望简化HTTP请求代码的Python学习者与工程师。该工具库以“让HTTP服务人类”为设计理念,提供了更直观的请求发送、会话维持、重定向跟踪、超时与异常处理等封装,适合日常接口调试及爬虫场景。压缩包共121个文件,总体积仅643KB,其中76个py文件构成核心源码与测试脚本,20个rst和5个html组成可离线翻阅的文档,其余为构建配置、许可证等支撑文件,便于查看项目全貌。目前已有7017人学习/下载,适合初学者对照源码理解请求库内部实现。读者可从中获得完整的库代码与官方文档,既能通过阅读py模块掌握会话对象、请求与响应模型、连接适配器等设计思路,也能借助rst或html快速查询常用接口用法;项目中的构建脚本与配置文件还可作为学习如何组织开源库的参考。
1. 为什么用 requests 下载文件,总会在第二个文件上翻车
“python requests模块下载”听起来是爬虫入门的第一道菜:拿到 URL,requests.get()一把梭,再把resp.write()写进文件。可这写法撑过第一个小文件靠运气,撑过第二个就难了——网络抖动断在半路,文件太大内存直接吃饱,文件名取出来是一串乱码,重新下载又从 0 开始。
这些坑不全是 requests 的错,而是“下载”和普通 GET 请求的诉求根本不同:普通请求关心响应内容,下载关心的是字节能不能完整、可续、可加速地落盘。这篇笔记按最小命令、参数细节、大文件流式、并发分段、高频踩坑的顺序讲透,适合写爬虫、做数据采集和日常取件的开发者。
2. 安装 requests 并跑通第一个下载:环境与最小代码
2.1 用 pip 装好 requests:镜像源、虚拟环境和依赖链
requests 是第三方库,安装前先确认 Python 环境别乱。我一般会先建一个独立虚拟环境,避免把系统 Python 搞脏:在项目目录下执行python -m venv .venv,然后激活它。Windows 下激活路径是.venv\Scripts\activate,Linux/macOS 下是source .venv/bin/activate。激活后终端提示符会变,说明当前走的是虚拟环境里的 Python。
装包命令只有一行:
pip install requests如果你的网络访问 Python 官方包源很慢,就把-i参数指到内网或地理距离更近的镜像源,团队里更常见的做法是写进pip.conf或pip.ini,后续所有安装都自动走镜像。这里有个容易忽视的点:requests 本身依赖 urllib3、certifi、idna、charset_normalizer,直接pip install requests会把这些依赖一并装好。不建议手动下载 requests 的压缩包解压安装,依赖一多会滚雪球。
装完验证一下:
python -c "import requests; print(requests.__version__)"能打印出版本号,说明安装链路是通的。如果提示找不到模块,先确认当前终端是否在虚拟环境里,再确认 pip 和 python 是不是同一个解释器。
2.2 最小可用的下载代码:get 请求、状态码与二进制写盘
先写一个最简单的下载,目标是“能跑、能存、不把错误页当文件”。以下是完整脚本:
import requests url = "https://example.com/files/demo.bin" save_path = "demo.bin" resp = requests.get(url, timeout=10) resp.raise_for_status() with open(save_path, "wb") as f: f.write(resp.content)timeout=10把连接和读取的总超时设为 10 秒,超过就抛异常,避免脚本卡死。raise_for_status()会在响应码为 4xx/5xx 时直接抛异常,这行非常关键:如果服务器返回的是 404 错误页,没做这个检查,resp.content会被原样写进文件,你得到的是一个“HTML 内容的 .bin 文件”。打开文件用"wb"而不是"w",因为下载的是二进制字节,文本模式会做编码转换,可能把二进制数据写坏。
但要说清楚:resp.content是一次性读完整个响应体。文件小没问题,文件大时这段代码会在内存里先缓存完整内容,再一次性返回。2GB 的文件就直接把进程内存打爆。所以这只是一段“最小可用代码”,真正的大文件场景从第 4 章开始用流式。
注意:
timeout这里传的是总超时。下载大文件时它实际表现为“最大允许空闲时间”,如果后端长时间不吐字节,连接会被掐断。第 3 章会讲拆成连接超时和读取超时的写法。
2.3 下载前先弄清三件事:Content-Type、Content-Length 与文件名
下载不是“打开连接就闷头写”,先看一眼响应头能省很多事。三个字段最常用:
| 响应头字段 | 典型值 | 作用 |
|---|---|---|
| Content-Type | application/zip、application/octet-stream | 判断是不是二进制;HTML 说明可能被重定向/拦截 |
| Content-Length | 字节数 | 预估文件大小、算进度、校验完整性 |
| Content-Disposition | attachment; filename="a.bin" | 服务端建议的文件名,常需要处理编码 |
从 URL 里提取文件名时,不要直接url.split("/")[-1],因为 URL 可能带查询参数,而且路径里可能有百分号编码。稳妥写法:
import posixpath from urllib.parse import unquote, urlparse path = urlparse(url).path file_name = posixpath.basename(path) file_name = unquote(file_name)urlparse先拆出纯路径,把?后面的参数丢掉;posixpath.basename只取最后一段;unquote把%20这类编码还原成原始字符。如果服务端在Content-Disposition里给了明确的文件名,优先用那个,第 5.5 节会单独讲中文文件名的坑。
3. 下载请求的参数细节:请求头、超时、重定向与重试策略
3.1 带完整请求头下载:UA、Referer、Cookie 的落地姿势
不少下载服务器对没有 User-Agent 的请求直接拦截,或者返回一个降级页面。要稳定下载,请求头至少要把 UA、Referer、Accept 这三样补齐:
headers = { "User-Agent": "test-downloader", "Referer": "https://example.com/download-page", "Accept": "*/*", } resp = requests.get(url, headers=headers, timeout=10)UA 字符串用自定义的简单标识只是保底。如果目标站点有反爬策略,优先换成浏览器环境里复制出来的完整 UA,这个动作往往比改什么参数都管用。Referer的作用是声明“你来自哪个页面”,有些下载链接只在特定页面上下文里生效,写成来源页面地址即可。
Cookie 有两种传法:放进 headers 里写Cookie: sessionid=xxx,或者用cookies={"sessionid": "xxx"}参数。后者更干净,requests 会自动拼接。登录后下载的场景建议直接用 Session 管理 Cookie,重定向到文件服务器时 Cookie 不会丢。
3.2 带签名 URL 下载:params、URL 编码与 timeout 元组
下载链接经常带着token、expires这类签名参数。手工拼字符串特别容易出错,因为 token 里的+、=、/属于 URL 保留字符,需要转义。requests 的params参数会帮你做正确编码:
params = { "token": "abc123/+==9098", "expires": "1735689600", "filename": "hello world.zip", } resp = requests.get("https://example.com/download", params=params, timeout=(5, 60))注意这里timeout传的是元组:(连接超时, 读取超时)。连接超时设短一点,比如 5 秒,网络不通时快速失败;读取超时设长一点,比如 60 秒,给大文件传输留够时间。下载场景里读取超时比连接超时更容易被触发,尤其服务器吞吐不稳定时。
签名 URL 还有一个隐晦的坑:如果服务端要求查询参数按特定顺序拼接,params字典在某些 requests 版本下不保证顺序。遇到这种接口,先把params手动拼成完整 URL 再请求,或者在代码里构造PreparedRequest固定参数顺序,避免上线后偶发出错。
3.3 Session + HTTPAdapter:重试、连接池与连接释放
下载场景里网络抖动太常见了,requests 默认不重试,一次断连就要整个任务从头来。我一般给每个下载任务单独建一个带重试策略的 Session:
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry( total=3, connect=2, read=2, backoff_factor=0.8, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET"], ) adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10) session.mount("https://", adapter)backoff_factor=0.8控制重试间隔:第一次等 0.8 秒,第二次 1.6 秒,按指数退避。status_forcelist列出的响应码会触发重试,适合 5xx 这种暂时的服务器错误。allowed_methods=["GET"]限定只对 GET 重试,因为 GET 是幂等的,重复执行不会产生副作用;POST 的重试要非常谨慎。pool_connections和pool_maxsize控制连接池大小,如果后面要多线程并发下载,这两个值必须大于等于并发数,否则请求会排队等连接。
配了重试的 Session,配合stream=True下载大文件,如果连接断在传输中途,urllib3 的重试逻辑会自动处理一部分场景。但如果你自己写循环处理分块,异常发生时要记得resp.close(),否则底层连接不会归还连接池,跑上几十个任务后可能把本地端口耗尽。
4. 大文件流式下载:iter_content、分块写入与进度计算
4.1 为什么大文件不能直接 resp.content
上一章的最小代码对 2GB 的文件就是灾难:resp.content要等整个响应体全部读完才会返回,内存峰值约等于文件大小。加上进程里其他数据,机器稍小一点就直接 OOM。正确做法是先拿响应头,再按块取字节,边读边写:
resp = requests.get(url, stream=True, timeout=10) print(resp.headers.get("Content-Length"))stream=True告诉 requests 不要立刻把响应体读进内存,而是保持底层连接打开,按需读取。这一步的代价是:你必须自己负责读完或者关闭连接。如果中途跳出循环,不调用resp.close(),这个连接不会自动释放。
顺带说一个很多人不知道的点:resp.headers在普通请求里也能用,但 stream 模式下载时它更关键——你要靠Content-Length来预估大小和算进度,这个头在流式场景下才真正发挥作用。
4.2 带进度回调的流式下载工具函数
把流式下载封装成一个函数,后续项目里直接复用。这里给一个带进度输出的版本:
import requests def download_with_progress(url, save_path, timeout=(5, 60), chunk_size=65536): resp = requests.get(url, stream=True, timeout=timeout) resp.raise_for_status() total = int(resp.headers.get("Content-Length", 0)) downloaded = 0 with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=chunk_size): if not chunk: continue f.write(chunk) downloaded += len(chunk) if total: percent = downloaded / total * 100 print(f"\r{percent:6.1f}% | {downloaded}/{total} bytes", end="", flush=True) print() resp.close() return downloaded循环里的if not chunk: continue是过滤 keep-alive 连接产生的空块,不写这行代码,偶尔会在日志里出现写 0 字节文件的诡异现象。Content-Length缺失时total=0,这时跳过百分比计算,避免除零。print用\r回车不换行,加flush=True强制刷新,进度条才能实时更新。函数返回值downloaded是实际写入字节数,给调用方做校验。
chunk_size怎么选?小文件用 8192 最通用,内存占用极小;大带宽下载大文件用 1MB,更容易跑满带宽;块太小时 CPU 开销变大,块太大时进度条不够平滑。我的习惯是:文件超过 500MB 用1 * 1024 * 1024,小于 500MB 用65536。
4.3 落盘姿势:临时文件、part 后缀与完整性核对
直接把文件写到最终路径,下载到一半失败,磁盘上就会留一个“看起来像正常文件”的半截文件,别的任务拿去解析就会报错。我习惯先写<save_path>.part,全部成功后再原子改名:
import os tmp_path = save_path + ".part" downloaded = download_with_progress(url, tmp_path) expected = int(resp_headers.get("Content-Length", 0)) if expected and downloaded != expected: os.remove(tmp_path) raise RuntimeError(f"size mismatch: got {downloaded}, need {expected}") os.replace(tmp_path, save_path)os.replace在 Windows 和 Linux 上都是原子操作,正式文件不会被半截写入。大小校验失败时把.part删掉,由调用方决定是否重试;如果后续要做断点续传,这个.part文件可以留着当断点记录,第 6 章会用到。磁盘空间不足时写入会抛OSError,所以校验前还要处理好异常上下文,避免临时文件堆积。
注意:如果请求头里带了
Accept-Encoding: gzip且服务器返回了压缩内容,Content-Length对应的是压缩后的大小,requests 自动解压后,进度可能会超过 100%。下载二进制文件时不要手动要求压缩,让服务器按原始字节返回。
5. 下载场景五个高频翻车点:现象、原因与排查
5.1 下载一半报 ConnectionError,重试时又从 0 开始
现象:大文件传到一半,客户端抛出ConnectionError: ('Connection aborted.', RemoteDisconnected()),重新跑一遍又从 0 开始下载,已经写好的部分全部作废。
原因:公网链路抖动、对端服务器空闲超时、网关设备在没有数据传输时主动断开连接,都可能掐断长连接。urllib3 的重试逻辑对“建立连接阶段”可靠,但当数据已经流到一半时,连接被断开就难以透明恢复。
解决:先确认服务器是否支持 Range。对支持 Range 的源站,可以断点续传:
downloaded = os.path.getsize(tmp_path) headers = {"Range": f"bytes={downloaded}-"} resp = requests.get(url, headers=headers, stream=True, timeout=(5, 60)) if resp.status_code == 206: with open(tmp_path, "ab") as f: for chunk in resp.iter_content(chunk_size=65536): f.write(chunk)状态码必须是206 Partial Content。如果服务器不支持 Range,它可能返回 200,然后把这个请求当成完整下载处理,你再以追加模式写入就会得到一个双倍大小的坏文件。所以判断完 206 再做追加写入。
5.2 文件大小对但 zip 打开报“文件损坏”
现象:下载完的压缩包,文件大小和服务端Content-Length完全一致,解压时却提示End-of-central-directory signature not found,或者解压到一半报 CRC 错。
原因:最常见的是没做raise_for_status(),把 403/404 的错误 HTML 页面当成文件存下来;另一个可能是响应经过 gzip 压缩,请求头手动声明了Accept-Encoding: gzip,requests 解压后字节流本身没问题,但最终落盘数据和你预期的原始文件不一致。
解决:先检查文件头。ZIP 文件的魔数是PK\x03\x04,读前四个字节就能分辨:
with open(save_path, "rb") as f: magic = f.read(4) if magic != b"PK\x03\x04": print("not a zip, actually:", magic)如果文件开头是<!DOCTYPE html>或<html>,基本可以断定被网关拦截了。排查完把请求头里的Accept-Encoding去掉,让服务器按原始字节返回,再重新下载一次。
5.3 并发下载时内存持续上涨,最后进程被杀
现象:用线程池同时下载十几个文件,刚启动时一切正常,几分钟后内存从几百 MB 涨到数 GB,最后进程被系统杀掉。
原因:最常见的是某个子任务用了resp.content而不是流式写入,或者虽然开了stream=True,却在循环里把分块 append 到列表里再统一写盘。chunks.append(chunk)这种写法在下大文件时,内存会被分块数据无限累积填满。
解决:统一改成流式边读边写,所有分块直接写入文件,不做任何保留;线程池的max_workers不要超过 8,不是越多越快,源站带宽和连接池都会成为瓶颈;连接池的pool_maxsize必须大于等于并发数。写代码时给自己定一条铁律:任何下载函数里,都不允许出现把整个文件或全部分块攒在内存里的写法。
5.4 内网资源报 SSLError,业务被证书卡住
现象:下载公司内部系统的安装包或数据文件时,requests 抛出SSLError: [SSL: CERTIFICATE_VERIFY_FAILED],但同一个地址在浏览器里访问完全正常。
原因:很多内网系统的证书是自签名,或者由内部 CA 签发,Python 的 certifi 证书库里没有这张链,TLS 校验就失败了。这不是 requests 的问题,是信任链缺失。
解决:两害相权,优先把内部 CA 证书导出成.pem文件,传给verify参数:
resp = requests.get(url, verify="/path/to/internal-ca.pem", timeout=10)这样既能保留 TLS 校验,又不依赖系统证书库。如果只是为了临时调试,可以在局部请求里加verify=False,但这样的代码进了生产环境就是风险,我一般在旁边注释写明“该连接未校验证书,仅限内网调试”。内网下载如果长期存在,更推荐在运维侧统一发放内部 CA 证书,而不是逐个改代码。
5.5 响应头里的中文文件名变成乱码
现象:Content-Disposition: attachment; filename*=UTF-8''%E7%94%A8%E6%88%B7%E6%89%8B%E5%86%8C.pdf,直接把响应头字符串取出来,是一串%开头的乱码。
原因:filename*=UTF-8''是 RFC 2231 标准格式,文件名用百分号编码;而旧的filename=格式不带编码声明。requests 的响应头解析不会自动帮你解 Unicode 编码,要自己处理。
解决:写一个解析函数,优先匹配filename*:
import re from urllib.parse import unquote def parse_filename(resp): cd = resp.headers.get("Content-Disposition", "") m = re.search(r"filename\*=UTF-8''(.*)", cd, re.IGNORECASE) if m: return unquote(m.group(1)) m = re.search(r'filename="?([^"]*)"?', cd) return m.group(1) if m else None取到文件名后,还要做一次路径清洗:Windows 路径里不能出现/、:、*等字符,否则写盘时会报错。把这些字符替换成-,比直接抛异常让用户处理体验好得多。
6. 大文件还能更快:Range 并发分段下载与完整性校验
HTTP 的 Range 头不只用于断点续传,它还能把一个大文件拆成多个区间并行拉取。思路是:先 HEAD 请求拿到总大小,按并发数分段,让每个线程只下载自己的区间,最后按顺序合并。源站需要明确支持 Range,不支持时整个策略要退化为普通流式下载。
先写一个分段下载函数:
def fetch_segment(url, start, end, seg_path): headers = {"Range": f"bytes={start}-{end}"} resp = requests.get(url, headers=headers, stream=True, timeout=(5, 60)) try: if resp.status_code != 206: raise RuntimeError("server does not support range") with open(seg_path, "wb") as f: for chunk in resp.iter_content(chunk_size=65536): f.write(chunk) finally: resp.close()主函数负责分段、提交、合并:
from concurrent.futures import ThreadPoolExecutor def parallel_download(url, save_path, segments=4): resp = requests.head(url, allow_redirects=True, timeout=10) total = int(resp.headers["Content-Length"]) resp.close() seg_paths = [] with ThreadPoolExecutor(max_workers=segments) as pool: futures = [] for i in range(segments): start = i * total // segments end = (i + 1) * total // segments - 1 if i == segments - 1: end = total - 1 seg_path = f"{save_path}.part{i}" seg_paths.append(seg_path) futures.append(pool.submit(fetch_segment, url, start, end, seg_path)) for future in futures: future.result() with open(save_path, "wb") as out: for seg_path in seg_paths: with open(seg_path, "rb") as f: while True: block = f.read(65536) if not block: break out.write(block) os.remove(seg_path)校验这一步不能省。合并后先比大小,和 HEAD 拿到的Content-Length一致只是最基础的检查;更严格的做法是拿服务端给出的ETag或sha256校验值,下载完用hashlib对合并文件重新计算一遍。网络传输中字节翻转的概率不高,但被网关改写、被压缩、被截断的情况一直存在,校验是最后一道后悔药。
我现在的下载脚本一定会加一个--check参数:下载完先比大小,再算哈希。确认并发分段只适用于明确支持 Range 的源站,盲开并发只会让连接更快被掐断。这不是玄学,是可复现的工程问题,先探测再决定方案,能让下载脚本少踩很多坑。希望帮到你。
本文还有配套的精品资源,点击获取