← back to Omega Watches 2

collectors/base-collector.mjs

238 lines

/**
 * Base Collector — Abstract class for all data source collectors
 * Omega Watches 2.0 Enterprise Pipeline
 */
import pg from 'pg';
const { Pool } = pg;

const pool = new Pool({
  host: process.env.DB_HOST || '127.0.0.1',
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME || 'omega_watches_v2',
  user: process.env.DB_USER || 'omega_admin',
  password: process.env.DB_PASSWORD || 'OmegaWatch2024Pass',
  max: 5,
});

export class BaseCollector {
  constructor(sourceName) {
    this.sourceName = sourceName;
    this.source = null;
    this.parser = null;
    this.runId = null;
    this.stats = {
      fetched: 0,
      parsed: 0,
      inserted: 0,
      updated: 0,
      quarantined: 0,
      deduplicated: 0,
    };
    this.startTime = null;
  }

  async initialize() {
    // Load source config
    const { rows } = await pool.query(
      'SELECT * FROM data_source WHERE name = $1',
      [this.sourceName]
    );
    if (!rows[0]) throw new Error(`Source not found: ${this.sourceName}`);
    this.source = rows[0];

    // Check ToS compliance
    if (this.source.tos_reviewed && this.source.tos_allows_collection === false) {
      throw new Error(`ToS prohibits automated collection for ${this.sourceName}`);
    }

    // Load current parser
    const { rows: parsers } = await pool.query(
      'SELECT * FROM parser_version WHERE source_id = $1 AND is_current = true',
      [this.source.id]
    );
    this.parser = parsers[0] || { version: '1.0.0', selectors_json: {} };

    // Create collector run record
    const { rows: runs } = await pool.query(
      `INSERT INTO collector_run (source_id, job_type, status, parser_version, started_at)
       VALUES ($1, $2, 'running', $3, NOW()) RETURNING run_id`,
      [this.source.id, this.getJobType(), this.parser.version]
    );
    this.runId = runs[0].run_id;
    this.startTime = Date.now();

    console.log(`[${this.sourceName}] Collector initialized (run #${this.runId}, parser v${this.parser.version})`);
  }

  getJobType() { return 'incremental'; }

  // Rate limiting
  async rateLimit() {
    const rpm = this.source?.rate_limit_rpm || 10;
    const delayMs = (60 / rpm) * 1000;
    await new Promise(r => setTimeout(r, delayMs));
  }

  // Insert market event with dedup check
  async insertMarketEvent(event) {
    try {
      // Hard dedup: (source_name, source_listing_id)
      const { rows: existing } = await pool.query(
        'SELECT event_id FROM market_event WHERE source_name = $1 AND source_listing_id = $2',
        [event.source_name, event.source_listing_id]
      );

      if (existing.length > 0) {
        this.stats.deduplicated++;
        return { action: 'deduplicated', event_id: existing[0].event_id };
      }

      // Validate and quarantine if needed
      const quarantineReason = this.validate(event);
      if (quarantineReason) {
        event.is_quarantined = true;
        event.quarantine_reason = quarantineReason;
        this.stats.quarantined++;
      }

      const { rows } = await pool.query(
        `INSERT INTO market_event (
          source_name, source_listing_id, event_type, reference_number_raw,
          serial_number, year_text, case_material, movement,
          condition_raw, condition_normalized, provenance_raw,
          sale_date, location_text, seller_type,
          price_amount, currency, hammer_price, buyers_premium, total_to_buyer, fee_semantics,
          images_json, source_url, page_hash, parser_version,
          is_quarantined, quarantine_reason
        ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
        RETURNING event_id`,
        [
          event.source_name, event.source_listing_id, event.event_type,
          event.reference_number_raw, event.serial_number, event.year_text,
          event.case_material, event.movement, event.condition_raw,
          event.condition_normalized || 'unknown', event.provenance_raw,
          event.sale_date, event.location_text, event.seller_type || 'unknown',
          event.price_amount, event.currency || 'USD',
          event.hammer_price, event.buyers_premium, event.total_to_buyer,
          event.fee_semantics || 'unknown',
          event.images_json ? JSON.stringify(event.images_json) : null,
          event.source_url, event.page_hash, this.parser.version,
          event.is_quarantined || false, event.quarantine_reason
        ]
      );

      this.stats.inserted++;

      // Create quarantine record if quarantined
      if (event.is_quarantined) {
        await pool.query(
          `INSERT INTO quarantine_record (event_id, reason, severity, details)
           VALUES ($1, $2, $3, $4)`,
          [rows[0].event_id, quarantineReason, this.classifySeverity(quarantineReason), JSON.stringify({ source: this.sourceName })]
        );
      }

      return { action: 'inserted', event_id: rows[0].event_id };
    } catch (err) {
      if (err.code === '23505') { // unique violation
        this.stats.deduplicated++;
        return { action: 'deduplicated' };
      }
      throw err;
    }
  }

