import { CATEGORIES, GRADERS, getCategory, parseGradeFromTitle } from '@rareindex/taxonomy'; /** * Natural-language query parsing (§127). Extracts structured filters from free text such as * "1999 Charizard PSA 10", "Rolex Daytona under $20,000", "sealed Mario 64", "first edition Harry Potter UK". * Anything not recognised stays in `text` for lexical/semantic matching. Nothing is guessed: * a filter is only set when the token pattern is unambiguous. */ export interface ParsedQuery { raw: string; /** residual free text used for FTS / trigram */ text: string; grader: string | null; grade: string | null; year: number | null; priceMin: number | null; priceMax: number | null; currency: string | null; /** taxonomy slugs whose names appear in the query (family or subcategory) */ categorySlugs: string[]; /** normalised condition/completeness hints: sealed, cib, loose, raw, deadstock… */ conditions: string[]; /** edition hints kept for ranking: 1st edition, shadowless, unlimited, first printing… */ editions: string[]; } const CATEGORY_ALIASES: Array<[RegExp, string]> = [ [/\bpok[eé]mon\b/i, 'pokemon'], [/\b(mtg|magic(?:\s*:\s*| )the gathering|magic cards?)\b/i, 'magic_the_gathering'], [/\b(yu-?gi-?oh!?|ygo)\b/i, 'yugioh'], [/\bone piece (?:card|tcg)/i, 'one_piece_card_game'], [/\blorcana\b/i, 'disney_lorcana'], [/\b(sports? cards?)\b/i, 'sports_cards'], [/\b(baseball cards?|topps|bowman)\b/i, 'baseball_cards'], [/\b(basketball cards?|prizm|nba cards?)\b/i, 'basketball_cards'], [/\b(hockey cards?|nhl cards?|o-pee-chee)\b/i, 'hockey_cards'], [/\b(football cards?|nfl cards?)\b/i, 'football_cards'], [/\b(soccer cards?)\b/i, 'soccer_cards'], [/\bcomics?\b/i, 'comics'], [/\bmanga\b/i, 'manga'], [/\b(video ?games?|nes|snes|n64|nintendo(?: 64)?|game ?boy|gamecube|famicom|switch game|mario|zelda|metroid|kirby|earthbound)\b/i, 'nintendo_games'], [/\b(sega|genesis|dreamcast|saturn|mega drive)\b/i, 'sega_games'], [/\b(playstation|ps[1-5]|psp|vita)\b/i, 'playstation_games'], [/\bxbox\b/i, 'xbox_games'], [/\b(sneakers?|jordan|air jordan|nike|dunk|yeezy|new balance)\b/i, 'sneakers'], [/\b(watch(?:es)?|rolex|patek|audemars|omega|tudor|cartier|daytona|submariner|nautilus|royal oak|speedmaster)\b/i, 'watches'], [/\blego\b/i, 'lego'], [/\bminifig(?:ure)?s?\b/i, 'lego_minifigures'], [/\bfunko|pop! ?vinyl\b/i, 'funko'], [/\b(bearbrick|be@rbrick|kaws|labubu|pop mart|designer toys?)\b/i, 'designer_toys'], [/\b(coins?|morgan dollar|double eagle|pcgs|ngc)\b/i, 'coins'], [/\b(handbags?|birkin|kelly bag|chanel flap|hermès|hermes)\b/i, 'luxury_handbags'], [/\b(wine|bordeaux|burgundy|champagne)\b/i, 'wine'], [/\b(whisky|whiskey|scotch|bourbon|macallan|yamazaki)\b/i, 'whisky'], [/\b(vinyl|records?|lp|pressing)\b/i, 'music'], [/\b(first edition|1st edition) (?:book|hardcover|harry potter|tolkien)/i, 'books'], [/\bharry potter\b/i, 'harry_potter'], [/\b(art|print|lithograph|screenprint|banksy|murakami|basquiat|haring)\b/i, 'art'], [/\b(camera|leica|hasselblad|rolleiflex)\b/i, 'cameras'], [/\b(apple[- ]1|macintosh|iphone|ipod|apple collectibles?)\b/i, 'apple_collectibles'], [/\bstamps?\b/i, 'stamps'], [/\bbanknotes?\b/i, 'banknotes'], [/\bfossils?|trilobite|ammonite|dinosaur\b/i, 'fossils'], [/\bmeteorites?\b/i, 'meteorites'], [/\bmilitaria|medals?\b/i, 'militaria'], [/\bpinball|arcade cabinet\b/i, 'arcade_pinball'], [/\bhot wheels|matchbox|diecast|model cars?\b/i, 'model_cars'], [/\b(action figures?|hot toys|figuarts|nendoroid)\b/i, 'action_figures'], [/\b(vintage toys?|kenner|he-man|transformers|g\.?i\.? joe)\b/i, 'vintage_toys'], ]; const CONDITION_TERMS: Array<[RegExp, string]> = [ [/\bsealed\b|\bfactory sealed\b|\bnew in box\b|\bmisb\b|\bnisb\b/i, 'sealed'], [/\bcib\b|\bcomplete in box\b/i, 'cib'], [/\bloose\b|\bcart only\b/i, 'loose'], [/\braw\b|\bungraded\b/i, 'raw'], [/\bdeadstock\b|\bds\b/i, 'deadstock'], [/\bunworn\b/i, 'unworn'], [/\bfull set\b|\bbox and papers\b|\bbox & papers\b/i, 'full_set'], [/\bnear mint\b|\bnm\b/i, 'near_mint'], ]; const EDITION_TERMS: Array<[RegExp, string]> = [ [/\b(1st|first) edition\b/i, '1st edition'], [/\bshadowless\b/i, 'shadowless'], [/\bunlimited\b/i, 'unlimited'], [/\b(1st|first) printing\b/i, 'first printing'], [/\bholo(?:graphic)?\b/i, 'holo'], [/\breverse holo\b/i, 'reverse holo'], [/\balt(?:ernate)? art\b/i, 'alternate art'], [/\brookie\b|\brc\b/i, 'rookie'], [/\bauto(?:graph)?\b/i, 'autograph'], [/\bprototype\b/i, 'prototype'], [/\bretired\b/i, 'retired'], [/\bjapanese\b|\bjpn\b/i, 'japanese'], [/\buk\b|\bbloomsbury\b/i, 'uk'], ]; const MONEY = String.raw`\$?\s*(\d[\d,]*(?:\.\d+)?)\s*([kKmM])?\s*(usd|cad|eur|gbp|jpy|\$|€|£)?`; function toNumber(num: string, suffix?: string): number { let n = Number(num.replace(/,/g, '')); if (suffix?.toLowerCase() === 'k') n *= 1_000; if (suffix?.toLowerCase() === 'm') n *= 1_000_000; return n; } function currencyOf(sym?: string): string | null { if (!sym) return null; const s = sym.toLowerCase(); if (s === '$' || s === 'usd') return 'USD'; if (s === '€' || s === 'eur') return 'EUR'; if (s === '£' || s === 'gbp') return 'GBP'; return s.toUpperCase(); } export function parseQuery(raw: string): ParsedQuery { let text = raw.replace(/\s+/g, ' ').trim(); const out: ParsedQuery = { raw, text, grader: null, grade: null, year: null, priceMin: null, priceMax: null, currency: null, categorySlugs: [], conditions: [], editions: [] }; if (!text) return out; // Price bounds: "under $5,000", "below 2k", "over $100", "between $500 and $1,000", "$100-$500" const between = text.match(new RegExp(String.raw`\b(?:between|from)\s+${MONEY}\s+(?:and|to|-)\s+${MONEY}`, 'i')); if (between) { out.priceMin = toNumber(between[1]!, between[2]); out.priceMax = toNumber(between[4]!, between[5]); out.currency = currencyOf(between[3] ?? between[6]) ?? (/\$/.test(between[0]) ? 'USD' : null); text = text.replace(between[0], ' '); } else { const range = text.match(new RegExp(String.raw`${MONEY}\s*(?:-|–|to)\s*${MONEY}`, 'i')); if (range && /\$|usd|eur|gbp|£|€|k\b/i.test(range[0])) { out.priceMin = toNumber(range[1]!, range[2]); out.priceMax = toNumber(range[4]!, range[5]); out.currency = currencyOf(range[3] ?? range[6]) ?? (/\$/.test(range[0]) ? 'USD' : null); text = text.replace(range[0], ' '); } else { const under = text.match(new RegExp(String.raw`\b(under|below|less than|up to|max(?:imum)?|<)\s+${MONEY}`, 'i')); if (under) { out.priceMax = toNumber(under[2]!, under[3]); out.currency = currencyOf(under[4]) ?? (/\$/.test(under[0]) ? 'USD' : null); text = text.replace(under[0], ' '); } const over = text.match(new RegExp(String.raw`\b(over|above|more than|at least|min(?:imum)?|>)\s+${MONEY}`, 'i')); if (over) { out.priceMin = toNumber(over[2]!, over[3]); out.currency = out.currency ?? currencyOf(over[4]) ?? (/\$/.test(over[0]) ? 'USD' : null); text = text.replace(over[0], ' '); } } } // Grade const g = parseGradeFromTitle(text); if (g.grader && (g.grade || g.grader === 'raw')) { out.grader = g.grader; out.grade = g.grade; const graderNames = GRADERS.flatMap((x) => [x.slug, ...x.aliases]).filter((a) => a.length >= 3).join('|'); 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'), ' '); } // Year (kept in text too — it is a strong lexical signal) const y = text.match(/\b(18\d{2}|19\d{2}|20[0-4]\d)\b/); if (y) out.year = Number(y[1]); for (const [re, slug] of CONDITION_TERMS) if (re.test(text)) out.conditions.push(slug); for (const [re, ed] of EDITION_TERMS) if (re.test(text)) out.editions.push(ed); const cats = new Set(); for (const [re, slug] of CATEGORY_ALIASES) if (re.test(text)) cats.add(slug); // exact taxonomy names ("Yu-Gi-Oh!", "Designer Toys") for (const c of CATEGORIES) { if (c.name.length >= 4 && new RegExp(`\\b${c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text)) cats.add(c.slug); } // keep the most specific nodes only (drop ancestors of other matches) out.categorySlugs = [...cats].filter((slug) => ![...cats].some((other) => other !== slug && getCategory(other)?.familySlug === slug && getCategory(other)?.level! > 0)); out.text = text.replace(/\s+/g, ' ').trim(); return out; } /** Build a websearch-style tsquery string: every token prefix-matched, safe for to_tsquery. */ export function toTsQuery(text: string): string | null { const toks = text .toLowerCase() .replace(/['’]/g, '') .replace(/[^a-z0-9#/.\s-]+/g, ' ') .split(/\s+/) .map((t) => t.replace(/^[-#/.]+|[-#/.]+$/g, '')) .filter((t) => t.length > 0); if (!toks.length) return null; return toks.map((t) => `${t.replace(/[':&|!()<>]/g, '')}:*`).filter((t) => t !== ':*').join(' & ') || null; }