fix: expand all whitelisted PDD share links (#109)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+14
-10
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "<script>location='goods.html?goods_id=67890'</script>")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
<!-- 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.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 最新档案一致。
|
||||
- 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。
|
||||
- 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 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 一致;完成、部分完成和失败继续按既有状态机释放设备槽。
|
||||
|
||||
@@ -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, "无法识别商品链接")
|
||||
|
||||
@@ -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(`<html><script>location.href="goods.html?goods_id=67890"</script></html>`))
|
||||
_, _ = response.Write([]byte(`<html>refer_goods_id=11111&goods_id=67890</html>`))
|
||||
case request.URL.Path == "/missing":
|
||||
_, _ = response.Write([]byte(`<html>no product identity</html>`))
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user