和平小店可以返回所有商品
This commit is contained in:
@@ -26,3 +26,4 @@ web/frontend/dist/
|
|||||||
data/web.db
|
data/web.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.reasonix/
|
.reasonix/
|
||||||
|
.tmp_reverse/
|
||||||
|
|||||||
@@ -827,6 +827,7 @@ class DouyuActivityClient:
|
|||||||
XPD_RECOMMEND_API = "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common"
|
XPD_RECOMMEND_API = "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common"
|
||||||
XPD_BALANCE_API = "https://apps.game.qq.com/daoju/igw/live/"
|
XPD_BALANCE_API = "https://apps.game.qq.com/daoju/igw/live/"
|
||||||
XPD_LIVE_BUY_API = "https://apps.game.qq.com/daoju/igw/livebuy/"
|
XPD_LIVE_BUY_API = "https://apps.game.qq.com/daoju/igw/livebuy/"
|
||||||
|
XPD_GOODS_PAGE_SIZE = 10
|
||||||
XPD_UA = (
|
XPD_UA = (
|
||||||
"Mozilla/5.0 (Linux; Android 12; HBN-AL00 Build/V417IR; wv) "
|
"Mozilla/5.0 (Linux; Android 12; HBN-AL00 Build/V417IR; wv) "
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 "
|
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 "
|
||||||
@@ -953,20 +954,17 @@ class DouyuActivityClient:
|
|||||||
roleid: str,
|
roleid: str,
|
||||||
areaid: str = "1",
|
areaid: str = "1",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""查询和平小店推荐商品(category=76 全部商品)。"""
|
"""查询和平小店全部商品,排除热门抢购分类并拉取全部分页。"""
|
||||||
params = {
|
base_params = {
|
||||||
"_service": "act.recommend.query",
|
"_service": "act.recommend.query",
|
||||||
"_biz_code": "cjm",
|
"_biz_code": "cjm",
|
||||||
"_app_id": "2123",
|
"_app_id": "2123",
|
||||||
"app_id": "2123",
|
"app_id": "2123",
|
||||||
"page_begin": "0",
|
|
||||||
"page_num": "1",
|
|
||||||
"page_size": "200",
|
|
||||||
"fields": (
|
"fields": (
|
||||||
"iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime,"
|
"iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime,"
|
||||||
"dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price,"
|
"dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price,"
|
||||||
"iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime,"
|
"iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime,"
|
||||||
"dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,iGoodsLeft,dtRushBegin,dtRushEnd"
|
"dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,dtRushBegin,dtRushEnd"
|
||||||
),
|
),
|
||||||
"actionFields": "iActionId,sActionName,dtBeginTime,dtEndTime",
|
"actionFields": "iActionId,sActionName,dtBeginTime,dtEndTime",
|
||||||
"order_by": "dtShowBeginTime",
|
"order_by": "dtShowBeginTime",
|
||||||
@@ -974,36 +972,69 @@ class DouyuActivityClient:
|
|||||||
"desc": "1",
|
"desc": "1",
|
||||||
"_act_id": act_id,
|
"_act_id": act_id,
|
||||||
"actid": act_id,
|
"actid": act_id,
|
||||||
"category": "76",
|
# category=76 是“热门抢购”;全部商品页明确将其排除。
|
||||||
|
"excludeFields": "iCategoryId_76",
|
||||||
"userid": openid,
|
"userid": openid,
|
||||||
"roleid": roleid,
|
"roleid": roleid,
|
||||||
"area": "1",
|
"area": "1",
|
||||||
"areaid": str(areaid),
|
"areaid": str(areaid),
|
||||||
"need_limit": "1",
|
"need_limit": "1",
|
||||||
}
|
}
|
||||||
response = self._request(
|
goods: list[dict[str, Any]] = []
|
||||||
"get",
|
seen_ids: set[str] = set()
|
||||||
self.XPD_RECOMMEND_API,
|
pages: list[dict[str, Any]] = []
|
||||||
source="刷新小店商品",
|
offset = 0
|
||||||
params=params,
|
page_num = 1
|
||||||
headers=self._xpd_daoju_headers(),
|
|
||||||
)
|
while True:
|
||||||
recommend = self._xpd_parse_var(response.text, "recommend")
|
params = {
|
||||||
items = (((recommend.get("data") or {}).get("client_data") or {}).get("itemsdetail")) or []
|
**base_params,
|
||||||
goods = []
|
"page_begin": str(offset),
|
||||||
for item in items:
|
"page_num": str(page_num),
|
||||||
goods.append(
|
"page_size": str(self.XPD_GOODS_PAGE_SIZE),
|
||||||
{
|
}
|
||||||
"commodity_id": str(item.get("iGoodsId") or ""),
|
response = self._request(
|
||||||
"name": str(item.get("sGoodsName") or ""),
|
"get",
|
||||||
"price": self._xpd_int(item.get("iPrice")),
|
self.XPD_RECOMMEND_API,
|
||||||
"org_price": self._xpd_int(item.get("iOrgPrice")),
|
source=f"刷新小店全部商品第 {page_num} 页",
|
||||||
"category": str(item.get("iCategoryId") or ""),
|
params=params,
|
||||||
"goods_left": self._xpd_int(item.get("iGoodsLeft")),
|
headers=self._xpd_daoju_headers(),
|
||||||
"raw": item,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return {"goods": goods, "raw": recommend}
|
recommend = self._xpd_parse_var(response.text, "recommend")
|
||||||
|
if str(recommend.get("errcode") or "0") != "0":
|
||||||
|
raise DouyuActivityError(str(recommend.get("errdesc") or "小店商品查询失败"))
|
||||||
|
pages.append(recommend)
|
||||||
|
items = (((recommend.get("data") or {}).get("client_data") or {}).get("itemsdetail")) or []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise DouyuActivityError("小店商品响应格式异常")
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
commodity_id = str(item.get("iGoodsId") or "")
|
||||||
|
if not commodity_id or commodity_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(commodity_id)
|
||||||
|
goods.append(
|
||||||
|
{
|
||||||
|
"commodity_id": commodity_id,
|
||||||
|
"name": str(item.get("sGoodsName") or ""),
|
||||||
|
"price": self._xpd_int(item.get("iPrice")),
|
||||||
|
"org_price": self._xpd_int(item.get("iOrgPrice")),
|
||||||
|
"category": str(item.get("iCategoryId") or ""),
|
||||||
|
"goods_left": self._xpd_int(item.get("iGoodsLeft")),
|
||||||
|
"raw": item,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(items) < self.XPD_GOODS_PAGE_SIZE:
|
||||||
|
break
|
||||||
|
offset += self.XPD_GOODS_PAGE_SIZE
|
||||||
|
page_num += 1
|
||||||
|
if page_num > 100:
|
||||||
|
raise DouyuActivityError("小店商品分页超出安全上限")
|
||||||
|
|
||||||
|
return {"goods": goods, "raw": {"pages": pages}}
|
||||||
|
|
||||||
def xpd_bind_qr(self, *, act_alias: str) -> dict[str, Any]:
|
def xpd_bind_qr(self, *, act_alias: str) -> dict[str, Any]:
|
||||||
"""生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。
|
"""生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。
|
||||||
|
|||||||
@@ -76,8 +76,35 @@ class XpdExchangeTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result['goods'][0]['price'], 6800)
|
self.assertEqual(result['goods'][0]['price'], 6800)
|
||||||
self.assertEqual(result['goods'][0]['goods_left'], 0)
|
self.assertEqual(result['goods'][0]['goods_left'], 0)
|
||||||
fields = self.client._request.call_args.kwargs['params']['fields']
|
params = self.client._request.call_args.kwargs['params']
|
||||||
self.assertIn('iGoodsLeft', fields.split(','))
|
self.assertEqual(params['excludeFields'], 'iCategoryId_76')
|
||||||
|
self.assertNotIn('category', params)
|
||||||
|
|
||||||
|
def test_list_goods_fetches_every_page_and_deduplicates_goods(self):
|
||||||
|
first_page = [
|
||||||
|
{'iGoodsId': f'goods-{index}', 'sGoodsName': f'商品 {index}'}
|
||||||
|
for index in range(10)
|
||||||
|
]
|
||||||
|
second_page = [
|
||||||
|
{'iGoodsId': 'goods-9', 'sGoodsName': '重复商品'},
|
||||||
|
{'iGoodsId': 'goods-10', 'sGoodsName': '商品 10'},
|
||||||
|
]
|
||||||
|
self.client._request = Mock(side_effect=[
|
||||||
|
SimpleNamespace(text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(first_page)}}}}}}};'),
|
||||||
|
SimpleNamespace(text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(second_page)}}}}}}};'),
|
||||||
|
])
|
||||||
|
|
||||||
|
result = self.client.xpd_list_goods(
|
||||||
|
embed_query={}, act_id='46195', openid='openid', roleid='roleid',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(result['goods']), 11)
|
||||||
|
self.assertEqual([item['commodity_id'] for item in result['goods']][-1], 'goods-10')
|
||||||
|
self.assertEqual(self.client._request.call_count, 2)
|
||||||
|
first_params = self.client._request.call_args_list[0].kwargs['params']
|
||||||
|
second_params = self.client._request.call_args_list[1].kwargs['params']
|
||||||
|
self.assertEqual((first_params['page_begin'], first_params['page_num']), ('0', '1'))
|
||||||
|
self.assertEqual((second_params['page_begin'], second_params['page_num']), ('10', '2'))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -437,8 +437,20 @@ class DouyuBatchRunner:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None:
|
def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None:
|
||||||
"""写入和平小店商品快照。"""
|
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
commodity_ids = {
|
||||||
|
str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
||||||
|
for raw in goods
|
||||||
|
}
|
||||||
|
commodity_ids.discard("")
|
||||||
|
query = db.query(DouyuXpdGoodsSnapshot)
|
||||||
|
if commodity_ids:
|
||||||
|
query.filter(~DouyuXpdGoodsSnapshot.commodity_id.in_(commodity_ids)).delete(
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query.delete(synchronize_session=False)
|
||||||
for raw in goods:
|
for raw in goods:
|
||||||
commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
||||||
if not commodity_id:
|
if not commodity_id:
|
||||||
@@ -1458,7 +1470,8 @@ class DouyuBatchRunner:
|
|||||||
currency = "点券" if pay_type == 1 else "扭蛋碎片"
|
currency = "点券" if pay_type == 1 else "扭蛋碎片"
|
||||||
self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换")
|
self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换")
|
||||||
return
|
return
|
||||||
if goods.goods_left is not None and goods.goods_left <= 0:
|
# iGoodsLeft=-1 表示活动未公开库存,不是售罄;只有 0 才阻止兑换。
|
||||||
|
if goods.goods_left == 0:
|
||||||
self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试")
|
self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ function xpdGoodsLabel(item: DouyuGoodsItem): string {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const stock = xpd.goods_left;
|
const stock = xpd.goods_left;
|
||||||
const stockText = stock != null && Number.isFinite(Number(stock))
|
const stockText = stock != null && Number.isFinite(Number(stock))
|
||||||
? `库存${stock}`
|
? (Number(stock) < 0 ? '库存未公开' : `库存${stock}`)
|
||||||
: '库存未知';
|
: '库存未知';
|
||||||
return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`;
|
return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`;
|
||||||
}
|
}
|
||||||
@@ -885,7 +885,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
);
|
);
|
||||||
const selectedXpdGoodsSoldOut = (() => {
|
const selectedXpdGoodsSoldOut = (() => {
|
||||||
const stock = (selectedXpdGoods as (DouyuGoodsItem & { goods_left?: number | null }) | undefined)?.goods_left;
|
const stock = (selectedXpdGoods as (DouyuGoodsItem & { goods_left?: number | null }) | undefined)?.goods_left;
|
||||||
return stock != null && Number.isFinite(Number(stock)) && Number(stock) <= 0;
|
return stock != null && Number.isFinite(Number(stock)) && Number(stock) === 0;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user