feat: add versioned rule capabilities (#22)

This commit is contained in:
QiuSW
2026-08-15 15:01:19 +08:00
parent 4b38a876de
commit 9d4a0b9845
28 changed files with 1005 additions and 85 deletions
@@ -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 }
}
}