fix: 采购选色前归位规格面板 (#230)
This commit is contained in:
@@ -70,6 +70,14 @@ _PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支
|
||||
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
|
||||
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
|
||||
_ADDRESS_ACTION_SETTLE_SECONDS = 1.0
|
||||
_COLOR_DIMENSION_HEADINGS = frozenset(
|
||||
{"颜色分类", "顏色分類", "颜色", "顏色", "款式", "花色", "颜色款式"}
|
||||
)
|
||||
_SECOND_DIMENSION_HEADINGS = frozenset(
|
||||
{"尺码", "尺碼", "尺寸", "套餐", "规格", "規格", "型号", "型號"}
|
||||
)
|
||||
_PANEL_TOP_MAX_SWIPES = 8
|
||||
_PANEL_TOP_STABLE_READS = 2
|
||||
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
|
||||
_MAX_QUANTITY_BUTTON_CLICKS = 5
|
||||
_KEYBOARD_STATUS_MARKERS = (
|
||||
@@ -167,6 +175,159 @@ def _parent_nodes(root: ET.Element) -> dict[ET.Element, ET.Element]:
|
||||
}
|
||||
|
||||
|
||||
def _dimension_heading(label: str) -> str:
|
||||
"""只接受独立规格标题,明确排除选择摘要和提交提示。"""
|
||||
|
||||
compact = re.sub(r"\s+", "", str(label or ""))
|
||||
if not compact or compact.startswith(
|
||||
("请选择", "請選擇", "已选", "已選", "已选择", "已選擇")
|
||||
):
|
||||
return ""
|
||||
if compact.startswith("选择") and "后" in compact:
|
||||
return ""
|
||||
compact = re.sub(r"[((]\d+[))]$", "", compact)
|
||||
return compact
|
||||
|
||||
|
||||
def _screen_bounds(root: ET.Element) -> Optional[Bounds]:
|
||||
parsed = [
|
||||
bounds
|
||||
for node in root.iter("node")
|
||||
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
|
||||
]
|
||||
if not parsed:
|
||||
return None
|
||||
return 0, 0, max(item[2] for item in parsed), max(item[3] for item in parsed)
|
||||
|
||||
|
||||
def _has_visible_color_region(root: ET.Element) -> bool:
|
||||
"""正式颜色标题下必须同时存在至少一个可靠的可点击选项。"""
|
||||
|
||||
parents = _parent_nodes(root)
|
||||
headings: list[Bounds] = []
|
||||
second_heading_tops: list[int] = []
|
||||
for node in root.iter("node"):
|
||||
heading = _dimension_heading(_label(node))
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if bounds is None or node.get("visible-to-user", "true") == "false":
|
||||
continue
|
||||
if heading in _COLOR_DIMENSION_HEADINGS:
|
||||
headings.append(bounds)
|
||||
elif heading in _SECOND_DIMENSION_HEADINGS:
|
||||
second_heading_tops.append(bounds[1])
|
||||
|
||||
screen = _screen_bounds(root)
|
||||
if not headings or screen is None:
|
||||
return False
|
||||
|
||||
excluded = {
|
||||
"增加数量",
|
||||
"减少数量",
|
||||
"提交订单",
|
||||
"确定",
|
||||
"打开大图",
|
||||
}
|
||||
for heading_bounds in headings:
|
||||
boundary = min(
|
||||
(
|
||||
top
|
||||
for top in second_heading_tops
|
||||
if top > heading_bounds[3]
|
||||
),
|
||||
default=screen[3],
|
||||
)
|
||||
for node in root.iter("node"):
|
||||
label = _label(node).strip()
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if not label or bounds is None:
|
||||
continue
|
||||
if bounds[1] < heading_bounds[3] or bounds[1] >= boundary:
|
||||
continue
|
||||
if _dimension_heading(label) in (
|
||||
_COLOR_DIMENSION_HEADINGS | _SECOND_DIMENSION_HEADINGS
|
||||
):
|
||||
continue
|
||||
compact = label.replace(" ", "")
|
||||
if (
|
||||
compact.startswith(
|
||||
("请选择", "請選擇", "已选", "已選", "选择", "選擇")
|
||||
)
|
||||
or any(marker in compact for marker in excluded)
|
||||
or re.fullmatch(r"[¥¥]?\d+(?:\.\d{1,2})?", compact)
|
||||
):
|
||||
continue
|
||||
if _nearest_clickable_bounds(node, parents) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _needs_color_region_restore(root: ET.Element) -> bool:
|
||||
"""只有第二规格已出现且颜色摘要仍存在时,才判定面板停在中部。"""
|
||||
|
||||
labels = [_label(node).strip() for node in root.iter("node")]
|
||||
has_color_summary = any(
|
||||
label.replace(" ", "").startswith(
|
||||
("请选择", "請選擇", "已选", "已選", "已选择", "已選擇")
|
||||
)
|
||||
and any(heading in label.replace(" ", "") for heading in _COLOR_DIMENSION_HEADINGS)
|
||||
for label in labels
|
||||
)
|
||||
has_second_heading = any(
|
||||
_dimension_heading(label) in _SECOND_DIMENSION_HEADINGS
|
||||
for label in labels
|
||||
)
|
||||
return has_color_summary and has_second_heading
|
||||
|
||||
|
||||
def _purchase_panel_scroll_region(root: ET.Element) -> Optional[Bounds]:
|
||||
"""返回规格面板中唯一可靠的纵向滚动容器。"""
|
||||
|
||||
screen = _screen_bounds(root)
|
||||
if screen is None:
|
||||
return None
|
||||
screen_width = screen[2] - screen[0]
|
||||
screen_height = screen[3] - screen[1]
|
||||
candidates: list[Bounds] = []
|
||||
for node in root.iter("node"):
|
||||
if (
|
||||
node.get("scrollable") != "true"
|
||||
or node.get("package") != PDD_PACKAGE_NAME
|
||||
or node.get("visible-to-user", "true") == "false"
|
||||
or node.get("enabled", "true") == "false"
|
||||
):
|
||||
continue
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if bounds is None:
|
||||
continue
|
||||
width = bounds[2] - bounds[0]
|
||||
height = bounds[3] - bounds[1]
|
||||
if width >= screen_width * 0.60 and height >= screen_height * 0.20:
|
||||
candidates.append(bounds)
|
||||
unique = sorted(set(candidates))
|
||||
return unique[0] if len(unique) == 1 else None
|
||||
|
||||
|
||||
def _region_signature(root: ET.Element, region: Bounds) -> str:
|
||||
"""生成当前滚动视口的短签名,只用于判断是否已经无法继续归位。"""
|
||||
|
||||
parts = []
|
||||
for node in root.iter("node"):
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if bounds is None:
|
||||
continue
|
||||
center_x = (bounds[0] + bounds[2]) // 2
|
||||
center_y = (bounds[1] + bounds[3]) // 2
|
||||
if not (
|
||||
region[0] <= center_x <= region[2]
|
||||
and region[1] <= center_y <= region[3]
|
||||
):
|
||||
continue
|
||||
label = _label(node)
|
||||
if label:
|
||||
parts.append(f"{label}|{bounds}")
|
||||
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _nearest_clickable_bounds(
|
||||
node: ET.Element, parents: Mapping[ET.Element, ET.Element]
|
||||
) -> Optional[Bounds]:
|
||||
@@ -862,6 +1023,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||
|
||||
color = checked.get("color")
|
||||
if color:
|
||||
panel_xml = self._restore_purchase_panel_color_region(panel_xml)
|
||||
trace = current_performance_trace()
|
||||
stage = (
|
||||
trace.stage("purchase_select_color")
|
||||
@@ -1258,6 +1420,104 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
def _restore_purchase_panel_color_region(self, xml_data: str) -> str:
|
||||
"""选择颜色前把规格内容有界归位到正式颜色区域。"""
|
||||
|
||||
root = _parse_xml(xml_data)
|
||||
if _has_visible_color_region(root):
|
||||
return xml_data
|
||||
if not _needs_color_region_restore(root):
|
||||
# 兼容不暴露正式标题或滚动属性、但原选择器可以处理的旧面板。
|
||||
return xml_data
|
||||
|
||||
region = _purchase_panel_scroll_region(root)
|
||||
if region is None:
|
||||
self._raise_color_region_not_found(
|
||||
swipe_count=0,
|
||||
stable_reads=0,
|
||||
scroll_region_found=False,
|
||||
)
|
||||
|
||||
device = self._require_device()
|
||||
previous_signature = _region_signature(root, region)
|
||||
unchanged_reads = 0
|
||||
swipe_count = 0
|
||||
for _ in range(_PANEL_TOP_MAX_SWIPES):
|
||||
self._check_cancelled("purchase_select_options")
|
||||
left, top, right, bottom = region
|
||||
height = bottom - top
|
||||
x = (left + right) // 2
|
||||
# 手指向下滑,让规格内容返回顶部;坐标始终限制在规格容器内。
|
||||
device.swipe(
|
||||
x,
|
||||
top + int(height * 0.25),
|
||||
x,
|
||||
top + int(height * 0.82),
|
||||
duration=0.35,
|
||||
)
|
||||
swipe_count += 1
|
||||
self._sleep(0.2)
|
||||
|
||||
current_xml = self._dump_hierarchy()
|
||||
root = _parse_xml(current_xml)
|
||||
page_kind = _page_kind(root, "")
|
||||
if page_kind in {
|
||||
"captcha",
|
||||
"login_required",
|
||||
"risk_control",
|
||||
"payment",
|
||||
}:
|
||||
self._raise_special_page(page_kind)
|
||||
if page_kind != "order_confirmation":
|
||||
self._raise_color_region_not_found(
|
||||
swipe_count=swipe_count,
|
||||
stable_reads=unchanged_reads,
|
||||
scroll_region_found=True,
|
||||
)
|
||||
if _has_visible_color_region(root):
|
||||
return current_xml
|
||||
|
||||
current_region = _purchase_panel_scroll_region(root)
|
||||
if current_region is None:
|
||||
break
|
||||
region = current_region
|
||||
signature = _region_signature(root, region)
|
||||
if signature == previous_signature:
|
||||
unchanged_reads += 1
|
||||
else:
|
||||
unchanged_reads = 0
|
||||
previous_signature = signature
|
||||
if unchanged_reads >= _PANEL_TOP_STABLE_READS:
|
||||
break
|
||||
|
||||
self._raise_color_region_not_found(
|
||||
swipe_count=swipe_count,
|
||||
stable_reads=unchanged_reads,
|
||||
scroll_region_found=True,
|
||||
)
|
||||
|
||||
def _raise_color_region_not_found(
|
||||
self,
|
||||
*,
|
||||
swipe_count: int,
|
||||
stable_reads: int,
|
||||
scroll_region_found: bool,
|
||||
) -> None:
|
||||
diagnostics: dict[str, Any] = {
|
||||
"swipe_count": swipe_count,
|
||||
"stable_reads": stable_reads,
|
||||
"scroll_region_found": scroll_region_found,
|
||||
}
|
||||
artifact = self._save_last_xml("purchase-color-region-not-found")
|
||||
if artifact is not None:
|
||||
diagnostics["artifacts"] = [artifact]
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_PANEL_COLOR_REGION_NOT_FOUND",
|
||||
"规格面板已打开,但无法回到颜色分类区域",
|
||||
step="purchase_select_options",
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
def _dump_hierarchy(self) -> str:
|
||||
raw = self._require_device().dump_hierarchy()
|
||||
xml_data = (
|
||||
|
||||
@@ -98,6 +98,53 @@ def contextual_confirm_panel_xml(quantity: int = 1) -> str:
|
||||
</hierarchy>"""
|
||||
|
||||
|
||||
def vertically_scrolled_purchase_panel_xml(*, at_color_top: bool) -> str:
|
||||
"""构造规格面板位于颜色顶部或停在中部的脱敏控件树。"""
|
||||
|
||||
if at_color_top:
|
||||
scroll_content = """
|
||||
<node text="颜色分类" bounds="[32,1120][190,1170]"/>
|
||||
<node text="黑色" clickable="true" enabled="true"
|
||||
visible-to-user="true" bounds="[32,1190][332,1320]"/>
|
||||
<node text="白色" clickable="true" enabled="true"
|
||||
visible-to-user="true" bounds="[359,1190][659,1320]"/>
|
||||
<node text="尺码" bounds="[32,1500][114,1550]"/>
|
||||
<node text="均码" clickable="true" enabled="true"
|
||||
visible-to-user="true" bounds="[32,1580][391,1660]"/>
|
||||
"""
|
||||
else:
|
||||
scroll_content = """
|
||||
<node text="黑色 ¥6.93" clickable="true" enabled="true"
|
||||
visible-to-user="true" bounds="[32,1095][332,1460]"/>
|
||||
<node text="白色 ¥6.93" clickable="true" enabled="true"
|
||||
visible-to-user="true" bounds="[359,1095][659,1460]"/>
|
||||
<node text="尺码" bounds="[32,1605][114,1653]"/>
|
||||
<node text="均码" clickable="true" selected="true"
|
||||
enabled="true" visible-to-user="true"
|
||||
bounds="[32,1677][391,1753]"/>
|
||||
"""
|
||||
return f"""<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||
<node text="确认款式" bounds="[430,650][650,710]"/>
|
||||
<node text="请选择: 颜色分类" bounds="[360,770][990,830]"/>
|
||||
<node class="android.widget.EditText" text="1"
|
||||
bounds="[440,870][510,940]"/>
|
||||
<node content-desc="减少数量" clickable="true"
|
||||
bounds="[360,870][430,940]"/>
|
||||
<node content-desc="增加数量" clickable="true"
|
||||
bounds="[515,870][585,940]"/>
|
||||
<node package="com.xunmeng.pinduoduo"
|
||||
class="androidx.recyclerview.widget.RecyclerView"
|
||||
scrollable="true" enabled="true" visible-to-user="true"
|
||||
bounds="[0,1095][1020,1918]">
|
||||
{scroll_content}
|
||||
</node>
|
||||
<node text="选择颜色分类后,提交订单"
|
||||
bounds="[0,2181][1080,2328]"/>
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
|
||||
|
||||
class FakeEditor:
|
||||
def __init__(self, device) -> None:
|
||||
self.device = device
|
||||
@@ -180,6 +227,32 @@ class FakeDevice:
|
||||
raise AssertionError(f"未预期的 selector: {selector}")
|
||||
|
||||
|
||||
class VerticallyScrolledColorPanelDevice(FakeDevice):
|
||||
"""模拟 PDD 记住规格面板纵向位置。"""
|
||||
|
||||
def __init__(self, *, starts_at_top: bool, restores_on_swipe: bool = True):
|
||||
super().__init__()
|
||||
self.at_color_top = starts_at_top
|
||||
self.restores_on_swipe = restores_on_swipe
|
||||
self.swipes = []
|
||||
|
||||
def dump_hierarchy(self):
|
||||
if not self.has_opened:
|
||||
return super().dump_hierarchy()
|
||||
if self.mode == "home":
|
||||
return home_xml()
|
||||
if self.mode == "panel":
|
||||
return vertically_scrolled_purchase_panel_xml(
|
||||
at_color_top=self.at_color_top
|
||||
)
|
||||
return super().dump_hierarchy()
|
||||
|
||||
def swipe(self, x1, y1, x2, y2, duration=0.0):
|
||||
self.swipes.append((x1, y1, x2, y2, duration))
|
||||
if self.restores_on_swipe:
|
||||
self.at_color_top = True
|
||||
|
||||
|
||||
class ColdStartDevice(FakeDevice):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -927,6 +1000,74 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||
self.assertEqual(device.clicks[-1], (540, 2140))
|
||||
adapter.close()
|
||||
|
||||
def test_purchase_restores_color_region_before_selecting_color(self):
|
||||
clock = FakeClock()
|
||||
device = VerticallyScrolledColorPanelDevice(starts_at_top=False)
|
||||
selected_xml = []
|
||||
|
||||
def select_color_fn(_device, xml_data, _target, **_kwargs):
|
||||
selected_xml.append(xml_data)
|
||||
return True
|
||||
|
||||
adapter = self._live_adapter(
|
||||
device,
|
||||
[],
|
||||
sleeper=clock.sleep,
|
||||
monotonic=clock.monotonic,
|
||||
select_color_fn=select_color_fn,
|
||||
)
|
||||
adapter.open_goods(GOODS_URL)
|
||||
|
||||
adapter.select_options({"color": "黑色"})
|
||||
|
||||
self.assertEqual(len(device.swipes), 1)
|
||||
self.assertLess(device.swipes[0][1], device.swipes[0][3])
|
||||
self.assertEqual(len(selected_xml), 1)
|
||||
self.assertIn('text="颜色分类"', selected_xml[0])
|
||||
adapter.close()
|
||||
|
||||
def test_purchase_does_not_scroll_when_color_region_is_already_visible(self):
|
||||
device = VerticallyScrolledColorPanelDevice(starts_at_top=True)
|
||||
adapter = self._live_adapter(
|
||||
device,
|
||||
[],
|
||||
select_color_fn=lambda *_args, **_kwargs: True,
|
||||
)
|
||||
adapter.open_goods(GOODS_URL)
|
||||
|
||||
adapter.select_options({"color": "黑色"})
|
||||
|
||||
self.assertEqual(device.swipes, [])
|
||||
adapter.close()
|
||||
|
||||
def test_purchase_stops_when_color_region_cannot_be_restored(self):
|
||||
clock = FakeClock()
|
||||
device = VerticallyScrolledColorPanelDevice(
|
||||
starts_at_top=False,
|
||||
restores_on_swipe=False,
|
||||
)
|
||||
select_calls = []
|
||||
adapter = self._live_adapter(
|
||||
device,
|
||||
[],
|
||||
sleeper=clock.sleep,
|
||||
monotonic=clock.monotonic,
|
||||
select_color_fn=lambda *_args, **_kwargs: select_calls.append(True),
|
||||
)
|
||||
adapter.open_goods(GOODS_URL)
|
||||
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.select_options({"color": "黑色"})
|
||||
|
||||
self.assertEqual(
|
||||
raised.exception.code,
|
||||
"PURCHASE_PANEL_COLOR_REGION_NOT_FOUND",
|
||||
)
|
||||
self.assertEqual(len(device.swipes), 2)
|
||||
self.assertEqual(select_calls, [])
|
||||
self.assertEqual(len(device.clicks), 1)
|
||||
adapter.close()
|
||||
|
||||
def test_address_is_split_at_first_separator_before_tagging(self):
|
||||
self.assertEqual(
|
||||
_tagged_shipping_address(
|
||||
|
||||
Reference in New Issue
Block a user