AO3 Word Count Script

Adds word counts to chapter links on AO3 Chapter Index pages and in Stats on each chapter page.

// ==UserScript==
// @name         AO3 Word Count Script
// @namespace    ao3chapterwordcounter
// @version      4.1
// @description  Adds word counts to chapter links on AO3 Chapter Index pages and in Stats on each chapter page.
// @author       Anton Dumov
// @license      MIT
// @match        https://archiveofourown.org/*/navigate
// @match        https://archiveofourown.org/*/chapters/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const uri = location.protocol+'//'+
          location.hostname+
          (location.port?":"+location.port:"")+
          location.pathname+
          (location.search?location.search:"");
    const wordCountRegex = /\s+/g;
    const chapterUrlRegex = new RegExp("https://archiveofourown\\.org/works/\\d+/chapters/\\d+/?");
    const cacheKeyPrefix = "ao3-word-count-cache-";
    const cacheDurationMs = 30 * 24 * 60 * 60 * 1000;

    const getCachedWordCount = link => {
        const cacheKey = cacheKeyPrefix + link.href;
        const cachedValue = localStorage.getItem(cacheKey);
        if (cachedValue) {
            const { timestamp, wordCount } = JSON.parse(cachedValue);
            if (Date.now() - timestamp < cacheDurationMs && wordCount !== 0) {
                return wordCount;
            } else {
                localStorage.removeItem(cacheKey);
            }
        }
        return null;
    };

    const setCachedWordCount = (url, wordCount) => {
        const cacheKey = cacheKeyPrefix + url;
        const cacheValue = JSON.stringify({ timestamp: Date.now(), wordCount });
        localStorage.setItem(cacheKey, cacheValue);
    };

    let fetchInProgress = false;

    const countWords = (doc) => {
        const article = doc.querySelector("div[role=article]");
        return article ? article.textContent.trim().split(wordCountRegex).length : 0;
    };

    const fetchWordCount = async (url) => {
        try {
            if (fetchInProgress) {
                // Wait for the previous request to complete
                await new Promise(resolve => {
                    const interval = setInterval(() => {
                        if (!fetchInProgress) {
                            clearInterval(interval);
                            resolve();
                        }
                    }, 2000);
                });
            }
            fetchInProgress = true;

            const response = await fetch(url);
            const text = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(text, "text/html");
            const wordCount = countWords(doc);
            setCachedWordCount(url, wordCount);
            fetchInProgress = false;
            return wordCount;
        } catch (error) {
            console.log(error);
            fetchInProgress = false;
        }
    };

    const getWordCount = async (link, maxWidth, longTitles) => {
        const cachedWordCount = getCachedWordCount(link);
        let wordCount;
        if (cachedWordCount) {
            wordCount = cachedWordCount;
        } else {
            wordCount = await fetchWordCount(link.href);
        }
        const wordCountElement = document.createElement("span");
        wordCountElement.textContent = `(${wordCount} words)`;
        if (!longTitles){
            const spanElement = link.parentElement.querySelector('span.datetime');
            const margin = maxWidth - link.getBoundingClientRect().width + 7;
            wordCountElement.style.marginLeft = `${margin}px`;
            spanElement.parentNode.insertBefore(wordCountElement, spanElement.nextSibling);
        } else {
            link.parentNode.insertBefore(wordCountElement, link);
            link.parentElement.style.paddingLeft = `7.5em`;
            wordCountElement.style.position = 'absolute';
            wordCountElement.style.left = '0';
        }
    };

    if (uri.endsWith("navigate")){
        const chapterLinks = document.querySelectorAll("ol.chapter.index.group li a");

        const parentWidth = chapterLinks[0].parentElement.getBoundingClientRect().width;
        let maxWidth = 0;
        let longTitles = false;

        chapterLinks.forEach(link => {
            const width = link.getBoundingClientRect().width;
            if (width > maxWidth) {
                maxWidth = width;
            }
            if (width + 175 >= parentWidth) {
                longTitles = true;
            }
        });

        chapterLinks.forEach(link => {
            getWordCount(link, maxWidth, longTitles);
        });
    } else if (chapterUrlRegex.test(uri)) {
        const wordsCount = countWords(document);
        const statsElement = document.querySelector('dl.stats');
        const ddElement = document.createElement('dd');
        ddElement.classList.add('chapter-words');
        ddElement.textContent = wordsCount;
        const dtElement = document.createElement('dt');
        dtElement.classList.add('chapter-words');
        dtElement.textContent = 'Chapter Words:';
        statsElement.appendChild(dtElement);
        statsElement.appendChild(ddElement);
        setCachedWordCount(uri, wordsCount);
    }
})();