Track statistics on the stats page. Stores the stats history in the device's local storage. (Do not rely on this tool to keep the data safe. Create backups by exporting regularly.)
当前为
// ==UserScript==
// @name Statistics tracker
// @description Track statistics on the stats page. Stores the stats history in the device's local storage. (Do not rely on this tool to keep the data safe. Create backups by exporting regularly.)
// @author Ifky_
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @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
// @license GPL-3.0-only
// @grant none
// ==/UserScript==
(function () {
"use strict";
/** ==================================== **/
/* 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: "day" / "hour" / "month"
const CLEAN_DATA = "hour";
// 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);
// Append scripts for chart
const chartScript = document.createElement("script");
chartScript.src = "https://cdn.jsdelivr.net/npm/chart.js";
statChart.appendChild(chartScript);
chartScript.onload = () => {
const chartDateScript = document.createElement("script");
chartDateScript.src =
"https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns";
statChart.appendChild(chartDateScript);
chartDateScript.onload = () => {
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";
statisticsMetaGroup.append(buttonContainer);
const chartExists = () => {
// 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
// eslint-disable-next-line
const chartStatus = Chart.getChart(canvas.id);
if (chartStatus !== undefined) {
chartStatus.destroy();
}
// 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) => {
stats.forEach((stat, index) => {
const key = getStatKey(stat.type, stat.workName, stat.statName, formatDate(stat.date).yyyyMMdd_hhmm);
localStorage.setItem(key, stat.value.toString());
});
};
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());
return {
yyyyMMdd_hhmm: `${year}-${month}-${day} ${hours}:${minutes}`,
yyyyMMdd_hh: `${year}-${month}-${day} ${hours}`,
yyyyMMdd: `${year}-${month}-${day}`,
yyyyMM: `${year}-${month}`,
};
};
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 === "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) {
//
}
}
})();