le-gpt-image v1.1:curl_cffi 可选传输层(Chrome TLS 指纹)修复 Cloudflare 质询——chatgpt.com 按指纹放行浏览器/Codex App、拦 urllib/curl;curl_cffi 路径不覆盖 Chrome UA(矛盾组合同样被掐);排障表改写(此前「切节点」结论证伪);实测功夫女足海报 61.5s 成功

This commit is contained in:
leroy
2026-10-06 22:17:14 +08:00
parent c3c8ef0544
commit b6bc12e031
3 changed files with 95 additions and 13 deletions
+3 -2
View File
@@ -16,7 +16,7 @@ data[0].b64_json → 校验 PNG → 原子落盘
这就是 Codex CLI 自己生图走的内部通道。`model` 字段固定为 `gpt-image-2`(请求格式匹配值,实际图像模型由服务端决定);备用 `--backend responses` 走推理模型强制 `image_generation` 工具。 这就是 Codex CLI 自己生图走的内部通道。`model` 字段固定为 `gpt-image-2`(请求格式匹配值,实际图像模型由服务端决定);备用 `--backend responses` 走推理模型强制 `image_generation` 工具。
脚本零第三方依赖(python3 标准库);Pillow 为可选增强——在位时改图输入自动压成最长边 1536 的 WebP。 脚本零第三方依赖(python3 标准库);**curl_cffi 为可选增强(强烈建议安装)**——chatgpt.com 的 Cloudflare 会按 TLS 指纹放行:浏览器/Codex App 的指纹直接过,python-urllib/curl 的会被质询。装了 curl_cffi 后 CLI 自动改用 Chrome 指纹传输(且不覆盖 Chrome UA——「Chrome TLS + codex UA」的矛盾组合同样会被掐)。Pillow 可选:改图输入自动压成最长边 1536 的 WebP。
## 使用 ## 使用
@@ -44,7 +44,8 @@ python3 scripts/gpt_image.py edit --image content.png --style-image ink.png --ou
| `check` | chatgpt / plus / token 剩 82.7h / refresh_token 在位 | | `check` | chatgpt / plus / token 剩 82.7h / refresh_token 在位 |
| generate low 1024x1024 | 38.8s 成功;请求 1024x1024 实际返回 1374x1145(**通道不严格遵循请求尺寸**) | | generate low 1024x1024 | 38.8s 成功;请求 1024x1024 实际返回 1374x1145(**通道不严格遵循请求尺寸**) |
| edit 单图 low | 45.4s 成功,主体保持、背景按指令替换 | | edit 单图 low | 45.4s 成功,主体保持、背景按指令替换 |
| 海报实测(约 1 小时后) | 失败:代理出口 IP 被 Cloudflare 质询(403 `cf-mitigated: challenge`,连接被掐)。凭证与 CLI 无辜——切换代理节点即恢复 | | 海报实测(约 1 小时后) | urllib 传输被 Cloudflare 质询(403 `cf-mitigated: challenge` / 连接被掐)——同网络下浏览器与 Codex App 正常,锁定 **TLS 指纹**风控 |
| 装 curl_cffi 后重测 | Chrome 指纹 + 真 token 完整生图 200;「功夫女足」海报 61.5s 成功(1024x1536,书法标题渲染准确) |
## 限制 ## 限制
+1 -1
View File
@@ -93,7 +93,7 @@ SKILL/scripts/gpt_image.py edit \
| HTTP 429 | 脚本自动退避重试;重试仍失败则告知用户订阅图像额度已到限,建议稍后再试或降 quality/尺寸 | | HTTP 429 | 脚本自动退避重试;重试仍失败则告知用户订阅图像额度已到限,建议稍后再试或降 quality/尺寸 |
| 尺寸不符警告 | 属正常(服务端建议值行为),按实际尺寸交付 | | 尺寸不符警告 | 属正常(服务端建议值行为),按实际尺寸交付 |
| 网络请求失败 | 先定位出口:`curl --noproxy '*' https://api.ipify.org` 与 `curl -x http://127.0.0.1:7890 ...` 对比出口 IP。macOS 上 Python urllib 会自动读系统代理设置(即使只开 TUN 模式也会被显式代理入口接管);TUN 与 7890 通常同核同出口 | | 网络请求失败 | 先定位出口:`curl --noproxy '*' https://api.ipify.org` 与 `curl -x http://127.0.0.1:7890 ...` 对比出口 IP。macOS 上 Python urllib 会自动读系统代理设置(即使只开 TUN 模式也会被显式代理入口接管);TUN 与 7890 通常同核同出口 |
| `Remote end closed connection` / 403 含 `cf-mitigated: challenge` | 出口 IP 被 chatgpt.com 的 Cloudflare 风控盯上(浏览器能过 JS 质询所以「看着正常」,CLI 过不了;`check` 仍正常、api.openai.com 可能仍可达——风控按域名单独加)。请用户在代理 App **切换节点换出口 IP**,切完重试即可;勿反复硬撞 | | `Remote end closed connection` / 403 含 `cf-mitigated: challenge` / curl 56 连接被掐 | **TLS 指纹被 Cloudflare 质询**:python-urllib/curl 的握手特征会被 chatgpt.com 域名级风控识别(浏览器与 Codex/ChatGPT.app 不受影响,所以「用户那边正常」)。解法:`pip install --user curl_cffi`(PEP 668 环境加 `--break-system-packages`),CLI 检测到即自动用 Chrome 指纹传输。装后仍被掐 → 检查是否覆盖了 Chrome UA(curl_cffi 路径下不要发 codex UA,矛盾组合会被掐)。换代理节点通常无效——同出口下浏览器指纹本来就能过 |
| native 通道 404/失效 | 内部接口无稳定性契约;切 `--backend responses` 试试,并在交付时说明 | | native 通道 404/失效 | 内部接口无稳定性契约;切 `--backend responses` 试试,并在交付时说明 |
## 安全边界 ## 安全边界
+91 -10
View File
@@ -42,6 +42,16 @@ try:
except ImportError: # Python < 3.11 except ImportError: # Python < 3.11
tomllib = None # type: ignore[assignment] tomllib = None # type: ignore[assignment]
# 可选传输层:curl_cffi 以 Chrome TLS 指纹发请求。
# chatgpt.com 的 Cloudflare 会质询 python-urllib/curl 的握手指纹(403 cf-mitigated: challenge),
# Chrome 指纹(及 Codex/ChatGPT.app 的网络栈)不受影响。未安装时回退 urllib。
try:
from curl_cffi import requests as _cffi_requests
_HAS_CFFI = True
except ImportError:
_HAS_CFFI = False
__version__ = "1.0.0" __version__ = "1.0.0"
DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex" DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex"
@@ -248,19 +258,52 @@ def _refresh_lock(auth_file: Path, timeout: float):
fcntl.flock(lock.fileno(), fcntl.LOCK_UN) fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
def _browser_headers(headers: dict[str, str]) -> dict[str, str]:
"""curl_cffi 路径的请求头:剥掉会破坏 Chrome 模拟的自定义 UA/version。
实测「Chrome TLS 指纹 + codex_cli_rs UA」的矛盾组合会被边缘直接掐断连接;
纯浏览器头(Authorization/originator/ChatGPT-Account-ID)可完整走通生图。
"""
trimmed = dict(headers)
trimmed.pop("User-Agent", None)
trimmed.pop("version", None)
return trimmed
def _post_json( def _post_json(
url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float
) -> dict[str, Any]: ) -> dict[str, Any]:
body = json.dumps(payload).encode("utf-8") if _HAS_CFFI:
req = request.Request(url, data=body, headers=headers, method="POST") try:
try: response = _cffi_requests.post(
with request.urlopen(req, timeout=timeout) as response: url,
raw = response.read().decode("utf-8") json=payload,
except error.HTTPError as exc: headers=_browser_headers(headers),
raw = exc.read().decode("utf-8", errors="replace") timeout=timeout,
raise HttpError(exc.code, raw, exc.headers.get("Retry-After") if exc.headers else None) from exc impersonate="chrome",
except (error.URLError, OSError) as exc: )
raise CliError(f"网络请求失败: {exc}") from exc except Exception as exc: # curl_cffi 的各类传输异常
raise CliError(f"网络请求失败: {exc}") from exc
if response.status_code >= 400:
raise HttpError(
response.status_code,
response.text or "",
response.headers.get("Retry-After"),
)
raw = response.text
else:
body = json.dumps(payload).encode("utf-8")
req = request.Request(url, data=body, headers=headers, method="POST")
try:
with request.urlopen(req, timeout=timeout) as response:
raw = response.read().decode("utf-8")
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
raise HttpError(
exc.code, raw, exc.headers.get("Retry-After") if exc.headers else None
) from exc
except (error.URLError, OSError) as exc:
raise CliError(f"网络请求失败: {exc}") from exc
if not raw: if not raw:
return {} return {}
try: try:
@@ -474,6 +517,38 @@ def _native_result(
def _sse_data_lines(url: str, headers: dict[str, str], payload: dict[str, Any], timeout: float): def _sse_data_lines(url: str, headers: dict[str, str], payload: dict[str, Any], timeout: float):
req_headers = {**headers, "Accept": "text/event-stream", "Content-Type": "application/json"} req_headers = {**headers, "Accept": "text/event-stream", "Content-Type": "application/json"}
if _HAS_CFFI:
req_headers = _browser_headers(req_headers)
try:
response = _cffi_requests.post(
url,
json=payload,
headers=req_headers,
timeout=timeout,
impersonate="chrome",
stream=True,
)
if response.status_code >= 400:
raise HttpError(
response.status_code,
response.text or "",
response.headers.get("Retry-After"),
)
for raw_line in response.iter_lines():
if isinstance(raw_line, bytes):
line = raw_line.decode("utf-8", "replace")
else:
line = str(raw_line)
stripped = line.strip()
if stripped.startswith("data:"):
data = stripped[5:].strip()
if data and data != "[DONE]":
yield data
except HttpError:
raise
except Exception as exc:
raise CliError(f"网络请求失败: {exc}") from exc
return
req = request.Request( req = request.Request(
url, data=json.dumps(payload).encode("utf-8"), headers=req_headers, method="POST" url, data=json.dumps(payload).encode("utf-8"), headers=req_headers, method="POST"
) )
@@ -849,6 +924,12 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
if not getattr(args, "dry_run", False) and args.func is not _cmd_check:
_log(
"传输层: curl_cffi (Chrome 指纹)"
if _HAS_CFFI
else "传输层: urllib(若被 Cloudflare 质询,pip install curl_cffi 后重试)"
)
try: try:
return int(args.func(args)) return int(args.func(args))
except CliError as exc: except CliError as exc: