fix: classify sponsored ads across new Amazon templates

This commit is contained in:
syrup 2026-09-04 15:05:47 +08:00
parent 4c26b108a7
commit 3c0e0e4c33
2 changed files with 139 additions and 10 deletions

View File

@ -1127,6 +1127,80 @@ async function main() {
false,
);
const unknownTemplatesDom = new JSDOM(
`
<main id="search">
<div class="s-main-slot">
<article data-component-type="s-search-result" data-asin="B0GENORG01">
<h2>Generic organic product</h2>
<a href="/dp/B0GENORG01"><img src="https://example.com/generic-organic.jpg" /></a>
</article>
<section class="future-layout-alpha" data-ad-details='{"adType":"sponsoredProducts"}'>
<h2>A heading that is not Shop by type</h2>
<span aria-label="Sponsored">Sponsored</span>
<div class="future-card-one">
<h3>Future SP one</h3>
<a href="https://redirect.amazon.com/x/c/new1?url=%2Fdp%2FB0GENSP001">
<img src="https://example.com/generic-sp-1.jpg" />
</a>
</div>
<div class="future-card-two">
<h3>Future SP two</h3>
<a href="https://direct.amazon.com/x/c/new2/https://www.amazon.com/dp/B0GENSP002">
<img src="https://example.com/generic-sp-2.jpg" />
</a>
</div>
</section>
<section class="future-layout-beta" data-component-type="sponsored-brand-vNext">
<span title="Sponsored">Sponsored</span>
<a href="/stores/future-brand"><span class="brand-logo">Future Brand</span></a>
<h2 class="future-copy">Future Brand collection</h2>
<div data-asin="B0GENSB001">
<h3>Future SB product</h3>
<a href="/dp/B0GENSB001"><img src="https://example.com/generic-sb.jpg" /></a>
</div>
</section>
<section class="future-layout-gamma" data-ad-details='{"adType":"displayAd"}'>
<span aria-label="Sponsored">Sponsored</span>
<div data-asin="B0GENSD001">
<h3>Future SD product</h3>
<a href="/dp/B0GENSD001"><img src="https://example.com/generic-sd.jpg" /></a>
</div>
</section>
</div>
</main>
`,
{ 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.');
}

View File

@ -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<HTMLElement>('[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<HTMLElement>(
'.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]',