#!/usr/bin/env python3 """Qoder daily check-in client (CN edition). Endpoints (reverse-engineered from the Qoder desktop client, see community projects wallechfox/qoder-checkin and L0NE-6/Qoder-Daily): CN base https://openapi.qoder.com.cn Global base https://openapi.qoder.sh campaigns GET /sash/api/v1/me/campaigns claim POST /sash/api/v1/me/campaigns/{cid}/claim refresh POST /api/v1/deviceToken/refresh body {"refresh_token": ...} Auth: Authorization: Bearer plus Cosy-* device headers (machineId / machineToken / machineCode / machineType / clientType ...). Campaign shape: campaigns[] entries with actionType == "CLAIM_BENEFIT" and claimStatus in ("CLAIMABLE", "CLAIMED"); benefit.amount is the credit grant. Result codes used across the app: ok / already / fail / no_session / retry_later (activity not released yet — Qoder activities are not published exactly at midnight, so multi-slot retries matter). """ import base64 import json import time from common import http_post_json, http_get, log, now_ts CN_BASE = "https://openapi.qoder.com.cn" GLOBAL_BASE = "https://openapi.qoder.sh" PATH_CAMPAIGNS = "/sash/api/v1/me/campaigns" PATH_CLAIM = "/sash/api/v1/me/campaigns/%s/claim" PATH_REFRESH = "/api/v1/deviceToken/refresh" REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour) DEVICE_DEFAULTS = { "clientType": "10", "version": "", # 不默认发送,用户可在设备标识里按客户端实际版本填 "machineOS": "", "machineHostname": "", "machineId": "", "machineToken": "", "machineCode": "", "machineType": "", "version": "", } def is_global(auth): return "qoder.sh" in (auth.get("domain") or "").lower() def billing_base(auth): return GLOBAL_BASE if is_global(auth) else CN_BASE def device_headers(account): """Cosy-* device headers from the account's device block.""" dev = dict(DEVICE_DEFAULTS) dev.update({k: v for k, v in (account.get("device") or {}).items() if k in DEVICE_DEFAULTS}) return { "Cosy-ClientType": str(dev["clientType"] or "10"), "Cosy-MachineOS": str(dev["machineOS"] or ""), "Cosy-MachineHostname": str(dev["machineHostname"] or ""), "Cosy-MachineId": str(dev["machineId"] or ""), "Cosy-MachineToken": str(dev["machineToken"] or ""), "Cosy-MachineCode": str(dev["machineCode"] or ""), "Cosy-MachineType": str(dev["machineType"] or ""), "Cosy-Version": str(dev["version"] or ""), } def _headers(account): h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"} # only send device headers that actually have values (empty values can # trip server-side device validation) for k, v in device_headers(account).items(): if v: h[k] = v token = (account.get("auth") or {}).get("accessToken", "") if token: h["Authorization"] = "Bearer %s" % token return h # --------------------------------------------------------------------------- # JWT helpers # --------------------------------------------------------------------------- def _b64_json(seg): seg += "=" * (-len(seg) % 4) try: return json.loads(base64.urlsafe_b64decode(seg).decode("utf-8")) except Exception: return {} def jwt_exp(token): parts = str(token or "").split(".") if len(parts) != 3: return 0 try: return int(_b64_json(parts[1]).get("exp") or 0) except (TypeError, ValueError): return 0 def jwt_uid(token): parts = str(token or "").split(".") if len(parts) != 3: return "" data = _b64_json(parts[1]) for k in ("sub", "uid", "user_id", "userId", "id"): if data.get(k): return str(data[k]) return "" # --------------------------------------------------------------------------- # Token refresh # --------------------------------------------------------------------------- def token_expiring(account, margin=REFRESH_MARGIN): auth = account.get("auth") or {} at = auth.get("accessToken") or "" if not at: return True exp = auth.get("expiresAt") or jwt_exp(at) if not exp: return False return now_ts() + margin > exp def refresh_token(account): """Refresh access token in place. Returns (ok, message).""" rt = (account.get("auth") or {}).get("refreshToken", "") if not rt: return False, "未配置 refreshToken,无法自动续期" # jrt- 前缀的刷新串走 jobToken 端点(社区实现验证) path = "/api/v1/jobToken/refresh" if rt.startswith("jrt-") else PATH_REFRESH data, status, err = http_post_json( billing_base(account.get("auth") or {}) + path, body={"refresh_token": rt}, headers={"User-Agent": "qoder-local-proxy", "Accept": "application/json"}) if status != 200 or not isinstance(data, dict): detail = "" if isinstance(data, dict): detail = data.get("errorMessage") or data.get("message") or data.get("msg") or "" return False, "续期失败(HTTP %s)%s%s" % (status, (":" + str(detail)[:80]) if detail else "", (" " + str(err)[:80]) if err and not detail else "") # 响应可能是 {"data": {...}} 嵌套,也可能是平铺 payload = data.get("data") if isinstance(data.get("data"), dict) else data tok = None for k in ("device_token", "deviceToken", "token", "accessToken", "access_token"): if payload.get(k): tok = payload[k] break if not tok: return False, "续期响应缺少 token(HTTP %s)" % status auth = account.setdefault("auth", {}) auth["accessToken"] = tok new_rt = None for k in ("device_refresh_token", "refresh_token", "refreshToken"): if payload.get(k): new_rt = payload[k] break if new_rt: auth["refreshToken"] = new_rt exp = jwt_exp(tok) if exp: auth["expiresAt"] = exp if not account.get("uid"): account["uid"] = jwt_uid(tok) return True, "已续期" def ensure_token(account, force=False): if not force and not token_expiring(account): return True, "" return refresh_token(account) # --------------------------------------------------------------------------- # Campaigns / claim # --------------------------------------------------------------------------- def _api(account, path, method="GET", body=None): return http_post_json(billing_base(account.get("auth") or {}) + path, body=body, headers=_headers(account)) if method == "POST" \ else http_get(billing_base(account.get("auth") or {}) + path, headers=_headers(account)) def query_campaigns(account): """Returns (campaigns, error). campaigns is a list (possibly empty).""" data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET") if status == 401: return None, "no_session" if not isinstance(data, dict) or status != 200: detail = "" if isinstance(data, dict): detail = data.get("message") or data.get("errorMessage") or "" return None, "查询失败(HTTP %s)%s" % (status, (":" + str(detail)[:80]) if detail else "") campaigns = data.get("campaigns") return (campaigns if isinstance(campaigns, list) else []), None def _benefits(campaigns): """CLAIM_BENEFIT campaigns; prefer kind == CREDITS when the field exists.""" out = [] for c in campaigns: if not (isinstance(c, dict) and c.get("actionType") == "CLAIM_BENEFIT"): continue benefit = c.get("benefit") if isinstance(benefit, dict) and benefit.get("kind") and benefit["kind"] != "CREDITS": continue out.append(c) return out USAGE_PATH = "/sash/api/v2/me/usage" def query_balance(account): """Remaining credits (userQuota + addOnQuota). Returns (total, error).""" data, status, err = _api(account, USAGE_PATH, "GET") if status == 401: return None, "no_session" if not isinstance(data, dict) or status != 200: return None, err usage = data.get("qoderUsage") if not isinstance(usage, dict): return None, None total = 0 for key in ("userQuota", "addOnQuota"): q = usage.get(key) if isinstance(q, dict) and isinstance(q.get("remaining"), (int, float)): total += q["remaining"] return total, None def _claim_one(account, cid): data, status, err = _api(account, PATH_CLAIM % cid, "POST", body={}) if status == 401: return None, "no_session" d = data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), dict) else data if status == 200 and isinstance(d, dict) and d.get("status") == "CLAIMED": amt = 0 if isinstance(d.get("benefit"), dict): amt = d["benefit"].get("amount") or 0 return amt, None return 0, "领取失败(HTTP %s)" % status def _token_diag(account): auth = account.get("auth") or {} at = auth.get("accessToken") or "" rt = auth.get("refreshToken") or "" return "(accessToken %d 字符 / refreshToken %d 字符)" % (len(at), len(rt)) def checkin_once(account): """Full single-account check-in. Returns a result dict.""" auth = account.get("auth") or {} if not auth.get("accessToken") and not auth.get("refreshToken"): return {"status": "no_auth", "message": "缺少 accessToken / refreshToken,请重新添加账号", "points": 0} # 注意:Qoder 的 dt-/drt- Token 原生就只有 27/28 字符(社区实测确认), # 这里只拦截明显异常短的值。 if auth.get("accessToken") and len(auth["accessToken"]) < 10: return {"status": "no_auth", "message": "accessToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["accessToken"]), "points": 0} if auth.get("refreshToken") and len(auth["refreshToken"]) < 10: return {"status": "no_auth", "message": "refreshToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["refreshToken"]), "points": 0} ok, msg = ensure_token(account) if not ok: # maybe the access token is still valid even though refresh failed at = auth.get("accessToken") or "" exp = auth.get("expiresAt") or jwt_exp(at) if not (at and (not exp or exp > now_ts())): return {"status": "no_session", "message": msg, "points": 0} campaigns, err = query_campaigns(account) if err == "no_session": ok2, msg2 = ensure_token(account, force=True) if ok2: campaigns, err = query_campaigns(account) if err == "no_session": dom = (account.get("auth") or {}).get("domain") or CN_BASE other = GLOBAL_BASE if "com.cn" in dom else CN_BASE return {"status": "no_session", "message": "认证失败(401)。两个站点 Token 不互通——若这是国际版 Token," "请把域名改为 %s(当前 %s)%s" % (other, dom, _token_diag(account)), "points": 0} if err: return {"status": "fail", "message": "续期后查询仍失败:%s" % err, "points": 0} if err: return {"status": "fail", "message": err, "points": 0} balance, berr = query_balance(account) if berr == "no_session": return {"status": "no_session", "message": "Token 已过期,请重新获取 Token" + _token_diag(account), "points": 0} if berr: balance = None benefits = _benefits(campaigns) claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"] if claimable: got, fail = 0.0, 0 for c in claimable: cid = str(c.get("campaignId") or "") amt, cerr = _claim_one(account, cid) if cerr == "no_session": return {"status": "no_session", "message": "登录态已失效,请重新提取 Token", "points": 0} if cerr: fail += 1 else: try: got += float(amt or 0) except (TypeError, ValueError): pass if fail: return {"status": "fail", "message": "%d/%d 个活动领取失败" % (fail, len(claimable)), "points": 0} return {"status": "ok", "message": "签到成功 +%s Credits" % int(got), "points": int(got), "credits": balance} # nothing claimable: already claimed today / activity not released / none now = now_ts() claimed_today = [c for c in benefits if c.get("claimStatus") == "CLAIMED" and (c.get("startAt") or 0) <= now < (c.get("endAt") or 0)] if claimed_today: return {"status": "already", "message": "今日已领取", "points": 0, "credits": balance} if not benefits: return {"status": "already", "message": "活动列表为空(活动结束或未开放)", "points": 0} return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0} def probe_domains(account): """Probe both CN/Global with the current token. Returns {domain: {"status": http_status, "auth": bool}} — a 404 means the token authenticated (path simply doesn't exist), 401 means rejected. """ out = {} for dom, base in ((CN_BASE, CN_BASE), (GLOBAL_BASE, GLOBAL_BASE)): acc = dict(account) acc["auth"] = dict(account.get("auth") or {}) acc["auth"]["domain"] = dom try: data, status, err = _api(acc, PATH_CAMPAIGNS, "GET") except Exception as e: out[dom] = {"status": 0, "auth": False, "error": str(e)} continue out[dom] = {"status": status, "auth": status not in (401, 403)} return out def account_display(account): return account.get("name") or account.get("uid") or "Qoder 账号"