Created
April 2, 2025 01:07
-
-
Save knowlet/21584dcf0386a56625b3ac12391fe074 to your computer and use it in GitHub Desktop.
東南旅行社郵輪快速篩選小工具
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name 東南旅行社郵輪查詢小工具 | |
// @namespace https://knowlet.me | |
// @version 2025-04-01 | |
// @description try to take over the world! | |
// @author knowlet | |
// @match https://tour.settour.com.tw/cruise.html | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=settour.com.tw | |
// @grant GM_registerMenuCommand | |
// @grant GM_getValue | |
// @grant GM_setValue | |
// @grant GM_addStyle | |
// @grant GM_setClipboard | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// Default filter values | |
const DEFAULTS = { | |
MIN_DAYS: 5, | |
MAX_DAYS: 7, | |
MONTH: 6, | |
MIN_PRICE: 50000, | |
KEYWORD: '' // Default keyword is empty | |
}; | |
// Function to get filter values, using defaults if not set | |
const getFilterValue = function(key) { | |
return GM_getValue(key, DEFAULTS[key]); | |
} | |
// Main filtering function, now parameterized | |
const filterCruises = function(minDays, maxDays, month, minPrice, keyword) { | |
console.log(`開始篩選郵輪行程 (天數: ${minDays}-${maxDays}, 月份: ${month}, 價格 > ${minPrice}, 關鍵字: '${keyword}')...`); | |
// Find or create the results container | |
let resultsContainer = document.getElementById('cruise-filter-results'); | |
if (!resultsContainer) { | |
resultsContainer = document.createElement('div'); | |
resultsContainer.id = 'cruise-filter-results'; | |
// Add event listener for close and copy buttons (using event delegation) | |
resultsContainer.addEventListener('click', (event) => { | |
if (event.target.classList.contains('close-btnn')) { | |
resultsContainer.style.display = 'none'; | |
} | |
if (event.target.classList.contains('copy-btn')) { | |
copyResultsToClipboard(resultsContainer, event.target); | |
} | |
}); | |
const mainContent = document.querySelector('.productWrapper') || document.body; | |
mainContent.parentNode.insertBefore(resultsContainer, mainContent); | |
} | |
// Ensure container is visible when filtering | |
resultsContainer.style.display = 'block'; | |
// Clear previous results and add structure | |
resultsContainer.innerHTML = ` | |
<div class="results-header"> | |
<h4>篩選結果載入中...</h4> | |
<div class="header-buttons"> | |
<button class="copy-btn" title="複製結果到剪貼簿">📋</button> | |
<button class="close-btnn" title="關閉結果">X</button> | |
</div> | |
</div> | |
<div class="results-content"></div> | |
`; | |
const resultsContent = resultsContainer.querySelector('.results-content'); | |
const filteredCruises = [...document.querySelectorAll('div.slider-card.horizontal-card')] | |
// 1. 是否可以報名 (排除 "報名已截止") | |
.filter(card => card.querySelector('span').textContent.trim() !== '報名已截止') | |
// 2. 天數 | |
.filter(card => { | |
const daysText = card.querySelector('small').textContent.trim(); | |
const daysMatch = daysText.match(/^(\d+)/); | |
if (daysMatch) { | |
const days = Number(daysMatch[1]); | |
// Handle cases where only min or max days are specified (e.g., user enters 0) | |
const checkMin = minDays <= 0 || days >= minDays; | |
const checkMax = maxDays <= 0 || days <= maxDays; | |
return checkMin && checkMax; | |
} | |
return false; | |
}) | |
// 3. 出發月份 | |
.filter(card => { | |
// If month is 0 or invalid, don't filter by month | |
if (month <= 0 || month > 12) { | |
return true; | |
} | |
const dateElement = card.querySelector('ul.productDate li'); | |
if (dateElement) { | |
const dateText = dateElement.textContent.trim(); | |
const departureMonth = Number(dateText.split('/')[0]); | |
return departureMonth === month; | |
} | |
return false; | |
}) | |
// 4. 價錢 | |
.filter(card => { | |
// If minPrice is 0 or invalid, don't filter by price | |
if (minPrice <= 0) { | |
return true; | |
} | |
const priceElement = card.querySelector('span.price-num'); | |
if (priceElement) { | |
const priceText = priceElement.textContent.replace(/[^0-9.-]+/g, ""); | |
const price = Number(priceText); | |
return price > minPrice; | |
} | |
return false; | |
}) | |
// 5. 關鍵字 (標題) | |
.filter(card => { | |
if (!keyword) { | |
return true; | |
} // Pass if keyword is empty | |
const titleElement = card.querySelector('h3.productTitle'); | |
if (titleElement) { | |
const title = titleElement.textContent.toLowerCase(); | |
return title.includes(keyword.toLowerCase()); | |
} | |
return false; // Don't include if title element is missing | |
}); | |
console.log(`找到 ${filteredCruises.length} 個符合條件的行程:`); | |
const headerTitle = resultsContainer.querySelector('.results-header h4'); | |
// Helper function to extract details from a card element | |
const getCardDetails = function(card) { | |
const title = card.querySelector('h3.productTitle')?.textContent.trim() ?? 'N/A'; | |
const days = card.querySelector('small')?.textContent.trim() ?? 'N/A'; | |
const date = card.querySelector('ul.productDate li')?.textContent.trim() ?? 'N/A'; | |
const price = card.querySelector('span.price-num')?.textContent.trim() ?? 'N/A'; | |
const link = card.querySelector('a')?.href ?? '#'; | |
// Handle potential error if getAttribute returns null | |
const rawImageUrl = card.querySelector('img.lazy-image-slick')?.getAttribute('data-src'); | |
const imageUrl = rawImageUrl ? rawImageUrl.split(',')[0].slice(4, -1) : ''; | |
const absoluteLink = link === '#' ? '#' : new URL(link, document.baseURI).href; | |
return { title, days, date, price, link, imageUrl, absoluteLink }; | |
} | |
if (filteredCruises.length > 0) { | |
headerTitle.textContent = `找到 ${filteredCruises.length} 個符合條件的行程:`; | |
const outputHTML = filteredCruises.map((card, index) => { | |
const details = getCardDetails(card); | |
return ` | |
<li> | |
${details.imageUrl ? `<img src="${details.imageUrl}" alt="${details.title}" class="result-img">` : ''} | |
<div class="result-details"> | |
<strong>${index + 1}. ${details.title}</strong><br> | |
<span>天數: ${details.days}</span><br> | |
<span>出發日期: ${details.date}</span><br> | |
<span>價格: ${details.price}</span><br> | |
<a href="${details.absoluteLink}" target="_blank">查看行程</a> | |
</div> | |
</li> | |
`; | |
}).join(''); | |
resultsContent.innerHTML = `<ul>${outputHTML}</ul>`; | |
// Also log to console for debugging / record | |
const consoleOutputList = filteredCruises.map((card, index) => { | |
const details = getCardDetails(card); | |
return `${index + 1}. ${details.title}\n - 天數: ${details.days}\n - 出發日期: ${details.date}\n - 價格: ${details.price}\n - 連結: ${details.absoluteLink}`; | |
}); | |
console.log(consoleOutputList.join('\n\n')); | |
} else { | |
headerTitle.textContent = '沒有找到符合所有條件的行程。'; | |
resultsContent.innerHTML = ''; // Clear content area | |
console.log('沒有找到符合所有條件的行程。'); | |
} | |
} | |
// Function to generate plain text results and copy to clipboard | |
const copyResultsToClipboard = function(container, button) { | |
const listItems = container.querySelectorAll('.results-content li'); | |
if (!listItems || listItems.length === 0) { | |
alert('沒有結果可以複製。'); | |
return; | |
} | |
const textToCopy = Array.from(listItems).map(li => { | |
const title = li.querySelector('strong')?.textContent.trim().replace(/^\d+\. /, '') ?? 'N/A'; // Remove index | |
const details = Array.from(li.querySelectorAll('span')).map(span => span.textContent.trim()).join('\n - '); | |
const link = li.querySelector('a')?.href ?? '#'; | |
return `${title}\n - ${details}\n - 連結: ${link}`; | |
}).join('\n\n'); | |
const headerText = container.querySelector('.results-header h4')?.textContent.trim() ?? '篩選結果'; | |
const fullText = `${headerText}\n\n${textToCopy}`; | |
GM_setClipboard(fullText, 'text'); | |
// Provide feedback | |
const originalText = button.textContent; | |
button.textContent = '已複製!'; | |
button.disabled = true; | |
setTimeout(() => { | |
button.textContent = originalText; | |
button.disabled = false; | |
}, 1500); | |
console.log('結果已複製到剪貼簿。'); | |
} | |
// Function to create the filter UI on the page | |
const createFilterUI = function() { | |
let filterUIContainer = document.getElementById('cruise-filter-ui'); | |
if (filterUIContainer) { | |
return; | |
} // Already created | |
filterUIContainer = document.createElement('div'); | |
filterUIContainer.id = 'cruise-filter-ui'; | |
filterUIContainer.innerHTML = ` | |
<h4>郵輪行程篩選器</h4> | |
<div class="filter-controls"> | |
<div class="filter-group"> | |
<label for="minDaysInput">最少天數:</label> | |
<input type="number" id="minDaysInput" min="0" placeholder="不限"> | |
</div> | |
<div class="filter-group"> | |
<label for="maxDaysInput">最多天數:</label> | |
<input type="number" id="maxDaysInput" min="0" placeholder="不限"> | |
</div> | |
<div class="filter-group"> | |
<label for="monthInput">出發月份 (1-12):</label> | |
<input type="number" id="monthInput" min="0" max="12" placeholder="不限"> | |
</div> | |
<div class="filter-group"> | |
<label for="minPriceInput">最低價格:</label> | |
<input type="number" id="minPriceInput" min="0" placeholder="不限"> | |
</div> | |
<div class="filter-group"> | |
<label for="keywordInput">關鍵字 (標題):</label> | |
<input type="text" id="keywordInput" placeholder="例: 日本"> | |
</div> | |
<button id="runFilterBtn">篩選行程</button> | |
</div> | |
`; | |
// Populate inputs with current values | |
filterUIContainer.querySelector('#minDaysInput').value = getFilterValue('MIN_DAYS'); | |
filterUIContainer.querySelector('#maxDaysInput').value = getFilterValue('MAX_DAYS'); | |
filterUIContainer.querySelector('#monthInput').value = getFilterValue('MONTH'); | |
filterUIContainer.querySelector('#minPriceInput').value = getFilterValue('MIN_PRICE'); | |
filterUIContainer.querySelector('#keywordInput').value = getFilterValue('KEYWORD'); | |
// Add event listener to the filter button | |
filterUIContainer.querySelector('#runFilterBtn').addEventListener('click', () => { | |
const minDays = parseInt(document.getElementById('minDaysInput').value) || 0; | |
const maxDays = parseInt(document.getElementById('maxDaysInput').value) || 0; | |
const month = parseInt(document.getElementById('monthInput').value) || 0; | |
const minPrice = parseInt(document.getElementById('minPriceInput').value) || 0; | |
const keyword = document.getElementById('keywordInput').value.trim(); | |
// Save current values | |
GM_setValue('MIN_DAYS', minDays); | |
GM_setValue('MAX_DAYS', maxDays); | |
GM_setValue('MONTH', month); | |
GM_setValue('MIN_PRICE', minPrice); | |
GM_setValue('KEYWORD', keyword); | |
console.log('篩選條件已更新並儲存。'); | |
// Run the filter | |
filterCruises(minDays, maxDays, month, minPrice, keyword); | |
}); | |
// Insert the UI before the results container (if it exists) or main content | |
const resultsContainer = document.getElementById('cruise-filter-results'); | |
const mainContent = document.querySelector('.productWrapper') || document.body; | |
const targetElement = resultsContainer || mainContent; | |
targetElement.parentNode.insertBefore(filterUIContainer, targetElement); | |
} | |
// --- Register Menu Commands --- | |
// Main command to run the filter with current settings | |
GM_registerMenuCommand('篩選郵輪行程 (使用儲存條件)', () => { | |
const minDays = getFilterValue('MIN_DAYS'); | |
const maxDays = getFilterValue('MAX_DAYS'); | |
const month = getFilterValue('MONTH'); | |
const minPrice = getFilterValue('MIN_PRICE'); | |
const keyword = getFilterValue('KEYWORD'); | |
filterCruises(minDays, maxDays, month, minPrice, keyword); | |
}); | |
// Commands to set each filter criterion - REMOVED | |
// GM_registerMenuCommand(`設定 - 最少天數 (目前: ${getFilterValue('MIN_DAYS')})`, () => { | |
// setNumericFilter('MIN_DAYS', '請輸入最少天數 (輸入 0 表示不限制):', getFilterValue('MIN_DAYS')); | |
// }); | |
// GM_registerMenuCommand(`設定 - 最多天數 (目前: ${getFilterValue('MAX_DAYS')})`, () => { | |
// setNumericFilter('MAX_DAYS', '請輸入最多天數 (輸入 0 表示不限制):', getFilterValue('MAX_DAYS')); | |
// }); | |
// GM_registerMenuCommand(`設定 - 出發月份 (目前: ${getFilterValue('MONTH')})`, () => { | |
// setNumericFilter('MONTH', '請輸入出發月份 (1-12, 輸入 0 表示不限制):', getFilterValue('MONTH')); | |
// }); | |
// GM_registerMenuCommand(`設定 - 最低價格 (目前: ${getFilterValue('MIN_PRICE')})`, () => { | |
// setNumericFilter('MIN_PRICE', '請輸入最低價格 (輸入 0 表示不限制):', getFilterValue('MIN_PRICE')); | |
// }); | |
GM_registerMenuCommand('重設篩選條件為預設值', () => { | |
GM_setValue('MIN_DAYS', DEFAULTS.MIN_DAYS); | |
GM_setValue('MAX_DAYS', DEFAULTS.MAX_DAYS); | |
GM_setValue('MONTH', DEFAULTS.MONTH); | |
GM_setValue('MIN_PRICE', DEFAULTS.MIN_PRICE); | |
GM_setValue('KEYWORD', DEFAULTS.KEYWORD); | |
// Update UI fields as well | |
const minDaysInput = document.getElementById('minDaysInput'); | |
const maxDaysInput = document.getElementById('maxDaysInput'); | |
const monthInput = document.getElementById('monthInput'); | |
const minPriceInput = document.getElementById('minPriceInput'); | |
const keywordInput = document.getElementById('keywordInput'); | |
if (minDaysInput) { | |
minDaysInput.value = DEFAULTS.MIN_DAYS; | |
} | |
if (maxDaysInput) { | |
maxDaysInput.value = DEFAULTS.MAX_DAYS; | |
} | |
if (monthInput) { | |
monthInput.value = DEFAULTS.MONTH; | |
} | |
if (minPriceInput) { | |
minPriceInput.value = DEFAULTS.MIN_PRICE; | |
} | |
if (keywordInput) { | |
keywordInput.value = DEFAULTS.KEYWORD; | |
} | |
console.log('篩選條件已重設為預設值。'); | |
alert('篩選條件已重設為預設值。'); | |
}); | |
// Add CSS Styles for the results container AND the filter UI | |
GM_addStyle(` | |
#cruise-filter-ui { | |
border: 2px solid #28a745; /* Green border */ | |
background-color: #f0fff0; /* Light green background */ | |
padding: 15px; | |
margin-bottom: 20px; | |
border-radius: 5px; | |
} | |
#cruise-filter-ui h4 { | |
margin-top: 0; | |
margin-bottom: 15px; | |
color: #155724; /* Dark green */ | |
border-bottom: 1px solid #c3e6cb; | |
padding-bottom: 5px; | |
} | |
#cruise-filter-ui .filter-controls { | |
display: flex; | |
flex-wrap: wrap; /* Allow wrapping on smaller screens */ | |
gap: 15px; /* Spacing between filter groups */ | |
align-items: flex-end; /* Align items to bottom */ | |
} | |
#cruise-filter-ui .filter-group { | |
display: flex; | |
flex-direction: column; /* Stack label and input vertically */ | |
gap: 3px; | |
} | |
#cruise-filter-ui label { | |
font-size: 0.9em; | |
color: #333; | |
} | |
#cruise-filter-ui input[type="number"], | |
#cruise-filter-ui input[type="text"] { /* Apply to text input too */ | |
padding: 5px 8px; | |
border: 1px solid #ccc; | |
border-radius: 3px; | |
width: 100px; /* Adjust width as needed */ | |
} | |
#cruise-filter-ui input[type="text"] { /* Specific style for text input if needed */ | |
width: 150px; /* Make keyword input wider */ | |
} | |
#cruise-filter-ui button { | |
padding: 6px 15px; | |
background-color: #28a745; | |
color: white; | |
border: none; | |
border-radius: 3px; | |
cursor: pointer; | |
font-size: 1em; | |
align-self: flex-end; /* Align button with input bottoms */ | |
} | |
#cruise-filter-ui button:hover { | |
background-color: #218838; | |
} | |
/* Existing styles for results */ | |
#cruise-filter-results { | |
border: 2px solid #007bff; | |
background-color: #f8f9fa; | |
padding: 15px; | |
margin-bottom: 20px; | |
border-radius: 5px; | |
position: relative; /* Needed for absolute positioning of buttons */ | |
overflow: hidden; /* Contain buttons */ | |
} | |
#cruise-filter-results .results-header { | |
display: flex; | |
align-items: center; | |
padding-bottom: 5px; | |
border-bottom: 1px solid #dee2e6; | |
margin-bottom: 10px; | |
} | |
#cruise-filter-results h4 { | |
margin: 0; | |
color: #0056b3; | |
flex-grow: 1; /* Allow title to take available space */ | |
padding-right: 10px; /* Space before buttons */ | |
} | |
#cruise-filter-results .results-content ul { | |
list-style: none; | |
padding: 0; | |
margin: 0; | |
} | |
#cruise-filter-results .results-content li { | |
display: flex; /* Arrange image and details side-by-side */ | |
align-items: flex-start; /* Align items to the top */ | |
gap: 15px; /* Space between image and details */ | |
border-bottom: 1px solid #eee; | |
padding: 10px 0; | |
margin-bottom: 10px; | |
} | |
#cruise-filter-results .results-content li:last-child { | |
border-bottom: none; | |
margin-bottom: 0; | |
} | |
#cruise-filter-results .result-img { /* Style for the image */ | |
max-width: 120px; /* Limit image width */ | |
height: auto; /* Maintain aspect ratio */ | |
flex-shrink: 0; /* Prevent image from shrinking */ | |
border: 1px solid #ddd; | |
border-radius: 3px; | |
} | |
#cruise-filter-results .result-details { /* Container for text */ | |
flex-grow: 1; /* Allow text details to take remaining space */ | |
} | |
#cruise-filter-results .results-content span { | |
display: inline-block; | |
margin-right: 10px; | |
} | |
#cruise-filter-results .results-content a { | |
color: #007bff; | |
text-decoration: none; | |
} | |
#cruise-filter-results .results-content a:hover { | |
text-decoration: underline; | |
} | |
#cruise-filter-results .close-btnn, | |
#cruise-filter-results .copy-btn { | |
background: none; | |
border: 1px solid #ccc; | |
color: #555; | |
cursor: pointer; | |
font-size: 14px; | |
font-weight: bold; | |
padding: 2px 8px; | |
border-radius: 3px; | |
line-height: 1; | |
} | |
#cruise-filter-results .header-buttons { | |
display: flex; /* Align buttons horizontally */ | |
align-items: center; | |
margin-left: auto; /* Push buttons to the right */ | |
flex-shrink: 0; /* Prevent shrinking */ | |
gap: 5px; /* Add space between buttons */ | |
} | |
#cruise-filter-results .close-btnn { | |
/* margin-left: 5px; <-- Remove this, use gap instead */ | |
} | |
#cruise-filter-results .close-btnn:hover, | |
#cruise-filter-results .copy-btn:hover { | |
background-color: #eee; | |
border-color: #aaa; | |
} | |
#cruise-filter-results .copy-btn:disabled { | |
cursor: default; | |
opacity: 0.7; | |
} | |
`); | |
// Create the UI when the script runs | |
createFilterUI(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment