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.6
// @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;
const CORNER_INDICATOR = true; // false: text; true: colored box
let   CORNER_TOP       = true; // true: trop corner, false: MOBILE-only bottom corner

if (window.location.href.includes('/threads/')) 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 VERSION = 11;
const KEY = 'measure_scale';
const NMAX = 3000;
const LRU_MAX = 300;

let data = JSON.parse(localStorage.getItem(KEY) || '{}');
if (!data.version || data.version !== VERSION) {
    data = { version: VERSION, mean: 0, M2: 0, count: 0, lruBuffer: [] };
}
const lruArray = data.lruBuffer;
function addToLRU(key) {
    const idx = lruArray.indexOf(key);
    if (idx !== -1) { // remove old copy unless at the front
        if (idx === 0) return false;
        lruArray.splice(idx, 1);
    }
    else if (lruArray.length === LRU_MAX) lruArray.pop(); // drop the oldest entry
    lruArray.unshift(key); // put the key at the front
    return idx === -1;     // was it a miss?
}
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; }

    let threadid = parseInt(thread.className.match(/\bjs-threadListItem-(\d+)/)[1], 10);

    // // 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);
    // if (isNaN(likes) ) { DEBUG && console.log('Scorer skip: NaN likes', {likes, thread}); continue; }

    // Replies
    const repliesText = meta.firstElementChild?.lastElementChild?.textContent;
    let replies = parseKmDigit(repliesText);
    if (replies === 0) replies = 1;
    if (isNaN(replies)) { DEBUG && console.log('Scorer skip: NaN replies', {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)

    let score = 10 * Math.log1p(replies) / Math.log1p(words);
    let displayScore = adjustedScore(score);

  // 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',
      'position:absolute',
      'color:rgb(42,42,42)',
      'background:' + color(displayScore / 100, 1, true),
      'float:right',
      'margin-left:4px',
       CORNER_TOP ? 'top:9px' : 'bottom:9px',//'margin-bottom:9px',
      'right: 9px'
    ].join(';');

    thread.style.position = 'relative';
    // thread.appendChild(indicator);

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