AO3: Stat Graphs+

New and improved statistics page for AO3

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        AO3: Stat Graphs+
// @version     1.0.1
// @description New and improved statistics page for AO3
// @author      sharkcat
// @namespace   https://github.com/sharkcatshark/Userscripts
// @match       https://archiveofourown.org/users/*/stats*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=archiveofourown.org
// @require     https://code.jquery.com/jquery-3.7.0.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// @license     GNU GPLv3
// ==/UserScript==

// -- START Settings -- //

const maxFicsPerGraph = 10; // how many fics per graph you want to be displayed

const coloursArray = ["#f77078","#eda278","#faff66","#92d86e","#6e92d8","#9679e1","#dd75a6","#363636"]; // colours for graphs // doesnt affect categories, ratings, warnings pie charts
const bordersArray = ["#ff696f","#ffa068","#f7fe68","#88f354","#5587f2","#9063f8","#f75ea1","#4b2827"];

const maxTitleCharacters = 15; // cuts off titles if they get too long for labels (includes spaces)

// Metric Graph Only
const displayZeroMetrics = false; // show a fic in the graph if the value is 0

// these two set the default display for the metric graph on page load
const defaultMetric = "kudos";  // can be 'hits', 'kudos', 'bookmarks', 'comments', 'subs', 'words'
const defaultOrder = "Dsc"; // "Asc" for ascending

// Overview Graph Only
const removeHitsFromOverview = true; // hits can overwhelm the graph and make the other metrics hard to see but you can turn them back on here

// -- END Settings -- //

Chart.register(ChartDataLabels);

class Fic {
    constructor(title, fandoms, words, hits, kudos, comments, subs, bookmarks) {
        this.title = title;
        this.fandom = fandoms;
        this.words = words;
        this.hits = hits;
        this.kudos = kudos;
        this.comments = comments;
        this.subs = subs;
        this.bookmarks = bookmarks;
    }
}

// redirect to flat view
const username = window.location.href.split("/")[4];
if (!window.location.href.endsWith("/stats?flat_view=true")) {
    window.location = "https://archiveofourown.org/users/" + username + "/stats?flat_view=true";
}

// adding in divs for graph placement
$('<div id="charts"></div>').insertAfter('#main > ul.navigation.actions');
$('#charts').append('<div id="mainSection">');
$('#mainSection').append('<div id="summary"></div><div id="metric"></div><div id="overall"></div>');
$('#metric').append('<div id="toggleButtonsDiv"></div><div id="metricGraph" class="graph"></div>');
$("#toggleButtonsDiv").append('<ul id="toggleButtons"><li><button id="hitsToggle" class="metricButton">Hits</button></li><li><button id="kudosToggle" class="metricButton">Kudos</button></li><li><button id="bookmarksToggle" class="metricButton">Bookmarks</button></li><li><button id="commentsToggle" class="metricButton">Comments</button></li><li><button id="subsToggle" class="metricButton">Subs</button></li><li><button id="wordsToggle" class="metricButton">Words</button></li></ul>');
$("#overall").append('<canvas id="overviewGraph"></canvas>');
$('#charts').append('<div id="sidePie"></div>');
$('#sidePie').append('<div id="fandom" class="pie"><canvas id="fandomGraph"></canvas></div>');
$("#sidePie").append('<div id="category" class="pie"><canvas id="categoryGraph"></canvas></div>');
$("#sidePie").append('<div id="rating" class="pie"><canvas id="ratingGraph"></canvas></div>');
$("#sidePie").append('<div id="warning" class="pie"><canvas id="warningGraph"></canvas></div>');

// getting main data
var ficList = [];

