From 100b4501e681ce199a184cf7e56ab54f2cbbed83 Mon Sep 17 00:00:00 2001 From: syrup <1032771964@qq.com> Date: Wed, 2 Sep 2026 11:11:05 +0800 Subject: [PATCH] fix: classify visible Amazon ad formats --- scripts/test-amazon-search-parser.ts | 55 ++++++++- .../web-injectors/amazon-search-parser.ts | 104 +++++++++++------- 2 files changed, 117 insertions(+), 42 deletions(-) diff --git a/scripts/test-amazon-search-parser.ts b/scripts/test-amazon-search-parser.ts index a4fd6a9..83ec123 100644 --- a/scripts/test-amazon-search-parser.ts +++ b/scripts/test-amazon-search-parser.ts @@ -6,6 +6,8 @@ const html = `
Sponsored + +

Example brand headline

Brand product one

@@ -51,6 +53,8 @@ const html = `
Sponsored + +

Video brand headline

@@ -117,7 +121,7 @@ async function main() { assert.equal(byAsin.get('B000000001')?.placementId, byAsin.get('B000000002')?.placementId); assert.equal(byAsin.get('B000000001')?.adProductIndex, 1); assert.equal(byAsin.get('B000000002')?.adProductIndex, 2); - assert.equal(byAsin.get('B000000005')?.adType, 'unknownSponsored'); + assert.equal(byAsin.get('B000000005')?.adType, 'sponsoredProducts'); assert.equal(byAsin.get('B000000005')?.placement, 'bottomOfSearch'); assert.equal(byAsin.get('B000000006')?.adType, 'sponsoredDisplay'); assert.equal(byAsin.get('B000000006')?.placement, 'sidebar'); @@ -163,6 +167,55 @@ async function main() { assert.equal(hiddenDisclosureItems.length, 1); assert.equal(hiddenDisclosureItems[0].adType, 'organic'); + const groupedAdsDom = new JSDOM( + ` +
+
+
+ Sponsored + Hidden brand destination +
+

Grouped sponsored product one

+ +
+
+

Grouped sponsored product two

+ +
+
+
+ +
+ `, + { url: 'https://www.amazon.com/s?k=mirror&page=8' }, + ); + Object.assign(globalThis, { + document: groupedAdsDom.window.document, + Node: groupedAdsDom.window.Node, + Element: groupedAdsDom.window.Element, + HTMLElement: groupedAdsDom.window.HTMLElement, + HTMLAnchorElement: groupedAdsDom.window.HTMLAnchorElement, + HTMLImageElement: groupedAdsDom.window.HTMLImageElement, + }); + const groupedAds = await collectAmazonSearchPageData({ pattern: 'pattern-2' }); + const groupedAdsByAsin = new Map(groupedAds.map((item) => [item.asin, item])); + assert.equal(groupedAdsByAsin.get('B000000012')?.adType, 'sponsoredProducts'); + assert.equal(groupedAdsByAsin.get('B000000013')?.adType, 'sponsoredProducts'); + assert.equal( + groupedAdsByAsin.get('B000000012')?.placementId, + groupedAdsByAsin.get('B000000013')?.placementId, + ); + assert.equal(groupedAdsByAsin.get('B000000012')?.adProductIndex, 1); + assert.equal(groupedAdsByAsin.get('B000000013')?.adProductIndex, 2); + assert.equal(groupedAdsByAsin.get('B000000014')?.adType, 'sponsoredDisplay'); + assert.equal(groupedAdsByAsin.get('B000000014')?.placement, 'sidebar'); + 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 0882e95..bb2183c 100644 --- a/src/page-worker/web-injectors/amazon-search-parser.ts +++ b/src/page-worker/web-injectors/amazon-search-parser.ts @@ -104,26 +104,32 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) return undefined; }; - const hasSponsoredDisclosure = (node: Element) => { - const candidates = [ + const sponsoredDisclosureCandidates = (node: Element) => + [ ...(node.matches('[aria-label], [title], span') ? [node] : []), ...Array.from(node.querySelectorAll('[aria-label], [title], span')), ].filter((candidate): candidate is HTMLElement => candidate instanceof HTMLElement); - return candidates.some((candidate) => { - if (isVisuallyHiddenText(candidate)) return false; - const accessibleLabels = [ - candidate.getAttribute('aria-label'), - candidate.getAttribute('title'), - ]; - if ( - accessibleLabels.some((value) => /(?:^|\b)Sponsored(?:\b|\s|$)|赞助/i.test(value || '')) - ) { - return true; - } - return candidate.matches('span') && /^(Sponsored|赞助)(?:\b|\s|$)/i.test(text(candidate)); - }); + + const isSponsoredDisclosure = (candidate: HTMLElement) => { + if (isVisuallyHiddenText(candidate)) return false; + const accessibleLabels = [ + candidate.getAttribute('aria-label'), + candidate.getAttribute('title'), + ]; + if (accessibleLabels.some((value) => /(?:^|\b)Sponsored(?:\b|\s|$)|赞助/i.test(value || ''))) { + return true; + } + return candidate.matches('span') && /^(Sponsored|赞助)(?:\b|\s|$)/i.test(text(candidate)); }; + const hasSponsoredDisclosure = (node: Element) => + sponsoredDisclosureCandidates(node).some(isSponsoredDisclosure); + + const hasModuleSponsoredDisclosure = (node: Element) => + sponsoredDisclosureCandidates(node).some( + (candidate) => !candidate.closest('[data-asin]') && isSponsoredDisclosure(candidate), + ); + const metadata = (node: Element) => [ node.getAttribute('data-component-type'), @@ -152,15 +158,21 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) const hasExplicitAdMetadata = (node: Element) => /sponsored.?brand|brand.?ad|display.?ad|sponsored.?display|adholder/.test(metadata(node)); - const hasBrandCreative = (node: Element) => - /sponsored.?brand|brand.?ad/.test(metadata(node)) || - Boolean(node.querySelector('video')) || - (uniqueAsinsIn(node) > 1 && - Boolean( - node.querySelector( - 'a[href*="/stores/"], a[href*="/brands/"], [class*="brand-logo"], [class*="brandLogo"]', - ), - )); + const hasVisibleElement = (node: Element, selector: string) => + Array.from(node.querySelectorAll(selector)).some(isVisible); + + const hasVisibleBrandCreative = (node: Element) => { + 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/.test(metadata(node)); + return visibleBrandDestination || (explicitBrandModule && visibleModuleHeadline); + }; const findAdModule = (card: HTMLElement) => { let current: HTMLElement | null = card; @@ -168,11 +180,11 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) for (; current; current = current.parentElement) { if (isMainResultsBoundary(current)) break; const explicitModule = hasExplicitAdMetadata(current); - const brandedSponsoredModule = - current !== card && hasSponsoredDisclosure(current) && hasBrandCreative(current); - const singleProductSponsored = - current !== card && hasSponsoredDisclosure(current) && uniqueAsinsIn(current) === 1; - if (explicitModule || brandedSponsoredModule) return current; + const moduleDisclosure = current !== card && hasModuleSponsoredDisclosure(current); + const brandedSponsoredModule = moduleDisclosure && hasVisibleBrandCreative(current); + const sharedSponsoredProductsModule = moduleDisclosure && uniqueAsinsIn(current) > 1; + const singleProductSponsored = moduleDisclosure && uniqueAsinsIn(current) === 1; + if (explicitModule || brandedSponsoredModule || sharedSponsoredProductsModule) return current; if (singleProductSponsored && !singleProductModule) singleProductModule = current; } return singleProductModule || card; @@ -187,7 +199,8 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) 'aside, #rightResultsATF, .s-right-column, [data-cel-widget*="right"], [data-component-type*="sidebar"]', ), ); - const sponsored = hasSponsoredDisclosure(card) || hasSponsoredDisclosure(module); + const sponsored = + hasSponsoredDisclosure(card) || (module !== card && hasModuleSponsoredDisclosure(module)); if (!sponsored) { return { @@ -203,24 +216,17 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) adFormat: 'standard' as const, }; } - if (hasBrandCreative(module)) { + if (hasVisibleBrandCreative(module)) { return { adType: 'sponsoredBrands' as const, placement: 'middleOfSearch' as const, adFormat: module.querySelector('video') ? ('video' as const) : ('standard' as const), }; } - if (hasSponsoredDisclosure(card)) { - return { - adType: 'sponsoredProducts' as const, - placement: 'middleOfSearch' as const, - adFormat: 'standard' as const, - }; - } return { - adType: 'unknownSponsored' as const, - placement: 'unknown' as const, - adFormat: 'unknown' as const, + adType: 'sponsoredProducts' as const, + placement: 'middleOfSearch' as const, + adFormat: hasVisibleElement(module, 'video') ? ('video' as const) : ('standard' as const), }; }; @@ -253,6 +259,22 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload) } }); + document + .querySelectorAll( + 'aside, #rightResultsATF, .s-right-column, [data-cel-widget*="right"], [data-component-type*="sidebar"], [data-component-type*="sponsored-display"]', + ) + .forEach((sidebarAd) => { + if ( + isVisible(sidebarAd) && + hasSponsoredDisclosure(sidebarAd) && + sidebarAd.querySelector('h2, h3') && + sidebarAd.querySelector('img[src]') && + sidebarAd.querySelector('a[href*="/dp/"], a[href*="%2Fdp%2F"]') + ) { + candidateSet.add(sidebarAd); + } + }); + const cards = Array.from(candidateSet) .filter( (card, index, all) =>