fix(#277): 首页判定必须由可点相机入口证明

真机核对(2026-09-15)发现个人中心 tab 的根节点同样带
content-desc='拍照搜索',覆盖全屏 [0,0][1080,2354] 且 clickable=false;
而 GoAutoAccessibilityService.visibleTexts() 会把 contentDescription 当
文本收集。原实现只按文案判 HOME,于是个人中心被判成首页,找不到可点入口
后走几何兜底,点到该页顶部最靠右的可点节点——实测那是「设置」按钮。

两处收紧:

- classify 增加 hasClickableCameraEntry,HOME 必须由可点入口证明。
- 几何兜底增加搜索栏证据(顶部区域 content-desc='搜索' 的节点),
  没有搜索栏就返回 null 而不是交出"最靠右的可点节点"。

补充回归测试:文案存在但无可点节点时必须判为 OTHER_PDD。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-15 11:04:03 +08:00
co-authored by Claude Opus 5
parent 95be6e7569
commit c42e32d49d
2 changed files with 47 additions and 11 deletions
@@ -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<String>,
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
}
@@ -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)
}