diff --git a/scripts/test-amazon-search-parser.ts b/scripts/test-amazon-search-parser.ts index 33f40b5..e58c9b9 100644 --- a/scripts/test-amazon-search-parser.ts +++ b/scripts/test-amazon-search-parser.ts @@ -1127,6 +1127,80 @@ async function main() { false, ); + const unknownTemplatesDom = new JSDOM( + ` +
+
+
+

Generic organic product

+ +
+ +
+

A heading that is not Shop by type

+ Sponsored +
+

Future SP one

+ + + +
+
+

Future SP two

+ + + +
+
+ +
+ Sponsored + +

Future Brand collection

+
+

Future SB product

+ +
+
+ +
+ Sponsored +
+

Future SD product

+ +
+
+
+
+ `, + { url: 'https://www.amazon.com/s?k=future-layout' }, + ); + Object.assign(globalThis, { + document: unknownTemplatesDom.window.document, + Node: unknownTemplatesDom.window.Node, + Element: unknownTemplatesDom.window.Element, + HTMLElement: unknownTemplatesDom.window.HTMLElement, + HTMLAnchorElement: unknownTemplatesDom.window.HTMLAnchorElement, + HTMLImageElement: unknownTemplatesDom.window.HTMLImageElement, + }); + const unknownTemplates = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const unknownTemplatesByAsin = new Map(unknownTemplates.map((item) => [item.asin, item])); + assert.equal(unknownTemplatesByAsin.get('B0GENORG01')?.adType, 'organic'); + assert.equal(unknownTemplatesByAsin.get('B0GENSP001')?.adType, 'sponsoredProducts'); + assert.equal(unknownTemplatesByAsin.get('B0GENSP002')?.adType, 'sponsoredProducts'); + assert.equal( + unknownTemplatesByAsin.get('B0GENSP001')?.placementId, + unknownTemplatesByAsin.get('B0GENSP002')?.placementId, + ); + assert.equal(unknownTemplatesByAsin.get('B0GENSB001')?.adType, 'sponsoredBrands'); + assert.equal(unknownTemplatesByAsin.get('B0GENSD001')?.adType, 'sponsoredDisplay'); + assert.ok( + ['B0GENSP001', 'B0GENSP002', 'B0GENSB001', 'B0GENSD001'].every((asin) => + unknownTemplatesByAsin.has(asin), + ), + 'Visible SP/SB/SD products must be collected even when their wrapper classes are unknown', + ); + console.log('Amazon search parser fixtures passed.'); } diff --git a/src/page-worker/web-injectors/amazon-search-parser.ts b/src/page-worker/web-injectors/amazon-search-parser.ts index a708a0d..8ea067c 100644 --- a/src/page-worker/web-injectors/amazon-search-parser.ts +++ b/src/page-worker/web-injectors/amazon-search-parser.ts @@ -239,8 +239,8 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) }, ); - const hasThematicSpSignal = (node: Element) => { - const blob = [ + const thematicMetadata = (node: Element) => + [ metadata(node), node.getAttribute('cel_widget_id') || '', node.getAttribute('data-multi-ad-feedback-form-trigger') || '', @@ -248,16 +248,19 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) ] .join(' ') .toLowerCase(); + + const hasDirectThematicSpSignal = (node: Element) => { + const blob = thematicMetadata(node); 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|shop.?by.?type|shop_by_type/.test( - blob, - ) - ) { - return true; - } + return /featured.?asins.?list|sponsored.?products|sp_search_thematic|loom-desktop|influencer|shop.?by.?type|shop_by_type/.test( + blob, + ); + }; + + const hasThematicSpSignal = (node: Element) => { + if (hasDirectThematicSpSignal(node)) return true; return hasInfluencerThematicCopy(node) || hasShopByTypeCopy(node); }; @@ -348,7 +351,7 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) adFormat: 'standard' as const, }; } - if (isSearchResultCard(card)) { + if (isSearchResultCard(card) || isSponsoredProductsThematic(module)) { return { adType: 'sponsoredProducts' as const, placement: 'middleOfSearch' as const, @@ -466,6 +469,58 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) }); }; + /** + * Amazon frequently changes the wrapper/template names used for ads. Discover a + * sponsored unit from its visible disclosure first, then find the closest + * ancestor that actually owns one or more product links. This keeps template + * names and marketing headings out of the SP/SB/SD classification contract. + */ + const findSponsoredProductScope = (disclosure: HTMLElement) => { + for ( + let current = disclosure.parentElement; + current && !isMainResultsBoundary(current); + current = current.parentElement + ) { + if ( + isVisible(current) && + (hasExplicitAdMetadata(current) || hasDirectThematicSpSignal(current)) && + hasCollectableProductLink(current) && + current.querySelector('img[src], img[data-src], img[srcset]') + ) { + return current; + } + } + + const searchResult = disclosure.closest('[data-component-type="s-search-result"]'); + if (searchResult) return searchResult; + + const asinHost = closestAsinHost(disclosure); + if (asinHost) return asinHost; + + for ( + let current = disclosure.parentElement; + current && !isMainResultsBoundary(current); + current = current.parentElement + ) { + if ( + isVisible(current) && + hasCollectableProductLink(current) && + current.querySelector('img[src], img[data-src], img[srcset]') + ) { + return current; + } + } + return null; + }; + + sponsoredDisclosureCandidates(document.body).forEach((disclosure) => { + if (!isSponsoredDisclosure(disclosure)) return; + const scope = findSponsoredProductScope(disclosure); + if (!scope) return; + collectThematicModuleCards(scope); + addCandidate(scope); + }); + document .querySelectorAll( '.s-widget-container, [class*="celwidget"], [class*="s-widget"], [class*="shop-by-type"], [cel_widget_id*="SHOP_BY_TYPE" i], [cel_widget_id*="shop-by-type" i]',