TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { CATEGORIES, GRADERS, getCategory, parseGradeFromTitle } from '@rareindex/taxonomy';23/**4 * Natural-language query parsing (§127). Extracts structured filters from free text such as5 * "1999 Charizard PSA 10", "Rolex Daytona under $20,000", "sealed Mario 64", "first edition Harry Potter UK".6 * Anything not recognised stays in `text` for lexical/semantic matching. Nothing is guessed:7 * a filter is only set when the token pattern is unambiguous.8 */9export interface ParsedQuery {10 raw: string;11 /** residual free text used for FTS / trigram */12 text: string;13 grader: string | null;14 grade: string | null;15 year: number | null;16 priceMin: number | null;17 priceMax: number | null;18 currency: string | null;19 /** taxonomy slugs whose names appear in the query (family or subcategory) */20 categorySlugs: string[];21 /** normalised condition/completeness hints: sealed, cib, loose, raw, deadstock… */22 conditions: string[];23 /** edition hints kept for ranking: 1st edition, shadowless, unlimited, first printing… */24 editions: string[];25}2627const CATEGORY_ALIASES: Array<[RegExp, string]> = [28 [/\bpok[eé]mon\b/i, 'pokemon'],29 [/\b(mtg|magic(?:\s*:\s*| )the gathering|magic cards?)\b/i, 'magic_the_gathering'],30 [/\b(yu-?gi-?oh!?|ygo)\b/i, 'yugioh'],31 [/\bone piece (?:card|tcg)/i, 'one_piece_card_game'],32 [/\blorcana\b/i, 'disney_lorcana'],33 [/\b(sports? cards?)\b/i, 'sports_cards'],34 [/\b(baseball cards?|topps|bowman)\b/i, 'baseball_cards'],35 [/\b(basketball cards?|prizm|nba cards?)\b/i, 'basketball_cards'],36 [/\b(hockey cards?|nhl cards?|o-pee-chee)\b/i, 'hockey_cards'],37 [/\b(football cards?|nfl cards?)\b/i, 'football_cards'],38 [/\b(soccer cards?)\b/i, 'soccer_cards'],39 [/\bcomics?\b/i, 'comics'],40 [/\bmanga\b/i, 'manga'],41 [/\b(video ?games?|nes|snes|n64|nintendo(?: 64)?|game ?boy|gamecube|famicom|switch game|mario|zelda|metroid|kirby|earthbound)\b/i, 'nintendo_games'],42 [/\b(sega|genesis|dreamcast|saturn|mega drive)\b/i, 'sega_games'],43 [/\b(playstation|ps[1-5]|psp|vita)\b/i, 'playstation_games'],44 [/\bxbox\b/i, 'xbox_games'],45 [/\b(sneakers?|jordan|air jordan|nike|dunk|yeezy|new balance)\b/i, 'sneakers'],46 [/\b(watch(?:es)?|rolex|patek|audemars|omega|tudor|cartier|daytona|submariner|nautilus|royal oak|speedmaster)\b/i, 'watches'],47 [/\blego\b/i, 'lego'],48 [/\bminifig(?:ure)?s?\b/i, 'lego_minifigures'],49 [/\bfunko|pop! ?vinyl\b/i, 'funko'],50 [/\b(bearbrick|be@rbrick|kaws|labubu|pop mart|designer toys?)\b/i, 'designer_toys'],51 [/\b(coins?|morgan dollar|double eagle|pcgs|ngc)\b/i, 'coins'],52 [/\b(handbags?|birkin|kelly bag|chanel flap|hermès|hermes)\b/i, 'luxury_handbags'],53 [/\b(wine|bordeaux|burgundy|champagne)\b/i, 'wine'],54 [/\b(whisky|whiskey|scotch|bourbon|macallan|yamazaki)\b/i, 'whisky'],55 [/\b(vinyl|records?|lp|pressing)\b/i, 'music'],56 [/\b(first edition|1st edition) (?:book|hardcover|harry potter|tolkien)/i, 'books'],57 [/\bharry potter\b/i, 'harry_potter'],58 [/\b(art|print|lithograph|screenprint|banksy|murakami|basquiat|haring)\b/i, 'art'],59 [/\b(camera|leica|hasselblad|rolleiflex)\b/i, 'cameras'],60 [/\b(apple[- ]1|macintosh|iphone|ipod|apple collectibles?)\b/i, 'apple_collectibles'],61 [/\bstamps?\b/i, 'stamps'],62 [/\bbanknotes?\b/i, 'banknotes'],63 [/\bfossils?|trilobite|ammonite|dinosaur\b/i, 'fossils'],64 [/\bmeteorites?\b/i, 'meteorites'],65 [/\bmilitaria|medals?\b/i, 'militaria'],66 [/\bpinball|arcade cabinet\b/i, 'arcade_pinball'],67 [/\bhot wheels|matchbox|diecast|model cars?\b/i, 'model_cars'],68 [/\b(action figures?|hot toys|figuarts|nendoroid)\b/i, 'action_figures'],69 [/\b(vintage toys?|kenner|he-man|transformers|g\.?i\.? joe)\b/i, 'vintage_toys'],70];7172const CONDITION_TERMS: Array<[RegExp, string]> = [73 [/\bsealed\b|\bfactory sealed\b|\bnew in box\b|\bmisb\b|\bnisb\b/i, 'sealed'],74 [/\bcib\b|\bcomplete in box\b/i, 'cib'],75 [/\bloose\b|\bcart only\b/i, 'loose'],76 [/\braw\b|\bungraded\b/i, 'raw'],77 [/\bdeadstock\b|\bds\b/i, 'deadstock'],78 [/\bunworn\b/i, 'unworn'],79 [/\bfull set\b|\bbox and papers\b|\bbox & papers\b/i, 'full_set'],80 [/\bnear mint\b|\bnm\b/i, 'near_mint'],81];8283const EDITION_TERMS: Array<[RegExp, string]> = [84 [/\b(1st|first) edition\b/i, '1st edition'],85 [/\bshadowless\b/i, 'shadowless'],86 [/\bunlimited\b/i, 'unlimited'],87 [/\b(1st|first) printing\b/i, 'first printing'],88 [/\bholo(?:graphic)?\b/i, 'holo'],89 [/\breverse holo\b/i, 'reverse holo'],90 [/\balt(?:ernate)? art\b/i, 'alternate art'],91 [/\brookie\b|\brc\b/i, 'rookie'],92 [/\bauto(?:graph)?\b/i, 'autograph'],93 [/\bprototype\b/i, 'prototype'],94 [/\bretired\b/i, 'retired'],95 [/\bjapanese\b|\bjpn\b/i, 'japanese'],96 [/\buk\b|\bbloomsbury\b/i, 'uk'],97];9899const MONEY = String.raw`\$?\s*(\d[\d,]*(?:\.\d+)?)\s*([kKmM])?\s*(usd|cad|eur|gbp|jpy|\$|€|£)?`;100101function toNumber(num: string, suffix?: string): number {102 let n = Number(num.replace(/,/g, ''));103 if (suffix?.toLowerCase() === 'k') n *= 1_000;104 if (suffix?.toLowerCase() === 'm') n *= 1_000_000;105 return n;106}107108function currencyOf(sym?: string): string | null {109 if (!sym) return null;110 const s = sym.toLowerCase();111 if (s === '$' || s === 'usd') return 'USD';112 if (s === '€' || s === 'eur') return 'EUR';113 if (s === '£' || s === 'gbp') return 'GBP';114 return s.toUpperCase();115}116117export function parseQuery(raw: string): ParsedQuery {118 let text = raw.replace(/\s+/g, ' ').trim();119 const out: ParsedQuery = { raw, text, grader: null, grade: null, year: null, priceMin: null, priceMax: null, currency: null, categorySlugs: [], conditions: [], editions: [] };120 if (!text) return out;121122 // Price bounds: "under $5,000", "below 2k", "over $100", "between $500 and $1,000", "$100-$500"123 const between = text.match(new RegExp(String.raw`\b(?:between|from)\s+${MONEY}\s+(?:and|to|-)\s+${MONEY}`, 'i'));124 if (between) {125 out.priceMin = toNumber(between[1]!, between[2]);126 out.priceMax = toNumber(between[4]!, between[5]);127 out.currency = currencyOf(between[3] ?? between[6]) ?? (/\$/.test(between[0]) ? 'USD' : null);128 text = text.replace(between[0], ' ');129 } else {130 const range = text.match(new RegExp(String.raw`${MONEY}\s*(?:-|–|to)\s*${MONEY}`, 'i'));131 if (range && /\$|usd|eur|gbp|£|€|k\b/i.test(range[0])) {132 out.priceMin = toNumber(range[1]!, range[2]);133 out.priceMax = toNumber(range[4]!, range[5]);134 out.currency = currencyOf(range[3] ?? range[6]) ?? (/\$/.test(range[0]) ? 'USD' : null);135 text = text.replace(range[0], ' ');136 } else {137 const under = text.match(new RegExp(String.raw`\b(under|below|less than|up to|max(?:imum)?|<)\s+${MONEY}`, 'i'));138 if (under) {139 out.priceMax = toNumber(under[2]!, under[3]);140 out.currency = currencyOf(under[4]) ?? (/\$/.test(under[0]) ? 'USD' : null);141 text = text.replace(under[0], ' ');142 }143 const over = text.match(new RegExp(String.raw`\b(over|above|more than|at least|min(?:imum)?|>)\s+${MONEY}`, 'i'));144 if (over) {145 out.priceMin = toNumber(over[2]!, over[3]);146 out.currency = out.currency ?? currencyOf(over[4]) ?? (/\$/.test(over[0]) ? 'USD' : null);147 text = text.replace(over[0], ' ');148 }149 }150 }151152 // Grade153 const g = parseGradeFromTitle(text);154 if (g.grader && (g.grade || g.grader === 'raw')) {155 out.grader = g.grader;156 out.grade = g.grade;157 const graderNames = GRADERS.flatMap((x) => [x.slug, ...x.aliases]).filter((a) => a.length >= 3).join('|');158 text = text.replace(new RegExp(String.raw`\b(${graderNames})\b[\s:-]*(?:gem\s*mt|gem\s*mint|mint|black\s*label|pristine)?[\s:-]*${g.grade ? g.grade.replace('.', '\\.') : ''}`, 'i'), ' ');159 }160161 // Year (kept in text too — it is a strong lexical signal)162 const y = text.match(/\b(18\d{2}|19\d{2}|20[0-4]\d)\b/);163 if (y) out.year = Number(y[1]);164165 for (const [re, slug] of CONDITION_TERMS) if (re.test(text)) out.conditions.push(slug);166 for (const [re, ed] of EDITION_TERMS) if (re.test(text)) out.editions.push(ed);167168 const cats = new Set<string>();169 for (const [re, slug] of CATEGORY_ALIASES) if (re.test(text)) cats.add(slug);170 // exact taxonomy names ("Yu-Gi-Oh!", "Designer Toys")171 for (const c of CATEGORIES) {172 if (c.name.length >= 4 && new RegExp(`\\b${c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text)) cats.add(c.slug);173 }174 // keep the most specific nodes only (drop ancestors of other matches)175 out.categorySlugs = [...cats].filter((slug) => ![...cats].some((other) => other !== slug && getCategory(other)?.familySlug === slug && getCategory(other)?.level! > 0));176177 out.text = text.replace(/\s+/g, ' ').trim();178 return out;179}180181/** Build a websearch-style tsquery string: every token prefix-matched, safe for to_tsquery. */182export function toTsQuery(text: string): string | null {183 const toks = text184 .toLowerCase()185 .replace(/['’]/g, '')186 .replace(/[^a-z0-9#/.\s-]+/g, ' ')187 .split(/\s+/)188 .map((t) => t.replace(/^[-#/.]+|[-#/.]+$/g, ''))189 .filter((t) => t.length > 0);190 if (!toks.length) return null;191 return toks.map((t) => `${t.replace(/[':&|!()<>]/g, '')}:*`).filter((t) => t !== ':*').join(' & ') || null;192}193