import { z } from 'zod'; import { parseGradeFromTitle } from '@rareindex/taxonomy'; import { extractYear } from '@rareindex/shared'; import { WATCH_BRANDS, caseSize, watchCategory, watchCompleteness, watchConditionRaw, watchMaterial, watchReferenceFromText } from '../_luxury-lib/index.js'; /** * Helpers shared by the g9 group (Japanese / Taiwanese / Korean marketplaces, watch dealers and auction * platforms, sneaker shops). Multilingual (ja / zh / ko) title parsing, JST/KST dates, CJK bundle and * condition hints, watch title decomposition. Kept inside connectors/api, not the framework. */ // ---------- seeds ---------- export const KeywordSeedSchema = z.object({ /** search keyword in the source's language */ q: z.string(), /** taxonomy slug applied to every row of the seed (refined by title when it is a family slug) */ category: z.string(), /** card / product language when the seed implies one ("Japanese", "Chinese", "Korean", "English") */ language: z.string().nullable().default(null), }); export type KeywordSeed = z.infer; // ---------- dates ---------- /** "2026-09-09 18:32:00" (JST wall clock) | "2026-09-08T17:07:22+09:00" | ISO with Z → UTC Date; null when unparseable. */ export function parseJstDateTime(s: string | null | undefined): Date | null { if (!s) return null; const t = s.trim(); if (/[+-]\d{2}:\d{2}$|Z$/.test(t)) { const d = new Date(t); return Number.isNaN(d.getTime()) ? null : d; } const m = t.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/); if (!m) return null; 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))); return Number.isNaN(d.getTime()) ? null : d; } /** Unix seconds (number or numeric string) → Date; null when missing/invalid. */ export function unixToDate(v: number | string | null | undefined): Date | null { if (v === null || v === undefined || v === '') return null; const n = typeof v === 'number' ? v : Number(v); if (!Number.isFinite(n) || n <= 0) return null; const d = new Date(n * 1000); return Number.isNaN(d.getTime()) ? null : d; } /** "2026-07-16" → UTC midnight. */ export function isoDateOnly(s: string | null | undefined): Date | null { const m = (s ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))) : null; } // ---------- CJK text ---------- /** Full-width → half-width, "PSA10" / "PSA10" / "BGS9.5" → "PSA 10" so the shared grade parser reads them; brackets → spaces. */ export function normaliseCjkGradeText(title: string): string { return title .normalize('NFKC') .replace(/\b(PSA|BGS|CGC|SGC|ARS|ACE|TAG)\s*(\d{1,2}(?:\.\d)?)/gi, '$1 $2') .replace(/【|】|\[|\]|「|」|『|』|(|)/g, ' ') .replace(/\s+/g, ' ') .trim(); } export function cjkGrade(title: string): { grader: string | null; grade: string | null; qualifier: string | null } { const g = parseGradeFromTitle(normaliseCjkGradeText(title)); return { grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grade, qualifier: g.qualifier }; } /** Lots of many items (ja / zh / ko). Deliberately narrow: "セット" alone also names sealed box sets. */ export const BUNDLE_RE_CJK = /まとめ|セット売り|大量|\d+\s*枚セット|\d+\s*点セット|おまとめ|引退品|バラ売り不可|合售|整套出售|一起賣|\d+\s*張(?:一組|合售)|\d+\s*件組|묶음|일괄|\d+\s*장\s*세트|\bbundle\b|\blot of\b/i; export function isCjkBundle(title: string): boolean { return BUNDLE_RE_CJK.test(title.normalize('NFKC')); } /** Coarse condition hint from a CJK title: "New" | "Used" | null. */ export function cjkConditionRaw(title: string): string | null { const t = title.normalize('NFKC'); if (/未開封|新品未使用|新品|未使用|全新|未拆|새상품|미개봉|新品同様|\bBNIB\b|\bDS\b|\bdeadstock\b|\bunworn\b/i.test(t)) return 'New'; if (/中古|二手|중고|used|pre-?owned/i.test(t)) return 'Used'; return null; } /** Card / product language implied by a CJK title. */ export function cjkLanguage(title: string): string | null { const t = title.normalize('NFKC'); if (/英語版|英文版|영문|북미판|\bEN\b|\bENG\b|english/i.test(t)) return 'English'; if (/日本語版|日版|日文版|일판|일본판|\bJP\b|\bJPN\b|japanese/i.test(t)) return 'Japanese'; if (/中文版|简中|繁中|簡中|中文/.test(t)) return 'Chinese'; if (/한글판|한판|\bKR\b|korean/i.test(t)) return 'Korean'; return null; } /** Family slugs whose rows are refined by title keywords. */ const FAMILY_SLUGS = new Set(['trading_cards', 'watches', 'sneakers', 'video_games', 'designer_toys', 'action_figures', 'lego']); const CATEGORY_KEYWORDS: Array<[RegExp, string]> = [ [/ポケモンカード|ポケカ|寶可夢|宝可梦|포켓몬\s*카드|pok[eé]mon/i, 'pokemon'], [/遊戯王|遊戲王|游戏王|유희왕|yu-?gi-?oh/i, 'yugioh'], [/ワンピースカード|ワンピカ|ONE PIECE\s*カード|海賊王卡|航海王卡|원피스\s*카드|one piece card/i, 'one_piece_card_game'], [/マジック[::]?ザ[・・]?ギャザリング|\bMTG\b|魔法風雲會|magic:? the gathering/i, 'magic_the_gathering'], [/デジモンカード|digimon card/i, 'digimon_tcg'], [/ヴァイスシュヴァルツ|weiss schwarz/i, 'weiss_schwarz'], [/ロレックス|勞力士|劳力士|롤렉스|\brolex\b/i, 'rolex'], [/パテック|百達翡麗|百达翡丽|파텍|patek/i, 'patek_philippe'], [/オーデマ|愛彼|爱彼|오데마|audemars/i, 'audemars_piguet'], [/オメガ|歐米茄|欧米茄|오메가|\bomega\b/i, 'omega'], [/腕時計|手錶|手表|시계|セイコー|\bseiko\b|g-?shock|カルティエ|cartier|tudor|チューダー|breitling|ブライトリング|IWC|panerai|hublot|zenith|longines|tissot|casio/i, 'other_watches'], [/ジョーダン|\bjordan\b|ナイキ|\bnike\b|나이키|조던|耐吉|\bdunk\b|air max|air force/i, 'nike_jordan'], [/アディダス|adidas|yeezy|イージー|이지|아디다스|愛迪達/i, 'adidas_yeezy'], [/new balance|ニューバランス|뉴발란스|asics|アシックス|salomon|puma|converse|vans|hoka/i, 'new_balance_asics_other'], [/ガンプラ|ガンダム|鋼彈|钢弹|건담|gundam/i, 'gundam'], [/BE@RBRICK|ベアブリック|\bKAWS\b|be@rbrick|bearbrick|designer toy|sofubi|ソフビ/i, 'designer_toys'], [/\bLEGO\b|レゴ|樂高|乐高|레고/i, 'lego_sets'], [/funko|ファンコ/i, 'funko'], [/ファミコン|スーファミ|スーパーファミコン|ゲームボーイ|nintendo|任天堂|닌텐도|game ?boy|\bswitch\b|\bN64\b|gamecube|\bwii\b/i, 'nintendo_games'], [/playstation|プレイステーション|プレステ|\bPS[1-5]\b|플레이스테이션/i, 'playstation_games'], [/セガ|\bsega\b|メガドライブ|dreamcast|saturn/i, 'sega_games'], [/figma|フィギュア|figure|피규어|公仔|ねんどろいど|nendoroid|hot toys|S\.H\.Figuarts/i, 'action_figures'], ]; /** Seed category, refined by title keywords when the seed is a family slug (trading_cards, watches, sneakers…). */ export function refineCategory(seedCategory: string, title: string): string { if (!FAMILY_SLUGS.has(seedCategory)) return seedCategory; const t = title.normalize('NFKC'); for (const [re, slug] of CATEGORY_KEYWORDS) if (re.test(t)) return slug; return seedCategory; } /** Best-effort category for a title with no seed hint (Bunjang / Ruten category ids are source-specific). */ export function categoryFromTitle(title: string): string | null { const t = title.normalize('NFKC'); for (const [re, slug] of CATEGORY_KEYWORDS) if (re.test(t)) return slug; return null; } // ---------- watches ---------- export interface WatchTitleParts { brand: string | null; categorySlug: string; reference: string | null; material: string | null; size: string | null; completeness: string | null; conditionRaw: string | null; year: number | null; } const BRAND_ALIASES: Array<[RegExp, string]> = [ [/^a\.? ?lange ?(?:&|and|und) ?s[oö]hne/i, 'A. Lange & Söhne'], [/^patek ?philippe/i, 'Patek Philippe'], [/^audemars ?piguet/i, 'Audemars Piguet'], [/^vacheron ?constantin/i, 'Vacheron Constantin'], [/^jaeger[- ]?lecoultre/i, 'Jaeger-LeCoultre'], [/^tag ?heuer/i, 'TAG Heuer'], [/^grand ?seiko/i, 'Grand Seiko'], [/^f\.? ?p\.? ?journe/i, 'F.P. Journe'], [/^h\.? ?moser/i, 'H. Moser & Cie'], [/^girard[- ]?perregaux/i, 'Girard-Perregaux'], [/^glash[uü]tte ?original/i, 'Glashütte Original'], [/^richard ?mille/i, 'Richard Mille'], [/^bell ?& ?ross/i, 'Bell & Ross'], [/^christopher ?ward/i, 'Christopher Ward'], [/^ming\b/i, 'Ming'], ]; /** Brand at the start of a watch title (WATCH_BRANDS list + aliases); null when unknown. */ export function watchBrandFromTitle(title: string): string | null { const t = title.trim(); for (const [re, name] of BRAND_ALIASES) if (re.test(t)) return name; const sorted = [...WATCH_BRANDS].sort((a, b) => b.length - a.length); for (const b of sorted) if (t.toLowerCase().startsWith(b.toLowerCase())) return b; return null; } /** Letter-prefixed references the shared regex misses: SBGE201G / SBGA211 (Seiko), IW371446 (IWC), PAM01312 (Panerai), M79030N-0001 (Tudor), CAR2A1Z (TAG Heuer). */ const 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/; const NOT_A_REF = new Set(['18K', '14K', '9K', '24K', 'GMT', 'UTC', 'COSC', 'PVD', 'DLC', 'SS', 'YG', 'WG', 'RG', 'PT', 'TI']); export function alnumReferenceFromText(text: string): string | null { for (const m of text.matchAll(new RegExp(ALNUM_REF_RE.source, 'g'))) { const ref = m[1]!; if (NOT_A_REF.has(ref) || /^(18|19|20)\d{2}$/.test(ref) || !/\d{3}/.test(ref)) continue; return ref; } return null; } export function watchFromTitle(title: string, brandHint?: string | null): WatchTitleParts { const brand = brandHint?.trim() || watchBrandFromTitle(title); const rest = brand ? title.replace(new RegExp(`^${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'i'), '') : title; return { brand, categorySlug: watchCategory(brand), reference: watchReferenceFromText(rest) ?? alnumReferenceFromText(rest), material: watchMaterial(title), size: caseSize(title), completeness: watchCompleteness(title), conditionRaw: watchConditionRaw(title), year: extractYear(title), }; } /** "USD 4,380" | "$79,900" | "€ 8.850" → { amount, currency } using the shared parser conventions; null when absent. */ export { parsePrice } from '@rareindex/shared'; // ---------- misc ---------- export function intOrNull(v: unknown): number | null { if (v === null || v === undefined || v === '') return null; const n = typeof v === 'number' ? v : Number.parseInt(String(v).replace(/[^0-9-]/g, ''), 10); return Number.isFinite(n) ? n : null; } export function posNumber(v: unknown): number | null { if (v === null || v === undefined || v === '') return null; const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } export function cleanTitle(s: string): string { return s.normalize('NFKC').replace(/\s+/g, ' ').trim(); }