ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python requests 模块下载文件:从基础到断点续传与完整校验

Python requests 模块下载文件:从基础到断点续传与完整校验

简介:一份围绕Python requests库的下载资源包,面向需要编写HTTP客户端程序的中级Python开发者,尤其适合希望摆脱urllib/urllib2繁琐调用、追求简洁API的学习人群。包内共121个文件,主体为76个Python源文件,覆盖请求构造、会话保持、SSL证书处理等核心功能;20个reStructuredText文档与5个HTML页面共同构成结构清晰的说明文档;另有配置文件、makefile/bat构建脚本、许可证及logo源文件,方便二次开发与文档重建。整个压缩包仅643KB,轻量紧凑,目录组织合理,便于按需查阅。资源已有7017人学习下载,关注度较高。通过这份资料,读者既能理解requests为什么能用少量代码完成HTTP交互,也能借鉴其源码结构与文档搭建经验,是一份适合源码研读和工程实践的Python网络编程参考资料。

1. 用 requests 模块下载文件,难点从来不是发起请求

需求文档里写着“python requests 模块下载”,你以为就是 get 一下保存文件,结果真正动手才发现:大文件把内存跑爆,断点续传没支持,下载回来的文件解压报错。requests 的下载链路其实就两条:小文件用 content,大文件用 stream;剩下的功夫全在 headers、超时和校验上。这里把这些点逐个拆透,给出可以直接改用的代码。适合用 Python 做资源采集、写自动化同步脚本,或者想把现有下载脚本改稳的开发者,踩过的坑都直接标出来。

2. requests 基础下载:最小骨架、响应对象与重定向的三个暗坑

2.1 安装与最小可用代码:20MB 以内文件的第一版

下载类脚本,第一步永远是装好 requests,然后敲一个最小的下载逻辑。我习惯先跑通链路,再根据文件大小决定要不要上流式:

pip install requests
import requests url = "https://example.com/path/to/demo.zip" resp = requests.get(url, timeout=(5, 15)) resp.raise_for_status() with open("demo.zip", "wb") as f: f.write(resp.content)

代码干了四件事:get 发起请求、raise_for_status 检查 HTTP 状态码、content 拿响应体字节、open 以二进制写入本地文件。timeout 这里用的是元组 (5, 15),前 5 秒是建连超时,后 15 秒是读超时,具体原因第五章专门说。这种写法对 20 MB 以内的小文件很稳,一次性读取,进程内存该用多少用多少,也不用考虑续传。

这里有个新手容易忽略的点:下载的文件名不要硬编码。URL 末尾那段路径经常不是真实文件名,尤其是走 CDN 的下载地址,真实名字在响应头里。解析函数我一般这样写:

import os import re from urllib.parse import unquote def get_filename(resp, url): cd = resp.headers.get("Content-Disposition", "") # 优先用 RFC 5987 的 filename*,它是 UTF-8 编码 m = re.search(r"filename\*=UTF-8''([^;]+)", cd, re.IGNORECASE) if m: return unquote(m.group(1)) # 回退到普通 filename 字段 m = re.search(r'filename="?([^"]+)"?', cd, re.IGNORECASE) if m: return m.group(1) # 最终回退到 URL 路径 return os.path.basename(url.split("?")[0])

解析顺序是 filename*、filename、URL 路径。filename* 是标准做法,中文名不会乱码,unquote 把百分号编码还原;filename 只是兜底;URL 路径是最后的救命稻草。很多网盘跳转地址带时间戳签名,必须从响应头拿真名,这一步省了,后面文件名就是一堆随机字符。

2.2 响应对象的三件套:status_code、content、close

resp 这个对象,在下载场景里只需要盯三样东西:状态码、原始字节、连接释放。状态码用 raise_for_status() 自动化处理,它会在 4xx/5xx 时抛出 HTTPError。但要注意,这个方法只认状态码,不认响应内容,有的服务器对不存在的文件也返回 200,响应体是一段 HTML 错误提示,等下载完做文件头校验才能发现。

