← back to Watches

utils/price-monitor-simple.js

266 lines

/**
 * Automated Price Monitoring System (CSV-Only Version)
 * Daily scraping at 6 AM - stores historical data in CSV for stock-like charts
 * No database required - uses CSV files like stock data
 */

import PriceScraper from './price-scraper.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

class SimplePriceMonitor {
  constructor() {
    this.scraper = new PriceScraper();
    this.csvDir = path.join(__dirname, '../data/price-history-csv');
    this.summaryDir = path.join(__dirname, '../data/price-summaries');

    if (!fs.existsSync(this.csvDir)) {
      fs.mkdirSync(this.csvDir, { recursive: true });
    }
    if (!fs.existsSync(this.summaryDir)) {
      fs.mkdirSync(this.summaryDir, { recursive: true });
    }
  }

  /**
   * Append to CSV file (stock-like format: Date, Open, High, Low, Close, Volume)
   */
  appendToCSV(scrapedData) {
    const watchId = scrapedData.watch.id;
    const csvPath = path.join(this.csvDir, `${watchId}.csv`);

    const today = new Date().toISOString().split('T')[0];
    const avgPrice = scrapedData.averageMarketPrice || '';
    const low = scrapedData.lowestPrice || '';
    const high = scrapedData.highestPrice || '';
    const volume = scrapedData.prices.length;

    // Create CSV if doesn't exist
    if (!fs.existsSync(csvPath)) {
      const header = 'Date,Open,High,Low,Close,Volume,Model,Reference,Sources\n';
      fs.writeFileSync(csvPath, header);
    }

    // Read last price as "Open"
    const existingData = fs.readFileSync(csvPath, 'utf8');
    const lines = existingData.trim().split('\n');
    let lastClose = avgPrice;
    if (lines.length > 1) {
      const lastLine = lines[lines.length - 1].split(',');
      lastClose = lastLine[4] || avgPrice; // Use previous Close as today's Open
    }

    // Get unique sources
    const sources = [...new Set(scrapedData.prices.map(p => p.source))].join(';');

    // Append new row (stock format: Date, Open, High, Low, Close, Volume)
    const row = `${today},${lastClose},${high},${low},${avgPrice},${volume},"${scrapedData.watch.model}","${scrapedData.watch.reference}","${sources}"\n`;

    // Check if today's data already exists
    const todayExists = lines.some(line => line.startsWith(today));
    if (todayExists) {
      // Update today's row
      const updatedLines = lines.map(line => {
        if (line.startsWith(today)) {
          return row.trim();
        }
        return line;
      });
      fs.writeFileSync(csvPath, updatedLines.join('\n') + '\n');
      console.log(`✓ Updated CSV: ${csvPath}`);
    } else {
      // Append new row
      fs.appendFileSync(csvPath, row);
      console.log(`✓ Appended to CSV: ${csvPath}`);
    }

    return csvPath;
  }

  /**
   * Save detailed price breakdown to JSON
   */
  savePriceDetails(scrapedData) {
    const watchId = scrapedData.watch.id;
    const today = new Date().toISOString().split('T')[0];
    const jsonPath = path.join(this.summaryDir, `${watchId}-${today}.json`);

    fs.writeFileSync(jsonPath, JSON.stringify(scrapedData, null, 2));
    console.log(`✓ Saved price details: ${jsonPath}`);

    return jsonPath;
  }

  /**
   * Create master index file
   */
  updateMasterIndex(allResults) {
    const indexPath = path.join(this.csvDir, 'index.json');

    const index = {
      lastUpdated: new Date().toISOString(),
      totalWatches: allResults.totalWatches,
      successfulScrapes: allResults.successCount,
      failedScrapes: allResults.failureCount,
      watches: allResults.watches.map(w => ({
        id: w.id,
        model: w.model,
        csvFile: `${w.id}.csv`,
        lastPrice: w.averagePrice,
        status: w.status,
        dataPoints: w.pricePoints
      }))
    };

    fs.writeFileSync(indexPath, JSON.stringify(index, null, 2));
    console.log(`✓ Updated master index: ${indexPath}`);

    return index;
  }

