import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { attrs } from '../_lib/shared.js'; import { usd } from '../_g6-comics-toys-games-lib/comics.js'; /** * Miniature Market — Shopware 6 storefront; public category listing pages (/board-games.html?p=N, 24 product * boxes per page, 379 pages) served over plain HTTPS to the honest bot UA. Each box gives the product name, * URL, image, SKU (data-sku), Shopware product id, retail/list price and Miniature Market's price, plus the buy * widget (Add to cart / Preorder). robots.txt allows ?p= pagination and sets Crawl-delay: 10. */ const SITE = 'https://www.miniaturemarket.com'; const PARSER_VERSION = '1.0.0'; export const BoxSchema = z.object({ name: z.string(), url: z.string(), image: z.string().nullable(), sku: z.string().nullable(), productId: z.string().nullable(), price: z.number().nullable(), listPrice: z.number().nullable(), buttonText: z.string().nullable(), badges: z.array(z.string()) }); export type Box = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('listing_page'), url: z.string(), seed: z.string(), categorySlug: z.string(), page: z.number(), totalPages: z.number().nullable(), boxes: z.array(BoxSchema), snapshot: z.string().optional() }); export type PagePayload = z.infer; export function parseListingPage(htmlText: string, url: string, seed: string, categorySlug: string, page: number): PagePayload { const $ = H.load(htmlText); const boxes: Box[] = []; $('.product-box').each((_, el) => { const $el = $(el); const a = $el.find('a.product-name').first(); const name = (a.attr('title') ?? H.text(a) ?? '').replace(/\s+/g, ' ').trim(); const href = a.attr('href') ?? ''; if (!name || !href) return; const redirect = $el.find('input[name="redirectParameters"]').attr('value') ?? ''; let productId: string | null = null; try { productId = redirect ? String((JSON.parse(redirect) as { productId?: string }).productId ?? '') || null : null; } catch { productId = redirect.match(/productId[^0-9a-f]+([0-9a-f]{32})/)?.[1] ?? null; } boxes.push({ name, url: H.absUrl(SITE, href) ?? href, image: $el.find('img.product-image').first().attr('src') ?? null, sku: $el.find('[data-sku]').first().attr('data-sku') ?? null, productId, price: usd(H.text($el.find('.product-price').first())), listPrice: usd(H.text($el.find('.list-price-price').first())), buttonText: H.text($el.find('.btn-buy').first()) ?? null, badges: $el.find('.product-badges .badge').map((__, b) => H.text($(b)) ?? '').get().filter(Boolean), }); }); const totalTxt = $('.pagination-nav .visually-hidden').first().text(); const totalPages = Number(totalTxt.match(/of\s+(\d+)/)?.[1] ?? 0) || null; return { kind: 'listing_page', url, seed, categorySlug, page, totalPages, boxes }; } interface Seed { path: string; categorySlug: string; pages?: number; } export class MiniatureMarketConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; /** robots.txt Crawl-delay: 10 */ protected override minIntervalMs = 10_000; async *crawl(ctx: CrawlContext): AsyncIterable { const configured = ((this.meta.config.seeds as Seed[] | undefined) ?? []).filter((s) => s.path && s.categorySlug); const seeds = ctx.options.seeds?.length ? configured.filter((s) => ctx.options.seeds!.includes(s.path)) : configured; if (!seeds.length) { ctx.anomaly('config_missing', 'no category seeds configured'); return; } const backfill = ctx.options.mode === 'backfill'; const probe = ctx.options.mode === 'probe'; const cursor = (ctx.options.cursor ?? {}) as { seed?: string; page?: number }; let resume = backfill && typeof cursor.seed === 'string'; let count = 0; for (const seed of seeds) { if (resume && cursor.seed !== seed.path) continue; const maxPages = probe ? 1 : backfill ? this.policy.backfillMaxPages : (seed.pages ?? Number(this.meta.config.pagesPerSeed ?? 2)); let page = resume && cursor.page ? cursor.page : 1; resume = false; for (; page <= maxPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${SITE}${seed.path}${page > 1 ? `?p=${page}` : ''}`; await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', timeoutMs: 60_000, expect: ['title', 'price', 'identifiers'], parse: (r) => { const b = r.html ? parseListingPage(r.html, url, seed.path, seed.categorySlug, page).boxes[0] : undefined; return b ? { title: b.name, price: b.price, identifiers: b.sku } : null; }, }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = parseListingPage(res.html, url, seed.path, seed.categorySlug, page); if (!payload.boxes.length) { ctx.anomaly('parse_failure_page', url); break; } count++; yield { url, externalId: `${seed.path}:p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, snapshot: res.html, fetchedAt: res.fetchedAt }; await ctx.setCursor({ seed: seed.path, page: page + 1, updatedAt: new Date().toISOString() }); if (backfill) await ctx.progress({ page, totalPages: payload.totalPages, itemsProcessed: count }); if (payload.totalPages !== null && page >= payload.totalPages) break; } } if (backfill) await ctx.setCursor({ done: true, updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const exclude = new RegExp(String(this.meta.config.exclude ?? 'sleeves?|playmat|dice set|token set|insert|organizer|gift card'), 'i'); const out: NormalizedRecord[] = []; for (const b of p.boxes) { if (b.price === null || exclude.test(b.name)) continue; const preorder = /pre-?order/i.test(b.buttonText ?? '') || b.badges.some((x) => /pre-?order/i.test(x)); const edition = b.name.match(/\b(\d+(?:st|nd|rd|th) Edition|Deluxe Edition|Collector'?s Edition|Kickstarter Edition|Retail Edition|Big Box)\b/i)?.[1] ?? null; const attributes = attrs({ categorySlug: p.categorySlug, name: b.name, edition, originalMsrp: b.listPrice, originalMsrpCurrency: b.listPrice !== null ? 'USD' : null, identifiers: { ...(b.sku ? { sku: b.sku } : {}), ...(b.productId ? { miniaturemarket_product_id: b.productId } : {}) }, metadata: { list_price: b.listPrice, button_text: b.buttonText, badges: b.badges, preorder, seed: p.seed }, }); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: b.url, externalId: b.productId ?? b.sku ?? b.url, rawTitle: b.name, imageUrls: b.image ? [b.image] : [], attributes, grade: {}, condition: { condition: null, conditionRaw: 'new', completeness: 'sealed' }, observedAt: raw.fetchedAt, confidence: 0.85, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: b.price, currency: 'USD', seller: 'Miniature Market', location: 'US', availability: /add to cart|pre-?order/i.test(b.buttonText ?? '') ? 'available' : b.buttonText ? 'ended' : 'unknown', }), ); } return out; } } export default (meta: ConnectorMeta) => new MiniatureMarketConnector(meta);