TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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(/ /g, ' ')17 .replace(/&/g, '&')18 .replace(/"/g, '"')19 .replace(/'|'/g, "'")20 .replace(/</g, '<')21 .replace(/>/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