From 556e45d642c7c997f35daa4523c3b35dd817824e Mon Sep 17 00:00:00 2001 From: AgentCheckin Date: Mon, 19 Oct 2026 12:49:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(qoder):=20=E5=BC=84=E6=B8=85=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E7=89=88=E6=AF=8F=E6=97=A5=20100=20Credits=20?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E6=8C=89=E8=AE=BE=E5=A4=87=E4=B8=8B=E5=8F=91?= =?UTF-8?q?=E3=80=8D=E8=A7=84=E5=88=99=20+=20=E4=B8=80=E9=94=AE=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8E=88=E6=9D=83=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查结论(本机对真实接口做了 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 前端全绿 --- AgentCheckin/app/backend/api.py | 187 ++++++++ AgentCheckin/app/backend/common.py | 19 + AgentCheckin/app/backend/qoder.py | 592 ++++++++++++++++++++++++-- AgentCheckin/app/backend/umid.py | 90 +++- AgentCheckin/app/www/assets/app.js | 63 ++- AgentCheckin/app/www/assets/style.css | 6 + AgentCheckin/app/www/index.html | 12 +- AgentCheckin/manifest | 4 +- README.md | 20 +- fnos-install.js | 2 +- test_backend.py | 18 +- 11 files changed, 943 insertions(+), 70 deletions(-) diff --git a/AgentCheckin/app/backend/api.py b/AgentCheckin/app/backend/api.py index 4af371c..784c990 100644 --- a/AgentCheckin/app/backend/api.py +++ b/AgentCheckin/app/backend/api.py @@ -347,6 +347,185 @@ def h_logs(params, _body): 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): """Diagnostics: resolved paths, env, daemon liveness (no secrets).""" import common as _c @@ -390,6 +569,14 @@ ROUTES = { ("GET", "/api/umid/status"): h_umid_status, ("POST", "/api/umid/install"): h_umid_install, ("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, ("POST", "/api/settings"): h_settings_post, ("GET", "/api/logs"): h_logs, diff --git a/AgentCheckin/app/backend/common.py b/AgentCheckin/app/backend/common.py index e1d1406..65ceffa 100644 --- a/AgentCheckin/app/backend/common.py +++ b/AgentCheckin/app/backend/common.py @@ -224,6 +224,25 @@ def http_get(url, headers=None, timeout=20): 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 # --------------------------------------------------------------------------- diff --git a/AgentCheckin/app/backend/qoder.py b/AgentCheckin/app/backend/qoder.py index 19089bf..53bce32 100644 --- a/AgentCheckin/app/backend/qoder.py +++ b/AgentCheckin/app/backend/qoder.py @@ -24,6 +24,7 @@ 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 @@ -34,8 +35,15 @@ 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" @@ -78,20 +86,27 @@ def device_headers(account): def risk_identity(account, force=False): """获取(并缓存到账号)设备风控身份 Cosy-MachineToken/Code/Type。 - Qoder 服务端(尤其国际版)只对带风控身份的请求下发「每天领 100 Credits」活动。 - 优先用账号 device 块里已存的;缺失时调用本机 runtime-info 现场生成。 + Qoder 服务端只对带风控身份的请求下发「每天领 100 Credits」活动 + (国际版尤其严格)。身份必须按账号 uid 生成——uid 从活动接口响应里取, + 所以调用前应先跑一次 query_campaigns()。 """ 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 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 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"]}) + "machineType": ident["machineType"], + "uid": uid}) account["device"] = dev 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)) -def query_campaigns(account): - """Returns (campaigns, error). campaigns is a list (possibly empty).""" - data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET") +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: @@ -224,6 +239,20 @@ def query_campaigns(account): 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 @@ -252,31 +281,37 @@ def campaigns_debug(account): return out -def _benefits(campaigns): - """CLAIM_BENEFIT campaigns(活动/签到坑位)。 +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 - 注意:真实数据里 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 = [] for c in campaigns: if not isinstance(c, dict): continue - action = c.get("actionType") - 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)): + if c.get("actionType") != CLAIM_BENEFIT_ACTION: continue - if not has_claim_state: - continue - # 只排除明确非积分权益(如实物/优惠券) - if kind and kind not in ("CREDITS",): + if not _window_open(c): continue out.append(c) return out @@ -384,6 +419,8 @@ def checkin_once(account): 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) @@ -402,10 +439,13 @@ def checkin_once(account): if err: return {"status": "fail", "message": err, "points": 0} - # Qoder 服务端只对带风控身份的请求下发签到活动——先确保身份就绪 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": @@ -415,7 +455,9 @@ def checkin_once(account): balance = None 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: got, fail = 0.0, 0 @@ -447,10 +489,398 @@ def checkin_once(account): return {"status": "already", "message": "今日已领取", "points": 0, "credits": balance} if not benefits: - # 没有任何可识别活动:可能是活动未下发/已结束,也可能是 schema 变化 - return {"status": "already", - "message": "未发现可领取活动(活动未下发/已结束,或需刷新风控身份)", "points": 0} - return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0} + # 服务端只下发了 VIEW_DETAILS 类推广(或不含每日轮次)——国际版常见原因: + # 活动按「登录过官方客户端的那台设备」下发,本机设备未被绑定。 + hint = ("未下发每日签到活动:服务端没有给本机设备下发 CLAIM_BENEFIT 轮次。" + "国际版的活动按设备下发,可在「添加账号 → Qoder → 一键登录」用设备授权" + "把本机绑定到账号后重试。") + return {"status": "retry_later", "message": hint, "points": 0, "credits": balance} + return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0, + "credits": balance} + + +# --------------------------------------------------------------------------- +# 诊断:原始响应(排查活动 schema / claimStatus 语义差异) +# --------------------------------------------------------------------------- + +def _raw_call(account, path, method="GET", body=None): + """一次 API 调用,返回 {status,error,body}(body 为服务端原始 JSON)。""" + try: + data, status, err = _api(account, path, method, body) + except Exception as e: # pragma: no cover - 网络异常 + return {"status": 0, "error": str(e)[:200]} + out = {"status": status} + if err: + out["error"] = str(err)[:200] + out["body"] = data + return out + + +def _collect_campaign_ids(obj, into): + if isinstance(obj, dict): + for k, v in obj.items(): + if k == "campaignId" and isinstance(v, str) and v: + into.append(v) + else: + _collect_campaign_ids(v, into) + elif isinstance(obj, list): + for it in obj: + _collect_campaign_ids(it, into) + + +def raw_probe(account): + """转储 Qoder 接口的原始 JSON(排查 claimStatus 语义 / 活动 schema)。 + + 会先记录余额,再对窗口内的活动调用一次领取接口(官方接口幂等, + 重复领取返回 replayed,不会重复发放),最后记录余额变化。 + """ + out = {"headers": {}, "domain": (account.get("auth") or {}).get("domain")} + for k, v in _headers(account).items(): + out["headers"][k] = ("Bearer " if k == "Authorization" + else ("" % len(str(v)) if v else "")) + camp = _raw_call(account, PATH_CAMPAIGNS + "?forceRefresh=true") + out["campaigns"] = camp + out["campaigns_no_force"] = _raw_call(account, PATH_CAMPAIGNS) + out["campaigns_v2"] = _raw_call(account, "/sash/api/v2/me/campaigns?forceRefresh=true") + cids = [] + _collect_campaign_ids(camp.get("body"), cids) + cids = list(dict.fromkeys(cids)) + out["campaign_ids"] = cids + out["usage"] = _raw_call(account, USAGE_PATH) + b0, _ = query_balance(account) + out["balance_before"] = b0 + if cids: + cid = cids[0] + out["campaign_detail"] = _raw_call(account, "/sash/api/v1/me/campaigns/" + cid) + out["claim"] = _raw_call(account, PATH_CLAIM % cid, "POST", body={}) + time.sleep(1.5) + out["balance_after"] = query_balance(account)[0] + out["usage_after"] = _raw_call(account, USAGE_PATH) + out["campaigns_after_claim"] = _raw_call(account, PATH_CAMPAIGNS + "?forceRefresh=true") + return out + + +def _summ(data): + """活动响应摘要(不含 machineToken 等敏感值)。""" + if not isinstance(data, dict): + return {"raw": str(data)[:200]} + out = {"uid": data.get("uid"), "showCampaign": data.get("showCampaign"), + "claimable": data.get("claimable")} + cs = data.get("campaigns") + if isinstance(cs, list): + out["count"] = len(cs) + out["campaigns"] = [{ + "key": c.get("campaignKey"), + "actionType": c.get("actionType"), + "claimStatus": c.get("claimStatus"), + "placements": [p.get("type") for p in (c.get("placements") or []) if isinstance(p, dict)], + } for c in cs if isinstance(c, dict)] + return out + + +def _base_headers(account, risk=False): + """基础请求头;risk=False 时不带任何 Cosy 设备/风控头。""" + h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"} + tok = (account.get("auth") or {}).get("accessToken", "") + if tok: + h["Authorization"] = "Bearer %s" % tok + if risk: + h["Cosy-ClientType"] = "10" + h["Cosy-Version"] = DEFAULT_VERSION + return h + + +def checkin_experiment(account): + """定位「每日 100 Credits」活动下发条件的对照实验(只读,不领取)。""" + base = billing_base(account.get("auth") or {}) + out = {"domain": base} + + # 1) 不带风控身份取一次,拿到 uid / showCampaign / claimable + plain, st, err = http_get(base + PATH_CAMPAIGNS, headers=_base_headers(account, risk=True)) + out["step1_plain"] = {"status": st, "error": err, "summary": _summ(plain)} + uid = (plain or {}).get("uid") or account.get("uid") or "" + out["uid"] = uid + + # 2) 不同 env 生成的身份(看输出字段差异) + idents = {} + for env in (0, 3, 4): + j, e = umid.run_runtime_info(env, uid) + idents["env%s" % env] = { + "error": e, + "fields": sorted(j.keys()) if isinstance(j, dict) else None, + "preview": {k: (str(v)[:100] if isinstance(v, str) else v) + for k, v in (j or {}).items() if k != "machineToken"}, + "token_len": len((j or {}).get("machineToken") or ""), + } + out["identities"] = idents + + # 3) 组合对照:env × 是否带 Cosy-MachineId/OS/Hostname × 是否带 clientType 查询参数 + m_id = str(uuid.uuid4()) + out["machine_id_used"] = m_id + combos = {} + for env in (3, 4): + j, e = umid.run_runtime_info(env, uid) + if e or not isinstance(j, dict): + combos["env%s" % env] = {"error": e} + continue + for mode in ("token_only", "full"): + h = _base_headers(account, risk=True) + h["Cosy-MachineToken"] = j.get("machineToken", "") + h["Cosy-MachineCode"] = j.get("machineCode", "") + h["Cosy-MachineType"] = j.get("machineType", "") + if mode == "full": + h["Cosy-MachineId"] = m_id + h["Cosy-MachineOS"] = "x86_64_linux" + h["Cosy-MachineHostname"] = "fnOS" + for qs, tag in (("", "noq"), ("?clientType=10", "ct10")): + d2, s2, e2 = http_get(base + PATH_CAMPAIGNS + qs, headers=h) + combos["env%s_%s_%s" % (env, mode, tag)] = { + "status": s2, "error": e2, "summary": _summ(d2)} + out["combos"] = combos + return out + + +def _snips(text, pattern, limit, width): + """返回 pattern 命中处 ±width 字符的片段(去重、限量)。""" + import re + seen, out = set(), [] + for m in re.finditer(pattern, text): + a = max(0, m.start() - width) + b = min(len(text), m.end() + width) + s = text[a:b] + key = s[:120] + if key in seen: + continue + seen.add(key) + out.append(s) + if len(out) >= limit: + break + return out + + +def growth_probe(account): + """扒「活动 iframe」页面与其 JS,找出每日签到真正调用的接口。""" + import re + from urllib.parse import urljoin + from common import http_get_text + + out = {} + html, status, err = http_get_text(GROWTH_URL, headers={"Accept": "text/html"}) + out["page"] = {"status": status, "error": err, "len": len(html or ""), + "head": (html or "")[:2000]} + srcs = re.findall(r']+src=["\']([^"\']+)["\']', html or "", re.I) + out["scripts"] = srcs[:8] + bundles = {} + for s in srcs[:4]: + u = urljoin(GROWTH_URL, s) + js, st, e = http_get_text(u, headers={"Accept": "*/*"}) + body = js or "" + paths = sorted(set(re.findall( + r'["\'`](/(?:sash|api|growth|oauth)[A-Za-z0-9_\-/{}.$:]*?)["\'`]', body))) + hits = sorted(set(re.findall( + r'[A-Za-z0-9_./-]*(?:checkin|dailyCheck|daily_credit|claim|credit|campaign)' + r'[A-Za-z0-9_./-]*', body, re.I))) + info = {"status": st, "error": e, "len": len(body), + "paths": paths[:120], "keywords": hits[:120]} + if "/sash/api/v1/me/campaigns" in body: + # 前端构造请求的上下文(请求头 / fetch 选项) + info["api_snips"] = _snips(body, r'/sash/api/v1/me/campaigns', 1, 700) + info["cosy_names"] = sorted(set(re.findall(r'Cosy-[A-Za-z0-9-]+', body))) + info["cosy_snips"] = _snips(body, r'Cosy-[A-Za-z0-9-]+', 6, 300) + info["hdr_snips"] = _snips(body, r'Authorization|machineToken|credentials', 6, 260) + bundles[u] = info + out["bundles"] = bundles + + # 候选接口探测(404 = 该路径不存在,其它状态码值得注意) + base = billing_base(account.get("auth") or {}) + h = _headers(account) + cands = [ + "/sash/api/v1/me/campaigns?clientType=10&forceRefresh=true", + "/sash/api/v1/me/campaigns?clientType=10&placement=USAGE", + "/sash/api/v1/me/dailyCheckin", "/sash/api/v1/me/daily-checkin", + "/sash/api/v1/me/checkin", "/sash/api/v1/me/check-in", + "/sash/api/v1/me/rewards", "/sash/api/v1/me/benefits", + "/sash/api/v1/me/campaigns/claimable", + "/sash/api/v1/growth/campaigns", "/sash/api/v1/me/growth/campaigns", + "/sash/api/v2/me/campaigns", "/sash/api/v1/me/credits/daily", + "/api/v2/me/usage", "/sash/api/v1/me/daily-credits", + ] + out["paths"] = {} + for p in cands: + d, st, e = http_get(base + p, headers=h) + out["paths"][p] = {"status": st, "error": e, + "summary": _summ(d) if isinstance(d, dict) else None} + return out + + +DAILY_ACTION_HINT = "CLAIM_BENEFIT" + + +def identity_matrix_probe(account): + """对照实验:哪种 umid 调用方式 + 哪种请求头组合能拿到 CLAIM_BENEFIT。 + + 只读,不会领取(发现 CLAIMABLE 也只报告)。 + """ + base = billing_base(account.get("auth") or {}) + tok = (account.get("auth") or {}).get("accessToken", "") + auth = {"Authorization": "Bearer %s" % tok} if tok else {} + out = {} + + # 1) 裸 bearer 拿 uid + plain_hdr = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", + "Cosy-ClientType": "10"}) + d, st, e = http_get(base + PATH_CAMPAIGNS, headers=plain_hdr) + out["plain"] = {"status": st, "error": e, "summary": _summ(d)} + uid = (d or {}).get("uid") or account.get("uid") or "" + out["uid"] = uid + + # 2) 各种 umid 调用方式 + stdin = json.dumps({"account": uid}) + "\n" + modes = { + "env4_stdin": (["4", "--account-stdin"], stdin), + "env4_bare": (["4"], None), + "env3_stdin": (["3", "--account-stdin"], stdin), + "env3_bare": (["3"], None), + "prod_stdin": (["prod", "--account-stdin"], stdin), + "prod_bare": (["prod"], None), + "noarg": ([], None), + } + idents = {} + for name, (args, sd) in modes.items(): + j, err = umid.run_runtime_info_raw(args, sd) + idents[name] = {"error": err, + "token_len": len((j or {}).get("machineToken") or ""), + "code": (j or {}).get("machineCode"), + "type": (j or {}).get("machineType"), + "vm": (j or {}).get("vmInfo")} + out["identities"] = idents + + # 3) 各身份 × 各请求头组合 + def _hdr(ident, extra): + h = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", + "Cosy-ClientType": "10", "Cosy-Version": DEFAULT_VERSION}) + if ident: + h["Cosy-MachineToken"] = ident.get("machineToken", "") + h["Cosy-MachineCode"] = ident.get("machineCode", "") + h["Cosy-MachineType"] = ident.get("machineType", "") + h.update(extra) + return h + + web_extra = {"Origin": "https://qoder.com", "Referer": "https://qoder.com/usage"} + x_extra = {"X-Request-ID": str(uuid.uuid4()), "X-Machine-ID": str(uuid.uuid4()), + "X-Session-ID": str(uuid.uuid4())} + combos = {} + tried = [] + for name in ("env4_bare", "env4_stdin", "prod_stdin", "noarg"): + j, _ = umid.run_runtime_info_raw(*modes[name]) + if not j: + continue + for label, extra in (("min", {}), ("web", web_extra), ("x", x_extra), + ("webx", dict(web_extra, **x_extra))): + h = _hdr(j, extra) + for qs in ("", "?clientType=10"): + key = "%s_%s%s" % (name, label, qs and "_ct10" or "") + if key in combos: + continue + d2, s2, e2 = http_get(base + PATH_CAMPAIGNS + qs, headers=h) + s = _summ(d2) + combos[key] = {"status": s2, "error": e2, "summary": s} + tried.append((key, s)) + out["combos"] = combos + + # 4) 命中 CLAIM_BENEFIT 的(不领取,只报告) + hits = [] + for key, s in tried: + for c in (s.get("campaigns") or []): + if c.get("actionType") == DAILY_ACTION_HINT: + hits.append({"combo": key, "campaign": c, + "top_claimable": s.get("claimable")}) + out["hits"] = hits + return out + + +def _derive_machine_id(uid): + """官方桌面端同款 machineId(社区实现:md5("machine:"+uid)[:36])。""" + import hashlib + return hashlib.md5(("machine:%s" % uid).encode("utf-8")).hexdigest()[:36] + + +def desktop_emulation_probe(account): + """模拟官方桌面端出站头,寻找能拿到 CLAIM_BENEFIT 的组合(只读)。""" + base = billing_base(account.get("auth") or {}) + tok = (account.get("auth") or {}).get("accessToken", "") + auth = {"Authorization": "Bearer %s" % tok} if tok else {} + + d, st, e = http_get(base + PATH_CAMPAIGNS, headers=dict(auth, **{ + "Accept": "application/json", "User-Agent": "Qoder", "Cosy-ClientType": "10"})) + uid = (d or {}).get("uid") or account.get("uid") or "" + out = {"uid": uid, "plain": {"status": st, "error": e, "summary": _summ(d)}} + + ident, ierr = umid.risk_identity("qoder", uid) + out["identity_error"] = ierr + out["identity"] = {k: (str(v)[:24] if isinstance(v, str) else v) + for k, v in (ident or {}).items()} + + mid = _derive_machine_id(uid) + out["machine_id"] = mid + umid_hdr = {} + if ident: + umid_hdr = {"Cosy-MachineToken": ident["machineToken"], + "Cosy-MachineCode": ident["machineCode"], + "Cosy-MachineType": ident["machineType"]} + win_hdr = {"Cosy-MachineOS": "x86_64_win32", "Cosy-MachineHostname": "DESKTOP-QODER", + "Cosy-MachineId": mid} + web = {"Origin": "https://qoder.com", "Referer": "https://qoder.com/"} + + variants = { + # 官方桌面端同款:全套六头 + win32 外形 + "desktop_full": dict(umid_hdr, **win_hdr), + "desktop_full_web": dict(umid_hdr, **dict(win_hdr, **web)), + # 去掉 machineId / machineToken(issue #10 的「去掉即恢复」) + "no_machineid": dict(umid_hdr, **{k: v for k, v in win_hdr.items() + if k != "Cosy-MachineId"}), + "no_token": dict({k: v for k, v in umid_hdr.items() if k != "Cosy-MachineToken"}, + **win_hdr), + # 只发部分 + "only_machineid": {"Cosy-MachineId": mid}, + "only_os_host": {"Cosy-MachineOS": "x86_64_win32", + "Cosy-MachineHostname": "DESKTOP-QODER"}, + "umid_only": dict(umid_hdr), + # umid + OS(不带 machineId / hostname) + "umid_os": dict(umid_hdr, **{"Cosy-MachineOS": "x86_64_win32"}), + } + out["combos"] = {} + hits = [] + for name, extra in variants.items(): + h = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", + "Cosy-ClientType": "10", "Cosy-Version": "0.4.3"}) + h.update(extra) + for qs in ("", "?clientType=10"): + d2, s2, e2 = http_get(base + PATH_CAMPAIGNS + qs, headers=h) + s = _summ(d2) + key = name + ("_ct10" if qs else "") + out["combos"][key] = {"status": s2, "error": e2, "summary": s} + for c in (s.get("campaigns") or []): + if c.get("actionType") == DAILY_ACTION_HINT: + hits.append({"combo": key, "campaign": c, + "claimable": s.get("claimable")}) + out["hits"] = hits + + # 其它可能暴露资格/成就的接口(只读) + extra_paths = ["/sash/api/v1/me/achievements", "/api/v2/quota/usage", + "/sash/api/v1/me/campaigns/%s/reward" + % "01a05bce-e800-7494-a002-806e4438f483"] + out["extra"] = {} + h = dict(auth, **{"Accept": "application/json", "User-Agent": "Qoder", + "Cosy-ClientType": "10", "Cosy-Version": "0.4.3"}) + h.update(umid_hdr) + for p in extra_paths: + d3, s3, e3 = http_get(base + p, headers=h) + body = d3 if isinstance(d3, dict) else None + if body is not None and len(json.dumps(body, ensure_ascii=False)) > 6000: + body = {"_truncated": True, "_keys": list(body.keys())[:20]} + out["extra"][p] = {"status": s3, "error": e3, "body": body} + return out def probe_domains(account): @@ -473,5 +903,103 @@ def probe_domains(account): 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 账号" diff --git a/AgentCheckin/app/backend/umid.py b/AgentCheckin/app/backend/umid.py index a915034..3af6110 100644 --- a/AgentCheckin/app/backend/umid.py +++ b/AgentCheckin/app/backend/umid.py @@ -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 `,完全不喂 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): """生成设备风控身份。返回 ({"machineToken","machineCode","machineType"}, error). @@ -188,28 +244,18 @@ def risk_identity(provider="qoder", account="", force_install=False): if not ok: return None, msg info = installed() - env = str(RISK_ENV.get(provider, 0)) - try: - stdin_data = json.dumps({"account": str(account or "")}) + "\n" - 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 = {} - for k in ("machineToken", "machineCode", "machineType"): - v = data.get(k) - if not isinstance(v, str) or not v.strip(): - return None, "runtime-info 输出缺少 %s" % k - out[k] = v.strip() - if data.get("vmInfo"): - out["vmInfo"] = data["vmInfo"] - return out, None - except subprocess.TimeoutExpired: - return None, "runtime-info 执行超时" - except Exception as e: - return None, "runtime-info 调用异常:%s" % e + data, err = run_runtime_info(RISK_ENV.get(provider, 0), account) + if err: + return None, err + out = {} + for k in ("machineToken", "machineCode", "machineType"): + v = data.get(k) + if not isinstance(v, str) or not v.strip(): + return None, "runtime-info 输出缺少 %s" % k + out[k] = v.strip() + if data.get("vmInfo"): + out["vmInfo"] = data["vmInfo"] + return out, None def status(): diff --git a/AgentCheckin/app/www/assets/app.js b/AgentCheckin/app/www/assets/app.js index 5375f68..cad6e1c 100644 --- a/AgentCheckin/app/www/assets/app.js +++ b/AgentCheckin/app/www/assets/app.js @@ -47,7 +47,7 @@ fail: { label: "签到失败", cls: "fail", icon: "❌" }, no_session: { label: "需重新授权", cls: "no_session", 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"); } @@ -256,7 +256,7 @@ // ---------------------------------------------------------------- add modal 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) { 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 () { var token = $("qoder-token").value.trim(); if (!token) return addMsg("请填写 Access Token", false); diff --git a/AgentCheckin/app/www/assets/style.css b/AgentCheckin/app/www/assets/style.css index f257230..9faff19 100644 --- a/AgentCheckin/app/www/assets/style.css +++ b/AgentCheckin/app/www/assets/style.css @@ -239,3 +239,9 @@ label .input { margin-top: 6px; } main { padding: 16px 12px 40px; } .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; } diff --git a/AgentCheckin/app/www/index.html b/AgentCheckin/app/www/index.html index e56e42b..b94dbac 100644 --- a/AgentCheckin/app/www/index.html +++ b/AgentCheckin/app/www/index.html @@ -175,6 +175,14 @@
+
+
+ + +
+ +

