Torn.com Virus Programming Indicator

Show a status icon if a virus is being made or not

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Torn.com Virus Programming Indicator
// @namespace    dev.wiing.virusindicator
// @version      0.2.0
// @description  Show a status icon if a virus is being made or not
// @author       BLOODWIING[3891894]
// @license      MIT
// @match        https://www.torn.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tornexchange.com
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function() {
    'use strict';

    const plugin_name = 'VirusIndicator';

    const log = (x) => { console.log(`[${plugin_name}] ${x}`) };
    const error = (x) => { console.error(`[${plugin_name}] ${x}`) };

    const virusCodeSvgIcon = `<?xml version='1.0' encoding='UTF-8'?><svg width='34' height='16' fill='none' version='1.1' viewBox='0 0 34 16' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><path d='m2 4v4h5v-1h-4v-3zm11 0v3h-4v1h5v-4zm-12 5v1h6v-1zm8 0v1h6v-1zm-7 2v4h1v-3h4.23c-0.146-0.327-0.23-0.663-0.23-1zm7 0c0 0.337-0.0844 0.673-0.23 1h4.23v3h1v-4z' fill='url(%23linearGradient1271)' stroke='%23357534' stroke-linecap='square' stroke-width='2' style='paint-order:stroke markers fill'/><defs><linearGradient id='linearGradient1266' x1='548' x2='548' y1='19' y2='24' gradientTransform='matrix(.667 0 0 .667 -357 -10.3)' gradientUnits='userSpaceOnUse'><stop stop-color='%2335824c' offset='0'/><stop stop-color='%23c3ea5b' offset='1'/></linearGradient><linearGradient id='linearGradient1271' x1='548' x2='548' y1='34' y2='21' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23d7e755' offset='0'/><stop stop-color='%2359a94d' offset='1'/></linearGradient><linearGradient id='linearGradient1276' x1='548' x2='548' y1='24' y2='33' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23d0ff4d' offset='0'/><stop stop-color='%2379a266' offset='1'/></linearGradient><linearGradient id='linearGradient1278' x1='548' x2='548' y1='26' y2='30' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23151515' offset='0'/><stop stop-color='%236c9d51' offset='1'/></linearGradient><linearGradient id='linearGradient1280' x1='550' x2='550' y1='24' y2='33' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%2362a351' offset='0'/><stop stop-color='%2362a351' stop-opacity='0' offset='1'/></linearGradient><linearGradient id='linearGradient1285' x1='548' x2='548' y1='26' y2='30' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23585858' offset='0'/><stop stop-color='%23919191' offset='1'/></linearGradient><linearGradient id='linearGradient1286' x1='550' x2='550' y1='24' y2='33' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%237a7a7a' offset='0'/><stop stop-color='%237a7a7a' stop-opacity='0' offset='1'/></linearGradient><linearGradient id='linearGradient1287' x1='548' x2='548' y1='24' y2='33' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23d6d6d6' offset='0'/><stop stop-color='%23848484' offset='1'/></linearGradient><linearGradient id='linearGradient1288' x1='548' x2='548' y1='19' y2='24' gradientTransform='matrix(.667 0 0 .667 -339 -10.3)' gradientUnits='userSpaceOnUse'><stop stop-color='%23797979' offset='0'/><stop stop-color='%23c5c5c5' offset='1'/></linearGradient><linearGradient id='linearGradient1289' x1='548' x2='548' y1='34' y2='21' gradientTransform='translate(-522,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23c5c5c5' offset='0'/><stop stop-color='%237b7b7b' offset='1'/></linearGradient><linearGradient id='linearGradient1291' x1='546' x2='555' y1='32' y2='32' gradientTransform='translate(-539,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23c28f20' offset='0'/><stop stop-color='%23fdff8e' offset='1'/></linearGradient><linearGradient id='linearGradient1304' x1='572' x2='572' y1='25' y2='30' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23eed87d' offset='0'/><stop stop-color='%23cd8949' offset='1'/></linearGradient><linearGradient id='linearGradient1306' x1='572' x2='572' y1='31' y2='33' gradientTransform='translate(-540,-18)' gradientUnits='userSpaceOnUse'><stop stop-color='%23e4cc66' offset='0'/><stop stop-color='%23d18552' offset='1'/></linearGradient></defs><g stroke='%233d893d' stroke-linecap='square' stroke-width='2'><path d='m5 2-0.709 0.709 1.39 1.39c0.213-0.261 0.469-0.486 0.758-0.662zm6 0-1.44 1.44c0.289 0.176 0.544 0.402 0.758 0.662l1.39-1.39z' fill='%23b3d6ab' style='paint-order:stroke markers fill'/><path d='m8 3c-0.382 0-0.74 0.106-1.04 0.29-0.193 0.117-0.363 0.268-0.505 0.441-0.282 0.345-0.452 0.786-0.452 1.27h4c0-0.482-0.169-0.923-0.452-1.27-0.142-0.174-0.312-0.324-0.505-0.441-0.303-0.185-0.661-0.29-1.04-0.29z' fill='url(%23linearGradient1266)' style='paint-order:stroke markers fill'/><path d='m6 6c-0.554 0-1 0.446-1 1v4c0 0.337 0.0844 0.673 0.23 1 0.722 1.62 1.94 3 2.77 3s2.05-1.38 2.77-3c0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1276)' style='paint-order:stroke markers fill'/></g><g><path d='m8 6v9c0.832 0 2.05-1.38 2.77-3 0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1280)' opacity='.655' style='paint-order:stroke markers fill'/><path d='m8 8a2 2 0 0 0-2 2 2 2 0 0 0 1 1.73v0.268h2v-0.268a2 2 0 0 0 1-1.73 2 2 0 0 0-2-2zm-0.666 2.33a0.333 0.333 0 0 1 0.332 0.332 0.333 0.333 0 0 1-0.332 0.334 0.333 0.333 0 0 1-0.334-0.334 0.333 0.333 0 0 1 0.334-0.332zm1.33 0a0.333 0.333 0 0 1 0.334 0.332 0.333 0.333 0 0 1-0.334 0.334 0.333 0.333 0 0 1-0.332-0.334 0.333 0.333 0 0 1 0.332-0.332z' fill='url(%23linearGradient1278)' style='paint-order:stroke markers fill'/><g stroke-linecap='square' stroke-width='2'><path d='m20 4v4h5v-1h-4v-3zm11 0v3h-4v1h5v-4zm-12 5v1h6v-1zm8 0v1h6v-1zm-7 2v4h1v-3h4.23c-0.146-0.327-0.23-0.663-0.23-1zm7 0c0 0.337-0.0844 0.673-0.23 1h4.23v3h1v-4z' fill='url(%23linearGradient1289)' stroke='%23696969' style='paint-order:stroke markers fill'/><path d='m23 2-0.709 0.709 1.39 1.39c0.213-0.261 0.469-0.486 0.758-0.662zm6 0-1.44 1.44c0.289 0.176 0.544 0.402 0.758 0.662l1.39-1.39z' fill='%23d2d2d2' stroke='%237a7a7a' style='paint-order:stroke markers fill'/><path d='m26 3c-0.382 0-0.74 0.106-1.04 0.29-0.193 0.117-0.363 0.268-0.505 0.441-0.282 0.345-0.452 0.786-0.452 1.27h4c0-0.482-0.169-0.923-0.452-1.27-0.142-0.174-0.312-0.324-0.505-0.441-0.303-0.185-0.661-0.29-1.04-0.29z' fill='url(%23linearGradient1288)' stroke='%237a7a7a' style='paint-order:stroke markers fill'/></g></g><path d='m24 6c-0.554 0-1 0.446-1 1v4c0 0.337 0.0844 0.673 0.23 1 0.722 1.62 1.94 3 2.77 3s2.05-1.38 2.77-3c0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1287)' stroke='%237a7a7a' stroke-linecap='square' stroke-width='2' style='paint-order:stroke markers fill'/><path d='m26 6v9c0.832 0 2.05-1.38 2.77-3 0.146-0.327 0.23-0.663 0.23-1v-4c0-0.554-0.446-1-1-1z' fill='url(%23linearGradient1286)' opacity='.655' style='paint-order:stroke markers fill'/><path d='m26 8a2 2 0 0 0-2 2 2 2 0 0 0 1 1.73v0.268h2v-0.268a2 2 0 0 0 1-1.73 2 2 0 0 0-2-2zm-0.666 2.33a0.333 0.333 0 0 1 0.332 0.332 0.333 0.333 0 0 1-0.332 0.334 0.333 0.333 0 0 1-0.334-0.334 0.333 0.333 0 0 1 0.334-0.332zm1.33 0a0.333 0.333 0 0 1 0.334 0.332 0.333 0.333 0 0 1-0.334 0.334 0.333 0.333 0 0 1-0.332-0.334 0.333 0.333 0 0 1 0.332-0.332z' fill='url(%23linearGradient1285)' style='paint-order:stroke markers fill'/><g stroke-linecap='square' stroke-width='2'><path d='m9 13a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm2.5 0a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm2.5 0a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1z' fill='url(%23linearGradient1291)' stroke='%23946c26' style='paint-order:stroke markers fill'/><rect x='31' y='7' width='2' height='5' fill='url(%23linearGradient1304)' stroke='%23aa5049' style='paint-order:stroke markers fill'/><circle cx='32' cy='14' r='1' fill='url(%23linearGradient1306)' stroke='%23aa5049' style='paint-order:stroke markers fill'/></g></svg>`;

    // BELOW API KEY CODE IS INSPIRED BY rDacted's FF SCOUTER v2
    // https://greasyfork.org/en/scripts/535292-ff-scouter-v2

    var apikey = "###PDA-APIKEY###";

    var VI_setValue;
    var VI_getValue;
    var VI_deleteValue;
    var VI_registerMenuCommand;

    if (apikey[0] != "#") {
        VI_setValue = function (name, value) {
            log(`Setting value for ${name}`);
            return localStorage.setItem(name, value);
        };
        VI_getValue = function (name, defaultValue) {
            var value = localStorage.getItem(name) ?? defaultValue;
            return value;
        };
        VI_deleteValue = function (name) {
            log(`Deleting value for ${name}`);
            return localStorage.removeItem(name);
        };
        VI_registerMenuCommand = function () {
            log(`Disabling GM_registerMenuCommand`);
        };
        VI_setValue("minimal_key", apikey);
    } else {
        VI_setValue = GM_setValue;
        VI_getValue = GM_getValue;
        VI_deleteValue = GM_deleteValue;
        VI_registerMenuCommand = GM_registerMenuCommand;
    }

    VI_registerMenuCommand("Enter minimal API Key", () => {
        let userInput = prompt(
            `[${plugin_name}]: Enter minimal API Key`,
            VI_getValue("minimal_key", ""),
        );
        if (userInput !== null) {
            VI_setValue("minimal_key", userInput);
            // Reload page
            window.location.reload();
        }
    });

    VI_registerMenuCommand("Clear virus cache", () => {
        clearVirusCache();
        window.location.reload();
    });

    // END BORROWED CODE

    var API_COMMENT = plugin_name;
    var API_KEY = VI_getValue("minimal_key", null);

    // --- data ----------------------------------------------------------

    const CACHE_KEY = "virus_cache";
    const CACHE_TTL_MS = 3600 * 1000; // 1 hour

    /*
     null | {item: {id: number, name: string}, until: number}
     */
    let virus_data = null;

    function readVirusCache() {
        log('Reading virus data from cache');

        const raw = VI_getValue(CACHE_KEY, null);
        if (!raw) return null;

        try {
            const json = JSON.parse(raw);
            log(`Virus data cache lasts for ${formatRelativeTime(untilTimestampToRelativeTime(Math.floor(json.expires / 1000)))}`)
            return json;
        } catch (e) {
            return null;
        }
    }

    function writeVirusCache(data, { ttl = null } = {}) {
        const true_ttl = ttl ? Math.min(CACHE_TTL_MS, ttl) : CACHE_TTL_MS;

        log(`Saving virus data to cache for ${true_ttl} (${ttl}) TTL`);

        VI_setValue(CACHE_KEY, JSON.stringify({
            data: data,
            expires: Date.now() + true_ttl,
        }));
    }

    function clearVirusCache() {
        VI_deleteValue(CACHE_KEY);
        virus_data = null;
        log('Virus cache cleared');
    }

    // --- static helpers ----------------------------------------------------------

    function onElement(selector, callback, { once = true, root = document.body, attributes = false } = {}) {
        const run = () => {
            const el = document.querySelector(selector);
            if (el) {
                callback(el);
                return true;
            }
            return false;
        };

        if (run() && once) return;

        const obs = new MutationObserver(() => {
            if (run() && once) obs.disconnect();
        });

        obs.observe(root, { childList: true, subtree: true, attributes: attributes });
        return obs;
    }

    function onAttribute(element, attribute, callback, { once = true } = {}) {
        const run = () => {
            const val = element.getAttribute(attribute);
            if (val !== null) {
                callback(val);
                return true;
            }
            return false;
        };

        if (run() && once) return;

        const obs = new MutationObserver(function(mutations) {
            mutations.forEach(function(mutation) {
                if (mutation.type === "attributes") {
                    if (run() && once) obs.disconnect();
                }
            });
        });

        obs.observe(element, { attributes: true });
        return obs;
    }

    // --- utils ----------------------------------------------------------

    function httpGetJSON(url) {
        if (typeof GM_xmlhttpRequest === "function") {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    timeout: 15000,
                    onload: (res) => resolve({ status: res.status, text: res.responseText }),
                    onerror: () => reject(new Error("Network error")),
                    ontimeout: () => reject(new Error("Request timed out")),
                });
            });
        }

        if (typeof PDA_httpGet === "function") {
            return Promise.resolve(PDA_httpGet(url)).then((res) => ({
                status: res.status ?? res.responseCode ?? 200,
                text: res.responseText ?? res.body ?? res.response ?? "",
            }));
        }

        return fetch(url).then((res) => res.text().then((text) => ({ status: res.status, text })));
    }

    function untilTimestampToRelativeTime(timestamp) {
        const now = Math.floor(Date.now() / 1000);
        var remaining = timestamp - now;

        const days = Math.floor(remaining / 86400);
        remaining -= days * 86400;

        const hours = Math.floor(remaining / 3600);
        remaining -= hours * 3600;

        const minutes = Math.floor(remaining / 60);
        remaining -= minutes * 60;

        return {days: days, hours: hours, minutes: minutes, seconds: remaining};
    }

    function formatRelativeTime(relative) {
        if (relative.days) {
            if (relative.hours) return `${relative.days}d ${relative.hours}h`;
            if (relative.minutes) return `${relative.days}d ${relative.minutes}m`;
            return `${relative.days}d`;
        }
        if (relative.hours) {
            if (relative.minutes) return `${relative.hours}h ${relative.minutes}m`;
            if (relative.seconds) return `${relative.hours}h ${relative.seconds}s`;
            return `${relative.hours}h`;
        }
        if (relative.minutes) return `${relative.minutes}m ${relative.seconds}s`;
        return `${relative.seconds}s`;
    }

    // --- torn api ----------------------------------------------------------

    const getVirusApi = (timestamp) => `https://api.torn.com/v2/user/virus?comment=${plugin_name}&key=${API_KEY}&timestamp=${timestamp}`;

    async function getVirusInformation() {
        const cached = readVirusCache();

        if (cached && Date.now() < cached.expires) {
            log('Using Virus cache');

            virus_data = cached.data;
            return virus_data;
        }

        try {
            log('Fetching Virus information from API');

            const url = getVirusApi(Math.floor(Date.now() / 1000));
            const { text } = await httpGetJSON(url);
            const data = JSON.parse(text);

            virus_data = data.virus;

            const ttl = virus_data ? (virus_data.until - Math.floor(Date.now() / 1000)) * 1000 : null;

            writeVirusCache(virus_data, {ttl: ttl});

            return virus_data;
        }
        catch (e) {
            error("Events fetch failed: " + (e && e.message));

            if (cached) {
                virus_data = cached.data;
                return virus_data;
            }

            return null;
        }
    }

    // --- rest ----------------------------------------------------------

    function addCSS() {
        const style_code = `
li.vius-icon {
  background-image: url("data:image/svg+xml;utf8,${virusCodeSvgIcon}") !important;
}

li.vius-icon.off {
  background-position: -17px 0px;
}
        `;
        log("Adding CSS...");
        let style = document.createElement("style");
        style.type = "text/css";
        style.innerHTML = style_code;
        document.head.appendChild(style);
        log("CSS added");
    }

    let clicked_status_icon_once = false;

    function injectStatusIcon() {
        onElement('ul[class^=status-icons]', (status_icon_list) => {
            const last_icon = status_icon_list.lastChild;
            const new_icon = last_icon.cloneNode(true);

            new_icon.setAttribute('class', 'vius-icon');

            if (virus_data === null) {
                new_icon.classList.add('off');
            }

            const relative_time_text = virus_data ? formatRelativeTime(untilTimestampToRelativeTime(virus_data.until)) : '';

            const a = new_icon.querySelector('a');

            const tooltip_text = virus_data === null ? 'No virus being made' : `You are currently programming the ${virus_data.item.name}\nIt will be completed in ${relative_time_text}`;

            a.removeAttribute('aria-label');
            a.removeAttribute('tabindex');
            // a.removeAttribute('data-is-tooltip-opened');
            a.removeAttribute('i-data');
            a.setAttribute('aria-label', tooltip_text);
            a.setAttribute('title', tooltip_text);

            a.setAttribute('href', 'https://www.torn.com/pc.php');

            // mimic double click status icon behavior
            a.addEventListener('click', (e) => {
                if (!clicked_status_icon_once) {
                    e.preventDefault();
                }
                clicked_status_icon_once = !clicked_status_icon_once;
            });

            Array.from(status_icon_list.children).forEach((other_status_icon) => {
                other_status_icon.addEventListener('click', (e) => {
                    clicked_status_icon_once = false;
                });
            });

            status_icon_list.appendChild(new_icon);
        });
    }

    function runMain() {
        log("Initializing...");

        addCSS();

        if (/^\/pc\.php/.test(window.location.pathname)) {
            log('In PC => Clearing virus data cache')
            clearVirusCache();
        } else {
            getVirusInformation().then(()=>{injectStatusIcon();});
        }

        log("Done!");
    }

    runMain();
})();