fix(#277): 图搜候选点击改瞄有文案子节点,关联不再被跨币种判据挡死
真机连续排查出三处缺陷,均已实测确认: 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
+195
-25
@@ -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>): SnapshotNode? =
|
||||
nodes.filter { it.label.isNotEmpty() && inside(card, it.bounds) }.minByOrNull { it.bounds.top }
|
||||
|
||||
fun firstOpenableCandidate(cards: List<ImageSearchResultCard>): 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<SnapshotNode>? {
|
||||
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<SnapshotNode>): 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>): 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<SnapshotNode>): 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
|
||||
|
||||
+7
-2
@@ -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 = "再试一次"
|
||||
|
||||
+11
-7
@@ -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(
|
||||
|
||||
+84
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user