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%
12.4 KB · 212 lines typescript
Raw Blame History
1/**2 * Helpers shared by the g10 connectors (global marketplaces + wine/whisky/design/pens sources).3 * Kept inside connectors/api (not the framework). Nothing here guesses data: every mapper returns4 * null when the source gives no evidence (SPEC §192).5 */6import { watchBrand } from '../_auction-lib/categories.js';78export { isBundleTitle, safeYear, watchBrand, slugFromTitle, hintFromLabel } from '../_auction-lib/categories.js';910/** Strip HTML tags/entities into a compact single-line text (descriptions). */11export function plainText(html: string | null | undefined, max = 2000): string | null {12  if (!html) return null;13  const t = html14    .replace(/<br\s*\/?>/gi, ' ')15    .replace(/<[^>]+>/g, ' ')16    .replace(/&nbsp;/g, ' ')17    .replace(/&amp;/g, '&')18    .replace(/&quot;/g, '"')19    .replace(/&#x27;|&#39;/g, "'")20    .replace(/&lt;/g, '<')21    .replace(/&gt;/g, '>')22    .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))23    .replace(/\s+/g, ' ')24    .trim();25  return t ? t.slice(0, max) : null;26}2728/** "$1,448" | "1448" | 1448 → number (> 0) or null. */29export function moneyNum(v: unknown): number | null {30  if (v === null || v === undefined || v === '') return null;31  const n = typeof v === 'number' ? v : Number.parseFloat(String(v).replace(/[^0-9.]/g, ''));32  return Number.isFinite(n) && n > 0 ? n : null;33}3435/**36 * Year evidence in a design/antiques title. "1930s" is a decade, not a year → year stays null and the37 * decade is kept separately; "circa 1965" / "(1965)" / ", 1965" → year 1965.38 */39export function yearOrDecade(title: string): { year: number | null; decade: string | null } {40  const dec = title.match(/\b(1[6-9]\d0|20[0-2]0)['’]?s\b/);41  const yr = [...title.matchAll(/\b(1[6-9]\d{2}|20[0-2]\d)\b(?!['’]?s)/g)].map((m) => Number(m[1])).filter((y) => y <= new Date().getUTCFullYear());42  return { year: yr.length ? yr[0]! : null, decade: dec ? `${dec[1]}s` : null };43}4445/** Century / period words that make a furniture or decorative lot an antique rather than "design". */46const ANTIQUE_RE = /\b(antique|1[5-8]th[- ]century|19th[- ]century|georgian|regency|victorian|edwardian|louis\s?x(?:iv|v|vi)|empire|biedermeier|baroque|rococo|renaissance|gothic|queen anne|chippendale|hepplewhite|sheraton|federal period|napoleon iii|gustavian|directoire|william iv|jacobean|elizabethan|tudor|17[0-9]0s|18[0-9]0s|16[0-9]0s|ming dynasty|qing dynasty|kangxi|qianlong|meiji|edo period)\b/i;47const PORCELAIN_RE = /\b(porcelain|ceramic|earthenware|stoneware|faience|majolica|delft|meissen|sèvres|sevres|wedgwood|royal copenhagen|limoges|imari|satsuma|pottery|terracotta|bisque)\b/i;48const GLASS_RE = /\b(glass|murano|lalique|baccarat|daum|gallé|galle|orrefors|kosta|iittala|holmegaard|crystal|paperweight|venini|steuben|waterford)\b/i;49const SILVER_RE = /\b(sterling|silver[- ]plate|silverplate|\bsilver\b|vermeil|silver-gilt|pewter|tea (?:set|service)|flatware|salver|tankard|christofle|georg jensen|tiffany & co\.? sterling)\b/i;50const CLOCK_RE = /\b(clock|barometer|chronometer|regulator|longcase|carriage clock|mantel clock|cuckoo)\b/i;51const WATCH_RE = /\b(wristwatch|wrist watch|watch|chronograph|pocket watch)\b/i;52const JEWEL_RE = /\b(ring|necklace|bracelet|brooch|earrings?|pendant|diamond|sapphire|ruby|emerald|carat|tiara|cufflinks|bangle|choker|cameo)\b/i;53const HANDBAG_RE = /\b(handbag|birkin|kelly|tote|clutch|shoulder bag|crossbody|satchel|purse|pochette|top handle)\b/i;54const PHOTO_RE = /\b(gelatin silver|photograph|silver print|c-print|chromogenic|platinum print|daguerreotype|albumen|photo(?:graphy)? print)\b/i;55const ART_RE = /\b(oil on canvas|oil on board|oil on panel|acrylic|watercolou?r|gouache|lithograph|screenprint|serigraph|etching|engraving|woodcut|linocut|giclée|giclee|sculpture|bronze|drawing|pastel|ink on paper|mixed media|painting|print\b|edition of|signed and numbered)\b/i;56const CONTEMPORARY_RE = /\b(banksy|kaws|murakami|hirst|koons|kusama|basquiat|haring|warhol|richter|hockney|kapoor|kiefer|condo|nara|stik|invader|shepard fairey|obey)\b/i;57const RUG_RE = /\b(rug|carpet|kilim|runner|tapestry|textile|pillow|cushion|throw|blanket|quilt|wallpaper|curtain|fabric|linen)\b/i;58const LIGHTING_RE = /\b(lamp|chandelier|sconce|pendant light|light fixture|lantern|flush mount|floor lamp|table lamp|wall light)\b/i;59const PEN_RE = /\b(fountain pen|ballpoint|rollerball|mechanical pencil|pen set|pen and pencil|writing instrument|dip pen|desk set|nib\b)/i;60const LIGHTER_RE = /\b(lighter|table lighter|pocket lighter|zippo|dunhill rollagas)\b/i;6162export type DesignVertical = 'furniture' | 'lighting' | 'decor' | 'art' | 'jewelry' | 'watches' | 'fashion' | 'tableware' | 'rugs' | 'pens' | 'unknown';6364/**65 * Map a design/antiques marketplace item to a taxonomy slug from its category label + title.66 * Returns null when the item is not a collectible asset class we track (rugs, pillows, wallpaper, new67 * production accessories…). Furniture and lighting default to `design_furniture`; period/antique68 * evidence moves them to `antiques`.69 */70export function designSlug(category: string | null | undefined, title: string, vertical: DesignVertical = 'unknown'): string | null {71  const c = (category ?? '').toLowerCase();72  const t = title;73  const v: DesignVertical = vertical !== 'unknown' ? vertical : /jewel/.test(c) ? 'jewelry' : /watch/.test(c) ? 'watches' : /handbag|bag|fashion|wallet|accessor/.test(c) ? 'fashion' : /\bart\b|paint|print|photograph|sculpture|drawing/.test(c) ? 'art' : /rug|textile|pillow|wallpaper|curtain|bedding|throw/.test(c) ? 'rugs' : /light|lamp|chandelier|sconce/.test(c) ? 'lighting' : /tableware|barware|serveware|dinnerware|glassware|silver|flatware|vase|ceramic|porcelain|decor|mirror|object|sculpture|clock|accent|accessor/.test(c) ? 'decor' : /furniture|seating|chair|sofa|table|desk|storage|cabinet|bed|bench|dresser|case/.test(c) ? 'furniture' : /pen|writing/.test(c) ? 'pens' : 'unknown';7475  if (v === 'rugs') return null;76  if (v === 'pens') return LIGHTER_RE.test(t) ? 'lighters' : 'pens';77  if (v === 'watches' || (WATCH_RE.test(t) && !/\bwatch (?:box|stand|winder|case|holder)/i.test(t) && v !== 'furniture' && v !== 'lighting')) return watchBrand(t).slug;78  if (v === 'jewelry') return /\b(loose|unmounted|gia certified|rough)\b/i.test(t) ? 'gemstones' : 'jewelry';79  if (v === 'fashion') return HANDBAG_RE.test(t) || /bag/.test(c) ? 'luxury_handbags' : 'fashion_streetwear';80  if (v === 'art') return CONTEMPORARY_RE.test(t) ? 'contemporary_art' : PHOTO_RE.test(t) ? 'photography' : 'art';81  if (v === 'decor' || v === 'tableware') {82    if (CLOCK_RE.test(t)) return 'clocks';83    if (SILVER_RE.test(t) && !GLASS_RE.test(t) && !PORCELAIN_RE.test(t)) return 'silver';84    if (PORCELAIN_RE.test(t)) return 'porcelain';85    if (GLASS_RE.test(t)) return 'glass_crystal';86    if (PHOTO_RE.test(t)) return 'photography';87    if (/sculpture|statue|bust\b/.test(c) || /\b(sculpture|statue|bust)\b/i.test(t)) return CONTEMPORARY_RE.test(t) ? 'contemporary_art' : 'art';88    if (JEWEL_RE.test(t) && /jewel/.test(c)) return 'jewelry';89    if (RUG_RE.test(t)) return null;90    if (LIGHTING_RE.test(t)) return ANTIQUE_RE.test(t) ? 'antiques' : 'design_furniture';91    if (/tableware|barware|serveware|dinnerware|glassware/.test(c)) return ANTIQUE_RE.test(t) ? 'antiques' : null;92    return ANTIQUE_RE.test(t) ? 'antiques' : 'design_furniture';93  }94  // furniture / lighting / unknown95  if (CLOCK_RE.test(t) && !/\bclock (?:table|cabinet)/i.test(t)) return 'clocks';96  if (RUG_RE.test(t) && !/\b(chair|sofa|table|cabinet|bench|stool|lamp)\b/i.test(t)) return null;97  if (v === 'unknown') {98    if (ART_RE.test(t) && !/\b(chair|sofa|table|cabinet|lamp|desk)\b/i.test(t)) return CONTEMPORARY_RE.test(t) ? 'contemporary_art' : 'art';99    if (PORCELAIN_RE.test(t) && /\b(vase|bowl|plate|figurine|figure|jar|charger|dish|service|tureen)\b/i.test(t)) return 'porcelain';100    if (GLASS_RE.test(t) && /\b(vase|bowl|decanter|paperweight|goblet|glasses|sculpture)\b/i.test(t)) return 'glass_crystal';101    if (SILVER_RE.test(t) && /\b(tray|salver|tea|coffee|flatware|bowl|candlestick|tankard)\b/i.test(t)) return 'silver';102    if (PEN_RE.test(t)) return 'pens';103    if (LIGHTER_RE.test(t)) return 'lighters';104  }105  return ANTIQUE_RE.test(t) ? 'antiques' : 'design_furniture';106}107108/** Designer / maker from titles like "Coffee Table by Maison Jansen, 1970s" or "Sideboard from Greaves & Thomas". */109export function makerFromTitle(title: string): string | null {110  const m = title.match(/\b(?:by|from|for|attributed to|attr\.?(?: to)?)\s+([A-Z][\w&'.\- ]{1,40}?)(?:,|\s+for\s+|\s+(?:circa|ca\.|c\.)\s|\s+\d{4}|\s*\(|$)/);111  if (!m) return null;112  const name = m[1]!.trim().replace(/\s+/g, ' ');113  if (/^(the|a|an|his|her)\b/i.test(name) || name.length < 3) return null;114  return name;115}116117/** "(Near Mint, Restored)" | "(Excellent, Works Well)" → the parenthetical condition text. */118export function conditionFromParens(title: string): string | null {119  const parts = [...title.matchAll(/\(([^()]{3,60})\)/g)].map((m) => m[1]!.trim());120  const hit = parts.find((p) => /\b(mint|excellent|very good|good|fair|poor|restored|works well|new old stock|nos\b|unused|used|worn|damaged|repaired|as is)\b/i.test(p));121  return hit ?? null;122}123124const COND_MAP: Array<[RegExp, string]> = [125  [/\bnear mint\b/i, 'near_mint'],126  [/\b(new old stock|nos|new in box|unused|brand new|mint)\b/i, 'mint'],127  [/\bexcellent\b/i, 'excellent'],128  [/\bvery good\b/i, 'very_good'],129  [/\bgood\b/i, 'good'],130  [/\bfair\b/i, 'fair'],131  [/\b(poor|damaged|for parts|as is)\b/i, 'poor'],132];133export function normalizeConditionWord(raw: string | null): string | null {134  if (!raw) return null;135  for (const [re, slug] of COND_MAP) if (re.test(raw)) return slug;136  return null;137}138139/** .NET JSON date "/Date(1725000000000)/" (Trade Me) → Date or null. */140export function dotNetDate(s: string | null | undefined): Date | null {141  if (!s) return null;142  const m = String(s).match(/\/Date\((-?\d+)(?:[+-]\d{4})?\)\//);143  if (m) {144    const d = new Date(Number(m[1]));145    return Number.isNaN(d.getTime()) ? null : d;146  }147  const d = new Date(s);148  return Number.isNaN(d.getTime()) ? null : d;149}150151/** Epoch seconds (Etsy) → Date or null. */152export function epochSeconds(n: number | null | undefined): Date | null {153  if (!n || !Number.isFinite(n)) return null;154  const d = new Date(n * 1000);155  return Number.isNaN(d.getTime()) ? null : d;156}157158/** ISO-8601 string → Date or null (never throws). */159export function isoDate(s: string | null | undefined): Date | null {160  if (!s) return null;161  const d = new Date(s);162  return Number.isNaN(d.getTime()) ? null : d;163}164165/** "15 May 2026" | "01 Sep 2026" → UTC midnight. */166export function dateDMonY(s: string | null | undefined): Date | null {167  const m = s?.match(/(\d{1,2})\s+([A-Za-z]{3,9})\.?\s+(\d{4})/);168  if (!m) return null;169  const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];170  const mo = months.indexOf(m[2]!.slice(0, 3).toLowerCase());171  if (mo < 0) return null;172  return new Date(Date.UTC(Number(m[3]), mo, Number(m[1])));173}174175/**176 * Process-wide OAuth token cache for gated official APIs (eBay client-credentials, Artsy xapp…).177 * Keyed by client id + scope; refreshes 60 s before expiry. Lives in the connector layer on purpose178 * (no framework edits): the framework only knows that the connector `requires` the env vars.179 */180interface CachedToken {181  token: string;182  expiresAt: number;183}184const tokenCache = new Map<string, CachedToken>();185186export async function cachedToken(key: string, fetcher: () => Promise<{ token: string; expiresInSeconds: number }>): Promise<string> {187  const hit = tokenCache.get(key);188  if (hit && hit.expiresAt > Date.now()) return hit.token;189  const fresh = await fetcher();190  tokenCache.set(key, { token: fresh.token, expiresAt: Date.now() + Math.max(30, fresh.expiresInSeconds - 60) * 1000 });191  return fresh.token;192}193194/** Test hook. */195export function clearTokenCache(): void {196  tokenCache.clear();197}198199/** Cursor helper shared by the seed × page crawlers: resume at (seedIndex, page) and rotate seeds between runs. */200export interface SeedPageCursor {201  seedIndex?: number;202  page?: number;203  done?: boolean;204  at?: string;205}206export function readSeedCursor(cursor: Record<string, unknown> | undefined, seedCount: number): { seedIndex: number; page: number } {207  const c = (cursor ?? {}) as SeedPageCursor;208  const seedIndex = typeof c.seedIndex === 'number' && c.seedIndex >= 0 && c.seedIndex < seedCount && !c.done ? c.seedIndex : 0;209  const page = typeof c.page === 'number' && c.page >= 1 && !c.done ? c.page : 1;210  return { seedIndex, page };211}212