- AmSuperKing2026-09-2431365
"""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"): parts.append(f"{label} 剩余 {q.get('remaining'):g}/{q.get('total'):g}") print(f"{prefix} : {','.join(parts) if parts else '无可用额度'}" f"{'(已超额)' if quota.get('isQuotaExceeded') else ''}") def do_claim(session: requests.Session, dry_run: bool) -> int: doc = get(session, "/sash/api/v1/me/campaigns") if "_error" in doc: print(f"活动列表请求失败: {doc['_error']} {doc['_body'][:120]}") return 1 campaigns = doc.get("campaigns") or [] if not campaigns: print("当前没有可展示的活动") return 0 for c in campaigns: b = c.get("benefit") or {} print(f"{c.get('campaignId')} {c.get('campaignKey')} {str(c.get('actionType', '')):13} " f"{str(c.get('claimStatus', '')):9} {b.get('kind')}x{b.get('amount')}") targets = [c for c in campaigns if c.get("claimStatus") == "CLAIMABLE" and c.get("actionType") == "CLAIM_BENEFIT"] if not targets: print("\n没有 CLAIMABLE 的活动,无需签到") return 0 if dry_run: print(f"\n[dry-run] 将领取 {len(targets)} 条:{[t.get('campaignKey') for t in targets]}") return 0 print() claimed = False for c in targets: res = claim_one(session, c["campaignId"]) b = res.get("benefit") or {} print(f"签到 {c.get('campaignKey')} -> HTTP {res.get('_http')} status={res.get('status')} " f"replayed={res.get('replayed')} 权益={b.get('kind')}x{b.get('amount')} " f"发放单={res.get('grantId')}") if res.get("_http") == 200 and res.get("status") == "CLAIMED": claimed = True if claimed: print("\n签到已到账,最新额度:") print_quota(session, prefix="剩余额度(签到后)") return 0 def show_usage(session: requests.Session, auth: dict, args) -> int: """usage 模式(默认)= 面板数据汇总 + 自动签到。""" user = auth.get("user") or {} summary = get(session, ENDPOINTS["credits-summary"][0]) streak = get(session, ENDPOINTS["seat-activity"][0]) heat = get(session, ENDPOINTS["credits-heatmap"][0], {"days": args.days or 30, "organization_id": ""}) print(f"账号 : {user.get('name')} ({user.get('id')})") print(f"token 到期: {auth.get('expiresAt')}") print_quota(session) if "_error" not in summary: print(f"Credits : 累计 {summary.get('totalCredits')} / 峰值 {summary.get('peakCredits')}") else: print(f"Credits : 请求失败 {summary['_error']} {summary['_body'][:120]}") if "_error" not in streak: print(f"活跃天数 : 累计 {streak.get('cumulativeActiveDays')} 天 / 当前连续 " f"{streak.get('currentConsecutiveDays')} 天 / 最长 {streak.get('maxConsecutiveDays')} 天" f"(最后活跃 {streak.get('lastActiveDate')})") else: print(f"活跃天数 : 请求失败 {streak['_error']}") if "_error" not in heat: items = heat.get("items") or [] used = [i for i in items if i.get("value")] print(f"热力图 : {items[0].get('date', '?') if items else '?'} ~ " f"{items[-1].get('date', '?') if items else '?'}," f"总和 {heat.get('total')},有用量的天数 {len(used)}") for i in used[-5:]: print(f" {i.get('date')} {i.get('value')}") print() return do_claim(session, args.dry_run) # ---------------------- 凭证加载 ---------------------- def load_auths(args) -> list[dict]: """凭证优先级: 1. --pat <pt-...> 命令行传 PAT(单账号,自动 exchange) 2. --auth-file <json> JSON 文件(支持两种格式,见下) 3. 同目录 auths.json 同上 4. 客户端本地解密 同 CN 脚本链路 auth-file JSON 支持两种条目格式: A. {"pat": "pt-..."} -> 脚本自动 exchange B. {"token": "jt-...", "refreshToken": "jrt-...", "user": {...}} -> 直接用 token,若 401 自动用 refreshToken 刷新 """ # 1) 命令行 PAT if args.pat: cred = exchange_pat(args.pat) # 补齐 user 字段 s = make_session(cred["accessToken"]) info = get(s, "/api/v1/userinfo") if "_error" not in info: cred["user"] = info return [cred] # 2/3) auth-file path = args.auth_file or (AUTH_FILE if os.path.exists(AUTH_FILE) else None) if path: result = [] with open(path, encoding="utf-8") as fh: data = json.load(fh) items = data if isinstance(data, list) else [data] for item in items: if "pat" in item: # PAT 条目:自动 exchange cred = exchange_pat(item["pat"]) cred["user"] = item.get("user") or {} result.append(cred) else: # 直接给 token 的条目 result.append(item) return result # 4) 客户端本地解密(需先登录过国际版客户端) return [load_auth_from_client()] def run_account(auth: dict, args) -> int: """单账号执行;若 token 失效且有 refreshToken,自动刷新重试一次。""" token = auth.get("token") or auth.get("accessToken") refresh = auth.get("refreshToken") for attempt in range(2): session = make_session(token) # 探测 token 是否有效 probe = get(session, "/api/v2/quota/usage") if "_error" not in probe: break # 401 且有 refreshToken 时刷新重试 if attempt == 0 and refresh and probe.get("_error") in (401, 403): print(" token 失效,尝试用 refreshToken 刷新...") try: new = refresh_job_token(refresh) token = new.get("accessToken") or new.get("token") or token refresh = new.get("refreshToken") or refresh continue except Exception as exc: print(f" 刷新失败: {exc}") return 1 return 1 if args.what == "claim": return do_claim(session, args.dry_run) if args.what in ENDPOINTS: path, params = ENDPOINTS[args.what] if args.what == "credits-heatmap" and args.days: params = {**(params or {}), "days": args.days} out = get(session, path, params) print(json.dumps(out, ensure_ascii=False, indent=2)) return 1 if "_error" in out else 0 return show_usage(session, auth, args) def main() -> int: ap = argparse.ArgumentParser( description="读取 Qoder 国际版 用量 / 签到", epilog=( "凭证来源(优先级从高到低):\n" " 1. --pat pt-xxxx 在 qoder.com 创建 PAT 后直接传入\n" " 2. --auth-file x.json 多账号 JSON(支持 pat 或 token+refreshToken 两种条目)\n" " 3. ./auths.json 同目录默认文件\n" " 4. 客户端本地解密 需先在国际版客户端登录\n" "\n" "auth-file 条目示例:\n" ' [{"pat": "pt-aaa"}, {"token": "jt-bbb", "refreshToken": "jrt-ccc"}]\n' ), formatter_class=argparse.RawDescriptionHelpFormatter, ) ap.add_argument("what", nargs="?", default="usage", help="usage | claim | " + " | ".join(ENDPOINTS)) ap.add_argument("--days", type=int, help="credits-heatmap 的天数") ap.add_argument("--dry-run", action="store_true", help="claim 时只列出将要领取的活动") ap.add_argument("--auth-file", help="账号凭证 JSON 路径") ap.add_argument("--pat", help="Personal Access Token(pt-...),脚本会自动 exchange") args = ap.parse_args() rc = 0 for idx, auth in enumerate(load_auths(args), 1): name = (auth.get("user") or {}).get("name") or "?" print(f"===== 账号 {idx}: {name} =====") try: rc |= run_account(auth, args) except Exception as exc: print(f"账号 {name} 处理失败: {exc}", file=sys.stderr) rc = 1 return rc if __name__ == "__main__": sys.exit(main())主楼 - 风咕嘎2026-09-2423888
- ceshi01Adx2026-09-246801
呃?感觉这个活动应该不是常驻活动所以感觉价值存疑(阿里经常玩不起将活动下架),不过还是支持佬分享.
- MyCard2026-09-2431366
还能签到吗?怎么我看说已经结束了?