[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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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);
            }
        }
    }
})();