SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
7.9 KB · 146 lines typescript
Raw Blame History
1import { z } from 'zod';2import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { normalizeCondition } from '@rareindex/taxonomy';4import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';5import { ShopifyProductSchema, bagHardware, bagMaterial, bagModel, bagSize, caseSize, fetchShopifyPage, moneyNumber, trimShopifyProduct, watchCategory, watchMaterial, watchReferenceFromText } from '../_luxury-lib/index.js';67/** Rebag — Shopify feed of individually graded pre-owned luxury items (USD). */8const BASE = 'https://shop.rebag.com';9const PARSER_VERSION = '1.0.0';1011export const PagePayloadSchema = z.object({ kind: z.literal('shopify_page'), url: z.string(), seed: z.string(), page: z.number(), products: z.array(ShopifyProductSchema) });12export type PagePayload = z.infer<typeof PagePayloadSchema>;1314const CONDITION_MAP: Record<string, string> = { pristine: 'pristine', excellent: 'excellent', great: 'very_good', 'very good': 'very_good', good: 'good', fair: 'fair', new: 'new' };1516export function categoryFor(productType: string | null | undefined, vendor: string | null | undefined, tags: string[]): string {17  const t = (productType ?? '').toLowerCase();18  const tagStr = tags.join(' ').toLowerCase();19  if (/watch/.test(t) || /bc-filter-watches/.test(tagStr)) return watchCategory(vendor);20  if (/jewel|ring|bracelet|necklace|earring|brooch|pendant|charm/.test(t)) return 'jewelry';21  if (/bag|tote|clutch|satchel|backpack|wallet|pouch|luggage|handle|hobo|crossbody|shoulder|belt bag|case|small leather/.test(t)) return 'luxury_handbags';22  if (/shoe|sneaker|boot|sandal|heel|pump|loafer|apparel|clothing|scarf|shawl|belt|hat|sunglass|dress|jacket|coat|\btop\b|pant|skirt|sweater/.test(t)) return 'fashion_streetwear';23  return 'luxury_handbags';24}2526/** "Great | Item # 417505/4 / Blue" → { grade: 'Great', color: 'Blue' } */27export function parseVariantTitle(title: string): { grade: string | null; color: string | null } {28  const parts = title.split('|').map((s) => s.trim());29  const grade = parts[0] && !/item/i.test(parts[0]) ? parts[0] : null;30  const colorPart = parts[parts.length - 1] ?? '';31  const color = colorPart.includes('/') ? (colorPart.split('/').pop()?.trim() ?? null) : null;32  return { grade, color: color || null };33}3435export function retailFromBody(body: string | null | undefined): number | null {36  const m = (body ?? '').replace(/<[^>]+>/g, ' ').match(/Estimated Retail Price:\s*\$?\s*([0-9,]+(?:\.\d+)?)/i);37  return m ? moneyNumber(m[1]) : null;38}3940export class RebagConnector extends BaseConnector {41  readonly version = '1.0.0';42  readonly parserVersion = PARSER_VERSION;43  protected override minIntervalMs = 1500;44  override readonly urlPatterns = [/^https?:\/\/(shop\.|www\.)?rebag\.com\/products\/([a-z0-9-]+)/i];4546  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {47    const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? [];48    const pages = ctx.options.mode === 'backfill' ? 20 : Number(this.meta.config.pagesPerSeed ?? 2);49    let count = 0;50    for (const seed of seeds) {51      for (let page = 1; page <= pages; page++) {52        if (ctx.signal?.aborted || this.reached(ctx, count)) return;53        await this.throttle();54        const { products, res } = await fetchShopifyPage(ctx, BASE, `/collections/${seed}/products.json`, page);55        if (!res.success) {56          ctx.anomaly('page_fetch_failed', `${seed} p${page}: ${res.error ?? res.httpStatus}`);57          break;58        }59        if (products.length === 0) break;60        count++;61        const payload: PagePayload = { kind: 'shopify_page', url: `${BASE}/collections/${seed}/products.json?page=${page}`, seed, page, products: products.map(trimShopifyProduct) };62        yield { url: payload.url, externalId: `collection:${seed}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };63        if (products.length < 250) break;64      }65    }66  }6768  async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {69    const handle = url.match(this.urlPatterns[0]!)?.[2];70    if (!handle) return [];71    await this.throttle();72    const res = await ctx.fetch(`${BASE}/products/${handle}.json`, { engines: ['api'], responseType: 'json', minQuality: 0.2 });73    const parsed = z.object({ product: ShopifyProductSchema }).safeParse(res.json);74    if (!res.success || !parsed.success) return [];75    const payload: PagePayload = { kind: 'shopify_page', url: `${BASE}/products/${handle}`, seed: `product:${handle}`, page: 1, products: [trimShopifyProduct(parsed.data.product)] };76    return [{ url: payload.url, externalId: `product:${handle}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }];77  }7879  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {80    const p = PagePayloadSchema.parse(raw.payload);81    const out: NormalizedRecord[] = [];82    for (const pr of p.products) {83      const v = pr.variants[0];84      if (!v) continue;85      const price = moneyNumber(v.price);86      if (!price) continue;87      const brand = pr.vendor ?? null;88      const tags = pr.tags ?? [];89      const categorySlug = categoryFor(pr.product_type, brand, tags);90      const isWatch = ['rolex', 'patek_philippe', 'audemars_piguet', 'omega', 'other_watches'].includes(categorySlug);91      const title = pr.title.replace(/\s+/g, ' ').trim();92      const { grade, color } = parseVariantTitle(v.title);93      const conditionRaw = grade;94      const condition = grade ? (CONDITION_MAP[grade.toLowerCase()] ?? normalizeCondition(categorySlug, grade)) : null;95      const ref = isWatch ? watchReferenceFromText(`${title} ${pr.body_html ?? ''}`) : null;96      const retail = retailFromBody(pr.body_html);97      const attributes = AssetAttributesSchema.parse({98        categorySlug,99        brand,100        name: brand ? `${brand} ${title}` : title,101        model: isWatch ? null : bagModel(title),102        reference: ref,103        material: isWatch ? watchMaterial(title) : bagMaterial(title),104        size: isWatch ? caseSize(title) : bagSize(title),105        color,106        originalMsrp: retail,107        originalMsrpCurrency: retail ? 'USD' : null,108        identifiers: { rebag_item: v.sku ?? String(pr.id), ...(ref ? { reference: ref } : {}) },109        metadata: { product_type: pr.product_type, hardware: bagHardware(title), filters: tags.filter((t) => t.startsWith('bc-filter-')).map((t) => t.slice(10)).slice(0, 15) },110      });111      const accessories = (pr.body_html ?? '').match(/Accessories:\s*([^.]{0,80})/i)?.[1]?.trim() ?? null;112      const completeness = accessories ? (/no accessories/i.test(accessories) ? 'item_only' : /box/i.test(accessories) && /dust ?bag|card|receipt/i.test(accessories) ? 'full_set' : /dust ?bag/i.test(accessories) ? 'dust_bag' : /box/i.test(accessories) ? 'box' : null) : null;113      out.push(114        NormalizedListingSchema.parse({115          kind: 'listing',116          connectorId: this.meta.id,117          sourceId: this.meta.sourceId,118          sourceUrl: `${BASE}/products/${pr.handle}`,119          externalId: String(pr.id),120          rawTitle: title,121          description: pr.body_html ?? null,122          imageUrls: (pr.images ?? []).map((i) => i.src),123          attributes,124          condition: { condition, conditionRaw, completeness },125          observedAt: raw.fetchedAt,126          confidence: 0.85,127          parserVersion: PARSER_VERSION,128          listingType: 'fixed_price',129          price,130          currency: 'USD',131          seller: 'Rebag',132          location: 'US',133          quantity: 1,134          listedAt: pr.published_at ? new Date(pr.published_at) : null,135          availability: v.available ? 'available' : 'sold',136        }),137      );138    }139    return out;140  }141}142143export default function createConnector(meta: ConnectorMeta) {144  return new RebagConnector(meta);145}146