// ==UserScript== // @name Popmundo Itinerary Booker (Improved) // @namespace http://tampermonkey.net/ // @version 9.1 // @description Refactored for readability and maintainability with enhanced logging. // @author Gemini & You // @match https://*.popmundo.com/* // @grant GM_addStyle // @grant unsafeWindow // @run-at document-idle // @downloadURL none // ==/UserScript== (function() { 'use strict'; // --- UTILITIES --- /** * Logs messages to the console with a consistent prefix. * @param {...any} args - The messages to log. */ const log = (...args) => console.log('[Itinerary Booker]', ...args); /** * Logs error messages to the console. * @param {...any} args - The error messages to log. */ const logError = (...args) => console.error('[Itinerary Booker]', ...args); /** * Creates a promise that resolves after a specified number of milliseconds. * @param {number} ms - The number of milliseconds to wait. * @returns {Promise} */ const delay = ms => new Promise(res => setTimeout(res, ms)); // --- ⚙️ CONFIGURATION --- const SCRIPT_CONFIG = { storage: { status: 'pm_booker_status', settings: 'pm_booker_settings', tour: 'pm_booker_planned_tour', bookedClubs: 'pm_booker_booked_clubs', showIndex: 'pm_booker_show_index', restore: 'pm_booker_restore_selections' }, selectors: { city: '#ctl00_cphLeftColumn_ctl01_ddlCities', day: '#ctl00_cphLeftColumn_ctl01_ddlDays', hour: '#ctl00_cphLeftColumn_ctl01_ddlHours', findClubsBtn: '#ctl00_cphLeftColumn_ctl01_btnFindClubs', clubsTable: '#tableclubs', bookShowBtn: '#ctl00_cphLeftColumn_ctl01_btnBookShow', dialogConfirm: 'body > div:nth-child(4) > div.ui-dialog-buttonpane.ui-widget-content.ui-helper-clearfix > div > button:nth-child(1)' }, STATE: { RUNNING: 'RUNNING', IDLE: 'IDLE' } }; const getFormattedDate = (date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const today = new Date(); const sevenDaysFromNow = new Date(); sevenDaysFromNow.setDate(today.getDate() + 7); const DEFAULTS = { INITIAL_CITY: "são paulo", SHOW_TIMES: ["14:00:00", "22:00:00"], SHOWS_PER_CITY: 1, SHOWS_PER_DATE: 1, BLOCK_TWO_SHOWS_IN_CITY_AT_SAME_DATE: true, REQUIRE_5_STARS: true, TARGET_CLUB_RANGE: { min: 80, max: 1500 }, INITIAL_DATE: getFormattedDate(today), FINAL_DATE: getFormattedDate(sevenDaysFromNow), ARTIST_ID: "2786249", }; const TOUR_ITINERARY = [ { city: "rio de janeiro", travelHours: 3 }, { city: "são paulo", travelHours: 3 }, { city: "buenos aires", travelHours: 6 }, { city: "são paulo", travelHours: 6 }, { city: "mexico city", travelHours: 12 }, { city: "los angeles", travelHours: 6 }, { city: "seattle", travelHours: 8 }, { city: "chicago", travelHours: 8 }, { city: "nashville", travelHours: 2 }, { city: "chicago", travelHours: 2 }, { city: "toronto", travelHours: 3 }, { city: "montreal", travelHours: 6 }, { city: "new york", travelHours: 6 }, { city: "london", travelHours: 18 }, { city: "brussels", travelHours: 2 }, { city: "paris", travelHours: 3 }, { city: "barcelona", travelHours: 6 }, { city: "madrid", travelHours: 3 }, { city: "porto", travelHours: 3 }, { city: "madrid", travelHours: 3 }, { city: "milan", travelHours: 4 }, { city: "rome", travelHours: 2 }, { city: "budapest", travelHours: 3 }, { city: "belgrade", travelHours: 2 }, { city: "dubrovnik", travelHours: 2 }, { city: "sarajevo", travelHours: 2 }, { city: "belgrade", travelHours: 2 }, { city: "bucharest", travelHours: 3 }, { city: "sofia", travelHours: 2 }, { city: "istanbul", travelHours: 3 }, { city: "izmir", travelHours: 2 }, { city: "antalya", travelHours: 2 }, { city: "ankara", travelHours: 2 }, { city: "baku", travelHours: 2 }, { city: "kyiv", travelHours: 5 }, { city: "moscow", travelHours: 2 }, { city: "tallinn", travelHours: 4 }, { city: "stockholm", travelHours: 2 }, { city: "vilnius", travelHours: 2 }, { city: "warsaw", travelHours: 2 }, { city: "berlin", travelHours: 3 }, { city: "copenhagen", travelHours: 3 }, { city: "tromsø", travelHours: 4 }, { city: "copenhagen", travelHours: 4 }, { city: "tallinn", travelHours: 3 }, { city: "helsinki", travelHours: 2 }, { city: "tallinn", travelHours: 2 }, { city: "tromsø", travelHours: 3 }, { city: "berlin", travelHours: 5 }, { city: "glasgow", travelHours: 4 }, { city: "london", travelHours: 4 }, { city: "amsterdam", travelHours: 5 }, { city: "istanbul", travelHours: 8 }, { city: "ankara", travelHours: 3 }, { city: "singapore", travelHours: 16 }, { city: "jakarta", travelHours: 3 }, { city: "singapore", travelHours: 3 }, { city: "shanghai", travelHours: 6 }, { city: "manila", travelHours: 4 }, { city: "singapore", travelHours: 7 }, { city: "melbourne", travelHours: 9 }, { city: "johannesburg", travelHours: 34 }, ]; // --- UI INJECTION --- /** * Injects the control panel UI into the page. */ function injectUi() { if (document.getElementById('pmBookerPanel')) return; const referenceElement = document.querySelector('#ppm-content > div:nth-child(6)'); const fallbackContainer = document.querySelector('#ppm-content'); if (!referenceElement && !fallbackContainer) return; const uniqueCities = [...new Set(TOUR_ITINERARY.map(leg => leg.city))].sort((a, b) => a.localeCompare(b)); const cityOptions = uniqueCities.map(city => { const cleanCity = city.toLowerCase(); const selected = cleanCity === DEFAULTS.INITIAL_CITY ? 'selected' : ''; return ``; }).join(''); const availableShowTimes = ["14:00:00", "16:00:00", "18:00:00", "20:00:00", "22:00:00"]; const timeOptions = availableShowTimes.map(time => { const selected = DEFAULTS.SHOW_TIMES.includes(time) ? 'selected' : ''; return ``; }).join(''); const controlPanel = document.createElement('div'); controlPanel.innerHTML = `

