backfill-listings.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
  2. const appId = process.env.PARSE_APP_ID;
  3. const masterKey = process.env.PARSE_MASTER_KEY;
  4. if (!appId || !masterKey) throw new Error('Missing Parse bootstrap environment variables');
  5. const headers = {
  6. 'Content-Type': 'application/json',
  7. 'X-Parse-Application-Id': appId,
  8. 'X-Parse-Master-Key': masterKey,
  9. };
  10. async function request(path, method = 'GET', body) {
  11. const response = await fetch(`${parseUrl}${path}`, {
  12. method,
  13. headers,
  14. body: body === undefined ? undefined : JSON.stringify(body),
  15. });
  16. const result = await response.json();
  17. if (!response.ok || result.error) throw new Error(`${method} ${path}: ${JSON.stringify(result.error || response.status)}`);
  18. return result;
  19. }
  20. async function allRows(className, keys = '', where = undefined) {
  21. const rows = [];
  22. let skip = 0;
  23. const whereParam = where === undefined ? '' : `&where=${encodeURIComponent(JSON.stringify(where))}`;
  24. while (true) {
  25. const page = await request(`/classes/${className}?limit=1000&skip=${skip}&order=objectId${keys ? `&keys=${keys}` : ''}${whereParam}`);
  26. rows.push(...(page.results || []));
  27. if ((page.results || []).length < 1000) return rows;
  28. skip += page.results.length;
  29. }
  30. }
  31. const siteByMarketplace = {
  32. ATVPDKIKX0DER: 'us', A2EUQ1WTGCTBG2: 'ca', A1AM78C64UM0Y8: 'mx', A2Q3Y263D00KWC: 'br',
  33. A1F83G8C2ARO7P: 'uk', A1PA6795UKMFR9: 'de', A13V1IB3VIYZZH: 'fr', A1RKKUPIHCS9HS: 'es',
  34. APJ6JRA9NG5V4: 'it', A1VC38T7YXB528: 'jp', A39IBJ37TRP1C6: 'au', A2VIGQ35RCS4UG: 'ae',
  35. A17E79C6D8DWNP: 'sa', A21TJRUUN4KGV: 'in',
  36. };
  37. const currencyByMarketplace = {
  38. ATVPDKIKX0DER: 'USD', A2EUQ1WTGCTBG2: 'CAD', A1AM78C64UM0Y8: 'MXN', A2Q3Y263D00KWC: 'BRL',
  39. A1F83G8C2ARO7P: 'GBP', A1PA6795UKMFR9: 'EUR', A13V1IB3VIYZZH: 'EUR', A1RKKUPIHCS9HS: 'EUR',
  40. APJ6JRA9NG5V4: 'EUR', A1805IZSGTT6HS: 'EUR', A2NODRKZP88ZB9: 'SEK', A1C3SOZRARQ6R3: 'PLN',
  41. AMEN7PMS3EDWL: 'EUR', A28R8C7NBKEWEA: 'EUR', A39IBJ37TRP1C6: 'AUD', A19VAU5U5O7RUS: 'SGD',
  42. A2VIGQ35RCS4UG: 'AED', A17E79C6D8DWNP: 'SAR',
  43. };
  44. const normalize = value => String(value || '').trim().toUpperCase();
  45. const rowKey = (shopId, asin) => `${shopId}:${normalize(asin)}`;
  46. const skuKey = (shopId, sku) => `${shopId}:${normalize(sku)}`;
  47. function firstAttribute(attributes, names) {
  48. for (const name of names) {
  49. const entry = attributes?.[name];
  50. const value = Array.isArray(entry) ? entry[0]?.value : entry?.value;
  51. if (value !== undefined && value !== null && String(value).trim()) return String(value).trim();
  52. }
  53. return '';
  54. }
  55. function catalogImage(catalog) {
  56. const images = (catalog?.images || catalog?.rawData?.images || [])
  57. .flatMap(group => group?.images || [])
  58. .filter(image => image?.link);
  59. return images
  60. .slice()
  61. .sort((left, right) => {
  62. const leftMain = left.variant === 'MAIN' ? 1 : 0;
  63. const rightMain = right.variant === 'MAIN' ? 1 : 0;
  64. return rightMain - leftMain || Number(right.width || 0) - Number(left.width || 0);
  65. })[0]?.link || '';
  66. }
  67. function pricingOffer(pricing, sku) {
  68. const offers = pricing?.product?.Offers || pricing?.rawData?.Product?.Offers || [];
  69. return offers.find(offer => normalize(offer?.SellerSKU) === normalize(sku)) || offers[0] || null;
  70. }
  71. function numberValue(value) {
  72. let normalized = String(value ?? '').trim().replace(/[^0-9,.-]/g, '');
  73. if (normalized.includes(',') && !normalized.includes('.')) normalized = normalized.replace(',', '.');
  74. else normalized = normalized.replace(/,/g, '');
  75. const result = Number(normalized);
  76. return Number.isFinite(result) ? result : 0;
  77. }
  78. function listingDate(value) {
  79. const raw = String(value || '').trim();
  80. if (!raw) return undefined;
  81. let timestamp = Date.parse(raw);
  82. if (!Number.isFinite(timestamp)) {
  83. const match = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2}):(\d{2}))?/);
  84. if (match) timestamp = Date.UTC(
  85. Number(match[3]), Number(match[2]) - 1, Number(match[1]),
  86. Number(match[4] || 0), Number(match[5] || 0), Number(match[6] || 0),
  87. );
  88. }
  89. return Number.isFinite(timestamp) ? { __type: 'Date', iso: new Date(timestamp).toISOString() } : undefined;
  90. }
  91. async function upsert(className, where, payload) {
  92. const encoded = encodeURIComponent(JSON.stringify(where));
  93. const existing = await request(`/classes/${className}?where=${encoded}&limit=1`);
  94. const objectId = existing.results?.[0]?.objectId;
  95. return objectId
  96. ? request(`/classes/${className}/${objectId}`, 'PUT', payload)
  97. : request(`/classes/${className}`, 'POST', payload);
  98. }
  99. const shopsResult = await request('/classes/Shop?limit=1000');
  100. const shops = new Map(shopsResult.results.map(shop => [shop.objectId, shop]));
  101. const shopsByMarketplace = new Map(shopsResult.results.map(shop => [shop.marketplaceId, shop]));
  102. for (const shop of shopsResult.results) {
  103. await request(`/classes/Shop/${shop.objectId}`, 'PUT', {
  104. shopName: shop.name || '',
  105. storeName: shop.name || '',
  106. disable: Boolean(shop.disable),
  107. deleted: Boolean(shop.deleted),
  108. });
  109. }
  110. // Listings Items requires a region-specific account ID, while merchant-listing
  111. // reports only require the authorized shop token. Materialize single-marketplace
  112. // reports so every product is attached to the marketplace that produced it.
  113. const merchantReportRows = await allRows(
  114. 'AmazonReportRow',
  115. 'reportId,marketplaceIds,rowData',
  116. { reportType: 'GET_MERCHANT_LISTINGS_ALL_DATA' },
  117. );
  118. let reportListingUpserts = 0;
  119. for (const reportRow of merchantReportRows) {
  120. if (!Array.isArray(reportRow.marketplaceIds) || reportRow.marketplaceIds.length !== 1) continue;
  121. const marketplaceId = reportRow.marketplaceIds[0];
  122. const shop = shopsByMarketplace.get(marketplaceId);
  123. const row = reportRow.rowData || {};
  124. if (!shop) continue;
  125. const asin = normalize(row.asin1 || row['product-id'] || row.product_id);
  126. const sku = String(row['seller-sku'] || row.seller_sku || '').trim();
  127. if (!asin || !sku) continue;
  128. const pointer = { __type: 'Pointer', className: 'Shop', objectId: shop.objectId };
  129. const createdDate = listingDate(row['open-date'] || row.open_date);
  130. await upsert('Listing', { sku, shop: pointer }, {
  131. asin,
  132. sku,
  133. sellerSku: sku,
  134. title: String(row['item-name'] || row.item_name || '').trim(),
  135. itemName: String(row['item-name'] || row.item_name || '').trim(),
  136. marketplaceId,
  137. price: numberValue(row.price),
  138. currency: currencyByMarketplace[marketplaceId] || '',
  139. quantity: numberValue(row.quantity),
  140. status: String(row.status || '').trim(),
  141. condition: String(row['item-condition'] || row.item_condition || '').trim(),
  142. fulfillmentType: String(row['fulfillment-channel'] || row['fulfilment-channel'] || '').trim(),
  143. source: 'amazon-sp-api-report',
  144. rawData: row,
  145. shop: pointer,
  146. ...(createdDate ? { createdDate } : {}),
  147. });
  148. reportListingUpserts += 1;
  149. }
  150. const [listingRows, catalogRows, pricingRows, inventoryRows, orderRows, orderItemRows] = await Promise.all([
  151. allRows('Listing'),
  152. allRows('AmazonCatalogItem'),
  153. allRows('AmazonProductPricing'),
  154. allRows('AmazonInventorySummary'),
  155. allRows('Order', 'objectId,platformOrderId,orderDate,status,shop'),
  156. allRows('AmazonOrderItem', 'amazonOrderId,asin,sellerSku,quantityOrdered,shop'),
  157. ]);
  158. const listingResult = { results: listingRows };
  159. const catalogByProduct = new Map(catalogRows.map(row => [rowKey(row.shop?.objectId || '', row.asin), row]));
  160. const pricingByProduct = new Map(pricingRows.map(row => [rowKey(row.shop?.objectId || '', row.asin), row]));
  161. const inventoryBySku = new Map(inventoryRows.map(row => [skuKey(row.shop?.objectId || '', row.sellerSku), row]));
  162. const inventoryByProduct = new Map(inventoryRows.map(row => [rowKey(row.shop?.objectId || '', row.asin), row]));
  163. const recentOrderIds = new Set(orderRows.filter(order => {
  164. if (['canceled', 'cancelled'].includes(String(order.status || '').trim().toLowerCase())) return false;
  165. const iso = order.orderDate?.iso || order.orderDate || '';
  166. const time = new Date(iso).getTime();
  167. return Number.isFinite(time) && time >= Date.now() - 30 * 24 * 60 * 60 * 1000;
  168. }).map(order => order.platformOrderId).filter(Boolean));
  169. const monthlySalesByProduct = new Map();
  170. for (const item of orderItemRows) {
  171. if (!recentOrderIds.has(item.amazonOrderId)) continue;
  172. const key = rowKey(item.shop?.objectId || '', item.asin);
  173. monthlySalesByProduct.set(key, (monthlySalesByProduct.get(key) || 0) + Number(item.quantityOrdered || 0));
  174. }
  175. let productUpserts = 0;
  176. let mappingUpserts = 0;
  177. for (const listing of listingResult.results) {
  178. const asin = String(listing.asin || '').trim().toUpperCase();
  179. const shopId = listing.shop?.objectId || '';
  180. if (!asin || !shopId) continue;
  181. const shop = shops.get(shopId) || {};
  182. const marketplaceId = listing.marketplaceId || shop.marketplaceId || '';
  183. const site = siteByMarketplace[marketplaceId] || String(shop.domain || '');
  184. const pointer = { __type: 'Pointer', className: 'Shop', objectId: shopId };
  185. const key = rowKey(shopId, asin);
  186. const catalog = catalogByProduct.get(key) || {};
  187. const pricing = pricingByProduct.get(key) || {};
  188. const inventory = inventoryBySku.get(skuKey(shopId, listing.sku)) || inventoryByProduct.get(key) || {};
  189. const offer = pricingOffer(pricing, listing.sku);
  190. const catalogSummary = catalog.summaries?.[0] || catalog.rawData?.summaries?.[0] || {};
  191. const imageUrl = catalogImage(catalog) || listing.mainImage || listing.imageUrl || '';
  192. const title = catalog.title || catalogSummary.itemName || listing.title || '';
  193. const price = Number(
  194. offer?.BuyingPrice?.ListingPrice?.Amount
  195. ?? offer?.BuyingPrice?.LandedPrice?.Amount
  196. ?? listing.price
  197. ?? 0
  198. );
  199. const currency = offer?.BuyingPrice?.ListingPrice?.CurrencyCode || listing.currency || '';
  200. const monthlySales = Number(monthlySalesByProduct.get(key) || 0);
  201. const listingColor = firstAttribute(listing.attributes, ['color_name', 'color', 'color_map'])
  202. || catalogSummary.color || firstAttribute(catalog.attributes, ['color']);
  203. const listingSize = firstAttribute(listing.attributes, ['size_name', 'size'])
  204. || catalogSummary.size || firstAttribute(catalog.attributes, ['size']);
  205. const createdDate = String(listing.createdDate?.iso || listing.createdDate || '').slice(0, 10);
  206. const createdTime = new Date(createdDate).getTime();
  207. const onlineDays = Number.isFinite(createdTime)
  208. ? Math.max(0, Math.floor((Date.now() - createdTime) / 86400000))
  209. : 0;
  210. const payload = {
  211. asin,
  212. parentAsin: listing.parentAsin || '',
  213. sku: listing.sku || '',
  214. sellerSku: listing.sku || '',
  215. title,
  216. itemName: title,
  217. imageUrl,
  218. photo: imageUrl,
  219. listingImageUrl: imageUrl,
  220. category: catalogSummary.browseClassification?.displayName
  221. ? [catalogSummary.browseClassification.displayName]
  222. : (listing.productType ? [listing.productType] : []),
  223. categoryName: catalogSummary.browseClassification?.displayName || '',
  224. productType: catalog.productType || listing.productType || '',
  225. brand: catalog.brand || catalogSummary.brand || '',
  226. marketplaceId,
  227. domain: String(shop.domain || ''),
  228. site,
  229. storeName: shop.name || '',
  230. shopName: shop.name || '',
  231. shopId,
  232. storeId: shopId,
  233. status: listing.status || '',
  234. onlineDate: createdDate,
  235. onlineDays,
  236. price,
  237. salesPrice: price,
  238. listingPrice: price,
  239. regularPrice: Number(offer?.RegularPrice?.Amount || price),
  240. currency,
  241. fnsku: inventory.fnSku || '',
  242. fulfillmentType: inventory.objectId ? 'FBA' : '',
  243. inventoryQuantity: Number(inventory.totalQuantity || 0),
  244. listingColor,
  245. listingSize,
  246. listingSalesVolumeOfMonth: monthlySales,
  247. ListingSalesVolumeOfMonth: monthlySales,
  248. listingSalesOfMonth: monthlySales,
  249. monthlySales,
  250. attributes: catalog.attributes || listing.attributes || {},
  251. catalogData: catalog.rawData || {},
  252. pricingData: pricing.product || pricing.rawData || {},
  253. inventoryData: inventory.inventoryDetails || inventory.rawData || {},
  254. source: 'amazon-sp-api',
  255. shop: pointer,
  256. };
  257. const where = { asin, shop: pointer };
  258. await upsert('Product', where, payload);
  259. await upsert('ProductDetail', where, payload);
  260. await upsert('AsinSkuMapping', { asin, sellerSku: listing.sku || '', shop: pointer }, {
  261. recordKey: `${shopId}:${asin}:${listing.sku || ''}`,
  262. asin,
  263. parentAsin: listing.parentAsin || '',
  264. sku: listing.sku || '',
  265. sellerSku: listing.sku || '',
  266. productSku: listing.sku || '',
  267. fnsku: inventory.fnSku || '',
  268. title,
  269. itemName: title,
  270. imageUrl,
  271. photo: imageUrl,
  272. productType: catalog.productType || listing.productType || '',
  273. category: catalogSummary.browseClassification?.displayName || listing.productType || '',
  274. brand: catalog.brand || catalogSummary.brand || '',
  275. marketplaceId,
  276. domain: String(shop.domain || ''),
  277. site,
  278. storeName: shop.name || '',
  279. shopName: shop.name || '',
  280. shopId,
  281. storeId: shopId,
  282. status: listing.status || '',
  283. onlineDate: createdDate,
  284. onlineDays,
  285. price,
  286. salesPrice: price,
  287. currency,
  288. fulfillmentType: inventory.objectId ? 'FBA' : '',
  289. inventoryQuantity: Number(inventory.totalQuantity || 0),
  290. color: listingColor,
  291. size: listingSize,
  292. listingColor,
  293. listingSize,
  294. source: 'amazon-sp-api',
  295. shop: pointer,
  296. });
  297. await request(`/classes/Listing/${listing.objectId}`, 'PUT', {
  298. site,
  299. storeName: shop.name || '',
  300. shopName: shop.name || '',
  301. shopId,
  302. sellerSku: listing.sku || '',
  303. fnsku: inventory.fnSku || '',
  304. fulfillmentType: inventory.objectId ? 'FBA' : '',
  305. inventoryQuantity: Number(inventory.totalQuantity || 0),
  306. listingColor,
  307. listingSize,
  308. listingSalesVolumeOfMonth: monthlySales,
  309. ListingSalesVolumeOfMonth: monthlySales,
  310. listingSalesOfMonth: monthlySales,
  311. });
  312. productUpserts += 1;
  313. mappingUpserts += 1;
  314. }
  315. for (const className of ['Product', 'ProductDetail', 'SorftimeProduct']) {
  316. const rows = await request(`/classes/${className}?limit=1000`);
  317. for (const row of rows.results || []) {
  318. const shopId = row.shop?.objectId || '';
  319. if (!shopId) continue;
  320. const shop = shops.get(shopId) || {};
  321. const marketplaceId = row.marketplaceId || shop.marketplaceId || '';
  322. await request(`/classes/${className}/${row.objectId}`, 'PUT', {
  323. site: row.site || siteByMarketplace[marketplaceId] || String(shop.domain || ''),
  324. marketplaceId,
  325. domain: String(row.domain || shop.domain || ''),
  326. storeName: row.storeName || shop.name || '',
  327. shopName: row.shopName || shop.name || '',
  328. shopId,
  329. });
  330. }
  331. }
  332. console.log(JSON.stringify({
  333. shops: shops.size,
  334. listings: listingResult.results.length,
  335. productUpserts,
  336. mappingUpserts,
  337. catalogRows: catalogRows.length,
  338. pricingRows: pricingRows.length,
  339. inventoryRows: inventoryRows.length,
  340. orderItemRows: orderItemRows.length,
  341. productsWithMonthlySales: monthlySalesByProduct.size,
  342. merchantReportRows: merchantReportRows.length,
  343. reportListingUpserts,
  344. }));