您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhance the search results table on vacationstogo.com
// ==UserScript== // @name VacationsToGo Better Table // @namespace https://hollen9.com // @version 1.0.0 // @description Enhance the search results table on vacationstogo.com // @author you // @match https://www.vacationstogo.com/ticker.cfm* // @grant GM_addStyle // @grant GM_getResourceText // @require https://cdnjs.cloudflare.com/ajax/libs/tabulator/6.3.1/js/tabulator.min.js // @resource TABULATOR_CSS https://cdnjs.cloudflare.com/ajax/libs/tabulator/6.3.1/css/tabulator.min.css // @run-at document-idle // @license MIT // ==/UserScript== (function () { "use strict"; // --- 注入 Tabulator CSS --- try { const css = GM_getResourceText("TABULATOR_CSS"); if (css) GM_addStyle(css); GM_addStyle(` /* 讓整頁接管橫向捲動 */ .tabulator, .tabulator .tabulator-table { width: max-content !important; } .tabulator .tabulator-tableholder { overflow-x: visible !important; /* 不要攔橫向捲動 */ } /* 外容器別裁切 */ #tabulator-deals-container { overflow: visible !important; } `); } catch (e) { console.warn("[Userscript] 無法載入 Tabulator CSS:", e); } const SELECTOR_TABLE = "table.ticker.deals"; const TABLE_HEIGHT = "70vh"; const BUILD_CHUNK = 500; let isBuilding = false; let isReady = false; let table = null; // Tabulator instance // --- utils --- const text = (el) => (el ? el.textContent.trim() : ""); const htmlToText = (el) => (el ? el.textContent.replace(/\s+/g, " ").trim() : ""); const moneyToNumber = (s) => { if (!s || s.trim() === "-") return null; const n = Number(s.replace(/\$/g, "").replace(/,/g, "").trim()); return Number.isFinite(n) ? n : null; }; const pctToNumber = (s) => { if (!s) return null; const m = s.trim().match(/^(-?\d+(?:\.\d+)?)%$/); return m ? Number(m[1]) : null; }; const firstLink = (cell) => { const a = cell?.querySelector?.("a"); return a ? { href: a.href, text: a.textContent.trim() } : { href: "", text: text(cell) }; }; const linksIn = (cell) => Array.from(cell?.querySelectorAll?.("a") || []).map((a) => ({ href: a.href, text: a.textContent.trim() })); async function parseDealsTable(tableEl) { const rows = []; const trs = tableEl.querySelectorAll("tbody > tr"); for (let i = 0; i < trs.length; i++) { const tds = trs[i].querySelectorAll("td"); if (tds.length < 11) continue; const [dealCell, nightsCell, dateCell, fromCell, toCell, lineShipCell, ratingCell, brochureCell, ourCell, pctCell, badgeCell] = tds; const dealLink = firstLink(dealCell); const fromLink = firstLink(fromCell); const toLink = firstLink(toCell); const ls = linksIn(lineShipCell); const lineInfo = ls[0] || { href: "", text: "" }; const shipInfo = ls[1] || { href: "", text: "" }; rows.push({ dealId: dealLink.text.replace(/^#/, ""), dealHref: dealLink.href, nights: Number(text(nightsCell)) || null, sailDate: htmlToText(dateCell).replace(/\s*,\s*/g, ", "), from: fromLink.text, fromHref: fromLink.href, to: toLink.text, toHref: toLink.href, line: lineInfo.text, lineHref: lineInfo.href, ship: shipInfo.text, shipHref: shipInfo.href, rating: Number(text(ratingCell)) || null, brochureRaw: text(brochureCell), brochure: moneyToNumber(text(brochureCell)), ourRaw: text(ourCell), our: moneyToNumber(text(ourCell)), savingsRaw: text(pctCell), savings: pctToNumber(text(pctCell)), badge: htmlToText(badgeCell), }); if (i % BUILD_CHUNK === 0) await new Promise((r) => setTimeout(r, 0)); } return rows; } function buildToolbar(host, table) { const bar = document.createElement("div"); bar.style.cssText = "margin:10px 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap;"; // 下載按鈕 const mkBtn = (label, key) => { const b = document.createElement("button"); b.textContent = label; b.style.cssText = "padding:6px 10px;border:1px solid #ccc;border-radius:6px;background:#fff;cursor:pointer;"; b.addEventListener("click", () => { if (key === "csv") table.download("csv", "deals.csv"); if (key === "json") table.download("json", "deals.json"); if (key === "html") table.download("html", "deals.html", { style: true }); }); return b; }; // 下拉:全部/同點/異點 const label = document.createElement("label"); label.textContent = "Depart/Dest Filter: "; const sel = document.createElement("select"); sel.innerHTML = ` <option value="all">All</option> <option value="same">From=To</option> <option value="diff">From≠To</option> `; sel.style.cssText = "padding:6px 8px;border:1px solid #ccc;border-radius:6px;background:#fff;"; sel.addEventListener("change", () => { const v = sel.value; if (v === "all") { table.clearFilter(true); } else if (v === "same") { table.setFilter((row) => (row.from || "").trim() === (row.to || "").trim()); } else if (v === "diff") { table.setFilter((row) => (row.from || "").trim() !== (row.to || "").trim()); } }); bar.appendChild(label); bar.appendChild(sel); bar.appendChild(mkBtn("Save as CSV", "csv")); bar.appendChild(mkBtn("Save as JSON", "json")); bar.appendChild(mkBtn("Save as HTML", "html")); host.parentNode.insertBefore(bar, host); } function buildTabulator(host, data) { const columns = [ { title: "Deal", field: "dealId", width: 90, headerFilter: "input", formatter: (cell) => `<a href="${cell.getRow().getData().dealHref}" target="_blank">#${cell.getValue()}</a>` }, { title: "Nights", field: "nights", sorter: "number", hozAlign: "right", width: 80 }, { title: "Sail Date", field: "sailDate", headerFilter: "input", width: 140 }, { title: "From", field: "from", headerFilter: "input", widthGrow: 1, formatter: (c)=>{const r=c.getRow().getData(); return r.fromHref?`<a href="${r.fromHref}" target="_blank">${c.getValue()}</a>`:c.getValue();} }, { title: "To", field: "to", headerFilter: "input", widthGrow: 1, formatter: (c)=>{const r=c.getRow().getData(); return r.toHref?`<a href="${r.toHref}" target="_blank">${c.getValue()}</a>`:c.getValue();} }, { title: "Line", field: "line", headerFilter: "input", width: 140, formatter: (c)=>{const r=c.getRow().getData(); return r.lineHref?`<a href="${r.lineHref}" target="_blank">${c.getValue()}</a>`:c.getValue();} }, { title: "Ship", field: "ship", headerFilter: "input", width: 160, formatter: (c)=>{const r=c.getRow().getData(); return r.shipHref?`<a href="${r.shipHref}" target="_blank">${c.getValue()}</a>`:c.getValue();} }, { title: "Rating", field: "rating", sorter: "number", hozAlign: "right", width: 90 }, { title: "Brochure", field: "brochure", sorter: "number", hozAlign: "right", width: 110, formatter:(c)=> c.getValue()==null? "-" : `$${c.getValue().toLocaleString()}`, tooltip:(c)=>c.getRow().getData().brochureRaw||"" }, { title: "Our Price", field: "our", sorter: "number", hozAlign: "right", width: 120, formatter:(c)=> c.getValue()==null? "-" : `<b>$${c.getValue().toLocaleString()}</b>`, tooltip:(c)=>c.getRow().getData().ourRaw||"" }, { title: "Savings %", field: "savings", sorter: "number", hozAlign: "right", width: 110, formatter:(c)=> c.getValue()==null? "-" : `${c.getValue()}%`, tooltip:(c)=>c.getRow().getData().savingsRaw||"" }, { title: "Badge", field: "badge", headerFilter: "input", width: 160 }, ]; table = new Tabulator(host, { data, columns, height: TABLE_HEIGHT, layout: "fitDataStretch", virtualDom: true, progressiveRender: "scroll", progressiveRenderSize: 100, progressiveRenderMargin: 200, movableColumns: true, columnDefaults: { headerSort: true }, persistence: false, placeholder: "No Data", initialSort: [{ column: "our", dir: "asc" }], }); buildToolbar(host, table); return table; } async function upgrade() { if (isBuilding || isReady) return; const tableEl = document.querySelector(SELECTOR_TABLE); if (!tableEl) return; isBuilding = true; try { if (typeof Tabulator === "undefined") throw new Error("Tabulator 未就緒(@require 未載入?)"); const data = await parseDealsTable(tableEl); if (!data.length) { isBuilding = false; return; } const container = document.createElement("div"); container.id = "tabulator-deals-container"; container.style.cssText = "margin: 16px 0;"; const host = document.createElement("div"); container.appendChild(host); tableEl.parentNode.insertBefore(container, tableEl); buildTabulator(host, data); // 成功後再隱藏原表,避免 reflow 風暴 tableEl.style.display = "none"; isReady = true; } catch (e) { console.error("[Userscript] 建立 Tabulator 失敗:", e); } finally { isBuilding = false; } } // 只要成功一次就斷開觀察器 const mo = new MutationObserver(() => { if (!isReady) upgrade(); else mo.disconnect(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); // 頁面就緒也試一次 upgrade(); })();