diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index a6c243e..dd40f01 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -11,7 +11,12 @@ 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 +from .cookie_utils import ( + cookie_pairs, + cookie_value, + normalize_cookie_pairs, + normalize_douyu_cookie, +) PC_UA = ( @@ -37,9 +42,15 @@ class DouyuActivityClient: 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" + 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" # 2026-08-30 页面新增链路(对齐浏览器): # 预兑 user/queryExchange -> user/confirmExchange;取消预兑 user/queryCancelExchange -> user/cancelExchange; @@ -47,29 +58,63 @@ class DouyuActivityClient: ELITE_USER_INFO_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getUserInfo" GOODS_DETAIL_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail" EXCHANGE_GIFT_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange" - PRE_EXCHANGE_CHECK_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryExchange" - PRE_EXCHANGE_CONFIRM_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/confirmExchange" - PRE_EXCHANGE_CANCEL_CHECK_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryCancelExchange" - PRE_EXCHANGE_CANCEL_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelExchange" - COMMODITY_SUBSCRIBE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/sub" - COMMODITY_UNSUBSCRIBE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelSub" - BATCH_EXCHANGE_LIMIT_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/batchExchangeLimit" - AUTO_CONVERT_QUERY_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvert" + PRE_EXCHANGE_CHECK_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryExchange" + ) + PRE_EXCHANGE_CONFIRM_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/user/confirmExchange" + ) + PRE_EXCHANGE_CANCEL_CHECK_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryCancelExchange" + ) + PRE_EXCHANGE_CANCEL_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelExchange" + ) + COMMODITY_SUBSCRIBE_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/user/sub" + ) + COMMODITY_UNSUBSCRIBE_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelSub" + ) + BATCH_EXCHANGE_LIMIT_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/batchExchangeLimit" + ) + AUTO_CONVERT_QUERY_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvert" + ) AUTO_CONVERT_SAVE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvertSave" - EXCHANGE_HOME_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/exchangeHome" + EXCHANGE_HOME_API = ( + "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/exchangeHome" + ) 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" + 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" @@ -91,11 +136,13 @@ class DouyuActivityClient: if proxies: self.session.proxies.update(proxies) 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.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 @@ -114,18 +161,22 @@ class DouyuActivityClient: 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.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.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]: @@ -158,7 +209,9 @@ class DouyuActivityClient: f"location={safe_location} history={response.status_code}->{safe_location}" ) self.logger(f"{source}: 已拦截重定向 | {trace}") - return DouyuActivityError(f"{source} 请求被重定向,Cookie 已失效,请重新登录({trace})") + return DouyuActivityError( + f"{source} 请求被重定向,Cookie 已失效,请重新登录({trace})" + ) def _request( self, @@ -203,7 +256,9 @@ class DouyuActivityClient: 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]: + 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, *, force_refresh: bool = False) -> str: @@ -252,7 +307,9 @@ class DouyuActivityClient: """精英手册商城页 Referer(对齐浏览器 create/pay/storedetail 等接口)。""" if rid: return f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}" - return "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0" + return ( + "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0" + ) def _post_csrf_form( self, @@ -363,7 +420,9 @@ class DouyuActivityClient: ) 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") + parsed = self._parse_bind_info( + payload, act_alias=act_alias, api_version="v2" if v2 else "v1" + ) self._log_bind_info(parsed) return parsed @@ -385,7 +444,9 @@ class DouyuActivityClient: 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]: + 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 {} @@ -416,14 +477,18 @@ class DouyuActivityClient: "canChangeBind", ) if can_change_role is None and isinstance(tx_act, dict): - can_change_role = self._first_present(tx_act, "canChangeRole", "canChangeBind") + 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") + can_change_time = self._first_present( + tx_act, "canChangeTime", "can_change_time" + ) return { "act_alias": act_alias, "api_version": api_version, @@ -453,11 +518,16 @@ class DouyuActivityClient: self.ESPORTS_BIND_INFO_API, "查询电竞手册绑定信息", params={"actAlias": act_alias}, - headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"}, + 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") + parsed = self._parse_bind_info( + payload, act_alias=act_alias, api_version="esports" + ) self._log_bind_info(parsed) return parsed @@ -468,7 +538,10 @@ class DouyuActivityClient: self.ESPORTS_BIND_ROLE_PARAM_API, "获取电竞手册绑定二维码", params={"actAlias": act_alias}, - headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"}, + 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 "获取电竞手册绑定二维码失败") @@ -503,7 +576,9 @@ class DouyuActivityClient: raise DouyuActivityError(f"获取电竞手册 ctn 失败: {response.text[:200]}") return ctn - def confirm_esports_bind(self, act_alias: str, *, room_id: str = "9263298") -> dict[str, Any]: + 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" @@ -555,7 +630,9 @@ class DouyuActivityClient: 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]: + 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 时误判未绑定游戏账号。 @@ -576,7 +653,11 @@ class DouyuActivityClient: "生成精英宝典支付码", json={ "bizType": "peace", - "biz": {"actAlias": act_alias, "amount": amount, "roomId": int(room_id or 0)}, + "biz": { + "actAlias": act_alias, + "amount": amount, + "roomId": int(room_id or 0), + }, "ctn": ctn, }, headers={"Content-Type": "application/json;charset=UTF-8"}, @@ -584,9 +665,17 @@ class DouyuActivityClient: 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} + 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]: + 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( @@ -604,7 +693,11 @@ class DouyuActivityClient: "生成电竞手册支付码", json={ "bizType": "peacePel", - "biz": {"actAlias": act_alias, "amount": amount, "roomId": int(room_id or 0)}, + "biz": { + "actAlias": act_alias, + "amount": amount, + "roomId": int(room_id or 0), + }, "ctn": ctn, }, headers={ @@ -633,7 +726,10 @@ class DouyuActivityClient: self.ESPORTS_USER_INFO_API, "查询电竞手册状态", params={"manualID": manual_id}, - headers={"Origin": "", "Referer": "https://www.douyu.com/pages/live-peace-pel/web/"}, + 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 "查询电竞手册状态失败") @@ -712,7 +808,9 @@ class DouyuActivityClient: "raw": payload, } - def create_gold_qr(self, *, amount: int, pay_type: int = 1, product_id: str = "DYTV_Product_Gold_1") -> dict[str, Any]: + 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", @@ -846,12 +944,13 @@ class DouyuActivityClient: """查询当前账号限兑商品。""" result = self.list_goods(manual_id=manual_id, rid=rid) limited = [ - item for item in result["goods"] - if str(item.get("status") or "") == "1" + 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]: + def exchange_records( + self, *, manual_id: str, limit: int = 100, offset: int = 0 + ) -> dict[str, Any]: """查询兑换记录。""" payload = self._request_json( "get", @@ -918,7 +1017,9 @@ class DouyuActivityClient: "raw": payload, } - def pay_exchange_order(self, *, manual_id: str, order_id: str, rid: str = "") -> dict[str, Any]: + def pay_exchange_order( + self, *, manual_id: str, order_id: str, rid: 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: @@ -946,7 +1047,9 @@ class DouyuActivityClient: "raw": payload, } - def exchange_orders(self, *, manual_id: str, page: int = 1, page_size: int = 10) -> dict[str, Any]: + 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", @@ -967,7 +1070,9 @@ class DouyuActivityClient: "raw": payload, } - def exchange_goods(self, *, manual_id: str, rid: str, commodity_id: str) -> dict[str, Any]: + 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, @@ -1011,7 +1116,9 @@ class DouyuActivityClient: "raw": payload, } - def query_goods_detail(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + def query_goods_detail( + self, *, manual_id: str, commodity_id: str, rid: str = "" + ) -> dict[str, Any]: """查询商品详情 storedetail(对齐浏览器 getGoodsDetail)。 详情里带有兑换状态机所需的全部标记:preExchange.isExists / subscribeType / @@ -1021,7 +1128,11 @@ class DouyuActivityClient: "get", self.GOODS_DETAIL_API, "查询商品详情", - params={"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"}, + params={ + "manualID": manual_id, + "commodityID": commodity_id, + "rid": rid or "0", + }, headers={"Origin": "", "Referer": self._peace_shop_referer(rid)}, ) if payload.get("error") not in (0, "0"): @@ -1103,7 +1214,9 @@ class DouyuActivityClient: plan.update(action="classic", text="立即兑换") return plan - def batch_exchange_limit(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + def batch_exchange_limit( + self, *, manual_id: str, commodity_id: str, rid: str = "" + ) -> dict[str, Any]: """查询批量兑换上限(对齐页面 batchExchangeLimit,仅 batchExchange=1 商品)。""" payload = self._post_csrf_form( self.BATCH_EXCHANGE_LIMIT_API, @@ -1116,7 +1229,9 @@ class DouyuActivityClient: data = payload.get("data") or {} return {"limit": data.get("limit"), "raw": payload} - def pre_exchange_check(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + def pre_exchange_check( + self, *, manual_id: str, commodity_id: str + ) -> dict[str, Any]: """查询预兑状态(对齐页面 user/queryExchange,预兑弹窗前必查)。""" payload = self._request_json( "get", @@ -1129,7 +1244,9 @@ class DouyuActivityClient: raise DouyuActivityError(payload.get("msg") or "查询预兑状态失败") return {"data": payload.get("data") or {}, "raw": payload} - def pre_exchange_cancel_check(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + def pre_exchange_cancel_check( + self, *, manual_id: str, commodity_id: str + ) -> dict[str, Any]: """查询取消预兑信息(对齐页面 user/queryCancelExchange)。""" payload = self._request_json( "get", @@ -1142,7 +1259,9 @@ class DouyuActivityClient: raise DouyuActivityError(payload.get("msg") or "查询取消预兑信息失败") return {"data": payload.get("data") or {}, "raw": payload} - def confirm_pre_exchange(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + def confirm_pre_exchange( + self, *, manual_id: str, commodity_id: str, rid: str = "" + ) -> dict[str, Any]: """确认预兑(对齐页面 user/confirmExchange,成功提示"预兑成功")。""" payload = self._post_csrf_form( self.PRE_EXCHANGE_CONFIRM_API, @@ -1154,7 +1273,9 @@ class DouyuActivityClient: raise DouyuActivityError(payload.get("msg") or "确认预兑失败") return {"raw": payload} - def cancel_pre_exchange(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + def cancel_pre_exchange( + self, *, manual_id: str, commodity_id: str, rid: str = "" + ) -> dict[str, Any]: """取消预兑(对齐页面 user/cancelExchange)。""" payload = self._post_csrf_form( self.PRE_EXCHANGE_CANCEL_API, @@ -1166,7 +1287,9 @@ class DouyuActivityClient: raise DouyuActivityError(payload.get("msg") or "取消预兑失败") return {"raw": payload} - def subscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + def subscribe_commodity( + self, *, manual_id: str, commodity_id: str + ) -> dict[str, Any]: """预约到货(对齐页面 user/sub,成功提示"预约成功")。""" payload = self._post_csrf_form( self.COMMODITY_SUBSCRIBE_API, @@ -1178,7 +1301,9 @@ class DouyuActivityClient: raise DouyuActivityError(payload.get("msg") or "预约到货失败") return {"raw": payload} - def unsubscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + def unsubscribe_commodity( + self, *, manual_id: str, commodity_id: str + ) -> dict[str, Any]: """取消预约到货(对齐页面 user/cancelSub)。""" payload = self._post_csrf_form( self.COMMODITY_UNSUBSCRIBE_API, @@ -1190,7 +1315,9 @@ class DouyuActivityClient: raise DouyuActivityError(payload.get("msg") or "取消预约到货失败") return {"raw": payload} - def exchange_gift(self, *, manual_id: str, commodity_id: str, rid: str = "", num: int = 1) -> dict[str, Any]: + def exchange_gift( + self, *, manual_id: str, commodity_id: str, rid: str = "", num: int = 1 + ) -> dict[str, Any]: """单步兑换(对齐页面 postExchangeGift -> /ordnc/activity/peace/exchange)。 2026-08-30 bundle 中新链路,页面主要用于兑换中心/自动兑换场景;字段按页面 @@ -1263,7 +1390,10 @@ class DouyuActivityClient: "get", self.GOLD_ACCOUNT_API, "查询鱼翅余额", - headers={"Origin": "https://cz.douyu.com", "Referer": "https://cz.douyu.com/"}, + headers={ + "Origin": "https://cz.douyu.com", + "Referer": "https://cz.douyu.com/", + }, ) data = payload.get("data") or {} return {"gold": data.get("gold"), "raw": payload} @@ -1315,7 +1445,7 @@ class DouyuActivityClient: match = re.search(rf"var\s+{varname}\s*=", text) if not match: raise DouyuActivityError(f"响应中找不到 var {varname}=: {text[:200]}") - chunk = text[match.end():].strip() + chunk = text[match.end() :].strip() if chunk.endswith(";"): chunk = chunk[:-1] try: @@ -1342,7 +1472,9 @@ class DouyuActivityClient: 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,无法获取小店参数") + 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]: @@ -1473,9 +1605,15 @@ class DouyuActivityClient: ) recommend = self._xpd_parse_var(response.text, "recommend") if str(recommend.get("errcode") or "0") != "0": - raise DouyuActivityError(str(recommend.get("errdesc") or "小店商品查询失败")) + raise DouyuActivityError( + str(recommend.get("errdesc") or "小店商品查询失败") + ) pages.append(recommend) - items = (((recommend.get("data") or {}).get("client_data") or {}).get("itemsdetail")) or [] + items = ( + ((recommend.get("data") or {}).get("client_data") or {}).get( + "itemsdetail" + ) + ) or [] if not isinstance(items, list): raise DouyuActivityError("小店商品响应格式异常") @@ -1554,8 +1692,12 @@ class DouyuActivityClient: 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 [] + data: dict[str, Any] = ( + info.get("data") if isinstance(info.get("data"), dict) else {} + ) + page_records: list[Any] = ( + data.get("list") if isinstance(data.get("list"), list) else [] + ) total = self._xpd_int(data.get("cnt")) or total pages.append(info) @@ -1568,10 +1710,14 @@ class DouyuActivityClient: goods_info = json.loads(goods_info) except json.JSONDecodeError: goods_info = {} - goods_list = goods_info.get("list") if isinstance(goods_info, dict) else [] + 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) + if isinstance(goods_list, list) + and goods_list + and isinstance(goods_list[0], dict) else {} ) app_ext = record.get("sAppExt") @@ -1580,31 +1726,41 @@ class DouyuActivityClient: 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, - }) + 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}} + return { + "records": records, + "total": total or len(records), + "raw": {"pages": pages}, + } def xpd_bind_qr(self, *, act_alias: str) -> dict[str, Any]: """生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。 @@ -1653,9 +1809,15 @@ class DouyuActivityClient: ) 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 {} + data: dict[str, Any] = ( + payload.get("data") if isinstance(payload.get("data"), dict) else {} + ) + game_role: dict[str, Any] = ( + data.get("gameRole") if isinstance(data.get("gameRole"), dict) else {} + ) + game_account: dict[str, Any] = ( + 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")), @@ -1790,7 +1952,8 @@ class DouyuActivityClient: "_biz_code": "cjm", "_act_id": act_id, "sLiveUserInfo": json.dumps( - {"sAnchorId": rid, "sVideoId": 0}, separators=(",", ":"), + {"sAnchorId": rid, "sVideoId": 0}, + separators=(",", ":"), ), "appext": json.dumps( {"user_price": price, "sGoodsPic": picture, "paytype": pay_type}, @@ -1801,7 +1964,16 @@ class DouyuActivityClient: "_sid": "8", "set": "8", } - for key in ("gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"): + for key in ( + "gameId", + "actId", + "appId", + "livePlatId", + "code", + "timestamp", + "v", + "sig", + ): value = str(embed_query.get(key) or "") if value: params[key] = value diff --git a/core/douyu/cookie_utils.py b/core/douyu/cookie_utils.py index 6d0040b..c2bd6be 100644 --- a/core/douyu/cookie_utils.py +++ b/core/douyu/cookie_utils.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -import requests +from requests.cookies import RequestsCookieJar def cookie_pairs(cookie: str) -> list[tuple[str, str]]: @@ -21,7 +21,7 @@ def cookie_pairs(cookie: str) -> list[tuple[str, str]]: return pairs -def normalize_cookie_pairs(pairs: Iterable[tuple[str, str]]) -> str: +def normalize_cookie_pairs(pairs: Iterable[tuple[str, str | None]]) -> str: """按 Cookie key 去重,保留最后一次出现的值。""" ordered_keys: list[str] = [] values: dict[str, str] = {} @@ -36,7 +36,7 @@ def normalize_cookie_pairs(pairs: Iterable[tuple[str, str]]) -> str: def normalize_douyu_cookie( - cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None, + cookie: str | Mapping[str, str] | RequestsCookieJar | None, ) -> str: """把 Cookie 字符串、dict 或 CookieJar 转成去重后的浏览器 Cookie 字符串。""" if not cookie: @@ -47,7 +47,7 @@ def normalize_douyu_cookie( def cookie_value( - cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None, + cookie: str | Mapping[str, str] | RequestsCookieJar | None, key: str, ) -> str: """从 Cookie 中读取 key;有重复时以最后一次出现为准。""" diff --git a/core/douyu/proxy_fetcher.py b/core/douyu/proxy_fetcher.py index 0c8f779..42d1a1e 100644 --- a/core/douyu/proxy_fetcher.py +++ b/core/douyu/proxy_fetcher.py @@ -10,8 +10,6 @@ import threading import time -from typing import Optional - from loguru import logger from .proxy_resolver import ProxyResolver @@ -28,8 +26,8 @@ class ProxyFetcher: self, api_url: str, whitelist_platform: str = "xiequ", - whitelist_credentials: dict = None, - stop_event: Optional[threading.Event] = None, + whitelist_credentials: dict | None = None, + stop_event: threading.Event | None = None, ): self.api_url = api_url self.stop_event = stop_event @@ -66,7 +64,7 @@ class ProxyFetcher: return True, f"出口 IP {local_ip} 已同步: {msg}" return False, f"出口 IP {local_ip} 同步失败: {msg}" - def fetch_new_proxy(self, max_attempts: int = 3) -> Optional[str]: + def fetch_new_proxy(self, max_attempts: int = 3) -> str | None: """从代理 API 获取 1 个可用代理,失败返回 None。 不主动检测/同步本地出口 IP;如果代理 API 返回"白名单错误", @@ -115,7 +113,7 @@ class ProxyFetcher: def get_proxy_fetcher( api_url: str, whitelist_platform: str = "xiequ", - whitelist_credentials: dict = None, + whitelist_credentials: dict | None = None, ) -> ProxyFetcher: """获取代理获取器实例。""" return ProxyFetcher( diff --git a/core/douyu/proxy_resolver.py b/core/douyu/proxy_resolver.py index 027cea8..2249dbb 100644 --- a/core/douyu/proxy_resolver.py +++ b/core/douyu/proxy_resolver.py @@ -165,7 +165,7 @@ class ProxyResolver: def resolve_working_proxy( api_url: str, whitelist_platform: str = "xiequ", - whitelist_credentials: dict = None, + whitelist_credentials: dict | None = None, max_attempts: int = 4, log_func: Optional[LogFunc] = None, ) -> tuple[Optional[str], str]: