TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { z } from 'zod';2import { parseGradeFromTitle } from '@rareindex/taxonomy';3import { extractYear } from '@rareindex/shared';4import { WATCH_BRANDS, caseSize, watchCategory, watchCompleteness, watchConditionRaw, watchMaterial, watchReferenceFromText } from '../_luxury-lib/index.js';56/**7 * Helpers shared by the g9 group (Japanese / Taiwanese / Korean marketplaces, watch dealers and auction8 * platforms, sneaker shops). Multilingual (ja / zh / ko) title parsing, JST/KST dates, CJK bundle and9 * condition hints, watch title decomposition. Kept inside connectors/api, not the framework.10 */1112// ---------- seeds ----------13export const KeywordSeedSchema = z.object({14 /** search keyword in the source's language */15 q: z.string(),16 /** taxonomy slug applied to every row of the seed (refined by title when it is a family slug) */17 category: z.string(),18 /** card / product language when the seed implies one ("Japanese", "Chinese", "Korean", "English") */19 language: z.string().nullable().default(null),20});21export type KeywordSeed = z.infer<typeof KeywordSeedSchema>;2223// ---------- dates ----------24/** "2026-09-09 18:32:00" (JST wall clock) | "2026-09-08T17:07:22+09:00" | ISO with Z → UTC Date; null when unparseable. */25export function parseJstDateTime(s: string | null | undefined): Date | null {26 if (!s) return null;27 const t = s.trim();28 if (/[+-]\d{2}:\d{2}$|Z$/.test(t)) {29 const d = new Date(t);30 return Number.isNaN(d.getTime()) ? null : d;31 }32 const m = t.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/);33 if (!m) return null;34 const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0) - 9, Number(m[5] ?? 0), Number(m[6] ?? 0)));35 return Number.isNaN(d.getTime()) ? null : d;36}3738/** Unix seconds (number or numeric string) → Date; null when missing/invalid. */39export function unixToDate(v: number | string | null | undefined): Date | null {40 if (v === null || v === undefined || v === '') return null;41 const n = typeof v === 'number' ? v : Number(v);42 if (!Number.isFinite(n) || n <= 0) return null;43 const d = new Date(n * 1000);44 return Number.isNaN(d.getTime()) ? null : d;45}4647/** "2026-07-16" → UTC midnight. */48export function isoDateOnly(s: string | null | undefined): Date | null {49 const m = (s ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/);50 return m ? new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))) : null;51}5253// ---------- CJK text ----------54/** Full-width → half-width, "PSA10" / "PSA10" / "BGS9.5" → "PSA 10" so the shared grade parser reads them; brackets → spaces. */55export function normaliseCjkGradeText(title: string): string {56 return title57 .normalize('NFKC')58 .replace(/\b(PSA|BGS|CGC|SGC|ARS|ACE|TAG)\s*(\d{1,2}(?:\.\d)?)/gi, '$1 $2')59 .replace(/【|】|\[|\]|「|」|『|』|(|)/g, ' ')60 .replace(/\s+/g, ' ')61 .trim();62}6364export function cjkGrade(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {65 const g = parseGradeFromTitle(normaliseCjkGradeText(title));66 return { grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grade, qualifier: g.qualifier };67}6869/** Lots of many items (ja / zh / ko). Deliberately narrow: "セット" alone also names sealed box sets. */70export const BUNDLE_RE_CJK = /まとめ|セット売り|大量|\d+\s*枚セット|\d+\s*点セット|おまとめ|引退品|バラ売り不可|合售|整套出售|一起賣|\d+\s*張(?:一組|合售)|\d+\s*件組|묶음|일괄|\d+\s*장\s*세트|\bbundle\b|\blot of\b/i;71export function isCjkBundle(title: string): boolean {72 return BUNDLE_RE_CJK.test(title.normalize('NFKC'));73}7475/** Coarse condition hint from a CJK title: "New" | "Used" | null. */76export function cjkConditionRaw(title: string): string | null {77 const t = title.normalize('NFKC');78 if (/未開封|新品未使用|新品|未使用|全新|未拆|새상품|미개봉|新品同様|\bBNIB\b|\bDS\b|\bdeadstock\b|\bunworn\b/i.test(t)) return 'New';79 if (/中古|二手|중고|used|pre-?owned/i.test(t)) return 'Used';80 return null;81}8283/** Card / product language implied by a CJK title. */84export function cjkLanguage(title: string): string | null {85 const t = title.normalize('NFKC');86 if (/英語版|英文版|영문|북미판|\bEN\b|\bENG\b|english/i.test(t)) return 'English';87 if (/日本語版|日版|日文版|일판|일본판|\bJP\b|\bJPN\b|japanese/i.test(t)) return 'Japanese';88 if (/中文版|简中|繁中|簡中|中文/.test(t)) return 'Chinese';89 if (/한글판|한판|\bKR\b|korean/i.test(t)) return 'Korean';90 return null;91}9293/** Family slugs whose rows are refined by title keywords. */94const FAMILY_SLUGS = new Set(['trading_cards', 'watches', 'sneakers', 'video_games', 'designer_toys', 'action_figures', 'lego']);9596const CATEGORY_KEYWORDS: Array<[RegExp, string]> = [97 [/ポケモンカード|ポケカ|寶可夢|宝可梦|포켓몬\s*카드|pok[eé]mon/i, 'pokemon'],98 [/遊戯王|遊戲王|游戏王|유희왕|yu-?gi-?oh/i, 'yugioh'],99 [/ワンピースカード|ワンピカ|ONE PIECE\s*カード|海賊王卡|航海王卡|원피스\s*카드|one piece card/i, 'one_piece_card_game'],100 [/マジック[::]?ザ[・・]?ギャザリング|\bMTG\b|魔法風雲會|magic:? the gathering/i, 'magic_the_gathering'],101 [/デジモンカード|digimon card/i, 'digimon_tcg'],102 [/ヴァイスシュヴァルツ|weiss schwarz/i, 'weiss_schwarz'],103 [/ロレックス|勞力士|劳力士|롤렉스|\brolex\b/i, 'rolex'],104 [/パテック|百達翡麗|百达翡丽|파텍|patek/i, 'patek_philippe'],105 [/オーデマ|愛彼|爱彼|오데마|audemars/i, 'audemars_piguet'],106 [/オメガ|歐米茄|欧米茄|오메가|\bomega\b/i, 'omega'],107 [/腕時計|手錶|手表|시계|セイコー|\bseiko\b|g-?shock|カルティエ|cartier|tudor|チューダー|breitling|ブライトリング|IWC|panerai|hublot|zenith|longines|tissot|casio/i, 'other_watches'],108 [/ジョーダン|\bjordan\b|ナイキ|\bnike\b|나이키|조던|耐吉|\bdunk\b|air max|air force/i, 'nike_jordan'],109 [/アディダス|adidas|yeezy|イージー|이지|아디다스|愛迪達/i, 'adidas_yeezy'],110 [/new balance|ニューバランス|뉴발란스|asics|アシックス|salomon|puma|converse|vans|hoka/i, 'new_balance_asics_other'],111 [/ガンプラ|ガンダム|鋼彈|钢弹|건담|gundam/i, 'gundam'],112 [/BE@RBRICK|ベアブリック|\bKAWS\b|be@rbrick|bearbrick|designer toy|sofubi|ソフビ/i, 'designer_toys'],113 [/\bLEGO\b|レゴ|樂高|乐高|레고/i, 'lego_sets'],114 [/funko|ファンコ/i, 'funko'],115 [/ファミコン|スーファミ|スーパーファミコン|ゲームボーイ|nintendo|任天堂|닌텐도|game ?boy|\bswitch\b|\bN64\b|gamecube|\bwii\b/i, 'nintendo_games'],116 [/playstation|プレイステーション|プレステ|\bPS[1-5]\b|플레이스테이션/i, 'playstation_games'],117 [/セガ|\bsega\b|メガドライブ|dreamcast|saturn/i, 'sega_games'],118 [/figma|フィギュア|figure|피규어|公仔|ねんどろいど|nendoroid|hot toys|S\.H\.Figuarts/i, 'action_figures'],119];120121/** Seed category, refined by title keywords when the seed is a family slug (trading_cards, watches, sneakers…). */122export function refineCategory(seedCategory: string, title: string): string {123 if (!FAMILY_SLUGS.has(seedCategory)) return seedCategory;124 const t = title.normalize('NFKC');125 for (const [re, slug] of CATEGORY_KEYWORDS) if (re.test(t)) return slug;126 return seedCategory;127}128129/** Best-effort category for a title with no seed hint (Bunjang / Ruten category ids are source-specific). */130export function categoryFromTitle(title: string): string | null {131 const t = title.normalize('NFKC');132 for (const [re, slug] of CATEGORY_KEYWORDS) if (re.test(t)) return slug;133 return null;134}135136// ---------- watches ----------137export interface WatchTitleParts {138 brand: string | null;139 categorySlug: string;140 reference: string | null;141 material: string | null;142 size: string | null;143 completeness: string | null;144 conditionRaw: string | null;145 year: number | null;146}147148const BRAND_ALIASES: Array<[RegExp, string]> = [149 [/^a\.? ?lange ?(?:&|and|und) ?s[oö]hne/i, 'A. Lange & Söhne'],150 [/^patek ?philippe/i, 'Patek Philippe'],151 [/^audemars ?piguet/i, 'Audemars Piguet'],152 [/^vacheron ?constantin/i, 'Vacheron Constantin'],153 [/^jaeger[- ]?lecoultre/i, 'Jaeger-LeCoultre'],154 [/^tag ?heuer/i, 'TAG Heuer'],155 [/^grand ?seiko/i, 'Grand Seiko'],156 [/^f\.? ?p\.? ?journe/i, 'F.P. Journe'],157 [/^h\.? ?moser/i, 'H. Moser & Cie'],158 [/^girard[- ]?perregaux/i, 'Girard-Perregaux'],159 [/^glash[uü]tte ?original/i, 'Glashütte Original'],160 [/^richard ?mille/i, 'Richard Mille'],161 [/^bell ?& ?ross/i, 'Bell & Ross'],162 [/^christopher ?ward/i, 'Christopher Ward'],163 [/^ming\b/i, 'Ming'],164];165166/** Brand at the start of a watch title (WATCH_BRANDS list + aliases); null when unknown. */167export function watchBrandFromTitle(title: string): string | null {168 const t = title.trim();169 for (const [re, name] of BRAND_ALIASES) if (re.test(t)) return name;170 const sorted = [...WATCH_BRANDS].sort((a, b) => b.length - a.length);171 for (const b of sorted) if (t.toLowerCase().startsWith(b.toLowerCase())) return b;172 return null;173}174175/** Letter-prefixed references the shared regex misses: SBGE201G / SBGA211 (Seiko), IW371446 (IWC), PAM01312 (Panerai), M79030N-0001 (Tudor), CAR2A1Z (TAG Heuer). */176const ALNUM_REF_RE = /\b([A-Z]{1,4}\d{3,6}[A-Z]{0,2}(?:-\d{4})?|[A-Z]{3}\d[A-Z]\d[A-Z])\b/;177const NOT_A_REF = new Set(['18K', '14K', '9K', '24K', 'GMT', 'UTC', 'COSC', 'PVD', 'DLC', 'SS', 'YG', 'WG', 'RG', 'PT', 'TI']);178export function alnumReferenceFromText(text: string): string | null {179 for (const m of text.matchAll(new RegExp(ALNUM_REF_RE.source, 'g'))) {180 const ref = m[1]!;181 if (NOT_A_REF.has(ref) || /^(18|19|20)\d{2}$/.test(ref) || !/\d{3}/.test(ref)) continue;182 return ref;183 }184 return null;185}186187export function watchFromTitle(title: string, brandHint?: string | null): WatchTitleParts {188 const brand = brandHint?.trim() || watchBrandFromTitle(title);189 const rest = brand ? title.replace(new RegExp(`^${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'i'), '') : title;190 return {191 brand,192 categorySlug: watchCategory(brand),193 reference: watchReferenceFromText(rest) ?? alnumReferenceFromText(rest),194 material: watchMaterial(title),195 size: caseSize(title),196 completeness: watchCompleteness(title),197 conditionRaw: watchConditionRaw(title),198 year: extractYear(title),199 };200}201202/** "USD 4,380" | "$79,900" | "€ 8.850" → { amount, currency } using the shared parser conventions; null when absent. */203export { parsePrice } from '@rareindex/shared';204205// ---------- misc ----------206export function intOrNull(v: unknown): number | null {207 if (v === null || v === undefined || v === '') return null;208 const n = typeof v === 'number' ? v : Number.parseInt(String(v).replace(/[^0-9-]/g, ''), 10);209 return Number.isFinite(n) ? n : null;210}211212export function posNumber(v: unknown): number | null {213 if (v === null || v === undefined || v === '') return null;214 const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.]/g, ''));215 return Number.isFinite(n) && n > 0 ? n : null;216}217218export function cleanTitle(s: string): string {219 return s.normalize('NFKC').replace(/\s+/g, ' ').trim();220}221