import fetch from 'node-fetch'; import cron from 'node-cron'; import fs from 'fs'; import path from 'path'; // Configuration const CONFIG = { API_URL: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/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 * * * *', // Every hour at minute 0 USER_AGENT: 'SD Park Pass Monitor/1.0' }; // Merge in any overrides from config.js import { CONFIG_OVERRIDE } from './config.js'; Object.assign(CONFIG, CONFIG_OVERRIDE); /** * Loads the last known availability count from the state file * @returns {number} The last availability count, or 0 if file doesn't exist */ function loadLastAvailabilityCount() { try { if (fs.existsSync(CONFIG.STATE_FILE)) { const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8')); return data.availabilityCount || 0; } } catch (error) { console.error('Error reading state file:', error.message); } return 0; } /** * Saves the current availability count to the state file * @param {number} count - The current availability count * @param {Object} metadata - Additional metadata to save */ function saveAvailabilityCount(count, metadata = {}) { try { const data = { availabilityCount: count, lastUpdated: new Date().toISOString(), ...metadata }; fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2)); console.log(`Saved availability count: ${count}`); } catch (error) { console.error('Error saving state file:', error.message); } } /** * 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: { '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 Rancho Penasquitos 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 from the library API * @returns {Object|null} The API response or null if failed */ async function fetchAvailabilityData() { try { console.log(`Fetching availability data from: ${CONFIG.API_URL}`); const response = await fetch(CONFIG.API_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'); return data; } catch (error) { console.error('Error fetching availability data:', error.message); return null; } } /** * Main monitoring function that checks availability and sends notifications */ async function checkAvailability() { console.log('\n--- Starting availability check ---'); console.log(`Time: ${new Date().toISOString()}`); try { // Fetch current availability data const apiResponse = await fetchAvailabilityData(); if (!apiResponse) { console.log('Failed to fetch data, skipping this check'); return; } // Count currently available items const currentCount = countAvailableItems(apiResponse); console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${currentCount}`); // Load previous count const previousCount = loadLastAvailabilityCount(); console.log(`Previous available items: ${previousCount}`); // Check if availability increased (new items became available) if (currentCount > previousCount) { const newItems = currentCount - previousCount; const title = 'Parking Pass Available!'; const message = `${newItems} new parking pass(es) became available at ${CONFIG.BRANCH_NAME} branch. Total available: ${currentCount}`; console.log(`šŸ“¢ Sending notification: ${message}`); await sendNotification(title, message, 'high'); } else if (currentCount < previousCount) { console.log(`Availability decreased by ${previousCount - currentCount} parking pass(es)`); } else { console.log('No change in availability'); } // Save current state saveAvailabilityCount(currentCount, { previousCount, branch: CONFIG.BRANCH_NAME, apiUrl: CONFIG.API_URL }); } 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.API_URL}`); console.log(`Branch: ${CONFIG.BRANCH_NAME}`); console.log(`Ntfy Topic: ${CONFIG.NTFY_TOPIC}`); console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL}`); // Send startup notification await sendNotification( 'SD Park Pass Monitor Started', `Now monitoring ${CONFIG.BRANCH_NAME} branch for park pass availability`, '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 };