feat: 单个商品以图搜同款 (#12)

由 Codex (gpt-5.6-sol) 实施,Claude 审核。R3a,先验证签名算法是否仍有效。

签名与参考实现字节级一致(审核时用固定随机值和时间戳对拍):

  pcSign      44 字节    一致
  params    1151 字节    一致
  requestText 1390 字节  一致
  MTOP sign   32 字节    一致
  token 提取             一致

Go 特有的陷阱已避开:签名是对 JSON 字符串做哈希,键顺序会改变结果。
Python 的 dict 按插入顺序序列化,而 Go 的 encoding/json 对 map 按键名
字母序排序,用 map 会产出完全不同的 JSON 导致签名必错,且淘宝只返回
含糊的业务错误码,极难定位。实现用 struct 按声明顺序输出,并用
json.Encoder + SetEscapeHTML(false) 对齐 Python 的 ensure_ascii=False。
四个价格字段用空指针输出真正的 null。有专门测试断言键顺序与字节,
防止后人改回 map。

internal/taobao/cdp.go 新增 CookieValue,仅用于读取 _m_h5_tk 计算签名。
AGENTS.md 明确允许该用途;全项目仅 imagesearch.go 调用一次,用完即弃,
不落库、不写日志、不返回前端。

以图搜前先做登录深度检查,未通过即停止,不发起 MTOP 请求,
避免抬高风控概率。

前端:表格操作列新增「搜同款」,结果以弹窗展示缩略图、标题、价格、
店铺、销量,点标题用系统浏览器打开淘宝商品页。

本工单不含批量、队列、并发与落库,那些属于 R3b。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
This commit is contained in:
QiuSW
2026-09-03 09:39:58 +08:00
co-authored by Claude Opus 5
parent e7c3c6849c
commit e8db874c04
7 changed files with 826 additions and 2 deletions
+56
View File
@@ -280,6 +280,62 @@ func (a *App) CountProducts() (int, error) {
return a.db.CountProducts()
}
// SearchTaobaoByProduct 用一个本地商品的主图执行一次淘宝以图搜。
// 结果只返回前端展示,不写入 SQLite,也不创建批量任务。
func (a *App) SearchTaobaoByProduct(productID string) ([]taobao.SimilarItem, error) {
if a.db == nil {
return nil, fmt.Errorf("数据库未就绪,请查看运行日志")
}
product, found, err := a.db.GetProduct(strings.TrimSpace(productID))
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("找不到商品:%s", productID)
}
if strings.TrimSpace(product.MainImage) == "" {
return nil, fmt.Errorf("商品没有可用于搜索的主图")
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
state, err := taobao.EnsureBrowser(ctx, a.cfg.Taobao, "https://www.taobao.com/")
if err != nil {
a.log.Error("准备淘宝专属 Chrome 失败:%v", err)
return nil, err
}
cdp, err := taobao.Connect(state.Port)
if err != nil {
a.log.Error("连接淘宝专属 Chrome 失败:%v", err)
return nil, err
}
defer cdp.Close()
status, err := taobao.CheckLogin(cdp)
if err != nil {
status.Message = err.Error()
a.emitTaobaoStatus(status)
a.log.Error("以图搜前检查淘宝登录状态失败:%v", err)
return nil, err
}
a.emitTaobaoStatus(status)
if !status.Valid {
a.log.Warn("淘宝登录检查未通过,已停止以图搜:%s", status.Message)
return nil, fmt.Errorf("淘宝登录无效:%s", status.Message)
}
a.log.Info("开始为商品 %s 搜索淘宝同款", product.ID)
items, err := taobao.SearchByImage(ctx, cdp, product.MainImage)
if err != nil {
a.log.Error("商品 %s 搜同款失败:%v", product.ID, err)
return nil, err
}
a.log.Success("商品 %s 搜同款完成,共 %d 条结果", product.ID, len(items))
return items, nil
}
// OpenInBrowser 用系统默认浏览器打开一个网址。
//
// 只允许 http/https,避免以后有人把本地文件路径或自定义协议传进来
+176 -2
View File
@@ -23,7 +23,7 @@
* 这是预期行为,不要加自动清理。
*/
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
import { useDialog, useMessage } from 'naive-ui'
import { NButton, useDialog, useMessage } from 'naive-ui'
import { EventsOff, EventsOn } from '../../wailsjs/runtime/runtime'
import {
DownloadProductData,
@@ -33,6 +33,7 @@ import {
OpenInBrowser,
DownloadVideos,
ListProducts,
SearchTaobaoByProduct,
UploadVideos,
} from '../../wailsjs/go/main/App'
@@ -198,6 +199,11 @@ const rows = ref([])
const total = ref(0)
const loading = ref(false)
const checkedIds = ref([])
const searchingProductID = ref('')
const similarModalVisible = ref(false)
const similarItems = ref([])
const similarSourceTitle = ref('')
const similarError = ref('')
const columns = [
{ type: 'selection' },
@@ -241,7 +247,7 @@ const columns = [
{
title: '操作',
key: 'actions',
width: 130,
width: 220,
render: (row) => {
const url = shopeeURL(row)
return h('div', { class: 'ops' }, [
@@ -266,6 +272,17 @@ const columns = [
},
'目录'
),
h(
NButton,
{
text: true,
type: 'primary',
loading: searchingProductID.value === row.id,
disabled: searchingProductID.value !== '' && searchingProductID.value !== row.id,
onClick: () => searchSimilar(row),
},
{ default: () => '搜同款' }
),
])
},
},
@@ -318,6 +335,31 @@ async function openShopee(row) {
}
}
async function searchSimilar(row) {
if (searchingProductID.value) return
searchingProductID.value = row.id
similarSourceTitle.value = row.itemName || row.itemId || '当前商品'
similarItems.value = []
similarError.value = ''
similarModalVisible.value = true
try {
similarItems.value = (await SearchTaobaoByProduct(row.id)) || []
} catch (err) {
similarError.value = String(err)
} finally {
searchingProductID.value = ''
}
}
async function openTaobaoItem(item) {
if (!item.url) return
try {
await OpenInBrowser(item.url)
} catch (err) {
message.error(`打开淘宝商品页失败:${err}`)
}
}
function renderStatus(value, dict) {
const [text, tone] = dict[value] || [value || '—', 'faint']
return h('span', { class: `st ${tone}` }, text)
@@ -490,6 +532,47 @@ onUnmounted(() => {
@update:page="search(false)"
/>
</div>
<n-modal v-model:show="similarModalVisible" preset="card" class="similar-modal">
<template #header>
<div class="similar-heading">
<span>淘宝同款</span>
<span class="similar-source" :title="similarSourceTitle">{{ similarSourceTitle }}</span>
</div>
</template>
<n-spin :show="searchingProductID !== ''">
<div v-if="searchingProductID" class="similar-state" aria-live="polite">
正在准备图片并搜索,通常需要十几秒…
</div>
<n-alert v-else-if="similarError" type="error" title="搜索失败">
{{ similarError }}
</n-alert>
<n-empty v-else-if="similarItems.length === 0" description="没有找到淘宝同款" />
<div v-else class="similar-list">
<article v-for="item in similarItems" :key="item.itemId" class="similar-item">
<img
v-if="item.image"
:src="item.image"
class="similar-thumb"
:alt="item.title || '淘宝商品图片'"
loading="lazy"
/>
<div v-else class="similar-thumb similar-thumb-empty">暂无图片</div>
<div class="similar-info">
<button class="similar-title" type="button" @click="openTaobaoItem(item)">
{{ item.title || `商品 ${item.itemId}` }}
</button>
<div class="similar-meta">
<span class="similar-price">{{ item.price ? `¥ ${item.price}` : '价格未知' }}</span>
<span>{{ item.shop || '店铺未知' }}</span>
<span>{{ item.sales || '销量未知' }}</span>
<span v-if="item.region">{{ item.region }}</span>
</div>
</div>
</article>
</div>
</n-spin>
</n-modal>
</div>
</template>
@@ -608,4 +691,95 @@ onUnmounted(() => {
font-size: 9px;
color: var(--faint);
}
:deep(.similar-modal) {
width: min(760px, calc(100vw - 48px));
max-height: calc(100vh - 64px);
}
:deep(.similar-modal .n-card__content) {
overflow-y: auto;
}
.similar-heading {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.similar-source {
color: var(--muted);
font-size: 12px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.similar-state {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.similar-list {
display: grid;
gap: 12px;
}
.similar-item {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
}
.similar-thumb {
width: 80px;
height: 80px;
flex: 0 0 80px;
object-fit: cover;
border-radius: 4px;
background: #e9eaee;
}
.similar-thumb-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--faint);
font-size: 12px;
}
.similar-info {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 10px;
}
.similar-title {
padding: 0;
border: 0;
background: transparent;
color: var(--primary);
cursor: pointer;
font: inherit;
line-height: 1.5;
text-align: left;
}
.similar-title:hover {
text-decoration: underline;
}
.similar-title:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
}
.similar-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: var(--muted);
font-size: 12px;
}
.similar-price {
color: var(--bad);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
</style>
+34
View File
@@ -128,6 +128,7 @@ func (c *CDP) Evaluate(expression string) (string, error) {
raw, err := c.Send("Runtime.evaluate", map[string]any{
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", err
@@ -154,6 +155,39 @@ func (c *CDP) Evaluate(expression string) (string, error) {
return value, nil
}
// CookieValue 读取单个 Cookie 的值。
//
// 只为 MTOP 签名而存在。AGENTS.md 允许「只从当前会话读取 _m_h5_tk
// 用于计算签名,不缓存、不长期复用、不写入日志」。
// 因此调用方必须:用完即弃、不存 SQLite、不写日志、不返回前端、
// 不放进任何结构体字段长期持有。
func (c *CDP) CookieValue(name, domainContains string) (string, error) {
if name != "_m_h5_tk" {
return "", fmt.Errorf("只允许读取用于 MTOP 签名的 _m_h5_tk")
}
raw, err := c.Send("Network.getAllCookies", nil)
if err != nil {
return "", err
}
var result struct {
Cookies []struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain"`
} `json:"cookies"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", fmt.Errorf("解析 Cookie 值失败:%w", err)
}
domainContains = strings.ToLower(domainContains)
for _, cookie := range result.Cookies {
if cookie.Name == name && strings.Contains(strings.ToLower(cookie.Domain), domainContains) {
return cookie.Value, nil
}
}
return "", fmt.Errorf("当前淘宝会话缺少 %s,请重新登录", name)
}
// Navigate 导航当前页面;等待时间只用于给页面完成服务端跳转和渲染。
func (c *CDP) Navigate(url string, wait time.Duration) error {
raw, err := c.Send("Page.navigate", map[string]any{"url": url})
+263
View File
@@ -0,0 +1,263 @@
package taobao
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const maxSourceImageBytes = 10 << 20
// SimilarItem 是单次以图搜返回给界面展示的淘宝商品。
type SimilarItem struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Price string `json:"price"`
Shop string `json:"shop"`
Image string `json:"image"`
Sales string `json:"sales"`
Region string `json:"region"`
URL string `json:"url"`
}
// SearchByImage 下载一张商品主图,并在当前淘宝页面会话中完成单次以图搜。
func SearchByImage(ctx context.Context, cdp *CDP, imageURL string) ([]SimilarItem, error) {
dataURL, err := downloadImageDataURL(ctx, imageURL)
if err != nil {
return nil, err
}
if err := cdp.Navigate("https://www.taobao.com/", 3*time.Second); err != nil {
return nil, fmt.Errorf("打开淘宝首页失败:%w", err)
}
strimg, err := resizeImage(cdp, dataURL)
if err != nil {
return nil, err
}
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return nil, fmt.Errorf("生成签名随机值失败:%w", err)
}
randomValue := base64.StdEncoding.EncodeToString(randomBytes)
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
pcSign, err := generatePCSign(randomValue, timestamp)
if err != nil {
return nil, err
}
paramsJSON, err := generateParamsJSON(strimg, pcSign, randomValue, timestamp)
if err != nil {
return nil, fmt.Errorf("生成以图搜参数失败:%w", err)
}
requestText, err := generateRequestText(paramsJSON)
if err != nil {
return nil, fmt.Errorf("生成以图搜请求体失败:%w", err)
}
cookieValue, err := cdp.CookieValue("_m_h5_tk", "taobao.com")
if err != nil {
return nil, err
}
token, err := extractToken(cookieValue)
cookieValue = ""
if err != nil {
return nil, err
}
signature := generateMTOPSign(token, timestamp, requestText)
token = ""
endpoint := 图搜接口 + "?jsv=2.7.4&appKey=" + 应用密钥 +
"&t=" + url.QueryEscape(timestamp) + "&sign=" + url.QueryEscape(signature) +
"&api=mtop.relationrecommend.wirelessrecommend.recommend&v=2.0" +
"&timeout=10000&type=originaljson&dataType=jsonp"
result, err := fetchImageSearch(cdp, endpoint, requestText)
requestText = ""
paramsJSON = ""
strimg = ""
dataURL = ""
if err != nil {
return nil, err
}
return parseImageSearchResult(result)
}
func downloadImageDataURL(ctx context.Context, imageURL string) (string, error) {
if strings.TrimSpace(imageURL) == "" {
return "", fmt.Errorf("商品没有主图地址")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return "", fmt.Errorf("创建主图下载请求失败:%w", err)
}
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("下载商品主图失败:%w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("下载商品主图失败:HTTP %d", response.StatusCode)
}
if response.ContentLength > maxSourceImageBytes {
return "", fmt.Errorf("商品主图超过 10 MB 上限")
}
raw, err := io.ReadAll(io.LimitReader(response.Body, maxSourceImageBytes+1))
if err != nil {
return "", fmt.Errorf("读取商品主图失败:%w", err)
}
if len(raw) > maxSourceImageBytes {
return "", fmt.Errorf("商品主图超过 10 MB 上限")
}
if len(raw) == 0 {
return "", fmt.Errorf("商品主图内容为空")
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(raw), nil
}
func resizeImage(cdp *CDP, dataURL string) (string, error) {
encoded, _ := json.Marshal(dataURL)
expression := `(async()=>{
const b = await (await fetch(` + string(encoded) + `)).blob();
const bm = await createImageBitmap(b);
const r = Math.min(1, 300 / Math.max(bm.width, bm.height));
const c = new OffscreenCanvas(Math.max(1,Math.round(bm.width*r)), Math.max(1,Math.round(bm.height*r)));
c.getContext('2d').drawImage(bm, 0, 0, c.width, c.height);
const o = await c.convertToBlob({type:'image/jpeg', quality:0.8});
const a = new Uint8Array(await o.arrayBuffer());
let s=''; for (const x of a) s += String.fromCharCode(x);
return btoa(s);
})()`
resized, err := cdp.Evaluate(expression)
if err != nil {
return "", fmt.Errorf("在淘宝页面缩放商品主图失败:%w", err)
}
if resized == "" {
return "", fmt.Errorf("缩放后的商品主图为空")
}
return resized, nil
}
func fetchImageSearch(cdp *CDP, endpoint, requestText string) (string, error) {
encodedURL, _ := json.Marshal(endpoint)
encodedBody, _ := json.Marshal(requestText)
expression := `(async()=>{
const r = await fetch(` + string(encodedURL) + `, {
method:'POST',
credentials:'include',
headers:{'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
body: new URLSearchParams({data: ` + string(encodedBody) + `}).toString()
});
return JSON.stringify({status:r.status, url:r.url, text: await r.text()});
})()`
outerJSON, err := cdp.Evaluate(expression)
if err != nil {
return "", fmt.Errorf("发送淘宝以图搜请求失败:%w", err)
}
var outer struct {
Status int `json:"status"`
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(outerJSON), &outer); err != nil {
return "", fmt.Errorf("解析淘宝以图搜 HTTP 响应失败:%w", err)
}
if outer.Status != http.StatusOK {
return "", fmt.Errorf("淘宝以图搜返回 HTTP %d", outer.Status)
}
return outer.Text, nil
}
func parseImageSearchResult(responseText string) ([]SimilarItem, error) {
var response struct {
Ret []string `json:"ret"`
Data struct {
Items []struct {
ItemID json.RawMessage `json:"item_id"`
ItemIDAlt json.RawMessage `json:"itemId"`
NID json.RawMessage `json:"nid"`
Title string `json:"title"`
UmpPrice struct {
ItemPrice json.RawMessage `json:"item_price"`
} `json:"umpPriceLog"`
PriceShow struct {
Price json.RawMessage `json:"price"`
} `json:"priceShow"`
PriceWap json.RawMessage `json:"priceWap"`
ShopInfo struct {
Title string `json:"title"`
} `json:"shopInfo"`
PicPath string `json:"pic_path"`
PicURL string `json:"picUrl"`
RealSales json.RawMessage `json:"realSales"`
Procity string `json:"procity"`
} `json:"itemsArray"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(responseText), &response); err != nil {
return nil, fmt.Errorf("淘宝以图搜返回的不是有效 JSON:%w", err)
}
success := false
for _, item := range response.Ret {
if strings.Contains(item, "SUCCESS") {
success = true
break
}
}
if !success {
return nil, fmt.Errorf("淘宝以图搜失败:%s", strings.Join(response.Ret, "; "))
}
items := make([]SimilarItem, 0, len(response.Data.Items))
for _, source := range response.Data.Items {
id := firstJSONText(source.ItemID, source.ItemIDAlt, source.NID)
if id == "" {
continue
}
image := source.PicPath
if image == "" {
image = source.PicURL
}
items = append(items, SimilarItem{
ItemID: id,
Title: source.Title,
Price: firstJSONText(source.UmpPrice.ItemPrice, source.PriceShow.Price, source.PriceWap),
Shop: source.ShopInfo.Title,
Image: image,
Sales: jsonText(source.RealSales),
Region: source.Procity,
URL: "https://item.taobao.com/item.htm?id=" + url.QueryEscape(id),
})
}
return items, nil
}
func firstJSONText(values ...json.RawMessage) string {
for _, value := range values {
if text := jsonText(value); text != "" {
return text
}
}
return ""
}
func jsonText(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var text string
if json.Unmarshal(raw, &text) == nil {
return text
}
var number json.Number
if json.Unmarshal(raw, &number) == nil {
return number.String()
}
return ""
}
+53
View File
@@ -0,0 +1,53 @@
package taobao
import (
"strings"
"testing"
)
func TestParseImageSearchResultRejectsNonSuccess(t *testing.T) {
_, err := parseImageSearchResult(`{"ret":["FAIL_SYS_TOKEN_EXOIRED::令牌过期"],"data":{}}`)
if err == nil {
t.Fatal("ret 不含 SUCCESS 时应返回错误")
}
if !strings.Contains(err.Error(), "FAIL_SYS_TOKEN_EXOIRED") || !strings.Contains(err.Error(), "令牌过期") {
t.Fatalf("错误没有包含 ret 内容:%v", err)
}
}
func TestParseImageSearchResultAllowsEmptyItemsArray(t *testing.T) {
items, err := parseImageSearchResult(`{"ret":["SUCCESS::调用成功"],"data":{"itemsArray":[]}}`)
if err != nil {
t.Fatal(err)
}
if items == nil || len(items) != 0 {
t.Fatalf("期望非 nil 空列表,得到 %#v", items)
}
}
func TestParseImageSearchResultMapsFieldsAndSkipsMissingID(t *testing.T) {
response := `{
"ret":["SUCCESS::调用成功"],
"data":{"result":[],"itemsArray":[
{"title":"无 ID"},
{"item_id":"1001","itemId":"ignored","nid":"ignored","title":"商品一","umpPriceLog":{"item_price":"12.30"},"priceShow":{"price":"13"},"priceWap":"14","shopInfo":{"title":"店铺一"},"pic_path":"//img/one.jpg","picUrl":"ignored","realSales":"99人付款","procity":"浙江 杭州"},
{"itemId":2002,"title":"商品二","priceShow":{"price":23.5},"picUrl":"https://img/two.jpg","realSales":8},
{"nid":"3003","title":"商品三","priceWap":"30"}
]}}`
items, err := parseImageSearchResult(response)
if err != nil {
t.Fatal(err)
}
if len(items) != 3 {
t.Fatalf("期望跳过无 ID 条目后剩 3 条,得到 %d", len(items))
}
if got := items[0]; got.ItemID != "1001" || got.Price != "12.30" || got.Shop != "店铺一" || got.Image != "//img/one.jpg" || got.Sales != "99人付款" || got.Region != "浙江 杭州" || got.URL != "https://item.taobao.com/item.htm?id=1001" {
t.Fatalf("第一条字段映射不正确:%#v", got)
}
if got := items[1]; got.ItemID != "2002" || got.Price != "23.5" || got.Image != "https://img/two.jpg" || got.Sales != "8" {
t.Fatalf("第二条回退字段映射不正确:%#v", got)
}
if got := items[2]; got.ItemID != "3003" || got.Price != "30" {
t.Fatalf("第三条回退字段映射不正确:%#v", got)
}
}
+152
View File
@@ -0,0 +1,152 @@
package taobao
import (
"bytes"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"strings"
)
const (
图搜接口 = "https://h5api.m.taobao.com/h5/mtop.relationrecommend.wirelessrecommend.recommend/2.0/"
应用密钥 = "12574478"
安全盐 = "6dbd0668a0634ae9badd25d3da236f47"
)
type pcSignPayload struct {
PageFrom string `json:"pageFrom"`
ImgFrom string `json:"imgFrom"`
Random string `json:"random"`
Timestamp string `json:"timestamp"`
}
// imageSearchParams 的声明顺序是淘宝签名协议的一部分,不能改成 map 或调整字段。
type imageSearchParams struct {
M string `json:"m"`
Device string `json:"device"`
IsBeta string `json:"isBeta"`
GrayHair string `json:"grayHair"`
From string `json:"from"`
Brand string `json:"brand"`
Info string `json:"info"`
Index string `json:"index"`
Rainbow string `json:"rainbow"`
SchemaType string `json:"schemaType"`
ElderHome string `json:"elderHome"`
IsEnterSrpSearch string `json:"isEnterSrpSearch"`
NewSearch string `json:"newSearch"`
Network string `json:"network"`
Subtype string `json:"subtype"`
HasPreposeFilter string `json:"hasPreposeFilter"`
PrepositionVersion string `json:"prepositionVersion"`
ClientOS string `json:"client_os"`
GPSEnabled string `json:"gpsEnabled"`
SearchDoorFrom string `json:"searchDoorFrom"`
DebugRerankNewOpenCard string `json:"debug_rerankNewOpenCard"`
HomePageVersion string `json:"homePageVersion"`
SearchElderHomeOpen string `json:"searchElderHomeOpen"`
SearchAction string `json:"search_action"`
Sugg string `json:"sugg"`
SVersion string `json:"sversion"`
Style string `json:"style"`
TTID string `json:"ttid"`
NeedTabs string `json:"needTabs"`
AreaCode string `json:"areaCode"`
VM string `json:"vm"`
CountryNum string `json:"countryNum"`
Page int `json:"page"`
N int `json:"n"`
Q string `json:"q"`
QSource string `json:"qSource"`
PageSource string `json:"pageSource"`
MyCNA string `json:"myCNA"`
Tab string `json:"tab"`
Sort string `json:"sort"`
FilterTag string `json:"filterTag"`
Service string `json:"service"`
Prop string `json:"prop"`
Loc string `json:"loc"`
StartPriceSnake *string `json:"start_price"`
EndPriceSnake *string `json:"end_price"`
StartPrice *string `json:"startPrice"`
EndPrice *string `json:"endPrice"`
CategoryP string `json:"categoryp"`
PageSize int `json:"pageSize"`
StrImg string `json:"strimg"`
ImgFrom string `json:"imgFrom"`
PageFrom string `json:"pageFrom"`
PCSign string `json:"pcSign"`
Random string `json:"random"`
Timestamp string `json:"timestamp"`
}
type imageSearchBody struct {
AppID string `json:"appId"`
Params string `json:"params"`
}
func marshalCompact(value any) (string, error) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return "", err
}
return strings.TrimSuffix(buffer.String(), "\n"), nil
}
func generatePCSign(random, timestamp string) (string, error) {
payload, err := marshalCompact(pcSignPayload{
PageFrom: "a21n57.imgsearch", ImgFrom: "upload", Random: random, Timestamp: timestamp,
})
if err != nil {
return "", fmt.Errorf("生成安全载荷失败:%w", err)
}
digest := sha256.Sum256([]byte(payload + 安全盐))
return base64.StdEncoding.EncodeToString(digest[:]), nil
}
func generateParamsJSON(strimg, pcSign, random, timestamp string) (string, error) {
params := imageSearchParams{
M: "pc_picture_search", Device: "HMA-AL00", IsBeta: "false", GrayHair: "false",
From: "nt_history", Brand: "HUAWEI", Info: "wifi", Index: "4", Rainbow: "",
SchemaType: "auction", ElderHome: "false", IsEnterSrpSearch: "true", NewSearch: "false",
Network: "wifi", Subtype: "", HasPreposeFilter: "false", PrepositionVersion: "v2",
ClientOS: "Android", GPSEnabled: "false", SearchDoorFrom: "srp",
DebugRerankNewOpenCard: "false", HomePageVersion: "v7", SearchElderHomeOpen: "false",
SearchAction: "initiative", Sugg: "_4_1", SVersion: "13.6", Style: "list",
TTID: "1@tbwang_mac_1.0.0#pc", NeedTabs: "true", AreaCode: "CN", VM: "nw",
CountryNum: "156", Page: 1, N: 48, Q: "", QSource: "manual",
PageSource: "a21bo.jianhua/a.201856.dimagesearch", MyCNA: "", Tab: "all", Sort: "_coefp",
FilterTag: "", Service: "", Prop: "", Loc: "",
CategoryP: "", PageSize: 60, StrImg: strimg, ImgFrom: "upload",
PageFrom: "a21n57.imgsearch", PCSign: pcSign, Random: random, Timestamp: timestamp,
}
return marshalCompact(params)
}
func generateRequestText(paramsJSON string) (string, error) {
return marshalCompact(imageSearchBody{AppID: "46006", Params: paramsJSON})
}
func extractToken(cookieValue string) (string, error) {
decoded, err := url.PathUnescape(cookieValue)
if err != nil {
return "", fmt.Errorf("URL 解码 _m_h5_tk 失败:%w", err)
}
token, _, _ := strings.Cut(decoded, "_")
if token == "" {
return "", fmt.Errorf("_m_h5_tk 中没有可用 token")
}
return token, nil
}
func generateMTOPSign(token, timestamp, requestText string) string {
digest := md5.Sum([]byte(token + "&" + timestamp + "&" + 应用密钥 + "&" + requestText))
return hex.EncodeToString(digest[:])
}
+92
View File
@@ -0,0 +1,92 @@
package taobao
import (
"strings"
"testing"
)
const (
fixedRandom = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
fixedTimestamp = "1700000000123"
)
func TestGeneratePCSignFixedInput(t *testing.T) {
got, err := generatePCSign(fixedRandom, fixedTimestamp)
if err != nil {
t.Fatal(err)
}
const want = "FdDgrM8F/Xx63BhrN5hOTaj/DJ3AFx4Gm9bX2JpeYVA="
if got != want {
t.Fatalf("pcSign 不一致\nwant: %s\n got: %s", want, got)
}
}
func TestGenerateParamsJSONExactBytesAndKeyOrder(t *testing.T) {
got, err := generateParamsJSON("<>&", "pc<>&", fixedRandom, fixedTimestamp)
if err != nil {
t.Fatal(err)
}
want := `{"m":"pc_picture_search","device":"HMA-AL00","isBeta":"false","grayHair":"false","from":"nt_history","brand":"HUAWEI","info":"wifi","index":"4","rainbow":"","schemaType":"auction","elderHome":"false","isEnterSrpSearch":"true","newSearch":"false","network":"wifi","subtype":"","hasPreposeFilter":"false","prepositionVersion":"v2","client_os":"Android","gpsEnabled":"false","searchDoorFrom":"srp","debug_rerankNewOpenCard":"false","homePageVersion":"v7","searchElderHomeOpen":"false","search_action":"initiative","sugg":"_4_1","sversion":"13.6","style":"list","ttid":"1@tbwang_mac_1.0.0#pc","needTabs":"true","areaCode":"CN","vm":"nw","countryNum":"156","page":1,"n":48,"q":"","qSource":"manual","pageSource":"a21bo.jianhua/a.201856.dimagesearch","myCNA":"","tab":"all","sort":"_coefp","filterTag":"","service":"","prop":"","loc":"","start_price":null,"end_price":null,"startPrice":null,"endPrice":null,"categoryp":"","pageSize":60,"strimg":"<>&","imgFrom":"upload","pageFrom":"a21n57.imgsearch","pcSign":"pc<>&","random":"` + fixedRandom + `","timestamp":"` + fixedTimestamp + `"}`
if got != want {
t.Fatalf("params JSON 字节或键顺序不一致\nwant: %s\n got: %s", want, got)
}
}
func TestGenerateParamsJSONNullsAndNoHTMLEscape(t *testing.T) {
got, err := generateParamsJSON("<>&", "pc<>&", fixedRandom, fixedTimestamp)
if err != nil {
t.Fatal(err)
}
for _, field := range []string{`"start_price":null`, `"end_price":null`, `"startPrice":null`, `"endPrice":null`} {
if !strings.Contains(got, field) {
t.Errorf("缺少真正的 null 字段 %s:%s", field, got)
}
}
if strings.Contains(got, `\u003c`) || strings.Contains(got, `\u003e`) || strings.Contains(got, `\u0026`) {
t.Fatalf("HTML 字符被转义:%s", got)
}
if !strings.Contains(got, `"strimg":"<>&"`) {
t.Fatalf("HTML 字符没有逐字节保留:%s", got)
}
}
func TestGenerateRequestTextExactBytes(t *testing.T) {
got, err := generateRequestText(`{"m":"pc_picture_search"}`)
if err != nil {
t.Fatal(err)
}
const want = `{"appId":"46006","params":"{\"m\":\"pc_picture_search\"}"}`
if got != want {
t.Fatalf("请求文本不一致\nwant: %s\n got: %s", want, got)
}
}
func TestGenerateMTOPSignFixedInput(t *testing.T) {
got := generateMTOPSign("abc123", fixedTimestamp, "fixed-request-text")
const want = "cf220492bd938c7dcee365eb2bfc6fec"
if got != want {
t.Fatalf("MTOP sign 不一致:want %s, got %s", want, got)
}
}
func TestExtractToken(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "普通值", input: "abc123_1699999999", want: "abc123"},
{name: "未编码加号", input: "abc+123_1699999999", want: "abc+123"},
{name: "URL 编码", input: "abc%2B123%5F1699999999", want: "abc+123"},
} {
t.Run(test.name, func(t *testing.T) {
got, err := extractToken(test.input)
if err != nil {
t.Fatal(err)
}
if got != test.want {
t.Fatalf("want %q, got %q", test.want, got)
}
})
}
}