Expand to both parking pass items

This commit is contained in:
Aram Chia Sarafian
2025-07-14 00:47:17 -07:00
parent 95dfdabebb
commit 2cf6afb572
7 changed files with 649 additions and 361 deletions

233
index.js
View File

@ -5,52 +5,97 @@ import path from 'path';
// Configuration
const CONFIG = {
API_URL: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability',
// 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 * * * *', // Every hour at minute 0
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'
};
// Merge in any overrides from config.js
import { CONFIG_OVERRIDE } from './config.js';
Object.assign(CONFIG, CONFIG_OVERRIDE);
// 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 count from the state file
* @returns {number} The last availability count, or 0 if file doesn't exist
* Loads the last known availability counts from the state file
* @returns {Object} Object with item IDs as keys and counts as values
*/
function loadLastAvailabilityCount() {
function loadLastAvailabilityCounts() {
try {
if (fs.existsSync(CONFIG.STATE_FILE)) {
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
return data.availabilityCount || 0;
// 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 0;
return {};
}
/**
* Saves the current availability count to the state file
* 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 = {}) {
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);
}
// For backward compatibility, save as total count for backpack item
const itemCounts = { 'S161C1805116': count };
saveAvailabilityCounts(itemCounts, metadata);
}
/**
@ -64,6 +109,7 @@ async function sendNotification(title, message, priority = 'default') {
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',
@ -101,7 +147,7 @@ function countAvailableItems(apiResponse) {
for (const itemId in bibItems) {
const item = bibItems[itemId];
// Check if this is the Rancho Penasquitos branch
// 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 &&
@ -129,14 +175,15 @@ function countAvailableItems(apiResponse) {
}
/**
* Fetches availability data from the library API
* 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 fetchAvailabilityData() {
async function fetchAvailabilityDataForItem(item) {
try {
console.log(`Fetching availability data from: ${CONFIG.API_URL}`);
console.log(`Fetching availability data for ${item.name} from: ${item.url}`);
const response = await fetch(CONFIG.API_URL, {
const response = await fetch(item.url, {
headers: {
'User-Agent': CONFIG.USER_AGENT,
'Accept': 'application/json'
@ -148,56 +195,112 @@ async function fetchAvailabilityData() {
}
const data = await response.json();
console.log('Successfully fetched availability data');
console.log(`Successfully fetched availability data for ${item.name}`);
return data;
} catch (error) {
console.error('Error fetching availability data:', error.message);
console.error(`Error fetching availability data for ${item.name}:`, error.message);
return null;
}
}
/**
* Main monitoring function that checks availability and sends notifications
* 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 ---');
console.log('\n--- Starting availability check for all items ---');
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;
// 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;
}
}
// Count currently available items
const currentCount = countAvailableItems(apiResponse);
console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${currentCount}`);
// 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)})`);
// 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}`;
// 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 (currentCount < previousCount) {
console.log(`Availability decreased by ${previousCount - currentCount} parking pass(es)`);
} else if (hasChanges) {
console.log('Items became unavailable (no notification sent)');
} else {
console.log('No change in availability');
}
// Save current state
saveAvailabilityCount(currentCount, {
previousCount,
branch: CONFIG.BRANCH_NAME,
apiUrl: CONFIG.API_URL
saveAvailabilityCounts(currentCounts, {
previousCounts,
totalCurrent: totalCurrentCount,
totalPrevious: totalPreviousCount,
branch: CONFIG.BRANCH_NAME
});
} catch (error) {
@ -219,15 +322,18 @@ async function checkAvailability() {
*/
async function initialize() {
console.log('🚀 SD Park Pass Monitor Starting...');
console.log(`Monitoring: ${CONFIG.API_URL}`);
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}`);
console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL} (hourly)`);
// Send startup notification
await sendNotification(
'SD Park Pass Monitor Started',
`Now monitoring ${CONFIG.BRANCH_NAME} branch for park pass availability`,
`Now monitoring ${CONFIG.ITEMS.length} park pass types at ${CONFIG.BRANCH_NAME} branch`,
'low'
);
@ -278,4 +384,15 @@ if (import.meta.url === `file://${process.argv[1]}`) {
initialize().catch(console.error);
}
export { checkAvailability, sendNotification, CONFIG, countAvailableItems, loadLastAvailabilityCount, saveAvailabilityCount };
export {
checkAvailability,
sendNotification,
CONFIG,
countAvailableItems,
loadLastAvailabilityCount,
saveAvailabilityCount,
loadLastAvailabilityCounts,
saveAvailabilityCounts,
fetchAllAvailabilityData,
fetchAvailabilityDataForItem
};