MFC tag counter

Adds tags count indicator to list of entries

目前为 2024-10-09 提交的版本。查看 最新版本

// ==UserScript==
// @name         MFC tag counter
// @namespace    https://takkkane.tumblr.com/scripts/mfcTagCounter
// @version      0.1.3
// @description  Adds tags count indicator to list of entries
// @author       Nefere
// @supportURL   https://twitter.com/TaxDelusion
// @match        https://myfigurecollection.net/entry/*
// @match        https://myfigurecollection.net/browse.v4.php*
// @match        https://myfigurecollection.net/browse/calendar/*

// @match        https://myfigurecollection.net/*
// @match        https://myfigurecollection.net/item/browse/figure/
// @match        https://myfigurecollection.net/item/browse/goods/
// @match        https://myfigurecollection.net/item/browse/media/
// @match        https://myfigurecollection.net/item/browse/calendar/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=myfigurecollection.net
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=myfigurecollection.net
// @license      MIT
// @grant        GM.getValue
// @grant        GM.setValue
// ==/UserScript==

(async function () {
    'use strict';

    /**
     * Name of the class used for a tag indicator container.
     * It should be not used on the page it's inserted to.
     **/
    var TAG_CLASSNAME = "us-tag";

    /**
     * Name of the class that does not appear on the page.
     * Used to return empty collections of nodes from functions.
     **/
    var FAKE_CLASS_PLACEHOLDER = "what-i-was-looking-for";

    /**
     * A time in miliseconds to wait between requests for /entry pages.
     * Too short time may results in "429 - Too many requests" error responses.
     * Can be increased with REQUEST_DELAY_MULTIPLIER.
     **/
    var REQUEST_DELAY = 1000;

    /**
     * A multipler that is used on REQUEST_DELAY when 429 response error is obtained.
     * Should be over 1 to properly work.
     **/
    var REQUEST_DELAY_MULTIPLIER = 1.1;

    /**
     * A time in seconds for how long the entry data saved in a cache is considered "fresh" and up to date.
     * After the entry data is "rotten", it is removed from cache and may be replaced with new data.
     **/
    var CACHE_FRESH_SECONDS = 10 * 60;

    /**
     * Map entries for tagCounterCache that are yet to be persisted in the extension storage.
     **/
    var CACHE_SAVE_ENTRIES = [];

    /**
     * How many entries have to be added to the cache so the cache can be persisted in the extension storage.
     * It requires using GM.getValue() and GM.setValue()
     **/
    var CACHE_SAVE_AFTER_SETTING_VALUES_ORDER = 5;

    /**
     * A cache for tag count indicated in the entry page.
     * It's a Map() consisted of:
     * * keys: pathname of an entry page ("/entry/2")
     * * values: object with fields:
     * ** number: integer with number of tags on the entry page (24)
     * ** updatedTime: timestamp of when the map was updated.
     * Map entries may be deleted after time indicated in CACHE_FRESH_SECONDS.
     **/
    var tagCounterCache;

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    };
    async function getTagCounterCache() {
        return new Map(Object.entries(
                JSON.parse(await GM.getValue('tagCounterCache', '{}'))));
    };
    async function saveTagCounterCache() {
        var newTagCounterCache = await getTagCounterCache();
        for (var entry of CACHE_SAVE_ENTRIES) {
            newTagCounterCache.set(entry.key, entry.value);
        }
        GM.setValue('tagCounterCache', JSON.stringify(Object.fromEntries(newTagCounterCache)));
        tagCounterCache = newTagCounterCache;
        newTagCounterCache.length = 0; /* clear new data as they are persisted */
    };
    async function pushToTagCounterCache(url, tagCounter) {
        if (tagCounter) {
            var time = Date.now();
            var entry = {
                key: url,
                value: {
                    'number': tagCounter,
                    'updatedTime': time
                }
            };
            tagCounterCache.set(entry.key, entry.value);
            CACHE_SAVE_ENTRIES.push(entry);
            if (CACHE_SAVE_ENTRIES.length % CACHE_SAVE_AFTER_SETTING_VALUES_ORDER == 0) {
                saveTagCounterCache();
            }
        }
    };
    function getTagCounterFromTagCounterCache(url) {
        var tagCounterPair = tagCounterCache.get(url);
        if (tagCounterPair == null) {
            return 0;
        }
        var rottenPairDate = new Date(tagCounterPair.updatedTime);
        rottenPairDate.setSeconds(rottenPairDate.getSeconds() + CACHE_FRESH_SECONDS);
        if (rottenPairDate < Date.now()) {
            tagCounterCache.delete(url);
            return 0;
        }
        return tagCounterPair.number;
    };
    function addStyles() {
        $("<style>")
        .prop("type", "text/css")
        .html("\
            .item-icon ." + TAG_CLASSNAME + " {\
            position: absolute;\
            display: block;\
            right: 1px;\
            bottom: 1px;\
            height: 16px;\
            padding: 0 4px;\
            font-weight: 700;\
            color: gold;\
            background-color: darkgreen\
            }")
        .appendTo("head");
    };
    function getEntryContainers() {
        var pathname = window.location.pathname;
        var search = window.location.search;
        var searchParams = new URLSearchParams(search);
        var tbParam = searchParams.get("_tb");
        if (pathname.includes("/entry/") /* encyclopedia entry */
            || pathname.includes("/browse.v4.php") /* search results with filters */
            || pathname.includes("/browse/calendar/") /* calendar page */
            || pathname.includes("/item/browse/calendar/") /* new calendar page */
            || pathname.includes("/item/browse/figure/") /* new figures page */
            || pathname.includes("/item/browse/goods/") /* new goods page */
            || pathname.includes("/item/browse/media/") /* new media page */
            || tbParam !== null) {
            var result = $("#wide .result:not(.hidden)");
            return result;
		}
        console.log("unsupported getEntryContainers");
        return $(FAKE_CLASS_PLACEHOLDER);
    };
    function isDetailedList() {
        var search = window.location.search;
        var searchParams = new URLSearchParams(search);
        var outputParam = searchParams.get("output"); /* 0 - detailedList, 1,2 - grid, 3 - diaporama */
        return outputParam == 0;
    };
    function getItemsFromContainer(entryContainer) {
        var icons = $(entryContainer).find(".item-icons .item-icon");
        if (icons.length > 0) {
            return icons;
        }
        var pathname = window.location.pathname;
        if (pathname.includes("/browse.v4.php") /* search page, detailed list view */
             && isDetailedList()) {
            return $(FAKE_CLASS_PLACEHOLDER);
        }
        console.log("unsupported getItemsFromContainer");
        return $(FAKE_CLASS_PLACEHOLDER);
    };
    function getTagCounterFromHtml(html) {
        var parser = new DOMParser();
        var doc = parser.parseFromString(html, 'text/html');
        var tagCounterNode = doc.querySelector('.tbx-target-TAGS .count');
        return tagCounterNode.textContent;
    };
    function addTagCounterToSearchResult(itemLinkElement, countOfTags) {
        var tagElement = document.createElement("span");
        tagElement.setAttribute("class", TAG_CLASSNAME);
        tagElement.textContent = countOfTags;
        itemLinkElement.appendChild(tagElement);
    };

    async function fetchAndHandle(queue) {
        var resultQueue = [];
        for (var itemElement of queue) {
            var itemLinkElement = itemElement.firstChild;
            var entryLink = itemLinkElement.getAttribute("href");

            fetch(entryLink, {
                headers: {
                    "User-Agent": GM.info.script.name + " " + GM.info.script.version
                }
            }).then(function (response) {
                if (response.ok) {
                    return response.text();
                }
                return Promise.reject(response);
            }).then(function (html) {
                var countOfTags = getTagCounterFromHtml(html);
                addTagCounterToSearchResult(itemLinkElement, countOfTags);
                pushToTagCounterCache(entryLink, countOfTags);
            }).catch(function (err) {
                if (err.status == 429) {
                    console.warn('Too many requests. Added the request to fetch later', err.url);
                    resultQueue.push(itemElement);
                    REQUEST_DELAY = REQUEST_DELAY * REQUEST_DELAY_MULTIPLIER;
                    console.info('Increased delay to ' + REQUEST_DELAY);
                }
            });
            await sleep(REQUEST_DELAY);

        }
        return resultQueue;
    };
    async function main() {
        var cacheQueue = [];
        var entryContainers = getEntryContainers();
        entryContainers.each(function (i, entryContainer) {
            var itemsElements = getItemsFromContainer(entryContainer);
            itemsElements.each(function (i, itemElement) {
                cacheQueue.push(itemElement);
            });
        });

        var queue = [];
        tagCounterCache = await getTagCounterCache();
        for (var itemElement of cacheQueue) {
            var itemLinkElement = itemElement.firstChild;
            var entryLink = itemLinkElement.getAttribute("href");
            var cache = getTagCounterFromTagCounterCache(entryLink);
            if (cache > 0) {
                addTagCounterToSearchResult(itemLinkElement, cache);
            } else {
                queue.push(itemElement);
            }
        }
        while (queue.length) {
            queue = await fetchAndHandle(queue);
        }
        saveTagCounterCache();

    };

    addStyles();
    main();
})();