const fics = document.querySelectorAll(".statistics.index.group > li > ul > li");
for (let i = 0; i < fics.length; i++) {
    let title = document.querySelectorAll(".index.group a")[i].innerHTML;
    let fandoms = document.querySelectorAll(".index.group span.fandom")[i].innerHTML.slice(1, -1).split(", ");
    let words = Number(document.querySelectorAll("span.words")[i].innerHTML.replace(/\(|,|\swords?\)/g, "")); // why is this Number not parseInt
    let hits = parseInt(document.querySelectorAll(".index.group dd.hits")[i].innerHTML.replace(",", ""));
    let kudos = parseInt(document.querySelectorAll(".index.group dd.kudos")[i].innerHTML.replace(",", ""));
    let comments = parseInt(document.querySelectorAll(".index.group dd.comments")[i].innerHTML.replace(",", ""));
    try {
        var subs = parseInt(document.querySelectorAll(".index.group dd.subscriptions")[i].innerHTML.replace(",", ""));
    }
    catch (error) {
        var subs = 0;
    }
    try {
        var bookmarks = parseInt(document.querySelectorAll(".index.group dd.bookmarks")[i].innerHTML.replace(",", ""));
    }
    catch (error) {
        var bookmarks = 0;
    }
    var newFic = new Fic(title, fandoms, words, hits, kudos, comments, subs, bookmarks); // create instance of class
    ficList.push(newFic); // add instance to ficList array
}

// get list of fic urls to get data from (reading from other pages)
const ficURLS = document.querySelectorAll('ul.statistics > li.fandom > ul > li a');
const urls = [];

for (let i = 0; i < ficURLS.length; i++) {
    let url = ficURLS[i].href;
    urls.push(url);
}

// organising fandom data
var fandomOutput = [];

for (let i = 0; i < ficList.length; i++) {
    for (let j = 0; j < ficList[i].fandom.length; j++) {
        let foundIndex = fandomOutput.map(a => a.fandom).indexOf(ficList[i].fandom[j]);
        if (foundIndex > -1) {
            fandomOutput[foundIndex].count++;
        }
        else {
            var newFandom = {fandom: ficList[i].fandom[j], count: 1};
            fandomOutput.push(newFandom);
        }
    }
}

// sort big to small
fandomOutput.sort(function(a, b) {
    return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
});

// make summary box
var totalWords = 0;
var totalHits = 0;
var totalKudos = 0;
var totalComments = 0;
var totalSubs = 0;
var totalBookmarks = 0;
var userSubs = $("dd.user.subscriptions").html(); // this is the only one you cant calculate

// calculating values as opposed to reading off chart because reasons
for (let i = 0; i < ficList.length; i++) {
    totalWords += ficList[i].words;
    totalHits += ficList[i].hits;
    totalKudos += ficList[i].kudos;
    totalComments += ficList[i].comments;
    totalSubs += ficList[i].subs;
    totalBookmarks += ficList[i].bookmarks;
}

const averageWords = Math.round(totalWords / 3);
const hkRatio = Math.round(totalHits / totalKudos);

// removing old content
$("#stat_chart").remove(); // default graph
$("ul.view.actions").remove(); // view switch options
$("ol.year.actions").remove(); // year nav
$("h3.heading").remove(); // totals heading
$("dl.statistics.meta.group").unwrap(); // wrapper parent for general cleanup
$("dl.statistics.meta.group").remove(); // total summary box
$("div.actions.module").remove(); // default sort by direction

// placing summary box
const summaryCode = "<dl><p>Totals:</p><dt>Words</dt><dd>" + totalWords.toLocaleString("en") + "</dd><dt>Hits</dt><dd>" + totalHits.toLocaleString("en") + "</dd><dt>Kudos</dt><dd>" + totalKudos.toLocaleString("en") + "</dd><dt>Bookmarks</dt><dd>" + totalBookmarks.toLocaleString("en") + "</dd><dt>Comment Threads</dt><dd>" + totalComments.toLocaleString("en") + "</dd><dt>User Subs</dt><dd>" + userSubs.toLocaleString("en") + "</dd><dt>Fic Subs</dt><dd>" + totalSubs.toLocaleString("en") + "</dd><dt>Fandoms Written For</dt><dd>" + fandomOutput.length.toLocaleString("en") + "</dd><p>Averages:</p><dt>Word Count</dt><dd>" + averageWords.toLocaleString("en") + "</dd><dt>Hits/Kudos Ratio</dt><dd>" + hkRatio + ":1</dd></dl>";
$("#summary").append(summaryCode);

