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].

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