SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
4.5 KB · 109 lines typescript
Raw Blame History
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