diff --git a/client/src/collect_task_service.py b/client/src/collect_task_service.py index 39ac4c0..13bb58a 100644 --- a/client/src/collect_task_service.py +++ b/client/src/collect_task_service.py @@ -146,6 +146,7 @@ class CollectTaskService: collector = self._factory( self._device_address, self._client.client_id, self._cancelled ) + business_failure_message = "" try: result = collector.collect(started.task) event = self._repository.save_collect_result( @@ -165,7 +166,14 @@ class CollectTaskService: ) if exc.code == "PDD_CANCELLED": return self._submit_cancelled(event, remote_task_id) - return self._submit(event) + if exc.code == "PDD_GOODS_UNAVAILABLE": + business_failure_message = exc.message + outcome = self._submit(event) + if business_failure_message and outcome.kind == "failed": + return CollectTaskOutcome( + "business_failed", business_failure_message, remote_task_id + ) + return outcome def _submit_cancelled( self, event: OutboxEventRecord, remote_task_id: str @@ -226,6 +234,8 @@ class CollectTaskService: def _classify_error(code: str) -> tuple[TaskStatus, bool]: if code == "PDD_CANCELLED": return TaskStatus.CANCELLED, False + if code == "PDD_GOODS_UNAVAILABLE": + return TaskStatus.FAILED, False if code in { "PDD_PAGE_LOGIN_REQUIRED", "PDD_PAGE_CAPTCHA", diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index a048caa..f088fbd 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -21,6 +21,22 @@ from urllib.parse import parse_qs, urlparse from .pdd_device_service import PDD_PACKAGE_NAME, PddDeviceError, PddDeviceService from .performance_timing import current_performance_trace +from .pdd_page_classifier import ( + ACTION_NETWORK_ERROR, + ACTION_READY, + ACTION_REOPEN, + ACTION_UNAVAILABLE, + PAGE_CAPTCHA, + PAGE_GOODS, + PAGE_HOME, + PAGE_LOGIN_REQUIRED, + PAGE_NETWORK_ERROR, + PAGE_PAYMENT, + PAGE_RISK_CONTROL, + GoodsOpenTracker, + PddPageObservation, + classify_pdd_page, +) from .util.get_size_panle_coord import get_size_panel_coord @@ -742,13 +758,15 @@ class PddCollectService: "规格面板没有生成可提交的规格组合", ) except PddCollectError as exc: - if not exc.diagnostics and self._last_goods_xml: + if self._last_goods_xml: artifact = self._save_xml("collect-failed", self._last_goods_xml) if artifact: - exc.diagnostics = { - "artifacts": [artifact], - "goods_screens_checked": self._goods_screens_checked, - } + diagnostics = dict(exc.diagnostics) + diagnostics.setdefault("artifacts", []).append(artifact) + diagnostics.setdefault( + "goods_screens_checked", self._goods_screens_checked + ) + exc.diagnostics = diagnostics raise except PddDeviceError as exc: raise PddCollectError(exc.code, exc.message) from exc @@ -797,6 +815,7 @@ class PddCollectService: if initial_app_state is not None else device.app_current() ) + before_open = self._read_page_observation(device, current) if current.get("package") != PDD_PACKAGE_NAME: stage = trace.stage("pdd_start_or_wait") if trace else nullcontext() with stage: @@ -822,8 +841,12 @@ class PddCollectService: ready_stage = trace.stage("goods_page_ready") if trace else nullcontext() with ready_stage: - deadline = self._monotonic() + self._page_timeout + opened_at = self._monotonic() + deadline = opened_at + self._page_timeout + tracker = GoodsOpenTracker(before_open, opened_at) first_dump = True + last_kind = "unknown" + last_recorded = "" while self._monotonic() < deadline: self._check_cancelled() current = device.app_current() @@ -834,29 +857,84 @@ class PddCollectService: else: xml_data = self._dump_hierarchy(device) root = _parse_xml(xml_data) - last_labels = _all_labels(root) - pdd_node_count = sum( - 1 - for node in root.iter("node") - if node.get("package") == PDD_PACKAGE_NAME + observation = classify_pdd_page( + root, str(current.get("package") or "") ) - # 部分 OPPO/ColorOS 设备会一直把无线调试设置页报告为焦点, - # 即使屏幕和无障碍树已经是 PDD。此时以树中真实包名为准。 - is_pdd_hierarchy = pdd_node_count >= 3 - is_pdd_focused = current.get("package") == PDD_PACKAGE_NAME - if is_pdd_focused or is_pdd_hierarchy: - _raise_special_page(last_labels) - combined = " ".join(last_labels) - loading = "加载中" in combined or "正在加载" in combined - if not loading and any( - marker in combined for marker in _READY_MARKERS + last_kind = observation.kind + if trace is not None and last_kind != last_recorded: + trace.record( + "pdd_page_transition", + 0, + f"attempt_{tracker.attempt}_{last_kind}", + ) + last_recorded = last_kind + self._raise_classified_special_page(last_kind) + decision = tracker.observe(observation, self._monotonic()) + if decision.action == ACTION_READY: + if trace is not None: + trace.checkpoint("end_to_end_total") + return + if decision.action == ACTION_REOPEN: + with ( + trace.stage("open_url_retry") + if trace is not None + else nullcontext() ): - if trace is not None: - trace.checkpoint("end_to_end_total") - return - self._sleep(0.5) + device.open_url(goods_url) + tracker.reopened(self._monotonic()) + last_recorded = "" + continue + if decision.action == ACTION_UNAVAILABLE: + raise PddCollectError( + "PDD_GOODS_UNAVAILABLE", + "商品链接已失效,PDD 无法打开商品详情页并返回了首页", + {"page_kind": PAGE_HOME, "open_attempts": 2}, + ) + if decision.action == ACTION_NETWORK_ERROR: + raise PddCollectError( + "PDD_PAGE_NETWORK_ERROR", + "PDD 商品页网络或服务异常,请稍后重试", + {"page_kind": PAGE_NETWORK_ERROR}, + ) + self._sleep(0.25) + if tracker.stale_goods_seen: + raise PddCollectError( + "PDD_GOODS_IDENTITY_UNCONFIRMED", + "打开商品链接后仍停留在原商品页,无法确认本次目标商品", + {"page_kind": PAGE_GOODS, "open_attempts": tracker.attempt}, + ) raise PddCollectError( - "PDD_PAGE_TIMEOUT", "等待 PDD 商品详情页加载超时" + "PDD_PAGE_TIMEOUT", + f"等待 PDD 商品详情页加载超时,最后页面为 {last_kind}", + {"page_kind": last_kind, "open_attempts": tracker.attempt}, + ) + + def _read_page_observation( + self, device: Any, current: Mapping[str, Any] + ) -> Optional[PddPageObservation]: + """读取深链打开前页面;读取失败不妨碍后续打开目标链接。""" + + try: + root = _parse_xml(self._dump_hierarchy(device)) + return classify_pdd_page( + root, str(current.get("package") or "") + ) + except (PddCollectError, RuntimeError, OSError): + return None + + @staticmethod + def _raise_classified_special_page(kind: str) -> None: + if kind == PAGE_CAPTCHA: + raise PddCollectError( + "PDD_PAGE_CAPTCHA", "PDD 出现安全验证,需要人工处理" + ) + if kind == PAGE_LOGIN_REQUIRED: + raise PddCollectError( + "PDD_PAGE_LOGIN_REQUIRED", "PDD 登录已失效,需要人工重新登录" + ) + if kind in {PAGE_RISK_CONTROL, PAGE_PAYMENT}: + raise PddCollectError( + "PDD_PAGE_UNKNOWN", "PDD 出现风控或支付页面,已停止采集" ) def _collect_goods_details(self, device: Any) -> GoodsSnapshot: diff --git a/client/src/pdd_page_classifier.py b/client/src/pdd_page_classifier.py new file mode 100644 index 0000000..ba39346 --- /dev/null +++ b/client/src/pdd_page_classifier.py @@ -0,0 +1,255 @@ +"""PDD 页面分类和商品深链跳转状态。 + +本模块只处理脱敏的页面结构,不依赖 Qt、SQLite 或具体业务服务。采集和采购必须 +共用这里的分类,避免一边把首页当商品页、另一边只能等待超时。 +""" + +from __future__ import annotations + +import hashlib +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass + +from .pdd_device_service import PDD_PACKAGE_NAME + + +PAGE_EXTERNAL = "external" +PAGE_LOADING = "loading" +PAGE_GOODS = "goods" +PAGE_HOME = "home" +PAGE_NETWORK_ERROR = "network_error" +PAGE_LOGIN_REQUIRED = "login_required" +PAGE_CAPTCHA = "captcha" +PAGE_RISK_CONTROL = "risk_control" +PAGE_PAYMENT = "payment" +PAGE_ORDER_CONFIRMATION = "order_confirmation" +PAGE_UNKNOWN = "unknown" + +ACTION_WAIT = "wait" +ACTION_READY = "ready" +ACTION_REOPEN = "reopen" +ACTION_UNAVAILABLE = "unavailable" +ACTION_NETWORK_ERROR = "network_error" + +_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光") +_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录") +_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中") +_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常") +_PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支付失败") +_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买") +_NETWORK_MARKERS = ( + "网络不给力", + "网络异常", + "加载失败", + "连接失败", + "服务异常", + "请检查网络", +) +_LOADING_MARKERS = ("加载中", "正在加载", "努力加载") +_HOME_NAVIGATION = frozenset({"首页", "聊天", "个人中心"}) +_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") +_VOLATILE_NUMBER = re.compile(r"\d+(?:\.\d+)?") + + +@dataclass(frozen=True) +class PddPageObservation: + """一次页面读取的分类和仅供当前任务比较的脱敏签名。""" + + kind: str + signature: str + pdd_hierarchy: bool + + +@dataclass(frozen=True) +class GoodsOpenDecision: + """商品深链观察器给调用方的下一步动作。""" + + action: str + attempt: int + + +def classify_pdd_page( + root: ET.Element, + current_package: str, +) -> PddPageObservation: + """使用包名和组合控件特征分类当前页面。""" + + nodes = list(root.iter("node")) + labels = [_node_label(node) for node in nodes] + labels = [label for label in labels if label] + combined = " ".join(labels) + pdd_node_count = sum( + 1 for node in nodes if node.get("package") == PDD_PACKAGE_NAME + ) + pdd_hierarchy = pdd_node_count >= 3 + is_pdd = current_package == PDD_PACKAGE_NAME or pdd_hierarchy + + if any(marker in combined for marker in _CAPTCHA_MARKERS): + kind = PAGE_CAPTCHA + elif any(marker in combined for marker in _LOGIN_MARKERS): + kind = PAGE_LOGIN_REQUIRED + elif any(marker in combined for marker in _RISK_MARKERS): + kind = PAGE_RISK_CONTROL + elif any(marker in combined for marker in _PAYMENT_MARKERS): + kind = PAGE_PAYMENT + elif any(marker in combined for marker in _FINAL_SUBMIT_MARKERS): + kind = PAGE_ORDER_CONFIRMATION + elif is_pdd and any(marker in combined for marker in _NETWORK_MARKERS): + kind = PAGE_NETWORK_ERROR + elif is_pdd and any(marker in combined for marker in _LOADING_MARKERS): + kind = PAGE_LOADING + elif is_pdd and any(marker in combined for marker in _READY_MARKERS): + kind = PAGE_GOODS + elif is_pdd and _is_loaded_home(nodes, labels): + kind = PAGE_HOME + elif not is_pdd: + kind = PAGE_EXTERNAL + else: + kind = PAGE_UNKNOWN + + return PddPageObservation( + kind=kind, + signature=_page_signature(nodes, kind), + pdd_hierarchy=pdd_hierarchy, + ) + + +class GoodsOpenTracker: + """确认商品深链已离开旧页,并控制首页场景只重开一次。""" + + def __init__( + self, + before_open: PddPageObservation | None, + opened_at: float, + *, + stable_home_reads: int = 3, + home_grace_seconds: float = 2.0, + ) -> None: + if stable_home_reads < 2: + raise ValueError("首页稳定读取次数至少为 2") + if home_grace_seconds < 0: + raise ValueError("首页跳转宽限时间不能为负数") + self._before_open = before_open + self._opened_at = float(opened_at) + self._stable_home_reads = stable_home_reads + self._home_grace_seconds = float(home_grace_seconds) + self._attempt = 1 + self._home_reads = 0 + self._stale_goods_seen = False + + @property + def attempt(self) -> int: + return self._attempt + + @property + def stale_goods_seen(self) -> bool: + return self._stale_goods_seen + + def reopened(self, opened_at: float) -> None: + """调用方重新打开同一 URL 后开始第二次、也是最后一次观察。""" + + if self._attempt != 1: + raise RuntimeError("商品链接最多只允许受控重开一次") + self._attempt = 2 + self._opened_at = float(opened_at) + self._home_reads = 0 + + def observe( + self, + observation: PddPageObservation, + observed_at: float, + ) -> GoodsOpenDecision: + """读取一个新页面分类,并返回等待、成功、重开或失败。""" + + if observation.kind == PAGE_GOODS: + self._home_reads = 0 + before = self._before_open + if ( + before is not None + and before.kind == PAGE_GOODS + and before.signature == observation.signature + ): + self._stale_goods_seen = True + return GoodsOpenDecision(ACTION_WAIT, self._attempt) + return GoodsOpenDecision(ACTION_READY, self._attempt) + + if observation.kind == PAGE_NETWORK_ERROR: + self._home_reads = 0 + return GoodsOpenDecision(ACTION_NETWORK_ERROR, self._attempt) + + if observation.kind != PAGE_HOME: + self._home_reads = 0 + return GoodsOpenDecision(ACTION_WAIT, self._attempt) + + self._home_reads += 1 + stable = self._home_reads >= self._stable_home_reads + grace_elapsed = ( + float(observed_at) - self._opened_at >= self._home_grace_seconds + ) + if not stable or not grace_elapsed: + return GoodsOpenDecision(ACTION_WAIT, self._attempt) + if self._attempt == 1: + return GoodsOpenDecision(ACTION_REOPEN, self._attempt) + return GoodsOpenDecision(ACTION_UNAVAILABLE, self._attempt) + + +def _is_loaded_home(nodes: list[ET.Element], labels: list[str]) -> bool: + exact_labels = set(labels) + if not _HOME_NAVIGATION.issubset(exact_labels): + return False + home_selected = any( + _node_label(node) == "首页" and node.get("selected") == "true" + for node in nodes + ) + has_clickable_home = any( + node.get("content-desc", "").strip() == "首页" + and node.get("clickable") == "true" + for node in nodes + ) + return home_selected and has_clickable_home + + +def _page_signature(nodes: list[ET.Element], kind: str) -> str: + """对页面上半部结构做哈希;不把原始文字写入日志或跨任务保存。""" + + screen_bottom = 0 + parsed_bounds: dict[int, tuple[int, int, int, int]] = {} + for node in nodes: + match = _BOUNDS_PATTERN.fullmatch(node.get("bounds", "")) + if match is None: + continue + bounds = tuple(map(int, match.groups())) + parsed_bounds[id(node)] = bounds + screen_bottom = max(screen_bottom, bounds[3]) + + parts = [kind] + for node in nodes: + label = _node_label(node) + if not label or node.get("visible-to-user") == "false": + continue + bounds = parsed_bounds.get(id(node)) + if bounds is not None and screen_bottom and bounds[1] > screen_bottom * 0.72: + continue + normalized = _VOLATILE_NUMBER.sub("#", " ".join(label.split())) + parts.append( + "|".join( + ( + node.get("class", ""), + normalized[:120], + node.get("selected", ""), + node.get("checked", ""), + ) + ) + ) + raw = "\n".join(parts).encode("utf-8", errors="replace") + return hashlib.sha256(raw).hexdigest()[:24] + + +def _node_label(node: ET.Element) -> str: + values = [] + for key in ("text", "content-desc"): + value = node.get(key, "").strip() + if value and value not in values: + values.append(value) + return " ".join(values) diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 1dcdd03..3aaf97e 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -21,6 +21,22 @@ from .pdd_device_service import ( current_thread_device_service, ) from .performance_timing import current_performance_trace +from .pdd_page_classifier import ( + ACTION_NETWORK_ERROR, + ACTION_READY, + ACTION_REOPEN, + ACTION_UNAVAILABLE, + PAGE_CAPTCHA, + PAGE_GOODS, + PAGE_HOME, + PAGE_LOGIN_REQUIRED, + PAGE_NETWORK_ERROR, + PAGE_PAYMENT, + PAGE_RISK_CONTROL, + GoodsOpenTracker, + PddPageObservation, + classify_pdd_page, +) from .pdd_purchase_adapter import ( PddLivePurchaseAdapter, PddPurchaseAdapter, @@ -103,22 +119,7 @@ def _goods_id_from_url(goods_url: str) -> str: def _page_kind(root: ET.Element, current_package: str) -> str: - combined = " ".join(_labels(root)) - if any(marker in combined for marker in _CAPTCHA_MARKERS): - return "captcha" - if any(marker in combined for marker in _LOGIN_MARKERS): - return "login_required" - if any(marker in combined for marker in _RISK_MARKERS): - return "risk_control" - if any(marker in combined for marker in _PAYMENT_MARKERS): - return "payment" - if any(marker in combined for marker in _FINAL_SUBMIT_MARKERS): - return "order_confirmation" - if current_package == PDD_PACKAGE_NAME and any( - marker in combined for marker in _READY_MARKERS - ): - return "goods" - return "unknown" + return classify_pdd_page(root, current_package).kind def _selected(root: ET.Element, target: str) -> bool: @@ -270,6 +271,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): self._session = self._device_service.connect(self._device_address) self._device = self._session.__enter__() current = self._session.initial_app_state + before_open = self._read_observation(current) trace = current_performance_trace() if current.get("package") != PDD_PACKAGE_NAME: stage = trace.stage("pdd_start_or_wait") if trace else nullcontext() @@ -287,7 +289,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): stage = trace.stage("open_url") if trace else nullcontext() with stage: self._device.open_url(goods_url) - self._wait_for_page("goods", self._page_timeout) + self._wait_for_goods_page(goods_url, before_open) except PddPurchaseError: raise except PddDeviceError as exc: @@ -445,12 +447,21 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): if session is not None: session.__exit__(None, None, None) - def _wait_for_page(self, expected: str, timeout: float) -> str: + def _wait_for_goods_page( + self, + goods_url: str, + before_open: Optional[PddPageObservation], + ) -> str: + """确认本次深链进入新商品页;稳定首页时只重开一次。""" + trace = current_performance_trace() ready_stage = trace.stage("goods_page_ready") if trace else nullcontext() with ready_stage: - deadline = self._monotonic() + timeout + opened_at = self._monotonic() + deadline = opened_at + self._page_timeout + tracker = GoodsOpenTracker(before_open, opened_at) last_kind = "unknown" + last_recorded = "" first_dump = True while self._monotonic() < deadline: self._check_cancelled("purchase_open_goods") @@ -463,26 +474,94 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): else: xml_data = self._dump_hierarchy() root = _parse_xml(xml_data) - last_kind = _page_kind(root, str(current.get("package") or "")) - if last_kind == expected: + observation = classify_pdd_page( + root, str(current.get("package") or "") + ) + last_kind = observation.kind + if trace is not None and last_kind != last_recorded: + trace.record( + "pdd_page_transition", + 0, + f"attempt_{tracker.attempt}_{last_kind}", + ) + last_recorded = last_kind + if last_kind in { + PAGE_CAPTCHA, + PAGE_LOGIN_REQUIRED, + PAGE_RISK_CONTROL, + PAGE_PAYMENT, + }: + self._raise_special_page(last_kind) + decision = tracker.observe(observation, self._monotonic()) + if decision.action == ACTION_READY: if trace is not None: trace.checkpoint("end_to_end_total") return xml_data - if last_kind in { - "captcha", - "login_required", - "risk_control", - "payment", - }: - self._raise_special_page(last_kind) + if decision.action == ACTION_REOPEN: + with ( + trace.stage("open_url_retry") + if trace is not None + else nullcontext() + ): + device.open_url(goods_url) + tracker.reopened(self._monotonic()) + last_recorded = "" + continue + if decision.action == ACTION_UNAVAILABLE: + raise PddPurchaseError( + "PDD_GOODS_UNAVAILABLE", + "商品链接已失效,PDD 无法打开商品详情页并返回了首页", + step="purchase_open_goods", + retryable=False, + diagnostics={ + "page_kind": PAGE_HOME, + "open_attempts": 2, + }, + ) + if decision.action == ACTION_NETWORK_ERROR: + raise PddPurchaseError( + "PDD_PAGE_NETWORK_ERROR", + "PDD 商品页网络或服务异常,请稍后重试", + step="purchase_open_goods", + retryable=True, + diagnostics={"page_kind": PAGE_NETWORK_ERROR}, + ) self._sleep(0.25) + if tracker.stale_goods_seen: + raise PddPurchaseError( + "PDD_GOODS_IDENTITY_UNCONFIRMED", + "打开商品链接后仍停留在原商品页,无法确认本次目标商品", + step="purchase_open_goods", + retryable=True, + diagnostics={ + "page_kind": PAGE_GOODS, + "open_attempts": tracker.attempt, + }, + ) raise PddPurchaseError( "PDD_PAGE_TIMEOUT", - f"等待 PDD {expected} 页面超时,最后页面为 {last_kind}", + f"等待 PDD 商品详情页超时,最后页面为 {last_kind}", step="purchase_open_goods", retryable=True, + diagnostics={ + "page_kind": last_kind, + "open_attempts": tracker.attempt, + }, ) + def _read_observation( + self, current: Mapping[str, Any] + ) -> Optional[PddPageObservation]: + """读取打开前页面;失败时不阻止后续按新页面重新判断。""" + + try: + root = _parse_xml(self._dump_hierarchy()) + return classify_pdd_page( + root, str(current.get("package") or "") + ) + except (PddPurchaseError, RuntimeError, OSError): + return None + def _wait_for_confirmation_panel(self) -> str: deadline = self._monotonic() + self._panel_timeout while self._monotonic() < deadline: diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index e5bb484..b99a461 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -1210,7 +1210,7 @@ class PDDTaskPageEvent(QObject): self._reload() if kind == "no_task": self._on_no_claimed_task() - elif kind == "succeeded": + elif kind in {"succeeded", "business_failed"}: self._retry_count = 0 self._continue_after( self._next_task_delay_ms, diff --git a/client/src/purchase_task_service.py b/client/src/purchase_task_service.py index 0bd91da..b713e0f 100644 --- a/client/src/purchase_task_service.py +++ b/client/src/purchase_task_service.py @@ -105,6 +105,7 @@ class PurchaseTaskService: ) adapter: PddPurchaseAdapter | None = None step = "purchase_prepare" + business_failure_message = "" try: target = self._target_from_task(started.task) adapter = self._factory(self._device_address, self._cancelled) @@ -189,6 +190,8 @@ class PurchaseTaskService: step, exc.diagnostics, ) + if exc.code == "PDD_GOODS_UNAVAILABLE": + business_failure_message = exc.message except Exception as exc: event = self._save_failure( remote_task_id, @@ -202,7 +205,12 @@ class PurchaseTaskService: finally: if adapter is not None: adapter.close() - return self._submit(event) + outcome = self._submit(event) + if business_failure_message and outcome.kind == "failed": + return PurchaseTaskOutcome( + "business_failed", business_failure_message, remote_task_id + ) + return outcome def _submit_live_once( self, @@ -483,6 +491,8 @@ class PurchaseTaskService: ) -> OutboxEventRecord: if code == "PURCHASE_CANCELLED": status = TaskStatus.CANCELLED + elif code == "PDD_GOODS_UNAVAILABLE": + status = TaskStatus.FAILED else: # 采购没有手动“重新执行”入口。即使错误属于 # 技术上可重试,也先留给人工判断,避免隐式重复采购。 diff --git a/client/test/fixtures/pdd_home_page.xml b/client/test/fixtures/pdd_home_page.xml new file mode 100644 index 0000000..e42a804 --- /dev/null +++ b/client/test/fixtures/pdd_home_page.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/client/test/test_collect_task_service.py b/client/test/test_collect_task_service.py index b74247a..107908c 100644 --- a/client/test/test_collect_task_service.py +++ b/client/test/test_collect_task_service.py @@ -164,6 +164,19 @@ class CollectTaskServiceTest(unittest.TestCase): ) self.assertEqual(self.gateway.submission_count, 1) + def test_unavailable_goods_is_terminal_but_allows_next_task(self): + message = "商品链接已失效,PDD 无法打开商品详情页并返回了首页" + outcome = self._service( + [], PddCollectError("PDD_GOODS_UNAVAILABLE", message) + ).execute_one() + + self.assertEqual("business_failed", outcome.kind) + self.assertEqual(message, outcome.message) + detail = self.repository.get_task("COL-001") + self.assertEqual(TaskStatus.FAILED, detail.status) + self.assertEqual("PDD_GOODS_UNAVAILABLE", detail.last_error_code) + self.assertEqual(1, self.gateway.submission_count) + def test_execute_selected_only_runs_requested_local_task(self): for task_id in ("COL-SELECTED", "COL-OTHER"): self.repository.add_claimed_task( diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index d22511b..b5e56cd 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -35,6 +35,7 @@ class FakeCollectDevice: self.swipes = [] self.swipe_panel_states = [] self.app_wait_calls = 0 + self.opened_url = None def app_current(self): return {"package": "com.xunmeng.pinduoduo"} @@ -50,6 +51,8 @@ class FakeCollectDevice: self.opened_url = url def dump_hierarchy(self): + if self.opened_url is None: + return '' return self.spec_xml if self.panel_open else self.home_xml def click(self, x, y): @@ -71,6 +74,8 @@ class GoodsMetadataScrollDevice(FakeCollectDevice): self.page_index = 0 def dump_hierarchy(self): + if self.opened_url is None and not self.panel_open: + return super().dump_hierarchy() if self.panel_open: return self.spec_xml return self.pages[self.page_index] @@ -133,6 +138,8 @@ class SnakeColorDevice(FakeCollectDevice): self.clicked_colors = [] def dump_hierarchy(self): + if self.opened_url is None and not self.panel_open: + return super().dump_hierarchy() if not self.panel_open: return self.home_xml return self._spec_xml() @@ -794,6 +801,28 @@ class PddCollectParserTest(unittest.TestCase): ) self.assertEqual(raised.exception.code, "PDD_PAGE_TIMEOUT") + def test_stable_home_reopens_once_then_stops_before_spec_click(self): + home = (FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8") + device = FakeCollectDevice(home, self.spec_xml) + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + page_timeout=10.0, + ) + + with self.assertRaises(PddCollectError) as raised: + service.collect( + FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123") + ) + + self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code) + self.assertEqual(2, raised.exception.diagnostics["open_attempts"]) + self.assertEqual([], device.clicks) + def test_non_pdd_tree_with_purchase_words_is_not_ready(self): device = FocusMismatchDevice( '' + '' + ).format(package, text) + return classify_pdd_page(ET.fromstring(xml), package) + + +class PddPageClassifierTest(unittest.TestCase): + def test_real_home_fixture_ignores_incorrect_focused_package(self) -> None: + root = ET.parse(FIXTURES / "pdd_home_page.xml").getroot() + + result = classify_pdd_page(root, "com.android.settings") + + self.assertEqual(PAGE_HOME, result.kind) + self.assertTrue(result.pdd_hierarchy) + + def test_special_pages_are_not_home(self) -> None: + cases = { + "加载中": PAGE_LOADING, + "网络不给力": PAGE_NETWORK_ERROR, + "手机号登录": PAGE_LOGIN_REQUIRED, + "请完成验证": PAGE_CAPTCHA, + "操作频繁": PAGE_RISK_CONTROL, + "立即购买": PAGE_GOODS, + } + for label, expected in cases.items(): + with self.subTest(label=label): + self.assertEqual(expected, observation(label).kind) + + def test_browser_word_does_not_count_as_goods(self) -> None: + result = observation("立即购买", "com.android.chrome") + self.assertEqual(PAGE_EXTERNAL, result.kind) + + def test_transient_home_can_reach_goods_without_reopen(self) -> None: + home = classify_pdd_page( + ET.parse(FIXTURES / "pdd_home_page.xml").getroot(), PDD + ) + tracker = GoodsOpenTracker(None, 0.0) + + self.assertEqual(ACTION_WAIT, tracker.observe(home, 2.0).action) + self.assertEqual(ACTION_WAIT, tracker.observe(home, 2.1).action) + self.assertEqual( + ACTION_READY, tracker.observe(observation("立即购买"), 2.2).action + ) + + def test_stable_home_reopens_once_then_marks_unavailable(self) -> None: + home = classify_pdd_page( + ET.parse(FIXTURES / "pdd_home_page.xml").getroot(), PDD + ) + tracker = GoodsOpenTracker(None, 0.0) + + for at in (2.0, 2.1): + self.assertEqual(ACTION_WAIT, tracker.observe(home, at).action) + self.assertEqual(ACTION_REOPEN, tracker.observe(home, 2.2).action) + tracker.reopened(3.0) + for at in (5.0, 5.1): + self.assertEqual(ACTION_WAIT, tracker.observe(home, at).action) + self.assertEqual(ACTION_UNAVAILABLE, tracker.observe(home, 5.2).action) + + def test_old_goods_tree_is_not_accepted_as_new_target(self) -> None: + old = observation("旧商品标题 立即购买") + tracker = GoodsOpenTracker(old, 0.0) + + self.assertEqual(ACTION_WAIT, tracker.observe(old, 0.2).action) + self.assertTrue(tracker.stale_goods_seen) + new = observation("目标商品标题 立即购买") + self.assertEqual(ACTION_READY, tracker.observe(new, 0.3).action) + + def test_network_error_has_own_decision(self) -> None: + tracker = GoodsOpenTracker(None, 0.0) + decision = tracker.observe(observation("网络不给力"), 3.0) + self.assertEqual(ACTION_NETWORK_ERROR, decision.action) + + +if __name__ == "__main__": + unittest.main() diff --git a/client/test/test_pdd_real_xml_fixtures.py b/client/test/test_pdd_real_xml_fixtures.py index 15d5e58..3a5227c 100644 --- a/client/test/test_pdd_real_xml_fixtures.py +++ b/client/test/test_pdd_real_xml_fixtures.py @@ -20,6 +20,7 @@ class SettingsFocusedRealPage: def __init__(self, xml_data): self.xml_data = xml_data + self.opened = False def app_current(self): return {"package": "com.oplus.wirelesssettings"} @@ -31,9 +32,11 @@ class SettingsFocusedRealPage: return 1 def open_url(self, _url): - return None + self.opened = True def dump_hierarchy(self): + if not self.opened: + return '' return self.xml_data diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index 31e3e62..5e919df 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -1,5 +1,6 @@ """uiautomator2 采购演练 Adapter 测试;不连接真实手机。""" +from pathlib import Path import unittest from src.pdd_device_service import PddDeviceService @@ -9,6 +10,18 @@ from src.pdd_u2_purchase_adapter import U2PddLivePurchaseAdapter GOODS_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=753136429979" +FIXTURES = Path(__file__).parent / "fixtures" + + +class FakeClock: + def __init__(self) -> None: + self.now = 0.0 + + def monotonic(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds def home_xml() -> str: @@ -60,6 +73,7 @@ class FakeDevice: self.clicks = [] self.opened_urls = [] self.app_wait_calls = 0 + self.has_opened = False def app_current(self): return {"package": "com.xunmeng.pinduoduo"} @@ -73,8 +87,11 @@ class FakeDevice: def open_url(self, url): self.opened_urls.append(url) + self.has_opened = True def dump_hierarchy(self): + if not self.has_opened: + return '' if self.mode == "special": return self.special_xml return home_xml() if self.mode == "home" else panel_xml(self.quantity) @@ -108,6 +125,13 @@ class ColdStartDevice(FakeDevice): self.app_started = True +class StaleGoodsDevice(FakeDevice): + """深链打开前后都停留在同一个旧商品页。""" + + def dump_hierarchy(self): + return home_xml() + + class U2PddPurchaseAdapterTest(unittest.TestCase): def _adapter(self, device, calls): def select_color_fn(_device, _xml, target, **_kwargs): @@ -207,6 +231,44 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(raised.exception.code, "PDD_PAGE_CAPTCHA") self.assertEqual(device.clicks, []) + + def test_stable_home_reopens_once_then_stops_before_click(self): + device = FakeDevice( + (FIXTURES / "pdd_home_page.xml").read_text(encoding="utf-8") + ) + clock = FakeClock() + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService(connector=lambda _serial: device), + sleeper=clock.sleep, + monotonic=clock.monotonic, + page_timeout=10.0, + ) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.open_goods(GOODS_URL) + + self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code) + self.assertFalse(raised.exception.retryable) + self.assertEqual(2, len(device.opened_urls)) + self.assertEqual([], device.clicks) + + def test_stale_goods_page_is_not_accepted_as_target(self): + device = StaleGoodsDevice() + clock = FakeClock() + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService(connector=lambda _serial: device), + sleeper=clock.sleep, + monotonic=clock.monotonic, + page_timeout=1.0, + ) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.open_goods(GOODS_URL) + + self.assertEqual("PDD_GOODS_IDENTITY_UNCONFIRMED", raised.exception.code) + self.assertEqual([], device.clicks) adapter.close() def test_invalid_non_pdd_url_is_rejected_before_connect(self): diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index f0102a7..0d5a019 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -1429,6 +1429,30 @@ class PDDTaskPageEventTest(unittest.TestCase): events.shutdown() page.deleteLater() + def test_unavailable_goods_continues_auto_fetch_after_current_task(self): + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, + self.repository, + claim_gateway=RecordingClaimGateway(None), + settings_repository=self._saved_settings(), + next_task_delay_ms=20, + ) + events._auto_fetch_running = True + + events._on_collect_outcome( + "business_failed", + "商品链接已失效,PDD 无法打开商品详情页并返回了首页", + "PDD-001", + ) + + self.assertEqual(20, events._cycle_next_delay_ms) + self.assertTrue(events._auto_fetch_running) + self.assertFalse(events._stop_requested) + self.assertIn("商品链接已失效", page.statusLabel.text()) + events.shutdown() + page.deleteLater() + def test_duplicate_task_is_normal_status(self): task = collect_admin_task() self.repository.add_claimed_task(admin_task_to_new_claimed_task(task)) diff --git a/client/test/test_purchase_task_service.py b/client/test/test_purchase_task_service.py index 3344b3a..1b502b4 100644 --- a/client/test/test_purchase_task_service.py +++ b/client/test/test_purchase_task_service.py @@ -108,6 +108,17 @@ class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter): ) +class UnavailableGoodsAdapter(RecordingDryRunAdapter): + def open_goods(self, goods_url: str) -> None: + self.calls.append(("open_goods", goods_url)) + raise PddPurchaseError( + "PDD_GOODS_UNAVAILABLE", + "商品链接已失效,PDD 无法打开商品详情页并返回了首页", + step="purchase_open_goods", + retryable=False, + ) + + class PurchaseTaskServiceTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -219,6 +230,18 @@ class PurchaseTaskServiceTest(unittest.TestCase): self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW) self.assertEqual(detail.last_error_code, "PURCHASE_PRICE_EXCEEDED") + def test_unavailable_goods_stops_before_specs_and_is_not_retryable(self): + self._prepare_task() + adapter = UnavailableGoodsAdapter() + + outcome = self._service(adapter).execute_one_local() + + self.assertEqual("business_failed", outcome.kind) + self.assertNotIn(("select_options", OPTIONS), adapter.calls) + detail = self.repository.get_task("PUR-001") + self.assertEqual(TaskStatus.FAILED, detail.status) + self.assertEqual("PDD_GOODS_UNAVAILABLE", detail.last_error_code) + def test_result_submit_timeout_does_not_run_adapter_twice(self): self._prepare_task() adapter = RecordingDryRunAdapter() diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 50647c8..2a7b6f0 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -115,6 +115,18 @@ Client 本地 live 授权不得通过调试参数、默认配置或界面误操 - uiautomator2 连接由单一工作线程独占,任务间清理临时状态。 - 所有循环具有超时、最大滑动次数和可取消检查。 +### 4.1 商品深链页面识别 + +采集和采购共用同一个页面分类器。分类同时参考当前包名和控件树中的 PDD 包名, +避免部分 Android 系统错误报告前台包名。首页必须同时出现已选中的“首页”以及 +“聊天”“个人中心”等底部导航特征,并且不能出现商品购买入口,不能只凭单个文字判断。 + +打开商品链接后若连续读取到稳定首页,Client 只重新打开同一链接一次;第二次仍为 +稳定首页时,以 `PDD_GOODS_UNAVAILABLE` 结束当前任务并上报失败,自动获取可以继续 +处理下一条任务。加载、网络异常、浏览器、登录、验证码、风控和未知页面不得归为 +商品失效。打开前后的商品页控件树签名相同则视为旧页面,不能据此执行规格选择或 +下单;采购进入不可逆阶段前仍须按商品编号等业务字段再次确认目标身份。 + > **运营提醒(不是代码规则):** Admin 超时重派可能导致同一任务被两台 Client 各下一单, > 产生重复的未付款订单。人工审核时取消多余订单即可。但未付款订单长期堆积可能触发平台风控, > 需要操作人员留意,不要放着不管。