import fs from 'node:fs'; const configPath = process.env.VOC_CONFIG || '/etc/jianhen-voc/config.json'; const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse'; const appId = config.parse.appId; const masterKey = config.parse.masterKey; const relayBase = String(config.relay.baseUrl || '').replace(/\/$/, ''); const relayAuth = config.relay.auth; const parseHeaders = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId, 'X-Parse-Master-Key': masterKey, }; async function parseRequest(path, method = 'GET', body) { const response = await fetch(`${parseUrl}${path}`, { method, headers: parseHeaders, body: body === undefined ? undefined : JSON.stringify(body), }); const result = await response.json(); if (!response.ok || result.error) throw new Error(`${method} ${path}: ${JSON.stringify(result.error || response.status)}`); return result; } async function relay(path, domain, body) { const response = await fetch(`${relayBase}${config.relay.sorftimePath}`, { method: 'POST', headers: { Authorization: relayAuth, 'Content-Type': 'application/json', 'X-Request-Id': `enrich_${Date.now()}_${Math.random().toString(16).slice(2)}`, }, body: JSON.stringify({ path, method: 'POST', query: { domain }, body }), }); const result = await response.json(); if (!response.ok || (result.code !== undefined && ![0, 200].includes(Number(result.code)))) { throw new Error(result.message || `Relay HTTP ${response.status}`); } let data = result.data ?? result.Data ?? result; if (data && typeof data === 'object' && !Array.isArray(data)) data = data.data ?? data.Data ?? data; return data; } async function upsert(className, where, payload) { const encoded = encodeURIComponent(JSON.stringify(where)); const existing = await parseRequest(`/classes/${className}?where=${encoded}&limit=1`); const objectId = existing.results?.[0]?.objectId; return objectId ? parseRequest(`/classes/${className}/${objectId}`, 'PUT', payload) : parseRequest(`/classes/${className}`, 'POST', payload); } const siteByMarketplace = { ATVPDKIKX0DER: 'us', A2EUQ1WTGCTBG2: 'ca', A1AM78C64UM0Y8: 'mx', A2Q3Y263D00KWC: 'br', A1F83G8C2ARO7P: 'uk', A1PA6795UKMFR9: 'de', A13V1IB3VIYZZH: 'fr', A1RKKUPIHCS9HS: 'es', APJ6JRA9NG5V4: 'it', A1VC38T7YXB528: 'jp', A39IBJ37TRP1C6: 'au', A2VIGQ35RCS4UG: 'ae', A17E79C6D8DWNP: 'sa', A21TJRUUN4KGV: 'in', }; const imageOf = value => Array.isArray(value) ? String(value[0] || '') : String(value || ''); const categoryOf = value => { if (typeof value === 'string') return value; if (Array.isArray(value)) return value.map(item => categoryOf(item)).filter(Boolean).join(' > '); if (value && typeof value === 'object') return String(value.Name || value.name || value.CategoryName || ''); return ''; }; const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const shopsResult = await parseRequest('/classes/Shop?limit=1000'); const shops = new Map(shopsResult.results.map(shop => [shop.objectId, shop])); const listingsResult = await parseRequest('/classes/Listing?limit=1000'); let enriched = 0; let reviewsSaved = 0; let failed = 0; for (const listing of listingsResult.results) { const asin = String(listing.asin || '').trim().toUpperCase(); const shopId = listing.shop?.objectId || ''; if (!asin || !shopId) continue; const shop = shops.get(shopId) || {}; const domain = Number(shop.domain || 1); const marketplaceId = listing.marketplaceId || shop.marketplaceId || ''; const pointer = { __type: 'Pointer', className: 'Shop', objectId: shopId }; try { const raw = await relay('/api/ProductRequest', domain, { ASIN: asin, Trend: 1, QueryTrendStartDt: '', QueryTrendEndDt: '', }); if (raw && (raw.Asin || raw.ASIN || raw.Title)) { const payload = { asin, parentAsin: raw.ParentAsin || listing.parentAsin || '', sku: listing.sku || '', sellerSku: listing.sku || '', title: raw.Title || listing.title || '', itemName: raw.Title || listing.title || '', imageUrl: imageOf(raw.Photo) || listing.mainImage || '', photo: imageOf(raw.Photo) || listing.mainImage || '', category: [categoryOf(raw.Category) || listing.productType || ''].filter(Boolean), productType: listing.productType || '', brand: raw.Brand || '', sellerId: raw.BuyboxSellerId || '', ratings: Number(raw.Ratings || 0), rating: Number(raw.Ratings || 0), ratingsCount: Number(raw.RatingsCount || 0), price: Number(raw.Price || listing.price || 0), salesPrice: Number(raw.SalesPrice || raw.Price || listing.price || 0), rank: Number(raw.Rank || 0), bsrCategory: Array.isArray(raw.BsrCategory) ? raw.BsrCategory : [], listingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0), ListingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0), listingSalesOfMonth: Number(raw.ListingSalesOfMonth || 0), marketplaceId, domain: String(domain), site: siteByMarketplace[marketplaceId] || String(domain), storeName: shop.name || '', shopName: shop.name || '', shopId, source: 'sorftime-product-request', onlineDate: String(raw.OnlineDate || listing.createdDate?.iso || listing.createdDate || '').slice(0, 10), shop: pointer, rawData: raw, }; const where = { asin, shop: pointer }; await upsert('Product', where, payload); await upsert('ProductDetail', where, payload); enriched += 1; } const reviewData = await relay('/api/ProductReviewsQuery', domain, { ASIN: asin, PageIndex: 1, OnlyPurchase: 0, Star: '1,2,3,4,5', }); const reviews = reviewData?.Reviews || reviewData?.Items || reviewData?.List || (Array.isArray(reviewData) ? reviewData : []); for (const review of reviews) { const reviewId = String(review.ReviewId || review.Id || review.ID || review.id || `${asin}:${review.ReviewsDate || review.ReviewDate || ''}:${review.ProfileName || review.Author || ''}`); await upsert('SorftimeReviews', { reviewId }, { reviewId, asin, star: Number(review.Star || review.Rating || 0), rating: Number(review.Star || review.Rating || 0), title: review.Title || review.ReviewTitle || '', content: review.Content || review.ReviewContent || review.Body || '', reviewDate: review.ReviewsDate || review.ReviewDate || review.Date || '', author: review.ProfileName || review.Author || '', verified: Boolean(review.OnlyPurchase || review.VerifiedPurchase), shop: pointer, rawData: review, }); reviewsSaved += 1; } } catch (error) { failed += 1; console.warn(`Failed ${asin}: ${error.message}`); } await delay(150); } console.log(JSON.stringify({ listings: listingsResult.results.length, enriched, reviewsSaved, failed }));