Your Name 987ab3f6a0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: support batch competitor planning lookup
2026-04-30 17:35:25 +08:00

378 lines
12 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.

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