From 73b00b4063fcaaadf65e4ea480e19fe6ae9bb63d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 27 Jul 2026 23:57:02 +0800 Subject: [PATCH] feat(t213): verify selected SKU combination price --- android-buyer/app/build.gradle.kts | 4 +- .../java/com/roubao/autopilot/MainActivity.kt | 42 ++- .../accessibility/BuyerAccessibilityBridge.kt | 18 ++ .../BuyerAccessibilityService.kt | 126 +++++++- .../AndroidPinduoduoCandidateDriver.kt | 39 ++- .../pinduoduo/CandidateEvidenceSource.kt | 19 +- .../pinduoduo/PinduoduoCandidateAutomation.kt | 193 +++++++++++- .../pinduoduo/PinduoduoCandidateModels.kt | 11 +- .../pinduoduo/PinduoduoPageClassifier.kt | 33 +- .../pinduoduo/PinduoduoSpecificationModels.kt | 245 ++++++++++++++- .../autopilot/vlm/SkuHardConstraints.kt | 102 +++--- .../PinduoduoCandidateAutomationTest.kt | 292 ++++++++++++++++-- .../pinduoduo/PinduoduoPageClassifierTest.kt | 44 ++- .../PinduoduoSpecificationParserTest.kt | 167 +++++++++- ...teSpecificationHardConstraintPolicyTest.kt | 43 ++- docs/04-architecture.md | 11 +- docs/current-state.md | 21 +- docs/tasks/T-213.md | 31 +- 18 files changed, 1301 insertions(+), 140 deletions(-) diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index e3c19f0..06591ee 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.roubao.autopilot" minSdk = 26 targetSdk = 34 - versionCode = 8 - versionName = "1.4.3" + versionCode = 9 + versionName = "1.4.4" vectorDrawables { useSupportLibrary = true 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 3c88fad..aa2c20d 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 @@ -67,6 +67,7 @@ import com.roubao.autopilot.vlm.CandidateHumanReviewPolicy import com.roubao.autopilot.vlm.CandidateTopFivePolicy import com.roubao.autopilot.vlm.CandidateSpecificationHardConstraintPolicy import com.roubao.autopilot.vlm.SkuHardConstraintExtractor +import com.roubao.autopilot.vlm.SkuConstraintKind import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -88,6 +89,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchAutomation import com.roubao.autopilot.pinduoduo.PinduoduoImageProbeAutomation import com.roubao.autopilot.pinduoduo.PinduoduoImageCandidateWorkflow import com.roubao.autopilot.pinduoduo.PinduoduoReferenceImagePolicy +import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTarget import com.roubao.autopilot.pinduoduo.PDD_IMAGE_SEARCH_AUDIT_QUERY import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD @@ -627,8 +629,22 @@ class MainActivity : ComponentActivity() { } else { searchKeyword } + val specificationTarget = ( + boundRequirement?.sku ?: procurementTask?.sku + )?.let(SkuHardConstraintExtractor::extract) + ?.takeIf { it.readyForAutomaticMatching } + ?.let { constraints -> + val values = constraints.constraints.associate { + it.kind to it.expected + } + PinduoduoSpecificationTarget( + color = requireNotNull(values[SkuConstraintKind.COLOR]), + size = requireNotNull(values[SkuConstraintKind.SIZE]) + ) + } val candidateAutomation = PinduoduoCandidateAutomation( - AndroidPinduoduoCandidateDriver(this) + driver = AndroidPinduoduoCandidateDriver(this), + specificationTarget = specificationTarget ) candidateAutomation.reset() searchProbeReport.value = null @@ -1002,15 +1018,27 @@ class MainActivity : ComponentActivity() { } val drafts = ranked.map { rankedCandidate -> val assessment = rankedCandidate.assessment + val candidateEvidence = requireNotNull( + evidenceByOrdinal[ + rankedCandidate.sourceOrdinal + ] + ) ExecutionCandidateDraft( ordinal = rankedCandidate.rankedOrdinal, title = "拼多多图片候选 " + rankedCandidate.sourceOrdinal, - skuText = assessment.hardConstraintResults - .joinToString(" / ") { - "${it.kind.name}:${it.expected}" - }, + skuText = + candidateEvidence.specification + .selectedSummary + ?: assessment + .hardConstraintResults + .joinToString(" / ") { + "${it.kind.name}:" + + it.expected + }, + price = candidateEvidence.specification + .price?.rawText.orEmpty(), evidenceLocalIDs = emptyList(), evaluation = ExecutionCandidateEvaluation( decision = assessment.decision.name, @@ -1184,6 +1212,10 @@ class MainActivity : ComponentActivity() { ExecutionCandidateDraft( ordinal = candidate.ordinal, title = "$taskTitle 候选 ${candidate.ordinal}", + skuText = candidate.specification + .selectedSummary.orEmpty(), + price = candidate.specification + .price?.rawText.orEmpty(), evidenceLocalIDs = emptyList() ) } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt index 8ffbf50..619cbd8 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt @@ -5,6 +5,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence +import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot import com.roubao.autopilot.readiness.DeviceObservationStore import kotlinx.coroutines.Dispatchers @@ -94,6 +95,18 @@ object BuyerAccessibilityBridge { service?.readPinduoduoSpecificationEvidence() } + suspend fun selectSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean = withContext(Dispatchers.Main.immediate) { + service?.selectPinduoduoSpecificationOption(kind, optionText) == true + } + + suspend fun scrollSpecifications(): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.scrollPinduoduoSpecifications() == true + } + suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? = withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) { withContext(Dispatchers.Main.immediate) { @@ -108,6 +121,11 @@ object BuyerAccessibilityBridge { service?.closePinduoduoSpecifications() == true } + suspend fun returnFromOrderConfirmation(): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.returnFromPinduoduoOrderConfirmation() == true + } + suspend fun returnToResults(): Boolean = withContext(Dispatchers.Main.immediate) { service?.returnFromPinduoduoCandidate() == true 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 fbb91bc..2836097 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 @@ -25,6 +25,7 @@ import com.roubao.autopilot.pinduoduo.isCandidateResultsPage import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence +import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationParser import java.io.ByteArrayOutputStream import java.util.ArrayDeque @@ -328,14 +329,25 @@ class BuyerAccessibilityService : AccessibilityService() { if (!isVerifiedProductDetail(root)) { return@withPinduoduoRoot false } - val entries = collectNodes(root).filter { node -> + val nodes = collectNodes(root) + val directTargets = uniqueClickableTargets(nodes.filter { node -> node.isVisibleToUser && node.isEnabled && PinduoduoSpecificationParser.isSafeEntryText( semanticText(node) ) - } - entries.singleOrNull()?.let(::clickNodeOrAncestor) == true + }) + val target = directTargets.singleOrNull() + ?: uniqueClickableTargets( + nodes.filter { node -> + node.isVisibleToUser && + node.isEnabled && + normalizeActionText(semanticText(node)) + .endsWith(SPECIFICATION_PURCHASE_ENTRY) + } + ).singleOrNull() + ?: return@withPinduoduoRoot false + target.performAction(AccessibilityNodeInfo.ACTION_CLICK) } ?: false internal fun readPinduoduoSpecificationEvidence(): @@ -353,6 +365,67 @@ class BuyerAccessibilityService : AccessibilityService() { ) } + internal fun selectPinduoduoSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.safetyStopReason != null || + snapshot.page != PinduoduoPage.SPECIFICATION_PANEL + ) { + return@withPinduoduoRoot false + } + val evidence = PinduoduoSpecificationParser.parse( + collectNodes(root).map(::uiElement) + ) ?: return@withPinduoduoRoot false + val group = evidence.groups + .filter { it.kind == kind } + .singleOrNull() + ?: return@withPinduoduoRoot false + val option = group.options.filter { candidate -> + normalizeActionText(candidate.text) == + normalizeActionText(optionText) + }.singleOrNull() + ?.takeIf { it.enabled && !it.selected } + ?: return@withPinduoduoRoot false + val normalizedOption = normalizeActionText(option.text) + val targets = uniqueClickableTargets( + collectNodes(root).filter { node -> + node.isVisibleToUser && + node.isEnabled && + normalizeActionText(semanticText(node)) == + normalizedOption + } + ) + targets.singleOrNull() + ?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true + } ?: false + + internal fun scrollPinduoduoSpecifications(): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.safetyStopReason != null || + snapshot.page != PinduoduoPage.SPECIFICATION_PANEL + ) { + return@withPinduoduoRoot false + } + collectNodes(root) + .filter { node -> + node.isVisibleToUser && + node.isEnabled && + node.isScrollable && + node.className?.toString() + ?.endsWith("ScrollView") == true + } + .singleOrNull() + ?.performAction( + AccessibilityNodeInfo.ACTION_SCROLL_FORWARD + ) == true + } ?: false + internal fun closePinduoduoSpecifications(): Boolean = withPinduoduoRoot { root -> val snapshot = classifyPinduoduoRoot(root) @@ -365,6 +438,17 @@ class BuyerAccessibilityService : AccessibilityService() { performGlobalAction(GLOBAL_ACTION_BACK) } ?: false + internal fun returnFromPinduoduoOrderConfirmation(): Boolean = + withPinduoduoRoot { root -> + if ( + classifyPinduoduoRoot(root).page != + PinduoduoPage.ORDER_CONFIRMATION + ) { + return@withPinduoduoRoot false + } + performGlobalAction(GLOBAL_ACTION_BACK) + } ?: false + internal fun returnFromPinduoduoCandidate(): Boolean = withPinduoduoRoot { root -> if (!isVerifiedProductDetail(root)) { @@ -531,7 +615,9 @@ class BuyerAccessibilityService : AccessibilityService() { selected = node.isSelected, scrollable = node.isScrollable, boundsLeft = bounds.left, - boundsTop = bounds.top + boundsTop = bounds.top, + boundsRight = bounds.right, + boundsBottom = bounds.bottom ) } @@ -802,6 +888,37 @@ class BuyerAccessibilityService : AccessibilityService() { return false } + private fun uniqueClickableTargets( + nodes: Collection + ): List = + nodes.mapNotNull(::clickableNodeOrAncestor) + .distinctBy { node -> + val bounds = Rect().also(node::getBoundsInScreen) + listOf( + bounds.left, + bounds.top, + bounds.right, + bounds.bottom + ) + } + + private fun clickableNodeOrAncestor( + node: AccessibilityNodeInfo + ): AccessibilityNodeInfo? { + var candidate: AccessibilityNodeInfo? = node + repeat(MAX_CLICK_ANCESTORS) { + val current = candidate ?: return null + if (current.isClickable && current.isEnabled) { + return current + } + candidate = current.parent + } + return null + } + + private fun normalizeActionText(value: String): String = + value.trim().replace(Regex("\\s+"), "") + private data class CandidateNode( val node: AccessibilityNodeInfo, val card: PinduoduoCandidateCard @@ -835,6 +952,7 @@ class BuyerAccessibilityService : AccessibilityService() { private const val RECENT_PROJECTS_TEXT = "最近项目" private const val IMAGE_GRID_COLUMNS = 4 private const val IMAGE_GRID_WIDTH_TOLERANCE = 24 + private const val SPECIFICATION_PURCHASE_ENTRY = "免拼购买" private val DECIMAL_PRICE_PATTERN = Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$") } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt index 2517bc0..453146a 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt @@ -36,9 +36,21 @@ class AndroidPinduoduoCandidateDriver( override suspend fun openSpecifications(): Boolean = BuyerAccessibilityBridge.openSpecifications() - override suspend fun captureSpecifications(): PinduoduoSpecificationCapture? { - val evidence = BuyerAccessibilityBridge.specificationEvidence() - ?: return null + override suspend fun readSpecifications(): PinduoduoSpecificationEvidence? = + BuyerAccessibilityBridge.specificationEvidence() + + override suspend fun selectSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean = + BuyerAccessibilityBridge.selectSpecificationOption(kind, optionText) + + override suspend fun scrollSpecifications(): Boolean = + BuyerAccessibilityBridge.scrollSpecifications() + + override suspend fun captureSpecifications( + evidence: PinduoduoSpecificationEvidence + ): PinduoduoSpecificationCapture? { val screenshot = BuyerAccessibilityBridge.captureSpecificationScreenshot() ?: return null @@ -48,6 +60,9 @@ class AndroidPinduoduoCandidateDriver( override suspend fun closeSpecifications(): Boolean = BuyerAccessibilityBridge.closeSpecifications() + override suspend fun returnFromOrderConfirmation(): Boolean = + BuyerAccessibilityBridge.returnFromOrderConfirmation() + override suspend fun saveCandidate( ordinal: Int, card: PinduoduoCandidateCard, @@ -170,6 +185,22 @@ private class CandidateEvidenceStore(context: Context) { "specification_semantic_text_count", evidence.specification.semanticTextCount ) + .put( + "specification_selected_summary", + evidence.specification.selectedSummary + ) + .put( + "specification_price_ambiguous", + evidence.specification.priceAmbiguous + ) + .put( + "specification_price_raw", + evidence.specification.price?.rawText + ) + .put( + "specification_price_cents", + evidence.specification.price?.cents + ) .put( "specification_asset", assetJson(evidence.specificationAsset) @@ -177,7 +208,7 @@ private class CandidateEvidenceStore(context: Context) { ) } val manifest = JSONObject() - .put("schema_version", 2) + .put("schema_version", 3) .put("candidate_count", evidenceByOrdinal.size) .put("candidates", candidates) .toString(2) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt index 5518745..7700c64 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt @@ -35,8 +35,23 @@ class CandidateEvidenceSource( var totalBytes = 0L sorted.map { metadata -> require( - metadata.specification.groups.map { it.kind }.toSet() == - PinduoduoSpecificationGroupKind.entries.toSet() + metadata.specification.groups.isNotEmpty() && + metadata.specification.groups + .map { it.kind } + .distinct() + .size == metadata.specification.groups.size + ) + require( + metadata.specification.selectedSummary + ?.length + ?.let { it in 1..160 } != false + ) + require( + metadata.specification.price?.let { price -> + price.rawText.length in 1..32 && + price.cents in 1..100_000_000L && + !metadata.specification.priceAmbiguous + } != false ) val detail = readAsset( canonicalRoot = canonicalRoot, diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomation.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomation.kt index f956936..550a107 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomation.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomation.kt @@ -19,6 +19,9 @@ enum class CandidateBrowsePhase { CAPTURING_DETAIL, OPENING_SPECIFICATIONS, READING_SPECIFICATIONS, + SELECTING_COLOR, + SELECTING_SIZE, + SCROLLING_SPECIFICATIONS, CLOSING_SPECIFICATIONS, RETURNING_RESULTS, SCROLLING_RESULTS, @@ -27,8 +30,10 @@ enum class CandidateBrowsePhase { class PinduoduoCandidateAutomation( private val driver: PinduoduoCandidateDriver, + private val specificationTarget: PinduoduoSpecificationTarget? = null, private val maxCandidates: Int = MAX_CANDIDATES_PER_PROBE, private val maxResultScrolls: Int = MAX_RESULT_SCROLLS_PER_PROBE, + private val maxSpecificationScrolls: Int = 2, private val pagePollIntervalMillis: Long = 200, private val unknownPageLimit: Int = 20 ) : AutomationGateway { @@ -45,6 +50,7 @@ class PinduoduoCandidateAutomation( init { require(maxCandidates in 1..MAX_CANDIDATES_PER_PROBE) require(maxResultScrolls in 0..MAX_RESULT_SCROLLS_PER_PROBE) + require(maxSpecificationScrolls in 0..2) require(pagePollIntervalMillis > 0) require(unknownPageLimit > 0) } @@ -110,11 +116,33 @@ class PinduoduoCandidateAutomation( ) mutablePhase.value = CandidateBrowsePhase.OPENING_SPECIFICATIONS if (!driver.openSpecifications()) { - return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE) + returnToResultsAfterUnsupportedCandidate()?.let { return it } + continue + } + when (val entry = awaitSpecificationEntry()) { + SpecificationEntry.PANEL -> Unit + SpecificationEntry.ORDER_CONFIRMATION -> { + if (!driver.returnFromOrderConfirmation()) { + return AutomationResult.Blocked( + SafetyStopReason.PAYMENT_BOUNDARY + ) + } + awaitPage(PinduoduoPage.PRODUCT_DETAIL)?.let { return it } + returnToResultsAfterUnsupportedCandidate()?.let { + return it + } + continue + } + is SpecificationEntry.FAILED -> return entry.result } - awaitPage(PinduoduoPage.SPECIFICATION_PANEL)?.let { return it } mutablePhase.value = CandidateBrowsePhase.READING_SPECIFICATIONS - val specifications = driver.captureSpecifications() + val specificationEvidence = collectSpecificationEvidence() + ?: return AutomationResult.FatalFailure( + WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED + ) + val specifications = driver.captureSpecifications( + specificationEvidence + ) ?: return AutomationResult.FatalFailure( WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED ) @@ -146,6 +174,157 @@ class PinduoduoCandidateAutomation( return terminalCollectionResult() } + private suspend fun collectSpecificationEvidence(): + PinduoduoSpecificationEvidence? { + val observations = mutableListOf() + var specificationScrolls = 0 + + suspend fun read(): PinduoduoSpecificationEvidence? = + driver.readSpecifications()?.also(observations::add) + + if (specificationTarget == null) { + read() + return PinduoduoSpecificationParser.merge(observations) + } + + val targets = listOf( + PinduoduoSpecificationGroupKind.COLOR to + specificationTarget.color, + PinduoduoSpecificationGroupKind.SIZE to + specificationTarget.size + ) + for ((kind, expected) in targets) { + var resolved = false + while (!resolved) { + val current = read() + ?: return PinduoduoSpecificationParser.merge(observations) + when ( + val resolution = + PinduoduoSpecificationSelectionPolicy.resolve( + evidence = current, + kind = kind, + expected = expected + ) + ) { + is PinduoduoSpecificationOptionResolution.Ready -> { + if (resolution.alreadySelected) { + resolved = true + continue + } + mutablePhase.value = when (kind) { + PinduoduoSpecificationGroupKind.COLOR -> + CandidateBrowsePhase.SELECTING_COLOR + PinduoduoSpecificationGroupKind.SIZE -> + CandidateBrowsePhase.SELECTING_SIZE + } + if ( + !driver.selectSpecificationOption( + kind, + resolution.optionText + ) + ) { + return PinduoduoSpecificationParser.merge( + observations + ) + } + delay(pagePollIntervalMillis) + awaitPage(PinduoduoPage.SPECIFICATION_PANEL)?.let { + return PinduoduoSpecificationParser.merge( + observations + ) + } + val verified = read() + ?: return PinduoduoSpecificationParser.merge( + observations + ) + resolved = + ( + PinduoduoSpecificationSelectionPolicy.resolve( + evidence = verified, + kind = kind, + expected = expected + ) as? PinduoduoSpecificationOptionResolution.Ready + )?.alreadySelected == true + if (!resolved) { + return PinduoduoSpecificationParser.merge( + observations + ) + } + } + PinduoduoSpecificationOptionResolution.Absent -> { + if (specificationScrolls >= maxSpecificationScrolls) { + return PinduoduoSpecificationParser.merge( + observations + ) + } + mutablePhase.value = + CandidateBrowsePhase.SCROLLING_SPECIFICATIONS + if (!driver.scrollSpecifications()) { + return PinduoduoSpecificationParser.merge( + observations + ) + } + specificationScrolls += 1 + delay(pagePollIntervalMillis) + awaitPage(PinduoduoPage.SPECIFICATION_PANEL)?.let { + return PinduoduoSpecificationParser.merge( + observations + ) + } + } + PinduoduoSpecificationOptionResolution.Ambiguous, + is PinduoduoSpecificationOptionResolution.Disabled -> + return PinduoduoSpecificationParser.merge(observations) + } + } + } + mutablePhase.value = CandidateBrowsePhase.READING_SPECIFICATIONS + read() + return PinduoduoSpecificationParser.merge(observations) + } + + private suspend fun awaitSpecificationEntry(): SpecificationEntry { + var stableUnexpectedObservations = 0 + while (true) { + val snapshot = driver.snapshot() + if (snapshot.page == PinduoduoPage.SPECIFICATION_PANEL) { + return SpecificationEntry.PANEL + } + if (snapshot.page == PinduoduoPage.ORDER_CONFIRMATION) { + return SpecificationEntry.ORDER_CONFIRMATION + } + safetyResult(snapshot)?.let { + return SpecificationEntry.FAILED(it) + } + stableUnexpectedObservations = if ( + snapshot.foregroundPackage == PINDUODUO_PACKAGE + ) { + stableUnexpectedObservations + 1 + } else { + 0 + } + if (stableUnexpectedObservations >= unknownPageLimit) { + return SpecificationEntry.FAILED( + AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE) + ) + } + delay(pagePollIntervalMillis) + } + } + + private suspend fun returnToResultsAfterUnsupportedCandidate(): + AutomationResult? { + mutablePhase.value = CandidateBrowsePhase.RETURNING_RESULTS + if (!driver.returnToResults()) { + return AutomationResult.RetryableFailure( + WorkflowFailureCode.TRANSIENT_AUTOMATION + ) + } + val result = awaitResultsPage() + mutablePhase.value = CandidateBrowsePhase.READING_RESULTS + return result + } + private suspend fun recoverDetailPageIfNeeded(): AutomationResult? { val snapshot = driver.snapshot() safetyResult(snapshot)?.let { return it } @@ -237,6 +416,14 @@ class PinduoduoCandidateAutomation( private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? = snapshot.safetyStopReason?.let(AutomationResult::Blocked) + + private sealed interface SpecificationEntry { + data object PANEL : SpecificationEntry + data object ORDER_CONFIRMATION : SpecificationEntry + data class FAILED( + val result: AutomationResult + ) : SpecificationEntry + } } class PinduoduoProbeAutomation( diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt index 8832ee8..2e70420 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt @@ -58,8 +58,17 @@ interface PinduoduoCandidateDriver { suspend fun openCandidate(signature: String): Boolean suspend fun captureDetail(): PinduoduoCandidateCapture? suspend fun openSpecifications(): Boolean - suspend fun captureSpecifications(): PinduoduoSpecificationCapture? + suspend fun readSpecifications(): PinduoduoSpecificationEvidence? + suspend fun selectSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean + suspend fun scrollSpecifications(): Boolean + suspend fun captureSpecifications( + evidence: PinduoduoSpecificationEvidence + ): PinduoduoSpecificationCapture? suspend fun closeSpecifications(): Boolean + suspend fun returnFromOrderConfirmation(): Boolean suspend fun saveCandidate( ordinal: Int, card: PinduoduoCandidateCard, 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 f5a6dce..33dd38b 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 @@ -17,7 +17,9 @@ data class PinduoduoUiElement( val selected: Boolean = false, val scrollable: Boolean = false, val boundsLeft: Int = 0, - val boundsTop: Int = 0 + val boundsTop: Int = 0, + val boundsRight: Int = 0, + val boundsBottom: Int = 0 ) enum class PinduoduoPage { @@ -29,6 +31,8 @@ enum class PinduoduoPage { IMAGE_SEARCH_RESULTS, PRODUCT_DETAIL, SPECIFICATION_PANEL, + ORDER_CONFIRMATION, + ORDER_LIST, UNKNOWN } @@ -159,10 +163,33 @@ object PinduoduoPageClassifier { paymentMarkers.none(semantic::contains) } val hasSpecificationPanel = - specificationGroupCount >= 2 && - specificationOptionCount >= 2 + ( + normalized.any { it == "确认款式" } && + normalized.any { it.startsWith("已选择") } && + visibleElements.any { element -> + element.clickable && + normalize( + element.contentDescription.orEmpty() + ) == "关闭" + } && + visibleElements.any { element -> + element.clickable && + normalize(element.text.orEmpty()) == "确定" + } + ) || + ( + specificationGroupCount >= 2 && + specificationOptionCount >= 2 + ) + val hasOrderConfirmation = normalized.any { it == "确认订单" } + val hasOrderList = + normalized.any { it == "我的订单" || it == "全部订单" } && + setOf("待付款", "待发货", "待收货") + .count { marker -> normalized.any { it == marker } } >= 2 val page = when { + hasOrderConfirmation -> PinduoduoPage.ORDER_CONFIRMATION + hasOrderList -> PinduoduoPage.ORDER_LIST hasSpecificationPanel -> PinduoduoPage.SPECIFICATION_PANEL hasImageResultHeader && legacySortControlCount >= 3 -> PinduoduoPage.IMAGE_SEARCH_RESULTS diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationModels.kt index f8768e3..1c5851a 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationModels.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationModels.kt @@ -1,5 +1,7 @@ package com.roubao.autopilot.pinduoduo +import java.text.Normalizer + enum class PinduoduoSpecificationGroupKind { COLOR, SIZE @@ -21,9 +23,34 @@ data class PinduoduoSpecificationGroup( data class PinduoduoSpecificationEvidence( val signature: String, val semanticTextCount: Int, - val groups: List + val groups: List, + val selectedSummary: String? = null, + val price: PinduoduoSpecificationPrice? = null, + val priceAmbiguous: Boolean = false ) +data class PinduoduoSpecificationPrice( + val rawText: String, + val cents: Long +) + +data class PinduoduoSpecificationTarget( + val color: String, + val size: String +) + +sealed interface PinduoduoSpecificationOptionResolution { + data object Absent : PinduoduoSpecificationOptionResolution + data object Ambiguous : PinduoduoSpecificationOptionResolution + data class Disabled( + val optionText: String + ) : PinduoduoSpecificationOptionResolution + data class Ready( + val optionText: String, + val alreadySelected: Boolean + ) : PinduoduoSpecificationOptionResolution +} + object PinduoduoSpecificationParser { fun parse( elements: Collection @@ -47,10 +74,7 @@ object PinduoduoSpecificationParser { GroupHeader(index, kind, semanticText(element), element.boundsTop) } } - if ( - headers.map { it.kind }.toSet() != - PinduoduoSpecificationGroupKind.entries.toSet() - ) { + if (headers.isEmpty()) { return null } @@ -77,8 +101,8 @@ object PinduoduoSpecificationParser { options.size > MAX_OPTIONS_PER_GROUP || ordered.any { element -> element.scrollable && - element.boundsTop >= header.top && - element.boundsTop < nextTop + element.boundsTop <= header.top && + element.boundsBottom > header.top } PinduoduoSpecificationGroup( kind = header.kind, @@ -87,10 +111,22 @@ object PinduoduoSpecificationParser { complete = !clipped ) } - if (groups.any { it.options.isEmpty() }) { + if (groups.all { it.options.isEmpty() }) { return null } + val selectedSummary = ordered.asSequence() + .map(::semanticText) + .firstOrNull { normalize(it).startsWith("已选择") } + ?.take(MAX_SELECTED_SUMMARY_LENGTH) + val observedPrices = ordered.asSequence() + .map(::semanticText) + .mapNotNull(::parsePrice) + .distinctBy { it.cents } + .take(MAX_PRICE_CANDIDATES + 1) + .toList() + val price = observedPrices.singleOrNull() + val priceAmbiguous = observedPrices.size > 1 val semantics = buildList { groups.forEach { group -> add("${group.kind.name}:${normalize(group.title)}:${group.complete}") @@ -101,13 +137,78 @@ object PinduoduoSpecificationParser { ) } } + selectedSummary?.let { add("selected:${normalize(it)}") } + price?.let { add("price:${it.cents}") } + add("price_ambiguous:$priceAmbiguous") } return PinduoduoSpecificationEvidence( signature = PinduoduoEvidenceHash.sha256( semantics.joinToString("\u001f") ), semanticTextCount = semantics.size, - groups = groups + groups = groups, + selectedSummary = selectedSummary, + price = price, + priceAmbiguous = priceAmbiguous + ) + } + + fun merge( + observations: List + ): PinduoduoSpecificationEvidence? { + if (observations.isEmpty()) { + return null + } + val groups = PinduoduoSpecificationGroupKind.entries.mapNotNull { kind -> + val observedGroups = observations.flatMap { evidence -> + evidence.groups.filter { it.kind == kind } + } + if (observedGroups.isEmpty()) { + return@mapNotNull null + } + val optionsByText = + linkedMapOf() + observedGroups.forEach { group -> + group.options.forEach { option -> + optionsByText[normalize(option.text)] = option + } + } + PinduoduoSpecificationGroup( + kind = kind, + title = observedGroups.first().title, + options = optionsByText.values.toList(), + complete = observedGroups.all { it.complete } + ) + } + val prices = observations.mapNotNull { it.price } + .distinctBy { it.cents } + val priceAmbiguous = + observations.any { it.priceAmbiguous } || prices.size > 1 + val selectedSummary = observations.asReversed() + .firstNotNullOfOrNull { it.selectedSummary } + val semantics = buildList { + groups.forEach { group -> + add("${group.kind.name}:${group.complete}") + group.options.forEach { option -> + add( + "${normalize(option.text)}:" + + "${option.selected}:${option.enabled}" + ) + } + } + selectedSummary?.let { add("selected:${normalize(it)}") } + prices.singleOrNull()?.let { add("price:${it.cents}") } + add("price_ambiguous:$priceAmbiguous") + } + return PinduoduoSpecificationEvidence( + signature = PinduoduoEvidenceHash.sha256( + semantics.joinToString("\u001f") + ), + semanticTextCount = semantics.size, + groups = groups, + selectedSummary = selectedSummary, + price = prices.singleOrNull().takeUnless { priceAmbiguous }, + priceAmbiguous = priceAmbiguous ) } @@ -124,6 +225,7 @@ object PinduoduoSpecificationParser { normalized !in SAFE_ENTRY_TEXTS && SAFE_ENTRY_PREFIXES.none(normalized::startsWith) && TRANSACTION_MARKERS.none(normalized::contains) && + normalized !in SPECIFICATION_ACTION_MARKERS && (element.clickable || element.selected || !element.enabled) } @@ -146,6 +248,25 @@ object PinduoduoSpecificationParser { private fun normalize(value: String): String = value.trim().lowercase().replace(Regex("\\s+"), "") + private fun parsePrice(value: String): PinduoduoSpecificationPrice? { + val match = PRICE_PATTERN.matchEntire( + Normalizer.normalize(value.trim(), Normalizer.Form.NFKC) + .replace(Regex("\\s+"), "") + ) ?: return null + val whole = match.groupValues[1].toLongOrNull() ?: return null + val fraction = match.groupValues[2].padEnd(2, '0') + .ifEmpty { "00" } + .toLongOrNull() ?: return null + val cents = whole * 100 + fraction + if (cents <= 0 || cents > MAX_PRICE_CENTS) { + return null + } + return PinduoduoSpecificationPrice( + rawText = value.trim().take(MAX_PRICE_TEXT_LENGTH), + cents = cents + ) + } + private data class GroupHeader( val index: Int, val kind: PinduoduoSpecificationGroupKind, @@ -172,7 +293,113 @@ object PinduoduoSpecificationParser { "支付", "结算" ) + private val SPECIFICATION_ACTION_MARKERS = setOf( + "查看大图", + "增加数量", + "减少数量", + "确定", + "关闭", + "确认款式" + ) + private val PRICE_PATTERN = Regex("""^[¥¥](\d{1,7})(?:\.(\d{1,2}))?$""") private const val MAX_ELEMENTS = 160 private const val MAX_OPTIONS_PER_GROUP = 30 private const val MAX_OPTION_TEXT_LENGTH = 80 + private const val MAX_SELECTED_SUMMARY_LENGTH = 160 + private const val MAX_PRICE_CANDIDATES = 4 + private const val MAX_PRICE_TEXT_LENGTH = 32 + private const val MAX_PRICE_CENTS = 100_000_000L +} + +object PinduoduoSpecificationSelectionPolicy { + fun resolve( + evidence: PinduoduoSpecificationEvidence, + kind: PinduoduoSpecificationGroupKind, + expected: String + ): PinduoduoSpecificationOptionResolution { + val groups = evidence.groups.filter { it.kind == kind } + if (groups.size != 1) { + return if (groups.isEmpty()) { + PinduoduoSpecificationOptionResolution.Absent + } else { + PinduoduoSpecificationOptionResolution.Ambiguous + } + } + val matches = groups.single().options.filter { option -> + PinduoduoSpecificationTargetMatcher.matches( + kind = kind, + expected = expected, + observed = option.text + ) + } + return when { + matches.isEmpty() -> PinduoduoSpecificationOptionResolution.Absent + matches.size > 1 -> PinduoduoSpecificationOptionResolution.Ambiguous + !matches.single().enabled -> + PinduoduoSpecificationOptionResolution.Disabled( + matches.single().text + ) + else -> PinduoduoSpecificationOptionResolution.Ready( + optionText = matches.single().text, + alreadySelected = matches.single().selected + ) + } + } +} + +object PinduoduoSpecificationTargetMatcher { + fun matches( + kind: PinduoduoSpecificationGroupKind, + expected: String, + observed: String + ): Boolean { + val normalized = Normalizer.normalize( + observed.trim(), + Normalizer.Form.NFKC + ).uppercase() + return when (kind) { + PinduoduoSpecificationGroupKind.COLOR -> + COLOR_ALIASES[expected].orEmpty() + .any { alias -> normalized.matchesAlias(alias) } + PinduoduoSpecificationGroupKind.SIZE -> + sizeAliases(expected).any { alias -> + Regex( + """(?:^|[^A-Z0-9])${Regex.escape(alias)}""" + + """(?=$|[^A-Z0-9])""" + ).containsMatchIn(normalized) + } + } + } + + private fun String.matchesAlias(alias: String): Boolean = + if (alias.all { it in 'A'..'Z' }) { + Regex( + """(?:^|[^A-Z])${Regex.escape(alias)}(?=$|[^A-Z])""" + ).containsMatchIn(this) + } else { + contains(alias) + } + + private fun sizeAliases(expected: String): Set = + when (expected) { + "2XL" -> setOf("2XL", "XXL") + "3XL" -> setOf("3XL", "XXXL") + "FREE" -> setOf("FREE", "FREESIZE", "均码") + else -> setOf(expected) + } + + private val COLOR_ALIASES = mapOf( + "BLACK" to listOf("BLACK", "黑色", "亮黑", "纯黑"), + "WHITE" to listOf("WHITE", "白色", "纯白", "米白"), + "GRAY" to listOf("GRAY", "GREY", "灰色", "浅灰", "深灰"), + "RED" to listOf("RED", "红色", "酒红", "玫红"), + "BLUE" to listOf("BLUE", "蓝色", "藏青", "牛仔蓝"), + "GREEN" to listOf("GREEN", "绿色", "军绿"), + "YELLOW" to listOf("YELLOW", "黄色"), + "PINK" to listOf("PINK", "粉色", "粉红"), + "PURPLE" to listOf("PURPLE", "紫色"), + "BROWN" to listOf("BROWN", "棕色", "咖色", "咖啡色"), + "BEIGE" to listOf("BEIGE", "米色", "卡其"), + "ORANGE" to listOf("ORANGE", "橙色") + ) } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/vlm/SkuHardConstraints.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/vlm/SkuHardConstraints.kt index 062e403..1405234 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/vlm/SkuHardConstraints.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/vlm/SkuHardConstraints.kt @@ -2,6 +2,7 @@ package com.roubao.autopilot.vlm import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind +import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTargetMatcher import java.text.Normalizer enum class SkuConstraintKind { @@ -107,8 +108,21 @@ object CandidateSpecificationHardConstraintPolicy { constraints: List, evidence: PinduoduoSpecificationEvidence, evidenceSha256: String - ): List = - constraints.map { constraint -> + ): List { + if (evidence.price == null || evidence.priceAmbiguous) { + return constraints.map { constraint -> + result( + constraint, + HardConstraintMatchStatus.UNKNOWN, + audit( + constraint, + "combination_price_unverified", + evidenceSha256 + ) + ) + } + } + return constraints.map { constraint -> val kind = when (constraint.kind) { SkuConstraintKind.COLOR -> PinduoduoSpecificationGroupKind.COLOR @@ -125,7 +139,11 @@ object CandidateSpecificationHardConstraintPolicy { } val group = matchingGroups.single() val matchingOptions = group.options.filter { option -> - optionMatches(constraint, option.text) + PinduoduoSpecificationTargetMatcher.matches( + kind = kind, + expected = constraint.expected, + observed = option.text + ) } when { matchingOptions.size > 1 -> @@ -134,11 +152,21 @@ object CandidateSpecificationHardConstraintPolicy { HardConstraintMatchStatus.UNKNOWN, audit(constraint, "target_duplicate", evidenceSha256) ) - matchingOptions.singleOrNull()?.enabled == true -> + matchingOptions.singleOrNull()?.let { option -> + option.enabled && + option.selected && + evidence.selectedSummary?.let { summary -> + PinduoduoSpecificationTargetMatcher.matches( + kind = kind, + expected = constraint.expected, + observed = summary + ) + } == true + } == true -> result( constraint, HardConstraintMatchStatus.MATCH, - audit(constraint, "target_enabled", evidenceSha256) + audit(constraint, "target_selected", evidenceSha256) ) matchingOptions.singleOrNull()?.enabled == false -> result( @@ -146,6 +174,16 @@ object CandidateSpecificationHardConstraintPolicy { HardConstraintMatchStatus.MISMATCH, audit(constraint, "target_disabled", evidenceSha256) ) + matchingOptions.singleOrNull()?.enabled == true -> + result( + constraint, + HardConstraintMatchStatus.UNKNOWN, + audit( + constraint, + "target_not_selected", + evidenceSha256 + ) + ) group.complete -> result( constraint, @@ -160,6 +198,7 @@ object CandidateSpecificationHardConstraintPolicy { ) } } + } private fun result( constraint: SkuHardConstraint, @@ -180,57 +219,4 @@ object CandidateSpecificationHardConstraintPolicy { "group=${constraint.kind.name};target=${constraint.expected};" + "observation=$observation;spec_sha256=$evidenceSha256" - private fun optionMatches( - constraint: SkuHardConstraint, - observed: String - ): Boolean { - val normalized = Normalizer.normalize( - observed.trim(), - Normalizer.Form.NFKC - ).uppercase() - return when (constraint.kind) { - SkuConstraintKind.COLOR -> - COLOR_ALIASES[constraint.expected].orEmpty() - .any { alias -> normalized.matchesAlias(alias) } - SkuConstraintKind.SIZE -> - sizeAliases(constraint.expected).any { alias -> - Regex( - """(?:^|[^A-Z0-9])${Regex.escape(alias)}""" + - """(?=$|[^A-Z0-9])""" - ).containsMatchIn(normalized) - } - } - } - - private fun String.matchesAlias(alias: String): Boolean = - if (alias.all { it in 'A'..'Z' }) { - Regex( - """(?:^|[^A-Z])${Regex.escape(alias)}(?=$|[^A-Z])""" - ).containsMatchIn(this) - } else { - contains(alias) - } - - private fun sizeAliases(expected: String): Set = - when (expected) { - "2XL" -> setOf("2XL", "XXL") - "3XL" -> setOf("3XL", "XXXL") - "FREE" -> setOf("FREE", "FREESIZE", "均码") - else -> setOf(expected) - } - - private val COLOR_ALIASES = mapOf( - "BLACK" to listOf("BLACK", "黑色", "亮黑", "纯黑"), - "WHITE" to listOf("WHITE", "白色", "纯白", "米白"), - "GRAY" to listOf("GRAY", "GREY", "灰色", "浅灰", "深灰"), - "RED" to listOf("RED", "红色", "酒红", "玫红"), - "BLUE" to listOf("BLUE", "蓝色", "藏青", "牛仔蓝"), - "GREEN" to listOf("GREEN", "绿色", "军绿"), - "YELLOW" to listOf("YELLOW", "黄色"), - "PINK" to listOf("PINK", "粉色", "粉红"), - "PURPLE" to listOf("PURPLE", "紫色"), - "BROWN" to listOf("BROWN", "棕色", "咖色", "咖啡色"), - "BEIGE" to listOf("BEIGE", "米色", "卡其"), - "ORANGE" to listOf("ORANGE", "橙色") - ) } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomationTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomationTest.kt index 29e893a..3f0d858 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomationTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateAutomationTest.kt @@ -157,7 +157,7 @@ class PinduoduoCandidateAutomationTest { } @Test - fun `missing safe specification entry blocks without purchase fallback`() = + fun `unsupported specification entry skips candidate safely`() = runTest { val driver = FakeCandidateDriver( cardPages = listOf(listOf(card("a"))), @@ -174,13 +174,175 @@ class PinduoduoCandidateAutomationTest { ) assertEquals( - AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE), + AutomationResult.FatalFailure( + WorkflowFailureCode.TARGET_NOT_READY + ), result ) assertEquals(1, driver.openSpecificationCalls) + assertEquals(1, driver.returnToResultsCalls) assertTrue(driver.savedEvidence.isEmpty()) } + @Test + fun `selects target color then size and captures verified price`() = + runTest { + val driver = FakeCandidateDriver( + cardPages = listOf(listOf(card("a"))) + ) + val automation = PinduoduoCandidateAutomation( + driver = driver, + specificationTarget = PinduoduoSpecificationTarget( + color = "BLACK", + size = "L" + ), + maxCandidates = 1, + pagePollIntervalMillis = 1 + ) + automation.reset() + + val result = automation.execute( + PinduoduoCandidateWorkflow.steps().last() + ) + + assertEquals(AutomationResult.Success, result) + assertEquals( + listOf( + PinduoduoSpecificationGroupKind.COLOR to "黑色", + PinduoduoSpecificationGroupKind.SIZE to "L" + ), + driver.selectedOptions + ) + val specification = driver.savedEvidence.single().specification + assertEquals("已选择:黑色 L", specification.selectedSummary) + assertEquals(1090L, specification.price?.cents) + } + + @Test + fun `direct order confirmation is exited without candidate save`() = + runTest { + val driver = FakeCandidateDriver( + cardPages = listOf(listOf(card("a"))), + openOrderConfirmation = true + ) + val automation = PinduoduoCandidateAutomation( + driver = driver, + maxCandidates = 1, + pagePollIntervalMillis = 1 + ) + automation.reset() + + val result = automation.execute( + PinduoduoCandidateWorkflow.steps().last() + ) + + assertEquals( + AutomationResult.FatalFailure( + WorkflowFailureCode.TARGET_NOT_READY + ), + result + ) + assertEquals(1, driver.returnFromOrderConfirmationCalls) + assertTrue(driver.savedEvidence.isEmpty()) + } + + @Test + fun `bounded specification scroll exposes target size`() = runTest { + val driver = FakeCandidateDriver( + cardPages = listOf(listOf(card("a"))), + sizeVisibleAfterSpecificationScroll = true + ) + val automation = PinduoduoCandidateAutomation( + driver = driver, + specificationTarget = PinduoduoSpecificationTarget( + color = "BLACK", + size = "L" + ), + maxCandidates = 1, + pagePollIntervalMillis = 1 + ) + automation.reset() + + val result = automation.execute( + PinduoduoCandidateWorkflow.steps().last() + ) + + assertEquals(AutomationResult.Success, result) + assertEquals(1, driver.specificationScrollCalls) + assertEquals( + PinduoduoSpecificationGroupKind.SIZE to "L", + driver.selectedOptions.last() + ) + } + + @Test + fun `unchanged selection state is captured but not continued`() = runTest { + val driver = FakeCandidateDriver( + cardPages = listOf(listOf(card("a"))), + ignoreSpecificationSelection = true + ) + val automation = PinduoduoCandidateAutomation( + driver = driver, + specificationTarget = PinduoduoSpecificationTarget( + color = "BLACK", + size = "L" + ), + maxCandidates = 1, + pagePollIntervalMillis = 1 + ) + automation.reset() + + val result = automation.execute( + PinduoduoCandidateWorkflow.steps().last() + ) + + assertEquals(AutomationResult.Success, result) + assertEquals( + listOf(PinduoduoSpecificationGroupKind.COLOR to "黑色"), + driver.selectedOptions + ) + assertTrue( + driver.savedEvidence.single().specification.groups + .single { + it.kind == PinduoduoSpecificationGroupKind.COLOR + } + .options + .none { it.selected } + ) + } + + @Test + fun `missing target never exceeds specification scroll budget`() = + runTest { + val driver = FakeCandidateDriver( + cardPages = listOf(listOf(card("a"))), + hideSizeAlways = true + ) + val automation = PinduoduoCandidateAutomation( + driver = driver, + specificationTarget = PinduoduoSpecificationTarget( + color = "BLACK", + size = "L" + ), + maxCandidates = 1, + pagePollIntervalMillis = 1 + ) + automation.reset() + + val result = automation.execute( + PinduoduoCandidateWorkflow.steps().last() + ) + + assertEquals(AutomationResult.Success, result) + assertEquals(2, driver.specificationScrollCalls) + assertEquals( + setOf(PinduoduoSpecificationGroupKind.COLOR), + driver.savedEvidence.single().specification.groups + .map { it.kind } + .toSet() + ) + } + private fun card(signature: String) = PinduoduoCandidateCard( signature = signature, semanticTextCount = 3, @@ -192,6 +354,10 @@ class PinduoduoCandidateAutomationTest { private val safetyStopReason: SafetyStopReason? = null, private val failCapture: Boolean = false, private val failOpenSpecifications: Boolean = false, + private val openOrderConfirmation: Boolean = false, + private val sizeVisibleAfterSpecificationScroll: Boolean = false, + private val hideSizeAlways: Boolean = false, + private val ignoreSpecificationSelection: Boolean = false, private val transitionDelaySnapshots: Int = 0 ) : PinduoduoCandidateDriver { private var cardPageIndex = 0 @@ -203,6 +369,13 @@ class PinduoduoCandidateAutomationTest { val savedEvidence = mutableListOf() var scrollCalls = 0 var openSpecificationCalls = 0 + var returnToResultsCalls = 0 + var returnFromOrderConfirmationCalls = 0 + var specificationScrollCalls = 0 + val selectedOptions = + mutableListOf>() + private val effectiveSelections = + mutableListOf>() override suspend fun snapshot(): PinduoduoUiSnapshot { if (pendingPage != null) { @@ -217,6 +390,9 @@ class PinduoduoCandidateAutomationTest { foregroundPackage = PINDUODUO_PACKAGE, page = page, safetyStopReason = safetyStopReason + ?: SafetyStopReason.PAYMENT_BOUNDARY.takeIf { + page == PinduoduoPage.ORDER_CONFIRMATION + } ) } @@ -250,14 +426,41 @@ class PinduoduoCandidateAutomationTest { if (failOpenSpecifications) { return false } - transitionTo(PinduoduoPage.SPECIFICATION_PANEL) + transitionTo( + if (openOrderConfirmation) { + PinduoduoPage.ORDER_CONFIRMATION + } else { + PinduoduoPage.SPECIFICATION_PANEL + } + ) return true } - override suspend fun captureSpecifications(): + override suspend fun readSpecifications(): + PinduoduoSpecificationEvidence = specification() + + override suspend fun selectSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean { + selectedOptions += kind to optionText + if (!ignoreSpecificationSelection) { + effectiveSelections += kind to optionText + } + return true + } + + override suspend fun scrollSpecifications(): Boolean { + specificationScrollCalls += 1 + return true + } + + override suspend fun captureSpecifications( + evidence: PinduoduoSpecificationEvidence + ): PinduoduoSpecificationCapture = PinduoduoSpecificationCapture( - evidence = specification(), + evidence = evidence, screenshot = PinduoduoScreenshotCapture( pngBytes = byteArrayOf(2), width = 1080, @@ -270,6 +473,12 @@ class PinduoduoCandidateAutomationTest { return true } + override suspend fun returnFromOrderConfirmation(): Boolean { + returnFromOrderConfirmationCalls += 1 + transitionTo(PinduoduoPage.PRODUCT_DETAIL) + return true + } + override suspend fun saveCandidate( ordinal: Int, card: PinduoduoCandidateCard, @@ -289,6 +498,7 @@ class PinduoduoCandidateAutomationTest { } override suspend fun returnToResults(): Boolean { + returnToResultsCalls += 1 transitionTo(PinduoduoPage.SEARCH_RESULTS) return true } @@ -306,6 +516,11 @@ class PinduoduoCandidateAutomationTest { savedEvidence.clear() scrollCalls = 0 openSpecificationCalls = 0 + returnToResultsCalls = 0 + returnFromOrderConfirmationCalls = 0 + specificationScrollCalls = 0 + selectedOptions.clear() + effectiveSelections.clear() cardPageIndex = 0 page = PinduoduoPage.SEARCH_RESULTS pendingPage = null @@ -335,24 +550,57 @@ class PinduoduoCandidateAutomationTest { private fun specification() = PinduoduoSpecificationEvidence( signature = "specification", semanticTextCount = 6, - groups = listOf( - PinduoduoSpecificationGroup( - kind = PinduoduoSpecificationGroupKind.COLOR, - title = "颜色分类", - options = listOf( - PinduoduoSpecificationOption("黑色", false, true) - ), - complete = true - ), - PinduoduoSpecificationGroup( - kind = PinduoduoSpecificationGroupKind.SIZE, - title = "尺码", - options = listOf( - PinduoduoSpecificationOption("L", false, true) - ), - complete = true + groups = buildList { + add( + PinduoduoSpecificationGroup( + kind = PinduoduoSpecificationGroupKind.COLOR, + title = "颜色分类", + options = listOf( + PinduoduoSpecificationOption( + "黑色", + effectiveSelections.any { + it.first == + PinduoduoSpecificationGroupKind.COLOR + }, + true + ) + ), + complete = true + ) ) - ) + if ( + !hideSizeAlways && + ( + !sizeVisibleAfterSpecificationScroll || + specificationScrollCalls > 0 + ) + ) { + add( + PinduoduoSpecificationGroup( + kind = PinduoduoSpecificationGroupKind.SIZE, + title = "尺码", + options = listOf( + PinduoduoSpecificationOption( + "L", + selected = effectiveSelections.any { + it.first == + PinduoduoSpecificationGroupKind.SIZE + }, + enabled = true + ) + ), + complete = true + ) + ) + } + }, + selectedSummary = effectiveSelections + .takeIf { it.isNotEmpty() } + ?.joinToString( + prefix = "已选择:", + separator = " " + ) { it.second }, + price = PinduoduoSpecificationPrice("¥10.9", 1090) ) } } 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 678aa4d..5d75a94 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 @@ -156,7 +156,7 @@ class PinduoduoPageClassifierTest { @Test fun `payment boundary blocks even on otherwise unknown page`() { - val snapshot = classify(element(text = "确认订单")) + val snapshot = classify(element(text = "立即支付")) assertEquals(PinduoduoPage.UNKNOWN, snapshot.page) assertEquals(SafetyStopReason.PAYMENT_BOUNDARY, snapshot.safetyStopReason) @@ -215,6 +215,48 @@ class PinduoduoPageClassifierTest { assertNull(snapshot.safetyStopReason) } + @Test + fun `stable specification controls survive scrolled option groups`() { + val snapshot = classify( + element(text = "确认款式"), + element(text = "已选择:白色 2XL"), + element(contentDescription = "关闭", clickable = true), + element(text = "确定", clickable = true), + element(text = "尺码"), + element(text = "2XL", clickable = true) + ) + + assertEquals(PinduoduoPage.SPECIFICATION_PANEL, snapshot.page) + assertNull(snapshot.safetyStopReason) + } + + @Test + fun `order confirmation is classified and remains payment blocked`() { + val snapshot = classify( + element(text = "确认订单"), + element(text = "提交订单", clickable = true) + ) + + assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page) + assertEquals( + SafetyStopReason.PAYMENT_BOUNDARY, + snapshot.safetyStopReason + ) + } + + @Test + fun `order list uses stable status tabs`() { + val snapshot = classify( + element(text = "我的订单"), + element(text = "待付款"), + element(text = "待发货"), + element(text = "待收货") + ) + + assertEquals(PinduoduoPage.ORDER_LIST, snapshot.page) + assertNull(snapshot.safetyStopReason) + } + @Test fun `cart and order pages are never classified as product detail`() { val cart = classify( diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationParserTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationParserTest.kt index ad52d7d..219aa92 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationParserTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSpecificationParserTest.kt @@ -36,7 +36,7 @@ class PinduoduoSpecificationParserTest { listOf( element("颜色分类", top = 100), element("黑色", top = 140, clickable = true), - element("", top = 150, scrollable = true), + element("", top = 50, bottom = 400, scrollable = true), element("尺码", top = 240), element("L", top = 280, clickable = true) ) @@ -44,7 +44,7 @@ class PinduoduoSpecificationParserTest { requireNotNull(evidence) assertFalse(evidence.groups.first().complete) - assertTrue(evidence.groups.last().complete) + assertFalse(evidence.groups.last().complete) } @Test @@ -75,9 +75,169 @@ class PinduoduoSpecificationParserTest { ) } + @Test + fun `reads selected combination and strict cny price`() { + val evidence = PinduoduoSpecificationParser.parse( + listOf( + element("确认款式", top = 10, clickable = true), + element("¥ 10.9", top = 20), + element("2件9.5折", top = 30), + element("已选择: 白色 2XL 建议145-160斤", top = 40), + element("颜色分类", top = 100), + element("白色", top = 140, clickable = true, selected = true), + element("尺码", top = 240), + element( + "2XL 建议145-160斤", + top = 280, + clickable = true, + selected = true + ), + element("确定", top = 500, clickable = true) + ) + ) + + requireNotNull(evidence) + assertEquals( + "已选择: 白色 2XL 建议145-160斤", + evidence.selectedSummary + ) + assertEquals(1090L, evidence.price?.cents) + assertEquals("¥ 10.9", evidence.price?.rawText) + assertFalse(evidence.priceAmbiguous) + } + + @Test + fun `multiple explicit prices are ambiguous`() { + val evidence = PinduoduoSpecificationParser.parse( + listOf( + element("¥10.9", top = 20), + element("¥12", top = 30), + element("颜色分类", top = 100), + element("黑色", top = 140, clickable = true) + ) + ) + + requireNotNull(evidence) + assertNull(evidence.price) + assertTrue(evidence.priceAmbiguous) + } + + @Test + fun `range and conditional prices do not become combination price`() { + val evidence = PinduoduoSpecificationParser.parse( + listOf( + element("¥10.9-¥12.9", top = 20), + element("券后¥9.9", top = 30), + element("2件9.5折", top = 40), + element("颜色分类", top = 100), + element("黑色", top = 140, clickable = true) + ) + ) + + requireNotNull(evidence) + assertNull(evidence.price) + assertFalse(evidence.priceAmbiguous) + } + + @Test + fun `merges partial observations after bounded scroll`() { + val color = requireNotNull( + PinduoduoSpecificationParser.parse( + listOf( + element("¥10.9", top = 20), + element("已选择:白色", top = 40), + element("颜色分类", top = 100), + element( + "白色", + top = 140, + clickable = true, + selected = true + ) + ) + ) + ) + val size = requireNotNull( + PinduoduoSpecificationParser.parse( + listOf( + element("¥10.9", top = 20), + element("已选择:白色 2XL", top = 40), + element("尺码", top = 100), + element( + "2XL 建议145-160斤", + top = 140, + clickable = true, + selected = true + ) + ) + ) + ) + + val merged = requireNotNull( + PinduoduoSpecificationParser.merge(listOf(color, size)) + ) + assertEquals( + PinduoduoSpecificationGroupKind.entries.toSet(), + merged.groups.map { it.kind }.toSet() + ) + assertEquals("已选择:白色 2XL", merged.selectedSummary) + assertEquals(1090L, merged.price?.cents) + } + + @Test + fun `selection policy rejects disabled and accepts selected alias`() { + val evidence = requireNotNull( + PinduoduoSpecificationParser.parse( + listOf( + element("已选择:黑色 XXL", top = 40), + element("颜色分类", top = 100), + element( + "经典黑色", + top = 140, + clickable = true, + selected = true + ), + element("尺码", top = 240), + element( + "XXL", + top = 280, + clickable = true, + selected = true + ), + element( + "3XL", + top = 320, + clickable = true, + enabled = false + ) + ) + ) + ) + + assertEquals( + PinduoduoSpecificationOptionResolution.Ready( + optionText = "XXL", + alreadySelected = true + ), + PinduoduoSpecificationSelectionPolicy.resolve( + evidence, + PinduoduoSpecificationGroupKind.SIZE, + "2XL" + ) + ) + assertEquals( + PinduoduoSpecificationOptionResolution.Disabled("3XL"), + PinduoduoSpecificationSelectionPolicy.resolve( + evidence, + PinduoduoSpecificationGroupKind.SIZE, + "3XL" + ) + ) + } + private fun element( text: String, top: Int, + bottom: Int = top + 1, clickable: Boolean = false, enabled: Boolean = true, selected: Boolean = false, @@ -93,6 +253,7 @@ class PinduoduoSpecificationParserTest { visibleToUser = true, selected = selected, scrollable = scrollable, - boundsTop = top + boundsTop = top, + boundsBottom = bottom ) } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/vlm/CandidateSpecificationHardConstraintPolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/vlm/CandidateSpecificationHardConstraintPolicyTest.kt index 1e78335..e2fb031 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/vlm/CandidateSpecificationHardConstraintPolicyTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/vlm/CandidateSpecificationHardConstraintPolicyTest.kt @@ -4,6 +4,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroup import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationOption +import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationPrice import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -54,6 +55,39 @@ class CandidateSpecificationHardConstraintPolicyTest { ) } + @Test + fun `visible but unselected target is unknown`() { + assertEquals( + HardConstraintMatchStatus.UNKNOWN, + evaluate( + colorOptions = listOf( + option("黑色", selected = false) + ), + sizeOptions = listOf(option("2XL")) + ).first().status + ) + } + + @Test + fun `missing combination price makes selected targets unknown`() { + val evidence = specification( + colorOptions = listOf(option("黑色")), + sizeOptions = listOf(option("2XL")) + ).copy(price = null) + + val results = CandidateSpecificationHardConstraintPolicy.evaluate( + constraints = constraints(), + evidence = evidence, + evidenceSha256 = "b".repeat(64) + ) + + assertTrue( + results.all { + it.status == HardConstraintMatchStatus.UNKNOWN + } + ) + } + @Test fun `truncated group and duplicated matching option are unknown`() { assertEquals( @@ -134,15 +168,18 @@ class CandidateSpecificationHardConstraintPolicyTest { sizeOptions, true ) - ) + ), + selectedSummary = "已选择:经典黑色 XXL", + price = PinduoduoSpecificationPrice("¥10.9", 1090) ) private fun option( text: String, - enabled: Boolean = true + enabled: Boolean = true, + selected: Boolean = true ) = PinduoduoSpecificationOption( text = text, - selected = false, + selected = selected, enabled = enabled ) } diff --git a/docs/04-architecture.md b/docs/04-architecture.md index af7a073..6139db4 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -266,8 +266,9 @@ T-102/T-104 在搜索结果后追加一个有界候选步骤: -> 一次全局返回并复核固定词结果页 ``` -候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v2 记录 -匿名文件名、截图 SHA-256、字节数、尺寸和有界规格语义,不保存商品标题或完整页面 +候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v3 记录 +匿名文件名、截图 SHA-256、字节数、尺寸、有界规格语义、已选摘要和严格组合价格, +不保存商品标题或完整页面 原文。T-104/T-213 使用这些证据时必须通过受控 evidence 边界读取,不能让 VLM adapter 自行遍历 cache。Android 10/API 29 及以下不能运行当前截图探针,应在预检时明确 不支持,不使用媒体投影或 shell 绕过。 @@ -300,6 +301,12 @@ PKG110 上的拼多多 8.17.0 服装详情只暴露不可点击的“颜色/款 提交订单或触发支付。后续 `ORDER_CREATE` 必须由 Admin 对确定候选签发一次性授权, 使用独立状态机和幂等/对账边界,不能复用候选探查权限。 +`DISCOVERY_INSPECT` 从任务 SKU 的本地规范化结果取得唯一颜色/尺码,按颜色后尺码 +的固定顺序选择,每次动作后重新读取 selected 与“已选择”摘要;规格区域最多向前 +滚动 2 次。价格只接受单一、完整的人民币文本并保存分值,区间、券后、条件价格、 +多价格冲突或无价格一律降级 `UNKNOWN`。若唯一入口落到订单确认页,自动化只能执行 +系统返回并跳过该候选。 + 评估结束后 automation 不再产生动作,UI 进入 `AWAITING_CONFIRMATION`、 `MANUAL_REVIEW` 或 `NO_MATCH`。人员可以把本地建议项标记为可用,或拒绝本次候选; 这只是验证结果,所有状态的 `order_submitted` 均为 `false`。 diff --git a/docs/current-state.md b/docs/current-state.md index 5c669c9..6a32ceb 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,9 +5,9 @@ ## 当前快照 - 日期:2026-07-27 -- 阶段:T-212 已完成;T-213 已按新业务要求重开,正在实现候选 SKU 组合与价格核验 +- 阶段:T-212 已完成;T-213 代码完成,等待用户启用无障碍后执行最后真机 smoke - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、 - T-210、T-211、T-212 均已纳入 Git 历史;T-213 外部阻塞已解除并重新执行 + T-210、T-211、T-212 均已纳入 Git 历史;T-213 已实现,当前仅真机验收阻塞 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 @@ -17,8 +17,8 @@ - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:T-213 运行 `:app:testDebugUnitTest` 和 `:app:assembleDebug` 通过,24 个 suite - 共 128 个测试、0 失败;Debug APK `1.4.3 (8)` 已安装到 PKG110 +- 测试:T-213 Android Debug 24 个 suite、143 个测试、0 失败,Debug/Release 回归 + 和 `assembleDebug` 通过;Debug APK `1.4.4 (9)` 已安装并冷启动于 PKG110 - 后端测试:T-213 运行 `go test ./...` 通过;T-207 的全包 race 与 migration 验证继续有效 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright @@ -50,9 +50,10 @@ 图片进入拼多多拍照搜索;App 本地从 SKU 唯一提取颜色和尺码,schema v3 逐项返回 `MATCH/MISMATCH/UNKNOWN`。只有两项均匹配且分数/置信度不低于 `0.75` 的候选按 分数、置信度和曝光顺序回传 `0..5` 项,弱匹配和未知项不凑数。 -- T-213 规格核验:旧版只读解析、详情/规格双证据和本地硬约束已完成;新业务边界 - 允许 `DISCOVERY_INSPECT` 从“免拼购买”进入规格弹层,正在补充目标颜色/尺码选择、 - 组合价格读取和退出验证。候选探查仍不能加入购物车、提交订单或触发支付。 +- T-213 规格核验:`DISCOVERY_INSPECT` 可从唯一“免拼购买”进入规格弹层,按任务 + SKU 唯一选择颜色/尺码并复核 selected/已选摘要,最多滚动 2 次;严格保存单一 + 人民币组合价和详情/规格双证据。区间/条件/冲突/缺失价格降级 `UNKNOWN`,直达 + 订单确认页只系统返回。候选探查不能加入购物车、提交订单或触发支付。 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -68,7 +69,7 @@ - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, RUNNING 不自动重新分配 -- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.3 (8)`;拼多多 +- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.4 (9)`;拼多多 `8.17.0 (81700)` - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 真机验证;最终 APK 重装/force-stop 后 ColorOS 已关闭肉包采购无障碍,当前需采购员 @@ -112,7 +113,7 @@ | `docs/tasks/T-210.md` | DONE | 兼容拼多多 8.17 搜索结果页与长词省略显示 | | `docs/tasks/T-211.md` | DONE | 参考图召回、SKU 颜色尺码硬匹配与 0..5 候选回传 | | `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 | -| `docs/tasks/T-213.md` | DOING | 候选阶段选择目标 SKU 并读取组合价格,不提交订单 | +| `docs/tasks/T-213.md` | BLOCKED | 代码完成;等待手动启用无障碍后执行真机 smoke | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -125,7 +126,7 @@ - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、T-210、T-211、 T-212。 -- 正在进行:T-213 候选 SKU 组合与价格核验。 +- 正在进行:T-213 仅剩用户授权后的真机自动化 smoke。 - 下一个可领取任务:无;T-208 依赖 T-213 真机验收完成。 - 后置任务:T-213 完成后做 T-208 候选决策数据与人工理由闭环,再依次实现商品 持久身份、Admin 下单授权、设备命令、订单 dry-run、单次提交对账和付款提醒。 diff --git a/docs/tasks/T-213.md b/docs/tasks/T-213.md index dec77f0..ca22ca9 100644 --- a/docs/tasks/T-213.md +++ b/docs/tasks/T-213.md @@ -4,7 +4,7 @@ title: 拼多多候选 SKU 组合与价格核验 phase: 2 deps: - T-212 -status: DOING +status: BLOCKED created: 2026-07-27 context_ref: 9a8388f work_branch: null @@ -58,16 +58,16 @@ T-211 目前只用候选详情页首屏截图判断颜色和尺码。真机已 ## 验收要点 -- [ ] 页面分类覆盖详情、规格弹层、订单确认、订单列表、登录、验证码、风控和支付。 -- [ ] `DISCOVERY_INSPECT` 只允许进入规格弹层、选择目标 SKU、读取价格和返回;代码 +- [x] 页面分类覆盖详情、规格弹层、订单确认、订单列表、登录、验证码、风控和支付。 +- [x] `DISCOVERY_INSPECT` 只允许进入规格弹层、选择目标 SKU、读取价格和返回;代码 中不存在提交订单、支付或加入购物车动作。 -- [ ] 颜色/尺码必须各自唯一匹配;依次点击后验证 selected/已选语义,禁用、重复、 +- [x] 颜色/尺码必须各自唯一匹配;依次点击后验证 selected/已选语义,禁用、重复、 缺失、截断和状态未变化均有测试。 -- [ ] 组合价格使用严格人民币解析并保存分值;模糊区间价、券后条件价、多价格冲突和 +- [x] 组合价格使用严格人民币解析并保存分值;模糊区间价、券后条件价、多价格冲突和 无价格均不能形成自动匹配证据。 -- [ ] 若购买入口直接进入订单确认页,自动化不点击任何页面控件并安全返回。 -- [ ] 每个候选的详情/规格证据、所选规格和价格不会在重排/outbox 中串位。 -- [ ] Android 单元测试、Debug 构建和后端回归通过。 +- [x] 若购买入口直接进入订单确认页,自动化不点击任何页面控件并安全返回。 +- [x] 每个候选的详情/规格证据、所选规格和价格不会在重排/outbox 中串位。 +- [x] Android 单元测试、Debug 构建和后端回归通过。 - [ ] PKG110、Android 16、肉包/拼多多版本真机 smoke 完成进入弹层、选择测试 SKU、 读取组合价格、关闭和返回;确认未创建订单、未进入支付。 @@ -105,3 +105,18 @@ T-211 目前只用候选详情页首屏截图判断颜色和尺码。真机已 并创建待付款订单,采购员最后在拼多多人工确认和付款。外部决策已解除旧阻塞,本 任务重新置为 `DOING`,权限缩小为候选阶段的 `DISCOVERY_INSPECT`;真正 `ORDER_CREATE` 仍须一次性 Admin 授权和独立任务。 +- 2026-07-27:完成 `DISCOVERY_INSPECT` 实现。任务 SKU 先由本地确定性规则唯一提取 + 颜色/尺码,再从直接规格入口或唯一“免拼购买”入口打开弹层;颜色、尺码依次选择并 + 复核 selected/“已选择”摘要,规格滚动上限为 2 次。严格解析单一人民币组合价为 + 分值,区间价、券后价、条件价、多价格冲突和无价格均不能形成自动匹配证据。 +- 2026-07-27:候选 manifest 升级到 v3,所选规格摘要、原始价格、价格分值和冲突状态 + 随详情/规格双截图证据进入候选草稿;本地结果是 VLM 不可覆盖的硬约束。若入口直接 + 到订单确认页,只调用系统返回并跳过候选,接口中没有“确定”、提交订单、购物车或 + 支付动作。 +- 2026-07-27:Android Debug 24 个 suite、143 个测试全部通过,`assembleDebug` 和 + Release 回归通过;根 `init.ps1` 完成 Go 全包测试、vet、gofmt 和三个二进制构建。 + APK `1.4.4 (9)` 已覆盖安装并冷启动于 PKG110。 +- 2026-07-27:任务暂置 `BLOCKED`,仅剩真机自动化 smoke。设备授权列表中没有肉包 + 无障碍服务,解除条件是采购员在 ColorOS 系统设置中手动启用肉包无障碍;不得通过 + ADB 绕过。启用后需验证自动进入规格弹层、选择测试 SKU、读取组合价、关闭并返回, + 同时确认拼多多没有生成订单或进入支付。