Initial commit
This commit is contained in:
281
index.js
Normal file
281
index.js
Normal file
@ -0,0 +1,281 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user