// graphs
const overviewData = [
    {
      label: "Hits",
      backgroundColor: coloursArray[0],
      borderColor: bordersArray[0],
      borderWidth: 2,
      data: ficList.map(a => a.hits)
    },
    {
      label: "Kudos",
      backgroundColor: coloursArray[1],
      borderColor: bordersArray[1],
      borderWidth: 2,
      data: ficList.map(a => a.kudos)
    },
    {
      label: "Bookmarks",
      backgroundColor: coloursArray[2],
      borderColor: bordersArray[2],
      borderWidth: 2,
      data: ficList.map(a => a.bookmarks)
    },
    {
      label: "Comments",
      backgroundColor: coloursArray[3],
      borderColor: bordersArray[3],
      borderWidth: 2,
      data: ficList.map(a => a.comments)
    },
    {
      label: "Subscriptions",
      backgroundColor: coloursArray[4],
      borderColor: bordersArray[4],
      borderWidth: 2,
      data: ficList.map(a => a.subs)
    }
];

if (removeHitsFromOverview){
    overviewData.splice(0, 1);
}

ficList.splice(maxFicsPerGraph);
displayGraph("overviewGraph", "bar", ficList.map(a => shortenTitle(a.title)), overviewData, "Stats Overview", true); // overview
generateGraph(defaultMetric, defaultOrder); // metric
makePieChart("fandomGraph", fandomOutput.map(a => a.fandom), coloursArray, fandomOutput.map(a => a.count), "Fics Per Fandom"); // pie chart

async function fetchData(url) {
    const response = await fetch(url);
    const html = await response.text();

    const domParser = new DOMParser();
    const page = domParser.parseFromString(html, "text/html");

    // get elements from fic page
    const rating = page.querySelector("dd.rating > ul.commas > li").innerText;
    const warningArray = Array.from(page.querySelectorAll("dd.warning.tags ul.commas > li"));
    let categoryArray = [];
    try {
        categoryArray = Array.from(page.querySelectorAll("dd.category.tags ul.commas > li"));
    }
    catch {
        categoryArray = ["No Category Given"];
    }
    const warnings = [];
    const category = [];
    for (let i = 0; i < warningArray.length; i++) {
        warnings.push(warningArray[i].innerText);
    }

    for (let i = 0; i < categoryArray.length; i++) {
        category.push(categoryArray[i].innerText);
    }
    const chapters = page.querySelector("dd.chapters").innerText;

    return { rating, warnings, category, chapters };
}

// bonus pie charts and stats
async function getDataFromMultipleURLs(urls) {
  const ficList = [];

  for (let i = 0; i < urls.length; i++) {
    const fic = await fetchData(urls[i], i);
    ficList.push(fic);
  }
  return ficList;
}

