465 lines
13 KiB
TypeScript
465 lines
13 KiB
TypeScript
export interface TaobaoDetailSummary {
|
||
itemId: string
|
||
title: string
|
||
shopName: string
|
||
price: string
|
||
location: string
|
||
sellerType: string
|
||
detailUrl: string | null
|
||
videoUrl: string | null
|
||
mainImageUrl: string | null
|
||
itemImages: string[]
|
||
allItemImages: string[]
|
||
skuImages: string[]
|
||
descImages: string[]
|
||
itemImageCount: number
|
||
skuCount: number
|
||
attributeCount: number
|
||
totalCount: string | null
|
||
}
|
||
|
||
export class TaobaoDetailPlusError extends Error {
|
||
status: number
|
||
code: string
|
||
|
||
constructor(status: number, code: string, message: string) {
|
||
super(message)
|
||
this.name = 'TaobaoDetailPlusError'
|
||
this.status = status
|
||
this.code = code
|
||
}
|
||
}
|
||
|
||
const DEFAULT_TAOBAO_DETAIL_PLUS_BASE_URL = 'http://156.225.27.52:60024'
|
||
const DEFAULT_TAOBAO_DETAIL_PLUS_TIMEOUT_MS = 12000
|
||
const ITEM_ID_REGEX = /\d{8,20}/
|
||
const IMAGE_URL_PATTERN = /^(https?:)?\/\//i
|
||
|
||
function pickString(...values: unknown[]): string {
|
||
for (const value of values) {
|
||
if (typeof value === 'string' && value.trim()) {
|
||
return value.trim()
|
||
}
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return String(value)
|
||
}
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function pickNullableString(...values: unknown[]): string | null {
|
||
const value = pickString(...values)
|
||
return value || null
|
||
}
|
||
|
||
function pickNumber(...values: unknown[]): number {
|
||
for (const value of values) {
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return value
|
||
}
|
||
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const parsed = Number(value)
|
||
if (Number.isFinite(parsed)) {
|
||
return parsed
|
||
}
|
||
}
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
function looksLikeImageUrl(value: string): boolean {
|
||
const normalized = value.trim()
|
||
if (!normalized || !IMAGE_URL_PATTERN.test(normalized)) {
|
||
return false
|
||
}
|
||
|
||
return !/item\.taobao\.com|detail\.tmall\.com/i.test(normalized)
|
||
}
|
||
|
||
function normalizeImageCandidate(value: string): string {
|
||
const normalized = value.trim()
|
||
if (normalized.startsWith('//')) {
|
||
return `https:${normalized}`
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
function parseJsonLike<T>(value: unknown, fallback: T): T {
|
||
if (value == null) {
|
||
return fallback
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim()
|
||
if (!trimmed) {
|
||
return fallback
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(trimmed) as T
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
return value as T
|
||
}
|
||
|
||
function normalizeImageUrls(
|
||
value: unknown,
|
||
candidateKeys: string[] = ['url', 'img', 'image', 'src', 'imageUrl', 'imgUrl', 'picUrl', 'pic'],
|
||
): string[] {
|
||
if (!Array.isArray(value)) {
|
||
return []
|
||
}
|
||
|
||
return Array.from(
|
||
new Set(
|
||
value
|
||
.map((item) => {
|
||
if (typeof item === 'string') {
|
||
const normalized = normalizeImageCandidate(item)
|
||
return looksLikeImageUrl(normalized) ? normalized : ''
|
||
}
|
||
|
||
if (item && typeof item === 'object') {
|
||
for (const key of candidateKeys) {
|
||
if (key in item) {
|
||
const candidate = normalizeImageCandidate(
|
||
pickString((item as Record<string, unknown>)[key]),
|
||
)
|
||
if (looksLikeImageUrl(candidate)) {
|
||
return candidate
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return ''
|
||
})
|
||
.filter(Boolean),
|
||
),
|
||
)
|
||
}
|
||
|
||
function extractNestedSkuImages(...sources: unknown[]): string[] {
|
||
const images: string[] = []
|
||
|
||
for (const source of sources) {
|
||
if (!source || typeof source !== 'object') {
|
||
continue
|
||
}
|
||
|
||
const props = Array.isArray((source as { props?: unknown[] }).props)
|
||
? (source as { props: unknown[] }).props
|
||
: []
|
||
|
||
for (const prop of props) {
|
||
if (!prop || typeof prop !== 'object') {
|
||
continue
|
||
}
|
||
|
||
const values = Array.isArray((prop as { values?: unknown[] }).values)
|
||
? (prop as { values: unknown[] }).values
|
||
: []
|
||
|
||
images.push(...normalizeImageUrls(values))
|
||
}
|
||
}
|
||
|
||
return Array.from(new Set(images))
|
||
}
|
||
|
||
function extractSpecSkuImages(...sources: unknown[]): string[] {
|
||
const images = new Set<string>()
|
||
|
||
const visit = (value: unknown) => {
|
||
if (typeof value === 'string') {
|
||
const candidate = normalizeImageCandidate(value)
|
||
if (looksLikeImageUrl(candidate)) {
|
||
images.add(candidate)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
value.forEach(visit)
|
||
return
|
||
}
|
||
|
||
if (value && typeof value === 'object') {
|
||
Object.values(value).forEach(visit)
|
||
}
|
||
}
|
||
|
||
for (const source of sources) {
|
||
visit(source)
|
||
}
|
||
|
||
return Array.from(images)
|
||
}
|
||
|
||
function extractHtmlImageUrls(...htmlSources: unknown[]): string[] {
|
||
const imageUrls: string[] = []
|
||
const patterns = [
|
||
/<img[^>]+src=["']([^"'<>]+)["']/gi,
|
||
/https?:\/\/[^"'<>\\s]+\.(?:jpg|jpeg|png|webp|gif|bmp)(?:\?[^"'<>\\s]*)?/gi,
|
||
]
|
||
|
||
for (const htmlSource of htmlSources) {
|
||
const html = pickString(htmlSource)
|
||
if (!html) {
|
||
continue
|
||
}
|
||
|
||
for (const pattern of patterns) {
|
||
let match: RegExpExecArray | null
|
||
while ((match = pattern.exec(html)) !== null) {
|
||
const candidate = normalizeImageCandidate(match[1] || match[0] || '')
|
||
if (looksLikeImageUrl(candidate)) {
|
||
imageUrls.push(candidate)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array.from(new Set(imageUrls))
|
||
}
|
||
|
||
function extractMainImages(data: Record<string, unknown>, rawData: Record<string, unknown>, mainItemInfo: Record<string, unknown>): string[] {
|
||
return Array.from(
|
||
new Set([
|
||
...normalizeImageUrls(data.item_imgs_json),
|
||
...normalizeImageUrls(data.itemImages),
|
||
...normalizeImageUrls(rawData.item_imgs),
|
||
...normalizeImageUrls(rawData.itemImages),
|
||
...normalizeImageUrls(mainItemInfo.images),
|
||
]),
|
||
)
|
||
}
|
||
|
||
function extractSkuImages(data: Record<string, unknown>, rawData: Record<string, unknown>, mainItemInfo: Record<string, unknown>): string[] {
|
||
return Array.from(
|
||
new Set([
|
||
...normalizeImageUrls(data.skus_json),
|
||
...normalizeImageUrls(data.sku_imgs_json),
|
||
...normalizeImageUrls(data.skuImages),
|
||
...normalizeImageUrls(rawData.skus),
|
||
...normalizeImageUrls(rawData.skuImages),
|
||
...extractNestedSkuImages(
|
||
data.sku_base_json,
|
||
data.skuBaseJson,
|
||
rawData.skuBase,
|
||
rawData.sku_base,
|
||
mainItemInfo.skuBase,
|
||
),
|
||
...extractSpecSkuImages(
|
||
data.specs_json,
|
||
data.specsJson,
|
||
rawData.specs,
|
||
rawData.specsJson,
|
||
mainItemInfo.specs,
|
||
),
|
||
]),
|
||
)
|
||
}
|
||
|
||
function extractDescImages(data: Record<string, unknown>, rawData: Record<string, unknown>, mainItemInfo: Record<string, unknown>): string[] {
|
||
return Array.from(
|
||
new Set([
|
||
...normalizeImageUrls(data.desc_imgs_json),
|
||
...normalizeImageUrls(data.desc_imgs),
|
||
...normalizeImageUrls(data.descImages),
|
||
...normalizeImageUrls(rawData.desc_imgs),
|
||
...normalizeImageUrls(rawData.descImgs),
|
||
...normalizeImageUrls(rawData.descImages),
|
||
...normalizeImageUrls(mainItemInfo.descImgs),
|
||
...normalizeImageUrls(mainItemInfo.descImages),
|
||
...extractHtmlImageUrls(
|
||
data.desc_html,
|
||
data.pc_desc_content,
|
||
rawData.descHtml,
|
||
rawData.desc,
|
||
rawData.pcDescContent,
|
||
rawData.desc_content,
|
||
mainItemInfo.descHtml,
|
||
),
|
||
]),
|
||
)
|
||
}
|
||
|
||
export function normalizeTaobaoItemId(rawValue: string | null | undefined): string | null {
|
||
const value = String(rawValue || '').trim()
|
||
if (!value) {
|
||
return null
|
||
}
|
||
|
||
const match = value.match(ITEM_ID_REGEX)
|
||
return match ? match[0] : null
|
||
}
|
||
|
||
export async function fetchTaobaoDetailPlus(rawItemId: string | null | undefined): Promise<TaobaoDetailSummary> {
|
||
const itemId = normalizeTaobaoItemId(rawItemId)
|
||
if (!itemId) {
|
||
throw new TaobaoDetailPlusError(400, 'INVALID_ITEM_ID', '请输入正确的商品ID')
|
||
}
|
||
|
||
const apiKey = process.env.TAOBAO_DETAIL_PLUS_API_KEY
|
||
if (!apiKey) {
|
||
throw new TaobaoDetailPlusError(500, 'SERVICE_NOT_CONFIGURED', '淘宝商品查询服务未配置')
|
||
}
|
||
|
||
const baseUrl = (process.env.TAOBAO_DETAIL_PLUS_API_BASE_URL || DEFAULT_TAOBAO_DETAIL_PLUS_BASE_URL).trim()
|
||
const timeoutMs = pickNumber(
|
||
process.env.TAOBAO_DETAIL_PLUS_TIMEOUT_MS,
|
||
DEFAULT_TAOBAO_DETAIL_PLUS_TIMEOUT_MS,
|
||
)
|
||
|
||
const requestUrl = new URL('/api/public/taobao-detail-plus', baseUrl)
|
||
requestUrl.searchParams.set('itemId', itemId)
|
||
|
||
const controller = new AbortController()
|
||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||
|
||
try {
|
||
const response = await fetch(requestUrl.toString(), {
|
||
method: 'GET',
|
||
cache: 'no-store',
|
||
headers: {
|
||
'x-api-key': apiKey,
|
||
},
|
||
signal: controller.signal,
|
||
})
|
||
|
||
const payload = await response.json().catch(() => null)
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 404) {
|
||
throw new TaobaoDetailPlusError(
|
||
404,
|
||
'ITEM_NOT_FOUND',
|
||
pickString(
|
||
payload && typeof payload === 'object' ? (payload as Record<string, unknown>).error : null,
|
||
payload && typeof payload === 'object'
|
||
? (payload as Record<string, unknown>).message
|
||
: null,
|
||
payload && typeof payload === 'object'
|
||
? ((payload as Record<string, unknown>).error as Record<string, unknown> | undefined)?.message
|
||
: null,
|
||
'未找到该商品数据,可能已下架或商品ID不正确',
|
||
),
|
||
)
|
||
}
|
||
|
||
throw new TaobaoDetailPlusError(
|
||
502,
|
||
'UPSTREAM_HTTP_ERROR',
|
||
pickString(
|
||
payload && typeof payload === 'object' ? (payload as Record<string, unknown>).error : null,
|
||
payload && typeof payload === 'object'
|
||
? ((payload as Record<string, unknown>).error as Record<string, unknown> | undefined)?.message
|
||
: null,
|
||
payload && typeof payload === 'object' ? (payload as Record<string, unknown>).message : null,
|
||
`淘宝商品查询失败,上游返回 ${response.status}`,
|
||
),
|
||
)
|
||
}
|
||
|
||
if (!payload?.success || !payload?.data) {
|
||
throw new TaobaoDetailPlusError(
|
||
502,
|
||
'UPSTREAM_INVALID_RESPONSE',
|
||
pickString(payload?.message, payload?.error?.message, payload?.data?.api_msg, '淘宝商品查询返回异常'),
|
||
)
|
||
}
|
||
|
||
const data = payload.data as Record<string, unknown>
|
||
const rawData = parseJsonLike<Record<string, unknown>>(data.raw_data_json, {})
|
||
const mainItemInfo = parseJsonLike<Record<string, unknown>>(
|
||
data.main_item_info_json ?? rawData.mainItemInfo,
|
||
{},
|
||
)
|
||
const itemImgsJson = parseJsonLike<unknown[]>(data.item_imgs_json, [])
|
||
const skusJson = parseJsonLike<unknown[]>(data.skus_json, [])
|
||
const skuImgsJson = parseJsonLike<unknown[]>(data.sku_imgs_json, [])
|
||
const descImgsJson = parseJsonLike<unknown[]>(data.desc_imgs_json, [])
|
||
const attributeJson = parseJsonLike<unknown[]>(data.attribute_json, [])
|
||
const skuBaseJson = parseJsonLike<Record<string, unknown>>(data.sku_base_json ?? data.skuBaseJson, {})
|
||
const specsJson = parseJsonLike<Record<string, unknown>>(data.specs_json ?? data.specsJson, {})
|
||
|
||
// 解析各种图片
|
||
const allItemImages = extractMainImages(
|
||
{
|
||
...data,
|
||
item_imgs_json: itemImgsJson,
|
||
},
|
||
rawData,
|
||
mainItemInfo,
|
||
)
|
||
|
||
// 解析 SKU 图片
|
||
const rawSkus = parseJsonLike<unknown[]>(rawData.skus, [])
|
||
const skus = skusJson.length > 0 ? skusJson : rawSkus
|
||
const skuImages = extractSkuImages(
|
||
{
|
||
...data,
|
||
skus_json: skusJson,
|
||
sku_imgs_json: skuImgsJson,
|
||
sku_base_json: skuBaseJson,
|
||
specs_json: specsJson,
|
||
},
|
||
rawData,
|
||
mainItemInfo,
|
||
)
|
||
|
||
// 解析详情图片
|
||
const descImages = extractDescImages(
|
||
{
|
||
...data,
|
||
desc_imgs_json: descImgsJson,
|
||
},
|
||
rawData,
|
||
mainItemInfo,
|
||
)
|
||
|
||
return {
|
||
itemId: pickString(data.item_id, data.requested_item_id, rawData.itemId, itemId),
|
||
title: pickString(data.title, rawData.title),
|
||
shopName: pickString(data.shop_name, data.nick, rawData.shopName, rawData.nick),
|
||
price: pickString(data.price, rawData.price, mainItemInfo.priceWap),
|
||
location: pickString(data.location, rawData.location),
|
||
sellerType: pickString(data.seller_type, rawData.sellerType),
|
||
detailUrl: pickNullableString(data.detail_url, rawData.detailUrl, mainItemInfo.url),
|
||
videoUrl: pickNullableString(data.video_url, rawData.videoUrl),
|
||
mainImageUrl: allItemImages[0] || null,
|
||
itemImages: allItemImages.slice(0, 5),
|
||
allItemImages,
|
||
skuImages,
|
||
descImages,
|
||
itemImageCount: pickNumber(data.item_imgs_count, allItemImages.length),
|
||
skuCount: pickNumber(data.skus_count, skus.length),
|
||
attributeCount: pickNumber(
|
||
data.attributes_count,
|
||
attributeJson.length,
|
||
),
|
||
totalCount: pickNullableString(data.total_count, rawData.totalCount),
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof TaobaoDetailPlusError) {
|
||
throw error
|
||
}
|
||
|
||
if ((error as Error).name === 'AbortError') {
|
||
throw new TaobaoDetailPlusError(504, 'UPSTREAM_TIMEOUT', '淘宝商品查询超时,请稍后再试')
|
||
}
|
||
|
||
console.error('[taobao-detail-plus] fetch error:', error)
|
||
throw new TaobaoDetailPlusError(500, 'SERVICE_ERROR', '淘宝商品查询失败,请稍后再试')
|
||
} finally {
|
||
clearTimeout(timeoutId)
|
||
}
|
||
}
|