jd-import-products.mjs 14 KB

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