Display a graph of how often new chapters of a work are published. Accessible from the chapter index page or a work blurb
// ==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); } } } })();