399 lines
14 KiB
JavaScript
399 lines
14 KiB
JavaScript
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
|
|
};
|