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:
@@ -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,避免以后有人把本地文件路径或自定义协议传进来
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user