getDataFromMultipleURLs(urls).then(ficList => {
    var ratingOutput = [];
    for (let i = 0; i < ficList.length; i++) { // for each fic in ficlist
        let foundIndex = ratingOutput.map(a => a.rating).indexOf(ficList[i].rating);
        if (foundIndex > -1) {
            ratingOutput[foundIndex].count++;
        }
        else {
            var newRating = {rating: ficList[i].rating, count: 1};
            ratingOutput.push(newRating);
        }
    }

    ratingOutput.sort(function(a, b) {
        return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
    });

    var ratingColourArray = getPieMetricColour(ratingOutput);

    makePieChart("ratingGraph", ratingOutput.map(a => a.rating), ratingColourArray, ratingOutput.map(a => a.count), "Ratings");

    var warningOutput = [];
    for (let i = 0; i < ficList.length; i++) { // for each fic in ficlist
        for (let j = 0; j < ficList[i].warnings.length; j++) { // for each warning in array
            let foundIndex = warningOutput.map(a => a.warning).indexOf(ficList[i].warnings[j]); // does the output have warning already?
            if (foundIndex > -1) { // if yes (found will give an index of where it is so above -1 means it exists)
                warningOutput[foundIndex].count++;
            }
            else {
                var newWarning = {warning: ficList[i].warnings[j], count: 1};
                warningOutput.push(newWarning);
            }
        }
    }

    warningOutput.sort(function(a, b) {
        return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
    });

    var warningColourArray = getPieMetricColour(warningOutput);
    makePieChart("warningGraph", warningOutput.map(a => a.warning), warningColourArray, warningOutput.map(a => a.count), "Warnings");

    var categoryOutput = [];
    for (let i = 0; i < ficList.length; i++) {
        for (let j = 0; j < ficList[i].category.length; j++) {
            let foundIndex = categoryOutput.map(a => a.category).indexOf(ficList[i].category[j]);
            if (foundIndex > -1) {
                categoryOutput[foundIndex].count++;
            }
            else {
                var newCategory = {category: ficList[i].category[j], count: 1};
                categoryOutput.push(newCategory);
            }
        }
    }

    // sort big to small
    categoryOutput.sort(function(a, b) {
        return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
    });

    var completeFics = 0;
    var incompleteFics = 0;
    var chapterCount = 0;

    for (let i = 0; i < ficList.length; i++) {
        if (ficList[i].chapters.match(/\S+(?=\/)/g)[0] == ficList[i].chapters.match(/(?<=\/)\S+/g)[0]) {
            completeFics++;
        }
        else {
            incompleteFics++;
        }

        chapterCount += parseInt(ficList[i].chapters); // will parse as a number until it gets to the / (ie only gets '45' from '45/56')
    }

    var categoryColourArray = getPieMetricColour(categoryOutput);
    makePieChart("categoryGraph", categoryOutput.map(a => a.category), categoryColourArray, categoryOutput.map(a => a.count), "Categories");

    var completeRate = (completeFics/(completeFics+incompleteFics)) * 100;
    var avgChapterCount = chapterCount / ficList.length;
    const summaryCode = "<dt>Fic Completion Rate</dt><dd>" + completeRate + "%</dd><dt>Chapter Count</dt><dd>" + avgChapterCount + "</dd>";
    $("#summary dl").append(summaryCode);
});

// event listeners for buttons
const hitsBtn = document.getElementById("hitsToggle");
hitsBtn.addEventListener("click", function() {
    removeAndFlipGraph("hits");
});

const kudosBtn = document.getElementById("kudosToggle");
kudosBtn.addEventListener("click", function() {
    removeAndFlipGraph("kudos");
});

const subsBtn = document.getElementById("subsToggle");
subsBtn.addEventListener("click", function() {
    removeAndFlipGraph("subs");
});

const wordsBtn = document.getElementById("wordsToggle");
wordsBtn.addEventListener("click", function() {
    removeAndFlipGraph("words");
});

const commentsBtn = document.getElementById("commentsToggle");
commentsBtn.addEventListener("click", function() {
    removeAndFlipGraph("comments");
});

const bookmarksBtn = document.getElementById("bookmarksToggle");
bookmarksBtn.addEventListener("click", function() {
    removeAndFlipGraph("bookmarks");
});

// functions
function makePieChart(elementID, pieLabels, bgColours, pieData, pieTitle) {
    new Chart(document.getElementById(elementID), {
        type: "pie",
        data: {
            labels: pieLabels,
            datasets: [{
                backgroundColor: bgColours,
                borderColor: bgColours,
                data: pieData
            }]
        },
        options: {
            plugins: {
                title: {
                    display: true,
                    text: pieTitle
                },
                datalabels: {
                    color: "#fff",
                    textStrokeColor: '#000',
                    textStrokeWidth: 4,
                    font: {
                        size: 12,
                        weight: "normal"
                    },
                    anchor: "center"
                },
                legend: {
                    display: false
                }
            }
        }
    });
}

