资讯动态

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

发布时间:2026/10/19 8:51:03 来源:尧图企业网站定制
简介一份围绕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 requestsimport 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(rfilename\*UTF-8([^;]), cd, re.IGNORECASE) if m: return unquote(m.group(1)) # 回退到普通 filename 字段 m re.search(rfilename?([^])?, 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、closeresp 这个对象在下载场景里只需要盯三样东西状态码、原始字节、连接释放。状态码用 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), streamTrue)session.headers 是请求级 headers 的兜底层每次请求都会带。把签名、Referer 放 session 层requests 构建新请求时会继承 session 的 headers问题就绕过去了。如果你的签名头必须按 URL 变化那就要用 PreparedRequest在钩子里针对最终地址重新生成签名复杂度上了一个台阶普通下载脚本用 session.headers 就够。3. 大文件下载streamTrue 避免内存崩溃chunk_size 选型3.1 为什么 streamFalse 会把内存耗尽默认情况下requests.get 发出请求后要等整个响应体读进内存get 这行才返回。小文件无所谓几百 MB 的压缩包或日志文件下载过程和内存增长同步机器内存不好时1GB 的文件就把可用内存吃掉大半。更糟的是响应体被全部读进内存后内容复制到 content 属性时又产生一批临时对象峰值内存比文件体积高出不少。streamTrue 做的事情是只拿到响应头body 保持未读状态直到你显式遍历 iter_content 或 iter_lines数据才从网络一层层流进缓冲区。内存占用量基本恒定不随文件大小变化。怎么判断用哪种我一般这样定小于 20MB 直接 content大于 50MB 直接 stream20~50MB 看现场机器剩余内存吃紧就统一 stream这是工程上最实用的分界线。3.2 streamTrue 的流式写盘代码与进度计算大文件场景的落地代码长这样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, streamTrue, 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_sizechunk_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, flushTrue) 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 先探测服务器是否支持 Range206 与 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: bytes0-}, timeout(5, 10), streamTrue) print(status:, resp.status_code) print(Content-Range:, resp.headers.get(Content-Range)) resp.close()bytes0- 表示从第 0 字节开始到文件末尾等价于请求完整文件但带上了 Range 字段。返回 206 就是支持Content-Range 形如 bytes 0-1000/1001斜杠后面是文件总大小返回 200 就说明服务器不支持或没理会 Range续传逻辑要退化为普通下载。这个探测请求用 streamTrue 同时立刻 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: fbytes{downloaded}-} mode ab if downloaded 0 else wb with requests.get(url, headersheaders, streamTrue, 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_sizechunk_size): if chunk: f.write(chunk) downloaded len(chunk)Range 头格式是 bytes起始字节-直接拿本地文件大小作为起始位置。文件没下过downloaded 为 0Range 等价于请求完整文件服务器若忽略 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: fbytes{start}-{end}} with requests.get(url, headersheaders, streamTrue, timeout(10, 60)) as resp: resp.raise_for_status() data resp.content return idx, data def concurrent_download(url, total_size, parts4): part_size total_size // parts futures [] with cf.ThreadPoolExecutor(max_workersparts) 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), keylambda x: x[0]) return b.join(data for _, data in results)parts 控制并发数生产环境控制在 4~8 比较合理太多会给服务器造成压力也可能触发限速。每段数据在内存里组装适合单文件不超过几百 MB 的场景更大的文件应该像流式一样逐段写到不同临时分片文件最后再合并。注意部分商业网盘对分段并发返回 403这类场景切换回单线程流式更稳。单线程续传适用范围最广并发分段更像是带宽充裕时的加速手段。5. 避坑与排查下载翻车的 5 个实录每个都能定位5.1 timeout 必须拆开设置并写好重试框架先讲最常见的错误写法requests.get(url, timeout5)。这里 timeout 同时作用于建连和读数据。问题是读数据阶段如果服务器一直慢慢吐数据总时间超过 5 秒就中断大文件下载几乎必挂反过来读超时设得太长一次网络假死能让你空等几十秒。正确做法是拆成元组resp requests.get(url, timeout(5, 20)) session requests.Session() adapter requests.adapters.HTTPAdapter( max_retries3, pool_connections10, pool_maxsize10 ) 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_retries3, timeout(5, 30)): for attempt in range(max_retries): try: resp requests.get(url, timeouttimeout, streamTrue) 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(fattempt {attempt1} failed: {e}) time.sleep(2 ** attempt) raise RuntimeError(all retries failed)503 是服务器过载的典型响应配合 Retry-After 头做等待。网络超时或连接错误用指数回退第一次等 1 秒、第二次 2 秒、第三次 4 秒避免重试风暴。返回的 resp 是 streamTrue 的后面接流式写盘逻辑这套写法覆盖率够高。5.2 坑一200 返回的却是 HTML 错误页现象脚本提示下载完成用压缩软件打开报格式未知或文件已损坏用 file 命令一看是个 HTML 文件。原因服务器对不存在或无权访问的路径没有返回 404/403而是返回 200响应体是统一错误页模板。HTTP 状态码正确但业务逻辑上是失败的raise_for_status 拦不住。解决下载后立刻检查文件头魔数常见格式前几个字节固定MAGIC { zip: bPK\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(fvalid {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\x04PDF 是 %PDFGZIP 是 \x1f\x8b读前几个字节就能识别错误页。哈希验的是内容用流式计算 SHA256能确认文件与服务器发布完全一致import hashlib def sha256_file(path, chunk_size256 * 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 是原子操作新文件要么完整出现要么不出现半截损坏永远不会被误用为正式文件。之前跑离线资源包同步时脚本因为直接覆盖正式文件同一个包在断网时被截断覆盖过两次文件头没查出来最后是下游解压报错才定位。从那以后我每次写下载脚本都强制走一遍临时文件、字节数核对、哈希校验三段流程。如果这套习惯能帮你的下载项目少翻一次车这篇记录就没白写。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