AO3 Qscore

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.

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