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%
21.0 KB · 318 lines typescript
Raw Blame History
1/**2 * Shared helpers for the European / Nordic / APAC auction-house connectors (group g8-auctions-eu-apac).3 * Multilingual date + money parsing (fr/de/it/es/nl/sv/da/fi/en), bundle detection in those languages,4 * cautious year extraction for continental title conventions ("1900-tal", "20. Jh.", "1950er").5 * Kept inside connectors/api (not the framework). Pure functions, fixture-testable.6 */7import { parsePrice, SUPPORTED_CURRENCIES, type CurrencyCode } from '@rareindex/shared';89/** Month names / abbreviations → 0-based month. Covers en, fr, de, it, es, nl, sv, da, fi (+ common abbreviations). */10const MONTHS: Record<string, number> = {11  // en12  jan: 0, january: 0, feb: 1, february: 1, mar: 2, march: 2, apr: 3, april: 3, may: 4, jun: 5, june: 5, jul: 6, july: 6, aug: 7, august: 7, sep: 8, sept: 8, september: 8, oct: 9, october: 9, nov: 10, november: 10, dec: 11, december: 11,13  // fr14  janvier: 0, janv: 0, février: 1, fevrier: 1, févr: 1, fevr: 1, mars: 2, avril: 3, avr: 3, mai: 4, juin: 5, juillet: 6, juil: 6, août: 7, aout: 7, septembre: 8, octobre: 9, novembre: 10, décembre: 11, decembre: 11, déc: 11,15  // de16  januar: 0, jänner: 0, februar: 1, märz: 2, maerz: 2, mrz: 2, juni: 5, juli: 6, oktober: 9, okt: 9, dezember: 11, dez: 11,17  // it18  gennaio: 0, gen: 0, febbraio: 1, marzo: 2, aprile: 3, maggio: 4, mag: 4, giugno: 5, giu: 5, luglio: 6, lug: 6, agosto: 7, ago: 7, settembre: 8, set: 8, ottobre: 9, ott: 9, dicembre: 11, dic: 11,19  // es20  enero: 0, ene: 0, febrero: 1, abril: 3, abr: 3, mayo: 4, junio: 5, julio: 6, septiembre: 8, setiembre: 8, octubre: 9, noviembre: 10, diciembre: 11,21  // nl22  januari: 0, februari: 1, maart: 2, mrt: 2, mei: 4, augustus: 7, // (juni/juli/september/oktober/november/december shared)23  // sv / da / no24  mars_sv: 2, // placeholder never matched; sv "mars" already mapped via fr25  maj: 4, // sv/da26  // fi27  tammikuu: 0, tammikuuta: 0, helmikuu: 1, helmikuuta: 1, maaliskuu: 2, maaliskuuta: 2, huhtikuu: 3, huhtikuuta: 3, toukokuu: 4, toukokuuta: 4, kesäkuu: 5, kesäkuuta: 5, heinäkuu: 6, heinäkuuta: 6, elokuu: 7, elokuuta: 7, syyskuu: 8, syyskuuta: 8, lokakuu: 9, lokakuuta: 9, marraskuu: 10, marraskuuta: 10, joulukuu: 11, joulukuuta: 11,28};2930function monthIndex(word: string): number | null {31  const w = word.toLowerCase().replace(/\.$/, '');32  if (w in MONTHS && w !== 'mars_sv') return MONTHS[w]!;33  // truncated forms ("sept", "déc", "okt")34  const hit = Object.keys(MONTHS).find((k) => k.length >= 3 && w.length >= 3 && k.startsWith(w) && k !== 'mars_sv');35  return hit !== undefined ? MONTHS[hit]! : null;36}3738/**39 * Parse a date written in any of the supported languages. Handles:40 *  "12 juin 2026" · "12. Juni 2026" · "12 giugno 2026" · "12 de junio de 2026" · "12 juni 2026" · "den 12 juni 2026"41 *  "June 12, 2026" · "12/06/2026" · "12.06.2026" · "2026-06-12" · "12 juin 2026 14:00" (time ignored → UTC midnight)42 *  "22–23 juin 2026" / "22-23. Juni 2026" (range → first day).43 * Returns null instead of guessing; day-first for numeric forms.44 */45export function parseEuDate(raw: string | null | undefined): Date | null {46  if (!raw) return null;47  const s = raw.replace(/[  ]/g, ' ').replace(/\s+/g, ' ').trim();48  if (!s) return null;49  const iso = s.match(/\b(\d{4})-(\d{2})-(\d{2})(?!\d)/);50  if (iso) return utc(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));51  // day [–day] [de] month [de] year   (fr/de/it/es/nl/sv/da/fi/en)52  const dmy = s.match(/\b(\d{1,2})(?:\s?[-–/]\s?\d{1,2})?\.?(?:er|e|º|°|th|st|nd|rd)?\s+(?:de\s+|den\s+)?([A-Za-zÀ-ÿ]{3,12})\.?\s+(?:de\s+)?(\d{4})\b/);53  if (dmy) {54    const mo = monthIndex(dmy[2]!);55    if (mo !== null) return utc(Number(dmy[3]), mo, Number(dmy[1]));56  }57  // month day, year (en)58  const mdy = s.match(/\b([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b/);59  if (mdy) {60    const mo = monthIndex(mdy[1]!);61    if (mo !== null) return utc(Number(mdy[3]), mo, Number(mdy[2]));62  }63  // numeric day-first: 12/06/2026, 12.06.2026, 12-06-202664  const num = s.match(/\b(\d{1,2})[./-](\d{1,2})[./-](\d{4})\b/);65  if (num) {66    const d = Number(num[1]);67    const m = Number(num[2]);68    if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return utc(Number(num[3]), m - 1, d);69  }70  // month year only ("juin 2026") → first of month (month precision)71  const my = s.match(/\b([A-Za-zÀ-ÿ]{3,12})\.?\s+(\d{4})\b/);72  if (my) {73    const mo = monthIndex(my[1]!);74    if (mo !== null) return utc(Number(my[2]), mo, 1);75  }76  return null;77}7879function utc(y: number, m: number, d: number): Date | null {80  const dt = new Date(Date.UTC(y, m, d));81  return Number.isNaN(dt.getTime()) || dt.getUTCMonth() !== m ? null : dt;82}8384/** Unix seconds (Auctionet `ends_at`) → Date. */85export function fromUnix(sec: number | null | undefined): Date | null {86  if (!sec || !Number.isFinite(sec)) return null;87  const d = new Date(sec * 1000);88  return Number.isNaN(d.getTime()) ? null : d;89}9091export type NumberLocale = 'eu' | 'en' | 'ch';9293/**94 * Parse a money string written with continental conventions. `locale` disambiguates the trailing-3-digit95 * case: "1.250" → 1250 under 'eu' (dot = thousands) but 1.25 under 'en'; "1'250" (Swiss) → 1250.96 * Currency: explicit symbol/code wins ("€", "CHF", "SEK", "kr", "£", "HK$", "¥"), else `fallback`.97 * Returns null for no number / non-positive amounts. Never invents a currency.98 */99export function parseEuMoney(text: string | null | undefined, fallback: CurrencyCode | null, locale: NumberLocale = 'eu'): { amount: number; currency: CurrencyCode; confidence: number } | null {100  if (!text) return null;101  let s = text.replace(/[  ]/g, ' ').replace(/\s+/g, ' ').trim();102  if (!s) return null;103  // Swiss apostrophe thousands and "Fr." prefix104  if (locale === 'ch') s = s.replace(/(\d)'(\d{3})/g, '$1$2').replace(/\bFr\.\s?/, 'CHF ');105  // Danish/Swedish/Norwegian "kr" ambiguity: keep as fallback unless explicit code present106  const explicit = detectCurrency(s);107  const currency = explicit ?? fallback;108  if (!currency) return null;109  // Normalise EU thousands groups written with spaces or dots when a comma decimal follows or when 3 trailing digits110  let m = s.match(/-?\d[\d .,']*\d|\d/);111  if (!m) return null;112  let n = m[0].replace(/[ ']/g, '');113  let confidence = 0.95;114  if (locale !== 'en') {115    if (/,\d{1,2}$/.test(n)) n = n.replace(/\./g, '').replace(',', '.');116    else if (/^\d{1,3}(\.\d{3})+$/.test(n)) n = n.replace(/\./g, '');117    else if (/^\d{1,3}(,\d{3})+$/.test(n)) n = n.replace(/,/g, ''); // some EU sites still print en groups118    else if (/\.\d{3}$/.test(n) && !/,/.test(n)) {119      n = n.replace(/\./g, '');120      confidence = 0.85;121    } else if (/,\d{3}$/.test(n)) {122      n = n.replace(/,/g, '');123      confidence = 0.85;124    } else n = n.replace(',', '.');125  } else {126    const p = parsePrice(m[0], currency);127    if (!p || p.amount <= 0) return null;128    return { amount: p.amount, currency, confidence: p.confidence };129  }130  const amount = Number.parseFloat(n);131  if (!Number.isFinite(amount) || amount <= 0) return null;132  return { amount, currency, confidence };133}134135const CURRENCY_TOKENS: Array<[RegExp, CurrencyCode]> = [136  [/\bHK\s?\$|\bHKD\b/i, 'HKD'],137  [/\bA\s?\$|\bAU\s?\$|\bAUD\b/i, 'AUD'],138  [/\bNZ\s?\$|\bNZD\b/i, 'NZD'],139  [/\bUS\s?\$|\bUSD\b/i, 'USD'],140  [/\bCA\s?\$|\bCAD\b/i, 'CAD'],141  [/\bS\s?\$|\bSGD\b/i, 'SGD'],142  [/€|\bEUR\b|\beuros?\b/i, 'EUR'],143  [/£|\bGBP\b/i, 'GBP'],144  [/¥|¥|\bJPY\b|円/i, 'JPY'],145  [/\bCHF\b|\bSFr\.?/i, 'CHF'],146  [/\bSEK\b|\bskr\b/i, 'SEK'],147  [/\bDKK\b|\bdkr\b/i, 'DKK'],148  [/\bNOK\b|\bnkr\b/i, 'NOK'],149  [/\bPLN\b|zł/i, 'PLN'],150  [/\bCZK\b|Kč/i, 'CZK'],151  [/\bCNY\b|\bRMB\b/i, 'CNY'],152  [/\bTWD\b|\bNT\$/i, 'TWD'],153];154155/** Explicit currency in a string (code or unambiguous symbol); null when absent or ambiguous ("kr", "$"). */156export function detectCurrency(text: string): CurrencyCode | null {157  for (const [re, code] of CURRENCY_TOKENS) if (re.test(text)) return code;158  return null;159}160161export function isSupportedCurrency(code: string | null | undefined): code is CurrencyCode {162  return !!code && (SUPPORTED_CURRENCIES as readonly string[]).includes(code);163}164165/**166 * Bundle / multi-item detection across the group's languages. Pairs ("ett par", "Paar", "paire", "coppia",167 * "a pair") are NOT bundles (a pair of candlesticks is one collectible unit); three or more distinct pieces,168 * "collection/lot of", "Konvolut", "samling", "parti", "lotto di", "lote de", "partij" are.169 */170export function isBundleMultilingual(title: string): boolean {171  const t = title;172  if (/\b(collection of|group of|lot of|assorted|quantity of|mixed lot|various|job lot)\b/i.test(t)) return true;173  if (/\b(konvolut|sammlung|posten|nachlass|\d+\s*-?\s*teilig|\d+\s*st(?:ück|k)\.?)\b/i.test(t)) return true; // de174  if (/\b(samling|parti|diverse|blandat|\d+\s*st\b|\d+\s*delar|\d+\s*stk)\b/i.test(t)) return true; // sv/da/no175  if (/\b(lot de|ensemble de|réunion de|reunion de|collection de|suite de|lot comprenant)\b/i.test(t)) return true; // fr176  if (/\b(lotto di|gruppo di|insieme di|collezione di)\b/i.test(t)) return true; // it177  if (/\b(lote de|conjunto de|colecci[oó]n de)\b/i.test(t)) return true; // es178  if (/\b(partij|collectie van|set van \d+|diverse)\b/i.test(t)) return true; // nl179  if (/\b(erä|kokoelma)\b/i.test(t)) return true; // fi180  const count = parenCount(t);181  return count !== null && count >= 3;182}183184/** Trailing "(4)" / "(12)" piece count used by Nordic/German houses; null when absent. */185export function parenCount(title: string): number | null {186  const m = title.match(/\((\d{1,3})\)\.?\s*$/) ?? title.match(/\b(\d{1,3})\s*(?:st|stk|pcs|pieces|pièces|pezzi|piezas|stuks|Stück|Teile)\b\.?/i);187  if (!m) return null;188  const n = Number(m[1]);189  return n >= 2 && n <= 999 ? n : null;190}191192/**193 * Year from a continental title, ignoring century/decade conventions: "1900-tal", "1900/2000-tal", "1950er",194 * "1950s", "20. Jh.", "XIXe siècle", "circa 1900" (kept), "1800-talets". Returns null when nothing safe.195 */196export function yearFromTitle(title: string): number | null {197  const now = new Date().getUTCFullYear();198  const re = /\b(1[5-9]\d{2}|20\d{2})\b(?![-–/]\s?\d{2,4}|-?\s?tal|er\b|s\b|-?\s?talet|\/)/g;199  let m: RegExpExecArray | null;200  while ((m = re.exec(title))) {201    const y = Number(m[1]);202    // skip when the token is preceded by "/" (second half of "1900/2000-tal") or followed by "-tal"203    const before = title[m.index - 1] ?? '';204    if (before === '/' || before === '-' || before === '–') continue;205    if (y >= 1500 && y <= now) return y;206  }207  return null;208}209210/** Strip tags, decode common entities, collapse whitespace; truncate to `max` chars. */211export function stripHtml(s: string | null | undefined, max = 600): string | null {212  if (!s) return null;213  const t = s214    .replace(/<br\s*\/?>/gi, ' ')215    .replace(/<\/p>/gi, ' ')216    .replace(/<[^>]+>/g, ' ')217    .replace(/&nbsp;/g, ' ')218    .replace(/&amp;/g, '&')219    .replace(/&lt;/g, '<')220    .replace(/&gt;/g, '>')221    .replace(/&quot;/g, '"')222    .replace(/&#39;|&apos;/g, "'")223    .replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n)))224    .replace(/\s+/g, ' ')225    .trim();226  if (!t) return null;227  return t.length > max ? `${t.slice(0, max - 1)}…` : t;228}229230/** Location string → ISO country when obvious (used for `location` display, never for currency). */231export function countryFromLocation(loc: string | null | undefined): string | null {232  if (!loc) return null;233  const l = loc.toLowerCase();234  if (/stockholm|göteborg|goteborg|malmö|malmo|uppsala|helsingborg|sweden|sverige/.test(l)) return 'SE';235  if (/köln|cologne|hamburg|berlin|münchen|munich|frankfurt|düsseldorf|stuttgart|germany|deutschland/.test(l)) return 'DE';236  if (/københavn|copenhagen|aarhus|denmark|danmark/.test(l)) return 'DK';237  if (/helsinki|helsingfors|turku|finland/.test(l)) return 'FI';238  if (/madrid|barcelona|valencia|sevilla|spain|españa/.test(l)) return 'ES';239  if (/london|manchester|edinburgh|glasgow|united kingdom|england|scotland/.test(l)) return 'GB';240  if (/paris|lyon|marseille|bordeaux|france/.test(l)) return 'FR';241  if (/wien|vienna|austria|österreich/.test(l)) return 'AT';242  if (/zürich|zurich|genève|geneva|basel|switzerland|schweiz/.test(l)) return 'CH';243  if (/oslo|bergen|norway|norge/.test(l)) return 'NO';244  if (/amsterdam|rotterdam|den haag|netherlands|nederland/.test(l)) return 'NL';245  if (/bruxelles|brussel|antwerpen|belgium|belgique/.test(l)) return 'BE';246  if (/milano|roma|torino|genova|italy|italia/.test(l)) return 'IT';247  return null;248}249250/**251 * Translate department / sale-title vocabulary from fr/de/it/es/nl/sv into the English keywords that252 * `_auction-lib`'s `hintFromLabel` understands, so continental sale titles map to the same department hints.253 */254const LABEL_VOCAB: Array<[RegExp, string]> = [255  [/\b(montres?|horlogerie|uhren|armbanduhren|orologi|relojes|horloges|klockor|armbandsur)\b/i, 'watches'],256  [/\b(bijoux|joaillerie|schmuck|juwelen|gioielli|joyas|sieraden|smycken|juweelen)\b/i, 'jewellery'],257  [/\b(vins?|spiritueux|wein|weine|spirituosen|vini|vinos|wijn|whisky|champagne|viner)\b/i, 'wine'],258  [/\b(monnaies|numismatique|münzen|numismatik|monete|monedas|munten|mynt|medailles|médailles)\b/i, 'coins'],259  [/\b(timbres|philatélie|briefmarken|philatelie|francobolli|filatelia|sellos|postzegels|frimärken)\b/i, 'stamps'],260  [/\b(automobiles?|voitures|motorcars|automobilia|autos?|automobili|coches|auto's|bilar)\b/i, 'motor cars'],261  [/\b(motos?|motocyclettes|motorräder|motociclette|motorfietsen|motorcyklar)\b/i, 'motorcycles'],262  [/\b(bandes? dessinées?|bd\b|comics|fumetti|cómics|strips|serier)\b/i, 'comics'],263  [/\b(livres?|manuscrits|bücher|bibliothek|libri|libros|boeken|böcker|autographes|autographen|cartes anciennes|landkarten)\b/i, 'books'],264  [/\b(photographies?|photographie|fotografie|fotografia|fotografía|foto's|fotografi)\b/i, 'photographs'],265  [/\b(jouets|spielzeug|giocattoli|juguetes|speelgoed|leksaker|poupées|puppen|trains|modellbahn)\b/i, 'toys'],266  [/\b(militaria|décorations|orden|ehrenzeichen|armes|waffen|armi|armas|wapens|vapen)\b/i, 'militaria'],267  [/\b(design|arts? décoratifs? du xxe|kunstgewerbe|arredi di design|diseño|vormgeving)\b/i, 'design'],268  [/\b(mobilier|meubles|möbel|arredi|mobili|muebles|meubelen|möbler|objets d'art|kunstgewerbe|antiquités|antiquitäten|antichità|antigüedades|antiek|antikviteter|tapis|teppiche|tappeti|alfombras)\b/i, 'furniture'],269  [/\b(argenterie|orfèvrerie|silber|argenti|plata|zilver|silver)\b/i, 'silver'],270  [/\b(céramiques?|porcelaine|porzellan|keramik|ceramiche|porcellane|cerámica|porcelana|porselein|keramiek|porslin|keramik)\b/i, 'ceramics'],271  [/\b(verre|verrerie|glas|vetri|vidrio|glaswerk)\b/i, 'glass'],272  [/\b(art contemporain|art moderne|après-guerre|post-war|moderne kunst|zeitgenössische kunst|arte moderna|arte contemporanea|arte contemporáneo|moderne en hedendaagse kunst|modern konst|samtida konst|street art|urban art|estampes|graphik|druckgraphik|editions)\b/i, 'contemporary'],273  [/\b(tableaux|peintures|dessins|gemälde|zeichnungen|alte meister|maîtres anciens|dipinti|disegni|pinturas|dibujos|schilderijen|tekeningen|målningar|sculptures|skulpturen|sculture|esculturas|beelden|konst|kunst|arte|art)\b/i, 'fine art'],274  [/\b(art d'asie|arts d'asie|asiatique|asiatica|asiatische kunst|chine|chinois|japon|japonais|arte asiatica|arte asiático|aziatische kunst|asiatisk|china|japan|japanese|chinese)\b/i, 'asian'],275  [/\b(arts premiers|art tribal|art africain|art océanien|arts d'afrique|stammeskunst|arte tribale|arte africano|tribale kunst|afrikansk)\b/i, 'tribal'],276  [/\b(archéologie|antiquités classiques|antike|archeologia|arqueología|archeologie|antikviteter klassiska)\b/i, 'antiquities'],277  [/\b(mode|haute couture|maroquinerie|sacs|hermès|vuitton|chanel|taschen|borse|bolsos|tassen|väskor|vintage fashion)\b/i, 'handbags'],278  [/\b(instruments? scientifiques?|wissenschaftliche instrumente|strumenti scientifici|marine|nautica|technica)\b/i, 'scientific instruments'],279  [/\b(cinéma|affiches|plakate|manifesti|carteles|affiches|filmplakat)\b/i, 'movie posters'],280  [/\b(musique|musik|musica|música|muziek|instruments de musique|musikinstrumente|strumenti musicali|vinyles|schallplatten)\b/i, 'music'],281  [/\b(sport|sports|sportmemorabilia)\b/i, 'sports'],282  [/\b(stylos|schreibgeräte|penne|plumas|pennen|pennor)\b/i, 'pens'],283  [/\b(parfums?|parfüm|profumi|perfumes)\b/i, 'perfume'],284  [/\b(appareils photo|kameras|fotocamere|cámaras|camera's|kameror)\b/i, 'cameras'],285  [/\b(jeux vidéo|videospiele|videogiochi|videojuegos|videogames|tv-spel)\b/i, 'video games'],286  [/\b(pendules|horloges|uhren und pendulen|orologi da tavolo|relojes de pared)\b/i, 'clocks'],287];288289/** Sale/department label (any supported language) → English keyword string for `hintFromLabel`. */290export function englishLabel(label: string | null | undefined): string {291  if (!label) return '';292  const hits: string[] = [];293  for (const [re, en] of LABEL_VOCAB) if (re.test(label)) hits.push(en);294  return hits.length ? hits.join(' ') : label;295}296297/**298 * Strong per-lot department cues in the group's languages (a Rolex filed under "Moderne Kunst" is still a299 * watch). Returns a department hint understood by `_auction-lib` or null when the lot text has no strong cue.300 */301export function strongLotHint(text: string): 'watches' | 'jewelry' | 'wine' | 'coins' | 'stamps' | 'handbags' | 'cars' | 'motorcycles' | 'cameras' | 'comics' | 'books' | 'asian' | 'antiquities' | null {302  const t = text;303  if (/\b(armbanduhr|armbanduhren|taschenuhr|montre|montres|orologio|orologi|reloj|relojes|horloge|polshorloge|armbandsur|fickur|wristwatch|pocket watch|chronograph|chronographe|cronografo|rolex|patek philippe|audemars piguet|omega|breitling|iwc|jaeger[- ]lecoultre|vacheron|cartier tank|tudor|panerai|hublot|zenith|longines|breguet|blancpain)\b/i.test(t) && !/\b(poster|affiche|plakat|book|livre|buch|catalogue|katalog)\b/i.test(t)) return 'watches';304  if (/\b(bague|collier|bracelet|broche|boucles d'oreilles|ring|halskette|armband|brosche|ohrringe|anello|collana|bracciale|spilla|orecchini|anillo|collar|pulsera|pendientes|diamant|diamanten|diamante|brillant|saphir|rubis|émeraude|smaragd|rubin|zaffiro|rubino|smeraldo|diamond|sapphire|emerald|ruby|carats?|\d+(?:[.,]\d+)?\s*ct\b|earrings?|necklace|brooch|pendant|tiara|bangle|cufflinks|halsband|örhängen|armbandsur)\b/i.test(t) && !/\barmbandsur\b/i.test(t)) return 'jewelry';305  if (/\b(whisky|whiskey|bourbon|cognac|armagnac|champagne|bordeaux|bourgogne|burgundy|château|chateau|domaine|magnum|bouteilles?|flaschen?|bottiglie?|botellas?|flaskor|wein|vino|vin\b|riesling|barolo|brunello|romanée|pétrus|petrus|lafite|latour|margaux|mouton|yquem|krug|dom pérignon|macallan|yamazaki|hibiki|springbank|bowmore|ardbeg)\b/i.test(t)) return 'wine';306  if (/\b(münze|münzen|mynt|monnaie|monnaies|moneta|monete|moneda|monedas|munt|munten|coin|coins|dukat|ducat|taler|thaler|riksdaler|sovereign|20 francs or|louis d'or|napoléon|napoleon 20|goldmünze|silbermünze|banknote|geldschein|billet de banque|sedel|sedlar|banconota|billete)\b/i.test(t)) return 'coins';307  if (/\b(briefmarke|briefmarken|timbre|timbres|francobollo|francobolli|sello|sellos|postzegel|postzegels|frimärke|frimärken|stamp|stamps|philatel\w*|postal history)\b/i.test(t)) return 'stamps';308  if (/\b(hermès|hermes birkin|birkin|kelly bag|sac kelly|chanel (?:timeless|classic|flap|2\.55)|louis vuitton|vuitton|goyard|handtasche|sac à main|borsa|bolso|handbag|tote bag)\b/i.test(t)) return 'handbags';309  if (/\b(motorrad|motocyclette|motocicletta|motocicleta|motorfiets|motorcykel|motorcycle|ducati|harley[- ]davidson|vespa|moto guzzi|bsa\b|norton|triumph bonneville)\b/i.test(t)) return 'motorcycles';310  if (/\b(chassis|châssis|fahrgestell|telaio|bastidor|numéro de série|vin\b|coupé|cabriolet|roadster|berline|limousine|spider|spyder|berlinetta)\b/i.test(t) && /\b(ferrari|porsche|mercedes|bmw|jaguar|aston martin|bentley|rolls[- ]royce|alfa romeo|lancia|maserati|bugatti|citroën|citroen|peugeot|renault|volvo|saab|ford|chevrolet|cadillac|lamborghini|mclaren|austin|mg\b|triumph|lotus|fiat|volkswagen|vw\b)\b/i.test(t)) return 'cars';311  if (/\b(leica|hasselblad|rolleiflex|nikon f\d?|contax|kamera|appareil photo|fotocamera|cámara|objektiv|objectif)\b/i.test(t)) return 'cameras';312  if (/\b(bande dessinée|bandes dessinées|planche originale|comic|comics|fumetto|fumetti|serietidning|tintin|astérix|asterix|hergé|uderzo|franquin|manga)\b/i.test(t)) return 'comics';313  if (/\b(dynasty|dynastie|kangxi|qianlong|yongzheng|wanli|ming|qing|song|tang|han\b|edo|meiji|taisho|satsuma|imari|kutani|celadon|cloisonné|netsuke|okimono|inro|tsuba|thangka|khmer|gandhara|famille rose|famille verte|blanc de chine|kakiemon|arita|chinesisch|chinoise|chinese|japanisch|japonais|japanese|tibetan|tibétain)\b/i.test(t)) return 'asian';314  if (/\b(roman|romaine|römisch|romano|greek|grec|griechisch|greco|etruscan|étrusque|egyptian|égyptien|ägyptisch|egizio|mesopotamian|sumerian|bactrian|hellenistic|hellénistique|\d+(?:st|nd|rd|th)? century (?:bc|b\.c\.)|av\. ?j\.-c\.|v\. ?chr\.|a\.c\.|\bbc\b)\b/i.test(t)) return 'antiquities';315  if (/\b(édition originale|first edition|erstausgabe|prima edizione|primera edición|incunable|incunabula|manuscrit|manuscript|handschrift|folio|in-4|in-8|in-12|reliure|einband|legatura|exemplaire numéroté)\b/i.test(t)) return 'books';316  return null;317}318