Compare commits

...
10 Commits
Author SHA1 Message Date
chaos d0f06ac93c feat: bot_flag 检测停机、注册间隔与 CLI 参数
注册成功后解析 access_token 的 bot_flag_source,命中则立即停止;
支持 register_interval_sec 与 argparse(count/concurrency/interval/hide-window);
邮箱 local-part 强制 z 起首,并关闭 AutomationControlled 特征。
2026-07-16 21:00:35 +08:00
chaos 986bc36f04 fix(oidc): consent allow 改走 auth.x.ai form POST,修复 404
硬编码 Next-Action 在 accounts.x.ai consent 页轮换后返回
"Server action not found"。对齐当前 HTML form:POST
https://auth.x.ai/oauth2/authorize (action=allow),旧
Next-Action 路径仅作 fallback。
2026-07-16 06:43:30 +08:00
chaos bc5ad63755 fix(oidc): 恢复 grok-cli:access 并支持批量重铸
补回 mint scope 中的 grok-cli:access,铸造后校验 JWT scope,
避免 cli-chat-proxy 返回 grok-cli-token-auth-required。
新增 remint_cli_scope.py,基于已有 sso 覆盖写回缺 scope 的 CPA auth;
同步更新 cpa/schema 文档说明 referrer 与 scope 双重要求。
2026-07-15 10:12:17 +08:00
chaos 7d6d52ac5d Detect Cloudflare 403 on mint and retry alternate proxies.
- Clear CF block errors with egress label and config hint
- Log mint exit (direct/proxy); probe local proxy ports on 403
- Retry mint_proxy/proxy/pool candidates without browser mint
2026-07-14 14:41:52 +08:00
chaos cc0aa6a433 Align OAuth mint with official Grok Build 0.2.101.
- UA xai-grok-build/0.2.101 + x-grok-client-version/surface
- Ephemeral loopback redirect_uri for authorize/consent/token
- Drop grok-cli:access from scope to match discovery
2026-07-14 14:30:38 +08:00
chaos 4abdc8aa4a Revert mint path to pre-browser PKCE baseline (2e833f2).
Restore HTTP-only SSO OAuth with curl_cffi then std requests fallback;
remove browser PKCE prefer/fallback knobs and mint_from_sso_browser.
2026-07-14 14:06:13 +08:00
chaos d74d14d3f4 Disable browser PKCE mint by default.
User does not want web/browser casting; HTTP-only unless
cpa_allow_browser_fallback is explicitly re-enabled.
2026-07-14 13:48:53 +08:00
chaos 0417fa3218 Lower browser PKCE mint priority; HTTP-first by default.
Browser path is slow; only use it as fallback after HTTP mint fails unless
cpa_prefer_browser_mint is explicitly enabled.
2026-07-14 10:36:20 +08:00
chaos bf30d40c8b Fix browser PKCE stuck on consent without code.
Use real Allow clicks (JS click breaks React action), then browser-side
Next.js Server Action POST as fallback; harden SSO cookie injection.
2026-07-14 10:23:24 +08:00
chaos 96061cbd78 Prefer browser PKCE mint over flaky direct HTTP to auth.x.ai.
Logs show curl(28)/ReadTimeout on direct mint while registration browser
still works; skip slow requests fallback, shorten HTTP step timeout, and
mint via page first when available.
2026-07-14 10:19:25 +08:00
13 changed files with 849 additions and 495 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
helenramos281@mail.bblbb.com----N9ee81e6c!a7#VCKDb6V6----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiMWU1OTMzYWEtMTJiYi00YjU5LTg1ZDktOTRiZmFhNDliYzU2In0.gnekLoMUj2B5DscbjE5hyC9FoLqkYjjXP9X3usyyqDQ
+1
View File
@@ -0,0 +1 @@
zoieroberts1869@mail.bblbb.com----Ndf7a20b2!a7#M3gEsHL-----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjRkOWNjNTMtM2Q0YS00MzNlLThhMTctMTIxYmUyODNmZTU4In0.ra0SYJyU146-eMqSQKcG9J63YDVip_tXOxk0SRXp_54
+3 -3
View File
@@ -27,12 +27,12 @@
"proxy_check_interval_sec": 300, "proxy_check_interval_sec": 300,
"proxy_source_disable_after": 3, "proxy_source_disable_after": 3,
"enable_nsfw": true, "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", "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_export_enabled": true,
"cpa_auth_dir": "./cpa_auths", "cpa_auth_dir": "./cpa_auths",
"cpa_base_url": "https://cli-chat-proxy.grok.com/v1", "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_base": "https://cpa.nopj.cn",
"cpa_remote_secret": "DNznuRVoBhDT2SpSAyWw", "cpa_remote_secret": "DNznuRVoBhDT2SpSAyWw",
"cpa_remote_verify_tls": true, "cpa_remote_verify_tls": true,
@@ -59,4 +59,4 @@
"gmail_imap_fallback": false, "gmail_imap_fallback": false,
"hide_window": false, "hide_window": false,
"block_media_fonts": false "block_media_fonts": false
} }
+5 -1
View File
@@ -5,7 +5,11 @@ internal/auth/xai/token.go 的 TokenStorage 结构。
自带 x-grok-client-version 头(xai_executor.go 硬编码 0.2.93),免费 Build 账号 自带 x-grok-client-version 头(xai_executor.go 硬编码 0.2.93),免费 Build 账号
不会 426。 不会 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)。 铸造请走 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
View File
@@ -62,6 +62,72 @@ def _resolve_proxy(cfg: dict) -> str | None:
return resolved or 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( def _mint_tokens(
*, *,
email: str, email: str,
@@ -72,66 +138,91 @@ def _mint_tokens(
log: Callable[[str], None], log: Callable[[str], None],
proxy: str | None, proxy: str | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""优先 HTTP SSO 授权码;TLS 失败时用注册浏览器 PKCE;可选设备码。""" """优先 SSO 授权码;可选回退设备码。"""
from oidc_mint.oauth_code import ( from oidc_mint import set_runtime_proxy
OAuthCodeError, from oidc_mint.oauth_code import OAuthCodeError, mint_from_sso, normalize_sso_cookie
_is_http_tls_failure,
mint_from_sso,
mint_from_sso_browser,
normalize_sso_cookie,
)
sso_token = normalize_sso_cookie(sso or "") sso_token = normalize_sso_cookie(sso or "")
prefer_sso = bool(cfg.get("cpa_prefer_sso_oauth", True)) 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)) allow_device = bool(cfg.get("cpa_allow_device_fallback", False))
timeout = float(cfg.get("mint_timeout_sec", 300) or 300) timeout = float(cfg.get("mint_timeout_sec", 300) or 300)
require_ref = bool(cfg.get("cpa_require_referrer", True)) max_proxy_tries = int(cfg.get("mint_proxy_retries", 4) or 4)
http_err: Exception | None = None
if prefer_sso and sso_token: if prefer_sso and sso_token:
log("[cpa] 使用 SSO→OAuth(PKCE, referrer=grok-build)") log("[cpa] 使用 SSO→OAuth(PKCE, referrer=grok-build)")
try: tried: set[str] = set()
return mint_from_sso( proxies_to_try: list[str | None] = [proxy]
sso_token, last_exc: Exception | None = None
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}")
# HTTP 失败后:有 page+sso 就优先浏览器 PKCE(Chrome TLS 通常正常,且保留 referrer) def _expand_proxy_candidates() -> None:
if allow_browser and page is not None and sso_token and http_err is not None: for p in _local_proxy_candidates(cfg):
why = "TLS/连接" if _is_http_tls_failure(http_err) else "HTTP" if p and p not in tried and p not in {
log(f"[cpa] 回退浏览器 PKCE 铸造({why}失败,绕开 Python TLS)") 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: try:
return mint_from_sso_browser( return mint_from_sso(
sso_token, sso_token,
page, proxy=use_proxy,
log=lambda m: log(f"[Debug] {m}"), log=lambda m: log(f"[Debug] {m}"),
require_referrer=require_ref, require_referrer=bool(cfg.get("cpa_require_referrer", True)),
timeout_sec=min(timeout, 120.0),
) )
except Exception as exc: # noqa: BLE001 except OAuthCodeError as exc:
log(f"[!] 浏览器 PKCE 失败: {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: if not allow_device:
raise raise
log("[cpa] 继续回退设备码铸造(可能缺 referrer)") log("[cpa] 回退设备码铸造(可能缺 referrer)")
elif http_err is not None and not allow_device: break
raise http_err 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: if not allow_device and not sso_token:
raise RuntimeError("无 sso cookie,且已禁用设备码回退;无法铸造带 referrer 的 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) # 设备码回退(旧路径,通常无 referrer)
from oidc_mint import mint_with_browser from oidc_mint import mint_with_browser
+132 -20
View File
@@ -17,6 +17,7 @@ import random
import re import re
import string import string
import json import json
import base64
from DrissionPage import Chromium, ChromiumOptions from DrissionPage import Chromium, ChromiumOptions
from DrissionPage.errors import PageDisconnectedError from DrissionPage.errors import PageDisconnectedError
@@ -85,7 +86,6 @@ DEFAULT_CONFIG = {
# OIDC:优先 SSO→Authorization Code + referrer=grok-build # OIDC:优先 SSO→Authorization Code + referrer=grok-build
"cpa_prefer_sso_oauth": True, # True=用 sso cookie 走 PKCE(必须带 referrer) "cpa_prefer_sso_oauth": True, # True=用 sso cookie 走 PKCE(必须带 referrer)
"cpa_require_referrer": True, # True=access_token 无 referrer=grok-build 则失败 "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 失败时回退设备码(通常不可用) "cpa_allow_device_fallback": False, # True=SSO 失败时回退设备码(通常不可用)
# OIDC 铸造代理/超时 # OIDC 铸造代理/超时
"mint_proxy": "", # 铸造专用代理;空=复用 proxy "mint_proxy": "", # 铸造专用代理;空=复用 proxy
@@ -587,13 +587,17 @@ def create_browser_options(proxy=None):
options.set_argument("--disable-background-timer-throttling") options.set_argument("--disable-background-timer-throttling")
options.set_argument("--disable-backgrounding-occluded-windows") options.set_argument("--disable-backgrounding-occluded-windows")
options.set_argument("--disable-renderer-backgrounding") options.set_argument("--disable-renderer-backgrounding")
# 反 bot:关闭 Blink AutomationControlled(否则 navigator.webdriver 在 JS 前就暴露)
options.set_argument("--disable-blink-features=AutomationControlled")
# 固定窗口尺寸,避免 Turnstile iframe 在 0 尺寸窗口里不渲染 # 固定窗口尺寸,避免 Turnstile iframe 在 0 尺寸窗口里不渲染
# 轻微随机化位置即可,尺寸过大/过小都可能影响验证渲染
options.set_argument("--window-size=1280,900") options.set_argument("--window-size=1280,900")
# 隐藏窗口(可选):仅移出屏幕。注意:部分环境下会让 Turnstile 永远 token=0 # 隐藏窗口(可选):仅移出屏幕。注意:部分环境下会让 Turnstile 永远 token=0
if config.get("hide_window", False): if config.get("hide_window", False):
options.set_argument("--window-position=-32000,-32000") options.set_argument("--window-position=-32000,-32000")
else: else:
options.set_argument("--window-position=40,40") options.set_argument(f"--window-position={random.randint(40, 120)},{random.randint(40, 120)}")
# 代理:线程绑定(代理池)> 入参 > config.proxy # 代理:线程绑定(代理池)> 入参 > config.proxy
use_proxy = "" use_proxy = ""
@@ -1126,13 +1130,25 @@ _LAST_NAMES = (
) )
_NAME_SEPARATORS = ("", ".", "_") _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): def generate_username(length=10):
"""生成更像正常英文用户的邮箱名:姓名/词根 + 数字结尾。 """生成更像正常英文用户的邮箱名:姓名/词根 + 数字结尾。
length 仅作兼容参数(旧调用传 10),实际长度由名字与数字后缀决定,约 8–18。 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) last = secrets.choice(_LAST_NAMES)
sep = secrets.choice(_NAME_SEPARATORS) sep = secrets.choice(_NAME_SEPARATORS)
# 数字后缀:2–4 位更像真实用户(生日年份、学号尾号等) # 数字后缀:2–4 位更像真实用户(生日年份、学号尾号等)
@@ -1164,8 +1180,8 @@ def generate_username(length=10):
# first 首字母 + last + digits e.g. jwilson87 # first 首字母 + last + digits e.g. jwilson87
base = f"{first[0]}{last}" base = f"{first[0]}{last}"
else: else:
# last + first 首字母 + digits e.g. wilsonj87 # z + last + digits e.g. zwilson87(原 last+first[0] 风格改造以保证 z 起首)
base = f"{last}{first[0]}" base = f"z{last}"
local = f"{base}{digits}".lower() local = f"{base}{digits}".lower()
# 邮箱 local 只保留 [a-z0-9._],去掉连续分隔符 # 邮箱 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: while time.time() < deadline:
raise_if_cancelled(cancel_callback) raise_if_cancelled(cancel_callback)
filled = page.run_js( filled = page.run_js(
""" r"""
const email = arguments[0]; const email = arguments[0];
function isVisible(node) { function isVisible(node) {
if (!node) return false; if (!node) return false;
@@ -3837,6 +3853,37 @@ def register_one(session, shared, worker_id, slot_no):
return email 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): def register_worker(worker_id, shared):
"""单个并发 worker:自建独立浏览器,循环领取名额并注册。""" """单个并发 worker:自建独立浏览器,循环领取名额并注册。"""
@@ -3879,6 +3926,18 @@ def register_worker(worker_id, shared):
except Exception: except Exception:
pass pass
log(f"[+] 注册成功: {email}(累计成功 {total})") 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 break
except RegistrationCancelled: except RegistrationCancelled:
raise raise
@@ -3929,6 +3988,13 @@ def register_worker(worker_id, shared):
break break
if shared.should_stop(): if shared.should_stop():
break 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() session.restart()
except RegistrationCancelled: except RegistrationCancelled:
@@ -3985,11 +4051,55 @@ def run_registration_concurrent(count, concurrency):
def main(): 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() 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("[*] 已加载配置") cli_log("[*] 已加载配置")
interval = config.get("register_interval_sec", 0)
cli_log( 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: try:
import proxy_pool import proxy_pool
@@ -4006,18 +4116,20 @@ def main():
) )
else: else:
cli_log("[*] CPA 导出: 关闭(仅写 accounts_*.txt)") cli_log("[*] CPA 导出: 关闭(仅写 accounts_*.txt)")
try:
raw = input("请输入并发数量(同时开几个浏览器,直接回车=1): ").strip() concurrency = args.concurrency
except (KeyboardInterrupt, EOFError): if concurrency is None:
cli_log("[!] 已取消") try:
return raw = input("请输入并发数量(同时开几个浏览器,直接回车=1): ").strip()
try: except (KeyboardInterrupt, EOFError):
concurrency = int(raw) if raw else 1 cli_log("[!] 已取消")
except ValueError: return
cli_log("[!] 并发数量无效,使用 1") try:
concurrency = 1 concurrency = int(raw) if raw else 1
if concurrency < 1: except ValueError:
concurrency = 1 cli_log("[!] 并发数量无效,使用 1")
concurrency = 1
concurrency = max(1, min(concurrency, count))
run_registration_concurrent(count, concurrency) run_registration_concurrent(count, concurrency)
+4
View File
@@ -979,3 +979,7 @@ t0rpdza1s0@mail.bblbb.com bblbb:t0rpdza1s0
w5ayintm8r@mail.bblbb.com bblbb:w5ayintm8r w5ayintm8r@mail.bblbb.com bblbb:w5ayintm8r
2in9y6cab4@mail.bblbb.com bblbb:2in9y6cab4 2in9y6cab4@mail.bblbb.com bblbb:2in9y6cab4
5fbu5nq3wj@mail.bblbb.com bblbb:5fbu5nq3wj 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
-2
View File
@@ -16,7 +16,6 @@ from .oauth_code import (
OAuthCodeError, OAuthCodeError,
SCOPE as CODE_SCOPE, SCOPE as CODE_SCOPE,
mint_from_sso, mint_from_sso,
mint_from_sso_browser,
normalize_sso_cookie, normalize_sso_cookie,
sso_to_token, sso_to_token,
) )
@@ -27,7 +26,6 @@ __all__ = [
"mint_with_browser", "mint_with_browser",
"shutdown_mint_browsers", "shutdown_mint_browsers",
"mint_from_sso", "mint_from_sso",
"mint_from_sso_browser",
"sso_to_token", "sso_to_token",
"normalize_sso_cookie", "normalize_sso_cookie",
"CLIENT_ID", "CLIENT_ID",
+221 -426
View File
@@ -3,8 +3,14 @@
对齐最新可用流程:authorize / consent 必须带 referrer=grok-build, 对齐最新可用流程:authorize / consent 必须带 referrer=grok-build,
否则 access_token JWT 缺少 referrer 字段,cli-chat-proxy / 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) -> 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 from __future__ import annotations
@@ -25,18 +31,24 @@ CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
ISSUER = "https://auth.x.ai" ISSUER = "https://auth.x.ai"
TOKEN_URL = f"{ISSUER}/oauth2/token" TOKEN_URL = f"{ISSUER}/oauth2/token"
AUTHORIZE_URL = f"{ISSUER}/oauth2/authorize" AUTHORIZE_URL = f"{ISSUER}/oauth2/authorize"
REDIRECT_URI = "http://127.0.0.1:56121/callback" # 官方 CLI 0.2.101:loopback redirect,运行时随机端口(RFC 8252 端口无关)
# 比旧 device-code scope 多 conversations:*,对齐 grok-build # 兼容保留旧固定端口常量,仅作 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 = ( SCOPE = (
"openid profile email offline_access " "openid profile email offline_access "
"grok-cli:access api:access conversations:read conversations:write" "grok-cli:access api:access conversations:read conversations:write"
) )
GROK_CLI_SCOPE = "grok-cli:access"
GROK_REFERRER = "grok-build" GROK_REFERRER = "grok-build"
GROK_VERSION = "0.2.93" # 对齐官方 stable CLI(2026-07-14 二进制:0.2.101)
GROK_TOKEN_UA = ( GROK_VERSION = "0.2.101"
f"grok-pager/{GROK_VERSION} grok-shell/{GROK_VERSION} (linux; x86_64)" GROK_TOKEN_UA = f"xai-grok-build/{GROK_VERSION}"
) GROK_CLIENT_SURFACE = "grok-build"
# Next.js Server Action id(consent 页 POST 需要) # 旧 Next.js Server Action id(已失效,仅作 fallback 尝试)
NEXT_ACTION_ID = "4005315a1d7e426de592990bb54bb37471f39dd6d2" NEXT_ACTION_ID = "4005315a1d7e426de592990bb54bb37471f39dd6d2"
BROWSER_UA = ( BROWSER_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
@@ -60,6 +72,7 @@ class AuthCodeFlow:
nonce: str nonce: str
code_verifier: str code_verifier: str
code_challenge: str code_challenge: str
redirect_uri: str = REDIRECT_URI_LEGACY
@dataclass @dataclass
@@ -77,6 +90,13 @@ def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") 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: def new_auth_code_flow() -> AuthCodeFlow:
verifier = _b64url(secrets.token_bytes(32)) verifier = _b64url(secrets.token_bytes(32))
state = _b64url(secrets.token_bytes(16)) state = _b64url(secrets.token_bytes(16))
@@ -87,6 +107,7 @@ def new_auth_code_flow() -> AuthCodeFlow:
nonce=nonce, nonce=nonce,
code_verifier=verifier, code_verifier=verifier,
code_challenge=challenge, 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) 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): def _make_std_session(proxy: str | None = None):
try: try:
import requests as std_requests 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]: def _token_headers() -> dict[str, str]:
# 对齐官方 0.2.101:小写 x-grok-* + surface=grok-build
return { return {
"User-Agent": GROK_TOKEN_UA, "User-Agent": GROK_TOKEN_UA,
"Accept": "*/*", "Accept": "*/*",
"X-Grok-Client-Version": GROK_VERSION,
"Content-Type": "application/x-www-form-urlencoded", "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: 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 = { params = {
"response_type": "code", "response_type": "code",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI, "redirect_uri": redirect_uri,
"scope": SCOPE, "scope": SCOPE,
"code_challenge": flow.code_challenge, "code_challenge": flow.code_challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
@@ -282,6 +342,10 @@ def open_authorize_page(session: Any, flow: AuthCodeFlow) -> str:
body = resp.text or "" body = resp.text or ""
final = _final_url(resp) final = _final_url(resp)
if resp.status_code < 200 or resp.status_code >= 300: 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( raise OAuthCodeError(
f"authorize HTTP {resp.status_code}: {_short(body)}" 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)}") 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 = [ payload = [
{ {
"action": "allow", "action": "allow",
"clientId": CLIENT_ID, "clientId": CLIENT_ID,
"redirectUri": REDIRECT_URI, "redirectUri": redirect_uri,
"scope": SCOPE, "scope": SCOPE,
"state": flow.state, "state": flow.state,
"codeChallenge": flow.code_challenge, "codeChallenge": flow.code_challenge,
@@ -354,29 +497,48 @@ def approve_authorization(session: Any, consent_url: str, flow: AuthCodeFlow) ->
consent_url, consent_url,
data=body.encode("utf-8"), data=body.encode("utf-8"),
headers=_browser_headers("POST", consent_url, NEXT_ACTION_ID), headers=_browser_headers("POST", consent_url, NEXT_ACTION_ID),
allow_redirects=True, allow_redirects=False,
timeout=30, timeout=30,
) )
text = resp.text or "" 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: if resp.status_code < 200 or resp.status_code >= 300:
raise OAuthCodeError(f"consent HTTP {resp.status_code}: {_short(text, 300)}") raise OAuthCodeError(
f"consent Next-Action 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
return parse_consent_code(text) 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: def exchange_auth_code(session: Any, code: str, flow: AuthCodeFlow) -> TokenResult:
redirect_uri = flow.redirect_uri or REDIRECT_URI_LEGACY
form = { form = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": code, "code": code,
"redirect_uri": REDIRECT_URI, "redirect_uri": redirect_uri,
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"code_verifier": flow.code_verifier, "code_verifier": flow.code_verifier,
} }
@@ -429,7 +591,10 @@ def _run_sso_flow(
) -> TokenResult: ) -> TokenResult:
flow = new_auth_code_flow() flow = new_auth_code_flow()
backend = getattr(session, "_cpa_http_backend", "unknown") 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) _set_sso_cookies(session, sso)
consent_url = open_authorize_page(session, flow) consent_url = open_authorize_page(session, flow)
log(f"authorize -> consent: {_short(consent_url, 120)}") log(f"authorize -> consent: {_short(consent_url, 120)}")
@@ -443,6 +608,26 @@ def _run_sso_flow(
log(f"WARN {msg}") log(f"WARN {msg}")
else: else:
log("access_token referrer=grok-build ok") 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") log(f"token ok expires_in={token.expires_in} refresh=yes")
return token return token
@@ -455,11 +640,16 @@ def sso_to_token(
require_referrer: bool = True, require_referrer: bool = True,
) -> TokenResult: ) -> TokenResult:
"""SSO cookie → 带 referrer=grok-build 的 OAuth token。""" """SSO cookie → 带 referrer=grok-build 的 OAuth token。"""
from .proxyutil import proxy_log_label, resolve_proxy
log = log or _noop_log log = log or _noop_log
sso = normalize_sso_cookie(sso_cookie) sso = normalize_sso_cookie(sso_cookie)
if not sso: if not sso:
raise OAuthCodeError("sso cookie 为空") 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 # 先 curl_cffi;若遇到 OpenSSL invalid library / curl(35),自动回退 std requests
session = _make_session(proxy, prefer="curl") session = _make_session(proxy, prefer="curl")
try: try:
@@ -472,6 +662,8 @@ def sso_to_token(
backend.startswith("curl_cffi") and "curl: (35)" in str(exc).lower() backend.startswith("curl_cffi") and "curl: (35)" in str(exc).lower()
) )
if not can_fallback: if not can_fallback:
if is_cloudflare_block(exc=exc):
raise OAuthCodeError(_cf_block_hint(proxy_log_label(resolved))) from exc
raise raise
log(f"curl TLS 异常,回退标准 requests: {_short(str(exc), 160)}") log(f"curl TLS 异常,回退标准 requests: {_short(str(exc), 160)}")
try: try:
@@ -483,6 +675,10 @@ def sso_to_token(
return _run_sso_flow( return _run_sso_flow(
sso, session=session, log=log, require_referrer=require_referrer 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: finally:
try: try:
session.close() session.close()
@@ -520,404 +716,3 @@ def mint_from_sso(
"referrer": tr.referrer, "referrer": tr.referrer,
"sso": normalize_sso_cookie(sso_cookie), "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,
}
+5 -1
View File
@@ -20,7 +20,11 @@ CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
ISSUER = "https://auth.x.ai" ISSUER = "https://auth.x.ai"
DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code" DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code"
TOKEN_URL = "https://auth.x.ai/oauth2/token" 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] LogFn = Callable[[str], None]
+292
View File
@@ -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())