content 是响应体的原始字节,跟 text 有本质区别。text 会按猜测的编码去解码,对文本页面没问题,但下载 zip、exe 这类二进制文件,一旦用了 resp.text,数据就已经被解码破坏了,写盘以后文件直接打不开。下载类请求永远只用 content 或者 iter_content,这是血泪教训换来的。

连接释放也需要关注。resp 底层持有 urllib3 的连接池连接,不复用就随手关掉,大量下载循环里会占满连接池,后续请求全部排队。常见做法是显式 resp.close(),或直接拿 with requests.get(...) as resp 包住整个下载流程,退出 with 块时连接自动归还,这比手动 close 更不容易漏。

2.3 重定向的暗坑:自定义 Header 在跳转时掉了

下载地址十有八九要走一次重定向。requests 默认允许重定向,看起来省心,但它有个隐蔽行为:第一次请求带的自定义 Header,在跟随重定向时可能不会传给第二次请求。比如某些下载接口要先带 Referer 或自定义签名头,第一次能过,重定向到实际存储节点时头丢了,返回 403。

如果用 Session,可以在 session 层面统一设置 headers,每次请求都会带上:

import requests session = requests.Session() session.headers.update({ "Referer": "https://example.com/", "User-Agent": "Mozilla/5.0", "X-Sign": "your-signature" }) resp = session.get("https://example.com/download/file.zip", timeout=(5, 30), stream=True)

session.headers 是请求级 headers 的兜底层,每次请求都会带。把签名、Referer 放 session 层,requests 构建新请求时会继承 session 的 headers,问题就绕过去了。如果你的签名头必须按 URL 变化,那就要用 PreparedRequest,在钩子里针对最终地址重新生成签名,复杂度上了一个台阶,普通下载脚本用 session.headers 就够。

3. 大文件下载:stream=True 避免内存崩溃,chunk_size 选型

3.1 为什么 stream=False 会把内存耗尽

默认情况下,requests.get 发出请求后,要等整个响应体读进内存,get 这行才返回。小文件无所谓,几百 MB 的压缩包或日志文件,下载过程和内存增长同步,机器内存不好时,1GB 的文件就把可用内存吃掉大半。更糟的是响应体被全部读进内存后,内容复制到 content 属性时又产生一批临时对象,峰值内存比文件体积高出不少。

stream=True 做的事情是:只拿到响应头,body 保持未读状态,直到你显式遍历 iter_content 或 iter_lines,数据才从网络一层层流进缓冲区。内存占用量基本恒定,不随文件大小变化。怎么判断用哪种?我一般这样定:小于 20MB 直接 content;大于 50MB 直接 stream;20~50MB 看现场机器剩余内存,吃紧就统一 stream,这是工程上最实用的分界线。

3.2 stream=True 的流式写盘代码与进度计算

大文件场景的落地代码长这样:

import requests url = "https://example.com/files/big-package.zip" local_path = "big-package.zip" chunk_size = 256 * 1024 # 256 KB with requests.get(url, stream=True, timeout=(5, 30)) as resp: resp.raise_for_status() total = int(resp.headers.get("Content-Length", 0)) downloaded = 0 with open(local_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 > 0: percent = downloaded / total * 100 print(f"\r{percent:.1f}% ({downloaded}/{total})", end="", flush=True) print("\ndownload done, total bytes:", downloaded)

with requests.get(...) as resp 保证响应连接在 with 块结束后自动归还,省掉手动 close 的重复代码。iter_content 是流式读取的核心,每次从底层网络取 chunk_size 大小的字节块返回给调用方,转手就写进磁盘文件,所以整个下载过程内存里最多同时存在一个 chunk 加一段文件缓冲。

if not chunk: continue 用来过滤 keep-alive 心跳产生的空数据块,这类块长度为 0,直接跳过。进度百分比依赖 Content-Length,只要这个头存在且可信,计算就准确。total 为 0 表示服务器没给长度,这时只累加 downloaded,不强行显示百分比,避免显示错误进度的尴尬。

