From 95b5f05ae81c05561c8b028ae212bf681e323df8 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Tue, 15 Sep 2026 16:27:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(#277):=20=E5=9B=BE=E6=90=9C=E5=80=99?= =?UTF-8?q?=E9=80=89=E7=82=B9=E5=87=BB=E6=94=B9=E7=9E=84=E6=9C=89=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=AD=90=E8=8A=82=E7=82=B9=EF=BC=8C=E5=85=B3=E8=81=94?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=B7=A8=E5=B8=81=E7=A7=8D=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E6=8C=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机连续排查出三处缺陷,均已实测确认: 1) 候选卡片点不动。快照里 SnapshotNode.label 只取节点自身的 text/ contentDescription,而 clickFreshDetailed 重新定位时用 preferredOrDescendantLabel()(会往子孙里挖)。候选卡片是自身无文案的 FrameLayout,两者算出的 label 一个是空串一个是标题文字,永远对不上, 重找直接 TARGET_NOT_FOUND。改为瞄准卡片内最靠上的有文案子节点,由 clickFreshDetailed 既有的向上走到可点祖先逻辑落到卡片容器上。 卡片内文案的归属同时从 path 前缀改为几何包含——path 父子语义从未核对, 几何包含已用真机 dump 直接验证。 2) 错误码拆分。IMAGE_SEARCH_NO_CANDIDATES 此前同时表示“结果页没出现”和 “卡片点不动”,定位问题时完全分不清。拆为 RESULTS_NOT_READY / NO_CANDIDATES / CANDIDATE_CLICK_FAILED,并把 clickFreshDetailed 的 result 与 reason 带进失败消息——这两个值本来就有,此前被丢弃。 3) 自动关联从上线起一次都没执行过。snapshot.PriceGuardSkipped 与 shopee_product.currency != "CNY" 两处各自 return nil,而 priceSkipped 的唯一来源就是 referenceCurrency != "CNY",两者是同一个条件;线上 26902 个虾皮商品全部是 TWD。表现为图搜任务 completed、PDD 商品采集完整,但 关联停在旧值,采购因此无法创建(订单 26091536MJHXG8 即如此)。 币种不同只说明价格没法比,不说明关联不该建立,两件事已解耦。 补偿控制是 image_search_linked 标记,已显示在商品页与采购视图。 ImageSearchPriceAllowed 零调用方,加 [未接线] 标注,避免再被当成生效的 防线;待引入可配汇率后再接。 测试夹具默认币种由 CNY 改为 TWD(线上真实币种),并新增跨币种必须关联、 同币种行为不变两个用例。此前夹具用 CNY,所以整套用例全绿而线上从未成功。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../PinduoduoImageSearchAutomation.kt | 220 ++++++++++++++++-- .../PinduoduoImageSearchCriteria.kt | 9 +- .../agent/service/AgentForegroundService.kt | 18 +- .../PinduoduoImageSearchAutomationTest.kt | 85 ++++++- server/app/goauto/task/image_search.go | 7 +- server/app/goauto/task/image_search_link.go | 21 +- .../app/goauto/task/image_search_link_test.go | 42 +++- 7 files changed, 353 insertions(+), 49 deletions(-) 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 c6c6b6a..6f4a55a 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 @@ -29,7 +29,16 @@ enum class ImageSearchRecoveryAction { NONE, /** 在首页,点击"拍照搜索"入口(唯一可点节点)。 */ CLICK_CAMERA, - /** 在结果页/重试弹窗/订单确认页/其它拼多多页面,按返回逐步归位。订单确认页只允许返回, + /** + * 关闭"再试一次"弹窗。 + * + * `[必须]` 这个弹窗按返回键**无效**——2026-09-15 真机实测,按 BACK 前后两次 + * uiautomator dump 逐字节相同。唯一出口是点"取消",而点"取消"会退出图搜、 + * 回到拼多多首页,所以关闭之后仍要重新走一遍入口。 + */ + DISMISS_RETRY_DIALOG, + + /** 在结果页/订单确认页/其它拼多多页面,按返回逐步归位。订单确认页只允许返回, * 绝不点击任何支付/下单控件。 */ BACK, /** 完全不在拼多多前台,需要先拉起应用。 */ @@ -44,6 +53,15 @@ object ImageSearchRecoveryPolicy { /** 归位动作上限,参考 cmroubao_old 的经验值,防止死循环。 */ const val MAX_RECOVERY_ACTIONS = 50 + /** + * 允许关闭"再试一次"弹窗并重新进入的次数上限。 + * + * `[必须]` 进入图搜页后拼多多的摄像头会实时识别,识别不到东西约 4 秒后自己弹出 + * 该弹窗(2026-09-15 真机实测:+3s 无弹窗、+5s 有)。关闭后会回到首页,重新进入 + * 又会撞上同一个窗口,因此必须单独设上限,否则会在通用的 50 次预算里空转数分钟。 + */ + const val MAX_RETRY_DIALOG_RECOVERIES = 3 + /** * 首页搜索栏的 content-desc,真机实测 `[381,166][765,223]` TextView 携带。 * 只用于证明"当前确实在带搜索栏的首页",不作为点击目标。 @@ -76,8 +94,8 @@ object ImageSearchRecoveryPolicy { ImageSearchPageKind.IMAGE_SEARCH_ENTRY -> ImageSearchRecoveryAction.NONE ImageSearchPageKind.HOME -> ImageSearchRecoveryAction.CLICK_CAMERA ImageSearchPageKind.NON_PDD -> ImageSearchRecoveryAction.LAUNCH_PDD + ImageSearchPageKind.RETRY_DIALOG -> ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG ImageSearchPageKind.IMAGE_SEARCH_RESULTS, - ImageSearchPageKind.RETRY_DIALOG, ImageSearchPageKind.ORDER_CONFIRM, ImageSearchPageKind.OTHER_PDD, -> ImageSearchRecoveryAction.BACK @@ -114,6 +132,23 @@ data class ImageSearchResultCard( object ImageSearchCandidatePolicy { private const val AD_MARKER = "广告" + /** 节点是否完全落在卡片矩形内。 */ + fun inside(card: ImageSearchResultCard, bounds: NodeBounds): Boolean = + bounds.left >= card.left && bounds.top >= card.top && + bounds.right <= card.left + card.width && bounds.bottom <= card.top + card.height + + /** + * 卡片内最靠上的有文案节点,作为点击目标。 + * + * `[必须]` 点击目标不能是卡片容器本身。快照里 SnapshotNode.label 只取节点**自身**的 + * text/contentDescription,而 clickFreshDetailed 重新定位时用 preferredOrDescendantLabel() + * ——会往子孙里挖。候选卡片是自身无文案的 FrameLayout,两者算出的 label 一个是空串、 + * 一个是标题文字,永远对不上,重找直接返回 TARGET_NOT_FOUND。改为瞄准有文案的子节点, + * 由 clickFreshDetailed 既有的"向上走到可点祖先"逻辑落到卡片容器上。 + */ + fun clickTargetInside(card: ImageSearchResultCard, nodes: List): SnapshotNode? = + nodes.filter { it.label.isNotEmpty() && inside(card, it.bounds) }.minByOrNull { it.bounds.top } + fun firstOpenableCandidate(cards: List): ImageSearchResultCard? = cards .filter { card -> card.texts.none { it.contains(AD_MARKER) } } @@ -153,9 +188,26 @@ class PinduoduoImageSearchAutomation( private val currentPackage: () -> String?, private val pause: (Long) -> Unit = Thread::sleep, ) { - /** 归位到图搜入口页。launchPdd 由调用方传入(真机由 AgentForegroundService 提供)。 */ - fun restoreToEntry(launchPdd: () -> PddLaunchResult): ImageSearchAutomationResult { + /** + * 归位到图搜入口页并在同一次迭代内选中相册第一张。 + * + * `[必须]` 归位与选图必须合成一个循环,不能拆成 restoreToEntry() + selectFirstRecentImage() + * 两次调用。2026-09-15 真机实测:进入图搜页后拼多多的摄像头开始实时识别,识别不到东西 + * 约 4 秒后自己弹出"再试一次"弹窗,把相册网格整个遮住;而该弹窗是独立 window, + * rootInActiveWindow 只能看到弹窗的 13 个节点,底下的图搜页完全不可见。 + * + * 拆成两次调用时,中间要多一次完整的无障碍树抓取(图搜页节点很多,开销不小), + * 等第二次抓取回来时弹窗往往已经弹出,于是找不到"最近项目"锚点并报 + * IMAGE_SEARCH_ENTRY_NOT_FOUND——线上任务 262 连续四次都是这样,每次约 3 秒。 + * 手动实测在 1.8 秒内点中第一格可以干净进入结果页,所以余量存在,只是不能浪费。 + * + * 结构参考同作者的 cmroubao_old(android-buyer PinduoduoImageSearchAutomation): + * 检测到图搜页的那一刻就在当前快照上选图,并且每个动作之后等待预期页面出现, + * 而不是盲目重判——页面加载中时判据一个都不命中,会被误判成其它页面而按下返回键。 + */ + fun enterAndSelectImage(launchPdd: () -> PddLaunchResult): ImageSearchAutomationResult { var actions = 0 + var dialogRecoveries = 0 while (true) { val snapshot = driver.capture() val visible = snapshot.nodes.filter { it.visible } @@ -169,11 +221,40 @@ class PinduoduoImageSearchAutomation( currentPackage(), texts, sortControlCount, hasClickableCameraEntry = cameraEntry != null, ) when (ImageSearchRecoveryPolicy.nextAction(kind)) { - ImageSearchRecoveryAction.NONE -> return ImageSearchAutomationResult(true, "OK", "已在图搜入口") + ImageSearchRecoveryAction.NONE -> { + // 已在图搜入口页:就用刚抓到的这份快照选图,不再返回调用方、不再重新抓树。 + if (selectFirstRecentImageFrom(visible)) { + return ImageSearchAutomationResult(true, "OK", "已选中相册第一张") + } + // 选不中通常是网格还没渲染出来,等一轮重来;弹窗弹出时会在下一轮被识别并关闭。 + } ImageSearchRecoveryAction.CLICK_CAMERA -> { val target = cameraEntry ?: return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "找不到唯一可点的拍照搜索入口") driver.clickFreshDetailed(target) + // `[必须]` 点完入口后等页面稳定再决策。图搜页加载中时判据一个都不命中, + // 会被判成 OTHER_PDD 并按下返回键,等于自己把刚打开的图搜页退掉。 + val settled = awaitPage(ENTRY_SETTLE_TIMEOUT_MILLIS) { + it == ImageSearchPageKind.IMAGE_SEARCH_ENTRY || it == ImageSearchPageKind.RETRY_DIALOG + } + if (settled != null && selectFirstRecentImageFrom(settled)) { + return ImageSearchAutomationResult(true, "OK", "已选中相册第一张") + } + } + ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG -> { + dialogRecoveries++ + if (dialogRecoveries > ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES) { + return ImageSearchAutomationResult( + false, "IMAGE_SEARCH_CAMERA_BLOCKED", + "拼多多摄像头识别反复失败,图搜页始终被重试弹窗占据", + ) + } + if (!dismissRetryDialog(visible)) { + return ImageSearchAutomationResult( + false, "IMAGE_SEARCH_CAMERA_BLOCKED", + "无法关闭重试弹窗,请人工处理后重试", + ) + } } ImageSearchRecoveryAction.BACK -> driver.back() ImageSearchRecoveryAction.LAUNCH_PDD -> { @@ -186,12 +267,49 @@ class PinduoduoImageSearchAutomation( } actions++ if (ImageSearchRecoveryPolicy.exceedsRecoveryBudget(actions)) { - return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "归位到拍照搜索入口失败,请人工检查") + return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "归位到图搜入口并选中参考图失败,请人工检查") } pause(RECOVERY_STEP_MILLIS) } } + /** + * 轮询直到页面稳定为 [accept] 接受的形态,命中时返回**那一次抓取**的可见节点, + * 供调用方直接使用,避免为了拿到同一个页面再抓一次树。超时返回 null。 + */ + private fun awaitPage( + timeoutMillis: Long, + accept: (ImageSearchPageKind) -> Boolean, + ): List? { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() <= deadline) { + val snapshot = driver.capture() + val visible = snapshot.nodes.filter { it.visible } + val texts = visible.map { it.label } + val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS } + val kind = ImageSearchRecoveryPolicy.classify( + currentPackage(), texts, sortControlCount, + hasClickableCameraEntry = uniqueCameraEntry(visible) != null, + ) + if (accept(kind)) return visible + pause(POLL_INTERVAL_MILLIS) + } + return null + } + + /** + * 点"取消"关闭"再试一次"弹窗。 + * + * `[必须]` 不用 BACK:真机实测 BACK 对该弹窗完全无效。也不点"再试一次"——那会 + * 重新启动摄像头识别,几秒后再弹一次,形成死循环。 + */ + private fun dismissRetryDialog(visible: List): Boolean { + val cancel = visible.firstOrNull { + it.enabled && it.clickable && it.label == PinduoduoImageSearchCriteria.RETRY_DIALOG_CANCEL_TEXT + } ?: return false + return driver.clickFreshDetailed(cancel).result == FreshActionResult.SUCCESS + } + /** 只保留唯一同时满足 content-desc 和 clickable 的节点;否则按几何兜底(顶部区域最靠右的可点节点)。 */ private fun uniqueCameraEntry(visible: List): SnapshotNode? { val byText = visible.filter { @@ -213,10 +331,13 @@ class PinduoduoImageSearchAutomation( return fallback.maxByOrNull { it.bounds.right } } - /** 定位"最近项目"锚点下方的网格,点击第一格。 */ - fun selectFirstRecentImage(): Boolean { - val snapshot = driver.capture() - val visible = snapshot.nodes.filter { it.visible } + /** + * 在**已经抓好**的快照上定位"最近项目"锚点下方的网格并点击第一格。 + * + * `[必须]` 接收 visible 而不是自己 capture:见 [enterAndSelectImage] 的说明, + * 多一次抓取就可能错过 4 秒窗口。 + */ + fun selectFirstRecentImageFrom(visible: List): Boolean { val anchor = visible.firstOrNull { it.label.contains(PinduoduoImageSearchCriteria.RECENT_ITEMS_LABEL) } ?: return false val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return false val cells = visible @@ -231,10 +352,14 @@ class PinduoduoImageSearchAutomation( } /** - * 轮询直到进入结果页;若出现重试弹窗则点击"再试一次"继续等待。`[未验证]`:重试弹窗分支未在 - * 真机触发过,见 [PinduoduoImageSearchCriteria] 顶部注释。 + * 轮询直到进入结果页。 + * + * `[必须]` 出现"再试一次"弹窗时**不点"再试一次"**:那会重启摄像头识别、几秒后再弹一次。 + * 这里直接判失败交回调用方,由 restoreToEntry 走关闭并重新进入的路径(有独立次数上限)。 */ fun waitForResultsPage(timeoutMillis: Long = DEFAULT_RESULTS_TIMEOUT_MILLIS): Boolean { + // 失败由调用方映射为 IMAGE_SEARCH_RESULTS_NOT_READY,与"结果页出来了但候选点不动" + // 区分开——两者此前共用 IMAGE_SEARCH_NO_CANDIDATES,定位问题时完全分不清。 val deadline = System.currentTimeMillis() + timeoutMillis while (System.currentTimeMillis() <= deadline) { val snapshot = driver.capture() @@ -242,46 +367,91 @@ class PinduoduoImageSearchAutomation( val texts = visible.map { it.label } val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS } if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(texts, sortControlCount)) return true - if (PinduoduoImageSearchCriteria.isRetryDialog(texts)) { - val retry = visible.firstOrNull { it.label == PinduoduoImageSearchCriteria.RETRY_DIALOG_CONFIRM_TEXT && it.clickable } - if (retry != null) driver.clickFreshDetailed(retry) - } + if (PinduoduoImageSearchCriteria.isRetryDialog(texts)) return false pause(RECOVERY_STEP_MILLIS) } return false } /** 打开结果页第一个非广告候选卡片;容忍卡片内图片尚未加载完成。 */ - fun openFirstCandidate(): Boolean { + fun openFirstCandidate(): ImageSearchAutomationResult { val snapshot = driver.capture() val visible = snapshot.nodes.filter { it.visible } val cardContainers = visible.filter { node -> node.clickable && - node.width6xHeight() && node.bounds.width in CARD_WIDTH_RANGE && node.bounds.height in CARD_HEIGHT_RANGE } + if (cardContainers.isEmpty()) { + return ImageSearchAutomationResult( + false, "IMAGE_SEARCH_NO_CANDIDATES", "结果页没有可识别的商品卡片", + ) + } + // `[必须]` 卡片内文案按**几何包含**判定,不用 path 前缀。path 的父子语义从未核对过, + // 而 bounds 包含在 2026-09-15 的真机 dump 上直接验证过:四张 535x764 卡片各自的 + // 标题、价格、店铺文案都能正确归位。 val cards = cardContainers.map { container -> - val descendantTexts = visible.filter { it.path.startsWith("${container.path}/") }.map { it.label } ImageSearchResultCard( top = container.bounds.top, left = container.bounds.left, width = container.bounds.width, height = container.bounds.height, - texts = descendantTexts, + texts = visible.filter { + it.label.isNotEmpty() && ImageSearchCandidatePolicy.inside( + ImageSearchResultCard(container.bounds.top, container.bounds.left, container.bounds.width, container.bounds.height, emptyList()), + it.bounds, + ) + }.map { it.label }, ) } - val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards) ?: return false - val node = cardContainers.firstOrNull { - it.bounds.top == chosen.top && it.bounds.left == chosen.left && it.bounds.width == chosen.width && it.bounds.height == chosen.height - } ?: return false - return driver.clickFreshDetailed(node).result == FreshActionResult.SUCCESS + val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards) + ?: return ImageSearchAutomationResult( + false, "IMAGE_SEARCH_NO_CANDIDATES", + "结果页 ${cards.size} 张卡片全部是广告,没有可采集的候选", + ) + val container = cardContainers.firstOrNull { + it.bounds.top == chosen.top && it.bounds.left == chosen.left && + it.bounds.width == chosen.width && it.bounds.height == chosen.height + } ?: return ImageSearchAutomationResult( + false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "选中的候选卡片在点击前消失", + ) + + // `[必须]` 不能直接点卡片容器。快照里 SnapshotNode.label 只取节点**自身**的 + // text/contentDescription,而 clickFreshDetailed 重新定位时用的是 + // preferredOrDescendantLabel()——会往子孙里挖。候选卡片是自身无文案的 + // FrameLayout,两者算出的 label 一个是空串、一个是标题文字,永远对不上, + // 重找直接返回 TARGET_NOT_FOUND(2026-09-15 真机:任务 262 卡在这里)。 + // 改为瞄准卡片内有文案的子节点,由 clickFreshDetailed 既有的"向上走到可点祖先" + // 逻辑落到卡片容器上。 + val labelled = ImageSearchCandidatePolicy.clickTargetInside(chosen, visible) + ?: return ImageSearchAutomationResult( + false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "候选卡片内没有可定位的文案节点", + ) + val outcome = driver.clickFreshDetailed(labelled) + if (outcome.result != FreshActionResult.SUCCESS) { + return ImageSearchAutomationResult( + false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", + "候选卡片点击失败[result=${outcome.result};reason=${outcome.reason}]", + ) + } + return ImageSearchAutomationResult(true, "OK", "已打开第一个候选") } + private fun SnapshotNode.width6xHeight(): Boolean = bounds.width > 0 && bounds.height > 0 private companion object { const val RECOVERY_STEP_MILLIS = 300L + + /** 动作之间的轮询间隔,取自 cmroubao_old 的实测值。 */ + const val POLL_INTERVAL_MILLIS = 200L + + /** + * 点击图搜入口后等待页面稳定的上限。 + * + * `[必须]` 必须明显短于摄像头自动弹出重试弹窗的约 4 秒,否则等到的就是弹窗而不是图搜页。 + */ + const val ENTRY_SETTLE_TIMEOUT_MILLIS = 2_500L const val DEFAULT_RESULTS_TIMEOUT_MILLIS = 15_000L val SORT_CONTROL_LABELS = setOf("综合", "销量", "价格", "品牌") val CARD_WIDTH_RANGE = 400..700 diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt index 610dd57..1e61cf1 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt @@ -34,8 +34,13 @@ object PinduoduoImageSearchCriteria { /** * "再试一次"弹窗文案。 * - * `[未验证]` 2026-09-15 的真机核对中该弹窗没有出现(识别成功,直接进结果页), - * 这三个常量仍是移植值。接线时若发现识别失败走了别的形态,需要重新 dump。 + * `[已验证]` 2026-09-15 真机复现,三个常量全部命中。进入图搜页后摄像头会 + * 实时识别,识别不到东西约 4 秒后拼多多**自己**弹出该弹窗(+3s 无、+5s 有), + * 不需要我们点任何东西。它会遮住相册网格。 + * + * `[必须]` 按 BACK 对该弹窗**完全无效**(前后两次 dump 逐字节相同);点「取消」 + * 可以关闭,但会退出图搜、回到拼多多首页。实测在 1.8 秒内点中相册第一格 + * 可以赶在弹窗之前干净进入结果页。 */ const val RETRY_DIALOG_CANCEL_TEXT = "取消" const val RETRY_DIALOG_CONFIRM_TEXT = "再试一次" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 1e6ffdc..ea258bf 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -421,7 +421,10 @@ class AgentForegroundService : Service() { private fun imageSearchUserMessage(code: String): String = when (code) { "IMAGE_SEARCH_PERMISSION_REQUIRED" -> "未授予相册权限,无法准备图搜参考图,请到“状态”页开启相册权限后重试。" "IMAGE_SEARCH_ENTRY_NOT_FOUND" -> "打不开拍照搜索入口,请检查 PDD App 版本或手动确认后重试。" + "IMAGE_SEARCH_RESULTS_NOT_READY" -> "图搜结果页没有如期出现,请稍后重试。" + "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED" -> "搜到了商品但打不开,请稍后重试或人工关联。" "IMAGE_SEARCH_NO_CANDIDATES" -> "没有搜到相似商品,请确认参考图清晰或换一张图后重试。" + "IMAGE_SEARCH_CAMERA_BLOCKED" -> "拼多多摄像头一直识别不到东西,图搜页反复弹出重试提示。请把手机对着有内容的画面后重试。" "IMAGE_SEARCH_ASSET_INVALID" -> "图片准备失败,请重新发起图搜任务。" "IMAGE_SEARCH_ASSET_NOT_LATEST" -> "相册里有更新的照片或视频,无法确认图搜会用到我们准备的图片,请清理相册后重试。" "IMAGE_SEARCH_AUTOMATION_UNAVAILABLE" -> "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务。" @@ -757,16 +760,17 @@ class AgentForegroundService : Service() { throw TaskFailure("IMAGE_SEARCH_ASSET_NOT_LATEST", imageSearchUserMessage("IMAGE_SEARCH_ASSET_NOT_LATEST")) } val imageSearch = PinduoduoImageSearchAutomation(accessibility, accessibility::currentPackage) - val entry = imageSearch.restoreToEntry(::launchPddToForeground) + // `[必须]` 归位与选图是同一个调用:中间多一次无障碍树抓取就可能错过 + // 拼多多摄像头自动弹出重试弹窗前的约 4 秒窗口,详见 enterAndSelectImage。 + val entry = imageSearch.enterAndSelectImage(::launchPddToForeground) if (!entry.successful) throw TaskFailure(entry.code, imageSearchUserMessage(entry.code).takeIf { entry.code.startsWith("IMAGE_SEARCH") } ?: entry.message) - if (!imageSearch.selectFirstRecentImage()) { - throw TaskFailure("IMAGE_SEARCH_ENTRY_NOT_FOUND", imageSearchUserMessage("IMAGE_SEARCH_ENTRY_NOT_FOUND")) - } if (!imageSearch.waitForResultsPage()) { - throw TaskFailure("IMAGE_SEARCH_NO_CANDIDATES", imageSearchUserMessage("IMAGE_SEARCH_NO_CANDIDATES")) + throw TaskFailure("IMAGE_SEARCH_RESULTS_NOT_READY", imageSearchUserMessage("IMAGE_SEARCH_RESULTS_NOT_READY")) } - if (!imageSearch.openFirstCandidate()) { - throw TaskFailure("IMAGE_SEARCH_NO_CANDIDATES", imageSearchUserMessage("IMAGE_SEARCH_NO_CANDIDATES")) + val candidate = imageSearch.openFirstCandidate() + if (!candidate.successful) { + // 保留执行器给出的具体原因(含 result/reason),排查时不必再猜卡在哪一步。 + throw TaskFailure(candidate.code, "${imageSearchUserMessage(candidate.code)}[${candidate.message}]") } // 接回现有的分享链接识别 + 商品详情采集链路(与 agent_current_page 完全一致)。 val identityResult = CurrentPageIdentityRunner( 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 04ce87f..b74f46c 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 @@ -76,7 +76,8 @@ class ImageSearchRecoveryPolicyTest { assertEquals(ImageSearchRecoveryAction.CLICK_CAMERA, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.HOME)) assertEquals(ImageSearchRecoveryAction.LAUNCH_PDD, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.NON_PDD)) assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_RESULTS)) - assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.RETRY_DIALOG)) + // RETRY_DIALOG 不再是 BACK,理由见下方 retry dialog must be dismissed by cancel。 + assertEquals(ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.RETRY_DIALOG)) assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.OTHER_PDD)) } @@ -85,6 +86,48 @@ class ImageSearchRecoveryPolicyTest { assertFalse(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS - 1)) assertTrue(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS)) } + + // `[必须]` 2026-09-15 真机实测:按 BACK 对这个弹窗完全无效,前后两次 uiautomator + // dump 逐字节相同;唯一出口是点「取消」。此前把它映射成 BACK,导致任务在弹窗上 + // 空转直到预算耗尽、报 IMAGE_SEARCH_ENTRY_NOT_FOUND,而弹窗一直留在屏幕上。 + @Test + fun `retry dialog must be dismissed by cancel, never by back`() { + val kind = ImageSearchRecoveryPolicy.classify( + "com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false, + ) + assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind) + assertEquals( + ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG, + ImageSearchRecoveryPolicy.nextAction(kind), + ) + } + + // 其余需要归位的页面仍然用返回,不受上面那条影响。 + @Test + fun `other pages still recover by back`() { + for (kind in listOf( + ImageSearchPageKind.IMAGE_SEARCH_RESULTS, + ImageSearchPageKind.ORDER_CONFIRM, + ImageSearchPageKind.OTHER_PDD, + )) { + assertEquals( + "$kind 的归位动作被改动了", + ImageSearchRecoveryAction.BACK, + ImageSearchRecoveryPolicy.nextAction(kind), + ) + } + } + + // 关闭弹窗会退出图搜回到首页,重新进入又会撞上同一个约 4 秒的摄像头识别窗口, + // 因此必须有独立且很小的上限,不能落在通用的 50 次预算里空转数分钟。 + @Test + fun `retry dialog recovery budget is separate and small`() { + assertTrue( + "弹窗恢复上限必须显著小于通用恢复预算", + ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES in + 1 until ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS / 5, + ) + } } class ImageSearchCandidatePolicyTest { @@ -132,6 +175,46 @@ class ImageSearchCandidatePolicyTest { } } +class ImageSearchClickTargetTest { + + private fun node(top: Int, left: Int, right: Int, bottom: Int, text: String?) = SnapshotNode( + path = "0/$top/$left", parentPath = "0", text = text, contentDescription = null, + resourceId = null, className = "android.widget.TextView", + bounds = NodeBounds(left, top, right, bottom), + clickable = false, scrollable = false, selected = false, checked = false, + enabled = true, visible = true, + ) + + // 真机 dump(2026-09-15)的第一张卡片:[0,600] 535x764。 + private val card = ImageSearchResultCard(top = 600, left = 0, width = 535, height = 764, texts = emptyList()) + + // `[必须]` 卡片容器自身无文案,label 为空串;clickFreshDetailed 重找时用的是 + // preferredOrDescendantLabel()(会挖子孙),两者永远对不上,直接 TARGET_NOT_FOUND。 + // 所以点击目标必须是卡片内有文案的子节点。 + @Test + fun `click target is the topmost labelled node inside the card`() { + val nodes = listOf( + node(600, 0, 535, 1364, null), // 卡片容器本身,无文案 + node(1100, 20, 300, 1140, "¥21.8"), + node(1000, 20, 500, 1060, "港风时尚"), // 最靠上的有文案节点 + ) + val target = ImageSearchCandidatePolicy.clickTargetInside(card, nodes) + assertEquals("港风时尚", target?.label) + } + + // 相邻卡片的文案不得被当成本卡片的内容。真机上右侧卡片从 x=545 开始。 + @Test + fun `nodes outside the card bounds are ignored`() { + val nodes = listOf(node(1000, 565, 1040, 1060, "森系假两件")) + assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, nodes)) + } + + @Test + fun `card without any labelled child yields no target`() { + assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, listOf(node(700, 10, 500, 900, null)))) + } +} + class ImageSearchMediaRecencyPolicyTest { @Test diff --git a/server/app/goauto/task/image_search.go b/server/app/goauto/task/image_search.go index c6393d9..887308e 100644 --- a/server/app/goauto/task/image_search.go +++ b/server/app/goauto/task/image_search.go @@ -218,8 +218,11 @@ func loadImageSearchSnapshot(record models.CollectionTask) (ImageSearchSnapshot, } // ImageSearchPriceAllowed deliberately refuses to compare unlike currencies. -// A skipped check is returned as comparable=false so callers must require -// manual confirmation before automatic association. +// A skipped check is returned as comparable=false. +// +// `[未接线]` 这个函数目前**没有任何调用方**。它不是一道正在生效的防线——线上 +// 虾皮商品全部是 TWD 而 PDD 是 CNY,没有汇率就无法比较。保留它是为了将来引入 +// 可配汇率后接上;在那之前不要把它当成已有的价格保护。 func ImageSearchPriceAllowed(snapshot ImageSearchSnapshot, pddCurrency string, priceCent int64) (allowed bool, comparable bool) { if priceCent < 0 || snapshot.ReferencePriceCent < 0 || snapshot.ReferencePriceCent == 0 || snapshot.MaxPriceRatio < 1 { return false, false diff --git a/server/app/goauto/task/image_search_link.go b/server/app/goauto/task/image_search_link.go index bcacd01..ec34eb5 100644 --- a/server/app/goauto/task/image_search_link.go +++ b/server/app/goauto/task/image_search_link.go @@ -3,7 +3,6 @@ package task import ( "context" "errors" - "strings" "go-admin/app/goauto/models" "gorm.io/gorm" @@ -25,9 +24,18 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) if err != nil { return err } - if snapshot.PriceGuardSkipped { - return nil - } + // `[必须]` 币种不同只说明**价格没法比**,不说明这条关联不该建立。这两件事 + // 此前被混在一起:snapshot.PriceGuardSkipped 与 shopee_product.currency != "CNY" + // 各自 return nil,而 priceSkipped 的唯一来源就是 referenceCurrency != "CNY", + // 两个判据其实是同一个条件。 + // + // 线上 26902 个虾皮商品**全部是 TWD**,一个 CNY 都没有(2026-09-15 核对), + // 所以自动关联从上线起一次都没有执行过:图搜任务显示 completed、PDD 商品也 + // 采集完整,但关联始终停在旧值,采购因此无法创建。 + // + // 补偿控制是 image_search_linked 标记(本函数下方写入),它会显示在虾皮商品页、 + // 批量采购预览和采购任务列表上,含义是"机器找的,没人核过"。价格比对留待 + // 引入汇率后再启用,见 ImageSearchPriceAllowed 的说明。 var product models.ShopeeProduct if err := service.DB.WithContext(ctx).First(&product, snapshot.ShopeeProductID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -35,11 +43,6 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) } return internalError(err) } - // PDD products are RMB in the current contract; unlike-currency snapshots - // must remain available for manual review and can never auto-link. - if strings.ToUpper(strings.TrimSpace(product.Currency)) != "CNY" { - return nil - } var pdd models.PDDProduct if err := service.DB.WithContext(ctx).Select("id", "status").First(&pdd, *task.PDDProductID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/server/app/goauto/task/image_search_link_test.go b/server/app/goauto/task/image_search_link_test.go index d07c53f..59854ce 100644 --- a/server/app/goauto/task/image_search_link_test.go +++ b/server/app/goauto/task/image_search_link_test.go @@ -49,8 +49,14 @@ func containsAny(value string, needles ...string) bool { // seedAutoLinkCase 建一个「蝦皮商品当前关联 current,图搜任务找到 found, // 任务创建时快照记录的关联是 original」的场景。 func seedAutoLinkCase(t *testing.T, db *gorm.DB, itemID string, current *uint64, original *uint64, found uint64) models.ShopeeProduct { + return seedAutoLinkCaseWithCurrency(t, db, itemID, "TWD", current, original, found) +} + +// `[必须]` 默认用 TWD,因为线上 26902 个蕃皮商品**全部是 TWD**(2026-09-15 核对)。 +// 之前夹具用 CNY,于是整套用例全绿,而线上因为跨币种判据一次都没关联成功过。 +func seedAutoLinkCaseWithCurrency(t *testing.T, db *gorm.DB, itemID string, currency string, current *uint64, original *uint64, found uint64) models.ShopeeProduct { t.Helper() - shopee := models.ShopeeProduct{ShopeeItemID: itemID, Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: current} + shopee := models.ShopeeProduct{ShopeeItemID: itemID, Title: "test", Currency: currency, SpecsJSON: "[]", PDDProductID: current} if err := db.Create(&shopee).Error; err != nil { t.Fatal(err) } @@ -60,8 +66,9 @@ func seedAutoLinkCase(t *testing.T, db *gorm.DB, itemID string, current *uint64, } snapshot := ImageSearchSnapshot{ ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, OriginalPDDProductID: original, - ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3, - ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}, + ReferenceCurrency: currency, ReferencePriceCent: 100, MaxPriceRatio: 3, + PriceGuardSkipped: currency != "CNY", + ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}, } raw, _ := json.Marshal(snapshot) task := models.CollectionTask{ @@ -150,3 +157,32 @@ func TestAutoLinkImageSearchLinksWhenStillUnlinked(t *testing.T) { t.Fatal("auto link must set the image-search marker") } } + +// 回归:PDD 是 CNY、蕃皮是 TWD 时仍然建立关联。 +// +// 币种不同只说明价格没法比,不说明关联不该建立。旧实现在这里直接 return nil, +// 而线上商品全是 TWD,于是自动关联从上线起一次都没执行过:图搜任务 +// completed、PDD 商品也采集完整,关联却停在旧值,采购因此无法创建。 +func TestAutoLinkImageSearchLinksCrossCurrencyShopeeProducts(t *testing.T) { + db := openTaskDatabase(t) + found := seedAutoLinkPDD(t, db, "found-pdd") + + saved := seedAutoLinkCaseWithCurrency(t, db, "twd-case", "TWD", nil, nil, found.ID) + if saved.PDDProductID == nil || *saved.PDDProductID != found.ID { + t.Fatalf("TWD 商品未建立关联: %+v", saved.PDDProductID) + } + if !saved.ImageSearchLinked { + t.Fatal("自动关联必须置上图搜标记,它是价格比对缺失时的唯一补偿控制") + } +} + +// CNY 商品的行为不变。 +func TestAutoLinkImageSearchStillLinksSameCurrencyProducts(t *testing.T) { + db := openTaskDatabase(t) + found := seedAutoLinkPDD(t, db, "found-pdd") + + saved := seedAutoLinkCaseWithCurrency(t, db, "cny-case", "CNY", nil, nil, found.ID) + if saved.PDDProductID == nil || *saved.PDDProductID != found.ID { + t.Fatalf("CNY 商品未建立关联: %+v", saved.PDDProductID) + } +}