diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomation.kt index bee6d61..c6c6b6a 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomation.kt @@ -44,10 +44,17 @@ object ImageSearchRecoveryPolicy { /** 归位动作上限,参考 cmroubao_old 的经验值,防止死循环。 */ const val MAX_RECOVERY_ACTIONS = 50 + /** + * 首页搜索栏的 content-desc,真机实测 `[381,166][765,223]` TextView 携带。 + * 只用于证明"当前确实在带搜索栏的首页",不作为点击目标。 + */ + const val SEARCH_BAR_CONTENT_DESCRIPTION = "搜索" + fun classify( packageName: String?, visibleTexts: Collection, sortControlCount: Int, + hasClickableCameraEntry: Boolean, ): ImageSearchPageKind { if (packageName != PDD_PACKAGE) return ImageSearchPageKind.NON_PDD if (PinduoduoImageSearchCriteria.isRetryDialog(visibleTexts)) return ImageSearchPageKind.RETRY_DIALOG @@ -56,7 +63,12 @@ object ImageSearchRecoveryPolicy { } if (PinduoduoImageSearchCriteria.isImageSearchEntryPage(visibleTexts)) return ImageSearchPageKind.IMAGE_SEARCH_ENTRY if (visibleTexts.any { text -> ORDER_CONFIRM_MARKERS.any(text::contains) }) return ImageSearchPageKind.ORDER_CONFIRM - if (visibleTexts.any { it == PinduoduoImageSearchCriteria.CAMERA_ENTRY_CONTENT_DESCRIPTION }) return ImageSearchPageKind.HOME + // `[必须]` HOME 只能由**可点**的相机入口证明,不能只看文案。真机核对 + // (2026-09-15)发现个人中心 tab 的根节点同样带 content-desc='拍照搜索', + // 覆盖全屏且 clickable=false,而 visibleTexts() 会把 contentDescription + // 当文本收集。只看文案会把个人中心判成首页,随后几何兜底就会点到该页 + // 顶部最靠右的可点节点——实测那是「设置」按钮。 + if (hasClickableCameraEntry) return ImageSearchPageKind.HOME return ImageSearchPageKind.OTHER_PDD } @@ -152,11 +164,14 @@ class PinduoduoImageSearchAutomation( return ImageSearchAutomationResult(false, it.code, it.message) } val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS } - val kind = ImageSearchRecoveryPolicy.classify(currentPackage(), texts, sortControlCount) + val cameraEntry = uniqueCameraEntry(visible) + val kind = ImageSearchRecoveryPolicy.classify( + currentPackage(), texts, sortControlCount, hasClickableCameraEntry = cameraEntry != null, + ) when (ImageSearchRecoveryPolicy.nextAction(kind)) { ImageSearchRecoveryAction.NONE -> return ImageSearchAutomationResult(true, "OK", "已在图搜入口") ImageSearchRecoveryAction.CLICK_CAMERA -> { - val target = uniqueCameraEntry(visible) + val target = cameraEntry ?: return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "找不到唯一可点的拍照搜索入口") driver.clickFreshDetailed(target) } @@ -184,6 +199,14 @@ class PinduoduoImageSearchAutomation( } byText.singleOrNull()?.let { return it } if (byText.isNotEmpty()) return null + // `[必须]` 几何兜底只在确实看得到顶部搜索栏时才允许。否则任何 PDD 页面都会 + // 交出"顶部最靠右的可点节点",在个人中心那是「设置」。实测首页搜索栏含 + // content-desc='搜索' 的节点,个人中心没有。 + val hasSearchBar = visible.any { + it.visible && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX && + it.contentDescription?.trim() == ImageSearchRecoveryPolicy.SEARCH_BAR_CONTENT_DESCRIPTION + } + if (!hasSearchBar) return null val fallback = visible.filter { it.enabled && it.clickable && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomationTest.kt index b1357cd..04ce87f 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAutomationTest.kt @@ -15,45 +15,58 @@ class ImageSearchRecoveryPolicyTest { @Test fun `classify recognizes non-pdd package regardless of texts`() { - val kind = ImageSearchRecoveryPolicy.classify("com.android.launcher", entryTexts, 0) + val kind = ImageSearchRecoveryPolicy.classify("com.android.launcher", entryTexts, 0, hasClickableCameraEntry = false) assertEquals(ImageSearchPageKind.NON_PDD, kind) } @Test fun `classify recognizes image search entry page`() { - val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", entryTexts, 0) + val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", entryTexts, 0, hasClickableCameraEntry = false) assertEquals(ImageSearchPageKind.IMAGE_SEARCH_ENTRY, kind) } @Test fun `classify recognizes results page over other markers`() { - val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", resultsTexts, 4) + val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", resultsTexts, 4, hasClickableCameraEntry = false) assertEquals(ImageSearchPageKind.IMAGE_SEARCH_RESULTS, kind) } @Test fun `classify recognizes retry dialog`() { - val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", retryTexts, 0) + val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false) assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind) } @Test fun `classify recognizes order confirm page by read-only markers without treating them as click targets`() { - val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("提交订单", "收货地址"), 0) + val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("提交订单", "收货地址"), 0, hasClickableCameraEntry = false) assertEquals(ImageSearchPageKind.ORDER_CONFIRM, kind) // order confirm only ever maps to BACK -- never a click action. assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(kind)) } @Test - fun `classify recognizes home page via unique camera content description`() { - val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", homeTexts, 0) + fun `classify recognizes home page only when a clickable camera entry exists`() { + val kind = ImageSearchRecoveryPolicy.classify( + "com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = true, + ) assertEquals(ImageSearchPageKind.HOME, kind) } + // 真机核对(2026-09-15):个人中心 tab 的根节点也带 content-desc='拍照搜索', + // 覆盖全屏且不可点,而 visibleTexts() 会把 contentDescription 当文本收集。 + // 只看文案会把个人中心判成首页,几何兜底随后会点到「设置」。 + @Test + fun `classify refuses home when camera text is present but nothing clickable carries it`() { + val kind = ImageSearchRecoveryPolicy.classify( + "com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = false, + ) + assertEquals(ImageSearchPageKind.OTHER_PDD, kind) + } + @Test fun `classify falls back to other pdd page`() { - val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("商品详情", "加入购物车"), 0) + val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("商品详情", "加入购物车"), 0, hasClickableCameraEntry = false) assertEquals(ImageSearchPageKind.OTHER_PDD, kind) }