Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/bsr/creators-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export function mapCreatorsItem(item, fallback = {}) {
asin: item.asin,
title: displayValue(itemInfo.title) || fallback.title,
images: [...new Set(imageCandidates)].slice(0, 6),
imageGalleryVerified: true,
price: listing?.price?.money?.amount,
listPrice: listing?.price?.savingBasis?.money?.amount,
rating:
Expand Down
16 changes: 9 additions & 7 deletions scripts/bsr/fill-quota.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,17 @@ const products = structuredClone(snap.products)
!String(u).startsWith('/brand/landing-forest') &&
!String(u).startsWith('/brand/hero'),
)
// If no Amazon photo left but we have an ASIN, keep ASIN image attempts for the client
if (cleaned.length === 0 && p.asin) {
const externallySourced =
p.source === 'amazon-bsr' || p.source === 'amazon-search'
const safeImages = externallySourced && !p.imageGalleryVerified
? cleaned.slice(0, 1)
: cleaned
// If no Amazon photo left but we have an ASIN, keep an ASIN image attempt for the client
if (safeImages.length === 0 && p.asin) {
const a = String(p.asin).toUpperCase()
cleaned.push(
`https://m.media-amazon.com/images/P/${a}.01._SCLZZZZZZZ_SX500_.jpg`,
`https://images-na.ssl-images-amazon.com/images/P/${a}.01.LZZZZZZZ.jpg`,
)
safeImages.push(`https://m.media-amazon.com/images/P/${a}.01._SCLZZZZZZZ_SX500_.jpg`)
}
return { ...p, images: cleaned }
return { ...p, images: safeImages }
})
const weekOf = snap.weekOf
const expiresAt = snap.expiresAt
Expand Down
29 changes: 16 additions & 13 deletions scripts/bsr/import-bsr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function upgradeAmazonImageUrl(url) {
}

