• AmSuperKing
    """Qoder 国际版 用量读取/签到脚本
    
    凭证链(两种方式,按优先级自动选择):
      方式 A(推荐,最稳定): PAT 换 job token
        1. 在 https://qoder.com → Settings → Personal Access Tokens 创建 PAT(pt-...)
        2. POST /api/v1/jobToken/exchange  {"personal_token": "<pt-...>"}
           -> 返回 {accessToken(jt-...), refreshToken(jrt-...), ...}
        3. 后续请求用 Bearer jt-...
        4. jt 过期时 POST /api/v1/jobToken/refresh 用 jrt 换新的 jt
    
      方式 B(同 CN 脚本链路): 客户端本地凭证仓
        Local State -> os_crypt.encrypted_key(base64 "DPAPI"…) -> CryptUnprotectData
        -> AES-256 key -> 解 auth.v1.dat("v10" + 12B nonce + ct + 16B tag)
        (国际版客户端目录名需实测,默认尝试 com.qoder.app.stable)
    
    接口域名:
      OpenAPI: https://openapi.qoder.sh
      聊天网关: https://api3.qoder.sh/algo/api/v2/   (本脚本用不到)
    
    实测注意:
      - PAT 不能直接放 Authorization,必须先 exchange 成 jt
      - campaigns 接口同样需要 Cosy-ClientType 头,否则返回空数组
      - 若 401/TOKEN_INVALID,优先检查 jt 是否过期,尝试 refresh
    """
    import argparse
    import base64
    import ctypes
    import ctypes.wintypes as wt
    import json
    import os
    import sys
    
    import requests
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    
    # ====================== 国际版差异配置(按需修改)======================
    API = "https://openapi.qoder.sh"                     # 国际版 OpenAPI 域名
    # 客户端凭证目录:国际版目录名需实测,常见候选如下,脚本会依次尝试
    PROFILE_CANDIDATES = [
        "com.qoder.app.stable",
        "com.qoder.app",
        "com.qoder.stable",
        "Qoder",
    ]
    # ======================================================================
    
    # Windows 控制台默认 GBK,中文输出会花屏
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
    
    CLIENT_TYPE = "10"   # 客户端日志里的 clientType
    VERSION = "0.4.2"    # 客户端版本号,可按实际安装版本调整
    AUTH_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auths.json")
    
    # ---------------------- Windows DPAPI 解密 ----------------------
    class _Blob(ctypes.Structure):
        _fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
    
    def _dpapi_unprotect(data: bytes) -> bytes:
        kernel32 = ctypes.windll.kernel32
        inp, out = _Blob(), _Blob()
        inp.cbData = len(data)
        inp.pbData = ctypes.create_string_buffer(data)
        if not ctypes.windll.crypt32.CryptUnprotectData(
                ctypes.byref(inp), None, None, None, None, 0, ctypes.byref(out)
        ):
            raise OSError(f"CryptUnprotectData failed, GetLastError={kernel32.GetLastError()}")
        try:
            return ctypes.string_at(out.pbData, out.cbData)
        finally:
            kernel32.LocalFree(out.pbData)
    
    def _find_profile() -> str:
        """依次尝试候选目录名,找到包含 Local State 的那个。"""
        appdata = os.environ["APPDATA"]
        for name in PROFILE_CANDIDATES:
            path = os.path.join(appdata, name)
            if os.path.isfile(os.path.join(path, "Local State")):
                return path
        raise FileNotFoundError(
            f"未找到 Qoder 国际版凭证目录,尝试过: {PROFILE_CANDIDATES}\n"
            f"请打开 %APPDATA% 查看实际目录名后修改 PROFILE_CANDIDATES"
        )
    
    def load_auth_from_client() -> dict:
        """解密国际版客户端登录仓,返回 {token, refreshToken, expiresAt, user, ...}。"""
        profile = _find_profile()
        with open(os.path.join(profile, "Local State"), encoding="utf-8") as fh:
            raw = base64.b64decode(json.load(fh)["os_crypt"]["encrypted_key"])
        if raw[:5] != b"DPAPI":
            raise ValueError("unexpected encrypted_key prefix")
        key = _dpapi_unprotect(raw[5:])
    
        with open(os.path.join(profile, "auth.v1.dat"), "rb") as fh:
            blob = fh.read()
        if blob[:3] != b"v10":
            raise ValueError("auth.v1.dat is not a v10 envelope")
        return json.loads(AESGCM(key).decrypt(blob[3:15], blob[15:], None))
    
    # ---------------------- PAT 换 job token ----------------------
    def exchange_pat(pat: str) -> dict:
        """用 PAT (pt-...) 换短时 job token (jt-...)。
    
        返回示例(字段名以实测为准):
        { "accessToken": "jt-...", "refreshToken": "jrt-...", "expiresIn": 3600, ... }
        """
        r = requests.post(
            f"{API}/api/v1/jobToken/exchange",
            json={"personal_token": pat},
            headers={"Accept": "application/json", "User-Agent": "Qoder"},
            timeout=20,
        )
        if r.status_code != 200:
            raise RuntimeError(f"jobToken/exchange 失败 HTTP {r.status_code}: {r.text[:300]}")
        return r.json()
    
    def refresh_job_token(refresh_token: str) -> dict:
        """用 jrt-... 刷新 jt-...,返回同 exchange 结构。"""
        r = requests.post(
            f"{API}/api/v1/jobToken/refresh",
            json={"refreshToken": refresh_token},
            headers={"Accept": "application/json", "User-Agent": "Qoder"},
            timeout=20,
        )
        if r.status_code != 200:
            raise RuntimeError(f"jobToken/refresh 失败 HTTP {r.status_code}: {r.text[:300]}")
        return r.json()
    
    # ---------------------- Session 构造 ----------------------
    def make_session(token: str) -> requests.Session:
        """公共头挂到 session 上;国际版 Authorization 同样必须是 Bearer <jt-...>。"""
        s = requests.Session()
        s.headers.update({
            "Accept": "application/json",
            "User-Agent": "Qoder",
            "Authorization": f"Bearer {token}",
            "Cosy-ClientType": CLIENT_TYPE,
            "Cosy-Version": VERSION,
        })
        return s
    
    def get(session: requests.Session, path: str, params: dict | None = None):
        r = session.get(API + path, params=params, timeout=20)
        if r.status_code != 200:
            return {"_error": r.status_code, "_body": r.text[:400]}
        return r.json()
    
    # ---------------------- 接口清单 ----------------------
    # 路径与 CN 版一致(同属 sash 服务),若 404 请用 --dump-asar 自查
    ENDPOINTS = {
        "credits-summary": ("/sash/api/v1/ai-conversations/credits-summary", None),
        "seat-activity": ("/sash/api/v1/ai-conversations/seat-activity", None),
        "credits-heatmap": ("/sash/api/v1/ai-conversations/credits-heatmap", {"days": 30, "organization_id": ""}),
        "campaigns": ("/sash/api/v1/me/campaigns", None),
        "plan": ("/api/v2/user/plan", None),
        "quota": ("/api/v2/quota/usage", None),
    }
    
    # ---------------------- 签到逻辑 ----------------------
    def claim_one(session: requests.Session, campaign_id: str) -> dict:
        """POST /sash/api/v1/me/campaigns/{campaignId}/claim(服务端幂等)"""
        r = session.post(f"{API}/sash/api/v1/me/campaigns/{campaign_id}/claim", timeout=20)
        try:
            body = r.json()
        except ValueError:
            body = {"_raw": r.text[:300]}
        return {"_http": r.status_code, **body} if isinstance(body, dict) else {"_http": r.status_code, "_raw": body}
    
    def print_quota(session: requests.Session, prefix: str = "剩余额度") -> None:
        """拉取 quota/usage 并打印可用额度。"""
        quota = get(session, ENDPOINTS["quota"][0])
        if "_error" in quota:
            print(f"{prefix}  : 请求失败 {quota['_error']}")
            return
        parts = []
        for label, key in (("套餐", "userQuota"), ("签到/加油包", "addOnQuota")):
            q = quota.get(key) or {}
            if q.get("total"):
                parts.append(f"{label} 剩余 {q.get('remaining'):g}/{q.get('total'):g}")
        print(f"{prefix}  : {','.join(parts) if parts else '无可用额度'}"
              f"{'(已超额)' if quota.get('isQuotaExceeded') else ''}")
    
    def do_claim(session: requests.Session, dry_run: bool) -> int:
        doc = get(session, "/sash/api/v1/me/campaigns")
        if "_error" in doc:
            print(f"活动列表请求失败: {doc['_error']} {doc['_body'][:120]}")
            return 1
        campaigns = doc.get("campaigns") or []
        if not campaigns:
            print("当前没有可展示的活动")
            return 0
        for c in campaigns:
            b = c.get("benefit") or {}
            print(f"{c.get('campaignId')}  {c.get('campaignKey')}  {str(c.get('actionType', '')):13} "
                  f"{str(c.get('claimStatus', '')):9} {b.get('kind')}x{b.get('amount')}")
    
        targets = [c for c in campaigns
                   if c.get("claimStatus") == "CLAIMABLE" and c.get("actionType") == "CLAIM_BENEFIT"]
        if not targets:
            print("\n没有 CLAIMABLE 的活动,无需签到")
            return 0
        if dry_run:
            print(f"\n[dry-run] 将领取 {len(targets)} 条:{[t.get('campaignKey') for t in targets]}")
            return 0
        print()
        claimed = False
        for c in targets:
            res = claim_one(session, c["campaignId"])
            b = res.get("benefit") or {}
            print(f"签到 {c.get('campaignKey')} -> HTTP {res.get('_http')} status={res.get('status')} "
                  f"replayed={res.get('replayed')} 权益={b.get('kind')}x{b.get('amount')} "
                  f"发放单={res.get('grantId')}")
            if res.get("_http") == 200 and res.get("status") == "CLAIMED":
                claimed = True
        if claimed:
            print("\n签到已到账,最新额度:")
            print_quota(session, prefix="剩余额度(签到后)")
        return 0
    
    def show_usage(session: requests.Session, auth: dict, args) -> int:
        """usage 模式(默认)= 面板数据汇总 + 自动签到。"""
        user = auth.get("user") or {}
        summary = get(session, ENDPOINTS["credits-summary"][0])
        streak = get(session, ENDPOINTS["seat-activity"][0])
        heat = get(session, ENDPOINTS["credits-heatmap"][0],
                   {"days": args.days or 30, "organization_id": ""})
    
        print(f"账号      : {user.get('name')} ({user.get('id')})")
        print(f"token 到期: {auth.get('expiresAt')}")
        print_quota(session)
        if "_error" not in summary:
            print(f"Credits   : 累计 {summary.get('totalCredits')} / 峰值 {summary.get('peakCredits')}")
        else:
            print(f"Credits   : 请求失败 {summary['_error']} {summary['_body'][:120]}")
        if "_error" not in streak:
            print(f"活跃天数  : 累计 {streak.get('cumulativeActiveDays')} 天 / 当前连续 "
                  f"{streak.get('currentConsecutiveDays')} 天 / 最长 {streak.get('maxConsecutiveDays')} 天"
                  f"(最后活跃 {streak.get('lastActiveDate')})")
        else:
            print(f"活跃天数  : 请求失败 {streak['_error']}")
        if "_error" not in heat:
            items = heat.get("items") or []
            used = [i for i in items if i.get("value")]
            print(f"热力图    : {items[0].get('date', '?') if items else '?'} ~ "
                  f"{items[-1].get('date', '?') if items else '?'},"
                  f"总和 {heat.get('total')},有用量的天数 {len(used)}")
            for i in used[-5:]:
                print(f"            {i.get('date')}  {i.get('value')}")
        print()
        return do_claim(session, args.dry_run)
    
    # ---------------------- 凭证加载 ----------------------
    def load_auths(args) -> list[dict]:
        """凭证优先级:
        1. --pat <pt-...>       命令行传 PAT(单账号,自动 exchange)
        2. --auth-file <json>   JSON 文件(支持两种格式,见下)
        3. 同目录 auths.json    同上
        4. 客户端本地解密       同 CN 脚本链路
    
        auth-file JSON 支持两种条目格式:
          A. {"pat": "pt-..."}                        -> 脚本自动 exchange
          B. {"token": "jt-...", "refreshToken": "jrt-...", "user": {...}}
             -> 直接用 token,若 401 自动用 refreshToken 刷新
        """
        # 1) 命令行 PAT
        if args.pat:
            cred = exchange_pat(args.pat)
            # 补齐 user 字段
            s = make_session(cred["accessToken"])
            info = get(s, "/api/v1/userinfo")
            if "_error" not in info:
                cred["user"] = info
            return [cred]
    
        # 2/3) auth-file
        path = args.auth_file or (AUTH_FILE if os.path.exists(AUTH_FILE) else None)
        if path:
            result = []
            with open(path, encoding="utf-8") as fh:
                data = json.load(fh)
            items = data if isinstance(data, list) else [data]
            for item in items:
                if "pat" in item:
                    # PAT 条目:自动 exchange
                    cred = exchange_pat(item["pat"])
                    cred["user"] = item.get("user") or {}
                    result.append(cred)
                else:
                    # 直接给 token 的条目
                    result.append(item)
            return result
    
        # 4) 客户端本地解密(需先登录过国际版客户端)
        return [load_auth_from_client()]
    
    def run_account(auth: dict, args) -> int:
        """单账号执行;若 token 失效且有 refreshToken,自动刷新重试一次。"""
        token = auth.get("token") or auth.get("accessToken")
        refresh = auth.get("refreshToken")
    
        for attempt in range(2):
            session = make_session(token)
            # 探测 token 是否有效
            probe = get(session, "/api/v2/quota/usage")
            if "_error" not in probe:
                break
            # 401 且有 refreshToken 时刷新重试
            if attempt == 0 and refresh and probe.get("_error") in (401, 403):
                print("  token 失效,尝试用 refreshToken 刷新...")
                try:
                    new = refresh_job_token(refresh)
                    token = new.get("accessToken") or new.get("token") or token
                    refresh = new.get("refreshToken") or refresh
                    continue
                except Exception as exc:
                    print(f"  刷新失败: {exc}")
                    return 1
            return 1
    
        if args.what == "claim":
            return do_claim(session, args.dry_run)
    
        if args.what in ENDPOINTS:
            path, params = ENDPOINTS[args.what]
            if args.what == "credits-heatmap" and args.days:
                params = {**(params or {}), "days": args.days}
            out = get(session, path, params)
            print(json.dumps(out, ensure_ascii=False, indent=2))
            return 1 if "_error" in out else 0
    
        return show_usage(session, auth, args)
    
    def main() -> int:
        ap = argparse.ArgumentParser(
            description="读取 Qoder 国际版 用量 / 签到",
            epilog=(
                "凭证来源(优先级从高到低):\n"
                "  1. --pat pt-xxxx        在 qoder.com 创建 PAT 后直接传入\n"
                "  2. --auth-file x.json   多账号 JSON(支持 pat 或 token+refreshToken 两种条目)\n"
                "  3. ./auths.json         同目录默认文件\n"
                "  4. 客户端本地解密       需先在国际版客户端登录\n"
                "\n"
                "auth-file 条目示例:\n"
                '  [{"pat": "pt-aaa"}, {"token": "jt-bbb", "refreshToken": "jrt-ccc"}]\n'
            ),
            formatter_class=argparse.RawDescriptionHelpFormatter,
        )
        ap.add_argument("what", nargs="?", default="usage",
                        help="usage | claim | " + " | ".join(ENDPOINTS))
        ap.add_argument("--days", type=int, help="credits-heatmap 的天数")
        ap.add_argument("--dry-run", action="store_true", help="claim 时只列出将要领取的活动")
        ap.add_argument("--auth-file", help="账号凭证 JSON 路径")
        ap.add_argument("--pat", help="Personal Access Token(pt-...),脚本会自动 exchange")
        args = ap.parse_args()
    
        rc = 0
        for idx, auth in enumerate(load_auths(args), 1):
            name = (auth.get("user") or {}).get("name") or "?"
            print(f"===== 账号 {idx}: {name} =====")
            try:
                rc |= run_account(auth, args)
            except Exception as exc:
                print(f"账号 {name} 处理失败: {exc}", file=sys.stderr)
                rc = 1
        return rc
    
    if __name__ == "__main__":
        sys.exit(main())
    主楼
  • 风咕嘎

    Qoder国际版

    和这个国内的版本有什么区别啊

    #1
  • ceshi01Adx

    呃?感觉这个活动应该不是常驻活动所以感觉价值存疑(阿里经常玩不起将活动下架),不过还是支持佬分享.

    #2
  • MyCard

    还能签到吗?怎么我看说已经结束了?

    #3

发表回复

登录后回复