Compare commits
10
Commits
8b399277e7
...
d0f06ac93c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f06ac93c | ||
|
|
986bc36f04 | ||
|
|
bc5ad63755 | ||
|
|
7d6d52ac5d | ||
|
|
cc0aa6a433 | ||
|
|
4abdc8aa4a | ||
|
|
d74d14d3f4 | ||
|
|
0417fa3218 | ||
|
|
bf30d40c8b | ||
|
|
96061cbd78 |
No files matched your search
@@ -0,0 +1 @@
|
||||
helenramos281@mail.bblbb.com----N9ee81e6c!a7#VCKDb6V6----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiMWU1OTMzYWEtMTJiYi00YjU5LTg1ZDktOTRiZmFhNDliYzU2In0.gnekLoMUj2B5DscbjE5hyC9FoLqkYjjXP9X3usyyqDQ
|
||||
@@ -0,0 +1 @@
|
||||
zoieroberts1869@mail.bblbb.com----Ndf7a20b2!a7#M3gEsHL-----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjRkOWNjNTMtM2Q0YS00MzNlLThhMTctMTIxYmUyODNmZTU4In0.ra0SYJyU146-eMqSQKcG9J63YDVip_tXOxk0SRXp_54
|
||||
+2
-2
@@ -27,12 +27,12 @@
|
||||
"proxy_check_interval_sec": 300,
|
||||
"proxy_source_disable_after": 3,
|
||||
"enable_nsfw": true,
|
||||
"register_count": 99999,
|
||||
"register_count": 3,
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||||
"cpa_export_enabled": true,
|
||||
"cpa_auth_dir": "./cpa_auths",
|
||||
"cpa_base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"cpa_push_enabled": true,
|
||||
"cpa_push_enabled": false,
|
||||
"cpa_remote_base": "https://cpa.nopj.cn",
|
||||
"cpa_remote_secret": "DNznuRVoBhDT2SpSAyWw",
|
||||
"cpa_remote_verify_tls": true,
|
||||
|
||||
+5
-1
@@ -5,7 +5,11 @@ internal/auth/xai/token.go 的 TokenStorage 结构。
|
||||
自带 x-grok-client-version 头(xai_executor.go 硬编码 0.2.93),免费 Build 账号
|
||||
不会 426。
|
||||
|
||||
2026-07:AccessToken 必须含 referrer=grok-build,否则 cli-chat-proxy 拒用。
|
||||
2026-07:AccessToken 必须同时满足:
|
||||
1) claim referrer=grok-build
|
||||
2) scope 含 grok-cli:access
|
||||
否则 cli-chat-proxy 会返回:
|
||||
WKE=unauthorized:grok-cli-token-auth-required
|
||||
铸造请走 oidc_mint.oauth_code(SSO→Authorization Code + PKCE)。
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6Ijc3ZTI4MTVlLTE1MjQtNDM0MS1iYWE4LWM5NGVjZjc3MzAzYSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDEwOTU5MywiaWF0IjoxNzg0MDg3OTkzLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiNzdlMjgxNWUtMTUyNC00MzQxLWJhYTgtYzk0ZWNmNzczMDNhIiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiZDQ5YWQyMzEtOGRkNy00Zjk1LWE3ZjAtYWQ2N2VjZTNhYWRmIiwidGVhbV9pZCI6Ijc5OWY1OWM1LTJhZGMtNDllYi1iMTQ3LTVjNTY1NzJjMzE5MCIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.LHoRB8Ze9wPEIbP1OYllw5cmDvMVLbYh7D2dL2AgQ3YPV0V7zpuw7gzQXYAOxbP7UPeuIQQ97qGOfXgJEW--yA",
|
||||
"refresh_token": "wXTgQRJDSMcyGmC8-krwGpHjkdsDzKHN2KnFwpvtdfnli_jBOqlvDnTSIrl2j90boNMVIO4Dg7UAdgGoTmE7ig",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-15T09:59:53Z",
|
||||
"last_refresh": "2026-07-15T03:59:53Z",
|
||||
"email": "helenramos281@mail.bblbb.com",
|
||||
"sub": "77e2815e-1524-4341-baa8-c94ecf77303a",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6Ijc3ZTI4MTVlLTE1MjQtNDM0MS1iYWE4LWM5NGVjZjc3MzAzYSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDEwOTU5MywiaWF0IjoxNzg0MDg3OTkzLCJub25jZSI6IkU3dGRabjVEQmRNZmdNc2tQRF9NelEiLCJnaXZlbl9uYW1lIjoiS2FpIiwiZmFtaWx5X25hbWUiOiJIYW4iLCJlbWFpbCI6ImhlbGVucmFtb3MyODFAbWFpbC5iYmxiYi5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0.Ok3MSOzASage-BhrkdDfVvttZYFat-yBmbQbsth0ykj-jRD07iyjw5snRfX8__2A3gkZM_pLdkSUAfgEkFJTjw",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiMWU1OTMzYWEtMTJiYi00YjU5LTg1ZDktOTRiZmFhNDliYzU2In0.gnekLoMUj2B5DscbjE5hyC9FoLqkYjjXP9X3usyyqDQ",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6ImJiYzI0ODVlLTcxM2UtNDU4Ny1iZTI3LTZkZTViZWNmZjhkNCIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDIxMTcyNywiaWF0IjoxNzg0MTkwMTI3LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiYmJjMjQ4NWUtNzEzZS00NTg3LWJlMjctNmRlNWJlY2ZmOGQ0IiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiZTQzNjI0ZmMtMDJmNC00MjhmLWFhMmEtNGM5MTAxMDk2MDY3IiwidGVhbV9pZCI6IjkzZGRiMzg3LWMyMDktNDEwZi1iMDJhLTA5NDZhNWM4ODkwNSIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.DM0FgR-cvN82OkOpza-vvdj6Y7F-DDqcIgALepE3XR_GFKNKlblf6oa6ZGFLg6EkNELsSGNb5bmka_CDx06Mcg",
|
||||
"refresh_token": "YCyRNl-D1-OAzfoVs9sAKPEBBLm5kHJvdI9SEx18W8tZmXKHGcQ9yp_N2q4hNqq_dQH4Emy3EPbV3ikEBBuOAA",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-16T14:22:07Z",
|
||||
"last_refresh": "2026-07-16T08:22:07Z",
|
||||
"email": "zoieroberts1869@mail.bblbb.com",
|
||||
"sub": "bbc2485e-713e-4587-be27-6de5becff8d4",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6ImJiYzI0ODVlLTcxM2UtNDU4Ny1iZTI3LTZkZTViZWNmZjhkNCIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDIxMTcyNywiaWF0IjoxNzg0MTkwMTI3LCJub25jZSI6Imoxb0JzWUkzR19WUXRseUxWVTBteVEiLCJnaXZlbl9uYW1lIjoiQ2FsZWIiLCJmYW1pbHlfbmFtZSI6IkxpdSIsImVtYWlsIjoiem9pZXJvYmVydHMxODY5QG1haWwuYmJsYmIuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.y38jxu1nWyO0Yzr4lcxVAqVbVc37880Jz0UGlIoQ8P5HWdD1b-hFleiGJmGbqayQ9AhpoKJMcoWrWIYFGMLc2g",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjRkOWNjNTMtM2Q0YS00MzNlLThhMTctMTIxYmUyODNmZTU4In0.ra0SYJyU146-eMqSQKcG9J63YDVip_tXOxk0SRXp_54",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
+133
-42
@@ -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 "<!doctype" in text or "oldie" in text
|
||||
)
|
||||
|
||||
|
||||
def _mint_tokens(
|
||||
*,
|
||||
email: str,
|
||||
@@ -72,66 +138,91 @@ def _mint_tokens(
|
||||
log: Callable[[str], None],
|
||||
proxy: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""优先 HTTP SSO 授权码;TLS 失败时用注册浏览器 PKCE;可选设备码。"""
|
||||
from oidc_mint.oauth_code import (
|
||||
OAuthCodeError,
|
||||
_is_http_tls_failure,
|
||||
mint_from_sso,
|
||||
mint_from_sso_browser,
|
||||
normalize_sso_cookie,
|
||||
)
|
||||
"""优先 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_browser = bool(cfg.get("cpa_allow_browser_fallback", True))
|
||||
allow_device = bool(cfg.get("cpa_allow_device_fallback", False))
|
||||
timeout = float(cfg.get("mint_timeout_sec", 300) or 300)
|
||||
require_ref = bool(cfg.get("cpa_require_referrer", True))
|
||||
http_err: Exception | None = None
|
||||
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=require_ref,
|
||||
)
|
||||
except OAuthCodeError as exc:
|
||||
http_err = exc
|
||||
log(f"[!] SSO→OAuth 失败: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
http_err = exc
|
||||
log(f"[!] SSO→OAuth 异常: {exc}")
|
||||
tried: set[str] = set()
|
||||
proxies_to_try: list[str | None] = [proxy]
|
||||
last_exc: Exception | None = None
|
||||
|
||||
# HTTP 失败后:有 page+sso 就优先浏览器 PKCE(Chrome TLS 通常正常,且保留 referrer)
|
||||
if allow_browser and page is not None and sso_token and http_err is not None:
|
||||
why = "TLS/连接" if _is_http_tls_failure(http_err) else "HTTP"
|
||||
log(f"[cpa] 回退浏览器 PKCE 铸造({why}失败,绕开 Python TLS)")
|
||||
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_browser(
|
||||
return mint_from_sso(
|
||||
sso_token,
|
||||
page,
|
||||
proxy=use_proxy,
|
||||
log=lambda m: log(f"[Debug] {m}"),
|
||||
require_referrer=require_ref,
|
||||
timeout_sec=min(timeout, 120.0),
|
||||
require_referrer=bool(cfg.get("cpa_require_referrer", True)),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"[!] 浏览器 PKCE 失败: {exc}")
|
||||
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)")
|
||||
elif http_err is not None and not allow_device:
|
||||
raise http_err
|
||||
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")
|
||||
if not allow_device:
|
||||
# 有 sso 但 browser 也没开/没 page
|
||||
if http_err is not None:
|
||||
raise http_err
|
||||
raise RuntimeError("SSO 铸造失败,且未启用任何回退")
|
||||
|
||||
# 设备码回退(旧路径,通常无 referrer)
|
||||
from oidc_mint import mint_with_browser
|
||||
|
||||
+132
-20
@@ -17,6 +17,7 @@ import random
|
||||
import re
|
||||
import string
|
||||
import json
|
||||
import base64
|
||||
|
||||
from DrissionPage import Chromium, ChromiumOptions
|
||||
from DrissionPage.errors import PageDisconnectedError
|
||||
@@ -85,7 +86,6 @@ DEFAULT_CONFIG = {
|
||||
# OIDC:优先 SSO→Authorization Code + referrer=grok-build
|
||||
"cpa_prefer_sso_oauth": True, # True=用 sso cookie 走 PKCE(必须带 referrer)
|
||||
"cpa_require_referrer": True, # True=access_token 无 referrer=grok-build 则失败
|
||||
"cpa_allow_browser_fallback": True, # True=HTTP 铸造失败时用注册浏览器走 PKCE(绕 Python TLS)
|
||||
"cpa_allow_device_fallback": False, # True=SSO 失败时回退设备码(通常不可用)
|
||||
# OIDC 铸造代理/超时
|
||||
"mint_proxy": "", # 铸造专用代理;空=复用 proxy
|
||||
@@ -587,13 +587,17 @@ def create_browser_options(proxy=None):
|
||||
options.set_argument("--disable-background-timer-throttling")
|
||||
options.set_argument("--disable-backgrounding-occluded-windows")
|
||||
options.set_argument("--disable-renderer-backgrounding")
|
||||
# 反 bot:关闭 Blink AutomationControlled(否则 navigator.webdriver 在 JS 前就暴露)
|
||||
options.set_argument("--disable-blink-features=AutomationControlled")
|
||||
|
||||
# 固定窗口尺寸,避免 Turnstile iframe 在 0 尺寸窗口里不渲染
|
||||
# 轻微随机化位置即可,尺寸过大/过小都可能影响验证渲染
|
||||
options.set_argument("--window-size=1280,900")
|
||||
# 隐藏窗口(可选):仅移出屏幕。注意:部分环境下会让 Turnstile 永远 token=0
|
||||
if config.get("hide_window", False):
|
||||
options.set_argument("--window-position=-32000,-32000")
|
||||
else:
|
||||
options.set_argument("--window-position=40,40")
|
||||
options.set_argument(f"--window-position={random.randint(40, 120)},{random.randint(40, 120)}")
|
||||
|
||||
# 代理:线程绑定(代理池)> 入参 > config.proxy
|
||||
use_proxy = ""
|
||||
@@ -1126,13 +1130,25 @@ _LAST_NAMES = (
|
||||
)
|
||||
_NAME_SEPARATORS = ("", ".", "_")
|
||||
|
||||
# z 开头的常见英文名字,用于生成 local-part 全部以 z 起首的邮箱
|
||||
_Z_FIRST_NAMES = (
|
||||
"zachary", "zack", "zackary", "zackery", "zane", "zayn", "zayne", "zavier",
|
||||
"zaid", "zaiden", "zayden", "zaire", "zeke", "zephyr", "zeus", "zion",
|
||||
"ziggy", "zed", "zeb", "zebulon", "zelig", "zeno", "zenon", "zaki",
|
||||
"zoe", "zoey", "zoie", "zoya", "zora", "zara", "zaria", "zariah",
|
||||
"zelda", "zena", "zenia", "zinnia", "ziva", "zola", "zainab", "zaynab",
|
||||
"zahra", "zaida", "zanna", "zara", "zarah", "zaria", "zia", "zula",
|
||||
)
|
||||
|
||||
|
||||
def generate_username(length=10):
|
||||
"""生成更像正常英文用户的邮箱名:姓名/词根 + 数字结尾。
|
||||
|
||||
length 仅作兼容参数(旧调用传 10),实际长度由名字与数字后缀决定,约 8–18。
|
||||
强制 local-part 以 'z' 起首(first 从 z 开头姓名池抽取;last+first[0] 风格改为
|
||||
z + last,仍保证首字符为 z)。
|
||||
"""
|
||||
first = secrets.choice(_FIRST_NAMES)
|
||||
first = secrets.choice(_Z_FIRST_NAMES)
|
||||
last = secrets.choice(_LAST_NAMES)
|
||||
sep = secrets.choice(_NAME_SEPARATORS)
|
||||
# 数字后缀:2–4 位更像真实用户(生日年份、学号尾号等)
|
||||
@@ -1164,8 +1180,8 @@ def generate_username(length=10):
|
||||
# first 首字母 + last + digits e.g. jwilson87
|
||||
base = f"{first[0]}{last}"
|
||||
else:
|
||||
# last + first 首字母 + digits e.g. wilsonj87
|
||||
base = f"{last}{first[0]}"
|
||||
# z + last + digits e.g. zwilson87(原 last+first[0] 风格改造以保证 z 起首)
|
||||
base = f"z{last}"
|
||||
|
||||
local = f"{base}{digits}".lower()
|
||||
# 邮箱 local 只保留 [a-z0-9._],去掉连续分隔符
|
||||
@@ -2501,7 +2517,7 @@ def fill_email_and_submit(session, timeout=45, log_callback=None, cancel_callbac
|
||||
while time.time() < deadline:
|
||||
raise_if_cancelled(cancel_callback)
|
||||
filled = page.run_js(
|
||||
"""
|
||||
r"""
|
||||
const email = arguments[0];
|
||||
function isVisible(node) {
|
||||
if (!node) return false;
|
||||
@@ -3837,6 +3853,37 @@ def register_one(session, shared, worker_id, slot_no):
|
||||
return email
|
||||
|
||||
|
||||
def _check_bot_flag(email, cfg=None):
|
||||
"""检查刚注册账号的 access_token 是否带 bot_flag_source。
|
||||
|
||||
返回 (is_bot: bool, detail: str)。
|
||||
"""
|
||||
cfg = cfg or config
|
||||
try:
|
||||
import cpa
|
||||
out_dir = str(cfg.get("cpa_auth_dir", "./cpa_auths"))
|
||||
fname = cpa.credential_file_name(email)
|
||||
path = os.path.join(out_dir, fname)
|
||||
if not os.path.exists(path):
|
||||
return False, f"auth file not found: {path}"
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
at = str(data.get("access_token") or "")
|
||||
if not at:
|
||||
return False, "no access_token"
|
||||
parts = at.split(".")
|
||||
if len(parts) < 2:
|
||||
return False, "invalid JWT"
|
||||
seg = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(seg))
|
||||
bot = payload.get("bot_flag_source")
|
||||
if bot == 1:
|
||||
return True, "bot_flag_source=1 → 账号被标记为 bot"
|
||||
return False, f"bot_flag_source={bot} (OK)"
|
||||
except Exception as e:
|
||||
return False, f"check error: {e}"
|
||||
|
||||
|
||||
def register_worker(worker_id, shared):
|
||||
"""单个并发 worker:自建独立浏览器,循环领取名额并注册。"""
|
||||
|
||||
@@ -3879,6 +3926,18 @@ def register_worker(worker_id, shared):
|
||||
except Exception:
|
||||
pass
|
||||
log(f"[+] 注册成功: {email}(累计成功 {total})")
|
||||
|
||||
# ===== 注册后立即检查 bot 标记 =====
|
||||
if config.get("stop_on_bot_flag", True):
|
||||
is_bot, detail = _check_bot_flag(email)
|
||||
if is_bot:
|
||||
log(f"[!] ⛔ 检测到 bot 标记: {detail}")
|
||||
log("[!] 立即停止所有注册(避免继续浪费账号)")
|
||||
shared.stop()
|
||||
raise RegistrationCancelled("检测到 bot_flag,停止注册")
|
||||
else:
|
||||
log(f"[Debug] bot 检查通过: {detail}")
|
||||
|
||||
break
|
||||
except RegistrationCancelled:
|
||||
raise
|
||||
@@ -3929,6 +3988,13 @@ def register_worker(worker_id, shared):
|
||||
break
|
||||
if shared.should_stop():
|
||||
break
|
||||
# 注册间隔:控制注册速率,降低被标记为 bot 的风险
|
||||
interval = float(config.get("register_interval_sec", 0) or 0)
|
||||
if interval > 0:
|
||||
log(f"[*] 等待 {interval:.0f}s 后开始下一个账号(register_interval_sec)")
|
||||
sleep_with_cancel(interval, shared.should_stop)
|
||||
if shared.should_stop():
|
||||
break
|
||||
# 下一账号:默认轮换出口并重启浏览器
|
||||
session.restart()
|
||||
except RegistrationCancelled:
|
||||
@@ -3985,11 +4051,55 @@ def run_registration_concurrent(count, concurrency):
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Grok 注册机 - 批量注册 xAI/Grok 账号",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python grok_register_ttk.py # 交互模式
|
||||
python grok_register_ttk.py --count 10 --concurrency 2 # 注册10个,2并发
|
||||
python grok_register_ttk.py --count 50 --interval 120 # 每2分钟注册1个
|
||||
python grok_register_ttk.py --no-stop-on-bot # 不自动停(调试用)
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--count", type=int, default=None,
|
||||
help="注册数量(默认读取 config.json 或 1)")
|
||||
parser.add_argument("--concurrency", type=int, default=None,
|
||||
help="并发浏览器数(默认交互输入)")
|
||||
parser.add_argument("--interval", type=int, default=None, dest="register_interval_sec",
|
||||
help="注册间隔秒数(如 120=每2分钟1个,0=无间隔)")
|
||||
parser.add_argument("--stop-on-bot", action="store_true", default=None, dest="stop_on_bot_flag",
|
||||
help="检测到 bot_flag 立即停止(默认开启)")
|
||||
parser.add_argument("--no-stop-on-bot", action="store_false", default=None, dest="stop_on_bot_flag",
|
||||
help="不自动停止(调试用)")
|
||||
parser.add_argument("--hide-window", action="store_true", default=None, dest="hide_window",
|
||||
help="隐藏浏览器窗口(移到屏幕外,非 headless)")
|
||||
parser.add_argument("--show-window", action="store_false", default=None, dest="hide_window",
|
||||
help="显示浏览器窗口(默认)")
|
||||
args = parser.parse_args()
|
||||
|
||||
load_config()
|
||||
count = int(config.get("register_count", 1) or 1)
|
||||
count = args.count if args.count is not None else int(config.get("register_count", 1) or 1)
|
||||
|
||||
# 命令行参数覆盖到 config(后续代码统一读 config)
|
||||
if args.register_interval_sec is not None:
|
||||
config["register_interval_sec"] = args.register_interval_sec
|
||||
if args.stop_on_bot_flag is not None:
|
||||
config["stop_on_bot_flag"] = args.stop_on_bot_flag
|
||||
if args.hide_window is not None:
|
||||
config["hide_window"] = args.hide_window
|
||||
# 默认值
|
||||
config.setdefault("register_interval_sec", 0)
|
||||
config.setdefault("stop_on_bot_flag", True)
|
||||
|
||||
cli_log("[*] 已加载配置")
|
||||
interval = config.get("register_interval_sec", 0)
|
||||
cli_log(
|
||||
f"[*] 当前邮箱服务商: {config.get('email_provider', 'duckmail')} | 目标注册数: {count}"
|
||||
f"[*] 邮箱: {config.get('email_provider', 'duckmail')} | "
|
||||
f"目标: {count} | 间隔: {'{}s'.format(interval) if interval else '无'} | "
|
||||
f"bot检测: {'开' if config.get('stop_on_bot_flag') else '关'} | "
|
||||
f"窗口: {'隐藏' if config.get('hide_window') else '显示'}"
|
||||
)
|
||||
try:
|
||||
import proxy_pool
|
||||
@@ -4006,18 +4116,20 @@ def main():
|
||||
)
|
||||
else:
|
||||
cli_log("[*] CPA 导出: 关闭(仅写 accounts_*.txt)")
|
||||
try:
|
||||
raw = input("请输入并发数量(同时开几个浏览器,直接回车=1): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
cli_log("[!] 已取消")
|
||||
return
|
||||
try:
|
||||
concurrency = int(raw) if raw else 1
|
||||
except ValueError:
|
||||
cli_log("[!] 并发数量无效,使用 1")
|
||||
concurrency = 1
|
||||
if concurrency < 1:
|
||||
concurrency = 1
|
||||
|
||||
concurrency = args.concurrency
|
||||
if concurrency is None:
|
||||
try:
|
||||
raw = input("请输入并发数量(同时开几个浏览器,直接回车=1): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
cli_log("[!] 已取消")
|
||||
return
|
||||
try:
|
||||
concurrency = int(raw) if raw else 1
|
||||
except ValueError:
|
||||
cli_log("[!] 并发数量无效,使用 1")
|
||||
concurrency = 1
|
||||
concurrency = max(1, min(concurrency, count))
|
||||
run_registration_concurrent(count, concurrency)
|
||||
|
||||
|
||||
|
||||
@@ -979,3 +979,7 @@ t0rpdza1s0@mail.bblbb.com bblbb:t0rpdza1s0
|
||||
w5ayintm8r@mail.bblbb.com bblbb:w5ayintm8r
|
||||
2in9y6cab4@mail.bblbb.com bblbb:2in9y6cab4
|
||||
5fbu5nq3wj@mail.bblbb.com bblbb:5fbu5nq3wj
|
||||
cynthia464@mail.bblbb.com bblbb:cynthia464
|
||||
meganmartinez956@mail.bblbb.com bblbb:meganmartinez956
|
||||
helenramos281@mail.bblbb.com bblbb:helenramos281
|
||||
zoieroberts1869@mail.bblbb.com bblbb:zoieroberts1869
|
||||
@@ -16,7 +16,6 @@ from .oauth_code import (
|
||||
OAuthCodeError,
|
||||
SCOPE as CODE_SCOPE,
|
||||
mint_from_sso,
|
||||
mint_from_sso_browser,
|
||||
normalize_sso_cookie,
|
||||
sso_to_token,
|
||||
)
|
||||
@@ -27,7 +26,6 @@ __all__ = [
|
||||
"mint_with_browser",
|
||||
"shutdown_mint_browsers",
|
||||
"mint_from_sso",
|
||||
"mint_from_sso_browser",
|
||||
"sso_to_token",
|
||||
"normalize_sso_cookie",
|
||||
"CLIENT_ID",
|
||||
|
||||
+221
-426
@@ -3,8 +3,14 @@
|
||||
对齐最新可用流程:authorize / consent 必须带 referrer=grok-build,
|
||||
否则 access_token JWT 缺少 referrer 字段,cli-chat-proxy / grok-build 不可用。
|
||||
|
||||
参考实现:sso -> oauth2/authorize(referrer=grok-build) -> consent allow
|
||||
参考实现:sso -> oauth2/authorize(referrer=grok-build) -> consent page
|
||||
-> POST auth.x.ai/oauth2/authorize (form action=allow)
|
||||
-> oauth2/token (authorization_code + PKCE)
|
||||
|
||||
2026-07-16:accounts.x.ai consent 页 Next.js Server Action 频繁轮换,
|
||||
硬编码 Next-Action 会 404 "Server action not found"。
|
||||
官方 HTML form 的 action 指向 auth.x.ai/oauth2/authorize(非 consent URL),
|
||||
form-urlencoded + action=allow 可稳定拿到 code。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,18 +31,24 @@ CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
ISSUER = "https://auth.x.ai"
|
||||
TOKEN_URL = f"{ISSUER}/oauth2/token"
|
||||
AUTHORIZE_URL = f"{ISSUER}/oauth2/authorize"
|
||||
REDIRECT_URI = "http://127.0.0.1:56121/callback"
|
||||
# 比旧 device-code scope 多 conversations:*,对齐 grok-build
|
||||
# 官方 CLI 0.2.101:loopback redirect,运行时随机端口(RFC 8252 端口无关)
|
||||
# 兼容保留旧固定端口常量,仅作 fallback
|
||||
REDIRECT_URI_LEGACY = "http://127.0.0.1:56121/callback"
|
||||
REDIRECT_URI = REDIRECT_URI_LEGACY
|
||||
# cli-chat-proxy 要求 access_token.scope 含 grok-cli:access,否则:
|
||||
# WKE=unauthorized:grok-cli-token-auth-required
|
||||
# 2026-07 一度误删该 scope(对齐 discovery),导致铸造成功但调用 403。
|
||||
SCOPE = (
|
||||
"openid profile email offline_access "
|
||||
"grok-cli:access api:access conversations:read conversations:write"
|
||||
)
|
||||
GROK_CLI_SCOPE = "grok-cli:access"
|
||||
GROK_REFERRER = "grok-build"
|
||||
GROK_VERSION = "0.2.93"
|
||||
GROK_TOKEN_UA = (
|
||||
f"grok-pager/{GROK_VERSION} grok-shell/{GROK_VERSION} (linux; x86_64)"
|
||||
)
|
||||
# Next.js Server Action id(consent 页 POST 需要)
|
||||
# 对齐官方 stable CLI(2026-07-14 二进制:0.2.101)
|
||||
GROK_VERSION = "0.2.101"
|
||||
GROK_TOKEN_UA = f"xai-grok-build/{GROK_VERSION}"
|
||||
GROK_CLIENT_SURFACE = "grok-build"
|
||||
# 旧 Next.js Server Action id(已失效,仅作 fallback 尝试)
|
||||
NEXT_ACTION_ID = "4005315a1d7e426de592990bb54bb37471f39dd6d2"
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
@@ -60,6 +72,7 @@ class AuthCodeFlow:
|
||||
nonce: str
|
||||
code_verifier: str
|
||||
code_challenge: str
|
||||
redirect_uri: str = REDIRECT_URI_LEGACY
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -77,6 +90,13 @@ def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _new_loopback_redirect_uri() -> str:
|
||||
"""官方 CLI:http://127.0.0.1:<ephemeral>/callback。"""
|
||||
# 高位端口,避免与常见本机服务冲突
|
||||
port = 49152 + secrets.randbelow(16383)
|
||||
return f"http://127.0.0.1:{port}/callback"
|
||||
|
||||
|
||||
def new_auth_code_flow() -> AuthCodeFlow:
|
||||
verifier = _b64url(secrets.token_bytes(32))
|
||||
state = _b64url(secrets.token_bytes(16))
|
||||
@@ -87,6 +107,7 @@ def new_auth_code_flow() -> AuthCodeFlow:
|
||||
nonce=nonce,
|
||||
code_verifier=verifier,
|
||||
code_challenge=challenge,
|
||||
redirect_uri=_new_loopback_redirect_uri(),
|
||||
)
|
||||
|
||||
|
||||
@@ -126,6 +147,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 (
|
||||
"<!doctype html" in text
|
||||
or "cloudflare" in text
|
||||
or "cf-ray" in text
|
||||
or "attention required" in text
|
||||
or "just a moment" in text
|
||||
or "enable javascript" in text
|
||||
or "oldie" in text
|
||||
):
|
||||
return True
|
||||
if "authorize http 403" in text and (
|
||||
"<!doctype" in text or "oldie" in text or "cloudflare" in text
|
||||
):
|
||||
return True
|
||||
return "cloudflare" in text and ("403" in text or "blocked" in text)
|
||||
|
||||
|
||||
def _cf_block_hint(proxy_label: str = "") -> 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
|
||||
@@ -245,11 +299,15 @@ def _browser_headers(method: str, url: str, next_action: str = "") -> dict[str,
|
||||
|
||||
|
||||
def _token_headers() -> dict[str, str]:
|
||||
# 对齐官方 0.2.101:小写 x-grok-* + surface=grok-build
|
||||
return {
|
||||
"User-Agent": GROK_TOKEN_UA,
|
||||
"Accept": "*/*",
|
||||
"X-Grok-Client-Version": GROK_VERSION,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"x-grok-client-version": GROK_VERSION,
|
||||
"x-grok-client-surface": GROK_CLIENT_SURFACE,
|
||||
# 兼容旧中间件/代理仍读 Pascal 头
|
||||
"X-Grok-Client-Version": GROK_VERSION,
|
||||
}
|
||||
|
||||
|
||||
@@ -261,10 +319,12 @@ def _final_url(resp: Any) -> str:
|
||||
|
||||
|
||||
def open_authorize_page(session: Any, flow: AuthCodeFlow) -> str:
|
||||
redirect_uri = flow.redirect_uri or _new_loopback_redirect_uri()
|
||||
flow.redirect_uri = redirect_uri
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": flow.code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
@@ -282,6 +342,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)}"
|
||||
)
|
||||
@@ -333,12 +397,91 @@ def parse_consent_code(body: str) -> str:
|
||||
raise OAuthCodeError(f"consent 响应缺少 code: {_short(text, 300)}")
|
||||
|
||||
|
||||
def approve_authorization(session: Any, consent_url: str, flow: AuthCodeFlow) -> str:
|
||||
def _code_from_location(url: str) -> str:
|
||||
if not url or "code=" not in url:
|
||||
return ""
|
||||
qs = parse_qs(urlparse(url).query)
|
||||
return str((qs.get("code") or [""])[0] or "").strip()
|
||||
|
||||
|
||||
def _approve_via_form_post(
|
||||
session: Any, consent_url: str, flow: AuthCodeFlow
|
||||
) -> str:
|
||||
"""POST form to auth.x.ai/oauth2/authorize (matches consent page HTML)."""
|
||||
redirect_uri = flow.redirect_uri or REDIRECT_URI_LEGACY
|
||||
fields = {
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": SCOPE,
|
||||
"state": flow.state,
|
||||
"code_challenge": flow.code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"nonce": flow.nonce,
|
||||
"principal_type": "User",
|
||||
"principal_id": "",
|
||||
"referrer": GROK_REFERRER,
|
||||
"action": "allow",
|
||||
}
|
||||
headers = {
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"*/*;q=0.8"
|
||||
),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://accounts.x.ai",
|
||||
"Referer": consent_url,
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
# 不自动 follow 到 127.0.0.1 loopback(本机无 listener)
|
||||
resp = session.post(
|
||||
AUTHORIZE_URL,
|
||||
data=urlencode(fields),
|
||||
headers=headers,
|
||||
allow_redirects=False,
|
||||
timeout=30,
|
||||
)
|
||||
loc = (
|
||||
resp.headers.get("Location")
|
||||
or resp.headers.get("location")
|
||||
or ""
|
||||
)
|
||||
code = _code_from_location(loc)
|
||||
if code:
|
||||
return code
|
||||
|
||||
# 少数库会吞 Location 到 resp.url / 已 follow
|
||||
final = _final_url(resp)
|
||||
code = _code_from_location(final)
|
||||
if code:
|
||||
return code
|
||||
|
||||
text = resp.text or ""
|
||||
if 200 <= resp.status_code < 300:
|
||||
try:
|
||||
return parse_consent_code(text)
|
||||
except OAuthCodeError:
|
||||
pass
|
||||
raise OAuthCodeError(
|
||||
f"consent form POST HTTP {resp.status_code}: "
|
||||
f"loc={_short(loc, 120)} body={_short(text, 200)}"
|
||||
)
|
||||
|
||||
|
||||
def _approve_via_next_action(
|
||||
session: Any, consent_url: str, flow: AuthCodeFlow
|
||||
) -> str:
|
||||
"""旧路径:Next.js Server Action POST consent URL(action id 常失效)。"""
|
||||
redirect_uri = flow.redirect_uri or REDIRECT_URI_LEGACY
|
||||
payload = [
|
||||
{
|
||||
"action": "allow",
|
||||
"clientId": CLIENT_ID,
|
||||
"redirectUri": REDIRECT_URI,
|
||||
"redirectUri": redirect_uri,
|
||||
"scope": SCOPE,
|
||||
"state": flow.state,
|
||||
"codeChallenge": flow.code_challenge,
|
||||
@@ -354,29 +497,48 @@ def approve_authorization(session: Any, consent_url: str, flow: AuthCodeFlow) ->
|
||||
consent_url,
|
||||
data=body.encode("utf-8"),
|
||||
headers=_browser_headers("POST", consent_url, NEXT_ACTION_ID),
|
||||
allow_redirects=True,
|
||||
allow_redirects=False,
|
||||
timeout=30,
|
||||
)
|
||||
text = resp.text or ""
|
||||
loc = (
|
||||
resp.headers.get("Location")
|
||||
or resp.headers.get("location")
|
||||
or ""
|
||||
)
|
||||
code = _code_from_location(loc) or _code_from_location(_final_url(resp))
|
||||
if code:
|
||||
return code
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise OAuthCodeError(f"consent HTTP {resp.status_code}: {_short(text, 300)}")
|
||||
|
||||
# 有时 302 到 redirect_uri?code=
|
||||
final = _final_url(resp)
|
||||
if "code=" in final:
|
||||
qs = parse_qs(urlparse(final).query)
|
||||
code = (qs.get("code") or [""])[0]
|
||||
if code:
|
||||
return code
|
||||
|
||||
raise OAuthCodeError(
|
||||
f"consent Next-Action HTTP {resp.status_code}: {_short(text, 300)}"
|
||||
)
|
||||
return parse_consent_code(text)
|
||||
|
||||
|
||||
def approve_authorization(session: Any, consent_url: str, flow: AuthCodeFlow) -> str:
|
||||
"""Approve consent and return authorization code.
|
||||
|
||||
优先:HTML form POST → https://auth.x.ai/oauth2/authorize
|
||||
回退:旧 Next-Action POST consent URL(易 404)。
|
||||
"""
|
||||
try:
|
||||
return _approve_via_form_post(session, consent_url, flow)
|
||||
except OAuthCodeError as form_exc:
|
||||
try:
|
||||
return _approve_via_next_action(session, consent_url, flow)
|
||||
except OAuthCodeError as next_exc:
|
||||
raise OAuthCodeError(
|
||||
f"consent allow failed: form={form_exc}; next_action={next_exc}"
|
||||
) from next_exc
|
||||
|
||||
|
||||
def exchange_auth_code(session: Any, code: str, flow: AuthCodeFlow) -> TokenResult:
|
||||
redirect_uri = flow.redirect_uri or REDIRECT_URI_LEGACY
|
||||
form = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": CLIENT_ID,
|
||||
"code_verifier": flow.code_verifier,
|
||||
}
|
||||
@@ -429,7 +591,10 @@ def _run_sso_flow(
|
||||
) -> TokenResult:
|
||||
flow = new_auth_code_flow()
|
||||
backend = getattr(session, "_cpa_http_backend", "unknown")
|
||||
log(f"Authorization Code Flow referrer={GROK_REFERRER} http={backend}")
|
||||
log(
|
||||
f"Authorization Code Flow ver={GROK_VERSION} ua={GROK_TOKEN_UA} "
|
||||
f"referrer={GROK_REFERRER} redirect={flow.redirect_uri} http={backend}"
|
||||
)
|
||||
_set_sso_cookies(session, sso)
|
||||
consent_url = open_authorize_page(session, flow)
|
||||
log(f"authorize -> consent: {_short(consent_url, 120)}")
|
||||
@@ -443,6 +608,26 @@ def _run_sso_flow(
|
||||
log(f"WARN {msg}")
|
||||
else:
|
||||
log("access_token referrer=grok-build ok")
|
||||
|
||||
# cli-chat-proxy 的 Grok CLI gate:缺 grok-cli:access 会直接 403
|
||||
scope_text = ""
|
||||
try:
|
||||
scope_text = str(jwt_payload(token.access_token).get("scope") or "")
|
||||
except Exception:
|
||||
scope_text = ""
|
||||
scopes = set(scope_text.split())
|
||||
if GROK_CLI_SCOPE not in scopes:
|
||||
msg = (
|
||||
f"access_token 缺少 {GROK_CLI_SCOPE} "
|
||||
f"(scope={scope_text or '(empty)'});"
|
||||
"cli-chat-proxy 会返回 grok-cli-token-auth-required"
|
||||
)
|
||||
if require_referrer:
|
||||
raise OAuthCodeError(msg)
|
||||
log(f"WARN {msg}")
|
||||
else:
|
||||
log(f"access_token scope 含 {GROK_CLI_SCOPE} ok")
|
||||
|
||||
log(f"token ok expires_in={token.expires_in} refresh=yes")
|
||||
return token
|
||||
|
||||
@@ -455,11 +640,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:
|
||||
@@ -472,6 +662,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:
|
||||
@@ -483,6 +675,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()
|
||||
@@ -520,404 +716,3 @@ def mint_from_sso(
|
||||
"referrer": tr.referrer,
|
||||
"sso": normalize_sso_cookie(sso_cookie),
|
||||
}
|
||||
|
||||
|
||||
def _is_http_tls_failure(exc: BaseException | str) -> bool:
|
||||
"""HTTP 层 TLS/连接失败:适合改走浏览器铸造。"""
|
||||
text = str(exc or "").lower()
|
||||
needles = (
|
||||
"unexpected_eof_while_reading",
|
||||
"sslerror",
|
||||
"ssleoferror",
|
||||
"max retries exceeded",
|
||||
"openssl_internal:invalid library",
|
||||
"tls connect error",
|
||||
"curl: (35)",
|
||||
"failed to perform, curl: (35)",
|
||||
"ssl_error_syscall",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"name resolution",
|
||||
"timed out",
|
||||
"timeout",
|
||||
)
|
||||
return any(n in text for n in needles)
|
||||
|
||||
|
||||
def _page_eval(page: Any, js: str, *args: Any) -> Any:
|
||||
"""兼容 DrissionPage page.run_js / page.run_js_loaded。"""
|
||||
if page is None:
|
||||
raise OAuthCodeError("page 为空,无法浏览器铸造")
|
||||
last_err: Exception | None = None
|
||||
for name in ("run_js", "run_js_loaded", "run_async_js"):
|
||||
fn = getattr(page, name, None)
|
||||
if not callable(fn):
|
||||
continue
|
||||
try:
|
||||
if args:
|
||||
return fn(js, *args)
|
||||
return fn(js)
|
||||
except TypeError:
|
||||
# 某些签名不接受额外参数
|
||||
try:
|
||||
return fn(js)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = exc
|
||||
continue
|
||||
raise OAuthCodeError(f"page 无法执行 JS: {last_err or 'no run_js'}")
|
||||
|
||||
|
||||
def _ensure_sso_on_page(page: Any, sso: str, log: LogFn) -> None:
|
||||
sso = normalize_sso_cookie(sso)
|
||||
if not sso:
|
||||
raise OAuthCodeError("sso cookie 为空")
|
||||
# 先落到 accounts 域,再写 cookie,避免 set 失败
|
||||
try:
|
||||
page.get("https://accounts.x.ai/")
|
||||
time.sleep(0.4)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"open accounts.x.ai warn: {exc}")
|
||||
set_js = r"""
|
||||
(sso) => {
|
||||
try {
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
const base = `; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||||
document.cookie = `sso=${sso}${base}`;
|
||||
document.cookie = `sso-rw=${sso}${base}`;
|
||||
// 兼容 secure 场景
|
||||
document.cookie = `sso=${sso}${base}; Secure`;
|
||||
document.cookie = `sso-rw=${sso}${base}; Secure`;
|
||||
return document.cookie.includes('sso=');
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
ok = _page_eval(page, set_js, sso)
|
||||
log(f"browser sso cookie set: {ok!r}")
|
||||
except Exception:
|
||||
# 退而求其次:DrissionPage set.cookies
|
||||
try:
|
||||
setter = getattr(page, "set", None)
|
||||
cookies = getattr(setter, "cookies", None) if setter is not None else None
|
||||
if callable(cookies):
|
||||
for domain in ("accounts.x.ai", "auth.x.ai", ".x.ai"):
|
||||
cookies({"name": "sso", "value": sso, "domain": domain, "path": "/"})
|
||||
cookies({"name": "sso-rw", "value": sso, "domain": domain, "path": "/"})
|
||||
log("browser sso cookie set via page.set.cookies")
|
||||
else:
|
||||
raise OAuthCodeError("无法写入 sso cookie")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise OAuthCodeError(f"写入 sso cookie 失败: {exc}") from exc
|
||||
|
||||
|
||||
def _browser_click_allow(page: Any, log: LogFn) -> bool:
|
||||
js = r"""
|
||||
() => {
|
||||
function isVisible(node) {
|
||||
if (!node) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
function textOf(node) {
|
||||
return [node.innerText, node.textContent, node.getAttribute('aria-label'), node.getAttribute('value')]
|
||||
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
const nodes = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], a'));
|
||||
const prefer = [];
|
||||
const weak = [];
|
||||
for (const n of nodes) {
|
||||
if (!isVisible(n) || n.disabled || n.getAttribute('aria-disabled') === 'true') continue;
|
||||
const t = textOf(n);
|
||||
const compact = t.replace(/\s+/g, '');
|
||||
const lower = compact.toLowerCase();
|
||||
if (!compact) continue;
|
||||
// 精确允许,排除“全部允许”
|
||||
if (compact === '允许' || lower === 'allow' || lower === 'authorize' || compact === '授权') {
|
||||
prefer.push(n);
|
||||
continue;
|
||||
}
|
||||
if ((compact.includes('允许') || lower.includes('allow') || lower.includes('authorize'))
|
||||
&& !compact.includes('全部') && !lower.includes('all')) {
|
||||
weak.push(n);
|
||||
}
|
||||
}
|
||||
const target = prefer[0] || weak[0];
|
||||
if (!target) return {clicked:false, texts: nodes.slice(0,8).map(textOf)};
|
||||
target.focus();
|
||||
target.click();
|
||||
return {clicked:true, text: textOf(target)};
|
||||
}
|
||||
"""
|
||||
try:
|
||||
ret = _page_eval(page, js)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"click allow js failed: {exc}")
|
||||
return False
|
||||
if isinstance(ret, dict) and ret.get("clicked"):
|
||||
log(f"browser clicked allow: {ret.get('text')!r}")
|
||||
return True
|
||||
log(f"browser allow button not found: {ret!r}")
|
||||
return False
|
||||
|
||||
|
||||
def _browser_fetch_token(page: Any, code: str, flow: AuthCodeFlow, log: LogFn) -> TokenResult:
|
||||
"""在浏览器上下文用 fetch 换 token,绕开 Python TLS 对 auth.x.ai 的 EOF。"""
|
||||
form = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"client_id": CLIENT_ID,
|
||||
"code_verifier": flow.code_verifier,
|
||||
}
|
||||
body = urlencode(form)
|
||||
js = r"""
|
||||
(tokenUrl, body, ua, ver) => {
|
||||
return fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': '*/*',
|
||||
'User-Agent': ua,
|
||||
'X-Grok-Client-Version': ver,
|
||||
},
|
||||
body: body,
|
||||
credentials: 'include',
|
||||
}).then(async (r) => {
|
||||
const text = await r.text();
|
||||
return {status: r.status, text: text};
|
||||
}).catch((e) => ({status: 0, text: String(e)}));
|
||||
}
|
||||
"""
|
||||
# 先到 auth 域,减少跨站限制
|
||||
try:
|
||||
page.get(ISSUER + "/")
|
||||
time.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
ret = None
|
||||
# DrissionPage 对 Promise 支持不一,做短轮询包装
|
||||
wrap = r"""
|
||||
(tokenUrl, body, ua, ver) => {
|
||||
const key = '__cpa_token_result_' + Date.now();
|
||||
window[key] = null;
|
||||
fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': '*/*',
|
||||
'User-Agent': ua,
|
||||
'X-Grok-Client-Version': ver,
|
||||
},
|
||||
body: body,
|
||||
credentials: 'include',
|
||||
}).then(async (r) => {
|
||||
const text = await r.text();
|
||||
window[key] = {status: r.status, text: text};
|
||||
}).catch((e) => {
|
||||
window[key] = {status: 0, text: String(e)};
|
||||
});
|
||||
return key;
|
||||
}
|
||||
"""
|
||||
try:
|
||||
key = _page_eval(page, wrap, TOKEN_URL, body, GROK_TOKEN_UA, GROK_VERSION)
|
||||
except Exception:
|
||||
# 无参回退:把参数内联
|
||||
key = _page_eval(
|
||||
page,
|
||||
f"""
|
||||
(() => {{
|
||||
const key = '__cpa_token_result_' + Date.now();
|
||||
window[key] = null;
|
||||
fetch({TOKEN_URL!r}, {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': '*/*',
|
||||
'User-Agent': {GROK_TOKEN_UA!r},
|
||||
'X-Grok-Client-Version': {GROK_VERSION!r},
|
||||
}},
|
||||
body: {body!r},
|
||||
credentials: 'include',
|
||||
}}).then(async (r) => {{
|
||||
const text = await r.text();
|
||||
window[key] = {{status: r.status, text: text}};
|
||||
}}).catch((e) => {{
|
||||
window[key] = {{status: 0, text: String(e)}};
|
||||
}});
|
||||
return key;
|
||||
}})()
|
||||
""",
|
||||
)
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
ret = _page_eval(page, f"() => window[{key!r}]")
|
||||
except Exception:
|
||||
try:
|
||||
ret = _page_eval(page, f"window[{key!r}]")
|
||||
except Exception as exc:
|
||||
raise OAuthCodeError(f"读取 browser token 结果失败: {exc}") from exc
|
||||
if ret:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if not isinstance(ret, dict):
|
||||
raise OAuthCodeError(f"browser token 无响应: {ret!r}")
|
||||
status = int(ret.get("status") or 0)
|
||||
text = str(ret.get("text") or "")
|
||||
if status < 200 or status >= 300:
|
||||
raise OAuthCodeError(f"browser token HTTP {status}: {_short(text, 300)}")
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception as e:
|
||||
raise OAuthCodeError(f"browser token 非 JSON: {_short(text)}") from e
|
||||
if not isinstance(data, dict) or not data.get("access_token"):
|
||||
raise OAuthCodeError(f"browser token 缺少 access_token: {data!r}")
|
||||
access = str(data["access_token"]).strip()
|
||||
refresh = str(data.get("refresh_token") or "").strip()
|
||||
if not refresh:
|
||||
raise OAuthCodeError("browser token 缺少 refresh_token")
|
||||
referrer = ""
|
||||
try:
|
||||
referrer = str(jwt_payload(access).get("referrer") or "")
|
||||
except Exception:
|
||||
pass
|
||||
return TokenResult(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
id_token=(str(data["id_token"]).strip() if data.get("id_token") else None),
|
||||
token_type=str(data.get("token_type") or "Bearer"),
|
||||
expires_in=int(data.get("expires_in") or 21600),
|
||||
raw=data,
|
||||
referrer=referrer,
|
||||
)
|
||||
|
||||
|
||||
def mint_from_sso_browser(
|
||||
sso_cookie: str,
|
||||
page: Any,
|
||||
*,
|
||||
log: LogFn | None = None,
|
||||
require_referrer: bool = True,
|
||||
timeout_sec: float = 90.0,
|
||||
) -> dict[str, Any]:
|
||||
"""用注册浏览器完成 SSO→PKCE(绕开 Python TLS 访问 auth.x.ai 失败)。
|
||||
|
||||
流程:
|
||||
1. 写入 sso cookie
|
||||
2. 打开 authorize(referrer=grok-build)
|
||||
3. 在 consent 页点击允许 / 或解析 callback code
|
||||
4. 浏览器 fetch oauth2/token
|
||||
"""
|
||||
log = log or _noop_log
|
||||
sso = normalize_sso_cookie(sso_cookie)
|
||||
if not sso:
|
||||
raise OAuthCodeError("sso cookie 为空")
|
||||
if page is None:
|
||||
raise OAuthCodeError("page 为空")
|
||||
|
||||
flow = new_auth_code_flow()
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": flow.code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": flow.state,
|
||||
"nonce": flow.nonce,
|
||||
"referrer": GROK_REFERRER,
|
||||
}
|
||||
auth_url = f"{AUTHORIZE_URL}?{urlencode(params)}"
|
||||
log(f"browser PKCE authorize referrer={GROK_REFERRER}")
|
||||
_ensure_sso_on_page(page, sso, log)
|
||||
|
||||
try:
|
||||
page.get(auth_url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise OAuthCodeError(f"browser 打开 authorize 失败: {exc}") from exc
|
||||
|
||||
code = ""
|
||||
deadline = time.time() + max(20.0, float(timeout_sec))
|
||||
last_url = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
url = str(getattr(page, "url", "") or "")
|
||||
except Exception:
|
||||
url = ""
|
||||
if url and url != last_url:
|
||||
log(f"browser url: {_short(url, 140)}")
|
||||
last_url = url
|
||||
|
||||
# callback 已跳到 redirect_uri?code=
|
||||
if "code=" in url and ("127.0.0.1" in url or "callback" in url or "localhost" in url):
|
||||
qs = parse_qs(urlparse(url).query)
|
||||
code = (qs.get("code") or [""])[0].strip()
|
||||
if code:
|
||||
log("browser got code from redirect")
|
||||
break
|
||||
|
||||
# consent 页
|
||||
if "/oauth2/consent" in url or "consent" in url:
|
||||
_browser_click_allow(page, log)
|
||||
time.sleep(0.8)
|
||||
# 有时 consent 响应是 RSC,不跳转;尝试从 HTML 抽 code
|
||||
try:
|
||||
html = ""
|
||||
try:
|
||||
html = str(getattr(page, "html", "") or "")
|
||||
except Exception:
|
||||
html = str(_page_eval(page, "() => document.documentElement.outerHTML") or "")
|
||||
if html:
|
||||
try:
|
||||
code = parse_consent_code(html)
|
||||
if code:
|
||||
log("browser got code from consent html")
|
||||
break
|
||||
except OAuthCodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
if "sign-in" in url or "sign-up" in url:
|
||||
# cookie 可能没带上,重写一次
|
||||
_ensure_sso_on_page(page, sso, log)
|
||||
try:
|
||||
page.get(auth_url)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.8)
|
||||
continue
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if not code:
|
||||
raise OAuthCodeError(
|
||||
f"browser PKCE 超时未拿到 code(last_url={_short(last_url, 160)})"
|
||||
)
|
||||
|
||||
token = _browser_fetch_token(page, code, flow, log)
|
||||
if token.referrer != GROK_REFERRER:
|
||||
msg = f"access_token 未包含预期 referrer(got={token.referrer!r})"
|
||||
if require_referrer:
|
||||
raise OAuthCodeError(msg)
|
||||
log(f"WARN {msg}")
|
||||
else:
|
||||
log("browser access_token referrer=grok-build ok")
|
||||
log(f"browser token ok expires_in={token.expires_in}")
|
||||
return {
|
||||
"access_token": token.access_token,
|
||||
"refresh_token": token.refresh_token,
|
||||
"id_token": token.id_token,
|
||||
"token_type": token.token_type,
|
||||
"expires_in": token.expires_in,
|
||||
"referrer": token.referrer,
|
||||
"sso": sso,
|
||||
}
|
||||
@@ -20,7 +20,11 @@ CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
ISSUER = "https://auth.x.ai"
|
||||
DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code"
|
||||
TOKEN_URL = "https://auth.x.ai/oauth2/token"
|
||||
SCOPE = "openid profile email offline_access grok-cli:access api:access"
|
||||
# cli-chat-proxy 要求 token scope 含 grok-cli:access(否则 grok-cli-token-auth-required)
|
||||
SCOPE = (
|
||||
"openid profile email offline_access "
|
||||
"grok-cli:access api:access conversations:read conversations:write"
|
||||
)
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""批量重铸 CPA auth:补回 grok-cli:access scope。
|
||||
|
||||
只覆盖写回,不删除原文件。默认仅处理缺 grok-cli:access 的 xai-*.json。
|
||||
|
||||
用法:
|
||||
python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --dry-run
|
||||
python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --limit 5
|
||||
python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --concurrency 6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
|
||||
def _jwt_payload(token: str) -> dict[str, Any]:
|
||||
parts = (token or "").split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("invalid JWT")
|
||||
seg = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(seg.encode("ascii")))
|
||||
|
||||
|
||||
def _scope_set(token: str) -> set[str]:
|
||||
try:
|
||||
return set(str(_jwt_payload(token).get("scope") or "").split())
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _needs_remint(data: dict[str, Any], *, only_missing_cli: bool) -> tuple[bool, str]:
|
||||
at = str(data.get("access_token") or "").strip()
|
||||
if not at:
|
||||
return False, "no_access_token"
|
||||
scopes = _scope_set(at)
|
||||
has_cli = "grok-cli:access" in scopes
|
||||
if only_missing_cli and has_cli:
|
||||
return False, "already_has_cli_scope"
|
||||
sso = str(data.get("sso") or "").strip()
|
||||
if not sso:
|
||||
return False, "no_sso"
|
||||
return True, "missing_cli_scope" if not has_cli else "force"
|
||||
|
||||
|
||||
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
|
||||
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}.{threading.get_ident()}")
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def remint_one(
|
||||
path: Path,
|
||||
*,
|
||||
proxy: str | None,
|
||||
dry_run: bool,
|
||||
only_missing_cli: bool,
|
||||
) -> dict[str, Any]:
|
||||
import cpa
|
||||
from oidc_mint.oauth_code import GROK_CLI_SCOPE, mint_from_sso, normalize_sso_cookie
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "path": str(path), "error": f"read: {exc}"}
|
||||
|
||||
need, reason = _needs_remint(data, only_missing_cli=only_missing_cli)
|
||||
email = str(data.get("email") or "").strip()
|
||||
if not need:
|
||||
return {
|
||||
"ok": True,
|
||||
"skipped": True,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
sso = normalize_sso_cookie(str(data.get("sso") or ""))
|
||||
if dry_run:
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
try:
|
||||
tokens = mint_from_sso(
|
||||
sso,
|
||||
proxy=proxy,
|
||||
require_referrer=True,
|
||||
log=lambda m: None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"ok": False,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"error": f"mint: {exc}",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
access = str(tokens.get("access_token") or "").strip()
|
||||
refresh = str(tokens.get("refresh_token") or "").strip()
|
||||
if GROK_CLI_SCOPE not in _scope_set(access):
|
||||
return {
|
||||
"ok": False,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"error": f"minted token still missing {GROK_CLI_SCOPE}",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
try:
|
||||
payload = cpa.build_cpa_xai_auth(
|
||||
email=email or str(data.get("email") or ""),
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
id_token=tokens.get("id_token") or data.get("id_token"),
|
||||
expires_in=tokens.get("expires_in"),
|
||||
base_url=data.get("base_url") or cpa.CLI_BASE_URL,
|
||||
sso=sso,
|
||||
headers=data.get("headers") if isinstance(data.get("headers"), dict) else None,
|
||||
)
|
||||
# 保留原 disabled 状态,避免重铸把人工禁用号重新启用
|
||||
if "disabled" in data:
|
||||
payload["disabled"] = bool(data.get("disabled"))
|
||||
_atomic_write(path, payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"ok": False,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"error": f"write: {exc}",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(path),
|
||||
"email": email or payload.get("email"),
|
||||
"reason": reason,
|
||||
"referrer": payload.get("referrer"),
|
||||
"scope_ok": True,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Remint CPA auths missing grok-cli:access")
|
||||
p.add_argument(
|
||||
"--auth-dir",
|
||||
default=os.environ.get("CPA_AUTH_DIR", str(_ROOT / "cpa_auths")),
|
||||
help="CPA auth 目录(默认 ./cpa_auths 或 $CPA_AUTH_DIR)",
|
||||
)
|
||||
p.add_argument("--proxy", default=os.environ.get("https_proxy") or os.environ.get("http_proxy") or "")
|
||||
p.add_argument("--concurrency", type=int, default=4)
|
||||
p.add_argument("--limit", type=int, default=0, help="最多处理 N 个需重铸文件(0=不限)")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
p.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="强制全部重铸(默认只处理缺 grok-cli:access 的)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--log-file",
|
||||
default="",
|
||||
help="结果日志路径(默认 auth-dir/remint_cli_scope.log)",
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
auth_dir = Path(args.auth_dir).expanduser().resolve()
|
||||
if not auth_dir.is_dir():
|
||||
print(f"[!] auth-dir 不存在: {auth_dir}", flush=True)
|
||||
return 2
|
||||
|
||||
proxy = (args.proxy or "").strip() or None
|
||||
only_missing_cli = not args.all
|
||||
files = sorted(auth_dir.glob("xai-*.json"))
|
||||
targets: list[Path] = []
|
||||
skipped = 0
|
||||
for path in files:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
need, _ = _needs_remint(data, only_missing_cli=only_missing_cli)
|
||||
if need:
|
||||
targets.append(path)
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception:
|
||||
skipped += 1
|
||||
if args.limit and len(targets) >= args.limit:
|
||||
break
|
||||
|
||||
log_path = Path(args.log_file).expanduser() if args.log_file else (auth_dir / "remint_cli_scope.log")
|
||||
print(
|
||||
f"[*] auth-dir={auth_dir} total={len(files)} targets={len(targets)} "
|
||||
f"skipped_scan={skipped} concurrency={args.concurrency} "
|
||||
f"proxy={proxy or '(direct)'} dry_run={args.dry_run}",
|
||||
flush=True,
|
||||
)
|
||||
if not targets:
|
||||
print("[*] 无需重铸", flush=True)
|
||||
return 0
|
||||
|
||||
ok_n = 0
|
||||
fail_n = 0
|
||||
skip_n = 0
|
||||
lock = threading.Lock()
|
||||
started = time.time()
|
||||
|
||||
def _handle(path: Path) -> dict[str, Any]:
|
||||
return remint_one(
|
||||
path,
|
||||
proxy=proxy,
|
||||
dry_run=args.dry_run,
|
||||
only_missing_cli=only_missing_cli,
|
||||
)
|
||||
|
||||
def _record(res: dict[str, Any]) -> None:
|
||||
nonlocal ok_n, fail_n, skip_n
|
||||
line = json.dumps(res, ensure_ascii=False)
|
||||
with lock:
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
if res.get("skipped") or res.get("dry_run"):
|
||||
skip_n += 1
|
||||
tag = "SKIP" if res.get("skipped") else "DRY"
|
||||
elif res.get("ok"):
|
||||
ok_n += 1
|
||||
tag = "OK"
|
||||
else:
|
||||
fail_n += 1
|
||||
tag = "FAIL"
|
||||
email = res.get("email") or Path(str(res.get("path") or "")).name
|
||||
extra = res.get("error") or res.get("reason") or ""
|
||||
print(f"[{tag}] {email} {extra}", flush=True)
|
||||
|
||||
# 清空/追加日志头
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "start",
|
||||
"ts": int(time.time()),
|
||||
"targets": len(targets),
|
||||
"dry_run": bool(args.dry_run),
|
||||
"proxy": proxy or "",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if args.concurrency <= 1:
|
||||
for path in targets:
|
||||
_record(_handle(path))
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as ex:
|
||||
futs = [ex.submit(_handle, path) for path in targets]
|
||||
for fut in as_completed(futs):
|
||||
try:
|
||||
_record(fut.result())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_record({"ok": False, "error": f"worker: {exc}"})
|
||||
|
||||
elapsed = time.time() - started
|
||||
print(
|
||||
f"[*] done ok={ok_n} fail={fail_n} skip/dry={skip_n} "
|
||||
f"elapsed={elapsed:.1f}s log={log_path}",
|
||||
flush=True,
|
||||
)
|
||||
return 0 if fail_n == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user