diff --git a/app.go b/app.go index 8019597..8a912be 100644 --- a/app.go +++ b/app.go @@ -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,避免以后有人把本地文件路径或自定义协议传进来 diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue index d1522bf..951ce47 100644 --- a/frontend/src/views/ProductListView.vue +++ b/frontend/src/views/ProductListView.vue @@ -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)" /> + + + + +
+ 正在准备图片并搜索,通常需要十几秒… +
+ + {{ similarError }} + + +
+
+ +
暂无图片
+
+ +
+ {{ item.price ? `¥ ${item.price}` : '价格未知' }} + {{ item.shop || '店铺未知' }} + {{ item.sales || '销量未知' }} + {{ item.region }} +
+
+
+
+
+
@@ -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; +} diff --git a/internal/taobao/cdp.go b/internal/taobao/cdp.go index dd517b8..1cb1094 100644 --- a/internal/taobao/cdp.go +++ b/internal/taobao/cdp.go @@ -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}) diff --git a/internal/taobao/imagesearch.go b/internal/taobao/imagesearch.go new file mode 100644 index 0000000..a730139 --- /dev/null +++ b/internal/taobao/imagesearch.go @@ -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 "" +} diff --git a/internal/taobao/imagesearch_test.go b/internal/taobao/imagesearch_test.go new file mode 100644 index 0000000..c27e8bc --- /dev/null +++ b/internal/taobao/imagesearch_test.go @@ -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) + } +} diff --git a/internal/taobao/sign.go b/internal/taobao/sign.go new file mode 100644 index 0000000..7ce545d --- /dev/null +++ b/internal/taobao/sign.go @@ -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[:]) +} diff --git a/internal/taobao/sign_test.go b/internal/taobao/sign_test.go new file mode 100644 index 0000000..97a0052 --- /dev/null +++ b/internal/taobao/sign_test.go @@ -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) + } + }) + } +}