diff --git a/setup/scripts/discover-product-images.mjs b/setup/scripts/discover-product-images.mjs
new file mode 100755
index 0000000..e270fa5
--- /dev/null
+++ b/setup/scripts/discover-product-images.mjs
@@ -0,0 +1,653 @@
+#!/usr/bin/env node
+
+import { fileURLToPath } from 'node:url'
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import crypto from 'node:crypto'
+import sharp from 'sharp'
+
+const REQUIRED_HEADERS = [
+ 'external_key',
+ 'product_title',
+ 'product_url',
+ 'affiliate_url',
+ 'retailer',
+ 'brand',
+ 'image_source_url',
+ 'image_status',
+ 'image_asset_path',
+ 'alt_text',
+ 'notes',
+ 'last_checked',
+ 'image_provenance',
+ 'image_checksum',
+ 'manual_override_url',
+ 'manual_crop_notes',
+]
+
+function parseArgs(argv) {
+ const options = {
+ inventory: '',
+ cacheDir: '',
+ publicRoot: '',
+ limit: '',
+ key: '',
+ }
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const entry = argv[index]
+ if (entry === '--inventory') {
+ options.inventory = argv[++index] ?? ''
+ continue
+ }
+ if (entry.startsWith('--inventory=')) {
+ options.inventory = entry.slice('--inventory='.length)
+ continue
+ }
+ if (entry === '--cache-dir') {
+ options.cacheDir = argv[++index] ?? ''
+ continue
+ }
+ if (entry.startsWith('--cache-dir=')) {
+ options.cacheDir = entry.slice('--cache-dir='.length)
+ continue
+ }
+ if (entry === '--public-root') {
+ options.publicRoot = argv[++index] ?? ''
+ continue
+ }
+ if (entry.startsWith('--public-root=')) {
+ options.publicRoot = entry.slice('--public-root='.length)
+ continue
+ }
+ if (entry === '--limit') {
+ options.limit = argv[++index] ?? ''
+ continue
+ }
+ if (entry.startsWith('--limit=')) {
+ options.limit = entry.slice('--limit='.length)
+ continue
+ }
+ if (entry === '--key') {
+ options.key = argv[++index] ?? ''
+ continue
+ }
+ if (entry.startsWith('--key=')) {
+ options.key = entry.slice('--key='.length)
+ }
+ }
+
+ return options
+}
+
+function ensureDir(dir) {
+ return fs.mkdir(dir, { recursive: true })
+}
+
+function escapeCsv(value) {
+ const text = value == null ? '' : String(value)
+ if (/[",\n\r]/.test(text)) {
+ return `"${text.replace(/"/g, '""')}"`
+ }
+ return text
+}
+
+async function readCsv(filePath) {
+ const raw = await fs.readFile(filePath, 'utf8')
+ const rows = []
+ let row = []
+ let cell = ''
+ let inQuotes = false
+
+ for (let index = 0; index < raw.length; index += 1) {
+ const char = raw[index]
+ const next = raw[index + 1]
+
+ if (inQuotes) {
+ if (char === '"') {
+ if (next === '"') {
+ cell += '"'
+ index += 1
+ } else {
+ inQuotes = false
+ }
+ } else {
+ cell += char
+ }
+ continue
+ }
+
+ if (char === '"') {
+ inQuotes = true
+ continue
+ }
+
+ if (char === ',') {
+ row.push(cell)
+ cell = ''
+ continue
+ }
+
+ if (char === '\n') {
+ row.push(cell)
+ rows.push(row)
+ row = []
+ cell = ''
+ continue
+ }
+
+ if (char !== '\r') {
+ cell += char
+ }
+ }
+
+ if (cell !== '' || row.length > 0) {
+ row.push(cell)
+ rows.push(row)
+ }
+
+ const [headerRow, ...dataRows] = rows
+ if (!headerRow) {
+ return { headers: [], rows: [] }
+ }
+
+ const mappedRows = dataRows
+ .filter((values) => values.some((value) => String(value ?? '').trim() !== ''))
+ .map((values) => {
+ const record = {}
+ headerRow.forEach((header, index) => {
+ record[header] = values[index] ?? ''
+ })
+ return record
+ })
+
+ return { headers: headerRow, rows: mappedRows }
+}
+
+async function writeCsv(filePath, headers, rows) {
+ const lines = [headers.map(escapeCsv).join(',')]
+ for (const row of rows) {
+ lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(','))
+ }
+
+ await fs.writeFile(filePath, `${lines.join('\n')}\n`, 'utf8')
+}
+
+function mergeHeaders(baseHeaders, existingHeaders) {
+ const headers = [...baseHeaders]
+ for (const header of existingHeaders) {
+ if (!headers.includes(header)) {
+ headers.push(header)
+ }
+ }
+ return headers
+}
+
+function cleanValue(value) {
+ return String(value ?? '').trim()
+}
+
+function formatTimestamp(date) {
+ return date.toISOString().slice(0, 19)
+}
+
+function inferRetailerName(url) {
+ if (!url) {
+ return ''
+ }
+ const host = cleanValue(new URL(url).hostname).replace(/^www\./, '')
+ const map = new Map([
+ ['amazon.com', 'Amazon'],
+ ['amazon.co.uk', 'Amazon'],
+ ['bandai.co.jp', 'Bandai'],
+ ['banpresto.jp', 'Banpresto'],
+ ['bbts.com', 'BigBadToyStore'],
+ ['crunchyroll.com', 'Crunchyroll Store'],
+ ['etsy.com', 'Etsy'],
+ ['gamestop.com', 'GameStop'],
+ ])
+
+ return map.get(host) ?? (host ? host.replace(/[-.]/g, ' ').replace(/\b\w/g, (match) => match.toUpperCase()) : '')
+}
+
+function inferProvenance(url) {
+ if (!url) {
+ return 'editorial_placeholder'
+ }
+ const host = cleanValue(new URL(url).hostname).replace(/^www\./, '')
+ if (!host || host.includes('example.com')) {
+ return 'editorial_placeholder'
+ }
+ if (host.includes('bandai') || host.includes('banpresto')) {
+ return 'manufacturer'
+ }
+ if (host.includes('official')) {
+ return 'official_press'
+ }
+ return 'retailer_listing'
+}
+
+function isLikelyImageUrl(url) {
+ const lower = url.toLowerCase()
+ if (!lower || lower.startsWith('data:')) {
+ return false
+ }
+ return !['logo', 'icon', 'avatar', 'sprite', 'placeholder', 'blank', 'spinner', 'pixel'].some((needle) => lower.includes(needle))
+}
+
+function normalizeCandidateUrl(candidate, baseUrl) {
+ const trimmed = cleanValue(candidate)
+ if (!trimmed) {
+ return ''
+ }
+
+ if (trimmed.startsWith('//')) {
+ return `${new URL(baseUrl).protocol}${trimmed}`
+ }
+
+ try {
+ return new URL(trimmed, baseUrl).toString()
+ } catch {
+ return trimmed
+ }
+}
+
+function readAttribute(tag, name) {
+ const match = tag.match(new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, 'i'))
+ return match?.[1]?.trim() ?? ''
+}
+
+function collectMetaCandidates(html, attributeNames, contentFilters) {
+ const candidates = []
+ const metaTags = html.match(/]*>/gi) ?? []
+ for (const tag of metaTags) {
+ const attributeValue = readAttribute(tag, 'property') || readAttribute(tag, 'name')
+ if (!attributeNames.includes(attributeValue.toLowerCase())) {
+ continue
+ }
+ const content = readAttribute(tag, 'content')
+ if (content && (!contentFilters.length || contentFilters.some((needle) => content.toLowerCase().includes(needle)))) {
+ candidates.push(content)
+ }
+ }
+ return candidates
+}
+
+function collectJsonLdCandidates(value) {
+ if (!value) {
+ return []
+ }
+ if (typeof value === 'string') {
+ return [value.trim()].filter(Boolean)
+ }
+ if (Array.isArray(value)) {
+ return value.flatMap((item) => collectJsonLdCandidates(item))
+ }
+ if (typeof value !== 'object') {
+ return []
+ }
+
+ const candidates = []
+ for (const key of ['image', 'thumbnailUrl']) {
+ if (key in value) {
+ candidates.push(...collectJsonLdCandidates(value[key]))
+ }
+ }
+ for (const nested of Object.values(value)) {
+ if (nested && typeof nested === 'object') {
+ candidates.push(...collectJsonLdCandidates(nested))
+ }
+ }
+ return candidates
+}
+
+function collectJsonLdImageCandidates(html) {
+ const candidates = []
+ const scriptMatches = html.match(/