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 提交的版本,查看 最新版本

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