Bitcointalk Monthly Stats

Insert stats box right below profile tabs inside #bodyarea on Bitcointalk profile page

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bitcointalk Monthly Stats
// @namespace    https://bitcointalk.org
// @version      1.1
// @description  Insert stats box right below profile tabs inside #bodyarea on Bitcointalk profile page
// @author       Ace
// @match        https://bitcointalk.org/index.php?action=profile*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const boxId = 'monthlyStatsBox';
  const now = new Date();
  let currentMonthOffset = 0;

  function pad(n) {
    return n.toString().padStart(2, '0');
  }

  function addOneDayWithRandomSeconds(dateString) {
    const d = new Date(dateString);
    d.setDate(d.getDate() + 1);
    const seconds = pad(Math.floor(Math.random() * 59) + 1);
    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:${seconds}`;
  }

  function getDateRange(monthOffset = 0) {
    const date = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1);
    const year = date.getFullYear();
    const month = date.getMonth() + 1;
    const firstDay = `${year}-${pad(month)}-01`;
    const lastDay = new Date(year, month, 0).getDate();
    const lastDate = `${year}-${pad(month)}-${pad(lastDay)}`;
    const label = `${date.toLocaleString('en', { month: 'long' })} ${year}`;
    return { from: firstDay, to: lastDate, label, y: year, m: month };
  }

  function extractUidAndUsername() {
    const uidMatch = location.href.match(/u=(\d+)/);
    const uid = uidMatch ? uidMatch[1] : null;
    const nameRow = Array.from(document.querySelectorAll('td')).find(td => td.textContent.trim() === 'Name:');
    const username = nameRow ? nameRow.nextElementSibling.textContent.trim() : null;
    return { uid, username };
  }

  async function fetchBoardData(username, from, to) {
    const url = `https://api.ninjastic.space/users/${username}/boards?from=${from}T00:00:00&to=${addOneDayWithRandomSeconds(to)}`;
    const res = await fetch(url);
    const json = await res.json();
    if (json.result !== 'success') return null;
    return json.data;
  }

  async function fetchMerit(type, username, y, m) {
    const from = `${y}-${pad(m)}-01`;
    const to = `${y}-${pad(m)}-${pad(new Date(y, m, 0).getDate())}`;
    const param = type === 'received' ? 'to' : 'from';
    const url = `https://api.allorigins.win/raw?url=${encodeURIComponent(`https://bpip.org/smerit.aspx?&${param}=${username}&start=${from}&end=${to}`)}`;
    const res = await fetch(url);
    const htmlText = await res.text();
    const doc = new DOMParser().parseFromString(htmlText, 'text/html');
    const rows = Array.from(doc.querySelectorAll('table tbody tr'));
    if (!rows.length) return null;
    const data = {};
    let total = 0;
    rows.forEach(tr => {
      const tds = tr.querySelectorAll('td');
      if (tds.length >= 4) {
        let name = tds[type === 'received' ? 1 : 2].innerText.trim();
        name = name.replace(/\s*\(Summary\)$/, '');
        const count = parseInt(tds[3].innerText.trim()) || 0;
        total += count;
        data[name] = (data[name] || 0) + count;
      }
    });
    return { total, data };
  }

  function createBox() {
    let existing = document.getElementById(boxId);
    if (existing) return existing;

    const container = document.createElement('div');
    container.id = boxId;
    container.style.background = '#222';
    container.style.color = '#fff';
    container.style.padding = '12px';
    container.style.marginTop = '10px';
    container.style.borderRadius = '12px';
    container.style.fontSize = '13px';
    container.style.maxWidth = '700px';
    container.style.boxShadow = '0 0 8px rgba(0,0,0,0.6)';
    container.style.fontFamily = 'Arial, sans-serif';

    const toggleBtn = document.createElement('button');
    toggleBtn.textContent = 'Hide Stats';
    toggleBtn.style.marginBottom = '10px';
    toggleBtn.style.padding = '5px 10px';
    toggleBtn.style.border = 'none';
    toggleBtn.style.borderRadius = '8px';
    toggleBtn.style.background = '#555';
    toggleBtn.style.color = '#fff';
    toggleBtn.style.cursor = 'pointer';
    toggleBtn.onclick = () => {
      if (container.style.display !== 'none') {
        container.style.display = 'none';
        toggleBtn.textContent = 'Show Stats';
      } else {
        container.style.display = 'block';
        toggleBtn.textContent = 'Hide Stats';
      }
    };
    container.appendChild(toggleBtn);

    const statsContent = document.createElement('div');
    statsContent.id = `${boxId}-content`;
    statsContent.innerHTML = 'Loading...';
    container.appendChild(statsContent);

    const nav = document.createElement('div');
    nav.style.marginTop = '8px';
    nav.style.display = 'flex';
    nav.style.justifyContent = 'space-between';

    const prevBtn = document.createElement('button');
    prevBtn.textContent = '← Previous Month';
    prevBtn.style.flex = '1';
    prevBtn.style.marginRight = '4px';
    prevBtn.style.padding = '6px';
    prevBtn.style.border = 'none';
    prevBtn.style.borderRadius = '6px';
    prevBtn.style.background = '#444';
    prevBtn.style.color = '#fff';
    prevBtn.style.cursor = 'pointer';
    prevBtn.onclick = () => {
      currentMonthOffset--;
      renderStats();
    };

    const nextBtn = document.createElement('button');
    nextBtn.textContent = 'Next Month →';
    nextBtn.style.flex = '1';
    nextBtn.style.marginLeft = '4px';
    nextBtn.style.padding = '6px';
    nextBtn.style.border = 'none';
    nextBtn.style.borderRadius = '6px';
    nextBtn.style.background = '#444';
    nextBtn.style.color = '#fff';
    nextBtn.style.cursor = 'pointer';
    nextBtn.onclick = () => {
      if (currentMonthOffset < 0) {
        currentMonthOffset++;
        renderStats();
      }
    };

    nav.appendChild(prevBtn);
    nav.appendChild(nextBtn);
    container.appendChild(nav);

    const bodyarea = document.getElementById('bodyarea');
    if (bodyarea) {
      bodyarea.insertBefore(container, bodyarea.firstChild);
    } else {
      // fallback
      document.body.insertBefore(container, document.body.firstChild);
    }

    return container;
  }

  async function renderStats() {
    const { uid, username } = extractUidAndUsername();
    if (!uid || !username) return;

    const box = createBox();
    const content = document.getElementById(`${boxId}-content`);
    content.innerHTML = '📊 Loading monthly data...';

    const { from, to, label, y, m } = getDateRange(currentMonthOffset);
    const boardData = await fetchBoardData(username, from, to);
    const meritReceived = await fetchMerit('received', username, y, m);
    const meritSent = await fetchMerit('sent', username, y, m);

    if (!boardData) {
      content.innerHTML = '❌ Error loading posts.';
      return;
    }

    let html = `🧮 <b>Statistics for ${label} – ${username}</b><br><br>`;
    html += `📝 <b>Posts written:</b> ${boardData.total_results_with_board}<br>`;
    boardData.boards.forEach(b => {
      html += `• ${b.name}: ${b.count}<br>`;
    });

    if (!meritReceived) {
      html += `<br>⭐ <b>Merits received:</b> Loading error.`;
    } else {
      html += `<br>⭐ <b>Merits received:</b> ${meritReceived.total}<br>`;
      Object.entries(meritReceived.data).sort((a, b) => b[1] - a[1]).forEach(([name, count]) => {
        html += `• ${name}: ${count}<br>`;
      });
    }

    if (!meritSent) {
      html += `<br>🎁 <b>Merits sent:</b> Loading error.`;
    } else {
      html += `<br>🎁 <b>Merits sent:</b> ${meritSent.total}<br>`;
      Object.entries(meritSent.data).sort((a, b) => b[1] - a[1]).forEach(([name, count]) => {
        html += `• ${name}: ${count}<br>`;
      });
    }

    content.innerHTML = html;
  }

  if (location.href.includes('action=profile')) {
    renderStats();
  }
})();