☰
Python requests模块下载:从入门到工程化文件下载实战
2026/10/15 1:25:11 网站建设 项目流程

简介:requests是Python生态中广受好评的HTTP客户端库,相比标准库urllib2,其API更简洁、语义更清晰,这份压缩包正是围绕requests模块整理而成,适合Python初学者、爬虫开发者以及需要在项目中快速封装网络请求的工程人员。压缩包共121个文件,主体为76个Python脚本,配合20个rst文档、5个HTML示例页面,以及少量配置文件、许可证和辅助脚本,构成一套完整的源码加文档示例集合,压缩后仅643KB,轻量易用。目前已有7014人学习或下载,在同类资源中关注度较高。读者可从中看到requests库的典型调用方式、参数传递、会话保持、异常处理等高频场景代码,并通过rst与HTML文档理解模块的设计思路与扩展方法。相比直接阅读官方文档,这份资源更贴近实际工程,能帮助快速上手并规避urllib2时代的繁琐写法,适合边看边练。

1. python requests 模块下载:装完只是开始

搜索“python requests 模块下载”的人,多半不是缺一条 pip install 命令,而是装完之后不知道下一步怎么写才稳。requests 是 Python 生态里使用面最广的 HTTP 客户端库,底层封装 urllib3,把 URL 拼接、请求头组装、响应解码、连接复用这些重复劳动全部收拢成几个参数。它适合两类人:一类是刚写脚本的开发者,想用最短代码完成接口请求和文件下载;另一类是写过一段时间脚本、但没系统处理过超时、重试、断点续传的熟手。这份笔记从安装开始,把 requests 的参数语义、流式下载、异常排查和工程化配置串成一条完整链路。你能看到的不只是“怎么写”,还有“为什么这么写”以及“哪里会翻车”。

2. 环境准备与首条请求:虚拟环境、pip 安装与响应读取

requests 的安装门槛极低,但“安装”这件事里藏着环境隔离、版本锁定、离线部署三个细节。在生产服务器上我一般会新建虚拟环境,而不是直接往系统 Python 里塞包;在离线内网环境,则需要提前把依赖包下载成 wheel 文件。这一章先把环境搞定,再发第一条真实请求。

2.1 虚拟环境与 pip 安装:锁定版本与离线部署

虚拟环境解决的是“脏依赖”问题。系统 Python 通常被多个项目共用,你装一个 requests 2.32.3,另一个项目可能就需要旧版,直接覆盖会产生连锁故障。所以常规做法是先建隔离环境,再安装依赖。

python -m venv req_env source req_env/bin/activate pip install requests==2.32.3 python -c "import requests; print(requests.__version__)"

第一行创建虚拟环境目录,第二行激活(Windows 下执行 req_env\Scripts\activate),第三行安装并锁定版本,最后一步验证安装结果。requests 自身还依赖 urllib3、certifi、idna、charset_normalizer,pip 安装时自动拉取,不需要手工处理。参数说明:==2.32.3 是硬锁定版本,防止某次升级改变行为;如果网络到官方源慢,可以在命令末尾加 -i 参数指向可用的 PyPI 镜像,速度差别很明显。

离线内网环境是另一个常见场景。我一般会在一台能联网的机器上执行 pip download requests==2.32.3 -d ./wheels,把依赖的 wheel 全装进一个目录,然后整个目录拷进内网,用 pip install --no-index --find-links=./wheels requests 完成安装。注意 --no-index 的含义是强制不从远端索引拉包,只认本地目录,避免内网环境里 pip 还傻等网络。这段的坑在于:手工拷贝时容易漏掉 requests 的间接依赖,你在外网打包时最好直接对 requests 做 pip download,pip 会把依赖树一并拉下来。

2.2 首条 GET 请求:text、content、json 三种响应体

环境就绪后,第一条请求用 GET 打到一个测试接口。下面这个例子请求 example.com 的接口,并分别演示三种读取响应体的方式。example.com 是保留示例域名,你实际对接时换成目标地址即可。

