fix: 识别失效商品链接返回首页 (#111)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
# 采购没有手动“重新执行”入口。即使错误属于
|
||||
# 技术上可重试,也先留给人工判断,避免隐式重复采购。
|
||||
|
||||
Reference in New Issue
Block a user