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

您需要先安装一款用户脚本管理器扩展,例如 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.22
// @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
// @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 = 33;   // 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/');

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,
      1: { mean: 0, M2: 0, count: 0 },
      2: { 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, always push key keys on front without removing them, evicts other keys quicker
// // 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(s, score) {
    const d = data[s]; // local ref
    const weight = d.count < NMAX ? 1 / (d.count + 1) : 1 / NMAX;
    const delta  = score - d.mean;

    d.mean += weight * delta;
    d.M2    = (1 - weight) * d.M2 + weight * delta * (score - d.mean);

    if (d.count < NMAX) d.count++;
}

function getMeasurement(s) {
  const { mean, M2, count } = data[s];
  const std = Math.sqrt(M2);
  return { mean, std, n: count };
}

// --- adjusted score 0–100
function adjustedScore(s, score) {
    const MEASUREMENT = getMeasurement(s);
    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 { 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;
}

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



// -------- pass 1: gather raw data
let rawData = [];

for (const thread of threads) {
    let hasMeta, threadid, replies, words, likes = NaN, views = NaN;

    if (IS_FORUM) {
        ({ hasMeta, threadid, replies, words, likes, views } = parseForumMetrics(thread));
        if (!hasMeta) 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]);
    }

    // -------- 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, 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(1, d.score1);
          if (d.score2 !== null) updateStreaming(2, 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(1, score1) : null;
    const displayScore2 = score2 != null ? adjustedScore(2, 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, 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.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) {
    const checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.checked = config[key];
    checkbox.addEventListener('change', () => {
        config[key] = checkbox.checked;
        localStorage.setItem('_showseen', JSON.stringify(config));
        updateVisibilityStyles();
    });

    const labelEl = document.createElement('label');
    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 seenLabel    = createCheckbox('seen',    'showSeen');
const namedLabel   = createCheckbox('named',   'showNamed');
const watchedLabel = createCheckbox('watched', 'showWatched');

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

// 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();
    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(watchedLabel);
    controlBar.appendChild(namedLabel);
    controlBar.appendChild(seenLabel);

    footer.insertBefore(controlBar, footerInner);
}