feat(t213): verify selected SKU combination price
This commit is contained in:
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -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
|
||||||
|
|||||||
+122
-4
@@ -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*$")
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-4
@@ -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)
|
||||||
|
|||||||
+17
-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,
|
||||||
|
|||||||
+190
-3
@@ -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(
|
||||||
|
|||||||
+10
-1
@@ -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,
|
||||||
|
|||||||
+30
-3
@@ -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
|
||||||
|
|||||||
+236
-9
@@ -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", "橙色")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+270
-22
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -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(
|
||||||
|
|||||||
+164
-3
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-3
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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、读取组合价、关闭并返回,
|
||||||
|
同时确认拼多多没有生成订单或进入支付。
|
||||||
|
|||||||
Reference in New Issue
Block a user