import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord } from '@rareindex/shared'; import { isBundleTitle, safeYear } from '../../api/_auction-lib/categories.js'; import { gradeOf, lotAttributes, money, selectedOption, sportsCategory } from '../../api/_memorabilia-lib/index.js'; import { dateMDY, makeSale } from '../_carlib/index.js'; const BASE = 'https://auction.lelands.com'; const PARSER_VERSION = '1.1.0'; export const ItemSchema = z.object({ itemId: z.string(), title: z.string(), url: z.string(), image: z.string().nullable(), bids: z.number().nullable(), openingBid: z.number().nullable(), status: z.string().nullable(), soldPrice: z.number().nullable(), lotNumber: z.string().nullable() }); export const PayloadSchema = z.object({ kind: z.literal('gallery_page'), auction: z.object({ id: z.string().nullable(), name: z.string(), startText: z.string().nullable(), endText: z.string().nullable(), premiumIncluded: z.boolean() }), page: z.number(), totalPages: z.number().nullable(), items: z.array(ItemSchema) }); export type Payload = z.infer; export function parseGallery(htmlText: string, page: number): Payload { const $ = H.load(htmlText); const side = $('.sidebar-widget').first(); const name = H.text(side.find('h5.title')) ?? ''; const sideText = H.text(side) ?? ''; const startText = sideText.match(/Start:\s*(\d{1,2}\/\d{1,2}\/\d{4}[^E]*E[SD]T)/)?.[1] ?? null; const endText = sideText.match(/End:\s*(\d{1,2}\/\d{1,2}\/\d{4}[^E]*E[SD]T)/)?.[1] ?? null; const premiumIncluded = /Prices Shown Include Buyer'?s Premium/i.test(sideText); const items: Payload['items'] = []; const seen = new Set(); $('.item').each((_, el) => { const it = $(el); const link = it.find('p.description a').first(); const href = link.attr('href'); const title = H.text(link); const itemId = href?.match(/itemid=(\d+)/i)?.[1]; if (!href || !title || !itemId || seen.has(itemId)) return; seen.add(itemId); const meta = H.text(it.find('.item-details > p').last()) ?? ''; const bids = meta.match(/Bids:\s*(\d+)/)?.[1]; const opening = meta.match(/Opening Bid:\s*\$([\d,]+)/)?.[1]; const status = meta.match(/Status:\s*([A-Za-z ]+)/)?.[1]?.trim() ?? null; const priceText = H.text(it.find('.item-price')) ?? ''; const sold = priceText.match(/SOLD FOR\s*\$([\d,]+(?:\.\d+)?)/i)?.[1]; items.push({ itemId, title, url: `${BASE}/bids/bidplace.aspx?itemid=${itemId}`, image: it.find('.item-image img').attr('src') ?? null, bids: bids ? Number(bids) : null, openingBid: opening ? money(`$${opening}`, 'USD')?.amount ?? null : null, status, soldPrice: sold ? money(`$${sold}`, 'USD')?.amount ?? null : null, lotNumber: H.text(it.find('h5.boxed')) }); }); const pages = $('ul.pagination a[href*="page="]') .map((_, a) => Number(($(a).attr('href') ?? '').match(/page=(\d+)/)?.[1] ?? 0)) .get() .filter((n) => n > 0); return { kind: 'gallery_page', auction: { id: selectedOption(htmlText, 'ctl00$Auction'), name, startText, endText, premiumIncluded }, page, totalPages: pages.length ? Math.max(...pages) : null, items }; } /** * Lelands (sports memorabilia & cards). The public gallery auction.lelands.com/Lots/Gallery?size=250&page=N * lists the currently displayed auction's lots with "SOLD FOR $X"; the sidebar gives start/end and states that * prices include the buyer's premium. Direct requests receive a Cloudflare 403, so the pages are rendered * through Firecrawl (a browser render of the public page — no login, no bidding). Past auctions are only * reachable through a form postback, which is not attempted; each auction is captured while it is the * displayed one (lots stay marked SOLD after the close). */ export class LelandsConnector extends BaseConnector { readonly version = '1.1.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2500; async *crawl(ctx: CrawlContext): AsyncIterable { const maxPages = Number(this.meta.config.pagesPerRun ?? 5); const doneAuctions = new Set(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []); let count = 0; let auctionId: string | null = null; for (let page = 1; page <= maxPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) break; const url = `${BASE}/Lots/Gallery?size=250${page > 1 ? `&page=${page}` : ''}`; await this.throttle(); const res = await ctx.fetch(url, { engines: ['firecrawl'], expect: ['title', 'price', 'status', 'date'], parse: (r) => { const p = r.html ? parseGallery(r.html, page) : null; const f = p?.items.find((i) => i.soldPrice); return p ? { title: f?.title ?? p.items[0]?.title ?? null, price: f?.soldPrice ?? null, status: f?.status ?? null, date: p.auction.endText } : null; } }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = parseGallery(res.html, page); if (page === 1) { auctionId = payload.auction.id; if (!payload.auction.endText) ctx.anomaly('missing_auction_end', payload.auction.name); // Closed auctions are crawled once; the live one is refreshed each run. if (auctionId && doneAuctions.has(auctionId)) break; } if (payload.items.length === 0) break; count++; yield { url, externalId: `auction:${payload.auction.id ?? 'current'}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; if (payload.totalPages !== null && page >= payload.totalPages) { const end = dateMDY(payload.auction.endText); if (auctionId && end && end.getTime() < Date.now()) doneAuctions.add(auctionId); break; } } await ctx.setCursor({ doneAuctions: [...doneAuctions].slice(-100), updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const saleDate = dateMDY(p.auction.endText); const out: NormalizedRecord[] = []; if (!saleDate || saleDate.getTime() > Date.now()) return out; // auction still open → no realized prices yet for (const it of p.items) { if (it.soldPrice === null || it.soldPrice <= 0 || !/sold/i.test(it.status ?? '')) continue; const g = gradeOf(it.title); const attributes = lotAttributes({ categorySlug: sportsCategory(it.title), name: it.title, year: safeYear(it.title), identifiers: { lelands_item_id: it.itemId }, metadata: { auction_id: p.auction.id, auction_name: p.auction.name, bids: it.bids, opening_bid: it.openingBid } }); out.push(makeSale({ meta: this.meta, sourceUrl: it.url, externalId: it.itemId, rawTitle: it.title, attributes, price: it.soldPrice, currency: 'USD', saleDate, buyerPremiumIncluded: p.auction.premiumIncluded ? true : null, auctionHouse: 'Lelands', lotNumber: it.lotNumber, imageUrls: it.image ? [it.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade, location: 'US', isBundle: isBundleTitle(it.title) })); } return out; } } export default (meta: ConnectorMeta) => new LelandsConnector(meta);