TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { jsonLd } from '../html.js';23/**4 * schema.org Product / Offer extraction (SPEC §1 structured metadata). Many stores and auction5 * houses embed a Product with Offer(s) in JSON-LD; this is the most stable acquisition path after an6 * official API and should be tried before selector-based parsing.7 */8export interface SchemaOffer {9 price: number | null;10 currency: string | null;11 availability: 'available' | 'sold' | 'ended' | 'unknown';12 url: string | null;13 seller: string | null;14 condition: string | null;15}1617export interface SchemaProduct {18 name: string | null;19 description: string | null;20 sku: string | null;21 gtin: string | null;22 mpn: string | null;23 brand: string | null;24 images: string[];25 url: string | null;26 offers: SchemaOffer[];27 productId: string | null;28 raw: Record<string, unknown>;29}3031const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : typeof v === 'number' ? String(v) : null);32const num = (v: unknown): number | null => {33 if (typeof v === 'number') return Number.isFinite(v) ? v : null;34 if (typeof v === 'string') {35 const n = Number.parseFloat(v.replace(/[^0-9.,-]/g, '').replace(/,(?=\d{3}\b)/g, '').replace(',', '.'));36 return Number.isFinite(n) ? n : null;37 }38 return null;39};4041function availabilityOf(v: unknown): SchemaOffer['availability'] {42 const s = String(v ?? '').toLowerCase();43 if (!s) return 'unknown';44 if (/instock|preorder|limitedavailability|onlineonly|instoreonly|presale|backorder/.test(s)) return 'available';45 if (/soldout/.test(s)) return 'sold';46 if (/outofstock|discontinued/.test(s)) return 'ended';47 return 'unknown';48}4950function offersOf(v: unknown): SchemaOffer[] {51 if (!v) return [];52 const arr = Array.isArray(v) ? v : [v];53 const out: SchemaOffer[] = [];54 for (const o of arr) {55 if (!o || typeof o !== 'object') continue;56 const r = o as Record<string, unknown>;57 if (r['@type'] === 'AggregateOffer') {58 const low = num(r.lowPrice);59 out.push({ price: low ?? num(r.highPrice), currency: str(r.priceCurrency), availability: availabilityOf(r.availability), url: str(r.url), seller: null, condition: null });60 if (Array.isArray(r.offers)) out.push(...offersOf(r.offers));61 continue;62 }63 const spec = r.priceSpecification as Record<string, unknown> | undefined;64 const seller = r.seller as Record<string, unknown> | undefined;65 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 });66 }67 return out;68}6970/** All schema.org Products found in a page. */71export function productsFromHtml(html: string): SchemaProduct[] {72 const nodes = [...jsonLd(html, 'Product'), ...jsonLd(html, 'IndividualProduct'), ...jsonLd(html, 'ProductModel')];73 return nodes.map((n) => {74 const brand = n.brand as Record<string, unknown> | string | undefined;75 const img = n.image;76 const images = Array.isArray(img) ? img.map((x) => (typeof x === 'string' ? x : str((x as Record<string, unknown>)?.url))).filter((x): x is string => Boolean(x)) : typeof img === 'string' ? [img] : img && typeof img === 'object' ? [str((img as Record<string, unknown>).url)].filter((x): x is string => Boolean(x)) : [];77 return {78 name: str(n.name),79 description: str(n.description),80 sku: str(n.sku),81 gtin: str(n.gtin13) ?? str(n.gtin12) ?? str(n.gtin14) ?? str(n.gtin8) ?? str(n.gtin),82 mpn: str(n.mpn),83 brand: typeof brand === 'string' ? brand : str(brand?.name),84 images,85 url: str(n.url),86 offers: offersOf(n.offers),87 productId: str(n.productID) ?? str(n['@id']),88 raw: n,89 };90 });91}9293/** First Product with at least one priced offer, else first Product. */94export function primaryProduct(html: string): SchemaProduct | null {95 const all = productsFromHtml(html);96 return all.find((p) => p.offers.some((o) => o.price !== null)) ?? all[0] ?? null;97}9899/** Open Graph / meta fallbacks for pages without JSON-LD. */100export function metaTags(html: string): Record<string, string> {101 const out: Record<string, string> = {};102 const re = /<meta\s+(?:property|name)=["']([^"']+)["']\s+content=["']([^"']*)["']/gi;103 let m: RegExpExecArray | null;104 while ((m = re.exec(html))) out[m[1]!.toLowerCase()] = m[2]!;105 const re2 = /<meta\s+content=["']([^"']*)["']\s+(?:property|name)=["']([^"']+)["']/gi;106 while ((m = re2.exec(html))) out[m[2]!.toLowerCase()] = m[1]!;107 return out;108}109