← back to Omega Watches 2

collectors/omega-official/collector.mjs

214 lines

/**
 * Omega Official MSRP Collector
 *
 * Fetches current retail prices from omegawatches.com for all
 * references in our database. Writes to the msrp_snapshot ledger.
 *
 * Data flow:
 *   1. Load all watch_reference rows
 *   2. For each reference, build the Omega product page URL
 *   3. Fetch the page and extract the retail price
 *   4. Insert into msrp_snapshot (dedup: reference_id + region + date)
 *   5. Track stats in collector_run
 *
 * The Omega website uses the pattern:
 *   https://www.omegawatches.com/watch-omega-{collection}-{ref}
 * But since URLs vary, we also use their product API endpoint.
 */
import { BaseCollector, pool } from '../base-collector.mjs';
import https from 'https';
import crypto from 'crypto';

const OMEGA_BASE = 'https://www.omegawatches.com';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';

export default class OmegaOfficialCollector extends BaseCollector {
  constructor() {
    super('omega_official');
    this.references = [];
  }

  getJobType() { return 'daily_msrp'; }

  /**
   * HTTP GET with TLS settings matching a regular browser
   */
  fetch(url) {
    return new Promise((resolve, reject) => {
      const req = https.get(url, {
        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) {
          return this.fetch(res.headers.location).then(resolve).catch(reject);
        }
        let body = '';
        res.on('data', chunk => body += chunk);
        res.on('end', () => resolve({ statusCode: res.statusCode, body, headers: res.headers }));
      });
      req.on('error', reject);
      req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
    });
  }

  /**
   * Extract price from Omega product page HTML
   * Omega uses structured data (JSON-LD) and visible price elements
   */
  extractPrice(html) {
    // Try JSON-LD structured data first
    const ldMatch = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi);
    if (ldMatch) {
      for (const block of ldMatch) {
        try {
          const jsonStr = block.replace(/<\/?script[^>]*>/gi, '');
          const data = JSON.parse(jsonStr);
          const product = Array.isArray(data) ? data.find(d => d['@type'] === 'Product') : data;
          if (product?.offers?.price) {
            return {
              amount: parseFloat(product.offers.price),
              currency: product.offers.priceCurrency || 'USD',
            };
          }
          if (product?.offers?.[0]?.price) {
            return {
              amount: parseFloat(product.offers[0].price),
              currency: product.offers[0].priceCurrency || 'USD',
            };
          }
        } catch {}
      }
    }

    // Fallback: Extract from visible price elements
    // Omega typically renders: <span class="price">$6,350</span> or similar
    const pricePatterns = [
      /class="[^"]*price[^"]*"[^>]*>\s*\$?\s*([\d,]+(?:\.\d{2})?)\s*</i,
      /data-price="([\d.]+)"/i,
      /itemprop="price"\s+content="([\d.]+)"/i,
      />\s*(?:USD|US\$|\$)\s*([\d,]+(?:\.\d{2})?)\s*</i,
    ];

    for (const pat of pricePatterns) {
      const m = html.match(pat);
      if (m) {
        const amount = parseFloat(m[1].replace(/,/g, ''));
        if (amount > 100 && amount < 5000000) {
          return { amount, currency: 'USD' };
        }
      }
    }

    return null;
  }

  /**
   * Build search URL for reference number
   * Omega's search endpoint returns product pages
   */
  buildSearchUrl(referenceNumber) {
    // Omega's internal search API
    return `${OMEGA_BASE}/en-us/search?q=${encodeURIComponent(referenceNumber)}`;
  }

  /**
   * Build direct product URL from reference number
   * Omega format: reference dots removed, lowercased
   * e.g., 310.30.42.50.01.001 → /en-us/watch/omega-speedmaster/31030425001001
   */
  buildProductUrl(referenceNumber) {
    const refClean = referenceNumber.replace(/\./g, '');
    return `${OMEGA_BASE}/en-us/watch/omega-watch/${refClean}`;
  }

  async collect() {
    // Load all references from our database
    const { rows } = await pool.query(
      'SELECT id, reference_number, collection, model_name FROM watch_reference ORDER BY collection, reference_number'
    );
    this.references = rows;
    this.stats.fetched = rows.length;

    console.log(`[omega_official] Collecting MSRP for ${rows.length} references`);

    const today = new Date().toISOString().split('T')[0];
    let successCount = 0;
    let skipCount = 0;

    for (const ref of rows) {
      try {
        await this.rateLimit();

        // Check if we already have today's snapshot
        const { rows: existing } = await pool.query(
          `SELECT msrp_id FROM msrp_snapshot
           WHERE reference_id = $1 AND region_code = 'US' AND captured_date = $2`,
          [ref.id, today]
        );

        if (existing.length > 0) {
          this.stats.deduplicated++;
          skipCount++;
          continue;
        }

        // Try direct product URL first
        const url = this.buildProductUrl(ref.reference_number);
        let response;
        try {
          response = await this.fetch(url);
        } catch (fetchErr) {
          console.warn(`[omega_official] Fetch error for ${ref.reference_number}: ${fetchErr.message}`);
          continue;
        }

        this.stats.parsed++;

        if (response.statusCode === 200 && response.body.length > 1000) {
          const pageHash = crypto.createHash('sha256').update(response.body).digest('hex');
          const price = this.extractPrice(response.body);

          if (price && price.amount > 0) {
            await this.insertMSRPSnapshot({
              reference_id: ref.id,
              region_code: 'US',
              currency: price.currency === 'USD' ? 'USD' : price.currency,
              msrp_amount: price.amount,
              captured_date: today,
              source_url: url,
              page_hash: pageHash,
            });
            successCount++;
            console.log(`  ${ref.reference_number}: ${price.currency} ${price.amount.toLocaleString()}`);
          } else {
            // Page loaded but no price found — maybe discontinued
            console.log(`  ${ref.reference_number}: No price found (page ${response.statusCode})`);
          }

          // Archive the raw page
          await this.archiveSnapshot(url, response.body);
        } else if (response.statusCode === 404) {
          console.log(`  ${ref.reference_number}: 404 — reference not on Omega site`);
        } else {
          console.log(`  ${ref.reference_number}: HTTP ${response.statusCode}`);
        }
      } catch (err) {
        console.error(`  ${ref.reference_number}: Error — ${err.message}`);
      }
    }

    console.log(`[omega_official] Complete: ${successCount} prices captured, ${skipCount} already today, ${this.stats.quarantined} quarantined`);
  }

  validate(event) {
    // MSRP-specific validation (for any events we insert)
    if (event.price_amount && event.price_amount < 500) return 'msrp_too_low';
    if (event.price_amount && event.price_amount > 2000000) return 'msrp_too_high';
    return null;
  }
}