#!/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 import uuid from common import http_post_json, http_get, log, now_ts import umid 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" # 只有这个 actionType 会真的发积分(VIEW_DETAILS 是订阅推广,不发放) CLAIM_BENEFIT_ACTION = "CLAIM_BENEFIT" CLAIMED_STATUS = "CLAIMED" REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour) # 活动 iframe(桌面客户端「每天领 100 Credits」卡片就由它渲染) GROWTH_URL = "https://openapi.qoder.sh/growth-page/activity-iframe" # 客户端默认版本(社区实现实测值:缺 Cosy-Version 时 campaigns 可能返回空数组) DEFAULT_VERSION = "0.3.4" DEVICE_DEFAULTS = { "clientType": "10", "version": DEFAULT_VERSION, "machineOS": "", "machineHostname": "", "machineId": "", "machineToken": "", "machineCode": "", "machineType": "", } 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 risk_identity(account, force=False): """获取(并缓存到账号)设备风控身份 Cosy-MachineToken/Code/Type。 Qoder 服务端只对带风控身份的请求下发「每天领 100 Credits」活动 (国际版尤其严格)。身份必须按账号 uid 生成——uid 从活动接口响应里取, 所以调用前应先跑一次 query_campaigns()。 """ dev = account.get("device") or {} uid = account.get("uid") or "" have = dev.get("machineToken") and dev.get("machineCode") and dev.get("machineType") if not force and have and (dev.get("uid") or "") == uid: return dev["machineToken"], dev["machineCode"], dev["machineType"], None provider = "qoder" if is_global(account.get("auth") or {}) else "qoder-cn" ident, err = umid.risk_identity(provider, uid) if err: if have: # 生成失败时退回旧身份,至少让请求能带上设备标识 return dev["machineToken"], dev["machineCode"], dev["machineType"], err return None, None, None, err dev = dict(dev) dev.update({"machineToken": ident["machineToken"], "machineCode": ident["machineCode"], "machineType": ident["machineType"], "uid": uid}) account["device"] = dev return dev["machineToken"], dev["machineCode"], dev["machineType"], None def _headers(account): h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"} # 设备标识:有值才发(空值可能触发服务端设备校验) 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_full(account): """活动接口的完整响应(含 uid / showCampaign / claimable)。""" data, status, err = _api(account, PATH_CAMPAIGNS + "?clientType=10&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 "") return data, None def query_campaigns(account): """Returns (campaigns, error). campaigns is a list (possibly empty). 顺带记录响应里的 uid —— 设备风控身份必须按 uid 生成(见 risk_identity)。 """ data, err = query_campaigns_full(account) if err: return None, err uid = data.get("uid") if isinstance(uid, str) and uid: account["uid"] = uid campaigns = data.get("campaigns") return (campaigns if isinstance(campaigns, list) else []), None def campaigns_debug(account): """诊断:返回活动接口的原始响应摘要(不含 token)。""" data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET") out = {"status": status, "error": err} if isinstance(data, dict): cs = data.get("campaigns") out["campaign_count"] = len(cs) if isinstance(cs, list) else None if isinstance(cs, list) and cs: out["campaigns"] = [describe_campaign(c) for c in cs[:10]] # 原始字段摘要(便于排查 schema 差异) raw = {} for k, v in cs[0].items(): if isinstance(v, (str, int, float, bool)) or v is None: raw[k] = (str(v)[:60] if isinstance(v, str) else v) elif isinstance(v, dict): raw[k] = {kk: (str(vv)[:40] if isinstance(vv, str) else vv) for kk, vv in list(v.items())[:8]} elif isinstance(v, list): raw[k] = "[%d items]" % len(v) out["campaign_raw_fields"] = raw else: out["raw_keys"] = list(data.keys())[:10] return out def _window_open(c, now=None): """活动是否在有效期内(缺字段视为开放)。""" now = now if now is not None else now_ts() start = c.get("startAt") or c.get("beginAt") end = c.get("endAt") try: if start and now < float(start): return False if end and now > float(end): return False except (TypeError, ValueError): return True return True def _benefits(campaigns): """可领取的签到轮次:只有 actionType == CLAIM_BENEFIT。 实测(2026-09-27 两区,本机复核 2026-10-19): 同一账号还挂着一条 actionType=VIEW_DETAILS 的订阅推广活动(无 benefit、 claimStatus 却是 CLAIMED)——只看 claimStatus 会把它当成签到活动去「领取」。 而 claimStatus 是判断「本轮是否已领」的唯一可靠字段:响应顶层的 claimable 在「今天已领」和「当前没有活动」两种情况下都是 false。 """ out = [] for c in campaigns: if not isinstance(c, dict): continue if c.get("actionType") != CLAIM_BENEFIT_ACTION: continue if not _window_open(c): continue out.append(c) return out def describe_campaign(c): """Human-readable campaign summary (title / amount / window / countdown). Mirrors what the client shows in the activity card: 每天领 100 Credits, 每日 10:00 (UTC+8) 刷新, 领取后 30 天有效, plus a countdown to next reset. """ b = c.get("benefit") or {} amount = b.get("amount") kind = b.get("kind") or "CREDITS" title = c.get("campaignName") or c.get("title") or c.get("campaignKey") or "" if title.startswith("act-"): title = "每日签到活动" info = {"campaignId": c.get("campaignId"), "campaignKey": c.get("campaignKey"), "title": title, "amount": amount, "kind": kind, "actionType": c.get("actionType"), "claimStatus": c.get("claimStatus")} # 有效期 for src_key, dst_key in (("startAt", "startAt"), ("endAt", "endAt")): if c.get(src_key): info[dst_key] = c[src_key] # 刷新周期(每日 10:00 UTC+8 等) for key in ("refreshCron", "refreshTime", "resetAt", "resetTime", "nextRefreshAt", "dailyRefreshTime", "refreshPeriod"): if c.get(key) is not None: info[key] = c[key] # 领取后有效期 for key in ("validDays", "benefitValidDays", "expireDays", "validityDays"): if c.get(key) is not None: info["validDays"] = c[key] # 倒计时(服务端可能直接给剩余秒数/毫秒) for key in ("countdownMs", "countdown", "remainingMs", "remainingSeconds", "nextClaimInMs"): if c.get(key) is not None: info["countdownMs"] = c[key] return info 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} # 两段式取活动:先裸取一次拿到 uid(风控身份按 uid 生成), # 带上设备身份后再取一次——国际版只有带设备身份的请求才会下发每日活动。 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} tok, code, mtype, rerr = risk_identity(account) if rerr: log("qoder risk identity unavailable: %s" % rerr, "WARN") if tok: campaigns2, err2 = query_campaigns(account) if not err2 and campaigns2 is not None: campaigns = campaigns2 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) # claimStatus 未知时按「可领」处理:服务端领取接口幂等(replayed 不再发放), # 乐观的代价是一次多余请求,保守的代价是把功能永久藏起来。 claimable = [c for c in benefits if c.get("claimStatus") != CLAIMED_STATUS] 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: # 服务端只下发了 VIEW_DETAILS 类推广(或不含每日轮次)——国际版常见原因: # 活动按「登录过官方客户端的那台设备」下发,本机设备未被绑定。 hint = ("未下发每日签到活动:服务端没有给本机设备下发 CLAIM_BENEFIT 轮次。" "国际版的活动按设备下发,可在「添加账号 → Qoder → 一键登录」用设备授权" "把本机绑定到账号后重试。") return {"status": "retry_later", "message": hint, "points": 0, "credits": balance} return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0, "credits": balance} # --------------------------------------------------------------------------- # 诊断:原始响应(排查活动 schema / claimStatus 语义差异) # --------------------------------------------------------------------------- def _raw_call(account, path, method="GET", body=None): """一次 API 调用,返回 {status,error,body}(body 为服务端原始 JSON)。""" try: data, status, err = _api(account, path, method, body) except Exception as e: # pragma: no cover - 网络异常 return {"status": 0, "error": str(e)[:200]} out = {"status": status} if err: out["error"] = str(err)[:200] out["body"] = data return out def _collect_campaign_ids(obj, into): if isinstance(obj, dict): for k, v in obj.items(): if k == "campaignId" and isinstance(v, str) and v: into.append(v) else: _collect_campaign_ids(v, into) elif isinstance(obj, list): for it in obj: _collect_campaign_ids(it, into) def raw_probe(account): """转储 Qoder 接口的原始 JSON(排查 claimStatus 语义 / 活动 schema)。 会先记录余额,再对窗口内的活动调用一次领取接口(官方接口幂等, 重复领取返回 replayed,不会重复发放),最后记录余额变化。 """ out = {"headers": {}, "domain": (account.get("auth") or {}).get("domain")} for k, v in _headers(account).items(): out["headers"][k] = ("Bearer " if k == "Authorization" else ("" % len(str(v)) if v else "")) camp = _raw_call(account, PATH_CAMPAIGNS + "?forceRefresh=true") out["campaigns"] = camp out["campaigns_no_force"] = _raw_call(account, PATH_CAMPAIGNS) out["campaigns_v2"] = _raw_call(account, "/sash/api/v2/me/campaigns?forceRefresh=true") cids = [] _collect_campaign_ids(camp.get("body"), cids) cids = list(dict.fromkeys(cids)) out["campaign_ids"] = cids out["usage"] = _raw_call(account, USAGE_PATH) b0, _ = query_balance(account) out["balance_before"] = b0 if cids: cid = cids[0] out["campaign_detail"] = _raw_call(account, "/sash/api/v1/me/campaigns/" + cid) out["claim"] = _raw_call(account, PATH_CLAIM % cid, "POST", body={}) time.sleep(1.5) out["balance_after"] = query_balance(account)[0] out["usage_after"] = _raw_call(account, USAGE_PATH) out["campaigns_after_claim"] = _raw_call(account, PATH_CAMPAIGNS + "?forceRefresh=true") return out def _summ(data): """活动响应摘要(不含 machineToken 等敏感值)。""" if not isinstance(data, dict): return {"raw": str(data)[:200]} out = {"uid": data.get("uid"), "showCampaign": data.get("showCampaign"), "claimable": data.get("claimable")} cs = data.get("campaigns") if isinstance(cs, list): out["count"] = len(cs) out["campaigns"] = [{ "key": c.get("campaignKey"), "actionType": c.get("actionType"), "claimStatus": c.get("claimStatus"), "placements": [p.get("type") for p in (c.get("placements") or []) if isinstance(p, dict)], } for c in cs if isinstance(c, dict)] return out def _base_headers(account, risk=False): """基础请求头;risk=False 时不带任何 Cosy 设备/风控头。""" h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"} tok = (account.get("auth") or {}).get("accessToken", "") if tok: h["Authorization"] = "Bearer %s" % tok if risk: h["Cosy-ClientType"] = "10" h["Cosy-Version"] = DEFAULT_VERSION return h def checkin_experiment(account): """定位「每日 100 Credits」活动下发条件的对照实验(只读,不领取)。""" base = billing_base(account.get("auth") or {}) out = {"domain": base} # 1) 不带风控身份取一次,拿到 uid / showCampaign / claimable plain, st, err = http_get(base + PATH_CAMPAIGNS, headers=_base_headers(account, risk=True)) out["step1_plain"] = {"status": st, "error": err, "summary": _summ(plain)} uid = (plain or {}).get("uid") or account.get("uid") or "" out["uid"] = uid # 2) 不同 env 生成的身份(看输出字段差异) idents = {} for env in (0, 3, 4): j, e = umid.run_runtime_info(env, uid) idents["env%s" % env] = { "error": e, "fields": sorted(j.keys()) if isinstance(j, dict) else None, "preview": {k: (str(v)[:100] if isinstance(v, str) else v) for k, v in (j or {}).items() if k != "machineToken"}, "token_len": len((j or {}).get("machineToken") or ""), } out["identities"] = idents # 3) 组合对照:env × 是否带 Cosy-MachineId/OS/Hostname × 是否带 clientType 查询参数 m_id = str(uuid.uuid4()) out["machine_id_used"] = m_id combos = {} for env in (3, 4): j, e = umid.run_runtime_info(env, uid) if e or not isinstance(j, dict): combos["env%s" % env] = {"error": e} continue for mode in ("token_only", "full"): h = _base_headers(account, risk=True) h["Cosy-MachineToken"] = j.get("machineToken", "") h["Cosy-MachineCode"] = j.get("machineCode", "") h["Cosy-MachineType"] = j.get("machineType", "") if mode == "full": h["Cosy-MachineId"] = m_id h["Cosy-MachineOS"] = "x86_64_linux" h["Cosy-MachineHostname"] = "fnOS" for qs, tag in (("", "noq"), ("?clientType=10", "ct10")): d2, s2, e2 = http_get(base + PATH_CAMPAIGNS + qs, headers=h) combos["env%s_%s_%s" % (env, mode, tag)] = { "status": s2, "error": e2, "summary": _summ(d2)} out["combos"] = combos return out def _snips(text, pattern, limit, width): """返回 pattern 命中处 ±width 字符的片段(去重、限量)。""" import re seen, out = set(), [] for m in re.finditer(pattern, text): a = max(0, m.start() - width) b = min(len(text), m.end() + width) s = text[a:b] key = s[:120] if key in seen: continue seen.add(key) out.append(s) if len(out) >= limit: break return out def growth_probe(account): """扒「活动 iframe」页面与其 JS,找出每日签到真正调用的接口。""" import re from urllib.parse import urljoin from common import http_get_text out = {} html, status, err = http_get_text(GROWTH_URL, headers={"Accept": "text/html"}) out["page"] = {"status": status, "error": err, "len": len(html or ""), "head": (html or "")[:2000]} srcs = re.findall(r']+src=["\']([^"\']+)["\']', html or "", re.I) out["scripts"] = srcs[:8] bundles = {} for s in srcs[:4]: u = urljoin(GROWTH_URL, s) js, st, e = http_get_text(u, headers={"Accept": "*/*"}) body = js or "" paths = sorted(set(re.findall( r'["\'`](/(?:sash|api|growth|oauth)[A-Za-z0-9_\-/{}.$:]*?)["\'`]', body))) hits = sorted(set(re.findall( r'[A-Za-z0-9_./-]*(?:checkin|dailyCheck|daily_credit|claim|credit|campaign)' r'[A-Za-z0-9_./-]*', body, re.I))) info = {"status": st, "error": e, "len": len(body), "paths": paths[:120], "keywords": hits[:120]} if "/sash/api/v1/me/campaigns" in body: # 前端构造请求的上下文(请求头 / fetch 选项) info["api_snips"] = _snips(body, r'/sash/api/v1/me/campaigns', 1, 700) info["cosy_names"] = sorted(set(re.findall(r'Cosy-[A-Za-z0-9-]+', body))) info["cosy_snips"] = _snips(body, r'Cosy-[A-Za-z0-9-]+', 6, 300) info["hdr_snips"] = _snips(body, r'Authorization|machineToken|credentials', 6, 260) bundles[u] = info out["bundles"] = bundles # 候选接口探测(404 = 该路径不存在,其它状态码值得注意) base = billing_base(account.get("auth") or {}) h = _headers(account) cands = [ "/sash/api/v1/me/campaigns?clientType=10&forceRefresh=true", "/sash/api/v1/me/campaigns?clientType=10&placement=USAGE", "/sash/api/v1/me/dailyCheckin", "/sash/api/v1/me/daily-checkin", "/sash/api/v1/me/checkin", "/sash/api/v1/me/check-in", "/sash/api/v1/me/rewards", "/sash/api/v1/me/benefits", "/sash/api/v1/me/campaigns/claimable", "/sash/api/v1/growth/campaigns", "/sash/api/v1/me/growth/campaigns", "/sash/api/v2/me/campaigns", "/sash/api/v1/me/credits/daily", "/api/v2/me/usage", "/sash/api/v1/me/daily-credits", ] out["paths"] = {} for p in cands: d, st, e = http_get(base + p, headers=h) out["paths"][p] = {"status": st, "error": e, "summary": _summ(d) if isinstance(d, dict) else None} return out DAILY_ACTION_HINT = "CLAIM_BENEFIT" def identity_matrix_probe(account): """对照实验:哪种 umid 调用方式 + 哪种请求头组合能拿到 CLAIM_BENEFIT。 只读,不会领取(发现 CLAIMABLE 也只报告)。 """ base = billing_base(account.get("auth") or {}) tok = (account.get("auth") or {}).get("accessToken", "") auth = {"Authorization": "Bearer %s" % tok} if tok else {} out = {} # 1) 裸 bearer 拿 uid plain_hdr = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", "Cosy-ClientType": "10"}) d, st, e = http_get(base + PATH_CAMPAIGNS, headers=plain_hdr) out["plain"] = {"status": st, "error": e, "summary": _summ(d)} uid = (d or {}).get("uid") or account.get("uid") or "" out["uid"] = uid # 2) 各种 umid 调用方式 stdin = json.dumps({"account": uid}) + "\n" modes = { "env4_stdin": (["4", "--account-stdin"], stdin), "env4_bare": (["4"], None), "env3_stdin": (["3", "--account-stdin"], stdin), "env3_bare": (["3"], None), "prod_stdin": (["prod", "--account-stdin"], stdin), "prod_bare": (["prod"], None), "noarg": ([], None), } idents = {} for name, (args, sd) in modes.items(): j, err = umid.run_runtime_info_raw(args, sd) idents[name] = {"error": err, "token_len": len((j or {}).get("machineToken") or ""), "code": (j or {}).get("machineCode"), "type": (j or {}).get("machineType"), "vm": (j or {}).get("vmInfo")} out["identities"] = idents # 3) 各身份 × 各请求头组合 def _hdr(ident, extra): h = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", "Cosy-ClientType": "10", "Cosy-Version": DEFAULT_VERSION}) if ident: h["Cosy-MachineToken"] = ident.get("machineToken", "") h["Cosy-MachineCode"] = ident.get("machineCode", "") h["Cosy-MachineType"] = ident.get("machineType", "") h.update(extra) return h web_extra = {"Origin": "https://qoder.com", "Referer": "https://qoder.com/usage"} x_extra = {"X-Request-ID": str(uuid.uuid4()), "X-Machine-ID": str(uuid.uuid4()), "X-Session-ID": str(uuid.uuid4())} combos = {} tried = [] for name in ("env4_bare", "env4_stdin", "prod_stdin", "noarg"): j, _ = umid.run_runtime_info_raw(*modes[name]) if not j: continue for label, extra in (("min", {}), ("web", web_extra), ("x", x_extra), ("webx", dict(web_extra, **x_extra))): h = _hdr(j, extra) for qs in ("", "?clientType=10"): key = "%s_%s%s" % (name, label, qs and "_ct10" or "") if key in combos: continue d2, s2, e2 = http_get(base + PATH_CAMPAIGNS + qs, headers=h) s = _summ(d2) combos[key] = {"status": s2, "error": e2, "summary": s} tried.append((key, s)) out["combos"] = combos # 4) 命中 CLAIM_BENEFIT 的(不领取,只报告) hits = [] for key, s in tried: for c in (s.get("campaigns") or []): if c.get("actionType") == DAILY_ACTION_HINT: hits.append({"combo": key, "campaign": c, "top_claimable": s.get("claimable")}) out["hits"] = hits return out def _derive_machine_id(uid): """官方桌面端同款 machineId(社区实现:md5("machine:"+uid)[:36])。""" import hashlib return hashlib.md5(("machine:%s" % uid).encode("utf-8")).hexdigest()[:36] def desktop_emulation_probe(account): """模拟官方桌面端出站头,寻找能拿到 CLAIM_BENEFIT 的组合(只读)。""" base = billing_base(account.get("auth") or {}) tok = (account.get("auth") or {}).get("accessToken", "") auth = {"Authorization": "Bearer %s" % tok} if tok else {} d, st, e = http_get(base + PATH_CAMPAIGNS, headers=dict(auth, **{ "Accept": "application/json", "User-Agent": "Qoder", "Cosy-ClientType": "10"})) uid = (d or {}).get("uid") or account.get("uid") or "" out = {"uid": uid, "plain": {"status": st, "error": e, "summary": _summ(d)}} ident, ierr = umid.risk_identity("qoder", uid) out["identity_error"] = ierr out["identity"] = {k: (str(v)[:24] if isinstance(v, str) else v) for k, v in (ident or {}).items()} mid = _derive_machine_id(uid) out["machine_id"] = mid umid_hdr = {} if ident: umid_hdr = {"Cosy-MachineToken": ident["machineToken"], "Cosy-MachineCode": ident["machineCode"], "Cosy-MachineType": ident["machineType"]} win_hdr = {"Cosy-MachineOS": "x86_64_win32", "Cosy-MachineHostname": "DESKTOP-QODER", "Cosy-MachineId": mid} web = {"Origin": "https://qoder.com", "Referer": "https://qoder.com/"} variants = { # 官方桌面端同款:全套六头 + win32 外形 "desktop_full": dict(umid_hdr, **win_hdr), "desktop_full_web": dict(umid_hdr, **dict(win_hdr, **web)), # 去掉 machineId / machineToken(issue #10 的「去掉即恢复」) "no_machineid": dict(umid_hdr, **{k: v for k, v in win_hdr.items() if k != "Cosy-MachineId"}), "no_token": dict({k: v for k, v in umid_hdr.items() if k != "Cosy-MachineToken"}, **win_hdr), # 只发部分 "only_machineid": {"Cosy-MachineId": mid}, "only_os_host": {"Cosy-MachineOS": "x86_64_win32", "Cosy-MachineHostname": "DESKTOP-QODER"}, "umid_only": dict(umid_hdr), # umid + OS(不带 machineId / hostname) "umid_os": dict(umid_hdr, **{"Cosy-MachineOS": "x86_64_win32"}), } out["combos"] = {} hits = [] for name, extra in variants.items(): h = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", "Cosy-ClientType": "10", "Cosy-Version": "0.4.3"}) h.update(extra) for qs in ("", "?clientType=10"): d2, s2, e2 = http_get(base + PATH_CAMPAIGNS + qs, headers=h) s = _summ(d2) key = name + ("_ct10" if qs else "") out["combos"][key] = {"status": s2, "error": e2, "summary": s} for c in (s.get("campaigns") or []): if c.get("actionType") == DAILY_ACTION_HINT: hits.append({"combo": key, "campaign": c, "claimable": s.get("claimable")}) out["hits"] = hits # 其它可能暴露资格/成就的接口(只读) extra_paths = ["/sash/api/v1/me/achievements", "/api/v2/quota/usage", "/sash/api/v1/me/campaigns/%s/reward" % "01a05bce-e800-7494-a002-806e4438f483"] out["extra"] = {} h = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", "Cosy-ClientType": "10", "Cosy-Version": "0.4.3"}) h.update(umid_hdr) for p in extra_paths: d3, s3, e3 = http_get(base + p, headers=h) body = d3 if isinstance(d3, dict) else None if body is not None and len(json.dumps(body, ensure_ascii=False)) > 6000: body = {"_truncated": True, "_keys": list(body.keys())[:20]} out["extra"][p] = {"status": s3, "error": e3, "body": body} return out 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 # --------------------------------------------------------------------------- # OAuth 设备授权登录(PKCE S256)——「一键登录」,同时也把设备绑到本机 # --------------------------------------------------------------------------- REALMS = { "intl": { "name": "国际版", "openapi": GLOBAL_BASE, "website": "https://qoder.com", "client_id": "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb", "redirect_uri": "", "nonce_dashed": False, }, "cn": { "name": "国内版", "openapi": CN_BASE, "website": "https://qoder.com.cn", "client_id": "1c5e33e1-364d-4ce6-b02c-acaa81274a5c", "redirect_uri": "qoder-work-cn://", "nonce_dashed": True, }, } PATH_DEVICE_POLL = "/api/v1/deviceToken/poll" def realm_of(domain): return "cn" if "com.cn" in (domain or "").lower() else "intl" def _b64url(raw): return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") def device_login_start(realm="intl", machine_id=""): """构造 PKCE 设备授权链接。返回 {realm, auth_url, verifier, nonce, machine_id}.""" import hashlib import os import urllib.parse cfg = REALMS.get(realm) or REALMS["intl"] verifier = _b64url(os.urandom(32)) challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest()) nonce = str(uuid.uuid4()) if cfg["nonce_dashed"] else uuid.uuid4().hex mid = machine_id or str(uuid.uuid4()) params = {"challenge": challenge, "challenge_method": "S256", "nonce": nonce} if cfg["redirect_uri"]: params["redirect_uri"] = cfg["redirect_uri"] params["client_id"] = cfg["client_id"] params["machine_id"] = mid return {"realm": realm, "auth_url": cfg["website"] + "/device/selectAccounts?" + urllib.parse.urlencode(params), "verifier": verifier, "nonce": nonce, "machine_id": mid, "openapi": cfg["openapi"]} def device_login_poll(realm, verifier, nonce, machine_id="", with_device=True): """轮询设备授权结果。返回 {status: pending|ok|error, ...}. 带上本机设备身份(Cosy-Machine*)再轮询,让服务端把这次登录绑到本机设备—— 国际版的每日 100 Credits 活动只下发给「登录过官方客户端的那台设备」。 """ import urllib.parse cfg = REALMS.get(realm) or REALMS["intl"] q = urllib.parse.urlencode({"nonce": nonce, "verifier": verifier, "challenge_method": "S256"}) url = cfg["openapi"] + PATH_DEVICE_POLL + "?" + q headers = {"Accept": "application/json", "User-Agent": "QoderWork"} if with_device and machine_id: dev = {"machineId": machine_id, "clientType": "10", "version": DEFAULT_VERSION} provider = "qoder-cn" if realm == "cn" else "qoder" ident, _ierr = umid.risk_identity(provider, "") if ident: dev.update({"machineToken": ident.get("machineToken", ""), "machineCode": ident.get("machineCode", ""), "machineType": ident.get("machineType", "")}) for k, v in device_headers({"device": dev}).items(): if v: headers[k] = v data, status, err = http_get(url, headers=headers) if status not in (200, 202, 404) and with_device: # 设备头可能让轮询接口不接受:去掉再试一次 return device_login_poll(realm, verifier, nonce, machine_id, with_device=False) if status in (404, 202): return {"status": "pending"} if status != 200 or not isinstance(data, dict): return {"status": "error", "message": "轮询失败(HTTP %s)%s" % (status, (":" + str(err)[:120]) if err else "")} token = data.get("token") or data.get("device_token") or "" if not token: return {"status": "pending"} return {"status": "ok", "accessToken": token, "refreshToken": data.get("refresh_token") or "", "uid": str(data.get("user_id") or ""), "expiresAt": jwt_exp(token), "realm": realm, "domain": cfg["openapi"], "machine_id": machine_id} def account_display(account): return account.get("name") or account.get("uid") or "Qoder 账号"