import type { AssetAttributes, CurrencyCode } from '@rareindex/shared'; import { parsePrice } from '@rareindex/shared'; /** * Shared helpers for the numismatic / philatelic connectors (group g5): coins, banknotes, medals, * stamps. Source-specific vocabulary stays in the connectors; this file holds the parsers several of * them need — category classification, Sheldon-scale grades with grader + certification number, * country / year / denomination extraction and the sale-date formats auction houses print. */ export type NumisSlug = 'coins' | 'banknotes' | 'medals' | 'stamps'; const BANKNOTE = /\b(banknote|bank note|banknotes|paper money|treasury note|currency note|specimen note|\bnote\b|notes\b|pick\s*#?\s*\d|p-?\d{1,4}[a-z]?\b|pmg\b|polymer|\d+\s*(?:dollars?|pounds?|francs?|marks?|rupees?|yuan|kwanzas?|pesos?|lire|kronor|zloty|rubles?)\s+(?:banknote|note))\b/i; const MEDAL = /\b(medal|medals|medallion|order of|decoration|campaign group|dso\b|mc group|victoria cross|badge|plaque|plaquette|jeton|token|tokens)\b/i; const STAMP = /\b(stamp|stamps|postal|cover|covers|philatel|penny black|scott\s*#?\s*\d|sg\s*\d|michel\s*\d|imperf|perf\.?\s*\d|mint never hinged|\bmnh\b|\bog\b|\bnh\b|postmark|cancel|cancellation|essay|die proof|plate proof|postal history|postcard|aerogram)\b/i; const COIN = /\b(coin|coins|penny|pence|shilling|crown|sovereign|guinea|farthing|groat|denarius|denarii|aureus|solidus|stater|tetradrachm|drachm|obol|sestertius|dupondius|antoninianus|follis|nummus|ducat|thaler|taler|florin|gulden|kreuzer|pfennig|mark|reichsmark|franc|centime|peso|real|reales|escudo|rupee|yuan|cash|tetras|litra|dollar|cent|dime|nickel|quarter|eagle|morgan|peace|bust|seated|barber|mercury|walking liberty|pcgs|ngc|anacs|iccs|\bms\s?-?\d{2}|\bau\s?-?\d{2}|\bvf\b|\bef\b|\bxf\b|\bunc\b|\bfdc\b|\bbu\b|proof|mint state|\bæ\b|\bar\b|\bav\b|\bbi\b|billon|electrum)\b/i; /** * Classify a lot title within a numismatic / philatelic house. `hint` is the department or * catalogue section the source itself gives (already lower-cased or not) and wins over title * keywords when it is unambiguous; the fallback is `defaultSlug` (a coin house defaults to coins). */ export function numisCategory(title: string, hint?: string | null, defaultSlug: NumisSlug = 'coins'): NumisSlug { const h = (hint ?? '').toLowerCase(); if (h) { if (/banknote|bank note|paper money|papiergeld|billet|currency|notes\b/.test(h) && !/coin/.test(h)) return 'banknotes'; if (/stamp|philatel|postal|briefmark|timbre/.test(h)) return 'stamps'; if (/(^|[^a-z])(medal|medals|medallion|orders|decorations|militaria|token|jeton)/.test(h) && !/coin/.test(h)) return 'medals'; if (/coin|numismat|münz|monnaie|ancient|greek|roman|byzantine|celtic|islamic|medieval|world|british|united states|gold|silver/.test(h)) { // department says coins; a banknote or stamp keyword in the title still wins if (BANKNOTE.test(title) && !COIN.test(title)) return 'banknotes'; if (STAMP.test(title) && !COIN.test(title)) return 'stamps'; return 'coins'; } } if (STAMP.test(title) && !COIN.test(title) && !BANKNOTE.test(title)) return 'stamps'; if (BANKNOTE.test(title) && !/\bcoin\b/i.test(title)) return 'banknotes'; if (MEDAL.test(title) && !COIN.test(title)) return 'medals'; if (COIN.test(title)) return 'coins'; return defaultSlug; } export interface CoinGrade { /** taxonomy grader slug (pcgs, ngc, anacs, iccs, pmg) or null when raw / unknown */ grader: string | null; /** normalised grade token as the grader prints it: MS65, MS65+, PR64DCAM, AU58, 66 EPQ (PMG) */ grade: string | null; qualifier: string | null; certificationNumber: string | null; /** raw condition words for ungraded lots: "good very fine", "EF", "FDC", "aUNC" */ conditionRaw: string | null; } const GRADERS = /\b(PCGS|NGC|ANACS|ICCS|PMG|CAC)\b/gi; const GRADE_TOKEN = /\b(MS|PR|PF|SP|AU|XF|EF|VF|F|VG|G|AG|PL|BU|UNC|CH\s?UNC|CHOICE UNC|GEM UNC|CH\s?AU|CHOICE AU|CH\s?XF|CH\s?VF|FR|PO)\s?-?\s?(\d{1,2})(?!\d)(\+?)(?:\s?(DCAM|DMPL|UCAM|UC|CAM|RD|RB|BN|FB|FBL|FH|FS|PL|\*|STAR|EPQ|NET)(?![A-Za-z]))?/i; const PMG_GRADE = /\b(?:PMG|PCGS(?: Banknote| Currency)?)\s+(?:(Superb Gem Unc|Gem Uncirculated|Gem Unc|Choice Unc|Choice About Unc|About Unc|Very Fine|Choice Very Fine|Extremely Fine|Choice Extremely Fine|Fine|Very Good|Good)\s+)?(\d{1,2})\s?(EPQ|PPQ|NET|\*)?/i; const CERT = /(?:\bcert(?:ification)?(?:\s*(?:no|number|#))?\.?\s*[:#]?\s*|(?:^|[\s(])#\s?|\bserial\s*#?\s*)(\d{6,10})\b/i; const CONDITION_WORDS = /\b((?:about |a\.?|near(?:ly)? |practically |virtually |choice |gem |brilliant |superb |good |very |almost |extremely )*(?:as struck|fleur de coin|fdc|uncirculated|unc|mint state|proof|extremely fine|very fine|fine|good|poor|fair|ef|xf|vf|gvf|nvf|avf|gf|nf|bu|au)\b(?: or better| or so| to [a-z ]+fine)?)/i; /** Parse grader / grade / cert from a coin, medal or banknote title. Never invents a grade. */ export function parseCoinGrade(title: string): CoinGrade { const graders = [...title.matchAll(GRADERS)].map((m) => m[1]!.toUpperCase()); const primary = graders.find((g) => g !== 'CAC') ?? null; const grader = primary ? primary.toLowerCase() : null; let grade: string | null = null; let qualifier: string | null = null; const pmg = title.match(PMG_GRADE); if (pmg) { grade = `${pmg[2]}${pmg[3] ? ` ${pmg[3].toUpperCase()}` : ''}`; if (pmg[1]) qualifier = pmg[1]; } else { const g = title.match(GRADE_TOKEN); if (g) { const n = Number(g[2]); if (n >= 1 && n <= 70) { const prefix = g[1]!.toUpperCase().replace(/\s+/g, ''); grade = `${prefix}${g[2]}${g[3] ?? ''}${g[4] ? g[4].toUpperCase().replace('*', 'STAR') : ''}`; } } } if (!grade && primary) { // adjectival slab grades (NGC Ancients "Choice VF", "Ch XF", "Gem MS"; PMG "Choice Fine") const adj = title.match(new RegExp(`\\b${primary}\\b[\\s|:-]*((?:Superb Gem|Gem|Choice|Ch\\.?)\\s*)?(MS|Mint State|AU|XF|EF|VF|F|VG|G|Fine|Very Fine|Extremely Fine|About Unc(?:irculated)?|Unc(?:irculated)?)\\b(?!\\s*-?\\d)`, 'i')); if (adj) grade = `${adj[1] ? `${adj[1].trim().replace(/^Ch\.?$/i, 'Choice')} ` : ''}${adj[2]!}`.replace(/\s+/g, ' ').trim(); } if (graders.includes('CAC')) qualifier = qualifier ? `${qualifier} · CAC` : 'CAC'; const cert = grader ? (title.match(CERT)?.[1] ?? null) : null; const cond = title.match(CONDITION_WORDS); return { grader, grade, qualifier, certificationNumber: cert, conditionRaw: !grade && cond ? cond[1]!.trim() : null }; } /** PCGS coin numbers when the source prints them ("PCGS# 7080", "PCGS No. 7080"). */ export function pcgsNumber(title: string): string | null { return title.match(/\bPCGS\s*(?:#|No\.?|Number)\s*(\d{3,7})\b/i)?.[1] ?? null; } /** NGC coin ids ("NGC ID: 253J") when printed. */ export function ngcId(title: string): string | null { return title.match(/\bNGC\s*ID\s*:?\s*([0-9A-Z]{3,6})\b/i)?.[1] ?? null; } const COUNTRY_MAP: Array<[RegExp, string]> = [ [/\b(united states|u\.?s\.?a\.?|usa|american|vereinigte staaten|états-unis|etats-unis|stati uniti)\b/i, 'US'], [/\b(great britain|british|england|english|united kingdom|uk|scotland|scottish|wales|welsh|großbritannien|grossbritannien|grande-bretagne|gran bretagna)\b/i, 'GB'], [/\b(ireland|irish|irland|irlande)\b/i, 'IE'], [/\b(canada|canadian|newfoundland|kanada)\b/i, 'CA'], [/\b(australia|australian|australien|australie)\b/i, 'AU'], [/\b(new zealand)\b/i, 'NZ'], [/\b(france|french|gaul|frankreich|francia)\b/i, 'FR'], [/\b(germany|german|deutschland|allemagne|germania|prussia|preußen|preussen|bavaria|bayern|saxony|sachsen|hamburg|hannover|württemberg|wurttemberg|brandenburg|reich|jülich|juelich|kleve|berg|esslingen|nürnberg|nuernberg|augsburg|frankfurt|köln|koeln|mainz|trier|hessen|baden|braunschweig|lübeck|luebeck|bremen|regensburg|ulm|münster|muenster|pfalz|anhalt|mecklenburg|schleswig|holstein|oldenburg|lippe|reuss|schwarzburg|waldeck|nassau|hohenzollern)\b/i, 'DE'], [/\b(austria|austrian|habsburg|österreich|oesterreich|autriche|salzburg|tirol|tyrol|steiermark|kärnten)\b/i, 'AT'], [/\b(switzerland|swiss|helvetia|schweiz|suisse|svizzera|bern|zürich|zuerich|basel|genf|genève|luzern|solothurn|graubünden)\b/i, 'CH'], [/\b(italy|italian|italien|italie|italia|venice|venetian|venezia|venedig|genoa|genova|genua|naples|napoli|neapel|sicily|sizilien|papal|vatican|vatikan|sardinia|sardinien|tuscany|toskana|milan|mailand|milano|kirchenstaat|papst|florenz|firenze|savoyen|savoia)\b/i, 'IT'], [/\b(spain|spanish|spanien|espagne|españa|castile|kastilien|aragon)\b/i, 'ES'], [/\b(portugal|portuguese|portugiesisch)\b/i, 'PT'], [/\b(netherlands|dutch|holland|niederlande|pays-bas|utrecht|zeeland|gelderland|overijssel|friesland|nederland)\b/i, 'NL'], [/\b(belgium|belgian|belgien|belgique|brabant|flanders|flandern|lüttich|liège|hennegau)\b/i, 'BE'], [/\b(sweden|swedish|schweden|suède|svezia)\b/i, 'SE'], [/\b(norway|norwegian|norwegen|norvège)\b/i, 'NO'], [/\b(denmark|danish|dänemark|daenemark|danemark)\b/i, 'DK'], [/\b(russia|russian|russland|russie|soviet|sowjetunion|ussr|udssr)\b/i, 'RU'], [/\b(poland|polish|polen|pologne|polska)\b/i, 'PL'], [/\b(hungary|hungarian|ungarn|hongrie)\b/i, 'HU'], [/\b(czech|bohemia|bohemian|böhmen|boehmen|mähren|czechoslovakia|tschechoslowakei|tschechien)\b/i, 'CZ'], [/\b(greece|greek|griechenland|grèce)\b/i, 'GR'], [/\b(turkey|turkish|türkei|tuerkei|ottoman|osmanisches reich|osmanen)\b/i, 'TR'], [/\b(china|chinese|chine|cina|volksrepublik china|kiangnan|kwangtung|hupeh|szechuan|yunnan)\b/i, 'CN'], [/\b(japan|japanese|japon)\b/i, 'JP'], [/\b(korea|korean)\b/i, 'KR'], [/\b(india|indian|indien|inde|british india|mughal|sultanate)\b/i, 'IN'], [/\b(mexico|mexican|mexiko|mexique)\b/i, 'MX'], [/\b(brazil|brazilian|brasilien|brésil)\b/i, 'BR'], [/\b(argentina|argentine)\b/i, 'AR'], [/\b(chile|chilean)\b/i, 'CL'], [/\b(peru|peruvian)\b/i, 'PE'], [/\b(south africa|south african|südafrika|suedafrika)\b/i, 'ZA'], [/\b(egypt|egyptian|ägypten|aegypten)\b/i, 'EG'], [/\b(israel|israeli|palestine)\b/i, 'IL'], [/\b(hong kong)\b/i, 'HK'], [/\b(singapore|straits settlements)\b/i, 'SG'], [/\b(malaysia|malaya)\b/i, 'MY'], [/\b(thailand|siam)\b/i, 'TH'], [/\b(philippines|philippine)\b/i, 'PH'], [/\b(indonesia|netherlands east indies|netherlands indies)\b/i, 'ID'], [/\b(vietnam|annam|indo-?china)\b/i, 'VN'], [/\b(iran|persia|persian|persien)\b/i, 'IR'], [/\b(monaco)\b/i, 'MC'], [/\b(luxembourg)\b/i, 'LU'], [/\b(finland|finnish)\b/i, 'FI'], [/\b(iceland)\b/i, 'IS'], [/\b(romania|romanian)\b/i, 'RO'], [/\b(bulgaria|bulgarian)\b/i, 'BG'], [/\b(serbia|yugoslavia)\b/i, 'RS'], [/\b(croatia)\b/i, 'HR'], [/\b(ukraine)\b/i, 'UA'], [/\b(cuba|cuban)\b/i, 'CU'], [/\b(colombia)\b/i, 'CO'], [/\b(venezuela)\b/i, 'VE'], [/\b(bolivia)\b/i, 'BO'], [/\b(guatemala)\b/i, 'GT'], [/\b(jamaica)\b/i, 'JM'], [/\b(bermuda)\b/i, 'BM'], [/\b(bahamas)\b/i, 'BS'], [/\b(ceylon|sri lanka)\b/i, 'LK'], [/\b(pakistan)\b/i, 'PK'], [/\b(burma|myanmar)\b/i, 'MM'], [/\b(nepal)\b/i, 'NP'], [/\b(tibet)\b/i, 'CN'], [/\b(mongolia)\b/i, 'MN'], [/\b(afghanistan)\b/i, 'AF'], [/\b(saudi|hejaz)\b/i, 'SA'], [/\b(morocco|moroccan)\b/i, 'MA'], [/\b(algeria)\b/i, 'DZ'], [/\b(tunisia)\b/i, 'TN'], [/\b(nigeria)\b/i, 'NG'], [/\b(kenya|east africa)\b/i, 'KE'], [/\b(ethiopia)\b/i, 'ET'], [/\b(zimbabwe|rhodesia)\b/i, 'ZW'], [/\b(angola)\b/i, 'AO'], [/\b(mozambique)\b/i, 'MZ'], [/\b(congo)\b/i, 'CD'], [/\b(malta)\b/i, 'MT'], [/\b(cyprus)\b/i, 'CY'], [/\b(gibraltar)\b/i, 'GI'], [/\b(isle of man)\b/i, 'IM'], [/\b(jersey|guernsey)\b/i, 'JE'], ]; /** ISO-3166 alpha-2 guessed from the country word a numismatic title starts with (null when absent). */ export function countryFromTitle(title: string): string | null { const head = title.slice(0, 80); for (const [re, iso] of COUNTRY_MAP) if (re.test(head)) return iso; // British monarchs / Anglo-Saxon and Norman rulers as printed by UK houses ("Edward VII (1901-1910), Sovereign…") if (/\b(Victoria|Edward VII|Edward VIII|George [IV]+\b|William IV|William III|William and Mary|Elizabeth II|Elizabeth I|Charles I{1,3}\b|James I{1,2}\b|Anne\b|Henry [IV]+\b|Edward [IV]+\b|Richard I{1,3}\b|Stephen\b|Cnut|Canute|Æthelred|Aethelred|Alfred|Offa|Coenwulf|Harold I{1,2}\b|Edward the Confessor|Mary I\b|Philip and Mary|Cromwell|Commonwealth)\b/.test(head) && !/\b(canada|australia|india|new zealand|south africa|ceylon|hong kong|straits|jamaica|newfoundland|cyprus|malta|fiji|east africa|rhodesia|nigeria|gold coast|bermuda|bahamas|barbados|guernsey|jersey|isle of man|gibraltar)\b/i.test(title)) return 'GB'; return null; } /** * Struck year: 4-digit 1400–now (medieval–modern); ancient coins get null. Years that are part of a * reign range ("Oskar II., 1872-1907", "(1901-1910)") are skipped so "1 Krone 1884" → 1884. * Mint mark "1893-S" → S. */ export function coinYear(title: string): { year: number | null; mintMark: string | null } { const now = new Date().getUTCFullYear(); const re = /(? = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, sept: 8, oct: 9, nov: 10, dec: 11 }; function mon(s: string): number | null { const k = s.toLowerCase().slice(0, 4) === 'sept' ? 'sept' : s.toLowerCase().slice(0, 3); return MONTHS[k] ?? null; } /** * Auction dates as printed by the houses → UTC midnight of the FIRST day. * "2 Sep 2026" · "8,11 Oct 2021" · "30 June 2026" · "9 & 10 June 2026" · "23-24 June 2026" · "7-11 Sep 2026" · * "September 30, 2026" · "30.06.2026" · "2026-06-30" · "Sep 30 - Oct 2, 2026". */ export function parseAuctionDate(text: string | null | undefined): Date | null { if (!text) return null; const s = text.replace(/\s+/g, ' ').trim(); let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return utc(Number(m[1]), Number(m[2]) - 1, Number(m[3])); m = s.match(/\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/); if (m) return utc(Number(m[3]), Number(m[2]) - 1, Number(m[1])); // day-first: "2 Sep 2026", "8,11 Oct 2021", "9 & 10 June 2026", "23-24 June 2026", "30 Sep - 2 Oct 2026" m = s.match(/\b(\d{1,2})(?:\s*(?:[,&–-]|and|to)\s*\d{1,2})*\s*(?:([A-Za-z]{3,9})\.?\s*(?:[–-]|to)\s*\d{1,2}\s*[A-Za-z]{3,9}\.?)?\s*([A-Za-z]{3,9})?\.?,?\s+(\d{4})\b/); if (m) { const monthWord = m[2] ?? m[3]; const mo = monthWord ? mon(monthWord) : null; if (mo !== null) return utc(Number(m[4]), mo, Number(m[1])); } // month-first: "September 30, 2026", "Sep 30 - Oct 2, 2026", "June 9-10, 2026" m = s.match(/\b([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:\s*(?:[,&–-]|and|to)\s*(?:[A-Za-z]{3,9}\.?\s+)?\d{1,2})*,?\s+(\d{4})\b/); if (m) { const mo = mon(m[1]!); if (mo !== null) return utc(Number(m[3]), mo, Number(m[2])); } return null; } function utc(y: number, mo: number, d: number): Date | null { if (mo < 0 || mo > 11 || d < 1 || d > 31) return null; const date = new Date(Date.UTC(y, mo, d)); return Number.isNaN(date.getTime()) ? null : date; } /** "600 USD" | "£3,800" | "1.250 EUR" | "CHF 1'200" → amount + ISO currency (null when unsold / missing). */ export function realized(text: string | null | undefined, fallback?: CurrencyCode): { amount: number; currency: CurrencyCode } | null { if (!text) return null; if (/^\s*(--|-|unsold|w\/d|withdrawn|n\/a|passed|not sold)\s*$/i.test(text)) return null; const p = parsePrice(text, fallback); if (!p || !p.currency || p.amount <= 0) return null; return { amount: p.amount, currency: p.currency }; } export interface NumisAttrInput { categorySlug: NumisSlug | string; title: string; /** catalogue section / department the source printed */ section?: string | null; country?: string | null; identifiers?: Record; metadata?: Record; brand?: string | null; series?: string | null; /** extra text (e.g. a weight/material property) consulted for the metal when the title has none */ materialHint?: string | null; /** source-structured year (overrides the title heuristic when present) */ year?: number | null; } /** Build canonical attributes for a numismatic lot; nothing is guessed beyond what the title states. */ export function numisAttributes(i: NumisAttrInput): AssetAttributes { const parsed = i.categorySlug === 'stamps' ? { year: coinYear(i.title).year, mintMark: null } : coinYear(i.title); const year = i.year ?? parsed.year; const mintMark = parsed.mintMark; const denom = i.categorySlug === 'coins' ? denomination(i.title) : null; const ids = { ...(i.identifiers ?? {}) }; const pn = pcgsNumber(i.title); if (pn) ids.pcgs_number = pn; const nid = ngcId(i.title); if (nid) ids.ngc_id = nid; return { categorySlug: i.categorySlug, subcategorySlug: null, franchise: null, brand: i.brand ?? null, series: i.series ?? null, set: i.section ?? null, setCode: null, name: i.title, model: null, reference: null, number: null, year, edition: null, variant: mintMark ? `${mintMark} mint` : null, language: null, region: null, country: i.country ?? countryFromTitle(i.title), material: metal(i.title) ?? (i.materialHint ? metal(i.materialHint) : null), size: null, color: null, rarity: null, productionQuantity: null, originalMsrp: null, originalMsrpCurrency: null, identifiers: ids, metadata: { ...(i.metadata ?? {}), denomination: denom, mint_mark: mintMark }, }; } /** Metal from numismatic abbreviations / words (AV/AR/Æ/AE, gold/silver/bronze/copper/billon/electrum/platinum, DE/FR/IT/ES words). */ export function metal(title: string): string | null { const t = title; if (/\bAV\b|\bgold(?:en)?\b|feingold|finegold|\bd'or\b|\boro\b(?!\w)|\bgoud\b/i.test(t)) return 'gold'; if (/\bAR\b|\bsilver\b|\bargent\b|\bplata\b|\bsilber\b|\bargento\b|\bzilver\b/i.test(t)) return 'silver'; if (/\bÆ\b|\bAE\b|\bbronze\b|\bcopper\b|\bbrass\b|\bcuivre\b|\bkupfer\b|\bmessing\b|\brame\b|\bcobre\b/i.test(t)) return 'bronze'; if (/\bBI\b|\bbillon\b/i.test(t)) return 'billon'; if (/\bEL\b|\belectrum\b|\belektron\b/i.test(t)) return 'electrum'; if (/\bplatinum\b|\bplatine?\b|\bplatin\b/i.test(t)) return 'platinum'; if (/\bnickel\b|cupro-?nickel|\bkupfernickel\b/i.test(t)) return 'nickel'; return null; } /** Firecrawl / cheerio text cleanup: entities, tags, whitespace. */ export function clean(s: string | null | undefined): string { return (s ?? '') .replace(/&/g, '&') .replace(/�?39;|'|’|’/g, "'") .replace(/"|“|”/g, '"') .replace(/ | /g, ' ') .replace(/£/g, '£') .replace(/€/g, '€') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); }