fix: 兼容款式与套餐规格分组 (#166)
This commit is contained in:
@@ -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()),
|
||||
)
|
||||
|
||||
|
||||
@@ -77,7 +77,9 @@ _DIAGNOSTIC_MARKERS = (
|
||||
"现在买",
|
||||
"确认购买",
|
||||
"颜色分类",
|
||||
"款式",
|
||||
"尺码",
|
||||
"套餐",
|
||||
"安全验证",
|
||||
"手机号登录",
|
||||
"操作频繁",
|
||||
@@ -420,6 +422,8 @@ def _has_confirmation_panel_evidence(panel: ET.Element) -> bool:
|
||||
"尺碼",
|
||||
"规格",
|
||||
"規格",
|
||||
"款式",
|
||||
"套餐",
|
||||
}
|
||||
for label in labels
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]">
|
||||
|
||||
Reference in New Issue
Block a user