TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors';2import type { CurrencyCode, ExtractionResult } from '@rareindex/shared';3import type { DeptHint } from '../_auction-lib/categories.js';4import { isSupportedCurrency, stripHtml } from '../_g8-auctions-eu-apac-lib/index.js';5import { SaleResultsConnector, resolveCategory, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js';67/**8 * Tokyo Chuo Auction (東京中央オークション — Tokyo, Hong Kong, Taipei) — bidding platform tcabid.com (BiddingArt).9 * The SPA reads an unauthenticated JSON API: /app_api/v1/auctions/finished_auctions_and_store_categories10 * (past sales) and /app_api/v1/auction_items?auction_category_auction_id_eq=<id>&page=N&per_page=M (lots with11 * final_price, status hammered/unsold, currency per auction category, images). Chinese/Japanese titles.12 */13const API = 'https://tcabid.com/app_api/v1';14const WEB = 'https://tcabid.com';1516type 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 };17type 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 };1819export function parseFinishedAuctions(json: unknown): SaleRef[] {20 const arr = Array.isArray(json) ? (json as ApiAuction[]) : [];21 return arr22 .filter((a) => a && typeof a.id === 'number' && (a.type ?? 'Auction') === 'Auction')23 .map((a) => ({24 id: String(a.id),25 title: String(a.title ?? `Auction ${a.id}`).trim(),26 url: `${WEB}/auctions/${a.id}`,27 date: a.start_at ?? null,28 location: locationOf(a.place),29 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 },30 }));31}3233/** "株式會社 東京中央拍賣(東京都…)" → "Tokyo, Japan"; "香港" → "Hong Kong"; Taipei → "Taipei, Taiwan". */34export function locationOf(place: string | null | undefined): string | null {35 if (!place) return null;36 if (/東京|Tokyo/i.test(place)) return 'Tokyo, Japan';37 if (/香港|Hong ?Kong/i.test(place)) return 'Hong Kong';38 if (/台北|臺北|台灣|臺灣|Taipei|Taiwan/i.test(place)) return 'Taipei, Taiwan';39 if (/北京|Beijing/i.test(place)) return 'Beijing, China';40 return place.length <= 60 ? place : null;41}4243export function currencyOf(item: ApiItem): CurrencyCode | null {44 const c = item.auction_category?.currency?.toUpperCase() ?? null;45 return isSupportedCurrency(c) ? c : null;46}4748/** JSON page of items → ParsedLot[]; `x-total-pages` (headers) is not exposed by the router, so hasMore = full page. */49export function parseItemsPage(json: unknown, sale: SaleRef, perPage: number): ParsedSalePage | null {50 const arr = Array.isArray(json) ? (json as ApiItem[]) : null;51 if (!arr) return null;52 const lots: ParsedLot[] = [];53 for (const it of arr) {54 if (!it || typeof it.id !== 'number') continue;55 const title = stripHtml(it.title ?? '', 300);56 const lotNo = it.number !== null && it.number !== undefined ? String(it.number) : null;57 if (!title || !lotNo) continue;58 const currency = currencyOf(it);59 const price = typeof it.final_price === 'number' && it.final_price > 0 ? it.final_price : null;60 const sold = it.status === 'hammered' && price !== null && it.show_items_final_price !== false;61 const images = [it.cover?.url, ...(it.images ?? []).map((i) => i?.url)].filter((u): u is string => typeof u === 'string' && u.length > 0);62 lots.push({63 lotNo,64 title,65 subtitle: it.auction_category?.title ? stripHtml(it.auction_category.title, 120) : null,66 description: stripHtml(it.desc ?? null, 500),67 url: `${WEB}/auction_items/${it.id}`,68 image: images[0] ?? null,69 price: sold ? price : null,70 currency,71 premiumIncluded: false,72 estimateLow: typeof it.estimate_price_from === 'number' && it.estimate_price_from > 0 ? it.estimate_price_from : null,73 estimateHigh: typeof it.estimate_price_to === 'number' && it.estimate_price_to > 0 ? it.estimate_price_to : null,74 date: it.transaction_time ?? it.auction_start_at ?? null,75 sold,76 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 },77 });78 }79 return { lots, hasMore: arr.length >= perPage, totalLots: null };80}8182/** Session title / lot title (zh/ja) → department hint. */83export function hintFromAsianLabel(label: string): DeptHint {84 const l = label;85 if (/腕時計|手錶|腕錶|時計|Watch|ROLEX|勞力士|百達翡麗|PATEK|OMEGA|歐米茄/i.test(l)) return 'watches';86 if (/珠寶|珠宝|翡翠|鑽石|钻石|寶石|宝石|Jewel/i.test(l)) return 'jewelry';87 if (/威士忌|ウイスキー|白酒|高粱|茅台|葡萄酒|洋酒|Whisky|Wine|山崎|響|余市|軽井沢|輕井澤/i.test(l)) return 'wine';88 if (/書畫|书画|繪畫|绘画|油畫|油画|水墨|Painting|Calligraphy|近現代|古代書畫|日本畫|洋畫|版畫|版画/i.test(l)) return 'art';89 if (/瓷器|陶瓷|玉器|銅器|铜器|雜項|杂项|工藝|工艺|漆器|茶道具|煎茶|文房|佛像|Ceramic|Porcelain|Bronze|Jade|Works of Art|古董|骨董|茶器/i.test(l)) return 'asian';90 if (/錢幣|钱币|古錢|紙幣|Coin|Banknote|金幣|銀幣/i.test(l)) return 'coins';91 if (/郵票|邮票|Stamp/i.test(l)) return 'stamps';92 if (/手袋|手提包|包袋|Hermès|HERMES|CHANEL|Handbag|Birkin|Kelly/i.test(l)) return 'handbags';93 if (/古籍|善本|碑帖|書籍|Book|Manuscript/i.test(l)) return 'books';94 if (/漫畫|漫画|動漫|手塚|Manga|Comic|Anime/i.test(l)) return 'comics';95 if (/玩具|模型|公仔|Toy|Figure|Bearbrick|KAWS/i.test(l)) return 'toys';96 if (/卡牌|卡片|Card|寶可夢|ポケモン|Pokemon|Pokémon/i.test(l)) return 'cards';97 if (/相機|カメラ|Leica|徠卡|Camera/i.test(l)) return 'cameras';98 if (/當代|当代|現代藝術|现代艺术|Contemporary|奈良美智|草間彌生|村上隆|Nara|Kusama|Murakami|Banksy|KAWS/i.test(l)) return 'contemporary';99 return 'unknown';100}101102export class TokyoChuoConnector extends SaleResultsConnector {103 readonly version = '1.0.0';104 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 };105 protected override minIntervalMs = 1500;106107 private get perPage(): number {108 return Math.min(100, Math.max(10, Number(this.meta.config.perPage ?? 100)));109 }110111 async listSales(ctx: CrawlContext): Promise<SaleRef[]> {112 const url = `${API}/auctions/finished_auctions_and_store_categories?per_page=${Number(this.meta.config.auctionsListSize ?? 200)}`;113 await this.throttle(url);114 const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', minQuality: 0, timeoutMs: 60_000 });115 if (!res.success || !res.json) {116 ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);117 return [];118 }119 return parseFinishedAuctions(res.json);120 }121122 salePageUrl(sale: SaleRef, page: number): string {123 return `${API}/auction_items?auction_category_auction_id_eq=${sale.id}&per_page=${this.perPage}&page=${page}`;124 }125126 parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null {127 return parseItemsPage(res.json, sale, this.perPage);128 }129130 override categoryFor(sale: SaleRef, lot: ParsedLot): string | null {131 const label = `${String(lot.extra.session ?? '')} ${sale.title}`;132 const hint = hintFromAsianLabel(`${label} ${lot.title}`);133 if (hint === 'asian') return 'antiques';134 if (hint === 'art') return /當代|当代|Contemporary|奈良美智|草間彌生|村上隆/i.test(label) ? 'contemporary_art' : 'art';135 // Hand the English department word to the shared resolver (multilingual lot cues + keyword sweep).136 const en: Partial<Record<DeptHint, string>> = { 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' };137 return resolveCategory(en[hint] ?? '', lot, this.house.fallbackSlug);138 }139140 protected override confidenceFor(_slug: string, lot: ParsedLot): number {141 return lot.currency ? 0.8 : 0.6;142 }143}144145export default function createConnector(meta: ConnectorMeta) {146 return new TokyoChuoConnector(meta);147}148