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 '../_auction-lib/categories.js'; import { gradeOf, hiddenFields, httpText, lotAttributes, money, morphyCategory, selectedOption } from '../_memorabilia-lib/index.js'; import { dateWords, makeSale } from '../../firecrawl/_carlib/index.js'; const WP = 'https://morphyauctions.com'; const CAT = 'https://auctions.morphyauctions.com'; const PARSER_VERSION = '1.0.0'; export const AuctionSchema = z.object({ id: z.string(), title: z.string(), dept: z.string().nullable(), dateText: z.string().nullable(), pageUrl: z.string().nullable() }); export const LotSchema = z.object({ lotNumber: z.string().nullable(), title: z.string(), url: z.string(), image: z.string().nullable(), finalPrice: z.number().nullable(), minBid: z.number().nullable(), estimateText: z.string().nullable(), bids: z.number().nullable() }); export const PayloadSchema = z.object({ kind: z.literal('catalog_page'), auction: AuctionSchema, page: z.number(), totalPages: z.number().nullable(), lots: z.array(LotSchema) }); export type Payload = z.infer; /** WordPress past-auction list: department label, title, date and the catalog id. */ export function parsePastList(htmlText: string): z.infer[] { const $ = H.load(htmlText); const out: z.infer[] = []; $('article.event').each((_, el) => { const a = $(el); const link = a.find('a[href*="catalog.aspx?auctionid="]').attr('href'); const id = link?.match(/auctionid=(\d+)/)?.[1]; if (!id) return; out.push({ id, title: H.text(a.find('h3')) ?? '', dept: H.text(a.find('.category-title')), dateText: H.text(a.find('.date')), pageUrl: a.find('h3 a').attr('href') ?? null }); }); return out; } export function parseCatalog(htmlText: string, auction: z.infer, page: number): Payload { const $ = H.load(htmlText); const lots: z.infer[] = []; $('#galleryList li .lot, #galleryList .lot').each((_, el) => { const l = $(el); const link = l.find('#LotName a, span[id="LotName"] a').first(); const title = H.text(link); const href = link.attr('href'); if (!title || !href) return; const data = H.text(l.find('.lotData')) ?? ''; const fp = data.match(/Final Price:\s*\$([\d,]+(?:\.\d+)?)/i); const mb = data.match(/Min Bid:\s*\$([\d,]+(?:\.\d+)?)/i); const bids = data.match(/#\s*Bids:\s*(\d+)/i); const est = data.match(/Estimate:\s*([^\n]+?)(?:\s*$|\s*#|\s*Min|\s*Final)/i); lots.push({ lotNumber: H.text(l.find('#LotNumber, span[id="LotNumber"]')), title, url: href.replace(/^http:/, 'https:'), image: (() => { const src = l.find('img.lotImage').attr('src'); return src ? (src.startsWith('http') ? src : CAT + src) : null; })(), finalPrice: fp ? money(`$${fp[1]}`, 'USD')?.amount ?? null : null, minBid: mb ? money(`$${mb[1]}`, 'USD')?.amount ?? null : null, estimateText: est ? est[1]!.trim() : null, bids: bids ? Number(bids[1]) : null, }); }); const totalPages = Number(htmlText.match(/id\s*=\s*"ofpages">\s*\/\s*(\d+)/)?.[1] ?? '') || null; return { kind: 'catalog_page', auction, page, totalPages, lots }; } /** * Morphy Auctions: past-auction list on morphyauctions.com (WordPress, /page/N/) → auction catalogs on * auctions.morphyauctions.com (ASP.NET). Page 1 is a GET; later pages are the page's own form POST * (page number + "Go" button with the hidden __VIEWSTATE fields) — no login involved. */ export class MorphyConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; private stat(ctx: CrawlContext, ok: boolean, ms: number) { const s = (ctx.engineStats.api ??= { attempts: 0, success: 0, credits: 0, ms: 0 }); s.attempts++; if (ok) s.success++; s.ms += ms; } async *crawl(ctx: CrawlContext): AsyncIterable { const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2); const maxPages = Number(this.meta.config.pagesPerAuction ?? 10); const perPage = String(this.meta.config.lotsPerPage ?? 100); const backfill = ctx.options.mode === 'backfill'; const listPage = backfill ? Number(ctx.options.cursor?.listPage ?? 1) : 1; const done = new Set(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []); const listUrl = `${WP}/auctions/past-auctions/${listPage > 1 ? `page/${listPage}/` : ''}`; await this.throttle(); const list = await ctx.fetch(listUrl, { engines: ['api'], responseType: 'text', minQuality: 0.3 }); if (!list.success || !list.html) { ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`); return; } const auctions = parsePastList(list.html).filter((a) => !done.has(a.id) && morphyCategory(a.dept, a.title, '') !== null); let processed = 0; let count = 0; for (const auction of auctions) { if (processed >= auctionsPerRun || ctx.signal?.aborted) break; const url = `${CAT}/catalog.aspx?auctionid=${auction.id}`; await this.throttle(); const t0 = Date.now(); let res; try { res = await httpText(url); } catch (err) { this.stat(ctx, false, Date.now() - t0); ctx.anomaly('page_fetch_failed', `${url}: ${err instanceof Error ? err.message : String(err)}`); continue; } this.stat(ctx, res.status < 400, Date.now() - t0); if (res.status >= 400) { ctx.anomaly('page_fetch_failed', `${url}: HTTP ${res.status}`); continue; } let html = res.text; let payload = parseCatalog(html, auction, 1); let page = 1; // Switch to the larger page size through the form (what the page-size dropdown does). if (perPage !== '25' && payload.lots.length > 0) { const posted = await this.postPage(url, html, 1, perPage, ctx, 'ctl00$ContentPlaceHolder$LotsPerPageDropDownTop'); if (posted) { html = posted; payload = parseCatalog(html, auction, 1); } } while (true) { if (payload.lots.length === 0) break; count++; yield { url: `${url}&page=${page}`, externalId: `auction:${auction.id}:page:${page}`, kind: 'sale', engine: 'api', httpStatus: 200, payload, fetchedAt: new Date() }; page++; if (this.reached(ctx, count) || page > maxPages || (payload.totalPages !== null && page > payload.totalPages) || ctx.signal?.aborted) break; await this.throttle(); const next = await this.postPage(url, html, page, perPage, ctx); if (!next) break; html = next; payload = parseCatalog(html, auction, page); } processed++; done.add(auction.id); await ctx.setCursor({ doneAuctions: [...done].slice(-400), listPage: backfill && auctions.every((a) => done.has(a.id)) ? listPage + 1 : listPage, updatedAt: new Date().toISOString() }); } } /** Jump to `page` with `perPage` lots by posting the catalog form (ASP.NET postback). */ private async postPage(url: string, currentHtml: string, page: number, perPage: string, ctx: CrawlContext, eventTarget?: string): Promise { const hidden = hiddenFields(currentHtml); const form: Record = {}; for (const [k, v] of Object.entries(hidden)) if (k.startsWith('__') || k.startsWith('categoryView')) form[k] = v; const keep = ['ctl00$ContentPlaceHolder$SortByDDLTop', 'ctl00$ContentPlaceHolder$SortByDDLBot', 'ctl00$ContentPlaceHolder$displayByDropDownTop', 'ctl00$ContentPlaceHolder$displayByDropDownBot', 'ctl00$ContentPlaceHolder$searchByDropDown']; for (const k of keep) { const v = selectedOption(currentHtml, k); if (v !== null) form[k] = v; } // The auction dropdown defaults to the live sale, not the one being viewed: post the requested id explicitly. form['ctl00$ContentPlaceHolder$AuctionDDL'] = url.match(/auctionid=(\d+)/)?.[1] ?? ''; form['ctl00$ContentPlaceHolder$LotsPerPageDropDownTop'] = perPage; form['ctl00$ContentPlaceHolder$LotsPerPageDropDownBot'] = perPage; form['ctl00$ContentPlaceHolder$CurrPageTopTB'] = String(page); form['ctl00$ContentPlaceHolder$CurrPageBotTB'] = String(page); form['ctl00$ContentPlaceHolder$searchTextBox'] = ''; if (eventTarget) form.__EVENTTARGET = eventTarget; else form['ctl00$ContentPlaceHolder$PageJumpBtn'] = 'Go'; const t0 = Date.now(); try { const res = await httpText(url, { method: 'POST', form, referer: url }); this.stat(ctx, res.status < 400, Date.now() - t0); if (res.status >= 400) return null; const cur = Number(res.text.match(/value="(\d+)" id="CurrPageTopTB"/)?.[1] ?? '0'); if (cur !== page) { ctx.anomaly('pagination_mismatch', `${url}: asked page ${page}, got ${cur}`); return null; } return res.text; } catch (err) { this.stat(ctx, false, Date.now() - t0); ctx.anomaly('page_fetch_failed', `${url} (postback): ${err instanceof Error ? err.message : String(err)}`); return null; } } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const saleDate = dateWords(p.auction.dateText); const out: NormalizedRecord[] = []; if (!saleDate) return out; for (const lot of p.lots) { if (lot.finalPrice === null || lot.finalPrice <= 0) continue; const slug = morphyCategory(p.auction.dept, p.auction.title, lot.title); if (!slug) continue; const g = gradeOf(lot.title); const attributes = lotAttributes({ categorySlug: slug, name: lot.title, year: safeYear(lot.title), identifiers: { morphy_lot: lot.url.match(/LOT(\d+)\.aspx/i)?.[1] ?? `${p.auction.id}-${lot.lotNumber}` }, metadata: { auction_id: p.auction.id, auction_title: p.auction.title, department: p.auction.dept, estimate: lot.estimateText, min_bid: lot.minBid, bids: lot.bids } }); out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: `${p.auction.id}-${lot.lotNumber ?? lot.url}`, rawTitle: lot.title, attributes, price: lot.finalPrice, currency: 'USD', saleDate, buyerPremiumIncluded: null, auctionHouse: 'Morphy Auctions', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade, location: 'US', isBundle: isBundleTitle(lot.title) })); } return out; } } export default (meta: ConnectorMeta) => new MorphyConnector(meta);