feat: add versioned rule capabilities (#22)
This commit is contained in:
+38
@@ -2,6 +2,9 @@ package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
@@ -69,6 +72,41 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver {
|
||||
|
||||
override fun back(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
|
||||
override fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean {
|
||||
if (target != SemanticTarget.SPEC_PANEL) return false
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
if (node.isVisibleToUser && node.isScrollable) candidates += node
|
||||
}
|
||||
val node = candidates.maxByOrNull { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height().toLong() }
|
||||
} ?: return false
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (bounds.width() < 2 || bounds.height() < 2) return false
|
||||
val left = bounds.left + bounds.width() * 25 / 100
|
||||
val right = bounds.left + bounds.width() * 75 / 100
|
||||
val top = bounds.top + bounds.height() * 25 / 100
|
||||
val bottom = bounds.top + bounds.height() * 75 / 100
|
||||
val centerX = bounds.centerX()
|
||||
val centerY = bounds.centerY()
|
||||
val (startX, startY, endX, endY) = when (direction) {
|
||||
SwipeDirection.UP -> listOf(centerX, bottom, centerX, top)
|
||||
SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom)
|
||||
SwipeDirection.LEFT -> listOf(right, centerY, left, centerY)
|
||||
SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY)
|
||||
}
|
||||
val path = Path().apply {
|
||||
moveTo(startX.toFloat(), startY.toFloat())
|
||||
lineTo(endX.toFloat(), endY.toFloat())
|
||||
}
|
||||
return dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 450)).build(),
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
|
||||
visit(node)
|
||||
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class RuleAction { WAIT, CLICK, INPUT, BACK, EXTRACT }
|
||||
enum class HookStage { AFTER_SPEC_PANEL_OPEN }
|
||||
enum class HookActionType { SWIPE }
|
||||
enum class SemanticTarget { SPEC_PANEL }
|
||||
enum class SwipeDirection { UP, DOWN, LEFT, RIGHT }
|
||||
|
||||
data class NodeSelector(
|
||||
val resourceId: String? = null,
|
||||
@@ -28,20 +33,63 @@ data class RuleStep(
|
||||
val optional: Boolean,
|
||||
)
|
||||
|
||||
data class CollectionRule(val schemaVersion: Int, val steps: List<RuleStep>)
|
||||
data class HookAction(
|
||||
val action: HookActionType,
|
||||
val target: SemanticTarget,
|
||||
val direction: SwipeDirection,
|
||||
val count: Int,
|
||||
val settleMs: Long,
|
||||
)
|
||||
|
||||
data class PageEvidence(
|
||||
val packageName: String,
|
||||
val activityName: String,
|
||||
val selector: NodeSelector,
|
||||
)
|
||||
|
||||
data class PddCollectorConfig(
|
||||
val collectorId: String,
|
||||
val specEntryStrategy: String,
|
||||
val priceParser: String,
|
||||
val priceGranularity: String,
|
||||
val colorAliases: List<String>,
|
||||
val sizeAliases: List<String>,
|
||||
val timeoutsMs: Map<String, Int>,
|
||||
val limits: Map<String, Int>,
|
||||
)
|
||||
|
||||
data class CollectionRule(
|
||||
val schemaVersion: Int,
|
||||
val steps: List<RuleStep>,
|
||||
val ruleType: String = "legacyCollection",
|
||||
val pageEvidence: PageEvidence? = null,
|
||||
val hooks: Map<HookStage, List<HookAction>> = emptyMap(),
|
||||
val collector: PddCollectorConfig? = null,
|
||||
)
|
||||
|
||||
class RuleValidationException(val code: String, message: String) : IllegalArgumentException(message)
|
||||
|
||||
object AgentCapabilities {
|
||||
const val SCHEMA_V2 = "rule.schema.v2"
|
||||
const val SWIPE_V1 = "action.swipe.v1"
|
||||
const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1"
|
||||
|
||||
// T20 provides the contract and swipe primitive. T21 adds the collector
|
||||
// capability only after the complete state machine is wired in.
|
||||
val supported: List<String> = listOf(SCHEMA_V2, SWIPE_V1)
|
||||
}
|
||||
|
||||
object RuleParser {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private val allowedPackages = setOf(
|
||||
"com.xunmeng.pinduoduo",
|
||||
PDD_PACKAGE,
|
||||
"com.android.chrome",
|
||||
"com.heytap.browser",
|
||||
"com.android.browser",
|
||||
"android",
|
||||
)
|
||||
private val forbiddenWords = listOf("立即支付", "确认支付", "付款", "提交订单", "免密支付", "pay now")
|
||||
private val allowedOpenTargets = setOf("打开拼多多APP", "打开拼多多 App", "打开")
|
||||
private val forbiddenCollectionWords = listOf("提交订单", "创建订单", "立即支付", "确认支付", "付款", "免密支付", "pay now")
|
||||
|
||||
fun parse(raw: String): CollectionRule {
|
||||
val root = try {
|
||||
@@ -49,52 +97,77 @@ object RuleParser {
|
||||
} catch (_: Exception) {
|
||||
throw RuleValidationException("RULE_INVALID", "规则不是有效的 JSON 对象")
|
||||
}
|
||||
if (root.optInt("schemaVersion", -1) != 1) {
|
||||
throw RuleValidationException("RULE_VERSION_UNSUPPORTED", "仅支持 schemaVersion 1")
|
||||
return when (root.optInt("schemaVersion", -1)) {
|
||||
1 -> parseV1(root)
|
||||
2 -> parseV2(root)
|
||||
else -> throw RuleValidationException("RULE_VERSION_UNSUPPORTED", "仅支持 schemaVersion 1 或 2")
|
||||
}
|
||||
val defaultPackage = root.optString("packageName").takeIf { it.isNotBlank() }
|
||||
val items = root.optJSONArray("steps")
|
||||
?: throw RuleValidationException("RULE_INVALID", "steps 必须是非空数组")
|
||||
if (items.length() == 0) throw RuleValidationException("RULE_INVALID", "steps 必须是非空数组")
|
||||
}
|
||||
|
||||
private fun parseV1(root: JSONObject): CollectionRule {
|
||||
val steps = parseSteps(root.optJSONArray("steps"), root.stringOrNull("packageName"), allowPddActions = true)
|
||||
return CollectionRule(1, steps)
|
||||
}
|
||||
|
||||
private fun parseV2(root: JSONObject): CollectionRule {
|
||||
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "collector"), "v2 规则")
|
||||
if (root.optString("ruleType") != "pddProductDetail") {
|
||||
invalid("v2 规则必须声明 ruleType=pddProductDetail")
|
||||
}
|
||||
val navigation = root.optJSONObject("navigation") ?: invalid("navigation 必填")
|
||||
rejectUnknown(navigation, setOf("steps"), "navigation")
|
||||
val steps = parseSteps(navigation.optJSONArray("steps"), null, allowPddActions = false)
|
||||
val evidenceJson = root.optJSONObject("pageEvidence") ?: invalid("pageEvidence 必填")
|
||||
rejectUnknown(evidenceJson, setOf("packageName", "activityName", "selector"), "pageEvidence")
|
||||
val evidence = PageEvidence(
|
||||
packageName = evidenceJson.optString("packageName"),
|
||||
activityName = evidenceJson.optString("activityName").trim(),
|
||||
selector = parseSelector(evidenceJson.optJSONObject("selector")) ?: invalid("pageEvidence.selector 必填"),
|
||||
)
|
||||
if (evidence.packageName != PDD_PACKAGE || evidence.activityName.isBlank() || evidence.selector.isEmpty()) {
|
||||
invalid("pageEvidence 必须包含 PDD 包名、精确 Activity 和非空 selector")
|
||||
}
|
||||
val hooks = parseHooks(root.optJSONObject("hooks"))
|
||||
val collector = parseCollector(root.optJSONObject("collector") ?: invalid("collector 必填"))
|
||||
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector)
|
||||
}
|
||||
|
||||
private fun parseSteps(items: JSONArray?, defaultPackage: String?, allowPddActions: Boolean): List<RuleStep> {
|
||||
items ?: invalid("steps 必须是非空数组")
|
||||
if (items.length() == 0) invalid("steps 必须是非空数组")
|
||||
val ids = mutableSetOf<String>()
|
||||
val steps = (0 until items.length()).map { index ->
|
||||
val item = items.optJSONObject(index)
|
||||
?: throw RuleValidationException("RULE_INVALID", "steps[$index] 必须是对象")
|
||||
return (0 until items.length()).map { index ->
|
||||
val item = items.optJSONObject(index) ?: invalid("steps[$index] 必须是对象")
|
||||
val id = item.optString("id").trim()
|
||||
if (id.isBlank() || !ids.add(id)) throw RuleValidationException("RULE_INVALID", "步骤 id 为空或重复")
|
||||
if (id.isBlank() || !ids.add(id)) invalid("步骤 id 为空或重复")
|
||||
val action = runCatching { RuleAction.valueOf(item.optString("action").uppercase()) }
|
||||
.getOrElse { throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "步骤 $id 的 action 不受支持") }
|
||||
val packageName = item.optString("packageName").takeIf { it.isNotBlank() } ?: defaultPackage
|
||||
if (!allowPddActions && action !in setOf(RuleAction.WAIT, RuleAction.CLICK, RuleAction.BACK)) {
|
||||
throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "v2 导航步骤 $id 的 action 不受支持")
|
||||
}
|
||||
val packageName = item.stringOrNull("packageName") ?: defaultPackage
|
||||
if (packageName !in allowedPackages) {
|
||||
throw RuleValidationException("RULE_PACKAGE_NOT_ALLOWED", "步骤 $id 的应用包不在白名单")
|
||||
}
|
||||
val selectorJson = item.optJSONObject("selector")
|
||||
val selector = selectorJson?.let {
|
||||
NodeSelector(
|
||||
resourceId = it.stringOrNull("resourceId"),
|
||||
text = it.stringOrNull("text"),
|
||||
contentDescription = it.stringOrNull("contentDescription"),
|
||||
className = it.stringOrNull("className"),
|
||||
clickable = if (it.has("clickable")) it.getBoolean("clickable") else null,
|
||||
)
|
||||
}
|
||||
if (action != RuleAction.BACK && (selector == null || selector.isEmpty())) {
|
||||
throw RuleValidationException("RULE_INVALID", "步骤 $id 缺少 selector")
|
||||
val selector = parseSelector(item.optJSONObject("selector"))
|
||||
if (action != RuleAction.BACK && (selector == null || selector.isEmpty())) invalid("步骤 $id 缺少 selector")
|
||||
if (!allowPddActions && action == RuleAction.CLICK && packageName == PDD_PACKAGE) {
|
||||
throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "v2 导航步骤 $id 不能点击 PDD 页面")
|
||||
}
|
||||
if (action == RuleAction.CLICK && packageName != PDD_PACKAGE) {
|
||||
val target = selector?.text ?: selector?.contentDescription
|
||||
if (target !in setOf("打开拼多多APP", "打开拼多多 App", "打开")) {
|
||||
if (target !in allowedOpenTargets) {
|
||||
throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "步骤 $id 不是允许的打开拼多多动作")
|
||||
}
|
||||
}
|
||||
val value = item.stringOrNull("value")
|
||||
val field = item.stringOrNull("field")
|
||||
if (action == RuleAction.INPUT && value == null) throw RuleValidationException("RULE_INVALID", "输入步骤 $id 缺少 value")
|
||||
if (action == RuleAction.EXTRACT && field == null) throw RuleValidationException("RULE_INVALID", "采集步骤 $id 缺少 field")
|
||||
if (action == RuleAction.INPUT && value == null) invalid("输入步骤 $id 缺少 value")
|
||||
if (action == RuleAction.EXTRACT && field == null) invalid("采集步骤 $id 缺少 field")
|
||||
val safetyText = listOf(id, selector?.text, selector?.contentDescription, value, field)
|
||||
.filterNotNull().joinToString(" ").lowercase()
|
||||
if (forbiddenWords.any(safetyText::contains)) {
|
||||
throw RuleValidationException("FORBIDDEN_ACTION", "步骤 $id 涉及支付或下单操作")
|
||||
if (forbiddenCollectionWords.any(safetyText::contains)) {
|
||||
throw RuleValidationException("FORBIDDEN_ACTION", "采集步骤 $id 涉及创建订单或付款操作")
|
||||
}
|
||||
RuleStep(
|
||||
id = id,
|
||||
@@ -105,13 +178,90 @@ object RuleParser {
|
||||
selector = selector,
|
||||
value = value,
|
||||
field = field,
|
||||
timeoutMs = item.optLong("timeoutMs", 5_000).coerceIn(100, 30_000),
|
||||
timeoutMs = item.optLong("timeoutMs", 5_000).also {
|
||||
if (it !in 100..30_000) invalid("步骤 $id 的 timeoutMs 必须为 100..30000")
|
||||
},
|
||||
optional = item.optBoolean("optional", false),
|
||||
)
|
||||
}
|
||||
return CollectionRule(1, steps)
|
||||
}
|
||||
|
||||
private fun JSONObject.stringOrNull(name: String): String? =
|
||||
optString(name).trim().takeIf { it.isNotBlank() }
|
||||
private fun parseHooks(value: JSONObject?): Map<HookStage, List<HookAction>> {
|
||||
if (value == null) return emptyMap()
|
||||
rejectUnknown(value, setOf("afterSpecPanelOpen"), "hooks")
|
||||
val items = value.optJSONArray("afterSpecPanelOpen") ?: return emptyMap()
|
||||
if (items.length() > 8) invalid("单个 hook 最多 8 个动作")
|
||||
val actions = (0 until items.length()).map { index ->
|
||||
val item = items.optJSONObject(index) ?: invalid("hook[$index] 必须是对象")
|
||||
rejectUnknown(item, setOf("action", "target", "direction", "count", "settleMs"), "hook[$index]")
|
||||
if (item.optString("action") != "swipe" || item.optString("target") != "specPanel") {
|
||||
throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "hook 只允许对 specPanel 执行 swipe")
|
||||
}
|
||||
val direction = runCatching { SwipeDirection.valueOf(item.optString("direction").uppercase()) }
|
||||
.getOrElse { invalid("swipe.direction 不受支持") }
|
||||
val count = item.optInt("count", -1)
|
||||
val settleMs = item.optLong("settleMs", 0)
|
||||
if (count !in 1..5) invalid("swipe.count 必须为 1..5")
|
||||
if (settleMs !in 0..2_000) invalid("swipe.settleMs 必须为 0..2000")
|
||||
HookAction(HookActionType.SWIPE, SemanticTarget.SPEC_PANEL, direction, count, settleMs)
|
||||
}
|
||||
return if (actions.isEmpty()) emptyMap() else mapOf(HookStage.AFTER_SPEC_PANEL_OPEN to actions)
|
||||
}
|
||||
|
||||
private fun parseCollector(value: JSONObject): PddCollectorConfig {
|
||||
rejectUnknown(value, setOf("collectorId", "specEntryStrategy", "priceParser", "priceGranularity", "dimensionAliases", "timeoutsMs", "limits"), "collector")
|
||||
if (value.optString("collectorId") != "pddProductDetailV1" ||
|
||||
value.optString("specEntryStrategy") != "safeBottomSpecEntryV1" ||
|
||||
value.optString("priceParser") != "pddRmbPriceV1" ||
|
||||
value.optString("priceGranularity") != "color"
|
||||
) invalid("collector 使用了 Agent 不支持的类型化能力")
|
||||
val aliases = value.optJSONObject("dimensionAliases") ?: invalid("dimensionAliases 必填")
|
||||
rejectUnknown(aliases, setOf("color", "size"), "dimensionAliases")
|
||||
val colorAliases = parseAliases(aliases.optJSONArray("color"), "color")
|
||||
val sizeAliases = parseAliases(aliases.optJSONArray("size"), "size")
|
||||
val timeouts = parseBoundedMap(
|
||||
value.optJSONObject("timeoutsMs") ?: invalid("timeoutsMs 必填"),
|
||||
mapOf("page" to 100..60_000, "specPanel" to 100..30_000, "selection" to 100..10_000, "price" to 100..10_000, "overall" to 1_000..600_000),
|
||||
)
|
||||
val limits = parseBoundedMap(
|
||||
value.optJSONObject("limits") ?: invalid("limits 必填"),
|
||||
mapOf("goodsPageVerticalSwipes" to 0..10, "specHorizontalSwipes" to 0..30, "specVerticalSwipes" to 0..30, "stableEdgeReads" to 1..5, "stablePriceReads" to 2..5, "maxSkuCount" to 1..2_000),
|
||||
)
|
||||
return PddCollectorConfig("pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", colorAliases, sizeAliases, timeouts, limits)
|
||||
}
|
||||
|
||||
private fun parseAliases(items: JSONArray?, name: String): List<String> {
|
||||
items ?: invalid("dimensionAliases.$name 必填")
|
||||
if (items.length() !in 1..20) invalid("dimensionAliases.$name 必须包含 1..20 项")
|
||||
return (0 until items.length()).map { index ->
|
||||
items.optString(index).trim().also { if (it.isEmpty() || it.length > 30) invalid("dimensionAliases.$name 含无效别名") }
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
private fun parseBoundedMap(value: JSONObject, allowed: Map<String, IntRange>): Map<String, Int> {
|
||||
rejectUnknown(value, allowed.keys, "整数配置")
|
||||
if (value.length() != allowed.size) invalid("规则整数配置缺失")
|
||||
return allowed.mapValues { (key, range) ->
|
||||
value.optInt(key, Int.MIN_VALUE).also { if (it !in range) invalid("$key 必须为 ${range.first}..${range.last}") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSelector(value: JSONObject?): NodeSelector? = value?.let {
|
||||
rejectUnknown(it, setOf("resourceId", "text", "contentDescription", "className", "clickable"), "selector")
|
||||
NodeSelector(
|
||||
resourceId = it.stringOrNull("resourceId"),
|
||||
text = it.stringOrNull("text"),
|
||||
contentDescription = it.stringOrNull("contentDescription"),
|
||||
className = it.stringOrNull("className"),
|
||||
clickable = if (it.has("clickable")) it.getBoolean("clickable") else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rejectUnknown(value: JSONObject, allowed: Set<String>, label: String) {
|
||||
val unknown = value.keys().asSequence().filterNot(allowed::contains).toList()
|
||||
if (unknown.isNotEmpty()) invalid("$label 包含未知字段: ${unknown.joinToString()}")
|
||||
}
|
||||
|
||||
private fun JSONObject.stringOrNull(name: String): String? = optString(name).trim().takeIf { it.isNotBlank() }
|
||||
private fun invalid(message: String): Nothing = throw RuleValidationException("RULE_INVALID", message)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,24 @@ interface UiDriver {
|
||||
fun click(node: UiNodeRef): Boolean
|
||||
fun input(node: UiNodeRef, value: String): Boolean
|
||||
fun back(): Boolean
|
||||
fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean
|
||||
}
|
||||
|
||||
class HookExecutor(
|
||||
private val driver: UiDriver,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
fun execute(actions: List<HookAction>): RuleExecutionResult {
|
||||
actions.forEachIndexed { index, action ->
|
||||
repeat(action.count) {
|
||||
if (!driver.swipe(action.target, action.direction)) {
|
||||
return RuleExecutionResult(false, "RULE_ACTION_FAILED", "hook 动作 ${index + 1} 执行失败")
|
||||
}
|
||||
if (action.settleMs > 0) pause(action.settleMs)
|
||||
}
|
||||
}
|
||||
return RuleExecutionResult(true, "OK", "hook 执行完成")
|
||||
}
|
||||
}
|
||||
|
||||
data class RuleExecutionResult(
|
||||
|
||||
@@ -15,6 +15,7 @@ data class DeviceInfo(
|
||||
val androidVersion: String,
|
||||
val agentVersion: String,
|
||||
val pddVersion: String,
|
||||
val capabilities: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class RegistrationResult(
|
||||
@@ -60,6 +61,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
.put("androidVersion", info.androidVersion)
|
||||
.put("agentVersion", info.agentVersion)
|
||||
.put("pddVersion", info.pddVersion)
|
||||
.put("capabilities", JSONArray(info.capabilities))
|
||||
val data = post("/api/agent/v1/register", payload, token).getJSONObject("data")
|
||||
return RegistrationResult(
|
||||
deviceId = data.getLong("deviceId"),
|
||||
@@ -68,10 +70,11 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
)
|
||||
}
|
||||
|
||||
fun heartbeat(token: String, currentTaskId: Long?): HeartbeatResult {
|
||||
fun heartbeat(token: String, currentTaskId: Long?, capabilities: List<String> = emptyList()): HeartbeatResult {
|
||||
val payload = JSONObject()
|
||||
.put("requestId", UUID.randomUUID().toString())
|
||||
.put("currentTaskId", currentTaskId ?: JSONObject.NULL)
|
||||
.put("capabilities", JSONArray(capabilities))
|
||||
val data = post("/api/agent/v1/heartbeat", payload, token).getJSONObject("data")
|
||||
return HeartbeatResult(
|
||||
deviceId = data.getLong("deviceId"),
|
||||
|
||||
@@ -19,6 +19,7 @@ import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.MainActivity
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import cn.ilapage.goauto.agent.automation.CollectionAssembler
|
||||
import cn.ilapage.goauto.agent.automation.AgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
|
||||
import cn.ilapage.goauto.agent.automation.RuleExecutor
|
||||
@@ -115,7 +116,11 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
val activeCredentials = credentials ?: error("设备尚未取得认证凭据")
|
||||
val heartbeat = api.heartbeat(activeCredentials.token, currentTaskId = runningTaskId.get())
|
||||
val heartbeat = api.heartbeat(
|
||||
activeCredentials.token,
|
||||
currentTaskId = runningTaskId.get(),
|
||||
capabilities = AgentCapabilities.supported,
|
||||
)
|
||||
check(heartbeat.deviceId == activeCredentials.deviceId) { "心跳返回了不同的设备身份" }
|
||||
stateStore.update(
|
||||
code = if (heartbeat.busy) "BUSY" else "ONLINE",
|
||||
@@ -235,6 +240,7 @@ class AgentForegroundService : Service() {
|
||||
androidVersion = Build.VERSION.RELEASE.take(32),
|
||||
agentVersion = BuildConfig.VERSION_NAME.take(32),
|
||||
pddVersion = installedVersion("com.xunmeng.pinduoduo").take(32),
|
||||
capabilities = AgentCapabilities.supported,
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.HookExecutor
|
||||
import cn.ilapage.goauto.agent.automation.HookStage
|
||||
import cn.ilapage.goauto.agent.automation.RuleExecutor
|
||||
import cn.ilapage.goauto.agent.automation.RuleParser
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SemanticTarget
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiDriver
|
||||
import cn.ilapage.goauto.agent.automation.UiNodeRef
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -80,11 +84,44 @@ class RuleExecutorTest {
|
||||
assertEquals("RULE_NOT_MATCHED", result.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v2 hook expresses two bounded spec panel swipes`() {
|
||||
val rule = RuleParser.parse(validV2Rule())
|
||||
val driver = FakeDriver(emptyMap())
|
||||
|
||||
val result = HookExecutor(driver, pause = {}).execute(
|
||||
rule.hooks.getValue(HookStage.AFTER_SPEC_PANEL_OPEN),
|
||||
)
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v2 rejects unknown hook target and unbounded count`() {
|
||||
listOf(
|
||||
validV2Rule().replace("\"specPanel\"", "\"screen\""),
|
||||
validV2Rule().replace("\"count\":2", "\"count\":6"),
|
||||
).forEach { raw ->
|
||||
val error = runCatching { RuleParser.parse(raw) }.exceptionOrNull()
|
||||
assertTrue(error is RuleValidationException)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validV2Rule() = """{
|
||||
"schemaVersion":2,"ruleType":"pddProductDetail",
|
||||
"navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},
|
||||
"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content"}},
|
||||
"hooks":{"afterSpecPanelOpen":[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]},
|
||||
"collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color","dimensionAliases":{"color":["颜色"],"size":["尺码"]},"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}}
|
||||
}"""
|
||||
|
||||
private class FakeDriver(
|
||||
private val nodes: Map<String, List<UiNodeRef>>,
|
||||
private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||
) : UiDriver {
|
||||
var clickCount = 0
|
||||
var swipeCount = 0
|
||||
override fun currentPackage() = "com.xunmeng.pinduoduo"
|
||||
override fun currentActivity() = activity
|
||||
override fun visibleTexts() = emptyList<String>()
|
||||
@@ -92,5 +129,6 @@ class RuleExecutorTest {
|
||||
override fun click(node: UiNodeRef): Boolean { clickCount += 1; return true }
|
||||
override fun input(node: UiNodeRef, value: String) = true
|
||||
override fun back() = true
|
||||
override fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean { swipeCount += 1; return true }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user