SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
9.1 KB · 148 lines typescript
Raw Blame History
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