feat(huya): 缺网页设备态的 CK 回退 HTTP cdnws 通道

问题:外部 CK(只带登录态)导入工作台后,任何任务都被「Cookie 缺少网页设备态
(sdid/udb_deviceid/udb_guiddata/udb_anobiztoken)」直接拦下,查询角色/开通都用不了。

实测结论(2026-10-15,同一张外部 CK):
- 这些字段是 WSS 通道的门禁,不是业务必需 —— 同一条 CK 走 HTTP cdnws 可以正常查询
  角色、商品,并走完整条开通链路(商品/小店/渠道/协议/返利/试算/同意/购买权限)
- A/B 对照(同一账号只抽掉 sdid):含 sdid 服务端回包,去掉即完全不回包,说明 sdid
  是 WSS 的硬门槛,而 HTTP 通道不需要
- 因此改为按能力选通道:设备态齐全走 WSS(行为不变),缺设备态回退 HTTP 并记日志

- http_client:补齐积分中心链路(get_supplier_info / accept_hy_protocol /
  calc_order_promotion / play_mall_get_my_promotion / play_mall_query_allocation /
  get_user_profile / get_follow_wechat_status);下单与支付参数对齐 2026-10-13 抓包
  (appInfo.scene=7、订单 src/orderScene=4、orderType=6、jifencenter 标记、空点击序列、
  m-shop.huya.com/points.html 回调),并去掉 orderType 1→10 的重试循环(有重复下单风险)
- huya_runner_core:_activity_client/_shop_client 不再抛 COOKIE_INCOMPLETE,改为
  返回 HTTP cdnws 客户端(方法签名与 WSS 会话保持一致,任务侧无感)

