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 = ("手机号登录", "登录后继续", "验证码登录", "账号登录") _LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中") _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光") _READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
_SHOP_NAME_EXCLUDES = frozenset({"进店", "关注", "店铺", "收藏", "客服"})
_SHOP_ROW_TOLERANCE = 40
def _is_device_disconnect(error: BaseException) -> bool: 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) 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: 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*(?:件|人)?")) sales = _find_metric(labels, re.compile(r"已拼\s*\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?\s*(?:件|人)?"))
reviews_pattern = re.compile( 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*\+?)" r"|评价\s*\d+(?:\.\d+)?\s*(?:万|亿)?\s*\+?)"
) )
reviews = _find_metric(labels, reviews_pattern) 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(("旗舰店", "专卖店", "专营店")): if len(label) > 2 and label.endswith(("旗舰店", "专卖店", "专营店")):
shop_name = label shop_name = label
break break
if shop_name is None:
shop_name = _shop_name_by_enter_anchor(root)
return GoodsSnapshot(title, shop_name, sales, reviews) return GoodsSnapshot(title, shop_name, sales, reviews)
@@ -592,6 +635,7 @@ class PddCollectService:
page_timeout: float = 30.0, page_timeout: float = 30.0,
spec_panel_timeout: float = 10.0, spec_panel_timeout: float = 10.0,
overall_timeout: float = 600.0, overall_timeout: float = 600.0,
max_goods_page_swipes: int = 12,
max_spec_swipes: int = 12, max_spec_swipes: int = 12,
max_sku_count: int = 200, max_sku_count: int = 200,
artifact_directory: Optional[Path] = None, artifact_directory: Optional[Path] = None,
@@ -607,6 +651,7 @@ class PddCollectService:
self._spec_panel_timeout = spec_panel_timeout self._spec_panel_timeout = spec_panel_timeout
self._overall_timeout = overall_timeout self._overall_timeout = overall_timeout
self._overall_deadline: Optional[float] = None self._overall_deadline: Optional[float] = None
self._max_goods_page_swipes = max_goods_page_swipes
self._max_spec_swipes = max_spec_swipes self._max_spec_swipes = max_spec_swipes
self._max_sku_count = max_sku_count self._max_sku_count = max_sku_count
self._artifact_directory = artifact_directory self._artifact_directory = artifact_directory
@@ -770,11 +815,77 @@ class PddCollectService:
raise PddCollectError("PDD_PAGE_TIMEOUT", "等待 PDD 商品详情页加载超时") raise PddCollectError("PDD_PAGE_TIMEOUT", "等待 PDD 商品详情页加载超时")
def _collect_goods_details(self, device: Any) -> GoodsSnapshot: def _collect_goods_details(self, device: Any) -> GoodsSnapshot:
"""只读商品首页当前视口,不在打开规格面板前滚动详情页。""" """保留首屏结果,并向下浏览补采评价数量和店铺名。"""
self._check_cancelled() self._check_cancelled()
xml_data = self._dump_hierarchy(device) 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: def _wait_spec_panel(self, device: Any) -> SpecSnapshot:
"""等待点击后的规格面板真正出现,不能只依赖固定延时。""" """等待点击后的规格面板真正出现,不能只依赖固定延时。"""
+135 -2
View File
@@ -60,6 +60,25 @@ class FakeCollectDevice:
self.swipe_panel_states.append(self.panel_open) self.swipe_panel_states.append(self.panel_open)
class GoodsMetadataScrollDevice(FakeCollectDevice):
"""模拟评价和店铺分别出现在商品页后续屏幕。"""
def __init__(self, pages, spec_xml):
super().__init__(pages[0], spec_xml)
self.pages = pages
self.page_index = 0
def dump_hierarchy(self):
if self.panel_open:
return self.spec_xml
return self.pages[self.page_index]
def swipe(self, *args, **kwargs):
super().swipe(*args, **kwargs)
if not self.panel_open:
self.page_index = min(self.page_index + 1, len(self.pages) - 1)
class LoadingDevice(FakeCollectDevice): class LoadingDevice(FakeCollectDevice):
def dump_hierarchy(self): def dump_hierarchy(self):
return '<hierarchy><node text="正在加载" /></hierarchy>' return '<hierarchy><node text="正在加载" /></hierarchy>'
@@ -251,6 +270,25 @@ class PddCollectParserTest(unittest.TestCase):
self.assertTrue(result.sales.approximate) self.assertTrue(result.sales.approximate)
self.assertEqual(result.reviews.value, 2356) self.assertEqual(result.reviews.value, 2356)
def test_parse_review_title_and_shop_name_next_to_enter_shop(self):
xml_data = """<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]">
<node class="android.widget.TextView" text="商品评价(191)"
bounds="[30,700][300,760]" />
<node class="android.widget.TextView" text="承诺假一赔十"
bounds="[30,1050][300,1110]" />
<node class="android.widget.TextView" text="小熊服饰店"
bounds="[300,1200][650,1260]" />
<node class="android.widget.TextView" text="进店"
bounds="[850,1200][1030,1260]" />
</node>
</hierarchy>"""
result = parse_goods_page(xml_data)
self.assertEqual(result.reviews.value, 191)
self.assertEqual(result.shop_name, "小熊服饰店")
def test_parse_spec_panel_uses_generic_dimensions_and_cents(self): def test_parse_spec_panel_uses_generic_dimensions_and_cents(self):
result = parse_spec_panel(self.spec_xml) result = parse_spec_panel(self.spec_xml)
self.assertEqual(result.price_cent, 1000) self.assertEqual(result.price_cent, 1000)
@@ -328,6 +366,70 @@ class PddCollectParserTest(unittest.TestCase):
self.assertEqual(data["source"]["device_address"], "USB-001") self.assertEqual(data["source"]["device_address"], "USB-001")
self.assertIsNone(data["purchase"]) self.assertIsNone(data["purchase"])
def test_collect_scrolls_before_spec_panel_and_accumulates_metadata(self):
initial_page = self.home_xml.replace(
'<node class="android.widget.TextView" text="2356条评价" '
'bounds="[330,950][600,1010]" visible-to-user="true" '
'enabled="true" />',
"",
).replace(
'<node class="android.widget.TextView" text="店铺:测试服饰旗舰店" '
'bounds="[30,1050][600,1110]" visible-to-user="true" '
'enabled="true" />',
"",
)
buy_button = """
<node class="android.view.ViewGroup" content-desc="¥10.00立即购买"
clickable="true" bounds="[500,1700][1080,1950]"
visible-to-user="true" enabled="true">
<node class="android.widget.TextView" text="¥10.00"
bounds="[650,1730][850,1810]"
visible-to-user="true" enabled="true" />
<node class="android.widget.TextView" text="立即购买"
bounds="[650,1820][900,1900]"
visible-to-user="true" enabled="true" />
</node>
"""
review_page = f"""<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]">
<node class="android.widget.TextView" text="商品评价(191)"
bounds="[30,700][300,760]" />
{buy_button}
</node>
</hierarchy>"""
shop_page = f"""<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]">
<node class="android.widget.TextView" text="小熊服饰店"
bounds="[300,1200][650,1260]" />
<node class="android.widget.TextView" text="进店"
bounds="[850,1200][1030,1260]" />
{buy_button}
</node>
</hierarchy>"""
device = GoodsMetadataScrollDevice(
[initial_page, review_page, shop_page],
keep_only_one_sku(self.spec_xml),
)
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
max_spec_swipes=0,
)
result = service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
)
self.assertEqual(result.title, "测试纯棉短袖商品")
self.assertEqual(result.sales.value, 12000)
self.assertEqual(result.reviews.value, 191)
self.assertEqual(result.shop_name, "小熊服饰店")
self.assertEqual(device.swipe_panel_states[:2], [False, False])
self.assertEqual(device.page_index, 2)
self.assertTrue(device.clicks)
def test_pdd_hierarchy_is_ready_even_when_focused_package_is_settings(self): def test_pdd_hierarchy_is_ready_even_when_focused_package_is_settings(self):
pdd_home_xml = self.home_xml.replace( pdd_home_xml = self.home_xml.replace(
"<node ", '<node package="com.xunmeng.pinduoduo" ' "<node ", '<node package="com.xunmeng.pinduoduo" '
@@ -593,7 +695,7 @@ class PddCollectParserTest(unittest.TestCase):
) )
self.assertIsNone(result.shop_name) self.assertIsNone(result.shop_name)
def test_missing_reviews_does_not_scroll_before_opening_spec_panel(self): def test_missing_reviews_stops_when_goods_page_no_longer_changes(self):
home_without_reviews = self.home_xml.replace( home_without_reviews = self.home_xml.replace(
'<node class="android.widget.TextView" text="2356条评价" ' '<node class="android.widget.TextView" text="2356条评价" '
'bounds="[330,950][600,1010]" visible-to-user="true" ' 'bounds="[330,950][600,1010]" visible-to-user="true" '
@@ -617,7 +719,38 @@ class PddCollectParserTest(unittest.TestCase):
self.assertIsNone(result.reviews.raw) self.assertIsNone(result.reviews.raw)
self.assertTrue(device.clicks) self.assertTrue(device.clicks)
self.assertTrue(all(device.swipe_panel_states)) self.assertEqual(device.swipe_panel_states[:2], [False, False])
def test_cancel_during_goods_page_scroll_stops_before_spec_panel(self):
incomplete_home = self.home_xml.replace(
'<node class="android.widget.TextView" text="2356条评价" '
'bounds="[330,950][600,1010]" visible-to-user="true" '
'enabled="true" />',
"",
).replace(
'<node class="android.widget.TextView" text="店铺:测试服饰旗舰店" '
'bounds="[30,1050][600,1110]" visible-to-user="true" '
'enabled="true" />',
"",
)
cancelled = {"value": False}
device = FakeCollectDevice(incomplete_home, self.spec_xml)
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: cancelled.update(value=True),
cancelled=lambda: cancelled["value"],
)
with self.assertRaises(PddCollectError) as raised:
service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
)
self.assertEqual(raised.exception.code, "PDD_CANCELLED")
self.assertEqual(device.swipe_panel_states, [False])
self.assertEqual(device.clicks, [])
def test_spec_panel_timeout_has_stable_code_and_xml_evidence(self): def test_spec_panel_timeout_has_stable_code_and_xml_evidence(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
+1 -1
View File
@@ -48,7 +48,7 @@ Client 顶级导航仅包含:
- 商品编号、商品链接和商品标题; - 商品编号、商品链接和商品标题;
- 店铺名称(无障碍树未提供时允许留空并保存诊断证据); - 店铺名称(无障碍树未提供时允许留空并保存诊断证据);
- 已拼数量及原始显示文字; - 已拼数量及原始显示文字;
- 评价数量及原始显示文字(当前首页未提供时允许留空,不能为此反复滚动并阻塞规格采集); - 评价数量及原始显示文字(首屏没有时,在进入规格面板前有限次缓慢向下浏览补采;到达页面底部或次数上限后仍没有时允许留空);
- 所有规格维度及其可见值; - 所有规格维度及其可见值;
- 每个颜色对应的价格、币种,以及当前控件树显示的规格可用状态; - 每个颜色对应的价格、币种,以及当前控件树显示的规格可用状态;
- 采集时间、设备及必要诊断产物引用。 - 采集时间、设备及必要诊断产物引用。
+7 -5
View File
@@ -354,15 +354,17 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview
PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面、库存变化和价格变化。适配层必须返回结构化错误,不得把这些情况统一返回 `False`。 PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面、库存变化和价格变化。适配层必须返回结构化错误,不得把这些情况统一返回 `False`。
采集商品时按“首页就绪 → 读取当前首页摘要 → 点击规格入口 → 确认规格面板 采集商品时按“首页就绪 → 读取当前首页摘要 → 有限滚动补采评价和店铺 →
出现 → 颜色列表归左 → 按行蛇形逐色点击并采价 → 向下滚动并只读尺码 → 点击规格入口 → 确认规格面板出现 → 颜色列表归左 → 按行蛇形逐色点击并采价 →
组装结果”的顺序执行。颜色点击可能改变列表位置,因此每次点击后必须重新读取 向下滚动并只读尺码 → 组装结果”的顺序执行。颜色点击可能改变列表位置,因此每次点击后必须重新读取
控件树,不能复用旧坐标;左右边缘以连续两次没有新颜色且视口稳定为准。 控件树,不能复用旧坐标;左右边缘以连续两次没有新颜色且视口稳定为准。
当前业务价格粒度固定为颜色。第一行从左到右、下一行从右到左交替遍历,操作 当前业务价格粒度固定为颜色。第一行从左到右、下一行从右到左交替遍历,操作
顺序采用蛇形以减少滑动,输出维度仍恢复为页面每行从左到右的自然顺序。尺码 顺序采用蛇形以减少滑动,输出维度仍恢复为页面每行从左到右的自然顺序。尺码
只读取文字和当前可用状态,不点击、不参与采价。打开规格面板前不得为了寻找 只读取文字和当前可用状态,不点击、不参与采价。商品首屏的标题和已拼数量必须
评价或店铺连续滚动商品详情;这两个可选字段缺失时保存证据,但不阻塞核心采集。 保留;首屏缺少评价或店铺时,打开规格面板前小幅、慢速向下浏览并分别累积这两个
字段,不要求它们同时出现在一屏。获取完整、页面不再变化或达到次数上限后停止,
字段仍缺失时保存证据,但不阻塞核心规格采集。
## 10. 关键架构决策 ## 10. 关键架构决策