import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { normalizeCondition } from '@rareindex/taxonomy'; import { AssetAttributesSchema, NormalizedListingSchema, type NormalizedRecord } from '@rareindex/shared'; import { KeywordSeedSchema, cjkConditionRaw, cjkGrade, cjkLanguage, cleanTitle, intOrNull, isCjkBundle, parseJstDateTime, posNumber, refineCategory, type KeywordSeed } from '../_g9-asia-watch-sneaker-lib/index.js'; /** * Rakuten Ichiba Item Search API (Rakuten Web Service) — GATED: needs RAKUTEN_APP_ID (+ RAKUTEN_ACCESS_KEY for * applications created since the 2026-07-01 API version). JPY asking prices of Rakuten Ichiba shop items → `listing`. * * Endpoint (2026-07-01): https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20260701?applicationId=…&accessKey=… * Legacy (2022-06-01): https://app.rakuten.co.jp/services/api/IchibaItem/Search/20220601?applicationId=… * Both return { count, page, first, last, hits, pageCount, Items: [...] }. With formatVersion=2 each element is the * item object itself and image lists are plain URL arrays; with formatVersion=1 elements are wrapped as { Item: {…} } * and images as [{ imageUrl }]. Both shapes are accepted. */ const ENDPOINT_2026 = 'https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20260701'; const ENDPOINT_LEGACY = 'https://app.rakuten.co.jp/services/api/IchibaItem/Search/20220601'; const PARSER_VERSION = '1.0.0'; const HITS = 30; export const ItemSchema = z.object({ itemCode: z.string(), itemName: z.string(), itemPrice: z.number(), itemUrl: z.string(), itemCaption: z.string().nullable(), images: z.array(z.string()), shopName: z.string().nullable(), shopCode: z.string().nullable(), genreId: z.string().nullable(), /** 1 = in stock, 0 = out of stock */ availability: z.number().nullable(), /** "2026/09/01 10:00" sale window when the shop set one */ startTime: z.string().nullable(), endTime: z.string().nullable(), reviewCount: z.number().nullable(), reviewAverage: z.number().nullable(), taxFlag: z.number().nullable(), }); export type Item = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), seed: KeywordSeedSchema, page: z.number(), count: z.number().nullable(), pageCount: z.number().nullable(), items: z.array(ItemSchema) }); export type PagePayload = z.infer; type RawItem = Record; const ResponseSchema = z.object({ count: z.number().optional(), page: z.number().optional(), pageCount: z.number().optional(), hits: z.number().optional(), Items: z.array(z.unknown()).default([]) }); function imageList(v: unknown): string[] { if (!Array.isArray(v)) return []; return v.map((x) => (typeof x === 'string' ? x : x && typeof x === 'object' && typeof (x as { imageUrl?: unknown }).imageUrl === 'string' ? (x as { imageUrl: string }).imageUrl : null)).filter((s): s is string => Boolean(s)).map((s) => s.replace(/\?_ex=\d+x\d+$/, '')); } /** Accepts formatVersion 1 ({ Item: {…} }) and 2 (flat) elements. */ export function toItem(el: unknown): Item | null { if (!el || typeof el !== 'object') return null; const raw = ('Item' in (el as RawItem) && typeof (el as RawItem).Item === 'object' ? (el as { Item: RawItem }).Item : (el as RawItem)) ?? {}; const code = typeof raw.itemCode === 'string' ? raw.itemCode : null; const name = typeof raw.itemName === 'string' ? raw.itemName : null; const price = posNumber(raw.itemPrice); const url = typeof raw.itemUrl === 'string' ? raw.itemUrl : null; if (!code || !name || price === null || !url) return null; const images = imageList(raw.mediumImageUrls).length ? imageList(raw.mediumImageUrls) : imageList(raw.smallImageUrls); return { itemCode: code, itemName: name, itemPrice: price, itemUrl: url, itemCaption: typeof raw.itemCaption === 'string' && raw.itemCaption ? raw.itemCaption.slice(0, 1500) : null, images: images.slice(0, 4), shopName: typeof raw.shopName === 'string' ? raw.shopName : null, shopCode: typeof raw.shopCode === 'string' ? raw.shopCode : null, genreId: raw.genreId === undefined || raw.genreId === null ? null : String(raw.genreId), availability: intOrNull(raw.availability), startTime: typeof raw.startTime === 'string' && raw.startTime ? raw.startTime : null, endTime: typeof raw.endTime === 'string' && raw.endTime ? raw.endTime : null, reviewCount: intOrNull(raw.reviewCount), reviewAverage: typeof raw.reviewAverage === 'number' ? raw.reviewAverage : posNumber(raw.reviewAverage), taxFlag: intOrNull(raw.taxFlag), }; } export function buildUrl(params: { appId: string; accessKey?: string | null; keyword: string; page: number; genreId?: string | null; sort?: string; legacy?: boolean }): string { const base = params.legacy || !params.accessKey ? ENDPOINT_LEGACY : ENDPOINT_2026; const q = new URLSearchParams({ applicationId: params.appId, format: 'json', formatVersion: '2', keyword: params.keyword, hits: String(HITS), page: String(params.page), sort: params.sort ?? '-updateTimestamp', imageFlag: '1' }); if (params.accessKey && !params.legacy) q.set('accessKey', params.accessKey); if (params.genreId) q.set('genreId', params.genreId); return `${base}?${q.toString()}`; } /** Strip the credentials before storing a URL. */ export function redact(url: string): string { return url.replace(/(applicationId|accessKey)=[^&]+/g, '$1=***'); } export class RakutenIchibaConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1100; private seeds(ctx: CrawlContext): KeywordSeed[] { if (ctx.options.seeds?.length) return ctx.options.seeds.map((q) => KeywordSeedSchema.parse({ q, category: String(this.meta.config.defaultCategory ?? 'trading_cards') })); const seeds = z.array(KeywordSeedSchema).parse(this.meta.config.seeds ?? []); const filter = ctx.options.categories; return filter?.length ? seeds.filter((s) => filter.includes(s.category)) : seeds; } async *crawl(ctx: CrawlContext): AsyncIterable { const appId = process.env.RAKUTEN_APP_ID?.trim(); if (!appId) { ctx.anomaly('missing_requirement', 'RAKUTEN_APP_ID is not set'); return; } const accessKey = process.env.RAKUTEN_ACCESS_KEY?.trim() || null; const legacy = Boolean(this.meta.config.legacyEndpoint ?? !accessKey); const seeds = this.seeds(ctx); const pages = ctx.options.mode === 'backfill' ? Math.min(this.policy.backfillMaxPages, 100) : Number(this.meta.config.pagesPerSeed ?? 2); const cur = (ctx.options.cursor ?? {}) as { seedIndex?: number; page?: number }; let count = 0; for (let si = cur.seedIndex ?? 0; si < seeds.length; si++) { const seed = seeds[si]!; let page = si === (cur.seedIndex ?? 0) && cur.page ? cur.page : 1; for (; page <= pages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = buildUrl({ appId, accessKey, keyword: seed.q, page, sort: String(this.meta.config.sort ?? '-updateTimestamp'), legacy }); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', expect: ['title', 'price'], parse: (r) => { const parsed = ResponseSchema.safeParse(r.json); const first = parsed.success ? parsed.data.Items.map(toItem).find(Boolean) : null; return first ? { title: first.itemName, price: first.itemPrice } : parsed.success && parsed.data.Items.length === 0 ? { title: 'empty', price: 1 } : null; } }); const parsed = ResponseSchema.safeParse(res.json); if (!res.success || !parsed.success) { ctx.anomaly(res.httpStatus === 429 ? 'rate_limited' : res.success ? 'schema_drift' : 'page_fetch_failed', `${redact(url)}: ${res.error ?? res.httpStatus}`); break; } const items = parsed.data.Items.map(toItem).filter((x): x is Item => Boolean(x)); if (!items.length) break; const payload: PagePayload = { kind: 'search_page', seed, page, count: parsed.data.count ?? null, pageCount: parsed.data.pageCount ?? null, items }; count++; yield { url: redact(url), externalId: `search:${seed.q}:${page}`, kind: 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ seedIndex: si, page: page + 1, at: new Date().toISOString() }); await ctx.progress({ page, totalPages: payload.pageCount, itemsProcessed: count }); if (payload.pageCount !== null && page >= payload.pageCount) break; } await ctx.setCursor({ seedIndex: si + 1, at: new Date().toISOString() }); } await ctx.setCursor({ done: true, at: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; const seen = new Set(); for (const it of p.items) { if (seen.has(it.itemCode)) continue; seen.add(it.itemCode); const title = cleanTitle(it.itemName); const categorySlug = refineCategory(p.seed.category, title); const conditionRaw = cjkConditionRaw(`${title} ${it.itemCaption ?? ''}`); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: it.itemUrl.split('?')[0]!, externalId: it.itemCode, rawTitle: it.itemName, description: it.itemCaption, imageUrls: it.images, attributes: AssetAttributesSchema.parse({ categorySlug, name: title, language: p.seed.language ?? cjkLanguage(title), country: 'JP', identifiers: { rakuten_item_code: it.itemCode }, metadata: { rakuten_genre_id: it.genreId, shop_code: it.shopCode, review_count: it.reviewCount, review_average: it.reviewAverage, tax_included: it.taxFlag === 0 ? true : it.taxFlag === 1 ? false : null, is_bundle: isCjkBundle(title), seed_query: p.seed.q }, }), grade: { ...cjkGrade(title), certificationNumber: null }, condition: { condition: normalizeCondition(categorySlug, conditionRaw), conditionRaw, completeness: null }, observedAt: raw.fetchedAt, confidence: 0.7, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: it.itemPrice, currency: 'JPY', seller: it.shopName, location: 'Japan', quantity: null, listedAt: parseJstDateTime(it.startTime?.replace(/\//g, '-') ?? null), endsAt: parseJstDateTime(it.endTime?.replace(/\//g, '-') ?? null), availability: it.availability === 1 ? 'available' : it.availability === 0 ? 'ended' : 'unknown', bidCount: null, }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new RakutenIchibaConnector(meta); }