// ==UserScript== // @name AO3: Reading Time & Quality Score // @description Combined reading time and quality scoring. Highly customizable. // @author BlackBatCat // @version 1.1.8 // @include http://archiveofourown.org/* // @include https://archiveofourown.org/* // @license MIT // @grant none // @namespace https://greasyfork.org/users/1498004 // @downloadURL none // ==/UserScript== (function () { "use strict"; // DEFAULT CONFIGURATION - Single config object const DEFAULTS = { // Feature Toggles enableReadingTime: true, enableQualityScore: true, // Reading Time Settings wpm: 375, alwaysCountReadingTime: true, readingTimeLvl1: 120, readingTimeLvl2: 360, // Quality Score Settings alwaysCountQualityScore: true, alwaysSortQualityScore: false, hideHitcount: false, useNormalization: false, userMaxScore: 32, minKudosToShowScore: 100, colorThresholdLow: 10, colorThresholdHigh: 20, // Shared Color Settings colorGreen: "#3e8fb0", colorYellow: "#f6c177", colorRed: "#eb6f92", colorText: "#ffffff", }; // Current config, loaded from localStorage let CONFIG = { ...DEFAULTS }; // Variables to track the state of the page let countable = false; let sortable = false; let statsPage = false; // --- HELPER FUNCTIONS --- const $ = (selector, root = document) => root.querySelectorAll(selector); const $1 = (selector, root = document) => root.querySelector(selector); // Load user settings from localStorage - single config object const loadUserSettings = () => { if (typeof Storage === "undefined") return; const savedConfig = localStorage.getItem("ao3_reading_quality_config"); if (savedConfig) { try { const parsedConfig = JSON.parse(savedConfig); CONFIG = { ...DEFAULTS, ...parsedConfig }; } catch (e) { console.error("Error loading saved config, using defaults:", e); CONFIG = { ...DEFAULTS }; } } }; // Save all settings to localStorage - single config object const saveAllSettings = () => { if (typeof Storage !== "undefined") { localStorage.setItem("ao3_reading_quality_config", JSON.stringify(CONFIG)); } }; // Save a specific setting (updates the single config object) const saveSetting = (key, value) => { CONFIG[key] = value; saveAllSettings(); }; // Reset all settings to defaults const resetAllSettings = () => { if (confirm("Reset all settings to defaults?")) { if (typeof Storage !== "undefined") { localStorage.removeItem("ao3_reading_quality_config"); } CONFIG = { ...DEFAULTS }; countRatio(); calculateReadtime(); } }; // Robust number extraction from element const getNumberFromElement = (element) => { if (!element) return NaN; let text = element.getAttribute("data-ao3e-original") || element.textContent; if (text === null) return NaN; let cleanText = text.replace(/[,\s  ]/g, ""); if (element.matches("dd.chapters")) { cleanText = cleanText.split("/")[0]; } const number = parseInt(cleanText, 10); return isNaN(number) ? NaN : number; }; // --- READING TIME FUNCTIONS --- const checkCountable = () => { const foundStats = $("dl.stats"); if (foundStats.length === 0) return; const firstStat = foundStats[0]; if (firstStat.closest("li")?.matches(".work, .bookmark")) { countable = sortable = true; } else if (firstStat.closest(".statistics")) { countable = sortable = statsPage = true; } else if (firstStat.closest("dl.work")) { countable = true; } // Menu logic is now handled by initSharedMenu() }; const calculateReadtime = () => { if (!countable || !CONFIG.enableReadingTime) return; $("dl.stats").forEach((statsElement) => { // Check if readtime already exists to avoid duplicates if ($1("dt.readtime", statsElement)) return; const wordsElement = $1("dd.words", statsElement); if (!wordsElement) return; const words_count = getNumberFromElement(wordsElement); if (isNaN(words_count)) return; const minutes = words_count / CONFIG.wpm; const hrs = Math.floor(minutes / 60); const mins = (minutes % 60).toFixed(0); const minutes_print = hrs > 0 ? hrs + "h" + mins + "m" : mins + "m"; // Create elements const readtime_label = document.createElement("dt"); readtime_label.className = "readtime"; readtime_label.textContent = "Readtime:"; const readtime_value = document.createElement("dd"); readtime_value.className = "readtime"; readtime_value.textContent = minutes_print; // Apply styling readtime_value.style.color = CONFIG.colorText; readtime_value.style.borderRadius = "4px"; readtime_value.style.padding = "0 6px"; readtime_value.style.fontWeight = "bold"; readtime_value.style.display = "inline-block"; readtime_value.style.verticalAlign = "middle"; // Apply color based on reading time if (minutes < CONFIG.readingTimeLvl1) { readtime_value.style.backgroundColor = CONFIG.colorGreen; } else if (minutes < CONFIG.readingTimeLvl2) { readtime_value.style.backgroundColor = CONFIG.colorYellow; } else { readtime_value.style.backgroundColor = CONFIG.colorRed; } // Inherit font size and line height from dl.stats const parentStats = readtime_value.closest("dl.stats"); if (parentStats) { const computed = window.getComputedStyle(parentStats); readtime_value.style.lineHeight = computed.lineHeight; readtime_value.style.fontSize = computed.fontSize; } // Insert after words_value wordsElement.insertAdjacentElement("afterend", readtime_label); readtime_label.insertAdjacentElement("afterend", readtime_value); }); }; // --- QUALITY SCORE FUNCTIONS --- const calculateWordBasedScore = (kudos, hits, words) => { if (hits === 0 || words === 0 || kudos === 0) return 0; const effectiveChapters = words / 5000; const adjustedHits = hits / Math.sqrt(effectiveChapters); return (100 * kudos) / adjustedHits; }; const countRatio = () => { if (!countable || !CONFIG.enableQualityScore) return; $("dl.stats").forEach((statsElement) => { // Check if score already exists to avoid duplicates if ($1("dt.kudoshits", statsElement)) return; const hitsElement = $1("dd.hits", statsElement); const kudosElement = $1("dd.kudos", statsElement); const wordsElement = $1("dd.words", statsElement); const parentLi = statsElement.closest("li"); try { const hits = getNumberFromElement(hitsElement); const kudos = getNumberFromElement(kudosElement); const words = getNumberFromElement(wordsElement); if (isNaN(hits) || isNaN(kudos) || isNaN(words)) return; // Hide score if kudos below threshold if (kudos < CONFIG.minKudosToShowScore) { // Remove any previous score elements if (statsElement.querySelector("dt.kudoshits")) statsElement.querySelector("dt.kudoshits").remove(); if (statsElement.querySelector("dd.kudoshits")) statsElement.querySelector("dd.kudoshits").remove(); return; } let rawScore = calculateWordBasedScore(kudos, hits, words); if (kudos < 10) rawScore = 1; let displayScore = rawScore; // Normalize thresholds if normalization is enabled let thresholdLow = CONFIG.colorThresholdLow; let thresholdHigh = CONFIG.colorThresholdHigh; if (CONFIG.useNormalization) { displayScore = (rawScore / CONFIG.userMaxScore) * 100; displayScore = Math.min(100, displayScore); displayScore = Math.ceil(displayScore); // round up, no decimals thresholdLow = Math.ceil( (CONFIG.colorThresholdLow / CONFIG.userMaxScore) * 100 ); thresholdHigh = Math.ceil( (CONFIG.colorThresholdHigh / CONFIG.userMaxScore) * 100 ); } else { displayScore = Math.round(displayScore * 10) / 10; } const ratioLabel = document.createElement("dt"); ratioLabel.className = "kudoshits"; ratioLabel.textContent = "Score:"; const ratioValue = document.createElement("dd"); ratioValue.className = "kudoshits"; ratioValue.textContent = displayScore; ratioValue.style.color = CONFIG.colorText; ratioValue.style.borderRadius = "4px"; ratioValue.style.padding = "0 6px"; ratioValue.style.fontWeight = "bold"; ratioValue.style.display = "inline-block"; ratioValue.style.verticalAlign = "middle"; // Apply color based on score if (displayScore >= thresholdHigh) { ratioValue.style.backgroundColor = CONFIG.colorGreen; } else if (displayScore >= thresholdLow) { ratioValue.style.backgroundColor = CONFIG.colorYellow; } else { ratioValue.style.backgroundColor = CONFIG.colorRed; } // Inherit font size and line height from dl.stats const parentStats = ratioValue.closest("dl.stats"); if (parentStats) { const computed = window.getComputedStyle(parentStats); ratioValue.style.lineHeight = computed.lineHeight; ratioValue.style.fontSize = computed.fontSize; } hitsElement.insertAdjacentElement("afterend", ratioValue); hitsElement.insertAdjacentElement("afterend", ratioLabel); if (CONFIG.hideHitcount && !statsPage && hitsElement) { hitsElement.style.display = "none"; } if (parentLi) parentLi.setAttribute("kudospercent", displayScore); } catch (error) { console.error("Error calculating score:", error); } }); }; const sortByRatio = (ascending = false) => { if (!sortable) return; $("dl.stats").forEach((statsElement) => { const parentLi = statsElement.closest("li"); const list = parentLi?.parentElement; if (!list) return; const listElements = Array.from(list.children); listElements.sort((a, b) => { const aPercent = parseFloat(a.getAttribute("kudospercent")) || 0; const bPercent = parseFloat(b.getAttribute("kudospercent")) || 0; return ascending ? aPercent - bPercent : bPercent - aPercent; }); list.innerHTML = ""; list.append(...listElements); }); }; // --- SETTINGS POPUP --- const showSettingsPopup = () => { // Get AO3 input field background color let inputBg = "#fffaf5"; // fallback const testInput = document.createElement("input"); document.body.appendChild(testInput); try { const computedBg = window.getComputedStyle(testInput).backgroundColor; if ( computedBg && computedBg !== "rgba(0, 0, 0, 0)" && computedBg !== "transparent" ) { inputBg = computedBg; } } catch (e) {} testInput.remove(); const popup = document.createElement("div"); popup.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: ${inputBg}; padding: 20px; border-radius: 8px; box-shadow: 0 0 20px rgba(0,0,0,0.2); z-index: 10000; width: 90%; max-width: 500px; max-height: 80vh; overflow-y: auto; font-family: inherit; font-size: 16px; box-sizing: border-box; `; // Ensure headings inherit font family const style = document.createElement('style'); style.textContent = ` #ao3-rtqs-popup h3, #ao3-rtqs-popup h4 { font-family: inherit !important; } `; popup.id = 'ao3-rtqs-popup'; document.head.appendChild(style); const form = document.createElement("form"); // Calculate values for display const displayThresholdLow = CONFIG.useNormalization ? Math.ceil((CONFIG.colorThresholdLow / CONFIG.userMaxScore) * 100) : CONFIG.colorThresholdLow; const displayThresholdHigh = CONFIG.useNormalization ? Math.ceil((CONFIG.colorThresholdHigh / CONFIG.userMaxScore) * 100) : CONFIG.colorThresholdHigh; form.innerHTML = `

