import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedListingSchema, NormalizedPriceObservationSchema, type NormalizedRecord } from '@rareindex/shared'; /** * Novelship — sneaker catalog with last sale / lowest ask read from the server-rendered * (React Server Components) payload of public browse pages. One raw record per browse page. */ const BASE = 'https://novelship.com'; const PARSER_VERSION = '1.0.0'; export const ProductSchema = z.object({ id: z.number(), name: z.string(), nameSlug: z.string(), sku: z.string().nullable(), mainBrand: z.string().nullable(), subBrand: z.string().nullable(), colorway: z.string().nullable(), category: z.string().nullable(), gender: z.string().nullable(), dropDate: z.string().nullable(), costRetail: z.number().nullable(), lastSalePrice: z.number().nullable(), lowestListingPrice: z.number().nullable(), salesCount180: z.number().nullable(), image: z.string().nullable(), }); export type Product = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('browse_page'), url: z.string(), seed: z.string(), page: z.number(), products: z.array(ProductSchema) }); export type PagePayload = z.infer; const VALUE_RE = '("(?:[^"\\\\]|\\\\.)*"|-?[0-9.]+|null|true|false)'; function decode(v: string | undefined): string | null { if (v === undefined || v === 'null') return null; if (v.startsWith('"')) return JSON.parse(v.replace(/\\u0026/g, '&')) as string; return v; } /** Last occurrence of `"key":value` in `seg` (used for keys serialised before name_slug). */ function pickLast(seg: string, key: string): string | null { const re = new RegExp(`"${key}":${VALUE_RE}`, 'g'); let last: string | undefined; for (const m of seg.matchAll(re)) last = m[1]; return decode(last); } /** First occurrence of `"key":value` in `seg` (used for keys serialised after name_slug). */ function pickFirst(seg: string, key: string): string | null { const m = seg.match(new RegExp(`"${key}":${VALUE_RE}`)); return decode(m?.[1]); } function numOf(v: string | null): number | null { if (v === null) return null; const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; } /** * Extract product objects from the RSC payload embedded in the HTML. Browse pages serialise * product keys alphabetically (keys < "name_slug" precede it, keys > follow it), product pages * keep insertion order; both are covered by looking on the matching side first. */ export function parseBrowsePage(htmlText: string, url: string, seed: string, page: number): PagePayload { const s = htmlText.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); const products = new Map(); const hits = [...s.matchAll(/"name_slug":"([a-z0-9-]+)"/g)]; for (let i = 0; i < hits.length; i++) { const h = hits[i]!; const slug = h[1]!; const at = h.index!; const prevEnd = i > 0 ? hits[i - 1]!.index! + hits[i - 1]![0].length : Math.max(0, at - 12000); const nextStart = i + 1 < hits.length ? hits[i + 1]!.index! : Math.min(s.length, at + 12000); const before = s.slice(Math.max(prevEnd, at - 12000), at); const after = s.slice(at + h[0].length, Math.min(nextStart, at + 12000)); const get = (key: string) => (key < 'name_slug' ? (pickLast(before, key) ?? pickFirst(after, key)) : (pickFirst(after, key) ?? pickLast(before, key))); const id = Number(get('id')); const name = get('name'); if (!Number.isFinite(id) || !name || products.has(slug)) continue; const sales = get('sales_count_180'); const p: Product = { id, name, nameSlug: slug, sku: get('sku'), mainBrand: get('main_brand'), subBrand: get('sub_brand') || null, colorway: get('colorway'), category: get('category'), gender: get('gender'), dropDate: get('drop_date'), costRetail: numOf(get('cost_retail')), lastSalePrice: numOf(get('last_sale_price')), lowestListingPrice: numOf(get('lowest_listing_price')), salesCount180: sales === null ? null : Number(sales), image: get('image'), }; if (p.sku || p.lastSalePrice || p.lowestListingPrice) products.set(slug, p); } return { kind: 'browse_page', url, seed, page, products: [...products.values()] }; } export function brandCategory(brand: string | null, name: string): string { const b = `${brand ?? ''} ${name}`.toLowerCase(); if (/jordan|nike/.test(b)) return 'nike_jordan'; if (/adidas|yeezy/.test(b)) return 'adidas_yeezy'; return 'new_balance_asics_other'; } export class NovelshipConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; override readonly urlPatterns = [/^https?:\/\/(www\.)?novelship\.com\/([a-z0-9-]+)$/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; const pages = Number(this.meta.config.pagesPerSeed ?? 3); let count = 0; for (const seed of seeds) { for (let page = 1; page <= pages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${BASE}/sneakers/${seed}${page > 1 ? `?page=${page}` : ''}`; await this.throttle(); const res = await ctx.fetch(url, { responseType: 'text', expect: ['title', 'price', 'identifiers'], parse: (r) => { const p = r.html ? parseBrowsePage(r.html, url, seed, page) : null; const first = p?.products[0]; return first ? { title: first.name, price: first.lastSalePrice ?? first.lowestListingPrice, identifiers: first.sku ? { sku: first.sku } : null } : null; }, }); const payload = res.success && res.html ? parseBrowsePage(res.html, url, seed, page) : null; if (!payload || payload.products.length === 0) { ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } count++; yield { url, externalId: `browse:${seed}:${page}`, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } } } async lookup(url: string, ctx: CrawlContext): Promise { const slug = url.match(this.urlPatterns[0]!)?.[2]; if (!slug || ['sneakers', 'apparel', 'collectibles', 'browse'].includes(slug)) return []; await this.throttle(); const res = await ctx.fetch(`${BASE}/${slug}`, { responseType: 'text', minQuality: 0.2 }); if (!res.success || !res.html) return []; const payload = parseBrowsePage(res.html, `${BASE}/${slug}`, `product:${slug}`, 1); payload.products = payload.products.filter((p) => p.nameSlug === slug); if (!payload.products.length) return []; return [{ url: `${BASE}/${slug}`, externalId: `product:${slug}`, kind: 'catalog_item', 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 pr of p.products) { const year = pr.dropDate?.match(/^(\d{4})/)?.[1]; const attributes = AssetAttributesSchema.parse({ categorySlug: brandCategory(pr.mainBrand, pr.name), brand: pr.mainBrand, series: pr.subBrand, name: pr.name.replace(/\s+[A-Z0-9]{2,}-?[A-Z0-9]{2,}$/i, (m0) => (pr.sku && m0.trim() === pr.sku ? '' : m0)).trim(), color: pr.colorway, year: year ? Number(year) : null, originalMsrp: pr.costRetail, originalMsrpCurrency: pr.costRetail ? 'USD' : null, identifiers: { ...(pr.sku ? { style_code: pr.sku } : {}), novelship_id: String(pr.id) }, metadata: { gender: pr.gender, category: pr.category, drop_date: pr.dropDate, sales_count_180: pr.salesCount180 }, }); const sourceUrl = `${BASE}/${pr.nameSlug}`; const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, rawTitle: pr.name, imageUrls: pr.image ? [pr.image] : [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: `product:${pr.id}`, confidence: 0.9, releaseDate: pr.dropDate && /^\d{4}-\d{2}-\d{2}/.test(pr.dropDate) ? new Date(`${pr.dropDate.slice(0, 10)}T00:00:00Z`) : null })); const cond = { condition: 'new', conditionRaw: 'Brand new (marketplace standard)', completeness: 'with_box' }; if (pr.lastSalePrice) { out.push(NormalizedPriceObservationSchema.parse({ kind: 'price_observation', ...base, externalId: `product:${pr.id}:last_sale`, confidence: 0.7, condition: cond, priceKind: 'last_sale_reported', price: pr.lastSalePrice, currency: 'USD', observationDate: raw.fetchedAt, sampleSize: pr.salesCount180 })); } if (pr.lowestListingPrice) { out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: `product:${pr.id}:lowest_ask`, confidence: 0.7, condition: cond, listingType: 'ask', price: pr.lowestListingPrice, currency: 'USD', seller: null, availability: 'available', listedAt: null })); } } return out; } } export default function createConnector(meta: ConnectorMeta) { return new NovelshipConnector(meta); }