"""T-103:仅限已取证 PDD 8.17.0 的规格面板恢复。""" from __future__ import annotations from dataclasses import dataclass import re from time import monotonic, sleep from typing import Any, Callable, Protocol from xml.etree import ElementTree from ..device.baseline import PDD_PACKAGE from .product_open import EXPECTED_PDD_VERSION from .product_url import parse_product_url EXPECTED_GOODS_ID = "937122477375" EXPECTED_UNIT_PRICE = "12.88" # 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。 TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")} _TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values())) _ENTRY = "快要抢光" _ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]" _ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]" _ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]" _SIZE = "尺码" _W, _H = 1080, 2376 _PRICE_PARENT = "[396,498][895,570]" _CURRENT = "[396,503][712,570]" _ORIGINAL = "[730,503][895,570]" _SUMMARY = "[396,654][1053,716]" _COLOR_REGION = "[36,1000][1080,1631]" _SIZE_LABEL = "[36,1654][114,1700]" _SIZE_HEADER = "[36,1637][1044,1718]" _SIZE_OPTIONS = "[36,1730][1044,2045]" _BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") _PRICE = re.compile(r"^[^0-9¥¥]*[¥¥]([1-9][0-9]*\.[0-9]{2})$") _ORIGINAL_PRICE = re.compile(r"^[¥¥][1-9][0-9]*\.[0-9]{2}$") _BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估") class SkuSelectionError(RuntimeError): """已取证判据不成立时的脱敏停止。""" class SkuPanelDevice(Protocol): def app_info(self, package_name: str) -> dict[str, Any]: ... def app_current(self) -> dict[str, Any]: ... def dump_window_hierarchy(self) -> str: ... def tap_sku_entry(self, bounds: str) -> None: ... def tap_sku_option(self, bounds: str) -> None: ... def leave_sku_panel(self) -> None: ... @dataclass(frozen=True) class SkuSelection: color: str size: str def resolve_task_selection(color: str, size: str) -> SkuSelection: mapped = TASK_TO_UI_SELECTION.get((color, size)) if mapped is None: raise SkuSelectionError("规格任务值不是已取证的唯一目标,已停止操作。") return SkuSelection(*mapped) @dataclass(frozen=True) class _Node: element: ElementTree.Element parent: "_Node | None" @property def text(self) -> str: return self.element.get("text", "") @property def desc(self) -> str: return self.element.get("content-desc", "") @property def bounds(self) -> str: return self.element.get("bounds", "") class SkuSelectionFlow: def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2, entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic, sleep_function: Callable[[float], None] = sleep) -> None: if entry_wait_timeout_seconds < 0 or entry_poll_interval_seconds <= 0: raise ValueError("入口等待参数无效。") self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds self._clock, self._sleep = monotonic_clock, sleep_function self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None: if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID: raise SkuSelectionError("商品不是已取证目标,已停止操作。") if pre_intent_hierarchy is not None: previous_nodes = _parse_nodes(pre_intent_hierarchy) if _eligible_entries(previous_nodes): raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。") entry, before = self._wait_for_entry(pre_intent_hierarchy) _action_bounds(entry.bounds) self._pending = (before, _panel) self._device.tap_sku_entry(entry.bounds) self._wait_after_action(before, _panel) def select_sku_options(self, selection: SkuSelection) -> None: if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}: raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。") initial = self._verified_nodes() _option(initial, "color", selection.color); _option(initial, "size", selection.size) _selected_label(initial, "color"); _selected_label(initial, "size") self._restore("color", selection.color) self._restore("size", selection.size) def read_sku_unit_price(self) -> str: return _unit_price(self._verified_nodes()) def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str: nodes = self._verified_nodes() _selected(nodes, "color", selection.color) _selected(nodes, "size", selection.size) return _unit_price(nodes) def exit_sku_panel_safely(self) -> None: self._require_foreground() before = self._read_hierarchy() _panel(_parse_nodes(before)) self._device.leave_sku_panel() deadline = self._clock() + self._entry_timeout while True: self._require_foreground() raw = self._read_hierarchy() if raw != before: try: _panel(_parse_nodes(raw)) except SkuSelectionError: return remaining = deadline - self._clock() if remaining <= 0: raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。") self._sleep(min(self._poll, remaining)) def reconcile_pending_action(self) -> None: """仅只读调和一次已发出但尚未得到后置条件确认的动作。""" if self._pending is None: return before, condition = self._pending self._wait_after_action(before, condition) def _restore(self, dimension: str, expected: str) -> None: self._require_foreground() before = self._read_hierarchy() nodes = _panel(_parse_nodes(before)) target = _option(nodes, dimension, expected) if _selected_label(nodes, dimension) == expected: return _action_bounds(target.bounds) condition: Callable[[list[_Node]], Any] if dimension == "color": condition = lambda refreshed: _post_color(refreshed, expected) else: condition = lambda refreshed: _post_all_targets(refreshed, expected) self._pending = (before, condition) self._device.tap_sku_option(target.bounds) if dimension == "color": self._wait_after_action(before, condition) else: self._wait_after_action(before, condition) def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]: deadline, stable = self._clock() + self._entry_timeout, None while True: self._require_version() current = self._device.app_current() if isinstance(current, dict) and current.get("package") == PDD_PACKAGE: raw = self._read_hierarchy() entries = _eligible_entries(_parse_nodes(raw)) if len(entries) > 1: raise SkuSelectionError("商品页规格入口不唯一,已停止操作。") if len(entries) == 1 and raw != previous: if stable == raw: return entries[0], raw stable = raw else: stable = None else: stable = None remaining = deadline - self._clock() if remaining <= 0: raise SkuSelectionError("等待已取证规格入口超时,未执行点击。") self._sleep(min(self._poll, remaining)) def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]: deadline = self._clock() + self._entry_timeout while True: self._require_foreground() raw = self._read_hierarchy() if raw != previous: nodes = _parse_nodes(raw) try: condition(nodes) self._pending = None return nodes except SkuSelectionError: pass remaining = deadline - self._clock() if remaining <= 0: raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。") self._sleep(min(self._poll, remaining)) def _verified_nodes(self) -> list[_Node]: self._require_foreground() return _panel(self._read_nodes()) def _require_version(self) -> None: info = self._device.app_info(PDD_PACKAGE) version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None if version != EXPECTED_PDD_VERSION: raise SkuSelectionError("拼多多版本与已取证版本不一致,已停止操作。") def _require_foreground(self) -> None: self._require_version() current = self._device.app_current() if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE: raise SkuSelectionError("拼多多不在前台,已停止操作。") def _read_hierarchy(self) -> str: try: raw = self._device.dump_window_hierarchy() except Exception as error: raise SkuSelectionError("节点树读取失败,已停止操作。") from error if not isinstance(raw, str) or not raw: raise SkuSelectionError("节点树不可用,已停止操作。") return raw def _read_nodes(self) -> list[_Node]: return _parse_nodes(self._read_hierarchy()) def _parse_nodes(raw: str) -> list[_Node]: try: root = ElementTree.fromstring(raw) except ElementTree.ParseError as error: raise SkuSelectionError("节点树格式无效,已停止操作。") from error if root.tag != "hierarchy": raise SkuSelectionError("节点树根节点无效,已停止操作。") result: list[_Node] = [] def visit(element: ElementTree.Element, parent: _Node | None) -> None: node = _Node(element, parent); result.append(node) for child in element: visit(child, node) visit(root, None) return result def _panel(nodes: list[_Node]) -> list[_Node]: parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止操作。") _one([n for n in nodes if n.parent is parent and n.bounds == _ORIGINAL and _readonly(n) and _ORIGINAL_PRICE.fullmatch(n.text)], "规格面板原价槽位不唯一,已停止操作。") _one([n for n in nodes if n.bounds == _SUMMARY and _readonly(n) and n.text.startswith("已选:")], "规格面板已选摘要不唯一,已停止操作。") _color_container(nodes); _size_container(nodes) return nodes def _unit_price(nodes: list[_Node]) -> str: parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止读取。") money = [n for n in nodes if n.parent is parent and _readonly(n) and any(mark in n.text for mark in "¥¥")] if len(money) != 2: raise SkuSelectionError("规格面板金额槽位不唯一,已停止读取。") current = _one([n for n in money if n.bounds == _CURRENT and not _clickable_ancestor(n) and not any(word in n.text for word in _BAD_PRICE_ROLE) and _PRICE.fullmatch(n.text)], "规格面板现价不唯一或不符合已取证槽位,已停止读取。") if not any(n.bounds == _ORIGINAL and _ORIGINAL_PRICE.fullmatch(n.text) for n in money): raise SkuSelectionError("规格面板原价槽位无效,已停止读取。") match = _PRICE.fullmatch(current.text) if match is None: raise SkuSelectionError("规格面板现价格式失效,已停止读取。") return match.group(1) def _option(nodes: list[_Node], dimension: str, expected: str) -> _Node: _panel(nodes) return _one([n for n in _options(nodes, dimension) if _label(n) == expected], "规格选项不唯一或不是精确匹配,已停止操作。") def _selected(nodes: list[_Node], dimension: str, expected: str) -> None: if _selected_label(nodes, dimension) != expected: raise SkuSelectionError("规格选择后读回的 selected 文案不一致,已停止操作。") def _selected_label(nodes: list[_Node], dimension: str) -> str: selected = [n for n in _options(nodes, dimension) if n.element.get("selected") == "true"] label = _label(_one(selected, "规格维度没有唯一 selected 状态,已停止操作。")) if label is None: raise SkuSelectionError("规格维度 selected 文案无效,已停止操作。") return label def _post_color(nodes: list[_Node], expected: str) -> None: _panel(nodes) _selected(nodes, "color", expected) _selected_label(nodes, "size") def _post_all_targets(nodes: list[_Node], expected_size: str) -> None: _panel(nodes) _selected(nodes, "color", _TARGET_COLOR_UI) _selected(nodes, "size", expected_size) def _options(nodes: list[_Node], dimension: str) -> list[_Node]: container = _color_container(nodes) if dimension == "color" else _size_container(nodes) if dimension == "size" else None if container is None: raise SkuSelectionError("未知规格维度,已停止操作。") candidates = [n for n in nodes if _descendant(n, container) and _contained(n, container) and _choice(n) and _label(n) is not None] return [n for n in candidates if not _labeled_ancestor(n, candidates)] def _color_container(nodes: list[_Node]) -> _Node: return _one([n for n in nodes if n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "androidx.recyclerview.widget.RecyclerView" and n.bounds == _COLOR_REGION], "规格面板颜色容器不唯一,已停止操作。") def _size_container(nodes: list[_Node]) -> _Node: label = _one([n for n in nodes if n.text == _SIZE and n.bounds == _SIZE_LABEL and _readonly(n)], "规格面板尺码标签不唯一,已停止操作。") header = label.parent if header is None or header.element.get("package") != PDD_PACKAGE or header.element.get("class") != "android.widget.LinearLayout" or header.bounds != _SIZE_HEADER or header.parent is None: raise SkuSelectionError("规格面板尺码标题容器不符合已取证结构,已停止操作。") return _one([n for n in nodes if n.parent is header.parent and n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "android.widget.LinearLayout" and n.bounds == _SIZE_OPTIONS], "规格面板尺码选项容器不唯一,已停止操作。") def _label(node: _Node) -> str | None: values = {value for value in (node.text, node.desc) if value} return values.pop() if len(values) == 1 else None def _labeled_ancestor(node: _Node, candidates: list[_Node]) -> bool: ids, parent = {id(n.element) for n in candidates}, node.parent while parent is not None: if id(parent.element) in ids and _label(parent) is not None: return True parent = parent.parent return False def _descendant(node: _Node, ancestor: _Node) -> bool: parent = node.parent while parent is not None: if parent.element is ancestor.element: return True parent = parent.parent return False def _contained(node: _Node, container: _Node) -> bool: left, top, right, bottom = _action_bounds(node.bounds) outer_left, outer_top, outer_right, outer_bottom = _action_bounds(container.bounds) return outer_left <= left < right <= outer_right and outer_top <= top < bottom <= outer_bottom def _clickable_ancestor(node: _Node) -> bool: parent = node.parent while parent is not None: if parent.element.get("clickable") == "true": return True parent = parent.parent return False def _readonly(node: _Node) -> bool: return node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" and node.element.get("clickable") == "false" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" def _live(node: _Node) -> bool: return node.element.get("package") == PDD_PACKAGE and node.element.get("clickable") == "true" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" and bool(node.bounds) def _choice(node: _Node) -> bool: return _live(node) and node.element.get("class") == "android.view.ViewGroup" and node.element.get("selected") in {"true", "false"} def _eligible_entries(nodes: list[_Node]) -> list[_Node]: # 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续 # 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部 # 容器既不属于这条祖先链,也绝不能作为替代入口。 if any(node.bounds == _PRICE_PARENT for node in nodes): return [] entry_labels = [ node for node in nodes if node.text == _ENTRY and node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" ] if len(entry_labels) != 1: return entry_labels action_ancestors = [ node for node in nodes if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true") ] if len(action_ancestors) != 1: return [] action_ancestor = action_ancestors[0] entries: list[_Node] = [] for node in entry_labels: if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"): return [] inner = node.parent switcher = inner.parent if inner is not None else None frame = switcher.parent if switcher is not None else None ancestor = frame.parent if frame is not None else None if ( inner is not None and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false") and switcher is not None and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false") and frame is not None and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false") and ancestor is action_ancestor ): entries.append(node) return entries def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool: return ( node.element.get("package") == PDD_PACKAGE and node.element.get("class") == class_name and node.bounds == bounds and node.element.get("clickable") == clickable and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" ) def _action_bounds(bounds: str) -> tuple[int, int, int, int]: match = _BOUNDS.fullmatch(bounds) if match is None: raise SkuSelectionError("规格节点坐标格式无效,已停止操作。") left, top, right, bottom = (int(item) for item in match.groups()) if not (0 <= left < right <= _W and 0 <= top < bottom <= _H): raise SkuSelectionError("规格节点坐标不在已取证屏幕范围内,已停止操作。") return left, top, right, bottom def _one(nodes: list[_Node], message: str) -> _Node: if len(nodes) != 1: raise SkuSelectionError(message) return nodes[0]