/** Extract product photo URLs from a list-card HTML chunk. */
function extractListImages(chunk, { max = 4 } = {}) {
function extractListImages(chunk, { max = 1 } = {}) {
const found = []
const patterns = [
/src="(https:\/\/[^"]+(?:media-amazon|ssl-images-amazon)\.com\/images\/I\/[^"]+)"/gi,
Expand Down Expand Up @@ -385,7 +385,7 @@ async function enrichAsin(asin) {
return {
asin,
title,
images: images.slice(0, 6),
images: images.slice(0, 1),
price,
rating,
reviewCount,
Expand Down Expand Up @@ -587,6 +587,15 @@ function toProduct(enriched, meta, weekOf, expiresAt) {
outdoor: 120,
baby: 200,
}
const resolvedImages = resolveProductImages({
listImages: meta.images || [],
enrichedImages: enriched.images || [],
category: meta.ibambooCategory,
asin: enriched.asin,
})
const images = enriched.imageGalleryVerified
? resolvedImages
: resolvedImages.slice(0, 1)

return {
id: `bsr-${enriched.asin}`,
Expand Down Expand Up @@ -628,12 +637,8 @@ function toProduct(enriched, meta, weekOf, expiresAt) {
asin: enriched.asin,
searchKeywords: enriched.title,
badge,
images: resolveProductImages({
listImages: meta.images || [],
enrichedImages: enriched.images || [],
category: meta.ibambooCategory,
asin: enriched.asin,
}),
images,
...(enriched.imageGalleryVerified ? { imageGalleryVerified: true } : {}),
...(enriched.rating != null ? { rating: enriched.rating } : {}),
...(enriched.reviewCount != null
? { reviewCount: enriched.reviewCount }
Expand Down Expand Up @@ -873,13 +878,11 @@ async function main() {
continue
}

// Prefer Creators images; fill gaps from list-page photos
// Creators galleries are ASIN-scoped. Never mix them with scraped page
// images; use the single list-card primary only when the API has no image.
if ((!enriched.images || enriched.images.length === 0) && meta.images?.length) {
enriched.images = meta.images
} else if (meta.images?.length) {
for (const img of meta.images) {
if (!enriched.images.includes(img)) enriched.images.push(img)
}
enriched.imageGalleryVerified = false
}
console.log(
`OK ${enriched.title.slice(0, 50)} imgs=${(enriched.images || meta.images || []).length}`,
Expand Down
219 changes: 37 additions & 182 deletions src/data/bsr-snapshot.json

Large diffs are not rendered by default.

219 changes: 37 additions & 182 deletions src/data/products.bsr.generated.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface Product {
badge?: string
/** Primary + gallery images (Amazon CDN, brand, or photography) */
images: string[]
/** True only when every secondary image came from an ASIN-scoped trusted API. */
imageGalleryVerified?: boolean
rating?: number
reviewCount?: number
hue: number
Expand Down
2 changes: 1 addition & 1 deletion src/lib/amazon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

export const AMAZON_ASSOCIATE_TAG =
import.meta.env.VITE_AMAZON_ASSOCIATE_TAG || 'iu0e3-20'
import.meta.env?.VITE_AMAZON_ASSOCIATE_TAG || 'iu0e3-20'

/** Product detail page with Associates tag. */
export function amazonProductUrl(asin: string, extras?: Record<string, string>) {
Expand Down
24 changes: 19 additions & 5 deletions src/lib/productImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* busy brand lifestyle fillers as the primary merchandising image.
*/

import { amazonAsinImage } from './amazon'
import type { Category, Product } from '../data/types'
import { amazonAsinImage } from './amazon.ts'
import type { Category, Product } from '../data/types.ts'

const BUSY_BRAND_PATHS = [
'/brand/products-flatlay.webp',
Expand Down Expand Up @@ -219,6 +219,20 @@ function upgradeAmazonThumb(url: string, size: 500 | 1000 = 500): string {
.replace(/\._SS\d+_\./i, `._SL${size}_.`)
}

/**
* Scraped Amazon pages can contain recommendation images outside the active
* ASIN's gallery. Until an ASIN-scoped API verifies the gallery, trust only
* the primary catalog image and use ASIN-derived fallbacks after it.
*/
function catalogImagesForProduct(product: Product): string[] {
const images = product.images || []
const externallySourced =
product.source === 'amazon-bsr' || product.source === 'amazon-search'
return externallySourced && !product.imageGalleryVerified
? images.slice(0, 1)
: images
}

/**
* Ordered gallery for display / onError chain:
* 1) Real Amazon CDN images from catalog
Expand All @@ -245,12 +259,12 @@ export function resolveProductImages(
out.push(u)
}

for (const img of product.images || []) {
for (const img of catalogImagesForProduct(product)) {
if (isAmazonCdnImage(img)) push(img)
}

// Non-Amazon, non-busy extras (e.g. local brand product photography later)
for (const img of product.images || []) {
for (const img of catalogImagesForProduct(product)) {
if (
!isAmazonCdnImage(img) &&
!isBusyBrandFallback(img) &&
Expand Down Expand Up @@ -284,7 +298,7 @@ export function galleryThumbImages(
): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const img of product.images || []) {
for (const img of catalogImagesForProduct(product)) {
if (!isReliableAmazonImage(img)) continue
const u = upgradeAmazonThumb(img.trim(), size)
if (!u || seen.has(u)) continue
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/content-balloon-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { bsrProducts } from '../../src/data/products.bsr.generated'

const PRODUCT_PATH = '/product/riveira-dark-bamboo-wooden-spoons-for-cooking-6-piece-apartment-essentials-wood-'
const NIAGARA_PATH = '/product/niagara-sleep-solution-ultra-soft-queen-size-mattress-topper-rayon-derived-from-'
const SMIRLY_PATH = '/product/smirly-bamboo-cutting-boards-for-kitchen-wood-cutting-board-for-meal-prep-servin'
const WIDTHS = [390, 768, 1024, 1440, 2560]
const products = (() => {
const seenAsins = new Set<string>()
Expand Down Expand Up @@ -143,6 +144,17 @@ test('a standard PDP has three useful, separated placements', async ({ page }) =
)).size).toBe(3)
})

test('an unverified Amazon gallery never exposes recommendation images', async ({ page }) => {
await page.goto(SMIRLY_PATH)
const media = page.locator('[data-product-surface="media"]')
await expect(media.locator('img')).toHaveAttribute('src', /81FoZNCStHL/)
await expect(media.locator('[data-has-thumbnail-rail]')).toHaveAttribute(
'data-has-thumbnail-rail',
'false',
)
await expect(media.locator('button')).toHaveCount(0)
})

test('mobile reads image, purchase decision, then specifications', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await loadWithFacts(page, NIAGARA_PATH)
Expand Down
58 changes: 58 additions & 0 deletions tests/product-images.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'

import { galleryThumbImages, resolveProductImages } from '../src/lib/productImages.ts'

const primary = 'https://m.media-amazon.com/images/I/primary._AC_SL1000_.jpg'
const unrelated = [
'https://m.media-amazon.com/images/I/recommendation-one.jpg',
'https://m.media-amazon.com/images/I/recommendation-two.jpg',
'https://m.media-amazon.com/images/I/recommendation-three.jpg',
]
const product = {
id: 'bsr-B0829DCVN6',
slug: 'smirly-cutting-boards',
name: 'SMIRLY Bamboo Cutting Boards',
tagline: 'Weekly list',
description: 'A cutting board set.',
category: 'cutting-boards',
collection: 'Boards',
material: 'Bamboo',
features: [],
specs: [],
priceHint: 0,
asin: 'B0829DCVN6',
searchKeywords: 'SMIRLY cutting boards',
images: [primary, ...unrelated],
hue: 70,
source: 'amazon-bsr',
}

test('unverified Amazon catalog entries expose only their primary image', () => {
assert.deepEqual(galleryThumbImages(product, 1000), [primary])
const resolved = resolveProductImages(product, 1000)
assert.equal(resolved[0], primary)
assert.ok(unrelated.every((url) => !resolved.includes(url)))
})

test('ASIN-scoped verified galleries retain their secondary images', () => {
const verified = { ...product, imageGalleryVerified: true }
assert.deepEqual(galleryThumbImages(verified, 1000), [primary, ...unrelated])
})

test('generated Amazon catalog persists no unverified secondary images', () => {
const snapshot = JSON.parse(
readFileSync(new URL('../src/data/bsr-snapshot.json', import.meta.url), 'utf8'),
)
const unsafe = snapshot.products.filter(
(entry) =>
['amazon-bsr', 'amazon-search'].includes(entry.source) &&
!entry.imageGalleryVerified &&
entry.images.length > 1,
)
assert.deepEqual(
unsafe.map((entry) => entry.asin),
[],
)
})
2 changes: 1 addition & 1 deletion worker/generated/routeMeta.json

Large diffs are not rendered by default.

Loading