mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-04 21:32:40 +02:00
When a user confirms a price from the modal, we now store it as an "anchor price". On subsequent refreshes, if multiple price candidates are found (common with variant products like different sizes/colors), we select the candidate closest to the anchor price. This fixes the issue where variant products would randomly switch to a different variant's price on refresh. Changes: - Add anchor_price column to products table - Save anchor price when user confirms a price selection - Use anchor price to select correct variant on refresh - 20% tolerance - if no candidate is within 20% of anchor, fall back to consensus Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
import cron from 'node-cron';
|
|
import { productQueries, priceHistoryQueries, userQueries, stockStatusHistoryQueries, notificationHistoryQueries, NotificationType } from '../models';
|
|
import { scrapeProduct, scrapeProductWithVoting, ExtractionMethod } from './scraper';
|
|
import { sendNotifications, NotificationPayload } from './notifications';
|
|
|
|
let isRunning = false;
|
|
|
|
async function checkPrices(): Promise<void> {
|
|
if (isRunning) {
|
|
console.log('Price check already in progress, skipping...');
|
|
return;
|
|
}
|
|
|
|
isRunning = true;
|
|
console.log('Starting scheduled price check...');
|
|
|
|
try {
|
|
// Find all products that are due for a refresh
|
|
const products = await productQueries.findDueForRefresh();
|
|
console.log(`Found ${products.length} products to check`);
|
|
|
|
for (const product of products) {
|
|
try {
|
|
console.log(`Checking price for product ${product.id}: ${product.url}`);
|
|
|
|
// Get preferred extraction method for this product (if user previously selected one)
|
|
const preferredMethod = await productQueries.getPreferredExtractionMethod(product.id);
|
|
|
|
// Get anchor price for variant products (the price the user confirmed)
|
|
const anchorPrice = await productQueries.getAnchorPrice(product.id);
|
|
|
|
// Use voting scraper with preferred method and anchor price if available
|
|
const scrapedData = await scrapeProductWithVoting(
|
|
product.url,
|
|
product.user_id,
|
|
preferredMethod as ExtractionMethod | undefined,
|
|
anchorPrice || undefined
|
|
);
|
|
|
|
// Check for back-in-stock notification
|
|
const wasOutOfStock = product.stock_status === 'out_of_stock';
|
|
const nowInStock = scrapedData.stockStatus === 'in_stock';
|
|
|
|
// Update stock status and record to history
|
|
if (scrapedData.stockStatus !== product.stock_status) {
|
|
await productQueries.updateStockStatus(product.id, scrapedData.stockStatus);
|
|
|
|
// Record the status change in history
|
|
await stockStatusHistoryQueries.recordChange(product.id, scrapedData.stockStatus);
|
|
|
|
console.log(
|
|
`Stock status changed for product ${product.id}: ${product.stock_status} -> ${scrapedData.stockStatus}`
|
|
);
|
|
|
|
// Send back-in-stock notification
|
|
if (wasOutOfStock && nowInStock && product.notify_back_in_stock) {
|
|
try {
|
|
const userSettings = await userQueries.getNotificationSettings(product.user_id);
|
|
if (userSettings) {
|
|
const payload: NotificationPayload = {
|
|
productName: product.name || 'Unknown Product',
|
|
productUrl: product.url,
|
|
type: 'back_in_stock',
|
|
newPrice: scrapedData.price?.price,
|
|
currency: scrapedData.price?.currency || 'USD',
|
|
};
|
|
const result = await sendNotifications(userSettings, payload);
|
|
console.log(`Back-in-stock notification sent for product ${product.id}`);
|
|
|
|
// Log notification to history
|
|
if (result.channelsNotified.length > 0) {
|
|
await notificationHistoryQueries.create({
|
|
user_id: product.user_id,
|
|
product_id: product.id,
|
|
notification_type: 'stock_change' as NotificationType,
|
|
old_stock_status: product.stock_status,
|
|
new_stock_status: scrapedData.stockStatus,
|
|
new_price: scrapedData.price?.price,
|
|
currency: scrapedData.price?.currency || 'USD',
|
|
channels_notified: result.channelsNotified,
|
|
product_name: product.name || 'Unknown Product',
|
|
product_url: product.url,
|
|
});
|
|
}
|
|
}
|
|
} catch (notifyError) {
|
|
console.error(`Failed to send back-in-stock notification for product ${product.id}:`, notifyError);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (scrapedData.price) {
|
|
// Get the latest recorded price to compare
|
|
const latestPrice = await priceHistoryQueries.getLatest(product.id);
|
|
|
|
// Only record if price has changed or it's the first entry
|
|
if (!latestPrice || latestPrice.price !== scrapedData.price.price) {
|
|
// Check for price drop notification before recording
|
|
if (latestPrice && product.price_drop_threshold) {
|
|
const oldPrice = parseFloat(String(latestPrice.price));
|
|
const newPrice = scrapedData.price.price;
|
|
const priceDrop = oldPrice - newPrice;
|
|
|
|
if (priceDrop >= product.price_drop_threshold) {
|
|
try {
|
|
const userSettings = await userQueries.getNotificationSettings(product.user_id);
|
|
if (userSettings) {
|
|
const payload: NotificationPayload = {
|
|
productName: product.name || 'Unknown Product',
|
|
productUrl: product.url,
|
|
type: 'price_drop',
|
|
oldPrice: oldPrice,
|
|
newPrice: newPrice,
|
|
currency: scrapedData.price.currency,
|
|
threshold: product.price_drop_threshold,
|
|
};
|
|
const result = await sendNotifications(userSettings, payload);
|
|
console.log(`Price drop notification sent for product ${product.id}: ${priceDrop} drop`);
|
|
|
|
// Log notification to history
|
|
if (result.channelsNotified.length > 0) {
|
|
const priceChangePercent = ((oldPrice - newPrice) / oldPrice) * 100;
|
|
await notificationHistoryQueries.create({
|
|
user_id: product.user_id,
|
|
product_id: product.id,
|
|
notification_type: 'price_drop' as NotificationType,
|
|
old_price: oldPrice,
|
|
new_price: newPrice,
|
|
currency: scrapedData.price.currency,
|
|
price_change_percent: Math.round(priceChangePercent * 100) / 100,
|
|
channels_notified: result.channelsNotified,
|
|
product_name: product.name || 'Unknown Product',
|
|
product_url: product.url,
|
|
});
|
|
}
|
|
}
|
|
} catch (notifyError) {
|
|
console.error(`Failed to send price drop notification for product ${product.id}:`, notifyError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for target price notification
|
|
if (product.target_price) {
|
|
const newPrice = scrapedData.price.price;
|
|
const targetPrice = parseFloat(String(product.target_price));
|
|
const oldPrice = latestPrice ? parseFloat(String(latestPrice.price)) : null;
|
|
|
|
// Only notify if price just dropped to or below target (wasn't already below)
|
|
if (newPrice <= targetPrice && (!oldPrice || oldPrice > targetPrice)) {
|
|
try {
|
|
const userSettings = await userQueries.getNotificationSettings(product.user_id);
|
|
if (userSettings) {
|
|
const payload: NotificationPayload = {
|
|
productName: product.name || 'Unknown Product',
|
|
productUrl: product.url,
|
|
type: 'target_price',
|
|
newPrice: newPrice,
|
|
currency: scrapedData.price.currency,
|
|
targetPrice: targetPrice,
|
|
};
|
|
const result = await sendNotifications(userSettings, payload);
|
|
console.log(`Target price notification sent for product ${product.id}: ${newPrice} <= ${targetPrice}`);
|
|
|
|
// Log notification to history
|
|
if (result.channelsNotified.length > 0) {
|
|
await notificationHistoryQueries.create({
|
|
user_id: product.user_id,
|
|
product_id: product.id,
|
|
notification_type: 'price_target' as NotificationType,
|
|
old_price: oldPrice || undefined,
|
|
new_price: newPrice,
|
|
currency: scrapedData.price.currency,
|
|
target_price: targetPrice,
|
|
channels_notified: result.channelsNotified,
|
|
product_name: product.name || 'Unknown Product',
|
|
product_url: product.url,
|
|
});
|
|
}
|
|
}
|
|
} catch (notifyError) {
|
|
console.error(`Failed to send target price notification for product ${product.id}:`, notifyError);
|
|
}
|
|
}
|
|
}
|
|
|
|
await priceHistoryQueries.create(
|
|
product.id,
|
|
scrapedData.price.price,
|
|
scrapedData.price.currency,
|
|
scrapedData.aiStatus
|
|
);
|
|
console.log(
|
|
`Recorded new price for product ${product.id}: ${scrapedData.price.currency} ${scrapedData.price.price}${scrapedData.aiStatus ? ` (AI: ${scrapedData.aiStatus})` : ''}`
|
|
);
|
|
} else {
|
|
console.log(`Price unchanged for product ${product.id}`);
|
|
}
|
|
} else if (scrapedData.stockStatus === 'out_of_stock') {
|
|
console.log(`Product ${product.id} is out of stock, no price available`);
|
|
} else {
|
|
console.warn(`Could not extract price for product ${product.id}`);
|
|
}
|
|
|
|
// Update last_checked and schedule next check with jitter
|
|
await productQueries.updateLastChecked(product.id, product.refresh_interval);
|
|
|
|
// Add a randomized delay between requests (2-5 seconds) to avoid rate limiting
|
|
const delay = 2000 + Math.floor(Math.random() * 3000);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
} catch (error) {
|
|
console.error(`Error checking product ${product.id}:`, error);
|
|
// Continue with next product even if one fails
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in scheduled price check:', error);
|
|
} finally {
|
|
isRunning = false;
|
|
console.log('Scheduled price check complete');
|
|
}
|
|
}
|
|
|
|
export function startScheduler(): void {
|
|
// Run every minute
|
|
cron.schedule('* * * * *', () => {
|
|
checkPrices().catch(console.error);
|
|
});
|
|
|
|
console.log('Price check scheduler started (runs every minute)');
|
|
}
|
|
|
|
// Allow manual trigger for testing
|
|
export { checkPrices };
|