Skip to content

Instantly share code, notes, and snippets.

@knowlet
Created April 2, 2025 01:07
Show Gist options
  • Save knowlet/21584dcf0386a56625b3ac12391fe074 to your computer and use it in GitHub Desktop.
Save knowlet/21584dcf0386a56625b3ac12391fe074 to your computer and use it in GitHub Desktop.
東南旅行社郵輪快速篩選小工具
// ==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