fix: 记录规格面板误超时诊断 (#115)

This commit is contained in:
chengma
2026-08-10 18:38:56 +08:00
parent 6530b11125
commit 24f4ad1ba9
2 changed files with 196 additions and 13 deletions
+95 -11
View File
@@ -6,14 +6,17 @@
from __future__ import annotations
import hashlib
import re
import time
import xml.etree.ElementTree as ET
from contextlib import nullcontext
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
from urllib.parse import parse_qs, urlparse
from .db import data_dir
from .pdd_device_service import (
PDD_PACKAGE_NAME,
PddDeviceError,
@@ -58,6 +61,19 @@ _PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
_DIAGNOSTIC_MARKERS = (
"增加数量",
"减少数量",
"提交订单",
"现在买",
"确认购买",
"颜色分类",
"尺码",
"安全验证",
"手机号登录",
"操作频繁",
"网络不给力",
)
def _parse_xml(xml_data: str | bytes) -> ET.Element:
@@ -83,6 +99,20 @@ def _labels(root: ET.Element) -> list[str]:
return [value for node in root.iter("node") if (value := _label(node))]
def _sanitize_diagnostic_xml(xml_data: str) -> str:
"""只保留页面判断所需语义,删除商品、账号和收货相关文字。"""
root = ET.fromstring(xml_data)
for node in root.iter("node"):
for key in ("text", "content-desc", "hint"):
value = node.get(key, "").strip()
if not value:
continue
markers = [marker for marker in _DIAGNOSTIC_MARKERS if marker in value]
node.set(key, " ".join(markers) if markers else "[已脱敏]")
return ET.tostring(root, encoding="unicode")
def _parse_bounds(value: str) -> Optional[Bounds]:
match = _BOUNDS_PATTERN.fullmatch((value or "").strip())
if not match:
@@ -248,6 +278,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
panel_timeout: float = 10.0,
select_color_fn: Callable[..., bool] = select_color,
select_size_fn: Callable[..., bool] = select_size,
artifact_directory: Optional[Path] = None,
) -> None:
self._device_address = str(device_address or "").strip()
self._device_service = device_service or PddDeviceService()
@@ -258,6 +289,9 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
self._panel_timeout = panel_timeout
self._select_color = select_color_fn
self._select_size = select_size_fn
self._artifact_directory = artifact_directory
self._last_xml: Optional[str] = None
self._hierarchy_reads = 0
self._session = None
self._device = None
self._goods_id = ""
@@ -266,6 +300,8 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
def open_goods(self, goods_url: str) -> None:
self._goods_id = _goods_id_from_url(goods_url)
self._last_xml = None
self._hierarchy_reads = 0
self._check_cancelled("purchase_open_goods")
try:
self._session = self._device_service.connect(self._device_address)
@@ -289,7 +325,12 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
stage = trace.stage("open_url") if trace else nullcontext()
with stage:
self._device.open_url(goods_url)
self._wait_for_goods_page(goods_url, before_open)
package_hint = (
PDD_PACKAGE_NAME
if current.get("package") == PDD_PACKAGE_NAME
else ""
)
self._wait_for_goods_page(goods_url, before_open, package_hint)
except PddPurchaseError:
raise
except PddDeviceError as exc:
@@ -451,6 +492,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
self,
goods_url: str,
before_open: Optional[PddPageObservation],
package_hint: str,
) -> str:
"""确认本次深链进入新商品页;稳定首页时只重开一次。"""
@@ -466,7 +508,6 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
while self._monotonic() < deadline:
self._check_cancelled("purchase_open_goods")
device = self._require_device()
current = device.app_current()
if first_dump and trace is not None:
with trace.stage("first_dump_hierarchy"):
xml_data = self._dump_hierarchy()
@@ -474,9 +515,9 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
else:
xml_data = self._dump_hierarchy()
root = _parse_xml(xml_data)
observation = classify_pdd_page(
root, str(current.get("package") or "")
)
# 不在轮询中调用 app_current()。某些设备会阻塞十秒并错误
# 报告设置页;最新控件树中的 PDD 包节点才是可靠依据。
observation = classify_pdd_page(root, package_hint)
last_kind = observation.kind
if trace is not None and last_kind != last_recorded:
trace.record(
@@ -564,33 +605,74 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
def _wait_for_confirmation_panel(self) -> str:
deadline = self._monotonic() + self._panel_timeout
last_kind = "unknown"
panel_hierarchy_reads = 0
while self._monotonic() < deadline:
self._check_cancelled("purchase_select_options")
xml_data = self._dump_hierarchy()
panel_hierarchy_reads += 1
root = _parse_xml(xml_data)
current = self._require_device().app_current()
kind = _page_kind(root, str(current.get("package") or ""))
if kind == "order_confirmation" and any(
last_kind = _page_kind(root, "")
if last_kind == "order_confirmation" and any(
"增加数量" in label for label in _labels(root)
):
return xml_data
if kind in {"captcha", "login_required", "risk_control", "payment"}:
self._raise_special_page(kind)
if last_kind in {
"captcha",
"login_required",
"risk_control",
"payment",
}:
self._raise_special_page(last_kind)
self._sleep(0.2)
diagnostics: dict[str, Any] = {
"page_kind": last_kind,
"hierarchy_reads": self._hierarchy_reads,
"panel_hierarchy_reads": panel_hierarchy_reads,
}
artifact = self._save_last_xml("purchase-panel-timeout")
if artifact is not None:
diagnostics["artifacts"] = [artifact]
raise PddPurchaseError(
"PURCHASE_PANEL_TIMEOUT",
"点击采购入口后没有到达可靠的提交前确认页",
step="purchase_select_options",
retryable=True,
diagnostics=diagnostics,
)
def _dump_hierarchy(self) -> str:
raw = self._require_device().dump_hierarchy()
return (
xml_data = (
raw.decode("utf-8", errors="replace")
if isinstance(raw, bytes)
else str(raw)
)
self._last_xml = xml_data
self._hierarchy_reads += 1
return xml_data
def _save_last_xml(self, label: str) -> Optional[Mapping[str, Any]]:
"""保存脱敏控件树;没有配置目录或写入失败时只返回空。"""
if self._artifact_directory is None or not self._last_xml:
return None
try:
sanitized = _sanitize_diagnostic_xml(self._last_xml)
digest = hashlib.sha256(sanitized.encode("utf-8")).hexdigest()
directory = self._artifact_directory / "purchase" / self._goods_id
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{label}-{digest[:12]}.xml"
if not path.exists():
path.write_text(sanitized, encoding="utf-8")
return {
"kind": "sanitized_accessibility_xml",
"path": str(path.resolve()),
"sha256": digest,
"hierarchy_reads": self._hierarchy_reads,
}
except (OSError, ET.ParseError):
return None
def _require_device(self) -> Any:
if self._device is None:
@@ -650,6 +732,7 @@ def create_u2_purchase_adapter(
device_address,
device_service=current_thread_device_service() or PddDeviceService(),
cancelled=cancelled,
artifact_directory=data_dir() / "artifacts",
)
@@ -704,4 +787,5 @@ def create_u2_live_purchase_adapter(
device_address,
device_service=current_thread_device_service() or PddDeviceService(),
cancelled=cancelled,
artifact_directory=data_dir() / "artifacts",
)