feat(android): collect bounded Pinduoduo candidates
This commit is contained in:
@@ -48,8 +48,13 @@ import kotlinx.coroutines.launch
|
||||
import rikka.shizuku.Shizuku
|
||||
import android.util.Log
|
||||
import com.roubao.autopilot.pinduoduo.AndroidPinduoduoUiDriver
|
||||
import com.roubao.autopilot.pinduoduo.AndroidPinduoduoCandidateDriver
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateAutomation
|
||||
import com.roubao.autopilot.pinduoduo.CandidateBrowsePhase
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateWorkflow
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoProbeAutomation
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
|
||||
import com.roubao.autopilot.workflow.WorkflowReport
|
||||
import com.roubao.autopilot.workflow.WorkflowRunner
|
||||
import com.roubao.autopilot.workflow.WorkflowState
|
||||
@@ -76,6 +81,8 @@ class MainActivity : ComponentActivity() {
|
||||
private val searchProbeState = mutableStateOf(WorkflowState.IDLE)
|
||||
private val searchProbeStepId = mutableStateOf<String?>(null)
|
||||
private val searchProbeReport = mutableStateOf<WorkflowReport?>(null)
|
||||
private val candidateEvidence =
|
||||
mutableStateOf<List<PinduoduoCandidateEvidence>>(emptyList())
|
||||
private var searchProbeRunner: WorkflowRunner? = null
|
||||
private var searchProbeJob: Job? = null
|
||||
|
||||
@@ -198,6 +205,7 @@ class MainActivity : ComponentActivity() {
|
||||
val probeState by remember { searchProbeState }
|
||||
val probeStepId by remember { searchProbeStepId }
|
||||
val probeReport by remember { searchProbeReport }
|
||||
val evidence by remember { candidateEvidence }
|
||||
|
||||
// 监听跳转事件
|
||||
LaunchedEffect(navigateToRecord, recordId) {
|
||||
@@ -286,6 +294,7 @@ class MainActivity : ComponentActivity() {
|
||||
state = probeState,
|
||||
currentStepId = probeStepId,
|
||||
report = probeReport,
|
||||
candidateEvidenceCount = evidence.size,
|
||||
onStart = { startSearchProbe() },
|
||||
onStop = { stopSearchProbe() }
|
||||
)
|
||||
@@ -384,13 +393,23 @@ class MainActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
val candidateAutomation = PinduoduoCandidateAutomation(
|
||||
AndroidPinduoduoCandidateDriver(this)
|
||||
)
|
||||
candidateAutomation.reset()
|
||||
val runner = WorkflowRunner(
|
||||
PinduoduoSearchAutomation(AndroidPinduoduoUiDriver(this))
|
||||
PinduoduoProbeAutomation(
|
||||
searchAutomation = PinduoduoSearchAutomation(
|
||||
AndroidPinduoduoUiDriver(this)
|
||||
),
|
||||
candidateAutomation = candidateAutomation
|
||||
)
|
||||
)
|
||||
searchProbeRunner = runner
|
||||
searchProbeReport.value = null
|
||||
searchProbeState.value = WorkflowState.IDLE
|
||||
searchProbeStepId.value = null
|
||||
candidateEvidence.value = emptyList()
|
||||
searchProbeJob = lifecycleScope.launch {
|
||||
val stateCollector = launch {
|
||||
runner.state.collect { state -> searchProbeState.value = state }
|
||||
@@ -398,13 +417,27 @@ class MainActivity : ComponentActivity() {
|
||||
val stepCollector = launch {
|
||||
runner.currentStepId.collect { stepId -> searchProbeStepId.value = stepId }
|
||||
}
|
||||
val evidenceCollector = launch {
|
||||
candidateAutomation.evidence.collect { evidence ->
|
||||
candidateEvidence.value = evidence
|
||||
}
|
||||
}
|
||||
val candidatePhaseCollector = launch {
|
||||
candidateAutomation.phase.collect { phase ->
|
||||
if (phase != CandidateBrowsePhase.IDLE) {
|
||||
Log.d(TAG, "Candidate probe phase: ${phase.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val report = runner.run(PinduoduoSearchWorkflow.steps())
|
||||
val report = runner.run(PinduoduoCandidateWorkflow.steps())
|
||||
searchProbeReport.value = report
|
||||
searchProbeState.value = report.state
|
||||
} finally {
|
||||
stateCollector.cancel()
|
||||
stepCollector.cancel()
|
||||
evidenceCollector.cancel()
|
||||
candidatePhaseCollector.cancel()
|
||||
searchProbeRunner = null
|
||||
refreshReadiness()
|
||||
}
|
||||
|
||||
+38
@@ -1,9 +1,13 @@
|
||||
package com.roubao.autopilot.accessibility
|
||||
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoPage
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
|
||||
import com.roubao.autopilot.readiness.DeviceObservationStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object BuyerAccessibilityBridge {
|
||||
@@ -46,4 +50,38 @@ object BuyerAccessibilityBridge {
|
||||
suspend fun submitSearch(): Boolean = withContext(Dispatchers.Main.immediate) {
|
||||
service?.submitPinduoduoSearch() == true
|
||||
}
|
||||
|
||||
suspend fun candidateCards(limit: Int): List<PinduoduoCandidateCard> =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.readPinduoduoCandidateCards(limit).orEmpty()
|
||||
}
|
||||
|
||||
suspend fun openCandidate(signature: String): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.openPinduoduoCandidate(signature) == true
|
||||
}
|
||||
|
||||
suspend fun candidateDetailEvidence(): PinduoduoCandidateDetailEvidence? =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.readPinduoduoCandidateDetailEvidence()
|
||||
}
|
||||
|
||||
suspend fun captureScreenshot(): PinduoduoScreenshotCapture? =
|
||||
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.capturePinduoduoScreenshot()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun returnToResults(): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.returnFromPinduoduoCandidate() == true
|
||||
}
|
||||
|
||||
suspend fun scrollResults(): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.scrollPinduoduoResults() == true
|
||||
}
|
||||
|
||||
private const val SCREENSHOT_TIMEOUT_MILLIS = 8_000L
|
||||
}
|
||||
|
||||
+399
@@ -1,11 +1,19 @@
|
||||
package com.roubao.autopilot.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash
|
||||
import com.roubao.autopilot.readiness.DeviceObservationStore
|
||||
import com.roubao.autopilot.readiness.LoginBlockerDetector
|
||||
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
||||
@@ -14,11 +22,17 @@ import com.roubao.autopilot.pinduoduo.PinduoduoUiElement
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoPage
|
||||
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class BuyerAccessibilityService : AccessibilityService() {
|
||||
private var lastPinduoduoScanAt = 0L
|
||||
private var expectedSearchQuery = SEARCH_PROBE_KEYWORD
|
||||
private val screenshotExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
@@ -62,6 +76,7 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
override fun onDestroy() {
|
||||
BuyerAccessibilityBridge.detach(this)
|
||||
DeviceObservationStore.setAccessibilityConnected(false)
|
||||
screenshotExecutor.shutdownNow()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -172,6 +187,165 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
) == true
|
||||
} ?: false
|
||||
|
||||
internal fun readPinduoduoCandidateCards(
|
||||
limit: Int
|
||||
): List<PinduoduoCandidateCard> =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
snapshot.page != PinduoduoPage.SEARCH_RESULTS
|
||||
) {
|
||||
return@withPinduoduoRoot emptyList()
|
||||
}
|
||||
candidateNodes(root, limit).map(CandidateNode::card)
|
||||
} ?: emptyList()
|
||||
|
||||
internal fun openPinduoduoCandidate(signature: String): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
snapshot.page != PinduoduoPage.SEARCH_RESULTS
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
candidateNodes(root, MAX_CANDIDATE_NODE_SCAN)
|
||||
.filter { candidate -> candidate.card.signature == signature }
|
||||
.singleOrNull()
|
||||
?.node
|
||||
?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
|
||||
} ?: false
|
||||
|
||||
internal fun readPinduoduoCandidateDetailEvidence():
|
||||
PinduoduoCandidateDetailEvidence? =
|
||||
withPinduoduoRoot { root ->
|
||||
if (!isVerifiedProductDetail(root)) {
|
||||
return@withPinduoduoRoot null
|
||||
}
|
||||
val semanticTexts = collectSemanticTexts(root)
|
||||
if (semanticTexts.isEmpty()) {
|
||||
return@withPinduoduoRoot null
|
||||
}
|
||||
PinduoduoCandidateDetailEvidence(
|
||||
signature = PinduoduoEvidenceHash.sha256(
|
||||
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
|
||||
),
|
||||
semanticTextCount = semanticTexts.size
|
||||
)
|
||||
}
|
||||
|
||||
internal fun returnFromPinduoduoCandidate(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
if (!isVerifiedProductDetail(root)) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
} ?: false
|
||||
|
||||
internal fun scrollPinduoduoResults(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
snapshot.page != PinduoduoPage.SEARCH_RESULTS
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
findMainResultsRecycler(root)?.performAction(
|
||||
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
|
||||
) == true
|
||||
} ?: false
|
||||
|
||||
internal suspend fun capturePinduoduoScreenshot(): PinduoduoScreenshotCapture? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
return null
|
||||
}
|
||||
return capturePinduoduoScreenshotApi30()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private suspend fun capturePinduoduoScreenshotApi30():
|
||||
PinduoduoScreenshotCapture? {
|
||||
val root = rootInActiveWindow
|
||||
if (
|
||||
root?.packageName?.toString() != PINDUODUO_PACKAGE ||
|
||||
!isVerifiedProductDetail(root)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback = object : TakeScreenshotCallback {
|
||||
override fun onSuccess(screenshot: ScreenshotResult) {
|
||||
val hardwareBuffer = screenshot.hardwareBuffer
|
||||
var hardwareBitmap: Bitmap? = null
|
||||
var softwareBitmap: Bitmap? = null
|
||||
val capture = try {
|
||||
hardwareBitmap = Bitmap.wrapHardwareBuffer(
|
||||
hardwareBuffer,
|
||||
screenshot.colorSpace
|
||||
)
|
||||
softwareBitmap = hardwareBitmap?.copy(
|
||||
Bitmap.Config.ARGB_8888,
|
||||
false
|
||||
)
|
||||
val bitmap = softwareBitmap
|
||||
if (bitmap == null) {
|
||||
null
|
||||
} else {
|
||||
val output = ByteArrayOutputStream()
|
||||
if (
|
||||
!bitmap.compress(
|
||||
Bitmap.CompressFormat.PNG,
|
||||
100,
|
||||
output
|
||||
)
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
PinduoduoScreenshotCapture(
|
||||
pngBytes = output.toByteArray(),
|
||||
width = bitmap.width,
|
||||
height = bitmap.height
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "Pinduoduo screenshot conversion failed")
|
||||
null
|
||||
} finally {
|
||||
softwareBitmap?.recycle()
|
||||
hardwareBitmap?.recycle()
|
||||
hardwareBuffer.close()
|
||||
}
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(capture)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(errorCode: Int) {
|
||||
Log.w(TAG, "Pinduoduo screenshot failed with code $errorCode")
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
takeScreenshot(
|
||||
Display.DEFAULT_DISPLAY,
|
||||
screenshotExecutor,
|
||||
callback
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "Pinduoduo screenshot request failed")
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyPinduoduoRoot(
|
||||
root: AccessibilityNodeInfo
|
||||
): PinduoduoUiSnapshot {
|
||||
@@ -194,6 +368,205 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun candidateNodes(
|
||||
root: AccessibilityNodeInfo,
|
||||
limit: Int
|
||||
): List<CandidateNode> {
|
||||
if (limit <= 0) {
|
||||
return emptyList()
|
||||
}
|
||||
val recycler = findMainResultsRecycler(root) ?: return emptyList()
|
||||
val rootBounds = Rect().also(root::getBoundsInScreen)
|
||||
val recyclerBounds = Rect().also(recycler::getBoundsInScreen)
|
||||
val minimumHeight = (rootBounds.height() / 8).coerceAtLeast(1)
|
||||
val candidates = ArrayList<CandidateNode>()
|
||||
|
||||
for (index in 0 until recycler.childCount) {
|
||||
val node = recycler.getChild(index) ?: continue
|
||||
if (
|
||||
!node.isVisibleToUser ||
|
||||
!node.isEnabled ||
|
||||
node.className?.toString()?.endsWith("FrameLayout") != true
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (
|
||||
bounds.width() * 100 < recyclerBounds.width() * MIN_CARD_WIDTH_PERCENT ||
|
||||
bounds.width() * 100 > recyclerBounds.width() * MAX_CARD_WIDTH_PERCENT ||
|
||||
bounds.height() < minimumHeight
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val visibleBounds = Rect(bounds)
|
||||
if (
|
||||
!visibleBounds.intersect(recyclerBounds) ||
|
||||
area(visibleBounds) * 100 < area(bounds) * MIN_VISIBLE_CARD_PERCENT
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
val descendants = collectNodes(node)
|
||||
val semanticTexts = collectSemanticTexts(descendants)
|
||||
val hasImage = descendants.any { descendant ->
|
||||
descendant.isVisibleToUser &&
|
||||
descendant.isEnabled &&
|
||||
descendant.className?.toString()?.endsWith("ImageView") == true
|
||||
}
|
||||
val hasPrice = semanticTexts.any(::hasPriceSemantics)
|
||||
val clickTarget = findSafeCandidateClickTarget(node, descendants)
|
||||
if (
|
||||
semanticTexts.size < MIN_CANDIDATE_TEXTS ||
|
||||
!hasImage ||
|
||||
!hasPrice ||
|
||||
clickTarget == null
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val card = PinduoduoCandidateCard(
|
||||
signature = PinduoduoEvidenceHash.sha256(
|
||||
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
|
||||
),
|
||||
semanticTextCount = semanticTexts.size,
|
||||
hasImage = true
|
||||
)
|
||||
candidates += CandidateNode(clickTarget, card)
|
||||
if (candidates.size >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private fun isVerifiedProductDetail(root: AccessibilityNodeInfo): Boolean {
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
snapshot.page != PinduoduoPage.PRODUCT_DETAIL
|
||||
) {
|
||||
return false
|
||||
}
|
||||
val rootBounds = Rect().also(root::getBoundsInScreen)
|
||||
val nodes = collectNodes(root)
|
||||
val hasWideViewPager = nodes.any { node ->
|
||||
if (
|
||||
!node.isVisibleToUser ||
|
||||
!node.isEnabled ||
|
||||
node.className?.toString()?.endsWith("ViewPager") != true
|
||||
) {
|
||||
return@any false
|
||||
}
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
bounds.width() * 100 >=
|
||||
rootBounds.width() * MIN_DETAIL_CONTENT_WIDTH_PERCENT
|
||||
}
|
||||
val hasWideScrollableRecycler = nodes.any { node ->
|
||||
if (
|
||||
!node.isVisibleToUser ||
|
||||
!node.isEnabled ||
|
||||
!node.isScrollable ||
|
||||
node.className?.toString()?.endsWith("RecyclerView") != true
|
||||
) {
|
||||
return@any false
|
||||
}
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
bounds.width() * 100 >=
|
||||
rootBounds.width() * MIN_DETAIL_CONTENT_WIDTH_PERCENT &&
|
||||
collectNodes(node).size >= MIN_DETAIL_RECYCLER_NODES
|
||||
}
|
||||
return hasWideViewPager && hasWideScrollableRecycler
|
||||
}
|
||||
|
||||
private fun findMainResultsRecycler(
|
||||
root: AccessibilityNodeInfo
|
||||
): AccessibilityNodeInfo? {
|
||||
val rootBounds = Rect().also(root::getBoundsInScreen)
|
||||
return collectNodes(root)
|
||||
.asSequence()
|
||||
.filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
node.isScrollable &&
|
||||
node.className?.toString()?.endsWith("RecyclerView") == true
|
||||
}
|
||||
.map { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
RecyclerCandidate(
|
||||
node = node,
|
||||
bounds = bounds,
|
||||
descendantCount = collectNodes(node).size
|
||||
)
|
||||
}
|
||||
.filter { candidate ->
|
||||
candidate.bounds.width() * 100 >=
|
||||
rootBounds.width() * MIN_MAIN_RECYCLER_WIDTH_PERCENT &&
|
||||
candidate.descendantCount >= MIN_MAIN_RECYCLER_NODES
|
||||
}
|
||||
.maxByOrNull { candidate -> area(candidate.bounds) }
|
||||
?.node
|
||||
}
|
||||
|
||||
private fun findSafeCandidateClickTarget(
|
||||
cardRoot: AccessibilityNodeInfo,
|
||||
descendants: Collection<AccessibilityNodeInfo>
|
||||
): AccessibilityNodeInfo? {
|
||||
if (cardRoot.isClickable) {
|
||||
return cardRoot
|
||||
}
|
||||
val cardBounds = Rect().also(cardRoot::getBoundsInScreen)
|
||||
val coveringTargets = descendants.filter { node ->
|
||||
if (
|
||||
node === cardRoot ||
|
||||
!node.isVisibleToUser ||
|
||||
!node.isEnabled ||
|
||||
!node.isClickable
|
||||
) {
|
||||
return@filter false
|
||||
}
|
||||
val targetBounds = Rect().also(node::getBoundsInScreen)
|
||||
val overlap = Rect(targetBounds)
|
||||
overlap.intersect(cardBounds) &&
|
||||
area(overlap) * 100 >=
|
||||
area(cardBounds) * MIN_CLICK_TARGET_COVER_PERCENT
|
||||
}
|
||||
return coveringTargets.singleOrNull()
|
||||
}
|
||||
|
||||
private fun hasPriceSemantics(value: String): Boolean =
|
||||
value.contains('¥') ||
|
||||
value.contains('¥') ||
|
||||
DECIMAL_PRICE_PATTERN.matches(value)
|
||||
|
||||
private fun area(bounds: Rect): Long =
|
||||
bounds.width().toLong() * bounds.height().toLong()
|
||||
|
||||
private fun collectSemanticTexts(
|
||||
root: AccessibilityNodeInfo
|
||||
): List<String> = collectSemanticTexts(collectNodes(root))
|
||||
|
||||
private fun collectSemanticTexts(
|
||||
nodes: Collection<AccessibilityNodeInfo>
|
||||
): List<String> =
|
||||
nodes.asSequence()
|
||||
.filter { node -> node.isVisibleToUser && node.isEnabled }
|
||||
.flatMap { node ->
|
||||
sequenceOf(
|
||||
node.text?.toString(),
|
||||
node.contentDescription?.toString()
|
||||
)
|
||||
}
|
||||
.filterNotNull()
|
||||
.map(::normalizeEvidenceText)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
.take(MAX_EVIDENCE_TEXTS)
|
||||
.toList()
|
||||
|
||||
private fun normalizeEvidenceText(value: String): String =
|
||||
value.trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.take(MAX_EVIDENCE_TEXT_LENGTH)
|
||||
|
||||
private fun shouldInspect(eventType: Int): Boolean =
|
||||
eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED ||
|
||||
eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED ||
|
||||
@@ -252,10 +625,36 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
return false
|
||||
}
|
||||
|
||||
private data class CandidateNode(
|
||||
val node: AccessibilityNodeInfo,
|
||||
val card: PinduoduoCandidateCard
|
||||
)
|
||||
|
||||
private data class RecyclerCandidate(
|
||||
val node: AccessibilityNodeInfo,
|
||||
val bounds: Rect,
|
||||
val descendantCount: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BuyerAccessibility"
|
||||
private const val MAX_NODES = 300
|
||||
private const val MAX_CLICK_ANCESTORS = 4
|
||||
private const val MAX_CANDIDATE_NODE_SCAN = 20
|
||||
private const val MIN_CANDIDATE_TEXTS = 2
|
||||
private const val MIN_MAIN_RECYCLER_NODES = 20
|
||||
private const val MIN_MAIN_RECYCLER_WIDTH_PERCENT = 90
|
||||
private const val MIN_DETAIL_CONTENT_WIDTH_PERCENT = 90
|
||||
private const val MIN_DETAIL_RECYCLER_NODES = 40
|
||||
private const val MIN_CARD_WIDTH_PERCENT = 35
|
||||
private const val MAX_CARD_WIDTH_PERCENT = 60
|
||||
private const val MIN_VISIBLE_CARD_PERCENT = 80
|
||||
private const val MIN_CLICK_TARGET_COVER_PERCENT = 85
|
||||
private const val MAX_EVIDENCE_TEXTS = 100
|
||||
private const val MAX_EVIDENCE_TEXT_LENGTH = 500
|
||||
private const val TEXT_SIGNATURE_SEPARATOR = "\u001f"
|
||||
private const val MIN_SCAN_INTERVAL_MS = 300L
|
||||
private val DECIMAL_PRICE_PATTERN =
|
||||
Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$")
|
||||
}
|
||||
}
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.roubao.autopilot.pinduoduo
|
||||
|
||||
import android.content.Context
|
||||
import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class AndroidPinduoduoCandidateDriver(
|
||||
context: Context
|
||||
) : PinduoduoCandidateDriver {
|
||||
private val evidenceStore = CandidateEvidenceStore(context.applicationContext)
|
||||
|
||||
override suspend fun snapshot(): PinduoduoUiSnapshot =
|
||||
BuyerAccessibilityBridge.snapshot()
|
||||
|
||||
override suspend fun candidateCards(
|
||||
limit: Int
|
||||
): List<PinduoduoCandidateCard> =
|
||||
BuyerAccessibilityBridge.candidateCards(limit)
|
||||
|
||||
override suspend fun openCandidate(signature: String): Boolean =
|
||||
BuyerAccessibilityBridge.openCandidate(signature)
|
||||
|
||||
override suspend fun captureCandidate(
|
||||
ordinal: Int,
|
||||
card: PinduoduoCandidateCard
|
||||
): PinduoduoCandidateEvidence? {
|
||||
val detail = BuyerAccessibilityBridge.candidateDetailEvidence()
|
||||
?: return null
|
||||
val screenshot = BuyerAccessibilityBridge.captureScreenshot()
|
||||
?: return null
|
||||
return withContext(Dispatchers.IO) {
|
||||
evidenceStore.save(
|
||||
ordinal = ordinal,
|
||||
card = card,
|
||||
detail = detail,
|
||||
screenshot = screenshot
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun returnToResults(): Boolean =
|
||||
BuyerAccessibilityBridge.returnToResults()
|
||||
|
||||
override suspend fun scrollResults(): Boolean =
|
||||
BuyerAccessibilityBridge.scrollResults()
|
||||
|
||||
override fun resetEvidence() {
|
||||
evidenceStore.reset()
|
||||
}
|
||||
}
|
||||
|
||||
private class CandidateEvidenceStore(context: Context) {
|
||||
private val root = File(context.cacheDir, EVIDENCE_DIRECTORY)
|
||||
private val evidenceByOrdinal =
|
||||
linkedMapOf<Int, PinduoduoCandidateEvidence>()
|
||||
|
||||
fun reset() {
|
||||
evidenceByOrdinal.clear()
|
||||
if (root.exists()) {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
root.mkdirs()
|
||||
}
|
||||
|
||||
fun save(
|
||||
ordinal: Int,
|
||||
card: PinduoduoCandidateCard,
|
||||
detail: PinduoduoCandidateDetailEvidence,
|
||||
screenshot: PinduoduoScreenshotCapture
|
||||
): PinduoduoCandidateEvidence? {
|
||||
if (ordinal !in 1..MAX_CANDIDATES_PER_PROBE) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
if (!root.exists() && !root.mkdirs()) {
|
||||
return null
|
||||
}
|
||||
val fileName = "candidate-%02d.png".format(ordinal)
|
||||
val screenshotFile = File(root, fileName)
|
||||
screenshotFile.writeBytes(screenshot.pngBytes)
|
||||
val evidence = PinduoduoCandidateEvidence(
|
||||
ordinal = ordinal,
|
||||
cardSignature = card.signature,
|
||||
cardSemanticTextCount = card.semanticTextCount,
|
||||
detailSignature = detail.signature,
|
||||
detailSemanticTextCount = detail.semanticTextCount,
|
||||
screenshotFileName = fileName,
|
||||
screenshotSha256 = PinduoduoEvidenceHash.sha256(
|
||||
screenshot.pngBytes
|
||||
),
|
||||
screenshotByteCount = screenshot.pngBytes.size,
|
||||
screenshotWidth = screenshot.width,
|
||||
screenshotHeight = screenshot.height
|
||||
)
|
||||
evidenceByOrdinal[ordinal] = evidence
|
||||
writeManifest()
|
||||
evidence
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeManifest() {
|
||||
val candidates = JSONArray()
|
||||
evidenceByOrdinal.values.forEach { evidence ->
|
||||
candidates.put(
|
||||
JSONObject()
|
||||
.put("ordinal", evidence.ordinal)
|
||||
.put("card_signature", evidence.cardSignature)
|
||||
.put(
|
||||
"card_semantic_text_count",
|
||||
evidence.cardSemanticTextCount
|
||||
)
|
||||
.put("detail_signature", evidence.detailSignature)
|
||||
.put(
|
||||
"detail_semantic_text_count",
|
||||
evidence.detailSemanticTextCount
|
||||
)
|
||||
.put("screenshot_file", evidence.screenshotFileName)
|
||||
.put("screenshot_sha256", evidence.screenshotSha256)
|
||||
.put(
|
||||
"screenshot_byte_count",
|
||||
evidence.screenshotByteCount
|
||||
)
|
||||
.put("screenshot_width", evidence.screenshotWidth)
|
||||
.put("screenshot_height", evidence.screenshotHeight)
|
||||
)
|
||||
}
|
||||
val manifest = JSONObject()
|
||||
.put("schema_version", 1)
|
||||
.put("candidate_count", evidenceByOrdinal.size)
|
||||
.put("candidates", candidates)
|
||||
.toString(2)
|
||||
val temporary = File(root, "$MANIFEST_FILE.tmp")
|
||||
val destination = File(root, MANIFEST_FILE)
|
||||
temporary.writeText(manifest, Charsets.UTF_8)
|
||||
if (destination.exists() && !destination.delete()) {
|
||||
throw IOException("Could not replace candidate evidence manifest")
|
||||
}
|
||||
if (!temporary.renameTo(destination)) {
|
||||
throw IOException("Could not publish candidate evidence manifest")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EVIDENCE_DIRECTORY = "pdd-candidate-probe"
|
||||
const val MANIFEST_FILE = "manifest.json"
|
||||
}
|
||||
}
|
||||
+3
@@ -42,6 +42,9 @@ class AndroidPinduoduoUiDriver(context: Context) : PinduoduoUiDriver {
|
||||
override suspend fun submitSearch(): Boolean =
|
||||
BuyerAccessibilityBridge.submitSearch()
|
||||
|
||||
override suspend fun returnFromCandidate(): Boolean =
|
||||
BuyerAccessibilityBridge.returnToResults()
|
||||
|
||||
private companion object {
|
||||
const val KEYWORD_VERIFY_ATTEMPTS = 5
|
||||
const val KEYWORD_VERIFY_INTERVAL_MS = 100L
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package com.roubao.autopilot.pinduoduo
|
||||
|
||||
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
||||
import com.roubao.autopilot.workflow.AutomationGateway
|
||||
import com.roubao.autopilot.workflow.AutomationResult
|
||||
import com.roubao.autopilot.workflow.SafetyStopReason
|
||||
import com.roubao.autopilot.workflow.WorkflowFailureCode
|
||||
import com.roubao.autopilot.workflow.WorkflowStep
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
enum class CandidateBrowsePhase {
|
||||
IDLE,
|
||||
READING_RESULTS,
|
||||
OPENING_CANDIDATE,
|
||||
WAITING_DETAIL,
|
||||
CAPTURING_EVIDENCE,
|
||||
RETURNING_RESULTS,
|
||||
SCROLLING_RESULTS,
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
class PinduoduoCandidateAutomation(
|
||||
private val driver: PinduoduoCandidateDriver,
|
||||
private val maxCandidates: Int = MAX_CANDIDATES_PER_PROBE,
|
||||
private val maxResultScrolls: Int = MAX_RESULT_SCROLLS_PER_PROBE,
|
||||
private val pagePollIntervalMillis: Long = 200,
|
||||
private val unknownPageLimit: Int = 20
|
||||
) : AutomationGateway {
|
||||
private val mutableEvidence =
|
||||
MutableStateFlow<List<PinduoduoCandidateEvidence>>(emptyList())
|
||||
private val mutablePhase = MutableStateFlow(CandidateBrowsePhase.IDLE)
|
||||
private val attemptedSignatures = linkedSetOf<String>()
|
||||
private var resultScrollCount = 0
|
||||
|
||||
val evidence: StateFlow<List<PinduoduoCandidateEvidence>> =
|
||||
mutableEvidence.asStateFlow()
|
||||
val phase: StateFlow<CandidateBrowsePhase> = mutablePhase.asStateFlow()
|
||||
|
||||
init {
|
||||
require(maxCandidates in 1..MAX_CANDIDATES_PER_PROBE)
|
||||
require(maxResultScrolls in 0..MAX_RESULT_SCROLLS_PER_PROBE)
|
||||
require(pagePollIntervalMillis > 0)
|
||||
require(unknownPageLimit > 0)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
attemptedSignatures.clear()
|
||||
resultScrollCount = 0
|
||||
mutableEvidence.value = emptyList()
|
||||
mutablePhase.value = CandidateBrowsePhase.IDLE
|
||||
driver.resetEvidence()
|
||||
}
|
||||
|
||||
override suspend fun execute(step: WorkflowStep): AutomationResult {
|
||||
if (step.id != PinduoduoCandidateWorkflow.BROWSE_CANDIDATES) {
|
||||
return AutomationResult.FatalFailure(
|
||||
WorkflowFailureCode.AUTOMATION_EXCEPTION
|
||||
)
|
||||
}
|
||||
return browseCandidates()
|
||||
}
|
||||
|
||||
private suspend fun browseCandidates(): AutomationResult {
|
||||
mutablePhase.value = CandidateBrowsePhase.READING_RESULTS
|
||||
recoverDetailPageIfNeeded()?.let { return it }
|
||||
|
||||
while (
|
||||
mutableEvidence.value.size < maxCandidates &&
|
||||
attemptedSignatures.size < maxCandidates
|
||||
) {
|
||||
validateResultsPage()?.let { return it }
|
||||
val nextCard = driver.candidateCards(maxCandidates * 2)
|
||||
.firstOrNull { card -> card.signature !in attemptedSignatures }
|
||||
|
||||
if (nextCard == null) {
|
||||
if (resultScrollCount >= maxResultScrolls) {
|
||||
return terminalCollectionResult()
|
||||
}
|
||||
resultScrollCount += 1
|
||||
mutablePhase.value = CandidateBrowsePhase.SCROLLING_RESULTS
|
||||
if (!driver.scrollResults()) {
|
||||
return terminalCollectionResult()
|
||||
}
|
||||
delay(pagePollIntervalMillis)
|
||||
mutablePhase.value = CandidateBrowsePhase.READING_RESULTS
|
||||
continue
|
||||
}
|
||||
|
||||
attemptedSignatures += nextCard.signature
|
||||
mutablePhase.value = CandidateBrowsePhase.OPENING_CANDIDATE
|
||||
if (!driver.openCandidate(nextCard.signature)) {
|
||||
return AutomationResult.RetryableFailure(
|
||||
WorkflowFailureCode.TRANSIENT_AUTOMATION
|
||||
)
|
||||
}
|
||||
|
||||
mutablePhase.value = CandidateBrowsePhase.WAITING_DETAIL
|
||||
awaitPage(PinduoduoPage.PRODUCT_DETAIL)?.let { return it }
|
||||
val ordinal = mutableEvidence.value.size + 1
|
||||
mutablePhase.value = CandidateBrowsePhase.CAPTURING_EVIDENCE
|
||||
val captured = driver.captureCandidate(ordinal, nextCard)
|
||||
?: return AutomationResult.FatalFailure(
|
||||
WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED
|
||||
)
|
||||
mutableEvidence.value = mutableEvidence.value + captured
|
||||
|
||||
mutablePhase.value = CandidateBrowsePhase.RETURNING_RESULTS
|
||||
if (!driver.returnToResults()) {
|
||||
return AutomationResult.RetryableFailure(
|
||||
WorkflowFailureCode.TRANSIENT_AUTOMATION
|
||||
)
|
||||
}
|
||||
awaitPage(PinduoduoPage.SEARCH_RESULTS)?.let { return it }
|
||||
mutablePhase.value = CandidateBrowsePhase.READING_RESULTS
|
||||
}
|
||||
|
||||
return terminalCollectionResult()
|
||||
}
|
||||
|
||||
private suspend fun recoverDetailPageIfNeeded(): AutomationResult? {
|
||||
val snapshot = driver.snapshot()
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
if (snapshot.page != PinduoduoPage.PRODUCT_DETAIL) {
|
||||
return null
|
||||
}
|
||||
if (!driver.returnToResults()) {
|
||||
return AutomationResult.RetryableFailure(
|
||||
WorkflowFailureCode.TRANSIENT_AUTOMATION
|
||||
)
|
||||
}
|
||||
return awaitPage(PinduoduoPage.SEARCH_RESULTS)
|
||||
}
|
||||
|
||||
private suspend fun validateResultsPage(): AutomationResult? {
|
||||
val snapshot = driver.snapshot()
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
return if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) {
|
||||
null
|
||||
} else {
|
||||
AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitPage(expected: PinduoduoPage): AutomationResult? {
|
||||
var stableUnexpectedObservations = 0
|
||||
while (true) {
|
||||
val snapshot = driver.snapshot()
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
if (snapshot.page == expected) {
|
||||
return null
|
||||
}
|
||||
|
||||
stableUnexpectedObservations = if (
|
||||
snapshot.foregroundPackage == PINDUODUO_PACKAGE
|
||||
) {
|
||||
stableUnexpectedObservations + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if (stableUnexpectedObservations >= unknownPageLimit) {
|
||||
return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
|
||||
}
|
||||
delay(pagePollIntervalMillis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun terminalCollectionResult(): AutomationResult {
|
||||
mutablePhase.value = CandidateBrowsePhase.COMPLETE
|
||||
return if (mutableEvidence.value.isNotEmpty()) {
|
||||
AutomationResult.Success
|
||||
} else {
|
||||
AutomationResult.FatalFailure(WorkflowFailureCode.TARGET_NOT_READY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? =
|
||||
snapshot.safetyStopReason?.let(AutomationResult::Blocked)
|
||||
}
|
||||
|
||||
class PinduoduoProbeAutomation(
|
||||
private val searchAutomation: PinduoduoSearchAutomation,
|
||||
private val candidateAutomation: PinduoduoCandidateAutomation
|
||||
) : AutomationGateway {
|
||||
override suspend fun execute(step: WorkflowStep): AutomationResult =
|
||||
if (step.id == PinduoduoCandidateWorkflow.BROWSE_CANDIDATES) {
|
||||
candidateAutomation.execute(step)
|
||||
} else {
|
||||
searchAutomation.execute(step)
|
||||
}
|
||||
}
|
||||
|
||||
object PinduoduoCandidateWorkflow {
|
||||
const val BROWSE_CANDIDATES = "pdd_browse_candidates"
|
||||
|
||||
fun steps(): List<WorkflowStep> =
|
||||
PinduoduoSearchWorkflow.steps() +
|
||||
WorkflowStep(
|
||||
id = BROWSE_CANDIDATES,
|
||||
timeoutMillis = 120_000,
|
||||
maxRetries = 1
|
||||
)
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.roubao.autopilot.pinduoduo
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
const val MAX_CANDIDATES_PER_PROBE = 5
|
||||
const val MAX_RESULT_SCROLLS_PER_PROBE = 2
|
||||
|
||||
data class PinduoduoCandidateCard(
|
||||
val signature: String,
|
||||
val semanticTextCount: Int,
|
||||
val hasImage: Boolean
|
||||
)
|
||||
|
||||
data class PinduoduoCandidateDetailEvidence(
|
||||
val signature: String,
|
||||
val semanticTextCount: Int
|
||||
)
|
||||
|
||||
data class PinduoduoScreenshotCapture(
|
||||
val pngBytes: ByteArray,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
|
||||
data class PinduoduoCandidateEvidence(
|
||||
val ordinal: Int,
|
||||
val cardSignature: String,
|
||||
val cardSemanticTextCount: Int,
|
||||
val detailSignature: String,
|
||||
val detailSemanticTextCount: Int,
|
||||
val screenshotFileName: String,
|
||||
val screenshotSha256: String,
|
||||
val screenshotByteCount: Int,
|
||||
val screenshotWidth: Int,
|
||||
val screenshotHeight: Int
|
||||
)
|
||||
|
||||
object PinduoduoEvidenceHash {
|
||||
fun sha256(value: String): String =
|
||||
sha256(value.toByteArray(Charsets.UTF_8))
|
||||
|
||||
fun sha256(value: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(value)
|
||||
.joinToString(separator = "") { byte -> "%02x".format(byte) }
|
||||
}
|
||||
|
||||
interface PinduoduoCandidateDriver {
|
||||
suspend fun snapshot(): PinduoduoUiSnapshot
|
||||
suspend fun candidateCards(limit: Int): List<PinduoduoCandidateCard>
|
||||
suspend fun openCandidate(signature: String): Boolean
|
||||
suspend fun captureCandidate(
|
||||
ordinal: Int,
|
||||
card: PinduoduoCandidateCard
|
||||
): PinduoduoCandidateEvidence?
|
||||
suspend fun returnToResults(): Boolean
|
||||
suspend fun scrollResults(): Boolean
|
||||
fun resetEvidence()
|
||||
}
|
||||
+8
@@ -20,6 +20,7 @@ enum class PinduoduoPage {
|
||||
HOME,
|
||||
SEARCH_INPUT,
|
||||
SEARCH_RESULTS,
|
||||
PRODUCT_DETAIL,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
@@ -91,12 +92,19 @@ object PinduoduoPageClassifier {
|
||||
val sortControlCount = setOf("综合", "销量", "价格", "筛选")
|
||||
.count { marker -> normalized.any { it == marker } }
|
||||
val hasExactQuery = visibleTexts.any { it.trim() == expectedQuery }
|
||||
val hasDetailBack = visibleElements.any { element ->
|
||||
element.clickable &&
|
||||
normalize(element.contentDescription.orEmpty()) == "返回"
|
||||
}
|
||||
val detailMarkerCount = setOf("联系客服", "收藏", "店铺")
|
||||
.count { marker -> normalized.any { it.contains(marker) } }
|
||||
|
||||
val page = when {
|
||||
hasExactQuery && hasResultSearchHeader && sortControlCount >= 3 ->
|
||||
PinduoduoPage.SEARCH_RESULTS
|
||||
hasSearchInput && hasSubmitButton -> PinduoduoPage.SEARCH_INPUT
|
||||
hasHomeSearchEntry && hasHome -> PinduoduoPage.HOME
|
||||
hasDetailBack && detailMarkerCount >= 2 -> PinduoduoPage.PRODUCT_DETAIL
|
||||
else -> PinduoduoPage.UNKNOWN
|
||||
}
|
||||
return PinduoduoUiSnapshot(
|
||||
|
||||
+14
-2
@@ -15,6 +15,7 @@ interface PinduoduoUiDriver {
|
||||
suspend fun openSearch(): Boolean
|
||||
suspend fun setSearchKeyword(keyword: String): Boolean
|
||||
suspend fun submitSearch(): Boolean
|
||||
suspend fun returnFromCandidate(): Boolean
|
||||
}
|
||||
|
||||
class PinduoduoSearchAutomation(
|
||||
@@ -45,7 +46,8 @@ class PinduoduoSearchAutomation(
|
||||
return awaitPage {
|
||||
it == PinduoduoPage.HOME ||
|
||||
it == PinduoduoPage.SEARCH_INPUT ||
|
||||
it == PinduoduoPage.SEARCH_RESULTS
|
||||
it == PinduoduoPage.SEARCH_RESULTS ||
|
||||
it == PinduoduoPage.PRODUCT_DETAIL
|
||||
} ?: AutomationResult.Success
|
||||
}
|
||||
|
||||
@@ -53,7 +55,8 @@ class PinduoduoSearchAutomation(
|
||||
val ready = awaitPage {
|
||||
it == PinduoduoPage.HOME ||
|
||||
it == PinduoduoPage.SEARCH_INPUT ||
|
||||
it == PinduoduoPage.SEARCH_RESULTS
|
||||
it == PinduoduoPage.SEARCH_RESULTS ||
|
||||
it == PinduoduoPage.PRODUCT_DETAIL
|
||||
}
|
||||
if (ready != null) {
|
||||
return ready
|
||||
@@ -63,6 +66,15 @@ class PinduoduoSearchAutomation(
|
||||
if (currentPage == PinduoduoPage.SEARCH_RESULTS) {
|
||||
return AutomationResult.Success
|
||||
}
|
||||
if (currentPage == PinduoduoPage.PRODUCT_DETAIL) {
|
||||
if (!driver.returnFromCandidate()) {
|
||||
return AutomationResult.RetryableFailure(
|
||||
WorkflowFailureCode.TRANSIENT_AUTOMATION
|
||||
)
|
||||
}
|
||||
return awaitPage { it == PinduoduoPage.SEARCH_RESULTS }
|
||||
?: AutomationResult.Success
|
||||
}
|
||||
if (currentPage != PinduoduoPage.SEARCH_INPUT) {
|
||||
if (!driver.openSearch()) {
|
||||
return AutomationResult.RetryableFailure(
|
||||
|
||||
+50
-6
@@ -34,6 +34,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
|
||||
import com.roubao.autopilot.pinduoduo.MAX_CANDIDATES_PER_PROBE
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateWorkflow
|
||||
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
|
||||
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||
import com.roubao.autopilot.ui.theme.BaoziTheme
|
||||
@@ -51,7 +53,8 @@ private val probeSteps = listOf(
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.OPEN_APP, "打开拼多多"),
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.ENTER_QUERY, "输入固定关键词"),
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.SUBMIT_QUERY, "提交搜索"),
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.VERIFY_RESULTS, "确认结果页")
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.VERIFY_RESULTS, "确认结果页"),
|
||||
ProbeStepUi(PinduoduoCandidateWorkflow.BROWSE_CANDIDATES, "采集候选证据")
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -60,6 +63,7 @@ fun SearchProbeScreen(
|
||||
state: WorkflowState,
|
||||
currentStepId: String?,
|
||||
report: WorkflowReport?,
|
||||
candidateEvidenceCount: Int,
|
||||
onStart: () -> Unit,
|
||||
onStop: () -> Unit
|
||||
) {
|
||||
@@ -75,13 +79,13 @@ fun SearchProbeScreen(
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "拼多多搜索探针",
|
||||
text = "拼多多候选探针",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = stateLabel(state, report),
|
||||
text = stateLabel(state, report, candidateEvidenceCount),
|
||||
fontSize = 14.sp,
|
||||
color = stateColor(state)
|
||||
)
|
||||
@@ -118,6 +122,41 @@ fun SearchProbeScreen(
|
||||
Divider(color = colors.surfaceVariant)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(58.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = if (candidateEvidenceCount > 0) {
|
||||
colors.success
|
||||
} else {
|
||||
colors.textHint
|
||||
},
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = "候选证据",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = "$candidateEvidenceCount / $MAX_CANDIDATES_PER_PROBE",
|
||||
fontSize = 13.sp,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
Divider(color = colors.surfaceVariant)
|
||||
}
|
||||
|
||||
items(probeSteps.size) { index ->
|
||||
val step = probeSteps[index]
|
||||
ProbeStepRow(
|
||||
@@ -151,7 +190,7 @@ fun SearchProbeScreen(
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("开始搜索探针")
|
||||
Text("开始候选探针")
|
||||
}
|
||||
}
|
||||
if (!readiness.canStartProbe) {
|
||||
@@ -263,12 +302,16 @@ private fun stateColor(state: WorkflowState): Color {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stateLabel(state: WorkflowState, report: WorkflowReport?): String =
|
||||
private fun stateLabel(
|
||||
state: WorkflowState,
|
||||
report: WorkflowReport?,
|
||||
candidateEvidenceCount: Int
|
||||
): String =
|
||||
when (state) {
|
||||
WorkflowState.IDLE -> "等待开始"
|
||||
WorkflowState.RUNNING -> "正在执行"
|
||||
WorkflowState.RETRYING -> "正在重试"
|
||||
WorkflowState.SUCCEEDED -> "搜索结果页已确认"
|
||||
WorkflowState.SUCCEEDED -> "已采集 $candidateEvidenceCount 个候选"
|
||||
WorkflowState.STOPPED -> "已由用户停止"
|
||||
WorkflowState.FAILED -> failureLabel(report?.failureCode)
|
||||
WorkflowState.BLOCKED -> blockedLabel(report?.safetyStopReason)
|
||||
@@ -279,6 +322,7 @@ private fun failureLabel(code: WorkflowFailureCode?): String = when (code) {
|
||||
WorkflowFailureCode.TRANSIENT_AUTOMATION -> "页面操作失败"
|
||||
WorkflowFailureCode.AUTOMATION_EXCEPTION -> "自动化执行异常"
|
||||
WorkflowFailureCode.TARGET_NOT_READY -> "拼多多无法启动"
|
||||
WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED -> "候选证据保存失败"
|
||||
null -> "执行失败"
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ enum class WorkflowFailureCode {
|
||||
TIMEOUT,
|
||||
TRANSIENT_AUTOMATION,
|
||||
AUTOMATION_EXCEPTION,
|
||||
TARGET_NOT_READY
|
||||
TARGET_NOT_READY,
|
||||
EVIDENCE_CAPTURE_FAILED
|
||||
}
|
||||
|
||||
enum class SafetyStopReason {
|
||||
|
||||
Reference in New Issue
Block a user