type: 收窄豆鱼服务层类型

This commit is contained in:
yml2213
2026-08-30 19:48:41 +08:00
parent 17577ff734
commit 6a16d561d2
4 changed files with 284 additions and 114 deletions
+275 -103
View File
@@ -11,7 +11,12 @@ from urllib.parse import parse_qs, unquote, urlsplit, urlunsplit
import requests 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 = ( PC_UA = (
@@ -37,9 +42,15 @@ class DouyuActivityClient:
BIND_INFO_V2_API = "https://www.douyu.com/japi/carnivalApi/v2/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" BIND_API = "https://www.douyu.com/japi/carnivalApi/tencent/bind"
GOODS_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storehome" GOODS_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storehome"
CREATE_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/createExchangeOrder" CREATE_EXCHANGE_ORDER_API = (
PAY_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/payExchangeOrder" "https://www.douyu.com/wgapi/ordnc/activity/peace/createExchangeOrder"
EXCHANGE_ORDER_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getExchangeOrderList" )
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" EXCHANGE_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList"
# 2026-08-30 页面新增链路(对齐浏览器): # 2026-08-30 页面新增链路(对齐浏览器):
# 预兑 user/queryExchange -> user/confirmExchange;取消预兑 user/queryCancelExchange -> user/cancelExchange # 预兑 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" 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" GOODS_DETAIL_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail"
EXCHANGE_GIFT_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange" 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_CHECK_API = (
PRE_EXCHANGE_CONFIRM_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/confirmExchange" "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryExchange"
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" PRE_EXCHANGE_CONFIRM_API = (
COMMODITY_SUBSCRIBE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/sub" "https://www.douyu.com/wgapi/ordnc/activity/peace/user/confirmExchange"
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" PRE_EXCHANGE_CANCEL_CHECK_API = (
AUTO_CONVERT_QUERY_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvert" "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" 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_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" 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_ITEM_API = "https://www.douyu.com/japi/oms/web/peace/item"
PEACE_TIME_API = "https://www.douyu.com/japi/oms/web/peace/time" PEACE_TIME_API = "https://www.douyu.com/japi/oms/web/peace/time"
PAY_QR_API = "https://www.douyu.com/japi/oms/web/pay/getQrCode" 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_INFO_API = (
ESPORTS_BIND_ROLE_PARAM_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/bindRoleParam" "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/bindInfo"
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_BIND_ROLE_PARAM_API = (
ESPORTS_ACT_BIND_API = "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/actBind" "https://www.douyu.com/wgapi/ordnc/live/memberthird/tencent/bindRoleParam"
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_CSRF_API = (
ESPORTS_EXCHANGE_API = "https://www.douyu.com/wgapi/ordnc/activity/peacematch/exchange" "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_QR_API = "https://cz.douyu.com/m/gold/getQrCode"
GOLD_ACCOUNT_API = "https://cz.douyu.com/item/gold/account" GOLD_ACCOUNT_API = "https://cz.douyu.com/item/gold/account"
EXCHANGE_BALANCE_API = "https://www.douyu.com/wjapi/nc/exchange/fim" EXCHANGE_BALANCE_API = "https://www.douyu.com/wjapi/nc/exchange/fim"
@@ -91,11 +136,13 @@ class DouyuActivityClient:
if proxies: if proxies:
self.session.proxies.update(proxies) self.session.proxies.update(proxies)
self.session.trust_env = False self.session.trust_env = False
self.session.headers.update({ self.session.headers.update(
"User-Agent": PC_UA, {
"Accept": "application/json, text/plain, */*", "User-Agent": PC_UA,
"Accept-Language": "zh-CN,zh;q=0.9", "Accept": "application/json, text/plain, */*",
}) "Accept-Language": "zh-CN,zh;q=0.9",
}
)
self._load_cookie(self.cookie) self._load_cookie(self.cookie)
@staticmethod @staticmethod
@@ -114,18 +161,22 @@ class DouyuActivityClient:
def _merge_response_cookie(self, response: requests.Response) -> None: def _merge_response_cookie(self, response: requests.Response) -> None:
if not response.cookies: if not response.cookies:
return return
self.cookie = normalize_cookie_pairs([ self.cookie = normalize_cookie_pairs(
*cookie_pairs(self.cookie), [
*[(key, value) for key, value in response.cookies.items()], *cookie_pairs(self.cookie),
]) *[(key, value) for key, value in response.cookies.items()],
]
)
self._load_cookie(self.cookie) self._load_cookie(self.cookie)
def _set_cookie_value(self, key: str, value: str) -> None: def _set_cookie_value(self, key: str, value: str) -> None:
"""写入单个 Cookie 值,确保手动 Cookie 请求头与 Session 一致。""" """写入单个 Cookie 值,确保手动 Cookie 请求头与 Session 一致。"""
self.cookie = normalize_cookie_pairs([ self.cookie = normalize_cookie_pairs(
*cookie_pairs(self.cookie), [
(key, value), *cookie_pairs(self.cookie),
]) (key, value),
]
)
self._load_cookie(self.cookie) self._load_cookie(self.cookie)
def _headers(self, referer: str = "https://www.douyu.com/") -> dict[str, str]: 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}" f"location={safe_location} history={response.status_code}->{safe_location}"
) )
self.logger(f"{source}: 已拦截重定向 | {trace}") self.logger(f"{source}: 已拦截重定向 | {trace}")
return DouyuActivityError(f"{source} 请求被重定向,Cookie 已失效,请重新登录({trace}") return DouyuActivityError(
f"{source} 请求被重定向,Cookie 已失效,请重新登录({trace}"
)
def _request( def _request(
self, self,
@@ -203,7 +256,9 @@ class DouyuActivityClient:
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise DouyuActivityError(f"{source} 返回不是 JSON: {text[:200]}") from 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) return self._json(self._request(method, url, source=source, **kwargs), source)
def csrf_token(self, *, force_refresh: bool = False) -> str: def csrf_token(self, *, force_refresh: bool = False) -> str:
@@ -252,7 +307,9 @@ class DouyuActivityClient:
"""精英手册商城页 Referer(对齐浏览器 create/pay/storedetail 等接口)。""" """精英手册商城页 Referer(对齐浏览器 create/pay/storedetail 等接口)。"""
if rid: if rid:
return f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={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( def _post_csrf_form(
self, self,
@@ -363,7 +420,9 @@ class DouyuActivityClient:
) )
if payload.get("error") not in (0, "0", None): if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询绑定信息失败") 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) self._log_bind_info(parsed)
return 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 '-'}" 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 {} data = payload.get("data") or {}
game_account = data.get("gameAccount") or {} game_account = data.get("gameAccount") or {}
@@ -416,14 +477,18 @@ class DouyuActivityClient:
"canChangeBind", "canChangeBind",
) )
if can_change_role is None and isinstance(tx_act, dict): 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( can_change_time = self._first_present(
data, data,
"canChangeTime", "canChangeTime",
"can_change_time", "can_change_time",
) )
if can_change_time is None and isinstance(tx_act, dict): 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 { return {
"act_alias": act_alias, "act_alias": act_alias,
"api_version": api_version, "api_version": api_version,
@@ -453,11 +518,16 @@ class DouyuActivityClient:
self.ESPORTS_BIND_INFO_API, self.ESPORTS_BIND_INFO_API,
"查询电竞手册绑定信息", "查询电竞手册绑定信息",
params={"actAlias": act_alias}, 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): if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询电竞手册绑定信息失败") 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) self._log_bind_info(parsed)
return parsed return parsed
@@ -468,7 +538,10 @@ class DouyuActivityClient:
self.ESPORTS_BIND_ROLE_PARAM_API, self.ESPORTS_BIND_ROLE_PARAM_API,
"获取电竞手册绑定二维码", "获取电竞手册绑定二维码",
params={"actAlias": act_alias}, 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): if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "获取电竞手册绑定二维码失败") raise DouyuActivityError(payload.get("msg") or "获取电竞手册绑定二维码失败")
@@ -503,7 +576,9 @@ class DouyuActivityClient:
raise DouyuActivityError(f"获取电竞手册 ctn 失败: {response.text[:200]}") raise DouyuActivityError(f"获取电竞手册 ctn 失败: {response.text[:200]}")
return ctn 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 确认电竞手册绑定。""" """自动完成协议前置请求后,通过 actBind 确认电竞手册绑定。"""
referer = ( referer = (
"https://www.douyu.com/pages/live-peace-pel/web" "https://www.douyu.com/pages/live-peace-pel/web"
@@ -555,7 +630,9 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "确认绑定失败") raise DouyuActivityError(payload.get("msg") or "确认绑定失败")
return {"csrf_token": token, "raw": payload} 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。 # E 语言实现会在 peace/item 和 getQrCode 两次请求里显式拼上 acf_ccn=ctn。
# 这里也同步 Cookie,避免接口只看 Cookie 时误判未绑定游戏账号。 # 这里也同步 Cookie,避免接口只看 Cookie 时误判未绑定游戏账号。
@@ -576,7 +653,11 @@ class DouyuActivityClient:
"生成精英宝典支付码", "生成精英宝典支付码",
json={ json={
"bizType": "peace", "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, "ctn": ctn,
}, },
headers={"Content-Type": "application/json;charset=UTF-8"}, headers={"Content-Type": "application/json;charset=UTF-8"},
@@ -584,9 +665,17 @@ class DouyuActivityClient:
qr = (payload.get("data") or {}).get("qr") or "" qr = (payload.get("data") or {}).get("qr") or ""
if not qr: if not qr:
raise DouyuActivityError(payload.get("msg") or "生成精英宝典支付码失败") 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) self._set_cookie_value("acf_ccn", ctn)
time_payload = self._request_json( time_payload = self._request_json(
@@ -604,7 +693,11 @@ class DouyuActivityClient:
"生成电竞手册支付码", "生成电竞手册支付码",
json={ json={
"bizType": "peacePel", "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, "ctn": ctn,
}, },
headers={ headers={
@@ -633,7 +726,10 @@ class DouyuActivityClient:
self.ESPORTS_USER_INFO_API, self.ESPORTS_USER_INFO_API,
"查询电竞手册状态", "查询电竞手册状态",
params={"manualID": manual_id}, 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): if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询电竞手册状态失败") raise DouyuActivityError(payload.get("msg") or "查询电竞手册状态失败")
@@ -712,7 +808,9 @@ class DouyuActivityClient:
"raw": payload, "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( payload = self._request_json(
"post", "post",
@@ -846,12 +944,13 @@ class DouyuActivityClient:
"""查询当前账号限兑商品。""" """查询当前账号限兑商品。"""
result = self.list_goods(manual_id=manual_id, rid=rid) result = self.list_goods(manual_id=manual_id, rid=rid)
limited = [ limited = [
item for item in result["goods"] item for item in result["goods"] if str(item.get("status") or "") == "1"
if str(item.get("status") or "") == "1"
] ]
return {"limited_goods": limited, "raw": result["raw"]} 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( payload = self._request_json(
"get", "get",
@@ -918,7 +1017,9 @@ class DouyuActivityClient:
"raw": payload, "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.""" """Pay a previously locked exchange order with handbook points."""
normalized_order_id = str(order_id).strip() normalized_order_id = str(order_id).strip()
if not normalized_order_id: if not normalized_order_id:
@@ -946,7 +1047,9 @@ class DouyuActivityClient:
"raw": payload, "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.""" """Query current locked exchange orders."""
payload = self._request_json( payload = self._request_json(
"get", "get",
@@ -967,7 +1070,9 @@ class DouyuActivityClient:
"raw": payload, "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.""" """Lock an activity commodity and immediately pay the locked order."""
locked = self.create_exchange_order( locked = self.create_exchange_order(
manual_id=manual_id, manual_id=manual_id,
@@ -1011,7 +1116,9 @@ class DouyuActivityClient:
"raw": payload, "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)。 """查询商品详情 storedetail(对齐浏览器 getGoodsDetail)。
详情里带有兑换状态机所需的全部标记:preExchange.isExists / subscribeType / 详情里带有兑换状态机所需的全部标记:preExchange.isExists / subscribeType /
@@ -1021,7 +1128,11 @@ class DouyuActivityClient:
"get", "get",
self.GOODS_DETAIL_API, 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)}, headers={"Origin": "", "Referer": self._peace_shop_referer(rid)},
) )
if payload.get("error") not in (0, "0"): if payload.get("error") not in (0, "0"):
@@ -1103,7 +1214,9 @@ class DouyuActivityClient:
plan.update(action="classic", text="立即兑换") plan.update(action="classic", text="立即兑换")
return plan 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 商品)。""" """查询批量兑换上限(对齐页面 batchExchangeLimit,仅 batchExchange=1 商品)。"""
payload = self._post_csrf_form( payload = self._post_csrf_form(
self.BATCH_EXCHANGE_LIMIT_API, self.BATCH_EXCHANGE_LIMIT_API,
@@ -1116,7 +1229,9 @@ class DouyuActivityClient:
data = payload.get("data") or {} data = payload.get("data") or {}
return {"limit": data.get("limit"), "raw": payload} 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,预兑弹窗前必查)。""" """查询预兑状态(对齐页面 user/queryExchange,预兑弹窗前必查)。"""
payload = self._request_json( payload = self._request_json(
"get", "get",
@@ -1129,7 +1244,9 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "查询预兑状态失败") raise DouyuActivityError(payload.get("msg") or "查询预兑状态失败")
return {"data": payload.get("data") or {}, "raw": payload} 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)。""" """查询取消预兑信息(对齐页面 user/queryCancelExchange)。"""
payload = self._request_json( payload = self._request_json(
"get", "get",
@@ -1142,7 +1259,9 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "查询取消预兑信息失败") raise DouyuActivityError(payload.get("msg") or "查询取消预兑信息失败")
return {"data": payload.get("data") or {}, "raw": payload} 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,成功提示"预兑成功")。""" """确认预兑(对齐页面 user/confirmExchange,成功提示"预兑成功")。"""
payload = self._post_csrf_form( payload = self._post_csrf_form(
self.PRE_EXCHANGE_CONFIRM_API, self.PRE_EXCHANGE_CONFIRM_API,
@@ -1154,7 +1273,9 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "确认预兑失败") raise DouyuActivityError(payload.get("msg") or "确认预兑失败")
return {"raw": payload} 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)。""" """取消预兑(对齐页面 user/cancelExchange)。"""
payload = self._post_csrf_form( payload = self._post_csrf_form(
self.PRE_EXCHANGE_CANCEL_API, self.PRE_EXCHANGE_CANCEL_API,
@@ -1166,7 +1287,9 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "取消预兑失败") raise DouyuActivityError(payload.get("msg") or "取消预兑失败")
return {"raw": payload} 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,成功提示"预约成功")。""" """预约到货(对齐页面 user/sub,成功提示"预约成功")。"""
payload = self._post_csrf_form( payload = self._post_csrf_form(
self.COMMODITY_SUBSCRIBE_API, self.COMMODITY_SUBSCRIBE_API,
@@ -1178,7 +1301,9 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "预约到货失败") raise DouyuActivityError(payload.get("msg") or "预约到货失败")
return {"raw": payload} 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)。""" """取消预约到货(对齐页面 user/cancelSub)。"""
payload = self._post_csrf_form( payload = self._post_csrf_form(
self.COMMODITY_UNSUBSCRIBE_API, self.COMMODITY_UNSUBSCRIBE_API,
@@ -1190,7 +1315,9 @@ class DouyuActivityClient:
raise DouyuActivityError(payload.get("msg") or "取消预约到货失败") raise DouyuActivityError(payload.get("msg") or "取消预约到货失败")
return {"raw": payload} 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)。 """单步兑换(对齐页面 postExchangeGift -> /ordnc/activity/peace/exchange)。
2026-08-30 bundle 中新链路,页面主要用于兑换中心/自动兑换场景;字段按页面 2026-08-30 bundle 中新链路,页面主要用于兑换中心/自动兑换场景;字段按页面
@@ -1263,7 +1390,10 @@ class DouyuActivityClient:
"get", "get",
self.GOLD_ACCOUNT_API, 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 {} data = payload.get("data") or {}
return {"gold": data.get("gold"), "raw": payload} return {"gold": data.get("gold"), "raw": payload}
@@ -1315,7 +1445,7 @@ class DouyuActivityClient:
match = re.search(rf"var\s+{varname}\s*=", text) match = re.search(rf"var\s+{varname}\s*=", text)
if not match: if not match:
raise DouyuActivityError(f"响应中找不到 var {varname}=: {text[:200]}") raise DouyuActivityError(f"响应中找不到 var {varname}=: {text[:200]}")
chunk = text[match.end():].strip() chunk = text[match.end() :].strip()
if chunk.endswith(";"): if chunk.endswith(";"):
chunk = chunk[:-1] chunk = chunk[:-1]
try: try:
@@ -1342,7 +1472,9 @@ class DouyuActivityClient:
stk = cookie_value(self.cookie, "acf_stk") stk = cookie_value(self.cookie, "acf_stk")
ltkid = cookie_value(self.cookie, "acf_ltkid") ltkid = cookie_value(self.cookie, "acf_ltkid")
if not uid or not stk or not 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}" return f"{uid}_1_{stk}_0_{ltkid}"
def xpd_embed_query(self, *, act_alias: str, rid: str) -> dict[str, Any]: 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") recommend = self._xpd_parse_var(response.text, "recommend")
if str(recommend.get("errcode") or "0") != "0": 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) 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): if not isinstance(items, list):
raise DouyuActivityError("小店商品响应格式异常") raise DouyuActivityError("小店商品响应格式异常")
@@ -1554,8 +1692,12 @@ class DouyuActivityClient:
info = self._xpd_parse_var(response.text, "info") info = self._xpd_parse_var(response.text, "info")
if str(info.get("ret") or "0") != "0": if str(info.get("ret") or "0") != "0":
raise DouyuActivityError(str(info.get("msg") or "小店购买记录查询失败")) raise DouyuActivityError(str(info.get("msg") or "小店购买记录查询失败"))
data = info.get("data") if isinstance(info.get("data"), dict) else {} data: dict[str, Any] = (
page_records = data.get("list") if isinstance(data.get("list"), list) else [] 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 total = self._xpd_int(data.get("cnt")) or total
pages.append(info) pages.append(info)
@@ -1568,10 +1710,14 @@ class DouyuActivityClient:
goods_info = json.loads(goods_info) goods_info = json.loads(goods_info)
except json.JSONDecodeError: except json.JSONDecodeError:
goods_info = {} 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 = (
goods_list[0] 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 {} else {}
) )
app_ext = record.get("sAppExt") app_ext = record.get("sAppExt")
@@ -1580,31 +1726,41 @@ class DouyuActivityClient:
app_ext = json.loads(app_ext) app_ext = json.loads(app_ext)
except json.JSONDecodeError: except json.JSONDecodeError:
app_ext = {} app_ext = {}
records.append({ records.append(
"serial": str(record.get("sSerialNum") or ""), {
"goods_name": str( "serial": str(record.get("sSerialNum") or ""),
goods.get("sGoodsName") "goods_name": str(
or goods.get("sPacketName") goods.get("sGoodsName")
or record.get("sActionDesc") or goods.get("sPacketName")
or "" 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 ""), "goods_image": str(
"pay_time": str(record.get("dtPayTime") or ""), goods.get("sGoodsPic")
"status": str(record.get("iStatus") or ""), or (app_ext or {}).get("sGoodsPic")
"price": self._xpd_int(record.get("iPrice")), or ""
"pay_amount": self._xpd_int(record.get("iPayAmount")), ),
"pay_type": str(record.get("sPayType") or ""), "buy_time": str(record.get("dtBuyTime") or ""),
"role_name": str(record.get("sRoleName") or ""), "pay_time": str(record.get("dtPayTime") or ""),
"send_status": str(goods.get("iSendStatus") or ""), "status": str(record.get("iStatus") or ""),
"delivery_message": str(goods.get("deliveryMsg") or ""), "price": self._xpd_int(record.get("iPrice")),
"raw": record, "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: if len(page_records) < page_size or len(records) >= total:
break 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]: def xpd_bind_qr(self, *, act_alias: str) -> dict[str, Any]:
"""生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。 """生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。
@@ -1653,9 +1809,15 @@ class DouyuActivityClient:
) )
if payload.get("error") not in (0, "0", None): if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "查询小店绑定信息失败") raise DouyuActivityError(payload.get("msg") or "查询小店绑定信息失败")
data = payload.get("data") or {} data: dict[str, Any] = (
game_role = data.get("gameRole") if isinstance(data.get("gameRole"), dict) else {} payload.get("data") if isinstance(payload.get("data"), dict) else {}
game_account = data.get("gameAccount") if isinstance(data.get("gameAccount"), 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 { return {
"bind_account": self._xpd_int(data.get("bindAccount")), "bind_account": self._xpd_int(data.get("bindAccount")),
"bind_role": self._xpd_int(data.get("bindRole")), "bind_role": self._xpd_int(data.get("bindRole")),
@@ -1790,7 +1952,8 @@ class DouyuActivityClient:
"_biz_code": "cjm", "_biz_code": "cjm",
"_act_id": act_id, "_act_id": act_id,
"sLiveUserInfo": json.dumps( "sLiveUserInfo": json.dumps(
{"sAnchorId": rid, "sVideoId": 0}, separators=(",", ":"), {"sAnchorId": rid, "sVideoId": 0},
separators=(",", ":"),
), ),
"appext": json.dumps( "appext": json.dumps(
{"user_price": price, "sGoodsPic": picture, "paytype": pay_type}, {"user_price": price, "sGoodsPic": picture, "paytype": pay_type},
@@ -1801,7 +1964,16 @@ class DouyuActivityClient:
"_sid": "8", "_sid": "8",
"set": "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 "") value = str(embed_query.get(key) or "")
if value: if value:
params[key] = value params[key] = value
+4 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
import requests from requests.cookies import RequestsCookieJar
def cookie_pairs(cookie: str) -> list[tuple[str, str]]: def cookie_pairs(cookie: str) -> list[tuple[str, str]]:
@@ -21,7 +21,7 @@ def cookie_pairs(cookie: str) -> list[tuple[str, str]]:
return pairs 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 去重,保留最后一次出现的值。""" """按 Cookie key 去重,保留最后一次出现的值。"""
ordered_keys: list[str] = [] ordered_keys: list[str] = []
values: dict[str, str] = {} values: dict[str, str] = {}
@@ -36,7 +36,7 @@ def normalize_cookie_pairs(pairs: Iterable[tuple[str, str]]) -> str:
def normalize_douyu_cookie( def normalize_douyu_cookie(
cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None, cookie: str | Mapping[str, str] | RequestsCookieJar | None,
) -> str: ) -> str:
"""把 Cookie 字符串、dict 或 CookieJar 转成去重后的浏览器 Cookie 字符串。""" """把 Cookie 字符串、dict 或 CookieJar 转成去重后的浏览器 Cookie 字符串。"""
if not cookie: if not cookie:
@@ -47,7 +47,7 @@ def normalize_douyu_cookie(
def cookie_value( def cookie_value(
cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None, cookie: str | Mapping[str, str] | RequestsCookieJar | None,
key: str, key: str,
) -> str: ) -> str:
"""从 Cookie 中读取 key;有重复时以最后一次出现为准。""" """从 Cookie 中读取 key;有重复时以最后一次出现为准。"""
+4 -6
View File
@@ -10,8 +10,6 @@
import threading import threading
import time import time
from typing import Optional
from loguru import logger from loguru import logger
from .proxy_resolver import ProxyResolver from .proxy_resolver import ProxyResolver
@@ -28,8 +26,8 @@ class ProxyFetcher:
self, self,
api_url: str, api_url: str,
whitelist_platform: str = "xiequ", whitelist_platform: str = "xiequ",
whitelist_credentials: dict = None, whitelist_credentials: dict | None = None,
stop_event: Optional[threading.Event] = None, stop_event: threading.Event | None = None,
): ):
self.api_url = api_url self.api_url = api_url
self.stop_event = stop_event self.stop_event = stop_event
@@ -66,7 +64,7 @@ class ProxyFetcher:
return True, f"出口 IP {local_ip} 已同步: {msg}" return True, f"出口 IP {local_ip} 已同步: {msg}"
return False, 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。 """从代理 API 获取 1 个可用代理,失败返回 None。
不主动检测/同步本地出口 IP;如果代理 API 返回"白名单错误" 不主动检测/同步本地出口 IP;如果代理 API 返回"白名单错误"
@@ -115,7 +113,7 @@ class ProxyFetcher:
def get_proxy_fetcher( def get_proxy_fetcher(
api_url: str, api_url: str,
whitelist_platform: str = "xiequ", whitelist_platform: str = "xiequ",
whitelist_credentials: dict = None, whitelist_credentials: dict | None = None,
) -> ProxyFetcher: ) -> ProxyFetcher:
"""获取代理获取器实例。""" """获取代理获取器实例。"""
return ProxyFetcher( return ProxyFetcher(
+1 -1
View File
@@ -165,7 +165,7 @@ class ProxyResolver:
def resolve_working_proxy( def resolve_working_proxy(
api_url: str, api_url: str,
whitelist_platform: str = "xiequ", whitelist_platform: str = "xiequ",
whitelist_credentials: dict = None, whitelist_credentials: dict | None = None,
max_attempts: int = 4, max_attempts: int = 4,
log_func: Optional[LogFunc] = None, log_func: Optional[LogFunc] = None,
) -> tuple[Optional[str], str]: ) -> tuple[Optional[str], str]: