Files
cmautobuy/client/src/pdd_collect_service.py
T

1380 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDD 商品采集基础服务。
本模块不依赖 Qt、SQLite 或 Admin。页面操作和 XML 解析拆开,解析函数可以
使用脱敏控件树单独测试;``collect`` 必须由后台工作线程调用。
"""
from __future__ import annotations
import hashlib
import re
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, Optional, Sequence
from urllib.parse import parse_qs, urlparse
from .pdd_device_service import PDD_PACKAGE_NAME, PddDeviceError, PddDeviceService
from .util.get_size_panle_coord import get_size_panel_coord
Bounds = tuple[int, int, int, int]
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
_PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)")
_QUANTITY_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*(万|亿)?\s*(\+)?")
_DIMENSION_NAMES = ("颜色分类", "颜色", "尺码", "尺寸", "规格", "型号", "款式")
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
_SHOP_NAME_EXCLUDES = frozenset({"进店", "关注", "店铺", "收藏", "客服"})
_SHOP_ROW_TOLERANCE = 40
def _is_device_disconnect(error: BaseException) -> bool:
details = str(error).lower()
return any(
marker in details
for marker in (
"device not found",
"device offline",
"disconnected",
"closed transport",
)
)
class PddCollectError(RuntimeError):
"""采集失败,并携带稳定错误码。"""
def __init__(
self,
code: str,
message: str,
diagnostics: Optional[Mapping[str, Any]] = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.diagnostics = dict(diagnostics or {})
@dataclass(frozen=True)
class QuantityMetric:
"""销量或评价数量,保留原文和是否为近似值。"""
value: Optional[int]
raw: Optional[str]
approximate: bool
def to_dict(self) -> dict[str, Any]:
return {
"value": self.value,
"raw": self.raw,
"approximate": self.approximate,
}
@dataclass(frozen=True)
class DimensionValue:
text: str
available: bool
def to_dict(self) -> dict[str, Any]:
return {"text": self.text, "available": self.available}
@dataclass(frozen=True)
class SpecDimension:
key: str
name: str
values: tuple[DimensionValue, ...]
def to_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"name": self.name,
"values": [value.to_dict() for value in self.values],
}
@dataclass(frozen=True)
class SkuResult:
options: Mapping[str, str]
price_cent: Optional[int]
available: bool
raw_price: Optional[str]
price_observed_at: Mapping[str, str]
list_price_cent: Optional[int] = None
def to_dict(self) -> dict[str, Any]:
return {
"options": dict(self.options),
"price_cent": self.price_cent,
"list_price_cent": self.list_price_cent,
"price_observed_at": dict(self.price_observed_at),
"currency": "CNY",
"available": self.available,
"raw_price": self.raw_price,
}
@dataclass(frozen=True)
class GoodsSnapshot:
title: Optional[str]
shop_name: Optional[str]
sales: QuantityMetric
reviews: QuantityMetric
@dataclass(frozen=True)
class SpecSnapshot:
dimensions: tuple[SpecDimension, ...]
selected_text: Optional[str]
price_cent: Optional[int]
raw_price: Optional[str]
list_price_cent: Optional[int]
@dataclass(frozen=True)
class VisibleSpecOption:
"""当前控件树中完整可点击的规格节点。"""
text: str
available: bool
bounds: Bounds
@dataclass(frozen=True)
class ColorPriceSample:
"""点击一个颜色后稳定读取到的颜色级价格。"""
price_cent: Optional[int]
raw_price: Optional[str]
list_price_cent: Optional[int]
@dataclass(frozen=True)
class CollectResult:
"""与 ``pdd_data`` v1 对应的采集结果。"""
goods_id: str
goods_url: str
title: str
shop_name: Optional[str]
sales: QuantityMetric
reviews: QuantityMetric
dimensions: tuple[SpecDimension, ...]
skus: tuple[SkuResult, ...]
captured_at: str
client_id: str
device_address: str
artifacts: tuple[Mapping[str, Any], ...] = ()
def to_pdd_data(self) -> dict[str, Any]:
return {
"schema_version": 1,
"goods_id": self.goods_id,
"goods_url": self.goods_url,
"title": self.title,
"shop_name": self.shop_name,
"price_granularity": "color",
"metrics": {
"sales": self.sales.to_dict(),
"reviews": self.reviews.to_dict(),
},
"dimensions": [item.to_dict() for item in self.dimensions],
"skus": [item.to_dict() for item in self.skus],
"purchase": None,
"captured_at": self.captured_at,
"source": {
"client_id": self.client_id,
"device_address": self.device_address,
"pdd_package": PDD_PACKAGE_NAME,
},
"artifacts": [dict(item) for item in self.artifacts],
}
def _parse_xml(xml_data: str | bytes) -> ET.Element:
try:
return ET.fromstring(xml_data)
except (ET.ParseError, TypeError) as exc:
raise PddCollectError(
"PDD_DATA_XML_INVALID",
"PDD 返回的无障碍控件树不是有效 XML",
) from exc
def _parse_bounds(value: str) -> Optional[Bounds]:
match = _BOUNDS_PATTERN.fullmatch((value or "").strip())
if not match:
return None
left, top, right, bottom = map(int, match.groups())
if right <= left or bottom <= top:
return None
return left, top, right, bottom
def _node_label(node: ET.Element) -> str:
return " ".join(
part.strip()
for part in (node.get("text", ""), node.get("content-desc", ""))
if part.strip()
)
def _preferred_node_label(node: ET.Element) -> str:
"""规格名被 text 截断时,优先采用完整的无障碍描述。"""
text = (node.get("text") or "").strip()
description = (node.get("content-desc") or "").strip()
return description or text
def _own_or_descendant_label(node: ET.Element) -> str:
own = _node_label(node)
if own:
return own
for child in node.iter("node"):
label = _node_label(child)
if label and label != "打开大图":
return label
return ""
def _preferred_or_descendant_label(node: ET.Element) -> str:
own = _preferred_node_label(node)
if own:
return own
for child in node.iter("node"):
label = _preferred_node_label(child)
if label and label != "打开大图":
return label
return ""
def _all_labels(root: ET.Element) -> list[str]:
return [label for node in root.iter("node") if (label := _node_label(node))]
def parse_quantity(raw: Optional[str]) -> QuantityMetric:
"""把“已拼1.2万+”等文字转成整数,同时保留原文。"""
if not raw:
return QuantityMetric(None, None, False)
match = _QUANTITY_PATTERN.search(raw.replace(",", ""))
if match is None:
return QuantityMetric(None, raw, False)
multiplier = {None: 1, "万": 10_000, "亿": 100_000_000}[match.group(2)]
value = int(Decimal(match.group(1)) * multiplier)
approximate = bool(match.group(2) or match.group(3))
return QuantityMetric(value, raw, approximate)
def _find_metric(labels: Sequence[str], pattern: re.Pattern[str]) -> QuantityMetric:
for label in labels:
match = pattern.search(label)
if match:
return parse_quantity(match.group(0))
return QuantityMetric(None, None, False)
def _shop_name_by_enter_anchor(root: ET.Element) -> Optional[str]:
"""以“进店”为锚点,读取同一行左侧的店铺名。"""
anchors = []
for node in root.iter("node"):
if _preferred_node_label(node) != "进店":
continue
bounds = _parse_bounds(node.get("bounds", ""))
if bounds is not None:
anchors.append(bounds)
if not anchors:
return None
anchor_left, anchor_top, _, _ = min(
anchors, key=lambda bounds: (bounds[1], bounds[0])
)
candidates = []
for node in root.iter("node"):
if node.get("class") != "android.widget.TextView":
continue
text = _preferred_node_label(node)
if not text or text in _SHOP_NAME_EXCLUDES:
continue
if "已拼" in text or "评价" in text or not 2 <= len(text) <= 30:
continue
bounds = _parse_bounds(node.get("bounds", ""))
if bounds is None:
continue
left, top, _, _ = bounds
if left >= anchor_left or abs(top - anchor_top) > _SHOP_ROW_TOLERANCE:
continue
candidates.append((abs(top - anchor_top), -left, text))
if not candidates:
return None
return min(candidates)[2]
def parse_goods_page(xml_data: str | bytes) -> GoodsSnapshot:
"""解析一棵商品页控件树中的标题、店铺、销量和评价。"""
root = _parse_xml(xml_data)
labels = _all_labels(root)
_raise_special_page(labels)
title: Optional[str] = None
for node in root.iter("node"):
if node.get("class") == "androidx.viewpager.widget.ViewPager":
candidate = (node.get("content-desc") or node.get("text") or "").strip()
if len(candidate) >= 6:
title = candidate
break
if title is None:
line_parts: dict[int, list[tuple[int, str]]] = {}
for node in root.iter("node"):
label = _preferred_node_label(node)
bounds = _parse_bounds(node.get("bounds", ""))
if not label or bounds is None:
continue
line_key = bounds[1] // 24
line_parts.setdefault(line_key, []).append((bounds[0], label))
joined_lines = [
"".join(text for _, text in sorted(parts))
for parts in line_parts.values()
]
candidates = [
label
for label in labels + joined_lines
if len(label) >= 12
and not any(word in label for word in ("通知", "支付", "已拼", "评价"))
]
title = max(candidates, key=len, default=None)
sales = _find_metric(labels, re.compile(r"已拼\s*\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?\s*(?:件|人)?"))
reviews_pattern = re.compile(
r"(?:商品评价\s*[((]\s*\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?\s*[))]"
r"|\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?\s*条?评价"
r"|评价\s*\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?)"
)
reviews = _find_metric(labels, reviews_pattern)
if reviews.raw is None:
empty_reviews = next(
(label for label in labels if label in ("暂无评价", "暂无评论")),
None,
)
if empty_reviews:
reviews = QuantityMetric(0, empty_reviews, False)
shop_name: Optional[str] = None
for label in labels:
match = re.search(r"(?:店铺|商家)[::]\s*(.+)", label)
if match and match.group(1).strip():
shop_name = match.group(1).strip()
break
if len(label) > 2 and label.endswith(("旗舰店", "专卖店", "专营店")):
shop_name = label
break
if shop_name is None:
shop_name = _shop_name_by_enter_anchor(root)
return GoodsSnapshot(title, shop_name, sales, reviews)
def _is_available(node: ET.Element) -> bool:
label = _own_or_descendant_label(node)
return (
node.get("enabled", "true") == "true"
and node.get("visible-to-user", "true") == "true"
and not any(word in label for word in ("售罄", "缺货", "不可选"))
)
def _dimension_key(name: str, used: set[str]) -> str:
if "颜色" in name or "款式" in name:
base = "color"
elif "尺码" in name or "尺寸" in name:
base = "size"
elif "型号" in name:
base = "model"
else:
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
base = f"dimension_{digest}"
key = base
suffix = 2
while key in used:
key = f"{base}_{suffix}"
suffix += 1
return key
def _is_dimension_heading(label: str) -> bool:
compact = label.replace(" ", "")
return compact in _DIMENSION_NAMES or compact.endswith(
("分类", "规格", "尺寸", "尺码", "颜色", "型号", "款式", "容量", "类型", "版本", "口味")
)
def _top_level_clickable_options(
container: ET.Element,
parents: Mapping[ET.Element, ET.Element],
) -> list[ET.Element]:
result: list[ET.Element] = []
for node in container.iter("node"):
label = _own_or_descendant_label(node).strip()
bounds = _parse_bounds(node.get("bounds", ""))
if node.get("clickable") != "true" or not label or bounds is None:
continue
if label.startswith("#") or label in (
"打开大图",
"增加数量",
"减少数量",
"关闭",
):
continue
parent = parents.get(node)
if parent is not None and parent is not container:
parent_label = _own_or_descendant_label(parent).strip()
if parent.get("clickable") == "true" and parent_label == label:
continue
result.append(node)
return result
def _price_from_nodes(
nodes: Iterable[ET.Element],
spec_top: int,
) -> tuple[Optional[int], Optional[str], Optional[int]]:
candidates: list[tuple[int, int, str]] = []
for node in nodes:
label = _node_label(node)
bounds = _parse_bounds(node.get("bounds", ""))
match = _PRICE_PATTERN.search(label)
if match is None or bounds is None or bounds[1] >= spec_top:
continue
try:
cents = int(
(Decimal(match.group(1)) * 100).quantize(
Decimal("1"),
rounding=ROUND_HALF_UP,
)
)
except InvalidOperation:
continue
candidates.append((bounds[1], cents, match.group(0).replace(" ", "")))
if not candidates:
return None, None, None
first_top = min(item[0] for item in candidates)
first_row = [item for item in candidates if abs(item[0] - first_top) <= 12]
_, cents, raw = min(first_row, key=lambda item: item[1])
list_prices = [item[1] for item in first_row if item[1] > cents]
return cents, raw, max(list_prices, default=None)
def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot:
"""解析规格面板当前视口中的规格值、已选摘要和当前价格。"""
root = _parse_xml(xml_data)
labels = _all_labels(root)
_raise_special_page(labels)
parents = {child: parent for parent in root.iter() for child in parent}
scrollables = [
node
for node in root.iter("node")
if node.get("scrollable") == "true" and _parse_bounds(node.get("bounds", ""))
]
if not scrollables:
raise PddCollectError("PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的规格区域")
outer = max(
scrollables,
key=lambda node: (
(_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[2]
- (_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[0]
) * (
(_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[3]
- (_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[1]
),
)
outer_bounds = _parse_bounds(outer.get("bounds", ""))
assert outer_bounds is not None
heading_nodes: list[tuple[int, str]] = []
for node in outer.iter("node"):
label = _node_label(node).strip()
bounds = _parse_bounds(node.get("bounds", ""))
if label and bounds and _is_dimension_heading(label):
heading_nodes.append((bounds[1], label))
heading_nodes.sort()
groups: list[tuple[str, list[ET.Element]]] = []
nested_scrollables = [
node
for node in scrollables
if node is not outer and outer in _ancestors(node, parents)
]
nested_options: set[ET.Element] = set()
for nested in nested_scrollables:
bounds = _parse_bounds(nested.get("bounds", ""))
if bounds is None:
continue
name = "颜色分类"
preceding = [item for item in heading_nodes if item[0] <= bounds[1] + 30]
if preceding:
name = preceding[-1][1]
options = _top_level_clickable_options(nested, parents)
if options:
groups.append((name, options))
nested_options.update(options)
for index, (top, name) in enumerate(heading_nodes):
bottom = heading_nodes[index + 1][0] if index + 1 < len(heading_nodes) else outer_bounds[3]
options = []
for node in _top_level_clickable_options(outer, parents):
bounds = _parse_bounds(node.get("bounds", ""))
if node in nested_options or bounds is None:
continue
width = bounds[2] - bounds[0]
if top < bounds[1] < bottom and width < (outer_bounds[2] - outer_bounds[0]) * 0.85:
options.append(node)
if options:
groups.append((name, options))
used_keys: set[str] = set()
dimensions: list[SpecDimension] = []
for name, option_nodes in groups:
values: list[DimensionValue] = []
seen: set[str] = set()
for node in option_nodes:
text = _preferred_or_descendant_label(node).strip()
if text.endswith(("…", "...")):
raise PddCollectError(
"PDD_DATA_SKU_NAME_TRUNCATED",
f"规格名称被截断,无法安全采集:{text}",
)
if not text or text in seen:
continue
seen.add(text)
values.append(DimensionValue(text, _is_available(node)))
if not values:
continue
key = _dimension_key(name, used_keys)
used_keys.add(key)
dimensions.append(SpecDimension(key, name, tuple(values)))
selected_text = next((label for label in labels if label.startswith("已选")), None)
price_cent, raw_price, list_price_cent = _price_from_nodes(
root.iter("node"), outer_bounds[1]
)
return SpecSnapshot(
tuple(dimensions), selected_text, price_cent, raw_price, list_price_cent
)
def _ancestors(
node: ET.Element,
parents: Mapping[ET.Element, ET.Element],
) -> list[ET.Element]:
result: list[ET.Element] = []
current = parents.get(node)
while current is not None:
result.append(current)
current = parents.get(current)
return result
def _raise_special_page(labels: Sequence[str]) -> None:
combined = " ".join(labels)
if any(marker in combined for marker in _CAPTCHA_MARKERS):
raise PddCollectError("PDD_PAGE_CAPTCHA", "PDD 出现安全验证,需要人工处理")
if any(marker in combined for marker in _LOGIN_MARKERS):
raise PddCollectError("PDD_PAGE_LOGIN_REQUIRED", "PDD 登录已失效,需要人工重新登录")
def _validate_goods_url(goods_url: str, task_goods_id: str) -> str:
parsed = urlparse(goods_url)
host = (parsed.hostname or "").lower()
allowed_host = host == "yangkeduo.com" or host.endswith(".yangkeduo.com")
allowed_host = allowed_host or host == "pinduoduo.com" or host.endswith(
".pinduoduo.com"
)
if parsed.scheme not in ("http", "https") or not allowed_host:
raise PddCollectError(
"PDD_DATA_GOODS_URL_INVALID",
"商品链接不是受支持的 PDD 链接",
)
url_goods_id = parse_qs(parsed.query).get("goods_id", [""])[0].strip()
if task_goods_id and url_goods_id and task_goods_id != url_goods_id:
raise PddCollectError(
"PDD_DATA_GOODS_ID_MISMATCH",
"任务商品编号与商品链接中的 goods_id 不一致",
)
value = task_goods_id or url_goods_id
if not value or not value.isdigit():
raise PddCollectError("PDD_DATA_GOODS_ID_MISSING", "商品链接中没有 goods_id")
return value
class PddCollectService:
"""打开商品页并采集结构化商品与 SKU 数据。"""
def __init__(
self,
device_service: PddDeviceService,
device_address: str,
client_id: str,
*,
sleeper: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
cancelled: Callable[[], bool] = lambda: False,
page_timeout: float = 30.0,
spec_panel_timeout: float = 10.0,
overall_timeout: float = 600.0,
max_goods_page_swipes: int = 12,
max_spec_swipes: int = 12,
max_sku_count: int = 200,
artifact_directory: Optional[Path] = None,
) -> None:
self._device_service = device_service
self._device_address = device_address
self._client_id = client_id
self._sleep = sleeper
self._monotonic = monotonic
self._now = now
self._cancelled = cancelled
self._page_timeout = page_timeout
self._spec_panel_timeout = spec_panel_timeout
self._overall_timeout = overall_timeout
self._overall_deadline: Optional[float] = None
self._max_goods_page_swipes = max_goods_page_swipes
self._max_spec_swipes = max_spec_swipes
self._max_sku_count = max_sku_count
self._artifact_directory = artifact_directory
self._last_goods_xml: Optional[str] = None
self._goods_screens_checked = 0
self._artifacts: list[Mapping[str, Any]] = []
def collect(self, task: Any) -> CollectResult:
"""执行采集;``task`` 至少提供 ``goods_url`` 和可选 ``goods_id``。"""
goods_url = str(getattr(task, "goods_url", "") or "").strip()
if not goods_url:
raise PddCollectError("PDD_DATA_GOODS_URL_MISSING", "采集任务缺少商品链接")
goods_id = str(getattr(task, "goods_id", "") or "").strip()
goods_id = _validate_goods_url(goods_url, goods_id)
self._overall_deadline = self._monotonic() + self._overall_timeout
self._check_cancelled()
try:
with self._device_service.connect(self._device_address) as device:
self._open_goods(device, goods_url)
goods = self._collect_goods_details(device)
if not goods.title:
raise PddCollectError("PDD_DATA_TITLE_MISSING", "商品页没有可识别的标题")
if not goods.sales.raw:
raise PddCollectError("PDD_DATA_SALES_MISSING", "商品页没有采集到已拼数量")
if (
(not goods.shop_name or not goods.reviews.raw)
and self._last_goods_xml
):
artifact = self._save_xml(
"goods-metadata-incomplete", self._last_goods_xml
)
if artifact:
self._artifacts.append(artifact)
home_xml = self._dump_hierarchy(device)
coordinate = get_size_panel_coord(home_xml)
if coordinate is None:
raise PddCollectError(
"PDD_PAGE_SPEC_ENTRY_MISSING",
"商品页没有找到可靠的规格入口",
)
device.click(*coordinate)
self._wait_spec_panel(device)
color_dimension, color_samples = self._collect_color_prices(device)
size_dimension = self._collect_size_dimension(device)
dimensions = (color_dimension,)
if size_dimension is not None:
dimensions += (size_dimension,)
if not dimensions or any(not item.values for item in dimensions):
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE",
"规格面板没有采集到完整的规格维度",
)
skus = self._build_color_price_skus(
dimensions, color_samples
)
if not skus:
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE",
"规格面板没有生成可提交的规格组合",
)
except PddCollectError as exc:
if not exc.diagnostics and 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,
}
raise
except PddDeviceError as exc:
raise PddCollectError(exc.code, exc.message) from exc
except Exception as exc:
if _is_device_disconnect(exc):
raise PddCollectError(
"DEVICE_DISCONNECTED",
f"Android 设备在采集过程中断开:{exc}",
) from exc
raise PddCollectError("PDD_PAGE_UNKNOWN", f"PDD 采集过程中发生未知错误:{exc}") from exc
return CollectResult(
goods_id=goods_id,
goods_url=goods_url,
title=goods.title,
shop_name=goods.shop_name,
sales=goods.sales,
reviews=goods.reviews,
dimensions=dimensions,
skus=skus,
captured_at=self._now().astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
client_id=self._client_id,
device_address=self._device_address,
artifacts=tuple(self._artifacts),
)
def _check_cancelled(self) -> None:
if self._cancelled():
raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消")
if (
self._overall_deadline is not None
and self._monotonic() >= self._overall_deadline
):
raise PddCollectError("PDD_PAGE_OVERALL_TIMEOUT", "PDD 采集超过 10 分钟")
def _open_goods(self, device: Any, goods_url: str) -> None:
try:
current = device.app_current()
if current.get("package") != PDD_PACKAGE_NAME:
device.app_start(PDD_PACKAGE_NAME)
if not device.app_wait(PDD_PACKAGE_NAME, timeout=10):
raise PddCollectError("DEVICE_APP_START_FAILED", "PDD 应用启动失败")
device.open_url(goods_url)
except PddCollectError:
raise
except Exception as exc:
if _is_device_disconnect(exc):
raise PddCollectError(
"DEVICE_DISCONNECTED",
f"Android 设备在打开商品页时断开:{exc}",
) from exc
raise PddCollectError("DEVICE_APP_START_FAILED", f"无法打开 PDD 商品链接:{exc}") from exc
deadline = self._monotonic() + self._page_timeout
while self._monotonic() < deadline:
self._check_cancelled()
current = device.app_current()
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
)
# 部分 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):
return
self._sleep(0.5)
raise PddCollectError("PDD_PAGE_TIMEOUT", "等待 PDD 商品详情页加载超时")
def _collect_goods_details(self, device: Any) -> GoodsSnapshot:
"""保留首屏结果,并向下浏览补采评价数量和店铺名。"""
self._check_cancelled()
xml_data = self._dump_hierarchy(device)
goods = parse_goods_page(xml_data)
if goods.shop_name and goods.reviews.raw is not None:
return goods
unchanged_reads = 0
for _ in range(self._max_goods_page_swipes):
self._check_cancelled()
self._swipe_goods_page(device, xml_data)
self._sleep(0.8)
self._check_cancelled()
next_xml = self._dump_hierarchy(device)
snapshot = parse_goods_page(next_xml)
goods = GoodsSnapshot(
title=goods.title,
shop_name=goods.shop_name or snapshot.shop_name,
sales=goods.sales,
reviews=(
goods.reviews
if goods.reviews.raw is not None
else snapshot.reviews
),
)
if goods.shop_name and goods.reviews.raw is not None:
break
unchanged_reads = unchanged_reads + 1 if next_xml == xml_data else 0
xml_data = next_xml
if unchanged_reads >= 2:
break
return goods
@staticmethod
def _swipe_goods_page(device: Any, xml_data: str | bytes) -> None:
"""在商品详情区域慢速向上滑动约 30%,浏览页面下方内容。"""
root = _parse_xml(xml_data)
scrollable_bounds = [
bounds
for node in root.iter("node")
if node.get("scrollable") == "true"
and (bounds := _parse_bounds(node.get("bounds", ""))) is not None
]
all_bounds = [
bounds
for node in root.iter("node")
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
]
candidates = scrollable_bounds or all_bounds
if not candidates:
raise PddCollectError(
"PDD_DATA_PAGE_BOUNDS_MISSING",
"商品详情页没有可用于滚动的有效区域",
)
left, top, right, bottom = max(
candidates,
key=lambda bounds: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
)
x = (left + right) // 2
height = bottom - top
device.swipe(
x,
top + int(height * 0.65),
x,
top + int(height * 0.35),
duration=0.6,
)
def _wait_spec_panel(self, device: Any) -> SpecSnapshot:
"""等待点击后的规格面板真正出现,不能只依赖固定延时。"""
deadline = self._monotonic() + self._spec_panel_timeout
while self._monotonic() < deadline:
self._check_cancelled()
xml_data = self._dump_hierarchy(device)
try:
snapshot = parse_spec_panel(xml_data)
except PddCollectError as exc:
if exc.code != "PDD_DATA_SPEC_INCOMPLETE":
raise
else:
if snapshot.dimensions:
return snapshot
self._sleep(0.25)
raise PddCollectError(
"PDD_PAGE_SPEC_PANEL_TIMEOUT",
"点击规格入口后,等待规格面板加载超时",
)
def _save_xml(self, label: str, xml_data: str) -> Optional[Mapping[str, Any]]:
"""保存本地诊断 XML;测试未提供目录时不写文件。"""
if self._artifact_directory is None:
return None
try:
digest = hashlib.sha256(xml_data.encode("utf-8")).hexdigest()
directory = self._artifact_directory / self._client_id
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{label}-{digest[:12]}.xml"
if not path.exists():
path.write_text(xml_data, encoding="utf-8")
return {
"kind": "accessibility_xml",
"path": str(path.resolve()),
"sha256": digest,
"screens_checked": self._goods_screens_checked,
}
except OSError:
return None
def _dump_hierarchy(self, device: Any) -> str:
"""读取并记住最新控件树,失败诊断必须指向最后一次操作。"""
raw_xml = device.dump_hierarchy()
xml_data = (
raw_xml.decode("utf-8", errors="replace")
if isinstance(raw_xml, bytes)
else str(raw_xml)
)
self._last_goods_xml = xml_data
self._goods_screens_checked += 1
return xml_data
def _collect_color_prices(
self, device: Any
) -> tuple[SpecDimension, Mapping[str, ColorPriceSample]]:
"""按行蛇形遍历颜色,并在每次点击后立即读取颜色级价格。"""
self._move_color_list_to_left(device)
first_xml = self._dump_hierarchy(device)
first_rows = self._visible_color_rows(first_xml)
if not first_rows:
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的颜色分类"
)
row_count = len(first_rows)
visited: set[str] = set()
ordered_colors: list[DimensionValue] = []
samples: dict[str, ColorPriceSample] = {}
for row_index in range(row_count):
move_right = row_index % 2 == 0
current_row_colors: list[DimensionValue] = []
stable_edge_reads = 0
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
for swipe_count in range(self._max_spec_swipes + 1):
self._check_cancelled()
# 每次只处理一个节点;点击可能让列表自动移动,下一项必须
# 从最新 XML 重新计算,不能继续使用点击前的旧坐标。
while True:
xml_data = self._dump_hierarchy(device)
rows = self._visible_color_rows(xml_data)
if row_index >= len(rows):
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE",
f"颜色第 {row_index + 1} 行在滑动后消失",
)
current_row = sorted(
rows[row_index],
key=lambda item: item.bounds[0],
reverse=not move_right,
)
visible = next(
(item for item in current_row if item.text not in visited),
None,
)
if visible is None:
break
visited.add(visible.text)
color = DimensionValue(visible.text, visible.available)
current_row_colors.append(color)
if visible.available:
samples[visible.text] = self._click_and_sample_color(
device, visible.text
)
else:
samples[visible.text] = ColorPriceSample(
None, None, None
)
signature = tuple((item.text, item.bounds) for item in current_row)
if signature == previous_signature:
stable_edge_reads += 1
else:
stable_edge_reads = 0
previous_signature = signature
if stable_edge_reads >= 2 or swipe_count >= self._max_spec_swipes:
break
root = _parse_xml(self._dump_hierarchy(device))
region = self._horizontal_region(root)
if region is None:
break
self._swipe_region(
device,
region,
horizontal=True,
reverse=not move_right,
)
self._sleep(0.35)
# 操作采用蛇形以减少无效滑动;输出仍恢复成页面自然的
# “每行从左到右”顺序,方便 Admin 下拉框稳定展示。
ordered_colors.extend(
current_row_colors if move_right else reversed(current_row_colors)
)
if not ordered_colors:
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE", "规格面板没有采集到颜色"
)
return (
SpecDimension("color", "颜色分类", tuple(ordered_colors)),
samples,
)
def _move_color_list_to_left(self, device: Any) -> None:
"""把颜色列表归位到左端;连续两次视口不变才认为到边。"""
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
stable_edge_reads = 0
for _ in range(self._max_spec_swipes):
self._check_cancelled()
xml_data = self._dump_hierarchy(device)
rows = self._visible_color_rows(xml_data)
signature = tuple(
(item.text, item.bounds) for row in rows for item in row
)
if signature == previous_signature:
stable_edge_reads += 1
else:
stable_edge_reads = 0
previous_signature = signature
if stable_edge_reads >= 2:
return
root = _parse_xml(xml_data)
region = self._horizontal_region(root)
if region is None:
return
self._swipe_region(
device, region, horizontal=True, reverse=True
)
self._sleep(0.35)
def _click_and_sample_color(
self, device: Any, target: str
) -> ColorPriceSample:
"""点击最新树中的颜色,等待选择和价格连续两次稳定。"""
xml_data = self._dump_hierarchy(device)
root = _parse_xml(xml_data)
latest_visible = next(
(
item
for row in self._visible_color_rows(xml_data)
for item in row
if item.text == target
),
None,
)
node = self._find_option_node(root, target) if latest_visible else None
if node is None or not _is_available(node):
return ColorPriceSample(None, None, None)
bounds = _parse_bounds(node.get("bounds", ""))
assert bounds is not None
device.click(
(bounds[0] + bounds[2]) // 2,
(bounds[1] + bounds[3]) // 2,
)
previous_price: Optional[tuple[int, Optional[str], Optional[int]]] = None
stable_price_reads = 0
for _ in range(8):
self._check_cancelled()
self._sleep(0.2)
latest_xml = self._dump_hierarchy(device)
latest_root = _parse_xml(latest_xml)
snapshot = parse_spec_panel(latest_xml)
latest_node = self._find_option_node(latest_root, target)
selection_exposed = self._color_selection_state_exposed(latest_xml)
selected = bool(
latest_node is not None and self._node_is_selected(latest_node)
) or bool(snapshot.selected_text and target in snapshot.selected_text)
# 老版本页面不暴露 selected/checked,也没有“已选”摘要时,
# 只能以完整可见可点击节点未报错的点击作为降级证据。
selection_confirmed = selected or not selection_exposed
if snapshot.price_cent is None or not selection_confirmed:
stable_price_reads = 0
previous_price = None
continue
current_price = (
snapshot.price_cent,
snapshot.raw_price,
snapshot.list_price_cent,
)
if current_price == previous_price:
stable_price_reads += 1
else:
stable_price_reads = 1
previous_price = current_price
if stable_price_reads >= 2:
return ColorPriceSample(
snapshot.price_cent,
snapshot.raw_price,
snapshot.list_price_cent,
)
return ColorPriceSample(None, None, None)
def _collect_size_dimension(self, device: Any) -> Optional[SpecDimension]:
"""颜色采价完成后只滚动并收集尺码文字,不点击尺码。"""
sizes: dict[str, bool] = {}
previous_signature: Optional[
tuple[tuple[str, tuple[str, ...]], ...]
] = None
stable_edge_reads = 0
for swipe_count in range(self._max_spec_swipes + 1):
self._check_cancelled()
xml_data = self._dump_hierarchy(device)
snapshot = parse_spec_panel(xml_data)
for dimension in snapshot.dimensions:
if dimension.key not in ("color", "size"):
raise PddCollectError(
"PDD_DATA_SPEC_UNSUPPORTED",
f"发现尚未支持的规格维度:{dimension.name}",
)
if dimension.key == "size":
for value in dimension.values:
sizes[value.text] = sizes.get(value.text, False) or value.available
signature = tuple(
(item.key, tuple(value.text for value in item.values))
for item in snapshot.dimensions
)
if signature == previous_signature:
stable_edge_reads += 1
else:
stable_edge_reads = 0
previous_signature = signature
if stable_edge_reads >= 2 or swipe_count >= self._max_spec_swipes:
break
root = _parse_xml(xml_data)
region = self._vertical_region(root)
if region is None:
break
self._swipe_region(
device, region, horizontal=False, reverse=False
)
self._sleep(0.35)
if not sizes:
return None
return SpecDimension(
"size",
"尺码",
tuple(DimensionValue(text, available) for text, available in sizes.items()),
)
def _build_color_price_skus(
self,
dimensions: tuple[SpecDimension, ...],
samples: Mapping[str, ColorPriceSample],
) -> tuple[SkuResult, ...]:
"""按颜色价格生成展示组合;采价证据只记录真实选中的颜色。"""
colors = dimensions[0].values
sizes = dimensions[1].values if len(dimensions) > 1 else (None,)
combination_count = len(colors) * len(sizes)
if combination_count > self._max_sku_count:
raise PddCollectError(
"PDD_DATA_TOO_MANY_SKUS",
f"规格组合共 {combination_count} 个,超过安全上限 {self._max_sku_count}",
)
results: list[SkuResult] = []
for color in colors:
sample = samples[color.text]
for size in sizes:
options = {"color": color.text}
if size is not None:
options["size"] = size.text
# 是否可用来自规格控件状态,不能用“是否采到价格”代替。
available = color.available and (size is None or size.available)
results.append(
SkuResult(
options,
sample.price_cent,
available,
sample.raw_price,
{"color": color.text}
if sample.price_cent is not None
else {},
sample.list_price_cent,
)
)
return tuple(results)
def _visible_color_rows(
self, xml_data: str | bytes
) -> list[list[VisibleSpecOption]]:
"""把当前视口中完整显示的颜色节点按中心 y 聚类成行。"""
root = _parse_xml(xml_data)
snapshot = parse_spec_panel(xml_data)
color_dimension = next(
(item for item in snapshot.dimensions if item.key == "color"),
None,
)
if color_dimension is None:
return []
candidates: list[VisibleSpecOption] = []
for value in color_dimension.values:
node = self._find_option_node(root, value.text)
if node is None:
continue
bounds = _parse_bounds(node.get("bounds", ""))
if bounds is None:
continue
candidates.append(
VisibleSpecOption(
value.text,
value.available,
bounds,
)
)
if not candidates:
return []
widest = max(item.bounds[2] - item.bounds[0] for item in candidates)
minimum_width = max(48, int(widest * 0.6))
complete = [
item
for item in candidates
if item.bounds[2] - item.bounds[0] >= minimum_width
]
complete.sort(key=lambda item: ((item.bounds[1] + item.bounds[3]) // 2, item.bounds[0]))
rows: list[list[VisibleSpecOption]] = []
row_centers: list[int] = []
for item in complete:
center_y = (item.bounds[1] + item.bounds[3]) // 2
matching_index = next(
(
index
for index, row_center in enumerate(row_centers)
if abs(center_y - row_center) <= 80
),
None,
)
if matching_index is None:
rows.append([item])
row_centers.append(center_y)
else:
rows[matching_index].append(item)
for row in rows:
row.sort(key=lambda item: item.bounds[0])
return rows
@staticmethod
def _node_selection_state_exposed(node: ET.Element) -> bool:
return any(
item.get("selected") is not None or item.get("checked") is not None
for item in node.iter()
)
@staticmethod
def _node_is_selected(node: ET.Element) -> bool:
return any(
item.get("selected") == "true" or item.get("checked") == "true"
for item in node.iter()
)
def _color_selection_state_exposed(self, xml_data: str | bytes) -> bool:
root = _parse_xml(xml_data)
snapshot = parse_spec_panel(xml_data)
if snapshot.selected_text:
return True
color_dimension = next(
(item for item in snapshot.dimensions if item.key == "color"),
None,
)
if color_dimension is None:
return False
return any(
node is not None and self._node_selection_state_exposed(node)
for value in color_dimension.values
if (node := self._find_option_node(root, value.text)) is not None
)
@staticmethod
def _find_option_node(root: ET.Element, target: str) -> Optional[ET.Element]:
parents = {child: parent for parent in root.iter() for child in parent}
scrollables = [node for node in root.iter("node") if node.get("scrollable") == "true"]
right_edges = [
bounds[2]
for node in root.iter("node")
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
]
screen_right = max(right_edges, default=0)
for node in root.iter("node"):
if _preferred_or_descendant_label(node).strip() != target:
continue
bounds = _parse_bounds(node.get("bounds", ""))
if node.get("clickable") != "true" or bounds is None:
continue
center_x = (bounds[0] + bounds[2]) // 2
if bounds[2] - bounds[0] < 48:
continue
if not 24 <= center_x <= screen_right - 24:
continue
if any(container in _ancestors(node, parents) for container in scrollables):
return node
return None
@staticmethod
def _horizontal_region(root: ET.Element) -> Optional[Bounds]:
parents = {child: parent for parent in root.iter() for child in parent}
candidates: list[tuple[int, Bounds]] = []
for node in root.iter("node"):
if node.get("scrollable") != "true":
continue
bounds = _parse_bounds(node.get("bounds", ""))
if bounds is None:
continue
depth = len(_ancestors(node, parents))
if any(child.get("clickable") == "true" for child in node):
candidates.append((depth, bounds))
return max(candidates, key=lambda item: item[0])[1] if candidates else None
@staticmethod
def _vertical_region(root: ET.Element) -> Optional[Bounds]:
candidates: list[Bounds] = []
for node in root.iter("node"):
bounds = _parse_bounds(node.get("bounds", ""))
if node.get("scrollable") == "true" and bounds is not None:
candidates.append(bounds)
if not candidates:
return None
return max(
candidates,
key=lambda item: (item[3] - item[1]) * (item[2] - item[0]),
)
@staticmethod
def _swipe_region(device: Any, bounds: Bounds, *, horizontal: bool, reverse: bool) -> None:
left, top, right, bottom = bounds
if horizontal:
y = (top + bottom) // 2
near_left = left + int((right - left) * 0.25)
near_right = left + int((right - left) * 0.75)
start_x, end_x = (
(near_left, near_right) if reverse else (near_right, near_left)
)
device.swipe(start_x, y, end_x, y, duration=0.35)
else:
x = (left + right) // 2
near_top = top + int((bottom - top) * 0.25)
near_bottom = top + int((bottom - top) * 0.75)
start_y, end_y = (
(near_top, near_bottom) if reverse else (near_bottom, near_top)
)
device.swipe(x, start_y, x, end_y, duration=0.35)