diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index 2177221..ef15785 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -31,6 +31,7 @@ from .pdd_page_classifier import ( PAGE_HOME, PAGE_LOGIN_REQUIRED, PAGE_NETWORK_ERROR, + PAGE_ORDER_CONFIRMATION, PAGE_PAYMENT, PAGE_RISK_CONTROL, GoodsOpenTracker, @@ -466,6 +467,8 @@ def _dimension_key(name: str, used: set[str]) -> str: def _is_dimension_heading(label: str) -> bool: compact = label.replace(" ", "") + if compact.startswith(("请选择", "請選擇", "已选", "已選")): + return False # 新版页面会把可选数量写进标题,例如“颜色 (6)”或“颜色(6)”。 # 数量不是规格名称的一部分,只在判断标题类型时去掉,最终展示仍保留原文。 compact = re.sub(r"[((]\d+[))]$", "", compact) @@ -496,37 +499,92 @@ def _find_non_scrollable_spec_panel( ) -> Optional[ET.Element]: """用多项强证据定位不暴露 scrollable 属性的自绘规格面板。""" - headings: list[ET.Element] = [] - summaries: list[ET.Element] = [] - panel_cues: list[ET.Element] = [] - confirms: list[ET.Element] = [] - for node in root.iter("node"): - label = _preferred_node_label(node).strip() - compact = label.replace(" ", "") - if not label or _parse_bounds(node.get("bounds", "")) is None: - continue - if _is_dimension_heading(label): - headings.append(node) - if compact.startswith(("已选", "已選", "请选择", "請選擇")): - summaries.append(node) - if compact in ("确认款式", "確認款式", "关闭", "關閉"): - panel_cues.append(node) - if compact in ("确定", "確定") and node.get("clickable") == "true": - confirms.append(node) - - if not headings or not summaries or not panel_cues or not confirms: - return None - - required = [*headings, summaries[0], panel_cues[0], confirms[0]] - common = set([required[0], *_ancestors(required[0], parents)]) - for node in required[1:]: - common.intersection_update([node, *_ancestors(node, parents)]) - all_bounds = [ bounds for node in root.iter("node") if (bounds := _parse_bounds(node.get("bounds", ""))) is not None ] + screen_bottom = max((item[3] for item in all_bounds), default=0) + pdd_node_count = sum( + node.get("package") == PDD_PACKAGE_NAME + for node in root.iter("node") + ) + + headings: list[ET.Element] = [] + summaries: list[ET.Element] = [] + panel_cues: list[ET.Element] = [] + confirms: list[ET.Element] = [] + submit_hints: list[ET.Element] = [] + quantity_editors: list[ET.Element] = [] + decreases: list[ET.Element] = [] + increases: list[ET.Element] = [] + for node in root.iter("node"): + label = _preferred_node_label(node).strip() + compact = label.replace(" ", "") + bounds = _parse_bounds(node.get("bounds", "")) + if bounds is None: + continue + is_summary = compact.startswith( + ("已选", "已選", "请选择", "請選擇") + ) + if label and not is_summary and _is_dimension_heading(label): + headings.append(node) + if is_summary: + summaries.append(node) + if compact in ("确认款式", "確認款式", "关闭", "關閉"): + panel_cues.append(node) + if compact in ("确定", "確定") and node.get("clickable") == "true": + confirms.append(node) + descendant_label = _preferred_or_descendant_label(node).replace(" ", "") + if ( + node.get("clickable") == "true" + and node.get("enabled", "true") != "false" + and node.get("visible-to-user", "true") != "false" + and screen_bottom + and (bounds[1] + bounds[3]) // 2 >= screen_bottom * 0.6 + and "提交订单" in descendant_label + and any( + word in descendant_label + for word in ("选择", "颜色", "尺码", "规格") + ) + ): + submit_hints.append(node) + if ( + node.get("class") == "android.widget.EditText" + and node.get("text", "").strip().isdigit() + and int(node.get("text", "0")) > 0 + ): + quantity_editors.append(node) + if compact == "减少数量" and node.get("clickable") == "true": + decreases.append(node) + if compact == "增加数量" and node.get("clickable") == "true": + increases.append(node) + + if not headings or not summaries or not panel_cues: + return None + if confirms: + action_nodes = [confirms[0]] + elif ( + pdd_node_count >= 3 + and len(submit_hints) == 1 + and len(quantity_editors) == 1 + and len(decreases) == 1 + and len(increases) == 1 + ): + action_nodes = [ + submit_hints[0], + quantity_editors[0], + decreases[0], + increases[0], + ] + else: + return None + + required = [*headings, summaries[0], panel_cues[0], *action_nodes] + common = set([required[0], *_ancestors(required[0], parents)]) + for node in required[1:]: + common.intersection_update([node, *_ancestors(node, parents)]) + screen_area = 0 if all_bounds: screen_area = max(item[2] for item in all_bounds) * max( @@ -1223,21 +1281,34 @@ class PddCollectService: """等待点击后的规格面板真正出现,不能只依赖固定延时。""" deadline = self._monotonic() + self._spec_panel_timeout + saw_confirmation_page = False + incomplete_reads = 0 while self._monotonic() < deadline: self._check_cancelled() xml_data = self._dump_hierarchy(device) + root = _parse_xml(xml_data) + if classify_pdd_page(root, "").kind == PAGE_ORDER_CONFIRMATION: + saw_confirmation_page = True try: snapshot = parse_spec_panel(xml_data) except PddCollectError as exc: if exc.code != "PDD_DATA_SPEC_INCOMPLETE": raise self._last_invalid_spec_xml = xml_data + incomplete_reads += 1 else: if _is_spec_panel_open(xml_data): self._last_valid_spec_xml = xml_data return snapshot self._last_invalid_spec_xml = xml_data + incomplete_reads += 1 self._sleep(0.25) + if saw_confirmation_page: + raise PddCollectError( + "PDD_DATA_SPEC_INCOMPLETE", + "规格面板已经打开,但页面结构无法识别", + {"incomplete_spec_reads": incomplete_reads}, + ) raise PddCollectError( "PDD_PAGE_SPEC_PANEL_TIMEOUT", "点击规格入口后,等待规格面板加载超时", diff --git a/client/test/fixtures/pdd_spec_panel_submit_hint.xml b/client/test/fixtures/pdd_spec_panel_submit_hint.xml new file mode 100644 index 0000000..97aa1ec --- /dev/null +++ b/client/test/fixtures/pdd_spec_panel_submit_hint.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index 27b477f..7809cab 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -609,6 +609,9 @@ class PddCollectParserTest(unittest.TestCase): cls.non_scrollable_spec_xml = ( FIXTURES / "pdd_spec_panel_non_scrollable.xml" ).read_text(encoding="utf-8") + cls.submit_hint_spec_xml = ( + FIXTURES / "pdd_spec_panel_submit_hint.xml" + ).read_text(encoding="utf-8") cls.sold_out_xml = ( FIXTURES / "pdd_sold_out_recommendations.xml" ).read_text(encoding="utf-8") @@ -789,6 +792,41 @@ class PddCollectParserTest(unittest.TestCase): self.assertEqual(result.raw_price, "¥8.17") self.assertNotIn("确认款式", [item.name for item in result.dimensions]) + def test_parse_non_scrollable_submit_hint_spec_panel(self): + result = parse_spec_panel(self.submit_hint_spec_xml) + + self.assertTrue(_is_spec_panel_open(self.submit_hint_spec_xml)) + self.assertEqual( + [(item.key, item.name) for item in result.dimensions], + [("color", "颜色分类"), ("size", "尺码")], + ) + self.assertEqual( + [value.text for value in result.dimensions[0].values], + ["测试黑色", "测试红色"], + ) + self.assertEqual( + [value.text for value in result.dimensions[1].values], + ["M", "L"], + ) + self.assertNotIn( + "请选择: 颜色分类 尺码", + [item.name for item in result.dimensions], + ) + + def test_isolated_submit_hint_is_not_a_spec_panel(self): + xml_data = """ + + + + + """ + + self.assertFalse(_is_spec_panel_open(xml_data)) + def test_non_scrollable_spec_panel_exposes_three_color_rows(self): service = PddCollectService( PddDeviceService(lambda _serial: object()), @@ -1790,6 +1828,32 @@ class PddCollectParserTest(unittest.TestCase): artifacts = raised.exception.diagnostics["artifacts"] self.assertTrue(Path(artifacts[0]["path"]).is_file()) + def test_open_confirmation_with_unrecognized_structure_is_not_timeout(self): + malformed_panel = self.submit_hint_spec_xml.replace( + '', + "", + ) + device = FakeCollectDevice(self.home_xml, malformed_panel) + device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123" + device.panel_open = True + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + spec_panel_timeout=1.0, + ) + + with self.assertRaises(PddCollectError) as raised: + service._wait_spec_panel(device) + + self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE") + self.assertIn("已经打开", raised.exception.message) + def test_transient_empty_spec_trees_are_skipped_during_full_collection(self): device = TransientSpecTreeDevice( self.home_xml, keep_only_one_sku(self.spec_xml) diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index 2e45fa3..925a0a7 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -434,6 +434,12 @@ PDD URL,包名和页面类型仍以最新控件树确认。 PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面、库存变化和价格变化。适配层必须返回结构化错误,不得把这些情况统一返回 `False`。 +规格面板既可能是带 `scrollable=true` 的滚动容器,也可能是自绘的非滚动容器。 +非滚动容器使用组合证据识别:关闭或确认标题、选择摘要、独立规格标题,以及 +“确定”按钮;另一种全高面板则必须同时具有唯一数量输入框、唯一加减按钮和底部 +提交提示。以“请选择/已选”开头的摘要不是规格标题。已经确认进入规格确认页但结构 +仍无法解析时返回规格数据不完整,不得继续误报规格入口或面板加载超时。 + 采集商品时按“首页就绪 → 读取当前首页摘要 → 有限滚动补采评价和店铺 → 点击规格入口 → 确认规格面板出现 → 颜色列表归左 → 按行蛇形逐色点击并采价 → 向下滚动并只读尺码 → 组装结果”的顺序执行。颜色点击可能改变列表位置,因此每次点击后必须重新读取