3.3 chunk_size 参数怎么选,弱网跟宽带完全是两套思路

chunk_size 是最容易被随手填一个 1024 了事的参数,但它直接决定下载性能和断点灵敏度。从原理上讲,iter_content 每次返回一个 chunk,内部做一次 recv 到内存块,然后交给你的代码。chunk_size 太小,比如 1024 字节,100MB 的文件要迭代十万次,Python 层面的循环和函数调用开销被放大,下载速度上不去。chunk_size 太大,比如 64MB,每次 recv 要等网络堆这么多字节才返回,内存占用又高起来,一个 chunk 卡住的时间变长,超时判断粒度变粗。

我一般这样选:常规公共网络用 256KB~1MB,兼顾速度与内存;内网或宽带环境直接上 1MB;真遇到弱网、频繁抖动,降到 32KB~64KB,单个 chunk 下载时间短,某一块卡住重试代价也小。没有绝对正确值,但有一个原则:让单个 chunk 的下载耗时落在 0.5~2 秒之间,这个区间重试体验最好。

4. 断点续传与并发下载:用 Range 头补齐 requests 的短板

4.1 先探测服务器是否支持 Range:206 与 200 的区别

requests 本身对断点续传没有内置封装,但 HTTP 协议层面的 Range 头是标准能力。带 Range 发 GET,服务器支持续传就返回 206 Partial Content,响应体是请求的那段字节;不支持就会忽略 Range 头,返回 200 和完整内容。写续传之前先探测一下:

import requests url = "https://example.com/files/big-package.zip" resp = requests.get(url, headers={"Range": "bytes=0-"}, timeout=(5, 10), stream=True) print("status:", resp.status_code) print("Content-Range:", resp.headers.get("Content-Range")) resp.close()

bytes=0- 表示从第 0 字节开始到文件末尾,等价于请求完整文件但带上了 Range 字段。返回 206 就是支持,Content-Range 形如 bytes 0-1000/1001,斜杠后面是文件总大小;返回 200 就说明服务器不支持或没理会 Range,续传逻辑要退化为普通下载。这个探测请求用 stream=True 同时立刻 close,是为了只拿响应头、不拉完整 body。

4.2 断点续传:从本地已有字节数构造 Range 头

可靠的续传脚本,第一步不是发请求,而是看本地已存在的文件有多大。这里有个容易出错的细节:本地已有的半截文件必须已经完整写入磁盘,而不是停留在 Python 缓冲区里。如果上一次下载写了一半就崩,本地文件大小就是进度,但内容可靠性存疑。我的做法是续传前先做一次文件头校验,确认前几个字节是正确的文件魔数,再决定要不要续传。

续传代码:

import os import requests url = "https://example.com/files/big-package.zip" local_path = "big-package.zip" chunk_size = 256 * 1024 downloaded = os.path.getsize(local_path) if os.path.exists(local_path) else 0 headers = {"Range": f"bytes={downloaded}-"} mode = "ab" if downloaded > 0 else "wb" with requests.get(url, headers=headers, stream=True, timeout=(5, 30)) as resp: if resp.status_code == 206: total = downloaded + int(resp.headers.get("Content-Length", 0)) else: # 服务器不支持 Range,只能从头下 downloaded = 0 mode = "wb" total = int(resp.headers.get("Content-Length", 0)) with open(local_path, mode) as f: for chunk in resp.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) downloaded += len(chunk)

Range 头格式是 bytes=起始字节-,直接拿本地文件大小作为起始位置。文件没下过,downloaded 为 0,Range 等价于请求完整文件;服务器若忽略 Range 返回 200,说明不支持续传,只能从头写起,所以把 downloaded 归零、模式改成 wb,避免续传数据追加到旧文件后面造成错位。这个回退逻辑是续传脚本的关键兜底。

4.3 并发分段下载:ThreadPoolExecutor 与 Range 分片

先把文件总长度拿到,分割成几个区间,每个区间用一个线程分段请求,最后按顺序拼回文件。并发能提高带宽利用率,但前提是服务器支持 Range。

