[AO3] Chapter Update Frequency

Display a graph of how often new chapters of a work are published. Accessible from the chapter index page or a work blurb

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         [AO3] Chapter Update Frequency
// @description  Display a graph of how often new chapters of a work are published. Accessible from the chapter index page or a work blurb
// @author       Ifky_
// @namespace    https://greasyfork.org/en/scripts/581769
// @version      1.0.0
// @history      1.0.0 — Display a graph of how often new chapters are published
// @match        https://archiveofourown.org/*works*
// @match        https://archiveofourown.org/*series*
// @icon         https://archiveofourown.org/images/logo.png
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @license      GPL-3.0-only
// @grant        none
// ==/UserScript==
"use strict";
(function () {
    /**
     * Get the chapter updates from the DOM and parse them as `ChapterUpdate` objects
     *
     * @returns List of objects that contain each chapter and its publishing date
     */
    const parseChapterUpdates = (list) => {
        const items = Array.from(list.querySelectorAll("li"));
        return items
            .map((li, index) => {
            const a = li.querySelector("a");
            const dateEl = li.querySelector("span.datetime");
            if (!a || !dateEl) {
                return null;
            }
            // Get date. E.g. "(2023-09-03)"
            const raw = (dateEl.textContent || "").trim();
            const iso = raw.replace(/[()]/g, "");
            // Force UTC midnight so timezone doesn't shift the day count
            const date = new Date(`${iso}T00:00:00Z`);
            if (Number.isNaN(date.getTime())) {
                return null;
            }
            const title = (a.textContent || "").trim();
            // Try to read the leading chapter number; fallback to position
            const match = title.match(/^(\d+)\./);
            const chapter = match ? Number(match[1]) : index + 1;
            return { chapter, date };
        })
            .filter((v) => v !== null)
            .sort((a, b) => a.chapter - b.chapter);
    };
    /**
     * Calculate the number of days between two dates
     *
     * @param start The first date
     * @param end The second date
     * @returns
     */
    const daysBetween = (start, end) => {
        const msPerDay = 24 * 60 * 60 * 1000;
        return Math.round((end.getTime() - start.getTime()) / msPerDay);
    };
    /**
     * Construct lists of labels and their belonging values (days between updates)
     *
     * @param chapters
     * @returns
     */
    const buildGapSeries = (chapters) => {
        if (chapters.length <= 1) {
            return { labels: [], values: [] };
        }
        const labels = [];
        const values = [];
        for (let i = 1; i < chapters.length; i++) {
            const prev = chapters[i - 1];
            const curr = chapters[i];
            labels.push(`Ch ${prev.chapter} → ${curr.chapter}`);
            values.push(daysBetween(prev.date, curr.date));
        }
        return { labels, values };
    };
    /**
     * Create a canvas if it doesn't already exist
     *
     * @returns
     */
    const ensureCanvas = (parent, height) => {
        let container = document.getElementById("chapter-update-chart");
        let canvas = container?.querySelector("canvas");
        if (!container) {
            container = document.createElement("div");
            container.style.width = "100%";
            container.style.maxWidth = "100vw";
            container.style.marginInline = "auto";
            container.style.height = `${height}px`;
            container.id = "chapter-update-chart";
            canvas = document.createElement("canvas");
            container.append(canvas);
            parent.append(container);
            return canvas;
        }
        else {
            parent.append(container);
            return canvas;
        }
    };
    /**
     * Create the chart
     *
     * @param canvas
     * @param chapters
     * @returns
     */
    const renderUpdateFrequencyChart = (canvas, chapters) => {
        const { labels, values } = buildGapSeries(chapters);
        if (chapters.length < 1) {
            return null;
        }
        // Assume Chart.js is already loaded globally
        // eslint-disable-next-line
        const ChartCtor = window.Chart;
        // Get dark mode or light mode colour scheme
        const getTheme = () => {
            const darkModeMql = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
            if (darkModeMql && darkModeMql.matches) {
                return {
                    text: "#BBB",
                    background: "#333",
                    gridLines: "#555",
                    line: "#900",
                };
            }
            else {
                return {
                    text: "#555",
                    background: "#FFF",
                    gridLines: "#DDD",
                    line: "#900",
                };
            }
        };
        // Set chart background
        const plugin = {
            id: "customCanvasBackgroundColor",
            beforeDraw: (chart, _, options) => {
                const { ctx: context } = chart;
                context.save();
                context.globalCompositeOperation = "destination-over";
                context.fillStyle = options.color || "white";
                context.fillRect(0, 0, chart.width, chart.height);
                context.restore();
            },
        };
        // Configuration for the chart
        const existingChart = ChartCtor.getChart(canvas);
        if (existingChart) {
            existingChart.destroy();
        }
        new ChartCtor(canvas.getContext("2d"), {
            type: "line",
            data: {
                labels,
                datasets: [
                    {
                        label: "Days between updates",
                        data: values,
                        borderColor: getTheme().line,
                        backgroundColor: getTheme().line,
                        borderWidth: 2,
                        pointRadius: chapters.length > 80 ? 1.5 : 3,
                        pointHoverRadius: chapters.length > 80 ? 2.5 : 5,
                    },
                ],
            },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                interaction: {
                    mode: "nearest",
                    intersect: false,
                },
                plugins: {
                    customCanvasBackgroundColor: {
                        color: getTheme().background,
                    },
                    title: {
                        display: true,
                        text: "Chapter Update Frequency",
                        color: getTheme().text,
                    },
                    tooltip: {
                        callbacks: {
                            label(ctx) {
                                return `${ctx.parsed.y} day(s)`;
                            },
                        },
                    },
                    legend: {
                        display: false,
                    },
                },
                scales: {
                    x: {
                        offset: true,
                        title: {
                            display: false,
                        },
                        ticks: {
                            autoSkip: true,
                            maxTicksLimit: chapters.length > 120 ? 20 : 40,
                            color: getTheme().text,
                        },
                        grid: {
                            color: getTheme().gridLines,
                        },
                    },
                    y: {
                        beginAtZero: true,
                        title: {
                            display: true,
                            text: "Days between updates",
                            color: getTheme().text,
                        },
                        ticks: {
                            color: getTheme().text,
                            precision: 0, // integers only
                        },
                        grid: {
                            color: getTheme().gridLines,
                        },
                    },
                },
            },
            plugins: [plugin],
        });
    };
    const getChapterIndex = async (workUrl) => {
        const fetchUrl = workUrl + "/navigate";
        return await fetch(fetchUrl)
            .then(async (res) => {
            return res.text();
        })
            .then((html) => {
            const parser = new DOMParser();
            return parser.parseFromString(html, "text/html");
        })
            .catch(() => {
            alert(`Could not fetch content from ${fetchUrl}. Try again later.`);
            return null;
        });
    };
    const createBlurbContent = (blurb) => {
        // Work URL
        const workLink = blurb.querySelector(".header .heading a:first-of-type");
        if (workLink == null) {
            alert("Could not find work URL in blurb");
        }
        // Chapters
        const chapterLink = blurb.querySelector(".stats .chapters a:first-of-type");
        const chapterCount = chapterLink?.innerHTML ?? "0";
        if (parseInt(chapterCount) <= 1) {
            // Ignore blurb
            return null;
        }
        // Container
        const container = document.createElement("div");
        container.classList.add("ch-update-frequency--container");
        container.style.clear = "both";
        container.style.paddingTop = ".5em";
        const actions = document.createElement("ul");
        actions.classList.add("actions");
        container.append(actions);
        const li = document.createElement("li");
        actions.append(li);
        const button = document.createElement("button");
        button.innerText = "Get Updates";
        actions.append(button);
        const diagram = document.createElement("div");
        diagram.classList.add("ch-update-frequency--diagram");
        diagram.style.clear = "both";
        diagram.style.paddingTop = "1rem";
        container.append(diagram);
        button.addEventListener("click", async () => {
            button.disabled = true;
            // Get chapter index HTML
            const workUrl = workLink.href;
            const chapterIndexHtml = await getChapterIndex(workUrl);
            const chapterIndex = chapterIndexHtml?.querySelector("#main ol.chapter.index.group");
            if (chapterIndex != null) {
                // Create chapter update frequency diagram
                const chapters = parseChapterUpdates(chapterIndex);
                const canvas = ensureCanvas(diagram, 300);
                renderUpdateFrequencyChart(canvas, chapters);
            }
            else {
                alert("Failed to parse chapter index HTML");
            }
            button.disabled = false;
        });
        return container;
    };
    // Chapter Index
    const chapterIndex = document.querySelector("#main ol.chapter.index.group");
    if (chapterIndex != null) {
        const chapters = parseChapterUpdates(chapterIndex);
        const main = document.getElementById("main");
        const canvas = ensureCanvas(main, 400);
        renderUpdateFrequencyChart(canvas, chapters);
    }
    // Blurbs
    const workIndex = document.querySelector("#main .work.index.group");
    if (workIndex != null) {
        const blurbs = workIndex.querySelectorAll(".work.blurb");
        for (let blurb of blurbs) {
            // Append container with button and diagram
            const container = createBlurbContent(blurb);
            if (container != null) {
                blurb.append(container);
            }
        }
    }
})();