← back to Omega Watches 2

collectors/antiquorum/collector.mjs

203 lines

/**
 * Antiquorum Auction Collector
 *
 * Fetches realized auction prices from antiquorum.swiss for Omega watches.
 * Writes to the market_event ledger with event_type = 'auction_realized'.
 *
 * Antiquorum publishes lot results after auctions complete. Their results
 * pages include hammer prices, buyer's premiums, and condition grades.
 *
 * Rate limit: 10 RPM (per source config)
 */
import { BaseCollector, pool } from '../base-collector.mjs';
import https from 'https';
import crypto from 'crypto';

const ANTIQUORUM_BASE = 'https://www.antiquorum.swiss';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';

export default class AntiquorumCollector extends BaseCollector {
  constructor() {
    super('antiquorum');
  }

  getJobType() { return 'incremental'; }

  fetch(url) {
    return new Promise((resolve, reject) => {
      const parsedUrl = new URL(url);
      const req = https.get({
        hostname: parsedUrl.hostname,
        path: parsedUrl.pathname + parsedUrl.search,
        headers: {
          'User-Agent': USER_AGENT,
          'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
          'Accept-Language': 'en-US,en;q=0.9',
        },
        timeout: 30000,
      }, (res) => {
        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
          const redir = res.headers.location.startsWith('http')
            ? res.headers.location
            : `${ANTIQUORUM_BASE}${res.headers.location}`;
          return this.fetch(redir).then(resolve).catch(reject);
        }
        let body = '';
        res.on('data', chunk => body += chunk);
        res.on('end', () => resolve({ statusCode: res.statusCode, body }));
      });
      req.on('error', reject);
      req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
    });
  }

  /**
   * Parse Antiquorum lot results page for auction results
   */
  parseLotResults(html) {
    const results = [];

    // Antiquorum lot pages use structured lot entries
    // Pattern: each lot has a lot number, description, estimate, and result
    const lotPattern = /<div[^>]*class="[^"]*lot-item[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/gi;
    let match;

    while ((match = lotPattern.exec(html)) !== null) {
      const content = match[1];

      // Extract lot number
      const lotNumMatch = content.match(/lot\s*#?\s*(\d+)/i);
      const lotNum = lotNumMatch ? lotNumMatch[1] : null;

      // Look for Omega references
      const refMatch = content.match(/\b(\d{3}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3})\b/);
      if (!refMatch) continue; // Skip non-Omega lots

      const result = {
        source_listing_id: `antiq-lot-${lotNum || crypto.randomBytes(4).toString('hex')}`,
        reference_number_raw: refMatch[1],
        event_type: 'auction_realized',
        source_name: 'antiquorum',
      };

      // Extract hammer price
      const hammerMatch = content.match(/(?:hammer|sold|realized)[^:]*:\s*(?:CHF|USD|EUR|GBP)?\s*([\d,]+)/i);
      if (hammerMatch) {
        result.hammer_price = parseFloat(hammerMatch[1].replace(/,/g, ''));
      }

      // Extract currency
      const currMatch = content.match(/\b(CHF|USD|EUR|GBP)\b/);
      result.currency = currMatch ? currMatch[1] : 'CHF'; // Antiquorum defaults to CHF

      // Extract estimate range
      const estMatch = content.match(/estimate[^:]*:\s*([\d,]+)\s*[-–]\s*([\d,]+)/i);
      if (estMatch) {
        result.estimate_low = parseFloat(estMatch[1].replace(/,/g, ''));
        result.estimate_high = parseFloat(estMatch[2].replace(/,/g, ''));
      }

      // Calculate buyer's premium (Antiquorum: typically 25% buyer's premium)
      if (result.hammer_price) {
        result.buyers_premium = Math.round(result.hammer_price * 0.25);
        result.total_to_buyer = result.hammer_price + result.buyers_premium;
        result.price_amount = result.total_to_buyer;
        result.fee_semantics = 'hammer_plus_premium';
      }

      // Extract condition
      const condMatch = content.match(/condition[^:]*:\s*([^<\n]+)/i);
      if (condMatch) {
        result.condition_raw = condMatch[1].trim();
        result.condition_normalized = this.normalizeCondition(condMatch[1]);
      }

      // Extract date
      const dateMatch = content.match(/(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{4})/i);
      if (dateMatch) {
        const months = { jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06', jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12' };
        result.sale_date = `${dateMatch[3]}-${months[dateMatch[2].toLowerCase()]}-${dateMatch[1].padStart(2, '0')}`;
      }

      // Extract serial/movement numbers
      const serialMatch = content.match(/(?:serial|no\.|number)\s*[:#]?\s*(\d{4,})/i);
      if (serialMatch) result.serial_number = serialMatch[1];

      result.seller_type = 'auction_house';

      if (result.price_amount > 0) {
        results.push(result);
      }
    }

    return results;
  }

  normalizeCondition(raw) {
    const lower = (raw || '').toLowerCase();
    if (lower.includes('mint') || lower.includes('excellent') || lower.includes('pristine')) return 'mint';
    if (lower.includes('very good') || lower.includes('fine')) return 'very_good';
    if (lower.includes('good')) return 'good';
    if (lower.includes('fair')) return 'fair';
    return 'unknown';
  }

  async collect() {
    // Load references to know which lots to look for
    const { rows: refs } = await pool.query(
      'SELECT reference_number FROM watch_reference'
    );
    const refSet = new Set(refs.map(r => r.reference_number));
    console.log(`[antiquorum] Tracking ${refSet.size} reference numbers`);

    // Fetch recent auction results
    // Antiquorum organizes by auction sales: /en/results/
    const resultsUrl = `${ANTIQUORUM_BASE}/en/results/?brand=omega&sort=date_desc`;

    try {
      await this.rateLimit();
      const response = await this.fetch(resultsUrl);
      this.stats.fetched++;

      if (response.statusCode !== 200) {
        console.log(`[antiquorum] Results page returned HTTP ${response.statusCode}`);
        return;
      }

      const pageHash = crypto.createHash('sha256').update(response.body).digest('hex');
      await this.archiveSnapshot(resultsUrl, response.body);

      const lots = this.parseLotResults(response.body);
      this.stats.parsed = lots.length;
      console.log(`[antiquorum] Found ${lots.length} Omega lots`);

      for (const lot of lots) {
        lot.source_url = resultsUrl;
        lot.page_hash = pageHash;

        const result = await this.insertMarketEvent(lot);
        if (result.action === 'inserted') {
          console.log(`  Lot ${lot.source_listing_id}: ${lot.reference_number_raw} — ${lot.currency} ${lot.total_to_buyer?.toLocaleString()}`);
        }
      }

      console.log(`[antiquorum] Complete: ${this.stats.inserted} new, ${this.stats.deduplicated} dupes, ${this.stats.quarantined} quarantined`);
    } catch (err) {
      console.error(`[antiquorum] Collection error:`, err.message);
      throw err;
    }
  }

  validate(event) {
    const base = super.validate(event);
    if (base) return base;

    // Auction-specific: extremely high prices need manual review
    if (event.total_to_buyer > 1000000) return 'auction_price_exceptional';
    // Lots with no condition data flagged
    if (!event.condition_raw) return 'missing_condition';

    return null;
  }
}