Greasy Fork

Greasy Fork is available in English.

QQ/SB/SV Score

Add self-calibrating score indicator per-thread using the (replies/words) metric. Add footer toggles [unsorted|autosort] and [show seen|hide seen].

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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.12
// @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

const VERSION = 23;   // change to reset
const NMAX    = 1500; // score over 60 pages
const LRU_MAX = 500;  // recount a thread after 20 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, for more accurate stats integration, counting threads you see often is desireable
// 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)
    }

    if (Number.isNaN(words)) continue;

    let invalid = (words<1 || replies<2); // filter out, correlation graph showed tail hump
    if (invalid) continue;

    let score = Math.log1p(replies) - Math.log1p(words);
    let displayScore = adjustedScore(score);
      // (invalid) ? 0: // 0 or NaN?

    // 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(
    '<option value="true">Show seen</option><option value="false">Hide seen</option>',
    config.showSeen.toString(),
    () => {
        config.showSeen = seenSelector.value === 'true';
        localStorage.setItem('_showseen', JSON.stringify(config));
        updateVisibilityStyles();
    }
);

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