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%
5.6 KB · 118 lines typescript
Raw Blame History
1import { z } from 'zod';2import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared';4import { brandFromName, compactMoney, fetchFirecrawl, stripBrand } from '../../api/_wlib/index.js';56/**7 * MPB — used camera & lens retailer (US/UK/EU storefronts). Category pages render client-side;8 * Firecrawl returns markdown cards "**Name** · N available, $min-$max" linking to the model page.9 * Prices are dealer asks for the cheapest unit of each model (range kept in metadata).10 */11const BASE = 'https://www.mpb.com';12const PARSER_VERSION = '1.0.0';1314export const ModelSchema = z.object({15  name: z.string(),16  url: z.string(),17  slug: z.string(),18  available: z.number().nullable(),19  priceMin: z.number().nullable(),20  priceMax: z.number().nullable(),21  currency: z.string(),22  image: z.string().nullable(),23});24export type Model = z.infer<typeof ModelSchema>;25export const PagePayloadSchema = z.object({ kind: z.literal('category_page'), url: z.string(), market: z.string(), category: z.string(), total: z.number().nullable(), models: z.array(ModelSchema) });26export type PagePayload = z.infer<typeof PagePayloadSchema>;2728const CURRENCY: Record<string, string> = { 'en-us': 'USD', 'en-uk': 'GBP', 'en-eu': 'EUR', 'de-de': 'EUR', 'fr-fr': 'EUR', 'nl-nl': 'EUR', 'es-es': 'EUR', 'it-it': 'EUR' };2930export function parseCategoryMarkdown(md: string, url: string): PagePayload {31  const market = url.match(/mpb\.com\/([a-z]{2}-[a-z]{2})\//)?.[1] ?? 'en-us';32  const category = url.match(/\/category\/(.+?)(?:[?#]|$)/)?.[1] ?? url;33  const currency = CURRENCY[market] ?? 'USD';34  const models: Model[] = [];35  const seen = new Set<string>();36  const re = /\[!\[([^\]]*)\]\(([^)\s]+)\)[^\]]*?\*\*([^*]+)\*\*[^\]]*?(\d+\+?|10\+)\s*available,\s*([$£€][\d,]+)(?:\s*-\s*([$£€][\d,]+))?\]\((https?:\/\/[^)\s]+\/product\/([a-z0-9-]+)[^)\s]*)\)/g;37  for (const m of md.matchAll(re)) {38    const slug = m[8]!;39    if (seen.has(slug)) continue;40    seen.add(slug);41    models.push({ name: m[3]!.replace(/\s+/g, ' ').trim(), url: m[7]!.split('?')[0]!, slug, available: Number(m[4]!.replace('+', '')) || null, priceMin: compactMoney(m[5]!), priceMax: m[6] ? compactMoney(m[6]) : compactMoney(m[5]!), currency, image: m[2] ?? null });42  }43  const total = md.match(/Showing\s+\d+\s+of\s+(\d+)\s+results/i)?.[1];44  return { kind: 'category_page', url, market, category, total: total ? Number(total) : null, models };45}4647export class MpbConnector extends BaseConnector {48  readonly version = '1.0.0';49  readonly parserVersion = PARSER_VERSION;50  protected override minIntervalMs = 3000;5152  async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {53    const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? [];54    let count = 0;55    const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0;56    for (let i = start; i < seeds.length; i++) {57      const seed = seeds[i]!;58      if (ctx.signal?.aborted || this.reached(ctx, count)) return;59      const url = seed.startsWith('http') ? seed : `${BASE}${seed}`;60      await this.throttle();61      const res = await fetchFirecrawl(ctx, url, { timeoutMs: 90_000, waitForMs: 2500, parse: (r) => (r.markdown ? parseCategoryMarkdown(r.markdown, url).models.length : 0) });62      const payload = res.success && res.markdown ? parseCategoryMarkdown(res.markdown, url) : null;63      if (!payload?.models.length) {64        ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no model cards'}`);65        continue;66      }67      count++;68      yield { url, externalId: `${payload.market}:${payload.category}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt };69      await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() });70    }71  }7273  async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {74    const p = PagePayloadSchema.parse(raw.payload);75    const out: NormalizedRecord[] = [];76    for (const m of p.models) {77      if (!m.priceMin) continue;78      const brand = brandFromName(m.name);79      const attributes = AssetAttributesSchema.parse({80        categorySlug: 'cameras',81        brand,82        model: stripBrand(m.name, brand),83        name: m.name,84        identifiers: { mpb_model: m.slug },85        metadata: { units_available: m.available, price_max: m.priceMax, market: p.market, mpb_category: p.category },86      });87      out.push(88        NormalizedListingSchema.parse({89          kind: 'listing',90          connectorId: this.meta.id,91          sourceId: this.meta.sourceId,92          sourceUrl: m.url,93          externalId: `${p.market}:${m.slug}`,94          rawTitle: m.name,95          imageUrls: m.image ? [m.image] : [],96          attributes,97          condition: { condition: null, conditionRaw: 'MPB graded per unit (Like New / Excellent / Good / Well Used) — cheapest unit shown', completeness: null },98          observedAt: raw.fetchedAt,99          confidence: 0.75,100          parserVersion: PARSER_VERSION,101          listingType: 'fixed_price',102          price: m.priceMin,103          currency: m.currency,104          seller: 'MPB',105          location: p.market.split('-')[1]?.toUpperCase() === 'UK' ? 'GB' : p.market.split('-')[1]?.toUpperCase() ?? null,106          quantity: m.available,107          availability: 'available',108        }),109      );110    }111    return out;112  }113}114115export default function createConnector(meta: ConnectorMeta) {116  return new MpbConnector(meta);117}118