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:
# 采购没有手动“重新执行”入口。即使错误属于
# 技术上可重试,也先留给人工判断,避免隐式重复采购。