// ==UserScript== // @name MT菠菜数据分析 // @namespace https://scriptcat.org/zh-CN/script-show-page/3548 // @version 3.0 // @description 适配网页变动(ant-design新结构+多重容错) // @author C408 // @match https://*.m-team.cc/* // @grant GM_addStyle // @grant GM_notification // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js // ==/UserScript== (function() { 'use strict'; // ======================= 配置 ======================= const CONFIG = { CHART_COLORS: ['#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#E53935', '#8BC34A', '#3ad', '#d8a520', '#8B008B', '#B8860B'], USER_LEVELS: [ { color: 'rgb(0, 159, 0)', name: 'Vip', order: 9 }, { color: 'rgb(236, 56, 78)', name: '大臣/mTorrent Master', order: 8 }, { color: 'rgb(0, 100, 0)', name: '總督/Ultimate User', order: 7 }, { color: 'rgb(255, 140, 0)', name: '府尹/Extreme User', order: 6 }, { color: 'rgb(72, 61, 139)', name: '府丞/Veteran User', order: 5 }, { color: 'rgb(139, 0, 139)', name: '知州/Insane User', order: 4 }, { color: 'rgb(0, 191, 255)', name: '通判/Crazy User', order: 3 }, { color: 'rgb(0, 139, 139)', name: '知縣/Elite User', order: 2 }, { color: 'rgb(218, 165, 32)', name: '捕頭/Power User', order: 1 }, { color: 'rgb(51, 51, 51)', name: '小卒/User', order: 0 } ], VISIBLE_LEVELS: 5 }; // ======================= 样式(压缩) ======================= GM_addStyle(` .bet-stats-container{position:fixed;top:50%;right:20px;transform:translateY(-50%);width:450px;background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:15px;box-shadow:0 4px 12px rgba(0,0,0,.15);z-index:9999;font-family:Segoe UI,Arial,sans-serif;max-height:85vh;overflow-y:auto;transition:opacity .3s;opacity:0;visibility:hidden} .bet-stats-container.visible{opacity:1;visibility:visible} .bet-stats-title{font-weight:600;margin-bottom:12px;font-size:18px;color:#2c3e50;text-align:center;padding-bottom:8px;border-bottom:2px solid #f0f3f6} .bet-stats-table{width:100%;border-collapse:collapse;margin:12px 0;font-size:14px} .bet-stats-table th,.bet-stats-table td{padding:8px 12px;border-bottom:1px solid #e9ecef} .bet-stats-table th{background:#f8f9fa;color:#495057} .bet-stats-table tr:hover td{background:#f8f9fa} .chart-container{margin:16px 0;position:relative;height:220px} .bet-stats-summary{padding:12px;background:#f8f9fa;border-radius:6px;margin-top:16px;font-size:13px} .highlight{color:#2c3e50;font-weight:600} .analyze-btn{position:fixed;top:50%;right:20px;transform:translateY(-50%);z-index:10000;padding:10px 20px;background:#4CAF50;color:#fff;border:none;border-radius:25px;cursor:pointer;font-size:14px;box-shadow:0 3px 8px rgba(76,175,80,.3);transition:all .3s;opacity:.4} .analyze-btn:hover{transform:translateY(-50%) translateY(-2px);box-shadow:0 5px 12px rgba(76,175,80,.4);opacity:1} .analyze-btn:disabled{background:#bdc3c7;cursor:not-allowed} .analyze-btn.hidden{opacity:0;visibility:hidden;transform:translateY(-50%) translateX(20px)} .close-btn{position:absolute;top:8px;right:8px;width:28px;height:28px;border-radius:50%;background:#f8f9fa;border:none;color:#6c757d;cursor:pointer;transition:all .2s;font-size:16px;line-height:1} .close-btn:hover{background:#e9ecef;transform:rotate(90deg)} .top-bettors-panel{margin-top:16px;border:1px solid #e0e0e0;border-radius:6px;overflow:hidden} .top-bettors-header{padding:10px 15px;background:#f8f9fa;cursor:pointer;font-weight:600;display:flex;justify-content:space-between;align-items:center} .top-bettors-header:hover{background:#e9ecef} .top-bettors-content{padding:0;max-height:0;overflow:hidden;transition:max-height .3s} .top-bettors-content.expanded{max-height:1000px;padding:10px} .top-bettors-option{margin-bottom:10px} .top-bettors-option-title{font-weight:600;margin-bottom:5px;color:#2c3e50} .top-bettors-list{list-style:none;padding-left:15px;margin:0} .top-bettors-list li{margin-bottom:3px;display:flex;justify-content:space-between} .toggle-icon{transition:transform .2s;margin-left:8px} .toggle-icon.expanded{transform:rotate(180deg)} .color-sample{display:inline-block;width:12px;height:12px;border-radius:2px;margin-right:6px;vertical-align:middle} .color-stats-table{margin-top:16px;width:100%;border-collapse:collapse;font-size:13px} .color-stats-table th,.color-stats-table td{padding:8px 12px;border-bottom:1px solid #e9ecef} .color-stats-table th{background:#f8f9fa;text-align:left} .color-stats-table tr:hover td{background:#f8f9fa} .option-header{background:#e9ecef!important;font-weight:bold} .level-name{white-space:nowrap} .collapsed-extra-levels-row{display:none} .collapsed-extra-levels-row.show{display:table-row} .collapse-toggle-row{cursor:pointer;background:#fff7e0!important;font-weight:600;user-select:none} .collapse-toggle-arrow{display:inline-block;width:15px;margin-right:6px;color:#f39c12;transition:transform .25s} .collapse-toggle-arrow.opened{transform:rotate(90deg)} `); // ======================= 工具函数 ======================= const getUserColor = (el) => { const span = el?.querySelector('[style*="color"]'); if (!span) return null; const m = span.style.color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); return m ? `rgb(${m[1]}, ${m[2]}, ${m[3]})` : null; }; const getUserName = (el) => { const e = el?.querySelector('a span, strong'); return e ? e.textContent.trim() : (el?.textContent.trim() || ''); }; const isValidRow = (cols) => /\d{4}-\d{2}-\d{2}/.test(cols[0].textContent) && cols[3].textContent.replace(/,/g, '').match(/^\d+$/); // ======================= 核心解析逻辑 ======================= const BetAnalyzer = { getUserLevel: (color) => { if (!color) return { name: '未知', order: -1 }; return CONFIG.USER_LEVELS.find(l => l.color.toLowerCase() === color.toLowerCase()) || { name: '未知', order: -1 }; }, parseData: () => { const data = []; // ---- 策略1 & 2:结构化选择器 ---- const strategies = [ { container: '.ant-list-items', row: '.ant-row', cols: ':scope > .ant-col' }, { container: '.ant-list-items', row: null, cols: ':scope > *' } ]; for (const s of strategies) { const container = document.querySelector(s.container); if (!container) continue; const entries = container.querySelectorAll(s.row ? ':scope > li, :scope > div' : ':scope > *'); for (const entry of entries) { const row = s.row ? entry.querySelector(s.row) : entry; if (!row) continue; const cols = row.querySelectorAll(s.cols); if (cols.length < 4 || !isValidRow(cols)) continue; const opt = cols[2].textContent.trim().replace(/^选项/, ''); const magic = parseInt(cols[3].textContent.replace(/,/g, ''), 10) || 0; if (!opt || !magic) continue; data.push({ time: cols[0].textContent.trim(), user: getUserName(cols[1]), userColor: getUserColor(cols[1]), userLevel: BetAnalyzer.getUserLevel(getUserColor(cols[1])), option: opt, magic }); } if (data.length > 0) return data; } // ---- 策略3:TreeWalker 全文扫描(兜底) ---- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode: (node) => /\d{4}-\d{2}-\d{2}/.test(node.textContent.trim()) && node.textContent.trim().length < 30 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP }); const seen = new Set(); let tn; while ((tn = walker.nextNode())) { const timeText = tn.textContent.trim(); if (seen.has(timeText)) continue; seen.add(timeText); const sibs = []; let s = tn.parentElement?.nextElementSibling; for (let i = 0; i < 3 && s; i++) { sibs.push(s); s = s.nextElementSibling; } if (sibs.length < 3) continue; const opt = sibs[1].textContent.trim().replace(/^选项/, ''); const magic = parseInt(sibs[2].textContent.replace(/,/g, ''), 10) || 0; if (!opt || !magic) continue; data.push({ time: timeText, user: getUserName(sibs[0]), userColor: getUserColor(sibs[0]), userLevel: BetAnalyzer.getUserLevel(getUserColor(sibs[0])), option: opt, magic }); } return data; }, calculateStats: (data) => { const stats = {}; data.forEach(({ option, magic, user, userColor, userLevel }) => { if (!stats[option]) stats[option] = { count: 0, total: 0, topBettors: [], levelStats: {} }; stats[option].count++; stats[option].total += magic; stats[option].topBettors.push({ user, magic, color: userColor, level: userLevel }); const ln = userLevel.name; if (!stats[option].levelStats[ln]) stats[option].levelStats[ln] = { count: 0, order: userLevel.order }; stats[option].levelStats[ln].count++; }); Object.values(stats).forEach(s => { s.topBettors.sort((a, b) => b.magic - a.magic); s.topBettors.length = 3; }); return { stats, totalBets: data.length, totalMagic: data.reduce((s, b) => s + b.magic, 0) }; } }; // ======================= UI ======================= const UI = { makeLevelRow: (level, stats) => { let total = 0; const tds = Object.values(stats).map(o => { const c = o.levelStats[level.name]?.count || 0; total += c; return c || '-'; }); return `${level.name.split('/')[0]}${tds.map(t => `${t}`).join('')}${total || '-'}`; }, createPanel: (stats, total, btn) => { const sorted = Object.entries(stats).sort((a, b) => b[1].count - a[1].count); const main = sorted[0]; const panel = document.createElement('div'); panel.className = 'bet-stats-container'; panel.innerHTML = `
📊 投注分析报告 (按人数)
${sorted.map(([o, d]) => ``).join('')}
选项人数占比总魔力人均
${o}${d.count}${(d.count/total.totalBets*100).toFixed(1)}%${d.total.toLocaleString()}${Math.round(d.total/d.count).toLocaleString()}
🏆 主力选项:${main[0]} (${main[1].count}人, ${(main[1].count/total.totalBets*100).toFixed(1)}%)
📈 总投注:${total.totalBets} 人 / ${total.totalMagic.toLocaleString()} 魔力
💰 平均魔力:${Math.round(total.totalMagic/total.totalBets).toLocaleString()}
🎨 用户等级分布 (按选项)
${Object.keys(stats).map(o => ``).join('')}
等级${o}总计
`; document.body.appendChild(panel); // 填充等级表格 const ordered = [...CONFIG.USER_LEVELS].sort((a, b) => b.order - a.order); const vis = ordered.slice(0, CONFIG.VISIBLE_LEVELS); const hidden = ordered.slice(CONFIG.VISIBLE_LEVELS); const tbody = panel.querySelector('#color-tbody'); vis.forEach(l => { tbody.innerHTML += UI.makeLevelRow(l, stats); }); if (hidden.length) { const rows = hidden.map(l => UI.makeLevelRow(l, stats)); tbody.innerHTML += ` 展示更多等级${rows.map(r => `${r}`).join('')}`; } tbody.innerHTML += `总计${Object.values(stats).map(o => `${Object.values(o.levelStats).reduce((s,v)=>s+v.count,0)}`).join('')}${total.totalBets}`; // 圆环图 new window.Chart(panel.querySelector('canvas') || (() => { const c = document.createElement('canvas'); panel.querySelector('.chart-container').appendChild(c); return c; })(), { type: 'doughnut', data: { labels: Object.keys(stats), datasets: [{ data: Object.values(stats).map(s => s.count), backgroundColor: CONFIG.CHART_COLORS, borderWidth: 0 }] }, options: { cutout: '60%', responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } } } }); // 投注大户面板 const tp = panel.querySelector('#top-panel'); tp.innerHTML = `
📊 各选项投注大户 (点击展开)
${Object.entries(stats).map(([o, d]) => d.topBettors.length ? `
选项 ${o}
` : '').join('')}
`; // 关闭 panel.querySelector('.close-btn').onclick = () => { panel.style.opacity = '0'; setTimeout(() => { panel.remove(); btn.style.opacity = '0.4'; btn.style.visibility = 'visible'; }, 300); }; setTimeout(() => { panel.classList.add('visible'); btn.style.visibility = 'hidden'; }, 10); } }; // ======================= 单例锁(防止刷新后旧实例重复启动) ======================= const LOCK_KEY = '__bet_lock'; if (sessionStorage.getItem(LOCK_KEY) === '1') { // 旧实例还在跑,等它结束 sessionStorage.setItem(LOCK_KEY, '2'); } else { sessionStorage.setItem(LOCK_KEY, '1'); } const cleanup = () => { sessionStorage.removeItem(LOCK_KEY); }; window.addEventListener('beforeunload', cleanup); setTimeout(() => { if (sessionStorage.getItem(LOCK_KEY) === '1') sessionStorage.removeItem(LOCK_KEY); }, 15000); // ======================= 主控制 ======================= const App = { analyze: () => { const btn = document.getElementById('analyze-btn'); btn.disabled = true; btn.textContent = '⏳ 分析中...'; try { const raw = BetAnalyzer.parseData(); if (!raw.length) throw new Error('未找到有效投注数据'); const { stats, ...total } = BetAnalyzer.calculateStats(raw); UI.createPanel(stats, total, btn); GM_notification({ title: '✅ 分析完成', text: `成功处理 ${raw.length} 条记录`, timeout: 2000 }); } catch (e) { GM_notification({ title: '❌ 分析失败', text: e.message, timeout: 3000 }); } finally { btn.disabled = false; btn.textContent = '🔍 重新分析'; } }, checkExists: () => !!document.querySelector('.ant-list-items,[class*="bet"],[class*="record"]') || /\d{4}-\d{2}-\d{2}.*\d{4}-\d{2}-\d{2}/.test(document.body.textContent), init: () => { // 强制单例:只允许一个实例运行 if (sessionStorage.getItem(LOCK_KEY) === '2') { sessionStorage.removeItem(LOCK_KEY); return; } // 清理残留(解决刷新后旧实例重复启动问题) document.querySelectorAll('.bet-stats-container').forEach(el => el.remove()); const oldBtn = document.getElementById('analyze-btn'); if (oldBtn) { oldBtn.remove(); // 旧按钮已清,不重复添加,等下轮 checkExists 再决定 if (App.checkExists()) { App.addBtn(); return; } } else { if (App.checkExists()) { App.addBtn(); return; } } const obs = new MutationObserver(() => { if (App.checkExists()) { App.addBtn(); obs.disconnect(); } }); obs.observe(document.body, { childList: true, subtree: true }); let c = 0; const iv = setInterval(() => { if (App.checkExists() || ++c > 20) { if (App.checkExists()) App.addBtn(); clearInterval(iv); } }, 500); }, addBtn: () => { if (document.getElementById('analyze-btn')) return; // 已有按钮勿重复添加 const btn = document.createElement('button'); btn.id = 'analyze-btn'; btn.className = 'analyze-btn'; btn.textContent = '🔍 开始分析'; btn.onclick = App.analyze; document.body.appendChild(btn); } }; document.readyState === 'complete' ? App.init() : window.addEventListener('load', App.init); })();