点按钮 → 打开弹出的授权链接 → 在浏览器里确认一次即可自动加入账号。不建议再手工粘贴别处提取的 Token:国际版的「每天领 100 Credits」活动只下发给这次登录的设备,用别的机器提取的 Token 会看不到活动。

+
@@ -187,8 +195,8 @@
如何获取 Qoder Token?(三种方式)
-

方式 A · 设备授权登录(推荐,无需抓包)

-

在电脑上下载社区工具 qoder_login.py(GitHub: L0NE-6/Qoder-Daily),运行 python qoder_login.py,在浏览器打开授权页确认一次,工具会输出 access_token|refresh_token,分别粘贴到上方即可。

+

方式 A · 一键登录(推荐,无需抓包)

+

直接点上方「一键登录(设备授权)」,在浏览器里确认一次即可。这种方式拿到的 Token 与本机设备绑定,国际版才能看到「每天领 100 Credits」活动。

方式 B · 浏览器抓包(最快)

浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 点页面里任意功能,任选一个请求 → 在请求头里找到 authorization: Bearer dt-...

复制技巧:右键该值 →「Copy value」复制完整内容,粘贴后删掉开头的 Bearer 。

diff --git a/AgentCheckin/manifest b/AgentCheckin/manifest index 3071945..198a1d1 100644 --- a/AgentCheckin/manifest +++ b/AgentCheckin/manifest @@ -1,7 +1,7 @@ appname = AgentCheckin -version = 1.3.3 +version = 1.4.1 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 source = thirdparty maintainer = AgentCheckin diff --git a/README.md b/README.md index b8a6fdc..3cae34a 100644 --- a/README.md +++ b/README.md @@ -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-...` 刷新串,一并填入可自动续期 | 会看开发者工具 | | **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`, > 国际版(qoder.com 客户端)的 Token 只能用于 `openapi.qoder.sh`,混用直接 401。 > 面板的「修复站点」按钮会自动探测 Token 所属站点并切换。 -> 3. **国际版需要设备风控身份**:国际版服务端**只对携带 `Cosy-MachineToken/Code/Type` 的请求下发 -> 「每天领 100 Credits」活动**,否则活动列表返回空。本应用会自动从官方 npm 包 -> `@qoder-ai/qodercli` 提取 UMID 组件(runtime-info)生成身份,无需手动配置。 -> 4. **国际版活动 schema 与国内版不同**:国际版 `actionType=VIEW_DETAILS`(国内版 `CLAIM_BENEFIT`), -> 但 `claimStatus`/`startAt`/`endAt` 一致;每日 10:00 (UTC+8) 刷新,领取后 30 天有效。 +> 3. **只有 `actionType=CLAIM_BENEFIT` 才是签到轮次**:同一个账号还挂着一条 +> `actionType=VIEW_DETAILS` 的订阅推广(无 benefit、`claimStatus` 却是 `CLAIMED`), +> 只看 `claimStatus` 会把它误当成「今日已领取」。响应顶层的 `claimable` 也不可信—— +> 「今天已领」和「当前没有活动」它都返回 `false`。 +> 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)** diff --git a/fnos-install.js b/fnos-install.js index f15d4bc..f6f0539 100644 --- a/fnos-install.js +++ b/fnos-install.js @@ -27,7 +27,7 @@ const BASE = `https://${HOST}:${PORT}`; const USER = process.env.FNOS_USER; const PASS = process.env.FNOS_PASS; const APPNAME = "AgentCheckin"; -const VERSION = "1.3.3"; +const VERSION = "1.4.1"; const FPK = process.argv[2] || path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk"); diff --git a/test_backend.py b/test_backend.py index f2d1441..db61198 100644 --- a/test_backend.py +++ b/test_backend.py @@ -216,9 +216,19 @@ def main(): "print('exp:', qoder.jwt_exp(tok), 'uid:', qoder.jwt_uid(tok))\n" "# global detection\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" - "intl = [{'actionType': 'VIEW_DETAILS', 'claimStatus': 'CLAIMABLE', 'campaignId': 'i1'}]\n" + "# 国际版同一账号还挂着 VIEW_DETAILS 的订阅推广(claimStatus=CLAIMED,无 benefit)\n" + "# —— 它不是签到活动,必须被排除\n" + "intl = [{'actionType': 'VIEW_DETAILS', 'claimStatus': 'CLAIMED', 'campaignId': 'i1'}]\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" "campaigns = [{'actionType': 'CLAIM_BENEFIT', 'claimStatus': 'CLAIMABLE', 'campaignId': 'c1', 'benefit': {'amount': 100}}]\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 campaign parsing", "benefits: 1 claimable: 1" in out, out) 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) print("== 13. engine: add qoder account + token masking ==")