| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- """CPA 上传:校验 session.account.planType=='plus'、构造 codex auth JSON、POST 到 CPA。
- 参考 chrome_extension/codex-oauth-automation-extension/background/cpa-api.js。
- """
- from __future__ import annotations
- import base64
- import json
- import re
- import time
- import urllib.error
- import urllib.request
- from datetime import datetime, timezone
- from typing import Callable
- from urllib.parse import urlparse, quote
- def _normalize_str(value) -> str:
- return str(value or "").strip()
- def _is_email(value: str) -> bool:
- return bool(value and re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", value))
- def _first_non_empty(*values) -> str:
- for v in values:
- s = _normalize_str(v)
- if s:
- return s
- return ""
- def _b64url_decode(segment: str) -> str:
- s = _normalize_str(segment).replace("-", "+").replace("_", "/")
- if not s:
- return ""
- pad = (-len(s)) % 4
- s += "=" * pad
- try:
- return base64.b64decode(s).decode("utf-8", errors="replace")
- except Exception:
- return ""
- def _b64url_encode_json(value) -> str:
- raw = json.dumps(value, separators=(",", ":")).encode("utf-8")
- enc = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
- return enc
- def parse_jwt_payload(token: str) -> dict:
- token = _normalize_str(token)
- if not token:
- return {}
- parts = token.split(".")
- if len(parts) < 2:
- return {}
- decoded = _b64url_decode(parts[1])
- if not decoded:
- return {}
- try:
- return json.loads(decoded)
- except Exception:
- return {}
- def _normalize_iso_timestamp(value) -> str:
- if isinstance(value, datetime):
- return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
- if isinstance(value, (int, float)):
- ms = value if value > 1e11 else value * 1000
- try:
- return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
- except Exception:
- return ""
- if isinstance(value, str) and value.strip():
- try:
- dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
- return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
- except Exception:
- return ""
- return ""
- def _ts_from_unix_seconds(value) -> str:
- try:
- n = float(value)
- except Exception:
- return ""
- try:
- return datetime.fromtimestamp(n, tz=timezone.utc).isoformat().replace("+00:00", "Z")
- except Exception:
- return ""
- def _epoch_seconds_from(value) -> int:
- if value in (None, ""):
- return 0
- try:
- n = float(value)
- return int(n / 1000) if n > 1e11 else int(n)
- except Exception:
- pass
- iso = _normalize_iso_timestamp(value)
- if not iso:
- return 0
- try:
- return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp())
- except Exception:
- return 0
- def _build_synthetic_id_token(email, account_id, plan_type, user_id, expires_at) -> str:
- if not _normalize_str(account_id):
- return ""
- now = int(time.time())
- expires = _epoch_seconds_from(expires_at) or (now + 90 * 24 * 60 * 60)
- auth_info = {"chatgpt_account_id": _normalize_str(account_id)}
- if plan_type:
- auth_info["chatgpt_plan_type"] = _normalize_str(plan_type)
- if user_id:
- auth_info["chatgpt_user_id"] = _normalize_str(user_id)
- auth_info["user_id"] = _normalize_str(user_id)
- payload = {
- "iat": now,
- "exp": expires,
- "https://api.openai.com/auth": auth_info,
- }
- if email:
- payload["email"] = _normalize_str(email)
- header = _b64url_encode_json({"alg": "none", "typ": "JWT", "cpa_synthetic": True})
- body = _b64url_encode_json(payload)
- return f"{header}.{body}.synthetic"
- def _sanitize_segment(value: str, fallback: str = "chatgpt-session") -> str:
- s = _normalize_str(value)
- s = re.sub(r"[\\/:*?\"<>|]+", "-", s)
- s = re.sub(r"\s+", "-", s)
- s = re.sub(r"-+", "-", s).strip("-")
- return s or fallback
- def _normalize_plan_for_filename(plan_type: str) -> str:
- parts = re.split(r"[^a-zA-Z0-9]+", _normalize_str(plan_type))
- return "-".join(p.lower() for p in parts if p)
- def build_cpa_filename(email: str, plan_type: str, account_id: str) -> str:
- e = _sanitize_segment(email or "")
- p = _normalize_plan_for_filename(plan_type or "")
- a = _sanitize_segment(account_id or "")
- if e and p:
- return f"codex-{e}-{p}.json"
- if e:
- return f"codex-{e}.json"
- if a and p:
- return f"codex-{a}-{p}.json"
- if a:
- return f"codex-{a}.json"
- return f"codex-{int(time.time()*1000)}.json"
- def get_session_plan_type(session: dict) -> str:
- """读 session.account.planType(与扩展一致)。"""
- if not isinstance(session, dict):
- return ""
- account = session.get("account") or {}
- if not isinstance(account, dict):
- return ""
- return _first_non_empty(account.get("planType"), account.get("plan_type"))
- def is_plus_session(session: dict) -> bool:
- plan = get_session_plan_type(session).lower()
- return plan == "plus"
- def build_cpa_auth_payload(session: dict, *, email_hint: str = "") -> dict:
- """从 ChatGPT /api/auth/session JSON 构造 CPA codex auth JSON。
- 返回 { authJson, accountId, email, fileName, hasRefreshToken }。
- """
- if not isinstance(session, dict):
- raise RuntimeError("session 不是 JSON 对象")
- access_token = _normalize_str(session.get("accessToken"))
- if not access_token:
- raise RuntimeError("session 中没有 accessToken")
- input_id_token = _first_non_empty(session.get("idToken"), session.get("id_token"))
- refresh_token = _first_non_empty(session.get("refreshToken"), session.get("refresh_token"))
- session_token = _first_non_empty(session.get("sessionToken"), session.get("session_token"))
- access_payload = parse_jwt_payload(access_token)
- id_payload = parse_jwt_payload(input_id_token)
- access_auth = access_payload.get("https://api.openai.com/auth") if isinstance(access_payload, dict) else {}
- id_auth = id_payload.get("https://api.openai.com/auth") if isinstance(id_payload, dict) else {}
- profile = access_payload.get("https://api.openai.com/profile") if isinstance(access_payload, dict) else {}
- if not isinstance(access_auth, dict):
- access_auth = {}
- if not isinstance(id_auth, dict):
- id_auth = {}
- if not isinstance(profile, dict):
- profile = {}
- expires_at = _first_non_empty(
- _ts_from_unix_seconds(access_payload.get("exp")) if isinstance(access_payload, dict) else "",
- _normalize_iso_timestamp(session.get("expires")),
- _normalize_iso_timestamp(session.get("expiresAt")),
- _normalize_iso_timestamp(session.get("expired")),
- _normalize_iso_timestamp(session.get("expires_at")),
- )
- user = session.get("user") if isinstance(session.get("user"), dict) else {}
- account = session.get("account") if isinstance(session.get("account"), dict) else {}
- def _email(v):
- s = _normalize_str(v).lower()
- return s if _is_email(s) else ""
- email = _first_non_empty(
- _email(user.get("email")),
- _email(session.get("email")),
- _email(email_hint),
- _email(profile.get("email")) if profile else "",
- _email(id_payload.get("email")) if isinstance(id_payload, dict) else "",
- _email(access_payload.get("email")) if isinstance(access_payload, dict) else "",
- )
- account_id = _first_non_empty(
- account.get("id") if account else "",
- session.get("account_id"),
- access_auth.get("chatgpt_account_id"),
- id_auth.get("chatgpt_account_id"),
- )
- user_id = _first_non_empty(
- user.get("id") if user else "",
- session.get("user_id"),
- access_auth.get("chatgpt_user_id"),
- access_auth.get("user_id"),
- id_auth.get("chatgpt_user_id"),
- id_auth.get("user_id"),
- )
- plan_type = _first_non_empty(
- account.get("planType") if account else "",
- account.get("plan_type") if account else "",
- session.get("planType"),
- session.get("plan_type"),
- access_auth.get("chatgpt_plan_type"),
- id_auth.get("chatgpt_plan_type"),
- )
- exported_at = _normalize_iso_timestamp(datetime.now(timezone.utc)) or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
- synthetic_id_token = "" if input_id_token else _build_synthetic_id_token(email, account_id, plan_type, user_id, expires_at)
- id_token = input_id_token or synthetic_id_token
- auth_json_full = {
- "type": "codex",
- "account_id": account_id,
- "chatgpt_account_id": account_id,
- "email": email,
- "name": _first_non_empty(email, "ChatGPT Account"),
- "plan_type": plan_type,
- "chatgpt_plan_type": plan_type,
- "id_token": id_token,
- "id_token_synthetic": True if synthetic_id_token else None,
- "access_token": access_token,
- "refresh_token": refresh_token or "",
- "session_token": session_token,
- "last_refresh": exported_at,
- "expired": expires_at,
- "disabled": True if session.get("disabled") is True else None,
- }
- auth_json = {k: v for k, v in auth_json_full.items() if v not in (None, "")}
- return {
- "authJson": auth_json,
- "accountId": account_id,
- "email": email,
- "expiresAt": expires_at,
- "fileName": build_cpa_filename(email, plan_type, account_id),
- "hasRefreshToken": bool(refresh_token),
- "planType": plan_type,
- }
- def _http_post_json(url: str, *, headers: dict, body: dict, timeout: int = 60) -> tuple[int, str, dict]:
- """POST JSON。优先 curl_cffi 走 chrome 指纹(绕 Cloudflare 等基于 UA 的拦截),失败回退 urllib。"""
- payload_bytes = json.dumps(body).encode("utf-8")
- try:
- from curl_cffi import requests as curl_requests
- except Exception:
- curl_requests = None
- if curl_requests is not None:
- try:
- r = curl_requests.post(
- url,
- data=payload_bytes,
- headers=headers,
- impersonate="chrome136",
- timeout=timeout,
- )
- text = r.text
- status = r.status_code
- try:
- parsed = r.json() if text else {}
- except Exception:
- parsed = {}
- return status, text, parsed
- except Exception:
- # 回退 urllib
- pass
- req = urllib.request.Request(url, data=payload_bytes, method="POST")
- for k, v in headers.items():
- req.add_header(k, v)
- try:
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- text = resp.read().decode("utf-8", errors="replace")
- status = resp.status
- except urllib.error.HTTPError as exc:
- try:
- text = exc.read().decode("utf-8", errors="replace")
- except Exception:
- text = ""
- status = exc.code
- parsed = {}
- try:
- parsed = json.loads(text or "{}")
- except Exception:
- parsed = {}
- return status, text, parsed
- def upload_session_to_cpa(
- session: dict,
- *,
- cpa_url: str,
- management_key: str,
- email_hint: str = "",
- timeout: int = 60,
- log: Callable[[str], None] = print,
- ) -> dict:
- """把 ChatGPT session 通过 CPA 管理接口上传。
- Returns: { fileName, email, planType, hasRefreshToken, status, response }
- """
- cpa_url = _normalize_str(cpa_url)
- management_key = _normalize_str(management_key)
- if not cpa_url:
- raise RuntimeError("CPA 地址未配置")
- if not management_key:
- raise RuntimeError("CPA 管理密钥未配置")
- parsed_url = urlparse(cpa_url)
- if not parsed_url.scheme or not parsed_url.netloc:
- raise RuntimeError(f"CPA 地址格式无效: {cpa_url}")
- origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
- payload = build_cpa_auth_payload(session, email_hint=email_hint)
- log(f"[cpa] 构造 auth JSON 完成 file={payload['fileName']} email={payload['email']!r} plan={payload['planType']!r} hasRefreshToken={payload['hasRefreshToken']}")
- if not payload["hasRefreshToken"]:
- log("[cpa] 警告:缺少 refresh_token,access_token 过期后无法续期")
- name_q = quote(payload["fileName"], safe="")
- url = f"{origin}/v0/management/auth-files?name={name_q}"
- headers = {
- "Accept": "application/json",
- "Content-Type": "application/json",
- "Authorization": f"Bearer {management_key}",
- "X-Management-Key": management_key,
- }
- log(f"[cpa] POST {url}")
- status, text, resp = _http_post_json(url, headers=headers, body=payload["authJson"], timeout=timeout)
- log(f"[cpa] HTTP {status} 返回长度 {len(text)} 预览={text[:300]}")
- if status >= 400:
- msg = ""
- if isinstance(resp, dict):
- for k in ("error", "message", "detail", "reason"):
- if resp.get(k):
- msg = str(resp[k])
- break
- raise RuntimeError(f"CPA 上传失败 HTTP {status}: {msg or text[:300]}")
- return {
- "fileName": payload["fileName"],
- "email": payload["email"],
- "planType": payload["planType"],
- "hasRefreshToken": payload["hasRefreshToken"],
- "status": status,
- "response": resp,
- }
|