import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { CurrencyCode, ExtractionResult } from '@rareindex/shared'; import type { DeptHint } from '../_auction-lib/categories.js'; import { isSupportedCurrency, stripHtml } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, resolveCategory, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * Tokyo Chuo Auction (東京中央オークション — Tokyo, Hong Kong, Taipei) — bidding platform tcabid.com (BiddingArt). * The SPA reads an unauthenticated JSON API: /app_api/v1/auctions/finished_auctions_and_store_categories * (past sales) and /app_api/v1/auction_items?auction_category_auction_id_eq=&page=N&per_page=M (lots with * final_price, status hammered/unsold, currency per auction category, images). Chinese/Japanese titles. */ const API = 'https://tcabid.com/app_api/v1'; const WEB = 'https://tcabid.com'; type ApiAuction = { id: number; type?: string; title?: string; lot_count?: number; start_at?: string | null; end_at?: string | null; place?: string | null; auction_items_number_range?: string | null; cover?: string | { url?: string } | null }; type ApiItem = { id: number; number?: string | number | null; title?: string | null; desc?: string | null; final_price?: number | null; starting_price?: number | null; estimate_price_from?: number | null; estimate_price_to?: number | null; status?: string | null; auction_start_at?: string | null; transaction_time?: string | null; cover?: { url?: string } | null; images?: Array<{ url?: string }> | null; auction_category?: { id?: number; auction_id?: number; currency?: string | null; title?: string | null; place?: string | null } | null; show_items_final_price?: boolean | null; is_finished?: boolean | null }; export function parseFinishedAuctions(json: unknown): SaleRef[] { const arr = Array.isArray(json) ? (json as ApiAuction[]) : []; return arr .filter((a) => a && typeof a.id === 'number' && (a.type ?? 'Auction') === 'Auction') .map((a) => ({ id: String(a.id), title: String(a.title ?? `Auction ${a.id}`).trim(), url: `${WEB}/auctions/${a.id}`, date: a.start_at ?? null, location: locationOf(a.place), extra: { lot_count: a.lot_count ?? null, place: a.place ?? null, number_range: a.auction_items_number_range ?? null, end_at: a.end_at ?? null }, })); } /** "株式會社 東京中央拍賣(東京都…)" → "Tokyo, Japan"; "香港" → "Hong Kong"; Taipei → "Taipei, Taiwan". */ export function locationOf(place: string | null | undefined): string | null { if (!place) return null; if (/東京|Tokyo/i.test(place)) return 'Tokyo, Japan'; if (/香港|Hong ?Kong/i.test(place)) return 'Hong Kong'; if (/台北|臺北|台灣|臺灣|Taipei|Taiwan/i.test(place)) return 'Taipei, Taiwan'; if (/北京|Beijing/i.test(place)) return 'Beijing, China'; return place.length <= 60 ? place : null; } export function currencyOf(item: ApiItem): CurrencyCode | null { const c = item.auction_category?.currency?.toUpperCase() ?? null; return isSupportedCurrency(c) ? c : null; } /** JSON page of items → ParsedLot[]; `x-total-pages` (headers) is not exposed by the router, so hasMore = full page. */ export function parseItemsPage(json: unknown, sale: SaleRef, perPage: number): ParsedSalePage | null { const arr = Array.isArray(json) ? (json as ApiItem[]) : null; if (!arr) return null; const lots: ParsedLot[] = []; for (const it of arr) { if (!it || typeof it.id !== 'number') continue; const title = stripHtml(it.title ?? '', 300); const lotNo = it.number !== null && it.number !== undefined ? String(it.number) : null; if (!title || !lotNo) continue; const currency = currencyOf(it); const price = typeof it.final_price === 'number' && it.final_price > 0 ? it.final_price : null; const sold = it.status === 'hammered' && price !== null && it.show_items_final_price !== false; const images = [it.cover?.url, ...(it.images ?? []).map((i) => i?.url)].filter((u): u is string => typeof u === 'string' && u.length > 0); lots.push({ lotNo, title, subtitle: it.auction_category?.title ? stripHtml(it.auction_category.title, 120) : null, description: stripHtml(it.desc ?? null, 500), url: `${WEB}/auction_items/${it.id}`, image: images[0] ?? null, price: sold ? price : null, currency, premiumIncluded: false, estimateLow: typeof it.estimate_price_from === 'number' && it.estimate_price_from > 0 ? it.estimate_price_from : null, estimateHigh: typeof it.estimate_price_to === 'number' && it.estimate_price_to > 0 ? it.estimate_price_to : null, date: it.transaction_time ?? it.auction_start_at ?? null, sold, extra: { item_id: it.id, status: it.status ?? null, starting_price: it.starting_price ?? null, session: it.auction_category?.title ?? null, session_place: it.auction_category?.place ?? null, currency_raw: it.auction_category?.currency ?? null }, }); } return { lots, hasMore: arr.length >= perPage, totalLots: null }; } /** Session title / lot title (zh/ja) → department hint. */ export function hintFromAsianLabel(label: string): DeptHint { const l = label; if (/腕時計|手錶|腕錶|時計|Watch|ROLEX|勞力士|百達翡麗|PATEK|OMEGA|歐米茄/i.test(l)) return 'watches'; if (/珠寶|珠宝|翡翠|鑽石|钻石|寶石|宝石|Jewel/i.test(l)) return 'jewelry'; if (/威士忌|ウイスキー|白酒|高粱|茅台|葡萄酒|洋酒|Whisky|Wine|山崎|響|余市|軽井沢|輕井澤/i.test(l)) return 'wine'; if (/書畫|书画|繪畫|绘画|油畫|油画|水墨|Painting|Calligraphy|近現代|古代書畫|日本畫|洋畫|版畫|版画/i.test(l)) return 'art'; if (/瓷器|陶瓷|玉器|銅器|铜器|雜項|杂项|工藝|工艺|漆器|茶道具|煎茶|文房|佛像|Ceramic|Porcelain|Bronze|Jade|Works of Art|古董|骨董|茶器/i.test(l)) return 'asian'; if (/錢幣|钱币|古錢|紙幣|Coin|Banknote|金幣|銀幣/i.test(l)) return 'coins'; if (/郵票|邮票|Stamp/i.test(l)) return 'stamps'; if (/手袋|手提包|包袋|Hermès|HERMES|CHANEL|Handbag|Birkin|Kelly/i.test(l)) return 'handbags'; if (/古籍|善本|碑帖|書籍|Book|Manuscript/i.test(l)) return 'books'; if (/漫畫|漫画|動漫|手塚|Manga|Comic|Anime/i.test(l)) return 'comics'; if (/玩具|模型|公仔|Toy|Figure|Bearbrick|KAWS/i.test(l)) return 'toys'; if (/卡牌|卡片|Card|寶可夢|ポケモン|Pokemon|Pokémon/i.test(l)) return 'cards'; if (/相機|カメラ|Leica|徠卡|Camera/i.test(l)) return 'cameras'; if (/當代|当代|現代藝術|现代艺术|Contemporary|奈良美智|草間彌生|村上隆|Nara|Kusama|Murakami|Banksy|KAWS/i.test(l)) return 'contemporary'; return 'unknown'; } export class TokyoChuoConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Tokyo Chuo Auction', defaultCurrency: 'HKD', location: null, idKey: 'tokyo_chuo_item_id', premiumIncluded: false, fallbackSlug: 'antiques', responseType: 'json', minIntervalMs: 1500, maxPagesPerSale: 60, requireCurrency: true }; protected override minIntervalMs = 1500; private get perPage(): number { return Math.min(100, Math.max(10, Number(this.meta.config.perPage ?? 100))); } async listSales(ctx: CrawlContext): Promise { const url = `${API}/auctions/finished_auctions_and_store_categories?per_page=${Number(this.meta.config.auctionsListSize ?? 200)}`; await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', minQuality: 0, timeoutMs: 60_000 }); if (!res.success || !res.json) { ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`); return []; } return parseFinishedAuctions(res.json); } salePageUrl(sale: SaleRef, page: number): string { return `${API}/auction_items?auction_category_auction_id_eq=${sale.id}&per_page=${this.perPage}&page=${page}`; } parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null { return parseItemsPage(res.json, sale, this.perPage); } override categoryFor(sale: SaleRef, lot: ParsedLot): string | null { const label = `${String(lot.extra.session ?? '')} ${sale.title}`; const hint = hintFromAsianLabel(`${label} ${lot.title}`); if (hint === 'asian') return 'antiques'; if (hint === 'art') return /當代|当代|Contemporary|奈良美智|草間彌生|村上隆/i.test(label) ? 'contemporary_art' : 'art'; // Hand the English department word to the shared resolver (multilingual lot cues + keyword sweep). const en: Partial> = { watches: 'watches', jewelry: 'jewellery', wine: 'wine', coins: 'coins', stamps: 'stamps', handbags: 'handbags', books: 'books', comics: 'comics', toys: 'toys', cards: 'trading cards', cameras: 'cameras', contemporary: 'contemporary' }; return resolveCategory(en[hint] ?? '', lot, this.house.fallbackSlug); } protected override confidenceFor(_slug: string, lot: ParsedLot): number { return lot.currency ? 0.8 : 0.6; } } export default function createConnector(meta: ConnectorMeta) { return new TokyoChuoConnector(meta); }