feat(t213): verify selected SKU combination price

This commit is contained in:
QiuSW
2026-07-27 23:57:02 +08:00
parent 5c08d8d7f3
commit 73b00b4063
18 changed files with 1301 additions and 140 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.roubao.autopilot" applicationId = "com.roubao.autopilot"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 8 versionCode = 9
versionName = "1.4.3" versionName = "1.4.4"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -67,6 +67,7 @@ import com.roubao.autopilot.vlm.CandidateHumanReviewPolicy
import com.roubao.autopilot.vlm.CandidateTopFivePolicy import com.roubao.autopilot.vlm.CandidateTopFivePolicy
import com.roubao.autopilot.vlm.CandidateSpecificationHardConstraintPolicy import com.roubao.autopilot.vlm.CandidateSpecificationHardConstraintPolicy
import com.roubao.autopilot.vlm.SkuHardConstraintExtractor import com.roubao.autopilot.vlm.SkuHardConstraintExtractor
import com.roubao.autopilot.vlm.SkuConstraintKind
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job 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.PinduoduoImageProbeAutomation
import com.roubao.autopilot.pinduoduo.PinduoduoImageCandidateWorkflow import com.roubao.autopilot.pinduoduo.PinduoduoImageCandidateWorkflow
import com.roubao.autopilot.pinduoduo.PinduoduoReferenceImagePolicy 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.PDD_IMAGE_SEARCH_AUDIT_QUERY
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
@@ -627,8 +629,22 @@ class MainActivity : ComponentActivity() {
} else { } else {
searchKeyword 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( val candidateAutomation = PinduoduoCandidateAutomation(
AndroidPinduoduoCandidateDriver(this) driver = AndroidPinduoduoCandidateDriver(this),
specificationTarget = specificationTarget
) )
candidateAutomation.reset() candidateAutomation.reset()
searchProbeReport.value = null searchProbeReport.value = null
@@ -1002,15 +1018,27 @@ class MainActivity : ComponentActivity() {
} }
val drafts = ranked.map { rankedCandidate -> val drafts = ranked.map { rankedCandidate ->
val assessment = rankedCandidate.assessment val assessment = rankedCandidate.assessment
val candidateEvidence = requireNotNull(
evidenceByOrdinal[
rankedCandidate.sourceOrdinal
]
)
ExecutionCandidateDraft( ExecutionCandidateDraft(
ordinal = rankedCandidate.rankedOrdinal, ordinal = rankedCandidate.rankedOrdinal,
title = title =
"拼多多图片候选 " + "拼多多图片候选 " +
rankedCandidate.sourceOrdinal, rankedCandidate.sourceOrdinal,
skuText = assessment.hardConstraintResults skuText =
.joinToString(" / ") { candidateEvidence.specification
"${it.kind.name}:${it.expected}" .selectedSummary
}, ?: assessment
.hardConstraintResults
.joinToString(" / ") {
"${it.kind.name}:" +
it.expected
},
price = candidateEvidence.specification
.price?.rawText.orEmpty(),
evidenceLocalIDs = emptyList(), evidenceLocalIDs = emptyList(),
evaluation = ExecutionCandidateEvaluation( evaluation = ExecutionCandidateEvaluation(
decision = assessment.decision.name, decision = assessment.decision.name,
@@ -1184,6 +1212,10 @@ class MainActivity : ComponentActivity() {
ExecutionCandidateDraft( ExecutionCandidateDraft(
ordinal = candidate.ordinal, ordinal = candidate.ordinal,
title = "$taskTitle 候选 ${candidate.ordinal}", title = "$taskTitle 候选 ${candidate.ordinal}",
skuText = candidate.specification
.selectedSummary.orEmpty(),
price = candidate.specification
.price?.rawText.orEmpty(),
evidenceLocalIDs = emptyList() evidenceLocalIDs = emptyList()
) )
} }
@@ -5,6 +5,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
import com.roubao.autopilot.readiness.DeviceObservationStore import com.roubao.autopilot.readiness.DeviceObservationStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -94,6 +95,18 @@ object BuyerAccessibilityBridge {
service?.readPinduoduoSpecificationEvidence() 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? = suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? =
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) { withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
@@ -108,6 +121,11 @@ object BuyerAccessibilityBridge {
service?.closePinduoduoSpecifications() == true service?.closePinduoduoSpecifications() == true
} }
suspend fun returnFromOrderConfirmation(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.returnFromPinduoduoOrderConfirmation() == true
}
suspend fun returnToResults(): Boolean = suspend fun returnToResults(): Boolean =
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
service?.returnFromPinduoduoCandidate() == true service?.returnFromPinduoduoCandidate() == true
@@ -25,6 +25,7 @@ import com.roubao.autopilot.pinduoduo.isCandidateResultsPage
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationParser import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationParser
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.ArrayDeque import java.util.ArrayDeque
@@ -328,14 +329,25 @@ class BuyerAccessibilityService : AccessibilityService() {
if (!isVerifiedProductDetail(root)) { if (!isVerifiedProductDetail(root)) {
return@withPinduoduoRoot false return@withPinduoduoRoot false
} }
val entries = collectNodes(root).filter { node -> val nodes = collectNodes(root)
val directTargets = uniqueClickableTargets(nodes.filter { node ->
node.isVisibleToUser && node.isVisibleToUser &&
node.isEnabled && node.isEnabled &&
PinduoduoSpecificationParser.isSafeEntryText( PinduoduoSpecificationParser.isSafeEntryText(
semanticText(node) 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 } ?: false
internal fun readPinduoduoSpecificationEvidence(): 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 = internal fun closePinduoduoSpecifications(): Boolean =
withPinduoduoRoot { root -> withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root) val snapshot = classifyPinduoduoRoot(root)
@@ -365,6 +438,17 @@ class BuyerAccessibilityService : AccessibilityService() {
performGlobalAction(GLOBAL_ACTION_BACK) performGlobalAction(GLOBAL_ACTION_BACK)
} ?: false } ?: 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 = internal fun returnFromPinduoduoCandidate(): Boolean =
withPinduoduoRoot { root -> withPinduoduoRoot { root ->
if (!isVerifiedProductDetail(root)) { if (!isVerifiedProductDetail(root)) {
@@ -531,7 +615,9 @@ class BuyerAccessibilityService : AccessibilityService() {
selected = node.isSelected, selected = node.isSelected,
scrollable = node.isScrollable, scrollable = node.isScrollable,
boundsLeft = bounds.left, boundsLeft = bounds.left,
boundsTop = bounds.top boundsTop = bounds.top,
boundsRight = bounds.right,
boundsBottom = bounds.bottom
) )
} }
@@ -802,6 +888,37 @@ class BuyerAccessibilityService : AccessibilityService() {
return false return false
} }
private fun uniqueClickableTargets(
nodes: Collection<AccessibilityNodeInfo>
): List<AccessibilityNodeInfo> =
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( private data class CandidateNode(
val node: AccessibilityNodeInfo, val node: AccessibilityNodeInfo,
val card: PinduoduoCandidateCard val card: PinduoduoCandidateCard
@@ -835,6 +952,7 @@ class BuyerAccessibilityService : AccessibilityService() {
private const val RECENT_PROJECTS_TEXT = "最近项目" private const val RECENT_PROJECTS_TEXT = "最近项目"
private const val IMAGE_GRID_COLUMNS = 4 private const val IMAGE_GRID_COLUMNS = 4
private const val IMAGE_GRID_WIDTH_TOLERANCE = 24 private const val IMAGE_GRID_WIDTH_TOLERANCE = 24
private const val SPECIFICATION_PURCHASE_ENTRY = "免拼购买"
private val DECIMAL_PRICE_PATTERN = private val DECIMAL_PRICE_PATTERN =
Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$") Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$")
} }
@@ -36,9 +36,21 @@ class AndroidPinduoduoCandidateDriver(
override suspend fun openSpecifications(): Boolean = override suspend fun openSpecifications(): Boolean =
BuyerAccessibilityBridge.openSpecifications() BuyerAccessibilityBridge.openSpecifications()
override suspend fun captureSpecifications(): PinduoduoSpecificationCapture? { override suspend fun readSpecifications(): PinduoduoSpecificationEvidence? =
val evidence = BuyerAccessibilityBridge.specificationEvidence() BuyerAccessibilityBridge.specificationEvidence()
?: return null
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 = val screenshot =
BuyerAccessibilityBridge.captureSpecificationScreenshot() BuyerAccessibilityBridge.captureSpecificationScreenshot()
?: return null ?: return null
@@ -48,6 +60,9 @@ class AndroidPinduoduoCandidateDriver(
override suspend fun closeSpecifications(): Boolean = override suspend fun closeSpecifications(): Boolean =
BuyerAccessibilityBridge.closeSpecifications() BuyerAccessibilityBridge.closeSpecifications()
override suspend fun returnFromOrderConfirmation(): Boolean =
BuyerAccessibilityBridge.returnFromOrderConfirmation()
override suspend fun saveCandidate( override suspend fun saveCandidate(
ordinal: Int, ordinal: Int,
card: PinduoduoCandidateCard, card: PinduoduoCandidateCard,
@@ -170,6 +185,22 @@ private class CandidateEvidenceStore(context: Context) {
"specification_semantic_text_count", "specification_semantic_text_count",
evidence.specification.semanticTextCount 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( .put(
"specification_asset", "specification_asset",
assetJson(evidence.specificationAsset) assetJson(evidence.specificationAsset)
@@ -177,7 +208,7 @@ private class CandidateEvidenceStore(context: Context) {
) )
} }
val manifest = JSONObject() val manifest = JSONObject()
.put("schema_version", 2) .put("schema_version", 3)
.put("candidate_count", evidenceByOrdinal.size) .put("candidate_count", evidenceByOrdinal.size)
.put("candidates", candidates) .put("candidates", candidates)
.toString(2) .toString(2)
@@ -35,8 +35,23 @@ class CandidateEvidenceSource(
var totalBytes = 0L var totalBytes = 0L
sorted.map { metadata -> sorted.map { metadata ->
require( require(
metadata.specification.groups.map { it.kind }.toSet() == metadata.specification.groups.isNotEmpty() &&
PinduoduoSpecificationGroupKind.entries.toSet() 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( val detail = readAsset(
canonicalRoot = canonicalRoot, canonicalRoot = canonicalRoot,
@@ -19,6 +19,9 @@ enum class CandidateBrowsePhase {
CAPTURING_DETAIL, CAPTURING_DETAIL,
OPENING_SPECIFICATIONS, OPENING_SPECIFICATIONS,
READING_SPECIFICATIONS, READING_SPECIFICATIONS,
SELECTING_COLOR,
SELECTING_SIZE,
SCROLLING_SPECIFICATIONS,
CLOSING_SPECIFICATIONS, CLOSING_SPECIFICATIONS,
RETURNING_RESULTS, RETURNING_RESULTS,
SCROLLING_RESULTS, SCROLLING_RESULTS,
@@ -27,8 +30,10 @@ enum class CandidateBrowsePhase {
class PinduoduoCandidateAutomation( class PinduoduoCandidateAutomation(
private val driver: PinduoduoCandidateDriver, private val driver: PinduoduoCandidateDriver,
private val specificationTarget: PinduoduoSpecificationTarget? = null,
private val maxCandidates: Int = MAX_CANDIDATES_PER_PROBE, private val maxCandidates: Int = MAX_CANDIDATES_PER_PROBE,
private val maxResultScrolls: Int = MAX_RESULT_SCROLLS_PER_PROBE, private val maxResultScrolls: Int = MAX_RESULT_SCROLLS_PER_PROBE,
private val maxSpecificationScrolls: Int = 2,
private val pagePollIntervalMillis: Long = 200, private val pagePollIntervalMillis: Long = 200,
private val unknownPageLimit: Int = 20 private val unknownPageLimit: Int = 20
) : AutomationGateway { ) : AutomationGateway {
@@ -45,6 +50,7 @@ class PinduoduoCandidateAutomation(
init { init {
require(maxCandidates in 1..MAX_CANDIDATES_PER_PROBE) require(maxCandidates in 1..MAX_CANDIDATES_PER_PROBE)
require(maxResultScrolls in 0..MAX_RESULT_SCROLLS_PER_PROBE) require(maxResultScrolls in 0..MAX_RESULT_SCROLLS_PER_PROBE)
require(maxSpecificationScrolls in 0..2)
require(pagePollIntervalMillis > 0) require(pagePollIntervalMillis > 0)
require(unknownPageLimit > 0) require(unknownPageLimit > 0)
} }
@@ -110,11 +116,33 @@ class PinduoduoCandidateAutomation(
) )
mutablePhase.value = CandidateBrowsePhase.OPENING_SPECIFICATIONS mutablePhase.value = CandidateBrowsePhase.OPENING_SPECIFICATIONS
if (!driver.openSpecifications()) { 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 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( ?: return AutomationResult.FatalFailure(
WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED
) )
@@ -146,6 +174,157 @@ class PinduoduoCandidateAutomation(
return terminalCollectionResult() return terminalCollectionResult()
} }
private suspend fun collectSpecificationEvidence():
PinduoduoSpecificationEvidence? {
val observations = mutableListOf<PinduoduoSpecificationEvidence>()
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? { private suspend fun recoverDetailPageIfNeeded(): AutomationResult? {
val snapshot = driver.snapshot() val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it } safetyResult(snapshot)?.let { return it }
@@ -237,6 +416,14 @@ class PinduoduoCandidateAutomation(
private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? = private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? =
snapshot.safetyStopReason?.let(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( class PinduoduoProbeAutomation(
@@ -58,8 +58,17 @@ interface PinduoduoCandidateDriver {
suspend fun openCandidate(signature: String): Boolean suspend fun openCandidate(signature: String): Boolean
suspend fun captureDetail(): PinduoduoCandidateCapture? suspend fun captureDetail(): PinduoduoCandidateCapture?
suspend fun openSpecifications(): Boolean 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 closeSpecifications(): Boolean
suspend fun returnFromOrderConfirmation(): Boolean
suspend fun saveCandidate( suspend fun saveCandidate(
ordinal: Int, ordinal: Int,
card: PinduoduoCandidateCard, card: PinduoduoCandidateCard,
@@ -17,7 +17,9 @@ data class PinduoduoUiElement(
val selected: Boolean = false, val selected: Boolean = false,
val scrollable: Boolean = false, val scrollable: Boolean = false,
val boundsLeft: Int = 0, val boundsLeft: Int = 0,
val boundsTop: Int = 0 val boundsTop: Int = 0,
val boundsRight: Int = 0,
val boundsBottom: Int = 0
) )
enum class PinduoduoPage { enum class PinduoduoPage {
@@ -29,6 +31,8 @@ enum class PinduoduoPage {
IMAGE_SEARCH_RESULTS, IMAGE_SEARCH_RESULTS,
PRODUCT_DETAIL, PRODUCT_DETAIL,
SPECIFICATION_PANEL, SPECIFICATION_PANEL,
ORDER_CONFIRMATION,
ORDER_LIST,
UNKNOWN UNKNOWN
} }
@@ -159,10 +163,33 @@ object PinduoduoPageClassifier {
paymentMarkers.none(semantic::contains) paymentMarkers.none(semantic::contains)
} }
val hasSpecificationPanel = 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 { val page = when {
hasOrderConfirmation -> PinduoduoPage.ORDER_CONFIRMATION
hasOrderList -> PinduoduoPage.ORDER_LIST
hasSpecificationPanel -> PinduoduoPage.SPECIFICATION_PANEL hasSpecificationPanel -> PinduoduoPage.SPECIFICATION_PANEL
hasImageResultHeader && legacySortControlCount >= 3 -> hasImageResultHeader && legacySortControlCount >= 3 ->
PinduoduoPage.IMAGE_SEARCH_RESULTS PinduoduoPage.IMAGE_SEARCH_RESULTS
@@ -1,5 +1,7 @@
package com.roubao.autopilot.pinduoduo package com.roubao.autopilot.pinduoduo
import java.text.Normalizer
enum class PinduoduoSpecificationGroupKind { enum class PinduoduoSpecificationGroupKind {
COLOR, COLOR,
SIZE SIZE
@@ -21,9 +23,34 @@ data class PinduoduoSpecificationGroup(
data class PinduoduoSpecificationEvidence( data class PinduoduoSpecificationEvidence(
val signature: String, val signature: String,
val semanticTextCount: Int, val semanticTextCount: Int,
val groups: List<PinduoduoSpecificationGroup> val groups: List<PinduoduoSpecificationGroup>,
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 { object PinduoduoSpecificationParser {
fun parse( fun parse(
elements: Collection<PinduoduoUiElement> elements: Collection<PinduoduoUiElement>
@@ -47,10 +74,7 @@ object PinduoduoSpecificationParser {
GroupHeader(index, kind, semanticText(element), element.boundsTop) GroupHeader(index, kind, semanticText(element), element.boundsTop)
} }
} }
if ( if (headers.isEmpty()) {
headers.map { it.kind }.toSet() !=
PinduoduoSpecificationGroupKind.entries.toSet()
) {
return null return null
} }
@@ -77,8 +101,8 @@ object PinduoduoSpecificationParser {
options.size > MAX_OPTIONS_PER_GROUP || options.size > MAX_OPTIONS_PER_GROUP ||
ordered.any { element -> ordered.any { element ->
element.scrollable && element.scrollable &&
element.boundsTop >= header.top && element.boundsTop <= header.top &&
element.boundsTop < nextTop element.boundsBottom > header.top
} }
PinduoduoSpecificationGroup( PinduoduoSpecificationGroup(
kind = header.kind, kind = header.kind,
@@ -87,10 +111,22 @@ object PinduoduoSpecificationParser {
complete = !clipped complete = !clipped
) )
} }
if (groups.any { it.options.isEmpty() }) { if (groups.all { it.options.isEmpty() }) {
return null 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 { val semantics = buildList {
groups.forEach { group -> groups.forEach { group ->
add("${group.kind.name}:${normalize(group.title)}:${group.complete}") 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( return PinduoduoSpecificationEvidence(
signature = PinduoduoEvidenceHash.sha256( signature = PinduoduoEvidenceHash.sha256(
semantics.joinToString("\u001f") semantics.joinToString("\u001f")
), ),
semanticTextCount = semantics.size, semanticTextCount = semantics.size,
groups = groups groups = groups,
selectedSummary = selectedSummary,
price = price,
priceAmbiguous = priceAmbiguous
)
}
fun merge(
observations: List<PinduoduoSpecificationEvidence>
): 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<String, PinduoduoSpecificationOption>()
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 && normalized !in SAFE_ENTRY_TEXTS &&
SAFE_ENTRY_PREFIXES.none(normalized::startsWith) && SAFE_ENTRY_PREFIXES.none(normalized::startsWith) &&
TRANSACTION_MARKERS.none(normalized::contains) && TRANSACTION_MARKERS.none(normalized::contains) &&
normalized !in SPECIFICATION_ACTION_MARKERS &&
(element.clickable || element.selected || !element.enabled) (element.clickable || element.selected || !element.enabled)
} }
@@ -146,6 +248,25 @@ object PinduoduoSpecificationParser {
private fun normalize(value: String): String = private fun normalize(value: String): String =
value.trim().lowercase().replace(Regex("\\s+"), "") 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( private data class GroupHeader(
val index: Int, val index: Int,
val kind: PinduoduoSpecificationGroupKind, 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_ELEMENTS = 160
private const val MAX_OPTIONS_PER_GROUP = 30 private const val MAX_OPTIONS_PER_GROUP = 30
private const val MAX_OPTION_TEXT_LENGTH = 80 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<String> =
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", "橙色")
)
} }
@@ -2,6 +2,7 @@ package com.roubao.autopilot.vlm
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTargetMatcher
import java.text.Normalizer import java.text.Normalizer
enum class SkuConstraintKind { enum class SkuConstraintKind {
@@ -107,8 +108,21 @@ object CandidateSpecificationHardConstraintPolicy {
constraints: List<SkuHardConstraint>, constraints: List<SkuHardConstraint>,
evidence: PinduoduoSpecificationEvidence, evidence: PinduoduoSpecificationEvidence,
evidenceSha256: String evidenceSha256: String
): List<CandidateHardConstraintResult> = ): List<CandidateHardConstraintResult> {
constraints.map { constraint -> 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) { val kind = when (constraint.kind) {
SkuConstraintKind.COLOR -> SkuConstraintKind.COLOR ->
PinduoduoSpecificationGroupKind.COLOR PinduoduoSpecificationGroupKind.COLOR
@@ -125,7 +139,11 @@ object CandidateSpecificationHardConstraintPolicy {
} }
val group = matchingGroups.single() val group = matchingGroups.single()
val matchingOptions = group.options.filter { option -> val matchingOptions = group.options.filter { option ->
optionMatches(constraint, option.text) PinduoduoSpecificationTargetMatcher.matches(
kind = kind,
expected = constraint.expected,
observed = option.text
)
} }
when { when {
matchingOptions.size > 1 -> matchingOptions.size > 1 ->
@@ -134,11 +152,21 @@ object CandidateSpecificationHardConstraintPolicy {
HardConstraintMatchStatus.UNKNOWN, HardConstraintMatchStatus.UNKNOWN,
audit(constraint, "target_duplicate", evidenceSha256) 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( result(
constraint, constraint,
HardConstraintMatchStatus.MATCH, HardConstraintMatchStatus.MATCH,
audit(constraint, "target_enabled", evidenceSha256) audit(constraint, "target_selected", evidenceSha256)
) )
matchingOptions.singleOrNull()?.enabled == false -> matchingOptions.singleOrNull()?.enabled == false ->
result( result(
@@ -146,6 +174,16 @@ object CandidateSpecificationHardConstraintPolicy {
HardConstraintMatchStatus.MISMATCH, HardConstraintMatchStatus.MISMATCH,
audit(constraint, "target_disabled", evidenceSha256) audit(constraint, "target_disabled", evidenceSha256)
) )
matchingOptions.singleOrNull()?.enabled == true ->
result(
constraint,
HardConstraintMatchStatus.UNKNOWN,
audit(
constraint,
"target_not_selected",
evidenceSha256
)
)
group.complete -> group.complete ->
result( result(
constraint, constraint,
@@ -160,6 +198,7 @@ object CandidateSpecificationHardConstraintPolicy {
) )
} }
} }
}
private fun result( private fun result(
constraint: SkuHardConstraint, constraint: SkuHardConstraint,
@@ -180,57 +219,4 @@ object CandidateSpecificationHardConstraintPolicy {
"group=${constraint.kind.name};target=${constraint.expected};" + "group=${constraint.kind.name};target=${constraint.expected};" +
"observation=$observation;spec_sha256=$evidenceSha256" "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<String> =
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", "橙色")
)
} }
@@ -157,7 +157,7 @@ class PinduoduoCandidateAutomationTest {
} }
@Test @Test
fun `missing safe specification entry blocks without purchase fallback`() = fun `unsupported specification entry skips candidate safely`() =
runTest { runTest {
val driver = FakeCandidateDriver( val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))), cardPages = listOf(listOf(card("a"))),
@@ -174,13 +174,175 @@ class PinduoduoCandidateAutomationTest {
) )
assertEquals( assertEquals(
AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE), AutomationResult.FatalFailure(
WorkflowFailureCode.TARGET_NOT_READY
),
result result
) )
assertEquals(1, driver.openSpecificationCalls) assertEquals(1, driver.openSpecificationCalls)
assertEquals(1, driver.returnToResultsCalls)
assertTrue(driver.savedEvidence.isEmpty()) 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( private fun card(signature: String) = PinduoduoCandidateCard(
signature = signature, signature = signature,
semanticTextCount = 3, semanticTextCount = 3,
@@ -192,6 +354,10 @@ class PinduoduoCandidateAutomationTest {
private val safetyStopReason: SafetyStopReason? = null, private val safetyStopReason: SafetyStopReason? = null,
private val failCapture: Boolean = false, private val failCapture: Boolean = false,
private val failOpenSpecifications: 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 private val transitionDelaySnapshots: Int = 0
) : PinduoduoCandidateDriver { ) : PinduoduoCandidateDriver {
private var cardPageIndex = 0 private var cardPageIndex = 0
@@ -203,6 +369,13 @@ class PinduoduoCandidateAutomationTest {
val savedEvidence = mutableListOf<PinduoduoCandidateEvidence>() val savedEvidence = mutableListOf<PinduoduoCandidateEvidence>()
var scrollCalls = 0 var scrollCalls = 0
var openSpecificationCalls = 0 var openSpecificationCalls = 0
var returnToResultsCalls = 0
var returnFromOrderConfirmationCalls = 0
var specificationScrollCalls = 0
val selectedOptions =
mutableListOf<Pair<PinduoduoSpecificationGroupKind, String>>()
private val effectiveSelections =
mutableListOf<Pair<PinduoduoSpecificationGroupKind, String>>()
override suspend fun snapshot(): PinduoduoUiSnapshot { override suspend fun snapshot(): PinduoduoUiSnapshot {
if (pendingPage != null) { if (pendingPage != null) {
@@ -217,6 +390,9 @@ class PinduoduoCandidateAutomationTest {
foregroundPackage = PINDUODUO_PACKAGE, foregroundPackage = PINDUODUO_PACKAGE,
page = page, page = page,
safetyStopReason = safetyStopReason safetyStopReason = safetyStopReason
?: SafetyStopReason.PAYMENT_BOUNDARY.takeIf {
page == PinduoduoPage.ORDER_CONFIRMATION
}
) )
} }
@@ -250,14 +426,41 @@ class PinduoduoCandidateAutomationTest {
if (failOpenSpecifications) { if (failOpenSpecifications) {
return false return false
} }
transitionTo(PinduoduoPage.SPECIFICATION_PANEL) transitionTo(
if (openOrderConfirmation) {
PinduoduoPage.ORDER_CONFIRMATION
} else {
PinduoduoPage.SPECIFICATION_PANEL
}
)
return true 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 =
PinduoduoSpecificationCapture( PinduoduoSpecificationCapture(
evidence = specification(), evidence = evidence,
screenshot = PinduoduoScreenshotCapture( screenshot = PinduoduoScreenshotCapture(
pngBytes = byteArrayOf(2), pngBytes = byteArrayOf(2),
width = 1080, width = 1080,
@@ -270,6 +473,12 @@ class PinduoduoCandidateAutomationTest {
return true return true
} }
override suspend fun returnFromOrderConfirmation(): Boolean {
returnFromOrderConfirmationCalls += 1
transitionTo(PinduoduoPage.PRODUCT_DETAIL)
return true
}
override suspend fun saveCandidate( override suspend fun saveCandidate(
ordinal: Int, ordinal: Int,
card: PinduoduoCandidateCard, card: PinduoduoCandidateCard,
@@ -289,6 +498,7 @@ class PinduoduoCandidateAutomationTest {
} }
override suspend fun returnToResults(): Boolean { override suspend fun returnToResults(): Boolean {
returnToResultsCalls += 1
transitionTo(PinduoduoPage.SEARCH_RESULTS) transitionTo(PinduoduoPage.SEARCH_RESULTS)
return true return true
} }
@@ -306,6 +516,11 @@ class PinduoduoCandidateAutomationTest {
savedEvidence.clear() savedEvidence.clear()
scrollCalls = 0 scrollCalls = 0
openSpecificationCalls = 0 openSpecificationCalls = 0
returnToResultsCalls = 0
returnFromOrderConfirmationCalls = 0
specificationScrollCalls = 0
selectedOptions.clear()
effectiveSelections.clear()
cardPageIndex = 0 cardPageIndex = 0
page = PinduoduoPage.SEARCH_RESULTS page = PinduoduoPage.SEARCH_RESULTS
pendingPage = null pendingPage = null
@@ -335,24 +550,57 @@ class PinduoduoCandidateAutomationTest {
private fun specification() = PinduoduoSpecificationEvidence( private fun specification() = PinduoduoSpecificationEvidence(
signature = "specification", signature = "specification",
semanticTextCount = 6, semanticTextCount = 6,
groups = listOf( groups = buildList {
PinduoduoSpecificationGroup( add(
kind = PinduoduoSpecificationGroupKind.COLOR, PinduoduoSpecificationGroup(
title = "颜色分类", kind = PinduoduoSpecificationGroupKind.COLOR,
options = listOf( title = "颜色分类",
PinduoduoSpecificationOption("黑色", false, true) options = listOf(
), PinduoduoSpecificationOption(
complete = true "黑色",
), effectiveSelections.any {
PinduoduoSpecificationGroup( it.first ==
kind = PinduoduoSpecificationGroupKind.SIZE, PinduoduoSpecificationGroupKind.COLOR
title = "尺码", },
options = listOf( true
PinduoduoSpecificationOption("L", false, true) )
), ),
complete = 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)
) )
} }
} }
@@ -156,7 +156,7 @@ class PinduoduoPageClassifierTest {
@Test @Test
fun `payment boundary blocks even on otherwise unknown page`() { 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(PinduoduoPage.UNKNOWN, snapshot.page)
assertEquals(SafetyStopReason.PAYMENT_BOUNDARY, snapshot.safetyStopReason) assertEquals(SafetyStopReason.PAYMENT_BOUNDARY, snapshot.safetyStopReason)
@@ -215,6 +215,48 @@ class PinduoduoPageClassifierTest {
assertNull(snapshot.safetyStopReason) 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 @Test
fun `cart and order pages are never classified as product detail`() { fun `cart and order pages are never classified as product detail`() {
val cart = classify( val cart = classify(
@@ -36,7 +36,7 @@ class PinduoduoSpecificationParserTest {
listOf( listOf(
element("颜色分类", top = 100), element("颜色分类", top = 100),
element("黑色", top = 140, clickable = true), element("黑色", top = 140, clickable = true),
element("", top = 150, scrollable = true), element("", top = 50, bottom = 400, scrollable = true),
element("尺码", top = 240), element("尺码", top = 240),
element("L", top = 280, clickable = true) element("L", top = 280, clickable = true)
) )
@@ -44,7 +44,7 @@ class PinduoduoSpecificationParserTest {
requireNotNull(evidence) requireNotNull(evidence)
assertFalse(evidence.groups.first().complete) assertFalse(evidence.groups.first().complete)
assertTrue(evidence.groups.last().complete) assertFalse(evidence.groups.last().complete)
} }
@Test @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( private fun element(
text: String, text: String,
top: Int, top: Int,
bottom: Int = top + 1,
clickable: Boolean = false, clickable: Boolean = false,
enabled: Boolean = true, enabled: Boolean = true,
selected: Boolean = false, selected: Boolean = false,
@@ -93,6 +253,7 @@ class PinduoduoSpecificationParserTest {
visibleToUser = true, visibleToUser = true,
selected = selected, selected = selected,
scrollable = scrollable, scrollable = scrollable,
boundsTop = top boundsTop = top,
boundsBottom = bottom
) )
} }
@@ -4,6 +4,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroup import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroup
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationOption import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationOption
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationPrice
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test 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 @Test
fun `truncated group and duplicated matching option are unknown`() { fun `truncated group and duplicated matching option are unknown`() {
assertEquals( assertEquals(
@@ -134,15 +168,18 @@ class CandidateSpecificationHardConstraintPolicyTest {
sizeOptions, sizeOptions,
true true
) )
) ),
selectedSummary = "已选择:经典黑色 XXL",
price = PinduoduoSpecificationPrice("¥10.9", 1090)
) )
private fun option( private fun option(
text: String, text: String,
enabled: Boolean = true enabled: Boolean = true,
selected: Boolean = true
) = PinduoduoSpecificationOption( ) = PinduoduoSpecificationOption(
text = text, text = text,
selected = false, selected = selected,
enabled = enabled enabled = enabled
) )
} }
+9 -2
View File
@@ -266,8 +266,9 @@ T-102/T-104 在搜索结果后追加一个有界候选步骤:
-> 一次全局返回并复核固定词结果页 -> 一次全局返回并复核固定词结果页
``` ```
候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v2 记录 候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v3 记录
匿名文件名、截图 SHA-256、字节数、尺寸和有界规格语义,不保存商品标题或完整页面 匿名文件名、截图 SHA-256、字节数、尺寸、有界规格语义、已选摘要和严格组合价格,
不保存商品标题或完整页面
原文。T-104/T-213 使用这些证据时必须通过受控 evidence 边界读取,不能让 VLM adapter 原文。T-104/T-213 使用这些证据时必须通过受控 evidence 边界读取,不能让 VLM adapter
自行遍历 cache。Android 10/API 29 及以下不能运行当前截图探针,应在预检时明确 自行遍历 cache。Android 10/API 29 及以下不能运行当前截图探针,应在预检时明确
不支持,不使用媒体投影或 shell 绕过。 不支持,不使用媒体投影或 shell 绕过。
@@ -300,6 +301,12 @@ PKG110 上的拼多多 8.17.0 服装详情只暴露不可点击的“颜色/款
提交订单或触发支付。后续 `ORDER_CREATE` 必须由 Admin 对确定候选签发一次性授权, 提交订单或触发支付。后续 `ORDER_CREATE` 必须由 Admin 对确定候选签发一次性授权,
使用独立状态机和幂等/对账边界,不能复用候选探查权限。 使用独立状态机和幂等/对账边界,不能复用候选探查权限。
`DISCOVERY_INSPECT` 从任务 SKU 的本地规范化结果取得唯一颜色/尺码,按颜色后尺码
的固定顺序选择,每次动作后重新读取 selected 与“已选择”摘要;规格区域最多向前
滚动 2 次。价格只接受单一、完整的人民币文本并保存分值,区间、券后、条件价格、
多价格冲突或无价格一律降级 `UNKNOWN`。若唯一入口落到订单确认页,自动化只能执行
系统返回并跳过该候选。
评估结束后 automation 不再产生动作,UI 进入 `AWAITING_CONFIRMATION`、 评估结束后 automation 不再产生动作,UI 进入 `AWAITING_CONFIRMATION`、
`MANUAL_REVIEW` 或 `NO_MATCH`。人员可以把本地建议项标记为可用,或拒绝本次候选; `MANUAL_REVIEW` 或 `NO_MATCH`。人员可以把本地建议项标记为可用,或拒绝本次候选;
这只是验证结果,所有状态的 `order_submitted` 均为 `false`。 这只是验证结果,所有状态的 `order_submitted` 均为 `false`。
+11 -10
View File
@@ -5,9 +5,9 @@
## 当前快照 ## 当前快照
- 日期:2026-07-27 - 日期: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、 - 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-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
@@ -17,8 +17,8 @@
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-213 运行 `:app:testDebugUnitTest` 和 `:app:assembleDebug` 通过,24 个 suite - 测试:T-213 Android Debug 24 个 suite、143 个测试、0 失败,Debug/Release 回归
共 128 个测试、0 失败;Debug APK `1.4.3 (8)` 已安装到 PKG110 和 `assembleDebug` 通过;Debug APK `1.4.4 (9)` 已安装并冷启动于 PKG110
- 后端测试:T-213 运行 `go test ./...` 通过;T-207 的全包 race 与 migration - 后端测试:T-213 运行 `go test ./...` 通过;T-207 的全包 race 与 migration
验证继续有效 验证继续有效
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
@@ -50,9 +50,10 @@
图片进入拼多多拍照搜索;App 本地从 SKU 唯一提取颜色和尺码,schema v3 逐项返回 图片进入拼多多拍照搜索;App 本地从 SKU 唯一提取颜色和尺码,schema v3 逐项返回
`MATCH/MISMATCH/UNKNOWN`。只有两项均匹配且分数/置信度不低于 `0.75` 的候选按 `MATCH/MISMATCH/UNKNOWN`。只有两项均匹配且分数/置信度不低于 `0.75` 的候选按
分数、置信度和曝光顺序回传 `0..5` 项,弱匹配和未知项不凑数。 分数、置信度和曝光顺序回传 `0..5` 项,弱匹配和未知项不凑数。
- T-213 规格核验:旧版只读解析、详情/规格双证据和本地硬约束已完成;新业务边界 - T-213 规格核验:`DISCOVERY_INSPECT` 可从唯一“免拼购买”进入规格弹层,按任务
允许 `DISCOVERY_INSPECT` 从“免拼购买”进入规格弹层,正在补充目标颜色/尺码选择、 SKU 唯一选择颜色/尺码并复核 selected/已选摘要,最多滚动 2 次;严格保存单一
组合价格读取和退出验证。候选探查仍不能加入购物车、提交订单或触发支付。 人民币组合价和详情/规格双证据。区间/条件/冲突/缺失价格降级 `UNKNOWN`,直达
订单确认页只系统返回。候选探查不能加入购物车、提交订单或触发支付。
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -68,7 +69,7 @@
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
RUNNING 不自动重新分配 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 (81700)`
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
真机验证;最终 APK 重装/force-stop 后 ColorOS 已关闭肉包采购无障碍,当前需采购员 真机验证;最终 APK 重装/force-stop 后 ColorOS 已关闭肉包采购无障碍,当前需采购员
@@ -112,7 +113,7 @@
| `docs/tasks/T-210.md` | DONE | 兼容拼多多 8.17 搜索结果页与长词省略显示 | | `docs/tasks/T-210.md` | DONE | 兼容拼多多 8.17 搜索结果页与长词省略显示 |
| `docs/tasks/T-211.md` | DONE | 参考图召回、SKU 颜色尺码硬匹配与 0..5 候选回传 | | `docs/tasks/T-211.md` | DONE | 参考图召回、SKU 颜色尺码硬匹配与 0..5 候选回传 |
| `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 | | `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 页面 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `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-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、T-210、T-211、
T-212。 T-212。
- 正在进行:T-213 候选 SKU 组合与价格核验。 - 正在进行:T-213 仅剩用户授权后的真机自动化 smoke。
- 下一个可领取任务:无;T-208 依赖 T-213 真机验收完成。 - 下一个可领取任务:无;T-208 依赖 T-213 真机验收完成。
- 后置任务:T-213 完成后做 T-208 候选决策数据与人工理由闭环,再依次实现商品 - 后置任务:T-213 完成后做 T-208 候选决策数据与人工理由闭环,再依次实现商品
持久身份、Admin 下单授权、设备命令、订单 dry-run、单次提交对账和付款提醒。 持久身份、Admin 下单授权、设备命令、订单 dry-run、单次提交对账和付款提醒。
+23 -8
View File
@@ -4,7 +4,7 @@ title: 拼多多候选 SKU 组合与价格核验
phase: 2 phase: 2
deps: deps:
- T-212 - T-212
status: DOING status: BLOCKED
created: 2026-07-27 created: 2026-07-27
context_ref: 9a8388f context_ref: 9a8388f
work_branch: null work_branch: null
@@ -58,16 +58,16 @@ T-211 目前只用候选详情页首屏截图判断颜色和尺码。真机已
## 验收要点 ## 验收要点
- [ ] 页面分类覆盖详情、规格弹层、订单确认、订单列表、登录、验证码、风控和支付。 - [x] 页面分类覆盖详情、规格弹层、订单确认、订单列表、登录、验证码、风控和支付。
- [ ] `DISCOVERY_INSPECT` 只允许进入规格弹层、选择目标 SKU、读取价格和返回;代码 - [x] `DISCOVERY_INSPECT` 只允许进入规格弹层、选择目标 SKU、读取价格和返回;代码
中不存在提交订单、支付或加入购物车动作。 中不存在提交订单、支付或加入购物车动作。
- [ ] 颜色/尺码必须各自唯一匹配;依次点击后验证 selected/已选语义,禁用、重复、 - [x] 颜色/尺码必须各自唯一匹配;依次点击后验证 selected/已选语义,禁用、重复、
缺失、截断和状态未变化均有测试。 缺失、截断和状态未变化均有测试。
- [ ] 组合价格使用严格人民币解析并保存分值;模糊区间价、券后条件价、多价格冲突和 - [x] 组合价格使用严格人民币解析并保存分值;模糊区间价、券后条件价、多价格冲突和
无价格均不能形成自动匹配证据。 无价格均不能形成自动匹配证据。
- [ ] 若购买入口直接进入订单确认页,自动化不点击任何页面控件并安全返回。 - [x] 若购买入口直接进入订单确认页,自动化不点击任何页面控件并安全返回。
- [ ] 每个候选的详情/规格证据、所选规格和价格不会在重排/outbox 中串位。 - [x] 每个候选的详情/规格证据、所选规格和价格不会在重排/outbox 中串位。
- [ ] Android 单元测试、Debug 构建和后端回归通过。 - [x] Android 单元测试、Debug 构建和后端回归通过。
- [ ] PKG110、Android 16、肉包/拼多多版本真机 smoke 完成进入弹层、选择测试 SKU、 - [ ] PKG110、Android 16、肉包/拼多多版本真机 smoke 完成进入弹层、选择测试 SKU、
读取组合价格、关闭和返回;确认未创建订单、未进入支付。 读取组合价格、关闭和返回;确认未创建订单、未进入支付。
@@ -105,3 +105,18 @@ T-211 目前只用候选详情页首屏截图判断颜色和尺码。真机已
并创建待付款订单,采购员最后在拼多多人工确认和付款。外部决策已解除旧阻塞,本 并创建待付款订单,采购员最后在拼多多人工确认和付款。外部决策已解除旧阻塞,本
任务重新置为 `DOING`,权限缩小为候选阶段的 `DISCOVERY_INSPECT`;真正 任务重新置为 `DOING`,权限缩小为候选阶段的 `DISCOVERY_INSPECT`;真正
`ORDER_CREATE` 仍须一次性 Admin 授权和独立任务。 `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、读取组合价、关闭并返回,
同时确认拼多多没有生成订单或进入支付。