ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

libtv-skills如何手写multipart/form-data上传:upload_file.py源码完整解析

libtv-skills如何手写multipart/form-data上传:upload_file.py源码完整解析

【免费下载链接】libtv-skills

项目地址:https://gitcode.com/gh_mirrors/li/libtv-skills
点击查看免费下载

在libtv-skills项目中,upload_file.py负责把本地的图片、视频文件上传到 LibTV 的 OSS 存储。它没有引入requests等第三方库,而是用 Python 标准库逐字节手写 multipart/form-data 请求体——这正是很多新手觉得"上传接口很神秘"的地方。本文带你拆解 upload_file.py 的完整源码,理解它如何构造边界(boundary)、拼装表单字段、发起 POST 请求并处理错误。

一、为什么选择手写 multipart/form-data?

很多人上传文件时习惯requests+files=参数,两行代码搞定。但 libtv-skills 有一个核心设计目标:仅使用 Python 标准库,零依赖安装。

这一点在技能描述文件 SKILL.md 中明确写着:

无需安装额外依赖,仅使用 Python 标准库。

因此整个scripts/目录(包括 create_session.py、query_session.py、download_results.py)都基于urllib.request实现 HTTP 请求。upload_file.py也不例外——它把multipart/form-data的"魔法"拆开了,让你看到请求体最原始的样子。

📌核心收益:

  • ✅ 跨环境即用,不需要pip install任何包
  • ✅ 请求体结构完全可控、可调试
  • ✅ 适合学习 HTTP 协议细节

二、源码结构总览

先建立全局视角,upload_file.py 整体只有约 120 行,职责非常清晰:

行号区间内容作用
L14from _common import IM_BASE, ACCESS_KEY复用公共模块的环境变量配置
L17ALLOWED_PREFIXES = ("image/", "video/")白名单:只允许图片、视频
L20-L82upload_file(file_path)核心上传函数
L36-L60请求体拼装(boundary + 两个字段)手写 multipart 的关键区段
L62-L82发起 POST 并解析响应网络层
L85-L116main()命令行入口参数解析 + 结果输出

环境变量配置来自公共模块 _common.py:IM_BASE默认指向https://im.liblib.tv,ACCESS_KEY从LIBTV_ACCESS_KEY读取(未设置时直接报错退出)。

三、第 1 步:上传前的双重校验

在动网络之前,代码先做两道"安检",见 upload_file.py:

if not os.path.isfile(file_path): print(f"错误:文件不存在: {file_path}", file=sys.stderr) sys.exit(1) # 检查 MIME 类型 mime_type, _ = mimetypes.guess_type(file_path) if mime_type and not any(mime_type.startswith(p) for p in ALLOWED_PREFIXES): print(f"错误:不支持的文件类型: {mime_type},仅支持图片和视频", file=sys.stderr) sys.exit(1)
  • 文件是否存在:os.path.isfile判断路径,不存在则立刻退出,避免浪费一次网络请求。
  • MIME 类型白名单:mimetypes.guess_type根据扩展名猜测类型(如a.png→image/png),只有image/或video/前缀才放行。

💡 注意这个细节:if mime_type的判断意味着,当扩展名无法识别(MIME 为None)时不拦截,交给后面的Content-Type: application/octet-stream兜底,由服务端最终裁决。这是一种宽容式校验。

四、第 2 步:逐字节拼装请求体(核心)

这是全文最值得学习的部分。multipart/form-data 的本质是:用一串"边界"(boundary)把多个表单字段切成块,字段之间是头部 + 空行 + 二进制内容。

4.1 生成唯一的 boundary

boundary = f"----PythonUpload{uuid.uuid4().hex}"

用uuid4保证每次请求的边界串都不同,避免与服务端解析冲突。这个 boundary 稍后还要写进请求头,两处必须严格一致。

4.2 拼装两个表单字段

请求体共包含两个字段:文本字段accessKey和文件字段file,对应源码 L39-L58:

body_parts = [] # accessKey 字段(普通文本字段) body_parts.append(f"--{boundary}\r\n".encode()) body_parts.append(b'Content-Disposition: form-data; name="accessKey"\r\n\r\n') body_parts.append(f"{ACCESS_KEY}\r\n".encode()) # file 字段(文件字段,带文件名和内容类型) content_type = mime_type or "application/octet-stream" body_parts.append(f"--{boundary}\r\n".encode()) body_parts.append( f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode() ) body_parts.append(f"Content-Type: {content_type}\r\n\r\n".encode()) with open(file_path, "rb") as f: body_parts.append(f.read()) body_parts.append(b"\r\n") # 结束边界 body_parts.append(f"--{boundary}--\r\n".encode()) data = b"".join(body_parts)

把body_parts拼接后的完整结构画出来就是这样({...}为占位内容):

--{boundary}\r\n Content-Disposition: form-data; name="accessKey"\r\n \r\n {ACCESS_KEY}\r\n --{boundary}\r\n Content-Disposition: form-data; name="file"; filename="{文件名}"\r\n Content-Type: image/png\r\n \r\n {文件二进制内容}\r\n --{boundary}--\r\n