Itinerary Booker










Status: Idle.

`; if (referenceElement) { referenceElement.parentNode.insertBefore(controlPanel, referenceElement); } else { fallbackContainer.prepend(controlPanel); } document.getElementById('startBookerBtn').addEventListener('click', startProcess); document.getElementById('stopBookerBtn').addEventListener('click', stopProcess); } // --- SCRIPT CONTROL --- /** * Gathers settings from the UI, saves them, and starts the booking process. */ function startProcess() { const selectedShowTimes = Array.from(document.querySelectorAll('#pm_show_times option:checked')).map(el => el.value); if (!document.getElementById('pm_initial_date').value || !document.getElementById('pm_final_date').value || selectedShowTimes.length === 0) { alert('Please select a Start Date, Final Date, and at least one Show Time.'); return; } const newSettings = { ARTIST_ID: document.getElementById('pm_artist_id').value.trim(), INITIAL_CITY: document.getElementById('pm_initial_city').value, INITIAL_DATE: document.getElementById('pm_initial_date').value, FINAL_DATE: document.getElementById('pm_final_date').value, SHOW_TIMES: selectedShowTimes, SHOWS_PER_CITY: parseInt(document.getElementById('pm_shows_per_city').value, 10), SHOWS_PER_DATE: parseInt(document.getElementById('pm_shows_per_date').value, 10), BLOCK_TWO_SHOWS_IN_CITY_AT_SAME_DATE: document.getElementById('pm_block_same_day').checked, TARGET_CLUB_RANGE: { min: parseInt(document.getElementById('pm_club_min').value, 10), max: parseInt(document.getElementById('pm_club_max').value, 10) }, REQUIRE_5_STARS: document.getElementById('pm_5star').checked, }; log('Starting booker with settings:', newSettings); sessionStorage.setItem(SCRIPT_CONFIG.storage.status, SCRIPT_CONFIG.STATE.RUNNING); sessionStorage.setItem(SCRIPT_CONFIG.storage.settings, JSON.stringify(newSettings)); document.getElementById('pmBookerForm').style.display = 'none'; document.getElementById('startBookerBtn').disabled = true; processNextShow(newSettings); } /** * Stops the booking process and clears all stored data. */ function stopProcess() { log('Stopping booker and clearing all data.'); sessionStorage.removeItem(SCRIPT_CONFIG.storage.status); sessionStorage.removeItem(SCRIPT_CONFIG.storage.settings); sessionStorage.removeItem(SCRIPT_CONFIG.storage.restore); localStorage.removeItem(SCRIPT_CONFIG.storage.showIndex); localStorage.removeItem(SCRIPT_CONFIG.storage.tour); localStorage.removeItem(SCRIPT_CONFIG.storage.bookedClubs); alert('Process stopped and all data cleared.'); location.reload(); } // --- CORE LOGIC --- /** * Generates the full tour itinerary based on user settings. * @param {object} settings - The user-defined settings for the tour. * @returns {Array} The generated tour itinerary. */ function generateTour(settings) { log('No tour found in storage. Generating new tour itinerary...'); const tour = []; let showsPerDateCount = {}; let lastActionTime = new Date(settings.INITIAL_DATE); const finalDate = new Date(settings.FINAL_DATE); finalDate.setHours(23, 59, 59, 999); const findNextShowSlot = (startTime) => { let searchTime = new Date(startTime); const sortedShowTimes = [...new Set(settings.SHOW_TIMES)].sort(); while (true) { for (const showTime of sortedShowTimes) { const [h, m, s] = showTime.split(':'); let potentialShowTime = new Date(searchTime); potentialShowTime.setHours(h, m, s, 0); if (potentialShowTime > startTime) { const dateStr = potentialShowTime.toISOString().split('T')[0]; if ((showsPerDateCount[dateStr] || 0) < settings.SHOWS_PER_DATE) { return potentialShowTime; } } } searchTime.setDate(searchTime.getDate() + 1); searchTime.setHours(0, 0, 0, 0); startTime = new Date(searchTime.getTime() - 1); } }; let startingIndex = TOUR_ITINERARY.findIndex(l => l.city.toLowerCase() === settings.INITIAL_CITY.toLowerCase()); if (startingIndex === -1) startingIndex = 0; const activeItinerary = TOUR_ITINERARY.slice(startingIndex); for (const leg of activeItinerary) { if (lastActionTime > finalDate) break; let cityArrivalTime = new Date(lastActionTime); if (tour.length > 0) cityArrivalTime.setHours(cityArrivalTime.getHours() + leg.travelHours); let lastShowTimeInCity = cityArrivalTime; for (let j = 0; j < settings.SHOWS_PER_CITY; j++) { const nextShowTime = findNextShowSlot(lastShowTimeInCity); if (nextShowTime > finalDate) break; const nextShowDateStr = nextShowTime.toISOString().split('T')[0]; tour.push({ city: leg.city, date: nextShowDateStr, time: nextShowTime.toTimeString().split(' ')[0] }); showsPerDateCount[nextShowDateStr] = (showsPerDateCount[nextShowDateStr] || 0) + 1; lastShowTimeInCity = nextShowTime; } lastActionTime = lastShowTimeInCity; } localStorage.setItem(SCRIPT_CONFIG.storage.tour, JSON.stringify(tour)); log(`Tour generation complete. ${tour.length} shows planned.`, tour); return tour; } /** * Retrieves the tour from localStorage or generates a new one. * @param {object} settings - The user-defined settings. * @returns {Array} The tour itinerary. */ function getTour(settings) { const tourJson = localStorage.getItem(SCRIPT_CONFIG.storage.tour); if (tourJson) { log('Loaded existing tour from storage.'); return JSON.parse(tourJson); } return generateTour(settings); } /** * Finds the best available club from the list, books it, and confirms. * @param {object} settings - The user-defined settings. * @param {object} currentShow - The current show object from the tour. * @returns {Promise} True if a club was successfully booked, false otherwise. */ async function findAndBookBestClub(settings, currentShow) { const getWeekStartDate = (dateStr) => { const date = new Date(dateStr); const day = date.getUTCDay(); const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1); return new Date(date.setUTCDate(diff)).toISOString().split('T')[0]; }; const clubsTable = document.querySelector(SCRIPT_CONFIG.selectors.clubsTable); const bookedClubs = JSON.parse(localStorage.getItem(SCRIPT_CONFIG.storage.bookedClubs) || '{}'); const validClubs = []; const rows = clubsTable.querySelectorAll('tbody tr'); log(`Found ${rows.length} clubs. Filtering based on settings...`); for (const row of rows) { const clubName = row.cells[0].textContent.trim(); const priceText = row.cells[row.cells.length - 1].textContent; const price = parseFloat(priceText.trim().replace(/\s*M\$$/, '').replace(/\./g, '').replace(',', '.')); const starRatingKey = row.cells[2].querySelector('span.sortkey')?.textContent; if (settings.REQUIRE_5_STARS && starRatingKey !== '50') { log(`- Skipping ${clubName}: Does not have 5 stars.`); continue; } if (price < settings.TARGET_CLUB_RANGE.min || price > settings.TARGET_CLUB_RANGE.max) { log(`- Skipping ${clubName}: Price ${price}M$ is outside target range (${settings.TARGET_CLUB_RANGE.min}-${settings.TARGET_CLUB_RANGE.max}M$).`); continue; } const bookedDate = bookedClubs[clubName]; const currentShowWeekStart = getWeekStartDate(currentShow.date); if (bookedDate && getWeekStartDate(bookedDate) === currentShowWeekStart) { log(`- Skipping ${clubName}: Already booked a show in this club for the week of ${currentShowWeekStart}.`); continue; } log(`+ Found valid club: ${clubName} (Price: ${price}M$)`); validClubs.push({ price, row, name: clubName }); } if (validClubs.length > 0) { validClubs.sort((a, b) => b.price - a.price); const bestClub = validClubs[0]; log(`Best club found: ${bestClub.name} for ${bestClub.price}M$. Attempting to book...`); bookedClubs[bestClub.name] = currentShow.date; localStorage.setItem(SCRIPT_CONFIG.storage.bookedClubs, JSON.stringify(bookedClubs)); bestClub.row.querySelector('input[type="radio"]').click(); await delay(500); document.querySelector(SCRIPT_CONFIG.selectors.bookShowBtn).click(); log('Clicked "Book Show". Waiting for confirmation dialog...'); await delay(1500); const confirmBtn = document.querySelector(SCRIPT_CONFIG.selectors.dialogConfirm); if (confirmBtn && (confirmBtn.textContent.includes('Yes') || confirmBtn.textContent.includes('OK'))) { confirmBtn.click(); log('Confirmation dialog found and clicked. Show booked successfully.'); return true; } else { logError("Could not find the confirmation 'Yes'/'OK' button. Stopping script."); stopProcess(); return false; } } else { log('No valid clubs found for this slot.'); return false; } } /** * Main processing function that orchestrates the booking of the next show. * @param {object} settings - The user-defined settings. */ async function processNextShow(settings) { const statusEl = document.getElementById('pmBookerStatus'); statusEl.style.color = 'orange'; const tour = getTour(settings); let currentIndex = parseInt(localStorage.getItem(SCRIPT_CONFIG.storage.showIndex) || '0', 10); if (currentIndex >= tour.length) { statusEl.textContent = 'Tour Finished! All shows booked.'; statusEl.style.color = 'green'; log('Tour finished!'); alert('Tour finished!'); stopProcess(); return; } const currentShow = tour[currentIndex]; statusEl.textContent = `Processing show ${currentIndex + 1}/${tour.length}: ${currentShow.city} on ${currentShow.date} at ${currentShow.time}`; log(`Processing show ${currentIndex + 1}/${tour.length}: ${currentShow.city} @ ${currentShow.date} ${currentShow.time}`); const cityDropdown = document.querySelector(SCRIPT_CONFIG.selectors.city); const selectedCityText = cityDropdown.options[cityDropdown.selectedIndex].text.toLowerCase(); if (selectedCityText.localeCompare(currentShow.city, undefined, { sensitivity: 'accent' }) !== 0) { const cityOption = [...cityDropdown.options].find(opt => opt.text.toLowerCase().localeCompare(currentShow.city, undefined, { sensitivity: 'accent' }) === 0); if (cityOption) { log(`Changing city from ${selectedCityText} to ${currentShow.city}.`); sessionStorage.setItem(SCRIPT_CONFIG.storage.restore, JSON.stringify(currentShow)); cityDropdown.value = cityOption.value; cityDropdown.dispatchEvent(new Event('change', { bubbles: true })); } else { logError(`City ${currentShow.city} not found in dropdown. Skipping.`); localStorage.setItem(SCRIPT_CONFIG.storage.showIndex, currentIndex + 1); await delay(2000); processNextShow(settings); } return; } if (!document.querySelector(SCRIPT_CONFIG.selectors.clubsTable)) { log('Club table not found. Setting date/time and clicking "Find Clubs".'); document.querySelector(SCRIPT_CONFIG.selectors.day).value = currentShow.date; document.querySelector(SCRIPT_CONFIG.selectors.hour).value = currentShow.time; await delay(300); document.querySelector(SCRIPT_CONFIG.selectors.findClubsBtn).click(); return; } const booked = await findAndBookBestClub(settings, currentShow); localStorage.setItem(SCRIPT_CONFIG.storage.showIndex, currentIndex + 1); if (!booked) { log('No club was booked. Moving to the next show in the itinerary.'); await delay(2000); window.location.reload(); } } // --- SCRIPT ROUTER --- /** * Entry point of the script. Determines the current state and acts accordingly. */ function run() { const status = sessionStorage.getItem(SCRIPT_CONFIG.storage.status); const settings = JSON.parse(sessionStorage.getItem(SCRIPT_CONFIG.storage.settings)); const onBookShowPage = window.location.pathname.includes('/Artist/BookShow/'); if (status === SCRIPT_CONFIG.STATE.RUNNING) { log(`Script is RUNNING. On book show page: ${onBookShowPage}.`); const bookShowPath = `/World/Popmundo.aspx/Artist/BookShow/${settings.ARTIST_ID}`; if (window.location.pathname !== bookShowPath) { log(`Incorrect page. Redirecting to ${bookShowPath}`); window.location.href = `https://${window.location.hostname}${bookShowPath}`; return; } injectUi(); document.getElementById('pmBookerForm').style.display = 'none'; document.getElementById('startBookerBtn').disabled = true; const restore = JSON.parse(sessionStorage.getItem(SCRIPT_CONFIG.storage.restore)); if (restore) { log('Restoring date/time after city change.'); sessionStorage.removeItem(SCRIPT_CONFIG.storage.restore); document.querySelector(SCRIPT_CONFIG.selectors.day).value = restore.date; document.querySelector(SCRIPT_CONFIG.selectors.hour).value = restore.time; document.querySelector(SCRIPT_CONFIG.selectors.findClubsBtn).click(); } else { processNextShow(settings); } } else { log(`Script is IDLE. On book show page: ${onBookShowPage}.`); if (onBookShowPage) { injectUi(); } } } run(); })();