import { jsonLd } from '../html.js'; /** * schema.org Product / Offer extraction (SPEC ยง1 structured metadata). Many stores and auction * houses embed a Product with Offer(s) in JSON-LD; this is the most stable acquisition path after an * official API and should be tried before selector-based parsing. */ export interface SchemaOffer { price: number | null; currency: string | null; availability: 'available' | 'sold' | 'ended' | 'unknown'; url: string | null; seller: string | null; condition: string | null; } export interface SchemaProduct { name: string | null; description: string | null; sku: string | null; gtin: string | null; mpn: string | null; brand: string | null; images: string[]; url: string | null; offers: SchemaOffer[]; productId: string | null; raw: Record; } const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : typeof v === 'number' ? String(v) : null); const num = (v: unknown): number | null => { if (typeof v === 'number') return Number.isFinite(v) ? v : null; if (typeof v === 'string') { const n = Number.parseFloat(v.replace(/[^0-9.,-]/g, '').replace(/,(?=\d{3}\b)/g, '').replace(',', '.')); return Number.isFinite(n) ? n : null; } return null; }; function availabilityOf(v: unknown): SchemaOffer['availability'] { const s = String(v ?? '').toLowerCase(); if (!s) return 'unknown'; if (/instock|preorder|limitedavailability|onlineonly|instoreonly|presale|backorder/.test(s)) return 'available'; if (/soldout/.test(s)) return 'sold'; if (/outofstock|discontinued/.test(s)) return 'ended'; return 'unknown'; } function offersOf(v: unknown): SchemaOffer[] { if (!v) return []; const arr = Array.isArray(v) ? v : [v]; const out: SchemaOffer[] = []; for (const o of arr) { if (!o || typeof o !== 'object') continue; const r = o as Record; if (r['@type'] === 'AggregateOffer') { const low = num(r.lowPrice); out.push({ price: low ?? num(r.highPrice), currency: str(r.priceCurrency), availability: availabilityOf(r.availability), url: str(r.url), seller: null, condition: null }); if (Array.isArray(r.offers)) out.push(...offersOf(r.offers)); continue; } const spec = r.priceSpecification as Record | undefined; const seller = r.seller as Record | undefined; out.push({ price: num(r.price) ?? num(spec?.price), currency: str(r.priceCurrency) ?? str(spec?.priceCurrency), availability: availabilityOf(r.availability), url: str(r.url), seller: str(seller?.name), condition: str(r.itemCondition)?.replace(/^.*\//, '') ?? null }); } return out; } /** All schema.org Products found in a page. */ export function productsFromHtml(html: string): SchemaProduct[] { const nodes = [...jsonLd(html, 'Product'), ...jsonLd(html, 'IndividualProduct'), ...jsonLd(html, 'ProductModel')]; return nodes.map((n) => { const brand = n.brand as Record | string | undefined; const img = n.image; const images = Array.isArray(img) ? img.map((x) => (typeof x === 'string' ? x : str((x as Record)?.url))).filter((x): x is string => Boolean(x)) : typeof img === 'string' ? [img] : img && typeof img === 'object' ? [str((img as Record).url)].filter((x): x is string => Boolean(x)) : []; return { name: str(n.name), description: str(n.description), sku: str(n.sku), gtin: str(n.gtin13) ?? str(n.gtin12) ?? str(n.gtin14) ?? str(n.gtin8) ?? str(n.gtin), mpn: str(n.mpn), brand: typeof brand === 'string' ? brand : str(brand?.name), images, url: str(n.url), offers: offersOf(n.offers), productId: str(n.productID) ?? str(n['@id']), raw: n, }; }); } /** First Product with at least one priced offer, else first Product. */ export function primaryProduct(html: string): SchemaProduct | null { const all = productsFromHtml(html); return all.find((p) => p.offers.some((o) => o.price !== null)) ?? all[0] ?? null; } /** Open Graph / meta fallbacks for pages without JSON-LD. */ export function metaTags(html: string): Record { const out: Record = {}; const re = /