// ==UserScript==
// @name AO3 Quality score (CDF ranking + autosort)
// @description Zero-dependency indicator script, uses a piecewise CDF regression in log–log PCA space to rank fics; extends the (kudos/hits) metric to popular fics. Adds a navbar toggle for automatic sorting & indicator position.
// @author C89sd
// @version 1.19
// @match https://archiveofourown.org/*
// @grant GM_addStyle
// @namespace https://greasyfork.org/users/1376767
// ==/UserScript==
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 */
@-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');
let searchBox = navbar.querySelector('.search');
if (!navbar || !searchBox) { console.log('!navbar || !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));
sortingData.push({ article: article, score: conf, index: i++ , isBookmark: isBookmark, isOpenedWork: isOpenedWork });
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 (pc1 < -6.0) indicator.classList.add('darkenA');
// 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);
}
}
}
// ---------------------------------- 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) {
let cornerClass = isBookmark ? 'underDateBookmark' : (isOpenedWork ? 'inWorkCorner' : 'underDate')
if (isInCorner) {
// Unless we are reading a work, try finding the Date's parent div.header.module
let cornerParent = isOpenedWork ? article : article.querySelector('div.header.module');
if (cornerParent) {
indicator.classList.add(cornerClass);
cornerParent.appendChild(indicator);
}
} else {
// Put it back in the stats corner.
let stats = article.querySelector('dl.stats');
if (stats) {
indicator.classList.remove(cornerClass);
stats.appendChild(indicator);
}
}
}
});
}
}
toggleCorner()