您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
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.16 // @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 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, 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); }