import fetch from 'node-fetch'; import cron from 'node-cron'; import fs from 'fs'; import path from 'path'; // Configuration const CONFIG = { // Items to monitor - each item has its own API endpoint ITEMS: [ { id: 'S161C1805116', name: 'Hiking Backpack with Park Pass', description: 'CA State Library Parks Pass Hiking Backpack', url: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability' }, { id: 'S161C1690437', name: 'Park Pass Only', description: 'CA State Library Parks Pass', url: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1690437/availability' } ], BRANCH_NAME: 'Rancho Penasquitos', NTFY_TOPIC: 'sdparkpass', // Change this to your preferred topic NTFY_SERVER: 'https://ntfy.sh', // Change this if using your own ntfy server STATE_FILE: path.join(process.cwd(), 'last_availability.json'), POLL_INTERVAL: '0 9-20/1 * * 1-6', // Every hour between 9 AM and 8 PM, Monday to Saturday USER_AGENT: 'SD Park Pass Monitor/1.0' }; // Try to merge in any overrides from config.js if it exists try { const { CONFIG_OVERRIDE } = await import('./config.js'); Object.assign(CONFIG, CONFIG_OVERRIDE); } catch (error) { // config.js doesn't exist or has no overrides - continue with defaults console.log('No config.js found, using default configuration'); } /** * Loads the last known availability counts from the state file * @returns {Object} Object with item IDs as keys and counts as values */ function loadLastAvailabilityCounts() { try { if (fs.existsSync(CONFIG.STATE_FILE)) { const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8')); // Support both old single-item format and new multi-item format if (data.availabilityCount !== undefined) { // Old format - assume it's for the backpack item return { 'S161C1805116': data.availabilityCount || 0 }; } return data.itemCounts || {}; } } catch (error) { console.error('Error reading state file:', error.message); } return {}; } /** * Saves the current availability counts to the state file * @param {Object} itemCounts - Object with item IDs as keys and counts as values * @param {Object} metadata - Additional metadata to save */ function saveAvailabilityCounts(itemCounts, metadata = {}) { try { const data = { itemCounts, lastUpdated: new Date().toISOString(), branch: CONFIG.BRANCH_NAME, ...metadata }; fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2)); console.log(`Saved availability counts:`, itemCounts); } catch (error) { console.error('Error saving state file:', error.message); } } /** * Legacy function for backward compatibility * @returns {number} Total count across all items */ function loadLastAvailabilityCount() { const counts = loadLastAvailabilityCounts(); return Object.values(counts).reduce((sum, count) => sum + count, 0); } /** * Legacy function for backward compatibility * @param {number} count - The current availability count * @param {Object} metadata - Additional metadata to save */ function saveAvailabilityCount(count, metadata = {}) { // For backward compatibility, save as total count for backpack item const itemCounts = { 'S161C1805116': count }; saveAvailabilityCounts(itemCounts, metadata); } /** * Sends a notification via ntfy * @param {string} title - Notification title * @param {string} message - Notification message * @param {string} priority - Priority level (low, default, high) */ async function sendNotification(title, message, priority = 'default') { try { const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, { method: 'POST', headers: { 'Content-Type': 'text/plain', 'Title': title, 'Priority': priority, 'Tags': 'park,pass,library', 'User-Agent': CONFIG.USER_AGENT }, body: message }); if (response.ok) { console.log('Notification sent successfully'); } else { console.error('Failed to send notification:', response.status, response.statusText); } } catch (error) { console.error('Error sending notification:', error.message); } } /** * Counts available or recently returned items for the specified branch * @param {Object} apiResponse - The API response object * @returns {number} Count of available/recently returned items */ function countAvailableItems(apiResponse) { try { if (!apiResponse?.entities?.bibItems) { console.log('No bibItems found in API response'); return 0; } let availableCount = 0; // Loop through all bibItems in the response const bibItems = apiResponse.entities.bibItems; for (const itemId in bibItems) { const item = bibItems[itemId]; // Check if this is the desired branch if (item.branchName?.includes(CONFIG.BRANCH_NAME)) { // Check if item is available (statusType is not "UNAVAILABLE") if (item.availability?.statusType && item.availability.statusType !== 'UNAVAILABLE') { availableCount++; console.log(`Found available item at ${CONFIG.BRANCH_NAME}: ${item.availability.statusType} (${item.availability.libraryStatus || 'No status'})`); } // Also check for items with no dueDate (not checked out) else if (item.availability && !item.dueDate && item.branchName.includes(CONFIG.BRANCH_NAME)) { availableCount++; console.log(`Found available item at ${CONFIG.BRANCH_NAME}: Available (no due date)`); } } } return availableCount; } catch (error) { console.error('Error counting available items:', error.message); return 0; } } /** * Fetches availability data for a specific item from the library API * @param {Object} item - Item configuration object * @returns {Object|null} The API response or null if failed */ async function fetchAvailabilityDataForItem(item) { try { console.log(`Fetching availability data for ${item.name} from: ${item.url}`); const response = await fetch(item.url, { headers: { 'User-Agent': CONFIG.USER_AGENT, 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); console.log(`Successfully fetched availability data for ${item.name}`); return data; } catch (error) { console.error(`Error fetching availability data for ${item.name}:`, error.message); return null; } } /** * Fetches availability data for all configured items * @returns {Object} Object with item IDs as keys and API responses as values */ async function fetchAllAvailabilityData() { const results = {}; for (const item of CONFIG.ITEMS) { const data = await fetchAvailabilityDataForItem(item); results[item.id] = data; } return results; } /** * Legacy function for backward compatibility * @returns {Object|null} The API response for the backpack item or null if failed */ async function fetchAvailabilityData() { const backpackItem = CONFIG.ITEMS.find(item => item.id === 'S161C1805116'); if (backpackItem) { return await fetchAvailabilityDataForItem(backpackItem); } return null; } /** * Main monitoring function that checks availability for all items and sends notifications */ async function checkAvailability() { console.log('\n--- Starting availability check for all items ---'); console.log(`Time: ${new Date().toISOString()}`); try { // Fetch current availability data for all items const allApiResponses = await fetchAllAvailabilityData(); // Count currently available items for each type const currentCounts = {}; let totalCurrentCount = 0; for (const item of CONFIG.ITEMS) { const apiResponse = allApiResponses[item.id]; if (apiResponse) { const count = countAvailableItems(apiResponse); currentCounts[item.id] = count; totalCurrentCount += count; console.log(`Current available ${item.name} at ${CONFIG.BRANCH_NAME}: ${count}`); } else { console.log(`Failed to fetch data for ${item.name}, using 0 count`); currentCounts[item.id] = 0; } } // Load previous counts const previousCounts = loadLastAvailabilityCounts(); const totalPreviousCount = Object.values(previousCounts).reduce((sum, count) => sum + count, 0); console.log(`Previous available items: ${totalPreviousCount} (${JSON.stringify(previousCounts)})`); // Check for changes and send notifications const notifications = []; let hasChanges = false; for (const item of CONFIG.ITEMS) { const currentCount = currentCounts[item.id] || 0; const previousCount = previousCounts[item.id] || 0; if (currentCount > previousCount) { const newItems = currentCount - previousCount; notifications.push(`${newItems} ${item.name} became available`); hasChanges = true; console.log(`šŸ“ˆ ${item.name}: +${newItems} (${previousCount} → ${currentCount})`); } else if (currentCount < previousCount) { const lostItems = previousCount - currentCount; console.log(`šŸ“‰ ${item.name}: -${lostItems} (${previousCount} → ${currentCount})`); hasChanges = true; } } if (notifications.length > 0) { const title = 'SD Park Pass Available!'; const message = `${notifications.join(' and ')} at ${CONFIG.BRANCH_NAME} branch. Total available: ${totalCurrentCount}`; console.log(`šŸ“¢ Sending notification: ${message}`); await sendNotification(title, message, 'high'); } else if (hasChanges) { console.log('Items became unavailable (no notification sent)'); } else { console.log('No change in availability'); } // Save current state saveAvailabilityCounts(currentCounts, { previousCounts, totalCurrent: totalCurrentCount, totalPrevious: totalPreviousCount, branch: CONFIG.BRANCH_NAME }); } catch (error) { console.error('Error in checkAvailability:', error.message); // Send error notification await sendNotification( 'SD Park Pass Monitor Error', `Error checking availability: ${error.message}`, 'low' ); } console.log('--- Availability check completed ---\n'); } /** * Initializes the monitoring service */ async function initialize() { console.log('šŸš€ SD Park Pass Monitor Starting...'); console.log(`Monitoring ${CONFIG.ITEMS.length} item types:`); CONFIG.ITEMS.forEach(item => { console.log(` - ${item.name} (${item.id})`); }); console.log(`Branch: ${CONFIG.BRANCH_NAME}`); console.log(`Ntfy Topic: ${CONFIG.NTFY_TOPIC}`); console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL} (hourly)`); // Send startup notification await sendNotification( 'SD Park Pass Monitor Started', `Now monitoring ${CONFIG.ITEMS.length} park pass types at ${CONFIG.BRANCH_NAME} branch`, 'low' ); // Run initial check console.log('Running initial availability check...'); await checkAvailability(); // Schedule hourly checks console.log('Setting up hourly monitoring schedule...'); cron.schedule(CONFIG.POLL_INTERVAL, checkAvailability, { scheduled: true, timezone: "America/Los_Angeles" // San Diego timezone }); console.log('āœ… SD Park Pass Monitor is now running!'); console.log('Press Ctrl+C to stop the monitor'); } /** * Graceful shutdown handler */ process.on('SIGINT', async () => { console.log('\nšŸ›‘ Shutting down SD Park Pass Monitor...'); await sendNotification( 'SD Park Pass Monitor Stopped', 'SD Park Pass monitoring has been stopped', 'low' ); console.log('Goodbye! šŸ‘‹'); process.exit(0); }); // Handle uncaught errors process.on('uncaughtException', async (error) => { console.error('Uncaught Exception:', error); await sendNotification( 'SD Park Pass Monitor Crashed', `Critical error: ${error.message}`, 'high' ); process.exit(1); }); // Start the application if (import.meta.url === `file://${process.argv[1]}`) { initialize().catch(console.error); } export { checkAvailability, sendNotification, CONFIG, countAvailableItems, loadLastAvailabilityCount, saveAvailabilityCount, loadLastAvailabilityCounts, saveAvailabilityCounts, fetchAllAvailabilityData, fetchAvailabilityDataForItem };