Greasy Fork is available in English.
Refactored for readability and maintainability with enhanced logging.
当前为
// ==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
// ==/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<void>}
*/
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 `<option value="${cleanCity}" ${selected}>${city.charAt(0).toUpperCase() + city.slice(1)}</option>`;
}).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 `<option value="${time}" ${selected}>${time}</option>`;
}).join('');
const controlPanel = document.createElement('div');
controlPanel.innerHTML = `
<div id="pmBookerPanel" style="padding: 15px; margin-bottom: 20px; border: 2px solid #4CAF50; background-color: #e8f5e9; text-align: center;">
<h3 style="margin: 0 0 10px 0;">Itinerary Booker</h3>
<div id="pmBookerForm" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); justify-content: center; align-items: flex-start; gap: 15px 25px;">
<span><label>Artist ID:</label><br><input type="text" id="pm_artist_id" value="${DEFAULTS.ARTIST_ID}" style="padding: 5px; width: 100px;"></span>
<span><label>Start City:</label><br><select id="pm_initial_city" style="padding: 5px; width: 150px;">${cityOptions}</select></span>
<span><label>Shows Per City:</label><br><input type="number" id="pm_shows_per_city" value="${DEFAULTS.SHOWS_PER_CITY}" min="1" style="padding: 5px; width: 80px;"></span>
<span><label>Shows Per Date:</label><br><input type="number" id="pm_shows_per_date" value="${DEFAULTS.SHOWS_PER_DATE}" min="1" style="padding: 5px; width: 80px;"></span>
<span><label>Start Date:</label><br><input type="date" id="pm_initial_date" value="${DEFAULTS.INITIAL_DATE}" style="padding: 5px;"></span>
<span><label>Final Date:</label><br><input type="date" id="pm_final_date" value="${DEFAULTS.FINAL_DATE}" style="padding: 5px;"></span>
<span style="grid-column: 1 / -1;"><label>Club Price (Min/Max):</label><br><input type="number" id="pm_club_min" value="${DEFAULTS.TARGET_CLUB_RANGE.min}" min="0" style="width: 60px; padding: 5px;"> <input type="number" id="pm_club_max" value="${DEFAULTS.TARGET_CLUB_RANGE.max}" min="0" style="width: 60px; padding: 5px;"></span>
<span style="grid-column: 1 / -1; display:flex; justify-content:center; gap: 20px;">
<span><label>Show Times (Ctrl+Click):</label><br><select id="pm_show_times" multiple style="padding: 5px; height: 100px; width: 120px;">${timeOptions}</select></span>
<div style="text-align: left;">
<input type="checkbox" id="pm_5star" ${DEFAULTS.REQUIRE_5_STARS ? 'checked' : ''}> <label for="pm_5star">Require 5 Stars</label><br>
<input type="checkbox" id="pm_block_same_day" ${DEFAULTS.BLOCK_TWO_SHOWS_IN_CITY_AT_SAME_DATE ? 'checked' : ''}> <label for="pm_block_same_day">Block Two Shows in same City on same Day</label>
</div>
</span>
</div>
<div style="margin-top: 15px;">
<button id="startBookerBtn" style="padding: 8px 15px; background-color: #4CAF50; color: white; border: none; cursor: pointer; font-size: 14px;">Start Booker</button>
<button id="stopBookerBtn" style="padding: 8px 15px; background-color: #f44336; color: white; border: none; cursor: pointer; margin-left: 10px; font-size: 14px;">Stop Booker</button>
</div>
<p id="pmBookerStatus" style="margin-top: 10px; font-weight: bold; min-height: 1.2em;">Status: Idle.</p>
</div>
`;
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<object>} 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<object>} 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<boolean>} 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();
})();