import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { normalizeCondition } from '@rareindex/taxonomy'; import { AssetAttributesSchema, NormalizedListingSchema, NormalizedSaleSchema, 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'; /** * Yahoo! Auctions Japan (ヤフオク!) — live listings from the public keyword search (server-rendered `pageData` * JSON) and recently closed lots from the public closed search (Next.js `__NEXT_DATA__`). JPY native. * Live rows → `listing` (auction or fixed price), closed rows with ≥ 1 bid → `sale` (hammer, no buyer premium). * Historical closed results beyond the site's own window are covered by the `aucfree` connector (same * `yahoo_auction_id` identifier, so entity resolution merges them). */ const BASE = 'https://auctions.yahoo.co.jp'; const PARSER_VERSION = '1.0.0'; const PAGE_SIZE = 50; // Yahoo's default; the `n=` parameter is disallowed by robots.txt so we never send it. const SeedSchema = KeywordSeedSchema.extend({ auccat: z.string().nullable().default(null) }); type Seed = z.infer; export const LiveRowSchema = z.object({ id: z.string(), title: z.string(), categoryId: z.string().nullable(), /** current price (JPY) */ price: z.number(), /** buy-now price (JPY); null when none */ buyNow: z.number().nullable(), bids: z.number().nullable(), /** "2026-09-09 18:32:00" JST wall clock as published */ endTime: z.string().nullable(), image: z.string().nullable(), isFlea: z.boolean().nullable().default(null), isStore: z.boolean().nullable().default(null), startTime: z.string().nullable().default(null), isClosed: z.boolean().nullable().default(null), hasWinner: z.boolean().nullable().default(null), /** "未使用" (unused) badge shown on the card */ isUnused: z.boolean().nullable().default(null), categoryPath: z.array(z.string()).default([]), }); export type LiveRow = z.infer; export const ClosedRowSchema = z.object({ id: z.string(), title: z.string(), categoryId: z.string().nullable(), categoryPath: z.array(z.string()), /** final price (JPY) */ price: z.number(), buyNow: z.number().nullable(), bids: z.number(), /** ISO 8601 with offset, e.g. "2026-09-08T17:07:22+09:00" */ endTime: z.string(), image: z.string().nullable(), isFixedPrice: z.boolean().nullable(), itemCondition: z.string().nullable(), isFleamarketItem: z.boolean().nullable(), brandId: z.number().nullable(), }); export type ClosedRow = z.infer; export const PagePayloadSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('search_page'), url: z.string(), seed: SeedSchema, offset: z.number(), total: z.number().nullable(), rows: z.array(LiveRowSchema) }), z.object({ kind: z.literal('closed_page'), url: z.string(), seed: SeedSchema, offset: z.number(), total: z.number().nullable(), rows: z.array(ClosedRowSchema) }), z.object({ kind: z.literal('item_page'), url: z.string(), seed: SeedSchema, offset: z.number(), total: z.number().nullable(), rows: z.array(LiveRowSchema) }), ]); export type PagePayload = z.infer; export type ItemPagePayload = Extract; export type SearchPagePayload = Extract; export type ClosedPagePayload = Extract; interface PageDataItem { productID?: string; productName?: string; productCategoryID?: string; price?: string | number; winPrice?: string | number; bids?: string | number; endtime?: string; starttime?: string; isStore?: string; isClosed?: string; hasWinner?: string } function str(v: unknown): string | null { return v === null || v === undefined || v === '' ? null : String(v); } /** "1,100,000円" → 1100000 */ function yen(s: string | null | undefined): number | null { if (!s) return null; const n = Number(s.replace(/[^\d]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } /** * Live search / category page: one `li.Product` card per lot. The card anchors carry the id, title, category, * image, current price, buy-now price and end time (unix) as data attributes; the visible price block gives the * displayed 現在 (current) and 即決 (buy-now, tax included for store sellers) prices; `dd.Product__bid` the bid count. * The small `pageData` JSON (3 featured items) is used only to fill start times when present. */ export function parseSearchPage(htmlText: string, url: string, seed: Seed, offset: number): SearchPagePayload { const data = H.inlineJson(htmlText, 'pageData') as { items?: PageDataItem[] } | null; const featured = new Map(); for (const it of data?.items ?? []) if (it.productID) featured.set(String(it.productID), it); const $ = H.load(htmlText); const rows: LiveRow[] = []; const seen = new Set(); $('li.Product').each((_, li) => { const $li = $(li); const link = $li.find('a.Product__titleLink').first(); const imageLink = $li.find('a.Product__imageLink').first(); const bonus = $li.find('.Product__bonus').first(); const id = link.attr('data-auction-id') ?? imageLink.attr('data-auction-id') ?? link.attr('href')?.match(/\/auction\/([a-z]?\d+)/)?.[1] ?? null; const title = H.text(link) ?? imageLink.attr('data-auction-title') ?? null; if (!id || !title || seen.has(id)) return; let current: number | null = null; let buyNow: number | null = null; $li.find('.Product__price').each((__, p) => { const label = H.text($(p).find('.Product__label').first()) ?? ''; const value = yen(H.text($(p).find('.Product__priceValue').first())); if (/即決/.test(label)) buyNow = value; else if (/現在|落札/.test(label) || current === null) current = value; }); const price = current ?? yen(link.attr('data-auction-price') ?? imageLink.attr('data-auction-price')); if (price === null) return; if (buyNow === null) buyNow = yen(bonus.attr('data-auction-buynowprice')); const endUnix = bonus.attr('data-auction-endtime'); const feat = featured.get(id); const endTime = endUnix && /^\d+$/.test(endUnix) ? new Date(Number(endUnix) * 1000).toISOString() : str(feat?.endtime); const flea = imageLink.attr('data-auction-isflea') ?? link.attr('data-auction-isflea'); const catPath = (bonus.attr('data-auction-categoryidpath') ?? '').split(',').filter(Boolean); seen.add(id); rows.push({ id, title, categoryId: link.attr('data-auction-category') ?? imageLink.attr('data-auction-category') ?? null, price, buyNow, bids: intOrNull(H.text($li.find('dd.Product__bid').first())) ?? intOrNull(feat?.bids), endTime, image: imageLink.attr('data-auction-img') ?? $li.find('img.Product__imageData').attr('src') ?? null, isFlea: flea === undefined ? null : flea === '1', isStore: feat?.isStore === undefined ? null : feat.isStore === '1', startTime: str(feat?.starttime), isClosed: null, hasWinner: null, isUnused: $li.find('.Product__icon--unused').length > 0, categoryPath: catPath, }); }); const total = htmlText.match(/(\d[\d,]*)件/)?.[1] ?? null; return { kind: 'search_page', url, seed, offset, total: total ? Number(total.replace(/,/g, '')) : null, rows }; } /** Single item page: `pageData.items` is one object. */ export function parseItemPage(htmlText: string, url: string, seed: Seed): ItemPagePayload | null { const data = H.inlineJson(htmlText, 'pageData') as { items?: PageDataItem } | null; const it = data?.items; const id = str(it?.productID); const title = str(it?.productName); const price = posNumber(it?.price); if (!it || !id || !title || price === null) return null; const image = H.load(htmlText)('meta[property="og:image"]').attr('content') ?? null; const row: LiveRow = { id, title, categoryId: str(it.productCategoryID), price, buyNow: posNumber(it.winPrice), bids: intOrNull(it.bids), endTime: str(it.endtime), image, isFlea: null, isStore: it.isStore === undefined ? null : it.isStore === '1', startTime: str(it.starttime), isClosed: it.isClosed === undefined ? null : it.isClosed === '1', hasWinner: it.hasWinner === undefined ? null : it.hasWinner === '1', isUnused: null, categoryPath: [] }; return { kind: 'item_page', url, seed, offset: 1, total: 1, rows: [row] }; } interface ClosedItem { auctionId?: string; title?: string; price?: number; buyNowPrice?: number | null; bidCount?: number; endTime?: string; imageUrl?: string; isFixedPrice?: boolean; itemCondition?: string; isFleamarketItem?: boolean; brandId?: number | null; category?: { id?: number }; categoryPath?: Array<{ name?: string }> } function findKey(o: unknown, key: string, depth = 0): unknown { if (depth > 8 || !o || typeof o !== 'object') return undefined; if (key in (o as Record)) return (o as Record)[key]; for (const v of Object.values(o as Record)) { const r = findKey(v, key, depth + 1); if (r !== undefined) return r; } return undefined; } /** Closed search page: Next.js state → initialState.search.items.listing.items[] (+ totalResultsAvailable). */ export function parseClosedPage(htmlText: string, url: string, seed: Seed, offset: number): ClosedPagePayload { const next = H.nextData(htmlText) as { props?: { pageProps?: { initialState?: { search?: { items?: { listing?: { items?: ClosedItem[]; totalResultsAvailable?: number } } } } } } } | null; const listing = next?.props?.pageProps?.initialState?.search?.items?.listing; const items = listing?.items ?? ((findKey(next?.props?.pageProps, 'items') as ClosedItem[] | undefined) ?? []); const rows: ClosedRow[] = []; for (const it of Array.isArray(items) ? items : []) { const id = str(it.auctionId); const title = str(it.title); const price = posNumber(it.price); if (!id || !title || price === null || !it.endTime) continue; rows.push({ id, title, categoryId: it.category?.id !== undefined ? String(it.category.id) : null, categoryPath: (it.categoryPath ?? []).map((c) => c.name ?? '').filter(Boolean), price, buyNow: posNumber(it.buyNowPrice), bids: intOrNull(it.bidCount) ?? 0, endTime: it.endTime, image: it.imageUrl ?? null, isFixedPrice: it.isFixedPrice ?? null, itemCondition: it.itemCondition ?? null, isFleamarketItem: it.isFleamarketItem ?? null, brandId: it.brandId ?? null }); } const total = listing?.totalResultsAvailable ?? (findKey(next?.props?.pageProps, 'totalResultsAvailable') as number | undefined) ?? null; return { kind: 'closed_page', url, seed, offset, total: typeof total === 'number' ? total : null, rows }; } /** Only `p`, `auccat` and `b` are ever sent — robots.txt disallows `n=`, `mode=`, `s1=`, `o1=`, `min=`/`max=` filters. */ export function searchUrl(seed: Seed, offset: number): string { const q = `p=${encodeURIComponent(seed.q)}${seed.auccat ? `&auccat=${encodeURIComponent(seed.auccat)}` : ''}${offset > 1 ? `&b=${offset}` : ''}`; return `${BASE}/search/search?${q}`; } export function closedUrl(seed: Seed, offset: number): string { const q = `p=${encodeURIComponent(seed.q)}${seed.auccat ? `&auccat=${encodeURIComponent(seed.auccat)}` : ''}${offset > 1 ? `&b=${offset}` : ''}`; return `${BASE}/closedsearch/closedsearch?${q}`; } type Phase = 'live' | 'closed'; interface Cursor { seedIndex?: number; phase?: Phase; page?: number; done?: boolean } export class YahooAuctionsJpConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 4000; override readonly urlPatterns = [/^https?:\/\/(?:page\.|www\.)?auctions\.yahoo\.co\.jp\/jp\/auction\/([a-z]?\d+)/i]; 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; } private async fetchPage(ctx: CrawlContext, seed: Seed, phase: Phase, page: number): Promise<{ payload: PagePayload | null; res: Awaited>; url: string }> { const offset = (page - 1) * PAGE_SIZE + 1; const url = phase === 'live' ? searchUrl(seed, offset) : closedUrl(seed, offset); await this.throttle(url); const res = await ctx.fetch(url, { responseType: 'text', headers: { 'accept-language': 'ja,en;q=0.8' }, expect: ['title', 'price', 'date', 'status'], parse: (r) => { if (!r.html) return null; const p = phase === 'live' ? parseSearchPage(r.html, url, seed, offset) : parseClosedPage(r.html, url, seed, offset); const row = p.rows[0]; return row ? { title: row.title, price: row.price, date: row.endTime, status: phase } : p.total === 0 ? { title: 'empty', price: 1, date: 'none', status: 'empty' } : null; }, }); if (!res.success || !res.html) return { payload: null, res, url }; return { payload: phase === 'live' ? parseSearchPage(res.html, url, seed, offset) : parseClosedPage(res.html, url, seed, offset), res, url }; } async *crawl(ctx: CrawlContext): AsyncIterable { const seeds = this.seeds(ctx); const backfill = ctx.options.mode === 'backfill'; const livePages = Number(this.meta.config.pagesPerSeed ?? 2); const closedPages = backfill ? this.policy.backfillMaxPages : Number(this.meta.config.closedPagesPerSeed ?? 1); const includeClosed = backfill || Boolean(this.meta.config.includeClosed ?? true); const cur = (ctx.options.cursor ?? {}) as Cursor; if (cur.done && backfill) return; let count = 0; const startSeed = cur.seedIndex ?? 0; for (let si = startSeed; si < seeds.length; si++) { const seed = seeds[si]!; const phases: Phase[] = backfill ? ['closed'] : includeClosed ? ['live', 'closed'] : ['live']; for (const phase of phases) { if (si === startSeed && cur.phase && cur.phase !== phase && phases.indexOf(cur.phase) > phases.indexOf(phase)) continue; const maxPages = phase === 'live' ? livePages : closedPages; let page = si === startSeed && cur.phase === phase && cur.page ? cur.page : 1; for (; page <= maxPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const { payload, res, url } = await this.fetchPage(ctx, seed, phase, page); if (!payload) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } if (payload.rows.length === 0) { if (page === 1 && payload.total !== 0) ctx.anomaly('parse_failure_page', `${url}: no rows parsed (total=${payload.total})`); break; } count++; yield { url, externalId: `${phase}:${seed.q}${seed.auccat ? `@${seed.auccat}` : ''}:${page}`, kind: phase === 'live' ? 'listing' : 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; await ctx.setCursor({ seedIndex: si, phase, page: page + 1, at: new Date().toISOString() }); if (backfill) { const oldest = payload.kind === 'closed_page' ? payload.rows.map((r) => parseJstDateTime(r.endTime)).filter((d): d is Date => Boolean(d)).sort((a, b) => a.getTime() - b.getTime())[0] ?? null : null; await ctx.progress({ page, totalPages: payload.total ? Math.min(maxPages, Math.ceil(payload.total / PAGE_SIZE)) : null, itemsProcessed: count, reachedDate: oldest }); } if (payload.total !== null && (page - 1) * PAGE_SIZE + payload.rows.length >= payload.total) break; if (payload.rows.length < PAGE_SIZE) break; } } await ctx.setCursor({ seedIndex: si + 1, at: new Date().toISOString() }); } await ctx.setCursor({ done: true, at: new Date().toISOString() }); } async lookup(url: string, ctx: CrawlContext): Promise { const id = url.match(this.urlPatterns[0]!)?.[1]; if (!id) return []; const target = `${BASE}/jp/auction/${id}`; await this.throttle(target); const res = await ctx.fetch(target, { responseType: 'text', headers: { 'accept-language': 'ja,en;q=0.8' }, minQuality: 0.2 }); if (!res.success || !res.html) return []; const seed: Seed = { q: id, category: String(this.meta.config.defaultCategory ?? 'trading_cards'), language: null, auccat: null }; const payload = parseItemPage(res.html, target, seed); if (!payload) return []; const row = payload.rows[0]!; const sold = row.isClosed === true && row.hasWinner === true; return [{ url: target, externalId: `item:${id}`, kind: sold ? 'sale' : 'listing', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }]; } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; if (p.kind === 'closed_page') { for (const r of p.rows) { if (r.bids < 1) continue; // ended without a winning bid → not a transaction const saleDate = parseJstDateTime(r.endTime); if (!saleDate) continue; const title = cleanTitle(r.title); const categorySlug = refineCategory(p.seed.category, title); const conditionRaw = r.itemCondition === 'NEW' ? 'New' : r.itemCondition === 'USED' ? 'Used' : cjkConditionRaw(title); out.push( NormalizedSaleSchema.parse({ kind: 'sale', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${BASE}/jp/auction/${r.id}`, externalId: r.id, rawTitle: r.title, imageUrls: r.image ? [r.image] : [], attributes: this.attributes(categorySlug, title, r.id, p.seed, { yahoo_category_id: r.categoryId, category_path: r.categoryPath, buy_now_price: r.buyNow, bids: r.bids, flea_market: r.isFleamarketItem, 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, saleType: r.isFixedPrice ? 'fixed_price' : 'auction', saleDate, price: r.price, currency: 'JPY', buyerPremiumIncluded: false, quantity: 1, isBundle: isCjkBundle(title), location: 'Japan', auctionHouse: 'Yahoo! Auctions Japan', lotNumber: r.id, }), ); } return out; } const liveRows: LiveRow[] = p.rows; for (const r of liveRows) { const title = cleanTitle(r.title); const categorySlug = refineCategory(p.seed.category, title); const endsAt = parseJstDateTime(r.endTime); const fixed = r.buyNow !== null && r.buyNow === r.price && (r.bids ?? 0) === 0; const closed = r.isClosed === true; const conditionRaw = r.isUnused ? 'New' : cjkConditionRaw(title); if (closed && r.hasWinner === true && endsAt) { out.push( NormalizedSaleSchema.parse({ kind: 'sale', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${BASE}/jp/auction/${r.id}`, externalId: r.id, rawTitle: r.title, imageUrls: r.image ? [r.image] : [], attributes: this.attributes(categorySlug, title, r.id, p.seed, { yahoo_category_id: r.categoryId, buy_now_price: r.buyNow, bids: r.bids, 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, saleType: fixed ? 'fixed_price' : 'auction', saleDate: endsAt, price: r.price, currency: 'JPY', buyerPremiumIncluded: false, quantity: 1, isBundle: isCjkBundle(title), location: 'Japan', auctionHouse: 'Yahoo! Auctions Japan', lotNumber: r.id, }), ); continue; } out.push( NormalizedListingSchema.parse({ kind: 'listing', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: `${BASE}/jp/auction/${r.id}`, externalId: r.id, rawTitle: r.title, imageUrls: r.image ? [r.image] : [], attributes: this.attributes(categorySlug, title, r.id, p.seed, { yahoo_category_id: r.categoryId, category_id_path: r.categoryPath, buy_now_price: r.buyNow, flea_market: r.isFlea, store_seller: r.isStore, 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 ? 'fixed_price' : 'auction', price: r.price, currency: 'JPY', seller: null, location: 'Japan', quantity: 1, listedAt: parseJstDateTime(r.startTime), endsAt, availability: closed ? 'ended' : 'available', bidCount: r.bids, }), ); } return out; } private attributes(categorySlug: string, title: string, id: string, seed: KeywordSeed, metadata: Record) { return AssetAttributesSchema.parse({ categorySlug, name: title, language: seed.language ?? cjkLanguage(title), country: 'JP', identifiers: { yahoo_auction_id: id }, metadata, }); } } export default function createConnector(meta: ConnectorMeta) { return new YahooAuctionsJpConnector(meta); }