MFC Tagger+

A set of tools to help MyFigureCollection contributors with tagging items

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        MFC Tagger+
// @description A set of tools to help MyFigureCollection contributors with tagging items
// @namespace   https://takkkane.tumblr.com/scripts/mfcTagCounter
// @version     0.2.1
// @author      Nefere
// @homepage    https://github.com/Nefere256/mfc-tagger-plus/tree/main
// @supportURL  https://github.com/Nefere256/mfc-tagger-plus/tree/main
// @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
// @connect     github.com
// @run-at      document-idle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @grant       GM_getResourceText
// @grant       GM_xmlhttpRequest
// ==/UserScript==
GM_addStyle(GM_getResourceText('css'));
var app = (function () {
    'use strict';
 
    function noop() { }
    function run(fn) {
        return fn();
    }
    function blank_object() {
        return Object.create(null);
    }
    function run_all(fns) {
        fns.forEach(run);
    }
    function is_function(thing) {
        return typeof thing === 'function';
    }
    function safe_not_equal(a, b) {
        return a != a ? b == b : a !== b || ((a && typeof a === 'object') || typeof a === 'function');
    }
    function is_empty(obj) {
        return Object.keys(obj).length === 0;
    }
 
    const globals = (typeof window !== 'undefined'
        ? window
        : typeof globalThis !== 'undefined'
            ? globalThis
            : global);
    function detach(node) {
        if (node.parentNode) {
            node.parentNode.removeChild(node);
        }
    }
    function children(element) {
        return Array.from(element.childNodes);
    }
    function custom_event(type, detail, { bubbles = false, cancelable = false } = {}) {
        const e = document.createEvent('CustomEvent');
        e.initCustomEvent(type, bubbles, cancelable, detail);
        return e;
    }
 
    let current_component;
    function set_current_component(component) {
        current_component = component;
    }
 
    const dirty_components = [];
    const binding_callbacks = [];
    let render_callbacks = [];
    const flush_callbacks = [];
    const resolved_promise = /* @__PURE__ */ Promise.resolve();
    let update_scheduled = false;
    function schedule_update() {
        if (!update_scheduled) {
            update_scheduled = true;
            resolved_promise.then(flush);
        }
    }
    function add_render_callback(fn) {
        render_callbacks.push(fn);
    }
    // flush() calls callbacks in this order:
    // 1. All beforeUpdate callbacks, in order: parents before children
    // 2. All bind:this callbacks, in reverse order: children before parents.
    // 3. All afterUpdate callbacks, in order: parents before children. EXCEPT
    //    for afterUpdates called during the initial onMount, which are called in
    //    reverse order: children before parents.
    // Since callbacks might update component values, which could trigger another
    // call to flush(), the following steps guard against this:
    // 1. During beforeUpdate, any updated components will be added to the
    //    dirty_components array and will cause a reentrant call to flush(). Because
    //    the flush index is kept outside the function, the reentrant call will pick
    //    up where the earlier call left off and go through all dirty components. The
    //    current_component value is saved and restored so that the reentrant call will
    //    not interfere with the "parent" flush() call.
    // 2. bind:this callbacks cannot trigger new flush() calls.
    // 3. During afterUpdate, any updated components will NOT have their afterUpdate
    //    callback called a second time; the seen_callbacks set, outside the flush()
    //    function, guarantees this behavior.
    const seen_callbacks = new Set();
    let flushidx = 0; // Do *not* move this inside the flush() function
    function flush() {
        // Do not reenter flush while dirty components are updated, as this can
        // result in an infinite loop. Instead, let the inner flush handle it.
        // Reentrancy is ok afterwards for bindings etc.
        if (flushidx !== 0) {
            return;
        }
        const saved_component = current_component;
        do {
            // first, call beforeUpdate functions
            // and update components
            try {
                while (flushidx < dirty_components.length) {
                    const component = dirty_components[flushidx];
                    flushidx++;
                    set_current_component(component);
                    update(component.$$);
                }
            }
            catch (e) {
                // reset dirty state to not end up in a deadlocked state and then rethrow
                dirty_components.length = 0;
                flushidx = 0;
                throw e;
            }
            set_current_component(null);
            dirty_components.length = 0;
            flushidx = 0;
            while (binding_callbacks.length)
                binding_callbacks.pop()();
            // then, once components are updated, call
            // afterUpdate functions. This may cause
            // subsequent updates...
            for (let i = 0; i < render_callbacks.length; i += 1) {
                const callback = render_callbacks[i];
                if (!seen_callbacks.has(callback)) {
                    // ...so guard against infinite loops
                    seen_callbacks.add(callback);
                    callback();
                }
            }
            render_callbacks.length = 0;
        } while (dirty_components.length);
        while (flush_callbacks.length) {
            flush_callbacks.pop()();
        }
        update_scheduled = false;
        seen_callbacks.clear();
        set_current_component(saved_component);
    }
    function update($$) {
        if ($$.fragment !== null) {
            $$.update();
            run_all($$.before_update);
            const dirty = $$.dirty;
            $$.dirty = [-1];
            $$.fragment && $$.fragment.p($$.ctx, dirty);
            $$.after_update.forEach(add_render_callback);
        }
    }
    /**
     * Useful for example to execute remaining `afterUpdate` callbacks before executing `destroy`.
     */
    function flush_render_callbacks(fns) {
        const filtered = [];
        const targets = [];
        render_callbacks.forEach((c) => fns.indexOf(c) === -1 ? filtered.push(c) : targets.push(c));
        targets.forEach((c) => c());
        render_callbacks = filtered;
    }
    const outroing = new Set();
    function transition_in(block, local) {
        if (block && block.i) {
            outroing.delete(block);
            block.i(local);
        }
    }
    function mount_component(component, target, anchor, customElement) {
        const { fragment, after_update } = component.$$;
        fragment && fragment.m(target, anchor);
        if (!customElement) {
            // onMount happens before the initial afterUpdate
            add_render_callback(() => {
                const new_on_destroy = component.$$.on_mount.map(run).filter(is_function);
                // if the component was destroyed immediately
                // it will update the `$$.on_destroy` reference to `null`.
                // the destructured on_destroy may still reference to the old array
                if (component.$$.on_destroy) {
                    component.$$.on_destroy.push(...new_on_destroy);
                }
                else {
                    // Edge case - component was destroyed immediately,
                    // most likely as a result of a binding initialising
                    run_all(new_on_destroy);
                }
                component.$$.on_mount = [];
            });
        }
        after_update.forEach(add_render_callback);
    }
    function destroy_component(component, detaching) {
        const $$ = component.$$;
        if ($$.fragment !== null) {
            flush_render_callbacks($$.after_update);
            run_all($$.on_destroy);
            $$.fragment && $$.fragment.d(detaching);
            // TODO null out other refs, including component.$$ (but need to
            // preserve final state?)
            $$.on_destroy = $$.fragment = null;
            $$.ctx = [];
        }
    }
    function make_dirty(component, i) {
        if (component.$$.dirty[0] === -1) {
            dirty_components.push(component);
            schedule_update();
            component.$$.dirty.fill(0);
        }
        component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
    }
    function init(component, options, instance, create_fragment, not_equal, props, append_styles, dirty = [-1]) {
        const parent_component = current_component;
        set_current_component(component);
        const $$ = component.$$ = {
            fragment: null,
            ctx: [],
            // state
            props,
            update: noop,
            not_equal,
            bound: blank_object(),
            // lifecycle
            on_mount: [],
            on_destroy: [],
            on_disconnect: [],
            before_update: [],
            after_update: [],
            context: new Map(options.context || (parent_component ? parent_component.$$.context : [])),
            // everything else
            callbacks: blank_object(),
            dirty,
            skip_bound: false,
            root: options.target || parent_component.$$.root
        };
        append_styles && append_styles($$.root);
        let ready = false;
        $$.ctx = instance
            ? instance(component, options.props || {}, (i, ret, ...rest) => {
                const value = rest.length ? rest[0] : ret;
                if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
                    if (!$$.skip_bound && $$.bound[i])
                        $$.bound[i](value);
                    if (ready)
                        make_dirty(component, i);
                }
                return ret;
            })
            : [];
        $$.update();
        ready = true;
        run_all($$.before_update);
        // `false` as a special case of no DOM component
        $$.fragment = create_fragment ? create_fragment($$.ctx) : false;
        if (options.target) {
            if (options.hydrate) {
                const nodes = children(options.target);
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                $$.fragment && $$.fragment.l(nodes);
                nodes.forEach(detach);
            }
            else {
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                $$.fragment && $$.fragment.c();
            }
            if (options.intro)
                transition_in(component.$$.fragment);
            mount_component(component, options.target, options.anchor, options.customElement);
            flush();
        }
        set_current_component(parent_component);
    }
    /**
     * Base class for Svelte components. Used when dev=false.
     */
    class SvelteComponent {
        $destroy() {
            destroy_component(this, 1);
            this.$destroy = noop;
        }
        $on(type, callback) {
            if (!is_function(callback)) {
                return noop;
            }
            const callbacks = (this.$$.callbacks[type] || (this.$$.callbacks[type] = []));
            callbacks.push(callback);
            return () => {
                const index = callbacks.indexOf(callback);
                if (index !== -1)
                    callbacks.splice(index, 1);
            };
        }
        $set($$props) {
            if (this.$$set && !is_empty($$props)) {
                this.$$.skip_bound = true;
                this.$$set($$props);
                this.$$.skip_bound = false;
            }
        }
    }
 
    function dispatch_dev(type, detail) {
        document.dispatchEvent(custom_event(type, Object.assign({ version: '3.59.2' }, detail), { bubbles: true }));
    }
    function validate_slots(name, slot, keys) {
        for (const slot_key of Object.keys(slot)) {
            if (!~keys.indexOf(slot_key)) {
                console.warn(`<${name}> received an unexpected slot "${slot_key}".`);
            }
        }
    }
    /**
     * Base class for Svelte components with some minor dev-enhancements. Used when dev=true.
     */
    class SvelteComponentDev extends SvelteComponent {
        constructor(options) {
            if (!options || (!options.target && !options.$$inline)) {
                throw new Error("'target' is a required option");
            }
            super();
        }
        $destroy() {
            super.$destroy();
            this.$destroy = () => {
                console.warn('Component was already destroyed'); // eslint-disable-line no-console
            };
        }
        $capture_state() { }
        $inject_state() { }
    }
 
    /* src\App.svelte generated by Svelte v3.59.2 */
 
    const { Object: Object_1, console: console_1 } = globals;
 
    function create_fragment(ctx) {
    	const block = {
    		c: noop,
    		l: function claim(nodes) {
    			throw new Error("options.hydrate only works if the component was compiled with the `hydratable: true` option");
    		},
    		m: noop,
    		p: noop,
    		i: noop,
    		o: noop,
    		d: noop
    	};
 
    	dispatch_dev("SvelteRegisterBlock", {
    		block,
    		id: create_fragment.name,
    		type: "component",
    		source: "",
    		ctx
    	});
 
    	return block;
    }
 
    function instance($$self, $$props) {
    	let { $$slots: slots = {}, $$scope } = $$props;
    	validate_slots('App', slots, []);
 
    	(async function () {
 
    		/**
     * Name of the class used for a tag counter container.
     * It should be not used on the page it's inserted to.
     **/
    		var TAG_CLASSNAME = "us-tag";
 
    		/**
     * Name of the class absent on the page.
     * Used to return empty collection of nodes from a function.
     **/
    		var FAKE_CLASS_PLACEHOLDER = "fake-class-placeholder";
 
    		/**
     * A time in miliseconds to wait between requests to MFC.
     * 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 work properly.
     **/
    		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 "stale", 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.
    	 * The contents: see tagCounterCache
     **/
    		var CACHE_SAVE_ENTRIES = [];
 
    		/**
     * How many entries have to be added to the cache so the cache can be persisted in the extension storage.
    	 * That way if the user gets into another page, some of the data gathered will not be lost.
     * 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/39")
     * * values: object with fields:
     * ** number: integer with number of tags on the entry page (39)
     * ** updatedTime: timestamp of when the map was updated.
     * Map entries may be deleted after time indicated in CACHE_FRESH_SECONDS.
     **/
    		var tagCounterCache;
 
    		/**
     * Util method. It let the thread sleep for ms (miliseconds) between calls to MFC website.
     **/
    		function sleep(ms) {
    			return new Promise(resolve => setTimeout(resolve, ms));
    		}
 
    		/**
     * Get tagCounterCache from a persistent storage.
     **/
    		async function getTagCounterCache() {
    			return new Map(Object.entries(JSON.parse(await GM.getValue('tagCounterCache', '{}'))));
    		}
 
    		/**
     * Save tagCounterCache with new CACHE_SAVE_ENTRIES to a persistent storage.
     * CACHE_SAVE_ENTRIES will be cleared after succesful save.
     **/
    		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 */
    		}
 
    		/**
     * Save an url and count of tags to both tagCounterCache and CACHE_SAVE_ENTRIES.
     * If CACHE_SAVE_ENTRIES will have CACHE_SAVE_AFTER_SETTING_VALUES_ORDER entries, 
     * the persistent storage will be updated.
     **/
    		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();
    				}
    			}
    		}
 
    		/**
     * Get a number of tags for a specified url from cache.
     * if the info is stale after CACHE_FRESH_SECONDS since last update of entry,
     * the info would be deleted, and the functon will return 0.
     * Otherwise, return number of tags.
     **/
    		function getTagCounterFromTagCounterCache(url) {
    			var tagCounterPair = tagCounterCache.get(url);
 
    			if (tagCounterPair == null) {
    				return 0;
    			}
 
    			var stalePairDate = new Date(tagCounterPair.updatedTime);
    			stalePairDate.setSeconds(stalePairDate.getSeconds() + CACHE_FRESH_SECONDS);
 
    			if (stalePairDate < Date.now()) {
    				tagCounterCache.delete(url);
    				return 0;
    			}
 
    			return tagCounterPair.number;
    		}
 
    		/**
    * Add a style for tag counter container (with a TAG_CLASSNAME class).
    * It's done only once the page is loaded.
    **/
    		function addStyles() {
    			let style = document.createElement('style');
    			style.type = 'text/css';
 
    			style.innerHTML = "\
            .item-icon ." + TAG_CLASSNAME + " {\
            position: absolute;\
            display: block;\
            right: -4px;\
            top: -4px;\
            padding: 4px;\
            border-radius: 3px;\
            text-align: center;\
            vertical-align: middle;\
            min-width: 12px;\
            font-weight: 700;\
            font-size: 11px;\
            color: gold;\
            background-color: darkgreen\
            }";
 
    			document.getElementsByTagName('head')[0].appendChild(style);
    		}
 
    		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/") || pathname.includes("/browse.v4.php") || pathname.includes("/browse/calendar/") || pathname.includes("/item/browse/calendar/") || pathname.includes("/item/browse/figure/") || pathname.includes("/item/browse/goods/") || pathname.includes("/item/browse/media/") || tbParam !== null) {
    				var result = document.querySelectorAll("#wide .result:not(.hidden)"); /* encyclopedia entry */ /* search results with filters */ /* calendar page */ /* new calendar page */ /* new figures page */ /* new goods page */ /* new media page */
    				return result;
    			}
 
    			console.log("unsupported getEntryContainers");
    			return document.querySelectorAll(FAKE_CLASS_PLACEHOLDER);
    		}
 
    		/**
    * Check if the current page (intended to be one with search results)
    * is detailed list.
    * The info is taken from GET/query params instead from the page contents.
    **/
    		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.querySelectorAll(".item-icons .item-icon");
 
    			if (icons.length > 0) {
    				return icons;
    			}
 
    			var pathname = window.location.pathname;
 
    			if (pathname.includes("/browse.v4.php") && isDetailedList()) {
    				return document.querySelectorAll(FAKE_CLASS_PLACEHOLDER); /* search page, detailed list view */
    			}
 
    			console.log("unsupported getItemsFromContainer");
    			return document.querySelectorAll(FAKE_CLASS_PLACEHOLDER);
    		}
 
    		function getTagCounterFromHtml(html) {
    			var parser = new DOMParser();
    			var doc = parser.parseFromString(html, 'text/html');
    			var tagCounterNode = doc.querySelector("div.tbx-target-TAGS .actions > .meta");
    			if (tagCounterNode == null) console.log("No tag counter element on downloaded html.");
    			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) {
    						response.text().then(function (html) {
    							var countOfTags = getTagCounterFromHtml(html);
    							addTagCounterToSearchResult(itemLinkElement, countOfTags);
    							pushToTagCounterCache(entryLink, countOfTags);
    						});
    					}
 
    					return Promise.reject(response);
    				}).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.forEach(entryContainer => {
    				var itemsElements = getItemsFromContainer(entryContainer);
 
    				itemsElements.forEach(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();
    		}
 
    		/**
    * All variables and methods are set.
    * Enjoy the show.
    **/
    		addStyles();
 
    		main();
    	})();
 
    	const writable_props = [];
 
    	Object_1.keys($$props).forEach(key => {
    		if (!~writable_props.indexOf(key) && key.slice(0, 2) !== '$$' && key !== 'slot') console_1.warn(`<App> was created with unknown prop '${key}'`);
    	});
 
    	return [];
    }
 
    class App extends SvelteComponentDev {
    	constructor(options) {
    		super(options);
    		init(this, options, instance, create_fragment, safe_not_equal, {});
 
    		dispatch_dev("SvelteRegisterComponent", {
    			component: this,
    			tagName: "App",
    			options,
    			id: create_fragment.name
    		});
    	}
    }
 
    const app = new App({
        target: document.body,
        props: {
            name: "World"
        }
    });
 
    return app;
 
})();