"""斗鱼活动、兑换与充值接口客户端。""" from __future__ import annotations import json import re import time from collections.abc import Callable from typing import Any from urllib.parse import parse_qs, unquote, urlsplit, urlunsplit import requests from .cookie_utils import cookie_pairs, cookie_value, normalize_cookie_pairs, normalize_douyu_cookie PC_UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/133.0.0.0 Safari/537.36" ) class DouyuActivityError(RuntimeError): """斗鱼活动接口异常。""" class DouyuActivityClient: """封装斗鱼活动页、兑换、充值和送礼相关接口。""" CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf" ACF_CCN_API = "https://www.douyu.com/curl/csrfApi/getCsrfCookie?" ACF_CCN_FALLBACK_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie" SUBSCRIBE_API = "https://www.douyu.com/wgapi/livenc/asubscribe/userSubStatus" ROLE_PARAM_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/roleParam" BIND_INFO_API = "https://www.douyu.com/japi/carnivalApi/tencent/bindInfo" BIND_INFO_V2_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo" BIND_API = "https://www.douyu.com/japi/carnivalApi/tencent/bind" GOODS_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storehome" CREATE_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/createExchangeOrder" PAY_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/payExchangeOrder" EXCHANGE_ORDER_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getExchangeOrderList" EXCHANGE_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList" CREDIT_BALANCE_API = "https://www.douyu.com/japi/oms/web/peace/credit/balance" CREDIT_DIFF_API = "https://www.douyu.com/japi/oms/web/peace/credit/diff" PEACE_ITEM_API = "https://www.douyu.com/japi/oms/web/peace/item" PEACE_TIME_API = "https://www.douyu.com/japi/oms/web/peace/time" PAY_QR_API = "https://www.douyu.com/japi/oms/web/pay/getQrCode" ESPORTS_BIND_INFO_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/bindInfo" ESPORTS_BIND_ROLE_PARAM_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/bindRoleParam" ESPORTS_CSRF_API = "https://www.douyu.com/wgapi/livenc/liveweb/csrfApi/getCsrfCookie" ESPORTS_AGREEMENT_API = "https://www.douyu.com/wgapi/ordnc/activity/peacematch/agreement" ESPORTS_ACT_BIND_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/actBind" ESPORTS_USER_INFO_API = "https://www.douyu.com/wgapi/ordnc/activity/peacematch/getUserInfo" ESPORTS_GOODS_API = "https://www.douyu.com/wgapi/ordnc/activity/peacematch/storehome" ESPORTS_EXCHANGE_API = "https://www.douyu.com/wgapi/ordnc/activity/peacematch/exchange" GOLD_QR_API = "https://cz.douyu.com/m/gold/getQrCode" GOLD_ACCOUNT_API = "https://cz.douyu.com/item/gold/account" EXCHANGE_BALANCE_API = "https://www.douyu.com/wjapi/nc/exchange/fim" DONATE_API = "https://www.douyu.com/japi/gift/donate/mainsite/v3" def __init__( self, cookie: str, *, logger: Callable[[str], None] | None = None, timeout: tuple[float, float] = (8, 20), ): self.cookie = normalize_douyu_cookie(cookie) self.timeout = timeout self.logger = logger or (lambda _msg: None) self.session = requests.Session() self.session.trust_env = False self.session.headers.update({ "User-Agent": PC_UA, "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", }) self._load_cookie(self.cookie) @staticmethod def profile_from_cookie(cookie: str) -> dict[str, str]: """从斗鱼 Cookie 提取账号基础信息。""" nickname = cookie_value(cookie, "acf_nickname") return { "uid": cookie_value(cookie, "acf_uid"), "nickname": unquote(nickname) if nickname else "", } def _load_cookie(self, cookie: str) -> None: for key, value in cookie_pairs(cookie): self.session.cookies.set(key, value, domain=".douyu.com", path="/") def _merge_response_cookie(self, response: requests.Response) -> None: if not response.cookies: return self.cookie = normalize_cookie_pairs([ *cookie_pairs(self.cookie), *[(key, value) for key, value in response.cookies.items()], ]) self._load_cookie(self.cookie) def _set_cookie_value(self, key: str, value: str) -> None: """写入单个 Cookie 值,确保手动 Cookie 请求头与 Session 一致。""" self.cookie = normalize_cookie_pairs([ *cookie_pairs(self.cookie), (key, value), ]) self._load_cookie(self.cookie) def _headers(self, referer: str = "https://www.douyu.com/") -> dict[str, str]: return { "User-Agent": PC_UA, "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Origin": "https://www.douyu.com", "Referer": referer, "Cookie": self.cookie, } @staticmethod def _safe_url(url: str) -> str: """移除 query/fragment,避免将 Cookie、签名或一次性参数写入日志。""" parsed = urlsplit(url) return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) def _redirect_error( self, method: str, url: str, source: str, response: requests.Response, ) -> DouyuActivityError: location = response.headers.get("Location", "") safe_location = self._safe_url(location) if location else "-" trace = ( f"method={method.upper()} url={self._safe_url(url)} " f"location={safe_location} history={response.status_code}->{safe_location}" ) self.logger(f"{source}: 已拦截重定向 | {trace}") return DouyuActivityError(f"{source} 请求被重定向,Cookie 已失效,请重新登录({trace})") def _request( self, method: str, url: str, *, source: str, headers: dict[str, str] | None = None, **kwargs, ) -> requests.Response: req_headers = self._headers() if headers: req_headers.update(headers) # 活动 JSON 接口不应自动跳到登录页;忽略调用方传入的覆盖值。 kwargs.pop("allow_redirects", None) try: response = self.session.request( method, url, headers=req_headers, timeout=kwargs.pop("timeout", self.timeout), allow_redirects=False, **kwargs, ) except requests.RequestException as exc: raise DouyuActivityError( f"{source} 请求失败: {method.upper()} {self._safe_url(url)}: {exc}" ) from exc self._merge_response_cookie(response) if 300 <= response.status_code < 400: raise self._redirect_error(method, url, source, response) self.logger(f"{source}: {response.status_code}") response.raise_for_status() return response def _json(self, response: requests.Response, source: str) -> dict[str, Any]: text = response.text.strip() if not text: raise DouyuActivityError(f"{source} 返回为空") try: return response.json() except json.JSONDecodeError as exc: raise DouyuActivityError(f"{source} 返回不是 JSON: {text[:200]}") from exc def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict[str, Any]: return self._json(self._request(method, url, source=source, **kwargs), source) def csrf_token(self) -> str: """获取 cvl_csrf_token。""" payload = self._request_json( "post", self.CSRF_API, "生成 CSRF", data="{}", headers={ "Content-Type": "application/json;charset=UTF-8", "Referer": "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0", }, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "生成 CSRF 失败") token = cookie_value(self.cookie, "cvl_csrf_token") if not token: raise DouyuActivityError("响应 Cookie 中没有 cvl_csrf_token") return token def acf_ccn(self, *, refresh_subscribe: bool = True) -> str: """获取 acf_ccn;必要时通过订阅接口刷新一次。""" response = self._request( "get", self.ACF_CCN_API, source="获取 acf_ccn", headers={ "Origin": "", "Referer": "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0", }, ) ctn = cookie_value(self.cookie, "acf_ccn") if not ctn: response = self._request( "get", self.ACF_CCN_FALLBACK_API, source="获取 acf_ccn 备用接口", headers={"Origin": ""}, ) ctn = cookie_value(self.cookie, "acf_ccn") if not ctn: raise DouyuActivityError(f"获取 acf_ccn 失败: {response.text[:200]}") if refresh_subscribe: payload = self._request_json( "post", self.SUBSCRIBE_API, "刷新订阅状态", data={"subscribe": "cmn", "ctn": ctn}, headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": "https://www.douyu.com/", }, ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "刷新订阅状态失败") ctn = cookie_value(self.cookie, "acf_ccn") or ctn return ctn def get_bind_qr(self, act_alias: str) -> dict[str, Any]: """生成腾讯游戏绑定二维码链接(v2 接口)。""" ctn = self.acf_ccn(refresh_subscribe=True) payload = self._request_json( "post", self.ROLE_PARAM_API, "获取绑定二维码", params={"actAlias": act_alias}, headers={ "Referer": "https://www.douyu.com/", }, ) url = (payload.get("data") or {}).get("url") or "" if not url: raise DouyuActivityError(payload.get("msg") or "获取绑定二维码失败") return {"url": url, "ctn": ctn, "raw": payload} @staticmethod def _first_present(data: dict[str, Any], *keys: str): """按候选 key 取第一个非 None 值。""" for key in keys: if key in data and data.get(key) is not None: return data.get(key) return None def bind_info(self, act_alias: str, *, v2: bool = False) -> dict[str, Any]: """查询绑定游戏账号信息。""" url = self.BIND_INFO_V2_API if v2 else self.BIND_INFO_API payload = self._request_json( "get", url, "查询绑定信息", params={"actAlias": act_alias}, headers={"Origin": "", "Referer": "https://www.douyu.com/"}, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "查询绑定信息失败") parsed = self._parse_bind_info(payload, act_alias=act_alias, api_version="v2" if v2 else "v1") self._log_bind_info(parsed) return parsed def _log_bind_info(self, parsed: dict[str, Any]) -> None: """输出绑定信息接口返回的角色明细(用于排查两个接口/多个 alias 的角色差异)。""" self.logger( f"查询绑定信息明细 api={parsed.get('api_version') or '-'} " f"act={parsed.get('act_alias') or '-'} " f"role={parsed.get('role_name') or '-'} " f"area={parsed.get('area_name') or '-'} plat={parsed.get('plat_name') or '-'} " f"nick={parsed.get('nickname') or '-'} " f"bound_account={1 if parsed.get('is_bound_account') else 0} " f"bound_role={1 if parsed.get('is_bound_role') else 0} " f"bound_act={1 if parsed.get('is_bound_act') else 0} " f"need_act={1 if parsed.get('need_bind_act') else 0} " f"need_role={1 if parsed.get('need_bind_role') else 0} " f"wait={parsed.get('change_role_wait_time') if parsed.get('change_role_wait_time') is not None else '-'} " f"can_change_role={parsed.get('can_change_role')} " f"can_change_time={parsed.get('can_change_time') if parsed.get('can_change_time') is not None else '-'}" ) def _parse_bind_info(self, payload: dict[str, Any], *, act_alias: str, api_version: str) -> dict[str, Any]: """统一解析不同活动的腾讯游戏绑定信息。""" data = payload.get("data") or {} game_account = data.get("gameAccount") or {} game_role = data.get("gameRole") or {} tx_act = data.get("txAct") or {} bind_account = data.get("bindAccount") bind_role = data.get("bindRole") bind_act = data.get("bindAct") # 换绑倒计时在不同 alias/版本字段可能不一致,尽量兼容。 change_role_wait_time = self._first_present( data, "changeRoleWaitTime", "change_role_wait_time", "changeBindWaitTime", "changBindTime", ) if change_role_wait_time is None and isinstance(tx_act, dict): change_role_wait_time = self._first_present( tx_act, "changeRoleWaitTime", "changeBindWaitTime", "changBindTime", ) can_change_role = self._first_present( data, "canChangeRole", "can_change_role", "canChangeBind", ) if can_change_role is None and isinstance(tx_act, dict): can_change_role = self._first_present(tx_act, "canChangeRole", "canChangeBind") can_change_time = self._first_present( data, "canChangeTime", "can_change_time", ) if can_change_time is None and isinstance(tx_act, dict): can_change_time = self._first_present(tx_act, "canChangeTime", "can_change_time") return { "act_alias": act_alias, "api_version": api_version, "bind_account": bind_account, "bind_role": bind_role, "bind_act": bind_act, "need_bind_role": data.get("needBindRole"), "need_bind_act": data.get("needBindAct"), "is_bound_account": str(bind_account) == "1", "is_bound_role": str(bind_role) == "1", "is_bound_act": str(bind_act) == "1", "nickname": game_account.get("nick") or "", "role_name": game_role.get("roleName") or "", "area_name": game_role.get("areaName") or "", "plat_name": game_role.get("platName") or "", "tx_act": tx_act if isinstance(tx_act, dict) else {}, "change_role_wait_time": change_role_wait_time, "can_change_role": can_change_role, "can_change_time": can_change_time, "raw": payload, } def esports_bind_info(self, act_alias: str) -> dict[str, Any]: """电竞手册唯一的角色状态查询,返回角色、绑定状态和换绑冷却。""" payload = self._request_json( "get", self.ESPORTS_BIND_INFO_API, "查询电竞手册绑定信息", params={"actAlias": act_alias}, headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"}, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "查询电竞手册绑定信息失败") parsed = self._parse_bind_info(payload, act_alias=act_alias, api_version="esports") self._log_bind_info(parsed) return parsed def get_esports_bind_qr(self, act_alias: str) -> dict[str, Any]: """生成电竞手册的腾讯角色绑定二维码链接。""" payload = self._request_json( "get", self.ESPORTS_BIND_ROLE_PARAM_API, "获取电竞手册绑定二维码", params={"actAlias": act_alias}, headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"}, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "获取电竞手册绑定二维码失败") data = payload.get("data") or {} # 电竞手册网页实际打开 data.url;gameRoleBindUrl 是兼容旧入口。 bind_url = data.get("url") or data.get("gameRoleBindUrl") or "" if not bind_url: raise DouyuActivityError(payload.get("msg") or "电竞手册绑定二维码链接为空") return { "url": bind_url, "bind_url": bind_url, "role_param_url": data.get("url") or "", "game_role_bind_url": data.get("gameRoleBindUrl") or "", "act_alias": act_alias, "raw": payload, } def esports_ctn(self, *, referer: str) -> str: """获取电竞手册页面当前 ctn,仅在 Cookie 缺失时走页面 CSRF 接口。""" ctn = cookie_value(self.cookie, "acf_ccn") if ctn: return ctn response = self._request( "get", self.ESPORTS_CSRF_API, source="获取电竞手册 ctn", headers={"Origin": "", "Referer": referer}, ) ctn = cookie_value(self.cookie, "acf_ccn") if not ctn: raise DouyuActivityError(f"获取电竞手册 ctn 失败: {response.text[:200]}") return ctn def confirm_esports_bind(self, act_alias: str, *, room_id: str = "9263298") -> dict[str, Any]: """自动完成协议前置请求后,通过 actBind 确认电竞手册绑定。""" referer = ( "https://www.douyu.com/pages/live-peace-pel/web" f"?roomId={room_id or '9263298'}&isAnchorSide=0" ) # 页面成功流程固定为 agreement -> actBind;协议不作为独立业务状态暴露。 # 两次 POST 都使用当前 Cookie 中的 acf_ccn,不额外主动刷新 CSRF。 agreement_ctn = self.esports_ctn(referer=referer) agreement = self._request_json( "post", self.ESPORTS_AGREEMENT_API, "同意电竞手册协议", files={"ctn": (None, agreement_ctn)}, headers={"Referer": referer}, ) if agreement.get("error") not in (0, "0", None): raise DouyuActivityError(agreement.get("msg") or "同意电竞手册协议失败") bind_ctn = self.esports_ctn(referer=referer) bind = self._request_json( "post", self.ESPORTS_ACT_BIND_API, "完成电竞手册绑定", files={ "actAlias": (None, act_alias), "ctn": (None, bind_ctn), }, headers={"Referer": referer}, ) if bind.get("error") not in (0, "0", None): raise DouyuActivityError(bind.get("msg") or "完成电竞手册绑定失败") return {"agreement": agreement, "act_bind": bind} def confirm_bind(self, act_alias: str) -> dict[str, Any]: """确认绑定当前扫码选择的游戏角色。""" token = self.csrf_token() payload = self._request_json( "post", self.BIND_API, "确认绑定", data={"actAlias": act_alias, "csrfToken": token}, headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": "https://www.douyu.com/", }, ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "确认绑定失败") return {"csrf_token": token, "raw": payload} def create_elite_qr(self, *, ctn: str, act_alias: str, amount: int, room_id: str) -> dict[str, Any]: """生成精英宝典支付二维码。""" # E 语言实现会在 peace/item 和 getQrCode 两次请求里显式拼上 acf_ccn=ctn。 # 这里也同步 Cookie,避免接口只看 Cookie 时误判未绑定游戏账号。 self._set_cookie_value("acf_ccn", ctn) item_payload = self._request_json( "post", self.PEACE_ITEM_API, "校验精英宝典", json={"itemId": 1, "ctn": ctn}, headers={"Content-Type": "application/json;charset=UTF-8"}, ) if item_payload.get("error") not in (0, "0"): raise DouyuActivityError(item_payload.get("msg") or "精英宝典校验失败") payload = self._request_json( "post", self.PAY_QR_API, "生成精英宝典支付码", json={ "bizType": "peace", "biz": {"actAlias": act_alias, "amount": amount, "roomId": int(room_id or 0)}, "ctn": ctn, }, headers={"Content-Type": "application/json;charset=UTF-8"}, ) qr = (payload.get("data") or {}).get("qr") or "" if not qr: raise DouyuActivityError(payload.get("msg") or "生成精英宝典支付码失败") return {"pay_url": qr, "ctn": ctn, "act_alias": act_alias, "item": item_payload, "raw": payload} def create_esports_qr(self, *, ctn: str, act_alias: str, amount: int, room_id: str) -> dict[str, Any]: """生成电竞手册支付二维码。""" self._set_cookie_value("acf_ccn", ctn) time_payload = self._request_json( "get", self.PEACE_TIME_API, "校验电竞手册开通时间", headers={"Referer": "https://www.douyu.com/pages/live-peace-pel/web/"}, ) if time_payload.get("error") not in (0, "0"): raise DouyuActivityError(time_payload.get("msg") or "电竞手册当前不可开通") payload = self._request_json( "post", self.PAY_QR_API, "生成电竞手册支付码", json={ "bizType": "peacePel", "biz": {"actAlias": act_alias, "amount": amount, "roomId": int(room_id or 0)}, "ctn": ctn, }, headers={ "Content-Type": "application/json;charset=UTF-8", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/", }, ) data = payload.get("data") or {} qr = data.get("qr") or "" if not qr: raise DouyuActivityError(payload.get("msg") or "生成电竞手册支付码失败") return { "pay_url": qr, "pay_token": data.get("token") or "", "pay_ttl": data.get("ttl"), "ctn": ctn, "act_alias": act_alias, "time": time_payload, "raw": payload, } def esports_user_info(self, *, manual_id: str) -> dict[str, Any]: """查询电竞手册的开通状态和积分。""" payload = self._request_json( "get", self.ESPORTS_USER_INFO_API, "查询电竞手册状态", params={"manualID": manual_id}, headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"}, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "查询电竞手册状态失败") user_info = (payload.get("data") or {}).get("userInfo") or {} return { "manual_id": manual_id, "manual_type": user_info.get("manualType"), "manual_score": user_info.get("manualScore"), "expire_time": user_info.get("expireTime"), "user_info": user_info, "raw": payload, } @staticmethod def _esports_shop_referer(rid: str) -> str: return ( "https://www.douyu.com/pages/live-peace-pel/web/shop" f"?roomId={rid or '0'}&isAnchorSide=0" ) def list_esports_goods(self, *, manual_id: str, rid: str) -> dict[str, Any]: """查询电竞手册皮肤商城。""" payload = self._request_json( "get", self.ESPORTS_GOODS_API, "刷新电竞皮肤商城", params={"manualID": manual_id, "rid": rid}, headers={ "Origin": "", "Referer": self._esports_shop_referer(rid), }, ) goods = (payload.get("data") or {}).get("list") or [] return { "goods": goods, "score": (payload.get("data") or {}).get("score"), "raw": payload, } def exchange_esports_goods( self, *, manual_id: str, rid: str, commodity_id: str, quantity: int = 1, ctn: str | None = None, ) -> dict[str, Any]: """兑换电竞手册皮肤商城商品。""" referer = self._esports_shop_referer(rid) ctn_value = ctn or self.esports_ctn(referer=referer) self._set_cookie_value("acf_ccn", ctn_value) payload = self._request_json( "post", self.ESPORTS_EXCHANGE_API, "兑换电竞皮肤", data={ "manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0", "num": str(max(1, quantity)), "ctn": ctn_value, }, headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": "", "Referer": referer, }, ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "兑换电竞皮肤失败") return { "commodity_id": commodity_id, "quantity": max(1, quantity), "ctn": ctn_value, "raw": payload, } def create_gold_qr(self, *, amount: int, pay_type: int = 1, product_id: str = "DYTV_Product_Gold_1") -> dict[str, Any]: """生成鱼翅充值支付二维码。""" payload = self._request_json( "post", self.GOLD_QR_API, "生成鱼翅充值码", data={ "pay_type": str(pay_type), "product_id": product_id, "number": str(amount), "toname": "", "source": "dy_box", }, headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": "https://cz.douyu.com", "Referer": "https://cz.douyu.com/", }, ) data = payload.get("data") or {} pay_url = data.get("code_url") or payload.get("code_url") or "" token = data.get("token") or payload.get("token") or "" if not pay_url: raise DouyuActivityError(payload.get("msg") or "生成鱼翅充值码失败") return {"pay_url": pay_url, "token": token, "raw": payload} def _donate_gift( self, *, gift_count: int, room_id: str, gift_id: str, skin_id: str, source: str, ) -> dict[str, Any]: """赠送直播间礼物。""" payload = self._request_json( "post", self.DONATE_API, source, data={ "giftId": gift_id, "giftCount": str(gift_count), "roomId": room_id, "bizExt": json.dumps({"isMainGift": 1}, separators=(",", ":")), "skinId": skin_id, }, headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": f"https://www.douyu.com/{room_id}", }, ) if payload.get("error") not in (0, "0") and payload.get("msg") != "success": raise DouyuActivityError(payload.get("msg") or f"{source}失败") return {"raw": payload} def donate_elite_gift( self, *, gift_count: int, room_id: str, gift_id: str, skin_id: str, ) -> dict[str, Any]: """赠送精英令礼物。""" return self._donate_gift( gift_count=gift_count, room_id=room_id, gift_id=gift_id, skin_id=skin_id, source="赠送精英令", ) def donate_esports_gift( self, *, gift_name: str, gift_count: int, room_id: str, gift_id: str, skin_id: str, ) -> dict[str, Any]: """赠送电竞手册任务指定礼物。""" return self._donate_gift( gift_count=gift_count, room_id=room_id, gift_id=gift_id, skin_id=skin_id, source=f"赠送{gift_name}", ) def query_points(self, *, uid: str, ctn: str) -> dict[str, Any]: """查询手册积分余额。""" payload = self._request_json( "post", self.CREDIT_BALANCE_API, "查询手册积分", json={"uid": str(uid), "ctn": ctn}, headers={"Content-Type": "application/json;charset=UTF-8"}, ) data = payload.get("data") or {} return {"points": data.get("balance"), "raw": payload} def query_points_diff(self, *, uid: str, ctn: str) -> dict[str, Any]: """查询手册积分差额。""" payload = self._request_json( "post", self.CREDIT_DIFF_API, "查询手册积分差额", json={"userId": str(uid), "ctn": ctn}, headers={"Content-Type": "application/json;charset=UTF-8"}, ) data = payload.get("data") or {} return {"diff": data.get("diff"), "raw": payload} def list_goods(self, *, manual_id: str, rid: str) -> dict[str, Any]: """刷新活动商品列表。""" payload = self._request_json( "get", self.GOODS_API, "刷新商品列表", params={"manualID": manual_id, "rid": rid}, headers={ "Origin": "", "Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}", }, ) goods = (payload.get("data") or {}).get("list") or [] return {"goods": goods, "raw": payload} def query_limited_goods(self, *, manual_id: str, rid: str) -> dict[str, Any]: """查询当前账号限兑商品。""" result = self.list_goods(manual_id=manual_id, rid=rid) limited = [ item for item in result["goods"] if str(item.get("status") or "") == "1" ] return {"limited_goods": limited, "raw": result["raw"]} def exchange_records(self, *, manual_id: str, limit: int = 100, offset: int = 0) -> dict[str, Any]: """查询兑换记录。""" payload = self._request_json( "get", self.EXCHANGE_LIST_API, "查询兑换记录", params={"manualID": manual_id, "limit": limit, "offset": offset}, headers={"Origin": "", "Referer": "https://www.douyu.com/"}, ) records = (payload.get("data") or {}).get("list") or [] return {"records": records, "raw": payload} @staticmethod def _multipart_fields(data: dict[str, Any]) -> dict[str, tuple[None, str]]: """Build requests multipart fields without uploading files.""" return {key: (None, str(value)) for key, value in data.items()} def create_exchange_order( self, *, manual_id: str, rid: str, commodity_id: str, num: int = 1, ) -> dict[str, Any]: """Lock stock for an activity commodity before spending points.""" if num < 1: raise DouyuActivityError("锁单数量必须大于 0") token = self.csrf_token() payload = self._request_json( "post", self.CREATE_EXCHANGE_ORDER_API, "锁定兑换商品", files=self._multipart_fields({ "manualID": manual_id, "rid": rid, "commodityID": commodity_id, "num": num, "csrfToken": token, }), headers={ "Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}", }, ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "锁定兑换商品失败") data = payload.get("data") or {} order_id = str(data.get("orderId") or "").strip() if not order_id: raise DouyuActivityError("锁定兑换商品成功,但响应中没有 orderId") locked_at = int(time.time()) try: expire_seconds = int(data.get("expireSeconds") or 0) except (TypeError, ValueError): expire_seconds = 0 if expire_seconds > 0: expires_at = locked_at + expire_seconds else: expires_at = None return { "order_id": order_id, "commodity_id": commodity_id, "commodity_name": data.get("commodityName") or "", "commodity_image": data.get("commodityImage") or "", "score": data.get("score"), "num": data.get("num", num), "expire_seconds": data.get("expireSeconds"), "locked_at": locked_at, "expires_at": expires_at, "csrf_token": token, "raw": payload, } def pay_exchange_order(self, *, manual_id: str, order_id: str) -> dict[str, Any]: """Pay a previously locked exchange order with handbook points.""" normalized_order_id = str(order_id).strip() if not normalized_order_id: raise DouyuActivityError("锁单订单号不能为空") token = self.csrf_token() payload = self._request_json( "post", self.PAY_EXCHANGE_ORDER_API, "支付锁单", files=self._multipart_fields({ "orderID": normalized_order_id, "manualID": manual_id, "csrfToken": token, }), headers={ "Referer": "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0", }, ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "支付锁单失败") data = payload.get("data") or {} return { "order_id": str(data.get("orderId") or normalized_order_id), "exchange_id": str(data.get("exchangeId") or ""), "commodity_type": data.get("commodityType"), "commodity_image": data.get("commodityImage") or "", "exchange_num": data.get("exchangeNum"), "s_type": data.get("sType"), "g_type": data.get("gType"), "csrf_token": token, "raw": payload, } def exchange_orders(self, *, manual_id: str, page: int = 1, page_size: int = 10) -> dict[str, Any]: """Query current locked exchange orders.""" payload = self._request_json( "get", self.EXCHANGE_ORDER_LIST_API, "查询锁单", params={"page": page, "pageSize": page_size, "manualID": manual_id}, headers={ "Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-handbook/web/lock-order?ditchname=pass0", }, ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "查询锁单失败") data = payload.get("data") or {} return { "orders": data.get("list") or [], "total": data.get("total") or 0, "raw": payload, } def exchange_goods(self, *, manual_id: str, rid: str, commodity_id: str) -> dict[str, Any]: """Lock an activity commodity and immediately pay the locked order.""" locked = self.create_exchange_order( manual_id=manual_id, rid=rid, commodity_id=commodity_id, num=1, ) paid = self.pay_exchange_order(manual_id=manual_id, order_id=locked["order_id"]) return { "commodity_id": commodity_id, "order_id": locked["order_id"], "exchange_id": paid["exchange_id"], "commodity_image": paid["commodity_image"] or locked["commodity_image"], "exchange_num": paid["exchange_num"], "lock_order": locked, "payment": paid, } def gold_account(self) -> dict[str, Any]: """查询 cz 侧鱼翅余额。""" payload = self._request_json( "get", self.GOLD_ACCOUNT_API, "查询鱼翅余额", headers={"Origin": "https://cz.douyu.com", "Referer": "https://cz.douyu.com/"}, ) data = payload.get("data") or {} return {"gold": data.get("gold"), "raw": payload} def exchange_balance(self) -> dict[str, Any]: """查询钱包兑换中心余额。""" payload = self._request_json( "get", self.EXCHANGE_BALANCE_API, "查询钱包兑换余额", params={"appCode": "YJTX"}, headers={ "Origin": "", "Referer": "https://www.douyu.com/member/walletcenter", }, ) data = payload.get("data") or {} return {"count": data.get("count"), "raw": payload} # ---- 和平小店(腾讯道聚城 H5)---- XPD_EMBED_API = "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl" XPD_H5_REFERER = "https://www.douyu.com/topic/h5/hpxd01" XPD_ROLE_PARAM_API = "https://apiv2.douyucdn.cn/japi/carnivalApi/tencent/roleParam" XPD_BIND_INFO_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo" XPD_DAOJU_REFERER = "https://app.daoju.qq.com/" XPD_GET_ROLE_API = "https://apps.game.qq.com/daoju/igw/live" XPD_ORDER_API = "https://apps.game.qq.com/daoju/igw/live/" # 2026-08 抓包(8.6-和平小店)实证:本期走 xn_live_cjm 变体(旧期为 recommend_live/common) XPD_RECOMMEND_API = "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common" XPD_BALANCE_API = "https://apps.game.qq.com/daoju/igw/live/" XPD_LIVE_BUY_API = "https://apps.game.qq.com/daoju/igw/livebuy/" XPD_GOODS_PAGE_SIZE = 10 XPD_UA = ( "Mozilla/5.0 (Linux; Android 12; HBN-AL00 Build/V417IR; wv) " "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " "Chrome/101.0.4951.61 Mobile Safari/537.36, Douyu_Android" ) @staticmethod def _xpd_int(value: Any) -> int | None: try: return int(value) except (TypeError, ValueError): return None @staticmethod def _xpd_parse_var(text: str, varname: str) -> dict[str, Any]: """解析道聚城 `var xxx={...};` 形式的响应(容忍等号两侧空白)。""" match = re.search(rf"var\s+{varname}\s*=", text) if not match: raise DouyuActivityError(f"响应中找不到 var {varname}=: {text[:200]}") chunk = text[match.end():].strip() if chunk.endswith(";"): chunk = chunk[:-1] try: return json.loads(chunk) except json.JSONDecodeError as exc: raise DouyuActivityError(f"var {varname}= 解析失败: {chunk[:200]}") from exc @classmethod def _xpd_daoju_headers(cls) -> dict[str, str]: return { "User-Agent": cls.XPD_UA, "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9", "X-Requested-With": "air.tv.douyu.android", "Sec-Fetch-Site": "same-site", "Sec-Fetch-Mode": "no-cors", "Sec-Fetch-Dest": "script", "Referer": cls.XPD_DAOJU_REFERER, } def _xpd_token(self) -> str: """拼斗鱼 token: acf_uid_1_acf_stk_0_acf_ltkid。""" uid = cookie_value(self.cookie, "acf_uid") stk = cookie_value(self.cookie, "acf_stk") ltkid = cookie_value(self.cookie, "acf_ltkid") if not uid or not stk or not ltkid: raise DouyuActivityError("Cookie 缺少 acf_uid/acf_stk/acf_ltkid,无法获取小店参数") return f"{uid}_1_{stk}_0_{ltkid}" def xpd_embed_query(self, *, act_alias: str, rid: str) -> dict[str, Any]: """从斗鱼换取和平小店 H5 参数(code/sig/timestamp/actId 等,每次现签)。""" payload = self._request_json( "get", self.XPD_EMBED_API, "获取小店 H5 参数", params={"actAlias": act_alias, "rid": rid, "token": self._xpd_token()}, headers={"Referer": self.XPD_H5_REFERER}, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "获取小店 H5 参数失败") data = payload.get("data") or {} txurl_h5 = str(data.get("txurlH5") or "") if not txurl_h5: raise DouyuActivityError(f"获取小店 H5 参数失败: {payload}") query = parse_qs(urlsplit(txurl_h5).query) embed_query = {key: values[0] for key, values in query.items() if values} return {"query": embed_query, "txurl_h5": txurl_h5, "raw": payload} def xpd_get_role( self, *, embed_query: dict[str, str], act_id: str, rid: str = "", ) -> dict[str, Any]: """用小店 code/sig 换取当前绑定角色信息。 2026-08 起道聚城校验升级:必须带 isCode=1 + authType=delegate + sAnchorId(房间号)。 """ params = { "acctype": "livelink", "_jsvar": "info", "_service": "other.livelink.getrole.out", "_biz_code": "cjm", "_act_id": act_id, "_app_id": "2123", "isCode": "1", "gameId": embed_query.get("gameId") or "cjm", "actId": embed_query.get("actId") or "", "appId": embed_query.get("appId") or "bp_cf", "livePlatId": embed_query.get("livePlatId") or "douyu", "code": embed_query.get("code") or "", "timestamp": embed_query.get("timestamp") or "", "v": embed_query.get("v") or "", "sig": embed_query.get("sig") or "", "authType": "delegate", "sAnchorId": rid, "sVideoId": "", } response = self._request( "get", self.XPD_GET_ROLE_API, source="查询小店绑定角色", params=params, headers=self._xpd_daoju_headers(), ) info = self._xpd_parse_var(response.text, "info") plat_id = info.get("platId") area = info.get("area") return { "game_open_id": str(info.get("gameOpenId") or ""), "role_id": str(info.get("roleId") or ""), "role_name": str(info.get("roleName") or ""), "type": str(info.get("type") or ""), # 0 是合法的 iOS 平台值,不能用 `or` 当作缺失处理。 "plat_id": str(plat_id) if plat_id is not None else "", "area": str(area) if area is not None else "", "raw": info, } def xpd_list_goods( self, *, embed_query: dict[str, str], act_id: str, openid: str, roleid: str, areaid: str = "1", ) -> dict[str, Any]: """查询和平小店全部商品,排除热门抢购分类并拉取全部分页。""" base_params = { "_service": "act.recommend.query", "_biz_code": "cjm", "_app_id": "2123", "app_id": "2123", "fields": ( "iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime," "dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price," "iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime," "dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,dtRushBegin,dtRushEnd" ), "actionFields": "iActionId,sActionName,dtBeginTime,dtEndTime", "order_by": "dtShowBeginTime", "orderColumnBatch": '[{"key":"iSort","sort":"desc"},{"key":"dtShowBeginTime","sort":"desc"},{"key":"iGoodsId","sort":"asc"}]', "desc": "1", "_act_id": act_id, "actid": act_id, # category=76 是“热门抢购”;全部商品页明确将其排除。 "excludeFields": "iCategoryId_76", "userid": openid, "roleid": roleid, "area": "1", "areaid": str(areaid), "need_limit": "1", } goods: list[dict[str, Any]] = [] seen_ids: set[str] = set() pages: list[dict[str, Any]] = [] offset = 0 page_num = 1 while True: params = { **base_params, "page_begin": str(offset), "page_num": str(page_num), "page_size": str(self.XPD_GOODS_PAGE_SIZE), } response = self._request( "get", self.XPD_RECOMMEND_API, source=f"刷新小店全部商品第 {page_num} 页", params=params, headers=self._xpd_daoju_headers(), ) recommend = self._xpd_parse_var(response.text, "recommend") if str(recommend.get("errcode") or "0") != "0": raise DouyuActivityError(str(recommend.get("errdesc") or "小店商品查询失败")) pages.append(recommend) items = (((recommend.get("data") or {}).get("client_data") or {}).get("itemsdetail")) or [] if not isinstance(items, list): raise DouyuActivityError("小店商品响应格式异常") for item in items: if not isinstance(item, dict): continue commodity_id = str(item.get("iGoodsId") or "") if not commodity_id or commodity_id in seen_ids: continue seen_ids.add(commodity_id) goods.append( { "commodity_id": commodity_id, "name": str(item.get("sGoodsName") or ""), "price": self._xpd_int(item.get("iPrice")), "org_price": self._xpd_int(item.get("iOrgPrice")), "category": str(item.get("iCategoryId") or ""), "goods_left": self._xpd_int(item.get("iGoodsLeft")), "raw": item, } ) if len(items) < self.XPD_GOODS_PAGE_SIZE: break offset += self.XPD_GOODS_PAGE_SIZE page_num += 1 if page_num > 100: raise DouyuActivityError("小店商品分页超出安全上限") return {"goods": goods, "raw": {"pages": pages}} def xpd_purchase_records( self, *, embed_query: dict[str, str], act_id: str, page_size: int = 10, max_pages: int = 100, ) -> dict[str, Any]: """查询和平小店购买记录,接口参数来自道聚城 H5 抓包。""" page_size = max(1, min(int(page_size), 50)) records: list[dict[str, Any]] = [] pages: list[dict[str, Any]] = [] total = 0 for page in range(1, max_pages + 1): params = { "_service": "order.list", "_jsvar": "info", "acctype": "livelink", "_app_id": "2123", "hist": "0", "ps": str(page_size), "pn": str(page), "_biz_code": "cjm", "_act_id": str(act_id), "authType": "delegate", "actId": embed_query.get("actId") or "", "appId": embed_query.get("appId") or "bp_cf", "gameId": embed_query.get("gameId") or "cjm", "livePlatId": embed_query.get("livePlatId") or "douyu", "code": embed_query.get("code") or "", "timestamp": embed_query.get("timestamp") or "", "v": embed_query.get("v") or "", "sig": embed_query.get("sig") or "", "_sid": "8", "set": "8", } response = self._request( "get", self.XPD_ORDER_API, source=f"查询小店购买记录第 {page} 页", params=params, headers=self._xpd_daoju_headers(), ) info = self._xpd_parse_var(response.text, "info") if str(info.get("ret") or "0") != "0": raise DouyuActivityError(str(info.get("msg") or "小店购买记录查询失败")) data = info.get("data") if isinstance(info.get("data"), dict) else {} page_records = data.get("list") if isinstance(data.get("list"), list) else [] total = self._xpd_int(data.get("cnt")) or total pages.append(info) for record in page_records: if not isinstance(record, dict): continue goods_info = record.get("sGoodsInfo") if isinstance(goods_info, str): try: goods_info = json.loads(goods_info) except json.JSONDecodeError: goods_info = {} goods_list = goods_info.get("list") if isinstance(goods_info, dict) else [] goods = ( goods_list[0] if isinstance(goods_list, list) and goods_list and isinstance(goods_list[0], dict) else {} ) app_ext = record.get("sAppExt") if isinstance(app_ext, str): try: app_ext = json.loads(app_ext) except json.JSONDecodeError: app_ext = {} records.append({ "serial": str(record.get("sSerialNum") or ""), "goods_name": str( goods.get("sGoodsName") or goods.get("sPacketName") or record.get("sActionDesc") or "" ), "goods_image": str(goods.get("sGoodsPic") or (app_ext or {}).get("sGoodsPic") or ""), "buy_time": str(record.get("dtBuyTime") or ""), "pay_time": str(record.get("dtPayTime") or ""), "status": str(record.get("iStatus") or ""), "price": self._xpd_int(record.get("iPrice")), "pay_amount": self._xpd_int(record.get("iPayAmount")), "pay_type": str(record.get("sPayType") or ""), "role_name": str(record.get("sRoleName") or ""), "send_status": str(goods.get("iSendStatus") or ""), "delivery_message": str(goods.get("deliveryMsg") or ""), "raw": record, }) if len(page_records) < page_size or len(records) >= total: break return {"records": records, "total": total or len(records), "raw": {"pages": pages}} def xpd_bind_qr(self, *, act_alias: str) -> dict[str, Any]: """生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。 返回 data.url(可生成二维码)与 gameRoleBindUrl(直接角色绑定页)。 """ payload = self._request_json( "post", self.XPD_ROLE_PARAM_API, "生成小店绑定二维码", params={"client_sys": "android"}, data={ "token": self._xpd_token(), "uid": cookie_value(self.cookie, "acf_uid") or "", "client": "mobile", "actAlias": act_alias, }, headers={ "User-Device": "MDExZTUxMzEyYmYxMmY5MmQ3YmY1M2M4MDA3MDcxMTF8djcuOS4y", "aid": "android1", "User-Agent": "android/7.9.2 (android 9; ; M973Q)", "Accept-Language": "zh-CN", "Content-Type": "application/x-www-form-urlencoded", }, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "生成小店绑定二维码失败") data = payload.get("data") or {} url = str(data.get("url") or "") if not url: raise DouyuActivityError(f"生成小店绑定二维码失败: {payload}") return { "url": url, "game_role_bind_url": str(data.get("gameRoleBindUrl") or ""), "nick_name": str((data.get("queryParams") or {}).get("nickName") or ""), "raw": payload, } def xpd_bind_info(self, *, act_alias: str) -> dict[str, Any]: """查询和平小店绑定信息(bindInfo)。""" payload = self._request_json( "get", self.XPD_BIND_INFO_API, "查询小店绑定信息", params={"actAlias": act_alias, "token": self._xpd_token()}, ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "查询小店绑定信息失败") data = payload.get("data") or {} game_role = data.get("gameRole") if isinstance(data.get("gameRole"), dict) else {} game_account = data.get("gameAccount") if isinstance(data.get("gameAccount"), dict) else {} return { "bind_account": self._xpd_int(data.get("bindAccount")), "bind_role": self._xpd_int(data.get("bindRole")), "need_bind_role": self._xpd_int(data.get("needBindRole")), "nick": str(game_account.get("nick") or ""), "role_name": str(game_role.get("roleName") or ""), "area_name": str(game_role.get("areaName") or ""), "plat_name": str(game_role.get("platName") or ""), "raw": payload, } def xpd_balance( self, *, embed_query: dict[str, str], act_id: str, openid: str, roleid: str, plat: str, areaid: str = "1", ) -> dict[str, Any]: """查询和平小店角色点券余额。""" params = { "_jsvar": "banlanceInfo", "_service": "pay.midas.dq.get.ttpp", "_app_id": "2123", "acctype": "ttpp", "areaid": str(areaid), "eventid": "", "interwork": "0", "openid": openid, "openkey": "openkey", "partition": "0", "pay_token": "", "plat": str(plat), "plat_pc": "0", "roleid": roleid, "_biz_code": "cjm", "_act_id": act_id, # 抓包中的接口使用毫秒级时间戳。 "_time": str(int(time.time() * 1000)), "_sid": "6", } response = self._request( "get", self.XPD_BALANCE_API, source="查询小店点券余额", params=params, headers=self._xpd_daoju_headers(), ) info = self._xpd_parse_var(response.text, "banlanceInfo") return {"balance": self._xpd_int(info.get("balance")), "raw": info} def xpd_fragments( self, *, embed_query: dict[str, str], act_id: str, openid: str, roleid: str, plat: str, areaid: str = "1", ) -> dict[str, Any]: """查询和平小店角色扭蛋碎片数量(gamecoin jb2)。""" params = { "_jsvar": "jbInfos", "_service": "pay.idip.gamecoin.get", "_app_id": "2123", "acctype": "ttpp", "areaid": str(areaid), "eventid": "", "interwork": "0", "openid": openid, "openkey": "openkey", "partition": "0", "pay_token": "", "plat": str(plat), "plat_pc": "0", "roleid": roleid, "_biz_code": "cjm", "_act_id": act_id, "coin_type": "1", "_sid": "6", } response = self._request( "get", self.XPD_BALANCE_API, source="查询小店扭蛋碎片", params=params, headers=self._xpd_daoju_headers(), ) info = self._xpd_parse_var(response.text, "jbInfos") return {"fragments": self._xpd_int(info.get("jb2")), "raw": info} def xpd_exchange_goods( self, *, embed_query: dict[str, str], act_id: str, rid: str, commodity_id: str, price: int, picture: str = "", pay_type: int = 1, action_id: str = "", ) -> dict[str, Any]: """兑换和平小店商品。 道聚城的 code/sig 为短时授权参数,调用方必须在每次兑换前重新获取。 该接口会实际扣除点券或扭蛋碎片,因此遇到网络错误不做自动重试。 """ if pay_type not in (1, 5): raise DouyuActivityError("小店兑换仅支持点券或扭蛋碎片") if not commodity_id: raise DouyuActivityError("小店兑换缺少商品 ID") if price < 0: raise DouyuActivityError("小店兑换商品价格无效") params = { "_service": "buy.plug.svr.web", "_plug_id": "7200", "_jsvar": "buyInfo", "acctype": "livelink", "_app_id": "2123", "iActionId": action_id or act_id, "propid": commodity_id, "paytype": str(pay_type), "source": rid, "sAnchorId": rid, "attach": json.dumps({"ch": "0"}, separators=(",", ":")), "apptype": "3", "_biz_code": "cjm", "_act_id": act_id, "sLiveUserInfo": json.dumps( {"sAnchorId": rid, "sVideoId": 0}, separators=(",", ":"), ), "appext": json.dumps( {"user_price": price, "sGoodsPic": picture, "paytype": pay_type}, separators=(",", ":"), ), "isCode": "1", "authType": "delegate", "_sid": "8", "set": "8", } for key in ("gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"): value = str(embed_query.get(key) or "") if value: params[key] = value response = self._request( "get", self.XPD_LIVE_BUY_API, source="兑换小店商品", params=params, headers=self._xpd_daoju_headers(), ) info = self._xpd_parse_var(response.text, "buyInfo") if str(info.get("ret")) != "0" or str(info.get("msg") or "").lower() != "ok": raise DouyuActivityError(str(info.get("msg") or "小店兑换失败")) return { "commodity_id": commodity_id, "pay_type": pay_type, "price": price, "serial": str(info.get("serial") or info.get("sSerialNum") or ""), "event_id": str(info.get("event_id") or ""), "new_balance": self._xpd_int(info.get("newBalance")), "raw": info, }