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 = /