function displayGraph(elementID, type, xLabels, dataset, graphTitle, legendDisplayTF) {
    new Chart(document.getElementById(elementID), {
        type: type,
        data: {
            labels: xLabels,
            datasets: dataset
        },
        options: {
            plugins: {
                title: {
                    display: true,
                    text: graphTitle
                },

                legend: {
                    display: legendDisplayTF,
                    position: "top"
                },
                datalabels: {
                    color: "#fff",
                    textStrokeColor: '#000',
                    textStrokeWidth: 4,
                    font: {
                        size: 12,
                        weight: "normal"
                    },
                    anchor: "end",
                    align: "end",
                    offset: 1
                }
            }
        }
    });
}

function generateGraph(metric, order) {
    // clear for next metric
    const titleArray = [];
    const valueArray = [];
    var sortedArray = [];

    var metricColour = getMetricColour(metric);
    var metricBorder = getBorderColour(metric);

    if (order == "Asc") {
        sortedArray = ficList.toSorted((a, b) => parseFloat(a[metric]) - parseFloat(b[metric]));
    }
    else { // "dsc"
        sortedArray = ficList.toSorted((a, b) => parseFloat(b[metric]) - parseFloat(a[metric]));
    }

    if (displayZeroMetrics) { // display fics with 0 values
        for (let i = 0; i < maxFicsPerGraph; i++) {
            if (sortedArray[i] != null) {
                titleArray.push(shortenTitle(sortedArray[i].title));
                valueArray.push(sortedArray[i][metric]);
            }
            else {
                break;
            }
        }
    }
    else {
        for (let i = 0; i < maxFicsPerGraph; i++) {
            if (sortedArray[i] != null && sortedArray[i][metric] != 0) {
                titleArray.push(shortenTitle(sortedArray[i].title));
                valueArray.push(sortedArray[i][metric]);
            }
            else if (sortedArray[i] == null) {
                break;
            }
        }
    }

    const graphID = metric + order; // hitsDsc
    const insertCode = '<canvas id="' + graphID + '"></canvas>'; // hitsDsc
    $("#metricGraph").append(insertCode); // CHANGED FROM #metric to #metricGraph

    var data = [{
        label: metric,
        backgroundColor: metricColour,
        borderColor: metricBorder,
        borderWidth: 2,
        data: valueArray
    }];
    displayGraph(graphID, "bar", titleArray, data, metric, false);
}


function getMetricColour(metric) {
    switch(metric) {
        case "hits": return coloursArray[0];
        case "kudos": return coloursArray[1];
        case "bookmarks": return coloursArray[2];
        case "comments": return coloursArray[3];
        case "subs": return coloursArray[4];
        case "words": return coloursArray[5];
    }
}

function getBorderColour(metric) {
    switch(metric) {
        case "hits": return bordersArray[0];
        case "kudos": return bordersArray[1];
        case "bookmarks": return bordersArray[2];
        case "comments": return bordersArray[3];
        case "subs": return bordersArray[4];
        case "words": return bordersArray[5];
    }
}

function shortenTitle(title) {
    if (title.length > maxTitleCharacters) {
        return title.slice(0, maxTitleCharacters) + "...";
    }
    return title;
}

