Bitcoin Forum
September 27, 2026, 02:49:39 PM *
News: Latest Bitcoin Core release: 31.1 [Torrent]
 
   Home   Help Search Login Register More  
Pages: [1]
  Print  
Author Topic: [Userscript] BitcoinTalk Merit History Plotter.  (Read 107 times)
satscraper (OP)
Legendary
*
Offline

Activity: 1596
Merit: 3012



View Profile
Today at 06:42:52 AM
Last edit: Today at 08:54:02 AM by satscraper
 #1

Credits:
 theymos  - administrator of bitcointalk.org and creator of its Merit system, the data this script visualizes.
 LoyceV   - creator of loyce.club, the merit-history archive this script reads from.
 Claude   -  AI assistant, used to help develop this script.

Code:

// ==UserScript==
// @name         BitcoinTalk Merit History Plotter 5.9.0
// @namespace    https://bitcointalk.org/
// @version      5.9.0
// @description  Plot a bitcointalk.org user's received-merit history in a single chart, switchable between By day / By Month / By last 120 days / By last 240 days / By last 360 days, combining loyce.club's history with any newer days on bitcointalk.org's own merit page (including entries shown as "Today"/"Yesterday" instead of a full date), with the account's real username (from its bitcointalk.org profile) shown alongside its ID
// @author       satscraper
// @match        https://bitcointalk.org/*
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      loyce.club
// @run-at       document-idle
// ==/UserScript==
//
// Credits:
//   theymos  - admin of bitcointalk.org's  Merit system, the data this script visualizes
//   LoyceV   - creator of loyce.club, the merit-history archive this script reads from
//   Claude   - Anthropic's AI assistant, used to help write this script

