feat: AgentCheckin v1.2.2 — fnOS 应用:TRAE/WorkBuddy/Qoder 三平台每日积分自动签到
- 原生 fnOS 应用(CGI 入口 + Python 守护进程),依赖 python312 运行时 - 三平台签到:TRAE 每日 200 积分、WorkBuddy 7 天循环、Qoder 每日 100 Credits - WorkBuddy 网页授权(OAuth 设备流)、TRAE icubeAuth 解密 Token、Qoder Cosy 设备头 - 多时段调度(默认 00:05/09:00/12:30/19:00)、启动补跑、幂等签到+回查确认 - 多平台 Webhook 通知(企业微信/钉钉/飞书/Bark/Server酱/PushPlus 自动识别) - Qoder 双站点 Token 隔离自动探测与修复 - 前端:仪表盘/账号管理/签到记录/设置四个页签,30s 轮询+变化检测+隐藏暂停 - 测试:34 后端 + 25 E2E + 9 前端回归 + 真实时钟调度测试
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TRAE (ByteDance, CN edition) daily check-in client.
|
||||
|
||||
Endpoints (reverse-engineered from the TRAE desktop client, see community
|
||||
projects wb-trae-checkin / Trae-AutoCheckin):
|
||||
|
||||
base https://api.trae.cn
|
||||
status POST /trae/api/v2/ug/checkin_credits/status body {"req_source":1}
|
||||
claim POST /trae/api/v2/ug/checkin_credits/claim body {"req_source":1}
|
||||
|
||||
Auth: Authorization: Cloud-IDE-JWT <accessToken> (a JWT issued by the client).
|
||||
The server also expects desktop-client headers including a 16-digit device id.
|
||||
|
||||
Result codes:
|
||||
0 success (claim returns data.points, usually 200)
|
||||
9095 already checked in today
|
||||
9074 device id remembered by the server -> rotate to a fresh device id
|
||||
1001/1002/401/403 session invalid -> re-login required
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
|
||||
from common import http_post_json, log
|
||||
|
||||
API_BASE = "https://api.trae.cn"
|
||||
TRAE_VERSION = "1.107.1"
|
||||
EP_STATUS = "/trae/api/v2/ug/checkin_credits/status"
|
||||
EP_CLAIM = "/trae/api/v2/ug/checkin_credits/claim"
|
||||
|
||||
AUTH_FAIL_CODES = {1001, 1002, 401, 403}
|
||||
|
||||
|
||||
def _b64url_decode(seg):
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
return base64.urlsafe_b64decode(seg.encode("ascii"))
|
||||
|
||||
|
||||
def jwt_payload(token):
|
||||
"""Decode a JWT payload without verifying the signature."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
return {}
|
||||
return json.loads(_b64url_decode(parts[1]).decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def user_region(account):
|
||||
auth = account.get("auth", {})
|
||||
if auth.get("region"):
|
||||
return auth["region"]
|
||||
payload = jwt_payload(auth.get("accessToken", ""))
|
||||
region = (payload.get("userRegion") or {}).get("region")
|
||||
return region or "CN"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device id (16 digits): stable per account, rotated on 9074
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def stable_device_id(account_id):
|
||||
digest = hashlib.sha256(("agentcheckin:%s" % account_id).encode("utf-8")).hexdigest()
|
||||
n = int(digest[:13], 16)
|
||||
return str(n).ljust(16, "0")[:16]
|
||||
|
||||
|
||||
def rotate_device_id():
|
||||
return (str(int(time.time() * 1000))[-13:] + str(int(time.time() * 1000) % 1000).zfill(3))[:16]
|
||||
|
||||
|
||||
def device_id(account):
|
||||
did = account.get("device_id")
|
||||
if not did:
|
||||
did = stable_device_id(account.get("id", "default"))
|
||||
account["device_id"] = did
|
||||
return did
|
||||
|
||||
|
||||
def headers(account):
|
||||
auth = account.get("auth", {})
|
||||
return {
|
||||
"Authorization": "Cloud-IDE-JWT %s" % auth.get("accessToken", ""),
|
||||
"X-User-Region": user_region(account),
|
||||
"User-Agent": "Trae/%s" % TRAE_VERSION,
|
||||
"Content-Type": "application/json",
|
||||
"x-device-id": device_id(account),
|
||||
"x-device-type": "Windows",
|
||||
"x-os-version": "10.0.19045",
|
||||
"x-app-version": TRAE_VERSION,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-in API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def query_status(account):
|
||||
"""Return (checked, enable, credits, error). checked True/False/None."""
|
||||
data, status, err = http_post_json(API_BASE + EP_STATUS, body={"req_source": 1},
|
||||
headers=headers(account), timeout=20)
|
||||
if err or data is None:
|
||||
if status in (401, 403):
|
||||
return None, None, None, "no_session"
|
||||
return None, None, None, err or "status query failed"
|
||||
if isinstance(data, dict):
|
||||
code = data.get("code")
|
||||
if code in AUTH_FAIL_CODES:
|
||||
return None, None, None, "no_session"
|
||||
if code == 0:
|
||||
return (
|
||||
bool(data.get("checked_in")),
|
||||
data.get("enable") is not False,
|
||||
data.get("credits"),
|
||||
None,
|
||||
)
|
||||
return None, None, None, None
|
||||
|
||||
|
||||
def claim(account):
|
||||
"""Claim today's credits. Returns (status, message, points).
|
||||
|
||||
status: ok / already / fail / no_session / retry_later
|
||||
"""
|
||||
hdrs = headers(account)
|
||||
data, status, err = http_post_json(API_BASE + EP_CLAIM, body={"req_source": 1},
|
||||
headers=hdrs, timeout=20)
|
||||
if status in (401, 403):
|
||||
return "no_session", "登录态已失效,请重新获取 Token", 0
|
||||
if data is None:
|
||||
return "fail", "请求失败(HTTP %s:%s)" % (status, err), 0
|
||||
if not isinstance(data, dict):
|
||||
return "fail", "非 JSON 返回(HTTP %s)" % status, 0
|
||||
code = data.get("code")
|
||||
if code == 0:
|
||||
d = data.get("data") if isinstance(data.get("data"), dict) else {}
|
||||
return "ok", "签到成功", d.get("points") or 200
|
||||
if code == 9095:
|
||||
return "already", "今天已签到", 0
|
||||
if code in AUTH_FAIL_CODES:
|
||||
return "no_session", "登录态已失效(code=%s),请重新获取 Token" % code, 0
|
||||
if code == 9074:
|
||||
return "retry_later", "触发风控(9074),需更换设备号重试", 0
|
||||
return "fail", "领取返回 code=%s %s" % (code, str(data.get("message") or data.get("msg") or "")[:80]), 0
|
||||
|
||||
|
||||
def checkin_once(account):
|
||||
"""Full single-account check-in: status -> claim -> verify.
|
||||
|
||||
Returns dict {status, message, points, credits}.
|
||||
"""
|
||||
checked, enable, credits, err = query_status(account)
|
||||
if err == "no_session":
|
||||
return {"status": "no_session", "message": "登录态已失效,请重新获取 Token", "points": 0}
|
||||
if checked is True:
|
||||
return {"status": "already", "message": "今日已签到",
|
||||
"points": 0, "credits": credits}
|
||||
if enable is False:
|
||||
return {"status": "inactive", "message": "当前账号/时段未开放签到活动", "points": 0}
|
||||
|
||||
rotated = False
|
||||
for attempt in range(2):
|
||||
status, message, points = claim(account)
|
||||
if status == "ok":
|
||||
# verify server-side
|
||||
for _ in range(3):
|
||||
time.sleep(2)
|
||||
v_checked, _, v_credits, _ = query_status(account)
|
||||
if v_checked is True:
|
||||
return {"status": "ok", "message": "签到成功(已回查确认)",
|
||||
"points": points, "credits": v_credits}
|
||||
if v_checked is False:
|
||||
break
|
||||
return {"status": "ok_unverified",
|
||||
"message": "领取成功(+%s)但回查未确认" % points,
|
||||
"points": points, "credits": credits}
|
||||
if status == "already":
|
||||
return {"status": "already", "message": "今天已签到", "points": 0,
|
||||
"credits": credits}
|
||||
if status == "no_session":
|
||||
return {"status": "no_session", "message": message, "points": 0}
|
||||
if status == "retry_later" and not rotated:
|
||||
account["device_id"] = rotate_device_id()
|
||||
rotated = True
|
||||
log("trae account %s: 9074 -> rotated device id" % account.get("id"))
|
||||
time.sleep(3)
|
||||
continue
|
||||
return {"status": "fail", "message": message, "points": 0}
|
||||
return {"status": "retry_later", "message": "风控限流(9074),换设备号后仍失败,下个时段自动补签",
|
||||
"points": 0}
|
||||
|
||||
|
||||
def account_display(account):
|
||||
return account.get("name") or (account.get("auth") or {}).get("nickname") or "TRAE 账号"
|
||||
Reference in New Issue
Block a user