fix: 容忍规格面板临时空控件树 (#162)
This commit is contained in:
@@ -834,6 +834,8 @@ class PddCollectService:
|
||||
)
|
||||
self._artifact_directory = artifact_directory
|
||||
self._last_goods_xml: Optional[str] = None
|
||||
self._last_valid_spec_xml: Optional[str] = None
|
||||
self._last_invalid_spec_xml: Optional[str] = None
|
||||
self._goods_screens_checked = 0
|
||||
self._artifacts: list[Mapping[str, Any]] = []
|
||||
|
||||
@@ -846,6 +848,8 @@ class PddCollectService:
|
||||
goods_id = str(getattr(task, "goods_id", "") or "").strip()
|
||||
goods_id = _validate_goods_url(goods_url, goods_id)
|
||||
self._overall_deadline = self._monotonic() + self._overall_timeout
|
||||
self._last_valid_spec_xml = None
|
||||
self._last_invalid_spec_xml = None
|
||||
self._check_cancelled()
|
||||
|
||||
try:
|
||||
@@ -908,6 +912,21 @@ class PddCollectService:
|
||||
"goods_screens_checked", self._goods_screens_checked
|
||||
)
|
||||
exc.diagnostics = diagnostics
|
||||
if exc.code == "PDD_PAGE_SPEC_PANEL_LOST":
|
||||
diagnostics = dict(exc.diagnostics)
|
||||
artifacts = list(diagnostics.get("artifacts") or [])
|
||||
for label, xml_data in (
|
||||
("last-valid-spec", self._last_valid_spec_xml),
|
||||
("last-invalid-spec", self._last_invalid_spec_xml),
|
||||
):
|
||||
if not xml_data:
|
||||
continue
|
||||
artifact = self._save_xml(label, xml_data)
|
||||
if artifact and artifact not in artifacts:
|
||||
artifacts.append(artifact)
|
||||
if artifacts:
|
||||
diagnostics["artifacts"] = artifacts
|
||||
exc.diagnostics = diagnostics
|
||||
raise
|
||||
except PddDeviceError as exc:
|
||||
raise PddCollectError(exc.code, exc.message) from exc
|
||||
@@ -1167,15 +1186,59 @@ class PddCollectService:
|
||||
except PddCollectError as exc:
|
||||
if exc.code != "PDD_DATA_SPEC_INCOMPLETE":
|
||||
raise
|
||||
self._last_invalid_spec_xml = xml_data
|
||||
else:
|
||||
if _is_spec_panel_open(xml_data):
|
||||
self._last_valid_spec_xml = xml_data
|
||||
return snapshot
|
||||
self._last_invalid_spec_xml = xml_data
|
||||
self._sleep(0.25)
|
||||
raise PddCollectError(
|
||||
"PDD_PAGE_SPEC_PANEL_TIMEOUT",
|
||||
"点击规格入口后,等待规格面板加载超时",
|
||||
)
|
||||
|
||||
def _read_valid_spec_panel(
|
||||
self,
|
||||
device: Any,
|
||||
initial_xml: Optional[str] = None,
|
||||
) -> tuple[str, SpecSnapshot]:
|
||||
"""跳过规格操作期间的临时空树,返回最新有效面板和解析结果。"""
|
||||
|
||||
deadline = self._monotonic() + min(2.0, self._spec_panel_timeout)
|
||||
xml_data = initial_xml
|
||||
transient_reads = 0
|
||||
# 次数上限避免测试时钟或设备时钟异常导致无限循环。
|
||||
for attempt in range(20):
|
||||
self._check_cancelled()
|
||||
if xml_data is None:
|
||||
xml_data = self._dump_hierarchy(device)
|
||||
try:
|
||||
snapshot = parse_spec_panel(xml_data)
|
||||
panel_open = _is_spec_panel_open(xml_data)
|
||||
except PddCollectError as exc:
|
||||
if exc.code != "PDD_DATA_SPEC_INCOMPLETE":
|
||||
raise
|
||||
panel_open = False
|
||||
snapshot = None
|
||||
|
||||
if panel_open and snapshot is not None:
|
||||
self._last_valid_spec_xml = xml_data
|
||||
return xml_data, snapshot
|
||||
|
||||
self._last_invalid_spec_xml = xml_data
|
||||
transient_reads += 1
|
||||
if attempt >= 19 or self._monotonic() >= deadline:
|
||||
break
|
||||
self._sleep(0.1)
|
||||
xml_data = None
|
||||
|
||||
raise PddCollectError(
|
||||
"PDD_PAGE_SPEC_PANEL_LOST",
|
||||
"规格面板操作期间控件树持续为空或面板已经消失",
|
||||
{"transient_spec_reads": transient_reads},
|
||||
)
|
||||
|
||||
def _save_xml(self, label: str, xml_data: str) -> Optional[Mapping[str, Any]]:
|
||||
"""保存本地诊断 XML;测试未提供目录时不写文件。"""
|
||||
|
||||
@@ -1216,7 +1279,7 @@ class PddCollectService:
|
||||
"""按行蛇形遍历颜色,并在每次点击后立即读取颜色级价格。"""
|
||||
|
||||
self._move_color_list_to_left(device)
|
||||
first_xml = self._dump_hierarchy(device)
|
||||
first_xml, _ = self._read_valid_spec_panel(device)
|
||||
first_rows = self._visible_color_rows(first_xml)
|
||||
if not first_rows:
|
||||
raise PddCollectError(
|
||||
@@ -1240,7 +1303,10 @@ class PddCollectService:
|
||||
# 每次只处理一个节点;点击可能让列表自动移动,下一项必须
|
||||
# 从最新 XML 重新计算,不能继续使用点击前的旧坐标。
|
||||
while True:
|
||||
xml_data = pending_xml or self._dump_hierarchy(device)
|
||||
if pending_xml is not None:
|
||||
xml_data = pending_xml
|
||||
else:
|
||||
xml_data, _ = self._read_valid_spec_panel(device)
|
||||
pending_xml = None
|
||||
rows = self._visible_color_rows(xml_data)
|
||||
if row_index >= len(rows):
|
||||
@@ -1314,7 +1380,7 @@ class PddCollectService:
|
||||
|
||||
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
|
||||
stable_edge_reads = 0
|
||||
xml_data = self._dump_hierarchy(device)
|
||||
xml_data, _ = self._read_valid_spec_panel(device)
|
||||
for _ in range(self._max_spec_swipes):
|
||||
self._check_cancelled()
|
||||
signature = self._color_view_signature(xml_data)
|
||||
@@ -1340,7 +1406,7 @@ class PddCollectService:
|
||||
) -> ColorPriceSample:
|
||||
"""点击最新树中的颜色,等待选择和价格连续两次稳定。"""
|
||||
|
||||
xml_data = self._dump_hierarchy(device)
|
||||
xml_data, _ = self._read_valid_spec_panel(device)
|
||||
root = _parse_xml(xml_data)
|
||||
latest_visible = next(
|
||||
(
|
||||
@@ -1384,8 +1450,10 @@ class PddCollectService:
|
||||
if self._is_big_image_viewer(latest_xml):
|
||||
self._recover_spec_panel_from_big_image(device)
|
||||
return ColorPriceSample(None, None, None)
|
||||
latest_xml, snapshot = self._read_valid_spec_panel(
|
||||
device, initial_xml=latest_xml
|
||||
)
|
||||
latest_root = _parse_xml(latest_xml)
|
||||
snapshot = parse_spec_panel(latest_xml)
|
||||
latest_node = self._find_option_node(latest_root, target)
|
||||
selection_exposed = self._color_selection_state_exposed(latest_xml)
|
||||
selected = bool(
|
||||
@@ -1446,9 +1514,8 @@ class PddCollectService:
|
||||
stable_edge_reads = 0
|
||||
for swipe_count in range(self._max_spec_swipes + 1):
|
||||
self._check_cancelled()
|
||||
xml_data = self._dump_hierarchy(device)
|
||||
xml_data, snapshot = self._read_valid_spec_panel(device)
|
||||
root = _parse_xml(xml_data)
|
||||
snapshot = parse_spec_panel(xml_data)
|
||||
size_dimension = next(
|
||||
(item for item in snapshot.dimensions if item.key == "size"),
|
||||
None,
|
||||
@@ -1471,7 +1538,8 @@ class PddCollectService:
|
||||
(bounds[1] + bounds[3]) // 2,
|
||||
)
|
||||
self._sleep(self._color_poll_interval)
|
||||
latest_root = _parse_xml(self._dump_hierarchy(device))
|
||||
latest_xml, _ = self._read_valid_spec_panel(device)
|
||||
latest_root = _parse_xml(latest_xml)
|
||||
latest_node = self._find_option_node(latest_root, target)
|
||||
if latest_node is None or not self._node_is_selected(
|
||||
latest_node
|
||||
@@ -1508,7 +1576,7 @@ class PddCollectService:
|
||||
stable_edge_reads = 0
|
||||
for _ in range(self._max_spec_swipes):
|
||||
self._check_cancelled()
|
||||
xml_data = self._dump_hierarchy(device)
|
||||
xml_data, _ = self._read_valid_spec_panel(device)
|
||||
root = _parse_xml(xml_data)
|
||||
signature = tuple(
|
||||
(
|
||||
@@ -1557,19 +1625,17 @@ class PddCollectService:
|
||||
"""误入大图后只返回一次,并确认规格面板已经恢复。"""
|
||||
|
||||
device.press("back")
|
||||
for _ in range(3):
|
||||
self._check_cancelled()
|
||||
self._sleep(self._color_poll_interval)
|
||||
xml_data = self._dump_hierarchy(device)
|
||||
try:
|
||||
if _is_spec_panel_open(xml_data):
|
||||
return
|
||||
except PddCollectError:
|
||||
break
|
||||
raise PddCollectError(
|
||||
"PDD_PAGE_SPEC_PANEL_LOST",
|
||||
"点击颜色后进入大图,返回一次仍未恢复规格面板",
|
||||
)
|
||||
self._sleep(self._color_poll_interval)
|
||||
try:
|
||||
self._read_valid_spec_panel(device)
|
||||
except PddCollectError as exc:
|
||||
if exc.code != "PDD_PAGE_SPEC_PANEL_LOST":
|
||||
raise
|
||||
raise PddCollectError(
|
||||
"PDD_PAGE_SPEC_PANEL_LOST",
|
||||
"点击颜色后进入大图,返回一次仍未恢复规格面板",
|
||||
exc.diagnostics,
|
||||
) from exc
|
||||
|
||||
def _color_view_signature(
|
||||
self, xml_data: str | bytes
|
||||
@@ -1593,7 +1659,7 @@ class PddCollectService:
|
||||
for _ in range(2):
|
||||
self._check_cancelled()
|
||||
self._sleep(self._horizontal_swipe_settle_interval)
|
||||
latest_xml = self._dump_hierarchy(device)
|
||||
latest_xml, _ = self._read_valid_spec_panel(device)
|
||||
if self._color_view_signature(latest_xml) != previous_signature:
|
||||
break
|
||||
return latest_xml
|
||||
@@ -1609,8 +1675,7 @@ class PddCollectService:
|
||||
|
||||
for swipe_count in range(self._max_spec_swipes + 1):
|
||||
self._check_cancelled()
|
||||
xml_data = self._dump_hierarchy(device)
|
||||
snapshot = parse_spec_panel(xml_data)
|
||||
xml_data, snapshot = self._read_valid_spec_panel(device)
|
||||
for dimension in snapshot.dimensions:
|
||||
if dimension.key not in ("color", "size"):
|
||||
raise PddCollectError(
|
||||
|
||||
@@ -133,6 +133,45 @@ class PanelDoesNotOpenDevice(FakeCollectDevice):
|
||||
self.clicks.append((x, y))
|
||||
|
||||
|
||||
TRANSIENT_EMPTY_SPEC_XML = """<hierarchy>
|
||||
<node class="android.widget.FrameLayout" package="com.xunmeng.pinduoduo"
|
||||
bounds="[0,0][1080,2340]">
|
||||
<node class="android.widget.LinearLayout" package="com.xunmeng.pinduoduo"
|
||||
bounds="[0,0][1080,2214]" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
|
||||
|
||||
class TransientSpecTreeDevice(FakeCollectDevice):
|
||||
"""规格面板阶段交替返回临时空树和有效树。"""
|
||||
|
||||
def __init__(self, home_xml, spec_xml):
|
||||
super().__init__(home_xml, spec_xml)
|
||||
self.panel_reads = 0
|
||||
|
||||
def dump_hierarchy(self):
|
||||
if self.panel_open:
|
||||
self.panel_reads += 1
|
||||
if self.panel_reads % 2 == 0:
|
||||
return TRANSIENT_EMPTY_SPEC_XML
|
||||
return super().dump_hierarchy()
|
||||
|
||||
|
||||
class SpecTreeStaysBlankDevice(FakeCollectDevice):
|
||||
"""面板首次有效,随后持续返回空树。"""
|
||||
|
||||
def __init__(self, home_xml, spec_xml):
|
||||
super().__init__(home_xml, spec_xml)
|
||||
self.panel_reads = 0
|
||||
|
||||
def dump_hierarchy(self):
|
||||
if self.panel_open:
|
||||
self.panel_reads += 1
|
||||
if self.panel_reads > 1:
|
||||
return TRANSIENT_EMPTY_SPEC_XML
|
||||
return super().dump_hierarchy()
|
||||
|
||||
|
||||
class SnakeColorDevice(FakeCollectDevice):
|
||||
"""模拟两行横向颜色网格和滚动后才出现的尺码区域。"""
|
||||
|
||||
@@ -1441,6 +1480,84 @@ class PddCollectParserTest(unittest.TestCase):
|
||||
artifacts = raised.exception.diagnostics["artifacts"]
|
||||
self.assertTrue(Path(artifacts[0]["path"]).is_file())
|
||||
|
||||
def test_transient_empty_spec_trees_are_skipped_during_full_collection(self):
|
||||
device = TransientSpecTreeDevice(
|
||||
self.home_xml, keep_only_one_sku(self.spec_xml)
|
||||
)
|
||||
service = PddCollectService(
|
||||
PddDeviceService(lambda _serial: device),
|
||||
"USB-001",
|
||||
"client-001",
|
||||
sleeper=lambda _seconds: None,
|
||||
max_spec_swipes=0,
|
||||
)
|
||||
|
||||
result = service.collect(
|
||||
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
|
||||
)
|
||||
|
||||
self.assertEqual("测试纯棉短袖商品", result.title)
|
||||
self.assertTrue(result.skus)
|
||||
self.assertGreater(device.panel_reads, 4)
|
||||
self.assertEqual(TRANSIENT_EMPTY_SPEC_XML, service._last_invalid_spec_xml)
|
||||
self.assertIn("颜色分类", service._last_valid_spec_xml)
|
||||
|
||||
def test_persistent_empty_spec_tree_has_lost_code_and_dual_evidence(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
device = SpecTreeStaysBlankDevice(
|
||||
self.home_xml, keep_only_one_sku(self.spec_xml)
|
||||
)
|
||||
clock = FakeClock()
|
||||
service = PddCollectService(
|
||||
PddDeviceService(lambda _serial: device),
|
||||
"USB-001",
|
||||
"client-001",
|
||||
sleeper=clock.sleep,
|
||||
monotonic=clock.monotonic,
|
||||
spec_panel_timeout=1.0,
|
||||
max_spec_swipes=0,
|
||||
artifact_directory=Path(directory),
|
||||
)
|
||||
|
||||
with self.assertRaises(PddCollectError) as raised:
|
||||
service.collect(
|
||||
FakeTask(
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual("PDD_PAGE_SPEC_PANEL_LOST", raised.exception.code)
|
||||
paths = [
|
||||
Path(item["path"]).name
|
||||
for item in raised.exception.diagnostics["artifacts"]
|
||||
]
|
||||
self.assertTrue(any(name.startswith("last-valid-spec-") for name in paths))
|
||||
self.assertTrue(any(name.startswith("last-invalid-spec-") for name in paths))
|
||||
self.assertLessEqual(clock.now, 1.2)
|
||||
|
||||
def test_special_page_is_not_retried_as_transient_empty_tree(self):
|
||||
device = FakeCollectDevice(self.home_xml, self.spec_xml)
|
||||
device.panel_open = True
|
||||
device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
device.spec_xml = (
|
||||
'<hierarchy><node text="请完成验证" '
|
||||
'package="com.xunmeng.pinduoduo" /></hierarchy>'
|
||||
)
|
||||
clock = FakeClock()
|
||||
service = PddCollectService(
|
||||
PddDeviceService(lambda _serial: device),
|
||||
"USB-001",
|
||||
"client-001",
|
||||
sleeper=clock.sleep,
|
||||
monotonic=clock.monotonic,
|
||||
)
|
||||
|
||||
with self.assertRaises(PddCollectError) as raised:
|
||||
service._read_valid_spec_panel(device)
|
||||
|
||||
self.assertEqual("PDD_PAGE_CAPTCHA", raised.exception.code)
|
||||
self.assertEqual([], clock.sleeps)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user