Connectors: instruments, hi-fi, cameras, watch dealer, retro games, minerals, Open Library (8 sources, agent W)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
34 changed files +3,565 −0
added
connectors/api/_wlib/index.ts
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +import type { CrawlContext } from '@rareindex/connectors'; | |
| 2 | +import type { ExtractionResult } from '@rareindex/shared'; | |
| 3 | + | |
| 4 | +/** Helpers shared by the dealer/marketplace connectors of this wave (instruments, audio, cameras, books, minerals). */ | |
| 5 | + | |
| 6 | +/** "$1,79995" (B&H used price with superscript cents flattened) → 1799.95 ; "$499.00" → 499 ; "€850" → 850 */ | |
| 7 | +export function compactMoney(s: string | null | undefined): number | null { | |
| 8 | + if (!s) return null; | |
| 9 | + const t = s.replace(/\s+/g, ''); | |
| 10 | + if (/\d\.\d{2}$/.test(t)) { | |
| 11 | + const n = Number(t.replace(/[^0-9.]/g, '')); | |
| 12 | + return Number.isFinite(n) && n > 0 ? n : null; | |
| 13 | + } | |
| 14 | + const digits = t.replace(/[^0-9]/g, ''); | |
| 15 | + if (!digits) return null; | |
| 16 | + const n = Number(digits); | |
| 17 | + return Number.isFinite(n) && n > 0 ? n : null; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** Same as compactMoney but treats the last two digits as cents when no decimal point is present ("$37895" → 378.95). */ | |
| 21 | +export function compactMoneyCents(s: string | null | undefined): number | null { | |
| 22 | + if (!s) return null; | |
| 23 | + const t = s.replace(/\s+/g, ''); | |
| 24 | + if (/\d\.\d{2}$/.test(t)) return compactMoney(t); | |
| 25 | + const digits = t.replace(/[^0-9]/g, ''); | |
| 26 | + if (digits.length < 3) return compactMoney(t); | |
| 27 | + const n = Number(`${digits.slice(0, -2)}.${digits.slice(-2)}`); | |
| 28 | + return Number.isFinite(n) && n > 0 ? n : null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +const KNOWN_BRANDS = ['Canon', 'Nikon', 'Sony', 'Fujifilm', 'FUJIFILM', 'Leica', 'Hasselblad', 'Panasonic', 'Olympus', 'OM System', 'Pentax', 'Ricoh', 'Sigma', 'Tamron', 'Zeiss', 'Mamiya', 'Phase One', 'Contax', 'Rollei', 'Rolleiflex', 'Voigtlander', 'Voigtländer', 'Blackmagic Design', 'Blackmagic', 'GoPro', 'DJI', 'Kodak', 'Minolta', 'Bronica', 'Polaroid', 'Lomography', 'Marantz', 'McIntosh', 'Technics', 'Nakamichi', 'JBL', 'Klipsch', 'Sansui', 'Pioneer', 'Revox', 'Sennheiser', 'Thorens', 'Linn', 'Tannoy', 'Yamaha', 'Denon', 'Luxman', 'Accuphase', 'Bang & Olufsen', 'Quad', 'Naim', 'Rega', 'Fender', 'Gibson', 'Martin', 'Gretsch', 'Rickenbacker', 'PRS', 'Moog', 'Roland', 'Korg', 'Sequential', 'Oberheim', 'Marshall', 'Vox', 'Mesa/Boogie', 'Dumble', 'Klon', 'Ibanez', 'Taylor', 'Epiphone', 'Music Man', 'Hofner', 'Höfner', 'Ludwig', 'Gretsch', 'Zildjian', 'Neumann', 'Yamaha', 'Hammond', 'Wurlitzer', 'Rhodes', 'Steinway', 'Selmer', 'Bach', 'Buffet']; | |
| 32 | + | |
| 33 | +/** Brand from the beginning of a product name (multi-word brands first), else the first word. */ | |
| 34 | +export function brandFromName(name: string): string | null { | |
| 35 | + const n = name.trim(); | |
| 36 | + for (const b of [...KNOWN_BRANDS].sort((a, c) => c.length - a.length)) { | |
| 37 | + if (n.toLowerCase().startsWith(b.toLowerCase() + ' ')) return b === 'FUJIFILM' ? 'Fujifilm' : b; | |
| 38 | + } | |
| 39 | + const first = n.split(/\s+/)[0] ?? ''; | |
| 40 | + return /^[A-Za-z][A-Za-z&.'-]*$/.test(first) ? first : null; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function stripBrand(name: string, brand: string | null): string { | |
| 44 | + if (!brand) return name.trim(); | |
| 45 | + return name.trim().replace(new RegExp(`^${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+`, 'i'), '').trim() || name.trim(); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Fetch a page through Firecrawl (markdown + rawHtml). Quality is judged by the caller's parser. */ | |
| 49 | +export async function fetchFirecrawl(ctx: CrawlContext, url: string, opts: { waitForMs?: number; timeoutMs?: number; parse?: (r: ExtractionResult) => number } = {}): Promise<ExtractionResult> { | |
| 50 | + return ctx.fetch(url, { | |
| 51 | + engines: ['firecrawl'], | |
| 52 | + minQuality: 0.2, | |
| 53 | + waitForMs: opts.waitForMs, | |
| 54 | + timeoutMs: opts.timeoutMs, | |
| 55 | + ...(opts.parse ? { expect: ['title', 'price'], parse: (r) => (opts.parse!(r) > 0 ? { title: 'ok', price: 1 } : null) } : {}), | |
| 56 | + }); | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function absUrl(base: string, href: string | null | undefined): string | null { | |
| 60 | + if (!href) return null; | |
| 61 | + try { | |
| 62 | + return new URL(href, base).toString(); | |
| 63 | + } catch { | |
| 64 | + return null; | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** Decode a handful of HTML entities that appear in JSON feeds and markdown. */ | |
| 69 | +export function decodeEntities(s: string): string { | |
| 70 | + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/\s+/g, ' ').trim(); | |
| 71 | +} | |
added
connectors/api/_wlib/smoke.ts
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +/** | |
| 2 | + * Live smoke / fixture capture for this wave's connectors (works before the registry is rebuilt). | |
| 3 | + * pnpm tsx connectors/api/_wlib/smoke.ts api/reverb [--limit 2] [--save <fixtureName>] [--trim 8] [--mode probe|incremental] | |
| 4 | + */ | |
| 5 | +import path from 'node:path'; | |
| 6 | +import { pathToFileURL } from 'node:url'; | |
| 7 | +import { readFileSync } from 'node:fs'; | |
| 8 | +import { createRouter, createCrawlContext, ConnectorMetaSchema, type RareIndexConnector, type ConnectorMeta } from '@rareindex/connectors'; | |
| 9 | +import { saveFixture } from '@rareindex/connectors/testing'; | |
| 10 | + | |
| 11 | +const [, , mod, ...rest] = process.argv; | |
| 12 | +if (!mod) throw new Error('usage: smoke.ts <engine>/<id> [--limit N] [--save name] [--trim N]'); | |
| 13 | +const arg = (k: string) => { | |
| 14 | + const i = rest.indexOf(k); | |
| 15 | + return i >= 0 ? rest[i + 1] : undefined; | |
| 16 | +}; | |
| 17 | +const limit = Number(arg('--limit') ?? 2); | |
| 18 | +const save = arg('--save'); | |
| 19 | +const trim = Number(arg('--trim') ?? 8); | |
| 20 | +const mode = (arg('--mode') ?? 'probe') as 'probe' | 'incremental' | 'backfill'; | |
| 21 | + | |
| 22 | +const dir = path.resolve(process.cwd(), 'connectors', mod); | |
| 23 | +const meta: ConnectorMeta = ConnectorMetaSchema.parse(JSON.parse(readFileSync(path.join(dir, 'meta.json'), 'utf8'))); | |
| 24 | +const factory = (await import(pathToFileURL(path.join(dir, 'index.ts')).href)) as { default: (m: ConnectorMeta) => RareIndexConnector }; | |
| 25 | +const connector = factory.default(meta); | |
| 26 | +const router = createRouter({ scrapflyApiKey: process.env.SCRAPFLY_API_KEY, firecrawlApiKey: process.env.FIRECRAWL_API_KEY }); | |
| 27 | +const ctx = createCrawlContext({ router, meta, options: { mode, limit } }); | |
| 28 | + | |
| 29 | +let i = 0; | |
| 30 | +for await (const raw of connector.crawl(ctx)) { | |
| 31 | + const out = await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }); | |
| 32 | + console.log(`raw ${raw.externalId} (${raw.kind}) → ${out.length} records`); | |
| 33 | + for (const r of out.slice(0, 3)) console.log(' ', JSON.stringify(r).slice(0, 420)); | |
| 34 | + if (save && i === 0) { | |
| 35 | + const p = raw.payload as Record<string, unknown>; | |
| 36 | + for (const k of ['items', 'listings', 'products', 'results', 'lots']) if (Array.isArray(p[k])) p[k] = (p[k] as unknown[]).slice(0, trim); | |
| 37 | + saveFixture(meta.id, save, { | |
| 38 | + raw: { url: raw.url, externalId: raw.externalId ?? null, kind: raw.kind, engine: raw.engine, fetchedAt: raw.fetchedAt ?? new Date(), payload: p }, | |
| 39 | + expect: { minCount: 1 }, | |
| 40 | + note: `Captured live from ${new URL(raw.url).host} on ${new Date().toISOString().slice(0, 10)} (trimmed to ${trim} items)`, | |
| 41 | + }); | |
| 42 | + console.log(`saved fixture data/fixtures/${meta.id}/${save}.json`); | |
| 43 | + } | |
| 44 | + i++; | |
| 45 | +} | |
| 46 | +console.log('engineStats', JSON.stringify(ctx.engineStats), 'anomalies', ctx.anomalies); | |
added
connectors/api/analog-shift/index.test.ts
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 5 | +import createConnector, { specs } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('analog-shift', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('parses spec lines and emits watch listings with references', async () => { | |
| 13 | + expect(specs('Reference: 1675 Year: 1968 Case Size: 40mm Condition: Excellent')).toMatchObject({ reference: '1675', year: '1968', 'case size': '40mm', condition: 'Excellent' }); | |
| 14 | + const fx = loadFixture('analog-shift', 'products-p1'); | |
| 15 | + const out = await connector.normalize(fx.raw); | |
| 16 | + expect(out.length).toBeGreaterThan(0); | |
| 17 | + for (const r of out) { | |
| 18 | + if (r.kind !== 'listing') throw new Error('expected listing'); | |
| 19 | + expect(['rolex', 'patek_philippe', 'audemars_piguet', 'omega', 'other_watches']).toContain(r.attributes.categorySlug); | |
| 20 | + expect(r.attributes.identifiers.analogshift_sku).toBeTruthy(); | |
| 21 | + expect(r.seller).toBe('Analog:Shift'); | |
| 22 | + if (r.price !== null) expect(r.currency).toBe('USD'); | |
| 23 | + } | |
| 24 | + }); | |
| 25 | +}); | |
added
connectors/api/analog-shift/index.ts
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { normalizeCondition } from '@rareindex/taxonomy'; | |
| 4 | +import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; | |
| 5 | +import { ShopifyProductSchema, caseSize, fetchShopifyPage, moneyNumber, trimShopifyProduct, watchCategory, watchCompleteness, watchConditionRaw, watchMaterial, watchReferenceFromText, yearFrom } from '../_luxury-lib/index.js'; | |
| 6 | + | |
| 7 | +/** Analog:Shift — vintage & pre-owned watch dealer (Shopify storefront, USD). One product = one watch. */ | |
| 8 | +const BASE = 'https://analogshift.com'; | |
| 9 | +const PARSER_VERSION = '1.0.0'; | |
| 10 | + | |
| 11 | +export const PagePayloadSchema = z.object({ kind: z.literal('shopify_page'), url: z.string(), seed: z.string(), page: z.number(), products: z.array(ShopifyProductSchema) }); | |
| 12 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 13 | + | |
| 14 | +/** Spec lines Analog:Shift writes in the description: "Reference: 1675", "Year: 1968", "Case Size: 40mm". */ | |
| 15 | +export function specs(body: string | null | undefined): Record<string, string> { | |
| 16 | + const out: Record<string, string> = {}; | |
| 17 | + if (!body) return out; | |
| 18 | + for (const m of body.matchAll(/\b(Reference|Ref\.?|Year|Case Size|Case Diameter|Movement|Caliber|Dial|Bracelet|Strap|Box|Papers|Condition|Material|Case Material)\s*:\s*([^:]{1,60}?)(?=\s+[A-Z][a-z]+(?: [A-Z][a-z]+)?\s*:|$)/g)) { | |
| 19 | + const KEYS: Record<string, string> = { ref: 'reference', reference: 'reference', 'case diameter': 'case size', 'case material': 'material' }; | |
| 20 | + const rawKey = m[1]!.toLowerCase().replace(/\.$/, ''); | |
| 21 | + const k = KEYS[rawKey] ?? rawKey; | |
| 22 | + if (!(k in out)) out[k] = m[2]!.trim(); | |
| 23 | + } | |
| 24 | + return out; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export class AnalogShiftConnector extends BaseConnector { | |
| 28 | + readonly version = '1.0.0'; | |
| 29 | + readonly parserVersion = PARSER_VERSION; | |
| 30 | + protected override minIntervalMs = 1500; | |
| 31 | + override readonly urlPatterns = [/^https?:\/\/(www\.)?analogshift\.com\/products\/([a-z0-9-]+)/i]; | |
| 32 | + | |
| 33 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 34 | + const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? ['all']; | |
| 35 | + const pages = ctx.options.mode === 'backfill' ? 20 : Number(this.meta.config.pagesPerSeed ?? 3); | |
| 36 | + let count = 0; | |
| 37 | + for (const seed of seeds) { | |
| 38 | + for (let page = 1; page <= pages; page++) { | |
| 39 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 40 | + await this.throttle(); | |
| 41 | + const path = seed === 'all' ? '/products.json' : `/collections/${seed}/products.json`; | |
| 42 | + const { products, res } = await fetchShopifyPage(ctx, BASE, path, page); | |
| 43 | + if (!res.success) { | |
| 44 | + ctx.anomaly('page_fetch_failed', `${seed} p${page}: ${res.error ?? res.httpStatus}`); | |
| 45 | + break; | |
| 46 | + } | |
| 47 | + if (products.length === 0) break; | |
| 48 | + count++; | |
| 49 | + const payload: PagePayload = { kind: 'shopify_page', url: `${BASE}${path}?page=${page}`, seed, page, products: products.map(trimShopifyProduct) }; | |
| 50 | + yield { url: payload.url, externalId: `collection:${seed}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 51 | + if (products.length < 250) break; | |
| 52 | + } | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 57 | + const handle = url.match(this.urlPatterns[0]!)?.[2]; | |
| 58 | + if (!handle) return []; | |
| 59 | + await this.throttle(); | |
| 60 | + const res = await ctx.fetch(`${BASE}/products/${handle}.json`, { engines: ['api'], responseType: 'json', minQuality: 0.2 }); | |
| 61 | + const parsed = z.object({ product: ShopifyProductSchema }).safeParse(res.json); | |
| 62 | + if (!res.success || !parsed.success) return []; | |
| 63 | + const payload: PagePayload = { kind: 'shopify_page', url: `${BASE}/products/${handle}`, seed: `product:${handle}`, page: 1, products: [trimShopifyProduct(parsed.data.product)] }; | |
| 64 | + return [{ url: payload.url, externalId: `product:${handle}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; | |
| 65 | + } | |
| 66 | + | |
| 67 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 68 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 69 | + const out: NormalizedRecord[] = []; | |
| 70 | + for (const pr of p.products) { | |
| 71 | + const v = pr.variants[0]; | |
| 72 | + if (!v) continue; | |
| 73 | + const price = moneyNumber(v.price); | |
| 74 | + const title = pr.title.replace(/\s+/g, ' ').trim(); | |
| 75 | + const body = pr.body_html ?? ''; | |
| 76 | + const sp = specs(body); | |
| 77 | + const brand = pr.vendor?.trim() || null; | |
| 78 | + const categorySlug = watchCategory(brand); | |
| 79 | + if (categorySlug === 'other_watches' && /gift card|strap|book|accessor/i.test(pr.product_type ?? '')) continue; | |
| 80 | + const ref = sp.reference ?? watchReferenceFromText(title) ?? watchReferenceFromText(body); | |
| 81 | + const year = sp.year ? yearFrom(sp.year) : yearFrom(title) ?? yearFrom(body); | |
| 82 | + const condRaw = sp.condition ?? watchConditionRaw(body); | |
| 83 | + const attributes = AssetAttributesSchema.parse({ | |
| 84 | + categorySlug, | |
| 85 | + brand, | |
| 86 | + name: brand && !title.toLowerCase().startsWith(brand.toLowerCase()) ? `${brand} ${title}` : title, | |
| 87 | + model: pr.product_type && !/watch/i.test(pr.product_type) ? pr.product_type : null, | |
| 88 | + reference: ref, | |
| 89 | + year, | |
| 90 | + material: sp.material ?? watchMaterial(title) ?? watchMaterial(body), | |
| 91 | + size: sp['case size'] ?? caseSize(title) ?? caseSize(body), | |
| 92 | + identifiers: { analogshift_sku: v.sku?.trim() || String(pr.id), ...(ref ? { reference: ref } : {}) }, | |
| 93 | + metadata: { product_type: pr.product_type, tags: pr.tags?.slice(0, 12) ?? [], movement: sp.movement ?? sp.caliber ?? null, dial: sp.dial ?? null, bracelet: sp.bracelet ?? sp.strap ?? null }, | |
| 94 | + }); | |
| 95 | + const listedAt = pr.published_at ? new Date(pr.published_at) : null; | |
| 96 | + out.push( | |
| 97 | + NormalizedListingSchema.parse({ | |
| 98 | + kind: 'listing', | |
| 99 | + connectorId: this.meta.id, | |
| 100 | + sourceId: this.meta.sourceId, | |
| 101 | + sourceUrl: `${BASE}/products/${pr.handle}`, | |
| 102 | + externalId: String(pr.id), | |
| 103 | + rawTitle: title, | |
| 104 | + description: body || null, | |
| 105 | + imageUrls: (pr.images ?? []).map((i) => i.src), | |
| 106 | + attributes, | |
| 107 | + condition: { condition: normalizeCondition(categorySlug, condRaw), conditionRaw: condRaw, completeness: watchCompleteness(body) }, | |
| 108 | + observedAt: raw.fetchedAt, | |
| 109 | + confidence: 0.85, | |
| 110 | + parserVersion: PARSER_VERSION, | |
| 111 | + listingType: 'fixed_price', | |
| 112 | + price, | |
| 113 | + currency: price ? 'USD' : null, | |
| 114 | + seller: 'Analog:Shift', | |
| 115 | + location: 'US', | |
| 116 | + quantity: 1, | |
| 117 | + listedAt: listedAt && !Number.isNaN(listedAt.getTime()) ? listedAt : null, | |
| 118 | + availability: pr.published_at ? (v.available ? 'available' : 'sold') : 'removed', | |
| 119 | + }), | |
| 120 | + ); | |
| 121 | + } | |
| 122 | + return out; | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +export default function createConnector(meta: ConnectorMeta) { | |
| 127 | + return new AnalogShiftConnector(meta); | |
| 128 | +} | |
added
connectors/api/analog-shift/meta.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "id": "analog-shift", | |
| 3 | + "displayName": "Analog:Shift (vintage watch dealer, USD)", | |
| 4 | + "sourceId": "analog-shift", | |
| 5 | + "sourceName": "Analog:Shift", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://analogshift.com", | |
| 8 | + "module": "api/analog-shift", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["rolex", "patek_philippe", "audemars_piguet", "omega", "other_watches"], | |
| 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://analogshift.com/policies/terms-of-service", | |
| 26 | + "accessNotes": "Public Shopify storefront feed (analogshift.com/products.json, 250 products/page) over plain HTTPS with the RareIndex user agent; robots.txt allows / for generic agents. Each product is one vintage/pre-owned watch: vendor = brand, description carries Reference / Year / Case Size / Condition lines which are parsed into attributes; sold watches stay published with available=false and are recorded as listings with availability=sold (asking price, not a transaction). Identifiers: analogshift_sku + reference (shared with Chrono24/Subdial for entity resolution).", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { "seeds": ["all"], "pagesPerSeed": 3 } | |
| 30 | +} | |
added
connectors/api/hifishark/index.test.ts
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 5 | +import createConnector, { parseSearchPage } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('hifishark', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('parses server-rendered for-sale rows', async () => { | |
| 13 | + const html = `<ul id="result-tabs"><li class="nav-item"><a class="nav-link active">For Sale <span>(60)</span></a></li></ul><a href="/goto/152_abc/xyz" id="152_abc" class="d-flex align-items-center search-product-row"><div class="search-product-img"><span><img class="img-fluid lazy" src="/no.png" data-original="https://img/x.jpg"/></span></div><div class="search-product-info"><span class="search-product-title"><span>marantz 2270</span></span><span class="website"><img src="/_.gif" class="flag flag-it" title="Italy"/> Subito</span><span class="price"><strong>€850</strong></span><span class="first-seen" title="Listed">Aug 2, 2026</span></div></a>`; | |
| 14 | + const p = parseSearchPage(html, 'u', 'Marantz 2270'); | |
| 15 | + expect(p.forSaleCount).toBe(60); | |
| 16 | + expect(p.rows[0]).toMatchObject({ id: '152_abc', priceText: '€850', marketplace: 'Subito', countryIso: 'IT', firstSeen: 'Aug 2, 2026', image: 'https://img/x.jpg' }); | |
| 17 | + const fx = loadFixture('hifishark', 'marantz-2270'); | |
| 18 | + const out = await connector.normalize(fx.raw); | |
| 19 | + expect(out.length).toBeGreaterThan(0); | |
| 20 | + const l = out[0]!; | |
| 21 | + if (l.kind !== 'listing') throw new Error('expected listing'); | |
| 22 | + expect(l.attributes).toMatchObject({ categorySlug: 'audio_equipment', brand: 'Marantz', model: '2270', name: 'Marantz 2270' }); | |
| 23 | + expect(out.some((r) => r.kind === 'listing' && r.price && r.currency)).toBe(true); | |
| 24 | + }); | |
| 25 | +}); | |
added
connectors/api/hifishark/index.ts
+128 −0
@@ -0,0 +1,128 @@ | ||
| 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, parseSourceDate, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { brandFromName, stripBrand } from '../_wlib/index.js'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * HiFiShark — aggregator of second-hand hi-fi listings (eBay, Subito, Kleinanzeigen, Audiogon, …). | |
| 8 | + * The "For Sale" tab of a search page is server-rendered: title, price in the seller's currency, | |
| 9 | + * marketplace, country, listing date and image. Sold/expired tabs load through endpoints that | |
| 10 | + * robots.txt disallows, so only live asks are collected. | |
| 11 | + */ | |
| 12 | +const BASE = 'https://www.hifishark.com'; | |
| 13 | +const PARSER_VERSION = '1.0.0'; | |
| 14 | + | |
| 15 | +export const RowSchema = z.object({ | |
| 16 | + id: z.string(), | |
| 17 | + href: z.string(), | |
| 18 | + title: z.string(), | |
| 19 | + priceText: z.string().nullable(), | |
| 20 | + marketplace: z.string().nullable(), | |
| 21 | + country: z.string().nullable(), | |
| 22 | + countryIso: z.string().nullable(), | |
| 23 | + firstSeen: z.string().nullable(), | |
| 24 | + image: z.string().nullable(), | |
| 25 | +}); | |
| 26 | +export type Row = z.infer<typeof RowSchema>; | |
| 27 | +export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), url: z.string(), query: z.string(), forSaleCount: z.number().nullable(), rows: z.array(RowSchema) }); | |
| 28 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 29 | + | |
| 30 | +export function parseSearchPage(htmlText: string, url: string, query: string): PagePayload { | |
| 31 | + const $ = H.load(htmlText); | |
| 32 | + const rows: Row[] = []; | |
| 33 | + $('a.search-product-row').each((_, el) => { | |
| 34 | + const a = $(el); | |
| 35 | + const id = a.attr('id') ?? ''; | |
| 36 | + const href = a.attr('href') ?? ''; | |
| 37 | + const title = H.text(a.find('.search-product-title')); | |
| 38 | + if (!id || !href || !title) return; | |
| 39 | + const flag = a.find('.search-product-info img.flag').first(); | |
| 40 | + const iso = (flag.attr('class') ?? '').match(/flag-([a-z]{2})/)?.[1] ?? null; | |
| 41 | + rows.push({ | |
| 42 | + id, | |
| 43 | + href, | |
| 44 | + title, | |
| 45 | + priceText: H.text(a.find('.price strong')) ?? H.text(a.find('.price')), | |
| 46 | + marketplace: H.text(a.find('.search-product-info .website'))?.replace(/\s+/g, ' ').trim() ?? null, | |
| 47 | + country: flag.attr('title') ?? null, | |
| 48 | + countryIso: iso ? iso.toUpperCase() : null, | |
| 49 | + firstSeen: H.text(a.find('.first-seen')), | |
| 50 | + image: a.find('.search-product-img img').attr('data-original') ?? null, | |
| 51 | + }); | |
| 52 | + }); | |
| 53 | + const countText = $('#result-tabs .nav-link.active span').first().text().replace(/[()]/g, '').trim(); | |
| 54 | + return { kind: 'search_page', url, query, forSaleCount: /^\d+$/.test(countText) ? Number(countText) : null, rows }; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export class HifisharkConnector 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 queries = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.queries as string[] | undefined)) ?? []; | |
| 64 | + let count = 0; | |
| 65 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.queryIndex ?? 0) : 0; | |
| 66 | + for (let qi = start; qi < queries.length; qi++) { | |
| 67 | + const query = queries[qi]!; | |
| 68 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 69 | + const url = `${BASE}/search?q=${encodeURIComponent(query)}`; | |
| 70 | + await this.throttle(); | |
| 71 | + const res = await ctx.fetch(url, { engines: ['api', 'firecrawl'], responseType: 'text', expect: ['title', 'price'], parse: (r) => (r.html && parseSearchPage(r.html, url, query).rows.length ? { title: 'ok', price: 1 } : null) }); | |
| 72 | + const payload = res.success && res.html ? parseSearchPage(res.html, url, query) : null; | |
| 73 | + if (!payload?.rows.length) { | |
| 74 | + ctx.anomaly('page_fetch_failed', `${query}: ${res.error ?? res.httpStatus ?? 'no rows'}`); | |
| 75 | + continue; | |
| 76 | + } | |
| 77 | + count++; | |
| 78 | + yield { url, externalId: `q:${query}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 79 | + await ctx.setCursor({ queryIndex: qi + 1 >= queries.length ? 0 : qi + 1, updatedAt: new Date().toISOString() }); | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 84 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 85 | + const out: NormalizedRecord[] = []; | |
| 86 | + const brand = brandFromName(p.query); | |
| 87 | + const model = stripBrand(p.query, brand); | |
| 88 | + for (const r of p.rows) { | |
| 89 | + const price = parsePrice(r.priceText ?? null); | |
| 90 | + const listedAt = parseSourceDate(r.firstSeen); | |
| 91 | + const attributes = AssetAttributesSchema.parse({ | |
| 92 | + categorySlug: 'audio_equipment', | |
| 93 | + brand, | |
| 94 | + model, | |
| 95 | + name: brand ? `${brand} ${model}` : p.query, | |
| 96 | + identifiers: {}, | |
| 97 | + metadata: { marketplace: r.marketplace, aggregator: 'HiFiShark', query: p.query }, | |
| 98 | + }); | |
| 99 | + out.push( | |
| 100 | + NormalizedListingSchema.parse({ | |
| 101 | + kind: 'listing', | |
| 102 | + connectorId: this.meta.id, | |
| 103 | + sourceId: this.meta.sourceId, | |
| 104 | + sourceUrl: r.href.startsWith('http') ? r.href : `${BASE}${r.href}`, | |
| 105 | + externalId: r.id, | |
| 106 | + rawTitle: r.title, | |
| 107 | + imageUrls: r.image ? [r.image] : [], | |
| 108 | + attributes, | |
| 109 | + observedAt: raw.fetchedAt, | |
| 110 | + confidence: 0.6, | |
| 111 | + parserVersion: PARSER_VERSION, | |
| 112 | + listingType: 'fixed_price', | |
| 113 | + price: price?.amount ?? null, | |
| 114 | + currency: price?.currency ?? null, | |
| 115 | + seller: r.marketplace, | |
| 116 | + location: r.countryIso ?? r.country, | |
| 117 | + listedAt, | |
| 118 | + availability: 'available', | |
| 119 | + }), | |
| 120 | + ); | |
| 121 | + } | |
| 122 | + return out; | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +export default function createConnector(meta: ConnectorMeta) { | |
| 127 | + return new HifisharkConnector(meta); | |
| 128 | +} | |
added
connectors/api/hifishark/meta.json
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +{ | |
| 2 | + "id": "hifishark", | |
| 3 | + "displayName": "HiFiShark (second-hand hi-fi aggregator)", | |
| 4 | + "sourceId": "hifishark", | |
| 5 | + "sourceName": "HiFiShark", | |
| 6 | + "sourceType": "analytics_provider", | |
| 7 | + "sourceUrl": "https://www.hifishark.com", | |
| 8 | + "module": "api/hifishark", | |
| 9 | + "enginePriority": ["api", "firecrawl"], | |
| 10 | + "categories": ["audio_equipment"], | |
| 11 | + "regions": ["EU", "US", "GB", "JP"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["EUR", "USD", "GBP", "JPY", "CHF", "SEK", "DKK", "NOK", "PLN", "CAD", "AUD"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.6, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.hifishark.com/terms", | |
| 26 | + "accessNotes": "Search pages (hifishark.com/search?q=<model>) are fetched over plain HTTPS with the RareIndex user agent, 2 s apart; robots.txt allows /search for generic agents. Only the server-rendered 'For Sale' tab is used (title, asking price in the seller's currency, marketplace, country, listing date, image); the Sold/Expired tab and the /searchrt, /searchSlice and /api endpoints are disallowed by robots.txt and are not requested, so no transactions are recorded. Listings are attributed to HiFiShark and the originating marketplace (metadata.marketplace); the source URL is HiFiShark's redirect link, which is not crawled. Seeds are collectible hi-fi models (query = brand + model, used as the asset name).", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "queries": ["Marantz 2270", "Marantz 2325", "Marantz Model 7", "McIntosh MC275", "McIntosh MC240", "McIntosh C22", "Technics SL-1200", "Technics SP-10", "Nakamichi Dragon", "Nakamichi 1000ZXL", "JBL L100", "JBL Paragon", "JBL 4343", "Klipschorn", "Klipsch La Scala", "Sansui 9090DB", "Sansui AU-111", "Pioneer SX-1980", "Pioneer SX-1250", "Revox B77", "Revox A77", "Studer A80", "Linn LP12", "Thorens TD124", "Thorens TD160", "Garrard 301", "Garrard 401", "Tannoy Monitor Gold", "Tannoy Westminster", "Quad ESL 57", "Quad 405", "Sennheiser HD800", "Stax SR-009", "Yamaha NS-1000", "Luxman L-550", "Accuphase E-303", "Bang & Olufsen Beogram 4000", "Bang & Olufsen Beomaster 1900", "Naim NAP 250", "Rega Planar 3", "Denon DP-3000", "Micro Seiki RX-5000", "Kenwood L-07", "Leak Stereo 20", "Marantz 10B"] | |
| 31 | + } | |
| 32 | +} | |
added
connectors/api/lukie-games/index.test.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 5 | +import createConnector, { splitName, PLATFORMS } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('lukie-games', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('maps platforms and strips platform tokens from names', async () => { | |
| 13 | + expect(splitName('Zelda Ocarina of Time N64 Game Complete', 'Nintendo 64')).toMatchObject({ name: 'Zelda Ocarina of Time', completeness: 'cib' }); | |
| 14 | + expect(splitName('GoldenEye 007', 'Nintendo 64')).toMatchObject({ name: 'GoldenEye 007', completeness: null }); | |
| 15 | + expect(PLATFORMS['Nintendo 64']).toEqual({ set: 'Nintendo 64', slug: 'nintendo_games' }); | |
| 16 | + const fx = loadFixture('lukie-games', 'nintendo-64-p1'); | |
| 17 | + const out = await connector.normalize(fx.raw); | |
| 18 | + expect(out.length).toBeGreaterThan(0); | |
| 19 | + for (const r of out) { | |
| 20 | + if (r.kind !== 'listing') throw new Error('expected listing'); | |
| 21 | + expect(r.attributes.categorySlug).toBe('nintendo_games'); | |
| 22 | + expect(r.attributes.set).toBe('Nintendo 64'); | |
| 23 | + expect(r.attributes.name).not.toMatch(/^Manual/); | |
| 24 | + expect(r.attributes.identifiers.lukie_sku).toBeTruthy(); | |
| 25 | + expect(r.condition.completeness).toBeTruthy(); | |
| 26 | + expect(r.currency).toBe('USD'); | |
| 27 | + } | |
| 28 | + }); | |
| 29 | +}); | |
added
connectors/api/lukie-games/index.ts
+198 −0
@@ -0,0 +1,198 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { decodeEntities } from '../_wlib/index.js'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Lukie Games — US retro video-game dealer. Its storefront search is the public SearchSpring JSON | |
| 8 | + * feed the site itself calls (siteId dytuzo); one result = one product with platform (extrafield5), | |
| 9 | + * genre (extrafield3), price, list price, SKU, stock message and image. Dealer asking prices only. | |
| 10 | + */ | |
| 11 | +const API = 'https://dytuzo.a.searchspring.io/api/search/search.json'; | |
| 12 | +const PARSER_VERSION = '1.0.0'; | |
| 13 | + | |
| 14 | +export const ResultSchema = z.object({ | |
| 15 | + uid: z.string(), | |
| 16 | + sku: z.string().nullable().default(null), | |
| 17 | + name: z.string(), | |
| 18 | + brand: z.string().nullable().default(null), | |
| 19 | + platform: z.string().nullable().default(null), | |
| 20 | + genre: z.string().nullable().default(null), | |
| 21 | + price: z.number().nullable(), | |
| 22 | + msrp: z.number().nullable(), | |
| 23 | + url: z.string(), | |
| 24 | + image: z.string().nullable().default(null), | |
| 25 | + stock: z.string().nullable().default(null), | |
| 26 | + onsale: z.boolean().default(false), | |
| 27 | +}); | |
| 28 | +export type Result = z.infer<typeof ResultSchema>; | |
| 29 | +export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), url: z.string(), platform: z.string(), page: z.number(), total: z.number().nullable(), results: z.array(ResultSchema) }); | |
| 30 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 31 | + | |
| 32 | +const num = (v: unknown) => { | |
| 33 | + const n = Number(v); | |
| 34 | + return Number.isFinite(n) && n > 0 ? n : null; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +export function parseApiPage(json: unknown, url: string, platform: string, page: number): PagePayload | null { | |
| 38 | + const j = json as { results?: Array<Record<string, unknown>>; pagination?: { totalResults?: number } } | null; | |
| 39 | + if (!j || !Array.isArray(j.results)) return null; | |
| 40 | + const results: Result[] = []; | |
| 41 | + for (const r of j.results) { | |
| 42 | + const parsed = ResultSchema.safeParse({ | |
| 43 | + uid: String(r.uid ?? r.id ?? ''), | |
| 44 | + sku: r.sku ? String(r.sku) : null, | |
| 45 | + name: decodeEntities(String(r.name ?? '')), | |
| 46 | + brand: r.brand ? String(r.brand) : null, | |
| 47 | + platform: r.extrafield5 ? decodeEntities(String(r.extrafield5)) : null, | |
| 48 | + genre: r.extrafield3 ? decodeEntities(String(r.extrafield3)) : null, | |
| 49 | + price: num(r.price), | |
| 50 | + msrp: num(r.msrp), | |
| 51 | + url: String(r.url ?? ''), | |
| 52 | + image: r.imageUrl ? decodeEntities(String(r.imageUrl)) : null, | |
| 53 | + stock: r.stock_message ? String(r.stock_message) : null, | |
| 54 | + onsale: r.onsale === '1' || r.onsale === 1 || r.onsale === true, | |
| 55 | + }); | |
| 56 | + if (parsed.success && parsed.data.name && parsed.data.url) results.push(parsed.data); | |
| 57 | + } | |
| 58 | + return { kind: 'search_page', url, platform, page, total: typeof j.pagination?.totalResults === 'number' ? j.pagination.totalResults : null, results }; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** Lukie platform label → PriceCharting-style console name + taxonomy slug. */ | |
| 62 | +export const PLATFORMS: Record<string, { set: string; slug: string }> = { | |
| 63 | + 'Nintendo 64': { set: 'Nintendo 64', slug: 'nintendo_games' }, | |
| 64 | + 'Super Nintendo': { set: 'Super Nintendo', slug: 'nintendo_games' }, | |
| 65 | + 'Nintendo NES': { set: 'NES', slug: 'nintendo_games' }, | |
| 66 | + NES: { set: 'NES', slug: 'nintendo_games' }, | |
| 67 | + Gamecube: { set: 'Gamecube', slug: 'nintendo_games' }, | |
| 68 | + 'Nintendo Gamecube': { set: 'Gamecube', slug: 'nintendo_games' }, | |
| 69 | + 'Nintendo Wii': { set: 'Wii', slug: 'nintendo_games' }, | |
| 70 | + 'Wii U': { set: 'Wii U', slug: 'nintendo_games' }, | |
| 71 | + 'Nintendo Switch': { set: 'Nintendo Switch', slug: 'nintendo_games' }, | |
| 72 | + Gameboy: { set: 'GameBoy', slug: 'nintendo_games' }, | |
| 73 | + 'Gameboy Color': { set: 'GameBoy Color', slug: 'nintendo_games' }, | |
| 74 | + 'Gameboy Advance': { set: 'GameBoy Advance', slug: 'nintendo_games' }, | |
| 75 | + 'Nintendo DS': { set: 'Nintendo DS', slug: 'nintendo_games' }, | |
| 76 | + 'Nintendo 3DS': { set: 'Nintendo 3DS', slug: 'nintendo_games' }, | |
| 77 | + 'Virtual Boy': { set: 'Virtual Boy', slug: 'nintendo_games' }, | |
| 78 | + Playstation: { set: 'Playstation', slug: 'playstation_games' }, | |
| 79 | + 'Playstation 2': { set: 'Playstation 2', slug: 'playstation_games' }, | |
| 80 | + 'Playstation 3': { set: 'Playstation 3', slug: 'playstation_games' }, | |
| 81 | + 'Playstation 4': { set: 'Playstation 4', slug: 'playstation_games' }, | |
| 82 | + PSP: { set: 'PSP', slug: 'playstation_games' }, | |
| 83 | + 'PS Vita': { set: 'Playstation Vita', slug: 'playstation_games' }, | |
| 84 | + Xbox: { set: 'Xbox', slug: 'xbox_games' }, | |
| 85 | + 'Xbox 360': { set: 'Xbox 360', slug: 'xbox_games' }, | |
| 86 | + 'Xbox One': { set: 'Xbox One', slug: 'xbox_games' }, | |
| 87 | + 'Sega Genesis': { set: 'Sega Genesis', slug: 'sega_games' }, | |
| 88 | + 'Sega Dreamcast': { set: 'Sega Dreamcast', slug: 'sega_games' }, | |
| 89 | + 'Sega Saturn': { set: 'Sega Saturn', slug: 'sega_games' }, | |
| 90 | + 'Sega Master System': { set: 'Sega Master System', slug: 'sega_games' }, | |
| 91 | + 'Sega Game Gear': { set: 'Sega Game Gear', slug: 'sega_games' }, | |
| 92 | + 'Sega CD': { set: 'Sega CD', slug: 'sega_games' }, | |
| 93 | + 'Sega 32X': { set: 'Sega 32X', slug: 'sega_games' }, | |
| 94 | + 'Atari 2600': { set: 'Atari 2600', slug: 'atari_retro_games' }, | |
| 95 | + 'Atari 5200': { set: 'Atari 5200', slug: 'atari_retro_games' }, | |
| 96 | + 'Atari 7800': { set: 'Atari 7800', slug: 'atari_retro_games' }, | |
| 97 | + 'Atari Jaguar': { set: 'Jaguar', slug: 'atari_retro_games' }, | |
| 98 | + 'Neo Geo Pocket': { set: 'Neo Geo Pocket Color', slug: 'atari_retro_games' }, | |
| 99 | + TurboGrafx: { set: 'TurboGrafx-16', slug: 'atari_retro_games' }, | |
| 100 | + Intellivision: { set: 'Intellivision', slug: 'atari_retro_games' }, | |
| 101 | + Colecovision: { set: 'Colecovision', slug: 'atari_retro_games' }, | |
| 102 | +}; | |
| 103 | + | |
| 104 | +const HARDWARE = /\b(system|console|controller|adapter|cable|memory card|expansion pak|rumble pak|power supply|av cable|accessor(?:y|ies)|carrying case|cover|skin|stylus|charger|headset|strategy guide|magazine)\b/i; | |
| 105 | +const NOT_A_GAME = /^\s*(manual|box|instructions?|poster|insert|map|sleeve)\b|\b(manual only|box only|instructions only)\b/i; | |
| 106 | + | |
| 107 | +/** "GoldenEye 007" / "Super Mario 64 Game" / "Zelda Ocarina of Time N64 Game Complete" → { name, completeness } */ | |
| 108 | +export function splitName(name: string, platform: string | null): { name: string; completeness: 'loose' | 'cib' | 'sealed' | null; condition: string | null } { | |
| 109 | + let n = name.replace(/\s+/g, ' ').trim(); | |
| 110 | + let completeness: 'loose' | 'cib' | 'sealed' | null = null; | |
| 111 | + if (/\b(brand new|new sealed|factory sealed|sealed)\b/i.test(n)) completeness = 'sealed'; | |
| 112 | + else if (/\b(complete in box|complete|cib|boxed|with box)\b/i.test(n)) completeness = 'cib'; | |
| 113 | + else if (/\b(cartridge only|cart only|disc only|game only|loose)\b/i.test(n)) completeness = 'loose'; | |
| 114 | + n = n.replace(/\b(brand new|new sealed|factory sealed|sealed|complete in box|complete|cib|boxed|with box|cartridge only|cart only|disc only|game only|loose)\b/gi, ' '); | |
| 115 | + if (platform) n = n.replace(new RegExp(`\\b${platform.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'), ' '); | |
| 116 | + n = n.replace(/\bNintendo\s+(N64|NES|SNES|DS|3DS|Wii)\b/gi, ' ').replace(/\b(N64|SNES|NES|GBA|GBC|PS1|PS2|PS3|PS4|PSP|GC|Wii U|Wii)\b\s*(Game)?/gi, ' ').replace(/\bGame\b\s*$/i, ' ').replace(/\s+/g, ' ').replace(/^[\s\-–:]+|[\s\-–:]+$/g, '').trim(); | |
| 117 | + return { name: n || name, completeness, condition: completeness }; | |
| 118 | +} | |
| 119 | + | |
| 120 | +export class LukieGamesConnector extends BaseConnector { | |
| 121 | + readonly version = '1.0.0'; | |
| 122 | + readonly parserVersion = PARSER_VERSION; | |
| 123 | + protected override minIntervalMs = 1500; | |
| 124 | + | |
| 125 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 126 | + const platforms = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.platforms as string[] | undefined)) ?? Object.keys(PLATFORMS); | |
| 127 | + const pages = ctx.options.mode === 'backfill' ? Number(this.meta.config.backfillPages ?? 10) : Number(this.meta.config.pagesPerPlatform ?? 2); | |
| 128 | + const perPage = Number(this.meta.config.perPage ?? 100); | |
| 129 | + let count = 0; | |
| 130 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.platformIndex ?? 0) : 0; | |
| 131 | + for (let pi = start; pi < platforms.length; pi++) { | |
| 132 | + const platform = platforms[pi]!; | |
| 133 | + for (let page = 1; page <= pages; page++) { | |
| 134 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 135 | + const url = `${API}?siteId=dytuzo&resultsFormat=native&resultsPerPage=${perPage}&page=${page}&bgfilter.extrafield5=${encodeURIComponent(platform)}&sort.current_price=desc`; | |
| 136 | + await this.throttle(); | |
| 137 | + const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', expect: ['title', 'price'], parse: (r) => (parseApiPage(r.json, url, platform, page)?.results.length ? { title: 'ok', price: 1 } : null) }); | |
| 138 | + const payload = res.success ? parseApiPage(res.json, url, platform, page) : null; | |
| 139 | + if (!payload) { | |
| 140 | + ctx.anomaly('page_fetch_failed', `${platform} p${page}: ${res.error ?? res.httpStatus}`); | |
| 141 | + break; | |
| 142 | + } | |
| 143 | + if (!payload.results.length) break; | |
| 144 | + count++; | |
| 145 | + yield { url, externalId: `platform:${platform}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 146 | + if (payload.results.length < perPage) break; | |
| 147 | + } | |
| 148 | + await ctx.setCursor({ platformIndex: pi + 1 >= platforms.length ? 0 : pi + 1, updatedAt: new Date().toISOString() }); | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 153 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 154 | + const out: NormalizedRecord[] = []; | |
| 155 | + for (const r of p.results) { | |
| 156 | + const platform = r.platform ?? p.platform; | |
| 157 | + const map = PLATFORMS[platform] ?? PLATFORMS[p.platform]; | |
| 158 | + if (!map || !r.price) continue; | |
| 159 | + if (HARDWARE.test(r.name) || NOT_A_GAME.test(r.name) || /SYS|_CONTROLLER|_ACC|_MANUAL|_BOX/i.test(r.sku ?? '')) continue; | |
| 160 | + const { name, completeness } = splitName(r.name, platform); | |
| 161 | + const attributes = AssetAttributesSchema.parse({ | |
| 162 | + categorySlug: map.slug, | |
| 163 | + brand: r.brand, | |
| 164 | + set: map.set, | |
| 165 | + name, | |
| 166 | + identifiers: { lukie_sku: r.sku ?? r.uid }, | |
| 167 | + metadata: { platform, genre: r.genre, lukie_list_price: r.msrp, on_sale: r.onsale }, | |
| 168 | + }); | |
| 169 | + out.push( | |
| 170 | + NormalizedListingSchema.parse({ | |
| 171 | + kind: 'listing', | |
| 172 | + connectorId: this.meta.id, | |
| 173 | + sourceId: this.meta.sourceId, | |
| 174 | + sourceUrl: r.url, | |
| 175 | + externalId: r.uid, | |
| 176 | + rawTitle: r.name, | |
| 177 | + imageUrls: r.image ? [r.image] : [], | |
| 178 | + attributes, | |
| 179 | + condition: { condition: completeness ?? 'loose', conditionRaw: completeness ? null : 'Lukie default (game only unless stated)', completeness: completeness ?? 'loose' }, | |
| 180 | + observedAt: raw.fetchedAt, | |
| 181 | + confidence: 0.8, | |
| 182 | + parserVersion: PARSER_VERSION, | |
| 183 | + listingType: 'fixed_price', | |
| 184 | + price: r.price, | |
| 185 | + currency: 'USD', | |
| 186 | + seller: 'Lukie Games', | |
| 187 | + location: 'US', | |
| 188 | + availability: r.stock && /out of stock|sold out/i.test(r.stock) ? 'ended' : 'available', | |
| 189 | + }), | |
| 190 | + ); | |
| 191 | + } | |
| 192 | + return out; | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +export default function createConnector(meta: ConnectorMeta) { | |
| 197 | + return new LukieGamesConnector(meta); | |
| 198 | +} | |
added
connectors/api/lukie-games/meta.json
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +{ | |
| 2 | + "id": "lukie-games", | |
| 3 | + "displayName": "Lukie Games (retro video-game dealer, USD)", | |
| 4 | + "sourceId": "lukie-games", | |
| 5 | + "sourceName": "Lukie Games", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.lukiegames.com", | |
| 8 | + "module": "api/lukie-games", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["nintendo_games", "playstation_games", "xbox_games", "sega_games", "atari_retro_games"], | |
| 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": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.75, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.lukiegames.com/terms.html", | |
| 26 | + "accessNotes": "Lukie's category pages render products through SearchSpring; the connector calls the same public JSON feed the storefront uses (dytuzo.a.searchspring.io/api/search/search.json, filtered by platform via bgfilter.extrafield5, 100 results/page, sorted by price desc). No login or key; 1.5 s between requests. Only games are kept (systems/accessories filtered by name and SKU); completeness defaults to 'loose' unless the product name says complete/sealed, and the platform is mapped to the PriceCharting console vocabulary (attributes.set) so the dealer asks attach to the same assets as PriceCharting sales. Prices are Lukie asking prices (USD), never transactions.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "perPage": 100, | |
| 31 | + "pagesPerPlatform": 2, | |
| 32 | + "backfillPages": 10, | |
| 33 | + "platforms": ["Nintendo 64", "Super Nintendo", "Nintendo NES", "Gamecube", "Gameboy", "Gameboy Color", "Gameboy Advance", "Nintendo DS", "Playstation", "Playstation 2", "Playstation 3", "PSP", "Xbox", "Xbox 360", "Sega Genesis", "Sega Dreamcast", "Sega Saturn", "Sega Game Gear", "Atari 2600", "Nintendo Wii"] | |
| 34 | + } | |
| 35 | +} | |
added
connectors/api/openlibrary/index.test.ts
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 5 | +import createConnector, { trimEdition } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('openlibrary', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('emits a catalog item with isbn + Open Library ids', async () => { | |
| 13 | + const e = trimEdition({ key: '/books/OL1M', title: 'T', publishers: ['P'], publish_date: '1997', isbn_10: ['0747532699'], works: [{ key: '/works/OL2W' }], covers: [1, -1] }); | |
| 14 | + expect(e).toMatchObject({ key: '/books/OL1M', publishers: ['P'], isbn10: ['0747532699'], workKey: '/works/OL2W', covers: [1] }); | |
| 15 | + const fx = loadFixture('openlibrary', 'hp-philosophers-stone'); | |
| 16 | + const out = await connector.normalize(fx.raw); | |
| 17 | + expect(out).toHaveLength(1); | |
| 18 | + const c = out[0]!; | |
| 19 | + if (c.kind !== 'catalog_item') throw new Error('expected catalog_item'); | |
| 20 | + expect(c.attributes).toMatchObject({ categorySlug: 'books', brand: 'J. K. Rowling', year: 1997 }); | |
| 21 | + expect(c.attributes.identifiers.isbn).toBeTruthy(); | |
| 22 | + expect(c.attributes.identifiers.openlibrary_id).toMatch(/^OL\d+M$/); | |
| 23 | + expect(c.sourceUrl).toMatch(/openlibrary\.org\/books\/OL/); | |
| 24 | + }); | |
| 25 | +}); | |
added
connectors/api/openlibrary/index.ts
+152 −0
@@ -0,0 +1,152 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedCatalogItemSchema, extractYear, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Open Library (Internet Archive) — free bibliographic API. Seeded with ISBNs of collectible modern | |
| 7 | + * first editions; each seed resolves edition → work → author through the JSON endpoints that | |
| 8 | + * robots.txt permits (/isbn, /books, /works, /authors — not /search). Catalog enrichment only. | |
| 9 | + */ | |
| 10 | +const BASE = 'https://openlibrary.org'; | |
| 11 | +const PARSER_VERSION = '1.0.0'; | |
| 12 | +const HEADERS = { 'user-agent': 'RareIndexBot/0.1 (market data research; data@rareindex.io)', accept: 'application/json' }; | |
| 13 | + | |
| 14 | +export const SeedSchema = z.object({ isbn: z.string(), note: z.string().optional() }); | |
| 15 | +export const EditionPayloadSchema = z.object({ | |
| 16 | + kind: z.literal('edition'), | |
| 17 | + isbn: z.string(), | |
| 18 | + note: z.string().nullable(), | |
| 19 | + edition: z.object({ | |
| 20 | + key: z.string(), | |
| 21 | + title: z.string(), | |
| 22 | + subtitle: z.string().nullable(), | |
| 23 | + publishers: z.array(z.string()), | |
| 24 | + publishDate: z.string().nullable(), | |
| 25 | + publishPlaces: z.array(z.string()), | |
| 26 | + isbn10: z.array(z.string()), | |
| 27 | + isbn13: z.array(z.string()), | |
| 28 | + pages: z.number().nullable(), | |
| 29 | + editionName: z.string().nullable(), | |
| 30 | + covers: z.array(z.number()), | |
| 31 | + physicalFormat: z.string().nullable(), | |
| 32 | + workKey: z.string().nullable(), | |
| 33 | + }), | |
| 34 | + work: z.object({ key: z.string(), title: z.string().nullable(), firstPublishDate: z.string().nullable(), subjects: z.array(z.string()) }).nullable(), | |
| 35 | + authors: z.array(z.string()), | |
| 36 | +}); | |
| 37 | +export type EditionPayload = z.infer<typeof EditionPayloadSchema>; | |
| 38 | + | |
| 39 | +type J = Record<string, any>; | |
| 40 | +const strList = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); | |
| 41 | + | |
| 42 | +export function trimEdition(e: J) { | |
| 43 | + return { | |
| 44 | + key: String(e.key ?? ''), | |
| 45 | + title: String(e.title ?? ''), | |
| 46 | + subtitle: e.subtitle ? String(e.subtitle) : null, | |
| 47 | + publishers: strList(e.publishers), | |
| 48 | + publishDate: e.publish_date ? String(e.publish_date) : null, | |
| 49 | + publishPlaces: strList(e.publish_places), | |
| 50 | + isbn10: strList(e.isbn_10), | |
| 51 | + isbn13: strList(e.isbn_13), | |
| 52 | + pages: typeof e.number_of_pages === 'number' ? e.number_of_pages : null, | |
| 53 | + editionName: e.edition_name ? String(e.edition_name) : null, | |
| 54 | + covers: Array.isArray(e.covers) ? e.covers.filter((c: unknown) => typeof c === 'number' && c > 0) : [], | |
| 55 | + physicalFormat: e.physical_format ? String(e.physical_format) : null, | |
| 56 | + workKey: Array.isArray(e.works) && e.works[0]?.key ? String(e.works[0].key) : null, | |
| 57 | + }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export class OpenLibraryConnector extends BaseConnector { | |
| 61 | + readonly version = '1.0.0'; | |
| 62 | + readonly parserVersion = PARSER_VERSION; | |
| 63 | + protected override minIntervalMs = 1000; | |
| 64 | + | |
| 65 | + private async getJson(ctx: CrawlContext, path: string) { | |
| 66 | + await this.throttle(); | |
| 67 | + return ctx.fetch(`${BASE}${path}`, { engines: ['api'], responseType: 'json', headers: HEADERS, minQuality: 0.2 }); | |
| 68 | + } | |
| 69 | + | |
| 70 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 71 | + const seeds = z.array(SeedSchema).parse(this.meta.config.seeds ?? []); | |
| 72 | + let count = 0; | |
| 73 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 74 | + for (let i = start; i < seeds.length; i++) { | |
| 75 | + const seed = seeds[i]!; | |
| 76 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 77 | + const url = `${BASE}/isbn/${seed.isbn}.json`; | |
| 78 | + if (!(await ctx.shouldFetch(url))) continue; | |
| 79 | + const ed = await this.getJson(ctx, `/isbn/${seed.isbn}.json`); | |
| 80 | + if (!ed.success || !ed.json || typeof ed.json !== 'object') { | |
| 81 | + ctx.anomaly('page_fetch_failed', `${seed.isbn}: ${ed.error ?? ed.httpStatus}`); | |
| 82 | + continue; | |
| 83 | + } | |
| 84 | + const edition = trimEdition(ed.json as J); | |
| 85 | + let work: EditionPayload['work'] = null; | |
| 86 | + const authorKeys: string[] = []; | |
| 87 | + if (edition.workKey) { | |
| 88 | + const w = await this.getJson(ctx, `${edition.workKey}.json`); | |
| 89 | + if (w.success && w.json && typeof w.json === 'object') { | |
| 90 | + const wj = w.json as J; | |
| 91 | + work = { key: edition.workKey, title: wj.title ? String(wj.title) : null, firstPublishDate: wj.first_publish_date ? String(wj.first_publish_date) : null, subjects: strList(wj.subjects).slice(0, 12) }; | |
| 92 | + for (const a of Array.isArray(wj.authors) ? wj.authors : []) if (a?.author?.key) authorKeys.push(String(a.author.key)); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + for (const a of Array.isArray((ed.json as J).authors) ? (ed.json as J).authors : []) if (a?.key && !authorKeys.includes(String(a.key))) authorKeys.push(String(a.key)); | |
| 96 | + const authors: string[] = []; | |
| 97 | + for (const key of authorKeys.slice(0, 2)) { | |
| 98 | + const a = await this.getJson(ctx, `${key}.json`); | |
| 99 | + const name = (a.json as J | null)?.name; | |
| 100 | + if (a.success && name) authors.push(String(name)); | |
| 101 | + } | |
| 102 | + const payload: EditionPayload = { kind: 'edition', isbn: seed.isbn, note: seed.note ?? null, edition, work, authors }; | |
| 103 | + count++; | |
| 104 | + yield { url, externalId: `isbn:${seed.isbn}`, kind: 'catalog_item', engine: ed.engine, httpStatus: ed.httpStatus, payload, fetchedAt: ed.fetchedAt }; | |
| 105 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 110 | + const p = EditionPayloadSchema.parse(raw.payload); | |
| 111 | + const e = p.edition; | |
| 112 | + if (!e.title) return []; | |
| 113 | + const author = p.authors[0] ?? null; | |
| 114 | + const year = e.publishDate ? extractYear(e.publishDate) : null; | |
| 115 | + const isbn = e.isbn13[0] ?? e.isbn10[0] ?? p.isbn; | |
| 116 | + const olid = e.key.replace('/books/', ''); | |
| 117 | + const variant = e.editionName ?? (p.note && /first/i.test(p.note) ? 'First edition' : null); | |
| 118 | + const attributes = AssetAttributesSchema.parse({ | |
| 119 | + categorySlug: 'books', | |
| 120 | + brand: author, | |
| 121 | + set: e.publishers[0] ?? null, | |
| 122 | + name: e.subtitle ? `${e.title}: ${e.subtitle}` : e.title, | |
| 123 | + year, | |
| 124 | + edition: variant, | |
| 125 | + variant, | |
| 126 | + country: e.publishPlaces[0] ?? null, | |
| 127 | + language: 'English', | |
| 128 | + identifiers: { isbn, openlibrary_id: olid, ...(e.workKey ? { openlibrary_work: e.workKey.replace('/works/', '') } : {}) }, | |
| 129 | + metadata: { authors: p.authors, pages: e.pages, physical_format: e.physicalFormat, first_publish_date: p.work?.firstPublishDate ?? null, subjects: p.work?.subjects ?? [], isbn_10: e.isbn10, isbn_13: e.isbn13, seed_note: p.note }, | |
| 130 | + }); | |
| 131 | + return [ | |
| 132 | + NormalizedCatalogItemSchema.parse({ | |
| 133 | + kind: 'catalog_item', | |
| 134 | + connectorId: this.meta.id, | |
| 135 | + sourceId: this.meta.sourceId, | |
| 136 | + sourceUrl: `${BASE}${e.key}`, | |
| 137 | + externalId: olid, | |
| 138 | + rawTitle: author ? `${author} — ${e.title}${year ? ` (${year})` : ''}` : e.title, | |
| 139 | + imageUrls: e.covers.slice(0, 1).map((c) => `https://covers.openlibrary.org/b/id/${c}-L.jpg`), | |
| 140 | + attributes, | |
| 141 | + observedAt: raw.fetchedAt, | |
| 142 | + confidence: 0.9, | |
| 143 | + parserVersion: PARSER_VERSION, | |
| 144 | + releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null, | |
| 145 | + }), | |
| 146 | + ]; | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +export default function createConnector(meta: ConnectorMeta) { | |
| 151 | + return new OpenLibraryConnector(meta); | |
| 152 | +} | |
added
connectors/api/openlibrary/meta.json
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +{ | |
| 2 | + "id": "openlibrary", | |
| 3 | + "displayName": "Open Library (bibliographic catalog — collectible first editions)", | |
| 4 | + "sourceId": "openlibrary", | |
| 5 | + "sourceName": "Open Library (Internet Archive)", | |
| 6 | + "sourceType": "catalog", | |
| 7 | + "sourceUrl": "https://openlibrary.org", | |
| 8 | + "module": "api/openlibrary", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["books", "harry_potter", "lord_of_the_rings"], | |
| 11 | + "regions": ["GB", "US"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": true, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 10080, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.9, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://openlibrary.org/developers/api", | |
| 26 | + "accessNotes": "Open Library's JSON endpoints /isbn/<isbn>.json, /works/<id>.json and /authors/<id>.json (allowed by robots.txt; /search and /api are disallowed and not used) with a descriptive user agent and 1 request/second, as their API terms ask. Seeds are ISBNs of collectible modern first editions/first printings (config.seeds with a note); each yields one catalog item (title, author, publisher, place, year, edition name, pages, cover) with identifiers isbn + openlibrary_id so dealer/auction book records can resolve against it. No prices come from this source.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + { "isbn": "0747532699", "note": "Harry Potter and the Philosopher's Stone, Bloomsbury 1997 first edition" }, | |
| 32 | + { "isbn": "0747538492", "note": "Harry Potter and the Chamber of Secrets, Bloomsbury 1998 first edition" }, | |
| 33 | + { "isbn": "0747542155", "note": "Harry Potter and the Prisoner of Azkaban, Bloomsbury 1999 first edition" }, | |
| 34 | + { "isbn": "074754624X", "note": "Harry Potter and the Goblet of Fire, Bloomsbury 2000 first edition" }, | |
| 35 | + { "isbn": "0590353403", "note": "Harry Potter and the Sorcerer's Stone, Scholastic 1998 first US edition" }, | |
| 36 | + { "isbn": "0395489318", "note": "The Lord of the Rings, Houghton Mifflin 1987 one-volume edition" }, | |
| 37 | + { "isbn": "0048231878", "note": "The Hobbit, Allen & Unwin 1978 fourth edition" }, | |
| 38 | + { "isbn": "0553380168", "note": "A Brief History of Time, Bantam 1998" }, | |
| 39 | + { "isbn": "0553103547", "note": "A Game of Thrones, Bantam 1996 first edition" }, | |
| 40 | + { "isbn": "0553108034", "note": "A Clash of Kings, Bantam 1999 first US edition" }, | |
| 41 | + { "isbn": "0670813028", "note": "It, Viking 1986 first edition (Stephen King)" }, | |
| 42 | + { "isbn": "0385121679", "note": "The Shining, Doubleday 1977 first edition" }, | |
| 43 | + { "isbn": "0385086954", "note": "Carrie, Doubleday 1974 first edition" }, | |
| 44 | + { "isbn": "0394800168", "note": "The Cat in the Hat, Random House" }, | |
| 45 | + { "isbn": "0060256656", "note": "Where the Wild Things Are, Harper & Row" }, | |
| 46 | + { "isbn": "0316769487", "note": "The Catcher in the Rye, Little Brown" }, | |
| 47 | + { "isbn": "0451524934", "note": "1984 (Signet)" }, | |
| 48 | + { "isbn": "0743273567", "note": "The Great Gatsby, Scribner" }, | |
| 49 | + { "isbn": "0446310786", "note": "To Kill a Mockingbird, Warner Books" }, | |
| 50 | + { "isbn": "0345339681", "note": "The Hobbit, Ballantine" }, | |
| 51 | + { "isbn": "0441172717", "note": "Dune, Ace" }, | |
| 52 | + { "isbn": "0345391802", "note": "The Hitchhiker's Guide to the Galaxy, Del Rey" }, | |
| 53 | + { "isbn": "0399501487", "note": "Lord of the Flies, Perigee" }, | |
| 54 | + { "isbn": "0679783261", "note": "Pride and Prejudice, Modern Library" }, | |
| 55 | + { "isbn": "0553296981", "note": "Anne Frank: The Diary of a Young Girl, Bantam" }, | |
| 56 | + { "isbn": "0064400557", "note": "Charlotte's Web, HarperTrophy" }, | |
| 57 | + { "isbn": "0140177396", "note": "Of Mice and Men, Penguin" }, | |
| 58 | + { "isbn": "0743297334", "note": "The Old Man and the Sea, Scribner" }, | |
| 59 | + { "isbn": "0618260307", "note": "The Hobbit, Houghton Mifflin 2001" }, | |
| 60 | + { "isbn": "0618002227", "note": "The Fellowship of the Ring, Houghton Mifflin 1999" }, | |
| 61 | + { "isbn": "0439139597", "note": "Harry Potter and the Goblet of Fire, Scholastic 2000 first US edition" }, | |
| 62 | + { "isbn": "043935806X", "note": "Harry Potter and the Order of the Phoenix, Scholastic 2003 first US edition" }, | |
| 63 | + { "isbn": "0439784549", "note": "Harry Potter and the Half-Blood Prince, Scholastic 2005 first US edition" }, | |
| 64 | + { "isbn": "0545010225", "note": "Harry Potter and the Deathly Hallows, Scholastic 2007 first US edition" }, | |
| 65 | + { "isbn": "0747551006", "note": "Harry Potter and the Order of the Phoenix, Bloomsbury 2003 first edition" }, | |
| 66 | + { "isbn": "0747581088", "note": "Harry Potter and the Half-Blood Prince, Bloomsbury 2005 first edition" }, | |
| 67 | + { "isbn": "0747591059", "note": "Harry Potter and the Deathly Hallows, Bloomsbury 2007 first edition" }, | |
| 68 | + { "isbn": "0399226907", "note": "The Very Hungry Caterpillar, Philomel" }, | |
| 69 | + { "isbn": "0394900014", "note": "Green Eggs and Ham, Random House" }, | |
| 70 | + { "isbn": "0060935464", "note": "To Kill a Mockingbird, Perennial Classics" }, | |
| 71 | + { "isbn": "0192833553", "note": "Frankenstein, Oxford" }, | |
| 72 | + { "isbn": "0553213113", "note": "Dracula, Bantam" }, | |
| 73 | + { "isbn": "0451526341", "note": "Animal Farm, Signet" }, | |
| 74 | + { "isbn": "0060850523", "note": "Brave New World, Harper Perennial" }, | |
| 75 | + { "isbn": "0393975959", "note": "Heart of Darkness, Norton" }, | |
| 76 | + { "isbn": "0140283331", "note": "The Grapes of Wrath, Penguin" }, | |
| 77 | + { "isbn": "0316346624", "note": "Infinite Jest, Little Brown 1996 first edition" }, | |
| 78 | + { "isbn": "0394758285", "note": "Beloved, Knopf 1987 first edition" }, | |
| 79 | + { "isbn": "0394587146", "note": "American Psycho, Vintage 1991" }, | |
| 80 | + { "isbn": "0679720200", "note": "The Stranger, Vintage" }, | |
| 81 | + { "isbn": "0061120081", "note": "To Kill a Mockingbird, Harper 50th anniversary" }, | |
| 82 | + { "isbn": "0385333846", "note": "Slaughterhouse-Five, Dial" }, | |
| 83 | + { "isbn": "0684801221", "note": "The Sun Also Rises, Scribner" }, | |
| 84 | + { "isbn": "0394429575", "note": "Gravity's Rainbow, Viking 1973 first edition" }, | |
| 85 | + { "isbn": "0385490816", "note": "Fight Club, Norton 1996 first edition" }, | |
| 86 | + { "isbn": "0399137580", "note": "Jurassic Park, Knopf 1990 first edition" }, | |
| 87 | + { "isbn": "0394549937", "note": "The Silence of the Lambs, St. Martin's 1988 first edition" }, | |
| 88 | + { "isbn": "0553380958", "note": "Neuromancer, Ace" }, | |
| 89 | + { "isbn": "0345342968", "note": "Fahrenheit 451, Ballantine" }, | |
| 90 | + { "isbn": "0525947647", "note": "The Road, Knopf 2006 first edition" } | |
| 91 | + ] | |
| 92 | + } | |
| 93 | +} | |
added
connectors/api/reverb/index.test.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../_lib/local-meta.js'; | |
| 5 | +import createConnector, { categoryFor, trimListing } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('reverb', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('maps categories and trims API listings', async () => { | |
| 13 | + expect(categoryFor(['Home Audio / Amplifiers'])).toBe('audio_equipment'); | |
| 14 | + expect(categoryFor(['Electric Guitars / Solid Body'])).toBe('musical_instruments'); | |
| 15 | + const l = trimListing({ id: 1, make: 'Fender', model: 'Stratocaster', year: '1965', title: 'Fender Stratocaster 1965', condition: { display_name: 'Excellent' }, price: { amount: '18184.16', currency: 'USD' }, listing_currency: 'EUR', categories: [{ full_name: 'Electric Guitars / Solid Body' }], state: { slug: 'live' }, _links: { web: { href: 'https://reverb.com/item/1-x' } }, photos: [{ _links: { large_crop: { href: 'https://img/1.jpg' } } }] }); | |
| 16 | + expect(l).toMatchObject({ id: 1, make: 'Fender', year: '1965', condition: 'Excellent', price: 18184.16, currency: 'USD', listingCurrency: 'EUR', photo: 'https://img/1.jpg' }); | |
| 17 | + const fx = loadFixture('reverb', 'stratocaster-p1'); | |
| 18 | + const out = await connector.normalize(fx.raw); | |
| 19 | + expect(out.length).toBeGreaterThan(0); | |
| 20 | + for (const r of out) { | |
| 21 | + if (r.kind !== 'listing') throw new Error('expected listing'); | |
| 22 | + expect(['musical_instruments', 'audio_equipment']).toContain(r.attributes.categorySlug); | |
| 23 | + expect(r.attributes.identifiers.reverb_listing_id).toMatch(/^\d+$/); | |
| 24 | + expect(r.price).toBeGreaterThan(0); | |
| 25 | + expect(r.currency).toBe('USD'); | |
| 26 | + expect(r.sourceUrl).toMatch(/reverb\.com/); | |
| 27 | + } | |
| 28 | + }); | |
| 29 | +}); | |
added
connectors/api/reverb/index.ts
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Reverb — public listings API (api.reverb.com/api/listings, HAL JSON, version 3.0, no token needed | |
| 7 | + * for read-only listing search). Instruments, amps, pedals, synths and hi-fi ("Home Audio"). | |
| 8 | + * Prices are returned converted to USD by Reverb; the original listing currency is kept in metadata. | |
| 9 | + */ | |
| 10 | +const API = 'https://api.reverb.com/api'; | |
| 11 | +const PARSER_VERSION = '1.0.0'; | |
| 12 | +const HEADERS = { accept: 'application/hal+json', 'accept-version': '3.0', 'content-type': 'application/hal+json' }; | |
| 13 | + | |
| 14 | +export const ListingSchema = z.object({ | |
| 15 | + id: z.number(), | |
| 16 | + make: z.string().nullable().default(null), | |
| 17 | + model: z.string().nullable().default(null), | |
| 18 | + finish: z.string().nullable().default(null), | |
| 19 | + year: z.string().nullable().default(null), | |
| 20 | + title: z.string(), | |
| 21 | + condition: z.string().nullable().default(null), | |
| 22 | + price: z.number().nullable().default(null), | |
| 23 | + currency: z.string().nullable().default(null), | |
| 24 | + listingCurrency: z.string().nullable().default(null), | |
| 25 | + categories: z.array(z.string()).default([]), | |
| 26 | + state: z.string().nullable().default(null), | |
| 27 | + createdAt: z.string().nullable().default(null), | |
| 28 | + publishedAt: z.string().nullable().default(null), | |
| 29 | + shop: z.string().nullable().default(null), | |
| 30 | + offersEnabled: z.boolean().default(false), | |
| 31 | + auction: z.boolean().default(false), | |
| 32 | + photo: z.string().nullable().default(null), | |
| 33 | + webUrl: z.string().nullable().default(null), | |
| 34 | + description: z.string().nullable().default(null), | |
| 35 | +}); | |
| 36 | +export type Listing = z.infer<typeof ListingSchema>; | |
| 37 | +export const PagePayloadSchema = z.object({ kind: z.literal('listing_page'), url: z.string(), query: z.string(), page: z.number(), total: z.number().nullable(), listings: z.array(ListingSchema) }); | |
| 38 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 39 | + | |
| 40 | +type ApiListing = Record<string, any>; | |
| 41 | + | |
| 42 | +export function trimListing(l: ApiListing): Listing { | |
| 43 | + const photos = Array.isArray(l.photos) ? l.photos : []; | |
| 44 | + const photo = photos[0]?._links?.large_crop?.href ?? photos[0]?._links?.full?.href ?? null; | |
| 45 | + return ListingSchema.parse({ | |
| 46 | + id: Number(l.id), | |
| 47 | + make: l.make ?? null, | |
| 48 | + model: l.model ?? null, | |
| 49 | + finish: l.finish ?? null, | |
| 50 | + year: l.year ?? null, | |
| 51 | + title: String(l.title ?? ''), | |
| 52 | + condition: l.condition?.display_name ?? null, | |
| 53 | + price: l.price?.amount ? Number(l.price.amount) : null, | |
| 54 | + currency: l.price?.currency ?? null, | |
| 55 | + listingCurrency: l.listing_currency ?? null, | |
| 56 | + categories: Array.isArray(l.categories) ? l.categories.map((c: { full_name?: string }) => String(c.full_name ?? '')).filter(Boolean) : [], | |
| 57 | + state: l.state?.slug ?? null, | |
| 58 | + createdAt: l.created_at ?? null, | |
| 59 | + publishedAt: l.published_at ?? null, | |
| 60 | + shop: l.shop_name ?? l.shop?.name ?? null, | |
| 61 | + offersEnabled: Boolean(l.offers_enabled), | |
| 62 | + auction: Boolean(l.auction), | |
| 63 | + photo, | |
| 64 | + webUrl: l._links?.web?.href ?? null, | |
| 65 | + description: typeof l.description === 'string' ? l.description.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 600) : null, | |
| 66 | + }); | |
| 67 | +} | |
| 68 | + | |
| 69 | +export function parseApiPage(json: unknown, url: string, query: string, page: number): PagePayload | null { | |
| 70 | + const j = json as { listings?: ApiListing[]; total?: number } | null; | |
| 71 | + if (!j || !Array.isArray(j.listings)) return null; | |
| 72 | + return { kind: 'listing_page', url, query, page, total: typeof j.total === 'number' ? j.total : null, listings: j.listings.map(trimListing) }; | |
| 73 | +} | |
| 74 | + | |
| 75 | +const CONDITION_MAP: Record<string, string> = { 'brand new': 'excellent', 'b-stock': 'excellent', mint: 'excellent', excellent: 'excellent', 'very good': 'very_good', good: 'good', fair: 'fair', poor: 'poor', 'non functioning': 'poor' }; | |
| 76 | + | |
| 77 | +export function categoryFor(categories: string[]): 'musical_instruments' | 'audio_equipment' { | |
| 78 | + return categories.some((c) => /^(Home Audio|Pro Audio|DJ and Lighting Gear)/i.test(c)) ? 'audio_equipment' : 'musical_instruments'; | |
| 79 | +} | |
| 80 | + | |
| 81 | +export class ReverbConnector extends BaseConnector { | |
| 82 | + readonly version = '1.0.0'; | |
| 83 | + readonly parserVersion = PARSER_VERSION; | |
| 84 | + protected override minIntervalMs = 1200; | |
| 85 | + override readonly urlPatterns = [/^https?:\/\/(www\.)?reverb\.com\/(?:[a-z-]+\/)?item\/(\d+)/i]; | |
| 86 | + | |
| 87 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 88 | + const queries = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.queries as string[] | undefined)) ?? []; | |
| 89 | + const pages = ctx.options.mode === 'backfill' ? Number(this.meta.config.backfillPages ?? 6) : Number(this.meta.config.pagesPerQuery ?? 2); | |
| 90 | + const perPage = Number(this.meta.config.perPage ?? 50); | |
| 91 | + let count = 0; | |
| 92 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.queryIndex ?? 0) : 0; | |
| 93 | + for (let qi = start; qi < queries.length; qi++) { | |
| 94 | + const query = queries[qi]!; | |
| 95 | + for (let page = 1; page <= pages; page++) { | |
| 96 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 97 | + const url = `${API}/listings?query=${encodeURIComponent(query)}&per_page=${perPage}&page=${page}`; | |
| 98 | + await this.throttle(); | |
| 99 | + const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', headers: HEADERS, expect: ['title', 'price'], parse: (r) => (parseApiPage(r.json, url, query, page)?.listings.length ? { title: 'ok', price: 1 } : null) }); | |
| 100 | + const payload = res.success ? parseApiPage(res.json, url, query, page) : null; | |
| 101 | + if (!payload) { | |
| 102 | + ctx.anomaly('page_fetch_failed', `${query} p${page}: ${res.error ?? res.httpStatus}`); | |
| 103 | + break; | |
| 104 | + } | |
| 105 | + if (!payload.listings.length) break; | |
| 106 | + count++; | |
| 107 | + yield { url, externalId: `q:${query}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 108 | + if (payload.listings.length < perPage) break; | |
| 109 | + } | |
| 110 | + await ctx.setCursor({ queryIndex: qi + 1 >= queries.length ? 0 : qi + 1, updatedAt: new Date().toISOString() }); | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> { | |
| 115 | + const id = url.match(this.urlPatterns[0]!)?.[2]; | |
| 116 | + if (!id) return []; | |
| 117 | + await this.throttle(); | |
| 118 | + const apiUrl = `${API}/listings/${id}`; | |
| 119 | + const res = await ctx.fetch(apiUrl, { engines: ['api'], responseType: 'json', headers: HEADERS, minQuality: 0.2 }); | |
| 120 | + if (!res.success || !res.json || typeof res.json !== 'object' || !('id' in (res.json as object))) return []; | |
| 121 | + const payload: PagePayload = { kind: 'listing_page', url: apiUrl, query: `item:${id}`, page: 1, total: 1, listings: [trimListing(res.json as ApiListing)] }; | |
| 122 | + return [{ url: apiUrl, externalId: `item:${id}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; | |
| 123 | + } | |
| 124 | + | |
| 125 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 126 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 127 | + const out: NormalizedRecord[] = []; | |
| 128 | + for (const l of p.listings) { | |
| 129 | + if (!l.price || !l.title) continue; | |
| 130 | + // parts, cases and accessories are not collectible assets in their own right | |
| 131 | + if (l.categories.length && l.categories.every((c) => /^(Parts|Accessories)\b/i.test(c))) continue; | |
| 132 | + const categorySlug = categoryFor(l.categories); | |
| 133 | + const year = l.year && /^\d{4}$/.test(l.year.trim()) ? Number(l.year.trim()) : null; | |
| 134 | + const brand = l.make?.trim() || null; | |
| 135 | + const model = l.model?.trim() || null; | |
| 136 | + const condKey = (l.condition ?? '').toLowerCase(); | |
| 137 | + const attributes = AssetAttributesSchema.parse({ | |
| 138 | + categorySlug, | |
| 139 | + brand, | |
| 140 | + model, | |
| 141 | + name: brand && model ? (model.toLowerCase().startsWith(brand.toLowerCase()) ? model : `${brand} ${model}`) : l.title, | |
| 142 | + year, | |
| 143 | + color: l.finish && l.finish.length <= 60 ? l.finish : null, | |
| 144 | + identifiers: { reverb_listing_id: String(l.id) }, | |
| 145 | + metadata: { reverb_categories: l.categories, listing_currency: l.listingCurrency, price_converted_by_reverb: l.currency === 'USD' && l.listingCurrency && l.listingCurrency !== 'USD', shop: l.shop, auction: l.auction }, | |
| 146 | + }); | |
| 147 | + const listedAt = l.publishedAt ? new Date(l.publishedAt) : l.createdAt ? new Date(l.createdAt) : null; | |
| 148 | + out.push( | |
| 149 | + NormalizedListingSchema.parse({ | |
| 150 | + kind: 'listing', | |
| 151 | + connectorId: this.meta.id, | |
| 152 | + sourceId: this.meta.sourceId, | |
| 153 | + sourceUrl: l.webUrl ?? `https://reverb.com/item/${l.id}`, | |
| 154 | + externalId: String(l.id), | |
| 155 | + rawTitle: l.title, | |
| 156 | + description: l.description, | |
| 157 | + imageUrls: l.photo ? [l.photo] : [], | |
| 158 | + attributes, | |
| 159 | + condition: { condition: CONDITION_MAP[condKey] ?? null, conditionRaw: l.condition, completeness: null }, | |
| 160 | + observedAt: raw.fetchedAt, | |
| 161 | + confidence: l.currency === 'USD' && l.listingCurrency && l.listingCurrency !== 'USD' ? 0.75 : 0.85, | |
| 162 | + parserVersion: PARSER_VERSION, | |
| 163 | + listingType: l.auction ? 'auction' : l.offersEnabled ? 'best_offer' : 'fixed_price', | |
| 164 | + price: l.price, | |
| 165 | + currency: l.currency && /^[A-Z]{3}$/.test(l.currency) ? l.currency : 'USD', | |
| 166 | + seller: l.shop, | |
| 167 | + listedAt: listedAt && !Number.isNaN(listedAt.getTime()) ? listedAt : null, | |
| 168 | + availability: l.state === 'live' || l.state === null ? 'available' : l.state === 'sold' ? 'sold' : 'ended', | |
| 169 | + }), | |
| 170 | + ); | |
| 171 | + } | |
| 172 | + return out; | |
| 173 | + } | |
| 174 | +} | |
| 175 | + | |
| 176 | +export default function createConnector(meta: ConnectorMeta) { | |
| 177 | + return new ReverbConnector(meta); | |
| 178 | +} | |
added
connectors/api/reverb/meta.json
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +{ | |
| 2 | + "id": "reverb", | |
| 3 | + "displayName": "Reverb (musical instruments & hi-fi marketplace)", | |
| 4 | + "sourceId": "reverb", | |
| 5 | + "sourceName": "Reverb", | |
| 6 | + "sourceType": "marketplace", | |
| 7 | + "sourceUrl": "https://reverb.com", | |
| 8 | + "module": "api/reverb", | |
| 9 | + "enginePriority": ["api"], | |
| 10 | + "categories": ["musical_instruments", "audio_equipment"], | |
| 11 | + "regions": ["US", "EU", "GB", "CA", "AU", "JP"], | |
| 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": 720, | |
| 22 | + "priority": "medium", | |
| 23 | + "trustScore": 0.8, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://reverb.com/page/terms", | |
| 26 | + "accessNotes": "Read-only public listings API (api.reverb.com/api/listings, HAL JSON with Accept-Version 3.0) — no token is required for listing search; the price-guide endpoint is no longer public and sold data needs an authenticated app, so only live asking prices are collected. robots.txt allows /api/listings (only /api/my and per-listing upsell helpers are disallowed). Reverb returns prices converted to USD; the original listing currency is stored in metadata and the confidence is lowered when a conversion happened. Seeds are collectible-model queries (vintage Fender/Gibson/Martin, synths, pedals, hi-fi). 1.2 s between requests; one query page = 50 listings.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "perPage": 50, | |
| 31 | + "pagesPerQuery": 2, | |
| 32 | + "backfillPages": 6, | |
| 33 | + "queries": [ | |
| 34 | + "fender stratocaster 1960s", "fender telecaster 1950s", "gibson les paul standard 1959", "gibson les paul custom 1960s", "gibson sg 1960s", "gibson es-335 1960s", "gibson flying v", "fender jazzmaster 1960s", "fender precision bass 1960s", "fender jazz bass 1960s", "rickenbacker 4001", "rickenbacker 360", "gretsch 6120", "martin d-28 1960s", "martin d-45", "gibson j-45 1950s", "gibson hummingbird vintage", "prs private stock", "moog minimoog model d", "roland tr-808", "roland tb-303", "roland jupiter-8", "sequential prophet-5", "yamaha cs-80", "oberheim ob-x", "korg ms-20 vintage", "arp 2600", "hammond b3", "fender rhodes mark i", "wurlitzer 200a", "klon centaur", "dumble overdrive special", "marshall plexi 1968", "fender tweed bassman 1959", "vox ac30 1960s", "mesa boogie mark i", "neumann u47", "neumann u67", "ludwig black beauty vintage", "gibson mandolin f-5 loar", "mcintosh mc275", "marantz 2270", "technics sl-1200 mk2", "nakamichi dragon", "jbl l100", "klipschorn", "linn lp12", "tannoy monitor gold", "sansui 9090db", "revox b77" | |
| 35 | + ] | |
| 36 | + } | |
| 37 | +} | |
added
connectors/firecrawl/bh-used/index.test.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../../api/_lib/local-meta.js'; | |
| 5 | +import createConnector, { conditionFor, parseUsedMarkdown } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('bh-used', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('parses used-department markdown blocks', async () => { | |
| 13 | + const md = `### [Nikon D850 DSLR Camera](https://www.bhphotovideo.com/c/product/803456756-USE/nikon_1585_d850_dslr_camera.html) | | BH # 3456756MFR # 1585 | | Condition:8+Moderate wear | | Shutter Count: 138,000Warranty: B&H 90 Days | | New$1,996.95 | | $1,79995 | | Used Savings$197.00 | | Add to Cart | | In Stock |`; | |
| 14 | + const p = parseUsedMarkdown(md, 'u', 'Used-Digital-Cameras'); | |
| 15 | + expect(p.items[0]).toMatchObject({ name: 'Nikon D850 DSLR Camera', bhSku: '3456756', mfr: '1585', grade: '8+', shutterCount: 138000, usedPrice: 1799.95, newPrice: 1996.95, inStock: true }); | |
| 16 | + expect(conditionFor('9+')).toBe('excellent'); | |
| 17 | + expect(conditionFor('8')).toBe('good'); | |
| 18 | + const fx = loadFixture('bh-used', 'used-digital-cameras'); | |
| 19 | + const out = await connector.normalize(fx.raw); | |
| 20 | + expect(out.length).toBeGreaterThan(0); | |
| 21 | + for (const r of out) { | |
| 22 | + if (r.kind !== 'listing') throw new Error('expected listing'); | |
| 23 | + expect(r.attributes.categorySlug).toBe('cameras'); | |
| 24 | + expect(r.attributes.identifiers.bh_sku).toMatch(/^\d+$/); | |
| 25 | + expect(r.attributes.brand).toBeTruthy(); | |
| 26 | + if (r.price !== null) expect(r.currency).toBe('USD'); | |
| 27 | + } | |
| 28 | + }); | |
| 29 | +}); | |
added
connectors/firecrawl/bh-used/index.ts
+151 −0
@@ -0,0 +1,151 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { brandFromName, compactMoney, compactMoneyCents, fetchFirecrawl, stripBrand } from '../../api/_wlib/index.js'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * B&H Photo — Used Department. Category result pages are rendered through Firecrawl (plain HTTPS | |
| 8 | + * gets an Akamai challenge) and parsed from markdown: product name, B&H #, MFR #, B&H used grade | |
| 9 | + * (10 / 9+ / 9 / 8+ / 8 / 7), shutter count, used price (cents flattened), stock state. | |
| 10 | + */ | |
| 11 | +const BASE = 'https://www.bhphotovideo.com'; | |
| 12 | +const PARSER_VERSION = '1.0.0'; | |
| 13 | + | |
| 14 | +export const ItemSchema = z.object({ | |
| 15 | + name: z.string(), | |
| 16 | + url: z.string(), | |
| 17 | + bhSku: z.string().nullable(), | |
| 18 | + mfr: z.string().nullable(), | |
| 19 | + grade: z.string().nullable(), | |
| 20 | + gradeText: z.string().nullable(), | |
| 21 | + shutterCount: z.number().nullable(), | |
| 22 | + warranty: z.string().nullable(), | |
| 23 | + usedPrice: z.number().nullable(), | |
| 24 | + newPrice: z.number().nullable(), | |
| 25 | + inStock: z.boolean().nullable(), | |
| 26 | + image: z.string().nullable(), | |
| 27 | +}); | |
| 28 | +export type Item = z.infer<typeof ItemSchema>; | |
| 29 | +export const PagePayloadSchema = z.object({ kind: z.literal('used_category_page'), url: z.string(), category: z.string(), total: z.number().nullable(), items: z.array(ItemSchema) }); | |
| 30 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 31 | + | |
| 32 | +/** Split the markdown into product blocks starting at "### [Name](url)". */ | |
| 33 | +export function parseUsedMarkdown(md: string, url: string, category: string): PagePayload { | |
| 34 | + const items: Item[] = []; | |
| 35 | + const total = md.match(/(\d[\d,]*)\s*Items?\s*Found/i)?.[1]; | |
| 36 | + const parts = md.split(/\n(?=###\s*\[)/g); | |
| 37 | + for (const part of parts) { | |
| 38 | + const head = part.match(/^###\s*\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/); | |
| 39 | + if (!head) continue; | |
| 40 | + const name = head[1]!.replace(/\s+/g, ' ').trim(); | |
| 41 | + const body = part.slice(head[0].length); | |
| 42 | + const bhSku = body.match(/BH\s*#\s*(\d+)/i)?.[1] ?? null; | |
| 43 | + const mfr = body.match(/MFR\s*#\s*([A-Za-z0-9\-./]+)/)?.[1] ?? null; | |
| 44 | + const cond = body.match(/Condition\s*:?\s*(10|9\+|9|8\+|8|7\+|7|6)\s*([A-Za-z][^|\n]*)?/); | |
| 45 | + const shutter = body.match(/Shutter Count\s*:?\s*([\d,]+)/i)?.[1]; | |
| 46 | + const warranty = body.match(/Warranty\s*:?\s*([^|\n]+)/i)?.[1]?.trim() ?? null; | |
| 47 | + const newPrice = compactMoney(body.match(/New\s*\$([\d,]+\.\d{2})/)?.[1] ? `$${body.match(/New\s*\$([\d,]+\.\d{2})/)![1]}` : null); | |
| 48 | + // the used price is the bare "$1,79995" token (no decimal point) on its own line | |
| 49 | + const used = body.match(/(?:^|\|)\s*\$(\d[\d,]*)\s*(?:\||$)/m)?.[1] ?? null; | |
| 50 | + const usedPrice = used ? compactMoneyCents(`$${used}`) : null; | |
| 51 | + const inStock = /\bIn Stock\b/.test(body) ? true : /Out of Stock|Temporarily Out/i.test(body) ? false : null; | |
| 52 | + const image = part.match(/!\[[^\]]*\]\((https?:\/\/[^)\s]+\.(?:jpg|jpeg|png|webp)[^)\s]*)\)/i)?.[1] ?? null; | |
| 53 | + items.push({ name, url: head[2]!.split('?')[0]!, bhSku, mfr, grade: cond?.[1] ?? null, gradeText: cond?.[2]?.trim() ?? null, shutterCount: shutter ? Number(shutter.replace(/,/g, '')) : null, warranty, usedPrice, newPrice, inStock, image }); | |
| 54 | + } | |
| 55 | + return { kind: 'used_category_page', url, category, total: total ? Number(total.replace(/,/g, '')) : null, items }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** B&H used grades → 'electronics' condition scale. */ | |
| 59 | +export function conditionFor(grade: string | null): string | null { | |
| 60 | + switch (grade) { | |
| 61 | + case '10': | |
| 62 | + return 'mint'; | |
| 63 | + case '9+': | |
| 64 | + case '9': | |
| 65 | + return 'excellent'; | |
| 66 | + case '8+': | |
| 67 | + case '8': | |
| 68 | + return 'good'; | |
| 69 | + case '7+': | |
| 70 | + case '7': | |
| 71 | + case '6': | |
| 72 | + return 'fair'; | |
| 73 | + default: | |
| 74 | + return null; | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +export class BhUsedConnector extends BaseConnector { | |
| 79 | + readonly version = '1.0.0'; | |
| 80 | + readonly parserVersion = PARSER_VERSION; | |
| 81 | + protected override minIntervalMs = 3000; | |
| 82 | + | |
| 83 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 84 | + const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; | |
| 85 | + let count = 0; | |
| 86 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 87 | + for (let i = start; i < seeds.length; i++) { | |
| 88 | + const seed = seeds[i]!; | |
| 89 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 90 | + const url = seed.startsWith('http') ? seed : `${BASE}${seed}`; | |
| 91 | + const category = url.match(/\/c\/buy\/([^/]+)/)?.[1] ?? url; | |
| 92 | + await this.throttle(); | |
| 93 | + const res = await fetchFirecrawl(ctx, url, { timeoutMs: 90_000, parse: (r) => (r.markdown ? parseUsedMarkdown(r.markdown, url, category).items.length : 0) }); | |
| 94 | + const payload = res.success && res.markdown ? parseUsedMarkdown(res.markdown, url, category) : null; | |
| 95 | + if (!payload?.items.length) { | |
| 96 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no products'}`); | |
| 97 | + continue; | |
| 98 | + } | |
| 99 | + count++; | |
| 100 | + yield { url, externalId: `used:${category}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 101 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + | |
| 105 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 106 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 107 | + const out: NormalizedRecord[] = []; | |
| 108 | + for (const it of p.items) { | |
| 109 | + if (!it.bhSku) continue; | |
| 110 | + const brand = brandFromName(it.name); | |
| 111 | + const attributes = AssetAttributesSchema.parse({ | |
| 112 | + categorySlug: 'cameras', | |
| 113 | + brand, | |
| 114 | + model: stripBrand(it.name, brand), | |
| 115 | + name: it.name, | |
| 116 | + originalMsrp: it.newPrice, | |
| 117 | + originalMsrpCurrency: it.newPrice ? 'USD' : null, | |
| 118 | + identifiers: { bh_sku: it.bhSku, ...(it.mfr ? { mfr: it.mfr } : {}) }, | |
| 119 | + metadata: { bh_grade: it.grade, bh_grade_text: it.gradeText, shutter_count: it.shutterCount, warranty: it.warranty, category: p.category, new_price_usd: it.newPrice }, | |
| 120 | + }); | |
| 121 | + out.push( | |
| 122 | + NormalizedListingSchema.parse({ | |
| 123 | + kind: 'listing', | |
| 124 | + connectorId: this.meta.id, | |
| 125 | + sourceId: this.meta.sourceId, | |
| 126 | + sourceUrl: it.url, | |
| 127 | + externalId: it.bhSku, | |
| 128 | + rawTitle: it.name, | |
| 129 | + imageUrls: it.image ? [it.image] : [], | |
| 130 | + attributes, | |
| 131 | + condition: { condition: conditionFor(it.grade), conditionRaw: it.grade ? `B&H used grade ${it.grade}${it.gradeText ? ` – ${it.gradeText}` : ''}` : null, completeness: null }, | |
| 132 | + observedAt: raw.fetchedAt, | |
| 133 | + confidence: 0.8, | |
| 134 | + parserVersion: PARSER_VERSION, | |
| 135 | + listingType: 'fixed_price', | |
| 136 | + price: it.usedPrice, | |
| 137 | + currency: it.usedPrice ? 'USD' : null, | |
| 138 | + seller: 'B&H Photo (Used Department)', | |
| 139 | + location: 'US', | |
| 140 | + quantity: 1, | |
| 141 | + availability: it.inStock === false ? 'ended' : it.usedPrice ? 'available' : 'unknown', | |
| 142 | + }), | |
| 143 | + ); | |
| 144 | + } | |
| 145 | + return out; | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +export default function createConnector(meta: ConnectorMeta) { | |
| 150 | + return new BhUsedConnector(meta); | |
| 151 | +} | |
added
connectors/firecrawl/bh-used/meta.json
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +{ | |
| 2 | + "id": "bh-used", | |
| 3 | + "displayName": "B&H Photo — Used Department (cameras & lenses, USD)", | |
| 4 | + "sourceId": "bh-photo", | |
| 5 | + "sourceName": "B&H Photo Video", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.bhphotovideo.com", | |
| 8 | + "module": "firecrawl/bh-used", | |
| 9 | + "enginePriority": ["firecrawl"], | |
| 10 | + "categories": ["cameras"], | |
| 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": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.bhphotovideo.com/find/HelpCenter/TermsAndConditions.jsp", | |
| 26 | + "accessNotes": "Used-department category pages (bhphotovideo.com/c/buy/Used-…/ci/<id>) rendered by Firecrawl (1 credit per page; plain HTTPS returns an Akamai browser check). robots.txt allows these /c/buy/ paths but disallows ?pn= pagination and search, so only the first page (~24–30 items) of each seeded category is read; coverage grows by adding category seeds. Each row gives name, B&H #, MFR #, the B&H used grade (10, 9+, 9, 8+, 8, 7), shutter count, used price (superscript cents flattened, e.g. $1,79995 → 1,799.95) and stock state. Dealer asking prices only; the new price is stored as original MSRP.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + "/c/buy/Used-Digital-Cameras/ci/32820/N/4288586282", | |
| 32 | + "/c/buy/used-mirrorless-cameras/ci/21264/N/4040479538", | |
| 33 | + "/c/browse/used-cameras-used-photography/ci/6387/N/4294246666", | |
| 34 | + "/c/browse/used-lenses-lens-accessories/ci/21426/N/4036297805", | |
| 35 | + "/c/browse/leica/ci/24708/N/3933929672" | |
| 36 | + ] | |
| 37 | + } | |
| 38 | +} | |
added
connectors/firecrawl/e-rocks/index.test.ts
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../../api/_lib/local-meta.js'; | |
| 5 | +import createConnector, { parseErocksDate } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('e-rocks', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('parses BST end times and emits live mineral lots in EUR', async () => { | |
| 13 | + expect(parseErocksDate('07/09/2026 22:10 BST')).toBe('2026-09-07T21:10:00.000Z'); | |
| 14 | + const fx = loadFixture('e-rocks', 'auction-p1'); | |
| 15 | + const out = await connector.normalize(fx.raw); | |
| 16 | + expect(out.length).toBeGreaterThan(0); | |
| 17 | + for (const r of out) { | |
| 18 | + if (r.kind !== 'auction_lot') throw new Error('expected auction_lot'); | |
| 19 | + expect(r.attributes.categorySlug).toBe('minerals'); | |
| 20 | + expect(r.attributes.identifiers.erocks_item).toMatch(/^[A-Z0-9]+$/); | |
| 21 | + expect(r.auctionHouse).toBe('e-Rocks'); | |
| 22 | + expect(r.currency).toBe('EUR'); | |
| 23 | + expect(['live', 'ended']).toContain(r.status); | |
| 24 | + } | |
| 25 | + expect(out.every((r) => r.kind === 'auction_lot' && r.endsAt instanceof Date)).toBe(true); | |
| 26 | + expect(out.some((r) => r.kind === 'auction_lot' && r.location)).toBe(true); | |
| 27 | + }); | |
| 28 | +}); | |
added
connectors/firecrawl/e-rocks/index.ts
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedAuctionLotSchema, parsePrice, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { fetchFirecrawl } from '../../api/_wlib/index.js'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * e-Rocks — weekly online mineral auctions run by dealers. Auction pages (rendered by Firecrawl) | |
| 8 | + * list every lot with locality, size class, current bid in EUR (+ USD conversion) and bid count, | |
| 9 | + * plus the auction end time. Ended auction pages hide the final price (only a 'Sold' marker), so | |
| 10 | + * lots are recorded as auction lots (current bid while live), never as sales. | |
| 11 | + */ | |
| 12 | +const BASE = 'https://e-rocks.com'; | |
| 13 | +const PARSER_VERSION = '1.0.0'; | |
| 14 | + | |
| 15 | +export const LotSchema = z.object({ | |
| 16 | + id: z.string(), | |
| 17 | + url: z.string(), | |
| 18 | + name: z.string(), | |
| 19 | + locality: z.string().nullable(), | |
| 20 | + sizeClass: z.string().nullable(), | |
| 21 | + priceText: z.string().nullable(), | |
| 22 | + bids: z.number().nullable(), | |
| 23 | + seller: z.string().nullable(), | |
| 24 | + image: z.string().nullable(), | |
| 25 | + sold: z.boolean().default(false), | |
| 26 | +}); | |
| 27 | +export type Lot = z.infer<typeof LotSchema>; | |
| 28 | +export const PagePayloadSchema = z.object({ kind: z.literal('auction_page'), url: z.string(), auctionId: z.string(), auctionName: z.string(), startsAt: z.string().nullable(), endsAt: z.string().nullable(), lots: z.array(LotSchema) }); | |
| 29 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 30 | + | |
| 31 | +/** "07/09/2026 22:10 BST" → ISO (BST = UTC+1, GMT = UTC). */ | |
| 32 | +export function parseErocksDate(s: string | null | undefined): string | null { | |
| 33 | + const m = s?.match(/(\d{2})\/(\d{2})\/(\d{4})\s+(\d{2}):(\d{2})\s*(BST|GMT|UTC)?/); | |
| 34 | + if (!m) return null; | |
| 35 | + const offset = m[6] === 'BST' ? 1 : 0; | |
| 36 | + const d = new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4]) - offset, Number(m[5]))); | |
| 37 | + return Number.isNaN(d.getTime()) ? null : d.toISOString(); | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function parseAuctionMarkdown(md: string, url: string): PagePayload { | |
| 41 | + const auctionId = url.match(/\/items\/auction\/(\d+)/)?.[1] ?? url; | |
| 42 | + const auctionName = md.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? auctionId; | |
| 43 | + const startsAt = parseErocksDate(md.match(/Start:\s*([^|\n]+?)\s{2,}End:/)?.[1] ?? md.match(/Start:\s*([\d/]+\s+[\d:]+\s*\w*)/)?.[1]); | |
| 44 | + const endsAt = parseErocksDate(md.match(/End:\s*([\d/]+\s+[\d:]+\s*\w*)/)?.[1]); | |
| 45 | + const lots: Lot[] = []; | |
| 46 | + const seen = new Set<string>(); | |
| 47 | + // Each lot starts with an image link to /item/<id>/<slug>; following cells hold locality, size, price, bids, seller. | |
| 48 | + const re = /!\[([^\]]*)\]\(([^)\s]+)\)\]\((https?:\/\/e-rocks\.com\/item\/([a-z0-9]+)\/[^)\s]*)\)([\s\S]*?)(?=!\[[^\]]*\]\([^)\s]+\)\]\(https?:\/\/e-rocks\.com\/item\/|$)/g; | |
| 49 | + for (const m of md.matchAll(re)) { | |
| 50 | + const id = m[4]!.toUpperCase(); | |
| 51 | + if (seen.has(id)) continue; | |
| 52 | + seen.add(id); | |
| 53 | + const block = m[5]!; | |
| 54 | + // live pages render lots as table cells ("|"), ended pages as line breaks — accept both. | |
| 55 | + const cells = block | |
| 56 | + .split(/\n|\|/) | |
| 57 | + .map((c) => c.replace(/\\/g, '').trim()) | |
| 58 | + .filter((c) => c && !/^\[?(Bid|Watch)\]?/.test(c) && !/^(You are|You have|Proxy bid|Delayed)/i.test(c) && !/^€$/.test(c) && !/^\(reserve/.test(c) && !/^!\[/.test(c) && !/^- /.test(c)); | |
| 59 | + const name = (m[1] ?? '').trim() || cells.find((c) => /^\[[^\]]+\]\(/.test(c))?.replace(/^\[([^\]]+)\].*/, '$1') || id; | |
| 60 | + const plain = cells.filter((c) => !/^\[/.test(c)); | |
| 61 | + const priceCell = plain.find((c) => /^[€$£]\s?\d/.test(c)) ?? null; | |
| 62 | + const bidsCell = plain.find((c) => /\d+\s*bids?/.test(c)); | |
| 63 | + const sizeCell = plain.find((c) => /\(\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?\s*(?:cm|mm)\)|^(Thumbnail|Miniature|Small miniature|Small cabinet|Cabinet|Large cabinet|Museum)/i.test(c)) ?? null; | |
| 64 | + const locality = plain.find((c) => c !== name && /,/.test(c) && !/€|bids?|\(\d/.test(c)) ?? null; | |
| 65 | + const sold = plain.some((c) => /^Sold$/i.test(c)); | |
| 66 | + const seller = plain.filter((c) => /^[A-Z0-9 .&'-]{3,}$/.test(c) && c !== id && !/^(SOLD|BIDS?)$/.test(c)).pop() ?? null; | |
| 67 | + lots.push({ id, url: m[3]!.split('?')[0]!, name, locality, sizeClass: sizeCell, priceText: priceCell, bids: bidsCell ? Number(bidsCell.match(/(\d+)\s*bids?/)![1]) : null, seller, image: m[2] ?? null, sold }); | |
| 68 | + } | |
| 69 | + return { kind: 'auction_page', url, auctionId, auctionName, startsAt, endsAt, lots }; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export class ERocksConnector extends BaseConnector { | |
| 73 | + readonly version = '1.0.0'; | |
| 74 | + readonly parserVersion = PARSER_VERSION; | |
| 75 | + protected override minIntervalMs = 10_000; // robots.txt Crawl-delay: 10 | |
| 76 | + | |
| 77 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 78 | + const maxAuctions = Number(this.meta.config.auctionsPerRun ?? 4); | |
| 79 | + let count = 0; | |
| 80 | + await this.throttle(); | |
| 81 | + const index = await fetchFirecrawl(ctx, `${BASE}/auctions`, { timeoutMs: 90_000 }); | |
| 82 | + const links = [...new Set((index.markdown ?? '').match(/https:\/\/e-rocks\.com\/items\/auction\/\d+\/[a-z0-9-]+/g) ?? [])].sort((a, b) => Number(b.match(/auction\/(\d+)/)![1]) - Number(a.match(/auction\/(\d+)/)![1])); | |
| 83 | + if (!links.length) { | |
| 84 | + ctx.anomaly('page_fetch_failed', `auction index: ${index.error ?? index.httpStatus ?? 'no auction links'}`); | |
| 85 | + return; | |
| 86 | + } | |
| 87 | + for (const url of links.slice(0, maxAuctions)) { | |
| 88 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 89 | + await this.throttle(); | |
| 90 | + const res = await fetchFirecrawl(ctx, url, { timeoutMs: 90_000, parse: (r) => (r.markdown ? parseAuctionMarkdown(r.markdown, url).lots.length : 0) }); | |
| 91 | + const payload = res.success && res.markdown ? parseAuctionMarkdown(res.markdown, url) : null; | |
| 92 | + if (!payload?.lots.length) { | |
| 93 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no lots'}`); | |
| 94 | + continue; | |
| 95 | + } | |
| 96 | + count++; | |
| 97 | + yield { url, externalId: `auction:${payload.auctionId}`, kind: 'auction_lot', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 102 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 103 | + const out: NormalizedRecord[] = []; | |
| 104 | + const ends = p.endsAt ? new Date(p.endsAt) : null; | |
| 105 | + const status = ends && ends.getTime() < raw.fetchedAt.getTime() ? 'ended' : 'live'; | |
| 106 | + for (const l of p.lots) { | |
| 107 | + const price = parsePrice(l.priceText ?? null, 'EUR'); | |
| 108 | + const country = l.locality?.split(',').pop()?.trim() ?? null; | |
| 109 | + const attributes = AssetAttributesSchema.parse({ | |
| 110 | + categorySlug: 'minerals', | |
| 111 | + name: l.name, | |
| 112 | + country, | |
| 113 | + size: l.sizeClass, | |
| 114 | + identifiers: { erocks_item: l.id }, | |
| 115 | + metadata: { locality: l.locality, seller: l.seller, auction: p.auctionName, unique_specimen: true, sold_flag: status === 'ended' ? l.sold : false }, | |
| 116 | + }); | |
| 117 | + out.push( | |
| 118 | + NormalizedAuctionLotSchema.parse({ | |
| 119 | + kind: 'auction_lot', | |
| 120 | + connectorId: this.meta.id, | |
| 121 | + sourceId: this.meta.sourceId, | |
| 122 | + sourceUrl: l.url, | |
| 123 | + externalId: l.id, | |
| 124 | + rawTitle: l.locality ? `${l.name} — ${l.locality}` : l.name, | |
| 125 | + imageUrls: l.image ? [l.image] : [], | |
| 126 | + attributes, | |
| 127 | + observedAt: raw.fetchedAt, | |
| 128 | + confidence: 0.7, | |
| 129 | + parserVersion: PARSER_VERSION, | |
| 130 | + auctionHouse: 'e-Rocks', | |
| 131 | + auctionName: p.auctionName, | |
| 132 | + lotNumber: l.id, | |
| 133 | + startsAt: p.startsAt ? new Date(p.startsAt) : null, | |
| 134 | + endsAt: ends, | |
| 135 | + currentBid: price?.amount ?? null, | |
| 136 | + currency: price?.currency ?? 'EUR', | |
| 137 | + status, | |
| 138 | + location: country, | |
| 139 | + }), | |
| 140 | + ); | |
| 141 | + } | |
| 142 | + return out; | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +export default function createConnector(meta: ConnectorMeta) { | |
| 147 | + return new ERocksConnector(meta); | |
| 148 | +} | |
added
connectors/firecrawl/e-rocks/meta.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "id": "e-rocks", | |
| 3 | + "displayName": "e-Rocks (online mineral auctions, EUR)", | |
| 4 | + "sourceId": "e-rocks", | |
| 5 | + "sourceName": "e-Rocks", | |
| 6 | + "sourceType": "marketplace", | |
| 7 | + "sourceUrl": "https://e-rocks.com", | |
| 8 | + "module": "firecrawl/e-rocks", | |
| 9 | + "enginePriority": ["firecrawl"], | |
| 10 | + "categories": ["minerals"], | |
| 11 | + "regions": ["GB", "EU"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["EUR"], | |
| 14 | + "supportsListings": false, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": true, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.7, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://e-rocks.com/terms-and-conditions", | |
| 26 | + "accessNotes": "e-rocks.com returns 403 to plain HTTPS clients; Firecrawl renders the public auction index and auction pages (1 credit each). robots.txt sets Crawl-delay: 10 (honoured: 10 s between requests) and disallows /search/ and /itemssearch, which are not used. Each weekly dealer auction page lists its lots with locality, size class, current bid in EUR, bid count and seller; the header gives start/end times (BST/GMT). Past auctions are not archived on the site, so lots are stored as auction lots (current bid, status live/ended at fetch time) — final hammer prices are not asserted. At most 4 auctions per run.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { "auctionsPerRun": 4 } | |
| 30 | +} | |
added
connectors/firecrawl/mpb/index.test.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import metaJson from './meta.json' with { type: 'json' }; | |
| 3 | +import { loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; | |
| 4 | +import { localMeta } from '../../api/_lib/local-meta.js'; | |
| 5 | +import createConnector, { parseCategoryMarkdown } from './index.js'; | |
| 6 | + | |
| 7 | +const connector = createConnector(localMeta(metaJson)); | |
| 8 | + | |
| 9 | +describe('mpb', () => { | |
| 10 | + runFixtureSuite(connector, it, expect); | |
| 11 | + | |
| 12 | + it('parses model cards with availability and price ranges', async () => { | |
| 13 | + const md = `[\\\n\\\n**Hasselblad X2D II 100c** \\\n\\\n3 available, $7,289-$7,509](https://www.mpb.com/en-us/product/hasselblad-x2d-ii-100c)`; | |
| 14 | + const p = parseCategoryMarkdown(md, 'https://www.mpb.com/en-us/category/used-cameras/medium-format-cameras'); | |
| 15 | + expect(p.market).toBe('en-us'); | |
| 16 | + expect(p.models[0]).toMatchObject({ name: 'Hasselblad X2D II 100c', slug: 'hasselblad-x2d-ii-100c', available: 3, priceMin: 7289, priceMax: 7509, currency: 'USD' }); | |
| 17 | + const fx = loadFixture('mpb', 'medium-format'); | |
| 18 | + const out = await connector.normalize(fx.raw); | |
| 19 | + expect(out.length).toBeGreaterThan(0); | |
| 20 | + for (const r of out) { | |
| 21 | + if (r.kind !== 'listing') throw new Error('expected listing'); | |
| 22 | + expect(r.attributes.categorySlug).toBe('cameras'); | |
| 23 | + expect(r.attributes.identifiers.mpb_model).toBeTruthy(); | |
| 24 | + expect(r.price).toBeGreaterThan(0); | |
| 25 | + expect(r.currency).toBe('USD'); | |
| 26 | + expect(r.seller).toBe('MPB'); | |
| 27 | + } | |
| 28 | + }); | |
| 29 | +}); | |
added
connectors/firecrawl/mpb/index.ts
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | +import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; | |
| 3 | +import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; | |
| 4 | +import { brandFromName, compactMoney, fetchFirecrawl, stripBrand } from '../../api/_wlib/index.js'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * MPB — used camera & lens retailer (US/UK/EU storefronts). Category pages render client-side; | |
| 8 | + * Firecrawl returns markdown cards "**Name** · N available, $min-$max" linking to the model page. | |
| 9 | + * Prices are dealer asks for the cheapest unit of each model (range kept in metadata). | |
| 10 | + */ | |
| 11 | +const BASE = 'https://www.mpb.com'; | |
| 12 | +const PARSER_VERSION = '1.0.0'; | |
| 13 | + | |
| 14 | +export const ModelSchema = z.object({ | |
| 15 | + name: z.string(), | |
| 16 | + url: z.string(), | |
| 17 | + slug: z.string(), | |
| 18 | + available: z.number().nullable(), | |
| 19 | + priceMin: z.number().nullable(), | |
| 20 | + priceMax: z.number().nullable(), | |
| 21 | + currency: z.string(), | |
| 22 | + image: z.string().nullable(), | |
| 23 | +}); | |
| 24 | +export type Model = z.infer<typeof ModelSchema>; | |
| 25 | +export const PagePayloadSchema = z.object({ kind: z.literal('category_page'), url: z.string(), market: z.string(), category: z.string(), total: z.number().nullable(), models: z.array(ModelSchema) }); | |
| 26 | +export type PagePayload = z.infer<typeof PagePayloadSchema>; | |
| 27 | + | |
| 28 | +const CURRENCY: Record<string, string> = { 'en-us': 'USD', 'en-uk': 'GBP', 'en-eu': 'EUR', 'de-de': 'EUR', 'fr-fr': 'EUR', 'nl-nl': 'EUR', 'es-es': 'EUR', 'it-it': 'EUR' }; | |
| 29 | + | |
| 30 | +export function parseCategoryMarkdown(md: string, url: string): PagePayload { | |
| 31 | + const market = url.match(/mpb\.com\/([a-z]{2}-[a-z]{2})\//)?.[1] ?? 'en-us'; | |
| 32 | + const category = url.match(/\/category\/(.+?)(?:[?#]|$)/)?.[1] ?? url; | |
| 33 | + const currency = CURRENCY[market] ?? 'USD'; | |
| 34 | + const models: Model[] = []; | |
| 35 | + const seen = new Set<string>(); | |
| 36 | + const re = /\[!\[([^\]]*)\]\(([^)\s]+)\)[^\]]*?\*\*([^*]+)\*\*[^\]]*?(\d+\+?|10\+)\s*available,\s*([$£€][\d,]+)(?:\s*-\s*([$£€][\d,]+))?\]\((https?:\/\/[^)\s]+\/product\/([a-z0-9-]+)[^)\s]*)\)/g; | |
| 37 | + for (const m of md.matchAll(re)) { | |
| 38 | + const slug = m[8]!; | |
| 39 | + if (seen.has(slug)) continue; | |
| 40 | + seen.add(slug); | |
| 41 | + models.push({ name: m[3]!.replace(/\s+/g, ' ').trim(), url: m[7]!.split('?')[0]!, slug, available: Number(m[4]!.replace('+', '')) || null, priceMin: compactMoney(m[5]!), priceMax: m[6] ? compactMoney(m[6]) : compactMoney(m[5]!), currency, image: m[2] ?? null }); | |
| 42 | + } | |
| 43 | + const total = md.match(/Showing\s+\d+\s+of\s+(\d+)\s+results/i)?.[1]; | |
| 44 | + return { kind: 'category_page', url, market, category, total: total ? Number(total) : null, models }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export class MpbConnector extends BaseConnector { | |
| 48 | + readonly version = '1.0.0'; | |
| 49 | + readonly parserVersion = PARSER_VERSION; | |
| 50 | + protected override minIntervalMs = 3000; | |
| 51 | + | |
| 52 | + async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> { | |
| 53 | + const seeds = (ctx.options.seeds?.length ? ctx.options.seeds : (this.meta.config.seeds as string[] | undefined)) ?? []; | |
| 54 | + let count = 0; | |
| 55 | + const start = ctx.options.mode === 'incremental' ? Number(ctx.options.cursor?.seedIndex ?? 0) : 0; | |
| 56 | + for (let i = start; i < seeds.length; i++) { | |
| 57 | + const seed = seeds[i]!; | |
| 58 | + if (ctx.signal?.aborted || this.reached(ctx, count)) return; | |
| 59 | + const url = seed.startsWith('http') ? seed : `${BASE}${seed}`; | |
| 60 | + await this.throttle(); | |
| 61 | + const res = await fetchFirecrawl(ctx, url, { timeoutMs: 90_000, waitForMs: 2500, parse: (r) => (r.markdown ? parseCategoryMarkdown(r.markdown, url).models.length : 0) }); | |
| 62 | + const payload = res.success && res.markdown ? parseCategoryMarkdown(res.markdown, url) : null; | |
| 63 | + if (!payload?.models.length) { | |
| 64 | + ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus ?? 'no model cards'}`); | |
| 65 | + continue; | |
| 66 | + } | |
| 67 | + count++; | |
| 68 | + yield { url, externalId: `${payload.market}:${payload.category}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; | |
| 69 | + await ctx.setCursor({ seedIndex: i + 1 >= seeds.length ? 0 : i + 1, updatedAt: new Date().toISOString() }); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> { | |
| 74 | + const p = PagePayloadSchema.parse(raw.payload); | |
| 75 | + const out: NormalizedRecord[] = []; | |
| 76 | + for (const m of p.models) { | |
| 77 | + if (!m.priceMin) continue; | |
| 78 | + const brand = brandFromName(m.name); | |
| 79 | + const attributes = AssetAttributesSchema.parse({ | |
| 80 | + categorySlug: 'cameras', | |
| 81 | + brand, | |
| 82 | + model: stripBrand(m.name, brand), | |
| 83 | + name: m.name, | |
| 84 | + identifiers: { mpb_model: m.slug }, | |
| 85 | + metadata: { units_available: m.available, price_max: m.priceMax, market: p.market, mpb_category: p.category }, | |
| 86 | + }); | |
| 87 | + out.push( | |
| 88 | + NormalizedListingSchema.parse({ | |
| 89 | + kind: 'listing', | |
| 90 | + connectorId: this.meta.id, | |
| 91 | + sourceId: this.meta.sourceId, | |
| 92 | + sourceUrl: m.url, | |
| 93 | + externalId: `${p.market}:${m.slug}`, | |
| 94 | + rawTitle: m.name, | |
| 95 | + imageUrls: m.image ? [m.image] : [], | |
| 96 | + attributes, | |
| 97 | + condition: { condition: null, conditionRaw: 'MPB graded per unit (Like New / Excellent / Good / Well Used) — cheapest unit shown', completeness: null }, | |
| 98 | + observedAt: raw.fetchedAt, | |
| 99 | + confidence: 0.75, | |
| 100 | + parserVersion: PARSER_VERSION, | |
| 101 | + listingType: 'fixed_price', | |
| 102 | + price: m.priceMin, | |
| 103 | + currency: m.currency, | |
| 104 | + seller: 'MPB', | |
| 105 | + location: p.market.split('-')[1]?.toUpperCase() === 'UK' ? 'GB' : p.market.split('-')[1]?.toUpperCase() ?? null, | |
| 106 | + quantity: m.available, | |
| 107 | + availability: 'available', | |
| 108 | + }), | |
| 109 | + ); | |
| 110 | + } | |
| 111 | + return out; | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +export default function createConnector(meta: ConnectorMeta) { | |
| 116 | + return new MpbConnector(meta); | |
| 117 | +} | |
added
connectors/firecrawl/mpb/meta.json
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +{ | |
| 2 | + "id": "mpb", | |
| 3 | + "displayName": "MPB (used cameras & lenses, US/UK/EU)", | |
| 4 | + "sourceId": "mpb", | |
| 5 | + "sourceName": "MPB", | |
| 6 | + "sourceType": "dealer", | |
| 7 | + "sourceUrl": "https://www.mpb.com", | |
| 8 | + "module": "firecrawl/mpb", | |
| 9 | + "enginePriority": ["firecrawl"], | |
| 10 | + "categories": ["cameras"], | |
| 11 | + "regions": ["US", "GB", "EU"], | |
| 12 | + "languages": ["en"], | |
| 13 | + "currency": ["USD", "GBP", "EUR"], | |
| 14 | + "supportsListings": true, | |
| 15 | + "supportsSold": false, | |
| 16 | + "supportsAuctions": false, | |
| 17 | + "supportsImages": true, | |
| 18 | + "supportsCatalog": false, | |
| 19 | + "supportsPopulation": false, | |
| 20 | + "supportsLookup": false, | |
| 21 | + "refreshFrequencyMinutes": 1440, | |
| 22 | + "priority": "low", | |
| 23 | + "trustScore": 0.85, | |
| 24 | + "attributionRequired": true, | |
| 25 | + "termsUrl": "https://www.mpb.com/en-us/terms-and-conditions", | |
| 26 | + "accessNotes": "MPB blocks non-browser clients (403 'Security check', robots.txt itself returns 403) but category pages render for Firecrawl (1 credit per page, ~2.5 s wait). The connector reads the model cards of seeded category pages (name, units available, price range, model URL, image) for the US storefront (add en-uk / en-eu seeds for GBP/EUR). One listing per model at the cheapest available unit; per-unit cosmetic grades live on the model page and are not fetched. Dealer asks only; no transactions.", | |
| 27 | + "enabled": true, | |
| 28 | + "schemaVersion": "1.0", | |
| 29 | + "config": { | |
| 30 | + "seeds": [ | |
| 31 | + "/en-us/category/used-cameras/medium-format-cameras", | |
| 32 | + "/en-us/category/used-cameras/mirrorless-cameras/fujifilm-mirrorless-cameras", | |
| 33 | + "/en-us/category/used-cameras/mirrorless-cameras/sony-e-mirrorless-cameras", | |
| 34 | + "/en-us/category/used-cameras/dslr-cameras/canon-dslr-cameras", | |
| 35 | + "/en-us/category/used-cameras/premium-compact-cameras", | |
| 36 | + "/en-us/category/used-photo-and-video-lenses" | |
| 37 | + ] | |
| 38 | + } | |
| 39 | +} | |
added
data/fixtures/analog-shift/products-p1.json
+330 −0
@@ -0,0 +1,330 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://analogshift.com/products.json?page=1", | |
| 4 | + "externalId": "collection:all:1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:13:19.966Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "shopify_page", | |
| 10 | + "url": "https://analogshift.com/products.json?page=1", | |
| 11 | + "seed": "all", | |
| 12 | + "page": 1, | |
| 13 | + "products": [ | |
| 14 | + { | |
| 15 | + "id": 7741801693271, | |
| 16 | + "title": "Ikepod Duopod 'Staying Alive 005'", | |
| 17 | + "handle": "ikepod-duopod-staying-alive-005-as12731", | |
| 18 | + "body_html": "If some Ikepods are designed to make you look twice, the Duopod Staying Alive 005 takes the opposite approach. This is Ikepod at its most restrained: the familiar pebble-shaped case, stripped-back two-hand display, and an almost entirely monochromatic palette that lets Marc Newson’s original design language do most of the talking. The dial is particularly good. Finished in silvery grey with a circular brushed effect, it uses simple applied baton markers and a crisp railroad-style minute track around its perimeter. The signature Ikepod hands provide just enough contrast, while the absence of a running seconds hand makes the whole thing feel unusually calm. It’s minimalism without becoming anonymous; that bulbous, lugless profile ensures that. Like the other modern Duopods, the brushed stainless-steel case measures 42mm, yet wears considerably smaller thanks to the complete absence of traditional lugs. The integrated black silicone strap disappears neatly beneath the case, emphasizing its smooth UFO-like form, while a curved sapphire crystal continues the rounded architecture. Inside, a Japanese Miyota quartz movement keeps things deliberately simple and dependable. This example come", | |
| 19 | + "published_at": "2026-09-03T18:07:44-04:00", | |
| 20 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 21 | + "vendor": "Ikepod", | |
| 22 | + "product_type": "Watch", | |
| 23 | + "tags": [ | |
| 24 | + "Pre-owned" | |
| 25 | + ], | |
| 26 | + "variants": [ | |
| 27 | + { | |
| 28 | + "id": 44181192540247, | |
| 29 | + "title": "Default Title", | |
| 30 | + "price": "850.00", | |
| 31 | + "compare_at_price": null, | |
| 32 | + "available": true, | |
| 33 | + "sku": "40993491", | |
| 34 | + "option1": "Default Title", | |
| 35 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 36 | + } | |
| 37 | + ], | |
| 38 | + "images": [ | |
| 39 | + { | |
| 40 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12731_40993491_IKEPOD_DUOPODSTAYINGALIVE_D005-SI-LB--6.jpg?v=1787917170" | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12731_40993491_IKEPOD_DUOPODSTAYINGALIVE_D005-SI-LB--1.jpg?v=1787917170" | |
| 44 | + } | |
| 45 | + ], | |
| 46 | + "options": [ | |
| 47 | + { | |
| 48 | + "name": "Title" | |
| 49 | + } | |
| 50 | + ] | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "id": 7741825220695, | |
| 54 | + "title": "Ikepod Seapod 'Zale' Automatic", | |
| 55 | + "handle": "ikepod-seapod-zale-automatic-as12734", | |
| 56 | + "body_html": "What happens when Ikepod makes a proper dive watch? Thankfully, it still looks like an Ikepod. The Seapod Zale takes the brand’s unmistakable UFO silhouette and gives it genuine underwater credentials, resulting in a diver that looks unlike almost anything else in the category. The 46mm brushed stainless-steel case retains Ikepod’s signature lugless architecture, with the strap disappearing beneath the case rather than attaching to conventional lugs. As a result, it wears considerably smaller than its dimensions suggest, closer to a conventional 42mm watch. Most clever is the rotating dive bezel, integrated so smoothly into the case that the familiar 10, 20, 30, 40 and 50 markings almost appear to be engraved directly into the flying-saucer profile. The dark dial brings some welcome Ikepod weirdness. Circular orange hour markers surrounded by luminous white rings give it a wonderfully graphic, almost Pop quality, while the broad orange-and-white hands and oversized minute track make the watch highly legible. That same vivid orange continues onto the integrated silicone strap, turning a functional dive watch into something considerably more fun. Inside is the automatic Miyota 9039, ", | |
| 57 | + "published_at": "2026-09-03T18:02:38-04:00", | |
| 58 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 59 | + "vendor": "Ikepod", | |
| 60 | + "product_type": "Watch", | |
| 61 | + "tags": [ | |
| 62 | + "Pre-owned" | |
| 63 | + ], | |
| 64 | + "variants": [ | |
| 65 | + { | |
| 66 | + "id": 44181288353879, | |
| 67 | + "title": "Default Title", | |
| 68 | + "price": "850.00", | |
| 69 | + "compare_at_price": null, | |
| 70 | + "available": true, | |
| 71 | + "sku": "40993498", | |
| 72 | + "option1": "Default Title", | |
| 73 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 74 | + } | |
| 75 | + ], | |
| 76 | + "images": [ | |
| 77 | + { | |
| 78 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12734_40993498_IKEPOD_SEAPOD_S001-SI-LO--6.jpg?v=1787917995" | |
| 79 | + }, | |
| 80 | + { | |
| 81 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12734_40993498_IKEPOD_SEAPOD_S001-SI-LO--1.jpg?v=1787917995" | |
| 82 | + } | |
| 83 | + ], | |
| 84 | + "options": [ | |
| 85 | + { | |
| 86 | + "name": "Title" | |
| 87 | + } | |
| 88 | + ] | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "id": 7741799891031, | |
| 92 | + "title": "Ikepod Duopod Dots 004", | |
| 93 | + "handle": "ikepod-duopod-dots-004-as12737", | |
| 94 | + "body_html": "Few contemporary watches are as immediately recognizable as an Ikepod. The smooth, lugless case originally conceived by Marc Newson in the 1990s helped turn the watch into an object of industrial design, and the modern Duopod carries that radical simplicity forward. It is round, slightly strange, and unmistakably Ikepod. The Duopod Dots 004 is one of the more graphic interpretations of the formula. Its convex black dial is covered in concentric fields of raised dots that grow larger toward the center, creating an almost three-dimensional optical effect. Tiny orange hour markers punctuate the otherwise monochromatic surface, while white minute hashes circle the perimeter. Against all that texture, the two black-and-white hands are almost comically simple. There isn’t even a running seconds hand to disturb the composition. The brushed 316L stainless-steel case measures 42mm, but without conventional lugs it wears considerably smaller, closer to a traditional 39mm watch. A curved sapphire crystal exaggerates the rounded, pebble-like profile, while the integrated black silicone strap continues directly from the case. Inside is a reliable Japanese Miyota quartz movement, an appropriatel", | |
| 95 | + "published_at": "2026-09-03T17:56:30-04:00", | |
| 96 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 97 | + "vendor": "Ikepod", | |
| 98 | + "product_type": "Watch", | |
| 99 | + "tags": [ | |
| 100 | + "Pre-owned" | |
| 101 | + ], | |
| 102 | + "variants": [ | |
| 103 | + { | |
| 104 | + "id": 44181188247639, | |
| 105 | + "title": "Default Title", | |
| 106 | + "price": "850.00", | |
| 107 | + "compare_at_price": null, | |
| 108 | + "available": true, | |
| 109 | + "sku": "40993497", | |
| 110 | + "option1": "Default Title", | |
| 111 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 112 | + } | |
| 113 | + ], | |
| 114 | + "images": [ | |
| 115 | + { | |
| 116 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12737_40993497_IKEPOD_DUOPOD_D004-SI-LB--6.jpg?v=1787923675" | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12737_40993497_IKEPOD_DUOPOD_D004-SI-LB--1.jpg?v=1787923675" | |
| 120 | + } | |
| 121 | + ], | |
| 122 | + "options": [ | |
| 123 | + { | |
| 124 | + "name": "Title" | |
| 125 | + } | |
| 126 | + ] | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + "id": 7743805522007, | |
| 130 | + "title": "Cartier Roadster", | |
| 131 | + "handle": "cartier-roadster-as12740", | |
| 132 | + "body_html": "Some watches arrive ahead of their time. Introduced at the dawn of the 21st century, the Cartier Roadster broke from the maison's traditional playbook, pairing Cartier's unmistakable elegance with the muscular stance of a modern sports watch. Inspired by the streamlined curves of 1950s European sports cars, its tonneau-shaped case, flowing bracelet, and distinctive magnified date window made it one of the brand's boldest contemporary designs. Long appreciated by collectors, the Roadster enjoyed a remarkable resurgence in 2026, when Cartier reintroduced the collection to widespread acclaim. The revival underscored what enthusiasts had known all along: the Roadster's sculptural design had aged exceptionally well, feeling every bit as fresh today as when it first debuted. This Ref. W62031Y4-2510 features the collection's versatile 38mm x 43mm two tone stainless steel and 18K gold case, topped by a scratch-resistant sapphire crystal with the Roadster's signature integrated Cyclops magnifier over the date display. The silver sunray dial showcases bold, radial black Roman numerals surrounding an inner railroad minute track, while luminescent sword-shaped hands provide excellent legibilit", | |
| 133 | + "published_at": "2026-09-02T11:55:45-04:00", | |
| 134 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 135 | + "vendor": "Cartier", | |
| 136 | + "product_type": "Watch", | |
| 137 | + "tags": [ | |
| 138 | + "ladies", | |
| 139 | + "neo-vintage" | |
| 140 | + ], | |
| 141 | + "variants": [ | |
| 142 | + { | |
| 143 | + "id": 44190502387799, | |
| 144 | + "title": "Default Title", | |
| 145 | + "price": "8950.00", | |
| 146 | + "compare_at_price": null, | |
| 147 | + "available": true, | |
| 148 | + "sku": "40950628", | |
| 149 | + "option1": "Default Title", | |
| 150 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 151 | + } | |
| 152 | + ], | |
| 153 | + "images": [ | |
| 154 | + { | |
| 155 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12740_40950628_CARTIER_ROADSTER_W62031Y4-2510-6.jpg?v=1787657383" | |
| 156 | + }, | |
| 157 | + { | |
| 158 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12740_40950628_CARTIER_ROADSTER_W62031Y4-2510-1.jpg?v=1787657383" | |
| 159 | + } | |
| 160 | + ], | |
| 161 | + "options": [ | |
| 162 | + { | |
| 163 | + "name": "Title" | |
| 164 | + } | |
| 165 | + ] | |
| 166 | + }, | |
| 167 | + { | |
| 168 | + "id": 7743810502743, | |
| 169 | + "title": "Patek Philippe Calatrava", | |
| 170 | + "handle": "patek-philippe-calatrava-as12743", | |
| 171 | + "body_html": "There are dress watches, and then there are watches that define what a dress watch is supposed to be. Since its introduction in 1932, the Patek Philippe Calatrava has served as the blueprint. Born during the economic uncertainty of the Great Depression, it represented a new direction for the manufacture—one that emphasized purity of design rather than mechanical complexity. Drawing inspiration from Bauhaus principles, the Calatrava distilled watchmaking to its essentials: proportion, legibility, and timeless elegance. Nearly a century later, its influence remains impossible to overstate. This particular Reference 6119R-001 is a contemporary expression of that philosophy, proving that the original formula still feels remarkably fresh. Housed in a 39mm rose gold case, it strikes a perfect balance between traditional dress-watch restraint and modern wrist presence. Framing the dial is the iconic Clous de Paris bezel, a Patek Philippe signature whose intricate hobnail pattern adds depth and texture without disturbing the watch’s overall simplicity. The silver-toned dial features a finely grained finish that catches the light beautifully, serving as the backdrop for applied rose gold ma", | |
| 172 | + "published_at": "2026-09-02T11:52:55-04:00", | |
| 173 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 174 | + "vendor": "Patek Philippe", | |
| 175 | + "product_type": "Watch", | |
| 176 | + "tags": [ | |
| 177 | + "Vintage" | |
| 178 | + ], | |
| 179 | + "variants": [ | |
| 180 | + { | |
| 181 | + "id": 44190513463383, | |
| 182 | + "title": "Default Title", | |
| 183 | + "price": "42500.00", | |
| 184 | + "compare_at_price": null, | |
| 185 | + "available": true, | |
| 186 | + "sku": "40931130", | |
| 187 | + "option1": "Default Title", | |
| 188 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 189 | + } | |
| 190 | + ], | |
| 191 | + "images": [ | |
| 192 | + { | |
| 193 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12743_40931130_PATEKPHILIPPE_CALATRAVARGIVORY_6119R-001-6.jpg?v=1787858114" | |
| 194 | + }, | |
| 195 | + { | |
| 196 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12743_40931130_PATEKPHILIPPE_CALATRAVARGIVORY_6119R-001-1.jpg?v=1787858113" | |
| 197 | + } | |
| 198 | + ], | |
| 199 | + "options": [ | |
| 200 | + { | |
| 201 | + "name": "Title" | |
| 202 | + } | |
| 203 | + ] | |
| 204 | + }, | |
| 205 | + { | |
| 206 | + "id": 7744327188567, | |
| 207 | + "title": "Patek Philippe Annual Calendar", | |
| 208 | + "handle": "patek-philippe-annual-calendar-as12751", | |
| 209 | + "body_html": "Few complications have had as much influence on modern watchmaking as the Annual Calendar. Introduced by Patek Philippe in 1996, the complication bridged the gap between simple calendar watches and the lofty perpetual calendar, offering remarkable practicality while requiring adjustment just once per year, at the end of February. It was a genuine innovation—one so significant that Patek patented the mechanism and created an entirely new category of complication. The Ref. 5396 traces its lineage directly to that groundbreaking first-generation Ref. 5035, refining the concept into one of the most elegant and enduring Annual Calendars in the manufacture's catalog. Its balanced display, classical proportions, and exceptional legibility have made it a favorite among collectors who appreciate complications designed for everyday use. This Ref. 5396G-011 is housed in a beautifully proportioned 38.5mm white gold case featuring a sapphire crystal, signed crown, and polished bezel. The silver opaline dial is a study in understated sophistication, fitted with applied white gold hour markers, dauphine hands, twin apertures for the day and month, a pointer date display, moonphase indication, and", | |
| 210 | + "published_at": "2026-09-02T11:50:06-04:00", | |
| 211 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 212 | + "vendor": "Patek Philippe", | |
| 213 | + "product_type": "Watch", | |
| 214 | + "tags": [ | |
| 215 | + "patek philippe complications", | |
| 216 | + "Pre-owned" | |
| 217 | + ], | |
| 218 | + "variants": [ | |
| 219 | + { | |
| 220 | + "id": 44194279424087, | |
| 221 | + "title": "Default Title", | |
| 222 | + "price": "44500.00", | |
| 223 | + "compare_at_price": null, | |
| 224 | + "available": true, | |
| 225 | + "sku": "40931139", | |
| 226 | + "option1": "Default Title", | |
| 227 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 228 | + } | |
| 229 | + ], | |
| 230 | + "images": [ | |
| 231 | + { | |
| 232 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12751_40931139_PATEKPHILIPPE_ANNUALCALENDARWGSECTORDIAL_5396G-001-6.jpg?v=1787857240" | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12751_40931139_PATEKPHILIPPE_ANNUALCALENDARWGSECTORDIAL_5396G-001-1.jpg?v=1787857240" | |
| 236 | + } | |
| 237 | + ], | |
| 238 | + "options": [ | |
| 239 | + { | |
| 240 | + "name": "Title" | |
| 241 | + } | |
| 242 | + ] | |
| 243 | + }, | |
| 244 | + { | |
| 245 | + "id": 7744163577943, | |
| 246 | + "title": "Cartier Tank Louis Cartier", | |
| 247 | + "handle": "cartier-tank-louis-cartier-as12741", | |
| 248 | + "body_html": "There are certain designs that just resonate throughout the years and remain evergreen despite changing trends and tastes. The Cartier Tank, in all its one hundred-plus years of production, is one such design. Whether in gold (yellow, pink, or white), stainless steel, or vermeil; whether Française, Chinoise, Americaine, or Normale; the Tank by Cartier is one watch that truly deserves to be called ‘iconic.’ Cartier introduced the Tank in 1917, with a run of six pieces — given, or so the legend goes, to American General Joseph Pershing and his staff. The design of the Tank was supposedly inspired by the Renault FT-17 tanks Cartier glimpsed on the battlefield of the Great War. (Cartier took the look of the Renault tank's treads and applied it to the lugs, which were integrated via brancards into the case itself.) That first run of six pieces increased to 33 by 1920, and by the end of the 20th century, that number stood well in the thousands. In sheer volume alone, the Tank — in all its various models — is Cartier's largest line of watches. The Tank Louis was introduced in 1922, following close on the heels of the Asian-influenced Tank Chinoise. With the Tank Louis, Cartier went back t", | |
| 249 | + "published_at": "2026-09-02T11:47:42-04:00", | |
| 250 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 251 | + "vendor": "Cartier", | |
| 252 | + "product_type": "Watch", | |
| 253 | + "tags": [ | |
| 254 | + "ladies", | |
| 255 | + "Pre-owned" | |
| 256 | + ], | |
| 257 | + "variants": [ | |
| 258 | + { | |
| 259 | + "id": 44193457143895, | |
| 260 | + "title": "Default Title", | |
| 261 | + "price": "15500.00", | |
| 262 | + "compare_at_price": null, | |
| 263 | + "available": true, | |
| 264 | + "sku": "40950629", | |
| 265 | + "option1": "Default Title", | |
| 266 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 267 | + } | |
| 268 | + ], | |
| 269 | + "images": [ | |
| 270 | + { | |
| 271 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12741_40950629_CARTIER_TANKLOUIS_WGTA0010-3814-6.jpg?v=1788363913" | |
| 272 | + }, | |
| 273 | + { | |
| 274 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12741_40950629_CARTIER_TANKLOUIS_WGTA0010-3814-1.jpg?v=1788363913" | |
| 275 | + } | |
| 276 | + ], | |
| 277 | + "options": [ | |
| 278 | + { | |
| 279 | + "name": "Title" | |
| 280 | + } | |
| 281 | + ] | |
| 282 | + }, | |
| 283 | + { | |
| 284 | + "id": 7746143551575, | |
| 285 | + "title": "Patek Philippe Water Drop", | |
| 286 | + "handle": "patek-philippe-water-drop-as12668", | |
| 287 | + "body_html": "The 'Holy Trinity' of watch brands: Audemars Piguet, Patek Philippe, and Vacheron Constantin. Perhaps the most celebrated of the three, Patek Philippe has an unrivaled reputation for crafting some of the most complicated — and some of the simplest, yet most beautiful — timekeepers the market has seen. Sports watches came later to Patek, as pressure from the new 'luxury sports watch' category introduced in the early 1970s forced the storied brand to evolve — but historically, classically-styled dress pieces is where the maison cut its teeth. This particular timepiece is a classic representation of the jewelry/ watch intersections of midcentury that helped build Patek's reputation. Measuring 16mm in yellow gold, it boasts a smooth bezel, with a brilliantly supple multi link waterdrop bracelet, an unsigned winding crown, and a petite black dial with diamond shaped gold indexes. Powered by a hand-wound movement, this watch is equal parts watchmaking and fine jewelry. Few brands produce watches of this kind in the modern market, and it is pieces like this one that are the types of watches that made Analog:Shift the place it is today. So much personality, packaged with the class of Patek", | |
| 288 | + "published_at": "2026-09-02T11:43:36-04:00", | |
| 289 | + "updated_at": "2026-09-07T03:13:19-04:00", | |
| 290 | + "vendor": "Patek Philippe", | |
| 291 | + "product_type": "Watch", | |
| 292 | + "tags": [ | |
| 293 | + "ladies", | |
| 294 | + "Vintage" | |
| 295 | + ], | |
| 296 | + "variants": [ | |
| 297 | + { | |
| 298 | + "id": 44199395262551, | |
| 299 | + "title": "Default Title", | |
| 300 | + "price": "14500.00", | |
| 301 | + "compare_at_price": null, | |
| 302 | + "available": true, | |
| 303 | + "sku": "40931105", | |
| 304 | + "option1": "Default Title", | |
| 305 | + "updated_at": "2026-09-07T03:13:19-04:00" | |
| 306 | + } | |
| 307 | + ], | |
| 308 | + "images": [ | |
| 309 | + { | |
| 310 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12668_40931105_PATEKPHILIPPE_VINTAGE18KWATERDROP-5.jpg?v=1788363700" | |
| 311 | + }, | |
| 312 | + { | |
| 313 | + "src": "https://cdn.shopify.com/s/files/1/0809/1255/files/AS12668_40931105_PATEKPHILIPPE_VINTAGE18KWATERDROP-1.jpg?v=1788363700" | |
| 314 | + } | |
| 315 | + ], | |
| 316 | + "options": [ | |
| 317 | + { | |
| 318 | + "name": "Title" | |
| 319 | + } | |
| 320 | + ] | |
| 321 | + } | |
| 322 | + ] | |
| 323 | + } | |
| 324 | + }, | |
| 325 | + "expect": { | |
| 326 | + "minCount": 1 | |
| 327 | + }, | |
| 328 | + "note": "Captured live from analogshift.com on 2026-09-07 (trimmed to 8 items)", | |
| 329 | + "capturedAt": "2026-09-07T07:13:19.993Z" | |
| 330 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/bh-used/used-digital-cameras.json
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.bhphotovideo.com/c/buy/Used-Digital-Cameras/ci/32820/N/4288586282", | |
| 4 | + "externalId": "used:Used-Digital-Cameras", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T07:13:26.361Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "used_category_page", | |
| 10 | + "url": "https://www.bhphotovideo.com/c/buy/Used-Digital-Cameras/ci/32820/N/4288586282", | |
| 11 | + "category": "Used-Digital-Cameras", | |
| 12 | + "total": 2631, | |
| 13 | + "items": [ | |
| 14 | + { | |
| 15 | + "name": "Canon EOS Rebel T7 DSLR Camera with 18-55mm Lens", | |
| 16 | + "url": "https://www.bhphotovideo.com/c/product/803532561-USE/canon_2727c002_eos_rebel_t7_dslr.html", | |
| 17 | + "bhSku": "3532561", | |
| 18 | + "mfr": "2727C002", | |
| 19 | + "grade": "9", | |
| 20 | + "gradeText": "Minor surface marks", | |
| 21 | + "shutterCount": 4142, | |
| 22 | + "warranty": "B&H 90 Days", | |
| 23 | + "usedPrice": 384.95, | |
| 24 | + "newPrice": 499, | |
| 25 | + "inStock": true, | |
| 26 | + "image": "https://www.bhphotovideo.com/cdn-cgi/image/fit=scale-down,width=345/https://static.bhphoto.com/images/images345x345/1550657171_1461734.jpg" | |
| 27 | + }, | |
| 28 | + { | |
| 29 | + "name": "Canon EOS Rebel T7 DSLR Camera with 18-55mm Lens", | |
| 30 | + "url": "https://www.bhphotovideo.com/c/product/803495896-USE/canon_2727c002_eos_rebel_t7_dslr.html", | |
| 31 | + "bhSku": "3495896", | |
| 32 | + "mfr": "2727C002", | |
| 33 | + "grade": "8+", | |
| 34 | + "gradeText": "Moderate wear", | |
| 35 | + "shutterCount": 500, | |
| 36 | + "warranty": "B&H 90 Days", | |
| 37 | + "usedPrice": 378.95, | |
| 38 | + "newPrice": 499, | |
| 39 | + "inStock": true, | |
| 40 | + "image": null | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + "name": "Canon EOS Rebel T7 DSLR with 18-55mm and 75-300mm Lenses", | |
| 44 | + "url": "https://www.bhphotovideo.com/c/product/803523959-USE/canon_2727c021_eos_rebel_t7_dslr.html", | |
| 45 | + "bhSku": "3523959", | |
| 46 | + "mfr": "2727C021", | |
| 47 | + "grade": "9", | |
| 48 | + "gradeText": "Minor surface marks", | |
| 49 | + "shutterCount": 1193, | |
| 50 | + "warranty": "B&H 90 Days", | |
| 51 | + "usedPrice": null, | |
| 52 | + "newPrice": null, | |
| 53 | + "inStock": false, | |
| 54 | + "image": null | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + "name": "Nikon D850 DSLR Camera", | |
| 58 | + "url": "https://www.bhphotovideo.com/c/product/803456756-USE/nikon_1585_d850_dslr_camera.html", | |
| 59 | + "bhSku": "3456756", | |
| 60 | + "mfr": "1585", | |
| 61 | + "grade": "8+", | |
| 62 | + "gradeText": "Moderate wear", | |
| 63 | + "shutterCount": 138000, | |
| 64 | + "warranty": "B&H 90 Days", | |
| 65 | + "usedPrice": 1799.95, | |
| 66 | + "newPrice": 1996.95, | |
| 67 | + "inStock": true, | |
| 68 | + "image": null | |
| 69 | + }, | |
| 70 | + { | |
| 71 | + "name": "Nikon D850 DSLR Camera", | |
| 72 | + "url": "https://www.bhphotovideo.com/c/product/803524675-USE/nikon_1585_d850_dslr_camera.html", | |
| 73 | + "bhSku": "3524675", | |
| 74 | + "mfr": "1585", | |
| 75 | + "grade": "9", | |
| 76 | + "gradeText": "Minor surface marks", | |
| 77 | + "shutterCount": 8318, | |
| 78 | + "warranty": "B&H 90 Days", | |
| 79 | + "usedPrice": 1919.95, | |
| 80 | + "newPrice": null, | |
| 81 | + "inStock": true, | |
| 82 | + "image": null | |
| 83 | + }, | |
| 84 | + { | |
| 85 | + "name": "Canon EOS 5D Mark IV DSLR Camera (Body Only)", | |
| 86 | + "url": "https://www.bhphotovideo.com/c/product/803510284-USE/canon_1483c002_eos_5d_mark_iv.html", | |
| 87 | + "bhSku": "3510284", | |
| 88 | + "mfr": "1483C002", | |
| 89 | + "grade": "8+", | |
| 90 | + "gradeText": "Moderate wear", | |
| 91 | + "shutterCount": 18975, | |
| 92 | + "warranty": "B&H 90 Days", | |
| 93 | + "usedPrice": 872.95, | |
| 94 | + "newPrice": 1799, | |
| 95 | + "inStock": true, | |
| 96 | + "image": null | |
| 97 | + }, | |
| 98 | + { | |
| 99 | + "name": "Nikon D7500 DSLR Camera (Body Only)", | |
| 100 | + "url": "https://www.bhphotovideo.com/c/product/803516162-USE/nikon_1581_d7500_dslr_camera_body.html", | |
| 101 | + "bhSku": "3516162", | |
| 102 | + "mfr": "1581", | |
| 103 | + "grade": "8+", | |
| 104 | + "gradeText": "Moderate wear", | |
| 105 | + "shutterCount": 86800, | |
| 106 | + "warranty": "B&H 90 Days", | |
| 107 | + "usedPrice": 836.95, | |
| 108 | + "newPrice": null, | |
| 109 | + "inStock": true, | |
| 110 | + "image": null | |
| 111 | + }, | |
| 112 | + { | |
| 113 | + "name": "Nikon D7500 DSLR Camera (Body Only)", | |
| 114 | + "url": "https://www.bhphotovideo.com/c/product/803512749-USE/nikon_1581_d7500_dslr_camera_body.html", | |
| 115 | + "bhSku": "3512749", | |
| 116 | + "mfr": "1581", | |
| 117 | + "grade": "9", | |
| 118 | + "gradeText": "Minor surface marks", | |
| 119 | + "shutterCount": 27500, | |
| 120 | + "warranty": "B&H 90 Days", | |
| 121 | + "usedPrice": 856.95, | |
| 122 | + "newPrice": null, | |
| 123 | + "inStock": true, | |
| 124 | + "image": null | |
| 125 | + } | |
| 126 | + ] | |
| 127 | + } | |
| 128 | + }, | |
| 129 | + "expect": { | |
| 130 | + "minCount": 1 | |
| 131 | + }, | |
| 132 | + "note": "Captured live from www.bhphotovideo.com on 2026-09-07 (trimmed to 8 items)", | |
| 133 | + "capturedAt": "2026-09-07T07:13:26.370Z" | |
| 134 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/e-rocks/auction-p1.json
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://e-rocks.com/items/auction/2529227/minerals-jir-06-sep-12-sep-2026", | |
| 4 | + "externalId": "auction:2529227", | |
| 5 | + "kind": "auction_lot", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T07:17:22.578Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "auction_page", | |
| 10 | + "url": "https://e-rocks.com/items/auction/2529227/minerals-jir-06-sep-12-sep-2026", | |
| 11 | + "auctionId": "2529227", | |
| 12 | + "auctionName": "Minerals JIR: 06 Sep - 12 Sep 2026", | |
| 13 | + "startsAt": "2026-09-06T17:30:00.000Z", | |
| 14 | + "endsAt": "2026-09-12T22:50:00.000Z", | |
| 15 | + "lots": [ | |
| 16 | + { | |
| 17 | + "id": "JIR1207936", | |
| 18 | + "url": "https://e-rocks.com/item/jir1207936/aluminocopiapite", | |
| 19 | + "name": "Aluminocopiapite", | |
| 20 | + "locality": "Mina Casualidad, Baños de Alhamilla, Almería, Andalusia, Spain", | |
| 21 | + "sizeClass": "Thumbnail (1-3cm)", | |
| 22 | + "priceText": "€10 ($11.61)", | |
| 23 | + "bids": 0, | |
| 24 | + "seller": null, | |
| 25 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-08-31/2026-08-31-20.54.15%20ZS%20PMax_n.jpg?itok=o6pLSSVD", | |
| 26 | + "sold": true | |
| 27 | + }, | |
| 28 | + { | |
| 29 | + "id": "JIR1208590", | |
| 30 | + "url": "https://e-rocks.com/item/jir1208590/anapaite", | |
| 31 | + "name": "Anapaite", | |
| 32 | + "locality": "Bellver de Cerdanya, Cerdanya, Lleida, Catalonia, Spain", | |
| 33 | + "sizeClass": "Cabinet (10-18 cm)", | |
| 34 | + "priceText": "€10 ($11.61)", | |
| 35 | + "bids": 0, | |
| 36 | + "seller": null, | |
| 37 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-09-05/2_n.jpg?itok=_jB97raC", | |
| 38 | + "sold": true | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "id": "JIR1207939", | |
| 42 | + "url": "https://e-rocks.com/item/jir1207939/andymcdonaldite-gold-rodalquilarite", | |
| 43 | + "name": "Andymcdonaldite Gold & Rodalquilarite", | |
| 44 | + "locality": "Filón 450, Rodalquilar, Almería, Andalusia, Spain", | |
| 45 | + "sizeClass": "Small miniature (3-4.5 cm)", | |
| 46 | + "priceText": "€10 ($11.61)", | |
| 47 | + "bids": 0, | |
| 48 | + "seller": null, | |
| 49 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-08-31/20240103_133317_n.jpg?itok=L-U0UXAA", | |
| 50 | + "sold": true | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "id": "JIR1208393", | |
| 54 | + "url": "https://e-rocks.com/item/jir1208393/apachite-hydroxyapophyllite", | |
| 55 | + "name": "Apachite & Hydroxyapophyllite", | |
| 56 | + "locality": "Christmas Mine, Banner District, Gila County, Arizona, United States of America", | |
| 57 | + "sizeClass": "Thumbnail (1-3cm)", | |
| 58 | + "priceText": "€10 ($11.61)", | |
| 59 | + "bids": 0, | |
| 60 | + "seller": null, | |
| 61 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-09-03/2026-09-03-16.17.28%20ZS%20PMax_n.jpg?itok=srJk82KR", | |
| 62 | + "sold": true | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "id": "JIR1208587", | |
| 66 | + "url": "https://e-rocks.com/item/jir1208587/aragonite", | |
| 67 | + "name": "Aragonite", | |
| 68 | + "locality": "Descuido Mine, Barranco Hondo, Pechina, Almería, Andalusia, Spain", | |
| 69 | + "sizeClass": "Small cabinet (7-10 cm)", | |
| 70 | + "priceText": "€10 ($11.61)", | |
| 71 | + "bids": 0, | |
| 72 | + "seller": null, | |
| 73 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-09-04/IMG_1537_n.jpg?itok=bkWFVtrd", | |
| 74 | + "sold": true | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "id": "JIR1208394", | |
| 78 | + "url": "https://e-rocks.com/item/jir1208394/arakiite", | |
| 79 | + "name": "Arakiite", | |
| 80 | + "locality": "Moss Mine, Nordmark, Filipstad, Värmland County, Sweden", | |
| 81 | + "sizeClass": "Thumbnail (1-3cm)", | |
| 82 | + "priceText": "€10 ($11.61)", | |
| 83 | + "bids": 0, | |
| 84 | + "seller": null, | |
| 85 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-09-03/20240105_103539_n.jpg?itok=qCqAtPxu", | |
| 86 | + "sold": true | |
| 87 | + }, | |
| 88 | + { | |
| 89 | + "id": "JIR1208435", | |
| 90 | + "url": "https://e-rocks.com/item/jir1208435/avdoninite", | |
| 91 | + "name": "Avdoninite", | |
| 92 | + "locality": "Yadovitaya Fumarole, Second Scoria Cone, Great Fissure, Tolbachik Volcano, Kamchatka Krai, Russian Federation", | |
| 93 | + "sizeClass": null, | |
| 94 | + "priceText": "€10 ($11.61)", | |
| 95 | + "bids": 1, | |
| 96 | + "seller": null, | |
| 97 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-09-04/2026-09-04-09.24.42%20ZS%20PMax_n.jpg?itok=Nhc7GtPP", | |
| 98 | + "sold": true | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + "id": "JIR1208599", | |
| 102 | + "url": "https://e-rocks.com/item/jir1208599/bariopharmacosiderite", | |
| 103 | + "name": "Bariopharmacosiderite", | |
| 104 | + "locality": "Sol Mine, Rodalquilar, Almería, Andalusia, Spain", | |
| 105 | + "sizeClass": "Small miniature (3-4.5 cm)", | |
| 106 | + "priceText": "€10 ($11.61)", | |
| 107 | + "bids": 0, | |
| 108 | + "seller": null, | |
| 109 | + "image": "https://images.e-rocks.com/styles/items_lists_200x200/public/item-images/1475/2026-09-05/502-1bbb_n.jpg?itok=ZKFZI_Rj", | |
| 110 | + "sold": true | |
| 111 | + } | |
| 112 | + ] | |
| 113 | + } | |
| 114 | + }, | |
| 115 | + "expect": { | |
| 116 | + "minCount": 1 | |
| 117 | + }, | |
| 118 | + "note": "Captured live from e-rocks.com on 2026-09-07 (trimmed to 8 items)", | |
| 119 | + "capturedAt": "2026-09-07T07:17:22.585Z" | |
| 120 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/hifishark/marantz-2270.json
+550 −0
@@ -0,0 +1,550 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.hifishark.com/search?q=Marantz%202270", | |
| 4 | + "externalId": "q:Marantz 2270", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:13:21.231Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "search_page", | |
| 10 | + "url": "https://www.hifishark.com/search?q=Marantz%202270", | |
| 11 | + "query": "Marantz 2270", | |
| 12 | + "forSaleCount": 60, | |
| 13 | + "rows": [ | |
| 14 | + { | |
| 15 | + "id": "152_fbac5580-96ad-4659-a872-e3022e54a225", | |
| 16 | + "href": "/goto/152_fbac5580-96ad-4659-a872-e3022e54a225/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 17 | + "title": "marantz 2270", | |
| 18 | + "priceText": "€850", | |
| 19 | + "marketplace": "Subito", | |
| 20 | + "country": "Italy", | |
| 21 | + "countryIso": "IT", | |
| 22 | + "firstSeen": "Aug 2, 2026", | |
| 23 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/fa/faba1f61-0872-4369-b538-ba66f3adf78e?rule=bigcardimages-auto" | |
| 24 | + }, | |
| 25 | + { | |
| 26 | + "id": "152_5fdfe61c-75de-44ae-a6b8-e9d399ada45b", | |
| 27 | + "href": "/goto/152_5fdfe61c-75de-44ae-a6b8-e9d399ada45b/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 28 | + "title": "marantz 2270", | |
| 29 | + "priceText": "€850", | |
| 30 | + "marketplace": "Subito", | |
| 31 | + "country": "Italy", | |
| 32 | + "countryIso": "IT", | |
| 33 | + "firstSeen": "Aug 2, 2026", | |
| 34 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/33/337aefb4-7547-45b1-96a7-8b0feb542b34?rule=bigcardimages-auto" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "id": "40_650311250", | |
| 38 | + "href": "/goto/40_650311250/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 39 | + "title": "Marantz 2270", | |
| 40 | + "priceText": "$1,800", | |
| 41 | + "marketplace": "US Audio Mart", | |
| 42 | + "country": "US", | |
| 43 | + "countryIso": "US", | |
| 44 | + "firstSeen": "Apr 7, 2026", | |
| 45 | + "image": "https://img.usaudiomart.com/uploads/2026/04/10/650311250_large_58e89c2747539f286538cb928f694ba4.jpg" | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "id": "239_3468808344-172-4896", | |
| 49 | + "href": "/goto/239_3468808344-172-4896/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 50 | + "title": "Marantz 2270", | |
| 51 | + "priceText": "€900", | |
| 52 | + "marketplace": "Kleinanzeigen", | |
| 53 | + "country": "Germany", | |
| 54 | + "countryIso": "DE", | |
| 55 | + "firstSeen": "Jul 27, 2026", | |
| 56 | + "image": "https://img.kleinanzeigen.de/api/v1/prod-ads/images/81/816551f3-0b61-4eb5-889a-e69b347df946?rule=$_3.AUTO" | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + "id": "248_195175199", | |
| 60 | + "href": "/goto/248_195175199/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 61 | + "title": "Marantz 2270", | |
| 62 | + "priceText": "€1,599", | |
| 63 | + "marketplace": "Bazos Slovakia", | |
| 64 | + "country": "Slovakia", | |
| 65 | + "countryIso": "SK", | |
| 66 | + "firstSeen": "Sep 4, 2026", | |
| 67 | + "image": "https://www.hifishark.com/imgproxy/0179419d4a8c/b4/8c/s/www.bazos.sk/img/1/199/195175199.jpg" | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "id": "372_222556133", | |
| 71 | + "href": "/goto/372_222556133/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 72 | + "title": "receiver Marantz 2270", | |
| 73 | + "priceText": "CZK 29,800", | |
| 74 | + "marketplace": "Bazos Czechia", | |
| 75 | + "country": "Czech Republic", | |
| 76 | + "countryIso": "CZ", | |
| 77 | + "firstSeen": "Aug 16, 2026", | |
| 78 | + "image": "https://www.bazos.cz/img/1/133/222556133.jpg" | |
| 79 | + }, | |
| 80 | + { | |
| 81 | + "id": "152_3e031811-0584-47ff-91ff-a80e55efe4e5", | |
| 82 | + "href": "/goto/152_3e031811-0584-47ff-91ff-a80e55efe4e5/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 83 | + "title": "Sintoamplificatore Marantz 2270", | |
| 84 | + "priceText": "€900", | |
| 85 | + "marketplace": "Subito", | |
| 86 | + "country": "Italy", | |
| 87 | + "countryIso": "IT", | |
| 88 | + "firstSeen": "Jun 21, 2026", | |
| 89 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/54/54ec9577-c347-4228-9ba7-3c55fdbd319a?rule=bigcardimages-auto" | |
| 90 | + }, | |
| 91 | + { | |
| 92 | + "id": "389_15873", | |
| 93 | + "href": "https://www.euromaxx.nu/product/marantz-2270-lampset-light-set-lampkit-bulb-birnen/", | |
| 94 | + "title": "Marantz 2270 lampset", | |
| 95 | + "priceText": "€36", | |
| 96 | + "marketplace": "EuroMAXX", | |
| 97 | + "country": "Netherlands", | |
| 98 | + "countryIso": "NL", | |
| 99 | + "firstSeen": "Apr 27, 2023", | |
| 100 | + "image": null | |
| 101 | + }, | |
| 102 | + { | |
| 103 | + "id": "152_636ea6c1-9d97-4489-8f06-ab117615d177", | |
| 104 | + "href": "/goto/152_636ea6c1-9d97-4489-8f06-ab117615d177/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 105 | + "title": "marantz 2270+lettore DVD marantz", | |
| 106 | + "priceText": "€1,500", | |
| 107 | + "marketplace": "Subito", | |
| 108 | + "country": "Italy", | |
| 109 | + "countryIso": "IT", | |
| 110 | + "firstSeen": "Jul 20, 2026", | |
| 111 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/56/56dfc963-b8df-4123-a3c2-67a7a63f83a9?rule=bigcardimages-auto" | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + "id": "152_d6a29450-f8f9-46dd-9d8c-0356818d7ba9", | |
| 115 | + "href": "/goto/152_d6a29450-f8f9-46dd-9d8c-0356818d7ba9/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 116 | + "title": "marantz 2270 +lettore DVD", | |
| 117 | + "priceText": "€1,700", | |
| 118 | + "marketplace": "Subito", | |
| 119 | + "country": "Italy", | |
| 120 | + "countryIso": "IT", | |
| 121 | + "firstSeen": "Jul 1, 2026", | |
| 122 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/3f/3f692e2b-8fa8-4dc8-8b45-bb4dd88ec469?rule=bigcardimages-auto" | |
| 123 | + }, | |
| 124 | + { | |
| 125 | + "id": "152_37e55a44-cf3d-44da-8d2a-69c4f6c0a967", | |
| 126 | + "href": "/goto/152_37e55a44-cf3d-44da-8d2a-69c4f6c0a967/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 127 | + "title": "marantz 2270+lettore DVD", | |
| 128 | + "priceText": "€1,500", | |
| 129 | + "marketplace": "Subito", | |
| 130 | + "country": "Italy", | |
| 131 | + "countryIso": "IT", | |
| 132 | + "firstSeen": "Aug 3, 2026", | |
| 133 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/77/77686c91-cdb5-47e6-825d-639a73b051bd?rule=bigcardimages-auto" | |
| 134 | + }, | |
| 135 | + { | |
| 136 | + "id": "7_243234", | |
| 137 | + "href": "/goto/7_243234/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 138 | + "title": "Marantz 2270 (professionellt restaurerad)", | |
| 139 | + "priceText": "SEK 29,000", | |
| 140 | + "marketplace": "Hifitorget", | |
| 141 | + "country": "Sweden", | |
| 142 | + "countryIso": "SE", | |
| 143 | + "firstSeen": "Sep 5, 2026", | |
| 144 | + "image": "https://www.hifishark.com/imgproxy/40a7b7c730de/b4/8c/s/d7qu36w25t4vd.cloudfront.net/1be1f05c4096cb1512ee427cd1c63b80.webp" | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "id": "415_100800631", | |
| 148 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F100800631-marantz-2270-stereo-receiver", | |
| 149 | + "title": "Marantz 2270 stereo receiver", | |
| 150 | + "priceText": "$2,200", | |
| 151 | + "marketplace": null, | |
| 152 | + "country": "US", | |
| 153 | + "countryIso": "US", | |
| 154 | + "firstSeen": "Aug 21, 2026", | |
| 155 | + "image": "https://rvb-img.reverb.com/i/s--qzshbvK2--/quality=medium-low,height=800,width=800,fit=contain/6f276de1-31a4-4b5e-b9dd-ce7d941b9195.jpeg" | |
| 156 | + }, | |
| 157 | + { | |
| 158 | + "id": "415_95640856", | |
| 159 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95640856-marantz-2270-1971-1976", | |
| 160 | + "title": "Marantz 2270 1971-1976", | |
| 161 | + "priceText": "$2,499", | |
| 162 | + "marketplace": null, | |
| 163 | + "country": "US", | |
| 164 | + "countryIso": "US", | |
| 165 | + "firstSeen": "Apr 3, 2026", | |
| 166 | + "image": "https://rvb-img.reverb.com/i/s--Doa2oG1---/quality=medium-low,height=800,width=800,fit=contain/2a84ce01-9e79-4b40-89b6-9fc23e3d0d7d.jpg" | |
| 167 | + }, | |
| 168 | + { | |
| 169 | + "id": "40_650346027", | |
| 170 | + "href": "/goto/40_650346027/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 171 | + "title": "Marantz 2270 stereo receiver", | |
| 172 | + "priceText": "$2,650", | |
| 173 | + "marketplace": "US Audio Mart", | |
| 174 | + "country": "US", | |
| 175 | + "countryIso": "US", | |
| 176 | + "firstSeen": "Aug 4, 2026", | |
| 177 | + "image": "https://img.usaudiomart.com/uploads/2026/08/7/650346027_large_44f3462534a4d49f5b9fc6946335a934.jpg" | |
| 178 | + }, | |
| 179 | + { | |
| 180 | + "id": "415_99585400", | |
| 181 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F99585400-marantz-2270-1974-silver", | |
| 182 | + "title": "Marantz 2270 1974 - Silver", | |
| 183 | + "priceText": "$128", | |
| 184 | + "marketplace": null, | |
| 185 | + "country": "US", | |
| 186 | + "countryIso": "US", | |
| 187 | + "firstSeen": "Jul 21, 2026", | |
| 188 | + "image": "https://rvb-img.reverb.com/i/s--szau5nR5--/quality=medium-low,height=800,width=800,fit=contain/93fff6fe-15be-4701-8ee3-edf21c997e24.jpg" | |
| 189 | + }, | |
| 190 | + { | |
| 191 | + "id": "239_3442142332-172-948", | |
| 192 | + "href": "/goto/239_3442142332-172-948/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 193 | + "title": "Marantz 2270 Stereo Receiver", | |
| 194 | + "priceText": "€750", | |
| 195 | + "marketplace": "Kleinanzeigen", | |
| 196 | + "country": "Germany", | |
| 197 | + "countryIso": "DE", | |
| 198 | + "firstSeen": "Jun 25, 2026", | |
| 199 | + "image": "https://img.kleinanzeigen.de/api/v1/prod-ads/images/db/dbf210f0-81e5-428d-b17b-d36de2d136bb?rule=$_3.AUTO" | |
| 200 | + }, | |
| 201 | + { | |
| 202 | + "id": "239_3497489365-172-9677", | |
| 203 | + "href": "/goto/239_3497489365-172-9677/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 204 | + "title": "Marantz 2270 Stereophonic Receiver", | |
| 205 | + "priceText": "€750", | |
| 206 | + "marketplace": "Kleinanzeigen", | |
| 207 | + "country": "Germany", | |
| 208 | + "countryIso": "DE", | |
| 209 | + "firstSeen": "Aug 29, 2026", | |
| 210 | + "image": "https://img.kleinanzeigen.de/api/v1/prod-ads/images/48/48fc86e3-c2db-4e5f-91b5-fd8530060e34?rule=$_3.AUTO" | |
| 211 | + }, | |
| 212 | + { | |
| 213 | + "id": "44_747167826", | |
| 214 | + "href": "/goto/44_747167826/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 215 | + "title": "Marantz 2270 Stereophonic receiver", | |
| 216 | + "priceText": "SEK 10,999", | |
| 217 | + "marketplace": "Tradera", | |
| 218 | + "country": "Sweden", | |
| 219 | + "countryIso": "SE", | |
| 220 | + "firstSeen": "Aug 26, 2026", | |
| 221 | + "image": "https://www.hifishark.com/imgproxy/c60d7784d0c1/b4/8c/s/img.tradera.net/small-square/741/657451741_0744f846-ad02-47de-9ba5-26646029a8e5.jpg" | |
| 222 | + }, | |
| 223 | + { | |
| 224 | + "id": "152_7c6bde58-7e2a-4e7e-a2ce-261daed69747", | |
| 225 | + "href": "/goto/152_7c6bde58-7e2a-4e7e-a2ce-261daed69747/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 226 | + "title": "Marantz 2270 Receiver – Funzionante- REVISIONATO!!", | |
| 227 | + "priceText": "€1,150", | |
| 228 | + "marketplace": "Subito", | |
| 229 | + "country": "Italy", | |
| 230 | + "countryIso": "IT", | |
| 231 | + "firstSeen": "Feb 7, 2026", | |
| 232 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/65/65b4babc-a2fb-4869-8f99-35c2523e9d67?rule=bigcardimages-auto" | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "id": "389_21442", | |
| 236 | + "href": "https://www.euromaxx.nu/product/marantz-2270-power-switch-schakelaar/", | |
| 237 | + "title": "Marantz 2270 Power Switch Schakelaar", | |
| 238 | + "priceText": "€8", | |
| 239 | + "marketplace": "EuroMAXX", | |
| 240 | + "country": "Netherlands", | |
| 241 | + "countryIso": "NL", | |
| 242 | + "firstSeen": "Mar 16, 2024", | |
| 243 | + "image": null | |
| 244 | + }, | |
| 245 | + { | |
| 246 | + "id": "843_92119", | |
| 247 | + "href": "https://www.gearwise.se/shop/audio/hifi/marantz-2270-am-fm-receiver/", | |
| 248 | + "title": "Marantz 2270 AM/FM Receiver", | |
| 249 | + "priceText": "€1,795", | |
| 250 | + "marketplace": "Gearwise AB", | |
| 251 | + "country": "Sweden", | |
| 252 | + "countryIso": "SE", | |
| 253 | + "firstSeen": "Aug 5, 2026", | |
| 254 | + "image": "https://www.hifishark.com/imgproxy/db13839f8935/b4/8c/s/www.gearwise.se/wp-content/uploads/2025/09/DSC06872.jpg" | |
| 255 | + }, | |
| 256 | + { | |
| 257 | + "id": "415_93651271", | |
| 258 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F93651271-marantz-2270-black-fully-reccapped", | |
| 259 | + "title": "Marantz 2270 Black Fully Reccapped", | |
| 260 | + "priceText": "$2,650", | |
| 261 | + "marketplace": null, | |
| 262 | + "country": "US", | |
| 263 | + "countryIso": "US", | |
| 264 | + "firstSeen": "Dec 21, 2025", | |
| 265 | + "image": "https://rvb-img.reverb.com/i/s--Ith8C88X--/quality=medium-low,height=800,width=800,fit=contain/383f0d99-0882-481b-a3c2-63eaff3510ea.jpg" | |
| 266 | + }, | |
| 267 | + { | |
| 268 | + "id": "415_59386041", | |
| 269 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F59386041-marantz-2270-bulbs-lamps-lights", | |
| 270 | + "title": "Marantz 2270 bulbs lamps lights", | |
| 271 | + "priceText": "$36", | |
| 272 | + "marketplace": null, | |
| 273 | + "country": "US", | |
| 274 | + "countryIso": "US", | |
| 275 | + "firstSeen": "May 3, 2026", | |
| 276 | + "image": "https://rvb-img.reverb.com/i/s--Qo-y9rwK--/quality=medium-low,height=800,width=800,fit=contain/x6js8bregpsagvewxrua.jpg" | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + "id": "12_354336736", | |
| 280 | + "href": "/goto/12_354336736/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 281 | + "title": "Sort Faceplate til Marantz 2270", | |
| 282 | + "priceText": "NOK 1,900", | |
| 283 | + "marketplace": "Finn", | |
| 284 | + "country": "Norway", | |
| 285 | + "countryIso": "NO", | |
| 286 | + "firstSeen": "May 26, 2024", | |
| 287 | + "image": "https://www.hifishark.com/imgproxy/9a1b77ddf8d1/b4/8c/s/images.finncdn.no/dynamic/default/2024/5/vertical-0/25/6/354/336/736_bce4693d-8ec4-420e-9dc2-bf8e0d0bb662.jpg" | |
| 288 | + }, | |
| 289 | + { | |
| 290 | + "id": "19_650340218", | |
| 291 | + "href": "/goto/19_650340218/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 292 | + "title": "Marantz 2270 Full Serviced ( Pristine )", | |
| 293 | + "priceText": "CA$3,500", | |
| 294 | + "marketplace": "Canuck Audio Mart", | |
| 295 | + "country": "Canada", | |
| 296 | + "countryIso": "CA", | |
| 297 | + "firstSeen": "Jul 14, 2026", | |
| 298 | + "image": "https://img.canuckaudiomart.com/uploads/2026/07/18/650340218_large_92afa751be220a59cfb70955da34de75.jpg" | |
| 299 | + }, | |
| 300 | + { | |
| 301 | + "id": "44_749160818", | |
| 302 | + "href": "/goto/44_749160818/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 303 | + "title": "Vintage Marantz 2270 Stereo Receiver", | |
| 304 | + "priceText": "SEK 6,500", | |
| 305 | + "marketplace": "Tradera", | |
| 306 | + "country": "Sweden", | |
| 307 | + "countryIso": "SE", | |
| 308 | + "firstSeen": "Sep 7, 2026", | |
| 309 | + "image": "https://www.hifishark.com/imgproxy/b61668b3b8c5/b4/8c/s/img.tradera.net/small-square/612/658984612_aff5832f-7446-4f83-9130-6b763af8e2a7.jpg" | |
| 310 | + }, | |
| 311 | + { | |
| 312 | + "id": "389_2080", | |
| 313 | + "href": "https://www.euromaxx.nu/product/marantz-2270-recap-set-kit/", | |
| 314 | + "title": "Marantz 2270 Recap Set Kit Condensatoren", | |
| 315 | + "priceText": "€128", | |
| 316 | + "marketplace": "EuroMAXX", | |
| 317 | + "country": "Netherlands", | |
| 318 | + "countryIso": "NL", | |
| 319 | + "firstSeen": "Mar 11, 2022", | |
| 320 | + "image": null | |
| 321 | + }, | |
| 322 | + { | |
| 323 | + "id": "389_21967", | |
| 324 | + "href": "https://www.euromaxx.nu/product/marantz-2270-button-knob-knop-knopf/", | |
| 325 | + "title": "Marantz 2270 button knop aluminium replica", | |
| 326 | + "priceText": "€8", | |
| 327 | + "marketplace": "EuroMAXX", | |
| 328 | + "country": "Netherlands", | |
| 329 | + "countryIso": "NL", | |
| 330 | + "firstSeen": "Apr 14, 2024", | |
| 331 | + "image": null | |
| 332 | + }, | |
| 333 | + { | |
| 334 | + "id": "415_100531852", | |
| 335 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F100531852-marantz-2270-vintage-stereo-receiver-recapped", | |
| 336 | + "title": "Marantz 2270 Vintage Stereo Receiver Recapped", | |
| 337 | + "priceText": "$2,950", | |
| 338 | + "marketplace": null, | |
| 339 | + "country": "US", | |
| 340 | + "countryIso": "US", | |
| 341 | + "firstSeen": "Aug 15, 2026", | |
| 342 | + "image": "https://rvb-img.reverb.com/i/s--2gtuzU2I--/quality=medium-low,height=800,width=800,fit=contain/ec226688-4783-4e36-abdc-1a6183b36c7a.jpeg" | |
| 343 | + }, | |
| 344 | + { | |
| 345 | + "id": "846_gx0e6z3", | |
| 346 | + "href": "/goto/846_gx0e6z3/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 347 | + "title": "Marantz 2270 ενισχυτής σαν καινούργιος, άριστη κατάσταση", | |
| 348 | + "priceText": "€3,000", | |
| 349 | + "marketplace": "Vendora Greece", | |
| 350 | + "country": "Greece", | |
| 351 | + "countryIso": "GR", | |
| 352 | + "firstSeen": "Aug 7, 2026", | |
| 353 | + "image": "https://www.hifishark.com/imgproxy/c8d4d878e52b/b4/8c/s/bcdn.vendora.gr/0/36/60/3660ff519b81bcb67c202f450d18ad23845fe028.jpg%3Fclass=mrec" | |
| 354 | + }, | |
| 355 | + { | |
| 356 | + "id": "389_1962", | |
| 357 | + "href": "https://www.euromaxx.nu/product/marantz-2270-recap-powerboard-only/", | |
| 358 | + "title": "Marantz 2270 Recap Powerboard P800 condensatorset", | |
| 359 | + "priceText": "€19", | |
| 360 | + "marketplace": "EuroMAXX", | |
| 361 | + "country": "Netherlands", | |
| 362 | + "countryIso": "NL", | |
| 363 | + "firstSeen": "Mar 11, 2022", | |
| 364 | + "image": null | |
| 365 | + }, | |
| 366 | + { | |
| 367 | + "id": "415_95544345", | |
| 368 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544345-marantz-2270-capacitor-rebuild-restoration-recap-service-kit-repair", | |
| 369 | + "title": "Marantz 2270 capacitor rebuild restoration recap service kit repair", | |
| 370 | + "priceText": "$136", | |
| 371 | + "marketplace": null, | |
| 372 | + "country": "US", | |
| 373 | + "countryIso": "US", | |
| 374 | + "firstSeen": "Aug 29, 2026", | |
| 375 | + "image": "https://rvb-img.reverb.com/i/s--ByiKhNdl--/quality=medium-low,height=800,width=800,fit=contain/9908d67e-84c9-4c62-b1b7-cce133c8dcb8.jpg" | |
| 376 | + }, | |
| 377 | + { | |
| 378 | + "id": "239_3500397815-172-3842", | |
| 379 | + "href": "/goto/239_3500397815-172-3842/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 380 | + "title": "Marantz 2270 Vintage-Receiver | Vollrevidiert (neue Elkos/Relais)", | |
| 381 | + "priceText": "€1,850", | |
| 382 | + "marketplace": "Kleinanzeigen", | |
| 383 | + "country": "Germany", | |
| 384 | + "countryIso": "DE", | |
| 385 | + "firstSeen": "Sep 1, 2026", | |
| 386 | + "image": "https://img.kleinanzeigen.de/api/v1/prod-ads/images/59/5958b145-b3b4-4cdb-86db-1eac322335f1?rule=$_3.AUTO" | |
| 387 | + }, | |
| 388 | + { | |
| 389 | + "id": "152_b88ac148-b679-4b22-a988-62f479634bb2", | |
| 390 | + "href": "/goto/152_b88ac148-b679-4b22-a988-62f479634bb2/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 391 | + "title": "MARANTZ 2270 FACE ENGRAVED CON WOOD CASE WC-22", | |
| 392 | + "priceText": "€1,940", | |
| 393 | + "marketplace": "Subito", | |
| 394 | + "country": "Italy", | |
| 395 | + "countryIso": "IT", | |
| 396 | + "firstSeen": "Jun 29, 2026", | |
| 397 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/7a/7a9db697-aec6-486b-a4ab-687dc624acb1?rule=bigcardimages-auto" | |
| 398 | + }, | |
| 399 | + { | |
| 400 | + "id": "415_95544241", | |
| 401 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544241-marantz-2270-rebuild-restoration-recap-service-kit-repair-capacitor", | |
| 402 | + "title": "Marantz 2270 rebuild restoration recap service kit repair capacitor", | |
| 403 | + "priceText": "$130", | |
| 404 | + "marketplace": null, | |
| 405 | + "country": "US", | |
| 406 | + "countryIso": "US", | |
| 407 | + "firstSeen": "Aug 29, 2026", | |
| 408 | + "image": "https://rvb-img.reverb.com/i/s--S-ltKVtD--/quality=medium-low,height=800,width=800,fit=contain/4133139e-d955-4fac-9b53-143f03313d94.jpg" | |
| 409 | + }, | |
| 410 | + { | |
| 411 | + "id": "415_95544239", | |
| 412 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544239-marantz-2270-rebuild-restoration-recap-service-kit-repair-capacitor", | |
| 413 | + "title": "Marantz 2270 rebuild restoration recap service kit repair capacitor", | |
| 414 | + "priceText": "$85", | |
| 415 | + "marketplace": null, | |
| 416 | + "country": "US", | |
| 417 | + "countryIso": "US", | |
| 418 | + "firstSeen": "Aug 29, 2026", | |
| 419 | + "image": "https://rvb-img.reverb.com/i/s--G9jCjOmp--/quality=medium-low,height=800,width=800,fit=contain/6bcaea97-080f-4471-87ec-a9d06373ca59.jpg" | |
| 420 | + }, | |
| 421 | + { | |
| 422 | + "id": "415_59499503", | |
| 423 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F59499503-marantz-2270-restoration-kit-filter-capacitor-repair-rebuild-fix", | |
| 424 | + "title": "Marantz 2270 restoration kit filter capacitor repair rebuild fix", | |
| 425 | + "priceText": "$160", | |
| 426 | + "marketplace": null, | |
| 427 | + "country": "US", | |
| 428 | + "countryIso": "US", | |
| 429 | + "firstSeen": "Aug 18, 2022", | |
| 430 | + "image": "https://rvb-img.reverb.com/i/s--una2nvPT--/quality=medium-low,height=800,width=800,fit=contain/bcuxcsx8pzw8v3lik12u.jpg" | |
| 431 | + }, | |
| 432 | + { | |
| 433 | + "id": "152_484632389", | |
| 434 | + "href": "/goto/152_484632389/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 435 | + "title": "Marantz pm 2270", | |
| 436 | + "priceText": "€800", | |
| 437 | + "marketplace": "Subito", | |
| 438 | + "country": "Italy", | |
| 439 | + "countryIso": "IT", | |
| 440 | + "firstSeen": "May 25, 2022", | |
| 441 | + "image": "https://images.sbito.it/api/v1/sbt-ads-images-pro/images/90/90011c79-6ef2-4ee7-8e1c-f1a0c34b7bb8?rule=bigcardimages-auto" | |
| 442 | + }, | |
| 443 | + { | |
| 444 | + "id": "213_3635", | |
| 445 | + "href": "https://www.audioscope.net/marantz-model-2270-p-3635.html?ref=3", | |
| 446 | + "title": "Marantz Model 2270", | |
| 447 | + "priceText": "€2,990", | |
| 448 | + "marketplace": "audioScope - The Original Classic", | |
| 449 | + "country": "Germany", | |
| 450 | + "countryIso": "DE", | |
| 451 | + "firstSeen": "Mar 21, 2022", | |
| 452 | + "image": "https://www.hifishark.com/imgproxy/a0c17b4bc486/b4/8c/s/www.audioscope.net/images/marantz_2270_wc_tn-1.jpg" | |
| 453 | + }, | |
| 454 | + { | |
| 455 | + "id": "415_97920803", | |
| 456 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F97920803-marantz-2270-vintage-receiver-1971-1975-fully-functional-led-lighting", | |
| 457 | + "title": "Marantz 2270 Vintage Receiver (1971–1975) – Fully Functional – LED Lighting", | |
| 458 | + "priceText": "$2,700", | |
| 459 | + "marketplace": null, | |
| 460 | + "country": "US", | |
| 461 | + "countryIso": "US", | |
| 462 | + "firstSeen": "Jun 5, 2026", | |
| 463 | + "image": "https://rvb-img.reverb.com/i/s--lCb7r1c_--/quality=medium-low,height=800,width=800,fit=contain/c8298f44-35d4-4ccb-9949-27f808633da3.jpg" | |
| 464 | + }, | |
| 465 | + { | |
| 466 | + "id": "415_95544046", | |
| 467 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544046-marantz-2270-rebuild-restoration-recap-upgrade-kit-repair-filter-capacitor", | |
| 468 | + "title": "Marantz 2270 rebuild restoration recap upgrade kit repair filter capacitor", | |
| 469 | + "priceText": "$165", | |
| 470 | + "marketplace": null, | |
| 471 | + "country": "US", | |
| 472 | + "countryIso": "US", | |
| 473 | + "firstSeen": "Aug 28, 2026", | |
| 474 | + "image": "https://rvb-img.reverb.com/i/s--9t6M-Gg2--/quality=medium-low,height=800,width=800,fit=contain/d4a9cc19-69e8-4347-aab0-b2106e937d9c.jpg" | |
| 475 | + }, | |
| 476 | + { | |
| 477 | + "id": "415_95544346", | |
| 478 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544346-marantz-2270-receiver-dial-scale-replacement-led-bulb-lamp-light-kit", | |
| 479 | + "title": "Marantz 2270 receiver dial scale replacement LED bulb lamp light kit", | |
| 480 | + "priceText": "$18", | |
| 481 | + "marketplace": null, | |
| 482 | + "country": "US", | |
| 483 | + "countryIso": "US", | |
| 484 | + "firstSeen": "Aug 29, 2026", | |
| 485 | + "image": "https://rvb-img.reverb.com/i/s--BQ5P5i8f--/quality=medium-low,height=800,width=800,fit=contain/dbb2c51d-e9d5-435c-b4be-7524ffaf3af7.jpg" | |
| 486 | + }, | |
| 487 | + { | |
| 488 | + "id": "415_95544084", | |
| 489 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544084-marantz-2270-titanium-upgrade-bulbs-lights-led-lamps-full-set-kit", | |
| 490 | + "title": "Marantz 2270 TITANIUM upgrade bulbs lights LED lamps FULL Set kit", | |
| 491 | + "priceText": "$39", | |
| 492 | + "marketplace": null, | |
| 493 | + "country": "US", | |
| 494 | + "countryIso": "US", | |
| 495 | + "firstSeen": "Aug 29, 2026", | |
| 496 | + "image": "https://rvb-img.reverb.com/i/s--O3UImVCm--/quality=medium-low,height=800,width=800,fit=contain/a8b25a73-1400-46df-9da3-99612fe827fa.jpg" | |
| 497 | + }, | |
| 498 | + { | |
| 499 | + "id": "415_95544112", | |
| 500 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544112-marantz-2270-replacement-led-bulb-lamp-light-set-diffuser-upgrade-kit", | |
| 501 | + "title": "Marantz 2270 replacement LED bulb lamp light set DIFFUSER upgrade kit", | |
| 502 | + "priceText": "$39", | |
| 503 | + "marketplace": null, | |
| 504 | + "country": "US", | |
| 505 | + "countryIso": "US", | |
| 506 | + "firstSeen": "Aug 29, 2026", | |
| 507 | + "image": "https://rvb-img.reverb.com/i/s--AAs9FWPy--/quality=medium-low,height=800,width=800,fit=contain/193e4ac4-4e9d-4b76-9604-aa9dea1caa19.jpg" | |
| 508 | + }, | |
| 509 | + { | |
| 510 | + "id": "40_650314717", | |
| 511 | + "href": "/goto/40_650314717/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 512 | + "title": "Marantz 2270 with recent tuner allignment, new power switch and more", | |
| 513 | + "priceText": "$2,100", | |
| 514 | + "marketplace": "US Audio Mart", | |
| 515 | + "country": "US", | |
| 516 | + "countryIso": "US", | |
| 517 | + "firstSeen": "Apr 18, 2026", | |
| 518 | + "image": "https://img.usaudiomart.com/uploads/2026/04/17/650314717_large_70174c7c84470d69e84672c5d145dd83.jpg" | |
| 519 | + }, | |
| 520 | + { | |
| 521 | + "id": "19_650286801", | |
| 522 | + "href": "/goto/19_650286801/9b567e0c-aa8b-11f1-a16a-616565393864", | |
| 523 | + "title": "Marantz 2270 receiver, voltage selector terminal block w/ protector cover plate", | |
| 524 | + "priceText": "CA$49", | |
| 525 | + "marketplace": "Canuck Audio Mart", | |
| 526 | + "country": "Canada", | |
| 527 | + "countryIso": "CA", | |
| 528 | + "firstSeen": "Jan 20, 2026", | |
| 529 | + "image": "https://img.canuckaudiomart.com/uploads/2026/01/1/650286801_large_057c5d293d9af7051a1f2df1cffa503f.jpg" | |
| 530 | + }, | |
| 531 | + { | |
| 532 | + "id": "415_95544400", | |
| 533 | + "href": "https://www.awin1.com/cread.php?awinmid=67144&awinaffid=536903&clickref=&p=https%3A%2F%2Freverb.com%2Fitem%2F95544400-marantz-2270-receiver-replacement-led-bulb-lamp-light-set-upgrade-kit-bulbs", | |
| 534 | + "title": "Marantz 2270 receiver replacement LED bulb lamp light set upgrade kit bulbs", | |
| 535 | + "priceText": "$29", | |
| 536 | + "marketplace": null, | |
| 537 | + "country": "US", | |
| 538 | + "countryIso": "US", | |
| 539 | + "firstSeen": "Aug 29, 2026", | |
| 540 | + "image": "https://rvb-img.reverb.com/i/s--z-5KSojQ--/quality=medium-low,height=800,width=800,fit=contain/c1f76c9b-e70d-460f-8108-fe87a234b843.jpg" | |
| 541 | + } | |
| 542 | + ] | |
| 543 | + } | |
| 544 | + }, | |
| 545 | + "expect": { | |
| 546 | + "minCount": 1 | |
| 547 | + }, | |
| 548 | + "note": "Captured live from www.hifishark.com on 2026-09-07 (trimmed to 8 items)", | |
| 549 | + "capturedAt": "2026-09-07T07:13:21.284Z" | |
| 550 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/lukie-games/nintendo-64-p1.json
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://dytuzo.a.searchspring.io/api/search/search.json?siteId=dytuzo&resultsFormat=native&resultsPerPage=100&page=1&bgfilter.extrafield5=Nintendo%2064&sort.current_price=desc", | |
| 4 | + "externalId": "platform:Nintendo 64:1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:13:23.853Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "search_page", | |
| 10 | + "url": "https://dytuzo.a.searchspring.io/api/search/search.json?siteId=dytuzo&resultsFormat=native&resultsPerPage=100&page=1&bgfilter.extrafield5=Nintendo%2064&sort.current_price=desc", | |
| 11 | + "platform": "Nintendo 64", | |
| 12 | + "page": 1, | |
| 13 | + "total": 792, | |
| 14 | + "results": [ | |
| 15 | + { | |
| 16 | + "uid": "20599", | |
| 17 | + "sku": "N64_CLAYFIGHTER_63_SCULPTORS_CUT_M", | |
| 18 | + "name": "Manual - Clayfighter 63 1/3 Sculptor's Cut - Nintendo N64", | |
| 19 | + "brand": "Nintendo", | |
| 20 | + "platform": "Nintendo 64", | |
| 21 | + "genre": null, | |
| 22 | + "price": 1999.95, | |
| 23 | + "msrp": 2499.99, | |
| 24 | + "url": "https://www.lukiegames.com/Clayfighter-63-1-3-Sculptors-Cut-Nintendo-64-Manual", | |
| 25 | + "image": null, | |
| 26 | + "stock": "Out of Stock", | |
| 27 | + "onsale": true | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "uid": "1491", | |
| 31 | + "sku": "N64_CLAYFIGHTER_63_SCULPTORS_CUT", | |
| 32 | + "name": "Clayfighter 63 1/3 Sculptor's Cut", | |
| 33 | + "brand": "Nintendo", | |
| 34 | + "platform": "Nintendo 64", | |
| 35 | + "genre": "Fighting", | |
| 36 | + "price": 1447.47, | |
| 37 | + "msrp": 1600.03, | |
| 38 | + "url": "https://www.lukiegames.com/Clayfighter-63-1-3-Sculptors-Cut-Nintendo-64-N64-Game.html", | |
| 39 | + "image": "https://www.lukiegames.com/thumbnail.asp?file=assets/images/N64/n64_clayfighter_63_sculptors_cut_p_fpcoeh.jpg&maxx=300&maxy=0", | |
| 40 | + "stock": "Out of Stock", | |
| 41 | + "onsale": true | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + "uid": "1686", | |
| 45 | + "sku": "N64_SUPER_BOWLING", | |
| 46 | + "name": "Super Bowling", | |
| 47 | + "brand": "Nintendo", | |
| 48 | + "platform": "Nintendo 64", | |
| 49 | + "genre": "Sports", | |
| 50 | + "price": 662.97, | |
| 51 | + "msrp": 739.99, | |
| 52 | + "url": "https://www.lukiegames.com/Super-Bowling-Nintendo-64-N64-Game.html", | |
| 53 | + "image": "https://www.lukiegames.com/thumbnail.asp?file=assets/images/N64/n64_super_bowling_p_ep45bi.jpg&maxx=300&maxy=0", | |
| 54 | + "stock": "Out of Stock", | |
| 55 | + "onsale": true | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + "uid": "1685", | |
| 59 | + "sku": "N64_STUNT_RACER_64", | |
| 60 | + "name": "Stunt Racer 64", | |
| 61 | + "brand": "Nintendo", | |
| 62 | + "platform": "Nintendo 64", | |
| 63 | + "genre": "Racing", | |
| 64 | + "price": 553.97, | |
| 65 | + "msrp": 619.99, | |
| 66 | + "url": "https://www.lukiegames.com/Stunt-Racer-64-Nintendo-64-N64-Game.html", | |
| 67 | + "image": "https://www.lukiegames.com/thumbnail.asp?file=assets/images/N64/n64_stunt_racer_64_p_9o597f.jpg&maxx=300&maxy=0", | |
| 68 | + "stock": "Out of Stock", | |
| 69 | + "onsale": true | |
| 70 | + }, | |
| 71 | + { | |
| 72 | + "uid": "1542", | |
| 73 | + "sku": "N64_HEY_YOU_PIKACHU", | |
| 74 | + "name": "Hey You Pikachu VRU Bundle", | |
| 75 | + "brand": "Nintendo", | |
| 76 | + "platform": "Nintendo 64", | |
| 77 | + "genre": "Simulation", | |
| 78 | + "price": 511.97, | |
| 79 | + "msrp": 579.99, | |
| 80 | + "url": "https://www.lukiegames.com/Hey-You-Pikachu-VMU-Nintendo-64-N64-Game.html", | |
| 81 | + "image": "https://www.lukiegames.com/thumbnail.asp?file=assets/images/N64/n64_hey_you_pikachu_p_k79n7a.jpg&maxx=300&maxy=0", | |
| 82 | + "stock": "Out of Stock", | |
| 83 | + "onsale": true | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + "uid": "1475", | |
| 87 | + "sku": "N64_BOMBERMAN_64_SECOND_ATTACK", | |
| 88 | + "name": "Bomberman 64 the Second Attack", | |
| 89 | + "brand": "Nintendo", | |
| 90 | + "platform": "Nintendo 64", | |
| 91 | + "genre": "Action & Adventure", | |
| 92 | + "price": 438.47, | |
| 93 | + "msrp": 469.99, | |
| 94 | + "url": "https://www.lukiegames.com/Bomberman-64-Second-Attack-Nintendo-64-N64-Game.html", | |
| 95 | + "image": "https://www.lukiegames.com/thumbnail.asp?file=assets/images/N64/n64_bomberman_64_second_attack_p_k049u3.jpg&maxx=300&maxy=0", | |
| 96 | + "stock": "Out of Stock", | |
| 97 | + "onsale": true | |
| 98 | + }, | |
| 99 | + { | |
| 100 | + "uid": "3666", | |
| 101 | + "sku": "N64SYSGL01GR_1CTR", | |
| 102 | + "name": "Rare Gold Nintendo 64 System", | |
| 103 | + "brand": "Nintendo", | |
| 104 | + "platform": "Nintendo 64", | |
| 105 | + "genre": null, | |
| 106 | + "price": 425.95, | |
| 107 | + "msrp": 469.99, | |
| 108 | + "url": "https://www.lukiegames.com/rare-gold-nintendo-64-system-n64.html", | |
| 109 | + "image": "https://www.lukiegames.com/thumbnail.asp?file=assets/images/n64sysgl01gr_ctr.jpg&maxx=300&maxy=0", | |
| 110 | + "stock": "Out of Stock", | |
| 111 | + "onsale": true | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + "uid": "3673", | |
| 115 | + "sku": "N64SYS_PK01GR_1CTR", | |
| 116 | + "name": "Pokemon Nintendo 64 System", | |
| 117 | + "brand": "Nintendo", | |
| 118 | + "platform": "Nintendo 64", | |
| 119 | + "genre": null, | |
| 120 | + "price": 424.95, | |
| 121 | + "msrp": 469.99, | |
| 122 | + "url": "https://www.lukiegames.com/Pokemon-N64-Nintendo-64-System-Used-Console.html", | |
| 123 | + "image": "https://www.lukiegames.com/thumbnail.asp?file=assets/images/N64/N64_Pokemon_1ctr.jpg&maxx=300&maxy=0", | |
| 124 | + "stock": "Out of Stock", | |
| 125 | + "onsale": true | |
| 126 | + } | |
| 127 | + ] | |
| 128 | + } | |
| 129 | + }, | |
| 130 | + "expect": { | |
| 131 | + "minCount": 1 | |
| 132 | + }, | |
| 133 | + "note": "Captured live from dytuzo.a.searchspring.io on 2026-09-07 (trimmed to 8 items)", | |
| 134 | + "capturedAt": "2026-09-07T07:13:23.866Z" | |
| 135 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/mpb/medium-format.json
+193 −0
@@ -0,0 +1,193 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://www.mpb.com/en-us/category/used-cameras/medium-format-cameras", | |
| 4 | + "externalId": "en-us:used-cameras/medium-format-cameras", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "firecrawl", | |
| 7 | + "fetchedAt": "2026-09-07T07:13:32.682Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "category_page", | |
| 10 | + "url": "https://www.mpb.com/en-us/category/used-cameras/medium-format-cameras", | |
| 11 | + "market": "en-us", | |
| 12 | + "category": "used-cameras/medium-format-cameras", | |
| 13 | + "total": 17, | |
| 14 | + "models": [ | |
| 15 | + { | |
| 16 | + "name": "Hasselblad 907X Anniversary Edition", | |
| 17 | + "url": "https://www.mpb.com/en-us/product/hasselblad-907x-anniversary-edition", | |
| 18 | + "slug": "hasselblad-907x-anniversary-edition", | |
| 19 | + "available": 1, | |
| 20 | + "priceMin": 8949, | |
| 21 | + "priceMax": 8949, | |
| 22 | + "currency": "USD", | |
| 23 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/94e08d43-5bd2-4f2f-ad7e-119c9a808d2f" | |
| 24 | + }, | |
| 25 | + { | |
| 26 | + "name": "Hasselblad X2D II 100c", | |
| 27 | + "url": "https://www.mpb.com/en-us/product/hasselblad-x2d-ii-100c", | |
| 28 | + "slug": "hasselblad-x2d-ii-100c", | |
| 29 | + "available": 3, | |
| 30 | + "priceMin": 7289, | |
| 31 | + "priceMax": 7509, | |
| 32 | + "currency": "USD", | |
| 33 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/6575a52f-66ac-48a7-a1b9-2363c1450e7f" | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + "name": "Fujifilm GFX 100 II", | |
| 37 | + "url": "https://www.mpb.com/en-us/product/fujifilm-gfx-100-ii", | |
| 38 | + "slug": "fujifilm-gfx-100-ii", | |
| 39 | + "available": 10, | |
| 40 | + "priceMin": 6149, | |
| 41 | + "priceMax": 6839, | |
| 42 | + "currency": "USD", | |
| 43 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/b6302992-8b9f-41cd-9520-6a3e8c9e9907" | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + "name": "Hasselblad 907X 100C", | |
| 47 | + "url": "https://www.mpb.com/en-us/product/hasselblad-907x-100c", | |
| 48 | + "slug": "hasselblad-907x-100c", | |
| 49 | + "available": 1, | |
| 50 | + "priceMin": 6799, | |
| 51 | + "priceMax": 6799, | |
| 52 | + "currency": "USD", | |
| 53 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/0dfab582-589f-46df-b241-2ba7129a90ce" | |
| 54 | + }, | |
| 55 | + { | |
| 56 | + "name": "Fujifilm GFX 100S II", | |
| 57 | + "url": "https://www.mpb.com/en-us/product/fujifilm-gfx-100s-ii", | |
| 58 | + "slug": "fujifilm-gfx-100s-ii", | |
| 59 | + "available": 9, | |
| 60 | + "priceMin": 4689, | |
| 61 | + "priceMax": 5049, | |
| 62 | + "currency": "USD", | |
| 63 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/6348d37d-6d59-4917-88ab-e7cd92895d07" | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + "name": "Fujifilm GFX100RF", | |
| 67 | + "url": "https://www.mpb.com/en-us/product/fujifilm-gfx100rf", | |
| 68 | + "slug": "fujifilm-gfx100rf", | |
| 69 | + "available": 4, | |
| 70 | + "priceMin": 4459, | |
| 71 | + "priceMax": 4609, | |
| 72 | + "currency": "USD", | |
| 73 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/ff1eff9b-69b1-4ed0-8e9f-362c01756430" | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + "name": "Hasselblad X2D 100c", | |
| 77 | + "url": "https://www.mpb.com/en-us/product/hasselblad-h2d-100c", | |
| 78 | + "slug": "hasselblad-h2d-100c", | |
| 79 | + "available": 10, | |
| 80 | + "priceMin": 3849, | |
| 81 | + "priceMax": 4349, | |
| 82 | + "currency": "USD", | |
| 83 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/2c25a405-5526-4829-a9de-2199f279f50b" | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + "name": "Fujifilm GFX 100S", | |
| 87 | + "url": "https://www.mpb.com/en-us/product/fujifilm-gfx-100s", | |
| 88 | + "slug": "fujifilm-gfx-100s", | |
| 89 | + "available": 7, | |
| 90 | + "priceMin": 3199, | |
| 91 | + "priceMax": 3439, | |
| 92 | + "currency": "USD", | |
| 93 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/31582d09-c546-4c8f-804e-eea729ba365b" | |
| 94 | + }, | |
| 95 | + { | |
| 96 | + "name": "Fujifilm GFX 100", | |
| 97 | + "url": "https://www.mpb.com/en-us/product/fuji-gfx-100", | |
| 98 | + "slug": "fuji-gfx-100", | |
| 99 | + "available": 6, | |
| 100 | + "priceMin": 2819, | |
| 101 | + "priceMax": 3109, | |
| 102 | + "currency": "USD", | |
| 103 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/b4792edf-1310-4421-a507-52fb92eb2784" | |
| 104 | + }, | |
| 105 | + { | |
| 106 | + "name": "Fujifilm GFX 50S II", | |
| 107 | + "url": "https://www.mpb.com/en-us/product/fujifilm-gfx-50s-ii", | |
| 108 | + "slug": "fujifilm-gfx-50s-ii", | |
| 109 | + "available": 10, | |
| 110 | + "priceMin": 2429, | |
| 111 | + "priceMax": 2739, | |
| 112 | + "currency": "USD", | |
| 113 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/676c4310-00c3-404a-b033-ba3ceb2fa754" | |
| 114 | + }, | |
| 115 | + { | |
| 116 | + "name": "Hasselblad X1D II 50C", | |
| 117 | + "url": "https://www.mpb.com/en-us/product/hasselblad-x1d-ii-50c", | |
| 118 | + "slug": "hasselblad-x1d-ii-50c", | |
| 119 | + "available": 10, | |
| 120 | + "priceMin": 2279, | |
| 121 | + "priceMax": 2719, | |
| 122 | + "currency": "USD", | |
| 123 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/6e2564fb-5965-4288-accd-66db1353b3a0" | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "name": "Leica S (Typ 007)", | |
| 127 | + "url": "https://www.mpb.com/en-us/product/leica-s-typ-007", | |
| 128 | + "slug": "leica-s-typ-007", | |
| 129 | + "available": 1, | |
| 130 | + "priceMin": 2639, | |
| 131 | + "priceMax": 2639, | |
| 132 | + "currency": "USD", | |
| 133 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/c19cc5c8-d093-42d6-bd66-e813ef06128b" | |
| 134 | + }, | |
| 135 | + { | |
| 136 | + "name": "Fujifilm GFX 50R", | |
| 137 | + "url": "https://www.mpb.com/en-us/product/fujifilm-gfx-50r", | |
| 138 | + "slug": "fujifilm-gfx-50r", | |
| 139 | + "available": 10, | |
| 140 | + "priceMin": 2129, | |
| 141 | + "priceMax": 2489, | |
| 142 | + "currency": "USD", | |
| 143 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/681b2e48-7e65-41b0-97f0-4e4021d27ff7" | |
| 144 | + }, | |
| 145 | + { | |
| 146 | + "name": "Hasselblad X1D-50c 4116 Edition", | |
| 147 | + "url": "https://www.mpb.com/en-us/product/hasselblad-x1d-50c-4116-edition", | |
| 148 | + "slug": "hasselblad-x1d-50c-4116-edition", | |
| 149 | + "available": 3, | |
| 150 | + "priceMin": 2039, | |
| 151 | + "priceMax": 2139, | |
| 152 | + "currency": "USD", | |
| 153 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/6d7ced06-b62e-4372-8363-c8725c58e8a0" | |
| 154 | + }, | |
| 155 | + { | |
| 156 | + "name": "Hasselblad X1D-50c", | |
| 157 | + "url": "https://www.mpb.com/en-us/product/hasselblad-x1d-50c", | |
| 158 | + "slug": "hasselblad-x1d-50c", | |
| 159 | + "available": 4, | |
| 160 | + "priceMin": 1619, | |
| 161 | + "priceMax": 2059, | |
| 162 | + "currency": "USD", | |
| 163 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/d6289062-30f1-4003-be86-726353216621" | |
| 164 | + }, | |
| 165 | + { | |
| 166 | + "name": "Fujifilm GFX 50S", | |
| 167 | + "url": "https://www.mpb.com/en-us/product/fujifilm-gfx-50s", | |
| 168 | + "slug": "fujifilm-gfx-50s", | |
| 169 | + "available": 10, | |
| 170 | + "priceMin": 1729, | |
| 171 | + "priceMax": 1969, | |
| 172 | + "currency": "USD", | |
| 173 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/524e2f26-3bfe-4398-b515-07be5b57890c" | |
| 174 | + }, | |
| 175 | + { | |
| 176 | + "name": "Leica S2", | |
| 177 | + "url": "https://www.mpb.com/en-us/product/leica-s2", | |
| 178 | + "slug": "leica-s2", | |
| 179 | + "available": 1, | |
| 180 | + "priceMin": 799, | |
| 181 | + "priceMax": 799, | |
| 182 | + "currency": "USD", | |
| 183 | + "image": "https://www.mpb.com/media-service-img-cdn/width=286,quality=90,format=jpeg/media-service/388c254e-cdf4-4240-8f6b-1a737e8f1308" | |
| 184 | + } | |
| 185 | + ] | |
| 186 | + } | |
| 187 | + }, | |
| 188 | + "expect": { | |
| 189 | + "minCount": 1 | |
| 190 | + }, | |
| 191 | + "note": "Captured live from www.mpb.com on 2026-09-07 (trimmed to 8 items)", | |
| 192 | + "capturedAt": "2026-09-07T07:13:32.693Z" | |
| 193 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/openlibrary/hp-philosophers-stone.json
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://openlibrary.org/isbn/0747532699.json", | |
| 4 | + "externalId": "isbn:0747532699", | |
| 5 | + "kind": "catalog_item", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:13:51.673Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "edition", | |
| 10 | + "isbn": "0747532699", | |
| 11 | + "note": "Harry Potter and the Philosopher's Stone, Bloomsbury 1997 first edition", | |
| 12 | + "edition": { | |
| 13 | + "key": "/books/OL25726767M", | |
| 14 | + "title": "Harry Potter and the Philosopher's Stone", | |
| 15 | + "subtitle": null, | |
| 16 | + "publishers": [ | |
| 17 | + "Bloomsbury Publishing" | |
| 18 | + ], | |
| 19 | + "publishDate": "1997", | |
| 20 | + "publishPlaces": [ | |
| 21 | + "London, England" | |
| 22 | + ], | |
| 23 | + "isbn10": [ | |
| 24 | + "0747532699" | |
| 25 | + ], | |
| 26 | + "isbn13": [ | |
| 27 | + "9780747532699" | |
| 28 | + ], | |
| 29 | + "pages": 223, | |
| 30 | + "editionName": null, | |
| 31 | + "covers": [ | |
| 32 | + 7355968 | |
| 33 | + ], | |
| 34 | + "physicalFormat": "Hardcover", | |
| 35 | + "workKey": "/works/OL82563W" | |
| 36 | + }, | |
| 37 | + "work": { | |
| 38 | + "key": "/works/OL82563W", | |
| 39 | + "title": "Harry Potter and the Philosopher's Stone", | |
| 40 | + "firstPublishDate": null, | |
| 41 | + "subjects": [ | |
| 42 | + "series:Harry_Potter", | |
| 43 | + "Ghosts", | |
| 44 | + "Monsters", | |
| 45 | + "Vampires", | |
| 46 | + "Witches", | |
| 47 | + "Challenges and Overcoming Obstacles", | |
| 48 | + "Magic and Supernatural", | |
| 49 | + "Cleverness", | |
| 50 | + "School Life", | |
| 51 | + "school stories", | |
| 52 | + "Wizards", | |
| 53 | + "Magic" | |
| 54 | + ] | |
| 55 | + }, | |
| 56 | + "authors": [ | |
| 57 | + "J. K. Rowling" | |
| 58 | + ] | |
| 59 | + } | |
| 60 | + }, | |
| 61 | + "expect": { | |
| 62 | + "minCount": 1 | |
| 63 | + }, | |
| 64 | + "note": "Captured live from openlibrary.org on 2026-09-07 (trimmed to 8 items)", | |
| 65 | + "capturedAt": "2026-09-07T07:13:53.038Z" | |
| 66 | +} | |
| \ No newline at end of file | ||
added
data/fixtures/reverb/stratocaster-p1.json
+167 −0
@@ -0,0 +1,167 @@ | ||
| 1 | +{ | |
| 2 | + "raw": { | |
| 3 | + "url": "https://api.reverb.com/api/listings?query=fender%20stratocaster%201960s&per_page=50&page=1", | |
| 4 | + "externalId": "q:fender stratocaster 1960s:1", | |
| 5 | + "kind": "listing", | |
| 6 | + "engine": "api", | |
| 7 | + "fetchedAt": "2026-09-07T07:13:17.189Z", | |
| 8 | + "payload": { | |
| 9 | + "kind": "listing_page", | |
| 10 | + "url": "https://api.reverb.com/api/listings?query=fender%20stratocaster%201960s&per_page=50&page=1", | |
| 11 | + "query": "fender stratocaster 1960s", | |
| 12 | + "page": 1, | |
| 13 | + "total": 440, | |
| 14 | + "listings": [ | |
| 15 | + { | |
| 16 | + "id": 100554385, | |
| 17 | + "make": "Fender Stratocaster 1960's upgrade with classic 1950's wiring and a vintage CRL Centralab ceramic 0.05uF capacitor", | |
| 18 | + "model": "Fender Stratocaster 1960's upgrade with classic 1950's wiring and a vintage CRL Centralab ceramic 0.05uF capacitor", | |
| 19 | + "finish": "", | |
| 20 | + "year": "New", | |
| 21 | + "title": "Fender Stratocaster 1960's upgrade with classic 1950's wiring and a vintage CRL Centralab ceramic 0.05uF capacitor", | |
| 22 | + "condition": "Excellent", | |
| 23 | + "price": 75, | |
| 24 | + "currency": "USD", | |
| 25 | + "listingCurrency": "USD", | |
| 26 | + "categories": [ | |
| 27 | + "Electric Guitars / Solid Body" | |
| 28 | + ], | |
| 29 | + "state": "live", | |
| 30 | + "createdAt": "2026-08-15T07:41:46-05:00", | |
| 31 | + "publishedAt": "2026-08-15T07:46:42-05:00", | |
| 32 | + "shop": "Teddy's Tone Shop", | |
| 33 | + "offersEnabled": false, | |
| 34 | + "auction": false, | |
| 35 | + "photo": "https://rvb-img.reverb.com/i/s--GlianuLg--/quality=medium-low,height=800,width=800,fit=contain/09cef8be-6633-4e17-babc-e4969e0c132b.jpeg", | |
| 36 | + "webUrl": "https://reverb.com/item/100554385-fender-stratocaster-1960-s-upgrade-with-classic-1950-s-wiring-and-a-vintage-crl-centralab-ceramic-0-05uf-capacitor", | |
| 37 | + "description": "Premium upgrade Fender Stratocaster 1950's wiring harness (classic wiring). Wired to give bridge tone control. This premium wiring harness comes with the following components for that classic vintage tone: - (3) CTS 250k split short shaft audio taper potentiometers - CRL Centralab 0.05uF ceramic capacitor (tested at 0.047uF) - Oak Grisby 5-way blade switch - Switchcraft 1/4\" input jack (comes with 12 inches of Gavitt 22awg cloth push back wire) - Gavitt 22awg vintage cloth push back wire. All potentiometers are grounded together. *** Wiring diagram included. Custom wiring variations can be mad" | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "id": 97286990, | |
| 41 | + "make": "Fender", | |
| 42 | + "model": "Custom shop 1960s Stratocaster", | |
| 43 | + "finish": "Sunburst Relic", | |
| 44 | + "year": "2005", | |
| 45 | + "title": "Custom shop 1960s Fender Stratocaster 2005 - Sunburst Relic", | |
| 46 | + "condition": "Excellent", | |
| 47 | + "price": 3950, | |
| 48 | + "currency": "USD", | |
| 49 | + "listingCurrency": "USD", | |
| 50 | + "categories": [ | |
| 51 | + "Electric Guitars / Solid Body" | |
| 52 | + ], | |
| 53 | + "state": "live", | |
| 54 | + "createdAt": "2026-05-19T21:58:06-05:00", | |
| 55 | + "publishedAt": "2026-05-19T22:54:28-05:00", | |
| 56 | + "shop": "Michael's Shop", | |
| 57 | + "offersEnabled": true, | |
| 58 | + "auction": false, | |
| 59 | + "photo": "https://rvb-img.reverb.com/i/s--6lUZOas2--/quality=medium-low,height=800,width=800,fit=contain/c485ee44-6b62-475f-9b29-ce85878ebb61.jpg", | |
| 60 | + "webUrl": "https://reverb.com/item/97286990-custom-shop-1960s-fender-stratocaster-2005-sunburst-relic", | |
| 61 | + "description": "I bought this \" Custom shop 1960s Strat Sunburst Relic \" new in 2005 Kept this guitar is in perfect condition - it has always been taken care of. Electronics have been upgraded out of the shop (5 way switch , Volume pot + cap) Everything works great - She's got a fine Volume and Tone control and that 60s killer tone. Its hard to let her go, but holding on if she's actually not being used anymore does not make any sense at all. So some lucky One might enjoy that sound and versatility. Everything on the pictures is original and comes with the guitar off course, including the case. Body : Alder, " | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "id": 94693824, | |
| 65 | + "make": "Fender", | |
| 66 | + "model": "Master Built 1960s Strat HSS Heavy Reflic Dacota Red built by Dale Wilson", | |
| 67 | + "finish": "", | |
| 68 | + "year": "", | |
| 69 | + "title": "Fender Custom Shop Master Built 1960s Stratocaster HSS Heavy Reflic Dakota Red built by Dale Wilson [CZ529726] (02/27)", | |
| 70 | + "condition": "Good", | |
| 71 | + "price": 12464.77, | |
| 72 | + "currency": "USD", | |
| 73 | + "listingCurrency": "JPY", | |
| 74 | + "categories": [ | |
| 75 | + "Electric Guitars / Solid Body" | |
| 76 | + ], | |
| 77 | + "state": "live", | |
| 78 | + "createdAt": "2026-02-26T21:32:47-06:00", | |
| 79 | + "publishedAt": "2026-02-26T22:14:18-06:00", | |
| 80 | + "shop": "ISHIBASHI MUSIC", | |
| 81 | + "offersEnabled": true, | |
| 82 | + "auction": false, | |
| 83 | + "photo": "https://rvb-img.reverb.com/i/s--G0HKB7oK--/quality=medium-low,height=800,width=800,fit=contain/390617d1-5153-4143-a12b-641a5b22195e.jpg", | |
| 84 | + "webUrl": "https://reverb.com/item/94693824-fender-custom-shop-master-built-1960s-stratocaster-hss-heavy-reflic-dakota-red-built-by-dale-wilson-cz529726-02-27", | |
| 85 | + "description": "The '60s Stratocaster Heavy Relic by Master Builder Dale Wilson, renowned for his overwhelming popularity and skill within the Fender Custom Shop, has arrived. Dale Wilson is globally acclaimed for his exceptional relic techniques and deep understanding of vintage guitars, and is now considered one of the most sought-after builders. This instrument fully embodies his signature craftsmanship in texture, tonal balance, and meticulous detail. The body features traditional alder with a Dakota Red finish. Wilson's signature Heavy Relic treatment goes beyond simple scratches or paint chips, creating" | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + "id": 95737008, | |
| 89 | + "make": "Fender", | |
| 90 | + "model": "1960s Dual Mag II Stratocaster", | |
| 91 | + "finish": "Black", | |
| 92 | + "year": "-", | |
| 93 | + "title": "Fender Custom Shop 1960s Dual Mag II Stratocaster Relic Black w/Roasted Maple Neck & Lightweight Ash Body (Serial #CZ586546)", | |
| 94 | + "condition": "Brand New", | |
| 95 | + "price": 5550, | |
| 96 | + "currency": "USD", | |
| 97 | + "listingCurrency": "USD", | |
| 98 | + "categories": [ | |
| 99 | + "Electric Guitars / Solid Body" | |
| 100 | + ], | |
| 101 | + "state": "live", | |
| 102 | + "createdAt": "2026-04-07T14:02:50-05:00", | |
| 103 | + "publishedAt": "2026-04-07T18:13:18-05:00", | |
| 104 | + "shop": "Chicago Music Exchange", | |
| 105 | + "offersEnabled": false, | |
| 106 | + "auction": false, | |
| 107 | + "photo": "https://rvb-img.reverb.com/i/s--3f9sgdw4--/quality=medium-low,height=800,width=800,fit=contain/07617046-2f08-4a4a-8efe-56d18d3ed859.jpg", | |
| 108 | + "webUrl": "https://reverb.com/item/95737008-fender-custom-shop-1960s-dual-mag-ii-stratocaster-relic-black-w-roasted-maple-neck-lightweight-ash-body-serial-cz586546", | |
| 109 | + "description": "Since 1987, the Fender Custom Shop—affectionately known at CME as “The Dream Factory”—has grown to a band of over 50 veteran guitar artisans who take Leo Fender’s legacy to its utmost heights by bringing dream guitars and basses to life. Taking an amp builder’s sensibility to instrument design, Leo Fender’s modular approach emphasized swappable components. This foundational design philosophy portended a future that we now inhabit, in which it’s not only easy to replace individual parts and maintain the longevity of each instrument—but, players also have the ability to customize every component" | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "id": 84123467, | |
| 113 | + "make": "Fender", | |
| 114 | + "model": "1960’s Vintage Player Stratocaster", | |
| 115 | + "finish": "Aztec Gold", | |
| 116 | + "year": "Early 2000’s", | |
| 117 | + "title": "Hendrix Style lefty Rare Fender 1960’s Vintage Player Stratocaster Early 2000’s - Aztec Gold", | |
| 118 | + "condition": "Good", | |
| 119 | + "price": 1800, | |
| 120 | + "currency": "USD", | |
| 121 | + "listingCurrency": "USD", | |
| 122 | + "categories": [ | |
| 123 | + "Electric Guitars / Solid Body" | |
| 124 | + ], | |
| 125 | + "state": "live", | |
| 126 | + "createdAt": "2024-08-08T15:50:08-05:00", | |
| 127 | + "publishedAt": "2024-08-08T15:52:11-05:00", | |
| 128 | + "shop": "Squidward’s Music Store", | |
| 129 | + "offersEnabled": true, | |
| 130 | + "auction": false, | |
| 131 | + "photo": "https://rvb-img.reverb.com/i/s--PSgrTWcW--/quality=medium-low,height=800,width=800,fit=contain/hqg0vfrcxtkylmmv4lng.jpg", | |
| 132 | + "webUrl": "https://reverb.com/item/84123467-hendrix-style-lefty-rare-fender-1960-s-vintage-player-stratocaster-early-2000-s-aztec-gold", | |
| 133 | + "description": "Rare 1960’s Vintage Player Stratocaster Aztec Gold Strung Hendrix Lefty I can set it up righty if you want, let me know \"Unique strat, around 2001 Fender made this limited series of stratocasters in Mexico after a design by masterbilder J.W. Black, equipped with American parts, including the Texas Special pick-ups. A kind of custom shop from Mexico. This example has minor signs of wear but is technically fine. and super playable, including Fender gig bag. Rosewood C neck 9.5''\"' fretboard radius 21 medium-jumbo frets for faster playing and easier bending Limited Edition decal USA Texas Special" | |
| 134 | + }, | |
| 135 | + { | |
| 136 | + "id": 101045609, | |
| 137 | + "make": "Fender", | |
| 138 | + "model": "Custom Shop Limited Edition 1960's DUALMAG II Stratocaster - Journeyman Relic", | |
| 139 | + "finish": "Shell Pink", | |
| 140 | + "year": "2026", | |
| 141 | + "title": "NEW ! Fender Custom Shop Limited Edition 1960's DUAL MAG II Stratocaster - Journeyman Relic - Aged Shell Pink Finish - AAA Rosewood Board - Authorized Dealer - 7.8lbs - G07633", | |
| 142 | + "condition": "Brand New", | |
| 143 | + "price": 6050, | |
| 144 | + "currency": "USD", | |
| 145 | + "listingCurrency": "USD", | |
| 146 | + "categories": [ | |
| 147 | + "Electric Guitars / Solid Body" | |
| 148 | + ], | |
| 149 | + "state": "live", | |
| 150 | + "createdAt": "2026-08-26T12:46:15-05:00", | |
| 151 | + "publishedAt": "2026-08-26T12:47:44-05:00", | |
| 152 | + "shop": "Bizarre Guitar", | |
| 153 | + "offersEnabled": true, | |
| 154 | + "auction": false, | |
| 155 | + "photo": "https://rvb-img.reverb.com/i/s--ocwHzCzm--/quality=medium-low,height=800,width=800,fit=contain/bc8465ca-c8db-44cd-a89b-ad01c1e5384a.jpg", | |
| 156 | + "webUrl": "https://reverb.com/item/101045609-new-fender-custom-shop-limited-edition-1960-s-dual-mag-ii-stratocaster-journeyman-relic-aged-shell-pink-finish-aaa-rosewood-board-authorized-dealer-7-8lbs-g07633", | |
| 157 | + "description": "NEW! Fender Custom Shop Limited Edition 60's Stratocaster Journeyman Relic - Aged Shell Pink Finish with AAA Rosewood Fingerboard - 7.8 lbs Serial # CZ590911 • Product #9231017091 We will BEAT any Authorized Fender Dealer's Price! If you see one for less, please let us know! LIMITED EDITION 60S DUALMAG II COMPOUND RADIUS STRAT® - RELIC® -Lacquer Finish; Lightweight 2-Piece Select Alder Body; Roasted Quarter Sawn Maple Neck w/ Flat Lam Dark Rosewood Fretboard & 1960 Style Oval \"\"C\"\" Back-Shape; 7.25\"\" to 9.5\"\" Compound Radius; 21 Narrow Tall (6105) Frets; Custom Shop Hand-Wound Dual M" | |
| 158 | + } | |
| 159 | + ] | |
| 160 | + } | |
| 161 | + }, | |
| 162 | + "expect": { | |
| 163 | + "minCount": 1 | |
| 164 | + }, | |
| 165 | + "note": "Captured live from api.reverb.com on 2026-09-07 (trimmed to 6 items)", | |
| 166 | + "capturedAt": "2026-09-07T07:13:17.203Z" | |
| 167 | +} | |
| \ No newline at end of file | ||
| 168 | ||