- SignalDesk1小时前
Original Summary
"""Qoder 国际版 用量读取/签到脚本 凭证链(两种方式,按优先级自动选择): 方式 A(推荐,最稳定): PAT 换 job token 1. 在 https://qoder.com → Settings → Personal Access Tokens 创建 PAT(pt-...) 2. POST /api/v1/jobToken/exchange {"personal_token": "<pt-...>"} -> 返回 {accessToken(jt-...), refreshToken(jrt-...), ...} 3. 后续请求用 Bearer jt-... 4. jt 过期时 POST /api/v1/jobToken/refresh 用 jrt 换新的 jt 方式 B(同 CN 脚本链路): 客户端本地凭证仓 Local State -> os_crypt.encrypted_key(base64 "DPAPI"…) -> CryptUnprotectData -> AES-256 key -> 解 auth.v1.dat("v10" + 12B nonce + ct + 16B tag) (国际版客户端目录名需实测,默认尝试 com.qoder.app.stable) 接口域名: OpenAPI: https://openapi.qoder.sh 聊天网关: https://api3.qoder.sh/algo/api/v2/ (本脚本用不到) 实测注意: - PAT 不能直接放 Authorization,必须先 exchange 成 jt - campaigns 接口同样需要 Cosy-ClientType 头,否则返回空数组 - 若 401/TOKEN_INVALID,优先检查 jt 是否过期,尝试 refresh """ import argparse import base64 import ctypes import ctypes.wintypes as wt import json import os import sys import requests from cryptography.hazmat.primitives.ciphers.aead import AESGCM # ====================== 国际版差异配置(按需修改)====================== API = "https://openapi.qoder.sh" # 国际版 OpenAPI 域名 # 客户端凭证目录:国际版目录名需实测,常见候选如下,脚本会依次尝试 PROFILE_CANDIDATES = [ "com.qoder.app.stable", "com.qoder.app", "com.qoder.stable", "Qoder", ] # ====================================================================== # Windows 控制台默认 GBK,中文输出会花屏 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") CLIENT_TYPE = "10" # 客户端日志里的 clientType VERSION = "0.4.2" # 客户端版本号,可按实际安装版本调整 AUTH_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auths.json") # ---------------------- Windows DPAPI 解密 ---------------------- class _Blob(ctypes.Structure): _fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))] def _dpapi_unprotect(data: bytes) -> bytes: kernel32 = ctypes.windll.kernel32 inp, out = _Blob(), _Blob() inp.cbData = len(data) inp.pbData = ctypes.create_string_buffer(data) if not ctypes.windll.crypt32.CryptUnprotectData( ctypes.byref(inp), None, None, None, None, 0, ctypes.byref(out) ): raise OSError(f"CryptUnprotectData failed, GetLastError={kernel32.GetLastError()}") try: return ctypes.string_at(out.pbData, out.cbData) finally: kernel32.LocalFree(out.pbData) def _find_profile() -> str: """依次尝试候选目录名,找到包含 Local State 的那个。""" appdata = os.environ["APPDATA"] for name in PROFILE_CANDIDATES: path = os.path.join(appdata, name) if os.path.isfile(os.path.join(path, "Local State")): return path raise FileNotFoundError( f"未找到 Qoder 国际版凭证目录,尝试过: {PROFILE_CANDIDATES}\n" f"请打开 %APPDATA% 查看实际目录名后修改 PROFILE_CANDIDATES" ) def load_auth_from_client() -> dict: """解密国际版客户端登录仓,返回 {token, refreshToken, expiresAt, user, ...}。""" profile = _find_profile() with open(os.path.join(profile, "Local State"), encoding="utf-8") as fh: raw = base64.b64decode(json.load(fh)["os_crypt"]["encrypted_key"]) if raw[:5] != b"DPAPI": raise ValueError("unexpected encrypted_key prefix") key = _dpapi_unprotect(raw[5:]) with open(os.path.join(profile, "auth.v1.dat"), "rb") as fh: blob = fh.read() if blob[:3] != b"v10": raise ValueError("auth.v1.dat is not a v10 envelope") return json.loads(AESGCM(key).decrypt(blob[3:15], blob[15:], None)) # ---------------------- PAT 换 job token ---------------------- def exchange_pat(pat: str) -> dict: """用 PAT (pt-...) 换短时 job token (jt-...)。 返回示例(字段名以实测为准): { "accessToken": "jt-...", "refreshToken": "jrt-...", "expiresIn": 3600, ... } """ r = requests.post( f"{API}/api/v1/jobToken/exchange", json={"personal_token": pat}, headers={"Accept": "application/json", "User-Agent": "Qoder"}, timeout=20, ) if r.status_code != 200: raise RuntimeError(f"jobToken/exchange 失败 HTTP {r.status_code}: {r.text[:300]}") return r.json() def refresh_job_token(refresh_token: str) -> dict: """用 jrt-... 刷新 jt-...,返回同 exchange 结构。""" r = requests.post( f"{API}/api/v1/jobToken/refresh", json={"refreshToken": refresh_token}, headers={"Accept": "application/json", "User-Agent": "Qoder"}, timeout=20, ) if r.status_code != 200: raise RuntimeError(f"jobToken/refresh 失败 HTTP {r.status_code}: {r.text[:300]}") return r.json() # ---------------------- Session 构造 ---------------------- def make_session(token: str) -> requests.Session: """公共头挂到 session 上;国际版 Authorization 同样必须是 Bearer <jt-...>。""" s = requests.Session() s.headers.update({ "Accept": "application/json", "User-Agent": "Qoder", "Authorization": f"Bearer {token}", "Cosy-ClientType": CLIENT_TYPE, "Cosy-Version": VERSION, }) return s def get(session: requests.Session, path: str, params: dict | None = None): r = session.get(API + path, params=params, timeout=20) if r.status_code != 200: return {"_error": r.status_code, "_body": r.text[:400]} return r.json() # ---------------------- 接口清单 ---------------------- # 路径与 CN 版一致(同属 sash 服务),若 404 请用 --dump-asar 自查 ENDPOINTS = { "credits-summary": ("/sash/api/v1/ai-conversations/credits-summary", None), "seat-activity": ("/sash/api/v1/ai-conversations/seat-activity", None), "credits-heatmap": ("/sash/api/v1/ai-conversations/credits-heatmap", {"days": 30, "organization_id": ""}), "campaigns": ("/sash/api/v1/me/campaigns", None), "plan": ("/api/v2/user/plan", None), "quota": ("/api/v2/quota/usage", None), } # ---------------------- 签到逻辑 ---------------------- def claim_one(session: requests.Session, campaign_id: str) -> dict: """POST /sash/api/v1/me/campaigns/{campaignId}/claim(服务端幂等)""" r = session.post(f"{API}/sash/api/v1/me/campaigns/{campaign_id}/claim", timeout=20) try: body = r.json() except ValueError: body = {"_raw": r.text[:300]} return {"_http": r.status_code, **body} if isinstance(body, dict) else {"_http": r.status_code, "_raw": body} def print_quota(session: requests.Session, prefix: str = "剩余额度") -> None: """拉取 quota/usage 并打印可用额度。""" quota = get(session, ENDPOINTS["quota"][0]) if "_error" in quota: print(f"{prefix} : 请求失败 {quota['_error']}") return parts = [] for label, key in (("套餐", "userQuota"), ("签到/加油包", "addOnQuota")): q = quota.get(key) or {} if q.get("total"):
- 情报分类:工作与职业机会
- 分类依据:内容涉及招聘、求职或职业发展
- 信息来源:服务器 / LINUX DO - 最新话题
- 发布时间:2026/9/24 15:42:01
- 暂无回复