import "server-only"; import type { ProductPlanningPropertyRow, ProductPlanningSkuRow, ProductPlanningTaobaoItem, } from "./types"; import { extractDetailedMaterialSummary, extractMaterialSummary } from "./taobao"; import { ensureHttpsUrl, normalizeText, parseInteger, parseRangeValue, uniqueBy } from "./utils"; import type { ProductPlanningApiConfig } from "./config"; type DetailPlusFetchResult = | { item: ProductPlanningTaobaoItem; raw: Record; } | { item: null; raw?: Record; error?: string; }; function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function parseJsonMaybe(value: unknown): unknown { if (typeof value !== "string") return value; const text = value.trim(); if (!text) return null; try { return JSON.parse(text); } catch { return value; } } function pickText(...values: unknown[]) { for (const value of values) { const text = normalizeText(value); if (text) return text; } return ""; } function pickNumber(...values: unknown[]) { for (const value of values) { const parsed = parseRangeValue(value); if (typeof parsed === "number") return parsed; } return null; } function normalizeImageList(...values: unknown[]) { const urls: string[] = []; for (const value of values) { const parsed = parseJsonMaybe(value); if (typeof parsed === "string") { const url = ensureHttpsUrl(parsed); if (url) urls.push(url); continue; } if (Array.isArray(parsed)) { for (const entry of parsed) { if (typeof entry === "string") { const url = ensureHttpsUrl(entry); if (url) urls.push(url); continue; } const record = asRecord(entry); const url = ensureHttpsUrl( record.url || record.img || record.image || record.pic_url || record.picUrl || record.main_image_url || record.src ); if (url) urls.push(url); } continue; } const record = asRecord(parsed); const direct = ensureHttpsUrl( record.url || record.img || record.image || record.pic_url || record.picUrl || record.main_image_url || record.mainImage ); if (direct) urls.push(direct); if (Array.isArray(record.item_imgs)) urls.push(...normalizeImageList(record.item_imgs)); if (Array.isArray(record.images)) urls.push(...normalizeImageList(record.images)); } return uniqueBy(urls, (url) => url); } function extractDetailImagesFromHtml(value: unknown) { const html = String(value || ""); if (!html) return [] as string[]; const urls: string[] = []; const pattern = /(?:data-lazyload|src)=["']([^"']+\.(?:jpg|jpeg|png|gif|webp)(?:[^"']*)?)["']/gi; let match = pattern.exec(html); while (match) { const url = ensureHttpsUrl(match[1]); if (url) urls.push(url); match = pattern.exec(html); } return uniqueBy(urls, (url) => url); } function normalizeSkuList(...values: unknown[]) { const result: ProductPlanningSkuRow[] = []; for (const value of values) { const parsed = parseJsonMaybe(value); const parsedRecord = asRecord(parsed); const rows: unknown[] = Array.isArray(parsed) ? parsed : Array.isArray(parsedRecord.sku) ? parsedRecord.sku : Array.isArray(parsedRecord.skus) ? parsedRecord.skus : []; for (const row of rows) { const record = asRecord(row); const name = pickText( record.properties_name, record.propertiesName, record.name, record.title, record.sku_name, record.skuName, record.sku_id, record.skuId ) || "默认规格"; result.push({ name, price: pickNumber(record.price, record.promotion_price, record.sale_price), }); } } return uniqueBy(result, (row) => `${row.name}:${row.price ?? ""}`); } function normalizePropertyRows(...values: unknown[]) { const result: ProductPlanningPropertyRow[] = []; const push = (name: unknown, value: unknown) => { const normalizedName = normalizeText(name); const normalizedValue = normalizeText(value); if (!normalizedName || !normalizedValue) return; result.push({ name: normalizedName, value: normalizedValue }); }; for (const value of values) { const parsed = parseJsonMaybe(value); if (Array.isArray(parsed)) { for (const entry of parsed) { if (typeof entry === "string") { const [name, ...rest] = entry.split(/[::]/); push(name, rest.join(":")); continue; } const record = asRecord(entry); push( record.name || record.attr_name || record.key || record.title || record.property_name, record.value || record.attr_value || record.val || record.text || record.property_value ); } continue; } const record = asRecord(parsed); for (const [name, value] of Object.entries(record)) { if (Array.isArray(value) || (value && typeof value === "object")) { const nestedRows = normalizePropertyRows(value); if (nestedRows.length > 0) { result.push(...nestedRows); continue; } } push(name, value); } } return uniqueBy(result, (row) => `${row.name}:${row.value}`); } function firstRecord(value: unknown) { const parsed = parseJsonMaybe(value); if (Array.isArray(parsed)) return asRecord(parsed[0]); return asRecord(parsed); } function buildDetailPlusUrls(config: ProductPlanningApiConfig, itemId: string) { const bases = [config.detailPlusInternalBaseUrl, config.detailPlusBaseUrl] .map((value) => String(value || "").trim().replace(/\/+$/, "")) .filter(Boolean); return uniqueBy(bases, (base) => base).map((base) => `${base}/${encodeURIComponent(itemId)}`); } export function normalizeDetailPlusItem( detail: Record, requestedItemId: string ): ProductPlanningTaobaoItem | null { const productParameters = asRecord(detail.productParameters); const productRecord = asRecord( detail.taobaoProductRecord || firstRecord(detail.taobaoProductRecords) ); const normalizedRecord = asRecord(parseJsonMaybe(productRecord.normalized_item_json)); const rawData = asRecord(parseJsonMaybe(detail.raw_data_json || productRecord.raw_data_json)); const rawItem = asRecord(rawData.item || rawData.data || rawData); const itemId = pickText( detail.item_id, productRecord.item_id, productRecord.source_item_id, normalizedRecord.itemId, requestedItemId ); if (!itemId) return null; const itemImages = normalizeImageList( detail.item_imgs_json, rawItem.item_imgs, rawItem.images, normalizedRecord.itemImages, normalizedRecord.images, productRecord.main_image_url ); const descriptionHtml = pickText(detail.desc_html, rawItem.desc, normalizedRecord.descriptionHtml); const detailImages = uniqueBy( [ ...normalizeImageList(rawItem.desc_img, normalizedRecord.detailImages, normalizedRecord.detail_images), ...extractDetailImagesFromHtml(descriptionHtml), ], (url) => url ); const skus = normalizeSkuList( productParameters.skus, detail.skus_json, rawItem.skus, normalizedRecord.skus ); const properties = normalizePropertyRows( productParameters.props, productParameters.attributes, productParameters.specs, detail.props_json, detail.attribute_json, detail.specs_json, normalizedRecord.properties, rawItem.props, rawItem.props_list ); const material = pickText(productRecord.product_material, productRecord.core_material_name) || extractMaterialSummary(properties); const detailedMaterial = pickText(productRecord.product_material, productRecord.core_material_name) || extractDetailedMaterialSummary(properties); const mainImage = ensureHttpsUrl(productRecord.main_image_url || rawItem.pic_url || normalizedRecord.mainImage || itemImages[0]) || undefined; return { platform: "taobao", dataSource: "detail-plus", itemId, requestedItemId, title: pickText(detail.title, productRecord.title, rawItem.title, normalizedRecord.title) || "无标题", detailUrl: ensureHttpsUrl( detail.detail_url || productRecord.detail_url || productRecord.detail_link || rawItem.detail_url || "" ), sourceUrl: ensureHttpsUrl(detail.source_url || productRecord.source_url || ""), productRecordId: Number(productRecord.id) || undefined, detailStorageRecordId: Number(detail.id || productRecord.detail_storage_record_id) || undefined, shopName: pickText(detail.shop_name, productRecord.shop_name, rawItem.shop_name, rawItem.nick) || undefined, shopId: pickText(detail.shop_id, productRecord.shop_id, productRecord.supplier_shop_id) || undefined, location: pickText(detail.location, productRecord.location, rawItem.location) || undefined, categoryId: pickText(detail.category_id, productRecord.category_id) || undefined, price: pickNumber(detail.price, productRecord.price, rawItem.price), originalPrice: pickNumber(productRecord.original_price, rawItem.orginal_price, rawItem.original_price, detail.price), salesText: pickText(productRecord.total_sold, rawItem.sales, rawItem.volume) || undefined, totalSales: parseInteger(productRecord.total_sold || rawItem.sales || rawItem.volume, 0), recentSales: parseRangeValue(productRecord.analysis_sales) ?? null, brand: pickText(rawItem.brand, normalizedRecord.brand) || undefined, isTmall: pickText(detail.seller_type, rawItem.seller_type).toLowerCase().includes("tmall") || String(detail.detail_url || productRecord.detail_url || "").includes("tmall.com"), mainImage, itemImages, detailImages, descriptionHtml: descriptionHtml || undefined, videoUrl: ensureHttpsUrl(detail.video_url || rawItem.video_url || asRecord(rawItem.video).url || ""), videoThumb: ensureHttpsUrl(asRecord(rawItem.video).thumb || ""), skus, properties, material, detailedMaterial, standardProductType: pickText(productRecord.standard_product_type, productRecord.core_product_type_name) || undefined, marketCategoryName: pickText(productRecord.market_category_name, productRecord.market_common_category_name) || undefined, priceBand: pickText(productRecord.price_band, productRecord.analysis_price_range) || undefined, skuCount: Number(productRecord.sku_count || skus.length) || skus.length, }; } export async function fetchTaobaoDetailPlusItem( itemId: string, config: ProductPlanningApiConfig ): Promise { const apiKey = String(config.detailPlusApiKey || "").trim(); if (!apiKey) { return { item: null, error: "详情增强接口 API Key 未配置" }; } const urls = buildDetailPlusUrls(config, itemId); if (urls.length === 0) { return { item: null, error: "详情增强接口地址未配置" }; } let lastError = ""; for (const url of urls) { try { const response = await fetch(url, { method: "GET", headers: { Accept: "application/json", "x-api-key": apiKey, }, signal: AbortSignal.timeout(15_000), cache: "no-store", }); const raw = (await response.json().catch(() => null)) as Record | null; if (!response.ok) { lastError = `详情增强接口请求失败:${response.status}`; if (response.status === 404) return { item: null, raw: raw || undefined, error: lastError }; continue; } if (!raw?.success || !raw.data) { lastError = normalizeText(raw?.error) || "详情增强接口未返回可用数据"; continue; } const item = normalizeDetailPlusItem(asRecord(raw.data), itemId); if (item) return { item, raw }; lastError = "详情增强接口数据结构无法识别"; } catch (error) { lastError = error instanceof Error ? error.message : String(error); console.warn("[ProductPlanning][DetailPlus] request failed:", error); } } return { item: null, error: lastError }; }