fix: resolve PDD share links on Agent (#108)

This commit is contained in:
QiuSW
2026-08-27 15:04:19 +08:00
parent ea970c755e
commit 20775cc1bf
11 changed files with 408 additions and 58 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 17
versionName = "0.9.4"
versionCode = 18
versionName = "0.9.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -3,7 +3,6 @@ package cn.ilapage.goauto.agent.automation
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import java.net.URI
data class CurrentPageIdentityResult(
val successful: Boolean,
@@ -110,27 +109,12 @@ class PddForegroundRestorer(
}
}
object PddShareLinkExtractor {
private val urlPattern = Regex("https://[^\\s]+", RegexOption.IGNORE_CASE)
private val trailingPunctuation = charArrayOf(')', ']', '}', ')', '】', '」', '』', '》', ',', ',', '.', '。', ';', ';')
fun extract(raw: CharSequence?): String? {
val matches = urlPattern.findAll(raw?.toString().orEmpty()).map { it.value.trimEnd(*trailingPunctuation) }.toList()
val accepted = matches.filter { value ->
val uri = runCatching { URI(value) }.getOrNull() ?: return@filter false
uri.scheme.equals("https", ignoreCase = true) && uri.host?.lowercase() in ALLOWED_HOSTS && uri.userInfo == null && uri.port in setOf(-1, 443)
}.distinct()
return accepted.singleOrNull()
}
private val ALLOWED_HOSTS = setOf("p.pinduoduo.com", "mobile.yangkeduo.com")
}
class CurrentPageIdentityRunner(
private val driver: PddCollectorDriver,
private val readFreshClipboard: (copiedAtMillis: Long, timeoutMillis: Long) -> ClipboardReadOutcome,
private val taskId: Long = 0,
private val diagnostic: (AgentDiagnosticEvent) -> Unit = {},
private val expandShareLink: (String) -> String? = { PddShareLinkExpander().expand(it) },
private val now: () -> Long = System::currentTimeMillis,
private val pause: (Long) -> Unit = Thread::sleep,
) {
@@ -179,8 +163,31 @@ class CurrentPageIdentityRunner(
)
val raw = clipboard.text
?: return failure("PDD_CLIPBOARD_UNAVAILABLE", "无法读取刚复制的商品链接")
val shareUrl = PddShareLinkExtractor.extract(raw)
?: return failure("PDD_SHARE_LINK_INVALID", "无法识别商品链接")
val selection = PddShareLinkExtractor.select(raw)
val shareUrl = when (selection.status) {
PddShareLinkSelectionStatus.LONG_LINK -> {
diagnose(AgentDiagnosticStage.LINK_RESOLUTION, AgentDiagnosticReason.LONG_LINK_DIRECT, elapsedMs = now() - startedAt)
requireNotNull(selection.url)
}
PddShareLinkSelectionStatus.SHORT_LINK -> {
val shortUrl = requireNotNull(selection.url)
val expanded = expandShareLink(shortUrl)
diagnose(
AgentDiagnosticStage.LINK_RESOLUTION,
if (expanded != null) AgentDiagnosticReason.SHORT_LINK_EXPANDED else AgentDiagnosticReason.SHORT_LINK_FAILED,
elapsedMs = now() - startedAt,
)
expanded ?: shortUrl
}
PddShareLinkSelectionStatus.AMBIGUOUS -> {
diagnose(AgentDiagnosticStage.LINK_RESOLUTION, AgentDiagnosticReason.LINK_AMBIGUOUS, elapsedMs = now() - startedAt)
return failure("PDD_SHARE_LINK_INVALID", "商品链接不唯一")
}
PddShareLinkSelectionStatus.NOT_FOUND -> {
diagnose(AgentDiagnosticStage.LINK_RESOLUTION, AgentDiagnosticReason.LINK_NOT_FOUND, elapsedMs = now() - startedAt)
return failure("PDD_SHARE_LINK_INVALID", "无法识别商品链接")
}
}
return CurrentPageIdentityResult(true, "OK", "已识别当前商品", shareUrl)
} finally {
closeSharePanel(config.copyLinkAliases)
@@ -0,0 +1,150 @@
package cn.ilapage.goauto.agent.automation
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
enum class PddShareLinkSelectionStatus {
LONG_LINK,
SHORT_LINK,
NOT_FOUND,
AMBIGUOUS,
}
data class PddShareLinkSelection(
val status: PddShareLinkSelectionStatus,
val url: String? = null,
)
object PddShareLinkExtractor {
private val urlPattern = Regex("https://[A-Za-z0-9\\-._~:/?#\\[\\]@!$&'()*+,;=%]+", RegexOption.IGNORE_CASE)
private val goodsIdPattern = Regex("(?:^|&)goods_id=([0-9]{5,32})(?:&|$)", RegexOption.IGNORE_CASE)
fun extract(raw: CharSequence?): String? = select(raw).url
fun select(raw: CharSequence?): PddShareLinkSelection {
val accepted = urlPattern.findAll(raw?.toString().orEmpty())
.mapNotNull { match -> parseAllowed(match.value)?.let { match.value to it } }
.distinctBy { it.first }
.toList()
val longLinks = accepted.mapNotNull { (value, uri) -> goodsId(uri)?.let { Triple(value, uri, it) } }
val goodsIds = longLinks.map { it.third }.distinct()
if (goodsIds.size > 1) return PddShareLinkSelection(PddShareLinkSelectionStatus.AMBIGUOUS)
if (longLinks.isNotEmpty()) {
return PddShareLinkSelection(PddShareLinkSelectionStatus.LONG_LINK, longLinks.first().first)
}
val shortLinks = accepted.filter { (_, uri) -> uri.host.equals(SHORT_HOST, ignoreCase = true) }
.map { it.first }
.distinct()
return when (shortLinks.size) {
0 -> PddShareLinkSelection(PddShareLinkSelectionStatus.NOT_FOUND)
1 -> PddShareLinkSelection(PddShareLinkSelectionStatus.SHORT_LINK, shortLinks.single())
else -> PddShareLinkSelection(PddShareLinkSelectionStatus.AMBIGUOUS)
}
}
internal fun parseAllowed(raw: String): URI? {
val uri = runCatching { URI(raw) }.getOrNull() ?: return null
if (!uri.scheme.equals("https", ignoreCase = true) || uri.userInfo != null || uri.port !in setOf(-1, 443)) return null
if (uri.host?.lowercase() !in ALLOWED_HOSTS) return null
return uri
}
internal fun goodsId(uri: URI): String? = goodsIdPattern.find(uri.rawQuery.orEmpty())?.groupValues?.get(1)
internal fun canonical(goodsId: String): String = "https://mobile.yangkeduo.com/goods.html?goods_id=$goodsId"
private const val SHORT_HOST = "p.pinduoduo.com"
private val ALLOWED_HOSTS = setOf(SHORT_HOST, "mobile.yangkeduo.com")
}
data class PddShareHttpResponse(
val statusCode: Int,
val location: String? = null,
val body: String = "",
)
fun interface PddShareLinkFetcher {
fun fetch(url: String): PddShareHttpResponse
}
class PddShareLinkExpander(
private val fetcher: PddShareLinkFetcher = HttpPddShareLinkFetcher(),
) {
fun expand(shortUrl: String): String? {
var current = shortUrl
var redirects = 0
while (true) {
val parsed = PddShareLinkExtractor.parseAllowed(current) ?: return null
PddShareLinkExtractor.goodsId(parsed)?.let { return current }
if (!parsed.host.equals(SHORT_HOST, ignoreCase = true)) return null
val response = runCatching { fetcher.fetch(current) }.getOrNull() ?: return null
if (response.statusCode in 300..399) {
if (redirects >= MAX_REDIRECTS) return null
val location = response.location ?: return null
current = runCatching { parsed.resolve(location).toString() }.getOrNull() ?: return null
if (PddShareLinkExtractor.parseAllowed(current) == null) return null
redirects++
continue
}
if (response.statusCode !in 200..299) return null
val goodsIds = bodyGoodsIdPattern.findAll(response.body)
.map { match -> match.groupValues.drop(1).first(String::isNotEmpty) }
.distinct()
.toList()
return goodsIds.singleOrNull()?.let(PddShareLinkExtractor::canonical)
}
}
private companion object {
const val SHORT_HOST = "p.pinduoduo.com"
const val MAX_REDIRECTS = 4
val bodyGoodsIdPattern = Regex(
"(?:[?&]|&|%26|\\\\u0026)?goods_id(?:=|%3[dD]|[\\\"']?\\s*:\\s*[\\\"']?)([0-9]{5,32})|goods_id\\\\u003[dD]([0-9]{5,32})",
RegexOption.IGNORE_CASE,
)
}
}
private class HttpPddShareLinkFetcher : PddShareLinkFetcher {
override fun fetch(url: String): PddShareHttpResponse {
val connection = URL(url).openConnection() as HttpURLConnection
try {
connection.instanceFollowRedirects = false
connection.connectTimeout = TIMEOUT_MILLIS
connection.readTimeout = TIMEOUT_MILLIS
connection.requestMethod = "GET"
connection.setRequestProperty("User-Agent", MOBILE_USER_AGENT)
val status = connection.responseCode
val body = if (status in 200..299) {
connection.inputStream?.use(::readLimitedUtf8).orEmpty()
} else {
""
}
return PddShareHttpResponse(status, connection.getHeaderField("Location"), body)
} finally {
connection.disconnect()
}
}
private fun readLimitedUtf8(stream: java.io.InputStream): String {
val output = ByteArrayOutputStream()
val buffer = ByteArray(4_096)
var remaining = MAX_BODY_BYTES
while (remaining > 0) {
val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
if (read <= 0) break
output.write(buffer, 0, read)
remaining -= read
}
return output.toString(Charsets.UTF_8.name())
}
private companion object {
const val TIMEOUT_MILLIS = 5_000
const val MAX_BODY_BYTES = 64 * 1_024
const val MOBILE_USER_AGENT =
"Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36"
}
}
@@ -11,6 +11,7 @@ enum class AgentDiagnosticStage {
SHARE_CLICK,
SHARE_PANEL,
CLIPBOARD,
LINK_RESOLUTION,
}
enum class AgentDiagnosticReason {
@@ -26,6 +27,11 @@ enum class AgentDiagnosticReason {
PANEL_TIMEOUT,
CLIPBOARD_SUCCESS,
CLIPBOARD_TIMEOUT,
LONG_LINK_DIRECT,
SHORT_LINK_EXPANDED,
SHORT_LINK_FAILED,
LINK_AMBIGUOUS,
LINK_NOT_FOUND,
UNKNOWN,
}
@@ -16,7 +16,10 @@ import cn.ilapage.goauto.agent.automation.PddForegroundRestoreResult
import cn.ilapage.goauto.agent.automation.PddForegroundRestorer
import cn.ilapage.goauto.agent.automation.PddLaunchResult
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
import cn.ilapage.goauto.agent.automation.PddShareHttpResponse
import cn.ilapage.goauto.agent.automation.PddShareLinkExpander
import cn.ilapage.goauto.agent.automation.PddShareLinkExtractor
import cn.ilapage.goauto.agent.automation.PddShareLinkFetcher
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
@@ -132,21 +135,93 @@ class CurrentPageCollectionTest {
assertNull(PddShareLinkExtractor.extract("https://p.pinduoduo.com/a https://p.pinduoduo.com/b"))
}
@Test
fun stopsShareUrlBeforeAdjacentChineseText() {
assertEquals(
"https://p.pinduoduo.com/AbC123",
PddShareLinkExtractor.extract("复制成功https://p.pinduoduo.com/AbC123,快来拼单"),
)
}
@Test
fun prefersLongProductLinkAndRejectsConflictingGoodsIds() {
assertEquals(
"https://mobile.yangkeduo.com/goods.html?goods_id=12345",
PddShareLinkExtractor.extract(
"短链 https://p.pinduoduo.com/abc 长链 https://mobile.yangkeduo.com/goods.html?goods_id=12345",
),
)
assertNull(
PddShareLinkExtractor.extract(
"https://mobile.yangkeduo.com/goods.html?goods_id=12345 " +
"https://mobile.yangkeduo.com/goods.html?goods_id=67890",
),
)
}
@Test
fun expandsShortLinkThroughRedirectOrLimitedBody() {
val redirects = mapOf(
"https://p.pinduoduo.com/start" to PddShareHttpResponse(302, "/next"),
"https://p.pinduoduo.com/next" to PddShareHttpResponse(
302,
"https://mobile.yangkeduo.com/goods.html?goods_id=12345",
),
)
assertEquals(
"https://mobile.yangkeduo.com/goods.html?goods_id=12345",
PddShareLinkExpander(PddShareLinkFetcher { redirects.getValue(it) })
.expand("https://p.pinduoduo.com/start"),
)
assertEquals(
"https://mobile.yangkeduo.com/goods.html?goods_id=67890",
PddShareLinkExpander(PddShareLinkFetcher {
PddShareHttpResponse(200, body = "<script>location='goods.html?goods_id=67890'</script>")
}).expand("https://p.pinduoduo.com/body"),
)
}
@Test
fun rejectsUnsafeOrExcessiveShortLinkExpansion() {
assertNull(
PddShareLinkExpander(PddShareLinkFetcher {
PddShareHttpResponse(302, "https://example.com/goods.html?goods_id=12345")
}).expand("https://p.pinduoduo.com/external"),
)
var redirect = 0
assertNull(
PddShareLinkExpander(PddShareLinkFetcher {
redirect++
PddShareHttpResponse(302, "https://p.pinduoduo.com/hop$redirect")
}).expand("https://p.pinduoduo.com/start"),
)
assertEquals(5, redirect)
assertNull(
PddShareLinkExpander(PddShareLinkFetcher { throw java.net.SocketTimeoutException("timeout") })
.expand("https://p.pinduoduo.com/timeout"),
)
}
@Test
fun identifiesCurrentDetailThroughUniqueShareAndFreshClipboard() {
var currentTime = 0L
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeDriver(List(6) { detailSnapshot() } + sharePanelSnapshot())
val result = CurrentPageIdentityRunner(
driver = driver,
readFreshClipboard = { _, _ -> ClipboardReadOutcome("复制成功 https://p.pinduoduo.com/abc123") },
taskId = 71,
diagnostic = events::add,
expandShareLink = { "https://mobile.yangkeduo.com/goods.html?goods_id=12345" },
now = { currentTime },
pause = { currentTime += it },
).identify(rule())
assertTrue(result.successful)
assertEquals("https://p.pinduoduo.com/abc123", result.shareUrl)
assertEquals("https://mobile.yangkeduo.com/goods.html?goods_id=12345", result.shareUrl)
assertEquals(listOf("分享", "复制链接"), driver.clicked)
assertTrue(driver.backCalled)
assertTrue(events.any { it.reason == AgentDiagnosticReason.SHORT_LINK_EXPANDED })
}
@Test
@@ -180,9 +255,10 @@ class CurrentPageCollectionTest {
val result = CurrentPageIdentityRunner(
driver = driver,
readFreshClipboard = { _, _ -> ClipboardReadOutcome("https://p.pinduoduo.com/retry", pollCount = 2, mimeAccepted = true, itemCount = 1, contentLength = 36, timestampFresh = true) },
readFreshClipboard = { _, _ -> ClipboardReadOutcome("https://mobile.yangkeduo.com/goods.html?goods_id=12345", pollCount = 2, mimeAccepted = true, itemCount = 1, contentLength = 60, timestampFresh = true) },
taskId = 70,
diagnostic = events::add,
expandShareLink = { error("long links must not perform expansion") },
now = { currentTime },
pause = { currentTime += it },
).identify(rule())
@@ -191,6 +267,7 @@ class CurrentPageCollectionTest {
assertTrue(events.any { it.reason == AgentDiagnosticReason.TARGET_NOT_FOUND })
assertTrue(events.any { it.reason == AgentDiagnosticReason.CLICK_SUCCESS && it.clickableAncestorDepth == 1 })
assertTrue(events.any { it.reason == AgentDiagnosticReason.CLIPBOARD_SUCCESS && it.clipboardPollCount == 2 })
assertTrue(events.any { it.reason == AgentDiagnosticReason.LONG_LINK_DIRECT })
}
private class FakeDriver(
+4 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 7a1281d8873cc6afaf121c13c0562a3caf1d3eb7
synchronized_at: 2026-08-27T06:25:11Z
wiki_revision: 2f4a3a546d128f8a79ec1370fdacdc1b0e5b2e4f
synchronized_at: 2026-08-27T06:57:16Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -210,10 +210,10 @@ Android Portal/Agent
## Agent 当前页面临时采集(#101)
- 服务端入口位于 `server/app/goauto/task/current_page.go`:`CreateCurrentPage` 原子完成设备校验、任务创建、规则快照和租约;`IdentifyCurrentPage` 负责白名单短链解析、PDD 商品复用/创建和任务身份绑定。路由为 `/api/agent/v1/current-page-collection-tasks` 及其 `/{taskId}/identify`。
- 服务端入口位于 `server/app/goauto/task/current_page.go`:`CreateCurrentPage` 原子完成设备校验、任务创建、规则快照和租约;`IdentifyCurrentPage` 对 Agent 上报的白名单链接重新校验并最终裁决 goods_id,负责 PDD 商品复用/创建和任务身份绑定;服务端保留受限正文解析作为短链兜底。路由为 `/api/agent/v1/current-page-collection-tasks` 及其 `/{taskId}/identify`。
- 默认规则设置位于 `server/app/goauto/rule/agent_manual_setting.go`,使用单行表 `agent_manual_collection_setting`。迁移 `1787790000000_agent_current_page_collection.go` 增加任务来源、识别幂等字段、可空 PDD 外键和默认规则表,并对旧任务回填 `admin`。
- `collection_task.source` 目前只允许 `admin` / `agent_current_page`。当前页面任务创建时由设备运行槽防并发,识别商品后再参与 PDD 商品活动槽;结构化结果仍写入既有任务及规格子表,不新增临时任务表。
- Android 入口仍由 `TaskHistoryFragment` 承载;点击确认时先启动 `AgentForegroundService`,不先把 Agent 退到后台。服务先用 `TaskExecutionMutex` 预占本地串行槽,服务端创建成功后把预占转为真实 taskId;前台已是 PDD 时不执行返回,前台仍是 Agent 且近期见过 PDD 时只尝试一次返回,否则使用不清理任务栈、不带深链参数的 PDD 启动 Intent 恢复现有任务,并在有界等待确认 PDD 前台后才识别身份和调用既有 `PddProductDetailCollector`。
- `CurrentPageIdentityRunner` 负责页面证据、唯一分享/复制入口和面板清理;`ClipboardRelayActivity` 使用独立、不可导出的短生命周期任务在前台读取新鲜剪贴板,完成后移除中转任务并露出原 PDD 页面。原始剪贴板不进入日志、缓存或网络请求。
- `CurrentPageIdentityRunner` 负责页面证据、唯一分享/复制入口和面板清理;`ClipboardRelayActivity` 使用独立、不可导出的短生命周期任务在前台读取新鲜剪贴板,完成后移除中转任务并露出原 PDD 页面。`PddShareLinkExpander` 仅在手机侧用无 Cookie、无项目凭据的移动端 GET 有界展开白名单短链,逐跳校验并最多读取 64KB 正文;Agent 不裁决 goods_id。原始剪贴板、链接与响应正文不进入日志或缓存。
- v2 规则新增可选 `currentPageIdentity`(分享/复制别名和三个有界超时),旧 v2 规则由 Android 使用安全默认值;新设备能力为 `collector.pdd.current-page-share.v1`。
- 该路径复用既有结果/失败接口、PDD 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: fa2a6f6a41290095a2625771715ad28c1c6c6736
synchronized_at: 2026-08-27T06:25:24Z
wiki_revision: 2c2d84b24108217e4242337a6afc69db64ff924c
synchronized_at: 2026-08-27T06:57:31Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -278,7 +278,7 @@ synchronized_at: 2026-08-27T06:25:24Z
- 操作员先人工在 PDD App 打开并停留在目标商品详情页,再从 Agent 采集 Tab 点击“采集”;该入口采集当前页面,不是重置历史任务。
- Agent 在请求服务端前取得采集/采购共用的本地互斥锁。服务端原子校验设备在线、空闲、跨域活动任务、默认规则和能力,创建固定当前设备且已进入 `running` 的标准采集任务。
- Agent 启动当前页面采集前先保持自身在前台并启动既有前台服务。恢复时若 PDD 已在前台则不返回;若 Agent 仍在前台且近期见过 PDD,只尝试一次系统返回;仍未恢复时只使用 PDD 自身的不带参数启动 Intent 拉回现有任务,不附加清栈/重置标志。恢复后必须重新验证包名、精确 Activity 与页面强证据;不得启动浏览器、构造深链、自动搜索或自动选择相似商品。
- 恢复详情页后先按规则唯一点击“分享”与“复制链接”,只读取本次复制后的单条、有限长度剪贴板文本;Android 只保留唯一白名单 URL,服务端再校验短链并确认 goods_id。原始剪贴板、分享文案、控件树和截图都不保存。
- 恢复详情页后先按规则唯一点击“分享”与“复制链接”,只读取本次复制后的单条、有限长度剪贴板文本。Android 优先选择含 goods_id 的白名单长链;短链只在手机侧以无 Cookie、无项目凭据、逐跳白名单校验的有限请求展开,失败时可把原短链交给服务端兜底。服务端始终重新校验链接、裁决并规范化 goods_id;Agent 不建档、不猜测商品身份。原始剪贴板、分享文案、链接、响应正文、控件树和截图都不保存。
- goods_id 已存在时复用唯一 PDD 商品,成功或部分成功结果按既有最新档案覆盖规则更新;不存在时创建 PDD 商品。该流程不自动停用或替换旧商品,也不修改虾皮到 PDD 的关联。
- 管理员必须配置一条存活的 PDD 商品详情 v2 规则作为 Agent 手动采集默认规则;迁移只做一次安全初选,创建任务时仍实时验证规则与设备 `collector.pdd.current-page-share.v1` 能力。
- 当前页面任务身份确认前允许 `pdd_product_id` 为空、URL/goods_id 快照为空;普通管理端任务不放宽。身份确认后不可改成其他商品,结果 goods_id 必须与任务一致。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 3dc6c3774b31e28f6ad60f564d673a5cabdbb678
synchronized_at: 2026-08-27T06:25:36Z
wiki_revision: 3ba5cf24adcd9175b7dfee596760d10b8a4423fb
synchronized_at: 2026-08-27T06:57:44Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -250,12 +250,12 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
- 将范围设为非固定值并完成一次采集,记录状态页显示的实际剩余秒数;间隔期间重启 Agent 前台服务,确认按同一抽取结果恢复且没有重新随机。使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。
- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。
### Android Agent 0.9.4 当前页面临时采集检查(#101/#107)
### Android Agent 0.9.5 当前页面临时采集检查(#101/#107/#108)
- 自动化验证运行 `cd android && .\gradlew.bat testDebugUnitTest assembleDebug`,服务端运行 `cd server && go test ./...`;覆盖本地串行预占转移、分享 URL 白名单、唯一分享/复制入口、默认规则、创建/识别幂等、身份冲突和结果写回。
- 本地 MySQL 8.4 必须先在明确授权后运行 `cd server && go run . migrate -c config/settings.yml`,确认迁移版本 `1787790000000` 已应用;重复执行应报告 0 个新增迁移。迁移保留旧任务并回填来源 `admin`。
- 真机覆盖安装前确认设备空闲、Agent 0.9.4 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。
- 真机覆盖安装前确认设备空闲、Agent 0.9.5 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。
- 在 PDD 人工打开已授权的测试商品详情页,分别经 PDD 直接切换、桌面图标和最近任务进入 Agent,点击“采集”并确认;验证前台服务先启动,PDD 现有任务被拉回前台且仍停在原商品详情页,没有清栈、重置首页或启动浏览器,再依次完成分享、复制链接、goods_id 识别和常规采集。另验证 PDD 未安装、启动 Intent 缺失、前台切换超时和前台服务启动被拒绝时均有可见反馈。
- 分别验证直链和 PDD 短链;成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。
- 分别验证含 goods_id 的直链和 `p.pinduoduo.com` 短链:直链不应触发 Agent 额外网络展开;短链应优先由手机侧在 4 跳、每次连接/读取 5 秒、64KB 正文上限内展开。另覆盖链接后紧跟中文、多个链接冲突、白名单外跳转、超时和服务端短链兜底;诊断、日志和数据库不得出现链接原文、goods_id、剪贴板或响应正文。成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。
- 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。
- 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 79f7b1e9e9058db1e9453db6f74dbe7219ffb16f
synchronized_at: 2026-08-27T02:39:41Z
wiki_revision: 7645ee794b5b45f40c4cd87b00ab0e4d5ae7b7be
synchronized_at: 2026-08-27T06:58:43Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -642,7 +642,7 @@ Content-Type: application/json
```
- Android 只上传从本次新鲜剪贴板内容中唯一提取出的分享 URL,不上传完整分享文案。
- 服务端仅接受 HTTPS,主机限定 `p.pinduoduo.com` 与 `mobile.yangkeduo.com`,端口只能省略或为 443;短链最多跟随 4 次重定向,每跳重新校验白名单,总超时 10 秒。
- Agent 从剪贴板文本提取唯一白名单链接:含 goods_id 的长链直接提交;`p.pinduoduo.com` 短链优先在手机侧用无 Cookie、无项目凭据的移动端 GET 展开,最多跟随 4 次跳转、每跳校验 HTTPS/主机/端口/userinfo,连接与读取各超时 5 秒,正文最多读取 64KB。展开失败可提交原短链,由服务端以相同白名单、最多 4 次重定向和 10 秒总超时兜底;请求字段仍只传 `shareUrl`。
- 服务端提取 5~32 位纯数字 `goods_id`,形成标准 URL,并在事务中创建或复用唯一 PDD 商品;同商品已有活动采集任务或身份冲突时拒绝。
- 相同识别 `requestId` 直接返回已确认身份且不再次访问短链;任务首次确认身份后,不允许不同请求覆盖为其他商品。
- 识别后继续复用 `POST /api/agent/v1/tasks/{taskId}/result` 与 `/fail`。结果接口要求任务已绑定身份且结果 goods_id 一致;完成、部分完成和失败继续按既有状态机释放设备槽。
+63 -20
View File
@@ -257,9 +257,19 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
return response, err
}
var pddGoodsIDPattern = regexp.MustCompile(`^[0-9]{5,32}$`)
var (
pddGoodsIDPattern = regexp.MustCompile(`^[0-9]{5,32}$`)
pddBodyGoodsIDPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)(?:^|[?&]|&amp;|%26|\\u0026)goods_id(?:=|%3d|\\u003d)([0-9]{5,32})`),
regexp.MustCompile(`(?i)["']goods_id["']\s*:\s*["']?([0-9]{5,32})`),
}
)
func ResolvePDDShareURL(ctx context.Context, raw string) (ResolvedPDDShare, error) {
return resolvePDDShareURL(ctx, raw, newPDDShareHTTPClient(nil))
}
func resolvePDDShareURL(ctx context.Context, raw string, client *http.Client) (ResolvedPDDShare, error) {
parsed, err := validatePDDShareURL(raw)
if err != nil {
return ResolvedPDDShare{}, err
@@ -270,35 +280,63 @@ func ResolvePDDShareURL(ctx context.Context, raw string) (ResolvedPDDShare, erro
if !strings.EqualFold(parsed.Hostname(), "p.pinduoduo.com") {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
client := &http.Client{
Timeout: 10 * time.Second,
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
request.Header.Set("User-Agent", pddMobileUserAgent)
response, err := client.Do(request)
if err != nil {
return ResolvedPDDShare{}, &ServiceError{Code: CodePDDShareLinkInvalid, Message: "商品链接解析失败,请检查网络后重试", Retryable: true, Cause: err}
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 400 {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
body, err := io.ReadAll(io.LimitReader(response.Body, pddShareBodyLimit))
if err != nil {
return ResolvedPDDShare{}, &ServiceError{Code: CodePDDShareLinkInvalid, Message: "商品链接解析失败,请检查网络后重试", Retryable: true, Cause: err}
}
finalURL, err := validatePDDShareURL(response.Request.URL.String())
if err != nil {
return ResolvedPDDShare{}, err
}
if goodsID := finalURL.Query().Get("goods_id"); goodsID != "" {
return resolvedPDDShare(goodsID)
}
return resolvedPDDShare(extractPDDGoodsIDFromBody(body))
}
func newPDDShareHTTPClient(transport http.RoundTripper) *http.Client {
return &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 4 {
if len(via) > 4 {
return errors.New("too many redirects")
}
_, err := validatePDDShareURL(request.URL.String())
return err
},
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
func extractPDDGoodsIDFromBody(body []byte) string {
values := make(map[string]struct{})
for _, pattern := range pddBodyGoodsIDPatterns {
for _, match := range pattern.FindAllSubmatch(body, -1) {
if len(match) > 1 {
values[string(match[1])] = struct{}{}
}
}
}
request.Header.Set("User-Agent", "GoAuto-Agent-Link-Resolver/1.0")
response, err := client.Do(request)
if err != nil {
return ResolvedPDDShare{}, &ServiceError{Code: CodePDDShareLinkInvalid, Message: "商品链接解析失败,请检查网络后重试", Retryable: true, Cause: err}
if len(values) != 1 {
return ""
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
if response.StatusCode < 200 || response.StatusCode >= 400 {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
for value := range values {
return value
}
finalURL, err := validatePDDShareURL(response.Request.URL.String())
if err != nil {
return ResolvedPDDShare{}, err
}
return resolvedPDDShare(finalURL.Query().Get("goods_id"))
return ""
}
func validatePDDShareURL(raw string) (*url.URL, error) {
@@ -322,3 +360,8 @@ func resolvedPDDShare(goodsID string) (ResolvedPDDShare, error) {
}
return ResolvedPDDShare{GoodsID: goodsID, URL: fmt.Sprintf("https://mobile.yangkeduo.com/goods.html?goods_id=%s", goodsID)}, nil
}
const (
pddShareBodyLimit = 64 << 10
pddMobileUserAgent = "Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36"
)
@@ -2,6 +2,12 @@ package task
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"go-admin/app/goauto/models"
@@ -145,3 +151,64 @@ func TestResolvePDDShareURLRejectsUnsafeOrAmbiguousIdentity(t *testing.T) {
}
}
}
func TestResolvePDDShareURLHTTPFallback(t *testing.T) {
handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
switch {
case request.URL.Path == "/redirect":
http.Redirect(response, request, "https://mobile.yangkeduo.com/goods.html?goods_id=12345", http.StatusFound)
case request.URL.Path == "/body":
_, _ = response.Write([]byte(`<html><script>location.href="goods.html?goods_id=67890"</script></html>`))
case request.URL.Path == "/missing":
_, _ = response.Write([]byte(`<html>no product identity</html>`))
case request.URL.Path == "/external":
http.Redirect(response, request, "https://example.com/goods.html?goods_id=12345", http.StatusFound)
case strings.HasPrefix(request.URL.Path, "/hop/"):
current, _ := strconv.Atoi(strings.TrimPrefix(request.URL.Path, "/hop/"))
http.Redirect(response, request, fmt.Sprintf("https://p.pinduoduo.com/hop/%d", current+1), http.StatusFound)
default:
response.WriteHeader(http.StatusOK)
}
})
server := httptest.NewTLSServer(handler)
defer server.Close()
transport := server.Client().Transport.(*http.Transport).Clone()
transport.TLSClientConfig = transport.TLSClientConfig.Clone()
transport.TLSClientConfig.InsecureSkipVerify = true // Test server certificate cannot contain the production PDD hosts.
targetAddress := server.Listener.Addr().String()
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, targetAddress)
}
client := newPDDShareHTTPClient(transport)
for _, test := range []struct {
name string
path string
goodsID string
}{
{name: "302 to long link", path: "/redirect", goodsID: "12345"},
{name: "200 body goods id", path: "/body", goodsID: "67890"},
} {
t.Run(test.name, func(t *testing.T) {
resolved, err := resolvePDDShareURL(context.Background(), "https://p.pinduoduo.com"+test.path, client)
if err != nil || resolved.GoodsID != test.goodsID || resolved.URL != "https://mobile.yangkeduo.com/goods.html?goods_id="+test.goodsID {
t.Fatalf("unexpected resolution: result=%+v error=%v", resolved, err)
}
})
}
for _, test := range []struct {
name string
path string
}{
{name: "200 without goods id", path: "/missing"},
{name: "redirect outside whitelist", path: "/external"},
{name: "more than four redirects", path: "/hop/0"},
} {
t.Run(test.name, func(t *testing.T) {
_, err := resolvePDDShareURL(context.Background(), "https://p.pinduoduo.com"+test.path, client)
if taskErrorCode(t, err) != CodePDDShareLinkInvalid {
t.Fatalf("expected invalid link error, got %v", err)
}
})
}
}