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.14
// @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 = 30;   // 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');

let sortData=[], idx=-1;

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

    if (IS_FORUM) {
      ({hasMeta, threadid, replies, words, likes, views} = parseForumMetrics(thread));
      if (!hasMeta) continue; // no meta is an error, dont push sortedData to keep it visible
    }

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

    // prevent NaN corruption (no indicator found)
    // filter out tail hump seen on correlation plot (words<1, replies<2)
    // note: - words<10 to filter deleted works
    //       - STUB fics are still an issue and get inflated score with current metric
     // the metric log1p(views)-log1p(likes) would solve that but is a worse engagement metric, we use it as a fallback score, then replies then likes
    if (typeof words   !== 'number' || Number.isNaN(words)   || words   < 10 ||
        typeof replies !== 'number' || Number.isNaN(replies) || replies < 2) {
        if (views>10||likes>2) sortData.push({thread, idx, rank: 2, score: Math.log1p(likes)-Math.log1p(views) });
        else if (replies>1)    sortData.push({thread, idx, rank: 3, score: replies });
        else if (likes>1)      sortData.push({thread, idx, rank: 4, score: likes });
        else                   sortData.push({thread, idx, rank: 5, score: NaN });
      // console.log(thread, threadid, words, likes, views, replies)
        continue;
    }

    let score = Math.log1p(replies) - Math.log1p(words);

    let score2 = null;
    if (IS_FORUM) {
      if (typeof views !== 'number' || Number.isNaN(views) || views < 100 ||
          typeof likes !== 'number' || Number.isNaN(likes) || likes < 2) {
        // sortData.push({thread, idx, rank: 5, score: NaN });
        // continue;
      } else {
        score2 = Math.log1p(likes) - Math.log1p(views);
      }
    }

    const INDICATOR2 = false;

    let displayScore  = adjustedScore(1, score);
    let displayScore2 = score2===null ? 0 : 1*adjustedScore(2, score2);

    if (INDICATOR2) displayScore = Math.max(displayScore, displayScore2); // blend scores together, not uniform, but makes more high score (low score more likely to be bad)

    sortData.push({thread, idx, rank: 1, score: displayScore});

  // console.log(score, displayScore, threadid, words, replies, thread)
  // console.log(threadid, typeof threadid)

  // MOBILE
  if (CORNER_INDICATOR) { // corner
    let makeIndicator = (score) => {
      let i = document.createElement('div');
      i.className = 'scoreA';
      i.textContent = score.toFixed(PT);
      i.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(score / 100, 1, true),
        'float:right',
        'margin-left:4px',
         CORNER_TOP ? 'top:3px' : 'bottom:9px',//'margin-bottom:9px',
         CORNER_TOP ? 'top:3px' : 'right: 9px'
      ].join(';');
      return i;
    }

    const indicator = makeIndicator(displayScore);
    thread.style.position = 'relative';

    if (IS_FORUM) {

      let indicator2 = INDICATOR2 ? makeIndicator(displayScore2) : null;

      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);
          if (indicator2) title.prepend(indicator2);
      }
      else {
          if (indicator2) thread.append(indicator2);
          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(1, score);
        if (score2 !== null) updateStreaming(2, score2);
        // 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; }`;
}

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

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