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%
6.9 KB · 130 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, yearFrom } from '../_luxury-lib/index.js';67/**8 * FASHIONPHILE — one Shopify product = one authenticated pre-owned item. Listings (asking prices in9 * USD) plus sold state (`available:false` on a published product = the site shows "SOLD").10 */11const BASE = 'https://www.fashionphile.com';12const PARSER_VERSION = '1.0.0';1314export const PagePayloadSchema = z.object({ kind: z.literal('shopify_page'), url: z.string(), seed: z.string(), page: z.number(), products: z.array(ShopifyProductSchema) });15export type PagePayload = z.infer<typeof PagePayloadSchema>;1617export function categoryFor(productType: string | null | undefined, vendor: string | null | undefined, title: string): string {18  const t = (productType ?? '').toLowerCase();19  const l = title.toLowerCase();20  if (/watch/.test(t) || /\bwatch\b/.test(l)) return watchCategory(vendor);21  if (/jewel|ring|bracelet|necklace|earring|brooch|pendant/.test(t)) return 'jewelry';22  if (/bag|wallet|clutch|tote|backpack|pouch|luggage|small leather/.test(t)) return 'luxury_handbags';23  if (/shoe|sneaker|boot|sandal|heel|pump|loafer|apparel|clothing|scarf|belt|hat|sunglass|jacket|coat|dress/.test(t)) return 'fashion_streetwear';24  if (/\b(sandals?|pumps?|loafers?|sneakers?|boots?|heels?|mules?|flats?|espadrilles?|slides?)\b/.test(l) || /\b(muffler|scarf|shawl|stole|belt|sunglasses|hat|cap|jacket|coat|dress|sweater|cardigan|t-shirt|shirt)\b/.test(l)) return 'fashion_streetwear';25  return 'luxury_handbags';26}2728export class FashionphileConnector extends BaseConnector {29  readonly version = '1.0.0';30  readonly parserVersion = PARSER_VERSION;31  protected override minIntervalMs = 1500;32  override readonly urlPatterns = [/^https?:\/\/(www\.)?fashionphile\.com\/products\/([a-z0-9-]+)/i];3334  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {35    const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? [];36    const pages = ctx.options.mode === 'backfill' ? 20 : Number(this.meta.config.pagesPerSeed ?? 2);37    let count = 0;38    for (const seed of seeds) {39      for (let page = 1; page <= pages; page++) {40        if (ctx.signal?.aborted || this.reached(ctx, count)) return;41        await this.throttle();42        const { products, res } = await fetchShopifyPage(ctx, BASE, `/collections/${seed}/products.json`, page);43        if (!res.success) {44          ctx.anomaly('page_fetch_failed', `${seed} p${page}: ${res.error ?? res.httpStatus}`);45          break;46        }47        if (products.length === 0) break;48        count++;49        const payload: PagePayload = { kind: 'shopify_page', url: `${BASE}/collections/${seed}/products.json?page=${page}`, seed, page, products: products.map(trimShopifyProduct) };50        yield { url: payload.url, externalId: `collection:${seed}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };51        if (products.length < 250) break;52      }53    }54  }5556  async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {57    const handle = url.match(this.urlPatterns[0]!)?.[2];58    if (!handle) return [];59    await this.throttle();60    const res = await ctx.fetch(`${BASE}/products/${handle}.json`, { engines: ['api'], responseType: 'json', minQuality: 0.2 });61    const parsed = z.object({ product: ShopifyProductSchema }).safeParse(res.json);62    if (!res.success || !parsed.success) return [];63    const payload: PagePayload = { kind: 'shopify_page', url: `${BASE}/products/${handle}`, seed: `product:${handle}`, page: 1, products: [trimShopifyProduct(parsed.data.product)] };64    return [{ url: payload.url, externalId: `product:${handle}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }];65  }6667  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {68    const p = PagePayloadSchema.parse(raw.payload);69    const out: NormalizedRecord[] = [];70    for (const pr of p.products) {71      const v = pr.variants[0];72      if (!v) continue;73      const price = moneyNumber(v.price);74      if (!price) continue;75      const brand = pr.vendor ?? null;76      const categorySlug = categoryFor(pr.product_type, brand, pr.title);77      const isWatch = categorySlug.endsWith('watches') || categorySlug === 'rolex' || categorySlug === 'patek_philippe' || categorySlug === 'audemars_piguet' || categorySlug === 'omega';78      const title = pr.title.replace(/\s+/g, ' ').trim();79      const ref = isWatch ? watchReferenceFromText(title) : null;80      const model = isWatch ? null : bagModel(title);81      const attributes = AssetAttributesSchema.parse({82        categorySlug,83        brand,84        name: brand ? `${brand} ${title}` : title,85        model,86        reference: ref,87        year: yearFrom(pr.body_html ?? '') ?? null,88        material: isWatch ? watchMaterial(title) : bagMaterial(title),89        size: isWatch ? caseSize(title) : bagSize(title),90        color: null,91        identifiers: { fashionphile_sku: v.sku ?? String(pr.id), ...(ref ? { reference: ref } : {}) },92        metadata: { product_type: pr.product_type, hardware: bagHardware(title), tags: pr.tags?.slice(0, 12) ?? [], compare_at_price: moneyNumber(v.compare_at_price) },93      });94      const sourceUrl = `${BASE}/products/${pr.handle}`;95      const availability = pr.published_at ? (v.available ? 'available' : 'sold') : 'removed';96      const listedAt = pr.published_at ? new Date(pr.published_at) : null;97      out.push(98        NormalizedListingSchema.parse({99          kind: 'listing',100          connectorId: this.meta.id,101          sourceId: this.meta.sourceId,102          sourceUrl,103          externalId: String(pr.id),104          rawTitle: title,105          description: pr.body_html ?? null,106          imageUrls: (pr.images ?? []).map((i) => i.src),107          attributes,108          condition: { condition: normalizeCondition(categorySlug, null), conditionRaw: null, completeness: null },109          observedAt: raw.fetchedAt,110          confidence: 0.85,111          parserVersion: PARSER_VERSION,112          listingType: 'fixed_price',113          price,114          currency: 'USD',115          seller: 'FASHIONPHILE',116          location: 'US',117          quantity: 1,118          listedAt: listedAt && !Number.isNaN(listedAt.getTime()) ? listedAt : null,119          availability,120        }),121      );122    }123    return out;124  }125}126127export default function createConnector(meta: ConnectorMeta) {128  return new FashionphileConnector(meta);129}130