jd-import-products.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  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 extractProductId(row) {
  179. return extractId(row, 'productId', 'wareId', 'id');
  180. }
  181. function extractSkuId(row) {
  182. return extractId(row, 'skuId', 'id');
  183. }
  184. function normalizeProduct(row, detail, shopId, collectedAt) {
  185. const productId = extractProductId(row) || extractProductId(detail?.productInfo || detail?.data || {});
  186. const info = detail?.productInfo || detail?.data || {};
  187. const title = stringValue(pickFirst(row?.productName, info?.productTitle?.title, info?.productName));
  188. const rowCategories = extractCategories(row);
  189. const categories = rowCategories.length ? rowCategories : extractCategories(info);
  190. const imageUrl = extractImage(row) || extractImage(info);
  191. const detailPayload = detail && Object.keys(detail).length ? detail : null;
  192. const businessKey = `jd:${shopId}:${productId}`;
  193. return {
  194. platform: 'jd',
  195. source: 'jd-sp-api',
  196. role: 'own',
  197. shopId: String(shopId),
  198. sellerId: String(shopId),
  199. productId,
  200. productKey: businessKey,
  201. jdProductKey: businessKey,
  202. asin: productId,
  203. title,
  204. itemName: title,
  205. brand: extractBrand(row) || extractBrand(info),
  206. category: categories.join(' / '),
  207. category1: categories[0] || '',
  208. category2: categories[1] || '',
  209. category3: categories[2] || '',
  210. imageUrl,
  211. images: imageUrl ? [imageUrl] : [],
  212. price: extractPrice(row) ?? extractPrice(info),
  213. itemStatus: pickFirst(row?.productStatus, row?.productStatusNew, info?.productStatus, null),
  214. detailStatus: detailPayload ? 'available' : 'empty',
  215. rawJdProduct: row,
  216. rawJdDetail: detailPayload,
  217. syncedAt: collectedAt,
  218. collectedAt,
  219. };
  220. }
  221. async function jdGet(path, query, token, pathParams = {}) {
  222. const timestamp = String(Date.now());
  223. const headers = {
  224. 'X-JOS-App-Key': config.jdAppKey,
  225. 'X-JOS-Access-Token': token,
  226. 'X-JOS-Timestamp': timestamp,
  227. 'X-JOS-Sign-Method': 'md5',
  228. 'X-JOS-Request-Identity': 'vender',
  229. };
  230. headers['X-JOS-Sign'] = jdSignature({
  231. ...query,
  232. ...pathParams,
  233. 'X-JOS-App-Key': config.jdAppKey,
  234. 'X-JOS-Access-Token': token,
  235. 'X-JOS-Timestamp': timestamp,
  236. });
  237. const encoded = jdQuery(query).toString();
  238. const body = await requestJson(`https://api-cn.jd.com/rest${path}${encoded ? `?${encoded}` : ''}`, { headers });
  239. if (body?.success === false) {
  240. const error = body.errorList?.[0] || {};
  241. throw new Error(`京东接口失败 ${error.code || ''}: ${error.message || error.details || 'unknown error'}`);
  242. }
  243. return body;
  244. }
  245. async function loadAuthorization() {
  246. const rows = await parseFind(
  247. { url: config.sourceParseUrl, appId: config.sourceParseAppId, masterKey: config.sourceParseMasterKey },
  248. 'EcomAuth',
  249. { platform: 'jd', type: 'access_token' },
  250. { limit: '1', order: '-createdAt' },
  251. );
  252. const row = rows[0];
  253. const token = row?.data?.access_token;
  254. const shopId = stringValue(pickFirst(row?.shop_id, row?.data?.uid));
  255. if (!token || !shopId) throw new Error('主 Parse 中没有可用的京东授权记录');
  256. return { token, shopId };
  257. }
  258. async function loadBatch(auth) {
  259. const productsResponse = await jdGet('/sp-product/v0/products', {
  260. scopeSet: 'productName',
  261. pageSize: config.limit,
  262. page: 1,
  263. }, auth.token);
  264. const products = productsResponse.data || [];
  265. const collectedAt = new Date().toISOString();
  266. const batch = [];
  267. for (const row of products) {
  268. const productId = extractProductId(row);
  269. if (!productId) continue;
  270. let skus = [];
  271. try {
  272. const skuResponse = await jdGet('/sp-product/v0/skus', {
  273. scopeSet: 'skuName',
  274. productIdList: productId,
  275. pageSize: 50,
  276. page: 1,
  277. }, auth.token);
  278. skus = skuResponse.data || [];
  279. } catch (error) {
  280. console.warn(`[jd-import] SKU 查询跳过 ${productId}: ${error.message}`);
  281. }
  282. const stocks = [];
  283. for (const sku of skus) {
  284. const skuId = extractSkuId(sku);
  285. if (!skuId) continue;
  286. try {
  287. const stockResponse = await jdGet('/sp-product/v0/sku-stocks', { skuIdList: skuId }, auth.token);
  288. stocks.push(...(stockResponse.data || []));
  289. } catch (error) {
  290. console.warn(`[jd-import] 库存查询跳过 ${skuId}: ${error.message}`);
  291. }
  292. }
  293. let detail = null;
  294. try {
  295. const detailResponse = await jdGet(`/sp-product/v0/products/${productId}`, { scene: 'pop' }, auth.token);
  296. detail = detailResponse?.data || detailResponse || null;
  297. } catch (error) {
  298. console.warn(`[jd-import] 详情查询跳过 ${productId}: ${error.message}`);
  299. }
  300. batch.push({ row, detail, skus, stocks, collectedAt });
  301. }
  302. return { ...auth, products: batch, total: productsResponse.paginationData?.totalItems ?? null };
  303. }
  304. function buildRecords(batch) {
  305. const productDetails = [];
  306. const products = [];
  307. const mappings = [];
  308. const stockSnapshots = [];
  309. for (const item of batch.products) {
  310. const product = normalizeProduct(item.row, item.detail, batch.shopId, item.collectedAt);
  311. productDetails.push(product);
  312. const stockTotal = item.stocks.reduce((total, stock) => {
  313. const value = Number(stock.stockNum ?? 0);
  314. return total + (Number.isFinite(value) ? value : 0);
  315. }, 0);
  316. products.push({
  317. ...product,
  318. platform: ['jd'],
  319. externalId: product.productId,
  320. productCode: product.productId,
  321. productName: product.title,
  322. description: product.title,
  323. primaryCategory: product.category1 || product.category,
  324. secondaryCategory: product.category2,
  325. productSubcategory: product.category3,
  326. tags: ['京东', '本店'],
  327. status: stringValue(product.itemStatus || 'active'),
  328. stock: stockTotal,
  329. pricing: {
  330. currency: 'CNY',
  331. salePrice: product.price,
  332. },
  333. lastSyncAt: { __type: 'Date', iso: item.collectedAt },
  334. priceAmount: product.price,
  335. currency: 'CNY',
  336. productSource: 'jd-sp-api',
  337. raw: product.rawJdProduct,
  338. });
  339. for (const sku of item.skus) {
  340. const skuId = extractSkuId(sku);
  341. if (!skuId) continue;
  342. const mappingKey = `jd:${batch.shopId}:${skuId}`;
  343. mappings.push({
  344. platform: 'jd',
  345. source: 'jd-sp-api',
  346. asin: product.productId,
  347. jdProductId: product.productId,
  348. jdSkuId: skuId,
  349. sellerSku: skuId,
  350. productSku: skuId,
  351. itemName: stringValue(sku.skuName),
  352. brand: extractBrand(sku),
  353. price: extractPrice(sku),
  354. listingPrice: extractPrice(sku),
  355. itemStatus: pickFirst(sku.skuStatus, sku.valid, null),
  356. site: 'jd',
  357. shopId: batch.shopId,
  358. jdSkuKey: mappingKey,
  359. rawJdSku: sku,
  360. syncedAt: item.collectedAt,
  361. });
  362. }
  363. for (const stock of item.stocks) {
  364. const skuId = extractSkuId(stock);
  365. if (!skuId) continue;
  366. stockSnapshots.push({
  367. platform: 'jd',
  368. source: 'jd-sp-api',
  369. shopId: batch.shopId,
  370. productId: product.productId,
  371. skuId,
  372. stockKey: `jd:${batch.shopId}:${skuId}:${item.collectedAt.slice(0, 10)}`,
  373. stockNum: Number(stock.stockNum ?? 0),
  374. orderBookingNum: Number(stock.orderBookingNum ?? 0),
  375. unpaidBookingNum: Number(stock.unpaidBookingNum ?? 0),
  376. reserveNum: Number(stock.reserveNum ?? 0),
  377. warehouseId: stringValue(stock.warehouseId),
  378. rawJdStock: stock,
  379. collectedAt: item.collectedAt,
  380. });
  381. }
  382. }
  383. return { productDetails, products, mappings, stockSnapshots };
  384. }
  385. async function main() {
  386. requireConfig(['sourceParseAppId', 'sourceParseMasterKey', 'jdAppKey', 'jdAppSecret']);
  387. if (!dryRun) requireConfig(['targetParseUrl', 'targetParseAppId', 'targetParseMasterKey']);
  388. const auth = await loadAuthorization();
  389. const batch = await loadBatch(auth);
  390. const records = buildRecords(batch);
  391. const summary = {
  392. shopId: batch.shopId,
  393. totalProducts: batch.total,
  394. products: records.productDetails.length,
  395. skuMappings: records.mappings.length,
  396. stockSnapshots: records.stockSnapshots.length,
  397. detailAvailable: records.productDetails.filter((row) => row.detailStatus === 'available').length,
  398. productIds: records.productDetails.map((row) => row.productId),
  399. };
  400. if (dryRun) {
  401. console.log(JSON.stringify({ mode: 'dry-run', ...summary }, null, 2));
  402. return;
  403. }
  404. const target = {
  405. url: config.targetParseUrl,
  406. appId: config.targetParseAppId,
  407. masterKey: config.targetParseMasterKey,
  408. };
  409. for (const row of records.productDetails) await parseUpsert(target, 'ProductDetail', row, 'jdProductKey');
  410. for (const row of records.products) await parseUpsert(target, 'Product', row, 'jdProductKey');
  411. for (const row of records.mappings) await parseUpsert(target, 'AsinSkuMapping', row, 'jdSkuKey');
  412. for (const row of records.stockSnapshots) await parseUpsert(target, 'JdStockSnapshot', row, 'stockKey');
  413. console.log(JSON.stringify({ mode: 'imported', ...summary }, null, 2));
  414. }
  415. main().catch((error) => {
  416. console.error(`[jd-import] ${error.message}`);
  417. process.exitCode = 1;
  418. });