(function () {
  'use strict';

  const BTN_ID = 'merit-plotter-btn';

  // ---------- UI ----------

  function createButton() {
    if (document.getElementById(BTN_ID)) return;
    const btn = document.createElement('button');
    btn.id = BTN_ID;
    btn.textContent = 'Plot Merit History';
    btn.style.cssText = [
      'position:fixed', 'bottom:20px', 'right:20px', 'z-index:999999',
      'padding:10px 14px', 'background:#f7931a', 'color:#fff', 'border:none',
      'border-radius:6px', 'font-size:14px', 'font-family:sans-serif',
      'cursor:pointer', 'box-shadow:0 2px 6px rgba(0,0,0,.35)'
    ].join(';');
    btn.addEventListener('click', onButtonClick);
    document.body.appendChild(btn);
  }

  function guessUserIdFromPage() {
    // Works when currently viewing a profile: index.php?action=profile;u=NNN
    const m = location.href.match(/[?;&]u=(\d+)/);
    return m ? m[1] : '';
  }

  function onButtonClick() {
    const guess = guessUserIdFromPage();
    const id = prompt(
      'Enter the bitcointalk.org User ID (the numeric "u=" value from a profile URL, e.g. 3442679):',
      guess || ''
    );
    if (id === null) return; // cancelled
    const trimmed = id.trim();
    if (!/^\d+$/.test(trimmed)) {
      alert('Please enter a numeric user ID, e.g. 3442679');
      return;
    }
    fetchAndPlot(trimmed);
  }

  // ---------- Data fetching ----------
  // Two sources are combined:
  //  1) loyce.club/Merit/history/<id>.html \u2014 a fuller history, but it's a
  //     periodically-updated third-party scrape so it can lag behind by
  //     several days.
  //  2) bitcointalk.org's own index.php?action=merit;u=<id> page \u2014 only the
  //     last 120 days, but always current. Same-origin, so a plain fetch()
  //     works with the signed-in session automatically, no CORS/GM grant
  //     needed, and it's also where we read the account's real username.
 

  function fetchLoyceHistory(userId) {
    return new Promise((resolve, reject) => {
      const url = `https://loyce.club/Merit/history/${userId}.html`;
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload: function (res) {
          if (res.status !== 200) {
            reject(new Error('loyce.club HTTP ' + res.status));
            return;
          }
          try {
            resolve(parseMeritHistory(res.responseText));
          } catch (e) {
            reject(e);
          }
        },
        onerror: function () {
          reject(new Error('loyce.club network error'));
        }
      });
    });
  }

  function fetchBitcointalkMeritPage(userId) {
    const url = `https://bitcointalk.org/index.php?action=merit;u=${userId}`;
    return fetch(url, { credentials: 'same-origin' })
      .then(res => {
        if (!res.ok) throw new Error('bitcointalk.org merit page HTTP ' + res.status);
        return res.text();
      })
      .then(html => parseBitcointalkMeritPage(html));
  }

  function fetchBitcointalkUsername(userId) {
          const url = `https://bitcointalk.org/index.php?action=profile;u=${userId}`;
    return fetch(url, { credentials: 'same-origin' })
      .then(res => {
        if (!res.ok) throw new Error('bitcointalk.org profile page HTTP ' + res.status);
        return res.text();
      })
      .then(html => {
        const m = html.match(/<title>[^<]*?View the profile of\s+([^<]+?)\s*<\/title>/i);
        return m ? m[1].trim() : null;
      });
  }

  function startOfNextDay(d) {
    return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0);
  }

  function mergeReceived(loyceReceived, btReceived) {
    // loyceReceived is sorted ascending, so the last entry is the most recent
    // date loyce.club has for this user.
    const lastLoyceDate = loyceReceived.length ? loyceReceived[loyceReceived.length - 1].date : null;
    const cutoff = lastLoyceDate ? startOfNextDay(lastLoyceDate) : null;

    const appendedRecords = cutoff ? btReceived.filter(r => r.date >= cutoff) : btReceived.slice();
    const discardedCount = btReceived.length - appendedRecords.length;

    const merged = loyceReceived.concat(appendedRecords).sort((a, b) => a.date - b.date);
    return {
      merged,
      appended: appendedRecords.length,
      discarded: discardedCount,
      cutoffDate: cutoff
    };
  }

  function fetchAndPlot(userId) {
    Promise.all([
      fetchLoyceHistory(userId).catch(e => {
        console.warn('[Merit Plotter] loyce.club fetch/parse failed:', e);
        return { received: [], sent: [], candidateLines: 0, parsedLines: 0, loyceClaimedTotal: 0 };
      }),
      fetchBitcointalkMeritPage(userId).catch(e => {
        console.warn('[Merit Plotter] bitcointalk.org merit page fetch/parse failed:', e);
        const empty = [];
        empty.candidateLines = 0;
        empty.username = null;
        return empty;
      }),
      fetchBitcointalkUsername(userId).catch(e => {
        console.warn('[Merit Plotter] bitcointalk.org profile page fetch failed:', e);
        return null;
      })
    ]).then(([loyceParsed, btReceived, profileUsername]) => {
      const { merged, appended, discarded, cutoffDate } = mergeReceived(loyceParsed.received, btReceived);
      const computedSum = merged.reduce((s, r) => s + r.merit, 0);
      const loyceReceivedSum = loyceParsed.received.reduce((s, r) => s + r.merit, 0);

      const diagnostics = {
        loyceCandidateLines: loyceParsed.candidateLines || 0,
        loyceParsedLines: loyceParsed.parsedLines || 0,
        loyceReceivedCount: loyceParsed.received.length,
        loyceReceivedSum,
        loyceClaimedTotal: loyceParsed.loyceClaimedTotal || 0, // loyce.club's own running total, independent check
        loyceLastDate: loyceParsed.received.length ? loyceParsed.received[loyceParsed.received.length - 1].dateText : '(none)',
        cutoffDate: cutoffDate ? cutoffDate.toDateString() : '(no loyce data, no cutoff applied)',
        btCandidateLines: btReceived.candidateLines || 0,
        btReceivedCount: btReceived.length,
        btReceivedSum: btReceived.reduce((s, r) => s + r.merit, 0),
        appended,
        discarded,
        computedSum
      };
      console.log('[Merit Plotter] diagnostics:', diagnostics);
      if (diagnostics.loyceReceivedSum !== diagnostics.loyceClaimedTotal && diagnostics.loyceClaimedTotal > 0) {
        console.warn('[Merit Plotter] Our sum of loyce.club received entries (' + diagnostics.loyceReceivedSum +
          ') does NOT match loyce.club\u2019s own running total (' + diagnostics.loyceClaimedTotal +
          ') \u2014 this points to a parsing bug (some lines were skipped), not a data-completeness gap.');
      }

      if (!merged.length) {
        alert('No received-merit records were found for that user ID from either source. Double-check the ID, and that you\u2019re logged in to bitcointalk.org.');
        return;
      }
      const username = profileUsername || btReceived.username || null;
      openPlotTab(userId, { received: merged, diagnostics, username });
    });
  }

  // ---------- Parsing ----------
  // loyce.club history pages are plain numbered lines (rendered via <br>),


  const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };

  const LINE_RE = /(\d+)\.\s+([A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\s+\S+)\s*:\s*(-?\d+)\s*\(\s*\u03a3\s*(-?\d+)\s*\)\s*(to|from)\s+(.+?)\s*(?:\(Trust list\)\s*)?\(history\)\s*for\s+([^\n\r]*)/g;

  function parseMeritHistory(html) {
    // Normalize <br>/block-end tags to newlines so each entry is its own line,
    // regardless of whether the page uses <br>, <div>, <p> or <li> per entry.
    const normalized = html
      .replace(/<br\s*\/?>/gi, '\n')
      .replace(/<\/(p|div|li|tr)>/gi, '\n');
    const doc = new DOMParser().parseFromString(normalized, 'text/html');
    const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, '');

    // Diagnostic: how many lines *look* like a numbered entry (start with
    // "NNN. Weekday DD Mon YYYY") versus how many LINE_RE actually captured.
    // A gap between these two numbers means some lines have a format LINE_RE
    // isn't matching -- i.e. a real parsing bug, not a data-completeness issue.
    const candidateLines = (text.match(/\d+\.\s+[A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M/g) || []).length;

    const received = [];
    const sent = [];
    let m;
    LINE_RE.lastIndex = 0;
    while ((m = LINE_RE.exec(text)) !== null) {
      const [, , dateStr, amountStr, sigmaStr, direction, otherUser, subject] = m;
      const date = parseHistoryDate(dateStr);
      const amount = parseFloat(amountStr);
      if (!date || isNaN(amount)) continue;
      const record = {
        date,
        merit: amount,
        dateText: dateStr.trim(),
        otherUser: otherUser.trim(),
        subject: subject.trim(),
        sigma: parseFloat(sigmaStr)
      };
      if (direction.toLowerCase() === 'from') received.push(record);
      else sent.push(record);
    }

    received.sort((a, b) => a.date - b.date);
    sent.sort((a, b) => a.date - b.date);

    
    const loyceClaimedTotal = received.length ? Math.max(...received.map(r => r.sigma)) : 0;

    return { received, sent, candidateLines, parsedLines: received.length + sent.length, loyceClaimedTotal };
  }

  function parseHistoryDate(text) {
    if (!text) return null;
    const m = text.match(/[A-Za-z]{3}\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM)/);
    if (!m) return null;
    const [, day, monStr, year, hh, mm, ss, ampm] = m;
    const month = MONTHS[monStr];
    if (month === undefined) return null;
    let hour = parseInt(hh, 10) % 12;
    if (ampm.toUpperCase() === 'PM') hour += 12;
    const d = new Date(parseInt(year, 10), month, parseInt(day, 10), hour, parseInt(mm, 10), parseInt(ss, 10));
    return isNaN(d.getTime()) ? null : d;
  }

 
  const BT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  const BT_LINE_RE = /(?:([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})|(Today|Yesterday))(?:\s+at)?,?\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM):\s*(\d+)\s+from\s+(.+?)\s+for\s+([^\n\r]*)/g;

  function parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm) {
    let hour = parseInt(hh, 10) % 12;
    if (ampm.toUpperCase() === 'PM') hour += 12;
    const minute = parseInt(mm, 10);
    const second = parseInt(ss, 10);
    if (monthName) {
      const monthIdx = BT_MONTHS.indexOf(monthName);
      if (monthIdx === -1) return null;
      const d = new Date(parseInt(year, 10), monthIdx, parseInt(day, 10), hour, minute, second);
      return isNaN(d.getTime()) ? null : d;
    }
    // "Today" / "Yesterday": relative to the actual current date this script
    // is running on (the date the page itself was generated for).
    const base = new Date();
    if (relativeWord.toLowerCase() === 'yesterday') base.setDate(base.getDate() - 1);
    const d = new Date(base.getFullYear(), base.getMonth(), base.getDate(), hour, minute, second);
    return isNaN(d.getTime()) ? null : d;
  }

  function extractReceivedSectionText(text) {
    const headingRe = /(Received|Sent)\s+in\s+the\s+last\s+\d+\s+days/gi;
    const marks = [];
    let hm;
    while ((hm = headingRe.exec(text)) !== null) {
      marks.push({ type: hm[1].toLowerCase(), index: hm.index, end: headingRe.lastIndex });
    }
    if (!marks.length) return text; // unknown layout: best effort, scan everything
    let combined = '';
    for (let i = 0; i < marks.length; i++) {
      if (marks[i].type !== 'received') continue;
      const start = marks[i].end;
      const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
      combined += text.slice(start, end) + '\n';
    }
    return combined;
  }

  function extractUsernameFromMeritPage(html) {
    // Try the <title> tag first: typically something like
    // "Bitcoin Forum - Merit for <username>" or similar phrasing.
    let m = html.match(/<title>([^<]*)<\/title>/i);
    if (m) {
      const t = m[1];
      const nm = t.match(/merit\s+for\s+(.+?)\s*$/i);
      if (nm) return nm[1].trim();
    }
    // Fallback: the phrase "Merit for X" appearing anywhere on the page,
    // e.g. as a heading above the Received/Sent sections.
    m = html.match(/merit\s+for\s+([^<\r\n]{1,60}?)(?:\s*<|\r|\n)/i);
    if (m) return m[1].trim();
    // Fallback: og:title meta tag.
    m = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
    if (m) {
      const name = m[1].replace(/merit\s+for\s+/i, '').trim();
      if (name) return name;
    }
    return null;
  }

  function parseBitcointalkMeritPage(html) {
    const normalized = html
      .replace(/<br\s*\/?>/gi, '\n')
      .replace(/<\/(p|div|li|tr)>/gi, '\n');
    const doc = new DOMParser().parseFromString(normalized, 'text/html');
    const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, '');
    const scanText = extractReceivedSectionText(text);

    // Diagnostic: candidate lines (start with a date-looking prefix, either
    // absolute or the "Today"/"Yesterday" relative form) vs successfully
    // parsed ones.
    const candidateLines = (scanText.match(/(?:[A-Za-z]+\s+\d{1,2},\s+\d{4}|Today|Yesterday)(?:\s+at)?,?\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/g) || []).length;

    const records = [];
    let m;
    BT_LINE_RE.lastIndex = 0;
    while ((m = BT_LINE_RE.exec(scanText)) !== null) {
      const [, monthName, day, year, relativeWord, hh, mm, ss, ampm, amountStr, sender, subject] = m;
      const date = parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm);
      const merit = parseFloat(amountStr);
      if (!date || isNaN(merit)) continue;
      records.push({ date, merit, otherUser: sender.trim(), subject: subject.trim() });
    }
    records.candidateLines = candidateLines; // stowed on the array for convenience
    records.username = extractUsernameFromMeritPage(html);
    return records;
  }

  // ---------- Aggregation ----------

  const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

  function aggregateByDay(records) {
    const map = new Map(); // "YYYY-MM-DD" -> { date, total, count }
    for (const r of records) {
      const d = r.date;
      const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
      let entry = map.get(key);
      if (!entry) {
        entry = { date: new Date(d.getFullYear(), d.getMonth(), d.getDate()), total: 0, count: 0 };
        map.set(key, entry);
      }
      entry.total += r.merit;
      entry.count += 1;
    }
    return Array.from(map.values()).sort((a, b) => a.date - b.date);
  }

  function aggregateByMonth(records) {
    const map = new Map(); // "YYYY-MM" -> { date, total, count }
    for (const r of records) {
      const d = r.date;
      const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
      let entry = map.get(key);
      if (!entry) {
        entry = { date: new Date(d.getFullYear(), d.getMonth(), 1), total: 0, count: 0 };
        map.set(key, entry);
      }
      entry.total += r.merit;
      entry.count += 1;
    }
    return Array.from(map.values()).sort((a, b) => a.date - b.date);
  }

  function formatDay(d) {
    return String(d.getDate()).padStart(2, '0') + ' ' + MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear();
  }

  function formatMonth(d) {
    return MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear();
  }

  function toSeries(records, aggregateFn, formatFn) {
    const buckets = aggregateFn(records);
    return {
      labels: buckets.map(b => formatFn(b.date)),
      perEvent: buckets.map(b => b.total),
      tooltips: buckets.map(b => b.count + ' merit event' + (b.count > 1 ? 's' : ''))
    };
  }

  // ---------- Plot tab ----------

  function windowRecords(records, days) {
    if (days == null) return records; // no filtering
    const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
    return records.filter(r => r.date >= cutoff);
  }

    const MODE_DEFS = [
    { key: 'daily', label: 'By day', days: null, aggregate: aggregateByDay, format: formatDay },
    { key: 'monthly', label: 'By Month', days: null, aggregate: aggregateByMonth, format: formatMonth },
    { key: 'last120', label: 'By last 120 days', days: 120, aggregate: aggregateByDay, format: formatDay },
    { key: 'last240', label: 'By last 240 days', days: 240, aggregate: aggregateByDay, format: formatDay },
    { key: 'last360', label: 'By last 360 days', days: 360, aggregate: aggregateByDay, format: formatDay }
  ];

  function buildModes(receivedRecords) {
    const modes = {};
    for (const m of MODE_DEFS) {
      const recs = windowRecords(receivedRecords, m.days);
      const series = toSeries(recs, m.aggregate, m.format);
      modes[m.key] = {
        label: m.label,
        labels: series.labels,
        perEvent: series.perEvent,
        tooltips: series.tooltips,
        total: recs.reduce((s, r) => s + r.merit, 0)
      };
    }
    return modes;
  }

  function openPlotTab(userId, parsed) {
    const win = window.open('', '_blank');
    if (!win) {
      alert('Popup blocked \u2014 please allow popups for bitcointalk.org and click the button again.');
      return;
    }
    const modes = buildModes(parsed.received);
    const payload = {
      modes,
      receivedTotal: modes.daily.total, // all-time total, for the headline
      diagnostics: parsed.diagnostics,
      username: parsed.username || null
    };
    win.document.open();
    win.document.write(buildPlotHtml(userId, payload));
    win.document.close();
  }

  function buildPlotHtml(userId, payload) {
    const dataJson = JSON.stringify(payload).replace(/</g, '\\u003c'); // avoid premature </script>
    const displayName = 'user ' + userId + (payload.username ? ' (' + payload.username + ')' : '');

    return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Merit history \u2014 ${displayName}</title>
<style>
  body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background:#111; color:#eee; margin:0; padding:24px; }
  h1 { font-size:18px; font-weight:600; margin:0 0 4px; }
  #wrap { max-width:1000px; margin:0 auto; }
  .meta { color:#999; font-size:13px; margin-bottom:16px; }
  details.diag { margin-bottom:16px; font-size:12px; color:#aaa; }
  details.diag summary { cursor:pointer; color:#f7931a; }
  details.diag table { border-collapse:collapse; margin-top:8px; }
  details.diag td { padding:2px 10px 2px 0; }
  details.diag .warn { color:#ff8080; font-weight:600; }
  details.diag .ok { color:#7dd87d; }
  .toggle { margin-bottom:16px; }
  .toggle button { background:#222; color:#ccc; border:1px solid #444; padding:8px 14px; font-size:13px; cursor:pointer; border-radius:6px; margin-right:8px; }
  .toggle button.active { background:#f7931a; color:#111; border-color:#f7931a; font-weight:600; }
  .chart-box { background:#1b1b1b; border-radius:8px; padding:16px 16px 8px; margin-bottom:28px; overflow-x:auto; position:relative; }
  .total-badge { position:sticky; left:8px; display:inline-block; background:#1b1b1b; border:2px solid #f7931a; border-radius:6px; padding:6px 12px; font-size:13px; font-weight:700; color:#f7931a; margin-bottom:10px; }
  svg { display:block; }
  .line-path { fill:none; stroke:#4dabf7; stroke-width:2; }
  .line-area { fill:rgba(77,171,247,0.15); stroke:none; }
  .dot { fill:#4dabf7; }
  .dot:hover { fill:#8ecbff; }
  .value-label-bg { fill:#222; stroke:#4dabf7; stroke-width:1; }
  .value-label-text { fill:#eee; font-size:10px; font-weight:600; }
  .axis-label { fill:#999; font-size:10px; }
  .grid-line { stroke:#333; stroke-width:1; }
  a { color:#f7931a; }
  #errorBox { display:none; white-space:pre-wrap; background:#3a1414; color:#ff8080; border:1px solid #661f1f; border-radius:6px; padding:12px; margin-bottom:20px; font-family:monospace; font-size:12px; }
</style>
</head>
<body>
<div id="wrap">
  <div id="errorBox"></div>
  <h1 id="title">Merit received over time \u2014 ${displayName}</h1>
  <div class="meta">
    Total received: <span id="totalReceived"></span>
    &nbsp;\u2014 sources: <a href="https://loyce.club/Merit/history/${userId}.html" target="_blank" rel="noopener">loyce.club</a>
    + <a href="https://bitcointalk.org/index.php?action=merit;u=${userId}" target="_blank" rel="noopener">bitcointalk.org merit page</a>, merged &amp; de-duplicated
  </div>
  <details class="diag" id="diagBox"><summary>Diagnostics (click to expand)</summary><div id="diagContent"></div></details>
  <div class="toggle" id="modeToggle"></div>
  <div class="chart-box">
    <div class="total-badge" id="totalBadge"></div>
    <div id="perEvent"></div>
  </div>
</div>
<script>
  window.onerror = function (msg, src, line, col, err) {
    const box = document.getElementById('errorBox');
    box.style.display = 'block';
    box.textContent = 'Script error: ' + msg + ' (line ' + line + ')' + (err && err.stack ? '\\n' + err.stack : '');
    return false;
  };

  try {
  const data = ${dataJson};
  document.getElementById('totalReceived').textContent = data.receivedTotal;

  if (data.diagnostics) {
    const d = data.diagnostics;
    const loyceMismatch = d.loyceClaimedTotal > 0 && d.loyceReceivedSum !== d.loyceClaimedTotal;
    const rows = [
      ['loyce.club \u2014 candidate lines found', d.loyceCandidateLines],
      ['loyce.club \u2014 lines successfully parsed (sent + received)', d.loyceParsedLines],
      ['loyce.club \u2014 received entries parsed', d.loyceReceivedCount],
      ['loyce.club \u2014 our sum of received amounts', d.loyceReceivedSum],
      ['loyce.club\\'s OWN running total (\u03a3, independent check)', d.loyceClaimedTotal],
      ['loyce.club \u2014 last date it has for this user', d.loyceLastDate],
      ['bitcointalk.org merit page \u2014 candidate lines found (Received section)', d.btCandidateLines],
      ['bitcointalk.org merit page \u2014 received entries parsed', d.btReceivedCount],
      ['bitcointalk.org merit page \u2014 sum of those entries', d.btReceivedSum],
      ['cutoff \u2014 only bitcointalk.org entries strictly after this date are kept', d.cutoffDate],
      ['merge \u2014 new entries appended (after cutoff)', d.appended],
      ['merge \u2014 entries discarded (on/before loyce.club\\'s last date)', d.discarded],
      ['FINAL \u2014 computed total across merged data', d.computedSum]
    ];
    let html = '<table>' + rows.map(r => '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>').join('') + '</table>';
    if (loyceMismatch) {
      html += '<p class="warn">MISMATCH: our sum of loyce.club received entries (' + d.loyceReceivedSum +
        ') does not match loyce.club\\'s own running total (' + d.loyceClaimedTotal +
        '). That points to a parsing bug on lines loyce.club has but this script is dropping \u2014 ' +
        'please report this.</p>';
    } else if (d.loyceClaimedTotal > 0) {
      html += '<p class="ok">Our parsing of loyce.club matches loyce.club\\'s own running total exactly.</p>';
    }
    if (d.btCandidateLines > d.btReceivedCount) {
      html += '<p class="warn">' + (d.btCandidateLines - d.btReceivedCount) + ' line(s) on the bitcointalk.org ' +
        'merit page looked like entries but failed to fully parse \u2014 check the console (F12).</p>';
    }
    document.getElementById('diagContent').innerHTML = html;
  } else {
    document.getElementById('diagBox').style.display = 'none';
  }

  const SVG_NS = 'http://www.w3.org/2000/svg';
  const MARGIN = { top: 24, right: 10, bottom: 60, left: 40 };
  const CHART_H = 420;
  const POINT_GAP = 36;

  function el(tag, attrs) {
    const e = document.createElementNS(SVG_NS, tag);
    for (const k in attrs) e.setAttribute(k, attrs[k]);
    return e;
  }

  function clearAndAppend(container, svg) {
    container.innerHTML = '';
    container.appendChild(svg);
  }

  function niceMax(v) {
    if (v <= 0) return 1;
    const mag = Math.pow(10, Math.floor(Math.log10(v)));
    const norm = v / mag;
    let step;
    if (norm <= 1) step = 1;
    else if (norm <= 2) step = 2;
    else if (norm <= 5) step = 5;
    else step = 10;
    return step * mag;
  }

  function renderLineChart(container, labels, values, tooltips) {
    const n = Math.max(values.length, 1);
    const width = Math.max(n * POINT_GAP + MARGIN.left + MARGIN.right, 300);
    const height = CHART_H;
    const innerW = width - MARGIN.left - MARGIN.right;
    const innerH = height - MARGIN.top - MARGIN.bottom;
    const maxVal = niceMax(Math.max(1, ...values));

    const svg = el('svg', { width, height, viewBox: '0 0 ' + width + ' ' + height });
    const g = el('g', { transform: 'translate(' + MARGIN.left + ',' + MARGIN.top + ')' });
    svg.appendChild(g);

    const gridCount = 5;
    for (let i = 0; i <= gridCount; i++) {
      const y = innerH - (innerH * i) / gridCount;
      const val = Math.round((maxVal * i) / gridCount);
      g.appendChild(el('line', { class: 'grid-line', x1: 0, x2: innerW, y1: y, y2: y }));
      const t = el('text', { class: 'axis-label', x: -6, y: y + 3, 'text-anchor': 'end' });
      t.textContent = val;
      g.appendChild(t);
    }

    const points = values.map((v, i) => {
      const x = i * POINT_GAP + POINT_GAP / 2;
      const y = innerH - (v / maxVal) * innerH;
      return [x, y];
    });

    if (points.length) {
      const linePath = 'M ' + points.map(p => p[0] + ' ' + p[1]).join(' L ');
      const areaPath = linePath + ' L ' + points[points.length - 1][0] + ' ' + innerH + ' L ' + points[0][0] + ' ' + innerH + ' Z';
      g.appendChild(el('path', { class: 'line-area', d: areaPath }));
      g.appendChild(el('path', { class: 'line-path', d: linePath }));
    }

    points.forEach(([x, y], i) => {
      const dot = el('circle', { class: 'dot', cx: x, cy: y, r: 3 });
      const title = el('title', {});
      title.textContent = labels[i] + ': ' + values[i] + ' merit' + (values[i] === 1 ? '' : 's') + (tooltips && tooltips[i] ? ' (' + tooltips[i] + ')' : '');
      dot.appendChild(title);
      g.appendChild(dot);
    });

    values.forEach((v, i) => {
      const [x, y] = points[i];
      const text = String(v);
      const labelW = Math.max(text.length * 7 + 10, 18);
      const labelH = 16;
      let labelY = y - 10 - labelH;
      if (labelY < 0) labelY = y + 10;
      const labelX = x - labelW / 2;
      g.appendChild(el('rect', { class: 'value-label-bg', x: labelX, y: labelY, width: labelW, height: labelH, rx: 3 }));
      const t = el('text', { class: 'value-label-text', x, y: labelY + labelH - 4, 'text-anchor': 'middle' });
      t.textContent = text;
      g.appendChild(t);
    });

    appendXLabels(g, labels, innerH, n);
    clearAndAppend(container, svg);
  }

  function appendXLabels(g, labels, innerH, n) {
    const step = Math.max(1, Math.ceil(n / 25));
    labels.forEach((lab, i) => {
      if (i % step !== 0) return;
      const x = i * POINT_GAP + POINT_GAP / 2;
      const t = el('text', {
        class: 'axis-label', x, y: innerH + 14,
        'text-anchor': 'end', transform: 'rotate(-60 ' + x + ' ' + (innerH + 14) + ')'
      });
      t.textContent = lab;
      g.appendChild(t);
    });
  }

  const MODE_KEYS = ['daily', 'monthly', 'last120', 'last240', 'last360'];
  const modeToggle = document.getElementById('modeToggle');
  const totalBadge = document.getElementById('totalBadge');
  const chartDiv = document.getElementById('perEvent');
  const buttons = {};

  MODE_KEYS.forEach(key => {
    const btn = document.createElement('button');
    btn.textContent = data.modes[key].label;
    btn.addEventListener('click', () => renderMode(key));
    modeToggle.appendChild(btn);
    buttons[key] = btn;
  });

  function renderMode(key) {
    const mode = data.modes[key];
    MODE_KEYS.forEach(k => buttons[k].classList.toggle('active', k === key));
    document.getElementById('totalReceived').textContent = mode.total;
    totalBadge.textContent = 'Total received: ' + mode.total + ' merit' + (mode.total === 1 ? '' : 's');
    if (!mode.labels.length) {
      chartDiv.innerHTML = '<p style="color:#777">No received-merit records in this view.</p>';
      return;
    }
    renderLineChart(chartDiv, mode.labels, mode.perEvent, mode.tooltips);
  }

  renderMode('daily');
  } catch (e) {
    const box = document.getElementById('errorBox');
    box.style.display = 'block';
    box.textContent = 'Render error: ' + e.message + '\\n' + (e.stack || '');
  }
<\/script>
</body>
</html>`;
  }

  // ---------- init ----------

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('Plot Merit History\u2026', onButtonClick);
  }
  createButton();
})();





Quote from: satscraper

If you like what this script does, use Tampermonkey browser extension to run it.


▄▄███████████████████▄▄
▄███████████████████████▄
███████████████████▄█████
█████████████████████████
███████████▀█████████████
█████████▀███▀██████▀████
██████████████████▄██████
█████████▄▄█▄▄███████████
██████████▄▄▄████████████
███████████████████▀█████
████████████████▀▀███████
▀███████████████████████▀
▀▀███████████████████▀▀
 
 EARNBET 
██
██
██
██
██
██
██
██
██
██
██
██
██
███████▄▄███████████
████▄██████████████████
██▄▀▀███████████████▀▀███
█▄████████████████████████
▄▄████████▀▀▀▀▀████████▄▄██
███████████████████████████
█████████▌████▀████████████
███████████████████████████
▀▀███████▄▄▄▄▄█████████▀▀██
█▀█████████████████████▀██
██▀▄▄███████████████▄▄███
████▀██████████████████
███████▀▀███████████
██
██
██
██
██
██
██
██
██
██
██
██
██


▄▄▄
▄▄▄███████▐███▌███████▄▄▄
█████████████████████████
▀████▄▄▄███████▄▄▄████▀
█████████████████████
▐███████████████████▌
███████████████████
███████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

 King of The Castle 
 $200,000 in prizes
██
██
██
██
██
██
██
██
██
██
██
██
██

 62.5% 

 
RAKEBACK
BONUS
SquirrelJulietGarden
Legendary
*
Offline

Activity: 2128
Merit: 1004


SIG-7DA3CCE6AD


View Profile
Today at 07:16:25 AM
 #2

It is available on Bitlist.co's archive page for users. For example this one is for your account.
https://bitlist.co/user/id/3442679/merits?date_min=2018-01-24&date_max=2026-09-27

It's also available at Loyce.club
https://loyce.club/Merit/history/3442679.html

satscraper (OP)
Legendary
*
Offline

Activity: 1596
Merit: 3012



View Profile
Today at 07:35:16 AM
Last edit: Today at 07:54:38 AM by satscraper
 #3

It is available on Bitlist.co's archive page for users. For example this one is for your account.
https://bitlist.co/user/id/3442679/merits?date_min=2018-01-24&date_max=2026-09-27

It's also available at Loyce.club
https://loyce.club/Merit/history/3442679.html

I know this.

That said,  I developed this script for the following reasons:

Loyce.club data are not always up to date just example, for 3442679 the last date is Fri 11 Sep 2026, so the script merges them with the latest data taken from the user's profile. Besides, the plots script presents and visualizes data in more informative form than Loyce.club. That said, credit to LoyceV is hereby acknowledged.

Regarding https://bitlist.co, I found this script gives better visualization.

Sure, you are free to use whichever one suits you best.


▄▄███████████████████▄▄
▄███████████████████████▄
███████████████████▄█████
█████████████████████████
███████████▀█████████████
█████████▀███▀██████▀████
██████████████████▄██████
█████████▄▄█▄▄███████████
██████████▄▄▄████████████
███████████████████▀█████
████████████████▀▀███████
▀███████████████████████▀
▀▀███████████████████▀▀
 
 EARNBET 
██
██
██
██
██
██
██
██
██
██
██
██
██
███████▄▄███████████
████▄██████████████████
██▄▀▀███████████████▀▀███
█▄████████████████████████
▄▄████████▀▀▀▀▀████████▄▄██
███████████████████████████
█████████▌████▀████████████
███████████████████████████
▀▀███████▄▄▄▄▄█████████▀▀██
█▀█████████████████████▀██
██▀▄▄███████████████▄▄███
████▀██████████████████
███████▀▀███████████
██
██
██
██
██
██
██
██
██
██
██
██
██


▄▄▄
▄▄▄███████▐███▌███████▄▄▄
█████████████████████████
▀████▄▄▄███████▄▄▄████▀
█████████████████████
▐███████████████████▌
███████████████████
███████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

 King of The Castle 
 $200,000 in prizes
██
██
██
██
██
██
██
██
██
██
██
██
██

 62.5% 

 
RAKEBACK
BONUS
LoyceV
Legendary
*
Offline

Activity: 4172
Merit: 22808


Thick-Skinned Gang Leader and Golden Feather 2021


View Profile WWW
Today at 09:56:41 AM
Merited by satscraper (1)
 #4

Loyce.club data are not always up to date just example, for 3442679 the last date is Fri 11 Sep 2026
There are 3 reasons for this: theymos' Merit data dump happens every Friday (merit.all.txt is updated shortly after this), processing all data takes more than a week on my current VPS, and the VPS was recently rebooted so all progress was lost.
If you can use my merit.all.txt, you'll have my most recent data weekly.

¡uʍop ǝpᴉsdn pɐǝɥ ɹnoʎ ɥʇᴉʍ ʎuunɟ ʞool no⅄
[MUSIC] The Ballad of LoyceV
satscraper (OP)
Legendary
*
Offline

Activity: 1596
Merit: 3012



View Profile
Today at 10:39:37 AM
Last edit: Today at 10:50:07 AM by satscraper
 #5

Loyce.club data are not always up to date just example, for 3442679 the last date is Fri 11 Sep 2026
There are 3 reasons for this: theymos' Merit data dump happens every Friday (merit.all.txt ...processing all data takes more than a week on my current VPS, and the VPS was recently rebooted so all progress was lost.

That is why script appends absent data  taken directly from https://bitcointalk.org/index.php?action=merit;u=<ID>. The latter is up-to-date, and virtually momentary.

And again, locev.club is more like a data archive that is hard to read and comprehend visually at least for me. That said, it helps to gather the data needed. Thanks for this.

▄▄███████████████████▄▄
▄███████████████████████▄
███████████████████▄█████
█████████████████████████
███████████▀█████████████
█████████▀███▀██████▀████
██████████████████▄██████
█████████▄▄█▄▄███████████
██████████▄▄▄████████████
███████████████████▀█████
████████████████▀▀███████
▀███████████████████████▀
▀▀███████████████████▀▀
 
 EARNBET 
██
██
██
██
██
██
██
██
██
██
██
██
██
███████▄▄███████████
████▄██████████████████
██▄▀▀███████████████▀▀███
█▄████████████████████████
▄▄████████▀▀▀▀▀████████▄▄██
███████████████████████████
█████████▌████▀████████████
███████████████████████████
▀▀███████▄▄▄▄▄█████████▀▀██
█▀█████████████████████▀██
██▀▄▄███████████████▄▄███
████▀██████████████████
███████▀▀███████████
██
██
██
██
██
██
██
██
██
██
██
██
██


▄▄▄
▄▄▄███████▐███▌███████▄▄▄
█████████████████████████
▀████▄▄▄███████▄▄▄████▀
█████████████████████
▐███████████████████▌
███████████████████
███████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

 King of The Castle 
 $200,000 in prizes
██
██
██
██
██
██
██
██
██
██
██
██
██

 62.5% 

 
RAKEBACK
BONUS
Pages: [1]
  Print  
 
Jump to:  

Powered by MySQL Powered by PHP Powered by SMF 1.1.19 | SMF © 2006-2009, Simple Machines Valid XHTML 1.0! Valid CSS!