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
+ 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]',