refactor(douyu): runner 按功能域拆分 Mixin + 精英手册兑换对齐真机流程 (HAR 审核通过)

拆分: douyu_runner.py (-3186行) → core/bind/manual/gold/donate/goods/xpd 七个
Mixin + 入口聚合类, douyu_batch_registry 供 routers 重导出

精英手册兑换对齐 HAR 抓包 (activity_client +379 行):
- csrf 复用 Cookie 值不再每次 generateCsrf (HAR 实测全程零 csrf 请求),
  仅服务端报 csrf 错误时 force_refresh 重试一次
- pay Referer 补 roomId 对齐浏览器
- 新增手册链路接口: elite_user_info/storedetail/pre_exchange check+confirm/
  subscribe/batch_limit/赠品兑换/自动转换
- resolve_exchange_plan: 浏览器兑换按钮状态机路由 (normal/subscribe/
  pre_exchange/wait/blocked)
- 兑换执行: 人类节奏抖动 → 锁单(火爆重试1次) → 支付分级退避
  (频控 30/60/90s 4次上限, 普错 2/5/10/20s 5次上限, 300s 锁单期 15s 余量,
  可中断睡眠)

测试: 新增 17 项 (csrf 复用语义/状态机路由/Referer), 全量 94 通过;
未夹带代理改动
This commit is contained in:
yml2213
2026-08-30 11:05:18 +08:00
parent 6e17a575dd
commit 39b202bc3f
11 changed files with 3959 additions and 3196 deletions
+358 -21
View File
@@ -41,6 +41,22 @@ class DouyuActivityClient:
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
# 预约到货 user/sub / user/cancelSub;批量兑换 batchExchangeLimit;单步兑换 exchange;自动兑换开关。
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"
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"
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"
@@ -186,8 +202,18 @@ class DouyuActivityClient:
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"""
def csrf_token(self, *, force_refresh: bool = False) -> str:
"""获取 cvl_csrf_token;优先复用 Cookie 中已有值,与浏览器行为一致。
2026-08-30 抓包实证:页面只读 Cookie `cvl_csrf_token` 的值作为 csrfToken
复用(同一 token 跨多次接口、跨十几秒复用),Cookie 里没有/失效时才调用
generateCsrf,生成的 token 写回 Cookie 继续复用。
force_refresh=True 用于服务端提示 token 失效后的强制刷新。
"""
if not force_refresh:
token = cookie_value(self.cookie, "cvl_csrf_token")
if token:
return token
payload = self._request_json(
"post",
self.CSRF_API,
@@ -205,6 +231,58 @@ class DouyuActivityClient:
raise DouyuActivityError("响应 Cookie 中没有 cvl_csrf_token")
return token
@staticmethod
def _is_csrf_error(payload: dict[str, Any]) -> bool:
"""判断响应是否为 csrfToken 校验失败(此时应刷新并重试一次)。"""
return "csrf" in str(payload.get("msg") or "").lower()
@staticmethod
def _peace_page_referer(rid: str = "") -> str:
"""精英手册首页 Referer(对齐浏览器 getUserInfo 等接口)。"""
if rid:
return f"https://www.douyu.com/pages/live-peace-handbook/web?ditchname=pass0&roomId={rid}"
return "https://www.douyu.com/pages/live-peace-handbook/web?ditchname=pass0"
@staticmethod
def _peace_shop_referer(rid: str = "") -> str:
"""精英手册商城页 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"
def _post_csrf_form(
self,
url: str,
source: str,
fields: dict[str, Any],
referer: str,
) -> dict[str, Any]:
"""提交带 csrfToken 的 multipart 表单(对齐浏览器请求层)。
浏览器 csrf 配置 {tvk: cvl_csrf_token, tn: csrfToken} 会把 Cookie 中的
token 值拼进表单;这里同样复用 Cookie 值,仅在服务端提示 token 失效时
刷新一次后重试。
"""
token = self.csrf_token()
payload = self._request_json(
"post",
url,
source,
files=self._multipart_fields({**fields, "csrfToken": token}),
headers={"Referer": referer},
)
if payload.get("error") not in (0, "0") and self._is_csrf_error(payload):
self.logger(f"{source}: csrfToken 校验失败,刷新后重试一次")
token = self.csrf_token(force_refresh=True)
payload = self._request_json(
"post",
url,
source,
files=self._multipart_fields({**fields, "csrfToken": token}),
headers={"Referer": referer},
)
return payload
def acf_ccn(self, *, refresh_subscribe: bool = True) -> str:
"""获取 acf_ccn;必要时通过订阅接口刷新一次。"""
response = self._request(
@@ -797,21 +875,16 @@ class DouyuActivityClient:
"""Lock stock for an activity commodity before spending points."""
if num < 1:
raise DouyuActivityError("锁单数量必须大于 0")
token = self.csrf_token()
payload = self._request_json(
"post",
payload = self._post_csrf_form(
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}",
},
referer=self._peace_shop_referer(rid),
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "锁定兑换商品失败")
@@ -838,28 +911,22 @@ class DouyuActivityClient:
"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]:
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:
raise DouyuActivityError("锁单订单号不能为空")
token = self.csrf_token()
payload = self._request_json(
"post",
payload = self._post_csrf_form(
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",
},
referer=self._peace_shop_referer(rid),
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "支付锁单失败")
@@ -872,7 +939,6 @@ class DouyuActivityClient:
"exchange_num": data.get("exchangeNum"),
"s_type": data.get("sType"),
"g_type": data.get("gType"),
"csrf_token": token,
"raw": payload,
}
@@ -916,6 +982,277 @@ class DouyuActivityClient:
"payment": paid,
}
def elite_user_info(self, *, manual_id: str, rid: str = "") -> dict[str, Any]:
"""查询精英手册状态(对齐页面 getUserInfo,含 manualType/manualScore)。
浏览器每次进入手册页都会先请求该接口,用于判断商品是否"精英专享"
是否已达兑换上限等状态;兑换前先查一次与页面行为一致。
"""
payload = self._request_json(
"get",
self.ELITE_USER_INFO_API,
"查询手册状态",
params={"manualID": manual_id},
headers={"Origin": "", "Referer": self._peace_page_referer(rid)},
)
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,
}
def query_goods_detail(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]:
"""查询商品详情 storedetail(对齐浏览器 getGoodsDetail)。
详情里带有兑换状态机所需的全部标记:preExchange.isExists / subscribeType /
exchangeInfo.userStatus / batchExchange / roomLimited / storeStatus 等。
"""
payload = self._request_json(
"get",
self.GOODS_DETAIL_API,
"查询商品详情",
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"):
raise DouyuActivityError(payload.get("msg") or "查询商品详情失败")
return {"detail": payload.get("data") or {}, "raw": payload}
@staticmethod
def resolve_exchange_plan(
detail: dict[str, Any],
*,
manual_type: Any = None,
batch_num_limit: Any = None,
) -> dict[str, Any]:
"""对齐浏览器兑换按钮状态机($e / Ve / Ge)给出自动化兑换计划。
浏览器判定顺序(2026-08-30 bundle index.4872272b.js / index.0539fbf8.js):
存在预扣订单 -> 已达兑换上限 -> 无库存 -> 限定房间 -> 活动未开启(预约/预兑) ->
精英专享未开通 -> 已预兑等待开放 -> 立即兑换(经典锁单/支付)。
action:
classic 经典链路 createExchangeOrder -> payExchangeOrder(批量商品 num 受上限约束)
pre_exchange 预兑商品(subscribeType=2 且未开启):user/queryExchange -> user/confirmExchange
subscribe 预约到货商品(subscribeType=1 且未开启):user/sub
wait 已预兑(userStatus=1)但预扣订单尚未生成,等待开放后重试
blocked 明确不可兑换,text 为原因(文案对齐浏览器/服务端)
"""
detail = detail or {}
pre = detail.get("preExchange") or {}
pre_is_exists = int(pre.get("isExists") or 0)
exchange_info = detail.get("exchangeInfo") or {}
user_status = int(exchange_info.get("userStatus") or 0)
subscribe_type = int(detail.get("subscribeType") or 0)
status = int(detail.get("status") or 0)
store_status = int(detail.get("storeStatus") or 0)
open_status = int(detail.get("openStatus") or 0)
unlimited = (
int(detail.get("unlimitedMode") or 0) == 1
and int(detail.get("unlimitedStatus") or 0) == 1
)
room_limited = int(detail.get("roomLimited") or 0) == 1
is_limit_room = int(detail.get("isLimitRoom") or 0) == 1
elite_limited = int(detail.get("eliteLimited") or 0) == 1
batch_exchange = int(detail.get("batchExchange") or 0) > 0
max_num = 1
if batch_exchange and batch_num_limit is not None:
try:
max_num = max(1, int(batch_num_limit or 1))
except (TypeError, ValueError):
max_num = 1
plan: dict[str, Any] = {
"action": "classic",
"text": "",
"max_num": max_num,
"batch_exchange": batch_exchange,
"pre_exchange": bool(pre_is_exists),
"subscribe_type": subscribe_type,
"user_status": user_status,
}
if pre_is_exists:
plan.update(action="classic", text="存在预扣订单,继续兑换")
elif status == 1 or (subscribe_type == 2 and user_status == 2):
plan.update(action="blocked", text="已达兑换上限")
elif store_status == 0 and not unlimited:
plan.update(action="blocked", text="无库存")
elif room_limited and not is_limit_room:
plan.update(action="blocked", text="限定房间兑换,请前往指定直播间")
elif open_status == 0:
if subscribe_type == 1:
plan.update(action="subscribe", text="活动未开启,预约到货")
elif subscribe_type == 2:
plan.update(action="pre_exchange", text="活动未开启,预兑")
else:
plan.update(action="blocked", text="活动未开启")
elif elite_limited and manual_type is not None and int(manual_type or 0) != 1:
plan.update(action="blocked", text="需要开通精英手册,才可以兑换本道具")
elif subscribe_type == 2 and user_status == 1:
plan.update(action="wait", text="已预兑,等待开放后继续兑换")
else:
plan.update(action="classic", text="立即兑换")
return plan
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,
"查询批量兑换上限",
{"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"},
referer=self._peace_shop_referer(rid),
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "查询批量兑换上限失败")
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]:
"""查询预兑状态(对齐页面 user/queryExchange,预兑弹窗前必查)。"""
payload = self._request_json(
"get",
self.PRE_EXCHANGE_CHECK_API,
"查询预兑状态",
params={"manualID": manual_id, "commodityID": commodity_id},
headers={"Origin": "", "Referer": self._peace_shop_referer()},
)
if payload.get("error") not in (0, "0"):
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]:
"""查询取消预兑信息(对齐页面 user/queryCancelExchange)。"""
payload = self._request_json(
"get",
self.PRE_EXCHANGE_CANCEL_CHECK_API,
"查询取消预兑信息",
params={"manualID": manual_id, "commodityID": commodity_id},
headers={"Origin": "", "Referer": self._peace_shop_referer()},
)
if payload.get("error") not in (0, "0"):
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]:
"""确认预兑(对齐页面 user/confirmExchange,成功提示"预兑成功")。"""
payload = self._post_csrf_form(
self.PRE_EXCHANGE_CONFIRM_API,
"确认预兑",
{"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"},
referer=self._peace_shop_referer(rid),
)
if payload.get("error") not in (0, "0"):
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]:
"""取消预兑(对齐页面 user/cancelExchange)。"""
payload = self._post_csrf_form(
self.PRE_EXCHANGE_CANCEL_API,
"取消预兑",
{"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"},
referer=self._peace_shop_referer(rid),
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "取消预兑失败")
return {"raw": payload}
def subscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]:
"""预约到货(对齐页面 user/sub,成功提示"预约成功")。"""
payload = self._post_csrf_form(
self.COMMODITY_SUBSCRIBE_API,
"预约到货",
{"manualID": manual_id, "commodityID": commodity_id},
referer=self._peace_shop_referer(),
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "预约到货失败")
return {"raw": payload}
def unsubscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]:
"""取消预约到货(对齐页面 user/cancelSub)。"""
payload = self._post_csrf_form(
self.COMMODITY_UNSUBSCRIBE_API,
"取消预约到货",
{"manualID": manual_id, "commodityID": commodity_id},
referer=self._peace_shop_referer(),
)
if payload.get("error") not in (0, "0"):
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]:
"""单步兑换(对齐页面 postExchangeGift -> /ordnc/activity/peace/exchange)。
2026-08-30 bundle 中新链路,页面主要用于兑换中心/自动兑换场景;字段按页面
调用({manualID, commodityID, num, rid} + csrfToken)对齐。
"""
payload = self._post_csrf_form(
self.EXCHANGE_GIFT_API,
"单步兑换",
{
"manualID": manual_id,
"commodityID": commodity_id,
"num": max(1, int(num)),
"rid": rid or "0",
},
referer=self._peace_shop_referer(rid),
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "兑换失败")
data = payload.get("data") or {}
return {
"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"),
"raw": payload,
}
def auto_convert_switch(self, *, manual_id: str) -> dict[str, Any]:
"""查询自动兑换开关(对齐页面 activity/automaticallyConvert)。"""
payload = self._request_json(
"get",
self.AUTO_CONVERT_QUERY_API,
"查询自动兑换开关",
params={"manualID": manual_id},
headers={"Origin": "", "Referer": self._peace_shop_referer()},
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "查询自动兑换开关失败")
return {"data": payload.get("data") or {}, "raw": payload}
def set_auto_convert(self, *, manual_id: str, switch: Any) -> dict[str, Any]:
"""设置自动兑换开关(对齐页面 automaticallyConvertSave {manualID, switch})。"""
payload = self._post_csrf_form(
self.AUTO_CONVERT_SAVE_API,
"设置自动兑换开关",
{"manualID": manual_id, "switch": switch},
referer=self._peace_shop_referer(),
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "设置自动兑换开关失败")
return {"raw": payload}
def exchange_home_info(self) -> dict[str, Any]:
"""查询兑换中心信息(对齐页面 activity/exchangeHome)。"""
payload = self._request_json(
"get",
self.EXCHANGE_HOME_API,
"查询兑换中心",
headers={"Origin": "", "Referer": self._peace_shop_referer()},
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "查询兑换中心失败")
return {"data": payload.get("data") or {}, "raw": payload}
def gold_account(self) -> dict[str, Any]:
"""查询 cz 侧鱼翅余额。"""
payload = self._request_json(