import requests resp = requests.get( "https://example.com/api/info", params={"page": 1}, timeout=(3, 5), ) print(resp.status_code) print(resp.headers.get("Content-Type")) print(resp.text[:200]) print(resp.content[:200])

requests.get 返回一个 Response 对象,status_code 是 HTTP 状态码,text 是按响应头 charset 解码后的字符串,content 是未经解码的原始字节。一次请求同时拿到两种形态,是因为 requests 在底层已经把响应体完整读进内存,text 和 content 只是两种视图。如果你对接的是一个 JSON 接口,直接 resp.json() 更方便,它会用 json 库解析 content 并自动处理编码。

text 和 content 的选择容易踩坑:当服务器响应头没有 charset 字段时,requests 会用兜底编码猜测,猜错之后 text 就是乱码,content 反而完好。所以我在脚本里习惯优先用 content,需要字符串时手动 decode("utf-8", errors="replace")。errors 参数的作用是遇到无法解码的字节时用替换符兜底,而不是直接抛异常把整个程序打断。状态码处理方面,不要只看 status_code 等于 200 就继续,我一般会调 resp.raise_for_status(),它在 4xx/5xx 时自动抛 HTTPError,比手动写 if 判断更省事,异常信息里还带 URL 和状态码,排查时一眼就能定位。

2.3 POST 与 data/json:接口对接最容易翻车的地方

GET 拿到数据之后,第二个高频操作是 POST 提交。requests 的 post 方法里有两组容易混淆的参数:data 和 json。直接用例子看差异。

import requests # 表单提交:Content-Type 为 application/x-www-form-urlencoded resp = requests.post( "https://example.com/form", data={"name": "test"}, timeout=(3, 5), ) # JSON 提交:Content-Type 为 application/json resp = requests.post( "https://example.com/api", json={"name": "test"}, timeout=(3, 5), ) print(resp.status_code) print(resp.text[:300])

用 data 传字典时,requests 会把数据编码成表单格式;用 json 传字典时,它会先做 JSON 序列化,再设置 Content-Type: application/json。后端框架就是靠这个 Content-Type 决定把请求体解析成表单字段还是 JSON 对象。所以接口文档写的要求是 JSON,你却用 data,服务端大概率读不到字段,返回 400 或者干脆没反应。

我遇到过最典型的情况是登录接口:文档上写着 body 是 JSON,代码里用的是 data={"username": "...", "password": "..."},本地测了很久都不通,抓包一看 Content-Type 是表单。改成 json= 之后一次通过。如果你是给第三方系统写对接脚本,先抓包确认目标接口实际接受的 Content-Type,再决定用哪个参数。还有个隐藏点:json= 传中文时不需要手动做 URL 编码,requests 会把整个对象序列化为 UTF-8 的 JSON 字符串,编码这一层它替你做完了。

3. 请求参数与运行机制:Params、Headers、超时与重定向

requests 的 API 设计得很“平”,但平不代表没有语义差别。params 管 URL 查询串,data 和 json 管请求体,headers 管服务端对“你是谁”的判断,timeout 管请求的生死线。这一章把这几组参数放在同一个画面里,讲清楚每个参数的适用边界。

3.1 params / data / json:三种传参方式选哪个

这三个参数不是等价物,它们修改的是 HTTP 报文的不同部分。

参数作用位置编码方式典型场景
paramsURL 查询串自动拼接为 ?a=1&b=2GET 查询、翻页、过滤
data请求体application/x-www-form-urlencoded表单登录、老接口
json请求体application/jsonREST API、嵌套结构

params 适合 GET 请求,requests 会把字典按 key=value 拼到 URL 后面,并且处理特殊字符的转义。中文和空格在 URL 里不能直接出现,params 自动做完百分号编码,你不需要手工 urllib.parse.urlencode。data 和 json 的区别,上一章已经演示过:同一份字典,换一个参数,Content-Type 就变了。很多人在一个接口上反复测试不通过,根源就是没看懂这三者的边界。

