From daf7406ec97262d12a501d85b1245395847a8584 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 15:32:40 +0800 Subject: [PATCH] fix: expand all whitelisted PDD share links (#109) --- android/app/build.gradle.kts | 4 +-- .../automation/PddShareLinkResolution.kt | 24 ++++++++++------- .../goauto/agent/CurrentPageCollectionTest.kt | 26 ++++++++++++++++++- docs/04-local-development-and-verification.md | 10 +++---- docs/08-agent-api-contract.md | 6 ++--- server/app/goauto/task/current_page.go | 3 --- server/app/goauto/task/current_page_test.go | 17 ++++++++---- 7 files changed, 61 insertions(+), 29 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5a884fb..27035da 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 = 18 - versionName = "0.9.5" + versionCode = 19 + versionName = "0.9.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 index 0f8aef8..aa3720e 100644 --- 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 @@ -34,7 +34,7 @@ object PddShareLinkExtractor { if (longLinks.isNotEmpty()) { return PddShareLinkSelection(PddShareLinkSelectionStatus.LONG_LINK, longLinks.first().first) } - val shortLinks = accepted.filter { (_, uri) -> uri.host.equals(SHORT_HOST, ignoreCase = true) } + val shortLinks = accepted.filter { (_, uri) -> goodsId(uri) == null } .map { it.first } .distinct() return when (shortLinks.size) { @@ -55,8 +55,7 @@ object PddShareLinkExtractor { 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") + private val ALLOWED_HOSTS = setOf("p.pinduoduo.com", "mobile.yangkeduo.com") } data class PddShareHttpResponse( @@ -78,7 +77,6 @@ class PddShareLinkExpander( 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 @@ -89,8 +87,9 @@ class PddShareLinkExpander( continue } if (response.statusCode !in 200..299) return null - val goodsIds = bodyGoodsIdPattern.findAll(response.body) - .map { match -> match.groupValues.drop(1).first(String::isNotEmpty) } + val goodsIds = bodyGoodsIdPatterns.asSequence() + .flatMap { pattern -> pattern.findAll(response.body) } + .map { match -> match.groupValues[1] } .distinct() .toList() return goodsIds.singleOrNull()?.let(PddShareLinkExtractor::canonical) @@ -98,11 +97,16 @@ class PddShareLinkExpander( } 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, + val bodyGoodsIdPatterns = listOf( + Regex( + "(?:^|[?&]|&|%26|\\\\u0026)goods_id(?:=|%3[dD]|\\\\u003[dD])([0-9]{5,32})", + RegexOption.IGNORE_CASE, + ), + Regex( + "[\\\"']goods_id[\\\"']\\s*:\\s*[\\\"']?([0-9]{5,32})", + RegexOption.IGNORE_CASE, + ), ) } } 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 d1396cc..5addf06 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 @@ -20,6 +20,7 @@ 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.PddShareLinkSelectionStatus import cn.ilapage.goauto.agent.automation.SnapshotNode import cn.ilapage.goauto.agent.automation.SwipeDirection import cn.ilapage.goauto.agent.automation.UiSnapshot @@ -143,6 +144,15 @@ class CurrentPageCollectionTest { ) } + @Test + fun selectsWhitelistedYangkeduoLinkWithoutGoodsIdForExpansion() { + val raw = "https://mobile.yangkeduo.com/goods2.html?ps=14mchKPjYR" + val selection = PddShareLinkExtractor.select(raw) + + assertEquals(PddShareLinkSelectionStatus.SHORT_LINK, selection.status) + assertEquals(raw, selection.url) + } + @Test fun prefersLongProductLinkAndRejectsConflictingGoodsIds() { assertEquals( @@ -176,9 +186,23 @@ class CurrentPageCollectionTest { assertEquals( "https://mobile.yangkeduo.com/goods.html?goods_id=67890", PddShareLinkExpander(PddShareLinkFetcher { - PddShareHttpResponse(200, body = "") + PddShareHttpResponse(200, body = "refer_goods_id=11111&goods_id=67890") }).expand("https://p.pinduoduo.com/body"), ) + assertEquals( + "https://mobile.yangkeduo.com/goods2.html?goods_id=971612375909&page_from=35", + PddShareLinkExpander(PddShareLinkFetcher { + PddShareHttpResponse( + 307, + "https://mobile.yangkeduo.com/goods2.html?goods_id=971612375909&page_from=35", + ) + }).expand("https://mobile.yangkeduo.com/goods2.html?ps=14mchKPjYR"), + ) + assertNull( + PddShareLinkExpander(PddShareLinkFetcher { + PddShareHttpResponse(200, body = "refer_goods_id=12345") + }).expand("https://mobile.yangkeduo.com/goods2.html?ps=only-refer"), + ) } @Test diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 84d2e34..3afcf15 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: 3ba5cf24adcd9175b7dfee596760d10b8a4423fb -synchronized_at: 2026-08-27T06:57:44Z +wiki_revision: 6362cbe25f4a0a1826b903ee4342c1a69ee87a42 +synchronized_at: 2026-08-27T07:26:49Z # 本地开发与验证 @@ -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.5 当前页面临时采集检查(#101/#107/#108) +### Android Agent 0.9.6 当前页面临时采集检查(#101/#107/#108/#109) - 自动化验证运行 `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.5 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。 +- 真机覆盖安装前确认设备空闲、Agent 0.9.6 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。 - 在 PDD 人工打开已授权的测试商品详情页,分别经 PDD 直接切换、桌面图标和最近任务进入 Agent,点击“采集”并确认;验证前台服务先启动,PDD 现有任务被拉回前台且仍停在原商品详情页,没有清栈、重置首页或启动浏览器,再依次完成分享、复制链接、goods_id 识别和常规采集。另验证 PDD 未安装、启动 Intent 缺失、前台切换超时和前台服务启动被拒绝时均有可见反馈。 -- 分别验证含 goods_id 的直链和 `p.pinduoduo.com` 短链:直链不应触发 Agent 额外网络展开;短链应优先由手机侧在 4 跳、每次连接/读取 5 秒、64KB 正文上限内展开。另覆盖链接后紧跟中文、多个链接冲突、白名单外跳转、超时和服务端短链兜底;诊断、日志和数据库不得出现链接原文、goods_id、剪贴板或响应正文。成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。 +- 分别验证含 goods_id 的直链,以及白名单内无 goods_id 的 `p.pinduoduo.com` 短链和 `mobile.yangkeduo.com/goods2.html?ps=...`:直链不应触发 Agent 额外网络展开;无 goods_id 的白名单链接应优先由手机侧在 4 跳、每次连接/读取 5 秒、64KB 正文上限内展开,并覆盖 302/307。正文只含 `refer_goods_id` 时不得误判为商品身份。另覆盖链接后紧跟中文、多个链接冲突、白名单外跳转、超时和服务端兜底;诊断、日志和数据库不得出现链接原文、goods_id、剪贴板或响应正文。成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。 - 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。 - 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 5b048f1..96a7495 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: 7645ee794b5b45f40c4cd87b00ab0e4d5ae7b7be -synchronized_at: 2026-08-27T06:58:43Z +wiki_revision: b722977b0c8b7db2ce0d3c8c2a1351f42941cb24 +synchronized_at: 2026-08-27T07:27:38Z # MVP 共享 API 契约 @@ -642,7 +642,7 @@ Content-Type: application/json ``` - Android 只上传从本次新鲜剪贴板内容中唯一提取出的分享 URL,不上传完整分享文案。 -- Agent 从剪贴板文本提取唯一白名单链接:含 goods_id 的长链直接提交;`p.pinduoduo.com` 短链优先在手机侧用无 Cookie、无项目凭据的移动端 GET 展开,最多跟随 4 次跳转、每跳校验 HTTPS/主机/端口/userinfo,连接与读取各超时 5 秒,正文最多读取 64KB。展开失败可提交原短链,由服务端以相同白名单、最多 4 次重定向和 10 秒总超时兜底;请求字段仍只传 `shareUrl`。 +- Agent 从剪贴板文本提取唯一白名单链接:含 goods_id 的链接直接提交;白名单内但 URL 本身无 goods_id 的链接(包括 `p.pinduoduo.com` 短链和 `mobile.yangkeduo.com/goods2.html?ps=...`)优先在手机侧用无 Cookie、无项目凭据的移动端 GET 展开,最多跟随 4 次跳转、每跳校验 HTTPS/主机/端口/userinfo,连接与读取各超时 5 秒,正文最多读取 64KB。URL 或正文只接受边界明确的 `goods_id`,不得把 `refer_goods_id` 等相近字段当作商品身份。展开失败可提交原链接,由服务端以相同白名单、最多 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 88b7c5c..a818047 100644 --- a/server/app/goauto/task/current_page.go +++ b/server/app/goauto/task/current_page.go @@ -277,9 +277,6 @@ func resolvePDDShareURL(ctx context.Context, raw string, client *http.Client) (R if goodsID := parsed.Query().Get("goods_id"); goodsID != "" { return resolvedPDDShare(goodsID) } - if !strings.EqualFold(parsed.Hostname(), "p.pinduoduo.com") { - return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接") - } request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) if err != nil { return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接") diff --git a/server/app/goauto/task/current_page_test.go b/server/app/goauto/task/current_page_test.go index 284b7cc..68ebb9d 100644 --- a/server/app/goauto/task/current_page_test.go +++ b/server/app/goauto/task/current_page_test.go @@ -157,8 +157,14 @@ func TestResolvePDDShareURLHTTPFallback(t *testing.T) { 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 == "/goods2.html": + if request.URL.Query().Get("goods_id") == "" { + http.Redirect(response, request, "https://mobile.yangkeduo.com/goods2.html?goods_id=971612375909&page_from=35", http.StatusTemporaryRedirect) + return + } + response.WriteHeader(http.StatusOK) case request.URL.Path == "/body": - _, _ = response.Write([]byte(``)) + _, _ = response.Write([]byte(`refer_goods_id=11111&goods_id=67890`)) case request.URL.Path == "/missing": _, _ = response.Write([]byte(`no product identity`)) case request.URL.Path == "/external": @@ -183,14 +189,15 @@ func TestResolvePDDShareURLHTTPFallback(t *testing.T) { for _, test := range []struct { name string - path string + raw string goodsID string }{ - {name: "302 to long link", path: "/redirect", goodsID: "12345"}, - {name: "200 body goods id", path: "/body", goodsID: "67890"}, + {name: "302 to long link", raw: "https://p.pinduoduo.com/redirect", goodsID: "12345"}, + {name: "307 yangkeduo ps link", raw: "https://mobile.yangkeduo.com/goods2.html?ps=14mchKPjYR", goodsID: "971612375909"}, + {name: "200 body ignores refer goods id", raw: "https://p.pinduoduo.com/body", goodsID: "67890"}, } { t.Run(test.name, func(t *testing.T) { - resolved, err := resolvePDDShareURL(context.Background(), "https://p.pinduoduo.com"+test.path, client) + resolved, err := resolvePDDShareURL(context.Background(), test.raw, 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) }