211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
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;
|
||
}
|