fix: 识别失效商品链接返回首页 (#111)

This commit is contained in:
chengma
2026-08-10 18:20:34 +08:00
parent 3b659eba0a
commit 8c9dbfed44
15 changed files with 802 additions and 62 deletions
+11 -1
View File
@@ -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",
+104 -26
View File
@@ -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:
+255
View File
@@ -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)
+108 -29
View File
@@ -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:
+1 -1
View File
@@ -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,
+11 -1
View File
@@ -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:
# 采购没有手动“重新执行”入口。即使错误属于
# 技术上可重试,也先留给人工判断,避免隐式重复采购。
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
2026-08-10 从真实 PDD 首页控件树裁剪并脱敏。
只保留页面分类需要的包名、首页导航、选中状态和真实坐标;
已删除推荐商品、未读数量、账号及其他业务内容。
-->
<hierarchy rotation="0">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout"
bounds="[0,0][1080,2376]" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="推荐" selected="true" bounds="[42,292][144,361]"
visible-to-user="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout"
content-desc="首页" clickable="true" selected="false"
bounds="[0,2181][216,2328]" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
resource-id="com.xunmeng.pinduoduo:id/pdd" text="首页"
selected="true" bounds="[78,2281][138,2316]"
visible-to-user="true" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
resource-id="com.xunmeng.pinduoduo:id/pdd" text="聊天"
selected="false" bounds="[726,2281][786,2316]"
visible-to-user="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout"
content-desc="个人中心" clickable="true" selected="false"
bounds="[864,2181][1080,2328]" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
resource-id="com.xunmeng.pinduoduo:id/pdd" text="个人中心"
selected="false" bounds="[912,2281][1032,2316]"
visible-to-user="true" />
</node>
</node>
</hierarchy>
+13
View File
@@ -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(
+32 -3
View File
@@ -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 '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
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(
'<hierarchy><node package="com.android.settings" text="免拼购买" '
@@ -819,13 +848,13 @@ class PddCollectParserTest(unittest.TestCase):
def test_page_timeout_saves_last_xml_diagnostic(self):
with tempfile.TemporaryDirectory() as directory:
device = LoadingDevice(self.home_xml, self.spec_xml)
ticks = iter((0.0, 0.0, 0.0, 0.0, 0.0, 2.0))
clock = FakeClock()
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
monotonic=lambda: next(ticks),
sleeper=clock.sleep,
monotonic=clock.monotonic,
page_timeout=1.0,
artifact_directory=Path(directory),
)
+108
View File
@@ -0,0 +1,108 @@
"""PDD 页面分类与商品深链状态机测试。"""
from pathlib import Path
import unittest
import xml.etree.ElementTree as ET
from src.pdd_page_classifier import (
ACTION_NETWORK_ERROR,
ACTION_READY,
ACTION_REOPEN,
ACTION_UNAVAILABLE,
ACTION_WAIT,
PAGE_CAPTCHA,
PAGE_EXTERNAL,
PAGE_GOODS,
PAGE_HOME,
PAGE_LOADING,
PAGE_LOGIN_REQUIRED,
PAGE_NETWORK_ERROR,
PAGE_RISK_CONTROL,
GoodsOpenTracker,
classify_pdd_page,
)
FIXTURES = Path(__file__).parent / "fixtures"
PDD = "com.xunmeng.pinduoduo"
def observation(text: str, package: str = PDD):
xml = (
'<hierarchy><node package="{0}" class="android.widget.TextView" '
'text="{1}" bounds="[0,0][900,300]" visible-to-user="true"/>'
'<node package="{0}" text="占位一"/><node package="{0}" '
'text="占位二"/></hierarchy>'
).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()
+4 -1
View File
@@ -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 '<hierarchy><node package="com.oplus.wirelesssettings" /></hierarchy>'
return self.xml_data
@@ -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 '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
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):
+24
View File
@@ -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))
+23
View File
@@ -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()
+12
View File
@@ -115,6 +115,18 @@ Client 本地 live 授权不得通过调试参数、默认配置或界面误操
- uiautomator2 连接由单一工作线程独占,任务间清理临时状态。
- 所有循环具有超时、最大滑动次数和可取消检查。
### 4.1 商品深链页面识别
采集和采购共用同一个页面分类器。分类同时参考当前包名和控件树中的 PDD 包名,
避免部分 Android 系统错误报告前台包名。首页必须同时出现已选中的“首页”以及
“聊天”“个人中心”等底部导航特征,并且不能出现商品购买入口,不能只凭单个文字判断。
打开商品链接后若连续读取到稳定首页,Client 只重新打开同一链接一次;第二次仍为
稳定首页时,以 `PDD_GOODS_UNAVAILABLE` 结束当前任务并上报失败,自动获取可以继续
处理下一条任务。加载、网络异常、浏览器、登录、验证码、风控和未知页面不得归为
商品失效。打开前后的商品页控件树签名相同则视为旧页面,不能据此执行规格选择或
下单;采购进入不可逆阶段前仍须按商品编号等业务字段再次确认目标身份。
> **运营提醒(不是代码规则):** Admin 超时重派可能导致同一任务被两台 Client 各下一单,
> 产生重复的未付款订单。人工审核时取消多余订单即可。但未付款订单长期堆积可能触发平台风控,
> 需要操作人员留意,不要放着不管。