#!/usr/bin/env node import crypto from 'node:crypto'; const args = new Set(process.argv.slice(2)); const valueArg = (name, fallback = '') => { const prefix = `${name}=`; const inline = process.argv.find((value) => value.startsWith(prefix)); if (inline) return inline.slice(prefix.length); const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] || fallback : fallback; }; const config = { sourceParseUrl: process.env.JD_SOURCE_PARSE_URL || 'https://server.fmode.cn/parse', sourceParseAppId: process.env.JD_SOURCE_PARSE_APP_ID || '', sourceParseMasterKey: process.env.JD_SOURCE_PARSE_MASTER_KEY || '', targetParseUrl: process.env.JD_TARGET_PARSE_URL || '', targetParseAppId: process.env.JD_TARGET_PARSE_APP_ID || '', targetParseMasterKey: process.env.JD_TARGET_PARSE_MASTER_KEY || '', jdAppKey: process.env.JD_APP_KEY || '', 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'); function requireConfig(keys) { const missing = keys.filter((key) => !config[key]); if (missing.length) throw new Error(`缺少环境变量: ${missing.join(', ')}`); } function cleanBaseUrl(value) { return String(value || '').replace(/\/+$/, ''); } function parseHeaders(appId, masterKey) { return { 'X-Parse-Application-Id': appId, 'X-Parse-Master-Key': masterKey, }; } async function requestJson(url, init = {}) { const isParseEndpoint = (() => { try { const parsed = new URL(url); return parsed.hostname === 'server.fmode.cn' && (parsed.pathname.startsWith('/parse/') || parsed.pathname.startsWith('/backend/')); } catch { return false; } })(); 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); } } throw new Error(`请求重试耗尽: ${url}`); } function parseQueryUrl(baseUrl, className, where, extra = {}) { const params = new URLSearchParams(extra); if (where && Object.keys(where).length) params.set('where', JSON.stringify(where)); return `${cleanBaseUrl(baseUrl)}/classes/${encodeURIComponent(className)}?${params}`; } async function parseFind(parseConfig, className, where, extra = {}) { const body = await requestJson(parseQueryUrl(parseConfig.url, className, where, extra), { headers: parseHeaders(parseConfig.appId, parseConfig.masterKey), }); return body?.results || []; } async function parseUpsert(parseConfig, className, data, uniqueKey) { const existing = await parseFind(parseConfig, className, { [uniqueKey]: data[uniqueKey] }, { limit: '1' }); const headers = { ...parseHeaders(parseConfig.appId, parseConfig.masterKey), 'Content-Type': 'application/json' }; if (existing[0]?.objectId) { return requestJson(`${cleanBaseUrl(parseConfig.url)}/classes/${encodeURIComponent(className)}/${existing[0].objectId}`, { method: 'PUT', headers, body: JSON.stringify(data), }); } return requestJson(`${cleanBaseUrl(parseConfig.url)}/classes/${encodeURIComponent(className)}`, { method: 'POST', headers, body: JSON.stringify(data), }); } function jdSignature(params) { const plain = Object.keys(params) .sort() .map((key) => `${key}${params[key] ?? ''}`) .join(''); return crypto.createHash('md5') .update(`${config.jdAppSecret}${plain}${config.jdAppSecret}`) .digest('hex') .toUpperCase(); } function jdQuery(params) { const query = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { query.set(key, Array.isArray(value) ? JSON.stringify(value) : String(value)); } return query; } function pickFirst(...values) { return values.find((value) => value !== undefined && value !== null && String(value).trim() !== '') ?? ''; } function stringValue(value) { return value === undefined || value === null ? '' : String(value); } function flattenText(value) { if (!value) return ''; if (typeof value === 'string' || typeof value === 'number') return String(value); if (Array.isArray(value)) return value.map(flattenText).filter(Boolean).join(' '); if (typeof value === 'object') return Object.values(value).map(flattenText).filter(Boolean).join(' '); return ''; } function extractId(row, ...keys) { return stringValue(pickFirst(...keys.map((key) => row?.[key]))); } function extractImage(row) { return stringValue(pickFirst( row?.imageUrl, row?.imgUrl, row?.logo, row?.productFrontDetailDTO?.imageUrl, row?.productFrontDetailDTO?.logo, row?.productFrontDetailDTO?.image, )); } function extractBrand(row) { return stringValue(pickFirst( row?.brand, row?.brandName, row?.brandDTO?.brandName, row?.brandDTO?.name, row?.brandDTO?.brandCnName, )); } function extractCategories(row) { const source = row?.categoryDTO || row?.category || {}; const values = [ source.category1Name, source.category2Name, source.category3Name, source.firstCategoryName, source.secondCategoryName, source.thirdCategoryName, source.name1, source.name2, source.name3, ].filter(Boolean).map(String); if (values.length) return [...new Set(values)]; if (Array.isArray(source)) return source.map(flattenText).filter(Boolean); return []; } function extractPrice(row) { const price = row?.priceDTO || row?.price || {}; const value = pickFirst(price.jdPrice, price.price, price.salePrice, row?.jdPrice, row?.price); const number = Number(value); return Number.isFinite(number) ? number : null; } function extractProductStatus(...rows) { for (const row of rows) { const value = pickFirst(row?.productStatusNew, row?.productStatus, row?.saleState, row?.status); if (value === undefined || value === null || value === '') continue; if (typeof value !== 'object') return stringValue(value); return stringValue(pickFirst( value.code, value.status, value.value, value.name, value.label, flattenText(value), )); } return ''; } function extractProductId(row) { return extractId(row, 'productId', 'wareId', 'id'); } function extractSkuId(row) { return extractId(row, 'skuId', 'id'); } function normalizeProduct(row, detail, shopId, collectedAt) { const productId = extractProductId(row) || extractProductId(detail?.productInfo || detail?.data || {}); const info = detail?.productInfo || detail?.data || {}; const title = stringValue(pickFirst(row?.productName, info?.productTitle?.title, info?.productName)); const rowCategories = extractCategories(row); const categories = rowCategories.length ? rowCategories : extractCategories(info); const imageUrl = extractImage(row) || extractImage(info); const detailPayload = detail && Object.keys(detail).length ? detail : null; const businessKey = `jd:${shopId}:${productId}`; return { platform: 'jd', source: 'jd-sp-api', role: 'own', shopId: String(shopId), sellerId: String(shopId), productId, productKey: businessKey, jdProductKey: businessKey, asin: productId, title, itemName: title, brand: extractBrand(row) || extractBrand(info), category: categories.join(' / '), category1: categories[0] || '', category2: categories[1] || '', category3: categories[2] || '', imageUrl, images: imageUrl ? [imageUrl] : [], price: extractPrice(row) ?? extractPrice(info), itemStatus: extractProductStatus(row, info), detailStatus: detailPayload ? 'available' : 'empty', rawJdProduct: row, rawJdDetail: detailPayload, syncedAt: collectedAt, collectedAt, }; } async function jdGet(path, query, token, pathParams = {}) { const timestamp = String(Date.now()); const headers = { 'X-JOS-App-Key': config.jdAppKey, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': timestamp, 'X-JOS-Sign-Method': 'md5', 'X-JOS-Request-Identity': 'vender', }; headers['X-JOS-Sign'] = jdSignature({ ...query, ...pathParams, 'X-JOS-App-Key': config.jdAppKey, 'X-JOS-Access-Token': token, 'X-JOS-Timestamp': timestamp, }); const encoded = jdQuery(query).toString(); const body = await requestJson(`https://api-cn.jd.com/rest${path}${encoded ? `?${encoded}` : ''}`, { headers }); if (body?.success === false) { const error = body.errorList?.[0] || {}; throw new Error(`京东接口失败 ${error.code || ''}: ${error.message || error.details || 'unknown error'}`); } return body; } async function loadAuthorization() { const rows = await parseFind( { url: config.sourceParseUrl, appId: config.sourceParseAppId, masterKey: config.sourceParseMasterKey }, 'EcomAuth', { platform: 'jd', type: 'access_token' }, { limit: '1', order: '-createdAt' }, ); const row = rows[0]; const token = row?.data?.access_token; const shopId = stringValue(pickFirst(row?.shop_id, row?.data?.uid)); if (!token || !shopId) throw new Error('主 Parse 中没有可用的京东授权记录'); return { token, shopId }; } async function loadBatch(auth) { const productsResponse = await jdGet('/sp-product/v0/products', { scopeSet: 'productName', pageSize: config.limit, page: 1, }, auth.token); const products = productsResponse.data || []; const collectedAt = new Date().toISOString(); const batch = []; for (const row of products) { const productId = extractProductId(row); if (!productId) continue; let skus = []; try { const skuResponse = await jdGet('/sp-product/v0/skus', { scopeSet: 'skuName', productIdList: productId, pageSize: 50, page: 1, }, auth.token); skus = skuResponse.data || []; } catch (error) { console.warn(`[jd-import] SKU 查询跳过 ${productId}: ${error.message}`); } const stocks = []; for (const sku of skus) { const skuId = extractSkuId(sku); if (!skuId) continue; try { const stockResponse = await jdGet('/sp-product/v0/sku-stocks', { skuIdList: skuId }, auth.token); stocks.push(...(stockResponse.data || [])); } catch (error) { console.warn(`[jd-import] 库存查询跳过 ${skuId}: ${error.message}`); } } let detail = null; try { // JD SP-API requires the path parameter to participate in the signature, // even though productId is not repeated in the query string. const detailResponse = await jdGet( `/sp-product/v0/products/${productId}`, { scene: 'pop' }, auth.token, { productId }, ); detail = detailResponse?.data || detailResponse || null; } catch (error) { console.warn(`[jd-import] 详情查询跳过 ${productId}: ${error.message}`); } batch.push({ row, detail, skus, stocks, collectedAt }); } return { ...auth, products: batch, total: productsResponse.paginationData?.totalItems ?? null }; } function buildRecords(batch) { const productDetails = []; const products = []; const mappings = []; const stockSnapshots = []; 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', raw: product.rawJdProduct, }); for (const sku of item.skus) { const skuId = extractSkuId(sku); if (!skuId) continue; const mappingKey = `jd:${batch.shopId}:${skuId}`; mappings.push({ platform: 'jd', source: 'jd-sp-api', asin: product.productId, jdProductId: product.productId, jdSkuId: skuId, sellerSku: skuId, productSku: skuId, itemName: stringValue(sku.skuName), brand: extractBrand(sku), price: extractPrice(sku), listingPrice: extractPrice(sku), itemStatus: pickFirst(sku.skuStatus, sku.valid, null), site: 'jd', shopId: batch.shopId, jdSkuKey: mappingKey, rawJdSku: sku, syncedAt: item.collectedAt, }); } for (const stock of item.stocks) { const skuId = extractSkuId(stock); if (!skuId) continue; stockSnapshots.push({ platform: 'jd', source: 'jd-sp-api', shopId: batch.shopId, productId: product.productId, skuId, stockKey: `jd:${batch.shopId}:${skuId}:${item.collectedAt.slice(0, 10)}`, stockNum: Number(stock.stockNum ?? 0), orderBookingNum: Number(stock.orderBookingNum ?? 0), unpaidBookingNum: Number(stock.unpaidBookingNum ?? 0), reserveNum: Number(stock.reserveNum ?? 0), warehouseId: stringValue(stock.warehouseId), rawJdStock: stock, collectedAt: item.collectedAt, }); } } return { productDetails, products, mappings, stockSnapshots }; } async function main() { requireConfig(['sourceParseAppId', 'sourceParseMasterKey', 'jdAppKey', 'jdAppSecret']); if (!dryRun) requireConfig(['targetParseUrl', 'targetParseAppId', 'targetParseMasterKey']); const auth = await loadAuthorization(); const batch = await loadBatch(auth); const records = buildRecords(batch); const summary = { shopId: batch.shopId, totalProducts: batch.total, products: records.productDetails.length, skuMappings: records.mappings.length, stockSnapshots: records.stockSnapshots.length, detailAvailable: records.productDetails.filter((row) => row.detailStatus === 'available').length, productIds: records.productDetails.map((row) => row.productId), }; if (dryRun) { console.log(JSON.stringify({ mode: 'dry-run', ...summary }, null, 2)); return; } const target = { url: config.targetParseUrl, appId: config.targetParseAppId, masterKey: config.targetParseMasterKey, }; for (const row of records.productDetails) await parseUpsert(target, 'ProductDetail', row, 'jdProductKey'); for (const row of records.products) await parseUpsert(target, 'Product', row, 'jdProductKey'); for (const row of records.mappings) await parseUpsert(target, 'AsinSkuMapping', row, 'jdSkuKey'); for (const row of records.stockSnapshots) await parseUpsert(target, 'JdStockSnapshot', row, 'stockKey'); console.log(JSON.stringify({ mode: 'imported', ...summary }, null, 2)); } main().catch((error) => { console.error(`[jd-import] ${error.message}`); process.exitCode = 1; });