fix: 兼容提交提示型非滚动规格面板 (#175)
This commit is contained in:
@@ -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",
|
||||
"点击规格入口后,等待规格面板加载超时",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout"
|
||||
bounds="[0,0][1080,2376]" visible-to-user="true" enabled="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout"
|
||||
clickable="true" bounds="[0,120][1080,2328]"
|
||||
visible-to-user="true" enabled="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.ImageView"
|
||||
content-desc="关闭" clickable="true" bounds="[975,273][1050,348]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="券后¥5.03" bounds="[396,580][679,647]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="请选择: 颜色分类 尺码" bounds="[396,731][1053,793]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.ImageView"
|
||||
content-desc="减少数量" clickable="true" bounds="[396,829][474,904]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.EditText"
|
||||
text="1" clickable="true" bounds="[480,829][561,904]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.ImageView"
|
||||
content-desc="增加数量" clickable="true" bounds="[567,829][645,904]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="颜色分类" bounds="[36,1102][216,1163]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup"
|
||||
clickable="true" bounds="[36,1188][352,1591]"
|
||||
visible-to-user="true" enabled="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="测试黑色" bounds="[36,1483][352,1591]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup"
|
||||
clickable="true" bounds="[382,1188][698,1591]"
|
||||
visible-to-user="true" enabled="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="测试红色" bounds="[382,1483][698,1591]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="尺码" bounds="[36,1641][126,1694]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="M" clickable="true" bounds="[36,1720][119,1805]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="L" clickable="true" bounds="[149,1720][217,1805]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout"
|
||||
clickable="true" bounds="[0,2181][1080,2328]"
|
||||
visible-to-user="true" enabled="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
|
||||
text="选择颜色分类及尺码后,提交订单"
|
||||
bounds="[208,2225][871,2284]"
|
||||
visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -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 = """<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo"
|
||||
class="android.widget.FrameLayout" bounds="[0,0][1080,2376]">
|
||||
<node package="com.xunmeng.pinduoduo" content-desc="关闭"
|
||||
clickable="true" bounds="[975,273][1050,348]" />
|
||||
<node package="com.xunmeng.pinduoduo"
|
||||
text="选择规格后,提交订单" clickable="true"
|
||||
bounds="[0,2181][1080,2328]" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
|
||||
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(
|
||||
'<node package="com.xunmeng.pinduoduo" class="android.widget.EditText"\n'
|
||||
' text="1" clickable="true" bounds="[480,829][561,904]"\n'
|
||||
' visible-to-user="true" enabled="true" />',
|
||||
"",
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -434,6 +434,12 @@ PDD URL,包名和页面类型仍以最新控件树确认。
|
||||
|
||||
PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面、库存变化和价格变化。适配层必须返回结构化错误,不得把这些情况统一返回 `False`。
|
||||
|
||||
规格面板既可能是带 `scrollable=true` 的滚动容器,也可能是自绘的非滚动容器。
|
||||
非滚动容器使用组合证据识别:关闭或确认标题、选择摘要、独立规格标题,以及
|
||||
“确定”按钮;另一种全高面板则必须同时具有唯一数量输入框、唯一加减按钮和底部
|
||||
提交提示。以“请选择/已选”开头的摘要不是规格标题。已经确认进入规格确认页但结构
|
||||
仍无法解析时返回规格数据不完整,不得继续误报规格入口或面板加载超时。
|
||||
|
||||
采集商品时按“首页就绪 → 读取当前首页摘要 → 有限滚动补采评价和店铺 →
|
||||
点击规格入口 → 确认规格面板出现 → 颜色列表归左 → 按行蛇形逐色点击并采价 →
|
||||
向下滚动并只读尺码 → 组装结果”的顺序执行。颜色点击可能改变列表位置,因此每次点击后必须重新读取
|
||||
|
||||
Reference in New Issue
Block a user