简介:curl2py 是一份面向 Python 开发者与运维人员的轻量工具脚本,用于将日常调试中常见的 curl 命令快速转换为可直接运行的 Python 脚本,省去手工改写请求头、参数与数据体的重复劳动,适合需要频繁对接 HTTP 接口、做接口调试或爬虫原型验证的初中级使用者。压缩包共 3 个文件,包含 1 个 py 主程序、1 个 md 说明文档和 1 个 license 授权文件,整体仅 14KB,结构精简、开箱即用。脚本支持通过 -r 或 -raw 参数以原始格式输出,默认则采用 json pretty print 美化结果,便于阅读与二次修改。目前已有 1169 人学习下载,说明其在接口调试场景中具备一定实用价值。读者可借此获得一个可复用的转换脚本、清晰的使用说明与授权信息,快速把命令行请求迁移到 Python 代码中,减少手动重写成本,并为后续封装请求逻辑、排查参数错误提供参考思路。
1. curl2py:把一条 curl 命令变成能跑、能改、能进版本库的 Python 脚本
调接口时最常遇到的场景,是文档里甩过来一条 curl 命令,你复制到终端能跑通,但下一步要把它塞进项目代码里,就得手动翻译成 requests。翻译过程看着简单,实际全是细节:-H有几个、--data-raw是表单还是 JSON、-b里的 cookie 要不要拆、--compressed对应哪个参数、-k跳过证书校验在 requests 里怎么写。一条命令翻十分钟,翻完还容易漏掉某个 header,跑起来 401 或者 415,回头对着文档一行行比对,血泪经验基本都是这么攒出来的。
curl2py 要解决的就是这一段。它把一条完整的 curl 命令解析成结构化字段,再按 requests 的调用约定生成一份可直接运行的 Python 脚本,省掉手抄 header 和 body 的环节。适合三类人:经常对接第三方接口的后端和测试、需要把浏览器「Copy as cURL」快速转成自动化脚本的爬虫与数据工程、以及写接口冒烟脚本时不想手写 requests 样板代码的开发者。下面按「解析原理 → 最小实现 → 参数映射 → 避坑 → 进阶」的顺序,把这条路走一遍。
2. 拆解 curl 命令:从字符串到结构化请求对象
curl 的参数体系很宽,但落到 HTTP 请求上,真正需要提取的字段就那么几个:method、url、headers、body、cookie、认证信息、超时与重定向策略。curl2py 的核心工作不是「翻译字符串」,而是先把命令解析成一份中间结构,再由中间结构生成代码。中间结构这一步做扎实,后面换语言、换库都好办。
2.1 为什么不能直接字符串替换
最直觉的做法是拿正则把-H后面的内容抠出来,拼成headers = {...}。这个思路在简单命令上能跑,但很快会翻车。curl 的参数有短选项和长选项两套写法,-H和--header等价,-d和--data、--data-raw、--data-binary语义还不完全一样;参数值可以用单引号、双引号、不加引号三种形式包裹,引号内部还可能带转义;\换行拼接是 shell 层面的,解析前得先归一化。直接替换等于把这些情况全交给运气。
正确做法是分两步:先做 shell 层面的词法切分,把命令拆成 token 序列;再做 curl 语义层面的解析,把 token 归到选项和值上。第一步处理引号和转义,第二步处理选项语义。这两步分开,出问题时能快速定位是切分错了还是映射错了。
2.2 用 shlex 做词法切分
Python 标准库里的shlex就是干这个的,它按 POSIX shell 规则切分字符串,能正确处理引号和转义。很多人不知道它,自己写正则切引号,遇到嵌套引号就崩。
import shlex def tokenize(curl_cmd: str) -> list[str]: # 去掉行尾续行符和多余空白,避免 shlex 把反斜杠当转义 normalized = curl_cmd.replace("\\\n", " ").strip() # posix=True 启用 POSIX 规则,能正确处理单双引号与转义 lexer = shlex.shlex(normalized, posix=True) lexer.whitespace_split = True lexer.commenters = "" # 关闭 # 注释,避免 URL 里的 # 被吞 return list(lexer)posix=True是关键,它让shlex按标准 shell 规则处理引号;whitespace_split=True保证按空白切分而不是按字符;commenters=""必须设,否则 URL 里的 fragment(#后面那段)会被当成注释截掉,这个坑我在处理带锚点的接口时踩过一次,排查了半天才发现是解析器把 URL 吃了一半。
2.3 把 token 归到选项上
切分完得到的是扁平 token 列表,接下来要识别哪些是选项、哪些是值。curl 的选项分三类:布尔开关(如-k、-L、--compressed)、单值选项(如-H、-d、-X)、可重复选项(-H可以出现多次)。解析时维护一个「当前选项」指针,遇到以-开头且不是负数的 token 就切换选项,否则当作当前选项的值。
# 需要收集多值的选项,其余按单值处理 MULTI_VALUE_OPTS = {"-H", "--header", "-d", "--data", "--data-raw", "--data-binary", "-b", "--cookie", "-F", "--form"} # 布尔开关,不消费后续值 FLAG_OPTS = {"-k", "--insecure", "-L", "--location", "-s", "--silent", "--compressed", "-i", "--include", "-v", "--verbose"} def parse_tokens(tokens: list[str]) -> dict: result = {"headers": [], "data": [], "form": [], "cookies": [], "method": None, "url": None, "flags": set()} i = 0 while i < len(tokens): tok = tokens[i] if tok in ("curl",): i += 1 continue if tok in FLAG_OPTS: result["flags"].add(tok) i += 1 continue if tok in MULTI_VALUE_OPTS: if i + 1 >= len(tokens): raise ValueError(f"选项 {tok} 缺少值") val = tokens[i + 1] if tok in ("-H", "--header"): result["headers"].append(val) elif tok in ("-d", "--data", "--data-raw", "--data-binary"): result["data"].append(val) elif tok in ("-F", "--form"): result["form"].append(val) elif tok in ("-b", "--cookie"): result["cookies"].append(val) i += 2 continue if tok in ("-X", "--request"): result["method"] = tokens[i + 1].upper() i += 2 continue if tok.startswith("-"): # 未知选项,保守跳过其值,避免误吞 URL i += 2 if i + 1 < len(tokens) and not tokens[i + 1].startswith("-") else 1 continue # 非选项 token 视为 URL if result["url"] is None: result["url"] = tok i += 1 return result这段解析器的取舍点在于:未知选项不报错而是保守跳过,因为 curl 选项太多,全量覆盖不现实,遇到不认识的选项时宁可少解析也不要抛异常中断整个流程。-X单独处理是因为它决定 method,而 method 又影响 body 的编码方式,属于强语义字段。-F单独收集是因为 multipart 表单和普通 body 在 requests 里的写法完全不同,混在一起后面没法生成正确代码。
3. 生成可运行的 requests 脚本:字段映射与代码模板
解析出中间结构后,生成代码这一步反而简单,难点全在字段映射的细节上。同一个语义在 curl 和 requests 里的表达方式经常对不上,映射表没建对,生成的脚本就是「看着像但跑不通」。
3.1 核心字段映射表
先把最容易出错的几组映射列清楚,这张表是生成器的依据,也是排查生成结果时的对照标准。
| curl 写法 | requests 对应 | 注意点 |
|---|---|---|
-X POST | requests.post(...) | 无-X但有-d时默认 POST |
-H 'Content-Type: application/json' | headers={"Content-Type": "application/json"} | 大小写不敏感,但保留原样更稳 |
-d '{"a":1}' | data='{"a":1}' | 字符串原样传,不要自动 json 序列化 |
--data-raw '{"a":1}' | data='{"a":1}' | 与-d行为一致,@不触发文件读取 |
--data-binary @file | data=open("file","rb") | 二进制读取,注意关闭文件 |
-F 'file=@a.png' | files={"file": open("a.png","rb")} | multipart,字段名取=左边 |
-b 'a=1; b=2' | cookies={"a":"1","b":"2"} | 分号分隔,需拆分去空白 |
-u user:pass | auth=("user","pass") | Basic 认证,注意冒号切分 |
-k | verify=False | 关闭证书校验,会触发警告 |
-L | allow_redirects=True | requests 默认就是 True |
--compressed | 无需显式设置 | requests 自动处理 gzip |
--max-time 10 | timeout=10 | 单位秒,直接对应 |
这张表里最容易翻车的是-d和--data-binary的区别。-d会把@开头的值当文件读取,--data-raw不会,--data-binary则强制二进制读取且不做换行处理。生成代码时如果统一按字符串处理,遇到--data-binary @payload.bin就会生成一个把文件路径当字符串发出去的脚本,服务端收到一堆乱码。
3.2 生成器主体代码
有了映射表,生成器就是模板填充。下面这份实现覆盖了最常见的场景,生成结果带注释,方便人工二次修改。
def generate_python(parsed: dict) -> str: lines = ["import requests", ""] # method 推断:显式 -X 优先,有 data/form 默认 POST,否则 GET method = parsed["method"] if method is None: method = "POST" if (parsed["data"] or parsed["form"]) else "GET" lines.append(f'url = "{parsed["url"]}"') # headers 逐条生成,保留原始大小写 if parsed["headers"]: lines.append("headers = {") for h in parsed["headers"]: if ":" not in h: continue k, v = h.split(":", 1) lines.append(f' "{k.strip()}": "{v.strip()}",') lines.append("}") # body:多条 -d 用 & 拼接,符合 curl 行为 if parsed["data"]: joined = "&".join(parsed["data"]) lines.append(f'data = {joined!r}') # multipart 表单 if parsed["form"]: lines.append("files = {") for f in parsed["form"]: if "=" not in f: continue k, v = f.split("=", 1) if v.startswith("@"): path = v[1:] lines.append(f' "{k}": open("{path}", "rb"),') else: lines.append(f' "{k}": (None, "{v}"),') lines.append("}") # cookies 拆分 if parsed["cookies"]: jar = {} for c in parsed["cookies"]: for pair in c.split(";"): if "=" in pair: k, v = pair.split("=", 1) jar[k.strip()] = v.strip() lines.append(f"cookies = {jar!r}") # 组装调用参数 call_args = ["url"] if parsed["headers"]: call_args.append("headers=headers") if parsed["data"]: call_args.append("data=data") if parsed["form"]: call_args.append("files=files") if parsed["cookies"]: call_args.append("cookies=cookies") if "-k" in parsed["flags"] or "--insecure" in parsed["flags"]: call_args.append("verify=False") if "-L" in parsed["flags"] or "--location" in parsed["flags"]: call_args.append("allow_redirects=True") lines.append("") lines.append(f"resp = requests.{method.lower()}({', '.join(call_args)})") lines.append("print(resp.status_code)") lines.append("print(resp.text)") return "\n".join(lines)生成逻辑里有几个刻意的选择。method 推断放在生成阶段而不是解析阶段,因为解析时可能还没看到-d,只有全部 token 处理完才能确定。data用!r格式化而不是手动加引号,这样 body 里带引号或换行时生成的代码仍然合法。files里对@开头的值生成open(...),非@的普通字段用(None, value)元组形式,这是 requests 对 multipart 普通字段的标准写法,直接传字符串会被当成文件对象处理。
3.3 把生成器接成命令行工具
生成器写成函数后,包一层命令行入口就能当工具用。常见做法是支持从参数读、从标准输入读、从文件读三种方式,方便接管道。
import sys import argparse def main(): parser = argparse.ArgumentParser(description="curl 转 Python requests 脚本") parser.add_argument("cmd", nargs="?", help="curl 命令,不传则从 stdin 读") parser.add_argument("-o", "--output", help="输出文件,默认打印到 stdout") args = parser.parse_args() raw = args.cmd if args.cmd else sys.stdin.read() tokens = tokenize(raw) parsed = parse_tokens(tokens) code = generate_python(parsed) if args.output: with open(args.output, "w", encoding="utf-8") as f: f.write(code) else: print(code) if __name__ == "__main__": main()接管道时有个细节:从 stdin 读到的命令可能带换行,tokenize里已经做了归一化,但如果是多行粘贴,最好在入口处再strip()一次。-o输出到文件时用utf-8编码,避免 Windows 上默认 GBK 写出乱码,这个坑在跨平台分发脚本时很常见。
4. 避坑与排查:生成脚本跑不通时先看这几处
生成器写得再细,实际用起来还是会遇到跑不通的情况。下面这几条是我在对接各类接口时反复遇到的,按「现象 → 原因 → 解决」整理,遇到问题可以按顺序排查。
4.1 生成脚本报 415 Unsupported Media Type
现象:curl 命令在终端能正常返回,生成的 Python 脚本一跑就 415。原因通常是Content-Type没被正确提取。curl 的-H值里冒号后面可能有空格,也可能没有,如果解析时按冒号切分后没strip(),生成的 header 值会带前导空格,服务端匹配不上。解决:在生成 headers 字典时对 key 和 value 都做strip(),并且检查-H是否被误判成了未知选项跳过。另一个可能是-d的 body 是 JSON 但Content-Type写的是application/x-www-form-urlencoded,这种是原始命令本身的问题,生成器不背锅,但可以在生成结果里加一行注释提醒。
4.2 body 里的@被当成文件路径
现象:-d '@{"a":1}'这种写法,生成脚本后 requests 报文件不存在。原因是-d在 curl 里确实会把@开头的值当文件读,但很多人写命令时@只是 JSON 内容的一部分,本意是--data-raw。解决:解析时区分-d和--data-raw,前者遇到@开头生成open(...),后者一律按字符串处理。如果原始命令用的是-d但内容明显是 JSON,生成结果里加注释提示「原命令用 -d,若 @ 非文件请改用 --data-raw」。
4.3 cookie 拆分后值里带等号
现象:cookie 值本身包含=,比如 base64 编码的 token,按=切分后值被截断。原因是拆分逻辑用了split("=")而不是split("=", 1)。解决:cookie 和 header 的拆分都必须限制切分次数,k, v = pair.split("=", 1),保证只切第一个等号。这个坑在对接带签名 token 的接口时特别容易遇到,token 里=是填充字符,切错一位整个认证就失败。
4.4 重定向后 method 被改写
现象:curl 加-L能拿到结果,生成的脚本allow_redirects=True却拿到 405。原因是 requests 在 301/302 重定向时会把 POST 改成 GET,而 curl 的-L默认保持原 method(除非加--post301之类)。解决:如果接口依赖 POST 重定向,生成脚本时显式加allow_redirects=False并手动处理 Location,或者在 requests 里用Session配合rebuild_method参数。这个差异属于两个工具的设计取舍,不是 bug,但对接 OAuth 类接口时经常撞上。
4.5 生成的脚本里 URL 被截断
现象:URL 里带#或?后面的参数丢失。原因是shlex的commenters没关,#被当注释;或者 URL 没加引号,&被 shell 当后台符号。解决:tokenize里设commenters="",并且提醒用户原始命令里 URL 最好用引号包起来。如果是从浏览器「Copy as cURL」拿到的命令,URL 一般已经带引号,问题不大;手写的命令容易漏。
5. 进阶:让生成器适配更多场景
基础版跑通后,有几个方向值得继续做,能让这个工具从「能用」变成「日常顺手」。
5.1 支持从浏览器复制内容直接转换
浏览器开发者工具的「Copy as cURL」输出格式比较固定,但不同浏览器有差异:有的用--data-raw,有的用--data,有的把 cookie 放在-H 'Cookie: ...'里而不是-b。可以在解析前加一层预处理,把-H 'Cookie: ...'统一转成-b,把--data统一成--data-raw,这样后续解析逻辑不用改。预处理用简单的字符串替换就行,但要注意只替换选项名,不要动 body 内容。
5.2 生成带重试和超时的生产级脚本
调试用的脚本和进版本库的脚本要求不一样。生产脚本通常需要超时、重试、日志。可以在生成器里加一个--production开关,开启后生成的代码带timeout、Retry配置和logging。
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(retries=3, backoff=0.5): session = requests.Session() retry = Retry(total=retries, backoff_factor=backoff, status_forcelist=[429, 500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) return sessionstatus_forcelist里放的是值得重试的状态码,429 和 5xx 是常见选择,4xx 里的 401、403 重试没意义,不要放进去。backoff_factor控制退避间隔,0.5 表示第一次重试等 0.5 秒,之后指数增长。这个配置在对接限流接口时能省不少事,但要注意重试会放大请求量,对写接口要谨慎。
5.3 反向验证:把生成的脚本和原命令做等价性检查
生成结果对不对,最可靠的验证是让两者发同一个请求,比对响应。可以写一个测试脚本,用subprocess跑原始 curl,用requests跑生成的代码,比对状态码和响应体(去掉时间戳等易变字段)。这个检查在生成器改动后跑一遍,能快速发现回归。我一般会把几条典型命令存成测试用例,包括带 cookie 的、带 multipart 的、带重定向的,每次改解析逻辑就跑一次。
5.4 一个容易忽略的细节:header 顺序
HTTP 协议里 header 顺序理论上不影响语义,但有些服务端实现会按顺序做签名校验,尤其是对接支付类接口时,签名串的拼接顺序和 header 顺序相关。curl 命令里-H的出现顺序就是发送顺序,生成器如果用了dict去重,顺序可能被打乱。Python 3.7 之后dict保持插入顺序,所以只要按解析顺序插入就没问题,但不要用set或排序。这个细节平时不影响,一旦遇到签名校验失败,排查起来很费时间。
最后说个习惯:我每次用这类工具生成脚本后,不会直接提交,而是先跑一遍,把响应和 curl 的结果对一下,确认一致再改造成项目里的调用方式。生成器省的是手抄的时间,不是省验证的时间。希望帮到你。
本文还有配套的精品资源,点击获取