
附件
// ==UserScript==
// @name NodeSeek鸡腿变化趋势图
// @namespace http://tampermonkey.net/
// @version 0.6
// @description 在NodeSeek论坛显示鸡腿数量随时间变化的趋势图,饼图和正态分布图,使用ECharts自渲染图表
// @author Your name
// @match https://www.nodeseek.com/credit*
// @grant GM_xmlhttpRequest
// @grant GM_cookie
// @connect nodeseek.com
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js
// ==/UserScript==
(function() {
'use strict';
// 配置项
const CONFIG = {
FETCH_DELAY: 1000, // 每次请求间隔(毫秒)
MAX_PAGES: 10, // 最大获取页数
CHART_HEIGHT: 300, // 减小图表高度到300px
CHART_WIDTH: '100%', // 图表宽度(自适应)
ANIMATION_DURATION: 1000, // 动画持续时间
};
// 样式定义
const STYLES = {
container: {
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
marginBottom: '20px'
},
button: {
padding: '8px 16px',
backgroundColor: '#40a9ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginBottom: '10px',
fontSize: '14px'
},
buttonHover: {
backgroundColor: '#1890ff'
},
buttonDisabled: {
backgroundColor: '#bfbfbf',
cursor: 'not-allowed'
},
progress: {
width: '100%',
height: '6px',
backgroundColor: '#e9ecef',
borderRadius: '3px',
overflow: 'hidden',
marginBottom: '10px'
},
progressBar: {
height: '100%',
backgroundColor: '#40a9ff',
transition: 'width 0.3s ease'
},
status: {
fontSize: '14px',
color: '#666',
marginBottom: '10px'
},
chartContainer: {
width: '100%',
height: `${CONFIG.CHART_HEIGHT}px`,
margin: '10px 0',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
display: 'none' // 初始隐藏图表容器
}
};
// 数据处理类
class CreditDataProcessor {
constructor() {
this.creditData = [];
this.currentPage = 1;
this.totalPages = 1;
this.reasonStats = {
'回帖奖励': 0,
'发帖奖励': 0,
'签到': 0,
'其他': 0
};
this.signInData = new Map(); // 使用Map存储每天的签到数据
}
// 解析HTML内容
parseHtml(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const rows = doc.querySelectorAll('tr[data-v-e2af6d06]');
console.log(`解析第${this.currentPage}页,找到数据行数:`, rows.length);
rows.forEach(row => {
const cells = row.querySelectorAll('td[data-v-e2af6d06]');
if (cells.length >= 4) {
const changeAmount = parseInt(cells[0].textContent.trim()) || 0;
const totalAmount = parseInt(cells[1].textContent.trim()) || 0;
const reason = cells[2].textContent.trim(); // 获取理由
const timeText = cells[3].textContent.trim();
const timestamp = new Date(timeText).getTime();
if (!isNaN(timestamp) && !isNaN(totalAmount)) {
this.creditData.push({
time: timestamp,
value: totalAmount,
change: changeAmount,
reason: reason,
date: new Date(timestamp)
});
}
}
});
// 检查是否有下一页
const pagerButtons = doc.querySelectorAll('button[data-v-e2af6d06]');
const pageNumbers = Array.from(pagerButtons)
.map(btn => parseInt(btn.textContent.trim()))
.filter(num => !isNaN(num));
if (pageNumbers.length > 0) {
this.totalPages = Math.max(...pageNumbers);
}
}
// 聚合数据
aggregateData(data, interval) {
const aggregated = new Map();
data.forEach(item => {
let key;
const date = new Date(item.time);
switch(interval) {
case 'day':
key = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
break;
case 'week':
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
key = new Date(date.getFullYear(), date.getMonth(), diff).getTime();
break;
case 'month':
key = new Date(date.getFullYear(), date.getMonth(), 1).getTime();
break;
}
if (!aggregated.has(key)) {
aggregated.set(key, {
time: key,
value: item.value,
count: 1,
change: item.change || 0 // 添加change字段
});
} else {
const existing = aggregated.get(key);
if (item.value > existing.value) {
existing.value = item.value;
}
existing.count++;
if (item.change) {
existing.change = item.change; // 使用当天的变化值
}
}
});
return Array.from(aggregated.values())
.sort((a, b) => a.time - b.time);
}
// 计算正态分布数据
calculateNormalDistribution(data) {
// 计算平均值
const sum = data.reduce((acc, val) => acc + val, 0);
const mean = sum / data.length;
// 计算标准差
const squareDiffs = data.map(value => {
const diff = value - mean;
return diff * diff;
});
const variance = squareDiffs.reduce((acc, val) => acc + val, 0) / data.length;
const standardDeviation = Math.sqrt(variance);
// 统计每个值的出现次数
const valueCounts = new Map();
data.forEach(value => {
valueCounts.set(value, (valueCounts.get(value) || 0) + 1);
});
// 生成分布数据
const distributionData = Array.from(valueCounts.entries())
.map(([value, count]) => ({
value: value,
count: count,
percentage: (count / data.length) * 100
}))
.sort((a, b) => a.value - b.value);
return {
distribution: distributionData,
mean: mean,
standardDeviation: standardDeviation,
totalSamples: data.length
};
}
// 处理数据,生成图表所需格式
processDataForChart() {
// 按时间排序
this.creditData.sort((a, b) => a.time - b.time);
// 统计不同类型的数据
this.creditData.forEach(item => {
const reason = item.reason?.trim() || "";
if (reason.includes('回帖')) {
this.reasonStats['回帖奖励'] += item.change;
} else if (reason.includes('发帖')) {
this.reasonStats['发帖奖励'] += item.change;
} else if (reason.includes('签到')) {
this.reasonStats['签到'] += item.change;
// 记录签到数据
const match = reason.match(/\d+/);
if (match) {
const signInAmount = parseInt(match[0]);
const dateKey = new Date(item.time).setHours(0, 0, 0, 0);
this.signInData.set(dateKey, signInAmount);
}
} else {
this.reasonStats['其他'] += item.change;
}
});
// 生成日期格式化函数
const formatDate = (timestamp) => {
const date = new Date(timestamp);
return `${date.getMonth() + 1}/${date.getDate()}`;
};
// 生成每日变化数据
const dailyData = this.aggregateData(this.creditData, 'day');
// 生成签到数据数组
const signInValues = Array.from(this.signInData.values());
const normalDistribution = this.calculateNormalDistribution(signInValues);
// 生成饼图数据
const pieData = Object.entries(this.reasonStats).map(([name, value]) => ({
name,
value
}));
return {
daily: {
times: dailyData.map(item => formatDate(item.time)),
values: dailyData.map(item => item.value)
},
signInStats: normalDistribution,
pie: pieData
};
}
}
// 图表类
class CreditChart {
constructor(container) {
this.trendChart = echarts.init(container.trend);
this.pieChart = echarts.init(container.pie);
this.distributionChart = echarts.init(container.distribution);
// 添加窗口大小变化监听
window.addEventListener('resize', () => {
this.trendChart.resize();
this.pieChart.resize();
this.distributionChart.resize();
});
}
// 渲染图表
render(data, instant = false) {
// 显示图表容器
this.trendChart.getDom().style.display = 'block';
this.pieChart.getDom().style.display = 'block';
this.distributionChart.getDom().style.display = 'block';
// 配置趋势图
const trendOption = {
title: {
text: '鸡腿变化趋势图',
left: 'center',
textStyle: {
fontSize: 18,
color: '#333'
}
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333'
}
},
xAxis: {
type: 'category',
data: data.daily.times,
axisLabel: {
rotate: 45,
interval: 'auto',
fontSize: 10,
color: '#666'
},
axisLine: {
lineStyle: {
color: '#ddd'
}
}
},
yAxis: {
type: 'value',
name: '鸡腿总量',
nameTextStyle: {
color: '#666'
},
axisLine: {
show: true,
lineStyle: {
color: '#ddd'
}
},
splitLine: {
lineStyle: {
color: '#eee'
}
}
},
series: [{
name: '鸡腿总量',
type: 'line',
data: data.daily.values,
smooth: true,
showSymbol: false,
lineStyle: {
width: 2,
color: '#40a9ff'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{
offset: 0,
color: 'rgba(64,169,255,0.3)'
}, {
offset: 1,
color: 'rgba(64,169,255,0.05)'
}])
}
}]
};
// 配置饼图
const pieOption = {
title: {
text: '鸡腿获取来源分布',
left: 'center',
textStyle: {
fontSize: 18,
color: '#333'
}
},
tooltip: {
trigger: 'item',
formatter: '{b}: {d}%'
},
legend: {
orient: 'horizontal',
bottom: '0%',
data: data.pie.map(item => item.name),
textStyle: {
fontSize: 12,
color: '#666'
}
},
series: [{
name: '来源',
type: 'pie',
radius: ['40%', '60%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '14',
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: data.pie
}]
};
// 配置正态分布图
const distributionOption = {
title: {
text: '签到鸡腿分布图',
subtext: [
`平均值: ${data.signInStats.mean.toFixed(1)}`,
`标准差: ${data.signInStats.standardDeviation.toFixed(1)}`,
`样本数: ${data.signInStats.totalSamples}`
].join('\n'),
left: 'center',
textStyle: {
fontSize: 18,
color: '#333'
},
subtextStyle: {
color: '#666',
fontSize: 12,
lineHeight: 18
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: function(params) {
const item = params[0];
return `获得${item.name}个鸡腿<br>出现次数:${item.value}<br>占比:${item.data.percentage.toFixed(1)}%`;
}
},
grid: {
top: '25%', // 为标题和副标题留出更多空间
left: '10%',
right: '10%',
bottom: '15%'
},
xAxis: {
type: 'category',
data: data.signInStats.distribution.map(item => item.value),
name: '获得鸡腿数',
nameTextStyle: {
color: '#666',
fontSize: 12,
padding: [10, 0, 0, 0]
},
axisLabel: {
fontSize: 10,
color: '#666'
},
axisLine: {
lineStyle: {
color: '#ddd'
}
}
},
yAxis: {
type: 'value',
name: '出现次数',
nameTextStyle: {
color: '#666',
fontSize: 12,
padding: [0, 0, 0, 30]
},
axisLine: {
show: true,
lineStyle: {
color: '#ddd'
}
},
splitLine: {
lineStyle: {
color: '#eee'
}
}
},
series: [{
name: '出现次数',
type: 'bar',
data: data.signInStats.distribution.map(item => ({
value: item.count,
percentage: item.percentage
})),
itemStyle: {
color: '#73d13d'
},
emphasis: {
itemStyle: {
color: '#95de64'
}
},
label: {
show: true,
position: 'top',
formatter: '{c}',
fontSize: 10,
color: '#666'
}
}]
};
// 设置图表配置
this.trendChart.setOption(trendOption);
this.pieChart.setOption(pieOption);
this.distributionChart.setOption(distributionOption);
// 强制触发一次resize以确保图表正确渲染
this.trendChart.resize();
this.pieChart.resize();
this.distributionChart.resize();
}
// 清除图表
clear() {
this.trendChart.clear();
this.pieChart.clear();
this.distributionChart.clear();
// 隐藏图表容器
this.trendChart.getDom().style.display = 'none';
this.pieChart.getDom().style.display = 'none';
this.distributionChart.getDom().style.display = 'none';
}
}
// UI控制器类
class UIController {
constructor() {
this.container = null;
this.button = null;
this.progressBar = null;
this.statusText = null;
this.chartContainers = null;
}
// 创建UI元素
createUI() {
// 创建主容器
this.container = document.createElement('div');
Object.assign(this.container.style, STYLES.container);
// 创建按钮
this.button = document.createElement('button');
Object.assign(this.button.style, STYLES.button);
this.button.textContent = '开始统计鸡腿变化';
this.button.addEventListener('mouseover', () => {
if (!this.button.disabled) {
Object.assign(this.button.style, STYLES.buttonHover);
}
});
this.button.addEventListener('mouseout', () => {
if (!this.button.disabled) {
Object.assign(this.button.style, STYLES.button);
}
});
// 创建进度条容器
const progressContainer = document.createElement('div');
Object.assign(progressContainer.style, STYLES.progress);
// 创建进度条
this.progressBar = document.createElement('div');
Object.assign(this.progressBar.style, STYLES.progressBar);
this.progressBar.style.width = '0%';
progressContainer.appendChild(this.progressBar);
// 创建状态文本
this.statusText = document.createElement('div');
Object.assign(this.statusText.style, STYLES.status);
this.statusText.textContent = '点击按钮开始统计';
// 创建图表容器
this.chartContainers = {
trend: document.createElement('div'),
pie: document.createElement('div'),
distribution: document.createElement('div')
};
// 创建图表容器的包装器
const chartsWrapper = document.createElement('div');
chartsWrapper.style.marginTop = '20px';
Object.keys(this.chartContainers).forEach(key => {
const chartDiv = this.chartContainers[key];
Object.assign(chartDiv.style, STYLES.chartContainer);
chartDiv.id = `credit-chart-${key}`;
chartsWrapper.appendChild(chartDiv);
});
// 组装UI
this.container.insertBefore(this.button, this.container.firstChild);
this.container.insertBefore(this.statusText, this.button.nextSibling);
this.container.insertBefore(progressContainer, this.statusText.nextSibling);
this.container.appendChild(chartsWrapper);
// 插入到页面
const targetElement = document.querySelector('.credit-table');
if (targetElement) {
targetElement.parentNode.insertBefore(this.container, targetElement);
}
return this.chartContainers;
}
// 更新进度
updateProgress(current, total) {
const percentage = (current / total) * 100;
this.progressBar.style.width = `${percentage}%`;
this.statusText.textContent = `正在获取数据... ${current}/${total} 页`;
}
// 更新状态
updateStatus(status) {
this.statusText.textContent = status;
}
// 禁用按钮
disableButton() {
this.button.disabled = true;
Object.assign(this.button.style, STYLES.buttonDisabled);
this.button.textContent = '正在统计中...';
}
// 启用按钮
enableButton() {
this.button.disabled = false;
Object.assign(this.button.style, STYLES.button);
this.button.textContent = '开始统计鸡腿变化';
}
}
// 控制器
class CreditController {
constructor() {
this.processor = new CreditDataProcessor();
this.ui = new UIController();
this.chartContainer = this.ui.createUI();
this.chart = new CreditChart(this.chartContainer);
this.bindEvents();
this.currentUrl = new URL(window.location.href);
}
// 绑定事件
bindEvents() {
this.ui.button.addEventListener('click', () => {
this.startProcessing();
});
}
// 更新URL并触发页面加载
async navigateToPage(page) {
// 更新URL的页码
this.currentUrl.hash = `/p-${page}`;
window.location.hash = `/p-${page}`;
// 等待页面数据加载
await new Promise(resolve => setTimeout(resolve, CONFIG.FETCH_DELAY));
// 等待表格元素出现
let retries = 0;
while (retries < 5) {
const rows = document.querySelectorAll('tr[data-v-e2af6d06]');
if (rows.length > 0) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 500));
retries++;
}
return false;
}
// 获取当前页面数据
parseCurrentPage() {
const rows = document.querySelectorAll('tr[data-v-e2af6d06]');
const data = [];
rows.forEach(row => {
const cells = row.querySelectorAll('td[data-v-e2af6d06]');
if (cells.length >= 4) {
const changeAmount = parseInt(cells[0].textContent.trim()) || 0;
const totalAmount = parseInt(cells[1].textContent.trim()) || 0;
const reason = cells[2].textContent.trim(); // 获取理由
const timeText = cells[3].textContent.trim();
const timestamp = new Date(timeText).getTime();
if (!isNaN(timestamp) && !isNaN(totalAmount)) {
data.push({
time: timestamp,
value: totalAmount,
change: changeAmount,
reason: reason,
date: new Date(timestamp)
});
}
}
});
return data;
}
// 获取最大页数
getMaxPages() {
const pagerLinks = document.querySelectorAll('a[class*="pager-pos"]');
let maxPage = 1;
pagerLinks.forEach(link => {
const href = link.getAttribute('href');
if (href) {
const match = href.match(/\/p-(\d+)/);
if (match) {
const pageNum = parseInt(match[1]);
if (!isNaN(pageNum) && pageNum > maxPage) {
maxPage = pageNum;
}
}
}
});
console.log('检测到最大页数:', maxPage);
return maxPage;
}
// 开始处理数据
async startProcessing() {
this.ui.disableButton();
this.chart.clear();
this.processor = new CreditDataProcessor();
try {
// 获取实际的最大页数
const totalPages = this.getMaxPages();
if (totalPages < 1) {
throw new Error('无法获取总页数');
}
console.log('开始处理,总页数:', totalPages);
let currentPage = 1;
let successCount = 0;
let failCount = 0;
// 处理所有页面
while (currentPage <= totalPages) {
this.ui.updateProgress(currentPage, totalPages);
this.ui.updateStatus(`正在获取第${currentPage}页数据...(成功:${successCount},失败:${failCount})`);
// 导航到指定页面
if (await this.navigateToPage(currentPage)) {
const currentData = this.parseCurrentPage();
if (currentData.length > 0) {
this.processor.creditData.push(...currentData);
console.log(`已处理第${currentPage}页,获取到${currentData.length}条数据`);
successCount++;
} else {
console.warn(`第${currentPage}页未获取到数据`);
failCount++;
}
} else {
console.error(`无法加载第${currentPage}页数据`);
failCount++;
}
currentPage++;
// 添加延迟避免过快请求
await new Promise(resolve => setTimeout(resolve, 500));
}
if (this.processor.creditData.length === 0) {
throw new Error('未能获取到任何数据');
}
const chartData = this.processor.processDataForChart();
this.ui.updateStatus('数据解析完成,正在绘制图表...');
// 渲染图表
this.chart.render(chartData);
this.ui.updateStatus(`统计完成!共处理${this.processor.creditData.length}条数据(成功:${successCount}页,失败:${failCount}页)`);
// 恢复第一页
await this.navigateToPage(1);
} catch (error) {
console.error('处理数据失败:', error);
this.ui.updateStatus(`处理数据失败:${error.message}`);
} finally {
this.ui.enableButton();
}
}
}
// 初始化
function init() {
// 检查是否在鸡腿页面
if (window.location.pathname.includes('/credit')) {
console.log('初始化鸡腿统计图表...');
// 等待Vue.js渲染完成
setTimeout(() => {
new CreditController();
}, 1000);
} else {
console.log('不在鸡腿页面,当前路径:', window.location.pathname);
}
}
// 等待页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
@kurssy #17 @KefIe #15
其实咱早就搞了,没想到被你们这些“穿越者”偷跑了,等官方发布吧
绑定
好技术 同用鸡腿页面做NS年终总结
太牛了
nb
@KefIe #2 你居然做过了
,想法不约而同了,感觉可以把功能整合一下
要是有个链接点击就能看到就好了
最后应该计算下平均数,看是不是约等于5
@shuai #6 好早有这想法就是鸡腿页面有个类似githubstar增长数的那种图
@shuai #6 要不管理教下如果过cf盾