require('dotenv').config(); const express = require('express'); const axios = require('axios'); const cron = require('node-cron'); const fs = require('fs'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const PRICE_THRESHOLD = 650; // USD const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID; const EBAY_CLIENT_ID = process.env.EBAY_CLIENT_ID; const EBAY_CLIENT_SECRET = process.env.EBAY_CLIENT_SECRET; // File path for notified items const NOTIFIED_ITEMS_FILE = path.join(__dirname, 'notified.json'); // Load notified items from file function loadNotifiedItems() { try { if (fs.existsSync(NOTIFIED_ITEMS_FILE)) { const data = fs.readFileSync(NOTIFIED_ITEMS_FILE, 'utf8'); return new Map(JSON.parse(data)); } } catch (error) { console.warn('⚠ Failed to load notified items file:', error.message); } return new Map(); } // Save notified items to file function saveNotifiedItems() { try { const data = JSON.stringify([...notifiedItems.entries()]); fs.writeFileSync(NOTIFIED_ITEMS_FILE, data); } catch (error) { console.error('✗ Failed to save notified items:', error.message); } } // Notification history (store last 50 notified items) let notifiedItems = loadNotifiedItems(); // key: legacyItemId, value: timestamp /** * Check if item was already notified */ function isAlreadyNotified(legacyItemId) { return notifiedItems.has(legacyItemId); } /** * Add item to notified list */ function addToNotified(legacyItemId) { notifiedItems.set(legacyItemId, Date.now()); // Keep only last 50 items if (notifiedItems.size > 50) { const oldestKey = notifiedItems.keys().next().value; notifiedItems.delete(oldestKey); } // Save to file saveNotifiedItems(); } // Exchange rate cache (AUD to USD) let audToUsdRate = 0.65; let rateExpireAt = 0; /** * Get real-time AUD to USD exchange rate */ async function getExchangeRate() { const now = Date.now(); // Return cached rate if still valid (1 hour) if (audToUsdRate && now < rateExpireAt) { return audToUsdRate; } try { const res = await axios.get( 'https://api.exchangerate-api.com/v4/latest/AUD', { timeout: 5000 } ); audToUsdRate = res.data.rates?.USD || 0.65; rateExpireAt = now + 60 * 60 * 1000; // Cache for 1 hour console.log(`💱 Exchange rate updated: 1 AUD = ${audToUsdRate} USD`); return audToUsdRate; } catch (error) { console.warn('⚠ Failed to fetch exchange rate, using cached value'); return audToUsdRate || 0.65; } } /** * Convert price to USD */ async function convertToUSD(price, currency) { if (currency === 'USD') return price; if (currency === 'AUD') { const rate = await getExchangeRate(); return price * rate; } return price; // Default: assume USD } // ========================== EBAY AUTH ========================== let ebayTokenCache = { token: null, expireAt: 0 }; async function getEbayAccessToken() { const now = Date.now(); if (ebayTokenCache.token && now < ebayTokenCache.expireAt) { return ebayTokenCache.token; } const auth = Buffer.from( `${EBAY_CLIENT_ID}:${EBAY_CLIENT_SECRET}` ).toString("base64"); const res = await axios.post( "https://api.ebay.com/identity/v1/oauth2/token", "grant_type=client_credentials&scope=https://api.ebay.com/oauth/api_scope", { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${auth}` } } ); ebayTokenCache.token = res.data.access_token; ebayTokenCache.expireAt = now + (res.data.expires_in - 60) * 1000; return ebayTokenCache.token; } // ========================== SEARCH EBAY ITEMS ========================== async function searchEbayItems({ q, marketplace = "EBAY_US" }) { const token = await getEbayAccessToken(); const res = await axios.get( "https://api.ebay.com/buy/browse/v1/item_summary/search", { headers: { Authorization: `Bearer ${token}`, "X-EBAY-C-MARKETPLACE-ID": marketplace }, params: { q, limit: 50, sort: "newlyListed" }, timeout: 20000 } ); return (res.data.itemSummaries || []).map(item => ({ legacyItemId: item.legacyItemId, title: item.title, price: item.price?.value, currency: item.price?.currency, condition: item.condition, seller: item.seller?.username, image: item.image?.imageUrl })); } /** * Send notification to Telegram group */ async function notifyTelegram(message) { if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) { console.warn('⚠ Telegram configuration missing, skipping notification'); return; } try { await axios.post( `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { chat_id: TELEGRAM_CHAT_ID, text: message, parse_mode: 'HTML' } ); // console.log(message); console.log('✓ Telegram notification sent'); } catch (error) { console.error('✗ Failed to send Telegram notification:', error.response?.data || error.message); } } /** * Check items and notify if price is below threshold */ async function checkAndNotify(keyword, marketplace = "EBAY_US") { console.log(`\n[${new Date().toISOString()}] Checking items for: ${keyword} (${marketplace})`); try { const items = await searchEbayItems({ q: keyword, marketplace }); if (items.length === 0) { console.log('No items found'); return; } // Find items below price threshold (skip already notified) const cheapItems = []; for (const item of items) { if (isAlreadyNotified(item.legacyItemId)) { continue; } const priceInUSD = await convertToUSD(parseFloat(item.price || 0), item.currency); if (priceInUSD < PRICE_THRESHOLD) { cheapItems.push({ ...item, priceInUSD }); addToNotified(item.legacyItemId); } } if (cheapItems.length > 0) { console.log(`Found ${cheapItems.length} item(s) below $${PRICE_THRESHOLD}`); const message = `========= ${keyword.toUpperCase()} (${marketplace}) ==========\n` + cheapItems.slice(0, 10).map(item => `${item.title}\n$${item.price} ${item.currency || 'USD'}\nhttps://${item.currency === 'USD' ? 'www.ebay.com' : 'www.ebay.com.au'}/itm/${item.legacyItemId}` ).join('\n==================================\n') + '\n=================================='; await notifyTelegram(message); } else { console.log(`No items found below $${PRICE_THRESHOLD} USD`); } } catch (error) { console.error('Error checking items:', error.message); } } // Middleware app.use(express.json()); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Manual trigger endpoint app.post('/check', express.json(), async (req, res) => { const { keyword, marketplace } = req.body; if (!keyword) { return res.status(400).json({ error: 'Keyword is required' }); } await checkAndNotify(keyword, marketplace); res.json({ success: true, message: 'Check completed' }); }); // eBay Search Configurations const EBAY_SEARCH_CONFIGS = [ { marketplace: "EBAY_US", q: "MACBOOK PRO M1 32GB", }, { marketplace: "EBAY_AU", q: "MACBOOK PRO M1 32GB", }, ]; // Run every minute cron.schedule('* * * * *', async () => { console.log('\n=== Running scheduled check ==='); for (const config of EBAY_SEARCH_CONFIGS) { await checkAndNotify(config.q, config.marketplace); } }); // Start server app.listen(PORT, () => { console.log(`🚀 Server running on port ${PORT}`); console.log(`⏰ Scheduled check running every minute`); console.log(`💰 Price threshold: $${PRICE_THRESHOLD} USD`); });