MAL tierlist

make tierlist on seasonal anime page

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         MAL tierlist
// @namespace    pepe
// @version      2025-09-15
// @description  make tierlist on seasonal anime page
// @author       pepe
// @match        https://myanimelist.net/anime/season*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=myanimelist.net
// @require      https://code.jquery.com/ui/1.13.2/jquery-ui.js
// @license      MIT
// @grant        none
// ==/UserScript==

/* global $ */
/* global html2canvas */

(function() {

// for image downloading but doesnt work yet, so its not included
// @require https://html2canvas.hertzen.com/dist/html2canvas.js

  // fix padding in header to fit tierlist in older seasons
  if(document.querySelectorAll('.navtab')[0].textContent == 'Current Season'){
      document.head.innerHTML += `<style>
      .dark-mode .navi-seasonal .horiznav_nav ul li a {
         padding: 5px 8px;
      }
      </style>`
  }

    create_tierlist_button:{

        let tierlist_button = document.createElement(`li`)
        tierlist_button.classList.add('btn-type')
        tierlist_button.innerHTML = `<a id="createtier" href="#" class="navtab ">Tierlist</a>`
        tierlist_button.onclick = function(event){
            event.preventDefault(); // disable default link click, interferes with scroll
            RenderTierlist();
        }
        let seasonal_header = document.querySelector('ul.btn-seasonal')
        seasonal_header.append(tierlist_button);

    }


  let tierlist_html = `
    <div id="tierlist">

  <style>
  :root {
    --image-width: 80px;
    --image-height: 113px;
  }
  ul.droptrue { list-style-type: none; margin: 0px; float: left; margin-right: 0px; padding: 0px; min-width: 100%; min-height: 80px;}
  ul.droptrue li { float: left; margin: 2px; font-size: 16px; height: var(--image-height); overflow: hidden;}
  .grid-container {
    margin-top: 2px;
    display: none;
    grid-template-columns: 1fr 11fr;
    grid-gap: 0px;
  }
  .grid-container1 {
    grid-template-columns: 1fr 4fr 3fr 2fr 2fr;
  }
  .grid-container2 {
    grid-template-columns: 1fr 11fr;
  }
.textarea {
  display: flex; align-items: center; text-align: center; height: var(--image-height); width: auto; padding: 0px!important; border: 0px!important; justify-content: center;
}
ul {display: block;
    list-style-type: disc;
    margin-block-start: 0;
    margin-block-end: 0;
    margin-inline-start: 0px;
    margin-inline-end: 0px;
    padding-inline-start: 0;
    margin: 2px;
    padding: 0;
}
.dropfalse div { height: 100%; font-size:14px!important;width:90px;}

#tier_s div, #header_s div { background-color: #2e51a2;}
#tier1 div, #header_1 div { background-color: green;}
#tier2 div, #header_2 div { background-color: oklch(0.70 0.18 126.62 / 1);}
#tier3 div, #header_3 div { background-color: oklch(0.75 0.2 70.66 / 1);}
#tier4 div { background-color: orangered;}
#tier5 div { background-color: darkred;}
  </style>

<div style="position: absolute; right: 0; padding-top: 0px; display:grid;">

<details style="width:80px; ">
  <summary style="cursor: pointer;"><a>FILE</a></summary>
  <div style="">
    <a id="deletetierlist" href="#" style="color: orange;" >DELETE</a>
    <a id="import" href="#">IMPORT</a>
    <a id="downloadLink" href="#" download="mal_tierlist_backup.json">EXPORT</a>
  </div>
</details>

<a href="#"
    onClick="
      event.preventDefault();
      document.querySelector('#contentWrapper').style.display = 'block';
      document.querySelectorAll('.grid-container')[0].style.display = 'none';
      document.querySelectorAll('.grid-container')[1].style.display = 'none';
      document.documentElement.scrollTop = localStorage.getItem('scrollPosition');
    "
>CLOSE</a>

</div>

${generateTiers()}
<!--
<ul id="tier_s" class="dropfalse">
 <div class="textarea" contenteditable="true">S</div>
</ul>
<ul id="s" class="droptrue"></ul>

<ul id="tier1" class="dropfalse">
 <div class="textarea" contenteditable="true">A</div>
</ul>
<ul id="sortable1" class="droptrue"></ul>

<ul id="tier2" class="dropfalse">
  <div class="textarea" contenteditable="true">B</div>
</ul>
<ul id="sortable2" class="droptrue"></ul>

<ul id="tier3" class="dropfalse">
  <div class="textarea" contenteditable="true">C</div>
</ul>
<ul id="sortable3" class="droptrue"></ul>

<ul id="tier4" class="dropfalse">
  <div class="textarea" contenteditable="true">D</div>
</ul>
<ul id="sortable4" class="droptrue"></ul>

<ul id="tier5" class="dropfalse">
  <div class="textarea" contenteditable="true">E</div>
</ul>
<ul id="sortable5" class="droptrue"></ul>
-->


<!-- this grid not in image -->
<div class="grid-container grid-container2">

<ul id="tier_last" class="dropfalse">
  <div class="textarea" contenteditable="true">F</div>
</ul>
<ul id="last" class="droptrue"></ul>

</div>

</div>
`
function generateTiers(){
    let tiers = 'S,A,B,C,D,E'

    let html = tiers.split(/[, ]/).map((tier, i) =>
`<ul id="tier${i==0?'_s':i}" class="dropfalse">
     <div class="textarea" contenteditable="true">${tier}</div>
</ul>
<ul id="${i==0?'s':'sortable'+i}" class="droptrue"></ul>`)

    return `<div class="grid-container">`+html.join('\n')+`</div>`
}
function generateGridTiers(){
    let tiers = 'S,A,B,C,D,E'

    let html = `<div class="grid-container grid-container1"><ul id="tier_header" class="dropfalse" style="height: var(--image-height)">
</ul>
<ul id="header_s" class="dropfalse"><div class="textarea" contenteditable="true">Carry</div></ul>
<ul id="header_1" class="dropfalse"><div class="textarea" contenteditable="true">Tactical</div></ul>
<ul id="header_2" class="dropfalse"><div class="textarea" contenteditable="true">Dark Horse</div></ul>
<ul id="header_3" class="dropfalse"><div class="textarea" contenteditable="true">Misc</div></ul>`

   html += tiers.split(/[, ]/).map((tier, i) =>
`<ul id="tier${i==0?'_s':i}" class="dropfalse">
     <div class="textarea" contenteditable="true">${tier}</div>
</ul>
${createColumns(tier)}`).join('\n')

    return html+`</div>`
}
    function createColumns(tier){
        return Array.from(Array(4)).map((v,i) => `<ul id="${tier==0?'s'+i:`sortable${i}${tier}`}" class="droptrue"></ul>`).join('\n')
    }
   
    function RenderTierlist(){
        localStorage.setItem('scrollPosition', document.documentElement.scrollTop)

        // prevent duplicate from being rendered, insert html
        try{document.getElementById('tierlist').remove();}catch(e){}
        document.querySelector('#contentWrapper').insertAdjacentHTML("afterend", tierlist_html);

        // hide seasonal, show tier
        document.querySelector('#contentWrapper').style.display = 'none';
        document.querySelectorAll('.grid-container')[0].style.display = 'grid';
        document.querySelectorAll('.grid-container')[1].style.display = 'grid';

        // jqueryui sortable
        $( "ul.droptrue" ).sortable({
            connectWith: "ul.droptrue"
        });

        // loop seasonal images and put them to tierlist
        let new_shows = document.querySelectorAll('.js-seasonal-anime-list-key-1 .js-seasonal-anime img, .js-seasonal-anime-list-key-5 .js-seasonal-anime img')
        let season = document.querySelector("#content .season_nav .on").textContent
        let tierlist = JSON.parse(localStorage.getItem(season));
        let tiersOrdered = Array.from({length: tierlist?.tiers?.length}, i => []);

        // create ordered list
        for(let anime of new_shows){
            let id = anime.parentElement.href.match(/(?<=anime\/)\d+/)[0]
            let members = anime.parentElement.parentElement.parentElement.querySelector('.member').textContent
            members = members.match(/[\d.]+/) * ( members.match(/M/) ? 1000000 : ( members.match(/K/) ? 1000 : 1 ) )
            if(members < 3000) continue; // skip if low members

            if(tierlist && tierlist[id]){ // old tierlist by sort
                document.querySelector('#'+tierlist[id]).innerHTML += `<li id="${id}" class="">${anime.outerHTML.replace('167', '80')}</li>`
            }else if(tierlist && tierlist.version == 2 && tierlist.tiers.flat().includes(id)){ // new ordered tierlist
                for(let tier_index of Object.keys(tierlist.tiers)){
                    if(!tierlist.tiers[tier_index].includes(id)) continue

                    let anime_index = tierlist.tiers[tier_index].indexOf(id)
                    let element = `<li id="${id}" class="">${anime.outerHTML.replace('167', '80')}</li>`

                    tiersOrdered[tier_index] ??= []
                    tiersOrdered[tier_index][anime_index] = element
                }
            }else{ // add shows to last tier by default
                document.querySelector('#last').innerHTML += `<li id="${id}" class="">${anime.outerHTML.replace('167', '80')}</li>`
            }
        }

        // insert anime to tierlist
        if(tierlist && tierlist.version == 2){
            for(let tier_index of Object.keys(tierlist.tiers)){
                let tier_row = document.getElementsByClassName('grid-container')[0].querySelectorAll('ul.droptrue')[tier_index]
                tier_row.innerHTML = tiersOrdered[tier_index].join('\n')
            }
        }

        // tier label names
        let tier_names = tierlist?.tier_names ?? JSON.parse(localStorage.getItem('tier_names'+season))
        if(tier_names){
            for(let [i, textareas] of document.querySelectorAll('ul div.textarea').entries()){
                textareas.innerText = tier_names[i]
            }
            // document.querySelectorAll('ul div.textarea').entries().forEach((i, textareas) => { textareas.innerText = tier_names[i] })
        }

        document.querySelector('#menu').scrollIntoView(); // window.scrollTo(0, 50);

        // automatic save when making tierlist
        // https://stackoverflow.com/questions/3219758/detect-changes-in-the-dom/3219767#3219767
        var targetNode = document.getElementsByClassName('grid-container')[0];
        var config = { subtree: true, childList: true, characterData: true,}; // works with this config

        var observer = new MutationObserver(SaveTierlistV2);
        observer.observe(targetNode, config); // detect dom change when creating tierlist

        // delete button event
        document.querySelector('#deletetierlist').onclick = function(event){
            event.preventDefault(); // disable default link click

            if (confirm('Delete tierlist from localstorage?')) {
                let season = document.querySelector("#content .season_nav .on").textContent
                localStorage.removeItem(season);
                localStorage.removeItem('tier_names'+season);
                RenderTierlist();
            }
        };

        // backup button
        document.getElementById("downloadLink").addEventListener("click", function(e) {
            const data = JSON.stringify(Object.fromEntries(Object.keys(localStorage).filter(key => ["Winter","Spring","Summer","Fall"].some(s => key.includes(s)) && key.match(/\d\d\d\d/)).map(key => [key,JSON.parse(localStorage.getItem(key))])))
            const blob = new Blob([data], { type: 'text/plain' });
            this.href = URL.createObjectURL(blob);
        });

        // import button
        document.getElementById("import").addEventListener("click", async function(e) {
            e.preventDefault();

            const fileInput = document.createElement("input");
            fileInput.type = "file";
            fileInput.click();

            fileInput.addEventListener("change", async function(event) {
                const file = event.target.files[0];
                if (!file) return;
                const reader = new FileReader();
                reader.onload = () => {
                    try {
                        for (const [key, val] of Object.entries(JSON.parse(reader.result))) {
                            localStorage.setItem(key, JSON.stringify(val));
                        }
                        RenderTierlist()
                        console.log("File import complete.");
                    } catch (err) {
                        console.error("Error processing file:", err);
                    }
                };
                reader.readAsText(file); 
            });
        });

    }

    function SaveTierlist(){
        let season = document.querySelector("#content .season_nav .on").textContent
        let tierlist = {}
        for(let tier of document.getElementsByClassName('grid-container')[0].querySelectorAll('ul.droptrue')){
            for(let anime of tier.querySelectorAll('li')){
                tierlist[anime.id] = tier.id
            }
        }
        localStorage.setItem(season, JSON.stringify(tierlist));

        let tier_names = Array.from(document.querySelectorAll('ul div.textarea')).map(tier => tier.textContent)
        localStorage.setItem('tier_names'+season, JSON.stringify(tier_names));
    }

    function SaveTierlistV2(){
        console.log('tierlist saved')
        let season = document.querySelector("#content .season_nav .on").textContent
        let tierlist = {version:2}

        tierlist.tiers = Array.from(document.querySelectorAll('.grid-container')[0].querySelectorAll('ul.droptrue')).map(tier => Array.from(tier.querySelectorAll('li')).map(anime => anime.id))
        tierlist.tier_names = Array.from(document.querySelectorAll('ul div.textarea')).map(tier => tier.textContent)

        localStorage.setItem(season, JSON.stringify(tierlist));
    }

    // anime count per season
    let seasonal = document.querySelectorAll('.seasonal-anime-list:nth-child(1) .seasonal-anime'),
        count = 0,
        cours = 0
    Array.from(seasonal).forEach(anime => {
        let [episodes, duration] = Array.from(anime.querySelectorAll('.info .item:nth-child(2) span')).map(info => parseInt(info.textContent) ?? 0)
        let members = anime.querySelector('.member').textContent
            members = members.match(/[\d.]+/) * ( members.match(/M/) ? 1000000 : ( members.match(/K/) ? 1000 : 1 ) )
        if(duration > 15 || members > 2000) count++
        if(episodes > 10) cours += episodes/13
    })
    document.querySelectorAll('.anime-header')[0].innerText += ` (${count})`//+` (${cours.toFixed(0)})`

    // buggy, zooms images
    // https://www.geeksforgeeks.org/how-to-take-screenshot-of-a-div-using-javascript/
    // download image button
    //     document.getElementById('download').addEventListener("click", function() {
    //         function download(canvas, filename) {
    //             const data = canvas.toDataURL("image/png;base64");
    //             const donwloadLink = document.querySelector("#download");
    //             donwloadLink.download = filename;
    //             donwloadLink.href = data;
    //         }

    //        html2canvas(document.querySelector(".grid-container"), {
    //            useCORS: true,
    //            onrendered: function (canvas) {
    //                document.body.appendChild(canvas);
    //            }}).then((canvas) => {
    //            // document.body.appendChild(canvas);

    //            download(canvas, "seasonal tierlist");
    //        });
    //     });

})();