| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- #!/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;
- });
|