feat(t213): verify selected SKU combination price
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
skuText =
|
||||
candidateEvidence.specification
|
||||
.selectedSummary
|
||||
?: assessment
|
||||
.hardConstraintResults
|
||||
.joinToString(" / ") {
|
||||
"${it.kind.name}:${it.expected}"
|
||||
"${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()
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -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
|
||||
|
||||
+121
-3
@@ -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)
|
||||
)
|
||||
})
|
||||
val target = directTargets.singleOrNull()
|
||||
?: uniqueClickableTargets(
|
||||
nodes.filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
normalizeActionText(semanticText(node))
|
||||
.endsWith(SPECIFICATION_PURCHASE_ENTRY)
|
||||
}
|
||||
entries.singleOrNull()?.let(::clickNodeOrAncestor) == true
|
||||
).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<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(
|
||||
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*$")
|
||||
}
|
||||
|
||||
+35
-4
@@ -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)
|
||||
|
||||
+17
-2
@@ -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,
|
||||
|
||||
+190
-3
@@ -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<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? {
|
||||
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(
|
||||
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+28
-1
@@ -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 =
|
||||
(
|
||||
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
|
||||
|
||||
+236
-9
@@ -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<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 {
|
||||
fun parse(
|
||||
elements: Collection<PinduoduoUiElement>
|
||||
@@ -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>
|
||||
): 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 &&
|
||||
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<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.PinduoduoSpecificationGroupKind
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTargetMatcher
|
||||
import java.text.Normalizer
|
||||
|
||||
enum class SkuConstraintKind {
|
||||
@@ -107,8 +108,21 @@ object CandidateSpecificationHardConstraintPolicy {
|
||||
constraints: List<SkuHardConstraint>,
|
||||
evidence: PinduoduoSpecificationEvidence,
|
||||
evidenceSha256: String
|
||||
): List<CandidateHardConstraintResult> =
|
||||
constraints.map { constraint ->
|
||||
): List<CandidateHardConstraintResult> {
|
||||
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<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", "橙色")
|
||||
)
|
||||
}
|
||||
|
||||
+257
-9
@@ -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<PinduoduoCandidateEvidence>()
|
||||
var scrollCalls = 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 {
|
||||
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(
|
||||
groups = buildList {
|
||||
add(
|
||||
PinduoduoSpecificationGroup(
|
||||
kind = PinduoduoSpecificationGroupKind.COLOR,
|
||||
title = "颜色分类",
|
||||
options = listOf(
|
||||
PinduoduoSpecificationOption("黑色", false, true)
|
||||
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", false, true)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -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(
|
||||
|
||||
+164
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+40
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`。
|
||||
|
||||
+11
-10
@@ -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、单次提交对账和付款提醒。
|
||||
|
||||
+23
-8
@@ -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、读取组合价、关闭并返回,
|
||||
同时确认拼多多没有生成订单或进入支付。
|
||||
|
||||
Reference in New Issue
Block a user