/**
* 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 };
}