cpa_uploader.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. """CPA 上传:校验 session.account.planType=='plus'、构造 codex auth JSON、POST 到 CPA。
  2. 参考 chrome_extension/codex-oauth-automation-extension/background/cpa-api.js。
  3. """
  4. from __future__ import annotations
  5. import base64
  6. import json
  7. import re
  8. import time
  9. import urllib.error
  10. import urllib.request
  11. from datetime import datetime, timezone
  12. from typing import Callable
  13. from urllib.parse import urlparse, quote
  14. def _normalize_str(value) -> str:
  15. return str(value or "").strip()
  16. def _is_email(value: str) -> bool:
  17. return bool(value and re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", value))
  18. def _first_non_empty(*values) -> str:
  19. for v in values:
  20. s = _normalize_str(v)
  21. if s:
  22. return s
  23. return ""
  24. def _b64url_decode(segment: str) -> str:
  25. s = _normalize_str(segment).replace("-", "+").replace("_", "/")
  26. if not s:
  27. return ""
  28. pad = (-len(s)) % 4
  29. s += "=" * pad
  30. try:
  31. return base64.b64decode(s).decode("utf-8", errors="replace")
  32. except Exception:
  33. return ""
  34. def _b64url_encode_json(value) -> str:
  35. raw = json.dumps(value, separators=(",", ":")).encode("utf-8")
  36. enc = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
  37. return enc
  38. def parse_jwt_payload(token: str) -> dict:
  39. token = _normalize_str(token)
  40. if not token:
  41. return {}
  42. parts = token.split(".")
  43. if len(parts) < 2:
  44. return {}
  45. decoded = _b64url_decode(parts[1])
  46. if not decoded:
  47. return {}
  48. try:
  49. return json.loads(decoded)
  50. except Exception:
  51. return {}
  52. def _normalize_iso_timestamp(value) -> str:
  53. if isinstance(value, datetime):
  54. return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
  55. if isinstance(value, (int, float)):
  56. ms = value if value > 1e11 else value * 1000
  57. try:
  58. return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
  59. except Exception:
  60. return ""
  61. if isinstance(value, str) and value.strip():
  62. try:
  63. dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
  64. return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
  65. except Exception:
  66. return ""
  67. return ""
  68. def _ts_from_unix_seconds(value) -> str:
  69. try:
  70. n = float(value)
  71. except Exception:
  72. return ""
  73. try:
  74. return datetime.fromtimestamp(n, tz=timezone.utc).isoformat().replace("+00:00", "Z")
  75. except Exception:
  76. return ""
  77. def _epoch_seconds_from(value) -> int:
  78. if value in (None, ""):
  79. return 0
  80. try:
  81. n = float(value)
  82. return int(n / 1000) if n > 1e11 else int(n)
  83. except Exception:
  84. pass
  85. iso = _normalize_iso_timestamp(value)
  86. if not iso:
  87. return 0
  88. try:
  89. return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp())
  90. except Exception:
  91. return 0
  92. def _build_synthetic_id_token(email, account_id, plan_type, user_id, expires_at) -> str:
  93. if not _normalize_str(account_id):
  94. return ""
  95. now = int(time.time())
  96. expires = _epoch_seconds_from(expires_at) or (now + 90 * 24 * 60 * 60)
  97. auth_info = {"chatgpt_account_id": _normalize_str(account_id)}
  98. if plan_type:
  99. auth_info["chatgpt_plan_type"] = _normalize_str(plan_type)
  100. if user_id:
  101. auth_info["chatgpt_user_id"] = _normalize_str(user_id)
  102. auth_info["user_id"] = _normalize_str(user_id)
  103. payload = {
  104. "iat": now,
  105. "exp": expires,
  106. "https://api.openai.com/auth": auth_info,
  107. }
  108. if email:
  109. payload["email"] = _normalize_str(email)
  110. header = _b64url_encode_json({"alg": "none", "typ": "JWT", "cpa_synthetic": True})
  111. body = _b64url_encode_json(payload)
  112. return f"{header}.{body}.synthetic"
  113. def _sanitize_segment(value: str, fallback: str = "chatgpt-session") -> str:
  114. s = _normalize_str(value)
  115. s = re.sub(r"[\\/:*?\"<>|]+", "-", s)
  116. s = re.sub(r"\s+", "-", s)
  117. s = re.sub(r"-+", "-", s).strip("-")
  118. return s or fallback
  119. def _normalize_plan_for_filename(plan_type: str) -> str:
  120. parts = re.split(r"[^a-zA-Z0-9]+", _normalize_str(plan_type))
  121. return "-".join(p.lower() for p in parts if p)
  122. def build_cpa_filename(email: str, plan_type: str, account_id: str) -> str:
  123. e = _sanitize_segment(email or "")
  124. p = _normalize_plan_for_filename(plan_type or "")
  125. a = _sanitize_segment(account_id or "")
  126. if e and p:
  127. return f"codex-{e}-{p}.json"
  128. if e:
  129. return f"codex-{e}.json"
  130. if a and p:
  131. return f"codex-{a}-{p}.json"
  132. if a:
  133. return f"codex-{a}.json"
  134. return f"codex-{int(time.time()*1000)}.json"
  135. def get_session_plan_type(session: dict) -> str:
  136. """读 session.account.planType(与扩展一致)。"""
  137. if not isinstance(session, dict):
  138. return ""
  139. account = session.get("account") or {}
  140. if not isinstance(account, dict):
  141. return ""
  142. return _first_non_empty(account.get("planType"), account.get("plan_type"))
  143. def is_plus_session(session: dict) -> bool:
  144. plan = get_session_plan_type(session).lower()
  145. return plan == "plus"
  146. def build_cpa_auth_payload(session: dict, *, email_hint: str = "") -> dict:
  147. """从 ChatGPT /api/auth/session JSON 构造 CPA codex auth JSON。
  148. 返回 { authJson, accountId, email, fileName, hasRefreshToken }。
  149. """
  150. if not isinstance(session, dict):
  151. raise RuntimeError("session 不是 JSON 对象")
  152. access_token = _normalize_str(session.get("accessToken"))
  153. if not access_token:
  154. raise RuntimeError("session 中没有 accessToken")
  155. input_id_token = _first_non_empty(session.get("idToken"), session.get("id_token"))
  156. refresh_token = _first_non_empty(session.get("refreshToken"), session.get("refresh_token"))
  157. session_token = _first_non_empty(session.get("sessionToken"), session.get("session_token"))
  158. access_payload = parse_jwt_payload(access_token)
  159. id_payload = parse_jwt_payload(input_id_token)
  160. access_auth = access_payload.get("https://api.openai.com/auth") if isinstance(access_payload, dict) else {}
  161. id_auth = id_payload.get("https://api.openai.com/auth") if isinstance(id_payload, dict) else {}
  162. profile = access_payload.get("https://api.openai.com/profile") if isinstance(access_payload, dict) else {}
  163. if not isinstance(access_auth, dict):
  164. access_auth = {}
  165. if not isinstance(id_auth, dict):
  166. id_auth = {}
  167. if not isinstance(profile, dict):
  168. profile = {}
  169. expires_at = _first_non_empty(
  170. _ts_from_unix_seconds(access_payload.get("exp")) if isinstance(access_payload, dict) else "",
  171. _normalize_iso_timestamp(session.get("expires")),
  172. _normalize_iso_timestamp(session.get("expiresAt")),
  173. _normalize_iso_timestamp(session.get("expired")),
  174. _normalize_iso_timestamp(session.get("expires_at")),
  175. )
  176. user = session.get("user") if isinstance(session.get("user"), dict) else {}
  177. account = session.get("account") if isinstance(session.get("account"), dict) else {}
  178. def _email(v):
  179. s = _normalize_str(v).lower()
  180. return s if _is_email(s) else ""
  181. email = _first_non_empty(
  182. _email(user.get("email")),
  183. _email(session.get("email")),
  184. _email(email_hint),
  185. _email(profile.get("email")) if profile else "",
  186. _email(id_payload.get("email")) if isinstance(id_payload, dict) else "",
  187. _email(access_payload.get("email")) if isinstance(access_payload, dict) else "",
  188. )
  189. account_id = _first_non_empty(
  190. account.get("id") if account else "",
  191. session.get("account_id"),
  192. access_auth.get("chatgpt_account_id"),
  193. id_auth.get("chatgpt_account_id"),
  194. )
  195. user_id = _first_non_empty(
  196. user.get("id") if user else "",
  197. session.get("user_id"),
  198. access_auth.get("chatgpt_user_id"),
  199. access_auth.get("user_id"),
  200. id_auth.get("chatgpt_user_id"),
  201. id_auth.get("user_id"),
  202. )
  203. plan_type = _first_non_empty(
  204. account.get("planType") if account else "",
  205. account.get("plan_type") if account else "",
  206. session.get("planType"),
  207. session.get("plan_type"),
  208. access_auth.get("chatgpt_plan_type"),
  209. id_auth.get("chatgpt_plan_type"),
  210. )
  211. exported_at = _normalize_iso_timestamp(datetime.now(timezone.utc)) or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
  212. synthetic_id_token = "" if input_id_token else _build_synthetic_id_token(email, account_id, plan_type, user_id, expires_at)
  213. id_token = input_id_token or synthetic_id_token
  214. auth_json_full = {
  215. "type": "codex",
  216. "account_id": account_id,
  217. "chatgpt_account_id": account_id,
  218. "email": email,
  219. "name": _first_non_empty(email, "ChatGPT Account"),
  220. "plan_type": plan_type,
  221. "chatgpt_plan_type": plan_type,
  222. "id_token": id_token,
  223. "id_token_synthetic": True if synthetic_id_token else None,
  224. "access_token": access_token,
  225. "refresh_token": refresh_token or "",
  226. "session_token": session_token,
  227. "last_refresh": exported_at,
  228. "expired": expires_at,
  229. "disabled": True if session.get("disabled") is True else None,
  230. }
  231. auth_json = {k: v for k, v in auth_json_full.items() if v not in (None, "")}
  232. return {
  233. "authJson": auth_json,
  234. "accountId": account_id,
  235. "email": email,
  236. "expiresAt": expires_at,
  237. "fileName": build_cpa_filename(email, plan_type, account_id),
  238. "hasRefreshToken": bool(refresh_token),
  239. "planType": plan_type,
  240. }
  241. def _http_post_json(url: str, *, headers: dict, body: dict, timeout: int = 60) -> tuple[int, str, dict]:
  242. """POST JSON。优先 curl_cffi 走 chrome 指纹(绕 Cloudflare 等基于 UA 的拦截),失败回退 urllib。"""
  243. payload_bytes = json.dumps(body).encode("utf-8")
  244. try:
  245. from curl_cffi import requests as curl_requests
  246. except Exception:
  247. curl_requests = None
  248. if curl_requests is not None:
  249. try:
  250. r = curl_requests.post(
  251. url,
  252. data=payload_bytes,
  253. headers=headers,
  254. impersonate="chrome136",
  255. timeout=timeout,
  256. )
  257. text = r.text
  258. status = r.status_code
  259. try:
  260. parsed = r.json() if text else {}
  261. except Exception:
  262. parsed = {}
  263. return status, text, parsed
  264. except Exception:
  265. # 回退 urllib
  266. pass
  267. req = urllib.request.Request(url, data=payload_bytes, method="POST")
  268. for k, v in headers.items():
  269. req.add_header(k, v)
  270. try:
  271. with urllib.request.urlopen(req, timeout=timeout) as resp:
  272. text = resp.read().decode("utf-8", errors="replace")
  273. status = resp.status
  274. except urllib.error.HTTPError as exc:
  275. try:
  276. text = exc.read().decode("utf-8", errors="replace")
  277. except Exception:
  278. text = ""
  279. status = exc.code
  280. parsed = {}
  281. try:
  282. parsed = json.loads(text or "{}")
  283. except Exception:
  284. parsed = {}
  285. return status, text, parsed
  286. def upload_session_to_cpa(
  287. session: dict,
  288. *,
  289. cpa_url: str,
  290. management_key: str,
  291. email_hint: str = "",
  292. timeout: int = 60,
  293. log: Callable[[str], None] = print,
  294. ) -> dict:
  295. """把 ChatGPT session 通过 CPA 管理接口上传。
  296. Returns: { fileName, email, planType, hasRefreshToken, status, response }
  297. """
  298. cpa_url = _normalize_str(cpa_url)
  299. management_key = _normalize_str(management_key)
  300. if not cpa_url:
  301. raise RuntimeError("CPA 地址未配置")
  302. if not management_key:
  303. raise RuntimeError("CPA 管理密钥未配置")
  304. parsed_url = urlparse(cpa_url)
  305. if not parsed_url.scheme or not parsed_url.netloc:
  306. raise RuntimeError(f"CPA 地址格式无效: {cpa_url}")
  307. origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
  308. payload = build_cpa_auth_payload(session, email_hint=email_hint)
  309. log(f"[cpa] 构造 auth JSON 完成 file={payload['fileName']} email={payload['email']!r} plan={payload['planType']!r} hasRefreshToken={payload['hasRefreshToken']}")
  310. if not payload["hasRefreshToken"]:
  311. log("[cpa] 警告:缺少 refresh_token,access_token 过期后无法续期")
  312. name_q = quote(payload["fileName"], safe="")
  313. url = f"{origin}/v0/management/auth-files?name={name_q}"
  314. headers = {
  315. "Accept": "application/json",
  316. "Content-Type": "application/json",
  317. "Authorization": f"Bearer {management_key}",
  318. "X-Management-Key": management_key,
  319. }
  320. log(f"[cpa] POST {url}")
  321. status, text, resp = _http_post_json(url, headers=headers, body=payload["authJson"], timeout=timeout)
  322. log(f"[cpa] HTTP {status} 返回长度 {len(text)} 预览={text[:300]}")
  323. if status >= 400:
  324. msg = ""
  325. if isinstance(resp, dict):
  326. for k in ("error", "message", "detail", "reason"):
  327. if resp.get(k):
  328. msg = str(resp[k])
  329. break
  330. raise RuntimeError(f"CPA 上传失败 HTTP {status}: {msg or text[:300]}")
  331. return {
  332. "fileName": payload["fileName"],
  333. "email": payload["email"],
  334. "planType": payload["planType"],
  335. "hasRefreshToken": payload["hasRefreshToken"],
  336. "status": status,
  337. "response": resp,
  338. }