import { z } from 'zod'; import { BaseConnector, html, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { attrs } from '../_lib/shared.js'; import { HTML_HEADERS, JP_EXCLUDE_RE, cleanText, isJpBundle, jpCondition, jpVariant, jsonLdObjects, parseJpCardTitle, yen } from '../_g1-cards-eu-jp-lib/index.js'; /** * magi (magi.camp) — Japanese card marketplace. Series browse pages → item cards with title, JPY * price and SOLD marker; item pages carry a schema.org Product JSON-LD (lookup). Listings only. */ const SITE = 'https://magi.camp'; const PARSER_VERSION = '1.0.0'; const SeedSchema = z.object({ series: z.string(), categorySlug: z.string(), name: z.string().optional(), franchise: z.string().nullable().optional() }); export type MagiSeed = z.infer; export const ItemSchema = z.object({ id: z.string(), url: z.string(), title: z.string(), priceJpy: z.number().nullable(), sold: z.boolean(), image: z.string().nullable(), certifiedSeller: z.boolean(), favorites: z.number().int().nullable() }); export type MagiItem = z.infer; const ListPayloadSchema = z.object({ type: z.literal('list'), seed: SeedSchema, page: z.number().int(), items: z.array(ItemSchema) }); const ItemPayloadSchema = z.object({ type: z.literal('item'), seed: SeedSchema.nullable(), id: z.string(), url: z.string(), title: z.string(), description: z.string().nullable(), image: z.string().nullable(), priceJpy: z.number().nullable(), availability: z.string().nullable(), condition: z.string().nullable() }); const RawPayloadSchema = z.union([ListPayloadSchema, ItemPayloadSchema]); export type MagiPayload = z.infer; /** Parse a series browse page (/series//items?page=n). */ export function parseSeriesPage(doc: string): { items: MagiItem[]; hasNext: boolean; heading: string | null } { const $ = html.load(doc); const items: MagiItem[] = []; $('.item-list__box').each((_, el) => { const $el = $(el); const a = $el.find('a.item-list__link').first(); const href = a.attr('href'); const id = href?.match(/\/items\/(\d+)/)?.[1]; const title = cleanText($el.find('.item-list__item-name').first().text()); if (!href || !id || !title) return; const priceJpy = yen(cleanText($el.find('.item-list__price-box--price').first().text())); const sold = $el.find('.item-list__sold-icon').length > 0; const img = $el.find('img').first(); const image = img.attr('data-src') ?? (img.attr('src')?.includes('lazy-dummy') ? null : img.attr('src')) ?? null; const certifiedSeller = $el.find('.item-list__badge img[alt*="認定"]').length > 0; const fav = cleanText($el.find('.item-list__price-box--favorite-number').first().text()); items.push({ id, url: href.startsWith('http') ? href : `${SITE}${href}`, title, priceJpy, sold, image, certifiedSeller, favorites: fav ? Number(fav.replace(/\D/g, '')) || null : null }); }); const hasNext = $('a[href*="page="]').toArray().some((a) => /[?&]page=\d+/.test($(a).attr('href') ?? '') && /次|next|›|»/i.test($(a).text() + ($(a).attr('rel') ?? ''))) || $('a[rel="next"]').length > 0; const headings = $('h1').toArray().map((h) => cleanText($(h).text())).filter((t): t is string => Boolean(t)); const heading = headings.find((t) => /出品/.test(t)) ?? headings[0] ?? null; return { items, hasNext: hasNext || items.length >= 96, heading }; } /** Item page: schema.org Product JSON-LD + title. */ export function parseItemPage(doc: string, url: string): Omit, 'type' | 'seed'> | null { const ld = jsonLdObjects(doc, 'Product')[0] as { sku?: string; name?: string; description?: string; image?: string; offers?: { price?: string | number; availability?: string; itemCondition?: string } } | undefined; const id = url.match(/\/items\/(\d+)/)?.[1] ?? ld?.sku; if (!id) return null; const $ = html.load(doc); const title = cleanText(ld?.name) ?? cleanText($('.item-detail-title h2, h2[aria-label="商品名"]').first().text()) ?? cleanText($('title').first().text()?.split('|')[0]?.replace(/の通販.*$/, '')); if (!title) return null; const priceJpy = yen(ld?.offers?.price ?? cleanText($('.item-detail__price').first().text())); const soldOut = $('.item-sold-out, .item-badge--sold').length > 0; return { id, url, title, description: cleanText(ld?.description) ?? null, image: ld?.image ?? null, priceJpy, availability: ld?.offers?.availability ?? (soldOut ? 'https://schema.org/OutOfStock' : null), condition: ld?.offers?.itemCondition ?? null }; } export class MagiConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 4000; override readonly urlPatterns = [/^https?:\/\/(www\.)?magi\.camp\/items\/\d+/]; private seeds(): MagiSeed[] { return z.array(SeedSchema).parse(this.meta.config.seeds ?? []); } private async page(ctx: CrawlContext, url: string) { await this.throttle(url); return ctx.fetch(url, { engines: ['api'], headers: HTML_HEADERS, responseType: 'text', minQuality: 0 }); } async *crawl(ctx: CrawlContext): AsyncIterable { let seeds = this.seeds(); if (ctx.options.seeds?.length) seeds = seeds.filter((s) => ctx.options.seeds!.includes(s.series) || ctx.options.seeds!.includes(s.categorySlug)); const backfill = ctx.options.mode === 'backfill'; const maxPages = backfill ? this.policy.backfillMaxPages : Number(this.meta.config.pagesPerSeed ?? 3); let seedIdx = Number(ctx.options.cursor?.seedIdx ?? 0); let page = Number(ctx.options.cursor?.page ?? 1); let count = 0; for (; seedIdx < seeds.length; seedIdx++, page = 1) { const seed = seeds[seedIdx]!; for (; page <= maxPages; page++) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) { await ctx.setCursor({ seedIdx, page }); return; } const url = `${SITE}/series/${seed.series}/items${page > 1 ? `?page=${page}` : ''}`; const res = await this.page(ctx, url); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const { items, hasNext } = parseSeriesPage(res.html); if (!items.length) { if (page === 1) ctx.anomaly('parse_failure_page', `${url}: no .item-list__box`); break; } count++; const payload: MagiPayload = { type: 'list', seed, page, items }; yield { url, externalId: `series:${seed.series}:p${page}`, kind: 'listing', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ seedIdx, page: page + 1 }); await ctx.progress({ page, totalPages: null, itemsProcessed: count }); if (!hasNext) break; } } await ctx.setCursor({ seedIdx: 0, page: 1, completedAt: new Date().toISOString(), done: true }); } async lookup(url: string, ctx: CrawlContext): Promise { if (!this.urlPatterns.some((re) => re.test(url))) return []; const res = await this.page(ctx, url); if (!res.success || !res.html) return []; const item = parseItemPage(res.html, url); if (!item) return []; const $ = html.load(res.html); const seriesId = $('a[href^="/series/"]').first().attr('href')?.match(/\/series\/(\d+)/)?.[1]; const seed = this.seeds().find((s) => s.series === seriesId) ?? null; const payload: MagiPayload = { type: 'item', seed, ...item }; return [{ url, externalId: item.id, kind: 'listing', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; } private listing(seed: MagiSeed | null, it: { id: string; url: string; title: string; priceJpy: number | null; sold: boolean; image: string | null; certifiedSeller: boolean | null; favorites: number | null; description?: string | null }, observedAt: Date): NormalizedRecord | null { if (it.priceJpy === null) return null; if (JP_EXCLUDE_RE.test(it.title)) return null; const t = parseJpCardTitle(it.title); const categorySlug = seed?.categorySlug ?? 'trading_cards'; const language = t.language ?? (t.notes.some((n) => /英語/.test(n)) ? 'English' : 'Japanese'); const isBundle = isJpBundle(it.title, t.quantity); const a = attrs({ categorySlug, franchise: seed?.franchise ?? null, name: t.name, number: t.number, variant: jpVariant(t.notes, t.name), language, rarity: t.rarity, identifiers: { magi_item_id: it.id }, metadata: { total: t.total, notes: t.notes, quantity: t.quantity, certified_seller: it.certifiedSeller, favorites: it.favorites, series: seed?.series ?? null, sealed: t.sealed }, }); return NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: it.url, externalId: it.id, rawTitle: it.title, description: it.description ?? null, imageUrls: it.image ? [it.image] : [], attributes: a, grade: { grader: t.grader, grade: t.grade, qualifier: t.qualifier, certificationNumber: null }, condition: { condition: jpCondition(t.conditionRaw), conditionRaw: t.conditionRaw, completeness: t.sealed ? 'sealed' : null }, observedAt, confidence: t.grade ? 0.75 : 0.65, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: it.priceJpy, currency: 'JPY', seller: null, location: 'JP', quantity: isBundle ? t.quantity : 1, availability: it.sold ? 'sold' : 'available', }); } async normalize(raw: RawRecordLike): Promise { const p = RawPayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; if (p.type === 'list') { const seen = new Set(); for (const it of p.items) { if (seen.has(it.id)) continue; seen.add(it.id); const l = this.listing(p.seed, it, raw.fetchedAt); if (l) out.push(l); } return out; } const sold = /OutOfStock|SoldOut/i.test(p.availability ?? ''); const l = this.listing(p.seed, { id: p.id, url: p.url, title: p.title, priceJpy: p.priceJpy, sold, image: p.image, certifiedSeller: null, favorites: null, description: p.description }, raw.fetchedAt); if (l) out.push(l); return out; } } export default (meta: ConnectorMeta) => new MagiConnector(meta);