写一个工具脚本时我习惯把接口参数按这三类拆开:查询条件放 params,表单放 data,结构化业务数据放 json。拆分的价值不在美观,而在排查:抓包时你能直接从报文格式判断是自己传错了还是服务端解析错了。如果你传的是 dict 但字段值是一个列表,data 会把它编码成多个同名键,json 会保留为数组,这两种表达对应后端完全不同的解析逻辑,对接前先确认目标接口对重复字段的约定。

3.2 请求头伪装:User-Agent、Referer 与 Session 组合

requests 默认的 User-Agent 是 python-requests/2.32.3 这样的字符串,服务端一眼就能识别是脚本。很多站点对这类请求直接 403,所以第一步是换成浏览器 UA。

import requests ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36") s = requests.Session() s.headers.update({ "User-Agent": ua, "Referer": "https://example.com/", "Accept-Language": "zh-CN,zh;q=0.9", }) resp = s.get("https://example.com/api/data", timeout=(3, 5)) print(resp.status_code)

这里用 Session 而不是直接 requests.get,有两个原因:第一,Session 内部维护 CookieJar,登录接口返回的 Set-Cookie 会被自动保存并带上后续请求;第二,同一个 Session 会复用 TCP 连接,频繁调接口时减少三次握手开销。headers.update 设置的请求头对整个 Session 内所有请求生效,省去每个请求单独传 headers 的重复代码。

Referer 是另一道常见校验。下载类接口尤其喜欢检查 Referer,判断请求是不是从站点页面点进来的,直接裸请求会返回 403 或 304。遇到这种情况,把浏览器开发者工具里网络面板的实际请求复制出来,Headers 里的 UA、Referer、Origin、Cookie 逐项对照填进你自己的请求。我一般会先在浏览器里抓一次真实请求,把关键请求头同步进脚本,而不是凭经验猜。注意 Session 的请求头是“合并策略”:Session.headers 里的值,会被单次请求传入的 headers 覆盖,但只影响那一次,不会污染 Session 全局配置。多账号切换时,用 Session 逐个创建实例,比手动改 Cookie 头要干净。

3.3 timeout 与重定向:连接语义和跳转处理

timeout 是很多人用了但没真正理解的参数。它控制的是“连接建立”和“数据读取”两个阶段的超时。

import requests resp = requests.get( "https://example.com/redirect", allow_redirects=False, timeout=(3, 5), ) if resp.status_code in (301, 302, 303, 307, 308): print("Location:", resp.headers.get("Location"))

timeout=(3, 5) 的含义:3 秒内建立 TCP 连接,5 秒内读到数据。如果只传一个数字,两个阶段共用同一个值。设置连接超时是因为连不上的服务器没必要等太久;设置读超时是因为服务器接受了请求但迟迟不返回数据,不能让线程无限挂起。allow_redirects=False 时,requests 不会自动跟随 3xx 跳转,你可以从 Location 头取出真实目标地址,再做二次处理。文件下载场景里,CDN 链接经常先返回 302 到实际存储节点,默认跟随是没问题的;但如果你要记录最终落盘 URL,用 resp.url 能看到跳转后的地址。

重定向还有一个和 stream 相关的点:stream=True 时默认仍然跟随重定向,但重定向耗掉的等待时间会计入你的流程总时长。如果目标服务器对跳转接口本身响应很慢,请求会卡在跳转这一步,而不是卡在文件下载。排查这类问题时,打开 resp.history,它记录了所有跳转的响应对象,历史长度就是跳转次数。很多“下载卡住”的玄学问题,最后查出来是重定向环节超时,文件接口本身没毛病。你在循环里复用同一个 Session 调大量 URL 时也一样,timeout 记得用元组形式,别图省事传一个数字。

4. 文件下载实战:流式读取、断点续传与完整性校验

