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

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