323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import type {
|
|
ProductPlanningPropertyRow,
|
|
ProductPlanningSkuRow,
|
|
ProductPlanningTaobaoItem,
|
|
} from "./types";
|
|
import { ensureHttpsUrl, normalizeText, parseInteger, parseRangeValue } from "./utils";
|
|
|
|
function normalizePrice(value: unknown) {
|
|
const parsed = parseRangeValue(value);
|
|
return typeof parsed === "number" ? parsed : null;
|
|
}
|
|
|
|
function normalizeImageList(item: any) {
|
|
if (!Array.isArray(item?.item_imgs)) return [] as string[];
|
|
|
|
return item.item_imgs
|
|
.map((entry: unknown) => {
|
|
if (typeof entry === "string") return ensureHttpsUrl(entry);
|
|
if (entry && typeof entry === "object") {
|
|
const url = (entry as Record<string, unknown>).url;
|
|
return typeof url === "string" ? ensureHttpsUrl(url) : "";
|
|
}
|
|
return "";
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeMixedImageList(value: unknown) {
|
|
if (!Array.isArray(value)) return [] as string[];
|
|
|
|
return value
|
|
.map((entry: unknown) => {
|
|
if (typeof entry === "string") return ensureHttpsUrl(entry);
|
|
if (entry && typeof entry === "object") {
|
|
const record = entry as Record<string, unknown>;
|
|
return ensureHttpsUrl(record.url || record.img || record.image || "");
|
|
}
|
|
return "";
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function extractDetailImagesFromHtml(value: unknown) {
|
|
const html = String(value || "");
|
|
if (!html) return [] as string[];
|
|
|
|
const matched = html.match(/(?:data-lazyload|src)=["']([^"']+\.(?:jpg|jpeg|png|gif|webp)(?:[^"']*)?)["']/gi);
|
|
if (!matched) return [] as string[];
|
|
|
|
const urls = matched
|
|
.map((entry) => {
|
|
const urlMatch = entry.match(/["']([^"']+)["']/);
|
|
return ensureHttpsUrl(urlMatch?.[1] || "");
|
|
})
|
|
.filter(Boolean);
|
|
|
|
return Array.from(new Set(urls));
|
|
}
|
|
|
|
function normalizeDetailImages(item: any) {
|
|
const descImages = normalizeMixedImageList(item?.desc_img);
|
|
if (descImages.length > 0) return Array.from(new Set(descImages));
|
|
return extractDetailImagesFromHtml(item?.desc);
|
|
}
|
|
|
|
function normalizeSkuList(item: any) {
|
|
const skuRows = Array.isArray(item?.skus?.sku) ? item.skus.sku : [];
|
|
const result: ProductPlanningSkuRow[] = [];
|
|
|
|
for (const sku of skuRows) {
|
|
const name =
|
|
normalizeText(sku?.properties_name) ||
|
|
normalizeText(sku?.properties) ||
|
|
normalizeText(sku?.sku_id) ||
|
|
"默认规格";
|
|
result.push({
|
|
name,
|
|
price: normalizePrice(sku?.price),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function normalizeJdSkuList(item: any) {
|
|
const skuRows = Array.isArray(item?.skus) ? item.skus : [];
|
|
const result: ProductPlanningSkuRow[] = [];
|
|
|
|
for (const sku of skuRows) {
|
|
const specs = Array.isArray(sku?.specs) ? sku.specs : [];
|
|
const specNames = specs.flatMap((spec: any) =>
|
|
Array.isArray(spec?.values)
|
|
? spec.values
|
|
.map((entry: any) => normalizeText(entry?.vname))
|
|
.filter(Boolean)
|
|
: []
|
|
);
|
|
const name =
|
|
specNames.join(" / ") ||
|
|
normalizeText(sku?.name) ||
|
|
normalizeText(sku?.sku_id) ||
|
|
"默认规格";
|
|
result.push({
|
|
name,
|
|
price: normalizePrice(sku?.price ?? item?.price),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function normalizePropertyList(item: any) {
|
|
const result: ProductPlanningPropertyRow[] = [];
|
|
|
|
if (Array.isArray(item?.props)) {
|
|
for (const prop of item.props) {
|
|
const name = normalizeText(prop?.name);
|
|
const value = normalizeText(prop?.value);
|
|
if (!name || !value) continue;
|
|
result.push({ name, value });
|
|
}
|
|
}
|
|
|
|
if (item?.props_list && typeof item.props_list === "object") {
|
|
for (const [name, value] of Object.entries(item.props_list)) {
|
|
const normalizedName = normalizeText(name);
|
|
const normalizedValue = normalizeText(value);
|
|
if (!normalizedName || !normalizedValue) continue;
|
|
if (result.some((entry) => entry.name === normalizedName && entry.value === normalizedValue)) {
|
|
continue;
|
|
}
|
|
result.push({ name: normalizedName, value: normalizedValue });
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function normalizeJdPropertyList(item: any) {
|
|
const result = normalizePropertyList(item);
|
|
const attrs = Array.isArray(item?.attrs) ? item.attrs : [];
|
|
|
|
for (const attr of attrs) {
|
|
const name = normalizeText(attr?.name || attr?.attr_name);
|
|
const value =
|
|
normalizeText(attr?.value || attr?.attr_value) ||
|
|
(Array.isArray(attr?.values)
|
|
? attr.values.map((entry: any) => normalizeText(entry?.vname || entry)).filter(Boolean).join(" / ")
|
|
: "");
|
|
|
|
if (!name || !value) continue;
|
|
if (result.some((entry) => entry.name === name && entry.value === value)) {
|
|
continue;
|
|
}
|
|
result.push({ name, value });
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function extractMaterialSummary(properties: ProductPlanningPropertyRow[]) {
|
|
const directMaterial = properties.find((property) => property.name === "材质");
|
|
const woodMaterial = properties.find((property) => property.name === "木质材质");
|
|
|
|
if (directMaterial && woodMaterial) {
|
|
return `${directMaterial.value}(${woodMaterial.value})`;
|
|
}
|
|
|
|
if (woodMaterial) return woodMaterial.value;
|
|
if (directMaterial) return directMaterial.value;
|
|
|
|
const partialMaterial = properties.find(
|
|
(property) =>
|
|
property.name.includes("材质") ||
|
|
property.name.includes("面料") ||
|
|
property.name.toLowerCase().includes("material")
|
|
);
|
|
|
|
return partialMaterial?.value || undefined;
|
|
}
|
|
|
|
export function extractDetailedMaterialSummary(properties: ProductPlanningPropertyRow[]) {
|
|
const materialProperties = properties.filter((property) =>
|
|
[
|
|
"材质",
|
|
"木质材质",
|
|
"饰面材质",
|
|
"实木占比",
|
|
"面料",
|
|
"成分",
|
|
"工艺",
|
|
"表面处理",
|
|
"风格",
|
|
"流行元素",
|
|
].some((keyword) => property.name.includes(keyword))
|
|
);
|
|
|
|
if (materialProperties.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return materialProperties.map((property) => `${property.name}:${property.value}`).join(" | ");
|
|
}
|
|
|
|
export function normalizeTaobaoItem(item: any, itemId: string): ProductPlanningTaobaoItem {
|
|
const itemImages = normalizeImageList(item);
|
|
const detailImages = normalizeDetailImages(item);
|
|
const skus = normalizeSkuList(item);
|
|
const properties = normalizePropertyList(item);
|
|
const material = extractMaterialSummary(properties);
|
|
const detailedMaterial = extractDetailedMaterialSummary(properties);
|
|
|
|
return {
|
|
platform: "taobao",
|
|
dataSource: "legacy",
|
|
itemId,
|
|
requestedItemId: itemId,
|
|
title: normalizeText(item?.title) || "无标题",
|
|
sourceUrl: ensureHttpsUrl(item?.source_url || ""),
|
|
detailUrl: ensureHttpsUrl(item?.detail_url || ""),
|
|
shopId:
|
|
normalizeText(item?.seller_info?.shop_id) ||
|
|
normalizeText(item?.shop_id) ||
|
|
undefined,
|
|
shopName:
|
|
normalizeText(item?.seller_info?.shop_name) ||
|
|
normalizeText(item?.shop_name) ||
|
|
normalizeText(item?.nick) ||
|
|
undefined,
|
|
location: normalizeText(item?.location) || undefined,
|
|
categoryId: normalizeText(item?.cid || item?.category_id) || undefined,
|
|
price: normalizePrice(item?.price),
|
|
originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price),
|
|
salesText: normalizeText(item?.sales) || undefined,
|
|
totalSales: parseInteger(item?.sales || item?.volume || item?.total_sold, 0),
|
|
recentSales: null,
|
|
brand: normalizeText(item?.brand) || undefined,
|
|
isTmall: Boolean(item?.tmall) || String(item?.detail_url || "").includes("tmall.com"),
|
|
mainImage: ensureHttpsUrl(item?.pic_url || itemImages[0] || ""),
|
|
itemImages,
|
|
detailImages,
|
|
descriptionHtml: normalizeText(item?.desc) ? String(item.desc) : undefined,
|
|
videoUrl: ensureHttpsUrl(item?.video?.url || ""),
|
|
videoThumb: ensureHttpsUrl(item?.video?.thumb || ""),
|
|
skus,
|
|
properties,
|
|
material,
|
|
detailedMaterial,
|
|
skuCount: skus.length,
|
|
};
|
|
}
|
|
|
|
export function normalizeJdItem(item: any, itemId: string): ProductPlanningTaobaoItem {
|
|
const itemImages = normalizeMixedImageList(item?.item_imgs);
|
|
const detailImages = normalizeDetailImages(item);
|
|
const skus = normalizeJdSkuList(item);
|
|
const properties = normalizeJdPropertyList(item);
|
|
const material = extractMaterialSummary(properties);
|
|
const detailedMaterial = extractDetailedMaterialSummary(properties);
|
|
const totalSales = parseInteger(item?.sales || item?.volume || item?.total_sold, 0);
|
|
|
|
return {
|
|
platform: "jd",
|
|
dataSource: "jd",
|
|
itemId,
|
|
requestedItemId: itemId,
|
|
title: normalizeText(item?.title) || "无标题",
|
|
detailUrl: ensureHttpsUrl(item?.detail_url || `https://item.jd.com/${itemId}.html`),
|
|
shopName:
|
|
normalizeText(item?.shop_name) ||
|
|
normalizeText(item?.seller_info?.shop_name) ||
|
|
normalizeText(item?.nick) ||
|
|
undefined,
|
|
shopId:
|
|
normalizeText(item?.shop_id) ||
|
|
normalizeText(item?.seller_info?.shop_id) ||
|
|
undefined,
|
|
location: normalizeText(item?.location) || undefined,
|
|
categoryId: normalizeText(item?.cid || item?.category_id) || undefined,
|
|
price: normalizePrice(item?.price),
|
|
originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price),
|
|
salesText: normalizeText(item?.sales || item?.volume) || undefined,
|
|
totalSales,
|
|
recentSales: totalSales,
|
|
brand: normalizeText(item?.brand) || undefined,
|
|
isTmall: false,
|
|
mainImage: ensureHttpsUrl(item?.pic_url || itemImages[0] || ""),
|
|
itemImages,
|
|
detailImages,
|
|
descriptionHtml: normalizeText(item?.desc) ? String(item.desc) : undefined,
|
|
videoUrl: ensureHttpsUrl(item?.video?.url || ""),
|
|
videoThumb: ensureHttpsUrl(item?.video?.thumb || ""),
|
|
skus,
|
|
properties,
|
|
material,
|
|
detailedMaterial,
|
|
skuCount: skus.length,
|
|
};
|
|
}
|
|
|
|
export function extractMonthSale(raw: Record<string, unknown> | null | undefined) {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
|
|
const candidates = [
|
|
raw.biz30day,
|
|
raw.sales,
|
|
raw.month_sales,
|
|
(raw.data as Record<string, unknown> | undefined)?.sales,
|
|
(raw.data as Record<string, unknown> | undefined)?.month_sales,
|
|
(raw.data as Record<string, unknown> | undefined)?.recent_sales,
|
|
raw.recent_sales,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const parsed = parseRangeValue(candidate);
|
|
if (typeof parsed === "number") {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|