fix(t210): recognize current Pinduoduo result pages
This commit is contained in:
@@ -594,7 +594,7 @@ class MainActivity : ComponentActivity() {
|
||||
searchAutomation = PinduoduoSearchAutomation(
|
||||
driver = AndroidPinduoduoUiDriver(this),
|
||||
keyword = searchKeyword,
|
||||
forceKeywordEntry = boundRequirement != null
|
||||
forceKeywordEntry = true
|
||||
),
|
||||
candidateAutomation = candidateAutomation
|
||||
)
|
||||
|
||||
+9
-2
@@ -110,9 +110,16 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
node.contentDescription?.toString()?.trim() == "搜索"
|
||||
PinduoduoPage.SEARCH_RESULTS,
|
||||
PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY ->
|
||||
node.contentDescription?.toString()?.trim() == "搜索" &&
|
||||
(
|
||||
node.className?.toString()
|
||||
?.endsWith("HorizontalScrollView") == true &&
|
||||
node.contentDescription?.toString()?.trim() == "搜索"
|
||||
?.endsWith("HorizontalScrollView") == true ||
|
||||
(
|
||||
node.className?.toString()
|
||||
?.endsWith("TextView") == true &&
|
||||
!node.text.isNullOrBlank()
|
||||
)
|
||||
)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
+70
-5
@@ -32,6 +32,20 @@ data class PinduoduoUiSnapshot(
|
||||
)
|
||||
|
||||
object PinduoduoPageClassifier {
|
||||
private val legacyResultSortMarkers = setOf("综合", "销量", "价格", "筛选")
|
||||
private val modernResultCategoryMarkers = setOf(
|
||||
"推荐",
|
||||
"综合",
|
||||
"销量",
|
||||
"价格",
|
||||
"筛选",
|
||||
"手机",
|
||||
"女装",
|
||||
"百货",
|
||||
"海淘",
|
||||
"母婴",
|
||||
"车品"
|
||||
)
|
||||
private val paymentMarkers = listOf(
|
||||
"确认订单",
|
||||
"提交订单",
|
||||
@@ -86,13 +100,30 @@ object PinduoduoPageClassifier {
|
||||
normalize(element.contentDescription.orEmpty()) == "搜索"
|
||||
}
|
||||
val hasHome = normalized.any { it == "首页" }
|
||||
val hasResultSearchHeader = visibleElements.any { element ->
|
||||
val hasLegacyResultSearchHeader = visibleElements.any { element ->
|
||||
element.className.endsWith("HorizontalScrollView") &&
|
||||
normalize(element.contentDescription.orEmpty()) == "搜索"
|
||||
}
|
||||
val sortControlCount = setOf("综合", "销量", "价格", "筛选")
|
||||
val hasModernResultSearchHeader = visibleElements.any { element ->
|
||||
element.className.endsWith("TextView") &&
|
||||
normalize(element.contentDescription.orEmpty()) == "搜索" &&
|
||||
!element.text.isNullOrBlank()
|
||||
}
|
||||
val hasResultSearchHeader =
|
||||
hasLegacyResultSearchHeader || hasModernResultSearchHeader
|
||||
val legacySortControlCount = legacyResultSortMarkers
|
||||
.count { marker -> normalized.any { it == marker } }
|
||||
val hasExactQuery = visibleTexts.any { it.trim() == expectedQuery }
|
||||
val modernCategoryCount = modernResultCategoryMarkers
|
||||
.count { marker -> normalized.any { it == marker } }
|
||||
val hasHorizontalCategoryBar = visibleElements.any { element ->
|
||||
element.className.endsWith("HorizontalScrollView")
|
||||
}
|
||||
val hasResultControls =
|
||||
legacySortControlCount >= 3 ||
|
||||
(hasHorizontalCategoryBar && modernCategoryCount >= 3)
|
||||
val hasExpectedQuery = visibleTexts.any { text ->
|
||||
matchesExpectedQuery(text, expectedQuery)
|
||||
}
|
||||
val hasDetailBack = visibleElements.any { element ->
|
||||
element.clickable &&
|
||||
normalize(element.contentDescription.orEmpty()) == "返回"
|
||||
@@ -101,9 +132,9 @@ object PinduoduoPageClassifier {
|
||||
.count { marker -> normalized.any { it.contains(marker) } }
|
||||
|
||||
val page = when {
|
||||
hasExactQuery && hasResultSearchHeader && sortControlCount >= 3 ->
|
||||
hasExpectedQuery && hasResultSearchHeader && hasResultControls ->
|
||||
PinduoduoPage.SEARCH_RESULTS
|
||||
hasResultSearchHeader && sortControlCount >= 3 ->
|
||||
hasResultSearchHeader && hasResultControls ->
|
||||
PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY
|
||||
hasSearchInput && hasSubmitButton -> PinduoduoPage.SEARCH_INPUT
|
||||
hasHomeSearchEntry && hasHome -> PinduoduoPage.HOME
|
||||
@@ -120,6 +151,40 @@ object PinduoduoPageClassifier {
|
||||
private fun normalize(value: String): String =
|
||||
value.lowercase().replace(Regex("\\s+"), "")
|
||||
|
||||
private fun matchesExpectedQuery(observed: String, expected: String): Boolean {
|
||||
val normalizedObserved = normalize(observed)
|
||||
val normalizedExpected = normalize(expected)
|
||||
if (normalizedObserved == normalizedExpected) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
normalizedObserved.length >= MINIMUM_QUERY_FRAGMENT_LENGTH &&
|
||||
normalizedExpected.contains(normalizedObserved)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
val fragments = normalizedObserved
|
||||
.replace("...", "…")
|
||||
.split('…')
|
||||
.filter { it.length >= MINIMUM_ELIDED_FRAGMENT_LENGTH }
|
||||
if (fragments.size < 2) {
|
||||
return false
|
||||
}
|
||||
var nextIndex = 0
|
||||
for (fragment in fragments) {
|
||||
val index = normalizedExpected.indexOf(fragment, nextIndex)
|
||||
if (index < 0) {
|
||||
return false
|
||||
}
|
||||
nextIndex = index + fragment.length
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Collection<String>.containsAny(markers: Collection<String>): Boolean =
|
||||
any { text -> markers.any(text::contains) }
|
||||
|
||||
private const val MINIMUM_QUERY_FRAGMENT_LENGTH = 12
|
||||
private const val MINIMUM_ELIDED_FRAGMENT_LENGTH = 4
|
||||
}
|
||||
|
||||
+53
@@ -49,6 +49,59 @@ class PinduoduoPageClassifierTest {
|
||||
assertEquals(PinduoduoPage.SEARCH_RESULTS, snapshot.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modern 817 result layout identifies search results`() {
|
||||
val snapshot = classify(
|
||||
element(
|
||||
text = SEARCH_PROBE_KEYWORD,
|
||||
contentDescription = "搜索"
|
||||
),
|
||||
element(className = "android.widget.HorizontalScrollView"),
|
||||
element(text = "推荐"),
|
||||
element(text = "手机"),
|
||||
element(text = "女装"),
|
||||
element(text = "百货")
|
||||
)
|
||||
|
||||
assertEquals(PinduoduoPage.SEARCH_RESULTS, snapshot.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `elided result query identifies long submitted search`() {
|
||||
val submittedQuery =
|
||||
"現貨+預購 有口袋直筒褲 長褲 褲子 高腰直筒褲 春夏秋長褲 高腰寬褲 黑色長褲 " +
|
||||
"高腰長褲 顯瘦西裝褲 遮肚女裝 冰絲褲 直筒褲(夏季薄款有口袋),9XL【建議130-150公斤】(預購)"
|
||||
val snapshot = PinduoduoPageClassifier.classify(
|
||||
foregroundPackage = PINDUODUO_PACKAGE,
|
||||
elements = listOf(
|
||||
element(
|
||||
text = "現貨+預購 有…斤】(預購)",
|
||||
contentDescription = "搜索"
|
||||
),
|
||||
element(className = "android.widget.HorizontalScrollView"),
|
||||
element(text = "推荐"),
|
||||
element(text = "手机"),
|
||||
element(text = "女装")
|
||||
),
|
||||
expectedQuery = submittedQuery
|
||||
)
|
||||
|
||||
assertEquals(PinduoduoPage.SEARCH_RESULTS, snapshot.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modern result layout with another query remains distinct`() {
|
||||
val snapshot = classify(
|
||||
element(text = "直筒裤有口袋女", contentDescription = "搜索"),
|
||||
element(className = "android.widget.HorizontalScrollView"),
|
||||
element(text = "推荐"),
|
||||
element(text = "手机"),
|
||||
element(text = "女装")
|
||||
)
|
||||
|
||||
assertEquals(PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY, snapshot.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login verification and risk markers remain distinct`() {
|
||||
assertEquals(
|
||||
|
||||
+11
-5
@@ -5,10 +5,10 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 阶段:T-207 App 本地 VLM、候选、证据和结果回传完成;T-209 登录失败保留输入完成,
|
||||
下一步 T-208
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-206
|
||||
均已纳入 Git 历史;T-207 待本次提交
|
||||
- 阶段:T-207 App 本地 VLM、候选、证据和结果回传完成;T-209 登录失败保留输入完成;
|
||||
T-210 拼多多 8.17 结果页兼容完成,下一步 T-208
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、
|
||||
T-210 均已纳入 Git 历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||
|
||||
@@ -65,6 +65,11 @@
|
||||
`8.17.0 (81700)`
|
||||
- 设备就绪:肉包采购无障碍已启用并连接;拼多多首页、搜索输入、固定词结果页、
|
||||
双列候选卡、详情截图和返回均已通过 8.17.0 真机验证
|
||||
- 拼多多 8.17 兼容:结果页可识别新版显示查询词的文本搜索栏和“推荐、手机、女装”等
|
||||
分类栏,也保留旧版“综合、销量、价格、筛选”识别;长任务搜索词的拼多多省略显示须
|
||||
至少两个按序片段对应原词才通过。每次候选探针会重写本次搜索词,不复用已有结果页。
|
||||
PKG110 真机已验证确认结果页完成、5 份候选证据采集、详情截图及返回结果页;未进入
|
||||
下单或支付页面。
|
||||
- 版本控制内测试数据:仅有脱敏、运行时生成的单元测试 fixture;没有真实订单内容
|
||||
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
|
||||
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
||||
@@ -95,6 +100,7 @@
|
||||
| `docs/tasks/T-206.md` | DONE | App 领取、默认 30 分钟有限离线执行、恢复和安全停止 |
|
||||
| `docs/tasks/T-207.md` | DONE | App 本地 VLM、Key 加密、候选、事件、截图和结果回传 |
|
||||
| `docs/tasks/T-209.md` | DONE | 登录失败保留采购员和设备输入以便直接重试 |
|
||||
| `docs/tasks/T-210.md` | DONE | 兼容拼多多 8.17 搜索结果页与长词省略显示 |
|
||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||
@@ -105,7 +111,7 @@
|
||||
|
||||
## 任务摘要
|
||||
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209。
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、T-210。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:T-208 候选决策数据与人工理由闭环。
|
||||
- 后置任务:T-208 候选决策数据与人工理由闭环;不得跳过 T-206/T-207 提前实现。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: T-210
|
||||
title: 兼容拼多多 8.17 搜索结果页识别
|
||||
phase: 2
|
||||
deps:
|
||||
- T-101
|
||||
- T-206
|
||||
status: DONE
|
||||
created: 2026-07-27
|
||||
context_ref: 6d70eee
|
||||
work_branch: null
|
||||
write_paths:
|
||||
- android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
|
||||
- android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt
|
||||
- android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt
|
||||
- android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt
|
||||
- android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomationTest.kt
|
||||
- docs/tasks/T-210.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
PKG110 真机在“开始候选探针”后已进入拼多多 8.17.0 的商品结果列表,但 App 在
|
||||
“确认结果页”停止并显示“页面无法确认,已停止”。现有分类器只接受旧版“综合、销量、
|
||||
价格、筛选”结果栏和特定 `HorizontalScrollView` 搜索栏,无法识别当前真机页面。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-101、F-206 受控拼多多候选采集。
|
||||
- 交互:点击“开始候选探针”后,搜索结果页应自动进入候选证据采集;未知、登录、验证码、
|
||||
风控和支付边界仍必须停止。
|
||||
- 架构/API:仅调整 Android 本地页面分类;不修改任务 API、执行状态机或支付边界。
|
||||
|
||||
## 方案
|
||||
|
||||
使用真机结果页的无障碍结构增加新版识别:带“搜索”描述且显示查询词的文本搜索栏、
|
||||
水平分类栏以及至少三个稳定分类标记。每次探针均强制写入本次搜索词,避免复用拼多多中
|
||||
遗留的其他搜索结果;对拼多多安全省略的长搜索词,要求省略号两侧多个片段按顺序匹配,
|
||||
不把任意短词当作当前搜索。保留旧版规则与“其他查询”区分,增加回归测试。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [x] 旧版结果页测试继续识别为 `SEARCH_RESULTS`。
|
||||
- [x] 真机 8.17.0 的“推荐、手机、女装”等结构识别为 `SEARCH_RESULTS`。
|
||||
- [x] 探针遇到拼多多的其他搜索词时,会替换为本次搜索词。
|
||||
- [x] 省略显示的长搜索词仅在多个片段顺序对应本次搜索词时通过。
|
||||
- [x] 其他搜索词、登录、验证码、风控、支付与未知页边界不放宽。
|
||||
- [x] Android 单元测试、Debug 构建和 PKG110 真机候选探针 smoke 通过。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不点击商品、提交订单或支付。
|
||||
- 不扩大候选数量,不更改 VLM 或后端数据模型。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-27:真机复现。拼多多结果页搜索栏为 `TextView`、`content-desc=搜索`,
|
||||
查询词为“直筒裤有口袋女”;分类栏为 `HorizontalScrollView`,可见“推荐、手机、女装、
|
||||
百货、海淘、母婴、车品”。现有规则要求旧版“综合、销量、价格、筛选”,导致误判。
|
||||
- 2026-07-27:页面分类器加入新版搜索栏和分类栏规则,并新增新版页面、其他查询的单元
|
||||
测试。显式设置 `ANDROID_HOME`/`ANDROID_SDK_ROOT` 后,`./gradlew.bat
|
||||
:app:testDebugUnitTest :app:assembleDebug` 通过;Debug APK 已安装至 PKG110。
|
||||
- 2026-07-27:安装更新会撤销 Android 对肉包无障碍服务的运行授权。真机完整探针 smoke
|
||||
等待设备持有人在系统设置中重新确认“保持开启”;不会使用 ADB 绕过该系统授权。
|
||||
- 2026-07-27:真机复测发现后台任务搜索词最长 160 字,拼多多结果栏安全省略为
|
||||
“現貨+預購 有…斤】(預購)”。分类器改为完整相等、长连续片段或至少两个按顺序的
|
||||
省略片段匹配;任意短词和乱序片段仍不通过。另发现新版结果页搜索栏可为显示文本的
|
||||
`TextView`,点击规则已兼容其可点击父节点。每次探针强制写入本次搜索词,避免沿用
|
||||
已打开的其他查询。
|
||||
- 2026-07-27:再次运行 `$env:ANDROID_HOME='C:\Users\ila20\AppData\Local\Android\Sdk';
|
||||
$env:ANDROID_SDK_ROOT=$env:ANDROID_HOME; .\gradlew.bat :app:testDebugUnitTest
|
||||
:app:assembleDebug`,通过;最新 Debug APK 已安装至 PKG110。
|
||||
- 2026-07-27:PKG110(Android 16/API 36)、肉包 1.4.2 (7)、拼多多 8.17.0 (81700)
|
||||
真机 smoke:后台任务搜索结果页的“确认结果页”完成,日志依序记录候选的
|
||||
`OPENING_CANDIDATE -> WAITING_DETAIL -> CAPTURING_EVIDENCE -> RETURNING_RESULTS`,
|
||||
最终 `COMPLETE`。探针页显示 `候选证据 5 / 5`,确认结果页和候选采集步骤均显示完成。
|
||||
测试没有进入订单确认、提交订单或支付页面。
|
||||
Reference in New Issue
Block a user