import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedCatalogItemSchema, extractYear, type NormalizedRecord } from '@rareindex/shared'; /** * Open Library (Internet Archive) — free bibliographic API. Seeded with ISBNs of collectible modern * first editions; each seed resolves edition → work → author through the JSON endpoints that * robots.txt permits (/isbn, /books, /works, /authors — not /search). Catalog enrichment only. */ const BASE = 'https://openlibrary.org'; const PARSER_VERSION = '1.0.0'; const HEADERS = { 'user-agent': 'RareIndexBot/0.1 (market data research; data@rareindex.io)', accept: 'application/json' }; export const SeedSchema = z.object({ isbn: z.string(), note: z.string().optional() }); export const EditionPayloadSchema = z.object({ kind: z.literal('edition'), isbn: z.string(), note: z.string().nullable(), edition: z.object({ key: z.string(), title: z.string(), subtitle: z.string().nullable(), publishers: z.array(z.string()), publishDate: z.string().nullable(), publishPlaces: z.array(z.string()), isbn10: z.array(z.string()), isbn13: z.array(z.string()), pages: z.number().nullable(), editionName: z.string().nullable(), covers: z.array(z.number()), physicalFormat: z.string().nullable(), workKey: z.string().nullable(), }), work: z.object({ key: z.string(), title: z.string().nullable(), firstPublishDate: z.string().nullable(), subjects: z.array(z.string()) }).nullable(), authors: z.array(z.string()), }); export type EditionPayload = z.infer; type J = Record; const strList = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); export function trimEdition(e: J) { return { key: String(e.key ?? ''), title: String(e.title ?? ''), subtitle: e.subtitle ? String(e.subtitle) : null, publishers: strList(e.publishers), publishDate: e.publish_date ? String(e.publish_date) : null, publishPlaces: strList(e.publish_places), isbn10: strList(e.isbn_10), isbn13: strList(e.isbn_13), pages: typeof e.number_of_pages === 'number' ? e.number_of_pages : null, editionName: e.edition_name ? String(e.edition_name) : null, covers: Array.isArray(e.covers) ? e.covers.filter((c: unknown) => typeof c === 'number' && c > 0) : [], physicalFormat: e.physical_format ? String(e.physical_format) : null, workKey: Array.isArray(e.works) && e.works[0]?.key ? String(e.works[0].key) : null, }; } export class OpenLibraryConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1000; private async getJson(ctx: CrawlContext, path: string) { await this.throttle(); return ctx.fetch(`${BASE}${path}`, { engines: ['api'], responseType: 'json', headers: HEADERS, minQuality: 0.2 }); } async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = z.array(SeedSchema).parse(this.meta.config.seeds ?? []); let count = 0; const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; for (let i = start; i < seeds.length; i++) { const seed = seeds[i]!; if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${BASE}/isbn/${seed.isbn}.json`; if (!(await ctx.shouldFetch(url))) continue; const ed = await this.getJson(ctx, `/isbn/${seed.isbn}.json`); if (!ed.success || !ed.json || typeof ed.json !== 'object') { ctx.anomaly('page_fetch_failed', `${seed.isbn}: ${ed.error ?? ed.httpStatus}`); continue; } const edition = trimEdition(ed.json as J); let work: EditionPayload['work'] = null; const authorKeys: string[] = []; if (edition.workKey) { const w = await this.getJson(ctx, `${edition.workKey}.json`); if (w.success && w.json && typeof w.json === 'object') { const wj = w.json as J; work = { key: edition.workKey, title: wj.title ? String(wj.title) : null, firstPublishDate: wj.first_publish_date ? String(wj.first_publish_date) : null, subjects: strList(wj.subjects).slice(0, 12) }; for (const a of Array.isArray(wj.authors) ? wj.authors : []) if (a?.author?.key) authorKeys.push(String(a.author.key)); } } for (const a of Array.isArray((ed.json as J).authors) ? (ed.json as J).authors : []) if (a?.key && !authorKeys.includes(String(a.key))) authorKeys.push(String(a.key)); const authors: string[] = []; for (const key of authorKeys.slice(0, 2)) { const a = await this.getJson(ctx, `${key}.json`); const name = (a.json as J | null)?.name; if (a.success && name) authors.push(String(name)); } const payload: EditionPayload = { kind: 'edition', isbn: seed.isbn, note: seed.note ?? null, edition, work, authors }; count++; yield { url, externalId: `isbn:${seed.isbn}`, kind: 'catalog_item', engine: ed.engine, httpStatus: ed.httpStatus, payload, fetchedAt: ed.fetchedAt }; await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); } } async normalize(raw: RawRecordLike): Promise { const p = EditionPayloadSchema.parse(raw.payload); const e = p.edition; if (!e.title) return []; const author = p.authors[0] ?? null; const year = e.publishDate ? extractYear(e.publishDate) : null; const isbn = e.isbn13[0] ?? e.isbn10[0] ?? p.isbn; const olid = e.key.replace('/books/', ''); const variant = e.editionName ?? (p.note && /first/i.test(p.note) ? 'First edition' : null); const attributes = AssetAttributesSchema.parse({ categorySlug: 'books', brand: author, set: e.publishers[0] ?? null, name: e.subtitle ? `${e.title}: ${e.subtitle}` : e.title, year, edition: variant, variant, country: e.publishPlaces[0] ?? null, language: 'English', identifiers: { isbn, openlibrary_id: olid, ...(e.workKey ? { openlibrary_work: e.workKey.replace('/works/', '') } : {}) }, metadata: { authors: p.authors, pages: e.pages, physical_format: e.physicalFormat, first_publish_date: p.work?.firstPublishDate ?? null, subjects: p.work?.subjects ?? [], isbn_10: e.isbn10, isbn_13: e.isbn13, seed_note: p.note }, }); return [ NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${BASE}${e.key}`, externalId: olid, rawTitle: author ? `${author} — ${e.title}${year ? ` (${year})` : ''}` : e.title, imageUrls: e.covers.slice(0, 1).map((c) => `https://covers.openlibrary.org/b/id/${c}-L.jpg`), attributes, observedAt: raw.fetchedAt, confidence: 0.9, parserVersion: PARSER_VERSION, releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null, }), ]; } } export default function createConnector(meta: ConnectorMeta) { return new OpenLibraryConnector(meta); }