diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index df7383e..f408ee4 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -48,6 +48,13 @@ _DIMENSION_NAMES = ("颜色分类", "颜色", "尺码", "尺寸", "规格", "型 _LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录") _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中") _READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光") +_OUT_OF_STOCK_PAGE_MARKERS = ( + "商品已售罄,推荐以下相似商品", + "商品已售罄,看看相似商品", + "该商品已售罄", + "该商品暂时缺货", + "商品暂时缺货", +) _SHOP_NAME_EXCLUDES = frozenset({"进店", "关注", "店铺", "收藏", "客服"}) _SHOP_ROW_TOLERANCE = 40 @@ -509,18 +516,19 @@ def _is_spec_panel_open(xml_data: str | bytes) -> bool: parents = {child: parent for parent in root.iter() for child in parent} if _find_non_scrollable_spec_panel(root, parents) is not None: return True - has_scrollable_region = any( - node.get("scrollable") == "true" - and _parse_bounds(node.get("bounds", "")) is not None + scrollable_regions = [ + node for node in root.iter("node") - ) + if node.get("scrollable") == "true" + and _parse_bounds(node.get("bounds", "")) is not None + ] + has_scrollable_region = bool(scrollable_regions) if not has_scrollable_region: return False compact_labels = [label.replace(" ", "") for label in labels] has_selection_summary = any( - label.startswith(("请选择", "已选")) - and any(word in label for word in ("颜色", "尺码", "规格", "款式")) + label.startswith(("请选择", "請選擇", "已选", "已選")) for label in compact_labels ) has_submit_hint = any( @@ -528,7 +536,15 @@ def _is_spec_panel_open(xml_data: str | bytes) -> bool: and any(word in label for word in ("选择", "颜色", "尺码", "规格")) for label in compact_labels ) - return has_selection_summary or has_submit_hint + has_dimension_heading = any( + _is_dimension_heading(_preferred_node_label(node).strip()) + and any( + region in _ancestors(node, parents) + for region in scrollable_regions + ) + for node in root.iter("node") + ) + return has_selection_summary or has_submit_hint or has_dimension_heading def _top_level_clickable_options( @@ -600,6 +616,10 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot: if node.get("scrollable") == "true" and _parse_bounds(node.get("bounds", "")) ] non_scrollable_outer = _find_non_scrollable_spec_panel(root, parents) + if non_scrollable_outer is None and not _is_spec_panel_open(xml_data): + raise PddCollectError( + "PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的规格区域" + ) panel_scrollables = [] if non_scrollable_outer is not None: panel_scrollables = [ @@ -646,10 +666,12 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot: bounds = _parse_bounds(nested.get("bounds", "")) if bounds is None: continue - name = "颜色分类" preceding = [item for item in heading_nodes if item[0] <= bounds[1] + 30] - if preceding: - name = preceding[-1][1] + if not preceding: + # 普通推荐列表也是 RecyclerView。没有真实规格标题时,不能 + # 默认把其中的商品卡片解释成颜色。 + continue + name = preceding[-1][1] options = _top_level_clickable_options(nested, parents) if options: groups.append((name, options)) @@ -713,6 +735,17 @@ def _ancestors( def _raise_special_page(labels: Sequence[str]) -> None: + compact_labels = [label.replace(" ", "") for label in labels] + if any( + marker in label + for label in compact_labels + for marker in _OUT_OF_STOCK_PAGE_MARKERS + ): + raise PddCollectError( + "PDD_GOODS_UNAVAILABLE", + "商品已售罄,PDD 没有提供可采集的规格", + {"page_reason": "out_of_stock"}, + ) combined = " ".join(labels) if any(marker in combined for marker in _CAPTCHA_MARKERS): raise PddCollectError("PDD_PAGE_CAPTCHA", "PDD 出现安全验证,需要人工处理") @@ -1134,7 +1167,7 @@ class PddCollectService: if exc.code != "PDD_DATA_SPEC_INCOMPLETE": raise else: - if snapshot.dimensions or _is_spec_panel_open(xml_data): + if _is_spec_panel_open(xml_data): return snapshot self._sleep(0.25) raise PddCollectError( diff --git a/client/test/fixtures/pdd_sold_out_recommendations.xml b/client/test/fixtures/pdd_sold_out_recommendations.xml new file mode 100644 index 0000000..65043db --- /dev/null +++ b/client/test/fixtures/pdd_sold_out_recommendations.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index da58023..c7f03a9 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -344,6 +344,9 @@ class PddCollectParserTest(unittest.TestCase): cls.non_scrollable_spec_xml = ( FIXTURES / "pdd_spec_panel_non_scrollable.xml" ).read_text(encoding="utf-8") + cls.sold_out_xml = ( + FIXTURES / "pdd_sold_out_recommendations.xml" + ).read_text(encoding="utf-8") def test_quantity_keeps_raw_value_and_approximate_flag(self): result = parse_quantity("已拼1.2万+件") @@ -483,6 +486,55 @@ class PddCollectParserTest(unittest.TestCase): def test_goods_page_is_not_non_scrollable_spec_panel(self): self.assertFalse(_is_spec_panel_open(self.home_xml)) + def test_sold_out_recommendations_are_not_spec_options(self): + with self.assertRaises(PddCollectError) as raised: + parse_spec_panel(self.sold_out_xml) + + self.assertEqual(raised.exception.code, "PDD_GOODS_UNAVAILABLE") + self.assertEqual( + raised.exception.diagnostics, {"page_reason": "out_of_stock"} + ) + + page_without_marker = self.sold_out_xml.replace( + "商品已售罄,推荐以下相似商品", "为你推荐以下相似商品" + ) + with self.assertRaises(PddCollectError) as incomplete: + parse_spec_panel(page_without_marker) + self.assertEqual(incomplete.exception.code, "PDD_DATA_SPEC_INCOMPLETE") + + def test_blank_pdd_tree_is_not_a_spec_panel(self): + xml_data = """ + + """ + + self.assertFalse(_is_spec_panel_open(xml_data)) + with self.assertRaises(PddCollectError) as raised: + parse_spec_panel(xml_data) + self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE") + + def test_collect_stops_on_sold_out_page_without_clicking_recommendations(self): + device = FakeCollectDevice(self.home_xml, self.sold_out_xml) + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=lambda _seconds: None, + ) + + with self.assertRaises(PddCollectError) as raised: + service.collect( + FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") + ) + + self.assertEqual(raised.exception.code, "PDD_GOODS_UNAVAILABLE") + self.assertEqual( + raised.exception.diagnostics["page_reason"], "out_of_stock" + ) + # 只点击商品页规格入口,不点击任何推荐商品。 + self.assertEqual(len(device.clicks), 1) + def test_full_window_with_scattered_labels_is_not_spec_panel(self): xml_data = """