import concurrent.futures as cf import requests def download_range(url, start, end, idx): headers = {"Range": f"bytes={start}-{end}"} with requests.get(url, headers=headers, stream=True, timeout=(10, 60)) as resp: resp.raise_for_status() data = resp.content return idx, data def concurrent_download(url, total_size, parts=4): part_size = total_size // parts futures = [] with cf.ThreadPoolExecutor(max_workers=parts) as pool: for i in range(parts): start = i * part_size end = start + part_size - 1 if i < parts - 1 else total_size - 1 futures.append(pool.submit(download_range, url, start, end, i)) results = sorted((f.result() for f in futures), key=lambda x: x[0]) return b"".join(data for _, data in results)

parts 控制并发数,生产环境控制在 4~8 比较合理,太多会给服务器造成压力,也可能触发限速。每段数据在内存里组装,适合单文件不超过几百 MB 的场景;更大的文件应该像流式一样逐段写到不同临时分片文件,最后再合并。注意部分商业网盘对分段并发返回 403,这类场景切换回单线程流式更稳。单线程续传适用范围最广,并发分段更像是带宽充裕时的加速手段。

5. 避坑与排查:下载翻车的 5 个实录,每个都能定位

5.1 timeout 必须拆开设置,并写好重试框架

先讲最常见的错误写法:requests.get(url, timeout=5)。这里 timeout 同时作用于建连和读数据。问题是读数据阶段,如果服务器一直慢慢吐数据,总时间超过 5 秒就中断,大文件下载几乎必挂;反过来读超时设得太长,一次网络假死能让你空等几十秒。正确做法是拆成元组:

resp = requests.get(url, timeout=(5, 20)) session = requests.Session() adapter = requests.adapters.HTTPAdapter( max_retries=3, pool_connections=10, pool_maxsize=10 ) session.mount("http://", adapter) session.mount("https://", adapter)

第一个 5 是 connect 超时,第二个 20 是 read 超时,意思是两次数据包间隔不超过 20 秒。重试我一般不用手动 while,直接用 HTTPAdapter 的 max_retries,它对连接错误重试 3 次。但 HTTP 状态码层面的重试策略有限,想要对 5xx 也重试,可以写一个带 Retry-After 的循环:

import time import requests def get_with_retry(url, max_retries=3, timeout=(5, 30)): for attempt in range(max_retries): try: resp = requests.get(url, timeout=timeout, stream=True) if resp.status_code == 503: retry_after = int(resp.headers.get("Retry-After", 1)) resp.close() time.sleep(min(retry_after, 10)) continue resp.raise_for_status() return resp except (requests.Timeout, requests.ConnectionError) as e: print(f"attempt {attempt+1} failed: {e}") time.sleep(2 ** attempt) raise RuntimeError("all retries failed")

503 是服务器过载的典型响应,配合 Retry-After 头做等待。网络超时或连接错误用指数回退,第一次等 1 秒、第二次 2 秒、第三次 4 秒,避免重试风暴。返回的 resp 是 stream=True 的,后面接流式写盘逻辑,这套写法覆盖率够高。

5.2 坑一:200 返回的却是 HTML 错误页

现象:脚本提示下载完成,用压缩软件打开,报格式未知或文件已损坏,用 file 命令一看是个 HTML 文件。

原因:服务器对不存在或无权访问的路径,没有返回 404/403,而是返回 200,响应体是统一错误页模板。HTTP 状态码正确但业务逻辑上是失败的,raise_for_status 拦不住。

解决:下载后立刻检查文件头魔数,常见格式前几个字节固定:

MAGIC = { "zip": b"PK\x03\x04", "gzip": b"\x1f\x8b", "pdf": b"%PDF", "png": b"\x89PNG", } with open(local_path, "rb") as f: head = f.read(4) for name, magic in MAGIC.items(): if head.startswith(magic): print(f"valid {name} file") break else: print("invalid file, check Content-Type or server response")

这段检查放在下载完成后、解压或导入前,能拦截绝大多数伪成功下载。弱网环境个别下载中途中断,文件头也可能没问题,这时还得靠大小校验兜底。

