fix: classify visible Amazon ad formats

This commit is contained in:
syrup 2026-09-02 11:11:05 +08:00
parent d42e19f42f
commit 100b4501e6
2 changed files with 117 additions and 42 deletions

View File

@ -6,6 +6,8 @@ const html = `
<main id="search"> <main id="search">
<section data-component-type="sponsored-brand"> <section data-component-type="sponsored-brand">
<span aria-label="Sponsored">Sponsored</span> <span aria-label="Sponsored">Sponsored</span>
<a href="/stores/example"><span class="brand-logo">Example brand</span></a>
<h2>Example brand headline</h2>
<article data-asin="B000000001"> <article data-asin="B000000001">
<h3>Brand product one</h3> <h3>Brand product one</h3>
<a href="/dp/B000000001"><img src="https://example.com/brand-1.jpg" /></a> <a href="/dp/B000000001"><img src="https://example.com/brand-1.jpg" /></a>
@ -51,6 +53,8 @@ const html = `
<section class="bottom-sponsored-video-module"> <section class="bottom-sponsored-video-module">
<video></video> <video></video>
<span>Sponsored</span> <span>Sponsored</span>
<a href="/stores/video-brand"><span class="brand-logo">Video brand</span></a>
<h2>Video brand headline</h2>
<div class="puis-card-container"> <div class="puis-card-container">
<div class="puisg-row"> <div class="puisg-row">
<article data-asin="B000000008"> <article data-asin="B000000008">
@ -117,7 +121,7 @@ async function main() {
assert.equal(byAsin.get('B000000001')?.placementId, byAsin.get('B000000002')?.placementId); assert.equal(byAsin.get('B000000001')?.placementId, byAsin.get('B000000002')?.placementId);
assert.equal(byAsin.get('B000000001')?.adProductIndex, 1); assert.equal(byAsin.get('B000000001')?.adProductIndex, 1);
assert.equal(byAsin.get('B000000002')?.adProductIndex, 2); 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('B000000005')?.placement, 'bottomOfSearch');
assert.equal(byAsin.get('B000000006')?.adType, 'sponsoredDisplay'); assert.equal(byAsin.get('B000000006')?.adType, 'sponsoredDisplay');
assert.equal(byAsin.get('B000000006')?.placement, 'sidebar'); assert.equal(byAsin.get('B000000006')?.placement, 'sidebar');
@ -163,6 +167,55 @@ async function main() {
assert.equal(hiddenDisclosureItems.length, 1); assert.equal(hiddenDisclosureItems.length, 1);
assert.equal(hiddenDisclosureItems[0].adType, 'organic'); assert.equal(hiddenDisclosureItems[0].adType, 'organic');
const groupedAdsDom = new JSDOM(
`
<main id="search">
<div class="s-main-slot">
<section class="shared-sponsored-products">
<span aria-label="Sponsored">Sponsored</span>
<a href="/stores/reolink" style="display: none">Hidden brand destination</a>
<article data-asin="B000000012">
<h3>Grouped sponsored product one</h3>
<a href="/dp/B000000012"><img src="https://example.com/grouped-1.jpg" /></a>
</article>
<article data-asin="B000000013">
<h3>Grouped sponsored product two</h3>
<a href="/dp/B000000013"><img src="https://example.com/grouped-2.jpg" /></a>
</article>
</section>
</div>
<aside class="left-sponsored-display" data-component-type="sponsored-display">
<span aria-label="Sponsored">Sponsored</span>
<article>
<h3>Left rail display product</h3>
<a href="/dp/B000000014"><img src="https://example.com/display-left.jpg" /></a>
</article>
</aside>
</main>
`,
{ 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.'); console.log('Amazon search parser fixtures passed.');
} }

View File

@ -104,26 +104,32 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload)
return undefined; return undefined;
}; };
const hasSponsoredDisclosure = (node: Element) => { const sponsoredDisclosureCandidates = (node: Element) =>
const candidates = [ [
...(node.matches('[aria-label], [title], span') ? [node] : []), ...(node.matches('[aria-label], [title], span') ? [node] : []),
...Array.from(node.querySelectorAll('[aria-label], [title], span')), ...Array.from(node.querySelectorAll('[aria-label], [title], span')),
].filter((candidate): candidate is HTMLElement => candidate instanceof HTMLElement); ].filter((candidate): candidate is HTMLElement => candidate instanceof HTMLElement);
return candidates.some((candidate) => {
if (isVisuallyHiddenText(candidate)) return false; const isSponsoredDisclosure = (candidate: HTMLElement) => {
const accessibleLabels = [ if (isVisuallyHiddenText(candidate)) return false;
candidate.getAttribute('aria-label'), const accessibleLabels = [
candidate.getAttribute('title'), candidate.getAttribute('aria-label'),
]; candidate.getAttribute('title'),
if ( ];
accessibleLabels.some((value) => /(?:^|\b)Sponsored(?:\b|\s|$)|赞助/i.test(value || '')) if (accessibleLabels.some((value) => /(?:^|\b)Sponsored(?:\b|\s|$)|赞助/i.test(value || ''))) {
) { return true;
return true; }
} return candidate.matches('span') && /^(Sponsored|赞助)(?:\b|\s|$)/i.test(text(candidate));
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) => const metadata = (node: Element) =>
[ [
node.getAttribute('data-component-type'), node.getAttribute('data-component-type'),
@ -152,15 +158,21 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload)
const hasExplicitAdMetadata = (node: Element) => const hasExplicitAdMetadata = (node: Element) =>
/sponsored.?brand|brand.?ad|display.?ad|sponsored.?display|adholder/.test(metadata(node)); /sponsored.?brand|brand.?ad|display.?ad|sponsored.?display|adholder/.test(metadata(node));
const hasBrandCreative = (node: Element) => const hasVisibleElement = (node: Element, selector: string) =>
/sponsored.?brand|brand.?ad/.test(metadata(node)) || Array.from(node.querySelectorAll<HTMLElement>(selector)).some(isVisible);
Boolean(node.querySelector('video')) ||
(uniqueAsinsIn(node) > 1 && const hasVisibleBrandCreative = (node: Element) => {
Boolean( const visibleBrandDestination = hasVisibleElement(
node.querySelector( node,
'a[href*="/stores/"], a[href*="/brands/"], [class*="brand-logo"], [class*="brandLogo"]', 'a[href*="/stores/"], a[href*="/brands/"], [class*="brand-logo"], [class*="brandLogo"]',
), );
)); const visibleModuleHeadline = Array.from(node.querySelectorAll<HTMLElement>('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) => { const findAdModule = (card: HTMLElement) => {
let current: HTMLElement | null = card; let current: HTMLElement | null = card;
@ -168,11 +180,11 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload)
for (; current; current = current.parentElement) { for (; current; current = current.parentElement) {
if (isMainResultsBoundary(current)) break; if (isMainResultsBoundary(current)) break;
const explicitModule = hasExplicitAdMetadata(current); const explicitModule = hasExplicitAdMetadata(current);
const brandedSponsoredModule = const moduleDisclosure = current !== card && hasModuleSponsoredDisclosure(current);
current !== card && hasSponsoredDisclosure(current) && hasBrandCreative(current); const brandedSponsoredModule = moduleDisclosure && hasVisibleBrandCreative(current);
const singleProductSponsored = const sharedSponsoredProductsModule = moduleDisclosure && uniqueAsinsIn(current) > 1;
current !== card && hasSponsoredDisclosure(current) && uniqueAsinsIn(current) === 1; const singleProductSponsored = moduleDisclosure && uniqueAsinsIn(current) === 1;
if (explicitModule || brandedSponsoredModule) return current; if (explicitModule || brandedSponsoredModule || sharedSponsoredProductsModule) return current;
if (singleProductSponsored && !singleProductModule) singleProductModule = current; if (singleProductSponsored && !singleProductModule) singleProductModule = current;
} }
return singleProductModule || card; 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"]', '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) { if (!sponsored) {
return { return {
@ -203,24 +216,17 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload)
adFormat: 'standard' as const, adFormat: 'standard' as const,
}; };
} }
if (hasBrandCreative(module)) { if (hasVisibleBrandCreative(module)) {
return { return {
adType: 'sponsoredBrands' as const, adType: 'sponsoredBrands' as const,
placement: 'middleOfSearch' as const, placement: 'middleOfSearch' as const,
adFormat: module.querySelector('video') ? ('video' as const) : ('standard' 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 { return {
adType: 'unknownSponsored' as const, adType: 'sponsoredProducts' as const,
placement: 'unknown' as const, placement: 'middleOfSearch' as const,
adFormat: 'unknown' as const, adFormat: hasVisibleElement(module, 'video') ? ('video' as const) : ('standard' as const),
}; };
}; };
@ -253,6 +259,22 @@ export async function collectAmazonSearchPageData(_: SearchPageCollectorPayload)
} }
}); });
document
.querySelectorAll<HTMLElement>(
'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) const cards = Array.from(candidateSet)
.filter( .filter(
(card, index, all) => (card, index, all) =>