├── .gitignore ├── LICENSE ├── README.md ├── background.js ├── content.js ├── earlyLoader.js ├── images ├── icon128.png ├── icon16.png └── icon48.png ├── injected.js ├── manifest.json ├── popup.html ├── popup.js ├── shadowInject.js └── styles.css /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 和編輯器 2 | .vscode/ 3 | .idea/ 4 | *.sublime-project 5 | *.sublime-workspace 6 | 7 | # 系統文件 8 | .DS_Store 9 | Thumbs.db 10 | 11 | # 暫存文件 12 | *.log 13 | *.tmp 14 | *.temp 15 | 16 | # Node.js 17 | node_modules/ 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # 打包文件 23 | *.zip 24 | *.rar 25 | *.7z 26 | 27 | # 其他 28 | *.bak 29 | *.swp 30 | *~ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 poning0224 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 圖片描述 搶票柴柴-售票網站篩選器 2 | 3 | 4 | 一個 Chrome 擴充功能,用於在各大售票網站上快速篩選您想要的區域票券。 5 | 6 | ## ✨ 功能特點 7 | 8 | ### 多平台支援 9 | - 拓元售票網站 (tixcraft.com) 10 | - KKTIX售票網站 (kktix.com) 11 | - ibon售票網站 (ibon.com.tw) 12 | - 遠大售票網站 (ticketplus.com.tw) 13 | - cityline售票網站 (cityline.com) 14 | - 寬宏售票網站 (kham.com.tw) 15 | - 富邦悍將售票網站 (fami.life) 16 | - JKFACE售票網站 (jkface.net) 17 | 18 | ### 篩選功能 19 | - 關鍵字篩選:輸入想要的區域名稱(如:VIP、特A區等),快速找出符合條件的票券 20 | - 黑名單過濾:輸入不想看到的區域名稱或價格,快速過濾不需要的票券 21 | - 價格篩選:輸入價格可以快速找出指定價格的票券 22 | - 已售完票券過濾:可選擇是否顯示已售完的票券 23 | - 一鍵重置:點擊「顯示全部票券」即可恢復原始顯示 24 | - 即時更新:當網頁更新時自動套用篩選條件 25 | 26 | ### 進階功能 27 | - 顯示伺服器時間:可選擇是否在頁面上顯示當前伺服器時間 28 | - 顯示篩選狀態:可選擇是否在頁面上顯示目前的篩選條件 29 | - 擴充功能開關:可快速開啟/關閉擴充功能 30 | 31 | ## 🧩 安裝方式 32 | ### Chrome商店(版本1.3.2) 33 | 1. 前往 [Chrome商店](https://chromewebstore.google.com/detail/pofndajlpfdonhkefkppngfghocppcck?utm_source=item-share-cb) 34 | 2. 加到Chrome 35 | 3. 新增擴充功能 36 | 37 | ### 📥 本地下載(版本1.4.0) 38 | 1. [點我下載](https://github.com/coder220224/ticket-filter/releases/download/v1.4.0/ticket-filter-v1.4.0.zip) 39 | 2. 解壓縮檔案 40 | 3. 開啟 Chrome 瀏覽器,前往 chrome://extensions/ 41 | 4. 開啟右上角的「開發人員模式」 42 | 5. 點擊「載入未封裝項目」 43 | 6. 選擇解壓縮後的資料夾 44 | 45 | ### Github Releases(版本1.4.0) 46 | 1. 前往 [Releases](https://github.com/coder220224/ticket-filter/releases) 頁面 47 | 2. 下載最新版本的 ZIP 檔案 48 | 3. 解壓縮檔案 49 | 4. 開啟 Chrome 瀏覽器,前往 chrome://extensions/ 50 | 5. 開啟右上角的「開發人員模式」 51 | 6. 點擊「載入未封裝項目」 52 | 7. 選擇解壓縮後的資料夾 53 | 54 | ## 📱 手機安裝方式 55 | - ios : [點此看YT教學](https://youtube.com/shorts/KQwCQwVKBBY?feature=share) 56 | 57 | ## 🔧 使用方式 58 | 59 | ### 基本操作 60 | 1. 點擊擴充功能圖示開啟篩選器 61 | 2. 在關鍵字輸入框中輸入想要篩選的區域名稱或價格 62 | 3. 在黑名單輸入框中輸入想要過濾的區域名稱或價格 63 | 4. 按 Enter 或點擊 + 按鈕新增條件 64 | 5. 可選擇是否顯示已售完的票券 65 | 6. 點擊「顯示全部票券」可重置所有條件 66 | 67 | ### 進階篩選語法 68 | - 使用逗號(,)可以篩選同時符合的票券(AND邏輯) 69 | - 例:`4800,搖滾區` - 尋找 4800 元且在搖滾區的票券 70 | - 使用加號(+)可以篩選任一條件符合的票券(OR邏輯) 71 | - 例:`4500+3200` - 尋找 4500 元或 3200 元的票券 72 | - 複合條件範例: 73 | - `4500,A區+3200,B區` - 尋找 (4500元的A區) 或 (3200元的B區) 的票券 74 | 75 | ### 智慧數字轉換 76 | - 支援中文數字和阿拉伯數字互相轉換 77 | - 輸入「特1區」可以找到「特一區」 78 | - 輸入「特一區」可以找到「特1區」 79 | - 支援價格格式轉換 80 | - 輸入「2800」可以找到「2,800」的票價 81 | 82 | ## 🔥 版本更新 (v1.4.0) 83 | ### 1. 新增平台支援 84 | - 支援寬宏售票網站 (kham.com.tw) 85 | - 支援富邦悍將售票網站 (fami.life) 86 | - 支援JKFACE售票網站 (jkface.net) 87 | 88 | ### 2. 新增快捷鍵啟用/停用功能 89 | - 可設定快捷鍵及時啟用/停用擴充功能 90 | 91 | 92 | ## ⚠️ 注意事項 93 | 94 | - 本擴充功能僅提供視覺化篩選,不影響實際購票功能 95 | - 建議搭配官方購票系統使用 96 | - 請遵守各售票網站的購票規則 97 | 98 | ## 🔒 隱私權政策 99 | 100 | - 本擴充功能不會收集任何個人資料 101 | - 所有設定均儲存在您的瀏覽器本地端 102 | - 不會向任何第三方傳送資料 103 | 104 | ## 🏷️ 版本資訊 105 | 106 | - 目前版本:1.4.0 107 | - 最後更新:2025/05/29 108 | 109 | ## 👨‍💻 開發者資訊 110 | 111 | 如有任何問題或建議,歡迎透過 [GitHub Issues](https://github.com/poning0224/tixcraft-filter/issues) 回報。 112 | 113 | 或著私訊我的社群帳號[摳得柴柴](https://www.threads.net/@coder22022)。 114 | 115 | ## 💝 贊助支持 116 | 如果你喜歡這個項目並希望支持它,可以考慮通過以下方式贊助: 117 | 118 | 歐富寶支付 街口支付 PayPal 119 | 120 | 每一分支持都對我很有幫助,謝謝! 121 | 122 | 123 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // 后台脚本 - 负责拦截和修改网络请求 2 | 3 | // 全局设置存储 4 | let settings = { 5 | // 拓元 6 | tixcraftKeywords: [], 7 | tixcraftHideSoldOut: false, 8 | 9 | // KKTIX 10 | kktixKeywords: [], 11 | kktixHideSoldOut: false, 12 | kktixShowAllPrices: true, 13 | 14 | // ibon 15 | ibonKeywords: [], 16 | ibonHideSoldOut: false, 17 | 18 | // Cityline 19 | citylineKeywords: [], 20 | citylineHideSoldOut: false, 21 | 22 | // 全局设置 23 | extensionEnabled: true, 24 | showServerTime: true, 25 | showFilterStatus: true 26 | }; 27 | 28 | // Service Worker激活事件 29 | chrome.runtime.onInstalled.addListener(() => { 30 | loadSettings(); 31 | console.log('搶票柴柴扩展已安装/更新'); 32 | }); 33 | 34 | // 确保在service worker启动时也加载设置 35 | self.onload = () => { 36 | loadSettings(); 37 | }; 38 | 39 | // 初始化加载设置 40 | function loadSettings() { 41 | chrome.storage.local.get(null, (result) => { 42 | if (result.keywords) settings.tixcraftKeywords = result.keywords; 43 | if (result.hideSoldOut !== undefined) settings.tixcraftHideSoldOut = result.hideSoldOut; 44 | 45 | if (result.targetKeywords) settings.kktixKeywords = result.targetKeywords; 46 | if (result.showAllPrices !== undefined) settings.kktixShowAllPrices = result.showAllPrices; 47 | if (result.kktixHideSoldOut !== undefined) settings.kktixHideSoldOut = result.kktixHideSoldOut; 48 | 49 | if (result.ibonKeywords) settings.ibonKeywords = result.ibonKeywords; 50 | if (result.ibonHideSoldOut !== undefined) settings.ibonHideSoldOut = result.ibonHideSoldOut; 51 | 52 | if (result.citylineKeywords) settings.citylineKeywords = result.citylineKeywords; 53 | if (result.citylineHideSoldOut !== undefined) settings.citylineHideSoldOut = result.citylineHideSoldOut; 54 | 55 | if (result.extensionEnabled !== undefined) settings.extensionEnabled = result.extensionEnabled; 56 | if (result.showServerTime !== undefined) settings.showServerTime = result.showServerTime; 57 | if (result.showFilterStatus !== undefined) settings.showFilterStatus = result.showFilterStatus; 58 | }); 59 | } 60 | 61 | // 监听设置变更 62 | chrome.storage.onChanged.addListener((changes, namespace) => { 63 | for (let key in changes) { 64 | if (key === 'keywords') settings.tixcraftKeywords = changes[key].newValue; 65 | if (key === 'hideSoldOut') settings.tixcraftHideSoldOut = changes[key].newValue; 66 | 67 | if (key === 'targetKeywords') settings.kktixKeywords = changes[key].newValue; 68 | if (key === 'showAllPrices') settings.kktixShowAllPrices = changes[key].newValue; 69 | if (key === 'kktixHideSoldOut') settings.kktixHideSoldOut = changes[key].newValue; 70 | 71 | if (key === 'ibonKeywords') settings.ibonKeywords = changes[key].newValue; 72 | if (key === 'ibonHideSoldOut') settings.ibonHideSoldOut = changes[key].newValue; 73 | 74 | if (key === 'citylineKeywords') settings.citylineKeywords = changes[key].newValue; 75 | if (key === 'citylineHideSoldOut') settings.citylineHideSoldOut = changes[key].newValue; 76 | 77 | if (key === 'extensionEnabled') settings.extensionEnabled = changes[key].newValue; 78 | if (key === 'showServerTime') settings.showServerTime = changes[key].newValue; 79 | if (key === 'showFilterStatus') settings.showFilterStatus = changes[key].newValue; 80 | } 81 | }); 82 | 83 | // 数字转换函数,供匹配使用 84 | function convertNumber(input) { 85 | const chineseNums = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; 86 | const arabicNums = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; 87 | 88 | // 移除所有空格 89 | input = input.replace(/\s+/g, ''); 90 | 91 | // 生成所有可能的版本 92 | let versions = new Set([input]); 93 | 94 | // 找出所有数字和中文数字的位置 95 | let matches = []; 96 | // 匹配阿拉伯数字 97 | input.replace(/\d+/g, (match, offset) => { 98 | matches.push({ 99 | type: 'arabic', 100 | value: match, 101 | offset: offset, 102 | length: match.length 103 | }); 104 | return match; 105 | }); 106 | // 匹配中文数字 107 | for (let i = 0; i < chineseNums.length; i++) { 108 | let pos = input.indexOf(chineseNums[i]); 109 | while (pos !== -1) { 110 | matches.push({ 111 | type: 'chinese', 112 | value: chineseNums[i], 113 | offset: pos, 114 | length: 1, 115 | arabic: arabicNums[i] 116 | }); 117 | pos = input.indexOf(chineseNums[i], pos + 1); 118 | } 119 | } 120 | 121 | // 按位置排序 122 | matches.sort((a, b) => a.offset - b.offset); 123 | 124 | // 生成替换版本 125 | if (matches.length > 0) { 126 | // 原始文本转换 127 | let converted = input; 128 | for (let match of matches) { 129 | if (match.type === 'arabic') { 130 | // 将阿拉伯数字转为中文数字 131 | const digits = match.value.split(''); 132 | const chinese = digits.map(d => chineseNums[parseInt(d)]).join(''); 133 | converted = converted.slice(0, match.offset) + chinese + 134 | converted.slice(match.offset + match.length); 135 | } else { 136 | // 将中文数字转为阿拉伯数字 137 | converted = converted.slice(0, match.offset) + match.arabic + 138 | converted.slice(match.offset + match.length); 139 | } 140 | } 141 | versions.add(converted); 142 | } 143 | 144 | return Array.from(versions); 145 | } 146 | 147 | // 检查文本是否包含关键字(考虑数字转换) 148 | function textIncludesKeyword(text, keyword) { 149 | // 移除所有空格并转换为小写再比较 150 | const cleanText = text.replace(/\s+/g, '').toLowerCase(); 151 | const cleanKeyword = keyword.replace(/\s+/g, '').toLowerCase(); 152 | 153 | // 获取文本的所有可能版本 154 | const textVersions = convertNumber(cleanText); 155 | const keywordVersions = convertNumber(cleanKeyword); 156 | 157 | // 交叉比对所有版本 158 | return keywordVersions.some(kw => 159 | textVersions.some(txt => txt.includes(kw)) 160 | ); 161 | } 162 | 163 | // 为内容脚本通信设置 164 | // 注册消息处理程序,以便内容脚本可以直接检查元素是否应该被筛选 165 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 166 | if (!settings.extensionEnabled) { 167 | sendResponse({ shouldShow: true }); // 如果扩展被禁用,所有元素都显示 168 | return true; 169 | } 170 | 171 | // 消息类型:检查内容是否符合筛选条件 172 | if (message.type === 'CHECK_FILTER') { 173 | const { site, text, price, isSoldOut } = message.data; 174 | 175 | // 根据不同网站和条件进行判断 176 | let siteKeywords = []; 177 | let siteHideSoldOut = false; 178 | 179 | if (site === 'tixcraft') { 180 | siteKeywords = settings.tixcraftKeywords; 181 | siteHideSoldOut = settings.tixcraftHideSoldOut; 182 | } else if (site === 'kktix') { 183 | siteKeywords = settings.kktixKeywords; 184 | siteHideSoldOut = settings.kktixHideSoldOut; 185 | // 如果设置为显示所有价格,则不筛选 186 | if (settings.kktixShowAllPrices) { 187 | sendResponse({ shouldShow: !isSoldOut || !siteHideSoldOut }); 188 | return true; 189 | } 190 | } else if (site === 'ibon') { 191 | siteKeywords = settings.ibonKeywords; 192 | siteHideSoldOut = settings.ibonHideSoldOut; 193 | } else if (site === 'cityline') { 194 | siteKeywords = settings.citylineKeywords; 195 | siteHideSoldOut = settings.citylineHideSoldOut; 196 | } else { 197 | sendResponse({ shouldShow: true }); // 不支持的网站直接显示 198 | return true; 199 | } 200 | 201 | // 如果设置隐藏已售完,且票券已售完,则隐藏 202 | if (siteHideSoldOut && isSoldOut) { 203 | sendResponse({ shouldShow: false }); 204 | return true; 205 | } 206 | 207 | // 如果没有关键字,则显示所有非售罄票券 208 | if (!siteKeywords.length) { 209 | sendResponse({ shouldShow: true }); 210 | return true; 211 | } 212 | 213 | // 将关键字组合处理为AND/OR逻辑组 214 | const keywordGroups = siteKeywords.map(keyword => { 215 | return keyword.split('+').map(k => { 216 | return k.split(',').map(item => item.trim()).filter(item => item); 217 | }).filter(group => group.length > 0); 218 | }); 219 | 220 | // 根据关键字判断是否显示 221 | const searchTexts = [text, price].filter(t => t && typeof t === 'string'); 222 | const shouldShow = keywordGroups.some(orGroup => 223 | orGroup.some(andGroup => { 224 | return andGroup.every(keyword => { 225 | // 数字匹配 226 | if (!isNaN(keyword)) { 227 | const priceToFind = parseInt(keyword); 228 | return searchTexts.some(text => { 229 | const prices = (text.match(/\d+/g) || []).map(p => parseInt(p)); 230 | return prices.includes(priceToFind); 231 | }); 232 | } else { 233 | // 文本匹配 234 | return searchTexts.some(text => textIncludesKeyword(text, keyword)); 235 | } 236 | }); 237 | }) 238 | ); 239 | 240 | sendResponse({ shouldShow }); 241 | return true; 242 | } 243 | 244 | // 消息类型:将设置应用于当前标签 245 | if (message.type === 'GET_SETTINGS') { 246 | const { site } = message.data; 247 | 248 | if (site === 'tixcraft') { 249 | sendResponse({ 250 | keywords: settings.tixcraftKeywords, 251 | hideSoldOut: settings.tixcraftHideSoldOut, 252 | showServerTime: settings.showServerTime, 253 | showFilterStatus: settings.showFilterStatus, 254 | extensionEnabled: settings.extensionEnabled 255 | }); 256 | } else if (site === 'kktix') { 257 | sendResponse({ 258 | keywords: settings.kktixKeywords, 259 | showAllPrices: settings.kktixShowAllPrices, 260 | hideSoldOut: settings.kktixHideSoldOut, 261 | showServerTime: settings.showServerTime, 262 | showFilterStatus: settings.showFilterStatus, 263 | extensionEnabled: settings.extensionEnabled 264 | }); 265 | } else if (site === 'ibon') { 266 | sendResponse({ 267 | keywords: settings.ibonKeywords, 268 | hideSoldOut: settings.ibonHideSoldOut, 269 | showServerTime: settings.showServerTime, 270 | showFilterStatus: settings.showFilterStatus, 271 | extensionEnabled: settings.extensionEnabled 272 | }); 273 | } else if (site === 'cityline') { 274 | sendResponse({ 275 | keywords: settings.citylineKeywords, 276 | hideSoldOut: settings.citylineHideSoldOut, 277 | showServerTime: settings.showServerTime, 278 | showFilterStatus: settings.showFilterStatus, 279 | extensionEnabled: settings.extensionEnabled 280 | }); 281 | } else { 282 | sendResponse({}); 283 | } 284 | return true; 285 | } 286 | }); 287 | 288 | // 保持service worker活跃 289 | chrome.runtime.onConnect.addListener((port) => { 290 | port.onDisconnect.addListener(() => { 291 | // 断开连接后可以记录日志或执行其他清理工作 292 | console.log('连接已断开'); 293 | }); 294 | }); 295 | 296 | // 确保service worker不会过早终止 297 | chrome.runtime.onStartup.addListener(() => { 298 | loadSettings(); 299 | console.log('浏览器启动,搶票柴柴扩展已激活'); 300 | }); -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | // 等待DOM載入完成 2 | function waitForElement(selector) { 3 | return new Promise(resolve => { 4 | if (document.querySelector(selector)) { 5 | return resolve(document.querySelector(selector)); 6 | } 7 | 8 | const observer = new MutationObserver(mutations => { 9 | if (document.querySelector(selector)) { 10 | observer.disconnect(); 11 | resolve(document.querySelector(selector)); 12 | } 13 | }); 14 | 15 | observer.observe(document.body, { 16 | childList: true, 17 | subtree: true 18 | }); 19 | }); 20 | } 21 | 22 | // 通知早期加载脚本,content.js已加载 23 | window.addEventListener('DOMContentLoaded', () => { 24 | window.postMessage({ type: 'CONTENT_JS_LOADED' }, '*'); 25 | 26 | // 尽早执行筛选 27 | setTimeout(() => { 28 | const site = getCurrentSite(); 29 | if (site) { 30 | loadSettings(); 31 | // 如果是 ibon 的 WEB網站入口頁面,立即執行篩選 32 | if (site === 'ibon' && window.location.href.includes('WEB網站入口')) { 33 | filterTickets(); 34 | } else { 35 | filterTickets(); 36 | } 37 | } 38 | }, 0); 39 | 40 | // 特別處理年代售票網站 41 | if (getCurrentSite() === 'ticket') { 42 | // 監聽頁面變化 43 | const observer = new MutationObserver((mutations) => { 44 | for (const mutation of mutations) { 45 | if (mutation.type === 'childList' || mutation.type === 'attributes') { 46 | // 檢查是否有票區列表 47 | const areaItems = document.querySelectorAll('.area-list li.main'); 48 | if (areaItems.length > 0) { 49 | filterTickets(); 50 | break; // 找到後立即退出循環 51 | } 52 | } 53 | } 54 | }); 55 | 56 | // 監視整個文檔的變化 57 | observer.observe(document.documentElement, { 58 | childList: true, 59 | subtree: true, 60 | attributes: true 61 | }); 62 | 63 | // 定期檢查並重新應用篩選(更頻繁的檢查) 64 | setInterval(() => { 65 | const areaItems = document.querySelectorAll('.area-list li.main'); 66 | if (areaItems.length > 0) { 67 | filterTickets(); 68 | } 69 | }, 500); // 每500ms檢查一次 70 | } 71 | }); 72 | 73 | // 判斷當前網站 74 | function getCurrentSite() { 75 | const url = window.location.href; 76 | if (url.includes('tixcraft.com')) { 77 | return 'tixcraft'; 78 | } else if (url.includes('kktix.com')) { 79 | return 'kktix'; 80 | } else if (url.includes('ibon.com.tw')) { 81 | return 'ibon'; 82 | } else if (url.includes('cityline.com')) { 83 | return 'cityline'; 84 | } else if (url.includes('ticket.com.tw')) { 85 | return 'ticket'; 86 | } else if (url.includes('ticketplus.com.tw')) { 87 | return 'ticketplus'; 88 | } 89 | return null; 90 | } 91 | 92 | // 數字轉換函數 93 | function convertNumber(input) { 94 | const chineseNums = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; 95 | const arabicNums = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; 96 | 97 | // 移除所有空格 98 | input = input.replace(/\s+/g, ''); 99 | 100 | // 生成所有可能的版本 101 | let versions = new Set([input]); 102 | 103 | // 找出所有數字和中文數字的位置 104 | let matches = []; 105 | // 匹配阿拉伯數字 106 | input.replace(/\d+/g, (match, offset) => { 107 | matches.push({ 108 | type: 'arabic', 109 | value: match, 110 | offset: offset, 111 | length: match.length 112 | }); 113 | }); 114 | // 匹配中文數字 115 | for (let i = 0; i < chineseNums.length; i++) { 116 | let pos = input.indexOf(chineseNums[i]); 117 | while (pos !== -1) { 118 | matches.push({ 119 | type: 'chinese', 120 | value: chineseNums[i], 121 | offset: pos, 122 | length: 1, 123 | arabic: arabicNums[i] 124 | }); 125 | pos = input.indexOf(chineseNums[i], pos + 1); 126 | } 127 | } 128 | 129 | // 按位置排序 130 | matches.sort((a, b) => a.offset - b.offset); 131 | 132 | // 生成替換版本 133 | if (matches.length > 0) { 134 | // 原始文本轉換 135 | let converted = input; 136 | for (let match of matches) { 137 | if (match.type === 'arabic') { 138 | // 將阿拉伯數字轉為中文數字 139 | const digits = match.value.split(''); 140 | const chinese = digits.map(d => chineseNums[parseInt(d)]).join(''); 141 | converted = converted.slice(0, match.offset) + chinese + 142 | converted.slice(match.offset + match.length); 143 | } else { 144 | // 將中文數字轉為阿拉伯數字 145 | converted = converted.slice(0, match.offset) + match.arabic + 146 | converted.slice(match.offset + match.length); 147 | } 148 | } 149 | versions.add(converted); 150 | } 151 | 152 | return Array.from(versions); 153 | } 154 | 155 | // 檢查文本是否包含關鍵字(考慮數字轉換) 156 | function textIncludesKeyword(text, keyword) { 157 | // 移除所有空格和逗號,並轉換為小寫再比較 158 | const cleanText = text.replace(/[\s,]+/g, '').toLowerCase(); 159 | 160 | // 處理 OR 邏輯(用 + 分隔) 161 | const orParts = keyword.split('+').map(part => part.trim()); 162 | 163 | // 如果任一 OR 條件符合就返回 true 164 | return orParts.some(orPart => { 165 | // 處理 AND 邏輯(用 , 分隔) 166 | const andParts = orPart.split(',').map(part => part.trim()); 167 | 168 | // 所有 AND 條件都要符合才返回 true 169 | return andParts.every(andPart => { 170 | // 移除關鍵字中的逗號 171 | andPart = andPart.replace(/,/g, ''); 172 | 173 | // 獲取文本的所有可能版本(包含數字轉換) 174 | const textVersions = convertNumber(cleanText); 175 | const keywordVersions = convertNumber(andPart.toLowerCase()); 176 | 177 | // 交叉比對所有版本 178 | return keywordVersions.some(kw => 179 | textVersions.some(txt => txt.includes(kw)) 180 | ); 181 | }); 182 | }); 183 | } 184 | 185 | let settings = { 186 | keywords: [], 187 | blacklist: [], // 新增黑名單設定 188 | hideSoldOut: false, 189 | isProcessing: false, 190 | showAllPrices: true 191 | }; 192 | 193 | // 從storage載入設定 194 | function loadSettings() { 195 | const site = getCurrentSite(); 196 | 197 | if (site === 'tixcraft') { 198 | chrome.storage.local.get(['keywords', 'blacklist', 'hideSoldOut'], (result) => { 199 | settings = { 200 | ...settings, 201 | ...result, 202 | blacklist: result.blacklist || [] 203 | }; 204 | if (document.readyState === 'complete') { 205 | filterTickets(); 206 | showFilterStatus(); 207 | } else { 208 | window.addEventListener('load', () => { 209 | filterTickets(); 210 | showFilterStatus(); 211 | }); 212 | } 213 | }); 214 | } else if (site === 'kktix') { 215 | chrome.storage.sync.get(['targetKeywords', 'blacklist', 'showAllPrices', 'hideSoldOut'], (result) => { 216 | if (result.targetKeywords) { 217 | settings.keywords = result.targetKeywords; 218 | } 219 | if (result.blacklist) { 220 | settings.blacklist = result.blacklist; 221 | } 222 | if (result.showAllPrices !== undefined) { 223 | settings.showAllPrices = result.showAllPrices; 224 | } 225 | if (result.hideSoldOut !== undefined) { 226 | settings.hideSoldOut = result.hideSoldOut; 227 | } 228 | if (document.readyState === 'complete') { 229 | filterTickets(); 230 | showFilterStatus(); 231 | } else { 232 | window.addEventListener('load', () => { 233 | filterTickets(); 234 | showFilterStatus(); 235 | }); 236 | } 237 | }); 238 | } else if (site === 'ibon') { 239 | chrome.storage.local.get(['ibonKeywords', 'ibonBlacklist', 'ibonHideSoldOut'], (result) => { 240 | if (result.ibonKeywords) { 241 | settings.keywords = result.ibonKeywords; 242 | } 243 | if (result.ibonBlacklist) { 244 | settings.blacklist = result.ibonBlacklist; 245 | } 246 | if (result.ibonHideSoldOut !== undefined) { 247 | settings.hideSoldOut = result.ibonHideSoldOut; 248 | } 249 | if (document.readyState === 'complete') { 250 | filterTickets(); 251 | showFilterStatus(); 252 | } else { 253 | window.addEventListener('load', () => { 254 | filterTickets(); 255 | showFilterStatus(); 256 | }); 257 | } 258 | }); 259 | } else if (site === 'cityline') { 260 | chrome.storage.local.get(['citylineKeywords', 'citylineBlacklist', 'citylineHideSoldOut'], (result) => { 261 | if (result.citylineKeywords) { 262 | settings.keywords = result.citylineKeywords; 263 | } 264 | if (result.citylineBlacklist) { 265 | settings.blacklist = result.citylineBlacklist; 266 | } 267 | if (result.citylineHideSoldOut !== undefined) { 268 | settings.hideSoldOut = result.citylineHideSoldOut; 269 | } 270 | if (document.readyState === 'complete') { 271 | filterTickets(); 272 | showFilterStatus(); 273 | } else { 274 | window.addEventListener('load', () => { 275 | filterTickets(); 276 | showFilterStatus(); 277 | }); 278 | } 279 | }); 280 | } else if (site === 'ticket') { 281 | chrome.storage.local.get(['ticketKeywords', 'ticketHideSoldOut'], (result) => { 282 | if (result.ticketKeywords) { 283 | settings.keywords = result.ticketKeywords; 284 | } 285 | if (result.ticketHideSoldOut !== undefined) { 286 | settings.hideSoldOut = result.ticketHideSoldOut; 287 | } 288 | if (document.readyState === 'complete') { 289 | filterTickets(); 290 | showFilterStatus(); 291 | } else { 292 | window.addEventListener('load', () => { 293 | filterTickets(); 294 | showFilterStatus(); 295 | }); 296 | } 297 | }); 298 | } else if (site === 'ticketplus') { 299 | chrome.storage.local.get(['ticketplusKeywords', 'ticketplusBlacklist', 'ticketplusHideSoldOut'], (result) => { 300 | if (result.ticketplusKeywords) { 301 | settings.keywords = result.ticketplusKeywords; 302 | } 303 | if (result.ticketplusBlacklist) { 304 | settings.blacklist = result.ticketplusBlacklist; 305 | } 306 | if (result.ticketplusHideSoldOut !== undefined) { 307 | settings.hideSoldOut = result.ticketplusHideSoldOut; 308 | } 309 | if (document.readyState === 'complete') { 310 | filterTickets(); 311 | showFilterStatus(); 312 | } else { 313 | window.addEventListener('load', () => { 314 | filterTickets(); 315 | showFilterStatus(); 316 | }); 317 | } 318 | }); 319 | } 320 | } 321 | 322 | // 初始化 323 | loadSettings(); 324 | 325 | // 確保預設設置被正確設置 326 | chrome.storage.local.get(['showServerTime', 'showFilterStatus'], function(result) { 327 | // 如果設置尚未初始化,設置預設值 328 | const updates = {}; 329 | if (result.showServerTime === undefined) { 330 | updates.showServerTime = true; 331 | } 332 | if (result.showFilterStatus === undefined) { 333 | updates.showFilterStatus = true; 334 | } 335 | 336 | // 如果有需要更新的設置,保存到storage 337 | if (Object.keys(updates).length > 0) { 338 | chrome.storage.local.set(updates); 339 | } 340 | }); 341 | 342 | // 注入CSS樣式 343 | const style = document.createElement('style'); 344 | style.textContent = ` 345 | .ticket-filter-notification { 346 | position: fixed; 347 | right: 10px; 348 | padding: 8px 12px; 349 | background-color: #2684FF; 350 | color: #fff; 351 | border-radius: 4px; 352 | box-shadow: 0 2px 5px rgba(0,0,0,0.2); 353 | z-index: 9999; 354 | font-size: 14px; 355 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft JhengHei", sans-serif; 356 | } 357 | 358 | .server-time-display { 359 | position: fixed; 360 | top: 10px; 361 | right: 10px; 362 | padding: 8px 12px; 363 | background-color: #2684FF; 364 | color: #fff; 365 | border-radius: 4px; 366 | box-shadow: 0 2px 5px rgba(0,0,0,0.2); 367 | z-index: 10000; 368 | font-size: 14px; 369 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft JhengHei", sans-serif; 370 | } 371 | 372 | /* 遠大售票平台平滑過渡效果 */ 373 | .v-expansion-panels.seats-area .v-expansion-panel { 374 | opacity: 0.1; 375 | transition: opacity 0.15s ease-out; 376 | } 377 | 378 | .v-expansion-panels.seats-area .v-expansion-panel.filter-processed { 379 | opacity: 1; 380 | } 381 | `; 382 | document.head.appendChild(style); 383 | 384 | // 顯示篩選狀態 385 | function showFilterStatus() { 386 | chrome.storage.local.get(['showServerTime', 'showFilterStatus', 'extensionEnabled'], function(result) { 387 | // 如果擴展被停用,移除所有顯示 388 | if (result.extensionEnabled === false) { 389 | const existingNotification = document.querySelector('.ticket-filter-notification'); 390 | if (existingNotification) { 391 | existingNotification.remove(); 392 | } 393 | const existingTimeDisplay = document.querySelector('.server-time-display'); 394 | if (existingTimeDisplay) { 395 | existingTimeDisplay.remove(); 396 | } 397 | return; 398 | } 399 | 400 | if (result.showFilterStatus === false) { 401 | const existingNotification = document.querySelector('.ticket-filter-notification'); 402 | if (existingNotification) { 403 | existingNotification.remove(); 404 | } 405 | return; 406 | } 407 | 408 | let notification = document.querySelector('.ticket-filter-notification'); 409 | if (!notification) { 410 | notification = document.createElement('div'); 411 | notification.className = 'ticket-filter-notification'; 412 | document.body.appendChild(notification); 413 | } 414 | 415 | if (result.showServerTime) { 416 | notification.style.top = '50px'; // 在時間顯示下方 417 | } else { 418 | notification.style.top = '10px'; // 原本的位置 419 | } 420 | 421 | let statusText = []; 422 | const site = getCurrentSite(); 423 | 424 | if (site === 'tixcraft') { 425 | let hasFilter = false; 426 | if (settings.keywords.length > 0) { 427 | statusText.push(`關鍵字篩選:${settings.keywords.join('、')}`); 428 | hasFilter = true; 429 | } 430 | if (settings.blacklist && settings.blacklist.length > 0) { 431 | statusText.push(`黑名單:${settings.blacklist.join('、')}`); 432 | hasFilter = true; 433 | } 434 | if (!hasFilter) { 435 | statusText.push('無篩選條件'); 436 | } 437 | if (settings.hideSoldOut) { 438 | statusText.push('不顯示已售完票券'); 439 | } 440 | } else if (site === 'kktix') { 441 | let hasFilter = false; 442 | if (settings.keywords.length > 0) { 443 | statusText.push(`關鍵字篩選:${settings.keywords.join('、')}`); 444 | hasFilter = true; 445 | } 446 | if (settings.blacklist && settings.blacklist.length > 0) { 447 | statusText.push(`黑名單:${settings.blacklist.join('、')}`); 448 | hasFilter = true; 449 | } 450 | if (!hasFilter) { 451 | statusText.push('無篩選條件'); 452 | } 453 | if (settings.hideSoldOut) { 454 | statusText.push('不顯示已售完票券'); 455 | } 456 | } else if (site === 'ibon') { 457 | let hasFilter = false; 458 | if (settings.keywords && settings.keywords.length > 0) { 459 | statusText.push(`關鍵字篩選:${settings.keywords.join('、')}`); 460 | hasFilter = true; 461 | } 462 | if (settings.blacklist && settings.blacklist.length > 0) { 463 | statusText.push(`黑名單:${settings.blacklist.join('、')}`); 464 | hasFilter = true; 465 | } 466 | if (!hasFilter) { 467 | statusText.push('無篩選條件'); 468 | } 469 | if (settings.hideSoldOut) { 470 | statusText.push('不顯示已售完票券'); 471 | } 472 | } else if (site === 'cityline') { 473 | let hasFilter = false; 474 | if (settings.keywords && settings.keywords.length > 0) { 475 | statusText.push(`關鍵字篩選:${settings.keywords.join('、')}`); 476 | hasFilter = true; 477 | } 478 | if (settings.blacklist && settings.blacklist.length > 0) { 479 | statusText.push(`黑名單:${settings.blacklist.join('、')}`); 480 | hasFilter = true; 481 | } 482 | if (!hasFilter) { 483 | statusText.push('無篩選條件'); 484 | } 485 | if (settings.hideSoldOut) { 486 | statusText.push('不顯示已售完票券'); 487 | } 488 | } else if (site === 'ticket') { 489 | let hasFilter = false; 490 | if (settings.keywords && settings.keywords.length > 0) { 491 | statusText.push(`關鍵字篩選:${settings.keywords.join('、')}`); 492 | hasFilter = true; 493 | } 494 | if (settings.blacklist && settings.blacklist.length > 0) { 495 | statusText.push(`黑名單:${settings.blacklist.join('、')}`); 496 | hasFilter = true; 497 | } 498 | if (!hasFilter) { 499 | statusText.push('無篩選條件'); 500 | } 501 | if (settings.hideSoldOut) { 502 | statusText.push('不顯示已售完票券'); 503 | } 504 | } else if (site === 'ticketplus') { 505 | let hasFilter = false; 506 | if (settings.keywords && settings.keywords.length > 0) { 507 | statusText.push(`關鍵字篩選:${settings.keywords.join('、')}`); 508 | hasFilter = true; 509 | } 510 | if (settings.blacklist && settings.blacklist.length > 0) { 511 | statusText.push(`黑名單:${settings.blacklist.join('、')}`); 512 | hasFilter = true; 513 | } 514 | if (!hasFilter) { 515 | statusText.push('無篩選條件'); 516 | } 517 | if (settings.hideSoldOut) { 518 | statusText.push('不顯示已售完票券'); 519 | } 520 | } 521 | 522 | const newText = statusText.join(' | '); 523 | if (notification.textContent !== newText) { 524 | notification.textContent = newText; 525 | } 526 | }); 527 | } 528 | 529 | // 顯示所有區域 530 | function showAllAreas() { 531 | const site = getCurrentSite(); 532 | if (site === 'tixcraft') { 533 | document.querySelectorAll('.area-list li').forEach(item => { 534 | item.style.display = ''; 535 | }); 536 | document.querySelectorAll('.zone-label').forEach(label => { 537 | label.style.display = ''; 538 | }); 539 | } else if (site === 'kktix') { 540 | document.querySelectorAll('.ticket-unit').forEach(item => { 541 | item.style.display = ''; 542 | }); 543 | } else if (site === 'ibon') { 544 | function showAllTickets(element) { 545 | if (element.shadowRoot) { 546 | const rows = element.shadowRoot.querySelectorAll('tr[id^="B"]'); 547 | rows.forEach(row => { 548 | // 移除所有由擴充功能添加的樣式和類別 549 | row.style.removeProperty('display'); 550 | row.classList.remove('hidden-by-extension'); 551 | row.classList.remove('filter-processed'); 552 | Array.from(row.children).forEach(cell => { 553 | cell.style.removeProperty('display'); 554 | }); 555 | }); 556 | 557 | // 移除 shadow root 中的過濾樣式 558 | const filterStyle = element.shadowRoot.querySelector('.extension-filter-style'); 559 | if (filterStyle) { 560 | filterStyle.remove(); 561 | } 562 | } 563 | Array.from(element.children || []).forEach(showAllTickets); 564 | } 565 | showAllTickets(document.documentElement); 566 | 567 | // 強制更新頁面狀態 568 | setTimeout(() => { 569 | window.dispatchEvent(new Event('resize')); 570 | }, 100); 571 | } else if (site === 'cityline') { 572 | document.querySelectorAll('.form-check').forEach(item => { 573 | item.style.display = ''; 574 | }); 575 | } else if (site === 'ticket') { 576 | document.querySelectorAll('.area-list li').forEach(item => { 577 | item.style.display = ''; 578 | }); 579 | } else if (site === 'ticketplus') { 580 | document.querySelectorAll('.v-expansion-panels.seats-area .v-expansion-panel').forEach(panel => { 581 | panel.style.display = ''; 582 | panel.classList.add('filter-processed'); 583 | }); 584 | } 585 | 586 | // 通知早期加载脚本,已显示所有票券 587 | window.postMessage({ type: 'FILTER_APPLIED' }, '*'); 588 | } 589 | 590 | // 主要篩選功能 591 | async function filterTickets() { 592 | // 檢查擴展是否被停用 593 | const result = await new Promise(resolve => { 594 | chrome.storage.local.get(['extensionEnabled'], resolve); 595 | }); 596 | 597 | if (result.extensionEnabled === false) { 598 | showAllAreas(); 599 | return; 600 | } 601 | 602 | if (settings.isProcessing) return; 603 | settings.isProcessing = true; 604 | 605 | try { 606 | const site = getCurrentSite(); 607 | 608 | // 根據不同網站執行對應的過濾邏輯 609 | if (site === 'tixcraft' && window.location.href.includes('/ticket/area/')) { 610 | await filterTixcraftTickets(); 611 | } else if (site === 'kktix' && document.querySelector('.ticket-unit')) { 612 | await filterKKTIXTickets(); 613 | } else if (site === 'ibon') { 614 | await filterIbonTickets(); 615 | } else if (site === 'cityline' && document.querySelector('.price-box1')) { 616 | await filterCitylineTickets(); 617 | } else if (site === 'ticket') { 618 | await filterTicketComTickets(); 619 | } else if (site === 'ticketplus') { 620 | await filterTicketPlusTickets(); 621 | } 622 | 623 | // 通知早期加載腳本,過濾已完成 624 | window.postMessage({ type: 'FILTER_APPLIED' }, '*'); 625 | } catch (error) { 626 | console.error('過濾票券時發生錯誤:', error); 627 | // 即使出錯也要通知移除隱藏樣式 628 | window.postMessage({ type: 'FILTER_APPLIED' }, '*'); 629 | } finally { 630 | settings.isProcessing = false; 631 | } 632 | } 633 | 634 | // KKTIX票券篩選 635 | async function filterKKTIXTickets() { 636 | const ticketRows = document.querySelectorAll('.ticket-unit'); 637 | if (!ticketRows.length) return; 638 | 639 | showAllAreas(); 640 | 641 | // 如果沒有任何篩選條件,只處理是否隱藏已售完 642 | if (settings.keywords.length === 0 && (!settings.blacklist || settings.blacklist.length === 0)) { 643 | ticketRows.forEach(row => { 644 | const isSoldOut = row.textContent.includes('已售完'); 645 | if (settings.hideSoldOut && isSoldOut) { 646 | row.style.display = 'none'; 647 | } else { 648 | row.style.display = ''; 649 | } 650 | }); 651 | return; 652 | } 653 | 654 | // 處理每個票券 655 | ticketRows.forEach(row => { 656 | const priceElement = row.querySelector('.ticket-price'); 657 | const nameElement = row.querySelector('.ticket-name'); 658 | 659 | if (priceElement && nameElement) { 660 | const price = priceElement.textContent.trim(); 661 | const name = nameElement.textContent.trim(); 662 | const text = name + ' ' + price; 663 | const isSoldOut = row.textContent.includes('已售完'); 664 | 665 | // 檢查是否在黑名單中 666 | const isBlacklisted = settings.blacklist && settings.blacklist.length > 0 && 667 | settings.blacklist.some(blacklistItem => { 668 | // 處理 OR 邏輯(用 + 分隔) 669 | const orParts = blacklistItem.split('+').map(part => part.trim()); 670 | return orParts.some(orPart => { 671 | // 處理 AND 邏輯(用 , 分隔) 672 | const andParts = orPart.split(',').map(part => part.trim()); 673 | return andParts.every(andPart => textIncludesKeyword(text, andPart)); 674 | }); 675 | }); 676 | 677 | // 檢查是否符合關鍵字 678 | const matchesKeyword = settings.keywords.length === 0 || 679 | settings.keywords.some(keyword => { 680 | // 處理 OR 邏輯(用 + 分隔) 681 | const orParts = keyword.split('+').map(part => part.trim()); 682 | return orParts.some(orPart => { 683 | // 處理 AND 邏輯(用 , 分隔) 684 | const andParts = orPart.split(',').map(part => part.trim()); 685 | return andParts.every(andPart => textIncludesKeyword(text, andPart)); 686 | }); 687 | }); 688 | 689 | // 決定是否顯示 690 | let shouldShow = true; 691 | 692 | // 如果在黑名單中,不顯示 693 | if (isBlacklisted) { 694 | shouldShow = false; 695 | } 696 | 697 | // 如果有關鍵字且不符合關鍵字,不顯示 698 | if (settings.keywords.length > 0 && !matchesKeyword) { 699 | shouldShow = false; 700 | } 701 | 702 | // 如果設定隱藏已售完且已售完,不顯示 703 | if (settings.hideSoldOut && isSoldOut) { 704 | shouldShow = false; 705 | } 706 | 707 | // 立即應用顯示/隱藏狀態 708 | row.style.setProperty('display', shouldShow ? '' : 'none', 'important'); 709 | } 710 | }); 711 | } 712 | 713 | // 拓元票券篩選 714 | async function filterTixcraftTickets() { 715 | await waitForElement('.zone-label[data-id]'); 716 | 717 | const areaGroups = document.querySelectorAll('.zone-label[data-id]'); 718 | if (!areaGroups.length) return; 719 | 720 | showAllAreas(); 721 | 722 | // 如果沒有任何篩選條件,只處理是否隱藏已售完 723 | if (settings.keywords.length === 0 && (!settings.blacklist || settings.blacklist.length === 0)) { 724 | if (settings.hideSoldOut) { 725 | document.querySelectorAll('.area-list li').forEach(item => { 726 | const remainingText = item.querySelector('font')?.textContent.trim() || ''; 727 | if (remainingText.includes('已售完')) { 728 | item.style.display = 'none'; 729 | } else { 730 | item.style.display = ''; 731 | } 732 | }); 733 | } 734 | return; 735 | } 736 | 737 | areaGroups.forEach(group => { 738 | const groupId = group.dataset.id; 739 | const areaList = document.getElementById(groupId); 740 | if (!areaList) return; 741 | 742 | const items = areaList.querySelectorAll('li'); 743 | let hasVisibleItems = false; 744 | 745 | const groupTitle = group.querySelector('b')?.textContent.trim() || group.textContent.trim(); 746 | 747 | items.forEach(item => { 748 | const areaText = item.textContent.trim(); 749 | const remainingText = item.querySelector('font')?.textContent.trim() || ''; 750 | const isSoldOut = remainingText.includes('已售完'); 751 | const fullText = groupTitle + ' ' + areaText; 752 | 753 | // 檢查是否在黑名單中 754 | const isBlacklisted = isInBlacklist(fullText, settings.blacklist); 755 | 756 | // 檢查是否符合關鍵字 757 | const matchesKeyword = settings.keywords.length === 0 || settings.keywords.some(keyword => 758 | textIncludesKeyword(fullText, keyword) 759 | ); 760 | 761 | // 決定是否顯示 762 | let shouldShow = true; 763 | 764 | // 如果有黑名單項目且符合黑名單,則不顯示 765 | if (settings.blacklist && settings.blacklist.length > 0 && isBlacklisted) { 766 | shouldShow = false; 767 | } 768 | 769 | // 如果有關鍵字且不符合關鍵字,則不顯示 770 | if (settings.keywords.length > 0 && !matchesKeyword) { 771 | shouldShow = false; 772 | } 773 | 774 | // 如果設定隱藏已售完且已售完,則不顯示 775 | if (settings.hideSoldOut && isSoldOut) { 776 | shouldShow = false; 777 | } 778 | 779 | item.style.display = shouldShow ? '' : 'none'; 780 | if (shouldShow) { 781 | hasVisibleItems = true; 782 | } 783 | }); 784 | 785 | group.style.display = hasVisibleItems ? '' : 'none'; 786 | }); 787 | } 788 | 789 | // ibon票券篩選 790 | async function filterIbonTickets() { 791 | function processTicketAreas(element) { 792 | if (element.shadowRoot) { 793 | const rows = element.shadowRoot.querySelectorAll('tr[id^="B"]'); 794 | if (rows.length > 0) { 795 | rows.forEach(row => { 796 | // 只保留篩選邏輯,移除 shadowRoot 處理 797 | const areaCell = row.querySelector('td[data-title="票區"]'); 798 | const priceCell = row.querySelector('td[data-title="票價(NT$)"]'); 799 | 800 | if (areaCell && priceCell) { 801 | const areaText = areaCell.textContent.trim(); 802 | const priceText = priceCell.textContent.replace(/,/g, '').trim(); 803 | const fullText = `${areaText} ${priceText}`; 804 | const isSoldOut = row.textContent.includes('售完'); 805 | 806 | // 檢查是否在黑名單中 807 | const isBlacklisted = settings.blacklist && settings.blacklist.length > 0 && 808 | settings.blacklist.some(blacklistItem => { 809 | const orParts = blacklistItem.split('+').map(part => part.trim()); 810 | return orParts.some(orPart => { 811 | const andParts = orPart.split(',').map(part => part.trim()); 812 | return andParts.every(andPart => textIncludesKeyword(fullText, andPart)); 813 | }); 814 | }); 815 | 816 | // 檢查是否符合關鍵字 817 | const matchesKeyword = settings.keywords.length === 0 || 818 | settings.keywords.some(keyword => { 819 | const orParts = keyword.split('+').map(part => part.trim()); 820 | return orParts.some(orPart => { 821 | const andParts = orPart.split(',').map(part => part.trim()); 822 | return andParts.every(andPart => textIncludesKeyword(fullText, andPart)); 823 | }); 824 | }); 825 | 826 | // 決定是否顯示 827 | let shouldShow = true; 828 | 829 | if (isBlacklisted) shouldShow = false; 830 | if (settings.keywords.length > 0 && !matchesKeyword) shouldShow = false; 831 | if (settings.hideSoldOut && isSoldOut) shouldShow = false; 832 | 833 | // 應用顯示/隱藏狀態 834 | if (!shouldShow) { 835 | row.style.setProperty('display', 'none', 'important'); 836 | row.classList.add('hidden-by-extension'); 837 | Array.from(row.children).forEach(cell => { 838 | cell.style.setProperty('display', 'none', 'important'); 839 | }); 840 | } else { 841 | row.style.removeProperty('display'); 842 | row.classList.remove('hidden-by-extension'); 843 | Array.from(row.children).forEach(cell => { 844 | cell.style.removeProperty('display'); 845 | }); 846 | } 847 | } 848 | }); 849 | } 850 | } 851 | 852 | Array.from(element.children || []).forEach(processTicketAreas); 853 | } 854 | 855 | processTicketAreas(document.documentElement); 856 | } 857 | 858 | // Cityline票券篩選 859 | async function filterCitylineTickets() { 860 | const ticketItems = document.querySelectorAll('.form-check'); 861 | if (!ticketItems.length) return; 862 | 863 | // 標記所有票券元素為"準備好被篩選" 864 | document.querySelectorAll('.price-box1, .form-check').forEach(el => { 865 | el.classList.add('filter-ready'); 866 | }); 867 | 868 | // 如果沒有任何篩選條件,只處理是否隱藏已售完 869 | if (settings.keywords.length === 0 && (!settings.blacklist || settings.blacklist.length === 0)) { 870 | if (settings.hideSoldOut) { 871 | ticketItems.forEach(item => { 872 | const isSoldOut = item.textContent.includes('售罄') || 873 | item.querySelector('input[data-disabled="true"]') !== null; 874 | item.style.display = isSoldOut ? 'none' : ''; 875 | if (!isSoldOut) { 876 | item.style.visibility = 'visible'; 877 | item.style.opacity = '1'; 878 | } 879 | }); 880 | } else { 881 | ticketItems.forEach(item => { 882 | item.style.display = ''; 883 | item.style.visibility = 'visible'; 884 | item.style.opacity = '1'; 885 | }); 886 | } 887 | return; 888 | } 889 | 890 | ticketItems.forEach(item => { 891 | const priceText = item.querySelector('.price-num')?.textContent.trim() || ''; 892 | const degreeText = item.querySelector('.price-degree')?.textContent.trim() || ''; 893 | const isSoldOut = item.textContent.includes('售罄') || 894 | item.querySelector('input[data-disabled="true"]') !== null; 895 | 896 | const fullText = `${degreeText} ${priceText}`; 897 | 898 | // 檢查是否在黑名單中 899 | const isBlacklisted = settings.blacklist && settings.blacklist.length > 0 && 900 | settings.blacklist.some(blacklistItem => textIncludesKeyword(fullText, blacklistItem)); 901 | 902 | // 檢查是否符合關鍵字 903 | const matchesKeyword = settings.keywords.length === 0 || 904 | settings.keywords.some(keyword => textIncludesKeyword(fullText, keyword)); 905 | 906 | // 決定是否顯示 907 | let shouldShow = true; 908 | 909 | // 如果在黑名單中,不顯示 910 | if (isBlacklisted) { 911 | shouldShow = false; 912 | } 913 | 914 | // 如果有關鍵字且不符合關鍵字,不顯示 915 | if (settings.keywords.length > 0 && !matchesKeyword) { 916 | shouldShow = false; 917 | } 918 | 919 | // 如果設定隱藏已售完且已售完,不顯示 920 | if (settings.hideSoldOut && isSoldOut) { 921 | shouldShow = false; 922 | } 923 | 924 | // 立即應用顯示/隱藏狀態 925 | if (shouldShow) { 926 | item.style.display = ''; 927 | item.style.visibility = 'visible'; 928 | item.style.opacity = '1'; 929 | } else { 930 | item.style.display = 'none'; 931 | } 932 | }); 933 | } 934 | 935 | // 年代售票網站篩選 936 | async function filterTicketComTickets() { 937 | // 使用輪詢機制等待票區列表加載 938 | let retryCount = 0; 939 | const maxRetries = 20; // 最多等待20次 940 | const retryInterval = 100; // 每100ms檢查一次,總共最多等待2秒 941 | 942 | async function waitForTicketList() { 943 | return new Promise((resolve) => { 944 | const checkElements = () => { 945 | const areaItems = document.querySelectorAll('.area-list li.main'); 946 | if (areaItems.length > 0) { 947 | resolve(true); 948 | } else if (retryCount < maxRetries) { 949 | retryCount++; 950 | setTimeout(checkElements, retryInterval); 951 | } else { 952 | resolve(false); 953 | } 954 | }; 955 | checkElements(); 956 | }); 957 | } 958 | 959 | // 等待票區列表加載 960 | const isLoaded = await waitForTicketList(); 961 | if (!isLoaded) { 962 | console.log('票區列表加載超時'); 963 | return; 964 | } 965 | 966 | const areaItems = document.querySelectorAll('.area-list li.main'); 967 | if (!areaItems.length) return; 968 | 969 | // 使用 setProperty 來確保樣式被正確應用 970 | function setDisplayStyle(element, show) { 971 | if (show) { 972 | element.style.setProperty('display', '', 'important'); 973 | element.style.setProperty('visibility', 'visible', 'important'); 974 | } else { 975 | element.style.setProperty('display', 'none', 'important'); 976 | } 977 | } 978 | 979 | // 如果沒有關鍵字,則只處理是否隱藏已售完 980 | if (settings.keywords.length === 0) { 981 | if (settings.hideSoldOut) { 982 | areaItems.forEach(item => { 983 | const soldOutText = item.querySelector('font[color="#FF0000"]')?.textContent || ''; 984 | const badgeText = item.querySelector('.badge')?.textContent || ''; 985 | setDisplayStyle(item, soldOutText.includes('熱賣中') && !badgeText.includes('剩位')); 986 | }); 987 | } 988 | return; 989 | } 990 | 991 | // 支援AND/OR邏輯的關鍵字處理 992 | const keywordGroups = settings.keywords.map(keyword => { 993 | return keyword.split('+').map(k => { 994 | return k.split(',').map(item => item.trim()).filter(item => item); 995 | }).filter(group => group.length > 0); 996 | }); 997 | 998 | areaItems.forEach(item => { 999 | const areaText = item.querySelector('font[color="#333"]')?.textContent || ''; 1000 | const statusText = item.querySelector('font[color="#FF0000"]')?.textContent || ''; 1001 | const badgeText = item.querySelector('.badge')?.textContent || ''; 1002 | const isSoldOut = !statusText.includes('熱賣中') || badgeText.includes('剩位'); 1003 | 1004 | const shouldShow = keywordGroups.some(orGroup => 1005 | orGroup.some(andGroup => { 1006 | return andGroup.every(keyword => { 1007 | if (!isNaN(keyword)) { 1008 | // 數字比對 1009 | const priceToFind = parseInt(keyword); 1010 | const prices = (areaText.match(/\d+/g) || []).map(p => parseInt(p)); 1011 | return prices.includes(priceToFind); 1012 | } else { 1013 | // 文字比對 1014 | return textIncludesKeyword(areaText, keyword); 1015 | } 1016 | }); 1017 | }) 1018 | ); 1019 | 1020 | setDisplayStyle(item, shouldShow && (!isSoldOut || !settings.hideSoldOut)); 1021 | }); 1022 | } 1023 | 1024 | // 遠大售票平台票券篩選 1025 | async function filterTicketPlusTickets() { 1026 | // 等待票券元素出現 1027 | const waitForTickets = () => { 1028 | return new Promise((resolve) => { 1029 | const check = () => { 1030 | const tickets = document.querySelectorAll('.v-expansion-panels.seats-area .v-expansion-panel'); 1031 | if (tickets.length > 0) { 1032 | resolve(tickets); 1033 | } else { 1034 | requestAnimationFrame(check); 1035 | } 1036 | }; 1037 | check(); 1038 | }); 1039 | }; 1040 | 1041 | const ticketPanels = await waitForTickets(); 1042 | 1043 | // 如果沒有任何篩選條件,顯示所有票券 1044 | if (settings.keywords.length === 0 && (!settings.blacklist || settings.blacklist.length === 0)) { 1045 | ticketPanels.forEach(panel => { 1046 | const soldOutText = panel.querySelector('.soldout'); 1047 | const remainingText = panel.querySelector('small'); 1048 | 1049 | if (settings.hideSoldOut && (soldOutText || (remainingText && remainingText.textContent.includes('剩餘 0')))) { 1050 | panel.style.display = 'none'; 1051 | } else { 1052 | panel.style.display = ''; 1053 | panel.classList.add('filter-processed'); 1054 | } 1055 | }); 1056 | return; 1057 | } 1058 | 1059 | ticketPanels.forEach(panel => { 1060 | const areaText = panel.querySelector('.d-flex.align-center')?.textContent.trim() || ''; 1061 | const priceText = panel.querySelector('.text-right.col.col-4')?.textContent.trim() || ''; 1062 | const soldOutText = panel.querySelector('.soldout'); 1063 | const remainingText = panel.querySelector('small'); 1064 | 1065 | const isSoldOut = soldOutText || (remainingText && remainingText.textContent.includes('剩餘 0')); 1066 | const fullText = `${areaText} ${priceText}`; 1067 | 1068 | // 檢查是否在黑名單中 1069 | const isBlacklisted = settings.blacklist && settings.blacklist.length > 0 && 1070 | settings.blacklist.some(blacklistItem => { 1071 | const orParts = blacklistItem.split('+').map(part => part.trim()); 1072 | return orParts.some(orPart => { 1073 | const andParts = orPart.split(',').map(part => part.trim()); 1074 | return andParts.every(andPart => textIncludesKeyword(fullText, andPart)); 1075 | }); 1076 | }); 1077 | 1078 | // 檢查是否符合關鍵字 1079 | const matchesKeyword = settings.keywords.length === 0 || 1080 | settings.keywords.some(keyword => { 1081 | const orParts = keyword.split('+').map(part => part.trim()); 1082 | return orParts.some(orPart => { 1083 | const andParts = orPart.split(',').map(part => part.trim()); 1084 | return andParts.every(andPart => textIncludesKeyword(fullText, andPart)); 1085 | }); 1086 | }); 1087 | 1088 | // 決定是否顯示 1089 | let shouldShow = true; 1090 | 1091 | if (isBlacklisted) shouldShow = false; 1092 | if (settings.keywords.length > 0 && !matchesKeyword) shouldShow = false; 1093 | if (settings.hideSoldOut && isSoldOut) shouldShow = false; 1094 | 1095 | // 應用顯示/隱藏狀態 1096 | if (shouldShow) { 1097 | panel.style.display = ''; 1098 | panel.classList.add('filter-processed'); 1099 | } else { 1100 | panel.style.display = 'none'; 1101 | panel.classList.remove('filter-processed'); 1102 | } 1103 | }); 1104 | } 1105 | 1106 | // 新增:檢查文本是否在黑名單中 1107 | function isInBlacklist(text, blacklist) { 1108 | if (!blacklist || blacklist.length === 0) return false; 1109 | 1110 | // 將文本轉換為小寫並移除多餘空格,以便比對 1111 | const normalizedText = text.toLowerCase().trim(); 1112 | 1113 | // 檢查每個黑名單關鍵字 1114 | return blacklist.some(blacklistItem => { 1115 | // 處理 OR 邏輯(用 + 分隔) 1116 | const orParts = blacklistItem.split('+').map(part => part.trim()); 1117 | 1118 | // 如果任一 OR 條件符合就返回 true 1119 | return orParts.some(orPart => { 1120 | // 處理 AND 邏輯(用 , 分隔) 1121 | const andParts = orPart.split(',').map(part => part.trim()); 1122 | 1123 | // 所有 AND 條件都要符合才返回 true 1124 | return andParts.every(andPart => { 1125 | if (!andPart) return false; // 空字串不納入比對 1126 | 1127 | // 使用 textIncludesKeyword 進行比對,支援數字轉換 1128 | return textIncludesKeyword(normalizedText, andPart); 1129 | }); 1130 | }); 1131 | }); 1132 | } 1133 | 1134 | // 監聽來自popup的訊息 1135 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 1136 | try { 1137 | const site = getCurrentSite(); 1138 | 1139 | // 處理拓元的消息 1140 | if (site === 'tixcraft') { 1141 | if (request.type === 'UPDATE_SETTINGS') { 1142 | settings = { ...settings, ...request.settings }; 1143 | filterTickets(); 1144 | showFilterStatus(); 1145 | sendResponse({ success: true }); 1146 | } else if (request.type === 'SHOW_ALL') { 1147 | settings.keywords = []; 1148 | settings.blacklist = []; 1149 | settings.hideSoldOut = false; 1150 | 1151 | // 通知 popup 更新標籤 1152 | chrome.runtime.sendMessage({ 1153 | type: 'UPDATE_POPUP_LABELS', 1154 | settings: { 1155 | keywords: [], 1156 | blacklist: [], 1157 | hideSoldOut: false 1158 | } 1159 | }); 1160 | 1161 | showAllAreas(); 1162 | showFilterStatus(); 1163 | sendResponse({ success: true }); 1164 | } 1165 | } 1166 | 1167 | // 處理KKTIX的消息 1168 | if (site === 'kktix') { 1169 | if (request.action === 'updateFilter') { 1170 | settings.keywords = request.keywords || []; 1171 | settings.blacklist = request.blacklist || []; // 確保更新黑名單 1172 | settings.showAllPrices = false; 1173 | if (request.settings && request.settings.hideSoldOut !== undefined) { 1174 | settings.hideSoldOut = request.settings.hideSoldOut; 1175 | } 1176 | 1177 | // 立即執行篩選 1178 | filterTickets(); 1179 | showFilterStatus(); 1180 | sendResponse({ success: true }); 1181 | return true; 1182 | } 1183 | if (request.action === 'showAllPrices') { 1184 | settings.keywords = []; 1185 | settings.blacklist = []; // 清空黑名單 1186 | settings.showAllPrices = true; 1187 | settings.hideSoldOut = false; 1188 | 1189 | filterTickets(); 1190 | showFilterStatus(); 1191 | sendResponse({ success: true }); 1192 | return true; 1193 | } 1194 | if (request.action === 'updateBlacklist') { // 新增:處理黑名單更新 1195 | settings.blacklist = request.blacklist || []; 1196 | filterTickets(); 1197 | showFilterStatus(); 1198 | sendResponse({ success: true }); 1199 | return true; 1200 | } 1201 | } 1202 | 1203 | // 處理ibon的消息 1204 | if (site === 'ibon') { 1205 | if (request.type === 'UPDATE_IBON_SETTINGS') { 1206 | console.log('收到ibon設定更新:', request.settings); 1207 | // 重置所有設定 1208 | settings = { 1209 | keywords: request.settings.keywords || [], 1210 | blacklist: request.settings.blacklist || [], 1211 | hideSoldOut: request.settings.hideSoldOut || false, 1212 | isProcessing: false 1213 | }; 1214 | 1215 | // 清除所有過濾狀態 1216 | showAllAreas(); 1217 | 1218 | // 更新顯示狀態 1219 | showFilterStatus(); 1220 | 1221 | // 如果有新的篩選條件,則重新應用 1222 | if (settings.keywords.length > 0 || settings.blacklist.length > 0 || settings.hideSoldOut) { 1223 | filterTickets(); 1224 | } 1225 | 1226 | sendResponse({ success: true }); 1227 | return true; 1228 | } 1229 | } 1230 | 1231 | // 處理cityline的消息 1232 | if (site === 'cityline') { 1233 | if (request.type === 'UPDATE_CITYLINE_SETTINGS') { 1234 | settings.keywords = request.settings.keywords || []; 1235 | settings.blacklist = request.settings.blacklist || []; // 添加黑名單設定 1236 | settings.hideSoldOut = request.settings.hideSoldOut || false; 1237 | filterTickets(); 1238 | showFilterStatus(); 1239 | sendResponse({ success: true }); 1240 | } 1241 | } 1242 | 1243 | // 處理年代售票網站的消息 1244 | if (site === 'ticket') { 1245 | if (request.type === 'UPDATE_TICKET_SETTINGS') { 1246 | settings.keywords = request.settings.keywords || []; 1247 | settings.hideSoldOut = request.settings.hideSoldOut || false; 1248 | filterTickets(); 1249 | showFilterStatus(); 1250 | sendResponse({ success: true }); 1251 | } 1252 | } 1253 | 1254 | // 處理遠大售票平台的消息 1255 | if (site === 'ticketplus') { 1256 | if (request.type === 'UPDATE_TICKETPLUS_SETTINGS') { 1257 | settings.keywords = request.settings.keywords || []; 1258 | settings.blacklist = request.settings.blacklist || []; 1259 | settings.hideSoldOut = request.settings.hideSoldOut || false; 1260 | filterTickets(); 1261 | showFilterStatus(); 1262 | sendResponse({ success: true }); 1263 | } 1264 | } 1265 | 1266 | // 在 ibon 票券頁面時觸發重整 1267 | if (site === 'ibon' && location.href.includes('UTK0201_000.aspx')) { 1268 | console.log("ibon票券頁面,自動重整頁面"); 1269 | setTimeout(() => location.reload(), 100); // 簡短延遲後重整 1270 | } 1271 | } catch (error) { 1272 | console.log('處理訊息時發生錯誤:', error); 1273 | sendResponse({ success: false, error: error.message }); 1274 | } 1275 | return true; 1276 | }); 1277 | 1278 | // 初始執行 1279 | const site = getCurrentSite(); 1280 | if (site === 'tixcraft' || site === 'kktix' || site === 'ibon' || site === 'cityline' || site === 'ticket' || site === 'ticketplus') { 1281 | if (document.readyState === 'complete') { 1282 | showFilterStatus(); 1283 | filterTickets(); 1284 | } else { 1285 | window.addEventListener('load', () => { 1286 | showFilterStatus(); 1287 | filterTickets(); 1288 | }); 1289 | } 1290 | } 1291 | 1292 | // 監聽DOM變化 1293 | let filterDebounceTimer = null; 1294 | let statusDebounceTimer = null; 1295 | const observer = new MutationObserver((mutations) => { 1296 | const site = getCurrentSite(); 1297 | if (!site) return; 1298 | 1299 | // 使用不同的計時器處理篩選和狀態顯示 1300 | if (filterDebounceTimer) { 1301 | clearTimeout(filterDebounceTimer); 1302 | } 1303 | if (statusDebounceTimer) { 1304 | clearTimeout(statusDebounceTimer); 1305 | } 1306 | 1307 | // 根據網站定義不同的延遲時間 1308 | const debounceTime = getDebounceTime(); 1309 | 1310 | // 篩選功能的防抖 1311 | filterDebounceTimer = setTimeout(() => { 1312 | if (site === 'tixcraft' && window.location.href.includes('/ticket/area/')) { 1313 | filterTickets(); 1314 | } else if (site === 'kktix' && document.querySelector('.ticket-unit')) { 1315 | filterTickets(); 1316 | } else if (site === 'ibon') { 1317 | filterTickets(); 1318 | } else if (site === 'cityline' && document.querySelector('.price-box1')) { 1319 | filterTickets(); 1320 | } else if (site === 'ticket') { 1321 | filterTickets(); 1322 | } else if (site === 'ticketplus') { 1323 | filterTickets(); 1324 | } 1325 | }, debounceTime); 1326 | 1327 | // 狀態顯示的防抖,使用相同的延遲時間 1328 | statusDebounceTimer = setTimeout(() => { 1329 | showFilterStatus(); 1330 | }, debounceTime); 1331 | }); 1332 | 1333 | // 使用更高效的觀察配置 1334 | observer.observe(document.body, { 1335 | childList: true, 1336 | subtree: true, 1337 | attributes: true, 1338 | attributeFilter: ['style', 'class', 'disabled'], 1339 | characterData: false 1340 | }); 1341 | 1342 | // 處理頁面從背景切回前景時的情況 1343 | document.addEventListener('visibilitychange', function() { 1344 | if (document.visibilityState === 'visible') { 1345 | loadSettings(); 1346 | } 1347 | }); 1348 | 1349 | // 顯示時間 1350 | function showServerTime() { 1351 | chrome.storage.local.get(['showServerTime', 'extensionEnabled'], function(result) { 1352 | if (result.showServerTime === false || result.extensionEnabled === false) { 1353 | const existingDisplay = document.querySelector('.server-time-display'); 1354 | if (existingDisplay) { 1355 | existingDisplay.remove(); 1356 | } 1357 | 1358 | // 時間顯示框被移除,更新篩選條件框位置 1359 | const notification = document.querySelector('.ticket-filter-notification'); 1360 | if (notification) { 1361 | notification.style.top = '10px'; 1362 | } 1363 | 1364 | return; 1365 | } 1366 | 1367 | let timeDisplay = document.querySelector('.server-time-display'); 1368 | if (!timeDisplay) { 1369 | timeDisplay = document.createElement('div'); 1370 | timeDisplay.className = 'server-time-display'; 1371 | document.body.appendChild(timeDisplay); 1372 | 1373 | // 時間顯示框添加後,更新篩選條件框位置 1374 | const notification = document.querySelector('.ticket-filter-notification'); 1375 | if (notification) { 1376 | notification.style.top = '50px'; 1377 | } 1378 | } 1379 | 1380 | // 定時更新顯示本地時間(CST) 1381 | function updateDisplay() { 1382 | const currentTime = new Date(); 1383 | timeDisplay.textContent = `本地時間:${currentTime.toLocaleString('zh-TW', { 1384 | timeZone: 'Asia/Taipei', 1385 | hour12: false, 1386 | hour: '2-digit', 1387 | minute: '2-digit', 1388 | second: '2-digit' 1389 | })}`; 1390 | } 1391 | 1392 | // 每秒更新一次時間 1393 | const displayInterval = setInterval(updateDisplay, 1000); 1394 | 1395 | // 立即顯示時間 1396 | updateDisplay(); 1397 | 1398 | // 清理函數 1399 | function cleanup() { 1400 | clearInterval(displayInterval); 1401 | } 1402 | 1403 | // 監聽元素移除 1404 | const observer = new MutationObserver((mutations) => { 1405 | if (!document.contains(timeDisplay)) { 1406 | cleanup(); 1407 | observer.disconnect(); 1408 | } 1409 | }); 1410 | 1411 | observer.observe(document.body, { 1412 | childList: true, 1413 | subtree: true 1414 | }); 1415 | }); 1416 | } 1417 | 1418 | // 監聽設置變化 1419 | chrome.storage.onChanged.addListener(function(changes, namespace) { 1420 | if (changes.showServerTime) { 1421 | if (changes.showServerTime.newValue === false) { 1422 | const existingDisplay = document.querySelector('.server-time-display'); 1423 | if (existingDisplay) { 1424 | existingDisplay.remove(); 1425 | } 1426 | } else { 1427 | showServerTime(); 1428 | } 1429 | // 重新顯示篩選狀態,以更新位置 1430 | showFilterStatus(); 1431 | } 1432 | 1433 | // 當篩選條件顯示設置改變時,重新顯示篩選狀態 1434 | if (changes.showFilterStatus !== undefined) { 1435 | if (changes.showFilterStatus.newValue === false) { 1436 | // 如果設置為不顯示,移除現有的篩選條件顯示 1437 | const existingNotification = document.querySelector('.ticket-filter-notification'); 1438 | if (existingNotification) { 1439 | existingNotification.remove(); 1440 | } 1441 | } else { 1442 | // 如果設置為顯示,重新顯示篩選條件 1443 | showFilterStatus(); 1444 | } 1445 | } 1446 | 1447 | // 監聽擴展啟用狀態的變化 1448 | if (changes.extensionEnabled !== undefined) { 1449 | const site = getCurrentSite(); 1450 | if (!site) return; 1451 | 1452 | if (changes.extensionEnabled.newValue === false) { 1453 | // 擴展被停用 1454 | // 1. 移除所有顯示 1455 | const notification = document.querySelector('.ticket-filter-notification'); 1456 | const timeDisplay = document.querySelector('.server-time-display'); 1457 | if (notification) notification.remove(); 1458 | if (timeDisplay) timeDisplay.remove(); 1459 | 1460 | // 2. 重置所有設定 1461 | settings.keywords = []; 1462 | settings.blacklist = []; // 確保黑名單也被清空 1463 | settings.hideSoldOut = false; 1464 | settings.showAllPrices = true; 1465 | settings.isProcessing = false; 1466 | 1467 | // 3. 強制顯示所有區域 1468 | showAllAreas(); 1469 | 1470 | // 4. 對於 ibon 網站,額外處理 1471 | if (site === 'ibon') { 1472 | // 移除所有由擴充功能添加的樣式 1473 | function removeStyles(element) { 1474 | if (element.shadowRoot) { 1475 | const styles = element.shadowRoot.querySelectorAll('style.extension-filter-style'); 1476 | styles.forEach(style => style.remove()); 1477 | 1478 | // 清除所有票券的隱藏狀態 1479 | const rows = element.shadowRoot.querySelectorAll('tr[id^="B"]'); 1480 | rows.forEach(row => { 1481 | row.style.removeProperty('display'); 1482 | row.classList.remove('hidden-by-extension'); 1483 | Array.from(row.children).forEach(cell => { 1484 | cell.style.removeProperty('display'); 1485 | }); 1486 | }); 1487 | } 1488 | Array.from(element.children || []).forEach(removeStyles); 1489 | } 1490 | removeStyles(document.documentElement); 1491 | } 1492 | } else { 1493 | // 擴展被啟用,重新初始化所有功能 1494 | loadSettings(); 1495 | showServerTime(); 1496 | showFilterStatus(); 1497 | filterTickets(); 1498 | } 1499 | } 1500 | }); 1501 | 1502 | // 在頁面加載完成後顯示時間(只保留这一个初始化点) 1503 | if (document.readyState === 'complete') { 1504 | showServerTime(); 1505 | } else { 1506 | window.addEventListener('load', showServerTime); 1507 | } 1508 | 1509 | // 设置页面加载策略 1510 | document.documentElement.setAttribute('pageLoadStrategy', 'eager'); 1511 | 1512 | // 在頁面載入和切換時重新載入設定 1513 | document.addEventListener('DOMContentLoaded', () => { 1514 | const site = getCurrentSite(); 1515 | if (site === 'ibon') { 1516 | chrome.storage.local.get(['ibonKeywords', 'ibonBlacklist', 'ibonHideSoldOut'], (result) => { 1517 | settings.keywords = result.ibonKeywords || []; 1518 | settings.blacklist = result.ibonBlacklist || []; 1519 | settings.hideSoldOut = result.ibonHideSoldOut || false; 1520 | filterTickets(); 1521 | showFilterStatus(); 1522 | }); 1523 | } 1524 | }); 1525 | 1526 | // 根據網站定義不同的延遲時間 1527 | function getDebounceTime() { 1528 | const site = getCurrentSite(); 1529 | switch(site) { 1530 | case 'ibon': 1531 | return 250; // ibon 網站需要較長的延遲 1532 | case 'ticketplus': 1533 | return 50; // 遠大售票也需要稍長延遲 1534 | default: 1535 | return 50; // 其他網站使用短延遲 1536 | } 1537 | } -------------------------------------------------------------------------------- /earlyLoader.js: -------------------------------------------------------------------------------- 1 | // 在页面加载的最早阶段执行 2 | (function() { 3 | // 判断当前网站类型 4 | const isIbon = window.location.hostname.includes('ibon.com.tw'); 5 | const isTixcraft = window.location.href.includes('tixcraft.com/ticket/area/'); 6 | const isKktix = window.location.hostname.includes('kktix.com'); 7 | const isCityline = window.location.hostname.includes('cityline.com'); 8 | const isEra = window.location.hostname.includes('ticket.com.tw'); 9 | 10 | // 仅在适用的网站上应用筛选 11 | if (!isIbon && !isTixcraft && !isKktix && !isCityline && !isEra) return; 12 | 13 | // 特別處理 ibon 網站 14 | if (isIbon) { 15 | const styleElement = document.createElement('style'); 16 | styleElement.id = 'ticket-filter-early-style-ibon'; 17 | styleElement.textContent = ` 18 | /* ibon 網站 - 使用不透明度和顯示過渡效果 */ 19 | body { 20 | opacity: 0.01 !important; 21 | transition: opacity 0.3s ease-out; 22 | } 23 | body.filter-ready { 24 | opacity: 1 !important; 25 | } 26 | `; 27 | document.documentElement.appendChild(styleElement); 28 | 29 | // 監聽過濾完成的消息 30 | window.addEventListener('message', function(event) { 31 | if (event.data.type === 'FILTER_APPLIED') { 32 | document.body.classList.add('filter-ready'); 33 | } 34 | }); 35 | 36 | // 安全機制:確保不會永久隱藏 37 | setTimeout(() => { 38 | document.body.classList.add('filter-ready'); 39 | }, 800); 40 | 41 | return; // 對 ibon 網站使用專門的處理,不執行後續代碼 42 | } 43 | 44 | // 为不同网站使用不同的隐藏方式 45 | if (isTixcraft) { 46 | // 拓元网站 - 使用visibility隐藏 47 | const styleElement = document.createElement('style'); 48 | styleElement.id = 'ticket-filter-early-style'; 49 | styleElement.textContent = ` 50 | /* 拓元网站 */ 51 | .area-list li, 52 | .zone-label[data-id] { 53 | visibility: hidden !important; 54 | } 55 | `; 56 | document.documentElement.appendChild(styleElement); 57 | } else if (isKktix) { 58 | // KKTIX网站 - 使用较轻量的透明度处理,避免页面空白 59 | const styleElement = document.createElement('style'); 60 | styleElement.id = 'ticket-filter-early-style-kktix'; 61 | styleElement.textContent = ` 62 | /* KKTIX网站 - 只减少不透明度而不是完全隐藏 */ 63 | .ticket-unit { 64 | opacity: 0.01; 65 | transition: opacity 0.3s ease-in; 66 | } 67 | `; 68 | document.documentElement.appendChild(styleElement); 69 | 70 | // 设置一个较短的超时,确保KKTIX可以快速显示 71 | setTimeout(() => { 72 | // 如果筛选还没完成,先显示所有元素 73 | if (!window.ticketFilterComplete) { 74 | document.querySelectorAll('.ticket-unit').forEach(el => { 75 | el.style.opacity = '1'; 76 | }); 77 | } 78 | }, 800); // 800ms后如果筛选未完成就显示 79 | } else if (isCityline) { 80 | // Cityline网站 - 使用更快速的处理方式 81 | const styleElement = document.createElement('style'); 82 | styleElement.id = 'ticket-filter-early-style-cityline'; 83 | styleElement.textContent = ` 84 | /* Cityline网站 - 使用更高效的隐藏方式并添加过渡效果 */ 85 | .price-box1, .form-check { 86 | visibility: hidden !important; 87 | opacity: 0 !important; 88 | transition: opacity 0.15s ease-out !important; 89 | } 90 | 91 | /* 隐藏票价区域,确保用户在筛选完成前看不到 */ 92 | .price-box1 > div.price { 93 | visibility: hidden !important; 94 | } 95 | 96 | /* 应用更快的过渡效果,在筛选完成后显示 */ 97 | .price-box1.filter-ready, .form-check.filter-ready { 98 | visibility: visible !important; 99 | opacity: 1 !important; 100 | transition: opacity 0.15s ease-out !important; 101 | } 102 | `; 103 | document.documentElement.appendChild(styleElement); 104 | 105 | // 创建一个变量记录筛选是否已经开始 106 | window.citylineFilterStarted = false; 107 | 108 | // 通知 content.js 脚本立即开始筛选,不要等待 109 | setTimeout(() => { 110 | window.postMessage({ type: 'CITYLINE_READY_FOR_FILTER' }, '*'); 111 | }, 10); 112 | 113 | // 立即运行 MutationObserver 以捕获任何动态添加的票券元素 114 | const citylineObserver = new MutationObserver((mutations) => { 115 | // 如果筛选已经完成,不进行处理 116 | if (window.ticketFilterComplete) return; 117 | 118 | // 筛选已经开始但尚未完成 119 | if (window.citylineFilterStarted) { 120 | let ticketElementsFound = false; 121 | 122 | // 仅处理新添加的节点 123 | for (const mutation of mutations) { 124 | if (mutation.addedNodes && mutation.addedNodes.length) { 125 | for (const node of mutation.addedNodes) { 126 | if (node.nodeType === 1) { // 元素节点 127 | const ticketElements = node.querySelectorAll ? 128 | node.querySelectorAll('.price-box1, .form-check') : []; 129 | 130 | if (ticketElements.length > 0) { 131 | ticketElementsFound = true; 132 | // 这里不做任何处理,保持其隐藏状态 133 | } 134 | 135 | // 如果节点本身就是票券元素 136 | if (node.matches && (node.matches('.price-box1') || node.matches('.form-check'))) { 137 | ticketElementsFound = true; 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | // 如果找到票券元素,通知 content.js 有新的票券要处理 145 | if (ticketElementsFound) { 146 | window.postMessage({ type: 'NEW_TICKETS_FOUND' }, '*'); 147 | } 148 | } else { 149 | // 筛选尚未开始,但看到了票券元素,通知 content.js 立即开始筛选 150 | const ticketElements = document.querySelectorAll('.price-box1, .form-check'); 151 | if (ticketElements.length > 0) { 152 | window.citylineFilterStarted = true; 153 | window.postMessage({ type: 'CITYLINE_READY_FOR_FILTER' }, '*'); 154 | } 155 | } 156 | }); 157 | 158 | // 使用更高效的配置,只观察必要的变化 159 | citylineObserver.observe(document.body, { 160 | childList: true, 161 | subtree: true, 162 | attributes: false, 163 | characterData: false 164 | }); 165 | 166 | // 设置一个较短的超时,确保Cityline可以更快地显示 167 | setTimeout(() => { 168 | // 如果筛选还没完成,但处理已经开始,给予更多时间 169 | if (window.citylineFilterStarted && !window.ticketFilterComplete) { 170 | // 再等待一段时间,因为筛选已经开始 171 | return; 172 | } 173 | 174 | // 如果筛选还没完成且未开始,为避免永久隐藏,显示所有元素 175 | if (!window.ticketFilterComplete) { 176 | document.querySelectorAll('.price-box1, .form-check').forEach(el => { 177 | el.classList.add('filter-ready'); 178 | }); 179 | } 180 | }, 800); // 800ms后检查状态 181 | 182 | // 设置最终超时作为安全机制 183 | setTimeout(() => { 184 | if (!window.ticketFilterComplete) { 185 | document.querySelectorAll('.price-box1, .form-check').forEach(el => { 186 | el.classList.add('filter-ready'); 187 | }); 188 | // 断开观察者以提高性能 189 | citylineObserver.disconnect(); 190 | } 191 | }, 1500); // 1.5秒后如果筛选未完成就显示 192 | } else if (isEra) { 193 | // 年代售票网站 - 使用visibility隐藏 194 | const styleElement = document.createElement('style'); 195 | styleElement.id = 'ticket-filter-early-style-era'; 196 | styleElement.textContent = ` 197 | /* 年代售票网站 */ 198 | .area-list li { 199 | visibility: hidden !important; 200 | } 201 | .area-list li.filter-ready { 202 | visibility: visible !important; 203 | transition: opacity 0.2s ease-out; 204 | } 205 | `; 206 | document.documentElement.appendChild(styleElement); 207 | } 208 | 209 | // 记录是否已收到筛选完成消息 210 | let filterApplied = false; 211 | 212 | // Shadow DOM处理相关变量 213 | let shadowObserver = null; 214 | const shadowStyleCache = new Set(); // 缓存所有添加的style元素 215 | 216 | // 处理Shadow DOM的函数 - 为ibon网站 217 | function handleIbonShadowDOM() { 218 | // 尝试向shadowRoot中注入样式的函数 219 | function injectStyleToShadowRoot(shadowRoot) { 220 | if (!shadowRoot) return; 221 | 222 | // 避免重复注入 223 | if (shadowRoot.querySelector('#ticket-filter-shadow-style')) return; 224 | 225 | try { 226 | const shadowStyle = document.createElement('style'); 227 | shadowStyle.id = 'ticket-filter-shadow-style'; 228 | shadowStyle.textContent = ` 229 | /* ibon网站 - 仅在筛选未完成前隐藏 */ 230 | tr[id^="B"] { 231 | opacity: 0; 232 | transition: opacity 0.2s; 233 | } 234 | `; 235 | shadowRoot.appendChild(shadowStyle); 236 | shadowStyleCache.add(shadowRoot); // 记录已注入样式的shadowRoot 237 | } catch (err) { 238 | // 静默错误 239 | } 240 | } 241 | 242 | // 递归处理所有可能包含shadowRoot的元素 243 | function checkShadowRoots(element) { 244 | if (!element) return; 245 | 246 | // 处理当前元素的shadowRoot 247 | if (element.shadowRoot) { 248 | injectStyleToShadowRoot(element.shadowRoot); 249 | } 250 | 251 | // 递归处理子元素 252 | const children = element.children; 253 | if (children && children.length) { 254 | for (let i = 0; i < children.length; i++) { 255 | checkShadowRoots(children[i]); 256 | } 257 | } 258 | } 259 | 260 | // 初始处理 261 | checkShadowRoots(document); 262 | 263 | // 监听DOM变化以处理动态添加的元素 264 | shadowObserver = new MutationObserver((mutations) => { 265 | if (filterApplied) { 266 | shadowObserver.disconnect(); 267 | return; 268 | } 269 | 270 | for (const mutation of mutations) { 271 | if (mutation.addedNodes && mutation.addedNodes.length) { 272 | for (const node of mutation.addedNodes) { 273 | if (node.nodeType === 1) { // 元素节点 274 | checkShadowRoots(node); 275 | } 276 | } 277 | } 278 | } 279 | }); 280 | 281 | shadowObserver.observe(document.documentElement, { 282 | childList: true, 283 | subtree: true 284 | }); 285 | } 286 | 287 | // 显示所有元素的函数 288 | function showAllElements() { 289 | filterApplied = true; 290 | // 告诉window对象筛选已完成 291 | window.ticketFilterComplete = true; 292 | 293 | // 针对不同网站处理样式 294 | if (isTixcraft) { 295 | const earlyStyle = document.getElementById('ticket-filter-early-style'); 296 | if (earlyStyle) { 297 | earlyStyle.remove(); 298 | } 299 | } else if (isKktix) { 300 | // 对于KKTIX,修改样式使元素显示出来,而不是移除 301 | const kktixStyle = document.getElementById('ticket-filter-early-style-kktix'); 302 | if (kktixStyle) { 303 | kktixStyle.textContent = ` 304 | .ticket-unit { 305 | opacity: 1 !important; 306 | transition: opacity 0.2s ease-out; 307 | } 308 | `; 309 | } 310 | 311 | // 直接修改所有票券元素的样式 312 | document.querySelectorAll('.ticket-unit').forEach(el => { 313 | el.style.opacity = '1'; 314 | }); 315 | } else if (isCityline) { 316 | // 对于Cityline,使用class切换来显示元素 317 | document.querySelectorAll('.price-box1, .form-check').forEach(el => { 318 | el.classList.add('filter-ready'); 319 | }); 320 | 321 | // 确保所有需要显示的元素都被正确显示(使用更短的延迟) 322 | setTimeout(() => { 323 | // 获取当前所有应该显示的元素(不隐藏的) 324 | document.querySelectorAll('.form-check').forEach(el => { 325 | if (el.style.display !== 'none') { 326 | el.style.visibility = 'visible'; 327 | el.style.opacity = '1'; 328 | } 329 | }); 330 | }, 10); // 更短的延迟确保 DOM 快速更新 331 | } else if (isEra) { 332 | // 对于年代售票,使用class切换来显示元素 333 | document.querySelectorAll('.area-list li').forEach(el => { 334 | el.classList.add('filter-ready'); 335 | }); 336 | } 337 | 338 | // 处理ibon的Shadow DOM 339 | if (isIbon) { 340 | // 停止观察者 341 | if (shadowObserver) { 342 | shadowObserver.disconnect(); 343 | } 344 | 345 | // 从Shadow DOM中移除样式,或者将所有tr设为可见 346 | shadowStyleCache.forEach(shadowRoot => { 347 | try { 348 | const style = shadowRoot.querySelector('#ticket-filter-shadow-style'); 349 | if (style) { 350 | // 修改样式使元素显示,而不是直接移除 351 | style.textContent = ` 352 | tr[id^="B"] { 353 | opacity: 1 !important; 354 | transition: opacity 0.2s; 355 | } 356 | `; 357 | } 358 | } catch (err) { 359 | // 静默错误 360 | } 361 | }); 362 | 363 | // 额外措施:通过contentWindow直接执行脚本使票券可见 364 | try { 365 | const showAllRows = ` 366 | function makeAllRowsVisible(root) { 367 | if (root.shadowRoot) { 368 | const rows = root.shadowRoot.querySelectorAll('tr[id^="B"]'); 369 | rows.forEach(row => { 370 | row.style.removeProperty('display'); 371 | row.style.opacity = '1'; 372 | row.style.visibility = 'visible'; 373 | }); 374 | } 375 | 376 | if (root.children) { 377 | Array.from(root.children).forEach(child => { 378 | makeAllRowsVisible(child); 379 | }); 380 | } 381 | } 382 | 383 | makeAllRowsVisible(document.documentElement); 384 | `; 385 | 386 | const scriptTag = document.createElement('script'); 387 | scriptTag.textContent = showAllRows; 388 | document.documentElement.appendChild(scriptTag); 389 | document.documentElement.removeChild(scriptTag); 390 | } catch (e) { 391 | // 静默错误 392 | } 393 | } 394 | } 395 | 396 | // 如果是ibon网站,启用Shadow DOM处理 397 | if (isIbon) { 398 | // 延迟一点点再执行,等待iframe和shadowDOM创建 399 | setTimeout(() => { 400 | handleIbonShadowDOM(); 401 | }, 10); 402 | } 403 | 404 | // 监听content.js加载完成的消息 405 | window.addEventListener('message', function(event) { 406 | // 筛选应用完成 407 | if (event.data && event.data.type === 'FILTER_APPLIED') { 408 | showAllElements(); 409 | } 410 | 411 | // content.js已加载但尚未完成筛选,延长超时时间 412 | if (event.data && event.data.type === 'CONTENT_JS_LOADED' && !filterApplied) { 413 | // Cityline网站的特殊处理,主动通知可以开始筛选 414 | if (isCityline) { 415 | setTimeout(() => { 416 | window.postMessage({ type: 'CITYLINE_READY_FOR_FILTER' }, '*'); 417 | }, 10); 418 | } 419 | 420 | // 设置更长的超时时间 421 | setTimeout(() => { 422 | if (!filterApplied) { // 如果还没有应用过筛选 423 | showAllElements(); 424 | } 425 | }, 2000); // 2秒后如果筛选未完成就显示 426 | } 427 | }); 428 | 429 | // 设置安全超时,防止永久隐藏 430 | setTimeout(() => { 431 | if (!filterApplied) { 432 | showAllElements(); 433 | } 434 | }, 1500); // 更短的超时时间,确保元素不会长时间隐藏 435 | })(); -------------------------------------------------------------------------------- /images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder220224/Ticket-Filter/40b0b02eaa8bc42f7ed5218640143233015e6d24/images/icon128.png -------------------------------------------------------------------------------- /images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder220224/Ticket-Filter/40b0b02eaa8bc42f7ed5218640143233015e6d24/images/icon16.png -------------------------------------------------------------------------------- /images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder220224/Ticket-Filter/40b0b02eaa8bc42f7ed5218640143233015e6d24/images/icon48.png -------------------------------------------------------------------------------- /injected.js: -------------------------------------------------------------------------------- 1 | // 立即執行的初始化函數 2 | (function() { 3 | // 保存原始方法 4 | Element.prototype._attachShadow = Element.prototype.attachShadow; 5 | 6 | // 重寫 attachShadow 方法 7 | Element.prototype.attachShadow = function () { 8 | try { 9 | return this._attachShadow({ mode: "open" }); 10 | } catch (e) { 11 | console.warn('Shadow root 轉換失敗:', e); 12 | return this._attachShadow({ mode: "closed" }); 13 | } 14 | }; 15 | 16 | // 嘗試轉換已存在的 shadow roots 17 | function convertExistingShadowRoots() { 18 | try { 19 | const elements = document.getElementsByTagName('*'); 20 | for (let i = 0; i < elements.length; i++) { 21 | const element = elements[i]; 22 | if (element.shadowRoot === null) { 23 | const shadowRootAttr = element.getAttribute('shadowroot'); 24 | if (shadowRootAttr === 'closed') { 25 | try { 26 | element._attachShadow({ mode: 'open' }); 27 | } catch (e) { 28 | // 忽略錯誤,繼續處理下一個元素 29 | } 30 | } 31 | } 32 | } 33 | } catch (e) { 34 | console.warn('轉換現有 shadow roots 時發生錯誤:', e); 35 | } 36 | } 37 | 38 | // 創建一個 MutationObserver 來監視 DOM 變化 39 | const observer = new MutationObserver((mutations) => { 40 | convertExistingShadowRoots(); 41 | }); 42 | 43 | // 開始監視整個文檔 44 | observer.observe(document.documentElement || document.body || document, { 45 | childList: true, 46 | subtree: true, 47 | attributes: true, 48 | attributeFilter: ['shadowroot'] 49 | }); 50 | 51 | // 立即執行一次轉換 52 | convertExistingShadowRoots(); 53 | 54 | // 在各種可能的時機點執行轉換 55 | ['DOMContentLoaded', 'load', 'pageshow'].forEach(event => { 56 | window.addEventListener(event, convertExistingShadowRoots, true); 57 | }); 58 | 59 | // 監聽頁面可見性變化 60 | document.addEventListener('visibilitychange', () => { 61 | if (document.visibilityState === 'visible') { 62 | convertExistingShadowRoots(); 63 | } 64 | }); 65 | 66 | // 監聽 history 變化 67 | let lastUrl = location.href; 68 | new MutationObserver(() => { 69 | const url = location.href; 70 | if (url !== lastUrl) { 71 | lastUrl = url; 72 | convertExistingShadowRoots(); 73 | } 74 | }).observe(document, {subtree: true, childList: true}); 75 | 76 | // 定期檢查(較短間隔) 77 | setInterval(convertExistingShadowRoots, 100); 78 | })(); -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "搶票柴柴-售票網站篩選器", 4 | "version": "1.3.1", 5 | "description": "在售票網站上快速篩選您想要的區域票券", 6 | "permissions": [ 7 | "storage", 8 | "activeTab", 9 | "scripting", 10 | "webRequest", 11 | "webRequestBlocking" 12 | ], 13 | "host_permissions": [ 14 | "*://*.tixcraft.com/*", 15 | "*://*.kktix.com/*", 16 | "*://kktix.com/*", 17 | "*://*.kktix.cc/*", 18 | "*://*.ibon.com.tw/*", 19 | "*://ticket.ibon.com.tw/*", 20 | "*://orders.ibon.com.tw/*", 21 | "*://*.cityline.com/*", 22 | "*://www.cityline.com/*", 23 | "*://*.ticket.com.tw/*", 24 | "*://*.ticketplus.com.tw/*" 25 | ], 26 | "content_scripts": [ 27 | { 28 | "matches": [ 29 | "*://*.tixcraft.com/*", 30 | "*://*.kktix.com/*", 31 | "*://*.kktix.cc/*", 32 | "*://*.ibon.com.tw/*", 33 | "*://ticket.ibon.com.tw/*", 34 | "*://orders.ibon.com.tw/*", 35 | "*://*.cityline.com/*", 36 | "*://www.cityline.com/*", 37 | "*://*.ticket.com.tw/*", 38 | "*://*.ticketplus.com.tw/*" 39 | ], 40 | "js": ["content.js"] 41 | }, 42 | { 43 | "matches": [ 44 | "*://*.ibon.com.tw/*", 45 | "*://ticket.ibon.com.tw/*", 46 | "*://orders.ibon.com.tw/*" 47 | ], 48 | "js": ["shadowInject.js"], 49 | "run_at": "document_start", 50 | "all_frames": true, 51 | "world": "MAIN" 52 | }, 53 | { 54 | "matches": [ 55 | "*://*.tixcraft.com/ticket/area/*", 56 | "*://*.kktix.com/*", 57 | "*://*.kktix.cc/*", 58 | "*://*.ibon.com.tw/*", 59 | "*://*.cityline.com/*", 60 | "*://*.ticket.com.tw/*", 61 | "*://*.ticketplus.com.tw/*" 62 | ], 63 | "js": ["earlyLoader.js"], 64 | "run_at": "document_start", 65 | "all_frames": true 66 | }, 67 | { 68 | "matches": ["*://*.ticket.com.tw/*"], 69 | "js": ["ticketLoader.js"], 70 | "run_at": "document_start", 71 | "all_frames": true 72 | } 73 | ], 74 | "background": { 75 | "service_worker": "background.js", 76 | "type": "module" 77 | }, 78 | "web_accessible_resources": [{ 79 | "resources": ["injected.js", "styles.css"], 80 | "matches": [ 81 | "*://orders.ibon.com.tw/*", 82 | "*://*.tixcraft.com/*", 83 | "*://*.kktix.com/*", 84 | "*://*.kktix.cc/*", 85 | "*://*.ibon.com.tw/*", 86 | "*://*.cityline.com/*", 87 | "*://*.ticket.com.tw/*", 88 | "*://*.ticketplus.com.tw/*" 89 | ] 90 | }], 91 | "action": { 92 | "default_popup": "popup.html", 93 | "default_icon": { 94 | "16": "images/icon16.png", 95 | "48": "images/icon48.png", 96 | "128": "images/icon128.png" 97 | } 98 | }, 99 | "icons": { 100 | "16": "images/icon16.png", 101 | "48": "images/icon48.png", 102 | "128": "images/icon128.png" 103 | }, 104 | "content_security_policy": { 105 | "extension_pages": "script-src 'self'; object-src 'self'", 106 | "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';" 107 | } 108 | } -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 搶票柴柴-售票網站篩選器 6 | 454 | 455 | 456 |
457 | 458 |
459 |
460 |
461 |
462 | 463 | 464 |
465 |
466 |
467 | 票券图标搶票柴柴-售票網站篩選器 468 | 469 | 470 | 471 |
472 |
473 |
474 | 啟用 475 | 479 |
480 |
481 |
482 | 483 | 484 |
485 |
486 | 487 | 488 |
489 |
490 | 491 | 492 |
493 |
494 |
495 |
✅關鍵字篩選
496 |
497 | 498 | 499 |
500 | 例如:VIP、3200,A區、3200+4500(按Enter新增) 501 |
502 |
503 | 506 |
507 | 尚未設定篩選條件 508 |
509 |
510 |
511 |
⛔️黑名單過濾
512 |
513 | 514 | 515 |
516 | 例如:VIP、3200,A區、3200+4500(按Enter新增) 517 | 520 |
521 | 尚未設定過濾條件 522 |
523 |
524 |
525 |
526 | 527 | 528 |
529 |
530 |
531 | 532 |
533 | 542 |
543 |
544 | 545 | 546 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | const areaFilter = document.getElementById('areaFilter'); 3 | const blacklistFilter = document.getElementById('blacklistFilter'); 4 | const keywordsContainer = document.getElementById('keywordsContainer'); 5 | const blacklistContainer = document.getElementById('blacklistContainer'); 6 | const showSoldOut = document.getElementById('showSoldOut'); 7 | const showAll = document.getElementById('showAll'); 8 | const addKeyword = document.getElementById('addKeyword'); 9 | const addBlacklist = document.getElementById('addBlacklist'); 10 | const currentFilter = document.getElementById('currentFilter'); 11 | const filterText = document.getElementById('filterText'); 12 | const showServerTime = document.getElementById('showServerTime'); 13 | const showFilterStatus = document.getElementById('showFilterStatus'); 14 | const showHelpText = document.getElementById('showHelpText'); 15 | const extensionSwitch = document.getElementById('extensionEnabled'); 16 | const settingsIcon = document.querySelector('.settings-icon'); 17 | const helpTexts = document.querySelectorAll('.help-text'); 18 | 19 | // 控制所有設定項目的啟用/禁用狀態 20 | function toggleControls(enabled) { 21 | const controls = [ 22 | areaFilter, 23 | showSoldOut, 24 | showAll, 25 | addKeyword, 26 | showServerTime, 27 | showFilterStatus, 28 | showHelpText, 29 | settingsIcon 30 | ]; 31 | 32 | controls.forEach(control => { 33 | if (control) { 34 | control.disabled = !enabled; 35 | if (control === settingsIcon) { 36 | control.style.opacity = enabled ? '1' : '0.5'; 37 | control.style.pointerEvents = enabled ? 'auto' : 'none'; 38 | } 39 | } 40 | }); 41 | 42 | // 設定關鍵字容器的樣式 43 | if (keywordsContainer) { 44 | keywordsContainer.style.opacity = enabled ? '1' : '0.5'; 45 | keywordsContainer.style.pointerEvents = enabled ? 'auto' : 'none'; 46 | } 47 | 48 | // 設定整個內容區域的樣式 49 | const contentArea = document.querySelector('.content'); 50 | if (contentArea) { 51 | const sections = contentArea.querySelectorAll('.filter-section, .time-toggle, .settings-panel'); 52 | sections.forEach(section => { 53 | section.style.opacity = enabled ? '1' : '0.5'; 54 | section.style.pointerEvents = enabled ? 'auto' : 'none'; 55 | }); 56 | } 57 | } 58 | 59 | // 載入擴充功能狀態並設定控制項狀態 60 | chrome.storage.local.get(['extensionEnabled'], function(result) { 61 | extensionSwitch.checked = result.extensionEnabled !== false; 62 | toggleControls(result.extensionEnabled !== false); 63 | }); 64 | 65 | // 監聽主開關變化 66 | extensionSwitch.addEventListener('change', function(e) { 67 | const enabled = e.target.checked; 68 | chrome.storage.local.set({ extensionEnabled: enabled }); 69 | toggleControls(enabled); 70 | }); 71 | 72 | // 載入提示訊息顯示設定 73 | chrome.storage.local.get(['showHelpText'], function(result) { 74 | showHelpText.checked = result.showHelpText !== false; 75 | toggleHelpText(result.showHelpText !== false); 76 | }); 77 | 78 | // 監聽提示訊息顯示設定變化 79 | showHelpText.addEventListener('change', function(e) { 80 | const show = e.target.checked; 81 | chrome.storage.local.set({ showHelpText: show }); 82 | toggleHelpText(show); 83 | }); 84 | 85 | // 切換提示訊息顯示狀態 86 | function toggleHelpText(show) { 87 | helpTexts.forEach(text => { 88 | text.style.display = show ? '' : 'none'; 89 | }); 90 | } 91 | 92 | // 添加搜尋說明 93 | const helpText = document.createElement('div'); 94 | helpText.className = 'help-text'; 95 | helpText.innerHTML = ` 96 |

