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)
}