TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { AssetAttributes, CurrencyCode } from '@rareindex/shared';2import { parsePrice } from '@rareindex/shared';34/**5 * Shared helpers for the numismatic / philatelic connectors (group g5): coins, banknotes, medals,6 * stamps. Source-specific vocabulary stays in the connectors; this file holds the parsers several of7 * them need — category classification, Sheldon-scale grades with grader + certification number,8 * country / year / denomination extraction and the sale-date formats auction houses print.9 */1011export type NumisSlug = 'coins' | 'banknotes' | 'medals' | 'stamps';1213const 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;14const MEDAL = /\b(medal|medals|medallion|order of|decoration|campaign group|dso\b|mc group|victoria cross|badge|plaque|plaquette|jeton|token|tokens)\b/i;15const 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;16const 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;1718/**19 * Classify a lot title within a numismatic / philatelic house. `hint` is the department or20 * catalogue section the source itself gives (already lower-cased or not) and wins over title21 * keywords when it is unambiguous; the fallback is `defaultSlug` (a coin house defaults to coins).22 */23export function numisCategory(title: string, hint?: string | null, defaultSlug: NumisSlug = 'coins'): NumisSlug {24 const h = (hint ?? '').toLowerCase();25 if (h) {26 if (/banknote|bank note|paper money|papiergeld|billet|currency|notes\b/.test(h) && !/coin/.test(h)) return 'banknotes';27 if (/stamp|philatel|postal|briefmark|timbre/.test(h)) return 'stamps';28 if (/(^|[^a-z])(medal|medals|medallion|orders|decorations|militaria|token|jeton)/.test(h) && !/coin/.test(h)) return 'medals';29 if (/coin|numismat|münz|monnaie|ancient|greek|roman|byzantine|celtic|islamic|medieval|world|british|united states|gold|silver/.test(h)) {30 // department says coins; a banknote or stamp keyword in the title still wins31 if (BANKNOTE.test(title) && !COIN.test(title)) return 'banknotes';32 if (STAMP.test(title) && !COIN.test(title)) return 'stamps';33 return 'coins';34 }35 }36 if (STAMP.test(title) && !COIN.test(title) && !BANKNOTE.test(title)) return 'stamps';37 if (BANKNOTE.test(title) && !/\bcoin\b/i.test(title)) return 'banknotes';38 if (MEDAL.test(title) && !COIN.test(title)) return 'medals';39 if (COIN.test(title)) return 'coins';40 return defaultSlug;41}4243export interface CoinGrade {44 /** taxonomy grader slug (pcgs, ngc, anacs, iccs, pmg) or null when raw / unknown */45 grader: string | null;46 /** normalised grade token as the grader prints it: MS65, MS65+, PR64DCAM, AU58, 66 EPQ (PMG) */47 grade: string | null;48 qualifier: string | null;49 certificationNumber: string | null;50 /** raw condition words for ungraded lots: "good very fine", "EF", "FDC", "aUNC" */51 conditionRaw: string | null;52}5354const GRADERS = /\b(PCGS|NGC|ANACS|ICCS|PMG|CAC)\b/gi;55const 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;56const 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;57const CERT = /(?:\bcert(?:ification)?(?:\s*(?:no|number|#))?\.?\s*[:#]?\s*|(?:^|[\s(])#\s?|\bserial\s*#?\s*)(\d{6,10})\b/i;58const 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;5960/** Parse grader / grade / cert from a coin, medal or banknote title. Never invents a grade. */61export function parseCoinGrade(title: string): CoinGrade {62 const graders = [...title.matchAll(GRADERS)].map((m) => m[1]!.toUpperCase());63 const primary = graders.find((g) => g !== 'CAC') ?? null;64 const grader = primary ? primary.toLowerCase() : null;65 let grade: string | null = null;66 let qualifier: string | null = null;67 const pmg = title.match(PMG_GRADE);68 if (pmg) {69 grade = `${pmg[2]}${pmg[3] ? ` ${pmg[3].toUpperCase()}` : ''}`;70 if (pmg[1]) qualifier = pmg[1];71 } else {72 const g = title.match(GRADE_TOKEN);73 if (g) {74 const n = Number(g[2]);75 if (n >= 1 && n <= 70) {76 const prefix = g[1]!.toUpperCase().replace(/\s+/g, '');77 grade = `${prefix}${g[2]}${g[3] ?? ''}${g[4] ? g[4].toUpperCase().replace('*', 'STAR') : ''}`;78 }79 }80 }81 if (!grade && primary) {82 // adjectival slab grades (NGC Ancients "Choice VF", "Ch XF", "Gem MS"; PMG "Choice Fine")83 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'));84 if (adj) grade = `${adj[1] ? `${adj[1].trim().replace(/^Ch\.?$/i, 'Choice')} ` : ''}${adj[2]!}`.replace(/\s+/g, ' ').trim();85 }86 if (graders.includes('CAC')) qualifier = qualifier ? `${qualifier} · CAC` : 'CAC';87 const cert = grader ? (title.match(CERT)?.[1] ?? null) : null;88 const cond = title.match(CONDITION_WORDS);89 return { grader, grade, qualifier, certificationNumber: cert, conditionRaw: !grade && cond ? cond[1]!.trim() : null };90}9192/** PCGS coin numbers when the source prints them ("PCGS# 7080", "PCGS No. 7080"). */93export function pcgsNumber(title: string): string | null {94 return title.match(/\bPCGS\s*(?:#|No\.?|Number)\s*(\d{3,7})\b/i)?.[1] ?? null;95}9697/** NGC coin ids ("NGC ID: 253J") when printed. */98export function ngcId(title: string): string | null {99 return title.match(/\bNGC\s*ID\s*:?\s*([0-9A-Z]{3,6})\b/i)?.[1] ?? null;100}101102const COUNTRY_MAP: Array<[RegExp, string]> = [103 [/\b(united states|u\.?s\.?a\.?|usa|american|vereinigte staaten|états-unis|etats-unis|stati uniti)\b/i, 'US'],104 [/\b(great britain|british|england|english|united kingdom|uk|scotland|scottish|wales|welsh|großbritannien|grossbritannien|grande-bretagne|gran bretagna)\b/i, 'GB'],105 [/\b(ireland|irish|irland|irlande)\b/i, 'IE'],106 [/\b(canada|canadian|newfoundland|kanada)\b/i, 'CA'],107 [/\b(australia|australian|australien|australie)\b/i, 'AU'],108 [/\b(new zealand)\b/i, 'NZ'],109 [/\b(france|french|gaul|frankreich|francia)\b/i, 'FR'],110 [/\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'],111 [/\b(austria|austrian|habsburg|österreich|oesterreich|autriche|salzburg|tirol|tyrol|steiermark|kärnten)\b/i, 'AT'],112 [/\b(switzerland|swiss|helvetia|schweiz|suisse|svizzera|bern|zürich|zuerich|basel|genf|genève|luzern|solothurn|graubünden)\b/i, 'CH'],113 [/\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'],114 [/\b(spain|spanish|spanien|espagne|españa|castile|kastilien|aragon)\b/i, 'ES'],115 [/\b(portugal|portuguese|portugiesisch)\b/i, 'PT'],116 [/\b(netherlands|dutch|holland|niederlande|pays-bas|utrecht|zeeland|gelderland|overijssel|friesland|nederland)\b/i, 'NL'],117 [/\b(belgium|belgian|belgien|belgique|brabant|flanders|flandern|lüttich|liège|hennegau)\b/i, 'BE'],118 [/\b(sweden|swedish|schweden|suède|svezia)\b/i, 'SE'],119 [/\b(norway|norwegian|norwegen|norvège)\b/i, 'NO'],120 [/\b(denmark|danish|dänemark|daenemark|danemark)\b/i, 'DK'],121 [/\b(russia|russian|russland|russie|soviet|sowjetunion|ussr|udssr)\b/i, 'RU'],122 [/\b(poland|polish|polen|pologne|polska)\b/i, 'PL'],123 [/\b(hungary|hungarian|ungarn|hongrie)\b/i, 'HU'],124 [/\b(czech|bohemia|bohemian|böhmen|boehmen|mähren|czechoslovakia|tschechoslowakei|tschechien)\b/i, 'CZ'],125 [/\b(greece|greek|griechenland|grèce)\b/i, 'GR'],126 [/\b(turkey|turkish|türkei|tuerkei|ottoman|osmanisches reich|osmanen)\b/i, 'TR'],127 [/\b(china|chinese|chine|cina|volksrepublik china|kiangnan|kwangtung|hupeh|szechuan|yunnan)\b/i, 'CN'],128 [/\b(japan|japanese|japon)\b/i, 'JP'],129 [/\b(korea|korean)\b/i, 'KR'],130 [/\b(india|indian|indien|inde|british india|mughal|sultanate)\b/i, 'IN'],131 [/\b(mexico|mexican|mexiko|mexique)\b/i, 'MX'],132 [/\b(brazil|brazilian|brasilien|brésil)\b/i, 'BR'],133 [/\b(argentina|argentine)\b/i, 'AR'],134 [/\b(chile|chilean)\b/i, 'CL'],135 [/\b(peru|peruvian)\b/i, 'PE'],136 [/\b(south africa|south african|südafrika|suedafrika)\b/i, 'ZA'],137 [/\b(egypt|egyptian|ägypten|aegypten)\b/i, 'EG'],138 [/\b(israel|israeli|palestine)\b/i, 'IL'],139 [/\b(hong kong)\b/i, 'HK'],140 [/\b(singapore|straits settlements)\b/i, 'SG'],141 [/\b(malaysia|malaya)\b/i, 'MY'],142 [/\b(thailand|siam)\b/i, 'TH'],143 [/\b(philippines|philippine)\b/i, 'PH'],144 [/\b(indonesia|netherlands east indies|netherlands indies)\b/i, 'ID'],145 [/\b(vietnam|annam|indo-?china)\b/i, 'VN'],146 [/\b(iran|persia|persian|persien)\b/i, 'IR'],147 [/\b(monaco)\b/i, 'MC'],148 [/\b(luxembourg)\b/i, 'LU'],149 [/\b(finland|finnish)\b/i, 'FI'],150 [/\b(iceland)\b/i, 'IS'],151 [/\b(romania|romanian)\b/i, 'RO'],152 [/\b(bulgaria|bulgarian)\b/i, 'BG'],153 [/\b(serbia|yugoslavia)\b/i, 'RS'],154 [/\b(croatia)\b/i, 'HR'],155 [/\b(ukraine)\b/i, 'UA'],156 [/\b(cuba|cuban)\b/i, 'CU'],157 [/\b(colombia)\b/i, 'CO'],158 [/\b(venezuela)\b/i, 'VE'],159 [/\b(bolivia)\b/i, 'BO'],160 [/\b(guatemala)\b/i, 'GT'],161 [/\b(jamaica)\b/i, 'JM'],162 [/\b(bermuda)\b/i, 'BM'],163 [/\b(bahamas)\b/i, 'BS'],164 [/\b(ceylon|sri lanka)\b/i, 'LK'],165 [/\b(pakistan)\b/i, 'PK'],166 [/\b(burma|myanmar)\b/i, 'MM'],167 [/\b(nepal)\b/i, 'NP'],168 [/\b(tibet)\b/i, 'CN'],169 [/\b(mongolia)\b/i, 'MN'],170 [/\b(afghanistan)\b/i, 'AF'],171 [/\b(saudi|hejaz)\b/i, 'SA'],172 [/\b(morocco|moroccan)\b/i, 'MA'],173 [/\b(algeria)\b/i, 'DZ'],174 [/\b(tunisia)\b/i, 'TN'],175 [/\b(nigeria)\b/i, 'NG'],176 [/\b(kenya|east africa)\b/i, 'KE'],177 [/\b(ethiopia)\b/i, 'ET'],178 [/\b(zimbabwe|rhodesia)\b/i, 'ZW'],179 [/\b(angola)\b/i, 'AO'],180 [/\b(mozambique)\b/i, 'MZ'],181 [/\b(congo)\b/i, 'CD'],182 [/\b(malta)\b/i, 'MT'],183 [/\b(cyprus)\b/i, 'CY'],184 [/\b(gibraltar)\b/i, 'GI'],185 [/\b(isle of man)\b/i, 'IM'],186 [/\b(jersey|guernsey)\b/i, 'JE'],187];188189/** ISO-3166 alpha-2 guessed from the country word a numismatic title starts with (null when absent). */190export function countryFromTitle(title: string): string | null {191 const head = title.slice(0, 80);192 for (const [re, iso] of COUNTRY_MAP) if (re.test(head)) return iso;193 // British monarchs / Anglo-Saxon and Norman rulers as printed by UK houses ("Edward VII (1901-1910), Sovereign…")194 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';195 return null;196}197198/**199 * Struck year: 4-digit 1400–now (medieval–modern); ancient coins get null. Years that are part of a200 * reign range ("Oskar II., 1872-1907", "(1901-1910)") are skipped so "1 Krone 1884" → 1884.201 * Mint mark "1893-S" → S.202 */203export function coinYear(title: string): { year: number | null; mintMark: string | null } {204 const now = new Date().getUTCFullYear();205 const re = /(?<![\d-])(1[4-9]\d{2}|20\d{2})(?:[-/]([A-Z]{1,2}|[A-Z]\d)(?![\d])|\s+([A-Z]{1,2})\s+mint)?(?!\s*[-–]\s*\d{4})(?![\d])/g;206 let m: RegExpExecArray | null;207 while ((m = re.exec(title))) {208 const y = Number(m[1]);209 if (y <= now) return { year: y, mintMark: (m[2] ?? m[3])?.toUpperCase() ?? null };210 }211 return { year: null, mintMark: null };212}213214/** Denomination as printed ("Sovereign", "5 Dollars", "Tetradrachm", "Penny", "20 Mark"). */215export function denomination(title: string): string | null {216 const m = title.match(/\b((?:\d+(?:[.,/]\d+)?\s*)?(?:sovereign|half[- ]sovereign|double sovereign|guinea|crown|halfcrown|half[- ]crown|florin|shilling|sixpence|threepence|groat|penny|pence|halfpenny|farthing|dollars?|half dollar|quarter dollar|dime|half dime|cents?|nickel|eagle|double eagle|half eagle|quarter eagle|mark|reichsmark|pfennig|thaler|taler|ducat|gulden|kreuzer|francs?|centimes?|lire|lira|centesimi|pesetas?|reales?|escudos?|pesos?|centavos?|rupees?|annas?|pice|paisa|yen|sen|yuan|won|cash|tael|drachm[ae]?|tetradrachm|didrachm|hemidrachm|obol|stater|hemistater|denarius|quinarius|sestertius|dupondius|as|antoninianus|aureus|solidus|semissis|tremissis|follis|nummus|siliqua|miliarense|tetras|litra|hexas|onkia|dinar|dirham|fals|rouble|ruble|kopeks?|zloty|groschen|kronor?|krone|kroner|øre|ore|markka|penni|forint|leu|lei|lev|leva|dinara|kuna|koruna|haléř|escudo|réis|reis|cruzeiro|sucre|boliviano|colon|colones|quetzal|balboa|gourde|piastres?|qirsh|pound|pounds|livre|écu|ecu|louis d'or|pistole|doubloon|8 reales|noble|angel|ryal|unite|laurel|testoon|shilling)s?)\b/i);217 return m ? m[1]!.replace(/\s+/g, ' ').trim() : null;218}219220/** Lots of several pieces: "(6)", "lot of 12", "group of", "collection", "Large Lots". */221export function isNumisBundle(title: string, section?: string | null): boolean {222 if (section && /\b(large lots?|group lots?|bulk|collections?|accumulations?|mixed lots?)\b/i.test(section)) return true;223 return /\b(lot of \d+|group of|collection of|accumulation|assortment|assorted|mixed lot|various|\(\d{2,}\)|\[\d{2,}\]|\b\d{2,}\s*(?:coins|pieces|notes|stamps|items|pcs)\b|set of \d{2,})\b/i.test(title) || /\((?:[2-9]|\d{2,})\)/.test(title) || /\[(?:[2-9]|\d{2,})\]/.test(title);224}225226const MONTHS: Record<string, number> = { 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 };227function mon(s: string): number | null {228 const k = s.toLowerCase().slice(0, 4) === 'sept' ? 'sept' : s.toLowerCase().slice(0, 3);229 return MONTHS[k] ?? null;230}231232/**233 * Auction dates as printed by the houses → UTC midnight of the FIRST day.234 * "2 Sep 2026" · "8,11 Oct 2021" · "30 June 2026" · "9 & 10 June 2026" · "23-24 June 2026" · "7-11 Sep 2026" ·235 * "September 30, 2026" · "30.06.2026" · "2026-06-30" · "Sep 30 - Oct 2, 2026".236 */237export function parseAuctionDate(text: string | null | undefined): Date | null {238 if (!text) return null;239 const s = text.replace(/\s+/g, ' ').trim();240 let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);241 if (m) return utc(Number(m[1]), Number(m[2]) - 1, Number(m[3]));242 m = s.match(/\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/);243 if (m) return utc(Number(m[3]), Number(m[2]) - 1, Number(m[1]));244 // day-first: "2 Sep 2026", "8,11 Oct 2021", "9 & 10 June 2026", "23-24 June 2026", "30 Sep - 2 Oct 2026"245 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/);246 if (m) {247 const monthWord = m[2] ?? m[3];248 const mo = monthWord ? mon(monthWord) : null;249 if (mo !== null) return utc(Number(m[4]), mo, Number(m[1]));250 }251 // month-first: "September 30, 2026", "Sep 30 - Oct 2, 2026", "June 9-10, 2026"252 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/);253 if (m) {254 const mo = mon(m[1]!);255 if (mo !== null) return utc(Number(m[3]), mo, Number(m[2]));256 }257 return null;258}259260function utc(y: number, mo: number, d: number): Date | null {261 if (mo < 0 || mo > 11 || d < 1 || d > 31) return null;262 const date = new Date(Date.UTC(y, mo, d));263 return Number.isNaN(date.getTime()) ? null : date;264}265266/** "600 USD" | "£3,800" | "1.250 EUR" | "CHF 1'200" → amount + ISO currency (null when unsold / missing). */267export function realized(text: string | null | undefined, fallback?: CurrencyCode): { amount: number; currency: CurrencyCode } | null {268 if (!text) return null;269 if (/^\s*(--|-|unsold|w\/d|withdrawn|n\/a|passed|not sold)\s*$/i.test(text)) return null;270 const p = parsePrice(text, fallback);271 if (!p || !p.currency || p.amount <= 0) return null;272 return { amount: p.amount, currency: p.currency };273}274275export interface NumisAttrInput {276 categorySlug: NumisSlug | string;277 title: string;278 /** catalogue section / department the source printed */279 section?: string | null;280 country?: string | null;281 identifiers?: Record<string, string>;282 metadata?: Record<string, unknown>;283 brand?: string | null;284 series?: string | null;285 /** extra text (e.g. a weight/material property) consulted for the metal when the title has none */286 materialHint?: string | null;287 /** source-structured year (overrides the title heuristic when present) */288 year?: number | null;289}290291/** Build canonical attributes for a numismatic lot; nothing is guessed beyond what the title states. */292export function numisAttributes(i: NumisAttrInput): AssetAttributes {293 const parsed = i.categorySlug === 'stamps' ? { year: coinYear(i.title).year, mintMark: null } : coinYear(i.title);294 const year = i.year ?? parsed.year;295 const mintMark = parsed.mintMark;296 const denom = i.categorySlug === 'coins' ? denomination(i.title) : null;297 const ids = { ...(i.identifiers ?? {}) };298 const pn = pcgsNumber(i.title);299 if (pn) ids.pcgs_number = pn;300 const nid = ngcId(i.title);301 if (nid) ids.ngc_id = nid;302 return {303 categorySlug: i.categorySlug,304 subcategorySlug: null,305 franchise: null,306 brand: i.brand ?? null,307 series: i.series ?? null,308 set: i.section ?? null,309 setCode: null,310 name: i.title,311 model: null,312 reference: null,313 number: null,314 year,315 edition: null,316 variant: mintMark ? `${mintMark} mint` : null,317 language: null,318 region: null,319 country: i.country ?? countryFromTitle(i.title),320 material: metal(i.title) ?? (i.materialHint ? metal(i.materialHint) : null),321 size: null,322 color: null,323 rarity: null,324 productionQuantity: null,325 originalMsrp: null,326 originalMsrpCurrency: null,327 identifiers: ids,328 metadata: { ...(i.metadata ?? {}), denomination: denom, mint_mark: mintMark },329 };330}331332/** Metal from numismatic abbreviations / words (AV/AR/Æ/AE, gold/silver/bronze/copper/billon/electrum/platinum, DE/FR/IT/ES words). */333export function metal(title: string): string | null {334 const t = title;335 if (/\bAV\b|\bgold(?:en)?\b|feingold|finegold|\bd'or\b|\boro\b(?!\w)|\bgoud\b/i.test(t)) return 'gold';336 if (/\bAR\b|\bsilver\b|\bargent\b|\bplata\b|\bsilber\b|\bargento\b|\bzilver\b/i.test(t)) return 'silver';337 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';338 if (/\bBI\b|\bbillon\b/i.test(t)) return 'billon';339 if (/\bEL\b|\belectrum\b|\belektron\b/i.test(t)) return 'electrum';340 if (/\bplatinum\b|\bplatine?\b|\bplatin\b/i.test(t)) return 'platinum';341 if (/\bnickel\b|cupro-?nickel|\bkupfernickel\b/i.test(t)) return 'nickel';342 return null;343}344345/** Firecrawl / cheerio text cleanup: entities, tags, whitespace. */346export function clean(s: string | null | undefined): string {347 return (s ?? '')348 .replace(/&/g, '&')349 .replace(/�?39;|'|’|’/g, "'")350 .replace(/"|“|”/g, '"')351 .replace(/ | /g, ' ')352 .replace(/£/g, '£')353 .replace(/€/g, '€')354 .replace(/<[^>]+>/g, ' ')355 .replace(/\s+/g, ' ')356 .trim();357}358