// ==UserScript==
// @name QQ/SB/SV Score
// @description Add self-calibrating score indicator per-thread using the (replies/words) metric. Add footer toggles [unsorted|autosort] and [show seen|hide seen].
// @version 0.13
// @author C89sd
// @namespace https://greasyfork.org/users/1376767
// @match https://*.alternatehistory.com/*
// @match https://*.questionablequesting.com/*
// @match https://*.spacebattles.com/*
// @match https://*.sufficientvelocity.com/*
// @grant GM_addStyle
// @noframes
// @downloadURL none
// ==/UserScript==
'use strict';
const ALIGN_LEFT = true;
const COMPACT = false;
const CORNER_INDICATOR = true; // false: text; true: colored box
let CORNER_TOP = true; // true: trop corner, false: MOBILE-only bottom corner
const VERSION = 25; // change to reset DB
const NMAX = 1500; // 10 pages change the score by 20%
const LRU_MAX = 300; // recount a thread after 10 pages
let IS_SEARCH = window.location.href.includes('/search/');
let IS_FORUM = window.location.href.includes('/watched/') || window.location.href.includes('/forums/');
if (!IS_SEARCH && !IS_FORUM) return;
GM_addStyle(`
/* hide on dekstop */
@media (min-width: 650px) { .structItem--thread>.scoreA { display: none !important; } }
:root {
--boost: 85%;
--boostDM: 75%; /* 82%; */
--darken: 55%;
--darkenDM: 33.3%;
}
:root.dark-theme {
--darken: var(--darkenDM);
--boost: var(--boostDM);
}
.scoreA {
background-image: linear-gradient(hsl(0, 0%, var(--boost)), hsl(0, 0%, var(--boost))) !important;
background-blend-mode: color-burn !important;
}
.scoreA.darkenA {
background-image: linear-gradient(hsl(0, 0%, var(--darken)), hsl(0, 0%, var(--darken))) !important;
background-blend-mode: multiply !important;
}
`);
const DM = window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128;
if (DM) document.documentElement.classList.add('dark-theme');
// if (dimmed) { indicator.classList.add('darkenA'); }
const HSL_STRINGS = [
'hsl(0.0, 90.7%, 92.3%)',
'hsl(47.8, 67.1%, 81.5%)',
'hsl(118.4, 51.2%, 85%)',
'hsl(122.9, 35.1%, 63.4%)',
];
const COLORS = HSL_STRINGS.map(str => (([h, s, l]) => ({ h, s, l }))(str.match(/[\d.]+/g).map(Number)));
function clamp(a, b, x) { return x < a ? a : (x > b ? b : x); }
function color(t, range=1.0, use3colors=false) {
let a, b;
t = t/range;
if (t < 0) { t = 0.0; }
if (use3colors && t > 1.0) { t = 1.0; }
else if (t > 1.5) { t = 1.5; }
if (t < 0.5) {
a = COLORS[0], b = COLORS[1];
t = t * 2.0;
} else if (t <= 1.0) {
a = COLORS[1], b = COLORS[2];
t = (t - 0.5) * 2.0;
} else {
a = COLORS[2], b = COLORS[3];
t = (t - 1.0) * 2.0;
}
const h = clamp(0, 360, a.h + (b.h - a.h) * t);
const s = clamp(0, 100, a.s + (b.s - a.s) * t);
const l = clamp(0, 100, a.l + (b.l - a.l) * t);
return `hsl(${h.toFixed(1)}, ${s.toFixed(1)}%, ${l.toFixed(1)}%)`;
}
let scale, PT;
const domain = window.location.hostname.split('.').slice(-2, -1)[0].toLowerCase();
PT = 0;
function ncdf(z) {
let t = 1 / (1 + 0.2315419 * Math.abs(z));
let d = 0.3989423 * Math.exp(-z * z / 2);
let prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
if (z > 0) prob = 1 - prob;
return prob;
}
// #MEASURE_SCALE#
// ----------- INIT
const KEY = 'measure_scale';
let data = JSON.parse(localStorage.getItem(KEY) || '{}');
if (!data.version || data.version !== VERSION) {
data = { version: VERSION, mean: 0, M2: 0, count: 0, lruBuffer: [] };
}
// console.log(localStorage.getItem(KEY).length / 1024, 'kb')
const lruArray = data.lruBuffer;
while (lruArray.length > LRU_MAX) lruArray.pop(); // cutoff in case LRU_MAX changes
// inserts/moves key to the front, returns if it was already present
function addToLRU(key) {
const idx = lruArray.indexOf(key);
const miss = idx === -1; // true: key wasn’t there
if (!miss) lruArray.splice(idx, 1); // remove old copy
lruArray.unshift(key); // insert at the front
if (lruArray.length > LRU_MAX) lruArray.pop(); // cutoff
return miss;
}
// // :: non LRU version, always push key keys on front without removing them, evicts other keys quicker
// // inserts key at the front only if it is NOT already present
// function addToLRU(key) {
// if (lruArray.includes(key)) return false;
// lruArray.unshift(key); // put new key at the front
// if (lruArray.length > LRU_MAX) lruArray.pop(); // trim if necessary
// return true;
// }
function updateStreaming(score) {
// determine effective weight
const weight = data.count < NMAX ? 1 / (data.count + 1) : 1 / NMAX;
// update mean and variance
const delta = score - data.mean;
data.mean += weight * delta;
data.M2 = (1 - weight) * data.M2 + weight * delta * (score - data.mean);
// increment count up to NMAX
if (data.count < NMAX) data.count++;
}
function getMeasurement() {
const variance = data.M2 || 0;
const stddev = Math.sqrt(variance);
return { mean: data.mean, std: stddev, n: data.count };
}
// --- adjusted score 0–100
function adjustedScore(score) {
const MEASUREMENT = getMeasurement();
if (MEASUREMENT.std === 0) return NaN; // avoid division by zero
const z = (score - MEASUREMENT.mean) / MEASUREMENT.std;
const p = ncdf(z);
return Math.min(Math.max(p * 100, 0), 100);
}
GM_addStyle(`
/* Hide on large screens */
.mscore {
display: none;
}
/* Show on mobile */
@media (max-width: 650px) {
.mscore {
display: block;
}
.mscore.mright {
float: right !important;
}
.mscore.mleft.mcompact {
padding-left: 4px !important;
}
.mscore::before { content: none !important; }
.mscore.mleft:not(.mcompact)::before{ content: "\\00A0\\00B7\\20\\00A0\\00A0" !important; }
}
`);
const DEBUG = false;
function parseForumMetrics(thread) {
let threadid = parseInt(thread.className.match(/\bjs-threadListItem-(\d+)/)[1], 10);
let meta = thread.querySelector(':scope > .structItem-cell--meta');
if (!meta) { DEBUG && console.log('Scorer skip: no meta cell', thread); return { ok: false }; }
// Hover replies to see title="First message reaction score: 30"
// SB/SV: Total likes
// QQ: First message likes
// let likes = parseInt(meta.getAttribute('title')?.match(/([\d,]+)/)[1].replace(/,/g, ''), 10);
// Replies
let replies = parseKmDigit(meta.firstElementChild?.lastElementChild?.textContent);
let pagesEl = thread.querySelector('.structItem-pageJump a:last-of-type');
if (pagesEl && pagesEl.textContent.trim() === 'New') { pagesEl = pagesEl.previousElementSibling; } // on AH, the last page number is a "New" link
const pages = pagesEl ? parseInt(pagesEl.textContent, 10) : 1;
// Better estimate of the replies via page count (25 posts per page) above 1k
if (replies >= 1000) {
replies = Math.max(replies, Math.floor((pages - 0.5) * 25)); // assume last page is half
}
// // Views
// let views = parseKmDigit(meta.firstElementChild?.nextElementSibling?.lastElementChild?.textContent);
// Words
// let isThread = !!thread.querySelector('.structItem-parts > li > a[data-xf-click="overlay"]');
let words = parseKmDigit(thread.querySelector('.structItem-parts > li > a[data-xf-click="overlay"]')?.textContent?.trim().split(' ').pop());
// let dates = thread.querySelectorAll('time');
// let first_message = dates[0].getAttribute('data-time');
// let last_message = dates[1].getAttribute('data-time');
// let forum = location.pathname.split('/')[2];
// let title = thread.querySelector('.structItem-title a[href*="/threads/"][data-tp-primary="on"]').textContent;
// let url = thread.querySelector('.structItem-title a[href*="/threads/"][data-tp-primary="on"]').href;
// let author = thread.querySelector('.username').textContent;
// let tags = Array.from(thread.querySelectorAll('.structItem-tagBlock > a')).map(a => a.textContent.trim());
return {ok: true, threadid, replies, words};
}
function parseKmDigit(text) {
if (!text) return NaN;
const cleanedText = text.trim().toLowerCase();
const multiplier = cleanedText.endsWith('k') ? 1000 : cleanedText.endsWith('m') ? 1000000 : 1;
return parseFloat(cleanedText.replace(/,/g, '')) * multiplier;
}
let threads;
if (IS_FORUM) threads = document.querySelectorAll('.structItem--thread[class*="js-threadListItem-"]');
if (IS_SEARCH) threads = document.querySelectorAll('.block-body > li.block-row');
for (const thread of threads) {
let ok, threadid, replies, words;
if (IS_FORUM) {
({ok, threadid, replies, words} = parseForumMetrics(thread));
if (!ok) continue;
}
if (IS_SEARCH) {
threadid = parseInt(thread.querySelector('.contentRow-title a[href*="/threads/"]').href.match(/\/threads\/[^\/]*?\.(\d+)\//)?.[1], 10);
words = parseKmDigit(thread.querySelector('.wordcount')?.textContent);
const repliesEl = [...thread.querySelectorAll('.contentRow-minor li')].find(li => li?.textContent.trim().startsWith('Replies:'));
replies = parseKmDigit(repliesEl?.textContent.split(' ')[1]);
// console.log(threadid, words, replies, repliesEl)
}
// prevent NaN corruption (no indicator found)
// filter out tail hump seen on correlation plot (words<1, replies<2)
// note: - words<10 to filter deleted works
// - STUB fics are still an issue and get inflated score with current metric
// the metric log1p(views)-log1p(likes) would solve that but is a worse engagement metric
if (typeof words !== 'number' || Number.isNaN(words) || words < 10 ||
typeof replies !== 'number' || Number.isNaN(replies) || replies < 2) {
continue;
}
let score = Math.log1p(replies) - Math.log1p(words);
let displayScore = adjustedScore(score);
// console.log(score, displayScore, threadid, words, replies, thread)
// console.log(threadid, typeof threadid)
// MOBILE
if (CORNER_INDICATOR) { // corner
const indicator = document.createElement('div');
indicator.className = 'scoreA';
indicator.textContent = displayScore.toFixed(PT);
indicator.style.cssText = [
'display:block',
'width:28px',
'text-align:center',
'line-height:18px',
'padding:0',
CORNER_TOP ? 'position:relative' : 'position:absolute',
'color:rgb(42,42,42)',
'background:' + color(displayScore / 100, 1, true),
'float:right',
'margin-left:4px',
CORNER_TOP ? 'top:3px' : 'bottom:9px',//'margin-bottom:9px',
CORNER_TOP ? 'top:3px' : 'right: 9px'
].join(';');
thread.style.position = 'relative';
if (IS_FORUM) {
if (CORNER_TOP) {
let title = thread.querySelector('.structItem-cell--main')
title.style.paddingRight = '6px';
title.style.paddingTop = '6px';
title.style.position = 'relative';
title.prepend(indicator);
}
else {
thread.append(indicator);
}
}
else { // IS_SEARCH
thread.prepend(indicator);
}
}
else if (IS_FORUM)
{
// DESKTOP -- right column
{
const desktopScoreEl = document.createElement('dl');
desktopScoreEl.className = 'pairs pairs--justified structItem-minor';
const dt = document.createElement('dt');
dt.textContent = 'Score';
const dd = document.createElement('dd');
dd.appendChild(document.createTextNode(displayScore.toFixed(PT)));
desktopScoreEl.appendChild(dt);
desktopScoreEl.appendChild(dd);
meta.appendChild(desktopScoreEl);
}
// Text Under
{
const mobileScoreEl = document.createElement('div');
mobileScoreEl.className = 'structItem-cell structItem-cell--latest mscore ' + (ALIGN_LEFT ? "mleft" : "mright") + (COMPACT ? " mcompact":"");
mobileScoreEl.textContent = displayScore.toFixed(PT);
mobileScoreEl.style.width = 'auto'
if (ALIGN_LEFT) thread.insertBefore(mobileScoreEl, thread.querySelector('.structItem-cell--latest') || null);
else thread.appendChild(mobileScoreEl);
}
}
// #MEASURE_SCALE#
if (addToLRU(threadid)) {
updateStreaming(score);
// console.log('ADDED', threadid)
} else {
// console.log('skipped', threadid)
}
}
localStorage.setItem(KEY, JSON.stringify(data));
// // ----------- VISUALISE
// let MEASUREMENT = getMeasurement();
// console.log(`{ MEASUREMENT = { mean:${MEASUREMENT.mean.toFixed(4)}, std:${MEASUREMENT.std.toFixed(4)}, n:${MEASUREMENT.n} }; }`);
// -------------------------------------------------------------------------------------
// Add footer [sort by score] and [hide seen] selectors.
const DEFAULT_CONFIG = { showSeen: true, sortByScore: false };
let config = { ...DEFAULT_CONFIG, ...JSON.parse(localStorage.getItem('_showseen') || '{}') };
// Style injection logic
const style = document.createElement('style');
document.head.appendChild(style);
function updateVisibilityStyles() {
style.textContent = config.showSeen ? '' : `.structItem--thread:has(.hl-name-seen), .structItem--thread:has(.hl-seen), .structItem--thread:has(.hl-watched) { display: none !important; }`;
}
let firstTime = true, list = [];
let sorted = false;
function updateSort() {
if (sorted === config.sortByScore) return;
sorted = config.sortByScore;
if (firstTime) {
firstTime = false;
let threads = document.querySelectorAll('.js-threadList .structItem--thread'); // dont sort Sticky Threads
let idx = 0;
for (let thread of threads) {
let score = -1;
let indicator = thread.querySelector('.scoreA');
if (indicator) score = parseInt(indicator.textContent, 10);
list.push([thread, idx++, score]);
}
}
if (sorted) {
list.sort((a, b) => b[2] - a[2]);
} else {
list.sort((a, b) => b[1] - a[1]);
}
list.forEach(([t, i, s]) => { t.parentElement.appendChild(t); });
}
// Helper function for creating select elements
function createSelect(options, currentValue, handler) {
const select = document.createElement('select');
select.style.width = 'max-content';
select.innerHTML = options;
select.value = currentValue;
select.addEventListener('change', handler);
return select;
}
// Seen posts selector
const seenSelector = createSelect(
'',
config.showSeen.toString(),
() => {
config.showSeen = seenSelector.value === 'true';
localStorage.setItem('_showseen', JSON.stringify(config));
updateVisibilityStyles();
}
);
// Sorting selector
const sortSelector = createSelect(
'',
config.sortByScore.toString(),
() => {
config.sortByScore = sortSelector.value === 'true';
localStorage.setItem('_showseen', JSON.stringify(config));
updateSort();
}
);
const footer = document.getElementById('footer');
if (footer) {
let footerInner = footer.querySelector('.p-footer--container, .p-footer-inner');
console.log()
updateVisibilityStyles();
updateSort();
const controlBar = document.createElement('div');
controlBar.style.width = '100%';
controlBar.style.paddingTop = '5px';
controlBar.style.paddingBottom = '5px';
controlBar.style.display = 'flex';
controlBar.style.justifyContent = 'center';
controlBar.style.gap = '10px';
controlBar.className = 'footer';
controlBar.appendChild(sortSelector);
controlBar.appendChild(seenSelector);
footer.insertBefore(controlBar, footerInner);
}