|
Z_MBFM (OP)
|
 |
August 27, 2026, 05:55:20 PM Last edit: August 28, 2026, 08:16:12 AM by Z_MBFM |
|
Bitcointalk Post Health Script The purpose of creating this scriptWe all use various types of extension scripts through tampermonkey and Violentmonkey . Because they can be used very easily in extension supported browsers from desktop laptops to smartphones and multiple scripts can be used together. That is why I made this script so that all users can use it easily. To improve post quality, it is important to monitor your post health score and the post health score of others, it will be easy to analyze the posts of those who are posting well and improve your own posts. So I think this script should be installed by everyone. To use this script, you need to download Chrome version tampermonkey or Violentmonkey and if you are using Mozilla Firefox, install the Firefox version tampermonkey or Violentmonkey The browsers you can use for Android Device are: Mises browser, Mozilla Firefox, Lemur Browser, Kiwi BrowserYou can directly add to tamperkmoney or Violentmonkey by one click Install this scrip- Bitcointalk Post Healthand also Create a new script on tamperkmoney or Violentmonkey (Copy the code bellow and paste there and save the script)Note - Developer mode must be turned on for mobile browsers. // ==UserScript== // @name Bitcointalk Post Health // @namespace https://postflayer.onrender.com/ // @version 3.0.0 // @description Shows the Bitcointalk Post Health Score beside each poster in a topic. // @author Z_MBFM // @license MIT // @homepageURL https://postflayer.onrender.com/ // @supportURL https://postflayer.onrender.com/ // @match *://bitcointalk.org/index.php* // @match *://www.bitcointalk.org/index.php* // @connect postflayer.onrender.com // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @grant GM_getValue // @grant GM.getValue // @grant GM_setValue // @grant GM.setValue // @run-at document-idle // @noframes // ==/UserScript==
/* * Compatibility notes * ------------------- * Tampermonkey : exposes both GM_* (sync) and GM.* (promise) APIs. * Violentmonkey : exposes both GM_* (sync) and GM.* (promise) APIs. * Greasemonkey 4: exposes ONLY the promise-based GM.* API. GM_xmlhttpRequest, * GM_getValue and GM_setValue do not exist there, which is why * every privileged call below goes through an adapter. * * The API at postflayer.onrender.com sends no Access-Control-Allow-Origin * header, so a plain window.fetch() from the page context is blocked by CORS. * GM_xmlhttpRequest / GM.xmlHttpRequest are not subject to that restriction, * and are therefore required rather than merely convenient. */
(() => { "use strict";
// ---------------------------------------------------------------- config
const API_ROOT = "https://postflayer.onrender.com"; const BADGE_CLASS = "bth-health-grade"; const STYLE_ID = "bth-style"; const STORAGE_KEY = "bth-cache-v1";
// Scores move slowly, so a positive result is worth keeping for a while. // "No score" is cached for less time in case the account starts qualifying. const TTL_OK_MS = 6 * 60 * 60 * 1000; const TTL_EMPTY_MS = 45 * 60 * 1000;
// The API is hosted on a free Render instance: cold starts routinely take // 30+ seconds, so the timeout is deliberately generous and the number of // simultaneous requests deliberately small. const REQUEST_TIMEOUT_MS = 45000; const MAX_CONCURRENCY = 3; const MAX_RETRIES = 1; const RETRY_DELAY_MS = 1500;
// Bounds the persisted cache so it cannot grow without limit. const CACHE_MAX_ENTRIES = 500; const CACHE_PRUNE_TO = 400; const CACHE_FLUSH_DEBOUNCE_MS = 1500;
const DEBUG = false;
function log(...args) { if (DEBUG) console.log("[BTH]", ...args); }
function warn(...args) { console.warn("[BTH]", ...args); }
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// ------------------------------------------------- GM API: HTTP adapter
/** * Resolves the best available cross-origin request function. * Returns null when the script is running without the required grant, * which lets the caller surface an accurate error instead of a CORS * failure that looks like the API being down. */ function resolveRequester() { if (typeof GM_xmlhttpRequest === "function") { return { fn: GM_xmlhttpRequest, name: "GM_xmlhttpRequest" }; }
if (typeof GM !== "undefined" && GM && typeof GM.xmlHttpRequest === "function") { // Greasemonkey 4+ path. Bound to GM so `this` stays correct. return { fn: GM.xmlHttpRequest.bind(GM), name: "GM.xmlHttpRequest" }; }
return null; }
const requester = resolveRequester();
/** * Performs a GET and resolves with { status, responseText }. * * Handles the three shapes a userscript manager can present: * - callback only (Tampermonkey / Violentmonkey GM_xmlhttpRequest) * - promise only (Greasemonkey 4 GM.xmlHttpRequest) * - both at once (Tampermonkey GM.xmlHttpRequest) * A `settled` guard makes the double-signalling case harmless. */ function httpGet(url) { if (!requester) { return Promise.reject(new Error( "No cross-origin request API available. The script needs " + "@grant GM_xmlhttpRequest (or GM.xmlHttpRequest)." )); }
return new Promise((resolve, reject) => { let settled = false; let handle = null; let timer = null;
const finish = fn => value => { if (settled) return; settled = true; if (timer !== null) clearTimeout(timer); fn(value); };
const succeed = finish(resolve); const fail = finish(reject);
const onResponse = response => { if (!response) { fail(new Error("Empty response")); return; } succeed({ status: response.status, responseText: response.responseText || "" }); };
// Greasemonkey does not reliably honour the `timeout` option, so an // independent guard is used for every manager. timer = setTimeout(() => { if (settled) return; try { if (handle && typeof handle.abort === "function") handle.abort(); } catch (e) { /* aborting is best-effort */ } fail(new Error("Request timed out after " + REQUEST_TIMEOUT_MS + "ms")); }, REQUEST_TIMEOUT_MS);
const details = { method: "GET", url, timeout: REQUEST_TIMEOUT_MS, headers: { Accept: "application/json" }, onload: onResponse, onerror: () => fail(new Error("Network error")), ontimeout: () => fail(new Error("Request timed out")), onabort: () => fail(new Error("Request aborted")) };
try { handle = requester.fn(details); } catch (e) { fail(new Error("Request failed to start: " + (e && e.message || e))); return; }
// Greasemonkey 4 returns a promise and ignores the callbacks above. if (handle && typeof handle.then === "function") { handle.then(onResponse, e => fail( new Error("Network error: " + (e && e.message || e)) )); } }); }
// ---------------------------------------------- GM API: storage adapter
/** * Promise-based value storage that degrades gracefully: * GM_* (sync) -> GM.* (promise) -> localStorage -> in-memory only. * Greasemonkey 4 only has the promise form, so both are supported. */ const store = (() => { const hasSync = typeof GM_getValue === "function" && typeof GM_setValue === "function"; const hasAsync = typeof GM !== "undefined" && GM && typeof GM.getValue === "function" && typeof GM.setValue === "function";
if (hasSync) { return { name: "GM_setValue", get: key => Promise.resolve().then(() => GM_getValue(key, null)), set: (key, value) => Promise.resolve().then(() => GM_setValue(key, value)) }; }
if (hasAsync) { return { name: "GM.setValue", get: key => Promise.resolve(GM.getValue(key, null)), set: (key, value) => Promise.resolve(GM.setValue(key, value)) }; }
try { const probe = "__bth_probe__"; localStorage.setItem(probe, "1"); localStorage.removeItem(probe);
return { name: "localStorage", get: key => Promise.resolve(localStorage.getItem(key)), set: (key, value) => Promise.resolve().then(() => localStorage.setItem(key, value)) }; } catch (e) { const mem = new Map(); return { name: "memory", get: key => Promise.resolve(mem.has(key) ? mem.get(key) : null), set: (key, value) => Promise.resolve(mem.set(key, value)) }; } })();
// ----------------------------------------------------------------- cache
/** * uid -> { score: string|null, time: number } * * Loaded once on start, mutated in memory, and flushed back on a debounce * so a page with many posters performs a single write instead of one per * poster. Entries are pruned oldest-first to keep the stored blob small. */ const cache = new Map(); let cacheReady = null; let flushTimer = null;
function loadCache() { if (cacheReady) return cacheReady;
cacheReady = store.get(STORAGE_KEY).then(raw => { if (!raw) return;
// GM_setValue can round-trip objects directly; localStorage cannot. const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; if (!parsed || typeof parsed !== "object") return;
const now = Date.now();
for (const [uid, entry] of Object.entries(parsed)) { if (!entry || typeof entry.time !== "number") continue; if (isExpired(entry, now)) continue;
cache.set(String(uid), { score: typeof entry.score === "string" ? entry.score : null, time: entry.time }); }
log("cache loaded:", cache.size, "entries via", store.name); }).catch(e => { warn("cache load failed, continuing without it:", e); });
return cacheReady; }
function isExpired(entry, now = Date.now()) { const ttl = entry.score ? TTL_OK_MS : TTL_EMPTY_MS; return now - entry.time >= ttl; }
function pruneCache() { if (cache.size <= CACHE_MAX_ENTRIES) return;
const byAge = [...cache.entries()].sort((a, b) => a[1].time - b[1].time);
for (const [uid] of byAge.slice(0, cache.size - CACHE_PRUNE_TO)) { cache.delete(uid); } }
function scheduleFlush() { if (flushTimer !== null) clearTimeout(flushTimer);
flushTimer = setTimeout(() => { flushTimer = null; pruneCache();
const plain = {}; for (const [uid, entry] of cache) plain[uid] = entry;
store.set(STORAGE_KEY, JSON.stringify(plain)) .catch(e => warn("cache save failed:", e)); }, CACHE_FLUSH_DEBOUNCE_MS); }
function cacheGet(uid) { const entry = cache.get(uid);
if (!entry) return null;
if (isExpired(entry)) { cache.delete(uid); return null; }
return entry; }
function cachePut(uid, score) { cache.set(uid, { score, time: Date.now() }); scheduleFlush(); }
// ------------------------------------------------- request queue / fetch
/** * Bounded-concurrency queue. The API runs on a free Render instance, so * firing 20+ parallel requests from a busy topic page is a reliable way to * get slow responses or rate limiting. */ const queue = []; let active = 0;
function enqueue(task) { return new Promise((resolve, reject) => { queue.push({ task, resolve, reject }); pump(); }); }
function pump() { while (active < MAX_CONCURRENCY && queue.length > 0) { const { task, resolve, reject } = queue.shift();
active += 1;
task().then(resolve, reject).finally(() => { active -= 1; pump(); }); } }
// uid -> in-flight promise, so the same poster appearing in several posts // on one page results in exactly one network request. const inFlight = new Map();
/** * Interprets an API response. * * Verified against the live API: * 200 -> { user_id, score_band, message, improvements[] } * 404 -> user exists but has no score / unknown user * 422 -> non-numeric uid (should not happen, uid is regex-validated) * A 404 is a definitive "no score", so it is cached rather than retried. */ function interpret(response) { if (response.status === 404) { return { ok: true, score: null }; }
if (response.status === 422) { return { ok: false, score: null, error: "Invalid user id", retry: false }; }
if (response.status < 200 || response.status >= 300) { // 5xx and 429 are worth one retry; other 4xx are not. const retry = response.status >= 500 || response.status === 429; return { ok: false, score: null, error: "Server returned HTTP " + response.status, retry }; }
let data;
try { data = JSON.parse(response.responseText); } catch (e) { return { ok: false, score: null, error: "Malformed JSON from API", retry: false }; }
const band = data && data.score_band;
return { ok: true, score: typeof band === "string" && band.trim() ? band.trim() : null, message: data && typeof data.message === "string" ? data.message : "", improvements: data && Array.isArray(data.improvements) ? data.improvements : [] }; }
async function fetchScoreOnce(uid) { const url = API_ROOT + "/score/" + encodeURIComponent(uid); const response = await httpGet(url); return interpret(response); }
async function fetchScoreWithRetry(uid) { // A missing GM request API is a configuration problem, not a transient // one, so fail immediately rather than sleeping through pointless retries. if (!requester) { return { ok: false, score: null, error: "No cross-origin request API available. This script needs " + "@grant GM_xmlhttpRequest (or GM.xmlHttpRequest). Try " + "reinstalling it." }; }
let last = { ok: false, score: null, error: "Unknown error" };
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { try { const result = await fetchScoreOnce(uid);
if (result.ok) return result;
last = result;
if (result.retry === false) return result; } catch (e) { last = { ok: false, score: null, error: String(e && e.message || e) }; }
if (attempt < MAX_RETRIES) await sleep(RETRY_DELAY_MS * (attempt + 1)); }
return last; }
/** * Cache-first score lookup. Resolves (never rejects) with * { ok, score, cached, error?, message?, improvements? }. */ function getScore(uid) { const cached = cacheGet(uid);
if (cached) { return Promise.resolve({ ok: true, score: cached.score, cached: true }); }
if (inFlight.has(uid)) return inFlight.get(uid);
const promise = enqueue(() => fetchScoreWithRetry(uid)) .then(result => { if (result.ok) cachePut(uid, result.score); return Object.assign({ cached: false }, result); }) .catch(e => ({ ok: false, score: null, cached: false, error: String(e && e.message || e) })) .finally(() => { inFlight.delete(uid); });
inFlight.set(uid, promise);
return promise; }
// ---------------------------------------------------------- presentation
const COLOR_TOP_TIER = "#3465A4"; const COLOR_GOOD = "#28a745"; const COLOR_BAD = "#dc3545"; const COLOR_NEUTRAL = "#777";
/** * Turns a raw score_band string into display values. * * Live API example: "Good (Top 25% of active posters)" * The grade is the part before the parenthesis, the detail is inside it. */ function scorePresentation(score, message) { if (!score) { return { text: "Score not met", color: COLOR_NEUTRAL, title: "A Post Health Score is not available for this account.", bold: false }; }
let grade = score; let detail = ""; let percentage = ""; let color = "#555";
const match = score.match(/^(.*?)\s*\((.*?)\)\s*$/);
if (match) { grade = match[1].trim(); detail = match[2].trim();
const percentMatch = detail.match(/(\d+)\s*%/);
if (percentMatch) { percentage = percentMatch[1] + "%";
const value = parseInt(percentMatch[1], 10); const isTop = /\btop\b/i.test(detail);
if (isTop) { if (value <= 10) color = COLOR_TOP_TIER; else if (value <= 50) color = COLOR_GOOD; else color = COLOR_BAD; } else { color = COLOR_BAD; } } }
// Fall back to keyword matching when there is no usable percentage, // covering bands phrased without a parenthetical. if (!percentage) { const lower = grade.toLowerCase();
if (/excellent|great|good|healthy/.test(lower)) color = COLOR_GOOD; else if (/poor|bad|low|unhealthy|spam/.test(lower)) color = COLOR_BAD; }
const titleParts = [detail || score]; if (message) titleParts.push(message);
return { text: percentage ? percentage + " - " + grade : grade, color, title: titleParts.join(" \u2014 "), bold: true }; }
// ------------------------------------------------------------- DOM layer
function getUid(cell) { // Bitcointalk profile links use SMF's `;`-delimited query strings, // e.g. index.php?action=profile;u=101872 const link = cell.querySelector('a[href*="action=profile"][href*="u="]');
if (!link) return null;
const href = link.getAttribute("href") || link.href || ""; const match = href.match(/[?;&]u=(\d+)/);
return match ? match[1] : null; }
function createBadge() { const badge = document.createElement("span"); badge.className = BADGE_CLASS;
const label = document.createElement("span"); label.className = "bth-label"; label.textContent = "Score: ";
const value = document.createElement("span"); value.className = "bth-value bth-loading"; value.textContent = "Loading\u2026";
badge.appendChild(document.createElement("br")); badge.appendChild(label); badge.appendChild(value);
return badge; }
/** * Finds the text node holding the Posts/Merit/Activity stats so the badge * can be placed directly after it. Prefers "Posts:", then "Merit:", then * "Activity:", matching the visual order Bitcointalk uses. */ function findAnchorNode(small) { let posts = null; let merit = null; let activity = null;
for (const node of small.childNodes) { if (node.nodeType !== Node.TEXT_NODE) continue;
const text = node.textContent || "";
if (!posts && text.includes("Posts:")) posts = node; if (!merit && text.includes("Merit:")) merit = node; if (!activity && text.includes("Activity:")) activity = node; }
return posts || merit || activity; }
function renderResult(badge, uid, result) { const value = badge.querySelector(".bth-value");
if (!value) return;
if (!result.ok) { value.classList.remove("bth-loading"); value.classList.add("bth-error"); value.textContent = "Unavailable"; value.title = "Could not load the Post Health Score: " + (result.error || "unknown error") + ". The API may be starting up \u2014 reload the page in a moment."; return; }
const info = scorePresentation(result.score, result.message);
const link = document.createElement("a"); link.className = "bth-value bth-link"; link.href = result.score ? API_ROOT + "/?uid=" + encodeURIComponent(uid) : API_ROOT + "/"; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = info.text; link.title = info.title; link.style.color = info.color; link.style.fontWeight = info.bold ? "600" : "400";
value.replaceWith(link); }
function insertBadge(cell, uid) { if (cell.querySelector("." + BADGE_CLASS)) return;
const small = cell.querySelector(".smalltext"); if (!small) return;
const badge = createBadge(); const anchor = findAnchorNode(small);
if (anchor && anchor.parentNode === small) { small.insertBefore(badge, anchor.nextSibling); } else { small.appendChild(badge); }
getScore(uid).then(result => renderResult(badge, uid, result)); }
function processPage() { const cells = document.querySelectorAll("td.poster_info");
log("poster cells:", cells.length);
cells.forEach(cell => { const uid = getUid(cell);
if (!uid) return;
insertBadge(cell, uid); }); }
// ---------------------------------------------------------------- styles
/** * Injects the stylesheet. GM_addStyle is deliberately not used: it is * absent in Greasemonkey 4 and would need another grant, whereas a plain * <style> element behaves identically in all three managers. */ function addStyles() { if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = [ "." + BADGE_CLASS + " {", " font-size: 12px;", " line-height: 1.45;", " font-weight: 600;", "}", "." + BADGE_CLASS + " .bth-loading {", " color: #777;", " font-weight: 400;", "}", "." + BADGE_CLASS + " .bth-error {", " color: #777;", " font-weight: 400;", " cursor: help;", "}", "." + BADGE_CLASS + " .bth-link {", " text-decoration: none;", "}", "." + BADGE_CLASS + " .bth-link:hover {", " text-decoration: underline;", "}", "@media (max-width: 600px) {", " ." + BADGE_CLASS + " { font-size: 11px; }", "}" ].join("\n");
(document.head || document.documentElement).appendChild(style); }
// ------------------------------------------------------------- bootstrap
/** * Only topic pages carry poster info cells. This is checked in JS as well * as via @match because @match cannot express query-string conditions. */ function isTopicPage() { try { const url = new URL(location.href);
if (!/(^|\.)bitcointalk\.org$/i.test(url.hostname)) return false; if (url.pathname !== "/index.php") return false;
// Topic pages are ?topic=123 or ?topic=123.20; SMF also accepts // the `;`-delimited form, which URLSearchParams does not split. if (url.searchParams.has("topic")) return true;
return /[?;&]topic=\d+/.test(url.search); } catch (e) { return false; } }
let debounceTimer = null;
function start() { addStyles();
// The cache load is awaited before the first pass so that a warm cache // renders without any network request at all. loadCache().then(processPage);
const observer = new MutationObserver(mutations => { // Ignore mutations caused by our own badge insertions. const relevant = mutations.some(m => { for (const node of m.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; if (node.classList && node.classList.contains(BADGE_CLASS)) continue; if (node.id === STYLE_ID) continue; return true; } return false; });
if (!relevant) return;
if (debounceTimer !== null) clearTimeout(debounceTimer); debounceTimer = setTimeout(processPage, 500); });
if (document.body) { observer.observe(document.body, { childList: true, subtree: true }); }
// Flush any pending cache write before the page goes away, otherwise a // quick navigation loses everything learned on this page. window.addEventListener("pagehide", () => { if (flushTimer === null) return;
clearTimeout(flushTimer); flushTimer = null; pruneCache();
const plain = {}; for (const [uid, entry] of cache) plain[uid] = entry;
store.set(STORAGE_KEY, JSON.stringify(plain)) .catch(() => { /* the page is closing; nothing to recover */ }); });
log("started via", requester ? requester.name : "no requester", "and", store.name); }
if (isTopicPage()) { start(); } else { log("not a topic page, idle"); } })();
Output 
|