⚙️ Reading Time & Quality Score Settings ⚙️


Reading Time 📚

Quality Score 💖

Color Settings 🎨

Reset to Default Settings
`; // Toggle reading time settings const readingTimeCheckbox = form.querySelector("#enableReadingTime"); const readingTimeSettings = form.querySelector("#readingTimeSettings"); const toggleReadingTimeSettings = () => { readingTimeSettings.style.display = readingTimeCheckbox.checked ? "block" : "none"; }; readingTimeCheckbox.addEventListener("change", toggleReadingTimeSettings); // Toggle quality score settings const qualityScoreCheckbox = form.querySelector("#enableQualityScore"); const qualityScoreSettings = form.querySelector("#qualityScoreSettings"); const toggleQualityScoreSettings = () => { qualityScoreSettings.style.display = qualityScoreCheckbox.checked ? "block" : "none"; }; qualityScoreCheckbox.addEventListener("change", toggleQualityScoreSettings); // Toggle normalization labels, convert values, and show/hide userMaxScore const normCheckbox = form.querySelector("#useNormalization"); const normLabel = form.querySelector("#normalizationLabel"); const thresholdLowLabel = form.querySelector("#thresholdLowLabel"); const thresholdHighLabel = form.querySelector("#thresholdHighLabel"); const thresholdLowInput = form.querySelector("#colorThresholdLow"); const thresholdHighInput = form.querySelector("#colorThresholdHigh"); const userMaxScoreInput = form.querySelector("#userMaxScore"); const userMaxScoreContainer = form.querySelector("#userMaxScoreContainer"); const toggleNormalization = () => { if (normCheckbox.checked) { normLabel.textContent = "(for 100%)"; thresholdLowLabel.textContent = "(%)"; thresholdHighLabel.textContent = "(%)"; userMaxScoreContainer.style.display = "block"; // Convert current raw thresholds to percentages thresholdLowInput.value = Math.ceil( (parseFloat(thresholdLowInput.value) / parseFloat(userMaxScoreInput.value)) * 100 ); thresholdHighInput.value = Math.ceil( (parseFloat(thresholdHighInput.value) / parseFloat(userMaxScoreInput.value)) * 100 ); } else { normLabel.textContent = ""; thresholdLowLabel.textContent = ""; thresholdHighLabel.textContent = ""; userMaxScoreContainer.style.display = "none"; // Convert current percentages back to raw values thresholdLowInput.value = Math.round( (parseFloat(thresholdLowInput.value) / 100) * parseFloat(userMaxScoreInput.value) ); thresholdHighInput.value = Math.round( (parseFloat(thresholdHighInput.value) / 100) * parseFloat(userMaxScoreInput.value) ); } }; normCheckbox.addEventListener("change", toggleNormalization); // Add event listeners for reset and close form .querySelector("#resetSettingsLink") .addEventListener("click", function (e) { e.preventDefault(); resetAllSettings(); popup.remove(); }); form .querySelector("#closePopup") .addEventListener("click", () => popup.remove()); // Form submission form.addEventListener("submit", (e) => { e.preventDefault(); // Collect all values first let userMaxScoreValue = parseFloat( form.querySelector("#userMaxScore").value ); let thresholdLowValue = parseFloat( form.querySelector("#colorThresholdLow").value ); let thresholdHighValue = parseFloat( form.querySelector("#colorThresholdHigh").value ); const isNormalizationEnabled = form.querySelector("#useNormalization").checked; // CRITICAL FIX: If normalization is enabled, convert percentages back to raw scores before saving if (isNormalizationEnabled) { thresholdLowValue = (thresholdLowValue / 100) * userMaxScoreValue; thresholdHighValue = (thresholdHighValue / 100) * userMaxScoreValue; } // Update config object with all settings CONFIG.enableReadingTime = form.querySelector("#enableReadingTime").checked; CONFIG.enableQualityScore = form.querySelector("#enableQualityScore").checked; CONFIG.alwaysCountReadingTime = form.querySelector("#alwaysCountReadingTime").checked; CONFIG.wpm = parseInt(form.querySelector("#wpm").value); CONFIG.readingTimeLvl1 = parseInt(form.querySelector("#readingTimeLvl1").value); CONFIG.readingTimeLvl2 = parseInt(form.querySelector("#readingTimeLvl2").value); CONFIG.alwaysCountQualityScore = form.querySelector("#alwaysCountQualityScore").checked; CONFIG.alwaysSortQualityScore = form.querySelector("#alwaysSortQualityScore").checked; CONFIG.hideHitcount = form.querySelector("#hideHitcount").checked; CONFIG.minKudosToShowScore = parseInt(form.querySelector("#minKudosToShowScore").value); CONFIG.useNormalization = isNormalizationEnabled; CONFIG.userMaxScore = userMaxScoreValue; CONFIG.colorThresholdLow = thresholdLowValue; CONFIG.colorThresholdHigh = thresholdHighValue; CONFIG.colorGreen = form.querySelector("#colorGreen").value; CONFIG.colorYellow = form.querySelector("#colorYellow").value; CONFIG.colorRed = form.querySelector("#colorRed").value; CONFIG.colorText = form.querySelector("#colorText").value; // Save the entire config object saveAllSettings(); popup.remove(); countRatio(); calculateReadtime(); }); popup.appendChild(form); document.body.appendChild(popup); }; // --- UI MENU --- // Helper: check if current page is one of the allowed types for menu options function isAllowedMenuPage() { const path = window.location.pathname; // User bookmarks: /users/USERNAME/bookmarks or /bookmarks if (/^\/users\/[^\/]+\/bookmarks(\/|$)/.test(path) || /^\/bookmarks(\/|$)/.test(path)) return true; // User profile: /users/USERNAME (no trailing /works etc) if (/^\/users\/[^\/]+\/?$/.test(path)) return true; // Tag works: /tags/ANYTHING/works if (/^\/tags\/[^\/]+\/works(\/|$)/.test(path)) return true; // Collections: /collections/ANYTHING if (/^\/collections\/[^\/]+(\/|$)/.test(path)) return true; // Works index: /works if (/^\/works(\/|$)/.test(path)) return true; return false; } // --- SHARED MENU MANAGEMENT --- function initSharedMenu() { // Create shared menu object if it doesn't exist (copied from ao3_chapter_shortcuts.js) if (!window.AO3UserScriptMenu) { window.AO3UserScriptMenu = { items: [], register: function(item) { this.items.push(item); this.renderMenu(); }, renderMenu: function() { // Find or create menu container let menuContainer = document.getElementById('ao3-userscript-menu'); if (!menuContainer) { const headerMenu = document.querySelector("ul.primary.navigation.actions"); const searchItem = headerMenu ? headerMenu.querySelector("li.search") : null; if (!headerMenu || !searchItem) return; menuContainer = document.createElement("li"); menuContainer.className = "dropdown"; menuContainer.id = "ao3-userscript-menu"; const title = document.createElement("a"); title.href = "#"; title.textContent = "Userscripts"; menuContainer.appendChild(title); const menu = document.createElement("ul"); menu.className = "menu dropdown-menu"; menuContainer.appendChild(menu); headerMenu.insertBefore(menuContainer, searchItem); } // Render menu items const menu = menuContainer.querySelector("ul.menu"); if (menu) { menu.innerHTML = ""; this.items.forEach(item => { const li = document.createElement("li"); const a = document.createElement("a"); a.href = "#"; a.textContent = item.label; a.addEventListener("click", (e) => { e.preventDefault(); item.onClick(); }); li.appendChild(a); menu.appendChild(li); }); } } }; } // Register this script's menu items const showMenuOptions = isAllowedMenuPage(); if (showMenuOptions && CONFIG.enableReadingTime) { window.AO3UserScriptMenu.register({ label: "Reading Time: Calculate", onClick: calculateReadtime, }); } if (showMenuOptions && CONFIG.enableQualityScore) { window.AO3UserScriptMenu.register({ label: "Quality Score: Calculate Scores", onClick: countRatio, }); window.AO3UserScriptMenu.register({ label: "Quality Score: Sort by Score", onClick: () => sortByRatio(), }); } window.AO3UserScriptMenu.register({ label: "Reading Time & Quality Score Settings", onClick: showSettingsPopup, }); } // --- INITIALIZATION --- loadUserSettings(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { checkCountable(); initSharedMenu(); if (CONFIG.alwaysCountReadingTime) setTimeout(calculateReadtime, 1000); if (CONFIG.alwaysCountQualityScore) { setTimeout(() => { countRatio(); if (CONFIG.alwaysSortQualityScore) sortByRatio(); }, 1000); } }); } else { checkCountable(); initSharedMenu(); if (CONFIG.alwaysCountReadingTime) setTimeout(calculateReadtime, 1000); if (CONFIG.alwaysCountQualityScore) { setTimeout(() => { countRatio(); if (CONFIG.alwaysSortQualityScore) sortByRatio(); }, 1000); } } })();