diff --git a/cpa_export.py b/cpa_export.py index 9a359d4..6a877d9 100644 --- a/cpa_export.py +++ b/cpa_export.py @@ -62,6 +62,72 @@ def _resolve_proxy(cfg: dict) -> str | None: return resolved or None +def _proxy_label(proxy: str | None) -> str: + try: + from oidc_mint.proxyutil import proxy_log_label + + return proxy_log_label(proxy or "") or "(direct)" + except Exception: + return proxy or "(direct)" + + +def _port_open(host: str, port: int, timeout: float = 0.25) -> bool: + import socket + + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _local_proxy_candidates(cfg: dict) -> list[str]: + """直连被 CF 拦时尝试的本机/配置代理列表(去重)。""" + cands: list[str] = [] + seen: set[str] = set() + + def _add(raw: str | None) -> None: + p = (raw or "").strip() + if not p or p in seen: + return + seen.add(p) + cands.append(p) + + for key in ("mint_proxy", "proxy"): + _add(str(cfg.get(key) or "")) + raw_list = cfg.get("proxy_pool") or [] + if isinstance(raw_list, str): + for line in raw_list.replace(",", "\n").splitlines(): + _add(line) + elif isinstance(raw_list, (list, tuple)): + for x in raw_list: + _add(str(x)) + try: + import proxy_pool + + for p in proxy_pool.pool_snapshot()[:8]: + _add(p) + except Exception: + pass + # 仅探测本机已监听的常见代理端口,避免空转超时 + for port in (7890, 7897, 10809, 10808, 7891, 20171, 6152, 1080): + if _port_open("127.0.0.1", port): + _add(f"http://127.0.0.1:{port}") + return cands + + +def _is_cf_mint_error(exc: BaseException | str) -> bool: + try: + from oidc_mint.oauth_code import is_cloudflare_block + + return is_cloudflare_block(exc=exc) + except Exception: + text = str(exc or "").lower() + return "403" in text and ( + "cloudflare" in text or " dict[str, Any]: """优先 SSO 授权码;可选回退设备码。""" + from oidc_mint import set_runtime_proxy from oidc_mint.oauth_code import OAuthCodeError, mint_from_sso, normalize_sso_cookie sso_token = normalize_sso_cookie(sso or "") prefer_sso = bool(cfg.get("cpa_prefer_sso_oauth", True)) allow_device = bool(cfg.get("cpa_allow_device_fallback", False)) timeout = float(cfg.get("mint_timeout_sec", 300) or 300) + max_proxy_tries = int(cfg.get("mint_proxy_retries", 4) or 4) if prefer_sso and sso_token: log("[cpa] 使用 SSO→OAuth(PKCE, referrer=grok-build)") - try: - return mint_from_sso( - sso_token, - proxy=proxy, - log=lambda m: log(f"[Debug] {m}"), - require_referrer=bool(cfg.get("cpa_require_referrer", True)), - ) - except OAuthCodeError as exc: - log(f"[!] SSO→OAuth 失败: {exc}") - if not allow_device: - raise - log("[cpa] 回退设备码铸造(可能缺 referrer)") - except Exception as exc: # noqa: BLE001 - log(f"[!] SSO→OAuth 异常: {exc}") - if not allow_device: - raise - log("[cpa] 回退设备码铸造(可能缺 referrer)") + tried: set[str] = set() + proxies_to_try: list[str | None] = [proxy] + last_exc: Exception | None = None + + def _expand_proxy_candidates() -> None: + for p in _local_proxy_candidates(cfg): + if p and p not in tried and p not in { + x for x in proxies_to_try if x + }: + proxies_to_try.append(p) + + # 直连时先挂上本机已开端口,减少首次 403 后的空等 + if not proxy: + _expand_proxy_candidates() + + idx = 0 + while idx < len(proxies_to_try) and idx < max(1, max_proxy_tries): + use_proxy = proxies_to_try[idx] + label = _proxy_label(use_proxy) + if idx == 0: + log(f"[cpa] mint 出口={label}") + else: + log(f"[cpa] CF/403 换出口重试 ({idx + 1}/{max_proxy_tries}): {label}") + set_runtime_proxy(use_proxy) + tried.add(use_proxy or "") + try: + return mint_from_sso( + sso_token, + proxy=use_proxy, + log=lambda m: log(f"[Debug] {m}"), + require_referrer=bool(cfg.get("cpa_require_referrer", True)), + ) + except OAuthCodeError as exc: + last_exc = exc + log(f"[!] SSO→OAuth 失败: {exc}") + if _is_cf_mint_error(exc): + if use_proxy: + try: + import proxy_pool + + proxy_pool.report_failure( + use_proxy, reason=f"mint CF: {str(exc)[:120]}" + ) + except Exception: + pass + _expand_proxy_candidates() + idx += 1 + continue + if not allow_device: + raise + log("[cpa] 回退设备码铸造(可能缺 referrer)") + break + except Exception as exc: # noqa: BLE001 + last_exc = exc + log(f"[!] SSO→OAuth 异常: {exc}") + if _is_cf_mint_error(exc): + _expand_proxy_candidates() + idx += 1 + continue + if not allow_device: + raise + log("[cpa] 回退设备码铸造(可能缺 referrer)") + break + # 成功已 return;失败已 continue/break + idx += 1 # pragma: no cover + else: + if last_exc is not None and not allow_device: + raise last_exc + if last_exc is not None and not allow_device: + raise last_exc if not allow_device and not sso_token: raise RuntimeError("无 sso cookie,且已禁用设备码回退;无法铸造带 referrer 的 token") diff --git a/oidc_mint/oauth_code.py b/oidc_mint/oauth_code.py index 1441b3d..75639cc 100644 --- a/oidc_mint/oauth_code.py +++ b/oidc_mint/oauth_code.py @@ -138,6 +138,39 @@ def _is_curl_tls_broken(exc: BaseException | str) -> bool: return any(n in text for n in needles) +def is_cloudflare_block( + status: int | None = None, + body: str = "", + exc: BaseException | str | None = None, +) -> bool: + """识别 auth.x.ai 被 Cloudflare 拦(常见直连 403 挑战页)。""" + text = f"{body or ''} {exc or ''}".lower() + if status == 403 and ( + " str: + via = proxy_label or "(direct)" + return ( + f"Cloudflare 拦截 auth.x.ai(出口={via})。" + "直连大陆/机房 IP 几乎必 403;请配置 mint_proxy / proxy," + "或开启 proxy_pool_enabled 并保证代理能访问 auth.x.ai。" + ) + + def _make_std_session(proxy: str | None = None): try: import requests as std_requests @@ -300,6 +333,10 @@ def open_authorize_page(session: Any, flow: AuthCodeFlow) -> str: body = resp.text or "" final = _final_url(resp) if resp.status_code < 200 or resp.status_code >= 300: + if is_cloudflare_block(resp.status_code, body): + raise OAuthCodeError( + f"authorize HTTP {resp.status_code}: Cloudflare 拦截 — {_short(body, 80)}" + ) raise OAuthCodeError( f"authorize HTTP {resp.status_code}: {_short(body)}" ) @@ -478,11 +515,16 @@ def sso_to_token( require_referrer: bool = True, ) -> TokenResult: """SSO cookie → 带 referrer=grok-build 的 OAuth token。""" + from .proxyutil import proxy_log_label, resolve_proxy + log = log or _noop_log sso = normalize_sso_cookie(sso_cookie) if not sso: raise OAuthCodeError("sso cookie 为空") + resolved = resolve_proxy(proxy) + log(f"mint 出口={proxy_log_label(resolved) or '(direct)'}") + # 先 curl_cffi;若遇到 OpenSSL invalid library / curl(35),自动回退 std requests session = _make_session(proxy, prefer="curl") try: @@ -495,6 +537,8 @@ def sso_to_token( backend.startswith("curl_cffi") and "curl: (35)" in str(exc).lower() ) if not can_fallback: + if is_cloudflare_block(exc=exc): + raise OAuthCodeError(_cf_block_hint(proxy_log_label(resolved))) from exc raise log(f"curl TLS 异常,回退标准 requests: {_short(str(exc), 160)}") try: @@ -506,6 +550,10 @@ def sso_to_token( return _run_sso_flow( sso, session=session, log=log, require_referrer=require_referrer ) + except Exception as exc2: # noqa: BLE001 + if is_cloudflare_block(exc=exc2): + raise OAuthCodeError(_cf_block_hint(proxy_log_label(resolved))) from exc2 + raise finally: try: session.close()