🔍逐行看懂这 4 个关键点:

  1. 每个字段都以--{boundary}\r\n开头——边界行是该字段的"门牌号"。
  2. 文本字段没有 filename,也没有 Content-Type,头部和值之间只有一个空行(\r\n\r\n)分隔。
  3. 文件字段必须带filename=和Content-Type:,服务端靠这两项识别"这是一个文件"以及它的类型;类型猜不出时兜底为application/octet-stream(二进制流)。
  4. 结尾的--{boundary}--注意多了两个短横线,表示"表单到此结束",这是新手最常漏写的地方。

4.3 为什么用b"".join而不是字符串拼接?

因为请求体里混着二进制文件数据,字符串无法安全承载任意字节。代码全程使用bytes列表(body_parts)再一次性b"".join(body_parts)拼合,是处理文件上传的标准姿势。

⚠️ 小提示:这里会把整个文件读进内存。对 200MB 以内的素材没问题,但若是超大文件,应改用分块流式上传。

五、第 3 步:请求头与 POST 发送

拼好的请求体交给urllib.request.Request,见 upload_file.py:

url = f"{IM_BASE.rstrip('/')}/openapi/upload" req = urllib.request.Request( url, data=data, method="POST", headers={ "Authorization": f"Bearer {ACCESS_KEY}", "Content-Type": f"multipart/form-data; boundary={boundary}", }, ) try: with urllib.request.urlopen(req, timeout=120) as resp: result = json.loads(resp.read().decode("utf-8")) return result.get("data", {})

三个细节值得注意:

细节说明
IM_BASE.rstrip('/')去掉末尾斜杠,防止拼出https://host//openapi/upload双斜杠
Content-Type必须带boundary=服务端靠它才知道边界串是什么,漏写会解析失败
timeout=120视频可能较大,超时放宽到 120 秒(对比 _common.py 中 JSON 接口只有 30 秒)

鉴权方式与整个项目一致:请求头Authorization: Bearer <LIBTV_ACCESS_KEY>,这一点在 README.md 的 API 参考部分有统一说明。

六、第 4 步:响应解析与错误处理

成功时,从 JSON 响应的data字段取出 OSS 地址;失败时按异常类型分别处理,见 upload_file.py:

except urllib.error.HTTPError as e: err_body = e.read().decode("utf-8") if e.fp else "" print(f"API 错误 {e.code}: {err_body}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as e: print(f"网络错误: {e.reason}", file=sys.stderr) sys.exit(1)
  • HTTPError(服务端返回 4xx/5xx):读出响应体原文打印,比如类型被拒、文件超限等,便于定位。
  • URLError(连不上服务器):打印网络原因(DNS 失败、超时等)。
  • 所有错误信息统一写到stderr,并以sys.exit(1)退出——这样 AI Agent 或非交互环境能可靠地感知失败。

七、实际使用:一条命令完成上传

配置好环境变量后,直接执行即可:

export LIBTV_ACCESS_KEY="your-access-key" # 上传图片 python3 skills/libtv-skill/scripts/upload_file.py /path/to/image.png # 上传视频 python3 skills/libtv-skill/scripts/upload_file.py /path/to/video.mp4

成功时输出 JSON 格式(main()中只保留最精简的url字段):

{ "url": "https://libtv-res.liblib.art/claw/{projectUuid}/{uuid}.png" }

这个 OSS 地址会在创作流程中派上用场:先上传参考图/视频,再把 URL 拼进消息发给后端,例如 SKILL.md 中"场景 2:用户提供图片/视频要求编辑修改"的典型工作流:

1. upload_file.py /path/to/video.mp4 → 拿到 OSS URL 2. create_session.py "把四周的纸船都换成白色的纸爱心 参考视频:{oss_url}" 3. 轮询 query_session.py 获取结果

八、总结:手写 multipart 的 5 个要点

学完upload_file.py,你掌握了手写 multipart/form-data 上传的完整套路:

  1. boundary 全局唯一:生成一次,同时用于请求体和Content-Type请求头。
  2. 每个字段 = 边界行 + 头部 + 空行 + 内容,头部用Content-Disposition声明字段名。
  3. 文件字段额外带filename和Content-Type,类型未知时用application/octet-stream兜底。
  4. 结尾必须写--boundary--(注意双短横线)。
  5. 全程用bytes拼接,二进制数据安全无损。

这套"零依赖 + 手写协议"的写法,让 upload_file.py 在任何有 Python 3.6+ 的机器上开箱即用,也是 libtv-skills 整个技能包保持一致风格的缩影。

📚延伸阅读:公共鉴权与 HTTP 封装见 _common.py,上传在整体工作流中的位置见 SKILL.md,所有脚本用法速查见 README.md。

【免费下载链接】libtv-skills

项目地址:https://gitcode.com/gh_mirrors/li/libtv-skills
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

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