| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- """对历史失败账号做补救:用 access_token 调 backend-api 检查 plan,若已 plus 则上传 CPA。
- 注意:/api/auth/session 走 NextAuth 只认 cookie,不能用 Bearer 直连。
- 真正的鉴权接口是 /backend-api/me(也叫 accounts/check),它接受 Authorization: Bearer。
- """
- from __future__ import annotations
- import copy
- import json
- import time
- import urllib.error
- import urllib.request
- from typing import Callable
- from cpa_uploader import (
- get_session_plan_type,
- is_plus_session,
- upload_session_to_cpa,
- )
- from storage import add_event, get_account, upsert_account
- ME_URL = "https://chatgpt.com/backend-api/me"
- ACCOUNTS_CHECK_URL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
- def _http_get_json(url: str, *, headers: dict, timeout: int = 20, log: Callable[[str], None] = print) -> tuple[int, str, dict]:
- started = time.time()
- text = ""
- status = 0
- try:
- from curl_cffi import requests as curl_requests # type: ignore
- r = curl_requests.get(url, headers=headers, impersonate="chrome136", timeout=timeout)
- text = r.text
- status = r.status_code
- except Exception as exc:
- log(f"[recheck] curl_cffi 不可用: {exc!r},回退 urllib")
- req = urllib.request.Request(url, method="GET")
- 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 exc2:
- text = exc2.read().decode("utf-8", errors="replace") if hasattr(exc2, "read") else ""
- status = exc2.code
- elapsed_ms = int((time.time() - started) * 1000)
- log(f"[recheck] GET {url} HTTP {status} 耗时 {elapsed_ms}ms 长度={len(text)}")
- parsed: dict = {}
- try:
- parsed = json.loads(text or "{}")
- except Exception:
- parsed = {}
- return status, text, parsed
- def _extract_plan_from_me(payload: dict) -> tuple[str, str, str]:
- """从 /backend-api/me 或 /accounts/check 响应里抽 (plan_type, account_id, email)。"""
- if not isinstance(payload, dict):
- return "", "", ""
- # /me 里直接给 email 和 chat_id;plan 通常在 accounts.<id>.account.plan_type
- email = ""
- if isinstance(payload.get("email"), str):
- email = payload["email"]
- accounts = payload.get("accounts")
- if isinstance(accounts, dict):
- # 优先找 plan_type='plus'
- best_id = ""
- best_plan = ""
- for acc_id, item in accounts.items():
- if not isinstance(item, dict):
- continue
- account_obj = item.get("account") if isinstance(item.get("account"), dict) else item
- plan = (account_obj or {}).get("plan_type") or (account_obj or {}).get("planType") or ""
- plan = str(plan or "").strip()
- if not plan:
- continue
- if plan.lower() == "plus" and not best_plan:
- best_plan = plan
- best_id = acc_id
- break
- if not best_plan:
- best_plan = plan
- best_id = acc_id
- if best_plan:
- return best_plan, best_id, email
- # 兜底:顶层 plan_type
- plan = str(payload.get("plan_type") or payload.get("planType") or "").strip()
- return plan, "", email
- def _merge_plus_into_session(base_session: dict, plan: str, account_id: str, email: str) -> dict:
- """把 backend-api 拉到的 plan/account_id 合并进 DB 里的 session,构造给 CPA 用的 plus session。"""
- sess = copy.deepcopy(base_session) if isinstance(base_session, dict) else {}
- if "account" not in sess or not isinstance(sess.get("account"), dict):
- sess["account"] = {}
- if plan:
- sess["account"]["planType"] = plan
- sess["planType"] = plan
- if account_id and not sess["account"].get("id"):
- sess["account"]["id"] = account_id
- if email:
- if "user" not in sess or not isinstance(sess.get("user"), dict):
- sess["user"] = {}
- if not sess["user"].get("email"):
- sess["user"]["email"] = email
- return sess
- def recheck_account(
- email: str,
- *,
- cpa_url: str,
- cpa_management_key: str,
- log: Callable[[str], None] = print,
- ) -> dict:
- """对单个账号做补救。返回 { ok, planType, action, cpa, error }。"""
- acc = get_account(email)
- if not acc:
- return {"ok": False, "error": "账号不存在"}
- plus_sess = acc.get("plus_session") or {}
- init_sess = acc.get("initial_session") or {}
- access_token = (plus_sess.get("accessToken") if isinstance(plus_sess, dict) else None) \
- or (init_sess.get("accessToken") if isinstance(init_sess, dict) else None)
- if not access_token:
- return {"ok": False, "error": "数据库里找不到 accessToken"}
- log(f"[recheck] {email} 开始补救(访问 backend-api/me)")
- add_event(email, "recheck", "info", "begin")
- headers = {
- "Authorization": f"Bearer {access_token}",
- "Accept": "application/json",
- "Origin": "https://chatgpt.com",
- "Referer": "https://chatgpt.com/",
- }
- plan = ""
- account_id = ""
- me_email = ""
- last_payload: dict = {}
- # 先试 /backend-api/me(轻量),失败回退 /accounts/check
- for url in (ME_URL, ACCOUNTS_CHECK_URL):
- try:
- status, text, payload = _http_get_json(url, headers=headers, log=log)
- except Exception as exc:
- log(f"[recheck] {url} 异常: {exc!r}")
- continue
- if status >= 400:
- log(f"[recheck] {url} 返回 {status},预览={text[:200]}")
- continue
- last_payload = payload
- plan, account_id, me_email = _extract_plan_from_me(payload)
- log(f"[recheck] {url} 提取 plan={plan!r} account_id={account_id!r} email={me_email!r}")
- if plan:
- break
- if not plan:
- msg = "重拉 session 失败:所有 backend-api 接口都未返回 plan_type"
- log(f"[recheck] {email} {msg}")
- upsert_account(email, acc.get("password") or "", fields={"last_error": msg})
- add_event(email, "recheck", "error", msg)
- return {"ok": False, "error": msg}
- # 用 base session(plus_session 优先,否则 init_session)合并新 plan
- base_for_merge = plus_sess if isinstance(plus_sess, dict) and plus_sess else init_sess
- merged = _merge_plus_into_session(base_for_merge or {}, plan, account_id, me_email or email)
- # accessToken 用我们手头那个(merged 不一定包含)
- if not merged.get("accessToken"):
- merged["accessToken"] = access_token
- upsert_account(email, acc.get("password") or "", fields={
- "plan_type": plan,
- "plus_session": merged,
- })
- if not is_plus_session(merged):
- upsert_account(email, acc.get("password") or "", fields={
- "final_status": "plus_check_failed",
- "last_error": f"补救后 planType 仍为 {plan!r}",
- })
- add_event(email, "recheck", "warn", f"plan={plan}")
- return {"ok": False, "planType": plan, "action": "still_not_plus"}
- upsert_account(email, acc.get("password") or "", fields={
- "final_status": "plus", "last_error": ""
- })
- add_event(email, "plus_check", "ok", f"plan={plan} (recheck)")
- if not (cpa_url and cpa_management_key):
- upsert_account(email, acc.get("password") or "", fields={
- "final_status": "cpa_skipped"
- })
- add_event(email, "cpa", "warn", "未配置 CPA")
- return {"ok": True, "planType": plan, "action": "plus_no_cpa_config"}
- try:
- cpa_result = upload_session_to_cpa(
- merged,
- cpa_url=cpa_url,
- management_key=cpa_management_key,
- email_hint=email,
- log=log,
- )
- except Exception as exc:
- msg = f"CPA 上传失败: {exc}"
- log(f"[recheck] {email} {msg}")
- upsert_account(email, acc.get("password") or "", fields={
- "final_status": "cpa_failed",
- "last_error": msg,
- })
- add_event(email, "cpa", "error", msg)
- return {"ok": False, "planType": plan, "action": "cpa_failed", "error": msg}
- upsert_account(email, acc.get("password") or "", fields={
- "final_status": "cpa_uploaded",
- "cpa_file_name": cpa_result.get("fileName"),
- "cpa_uploaded_at": int(time.time() * 1000),
- "last_error": "",
- })
- add_event(email, "cpa", "ok", cpa_result.get("fileName"), payload=cpa_result)
- return {
- "ok": True,
- "planType": plan,
- "action": "cpa_uploaded",
- "cpa": cpa_result,
- }
|