[AO3] Kat's Tweaks: Read Time & Word Count

Adds chapter word count, chapter read time, and work read time to stats in the blurb.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         [AO3] Kat's Tweaks: Read Time & Word Count
// @author       Katstrel
// @description  Adds chapter word count, chapter read time, and work read time to stats in the blurb.
// @version      1.1.1
// @history      1.1.1 - added fourth default level and fixed error with deleted bookmark blurbs
// @history      1.1 - added dynamic customization options
// @history      1.0.1 - fixed userscript header
// @namespace    https://github.com/Katstrel/Kats-Tweaks-and-Skins
// @include      https://archiveofourown.org/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=archiveofourown.org
// @grant        none
// ==/UserScript==
"use strict";
let DEBUG = false;

// তততততততত SETTINGS তততততততত //

let SETTINGS = {
    readTime: {
        enabled: true,
        wordsPerMinute: 200,
        levels: [
            {
                id: "Level_0",
                name: "Level_0",
                mins: 0,
                color: '#80ff8080',
            },
            {
                id: "Level_1",
                name: "Level_1",
                mins: 60,
                color: '#ffff8080',
            },
            {
                id: "Level_2",
                name: "Level_2",
                mins: 180,
                color: '#ff808080',
            },
            {
                id: "Level_3",
                name: "24 Hours",
                mins: 1440,
                color: '#ff80ff80',
            },
        ],
    }
};

// তততততত STOP SETTINGS তততততত //

/* Parts of code used or based on:
AO3 Bookmarking Records by Bairdel
AO3: Estimated Reading Time v2 by lomky
AO3: Get Current Chapter Word Count by w4tchdoge
*/

class ReadTime {
    constructor(settings) {
        this.settings = settings.readTime;
        console.info(`[Kat's Tweaks] Read Time & Word Count | Initialized with:`, this.settings);

        // Performs the Read Time on all blurbs
        document.querySelectorAll('li.work.blurb, li.bookmark.blurb, dl.work.meta, dl.series.meta, li.series.blurb').forEach(blurb=> {
            if (blurb.querySelector('p.message')) {
                return;
            }
            let wordCount = this.getWordCount(blurb);
            this.calculateTime(blurb.querySelector('dd.words'), wordCount);
        });

        // Chapter Word Count and Read Time if more than one chapter exists
        if (document.querySelector(`dl.work.meta dl.stats dd.chapters`)) {
            let chapCount = parseInt(document.querySelector(`dd.stats dd.chapters`).textContent.split(`/`).at(0));
            if (window.location.pathname.toLowerCase().includes(`chapters`) && chapCount > 1) {
                const chapWord = this.chapWordCount();
                const formatCount = new Intl.NumberFormat({ style: `decimal` }).format(chapWord);
                this.addBlurbStat(document.querySelector('dl.stats dd.chapters'), 'Words in Chapter:', formatCount, 'chapterWords');
                this.calculateTime(document.querySelector('dd#katstweaks.chapterWords'), chapWord, 'Chapter');
            }
        }
    }

    getWordCount(blurb) {
        let words = blurb.querySelector('dd.words').innerText;
        if (words.includes(",")) {
            words = words.replaceAll(",", ""); 
        }
        if (words.includes(" ")) {
            words = words.replaceAll(" ", "");
        }
        if (words.includes(" ")) {
            words = words.replaceAll(/\s/g, ""); 
        }
    
        let wordsINT = parseInt(words);
        DEBUG && console.log(`[Kat's Tweaks] Work Word Count: ${wordsINT}`);
        return wordsINT;
    }

    calculateTime(querySelect, wordCount, type = '') {
        let minutes = wordCount/(this.settings.wordsPerMinute);
        let hrs = Math.floor(minutes/60);
        let mins = (minutes%60).toFixed(0);

        // Get minutes with zero decimal points
        let timePrint = hrs > 0 ? hrs + "h" + mins + "m" : mins + "m";
        console.info(`[Kat's Tweaks] Time calculated for ${wordCount} words: ${timePrint}`);

        // Add readtime stats
        let dlItem = this.addBlurbStat(querySelect, `${type} Readtime:`, timePrint, `${type}readtime`);

        // Finds the closest smaller value for read time
        let filteredLevels = this.settings.levels.filter((level) => {
            return level.mins <= minutes;
        });
        let sorted = filteredLevels.sort(function(a, b){return a.mins - b.mins});
        DEBUG && console.log(`[Kat's Tweaks] Sorted list`, sorted);

        dlItem[1].style.backgroundColor = sorted[sorted.length-1].color;
    }
    
