diff --git a/skills/le-gpt-image/README.md b/skills/le-gpt-image/README.md index 7eca1fd..b34b295 100644 --- a/skills/le-gpt-image/README.md +++ b/skills/le-gpt-image/README.md @@ -16,7 +16,7 @@ data[0].b64_json → 校验 PNG → 原子落盘 这就是 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 在位 | | generate low 1024x1024 | 38.8s 成功;请求 1024x1024 实际返回 1374x1145(**通道不严格遵循请求尺寸**) | | 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,书法标题渲染准确) | ## 限制 diff --git a/skills/le-gpt-image/SKILL.md b/skills/le-gpt-image/SKILL.md index 9efe221..f582703 100644 --- a/skills/le-gpt-image/SKILL.md +++ b/skills/le-gpt-image/SKILL.md @@ -93,7 +93,7 @@ SKILL/scripts/gpt_image.py edit \ | HTTP 429 | 脚本自动退避重试;重试仍失败则告知用户订阅图像额度已到限,建议稍后再试或降 quality/尺寸 | | 尺寸不符警告 | 属正常(服务端建议值行为),按实际尺寸交付 | | 网络请求失败 | 先定位出口:`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` 试试,并在交付时说明 | ## 安全边界 diff --git a/skills/le-gpt-image/scripts/gpt_image.py b/skills/le-gpt-image/scripts/gpt_image.py index e749141..52d487b 100755 --- a/skills/le-gpt-image/scripts/gpt_image.py +++ b/skills/le-gpt-image/scripts/gpt_image.py @@ -42,6 +42,16 @@ try: except ImportError: # Python < 3.11 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" 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) +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( url: str, *, headers: dict[str, str], payload: dict[str, Any], timeout: float ) -> dict[str, Any]: - 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 _HAS_CFFI: + try: + response = _cffi_requests.post( + url, + json=payload, + headers=_browser_headers(headers), + timeout=timeout, + impersonate="chrome", + ) + 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: return {} try: @@ -474,6 +517,38 @@ def _native_result( 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"} + 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( 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: 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: return int(args.func(args)) except CliError as exc: