enrich-products.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import fs from 'node:fs';
  2. const configPath = process.env.VOC_CONFIG || '/etc/jianhen-voc/config.json';
  3. const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  4. const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
  5. const appId = config.parse.appId;
  6. const masterKey = config.parse.masterKey;
  7. const relayBase = String(config.relay.baseUrl || '').replace(/\/$/, '');
  8. const relayAuth = config.relay.auth;
  9. const parseHeaders = {
  10. 'Content-Type': 'application/json',
  11. 'X-Parse-Application-Id': appId,
  12. 'X-Parse-Master-Key': masterKey,
  13. };
  14. async function parseRequest(path, method = 'GET', body) {
  15. const response = await fetch(`${parseUrl}${path}`, {
  16. method,
  17. headers: parseHeaders,
  18. body: body === undefined ? undefined : JSON.stringify(body),
  19. });
  20. const result = await response.json();
  21. if (!response.ok || result.error) throw new Error(`${method} ${path}: ${JSON.stringify(result.error || response.status)}`);
  22. return result;
  23. }
  24. async function relay(path, domain, body) {
  25. const response = await fetch(`${relayBase}${config.relay.sorftimePath}`, {
  26. method: 'POST',
  27. headers: {
  28. Authorization: relayAuth,
  29. 'Content-Type': 'application/json',
  30. 'X-Request-Id': `enrich_${Date.now()}_${Math.random().toString(16).slice(2)}`,
  31. },
  32. body: JSON.stringify({ path, method: 'POST', query: { domain }, body }),
  33. });
  34. const result = await response.json();
  35. if (!response.ok || (result.code !== undefined && ![0, 200].includes(Number(result.code)))) {
  36. throw new Error(result.message || `Relay HTTP ${response.status}`);
  37. }
  38. let data = result.data ?? result.Data ?? result;
  39. if (data && typeof data === 'object' && !Array.isArray(data)) data = data.data ?? data.Data ?? data;
  40. return data;
  41. }
  42. async function upsert(className, where, payload) {
  43. const encoded = encodeURIComponent(JSON.stringify(where));
  44. const existing = await parseRequest(`/classes/${className}?where=${encoded}&limit=1`);
  45. const objectId = existing.results?.[0]?.objectId;
  46. return objectId
  47. ? parseRequest(`/classes/${className}/${objectId}`, 'PUT', payload)
  48. : parseRequest(`/classes/${className}`, 'POST', payload);
  49. }
  50. const siteByMarketplace = {
  51. ATVPDKIKX0DER: 'us', A2EUQ1WTGCTBG2: 'ca', A1AM78C64UM0Y8: 'mx', A2Q3Y263D00KWC: 'br',
  52. A1F83G8C2ARO7P: 'uk', A1PA6795UKMFR9: 'de', A13V1IB3VIYZZH: 'fr', A1RKKUPIHCS9HS: 'es',
  53. APJ6JRA9NG5V4: 'it', A1VC38T7YXB528: 'jp', A39IBJ37TRP1C6: 'au', A2VIGQ35RCS4UG: 'ae',
  54. A17E79C6D8DWNP: 'sa', A21TJRUUN4KGV: 'in',
  55. };
  56. const imageOf = value => Array.isArray(value) ? String(value[0] || '') : String(value || '');
  57. const categoryOf = value => {
  58. if (typeof value === 'string') return value;
  59. if (Array.isArray(value)) return value.map(item => categoryOf(item)).filter(Boolean).join(' > ');
  60. if (value && typeof value === 'object') return String(value.Name || value.name || value.CategoryName || '');
  61. return '';
  62. };
  63. const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
  64. const shopsResult = await parseRequest('/classes/Shop?limit=1000');
  65. const shops = new Map(shopsResult.results.map(shop => [shop.objectId, shop]));
  66. const listingsResult = await parseRequest('/classes/Listing?limit=1000');
  67. let enriched = 0;
  68. let reviewsSaved = 0;
  69. let failed = 0;
  70. for (const listing of listingsResult.results) {
  71. const asin = String(listing.asin || '').trim().toUpperCase();
  72. const shopId = listing.shop?.objectId || '';
  73. if (!asin || !shopId) continue;
  74. const shop = shops.get(shopId) || {};
  75. const domain = Number(shop.domain || 1);
  76. const marketplaceId = listing.marketplaceId || shop.marketplaceId || '';
  77. const pointer = { __type: 'Pointer', className: 'Shop', objectId: shopId };
  78. try {
  79. const raw = await relay('/api/ProductRequest', domain, {
  80. ASIN: asin,
  81. Trend: 1,
  82. QueryTrendStartDt: '',
  83. QueryTrendEndDt: '',
  84. });
  85. if (raw && (raw.Asin || raw.ASIN || raw.Title)) {
  86. const payload = {
  87. asin,
  88. parentAsin: raw.ParentAsin || listing.parentAsin || '',
  89. sku: listing.sku || '',
  90. sellerSku: listing.sku || '',
  91. title: raw.Title || listing.title || '',
  92. itemName: raw.Title || listing.title || '',
  93. imageUrl: imageOf(raw.Photo) || listing.mainImage || '',
  94. photo: imageOf(raw.Photo) || listing.mainImage || '',
  95. category: [categoryOf(raw.Category) || listing.productType || ''].filter(Boolean),
  96. productType: listing.productType || '',
  97. brand: raw.Brand || '',
  98. sellerId: raw.BuyboxSellerId || '',
  99. ratings: Number(raw.Ratings || 0),
  100. rating: Number(raw.Ratings || 0),
  101. ratingsCount: Number(raw.RatingsCount || 0),
  102. price: Number(raw.Price || listing.price || 0),
  103. salesPrice: Number(raw.SalesPrice || raw.Price || listing.price || 0),
  104. rank: Number(raw.Rank || 0),
  105. bsrCategory: Array.isArray(raw.BsrCategory) ? raw.BsrCategory : [],
  106. listingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
  107. ListingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
  108. listingSalesOfMonth: Number(raw.ListingSalesOfMonth || 0),
  109. marketplaceId,
  110. domain: String(domain),
  111. site: siteByMarketplace[marketplaceId] || String(domain),
  112. storeName: shop.name || '',
  113. shopName: shop.name || '',
  114. shopId,
  115. source: 'sorftime-product-request',
  116. onlineDate: String(raw.OnlineDate || listing.createdDate?.iso || listing.createdDate || '').slice(0, 10),
  117. shop: pointer,
  118. rawData: raw,
  119. };
  120. const where = { asin, shop: pointer };
  121. await upsert('Product', where, payload);
  122. await upsert('ProductDetail', where, payload);
  123. enriched += 1;
  124. }
  125. const reviewData = await relay('/api/ProductReviewsQuery', domain, {
  126. ASIN: asin,
  127. PageIndex: 1,
  128. OnlyPurchase: 0,
  129. Star: '1,2,3,4,5',
  130. });
  131. const reviews = reviewData?.Reviews || reviewData?.Items || reviewData?.List || (Array.isArray(reviewData) ? reviewData : []);
  132. for (const review of reviews) {
  133. const reviewId = String(review.ReviewId || review.Id || review.ID || review.id || `${asin}:${review.ReviewsDate || review.ReviewDate || ''}:${review.ProfileName || review.Author || ''}`);
  134. await upsert('SorftimeReviews', { reviewId }, {
  135. reviewId,
  136. asin,
  137. star: Number(review.Star || review.Rating || 0),
  138. rating: Number(review.Star || review.Rating || 0),
  139. title: review.Title || review.ReviewTitle || '',
  140. content: review.Content || review.ReviewContent || review.Body || '',
  141. reviewDate: review.ReviewsDate || review.ReviewDate || review.Date || '',
  142. author: review.ProfileName || review.Author || '',
  143. verified: Boolean(review.OnlyPurchase || review.VerifiedPurchase),
  144. shop: pointer,
  145. rawData: review,
  146. });
  147. reviewsSaved += 1;
  148. }
  149. } catch (error) {
  150. failed += 1;
  151. console.warn(`Failed ${asin}: ${error.message}`);
  152. }
  153. await delay(150);
  154. }
  155. console.log(JSON.stringify({ listings: listingsResult.results.length, enriched, reviewsSaved, failed }));