// ==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 –
Ready to watch
`; 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 }; })(); })();