diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt index 86f69f5..183cbbf 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt @@ -594,7 +594,7 @@ class MainActivity : ComponentActivity() { searchAutomation = PinduoduoSearchAutomation( driver = AndroidPinduoduoUiDriver(this), keyword = searchKeyword, - forceKeywordEntry = boundRequirement != null + forceKeywordEntry = true ), candidateAutomation = candidateAutomation ) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt index 96d705b..8882e7e 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt @@ -110,9 +110,16 @@ class BuyerAccessibilityService : AccessibilityService() { node.contentDescription?.toString()?.trim() == "搜索" PinduoduoPage.SEARCH_RESULTS, PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY -> - node.className?.toString() - ?.endsWith("HorizontalScrollView") == true && - node.contentDescription?.toString()?.trim() == "搜索" + node.contentDescription?.toString()?.trim() == "搜索" && + ( + node.className?.toString() + ?.endsWith("HorizontalScrollView") == true || + ( + node.className?.toString() + ?.endsWith("TextView") == true && + !node.text.isNullOrBlank() + ) + ) else -> false } } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt index aa80353..4682fe8 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt @@ -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.containsAny(markers: Collection): Boolean = any { text -> markers.any(text::contains) } + + private const val MINIMUM_QUERY_FRAGMENT_LENGTH = 12 + private const val MINIMUM_ELIDED_FRAGMENT_LENGTH = 4 } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt index 9998caa..61d6b3b 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt @@ -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( diff --git a/docs/current-state.md b/docs/current-state.md index 83c86d6..9a73481 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 提前实现。 diff --git a/docs/tasks/T-210.md b/docs/tasks/T-210.md new file mode 100644 index 0000000..b927a67 --- /dev/null +++ b/docs/tasks/T-210.md @@ -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`,确认结果页和候选采集步骤均显示完成。 + 测试没有进入订单确认、提交订单或支付页面。