378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
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<string, unknown>;
|
||
}
|
||
| {
|
||
item: null;
|
||
raw?: Record<string, unknown>;
|
||
error?: string;
|
||
};
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
}
|
||
|
||
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<string, unknown>,
|
||
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<DetailPlusFetchResult> {
|
||
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<string, unknown> | 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 };
|
||
}
|