fix: 兼容款式与套餐规格分组 (#166)

This commit is contained in:
chengma
2026-08-11 17:07:17 +08:00
parent f5d91cd707
commit 9c7e715489
5 changed files with 204 additions and 9 deletions
+41 -7
View File
@@ -44,7 +44,17 @@ Bounds = tuple[int, int, int, int]
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
_PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)")
_QUANTITY_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*(万|亿)?\s*(\+)?")
_DIMENSION_NAMES = ("颜色分类", "颜色", "尺码", "尺寸", "规格", "型号", "款式")
_DIMENSION_NAMES = (
"颜色分类",
"颜色",
"花色",
"款式",
"尺码",
"尺寸",
"套餐",
"规格",
"型号",
)
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
@@ -420,10 +430,15 @@ def _is_available(node: ET.Element) -> bool:
def _dimension_key(name: str, used: set[str]) -> str:
if "颜色" in name or "款式" in name:
if any(word in name for word in ("颜色", "花色", "款式")):
base = "color"
elif "尺码" in name or "尺寸" in name:
base = "size"
elif "套餐" in name:
# 本项目的稳定结构只有主规格 color 和第二规格 size。商品只有
# “套餐”一组时,它必须作为主规格采价;前面已有款式/颜色时,
# 套餐才是第二规格。不能无条件映射成 size,否则单规格商品无法采集。
base = "size" if "color" in used else "color"
elif "型号" in name:
base = "model"
else:
@@ -446,7 +461,20 @@ def _is_dimension_heading(label: str) -> bool:
if compact in ("确认款式", "確認款式"):
return False
return compact in _DIMENSION_NAMES or compact.endswith(
("分类", "规格", "尺寸", "尺码", "颜色", "型号", "款式", "容量", "类型", "版本", "口味")
(
"分类",
"规格",
"尺寸",
"尺码",
"颜色",
"型号",
"款式",
"套餐",
"容量",
"类型",
"版本",
"口味",
)
)
@@ -1279,9 +1307,13 @@ class PddCollectService:
"""按行蛇形遍历颜色,并在每次点击后立即读取颜色级价格。"""
self._move_color_list_to_left(device)
first_xml, _ = self._read_valid_spec_panel(device)
first_xml, first_snapshot = self._read_valid_spec_panel(device)
source_dimension = next(
(item for item in first_snapshot.dimensions if item.key == "color"),
None,
)
first_rows = self._visible_color_rows(first_xml)
if not first_rows:
if source_dimension is None or not first_rows:
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的颜色分类"
)
@@ -1371,7 +1403,7 @@ class PddCollectService:
"PDD_DATA_SPEC_INCOMPLETE", "规格面板没有采集到颜色"
)
return (
SpecDimension("color", "颜色分类", tuple(ordered_colors)),
SpecDimension("color", source_dimension.name, tuple(ordered_colors)),
samples,
)
@@ -1668,6 +1700,7 @@ class PddCollectService:
"""颜色采价完成后只滚动并收集尺码文字,不点击尺码。"""
sizes: dict[str, bool] = {}
size_name = "尺码"
previous_signature: Optional[
tuple[tuple[str, tuple[str, ...]], ...]
] = None
@@ -1683,6 +1716,7 @@ class PddCollectService:
f"发现尚未支持的规格维度:{dimension.name}",
)
if dimension.key == "size":
size_name = dimension.name
for value in dimension.values:
sizes[value.text] = sizes.get(value.text, False) or value.available
@@ -1711,7 +1745,7 @@ class PddCollectService:
return None
return SpecDimension(
"size",
"尺码",
size_name,
tuple(DimensionValue(text, available) for text, available in sizes.items()),
)
+4
View File
@@ -77,7 +77,9 @@ _DIAGNOSTIC_MARKERS = (
"现在买",
"确认购买",
"颜色分类",
"款式",
"尺码",
"套餐",
"安全验证",
"手机号登录",
"操作频繁",
@@ -420,6 +422,8 @@ def _has_confirmation_panel_evidence(panel: ET.Element) -> bool:
"尺碼",
"规格",
"規格",
"款式",
"套餐",
}
for label in labels
)
+9 -2
View File
@@ -8,7 +8,8 @@ XmlData = Union[str, bytes]
Bounds = tuple[int, int, int, int]
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
_COLOR_HEADINGS = ("颜色分类", "颜色", "款式", "颜色款式")
_COLOR_HEADINGS = ("颜色分类", "颜色", "款式", "颜色款式", "花色")
_PACKAGE_HEADINGS = ("套餐",)
def _parse_xml(xml_data: XmlData) -> ET.Element:
@@ -91,6 +92,7 @@ def _screen_bounds(root: ET.Element) -> Optional[Bounds]:
def _heading_bounds(root: ET.Element) -> Optional[Bounds]:
fuzzy_match: Optional[Bounds] = None
package_fallback: Optional[Bounds] = None
for node in root.iter("node"):
text = node.get("text", "").strip()
bounds = _parse_bounds(node.get("bounds", ""))
@@ -102,7 +104,12 @@ def _heading_bounds(root: ET.Element) -> Optional[Bounds]:
return bounds
if fuzzy_match is None and any(heading in text for heading in _COLOR_HEADINGS):
fuzzy_match = bounds
return fuzzy_match
if package_fallback is None and any(
heading in text for heading in _PACKAGE_HEADINGS
):
package_fallback = bounds
# “款式 + 套餐”优先定位款式;只有套餐一个维度时才用套餐兜底。
return fuzzy_match or package_fallback
def _target_scrollable_ancestor(
+141
View File
@@ -21,6 +21,31 @@ from src.pdd_device_service import PddDeviceService
FIXTURES = Path(__file__).parent / "fixtures"
def style_package_spec_xml() -> str:
"""模拟口罩商品的“款式 + 套餐”规格面板。"""
return """<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2340]">
<node class="android.widget.ScrollView" scrollable="true"
bounds="[0,600][1080,2200]">
<node text="券后 ¥1.68" bounds="[300,650][600,710]" />
<node text="已选:成人款 单个装" bounds="[260,720][1000,780]" />
<node text="款式" bounds="[36,800][180,860]" />
<node text="成人款" clickable="true" selected="true"
bounds="[36,900][300,1000]" />
<node text="儿童款" clickable="true" selected="false"
bounds="[330,900][594,1000]" />
<node text="套餐" bounds="[36,1100][180,1160]" />
<node text="单个装" clickable="true" selected="true"
bounds="[36,1200][300,1300]" />
<node text="两个装" clickable="true" selected="false"
bounds="[330,1200][594,1300]" />
<node text="确定" clickable="true" bounds="[0,2000][1080,2150]" />
</node>
</node>
</hierarchy>"""
@dataclass(frozen=True)
class FakeTask:
goods_url: str
@@ -428,6 +453,13 @@ class DefaultSizeDevice(SnakeColorDevice):
return xml_data
class DefaultPackageDevice(DefaultSizeDevice):
"""“套餐”作为第二规格时沿用安全的默认选择取消流程。"""
def _spec_xml(self):
return super()._spec_xml().replace('text="尺码"', 'text="套餐"')
class FakeClock:
"""测试用时钟:sleep 只推进虚拟时间,不真的等待。"""
@@ -538,6 +570,97 @@ class PddCollectParserTest(unittest.TestCase):
["黑色", "红色", "蓝色", "紫色", "浅粉", "本色"],
)
def test_style_and_package_are_separate_color_and_size_dimensions(self):
result = parse_spec_panel(style_package_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],
["单个装", "两个装"],
)
def test_package_only_is_primary_dimension(self):
root = ET.fromstring(style_package_spec_xml())
for parent in root.iter():
for child in list(parent):
if child.get("text") in {"款式", "成人款", "儿童款"}:
parent.remove(child)
result = parse_spec_panel(ET.tostring(root, encoding="unicode"))
self.assertEqual(
[(item.key, item.name) for item in result.dimensions],
[("color", "套餐")],
)
def test_third_dimension_is_not_silently_merged(self):
xml_data = style_package_spec_xml().replace(
'<node text="确定" clickable="true" bounds="[0,2000][1080,2150]" />',
'<node text="尺码" bounds="[36,1400][180,1460]" />\n'
'<node text="均码" clickable="true" selected="false" '
'bounds="[36,1500][300,1600]" />\n'
'<node text="确定" clickable="true" bounds="[0,2000][1080,2150]" />',
)
parsed = parse_spec_panel(xml_data)
self.assertEqual(
[item.key for item in parsed.dimensions],
["color", "size", "size_2"],
)
device = FakeCollectDevice(self.home_xml, xml_data)
device.panel_open = True
device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
max_spec_swipes=0,
)
with self.assertRaises(PddCollectError) as raised:
service._collect_size_dimension(device)
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_UNSUPPORTED")
def test_collect_preserves_style_and_package_names(self):
xml_data = style_package_spec_xml().replace(
'<node text="儿童款" clickable="true" selected="false"\n'
' bounds="[330,900][594,1000]" />',
"",
).replace(
'<node text="两个装" clickable="true" selected="false"\n'
' bounds="[330,1200][594,1300]" />',
"",
)
device = FakeCollectDevice(self.home_xml, xml_data)
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(
[(item.key, item.name) for item in result.dimensions],
[("color", "款式"), ("size", "套餐")],
)
self.assertEqual(
result.skus[0].options,
{"color": "成人款", "size": "单个装"},
)
def test_parse_non_scrollable_spec_panel(self):
result = parse_spec_panel(self.non_scrollable_spec_xml)
@@ -1030,6 +1153,24 @@ class PddCollectParserTest(unittest.TestCase):
self.assertEqual(1, device.size_clicks)
self.assertTrue(device.size_selected)
def test_default_package_is_cleared_and_keeps_original_heading(self):
device = DefaultPackageDevice(self.home_xml)
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
)
data = service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
).to_pdd_data()
self.assertEqual(1, device.size_clicks)
self.assertFalse(device.size_selected)
self.assertEqual(data["dimensions"][1]["key"], "size")
self.assertEqual(data["dimensions"][1]["name"], "套餐")
def test_visible_click_is_fallback_when_page_exposes_no_selection_state(self):
xml_data = keep_only_one_sku(self.spec_xml)
root = ET.fromstring(xml_data)
@@ -337,6 +337,15 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
self.assertEqual(device.clicks, [(790, 2214)])
adapter.close()
def test_purchase_panel_recognizes_style_and_package_headings(self):
xml_data = contextual_confirm_panel_xml().replace(
'text="颜色分类"', 'text="款式"'
).replace('text="尺码"', 'text="套餐"')
root = ET.fromstring(xml_data)
self.assertEqual(_page_kind(root, ""), "order_confirmation")
self.assertEqual(len(_final_submit_targets(root)), 1)
def test_plain_confirm_dialog_is_not_a_submit_target(self):
root = ET.fromstring("""<hierarchy>
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2340]">