satscraper (OP)
Legendary

Activity: 1596
Merit: 3012
|
 |
September 29, 2026, 08:15:11 AM Last edit: September 29, 2026, 09:12:57 AM by satscraper |
|
BitcoinTalk Merit History Plotter script has been elevated to v. 6.0.0 It adds "By thread" button showing all time totals per thread with default to top ~15 expandable by "show all". // ==UserScript== // @name BitcoinTalk Merit History Plotter 6.0.0 // @namespace https://bitcointalk.org/ // @version 6.0.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';
// Escapes a string for safe interpolation as HTML text content. Used // wherever data read from a remote page (username, etc.) is written into // the generated plot tab's markup \u2014 remote-sourced strings are never // trusted at the point they're turned into HTML, regardless of any // upstream regex constraints already applied when extracting them. function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }
// ---------- 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. // // Only bitcointalk.org entries dated AFTER loyce.club's most recent date are // appended \u2014 anything on or before that cutoff day is discarded outright, // rather than trying to de-duplicate by matching timestamps. An earlier // version tried exact timestamp+sender+amount matching, but bitcointalk.org // displays times in each viewer's own forum timezone setting while // loyce.club always shows CEST, so the same transaction can carry two // different displayed times across the two pages \u2014 exact-time matching // then fails to recognize it as a duplicate and double-counts it. A plain // date cutoff (loyce.club's last calendar day and everything on/before it is // never touched) sidesteps that entirely.
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) { // The merit page's own title doesn't reliably contain the username, so the // profile page is used instead \u2014 its title is a fixed, confirmed SMF // string: "... - View the profile of <username>". 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>), // e.g.: // 499. Wed 09 Sep 2026 05:19:11 PM CEST: 2 (\u03a3 682) to ChiBitCty (Trust list) (history) for Re: ... // 12. Mon 03 Aug 2026 09:12:00 AM CEST: 1 (\u03a3 45) from SomeSender (Trust list) (history) for Re: ... // "to X" = merit this user SENT to X. "from X" = merit this user RECEIVED from X. // "(Trust list)" is treated as optional (some senders may not have one, e.g. // very new or restricted accounts) so a missing tag there doesn't silently // drop an otherwise-valid line; "(history)" is kept as the required anchor.
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);
// loyce.club prints a running cumulative total (\u03a3) after every line, so // the highest \u03a3 among "received" lines is loyce.club's OWN claimed // lifetime received total, independent of how we summed the individual // amounts. If our sum doesn't match this, that's a parsing bug on our // side (dropped/mis-parsed lines), not a data-completeness gap. 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; }
// bitcointalk.org's own action=merit page format (only covers the last 120 // days), e.g.: // September 20, 2026, 09:19:54 PM: 2 from klarki for Re: some thread title // SMF (which bitcointalk.org runs) has a very common built-in "Today"/ // "Yesterday" relative-date display: recent entries show as e.g. // Today, 09:19:54 PM: 2 from klarki for Re: some thread title // Yesterday at 09:19:54 PM: 2 from klarki for Re: some thread title // instead of a full "Month DD, YYYY" date, so both forms are matched (with // or without "at" before the time). The page lists both a "Received in the // last N days" and a "Sent in the last N days" section; only the Received // section is parsed. The username is also read from this same page (its // <title> or an on-page heading), so no extra request is needed just to // display it.
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 aggregateByThread(records) { // Groups by subject text (with a leading "Re: " stripped), since neither // data source exposes a thread id or URL \u2014 the subject line is the // closest thing we have to a thread identity. Two distinct threads that // happen to share an identical title will be merged; that's a known // limitation, not a bug. const map = new Map(); // lowercased subject -> { subject (display form), total, count } for (const r of records) { let subject = (r.subject || '').trim().replace(/^Re:\s*/i, '').trim(); if (!subject) subject = '(no subject)'; const key = subject.toLowerCase(); let entry = map.get(key); if (!entry) { entry = { subject, total: 0, count: 0 }; map.set(key, entry); } entry.total += r.merit; entry.count += 1; } return Array.from(map.values()).sort((a, b) => b.total - a.total); }
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); }
// Exactly six view modes, switched by their own button \u2014 all rendered // into the same single chart area rather than separate sections. 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) }; }
// Sixth mode: all-time, grouped by thread instead of by date. Kept // outside the loop above since it doesn't fit the date-bucket shape the // other five share (no window filtering \u2014 always all-time, per your // call to keep it consistent with By day / By Month). const threads = aggregateByThread(receivedRecords); modes.thread = { label: 'By thread', threads, total: threads.reduce((s, t) => s + t.total, 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 rawDisplayName = 'user ' + userId + (payload.username ? ' (' + payload.username + ')' : ''); // Defense in depth: userId is always numeric (validated on input) and the // username is regex-constrained not to contain "<" at extraction time, // but this is escaped again here too since it's about to be interpolated // as raw HTML text (in <title> and <h1>) \u2014 never trust a remote-sourced // string at the point it's written into markup, regardless of what // upstream validation already did. const displayName = escapeHtml(rawDisplayName);
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; } .thread-row { display:flex; align-items:center; gap:12px; padding:10px 4px; border-bottom:1px solid #2a2a2a; } .thread-rank { font-size:13px; color:#777; width:20px; flex-shrink:0; } .thread-info { flex:1; min-width:0; } .thread-title { font-size:14px; margin:0 0 5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .thread-bar-bg { background:#111; border-radius:3px; height:5px; overflow:hidden; } .thread-bar-fill { background:#4dabf7; height:100%; } .thread-events { font-size:12px; color:#777; white-space:nowrap; flex-shrink:0; } .thread-total { font-size:15px; font-weight:700; color:#f7931a; width:44px; text-align:right; flex-shrink:0; } .thread-showmore { display:block; width:100%; margin-top:10px; background:#222; color:#ccc; border:1px solid #444; padding:8px; font-size:13px; cursor:pointer; border-radius:6px; } 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> \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 & 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; };
// Local copy: this inline script runs in a separate document from the // outer Tampermonkey script, so it needs its own escaping helper. Used on // every diagnostics value below (even ones this script itself computed) // since it's cheap and one less thing to have to re-verify later if the // diagnostics table grows. function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }
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>' + escapeHtml(r[0]) + '</td><td>' + escapeHtml(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); }); }
function renderThreadList(container, threads) { container.innerHTML = ''; const SHOW_LIMIT = 15; const maxTotal = threads.length ? threads[0].total : 0; // already sorted descending let expanded = false;
const list = document.createElement('div');
function draw() { list.innerHTML = ''; const toShow = expanded ? threads : threads.slice(0, SHOW_LIMIT); toShow.forEach((t, i) => { const row = document.createElement('div'); row.className = 'thread-row'; const pct = maxTotal ? Math.round((t.total / maxTotal) * 100) : 0;
const rank = document.createElement('span'); rank.className = 'thread-rank'; rank.textContent = String(i + 1);
const info = document.createElement('div'); info.className = 'thread-info'; const titleEl = document.createElement('p'); titleEl.className = 'thread-title'; titleEl.textContent = t.subject; // textContent only \u2014 subject is remote-sourced text titleEl.title = t.subject; // full text on hover, since it's truncated visually const barBg = document.createElement('div'); barBg.className = 'thread-bar-bg'; const barFill = document.createElement('div'); barFill.className = 'thread-bar-fill'; barFill.style.width = pct + '%'; barBg.appendChild(barFill); info.appendChild(titleEl); info.appendChild(barBg);
const events = document.createElement('span'); events.className = 'thread-events'; events.textContent = t.count + ' event' + (t.count === 1 ? '' : 's');
const total = document.createElement('span'); total.className = 'thread-total'; total.textContent = String(t.total);
row.appendChild(rank); row.appendChild(info); row.appendChild(events); row.appendChild(total); list.appendChild(row); });
if (threads.length > SHOW_LIMIT) { const btn = document.createElement('button'); btn.className = 'thread-showmore'; btn.textContent = expanded ? 'Show fewer' : 'Show all ' + threads.length + ' threads'; btn.addEventListener('click', () => { expanded = !expanded; draw(); }); list.appendChild(btn); } }
draw(); container.appendChild(list); }
const MODE_KEYS = ['daily', 'monthly', 'last120', 'last240', 'last360', 'thread']; 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.threads) { if (!mode.threads.length) { chartDiv.innerHTML = '<p style="color:#777">No received-merit records found.</p>'; return; } renderThreadList(chartDiv, mode.threads); return; }
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(); })();

|