验证:tests/test_huya_http_shop_flow.py(下单/支付参数与回调域名)、
tests/test_huya_runner_transport.py(通道选择:缺设备态走 HTTP、齐全仍走 WSS);
真机 HTTP 开通链路 8 步全通(末步服务端返回业务提示「已超过该商品的购买上限」,
说明通道可用而非被丢弃)。反向验证:关掉回退即复现原报错。全量 pytest 717 passed。
This commit is contained in:
yml2213
2026-10-15 15:21:51 +08:00
parent a9d711a7e9
commit b5d8d5b5d8
4 changed files with 463 additions and 75 deletions
+286 -64
View File
@@ -19,7 +19,18 @@ from collections.abc import Callable
from typing import Any
from .cookie_utils import cookie_pairs, normalize_cookie_pairs, normalize_huya_cookie
from .frame_decoder import decode_struct_bytes
from .taf_protocol import TafInputStream, TafOutputStream, TafStruct
# 积分中心链路常量与 WSS 通道共用(wss_client 不反向依赖本模块,无循环导入)
from .wss_client import (
GAME_CATEGORY_ID_HUYA,
JIFENCENTER_BIZ_PARAMS,
ORDER_BIZ_TYPE,
ORDER_SCENE,
POINTS_PAY_CALLBACK,
SHOP_APP_SCENE,
)
from .wup_protocol import WupRequest, WupResponse
CDNWS_HOST = "cdnws.api.huya.com"
@@ -246,8 +257,8 @@ class HuyaHttpClient:
return user
@staticmethod
def _build_shop_app(source_id: str = "yellowcarlist", scene: int = 7):
"""构造 HTTP 业务 ShopAppInfo。"""
def _build_shop_app(source_id: str = "yellowcarlist", scene: int = SHOP_APP_SCENE):
"""构造 HTTP 业务 ShopAppInfo(积分中心抓包 scene=7)。"""
from .shop_structs import ShopAppInfo
info = ShopAppInfo()
@@ -258,15 +269,12 @@ class HuyaHttpClient:
return info
@staticmethod
def _build_order_env(item_count: int = 1):
"""构造下单/支付环境参数。"""
import time
def _build_order_env():
"""构造下单/支付环境参数。
click_time = int(time.time() * 1000)
return {
"user_click_seq": f'[{{"x":258,"y":487,"time":{click_time},"id":""}}]',
"input_1": str(item_count),
}
2026-10-13 积分中心抓包:仅一个空点击序列,没有旧版坐标数组与 input_1。
"""
return {"user_click_seq": "[]"}
@staticmethod
def _build_activity_user(uid: int, cookie: str):
@@ -913,13 +921,13 @@ class HuyaHttpClient:
sku_id=0,
game_id="",
source_id="yellowcarlist",
scene=7,
app_scene=SHOP_APP_SCENE,
):
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
req = GetGoodsInfoReqV5()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, scene)
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
@@ -942,7 +950,7 @@ class HuyaHttpClient:
req = ListPayChannelReq()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", 4)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.supplierId = int(supplier_id or 0)
req.spuId = spu_id
req.skuId = int(sku_id or 0)
@@ -964,7 +972,7 @@ class HuyaHttpClient:
req = CheckHyProtocolReq()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", 4)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
return self.call_rpc(
"shopMiddleUI",
"checkHyProtocolV5",
@@ -989,7 +997,7 @@ class HuyaHttpClient:
req = CheckUserBuyAuthReq()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", 4)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.spuId = spu_id
req.skuId = int(sku_id or 0)
return self.call_rpc(
@@ -1056,7 +1064,7 @@ class HuyaHttpClient:
req = OrderDetailReq()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", 4)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.orderId = int(order_id or 0)
return self.call_rpc(
"shopMiddleUI",
@@ -1080,10 +1088,16 @@ class HuyaHttpClient:
item_count=1,
source_id="yellowcarlist",
game_id="",
scene=7,
order_type=None,
app_scene=SHOP_APP_SCENE,
order_scene=ORDER_SCENE,
order_type=6,
biz_params=JIFENCENTER_BIZ_PARAMS,
):
"""order_type=None 时自动尝试从 1 到 10 找到有效值"""
"""下单(HTTP 通道),参数与 2026-10-13 积分中心抓包一致。
旧实现会从 orderType=1 试到 10,失败重试有重复下单风险;抓包已确认
和平精英虚拟商品固定 orderType=6,这里改为单次调用。
"""
from .shop_structs import (
CreateOrderAccountParam,
CreateOrderExtraParam,
@@ -1092,47 +1106,40 @@ class HuyaHttpClient:
CreateOrderRsp,
)
# 如果指定了具体值,直接试
types_to_try = [order_type] if order_type is not None else list(range(1, 11))
req = CreateOrderReqV5()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.receiveId = 0
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
req.itemCount = item_count
req.gameId = game_id or "0"
req.src = order_scene
req.scene = 0
req.sourceId = source_id
req.orderType = order_type
extra = CreateOrderExtraParam()
extra.bizParams = biz_params
req.extraParam = extra
req.env = self._build_order_env()
req.orderScene = order_scene
req.promotionParam = CreateOrderPromotionParam()
req.accountParam = CreateOrderAccountParam()
req.bizType = ORDER_BIZ_TYPE
req.gameCategoryId = GAME_CATEGORY_ID_HUYA
req.ext = ""
last_result = None
for ot in types_to_try:
req = CreateOrderReqV5()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, scene)
req.receiveId = 0
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
req.itemCount = item_count
req.gameId = game_id or "0"
req.src = scene
req.scene = 0
req.sourceId = source_id
req.orderType = ot
req.extraParam = CreateOrderExtraParam()
req.env = self._build_order_env(item_count)
req.orderScene = scene
req.promotionParam = CreateOrderPromotionParam()
req.accountParam = CreateOrderAccountParam()
req.bizType = 5
req.gameCategoryId = 507
req.ext = ""
result: Any = self.call_rpc(
"shopMiddleUI", "createOrderV5", req, CreateOrderRsp, uid, guid, cookie
result: Any = self.call_rpc(
"shopMiddleUI", "createOrderV5", req, CreateOrderRsp, uid, guid, cookie
)
if result is not None:
self.logger(
f" createOrderV5: code={result.code} orderId={result.orderId} msg={result.message}"
)
if result is not None:
self.logger(
f" orderType={ot}: code={result.code} orderId={result.orderId} msg={result.message}"
)
if result.code == 200 and result.orderId:
self.logger(f"[✓] 找到有效 orderType={ot}")
return result
last_result = result
else:
self.logger(f" orderType={ot}: 无响应")
return last_result
else:
self.logger(" createOrderV5: 无响应")
return result
@staticmethod
def _normalize_pay_channel(pay_type) -> str:
@@ -1153,27 +1160,27 @@ class HuyaHttpClient:
pay_type=1,
pid=0,
source_id="yellowcarlist",
scene=7,
app_scene=SHOP_APP_SCENE,
item_count=1,
):
"""发起支付,结构与 WSS HAR 中 payOrderSubmitV5 对齐。"""
"""发起支付(HTTP 通道),字段与 2026-10-13 积分中心抓包一致。"""
from .shop_structs import PayOrderRes
os = TafOutputStream()
os.write_struct_begin(0)
os.write_struct(0, self._build_shop_user(uid, cookie))
os.write_struct(1, self._build_shop_app(source_id, scene))
os.write_struct(1, self._build_shop_app(source_id, app_scene))
os.write_int64(2, order_id)
os.write_string(3, self._normalize_pay_channel(pay_type))
os.write_string(4, "QrCode")
callback_url = (
f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback"
f"?orderId={order_id}&pid={pid}&sourceId={source_id}"
f"{POINTS_PAY_CALLBACK}?orderId={order_id}&pid={pid}&sourceId={source_id}"
)
os.write_string(5, callback_url)
os.write_map(6, self._build_order_env(item_count))
os.write_map(6, self._build_order_env())
os.write_string(7, "null")
os.write_string(8, "")
os.write_struct_end()
@@ -1226,6 +1233,221 @@ class HuyaHttpClient:
result = wup_resp.readStruct("tResp", PayOrderRes)
return result
# ============================================================
# 积分中心链路新增 RPC(HTTP 通道版本,参数与 WSS 侧一致)
# ============================================================
@staticmethod
def _raw_from_wup(wup_resp: WupResponse):
"""已解码的 WUP 响应 → ShopRawRsp(未逐字段建模的接口用)。"""
from .shop_structs import ShopRawRsp
data = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp")
payload = decode_struct_bytes(data) if isinstance(data, bytes) else {}
return ShopRawRsp.from_payload(payload)
def get_supplier_info(
self,
uid: int,
guid: str,
cookie: str,
supplier_id: int = 87401,
timeout: float = 15.0,
):
"""shopMiddleUI.getSupplierInfoV5(供应商/小店信息)。"""
from .shop_structs import GetSupplierInfoReqV5
req = GetSupplierInfoReqV5()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.supplierId = int(supplier_id or 0)
resp = self.call_rpc(
"shopMiddleUI",
"getSupplierInfoV5",
req,
None,
uid,
guid,
cookie,
timeout=timeout,
)
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
def accept_hy_protocol(
self, uid: int, guid: str, cookie: str, timeout: float = 15.0
):
"""shopMiddleUI.acceptHyProtocolV5(同意商城协议)。"""
from .shop_structs import AcceptHyProtocolReq, ShopCodeRsp
req = AcceptHyProtocolReq()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
return self.call_rpc(
"shopMiddleUI",
"acceptHyProtocolV5",
req,
ShopCodeRsp,
uid,
guid,
cookie,
timeout=timeout,
)
def calc_order_promotion(
self,
uid: int,
guid: str,
cookie: str,
pid: int,
spu_id: str,
sku_id: int = 0,
item_count: int = 1,
game_id: int = 9815,
timeout: float = 15.0,
):
"""shopMiddleUI.calcOrderPromotion(下单前试算)。"""
from .shop_structs import CalcOrderPromotionReqV5, shop_spu_number
spu_num = shop_spu_number(spu_id)
req = CalcOrderPromotionReqV5()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.spuNum = spu_num
req.skuId = int(sku_id or 0)
req.itemCount = int(item_count or 1)
req.pid = int(pid or 0)
req.spuNum2 = spu_num
req.gameId = int(game_id or 0)
resp = self.call_rpc(
"shopMiddleUI",
"calcOrderPromotion",
req,
None,
uid,
guid,
cookie,
timeout=timeout,
)
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
def play_mall_query_allocation(
self,
uid: int,
guid: str,
cookie: str,
source_id: str = "yellowcarlist",
page_code: str = "heping_baodian_credit_center_page",
timeout: float = 15.0,
):
"""PlayMallUI.queryAllocation(积分中心配额查询)。"""
from .shop_structs import PlayMallQueryAllocationReq
req = PlayMallQueryAllocationReq()
req.userId = self._build_shop_user(uid, cookie)
req.pageCode = page_code
req.appInfo.sourceId = source_id
resp = self.call_rpc(
"PlayMallUI",
"queryAllocation",
req,
None,
uid,
guid,
cookie,
timeout=timeout,
)
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
def play_mall_get_my_promotion(
self,
uid: int,
guid: str,
cookie: str,
spu_id: str,
sku_id: int = 0,
price: int = 0,
supplier_id: int = 87401,
game_id: int = 9815,
source_id: str = "yellowcarlist",
timeout: float = 15.0,
):
"""PlayMallUI.getMyPromotion(返利/促销查询)。"""
from .shop_structs import (
PlayMallGetMyPromotionReq,
PromotionGoodsKey,
shop_spu_number,
)
spu_num = shop_spu_number(spu_id)
req = PlayMallGetMyPromotionReq()
req.userId = self._build_shop_user(uid, cookie)
req.appInfo.sourceId = source_id
goods = PromotionGoodsKey()
goods.supplierId = int(supplier_id or 0)
goods.spuNum = spu_num
goods.skuId = int(sku_id or 0)
goods.price = int(price or 0)
goods.gameId = int(game_id or 0)
goods.spuNum2 = spu_num
req.goods = goods
resp = self.call_rpc(
"PlayMallUI",
"getMyPromotion",
req,
None,
uid,
guid,
cookie,
timeout=timeout,
)
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
def get_user_profile(
self,
uid: int,
guid: str,
cookie: str,
target_uid: int = 0,
timeout: float = 15.0,
):
"""huyauserui.getUserProfile(单个用户资料,资料在 tag0.tag0)。"""
from .shop_structs import GetUserProfileReq
req = GetUserProfileReq()
req.userId = self._build_shop_user(uid, cookie)
req.targetUid = int(target_uid or uid or 0)
resp = self.call_rpc(
"huyauserui",
"getUserProfile",
req,
None,
uid,
guid,
cookie,
timeout=timeout,
)
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
def get_follow_wechat_status(
self, uid: int, guid: str, cookie: str, timeout: float = 15.0
):
"""wupui.getUserFollowWeChatOfficialStatus(是否关注公众号)。"""
from .shop_structs import FollowWeChatStatusReq
req = FollowWeChatStatusReq()
req.userId = self._build_shop_user(uid, cookie)
resp = self.call_rpc(
"wupui",
"getUserFollowWeChatOfficialStatus",
req,
None,
uid,
guid,
cookie,
timeout=timeout,
)
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
def _self_check():
uid = 1199647239697