// ==UserScript== // @name Game8 馬娘支援卡評價與持有整合面板 // @namespace https://github.com/downwarjers/WebTweaks // @version 2.6.7 // @description 整合Game8馬娘攻略網的支援卡評價顯示與持有率管理。核心功能包括:自動背景抓取評價資料、CSV匯入匯出、以及優化的「資料庫/畫面」同步邏輯 // @author downwarjers // @license MIT // @match https://game8.jp/umamusume/393152 // @match https://game8.jp/umamusume/372188 // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @downloadURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/game8-umamusume-support-cards-manager/game8-umamusume-support-cards-manager.user.js // @updateURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/game8-umamusume-support-cards-manager/game8-umamusume-support-cards-manager.user.js // ==/UserScript== (function() { 'use strict'; // ========================================== // 0. 全域變數與設定 // ========================================== const DB_KEY = 'g8_uma_db_v16'; // 資料庫存儲 Key (維持 v16 以繼承舊有資料) const MAX_CONCURRENT = 5; // 背景抓取評價時的最大並發請求數 // 記憶體中的資料庫快取 // Map 結構: Key(圖片檔名) -> { key, img, cardTitle, charName, url, rating, fetched, possession } window.DB_MAP = new Map(); window.LAST_UPDATE = 0; // 背景抓取佇列控制 const FETCH_QUEUE = []; const QUEUE_SET = new Set(); let ACTIVE_REQUESTS = 0; let monitorTimer = null; // ========================================== // 1. CSS 樣式定義 // ========================================== GM_addStyle(` /* 啟動按鈕 */ #g8-launcher { position: fixed; top: 120px; right: 20px; z-index: 99999; padding: 10px 20px; background: #C2185B; color: #fff; border: 2px solid #fff; border-radius: 8px; cursor: pointer; font-weight: bold; box-shadow: 0 4px 8px rgba(0,0,0,0.5); font-size: 14px; } /* 主儀表板容器 */ #g8-dashboard { display: none; position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: 95%; max-width: 1200px; height: 90vh; background: #212121; color: #eee; z-index: 100000; border: 2px solid #C2185B; border-radius: 8px; box-shadow: 0 0 30px rgba(0,0,0,0.9); flex-direction: column; font-family: "Microsoft JhengHei", sans-serif; } /* 標題列 */ .g8-header { padding: 15px; background: #333; border-bottom: 1px solid #555; display: flex; justify-content: space-between; align-items: center; } .g8-update-time { font-size: 14px; color: #4CAF50; font-weight: bold; } /* 監控狀態列 */ .g8-monitor-bar { background: #1a1a1a; padding: 8px 15px; font-size: 13px; color: #aaa; border-bottom: 1px solid #444; display: flex; gap: 20px; font-family: monospace; align-items: center; } .g8-monitor-val { color: #fff; font-weight: bold; } /* 控制面板與按鈕群 */ .g8-controls { padding: 10px 20px; background: #2a2a2a; border-bottom: 1px solid #444; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .g8-btn { padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; color: white; font-size: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; } .btn-db { background: #009688; } .btn-sync { background: #E91E63; } .btn-bg-fetch { background: #7B1FA2; } .btn-export { background: #FF9800; } .btn-import { background: #2196F3; } .btn-dom-sync { background: #d32f2f; border: 1px solid #ff5252; } .btn-clear { background: #607D8B; margin-left: auto; } .g8-checkbox-label { display: flex; align-items: center; gap: 5px; cursor: pointer; font-size: 13px; background: #333; padding: 5px 10px; border-radius: 4px; border: 1px solid #555; user-select: none; } /* 表格區塊 */ .g8-table-wrap { flex: 1; overflow-y: auto; padding: 0; position: relative; } .g8-table { width: 100%; border-collapse: collapse; font-size: 12px; } .g8-table th { background: #444; position: sticky; top: 0; padding: 8px; text-align: center; z-index: 10; border-bottom: 2px solid #555; color: #fff;} .g8-table td { border-bottom: 1px solid #333; padding: 5px; vertical-align: middle; color: #ddd; text-align: center; } .g8-table td.text-left { text-align: left; } .g8-table tr:nth-child(even) { background: #2a2a2a; } .g8-table tr:hover { background: #383838; } .g8-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid #555; } /* 狀態指示顏色 */ .rate-cell { font-weight: 900; font-size: 14px; } .possess-cell { font-weight: bold; } .p-4 { color: #00E676; text-shadow: 0 0 5px rgba(0, 230, 118, 0.5); } .p-none { color: #777; opacity: 0.5; } .owned-yes { color: #4FC3F7; font-weight: bold; } .owned-no { color: #777; } /* 卡片上的 Overlay (顯示凸數+評價) */ .g8-overlay { position: absolute; top: 0; right: 0; width: 100%; height: 100%; pointer-events: none; z-index: 20; display: flex; flex-direction: column; justify-content: space-between; align-items: flex-end; padding: 2px; } .g8-ov-rate { font-size: 22px; font-weight: 900; color: #FFEB3B; font-family: 'Arial Black', sans-serif; line-height: 1; text-shadow: 2px 0 0 #000, -2px 0 0 #000, 0 2px 0 #000, 0 -2px 0 #000, 3px 3px 3px rgba(0,0,0,0.8); margin-right: -2px; margin-top: -2px; } .g8-ov-poss { font-size: 18px; font-weight: bold; color: #fff; font-family: sans-serif; background: rgba(0,0,0,0.7); padding: 2px 6px; border-radius: 4px 0 0 0; text-shadow: 1px 1px 0 #000; } .g8-ov-poss.p-4 { color: #00E676; border: 1px solid #00E676; } .g8-ov-poss.p-none { color: #aaa; font-size: 12px; } .rate-ss { color: #E040FB; } .rate-s { color: #FF5252; } .rate-u { color: #FFF; font-size: 16px; } /* 強制顯示與覆蓋 Game8 原生樣式 */ div[class*="style-module__possessionItem___"] { position: relative !important; overflow: visible !important; } #g8-file-input { display: none; } /* 同步動畫標記 (Visual Feedback) */ .g8-syncing { outline: 2px solid #FFEB3B !important; } `); // ========================================== // 2. UI 介面初始化 (Launcher & Dashboard) // ========================================== const launcher = document.createElement('button'); launcher.id = 'g8-launcher'; launcher.innerText = '📊 開啟儀表板 (v17)'; launcher.onclick = () => document.getElementById('g8-dashboard').style.display = 'flex'; document.body.appendChild(launcher); const dashboard = document.createElement('div'); dashboard.id = 'g8-dashboard'; dashboard.innerHTML = `
Game8 馬娘強度管理 v17.0
尚未同步
佇列: 0 進行: 0 * 監控中 (優先還原DB紀錄)
圖片 稱號 馬娘 持有 凸數 評價 狀態
`; document.body.appendChild(dashboard); const timeDisplay = document.getElementById('g8-time-display'); const tbody = document.getElementById('g8-tbody'); const monQueue = document.getElementById('mon-queue'); const monActive = document.getElementById('mon-active'); const fileInput = document.getElementById('g8-file-input'); const exportFilterCheck = document.getElementById('g8-export-filter'); const syncBtn = document.getElementById('btn-sync-view'); // ========================================== // 3. 資料庫讀寫操作 // ========================================== loadDB(); function loadDB() { const json = GM_getValue(DB_KEY); if (json) { try { const parsed = JSON.parse(json); window.DB_MAP = new Map(parsed.data); window.LAST_UPDATE = parsed.timestamp || 0; updateTimeDisplay(); renderTable(); } catch (e) { timeDisplay.innerText = '存檔損毀'; } } else { timeDisplay.innerText = '無存檔'; } } function saveDB() { if (window.LAST_UPDATE === 0) window.LAST_UPDATE = Date.now(); const dataArray = Array.from(window.DB_MAP.entries()).map(([key, val]) => { const { tr, ...saveData } = val; // 排除 DOM 元素參照,避免序列化錯誤 return [key, saveData]; }); GM_setValue(DB_KEY, JSON.stringify({ timestamp: window.LAST_UPDATE, data: dataArray })); updateTimeDisplay(); } function updateTimeDisplay() { if (window.LAST_UPDATE > 0) { const d = new Date(window.LAST_UPDATE); const timeStr = `${d.getFullYear()}/${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`; timeDisplay.innerText = `最後紀錄時間: ${timeStr}`; } else { timeDisplay.innerText = '尚未有紀錄'; } } // ========================================== // 4. 畫面渲染 (Table & Overlay) // ========================================== function renderTable() { tbody.innerHTML = ''; if (window.DB_MAP.size === 0) return; const fragment = document.createDocumentFragment(); window.DB_MAP.forEach((data, key) => { const tr = document.createElement('tr'); tr.id = `g8-tr-${key}`; let rateClass = 'rate-cell'; if (data.rating && data.rating.includes('SS')) rateClass += ' rate-ss'; else if (data.rating && data.rating.includes('S')) rateClass += ' rate-s'; else if (data.rating && data.rating !== '...') rateClass += ' rate-u'; const isOwned = data.possession !== -1; const ownedHtml = isOwned ? '' : ''; const limitBreakHtml = isOwned ? (data.possession === 4 ? '4凸' : `${data.possession}凸`) : '-'; const statusHtml = data.fetched ? 'OK' : 'Wait'; tr.innerHTML = ` ${data.cardTitle} ${data.charName} ${ownedHtml} ${limitBreakHtml} ${data.rating || '...'} ${statusHtml} `; fragment.appendChild(tr); }); tbody.appendChild(fragment); } function updateCardOverlay(cardEl, data) { if (!cardEl) return; const old = cardEl.querySelector('.g8-overlay'); if (old) old.remove(); const div = document.createElement('div'); div.className = 'g8-overlay'; let rateClass = 'g8-ov-rate'; if (data.rating.includes('SS')) rateClass += ' rate-ss'; else if (data.rating.includes('S')) rateClass += ' rate-s'; else rateClass += ' rate-u'; let possText = '未'; let possClass = 'g8-ov-poss p-none'; if (data.possession >= 0) { possText = `★${data.possession}`; possClass = 'g8-ov-poss'; if (data.possession === 4) possClass += ' p-4'; } div.innerHTML = `
${data.rating}
${possText}
`; cardEl.appendChild(div); } // ========================================== // 5. 按鈕事件綁定 (匯入/匯出/同步/背景抓取) // ========================================== // 匯出 CSV document.getElementById('btn-export-file').onclick = function() { if (window.DB_MAP.size === 0) return; saveDB(); const onlyOwned = exportFilterCheck.checked; const BOM = '\uFEFF'; let csv = BOM + '卡片名稱,馬娘名稱,持有凸數,評價,圖片連結,詳細頁連結\n'; let count = 0; window.DB_MAP.forEach(d => { if (onlyOwned && d.possession === -1) return; let pStr = (d.possession === -1) ? "未持有" : d.possession; const t1 = `"${d.cardTitle.replace(/"/g, '""')}"`; const t2 = `"${d.charName.replace(/"/g, '""')}"`; csv += `${t1},${t2},${pStr},${d.rating},${d.img},${d.url}\n`; count++; }); if (count === 0) { alert('沒有符合條件的資料'); return; } const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `umamusume_possession_${new Date().toISOString().slice(0,10)}.csv`; link.click(); URL.revokeObjectURL(url); }; // 匯入 CSV document.getElementById('btn-import-file').onclick = () => fileInput.click(); fileInput.onchange = function(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(evt) { const text = evt.target.result; const lines = text.split('\n'); let updatedCount = 0; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const matches = line.match(/(".*?"|[^",]+)(?=\s*,|\s*$)/g); if (!matches || matches.length < 6) continue; const pValStr = matches[2].replace(/^"|"$/g, ''); const key = getImageKey(matches[4].replace(/^"|"$/g, '')); if (key && window.DB_MAP.has(key)) { const data = window.DB_MAP.get(key); let newP = -1; if (pValStr !== '未持有') { newP = parseInt(pValStr, 10); if (isNaN(newP)) newP = -1; } data.possession = newP; updatedCount++; } } window.LAST_UPDATE = Date.now(); saveDB(); renderTable(); alert(`匯入完成!更新 ${updatedCount} 筆。`); // 匯入後清除所有卡片的同步狀態,強制重新同步 document.querySelectorAll('div[data-g8-synced]').forEach(el => el.removeAttribute('data-g8-synced')); applyDbToDom(); }; reader.readAsText(file); this.value = ''; }; // 強制全頁同步 (以 DB 為準覆蓋畫面) document.getElementById('btn-dom-sync').onclick = function() { if(confirm('確定要將畫面所有卡片重置為 DB 紀錄的狀態嗎?')) { // 清除鎖定標記,讓掃描器重新工作 document.querySelectorAll('div[data-g8-synced]').forEach(el => el.removeAttribute('data-g8-synced')); alert('即將開始同步,請勿移動滑鼠。'); applyDbToDom(); } }; // 啟動監控 Loop function startMonitor() { syncBtn.innerText = '2. 監控中 (點擊停止)'; syncBtn.style.background = '#4CAF50'; scanVisibleCards(); monitorTimer = setInterval(scanVisibleCards, 1000); // 縮短間隔以加快反應 } startMonitor(); document.getElementById('btn-sync-view').onclick = function() { if (monitorTimer) { clearInterval(monitorTimer); monitorTimer = null; this.innerText = '2. 暫停中 (點擊啟動)'; this.style.background = '#E91E63'; } else { startMonitor(); } }; // 背景抓取評價 document.getElementById('btn-bg-fetch').onclick = function() { const btn = this; let addedCount = 0; window.DB_MAP.forEach((data, key) => { if (!data.fetched && !QUEUE_SET.has(key)) { QUEUE_SET.add(key); FETCH_QUEUE.push({ key: key, data: data, cardEl: null }); addedCount++; } }); if (addedCount > 0) { btn.innerText = `佇列 +${addedCount}`; processQueue(); setTimeout(() => { btn.innerText = '3. 背景補完評價'; }, 3000); } else { alert('都抓完了'); } }; // 初始化 DB (從頁面爬取清單) document.getElementById('btn-fetch-idx').onclick = function() { if(!confirm('這會重置所有資料,確定嗎?')) return; this.disabled = true; GM_xmlhttpRequest({ method: "GET", url: 'https://game8.jp/umamusume/372188', onload: (res) => { if(res.status===200) { window.DB_MAP.clear(); const doc = new DOMParser().parseFromString(res.responseText, "text/html"); const links = doc.querySelectorAll('table.a-table td.center a.a-link'); let c = 0; links.forEach(link => { const img = link.querySelector('img'); if(!img) return; const alt = img.getAttribute('alt')||""; if(!alt.includes('[')) return; const src = img.getAttribute('data-src') || img.src; const key = getImageKey(src); const title = alt.match(/[(.*?)]/)[1]; if(key) { window.DB_MAP.set(key, { key: key, img: src, cardTitle: title, charName: link.innerText.trim(), url: link.href, rating: '...', fetched: false, possession: -1 }); c++; } }); window.LAST_UPDATE = Date.now(); saveDB(); renderTable(); alert(`重置完成:${c} 筆`); this.disabled = false; } } }); }; // 清空資料 document.getElementById('btn-clear-db').onclick = () => { if(confirm('清空?')) { GM_deleteValue(DB_KEY); window.DB_MAP.clear(); renderTable(); } }; // ========================================== // 6. 核心邏輯:防覆蓋與同步演算法 // ========================================== function getImageKey(url) { if (!url) return null; try { const parts = url.split('/'); for (let i = parts.length - 1; i >= 0; i--) { if (parts[i].match(/\.(png|jpg|jpeg|webp)/)) return parts[i]; } } catch (e) {} return null; } // 主要掃描迴圈 (負責解決 DB 與 DOM 的狀態衝突) function scanVisibleCards() { const cards = document.querySelectorAll('div[class*="style-module__possessionItem___"]'); let dbChanged = false; cards.forEach(card => { const img = card.querySelector('img'); if (!img) return; const key = getImageKey(img.src); if (!key || !window.DB_MAP.has(key)) return; const dbData = window.DB_MAP.get(key); // 取得畫面目前狀態 const notOwnedEl = card.querySelector('div[class*="notPossessedItem"]'); const isOwnedVisual = !notOwnedEl; const currentLevel = isOwnedVisual ? card.querySelectorAll('div[class*="rhombusActive"]').length : -1; // --- 邏輯修正重點 --- // 檢查卡片是否已經「初始化同步」過 (防止渲染覆蓋 DB) if (!card.hasAttribute('data-g8-synced')) { // 狀況 A: 這是這張卡片第一次被腳本看到 (或是被重置過) // 優先權:DB > 畫面 // 檢查 DB 是否有紀錄且與畫面不符 if (dbData.possession !== currentLevel) { // 執行同步動作:將畫面點擊成 DB 的樣子 simulateClick(card, dbData.possession); // 注意:這裡 **不更新 DB**,也不標記 synced,等待下一次迴圈確認同步成功 // 因為點擊是異步的,可能需要幾次 scan 才能完成 } else { // 畫面與 DB 一致了,標記為已同步 card.setAttribute('data-g8-synced', 'true'); updateCardOverlay(card, dbData); // 補上 Overlay // 將卡片加入背景抓取佇列 (如果還沒抓過評價) if (!dbData.fetched && !QUEUE_SET.has(key)) { QUEUE_SET.add(key); FETCH_QUEUE.push({ key: key, data: dbData, cardEl: card }); } } } else { // 狀況 B: 已經同步過的卡片,代表使用者可能在手動修改 // 此時以畫面為主,更新 DB if (dbData.possession !== currentLevel) { dbData.possession = currentLevel; window.LAST_UPDATE = Date.now(); dbChanged = true; // 更新 UI 表格 const tdOwned = document.getElementById(`o-${key}`); const tdPoss = document.getElementById(`p-${key}`); if (tdOwned) tdOwned.innerHTML = (currentLevel !== -1) ? '' : ''; if (tdPoss) tdPoss.innerHTML = (currentLevel !== -1) ? (currentLevel===4 ? '4凸' : `${currentLevel}凸`) : '-'; // 更新 Overlay updateCardOverlay(card, dbData); } } // 隨時確保 Overlay 存在 if (!card.querySelector('.g8-overlay') && dbData.fetched) { updateCardOverlay(card, dbData); } }); if (dbChanged) updateTimeDisplay(); processQueue(); } // 改良版模擬點擊:精準控制凸數 (0~4) function simulateClick(card, targetVal) { // 取得目前狀態 const notOwnedEl = card.querySelector('div[class*="notPossessedItem"]'); const isOwnedVisual = !notOwnedEl; const currentLevel = isOwnedVisual ? card.querySelectorAll('div[class*="rhombusActive"]').length : -1; if (currentLevel === targetVal) return; // 已達成目標 // 視覺回饋:標記正在處理 card.classList.add('g8-syncing'); setTimeout(() => card.classList.remove('g8-syncing'), 500); // 動作 1: 若目標是「未持有」 if (targetVal === -1) { if (isOwnedVisual) { // 通常點擊主圖片會切換持有狀態 const imgBtn = card.querySelector('img'); if (imgBtn) imgBtn.click(); } return; } // 動作 2: 若目前是「未持有」,但目標是「持有」(0~4) if (!isOwnedVisual) { // 點擊遮罩變成持有 (通常變 0凸) if (notOwnedEl) notOwnedEl.click(); // 點完後需要等 React 渲染,這次 function 先結束,交給下一次 Loop 繼續處理凸數 return; } // 動作 3: 調整凸數 (目前持有,目標也持有,但凸數不對) // Game8 結構:通常有一組 div 代表菱形 // 觀察 DOM 結構,菱形通常在 class 包含 object 的 div 內 const diamondContainer = card.querySelector('div[class*="object"]'); if (diamondContainer && diamondContainer.children.length === 4) { const diamonds = diamondContainer.children; if (targetVal === 0) { // 目標 0凸。 // 如果目前 > 0,需要切回 0。 // Game8 介面有時點擊當前凸數會取消?或者只能透過切換未持有重置? // 策略:如果無法直接設為 0,先切成未持有,下一次 Loop 會把它切回持有(預設0) if (currentLevel > 0) { const imgBtn = card.querySelector('img'); if (imgBtn) imgBtn.click(); } } else if (targetVal >= 1 && targetVal <= 4) { // 目標 1~4凸,直接點擊對應的第 N 顆菱形 (index = targetVal - 1) // 這是最準確的方法,不用依賴 "點幾下" const targetDiamond = diamonds[targetVal - 1]; if (targetDiamond) { targetDiamond.click(); } } } else { // 如果找不到菱形結構 (Fallback),使用舊式點擊圖片邏輯 // 但這很不穩,僅作備案 const imgBtn = card.querySelector('img'); if(imgBtn) imgBtn.click(); } } // 觸發全頁同步 function applyDbToDom() { // 這裡不需要額外寫迴圈,只要把 data-g8-synced 拿掉 // scanVisibleCards 下一次執行時就會自動執行 simulateClick document.querySelectorAll('div[data-g8-synced]').forEach(el => el.removeAttribute('data-g8-synced')); // 呼叫一次掃描 scanVisibleCards(); } // 佇列處理器 (處理 HTTP 請求) function processQueue() { if (FETCH_QUEUE.length === 0) { if (ACTIVE_REQUESTS === 0) saveDB(); monQueue.innerText = 0; monActive.innerText = 0; return; } monQueue.innerText = FETCH_QUEUE.length; monActive.innerText = ACTIVE_REQUESTS; while (ACTIVE_REQUESTS < MAX_CONCURRENT && FETCH_QUEUE.length > 0) { const task = FETCH_QUEUE.shift(); QUEUE_SET.delete(task.key); if (task.data.fetched) { if (task.cardEl) updateCardOverlay(task.cardEl, task.data); continue; } ACTIVE_REQUESTS++; GM_xmlhttpRequest({ method: "GET", url: task.data.url, timeout: 10000, onload: (res) => { let rating = '??'; if (res.status === 200) { const doc = new DOMParser().parseFromString(res.responseText, "text/html"); const imgs = doc.querySelectorAll('img'); for (let img of imgs) { const alt = img.getAttribute('alt') || ''; if (alt.startsWith('評価') && alt.includes('画像')) { rating = alt.replace('評価', '').replace('画像', '').trim(); break; } } if (rating === '??') { const tds = doc.querySelectorAll('td'); for (let td of tds) { if (td.innerText.replace(/\s/g, '').includes('圏外')) { rating = '圏外'; break; } } } } task.data.rating = rating; task.data.fetched = true; const tr = document.getElementById(`g8-tr-${task.key}`); if(tr) { tr.children[5].innerText = rating; tr.children[6].innerHTML = 'OK'; } if (task.cardEl) updateCardOverlay(task.cardEl, task.data); }, onloadend: () => { ACTIVE_REQUESTS--; processQueue(); } }); } } })();