/ clis / amazon / offer.js
offer.js
  1  import { CommandExecutionError } from '@jackwener/opencli/errors';
  2  import { cli, Strategy } from '@jackwener/opencli/registry';
  3  import { buildProductUrl, buildProvenance, cleanText, extractAsin, isAmazonEntity, normalizeProductUrl, PRIMARY_PRICE_SELECTORS, parsePriceText, assertUsableState, gotoAndReadState, } from './shared.js';
  4  const OFFER_FACT_SELECTOR = [
  5      '#sellerProfileTriggerId',
  6      '#shipsFromSoldByInsideBuyBox_feature_div',
  7      '#fulfillerInfoFeature_feature_div',
  8      '#merchantInfoFeature_feature_div',
  9      '#tabular-buybox-container',
 10      '#merchant-info',
 11  ].join(', ');
 12  function collapseAdjacentWords(text) {
 13      const parts = cleanText(text).split(' ').filter(Boolean);
 14      const deduped = [];
 15      for (const part of parts) {
 16          if (deduped[deduped.length - 1] === part)
 17              continue;
 18          deduped.push(part);
 19      }
 20      return deduped.join(' ');
 21  }
 22  function extractShipsFrom(text) {
 23      const normalized = cleanText(text);
 24      const match = normalized.match(/Ships from\s+(.+?)(?=Sold by|and Fulfilled by|$)/i);
 25      return match ? collapseAdjacentWords(match[1].replace(/Ships from/ig, '')) : null;
 26  }
 27  function extractSoldBy(text) {
 28      const normalized = cleanText(text);
 29      const match = normalized.match(/Sold by\s+(.+?)(?=and Fulfilled by|Ships from|$)/i);
 30      return match ? collapseAdjacentWords(match[1]) : null;
 31  }
 32  function isDeliveryLocationBlocked(text) {
 33      const normalized = cleanText(text).toLowerCase();
 34      return normalized.includes('cannot be shipped to your selected delivery location')
 35          || normalized.includes('similar items shipping to')
 36          || normalized.includes('deliver to hong kong');
 37  }
 38  function normalizeOfferPayload(payload) {
 39      const asin = extractAsin(payload.href ?? '') ?? null;
 40      const sourceUrl = cleanText(payload.href) || buildProductUrl(payload.href ?? '');
 41      const price = parsePriceText(payload.price_text);
 42      const merchantInfo = cleanText(payload.merchant_info) || null;
 43      const soldBy = cleanText(payload.sold_by)
 44          || extractSoldBy(payload.ships_from_text ?? '')
 45          || extractSoldBy(merchantInfo ?? '')
 46          || null;
 47      const shipsFrom = extractShipsFrom(payload.ships_from_text ?? '')
 48          || extractShipsFrom(merchantInfo ?? '')
 49          || cleanText(payload.ships_from_text)
 50          || null;
 51      const provenance = buildProvenance(sourceUrl);
 52      return {
 53          asin,
 54          product_url: normalizeProductUrl(payload.href),
 55          ...provenance,
 56          price_text: price.price_text,
 57          price_value: price.price_value,
 58          currency: price.currency,
 59          merchant_info_text: merchantInfo,
 60          sold_by: soldBy,
 61          ships_from: shipsFrom,
 62          offer_listing_url: cleanText(payload.offer_link) || null,
 63          review_url: cleanText(payload.review_url) || null,
 64          qa_url: cleanText(payload.qa_url) || null,
 65          is_amazon_sold: isAmazonEntity(soldBy),
 66          is_amazon_fulfilled: isAmazonEntity(shipsFrom) || /fulfilled by amazon/i.test(merchantInfo ?? ''),
 67      };
 68  }
 69  async function readOfferPayload(page, input) {
 70      const url = buildProductUrl(input);
 71      const state = await gotoAndReadState(page, url, 2500, 'offer');
 72      assertUsableState(state, 'offer');
 73      // Reconnecting to an existing Amazon target can surface the product page
 74      // before the buy-box / merchant blocks are reattached to the DOM.
 75      await page.wait({ selector: OFFER_FACT_SELECTOR, timeout: 6 }).catch(() => { });
 76      return await page.evaluate(`
 77      (() => ({
 78        href: window.location.href,
 79        title: document.title || '',
 80        price_text: (() => {
 81          const selectors = ${JSON.stringify(PRIMARY_PRICE_SELECTORS)};
 82          for (const selector of selectors) {
 83            const text = document.querySelector(selector)?.textContent || '';
 84            if (text.trim()) return text;
 85          }
 86          return '';
 87        })(),
 88        merchant_info: document.querySelector('#merchant-info')?.textContent || '',
 89        sold_by: document.querySelector('#sellerProfileTriggerId')?.textContent || '',
 90        ships_from_text:
 91          document.querySelector('#shipsFromSoldByInsideBuyBox_feature_div')?.textContent
 92          || document.querySelector('#fulfillerInfoFeature_feature_div')?.textContent
 93          || document.querySelector('#merchantInfoFeature_feature_div')?.textContent
 94          || document.querySelector('#tabular-buybox-container')?.textContent
 95          || '',
 96        offer_link: document.querySelector('a[href*="/gp/offer-listing/"]')?.href || '',
 97        review_url: document.querySelector('a[href*="#customerReviews"]')?.href || '',
 98        qa_url: document.querySelector('a[href*="ask/questions"]')?.href || '',
 99        buybox_text:
100          document.querySelector('#desktop_qualifiedBuyBox')?.textContent
101          || document.querySelector('#buybox')?.textContent
102          || '',
103      }))()
104    `);
105  }
106  cli({
107      site: 'amazon',
108      name: 'offer',
109      description: 'Amazon seller, buy box, and fulfillment facts from the product page',
110      domain: 'amazon.com',
111      strategy: Strategy.COOKIE,
112      navigateBefore: false,
113      args: [
114          {
115              name: 'input',
116              required: true,
117              positional: true,
118              help: 'ASIN or product URL, for example B0FJS72893',
119          },
120      ],
121      columns: ['asin', 'price_text', 'sold_by', 'ships_from', 'is_amazon_sold', 'is_amazon_fulfilled'],
122      func: async (page, kwargs) => {
123          const input = String(kwargs.input ?? '');
124          const payload = await readOfferPayload(page, input);
125          const normalized = normalizeOfferPayload(payload);
126          if (!normalized.sold_by && !normalized.ships_from && !normalized.merchant_info_text) {
127              if (isDeliveryLocationBlocked(payload.buybox_text)) {
128                  throw new CommandExecutionError('amazon offer buy box is blocked by the current delivery location', 'The shared Chrome profile is not set to the target US delivery address. Switch Amazon delivery location to the requested US destination, reopen the product page, and retry.');
129              }
130              throw new CommandExecutionError('amazon offer surface did not expose seller or fulfillment facts', 'The product page may have changed. Open the product page in Chrome, make sure the buy box is visible, and retry.');
131          }
132          return [normalized];
133      },
134  });
135  export const __test__ = {
136      extractShipsFrom,
137      extractSoldBy,
138      isDeliveryLocationBlocked,
139      normalizeOfferPayload,
140  };