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, isoDateOnly, posNumber, refineCategory, type KeywordSeed } from '../_g9-asia-watch-sneaker-lib/index.js'; /** * Yahoo! Shopping Japan — itemSearch V3 (Yahoo! JAPAN Web API) — GATED: needs a Yahoo! JAPAN application id * (YAHOO_JP_APP_ID). JPY asking prices of Yahoo! Shopping store items → `listing`, with JAN codes when the store * publishes them (identifiers.jan) and the store's new/used condition flag. * * GET https://shopping.yahooapis.jp/ShoppingWebService/V3/itemSearch?appid=…&query=…&results=50&start=N[&condition=used|new] * → { totalResultsAvailable, totalResultsReturned, firstResultPosition, hits: [{ name, url, code, condition, price, janCode, brand, seller, genreCategory, releaseDate, image, inStock, … }] } */ const ENDPOINT = 'https://shopping.yahooapis.jp/ShoppingWebService/V3/itemSearch'; const PARSER_VERSION = '1.0.0'; const RESULTS = 50; export const HitSchema = z.object({ code: z.string(), name: z.string(), url: z.string(), price: z.number(), condition: z.string().nullable(), inStock: z.boolean().nullable(), janCode: z.string().nullable(), brand: z.string().nullable(), sellerId: z.string().nullable(), sellerName: z.string().nullable(), genreCategory: z.string().nullable(), genreCategoryId: z.number().nullable(), releaseDate: z.string().nullable(), image: z.string().nullable(), description: z.string().nullable(), headLine: z.string().nullable(), reviewCount: z.number().nullable(), }); export type Hit = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('search_page'), seed: KeywordSeedSchema, start: z.number(), total: z.number().nullable(), hits: z.array(HitSchema) }); export type PagePayload = z.infer; const ResponseSchema = z.object({ totalResultsAvailable: z.number().optional(), totalResultsReturned: z.number().optional(), firstResultPosition: z.number().optional(), hits: z.array(z.record(z.string(), z.unknown())).default([]) }); export function toHit(raw: Record): Hit | null { const code = typeof raw.code === 'string' ? raw.code : null; const name = typeof raw.name === 'string' ? raw.name : null; const url = typeof raw.url === 'string' ? raw.url : null; const price = posNumber(raw.price); if (!code || !name || !url || price === null) return null; const brand = raw.brand as { name?: unknown } | undefined; const seller = raw.seller as { sellerId?: unknown; name?: unknown } | undefined; const genre = raw.genreCategory as { id?: unknown; name?: unknown } | undefined; const image = raw.image as { medium?: unknown; small?: unknown } | undefined; const exImage = raw.exImage as { url?: unknown } | undefined; const review = raw.review as { count?: unknown } | undefined; return { code, name, url, price, condition: typeof raw.condition === 'string' ? raw.condition : null, inStock: typeof raw.inStock === 'boolean' ? raw.inStock : null, janCode: typeof raw.janCode === 'string' && /^\d{8,14}$/.test(raw.janCode) ? raw.janCode : null, brand: typeof brand?.name === 'string' && brand.name ? brand.name : null, sellerId: typeof seller?.sellerId === 'string' ? seller.sellerId : null, sellerName: typeof seller?.name === 'string' ? seller.name : null, genreCategory: typeof genre?.name === 'string' ? genre.name : null, genreCategoryId: intOrNull(genre?.id), releaseDate: typeof raw.releaseDate === 'string' && raw.releaseDate ? raw.releaseDate : null, image: typeof exImage?.url === 'string' ? exImage.url : typeof image?.medium === 'string' ? image.medium : null, description: typeof raw.description === 'string' && raw.description ? raw.description.slice(0, 1500) : null, headLine: typeof raw.headLine === 'string' && raw.headLine ? raw.headLine : null, reviewCount: intOrNull(review?.count), }; } export function buildUrl(params: { appId: string; query: string; start: number; condition?: string | null; sort?: string; genreCategoryId?: string | null }): string { const q = new URLSearchParams({ appid: params.appId, query: params.query, results: String(RESULTS), start: String(params.start), image_size: '600', sort: params.sort ?? '-score' }); if (params.condition) q.set('condition', params.condition); if (params.genreCategoryId) q.set('genre_category_id', params.genreCategoryId); return `${ENDPOINT}?${q.toString()}`; } export function redact(url: string): string { return url.replace(/appid=[^&]+/g, 'appid=***'); } const SeedSchema = KeywordSeedSchema.extend({ condition: z.enum(['new', 'used']).nullable().default(null) }); type Seed = z.infer; export class YahooShoppingJpConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1100; private seeds(ctx: CrawlContext): Seed[] { if (ctx.options.seeds?.length) return ctx.options.seeds.map((q) => SeedSchema.parse({ q, category: String(this.meta.config.defaultCategory ?? 'trading_cards') })); const seeds = z.array(SeedSchema).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.YAHOO_JP_APP_ID?.trim(); if (!appId) { ctx.anomaly('missing_requirement', 'YAHOO_JP_APP_ID is not set'); return; } const seeds = this.seeds(ctx); // start + results must stay ≤ 1000 per the API → at most 20 pages of 50 const pages = Math.min(20, ctx.options.mode === 'backfill' ? this.policy.backfillMaxPages : 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 start = (page - 1) * RESULTS + 1; const url = buildUrl({ appId, query: seed.q, start, condition: seed.condition, sort: String(this.meta.config.sort ?? '-score') }); 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.hits.map(toHit).find(Boolean) : null; return first ? { title: first.name, price: first.price } : parsed.success && parsed.data.hits.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 hits = parsed.data.hits.map(toHit).filter((x): x is Hit => Boolean(x)); if (!hits.length) break; const payload: PagePayload = { kind: 'search_page', seed, start, total: parsed.data.totalResultsAvailable ?? null, hits }; count++; yield { url: redact(url), externalId: `search:${seed.q}${seed.condition ? `:${seed.condition}` : ''}:${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.total ? Math.min(pages, Math.ceil(payload.total / RESULTS)) : null, itemsProcessed: count }); if (payload.total !== null && start - 1 + hits.length >= payload.total) 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 h of p.hits) { if (seen.has(h.code)) continue; seen.add(h.code); const title = cleanTitle(h.name); const categorySlug = refineCategory(p.seed.category, `${title} ${h.genreCategory ?? ''}`); const conditionRaw = h.condition === 'used' ? 'Used' : h.condition === 'new' ? 'New' : cjkConditionRaw(title); out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: h.url, externalId: h.code, rawTitle: h.name, description: h.description ?? h.headLine, imageUrls: h.image ? [h.image] : [], attributes: AssetAttributesSchema.parse({ categorySlug, brand: h.brand, name: title, language: p.seed.language ?? cjkLanguage(title), country: 'JP', identifiers: { yahoo_shopping_code: h.code, ...(h.janCode ? { jan: h.janCode, ean: h.janCode } : {}) }, metadata: { genre_category: h.genreCategory, genre_category_id: h.genreCategoryId, store_id: h.sellerId, release_date: h.releaseDate, review_count: h.reviewCount, 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: h.janCode ? 0.85 : 0.7, parserVersion: PARSER_VERSION, listingType: 'fixed_price', price: h.price, currency: 'JPY', seller: h.sellerName, location: 'Japan', quantity: null, listedAt: isoDateOnly(h.releaseDate?.replace(/\//g, '-') ?? null) ? null : null, endsAt: null, availability: h.inStock === false ? 'ended' : h.inStock === true ? 'available' : 'unknown', bidCount: null, }), ); } return out; } } export default function createConnector(meta: ConnectorMeta) { return new YahooShoppingJpConnector(meta); }