// gotta be a way for this to be shorter
function getPieMetricColour(array) {
    var pieArrayMetric = [];

    // get key
    const obj = array[0];
    const entries = Object.entries(obj);
    const [firstKey, firstValue] = entries[0];

    let red = "#f77078";
    let orange = "#eda278";
    let yellow = "#faff66";
    let green = "#92d86e";
    let blue = "#6e92d8";
    let purple = "#9679e1";
    let pink = "#dd75a6";
    let black = "#363636";
    let white = "#dedede";

    if (firstKey == "rating") {
        for (let i = 0; i < array.length; i++) {
            switch(array[i].rating) { // array[i] refers to the object // get first object in array and look at first value
                case "General Audiences": pieArrayMetric.push(green); break;
                case "Teen And Up Audiences": pieArrayMetric.push(yellow); break;
                case "Mature": pieArrayMetric.push(orange);break;
                case "Explicit": pieArrayMetric.push(red);break;
                case "Not Rated": pieArrayMetric.push(white); break;
            }
        }
    }
    else if (firstKey == "category") {
        for (let i = 0; i < array.length; i++) {
            switch(array[i].category) {
                case "M/M": pieArrayMetric.push(blue); break;
                case "F/M": pieArrayMetric.push(purple); break;
                case "F/F": pieArrayMetric.push(pink); break;
                case "Multi": pieArrayMetric.push(yellow); break;
                case "Other": pieArrayMetric.push(black); break;
                case "Gen": pieArrayMetric.push(green); break;
                case "No Category Given": pieArrayMetric.push(white); break; // might break, might not, who knows
            }
        }
    }
    else if (firstKey == "warning") {
        for (let i = 0; i < array.length; i++) {
            switch(array[i].warning) {
                case "No Archive Warnings Apply": pieArrayMetric.push(green); break;
                case "Graphic Depictions Of Violence": pieArrayMetric.push(red); break;
                case "Creator Chose Not To Use Archive Warnings": pieArrayMetric.push(orange); break;
                case "Rape/Non-Con": pieArrayMetric.push(purple); break;
                case "Underage": pieArrayMetric.push(blue); break;
                case "Major Character Death": pieArrayMetric.push(black); break;
            }
        }
    }
    else {
        console.log("invalid key idk how you got here man");
    }

    return pieArrayMetric;
}

function removeAndFlipGraph(newMetric) {
    var graph = document.querySelector("#metricGraph canvas");
    var includesMetric = graph.id.includes(newMetric);
    var includesAsc = graph.id.includes("Asc");

    if ((includesMetric && includesAsc) || (!includesMetric && !includesAsc)) {
        graph.remove();
        generateGraph(newMetric, "Dsc");
    }
    else if ((includesMetric && !includesAsc) || !includesMetric && includesAsc) {
        graph.remove();
        generateGraph(newMetric, "Asc");
    }
}

const styles =
`#summary {
    border: 3px solid;
    margin-bottom: 1em;
    margin-right: 1em;
    display: inline-block;
}

#summary dl {
    display: grid;
    padding: 0.5em;
    padding-right: 2em;
}

#summary p, #summary dd {
    margin: 0;
}

#summary dd {
    grid-column-start: 2;
    margin-left: 2em;
}

#summary dt {
    grid-column-start: 1;
}

#summary dl p {
    text-decoration-line: underline;
}

#toggleButtons li {
    padding: 0px 5px;
    display: inline;
}

#metric {
    padding: 0px 5px;
    display: inline-block;
}

#metricGraph {
  max-width: 35em;
}

#charts {
    margin-top: 1.5em;
}

#mainSection, #sidePie {
    float: left;
}

#sidePie {
    display: inline-block;
    max-width: 12em;
}

#overall {
    max-width: 60em;
}

@media screen and (min-width: 670px) and (max-width: 1290px) {
    #mainSection {
        float: none;
    }
    #sidePie {
        display: inline;
        float: none;
        max-width: none;
    }
    .pie {
        float: left;
        width: 12em;
    }
}

@media screen and (max-width: 670px) {
    #mainSection {
        float: none;
    }
    #sidePie {
        display: inline-block;
        float: none;
        max-width: none;
    }
    .pie {
        float: left;
        width: 10em;
    }
}`;

const styleSheet = document.createElement("style");
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);