Greasy Fork is available in English.

AO3: Quality Score Improved

Calculates and displays quality scores for AO3 works with customizable options

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name        AO3: Quality Score Improved
// @description Calculates and displays quality scores for AO3 works with customizable options
// @namespace   https://greasyfork.org/scripts/3144-ao3-kudos-hits-ratio
// @author      Min (Extensive modifications by Assistant)
// @version     6.1
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @require     https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @include     http://archiveofourown.org/*
// @include     https://archiveofourown.org/*
// @license     MIT
// ==/UserScript==

(function ($) {
    'use strict';

    // Configuration
    const CONFIG = {
        weights: {
            kudosHitRatio: 50,
            chapterAdjustment: 0.05,
            commentEngagement: 20,
            bookmarkScore: 30,
            wordCountFactor: 0.5,
            timeDecayHalfLife: 365 // days
        },
        colorThresholds: {
            low: 30,
            medium: 60
        },
        options: {
            autoSort: GM_getValue('autoSort', false),
            showScores: GM_getValue('showScores', true),
            hideWorks: GM_getValue('hideWorks', false),
            hideThreshold: GM_getValue('hideThreshold', 20)
        }
    };

    // CSS Styles
    GM_addStyle(`
      .quality-score {
        font-weight: bold;
        padding: 2px 5px;
        border-radius: 3px;
        margin-left: 10px;
      }
      .quality-score-low { background-color: #ffcccb; color: #8b0000; }
      .quality-score-medium { background-color: #ffffa1; color: #8b8b00; }
      .quality-score-high { background-color: #90EE90; color: #006400; }
      .work-stats { display: flex; align-items: center; }
      .work-stats > dd { margin-right: 10px; }
    `);

    // Core Functions
    const calculateQualityScore = (stats) => {
        if (stats.hits === 0) return 0;

        const baseScore = (stats.kudos / Math.sqrt(stats.hits)) * CONFIG.weights.kudosHitRatio;
        const chapterAdjustment = 1 + (stats.chapters - 1) * CONFIG.weights.chapterAdjustment;
        const commentBonus = (stats.comments / stats.hits) * CONFIG.weights.commentEngagement;
        const bookmarkBonus = (stats.bookmarks / stats.hits) * CONFIG.weights.bookmarkScore;
        const wordCountFactor = Math.log(stats.wordCount) / Math.log(10000) * CONFIG.weights.wordCountFactor;

        const daysSincePublish = (new Date() - stats.publishDate) / (1000 * 60 * 60 * 24);
        const timeDecayFactor = Math.exp(-daysSincePublish / CONFIG.weights.timeDecayHalfLife);

        const score = ((baseScore * chapterAdjustment + commentBonus + bookmarkBonus) * (1 + wordCountFactor)) * timeDecayFactor;
        return Math.min(99, score * 0.9);
    };

    const getScoreClass = (score) => {
        if (score >= CONFIG.colorThresholds.medium) return 'quality-score-high';
        if (score >= CONFIG.colorThresholds.low) return 'quality-score-medium';
        return 'quality-score-low';
    };

    const addScoresToWorks = () => {
        $('ol.work.index > li').each(function () {
            const $work = $(this);
            const $stats = $work.find('dl.stats');

            try {
                const stats = {
                    hits: parseInt($stats.find('dd.hits').text().replace(/,/g, '')) || 0,
                    kudos: parseInt($stats.find('dd.kudos a').text().replace(/,/g, '')) || 0,
                    chapters: parseInt($stats.find('dd.chapters a').text().split('/')[0]) || 1,
                    comments: parseInt($stats.find('dd.comments a').text().replace(/,/g, '')) || 0,
                    bookmarks: parseInt($stats.find('dd.bookmarks a').text().replace(/,/g, '')) || 0,
                    wordCount: parseInt($stats.find('dd.words').text().replace(/,/g, '')) || 0,
                    publishDate: new Date($work.find('p.datetime').text())
                };

                const qualityScore = calculateQualityScore(stats);
                $work.attr('data-quality-score', qualityScore);

                if (CONFIG.options.showScores) {
                    const scoreDisplay = qualityScore.toFixed(1);
                    const $scoreElement = $('<dd>')
                        .addClass('quality-score')
                        .addClass(getScoreClass(qualityScore))
                        .text(`Score: ${scoreDisplay}`);
                    $stats.addClass('work-stats').append($scoreElement);
                }

                if (CONFIG.options.hideWorks && qualityScore < CONFIG.options.hideThreshold) {
                    $work.hide();
                }

            } catch (error) {
                console.error(`Error processing work stats: ${error.message}`);
            }
        });

        if (CONFIG.options.autoSort) {
            sortWorksByScore();
        }
    };

    const sortWorksByScore = () => {
        const $workList = $('ol.work.index');
        const $works = $workList.children('li').get();

        $works.sort((a, b) => {
            const scoreA = parseFloat($(a).attr('data-quality-score')) || 0;
            const scoreB = parseFloat($(b).attr('data-quality-score')) || 0;
            return scoreB - scoreA;
        });

        $workList.append($works);
    };

    const addQualityScoreMenu = () => {
        const $headerMenu = $('ul.primary.navigation.actions');
        if ($headerMenu.length === 0) {
            console.error('Header menu not found, skipping menu addition');
            return;
        }

        const $scoreMenu = $('<li class="dropdown">').html('<a href="#">Quality Score</a>');
        $headerMenu.find('li.search').before($scoreMenu);

        const $dropMenu = $('<ul class="menu dropdown-menu">');
        $scoreMenu.append($dropMenu);

        const addMenuItem = (text, clickHandler) => {
            const $menuItem = $('<li>').html(`<a href="#">${text}</a>`);
            $menuItem.on('click', (e) => {
                e.preventDefault();
                clickHandler();
            });
            $dropMenu.append($menuItem);
        };

        addMenuItem(`Auto-sort: ${CONFIG.options.autoSort ? 'ON' : 'OFF'}`, () => {
            CONFIG.options.autoSort = !CONFIG.options.autoSort;
            GM_setValue('autoSort', CONFIG.options.autoSort);
            location.reload();
        });

        addMenuItem(`Show Scores: ${CONFIG.options.showScores ? 'ON' : 'OFF'}`, () => {
            CONFIG.options.showScores = !CONFIG.options.showScores;
            GM_setValue('showScores', CONFIG.options.showScores);
            location.reload();
        });

        addMenuItem(`Hide Low Quality: ${CONFIG.options.hideWorks ? 'ON' : 'OFF'}`, () => {
            CONFIG.options.hideWorks = !CONFIG.options.hideWorks;
            GM_setValue('hideWorks', CONFIG.options.hideWorks);
            location.reload();
        });

        addMenuItem('Set Hide Threshold', () => {
            const newThreshold = prompt('Enter new hide threshold (0-100):', CONFIG.options.hideThreshold);
            if (newThreshold !== null && !isNaN(newThreshold)) {
                CONFIG.options.hideThreshold = Math.min(100, Math.max(0, parseInt(newThreshold)));
                GM_setValue('hideThreshold', CONFIG.options.hideThreshold);
                location.reload();
            }
        });

        addMenuItem('Recalculate Scores', () => {
            addScoresToWorks();
        });

        addMenuItem('Sort by Score (High to Low)', () => sortWorksByScore(false));
        addMenuItem('Sort by Score (Low to High)', () => sortWorksByScore(true));
    };

    // Main execution
    $(document).ready(() => {
        addQualityScoreMenu();
        addScoresToWorks();
    });

})(jQuery);