sp-api-collector-lib.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import crypto from 'node:crypto';
  2. export const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
  3. export const apiUrl = process.env.API_URL || 'http://127.0.0.1:3000/api/amazon';
  4. export const appId = process.env.PARSE_APP_ID;
  5. export const masterKey = process.env.PARSE_MASTER_KEY;
  6. export const runId = process.env.SP_API_RUN_ID || `spapi-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`;
  7. if (!appId || !masterKey) throw new Error('Missing PARSE_APP_ID or PARSE_MASTER_KEY');
  8. export const parseHeaders = {
  9. 'Content-Type': 'application/json',
  10. 'X-Parse-Application-Id': appId,
  11. 'X-Parse-Master-Key': masterKey,
  12. };
  13. export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
  14. export const shopPointer = objectId => ({ __type: 'Pointer', className: 'Shop', objectId });
  15. export const orderPointer = objectId => ({ __type: 'Pointer', className: 'Order', objectId });
  16. export const dateValue = value => {
  17. if (!value) return undefined;
  18. const date = new Date(value);
  19. return Number.isNaN(date.getTime()) ? undefined : { __type: 'Date', iso: date.toISOString() };
  20. };
  21. export function cleanObject(value) {
  22. return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
  23. }
  24. export function stableStringify(value) {
  25. if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
  26. if (value && typeof value === 'object') {
  27. return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
  28. }
  29. return JSON.stringify(value);
  30. }
  31. export function hash(value) {
  32. return crypto.createHash('sha256').update(typeof value === 'string' ? value : stableStringify(value)).digest('hex');
  33. }
  34. export function log(event, data = {}) {
  35. console.log(JSON.stringify({ at: new Date().toISOString(), runId, event, ...data }));
  36. }
  37. export async function parseRequest(path, method = 'GET', body) {
  38. const response = await fetch(`${parseUrl}${path}`, {
  39. method,
  40. headers: parseHeaders,
  41. body: body === undefined ? undefined : JSON.stringify(body),
  42. });
  43. const text = await response.text();
  44. let result = {};
  45. try { result = JSON.parse(text); } catch {}
  46. if (!response.ok || result.error) {
  47. throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 400) || response.status}`);
  48. }
  49. return result;
  50. }
  51. export async function allRows(className, keys = '', where = undefined) {
  52. const rows = [];
  53. let skip = 0;
  54. const whereParam = where === undefined ? '' : `&where=${encodeURIComponent(JSON.stringify(where))}`;
  55. while (true) {
  56. const page = await parseRequest(`/classes/${className}?limit=1000&skip=${skip}${keys ? `&keys=${keys}` : ''}${whereParam}`);
  57. rows.push(...(page.results || []));
  58. const length = page.results?.length || 0;
  59. if (length < 1000) return rows;
  60. skip += length;
  61. }
  62. }
  63. export async function forward(shopId, path, method = 'GET', body = undefined, maxAttempts = 5) {
  64. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  65. try {
  66. const response = await fetch(`${apiUrl}/forward`, {
  67. method: 'POST',
  68. headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
  69. body: JSON.stringify({ path, method, ...(body === undefined ? {} : { body }) }),
  70. signal: AbortSignal.timeout(180000),
  71. });
  72. const text = await response.text();
  73. let result = {};
  74. try { result = JSON.parse(text); } catch {}
  75. if (!response.ok || result.success === false) {
  76. const error = new Error(result.message || result.errors?.[0]?.message || text.slice(0, 400) || `HTTP ${response.status}`);
  77. error.status = Number(result.status || response.status);
  78. throw error;
  79. }
  80. return result.data || result;
  81. } catch (error) {
  82. if (attempt === maxAttempts || [400, 401, 403, 404, 409, 422].includes(error.status)) throw error;
  83. const delay = Math.min(attempt * 15000, 60000);
  84. log('forward_retry', { shopId, path: path.split('?')[0], attempt, delay, error: error.message });
  85. await sleep(delay);
  86. }
  87. }
  88. }
  89. const classCaches = new Map();
  90. async function classCache(className) {
  91. if (!classCaches.has(className)) {
  92. const rows = await allRows(className, 'objectId,recordKey');
  93. classCaches.set(className, new Map(rows.filter(row => row.recordKey).map(row => [row.recordKey, row.objectId])));
  94. }
  95. return classCaches.get(className);
  96. }
  97. export async function upsertMany(className, rows) {
  98. if (!rows.length) return { inserted: 0, updated: 0 };
  99. rows = [...new Map(rows.map(row => [row.recordKey, row])).values()];
  100. const cache = await classCache(className);
  101. let inserted = 0;
  102. let updated = 0;
  103. for (let start = 0; start < rows.length; start += 25) {
  104. const page = rows.slice(start, start + 25);
  105. const requests = page.map(row => {
  106. if (!row.recordKey) throw new Error(`${className} row is missing recordKey`);
  107. const objectId = cache.get(row.recordKey);
  108. return {
  109. method: objectId ? 'PUT' : 'POST',
  110. path: objectId ? `/parse/classes/${className}/${objectId}` : `/parse/classes/${className}`,
  111. body: row,
  112. };
  113. });
  114. const result = await parseRequest('/batch', 'POST', { requests });
  115. for (let index = 0; index < page.length; index++) {
  116. const response = result[index];
  117. if (response?.error) throw new Error(`${className}: ${response.error.error || response.error.code}`);
  118. if (cache.has(page[index].recordKey)) {
  119. updated++;
  120. } else {
  121. cache.set(page[index].recordKey, response?.success?.objectId);
  122. inserted++;
  123. }
  124. }
  125. }
  126. return { inserted, updated };
  127. }
  128. export async function updateObject(className, objectId, body) {
  129. return parseRequest(`/classes/${className}/${objectId}`, 'PUT', body);
  130. }
  131. export async function writeAudit({ dataType, endpoint, status, shop, region = '', marketplaceId = '', records = 0, pages = 0, error = '', startedAt, details = {} }) {
  132. const finishedAt = new Date();
  133. return parseRequest('/classes/SpApiSyncAudit', 'POST', {
  134. runId,
  135. dataType,
  136. endpoint,
  137. status,
  138. region,
  139. marketplaceId,
  140. records,
  141. pages,
  142. error: String(error || '').slice(0, 2000),
  143. startedAt: dateValue(startedAt || finishedAt),
  144. finishedAt: dateValue(finishedAt),
  145. details,
  146. ...(shop?.objectId ? { shop: shopPointer(shop.objectId) } : {}),
  147. });
  148. }
  149. export const regionByMarketplace = {
  150. ATVPDKIKX0DER: 'NA', A2EUQ1WTGCTBG2: 'NA', A1AM78C64UM0Y8: 'NA', A2Q3Y263D00KWC: 'NA',
  151. A1F83G8C2ARO7P: 'EU', A1PA6795UKMFR9: 'EU', A13V1IB3VIYZZH: 'EU', A1RKKUPIHCS9HS: 'EU',
  152. APJ6JRA9NG5V4: 'EU', A1805IZSGTT6HS: 'EU', A2NODRKZP88ZB9: 'EU', A1C3SOZRARQ6R3: 'EU',
  153. AMEN7PMS3EDWL: 'EU', A28R8C7NBKEWEA: 'EU', A2VIGQ35RCS4UG: 'EU', A17E79C6D8DWNP: 'EU',
  154. A39IBJ37TRP1C6: 'FE', A19VAU5U5O7RUS: 'FE',
  155. };
  156. export async function activeAmazonShops() {
  157. const shops = await allRows('Shop');
  158. return shops.filter(shop => shop.platform === 'amazon'
  159. && shop.status === 'active'
  160. && shop.disable !== true
  161. && shop.deleted !== true
  162. && shop.config?.SpApiConfig);
  163. }