|
|
@@ -22,6 +22,8 @@ const config = {
|
|
|
jdAppSecret: process.env.JD_APP_SECRET || '',
|
|
|
limit: Math.min(20, Math.max(1, Number(valueArg('--limit', '10')) || 10)),
|
|
|
timeoutMs: Number(process.env.JD_IMPORT_TIMEOUT_MS || 30000),
|
|
|
+ parseRetryAttempts: Math.min(10, Math.max(1, Number(process.env.JD_PARSE_RETRY_ATTEMPTS || 8))),
|
|
|
+ parseRetryDelayMs: Math.min(5000, Math.max(100, Number(process.env.JD_PARSE_RETRY_DELAY_MS || 350))),
|
|
|
};
|
|
|
|
|
|
const dryRun = args.has('--dry-run');
|
|
|
@@ -43,25 +45,49 @@ function parseHeaders(appId, masterKey) {
|
|
|
}
|
|
|
|
|
|
async function requestJson(url, init = {}) {
|
|
|
- const controller = new AbortController();
|
|
|
- const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
|
- try {
|
|
|
- const response = await fetch(url, { ...init, signal: controller.signal });
|
|
|
- const text = await response.text();
|
|
|
- let body;
|
|
|
+ const isParseEndpoint = (() => {
|
|
|
try {
|
|
|
- body = text ? JSON.parse(text) : null;
|
|
|
+ const parsed = new URL(url);
|
|
|
+ return parsed.hostname === 'server.fmode.cn' &&
|
|
|
+ (parsed.pathname.startsWith('/parse/') || parsed.pathname.startsWith('/backend/'));
|
|
|
} catch {
|
|
|
- body = { raw: text.slice(0, 500) };
|
|
|
+ return false;
|
|
|
}
|
|
|
- if (!response.ok) {
|
|
|
- const detail = body?.error || body?.message || body?.errorList?.[0]?.message || response.statusText;
|
|
|
- throw new Error(`HTTP ${response.status} ${url}: ${detail}`);
|
|
|
+ })();
|
|
|
+ const method = String(init.method || 'GET').toUpperCase();
|
|
|
+ const retryableMethod = ['GET', 'PUT', 'DELETE'].includes(method);
|
|
|
+
|
|
|
+ for (let attempt = 1; attempt <= (isParseEndpoint ? config.parseRetryAttempts : 1); attempt += 1) {
|
|
|
+ const controller = new AbortController();
|
|
|
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
|
+ try {
|
|
|
+ const response = await fetch(url, { ...init, signal: controller.signal });
|
|
|
+ const text = await response.text();
|
|
|
+ let body;
|
|
|
+ try {
|
|
|
+ body = text ? JSON.parse(text) : null;
|
|
|
+ } catch {
|
|
|
+ body = { raw: text.slice(0, 500) };
|
|
|
+ }
|
|
|
+ const transientParseFailure = isParseEndpoint && (
|
|
|
+ (response.status === 403 && body?.error === 'unauthorized') ||
|
|
|
+ (retryableMethod && response.status >= 500)
|
|
|
+ );
|
|
|
+ if (transientParseFailure && attempt < config.parseRetryAttempts) {
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, config.parseRetryDelayMs * attempt));
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (!response.ok) {
|
|
|
+ const detail = body?.error || body?.message || body?.errorList?.[0]?.message || response.statusText;
|
|
|
+ throw new Error(`HTTP ${response.status} ${url}: ${detail}`);
|
|
|
+ }
|
|
|
+ return body;
|
|
|
+ } finally {
|
|
|
+ clearTimeout(timer);
|
|
|
}
|
|
|
- return body;
|
|
|
- } finally {
|
|
|
- clearTimeout(timer);
|
|
|
}
|
|
|
+
|
|
|
+ throw new Error(`请求重试耗尽: ${url}`);
|
|
|
}
|
|
|
|
|
|
function parseQueryUrl(baseUrl, className, where, extra = {}) {
|
|
|
@@ -313,8 +339,28 @@ function buildRecords(batch) {
|
|
|
for (const item of batch.products) {
|
|
|
const product = normalizeProduct(item.row, item.detail, batch.shopId, item.collectedAt);
|
|
|
productDetails.push(product);
|
|
|
+ const stockTotal = item.stocks.reduce((total, stock) => {
|
|
|
+ const value = Number(stock.stockNum ?? 0);
|
|
|
+ return total + (Number.isFinite(value) ? value : 0);
|
|
|
+ }, 0);
|
|
|
products.push({
|
|
|
...product,
|
|
|
+ platform: ['jd'],
|
|
|
+ externalId: product.productId,
|
|
|
+ productCode: product.productId,
|
|
|
+ productName: product.title,
|
|
|
+ description: product.title,
|
|
|
+ primaryCategory: product.category1 || product.category,
|
|
|
+ secondaryCategory: product.category2,
|
|
|
+ productSubcategory: product.category3,
|
|
|
+ tags: ['京东', '本店'],
|
|
|
+ status: stringValue(product.itemStatus || 'active'),
|
|
|
+ stock: stockTotal,
|
|
|
+ pricing: {
|
|
|
+ currency: 'CNY',
|
|
|
+ salePrice: product.price,
|
|
|
+ },
|
|
|
+ lastSyncAt: { __type: 'Date', iso: item.collectedAt },
|
|
|
priceAmount: product.price,
|
|
|
currency: 'CNY',
|
|
|
productSource: 'jd-sp-api',
|