您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Quality score indicator with page autosort. Calibrated using a piecewise regression in log–log PCA space (~3500 works); uses CDF ranking. Adds live sorting and position toggles (top or bottom corner).
// ==UserScript== // @name AO3 qscore // @description Quality score indicator with page autosort. Calibrated using a piecewise regression in log–log PCA space (~3500 works); uses CDF ranking. Adds live sorting and position toggles (top or bottom corner). // @author C89sd // @version 1.27 // @match https://archiveofourown.org/* // @grant GM_addStyle // @namespace https://greasyfork.org/users/1376767 // @noframes // ==/UserScript== 'use strict'; const bakedJSON = ` { "mean": [8.78763670537219, 5.756818885989689], "pc_axes": [[0.7652187732654738, 0.6437703232070295], [-0.6437703232070295, 0.7652187732654738]], "sigma_up": 0.49154274821813837, "sigma_down": 0.6763725627627314 } `; const cfg = JSON.parse(bakedJSON); const m0 = cfg.mean[0], m1 = cfg.mean[1]; const a11 = cfg.pc_axes[0][0], a12 = cfg.pc_axes[0][1]; const a21 = cfg.pc_axes[1][0], a22 = cfg.pc_axes[1][1]; const sUp = cfg.sigma_up, sDn = cfg.sigma_down; function computeCDF(xlog, ylog) { // translate const dx = xlog - m0; const dy = ylog - m1; // rotate, we only need the 2nd PCA axis, dot(dx,dy) const p1 = dx * a11 + dy * a12; const p2 = dx * a21 + dy * a22; // top half: ncdf(z) ∈ [0.5…1] for z>=0 if (p2 >= 0) { return [ ncdf(p2 / sUp), p1 ] } // bottom half: we want [0…0.5] so we reflect else { return [ 1 - ncdf((-p2) / sDn), p1 ] } } // source: https://stackoverflow.com/a/59217784 function ncdf(z) { // (x, mean, std) // let z = (x - mean) / std; 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; } // ---------------------------------- COLORS ---------------------------------- // Test bookmarks: https://archiveofourown.org/collections/shortficsilove/bookmarks GM_addStyle(` .scoreA { display: inline-block !important; width: 28px; text-align: center; line-height: 18px; padding: 0; color: rgb(42,42,42); } /* em scales different on mobile */ .halfWidth { width: 1.3ch !important; padding: 0.429em calc(0.75em/1) !important; } .underDate { position: absolute; top: -3px; right: -2px; } .underDateBookmark { position: absolute; top: calc(-3px + 28px); right: -2px; z-index: 1; } /* .datetime has top=28px in this config */ .inWorkCorner { position: absolute; top: 10px; right: 10px; z-index: 1; } .inWork { float: right; } /* .stats becomes float:left inside works */ .moveDateDown { top: 17px !important; } .moveDateDownBookmark { top: calc(17px + 28px) !important; } @-moz-document url-prefix() { @media (max-width: 655px) { .scoreA { width: 26px; /* on desktop 26px doesn't cover the date, 27 does, but the 26px gap looks nicer on mobile. */ } } } :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))); background-blend-mode: color-burn; } .scoreA.darkenA { background-image: linear-gradient(hsl(0, 0%, var(--darken)), hsl(0, 0%, var(--darken))); background-blend-mode: multiply; } `); const isDarkMode = window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128; if (isDarkMode) document.documentElement.classList.add('dark-theme'); const HSL_STRINGS = [ 'hsl(0.0, 90.7%, 92.3%)', //'hsl(0, 100%, 93.5%)', // red 'hsl(47.8, 67.1%, 81.5%)', //'hsl(53.2, 67.6%, 78.3%)', // yellow 'hsl(118.4, 51.2%, 85%)', //'hsl(118.5, 48.1%, 84.1%)', // green 'hsl(122.9, 35.1%, 63.4%)', //'hsl(171.2, 61.4%, 82%)' //'hsl(121.4, 32.7%, 67.9%)' // greener ]; 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; } // use 4th color 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)}%)`; } // ---------------------------------- NAVBAR ---------------------------------- let navSortingString = null; let sortingTxt = ['⇊', '⇅']; // sorted / default let navCornerString = null; let cornerTxt = ['⇱', '⇲']; // top / bottom { let navbar = document.querySelector('ul.primary'); if (!navbar) { console.log('!navbar'); return; } let searchBox = navbar.querySelector('.search'); if (!searchBox) { console.log('!searchBox'); return; } { // --- Sorting toggle let li = document.createElement('li'); li.classList.add('dropdown'); navSortingString = localStorage.getItem('C89AO3_sorting') || sortingTxt[0]; navSortingString = sortingTxt.includes(navSortingString) ? navSortingString : sortingTxt[0]; let a = document.createElement('a'); a.className = 'halfWidth'; a.textContent = navSortingString; a.href = '#'; a.addEventListener('click', (e) => { navSortingString = sortingTxt[(sortingTxt.indexOf(a.textContent) + 1) % sortingTxt.length]; a.textContent = navSortingString; localStorage.setItem('C89AO3_sorting', navSortingString); a.blur(); toggleSorting(); }); li.appendChild(a); navbar.insertBefore(li, searchBox); } { // --- Corner toggle let li = document.createElement('li'); li.classList.add('dropdown'); navCornerString = localStorage.getItem('C89AO3_corner') || cornerTxt[0]; navCornerString = cornerTxt.includes(navCornerString) ? navCornerString : cornerTxt[0]; let a = document.createElement('a'); a.className = 'halfWidth'; a.textContent = navCornerString; a.href = '#'; a.addEventListener('click', (e) => { navCornerString = cornerTxt[(cornerTxt.indexOf(a.textContent) + 1) % cornerTxt.length]; a.textContent = navCornerString; localStorage.setItem('C89AO3_corner', navCornerString); a.blur(); toggleCorner(); }); li.appendChild(a); navbar.insertBefore(li, searchBox); } } // ---------------------------------- MAIN ---------------------------------- // Parse int and ignore the thousands marker 1,000 const commaRegex = /,/g function parse(str) { return str ? parseInt(str.replace(commaRegex, ''), 10) : null; } let i = 0; let sortingData = []; let articles = document.querySelectorAll('li.work[role="article"], li.bookmark[role="article"], dl.work.meta.group'); for (let article of articles) { // https://archiveofourown.org/collections/shortficsilove/bookmarks let isBookmark = article.classList.contains('bookmark') let isOpenedWork = article?.tagName === 'DL'; let stats = article.querySelector('dl.stats'); if (!stats) continue; let words = parse(stats.querySelectorAll('.words') [1]?.textContent); let chapters = parse(stats.querySelectorAll('.chapters')[1]?.textContent.split('/')[0]); let error = (!words || !chapters); let kudos = parse(stats.querySelectorAll('.kudos')[1]?.textContent); let hits = parse(stats.querySelectorAll('.hits') [1]?.textContent); let missing = (!kudos || !hits); if (missing && kudos && kudos >= 1) { missing = false; hits = 1; } { let [ conf, pc1 ] = missing ? [ 0, -10.0 ] : computeCDF(Math.log(hits), Math.log(kudos)); let dimmed = false; let indicator = document.createElement('div'); if (isOpenedWork) indicator.classList.add('inWork'); { indicator.classList.add('scoreA') indicator.textContent = Math.round(100*conf); indicator.style.backgroundColor = color(conf, 1.0, true); //, 0.8); if (kudos < 5 && !(pc1 > -6.132)) { // take kudos >=5, also take fics above the diagonal (genuinely low scores won't be dimmed), -6.132 is the intersection of k5 and median indicator.classList.add('darkenA'); dimmed = true; } // const themeBorderColor = getComputedStyle(article).borderColor; // indicator.style.borderColor = themeBorderColor; // indicator.style.boxShadow = `inset 0 0 0 1px ${themeBorderColor}`; // Bad idea, worse contrast. // indicator.style.boxShadow = `inset 0 0 0 0.5px rgb(42,42,42)`; stats.appendChild(document.createTextNode(' ')); stats.appendChild(indicator); } let sortKey = dimmed ? conf : conf + 100; sortingData.push({ article, score: conf, index: i++ , isBookmark, isOpenedWork, sortKey }); } } // ---------------------------------- SORTING ---------------------------------- let isSorted = false; function toggleSorting() { let parent = articles[0]?.parentNode; if (parent) { let run = false; if (navSortingString === sortingTxt[0]) { sortingData.sort((a, b) => b.score - a.score); isSorted = true; run = true; } else if (isSorted) { // skips on the first run sortingData.sort((a, b) => a.index - b.index); isSorted = false; run = true; } if (run) sortingData.forEach(({ article, score }) => { parent.appendChild(article); // console.log(article, score) }); } } toggleSorting() let isInCorner = false; function toggleCorner() { let run = false; if (navCornerString === cornerTxt[0]) { isInCorner = true; run = true; } else if (isInCorner) { // skips on the first run isInCorner = false; run = true; } if (run) { sortingData.forEach(({ article, isBookmark, isOpenedWork }) => { let indicator = article.querySelector('.scoreA'); if (indicator) { // There are 3 top indicator positions: Boorkmark / Opened Work / Forum view let cornerClass = isBookmark ? 'underDateBookmark' : (isOpenedWork ? 'inWorkCorner' : 'underDate'); let dateClass = isBookmark ? 'moveDateDownBookmark' : 'moveDateDown'; if (isInCorner) // ------- Move to top corner { // Move the indicator inside the padded header. // In a work this is the article itself, in forum view its the date header. let cornerParent = isOpenedWork ? article : article.querySelector('div.header.module'); if (cornerParent) { indicator.classList.add(cornerClass); // Style it according to the 3 cases. cornerParent.appendChild(indicator); } // Move the date below the indicator. if (!isOpenedWork) article.querySelector('.datetime')?.classList.add(dateClass); } else // ------------------ Move to bottom corner { // Put it back in the stats corner. let stats = article.querySelector('dl.stats'); if (stats) { indicator.classList.remove(cornerClass); stats.appendChild(indicator); } if (!isOpenedWork) article.querySelector('.datetime')?.classList.remove(dateClass); } } }); } } toggleCorner()