diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f614156..5a884fb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt index d34613d..d8cee1e 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt @@ -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) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddShareLinkResolution.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddShareLinkResolution.kt new file mode 100644 index 0000000..0f8aef8 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddShareLinkResolution.kt @@ -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" + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index bb20b8d..19cef21 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -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, } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt index 885b06f..d1396cc 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt @@ -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 = "") + }).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() 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( diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index e206c24..7fb51df 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 21c7c51..7bd5af9 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 @@ -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 必须与任务一致。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index db9e719..84d2e34 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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 # 本地开发与验证 @@ -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 最新档案一致。 - 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。 - 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 5abd8c0..5b048f1 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -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 # 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 一致;完成、部分完成和失败继续按既有状态机释放设备槽。 diff --git a/server/app/goauto/task/current_page.go b/server/app/goauto/task/current_page.go index a5d6f49..88b7c5c 100644 --- a/server/app/goauto/task/current_page.go +++ b/server/app/goauto/task/current_page.go @@ -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)(?:^|[?&]|&|%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" +) diff --git a/server/app/goauto/task/current_page_test.go b/server/app/goauto/task/current_page_test.go index 7fdf35c..284b7cc 100644 --- a/server/app/goauto/task/current_page_test.go +++ b/server/app/goauto/task/current_page_test.go @@ -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(``)) + case request.URL.Path == "/missing": + _, _ = response.Write([]byte(`no product identity`)) + 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) + } + }) + } +}