您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A set of tools to help MyFigureCollection contributors with tagging items
// ==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; })();