feat: 滚动采集评价数量和店铺名 (#40)

This commit is contained in:
chengma
2026-08-08 10:30:04 +08:00
parent 2f83be5e40
commit 9dadac2bff
4 changed files with 257 additions and 11 deletions
+114 -3
View File
@@ -29,6 +29,8 @@ _DIMENSION_NAMES = ("颜色分类", "颜色", "尺码", "尺寸", "规格", "型
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
_SHOP_NAME_EXCLUDES = frozenset({"进店", "关注", "店铺", "收藏", "客服"})
_SHOP_ROW_TOLERANCE = 40
def _is_device_disconnect(error: BaseException) -> bool:
@@ -280,6 +282,44 @@ def _find_metric(labels: Sequence[str], pattern: re.Pattern[str]) -> QuantityMet
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:
"""解析一棵商品页控件树中的标题、店铺、销量和评价。"""
@@ -317,7 +357,8 @@ def parse_goods_page(xml_data: str | bytes) -> GoodsSnapshot:
sales = _find_metric(labels, re.compile(r"已拼\s*\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?\s*(?:件|人)?"))
reviews_pattern = re.compile(
r"(?:\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?\s*条?评价"
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)
@@ -338,6 +379,8 @@ def parse_goods_page(xml_data: str | bytes) -> GoodsSnapshot:
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)
@@ -592,6 +635,7 @@ class PddCollectService:
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,
@@ -607,6 +651,7 @@ class PddCollectService:
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
@@ -770,11 +815,77 @@ class PddCollectService:
raise PddCollectError("PDD_PAGE_TIMEOUT", "等待 PDD 商品详情页加载超时")
def _collect_goods_details(self, device: Any) -> GoodsSnapshot:
"""只读商品首页当前视口,不在打开规格面板前滚动详情页。"""
"""保留首屏结果,并向下浏览补采评价数量和店铺名。"""
self._check_cancelled()
xml_data = self._dump_hierarchy(device)
return parse_goods_page(xml_data)
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:
"""等待点击后的规格面板真正出现,不能只依赖固定延时。"""