  /**
   * Run daily price monitoring job
   */
  async runDailyMonitoring() {
    console.log('\n🕐 Starting daily price monitoring at', new Date().toLocaleString());
    console.log('=' .repeat(70));

    try {
      // Load watches to monitor
      const dbPath = path.join(__dirname, '../data/watches.json');
      const database = JSON.parse(fs.readFileSync(dbPath, 'utf8'));

      const results = {
        timestamp: new Date().toISOString(),
        totalWatches: database.watches.length,
        successCount: 0,
        failureCount: 0,
        watches: []
      };

      // Monitor each watch
      for (const watch of database.watches) {
        try {
          console.log(`\n[${results.successCount + results.failureCount + 1}/${database.watches.length}] Processing: ${watch.model}`);

          // Scrape prices
          const scrapedData = await this.scraper.scrapeAllSources(watch);

          // Store in CSV (stock format)
          this.appendToCSV(scrapedData);

          // Save detailed JSON
          this.savePriceDetails(scrapedData);

          results.successCount++;
          results.watches.push({
            id: watch.id,
            model: watch.model,
            status: 'success',
            pricePoints: scrapedData.prices.length,
            averagePrice: scrapedData.averageMarketPrice
          });

          // Rate limiting - wait 5 seconds between watches
          await new Promise(resolve => setTimeout(resolve, 5000));

        } catch (error) {
          console.error(`✗ Failed to monitor ${watch.model}:`, error.message);
          results.failureCount++;
          results.watches.push({
            id: watch.id,
            model: watch.model,
            status: 'failed',
            error: error.message
          });
        }
      }

      // Update master index
      this.updateMasterIndex(results);

      // Save daily report
      const reportPath = path.join(this.summaryDir, `daily-report-${new Date().toISOString().split('T')[0]}.json`);
      fs.writeFileSync(reportPath, JSON.stringify(results, null, 2));

      console.log('\n' + '='.repeat(70));
      console.log('📊 Daily Monitoring Complete');
      console.log(`✓ Success: ${results.successCount}/${results.totalWatches}`);
      console.log(`✗ Failed: ${results.failureCount}/${results.totalWatches}`);
      console.log(`📄 Report saved: ${reportPath}`);
      console.log(`📁 CSV files: ${this.csvDir}`);

      return results;

    } catch (error) {
      console.error('✗ Daily monitoring failed:', error);
      throw error;
    } finally {
      await this.scraper.close();
    }
  }

  /**
   * Get price history from CSV (stock-like data)
   */
  getPriceHistory(watchId) {
    const csvPath = path.join(this.csvDir, `${watchId}.csv`);

    if (!fs.existsSync(csvPath)) {
      return [];
    }

    const data = fs.readFileSync(csvPath, 'utf8');
    const lines = data.trim().split('\n');

    // Skip header
    return lines.slice(1).map(line => {
      const [date, open, high, low, close, volume, model, reference] = line.split(',');
      return {
        date,
        open: parseFloat(open),
        high: parseFloat(high),
        low: parseFloat(low),
        close: parseFloat(close),
        volume: parseInt(volume),
        model: model?.replace(/"/g, ''),
        reference: reference?.replace(/"/g, '')
      };
    });
  }

  /**
   * Get all available watches
   */
  getAllWatches() {
    const indexPath = path.join(this.csvDir, 'index.json');

    if (fs.existsSync(indexPath)) {
      return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
    }

    return null;
  }
}

export default SimplePriceMonitor;

// CLI usage
if (import.meta.url === `file://${process.argv[1]}`) {
  const monitor = new SimplePriceMonitor();

  (async () => {
    try {
      await monitor.runDailyMonitoring();
      process.exit(0);
    } catch (error) {
      console.error('Fatal error:', error);
      process.exit(1);
    }
  })();
}