    // Credit: w4tchdoge's AO3: Get Current Chapter Word Count
    chapWordCount() {
        // Get the Chapter Text 
        const chapter_text = (function () {
            let elm_parent = document.querySelector(`[role="article"]:has(> #work)`).cloneNode(true);
            elm_parent.removeChild(elm_parent.querySelector(`#work`));
            return elm_parent.textContent.trim();
        })();

        const script_list = [`Arabic`, `Armenian`, `Balinese`, `Bengali`, `Bopomofo`, `Braille`, `Buginese`, `Buhid`, `Canadian_Aboriginal`, `Carian`, `Cham`, `Cherokee`, `Common`, `Coptic`, `Cuneiform`, `Cypriot`, `Cyrillic`, `Deseret`, `Devanagari`, `Ethiopic`, `Georgian`, `Glagolitic`, `Gothic`, `Greek`, `Gujarati`, `Gurmukhi`, `Han`, `Hangul`, `Hanunoo`, `Hebrew`, `Hiragana`, `Inherited`, `Kannada`, `Katakana`, `Kayah_Li`, `Kharoshthi`, `Khmer`, `Lao`, `Latin`, `Lepcha`, `Limbu`, `Linear_B`, `Lycian`, `Lydian`, `Malayalam`, `Mongolian`, `Myanmar`, `New_Tai_Lue`, `Nko`, `Ogham`, `Ol_Chiki`, `Old_Italic`, `Old_Persian`, `Oriya`, `Osmanya`, `Phags_Pa`, `Phoenician`, `Rejang`, `Runic`, `Saurashtra`, `Shavian`, `Sinhala`, `Sundanese`, `Syloti_Nagri`, `Syriac`, `Tagalog`, `Tagbanwa`, `Tai_Le`, `Tamil`, `Telugu`, `Thaana`, `Thai`, `Tibetan`, `Tifinagh`, `Ugaritic`, `Vai`, `Yi`];
        const script_exclude_list = [`Common`, `Latin`, `Inherited`];

        // Counting the number of words
        const word_count_regex = new RegExp((function () {
            const regex_scripts = script_list.filter((elm) => !script_exclude_list.includes(elm)).map((elm) => `\\p{Script=${elm}}`).join(``);
            const full_regex_str = `[${regex_scripts}]|((?![${regex_scripts}])[\\p{Letter}\\p{Mark}\\p{Number}\\p{Connector_Punctuation}])+`;
            return full_regex_str;
        })(), `gv`);
        const word_count_arr = Array.from(chapter_text.replaceAll(/--/g, `—`).replaceAll(/['’‘-]/g, ``).matchAll(word_count_regex), (m) => m[0]);
        const word_count_int = word_count_arr.length;
        DEBUG && console.log(`[Kat's Tweaks] Chapter Word Count: ${word_count_int}`);
        return word_count_int;
    }

    addBlurbStat(querySelectDL, term, definiton, styleClass) {
        const descListTerm = Object.assign(document.createElement(`dt`), {
            id: `katstweaks`,
            className: styleClass || "",
            textContent: term || "",
        });
        const descListDefine = Object.assign(document.createElement(`dd`), {
            id: `katstweaks`,
            className: styleClass || "",
            textContent: definiton || ""
        });

        querySelectDL.after(descListTerm, descListDefine);
        DEBUG && console.info(`[Kat's Tweaks] Custom DLItem '${term}' added successfully`);
        return [descListTerm, descListDefine];
    }

}

class Main {
    constructor() {
        this.settings = this.loadSettings();
        if (this.settings.readTime.enabled) {
            new ReadTime(this.settings);
        }
    }

    // Load settings from the storage or fallback to default ones
    loadSettings() {
        const startTime = performance.now();
        let savedSettings = localStorage.getItem('KT-SavedSettings');
        let settings = SETTINGS;

        if (savedSettings) {
            try {
                let parse = JSON.parse(savedSettings);
                DEBUG && console.log(`[Kat's Tweaks] Settings loaded successfully:`, savedSettings);

                settings = parse;

            } catch (error) {
                DEBUG && console.error(`[Kat's Tweaks] Error parsing settings: ${error}`);
            }
        } else {
            DEBUG && console.warn(`[Kat's Tweaks] No saved settings found for Read Time & Word Count, using default settings.`);
        }

        const endTime = performance.now();
        DEBUG && console.log(`[Kat's Tweaks] Settings loaded in ${endTime - startTime} ms`);
        return settings;
    }
}

new Main();