SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
11.1 KB · 221 lines typescript
Raw Blame History
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