Files
fnos-agent-checkin/AgentCheckin/app/backend/qoder.py
T
AgentCheckin 556e45d642 feat(qoder): 弄清国际版每日 100 Credits 的「按设备下发」规则 + 一键设备授权登录
排查结论(本机对真实接口做了 30+ 组对照实验)
- GET /sash/api/v1/me/campaigns 只返回一条常驻 VIEW_DETAILS 广告
  (act-20260901-493,BOGO 订阅推广,claimStatus=CLAIMED 与签到无关)
- 无论 Cosy-MachineToken/Code/Type 用 env 0/3/4/prod、裸调用/带 stdin、
  是否附 Cosy-MachineId/OS/Hostname/Origin/Referer/X-*、是否带 clientType,
  都拿不到 CLAIM_BEFIT 轮次;账号本人 balances/achievements 正常
- 三个社区实现(qoder2api-hub、dsh-connect-qoder、CreditDaddy)一致:
  每日轮次按「登录过官方客户端的那台设备」下发,NAS 侧另造的设备看不到它

修复
- _benefits() 改为只认 actionType=CLAIM_BEFIT(不再把 VIEW_DETAILS 当签到),
  窗口过期也排除;claimStatus 未知按可领处理(领取接口幂等)
- 两段式取活动:先裸取拿 uid → 按 uid 生成风控身份 → 再带设备身份重取
- 状态文案改为「未下发每日签到活动」而不是误导性的「今日已领取」

新功能
- Qoder 一键登录(OAuth 设备授权 / PKCE S256):应用内点按钮 → 浏览器确认一次,
  Token 直接绑定本机设备;同 uid 已存在则更新凭证而不是新增重复账号
- 手工导入桌面端设备身份接口 /api/qoder/import_device
- 诊断接口:/api/qoder/{probe,experiment,growth,matrix,desktop}

测试:39 后端 + 25 E2E + 9 前端全绿
2026-10-19 12:49:06 +08:00

1006 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <accessToken> 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 <redacted>" if k == "Authorization"
else ("<set:%d>" % 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'<script[^>]+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 账号"