文件保存后是 0KB,先看 HTTP 响应有没有内容,再看内容是否写到了预期位置。请求头影响的是请求,文件的 write 只负责保存已经取得的字节,不能把两层混在一起。

一次空文件经历留下的线索

2019 年我先用 urllib.request.urlretrieve,随后改成 Requests 获取内容再用 wb 写入,结果仍是空文件。改请求头后当时得到内容,但没有完整响应证据,不能断言所有 0KB 都缺某个 Referer 或 User-Agent。

请求失败、合法的无内容响应、重定向到登录页,以及写错路径,都可能表现成“没有得到预期文件”。示例仅面向自己有权访问的文件,不提供媒体入口或绕过访问限制的方法。

请求、校验和写入分开

下面是小文件示例:调用者明确提供授权 URL、预期 MIME 类型和新的目标路径;函数不在导入时自动联网。

from pathlib import Path
import requests

def save_authorized_file(url, output_path, expected_type):
    response = requests.get(
        url,
        headers={"User-Agent": "AuthorizedFileCheck/1.0"},
        timeout=(5, 30),
    )
    response.raise_for_status()

    content_type = (
        response.headers.get("Content-Type", "")
        .split(";", 1)[0].strip().lower()
    )
    if content_type != expected_type.lower():
        raise ValueError("响应类型与预期不符")

    payload = response.content
    if not payload:
        raise ValueError("响应没有文件内容")

    destination = Path(output_path).resolve()
    # 只有完成上述检查才创建新文件;已有目标不会被覆盖。
    with destination.open("xb") as stream:
        stream.write(payload)
    return destination, len(payload)

这段代码没有执行下载。实际调用会联网并创建文件;父目录须存在,目标已存在会失败。大文件应另做流式下载、最大字节限制、临时文件与失败清理,不能直接把整个内容读入内存。

Requests Quickstart 说明 content、请求头、状态检查、跳转与 timeout。这里的 timeout 也不是对整个下载设一个统一总时限。

状态成功,不等于文件正确

raise_for_status() 检查 HTTP 错误,但 200 也可能返回 HTML 登录页;Content-Type 是线索,不是内容真实性证明。重要文件还要按可信来源核对格式、长度或校验值,不能只看扩展名。

重定向场景应检查最终目标和响应历史是否符合预期,但不要把包含临时签名、token 的完整 URL 直接写入公开日志。请求头中的鉴权信息也同样需要保护。

响应有字节,再查写入

确认目标绝对路径、打开模式和文件关闭过程,检查是否在另一个工作目录生成了同名文件。写入时磁盘满、权限失败或进程中断,可能留下部分文件;独占创建避免覆盖旧文件,却不自动提供原子完成保证。

最终验收应分别确认响应内容、写入字节和目标文件可用性。不要在取得有效响应之前用 wb 截断一个已有文件,再把后续网络错误误认作“服务器返回了空内容”。