SPB Git

spb/rareindex

Public
9.1 KB · 235 lines typescript
1import { z } from 'zod';2import categoriesJson from '../../../data/taxonomy/categories.json' with { type: 'json' };3import conditionsJson from '../../../data/taxonomy/conditions.json' with { type: 'json' };4import gradersJson from '../../../data/taxonomy/graders.json' with { type: 'json' };5import indicesJson from '../../../data/taxonomy/indices.json' with { type: 'json' };67/**8 * Taxonomy loader. The JSON files under data/taxonomy are the seed; the database `categories`9 * table is the runtime source of truth (it may contain approved proposals). This module offers10 * pure helpers usable from connectors, workers and the web layer without a DB round-trip.11 */1213const NodeSchema = z.object({14  slug: z.string(),15  parent: z.string().optional(),16  name: z.string(),17  short: z.string().optional(),18  phase: z.number().int().min(1).max(3),19  index: z.string().nullable().optional(),20  conditionScale: z.string().optional(),21  graders: z.array(z.string()).optional(),22  compliance: z.array(z.string()).optional(),23  description: z.string().optional(),24  icon: z.string().optional(),25});26export type TaxonomyNode = z.infer<typeof NodeSchema>;2728export interface CategoryNode extends TaxonomyNode {29  level: number;30  familySlug: string;31  children: string[];32  sortOrder: number;33}3435const parsedNodes = z.array(NodeSchema).parse(categoriesJson.nodes);3637const bySlug = new Map<string, CategoryNode>();38for (const [i, n] of parsedNodes.entries()) {39  bySlug.set(n.slug, { ...n, level: 0, familySlug: n.slug, children: [], sortOrder: i });40}41for (const n of bySlug.values()) {42  if (n.parent) {43    const p = bySlug.get(n.parent);44    if (!p) throw new Error(`taxonomy: parent ${n.parent} of ${n.slug} not found`);45    p.children.push(n.slug);46  }47}48function resolve(n: CategoryNode): void {49  if (!n.parent) return;50  const p = bySlug.get(n.parent)!;51  resolve(p);52  n.level = p.level + 1;53  n.familySlug = p.familySlug;54  n.index = n.index ?? p.index ?? null;55  n.conditionScale = n.conditionScale ?? p.conditionScale;56  n.graders = n.graders ?? p.graders;57}58for (const n of bySlug.values()) resolve(n);5960export const CATEGORIES: readonly CategoryNode[] = [...bySlug.values()];61export const FAMILIES: readonly CategoryNode[] = CATEGORIES.filter((c) => c.level === 0);6263export function getCategory(slug: string): CategoryNode | undefined {64  return bySlug.get(slug);65}66export function requireCategory(slug: string): CategoryNode {67  const c = bySlug.get(slug);68  if (!c) throw new Error(`Unknown category slug: ${slug}`);69  return c;70}71export function categoryFamily(slug: string): string {72  return bySlug.get(slug)?.familySlug ?? slug;73}74export function categoryPath(slug: string): CategoryNode[] {75  const out: CategoryNode[] = [];76  let cur = bySlug.get(slug);77  while (cur) {78    out.unshift(cur);79    cur = cur.parent ? bySlug.get(cur.parent) : undefined;80  }81  return out;82}83export function descendants(slug: string): string[] {84  const out: string[] = [];85  const stack = [slug];86  while (stack.length) {87    const s = stack.pop()!;88    const n = bySlug.get(s);89    if (!n) continue;90    for (const c of n.children) {91      out.push(c);92      stack.push(c);93    }94  }95  return out;96}97export function phaseCategories(phase: 1 | 2 | 3): CategoryNode[] {98  return CATEGORIES.filter((c) => c.phase <= phase);99}100101// ---------- Condition scales ----------102export interface ConditionScale {103  values: string[];104  completeness: string[];105  aliases: Record<string, string | null>;106}107export const CONDITION_SCALES: Record<string, ConditionScale> = conditionsJson.scales as Record<string, ConditionScale>;108109export function conditionScaleFor(categorySlug: string): ConditionScale {110  const scale = bySlug.get(categorySlug)?.conditionScale ?? 'general';111  return CONDITION_SCALES[scale] ?? CONDITION_SCALES.general!;112}113114/** Map a raw condition string to the category's normalised slug; null when unknown (never guess). */115export function normalizeCondition(categorySlug: string, raw: string | null | undefined): string | null {116  if (!raw) return null;117  const scale = conditionScaleFor(categorySlug);118  const key = raw.trim().toLowerCase().replace(/\s+/g, ' ');119  if (key in scale.aliases) return scale.aliases[key] ?? null;120  const asSlug = key.replace(/[\s-]+/g, '_');121  if (scale.values.includes(asSlug)) return asSlug;122  // Try contained alias (e.g. "Condition: Near Mint or Better")123  for (const [alias, slug] of Object.entries(scale.aliases)) {124    if (alias.length >= 4 && key.includes(alias)) return slug;125  }126  return null;127}128129export function conditionRank(categorySlug: string, condition: string | null): number | null {130  if (!condition) return null;131  const i = conditionScaleFor(categorySlug).values.indexOf(condition);132  return i >= 0 ? i : null;133}134135// ---------- Graders ----------136export interface GraderDef {137  slug: string;138  name: string;139  categories: string[];140  scale: { type: 'numeric' | 'sheldon' | 'descriptive'; values: string[]; top: string };141  populationUrl?: string;142  verifyUrl?: string;143  aliases: string[];144}145export const GRADERS: readonly GraderDef[] = gradersJson.graders as GraderDef[];146const graderByAlias = new Map<string, GraderDef>();147for (const g of GRADERS) {148  graderByAlias.set(g.slug, g);149  for (const a of g.aliases) graderByAlias.set(a.toLowerCase(), g);150}151export function getGrader(slugOrAlias: string | null | undefined): GraderDef | undefined {152  if (!slugOrAlias) return undefined;153  return graderByAlias.get(slugOrAlias.trim().toLowerCase());154}155156const GRADE_RE = /\b(PSA|BGS|BECKETT|CGC|SGC|TAG|ACE|CBCS|PGX|WATA|VGA|PCGS|NGC|ANACS|ICCS|PMG)\b[\s:-]*(?:GEM\s*MT|GEM\s*MINT|MINT|NM-?MT|PRISTINE|BLACK\s*LABEL)?[\s:-]*(\d{1,3}(?:\.\d)?)?/gi;157/** Grader tokens that are also ordinary words ("Tag Team", "Ace Attorney") — only trusted when followed by a grade. */158const AMBIGUOUS_GRADERS = new Set(['tag', 'ace']);159160/** Extract grader + grade from a free-text title. Returns nulls when absent. */161export function parseGradeFromTitle(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {162  const matches = [...title.matchAll(GRADE_RE)];163  // Prefer the first match that carries a numeric grade; ambiguous tokens without a grade are ignored.164  const m = matches.find((x) => x[2]) ?? matches.find((x) => !AMBIGUOUS_GRADERS.has(x[1]!.toLowerCase()));165  if (!m) {166    if (/\b(raw|ungraded)\b/i.test(title)) return { grader: 'raw', grade: null, qualifier: null };167    return { grader: null, grade: null, qualifier: null };168  }169  const grader = getGrader(m[1]!)?.slug ?? m[1]!.toLowerCase();170  let grade = m[2] ?? null;171  const g = getGrader(grader);172  if (grade && g && g.scale.values.length && !g.scale.values.includes(grade)) {173    // e.g. "PSA 10" where 10 is fine; "PSA 2021" is a year → reject174    if (Number(grade) > Number(g.scale.top)) grade = null;175  }176  let qualifier: string | null = null;177  if (/black\s*label/i.test(title)) qualifier = 'Black Label';178  else if (/pristine/i.test(title) && grader === 'bgs') qualifier = 'Pristine';179  else if (/signature\s*series/i.test(title)) qualifier = 'Signature Series';180  else if (/\b(OC|MC|ST|PD|MK)\b/.test(title) && grader === 'psa') qualifier = title.match(/\b(OC|MC|ST|PD|MK)\b/)![1]!;181  return { grader, grade, qualifier };182}183184/** Variant key used by asset_variants (deterministic). */185export function variantKey(input: { grader?: string | null; grade?: string | null; qualifier?: string | null; condition?: string | null; completeness?: string | null; size?: string | null }): { key: string; label: string } {186  const parts: string[] = [];187  const labelParts: string[] = [];188  if (input.grader && input.grader !== 'raw' && input.grade) {189    parts.push(input.grader, input.grade);190    labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} ${input.grade}`);191    if (input.qualifier) {192      parts.push(input.qualifier.toLowerCase().replace(/\s+/g, '_'));193      labelParts.push(input.qualifier);194    }195  } else {196    parts.push('raw');197    if (input.completeness) {198      parts.push(input.completeness);199      labelParts.push(humanize(input.completeness));200    } else if (input.condition) {201      parts.push(input.condition);202      labelParts.push(humanize(input.condition));203    } else {204      labelParts.push('Raw / ungraded');205    }206  }207  if (input.size) {208    parts.push(`size_${input.size.toLowerCase().replace(/\s+/g, '')}`);209    labelParts.push(`Size ${input.size}`);210  }211  return { key: parts.join('|'), label: labelParts.join(' · ') };212}213214export function humanize(slug: string): string {215  return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bCib\b/, 'CIB');216}217218// ---------- Indices ----------219export interface IndexDef {220  ticker: string;221  name: string;222  flagship?: boolean;223  description?: string;224  families: string[];225  color: string;226  minConstituents: number;227}228export const INDEX_BASE_DATE: string = indicesJson.baseDate;229export const INDEX_BASE_VALUE: number = indicesJson.baseValue;230export const INDICES: readonly IndexDef[] = indicesJson.indices as IndexDef[];231export function indexForCategory(categorySlug: string): IndexDef | undefined {232  const ticker = bySlug.get(categorySlug)?.index;233  return ticker ? INDICES.find((i) => i.ticker === ticker) : undefined;234}235
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme