// ==UserScript==
// @name Booky — Wharton Spaces auto-booker
// @namespace gsr-autobook
// @version 2.6
// @description Watches Wharton Spaces for open rooms and books them for you.
// @match https://apps.wharton.upenn.edu/gsr/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function waitForSite() {
if (!document.querySelector('select.custom-select')) return setTimeout(waitForSite, 500);
(() => {
const DEFAULTS = { building: '1,JMHH', earliestStart: '12:00 PM', latestStart: '1:00 PM', idealStart: '12:30 PM', flex: 30, dayOffset: 0, idealMinutes: 60, minMinutes: 30, checkEveryMin: 3, autoBook: true, keepUpgrading: true, autoSwap: false };
const KEY = 'gsrAutoBookState', CFGKEY = 'gsrAutoBookCfg';
const wait = ms => new Promise(r => setTimeout(r, ms));
const toMin = t => { const [, h, m, ap] = t.match(/(\d+):(\d+)\s*(AM|PM)/i); return (+h % 12 + (/pm/i.test(ap) ? 12 : 0)) * 60 + +m; };
const fmt = m => { const h = Math.floor(m / 60), mm = String(m % 60).padStart(2, '0'); return `${h % 12 || 12}:${mm} ${h < 12 ? 'AM' : 'PM'}`; };
const clock = (d = new Date()) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const ls = { get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, del(k) { try { localStorage.removeItem(k); } catch {} } };
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const MAX_MIN = 90;
const CFG = Object.assign({}, DEFAULTS, ls.get(CFGKEY) || {});
CFG.idealMinutes = Math.min(+CFG.idealMinutes || 60, MAX_MIN); CFG.minMinutes = Math.min(+CFG.minMinutes || 30, CFG.idealMinutes);
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const today = ymd(new Date());
const targetDate = () => { const d = new Date(); d.setDate(d.getDate() + (+CFG.dayOffset || 0)); return ymd(d); };
const isToday = () => targetDate() === ymd(new Date());
const dayLabel = () => new Date(targetDate() + 'T00:00').toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' });
let state = ls.get(KEY) || {}; state.booked = (state.booked || []).filter(b => b.date && b.date >= today);
let timer = null, busy = false, nextAt = 0;
try { clearInterval(window.GSR?.timer); window.GSR?.stop?.(); } catch {}
document.getElementById('gsr-banner')?.remove();
document.getElementById('gsr-panel')?.remove();
const P = document.createElement('div'); P.id = 'gsr-panel';
const siteSel = document.querySelector('select.custom-select');
const buildings = siteSel ? [...siteSel.options].map(o => [o.value, o.text]) : [['1,JMHH', 'Huntsman Hall']];
const times = []; for (let m = 7 * 60; m <= 23 * 60 + 30; m += 30) times.push(fmt(m));
const opt = (list, cur) => list.map(([v, t]) => ``).join('');
const tOpt = cur => opt(times.map(t => [t, t]), cur);
const lens = [30, 60, 90].map(n => [n, n + ' min']);
const days = [...Array(14)].map((_, i) => { const d = new Date(); d.setDate(d.getDate() + i); const lbl = d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' }); return [i, i === 0 ? `Today (${lbl})` : i === 1 ? `Tomorrow (${lbl})` : lbl]; });
P.innerHTML = `
BookyPaused –
`;
document.body.appendChild(P);
const $ = s => P.querySelector(s);
const summary = () => `${dayLabel()} · ${+CFG.flex ? `starts ${CFG.earliestStart}–${CFG.latestStart}` : `starts ${CFG.idealStart}`} · ${CFG.idealMinutes} min`;
function renderBooked() {
$('#gsr-bkh').textContent = `Booked by Booky · ${dayLabel()}`;
const mine = state.booked.filter(b => b.date === targetDate()).sort((a, b) => a.start - b.start);
$('#gsr-bk').innerHTML = mine.length
? mine.map(b => `${fmt(b.start)} · ${esc(b.room)}${b.minutes} min
`).join('')
: 'Nothing yet
';
}
function say(head, sub = '', kind = '', notify = false) {
$('#gsr-now').className = 'now' + (kind ? ' ' + kind : '');
$('#gsr-head').textContent = head;
$('#gsr-time').textContent = timer && !busy && kind !== 'hot' && nextAt ? `next check ${clock(new Date(nextAt))}` : clock();
$('#gsr-sub').textContent = sub;
renderBooked();
console.log('[Booky]', head, '—', sub);
if (notify) {
document.title = '🔔 Booky: ' + head;
try { if (Notification.permission === 'granted') new Notification('Booky', { body: sub ? `${head} — ${sub}` : head }); } catch {}
}
}
function enforceOrder(changed) {
const t = toMin(CFG.idealStart), f = +CFG.flex || 0;
CFG.earliestStart = fmt(Math.max(7 * 60, t - f));
CFG.latestStart = fmt(Math.min(23 * 60 + 30, t + f));
if (CFG.minMinutes > CFG.idealMinutes) { if (changed === 'minMinutes') CFG.idealMinutes = CFG.minMinutes; else CFG.minMinutes = CFG.idealMinutes; }
['idealMinutes', 'minMinutes'].forEach(k => { P.querySelector(`[data-k="${k}"]`).value = String(CFG[k]); });
[...P.querySelector('[data-k="minMinutes"]').options].forEach(o => o.disabled = +o.value > CFG.idealMinutes);
$('#gsr-range').textContent = f === 0 ? `Starts at ${CFG.idealStart} only` : `Starts ${CFG.earliestStart} – ${CFG.latestStart}`;
ls.set(CFGKEY, CFG);
}
P.querySelectorAll('[data-k]').forEach(el => el.addEventListener('change', () => {
CFG[el.dataset.k] = el.type === 'checkbox' ? el.checked : el.hasAttribute('data-n') ? +el.value : el.value;
enforceOrder(el.dataset.k);
if (timer) { start('Settings updated'); } else say('Ready to watch', summary());
}));
enforceOrder();
$('.hd').onclick = () => { P.classList.toggle('min'); $('#gsr-min').textContent = P.classList.contains('min') ? '+' : '–'; };
$('.go').onclick = () => start();
$('.st').onclick = () => stop('Paused', summary());
$('.mr').onclick = () => showReservations();
$('.rs').onclick = () => { ls.del(KEY); state = { booked: [] }; say(timer ? 'Watching' : 'Ready to watch', 'Booking memory cleared · ' + summary()); };
let siteSeen = null;
function readSite() {
const all = [...document.querySelectorAll('select.custom-select')];
const tSel = all.find(x => [...x.options].some(o => /T\d\d:\d\d/.test(o.value)));
const dSel = all.find(x => [...x.options].some(o => /minute/i.test(o.text)));
const fpd = document.querySelector('.flatpickr-input')?._flatpickr?.selectedDates?.[0];
const tTxt = tSel?.selectedOptions?.[0]?.text || '';
return {
building: all[0]?.value || null,
date: fpd ? ymd(fpd) : null,
start: /\d+:\d+\s*(AM|PM)/i.test(tTxt) ? toMin(tTxt) : null,
minutes: dSel ? +dSel.value : null,
};
}
function applySite(now, before) {
const changed = [];
const setField = (k, v) => { CFG[k] = v; const el = P.querySelector(`[data-k="${k}"]`); if (el) el.value = String(v); };
if (now.building && now.building !== before.building && buildings.some(([v]) => v === now.building)) { setField('building', now.building); changed.push('building'); }
if (now.date && now.date !== before.date) {
const off = Math.round((new Date(now.date + 'T00:00') - new Date(ymd(new Date()) + 'T00:00')) / 864e5);
if (off >= 0 && off < days.length) { setField('dayOffset', off); changed.push('date'); }
}
if (now.start != null && now.start !== before.start && times.includes(fmt(now.start))) { setField('idealStart', fmt(now.start)); changed.push('time'); }
if (now.minutes && now.minutes !== before.minutes && lens.some(([v]) => v === now.minutes)) { setField('idealMinutes', now.minutes); changed.push('length'); }
if (!changed.length) return;
enforceOrder('idealMinutes');
if (timer) start('Matched the page'); else say('Ready to watch', 'Matched the page · ' + summary());
}
setInterval(() => {
if (busy) return;
const now = readSite(), key = JSON.stringify(now);
if (siteSeen === null) { siteSeen = key; return; }
if (key !== siteSeen) { const before = JSON.parse(siteSeen); siteSeen = key; applySite(now, before); }
}, 1000);
const resUrl = () => [...document.querySelectorAll('a')].find(a => /my reservations/i.test(a.textContent))?.href || '/spaces/';
async function showReservations() {
say('Checking your reservations…', '');
try {
const html = await fetch(resUrl(), { credentials: 'include' }).then(r => r.text());
const doc = new DOMParser().parseFromString(html, 'text/html');
const rows = [...doc.querySelectorAll('tr')]
.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.replace(/\s+/g, ' ').trim()).filter(x => x && !/^delete$/i.test(x)))
.filter(c => c.some(x => /\d{1,2}\/\d{1,2}\/\d{4}/.test(x)));
if (!rows.length) throw new Error('none');
say('Your reservations', `${rows.length} on My reservations`);
$('#gsr-bkh').textContent = 'On My reservations';
$('#gsr-bk').innerHTML = rows.map(c => {
const date = c.find(x => /\d{1,2}\/\d{1,2}\/\d{4}/.test(x)) || '';
const rest = c.filter(x => x !== date);
return `${esc(rest[0] || '')} · ${esc(rest[1] || '')}${esc(date.replace(/\/\d{4}$/, ''))}
`;
}).join('');
} catch {
window.open(resUrl(), '_blank');
say('Opened My reservations', 'In a new tab');
}
}
async function loadMyReservations() {
const html = await fetch(resUrl(), { credentials: 'include' }).then(r => r.text());
const doc = new DOMParser().parseFromString(html, 'text/html');
let token = null; try { token = JSON.parse(doc.getElementById('app_code')?.textContent || '{}').id; } catch {}
const rows = [...doc.querySelectorAll('tr')].map(tr => {
const text = tr.textContent.replace(/\s+/g, ' ');
const id = (tr.innerHTML.match(/cancel_reservation\(\s*this\s*,\s*['"]?(\d+)/) || [])[1];
return { text, id };
}).filter(r => r.id);
return { token, rows };
}
const resDate = d => { const [y, m, dd] = d.split('-'); return `${m}/${dd}/${y}`; };
const resTime = m => { if (m === 720) return 'noon'; if (m === 0) return 'midnight'; const h = Math.floor(m / 60) % 12 || 12, mm = m % 60; return `${h}${mm ? ':' + String(mm).padStart(2, '0') : ''} ${m < 720 ? 'a.m.' : 'p.m.'}`; };
async function cancelBooking(b) {
const { token, rows } = await loadMyReservations();
if (!token) throw new Error("Couldn't get permission to cancel from My reservations");
const room = b.room.replace(/\s+/g, ' ');
const row = rows.find(r => r.text.includes(resDate(b.date)) && r.text.includes(room) && (' ' + r.text.toLowerCase()).includes(' ' + resTime(b.start)));
if (!row) throw new Error(`Couldn't find ${b.room} at ${fmt(b.start)} on My reservations`);
const res = await fetch(resUrl() + '/gsr_cancel/' + row.id, { method: 'POST', credentials: 'include', headers: { 'X-CSRFToken': token, 'X-Requested-With': 'XMLHttpRequest' } });
if (!res.ok) throw new Error(`The site refused to cancel ${b.room} (${res.status})`);
const after = await loadMyReservations();
if (after.rows.some(r => r.id === row.id)) throw new Error(`${b.room} still shows on My reservations`);
state.booked = state.booked.filter(x => x !== b); ls.set(KEY, state);
}
const setSel = (s, v) => { Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, v); s.dispatchEvent(new Event('change', { bubbles: true })); };
const sels = () => {
const all = [...document.querySelectorAll('select.custom-select')];
return {
bSel: all[0],
tSel: all.find(s => [...s.options].some(o => /T\d\d:\d\d/.test(o.value))),
dSel: all.find(s => [...s.options].some(o => /minute/i.test(o.text))),
};
};
function nowMin() { const d = new Date(); return d.getHours() * 60 + d.getMinutes(); }
async function prepList() {
if (!document.querySelector('select.custom-select') && document.querySelector('#reserve_form')) {
document.querySelector('.fa-arrow-left')?.closest('a,button,div')?.click(); await wait(1500);
}
if (!document.querySelector('select.custom-select')) throw new Error('Search filters not found — go back to the main Spaces page');
if (!sels().tSel) { document.querySelector('.grid-list-view')?.click(); await wait(1500); }
const fp = document.querySelector('.flatpickr-input')?._flatpickr;
if (!fp) throw new Error('Date picker not found on the page');
if (!fp.selectedDates[0] || ymd(fp.selectedDates[0]) !== targetDate()) { fp.setDate(new Date(targetDate() + 'T00:00'), true); await wait(2000); }
const { bSel } = sels();
const other = [...bSel.options].map(o => o.value).find(v => v !== CFG.building);
setSel(bSel, other); await wait(1500);
setSel(sels().bSel, CFG.building); await wait(2000);
if (!sels().tSel) { document.querySelector('.grid-list-view')?.click(); await wait(1500); }
if (!sels().tSel) throw new Error('List View filters not found');
}
async function query(start, minutes) {
let { tSel, dSel } = sels();
const tOpt = [...tSel.options].find(o => /\d+:\d+\s*(AM|PM)/i.test(o.text) && toMin(o.text) === start);
const dOpt = [...dSel.options].find(o => +o.value === minutes);
if (!tOpt || !dOpt) return [];
if (tSel.value !== tOpt.value) { setSel(tSel, tOpt.value); await wait(1500); }
({ dSel } = sels());
if (dSel.value !== String(minutes)) { setSel(dSel, String(minutes)); await wait(1500); }
return [...document.querySelectorAll('.Media-body')].filter(r => r.querySelector('.green')).map(r => {
const room = ((r.querySelector('.list-view-item-building')?.innerText || '') + ' ' + (r.querySelector('.list-view-item-building-number')?.innerText || '')).trim();
const st = r.querySelector('.list-view-item__start-time')?.innerText || '';
return { room, start: /\d+:\d+/.test(st) ? toMin(st) : start, minutes, btn: r.querySelector('.green') };
});
}
async function findBest(onlyMinutes) {
const lo = toMin(CFG.earliestStart), hi = toMin(CFG.latestStart), target = toMin(CFG.idealStart);
const starts = []; for (let t = lo; t <= hi; t += 30) if (!isToday() || t >= nowMin() - 30) starts.push(t);
starts.sort((a, b) => Math.abs(a - target) - Math.abs(b - target));
const lns = []; for (let m = onlyMinutes || CFG.idealMinutes; m >= (onlyMinutes || CFG.minMinutes); m -= 30) lns.push(m);
for (const m of lns) for (const t of starts) {
const hits = (await query(t, m)).filter(h => h.start >= lo && h.start <= hi);
if (hits.length) return hits.sort((a, b) => Math.abs(a.start - target) - Math.abs(b.start - target))[0];
}
return null;
}
async function book(slot) {
slot.btn.click();
for (let i = 0; i < 40 && !document.querySelector('#reserve_form'); i++) await wait(300);
const form = document.querySelector('#reserve_form');
if (!form) throw new Error('Reservation form did not open');
const btns = [...form.querySelectorAll('input[type=submit],input[type=button],button')];
const submit = btns.find(b => /book now/i.test(b.value || b.innerText)) || btns.filter(b => /reserve|submit|book|confirm/i.test(b.value || b.innerText)).pop();
if (!submit) throw new Error('"Book Now" button not found on the reservation form');
const b = { date: targetDate(), room: slot.room, start: slot.start, minutes: slot.minutes };
state.booked.push(b); ls.set(KEY, state);
submit.click();
for (let i = 0; i < 40 && /creating reservation/i.test(document.body.innerText); i++) await wait(500);
await wait(1500);
const errs = document.querySelector('#reserve_form .alert, #reserve_form [class*=error]');
if (errs && errs.innerText.trim()) { state.booked.pop(); ls.set(KEY, state); throw new Error('Site said: ' + errs.innerText.trim().slice(0, 150)); }
say(`Booked ${b.room}`, `${dayLabel()} · ${fmt(b.start)} · ${b.minutes} min`, 'hot', true);
}
const active = () => state.booked.filter(b => b.date === targetDate() && (!isToday() || b.start + b.minutes > nowMin()));
function heldMinutes() {
const s = active().sort((a, b) => a.start - b.start); let best = 0, cur = 0, end = -1;
s.forEach(b => { cur = b.start === end ? cur + b.minutes : b.minutes; end = b.start + b.minutes; best = Math.max(best, cur); });
return best;
}
const totalHeld = () => {
const d0 = ymd(new Date()), now = nowMin();
return state.booked.reduce((n, b) => {
if (b.date < d0) return n;
if (b.date > d0) return n + b.minutes;
let left = 0; for (let t = b.start; t < b.start + b.minutes; t += 30) if (t + 30 > now) left += 30;
return n + left;
}, 0);
};
const roomNum = r => { const m = r.match(/([A-Z]?)(\d+)\s*$/i); return m ? { floor: m[1] || m[2][0], n: +m[2] } : null; };
const neighbours = (a, b) => { const x = roomNum(a), y = roomNum(b); return x && y && x.floor === y.floor && a !== b && Math.abs(x.n - y.n) <= 10; };
async function check() {
const deadline = new Date(targetDate() + 'T00:00'); deadline.setMinutes(toMin(CFG.latestStart) + 1);
if (Date.now() > deadline.getTime()) return stop('Stopped', `Past your latest start time on ${dayLabel()}`, '', true);
const have = heldMinutes();
if (have >= CFG.idealMinutes) return stop(`You're set for ${dayLabel()}`, `You hold ${have} min. Press Reset to look for another room.`, 'hot');
say('Checking…', summary());
await prepList();
if (!active().length) {
const slot = await findBest();
if (!slot) return say('No openings yet', `Last checked ${clock()} · ${summary()}`);
if (!CFG.autoBook) return say(`Open: ${slot.room} at ${fmt(slot.start)}`, `${slot.minutes} min — book it on the page`, 'hot', true);
return book(slot);
}
if (!CFG.keepUpgrading) return stop(`Holding ${have} min`, 'Not looking for a longer time slot — stopped');
const cur = active().sort((a, b) => a.start - b.start).slice(-1)[0];
for (const t of [cur.start + cur.minutes, cur.start - 30]) {
if (totalHeld() + 30 > MAX_MIN) break;
if (isToday() && t < nowMin() - 30) continue;
const hits = await query(t, 30);
const pick = hits.find(h => h.room === cur.room) || hits.filter(h => neighbours(h.room, cur.room))
.sort((a, b) => Math.abs(roomNum(a.room).n - roomNum(cur.room).n) - Math.abs(roomNum(b.room).n - roomNum(cur.room).n))[0];
if (pick) {
if (!CFG.autoBook) return say(`Extension open: ${pick.room}`, `${fmt(pick.start)} · 30 min — book it on the page`, 'hot', true);
return book(pick);
}
}
const full = await findBest(CFG.idealMinutes);
if (full && CFG.autoSwap && CFG.autoBook) {
const old = active().filter(b => b.minutes < full.minutes);
say('Swapping for a longer slot…', `Cancelling ${old.map(b => `${b.room} ${fmt(b.start)}`).join(', ')} to take ${full.room} at ${fmt(full.start)}`);
for (const b of old) await cancelBooking(b);
await prepList();
const again = (await query(full.start, full.minutes));
const slot = again.find(h => h.room === full.room) || again[0];
if (!slot) return say('Cancelled, but the longer slot was taken', 'Booky will keep looking for a new room', 'bad', true);
await book(slot);
return stop(`Swapped to ${slot.room}`, `${dayLabel()} · ${fmt(slot.start)} · ${slot.minutes} min`, 'hot', true);
}
if (full && totalHeld() + full.minutes > MAX_MIN) {
return say(`Longer slot open: ${full.room}`, `${fmt(full.start)} · ${full.minutes} min — that's more than your 90 min allowance right now. Cancel your current booking in My reservations to take it.`, 'hot', true);
}
if (full) {
if (!CFG.autoBook) return say(`Full slot open: ${full.room}`, `${fmt(full.start)} · ${full.minutes} min — book it on the page`, 'hot', true);
await book(full);
return stop(`Upgraded to ${full.room}`, 'Cancel your earlier short booking in My reservations', 'hot', true);
}
say(`Holding ${have} min`, `Still looking to extend to ${CFG.idealMinutes} min`);
}
const run = async () => {
if (busy) return; busy = true; nextAt = Date.now() + CFG.checkEveryMin * 60000;
try { await check(); } catch (e) { say('Something went wrong', e.message, 'bad', true); }
finally { siteSeen = JSON.stringify(readSite()); busy = false; if (timer) $('#gsr-time').textContent = $('#gsr-now').classList.contains('hot') ? clock() : `next check ${clock(new Date(nextAt))}`; }
};
function start(reason) {
clearInterval(timer); timer = setInterval(run, CFG.checkEveryMin * 60000);
$('#gsr-dot').classList.add('on'); $('#gsr-state').textContent = 'Watching';
try { if (Notification.permission === 'default') Notification.requestPermission(); } catch {}
say(reason || 'Watching', (reason ? 'Restarted · ' : '') + summary()); run();
}
function stop(head, sub, kind, notify) {
clearInterval(timer); timer = null; nextAt = 0;
$('#gsr-dot').classList.remove('on'); $('#gsr-state').textContent = 'Paused';
say(head, sub, kind, notify);
}
say('Ready to watch', summary());
window.GSR = { start, stop: () => stop('Paused', summary()), CFG };
})();
})();