jd-import-products.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. #!/usr/bin/env node
  2. import crypto from 'node:crypto';
  3. const args = new Set(process.argv.slice(2));
  4. const valueArg = (name, fallback = '') => {
  5. const prefix = `${name}=`;
  6. const inline = process.argv.find((value) => value.startsWith(prefix));
  7. if (inline) return inline.slice(prefix.length);
  8. const index = process.argv.indexOf(name);
  9. return index >= 0 ? process.argv[index + 1] || fallback : fallback;
  10. };
  11. const config = {
  12. sourceParseUrl: process.env.JD_SOURCE_PARSE_URL || 'https://server.fmode.cn/parse',
  13. sourceParseAppId: process.env.JD_SOURCE_PARSE_APP_ID || '',
  14. sourceParseMasterKey: process.env.JD_SOURCE_PARSE_MASTER_KEY || '',
  15. targetParseUrl: process.env.JD_TARGET_PARSE_URL || '',
  16. targetParseAppId: process.env.JD_TARGET_PARSE_APP_ID || '',
  17. targetParseMasterKey: process.env.JD_TARGET_PARSE_MASTER_KEY || '',
  18. jdAppKey: process.env.JD_APP_KEY || '',
  19. jdAppSecret: process.env.JD_APP_SECRET || '',
  20. limit: Math.min(20, Math.max(1, Number(valueArg('--limit', '10')) || 10)),
  21. timeoutMs: Number(process.env.JD_IMPORT_TIMEOUT_MS || 30000),
  22. parseRetryAttempts: Math.min(10, Math.max(1, Number(process.env.JD_PARSE_RETRY_ATTEMPTS || 8))),
  23. parseRetryDelayMs: Math.min(5000, Math.max(100, Number(process.env.JD_PARSE_RETRY_DELAY_MS || 350))),
  24. };
  25. const dryRun = args.has('--dry-run');
  26. function requireConfig(keys) {
  27. const missing = keys.filter((key) => !config[key]);
  28. if (missing.length) throw new Error(`缺少环境变量: ${missing.join(', ')}`);
  29. }
  30. function cleanBaseUrl(value) {
  31. return String(value || '').replace(/\/+$/, '');
  32. }
  33. function parseHeaders(appId, masterKey) {
  34. return {
  35. 'X-Parse-Application-Id': appId,
  36. 'X-Parse-Master-Key': masterKey,
  37. };
  38. }
  39. async function requestJson(url, init = {}) {
  40. const isParseEndpoint = (() => {
  41. try {
  42. const parsed = new URL(url);
  43. return parsed.hostname === 'server.fmode.cn' &&
  44. (parsed.pathname.startsWith('/parse/') || parsed.pathname.startsWith('/backend/'));
  45. } catch {
  46. return false;
  47. }
  48. })();
  49. const method = String(init.method || 'GET').toUpperCase();
  50. const retryableMethod = ['GET', 'PUT', 'DELETE'].includes(method);
  51. for (let attempt = 1; attempt <= (isParseEndpoint ? config.parseRetryAttempts : 1); attempt += 1) {
  52. const controller = new AbortController();
  53. const timer = setTimeout(() => controller.abort(), config.timeoutMs);
  54. try {
  55. const response = await fetch(url, { ...init, signal: controller.signal });
  56. const text = await response.text();
  57. let body;
  58. try {
  59. body = text ? JSON.parse(text) : null;
  60. } catch {
  61. body = { raw: text.slice(0, 500) };
  62. }
  63. const transientParseFailure = isParseEndpoint && (
  64. (response.status === 403 && body?.error === 'unauthorized') ||
  65. (retryableMethod && response.status >= 500)
  66. );
  67. if (transientParseFailure && attempt < config.parseRetryAttempts) {
  68. await new Promise((resolve) => setTimeout(resolve, config.parseRetryDelayMs * attempt));
  69. continue;
  70. }
  71. if (!response.ok) {
  72. const detail = body?.error || body?.message || body?.errorList?.[0]?.message || response.statusText;
  73. throw new Error(`HTTP ${response.status} ${url}: ${detail}`);
  74. }
  75. return body;
  76. } finally {
  77. clearTimeout(timer);
  78. }
  79. }
  80. throw new Error(`请求重试耗尽: ${url}`);
  81. }
  82. function parseQueryUrl(baseUrl, className, where, extra = {}) {
  83. const params = new URLSearchParams(extra);
  84. if (where && Object.keys(where).length) params.set('where', JSON.stringify(where));
  85. return `${cleanBaseUrl(baseUrl)}/classes/${encodeURIComponent(className)}?${params}`;
  86. }
  87. async function parseFind(parseConfig, className, where, extra = {}) {
  88. const body = await requestJson(parseQueryUrl(parseConfig.url, className, where, extra), {
  89. headers: parseHeaders(parseConfig.appId, parseConfig.masterKey),
  90. });
  91. return body?.results || [];
  92. }
  93. async function parseUpsert(parseConfig, className, data, uniqueKey) {
  94. const existing = await parseFind(parseConfig, className, { [uniqueKey]: data[uniqueKey] }, { limit: '1' });
  95. const headers = { ...parseHeaders(parseConfig.appId, parseConfig.masterKey), 'Content-Type': 'application/json' };
  96. if (existing[0]?.objectId) {
  97. return requestJson(`${cleanBaseUrl(parseConfig.url)}/classes/${encodeURIComponent(className)}/${existing[0].objectId}`, {
  98. method: 'PUT',
  99. headers,
  100. body: JSON.stringify(data),
  101. });
  102. }
  103. return requestJson(`${cleanBaseUrl(parseConfig.url)}/classes/${encodeURIComponent(className)}`, {
  104. method: 'POST',
  105. headers,
  106. body: JSON.stringify(data),
  107. });
  108. }
  109. function jdSignature(params) {
  110. const plain = Object.keys(params)
  111. .sort()
  112. .map((key) => `${key}${params[key] ?? ''}`)
  113. .join('');
  114. return crypto.createHash('md5')
  115. .update(`${config.jdAppSecret}${plain}${config.jdAppSecret}`)
  116. .digest('hex')
  117. .toUpperCase();
  118. }
  119. function jdQuery(params) {
  120. const query = new URLSearchParams();
  121. for (const [key, value] of Object.entries(params)) {
  122. query.set(key, Array.isArray(value) ? JSON.stringify(value) : String(value));
  123. }
  124. return query;
  125. }
  126. function pickFirst(...values) {
  127. return values.find((value) => value !== undefined && value !== null && String(value).trim() !== '') ?? '';
  128. }
  129. function stringValue(value) {
  130. return value === undefined || value === null ? '' : String(value);
  131. }
  132. function flattenText(value) {
  133. if (!value) return '';
  134. if (typeof value === 'string' || typeof value === 'number') return String(value);
  135. if (Array.isArray(value)) return value.map(flattenText).filter(Boolean).join(' ');
  136. if (typeof value === 'object') return Object.values(value).map(flattenText).filter(Boolean).join(' ');
  137. return '';
  138. }
  139. function extractId(row, ...keys) {
  140. return stringValue(pickFirst(...keys.map((key) => row?.[key])));
  141. }
  142. function extractImage(row) {
  143. return stringValue(pickFirst(
  144. row?.imageUrl,
  145. row?.imgUrl,
  146. row?.logo,
  147. row?.productFrontDetailDTO?.imageUrl,
  148. row?.productFrontDetailDTO?.logo,
  149. row?.productFrontDetailDTO?.image,
  150. ));
  151. }
  152. function extractBrand(row) {
  153. return stringValue(pickFirst(
  154. row?.brand,
  155. row?.brandName,
  156. row?.brandDTO?.brandName,
  157. row?.brandDTO?.name,
  158. row?.brandDTO?.brandCnName,
  159. ));
  160. }
  161. function extractCategories(row) {
  162. const source = row?.categoryDTO || row?.category || {};
  163. const values = [
  164. source.category1Name, source.category2Name, source.category3Name,
  165. source.firstCategoryName, source.secondCategoryName, source.thirdCategoryName,
  166. source.name1, source.name2, source.name3,
  167. ].filter(Boolean).map(String);
  168. if (values.length) return [...new Set(values)];
  169. if (Array.isArray(source)) return source.map(flattenText).filter(Boolean);
  170. return [];
  171. }
  172. function extractPrice(row) {
  173. const price = row?.priceDTO || row?.price || {};
  174. const value = pickFirst(price.jdPrice, price.price, price.salePrice, row?.jdPrice, row?.price);
  175. const number = Number(value);
  176. return Number.isFinite(number) ? number : null;
  177. }
  178. function extractProductStatus(...rows) {
  179. for (const row of rows) {
  180. const value = pickFirst(row?.productStatusNew, row?.productStatus, row?.saleState, row?.status);
  181. if (value === undefined || value === null || value === '') continue;
  182. if (typeof value !== 'object') return stringValue(value);
  183. return stringValue(pickFirst(
  184. value.code,
  185. value.status,
  186. value.value,
  187. value.name,
  188. value.label,
  189. flattenText(value),
  190. ));
  191. }
  192. return '';
  193. }
  194. function extractProductId(row) {
  195. return extractId(row, 'productId', 'wareId', 'id');
  196. }
  197. function extractSkuId(row) {
  198. return extractId(row, 'skuId', 'id');
  199. }
  200. function normalizeProduct(row, detail, shopId, collectedAt) {
  201. const productId = extractProductId(row) || extractProductId(detail?.productInfo || detail?.data || {});
  202. const info = detail?.productInfo || detail?.data || {};
  203. const title = stringValue(pickFirst(row?.productName, info?.productTitle?.title, info?.productName));
  204. const rowCategories = extractCategories(row);
  205. const categories = rowCategories.length ? rowCategories : extractCategories(info);
  206. const imageUrl = extractImage(row) || extractImage(info);
  207. const detailPayload = detail && Object.keys(detail).length ? detail : null;
  208. const businessKey = `jd:${shopId}:${productId}`;
  209. return {
  210. platform: 'jd',
  211. source: 'jd-sp-api',
  212. role: 'own',
  213. shopId: String(shopId),
  214. sellerId: String(shopId),
  215. productId,
  216. productKey: businessKey,
  217. jdProductKey: businessKey,
  218. asin: productId,
  219. title,
  220. itemName: title,
  221. brand: extractBrand(row) || extractBrand(info),
  222. category: categories.join(' / '),
  223. category1: categories[0] || '',
  224. category2: categories[1] || '',
  225. category3: categories[2] || '',
  226. imageUrl,
  227. images: imageUrl ? [imageUrl] : [],
  228. price: extractPrice(row) ?? extractPrice(info),
  229. itemStatus: extractProductStatus(row, info),
  230. detailStatus: detailPayload ? 'available' : 'empty',
  231. rawJdProduct: row,
  232. rawJdDetail: detailPayload,
  233. syncedAt: collectedAt,
  234. collectedAt,
  235. };
  236. }
  237. async function jdGet(path, query, token, pathParams = {}) {
  238. const timestamp = String(Date.now());
  239. const headers = {
  240. 'X-JOS-App-Key': config.jdAppKey,
  241. 'X-JOS-Access-Token': token,
  242. 'X-JOS-Timestamp': timestamp,
  243. 'X-JOS-Sign-Method': 'md5',
  244. 'X-JOS-Request-Identity': 'vender',
  245. };
  246. headers['X-JOS-Sign'] = jdSignature({
  247. ...query,
  248. ...pathParams,
  249. 'X-JOS-App-Key': config.jdAppKey,
  250. 'X-JOS-Access-Token': token,
  251. 'X-JOS-Timestamp': timestamp,
  252. });
  253. const encoded = jdQuery(query).toString();
  254. const body = await requestJson(`https://api-cn.jd.com/rest${path}${encoded ? `?${encoded}` : ''}`, { headers });
  255. if (body?.success === false) {
  256. const error = body.errorList?.[0] || {};
  257. throw new Error(`京东接口失败 ${error.code || ''}: ${error.message || error.details || 'unknown error'}`);
  258. }
  259. return body;
  260. }
  261. async function loadAuthorization() {
  262. const rows = await parseFind(
  263. { url: config.sourceParseUrl, appId: config.sourceParseAppId, masterKey: config.sourceParseMasterKey },
  264. 'EcomAuth',
  265. { platform: 'jd', type: 'access_token' },
  266. { limit: '1', order: '-createdAt' },
  267. );
  268. const row = rows[0];
  269. const token = row?.data?.access_token;
  270. const shopId = stringValue(pickFirst(row?.shop_id, row?.data?.uid));
  271. if (!token || !shopId) throw new Error('主 Parse 中没有可用的京东授权记录');
  272. return { token, shopId };
  273. }
  274. async function loadBatch(auth) {
  275. const productsResponse = await jdGet('/sp-product/v0/products', {
  276. scopeSet: 'productName',
  277. pageSize: config.limit,
  278. page: 1,
  279. }, auth.token);
  280. const products = productsResponse.data || [];
  281. const collectedAt = new Date().toISOString();
  282. const batch = [];
  283. for (const row of products) {
  284. const productId = extractProductId(row);
  285. if (!productId) continue;
  286. let skus = [];
  287. try {
  288. const skuResponse = await jdGet('/sp-product/v0/skus', {
  289. scopeSet: 'skuName',
  290. productIdList: productId,
  291. pageSize: 50,
  292. page: 1,
  293. }, auth.token);
  294. skus = skuResponse.data || [];
  295. } catch (error) {
  296. console.warn(`[jd-import] SKU 查询跳过 ${productId}: ${error.message}`);
  297. }
  298. const stocks = [];
  299. for (const sku of skus) {
  300. const skuId = extractSkuId(sku);
  301. if (!skuId) continue;
  302. try {
  303. const stockResponse = await jdGet('/sp-product/v0/sku-stocks', { skuIdList: skuId }, auth.token);
  304. stocks.push(...(stockResponse.data || []));
  305. } catch (error) {
  306. console.warn(`[jd-import] 库存查询跳过 ${skuId}: ${error.message}`);
  307. }
  308. }
  309. let detail = null;
  310. try {
  311. // JD SP-API requires the path parameter to participate in the signature,
  312. // even though productId is not repeated in the query string.
  313. const detailResponse = await jdGet(
  314. `/sp-product/v0/products/${productId}`,
  315. { scene: 'pop' },
  316. auth.token,
  317. { productId },
  318. );
  319. detail = detailResponse?.data || detailResponse || null;
  320. } catch (error) {
  321. console.warn(`[jd-import] 详情查询跳过 ${productId}: ${error.message}`);
  322. }
  323. batch.push({ row, detail, skus, stocks, collectedAt });
  324. }
  325. return { ...auth, products: batch, total: productsResponse.paginationData?.totalItems ?? null };
  326. }
  327. function buildRecords(batch) {
  328. const productDetails = [];
  329. const products = [];
  330. const mappings = [];
  331. const stockSnapshots = [];
  332. for (const item of batch.products) {
  333. const product = normalizeProduct(item.row, item.detail, batch.shopId, item.collectedAt);
  334. productDetails.push(product);
  335. const stockTotal = item.stocks.reduce((total, stock) => {
  336. const value = Number(stock.stockNum ?? 0);
  337. return total + (Number.isFinite(value) ? value : 0);
  338. }, 0);
  339. products.push({
  340. ...product,
  341. platform: ['jd'],
  342. externalId: product.productId,
  343. productCode: product.productId,
  344. productName: product.title,
  345. description: product.title,
  346. primaryCategory: product.category1 || product.category,
  347. secondaryCategory: product.category2,
  348. productSubcategory: product.category3,
  349. tags: ['京东', '本店'],
  350. status: stringValue(product.itemStatus || 'active'),
  351. stock: stockTotal,
  352. pricing: {
  353. currency: 'CNY',
  354. salePrice: product.price,
  355. },
  356. lastSyncAt: { __type: 'Date', iso: item.collectedAt },
  357. priceAmount: product.price,
  358. currency: 'CNY',
  359. productSource: 'jd-sp-api',
  360. raw: product.rawJdProduct,
  361. });
  362. for (const sku of item.skus) {
  363. const skuId = extractSkuId(sku);
  364. if (!skuId) continue;
  365. const mappingKey = `jd:${batch.shopId}:${skuId}`;
  366. mappings.push({
  367. platform: 'jd',
  368. source: 'jd-sp-api',
  369. asin: product.productId,
  370. jdProductId: product.productId,
  371. jdSkuId: skuId,
  372. sellerSku: skuId,
  373. productSku: skuId,
  374. itemName: stringValue(sku.skuName),
  375. brand: extractBrand(sku),
  376. price: extractPrice(sku),
  377. listingPrice: extractPrice(sku),
  378. itemStatus: pickFirst(sku.skuStatus, sku.valid, null),
  379. site: 'jd',
  380. shopId: batch.shopId,
  381. jdSkuKey: mappingKey,
  382. rawJdSku: sku,
  383. syncedAt: item.collectedAt,
  384. });
  385. }
  386. for (const stock of item.stocks) {
  387. const skuId = extractSkuId(stock);
  388. if (!skuId) continue;
  389. stockSnapshots.push({
  390. platform: 'jd',
  391. source: 'jd-sp-api',
  392. shopId: batch.shopId,
  393. productId: product.productId,
  394. skuId,
  395. stockKey: `jd:${batch.shopId}:${skuId}:${item.collectedAt.slice(0, 10)}`,
  396. stockNum: Number(stock.stockNum ?? 0),
  397. orderBookingNum: Number(stock.orderBookingNum ?? 0),
  398. unpaidBookingNum: Number(stock.unpaidBookingNum ?? 0),
  399. reserveNum: Number(stock.reserveNum ?? 0),
  400. warehouseId: stringValue(stock.warehouseId),
  401. rawJdStock: stock,
  402. collectedAt: item.collectedAt,
  403. });
  404. }
  405. }
  406. return { productDetails, products, mappings, stockSnapshots };
  407. }
  408. async function main() {
  409. requireConfig(['sourceParseAppId', 'sourceParseMasterKey', 'jdAppKey', 'jdAppSecret']);
  410. if (!dryRun) requireConfig(['targetParseUrl', 'targetParseAppId', 'targetParseMasterKey']);
  411. const auth = await loadAuthorization();
  412. const batch = await loadBatch(auth);
  413. const records = buildRecords(batch);
  414. const summary = {
  415. shopId: batch.shopId,
  416. totalProducts: batch.total,
  417. products: records.productDetails.length,
  418. skuMappings: records.mappings.length,
  419. stockSnapshots: records.stockSnapshots.length,
  420. detailAvailable: records.productDetails.filter((row) => row.detailStatus === 'available').length,
  421. productIds: records.productDetails.map((row) => row.productId),
  422. };
  423. if (dryRun) {
  424. console.log(JSON.stringify({ mode: 'dry-run', ...summary }, null, 2));
  425. return;
  426. }
  427. const target = {
  428. url: config.targetParseUrl,
  429. appId: config.targetParseAppId,
  430. masterKey: config.targetParseMasterKey,
  431. };
  432. for (const row of records.productDetails) await parseUpsert(target, 'ProductDetail', row, 'jdProductKey');
  433. for (const row of records.products) await parseUpsert(target, 'Product', row, 'jdProductKey');
  434. for (const row of records.mappings) await parseUpsert(target, 'AsinSkuMapping', row, 'jdSkuKey');
  435. for (const row of records.stockSnapshots) await parseUpsert(target, 'JdStockSnapshot', row, 'stockKey');
  436. console.log(JSON.stringify({ mode: 'imported', ...summary }, null, 2));
  437. }
  438. main().catch((error) => {
  439. console.error(`[jd-import] ${error.message}`);
  440. process.exitCode = 1;
  441. });