// ==UserScript==
// @name AO3 Qscore
// @description Autosorting 'Quality' Indicator trained on 11k+ works. Very generous with small fics, rewards engagement over popularity (bookmarks/collections/comments/kudos vs. hits) with a perfect 0-100 score spread. Sort & position toggles included.
// @version 2.11
// @author C89sd
// @namespace https://greasyfork.org/users/1376767
// @match https://archiveofourown.org/*
// @grant GM_addStyle
// @noframes
// ==/UserScript==
'use strict';
// A work will not be dimmed if it's metrics are greater or equal to these values.
const DIMMING_THRESHOLDS = {
'kudos': 5,
'bookmarks': 3,
'collections': 1,
};
let MODEL_GAM = {"model_type":"expectile_gam_n13","regressions":[{"x_metric":"bookmarks","y_metric":"kudos","x_grid":[0,0.22042,0.44084,0.66126,0.88168,1.1021,1.32252,1.54294,1.76336,1.98378,2.20419,2.42461,2.64503,2.86545,3.08587,3.30629,3.52671,3.74713,3.96755,4.18797,4.40839,4.62881,4.84923,5.06965,5.29007,5.51049,5.73091,5.95133,6.17175,6.39217,6.61258,6.833,7.05342,7.27384,7.49426,7.71468,7.9351,8.15552,8.37594,8.59636,8.81678,9.0372,9.25762,9.47804,9.69846,9.91888,10.1393,10.35972,10.58014,10.80055],"q10_curve_y":[0.46272,0.76784,1.13854,1.54822,1.97029,2.37817,2.75168,3.08979,3.39497,3.6697,3.91649,4.13897,4.34341,4.5364,4.72454,4.9144,5.10949,5.30814,5.50818,5.70745,5.90382,6.09654,6.28667,6.47541,6.66394,6.85341,7.04422,7.236,7.42837,7.62093,7.81334,8.00597,8.19974,8.39561,8.59451,8.79731,9.00399,9.21404,9.42696,9.64221,9.85942,10.07922,10.30268,10.53091,10.76498,11.00569,11.25231,11.5036,11.7583,12.01517],"q50_curve_y":[1.13303,1.48723,1.86821,2.25812,2.63909,2.99328,3.30826,3.58775,3.83848,4.06716,4.28049,4.48394,4.68028,4.87185,5.06103,5.25018,5.44059,5.63172,5.82286,6.0133,6.20235,6.38978,6.57596,6.76133,6.9463,7.13129,7.31654,7.50216,7.68822,7.87482,8.06207,8.25035,8.44029,8.63253,8.82768,9.02629,9.22798,9.43183,9.63691,9.84229,10.04733,10.25343,10.46293,10.67818,10.9015,11.13475,11.3771,11.62681,11.88216,12.14141],"q90_curve_y":[1.94356,2.26739,2.59398,2.91556,3.22436,3.51261,3.77518,4.01486,4.23584,4.44236,4.63863,4.82824,5.01341,5.19617,5.37854,5.56255,5.74911,5.93735,6.12619,6.31456,6.50139,6.68629,6.86966,7.05198,7.23372,7.41536,7.59723,7.77953,7.96248,8.14628,8.33114,8.51725,8.70485,8.89414,9.08535,9.27862,9.47354,9.66931,9.86515,10.06027,10.25418,10.44853,10.64592,10.84895,11.06023,11.28186,11.51305,11.75209,11.99724,12.24679]},{"x_metric":"collections","y_metric":"kudos","x_grid":[0,0.12263,0.24526,0.36789,0.49052,0.61314,0.73577,0.8584,0.98103,1.10366,1.22629,1.34892,1.47155,1.59417,1.7168,1.83943,1.96206,2.08469,2.20732,2.32995,2.45258,2.57521,2.69783,2.82046,2.94309,3.06572,3.18835,3.31098,3.43361,3.55624,3.67887,3.80149,3.92412,4.04675,4.16938,4.29201,4.41464,4.53727,4.6599,4.78252,4.90515,5.02778,5.15041,5.27304,5.39567,5.5183,5.64093,5.76356,5.88618,6.00881],"q10_curve_y":[3.55054,2.97069,2.53439,2.28747,2.27579,2.54515,3.10727,3.87248,4.73237,5.57855,6.30279,6.83875,7.21344,7.46648,7.63754,7.76607,7.87866,7.98006,8.07289,8.15976,8.24329,8.32517,8.40595,8.48609,8.56607,8.64634,8.72762,8.81078,8.89674,8.98642,9.08062,9.1789,9.27979,9.38183,9.48352,9.5836,9.68269,9.78259,9.88511,9.99205,10.10479,10.22149,10.3389,10.45375,10.56277,10.66327,10.7556,10.84116,10.92132,10.99748],"q50_curve_y":[5.35893,4.99074,4.72441,4.58836,4.61099,4.82072,5.22307,5.75572,6.34379,6.9124,7.38682,7.72293,7.94493,8.08625,8.18034,8.26048,8.34697,8.43813,8.53014,8.61919,8.70153,8.77597,8.84462,8.90979,8.97381,9.03898,9.10689,9.17838,9.25429,9.33546,9.42257,9.5145,9.60865,9.70243,9.7932,9.87875,9.96065,10.04278,10.12903,10.22328,10.32874,10.44336,10.56278,10.68263,10.79851,10.90674,11.00723,11.10114,11.18964,11.27387],"q90_curve_y":[6.92099,6.71359,6.56156,6.48223,6.4929,6.61089,6.84058,7.14795,7.49187,7.8312,8.12491,8.34798,8.51124,8.63034,8.72092,8.7986,8.87357,8.94697,9.01904,9.09001,9.16013,9.22925,9.2968,9.36213,9.42463,9.48372,9.54027,9.59653,9.65484,9.71752,9.78672,9.8621,9.94135,10.02211,10.10203,10.17907,10.25437,10.33093,10.41179,10.49997,10.59801,10.7047,10.81715,10.93249,11.04781,11.16063,11.27057,11.37798,11.48319,11.58656]},{"x_metric":"comments","y_metric":"kudos","x_grid":[0,0.22077,0.44154,0.6623,0.88307,1.10384,1.32461,1.54537,1.76614,1.98691,2.20768,2.42844,2.64921,2.86998,3.09075,3.31152,3.53228,3.75305,3.97382,4.19459,4.41535,4.63612,4.85689,5.07766,5.29842,5.51919,5.73996,5.96073,6.18149,6.40226,6.62303,6.8438,7.06457,7.28533,7.5061,7.72687,7.94764,8.1684,8.38917,8.60994,8.83071,9.05147,9.27224,9.49301,9.71378,9.93455,10.15531,10.37608,10.59685,10.81762],"q10_curve_y":[0.79636,1.2799,1.69371,2.05644,2.38676,2.70333,3.02004,3.3366,3.6501,3.95764,4.2563,4.54321,4.81553,5.07046,5.30518,5.5169,5.7065,5.88095,6.04784,6.21476,6.38911,6.57118,6.75202,6.92207,7.07175,7.19195,7.28379,7.35863,7.42828,7.50454,7.59849,7.71036,7.83206,7.95527,8.07165,8.17305,8.25301,8.30607,8.32677,8.30967,8.24947,8.14204,7.98378,7.77109,7.50035,7.16988,6.78833,6.36783,5.92051,5.45852],"q50_curve_y":[1.7027,2.2114,2.63806,3.00387,3.33001,3.63763,3.94244,4.24387,4.53834,4.82227,5.09209,5.34565,5.58399,5.80858,6.02088,6.22236,6.41477,6.60036,6.7814,6.96019,7.13891,7.31632,7.48666,7.64388,7.78194,7.8951,7.98479,8.0596,8.12844,8.20022,8.28333,8.37866,8.48129,8.58618,8.68826,8.78281,8.86858,8.94637,9.01699,9.08127,9.13874,9.17945,9.18922,9.15383,9.05906,8.89406,8.66611,8.38863,8.07502,7.7387],"q90_curve_y":[2.78647,3.17571,3.52824,3.8515,4.15289,4.43985,4.71805,4.98803,5.24935,5.50159,5.74432,5.97748,6.20179,6.41809,6.62722,6.83001,7.02757,7.2214,7.41306,7.60411,7.79601,7.987,8.17109,8.34204,8.49358,8.61971,8.7204,8.80162,8.86956,8.93044,8.99027,9.05228,9.11755,9.18709,9.26191,9.34289,9.42938,9.51985,9.61275,9.70656,9.7992,9.88478,9.9557,10.00434,10.0231,10.00616,9.95753,9.88449,9.79435,9.69439]}],"ecdf_x":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"ecdf_y":[0.00453,1.49447,2.41052,3.18867,3.97978,4.72993,5.5517,6.19209,6.77122,7.45981,7.99073,8.58761,9.1,9.75143,10.34772,10.88718,11.51375,12.04432,12.6424,13.17468,13.76921,14.38092,14.91748,15.50097,16.0346,16.52985,17.14877,17.80091,18.3907,18.92251,19.49807,20.10435,20.62884,21.14207,21.78367,22.44415,22.88044,23.36704,23.97975,24.69028,25.26861,25.97347,26.51812,27.09431,27.79792,28.45855,28.98811,29.57283,30.21509,30.83187,31.52881,32.15345,32.843,33.49147,34.04562,34.68913,35.36226,36.27517,37.01479,37.69714,38.50848,39.35763,40.17315,40.93765,41.74929,42.63792,43.38093,44.17624,45.01725,45.7349,46.55735,47.49528,48.2566,49.12328,49.95786,50.81718,51.79119,52.67544,53.88605,54.80799,55.73303,56.81879,57.88498,58.96904,59.9889,61.24397,62.53805,63.63497,64.91449,66.41104,67.63556,68.98888,70.78118,72.57835,74.18827,76.27102,78.59842,81.4088,84.68275,89.08507,100]};
const DEFAULT_SCORE = 0.0;
const Z_85_GAM = 1.0364333894937896; // norm.ppf(0.85)
// --- HELPER FUNCTIONS ---
function interpolate(xs, ys, targetX) {
if (targetX <= xs[0]) return ys[0];
if (targetX >= xs[xs.length - 1]) return ys[ys.length - 1];
const i = xs.findIndex(x => x > targetX) - 1;
const fraction = (targetX - xs[i]) / (xs[i + 1] - xs[i]);
return ys[i] + fraction * (ys[i + 1] - ys[i]);
}
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;
}
function calculateScore(metrics, model, z_const) {
const valid_scores = [];
for (const regression of model.regressions) {
const x_metric = regression.x_metric;
const y_metric = regression.y_metric;
// A regression is only used if both of its metrics are non-zero.
const regression_is_valid = (
metrics[x_metric] > 0 &&
metrics[y_metric] > 0
);
if (regression_is_valid) {
const x_point = Math.log1p(metrics[x_metric]);
const y_point = Math.log1p(metrics[y_metric]);
// Always use GAM logic
const y_center = interpolate(regression.x_grid, regression.q50_curve_y, x_point);
const y_lower = interpolate(regression.x_grid, regression.q10_curve_y, x_point);
const y_upper = interpolate(regression.x_grid, regression.q90_curve_y, x_point);
const sigma_right = (y_upper - y_center) / z_const;
const sigma_left = (y_center - y_lower) / z_const;
if (sigma_left <= 1e-6 && sigma_right <= 1e-6) {
valid_scores.push(50.0);
continue;
}
let sigma;
if (y_point >= y_center) {
sigma = sigma_right > 1e-6 ? sigma_right : sigma_left;
} else {
sigma = sigma_left > 1e-6 ? sigma_left : sigma_right;
}
const z_score = (y_point - y_center) / sigma;
const score = (1 - ncdf(z_score)) * 100;
valid_scores.push(score);
}
}
let rawScore;
if (valid_scores.length === 0) {
rawScore = DEFAULT_SCORE;
} else {
rawScore = Math.max(...valid_scores);
}
return interpolate(model.ecdf_x, model.ecdf_y, rawScore); // ECDF
}
function getFinalScore(metrics) {
// Only GAM model is used now
return calculateScore(metrics, MODEL_GAM, Z_85_GAM);
}
// ---------------------------------- COLORS ----------------------------------
GM_addStyle(`
.halfWidth { width: 1.3ch !important; padding: 0.429em calc(0.75em/1) !important; }
.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 */
.inCorner .scoreA { position: absolute; top: -3px; right: -2px; }
.inCorner.cornerFull:not(.skipped-work):not(.marked-seen) .scoreA { top: 27px; }
/* khx fix skipped before:: text */
.inCorner.skipped-work .scoreA { top: -21px; }
/* repeat .inWork to increase rule specificity, else the :not() win */
.inWork.inWork.inWork .scoreA { float: right; } /* .stats becomes float:left inside works */
.inWork.inWork.inWork.inCorner .scoreA { position: absolute; top: 10px; right: 10px; z-index: 1; }
.inCorner .isDate { top: 17px; }
.inCorner.cornerFull:not(.skipped-work):not(.marked-seen) .isDate { top: calc(17px + 28px); }
/* khx fix skipped before:: text */
.skipped-work .isDate { top: -18px; }
.inCorner.skipped-work .isDate { top: -1px; }
@-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(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)}%)`;
}
// ---------------------------------- NAVBAR ----------------------------------
let navSortingString = null;
let sortingTxt = ['⇊', '⇅'];
let navCornerString = null;
let cornerTxt = ['⇱', '⇲'];
{
let navbar = document.querySelector('ul.primary');
if (navbar) {
let searchBox = navbar.querySelector('.search');
if (searchBox) {
{
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);
}
{
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 ----------------------------------
const commaRegex = /,/g
function parse(str) {
return str ? parseInt(str.replace(commaRegex, ''), 10) : null;
}
let sortingData = [];
const articles = document.querySelectorAll('li.work[role="article"], li.bookmark[role="article"], dl.work.meta.group');
function removeOldScores() {
document.querySelectorAll('.scoreA').forEach(el => el.remove());
}
function isVisible(el) { return window.getComputedStyle(el).display !== "none"; }
function processArticles() {
removeOldScores();
sortingData = [];
let i = 0;
for (let article of articles) {
let bookmarkCornerOccupied = !!article.querySelector('.status'); // isVisible(article.querySelector('.status'));
let insideAWork = article?.tagName === 'DL';
let stats = article.querySelector('dl.stats');
if (!stats) continue;
const metrics = {
bookmarks: parse(stats.querySelector('dd.bookmarks')?.textContent) || 0,
collections: parse(stats.querySelector('dd.collections')?.textContent) || 0,
comments: parse(stats.querySelector('dd.comments')?.textContent) || 0,
kudos: parse(stats.querySelector('dd.kudos')?.textContent) || 0,
};
const finalScore = getFinalScore(metrics);
const dimmed = (
metrics.kudos < DIMMING_THRESHOLDS.kudos &&
metrics.bookmarks < DIMMING_THRESHOLDS.bookmarks &&
metrics.collections < DIMMING_THRESHOLDS.collections
);
let indicator = document.createElement('div');
{
if (bookmarkCornerOccupied) article.classList.add('cornerFull');
if (insideAWork) article.classList.add('inWork');
indicator.classList.add('scoreA');
indicator.textContent = Math.round(finalScore);
indicator.style.backgroundColor = color(finalScore / 100.0, 1.0, true);
if (dimmed) {
indicator.classList.add('darkenA');
}
stats.appendChild(document.createTextNode(' '));
stats.appendChild(indicator);
}
let sortKey = dimmed ? finalScore : finalScore + 100;
sortingData.push({ indicator, article, score: finalScore, index: i++, bookmarkCornerOccupied, insideAWork, sortKey });
}
}
function updateAllScores() {
processArticles();
toggleSorting();
toggleCorner();
}
// ---------------------------------- 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.sortKey - a.sortKey); isSorted = true; run = true;
} else if (isSorted) {
sortingData.sort((a, b) => a.index - b.index); isSorted = false; run = true;
}
if (run) sortingData.forEach(({ article }) => {
parent.appendChild(article);
});
}
}
// ---------------------------------- CORNER TOGGLE ----------------------------------
let isInCorner = false;
function toggleCorner() {
let run = false;
if (navCornerString === cornerTxt[0]) {
isInCorner = true; run = true;
} else if (isInCorner) {
isInCorner = false; run = true;
}
if (run) {
sortingData.forEach(({ article, indicator, insideAWork }) => {
if (indicator) {
article.querySelector('.datetime')?.classList.add('isDate'); // can be null if inside a work
if (isInCorner) {
let cornerParent = insideAWork ? article : article.querySelector('div.header.module');
if (cornerParent) {
article.classList.add('inCorner');
cornerParent.appendChild(indicator);
}
}
else {
let stats = article.querySelector('dl.stats');
if (stats) {
article.classList.remove('inCorner');
stats.appendChild(indicator);
}
}
}
});
}
}
updateAllScores()