97 | 搜尋格式:3200,A區 (同時符合) 或 4500+3200 (任一符合) 98 |

99 | `; 100 | 101 | // 將說明插入到輸入框上方 102 | const exampleText = areaFilter.previousElementSibling; 103 | if (exampleText) { 104 | exampleText.parentNode.insertBefore(helpText, exampleText.nextSibling); 105 | } 106 | 107 | let keywords = new Set(); 108 | let blacklist = new Set(); 109 | let currentSite = null; 110 | 111 | // 檢查當前網站類型 112 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 113 | if (tabs[0]) { 114 | const url = tabs[0].url; 115 | if (url.includes('tixcraft.com')) { 116 | currentSite = 'tixcraft'; 117 | loadTixcraftSettings(); 118 | document.body.setAttribute('data-site', 'tixcraft'); 119 | } else if (url.includes('kktix.com')) { 120 | currentSite = 'kktix'; 121 | loadKKTIXSettings(); 122 | document.body.setAttribute('data-site', 'kktix'); 123 | } else if (url.includes('ibon.com.tw')) { 124 | currentSite = 'ibon'; 125 | loadIbonSettings(); 126 | document.body.setAttribute('data-site', 'ibon'); 127 | } else if (url.includes('cityline.com')) { 128 | currentSite = 'cityline'; 129 | loadCitylineSettings(); 130 | document.body.setAttribute('data-site', 'cityline'); 131 | } else if (url.includes('ticket.com.tw')) { 132 | currentSite = 'ticket'; 133 | loadTicketSettings(); 134 | document.body.setAttribute('data-site', 'ticket'); 135 | } else if (url.includes('ticketplus.com.tw')) { 136 | currentSite = 'ticketplus'; 137 | loadTicketPlusSettings(); 138 | document.body.setAttribute('data-site', 'ticketplus'); 139 | } 140 | } 141 | }); 142 | 143 | // 載入拓元設定 144 | function loadTixcraftSettings() { 145 | chrome.storage.local.get(['keywords', 'blacklist', 'hideSoldOut'], (result) => { 146 | if (result.keywords) { 147 | keywords = new Set(result.keywords); 148 | renderKeywords(); 149 | updateFilterLabel(); 150 | } 151 | if (result.blacklist) { 152 | blacklist = new Set(result.blacklist); 153 | renderBlacklist(); 154 | } 155 | if (result.hideSoldOut !== undefined) { 156 | showSoldOut.checked = result.hideSoldOut; 157 | } 158 | }); 159 | } 160 | 161 | // 載入KKTIX設定 162 | function loadKKTIXSettings() { 163 | chrome.storage.sync.get(['targetKeywords', 'blacklist', 'hideSoldOut'], (result) => { 164 | if (result.targetKeywords) { 165 | keywords = new Set(result.targetKeywords); 166 | renderKeywords(); 167 | updateFilterLabel(); 168 | } 169 | if (result.blacklist) { 170 | blacklist = new Set(result.blacklist); 171 | renderBlacklist(); 172 | } 173 | if (result.hideSoldOut !== undefined) { 174 | showSoldOut.checked = result.hideSoldOut; 175 | } 176 | }); 177 | } 178 | 179 | // 載入ibon設定 180 | function loadIbonSettings() { 181 | chrome.storage.local.get(['ibonKeywords', 'ibonBlacklist', 'ibonHideSoldOut'], (result) => { 182 | if (result.ibonKeywords) { 183 | keywords = new Set(result.ibonKeywords); 184 | renderKeywords(); 185 | updateFilterLabel(); 186 | } 187 | if (result.ibonBlacklist) { 188 | blacklist = new Set(result.ibonBlacklist); 189 | renderBlacklist(); 190 | } 191 | if (result.ibonHideSoldOut !== undefined) { 192 | showSoldOut.checked = result.ibonHideSoldOut; 193 | } 194 | }); 195 | } 196 | 197 | // 載入Cityline設定 198 | function loadCitylineSettings() { 199 | chrome.storage.local.get(['citylineKeywords', 'citylineBlacklist', 'citylineHideSoldOut'], (result) => { 200 | if (result.citylineKeywords) { 201 | keywords = new Set(result.citylineKeywords); 202 | renderKeywords(); 203 | updateFilterLabel(); 204 | } 205 | if (result.citylineBlacklist) { 206 | blacklist = new Set(result.citylineBlacklist); 207 | renderBlacklist(); 208 | } 209 | if (result.citylineHideSoldOut !== undefined) { 210 | showSoldOut.checked = result.citylineHideSoldOut; 211 | } 212 | }); 213 | } 214 | 215 | // 載入年代售票設定 216 | function loadTicketSettings() { 217 | chrome.storage.local.get(['ticketKeywords', 'ticketHideSoldOut'], (result) => { 218 | if (result.ticketKeywords) { 219 | keywords = new Set(result.ticketKeywords); 220 | renderKeywords(); 221 | updateFilterLabel(); 222 | } 223 | if (result.ticketHideSoldOut !== undefined) { 224 | showSoldOut.checked = result.ticketHideSoldOut; 225 | } 226 | }); 227 | } 228 | 229 | // 載入遠大售票平台設定 230 | function loadTicketPlusSettings() { 231 | chrome.storage.local.get(['ticketplusKeywords', 'ticketplusBlacklist', 'ticketplusHideSoldOut'], (result) => { 232 | if (result.ticketplusKeywords) { 233 | keywords = new Set(result.ticketplusKeywords); 234 | renderKeywords(); 235 | updateFilterLabel(); 236 | } 237 | if (result.ticketplusBlacklist) { 238 | blacklist = new Set(result.ticketplusBlacklist); 239 | renderBlacklist(); 240 | } 241 | if (result.ticketplusHideSoldOut !== undefined) { 242 | showSoldOut.checked = result.ticketplusHideSoldOut; 243 | } 244 | }); 245 | } 246 | 247 | // Update filter label 248 | function updateFilterLabel() { 249 | if (keywords.size > 0) { 250 | currentFilter.style.display = 'inline-block'; 251 | filterText.textContent = Array.from(keywords).join('、'); 252 | } else { 253 | currentFilter.style.display = 'none'; 254 | } 255 | } 256 | 257 | // Render keyword tags 258 | function renderKeywords() { 259 | keywordsContainer.innerHTML = ''; 260 | 261 | if (keywords.size === 0) { 262 | keywordsContainer.innerHTML = '尚未設定篩選條件'; 263 | 264 | // 添加這行來隱藏標籤 265 | if (currentFilter) { 266 | currentFilter.style.display = 'none'; 267 | } 268 | if (filterText) { 269 | filterText.textContent = ''; 270 | } 271 | return; 272 | } 273 | 274 | keywords.forEach(keyword => { 275 | const tag = document.createElement('div'); 276 | tag.className = 'keyword-tag'; 277 | tag.innerHTML = ` 278 | ${keyword} 279 | × 280 | `; 281 | keywordsContainer.appendChild(tag); 282 | }); 283 | 284 | // Add event listeners for remove buttons 285 | document.querySelectorAll('.keyword-tag .remove').forEach(removeBtn => { 286 | removeBtn.addEventListener('click', (e) => { 287 | const keyword = e.target.dataset.keyword; 288 | keywords.delete(keyword); 289 | renderKeywords(); 290 | updateFilterLabel(); 291 | applyFilters(); 292 | }); 293 | }); 294 | 295 | // 更新關鍵字顯示標籤 296 | if (currentFilter && filterText) { 297 | currentFilter.style.display = 'inline-block'; 298 | filterText.textContent = Array.from(keywords).join('、'); 299 | } 300 | } 301 | 302 | // Render blacklist tags 303 | function renderBlacklist() { 304 | blacklistContainer.innerHTML = ''; 305 | 306 | if (blacklist.size === 0) { 307 | blacklistContainer.innerHTML = '尚未設定黑名單'; 308 | const currentBlacklist = document.getElementById('currentBlacklist'); 309 | if (currentBlacklist) { 310 | currentBlacklist.style.display = 'none'; 311 | } 312 | return; 313 | } 314 | 315 | blacklist.forEach(keyword => { 316 | const tag = document.createElement('div'); 317 | tag.className = 'keyword-tag'; 318 | tag.innerHTML = ` 319 | ${keyword} 320 | × 321 | `; 322 | blacklistContainer.appendChild(tag); 323 | }); 324 | 325 | // 更新黑名單顯示標籤 326 | const currentBlacklist = document.getElementById('currentBlacklist'); 327 | const blacklistText = document.getElementById('blacklistText'); 328 | if (currentBlacklist && blacklistText) { 329 | currentBlacklist.style.display = 'inline-block'; 330 | blacklistText.textContent = Array.from(blacklist).join('、'); 331 | } 332 | 333 | // Add event listeners for remove buttons 334 | document.querySelectorAll('#blacklistContainer .keyword-tag .remove').forEach(removeBtn => { 335 | removeBtn.addEventListener('click', (e) => { 336 | const keyword = e.target.dataset.keyword; 337 | blacklist.delete(keyword); 338 | renderBlacklist(); 339 | applyFilters(); 340 | }); 341 | }); 342 | } 343 | 344 | // 自定義警告框函數 345 | function showCustomAlert(message) { 346 | return new Promise((resolve) => { 347 | const overlay = document.querySelector('.alert-overlay'); 348 | const alert = document.querySelector('.custom-alert'); 349 | const content = alert.querySelector('.alert-content'); 350 | const confirmBtn = alert.querySelector('.alert-confirm'); 351 | const cancelBtn = alert.querySelector('.alert-cancel'); 352 | 353 | content.textContent = message; 354 | overlay.style.display = 'block'; 355 | alert.style.display = 'block'; 356 | 357 | const handleConfirm = () => { 358 | overlay.style.display = 'none'; 359 | alert.style.display = 'none'; 360 | cleanup(); 361 | resolve(true); 362 | }; 363 | 364 | const handleCancel = () => { 365 | overlay.style.display = 'none'; 366 | alert.style.display = 'none'; 367 | cleanup(); 368 | resolve(false); 369 | }; 370 | 371 | const cleanup = () => { 372 | confirmBtn.removeEventListener('click', handleConfirm); 373 | cancelBtn.removeEventListener('click', handleCancel); 374 | overlay.removeEventListener('click', handleCancel); 375 | }; 376 | 377 | confirmBtn.addEventListener('click', handleConfirm); 378 | cancelBtn.addEventListener('click', handleCancel); 379 | overlay.addEventListener('click', handleCancel); 380 | }); 381 | } 382 | 383 | // 修改添加關鍵字的函數 384 | async function addKeywordToFilter() { 385 | const value = areaFilter.value.trim(); 386 | if (value) { 387 | const hasConflict = Array.from(blacklist).some(blacklistItem => { 388 | if (blacklistItem === value) return true; 389 | 390 | // 處理 OR 條件 (+) 391 | const blacklistOrParts = blacklistItem.split('+').map(p => p.trim()); 392 | const valueOrParts = value.split('+').map(p => p.trim()); 393 | 394 | // 處理 AND 條件 (,) 395 | const blacklistAndParts = blacklistOrParts.flatMap(p => p.split(',').map(p => p.trim())); 396 | const valueAndParts = valueOrParts.flatMap(p => p.split(',').map(p => p.trim())); 397 | 398 | return blacklistAndParts.some(bp => valueAndParts.includes(bp)); 399 | }); 400 | 401 | if (hasConflict) { 402 | const shouldAdd = await showCustomAlert('此條件與黑名單中的條件重複或衝突,是否仍要新增?'); 403 | if (!shouldAdd) return; 404 | } 405 | keywords.add(value); 406 | areaFilter.value = ''; 407 | renderKeywords(); 408 | updateFilterLabel(); 409 | applyFilters(); 410 | } 411 | } 412 | 413 | // Add keyword on Enter 414 | if (areaFilter) { 415 | areaFilter.addEventListener('keypress', (e) => { 416 | if (e.key === 'Enter') { 417 | e.preventDefault(); 418 | addKeywordToFilter(); 419 | } 420 | }); 421 | } 422 | 423 | // Add keyword on button click 424 | if (addKeyword) { 425 | addKeyword.addEventListener('click', (e) => { 426 | e.preventDefault(); 427 | addKeywordToFilter(); 428 | }); 429 | } 430 | 431 | // 修改添加黑名單的函數 432 | async function addBlacklistKeyword() { 433 | const value = blacklistFilter.value.trim(); 434 | if (value) { 435 | const hasConflict = Array.from(keywords).some(keyword => { 436 | if (keyword === value) return true; 437 | 438 | // 處理 OR 條件 (+) 439 | const keywordOrParts = keyword.split('+').map(p => p.trim()); 440 | const valueOrParts = value.split('+').map(p => p.trim()); 441 | 442 | // 處理 AND 條件 (,) 443 | const keywordAndParts = keywordOrParts.flatMap(p => p.split(',').map(p => p.trim())); 444 | const valueAndParts = valueOrParts.flatMap(p => p.split(',').map(p => p.trim())); 445 | 446 | return keywordAndParts.some(kp => valueAndParts.includes(kp)); 447 | }); 448 | 449 | if (hasConflict) { 450 | const shouldAdd = await showCustomAlert('此條件與關鍵字篩選中的條件重複或衝突,是否仍要新增?'); 451 | if (!shouldAdd) return; 452 | } 453 | blacklist.add(value); 454 | blacklistFilter.value = ''; 455 | renderBlacklist(); 456 | applyFilters(); 457 | } 458 | } 459 | 460 | // Add blacklist on Enter 461 | if (blacklistFilter) { 462 | blacklistFilter.addEventListener('keypress', (e) => { 463 | if (e.key === 'Enter') { 464 | e.preventDefault(); 465 | addBlacklistKeyword(); 466 | } 467 | }); 468 | } 469 | 470 | // Add blacklist on button click 471 | if (addBlacklist) { 472 | addBlacklist.addEventListener('click', (e) => { 473 | e.preventDefault(); 474 | addBlacklistKeyword(); 475 | }); 476 | } 477 | 478 | // Show all button click event 479 | if (showAll) { 480 | showAll.addEventListener('click', (e) => { 481 | e.preventDefault(); 482 | keywords.clear(); 483 | blacklist.clear(); 484 | renderKeywords(); 485 | renderBlacklist(); 486 | showSoldOut.checked = false; 487 | 488 | if (currentSite === 'tixcraft') { 489 | chrome.storage.local.set({ 490 | keywords: [], 491 | blacklist: [], 492 | hideSoldOut: false 493 | }, () => { 494 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 495 | if (tabs[0]) { 496 | chrome.tabs.sendMessage(tabs[0].id, { 497 | type: 'SHOW_ALL' 498 | }); 499 | } 500 | }); 501 | }); 502 | } else if (currentSite === 'kktix') { 503 | // 先清除所有設定 504 | chrome.storage.sync.remove(['targetKeywords', 'blacklist', 'showAllPrices', 'hideSoldOut'], () => { 505 | // 設置新的空值 506 | chrome.storage.sync.set({ 507 | targetKeywords: [], 508 | blacklist: [], 509 | showAllPrices: true, 510 | hideSoldOut: false 511 | }, () => { 512 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 513 | if (tabs[0]) { 514 | chrome.tabs.sendMessage(tabs[0].id, { 515 | action: 'showAllPrices', 516 | settings: { 517 | hideSoldOut: false 518 | } 519 | }, () => { 520 | // 強制重新載入設定 521 | loadKKTIXSettings(); 522 | // 強制更新標籤顯示 523 | renderKeywords(); 524 | renderBlacklist(); 525 | updateFilterLabel(); 526 | }); 527 | } 528 | }); 529 | }); 530 | }); 531 | } else if (currentSite === 'ibon') { 532 | // 先清除 storage 533 | chrome.storage.local.remove(['ibonKeywords', 'ibonBlacklist', 'ibonHideSoldOut'], () => { 534 | // 然後設置新的空值 535 | chrome.storage.local.set({ 536 | ibonKeywords: [], 537 | ibonBlacklist: [], 538 | ibonHideSoldOut: false 539 | }, () => { 540 | // 發送消息到 content script 541 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 542 | if (tabs[0]) { 543 | chrome.tabs.sendMessage(tabs[0].id, { 544 | type: 'UPDATE_IBON_SETTINGS', 545 | settings: { 546 | keywords: [], 547 | blacklist: [], 548 | hideSoldOut: false 549 | } 550 | }, () => { 551 | // 強制重新載入設定 552 | loadIbonSettings(); 553 | }); 554 | } 555 | }); 556 | }); 557 | }); 558 | } else if (currentSite === 'cityline') { 559 | chrome.storage.local.set({ 560 | citylineKeywords: [], 561 | citylineBlacklist: [], 562 | citylineHideSoldOut: false 563 | }, () => { 564 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 565 | if (tabs[0]) { 566 | chrome.tabs.sendMessage(tabs[0].id, { 567 | type: 'UPDATE_CITYLINE_SETTINGS', 568 | settings: { 569 | keywords: [], 570 | blacklist: [], 571 | hideSoldOut: false 572 | } 573 | }); 574 | } 575 | }); 576 | }); 577 | } else if (currentSite === 'ticket') { 578 | chrome.storage.local.set({ 579 | ticketKeywords: [], 580 | ticketHideSoldOut: false 581 | }, () => { 582 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 583 | if (tabs[0]) { 584 | chrome.tabs.sendMessage(tabs[0].id, { 585 | type: 'UPDATE_TICKET_SETTINGS', 586 | settings: { 587 | keywords: [], 588 | hideSoldOut: false 589 | } 590 | }); 591 | } 592 | }); 593 | }); 594 | } else if (currentSite === 'ticketplus') { 595 | chrome.storage.local.set({ 596 | ticketplusKeywords: [], 597 | ticketplusBlacklist: [], 598 | ticketplusHideSoldOut: false 599 | }, () => { 600 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 601 | if (tabs[0]) { 602 | chrome.tabs.sendMessage(tabs[0].id, { 603 | type: 'UPDATE_TICKETPLUS_SETTINGS', 604 | settings: { 605 | keywords: [], 606 | blacklist: [], 607 | hideSoldOut: false 608 | } 609 | }); 610 | } 611 | }); 612 | }); 613 | } 614 | }); 615 | } 616 | 617 | // Show sold out checkbox change event 618 | if (showSoldOut) { 619 | showSoldOut.addEventListener('change', () => { 620 | applyFilters(); 621 | }); 622 | } 623 | 624 | // Apply filters 625 | function applyFilters() { 626 | const keywordArray = Array.from(keywords); 627 | const blacklistArray = Array.from(blacklist); 628 | const site = document.body.getAttribute('data-site'); 629 | 630 | if (site === 'tixcraft') { 631 | const settings = { 632 | keywords: keywordArray, 633 | blacklist: blacklistArray, 634 | hideSoldOut: showSoldOut.checked 635 | }; 636 | chrome.storage.local.set({ 637 | keywords: keywordArray, 638 | blacklist: blacklistArray, 639 | hideSoldOut: showSoldOut.checked 640 | }, () => { 641 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 642 | if (tabs[0]) { 643 | chrome.tabs.sendMessage(tabs[0].id, { 644 | type: 'UPDATE_SETTINGS', 645 | settings: settings 646 | }); 647 | } 648 | }); 649 | }); 650 | } else if (site === 'kktix') { 651 | const settings = { 652 | keywords: keywordArray, 653 | blacklist: blacklistArray, 654 | hideSoldOut: showSoldOut.checked 655 | }; 656 | chrome.storage.sync.set({ 657 | targetKeywords: keywordArray, 658 | blacklist: blacklistArray, 659 | showAllPrices: false, 660 | hideSoldOut: showSoldOut.checked 661 | }, () => { 662 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 663 | if (tabs[0]) { 664 | chrome.tabs.sendMessage(tabs[0].id, { 665 | action: 'updateFilter', 666 | keywords: keywordArray, 667 | blacklist: blacklistArray, 668 | settings: settings 669 | }); 670 | } 671 | }); 672 | }); 673 | } else if (site === 'ibon') { 674 | const settings = { 675 | keywords: keywordArray, 676 | blacklist: blacklistArray, 677 | hideSoldOut: showSoldOut.checked 678 | }; 679 | chrome.storage.local.set({ 680 | ibonKeywords: keywordArray, 681 | ibonBlacklist: blacklistArray, 682 | ibonHideSoldOut: showSoldOut.checked 683 | }, () => { 684 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 685 | if (tabs[0]) { 686 | chrome.tabs.sendMessage(tabs[0].id, { 687 | type: 'UPDATE_IBON_SETTINGS', 688 | settings: settings 689 | }); 690 | } 691 | }); 692 | }); 693 | } else if (site === 'cityline') { 694 | const settings = { 695 | keywords: keywordArray, 696 | blacklist: blacklistArray, 697 | hideSoldOut: showSoldOut.checked 698 | }; 699 | chrome.storage.local.set({ 700 | citylineKeywords: keywordArray, 701 | citylineBlacklist: blacklistArray, 702 | citylineHideSoldOut: showSoldOut.checked 703 | }, () => { 704 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 705 | if (tabs[0]) { 706 | chrome.tabs.sendMessage(tabs[0].id, { 707 | type: 'UPDATE_CITYLINE_SETTINGS', 708 | settings: settings 709 | }); 710 | } 711 | }); 712 | }); 713 | } else if (site === 'ticket') { 714 | const settings = { 715 | keywords: keywordArray, 716 | hideSoldOut: showSoldOut.checked 717 | }; 718 | 719 | // 儲存設定 720 | chrome.storage.local.set({ 721 | ticketKeywords: keywordArray, 722 | ticketHideSoldOut: showSoldOut.checked 723 | }, () => { 724 | // 發送設定到content.js 725 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 726 | if (tabs[0]) { 727 | chrome.tabs.sendMessage(tabs[0].id, { 728 | type: 'UPDATE_TICKET_SETTINGS', 729 | settings: settings 730 | }); 731 | } 732 | }); 733 | }); 734 | } else if (site === 'ticketplus') { 735 | const settings = { 736 | keywords: keywordArray, 737 | blacklist: blacklistArray, 738 | hideSoldOut: showSoldOut.checked 739 | }; 740 | 741 | chrome.storage.local.set({ 742 | ticketplusKeywords: keywordArray, 743 | ticketplusBlacklist: blacklistArray, 744 | ticketplusHideSoldOut: showSoldOut.checked 745 | }, () => { 746 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 747 | if (tabs[0]) { 748 | chrome.tabs.sendMessage(tabs[0].id, { 749 | type: 'UPDATE_TICKETPLUS_SETTINGS', 750 | settings: settings 751 | }); 752 | } 753 | }); 754 | }); 755 | } 756 | } 757 | 758 | // 处理服务器时间显示开关 759 | showServerTime.addEventListener('change', function(e) { 760 | chrome.storage.local.set({ showServerTime: e.target.checked }); 761 | }); 762 | 763 | // 加载时设置开关状态 764 | chrome.storage.local.get(['showServerTime'], function(result) { 765 | // 如果从未设置过,默认为开启 766 | const serverTimeEnabled = result.showServerTime === undefined ? true : result.showServerTime; 767 | showServerTime.checked = serverTimeEnabled; 768 | }); 769 | 770 | // 设置面板相关 771 | const settingsPanel = document.querySelector('.settings-panel'); 772 | 773 | // 点击设置图标显示/隐藏设置面板 774 | settingsIcon.addEventListener('click', (e) => { 775 | e.stopPropagation(); 776 | settingsPanel.classList.toggle('show'); 777 | }); 778 | 779 | // 点击其他地方关闭设置面板 780 | document.addEventListener('click', (e) => { 781 | if (!settingsPanel.contains(e.target) && !settingsIcon.contains(e.target)) { 782 | settingsPanel.classList.remove('show'); 783 | } 784 | }); 785 | 786 | // 处理筛选条件显示设置 787 | showFilterStatus.addEventListener('change', (e) => { 788 | chrome.storage.local.set({ showFilterStatus: e.target.checked }); 789 | }); 790 | 791 | // 加载筛选条件显示设置 792 | chrome.storage.local.get(['showFilterStatus'], function(result) { 793 | const showFilterStatus = result.showFilterStatus === undefined ? true : result.showFilterStatus; 794 | document.getElementById('showFilterStatus').checked = showFilterStatus; 795 | }); 796 | 797 | // 监听设置变化 798 | chrome.storage.onChanged.addListener(function(changes, namespace) { 799 | if (changes.showServerTime) { 800 | if (changes.showServerTime.newValue === false) { 801 | const existingDisplay = document.querySelector('.server-time-display'); 802 | if (existingDisplay) { 803 | existingDisplay.remove(); 804 | } 805 | } else { 806 | showServerTime(); 807 | } 808 | // 重新显示筛选状态,以更新位置 809 | showFilterStatus(); 810 | } 811 | 812 | // 当筛选条件显示设置改变时,重新显示筛选状态 813 | if (changes.showFilterStatus !== undefined) { 814 | if (changes.showFilterStatus.newValue === false) { 815 | // 如果设置为不显示,移除现有的筛选条件显示 816 | const existingNotification = document.querySelector('.ticket-filter-notification'); 817 | if (existingNotification) { 818 | existingNotification.remove(); 819 | } 820 | } else { 821 | // 如果设置为显示,重新显示筛选条件 822 | showFilterStatus(); 823 | } 824 | } 825 | }); 826 | }); -------------------------------------------------------------------------------- /shadowInject.js: -------------------------------------------------------------------------------- 1 | // 在最早期重寫 attachShadow 2 | const script = document.createElement('script'); 3 | script.textContent = ` 4 | // 立即重寫 attachShadow,不允許 closed 模式 5 | (function() { 6 | const originalAttachShadow = Element.prototype.attachShadow; 7 | Element.prototype.attachShadow = function(init) { 8 | // 強制使用 open 模式 9 | return originalAttachShadow.call(this, { mode: 'open' }); 10 | }; 11 | 12 | // 處理已存在的 shadowRoot 13 | function processExistingShadowRoots(root) { 14 | if (!root) return; 15 | 16 | if (root instanceof Element) { 17 | // 如果元素有 closed shadowRoot,嘗試重新創建為 open 18 | if (root.shadowRoot === null && root.getAttribute('shadowroot') === 'closed') { 19 | try { 20 | root.attachShadow({ mode: 'open' }); 21 | } catch(e) {} 22 | } 23 | } 24 | 25 | // 遞迴處理子元素 26 | const children = root.children; 27 | if (children) { 28 | for (let i = 0; i < children.length; i++) { 29 | processExistingShadowRoots(children[i]); 30 | } 31 | } 32 | } 33 | 34 | // 設置 MutationObserver 35 | const observer = new MutationObserver((mutations) => { 36 | for (const mutation of mutations) { 37 | if (mutation.addedNodes) { 38 | mutation.addedNodes.forEach(node => { 39 | if (node.nodeType === 1) { 40 | processExistingShadowRoots(node); 41 | } 42 | }); 43 | } 44 | } 45 | }); 46 | 47 | // 開始觀察 48 | observer.observe(document.documentElement || document.body || document, { 49 | childList: true, 50 | subtree: true 51 | }); 52 | 53 | // 初始處理 54 | processExistingShadowRoots(document.documentElement); 55 | 56 | // 定期檢查 57 | setInterval(() => { 58 | processExistingShadowRoots(document.documentElement); 59 | }, 100); 60 | })(); 61 | `; 62 | (document.head || document.documentElement).appendChild(script); 63 | script.remove(); 64 | 65 | // 注入主要處理腳本 66 | const injectedScript = document.createElement('script'); 67 | injectedScript.src = chrome.runtime.getURL('injected.js'); 68 | (document.head || document.documentElement).appendChild(injectedScript); -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 300px; 3 | padding: 15px; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 5 | } 6 | 7 | .container { 8 | display: flex; 9 | flex-direction: column; 10 | gap: 15px; 11 | } 12 | 13 | h2 { 14 | margin: 0 0 15px 0; 15 | color: #333; 16 | font-size: 18px; 17 | text-align: center; 18 | } 19 | 20 | h3 { 21 | margin: 0 0 10px 0; 22 | color: #666; 23 | font-size: 14px; 24 | } 25 | 26 | .filter-section { 27 | padding: 10px; 28 | background-color: #f8f9fa; 29 | border-radius: 5px; 30 | } 31 | 32 | input[type="text"], 33 | input[type="number"] { 34 | width: 100%; 35 | padding: 8px; 36 | border: 1px solid #ddd; 37 | border-radius: 4px; 38 | box-sizing: border-box; 39 | } 40 | 41 | .price-range { 42 | display: flex; 43 | align-items: center; 44 | gap: 10px; 45 | } 46 | 47 | .price-range input { 48 | width: 45%; 49 | } 50 | 51 | small { 52 | display: block; 53 | margin-top: 5px; 54 | color: #666; 55 | font-size: 12px; 56 | } 57 | 58 | button { 59 | width: 100%; 60 | padding: 10px; 61 | background-color: #007bff; 62 | color: white; 63 | border: none; 64 | border-radius: 4px; 65 | cursor: pointer; 66 | font-size: 14px; 67 | transition: background-color 0.2s; 68 | } 69 | 70 | button:hover { 71 | background-color: #0056b3; 72 | } 73 | 74 | label { 75 | display: flex; 76 | align-items: center; 77 | gap: 8px; 78 | cursor: pointer; 79 | } 80 | 81 | /* 高亮显示匹配的座位 */ 82 | .seat-highlight { 83 | background-color: #90EE90 !important; 84 | border: 2px solid #32CD32 !important; 85 | } --------------------------------------------------------------------------------