ai-chatbot/lib/product-planning/marketplace-items.ts
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

211 lines
6.7 KiB
TypeScript
Raw Permalink 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 { ProductPlanningApiConfig } from "./config";
import { fetchTaobaoDetailPlusItem } from "./detail-plus";
import { syncLegacyTaobaoDetailToPlanningDb } from "./planning-db";
import {
extractMonthSale,
normalizeJdItem,
normalizeTaobaoItem,
} from "./taobao";
import type {
ProductPlanningMarketplaceItemRef,
ProductPlanningMarketplaceItemsResult,
ProductPlanningTaobaoItem,
} from "./types";
const BATCH_CONCURRENCY = 4;
async function fetchJson(url: string, label: string) {
const response = await fetch(url, {
signal: AbortSignal.timeout(15_000),
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!response.ok) {
throw new Error(`${label}请求失败:${response.status}`);
}
return response.json();
}
async function fetchLegacyTaobaoItem(itemId: string, config: ProductPlanningApiConfig) {
const apiKey = config.taobaoApiKey;
const apiSecret = config.taobaoApiSecret;
if (!apiKey || !apiSecret) {
throw new Error("详情增强接口未命中,且缺少旧版淘宝 API Key / Secret,请前往系统设置配置");
}
const primaryUrl =
`${config.oneboundBaseUrl}/taobao/item_get_pro/?key=${encodeURIComponent(apiKey)}` +
`&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`;
const primaryData = await fetchJson(primaryUrl, "淘宝商品详情接口");
if (primaryData?.error_code && primaryData.error_code !== "0000") {
throw new Error(primaryData.error || primaryData.reason || "淘宝商品详情接口返回错误");
}
if (primaryData?.item) {
return {
rawItem: primaryData.item as Record<string, unknown>,
rawResponse: primaryData as Record<string, unknown>,
};
}
const fallbackUrl =
`${config.oneboundBaseUrl}/taobao/item_get/?key=${encodeURIComponent(apiKey)}` +
`&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`;
const fallbackData = await fetchJson(fallbackUrl, "淘宝商品详情接口");
if (fallbackData?.error_code && fallbackData.error_code !== "0000") {
throw new Error(fallbackData.error || fallbackData.reason || "淘宝商品详情接口返回错误");
}
if (!fallbackData?.item) {
throw new Error("未找到商品信息");
}
return {
rawItem: fallbackData.item as Record<string, unknown>,
rawResponse: fallbackData as Record<string, unknown>,
};
}
export async function fetchProductPlanningTaobaoItem(
itemId: string,
config: ProductPlanningApiConfig
) {
const detailPlusResult = await fetchTaobaoDetailPlusItem(itemId, config);
if (detailPlusResult.item) {
return detailPlusResult.item;
}
const legacy = await fetchLegacyTaobaoItem(itemId, config);
const normalizedItem = normalizeTaobaoItem(legacy.rawItem, itemId);
await syncLegacyTaobaoDetailToPlanningDb({
config,
requestedItemId: itemId,
rawItem: legacy.rawItem,
rawResponse: legacy.rawResponse,
normalizedItem,
}).catch((error) => {
console.warn("[ProductPlanning][MarketplaceItems] sync fallback detail failed:", error);
});
return normalizedItem;
}
export async function fetchProductPlanningJdItem(
itemId: string,
config: ProductPlanningApiConfig
) {
const apiKey = config.taobaoApiKey;
const apiSecret = config.taobaoApiSecret;
if (!apiKey || !apiSecret) {
throw new Error("缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置");
}
const apiUrl =
`${config.oneboundBaseUrl}/jd/item_get_app/?key=${encodeURIComponent(apiKey)}` +
`&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`;
const descApiUrl =
`${config.oneboundBaseUrl}/jd/item_get_desc/?key=${encodeURIComponent(apiKey)}` +
`&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`;
const [data, descData] = await Promise.all([
fetchJson(apiUrl, "京东商品详情接口"),
fetchJson(descApiUrl, "京东商品详情描述接口").catch(() => null),
]);
if (data?.error_code && data.error_code !== "0000") {
throw new Error(data.error || data.reason || "京东商品详情接口返回错误");
}
if (!data?.item) {
throw new Error("未找到商品信息");
}
const descHtml = String(descData?.item?.desc || "").trim();
return normalizeJdItem(
{
...data.item,
desc: descHtml || data.item.desc,
},
itemId
);
}
export async function fetchProductPlanningMonthSale(
itemId: string,
config: ProductPlanningApiConfig
) {
const saleToken = config.saleToken;
if (!saleToken) return null;
const apiUrl =
`${config.saleApiUrl}?itemId=${encodeURIComponent(itemId)}` +
`&token=${encodeURIComponent(saleToken)}&v=3.0`;
const data = await fetchJson(apiUrl, "月销量接口");
return extractMonthSale(data);
}
export async function fetchProductPlanningMarketplaceItem(
reference: ProductPlanningMarketplaceItemRef,
config: ProductPlanningApiConfig,
options: { includeMonthSale?: boolean } = {}
): Promise<ProductPlanningTaobaoItem> {
const item =
reference.platform === "jd"
? await fetchProductPlanningJdItem(reference.itemId, config)
: await fetchProductPlanningTaobaoItem(reference.itemId, config);
if (reference.platform === "taobao" && options.includeMonthSale) {
const recentSales = await fetchProductPlanningMonthSale(reference.itemId, config).catch((error) => {
console.warn("[ProductPlanning][MarketplaceItems] month sale failed:", error);
return null;
});
if (typeof recentSales === "number") {
item.recentSales = recentSales;
}
}
if (reference.platform === "jd") {
item.recentSales = item.recentSales ?? item.totalSales;
}
return item;
}
export async function fetchProductPlanningMarketplaceItemsBatch(
references: ProductPlanningMarketplaceItemRef[],
config: ProductPlanningApiConfig,
options: { includeMonthSale?: boolean } = {}
) {
const results: ProductPlanningMarketplaceItemsResult[] = new Array(references.length);
let cursor = 0;
async function worker() {
while (cursor < references.length) {
const index = cursor;
cursor += 1;
const reference = references[index];
try {
const item = await fetchProductPlanningMarketplaceItem(reference, config, options);
results[index] = {
...reference,
success: true,
item,
};
} catch (error) {
results[index] = {
...reference,
success: false,
error: error instanceof Error ? error.message : "获取商品详情失败",
};
}
}
}
await Promise.all(
Array.from({ length: Math.min(BATCH_CONCURRENCY, references.length) }, () => worker())
);
return results;
}