把 requests 用在文件下载上,才算碰到它最容易出问题的地方。对一个大文件直接 resp.content,内存和磁盘双重告急。这一章给出大文件下载的完整方案:先讲流式读取的标准姿势,再讲 Range 实现断点续传,最后补一个 MD5 校验,保证下载完的文件是完整的。

4.1 流式读取:大文件下载的标准姿势

先看反面示例。很多人在下载文件时这么写:

resp = requests.get("https://example.com/file.zip", timeout=(5, 30)) with open("file.zip", "wb") as f: f.write(resp.content)

文件小于 50MB 时这么写没什么体感,但到了几百 MB 甚至 GB 级,resp.content 会先把整个响应体加载进内存,内存占用瞬间拉高,机器内存不足时直接 OOM。正确的方案是让响应体流式进入,边接收边写磁盘。

import requests def download_file(url, local_path): 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=8192): if not chunk: continue f.write(chunk) downloaded += len(chunk) if total: print(f"\r{downloaded / total * 100:.1f}%", end="") print("\ndownload done")

stream=True 告诉 requests 不要等整个响应体到达再返回值,而是连接保持打开,响应体数据分块到达。iter_content(chunk_size=8192) 按 8KB 一块迭代,每一块拿到就立刻写入磁盘。8192 这个值是我常用的起始值,磁盘 IO 和内存占用之间比较均衡;追求速度可以调到 65536,但在弱网环境大块反而容易被半路切断,导致本次迭代拿到的数据长度小于预期。

参数说明里有两个细节值得记:一是 with 语句管理 response,无论下载中途是否抛异常,response 都会关闭,底层的 TCP 连接能正常释放回连接池;二是 Content-Length 不一定存在,分块传输编码的响应就没有这个头,total 取 0 时进度条退化成只显示 “download done”,脚本仍然可以跑完。还有一个容易忽略的行为:iter_content 内部会处理响应解码,拿到的是解码后的字节,不是你直接在网络上抓到的压缩字节。也就是说即使响应头里 Content-Encoding: gzip,你写入磁盘的也是解压后的数据,不需要自己做解压。

4.2 Range 断点续传:从 200 到 206 的差异处理

下载任务中断后从头再来,在弱网环境里非常折磨人。HTTP 协议本身支持 Range 头,服务端可以只返回文件从某个偏移量开始的后半段。requests 对 Range 没有做专门的封装,你只需要在 headers 里声明它。

import os import requests def resume_download(url, local_path): existing = os.path.getsize(local_path) if os.path.exists(local_path) else 0 headers = {"Range": f"bytes={existing}-"} resp = requests.get(url, headers=headers, stream=True, timeout=(5, 30)) if resp.status_code == 206: mode = "ab" else: mode = "wb" existing = 0 total = int(resp.headers.get("Content-Length", 0)) + existing done = existing with open(local_path, mode) as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) done += len(chunk) print(f"\r{done / total * 100:.1f}%", end="") print("\nresume done")

逻辑说明分三步。第一步,os.path.getsize 读取本地已下载字节数,文件不存在时记 0。第二步,把偏移量写进 Range 头,bytes=1024- 表示从第 1024 字节开始返回。第三步,服务端支持 Range 时返回 206 Partial Content,此时文件用追加模式 "ab" 写,existing 保留;服务端不支持时返回 200 全量内容,此时必须回退到覆盖模式 "wb",existing 归零,否则文件会越写越长。

这里最容易记混的是 Content-Length。206 响应里的 Content-Length 是“剩余字节数”,不是整个文件大小,所以计算 total 要加 existing。如果你在续传完成后发现文件大小对不上,优先检查这一行。还有一个边界情况:续传时原文件已经下载完,Range 头会变成 bytes=完整大小-,此时服务端返回 416 Range Not Satisfiable,代码需要单独处理,一般做法是删除已存在文件重新下载,或者直接返回“已完成,跳过”。我在脚本里习惯先比较 existing 和总大小,相等就提前 return,避免多打一次请求。

4.3 完整性校验:MD5 分块计算与 Content-Range 核对

