您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Track statistics on the stats page and display the stats in charts. Stores the stats in the browser's local storage.
// ==UserScript== // @name AO3 Statistics tracker // @description Track statistics on the stats page and display the stats in charts. Stores the stats in the browser's local storage. // @author Ifky_ // @namespace https://greasyfork.org/en/scripts/518523 // @version 1.1.0 // @history 1.1.0 — Add option "week" for cleaning stats. Add error alert for setting local storage item. // @history 1.0.1 — Import dependency scripts in the proper way. Set CLEAN_DATA default to "day" instead of "hour" // @history 1.0.0 — Track stats, draw charts, download chart, clean stats, import/export stats, toggle view // @match https://archiveofourown.org/users/*/stats* // @match http://archiveofourown.org/users/*/stats* // @icon https://archiveofourown.org/images/logo.png // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-adapter-date-fns.bundle.min.js // @license GPL-3.0-only // @grant none // ==/UserScript== "use strict"; (function () { /** ==================================== **/ /* FLAGS MEANT TO BE EDITED: CAPITALIZED */ /* Keep in mind that these might be */ /* reset if the script is auto-updated, */ /* so create a backup somewhere safe */ /** ==================================== **/ // Clean data by removing unnecessary points. // Options: "hour" / "day" / "week" / "month" const CLEAN_DATA = "day"; // Theme mode for the chart. // Options: "light" / "dark" const THEME_MODE = "light"; // Whether to include or exclude work stats // Can be a good idea to turn off if you have many works // Options: true / false const INCLUDE_WORKS = true; // The sign used to separate values // Change it to something else if it clashes with a work name // Options: any string const DELIMITER = ";"; const getTheme = (mode) => { if (mode === "dark") { return { text: "#999", background: "#222", gridLines: "#333", userSubscriptions: "#F94144", kudos: "#F3722C", commentThreads: "#F9C74F", bookmarks: "#90BE6D", subscriptions: "#43AA8B", wordCount: "#6C8EAD", hits: "#8552BA", }; } else { // Default to light mode return { text: "#777", background: "#FFF", gridLines: "#DDD", userSubscriptions: "#F94144", kudos: "#F3722C", commentThreads: "#F9C74F", bookmarks: "#90BE6D", subscriptions: "#43AA8B", wordCount: "#577590", hits: "#663C91", }; } }; const sleep = (ms) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; const trackStats = () => { const newTotalStats = getTotalStats(); storeInLocalStorage(newTotalStats); if (INCLUDE_WORKS) { const newWorkStats = getWorkStats(); storeInLocalStorage(newWorkStats); } drawChart(chartContainer, canvasTotal, "total"); }; const exportStats = () => { let csvContent = "data:text/csv;charset=utf-8,"; // Headers csvContent += "type;workName;statName;date;value\r\n"; const keys = Object.keys(localStorage).filter((key) => isValidKey(key)); keys.forEach((key, index) => { // Add \r\n to every row but the last one csvContent += [key, localStorage.getItem(key)].join(DELIMITER) + (index !== keys.length - 1 ? "\r\n" : ""); }); var encodedUri = encodeURI(csvContent); var link = document.createElement("a"); link.href = encodedUri; link.download = `stats-${formatDate(new Date()).yyyyMMdd}.csv`; link.click(); }; const importStats = (event) => { const target = event.target; if (target instanceof HTMLInputElement) { Array.from(target.files).forEach((file) => { csvFileToStatsRow(file) .then((rows) => { storeInLocalStorage(rows); alert(`${rows.length} rows imported!`); drawChart(chartContainer, canvasTotal, "total"); }) .catch((error) => { alert(error); }); }); // Reset input target.value = ""; } }; const csvFileToStatsRow = (file) => { return new Promise((resolve, reject) => { // Check if the file is of .csv type if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { reject(new Error("The file must be a .csv file.")); } const reader = new FileReader(); // Handle the file load event reader.onload = () => { if (typeof reader.result === "string") { const lines = reader.result.split("\r\n"); // Validate header const header = lines[0]; const [headerType, headerWorkName, headerStatName, headerDate, headerValue,] = header.split(DELIMITER); if (headerType !== "type" || headerWorkName !== "workName" || headerStatName !== "statName" || headerDate !== "date" || headerValue !== "value") { reject(new Error(`Header(s) could not be inferred: ${header}`)); } const rows = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]; const [type, workName, statName, date, value] = line.split(DELIMITER); try { validateStatRow({ type, workName, statName, date, value, }, i); const row = { type: type, workName: workName, statName: statName, date: new Date(date), value: Number(value), }; rows.push(row); } catch (error) { reject(error); } } resolve(rows); } else { reject(new Error("File content could not be read as text.")); } }; reader.onerror = () => { reject(new Error("An error occurred while reading the file.")); }; reader.readAsText(file); }); }; let statChart = null; let statisticsMetaGroup = null; let statisticsIndexGroup = null; let canvasTotal = null; let chartWidth = 300; const chartHeight = 250; const chartContainer = document.createElement("div"); chartContainer.id = "chart-container-total"; chartContainer.style.marginBlock = "5px"; chartContainer.style.transition = "height 500ms ease-in-out"; chartContainer.style.overflow = "hidden"; const toggleChartContainer = (container, open) => { if (open === true) { container.style.height = `${chartHeight}px`; return; } else if (open === false) { container.style.height = "0px"; return; } if (container.style.height === "0px") { container.style.height = `${chartHeight}px`; } else { container.style.height = "0px"; } }; const observer = new MutationObserver(() => { statChart = document.getElementById("stat_chart"); statisticsMetaGroup = document.querySelector(".statistics.meta.group"); statisticsIndexGroup = document.querySelector(".statistics.index.group"); if (statChart && statisticsMetaGroup && statisticsIndexGroup) { observer.disconnect(); // Stop observing once the element is found chartWidth = Math.round(statChart.offsetWidth); chartContainer.style.width = `${chartWidth}px`; chartContainer.style.height = `0px`; statChart.prepend(chartContainer); canvasTotal = document.createElement("canvas"); canvasTotal.id = "tracked-stats-chart"; canvasTotal.width = chartWidth; canvasTotal.height = chartHeight; canvasTotal.style.display = "block"; canvasTotal.style.backgroundColor = "#8888"; chartContainer.append(canvasTotal); const buttonContainer = document.createElement("div"); buttonContainer.style.width = "100%"; buttonContainer.style.display = "flex"; buttonContainer.style.justifyContent = "flex-end"; buttonContainer.style.alignItems = "center"; buttonContainer.style.flexWrap = "wrap"; buttonContainer.style.gap = "1em"; buttonContainer.style.paddingInline = "1em"; buttonContainer.style.boxSizing = "border-box"; statisticsMetaGroup.append(buttonContainer); const chartExists = () => { // @ts-ignore // eslint-disable-next-line const chartStatus = Chart.getChart("tracked-stats-chart"); return chartStatus !== undefined; }; const downloadChart = async () => { if (!chartExists) { drawChart(chartContainer, canvasTotal, "total"); // Sleep to allow chart to be drawn await sleep(500); } const url = canvasTotal.toDataURL("image/png"); var a = document.createElement("a"); a.href = url; a.download = `stats-chart-${formatDate(new Date()).yyyyMMdd}.png`; a.click(); }; // Button to track statistics const logButton = document.createElement("button"); logButton.style.cursor = "pointer"; logButton.innerText = "Track"; logButton.onclick = trackStats; buttonContainer.append(logButton); // Button to draw chart const drawButton = document.createElement("button"); drawButton.style.cursor = "pointer"; drawButton.innerText = "Draw Chart"; drawButton.onclick = () => drawChart(chartContainer, canvasTotal, "total"); buttonContainer.append(drawButton); // Button to download chart const downloadButton = document.createElement("button"); downloadButton.style.cursor = "pointer"; downloadButton.innerText = "Download"; downloadButton.onclick = downloadChart; buttonContainer.append(downloadButton); // Button to clean stats const cleanButton = document.createElement("button"); cleanButton.style.cursor = "pointer"; cleanButton.innerText = "Clean Stats"; cleanButton.onclick = () => cleanStats(CLEAN_DATA); buttonContainer.append(cleanButton); // Button to export stats const exportButton = document.createElement("button"); exportButton.style.cursor = "pointer"; exportButton.innerText = "Export"; exportButton.onclick = exportStats; buttonContainer.append(exportButton); // Actions container (for styling purposes) const actionsContainer = document.createElement("div"); actionsContainer.classList.add("actions"); buttonContainer.append(actionsContainer); // Input to import stats const importInput = document.createElement("input"); importInput.id = "import-stats-input"; importInput.type = "file"; importInput.accept = ".csv"; importInput.multiple = true; importInput.style.height = "0"; importInput.style.width = "0"; importInput.style.overflow = "hidden !important"; importInput.style.opacity = "0"; importInput.style.position = "absolute"; importInput.style.zIndex = "-100"; importInput.onchange = importStats; actionsContainer.append(importInput); // Label to import stats const importLabel = document.createElement("label"); importLabel.htmlFor = "import-stats-input"; importLabel.style.cursor = "pointer"; importLabel.style.margin = "0"; importLabel.innerText = "Import"; importLabel.classList.add("button"); actionsContainer.append(importLabel); // Toggle to hide/show chart const toggleLabel = document.createElement("button"); toggleLabel.style.cursor = "pointer"; toggleLabel.innerText = "Toggle View"; toggleLabel.onclick = () => toggleChartContainer(chartContainer); buttonContainer.append(toggleLabel); // Buttons for each work const workElements = statisticsIndexGroup.querySelectorAll(".index.group>li:not(.group)"); workElements.forEach((item) => { const workName = item.querySelector("dt>a:link").innerHTML; const chartContainer = document.createElement("div"); chartContainer.id = `chart-container:${workName}`; chartContainer.style.marginBlock = "5px"; chartContainer.style.transition = "height 500ms ease-in-out"; chartContainer.style.overflow = "hidden"; chartContainer.style.width = `100%`; chartContainer.style.height = `0px`; item.append(chartContainer); const canvasWork = document.createElement("canvas"); canvasWork.id = `stats-chart:${workName}`; canvasWork.width = item.offsetWidth; canvasWork.height = chartHeight; canvasWork.style.display = "block"; canvasWork.style.backgroundColor = "#8888"; chartContainer.append(canvasWork); const buttonContainer = document.createElement("div"); buttonContainer.style.width = "100%"; buttonContainer.style.display = "flex"; buttonContainer.style.justifyContent = "flex-end"; buttonContainer.style.flexWrap = "wrap"; buttonContainer.style.gap = "1em"; buttonContainer.style.paddingInline = "1em"; buttonContainer.style.boxSizing = "border-box"; const drawButton = document.createElement("button"); drawButton.style.cursor = "pointer"; drawButton.innerText = "Draw"; drawButton.onclick = () => drawChart(chartContainer, canvasWork, "work", workName); buttonContainer.append(drawButton); const toggleLabel = document.createElement("button"); toggleLabel.style.cursor = "pointer"; toggleLabel.innerText = "Toggle"; toggleLabel.onclick = () => toggleChartContainer(chartContainer); buttonContainer.append(toggleLabel); const dt = item.querySelector("dt"); dt.append(buttonContainer); }); } }); observer.observe(document.body, { childList: true, subtree: true }); const drawChart = async (container, canvas, type, workName) => { // If chart is hidden, show it if (container.style.height === "0px") { toggleChartContainer(container, true); await sleep(1000); } let storageStats = getStatsFromLocalStorage().filter((item) => item.type === type); if (workName) { storageStats = storageStats.filter((item) => item.workName === workName); } const { datasets } = statRowToDataSet(storageStats, type); // Destroy existing chart // @ts-ignore // eslint-disable-next-line const chartStatus = Chart.getChart(canvas.id); if (chartStatus !== undefined) { chartStatus.destroy(); } // @ts-ignore // eslint-disable-next-line new Chart(canvas.getContext("2d"), { type: "line", data: { datasets: datasets, }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { type: "time", time: { unit: "day", }, distribution: "linear", grid: { color: getTheme(THEME_MODE).gridLines, }, ticks: { color: getTheme(THEME_MODE).text, }, }, y: { ticks: { precision: 0, color: getTheme(THEME_MODE).text, }, grid: { color: getTheme(THEME_MODE).gridLines, }, }, }, plugins: { customCanvasBackgroundColor: { color: getTheme(THEME_MODE).background, }, tooltip: { callbacks: { title: function (context) { const date = new Date(context[0].parsed.x); return date.toLocaleString("en-GB", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, // Ensure 24-hour clock }); }, }, }, }, }, plugins: [plugin], }); }; const getStatKey = (type, workName, statName, date) => { return `${type};${workName};${statName};${date}`; }; // Element names (arbitrary) and their DOM identifier const elementsTotal = new Map([ [ "user-subscriptions", { selector: "dd.user.subscriptions", color: getTheme(THEME_MODE).userSubscriptions, hidden: true, }, ], [ "kudos", { selector: "dd.kudos", color: getTheme(THEME_MODE).kudos, hidden: true, }, ], [ "comment-threads", { selector: "dd.comment.thread", color: getTheme(THEME_MODE).commentThreads, hidden: true, }, ], [ "bookmarks", { selector: "dd.bookmarks", color: getTheme(THEME_MODE).bookmarks, hidden: true, }, ], [ "subscriptions", { selector: "dd.subscriptions:not(.user)", color: getTheme(THEME_MODE).subscriptions, hidden: true, }, ], [ "word-count", { selector: "dd.words", color: getTheme(THEME_MODE).wordCount, hidden: true, }, ], [ "hits", { selector: "dd.hits", color: getTheme(THEME_MODE).hits, hidden: false, }, ], ]); const elementsWork = new Map([ [ "kudos", { selector: "dd.kudos", color: getTheme(THEME_MODE).kudos, hidden: true, }, ], [ "comments", { selector: "dd.comments", color: getTheme(THEME_MODE).commentThreads, hidden: true, }, ], [ "bookmarks", { selector: "dd.bookmarks", color: getTheme(THEME_MODE).bookmarks, hidden: true, }, ], [ "subscriptions", { selector: "dd.subscriptions", color: getTheme(THEME_MODE).subscriptions, hidden: true, }, ], [ "words", { selector: "span.words", color: getTheme(THEME_MODE).wordCount, hidden: true, }, ], [ "hits", { selector: "dd.hits", color: getTheme(THEME_MODE).hits, hidden: false, }, ], ]); const getTotalStats = () => { const stats = []; elementsTotal.forEach((value, key) => { const valueString = statisticsMetaGroup.querySelector(value.selector).innerHTML; // Regex to remove any non-digit symbols const valueNumber = Number(valueString.replace(/\D/g, "")); stats.push({ type: "total", workName: "", statName: key, date: new Date(), value: valueNumber, }); }); return stats; }; const getWorkStats = () => { const stats = []; const workElements = statisticsIndexGroup.querySelectorAll(".index.group>li:not(.group)"); workElements.forEach((elem) => { const workName = elem.querySelector("dt>a:link").innerHTML; elementsWork.forEach((value, key) => { const valueString = elem.querySelector(value.selector); // Some stats might not exist on a work. Skip them if (valueString) { // Regex to remove any non-digit symbols const valueNumber = Number(valueString.innerHTML.replace(/\D/g, "")); stats.push({ type: "work", workName: workName, statName: key, date: new Date(), value: valueNumber, }); } }); }); return stats; }; const storeInLocalStorage = (stats) => { try { stats.forEach((stat) => { const key = getStatKey(stat.type, stat.workName, stat.statName, formatDate(stat.date).yyyyMMdd_hhmm); localStorage.setItem(key, stat.value.toString()); }); } catch (e) { alert(`Error: Failed to store stats in local storage. ${e}`); } }; const isValidKey = (key) => { return key.startsWith("work") || key.startsWith("total"); }; const getStatsFromLocalStorage = () => { const rows = []; const keys = Object.keys(localStorage); let i = keys.length; while (i--) { if (isValidKey(keys[i])) { const [type, workName, statName, date] = keys[i].split(DELIMITER); rows.push({ type: type, workName: workName, statName: statName, date: new Date(date), value: Number(localStorage.getItem(keys[i])), }); } } return rows.sort((a, b) => a.date.getTime() - b.date.getTime()); }; const kebabToTitleCase = (input) => { return input .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(" "); }; const formatDate = (date) => { // Helper to pad single digits with leading zero const pad = (num) => num.toString().padStart(2, "0"); const year = date.getFullYear(); const month = pad(date.getMonth() + 1); // Months are 0-indexed const day = pad(date.getDate()); const hours = pad(date.getHours()); const minutes = pad(date.getMinutes()); // --- ISO Week Calculation --- const getISOWeek = (d) => { const target = new Date(d); target.setHours(0, 0, 0, 0); // Thursday in current week decides the year. target.setDate(target.getDate() + 3 - ((target.getDay() + 6) % 7)); const weekYear = target.getFullYear(); // January 4 is always in week 1. const firstThursday = new Date(weekYear, 0, 4); firstThursday.setDate(firstThursday.getDate() + 3 - ((firstThursday.getDay() + 6) % 7)); const week = 1 + Math.round(((target.getTime() - firstThursday.getTime()) / 86400000 - 3) / 7); return { week, weekYear }; }; const { week, weekYear } = getISOWeek(date); return { yyyyMMdd_hhmm: `${year}-${month}-${day} ${hours}:${minutes}`, yyyyMMdd_hh: `${year}-${month}-${day} ${hours}`, yyyyMMdd: `${year}-${month}-${day}`, yyyyMM: `${year}-${month}`, yyyy_ww: `${weekYear}-W${pad(week)}`, }; }; const statRowToDataSet = (stats, type) => { const dataset = new Map(); let keys = []; switch (type) { case "total": keys = Array.from(elementsTotal.keys()); break; case "work": keys = Array.from(elementsWork.keys()); break; } // Find each stat type keys.forEach((key) => { const list = []; // Get the stats for the stat type stats.forEach((stat) => { if (stat.statName === key) { list.push([stat.date, stat.value]); } }); // Convert keys from kebab-case to Title Case dataset.set(kebabToTitleCase(key), list); }); return { datasets: Array.from(dataset).map((data, index) => { const values = data[1].map((entry) => { return { x: formatDate(entry[0]).yyyyMMdd_hhmm, y: entry[1], }; }); let backgroundColor = ""; let hidden = false; switch (type) { case "total": backgroundColor = elementsTotal.get(keys[index]).color; hidden = elementsTotal.get(keys[index]).hidden; break; case "work": backgroundColor = elementsWork.get(keys[index]).color; hidden = elementsWork.get(keys[index]).hidden; break; } return { label: data[0], data: values, borderWidth: 2, // Set border color same as background except with less opacity borderColor: `${backgroundColor}88`, backgroundColor: backgroundColor, hidden: hidden, }; }), }; }; const cleanStats = (mode) => { const keys = Object.keys(localStorage).filter((key) => isValidKey(key)); const dataPoints = new Map(keys.map((key) => { const [type, workName, statName, date] = key.split(DELIMITER); return [ key, { type, workName, statName, date: new Date(date), }, ]; })); const toKeep = new Map(); const toDelete = new Set(); const findAndSortData = (value, key, shortDate) => { const shortKey = getStatKey(value.type, value.workName, value.statName, shortDate); const storedVal = toKeep.get(shortKey); // If not stored, store it if (!storedVal) { toKeep.set(shortKey, { fullDate: value.date, key, }); } else if (value.date > storedVal.fullDate) { // If current date is later than stored date // Move stored item to delete toDelete.add(storedVal.key); // Set new stored item toKeep.set(shortKey, { fullDate: value.date, key, }); } else if (value.date < storedVal.fullDate) { // If current date is before stored date // Set it to be deleted toDelete.add(key); } }; if (mode === "hour") { dataPoints.forEach((value, key) => { findAndSortData(value, key, formatDate(value.date).yyyyMMdd_hh); }); } else if (mode === "day") { dataPoints.forEach((value, key) => { findAndSortData(value, key, formatDate(value.date).yyyyMMdd); }); } else if (mode === "week") { dataPoints.forEach((value, key) => { findAndSortData(value, key, formatDate(value.date).yyyy_ww); }); } else if (mode === "month") { dataPoints.forEach((value, key) => { findAndSortData(value, key, formatDate(value.date).yyyyMM); }); } toDelete.forEach((item) => { localStorage.removeItem(item); }); drawChart(chartContainer, canvasTotal, "total"); }; const validateStatRow = (row, index) => { if (row.type !== "total" && row.type !== "work") { throw new Error(`Type "${row.type}" for row ${index} not recognized.`); } if (row.type !== "work" && row.workName !== "") { throw new Error(`Work name "${row.workName}" was found for row ${index}, but the type is not "work"`); } else if (row.type === "work" && row.workName === "") { throw new Error(`Type of row ${index} is "work", but work name is empty.`); } if (!Array.from(elementsTotal.keys()).includes(row.statName) && !Array.from(elementsWork.keys()).includes(row.statName)) { throw new Error(`Stat name "${row.statName}" for row ${index} not recognized.`); } if (!new Date(row.date).getDate()) { throw new Error(`Date "${row.date}" for row ${index} is invalid.`); } if (!row.value || Number(row.value) < 0) { throw new Error(`Value "${row.value}" for row ${index} is invalid.`); } return true; }; 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(); }, }; // Style buttons to match skin const sheets = document.styleSheets; for (const sheet of sheets) { try { const rules = sheet.cssRules; for (const rule of rules) { if (rule instanceof CSSStyleRule && rule.selectorText.includes("button:focus")) { const newSelector = `${rule.selectorText}, #import-stats-input:focus+label`; sheet.deleteRule([...rules].indexOf(rule)); sheet.insertRule(`${newSelector} { ${rule.style.cssText} }`, rules.length); break; } } for (const rule of rules) { if (rule instanceof CSSStyleRule && rule.selectorText.includes(".actions label:hover")) { const newSelector = `${rule.selectorText}, button:hover`; sheet.deleteRule([...rules].indexOf(rule)); sheet.insertRule(`${newSelector} { ${rule.style.cssText} }`, rules.length); break; } } } catch (e) { // } } })();