diff --git a/scripts/test-amazon-search-parser.ts b/scripts/test-amazon-search-parser.ts index 83ec123..d170297 100644 --- a/scripts/test-amazon-search-parser.ts +++ b/scripts/test-amazon-search-parser.ts @@ -216,6 +216,721 @@ async function main() { assert.equal(groupedAdsByAsin.get('B000000014')?.adType, 'sponsoredDisplay'); assert.equal(groupedAdsByAsin.get('B000000014')?.placement, 'sidebar'); + const liveSponsoredBrandsDom = new JSDOM( + ` +
+
+
+
Arlo Security for any Home
+ Sponsored +
+ Video Doorbell + + + +
+
+ Outdoor Camera + + + +
+
+ Bundle + + + +
+
+
+ Sponsored +

Ring Spotlight Cam Plus, Battery

+ +
+
+

Organic camera

+ +
+
+ Sponsored + +
PROTECT YOUR EQUIPMENT & FACILITIES
+
+

Vosker VKX Solar Powered 4G-LTE Outdoor Cellular Security Camera

+ + $379.99 +
+
+ +
+
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: liveSponsoredBrandsDom.window.document, + Node: liveSponsoredBrandsDom.window.Node, + Element: liveSponsoredBrandsDom.window.Element, + HTMLElement: liveSponsoredBrandsDom.window.HTMLElement, + HTMLAnchorElement: liveSponsoredBrandsDom.window.HTMLAnchorElement, + HTMLImageElement: liveSponsoredBrandsDom.window.HTMLImageElement, + }); + const liveSponsoredBrands = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const liveByAsin = new Map(liveSponsoredBrands.map((item) => [item.asin, item])); + assert.equal(liveByAsin.get('B08AR2S6MC')?.adType, 'sponsoredBrands'); + assert.equal(liveByAsin.get('B0ARLO0002')?.adType, 'sponsoredBrands'); + assert.equal(liveByAsin.get('B0ARLO0003')?.adType, 'sponsoredBrands'); + assert.equal(liveByAsin.get('B08AR2S6MC')?.placement, 'topOfSearch'); + assert.equal(liveByAsin.get('B09RING001')?.adType, 'sponsoredProducts'); + assert.equal(liveByAsin.get('B09ORG0001')?.adType, 'organic'); + assert.equal(liveByAsin.get('B0VOSKER01')?.adType, 'sponsoredBrands'); + assert.equal(liveByAsin.get('B0VOSKER01')?.adFormat, 'video'); + assert.equal(liveByAsin.get('B0VOSKER01')?.placement, 'bottomOfSearch'); + assert.equal(liveByAsin.get('B0BRAND001')?.adType, 'sponsoredBrands'); + assert.equal(liveByAsin.get('B0BRAND002')?.adType, 'sponsoredBrands'); + + const lazyImageDom = new JSDOM( + ` +
+
+
+
Arlo Security for any Home
+ Sponsored +
+

Video Doorbell

+ + + +
+
+
+
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: lazyImageDom.window.document, + Node: lazyImageDom.window.Node, + Element: lazyImageDom.window.Element, + HTMLElement: lazyImageDom.window.HTMLElement, + HTMLAnchorElement: lazyImageDom.window.HTMLAnchorElement, + HTMLImageElement: lazyImageDom.window.HTMLImageElement, + }); + const lazyImageItems = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + assert.equal(lazyImageItems.length, 1); + assert.equal(lazyImageItems[0]?.asin, 'B08AR2LAZY'); + assert.equal(lazyImageItems[0]?.adType, 'sponsoredBrands'); + assert.equal(lazyImageItems[0]?.imageSrc, 'https://example.com/arlo-lazy.jpg'); + + const classificationBoundaryDom = new JSDOM( + ` +
+
+
+
Arlo Security for any Home
+ Sponsored +
+ Video Doorbell + + + +
+
+ Outdoor Camera + + + +
+
+ +
+
+ Sponsored +

Arlo Video Doorbell Wired

+ + + +
+
+ Sponsored +

Ring Spotlight Cam Plus, Battery

+ + + +
+
+

Organic camera

+ +
+
+ + + +
+ Sponsored +

Ring Indoor Cam with demo

+ + + + +
+ +
+ Sponsored + +
PROTECT YOUR EQUIPMENT & FACILITIES
+
+

Vosker VKX Solar Powered 4G-LTE Outdoor Cellular Security Camera

+ + + +
+
+ +
+

Organic with product video

+ + + + +
+ + +
+ +
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: classificationBoundaryDom.window.document, + Node: classificationBoundaryDom.window.Node, + Element: classificationBoundaryDom.window.Element, + HTMLElement: classificationBoundaryDom.window.HTMLElement, + HTMLAnchorElement: classificationBoundaryDom.window.HTMLAnchorElement, + HTMLImageElement: classificationBoundaryDom.window.HTMLImageElement, + }); + const classificationBoundary = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const boundaryByAsin = new Map(classificationBoundary.map((item) => [item.asin, item])); + assert.equal(boundaryByAsin.get('B08ARLOSB1')?.adType, 'sponsoredBrands'); + assert.equal(boundaryByAsin.get('B08ARLOSB2')?.adType, 'sponsoredBrands'); + assert.equal(boundaryByAsin.get('B0ARLOGRID')?.adType, 'sponsoredProducts'); + assert.equal(boundaryByAsin.get('B09RINGSP1')?.adType, 'sponsoredProducts'); + assert.equal(boundaryByAsin.get('B09ORGKEEP')?.adType, 'organic'); + assert.equal(boundaryByAsin.get('B0TRACKSP1')?.adType, 'sponsoredProducts'); + assert.equal(boundaryByAsin.has('B0TRACKXX2'), false); + assert.equal(boundaryByAsin.get('B0DEMOSP01')?.adType, 'sponsoredProducts'); + assert.equal(boundaryByAsin.get('B0VOSKER02')?.adType, 'sponsoredBrands'); + assert.equal(boundaryByAsin.get('B0VOSKER02')?.adFormat, 'video'); + assert.equal(boundaryByAsin.get('B0NOADVID1')?.adType, 'organic'); + assert.equal(boundaryByAsin.get('B0BRANDWL1')?.adType, 'sponsoredBrands'); + assert.equal(boundaryByAsin.get('B0SIDEBAR1')?.adType, 'sponsoredDisplay'); + assert.equal(boundaryByAsin.get('B0SIDEBAR1')?.placement, 'sidebar'); + + const thematicLoomDom = new JSDOM( + ` +
+
+
+

Organic camera

+ +
+ + + +
+ Sponsored + +
PROTECT YOUR EQUIPMENT & FACILITIES
+
+

Vosker VKX Solar Powered camera

+ +
+
+ +
+
Arlo Security for any Home
+ Sponsored +
+ Video Doorbell + + + +
+
+
+
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: thematicLoomDom.window.document, + Node: thematicLoomDom.window.Node, + Element: thematicLoomDom.window.Element, + HTMLElement: thematicLoomDom.window.HTMLElement, + HTMLAnchorElement: thematicLoomDom.window.HTMLAnchorElement, + HTMLImageElement: thematicLoomDom.window.HTMLImageElement, + }); + const thematicLoom = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const thematicByAsin = new Map(thematicLoom.map((item) => [item.asin, item])); + assert.equal(thematicByAsin.get('B0ORGLOOM1')?.adType, 'organic'); + assert.equal(thematicByAsin.get('B0B2MPYRKP')?.adType, 'sponsoredProducts'); + assert.equal(thematicByAsin.get('B0F8HS3RRT')?.adType, 'sponsoredProducts'); + assert.equal(thematicByAsin.get('B0GQX6GHK2')?.adType, 'sponsoredProducts'); + assert.equal( + thematicByAsin.get('B0B2MPYRKP')?.placementId, + thematicByAsin.get('B0F8HS3RRT')?.placementId, + ); + assert.equal(thematicByAsin.get('B0B2MPYRKP')?.adProductIndex, 1); + assert.equal(thematicByAsin.get('B0F8HS3RRT')?.adProductIndex, 2); + assert.equal(thematicByAsin.get('B0GQX6GHK2')?.adProductIndex, 3); + assert.equal(thematicByAsin.has('B0CN95HR9V'), false); + assert.equal(thematicByAsin.has('B07M68S376'), false); + assert.equal(thematicByAsin.get('B0VOSKERLM')?.adType, 'sponsoredBrands'); + assert.equal(thematicByAsin.get('B0VOSKERLM')?.adFormat, 'video'); + assert.equal(thematicByAsin.get('B08ARLOLM1')?.adType, 'sponsoredBrands'); + + const organicLoomDom = new JSDOM( + ` +
+
+
+

Organic camera

+ +
+ +
+
+

Customers frequently viewed

+ + Sponsored + + + +
+
+ +
+

Recently bought and rated

+ + + +
+
+
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: organicLoomDom.window.document, + Node: organicLoomDom.window.Node, + Element: organicLoomDom.window.Element, + HTMLElement: organicLoomDom.window.HTMLElement, + HTMLAnchorElement: organicLoomDom.window.HTMLAnchorElement, + HTMLImageElement: organicLoomDom.window.HTMLImageElement, + }); + const organicLoom = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const organicLoomByAsin = new Map(organicLoom.map((item) => [item.asin, item])); + assert.equal(organicLoomByAsin.get('B0ORGCFV01')?.adType, 'organic'); + assert.equal(organicLoomByAsin.get('B0CFV00001')?.adType, 'organic'); + assert.equal(organicLoomByAsin.get('B0CFV00002')?.adType, 'organic'); + assert.equal(organicLoomByAsin.get('B0RBR00001')?.adType, 'organic'); + assert.equal( + organicLoom.some((item) => item.adType === 'sponsoredProducts'), + false, + ); + + const organicVideoStripDom = new JSDOM( + ` +
+
+
+

Organic camera

+ +
+ +
+ + +
Chamberlain myQ
+
Control your garage from anywhere
+
+

Chamberlain garage opener

+ +
+
+ +
+ Sponsored + + +
PROTECT YOUR EQUIPMENT
+
+

Vosker solar camera

+ +
+
+ +
+ Sponsored + + +
PROTECT YOUR EQUIPMENT
+
+

Vosker with visible sponsored

+ +
+
+
+
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: organicVideoStripDom.window.document, + Node: organicVideoStripDom.window.Node, + Element: organicVideoStripDom.window.Element, + HTMLElement: organicVideoStripDom.window.HTMLElement, + HTMLAnchorElement: organicVideoStripDom.window.HTMLAnchorElement, + HTMLImageElement: organicVideoStripDom.window.HTMLImageElement, + }); + const organicVideoStrip = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const videoStripByAsin = new Map(organicVideoStrip.map((item) => [item.asin, item])); + assert.equal(videoStripByAsin.get('B0ORGVID01')?.adType, 'organic'); + assert.equal(videoStripByAsin.get('B0NOSBV001')?.adType, 'organic'); + assert.equal(videoStripByAsin.get('B0HIDDENSB')?.adType, 'organic'); + assert.equal(videoStripByAsin.get('B0YESBV001')?.adType, 'sponsoredBrands'); + assert.equal(videoStripByAsin.get('B0YESBV001')?.adFormat, 'video'); + assert.deepEqual( + organicVideoStrip.filter((item) => item.adType === 'sponsoredBrands').map((item) => item.asin), + ['B0YESBV001'], + ); + + const liveBrandWallDom = new JSDOM( + ` +
+
+
+

Organic camera

+ +
+ +
+ Brands related to your search + Sponsored +
+ + Vosker + +
+
+ + REOLINK + +
+
+
+
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: liveBrandWallDom.window.document, + Node: liveBrandWallDom.window.Node, + Element: liveBrandWallDom.window.Element, + HTMLElement: liveBrandWallDom.window.HTMLElement, + HTMLAnchorElement: liveBrandWallDom.window.HTMLAnchorElement, + HTMLImageElement: liveBrandWallDom.window.HTMLImageElement, + }); + const liveBrandWall = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const liveBrandWallByAsin = new Map(liveBrandWall.map((item) => [item.asin, item])); + assert.equal(liveBrandWallByAsin.get('B0ORGGRID1')?.adType, 'organic'); + assert.equal(liveBrandWallByAsin.get('B0D5Z3V464')?.adType, 'sponsoredBrands'); + assert.equal(liveBrandWallByAsin.get('B0C2PY2KTJ')?.adType, 'sponsoredBrands'); + assert.equal(liveBrandWallByAsin.get('B0D5Z3V464')?.title, 'Vosker'); + assert.equal( + liveBrandWallByAsin.get('B0D5Z3V464')?.imageSrc, + 'https://example.com/vosker-wall.jpg', + ); + assert.equal( + liveBrandWallByAsin.get('B0C2PY2KTJ')?.imageSrc, + 'https://example.com/reolink-wall.jpg', + ); + assert.equal(liveBrandWallByAsin.has('B0FY2QCXRJ'), false); + + const influencerPicksDom = new JSDOM( + ` +
+
+
+

Organic camera

+ +
+ +
+

Picks from Amazon Influencers

+ Sponsored + From Amazon Influencer storefronts +
+

ANNKE 4K PoE Security Camera System

+ +
+
+

Night Owl DVR

+ storefront + +
+
+

Solar Street Lights

+ +
+
+ +
+
+ jobsite + camera + + packaging +
+
+ Sponsored +

Vosker VKX Solar Powered 4G-LTE Outdoor Cellular Security Camera

+ + + + $379.99 +
+
+ +
+
+ PROTECT YOUR INVESTMENT + CELLULAR SECURITY CAMERA + +
+
+ Sponsored +

Vosker VKX Solar Powered camera

+ + + +
+
+ +
+ Sponsored + Previously viewed +

Vosker VKX Solar Powered 4G-LTE Outdoor Cellular Security Camera

+ + + + $149.99 +
+
+ Sponsored +

Owl Labs Meeting Owl 3

+ + + + $1,099.00 +
+ + +
+
+ `, + { url: 'https://www.amazon.com/s?k=home+security' }, + ); + Object.assign(globalThis, { + document: influencerPicksDom.window.document, + Node: influencerPicksDom.window.Node, + Element: influencerPicksDom.window.Element, + HTMLElement: influencerPicksDom.window.HTMLElement, + HTMLAnchorElement: influencerPicksDom.window.HTMLAnchorElement, + HTMLImageElement: influencerPicksDom.window.HTMLImageElement, + }); + const influencerPicks = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const influencerByAsin = new Map(influencerPicks.map((item) => [item.asin, item])); + assert.equal(influencerByAsin.get('B0ORGINF01')?.adType, 'organic'); + assert.equal(influencerByAsin.get('B0ANNKE001')?.adType, 'sponsoredProducts'); + assert.equal(influencerByAsin.get('B0NIGHTO01')?.adType, 'sponsoredProducts'); + assert.equal(influencerByAsin.get('B0SOLAR001')?.adType, 'sponsoredProducts'); + assert.equal( + influencerByAsin.get('B0ANNKE001')?.placementId, + influencerByAsin.get('B0NIGHTO01')?.placementId, + ); + assert.equal(influencerByAsin.get('B0VOSKERVK')?.adType, 'sponsoredBrands'); + assert.equal(influencerByAsin.get('B0VOSKERVK')?.adFormat, 'standard'); + assert.equal(influencerByAsin.get('B0VOSKERSB')?.adType, 'sponsoredBrands'); + assert.equal(influencerByAsin.get('B0VIEWVOSK')?.adType, 'sponsoredProducts'); + assert.equal(influencerByAsin.get('B0OWLLABS1')?.adType, 'sponsoredProducts'); + assert.equal( + influencerPicks.some((item) => /related searches/i.test(item.title)), + false, + ); + console.log('Amazon search parser fixtures passed.'); } diff --git a/src/page-worker/web-injectors/amazon-search-page-ready.ts b/src/page-worker/web-injectors/amazon-search-page-ready.ts index 72e9682..58b6a5b 100644 --- a/src/page-worker/web-injectors/amazon-search-page-ready.ts +++ b/src/page-worker/web-injectors/amazon-search-page-ready.ts @@ -48,7 +48,7 @@ export async function waitForAmazonSearchPageReady() { // Visit the full result stream instead of jumping straight to pagination. Amazon loads // recommendation and sponsored modules when their region approaches the viewport. - for (const fraction of [0.2, 0.55, 0.92]) { + for (const fraction of [0.15, 0.35, 0.55, 0.75, 0.92]) { const documentHeight = Math.max( document.documentElement.scrollHeight, document.body?.scrollHeight || 0, diff --git a/src/page-worker/web-injectors/amazon-search-parser.ts b/src/page-worker/web-injectors/amazon-search-parser.ts index bb2183c..4068b81 100644 --- a/src/page-worker/web-injectors/amazon-search-parser.ts +++ b/src/page-worker/web-injectors/amazon-search-parser.ts @@ -92,14 +92,29 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) return decoded; }; + const parseAsinFromText = (value: string) => { + const decoded = decodeRepeatedly(value); + const patterns = [ + /\/(?:dp|gp\/product|gp\/aw\/d)\/([A-Z0-9]{10})(?:[/?]|$)/i, + /[?&#]asin=([A-Z0-9]{10})(?:[&]|$)/i, + /[?&#]lp_asins=([A-Z0-9]{10})/i, + /\/dp\/([A-Z0-9]{10})/i, + ]; + for (const pattern of patterns) { + const match = pattern.exec(decoded); + if (match) return match[1].toUpperCase(); + } + return undefined; + }; + const parseAsin = (node: HTMLElement, href?: string) => { const dataAsin = node.closest('[data-asin]')?.dataset.asin || node.dataset.asin; if (dataAsin && /^[A-Z0-9]{10}$/.test(dataAsin)) return dataAsin; const candidates = [href || '', decodeRepeatedly(href || '')]; for (const candidate of candidates) { - const match = /\/(?:dp|gp\/product)\/([A-Z0-9]{10})(?:[/?]|$)/i.exec(candidate); - if (match) return match[1].toUpperCase(); + const asin = parseAsinFromText(candidate); + if (asin) return asin; } return undefined; }; @@ -125,9 +140,14 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) const hasSponsoredDisclosure = (node: Element) => sponsoredDisclosureCandidates(node).some(isSponsoredDisclosure); + const closestAsinHost = (node: Element) => { + const host = node.closest('[data-asin]'); + return host && /^[A-Z0-9]{10}$/.test(host.dataset.asin || '') ? host : null; + }; + const hasModuleSponsoredDisclosure = (node: Element) => sponsoredDisclosureCandidates(node).some( - (candidate) => !candidate.closest('[data-asin]') && isSponsoredDisclosure(candidate), + (candidate) => !closestAsinHost(candidate) && isSponsoredDisclosure(candidate), ); const metadata = (node: Element) => @@ -152,6 +172,10 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) const asin = candidate.dataset.asin; if (asin && /^[A-Z0-9]{10}$/.test(asin)) asins.add(asin); }); + node.querySelectorAll('a[href]').forEach((anchor) => { + const asin = parseAsinFromText(anchor.href); + if (asin) asins.add(asin); + }); return asins.size; }; @@ -161,17 +185,117 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) const hasVisibleElement = (node: Element, selector: string) => Array.from(node.querySelectorAll(selector)).some(isVisible); + const isSearchResultCard = (node: Element) => + node.matches('[data-component-type="s-search-result"]'); + + const hasModuleHeadline = (node: Element) => + Array.from( + node.querySelectorAll( + 'h1, h2, h3, [class*="headline"], [class*="sb-headline"], [class*="sbv-"]', + ), + ).some( + (heading) => + !heading.closest('[data-asin]') && isVisible(heading) && text(heading).length > 0, + ); + + const isOutsideProductCard = (node: Element, root: Element) => { + if (node.closest('[data-component-type="s-search-result"]')) return false; + const asinHost = node.closest('[data-asin]'); + return !asinHost || asinHost === root; + }; + + const hasModuleLevelVideo = (node: Element) => + Array.from(node.querySelectorAll('video')).some((video) => isOutsideProductCard(video, node)); + + const hasModuleLevelBrandVisual = (node: Element) => { + if (hasModuleLevelVideo(node)) return true; + const playPause = Array.from( + node.querySelectorAll( + '[aria-label*="Pause" i], [aria-label*="Play" i], [title*="Pause" i], [title*="Play" i]', + ), + ).some((candidate) => isOutsideProductCard(candidate, node) && isVisible(candidate)); + if (playPause) return true; + const outsideImages = Array.from(node.querySelectorAll('img')).filter( + (image) => isOutsideProductCard(image, node) && isVisible(image), + ); + return outsideImages.length >= 2; + }; + + const hasInfluencerThematicCopy = (node: Element) => + Array.from(node.querySelectorAll('h1, h2, h3, span, a, [class*="headline"]')).some( + (candidate) => { + if (closestAsinHost(candidate) || isVisuallyHiddenText(candidate)) return false; + return /picks from amazon influencers|influencer storefronts|amazon influencer/i.test( + text(candidate), + ); + }, + ); + + const hasThematicSpSignal = (node: Element) => { + const blob = [ + metadata(node), + node.getAttribute('cel_widget_id') || '', + node.getAttribute('data-multi-ad-feedback-form-trigger') || '', + node.getAttribute('data-component-props') || '', + ] + .join(' ') + .toLowerCase(); + if (/brand-footer|multi-brand-creative|brands-related|desktop-hsa-3psl/.test(blob)) { + return false; + } + if ( + /featured.?asins.?list|sponsoredproductsloom|sp_search_thematic|loom-desktop|influencer/.test( + blob, + ) + ) { + return true; + } + return hasInfluencerThematicCopy(node); + }; + + const isSponsoredProductsThematic = (node: Element) => { + if (hasThematicSpSignal(node)) return true; + if ( + Array.from( + node.querySelectorAll( + '[cel_widget_id], [data-multi-ad-feedback-form-trigger], [id*="loom"], [class*="loom"]', + ), + ).some(hasThematicSpSignal) + ) { + return true; + } + for ( + let current = node.parentElement; + current && !isMainResultsBoundary(current); + current = current.parentElement + ) { + if (hasThematicSpSignal(current)) return true; + } + return false; + }; + const hasVisibleBrandCreative = (node: Element) => { + if (isSearchResultCard(node)) return false; + if (isSponsoredProductsThematic(node)) return false; + const visibleBrandDestination = hasVisibleElement( node, 'a[href*="/stores/"], a[href*="/brands/"], [class*="brand-logo"], [class*="brandLogo"]', ); - const visibleModuleHeadline = Array.from(node.querySelectorAll('h1, h2, h3')).some( - (heading) => - !heading.closest('[data-asin]') && isVisible(heading) && text(heading).length > 0, + const explicitBrandModule = /sponsored.?brand|brand.?ad|brands-related|brand-card|\bsbv-/.test( + metadata(node), + ); + const brandsRelatedCopy = /brands related to your search/i.test(text(node)); + const moduleSponsored = hasModuleSponsoredDisclosure(node) || hasSponsoredDisclosure(node); + const videoStrip = hasModuleLevelBrandVisual(node) && moduleSponsored; + const headlineUnit = hasModuleHeadline(node) && moduleSponsored; + return ( + visibleBrandDestination || + explicitBrandModule || + brandsRelatedCopy || + videoStrip || + headlineUnit ); - const explicitBrandModule = /sponsored.?brand|brand.?ad/.test(metadata(node)); - return visibleBrandDestination || (explicitBrandModule && visibleModuleHeadline); }; const findAdModule = (card: HTMLElement) => { @@ -216,11 +340,18 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) adFormat: 'standard' as const, }; } + if (isSearchResultCard(card)) { + return { + adType: 'sponsoredProducts' as const, + placement: 'middleOfSearch' as const, + adFormat: hasVisibleElement(card, 'video') ? ('video' as const) : ('standard' as const), + }; + } if (hasVisibleBrandCreative(module)) { return { adType: 'sponsoredBrands' as const, placement: 'middleOfSearch' as const, - adFormat: module.querySelector('video') ? ('video' as const) : ('standard' as const), + adFormat: hasModuleLevelVideo(module) ? ('video' as const) : ('standard' as const), }; } return { @@ -230,6 +361,24 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) }; }; + const hasCollectableProductLink = (node: Element) => + Boolean( + node.querySelector( + 'a[href*="/dp/"], a[href*="%2Fdp%2F"], a[href*="/gp/product"], a[href*="%2Fgp%2Fproduct%2F"], a[href*="sspa/click"], a[href*="lp_asins="]', + ), + ); + + const addCandidate = (node: HTMLElement) => { + if ( + isVisible(node) && + (node.dataset.asin || + hasCollectableProductLink(node) || + parseAsin(node, node.querySelector('a[href]')?.href)) + ) { + candidateSet.add(node); + } + }; + const candidateSet = new Set(); document .querySelectorAll( @@ -238,13 +387,7 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) .forEach((node) => { const stableCard = node.closest('[data-component-type="s-search-result"]') || node; - if ( - isVisible(stableCard) && - (stableCard.dataset.asin || - stableCard.querySelector('a[href*="/dp/"], a[href*="%2Fdp%2F"]')) - ) { - candidateSet.add(stableCard); - } + addCandidate(stableCard); }); document.querySelectorAll('[data-asin]').forEach((node) => { @@ -268,13 +411,29 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) isVisible(sidebarAd) && hasSponsoredDisclosure(sidebarAd) && sidebarAd.querySelector('h2, h3') && - sidebarAd.querySelector('img[src]') && - sidebarAd.querySelector('a[href*="/dp/"], a[href*="%2Fdp%2F"]') + sidebarAd.querySelector('img[src], img[data-src]') && + hasCollectableProductLink(sidebarAd) ) { candidateSet.add(sidebarAd); } }); + document + .querySelectorAll( + '.AdHolder, [class*="brands-related"], [class*="brand-card"], [data-component-type="sponsored-brand"]', + ) + .forEach((module) => { + if (!isVisible(module)) return; + const nestedCards = module.querySelectorAll( + '[data-asin], [class*="brand-card"], .sbv-product, [data-slot*="3psl"], [data-slot*="hsa"]', + ); + if (nestedCards.length > 0) { + nestedCards.forEach(addCandidate); + return; + } + addCandidate(module); + }); + const cards = Array.from(candidateSet) .filter( (card, index, all) => @@ -310,13 +469,22 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) if (!asin) return items; const titleNode = card.querySelector('h2[aria-label], h2, h3[aria-label], h3'); - const imageNode = card.querySelector('img.s-image, img[src]'); + const imageNode = card.querySelector( + 'img.s-image, img[src], img[data-src], img[srcset]', + ); const title = titleNode?.getAttribute('aria-label')?.trim() || text(titleNode) || imageNode?.alt?.trim() || asin; - const imageSrc = imageNode?.src; + const dataSrc = imageNode?.getAttribute('data-src') || imageNode?.dataset.src; + const srcsetUrl = (imageNode?.getAttribute('srcset') || '') + .split(',')[0] + ?.trim() + .split(/\s+/)[0]; + const rawSrc = imageNode?.src && !imageNode.src.startsWith('data:') ? imageNode.src : undefined; + const placeholderSrc = /grey-pixel|transparent-pixel|\/1x1(?:\.|$)/i.test(rawSrc || ''); + const imageSrc = (placeholderSrc ? undefined : rawSrc) || dataSrc || srcsetUrl; if (!imageSrc) return items; const currentPriceNode = card.querySelector(