import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; /** * Saatchi Art — original artworks listed for sale. One raw record per browse page (25 works); * normalise → one listing per artwork (USD list price; sold works → availability 'sold'). */ const BASE = 'https://www.saatchiart.com'; const PARSER_VERSION = '1.0.0'; const SeedSchema = z.object({ path: z.string(), category: z.string() }); type Seed = z.infer; export const WorkSchema = z.object({ artworkId: z.string(), url: z.string(), title: z.string(), artistFirstName: z.string().nullable(), artistLastName: z.string().nullable(), artistId: z.number().nullable(), category: z.string().nullable(), mediums: z.array(z.string()), materials: z.array(z.string()), styles: z.array(z.string()), subject: z.string().nullable(), widthCm: z.number().nullable(), heightCm: z.number().nullable(), depthCm: z.number().nullable(), listPriceCents: z.number().nullable(), status: z.string().nullable(), sku: z.string().nullable(), image: z.string().nullable(), countryName: z.string().nullable(), uploadedAt: z.number().nullable(), }); export const PagePayloadSchema = z.object({ kind: z.literal('browse_page'), url: z.string(), seed: SeedSchema, page: z.number(), totalResults: z.number().nullable(), works: z.array(WorkSchema) }); export type PagePayload = z.infer; export function parseBrowsePage(htmlText: string, url: string, seed: Seed, page: number): PagePayload | null { const data = H.nextData(htmlText) as { props?: { pageProps?: { initialState?: { searchProvider?: { serverSideData?: { results?: Array>; totalResults?: number } } } } } } | null; const ssd = data?.props?.pageProps?.initialState?.searchProvider?.serverSideData; if (!ssd?.results) return null; const works = ssd.results .map((r) => { const dims = (r.dimensions as { widthInCentimeters?: number; heightInCentimeters?: number; depthInCentimeters?: number } | undefined) ?? {}; const path = String(r.artworkOriginalUrl ?? ''); return { artworkId: String(r.artworkId ?? ''), url: path.startsWith('http') ? path : BASE + path, title: String(r.artworkTitle ?? '').trim(), artistFirstName: r.artistFirstName ? String(r.artistFirstName) : null, artistLastName: r.artistLastName ? String(r.artistLastName) : null, artistId: typeof r.artistId === 'number' ? r.artistId : null, category: r.category ? String(r.category) : null, mediums: Array.isArray(r.mediums) ? (r.mediums as unknown[]).map(String) : [], materials: Array.isArray(r.materials) ? (r.materials as unknown[]).map(String) : [], styles: Array.isArray(r.styles) ? (r.styles as unknown[]).map(String) : [], subject: r.subject ? String(r.subject) : null, widthCm: typeof dims.widthInCentimeters === 'number' ? dims.widthInCentimeters : null, heightCm: typeof dims.heightInCentimeters === 'number' ? dims.heightInCentimeters : null, depthCm: typeof dims.depthInCentimeters === 'number' ? dims.depthInCentimeters : null, listPriceCents: typeof r.listPriceInCents === 'number' ? r.listPriceInCents : null, status: r.originalArtworkStatus ? String(r.originalArtworkStatus) : null, sku: r.sku ? String(r.sku) : null, image: r.imageUrl ? String(r.imageUrl) : null, countryName: r.countryName ? String(r.countryName) : null, uploadedAt: typeof r.artworkUploadedDate === 'number' ? r.artworkUploadedDate : null, }; }) .filter((w) => w.artworkId && w.title); return { kind: 'browse_page', url, seed, page, totalResults: typeof ssd.totalResults === 'number' ? ssd.totalResults : null, works }; } function titleCase(s: string): string { return s.replace(/\b\w/g, (c) => c.toUpperCase()); } export class SaatchiArtConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; override readonly urlPatterns = [/saatchiart\.com\/(?:en-[a-z]+\/)?art\/[^/]+\/\d+\/\d+\/view/]; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = z.array(SeedSchema).parse(this.meta.config.seeds ?? []); const pages = Number(this.meta.config.pagesPerSeed ?? 4); let count = 0; for (const seed of seeds) { if (ctx.options.categories?.length && !ctx.options.categories.includes(seed.category)) continue; for (let page = 1; page <= pages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${BASE}${seed.path}${page > 1 ? `?page=${page}` : ''}`; await this.throttle(); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', expect: ['title', 'price', 'currency', 'images'], parse: (r) => { const p = r.html ? parseBrowsePage(r.html, url, seed, page) : null; const w = p?.works[0]; return w ? { title: w.title, price: w.listPriceCents, currency: 'USD', images: w.image ? [w.image] : [] } : null; }, }); const payload = res.success && res.html ? parseBrowsePage(res.html, url, seed, page) : null; if (!payload || payload.works.length === 0) { ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } count++; yield { url, externalId: `browse:${seed.path}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } } } async lookup(url: string, ctx: CrawlContext): Promise { const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0 }); if (!res.success || !res.html) return []; const data = H.nextData(res.html) as { props?: { pageProps?: { initialState?: { page?: { data?: { pdpArtwork?: Record; artwork?: Record } } } } } } | null; const a = data?.props?.pageProps?.initialState?.page?.data?.pdpArtwork ?? data?.props?.pageProps?.initialState?.page?.data?.artwork; if (!a) return []; const dims = (a.dimensions as { widthInCentimeters?: number; heightInCentimeters?: number; depthInCentimeters?: number; width?: number; height?: number; depth?: number } | undefined) ?? {}; const artist = (a.artist as { firstName?: string; lastName?: string; id?: number } | undefined) ?? {}; const work: z.infer = { artworkId: String(a.artworkId ?? a.id ?? url.match(/\/(\d+)\/view/)?.[1] ?? ''), url, title: String(a.title ?? a.artworkTitle ?? ''), artistFirstName: artist.firstName ?? (a.artistFirstName ? String(a.artistFirstName) : null), artistLastName: artist.lastName ?? (a.artistLastName ? String(a.artistLastName) : null), artistId: artist.id ?? null, category: a.category ? String(a.category) : null, mediums: Array.isArray(a.mediums) ? (a.mediums as unknown[]).map(String) : [], materials: Array.isArray(a.materials) ? (a.materials as unknown[]).map(String) : [], styles: Array.isArray(a.styles) ? (a.styles as unknown[]).map(String) : [], subject: a.subject ? String(a.subject) : null, widthCm: dims.widthInCentimeters ?? dims.width ?? null, heightCm: dims.heightInCentimeters ?? dims.height ?? null, depthCm: dims.depthInCentimeters ?? dims.depth ?? null, listPriceCents: typeof a.listPriceInCents === 'number' ? a.listPriceInCents : typeof a.price === 'number' ? Math.round(a.price * 100) : null, status: a.originalArtworkStatus ? String(a.originalArtworkStatus) : null, sku: a.sku ? String(a.sku) : null, image: a.imageUrl ? String(a.imageUrl) : null, countryName: null, uploadedAt: null, }; if (!work.artworkId || !work.title) return []; const payload: PagePayload = { kind: 'browse_page', url, seed: { path: 'lookup', category: work.category?.toLowerCase() === 'photography' ? 'photography' : 'contemporary_art' }, page: 0, totalResults: null, works: [work] }; return [{ url, externalId: `artwork:${work.artworkId}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; for (const w of p.works) { const artist = [w.artistFirstName, w.artistLastName].filter(Boolean).map((s) => titleCase(String(s))).join(' ') || null; const size = w.widthCm && w.heightCm ? `${w.widthCm} × ${w.heightCm}${w.depthCm ? ` × ${w.depthCm}` : ''} cm` : null; const categorySlug = w.category?.toLowerCase() === 'photography' ? 'photography' : p.seed.category; const attributes = AssetAttributesSchema.parse({ categorySlug, brand: artist, name: w.title, material: [...w.mediums, ...w.materials].join(', ') || null, size, country: w.countryName ? titleCase(w.countryName) : null, identifiers: { saatchi_artwork_id: w.artworkId, ...(w.sku ? { saatchi_sku: w.sku } : {}) }, metadata: { category: w.category, styles: w.styles, subject: w.subject, artist_id: w.artistId, uploaded_at: w.uploadedAt ? new Date(w.uploadedAt * 1000).toISOString() : null, unique_original: true }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: w.url, externalId: w.artworkId, rawTitle: artist ? `${artist} — ${w.title}` : w.title, description: null, imageUrls: w.image ? [w.image] : [], attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, observedAt: raw.fetchedAt, confidence: 0.85, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: w.listPriceCents !== null ? w.listPriceCents / 100 : null, currency: w.listPriceCents !== null ? 'USD' : null, seller: artist, location: w.countryName ? titleCase(w.countryName) : null, quantity: 1, listedAt: w.uploadedAt ? new Date(w.uploadedAt * 1000) : null, availability: w.status === 'sold' ? 'sold' : w.status === 'avail' ? 'available' : 'unknown', }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new SaatchiArtConnector(meta); }