  // Insert MSRP snapshot
  async insertMSRPSnapshot(snapshot) {
    try {
      await pool.query(
        `INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, page_hash, parser_version)
         VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
         ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING`,
        [
          snapshot.reference_id, snapshot.region_code, snapshot.currency,
          snapshot.msrp_amount, snapshot.captured_date, snapshot.source_url,
          snapshot.page_hash, this.parser.version
        ]
      );
      this.stats.inserted++;
    } catch (err) {
      console.error(`[${this.sourceName}] MSRP insert error:`, err.message);
    }
  }

  // Validation — override in subclasses for source-specific rules
  validate(event) {
    if (!event.price_amount && !event.hammer_price) return 'missing_price';
    if (event.price_amount < 0) return 'negative_price';
    if (event.price_amount > 50000000) return 'implausible_price';
    if (!event.source_listing_id) return 'missing_listing_id';
    return null; // no quarantine needed
  }

  classifySeverity(reason) {
    const critical = ['implausible_price'];
    const high = ['missing_price', 'negative_price'];
    if (critical.includes(reason)) return 'critical';
    if (high.includes(reason)) return 'high';
    return 'medium';
  }

  // Archive raw HTML/PDF snapshot
  async archiveSnapshot(url, content, contentType = 'text/html') {
    const crypto = await import('crypto');
    const contentHash = crypto.createHash('sha256').update(content).digest('hex');

    try {
      await pool.query(
        `INSERT INTO raw_snapshot (source_name, url, content_hash, content_type, content_size_bytes, parser_version)
         VALUES ($1,$2,$3,$4,$5,$6)
         ON CONFLICT DO NOTHING`,
        [this.sourceName, url, contentHash, contentType, Buffer.byteLength(content), this.parser.version]
      );
    } catch (err) {
      console.warn(`[${this.sourceName}] Snapshot archive warning:`, err.message);
    }
    return contentHash;
  }

  // Finalize run
  async finalize(status = 'completed', errorMessage = null) {
    const durationMs = Date.now() - this.startTime;
    await pool.query(
      `UPDATE collector_run SET
        status = $1, completed_at = NOW(), duration_ms = $2,
        records_fetched = $3, records_parsed = $4, records_inserted = $5,
        records_updated = $6, records_quarantined = $7, records_deduplicated = $8,
        error_message = $9
       WHERE run_id = $10`,
      [
        status, durationMs,
        this.stats.fetched, this.stats.parsed, this.stats.inserted,
        this.stats.updated, this.stats.quarantined, this.stats.deduplicated,
        errorMessage, this.runId
      ]
    );
    console.log(`[${this.sourceName}] Run #${this.runId} ${status} in ${durationMs}ms:`, this.stats);
  }

  // Override in subclass
  async collect() {
    throw new Error('collect() must be implemented by subclass');
  }

  // Main entry point
  async run() {
    try {
      await this.initialize();
      await this.collect();
      await this.finalize('completed');
    } catch (err) {
      console.error(`[${this.sourceName}] Collection failed:`, err.message);
      if (this.runId) await this.finalize('failed', err.message);
    }
  }
}

export { pool };