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 前端全绿
This commit is contained in:
@@ -347,6 +347,185 @@ def h_logs(params, _body):
|
|||||||
return {"lines": [], "error": str(e)}
|
return {"lines": [], "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_probe(_params, body):
|
||||||
|
"""诊断:转储 Qoder 接口原始响应(含一次幂等领取)。"""
|
||||||
|
import qoder as _q
|
||||||
|
acc_id = body.get("id")
|
||||||
|
acc = None
|
||||||
|
for a in load_accounts():
|
||||||
|
if a.get("id") == acc_id:
|
||||||
|
acc = a
|
||||||
|
break
|
||||||
|
if acc is None:
|
||||||
|
return ("error", "账号不存在")
|
||||||
|
if acc.get("platform") != "qoder":
|
||||||
|
return ("error", "该账号不是 Qoder")
|
||||||
|
return _q.raw_probe(acc)
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_experiment(_params, body):
|
||||||
|
"""诊断:定位「每日领 100 Credits」活动下发条件的对照实验(只读)。"""
|
||||||
|
import qoder as _q
|
||||||
|
acc = None
|
||||||
|
for a in load_accounts():
|
||||||
|
if a.get("id") == body.get("id"):
|
||||||
|
acc = a
|
||||||
|
break
|
||||||
|
if acc is None:
|
||||||
|
return ("error", "账号不存在")
|
||||||
|
if acc.get("platform") != "qoder":
|
||||||
|
return ("error", "该账号不是 Qoder")
|
||||||
|
return _q.checkin_experiment(acc)
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_growth(_params, body):
|
||||||
|
"""诊断:扒活动 iframe 页面/JS,定位每日签到真实接口。"""
|
||||||
|
import qoder as _q
|
||||||
|
acc = None
|
||||||
|
for a in load_accounts():
|
||||||
|
if a.get("id") == body.get("id"):
|
||||||
|
acc = a
|
||||||
|
break
|
||||||
|
if acc is None:
|
||||||
|
return ("error", "账号不存在")
|
||||||
|
return _q.growth_probe(acc)
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_matrix(_params, body):
|
||||||
|
"""诊断:umid 调用方式 × 请求头组合的对照矩阵。"""
|
||||||
|
import qoder as _q
|
||||||
|
acc = None
|
||||||
|
for a in load_accounts():
|
||||||
|
if a.get("id") == body.get("id"):
|
||||||
|
acc = a
|
||||||
|
break
|
||||||
|
if acc is None:
|
||||||
|
return ("error", "账号不存在")
|
||||||
|
return _q.identity_matrix_probe(acc)
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_desktop(_params, body):
|
||||||
|
"""诊断:模拟官方桌面端出站头,寻找能拿到 CLAIM_BENEFIT 的组合。"""
|
||||||
|
import qoder as _q
|
||||||
|
acc = None
|
||||||
|
for a in load_accounts():
|
||||||
|
if a.get("id") == body.get("id"):
|
||||||
|
acc = a
|
||||||
|
break
|
||||||
|
if acc is None:
|
||||||
|
return ("error", "账号不存在")
|
||||||
|
return _q.desktop_emulation_probe(acc)
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_login_start(_params, body):
|
||||||
|
"""发起 Qoder OAuth 设备授权登录(一键登录)。"""
|
||||||
|
import qoder as _q
|
||||||
|
realm = body.get("realm") or "intl"
|
||||||
|
if realm not in ("intl", "cn"):
|
||||||
|
return ("error", "realm 只能是 intl / cn")
|
||||||
|
info = _q.device_login_start(realm)
|
||||||
|
st = load_state()
|
||||||
|
logins = st.setdefault("qoder_logins", {})
|
||||||
|
now = now_ts()
|
||||||
|
for k in list(logins.keys()):
|
||||||
|
if now - logins[k].get("created", 0) > OAUTH_TTL:
|
||||||
|
del logins[k]
|
||||||
|
state = "qd%d" % int(now * 1000)
|
||||||
|
info["created"] = now
|
||||||
|
logins[state] = info
|
||||||
|
save_state(st)
|
||||||
|
log("qoder device login started (realm=%s)" % realm)
|
||||||
|
return {"ok": True, "state": state, "auth_url": info["auth_url"],
|
||||||
|
"realm": realm, "ttl": OAUTH_TTL}
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_login_poll(params, body):
|
||||||
|
"""轮询 Qoder 设备授权结果,成功后直接入池。"""
|
||||||
|
import qoder as _q
|
||||||
|
state = params.get("state", "")
|
||||||
|
st = load_state()
|
||||||
|
info = (st.get("qoder_logins") or {}).get(state)
|
||||||
|
if not info:
|
||||||
|
return ("error", "登录会话不存在或已过期,请重新发起")
|
||||||
|
res = _q.device_login_poll(info.get("realm", "intl"), info.get("verifier", ""),
|
||||||
|
info.get("nonce", ""), info.get("machine_id", ""))
|
||||||
|
if res.get("status") != "ok":
|
||||||
|
return res
|
||||||
|
dev = {"machineId": info.get("machine_id", "")}
|
||||||
|
uid = res.get("uid") or ""
|
||||||
|
name = (body or {}).get("name") or ("Qoder %s"
|
||||||
|
% ("国内版" if info.get("realm") == "cn" else "国际版"))
|
||||||
|
# 同一个 uid 已经存在则更新凭证(把设备重新绑到本机),不新增重复账号
|
||||||
|
existing = None
|
||||||
|
for a in load_accounts():
|
||||||
|
if a.get("platform") == "qoder" and uid and a.get("uid") == uid:
|
||||||
|
existing = a
|
||||||
|
break
|
||||||
|
if existing is not None:
|
||||||
|
accounts = load_accounts()
|
||||||
|
acc = existing
|
||||||
|
for a in accounts:
|
||||||
|
if a.get("id") == existing["id"]:
|
||||||
|
a.setdefault("auth", {})["accessToken"] = res["accessToken"]
|
||||||
|
a["auth"]["refreshToken"] = res["refreshToken"]
|
||||||
|
a["auth"]["domain"] = res["domain"]
|
||||||
|
a["auth"]["expiresAt"] = res.get("expiresAt") or 0
|
||||||
|
dev2 = dict(a.get("device") or {})
|
||||||
|
dev2.update({k: v for k, v in dev.items() if v})
|
||||||
|
a["device"] = dev2
|
||||||
|
a["uid"] = uid
|
||||||
|
a["need_reauth"] = False
|
||||||
|
acc = a
|
||||||
|
engine.save_accounts(accounts)
|
||||||
|
log("qoder device login refreshed %s (uid=%s)"
|
||||||
|
% (existing["id"], uid[:8]))
|
||||||
|
msg = "登录成功:已把账号重新绑定到本机设备"
|
||||||
|
else:
|
||||||
|
acc = engine.add_qoder(
|
||||||
|
name=name,
|
||||||
|
access_token=res["accessToken"],
|
||||||
|
refresh_token=res["refreshToken"],
|
||||||
|
device={k: v for k, v in dev.items() if v},
|
||||||
|
domain=res["domain"],
|
||||||
|
)
|
||||||
|
if uid:
|
||||||
|
accounts = load_accounts()
|
||||||
|
for a in accounts:
|
||||||
|
if a.get("id") == acc["id"]:
|
||||||
|
a["uid"] = uid
|
||||||
|
engine.save_accounts(accounts)
|
||||||
|
msg = "登录成功,已加入账号列表"
|
||||||
|
st = load_state()
|
||||||
|
(st.get("qoder_logins") or {}).pop(state, None)
|
||||||
|
save_state(st)
|
||||||
|
log("qoder device login ok -> %s" % acc["id"])
|
||||||
|
return {"status": "ok", "account": public_account(acc),
|
||||||
|
"message": "登录成功,已加入账号列表"}
|
||||||
|
|
||||||
|
|
||||||
|
def h_qoder_import_device(_params, body):
|
||||||
|
"""导入桌面客户端的设备身份(machineToken/Code/Type)。"""
|
||||||
|
acc_id = body.get("id")
|
||||||
|
if not any(a.get("id") == acc_id for a in load_accounts()):
|
||||||
|
return ("error", "账号不存在")
|
||||||
|
tok = (body.get("machineToken") or "").strip()
|
||||||
|
code = (body.get("machineCode") or "").strip()
|
||||||
|
mtype = (body.get("machineType") or "").strip()
|
||||||
|
if not (tok and code and mtype):
|
||||||
|
return ("error", "machineToken / machineCode / machineType 都不能为空")
|
||||||
|
accounts = load_accounts()
|
||||||
|
for a in accounts:
|
||||||
|
if a.get("id") == acc_id:
|
||||||
|
dev = dict(a.get("device") or {})
|
||||||
|
dev.update({"machineToken": tok, "machineCode": code,
|
||||||
|
"machineType": mtype, "uid": a.get("uid") or ""})
|
||||||
|
a["device"] = dev
|
||||||
|
break
|
||||||
|
engine.save_accounts(accounts)
|
||||||
|
log("qoder account %s device identity imported" % acc_id)
|
||||||
|
return {"ok": True, "message": "已导入桌面端设备身份,下次签到会使用它"}
|
||||||
|
|
||||||
|
|
||||||
def h_debug(_params, _body):
|
def h_debug(_params, _body):
|
||||||
"""Diagnostics: resolved paths, env, daemon liveness (no secrets)."""
|
"""Diagnostics: resolved paths, env, daemon liveness (no secrets)."""
|
||||||
import common as _c
|
import common as _c
|
||||||
@@ -390,6 +569,14 @@ ROUTES = {
|
|||||||
("GET", "/api/umid/status"): h_umid_status,
|
("GET", "/api/umid/status"): h_umid_status,
|
||||||
("POST", "/api/umid/install"): h_umid_install,
|
("POST", "/api/umid/install"): h_umid_install,
|
||||||
("POST", "/api/accounts/refresh_risk"): h_qoder_refresh_risk,
|
("POST", "/api/accounts/refresh_risk"): h_qoder_refresh_risk,
|
||||||
|
("POST", "/api/qoder/probe"): h_qoder_probe,
|
||||||
|
("POST", "/api/qoder/experiment"): h_qoder_experiment,
|
||||||
|
("POST", "/api/qoder/growth"): h_qoder_growth,
|
||||||
|
("POST", "/api/qoder/matrix"): h_qoder_matrix,
|
||||||
|
("POST", "/api/qoder/desktop"): h_qoder_desktop,
|
||||||
|
("POST", "/api/qoder/login/start"): h_qoder_login_start,
|
||||||
|
("GET", "/api/qoder/login/poll"): h_qoder_login_poll,
|
||||||
|
("POST", "/api/qoder/import_device"): h_qoder_import_device,
|
||||||
("GET", "/api/settings"): h_settings_get,
|
("GET", "/api/settings"): h_settings_get,
|
||||||
("POST", "/api/settings"): h_settings_post,
|
("POST", "/api/settings"): h_settings_post,
|
||||||
("GET", "/api/logs"): h_logs,
|
("GET", "/api/logs"): h_logs,
|
||||||
|
|||||||
@@ -224,6 +224,25 @@ def http_get(url, headers=None, timeout=20):
|
|||||||
return None, status, "non-JSON response: %s" % raw[:200]
|
return None, status, "non-JSON response: %s" % raw[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def http_get_text(url, headers=None, timeout=20, limit=4 * 1024 * 1024):
|
||||||
|
"""GET returning the raw body as text (for HTML/JS). (text, status, error)."""
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
req.add_header("User-Agent", USER_AGENT)
|
||||||
|
if headers:
|
||||||
|
for k, v in headers.items():
|
||||||
|
req.add_header(k, v)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
|
||||||
|
return resp.read(limit).decode("utf-8", errors="replace"), resp.status, None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.read(limit).decode("utf-8", errors="replace"), e.code, None
|
||||||
|
except Exception as exc:
|
||||||
|
return None, e.code, "HTTP %s: %s" % (e.code, exc)
|
||||||
|
except Exception as e:
|
||||||
|
return None, 0, "request failed: %s" % e
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Atomic JSON storage with flock
|
# Atomic JSON storage with flock
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ exactly at midnight, so multi-slot retries matter).
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from common import http_post_json, http_get, log, now_ts
|
from common import http_post_json, http_get, log, now_ts
|
||||||
import umid
|
import umid
|
||||||
@@ -34,8 +35,15 @@ PATH_CAMPAIGNS = "/sash/api/v1/me/campaigns"
|
|||||||
PATH_CLAIM = "/sash/api/v1/me/campaigns/%s/claim"
|
PATH_CLAIM = "/sash/api/v1/me/campaigns/%s/claim"
|
||||||
PATH_REFRESH = "/api/v1/deviceToken/refresh"
|
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)
|
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 可能返回空数组)
|
# 客户端默认版本(社区实现实测值:缺 Cosy-Version 时 campaigns 可能返回空数组)
|
||||||
DEFAULT_VERSION = "0.3.4"
|
DEFAULT_VERSION = "0.3.4"
|
||||||
|
|
||||||
@@ -78,20 +86,27 @@ def device_headers(account):
|
|||||||
def risk_identity(account, force=False):
|
def risk_identity(account, force=False):
|
||||||
"""获取(并缓存到账号)设备风控身份 Cosy-MachineToken/Code/Type。
|
"""获取(并缓存到账号)设备风控身份 Cosy-MachineToken/Code/Type。
|
||||||
|
|
||||||
Qoder 服务端(尤其国际版)只对带风控身份的请求下发「每天领 100 Credits」活动。
|
Qoder 服务端只对带风控身份的请求下发「每天领 100 Credits」活动
|
||||||
优先用账号 device 块里已存的;缺失时调用本机 runtime-info 现场生成。
|
(国际版尤其严格)。身份必须按账号 uid 生成——uid 从活动接口响应里取,
|
||||||
|
所以调用前应先跑一次 query_campaigns()。
|
||||||
"""
|
"""
|
||||||
dev = account.get("device") or {}
|
dev = account.get("device") or {}
|
||||||
if not force and dev.get("machineToken") and dev.get("machineCode") and dev.get("machineType"):
|
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
|
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
|
||||||
provider = "qoder" if is_global(account.get("auth") or {}) else "qoder-cn"
|
provider = "qoder" if is_global(account.get("auth") or {}) else "qoder-cn"
|
||||||
ident, err = umid.risk_identity(provider, account.get("uid") or "")
|
ident, err = umid.risk_identity(provider, uid)
|
||||||
if err:
|
if err:
|
||||||
|
if have:
|
||||||
|
# 生成失败时退回旧身份,至少让请求能带上设备标识
|
||||||
|
return dev["machineToken"], dev["machineCode"], dev["machineType"], err
|
||||||
return None, None, None, err
|
return None, None, None, err
|
||||||
dev = dict(dev)
|
dev = dict(dev)
|
||||||
dev.update({"machineToken": ident["machineToken"],
|
dev.update({"machineToken": ident["machineToken"],
|
||||||
"machineCode": ident["machineCode"],
|
"machineCode": ident["machineCode"],
|
||||||
"machineType": ident["machineType"]})
|
"machineType": ident["machineType"],
|
||||||
|
"uid": uid})
|
||||||
account["device"] = dev
|
account["device"] = dev
|
||||||
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
|
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
|
||||||
|
|
||||||
@@ -214,9 +229,9 @@ def _api(account, path, method="GET", body=None):
|
|||||||
else http_get(billing_base(account.get("auth") or {}) + path, headers=_headers(account))
|
else http_get(billing_base(account.get("auth") or {}) + path, headers=_headers(account))
|
||||||
|
|
||||||
|
|
||||||
def query_campaigns(account):
|
def query_campaigns_full(account):
|
||||||
"""Returns (campaigns, error). campaigns is a list (possibly empty)."""
|
"""活动接口的完整响应(含 uid / showCampaign / claimable)。"""
|
||||||
data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET")
|
data, status, err = _api(account, PATH_CAMPAIGNS + "?clientType=10&forceRefresh=true", "GET")
|
||||||
if status == 401:
|
if status == 401:
|
||||||
return None, "no_session"
|
return None, "no_session"
|
||||||
if not isinstance(data, dict) or status != 200:
|
if not isinstance(data, dict) or status != 200:
|
||||||
@@ -224,6 +239,20 @@ def query_campaigns(account):
|
|||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
detail = data.get("message") or data.get("errorMessage") or ""
|
detail = data.get("message") or data.get("errorMessage") or ""
|
||||||
return None, "查询失败(HTTP %s)%s" % (status, (":" + str(detail)[:80]) if detail else "")
|
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")
|
campaigns = data.get("campaigns")
|
||||||
return (campaigns if isinstance(campaigns, list) else []), None
|
return (campaigns if isinstance(campaigns, list) else []), None
|
||||||
|
|
||||||
@@ -252,31 +281,37 @@ def campaigns_debug(account):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _benefits(campaigns):
|
def _window_open(c, now=None):
|
||||||
"""CLAIM_BENEFIT campaigns(活动/签到坑位)。
|
"""活动是否在有效期内(缺字段视为开放)。"""
|
||||||
|
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
|
||||||
|
|
||||||
注意:真实数据里 benefit 可能只有 kind 没有 amount(活动信息在别处),
|
|
||||||
所以只按 actionType 过滤;kind 明确非 CREDITS 时才排除。
|
def _benefits(campaigns):
|
||||||
|
"""可领取的签到轮次:只有 actionType == CLAIM_BENEFIT。
|
||||||
|
|
||||||
|
实测(2026-09-27 两区,本机复核 2026-10-19):
|
||||||
|
同一账号还挂着一条 actionType=VIEW_DETAILS 的订阅推广活动(无 benefit、
|
||||||
|
claimStatus 却是 CLAIMED)——只看 claimStatus 会把它当成签到活动去「领取」。
|
||||||
|
而 claimStatus 是判断「本轮是否已领」的唯一可靠字段:响应顶层的
|
||||||
|
claimable 在「今天已领」和「当前没有活动」两种情况下都是 false。
|
||||||
"""
|
"""
|
||||||
out = []
|
out = []
|
||||||
for c in campaigns:
|
for c in campaigns:
|
||||||
if not isinstance(c, dict):
|
if not isinstance(c, dict):
|
||||||
continue
|
continue
|
||||||
action = c.get("actionType")
|
if c.get("actionType") != CLAIM_BENEFIT_ACTION:
|
||||||
claim_status = c.get("claimStatus")
|
|
||||||
benefit = c.get("benefit")
|
|
||||||
kind = benefit.get("kind") if isinstance(benefit, dict) else None
|
|
||||||
# 判定规则(已实测):
|
|
||||||
# 国内版:actionType == CLAIM_BENEFIT 且 claimStatus 为 CLAIMABLE/CLAIMED
|
|
||||||
# 国际版:actionType 为 VIEW_DETAILS(点「领取」按钮才领),claimStatus 同样有效
|
|
||||||
is_benefit_action = action in ("CLAIM_BENEFIT", "VIEW_DETAILS")
|
|
||||||
has_claim_state = claim_status in ("CLAIMABLE", "CLAIMED")
|
|
||||||
if not (is_benefit_action or (action is None and has_claim_state)):
|
|
||||||
continue
|
continue
|
||||||
if not has_claim_state:
|
if not _window_open(c):
|
||||||
continue
|
|
||||||
# 只排除明确非积分权益(如实物/优惠券)
|
|
||||||
if kind and kind not in ("CREDITS",):
|
|
||||||
continue
|
continue
|
||||||
out.append(c)
|
out.append(c)
|
||||||
return out
|
return out
|
||||||
@@ -384,6 +419,8 @@ def checkin_once(account):
|
|||||||
if not (at and (not exp or exp > now_ts())):
|
if not (at and (not exp or exp > now_ts())):
|
||||||
return {"status": "no_session", "message": msg, "points": 0}
|
return {"status": "no_session", "message": msg, "points": 0}
|
||||||
|
|
||||||
|
# 两段式取活动:先裸取一次拿到 uid(风控身份按 uid 生成),
|
||||||
|
# 带上设备身份后再取一次——国际版只有带设备身份的请求才会下发每日活动。
|
||||||
campaigns, err = query_campaigns(account)
|
campaigns, err = query_campaigns(account)
|
||||||
if err == "no_session":
|
if err == "no_session":
|
||||||
ok2, msg2 = ensure_token(account, force=True)
|
ok2, msg2 = ensure_token(account, force=True)
|
||||||
@@ -402,10 +439,13 @@ def checkin_once(account):
|
|||||||
if err:
|
if err:
|
||||||
return {"status": "fail", "message": err, "points": 0}
|
return {"status": "fail", "message": err, "points": 0}
|
||||||
|
|
||||||
# Qoder 服务端只对带风控身份的请求下发签到活动——先确保身份就绪
|
|
||||||
tok, code, mtype, rerr = risk_identity(account)
|
tok, code, mtype, rerr = risk_identity(account)
|
||||||
if rerr:
|
if rerr:
|
||||||
log("qoder risk identity unavailable: %s" % rerr, "WARN")
|
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)
|
balance, berr = query_balance(account)
|
||||||
if berr == "no_session":
|
if berr == "no_session":
|
||||||
@@ -415,7 +455,9 @@ def checkin_once(account):
|
|||||||
balance = None
|
balance = None
|
||||||
|
|
||||||
benefits = _benefits(campaigns)
|
benefits = _benefits(campaigns)
|
||||||
claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"]
|
# claimStatus 未知时按「可领」处理:服务端领取接口幂等(replayed 不再发放),
|
||||||
|
# 乐观的代价是一次多余请求,保守的代价是把功能永久藏起来。
|
||||||
|
claimable = [c for c in benefits if c.get("claimStatus") != CLAIMED_STATUS]
|
||||||
|
|
||||||
if claimable:
|
if claimable:
|
||||||
got, fail = 0.0, 0
|
got, fail = 0.0, 0
|
||||||
@@ -447,10 +489,398 @@ def checkin_once(account):
|
|||||||
return {"status": "already", "message": "今日已领取", "points": 0,
|
return {"status": "already", "message": "今日已领取", "points": 0,
|
||||||
"credits": balance}
|
"credits": balance}
|
||||||
if not benefits:
|
if not benefits:
|
||||||
# 没有任何可识别活动:可能是活动未下发/已结束,也可能是 schema 变化
|
# 服务端只下发了 VIEW_DETAILS 类推广(或不含每日轮次)——国际版常见原因:
|
||||||
return {"status": "already",
|
# 活动按「登录过官方客户端的那台设备」下发,本机设备未被绑定。
|
||||||
"message": "未发现可领取活动(活动未下发/已结束,或需刷新风控身份)", "points": 0}
|
hint = ("未下发每日签到活动:服务端没有给本机设备下发 CLAIM_BENEFIT 轮次。"
|
||||||
return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0}
|
"国际版的活动按设备下发,可在「添加账号 → 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):
|
def probe_domains(account):
|
||||||
@@ -473,5 +903,103 @@ def probe_domains(account):
|
|||||||
return out
|
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):
|
def account_display(account):
|
||||||
return account.get("name") or account.get("uid") or "Qoder 账号"
|
return account.get("name") or account.get("uid") or "Qoder 账号"
|
||||||
|
|||||||
@@ -177,6 +177,62 @@ def install(force=False):
|
|||||||
# 调用
|
# 调用
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_runtime_info(env_code, account=""):
|
||||||
|
"""以指定 env 直接运行 runtime-info,返回 (完整输出 JSON, error)。
|
||||||
|
|
||||||
|
env_code: 0=国内版 ONLINE,3=国际版(客户端默认),4=国际版(CLI 默认)。
|
||||||
|
返回的 JSON 除 machineToken/Code/Type 外通常还有 vmInfo 等字段。
|
||||||
|
"""
|
||||||
|
info = installed()
|
||||||
|
if not info:
|
||||||
|
return None, "未安装 runtime-info(请先在设置里下载)"
|
||||||
|
try:
|
||||||
|
stdin_data = json.dumps({"account": str(account or "")}) + "\n"
|
||||||
|
p = subprocess.run([BIN_PATH, str(env_code), "--account-stdin"],
|
||||||
|
input=stdin_data, capture_output=True, text=True, timeout=30)
|
||||||
|
if p.returncode != 0 or not p.stdout.strip():
|
||||||
|
return None, "runtime-info 执行失败(退出码 %s)%s" % (p.returncode, (p.stderr or "")[:120])
|
||||||
|
line = p.stdout.strip().split("\n")[0]
|
||||||
|
data = json.loads(line)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, "runtime-info 输出不是 JSON 对象"
|
||||||
|
return data, None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, "runtime-info 执行超时"
|
||||||
|
except Exception as e:
|
||||||
|
return None, "runtime-info 调用异常:%s" % e
|
||||||
|
|
||||||
|
|
||||||
|
def run_runtime_info_raw(args, stdin_data=None):
|
||||||
|
"""按给定 argv / stdin 调用 runtime-info,返回 (JSON, error)。
|
||||||
|
|
||||||
|
用途:对照不同的 env 取值与「是否带 --account-stdin」(客户端 Linux 分支
|
||||||
|
只用 `runtime-info <env>`,完全不喂 stdin)。
|
||||||
|
"""
|
||||||
|
info = installed()
|
||||||
|
if not info:
|
||||||
|
return None, "未安装 runtime-info(请先在设置里下载)"
|
||||||
|
try:
|
||||||
|
argv = [BIN_PATH] + [str(a) for a in args]
|
||||||
|
if stdin_data is None:
|
||||||
|
p = subprocess.run(argv, stdin=subprocess.DEVNULL,
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
else:
|
||||||
|
p = subprocess.run(argv, input=stdin_data,
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
if p.returncode != 0 or not p.stdout.strip():
|
||||||
|
return None, "退出码 %s %s" % (p.returncode, (p.stderr or "")[:160])
|
||||||
|
line = p.stdout.strip().split("\n")[0]
|
||||||
|
data = json.loads(line)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, "输出不是 JSON 对象"
|
||||||
|
return data, None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, "超时"
|
||||||
|
except Exception as e:
|
||||||
|
return None, "异常:%s" % e
|
||||||
|
|
||||||
|
|
||||||
def risk_identity(provider="qoder", account="", force_install=False):
|
def risk_identity(provider="qoder", account="", force_install=False):
|
||||||
"""生成设备风控身份。返回 ({"machineToken","machineCode","machineType"}, error).
|
"""生成设备风控身份。返回 ({"machineToken","machineCode","machineType"}, error).
|
||||||
|
|
||||||
@@ -188,15 +244,9 @@ def risk_identity(provider="qoder", account="", force_install=False):
|
|||||||
if not ok:
|
if not ok:
|
||||||
return None, msg
|
return None, msg
|
||||||
info = installed()
|
info = installed()
|
||||||
env = str(RISK_ENV.get(provider, 0))
|
data, err = run_runtime_info(RISK_ENV.get(provider, 0), account)
|
||||||
try:
|
if err:
|
||||||
stdin_data = json.dumps({"account": str(account or "")}) + "\n"
|
return None, err
|
||||||
p = subprocess.run([BIN_PATH, env, "--account-stdin"],
|
|
||||||
input=stdin_data, capture_output=True, text=True, timeout=30)
|
|
||||||
if p.returncode != 0 or not p.stdout.strip():
|
|
||||||
return None, "runtime-info 执行失败(退出码 %s)%s" % (p.returncode, (p.stderr or "")[:120])
|
|
||||||
line = p.stdout.strip().split("\n")[0]
|
|
||||||
data = json.loads(line)
|
|
||||||
out = {}
|
out = {}
|
||||||
for k in ("machineToken", "machineCode", "machineType"):
|
for k in ("machineToken", "machineCode", "machineType"):
|
||||||
v = data.get(k)
|
v = data.get(k)
|
||||||
@@ -206,10 +256,6 @@ def risk_identity(provider="qoder", account="", force_install=False):
|
|||||||
if data.get("vmInfo"):
|
if data.get("vmInfo"):
|
||||||
out["vmInfo"] = data["vmInfo"]
|
out["vmInfo"] = data["vmInfo"]
|
||||||
return out, None
|
return out, None
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return None, "runtime-info 执行超时"
|
|
||||||
except Exception as e:
|
|
||||||
return None, "runtime-info 调用异常:%s" % e
|
|
||||||
|
|
||||||
|
|
||||||
def status():
|
def status():
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
fail: { label: "签到失败", cls: "fail", icon: "❌" },
|
fail: { label: "签到失败", cls: "fail", icon: "❌" },
|
||||||
no_session: { label: "需重新授权", cls: "no_session", icon: "🔒" },
|
no_session: { label: "需重新授权", cls: "no_session", icon: "🔒" },
|
||||||
no_auth: { label: "缺少凭据", cls: "no_auth", icon: "🔒" },
|
no_auth: { label: "缺少凭据", cls: "no_auth", icon: "🔒" },
|
||||||
retry_later: { label: "风控冷却", cls: "retry_later", icon: "⏳" }
|
retry_later: { label: "活动未下发·待重试", cls: "retry_later", icon: "⏳" }
|
||||||
};
|
};
|
||||||
|
|
||||||
function platformName(p) { return p === "trae" ? "TRAE" : (p === "qoder" ? "Qoder" : "WorkBuddy"); }
|
function platformName(p) { return p === "trae" ? "TRAE" : (p === "qoder" ? "Qoder" : "WorkBuddy"); }
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- add modal
|
// ---------------------------------------------------------------- add modal
|
||||||
function openModal() { $("modal-add").classList.remove("hidden"); }
|
function openModal() { $("modal-add").classList.remove("hidden"); }
|
||||||
function closeModal(id) { $(id).classList.add("hidden"); stopOauth(); $("add-msg").textContent = ""; }
|
function closeModal(id) { $(id).classList.add("hidden"); stopOauth(); stopQdLogin(); $("add-msg").textContent = ""; }
|
||||||
|
|
||||||
document.querySelectorAll("[data-close]").forEach(function (b) {
|
document.querySelectorAll("[data-close]").forEach(function (b) {
|
||||||
b.addEventListener("click", function () { closeModal(b.dataset.close); });
|
b.addEventListener("click", function () { closeModal(b.dataset.close); });
|
||||||
@@ -310,6 +310,65 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---- qoder 一键登录(OAuth 设备授权,绑定本机设备)
|
||||||
|
var qdState = null, qdTimer = null;
|
||||||
|
|
||||||
|
function stopQdLogin() {
|
||||||
|
if (qdTimer) { clearInterval(qdTimer); qdTimer = null; }
|
||||||
|
qdState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("btn-qoder-login").addEventListener("click", function () {
|
||||||
|
var realm = $("qoder-domain").value.indexOf("com.cn") >= 0 ? "cn" : "intl";
|
||||||
|
var btn = this;
|
||||||
|
stopQdLogin();
|
||||||
|
btn.disabled = true;
|
||||||
|
$("qoder-login-link").innerHTML = "";
|
||||||
|
$("qoder-login-status").textContent = "正在申请授权链接…";
|
||||||
|
api("POST", "/qoder/login/start", { realm: realm })
|
||||||
|
.then(function (d) {
|
||||||
|
qdState = d.state;
|
||||||
|
var box = $("qoder-login-link");
|
||||||
|
box.innerHTML = "";
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = d.auth_url;
|
||||||
|
a.target = "_blank";
|
||||||
|
a.rel = "noopener";
|
||||||
|
a.textContent = "👉 点这里打开 Qoder 授权页";
|
||||||
|
box.appendChild(a);
|
||||||
|
var p = document.createElement("div");
|
||||||
|
p.className = "muted";
|
||||||
|
p.textContent = "(或在浏览器打开:" + d.auth_url + ")";
|
||||||
|
box.appendChild(p);
|
||||||
|
$("qoder-login-status").textContent =
|
||||||
|
"等待浏览器里确认授权…(" + Math.round(d.ttl / 60) + " 分钟内有效)";
|
||||||
|
qdTimer = setInterval(pollQdLogin, 3000);
|
||||||
|
})
|
||||||
|
.catch(function (e) { $("qoder-login-status").textContent = e.message; })
|
||||||
|
.finally(function () { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
function pollQdLogin() {
|
||||||
|
if (!qdState) return;
|
||||||
|
api("GET", "/qoder/login/poll?state=" + encodeURIComponent(qdState))
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.status === "ok") {
|
||||||
|
stopQdLogin();
|
||||||
|
$("qoder-login-status").textContent = "登录成功";
|
||||||
|
toast(d.message || "Qoder 账号已添加", "ok");
|
||||||
|
closeModal("modal-add");
|
||||||
|
refreshState();
|
||||||
|
} else if (d.status === "error") {
|
||||||
|
stopQdLogin();
|
||||||
|
$("qoder-login-status").textContent = d.message || "登录失败";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
stopQdLogin();
|
||||||
|
$("qoder-login-status").textContent = e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$("btn-qoder-add").addEventListener("click", function () {
|
$("btn-qoder-add").addEventListener("click", function () {
|
||||||
var token = $("qoder-token").value.trim();
|
var token = $("qoder-token").value.trim();
|
||||||
if (!token) return addMsg("请填写 Access Token", false);
|
if (!token) return addMsg("请填写 Access Token", false);
|
||||||
|
|||||||
@@ -239,3 +239,9 @@ label .input { margin-top: 6px; }
|
|||||||
main { padding: 16px 12px 40px; }
|
main { padding: 16px 12px 40px; }
|
||||||
.tab { padding: 10px 12px; }
|
.tab { padding: 10px 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Qoder 一键登录(设备授权) */
|
||||||
|
.qoder-oauth { margin-bottom: 14px; background: var(--bg2, #f6f8fa); }
|
||||||
|
.qoder-oauth a { color: var(--accent, #2563eb); font-weight: 600; text-decoration: none; }
|
||||||
|
.qoder-oauth a:hover { text-decoration: underline; }
|
||||||
|
.qoder-oauth .msg { white-space: pre-wrap; word-break: break-all; }
|
||||||
|
|||||||
@@ -175,6 +175,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mtab-qoder" class="mtabpage">
|
<div id="mtab-qoder" class="mtabpage">
|
||||||
|
<div class="panel qoder-oauth">
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" id="btn-qoder-login">一键登录(设备授权,推荐)</button>
|
||||||
|
<span id="qoder-login-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<div id="qoder-login-link" class="msg"></div>
|
||||||
|
<p class="muted">点按钮 → 打开弹出的授权链接 → 在浏览器里确认一次即可自动加入账号。<b>不建议</b>再手工粘贴别处提取的 Token:国际版的「每天领 100 Credits」活动只下发给<b>这次登录的设备</b>,用别的机器提取的 Token 会看不到活动。</p>
|
||||||
|
</div>
|
||||||
<label>名称(可选)<input id="qoder-name" class="input input-wide" placeholder="Qoder 主账号"></label>
|
<label>名称(可选)<input id="qoder-name" class="input input-wide" placeholder="Qoder 主账号"></label>
|
||||||
<label>Access Token <span class="req">*</span><input id="qoder-token" class="input input-wide" placeholder="dt- 开头的 JWT"></label>
|
<label>Access Token <span class="req">*</span><input id="qoder-token" class="input input-wide" placeholder="dt- 开头的 JWT"></label>
|
||||||
<label>Refresh Token(可选,用于自动续期)<input id="qoder-refresh" class="input input-wide" placeholder="drt- 开头,可选"></label>
|
<label>Refresh Token(可选,用于自动续期)<input id="qoder-refresh" class="input input-wide" placeholder="drt- 开头,可选"></label>
|
||||||
@@ -187,8 +195,8 @@
|
|||||||
<details class="help" open>
|
<details class="help" open>
|
||||||
<summary>如何获取 Qoder Token?(三种方式)</summary>
|
<summary>如何获取 Qoder Token?(三种方式)</summary>
|
||||||
<div class="help-body">
|
<div class="help-body">
|
||||||
<p><b>方式 A · 设备授权登录(推荐,无需抓包)</b></p>
|
<p><b>方式 A · 一键登录(推荐,无需抓包)</b></p>
|
||||||
<p>在电脑上下载社区工具 <code>qoder_login.py</code>(GitHub: L0NE-6/Qoder-Daily),运行 <code>python qoder_login.py</code>,在浏览器打开授权页确认一次,工具会输出 <code>access_token|refresh_token</code>,分别粘贴到上方即可。</p>
|
<p>直接点上方「一键登录(设备授权)」,在浏览器里确认一次即可。这种方式拿到的 Token 与<b>本机设备绑定</b>,国际版才能看到「每天领 100 Credits」活动。</p>
|
||||||
<p><b>方式 B · 浏览器抓包(最快)</b></p>
|
<p><b>方式 B · 浏览器抓包(最快)</b></p>
|
||||||
<p>浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 点页面里任意功能,任选一个请求 → 在请求头里找到 <code>authorization: Bearer dt-...</code></p>
|
<p>浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 点页面里任意功能,任选一个请求 → 在请求头里找到 <code>authorization: Bearer dt-...</code></p>
|
||||||
<p><b>复制技巧</b>:右键该值 →「Copy value」复制完整内容,粘贴后删掉开头的 <code>Bearer </code>。</p>
|
<p><b>复制技巧</b>:右键该值 →「Copy value」复制完整内容,粘贴后删掉开头的 <code>Bearer </code>。</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
appname = AgentCheckin
|
appname = AgentCheckin
|
||||||
version = 1.3.3
|
version = 1.4.1
|
||||||
display_name = Agent 签到助手
|
display_name = Agent 签到助手
|
||||||
desc = AI 编程工具每日积分自动签到:支持 TRAE(字节跳动,国内版每日 200 积分)与 WorkBuddy / CodeBuddy(腾讯,7 天循环签到最高 1000 积分/天)。多账号管理、多时段自动补签、积分看板、签到日志、Webhook 通知,数据全部保存在 NAS 本地。
|
desc = AI 编程工具每日积分自动签到:TRAE(字节跳动)、WorkBuddy / CodeBuddy(腾讯)、Qoder(国内版 + 国际版每日 100 Credits)。多账号管理、多时段自动补签、积分看板、签到日志、Webhook 通知,数据全部保存在 NAS 本地。
|
||||||
platform = all
|
platform = all
|
||||||
source = thirdparty
|
source = thirdparty
|
||||||
maintainer = AgentCheckin
|
maintainer = AgentCheckin
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ Token 约 60 天有效,过期后在面板重新粘贴即可。
|
|||||||
|
|
||||||
| 方式 | 操作 | 适合 |
|
| 方式 | 操作 | 适合 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **A · 设备授权登录(推荐)** | 下载社区工具 `qoder_login.py`([L0NE-6/Qoder-Daily](https://github.com/L0NE-6/Qoder-Daily)),运行 `python qoder_login.py`,浏览器打开授权页确认一次,工具输出 `access_token\|refresh_token` | 所有人,无需抓包 |
|
| **A · 应用内一键登录(推荐)** | 添加账号 → Qoder → 点「一键登录(设备授权)」,浏览器里确认一次即可(PKCE S256 设备流)。**国际版必须走这条**:每日 100 Credits 只下发给登录过的那台设备 | 所有人,无需抓包 |
|
||||||
| **B · 浏览器抓包(最快)** | 浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 任选一个请求,看请求头 `authorization: Bearer dt-...`,`dt-` 后那串即 Access Token;若还能看到 `drt-...` 刷新串,一并填入可自动续期 | 会看开发者工具 |
|
| **B · 浏览器抓包(最快)** | 浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 任选一个请求,看请求头 `authorization: Bearer dt-...`,`dt-` 后那串即 Access Token;若还能看到 `drt-...` 刷新串,一并填入可自动续期 | 会看开发者工具 |
|
||||||
| **C · 本机客户端提取** | 在装了并登录 Qoder 客户端的 Windows 上运行 `01_extract.py`([wallechfox/qoder-checkin](https://github.com/wallechfox/qoder-checkin)),自动 DPAPI+AES-GCM 解密 `%APPDATA%\com.qodercn.app.*\auth.v1.dat` 取出 Token 和设备标识 | 需要设备标识时 |
|
| **C · 本机客户端提取** | 在装了并登录 Qoder 客户端的 Windows 上运行 `01_extract.py`([wallechfox/qoder-checkin](https://github.com/wallechfox/qoder-checkin)),自动 DPAPI+AES-GCM 解密 `%APPDATA%\com.qodercn.app.*\auth.v1.dat` 取出 Token 和设备标识 | 需要设备标识时 |
|
||||||
|
|
||||||
@@ -166,11 +166,19 @@ fnos-agent-checkin/
|
|||||||
> 2. **站点隔离**:国内版(qoder.cn 客户端)的 Token 只能用于 `openapi.qoder.com.cn`,
|
> 2. **站点隔离**:国内版(qoder.cn 客户端)的 Token 只能用于 `openapi.qoder.com.cn`,
|
||||||
> 国际版(qoder.com 客户端)的 Token 只能用于 `openapi.qoder.sh`,混用直接 401。
|
> 国际版(qoder.com 客户端)的 Token 只能用于 `openapi.qoder.sh`,混用直接 401。
|
||||||
> 面板的「修复站点」按钮会自动探测 Token 所属站点并切换。
|
> 面板的「修复站点」按钮会自动探测 Token 所属站点并切换。
|
||||||
> 3. **国际版需要设备风控身份**:国际版服务端**只对携带 `Cosy-MachineToken/Code/Type` 的请求下发
|
> 3. **只有 `actionType=CLAIM_BENEFIT` 才是签到轮次**:同一个账号还挂着一条
|
||||||
> 「每天领 100 Credits」活动**,否则活动列表返回空。本应用会自动从官方 npm 包
|
> `actionType=VIEW_DETAILS` 的订阅推广(无 benefit、`claimStatus` 却是 `CLAIMED`),
|
||||||
> `@qoder-ai/qodercli` 提取 UMID 组件(runtime-info)生成身份,无需手动配置。
|
> 只看 `claimStatus` 会把它误当成「今日已领取」。响应顶层的 `claimable` 也不可信——
|
||||||
> 4. **国际版活动 schema 与国内版不同**:国际版 `actionType=VIEW_DETAILS`(国内版 `CLAIM_BENEFIT`),
|
> 「今天已领」和「当前没有活动」它都返回 `false`。
|
||||||
> 但 `claimStatus`/`startAt`/`endAt` 一致;每日 10:00 (UTC+8) 刷新,领取后 30 天有效。
|
> 4. **国际版的每日 100 Credits 按「设备」下发**(实测 + 社区一致结论):服务端只把
|
||||||
|
> `CLAIM_BEFIT` 轮次下发给**登录过官方客户端的那台设备**;从 NAS 上另造一套
|
||||||
|
> `Cosy-MachineToken/Code/Type` 只能拿到常驻的 `VIEW_DETAILS` 广告,活动列表里
|
||||||
|
> 根本没有每日轮次(这就是「活动列表为空 / 看不到 100 Credits」的根因)。
|
||||||
|
> 本应用因此提供**「一键登录(设备授权,PKCE S256)」**:把登录绑定到本机,
|
||||||
|
> 之后才能看到并领取每日轮次;也支持把桌面客户端那台机器的
|
||||||
|
> `machineToken/Code/Type` 导入进来复用它的设备身份。
|
||||||
|
> 每日 10:00 (UTC+8) 刷新,错过不补,领取后 30 天有效;同一台设备每天只有一个
|
||||||
|
> 国际版账号能领到。
|
||||||
|
|
||||||
**TRAE(api.trae.cn)**
|
**TRAE(api.trae.cn)**
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ const BASE = `https://${HOST}:${PORT}`;
|
|||||||
const USER = process.env.FNOS_USER;
|
const USER = process.env.FNOS_USER;
|
||||||
const PASS = process.env.FNOS_PASS;
|
const PASS = process.env.FNOS_PASS;
|
||||||
const APPNAME = "AgentCheckin";
|
const APPNAME = "AgentCheckin";
|
||||||
const VERSION = "1.3.3";
|
const VERSION = "1.4.1";
|
||||||
const FPK = process.argv[2] ||
|
const FPK = process.argv[2] ||
|
||||||
path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk");
|
path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk");
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -216,9 +216,19 @@ def main():
|
|||||||
"print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n"
|
"print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n"
|
||||||
"# global detection\n"
|
"# global detection\n"
|
||||||
"print('global:', qoder.is_global({'domain': 'openapi.qoder.sh'}), qoder.is_global({'domain': 'openapi.qoder.com.cn'}))\n"
|
"print('global:', qoder.is_global({'domain': 'openapi.qoder.sh'}), qoder.is_global({'domain': 'openapi.qoder.com.cn'}))\n"
|
||||||
"# 国际版 schema: actionType=VIEW_DETAILS + claimStatus\n"
|
"# 国际版同一账号还挂着 VIEW_DETAILS 的订阅推广(claimStatus=CLAIMED,无 benefit)\n"
|
||||||
"intl = [{'actionType': 'VIEW_DETAILS', 'claimStatus': 'CLAIMABLE', 'campaignId': 'i1'}]\n"
|
"# —— 它不是签到活动,必须被排除\n"
|
||||||
|
"intl = [{'actionType': 'VIEW_DETAILS', 'claimStatus': 'CLAIMED', 'campaignId': 'i1'}]\n"
|
||||||
"print('intl_benefits:', len(qoder._benefits(intl)))\n"
|
"print('intl_benefits:', len(qoder._benefits(intl)))\n"
|
||||||
|
"# 每日轮次:actionType=CLAIM_BENEFIT、窗口开放\n"
|
||||||
|
"now0 = int(time.time())\n"
|
||||||
|
"daily = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'd1',\n"
|
||||||
|
" 'startAt': now0 - 60, 'endAt': now0 + 600}]\n"
|
||||||
|
"print('daily_benefits:', len(qoder._benefits(daily)))\n"
|
||||||
|
"# 过期轮次要排除\n"
|
||||||
|
"expired = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'd2',\n"
|
||||||
|
" 'startAt': now0 - 600, 'endAt': now0 - 60}]\n"
|
||||||
|
"print('expired_benefits:', len(qoder._benefits(expired)))\n"
|
||||||
"# 国内版 schema\n"
|
"# 国内版 schema\n"
|
||||||
"campaigns = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'c1', 'benefit': {'amount': 100}}]\n"
|
"campaigns = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'c1', 'benefit': {'amount': 100}}]\n"
|
||||||
"b = qoder._benefits(campaigns)\n"
|
"b = qoder._benefits(campaigns)\n"
|
||||||
@@ -235,7 +245,9 @@ def main():
|
|||||||
check("qoder global detection", len(lines) > 2 and lines[2] == "global: True False", out)
|
check("qoder global detection", len(lines) > 2 and lines[2] == "global: True False", out)
|
||||||
check("qoder campaign parsing", "benefits: 1 claimable: 1" in out, out)
|
check("qoder campaign parsing", "benefits: 1 claimable: 1" in out, out)
|
||||||
intl_line = next((l for l in lines if l.startswith("intl_benefits:")), "")
|
intl_line = next((l for l in lines if l.startswith("intl_benefits:")), "")
|
||||||
check("qoder intl schema (VIEW_DETAILS)", intl_line == "intl_benefits: 1", out)
|
check("qoder intl: VIEW_DETAILS excluded", intl_line == "intl_benefits: 0", out)
|
||||||
|
check("qoder daily CLAIM_BENEFIT kept", "daily_benefits: 1" in out, out)
|
||||||
|
check("qoder expired round excluded", "expired_benefits: 0" in out, out)
|
||||||
check("qoder already-claimed detection", "claimed_today: 1" in out, out)
|
check("qoder already-claimed detection", "claimed_today: 1" in out, out)
|
||||||
|
|
||||||
print("== 13. engine: add qoder account + token masking ==")
|
print("== 13. engine: add qoder account + token masking ==")
|
||||||
|
|||||||
Reference in New Issue
Block a user