← 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);
}
})();
}