Greasy Fork

Greasy Fork is available in English.

QQ/SB/SV Score

Add score indicator to threads using the (likes/replies) metric.

当前为 2025-09-06 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         QQ/SB/SV Score
// @description  Add score indicator to threads using the (likes/replies) metric.
// @version      0.4
// @author       C89sd
// @namespace    http://greasyfork.icu/users/1376767
// @match        https://*.alternatehistory.com/*
// @match        https://*.questionablequesting.com/*
// @match        https://*.spacebattles.com/*
// @match        https://*.sufficientvelocity.com/*
// @grant        GM_addStyle
// @noframes
// ==/UserScript==
'use strict';

const ALIGN_LEFT = true;
const COMPACT    = false;

if (window.location.href.includes('/threads/')) return;

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 VERSION = 7;
const KEY = 'measure_scale';
const NMAX = 10000.0; // maximum effective sample size
let data = JSON.parse(localStorage.getItem(KEY) || '{}');
if (!data.version || data.version !== VERSION) {
    data = { version: VERSION, mean: 0, M2: 0, count: 0, version: VERSION };
}
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; }
  }
`);

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;
}

const threads = document.getElementsByClassName('structItem--thread');
// const threads = document.querySelectorAll('.js-threadList>.structItem--thread, .structItemContainer>.structItem--thread');

const DEBUG = false;
for (const thread of threads) {
    const meta = thread.querySelector(':scope > .structItem-cell--meta');
    if (!meta) { DEBUG && console.log('Scorer skip: no meta cell', thread); continue; }

    // First message score, title="First message reaction score: 30"
    //
    //   !! On some site its the TOTAL LIKES not FIRST LIKES !!
    //
    const titleAttr = meta.getAttribute('title');
    const likesMatch = titleAttr?.match(/([\d,]+)/);
    if (!likesMatch) { DEBUG && console.log('Scorer skip: no likes match in title', thread); continue; }
    const likes = parseInt(likesMatch[1].replace(/,/g, ''), 10);

    // Replies
    const repliesText = meta.firstElementChild?.lastElementChild?.textContent;
    let replies = parseKmDigit(repliesText);
    if (replies === 0) replies = 1;
    if (isNaN(likes) || isNaN(replies)) { DEBUG && console.log('Scorer skip: NaN likes/replies', {likes, replies, thread}); continue; }
    if (replies > 1000) {
        // replies >= 999 becomes 1k, 2k, 3k ... resolution loss
        // instead we estimate the replies via page count (25 posts per page)
        let pagesEl = thread.querySelector('.structItem-pageJump a:last-of-type');
        if (pagesEl && pagesEl.textContent.trim() === 'New') { // AH has the last page be a New link, grab previous one
            pagesEl = pagesEl.previousElementSibling;
        }

        const pages = pagesEl ? parseInt(pagesEl.textContent, 10) : 1;
        replies = Math.max(replies, Math.floor((pages - 0.5) * 25));
    }

    // Views
    const viewsText = meta.firstElementChild?.nextElementSibling?.lastElementChild?.textContent;
    let views = parseKmDigit(viewsText);

    // Words
    const wordsText = thread.querySelector('.structItem-parts > li > a[data-xf-click="overlay"]')?.textContent;
    const wordsMatch = wordsText?.trim().split(' ').pop();
    if (!wordsMatch) { DEBUG && console.log('Scorer skip: no words match in title', thread); continue; }
    let words = parseKmDigit(wordsMatch);

    DEBUG && console.log(likes, replies, words, views, thread)

    // const score = (scale * likes / replies).toFixed(PT);
    // const score = (1000* scale * replies / words).toFixed(PT);
    // const score = (1000* scale *  (Math.max(replies, views/140) / words) ).toFixed(PT);

    let score = 10 * Math.log1p(replies) / Math.log1p(words);
    // score = Math.log1p(score);
    // console.log(score)

    let displayScore = adjustedScore(score);

  {
    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);
  }
  {
    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#
    updateStreaming(score);
}

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} }; }`);