AO3 Quality score (CDF ranking + autosort)

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.

// ==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()