#!/usr/bin/env python # -*- coding: utf-8 -*- """批量 SSO → CPA OAuth(referrer=grok-build)。 用法: python sso_to_cpa.py --sso accounts.txt --out-dir ./cpa_auths python sso_to_cpa.py --sso-cookie "eyJ..." --email a@b.com python sso_to_cpa.py --sso accounts.txt --concurrency 4 --proxy http://127.0.0.1:7897 输入行格式: 邮箱----密码----sso_jwt 邮箱----sso_jwt """ from __future__ import annotations import argparse import json import os import sys import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path _ROOT = Path(__file__).resolve().parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) def _load_config() -> dict: cfg_path = _ROOT / "config.json" if not cfg_path.is_file(): return {} try: with open(cfg_path, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def _parse_line(line: str) -> tuple[str, str]: line = (line or "").strip() if not line or line.startswith("#"): return "", "" email = "" sso = line if "----" in line: parts = [p.strip() for p in line.split("----")] if parts: email = parts[0] sso = parts[-1] return email, sso def load_records(sso_file: str, sso_cookie: str, email: str) -> list[dict]: from oidc_mint.oauth_code import normalize_sso_cookie out: list[dict] = [] seen: set[str] = set() if sso_cookie.strip(): sso = normalize_sso_cookie(sso_cookie) if sso and sso not in seen: seen.add(sso) out.append({"email": (email or "").strip(), "sso": sso}) return out if not sso_file: return out path = Path(sso_file) data = path.read_text(encoding="utf-8", errors="replace") for line in data.splitlines(): em, sso_raw = _parse_line(line) sso = normalize_sso_cookie(sso_raw) if not sso or sso in seen: continue seen.add(sso) out.append({"email": em, "sso": sso}) return out def convert_one( rec: dict, *, out_dir: Path, proxy: str | None, push: bool, cfg: dict, lock: threading.Lock, index: int, total: int, ) -> dict: from oidc_mint.oauth_code import OAuthCodeError, mint_from_sso import cpa email = (rec.get("email") or "").strip() sso = rec["sso"] tag = email or sso[:16] def log(msg: str) -> None: print(f"[{index}/{total}] {msg}", flush=True) # 预检查已存在 if email: pre = out_dir / cpa.credential_file_name(email) if pre.is_file(): log(f"skip exists {pre.name}") return {"ok": True, "skipped": True, "email": email, "path": str(pre)} log(f"converting {tag}") try: tokens = mint_from_sso( sso, proxy=proxy, log=lambda m: log(f" {m}"), require_referrer=True, ) except OAuthCodeError as exc: log(f"FAIL {tag}: {exc}") return {"ok": False, "email": email, "error": str(exc)} except Exception as exc: # noqa: BLE001 log(f"FAIL {tag}: {exc}") return {"ok": False, "email": email, "error": str(exc)} # email 可从 id_token 补 if not email and tokens.get("id_token"): try: from cpa.schema import _jwt_payload email = str(_jwt_payload(tokens["id_token"]).get("email") or "").strip() except Exception: pass payload = cpa.build_cpa_xai_auth( email=email, access_token=tokens["access_token"], refresh_token=tokens["refresh_token"], id_token=tokens.get("id_token"), expires_in=tokens.get("expires_in"), base_url=cfg.get("cpa_base_url") or cpa.CLI_BASE_URL, sso=sso, ) with lock: path = cpa.write_cpa_xai_auth(out_dir, payload) log(f"OK {path.name} referrer={payload.get('referrer')!r}") result = { "ok": True, "email": email or payload.get("email"), "path": str(path), "referrer": payload.get("referrer"), "pushed": False, } if push: remote_base = str(cfg.get("cpa_remote_base") or "").strip() secret = str(cfg.get("cpa_remote_secret") or "").strip() if remote_base and secret: # 仅首条在 convert 前统一 flush;这里 flush_first=False 避免并发重复重试 push_res = cpa.push_with_queue( out_dir, Path(path).name, payload, remote_base=remote_base, secret=secret, proxy=str(cfg.get("cpa_push_proxy") or "").strip() or None, verify_tls=bool(cfg.get("cpa_remote_verify_tls", True)), flush_first=False, log=lambda m: log(m.replace("[Debug] ", "").replace("[!] ", "")), ) result["pushed"] = bool(push_res.get("pushed")) if "push_status" in push_res: result["push_status"] = push_res["push_status"] if push_res.get("push_error"): result["push_error"] = push_res["push_error"] else: cpa.record_push_failure(out_dir, Path(path).name, "remote not configured") log("push skipped: remote not configured (queued)") return result def main(argv: list[str] | None = None) -> int: cfg = _load_config() default_proxy = ( cfg.get("mint_proxy") or cfg.get("proxy") or os.environ.get("https_proxy") or os.environ.get("http_proxy") or "http://127.0.0.1:7897" ) default_out = cfg.get("cpa_auth_dir") or "./cpa_auths" p = argparse.ArgumentParser(description="SSO cookie → CPA xai auth (referrer=grok-build)") p.add_argument("--sso", default="", help="sso 列表文件") p.add_argument("--sso-cookie", default="", help="单个 sso cookie") p.add_argument("--email", default="", help="单 cookie 时的 email") p.add_argument("--out-dir", default=default_out, help="输出目录") p.add_argument("--proxy", default=default_proxy, help="HTTP 代理") p.add_argument("--concurrency", type=int, default=1, help="并发数") p.add_argument("--delay", type=float, default=0, help="串行时每个间隔秒") p.add_argument("--push", action="store_true", help="推送到 config 里的远端 CPA") args = p.parse_args(argv) records = load_records(args.sso, args.sso_cookie, args.email) if not records: print("需要 --sso 或 --sso-cookie", file=sys.stderr) return 1 out_dir = Path(args.out_dir) if not out_dir.is_absolute(): out_dir = (_ROOT / out_dir).resolve() out_dir.mkdir(parents=True, exist_ok=True) proxy = (args.proxy or "").strip() or None lock = threading.Lock() total = len(records) ok = fail = skipped = 0 do_push = args.push or bool(cfg.get("cpa_push_enabled")) print( f"开始转换 count={total} concurrency={args.concurrency} out={out_dir} proxy={proxy}", flush=True, ) # 开启推送时先重试历史失败队列,再处理本批 if do_push: import cpa remote_base = str(cfg.get("cpa_remote_base") or "").strip() secret = str(cfg.get("cpa_remote_secret") or "").strip() if remote_base and secret: flush = cpa.flush_push_pending( out_dir, remote_base=remote_base, secret=secret, proxy=str(cfg.get("cpa_push_proxy") or "").strip() or None, verify_tls=bool(cfg.get("cpa_remote_verify_tls", True)), log=lambda m: print(m, flush=True), ) if flush.get("total"): print( f"队列重推 ok={flush.get('ok')} fail={flush.get('fail')} " f"missing={flush.get('missing')} remaining={flush.get('remaining')}", flush=True, ) if args.concurrency <= 1: for i, rec in enumerate(records, 1): r = convert_one( rec, out_dir=out_dir, proxy=proxy, push=do_push, cfg=cfg, lock=lock, index=i, total=total, ) if r.get("skipped"): skipped += 1 elif r.get("ok"): ok += 1 else: fail += 1 if args.delay > 0 and i < total: time.sleep(args.delay) else: with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as ex: futs = [ ex.submit( convert_one, rec, out_dir=out_dir, proxy=proxy, push=do_push, cfg=cfg, lock=lock, index=i, total=total, ) for i, rec in enumerate(records, 1) ] for fut in as_completed(futs): r = fut.result() if r.get("skipped"): skipped += 1 elif r.get("ok"): ok += 1 else: fail += 1 print(f"完成 success={ok} failed={fail} skipped={skipped} total={total}", flush=True) return 1 if fail else 0 if __name__ == "__main__": raise SystemExit(main())