您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Visualize a year of points availability with quickbook function on Hyatt points calendar
当前为
// ==UserScript== // @name Haiyaa Points! // @namespace http://tampermonkey.net/ // @version 1.4 // @description Visualize a year of points availability with quickbook function on Hyatt points calendar // @author World of Haiyaa // @match https://www.hyatt.com/explore-hotels/rate-calendar* // @grant GM_xmlhttpRequest // @connect www.hyatt.com // ==/UserScript== (function() { 'use strict'; function addGlobalStyle(css) { const head = document.head || document.getElementsByTagName('head')[0]; if (!head) { return; } const style = document.createElement('style'); style.type = 'text/css'; style.innerHTML = css; head.appendChild(style); } addGlobalStyle(` .haiyaa-trigger-btn { background-color: #0D2D52; color: white; border: none; border-radius: 6px; padding: 6px 14px; font-size: 14px; font-weight: bold; cursor: pointer; margin-left: 20px; transition: all 0.2s; } .haiyaa-trigger-btn:hover { background-color: #1a4a87; } .haiyaa-trigger-btn:disabled { background-color: #888; cursor: not-allowed; } .visualizer-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.6); z-index: 9999; display: flex; justify-content: center; align-items: center; } .visualizer-modal { background-color: #ffffff; padding: 25px; border-radius: 12px; box-shadow: 0 5px 20px rgba(0,0,0,0.3); width: auto; max-width: 95vw; } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #e0e0e0; } .modal-header h3 { margin: 0; font-size: 18px; } .modal-header select { margin-left: 20px; padding: 5px; border-radius: 5px; border: 1px solid #ccc; } .modal-close-btn { font-size: 24px; font-weight: bold; color: #888; cursor: pointer; border: none; background: none; } .modal-close-btn:hover { color: #000; } .calendar-chart-container { display: grid; grid-template-areas: "empty months" "weeks grid"; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; gap: 5px 3px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; } .calendar-months { grid-area: months; position: relative; height: 15px; } .calendar-months .month { position: absolute; top: 0; font-size: 11px; color: #555; } .calendar-weeks { grid-area: weeks; font-size: 11px; color: #555; } .points-calendar-grid { grid-area: grid; } .calendar-weeks .week-day { height: 14px; margin-bottom: 3px; display: flex; align-items: center; } .points-calendar-grid { display: grid; grid-template-columns: repeat(53, 14px); grid-template-rows: repeat(7, 14px); grid-auto-flow: column; grid-gap: 3px; } .calendar-day { width: 14px; height: 14px; background-color: transparent; border: 1px solid #e1e4e8; border-radius: 3px; transition: all 0.1s; position: relative; } .calendar-day.is-holiday { border-width: 2px; box-sizing: border-box; } .day-number { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 9px; font-weight: bold; color: white; text-shadow: 0 0 2px rgba(0,0,0,0.7); } .calendar-day.clickable:hover { transform: scale(1.2); box-shadow: 0 0 5px rgba(0,0,0,0.5); cursor: pointer; } .calendar-day.unavailable { background-image: repeating-linear-gradient(45deg, rgba(255,255,255,0.7), rgba(255,255,255,0.7) 2px, transparent 2px, transparent 4px); cursor: not-allowed; } .calendar-day.off-peak { background-color: #216e39; border-color: transparent; } .calendar-day.standard { background-color: #9be9a8; border-color: transparent; } .calendar-day.peak { background-color: #ff9800; border-color: transparent; } .calendar-legend, .holiday-legend { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; font-size: 12px; margin-top: 15px; color: #555; } .legend-item { display: flex; align-items: center; margin: 2px 8px; } .legend-color-box { width: 14px; height: 14px; border-radius: 3px; margin-right: 5px; } .holiday-legend .legend-color-box { border-width: 2px; border-style: solid; background-color: transparent; } .availability-disclaimer { font-size: 10px; font-style: italic; color: #888; text-align: center; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee; } .hidden { display: none !important; } `); function getHotelCodeFromURL() { return new URLSearchParams(window.location.search).get('spiritCode'); } async function fetchPriceData(hotelCode) { const CHUNK_DAYS = 70; const promises = []; let currentDate = new Date(); for (let i = 0; i < 6; i++) { const startDate = new Date(currentDate); const endDate = new Date(currentDate); endDate.setDate(endDate.getDate() + CHUNK_DAYS); const startDateStr = startDate.toISOString().split('T')[0]; const endDateStr = endDate.toISOString().split('T')[0]; const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail?spiritCode=${hotelCode}&startDate=${startDateStr}&endDate=${endDateStr}`; promises.push(fetchSingleChunk(apiUrl)); currentDate.setDate(currentDate.getDate() + CHUNK_DAYS + 1); } const settledResults = await Promise.all(promises); const successfulResults = settledResults.filter(Boolean); const allData = { STANDARD_ROOM: new Map(), CLUB: new Map(), STANDARD_SUITE: new Map(), PREMIUM_SUITE: new Map() }; successfulResults.forEach(chunkData => { if (chunkData.STANDARD_ROOM) chunkData.STANDARD_ROOM.forEach((v, k) => allData.STANDARD_ROOM.set(k, v)); if (chunkData.CLUB) chunkData.CLUB.forEach((v, k) => allData.CLUB.set(k, v)); if (chunkData.STANDARD_SUITE) chunkData.STANDARD_SUITE.forEach((v, k) => allData.STANDARD_SUITE.set(k, v)); if (chunkData.PREMIUM_SUITE) chunkData.PREMIUM_SUITE.forEach((v, k) => allData.PREMIUM_SUITE.set(k, v)); }); return allData; } function fetchSingleChunk(apiUrl) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: apiUrl, headers: { "Accept": "application/json" }, onload: function(response) { if (response.status === 200) { const data = JSON.parse(response.responseText); const roomCategories = data?.roomCategories; const chunkData = {}; if (roomCategories?.STANDARD_ROOM) chunkData.STANDARD_ROOM = parseRoomData(roomCategories.STANDARD_ROOM); if (roomCategories?.CLUB) chunkData.CLUB = parseRoomData(roomCategories.CLUB); if (roomCategories?.STANDARD_SUITE) chunkData.STANDARD_SUITE = parseRoomData(roomCategories.STANDARD_SUITE); if (roomCategories?.PREMIUM_SUITE) chunkData.PREMIUM_SUITE = parseRoomData(roomCategories.PREMIUM_SUITE); resolve(chunkData); } else { resolve(null); } }, onerror: function() { resolve(null); } }); }); } function parseRoomData(roomData) { const priceMap = new Map(); for (const date in roomData) { const details = roomData[date]; const type = details.pointsLevel ? details.pointsLevel.toLowerCase() : 'unknown'; priceMap.set(date, { type: type, points: details.pointsValue }); } return priceMap; } async function fetchAvailabilityData(hotelCode) { const today = new Date(); const endDate = new Date(); endDate.setDate(today.getDate() + 365); const startDateStr = today.toISOString().split('T')[0]; const endDateStr = endDate.toISOString().split('T')[0]; const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail/days?spiritCode=${hotelCode}&startDate=${startDateStr}&endDate=${endDateStr}&numAdults=1&numChildren=0&roomQuantity=1&los=1&isMock=false`; return new Promise(resolve => { GM_xmlhttpRequest({ method: "GET", url: apiUrl, headers: { "Accept": "application/json" }, onload: response => { const availabilityByRoom = { STANDARD_ROOM: new Set(), CLUB: new Set(), STANDARD_SUITE: new Set(), PREMIUM_SUITE: new Set() }; if (response.status === 200) { const data = JSON.parse(response.responseText); const days = data.days || {}; for (const date in days) { const roomsOnDate = days[date]; if (roomsOnDate) { if (roomsOnDate.STANDARD_ROOM) availabilityByRoom.STANDARD_ROOM.add(date); if (roomsOnDate.CLUB) availabilityByRoom.CLUB.add(date); if (roomsOnDate.STANDARD_SUITE) availabilityByRoom.STANDARD_SUITE.add(date); if (roomsOnDate.PREMIUM_SUITE) availabilityByRoom.PREMIUM_SUITE.add(date); } } } resolve(availabilityByRoom); }, onerror: () => resolve({ STANDARD_ROOM: new Set(), CLUB: new Set(), STANDARD_SUITE: new Set(), PREMIUM_SUITE: new Set() }) }); }); } function getPointsTiers(priceMap) { const tiers = {}; for (const item of priceMap.values()) { if (item.type && item.points && !tiers[item.type]) { tiers[item.type] = item.points; } if (tiers.off_peak && tiers.standard && tiers.peak) break; } return tiers; } function getHolidays(year) { const holidays = new Map(); const nthWeekdayOfMonth = (n, weekday, month, year) => { let count = 0; const date = new Date(year, month, 1); while (count < n) { if (date.getDay() === weekday) count++; if (count < n) date.setDate(date.getDate() + 1); } return date; }; const lastWeekdayOfMonth = (weekday, month, year) => { const date = new Date(year, month + 1, 0); while (date.getDay() !== weekday) { date.setDate(date.getDate() - 1); } return date; }; const formatDate = (date) => date.toISOString().split('T')[0]; holidays.set(formatDate(new Date(year, 0, 1)), { name: "New Year's Day", color: '#FFD700' }); holidays.set(formatDate(nthWeekdayOfMonth(3, 1, 0, year)), { name: 'MLK Day', color: '#483D8B' }); holidays.set(formatDate(lastWeekdayOfMonth(1, 4, year)), { name: 'Memorial Day', color: '#000080' }); holidays.set(formatDate(new Date(year, 6, 4)), { name: 'Independence Day', color: '#B22222' }); holidays.set(formatDate(nthWeekdayOfMonth(1, 1, 8, year)), { name: 'Labor Day', color: '#1E90FF' }); holidays.set(formatDate(nthWeekdayOfMonth(4, 4, 10, year)), { name: 'Thanksgiving', color: '#A0522D' }); holidays.set(formatDate(new Date(year, 11, 25)), { name: 'Christmas Day', color: '#228B22' }); return holidays; } function createVisualizerUI(priceData, availabilityData) { const hotelCode = getHotelCodeFromURL(); const currentYear = new Date().getFullYear(); const holidays = new Map([...getHolidays(currentYear), ...getHolidays(currentYear + 1)]); const overlay = document.createElement('div'); overlay.className = 'visualizer-overlay hidden'; const modal = document.createElement('div'); modal.className = 'visualizer-modal'; const header = document.createElement('div'); header.className = 'modal-header'; header.innerHTML = `<h3>Points Calendar Quick View</h3>`; const selectorContainer = document.createElement('div'); const selector = document.createElement('select'); let optionsHTML = ''; if (priceData.STANDARD_ROOM?.size > 0) optionsHTML += `<option value="STANDARD_ROOM">Standard Room</option>`; if (priceData.CLUB?.size > 0) optionsHTML += `<option value="CLUB">Club Access</option>`; if (priceData.STANDARD_SUITE?.size > 0) optionsHTML += `<option value="STANDARD_SUITE">Standard Suite</option>`; if (priceData.PREMIUM_SUITE?.size > 0) optionsHTML += `<option value="PREMIUM_SUITE">Premium Suite</option>`; selector.innerHTML = optionsHTML; if (optionsHTML) { selectorContainer.appendChild(selector); header.appendChild(selectorContainer); } const closeBtn = document.createElement('button'); closeBtn.className = 'modal-close-btn'; closeBtn.innerHTML = `×`; header.appendChild(closeBtn); const chartContainer = document.createElement('div'); chartContainer.className = 'calendar-chart-container'; const monthsContainer = document.createElement('div'); monthsContainer.className = 'calendar-months'; const weeksContainer = document.createElement('div'); weeksContainer.className = 'calendar-weeks'; const grid = document.createElement('div'); grid.className = 'points-calendar-grid'; chartContainer.append(monthsContainer, weeksContainer, grid); const legend = document.createElement('div'); legend.className = 'calendar-legend'; const holidayLegend = document.createElement('div'); holidayLegend.className = 'holiday-legend'; const disclaimer = document.createElement('div'); disclaimer.className = 'availability-disclaimer'; disclaimer.textContent = 'Availability based on one-night stay'; modal.append(header, chartContainer, legend, holidayLegend, disclaimer); overlay.appendChild(modal); const weekDays = ['', 'Mon', '', 'Wed', '', 'Fri', '']; weekDays.forEach(day => { weeksContainer.innerHTML += `<div class="week-day">${day}</div>`; }); let holidayLegendHTML = ''; const sortedHolidays = Array.from(holidays.values()).sort((a,b) => a.name.localeCompare(b.name)); const addedHolidays = new Set(); for(const holiday of sortedHolidays){ if(!addedHolidays.has(holiday.name)){ holidayLegendHTML += `<div class="legend-item"><div class="legend-color-box" style="border-color: ${holiday.color};"></div> ${holiday.name}</div>`; addedHolidays.add(holiday.name); } } holidayLegend.innerHTML = holidayLegendHTML; function renderGrid(roomType) { grid.innerHTML = ''; const priceMap = priceData[roomType]; const availabilitySet = availabilityData[roomType] || new Set(); if (!priceMap) return; let monthsHTML = '', lastMonth = -1, lastMonthLabelWeekIndex = -10; const startDate = new Date(); startDate.setDate(startDate.getDate() - startDate.getDay()); for (let i = 0; i < 371; i++) { const currentDate = new Date(startDate); currentDate.setDate(startDate.getDate() + i); const dayElement = document.createElement('div'); dayElement.className = 'calendar-day'; if (currentDate.getDay() === 0) { const currentMonth = currentDate.getMonth(); if (currentMonth !== lastMonth) { const weekIndex = Math.floor(i / 7); if (weekIndex > lastMonthLabelWeekIndex + 1) { const monthName = currentDate.toLocaleString('default', { month: 'short' }); const leftPosition = weekIndex * 17; monthsHTML += `<div class="month" style="left: ${leftPosition}px;">${monthName}</div>`; lastMonth = currentMonth; lastMonthLabelWeekIndex = weekIndex; } } } if (currentDate >= new Date(new Date().setHours(0, 0, 0, 0))) { const dateString = currentDate.toISOString().split('T')[0]; const dayData = priceMap.get(dateString); let title = dateString; const holiday = holidays.get(dateString); if (currentDate.getDate() === 1) { dayElement.innerHTML = `<div class="day-number">1</div>`; } if (dayData) { dayElement.classList.add(dayData.type.replace('_', '-')); title += `\n${dayData.points.toLocaleString()} Points (${dayData.type})`; if(holiday){ dayElement.classList.add('is-holiday'); dayElement.style.borderColor = holiday.color; title += `\n${holiday.name}`; } const isAvailable = availabilitySet.has(dateString); if (isAvailable) { dayElement.classList.add('clickable'); title += `\nClick to book!`; dayElement.addEventListener('click', () => { const checkoutDate = new Date(currentDate); checkoutDate.setDate(checkoutDate.getDate() + 1); const checkoutDateString = checkoutDate.toISOString().split('T')[0]; const bookingUrl = `https://www.hyatt.com/shop/rooms/${hotelCode}?checkinDate=${dateString}&checkoutDate=${checkoutDateString}&rooms=1&adults=1&kids=0&rateFilter=woh`; window.open(bookingUrl, '_blank'); }); } else { dayElement.classList.add('unavailable'); title += `\n(Not Available)`; } } else { if(holiday){ dayElement.classList.add('is-holiday'); dayElement.style.borderColor = holiday.color; title += `\n${holiday.name}`; } dayElement.classList.add('unavailable'); title += `\nNot priced`; } dayElement.title = title; } grid.appendChild(dayElement); } monthsContainer.innerHTML = monthsHTML; const pointTiers = getPointsTiers(priceMap); const legendOrder = [ { key: 'off-peak', label: 'Off-Peak' }, { key: 'standard', label: 'Standard' }, { key: 'peak', label: 'Peak' } ]; let legendHTML = ''; for (const item of legendOrder) { const tierKey = item.key.replace('-', '_'); const points = pointTiers[tierKey]; if (points) { legendHTML += `<div class="legend-item"><div class="legend-color-box calendar-day ${item.key}"></div> ${item.label} (${points.toLocaleString()})</div>`; } } legend.innerHTML = legendHTML; } selector.addEventListener('change', () => renderGrid(selector.value)); closeBtn.addEventListener('click', () => overlay.classList.add('hidden')); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.add('hidden'); }); if(selector.value) renderGrid(selector.value); return overlay; } function waitForElement(selector) { return new Promise(resolve => { const el = document.querySelector(selector); if (el) return resolve(el); const observer = new MutationObserver(() => { const targetEl = document.querySelector(selector); if (targetEl) { observer.disconnect(); resolve(targetEl); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } async function main() { const hotelCode = getHotelCodeFromURL(); if (!hotelCode) return; const injectionSelector = 'body > div.explore-hotels-content > main > div.vrc-cal-container > div.vrc-calendar > div.calendar-body-header > div.calendar-date-container'; const injectionPoint = await waitForElement(injectionSelector); if (!injectionPoint) { console.error('[Hyatt Visualizer] Could not find the injection point for the button.'); return; } const triggerBtn = document.createElement('button'); triggerBtn.className = 'haiyaa-trigger-btn'; triggerBtn.textContent = 'Haiyaa!'; injectionPoint.appendChild(triggerBtn); triggerBtn.addEventListener('click', async () => { const existingModal = document.querySelector('.visualizer-overlay'); if (existingModal) existingModal.remove(); triggerBtn.textContent = '...'; triggerBtn.disabled = true; try { const [priceData, availabilityData] = await Promise.all([ fetchPriceData(hotelCode), fetchAvailabilityData(hotelCode) ]); const visualizerModal = createVisualizerUI(priceData, availabilityData); document.body.appendChild(visualizerModal); visualizerModal.classList.remove('hidden'); } catch (error) { console.error('[Hyatt Visualizer] A critical error occurred:', error); triggerBtn.textContent = 'Error!'; setTimeout(() => { triggerBtn.textContent = 'Haiyaa!'; triggerBtn.disabled = false; }, 2000); return; } triggerBtn.textContent = 'Haiyaa!'; triggerBtn.disabled = false; }); } main(); })();