2026-08-10 16:54:06 +08:00
|
|
|
|
"""uiautomator2 只读订单核对 Adapter。
|
|
|
|
|
|
|
|
|
|
|
|
只允许启动 PDD、切换到“个人中心/我的订单/待付款”、返回和滚动读取。
|
|
|
|
|
|
代码中没有提交订单、取消订单或付款入口。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import time
|
|
|
|
|
|
import xml.etree.ElementTree as ET
|
2026-08-10 19:58:57 +08:00
|
|
|
|
from collections import Counter
|
2026-08-10 16:54:06 +08:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
|
|
|
|
|
from typing import Any, Callable, Optional
|
|
|
|
|
|
|
2026-08-10 18:00:38 +08:00
|
|
|
|
from .pdd_device_service import (
|
|
|
|
|
|
PDD_PACKAGE_NAME,
|
|
|
|
|
|
PddDeviceService,
|
|
|
|
|
|
current_thread_device_service,
|
|
|
|
|
|
)
|
2026-08-10 16:54:06 +08:00
|
|
|
|
from .pdd_purchase_reconcile_adapter import (
|
|
|
|
|
|
PddPurchaseReconcileAdapter,
|
|
|
|
|
|
PurchaseOrderCandidate,
|
|
|
|
|
|
PurchaseReconcileQuery,
|
|
|
|
|
|
PurchaseReconcileScan,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Bounds = tuple[int, int, int, int]
|
|
|
|
|
|
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
|
|
|
|
|
_ORDER_NO_PATTERN = re.compile(
|
|
|
|
|
|
r"(?:订单编号|订单号)\s*[::]?\s*([A-Za-z0-9-]{6,64})"
|
|
|
|
|
|
)
|
|
|
|
|
|
_GOODS_ID_PATTERN = re.compile(
|
|
|
|
|
|
r"(?:goods_id=|商品编号\s*[::]?\s*)(\d{6,})",
|
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
|
)
|
|
|
|
|
|
_QUANTITY_PATTERN = re.compile(r"(?:共\s*(\d+)\s*件|[xX×]\s*(\d+))")
|
|
|
|
|
|
_TOTAL_PATTERN = re.compile(
|
|
|
|
|
|
r"(?:需付款|应付款|合计|实付款)\s*[::]?\s*[¥¥]?\s*(\d+(?:\.\d{1,2})?)"
|
|
|
|
|
|
)
|
|
|
|
|
|
_ORDER_TIME_PATTERN = re.compile(
|
|
|
|
|
|
r"(?:下单时间|创建时间)\s*[::]?\s*"
|
|
|
|
|
|
r"(20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s+\d{1,2}:\d{2}(?::\d{2})?)"
|
|
|
|
|
|
)
|
|
|
|
|
|
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
|
|
|
|
|
|
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
|
|
|
|
|
|
_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常")
|
|
|
|
|
|
_PAYMENT_ACTION_MARKERS = ("立即支付", "确认支付", "输入支付密码")
|
|
|
|
|
|
_UNPAID_MARKERS = ("待付款", "待支付")
|
|
|
|
|
|
_NON_UNPAID_MARKERS = ("已付款", "交易成功", "交易完成", "已取消", "退款")
|
|
|
|
|
|
_SAFE_NAVIGATION_LABELS = ("个人中心", "我的订单", "待付款")
|
2026-08-10 19:46:00 +08:00
|
|
|
|
_ORDER_DETAIL_PAY_LABELS = ("去支付",)
|
|
|
|
|
|
_ORDER_DETAIL_CANCEL_LABELS = ("取消订单",)
|
2026-08-10 16:54:06 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PddPurchaseReconcileError(RuntimeError):
|
|
|
|
|
|
"""只读核单无法安全继续。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
|
self.code = code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_xml(xml_data: str | bytes) -> ET.Element:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return ET.fromstring(xml_data)
|
|
|
|
|
|
except (ET.ParseError, TypeError) as exc:
|
|
|
|
|
|
raise PddPurchaseReconcileError(
|
|
|
|
|
|
"RECONCILE_XML_INVALID", "订单页无障碍控件树无效"
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _label(node: ET.Element) -> str:
|
|
|
|
|
|
return " ".join(
|
|
|
|
|
|
value.strip()
|
|
|
|
|
|
for value in (node.get("text", ""), node.get("content-desc", ""))
|
|
|
|
|
|
if value.strip()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _subtree_text(node: ET.Element) -> str:
|
|
|
|
|
|
return " ".join(
|
|
|
|
|
|
label for item in node.iter("node") if (label := _label(item))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:46:00 +08:00
|
|
|
|
def _package_hint_from_tree(root: ET.Element) -> str:
|
|
|
|
|
|
"""可靠控件树可以证明 PDD 在前台,避免错误的前台查询结果。"""
|
|
|
|
|
|
|
|
|
|
|
|
pdd_node_count = sum(
|
|
|
|
|
|
1
|
|
|
|
|
|
for node in root.iter("node")
|
|
|
|
|
|
if node.get("package") == PDD_PACKAGE_NAME
|
|
|
|
|
|
)
|
|
|
|
|
|
return PDD_PACKAGE_NAME if pdd_node_count >= 3 else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:58:57 +08:00
|
|
|
|
def _foreground_package_from_tree(root: ET.Element) -> str:
|
|
|
|
|
|
"""控件树只有一个明确业务包时返回它;系统界面节点不参与判断。"""
|
|
|
|
|
|
|
|
|
|
|
|
ignored_packages = {"", "com.android.systemui"}
|
|
|
|
|
|
counts = Counter(
|
|
|
|
|
|
node.get("package", "")
|
|
|
|
|
|
for node in root.iter("node")
|
|
|
|
|
|
if node.get("package", "") not in ignored_packages
|
|
|
|
|
|
)
|
|
|
|
|
|
if not counts:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
highest = max(counts.values())
|
|
|
|
|
|
packages = [
|
|
|
|
|
|
package
|
|
|
|
|
|
for package, count in counts.items()
|
|
|
|
|
|
if count == highest and count >= 3
|
|
|
|
|
|
]
|
|
|
|
|
|
return packages[0] if len(packages) == 1 else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:46:00 +08:00
|
|
|
|
def _label_count(root: ET.Element, targets: tuple[str, ...]) -> int:
|
|
|
|
|
|
return sum(
|
|
|
|
|
|
1
|
|
|
|
|
|
for node in root.iter("node")
|
|
|
|
|
|
if _label(node) in targets
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_single_order_detail(root: ET.Element) -> bool:
|
|
|
|
|
|
"""只在页面上只有一组订单操作时允许跨视口累积字段。"""
|
|
|
|
|
|
|
|
|
|
|
|
pay_count = _label_count(root, _ORDER_DETAIL_PAY_LABELS)
|
|
|
|
|
|
cancel_count = _label_count(root, _ORDER_DETAIL_CANCEL_LABELS)
|
|
|
|
|
|
order_numbers = {
|
|
|
|
|
|
match.group(1)
|
|
|
|
|
|
for node in root.iter("node")
|
|
|
|
|
|
if (match := _ORDER_NO_PATTERN.search(_label(node))) is not None
|
|
|
|
|
|
}
|
|
|
|
|
|
return pay_count == 1 and cancel_count <= 1 and len(order_numbers) <= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 16:54:06 +08:00
|
|
|
|
def _parse_bounds(value: str) -> Optional[Bounds]:
|
|
|
|
|
|
match = _BOUNDS_PATTERN.fullmatch(str(value or "").strip())
|
|
|
|
|
|
if match is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
left, top, right, bottom = map(int, match.groups())
|
|
|
|
|
|
if right <= left or bottom <= top:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return left, top, right, bottom
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_money_cent(text: str) -> int:
|
|
|
|
|
|
match = _TOTAL_PATTERN.search(text)
|
|
|
|
|
|
if match is None:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = Decimal(match.group(1)).quantize(
|
|
|
|
|
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
|
|
|
|
|
)
|
|
|
|
|
|
except InvalidOperation:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return int(value * 100)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_quantity(text: str) -> int:
|
|
|
|
|
|
match = _QUANTITY_PATTERN.search(text)
|
|
|
|
|
|
if match is None:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
value = match.group(1) or match.group(2) or "0"
|
|
|
|
|
|
return int(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_ordered_at(text: str) -> tuple[str, str]:
|
|
|
|
|
|
match = _ORDER_TIME_PATTERN.search(text)
|
|
|
|
|
|
if match is None:
|
|
|
|
|
|
return "", ""
|
|
|
|
|
|
raw = match.group(1)
|
|
|
|
|
|
normalized = (
|
|
|
|
|
|
raw.replace("年", "-")
|
|
|
|
|
|
.replace("月", "-")
|
|
|
|
|
|
.replace("日", "")
|
|
|
|
|
|
.replace("/", "-")
|
|
|
|
|
|
.replace(".", "-")
|
|
|
|
|
|
)
|
|
|
|
|
|
formats = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M")
|
|
|
|
|
|
for time_format in formats:
|
|
|
|
|
|
try:
|
|
|
|
|
|
local = datetime.strptime(normalized, time_format).replace(
|
|
|
|
|
|
tzinfo=timezone(timedelta(hours=8))
|
|
|
|
|
|
)
|
|
|
|
|
|
ordered_at = local.astimezone(timezone.utc).isoformat(
|
|
|
|
|
|
timespec="seconds"
|
|
|
|
|
|
).replace("+00:00", "Z")
|
|
|
|
|
|
return ordered_at, raw
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
return "", raw
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:46:00 +08:00
|
|
|
|
def _payment_status(text: str) -> str:
|
|
|
|
|
|
"""只在页面有明确状态文字时返回状态。"""
|
|
|
|
|
|
|
|
|
|
|
|
if any(marker in text for marker in _NON_UNPAID_MARKERS):
|
|
|
|
|
|
return "other"
|
|
|
|
|
|
if any(marker in text for marker in _UNPAID_MARKERS):
|
|
|
|
|
|
return "unpaid"
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 16:54:06 +08:00
|
|
|
|
def parse_order_candidates(
|
|
|
|
|
|
xml_data: str | bytes, query: PurchaseReconcileQuery
|
|
|
|
|
|
) -> tuple[PurchaseOrderCandidate, ...]:
|
|
|
|
|
|
"""从脱敏控件树提取候选,只保留核单必需字段。"""
|
|
|
|
|
|
|
|
|
|
|
|
root = _parse_xml(xml_data)
|
|
|
|
|
|
parents = {
|
|
|
|
|
|
child: parent
|
|
|
|
|
|
for parent in root.iter("node")
|
|
|
|
|
|
for child in parent
|
|
|
|
|
|
if child.tag == "node"
|
|
|
|
|
|
}
|
|
|
|
|
|
parsed: dict[str, PurchaseOrderCandidate] = {}
|
|
|
|
|
|
for node in root.iter("node"):
|
|
|
|
|
|
node_label = _label(node)
|
|
|
|
|
|
order_match = _ORDER_NO_PATTERN.search(node_label)
|
|
|
|
|
|
if order_match is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
order_no = order_match.group(1)
|
|
|
|
|
|
current = node
|
|
|
|
|
|
card_text = node_label
|
|
|
|
|
|
for _ in range(8):
|
|
|
|
|
|
text = _subtree_text(current)
|
|
|
|
|
|
if any(marker in text for marker in _UNPAID_MARKERS):
|
|
|
|
|
|
card_text = text
|
|
|
|
|
|
if (
|
|
|
|
|
|
_TOTAL_PATTERN.search(text)
|
|
|
|
|
|
and _QUANTITY_PATTERN.search(text)
|
|
|
|
|
|
and _ORDER_TIME_PATTERN.search(text)
|
|
|
|
|
|
):
|
|
|
|
|
|
break
|
|
|
|
|
|
parent = parents.get(current)
|
|
|
|
|
|
if parent is None:
|
|
|
|
|
|
break
|
|
|
|
|
|
current = parent
|
|
|
|
|
|
|
|
|
|
|
|
goods_match = _GOODS_ID_PATTERN.search(card_text)
|
|
|
|
|
|
ordered_at, ordered_at_raw = _parse_ordered_at(card_text)
|
|
|
|
|
|
options = {
|
|
|
|
|
|
key: value
|
|
|
|
|
|
for key, value in query.options.items()
|
|
|
|
|
|
if str(value).strip() and str(value).strip() in card_text
|
|
|
|
|
|
}
|
|
|
|
|
|
candidate = PurchaseOrderCandidate(
|
|
|
|
|
|
order_no=order_no,
|
|
|
|
|
|
goods_id=goods_match.group(1) if goods_match else "",
|
|
|
|
|
|
options=options,
|
|
|
|
|
|
quantity=_parse_quantity(card_text),
|
|
|
|
|
|
total_price_cent=_parse_money_cent(card_text),
|
|
|
|
|
|
ordered_at=ordered_at,
|
|
|
|
|
|
ordered_at_raw=ordered_at_raw,
|
2026-08-10 19:46:00 +08:00
|
|
|
|
payment_status=_payment_status(card_text),
|
2026-08-10 16:54:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
previous = parsed.get(order_no)
|
|
|
|
|
|
if previous is None or previous == candidate:
|
|
|
|
|
|
parsed[order_no] = candidate
|
|
|
|
|
|
return tuple(parsed.values())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:46:00 +08:00
|
|
|
|
def _parse_order_detail_evidence(
|
|
|
|
|
|
root: ET.Element, query: PurchaseReconcileQuery
|
|
|
|
|
|
) -> PurchaseOrderCandidate:
|
|
|
|
|
|
"""读取单个订单详情当前视口;字段可以暂时不完整。"""
|
|
|
|
|
|
|
|
|
|
|
|
text = _subtree_text(root)
|
|
|
|
|
|
order_numbers = tuple(dict.fromkeys(_ORDER_NO_PATTERN.findall(text)))
|
|
|
|
|
|
goods_match = _GOODS_ID_PATTERN.search(text)
|
|
|
|
|
|
ordered_at, ordered_at_raw = _parse_ordered_at(text)
|
|
|
|
|
|
options = {
|
|
|
|
|
|
key: value
|
|
|
|
|
|
for key, value in query.options.items()
|
|
|
|
|
|
if str(value).strip() and str(value).strip() in text
|
|
|
|
|
|
}
|
|
|
|
|
|
return PurchaseOrderCandidate(
|
|
|
|
|
|
order_no=order_numbers[0] if len(order_numbers) == 1 else "",
|
|
|
|
|
|
goods_id=goods_match.group(1) if goods_match else "",
|
|
|
|
|
|
options=options,
|
|
|
|
|
|
quantity=_parse_quantity(text),
|
|
|
|
|
|
total_price_cent=_parse_money_cent(text),
|
|
|
|
|
|
ordered_at=ordered_at,
|
|
|
|
|
|
ordered_at_raw=ordered_at_raw,
|
|
|
|
|
|
payment_status=_payment_status(text),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _merge_order_evidence(
|
|
|
|
|
|
previous: PurchaseOrderCandidate,
|
|
|
|
|
|
current: PurchaseOrderCandidate,
|
|
|
|
|
|
) -> Optional[PurchaseOrderCandidate]:
|
|
|
|
|
|
"""合并同一订单的相邻视口;关键字段冲突时拒绝合并。"""
|
|
|
|
|
|
|
|
|
|
|
|
scalar_fields = (
|
|
|
|
|
|
"order_no",
|
|
|
|
|
|
"goods_id",
|
|
|
|
|
|
"quantity",
|
|
|
|
|
|
"total_price_cent",
|
|
|
|
|
|
"ordered_at",
|
|
|
|
|
|
"ordered_at_raw",
|
|
|
|
|
|
)
|
|
|
|
|
|
values: dict[str, object] = {}
|
|
|
|
|
|
for field_name in scalar_fields:
|
|
|
|
|
|
old_value = getattr(previous, field_name)
|
|
|
|
|
|
new_value = getattr(current, field_name)
|
|
|
|
|
|
if old_value and new_value and old_value != new_value:
|
|
|
|
|
|
return None
|
|
|
|
|
|
values[field_name] = old_value or new_value
|
|
|
|
|
|
|
|
|
|
|
|
options = dict(previous.options)
|
|
|
|
|
|
for key, value in current.options.items():
|
|
|
|
|
|
if key in options and options[key] != value:
|
|
|
|
|
|
return None
|
|
|
|
|
|
options[key] = value
|
|
|
|
|
|
|
|
|
|
|
|
statuses = {
|
|
|
|
|
|
value
|
|
|
|
|
|
for value in (previous.payment_status, current.payment_status)
|
|
|
|
|
|
if value
|
|
|
|
|
|
}
|
|
|
|
|
|
if "other" in statuses:
|
|
|
|
|
|
payment_status = "other"
|
|
|
|
|
|
else:
|
|
|
|
|
|
payment_status = "unpaid" if "unpaid" in statuses else ""
|
|
|
|
|
|
return PurchaseOrderCandidate(
|
|
|
|
|
|
order_no=str(values["order_no"]),
|
|
|
|
|
|
goods_id=str(values["goods_id"]),
|
|
|
|
|
|
options=options,
|
|
|
|
|
|
quantity=int(values["quantity"]),
|
|
|
|
|
|
total_price_cent=int(values["total_price_cent"]),
|
|
|
|
|
|
ordered_at=str(values["ordered_at"]),
|
|
|
|
|
|
ordered_at_raw=str(values["ordered_at_raw"]),
|
|
|
|
|
|
payment_status=payment_status,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 16:54:06 +08:00
|
|
|
|
class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
|
|
|
|
|
|
"""只导航和滚动读取待付款订单,不包含任何订单写操作。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
device_address: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
device_service: Optional[PddDeviceService] = None,
|
|
|
|
|
|
cancelled: Callable[[], bool] = lambda: False,
|
|
|
|
|
|
settle_seconds: float = 0.4,
|
2026-08-10 19:58:57 +08:00
|
|
|
|
transition_timeout_seconds: float = 30.0,
|
2026-08-10 16:54:06 +08:00
|
|
|
|
max_pages: int = 5,
|
2026-08-10 19:58:57 +08:00
|
|
|
|
monotonic: Callable[[], float] = time.monotonic,
|
|
|
|
|
|
sleep: Callable[[float], None] = time.sleep,
|
2026-08-10 16:54:06 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
self._device_address = str(device_address or "").strip()
|
2026-08-10 18:00:38 +08:00
|
|
|
|
self._device_service = (
|
|
|
|
|
|
device_service
|
|
|
|
|
|
or current_thread_device_service()
|
|
|
|
|
|
or PddDeviceService()
|
|
|
|
|
|
)
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._cancelled = cancelled
|
|
|
|
|
|
self._settle_seconds = max(0.0, float(settle_seconds))
|
2026-08-10 19:58:57 +08:00
|
|
|
|
self._transition_timeout_seconds = max(
|
|
|
|
|
|
0.0, float(transition_timeout_seconds)
|
|
|
|
|
|
)
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._max_pages = max(1, int(max_pages))
|
2026-08-10 19:58:57 +08:00
|
|
|
|
self._monotonic = monotonic
|
|
|
|
|
|
self._sleep = sleep
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._session = None
|
|
|
|
|
|
self._device = None
|
|
|
|
|
|
|
|
|
|
|
|
def read_order_candidates(
|
|
|
|
|
|
self, query: PurchaseReconcileQuery
|
|
|
|
|
|
) -> PurchaseReconcileScan:
|
|
|
|
|
|
self._check_cancelled()
|
|
|
|
|
|
device = self._connect()
|
|
|
|
|
|
self._open_unpaid_orders(device)
|
|
|
|
|
|
found: dict[str, PurchaseOrderCandidate] = {}
|
2026-08-10 19:46:00 +08:00
|
|
|
|
detail_evidence: Optional[PurchaseOrderCandidate] = None
|
|
|
|
|
|
detail_context_valid = True
|
|
|
|
|
|
previous_xml = ""
|
2026-08-10 16:54:06 +08:00
|
|
|
|
pages_scanned = 0
|
|
|
|
|
|
for _ in range(self._max_pages):
|
|
|
|
|
|
self._check_cancelled()
|
|
|
|
|
|
xml_data = device.dump_hierarchy(compressed=False)
|
2026-08-10 19:46:00 +08:00
|
|
|
|
root = _parse_xml(xml_data)
|
|
|
|
|
|
self._raise_for_special_page(root)
|
2026-08-10 16:54:06 +08:00
|
|
|
|
pages_scanned += 1
|
|
|
|
|
|
for candidate in parse_order_candidates(xml_data, query):
|
2026-08-10 19:46:00 +08:00
|
|
|
|
previous = found.get(candidate.order_no)
|
|
|
|
|
|
merged = (
|
|
|
|
|
|
_merge_order_evidence(previous, candidate)
|
|
|
|
|
|
if previous is not None
|
|
|
|
|
|
else candidate
|
|
|
|
|
|
)
|
|
|
|
|
|
if merged is not None:
|
|
|
|
|
|
found[candidate.order_no] = merged
|
|
|
|
|
|
|
|
|
|
|
|
if detail_context_valid and _is_single_order_detail(root):
|
|
|
|
|
|
current_evidence = _parse_order_detail_evidence(root, query)
|
|
|
|
|
|
if detail_evidence is None:
|
|
|
|
|
|
detail_evidence = current_evidence
|
|
|
|
|
|
else:
|
|
|
|
|
|
merged = _merge_order_evidence(
|
|
|
|
|
|
detail_evidence, current_evidence
|
|
|
|
|
|
)
|
|
|
|
|
|
if merged is None:
|
|
|
|
|
|
detail_context_valid = False
|
|
|
|
|
|
detail_evidence = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
detail_evidence = merged
|
|
|
|
|
|
else:
|
|
|
|
|
|
detail_context_valid = False
|
|
|
|
|
|
detail_evidence = None
|
|
|
|
|
|
|
|
|
|
|
|
current_xml = str(xml_data)
|
|
|
|
|
|
if current_xml == previous_xml:
|
2026-08-10 16:54:06 +08:00
|
|
|
|
break
|
2026-08-10 19:46:00 +08:00
|
|
|
|
previous_xml = current_xml
|
2026-08-10 16:54:06 +08:00
|
|
|
|
width, height = device.window_size()
|
|
|
|
|
|
device.swipe(
|
|
|
|
|
|
width // 2,
|
|
|
|
|
|
int(height * 0.78),
|
|
|
|
|
|
width // 2,
|
|
|
|
|
|
int(height * 0.32),
|
|
|
|
|
|
0.35,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._settle()
|
2026-08-10 19:46:00 +08:00
|
|
|
|
if detail_context_valid and detail_evidence and detail_evidence.order_no:
|
|
|
|
|
|
previous = found.get(detail_evidence.order_no)
|
|
|
|
|
|
merged = (
|
|
|
|
|
|
_merge_order_evidence(previous, detail_evidence)
|
|
|
|
|
|
if previous is not None
|
|
|
|
|
|
else detail_evidence
|
|
|
|
|
|
)
|
|
|
|
|
|
if merged is not None:
|
|
|
|
|
|
found[detail_evidence.order_no] = merged
|
2026-08-10 16:54:06 +08:00
|
|
|
|
return PurchaseReconcileScan(
|
|
|
|
|
|
candidates=tuple(found.values()),
|
|
|
|
|
|
diagnostics={
|
|
|
|
|
|
"pages_scanned": pages_scanned,
|
|
|
|
|
|
"candidate_count": len(found),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
|
session = self._session
|
|
|
|
|
|
self._session = None
|
|
|
|
|
|
self._device = None
|
|
|
|
|
|
if session is not None:
|
|
|
|
|
|
session.__exit__(None, None, None)
|
|
|
|
|
|
|
|
|
|
|
|
def _connect(self) -> Any:
|
|
|
|
|
|
if self._device is None:
|
|
|
|
|
|
self._session = self._device_service.connect(self._device_address)
|
|
|
|
|
|
self._device = self._session.__enter__()
|
|
|
|
|
|
return self._device
|
|
|
|
|
|
|
|
|
|
|
|
def _open_unpaid_orders(self, device: Any) -> None:
|
2026-08-10 19:58:57 +08:00
|
|
|
|
deadline = self._monotonic() + self._transition_timeout_seconds
|
2026-08-10 16:54:06 +08:00
|
|
|
|
opened_order_area = False
|
2026-08-10 19:58:57 +08:00
|
|
|
|
checked_foreground = False
|
|
|
|
|
|
started_pdd = False
|
|
|
|
|
|
pressed_back = False
|
|
|
|
|
|
clicked_navigation: set[str] = set()
|
|
|
|
|
|
last_reason = "等待 PDD 待付款页稳定"
|
|
|
|
|
|
while self._monotonic() <= deadline:
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._check_cancelled()
|
|
|
|
|
|
root = _parse_xml(device.dump_hierarchy(compressed=False))
|
2026-08-10 19:58:57 +08:00
|
|
|
|
if self._monotonic() > deadline:
|
|
|
|
|
|
last_reason = "读取手机控件树超时"
|
|
|
|
|
|
break
|
2026-08-10 19:46:00 +08:00
|
|
|
|
if not _package_hint_from_tree(root):
|
2026-08-10 19:58:57 +08:00
|
|
|
|
if not checked_foreground:
|
|
|
|
|
|
current_package = _foreground_package_from_tree(root)
|
|
|
|
|
|
if not current_package:
|
|
|
|
|
|
current = device.app_current()
|
|
|
|
|
|
current_package = str(current.get("package") or "")
|
|
|
|
|
|
if self._monotonic() > deadline:
|
|
|
|
|
|
last_reason = "查询手机前台应用超时"
|
|
|
|
|
|
break
|
|
|
|
|
|
checked_foreground = True
|
|
|
|
|
|
if current_package != PDD_PACKAGE_NAME:
|
|
|
|
|
|
device.app_start(PDD_PACKAGE_NAME)
|
|
|
|
|
|
started_pdd = True
|
|
|
|
|
|
last_reason = "已返回 PDD,等待待付款页加载"
|
|
|
|
|
|
elif started_pdd:
|
|
|
|
|
|
last_reason = "PDD 正在从微信中间页恢复"
|
|
|
|
|
|
else:
|
|
|
|
|
|
last_reason = "PDD 控件树正在加载"
|
|
|
|
|
|
self._settle()
|
|
|
|
|
|
continue
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._raise_for_special_page(root)
|
|
|
|
|
|
combined = _subtree_text(root)
|
2026-08-10 19:46:00 +08:00
|
|
|
|
if _is_single_order_detail(root) or (
|
|
|
|
|
|
opened_order_area
|
|
|
|
|
|
and (
|
|
|
|
|
|
_ORDER_NO_PATTERN.search(combined)
|
|
|
|
|
|
or "暂无订单" in combined
|
|
|
|
|
|
or "暂无相关订单" in combined
|
|
|
|
|
|
)
|
2026-08-10 16:54:06 +08:00
|
|
|
|
):
|
|
|
|
|
|
return
|
|
|
|
|
|
if any(marker in combined for marker in _PAYMENT_ACTION_MARKERS):
|
2026-08-10 19:58:57 +08:00
|
|
|
|
if not pressed_back:
|
|
|
|
|
|
device.press("back")
|
|
|
|
|
|
pressed_back = True
|
|
|
|
|
|
last_reason = "已只读返回,等待待付款页稳定"
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._settle()
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "我的订单" in combined:
|
2026-08-10 19:58:57 +08:00
|
|
|
|
if (
|
|
|
|
|
|
"待付款" not in clicked_navigation
|
|
|
|
|
|
and self._click_unique_label(device, root, "待付款")
|
|
|
|
|
|
):
|
|
|
|
|
|
clicked_navigation.add("待付款")
|
2026-08-10 16:54:06 +08:00
|
|
|
|
opened_order_area = True
|
2026-08-10 19:58:57 +08:00
|
|
|
|
last_reason = "已打开待付款,等待订单列表加载"
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._settle()
|
|
|
|
|
|
continue
|
2026-08-10 19:58:57 +08:00
|
|
|
|
if (
|
|
|
|
|
|
"我的订单" not in clicked_navigation
|
|
|
|
|
|
and self._click_unique_label(device, root, "我的订单")
|
|
|
|
|
|
):
|
|
|
|
|
|
clicked_navigation.add("我的订单")
|
2026-08-10 16:54:06 +08:00
|
|
|
|
opened_order_area = True
|
2026-08-10 19:58:57 +08:00
|
|
|
|
last_reason = "已打开我的订单,等待待付款入口加载"
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._settle()
|
|
|
|
|
|
continue
|
2026-08-10 19:58:57 +08:00
|
|
|
|
if (
|
|
|
|
|
|
"个人中心" not in clicked_navigation
|
|
|
|
|
|
and self._click_unique_label(device, root, "个人中心")
|
|
|
|
|
|
):
|
|
|
|
|
|
clicked_navigation.add("个人中心")
|
|
|
|
|
|
last_reason = "已打开个人中心,等待我的订单入口加载"
|
2026-08-10 16:54:06 +08:00
|
|
|
|
self._settle()
|
|
|
|
|
|
continue
|
2026-08-10 19:58:57 +08:00
|
|
|
|
last_reason = "PDD 页面仍在过渡,尚未出现唯一只读订单入口"
|
|
|
|
|
|
self._settle()
|
2026-08-10 16:54:06 +08:00
|
|
|
|
raise PddPurchaseReconcileError(
|
2026-08-10 19:58:57 +08:00
|
|
|
|
"RECONCILE_ORDER_PAGE_TIMEOUT",
|
|
|
|
|
|
f"等待 PDD 待付款页稳定超时:{last_reason}",
|
2026-08-10 16:54:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _click_unique_label(
|
|
|
|
|
|
device: Any, root: ET.Element, target: str
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
if target not in _SAFE_NAVIGATION_LABELS:
|
|
|
|
|
|
raise ValueError("只允许使用白名单只读导航入口")
|
|
|
|
|
|
targets = []
|
|
|
|
|
|
for node in root.iter("node"):
|
|
|
|
|
|
labels = {
|
|
|
|
|
|
node.get("text", "").strip(),
|
|
|
|
|
|
node.get("content-desc", "").strip(),
|
|
|
|
|
|
}
|
|
|
|
|
|
if target not in labels:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if (
|
|
|
|
|
|
node.get("visible-to-user") != "true"
|
|
|
|
|
|
or node.get("enabled") != "true"
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
bounds = _parse_bounds(node.get("bounds", ""))
|
|
|
|
|
|
if bounds is not None:
|
|
|
|
|
|
targets.append(bounds)
|
|
|
|
|
|
unique = list(dict.fromkeys(targets))
|
|
|
|
|
|
if len(unique) != 1:
|
|
|
|
|
|
return False
|
|
|
|
|
|
left, top, right, bottom = unique[0]
|
|
|
|
|
|
device.click((left + right) // 2, (top + bottom) // 2)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _raise_for_special_page(root: ET.Element) -> None:
|
|
|
|
|
|
combined = _subtree_text(root)
|
|
|
|
|
|
markers = (
|
|
|
|
|
|
("RECONCILE_CAPTCHA", _CAPTCHA_MARKERS),
|
|
|
|
|
|
("RECONCILE_LOGIN_REQUIRED", _LOGIN_MARKERS),
|
|
|
|
|
|
("RECONCILE_RISK_CONTROL", _RISK_MARKERS),
|
|
|
|
|
|
)
|
|
|
|
|
|
for code, values in markers:
|
|
|
|
|
|
if any(value in combined for value in values):
|
|
|
|
|
|
raise PddPurchaseReconcileError(
|
|
|
|
|
|
code, "PDD 核单遇到登录、验证或风控页面,请人工处理"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _check_cancelled(self) -> None:
|
|
|
|
|
|
if self._cancelled():
|
|
|
|
|
|
raise PddPurchaseReconcileError(
|
|
|
|
|
|
"RECONCILE_CANCELLED", "用户已停止只读订单核对"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _settle(self) -> None:
|
|
|
|
|
|
if self._settle_seconds:
|
2026-08-10 19:58:57 +08:00
|
|
|
|
self._sleep(self._settle_seconds)
|
2026-08-10 16:54:06 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_u2_purchase_reconcile_adapter(
|
|
|
|
|
|
device_address: str, cancelled: Callable[[], bool]
|
|
|
|
|
|
) -> PddPurchaseReconcileAdapter:
|
|
|
|
|
|
"""为正式 Client 创建只读订单核对会话。"""
|
|
|
|
|
|
|
|
|
|
|
|
return U2PddPurchaseReconcileAdapter(
|
|
|
|
|
|
device_address,
|
2026-08-10 18:00:38 +08:00
|
|
|
|
device_service=current_thread_device_service() or PddDeviceService(),
|
2026-08-10 16:54:06 +08:00
|
|
|
|
cancelled=cancelled,
|
|
|
|
|
|
)
|