diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py
index 1e5f201..cdf1a9f 100644
--- a/client/src/pdd_collect_service.py
+++ b/client/src/pdd_collect_service.py
@@ -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()),
)
diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py
index 89f7273..193cfc1 100644
--- a/client/src/pdd_u2_purchase_adapter.py
+++ b/client/src/pdd_u2_purchase_adapter.py
@@ -77,7 +77,9 @@ _DIAGNOSTIC_MARKERS = (
"现在买",
"确认购买",
"颜色分类",
+ "款式",
"尺码",
+ "套餐",
"安全验证",
"手机号登录",
"操作频繁",
@@ -420,6 +422,8 @@ def _has_confirmation_panel_evidence(panel: ET.Element) -> bool:
"尺碼",
"规格",
"規格",
+ "款式",
+ "套餐",
}
for label in labels
)
diff --git a/client/src/util/select_color_size.py b/client/src/util/select_color_size.py
index 0921f88..b5e9864 100644
--- a/client/src/util/select_color_size.py
+++ b/client/src/util/select_color_size.py
@@ -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(
diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py
index bf19be2..52667e7 100644
--- a/client/test/test_pdd_collect_service.py
+++ b/client/test/test_pdd_collect_service.py
@@ -21,6 +21,31 @@ from src.pdd_device_service import PddDeviceService
FIXTURES = Path(__file__).parent / "fixtures"
+def style_package_spec_xml() -> str:
+ """模拟口罩商品的“款式 + 套餐”规格面板。"""
+
+ return """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+
@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(
+ '',
+ '\n'
+ '\n'
+ '',
+ )
+ 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(
+ '',
+ "",
+ ).replace(
+ '',
+ "",
+ )
+ 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)
diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py
index d569b12..bdcb67d 100644
--- a/client/test/test_pdd_u2_purchase_adapter.py
+++ b/client/test/test_pdd_u2_purchase_adapter.py
@@ -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("""