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%
10.2 KB · 247 lines typescript
Raw Blame History
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}155156// Grader token, optional "/DNA" (PSA/DNA autograph slabs), optional descriptive word, then either a157// numeric grade ("10", "9.5", "(8.5)") or an authentication-only marker ("Authentic", "AUTH", "A").158const GRADE_RE = /\b(PSA|BGS|BECKETT|CGC|SGC|TAG|ACE|CBCS|PGX|WATA|VGA|PCGS|NGC|ANACS|ICCS|PMG)\b(?:\s*\/?\s*DNA\b)?[\s:\-\[(]*(?:GEM\s*MT|GEM\s*MINT|PRISTINE|BLACK\s*LABEL|NM[-\/]?MT\+?|EX[-\/]?MT\+?|VG[-\/]?EX\+?|MINT\+?|NM\+?|EX\+?|VG\+?|FN\+?|GD\+?|MT\+?|PR|FR)?[\s:\-\[(]*(?:(\d{1,3}(?:\.\d)?)(?![\d.])|\b(AUTHENTIC|AUTH|CERTIFIED\s+AUTHENTIC)\b)?/gi;159/** Grader tokens that are also ordinary words ("Tag Team", "Ace Attorney") — only trusted when followed by a grade. */160const AMBIGUOUS_GRADERS = new Set(['tag', 'ace']);161/** Authentication-only slabs (no numeric grade). Kept apart from raw and from graded variants. */162export const AUTHENTIC_GRADE = 'authentic';163164/**165 * Extract grader + grade from a free-text title. Returns nulls when absent.166 * A grader token WITHOUT a readable grade yields `{ grader, grade: null }` — callers must treat that as167 * "graded, grade unknown" (never as raw): mixing such records into the raw variant polluted RIVs.168 */169export function parseGradeFromTitle(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {170  const matches = [...title.matchAll(GRADE_RE)];171  // Prefer the first match that carries a numeric grade; ambiguous tokens without a grade are ignored.172  const m = matches.find((x) => x[2]) ?? matches.find((x) => x[3]) ?? matches.find((x) => !AMBIGUOUS_GRADERS.has(x[1]!.toLowerCase()));173  if (!m) {174    if (/\b(raw|ungraded)\b/i.test(title)) return { grader: 'raw', grade: null, qualifier: null };175    return { grader: null, grade: null, qualifier: null };176  }177  const grader = getGrader(m[1]!)?.slug ?? m[1]!.toLowerCase();178  let grade = m[2] ?? (m[3] ? AUTHENTIC_GRADE : null);179  const g = getGrader(grader);180  if (grade && grade !== AUTHENTIC_GRADE && g && g.scale.values.length && !g.scale.values.includes(grade)) {181    // e.g. "PSA 10" where 10 is fine; "PSA 2021" is a year → reject182    if (Number(grade) > Number(g.scale.top)) grade = null;183  }184  let qualifier: string | null = null;185  if (/black\s*label/i.test(title)) qualifier = 'Black Label';186  else if (/pristine/i.test(title) && grader === 'bgs') qualifier = 'Pristine';187  else if (/signature\s*series/i.test(title)) qualifier = 'Signature Series';188  else if (/\b(OC|MC|ST|PD|MK)\b/.test(title) && grader === 'psa') qualifier = title.match(/\b(OC|MC|ST|PD|MK)\b/)![1]!;189  return { grader, grade, qualifier };190}191192/** Variant key used by asset_variants (deterministic). */193export 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 } {194  const parts: string[] = [];195  const labelParts: string[] = [];196  if (input.grader && input.grader !== 'raw' && input.grade) {197    parts.push(input.grader, input.grade);198    labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} ${input.grade === AUTHENTIC_GRADE ? 'Authentic' : input.grade}`);199    if (input.qualifier) {200      parts.push(input.qualifier.toLowerCase().replace(/\s+/g, '_'));201      labelParts.push(input.qualifier);202    }203  } else if (input.grader && input.grader !== 'raw') {204    // Slabbed by a known grader but the grade could not be read: its own variant, never "raw".205    parts.push(input.grader, 'unknown');206    labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} · grade unknown`);207  } else {208    parts.push('raw');209    if (input.completeness) {210      parts.push(input.completeness);211      labelParts.push(humanize(input.completeness));212    } else if (input.condition) {213      parts.push(input.condition);214      labelParts.push(humanize(input.condition));215    } else {216      labelParts.push('Raw / ungraded');217    }218  }219  if (input.size) {220    parts.push(`size_${input.size.toLowerCase().replace(/\s+/g, '')}`);221    labelParts.push(`Size ${input.size}`);222  }223  return { key: parts.join('|'), label: labelParts.join(' · ') };224}225226export function humanize(slug: string): string {227  return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bCib\b/, 'CIB');228}229230// ---------- Indices ----------231export interface IndexDef {232  ticker: string;233  name: string;234  flagship?: boolean;235  description?: string;236  families: string[];237  color: string;238  minConstituents: number;239}240export const INDEX_BASE_DATE: string = indicesJson.baseDate;241export const INDEX_BASE_VALUE: number = indicesJson.baseValue;242export const INDICES: readonly IndexDef[] = indicesJson.indices as IndexDef[];243export function indexForCategory(categorySlug: string): IndexDef | undefined {244  const ticker = bySlug.get(categorySlug)?.index;245  return ticker ? INDICES.find((i) => i.ticker === ticker) : undefined;246}247