CheckPriceMacbook/src/index.js

289 lines
8.0 KiB
JavaScript

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`);
});