/** * Helpers shared by the g10 connectors (global marketplaces + wine/whisky/design/pens sources). * Kept inside connectors/api (not the framework). Nothing here guesses data: every mapper returns * null when the source gives no evidence (SPEC §192). */ import { watchBrand } from '../_auction-lib/categories.js'; export { isBundleTitle, safeYear, watchBrand, slugFromTitle, hintFromLabel } from '../_auction-lib/categories.js'; /** Strip HTML tags/entities into a compact single-line text (descriptions). */ export function plainText(html: string | null | undefined, max = 2000): string | null { if (!html) return null; const t = html .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) .replace(/\s+/g, ' ') .trim(); return t ? t.slice(0, max) : null; } /** "$1,448" | "1448" | 1448 → number (> 0) or null. */ export function moneyNum(v: unknown): number | null { if (v === null || v === undefined || v === '') return null; const n = typeof v === 'number' ? v : Number.parseFloat(String(v).replace(/[^0-9.]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } /** * Year evidence in a design/antiques title. "1930s" is a decade, not a year → year stays null and the * decade is kept separately; "circa 1965" / "(1965)" / ", 1965" → year 1965. */ export function yearOrDecade(title: string): { year: number | null; decade: string | null } { const dec = title.match(/\b(1[6-9]\d0|20[0-2]0)['’]?s\b/); 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()); return { year: yr.length ? yr[0]! : null, decade: dec ? `${dec[1]}s` : null }; } /** Century / period words that make a furniture or decorative lot an antique rather than "design". */ const 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; const 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; const GLASS_RE = /\b(glass|murano|lalique|baccarat|daum|gallé|galle|orrefors|kosta|iittala|holmegaard|crystal|paperweight|venini|steuben|waterford)\b/i; const 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; const CLOCK_RE = /\b(clock|barometer|chronometer|regulator|longcase|carriage clock|mantel clock|cuckoo)\b/i; const WATCH_RE = /\b(wristwatch|wrist watch|watch|chronograph|pocket watch)\b/i; const JEWEL_RE = /\b(ring|necklace|bracelet|brooch|earrings?|pendant|diamond|sapphire|ruby|emerald|carat|tiara|cufflinks|bangle|choker|cameo)\b/i; const HANDBAG_RE = /\b(handbag|birkin|kelly|tote|clutch|shoulder bag|crossbody|satchel|purse|pochette|top handle)\b/i; const PHOTO_RE = /\b(gelatin silver|photograph|silver print|c-print|chromogenic|platinum print|daguerreotype|albumen|photo(?:graphy)? print)\b/i; const 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; const 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; const RUG_RE = /\b(rug|carpet|kilim|runner|tapestry|textile|pillow|cushion|throw|blanket|quilt|wallpaper|curtain|fabric|linen)\b/i; const LIGHTING_RE = /\b(lamp|chandelier|sconce|pendant light|light fixture|lantern|flush mount|floor lamp|table lamp|wall light)\b/i; const PEN_RE = /\b(fountain pen|ballpoint|rollerball|mechanical pencil|pen set|pen and pencil|writing instrument|dip pen|desk set|nib\b)/i; const LIGHTER_RE = /\b(lighter|table lighter|pocket lighter|zippo|dunhill rollagas)\b/i; export type DesignVertical = 'furniture' | 'lighting' | 'decor' | 'art' | 'jewelry' | 'watches' | 'fashion' | 'tableware' | 'rugs' | 'pens' | 'unknown'; /** * Map a design/antiques marketplace item to a taxonomy slug from its category label + title. * Returns null when the item is not a collectible asset class we track (rugs, pillows, wallpaper, new * production accessories…). Furniture and lighting default to `design_furniture`; period/antique * evidence moves them to `antiques`. */ export function designSlug(category: string | null | undefined, title: string, vertical: DesignVertical = 'unknown'): string | null { const c = (category ?? '').toLowerCase(); const t = title; 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'; if (v === 'rugs') return null; if (v === 'pens') return LIGHTER_RE.test(t) ? 'lighters' : 'pens'; if (v === 'watches' || (WATCH_RE.test(t) && !/\bwatch (?:box|stand|winder|case|holder)/i.test(t) && v !== 'furniture' && v !== 'lighting')) return watchBrand(t).slug; if (v === 'jewelry') return /\b(loose|unmounted|gia certified|rough)\b/i.test(t) ? 'gemstones' : 'jewelry'; if (v === 'fashion') return HANDBAG_RE.test(t) || /bag/.test(c) ? 'luxury_handbags' : 'fashion_streetwear'; if (v === 'art') return CONTEMPORARY_RE.test(t) ? 'contemporary_art' : PHOTO_RE.test(t) ? 'photography' : 'art'; if (v === 'decor' || v === 'tableware') { if (CLOCK_RE.test(t)) return 'clocks'; if (SILVER_RE.test(t) && !GLASS_RE.test(t) && !PORCELAIN_RE.test(t)) return 'silver'; if (PORCELAIN_RE.test(t)) return 'porcelain'; if (GLASS_RE.test(t)) return 'glass_crystal'; if (PHOTO_RE.test(t)) return 'photography'; if (/sculpture|statue|bust\b/.test(c) || /\b(sculpture|statue|bust)\b/i.test(t)) return CONTEMPORARY_RE.test(t) ? 'contemporary_art' : 'art'; if (JEWEL_RE.test(t) && /jewel/.test(c)) return 'jewelry'; if (RUG_RE.test(t)) return null; if (LIGHTING_RE.test(t)) return ANTIQUE_RE.test(t) ? 'antiques' : 'design_furniture'; if (/tableware|barware|serveware|dinnerware|glassware/.test(c)) return ANTIQUE_RE.test(t) ? 'antiques' : null; return ANTIQUE_RE.test(t) ? 'antiques' : 'design_furniture'; } // furniture / lighting / unknown if (CLOCK_RE.test(t) && !/\bclock (?:table|cabinet)/i.test(t)) return 'clocks'; if (RUG_RE.test(t) && !/\b(chair|sofa|table|cabinet|bench|stool|lamp)\b/i.test(t)) return null; if (v === 'unknown') { if (ART_RE.test(t) && !/\b(chair|sofa|table|cabinet|lamp|desk)\b/i.test(t)) return CONTEMPORARY_RE.test(t) ? 'contemporary_art' : 'art'; if (PORCELAIN_RE.test(t) && /\b(vase|bowl|plate|figurine|figure|jar|charger|dish|service|tureen)\b/i.test(t)) return 'porcelain'; if (GLASS_RE.test(t) && /\b(vase|bowl|decanter|paperweight|goblet|glasses|sculpture)\b/i.test(t)) return 'glass_crystal'; if (SILVER_RE.test(t) && /\b(tray|salver|tea|coffee|flatware|bowl|candlestick|tankard)\b/i.test(t)) return 'silver'; if (PEN_RE.test(t)) return 'pens'; if (LIGHTER_RE.test(t)) return 'lighters'; } return ANTIQUE_RE.test(t) ? 'antiques' : 'design_furniture'; } /** Designer / maker from titles like "Coffee Table by Maison Jansen, 1970s" or "Sideboard from Greaves & Thomas". */ export function makerFromTitle(title: string): string | null { 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*\(|$)/); if (!m) return null; const name = m[1]!.trim().replace(/\s+/g, ' '); if (/^(the|a|an|his|her)\b/i.test(name) || name.length < 3) return null; return name; } /** "(Near Mint, Restored)" | "(Excellent, Works Well)" → the parenthetical condition text. */ export function conditionFromParens(title: string): string | null { const parts = [...title.matchAll(/\(([^()]{3,60})\)/g)].map((m) => m[1]!.trim()); 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)); return hit ?? null; } const COND_MAP: Array<[RegExp, string]> = [ [/\bnear mint\b/i, 'near_mint'], [/\b(new old stock|nos|new in box|unused|brand new|mint)\b/i, 'mint'], [/\bexcellent\b/i, 'excellent'], [/\bvery good\b/i, 'very_good'], [/\bgood\b/i, 'good'], [/\bfair\b/i, 'fair'], [/\b(poor|damaged|for parts|as is)\b/i, 'poor'], ]; export function normalizeConditionWord(raw: string | null): string | null { if (!raw) return null; for (const [re, slug] of COND_MAP) if (re.test(raw)) return slug; return null; } /** .NET JSON date "/Date(1725000000000)/" (Trade Me) → Date or null. */ export function dotNetDate(s: string | null | undefined): Date | null { if (!s) return null; const m = String(s).match(/\/Date\((-?\d+)(?:[+-]\d{4})?\)\//); if (m) { const d = new Date(Number(m[1])); return Number.isNaN(d.getTime()) ? null : d; } const d = new Date(s); return Number.isNaN(d.getTime()) ? null : d; } /** Epoch seconds (Etsy) → Date or null. */ export function epochSeconds(n: number | null | undefined): Date | null { if (!n || !Number.isFinite(n)) return null; const d = new Date(n * 1000); return Number.isNaN(d.getTime()) ? null : d; } /** ISO-8601 string → Date or null (never throws). */ export function isoDate(s: string | null | undefined): Date | null { if (!s) return null; const d = new Date(s); return Number.isNaN(d.getTime()) ? null : d; } /** "15 May 2026" | "01 Sep 2026" → UTC midnight. */ export function dateDMonY(s: string | null | undefined): Date | null { const m = s?.match(/(\d{1,2})\s+([A-Za-z]{3,9})\.?\s+(\d{4})/); if (!m) return null; const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const mo = months.indexOf(m[2]!.slice(0, 3).toLowerCase()); if (mo < 0) return null; return new Date(Date.UTC(Number(m[3]), mo, Number(m[1]))); } /** * Process-wide OAuth token cache for gated official APIs (eBay client-credentials, Artsy xapp…). * Keyed by client id + scope; refreshes 60 s before expiry. Lives in the connector layer on purpose * (no framework edits): the framework only knows that the connector `requires` the env vars. */ interface CachedToken { token: string; expiresAt: number; } const tokenCache = new Map(); export async function cachedToken(key: string, fetcher: () => Promise<{ token: string; expiresInSeconds: number }>): Promise { const hit = tokenCache.get(key); if (hit && hit.expiresAt > Date.now()) return hit.token; const fresh = await fetcher(); tokenCache.set(key, { token: fresh.token, expiresAt: Date.now() + Math.max(30, fresh.expiresInSeconds - 60) * 1000 }); return fresh.token; } /** Test hook. */ export function clearTokenCache(): void { tokenCache.clear(); } /** Cursor helper shared by the seed × page crawlers: resume at (seedIndex, page) and rotate seeds between runs. */ export interface SeedPageCursor { seedIndex?: number; page?: number; done?: boolean; at?: string; } export function readSeedCursor(cursor: Record | undefined, seedCount: number): { seedIndex: number; page: number } { const c = (cursor ?? {}) as SeedPageCursor; const seedIndex = typeof c.seedIndex === 'number' && c.seedIndex >= 0 && c.seedIndex < seedCount && !c.done ? c.seedIndex : 0; const page = typeof c.page === 'number' && c.page >= 1 && !c.done ? c.page : 1; return { seedIndex, page }; }