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

// ==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.11
// @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 = 12;
const KEY = 'measure_scale';
const NMAX = 1500;
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 = 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',
      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 (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} }; }`);


// -------------------------------------------------------------------------------------

// Add footer [sort by score] and [hide seen] selectors.
if (!window.location.href.includes('/threads/')) {

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