文件下载完成不意味着内容正确。中间网络闪断、磁盘抖动都可能让文件损坏。HTTP 协议不保证响应体落盘后的完整性,所以下载器一般会拿服务端提供的 MD5 或文件大小做校验。requests 场景下做 MD5 校验要用分块读取,一次性 read 一个大文件同样会吃满内存。

import hashlib def md5_of_file(path, chunk_size=8192): h = hashlib.md5() with open(path, "rb") as f: while True: block = f.read(chunk_size) if not block: break h.update(block) return h.hexdigest()

这个函数把文件按 8KB 分块喂给 md5 累加器,最终得到整个文件的校验值,和服务器下发的 MD5 字符串比对即可。如果服务器只给了 Content-Length 没有给 MD5,有一个低成本替代方案:断点续传完成后,从响应头里找 Content-Range,它的格式是 bytes start-end/total,把 total 部分取出来,和本地文件实际大小比对。不一致就说明续传链路里有数据错位,删除重下。

文件校验要考虑性能。GB 级文件跑 MD5 本身就是一二十秒的量级,别在下载回调里同步做,放下载后单独跑。对下载器里的每个文件都校验,能挡掉大部分“文件怎么打不开”的玄学问题。另外,服务端给的 MD5 有时候是文件未压缩前的哈希,而 requests 在流式模式下已经帮你做了解码,这时候直接对比可能不相等。遇到这种情况,检查服务端文档里校验值针对的是传输字节还是原始内容,必要时对压缩字节再做一次哈希。

5. requests 下载避坑:高频问题与排查清单

requests 用起来顺手,恰恰是这种“顺手”把很多 HTTP 细节盖住了,出了问题反而不容易定位。这一章整理我实际排查中遇到最多的五个问题,每个都按现象、原因、解决三步记录。你按顺序对号入座,多数情况能省下半天时间的抓包。

5.1 乱码:响应文本与文件名的编码问题

现象:resp.text 打印出来中文全是乱码,或者下载的文件名变成 %E4%B8%AD%E6%96%87 这样的百分号串。 原因:响应头没有 charset 时,requests 用 ISO-8859-1 兜底解码,遇到 UTF-8 的中文必然乱;文件名乱码则是服务端把中文名做了 URL 百分号编码,requests 不会自动帮你解。 解决:先检查 resp.encoding 和 resp.apparent_encoding,两者不一致时手动指定 resp.encoding = resp.apparent_encoding,再取 resp.text。文件名则用 urllib.parse.unquote 解一次。注意 apparent_encoding 是靠内容猜的,短页面可能猜错,要求高就固定用 utf-8 试,失败再换 gbk。

5.2 内存暴涨:不能用 resp.content 下载大文件

现象:下载一个 500MB 的安装包,脚本内存占用冲到 1GB 以上,在内存小的机器上直接被系统杀掉。 原因:resp.content 会一次性把整个响应体加载进内存,下载文件时没有走流式。 解决:见上一章,stream=True 加 iter_content 分块写磁盘。判断标准很简单:目标文件超过 50MB,就不要碰 resp.content;另一个隐蔽点是 resp.json() 也会加载全文,大 JSON 接口同样有内存压力,用 resp.iter_content 自己拼或干脆换更小的分页接口。

5.3 timeout 失效:DNS 解析与总耗时的空白地带

现象:timeout=(3, 5) 明明设置了,但一个请求卡了 30 秒才抛异常,或者干脆卡死不动。 原因:timeout 只作用于 socket 连接和读操作,覆盖不到 DNS 解析阶段,也不限制整个请求的总耗时。DNS 服务器无响应时,resolve 过程是独立的系统调用,requests 的 timeout 管不到它。 解决:在 request 之前给 socket 层设置兜底:socket.setdefaulttimeout(3)。这个设置对当前进程内所有 socket 生效,DNS 解析超时也会被约束。如果还要限制总耗时,用 concurrent.futures.ThreadPoolExecutor 包一层,future.result(timeout=10) 超时后直接放弃。注意放弃前要把对应的 response 关闭,避免连接泄漏。

