diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index 3d94e50..4635cb8 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -64,7 +64,13 @@ android { } into(generatedProbeAssets.map { it.dir("probe-fixtures") }) } - tasks.matching { it.name == "mergeDebugAssets" }.configureEach { + tasks.matching { + it.name == "mergeDebugAssets" || + ( + it.name.contains("Debug") && + it.name.contains("lint", ignoreCase = true) + ) + }.configureEach { dependsOn(syncProbeFixtures) } } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt index 68cbb18..74935a3 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt @@ -42,15 +42,23 @@ import com.roubao.autopilot.vlm.GUIOwlClient import com.roubao.autopilot.vlm.MAIUIClient import com.roubao.autopilot.vlm.VLMClient import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import rikka.shizuku.Shizuku import android.util.Log +import com.roubao.autopilot.pinduoduo.AndroidPinduoduoUiDriver +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 private const val TAG = "MainActivity" sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) { object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build) - object Home : Screen("home", "肉包", Icons.Outlined.Home, Icons.Filled.Home) + object Home : Screen("home", "探针", Icons.Outlined.Search, Icons.Filled.Search) object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List) object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings) } @@ -65,6 +73,11 @@ class MainActivity : ComponentActivity() { private val mobileAgent = mutableStateOf(null) private var shizukuAvailable = mutableStateOf(false) private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty()) + private val searchProbeState = mutableStateOf(WorkflowState.IDLE) + private val searchProbeStepId = mutableStateOf(null) + private val searchProbeReport = mutableStateOf(null) + private var searchProbeRunner: WorkflowRunner? = null + private var searchProbeJob: Job? = null // 当前执行的协程 Job(用于停止任务) private var currentExecutionJob: kotlinx.coroutines.Job? = null @@ -172,21 +185,19 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainApp() { - var currentScreen by remember { mutableStateOf(Screen.Device) } + var currentScreen by remember { mutableStateOf(Screen.Home) } var selectedRecord by remember { mutableStateOf(null) } - var showShizukuHelpDialog by remember { mutableStateOf(false) } val settings by settingsManager.settings.collectAsState() val colors = BaoziTheme.colors - val agent = mobileAgent.value - val agentState by agent?.state?.collectAsState() ?: remember { mutableStateOf(null) } - val logs by agent?.logs?.collectAsState() ?: remember { mutableStateOf(emptyList()) } val records by remember { executionRecords } val isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission() - val executing by remember { isExecuting } val navigateToRecord by remember { shouldNavigateToRecord } val recordId by remember { currentRecordId } val readiness by remember { readinessSnapshot } + val probeState by remember { searchProbeState } + val probeStepId by remember { searchProbeStepId } + val probeReport by remember { searchProbeReport } // 监听跳转事件 LaunchedEffect(navigateToRecord, recordId) { @@ -211,7 +222,7 @@ class MainActivity : ComponentActivity() { contentColor = colors.textPrimary, tonalElevation = 0.dp ) { - listOf(Screen.Device, Screen.Home, Screen.History, Screen.Settings).forEach { screen -> + listOf(Screen.Home, Screen.Device, Screen.History, Screen.Settings).forEach { screen -> val selected = currentScreen == screen NavigationBarItem( icon = { @@ -270,35 +281,14 @@ class MainActivity : ComponentActivity() { }, onOpenPinduoduo = { openPinduoduo() } ) - Screen.Home -> { - // 每次进入首页都检测 Shizuku 状态 - LaunchedEffect(Unit) { - checkAndUpdateShizukuStatus() - } - HomeScreen( - agentState = agentState, - logs = logs, - onExecute = { instruction -> - runAgent( - instruction = instruction, - apiKey = settings.apiKey, - baseUrl = settings.baseUrl, - model = settings.model, - maxSteps = settings.maxSteps, - isGUIAgent = settings.currentProvider.isGUIAgent, - providerId = settings.currentProviderId - ) - }, - onStop = { - mobileAgent.value?.stop() - }, - shizukuAvailable = isShizukuAvailable, - currentModel = settings.model, - onRefreshShizuku = { refreshShizukuStatus() }, - onShizukuRequired = { showShizukuHelpDialog = true }, - isExecuting = executing - ) - } + Screen.Home -> SearchProbeScreen( + readiness = readiness, + state = probeState, + currentStepId = probeStepId, + report = probeReport, + onStart = { startSearchProbe() }, + onStop = { stopSearchProbe() } + ) Screen.History -> HistoryScreen( records = records, onRecordClick = { record -> selectedRecord = record }, @@ -340,10 +330,6 @@ class MainActivity : ComponentActivity() { } } - // Shizuku 帮助对话框 - if (showShizukuHelpDialog) { - ShizukuHelpDialog(onDismiss = { showShizukuHelpDialog = false }) - } } private fun deleteRecord(id: String) { @@ -357,10 +343,15 @@ class MainActivity : ComponentActivity() { super.onResume() if (::readinessChecker.isInitialized) { refreshReadiness() + lifecycleScope.launch { + delay(READINESS_RECHECK_DELAY_MS) + refreshReadiness() + } } } override fun onDestroy() { + searchProbeRunner?.requestStop() super.onDestroy() Shizuku.removeBinderReceivedListener(binderReceivedListener) Shizuku.removeBinderDeadListener(binderDeadListener) @@ -382,6 +373,48 @@ class MainActivity : ComponentActivity() { startActivity(launchIntent) } + private fun startSearchProbe() { + val readiness = readinessChecker.snapshot() + readinessSnapshot.value = readiness + if (!readiness.canStartProbe) { + Toast.makeText(this, "设备检查存在阻塞项", Toast.LENGTH_SHORT).show() + return + } + if (searchProbeJob?.isActive == true) { + return + } + + val runner = WorkflowRunner( + PinduoduoSearchAutomation(AndroidPinduoduoUiDriver(this)) + ) + searchProbeRunner = runner + searchProbeReport.value = null + searchProbeState.value = WorkflowState.IDLE + searchProbeStepId.value = null + searchProbeJob = lifecycleScope.launch { + val stateCollector = launch { + runner.state.collect { state -> searchProbeState.value = state } + } + val stepCollector = launch { + runner.currentStepId.collect { stepId -> searchProbeStepId.value = stepId } + } + try { + val report = runner.run(PinduoduoSearchWorkflow.steps()) + searchProbeReport.value = report + searchProbeState.value = report.state + } finally { + stateCollector.cancel() + stepCollector.cancel() + searchProbeRunner = null + refreshReadiness() + } + } + } + + private fun stopSearchProbe() { + searchProbeRunner?.requestStop() + } + private fun checkShizukuPermission(): Boolean { return try { val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED @@ -460,6 +493,10 @@ class MainActivity : ComponentActivity() { } } + private companion object { + const val READINESS_RECHECK_DELAY_MS = 750L + } + private fun runAgent( instruction: String, apiKey: String, diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt new file mode 100644 index 0000000..bd173a8 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt @@ -0,0 +1,49 @@ +package com.roubao.autopilot.accessibility + +import com.roubao.autopilot.pinduoduo.PinduoduoPage +import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot +import com.roubao.autopilot.readiness.DeviceObservationStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object BuyerAccessibilityBridge { + @Volatile + private var service: BuyerAccessibilityService? = null + + fun attach(connectedService: BuyerAccessibilityService) { + service = connectedService + } + + fun detach(disconnectedService: BuyerAccessibilityService) { + if (service === disconnectedService) { + service = null + } + } + + suspend fun snapshot(): PinduoduoUiSnapshot = withContext(Dispatchers.Main.immediate) { + service?.snapshotPinduoduoUi() + ?: PinduoduoUiSnapshot( + foregroundPackage = DeviceObservationStore.snapshot().foregroundPackage, + page = PinduoduoPage.UNKNOWN, + safetyStopReason = null + ) + } + + suspend fun openSearch(): Boolean = withContext(Dispatchers.Main.immediate) { + service?.clickPinduoduoSearchEntry() == true + } + + suspend fun setSearchKeyword(keyword: String): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.setPinduoduoSearchKeyword(keyword) == true + } + + suspend fun isSearchKeywordSet(keyword: String): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.isPinduoduoSearchKeywordSet(keyword) == true + } + + suspend fun submitSearch(): Boolean = withContext(Dispatchers.Main.immediate) { + service?.submitPinduoduoSearch() == true + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt index f4291b1..67b8268 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt @@ -1,6 +1,7 @@ package com.roubao.autopilot.accessibility import android.accessibilityservice.AccessibilityService +import android.os.Bundle import android.os.SystemClock import android.util.Log import android.view.accessibility.AccessibilityEvent @@ -8,14 +9,21 @@ import android.view.accessibility.AccessibilityNodeInfo import com.roubao.autopilot.readiness.DeviceObservationStore import com.roubao.autopilot.readiness.LoginBlockerDetector import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import com.roubao.autopilot.pinduoduo.PinduoduoPageClassifier +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 java.util.ArrayDeque class BuyerAccessibilityService : AccessibilityService() { private var lastPinduoduoScanAt = 0L + private var expectedSearchQuery = SEARCH_PROBE_KEYWORD override fun onServiceConnected() { super.onServiceConnected() DeviceObservationStore.setAccessibilityConnected(true) + BuyerAccessibilityBridge.attach(this) Log.i(TAG, "Buyer accessibility service connected") } @@ -52,10 +60,140 @@ class BuyerAccessibilityService : AccessibilityService() { } override fun onDestroy() { + BuyerAccessibilityBridge.detach(this) DeviceObservationStore.setAccessibilityConnected(false) super.onDestroy() } + internal fun snapshotPinduoduoUi(): PinduoduoUiSnapshot { + val root = rootInActiveWindow + val foregroundPackage = root?.packageName?.toString() + ?: DeviceObservationStore.snapshot().foregroundPackage + if (foregroundPackage == PINDUODUO_PACKAGE && root != null) { + return classifyPinduoduoRoot(root) + } + return PinduoduoPageClassifier.classify( + foregroundPackage = foregroundPackage, + elements = emptyList(), + expectedQuery = expectedSearchQuery + ) + } + + internal fun clickPinduoduoSearchEntry(): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.safetyStopReason != null || + snapshot.page != PinduoduoPage.HOME + ) { + return@withPinduoduoRoot false + } + val candidates = collectNodes(root).filter { node -> + node.isVisibleToUser && + node.isEnabled && + node.className?.toString()?.endsWith("TextView") == true && + node.contentDescription?.toString()?.trim() == "搜索" + } + candidates.singleOrNull()?.let(::clickNodeOrAncestor) == true + } ?: false + + internal fun setPinduoduoSearchKeyword(keyword: String): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.safetyStopReason != null || + snapshot.page != PinduoduoPage.SEARCH_INPUT + ) { + return@withPinduoduoRoot false + } + val inputs = collectNodes(root).filter { node -> + node.isVisibleToUser && + node.isEnabled && + (node.isEditable || + node.className?.toString()?.endsWith("EditText") == true) && + node.contentDescription?.toString()?.trim() == "搜索" + } + val input = inputs.singleOrNull() ?: return@withPinduoduoRoot false + + input.performAction(AccessibilityNodeInfo.ACTION_FOCUS) + val arguments = Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + keyword + ) + } + val changed = input.performAction( + AccessibilityNodeInfo.ACTION_SET_TEXT, + arguments + ) + if (changed) { + expectedSearchQuery = keyword + } + changed + } ?: false + + internal fun isPinduoduoSearchKeywordSet(keyword: String): Boolean = + withPinduoduoRoot { root -> + collectNodes(root) + .filter { node -> + node.isVisibleToUser && + node.isEnabled && + (node.isEditable || + node.className?.toString()?.endsWith("EditText") == true) && + node.contentDescription?.toString()?.trim() == "搜索" + } + .singleOrNull() + ?.text + ?.toString() == keyword + } ?: false + + internal fun submitPinduoduoSearch(): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if (snapshot.safetyStopReason != null) { + return@withPinduoduoRoot false + } + if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) { + return@withPinduoduoRoot true + } + if (snapshot.page != PinduoduoPage.SEARCH_INPUT) { + return@withPinduoduoRoot false + } + val nodes = collectNodes(root) + val submitButtons = nodes.filter { node -> + node.isVisibleToUser && + node.isEnabled && + node.isClickable && + node.className?.toString()?.endsWith("TextView") == true && + node.text?.toString()?.trim() == "搜索" + } + submitButtons.singleOrNull()?.performAction( + AccessibilityNodeInfo.ACTION_CLICK + ) == true + } ?: false + + private fun classifyPinduoduoRoot( + root: AccessibilityNodeInfo + ): PinduoduoUiSnapshot { + val elements = collectNodes(root).map { node -> + PinduoduoUiElement( + text = node.text?.toString(), + contentDescription = node.contentDescription?.toString(), + className = node.className?.toString().orEmpty(), + resourceId = node.viewIdResourceName, + clickable = node.isClickable, + editable = node.isEditable, + enabled = node.isEnabled, + visibleToUser = node.isVisibleToUser + ) + } + return PinduoduoPageClassifier.classify( + foregroundPackage = root.packageName?.toString(), + elements = elements, + expectedQuery = expectedSearchQuery + ) + } + private fun shouldInspect(eventType: Int): Boolean = eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED || @@ -63,6 +201,18 @@ class BuyerAccessibilityService : AccessibilityService() { private fun collectVisibleTexts(root: AccessibilityNodeInfo): List { val texts = ArrayList() + collectNodes(root).forEach { node -> + node.text?.toString()?.takeIf(String::isNotBlank)?.let(texts::add) + node.contentDescription + ?.toString() + ?.takeIf(String::isNotBlank) + ?.let(texts::add) + } + return texts + } + + private fun collectNodes(root: AccessibilityNodeInfo): List { + val nodes = ArrayList() val queue = ArrayDeque() queue.add(root) var visited = 0 @@ -70,23 +220,42 @@ class BuyerAccessibilityService : AccessibilityService() { while (queue.isNotEmpty() && visited < MAX_NODES) { val node = queue.removeFirst() visited += 1 - - node.text?.toString()?.takeIf(String::isNotBlank)?.let(texts::add) - node.contentDescription - ?.toString() - ?.takeIf(String::isNotBlank) - ?.let(texts::add) + nodes += node for (index in 0 until node.childCount) { node.getChild(index)?.let(queue::addLast) } } - return texts + return nodes + } + + private fun withPinduoduoRoot(block: (AccessibilityNodeInfo) -> T): T? { + val root = rootInActiveWindow ?: return null + if (root.packageName?.toString() != PINDUODUO_PACKAGE) { + return null + } + return block(root) + } + + private fun clickNodeOrAncestor(node: AccessibilityNodeInfo): Boolean { + var candidate: AccessibilityNodeInfo? = node + repeat(MAX_CLICK_ANCESTORS) { + val current = candidate ?: return false + if ( + current.isClickable && + current.performAction(AccessibilityNodeInfo.ACTION_CLICK) + ) { + return true + } + candidate = current.parent + } + return false } companion object { private const val TAG = "BuyerAccessibility" private const val MAX_NODES = 300 + private const val MAX_CLICK_ANCESTORS = 4 private const val MIN_SCAN_INTERVAL_MS = 300L } } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoUiDriver.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoUiDriver.kt new file mode 100644 index 0000000..30e6c92 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoUiDriver.kt @@ -0,0 +1,49 @@ +package com.roubao.autopilot.pinduoduo + +import android.content.Context +import android.content.Intent +import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge +import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import kotlinx.coroutines.delay + +class AndroidPinduoduoUiDriver(context: Context) : PinduoduoUiDriver { + private val appContext = context.applicationContext + + override suspend fun openApp(): Boolean { + val intent = appContext.packageManager.getLaunchIntentForPackage(PINDUODUO_PACKAGE) + ?: return false + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + ) + appContext.startActivity(intent) + return true + } + + override suspend fun snapshot(): PinduoduoUiSnapshot = + BuyerAccessibilityBridge.snapshot() + + override suspend fun openSearch(): Boolean = + BuyerAccessibilityBridge.openSearch() + + override suspend fun setSearchKeyword(keyword: String): Boolean { + if (!BuyerAccessibilityBridge.setSearchKeyword(keyword)) { + return false + } + repeat(KEYWORD_VERIFY_ATTEMPTS) { + delay(KEYWORD_VERIFY_INTERVAL_MS) + if (BuyerAccessibilityBridge.isSearchKeywordSet(keyword)) { + return true + } + } + return false + } + + override suspend fun submitSearch(): Boolean = + BuyerAccessibilityBridge.submitSearch() + + private companion object { + const val KEYWORD_VERIFY_ATTEMPTS = 5 + const val KEYWORD_VERIFY_INTERVAL_MS = 100L + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt new file mode 100644 index 0000000..16710c8 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt @@ -0,0 +1,114 @@ +package com.roubao.autopilot.pinduoduo + +import com.roubao.autopilot.readiness.LoginBlocker +import com.roubao.autopilot.readiness.LoginBlockerDetector +import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import com.roubao.autopilot.workflow.SafetyStopReason + +data class PinduoduoUiElement( + val text: String?, + val contentDescription: String?, + val className: String, + val resourceId: String?, + val clickable: Boolean, + val editable: Boolean, + val enabled: Boolean, + val visibleToUser: Boolean +) + +enum class PinduoduoPage { + HOME, + SEARCH_INPUT, + SEARCH_RESULTS, + UNKNOWN +} + +data class PinduoduoUiSnapshot( + val foregroundPackage: String?, + val page: PinduoduoPage, + val safetyStopReason: SafetyStopReason? +) + +object PinduoduoPageClassifier { + private val paymentMarkers = listOf( + "确认订单", + "提交订单", + "确认支付", + "立即支付", + "收银台" + ) + + fun classify( + foregroundPackage: String?, + elements: Collection, + expectedQuery: String = SEARCH_PROBE_KEYWORD + ): PinduoduoUiSnapshot { + if (foregroundPackage != PINDUODUO_PACKAGE) { + return PinduoduoUiSnapshot( + foregroundPackage = foregroundPackage, + page = PinduoduoPage.UNKNOWN, + safetyStopReason = null + ) + } + + val visibleElements = elements.filter { it.visibleToUser && it.enabled } + val visibleTexts = visibleElements.flatMap { element -> + listOfNotNull(element.text, element.contentDescription) + } + val safetyStop = when (LoginBlockerDetector.detect(visibleTexts)) { + LoginBlocker.LOGIN_REQUIRED -> SafetyStopReason.LOGIN_REQUIRED + LoginBlocker.VERIFICATION_REQUIRED -> SafetyStopReason.VERIFICATION_REQUIRED + LoginBlocker.RISK_CONTROL -> SafetyStopReason.RISK_CONTROL + LoginBlocker.NONE, + LoginBlocker.UNKNOWN -> { + if (visibleTexts.containsAny(paymentMarkers)) { + SafetyStopReason.PAYMENT_BOUNDARY + } else { + null + } + } + } + + val normalized = visibleTexts.map(::normalize) + val hasSearchInput = visibleElements.any { element -> + (element.editable || element.className.endsWith("EditText")) && + normalize(element.contentDescription.orEmpty()) == "搜索" + } + val hasSubmitButton = visibleElements.any { element -> + element.className.endsWith("TextView") && + element.clickable && + normalize(element.text.orEmpty()) == "搜索" + } + val hasHomeSearchEntry = visibleElements.any { element -> + element.className.endsWith("TextView") && + normalize(element.contentDescription.orEmpty()) == "搜索" + } + val hasHome = normalized.any { it == "首页" } + val hasResultSearchHeader = visibleElements.any { element -> + element.className.endsWith("HorizontalScrollView") && + normalize(element.contentDescription.orEmpty()) == "搜索" + } + val sortControlCount = setOf("综合", "销量", "价格", "筛选") + .count { marker -> normalized.any { it == marker } } + val hasExactQuery = visibleTexts.any { it.trim() == expectedQuery } + + val page = when { + hasExactQuery && hasResultSearchHeader && sortControlCount >= 3 -> + PinduoduoPage.SEARCH_RESULTS + hasSearchInput && hasSubmitButton -> PinduoduoPage.SEARCH_INPUT + hasHomeSearchEntry && hasHome -> PinduoduoPage.HOME + else -> PinduoduoPage.UNKNOWN + } + return PinduoduoUiSnapshot( + foregroundPackage = foregroundPackage, + page = page, + safetyStopReason = safetyStop + ) + } + + private fun normalize(value: String): String = + value.lowercase().replace(Regex("\\s+"), "") + + private fun Collection.containsAny(markers: Collection): Boolean = + any { text -> markers.any(text::contains) } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomation.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomation.kt new file mode 100644 index 0000000..433b00d --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomation.kt @@ -0,0 +1,143 @@ +package com.roubao.autopilot.pinduoduo + +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 + +const val SEARCH_PROBE_KEYWORD = "手机支架" + +interface PinduoduoUiDriver { + suspend fun openApp(): Boolean + suspend fun snapshot(): PinduoduoUiSnapshot + suspend fun openSearch(): Boolean + suspend fun setSearchKeyword(keyword: String): Boolean + suspend fun submitSearch(): Boolean +} + +class PinduoduoSearchAutomation( + private val driver: PinduoduoUiDriver, + private val keyword: String = SEARCH_PROBE_KEYWORD, + private val pollIntervalMillis: Long = 200, + private val unknownPageLimit: Int = 10 +) : AutomationGateway { + init { + require(keyword.isNotBlank()) { "Search keyword must not be blank" } + require(pollIntervalMillis > 0) { "Poll interval must be positive" } + require(unknownPageLimit > 0) { "Unknown page limit must be positive" } + } + + override suspend fun execute(step: WorkflowStep): AutomationResult = + when (step.id) { + PinduoduoSearchWorkflow.OPEN_APP -> openApp() + PinduoduoSearchWorkflow.ENTER_QUERY -> enterQuery() + PinduoduoSearchWorkflow.SUBMIT_QUERY -> submitQuery() + PinduoduoSearchWorkflow.VERIFY_RESULTS -> verifyResults() + else -> AutomationResult.FatalFailure(WorkflowFailureCode.AUTOMATION_EXCEPTION) + } + + private suspend fun openApp(): AutomationResult { + if (!driver.openApp()) { + return AutomationResult.FatalFailure(WorkflowFailureCode.TARGET_NOT_READY) + } + return awaitPage { + it == PinduoduoPage.HOME || + it == PinduoduoPage.SEARCH_INPUT || + it == PinduoduoPage.SEARCH_RESULTS + } ?: AutomationResult.Success + } + + private suspend fun enterQuery(): AutomationResult { + val ready = awaitPage { + it == PinduoduoPage.HOME || + it == PinduoduoPage.SEARCH_INPUT || + it == PinduoduoPage.SEARCH_RESULTS + } + if (ready != null) { + return ready + } + + val currentPage = driver.snapshot().page + if (currentPage == PinduoduoPage.SEARCH_RESULTS) { + return AutomationResult.Success + } + if (currentPage != PinduoduoPage.SEARCH_INPUT) { + if (!driver.openSearch()) { + return AutomationResult.RetryableFailure( + WorkflowFailureCode.TRANSIENT_AUTOMATION + ) + } + val searchInput = awaitPage { it == PinduoduoPage.SEARCH_INPUT } + if (searchInput != null) { + return searchInput + } + } + + return if (driver.setSearchKeyword(keyword)) { + AutomationResult.Success + } else { + AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION) + } + } + + private suspend fun submitQuery(): AutomationResult { + val snapshot = driver.snapshot() + safetyResult(snapshot)?.let { return it } + if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) { + return AutomationResult.Success + } + return if (driver.submitSearch()) { + AutomationResult.Success + } else { + AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION) + } + } + + private suspend fun verifyResults(): AutomationResult = + awaitPage { it == PinduoduoPage.SEARCH_RESULTS } ?: AutomationResult.Success + + private suspend fun awaitPage( + expected: (PinduoduoPage) -> Boolean + ): AutomationResult? { + var stableUnknownObservations = 0 + while (true) { + val snapshot = driver.snapshot() + safetyResult(snapshot)?.let { return it } + if (expected(snapshot.page)) { + return null + } + + stableUnknownObservations = if ( + snapshot.foregroundPackage == com.roubao.autopilot.readiness.PINDUODUO_PACKAGE && + snapshot.page == PinduoduoPage.UNKNOWN + ) { + stableUnknownObservations + 1 + } else { + 0 + } + if (stableUnknownObservations >= unknownPageLimit) { + return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE) + } + delay(pollIntervalMillis) + } + } + + private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? = + snapshot.safetyStopReason?.let(AutomationResult::Blocked) +} + +object PinduoduoSearchWorkflow { + const val OPEN_APP = "pdd_open_app" + const val ENTER_QUERY = "pdd_enter_query" + const val SUBMIT_QUERY = "pdd_submit_query" + const val VERIFY_RESULTS = "pdd_verify_results" + + fun steps(): List = listOf( + WorkflowStep(OPEN_APP, timeoutMillis = 8_000, maxRetries = 1), + WorkflowStep(ENTER_QUERY, timeoutMillis = 10_000, maxRetries = 1), + WorkflowStep(SUBMIT_QUERY, timeoutMillis = 6_000, maxRetries = 1), + WorkflowStep(VERIFY_RESULTS, timeoutMillis = 12_000, maxRetries = 1) + ) +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt new file mode 100644 index 0000000..33335f8 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt @@ -0,0 +1,292 @@ +package com.roubao.autopilot.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.SEARCH_PROBE_KEYWORD +import com.roubao.autopilot.readiness.DeviceReadinessSnapshot +import com.roubao.autopilot.ui.theme.BaoziTheme +import com.roubao.autopilot.workflow.SafetyStopReason +import com.roubao.autopilot.workflow.WorkflowFailureCode +import com.roubao.autopilot.workflow.WorkflowReport +import com.roubao.autopilot.workflow.WorkflowState + +private data class ProbeStepUi( + val id: String, + val label: String +) + +private val probeSteps = listOf( + ProbeStepUi(PinduoduoSearchWorkflow.OPEN_APP, "打开拼多多"), + ProbeStepUi(PinduoduoSearchWorkflow.ENTER_QUERY, "输入固定关键词"), + ProbeStepUi(PinduoduoSearchWorkflow.SUBMIT_QUERY, "提交搜索"), + ProbeStepUi(PinduoduoSearchWorkflow.VERIFY_RESULTS, "确认结果页") +) + +@Composable +fun SearchProbeScreen( + readiness: DeviceReadinessSnapshot, + state: WorkflowState, + currentStepId: String?, + report: WorkflowReport?, + onStart: () -> Unit, + onStop: () -> Unit +) { + val colors = BaoziTheme.colors + val active = state == WorkflowState.RUNNING || state == WorkflowState.RETRYING + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(colors.background), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + item { + Text( + text = "拼多多搜索探针", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = colors.textPrimary + ) + Text( + text = stateLabel(state, report), + fontSize = 14.sp, + color = stateColor(state) + ) + Spacer(modifier = Modifier.height(24.dp)) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + text = "固定关键词", + fontSize = 13.sp, + color = colors.textSecondary + ) + Text( + text = SEARCH_PROBE_KEYWORD, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = colors.textPrimary + ) + } + } + Divider(color = colors.surfaceVariant) + } + + items(probeSteps.size) { index -> + val step = probeSteps[index] + ProbeStepRow( + label = step.label, + state = stepState( + stepId = step.id, + workflowState = state, + currentStepId = currentStepId, + report = report + ) + ) + } + + item { + Spacer(modifier = Modifier.height(24.dp)) + if (active) { + OutlinedButton( + onClick = onStop, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Close, contentDescription = null) + Spacer(modifier = Modifier.size(8.dp)) + Text("停止探针") + } + } else { + Button( + onClick = onStart, + enabled = readiness.canStartProbe, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = colors.primary) + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.size(8.dp)) + Text("开始搜索探针") + } + } + if (!readiness.canStartProbe) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "设备检查存在阻塞项", + fontSize = 13.sp, + color = colors.error + ) + } + } + } +} + +private enum class ProbeStepState { + WAITING, + ACTIVE, + COMPLETE, + FAILED +} + +@Composable +private fun ProbeStepRow(label: String, state: ProbeStepState) { + val colors = BaoziTheme.colors + val icon: ImageVector + val tint: Color + val status: String + when (state) { + ProbeStepState.WAITING -> { + icon = Icons.Default.Info + tint = colors.textHint + status = "等待" + } + ProbeStepState.ACTIVE -> { + icon = Icons.Default.PlayArrow + tint = colors.primary + status = "执行中" + } + ProbeStepState.COMPLETE -> { + icon = Icons.Default.CheckCircle + tint = colors.success + status = "完成" + } + ProbeStepState.FAILED -> { + icon = Icons.Default.Warning + tint = colors.error + status = "停止" + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(22.dp) + ) + Text( + text = label, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colors.textPrimary, + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) + Text( + text = status, + fontSize = 13.sp, + color = tint + ) + } + Divider(color = colors.surfaceVariant) +} + +private fun stepState( + stepId: String, + workflowState: WorkflowState, + currentStepId: String?, + report: WorkflowReport? +): ProbeStepState = when { + report?.completedStepIds?.contains(stepId) == true -> ProbeStepState.COMPLETE + stepId == currentStepId && + workflowState in setOf( + WorkflowState.FAILED, + WorkflowState.BLOCKED, + WorkflowState.STOPPED + ) -> ProbeStepState.FAILED + stepId == currentStepId -> ProbeStepState.ACTIVE + else -> ProbeStepState.WAITING +} + +@Composable +private fun stateColor(state: WorkflowState): Color { + val colors = BaoziTheme.colors + return when (state) { + WorkflowState.SUCCEEDED -> colors.success + WorkflowState.FAILED, + WorkflowState.BLOCKED -> colors.error + WorkflowState.RETRYING -> colors.warning + WorkflowState.RUNNING -> colors.primary + WorkflowState.IDLE, + WorkflowState.STOPPED -> colors.textSecondary + } +} + +private fun stateLabel(state: WorkflowState, report: WorkflowReport?): String = + when (state) { + WorkflowState.IDLE -> "等待开始" + WorkflowState.RUNNING -> "正在执行" + WorkflowState.RETRYING -> "正在重试" + WorkflowState.SUCCEEDED -> "搜索结果页已确认" + WorkflowState.STOPPED -> "已由用户停止" + WorkflowState.FAILED -> failureLabel(report?.failureCode) + WorkflowState.BLOCKED -> blockedLabel(report?.safetyStopReason) + } + +private fun failureLabel(code: WorkflowFailureCode?): String = when (code) { + WorkflowFailureCode.TIMEOUT -> "执行超时" + WorkflowFailureCode.TRANSIENT_AUTOMATION -> "页面操作失败" + WorkflowFailureCode.AUTOMATION_EXCEPTION -> "自动化执行异常" + WorkflowFailureCode.TARGET_NOT_READY -> "拼多多无法启动" + null -> "执行失败" +} + +private fun blockedLabel(reason: SafetyStopReason?): String = when (reason) { + SafetyStopReason.LOGIN_REQUIRED -> "检测到登录要求,已停止" + SafetyStopReason.VERIFICATION_REQUIRED -> "检测到验证码,已停止" + SafetyStopReason.RISK_CONTROL -> "检测到风控,已停止" + SafetyStopReason.UNKNOWN_PAGE -> "页面无法确认,已停止" + SafetyStopReason.PAYMENT_BOUNDARY -> "检测到支付边界,已停止" + null -> "安全检查阻塞" +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt index 1684e6f..6a930c6 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt @@ -29,7 +29,8 @@ enum class WorkflowState { enum class WorkflowFailureCode { TIMEOUT, TRANSIENT_AUTOMATION, - AUTOMATION_EXCEPTION + AUTOMATION_EXCEPTION, + TARGET_NOT_READY } enum class SafetyStopReason { diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt index 56e75a6..01e5e2a 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt @@ -17,6 +17,7 @@ class WorkflowRunner( ) { private val running = AtomicBoolean(false) private val mutableState = MutableStateFlow(WorkflowState.IDLE) + private val mutableCurrentStepId = MutableStateFlow(null) @Volatile private var stopRequested = false @@ -25,12 +26,14 @@ class WorkflowRunner( private var currentExecution: Job? = null val state: StateFlow = mutableState.asStateFlow() + val currentStepId: StateFlow = mutableCurrentStepId.asStateFlow() suspend fun run(steps: List): WorkflowReport { require(steps.isNotEmpty()) { "Workflow must contain at least one step" } check(running.compareAndSet(false, true)) { "WorkflowRunner is already running" } stopRequested = false + mutableCurrentStepId.value = null val completedSteps = mutableListOf() val attempts = linkedMapOf() val transitions = mutableListOf() @@ -197,6 +200,7 @@ class WorkflowRunner( ) { val previous = mutableState.value mutableState.value = next + mutableCurrentStepId.value = stepId transitions += WorkflowTransition( from = previous, to = next, diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt new file mode 100644 index 0000000..f38bf7b --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt @@ -0,0 +1,135 @@ +package com.roubao.autopilot.pinduoduo + +import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import com.roubao.autopilot.workflow.SafetyStopReason +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PinduoduoPageClassifierTest { + @Test + fun `home page requires search entry and home marker`() { + val snapshot = classify( + element(contentDescription = "搜索"), + element(text = "首页") + ) + + assertEquals(PinduoduoPage.HOME, snapshot.page) + assertNull(snapshot.safetyStopReason) + } + + @Test + fun `editable search page is recognized`() { + val snapshot = classify( + element( + contentDescription = "搜索", + className = "android.widget.EditText", + editable = true + ), + element(text = "搜索", clickable = true) + ) + + assertEquals(PinduoduoPage.SEARCH_INPUT, snapshot.page) + } + + @Test + fun `result controls identify search results`() { + val snapshot = classify( + element( + contentDescription = "搜索", + className = "android.widget.HorizontalScrollView" + ), + element(text = SEARCH_PROBE_KEYWORD), + element(text = "综合"), + element(text = "销量"), + element(text = "价格"), + element(text = "筛选") + ) + + assertEquals(PinduoduoPage.SEARCH_RESULTS, snapshot.page) + } + + @Test + fun `login verification and risk markers remain distinct`() { + assertEquals( + SafetyStopReason.LOGIN_REQUIRED, + classify(element(text = "请先登录")).safetyStopReason + ) + assertEquals( + SafetyStopReason.VERIFICATION_REQUIRED, + classify(element(text = "请输入验证码")).safetyStopReason + ) + assertEquals( + SafetyStopReason.RISK_CONTROL, + classify(element(text = "账号存在风险,请完成安全验证")).safetyStopReason + ) + } + + @Test + fun `payment boundary blocks even on otherwise unknown page`() { + val snapshot = classify(element(text = "确认订单")) + + assertEquals(PinduoduoPage.UNKNOWN, snapshot.page) + assertEquals(SafetyStopReason.PAYMENT_BOUNDARY, snapshot.safetyStopReason) + } + + @Test + fun `hidden and disabled lookalikes do not classify a page`() { + val snapshot = classify( + element(contentDescription = "搜索", visibleToUser = false), + element(text = "首页", enabled = false) + ) + + assertEquals(PinduoduoPage.UNKNOWN, snapshot.page) + assertNull(snapshot.safetyStopReason) + } + + @Test + fun `results require the exact expected query`() { + val snapshot = classify( + element( + contentDescription = "搜索", + className = "android.widget.HorizontalScrollView" + ), + element(text = "$SEARCH_PROBE_KEYWORD 推荐"), + element(text = "综合"), + element(text = "销量"), + element(text = "价格") + ) + + assertEquals(PinduoduoPage.UNKNOWN, snapshot.page) + } + + @Test + fun `other foreground package is unknown and has no inferred blocker`() { + val snapshot = PinduoduoPageClassifier.classify( + foregroundPackage = "com.roubao.autopilot", + elements = listOf(element(text = "请先登录")) + ) + + assertEquals(PinduoduoPage.UNKNOWN, snapshot.page) + assertNull(snapshot.safetyStopReason) + } + + private fun classify(vararg elements: PinduoduoUiElement): PinduoduoUiSnapshot = + PinduoduoPageClassifier.classify(PINDUODUO_PACKAGE, elements.toList()) + + private fun element( + text: String? = null, + contentDescription: String? = null, + className: String = "android.widget.TextView", + editable: Boolean = false, + clickable: Boolean = false, + enabled: Boolean = true, + visibleToUser: Boolean = true + ) = PinduoduoUiElement( + text = text, + contentDescription = contentDescription, + className = className, + resourceId = null, + clickable = clickable, + editable = editable, + enabled = enabled, + visibleToUser = visibleToUser + ) +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomationTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomationTest.kt new file mode 100644 index 0000000..7d240f0 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoSearchAutomationTest.kt @@ -0,0 +1,131 @@ +package com.roubao.autopilot.pinduoduo + +import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import com.roubao.autopilot.workflow.AutomationResult +import com.roubao.autopilot.workflow.SafetyStopReason +import com.roubao.autopilot.workflow.WorkflowRunner +import com.roubao.autopilot.workflow.WorkflowState +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PinduoduoSearchAutomationTest { + @Test + fun `fixed keyword workflow reaches verified results`() = runTest { + val driver = FakeDriver(page = PinduoduoPage.HOME) + val runner = WorkflowRunner( + PinduoduoSearchAutomation( + driver = driver, + pollIntervalMillis = 1, + unknownPageLimit = 3 + ) + ) + + val report = runner.run(PinduoduoSearchWorkflow.steps()) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(SEARCH_PROBE_KEYWORD, driver.enteredKeyword) + assertTrue(driver.searchSubmitted) + assertEquals(PinduoduoPage.SEARCH_RESULTS, driver.page) + } + + @Test + fun `transient missing search entry uses bounded workflow retry`() = runTest { + val driver = FakeDriver( + page = PinduoduoPage.HOME, + failedOpenSearchAttempts = 1 + ) + val runner = WorkflowRunner( + PinduoduoSearchAutomation( + driver = driver, + pollIntervalMillis = 1, + unknownPageLimit = 3 + ) + ) + + val report = runner.run(PinduoduoSearchWorkflow.steps()) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(2, driver.openSearchCalls) + assertEquals(2, report.attemptsByStep[PinduoduoSearchWorkflow.ENTER_QUERY]) + } + + @Test + fun `login blocker stops before any search action`() = runTest { + val driver = FakeDriver( + page = PinduoduoPage.UNKNOWN, + safetyStopReason = SafetyStopReason.LOGIN_REQUIRED + ) + val runner = WorkflowRunner( + PinduoduoSearchAutomation( + driver = driver, + pollIntervalMillis = 1, + unknownPageLimit = 3 + ) + ) + + val report = runner.run(PinduoduoSearchWorkflow.steps()) + + assertEquals(WorkflowState.BLOCKED, report.state) + assertEquals(SafetyStopReason.LOGIN_REQUIRED, report.safetyStopReason) + assertEquals(0, driver.openSearchCalls) + } + + @Test + fun `stable unknown Pinduoduo page blocks safely`() = runTest { + val driver = FakeDriver(page = PinduoduoPage.UNKNOWN) + val automation = PinduoduoSearchAutomation( + driver = driver, + pollIntervalMillis = 10, + unknownPageLimit = 3 + ) + + val result = automation.execute(PinduoduoSearchWorkflow.steps().first()) + + assertEquals( + AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE), + result + ) + } + + private class FakeDriver( + var page: PinduoduoPage, + private val safetyStopReason: SafetyStopReason? = null, + private var failedOpenSearchAttempts: Int = 0 + ) : PinduoduoUiDriver { + var enteredKeyword: String? = null + var searchSubmitted = false + var openSearchCalls = 0 + + override suspend fun openApp(): Boolean = true + + override suspend fun snapshot(): PinduoduoUiSnapshot = + PinduoduoUiSnapshot( + foregroundPackage = PINDUODUO_PACKAGE, + page = page, + safetyStopReason = safetyStopReason + ) + + override suspend fun openSearch(): Boolean { + openSearchCalls += 1 + if (failedOpenSearchAttempts > 0) { + failedOpenSearchAttempts -= 1 + return false + } + page = PinduoduoPage.SEARCH_INPUT + return true + } + + override suspend fun setSearchKeyword(keyword: String): Boolean { + enteredKeyword = keyword + return true + } + + override suspend fun submitSearch(): Boolean { + searchSubmitted = true + page = PinduoduoPage.SEARCH_RESULTS + return true + } + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt index 01b50c4..7abcefd 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt @@ -32,6 +32,7 @@ class WorkflowRunnerTest { assertEquals(WorkflowState.SUCCEEDED, report.state) assertEquals(listOf("open_app", "verify_page"), report.completedStepIds) assertEquals(mapOf("open_app" to 1, "verify_page" to 1), report.attemptsByStep) + assertEquals(null, runner.currentStepId.value) assertEquals( listOf( WorkflowState.RUNNING, @@ -79,6 +80,7 @@ class WorkflowRunnerTest { assertEquals(WorkflowState.FAILED, report.state) assertEquals(WorkflowFailureCode.TIMEOUT, report.failureCode) assertEquals(2, report.attemptsByStep["wait_for_page"]) + assertEquals("wait_for_page", runner.currentStepId.value) assertEquals(200, currentTime) } diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 29459ca..31a96c0 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -51,8 +51,9 @@ ## 当前阶段与优先路径 -当前已完成 Phase 0:Android 可运行、设备就绪、workflow 测试和私有样本导入基线。 -下一步是 T-101,用固定脱敏搜索词跑通拼多多关键词搜索并验证安全停止。 +当前已完成 Phase 0 和 T-101:Android 可运行、设备就绪、workflow、私有样本导入 +以及固定脱敏词拼多多搜索均已在真机验证。下一步是 T-102,只浏览并采集最多 5 个 +候选,继续禁止订单提交和支付。 严格按以下顺序推进: diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 2c0a35a..134880b 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -8,7 +8,7 @@ | --- | --- | | 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、描述、图片、数量和预算。 | | 第一层样本来源 | 本机目录中的蝦皮订单文本和参考图;二者以蝦皮订单号作为同名文件名。 | -| 执行方式 | 采购人员使用 Android App 操作拼多多;当前没有可运行实现。 | +| 执行方式 | 采购人员使用 Android App 操作拼多多;固定词搜索探针已可运行,候选浏览和真实任务尚未实现。 | | 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 | | 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 | | 资金边界 | MVP 不提交订单、不支付,只验证到人工确认位置。 | @@ -140,7 +140,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi `T-001` 完成。 - 首份蝦皮样本已由 T-004 真实导入验证;当前只支持单 SKU 和 JPEG,扩展格式需新 样例和独立任务。 -- 拼多多版本、页面结构、账号登录状态和测试设备尚未形成可复现基线。 +- OnePlus PKG110、Android 16/API 36、拼多多 8.17.0 的首页、搜索输入和结果页已形成 + 可复现基线;候选列表、详情页及账号风控差异仍待 T-102 验证。 - VLM 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。 - 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。 - 后续若允许提交订单,必须先明确 SKU、收货地址、运费、优惠、发票、金额审批、 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index a86ad3a..3c84642 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -11,7 +11,7 @@ | Android 构建链 | Gradle 8.2;AGP 8.2.0;Kotlin 1.9.20;JVM target 17 | 已验证 | 已补齐上游缺失的 `gradlew.bat`,不依赖全局 Gradle。 | | Android SDK | compileSdk/targetSdk 34;minSdk 26;SDK Build Tools 34.0.0 | 已验证 | 支持 Android 8.0+;本机使用 Command-line Tools 22.0。 | | Android UI | Jetpack Compose + Material 3;Compose Compiler 1.5.5 | 上游已核实 | Compose BOM 为 2023.10.01。 | -| Android 自动化 | 项目目标以 `AccessibilityService` 为主,Shizuku 为兼容/增强路径 | 就绪观察基线已实现,动作待实现 | T-002 已实现只读前台/登录阻塞观察;搜索、点击和安全动作门禁由 T-101 开始实现。上游 `main` 仍保留 Shizuku 13.1.5。 | +| Android 自动化 | `AccessibilityService` 语义节点动作;Shizuku 保留为上游兼容路径 | 固定词搜索已真机验证 | T-101 已用可见、启用、唯一节点完成打开搜索、`ACTION_SET_TEXT` 精确回读、提交和结果页确认;没有坐标或 shell 降级。上游 `main` 仍保留 Shizuku 13.1.5。 | | 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 | | Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 | | 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 | @@ -27,7 +27,7 @@ | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 接口已定,供应商待定 | 模型输出必须符合本项目 JSON Schema。 | | 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | | 后端测试 | 标准库 `testing` + `httptest` | MVP 已定 | 覆盖状态机、权限、幂等、SQLite 事务和输入校验。 | -| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | workflow 基线已实现 | T-003 已覆盖成功、timeout、有限 retry、安全阻塞、用户停止和并发拒绝;真实 UI 动作仍需真机 smoke。 | +| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | 搜索探针已验证 | T-003 runner 与 T-101 页面分类/Fake driver 已覆盖;OnePlus PKG110 + 拼多多 8.17.0 固定词结果页 smoke 成功。 | | 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 | ## Roubao 上游版本基线 @@ -134,7 +134,7 @@ docs/ | --- | --- | --- | | Android 标准验证 | `.\init.ps1` | 已验证 | | Android 构建 | `android-buyer\gradlew.bat assembleDebug --no-daemon` | 已验证 | -| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;无测试源 | +| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 62 次测试通过 | | Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 | | 后端依赖 | `go mod download`(在 `backend-api/`) | 待 `T-201` | | 后端测试 | `go test ./...`(在 `backend-api/`) | 待 `T-201` | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 2f4330b..86a3e85 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -152,6 +152,23 @@ Debug 构建或测试装载。导入必须: 私有 fixture 只有在构建显式设置 `-PprobeFixturesDir` 时注入 Debug APK;普通构建 必须同步清空生成资产,防止上一次真实样本残留。 +T-101 的固定词搜索已落在以下运行边界: + +```text +SearchProbeScreen + -> WorkflowRunner(timeout / retry / stop) + -> PinduoduoSearchAutomation(纯 Kotlin) + -> AndroidPinduoduoUiDriver + -> BuyerAccessibilityBridge + -> BuyerAccessibilityService + -> 拼多多可见、启用、唯一语义节点 +``` + +结果页成功不是“点击已发送”,而是同时确认精确查询词、搜索结果头和至少 3 个排序 +控件。`ACTION_SET_TEXT` 后必须重新读取当前根节点并精确比对输入;任何稳定未知页以及 +登录、验证码、风控、订单或支付边界都终止 workflow。该路径不提供坐标、ADB、 +Shizuku shell、OCR 或 VLM 动作降级。 + ### 3.2 MVP 业务闭环 ```text @@ -306,7 +323,10 @@ IDLE - 只允许目标拼多多包处于前台时发送动作。 - 关键动作前后都校验包名、页面特征和任务取消标志。 -- 节点选择优先资源 ID、可访问性文本和稳定层级;文本版本变化要集中配置。 +- 节点选择要求可见、启用且唯一匹配;优先可访问性语义、稳定文本和受限祖先层级, + 不能因资源 ID 重复而任取第一个节点。 +- 已验证的首页、搜索输入和结果页路径禁止坐标降级;新页面必须先取得版本化真机 + 证据并在独立任务中定义识别条件。 - 截图发送给模型前裁剪无关区域并按配置脱敏。 - 每个动作记录抽象步骤,不默认记录完整输入文本。 - 发现验证码、风险控制、支付、生物识别或系统权限页面立即停止。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index e0f110a..a3d9574 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -30,7 +30,10 @@ ## 4. Android 自动化规则 - 动作前确认目标 App 包名和预期页面,动作后确认状态确实变化。 -- 优先使用资源 ID、可访问性语义和稳定文本;坐标只能作为被验证的局部降级。 +- 节点必须可见、启用且唯一匹配;资源 ID 重复时不得选择第一个结果。 +- 首页、搜索输入和结果页的已验证路径只用可访问性语义和稳定文本,禁止坐标降级。 + 其他页面如需坐标必须另立任务、固定版本并提供语义方案确实不可用的证据。 +- 文本写入动作返回成功不等于完成;必须从新的根节点精确回读后再推进。 - 每个步骤定义 timeout、有限 retry、成功条件和停止条件。 - 禁止无界循环、无限滑动、无限候选遍历和随机点击。 - 默认最多检查 5 个候选;扩大范围必须先改需求。 diff --git a/docs/current-state.md b/docs/current-state.md index 39d126a..b86c3d3 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-25 -- 阶段:Phase 0 完成,准备执行 T-101 拼多多关键词搜索探针 +- 阶段:T-101 已完成;准备 T-102 候选浏览和采集 - Git:当前分支为 `main`;T-001 至 T-004 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 @@ -14,21 +14,21 @@ Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 - 共执行 38 次测试,0 failure、0 error、0 skipped + 共执行 62 次测试,0 failure、0 error、0 skipped - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 - 用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作 + 用户停止和单 runner 并发拒绝;T-101 已接入真实拼多多固定词搜索四步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture - 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多 `8.17.0 (81700)` -- 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、 - 验证码或风控文案,但该结果不等于账号已确认登录 +- 设备就绪:肉包采购无障碍已启用并连接;拼多多首页、搜索输入和固定词结果页已通过 + 8.17.0 真机验证,结果页精确词、搜索头和 4 个排序控件均确认 - 版本控制内测试数据:仅有脱敏、运行时生成的单元测试 fixture;没有真实订单内容 - 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG; 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` -- 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认; - 当前只支持单 SKU/JPEG;VLM 供应商和测试凭证未确认 +- 当前 blocker:候选列表/详情页语义与有界返回路径尚待 T-102 验证;当前只支持单 + SKU/JPEG;VLM 供应商、模型和测试凭证未确认 ## 当前目录 @@ -40,6 +40,7 @@ | `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 | | `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner | | `docs/tasks/T-004.md` | DONE | 私有蝦皮文本/JPEG 严格导入和 Debug TaskSource | +| `docs/tasks/T-101.md` | DONE | 固定脱敏词拼多多搜索和结果页真机验证 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource | @@ -49,9 +50,9 @@ ## 任务摘要 -- 已完成:T-001 至 T-004,Phase 0 可运行和输入基线。 +- 已完成:T-001 至 T-004,以及 T-101 固定词搜索探针。 - 正在进行:无。 -- 下一个可领取任务:T-101 固定任务跑通拼多多关键词搜索。 +- 下一个可领取任务:T-102 浏览并采集最多 5 个候选。 ## 当前可运行内容 @@ -62,8 +63,9 @@ $env:RUN_START_COMMAND = "1" .\init.ps1 ``` -2026-07-25 已在 PKG110、Android 16/API 36 上完成 Debug APK 更新安装和启动; -首屏为设备就绪检查,肉包采购无障碍服务已绑定,进程存活且 logcat 无崩溃。 +2026-07-25 已在 PKG110、Android 16/API 36、拼多多 8.17.0 上完成 Debug APK +固定词搜索。默认首屏为搜索探针,用户点击后四步全部完成并确认结果页;设备页保留 +就绪检查入口,肉包采购无障碍服务已绑定,logcat 无崩溃或 ANR。 ## 维护规则 diff --git a/docs/tasks/T-101.md b/docs/tasks/T-101.md new file mode 100644 index 0000000..2682ca1 --- /dev/null +++ b/docs/tasks/T-101.md @@ -0,0 +1,119 @@ +--- +id: T-101 +title: 固定任务跑通拼多多关键词搜索 +phase: 1 +deps: + - T-002 + - T-003 +status: DONE +created: 2026-07-25 +context_ref: 1c1c1581493af2d04433012064f44f58c1f1d56b +work_branch: main +write_paths: + - android-buyer/app/build.gradle.kts + - android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt + - android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/** + - android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/** + - android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt + - android-buyer/app/src/main/java/com/roubao/autopilot/workflow/** + - android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/** + - android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt + - docs/00-ai-start-here.md + - docs/02-requirements.md + - docs/03-tech-stack.md + - docs/04-architecture.md + - docs/05-coding-rules.md + - docs/current-state.md + - docs/tasks/T-101.md + - progress.md +--- + +## 问题 / 背景 + +Phase 0 已证明设备、无障碍、workflow 和任务输入可用,但尚未让肉包执行任何真实 +拼多多动作。最高风险假设是:在拼多多 8.17.0 上,能否只依赖可访问性语义稳定打开 +搜索、输入固定关键词并确认进入结果页,同时对异常页面安全停止。 + +## 关联需求与交互 + +- 功能:F-004 的关键词搜索部分、F-007 安全失败。 +- 用户故事:US-004、US-006。 +- 交互:Android“探针”页,用户显式点击开始和停止。 +- 架构:`WorkflowRunner` + `PinduoduoSearchAutomation` + 无障碍 driver。 + +## 方案 + +1. 使用固定脱敏关键词,不读取真实订单标题,也不调用 VLM。 +2. 把页面文本分类和搜索 workflow adapter 保持为纯 Kotlin 可测边界。 +3. 无障碍 driver 只对 `com.xunmeng.pinduoduo` 操作,使用 resource ID、editable、 + 文本和 content description,不使用屏幕坐标。 +4. 步骤为打开 App、进入搜索、输入并提交、验证结果页;每步有 timeout 和最多一次 + retry。 +5. 登录、验证码、风控、支付边界立即 `BLOCKED`;未知页面连续观察后安全停止。 +6. Android“探针”页展示当前步骤、终态和停止按钮;不执行商品点击或下单。 + +## 验收要点 + +- [x] 用户显式点击后打开拼多多并提交固定关键词。 +- [x] 成功条件是识别到搜索结果页,不以“点击已发出”冒充成功。 +- [x] 全流程不用坐标,不进入商品详情、不提交订单、不支付。 +- [x] 登录、验证码、风控、未知页和支付边界产生可区分的 `BLOCKED`。 +- [x] 每步 timeout、retry 和用户停止由 T-003 runner 执行。 +- [x] 页面分类和 adapter 使用 Fake driver 有自动化测试。 +- [x] 拼多多 8.17.0 真机 smoke 通过并保存不含私有内容的证据。 +- [x] `lintDebug test assembleDebug` 通过。 + +## 边界 + +- 不从真实 Shopee 标题生成搜索词;固定词只验证页面自动化。 +- 不浏览候选、不点击商品、不滚动结果列表。 +- 不使用 Shizuku shell、ADB 坐标、OCR 或 VLM 作为降级。 +- 不绕过登录、验证码、风控或平台提示。 + +## 执行记录 + +### 2026-07-25:任务开始 + +- 基于 T-004 提交 `1c1c158` 开始。 +- 测试基线为 OnePlus PKG110、Android 16/API 36、拼多多 8.17.0;肉包采购无障碍 + 已启用并连接。 + +### 2026-07-25:实现 + +- 增加纯 Kotlin 页面分类器和 `PinduoduoSearchAutomation`。页面节点必须同时可见、 + 启用并唯一匹配;结果页必须同时出现精确固定关键词、搜索头和至少 3 个排序控件。 +- 增加 Android 无障碍 driver。打开搜索只点击语义节点或其可点击祖先,输入只使用 + `ACTION_SET_TEXT`,并从新的根节点重新读取精确文本后才算成功;没有坐标或 shell + 降级。 +- workflow 固定执行打开 App、输入、提交和验证四步,沿用 T-003 的 timeout、有限 + retry、停止和单实例约束。登录、验证码、风控、未知页面及订单/支付边界直接阻塞。 +- 新增“拼多多搜索探针”默认工作页,显示固定词、当前步骤、终态和开始/停止操作。 + Activity 恢复后延迟复检无障碍连接,避免系统服务晚连接造成按钮状态滞后。 +- 修正 Debug fixture 资产与 lint model 的 Gradle 任务依赖,使完整验证不受增量任务 + 顺序影响。 + +### 2026-07-25:自动化验证 + +- `gradlew.bat lintDebug test assembleDebug --no-daemon` 成功。 +- App Debug/Release、task contract 和导入器共 12 份报告、62 次测试,0 failure、 + 0 error、0 skipped。 +- 页面分类覆盖首页、输入页、精确结果页、隐藏/禁用伪节点以及登录、验证码、风控、 + 未知页和订单/支付边界;Fake driver 覆盖成功、瞬态 retry 和安全阻塞。 +- 普通 Debug APK 中 `assets/probe-fixtures/` 条目数为 0。 + +### 2026-07-25:真机 smoke + +- 从肉包“开始搜索探针”按钮显式启动;App 使用固定脱敏词执行,未读取或显示私有 + 蝦皮样本。 +- 拼多多 8.17.0 结果页采集到 174 个可访问节点:精确关键词存在、结果页搜索头存在、 + 4 个排序控件存在、搜索输入框为 0、订单/支付边界为 0。 +- `ACTION_SET_TEXT` 后的新根节点精确回读通过,肉包最终显示 `SUCCEEDED` 且四步均 + 完成;logcat 未发现肉包崩溃或 ANR。 +- smoke XML/PNG 只保存在被忽略的 `.local/t101-smoke/`,未纳入 Git。重装后 ColorOS + 出现一次系统无障碍“保持开启”确认,确认后服务正常绑定;产品代码没有绕过该权限。 + +## 后续 + +- T-102 从当前结果页浏览并采集最多 5 个候选;仍不得无界滚动、点击下单或支付。 +- 从不属于已识别首页/输入页/当前固定词结果页的状态启动时,当前实现按 + `UNKNOWN_PAGE` 安全阻塞,不猜测返回路径。 diff --git a/progress.md b/progress.md index bc87d8d..4d9010e 100644 --- a/progress.md +++ b/progress.md @@ -69,3 +69,11 @@ Debug fixture 注入,真实样本逐字段与图片哈希验证通过。 - 影响:Phase 0 完成;T-101 可先使用固定搜索词验证真实拼多多操作,T-103 可直接 消费同一私有任务契约接入 VLM。 + +## 2026-07-25 拼多多固定词搜索探针 + +- 类型:阶段完成 +- 内容:完成 T-101;用纯无障碍语义节点跑通固定脱敏词的打开搜索、精确输入、提交 + 和结果页确认,并在拼多多 8.17.0 真机验证四步 workflow。 +- 影响:最高风险的基础搜索动作已证实可行;下一步 T-102 只扩展最多 5 个候选的 + 有界浏览和证据采集,继续禁止下单与支付。