gongxiangp2/lib/taobao-detail-plus.ts
2026-05-07 16:37:22 +08:00

465 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}