import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedCatalogItemSchema, type NormalizedRecord } from '@rareindex/shared'; import { attrs } from '../_lib/shared.js'; import { publisherCategory } from '../_g6-comics-toys-games-lib/comics.js'; /** * Grand Comics Database (comics.org) — public, unauthenticated JSON REST API * (https://www.comics.org/api/ — Django REST framework): /api/series/?page=N (50 per page, 232k series), * /api/series//, /api/issue//, /api/publisher//. Data is CC-BY-SA (attribution kept on every record). * One raw record per series (with up to `issuesPerSeries` issue details); normalize → one catalog_item per * issue keyed by `comics_org_id`. */ const API = 'https://www.comics.org/api'; const SITE = 'https://www.comics.org'; const PARSER_VERSION = '1.0.0'; const HEADERS = { accept: 'application/json' }; export const IssueSchema = z.object({ id: z.string(), number: z.string().nullable().default(null), volume: z.string().nullable().default(null), variant_name: z.string().nullable().default(null), variant_of: z.string().nullable().default(null), title: z.string().nullable().default(null), publication_date: z.string().nullable().default(null), key_date: z.string().nullable().default(null), on_sale_date: z.string().nullable().default(null), price: z.string().nullable().default(null), page_count: z.string().nullable().default(null), isbn: z.string().nullable().default(null), barcode: z.string().nullable().default(null), indicia_publisher: z.string().nullable().default(null), brand_emblem: z.string().nullable().default(null), cover: z.string().nullable().default(null), cover_pencils: z.string().nullable().default(null), cover_title: z.string().nullable().default(null), story_count: z.number().int().nullable().default(null), notes: z.string().nullable().default(null), }); export type Issue = z.infer; export const SeriesSchema = z.object({ id: z.string(), name: z.string(), country: z.string().nullable().default(null), language: z.string().nullable().default(null), year_began: z.number().int().nullable().default(null), year_ended: z.number().int().nullable().default(null), binding: z.string().nullable().default(null), publishing_format: z.string().nullable().default(null), color: z.string().nullable().default(null), issue_count: z.number().int().nullable().default(null), }); export type Series = z.infer; export const PublisherSchema = z.object({ id: z.string().nullable(), name: z.string().nullable(), country: z.string().nullable().default(null) }); export const PayloadSchema = z.object({ kind: z.literal('series_issues'), series: SeriesSchema, publisher: PublisherSchema, issues: z.array(IssueSchema) }); export type Payload = z.infer; type Json = Record; export function idFromApiUrl(url: string | null | undefined): string | null { return url?.match(/\/api\/(?:issue|series|publisher)\/(\d+)\//)?.[1] ?? null; } const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null); export function trimIssue(j: Json): Issue { const stories: Json[] = Array.isArray(j.story_set) ? j.story_set : []; const cover = stories.find((s) => s.type === 'cover'); return IssueSchema.parse({ id: idFromApiUrl(j.api_url) ?? String(j.id ?? ''), number: str(j.number), volume: str(j.volume), variant_name: str(j.variant_name), variant_of: idFromApiUrl(j.variant_of), title: str(j.title), publication_date: str(j.publication_date), key_date: str(j.key_date), on_sale_date: str(j.on_sale_date), price: str(j.price), page_count: str(j.page_count), isbn: str(j.isbn), barcode: str(j.barcode), indicia_publisher: str(j.indicia_publisher), brand_emblem: str(j.brand_emblem), cover: str(j.cover), cover_pencils: str(cover?.pencils), cover_title: str(cover?.title), story_count: stories.length, notes: str(j.notes)?.slice(0, 400) ?? null, }); } export function trimSeries(j: Json): Series { return SeriesSchema.parse({ id: idFromApiUrl(j.api_url) ?? String(j.id ?? ''), name: String(j.name ?? ''), country: str(j.country), language: str(j.language), year_began: typeof j.year_began === 'number' ? j.year_began : null, year_ended: typeof j.year_ended === 'number' ? j.year_ended : null, binding: str(j.binding), publishing_format: str(j.publishing_format), color: str(j.color), issue_count: Array.isArray(j.active_issues) ? j.active_issues.length : null, }); } /** "1988-05-00" → { year: 1988, date: null } · "1988-02-23" → full UTC date. GCD uses 00 for unknown month/day. */ export function parseKeyDate(s: string | null | undefined): { year: number | null; date: Date | null } { if (!s) return { year: null, date: null }; const m = s.match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?/); if (!m) return { year: null, date: null }; const year = Number(m[1]); if (!year || year < 1500) return { year: null, date: null }; const mo = m[2] ? Number(m[2]) : 0; const d = m[3] ? Number(m[3]) : 0; if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) { const date = new Date(Date.UTC(year, mo - 1, d)); return { year, date: Number.isNaN(date.getTime()) ? null : date }; } return { year, date: null }; } /** "0.12 USD" · "$0.12 USD; 0.15 CAD" → first amount with an ISO currency we support. */ export function parseCoverPrice(s: string | null | undefined): { amount: number; currency: 'USD' | 'CAD' | 'GBP' | 'EUR' | 'JPY' } | null { if (!s) return null; const m = s.match(/(\d+(?:\.\d+)?)\s*(USD|CAD|GBP|EUR|JPY)\b/); if (!m) return null; const amount = Number(m[1]); return Number.isFinite(amount) && amount > 0 ? { amount, currency: m[2] as 'USD' } : null; } export function barcodeIds(barcode: string | null | undefined): Record { if (!barcode) return {}; const d = barcode.replace(/[^0-9]/g, ''); if (d.length === 12) return { upc: d }; if (d.length === 13) return /^97[89]/.test(d) ? { isbn: d, ean: d } : { ean: d }; if (d.length >= 17 && d.length <= 18) return { upc: d.slice(0, 12), upc_supplement: d.slice(12) }; return {}; } export class GcdConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2500; override readonly urlPatterns = [/^https?:\/\/(?:www\.)?comics\.org\/(issue|series)\/(\d+)\/?/i]; private publishers = new Map>(); /** Set when the API answers 429 (GCD throttles at roughly 50 requests per ~30 min); the run then stops at its last checkpoint. */ private rateLimited = false; private requests = 0; private async getJson(ctx: CrawlContext, url: string): Promise { if (this.rateLimited) return null; await this.throttle(url); this.requests++; const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', headers: HEADERS, minQuality: 0 }); if (res.httpStatus === 429) { this.rateLimited = true; ctx.anomaly('rate_limited', `${url}: HTTP 429 after ${this.requests} requests this run — stopping at the last checkpoint`); return null; } if (!res.success || !res.json || typeof res.json !== 'object') { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return null; } return res.json as Json; } private budgetLeft(): boolean { return !this.rateLimited && this.requests < Number(this.meta.config.maxRequestsPerRun ?? 40); } private async publisher(ctx: CrawlContext, apiUrl: string | null): Promise> { const id = idFromApiUrl(apiUrl); if (!id) return { id: null, name: null, country: null }; const hit = this.publishers.get(id); if (hit) return hit; const j = await this.getJson(ctx, `${API}/publisher/${id}/?format=json`); const p = { id, name: str(j?.name), country: str(j?.country) }; this.publishers.set(id, p); return p; } /** Fetch a series JSON (already loaded list entry or by id) plus up to `max` of its issues. */ private async seriesRecord(ctx: CrawlContext, seriesJson: Json, max: number, onlyIssueIds?: string[]): Promise { const series = trimSeries(seriesJson); if (!series.id) return null; const publisher = await this.publisher(ctx, seriesJson.publisher); const issueUrls: string[] = Array.isArray(seriesJson.active_issues) ? seriesJson.active_issues : []; const wanted = onlyIssueIds ? issueUrls.filter((u) => onlyIssueIds.includes(idFromApiUrl(u) ?? '')) : issueUrls.slice(0, max); const issues: Issue[] = []; for (const u of wanted) { if (ctx.signal?.aborted || !this.budgetLeft()) break; const j = await this.getJson(ctx, u.includes('format=') ? u : `${u}${u.includes('?') ? '&' : '?'}format=json`); if (!j) continue; try { issues.push(trimIssue(j)); } catch (err) { ctx.anomaly('schema_drift', `${u}: ${err instanceof Error ? err.message : String(err)}`); } } if (!issues.length) return null; const payload: Payload = { kind: 'series_issues', series, publisher, issues }; return { url: `${SITE}/series/${series.id}/`, externalId: `series:${series.id}`, kind: 'catalog_item', engine: 'api', httpStatus: 200, payload, fetchedAt: new Date() }; } async *crawl(ctx: CrawlContext): AsyncIterable { const backfill = ctx.options.mode === 'backfill'; const issuesPerSeries = Number(this.meta.config.issuesPerSeries ?? 12); const seriesPerRun = ctx.options.mode === 'probe' ? Math.max(1, ctx.options.limit ?? 3) : Number(backfill ? this.meta.config.backfillSeriesPerRun ?? 200 : this.meta.config.seriesPerRun ?? 25); let count = 0; // Manual seeds: "series:", "issue:" or comics.org URLs. if (ctx.options.seeds?.length) { for (const seed of ctx.options.seeds) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const m = seed.match(/(series|issue)[:/](\d+)/i); if (!m) continue; const recs = await this.lookup(`${SITE}/${m[1]!.toLowerCase()}/${m[2]}/`, ctx); for (const r of recs) { count++; yield r; } } return; } const cursor = (ctx.options.cursor ?? {}) as { page?: number; index?: number; done?: boolean }; let page = Math.max(1, Number(cursor.page ?? 1)); let index = Math.max(0, Number(cursor.index ?? 0)); let processed = 0; this.rateLimited = false; this.requests = 0; while (processed < seriesPerRun) { if (ctx.signal?.aborted || this.reached(ctx, count) || !this.budgetLeft()) return; const listUrl = `${API}/series/?format=json&page=${page}`; const list = await this.getJson(ctx, listUrl); const results: Json[] = Array.isArray(list?.results) ? list!.results : []; if (!list || !results.length) { if (list && list.next === null) { await ctx.setCursor({ done: true, page: 1, index: 0, updatedAt: new Date().toISOString() }); return; } ctx.anomaly('pagination_failure', listUrl); return; } const totalPages = typeof list.count === 'number' ? Math.ceil(list.count / 50) : null; for (; index < results.length && processed < seriesPerRun; index++) { if (ctx.signal?.aborted || this.reached(ctx, count) || !this.budgetLeft()) break; const s = results[index]!; if (!Array.isArray(s.active_issues) || !s.active_issues.length) { processed++; continue; } const rec = await this.seriesRecord(ctx, s, issuesPerSeries); processed++; if (rec) { count++; yield rec; } await ctx.setCursor({ page, index: index + 1, updatedAt: new Date().toISOString() }); } if (!this.budgetLeft()) return; if (index >= results.length) { if (list.next === null) { await ctx.setCursor({ done: true, page: 1, index: 0, updatedAt: new Date().toISOString() }); return; } page++; index = 0; await ctx.setCursor({ page, index: 0, updatedAt: new Date().toISOString() }); if (backfill) await ctx.progress({ page, totalPages, itemsProcessed: count }); } } } async lookup(url: string, ctx: CrawlContext): Promise { const m = url.match(this.urlPatterns[0]!); if (!m) return []; const [, kind, id] = m; if (kind!.toLowerCase() === 'issue') { const issue = await this.getJson(ctx, `${API}/issue/${id}/?format=json`); const seriesUrl = issue?.series; const sid = idFromApiUrl(seriesUrl); if (!issue || !sid) return []; const series = await this.getJson(ctx, `${API}/series/${sid}/?format=json`); if (!series) return []; const rec = await this.seriesRecord(ctx, series, 1, [id!]); return rec ? [rec] : []; } const series = await this.getJson(ctx, `${API}/series/${id}/?format=json`); if (!series) return []; const rec = await this.seriesRecord(ctx, series, Number(this.meta.config.issuesPerSeries ?? 12)); return rec ? [rec] : []; } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const categorySlug = publisherCategory(p.publisher.name, p.series.language); const out: NormalizedRecord[] = []; for (const it of p.issues) { const kd = parseKeyDate(it.key_date); const onSale = parseKeyDate(it.on_sale_date); const year = kd.year ?? onSale.year ?? (p.series.year_began && p.series.year_began === p.series.year_ended ? p.series.year_began : null); const price = parseCoverPrice(it.price); const number = it.number && it.number !== '[nn]' ? it.number : null; const seriesLabel = p.series.year_began ? `${p.series.name} (${p.series.year_began}${p.series.year_ended && p.series.year_ended !== p.series.year_began ? `-${p.series.year_ended}` : ''} series)` : p.series.name; const name = number ? `${p.series.name} #${number}${it.variant_name ? ` (${it.variant_name})` : ''}` : it.title || p.series.name; const identifiers: Record = { comics_org_id: it.id, gcd_series_id: p.series.id, ...barcodeIds(it.barcode) }; if (it.isbn) identifiers.isbn = it.isbn.replace(/[^0-9Xx]/g, ''); if (p.publisher.id) identifiers.gcd_publisher_id = p.publisher.id; const attributes = attrs({ categorySlug, brand: p.publisher.name, series: seriesLabel, set: p.series.name, name, number, year, variant: it.variant_name, language: p.series.language, country: p.series.country ? p.series.country.toUpperCase() : null, originalMsrp: price?.amount ?? null, originalMsrpCurrency: price?.currency ?? null, identifiers, metadata: { key_date: it.key_date, publication_date: it.publication_date, on_sale_date: it.on_sale_date, cover_price_raw: it.price, page_count: it.page_count, indicia_publisher: it.indicia_publisher, brand_emblem: it.brand_emblem, variant_of: it.variant_of, cover_pencils: it.cover_pencils, story_count: it.story_count, binding: p.series.binding, publishing_format: p.series.publishing_format, series_year_began: p.series.year_began, series_year_ended: p.series.year_ended, license: 'CC-BY-SA (Grand Comics Database)', }, }); out.push( NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${SITE}/issue/${it.id}/`, externalId: it.id, rawTitle: `${p.series.name} #${number ?? it.title ?? ''} (${it.publication_date ?? p.series.year_began ?? '?'})`.trim(), description: it.notes, imageUrls: it.cover && /^https?:\/\//.test(it.cover) ? [it.cover] : [], attributes, grade: {}, condition: {}, observedAt: raw.fetchedAt, confidence: 0.92, parserVersion: PARSER_VERSION, releaseDate: onSale.date ?? kd.date, }), ); } return out; } } export default (meta: ConnectorMeta) => new GcdConnector(meta);