5.3 坑二:进度条卡死加重试死循环

现象:进度显示到某个百分比就停止刷新,几十秒没变化;日志里出现大量重复的同类重试记录。

原因:读超时设置太长,底层 socket 已经挂死但连接没切断;或者重试逻辑没有区分连接失败和读取超时,一直重试同一台故障节点。

解决:把 read 超时控制在 10~20 秒,让 iter_content 在单块卡住时快速抛异常;重试时考虑换 Host 或直接放弃部分分片。我习惯把大文件分成多个临时分片,哪个分片失败单独重试哪个,而不是从 0% 重新来。单个 URL 连续失败两次以上就不做第三次,写进日志,让上层任务调度处理。

5.4 坑三:断点续传出来一个损坏的文件

现象:206 续传流程走完,文件能下满,但解压时 CRC 报错。

原因:本地半截文件本身就是残缺的,残缺位置不一定在文件末尾。如果上次用 wb 模式写到一半进程被 kill,文件里可能有尾部残缺块;下次直接拿这个 size 当续传起点,文件就永久性地缺了一块。

解决:本地文件不是简单的 size 大于 0 就能续传,先做文件头校验,确认前 4~8 字节是合法魔数,再做大小判断。更稳的做法是下载全程用 .part 临时文件,完成后用 os.replace 换成正式文件名,让半截文件永远没有机会被当成正式文件使用,这个习惯第六章详细展开。

5.5 坑四:文件名乱码与 Content-Length 缺失

现象:文件名解析出来是 %E4%B8%AD 这种百分号串,或者直接为空;同时进度条百分比消失。

原因:服务器返回的 Content-Disposition 优先给了 filename*,解析代码却先读了 filename,拿到乱码;Content-Length 缺失多发生在 chunked 传输编码场景,响应体没有明确总长度。

解决:文件名解析按 2.1 节顺序走,优先 filename* 并 unquote 还原。Content-Length 缺失时,进度条改为显示已下载字节数,不做百分比,下载完成后用记录的总字节数和本地文件大小做最终校验。requests 对 chunked 响应的迭代是自动完成的,所以缺失长度影响的主要是进度显示,不是下载本身。

6. 下载完不急着解压:文件完整性校验的三个小习惯

6.1 核对字节数:下载记录与磁盘实际大小

下载完成的瞬间是最容易松懈的时候。第一个习惯是核对字节数,把脚本里累加的 downloaded 和本地 os.path.getsize 对比,不一致就直接删掉重下。服务器没给 Content-Length 时,这个核对就是本地唯一的事实来源。

6.2 文件头和哈希双重确认

第二个习惯是文件头和哈希双重确认。文件头验的是格式,ZIP 前四字节是 PK\x03\x04,PDF 是 %PDF,GZIP 是 \x1f\x8b,读前几个字节就能识别错误页。哈希验的是内容,用流式计算 SHA256,能确认文件与服务器发布完全一致:

import hashlib def sha256_file(path, chunk_size=256 * 1024): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(chunk_size), b""): h.update(chunk) return h.hexdigest()

hashlib 是内置库不用装第三方包,分块更新让内存占用保持在 KB 级。服务器没提供 sha256 时,用 zipfile 模块的 testzip() 检查压缩包结构,也是低成本替代。

6.3 临时文件加原子替换:半截文件的后悔药

第三个习惯,也是最重要的,是写临时文件再原子替换。下载写进一个带 .part 后缀的文件,全部校验通过后用 os.replace 换名。os.replace 是原子操作,新文件要么完整出现,要么不出现,半截损坏永远不会被误用为正式文件。

之前跑离线资源包同步时,脚本因为直接覆盖正式文件,同一个包在断网时被截断覆盖过两次,文件头没查出来,最后是下游解压报错才定位。从那以后我每次写下载脚本都强制走一遍临时文件、字节数核对、哈希校验三段流程。如果这套习惯能帮你的下载项目少翻一次车,这篇记录就没白写。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进