2026-10-13 19:09:48 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""Qoder daily check-in client (CN edition).
|
|
|
|
|
|
|
|
|
|
|
|
Endpoints (reverse-engineered from the Qoder desktop client, see community
|
|
|
|
|
|
projects wallechfox/qoder-checkin and L0NE-6/Qoder-Daily):
|
|
|
|
|
|
|
|
|
|
|
|
CN base https://openapi.qoder.com.cn
|
|
|
|
|
|
Global base https://openapi.qoder.sh
|
|
|
|
|
|
|
|
|
|
|
|
campaigns GET /sash/api/v1/me/campaigns
|
|
|
|
|
|
claim POST /sash/api/v1/me/campaigns/{cid}/claim
|
|
|
|
|
|
refresh POST /api/v1/deviceToken/refresh body {"refresh_token": ...}
|
|
|
|
|
|
|
|
|
|
|
|
Auth: Authorization: Bearer <accessToken> plus Cosy-* device headers
|
|
|
|
|
|
(machineId / machineToken / machineCode / machineType / clientType ...).
|
|
|
|
|
|
|
|
|
|
|
|
Campaign shape: campaigns[] entries with actionType == "CLAIM_BENEFIT" and
|
|
|
|
|
|
claimStatus in ("CLAIMABLE", "CLAIMED"); benefit.amount is the credit grant.
|
|
|
|
|
|
|
|
|
|
|
|
Result codes used across the app: ok / already / fail / no_session /
|
|
|
|
|
|
retry_later (activity not released yet — Qoder activities are not published
|
|
|
|
|
|
exactly at midnight, so multi-slot retries matter).
|
|
|
|
|
|
"""
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import json
|
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
|
|
from common import http_post_json, http_get, log, now_ts
|
2026-10-19 12:04:44 +08:00
|
|
|
|
import umid
|
2026-10-13 19:09:48 +08:00
|
|
|
|
|
|
|
|
|
|
CN_BASE = "https://openapi.qoder.com.cn"
|
|
|
|
|
|
GLOBAL_BASE = "https://openapi.qoder.sh"
|
|
|
|
|
|
PATH_CAMPAIGNS = "/sash/api/v1/me/campaigns"
|
|
|
|
|
|
PATH_CLAIM = "/sash/api/v1/me/campaigns/%s/claim"
|
|
|
|
|
|
PATH_REFRESH = "/api/v1/deviceToken/refresh"
|
|
|
|
|
|
|
|
|
|
|
|
REFRESH_MARGIN = 72 * 3600 # refresh 72h before expiry (client behaviour)
|
|
|
|
|
|
|
2026-10-19 12:04:44 +08:00
|
|
|
|
# 客户端默认版本(社区实现实测值:缺 Cosy-Version 时 campaigns 可能返回空数组)
|
|
|
|
|
|
DEFAULT_VERSION = "0.3.4"
|
|
|
|
|
|
|
2026-10-13 19:09:48 +08:00
|
|
|
|
DEVICE_DEFAULTS = {
|
|
|
|
|
|
"clientType": "10",
|
2026-10-19 12:04:44 +08:00
|
|
|
|
"version": DEFAULT_VERSION,
|
2026-10-13 19:09:48 +08:00
|
|
|
|
"machineOS": "",
|
|
|
|
|
|
"machineHostname": "",
|
|
|
|
|
|
"machineId": "",
|
|
|
|
|
|
"machineToken": "",
|
|
|
|
|
|
"machineCode": "",
|
|
|
|
|
|
"machineType": "",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_global(auth):
|
|
|
|
|
|
return "qoder.sh" in (auth.get("domain") or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def billing_base(auth):
|
|
|
|
|
|
return GLOBAL_BASE if is_global(auth) else CN_BASE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def device_headers(account):
|
|
|
|
|
|
"""Cosy-* device headers from the account's device block."""
|
|
|
|
|
|
dev = dict(DEVICE_DEFAULTS)
|
|
|
|
|
|
dev.update({k: v for k, v in (account.get("device") or {}).items() if k in DEVICE_DEFAULTS})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"Cosy-ClientType": str(dev["clientType"] or "10"),
|
|
|
|
|
|
"Cosy-MachineOS": str(dev["machineOS"] or ""),
|
|
|
|
|
|
"Cosy-MachineHostname": str(dev["machineHostname"] or ""),
|
|
|
|
|
|
"Cosy-MachineId": str(dev["machineId"] or ""),
|
|
|
|
|
|
"Cosy-MachineToken": str(dev["machineToken"] or ""),
|
|
|
|
|
|
"Cosy-MachineCode": str(dev["machineCode"] or ""),
|
|
|
|
|
|
"Cosy-MachineType": str(dev["machineType"] or ""),
|
|
|
|
|
|
"Cosy-Version": str(dev["version"] or ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 12:04:44 +08:00
|
|
|
|
def risk_identity(account, force=False):
|
|
|
|
|
|
"""获取(并缓存到账号)设备风控身份 Cosy-MachineToken/Code/Type。
|
|
|
|
|
|
|
|
|
|
|
|
Qoder 服务端(尤其国际版)只对带风控身份的请求下发「每天领 100 Credits」活动。
|
|
|
|
|
|
优先用账号 device 块里已存的;缺失时调用本机 runtime-info 现场生成。
|
|
|
|
|
|
"""
|
|
|
|
|
|
dev = account.get("device") or {}
|
|
|
|
|
|
if not force and dev.get("machineToken") and dev.get("machineCode") and dev.get("machineType"):
|
|
|
|
|
|
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 "")
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return None, None, None, err
|
|
|
|
|
|
dev = dict(dev)
|
|
|
|
|
|
dev.update({"machineToken": ident["machineToken"],
|
|
|
|
|
|
"machineCode": ident["machineCode"],
|
|
|
|
|
|
"machineType": ident["machineType"]})
|
|
|
|
|
|
account["device"] = dev
|
|
|
|
|
|
return dev["machineToken"], dev["machineCode"], dev["machineType"], None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 19:09:48 +08:00
|
|
|
|
def _headers(account):
|
|
|
|
|
|
h = {"Accept": "application/json", "Accept-Language": "zh-CN", "User-Agent": "Qoder"}
|
2026-10-19 12:04:44 +08:00
|
|
|
|
# 设备标识:有值才发(空值可能触发服务端设备校验)
|
2026-10-13 19:09:48 +08:00
|
|
|
|
for k, v in device_headers(account).items():
|
|
|
|
|
|
if v:
|
|
|
|
|
|
h[k] = v
|
|
|
|
|
|
token = (account.get("auth") or {}).get("accessToken", "")
|
|
|
|
|
|
if token:
|
|
|
|
|
|
h["Authorization"] = "Bearer %s" % token
|
|
|
|
|
|
return h
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# JWT helpers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _b64_json(seg):
|
|
|
|
|
|
seg += "=" * (-len(seg) % 4)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(base64.urlsafe_b64decode(seg).decode("utf-8"))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def jwt_exp(token):
|
|
|
|
|
|
parts = str(token or "").split(".")
|
|
|
|
|
|
if len(parts) != 3:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(_b64_json(parts[1]).get("exp") or 0)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def jwt_uid(token):
|
|
|
|
|
|
parts = str(token or "").split(".")
|
|
|
|
|
|
if len(parts) != 3:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
data = _b64_json(parts[1])
|
|
|
|
|
|
for k in ("sub", "uid", "user_id", "userId", "id"):
|
|
|
|
|
|
if data.get(k):
|
|
|
|
|
|
return str(data[k])
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Token refresh
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def token_expiring(account, margin=REFRESH_MARGIN):
|
|
|
|
|
|
auth = account.get("auth") or {}
|
|
|
|
|
|
at = auth.get("accessToken") or ""
|
|
|
|
|
|
if not at:
|
|
|
|
|
|
return True
|
|
|
|
|
|
exp = auth.get("expiresAt") or jwt_exp(at)
|
|
|
|
|
|
if not exp:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return now_ts() + margin > exp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def refresh_token(account):
|
|
|
|
|
|
"""Refresh access token in place. Returns (ok, message)."""
|
|
|
|
|
|
rt = (account.get("auth") or {}).get("refreshToken", "")
|
|
|
|
|
|
if not rt:
|
|
|
|
|
|
return False, "未配置 refreshToken,无法自动续期"
|
|
|
|
|
|
# jrt- 前缀的刷新串走 jobToken 端点(社区实现验证)
|
|
|
|
|
|
path = "/api/v1/jobToken/refresh" if rt.startswith("jrt-") else PATH_REFRESH
|
|
|
|
|
|
data, status, err = http_post_json(
|
|
|
|
|
|
billing_base(account.get("auth") or {}) + path,
|
|
|
|
|
|
body={"refresh_token": rt},
|
|
|
|
|
|
headers={"User-Agent": "qoder-local-proxy", "Accept": "application/json"})
|
|
|
|
|
|
if status != 200 or not isinstance(data, dict):
|
|
|
|
|
|
detail = ""
|
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
|
detail = data.get("errorMessage") or data.get("message") or data.get("msg") or ""
|
|
|
|
|
|
return False, "续期失败(HTTP %s)%s%s" % (status, (":" + str(detail)[:80]) if detail else "", (" " + str(err)[:80]) if err and not detail else "")
|
|
|
|
|
|
# 响应可能是 {"data": {...}} 嵌套,也可能是平铺
|
|
|
|
|
|
payload = data.get("data") if isinstance(data.get("data"), dict) else data
|
|
|
|
|
|
tok = None
|
|
|
|
|
|
for k in ("device_token", "deviceToken", "token", "accessToken", "access_token"):
|
|
|
|
|
|
if payload.get(k):
|
|
|
|
|
|
tok = payload[k]
|
|
|
|
|
|
break
|
|
|
|
|
|
if not tok:
|
|
|
|
|
|
return False, "续期响应缺少 token(HTTP %s)" % status
|
|
|
|
|
|
auth = account.setdefault("auth", {})
|
|
|
|
|
|
auth["accessToken"] = tok
|
|
|
|
|
|
new_rt = None
|
|
|
|
|
|
for k in ("device_refresh_token", "refresh_token", "refreshToken"):
|
|
|
|
|
|
if payload.get(k):
|
|
|
|
|
|
new_rt = payload[k]
|
|
|
|
|
|
break
|
|
|
|
|
|
if new_rt:
|
|
|
|
|
|
auth["refreshToken"] = new_rt
|
|
|
|
|
|
exp = jwt_exp(tok)
|
|
|
|
|
|
if exp:
|
|
|
|
|
|
auth["expiresAt"] = exp
|
|
|
|
|
|
if not account.get("uid"):
|
|
|
|
|
|
account["uid"] = jwt_uid(tok)
|
|
|
|
|
|
return True, "已续期"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_token(account, force=False):
|
|
|
|
|
|
if not force and not token_expiring(account):
|
|
|
|
|
|
return True, ""
|
|
|
|
|
|
return refresh_token(account)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Campaigns / claim
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _api(account, path, method="GET", body=None):
|
|
|
|
|
|
return http_post_json(billing_base(account.get("auth") or {}) + path,
|
|
|
|
|
|
body=body, headers=_headers(account)) if method == "POST" \
|
|
|
|
|
|
else http_get(billing_base(account.get("auth") or {}) + path, headers=_headers(account))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def query_campaigns(account):
|
|
|
|
|
|
"""Returns (campaigns, error). campaigns is a list (possibly empty)."""
|
|
|
|
|
|
data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET")
|
|
|
|
|
|
if status == 401:
|
|
|
|
|
|
return None, "no_session"
|
|
|
|
|
|
if not isinstance(data, dict) or status != 200:
|
|
|
|
|
|
detail = ""
|
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
|
detail = data.get("message") or data.get("errorMessage") or ""
|
|
|
|
|
|
return None, "查询失败(HTTP %s)%s" % (status, (":" + str(detail)[:80]) if detail else "")
|
|
|
|
|
|
campaigns = data.get("campaigns")
|
|
|
|
|
|
return (campaigns if isinstance(campaigns, list) else []), None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 12:04:44 +08:00
|
|
|
|
def campaigns_debug(account):
|
|
|
|
|
|
"""诊断:返回活动接口的原始响应摘要(不含 token)。"""
|
|
|
|
|
|
data, status, err = _api(account, PATH_CAMPAIGNS + "?forceRefresh=true", "GET")
|
|
|
|
|
|
out = {"status": status, "error": err}
|
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
|
cs = data.get("campaigns")
|
|
|
|
|
|
out["campaign_count"] = len(cs) if isinstance(cs, list) else None
|
|
|
|
|
|
if isinstance(cs, list) and cs:
|
|
|
|
|
|
out["campaigns"] = [describe_campaign(c) for c in cs[:10]]
|
|
|
|
|
|
# 原始字段摘要(便于排查 schema 差异)
|
|
|
|
|
|
raw = {}
|
|
|
|
|
|
for k, v in cs[0].items():
|
|
|
|
|
|
if isinstance(v, (str, int, float, bool)) or v is None:
|
|
|
|
|
|
raw[k] = (str(v)[:60] if isinstance(v, str) else v)
|
|
|
|
|
|
elif isinstance(v, dict):
|
|
|
|
|
|
raw[k] = {kk: (str(vv)[:40] if isinstance(vv, str) else vv) for kk, vv in list(v.items())[:8]}
|
|
|
|
|
|
elif isinstance(v, list):
|
|
|
|
|
|
raw[k] = "[%d items]" % len(v)
|
|
|
|
|
|
out["campaign_raw_fields"] = raw
|
|
|
|
|
|
else:
|
|
|
|
|
|
out["raw_keys"] = list(data.keys())[:10]
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 19:09:48 +08:00
|
|
|
|
def _benefits(campaigns):
|
2026-10-19 12:04:44 +08:00
|
|
|
|
"""CLAIM_BENEFIT campaigns(活动/签到坑位)。
|
|
|
|
|
|
|
|
|
|
|
|
注意:真实数据里 benefit 可能只有 kind 没有 amount(活动信息在别处),
|
|
|
|
|
|
所以只按 actionType 过滤;kind 明确非 CREDITS 时才排除。
|
|
|
|
|
|
"""
|
2026-10-13 19:09:48 +08:00
|
|
|
|
out = []
|
|
|
|
|
|
for c in campaigns:
|
2026-10-19 12:04:44 +08:00
|
|
|
|
if not isinstance(c, dict):
|
2026-10-13 19:09:48 +08:00
|
|
|
|
continue
|
2026-10-19 12:04:44 +08:00
|
|
|
|
action = c.get("actionType")
|
|
|
|
|
|
claim_status = c.get("claimStatus")
|
2026-10-13 19:09:48 +08:00
|
|
|
|
benefit = c.get("benefit")
|
2026-10-19 12:04:44 +08:00
|
|
|
|
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
|
|
|
|
|
|
if not has_claim_state:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# 只排除明确非积分权益(如实物/优惠券)
|
|
|
|
|
|
if kind and kind not in ("CREDITS",):
|
2026-10-13 19:09:48 +08:00
|
|
|
|
continue
|
|
|
|
|
|
out.append(c)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 12:04:44 +08:00
|
|
|
|
def describe_campaign(c):
|
|
|
|
|
|
"""Human-readable campaign summary (title / amount / window / countdown).
|
|
|
|
|
|
|
|
|
|
|
|
Mirrors what the client shows in the activity card: 每天领 100 Credits,
|
|
|
|
|
|
每日 10:00 (UTC+8) 刷新, 领取后 30 天有效, plus a countdown to next reset.
|
|
|
|
|
|
"""
|
|
|
|
|
|
b = c.get("benefit") or {}
|
|
|
|
|
|
amount = b.get("amount")
|
|
|
|
|
|
kind = b.get("kind") or "CREDITS"
|
|
|
|
|
|
title = c.get("campaignName") or c.get("title") or c.get("campaignKey") or ""
|
|
|
|
|
|
if title.startswith("act-"):
|
|
|
|
|
|
title = "每日签到活动"
|
|
|
|
|
|
info = {"campaignId": c.get("campaignId"), "campaignKey": c.get("campaignKey"),
|
|
|
|
|
|
"title": title, "amount": amount, "kind": kind,
|
|
|
|
|
|
"actionType": c.get("actionType"),
|
|
|
|
|
|
"claimStatus": c.get("claimStatus")}
|
|
|
|
|
|
# 有效期
|
|
|
|
|
|
for src_key, dst_key in (("startAt", "startAt"), ("endAt", "endAt")):
|
|
|
|
|
|
if c.get(src_key):
|
|
|
|
|
|
info[dst_key] = c[src_key]
|
|
|
|
|
|
# 刷新周期(每日 10:00 UTC+8 等)
|
|
|
|
|
|
for key in ("refreshCron", "refreshTime", "resetAt", "resetTime", "nextRefreshAt",
|
|
|
|
|
|
"dailyRefreshTime", "refreshPeriod"):
|
|
|
|
|
|
if c.get(key) is not None:
|
|
|
|
|
|
info[key] = c[key]
|
|
|
|
|
|
# 领取后有效期
|
|
|
|
|
|
for key in ("validDays", "benefitValidDays", "expireDays", "validityDays"):
|
|
|
|
|
|
if c.get(key) is not None:
|
|
|
|
|
|
info["validDays"] = c[key]
|
|
|
|
|
|
# 倒计时(服务端可能直接给剩余秒数/毫秒)
|
|
|
|
|
|
for key in ("countdownMs", "countdown", "remainingMs", "remainingSeconds", "nextClaimInMs"):
|
|
|
|
|
|
if c.get(key) is not None:
|
|
|
|
|
|
info["countdownMs"] = c[key]
|
|
|
|
|
|
return info
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 19:09:48 +08:00
|
|
|
|
USAGE_PATH = "/sash/api/v2/me/usage"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def query_balance(account):
|
|
|
|
|
|
"""Remaining credits (userQuota + addOnQuota). Returns (total, error)."""
|
|
|
|
|
|
data, status, err = _api(account, USAGE_PATH, "GET")
|
|
|
|
|
|
if status == 401:
|
|
|
|
|
|
return None, "no_session"
|
|
|
|
|
|
if not isinstance(data, dict) or status != 200:
|
|
|
|
|
|
return None, err
|
|
|
|
|
|
usage = data.get("qoderUsage")
|
|
|
|
|
|
if not isinstance(usage, dict):
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for key in ("userQuota", "addOnQuota"):
|
|
|
|
|
|
q = usage.get(key)
|
|
|
|
|
|
if isinstance(q, dict) and isinstance(q.get("remaining"), (int, float)):
|
|
|
|
|
|
total += q["remaining"]
|
|
|
|
|
|
return total, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _claim_one(account, cid):
|
|
|
|
|
|
data, status, err = _api(account, PATH_CLAIM % cid, "POST", body={})
|
|
|
|
|
|
if status == 401:
|
|
|
|
|
|
return None, "no_session"
|
|
|
|
|
|
d = data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), dict) else data
|
|
|
|
|
|
if status == 200 and isinstance(d, dict) and d.get("status") == "CLAIMED":
|
|
|
|
|
|
amt = 0
|
|
|
|
|
|
if isinstance(d.get("benefit"), dict):
|
|
|
|
|
|
amt = d["benefit"].get("amount") or 0
|
|
|
|
|
|
return amt, None
|
|
|
|
|
|
return 0, "领取失败(HTTP %s)" % status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _token_diag(account):
|
|
|
|
|
|
auth = account.get("auth") or {}
|
|
|
|
|
|
at = auth.get("accessToken") or ""
|
|
|
|
|
|
rt = auth.get("refreshToken") or ""
|
|
|
|
|
|
return "(accessToken %d 字符 / refreshToken %d 字符)" % (len(at), len(rt))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def checkin_once(account):
|
|
|
|
|
|
"""Full single-account check-in. Returns a result dict."""
|
|
|
|
|
|
auth = account.get("auth") or {}
|
|
|
|
|
|
if not auth.get("accessToken") and not auth.get("refreshToken"):
|
|
|
|
|
|
return {"status": "no_auth", "message": "缺少 accessToken / refreshToken,请重新添加账号",
|
|
|
|
|
|
"points": 0}
|
|
|
|
|
|
# 注意:Qoder 的 dt-/drt- Token 原生就只有 27/28 字符(社区实测确认),
|
|
|
|
|
|
# 这里只拦截明显异常短的值。
|
|
|
|
|
|
if auth.get("accessToken") and len(auth["accessToken"]) < 10:
|
|
|
|
|
|
return {"status": "no_auth",
|
|
|
|
|
|
"message": "accessToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["accessToken"]),
|
|
|
|
|
|
"points": 0}
|
|
|
|
|
|
if auth.get("refreshToken") and len(auth["refreshToken"]) < 10:
|
|
|
|
|
|
return {"status": "no_auth",
|
|
|
|
|
|
"message": "refreshToken 仅 %d 字符,明显不完整,请重新复制" % len(auth["refreshToken"]),
|
|
|
|
|
|
"points": 0}
|
|
|
|
|
|
|
|
|
|
|
|
ok, msg = ensure_token(account)
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
# maybe the access token is still valid even though refresh failed
|
|
|
|
|
|
at = auth.get("accessToken") or ""
|
|
|
|
|
|
exp = auth.get("expiresAt") or jwt_exp(at)
|
|
|
|
|
|
if not (at and (not exp or exp > now_ts())):
|
|
|
|
|
|
return {"status": "no_session", "message": msg, "points": 0}
|
|
|
|
|
|
|
|
|
|
|
|
campaigns, err = query_campaigns(account)
|
|
|
|
|
|
if err == "no_session":
|
|
|
|
|
|
ok2, msg2 = ensure_token(account, force=True)
|
|
|
|
|
|
if ok2:
|
|
|
|
|
|
campaigns, err = query_campaigns(account)
|
|
|
|
|
|
if err == "no_session":
|
|
|
|
|
|
dom = (account.get("auth") or {}).get("domain") or CN_BASE
|
|
|
|
|
|
other = GLOBAL_BASE if "com.cn" in dom else CN_BASE
|
|
|
|
|
|
return {"status": "no_session",
|
|
|
|
|
|
"message": "认证失败(401)。两个站点 Token 不互通——若这是国际版 Token,"
|
|
|
|
|
|
"请把域名改为 %s(当前 %s)%s"
|
|
|
|
|
|
% (other, dom, _token_diag(account)),
|
|
|
|
|
|
"points": 0}
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return {"status": "fail", "message": "续期后查询仍失败:%s" % err, "points": 0}
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return {"status": "fail", "message": err, "points": 0}
|
|
|
|
|
|
|
2026-10-19 12:04:44 +08:00
|
|
|
|
# Qoder 服务端只对带风控身份的请求下发签到活动——先确保身份就绪
|
|
|
|
|
|
tok, code, mtype, rerr = risk_identity(account)
|
|
|
|
|
|
if rerr:
|
|
|
|
|
|
log("qoder risk identity unavailable: %s" % rerr, "WARN")
|
|
|
|
|
|
|
2026-10-13 19:09:48 +08:00
|
|
|
|
balance, berr = query_balance(account)
|
|
|
|
|
|
if berr == "no_session":
|
|
|
|
|
|
return {"status": "no_session",
|
|
|
|
|
|
"message": "Token 已过期,请重新获取 Token" + _token_diag(account), "points": 0}
|
|
|
|
|
|
if berr:
|
|
|
|
|
|
balance = None
|
|
|
|
|
|
|
|
|
|
|
|
benefits = _benefits(campaigns)
|
|
|
|
|
|
claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"]
|
|
|
|
|
|
|
|
|
|
|
|
if claimable:
|
|
|
|
|
|
got, fail = 0.0, 0
|
|
|
|
|
|
for c in claimable:
|
|
|
|
|
|
cid = str(c.get("campaignId") or "")
|
|
|
|
|
|
amt, cerr = _claim_one(account, cid)
|
|
|
|
|
|
if cerr == "no_session":
|
|
|
|
|
|
return {"status": "no_session",
|
|
|
|
|
|
"message": "登录态已失效,请重新提取 Token", "points": 0}
|
|
|
|
|
|
if cerr:
|
|
|
|
|
|
fail += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
got += float(amt or 0)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
if fail:
|
|
|
|
|
|
return {"status": "fail",
|
|
|
|
|
|
"message": "%d/%d 个活动领取失败" % (fail, len(claimable)), "points": 0}
|
|
|
|
|
|
return {"status": "ok", "message": "签到成功 +%s Credits" % int(got),
|
|
|
|
|
|
"points": int(got), "credits": balance}
|
|
|
|
|
|
|
|
|
|
|
|
# nothing claimable: already claimed today / activity not released / none
|
|
|
|
|
|
now = now_ts()
|
|
|
|
|
|
claimed_today = [c for c in benefits
|
|
|
|
|
|
if c.get("claimStatus") == "CLAIMED"
|
|
|
|
|
|
and (c.get("startAt") or 0) <= now < (c.get("endAt") or 0)]
|
|
|
|
|
|
if claimed_today:
|
|
|
|
|
|
return {"status": "already", "message": "今日已领取", "points": 0,
|
|
|
|
|
|
"credits": balance}
|
|
|
|
|
|
if not benefits:
|
2026-10-19 12:04:44 +08:00
|
|
|
|
# 没有任何可识别活动:可能是活动未下发/已结束,也可能是 schema 变化
|
|
|
|
|
|
return {"status": "already",
|
|
|
|
|
|
"message": "未发现可领取活动(活动未下发/已结束,或需刷新风控身份)", "points": 0}
|
2026-10-13 19:09:48 +08:00
|
|
|
|
return {"status": "retry_later", "message": "活动未下发,下个时段再试", "points": 0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def probe_domains(account):
|
|
|
|
|
|
"""Probe both CN/Global with the current token.
|
|
|
|
|
|
|
|
|
|
|
|
Returns {domain: {"status": http_status, "auth": bool}} — a 404 means the
|
|
|
|
|
|
token authenticated (path simply doesn't exist), 401 means rejected.
|
|
|
|
|
|
"""
|
|
|
|
|
|
out = {}
|
|
|
|
|
|
for dom, base in ((CN_BASE, CN_BASE), (GLOBAL_BASE, GLOBAL_BASE)):
|
|
|
|
|
|
acc = dict(account)
|
|
|
|
|
|
acc["auth"] = dict(account.get("auth") or {})
|
|
|
|
|
|
acc["auth"]["domain"] = dom
|
|
|
|
|
|
try:
|
|
|
|
|
|
data, status, err = _api(acc, PATH_CAMPAIGNS, "GET")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out[dom] = {"status": 0, "auth": False, "error": str(e)}
|
|
|
|
|
|
continue
|
|
|
|
|
|
out[dom] = {"status": status, "auth": status not in (401, 403)}
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def account_display(account):
|
|
|
|
|
|
return account.get("name") or account.get("uid") or "Qoder 账号"
|