// ==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]) => `| ${o} | ${d.count} | ${(d.count/total.totalBets*100).toFixed(1)}% | ${d.total.toLocaleString()} | ${Math.round(d.total/d.count).toLocaleString()} |
`).join('')}
🏆 主力选项:${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 => `${o} | `).join('')}总计 |
`;
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 => ``).join('')}`;
}
tbody.innerHTML += ``;
// 圆环图
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}
${d.topBettors.map(b => `- ${b.color?``:''}${b.user} (${b.level.name.split('/')[0]}) ${b.magic.toLocaleString()} 魔力
`).join('')}
` : '').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);
})();