import { z } from 'zod'; import { AssetAttributesSchema, CurrencySchema, NormalizedListingSchema, type AssetAttributes, type NormalizedListing } from '@rareindex/shared'; import { parseGradeFromTitle } from '@rareindex/taxonomy'; /** * Shared configuration and normalisation for storefront-platform adapters (Shopify, WooCommerce…). * A store connector is metadata only: which collections/categories to read and how they map onto the * RareIndex taxonomy. Anything unmapped is skipped — never guessed (§192). */ export const StorefrontRuleSchema = z.object({ /** regex tested against "title | product_type | tags | collection" (case-insensitive) */ match: z.string(), /** optional extra conditions: regex on product_type only / on vendor only */ typeMatch: z.string().optional(), vendorMatch: z.string().optional(), categorySlug: z.string(), brand: z.string().nullable().optional(), franchise: z.string().nullable().optional(), series: z.string().nullable().optional(), }); export const StorefrontCollectionSchema = z.object({ /** collection handle (Shopify) or category slug/id (WooCommerce) */ handle: z.string(), categorySlug: z.string().nullable().optional(), brand: z.string().nullable().optional(), franchise: z.string().nullable().optional(), series: z.string().nullable().optional(), /** pages to read per incremental run (defaults to the domain crawlDepth) */ pages: z.number().int().positive().optional(), }); export const StorefrontConfigSchema = z.object({ currency: CurrencySchema, collections: z.array(StorefrontCollectionSchema).default([]), /** title/tag rules applied after the collection mapping (first match wins) */ rules: z.array(StorefrontRuleSchema).default([]), /** used when neither collection nor rule maps the product; null = skip */ defaultCategory: z.string().nullable().default(null), /** products whose title/type/tags match are skipped (accessories, supplies, gift cards…) */ exclude: z.string().default('gift card|sleeves?|deck box|binder|playmat|toploader|top loader|storage box|dice|tokens? only|bundle of|mystery'), /** seller label stored on listings */ seller: z.string().optional(), /** treat out-of-stock items as `ended` listings (default) or drop them */ keepOutOfStock: z.boolean().default(true), /** regex with named groups name/set/number/style/year applied to titles, e.g. "^(?.+?) - (?.+?) #(?\\S+)"; `style` → identifiers.style_code */ titlePattern: z.string().optional(), location: z.string().nullable().default(null), }); export type StorefrontConfig = z.infer; export interface StorefrontProduct { id: string; title: string; url: string; description: string | null; vendor: string | null; productType: string | null; tags: string[]; collection: string | null; images: string[]; publishedAt: string | null; updatedAt: string | null; variants: Array<{ id: string; title: string | null; sku: string | null; barcode: string | null; price: number | null; compareAtPrice: number | null; available: boolean | null; quantity: number | null; image: string | null }>; } export interface Mapping { categorySlug: string; brand: string | null; franchise: string | null; series: string | null; } export function mapProduct(cfg: StorefrontConfig, p: StorefrontProduct): Mapping | null { const hay = [p.title, p.productType ?? '', p.tags.join(' '), p.collection ?? ''].join(' | '); if (cfg.exclude && new RegExp(cfg.exclude, 'i').test(hay)) return null; const col = p.collection ? cfg.collections.find((c) => c.handle === p.collection) : undefined; let m: Mapping | null = col?.categorySlug ? { categorySlug: col.categorySlug, brand: col.brand ?? null, franchise: col.franchise ?? null, series: col.series ?? null } : null; for (const r of cfg.rules) { if (r.typeMatch && !new RegExp(r.typeMatch, 'i').test(p.productType ?? '')) continue; if (r.vendorMatch && !new RegExp(r.vendorMatch, 'i').test(p.vendor ?? '')) continue; if (new RegExp(r.match, 'i').test(hay)) { m = { categorySlug: r.categorySlug, brand: r.brand ?? m?.brand ?? null, franchise: r.franchise ?? m?.franchise ?? null, series: r.series ?? m?.series ?? null }; break; } } if (!m && cfg.defaultCategory) m = { categorySlug: cfg.defaultCategory, brand: null, franchise: null, series: null }; if (!m && col && !col.categorySlug) return null; return m; } /** UPC (12), EAN-13, EAN-8, JAN (EAN-13 starting 45/49), ISBN-13 (978/979) from a barcode string. */ export function identifiersFromBarcode(barcode: string | null | undefined): Record { if (!barcode) return {}; const d = barcode.replace(/\D/g, ''); if (d.length === 12) return { upc: d }; if (d.length === 13) { if (/^97[89]/.test(d)) return { isbn: d, ean: d }; if (/^4[59]/.test(d)) return { jan: d, ean: d }; return { ean: d }; } if (d.length === 8) return { ean: d }; if (d.length === 10 && /^\d{9}[\dXx]$/.test(barcode)) return { isbn: barcode.toUpperCase() }; return {}; } const COND_RE = /\b(near mint|nm|lightly played|lp|moderately played|mp|heavily played|hp|damaged|dmg|mint|sealed|new|used|cib|loose|complete|graded)\b/i; export function variantCondition(variantTitle: string | null): string | null { if (!variantTitle || /^default title$/i.test(variantTitle)) return null; const m = variantTitle.match(COND_RE); return m ? m[0] : null; } /** Normalise one storefront product into listings (one per variant). */ export function storefrontListings(input: { connectorId: string; sourceId: string; cfg: StorefrontConfig; product: StorefrontProduct; observedAt: Date; parserVersion: string; confidence?: number }): NormalizedListing[] { const { cfg, product: p } = input; const mapping = mapProduct(cfg, p); if (!mapping) return []; const out: NormalizedListing[] = []; const grade = parseGradeFromTitle(p.title); let name = p.title.replace(/\s+/g, ' ').trim(); let set: string | null = null; let number: string | null = null; let styleCode: string | null = null; let patternYear: number | null = null; if (cfg.titlePattern) { const m = p.title.match(new RegExp(cfg.titlePattern, 'i')); if (m?.groups) { name = m.groups.name?.trim() || name; set = m.groups.set?.trim() || null; number = m.groups.number?.trim() || null; styleCode = m.groups.style?.trim().toUpperCase() || null; patternYear = m.groups.year ? Number(m.groups.year) : null; } } const yearMatch = p.title.match(/\b(19[0-9]{2}|20[0-4][0-9])\b/); for (const v of p.variants) { if (v.price === null) continue; const available = v.available === null ? 'unknown' : v.available ? 'available' : 'ended'; if (!cfg.keepOutOfStock && available === 'ended') continue; const variantTitle = v.title && !/^default title$/i.test(v.title) ? v.title : null; const vGrade = variantTitle ? parseGradeFromTitle(variantTitle) : { grader: null, grade: null, qualifier: null }; const condRaw = variantCondition(variantTitle); const attributes: AssetAttributes = AssetAttributesSchema.parse({ categorySlug: mapping.categorySlug, name, brand: mapping.brand ?? p.vendor ?? null, franchise: mapping.franchise, series: mapping.series, set, number, year: patternYear ?? (yearMatch ? Number(yearMatch[1]) : null), identifiers: { ...(v.sku ? { sku: v.sku } : {}), ...(styleCode ? { style_code: styleCode } : {}), ...identifiersFromBarcode(v.barcode) }, metadata: { productType: p.productType, tags: p.tags.slice(0, 20), collection: p.collection, variantTitle, compareAtPrice: v.compareAtPrice }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: input.connectorId, sourceId: input.sourceId, sourceUrl: p.url, externalId: p.variants.length > 1 ? `${p.id}:${v.id}` : p.id, rawTitle: variantTitle ? `${p.title} — ${variantTitle}` : p.title, description: p.description ? p.description.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 2000) || null : null, imageUrls: v.image ? [v.image, ...p.images.filter((i) => i !== v.image)].slice(0, 8) : p.images.slice(0, 8), attributes, grade: { grader: vGrade.grader ?? grade.grader, grade: vGrade.grade ?? grade.grade, qualifier: vGrade.qualifier ?? grade.qualifier, certificationNumber: null }, condition: { conditionRaw: condRaw, completeness: /\bsealed\b/i.test(`${p.title} ${variantTitle ?? ''}`) ? 'sealed' : null }, observedAt: input.observedAt, confidence: input.confidence ?? 0.7, parserVersion: input.parserVersion, listingType: 'fixed_price', price: v.price, currency: cfg.currency, seller: cfg.seller ?? null, location: cfg.location, quantity: v.quantity, listedAt: p.publishedAt ? new Date(p.publishedAt) : null, availability: available, }), ); } return out; }