// ==UserScript==
// @name AH/QQ/SB/SV Score
// @description Add self-calibrating (per subforum) score indicator using the (replies/words) and (likes/views) metrics. Add footer toggles [unsorted|autosort] and [show seen|hide seen].
// @version 0.26
// @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
// @run-at document-idle
// @noframes
// ==/UserScript==
'use strict';
const ALIGN_LEFT = true;
const COMPACT = false;
const CORNER_INDICATOR = true; // false: text; true: colored box
const CORNER_TOP = true; // true: trop corner, false: MOBILE-only bottom corner
const INDICATOR2 = false; // debug in CORNER_TOP
const VERSION = 36; // 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/');
let site = location.hostname.split('.').slice(-2, -1)[0];
const FORUM_MAP = {
alternatehistory: {},
spacebattles: {
'story-only': 'quests',
'creative-writing-archives': 'creative-writing',
'unlisted-original-fiction': 'creative-writing',
'original-fiction': 'creative-writing',
'worm': 'creative-writing',
},
sufficientvelocity: {
'archive': 'user-fiction',
'quests-archive': 'quests',
},
questionablequesting: {
'story-archive': 'creative-writing',
'quest-archive': 'questing',
'nsfw-story-archive': 'nsfw-creative-writing',
'nsfw-quest-archive': 'nsfw-questing',
}
}[site];
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,
streams: {},
lruBuffer: []
};
}
const DEFAULT_STREAM = [ /*words/replies*/ { mean: 0, M2: 0, count: 0 }, /*views/likes*/ { mean: 0, M2: 0, count: 0 }];
// 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;
}
function updateStreaming(forum, s, score) {
if (!(forum in data.streams)) {
data.streams[forum] = structuredClone(DEFAULT_STREAM);
}
const streams = data.streams[forum];
const stream = streams[s];
const weight = stream.count < NMAX ? 1 / (stream.count + 1) : 1 / NMAX;
const delta = score - stream.mean;
stream.mean += weight * delta;
stream.M2 = (1 - weight) * stream.M2 + weight * delta * (score - stream.mean);
if (stream.count < NMAX) stream.count++;
}
// --- adjusted score 0–100
function adjustedScore(forum, s, score) {
const streams = data.streams[forum];
if (!streams) return NaN;
const stream = streams[s];
const MEASUREMENT = getMeasurement(stream);
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);
}
function getMeasurement(stream, s) {
const { mean, M2, count } = stream;
const std = Math.sqrt(M2);
return { mean, std, n: count };
}
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 { hasMeta: 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 {hasMeta: true, threadid, replies, words, likes, views};
}
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;
}
function slugify(str) {
if (!str) return '';
return str
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '-');
}
let threads;
// if (IS_FORUM) threads = document.querySelectorAll('.structItem--thread[class*="js-threadListItem-"]');
if (IS_FORUM) threads = document.querySelectorAll(
'.js-threadList>.structItem--thread[class*="js-threadListItem-"],' + // main forum, 'js-threadList' ignores sticky
'.structItemContainer>.structItem--thread[class*="js-threadListItem-"]' // /watched/threads
);
if (IS_SEARCH) threads = document.querySelectorAll('.block-body > li.block-row');
function getForum(url) { return url?.match(/\/forums\/([^\/]*)\.\d+/)[1]; }
let FORUM;
// -------- pass 1: gather raw data
let rawData = [];
for (const thread of threads) {
let hasMeta, threadid, replies, words, likes = NaN, views = NaN;
let forum_in_post; // some posts have links to a subforum
if (IS_FORUM) {
({ hasMeta, threadid, replies, words, likes, views } = parseForumMetrics(thread));
if (!hasMeta) continue;
FORUM = getForum(location.href); // DEFAULT, this page
forum_in_post = slugify(thread.querySelector('.structItem-parts a.labelLink')?.textContent); // not a subforum link but a filter! we slugify ourselves
}
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]);
FORUM = getForum(thread.querySelector('a[href*="/forums/"]')?.href);
forum_in_post = getForum(thread.querySelector('.contentRow-minor > .listInline > li > a[href*="/forums/"]')?.href); // direct subforum link
}
if (forum_in_post) FORUM = forum_in_post;
FORUM = FORUM_MAP[FORUM] ?? FORUM;
// -------- independent validity checks
let score1 = null;
let score2 = null;
if (typeof words === 'number' && !Number.isNaN(words) && words >= 10 &&
typeof replies === 'number' && !Number.isNaN(replies) && replies >= 2) {
score1 = Math.log1p(replies) - Math.log1p(words);
}
if (IS_FORUM &&
typeof views === 'number' && !Number.isNaN(views) && views >= 100 &&
typeof likes === 'number' && !Number.isNaN(likes) && likes >= 2) {
score2 = Math.log1p(likes) - Math.log1p(views);
}
rawData.push({ thread, FORUM, score1, score2, threadid, replies, likes });
// console.log(rawData[rawData.length-1])
}
// -------- pass 2: batch LRU + streaming update
// if (IS_FORUM) {
for (const d of rawData) {
if (addToLRU(d.threadid)) {
if (d.score1 !== null) updateStreaming(d.FORUM, 0, d.score1);
if (d.score2 !== null) updateStreaming(d.FORUM, 1, d.score2);
}
}
// }
// -------- pass 3: adjusted score, rank, indicators, sortData
let sortData = [], idx = -1;
for (const d of rawData) {
idx++;
const { thread, score1, score2, replies, likes } = d;
const displayScore1 = score1 != null ? adjustedScore(d.FORUM, 0, score1) : null;
const displayScore2 = score2 != null ? adjustedScore(d.FORUM, 1, score2) : null;
const displayScore = (displayScore1 != null && displayScore2 != null) ? Math.max(displayScore1, displayScore2) : (displayScore1 ?? displayScore2);
// ---------- rank (now that displayScore is known)
let rank, sortScore;
// if (score1 !== null && score1 !== null) { rank = 1; sortScore = displayScore; } // sort by mixed score
// else if (score1 !== null || score2 !== null) { rank = 2; sortScore = displayScore; } // sort by only score2
if (displayScore !== null) { rank = 1; sortScore = displayScore; } // sort mixed/fallback at same level in case words were deleted (STUB)
else if (replies > 1) { rank = 3; sortScore = replies; } // fallback replies
else if (likes > 1) { rank = 4; sortScore = likes; } // fallback likes
else { rank = 5; sortScore = NaN; } // default order
sortData.push({ thread, idx, rank, score: sortScore });
// MOBILE
if (CORNER_INDICATOR) {
let makeIndicator = (score, n=1) => {
if (score === null) {
if (IS_FORUM && INDICATOR2) score = NaN;
else return null;
}
let i = document.createElement('div');
i.className = 'scoreA';
i.textContent = score.toFixed(PT);
i.style.cssText = [
'display:block',
'width:25px', //INDICATOR2 ? 'width:25px' : 'width:28px',
'text-align:center',
'line-height:18px',
'padding:0',
CORNER_TOP ? 'position:relative' : 'position:absolute',
'color:rgb(42,42,42)',
'background:' + color(score / 100, 1, true),
'float:right',
n==2 ? 'margin-left:0px' : 'margin-left:4px',
CORNER_TOP ? 'top:3px' : 'bottom:9px',
CORNER_TOP ? 'top:3px' : 'right: 9px'
].join(';');
return i;
};
const indicator = makeIndicator(CORNER_TOP&&INDICATOR2 ? displayScore1 : displayScore);
thread.style.position = 'relative';
if (IS_FORUM) {
const indicator2 = INDICATOR2 ? makeIndicator(displayScore2, 2) : null;
if (CORNER_TOP) {
const title = thread.querySelector('.structItem-cell--main');
title.style.paddingRight = '6px';
title.style.paddingTop = '6px';
title.style.position = 'relative';
if (indicator) title.prepend(indicator);
if (indicator2) title.prepend(indicator2);
} else {
if (indicator) thread.append(indicator);
}
} else { // IS_SEARCH
if (indicator) thread.prepend(indicator);
}
} else if (IS_FORUM) {
// DESKTOP indicator
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);
const meta = thread.querySelector(':scope > .structItem-cell--meta');
meta.appendChild(desktopScoreEl);
// MOBILE under-text indicator (desktop hidden via CSS)
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#
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, showNamed: true, showWatched: true, showALL: 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() {
const base = IS_FORUM ? '.structItem--thread:has(.structItem-title>a' : '.block-row:has(.contentRow-title>a';
let text = '';
if (config.showALL) {
text = '.disabled-look { filter: grayscale(100%); opacity: 0.6; pointer-events: none; }';
} else {
if (!config.showSeen) text += base + '.hl-seen:not(.hl-name-seen):not(.hl-watched)) { display: none !important; }';
if (!config.showNamed) text += base + '.hl-name-seen:not(.hl-watched)) { display: none !important; }';
if (!config.showWatched) text += base + '.hl-watched) { display: none !important; }';
}
style.textContent = text;
}
// `sortData` already exists and looks like:
// { thread : <li>, idx : Number, rank : 1|2|3|4, score : Number }
let sorted = false; //
function updateSort () {
if (sorted === config.sortByScore) return; // nothing changed
sorted = config.sortByScore;
// comparators
const byRankScoreIdx = (a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank; // rank 1 beats 2,3,4
if (a.rank === 5) return a.idx - b.idx; // rank 5 keeps old order (NaN score)
if (a.score !== b.score) return b.score - a.score; // higher score first
return a.idx - b.idx; // keep old order
};
const byIdx = (a, b) => a.idx - b.idx; // restore original
// sort once and redraw
sortData.sort(sorted ? byRankScoreIdx : byIdx);
sortData.forEach(({ thread }) => thread.parentElement.appendChild(thread));
}
// Helper function for creating checkboxes
function createCheckbox(name, key, canBeDisabled=false) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = config[key];
checkbox.addEventListener('change', () => {
// preserve distance from bottom so the toggled items don't shift the checkbox away from the user's viewport
const docEl = document.documentElement;
const distFromBottom = docEl.scrollHeight - window.scrollY - window.innerHeight;
config[key] = checkbox.checked;
localStorage.setItem('_showseen', JSON.stringify(config));
updateVisibilityStyles();
// restore after layout changes
requestAnimationFrame(() => {
const maxScroll = docEl.scrollHeight - window.innerHeight;
const target = docEl.scrollHeight - window.innerHeight - distFromBottom;
const newScroll = Math.max(0, Math.min(maxScroll, target));
window.scrollTo(0, newScroll);
});
});
const labelEl = document.createElement('label');
if (canBeDisabled) labelEl.classList.add('disabled-look');
labelEl.style.display = 'flex';
labelEl.style.alignItems = 'center';
labelEl.style.gap = '4px';
labelEl.appendChild(checkbox);
labelEl.appendChild(document.createTextNode(name));
return labelEl;
}
// Create checkboxes
const allLabel = createCheckbox('ALL', 'showALL');
const seenLabel = createCheckbox('seen', 'showSeen', true);
const namedLabel = createCheckbox('named', 'showNamed', true);
const watchedLabel = createCheckbox('watched', 'showWatched', true);
// 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('click', handler);
select.addEventListener('change', handler);
return select;
}
// Sorting selector
const sortSelector = createSelect(
'<option value="false">Unsorted</option><option value="true">Autosort</option>',
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');
updateVisibilityStyles();
let isUserPage = location.href.includes('/members/') || location.search.includes('c[users]=');
if (!isUserPage) 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(allLabel);
controlBar.appendChild(watchedLabel);
controlBar.appendChild(namedLabel);
controlBar.appendChild(seenLabel);
footer.insertBefore(controlBar, footerInner);
}