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 };