Connectors: JP market, official LEGO store, coin dealers, FossilEra (7 sources, agent N)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
35 changed files +2,624 −0
added
connectors/api/fossilera/_smoke.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { createRouter, createCrawlContext, ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import createConnector from './index.js'; | |
| 5 | + | |
| 6 | +const save = process.argv.includes('--save'); | |
| 7 | +const meta = ConnectorMetaSchema.parse(metaJson); | |
| 8 | +const connector = createConnector(meta); | |
| 9 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 10 | +const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 } }); | |
| 11 | +let i = 0; | |
| 12 | +for await (const raw of connector.crawl(ctx)) { | |
| 13 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 14 | + console.log(`raw ${raw.externalId} → ${out.length} records`); | |
| 15 | + for (const r of out.slice(0, 4)) console.log(' ', JSON.stringify(r).slice(0, 320)); | |
| 16 | + if (save && i === 0) { | |
| 17 | + const p = raw.payload as any; | |
| 18 | + p.items = p.items.slice(0, 8); | |
| 19 | + saveFixture('fossilera', 'dinosaur-teeth-p1', { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, expect: { minCount: 8, kinds: ['listing'], requiredFields: ['attributes.identifiers.fossilera_item'] }, note: 'Captured live from fossilera.com dinosaur teeth category, trimmed to 8 specimens' }); | |
| 20 | + } | |
| 21 | + i++; | |
| 22 | +} | |
| 23 | +console.log('engineStats', ctx.engineStats, 'anomalies', ctx.anomalies); | |
added
connectors/api/fossilera/index.test.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 3 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 4 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { parseCategoryPage, parseSpecimenPage } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(ConnectorMetaSchema.parse(metaJson)); | |
| 8 | + | |
| 9 | +describe('fossilera', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits one USD listing per unique specimen with the FossilEra item number', async () => { | |
| 13 | + const fx = loadFixture('fossilera', 'dinosaur-teeth-p1'); | |
| 14 | + const out = await connector.normalize(fx.raw); | |
| 15 | + expect(out.length).toBeGreaterThanOrEqual(8); | |
| 16 | + for (const l of out) { | |
| 17 | + if (l.kind !== 'listing') throw new Error('expected listing'); | |
| 18 | + expect(l.attributes.categorySlug).toBe('fossils'); | |
| 19 | + expect(l.attributes.identifiers.fossilera_item).toMatch(/^\d+$/); | |
| 20 | + expect(l.currency).toBe('USD'); | |
| 21 | + expect(l.seller).toBe('FossilEra'); | |
| 22 | + expect(l.sourceUrl).toMatch(/fossilera\.com\/fossils\//); | |
| 23 | + expect(l.attributes.metadata.unique_specimen).toBe(true); | |
| 24 | + } | |
| 25 | + const reduced = out.find((l) => l.kind === 'listing' && l.attributes.metadata.previous_price_usd); | |
| 26 | + expect(reduced).toBeTruthy(); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it('parsers handle cards, reduced prices and specimen details', () => { | |
| 30 | + const html = `<a href="/fossils/x-tooth"><div class="image"><img alt="X Tooth #123" src="//assets0.fossilera.com/x.jpg"></div><div class="info"> 3.1" X Tooth - Montana <div class="price"><span class="old-price">$300</span> $250</div></div></a>`; | |
| 31 | + const p = parseCategoryPage(html, 'u', 'fossils'); | |
| 32 | + expect(p.items).toHaveLength(1); | |
| 33 | + expect(p.items[0]).toMatchObject({ item: '123', price: 250, oldPrice: 300, sold: false, title: '3.1" X Tooth - Montana', image: 'https://assets0.fossilera.com/x.jpg' }); | |
| 34 | + const detail = `<html><body><h1>Big Tooth</h1><div class="price">$1,000</div><div class="detail">SPECIES Tyrannosaurus rex</div><div class="detail">AGE Late Cretaceous</div><div class="detail">LOCATION Garfield County, Montana</div><div class="detail">FORMATION Hell Creek Formation</div><div class="detail">SIZE 3.95" tooth</div>ITEM #355472</body></html>`; | |
| 35 | + const d = parseSpecimenPage(detail, 'https://www.fossilera.com/fossils/big-tooth', 'fossils'); | |
| 36 | + expect(d?.items[0]).toMatchObject({ item: '355472', price: 1000, species: 'Tyrannosaurus rex', formation: 'Hell Creek Formation', location: 'Garfield County, Montana', size: '3.95" tooth' }); | |
| 37 | + }); | |
| 38 | +}); | |
added
connectors/api/fossilera/index.ts
+153 −0
@@ -0,0 +1,153 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedListingSchema, parsePrice, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +const BASE = 'https://www.fossilera.com'; | |
| 6 | +const PARSER_VERSION = '1.0.0'; | |
| 7 | + | |
| 8 | +export const SpecimenSchema = z.object({ | |
| 9 | + item: z.string(), | |
| 10 | + url: z.string(), | |
| 11 | + title: z.string(), | |
| 12 | + price: z.number().nullable(), | |
| 13 | + oldPrice: z.number().nullable(), | |
| 14 | + sold: z.boolean(), | |
| 15 | + image: z.string().nullable(), | |
| 16 | + species: z.string().nullable().default(null), | |
| 17 | + age: z.string().nullable().default(null), | |
| 18 | + location: z.string().nullable().default(null), | |
| 19 | + formation: z.string().nullable().default(null), | |
| 20 | + size: z.string().nullable().default(null), | |
| 21 | + category: z.string().nullable().default(null), | |
| 22 | + subCategory: z.string().nullable().default(null), | |
| 23 | +}); | |
| 24 | +export type Specimen = z.infer<typeof SpecimenSchema>; | |
| 25 | +export const PagePayloadSchema = z.object({ kind: z.enum(['category_page', 'specimen_page']), url: z.string(), categorySlug: z.string(), items: z.array(SpecimenSchema) }); | |
| 26 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 27 | + | |
| 28 | +const abs = (u: string | null | undefined) => (u ? (u.startsWith('//') ? `https:${u}` : u.startsWith('http') ? u : BASE + u) : null); | |
| 29 | + | |
| 30 | +export function parseCategoryPage(htmlText: string, pageUrl: string, categorySlug: string): PagePayload { | |
| 31 | + const $ = H.load(htmlText); | |
| 32 | + const items: Specimen[] = []; | |
| 33 | + $('a[href^="/fossils/"], a[href^="/minerals/"], a[href^="/meteorites/"]').each((_, el) => { | |
| 34 | + const a = $(el); | |
| 35 | + if (!a.find('.info').length) return; | |
| 36 | + const url = BASE + a.attr('href')!; | |
| 37 | + const alt = a.find('img').attr('alt') ?? ''; | |
| 38 | + const item = alt.match(/#(\d+)\s*$/)?.[1] ?? url.split('/').pop()!; | |
| 39 | + const info = a.find('.info').clone(); | |
| 40 | + const priceEl = info.find('.price'); | |
| 41 | + const oldPrice = parsePrice(H.text(priceEl.find('.old-price')), 'USD')?.amount ?? null; | |
| 42 | + priceEl.find('.old-price').remove(); | |
| 43 | + const priceText = H.text(priceEl) ?? ''; | |
| 44 | + const sold = /sold/i.test(priceText); | |
| 45 | + const price = sold ? null : (parsePrice(priceText, 'USD')?.amount ?? null); | |
| 46 | + priceEl.remove(); | |
| 47 | + const title = H.text(info) ?? alt.replace(/\s*#\d+\s*$/, ''); | |
| 48 | + if (!title) return; | |
| 49 | + items.push({ item, url, title, price, oldPrice, sold, image: abs(a.find('img').attr('src')), species: null, age: null, location: null, formation: null, size: null, category: null, subCategory: null }); | |
| 50 | + }); | |
| 51 | + return { kind: 'category_page', url: pageUrl, categorySlug, items }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function parseSpecimenPage(htmlText: string, url: string, categorySlug: string): PagePayload | null { | |
| 55 | + const $ = H.load(htmlText); | |
| 56 | + const title = H.text($('h1').first()); | |
| 57 | + if (!title) return null; | |
| 58 | + const detail = (label: string) => { | |
| 59 | + let v: string | null = null; | |
| 60 | + $('[class*="detail"]').each((_, el) => { | |
| 61 | + const t = $(el).text().replace(/\s+/g, ' ').trim(); | |
| 62 | + const m = t.match(new RegExp(`^${label}\\s+(.+)$`, 'i')); | |
| 63 | + if (m && !v) v = m[1]!.trim(); | |
| 64 | + }); | |
| 65 | + return v; | |
| 66 | + }; | |
| 67 | + const body = $('body').text().replace(/\s+/g, ' '); | |
| 68 | + const item = body.match(/ITEM\s*#\s*(\d+)/i)?.[1] ?? url.split('/').pop()!; | |
| 69 | + const priceBox = $('.price').first(); | |
| 70 | + const oldPrice = parsePrice(H.text(priceBox.find('.old-price')), 'USD')?.amount ?? null; | |
| 71 | + const priceText = priceBox.clone().find('.old-price').remove().end().text(); | |
| 72 | + const sold = /this (specimen|item) (has been|was) sold|sold out/i.test(body); | |
| 73 | + const price = sold ? null : (parsePrice(priceText, 'USD')?.amount ?? null); | |
| 74 | + return { | |
| 75 | + kind: 'specimen_page', | |
| 76 | + url, | |
| 77 | + categorySlug, | |
| 78 | + items: [{ item, url, title, price, oldPrice, sold, image: abs($('meta[property="og:image"]').attr('content')), species: detail('SPECIES'), age: detail('AGE'), location: detail('LOCATION'), formation: detail('FORMATION'), size: detail('SIZE'), category: detail('CATEGORY'), subCategory: detail('SUB CATEGORY') }], | |
| 79 | + }; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export class FossilEraConnector extends BaseConnector { | |
| 83 | + readonly version = '1.0.0'; | |
| 84 | + readonly parserVersion = PARSER_VERSION; | |
| 85 | + protected override minIntervalMs = 1500; | |
| 86 | + override readonly urlPatterns = [/^https?:\/\/(www\.)?fossilera\.com\/(fossils|minerals|meteorites)\/[a-z0-9-]+/i]; | |
| 87 | + | |
| 88 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 89 | + const seeds = (this.meta.config.seeds as Array<{ path: string; categorySlug: string }> | undefined) ?? []; | |
| 90 | + const pages = Number(this.meta.config.pagesPerSeed ?? 1); | |
| 91 | + const cap = ctx.options.limit; | |
| 92 | + let count = 0; | |
| 93 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 94 | + for (let i = start; i < seeds.length; i++) { | |
| 95 | + const seed = seeds[i]!; | |
| 96 | + for (let page = 1; page <= pages; page++) { | |
| 97 | + if (ctx.signal?.aborted || (cap !== undefined && count >= cap)) return; | |
| 98 | + const url = `${BASE}${seed.path}${page > 1 ? `?page=${page}` : ''}`; | |
| 99 | + await this.throttle(); | |
| 100 | + const res = await ctx.fetch(url, { | |
| 101 | + engines: ['api', 'firecrawl'], | |
| 102 | + responseType: 'text', | |
| 103 | + expect: ['title', 'price'], | |
| 104 | + parse: (r) => { | |
| 105 | + const p = r.html ? parseCategoryPage(r.html, url, seed.categorySlug) : null; | |
| 106 | + return p?.items.length ? { title: 'ok', price: p.items.some((x) => x.price) ? 1 : null } : null; | |
| 107 | + }, | |
| 108 | + }); | |
| 109 | + const payload = res.success && res.html ? parseCategoryPage(res.html, url, seed.categorySlug) : null; | |
| 110 | + if (!payload?.items.length) { | |
| 111 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no specimen cards'}`); | |
| 112 | + break; | |
| 113 | + } | |
| 114 | + count++; | |
| 115 | + yield { url, externalId: `${seed.path}|p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 116 | + } | |
| 117 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 122 | + const m = url.match(this.urlPatterns[0]!); | |
| 123 | + if (!m) return []; | |
| 124 | + const slug = m[2]!.toLowerCase() === 'minerals' ? 'minerals' : m[2]!.toLowerCase() === 'meteorites' ? 'meteorites' : 'fossils'; | |
| 125 | + const res = await ctx.fetch(url, { engines: ['api', 'firecrawl'], responseType: 'text', minQuality: 0 }); | |
| 126 | + const payload = res.success && res.html ? parseSpecimenPage(res.html, url, slug) : null; | |
| 127 | + if (!payload) return []; | |
| 128 | + return [{ url, externalId: payload.items[0]!.item, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; | |
| 129 | + } | |
| 130 | + | |
| 131 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 132 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 133 | + const out: NormalizedRecord[] = []; | |
| 134 | + for (const s of p.items) { | |
| 135 | + const loc = s.location ?? s.title.match(/ - ([A-Z][A-Za-z .]+)$/)?.[1] ?? null; | |
| 136 | + const attributes = AssetAttributesSchema.parse({ | |
| 137 | + categorySlug: p.categorySlug, | |
| 138 | + name: s.title, | |
| 139 | + size: s.size ?? s.title.match(/(\d+(?:\.\d+)?")/)?.[1] ?? null, | |
| 140 | + country: loc, | |
| 141 | + identifiers: { fossilera_item: s.item }, | |
| 142 | + metadata: { species: s.species, geological_age: s.age, formation: s.formation, locality: s.location, category: s.category, sub_category: s.subCategory, previous_price_usd: s.oldPrice, dealer_guarantee: 'FossilEra authenticity guarantee (dealer statement)', unique_specimen: true }, | |
| 143 | + }); | |
| 144 | + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: s.url, rawTitle: s.title, imageUrls: s.image ? [s.image] : [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; | |
| 145 | + out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: s.item, confidence: 0.85, listingType: 'fixed_price', price: s.price, currency: 'USD', seller: 'FossilEra', location: 'US', availability: s.sold ? 'sold' : s.price ? 'available' : 'unknown' })); | |
| 146 | + } | |
| 147 | + return out; | |
| 148 | + } | |
| 149 | +} | |
| 150 | + | |
| 151 | +export default function createConnector(meta: ConnectorMeta) { | |
| 152 | + return new FossilEraConnector(meta); | |
| 153 | +} | |
added
connectors/api/fossilera/meta.json
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +{ | |
| 2 | + "id": "fossilera", | |
| 3 | + "displayName": "FossilEra (fossils, minerals & meteorites dealer, USD)", | |
| 4 | + "sourceId": "fossilera", | |
| 5 | + "sourceName": "FossilEra", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.fossilera.com", | |
| 8 | + "module": "api/fossilera", | |
| 9 | + "enginePriority": ["api", "firecrawl"], | |
| 10 | + "categories": ["fossils", "minerals", "meteorites"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.fossilera.com/pages/terms-of-service", | |
| 26 | + "accessNotes": "Public category pages (fossilera.com/fossils-for-sale/<category>?page=N) and specimen pages fetched over plain HTTPS with the RareIndex user agent (robots.txt only disallows gift-card/checkout helpers). Each card gives the specimen title, price (and previous price when reduced), image and the FossilEra item number; specimen pages add species, geological age, location, formation, size and category. Every specimen is unique, so each becomes its own asset with one fixed-price listing (available or, when the page states it, sold — no sale date is published, so sold items are listings with availability=sold, never transactions). Compliance: fossilera states all items are legally collected; locality is kept in attributes.country/region so jurisdiction flags (taxonomy compliance 'jurisdiction_check') can be applied downstream.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + { "path": "/fossils-for-sale/dinosaur-teeth", "categorySlug": "fossils" }, | |
| 32 | + { "path": "/fossils-for-sale/megalodon-teeth", "categorySlug": "fossils" }, | |
| 33 | + { "path": "/fossils-for-sale/ammonites", "categorySlug": "fossils" }, | |
| 34 | + { "path": "/fossils-for-sale/trilobites", "categorySlug": "fossils" }, | |
| 35 | + { "path": "/minerals-for-sale", "categorySlug": "minerals" }, | |
| 36 | + { "path": "/meteorites-for-sale", "categorySlug": "meteorites" } | |
| 37 | + ], | |
| 38 | + "pagesPerSeed": 1 | |
| 39 | + } | |
| 40 | +} | |
added
connectors/api/ma-shops/_smoke.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { createRouter, createCrawlContext, ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import createConnector from './index.js'; | |
| 5 | + | |
| 6 | +const save = process.argv.includes('--save'); | |
| 7 | +const meta = ConnectorMetaSchema.parse(metaJson); | |
| 8 | +const connector = createConnector(meta); | |
| 9 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 10 | +const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 } }); | |
| 11 | +let i = 0; | |
| 12 | +for await (const raw of connector.crawl(ctx)) { | |
| 13 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 14 | + console.log(`raw ${raw.externalId} → ${out.length} records`); | |
| 15 | + for (const r of out.slice(0, 4)) console.log(' ', JSON.stringify(r).slice(0, 320)); | |
| 16 | + if (save && i === 0) { | |
| 17 | + const p = raw.payload as any; | |
| 18 | + p.items = p.items.slice(0, 8); | |
| 19 | + saveFixture('ma-shops', 'denarius-search-p1', { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, expect: { minCount: 8, kinds: ['listing'], requiredFields: ['price', 'currency'] }, note: 'Captured live from ma-shops.com search.php?keywords=denarius gallery, trimmed to 8 cells' }); | |
| 20 | + } | |
| 21 | + i++; | |
| 22 | +} | |
| 23 | +console.log('engineStats', ctx.engineStats, 'anomalies', ctx.anomalies); | |
added
connectors/api/ma-shops/index.test.ts
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 3 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 4 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { parseMaPrice } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(ConnectorMetaSchema.parse(metaJson)); | |
| 8 | + | |
| 9 | +describe('ma-shops', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits dealer asks with the displayed currency and parsed grades', async () => { | |
| 13 | + const fx = loadFixture('ma-shops', 'denarius-search-p1'); | |
| 14 | + const out = await connector.normalize(fx.raw); | |
| 15 | + expect(out.length).toBeGreaterThanOrEqual(8); | |
| 16 | + for (const l of out) { | |
| 17 | + if (l.kind !== 'listing') throw new Error('expected listing'); | |
| 18 | + expect(l.attributes.categorySlug).toBe('coins'); | |
| 19 | + expect(l.attributes.identifiers.ma_shops_item).toMatch(/^[a-z0-9_-]+\/\d+$/i); | |
| 20 | + expect(['CAD', 'USD', 'EUR', 'GBP', 'CHF']).toContain(l.currency); | |
| 21 | + expect(l.price).toBeGreaterThan(0); | |
| 22 | + expect(l.seller).toBeTruthy(); | |
| 23 | + expect(l.sourceUrl).toMatch(/ma-shops\.com\/[a-z0-9_-]+\/item\.php\?id=\d+/i); | |
| 24 | + } | |
| 25 | + expect(out.some((l) => l.kind === 'listing' && l.grade.grader === 'pcgs')).toBe(true); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it('parses displayed prices', () => { | |
| 29 | + expect(parseMaPrice('642.98 CAN$')).toEqual({ price: 642.98, currency: 'CAD' }); | |
| 30 | + expect(parseMaPrice('1,250.00 US$')).toEqual({ price: 1250, currency: 'USD' }); | |
| 31 | + expect(parseMaPrice('89.00 EUR')).toEqual({ price: 89, currency: 'EUR' }); | |
| 32 | + expect(parseMaPrice(null)).toEqual({ price: null, currency: null }); | |
| 33 | + }); | |
| 34 | +}); | |
added
connectors/api/ma-shops/index.ts
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedListingSchema, extractYear, type CurrencyCode, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { parseGradeFromTitle } from '@rareindex/taxonomy'; | |
| 5 | + | |
| 6 | +const BASE = 'https://www.ma-shops.com'; | |
| 7 | +const PARSER_VERSION = '1.0.0'; | |
| 8 | + | |
| 9 | +export const CellSchema = z.object({ | |
| 10 | + shop: z.string(), | |
| 11 | + id: z.string(), | |
| 12 | + url: z.string(), | |
| 13 | + title: z.string(), | |
| 14 | + price: z.number().nullable(), | |
| 15 | + currency: z.string().nullable(), | |
| 16 | + seller: z.string().nullable(), | |
| 17 | + image: z.string().nullable(), | |
| 18 | +}); | |
| 19 | +export type Cell = z.infer<typeof CellSchema>; | |
| 20 | +export const PagePayloadSchema = z.object({ kind: z.literal('gallery_page'), url: z.string(), categorySlug: z.string(), items: z.array(CellSchema) }); | |
| 21 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 22 | + | |
| 23 | +/** "642.98 CAN$" | "1,250.00 US$" | "89.00 EUR" → amount + ISO currency. */ | |
| 24 | +export function parseMaPrice(text: string | null | undefined): { price: number | null; currency: CurrencyCode | null } { | |
| 25 | + if (!text) return { price: null, currency: null }; | |
| 26 | + const m = text.replace(/\s+/g, ' ').match(/([\d.,]+)\s*(CAN\$|US\$|EUR|€|GBP|£|CHF)/i); | |
| 27 | + if (!m) return { price: null, currency: null }; | |
| 28 | + const num = Number(m[1]!.replace(/,/g, '')); | |
| 29 | + const cur = /CAN/i.test(m[2]!) ? 'CAD' : /US/i.test(m[2]!) || m[2] === '$' ? 'USD' : /EUR|€/i.test(m[2]!) ? 'EUR' : /GBP|£/i.test(m[2]!) ? 'GBP' : 'CHF'; | |
| 30 | + return { price: Number.isFinite(num) && num > 0 ? num : null, currency: cur }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function parseGalleryPage(htmlText: string, pageUrl: string, categorySlug: string): PagePayload { | |
| 34 | + const $ = H.load(htmlText); | |
| 35 | + const items: Cell[] = []; | |
| 36 | + const seen = new Set<string>(); | |
| 37 | + $('.galleryCell').each((_, el) => { | |
| 38 | + const e = $(el); | |
| 39 | + const a = e.find('.galleryItemTitle a, a[href*="item.php?id="]').first(); | |
| 40 | + const href = a.attr('href'); | |
| 41 | + const m = href?.match(/^\/?([a-z0-9_-]+)\/item\.php\?id=(\d+)/i); | |
| 42 | + if (!href || !m) return; | |
| 43 | + const key = `${m[1]}/${m[2]}`; | |
| 44 | + if (seen.has(key)) return; | |
| 45 | + seen.add(key); | |
| 46 | + // gallery titles are truncated with an ellipsis; the thumbnail alt/title carries the full title | |
| 47 | + const title = ((e.find('img.thumb').attr('title') || e.find('img.thumb').attr('alt') || H.text(e.find('.galleryItemTitle').first())) ?? '').replace(/\s+/g, ' ').trim(); | |
| 48 | + if (!title) return; | |
| 49 | + const { price, currency } = parseMaPrice(H.text(e.find('.itemPrice').first())); | |
| 50 | + const seller = H.text(e.find('.gallerySellerName').first()); | |
| 51 | + const image = e.find('img.thumb').attr('src') ?? null; | |
| 52 | + items.push({ shop: m[1]!, id: m[2]!, url: `${BASE}/${m[1]}/item.php?id=${m[2]}`, title, price, currency, seller, image: image ? (image.startsWith('http') ? image : BASE + image) : null }); | |
| 53 | + }); | |
| 54 | + return { kind: 'gallery_page', url: pageUrl, categorySlug, items }; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export class MaShopsConnector extends BaseConnector { | |
| 58 | + readonly version = '1.0.0'; | |
| 59 | + readonly parserVersion = PARSER_VERSION; | |
| 60 | + protected override minIntervalMs = 2000; | |
| 61 | + | |
| 62 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 63 | + const seeds = (this.meta.config.seeds as Array<{ path: string; categorySlug: string }> | undefined) ?? []; | |
| 64 | + const pages = Number(this.meta.config.pagesPerSeed ?? 1); | |
| 65 | + const cap = ctx.options.limit; | |
| 66 | + let count = 0; | |
| 67 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 68 | + for (let i = start; i < seeds.length; i++) { | |
| 69 | + const seed = seeds[i]!; | |
| 70 | + for (let page = 1; page <= pages; page++) { | |
| 71 | + if (ctx.signal?.aborted || (cap !== undefined && count >= cap)) return; | |
| 72 | + const sep = seed.path.includes('?') ? '&' : '?'; | |
| 73 | + const url = `${BASE}${seed.path}${page > 1 ? `${sep}page=${page}` : ''}`; | |
| 74 | + await this.throttle(); | |
| 75 | + const res = await ctx.fetch(url, { | |
| 76 | + engines: ['api', 'firecrawl'], | |
| 77 | + responseType: 'text', | |
| 78 | + expect: ['title', 'price'], | |
| 79 | + parse: (r) => { | |
| 80 | + const p = r.html ? parseGalleryPage(r.html, url, seed.categorySlug) : null; | |
| 81 | + return p?.items.length ? { title: 'ok', price: p.items.some((x) => x.price) ? 1 : null } : null; | |
| 82 | + }, | |
| 83 | + }); | |
| 84 | + const payload = res.success && res.html ? parseGalleryPage(res.html, url, seed.categorySlug) : null; | |
| 85 | + if (!payload?.items.length) { | |
| 86 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no gallery cells'}`); | |
| 87 | + break; | |
| 88 | + } | |
| 89 | + count++; | |
| 90 | + yield { url, externalId: `${seed.path}|p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 91 | + } | |
| 92 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 97 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 98 | + const out: NormalizedRecord[] = []; | |
| 99 | + for (const c of p.items) { | |
| 100 | + const g = parseGradeFromTitle(c.title); | |
| 101 | + const attributes = AssetAttributesSchema.parse({ | |
| 102 | + categorySlug: p.categorySlug, | |
| 103 | + name: c.title, | |
| 104 | + year: extractYear(c.title), | |
| 105 | + identifiers: { ma_shops_item: `${c.shop}/${c.id}` }, | |
| 106 | + metadata: { dealer: c.seller, shop_slug: c.shop, unique_item: true }, | |
| 107 | + }); | |
| 108 | + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, rawTitle: c.title, imageUrls: c.image ? [c.image] : [], attributes, grade: { grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grade, qualifier: g.qualifier, certificationNumber: null }, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; | |
| 109 | + out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: `${c.shop}/${c.id}`, confidence: 0.8, listingType: 'fixed_price', price: c.price, currency: c.currency, seller: c.seller ?? c.shop, location: 'EU', availability: c.price ? 'available' : 'unknown' })); | |
| 110 | + } | |
| 111 | + return out; | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +export default function createConnector(meta: ConnectorMeta) { | |
| 116 | + return new MaShopsConnector(meta); | |
| 117 | +} | |
added
connectors/api/ma-shops/meta.json
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +{ | |
| 2 | + "id": "ma-shops", | |
| 3 | + "displayName": "MA-Shops (European coin, banknote & militaria dealer network)", | |
| 4 | + "sourceId": "ma-shops", | |
| 5 | + "sourceName": "MA-Shops", | |
| 6 | + "sourceType": "marketplace", | |
| 7 | + "sourceUrl": "https://www.ma-shops.com", | |
| 8 | + "module": "api/ma-shops", | |
| 9 | + "enginePriority": [ | |
| 10 | + "api", | |
| 11 | + "firecrawl" | |
| 12 | + ], | |
| 13 | + "categories": [ | |
| 14 | + "coins", | |
| 15 | + "banknotes", | |
| 16 | + "medals", | |
| 17 | + "militaria" | |
| 18 | + ], | |
| 19 | + "regions": [ | |
| 20 | + "DE", | |
| 21 | + "EU" | |
| 22 | + ], | |
| 23 | + "languages": [ | |
| 24 | + "en", | |
| 25 | + "de" | |
| 26 | + ], | |
| 27 | + "currency": [ | |
| 28 | + "EUR", | |
| 29 | + "USD", | |
| 30 | + "CAD" | |
| 31 | + ], | |
| 32 | + "supportsListings": true, | |
| 33 | + "supportsSold": false, | |
| 34 | + "supportsAuctions": false, | |
| 35 | + "supportsImages": true, | |
| 36 | + "supportsCatalog": false, | |
| 37 | + "supportsPopulation": false, | |
| 38 | + "supportsLookup": false, | |
| 39 | + "refreshFrequencyMinutes": 1440, | |
| 40 | + "priority": "low", | |
| 41 | + "trustScore": 0.75, | |
| 42 | + "attributionRequired": true, | |
| 43 | + "termsUrl": "https://www.ma-shops.com/shops/help.php?id=1", | |
| 44 | + "accessNotes": "Public search gallery pages (ma-shops.com/search.php?keywords=…, 45 items per page; category landing pages only show a handful of featured items) fetched over plain HTTPS with the RareIndex user agent; robots.txt is fully permissive. Each gallery cell gives the dealer's item URL, title, price with the displayed currency (the site picks a currency per visitor — CAN$, US$ or EUR — which is parsed and stored as-is), the dealer name and the primary image. Emits dealer asks (fixed-price listings). Militaria items carry the taxonomy compliance flags (jurisdiction_check, restricted_symbols_review) and are presented neutrally.", | |
| 45 | + "enabled": true, | |
| 46 | + "schemaVersion": "1.0", | |
| 47 | + "config": { | |
| 48 | + "seeds": [ | |
| 49 | + { | |
| 50 | + "path": "/search.php?keywords=denarius", | |
| 51 | + "categorySlug": "coins", | |
| 52 | + "label": "Roman denarius" | |
| 53 | + }, | |
| 54 | + { | |
| 55 | + "path": "/search.php?keywords=thaler", | |
| 56 | + "categorySlug": "coins", | |
| 57 | + "label": "Thaler" | |
| 58 | + }, | |
| 59 | + { | |
| 60 | + "path": "/search.php?keywords=tetradrachm", | |
| 61 | + "categorySlug": "coins", | |
| 62 | + "label": "Greek tetradrachm" | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "path": "/search.php?keywords=20+mark+gold", | |
| 66 | + "categorySlug": "coins", | |
| 67 | + "label": "German 20 Mark gold" | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "path": "/search.php?keywords=sovereign", | |
| 71 | + "categorySlug": "coins", | |
| 72 | + "label": "Sovereign" | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "path": "/search.php?keywords=banknote", | |
| 76 | + "categorySlug": "banknotes", | |
| 77 | + "label": "Banknotes" | |
| 78 | + }, | |
| 79 | + { | |
| 80 | + "path": "/search.php?keywords=medaille", | |
| 81 | + "categorySlug": "medals", | |
| 82 | + "label": "Medals" | |
| 83 | + } | |
| 84 | + ], | |
| 85 | + "pagesPerSeed": 1 | |
| 86 | + } | |
| 87 | +} | |
added
connectors/firecrawl/apmex/_smoke.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { createRouter, createCrawlContext, ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import createConnector from './index.js'; | |
| 5 | + | |
| 6 | +const save = process.argv.includes('--save'); | |
| 7 | +const meta = ConnectorMetaSchema.parse(metaJson); | |
| 8 | +const connector = createConnector(meta); | |
| 9 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 10 | +const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 } }); | |
| 11 | +let i = 0; | |
| 12 | +for await (const raw of connector.crawl(ctx)) { | |
| 13 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 14 | + console.log(`raw ${raw.externalId} → ${out.length} records`); | |
| 15 | + for (const r of out.slice(0, 4)) console.log(' ', JSON.stringify(r).slice(0, 320)); | |
| 16 | + if (save && i === 0) { | |
| 17 | + const p = raw.payload as any; | |
| 18 | + p.items = p.items.slice(0, 8); | |
| 19 | + saveFixture('apmex', 'liberty-eagle-category-p1', { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, expect: { minCount: 8, kinds: ['catalog_item', 'listing'] }, note: 'Captured live from apmex.com $10 Liberty Eagle category, trimmed to 8 cards' }); | |
| 20 | + } | |
| 21 | + i++; | |
| 22 | +} | |
| 23 | +console.log('engineStats', ctx.engineStats, 'anomalies', ctx.anomalies); | |
added
connectors/firecrawl/apmex/index.test.ts
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 3 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 4 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { parseCoinTitle } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(ConnectorMetaSchema.parse(metaJson)); | |
| 8 | + | |
| 9 | +describe('apmex', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits coin catalog items + USD dealer asks with parsed grades', async () => { | |
| 13 | + const fx = loadFixture('apmex', 'liberty-eagle-category-p1'); | |
| 14 | + const out = await connector.normalize(fx.raw); | |
| 15 | + const cats = out.filter((r) => r.kind === 'catalog_item'); | |
| 16 | + const lst = out.filter((r) => r.kind === 'listing'); | |
| 17 | + expect(cats.length).toBeGreaterThan(0); | |
| 18 | + expect(lst.length).toBeGreaterThan(0); | |
| 19 | + for (const c of cats) { | |
| 20 | + if (c.kind !== 'catalog_item') continue; | |
| 21 | + expect(c.attributes.categorySlug).toBe('coins'); | |
| 22 | + expect(c.attributes.series).toBe('$10 Liberty Gold Eagle'); | |
| 23 | + expect(c.attributes.identifiers.apmex_sku).toMatch(/^\d+$/); | |
| 24 | + } | |
| 25 | + for (const l of lst) { | |
| 26 | + if (l.kind !== 'listing') continue; | |
| 27 | + expect(l.currency).toBe('USD'); | |
| 28 | + expect(l.price).toBeGreaterThan(100); | |
| 29 | + expect(l.seller).toBe('APMEX'); | |
| 30 | + } | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it('parses coin titles', () => { | |
| 34 | + expect(parseCoinTitle('1887-S $10 Liberty Gold Eagle MS-63 NGC')).toMatchObject({ year: 1887, mintMark: 'S', grader: 'ngc', grade: 'MS63' }); | |
| 35 | + expect(parseCoinTitle('1900 $10 Liberty Gold Eagle MS-64+ PCGS CAC')).toMatchObject({ year: 1900, mintMark: null, grader: 'pcgs', grade: 'MS64+', designation: 'CAC' }); | |
| 36 | + expect(parseCoinTitle('$10 Liberty Gold Eagle XF (Random Year)')).toMatchObject({ year: null, grader: null, random: true }); | |
| 37 | + expect(parseCoinTitle('$10 Liberty Gold Eagle (Cleaned)').cleaned).toBe(true); | |
| 38 | + }); | |
| 39 | +}); | |
added
connectors/firecrawl/apmex/index.ts
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedListingSchema, parsePrice, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +const PARSER_VERSION = '1.0.0'; | |
| 6 | + | |
| 7 | +export const CardSchema = z.object({ | |
| 8 | + id: z.string(), | |
| 9 | + url: z.string(), | |
| 10 | + title: z.string(), | |
| 11 | + price: z.number().nullable(), | |
| 12 | + message: z.string().nullable(), | |
| 13 | + image: z.string().nullable(), | |
| 14 | + badge: z.string().nullable(), | |
| 15 | +}); | |
| 16 | +export type Card = z.infer<typeof CardSchema>; | |
| 17 | +export const PagePayloadSchema = z.object({ kind: z.literal('category_page'), url: z.string(), series: z.string().nullable(), country: z.string().nullable(), items: z.array(CardSchema) }); | |
| 18 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 19 | + | |
| 20 | +export function parseCategoryPage(htmlText: string, pageUrl: string, series: string | null, country: string | null): PagePayload { | |
| 21 | + const $ = H.load(htmlText); | |
| 22 | + const items: Card[] = []; | |
| 23 | + const seen = new Set<string>(); | |
| 24 | + $('.mod-product-card').each((_, el) => { | |
| 25 | + const e = $(el); | |
| 26 | + const a = e.find('a.item-link').first(); | |
| 27 | + const url = a.attr('href'); | |
| 28 | + const id = a.attr('data-product-id') ?? url?.match(/\/product\/(\d+)\//)?.[1]; | |
| 29 | + if (!url || !id || seen.has(id)) return; | |
| 30 | + seen.add(id); | |
| 31 | + const title = (a.attr('data-product-name') ?? H.text(e.find('.mod-product-title').first()) ?? '').replace(/\s+/g, ' ').trim(); | |
| 32 | + if (!title) return; | |
| 33 | + const price = parsePrice(H.text(e.find('.mod-product-pricing .price').first()), 'USD')?.amount ?? null; | |
| 34 | + const message = H.text(e.find('.mod-product-message').first()); | |
| 35 | + const image = e.find('.mod-product-img img').attr('src') ?? null; | |
| 36 | + const badge = H.text(e.find('.product-badge').first()); | |
| 37 | + items.push({ id, url, title, price, message, image, badge }); | |
| 38 | + }); | |
| 39 | + return { kind: 'category_page', url: pageUrl, series, country, items }; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** "1887-S $10 Liberty Gold Eagle MS-63 NGC" → parts. Unknown fields stay null. */ | |
| 43 | +export function parseCoinTitle(title: string): { year: number | null; mintMark: string | null; grader: string | null; grade: string | null; designation: string | null; cleaned: boolean; random: boolean } { | |
| 44 | + const y = title.match(/\b(1[6-9]\d{2}|20\d{2})(?:-([A-Z]{1,2}))?\b/); | |
| 45 | + const g = title.match(/\b(MS|AU|XF|EF|VF|F|VG|G|AG|PR|PF|PL|SP|BU)-?(\d{1,2})(\+?)(?!\d)/i); | |
| 46 | + const grader = title.match(/\b(PCGS|NGC|ANACS|ICG|CAC)\b/i)?.[1]?.toLowerCase() ?? null; | |
| 47 | + const designation = title.match(/\b(CAC|DMPL|PL|FBL|FB|RD|RB|BN|Cameo|Ultra Cameo|DCAM|First Strike|Early Releases)\b/i)?.[1] ?? null; | |
| 48 | + return { | |
| 49 | + year: y ? Number(y[1]) : null, | |
| 50 | + mintMark: y?.[2] ?? null, | |
| 51 | + grader, | |
| 52 | + grade: g ? `${g[1]!.toUpperCase()}${g[2]}${g[3] ?? ''}` : null, | |
| 53 | + designation, | |
| 54 | + cleaned: /\(cleaned\)|cleaned/i.test(title), | |
| 55 | + random: /random/i.test(title), | |
| 56 | + }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export class ApmexConnector extends BaseConnector { | |
| 60 | + readonly version = '1.0.0'; | |
| 61 | + readonly parserVersion = PARSER_VERSION; | |
| 62 | + protected override minIntervalMs = 2500; | |
| 63 | + | |
| 64 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 65 | + const seeds = (this.meta.config.seeds as Array<{ url: string; series?: string; country?: string }> | undefined) ?? []; | |
| 66 | + const pages = Number(this.meta.config.pagesPerSeed ?? 1); | |
| 67 | + const cap = ctx.options.limit; | |
| 68 | + let count = 0; | |
| 69 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 70 | + for (let i = start; i < seeds.length; i++) { | |
| 71 | + const seed = seeds[i]!; | |
| 72 | + for (let page = 1; page <= pages; page++) { | |
| 73 | + if (ctx.signal?.aborted || (cap !== undefined && count >= cap)) return; | |
| 74 | + const url = page > 1 ? `${seed.url}?page=${page}` : seed.url; | |
| 75 | + await this.throttle(); | |
| 76 | + const res = await ctx.fetch(url, { | |
| 77 | + engines: ['firecrawl', 'scrapfly'], | |
| 78 | + expect: ['title', 'price'], | |
| 79 | + parse: (r) => { | |
| 80 | + const p = r.html ? parseCategoryPage(r.html, url, seed.series ?? null, seed.country ?? null) : null; | |
| 81 | + return p?.items.length ? { title: 'ok', price: p.items.some((x) => x.price) ? 1 : null } : null; | |
| 82 | + }, | |
| 83 | + }); | |
| 84 | + const payload = res.success && res.html ? parseCategoryPage(res.html, url, seed.series ?? null, seed.country ?? null) : null; | |
| 85 | + if (!payload?.items.length) { | |
| 86 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no product cards'}`); | |
| 87 | + break; | |
| 88 | + } | |
| 89 | + count++; | |
| 90 | + yield { url, externalId: `${seed.url}|p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 91 | + } | |
| 92 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 97 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 98 | + const out: NormalizedRecord[] = []; | |
| 99 | + for (const c of p.items) { | |
| 100 | + const t = parseCoinTitle(c.title); | |
| 101 | + const name = c.title.replace(/\s*\(cleaned\)/i, '').replace(/\b(PCGS|NGC|ANACS|ICG)\b/gi, '').replace(/\b(MS|AU|XF|EF|VF|F|VG|G|PR|PF|BU)-?\d{1,2}\+?\b/gi, '').replace(/\s+/g, ' ').trim(); | |
| 102 | + const attributes = AssetAttributesSchema.parse({ | |
| 103 | + categorySlug: 'coins', | |
| 104 | + series: p.series, | |
| 105 | + name, | |
| 106 | + year: t.year, | |
| 107 | + variant: [t.mintMark ? `${t.mintMark} mint` : null, t.designation, t.cleaned ? 'Cleaned' : null].filter(Boolean).join(' · ') || null, | |
| 108 | + country: p.country, | |
| 109 | + identifiers: { apmex_sku: c.id }, | |
| 110 | + metadata: { mint_mark: t.mintMark, designation: t.designation, random_year: t.random, badge: c.badge }, | |
| 111 | + }); | |
| 112 | + const grade = t.grader && t.grade ? { grader: t.grader, grade: t.grade, qualifier: t.designation, certificationNumber: null } : { grader: null, grade: t.grade, qualifier: null, certificationNumber: null }; | |
| 113 | + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, rawTitle: c.title, imageUrls: c.image ? [c.image] : [], attributes, grade, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; | |
| 114 | + out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: c.id, confidence: 0.85, releaseDate: null })); | |
| 115 | + if (c.price) { | |
| 116 | + const availability = /out of stock|sold out|notify/i.test(c.message ?? '') ? 'sold' : 'available'; | |
| 117 | + out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: c.id, confidence: 0.85, listingType: 'fixed_price', price: c.price, currency: 'USD', seller: 'APMEX', location: 'US', condition: { condition: t.grade ? null : /\bBU\b|brilliant uncirculated/i.test(c.title) ? 'mint_state' : null, conditionRaw: c.title.match(/\b(BU|Brilliant Uncirculated|Cull|Cleaned|Uncirculated|Proof)\b/i)?.[1] ?? null, completeness: null }, availability })); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + return out; | |
| 121 | + } | |
| 122 | +} | |
| 123 | + | |
| 124 | +export default function createConnector(meta: ConnectorMeta) { | |
| 125 | + return new ApmexConnector(meta); | |
| 126 | +} | |
added
connectors/firecrawl/apmex/meta.json
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +{ | |
| 2 | + "id": "apmex", | |
| 3 | + "displayName": "APMEX (coins & bullion dealer, USD asks)", | |
| 4 | + "sourceId": "apmex", | |
| 5 | + "sourceName": "APMEX", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.apmex.com", | |
| 8 | + "module": "firecrawl/apmex", | |
| 9 | + "enginePriority": ["firecrawl", "scrapfly"], | |
| 10 | + "categories": ["coins"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.apmex.com/terms-and-conditions", | |
| 26 | + "accessNotes": "Public category grids (apmex.com/category/<id>/<slug>) of one of the largest US coin dealers; robots.txt disallows /catalog/, /cart/, /account/, /spotprice/ etc., none of which are used. Plain HTTPS gets an Akamai 403 for non-browser clients, so pages are fetched with Firecrawl (1 credit per ~80-product page). Parsed per product card: APMEX product id, title, 'As Low As' price (USD, cash/wire tier), stock message, image. Year, mint mark, grade and grading service (PCGS/NGC/ANACS/CAC) are parsed from the title; graded coins become grader/grade variants, raw/BU coins stay raw. Emits catalog items + dealer fixed-price listings (asks, not transactions).", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + { "url": "https://www.apmex.com/category/11903/10-liberty-eagle-coins-1795-1907", "series": "$10 Liberty Gold Eagle", "country": "US" }, | |
| 32 | + { "url": "https://www.apmex.com/category/11904/20-liberty-double-eagle-coins-1849-1907", "series": "$20 Liberty Double Eagle", "country": "US" }, | |
| 33 | + { "url": "https://www.apmex.com/category/11905/20-saint-gaudens-double-eagle-coins-1907-1933", "series": "$20 Saint-Gaudens Double Eagle", "country": "US" }, | |
| 34 | + { "url": "https://www.apmex.com/category/25310/morgan-dollars-1878-1921", "series": "Morgan Dollar", "country": "US" }, | |
| 35 | + { "url": "https://www.apmex.com/category/25320/peace-dollars-1921-1935", "series": "Peace Dollar", "country": "US" }, | |
| 36 | + { "url": "https://www.apmex.com/category/11440/american-gold-eagles", "series": "American Gold Eagle", "country": "US" } | |
| 37 | + ], | |
| 38 | + "pagesPerSeed": 1 | |
| 39 | + } | |
| 40 | +} | |
added
connectors/firecrawl/hobbysearch/_smoke.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { createRouter, createCrawlContext, ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import createConnector from './index.js'; | |
| 5 | + | |
| 6 | +const save = process.argv.includes('--save'); | |
| 7 | +const meta = ConnectorMetaSchema.parse(metaJson); | |
| 8 | +const connector = createConnector(meta); | |
| 9 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 10 | +const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 } }); | |
| 11 | +let i = 0; | |
| 12 | +for await (const raw of connector.crawl(ctx)) { | |
| 13 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 14 | + console.log(`raw ${raw.externalId} → ${out.length} records`); | |
| 15 | + for (const r of out.slice(0, 4)) console.log(' ', JSON.stringify(r).slice(0, 320)); | |
| 16 | + if (save && i === 0) { | |
| 17 | + const p = raw.payload as any; | |
| 18 | + p.items = p.items.slice(0, 8); | |
| 19 | + saveFixture('hobbysearch', 'gundam-mg-search-p1', { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, expect: { minCount: 8, kinds: ['catalog_item', 'listing'] }, note: 'Captured live from 1999.co.jp search (Gundam MG), trimmed to 8 cards' }); | |
| 20 | + } | |
| 21 | + i++; | |
| 22 | +} | |
| 23 | +console.log('engineStats', ctx.engineStats, 'anomalies', ctx.anomalies); | |
added
connectors/firecrawl/hobbysearch/index.test.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 3 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 4 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { jpy } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(ConnectorMetaSchema.parse(metaJson)); | |
| 8 | + | |
| 9 | +describe('hobbysearch', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits catalog items with MSRP and JPY retailer listings', async () => { | |
| 13 | + const fx = loadFixture('hobbysearch', 'gundam-mg-search-p1'); | |
| 14 | + const out = await connector.normalize(fx.raw); | |
| 15 | + const cats = out.filter((r) => r.kind === 'catalog_item'); | |
| 16 | + const lst = out.filter((r) => r.kind === 'listing'); | |
| 17 | + expect(cats.length).toBeGreaterThan(0); | |
| 18 | + expect(lst.length).toBeGreaterThan(0); | |
| 19 | + for (const c of cats) { | |
| 20 | + if (c.kind !== 'catalog_item') continue; | |
| 21 | + expect(['gundam', 'action_figures']).toContain(c.attributes.categorySlug); | |
| 22 | + expect(c.attributes.identifiers.hobbysearch_id).toMatch(/^\d+$/); | |
| 23 | + expect(c.sourceUrl).toMatch(/1999\.co\.jp\/eng\/\d+$/); | |
| 24 | + } | |
| 25 | + for (const l of lst) { | |
| 26 | + if (l.kind !== 'listing') continue; | |
| 27 | + expect(l.currency).toBe('JPY'); | |
| 28 | + expect(l.price).toBeGreaterThan(0); | |
| 29 | + expect(l.seller).toBe('HobbySearch'); | |
| 30 | + } | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it('parses JPY strings', () => { | |
| 34 | + expect(jpy('7,200 JPY')).toBe(7200); | |
| 35 | + expect(jpy('110')).toBe(110); | |
| 36 | + expect(jpy(null)).toBeNull(); | |
| 37 | + }); | |
| 38 | +}); | |
added
connectors/firecrawl/hobbysearch/index.ts
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +const BASE = 'https://www.1999.co.jp'; | |
| 6 | +const PARSER_VERSION = '1.0.0'; | |
| 7 | + | |
| 8 | +export const CardSchema = z.object({ | |
| 9 | + id: z.string(), | |
| 10 | + url: z.string(), | |
| 11 | + title: z.string(), | |
| 12 | + image: z.string().nullable(), | |
| 13 | + price: z.number().nullable(), | |
| 14 | + listPrice: z.number().nullable(), | |
| 15 | + stock: z.string().nullable(), | |
| 16 | + released: z.string().nullable(), | |
| 17 | +}); | |
| 18 | +export type Card = z.infer<typeof CardSchema>; | |
| 19 | +export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), url: z.string(), categorySlug: z.string(), items: z.array(CardSchema) }); | |
| 20 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 21 | + | |
| 22 | +export function jpy(s: string | null | undefined): number | null { | |
| 23 | + const m = s?.replace(/,/g, '').match(/(\d+)\s*JPY/i) ?? s?.replace(/,/g, '').match(/(\d+)/); | |
| 24 | + return m ? Number(m[1]) : null; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function parseSearchPage(htmlText: string, pageUrl: string, categorySlug: string): PagePayload { | |
| 28 | + const $ = H.load(htmlText); | |
| 29 | + const items: Card[] = []; | |
| 30 | + $('.c-product-list__item').each((_, el) => { | |
| 31 | + const e = $(el); | |
| 32 | + const a = e.find('a[href*="1999.co.jp/eng/"]').filter((__, x) => /\/eng\/\d{5,9}$/.test($(x).attr('href') ?? '')).first(); | |
| 33 | + const url = a.attr('href'); | |
| 34 | + const id = url?.match(/\/eng\/(\d+)$/)?.[1]; | |
| 35 | + if (!url || !id) return; | |
| 36 | + const img = e.find('img').first(); | |
| 37 | + const title = (img.attr('alt') || img.attr('title') || H.text(a) || '').replace(/\s+/g, ' ').trim(); | |
| 38 | + if (!title) return; | |
| 39 | + const image = img.attr('src') ?? img.attr('data-src') ?? null; | |
| 40 | + const price = jpy(H.text(e.find('.c-card__price-element').first())); | |
| 41 | + const listPrice = jpy(H.text(e.find('.c-card__price-proper').first())); | |
| 42 | + const text = e.text().replace(/\s+/g, ' '); | |
| 43 | + const stock = text.match(/(In Stock|Sold Out|Pre-Order|Back-?order|Order Stop|Reservation)/i)?.[1] ?? null; | |
| 44 | + const released = text.match(/((?:Early|Mid|Late)\s+[A-Z][a-z]{2}\.?,?\s+\d{4}|[A-Z][a-z]{2}\.?,?\s+\d{4})\s+Released/)?.[1] ?? null; | |
| 45 | + items.push({ id, url, title, image: image ? (image.startsWith('http') ? image : BASE + image) : null, price, listPrice, stock, released }); | |
| 46 | + }); | |
| 47 | + return { kind: 'search_page', url: pageUrl, categorySlug, items }; | |
| 48 | +} | |
| 49 | + | |
| 50 | +const GUNDAM_RE = /gundam|gunpla|\bHG(UC|CE|AC|BF|IBO|GTO|BD)?\b|\bMG\b|\bRG\b|\bPG\b|\bMGEX\b|\bSDCS\b|zaku|zeta|char's|unicorn|barbatos|strike freedom|nu\b/i; | |
| 51 | + | |
| 52 | +export class HobbySearchConnector extends BaseConnector { | |
| 53 | + readonly version = '1.0.0'; | |
| 54 | + readonly parserVersion = PARSER_VERSION; | |
| 55 | + protected override minIntervalMs = 2000; | |
| 56 | + override readonly urlPatterns = [/^https?:\/\/(www\.)?1999\.co\.jp\/eng\/(\d{5,9})/i]; | |
| 57 | + | |
| 58 | + private searchUrl(key: string, page: number): string { | |
| 59 | + return `${BASE}/eng/search?typ1_c=100&cat=&state=&sold=0&sortid=7&searchkey=${encodeURIComponent(key)}${page > 1 ? `&page=${page}` : ''}`; | |
| 60 | + } | |
| 61 | + | |
| 62 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 63 | + const seeds = (this.meta.config.seeds as Array<{ key: string; categorySlug: string }> | undefined) ?? []; | |
| 64 | + const pages = Number(this.meta.config.pagesPerSeed ?? 1); | |
| 65 | + const cap = ctx.options.limit; | |
| 66 | + let count = 0; | |
| 67 | + const startSeed = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 68 | + for (let i = startSeed; i < seeds.length; i++) { | |
| 69 | + const seed = seeds[i]!; | |
| 70 | + for (let page = 1; page <= pages; page++) { | |
| 71 | + if (ctx.signal?.aborted || (cap !== undefined && count >= cap)) return; | |
| 72 | + const url = this.searchUrl(seed.key, page); | |
| 73 | + await this.throttle(); | |
| 74 | + const res = await ctx.fetch(url, { | |
| 75 | + engines: ['firecrawl', 'scrapfly'], | |
| 76 | + expect: ['title', 'price'], | |
| 77 | + parse: (r) => { | |
| 78 | + const p = r.html ? parseSearchPage(r.html, url, seed.categorySlug) : null; | |
| 79 | + return p?.items.length ? { title: 'ok', price: p.items.some((x) => x.price) ? 1 : null } : null; | |
| 80 | + }, | |
| 81 | + }); | |
| 82 | + const payload = res.success && res.html ? parseSearchPage(res.html, url, seed.categorySlug) : null; | |
| 83 | + if (!payload?.items.length) { | |
| 84 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no cards'}`); | |
| 85 | + break; | |
| 86 | + } | |
| 87 | + count++; | |
| 88 | + yield { url, externalId: `${seed.key}|p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 89 | + } | |
| 90 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 95 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 96 | + const out: NormalizedRecord[] = []; | |
| 97 | + for (const c of p.items) { | |
| 98 | + const isGundam = p.categorySlug === 'gundam' && GUNDAM_RE.test(c.title); | |
| 99 | + const categorySlug = p.categorySlug === 'gundam' ? (isGundam ? 'gundam' : 'action_figures') : p.categorySlug; | |
| 100 | + const scale = c.title.match(/\b(1\/\d{1,3})\b/)?.[1] ?? null; | |
| 101 | + const year = c.released?.match(/(\d{4})/)?.[1] ? Number(c.released.match(/(\d{4})/)![1]) : null; | |
| 102 | + const name = c.title.replace(/\s*\((Plastic model|Completed|Figure|Action Figure|PVC Figure)\)\s*$/i, '').trim(); | |
| 103 | + const attributes = AssetAttributesSchema.parse({ | |
| 104 | + categorySlug, | |
| 105 | + brand: isGundam ? 'Bandai' : null, | |
| 106 | + franchise: isGundam ? 'Gundam' : null, | |
| 107 | + name, | |
| 108 | + year, | |
| 109 | + size: scale, | |
| 110 | + region: 'JP', | |
| 111 | + originalMsrp: c.listPrice ?? c.price, | |
| 112 | + originalMsrpCurrency: c.listPrice ?? c.price ? 'JPY' : null, | |
| 113 | + identifiers: { hobbysearch_id: c.id }, | |
| 114 | + metadata: { product_type: c.title.match(/\(([^)]+)\)\s*$/)?.[1] ?? null, released: c.released, stock: c.stock }, | |
| 115 | + }); | |
| 116 | + const rawTitle = `${name}${scale ? ` ${scale}` : ''}${year ? ` (${year})` : ''}`; | |
| 117 | + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, rawTitle, imageUrls: c.image ? [c.image] : [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; | |
| 118 | + out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: c.id, confidence: 0.8, releaseDate: null })); | |
| 119 | + if (c.price) { | |
| 120 | + const availability = /sold out|order stop/i.test(c.stock ?? '') ? 'sold' : 'available'; | |
| 121 | + out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: c.id, confidence: 0.85, listingType: 'fixed_price', price: c.price, currency: 'JPY', seller: 'HobbySearch', location: 'Japan', condition: { condition: 'mint_in_box', conditionRaw: 'New', completeness: 'sealed' }, availability })); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + return out; | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +export default function createConnector(meta: ConnectorMeta) { | |
| 129 | + return new HobbySearchConnector(meta); | |
| 130 | +} | |
added
connectors/firecrawl/hobbysearch/meta.json
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +{ | |
| 2 | + "id": "hobbysearch", | |
| 3 | + "displayName": "HobbySearch 1999.co.jp (Gunpla & model kits, JPY)", | |
| 4 | + "sourceId": "hobbysearch", | |
| 5 | + "sourceName": "HobbySearch", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.1999.co.jp", | |
| 8 | + "module": "firecrawl/hobbysearch", | |
| 9 | + "enginePriority": ["firecrawl", "scrapfly"], | |
| 10 | + "categories": ["gundam", "action_figures", "model_cars"], | |
| 11 | + "regions": ["JP"], | |
| 12 | + "languages": ["en", "ja"], | |
| 13 | + "currency": ["JPY"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.8, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.1999.co.jp/eng/guide/", | |
| 26 | + "accessNotes": "Public English search/list pages (1999.co.jp/eng/search?...; robots.txt allows all for generic agents) and item pages. Direct HTTPS returns 403 to non-browser clients, so pages go through Firecrawl (1 credit per page of 60 items). Parsed per card: HobbySearch item id, title, street price and list price (JPY), discount, stock state (In Stock / Sold Out / Pre-Order / Back-order), image. Emits catalog items (list price = manufacturer MSRP) and the retailer's fixed-price listing. Gundam categorisation is keyword-based on the seed + title; other kits fall back to the seed's category.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + { "key": "Gundam MG", "categorySlug": "gundam" }, | |
| 32 | + { "key": "Gundam RG", "categorySlug": "gundam" }, | |
| 33 | + { "key": "Gundam PG", "categorySlug": "gundam" }, | |
| 34 | + { "key": "HGUC", "categorySlug": "gundam" }, | |
| 35 | + { "key": "Nendoroid", "categorySlug": "action_figures" }, | |
| 36 | + { "key": "figma", "categorySlug": "action_figures" } | |
| 37 | + ], | |
| 38 | + "pagesPerSeed": 1 | |
| 39 | + } | |
| 40 | +} | |
added
connectors/firecrawl/lego-shop/_smoke.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { createRouter, createCrawlContext, ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import createConnector from './index.js'; | |
| 5 | + | |
| 6 | +const save = process.argv.includes('--save'); | |
| 7 | +const meta = ConnectorMetaSchema.parse(metaJson); | |
| 8 | +const connector = createConnector(meta); | |
| 9 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 10 | +const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 } }); | |
| 11 | +let i = 0; | |
| 12 | +for await (const raw of connector.crawl(ctx)) { | |
| 13 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 14 | + console.log(`raw ${raw.externalId} → ${out.length} records`); | |
| 15 | + for (const r of out.slice(0, 4)) console.log(' ', JSON.stringify(r).slice(0, 320)); | |
| 16 | + if (save && i === 0) { | |
| 17 | + const p = raw.payload as any; | |
| 18 | + p.products = p.products.slice(0, 8); | |
| 19 | + saveFixture('lego-shop', 'star-wars-theme-p1', { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, expect: { minCount: 8, kinds: ['catalog_item', 'listing'], requiredFields: ['attributes.identifiers.lego_set_number'] }, note: 'Captured live from lego.com/en-us/themes/star-wars, trimmed to 8 products' }); | |
| 20 | + } | |
| 21 | + i++; | |
| 22 | +} | |
| 23 | +console.log('engineStats', ctx.engineStats, 'anomalies', ctx.anomalies); | |
added
connectors/firecrawl/lego-shop/index.test.ts
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 3 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 4 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(ConnectorMetaSchema.parse(metaJson)); | |
| 8 | + | |
| 9 | +describe('lego-shop', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits official MSRP catalog items and store listings keyed by set number', async () => { | |
| 13 | + const fx = loadFixture('lego-shop', 'star-wars-theme-p1'); | |
| 14 | + const out = await connector.normalize(fx.raw); | |
| 15 | + const cats = out.filter((r) => r.kind === 'catalog_item'); | |
| 16 | + const lst = out.filter((r) => r.kind === 'listing'); | |
| 17 | + expect(cats.length).toBeGreaterThanOrEqual(4); | |
| 18 | + expect(lst.length).toBeGreaterThanOrEqual(4); | |
| 19 | + for (const c of cats) { | |
| 20 | + if (c.kind !== 'catalog_item') continue; | |
| 21 | + expect(c.attributes.categorySlug).toBe('lego_sets'); | |
| 22 | + expect(c.attributes.identifiers.lego_set_number).toMatch(/^\d{4,6}$/); | |
| 23 | + expect(c.attributes.number).toBe(c.attributes.identifiers.lego_set_number); | |
| 24 | + expect(c.attributes.brand).toBe('LEGO'); | |
| 25 | + expect(c.attributes.franchise).toBe('Star Wars'); | |
| 26 | + expect(c.attributes.originalMsrpCurrency).toBe('USD'); | |
| 27 | + expect(c.attributes.originalMsrp).toBeGreaterThan(0); | |
| 28 | + expect(c.sourceUrl).toMatch(/lego\.com\/en-us\/product\//); | |
| 29 | + } | |
| 30 | + const l = lst[0]!; | |
| 31 | + if (l.kind !== 'listing') throw new Error(); | |
| 32 | + expect(l.seller).toBe('LEGO Shop (official)'); | |
| 33 | + expect(l.currency).toBe('USD'); | |
| 34 | + }); | |
| 35 | +}); | |
added
connectors/firecrawl/lego-shop/index.ts
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedListingSchema, parsePrice, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +const BASE = 'https://www.lego.com'; | |
| 6 | +const PARSER_VERSION = '1.0.0'; | |
| 7 | + | |
| 8 | +export const LeafSchema = z.object({ | |
| 9 | + code: z.string(), | |
| 10 | + url: z.string(), | |
| 11 | + name: z.string(), | |
| 12 | + price: z.number().nullable(), | |
| 13 | + badges: z.array(z.string()), | |
| 14 | + image: z.string().nullable(), | |
| 15 | + availability: z.string().nullable(), | |
| 16 | + pieces: z.number().nullable(), | |
| 17 | + ages: z.string().nullable(), | |
| 18 | +}); | |
| 19 | +export type Leaf = z.infer<typeof LeafSchema>; | |
| 20 | +export const PagePayloadSchema = z.object({ kind: z.literal('theme_page'), url: z.string(), theme: z.string(), products: z.array(LeafSchema) }); | |
| 21 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 22 | + | |
| 23 | +const BADGE_RE = /^(New|Retiring soon|Coming Soon|Exclusive|Hard to find|Sold out|Out of stock|Backorders accepted|Pre-order|Limited edition|Insiders)$/i; | |
| 24 | + | |
| 25 | +export const THEME_NAMES: Record<string, string> = { | |
| 26 | + 'star-wars': 'Star Wars', icons: 'Icons', technic: 'Technic', ideas: 'Ideas', 'harry-potter': 'Harry Potter', marvel: 'Marvel', architecture: 'Architecture', ninjago: 'Ninjago', 'creator-expert': 'Creator Expert', 'botanical-collection': 'Botanical Collection', 'lord-of-the-rings': 'The Lord of the Rings', 'dc': 'DC', 'speed-champions': 'Speed Champions', 'super-mario': 'Super Mario', | |
| 27 | +}; | |
| 28 | + | |
| 29 | +export function parseThemePage(htmlText: string, pageUrl: string, theme: string): PagePayload { | |
| 30 | + const $ = H.load(htmlText); | |
| 31 | + const products: Leaf[] = []; | |
| 32 | + const seen = new Set<string>(); | |
| 33 | + $('[data-test="product-leaf"]').each((_, el) => { | |
| 34 | + const e = $(el); | |
| 35 | + const a = e.find('a[href*="/product/"]').first(); | |
| 36 | + const url = a.attr('href'); | |
| 37 | + const code = url?.match(/-(\d{4,6})(?:[/?#]|$)/)?.[1]; | |
| 38 | + if (!url || !code || seen.has(code)) return; | |
| 39 | + seen.add(code); | |
| 40 | + const name = (a.attr('aria-label') ?? H.text(e.find('[data-test="product-leaf-title"]').first()) ?? '').replace(/\s+/g, ' ').trim(); | |
| 41 | + if (!name) return; | |
| 42 | + const priceText = H.text(e.find('[data-test="product-leaf-price"]').first()) ?? H.text(e.find('[data-test="product-leaf-price-row"]').first()); | |
| 43 | + const price = parsePrice(priceText, 'USD')?.amount ?? null; | |
| 44 | + const badges: string[] = []; | |
| 45 | + e.find('span, div').each((__, b) => { | |
| 46 | + const t = $(b).children().length ? '' : $(b).text().trim(); | |
| 47 | + if (t && BADGE_RE.test(t) && !badges.includes(t)) badges.push(t); | |
| 48 | + }); | |
| 49 | + const image = e.find('img[data-test="product-leaf-image-1"]').attr('src') ?? e.find('img').first().attr('src') ?? null; | |
| 50 | + products.push({ code, url: url.startsWith('http') ? url : BASE + url, name, price, badges, image, availability: null, pieces: null, ages: null }); | |
| 51 | + }); | |
| 52 | + return { kind: 'theme_page', url: pageUrl, theme, products }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +export function parseProductPage(htmlText: string, url: string, theme: string): PagePayload | null { | |
| 56 | + const $ = H.load(htmlText); | |
| 57 | + const code = url.match(/-(\d{4,6})(?:[/?#]|$)/)?.[1]; | |
| 58 | + const name = H.text($('[data-test="product-overview-name"]').first()); | |
| 59 | + if (!code || !name) return null; | |
| 60 | + const price = parsePrice(H.text($('[data-test="product-price-display-price"]').first()), 'USD')?.amount ?? null; | |
| 61 | + const availability = H.text($('[data-test="product-overview-availability"]').first()); | |
| 62 | + const image = $('meta[property="og:image"]').attr('content') ?? null; | |
| 63 | + const body = $('body').text().replace(/\s+/g, ' '); | |
| 64 | + const pieces = body.match(/(\d[\d,]*)\s*Pieces/)?.[1]; | |
| 65 | + const ages = body.match(/(\d{1,2}\+)\s*Ages/)?.[1] ?? null; | |
| 66 | + return { kind: 'theme_page', url, theme, products: [{ code, url, name, price, badges: [], image, availability, pieces: pieces ? Number(pieces.replace(/,/g, '')) : null, ages }] }; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export class LegoShopConnector extends BaseConnector { | |
| 70 | + readonly version = '1.0.0'; | |
| 71 | + readonly parserVersion = PARSER_VERSION; | |
| 72 | + protected override minIntervalMs = 2000; | |
| 73 | + override readonly urlPatterns = [/^https?:\/\/(www\.)?lego\.com\/[a-z]{2}-[a-z]{2}\/product\/[a-z0-9-]+-\d{4,6}/i]; | |
| 74 | + | |
| 75 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 76 | + const themes = (this.meta.config.themes as string[] | undefined) ?? []; | |
| 77 | + const pages = Number(this.meta.config.pagesPerTheme ?? 1); | |
| 78 | + const cap = ctx.options.limit; | |
| 79 | + let count = 0; | |
| 80 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.themeIndex ?? 0) : 0; | |
| 81 | + for (let i = start; i < themes.length; i++) { | |
| 82 | + const theme = themes[i]!; | |
| 83 | + for (let page = 1; page <= pages; page++) { | |
| 84 | + if (ctx.signal?.aborted || (cap !== undefined && count >= cap)) return; | |
| 85 | + const url = `${BASE}/en-us/themes/${theme}${page > 1 ? `?page=${page}` : ''}`; | |
| 86 | + await this.throttle(); | |
| 87 | + const res = await ctx.fetch(url, { | |
| 88 | + engines: ['firecrawl', 'scrapfly'], | |
| 89 | + waitForMs: 3000, | |
| 90 | + expect: ['title', 'price'], | |
| 91 | + parse: (r) => { | |
| 92 | + const p = r.html ? parseThemePage(r.html, url, theme) : null; | |
| 93 | + return p?.products.length ? { title: 'ok', price: p.products.some((x) => x.price) ? 1 : null } : null; | |
| 94 | + }, | |
| 95 | + }); | |
| 96 | + const payload = res.success && res.html ? parseThemePage(res.html, url, theme) : null; | |
| 97 | + if (!payload?.products.length) { | |
| 98 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no product leaves'}`); | |
| 99 | + break; | |
| 100 | + } | |
| 101 | + count++; | |
| 102 | + yield { url, externalId: `${theme}|p${page}`, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 103 | + } | |
| 104 | + await ctx.setCursor({ themeIndex: i + 1 >= themes.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 109 | + if (!this.urlPatterns[0]!.test(url)) return []; | |
| 110 | + const res = await ctx.fetch(url, { engines: ['firecrawl', 'scrapfly'], waitForMs: 3000, minQuality: 0 }); | |
| 111 | + const payload = res.success && res.html ? parseProductPage(res.html, url, 'product') : null; | |
| 112 | + if (!payload) return []; | |
| 113 | + return [{ url, externalId: payload.products[0]!.code, kind: 'catalog_item', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; | |
| 114 | + } | |
| 115 | + | |
| 116 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 117 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 118 | + const themeName = THEME_NAMES[p.theme] ?? (p.theme === 'product' ? null : p.theme.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())); | |
| 119 | + const out: NormalizedRecord[] = []; | |
| 120 | + for (const l of p.products) { | |
| 121 | + const name = l.name.replace(/[™®]/g, '').replace(/^LEGO\s+/i, '').trim(); | |
| 122 | + const attributes = AssetAttributesSchema.parse({ | |
| 123 | + categorySlug: 'lego_sets', | |
| 124 | + brand: 'LEGO', | |
| 125 | + franchise: themeName, | |
| 126 | + set: themeName, | |
| 127 | + name, | |
| 128 | + number: l.code, | |
| 129 | + originalMsrp: l.price, | |
| 130 | + originalMsrpCurrency: l.price ? 'USD' : null, | |
| 131 | + identifiers: { lego_set_number: l.code }, | |
| 132 | + metadata: { badges: l.badges, pieces: l.pieces, ages: l.ages, availability: l.availability, retiring_soon: l.badges.some((b) => /retiring/i.test(b)), official_store: true }, | |
| 133 | + }); | |
| 134 | + const rawTitle = `LEGO ${l.code} ${name}${themeName ? ` · ${themeName}` : ''}`; | |
| 135 | + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: l.url, rawTitle, imageUrls: l.image ? [l.image] : [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; | |
| 136 | + out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: l.code, confidence: 0.95, releaseDate: null })); | |
| 137 | + if (l.price) { | |
| 138 | + const soldOut = l.badges.some((b) => /sold out|out of stock/i.test(b)) || /out of stock|sold out|retired/i.test(l.availability ?? ''); | |
| 139 | + const coming = l.badges.some((b) => /coming soon|pre-order/i.test(b)) || /coming soon|pre-?order/i.test(l.availability ?? ''); | |
| 140 | + out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: l.code, confidence: 0.95, listingType: 'fixed_price', price: l.price, currency: 'USD', seller: 'LEGO Shop (official)', location: 'US', condition: { condition: 'sealed', conditionRaw: 'New (official retail)', completeness: 'sealed' }, availability: soldOut ? 'sold' : coming ? 'unknown' : 'available' })); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + return out; | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +export default function createConnector(meta: ConnectorMeta) { | |
| 148 | + return new LegoShopConnector(meta); | |
| 149 | +} | |
added
connectors/firecrawl/lego-shop/meta.json
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +{ | |
| 2 | + "id": "lego-shop", | |
| 3 | + "displayName": "LEGO.com Shop (official retail price & availability)", | |
| 4 | + "sourceId": "lego-shop", | |
| 5 | + "sourceName": "LEGO Shop (official)", | |
| 6 | + "sourceType": "manufacturer", | |
| 7 | + "sourceUrl": "https://www.lego.com", | |
| 8 | + "module": "firecrawl/lego-shop", | |
| 9 | + "enginePriority": ["firecrawl", "scrapfly"], | |
| 10 | + "categories": ["lego_sets", "lego"], | |
| 11 | + "regions": ["US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.95, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.lego.com/en-us/legal/notices-and-policies/terms-of-use", | |
| 26 | + "accessNotes": "Official manufacturer store. Public theme pages (lego.com/en-us/themes/<theme>?page=N) and product pages are rendered client-side and return 403 to plain non-browser clients, so they are fetched with Firecrawl (1 credit per page; ~22 products per theme page). Parsed per product leaf: set number (from the product URL), name, US retail price, badges (New / Retiring soon / Coming Soon / Exclusive / Hard to find / Sold out), image; product pages add availability, age and piece count. Emits catalog items (MSRP USD, identifiers.lego_set_number shared with PriceCharting/Brickset/BrickEconomy) and the official-store fixed-price listing. robots.txt only disallows account/checkout/identity paths.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "themes": ["star-wars", "icons", "technic", "ideas", "harry-potter", "marvel", "architecture", "ninjago", "creator-expert", "botanical-collection"], | |
| 31 | + "pagesPerTheme": 1 | |
| 32 | + } | |
| 33 | +} | |
added
connectors/firecrawl/surugaya/_smoke.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { createRouter, createCrawlContext, ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import createConnector from './index.js'; | |
| 5 | + | |
| 6 | +const save = process.argv.includes('--save'); | |
| 7 | +const meta = ConnectorMetaSchema.parse(metaJson); | |
| 8 | +const connector = createConnector(meta); | |
| 9 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 10 | +const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 } }); | |
| 11 | +let i = 0; | |
| 12 | +for await (const raw of connector.crawl(ctx)) { | |
| 13 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 14 | + console.log(`raw ${raw.externalId} → ${out.length} records`); | |
| 15 | + for (const r of out.slice(0, 4)) console.log(' ', JSON.stringify(r).slice(0, 320)); | |
| 16 | + if (save && i === 0) { | |
| 17 | + const p = raw.payload as any; | |
| 18 | + p.items = p.items.slice(0, 8); | |
| 19 | + saveFixture('surugaya', 'pokemon-psa-search-p1', { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, expect: { minCount: 8, kinds: ['catalog_item', 'listing'] }, note: 'Captured live from suruga-ya.jp search (Pokémon PSA), trimmed to 8 cards' }); | |
| 20 | + } | |
| 21 | + i++; | |
| 22 | +} | |
| 23 | +console.log('engineStats', ctx.engineStats, 'anomalies', ctx.anomalies); | |
added
connectors/firecrawl/surugaya/index.test.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 3 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 4 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { parseJpCardTitle, yen } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(ConnectorMetaSchema.parse(metaJson)); | |
| 8 | + | |
| 9 | +describe('surugaya', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits JPY listings with PSA grades parsed from Japanese titles', async () => { | |
| 13 | + const fx = loadFixture('surugaya', 'pokemon-psa-search-p1'); | |
| 14 | + const out = await connector.normalize(fx.raw); | |
| 15 | + const listings = out.filter((r) => r.kind === 'listing'); | |
| 16 | + expect(listings.length).toBeGreaterThan(0); | |
| 17 | + const graded = out.find((r) => r.kind === 'catalog_item' && r.grade.grader === 'psa'); | |
| 18 | + expect(graded).toBeTruthy(); | |
| 19 | + if (graded?.kind !== 'catalog_item') throw new Error(); | |
| 20 | + expect(graded.attributes.categorySlug).toBe('pokemon'); | |
| 21 | + expect(graded.attributes.number).toMatch(/^\d{3}\/\d{3}$/); | |
| 22 | + expect(graded.attributes.language).toBe('Japanese'); | |
| 23 | + expect(graded.attributes.identifiers.surugaya_id).toBeTruthy(); | |
| 24 | + for (const l of listings) { | |
| 25 | + if (l.kind !== 'listing') continue; | |
| 26 | + expect(l.currency).toBe('JPY'); | |
| 27 | + expect(l.sourceUrl).toMatch(/suruga-ya\.jp\/product\//); | |
| 28 | + } | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it('helpers', () => { | |
| 32 | + expect(yen('中古:¥12,800')).toBe(12800); | |
| 33 | + expect(yen('品切れ')).toBeNull(); | |
| 34 | + expect(parseJpCardTitle('114/083[SAR]:【PSA/GEM MT 10】(キラ)メガゲッコウガex')).toEqual({ number: '114/083', rarity: 'SAR', name: 'メガゲッコウガex', grader: 'psa', grade: '10' }); | |
| 35 | + expect(parseJpCardTitle('096/071[SAR]:【PSA/MINT 9】(キラ)ナンジャモ').grade).toBe('9'); | |
| 36 | + expect(parseJpCardTitle('ポケモンカードゲーム デッキシールド').grader).toBeNull(); | |
| 37 | + }); | |
| 38 | +}); | |
added
connectors/firecrawl/surugaya/index.ts
+168 −0
@@ -0,0 +1,168 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedListingSchema, parseSourceDate, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { parseGradeFromTitle } from '@rareindex/taxonomy'; | |
| 5 | + | |
| 6 | +const BASE = 'https://www.suruga-ya.jp'; | |
| 7 | +const PARSER_VERSION = '1.0.0'; | |
| 8 | + | |
| 9 | +export const CardSchema = z.object({ | |
| 10 | + id: z.string(), | |
| 11 | + url: z.string(), | |
| 12 | + title: z.string(), | |
| 13 | + image: z.string().nullable(), | |
| 14 | + typeLabel: z.string().nullable(), | |
| 15 | + releaseDate: z.string().nullable(), | |
| 16 | + brand: z.string().nullable(), | |
| 17 | + price: z.number().nullable(), | |
| 18 | + soldOut: z.boolean(), | |
| 19 | + listPrice: z.number().nullable(), | |
| 20 | + marketplacePrice: z.number().nullable(), | |
| 21 | + used: z.boolean(), | |
| 22 | +}); | |
| 23 | +export type Card = z.infer<typeof CardSchema>; | |
| 24 | +export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), url: z.string(), categorySlug: z.string(), items: z.array(CardSchema) }); | |
| 25 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 26 | + | |
| 27 | +export function yen(s: string | null | undefined): number | null { | |
| 28 | + const m = s?.replace(/,/g, '').match(/[¥¥]\s*(\d+)/); | |
| 29 | + return m ? Number(m[1]) : null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Parse a search results page into compact cards. */ | |
| 33 | +export function parseSearchPage(htmlText: string, pageUrl: string, categorySlug: string): PagePayload { | |
| 34 | + const $ = H.load(htmlText); | |
| 35 | + const items: Card[] = []; | |
| 36 | + $('.item').each((_, el) => { | |
| 37 | + const e = $(el); | |
| 38 | + const a = e.find('a[href*="/product/detail/"], a[href*="/product/other/"]').first(); | |
| 39 | + const href = a.attr('href'); | |
| 40 | + if (!href) return; | |
| 41 | + const url = href.split('?')[0]!; | |
| 42 | + const id = url.match(/\/product\/(?:detail|other)\/([A-Za-z0-9]+)/)?.[1]; | |
| 43 | + const title = H.text(e.find('h3.product-name').first()) ?? H.text(a); | |
| 44 | + if (!id || !title) return; | |
| 45 | + const image = e.find('img').first().attr('src') ?? null; | |
| 46 | + const typeLabel = H.text(e.find('.condition').filter((__, c) => Boolean($(c).text().trim())).first()); | |
| 47 | + const releaseRaw = H.text(e.find('.release_date').first()); | |
| 48 | + const releaseDate = releaseRaw?.match(/(\d{4}\/\d{2}\/\d{2})/)?.[1] ?? null; | |
| 49 | + const brand = H.text(e.find('.brand').first())?.replace(/^\[|\]$/g, '').trim() || null; | |
| 50 | + const priceText = H.text(e.find('.item_price .price').first()) ?? ''; | |
| 51 | + const soldOut = /品切れ/.test(priceText); | |
| 52 | + const price = soldOut ? null : yen(priceText); | |
| 53 | + const listPrice = yen(H.text(e.find('.price_teika').first())); | |
| 54 | + const marketplacePrice = yen(e.find('.highlight-box strong').map((__, x) => $(x).text()).get().find((t) => /[¥¥]/.test(t)) ?? null); | |
| 55 | + const used = /中古/.test(e.text()) || /中古/.test(priceText); | |
| 56 | + items.push({ id, url: url.startsWith('http') ? url : BASE + url, title, image: image && image.startsWith('http') ? image : image ? BASE + image : null, typeLabel: typeLabel ?? null, releaseDate, brand, price, soldOut, listPrice, marketplacePrice, used }); | |
| 57 | + }); | |
| 58 | + return { kind: 'search_page', url: pageUrl, categorySlug, items }; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * Japanese card titles look like "114/083[SAR]:【PSA/GEM MT 10】(キラ)メガゲッコウガex". | |
| 63 | + * Extract card number, rarity code, grade and the bare name; everything unknown stays null. | |
| 64 | + */ | |
| 65 | +export function parseJpCardTitle(title: string): { number: string | null; rarity: string | null; name: string; grader: string | null; grade: string | null } { | |
| 66 | + const number = title.match(/^(\d{1,3}\/\d{1,3})/)?.[1] ?? null; | |
| 67 | + const rarity = title.match(/\[([A-Z]{1,4})\]/)?.[1] ?? null; | |
| 68 | + const g = parseGradeFromTitle(title.replace(/【([A-Z]{2,4})\/?/g, '【$1 ').replace(/[【】]/g, ' ')); | |
| 69 | + let name = title.replace(/^\d{1,3}\/\d{1,3}\s*/, '').replace(/\[[A-Z]{1,4}\]\s*[::]?\s*/, '').replace(/【[^】]*】\s*/g, '').replace(/\((キラ|ミラー|ノーマル)\)\s*/g, '').trim(); | |
| 70 | + if (!name) name = title; | |
| 71 | + return { number, rarity, name, grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grade }; | |
| 72 | +} | |
| 73 | + | |
| 74 | +export class SurugayaConnector extends BaseConnector { | |
| 75 | + readonly version = '1.0.0'; | |
| 76 | + readonly parserVersion = PARSER_VERSION; | |
| 77 | + protected override minIntervalMs = 2000; | |
| 78 | + | |
| 79 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 80 | + const seeds = (this.meta.config.seeds as Array<{ query: string; categorySlug: string }> | undefined) ?? []; | |
| 81 | + const pages = Number(this.meta.config.pagesPerSeed ?? 1); | |
| 82 | + const cap = ctx.options.limit; | |
| 83 | + let count = 0; | |
| 84 | + const startSeed = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 85 | + for (let i = startSeed; i < seeds.length; i++) { | |
| 86 | + const seed = seeds[i]!; | |
| 87 | + for (let page = 1; page <= pages; page++) { | |
| 88 | + if (ctx.signal?.aborted || (cap !== undefined && count >= cap)) return; | |
| 89 | + const url = `${BASE}/search?${seed.query}${page > 1 ? `&page=${page}` : ''}`; | |
| 90 | + await this.throttle(); | |
| 91 | + const res = await ctx.fetch(url, { | |
| 92 | + // Firecrawl only: Scrapfly's rendered fallback costs ~40 credits/page here and adds nothing. | |
| 93 | + engines: ['firecrawl'], | |
| 94 | + minQuality: 0.3, // pages where every card is sold out (no price) are still valid catalog data | |
| 95 | + expect: ['title', 'price'], | |
| 96 | + parse: (r) => { | |
| 97 | + const p = r.html ? parseSearchPage(r.html, url, seed.categorySlug) : null; | |
| 98 | + return p?.items.length ? { title: 'ok', price: p.items.some((x) => x.price || x.marketplacePrice) ? 1 : null } : null; | |
| 99 | + }, | |
| 100 | + }); | |
| 101 | + const payload = res.success && res.html ? parseSearchPage(res.html, url, seed.categorySlug) : null; | |
| 102 | + if (!payload?.items.length) { | |
| 103 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no cards'}`); | |
| 104 | + break; | |
| 105 | + } | |
| 106 | + count++; | |
| 107 | + yield { url, externalId: `${seed.query}|p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 108 | + } | |
| 109 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 114 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 115 | + const out: NormalizedRecord[] = []; | |
| 116 | + for (const c of p.items) { | |
| 117 | + const release = c.releaseDate ? parseSourceDate(c.releaseDate) : null; | |
| 118 | + const year = release ? release.getUTCFullYear() : null; | |
| 119 | + const isCard = ['pokemon', 'yugioh', 'one_piece_card_game'].includes(p.categorySlug); | |
| 120 | + const card = isCard ? parseJpCardTitle(c.title) : null; | |
| 121 | + const attributes = AssetAttributesSchema.parse({ | |
| 122 | + categorySlug: p.categorySlug, | |
| 123 | + brand: c.brand, | |
| 124 | + franchise: c.brand, | |
| 125 | + name: card?.name ?? c.title, | |
| 126 | + number: card?.number ?? null, | |
| 127 | + rarity: card?.rarity ?? null, | |
| 128 | + year, | |
| 129 | + language: 'Japanese', | |
| 130 | + region: 'JP', | |
| 131 | + originalMsrp: c.listPrice, | |
| 132 | + originalMsrpCurrency: c.listPrice ? 'JPY' : null, | |
| 133 | + identifiers: { surugaya_id: c.id }, | |
| 134 | + metadata: { type_label: c.typeLabel, release: c.releaseDate }, | |
| 135 | + }); | |
| 136 | + const rawTitle = `${c.title}${c.brand ? ` [${c.brand}]` : ''}${year ? ` (${year})` : ''}`; | |
| 137 | + const grade = { grader: card?.grader ?? null, grade: card?.grade ?? null, qualifier: null, certificationNumber: null }; | |
| 138 | + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: c.url, rawTitle, imageUrls: c.image ? [c.image] : [], attributes, grade, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; | |
| 139 | + out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: c.id, confidence: 0.8, releaseDate: release })); | |
| 140 | + const price = c.price ?? c.marketplacePrice; | |
| 141 | + if (price) { | |
| 142 | + const viaMarketplace = c.price === null && c.marketplacePrice !== null; | |
| 143 | + out.push( | |
| 144 | + NormalizedListingSchema.parse({ | |
| 145 | + kind: 'listing', | |
| 146 | + ...base, | |
| 147 | + externalId: viaMarketplace ? `${c.id}:mp` : c.id, | |
| 148 | + confidence: 0.8, | |
| 149 | + listingType: 'fixed_price', | |
| 150 | + price, | |
| 151 | + currency: 'JPY', | |
| 152 | + seller: viaMarketplace ? 'Suruga-ya marketplace seller' : 'Suruga-ya', | |
| 153 | + location: 'Japan', | |
| 154 | + condition: { condition: c.used || viaMarketplace ? null : 'mint_in_box', conditionRaw: viaMarketplace ? '中古 (marketplace)' : c.used ? '中古' : '新品', completeness: null }, | |
| 155 | + availability: 'available', | |
| 156 | + }), | |
| 157 | + ); | |
| 158 | + } else if (c.soldOut) { | |
| 159 | + out.push(NormalizedListingSchema.parse({ kind: 'listing', ...base, externalId: c.id, confidence: 0.7, listingType: 'fixed_price', price: null, currency: 'JPY', seller: 'Suruga-ya', location: 'Japan', availability: 'sold' })); | |
| 160 | + } | |
| 161 | + } | |
| 162 | + return out; | |
| 163 | + } | |
| 164 | +} | |
| 165 | + | |
| 166 | +export default function createConnector(meta: ConnectorMeta) { | |
| 167 | + return new SurugayaConnector(meta); | |
| 168 | +} | |
added
connectors/firecrawl/surugaya/meta.json
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +{ | |
| 2 | + "id": "surugaya", | |
| 3 | + "displayName": "Suruga-ya (Japanese second-hand hobby retailer, JPY)", | |
| 4 | + "sourceId": "surugaya", | |
| 5 | + "sourceName": "Suruga-ya", | |
| 6 | + "sourceType": "marketplace", | |
| 7 | + "sourceUrl": "https://www.suruga-ya.jp", | |
| 8 | + "module": "firecrawl/surugaya", | |
| 9 | + "enginePriority": ["firecrawl"], | |
| 10 | + "categories": ["pokemon", "yugioh", "one_piece_card_game", "gundam", "action_figures", "nintendo_games", "sega_games", "playstation_games", "designer_toys"], | |
| 11 | + "regions": ["JP"], | |
| 12 | + "languages": ["ja"], | |
| 13 | + "currency": ["JPY"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 720, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.75, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.suruga-ya.jp/man/index.html", | |
| 26 | + "accessNotes": "Public search result pages (suruga-ya.jp/search?...; robots.txt: 'Allow: /' for generic agents with Content-Signal search=yes, ai-train=no, use=reference — we only index prices and never train on content). Plain HTTPS returns 403 from data-centre IPs, so pages are fetched through Firecrawl (1 credit per page, ~24 items). Parsed per card: product id (shinaban), title, condition/type label, release date, brand, list price (定価), Suruga-ya price or 品切れ (sold out), and the marketplace (マケプレ) lowest price when Suruga-ya itself is out of stock. JPY. Emits catalog items + fixed-price listings; no sold history is available publicly.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + { "query": "category=5&search_word=%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%89%20PSA", "categorySlug": "pokemon", "label": "Pokémon cards PSA" }, | |
| 32 | + { "query": "category=5&search_word=%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%89%20SAR", "categorySlug": "pokemon", "label": "Pokémon SAR" }, | |
| 33 | + { "query": "category=5&search_word=%E9%81%8A%E6%88%AF%E7%8E%8B%20%E3%83%97%E3%83%AA%E3%82%BA%E3%83%9E", "categorySlug": "yugioh", "label": "Yu-Gi-Oh! prismatic" }, | |
| 34 | + { "query": "category=5&search_word=%E3%82%AC%E3%83%B3%E3%83%97%E3%83%A9%20MG", "categorySlug": "gundam", "label": "Gunpla MG" }, | |
| 35 | + { "query": "category=5&search_word=figma", "categorySlug": "action_figures", "label": "figma" }, | |
| 36 | + { "query": "category=5&search_word=%E3%82%B9%E3%83%BC%E3%83%91%E3%83%BC%E3%83%95%E3%82%A1%E3%83%9F%E3%82%B3%E3%83%B3%20%E3%82%BD%E3%83%95%E3%83%88", "categorySlug": "nintendo_games", "label": "Super Famicom software" } | |
| 37 | + ], | |
| 38 | + "pagesPerSeed": 1 | |
| 39 | + } | |
| 40 | +} | |
added
connectors/scrapfly/amiami/_smoke.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { createRouter, createCrawlContext, ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 4 | +import createConnector from './index.js'; | |
| 5 | + | |
| 6 | +const save = process.argv.includes('--save'); | |
| 7 | +const meta = ConnectorMetaSchema.parse(metaJson); | |
| 8 | +const connector = createConnector(meta); | |
| 9 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 10 | +const ctx = createCrawlContext({ router, meta, options: { mode: 'probe', limit: 1 } }); | |
| 11 | +let i = 0; | |
| 12 | +for await (const raw of connector.crawl(ctx)) { | |
| 13 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 14 | + console.log(`raw ${raw.externalId} → ${out.length} records`); | |
| 15 | + for (const r of out.slice(0, 4)) console.log(' ', JSON.stringify(r).slice(0, 320)); | |
| 16 | + if (save && i === 0) { | |
| 17 | + const p = raw.payload as { items: unknown[] }; | |
| 18 | + p.items = p.items.slice(0, 6); | |
| 19 | + saveFixture('amiami', 'figures-preowned-p1', { raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, expect: { minCount: 6, kinds: ['catalog_item', 'listing'] }, note: 'Captured live from api.amiami.com (pre-owned figures, page 1, trimmed to 6 items)' }); | |
| 20 | + } | |
| 21 | + i++; | |
| 22 | +} | |
| 23 | +console.log('engineStats', ctx.engineStats, 'anomalies', ctx.anomalies); | |
added
connectors/scrapfly/amiami/index.test.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { ConnectorMetaSchema } from '@rareindex/connectors'; | |
| 3 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 4 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 5 | +import createConnector, { cleanName, preownedCondition, releaseYear } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(ConnectorMetaSchema.parse(metaJson)); | |
| 8 | + | |
| 9 | +describe('amiami', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits catalog + JPY listings with JAN and maker', async () => { | |
| 13 | + const fx = loadFixture('amiami', 'figures-preowned-p1'); | |
| 14 | + const out = await connector.normalize(fx.raw); | |
| 15 | + const cats = out.filter((r) => r.kind === 'catalog_item'); | |
| 16 | + const lst = out.filter((r) => r.kind === 'listing'); | |
| 17 | + expect(cats.length).toBeGreaterThan(0); | |
| 18 | + expect(lst.length).toBeGreaterThan(0); | |
| 19 | + const l = lst[0]!; | |
| 20 | + if (l.kind !== 'listing') throw new Error(); | |
| 21 | + expect(l.currency).toBe('JPY'); | |
| 22 | + expect(l.price).toBeGreaterThan(0); | |
| 23 | + expect(l.attributes.categorySlug).toBe('action_figures'); | |
| 24 | + expect(l.attributes.identifiers.amiami_gcode).toBeTruthy(); | |
| 25 | + expect(l.sourceUrl).toContain('amiami.com/eng/detail/?gcode='); | |
| 26 | + expect(cats.some((c) => c.kind === 'catalog_item' && c.attributes.identifiers.jan)).toBe(true); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it('helpers', () => { | |
| 30 | + expect(cleanName('[AmiAmi Exclusive Bonus] Azur Lane Glorious 1/7 Complete Figure')).toEqual({ name: 'Azur Lane Glorious 1/7 Complete Figure', bonus: true, scale: '1/7' }); | |
| 31 | + expect(preownedCondition({ gcode: 'X', gname: '(Pre-owned ITEM:A/BOX:B) Foo', condition_flg: 1 })).toEqual({ condition: 'near_mint_box', raw: 'Pre-owned ITEM:A/BOX:B' }); | |
| 32 | + expect(preownedCondition({ gcode: 'X', gname: 'Foo', condition_flg: 0 }).condition).toBe('mint_in_box'); | |
| 33 | + expect(releaseYear('2026-04-30 00:00:00')).toBe(2026); | |
| 34 | + expect(releaseYear('Apr-2026')).toBe(2026); | |
| 35 | + expect(releaseYear(null)).toBeNull(); | |
| 36 | + }); | |
| 37 | +}); | |
added
connectors/scrapfly/amiami/index.ts
+188 −0
@@ -0,0 +1,188 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedCatalogItemSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * AmiAmi — Japanese figure/hobby retailer. Uses the storefront's public JSON API via Scrapfly | |
| 7 | + * (Cloudflare blocks plain data-centre requests). One raw record per result page. | |
| 8 | + */ | |
| 9 | +const API = 'https://api.amiami.com/api/v1.0'; | |
| 10 | +const SITE = 'https://www.amiami.com'; | |
| 11 | +const IMG = 'https://img.amiami.com'; | |
| 12 | +const PARSER_VERSION = '1.0.0'; | |
| 13 | + | |
| 14 | +const ItemSchema = z.object({ | |
| 15 | + gcode: z.string(), | |
| 16 | + gname: z.string(), | |
| 17 | + thumb_url: z.string().nullable().optional(), | |
| 18 | + min_price: z.number().nullable().optional(), | |
| 19 | + max_price: z.number().nullable().optional(), | |
| 20 | + c_price_taxed: z.number().nullable().optional(), | |
| 21 | + maker_name: z.string().nullable().optional(), | |
| 22 | + condition_flg: z.number().nullable().optional(), | |
| 23 | + instock_flg: z.number().nullable().optional(), | |
| 24 | + order_closed_flg: z.number().nullable().optional(), | |
| 25 | + releasedate: z.string().nullable().optional(), | |
| 26 | + jancode: z.string().nullable().optional(), | |
| 27 | + preowned_sale_flg: z.number().nullable().optional(), | |
| 28 | + resale_flg: z.number().nullable().optional(), | |
| 29 | + saleitem: z.number().nullable().optional(), | |
| 30 | +}); | |
| 31 | +export type AmiAmiItem = z.infer<typeof ItemSchema>; | |
| 32 | + | |
| 33 | +export const PagePayloadSchema = z.object({ | |
| 34 | + kind: z.literal('search_page'), | |
| 35 | + params: z.string(), | |
| 36 | + categorySlug: z.string(), | |
| 37 | + page: z.number(), | |
| 38 | + total: z.number().nullable(), | |
| 39 | + items: z.array(ItemSchema), | |
| 40 | +}); | |
| 41 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 42 | + | |
| 43 | +const KEEP: Array<keyof AmiAmiItem> = ['gcode', 'gname', 'thumb_url', 'min_price', 'max_price', 'c_price_taxed', 'maker_name', 'condition_flg', 'instock_flg', 'order_closed_flg', 'releasedate', 'jancode', 'preowned_sale_flg', 'resale_flg', 'saleitem']; | |
| 44 | + | |
| 45 | +export function trimItem(raw: Record<string, unknown>): AmiAmiItem | null { | |
| 46 | + const o: Record<string, unknown> = {}; | |
| 47 | + for (const k of KEEP) if (k in raw) o[k] = raw[k]; | |
| 48 | + const p = ItemSchema.safeParse(o); | |
| 49 | + return p.success ? p.data : null; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** "[AmiAmi Exclusive Bonus] Azur Lane Glorious Chinese New Year Ver. 1/7 Complete Figure" → clean name + flags */ | |
| 53 | +export function cleanName(gname: string): { name: string; bonus: boolean; scale: string | null } { | |
| 54 | + let name = gname.replace(/\[[^\]]*(Bonus|Exclusive|Limited)[^\]]*\]\s*/gi, '').trim(); | |
| 55 | + const bonus = name !== gname.trim(); | |
| 56 | + const scale = name.match(/\b(1\/\d{1,3})\b/)?.[1] ?? null; | |
| 57 | + name = name.replace(/\s+/g, ' ').trim(); | |
| 58 | + return { name, bonus, scale }; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** "(Pre-owned ITEM:A/BOX:B)" grades → normalised condition for the boxed_toys scale. */ | |
| 62 | +export function preownedCondition(item: AmiAmiItem): { condition: string | null; raw: string | null } { | |
| 63 | + if (item.condition_flg !== 1) return { condition: 'mint_in_box', raw: 'New' }; | |
| 64 | + const m = item.gname.match(/ITEM:([A-Z][+-]?)\/BOX:([A-Z][+-]?)/i); | |
| 65 | + if (!m) return { condition: 'boxed', raw: 'Pre-owned' }; | |
| 66 | + const itemGrade = m[1]!.toUpperCase(); | |
| 67 | + const cond = itemGrade.startsWith('A') ? 'near_mint_box' : itemGrade.startsWith('B') ? 'boxed' : 'loose_complete'; | |
| 68 | + return { condition: cond, raw: `Pre-owned ITEM:${m[1]}/BOX:${m[2]}` }; | |
| 69 | +} | |
| 70 | + | |
| 71 | +export function releaseYear(s: string | null | undefined): number | null { | |
| 72 | + const m = s?.match(/(\d{4})/); | |
| 73 | + return m ? Number(m[1]) : null; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export class AmiAmiConnector extends BaseConnector { | |
| 77 | + readonly version = '1.0.0'; | |
| 78 | + readonly parserVersion = PARSER_VERSION; | |
| 79 | + protected override minIntervalMs = 1500; | |
| 80 | + override readonly urlPatterns = [/^https?:\/\/(www\.)?amiami\.com\/(eng|jp)\/detail\/?\?.*gcode=([A-Z0-9-]+)/i]; | |
| 81 | + | |
| 82 | + private apiOpts() { | |
| 83 | + return { engines: ['scrapfly' as const], renderJs: false, country: 'jp', headers: { 'X-User-Key': 'amiami_dev' }, timeoutMs: 60_000 }; | |
| 84 | + } | |
| 85 | + | |
| 86 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 87 | + const seeds = (this.meta.config.seeds as Array<{ params: string; categorySlug: string }> | undefined) ?? []; | |
| 88 | + const pages = Number(this.meta.config.pagesPerSeed ?? 2); | |
| 89 | + const pageSize = Number(this.meta.config.pageSize ?? 50); | |
| 90 | + const cap = ctx.options.limit; | |
| 91 | + let count = 0; | |
| 92 | + const startSeed = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 93 | + for (let i = startSeed; i < seeds.length; i++) { | |
| 94 | + const seed = seeds[i]!; | |
| 95 | + for (let page = 1; page <= pages; page++) { | |
| 96 | + if (ctx.signal?.aborted || (cap !== undefined && count >= cap)) return; | |
| 97 | + const url = `${API}/items?pagemax=${pageSize}&lang=eng&${seed.params}&pagecnt=${page}`; | |
| 98 | + await this.throttle(); | |
| 99 | + const res = await ctx.fetch(url, { | |
| 100 | + ...this.apiOpts(), | |
| 101 | + expect: ['title', 'price'], | |
| 102 | + parse: (r) => { | |
| 103 | + const j = r.json as { items?: unknown[] } | null; | |
| 104 | + return j?.items?.length ? { title: 'ok', price: 1 } : null; | |
| 105 | + }, | |
| 106 | + }); | |
| 107 | + const j = res.json as { items?: Record<string, unknown>[]; search_result?: { total_results?: number } } | null; | |
| 108 | + if (!res.success || !j?.items) { | |
| 109 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); | |
| 110 | + break; | |
| 111 | + } | |
| 112 | + const items = j.items.map(trimItem).filter((x): x is AmiAmiItem => Boolean(x)); | |
| 113 | + const payload: PagePayload = { kind: 'search_page', params: seed.params, categorySlug: seed.categorySlug, page, total: j.search_result?.total_results ?? null, items }; | |
| 114 | + count++; | |
| 115 | + yield { url, externalId: `${seed.params}|p${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 116 | + if (items.length < pageSize) break; | |
| 117 | + } | |
| 118 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 123 | + const m = url.match(this.urlPatterns[0]!); | |
| 124 | + if (!m) return []; | |
| 125 | + const gcode = m[3]!; | |
| 126 | + const api = `${API}/item?gcode=${encodeURIComponent(gcode)}&lang=eng`; | |
| 127 | + const res = await ctx.fetch(api, { ...this.apiOpts(), minQuality: 0 }); | |
| 128 | + const j = res.json as { item?: Record<string, unknown> } | null; | |
| 129 | + if (!res.success || !j?.item) return []; | |
| 130 | + const it = { ...j.item, min_price: j.item.price ?? j.item.min_price, max_price: j.item.price ?? j.item.max_price, c_price_taxed: j.item.list_price ?? j.item.c_price_taxed }; | |
| 131 | + const item = trimItem(it as Record<string, unknown>); | |
| 132 | + if (!item) return []; | |
| 133 | + const payload: PagePayload = { kind: 'search_page', params: `gcode=${gcode}`, categorySlug: 'action_figures', page: 1, total: 1, items: [item] }; | |
| 134 | + return [{ url: api, externalId: gcode, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; | |
| 135 | + } | |
| 136 | + | |
| 137 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 138 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 139 | + const out: NormalizedRecord[] = []; | |
| 140 | + for (const it of p.items) { | |
| 141 | + const { name, bonus, scale } = cleanName(it.gname); | |
| 142 | + const year = releaseYear(it.releasedate); | |
| 143 | + const cond = preownedCondition(it); | |
| 144 | + const sourceUrl = `${SITE}/eng/detail/?gcode=${encodeURIComponent(it.gcode)}`; | |
| 145 | + const image = it.thumb_url ? `${IMG}${it.thumb_url}` : null; | |
| 146 | + const baseCode = it.gcode.replace(/-R$/, ''); // "-R" = pre-owned listing of the same product | |
| 147 | + const attributes = AssetAttributesSchema.parse({ | |
| 148 | + categorySlug: p.categorySlug, | |
| 149 | + brand: it.maker_name ?? null, | |
| 150 | + name, | |
| 151 | + year, | |
| 152 | + size: scale, | |
| 153 | + originalMsrp: it.c_price_taxed ?? null, | |
| 154 | + originalMsrpCurrency: it.c_price_taxed ? 'JPY' : null, | |
| 155 | + identifiers: { amiami_gcode: baseCode, ...(it.jancode ? { jan: it.jancode } : {}) }, | |
| 156 | + metadata: { bonus_edition: bonus, release: it.releasedate ?? null, resale: it.resale_flg === 1 }, | |
| 157 | + }); | |
| 158 | + const rawTitle = `${name}${it.maker_name ? ` · ${it.maker_name}` : ''}${year ? ` (${year})` : ''}`; | |
| 159 | + const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl, rawTitle, imageUrls: image ? [image] : [], attributes, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION }; | |
| 160 | + out.push(NormalizedCatalogItemSchema.parse({ kind: 'catalog_item', ...base, externalId: baseCode, confidence: 0.85, releaseDate: null })); | |
| 161 | + const price = it.min_price ?? it.max_price ?? null; | |
| 162 | + if (price && price > 0) { | |
| 163 | + const available = it.instock_flg === 1 && it.order_closed_flg !== 1; | |
| 164 | + out.push( | |
| 165 | + NormalizedListingSchema.parse({ | |
| 166 | + kind: 'listing', | |
| 167 | + ...base, | |
| 168 | + externalId: it.gcode, | |
| 169 | + confidence: 0.85, | |
| 170 | + listingType: 'fixed_price', | |
| 171 | + price, | |
| 172 | + currency: 'JPY', | |
| 173 | + seller: 'AmiAmi', | |
| 174 | + location: 'Japan', | |
| 175 | + condition: { condition: cond.condition, conditionRaw: cond.raw, completeness: it.condition_flg === 1 ? 'boxed' : 'sealed' }, | |
| 176 | + availability: available ? 'available' : 'sold', | |
| 177 | + quantity: null, | |
| 178 | + }), | |
| 179 | + ); | |
| 180 | + } | |
| 181 | + } | |
| 182 | + return out; | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +export default function createConnector(meta: ConnectorMeta) { | |
| 187 | + return new AmiAmiConnector(meta); | |
| 188 | +} | |
added
connectors/scrapfly/amiami/meta.json
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +{ | |
| 2 | + "id": "amiami", | |
| 3 | + "displayName": "AmiAmi (figures & hobby — new + pre-owned, JPY)", | |
| 4 | + "sourceId": "amiami", | |
| 5 | + "sourceName": "AmiAmi", | |
| 6 | + "sourceType": "marketplace", | |
| 7 | + "sourceUrl": "https://www.amiami.com", | |
| 8 | + "module": "scrapfly/amiami", | |
| 9 | + "enginePriority": ["scrapfly"], | |
| 10 | + "categories": ["action_figures", "gundam", "plush", "designer_toys"], | |
| 11 | + "regions": ["JP"], | |
| 12 | + "languages": ["en", "ja"], | |
| 13 | + "currency": ["JPY"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": true, | |
| 21 | + "refreshFrequencyMinutes": 720, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.8, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.amiami.com/eng/guide/", | |
| 26 | + "accessNotes": "Reads the public JSON API that AmiAmi's own storefront calls (api.amiami.com/api/v1.0/items with the storefront's X-User-Key header, no account or login). Plain requests from data-centre IPs get a Cloudflare 'Attention Required' page, so the request is routed through Scrapfly (asp, no JS, country jp; 1 credit per 50-item page). Data: item code, JAN, maker, release date, list price (tax-in), current sell price (pre-owned range min/max or new price), stock and pre-owned flags — all JPY. Emits catalog items + fixed-price listings; pre-owned condition comes from the storefront's ITEM/BOX grades when present in the title. No purchase, cart or account endpoints are touched.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + { "params": "s_cate_tag=14&s_st_condition_flg=1", "categorySlug": "action_figures", "label": "Figures — pre-owned" }, | |
| 32 | + { "params": "s_cate_tag=14&s_st_condition_flg=0", "categorySlug": "action_figures", "label": "Figures — new" }, | |
| 33 | + { "params": "s_keywords=Gundam&s_st_condition_flg=1", "categorySlug": "gundam", "label": "Gundam — pre-owned" }, | |
| 34 | + { "params": "s_keywords=Nendoroid", "categorySlug": "action_figures", "label": "Nendoroid" } | |
| 35 | + ], | |
| 36 | + "pagesPerSeed": 2, | |
| 37 | + "pageSize": 50 | |
| 38 | + } | |
| 39 | +} | |
added
data/fixtures/amiami/figures-preowned-p1.json
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://api.amiami.com/api/v1.0/items?pagemax=50&lang=eng&s_cate_tag=14&s_st_condition_flg=1&pagecnt=1", | |
| 4 | + "externalId": "s_cate_tag=14&s_st_condition_flg=1|p1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "scrapfly", | |
| 7 | + "fetchedAt": "2026-09-07T06:25:56.894Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "search_page", | |
| 10 | + "params": "s_cate_tag=14&s_st_condition_flg=1", | |
| 11 | + "categorySlug": "action_figures", | |
| 12 | + "page": 1, | |
| 13 | + "total": 7244, | |
| 14 | + "items": [ | |
| 15 | + { | |
| 16 | + "gcode": "FIGURE-180313-R", | |
| 17 | + "gname": "[AmiAmi Exclusive Bonus] Azur Lane Glorious Chinese New Year Ver. 1/7 Complete Figure", | |
| 18 | + "thumb_url": "/images/product/main/244/FIGURE-180313.jpg", | |
| 19 | + "min_price": 20680, | |
| 20 | + "max_price": 22980, | |
| 21 | + "c_price_taxed": 30580, | |
| 22 | + "maker_name": "Alter", | |
| 23 | + "condition_flg": 1, | |
| 24 | + "instock_flg": 1, | |
| 25 | + "order_closed_flg": 0, | |
| 26 | + "releasedate": "2026-04-30 00:00:00", | |
| 27 | + "jancode": "4560228207415", | |
| 28 | + "preowned_sale_flg": 0, | |
| 29 | + "resale_flg": 0, | |
| 30 | + "saleitem": 0 | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + "gcode": "FIG-MOE-9206-R", | |
| 34 | + "gname": "Super Sonico SoniComi Package ver. 1/5 Complete Figure", | |
| 35 | + "thumb_url": "/images/product/main/132/FIG-MOE-9206.jpg", | |
| 36 | + "min_price": 32380, | |
| 37 | + "max_price": 35980, | |
| 38 | + "c_price_taxed": 12571, | |
| 39 | + "maker_name": "OrchidSeed", | |
| 40 | + "condition_flg": 1, | |
| 41 | + "instock_flg": 1, | |
| 42 | + "order_closed_flg": 0, | |
| 43 | + "releasedate": "2013-12-28 00:00:00", | |
| 44 | + "jancode": "4582292601265", | |
| 45 | + "preowned_sale_flg": 0, | |
| 46 | + "resale_flg": 0, | |
| 47 | + "saleitem": 0 | |
| 48 | + }, | |
| 49 | + { | |
| 50 | + "gcode": "FIGURE-191723-R", | |
| 51 | + "gname": "Super Sonico Nurse Bikini ver. Complete Figure", | |
| 52 | + "thumb_url": "/images/product/main/253/FIGURE-191723.jpg", | |
| 53 | + "min_price": 39580, | |
| 54 | + "max_price": 43980, | |
| 55 | + "c_price_taxed": 25300, | |
| 56 | + "maker_name": "Union Creative", | |
| 57 | + "condition_flg": 1, | |
| 58 | + "instock_flg": 1, | |
| 59 | + "order_closed_flg": 0, | |
| 60 | + "releasedate": "2026-05-31 00:00:00", | |
| 61 | + "jancode": "4589642716778", | |
| 62 | + "preowned_sale_flg": 0, | |
| 63 | + "resale_flg": 0, | |
| 64 | + "saleitem": 0 | |
| 65 | + }, | |
| 66 | + { | |
| 67 | + "gcode": "FIGURE-201904-R", | |
| 68 | + "gname": "Puella Magi Madoka Magica the Movie -Walpurgisnacht: Rising- Madoka Kaname Figure (Game-prize)", | |
| 69 | + "thumb_url": "/images/product/main/262/FIGURE-201904.jpg", | |
| 70 | + "min_price": 3280, | |
| 71 | + "max_price": 3280, | |
| 72 | + "c_price_taxed": 0, | |
| 73 | + "maker_name": "BANDAI SPIRITS", | |
| 74 | + "condition_flg": 1, | |
| 75 | + "instock_flg": 1, | |
| 76 | + "order_closed_flg": 0, | |
| 77 | + "releasedate": "2026-05-31 00:00:00", | |
| 78 | + "jancode": "4573102705037", | |
| 79 | + "preowned_sale_flg": 0, | |
| 80 | + "resale_flg": 0, | |
| 81 | + "saleitem": 0 | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "gcode": "FIG-MOE-4497-R", | |
| 85 | + "gname": "Mikumo #01 Original Collection - Koi-Iro Byoutou (Love-Colored Hospital Ward) Complete Figure", | |
| 86 | + "thumb_url": "/images/product/main/114/FIG-MOE-4497.jpg", | |
| 87 | + "min_price": 110380, | |
| 88 | + "max_price": 119980, | |
| 89 | + "c_price_taxed": 4126, | |
| 90 | + "maker_name": "Hobby Stock", | |
| 91 | + "condition_flg": 1, | |
| 92 | + "instock_flg": 1, | |
| 93 | + "order_closed_flg": 0, | |
| 94 | + "releasedate": "2012-01-28 00:00:00", | |
| 95 | + "jancode": "4582225000110", | |
| 96 | + "preowned_sale_flg": 0, | |
| 97 | + "resale_flg": 0, | |
| 98 | + "saleitem": 0 | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + "gcode": "FIGURE-184067-R", | |
| 102 | + "gname": "DISH Original Priestess Shibuna 1/7 Complete Figure", | |
| 103 | + "thumb_url": "/images/product/main/251/FIGURE-184067.jpg", | |
| 104 | + "min_price": 34380, | |
| 105 | + "max_price": 42980, | |
| 106 | + "c_price_taxed": 26180, | |
| 107 | + "maker_name": "ques Q", | |
| 108 | + "condition_flg": 1, | |
| 109 | + "instock_flg": 1, | |
| 110 | + "order_closed_flg": 0, | |
| 111 | + "releasedate": "2026-05-31 00:00:00", | |
| 112 | + "jancode": "4560393843104", | |
| 113 | + "preowned_sale_flg": 0, | |
| 114 | + "resale_flg": 0, | |
| 115 | + "saleitem": 0 | |
| 116 | + } | |
| 117 | + ] | |
| 118 | + } | |
| 119 | + }, | |
| 120 | + "expect": { | |
| 121 | + "minCount": 6, | |
| 122 | + "kinds": [ | |
| 123 | + "catalog_item", | |
| 124 | + "listing" | |
| 125 | + ] | |
| 126 | + }, | |
| 127 | + "note": "Captured live from api.amiami.com (pre-owned figures, page 1, trimmed to 6 items)", | |
| 128 | + "capturedAt": "2026-09-07T06:25:56.899Z" | |
| 129 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/apmex/liberty-eagle-category-p1.json
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.apmex.com/category/11903/10-liberty-eagle-coins-1795-1907", | |
| 4 | + "externalId": "https://www.apmex.com/category/11903/10-liberty-eagle-coins-1795-1907|p1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T06:29:19.655Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "category_page", | |
| 10 | + "url": "https://www.apmex.com/category/11903/10-liberty-eagle-coins-1795-1907", | |
| 11 | + "series": "$10 Liberty Gold Eagle", | |
| 12 | + "country": "US", | |
| 13 | + "items": [ | |
| 14 | + { | |
| 15 | + "id": "9121", | |
| 16 | + "url": "https://www.apmex.com/product/9121/10-liberty-gold-eagle-cleaned", | |
| 17 | + "title": "$10 Liberty Gold Eagle (Cleaned)", | |
| 18 | + "price": 2159.66, | |
| 19 | + "message": "As Low As", | |
| 20 | + "image": "https://www.images-apmex.com/images/products/10-liberty-gold-eagle-cleaned_9121_Slab.jpg?v=20191107213552?v=20250325121239&width=130&height=130", | |
| 21 | + "badge": "Top Pick" | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "id": "118", | |
| 25 | + "url": "https://www.apmex.com/product/118/10-liberty-gold-eagle-xf-random-year", | |
| 26 | + "title": "$10 Liberty Gold Eagle XF (Random Year)", | |
| 27 | + "price": 2167.66, | |
| 28 | + "message": "As Low As", | |
| 29 | + "image": "https://www.images-apmex.com/images/products/10-liberty-gold-eagle-xf-random-year_118_Slab.jpg?v=20191107213552?v=20260831030714&width=130&height=130", | |
| 30 | + "badge": "Top Pick" | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + "id": "1123", | |
| 34 | + "url": "https://www.apmex.com/product/1123/10-liberty-gold-eagle-au-random-year", | |
| 35 | + "title": "$10 Liberty Gold Eagle AU (Random Year)", | |
| 36 | + "price": 2184.66, | |
| 37 | + "message": "Any Quantity", | |
| 38 | + "image": "https://www.images-apmex.com/images/products/10-liberty-gold-eagle-au-random-year_1123_slab.jpg?v=20191107093552&width=130&height=130", | |
| 39 | + "badge": "Sale" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "id": "98270", | |
| 43 | + "url": "https://www.apmex.com/product/98270/10-liberty-gold-eagle-bu-random-year", | |
| 44 | + "title": "$10 Liberty Gold Eagle BU (Random Year)", | |
| 45 | + "price": 2204.66, | |
| 46 | + "message": "As Low As", | |
| 47 | + "image": "https://www.images-apmex.com/images/products/10-liberty-gold-eagle-bu-random-year_98270_Obv.jpg?v=20191107213552?v=20260616101018&width=130&height=130", | |
| 48 | + "badge": "Sale" | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "id": "90236", | |
| 52 | + "url": "https://www.apmex.com/product/90236/1838-1866-10-liberty-gold-eagle-no-motto-cleaned", | |
| 53 | + "title": "1838-1866 $10 Liberty Gold Eagle No Motto (Cleaned)", | |
| 54 | + "price": 2194.66, | |
| 55 | + "message": "As Low As", | |
| 56 | + "image": "https://www.images-apmex.com/images/products/1838-1866-10-liberty-gold-eagle-no-motto-cleaned_90236_Obv.jpg?v=20191025091623&v=20260318163529&v=?v=20260507121536&width=130&height=130", | |
| 57 | + "badge": null | |
| 58 | + }, | |
| 59 | + { | |
| 60 | + "id": "230455", | |
| 61 | + "url": "https://www.apmex.com/product/230455/10-liberty-gold-eagle-new-orleans-mint-cleaned", | |
| 62 | + "title": "$10 Liberty Gold Eagle New Orleans Mint (Cleaned)", | |
| 63 | + "price": 2219.66, | |
| 64 | + "message": "Any Quantity", | |
| 65 | + "image": "https://www.images-apmex.com/images/products/10-liberty-gold-eagle-new-orleans-mint-cleaned_230455_obv.jpg?v=20210324090408?v=20260630105820&width=130&height=130", | |
| 66 | + "badge": null | |
| 67 | + }, | |
| 68 | + { | |
| 69 | + "id": "23197", | |
| 70 | + "url": "https://www.apmex.com/product/23197/10-liberty-gold-eagle-ms-61-ngc-random", | |
| 71 | + "title": "$10 Liberty Gold Eagle MS-61 NGC (Random)", | |
| 72 | + "price": 2234.66, | |
| 73 | + "message": "Any Quantity", | |
| 74 | + "image": "https://www.images-apmex.com/images/products/10-liberty-gold-eagle-ms-61-ngc-random_23197_Slab.jpg?v=20191025091623?v=20250314114952&width=130&height=130", | |
| 75 | + "badge": null | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + "id": "1125", | |
| 79 | + "url": "https://www.apmex.com/product/1125/10-liberty-gold-eagle-ms-62-ngc-random", | |
| 80 | + "title": "$10 Liberty Gold Eagle MS-62 NGC (Random)", | |
| 81 | + "price": 2244.66, | |
| 82 | + "message": "Any Quantity", | |
| 83 | + "image": "https://www.images-apmex.com/images/products/10-liberty-gold-eagle-ms-62-ngc-random_1125_slab.jpg?v=20191025091623&width=130&height=130", | |
| 84 | + "badge": "Sale" | |
| 85 | + } | |
| 86 | + ] | |
| 87 | + } | |
| 88 | + }, | |
| 89 | + "expect": { | |
| 90 | + "minCount": 8, | |
| 91 | + "kinds": [ | |
| 92 | + "catalog_item", | |
| 93 | + "listing" | |
| 94 | + ] | |
| 95 | + }, | |
| 96 | + "note": "Captured live from apmex.com $10 Liberty Eagle category, trimmed to 8 cards", | |
| 97 | + "capturedAt": "2026-09-07T06:29:19.743Z" | |
| 98 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/fossilera/dinosaur-teeth-p1.json
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.fossilera.com/fossils-for-sale/dinosaur-teeth", | |
| 4 | + "externalId": "/fossils-for-sale/dinosaur-teeth|p1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T06:29:21.057Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "category_page", | |
| 10 | + "url": "https://www.fossilera.com/fossils-for-sale/dinosaur-teeth", | |
| 11 | + "categorySlug": "fossils", | |
| 12 | + "items": [ | |
| 13 | + { | |
| 14 | + "item": "355472", | |
| 15 | + "url": "https://www.fossilera.com/fossils/beastly-3-95-serrated-tyrannosaurus-t-rex-tooth-montana", | |
| 16 | + "title": "Beastly 3.95\" Serrated Tyrannosaurus (T. rex) Tooth - Montana", | |
| 17 | + "price": 24500, | |
| 18 | + "oldPrice": 29500, | |
| 19 | + "sold": false, | |
| 20 | + "image": "https://assets0.fossilera.com/sp/845790/t-rex-teeth/325x215/tyrannosaurus-rex.jpg", | |
| 21 | + "species": null, | |
| 22 | + "age": null, | |
| 23 | + "location": null, | |
| 24 | + "formation": null, | |
| 25 | + "size": null, | |
| 26 | + "category": null, | |
| 27 | + "subCategory": null | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "item": "359350", | |
| 31 | + "url": "https://www.fossilera.com/fossils/4-57-serrated-carcharodontosaurus-tooth-giant-dinosaur-tooth", | |
| 32 | + "title": "4.57\" Serrated Carcharodontosaurus Tooth - Giant Dinosaur Tooth", | |
| 33 | + "price": 1495, | |
| 34 | + "oldPrice": null, | |
| 35 | + "sold": false, | |
| 36 | + "image": "https://assets0.fossilera.com/sp/849367/carcharodontosaurus-teeth/325x215/carcharodontosaurus-sp.jpg", | |
| 37 | + "species": null, | |
| 38 | + "age": null, | |
| 39 | + "location": null, | |
| 40 | + "formation": null, | |
| 41 | + "size": null, | |
| 42 | + "category": null, | |
| 43 | + "subCategory": null | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + "item": "352102", | |
| 47 | + "url": "https://www.fossilera.com/fossils/1-00-fossil-iguanodontid-mantellisaurus-tooth-england", | |
| 48 | + "title": "1.00\" Fossil Iguanodontid (I. bernissartensis) Tooth - England", | |
| 49 | + "price": 1250, | |
| 50 | + "oldPrice": null, | |
| 51 | + "sold": false, | |
| 52 | + "image": "https://assets3.fossilera.com/sp/835821/dinosaur-teeth/325x215/iguanodon-bernissartensis.jpg", | |
| 53 | + "species": null, | |
| 54 | + "age": null, | |
| 55 | + "location": null, | |
| 56 | + "formation": null, | |
| 57 | + "size": null, | |
| 58 | + "category": null, | |
| 59 | + "subCategory": null | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + "item": "356969", | |
| 63 | + "url": "https://www.fossilera.com/fossils/2-55-camarasaurus-tooth-with-partial-root-in-situ-bone-cabin-quarry", | |
| 64 | + "title": "2.55\" Camarasaurus Tooth with Partial Root in Situ - Bone Cabin Quarry", | |
| 65 | + "price": 1150, | |
| 66 | + "oldPrice": null, | |
| 67 | + "sold": false, | |
| 68 | + "image": "https://assets2.fossilera.com/sp/844696/sauropod/325x215/camarasaurus-sp.jpg", | |
| 69 | + "species": null, | |
| 70 | + "age": null, | |
| 71 | + "location": null, | |
| 72 | + "formation": null, | |
| 73 | + "size": null, | |
| 74 | + "category": null, | |
| 75 | + "subCategory": null | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + "item": "364766", | |
| 79 | + "url": "https://www.fossilera.com/fossils/serrated-3-33-carcharodontosaurus-tooth-real-dinosaur-tooth", | |
| 80 | + "title": "Serrated, 3.33\" Carcharodontosaurus Tooth - Real Dinosaur Tooth", | |
| 81 | + "price": 795, | |
| 82 | + "oldPrice": null, | |
| 83 | + "sold": false, | |
| 84 | + "image": "https://assets3.fossilera.com/sp/858717/carcharodontosaurus-teeth/325x215/carcharodontosaurus-sp.jpg", | |
| 85 | + "species": null, | |
| 86 | + "age": null, | |
| 87 | + "location": null, | |
| 88 | + "formation": null, | |
| 89 | + "size": null, | |
| 90 | + "category": null, | |
| 91 | + "subCategory": null | |
| 92 | + }, | |
| 93 | + { | |
| 94 | + "item": "374003", | |
| 95 | + "url": "https://www.fossilera.com/fossils/4-30-real-spinosaurus-tooth-robust-dinosaur-tooth", | |
| 96 | + "title": "4.30\" Real Spinosaurus Tooth - Robust Dinosaur Tooth", | |
| 97 | + "price": 395, | |
| 98 | + "oldPrice": null, | |
| 99 | + "sold": false, | |
| 100 | + "image": "https://assets1.fossilera.com/sp/875464/spinosaurus-teeth/325x215/spinosaurus-sp.jpg", | |
| 101 | + "species": null, | |
| 102 | + "age": null, | |
| 103 | + "location": null, | |
| 104 | + "formation": null, | |
| 105 | + "size": null, | |
| 106 | + "category": null, | |
| 107 | + "subCategory": null | |
| 108 | + }, | |
| 109 | + { | |
| 110 | + "item": "351686", | |
| 111 | + "url": "https://www.fossilera.com/fossils/serrated-2-89-carcharodontosaurus-tooth-feeding-worn-tip", | |
| 112 | + "title": "Serrated, 2.89\" Carcharodontosaurus Tooth - Feeding Worn Tip", | |
| 113 | + "price": 395, | |
| 114 | + "oldPrice": null, | |
| 115 | + "sold": false, | |
| 116 | + "image": "https://assets3.fossilera.com/sp/835034/carcharodontosaurus-teeth/325x215/carcharodontosaurus-sp.jpg", | |
| 117 | + "species": null, | |
| 118 | + "age": null, | |
| 119 | + "location": null, | |
| 120 | + "formation": null, | |
| 121 | + "size": null, | |
| 122 | + "category": null, | |
| 123 | + "subCategory": null | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "item": "316988", | |
| 127 | + "url": "https://www.fossilera.com/fossils/rare-92-fossil-theropod-neovenator-tooth-england", | |
| 128 | + "title": "Rare .92\" Fossil Theropod (Neovenator) Tooth - England", | |
| 129 | + "price": 395, | |
| 130 | + "oldPrice": null, | |
| 131 | + "sold": false, | |
| 132 | + "image": "https://assets2.fossilera.com/sp/771351/dinosaur-teeth/325x215/neovenator-cf-salerii.jpg", | |
| 133 | + "species": null, | |
| 134 | + "age": null, | |
| 135 | + "location": null, | |
| 136 | + "formation": null, | |
| 137 | + "size": null, | |
| 138 | + "category": null, | |
| 139 | + "subCategory": null | |
| 140 | + } | |
| 141 | + ] | |
| 142 | + } | |
| 143 | + }, | |
| 144 | + "expect": { | |
| 145 | + "minCount": 8, | |
| 146 | + "kinds": [ | |
| 147 | + "listing" | |
| 148 | + ], | |
| 149 | + "requiredFields": [ | |
| 150 | + "attributes.identifiers.fossilera_item" | |
| 151 | + ] | |
| 152 | + }, | |
| 153 | + "note": "Captured live from fossilera.com dinosaur teeth category, trimmed to 8 specimens", | |
| 154 | + "capturedAt": "2026-09-07T06:29:21.077Z" | |
| 155 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/hobbysearch/gundam-mg-search-p1.json
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.1999.co.jp/eng/search?typ1_c=100&cat=&state=&sold=0&sortid=7&searchkey=Gundam%20MG", | |
| 4 | + "externalId": "Gundam MG|p1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T06:29:10.807Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "search_page", | |
| 10 | + "url": "https://www.1999.co.jp/eng/search?typ1_c=100&cat=&state=&sold=0&sortid=7&searchkey=Gundam%20MG", | |
| 11 | + "categorySlug": "gundam", | |
| 12 | + "items": [ | |
| 13 | + { | |
| 14 | + "id": "11200296", | |
| 15 | + "url": "https://www.1999.co.jp/eng/11200296", | |
| 16 | + "title": "1/12 Little Armory [LA-MD02] Megami Device Equipment Set / SMG (Plastic model)", | |
| 17 | + "image": "https://www.1999.co.jp/itbig120/11200296.jpg", | |
| 18 | + "price": 3060, | |
| 19 | + "listPrice": 3740, | |
| 20 | + "stock": "In Stock", | |
| 21 | + "released": "Late Sep., 2025" | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "id": "11116127", | |
| 25 | + "url": "https://www.1999.co.jp/eng/11116127", | |
| 26 | + "title": "*Bargain Item* Goddess of Victory: Nikke Gungirl Weapons Seventh Dwarf (Anime Toy)", | |
| 27 | + "image": "https://www.1999.co.jp/itbig111/11116127.jpg", | |
| 28 | + "price": 1320, | |
| 29 | + "listPrice": 2640, | |
| 30 | + "stock": "In Stock", | |
| 31 | + "released": "Late Nov., 2024" | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + "id": "11116128", | |
| 35 | + "url": "https://www.1999.co.jp/eng/11116128", | |
| 36 | + "title": "*Bargain Item* Goddess of Victory: Nikke Gungirl Weapons Lonely Freedom (Anime Toy)", | |
| 37 | + "image": "https://www.1999.co.jp/itbig111/11116128.jpg", | |
| 38 | + "price": 1200, | |
| 39 | + "listPrice": 2640, | |
| 40 | + "stock": "In Stock", | |
| 41 | + "released": "Late Nov., 2024" | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + "id": "11116131", | |
| 45 | + "url": "https://www.1999.co.jp/eng/11116131", | |
| 46 | + "title": "*Bargain Item* Goddess of Victory: Nikke Gungirl Weapons Vitamin Bomb (Anime Toy)", | |
| 47 | + "image": "https://www.1999.co.jp/itbig111/11116131.jpg", | |
| 48 | + "price": 840, | |
| 49 | + "listPrice": 2640, | |
| 50 | + "stock": "In Stock", | |
| 51 | + "released": "Late Nov., 2024" | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + "id": "11116126", | |
| 55 | + "url": "https://www.1999.co.jp/eng/11116126", | |
| 56 | + "title": "*Bargain Item* Goddess of Victory: Nikke Gungirl Weapons Wolfsbane (Anime Toy)", | |
| 57 | + "image": "https://www.1999.co.jp/itbig111/11116126.jpg", | |
| 58 | + "price": 1080, | |
| 59 | + "listPrice": 2640, | |
| 60 | + "stock": "In Stock", | |
| 61 | + "released": "Late Nov., 2024" | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "id": "11116130", | |
| 65 | + "url": "https://www.1999.co.jp/eng/11116130", | |
| 66 | + "title": "*Bargain Item* Goddess of Victory: Nikke Gungirl Weapons Amazing Wonderland (Anime Toy)", | |
| 67 | + "image": "https://www.1999.co.jp/itbig111/11116130.jpg", | |
| 68 | + "price": 1080, | |
| 69 | + "listPrice": 2640, | |
| 70 | + "stock": "In Stock", | |
| 71 | + "released": "Late Nov., 2024" | |
| 72 | + }, | |
| 73 | + { | |
| 74 | + "id": "11116129", | |
| 75 | + "url": "https://www.1999.co.jp/eng/11116129", | |
| 76 | + "title": "*Bargain Item* Goddess of Victory: Nikke Gungirl Weapons Crown of Warriors (Anime Toy)", | |
| 77 | + "image": "https://www.1999.co.jp/itbig111/11116129.jpg", | |
| 78 | + "price": 1416, | |
| 79 | + "listPrice": 2640, | |
| 80 | + "stock": "In Stock", | |
| 81 | + "released": "Late Nov., 2024" | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "id": "11241743", | |
| 85 | + "url": "https://www.1999.co.jp/eng/11241743", | |
| 86 | + "title": "*Bargain Item* GGG Series Mobile Suit Z Gundam Four Murasame -The Spirit of Z Revived- (Figure)", | |
| 87 | + "image": "https://www.1999.co.jp/itbig124/11241743.jpg", | |
| 88 | + "price": 15400, | |
| 89 | + "listPrice": 24200, | |
| 90 | + "stock": "In Stock", | |
| 91 | + "released": "Late Feb., 2026" | |
| 92 | + } | |
| 93 | + ] | |
| 94 | + } | |
| 95 | + }, | |
| 96 | + "expect": { | |
| 97 | + "minCount": 8, | |
| 98 | + "kinds": [ | |
| 99 | + "catalog_item", | |
| 100 | + "listing" | |
| 101 | + ] | |
| 102 | + }, | |
| 103 | + "note": "Captured live from 1999.co.jp search (Gundam MG), trimmed to 8 cards", | |
| 104 | + "capturedAt": "2026-09-07T06:29:10.899Z" | |
| 105 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/lego-shop/star-wars-theme-p1.json
+122 −0
@@ -0,0 +1,122 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.lego.com/en-us/themes/star-wars", | |
| 4 | + "externalId": "star-wars|p1", | |
| 5 | + "kind": "catalog_item", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T06:29:18.358Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "theme_page", | |
| 10 | + "url": "https://www.lego.com/en-us/themes/star-wars", | |
| 11 | + "theme": "star-wars", | |
| 12 | + "products": [ | |
| 13 | + { | |
| 14 | + "code": "75456", | |
| 15 | + "url": "https://www.lego.com/en-us/product/advent-calendar-2026-75456", | |
| 16 | + "name": "LEGO® Star Wars™ Advent Calendar 2026", | |
| 17 | + "price": 44.99, | |
| 18 | + "badges": [ | |
| 19 | + "New" | |
| 20 | + ], | |
| 21 | + "image": "https://www.lego.com/cdn/cs/set/assets/bltfefed03b11b7bc39/blt691e598d84db744d-75456_Box1_v29.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 22 | + "availability": null, | |
| 23 | + "pieces": null, | |
| 24 | + "ages": null | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "code": "75451", | |
| 28 | + "url": "https://www.lego.com/en-us/product/hutt-palace-sentry-droid-showdown-75451", | |
| 29 | + "name": "Hutt Palace Sentry Droid Showdown", | |
| 30 | + "price": 49.99, | |
| 31 | + "badges": [ | |
| 32 | + "New" | |
| 33 | + ], | |
| 34 | + "image": "https://www.lego.com/cdn/cs/set/assets/bltcbdf08565d92df57/75451_Prod.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 35 | + "availability": null, | |
| 36 | + "pieces": null, | |
| 37 | + "ages": null | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "code": "75455", | |
| 41 | + "url": "https://www.lego.com/en-us/product/boba-fett-75455", | |
| 42 | + "name": "Boba Fett™", | |
| 43 | + "price": 169.99, | |
| 44 | + "badges": [ | |
| 45 | + "New" | |
| 46 | + ], | |
| 47 | + "image": "https://www.lego.com/cdn/cs/set/assets/bltb124c16e2313b0f1/blt0fed9d785e5abb17-75455_Prod.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 48 | + "availability": null, | |
| 49 | + "pieces": null, | |
| 50 | + "ages": null | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "code": "75419", | |
| 54 | + "url": "https://www.lego.com/en-us/product/death-star-75419", | |
| 55 | + "name": "Death Star™", | |
| 56 | + "price": 999.99, | |
| 57 | + "badges": [], | |
| 58 | + "image": "https://www.lego.com/cdn/cs/set/assets/blt597e64c3c0f4786c/75419_Prod.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 59 | + "availability": null, | |
| 60 | + "pieces": null, | |
| 61 | + "ages": null | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "code": "40806", | |
| 65 | + "url": "https://www.lego.com/en-us/product/gingerbread-at-at-walker-40806", | |
| 66 | + "name": "Gingerbread AT-AT™ Walker", | |
| 67 | + "price": 59.99, | |
| 68 | + "badges": [], | |
| 69 | + "image": "https://www.lego.com/cdn/cs/set/assets/blt6d4c85ec676b3157/40806_Prod.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 70 | + "availability": null, | |
| 71 | + "pieces": null, | |
| 72 | + "ages": null | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "code": "75397", | |
| 76 | + "url": "https://www.lego.com/en-us/product/jabbas-sail-barge-75397", | |
| 77 | + "name": "Jabba's Sail Barge™", | |
| 78 | + "price": 499.99, | |
| 79 | + "badges": [], | |
| 80 | + "image": "https://www.lego.com/cdn/cs/set/assets/bltad48b7c771f86707/75397_Prod.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 81 | + "availability": null, | |
| 82 | + "pieces": null, | |
| 83 | + "ages": null | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + "code": "75367", | |
| 87 | + "url": "https://www.lego.com/en-us/product/venator-class-republic-attack-cruiser-75367", | |
| 88 | + "name": "Venator-Class Republic Attack Cruiser™", | |
| 89 | + "price": 649.99, | |
| 90 | + "badges": [], | |
| 91 | + "image": "https://www.lego.com/cdn/cs/set/assets/blt06c6593d8e8d1c13/75367.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 92 | + "availability": null, | |
| 93 | + "pieces": null, | |
| 94 | + "ages": null | |
| 95 | + }, | |
| 96 | + { | |
| 97 | + "code": "75192", | |
| 98 | + "url": "https://www.lego.com/en-us/product/millennium-falcon-75192", | |
| 99 | + "name": "Millennium Falcon™", | |
| 100 | + "price": 849.99, | |
| 101 | + "badges": [], | |
| 102 | + "image": "https://www.lego.com/cdn/cs/set/assets/blt3349f56c6f192e18/75192_Prod.png?fit=crop&quality=80&width=400&height=400&dpr=1", | |
| 103 | + "availability": null, | |
| 104 | + "pieces": null, | |
| 105 | + "ages": null | |
| 106 | + } | |
| 107 | + ] | |
| 108 | + } | |
| 109 | + }, | |
| 110 | + "expect": { | |
| 111 | + "minCount": 8, | |
| 112 | + "kinds": [ | |
| 113 | + "catalog_item", | |
| 114 | + "listing" | |
| 115 | + ], | |
| 116 | + "requiredFields": [ | |
| 117 | + "attributes.identifiers.lego_set_number" | |
| 118 | + ] | |
| 119 | + }, | |
| 120 | + "note": "Captured live from lego.com/en-us/themes/star-wars, trimmed to 8 products", | |
| 121 | + "capturedAt": "2026-09-07T06:29:18.427Z" | |
| 122 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/ma-shops/denarius-search-p1.json
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.ma-shops.com/search.php?keywords=denarius", | |
| 4 | + "externalId": "/search.php?keywords=denarius|p1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T06:29:58.910Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "gallery_page", | |
| 10 | + "url": "https://www.ma-shops.com/search.php?keywords=denarius", | |
| 11 | + "categorySlug": "coins", | |
| 12 | + "items": [ | |
| 13 | + { | |
| 14 | + "shop": "krueger", | |
| 15 | + "id": "3889", | |
| 16 | + "url": "https://www.ma-shops.com/krueger/item.php?id=3889", | |
| 17 | + "title": "Königreich Lydien: Alyattes bis Kroisos Trite (1/3 Stater) ca. 610-550 v. Chr. Sardeis, Löwenkopf /", | |
| 18 | + "price": 2148.75, | |
| 19 | + "currency": "USD", | |
| 20 | + "seller": "Divus", | |
| 21 | + "image": "https://img.ma-shops.com/krueger/pic/500px/3889_100496k00.jpg" | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "shop": "henzen", | |
| 25 | + "id": "81428", | |
| 26 | + "url": "https://www.ma-shops.com/henzen/item.php?id=81428", | |
| 27 | + "title": "30 stuivers 1521-1524 ZUIDELIJKE NEDERLANDEN (SOUTHERN NETHERLANDS) - GRAAFSCHAP VLAANDEREN - KAREL", | |
| 28 | + "price": 2084.87, | |
| 29 | + "currency": "USD", | |
| 30 | + "seller": "Henzen", | |
| 31 | + "image": "https://img.ma-shops.com/henzen/pic/500px/81428.jpg" | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + "shop": "bodde", | |
| 35 | + "id": "260422010", | |
| 36 | + "url": "https://www.ma-shops.com/bodde/item.php?id=260422010", | |
| 37 | + "title": "Sachsen-Coburg-Gotha 5 Mark 1895 A Alfred 1893-1900. PCGS MS64", | |
| 38 | + "price": 8420.79, | |
| 39 | + "currency": "USD", | |
| 40 | + "seller": "Bodde", | |
| 41 | + "image": "https://img.ma-shops.com/bodde/pic/500px/jk3qnwntp0ykdprkxzurfq.jpg" | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + "shop": "mayer", | |
| 45 | + "id": "14164", | |
| 46 | + "url": "https://www.ma-shops.com/mayer/item.php?id=14164", | |
| 47 | + "title": "Österreich, Haus Habsburg 1 Dukat 1915 Franz Josef I. 1848-1916 fstgl", | |
| 48 | + "price": 609.78, | |
| 49 | + "currency": "USD", | |
| 50 | + "seller": "Mayer", | |
| 51 | + "image": "https://img.ma-shops.com/mayer/pic/500px/14164_16180k.jpg" | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + "shop": "koelnermuenzkabinett", | |
| 55 | + "id": "110570", | |
| 56 | + "url": "https://www.ma-shops.com/koelnermuenzkabinett/item.php?id=110570", | |
| 57 | + "title": "USA 20 Dollars 1878 S Liberty Head - Double Eagle, With motto - Kursmünze (1877-1907) VF", | |
| 58 | + "price": 5337.04, | |
| 59 | + "currency": "USD", | |
| 60 | + "seller": "Kölner Münzkabinett", | |
| 61 | + "image": "https://img.ma-shops.com/koelnermuenzkabinett/pic/500px/artid110570_combined.jpg" | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "shop": "coinsnb", | |
| 65 | + "id": "131397", | |
| 66 | + "url": "https://www.ma-shops.com/coinsnb/item.php?id=131397", | |
| 67 | + "title": "India, Portuguese 1881 Calcutta Mint India Portuguese colony 1881 ½ / Meia Rupia - Luíz I (Calcutta", | |
| 68 | + "price": 108.6, | |
| 69 | + "currency": "USD", | |
| 70 | + "seller": "Coins NB", | |
| 71 | + "image": "https://img.ma-shops.com/coinsnb/pic/500px/131397.jpg" | |
| 72 | + }, | |
| 73 | + { | |
| 74 | + "shop": "hanke", | |
| 75 | + "id": "6285", | |
| 76 | + "url": "https://www.ma-shops.com/hanke/item.php?id=6285", | |
| 77 | + "title": "Deutschland, 20 Mark, 1906, Kaiserreich, Hessen, Großherzogtum, Ernst Ludwig , 1892-1918, AU,", | |
| 78 | + "price": 1614.47, | |
| 79 | + "currency": "USD", | |
| 80 | + "seller": "Hanke", | |
| 81 | + "image": "https://img.ma-shops.com/hanke/pic/500px/artid6285_combined.jpg" | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "shop": "dylla", | |
| 85 | + "id": "43100", | |
| 86 | + "url": "https://www.ma-shops.com/dylla/item.php?id=43100", | |
| 87 | + "title": "Somalia 1000 Schilling 2020 African Wildlife,RAR! Auflage 100 Stück Proof (PP) Proof", | |
| 88 | + "price": 5749.37, | |
| 89 | + "currency": "USD", | |
| 90 | + "seller": "Dylla, Gerhard", | |
| 91 | + "image": "https://img.ma-shops.com/dylla/pic/500px/43100_p1210911.jpg" | |
| 92 | + } | |
| 93 | + ] | |
| 94 | + } | |
| 95 | + }, | |
| 96 | + "expect": { | |
| 97 | + "minCount": 8, | |
| 98 | + "kinds": [ | |
| 99 | + "listing" | |
| 100 | + ], | |
| 101 | + "requiredFields": [ | |
| 102 | + "price", | |
| 103 | + "currency" | |
| 104 | + ] | |
| 105 | + }, | |
| 106 | + "note": "Captured live from ma-shops.com search.php?keywords=denarius gallery, trimmed to 8 cells", | |
| 107 | + "capturedAt": "2026-09-07T06:29:58.940Z" | |
| 108 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/surugaya/pokemon-psa-search-p1.json
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.suruga-ya.jp/search?category=5&search_word=%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%89%20PSA", | |
| 4 | + "externalId": "category=5&search_word=%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%89%20PSA|p1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T06:31:09.262Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "search_page", | |
| 10 | + "url": "https://www.suruga-ya.jp/search?category=5&search_word=%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%89%20PSA", | |
| 11 | + "categorySlug": "pokemon", | |
| 12 | + "items": [ | |
| 13 | + { | |
| 14 | + "id": "GU823224", | |
| 15 | + "url": "https://www.suruga-ya.jp/product/detail/GU823224", | |
| 16 | + "title": "114/083[SAR]:【PSA/GEM MT 10】(キラ)メガゲッコウガex", | |
| 17 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GU823224&size=m", | |
| 18 | + "typeLabel": "新入荷", | |
| 19 | + "releaseDate": "2026/03/13", | |
| 20 | + "brand": "ポケモン", | |
| 21 | + "price": null, | |
| 22 | + "soldOut": true, | |
| 23 | + "listPrice": null, | |
| 24 | + "marketplacePrice": 61200, | |
| 25 | + "used": true | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + "id": "GL885959", | |
| 29 | + "url": "https://www.suruga-ya.jp/product/detail/GL885959", | |
| 30 | + "title": "096/071[SAR]:【PSA/GEM MT 10】(キラ)ナンジャモ", | |
| 31 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GL885959&size=m", | |
| 32 | + "typeLabel": "新入荷", | |
| 33 | + "releaseDate": "2023/04/14", | |
| 34 | + "brand": "ポケモン", | |
| 35 | + "price": null, | |
| 36 | + "soldOut": true, | |
| 37 | + "listPrice": null, | |
| 38 | + "marketplacePrice": 95760, | |
| 39 | + "used": true | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "id": "GL900718", | |
| 43 | + "url": "https://www.suruga-ya.jp/product/detail/GL900718", | |
| 44 | + "title": "096/071[SAR]:【PSA/MINT 9】(キラ)ナンジャモ", | |
| 45 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GL900718&size=m", | |
| 46 | + "typeLabel": "新入荷", | |
| 47 | + "releaseDate": "2023/04/14", | |
| 48 | + "brand": "ポケモン", | |
| 49 | + "price": null, | |
| 50 | + "soldOut": true, | |
| 51 | + "listPrice": null, | |
| 52 | + "marketplacePrice": 42800, | |
| 53 | + "used": true | |
| 54 | + }, | |
| 55 | + { | |
| 56 | + "id": "GU692147", | |
| 57 | + "url": "https://www.suruga-ya.jp/product/detail/GU692147", | |
| 58 | + "title": "236/193[SAR]:【PSA/GEM MT 10】(キラ)ナンジャモのハラバリーex", | |
| 59 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GU692147&size=m", | |
| 60 | + "typeLabel": "新入荷", | |
| 61 | + "releaseDate": "2025/11/28", | |
| 62 | + "brand": "ポケモン", | |
| 63 | + "price": null, | |
| 64 | + "soldOut": true, | |
| 65 | + "listPrice": null, | |
| 66 | + "marketplacePrice": 23800, | |
| 67 | + "used": true | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "id": "GN100474", | |
| 71 | + "url": "https://www.suruga-ya.jp/product/detail/GN100474", | |
| 72 | + "title": "080/073[AR]:【PSA/GEM MT 10】(キラ)コイキング", | |
| 73 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GN100474&size=m", | |
| 74 | + "typeLabel": "新入荷", | |
| 75 | + "releaseDate": "2023/03/10", | |
| 76 | + "brand": "ポケモン", | |
| 77 | + "price": null, | |
| 78 | + "soldOut": true, | |
| 79 | + "listPrice": null, | |
| 80 | + "marketplacePrice": 59760, | |
| 81 | + "used": true | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "id": "GU718227", | |
| 85 | + "url": "https://www.suruga-ya.jp/product/detail/GU718227", | |
| 86 | + "title": "116/080[MUR]:【PSA/GEM MT 10】メガリザードンXex", | |
| 87 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GU718227&size=m", | |
| 88 | + "typeLabel": "新入荷", | |
| 89 | + "releaseDate": "2025/09/26", | |
| 90 | + "brand": "ポケモン", | |
| 91 | + "price": null, | |
| 92 | + "soldOut": true, | |
| 93 | + "listPrice": null, | |
| 94 | + "marketplacePrice": 660000, | |
| 95 | + "used": true | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "id": "GL885960", | |
| 99 | + "url": "https://www.suruga-ya.jp/product/detail/GL885960", | |
| 100 | + "title": "091/071[SR]:【PSA/GEM MT 10】(キラ)ナンジャモ", | |
| 101 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GL885960&size=m", | |
| 102 | + "typeLabel": "新入荷", | |
| 103 | + "releaseDate": "2023/04/14", | |
| 104 | + "brand": "ポケモン", | |
| 105 | + "price": null, | |
| 106 | + "soldOut": true, | |
| 107 | + "listPrice": null, | |
| 108 | + "marketplacePrice": 17800, | |
| 109 | + "used": true | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "id": "GN521133", | |
| 113 | + "url": "https://www.suruga-ya.jp/product/detail/GN521133", | |
| 114 | + "title": "192/172[AR]:【PSA/GEM MT 10】(キラ)フォクスライ", | |
| 115 | + "image": "https://www.suruga-ya.jp/database/photo.php?shinaban=GN521133&size=m", | |
| 116 | + "typeLabel": "新入荷", | |
| 117 | + "releaseDate": "2022/12/02", | |
| 118 | + "brand": "ポケモン", | |
| 119 | + "price": null, | |
| 120 | + "soldOut": true, | |
| 121 | + "listPrice": null, | |
| 122 | + "marketplacePrice": 7680, | |
| 123 | + "used": true | |
| 124 | + } | |
| 125 | + ] | |
| 126 | + } | |
| 127 | + }, | |
| 128 | + "expect": { | |
| 129 | + "minCount": 8, | |
| 130 | + "kinds": [ | |
| 131 | + "catalog_item", | |
| 132 | + "listing" | |
| 133 | + ] | |
| 134 | + }, | |
| 135 | + "note": "Captured live from suruga-ya.jp search (Pokémon PSA), trimmed to 8 cards", | |
| 136 | + "capturedAt": "2026-09-07T06:31:09.300Z" | |
| 137 | +} | |
| \ No newline at end of file | ||
| 138 | ||