AO3: Quality score (Adjusted Kudos/Hits ratio)

Uses the kudos/hits ratio, number of chapters, and statistical evaluation to score and sort AO3 works.

  1. // ==UserScript==
  2. // @name AO3: Quality score (Adjusted Kudos/Hits ratio)
  3. // @description Uses the kudos/hits ratio, number of chapters, and statistical evaluation to score and sort AO3 works.
  4. // @namespace https://greasyfork.org/scripts/3144-ao3-kudos-hits-ratio
  5. // @author cupkax
  6. // @version 2.2
  7. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
  8. // @include http://archiveofourown.org/*
  9. // @include https://archiveofourown.org/*
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. // Configuration object: centralizes all settings for easier management
  14. const CONFIG = {
  15. alwaysCount: true, // count kudos/hits automatically
  16. alwaysSort: false, // sort works on this page by kudos/hits ratio automatically
  17. hideHitcount: true, // hide hitcount
  18. colourBackground: true, // colour background depending on percentage
  19. thresholds: {
  20. low: 4, // percentage level separating red and yellow background
  21. high: 7 // percentage level separating yellow and green background
  22. },
  23. colors: {
  24. red: '#8b0000', // background color for low scores
  25. yellow: '#994d00', // background color for medium scores
  26. green: '#006400' // background color for high scores
  27. }
  28. };
  29.  
  30. // Main function: wraps all code to avoid polluting global scope
  31. (($) => {
  32. 'use strict'; // Enables strict mode to catch common coding errors
  33.  
  34. // Variables to track the state of the page
  35. let countable = false; // true if kudos/hits can be counted on this page
  36. let sortable = false; // true if works can be sorted on this page
  37. let statsPage = false; // true if this is a statistics page
  38.  
  39. // Load user settings from localStorage
  40. const loadUserSettings = () => {
  41. if (typeof Storage !== 'undefined') {
  42. CONFIG.alwaysCount = localStorage.getItem('alwaysCountLocal') !== 'no';
  43. CONFIG.alwaysSort = localStorage.getItem('alwaysSortLocal') === 'yes';
  44. CONFIG.hideHitcount = localStorage.getItem('hideHitcountLocal') !== 'no';
  45. }
  46. };
  47.  
  48. // Check if it's a list of works or bookmarks, or header on work page
  49. const checkCountable = () => {
  50. const foundStats = $('dl.stats');
  51.  
  52. if (foundStats.length) {
  53. if (foundStats.closest('li').is('.work') || foundStats.closest('li').is('.bookmark')) {
  54. countable = sortable = true;
  55. addRatioMenu();
  56. } else if (foundStats.parents('.statistics').length) {
  57. countable = sortable = statsPage = true;
  58. addRatioMenu();
  59. } else if (foundStats.parents('dl.work').length) {
  60. countable = true;
  61. addRatioMenu();
  62. }
  63. }
  64. };
  65.  
  66. // Count the kudos/hits ratio for each work
  67. const countRatio = () => {
  68. if (!countable) return;
  69.  
  70. $('dl.stats').each(function () {
  71. const $this = $(this);
  72. const $hitsValue = $this.find('dd.hits');
  73. const $kudosValue = $this.find('dd.kudos');
  74. const $chaptersValue = $this.find('dd.chapters');
  75.  
  76. // Improved error handling
  77. try {
  78. const chaptersString = $chaptersValue.text().split("/")[0];
  79. if (!$hitsValue.length || !$kudosValue.length || !chaptersString) {
  80. throw new Error("Missing required statistics");
  81. }
  82.  
  83. const hitsCount = parseInt($hitsValue.text().replace(/,/g, ''));
  84. const kudosCount = parseInt($kudosValue.text().replace(/,/g, ''));
  85. const chaptersCount = parseInt(chaptersString);
  86.  
  87. if (isNaN(hitsCount) || isNaN(kudosCount) || isNaN(chaptersCount)) {
  88. throw new Error("Invalid numeric values");
  89. }
  90.  
  91. const newHitsCount = hitsCount / Math.sqrt(chaptersCount);
  92.  
  93. let percents = 100 * kudosCount / newHitsCount;
  94. if (kudosCount < 11) {
  95. percents = 1;
  96. }
  97. const pValue = getPValue(newHitsCount, kudosCount, chaptersCount);
  98. if (pValue < 0.05) {
  99. percents = 1;
  100. }
  101.  
  102. const percents_print = percents.toFixed(1).replace(',', '.');
  103.  
  104. // Add ratio stats
  105. const $ratioLabel = $('<dt class="kudoshits">').text('Score:');
  106. const $ratioValue = $('<dd class="kudoshits">').text(`${percents_print}`);
  107. $hitsValue.after($ratioLabel, $ratioValue);
  108.  
  109. if (CONFIG.colourBackground) {
  110. if (percents >= CONFIG.thresholds.high) {
  111. $ratioValue.css('background-color', CONFIG.colors.green);
  112. } else if (percents >= CONFIG.thresholds.low) {
  113. $ratioValue.css('background-color', CONFIG.colors.yellow);
  114. } else {
  115. $ratioValue.css('background-color', CONFIG.colors.red);
  116. }
  117. }
  118.  
  119. if (CONFIG.hideHitcount && !statsPage) {
  120. $this.find('.hits').hide();
  121. }
  122.  
  123. $this.closest('li').attr('kudospercent', percents);
  124. } catch (error) {
  125. console.error(`Error processing work stats: ${error.message}`);
  126. $this.closest('li').attr('kudospercent', 0);
  127. }
  128. });
  129. };
  130.  
  131. // Sort works by kudos/hits ratio
  132. const sortByRatio = (ascending = false) => {
  133. if (!sortable) return;
  134.  
  135. $('dl.stats').closest('li').parent().each(function () {
  136. const $list = $(this);
  137. const listElements = $list.children('li').get();
  138.  
  139. listElements.sort((a, b) => {
  140. const aPercent = parseFloat(a.getAttribute('kudospercent'));
  141. const bPercent = parseFloat(b.getAttribute('kudospercent'));
  142. return ascending ? aPercent - bPercent : bPercent - aPercent;
  143. });
  144.  
  145. $list.append(listElements);
  146. });
  147. };
  148.  
  149. // Statistical functions
  150. const nullHyp = 0.04;
  151.  
  152. const getPValue = (hits, kudos, chapters) => {
  153. const testProp = kudos / hits;
  154. const zValue = (testProp - nullHyp) / Math.sqrt((nullHyp * (1 - nullHyp)) / hits);
  155. return normalcdf(0, -1 * zValue, 1);
  156. };
  157.  
  158. const normalcdf = (mean, upperBound, standardDev) => {
  159. const z = (standardDev - mean) / Math.sqrt(2 * upperBound * upperBound);
  160. const t = 1 / (1 + 0.3275911 * Math.abs(z));
  161. const a1 = 0.254829592;
  162. const a2 = -0.284496736;
  163. const a3 = 1.421413741;
  164. const a4 = -1.453152027;
  165. const a5 = 1.061405429;
  166. const erf = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-z * z);
  167. const sign = z < 0 ? -1 : 1;
  168. return (1 / 2) * (1 + sign * erf);
  169. };
  170.  
  171. // Add the ratio menu to the page
  172. const addRatioMenu = () => {
  173. const $headerMenu = $('ul.primary.navigation.actions');
  174. const $ratioMenu = $('<li class="dropdown">').html('<a>Kudos/hits</a>');
  175. $headerMenu.find('li.search').before($ratioMenu);
  176.  
  177. const $dropMenu = $('<ul class="menu dropdown-menu">');
  178. $ratioMenu.append($dropMenu);
  179.  
  180. const $buttonCount = $('<li>').html('<a>Count on this page</a>');
  181. $buttonCount.click(countRatio);
  182.  
  183. $dropMenu.append($buttonCount);
  184.  
  185. if (sortable) {
  186. const $buttonSort = $('<li>').html('<a>Sort on this page</a>');
  187. $buttonSort.click(() => sortByRatio());
  188. $dropMenu.append($buttonSort);
  189. }
  190.  
  191. if (typeof Storage !== 'undefined') {
  192. const $buttonSettings = $('<li>').html('<a style="padding: 0.5em 0.5em 0.25em; text-align: center; font-weight: bold;">&mdash; Settings (click to change): &mdash;</a>');
  193. $dropMenu.append($buttonSettings);
  194.  
  195. const createToggleButton = (text, storageKey, onState, offState) => {
  196. const $button = $('<li>').html(`<a>${text}: ${CONFIG[storageKey] ? 'YES' : 'NO'}</a>`);
  197. $button.click(function () {
  198. CONFIG[storageKey] = !CONFIG[storageKey];
  199. localStorage.setItem(storageKey + 'Local', CONFIG[storageKey] ? onState : offState);
  200. $(this).find('a').text(`${text}: ${CONFIG[storageKey] ? 'YES' : 'NO'}`);
  201. if (storageKey === 'hideHitcount') {
  202. $('.stats .hits').toggle(!CONFIG.hideHitcount);
  203. }
  204. });
  205. return $button;
  206. };
  207.  
  208. $dropMenu.append(createToggleButton('Count automatically', 'alwaysCount', 'yes', 'no'));
  209. $dropMenu.append(createToggleButton('Sort automatically', 'alwaysSort', 'yes', 'no'));
  210. $dropMenu.append(createToggleButton('Hide hitcount', 'hideHitcount', 'yes', 'no'));
  211. }
  212.  
  213. // Add button for statistics page
  214. if ($('#main').is('.stats-index')) {
  215. const $buttonSortStats = $('<li>').html('<a>↓&nbsp;Kudos/hits</a>');
  216. $buttonSortStats.click(function () {
  217. sortByRatio();
  218. $(this).after($buttonSortStatsAsc).detach();
  219. });
  220.  
  221. const $buttonSortStatsAsc = $('<li>').html('<a>↑&nbsp;Kudos/hits</a>');
  222. $buttonSortStatsAsc.click(function () {
  223. sortByRatio(true);
  224. $(this).after($buttonSortStats).detach();
  225. });
  226.  
  227. $('ul.sorting.actions li:nth-child(3)').after($buttonSortStats);
  228. }
  229. };
  230.  
  231. // Main execution
  232. loadUserSettings();
  233. checkCountable();
  234.  
  235. if (CONFIG.alwaysCount) {
  236. countRatio();
  237. if (CONFIG.alwaysSort) {
  238. sortByRatio();
  239. }
  240. }
  241.  
  242. })(jQuery); sc