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, makeTitle } from '../_lib/shared.js'; import { HTML_HEADERS, JSON_HEADERS, cleanText, yen } from '../_g1-cards-eu-jp-lib/index.js'; /** * Hareruya (晴れる屋) — Japan's largest MTG shop. The public search page loads its results from a * JSON endpoint (unisearch_api); we read that endpoint per card set. One raw record per API page; * normalize emits one JPY listing per offer (product × language × condition × foil). */ const SITE = 'https://www.hareruyamtg.com'; const API = `${SITE}/en/products/search/unisearch_api`; const PARSER_VERSION = '1.0.0'; export const LANGUAGES: Record = { '1': 'Japanese', '2': 'English', '3': 'Chinese (Simplified)', '4': 'Chinese (Traditional)', '5': 'French', '6': 'German', '7': 'Italian', '8': 'Korean', '9': 'Russian', '10': 'Spanish', '11': 'Portuguese' }; const LANG_CODE: Record = { '1': 'JP', '2': 'EN', '3': 'CS', '4': 'CT', '5': 'FR', '6': 'DE', '7': 'IT', '8': 'KO', '9': 'RU', '10': 'ES', '11': 'PT' }; const str = z.union([z.string(), z.number()]).transform((v) => String(v)); export const DocSchema = z.object({ product: str, product_name: z.string().nullable().optional(), product_name_en: z.string().nullable().optional(), card_name: z.string().nullable().optional(), language: str.nullable().optional(), price: str.nullable().optional(), image_url: z.string().nullable().optional(), foil_flg: str.nullable().optional(), stock: str.nullable().optional(), weekly_sales: str.nullable().optional(), product_class: str.nullable().optional(), card_condition: str.nullable().optional(), sale_flg: str.nullable().optional(), high_price_code: str.nullable().optional(), }); export type HareruyaDoc = z.infer; const RawPayloadSchema = z.object({ cardset: z.object({ id: z.string(), name: z.string() }), page: z.number().int(), numFound: z.number().int().nullable(), docs: z.array(DocSchema) }); export type HareruyaPayload = z.infer; const ApiResponseSchema = z.object({ response: z.object({ numFound: z.number().int().optional(), page: z.union([z.number(), z.string()]).optional(), docs: z.array(z.unknown()).default([]) }) }); /** Card-set options of the public search form. */ export function parseCardsets(doc: string): Array<{ id: string; name: string }> { const $ = html.load(doc); const out: Array<{ id: string; name: string }> = []; $('select[name="cardset"] option').each((_, el) => { const id = $(el).attr('value')?.trim(); const name = cleanText($(el).text()); if (id && name) out.push({ id, name }); }); return out; } /** "(001)《Ugin, Eye of the Storms》[TDM]" → { number: "001", name: "Ugin, Eye of the Storms", setCode: "TDM" }; "《Black Lotus》[2ED] 茶R" keeps only those parts. */ export function parseProductName(s: string | null | undefined): { name: string | null; setCode: string | null; number: string | null } { if (!s) return { name: null, setCode: null, number: null }; const name = s.match(/《([^》]+)》/)?.[1]?.trim() ?? null; const setCode = s.match(/\[([A-Za-z0-9_+-]+)\]/)?.[1]?.trim() ?? null; const number = s.match(/^\s*\(([A-Za-z0-9]{1,4}[-/]?[A-Za-z0-9]{0,4})\)\s*《/)?.[1] ?? null; return { name, setCode, number }; } export function apiUrl(cardsetId: string, page: number, rows: number): string { return `${API}?fq.category_id=1&fq.cardset=${encodeURIComponent(cardsetId)}&fq.price=1%7E%2A&rows=${rows}&page=${page}`; } export class HareruyaConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 4000; async *crawl(ctx: CrawlContext): AsyncIterable { const rows = Number(this.meta.config.rows ?? 60); const backfill = ctx.options.mode === 'backfill'; const setsPerRun = backfill ? Infinity : Number(this.meta.config.setsPerRun ?? 12); const maxPages = Math.min(Number(this.meta.config.maxPagesPerSet ?? 67), Math.ceil(4000 / rows)); let cardsets = (this.meta.config.cardsets as Array<{ id: string; name: string }> | undefined) ?? []; if (!cardsets.length) { const formUrl = `${SITE}/en/products/search`; await this.throttle(formUrl); const form = await ctx.fetch(formUrl, { engines: ['api'], headers: HTML_HEADERS, responseType: 'text', minQuality: 0 }); if (!form.success || !form.html) throw new Error(`hareruya search form failed: ${form.error ?? form.httpStatus}`); cardsets = parseCardsets(form.html); if (!cardsets.length) throw new Error('hareruya: no cardset options found (selector_missing)'); } if (ctx.options.seeds?.length) cardsets = cardsets.filter((c) => ctx.options.seeds!.includes(c.id) || ctx.options.seeds!.includes(c.name)); cardsets = cardsets.slice(0, Number.isFinite(setsPerRun) ? setsPerRun : cardsets.length); let setIdx = Number(ctx.options.cursor?.setIdx ?? 0); let page = Number(ctx.options.cursor?.page ?? 1); let count = 0; for (; setIdx < cardsets.length; setIdx++, page = 1) { const cardset = cardsets[setIdx]!; for (; page <= maxPages; page++) { if (ctx.signal?.aborted) return; if (this.reached(ctx, count)) { await ctx.setCursor({ setIdx, page }); return; } const url = apiUrl(cardset.id, page, rows); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], headers: { ...JSON_HEADERS, referer: `${SITE}/en/products/search?cardset=${cardset.id}` }, minQuality: 0 }); const parsed = ApiResponseSchema.safeParse(res.json); if (!res.success || !parsed.success) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? 'unexpected response shape'}`); break; } const docs: HareruyaDoc[] = []; let bad = 0; for (const d of parsed.data.response.docs) { const p = DocSchema.safeParse(d); if (p.success) docs.push(p.data); else bad++; } if (bad) ctx.anomaly('schema_drift', `${url}: ${bad} docs failed DocSchema`); const numFound = parsed.data.response.numFound ?? null; if (!docs.length) { if (page === 1 && (numFound ?? 0) > 0) ctx.anomaly('parse_failure_page', url); break; } count++; const payload: HareruyaPayload = { cardset, page, numFound, docs }; yield { url, externalId: `${cardset.id}:p${page}`, kind: 'listing', engine: 'api', httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ setIdx, page: page + 1 }); const total = Math.min(numFound ?? 0, 4000); await ctx.progress({ page, totalPages: total ? Math.ceil(total / rows) : null, itemsProcessed: count }); if (numFound !== null && page * rows >= total) break; } } await ctx.setCursor({ setIdx: 0, page: 1, completedAt: new Date().toISOString(), done: true }); } async normalize(raw: RawRecordLike): Promise { const p = RawPayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; const observedAt = raw.fetchedAt; const seen = new Set(); for (const d of p.docs) { const price = yen(d.price); if (price === null) continue; const pn = parseProductName(d.product_name_en ?? d.product_name); const name = d.card_name?.trim() || pn.name; if (!name) continue; const langCode = d.language ?? null; const language = langCode ? (LANGUAGES[langCode] ?? null) : null; const foil = d.foil_flg === '1'; const externalId = `${d.product}:${d.product_class ?? `${langCode ?? 'x'}-${d.card_condition ?? 'x'}-${foil ? 'f' : 'n'}`}`; if (seen.has(externalId)) continue; seen.add(externalId); const stock = d.stock !== null && d.stock !== undefined && d.stock !== '' ? Number(d.stock) : null; const url = `${SITE}/en/products/detail/${d.product}?lang=${LANG_CODE[langCode ?? ''] ?? 'EN'}${d.high_price_code && d.high_price_code !== '0' && d.product_class ? `&class=${d.product_class}` : ''}`; const a = attrs({ categorySlug: 'magic_the_gathering', franchise: 'Magic: The Gathering', brand: 'Wizards of the Coast', set: p.cardset.name, setCode: pn.setCode, name, number: pn.number, variant: foil ? 'Foil' : null, language, identifiers: { hareruya_product_id: d.product, ...(d.product_class ? { hareruya_product_class: d.product_class } : {}) }, metadata: { hareruya_cardset_id: p.cardset.id, product_name_ja: d.product_name ?? null, condition_code: d.card_condition ?? null, weekly_sales: d.weekly_sales !== undefined && d.weekly_sales !== null ? Number(d.weekly_sales) : null, on_sale: d.sale_flg === '1', high_price_code: d.high_price_code && d.high_price_code !== '0' ? d.high_price_code : null }, }); const rawTitle = `${makeTitle({ name, set: p.cardset.name, number: pn.number, variant: foil ? 'Foil' : null })}${language ? ` (${language})` : ''}${d.card_condition ? ` · cond ${d.card_condition}` : ''}`; out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: url, externalId, rawTitle, imageUrls: d.image_url ? [d.image_url] : [], attributes: a, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, observedAt, confidence: 0.8, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price, currency: 'JPY', seller: 'Hareruya', location: 'JP', quantity: Number.isFinite(stock) ? stock : null, availability: stock === 0 ? 'ended' : 'available', }), ); } return out; } } export default (meta: ConnectorMeta) => new HareruyaConnector(meta);