import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedPriceObservationSchema, type NormalizedRecord } from '@rareindex/shared'; /** * NGC US Coin Price Guide — one server-rendered page per series (subcategory): a pinned table with the * coin rows (NGC coin id, year/mint, description, denomination, designation, "Updated: m/d/yyyy") and a * price table with one cell per grade (`price-history-link coin-id grade`). Values are NGC retail guide * values in USD. Subcategories come from the public JSON /coin-explorer/data/categories/. */ const SITE = 'https://www.ngccoin.com'; const PARSER_VERSION = '1.0.0'; export const CoinSchema = z.object({ coinId: z.string(), yearMint: z.string().nullable(), description: z.string(), denomination: z.string().nullable(), designation: z.string().nullable(), strike: z.enum(['ms', 'pf']).nullable(), updatedText: z.string().nullable(), /** grade label as NGC prints it ("PrAg", "VF", "XF+", "50", "65", "65+", "70★") → USD */ prices: z.record(z.string(), z.number()), }); export const PayloadSchema = z.object({ kind: z.literal('price_guide_series'), url: z.string(), subcategoryId: z.string(), categorySeo: z.string().nullable(), seriesName: z.string(), coins: z.array(CoinSchema), }); export type Payload = z.infer; const CategoriesSchema = z.array(z.object({ CategoryID: z.number(), Name: z.string(), SeoName: z.string(), Subcategories: z.array(z.object({ SubcategoryID: z.number(), Name: z.string(), SubcategorySeoName: z.string().nullable().optional(), HasMS: z.boolean().nullable().optional(), HasPF: z.boolean().nullable().optional() })) })); export type Subcategory = { id: string; name: string; categorySeo: string; categoryName: string }; export function parseCategories(json: unknown): Subcategory[] { const parsed = CategoriesSchema.safeParse(json); if (!parsed.success) return []; return parsed.data.flatMap((c) => c.Subcategories.map((s) => ({ id: String(s.SubcategoryID), name: s.Name, categorySeo: c.SeoName, categoryName: c.Name }))); } function money(s: string | null | undefined): number | null { if (!s) return null; const n = Number(s.replace(/[^\d.]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } export function parseSeriesPage(htmlText: string, url: string, categorySeo: string | null): Payload | null { const $ = H.load(htmlText); const seriesName = H.text($('h1.census-header-title').first()) ?? ''; const subcategoryId = url.match(/\/(\d+)\/?(?:[?#].*)?$/)?.[1] ?? $('[subcategory-id]').first().attr('subcategory-id') ?? ''; if (!seriesName || !subcategoryId) return null; const coins = new Map>(); $('td[parent-coin-id]').each((_, td) => { const cell = $(td); const coinId = cell.attr('parent-coin-id'); if (!coinId || coins.has(coinId)) return; const tr = cell.closest('tr'); const cls = tr.attr('class') ?? ''; const strike = /\bpf\b/.test(cls) ? 'pf' : /\bms\b/.test(cls) ? 'ms' : null; const description = H.text(cell.find('.merged').first()) ?? H.text(cell.find('a').first()) ?? ''; if (!description) return; const tds = tr.children('td'); coins.set(coinId, { coinId, yearMint: H.text(cell.find('.standard').first()), description, denomination: H.text(tds.eq(1)), designation: H.text(tds.eq(2)), strike, updatedText: (H.text(cell.find('.last-updated').first()) ?? '').replace(/^Updated:\s*/i, '') || null, prices: {}, }); }); $('td[price-history-link]').each((_, td) => { const cell = $(td); const coinId = cell.attr('coin-id'); const grade = cell.attr('grade'); const value = money(H.text(cell)); if (!coinId || !grade || value === null) return; const coin = coins.get(coinId); if (coin) coin.prices[grade] = value; }); return { kind: 'price_guide_series', url, subcategoryId, categorySeo, seriesName, coins: [...coins.values()].filter((c) => Object.keys(c.prices).length) }; } /** "9/8/2026" (US m/d/yyyy) → UTC date; null when absent. */ export function updatedDate(s: string | null): Date | null { const m = s?.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/); if (!m) return null; return new Date(Date.UTC(Number(m[3]), Number(m[1]) - 1, Number(m[2]))); } /** "1878 8TF" → { year, mintMark, variety }; "1893-S" → S; "1921 D" → D. */ export function parseYearMint(s: string | null): { year: number | null; mintMark: string | null; variety: string | null } { const m = s?.match(/^(\d{4})(?:[-\s]([A-Z]{1,2}))?\s*(.*)$/); if (!m) return { year: null, mintMark: null, variety: s?.trim() || null }; return { year: Number(m[1]), mintMark: m[2] ?? null, variety: m[3]?.trim() || null }; } /** NGC grade label + designation → grade token ("65" + "MS" → MS65, "65+" → MS65+, "XF+" stays, "PrAg" stays). */ export function gradeToken(label: string, designation: string | null, strike: 'ms' | 'pf' | null): string { const star = label.includes('★') || /star/i.test(label); const base = label.replace(/★|star/gi, '').trim(); const prefix = designation?.match(/^(MS|PF|SP|PL)/)?.[1] ?? (strike === 'pf' ? 'PF' : 'MS'); const token = /^\d{1,2}\+?$/.test(base) ? `${prefix}${base}` : base; return star ? `${token}★` : token; } interface Cursor { index?: number; done?: boolean; updatedAt?: string; } export class NgcPriceGuideConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 4000; async *crawl(ctx: CrawlContext): AsyncIterable { const perRun = Number(this.meta.config.subcategoriesPerRun ?? 10); const only = (this.meta.config.onlySubcategories as string[] | undefined) ?? []; const cursor = { ...((ctx.options.cursor ?? {}) as Cursor) }; await this.throttle(); const cats = await ctx.fetch(`${SITE}/coin-explorer/data/categories/`, { engines: ['api'], minQuality: 0 }); let subs = cats.success && cats.json ? parseCategories(cats.json) : []; if (!subs.length) { ctx.anomaly(cats.success ? 'schema_drift' : 'page_fetch_failed', `categories: ${cats.error ?? cats.httpStatus ?? 'unexpected shape'}`); return; } if (only.length) subs = subs.filter((s) => only.includes(s.id)); let i = Math.min(cursor.index ?? 0, subs.length); let count = 0; let fetched = 0; while (i < subs.length && fetched < perRun && !ctx.signal?.aborted && !this.reached(ctx, count)) { const sub = subs[i]!; const url = `${SITE}/price-guide/united-states/${sub.categorySeo}/${sub.id}/`; i++; if (ctx.options.mode !== 'backfill' && !(await ctx.shouldFetch(url))) continue; await this.throttle(); const res = await ctx.fetch(url, { engines: ['api', 'firecrawl'], responseType: 'text', expect: ['title', 'price', 'identifiers'], parse: (r) => { const p = r.html ? parseSeriesPage(r.html, url, sub.categorySeo) : null; const c = p?.coins[0]; return p && c ? { title: p.seriesName, price: Object.values(c.prices)[0] ?? null, identifiers: { ngc: c.coinId } } : null; }, }); fetched++; const payload = res.success && res.html ? parseSeriesPage(res.html, url, sub.categorySeo) : null; if (!payload || !payload.coins.length) { ctx.anomaly(res.success ? 'parse_failure_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no priced rows'}`); continue; } count++; yield { url, externalId: `subcategory:${sub.id}`, kind: 'price_observation', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ index: i >= subs.length ? 0 : i, updatedAt: new Date().toISOString() }); if (ctx.options.mode === 'backfill') await ctx.progress({ page: i, totalPages: subs.length, itemsProcessed: count }); } if (i >= subs.length) await ctx.setCursor({ index: 0, done: ctx.options.mode === 'backfill', updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; for (const coin of p.coins) { const { year, mintMark, variety } = parseYearMint(coin.yearMint); const obsDate = updatedDate(coin.updatedText); if (!obsDate) continue; // never date a guide value with the fetch time const desig = coin.designation?.toUpperCase() ?? null; const attributes = AssetAttributesSchema.parse({ categorySlug: 'coins', brand: 'United States Mint', series: p.seriesName, set: p.seriesName, name: coin.description, number: coin.coinId, year, variant: [mintMark ? `${mintMark} mint` : null, variety, desig && desig !== 'MS' ? desig : null].filter(Boolean).join(' · ') || null, country: 'US', identifiers: { ngc_coin_id: coin.coinId }, metadata: { mint_mark: mintMark, variety, designation: desig, strike: coin.strike, denomination: coin.denomination, ngc_subcategory_id: p.subcategoryId, ngc_category_seo: p.categorySeo, guide: 'NGC US Coin Price Guide (retail)' }, }); const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${SITE}/coin-explorer/${coin.coinId}/`, rawTitle: `${coin.description} · NGC #${coin.coinId}`, imageUrls: [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: `coin:${coin.coinId}`, confidence: 0.95 })); for (const [label, value] of Object.entries(coin.prices)) { const grade = gradeToken(label, desig, coin.strike); out.push( NormalizedPriceObservationSchema.parse({ kind: 'price_observation', ...base, externalId: `coin:${coin.coinId}:${grade}`, confidence: 0.85, grade: { grader: 'ngc', grade, qualifier: null, certificationNumber: null }, priceKind: 'guide_value', price: value, currency: 'USD', observationDate: obsDate, sampleSize: null, }), ); } } return out; } } export default (meta: ConnectorMeta) => new NgcPriceGuideConnector(meta);