AO3: Quality Score Improved

Calculates and displays quality scores for AO3 works with customizable options

  1. // ==UserScript==
  2. // @name AO3: Quality Score Improved
  3. // @description Calculates and displays quality scores for AO3 works with customizable options
  4. // @namespace https://greasyfork.org/scripts/3144-ao3-kudos-hits-ratio
  5. // @author Min (Extensive modifications by Assistant)
  6. // @version 6.1
  7. // @grant GM_addStyle
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
  11. // @include http://archiveofourown.org/*
  12. // @include https://archiveofourown.org/*
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function ($) {
  17. 'use strict';
  18.  
  19. // Configuration
  20. const CONFIG = {
  21. weights: {
  22. kudosHitRatio: 50,
  23. chapterAdjustment: 0.05,
  24. commentEngagement: 20,
  25. bookmarkScore: 30,
  26. wordCountFactor: 0.5,
  27. timeDecayHalfLife: 365 // days
  28. },
  29. colorThresholds: {
  30. low: 30,
  31. medium: 60
  32. },
  33. options: {
  34. autoSort: GM_getValue('autoSort', false),
  35. showScores: GM_getValue('showScores', true),
  36. hideWorks: GM_getValue('hideWorks', false),
  37. hideThreshold: GM_getValue('hideThreshold', 20)
  38. }
  39. };
  40.  
  41. // CSS Styles
  42. GM_addStyle(`
  43. .quality-score {
  44. font-weight: bold;
  45. padding: 2px 5px;
  46. border-radius: 3px;
  47. margin-left: 10px;
  48. }
  49. .quality-score-low { background-color: #ffcccb; color: #8b0000; }
  50. .quality-score-medium { background-color: #ffffa1; color: #8b8b00; }
  51. .quality-score-high { background-color: #90EE90; color: #006400; }
  52. .work-stats { display: flex; align-items: center; }
  53. .work-stats > dd { margin-right: 10px; }
  54. `);
  55.  
  56. // Core Functions
  57. const calculateQualityScore = (stats) => {
  58. if (stats.hits === 0) return 0;
  59.  
  60. const baseScore = (stats.kudos / Math.sqrt(stats.hits)) * CONFIG.weights.kudosHitRatio;
  61. const chapterAdjustment = 1 + (stats.chapters - 1) * CONFIG.weights.chapterAdjustment;
  62. const commentBonus = (stats.comments / stats.hits) * CONFIG.weights.commentEngagement;
  63. const bookmarkBonus = (stats.bookmarks / stats.hits) * CONFIG.weights.bookmarkScore;
  64. const wordCountFactor = Math.log(stats.wordCount) / Math.log(10000) * CONFIG.weights.wordCountFactor;
  65.  
  66. const daysSincePublish = (new Date() - stats.publishDate) / (1000 * 60 * 60 * 24);
  67. const timeDecayFactor = Math.exp(-daysSincePublish / CONFIG.weights.timeDecayHalfLife);
  68.  
  69. const score = ((baseScore * chapterAdjustment + commentBonus + bookmarkBonus) * (1 + wordCountFactor)) * timeDecayFactor;
  70. return Math.min(99, score * 0.9);
  71. };
  72.  
  73. const getScoreClass = (score) => {
  74. if (score >= CONFIG.colorThresholds.medium) return 'quality-score-high';
  75. if (score >= CONFIG.colorThresholds.low) return 'quality-score-medium';
  76. return 'quality-score-low';
  77. };
  78.  
  79. const addScoresToWorks = () => {
  80. $('ol.work.index > li').each(function () {
  81. const $work = $(this);
  82. const $stats = $work.find('dl.stats');
  83.  
  84. try {
  85. const stats = {
  86. hits: parseInt($stats.find('dd.hits').text().replace(/,/g, '')) || 0,
  87. kudos: parseInt($stats.find('dd.kudos a').text().replace(/,/g, '')) || 0,
  88. chapters: parseInt($stats.find('dd.chapters a').text().split('/')[0]) || 1,
  89. comments: parseInt($stats.find('dd.comments a').text().replace(/,/g, '')) || 0,
  90. bookmarks: parseInt($stats.find('dd.bookmarks a').text().replace(/,/g, '')) || 0,
  91. wordCount: parseInt($stats.find('dd.words').text().replace(/,/g, '')) || 0,
  92. publishDate: new Date($work.find('p.datetime').text())
  93. };
  94.  
  95. const qualityScore = calculateQualityScore(stats);
  96. $work.attr('data-quality-score', qualityScore);
  97.  
  98. if (CONFIG.options.showScores) {
  99. const scoreDisplay = qualityScore.toFixed(1);
  100. const $scoreElement = $('<dd>')
  101. .addClass('quality-score')
  102. .addClass(getScoreClass(qualityScore))
  103. .text(`Score: ${scoreDisplay}`);
  104. $stats.addClass('work-stats').append($scoreElement);
  105. }
  106.  
  107. if (CONFIG.options.hideWorks && qualityScore < CONFIG.options.hideThreshold) {
  108. $work.hide();
  109. }
  110.  
  111. } catch (error) {
  112. console.error(`Error processing work stats: ${error.message}`);
  113. }
  114. });
  115.  
  116. if (CONFIG.options.autoSort) {
  117. sortWorksByScore();
  118. }
  119. };
  120.  
  121. const sortWorksByScore = () => {
  122. const $workList = $('ol.work.index');
  123. const $works = $workList.children('li').get();
  124.  
  125. $works.sort((a, b) => {
  126. const scoreA = parseFloat($(a).attr('data-quality-score')) || 0;
  127. const scoreB = parseFloat($(b).attr('data-quality-score')) || 0;
  128. return scoreB - scoreA;
  129. });
  130.  
  131. $workList.append($works);
  132. };
  133.  
  134. const addQualityScoreMenu = () => {
  135. const $headerMenu = $('ul.primary.navigation.actions');
  136. if ($headerMenu.length === 0) {
  137. console.error('Header menu not found, skipping menu addition');
  138. return;
  139. }
  140.  
  141. const $scoreMenu = $('<li class="dropdown">').html('<a href="#">Quality Score</a>');
  142. $headerMenu.find('li.search').before($scoreMenu);
  143.  
  144. const $dropMenu = $('<ul class="menu dropdown-menu">');
  145. $scoreMenu.append($dropMenu);
  146.  
  147. const addMenuItem = (text, clickHandler) => {
  148. const $menuItem = $('<li>').html(`<a href="#">${text}</a>`);
  149. $menuItem.on('click', (e) => {
  150. e.preventDefault();
  151. clickHandler();
  152. });
  153. $dropMenu.append($menuItem);
  154. };
  155.  
  156. addMenuItem(`Auto-sort: ${CONFIG.options.autoSort ? 'ON' : 'OFF'}`, () => {
  157. CONFIG.options.autoSort = !CONFIG.options.autoSort;
  158. GM_setValue('autoSort', CONFIG.options.autoSort);
  159. location.reload();
  160. });
  161.  
  162. addMenuItem(`Show Scores: ${CONFIG.options.showScores ? 'ON' : 'OFF'}`, () => {
  163. CONFIG.options.showScores = !CONFIG.options.showScores;
  164. GM_setValue('showScores', CONFIG.options.showScores);
  165. location.reload();
  166. });
  167.  
  168. addMenuItem(`Hide Low Quality: ${CONFIG.options.hideWorks ? 'ON' : 'OFF'}`, () => {
  169. CONFIG.options.hideWorks = !CONFIG.options.hideWorks;
  170. GM_setValue('hideWorks', CONFIG.options.hideWorks);
  171. location.reload();
  172. });
  173.  
  174. addMenuItem('Set Hide Threshold', () => {
  175. const newThreshold = prompt('Enter new hide threshold (0-100):', CONFIG.options.hideThreshold);
  176. if (newThreshold !== null && !isNaN(newThreshold)) {
  177. CONFIG.options.hideThreshold = Math.min(100, Math.max(0, parseInt(newThreshold)));
  178. GM_setValue('hideThreshold', CONFIG.options.hideThreshold);
  179. location.reload();
  180. }
  181. });
  182.  
  183. addMenuItem('Recalculate Scores', () => {
  184. addScoresToWorks();
  185. });
  186.  
  187. addMenuItem('Sort by Score (High to Low)', () => sortWorksByScore(false));
  188. addMenuItem('Sort by Score (Low to High)', () => sortWorksByScore(true));
  189. };
  190.  
  191. // Main execution
  192. $(document).ready(() => {
  193. addQualityScoreMenu();
  194. addScoresToWorks();
  195. });
  196.  
  197. })(jQuery);