5.4 SSL 警告与 verify=False:测试环境专用开关

现象:requests.get(..., verify=False) 后控制台刷 InsecureRequestWarning,一行接一行,看着像报错。有些脚本作者为了省事直接不去管,甚至把 verify=False 带进了生产环境。 原因:verify=False 关闭了 SSL 证书校验,urllib3 按设计会输出警告,提醒你当前不在安全通道里。 解决:测试环境可以显式压掉警告:urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning),并在代码里注释“仅测试自签名证书用”。生产环境坚持 verify=True,遇到证书报错先查证书链、根证书、系统时间,而不是关校验。真实生产事故里,有一类就是手滑把 verify=False 传到生产,导致中间人攻击风险直接暴露。

5.5 连接复用后的偶发 ConnectionError

现象:同一个 Session 连续请求几十次后,某次突然抛 ConnectionError: Connection aborted。 原因:服务端或中间设备把空闲 keep-alive 连接断开了,客户端不知道,下一次请求还走旧连接,数据没发出去就报错。 解决:捕获 ConnectionError 后重建 Session 重试一次,这是成本最低的方案;更稳的是给 Session 挂 HTTPAdapter,把连接池大小调小一点、重试调起来,见下一章。不要在不加任何容错的情况下让长跑脚本裸奔,下载类任务尤其明显,断连一次就任务失败,太亏。

6. 再进一步:Session 复用与重试机制的正确用法

到这一步,单次请求和文件下载都能跑通。剩下的问题变成“脚本长时间运行怎么不出事”。requests 底层的连接和重试控制都在 urllib3 里,通过 HTTPAdapter 暴露出来。不显式配置时,requests 不会自动重试,连接失败就直接抛异常。对下载类任务,一次闪断就失败是不可接受的。

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(retries=3): retry = Retry( total=retries, connect=retries, read=retries, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504], ) adapter = HTTPAdapter( max_retries=retry, pool_connections=10, pool_maxsize=20, ) s = requests.Session() s.mount("https://", adapter) s.mount("http://", adapter) return s

Retry 的几个参数值得展开:

参数作用建议值
total总重试次数上限3
backoff_factor退避间隔系数,间隔=系数*2^(重试次数-1)0.5
status_forcelist命中这些状态码时也重试[500, 502, 503, 504]

total=3 配合 backoff_factor=0.5,重试间隔约为 0.5s、1s、2s,后续请求有退避,不会对服务端造成瞬时压力。status_forcelist 里不要放 401 和 403:账号密码错误重试也会被拒,403 往往是风控,重试只会加速封禁。HTTPAdapter 的 pool_connections 和 pool_maxsize 控制连接池容量,单机脚本 10 和 20 足够,不要盲目调大。

重试也有边界。Retry 只对特定异常类型和状态码生效,DNS 解析失败、超时类型需要配合 retry 参数里的其他选项,且重试的请求是透明的,日志里不容易看出来发生过重试。所以生产脚本我会在调用侧再加一层日志:捕获 RequestException 时打印异常类型、URL、已重试次数,方便事后复盘。另外,requests 的异常体系有一个总入口 requests.RequestException,所有网络相关异常都从它派生。脚本里不需要逐个 except,只捕获这一个就能拿到大部分故障,但注意它会吞掉异常细节,打印时用 repr(e) 保留完整信息。

我以前写定时任务,习惯性不配 Retry,某天网络抖动,脚本原地连着重试了几百次,把一个内部接口打到 5xx。从那以后我强制自己:凡是走 requests 的下载脚本,必须统一从 build_session 创建 Session,显式写清重试次数和退避参数,再在入口处加一层异常日志。这个习惯让后来所有定时任务的稳定性都上了一个台阶。requests 的坑大多是弯路踩出来的,把这些配置固化进模板,后面的人接手也少受折腾,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询