diff --git a/.gitignore b/.gitignore index 57e67c2..160d819 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ web/frontend/dist/ data/web.db .DS_Store .reasonix/ +.tmp_reverse/ diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index 70533dc..c369caa 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -827,6 +827,7 @@ class DouyuActivityClient: 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_LIVE_BUY_API = "https://apps.game.qq.com/daoju/igw/livebuy/" + XPD_GOODS_PAGE_SIZE = 10 XPD_UA = ( "Mozilla/5.0 (Linux; Android 12; HBN-AL00 Build/V417IR; wv) " "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " @@ -953,20 +954,17 @@ class DouyuActivityClient: roleid: str, areaid: str = "1", ) -> dict[str, Any]: - """查询和平小店推荐商品(category=76 全部商品)。""" - params = { + """查询和平小店全部商品,排除热门抢购分类并拉取全部分页。""" + base_params = { "_service": "act.recommend.query", "_biz_code": "cjm", "_app_id": "2123", "app_id": "2123", - "page_begin": "0", - "page_num": "1", - "page_size": "200", "fields": ( "iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime," "dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price," "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", "order_by": "dtShowBeginTime", @@ -974,36 +972,69 @@ class DouyuActivityClient: "desc": "1", "_act_id": act_id, "actid": act_id, - "category": "76", + # category=76 是“热门抢购”;全部商品页明确将其排除。 + "excludeFields": "iCategoryId_76", "userid": openid, "roleid": roleid, "area": "1", "areaid": str(areaid), "need_limit": "1", } - response = self._request( - "get", - self.XPD_RECOMMEND_API, - source="刷新小店商品", - params=params, - headers=self._xpd_daoju_headers(), - ) - recommend = self._xpd_parse_var(response.text, "recommend") - items = (((recommend.get("data") or {}).get("client_data") or {}).get("itemsdetail")) or [] - goods = [] - for item in items: - goods.append( - { - "commodity_id": str(item.get("iGoodsId") or ""), - "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, - } + goods: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + pages: list[dict[str, Any]] = [] + offset = 0 + page_num = 1 + + while True: + params = { + **base_params, + "page_begin": str(offset), + "page_num": str(page_num), + "page_size": str(self.XPD_GOODS_PAGE_SIZE), + } + response = self._request( + "get", + self.XPD_RECOMMEND_API, + source=f"刷新小店全部商品第 {page_num} 页", + params=params, + headers=self._xpd_daoju_headers(), ) - 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]: """生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。 diff --git a/tests/test_douyu_xpd_exchange.py b/tests/test_douyu_xpd_exchange.py index 71356e3..55323ba 100644 --- a/tests/test_douyu_xpd_exchange.py +++ b/tests/test_douyu_xpd_exchange.py @@ -76,8 +76,35 @@ class XpdExchangeTests(unittest.TestCase): self.assertEqual(result['goods'][0]['price'], 6800) self.assertEqual(result['goods'][0]['goods_left'], 0) - fields = self.client._request.call_args.kwargs['params']['fields'] - self.assertIn('iGoodsLeft', fields.split(',')) + params = self.client._request.call_args.kwargs['params'] + 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__': diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 165b570..9e64a2d 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -437,8 +437,20 @@ class DouyuBatchRunner: db.commit() def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None: - """写入和平小店商品快照。""" + """同步和平小店商品快照,移除上一次热门抢购等遗留商品。""" 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: commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "") if not commodity_id: @@ -1458,7 +1470,8 @@ class DouyuBatchRunner: currency = "点券" if pay_type == 1 else "扭蛋碎片" self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换") 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", "该商品库存不足,请刷新商品列表后重试") return diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 5ad8575..de4ee5d 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -242,7 +242,7 @@ function xpdGoodsLabel(item: DouyuGoodsItem): string { ].filter(Boolean); const stock = xpd.goods_left; const stockText = stock != null && Number.isFinite(Number(stock)) - ? `库存${stock}` + ? (Number(stock) < 0 ? '库存未公开' : `库存${stock}`) : '库存未知'; 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 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(() => {