Twitter Click'n'Save

Add buttons to download images and videos in Twitter, also does some other enhancements.

目前為 2022-02-21 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Twitter Click'n'Save
// @version     0.5.11-2022.02.21
// @namespace   gh.alttiri
// @description Add buttons to download images and videos in Twitter, also does some other enhancements.
// @match       https://twitter.com/*
// @homepageURL https://github.com/AlttiRi/twitter-click-and-save
// @supportURL  https://github.com/AlttiRi/twitter-click-and-save/issues
// @license     GPL-3.0
// @grant       GM_registerMenuCommand
// ==/UserScript==
// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------------------------------------------

if (globalThis.GM_registerMenuCommand /* undefined in Firefox with VM */ || typeof GM_registerMenuCommand === "function") {
  GM_registerMenuCommand("Show settings", showSettings);
}


const settings = loadSettings();

function loadSettings() {
  const defaultSettings = {
    hideTrends: true,
    hideSignUpSection: true,
    hideTopicsToFollow: true,
    hideTopicsToFollowInstantly: true,
    hideSignUpBottomBarAndMessages: true,
    doNotPlayVideosAutomatically: false,
    goFromMobileToMainSite: false,

    highlightVisitedLinks: true,
    expandSpoilers: true,

    directLinks: true,
    handleTitle: true,

    imagesHandler: true,
    videoHandler: true,
    addRequiredCSS: true,
  };

  let savedSettings;
  try {
    savedSettings = JSON.parse(localStorage.getItem("ujs-click-n-save-settings")) || {};
  } catch (e) {
    console.error(e);
    localStorage.removeItem("ujs-click-n-save-settings");
    savedSettings = {};
  }
  savedSettings = Object.assign(defaultSettings, savedSettings);
  return savedSettings;
}
function showSettings() {
  closeSetting();
  document.body.classList.add("ujs-no-scroll");

  const modalWrapperStyle = `
    width: 100%;
    height: 100%;
    position: absolute;
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 99999;
    backdrop-filter: blur(4px);
  `;
  const modalSettingsStyle = `
    background-color: white;
    min-width: 320px;
    min-height: 320px;
    border: 1px solid darkgray;
    padding: 8px;
    box-shadow: 0 0 10px rgba(0,0,0,0.5);
  `;
  const s = settings;
  document.body.insertAdjacentHTML("afterbegin", `
  <div class="ujs-modal-wrapper" style="${modalWrapperStyle}">
      <div class="ujs-modal-settings" style="${modalSettingsStyle}">
          <fieldset>
              <legend>Optional</legend>
              <label><input type="checkbox" ${s.hideTrends ? "checked" : ""} name="hideTrends">Hide <b>Trends</b> (in the right column)*<br/></label>              
              <label><input type="checkbox" ${s.hideTopicsToFollow ? "checked" : ""} name="hideTopicsToFollow">Hide <b>Topics To Follow</b> (in the right column)*<br/></label>
              <label><input type="checkbox" ${s.hideSignUpSection ? "checked" : ""} name="hideSignUpSection">Hide <b title='"New to Twitter?"'>Sign Up</b> section (in the right column)*<br/></label>
              <label hidden><input type="checkbox" ${s.hideTopicsToFollowInstantly ? "checked" : ""} name="hideTopicsToFollowInstantly">Hide <b>Topics To Follow</b> Instantly*<br/></label>
              <label><input type="checkbox" ${s.hideSignUpBottomBarAndMessages ? "checked" : ""} name="hideSignUpBottomBarAndMessages">Hide <b>Sign Up Bar</b> and <b>Messages</b> (in the bottom)<br/></label>
              <label hidden><input type="checkbox" ${s.doNotPlayVideosAutomatically ? "checked" : ""} name="doNotPlayVideosAutomatically">Do <i>Not</i> Play Videos Automatically</b><br/></label>
              <label hidden><input type="checkbox" ${s.goFromMobileToMainSite ? "checked" : ""} name="goFromMobileToMainSite">Redirect from Mobile version (beta)<br/></label>
          </fieldset>
          <fieldset>
              <legend>Recommended</legend>
              <label><input type="checkbox" ${s.highlightVisitedLinks ? "checked" : ""} name="highlightVisitedLinks">Highlight Visited Links<br/></label>
              <label><input type="checkbox" ${s.expandSpoilers ? "checked" : ""} name="expandSpoilers">Expand Spoilers*<br/></label>
          </fieldset>
          <fieldset>
              <legend>Highly Recommended</legend>
              <label><input type="checkbox" ${s.directLinks ? "checked" : ""} name="directLinks">Direct Links</label><br/>
              <label><input type="checkbox" ${s.handleTitle ? "checked" : ""} name="handleTitle">Enchance Title*<br/></label>
          </fieldset>
          <fieldset>
              <legend>Main</legend>
              <label><input type="checkbox" ${s.imagesHandler ? "checked" : ""} name="imagesHandler">Image Download Button<br/></label>
              <label><input type="checkbox" ${s.videoHandler ? "checked" : ""} name="videoHandler">Video Download Button<br/></label>
              <label hidden><input type="checkbox" ${s.addRequiredCSS ? "checked" : ""} name="addRequiredCSS">Add Required CSS*<br/></label><!-- * Only for the image download button in /photo/1 mode -->
          </fieldset>
          <hr>
          <div style="display: flex; justify-content: space-around;">
              <button class="ujs-save-setting-button"  style="padding: 5px">Save Settings</button>
              <button class="ujs-close-setting-button" style="padding: 5px">Close Settings</button>
          </div>
          <hr>
          <h4 style="margin: 0; padding-left: 8px; color: #444;">Notes:</h4>
          <ul style="margin: 2px; padding-left: 16px; color: #444;">
            <li>Click on <b>Save Settings</b> and reload the page to apply changes.</li>
            <li><b>*</b>-marked settings are language dependent. Currently, the follow languages are supported:<br/> "en", "ru", "es", "zh", "ja".</li>
            <li hidden>The extension downloads only from twitter.com, not from <b>mobile</b>.twitter.com</li>
          </ul>
      </div>
  </div>`);

  document.querySelector("body > .ujs-modal-wrapper .ujs-save-setting-button").addEventListener("click", saveSetting);
  document.querySelector("body > .ujs-modal-wrapper .ujs-close-setting-button").addEventListener("click", closeSetting);

  function saveSetting() {
    const entries = [...document.querySelectorAll("body > .ujs-modal-wrapper input[type=checkbox]")]
        .map(checkbox => [checkbox.name, checkbox.checked]);
    const settings = Object.fromEntries(entries);
    settings.hideTopicsToFollowInstantly = settings.hideTopicsToFollow;
    console.log(settings);
    localStorage.setItem("ujs-click-n-save-settings", JSON.stringify(settings));
  }

  function closeSetting() {
    document.body.classList.remove("ujs-no-scroll");
    document.querySelector("body > .ujs-modal-wrapper")?.remove();
  }
}



// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------------------------------------------

// --- Features to execute --- //
const doNotPlayVideosAutomatically = false;

function execFeaturesOnce() {
    settings.goFromMobileToMainSite         && Features.goFromMobileToMainSite();
    settings.addRequiredCSS                 && Features.addRequiredCSS();
    settings.hideSignUpBottomBarAndMessages && Features.hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically);
    settings.hideTrends                     && Features.hideTrends();
    settings.highlightVisitedLinks          && Features.highlightVisitedLinks();
    settings.hideTopicsToFollowInstantly    && Features.hideTopicsToFollowInstantly();
}
function execFeaturesImmediately() {
    settings.expandSpoilers     && Features.expandSpoilers();
}
function execFeatures() {
    settings.imagesHandler      && Features.imagesHandler();
    settings.videoHandler       && Features.videoHandler();
    settings.expandSpoilers     && Features.expandSpoilers();
    settings.expandSpoilers     && Features.hideSignUpSection();
    settings.hideTopicsToFollow && Features.hideTopicsToFollow();
    settings.directLinks        && Features.directLinks();
    settings.handleTitle        && Features.handleTitle();
}

// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------------------------------------------


// --- For debug --- //
const verbose = false;
if (verbose) {
  console.log(settings);
  showSettings();
}


// --- [VM/GM + Firefox ~90+ + Enabled "Strict Tracking Protection"] fix --- //
const fetch = (globalThis.wrappedJSObject && typeof globalThis.wrappedJSObject.fetch === "function") ? function(resource, init) {
    verbose && console.log("wrappedJSObject.fetch", resource, init);

    if (init.headers instanceof Headers) {
        // Since `Headers` are not allowed for structured cloning.
        init.headers = Object.fromEntries(init.headers.entries());
    }

    return globalThis.wrappedJSObject.fetch(cloneInto(resource, document), cloneInto(init, document));
} : globalThis.fetch;


// --- "Imports" --- //
const {
    sleep, fetchResource, download,
    addCSS,
    getCookie,
    throttle,
    xpath, xpathAll,
    getNearestElementByType, getParentWithSiblingDataset,
} = getUtils({verbose});
const LS = hoistLS({verbose});

const API = hoistAPI();
const Tweet = hoistTweet();
const Features = hoistFeatures();
const I18N = getLanguageConstants();


// --- That to use for the image history --- //
// "TWEET_ID" or "IMAGE_NAME"
const imagesHistoryBy = LS.getItem("ujs-images-history-by", "IMAGE_NAME");
// With "TWEET_ID" downloading of 1 image of 4 will mark all 4 images as "already downloaded"
// on the next time when the tweet will appear.
// "IMAGE_NAME" will count each image of a tweet, but it will take more data to store.


// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------------------------------------------
// --- Script runner --- //

(function starter(feats) {
    const {once, onChangeImmediate, onChange} = feats;

    once();
    onChangeImmediate();
    const onChangeThrottled = throttle(onChange, 250);
    onChangeThrottled();

    const targetNode = document.querySelector("body");
    const observerOptions = {
        subtree: true,
        childList: true,
    };
    const observer = new MutationObserver(callback);
    observer.observe(targetNode, observerOptions);

    function callback(mutationList, observer) {
        verbose && console.log(mutationList);
        onChangeImmediate();
        onChangeThrottled();
    }
})({
    once: execFeaturesOnce,
    onChangeImmediate: execFeaturesImmediately,
    onChange: execFeatures
});

// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------------------------------------------
// --- Twitter Specific code --- //


const downloadedImages = new LS("ujs-twitter-downloaded-images-names");
const downloadedImageTweetIds = new LS("ujs-twitter-downloaded-image-tweet-ids");
const downloadedVideoTweetIds = new LS("ujs-twitter-downloaded-video-tweet-ids");

// --- Twitter.Features --- //
function hoistFeatures() {
    class Features {
        static goFromMobileToMainSite() {
            if (location.href.startsWith("https://mobile.twitter.com/")) {
                location.href = location.href.replace("https://mobile.twitter.com/", "https://twitter.com/");
            }
            // TODO: add #redirected, remove by timer // to prevent a potential infinity loop
        }
        static _ImageHistory = class {
            static getImageNameFromUrl(url) {
                const _url = new URL(url);
                const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;
                return filename.match(/^[^\.]+/)[0]; // remove extension
            }
            static isDownloaded({id, url}) {
                if (imagesHistoryBy === "TWEET_ID") {
                    return downloadedImageTweetIds.hasItem(id);
                } else if (imagesHistoryBy === "IMAGE_NAME") {
                    const name = Features._ImageHistory.getImageNameFromUrl(url);
                    return downloadedImages.hasItem(name);
                }
            }
            static async markDownloaded({id, url}) {
                if (imagesHistoryBy === "TWEET_ID") {
                    await downloadedImageTweetIds.pushItem(id);
                } else if (imagesHistoryBy === "IMAGE_NAME") {
                    const name = Features._ImageHistory.getImageNameFromUrl(url);
                    await downloadedImages.pushItem(name);
                }
            }
        }
        static async imagesHandler() {
            const images = document.querySelectorAll("img");
            for (const img of images) {

                if (img.width < 200 || img.dataset.handled) {
                    continue;
                }
                verbose && console.log(img, img.width);

                img.dataset.handled = "true";

                const btn = document.createElement("div");
                btn.classList.add("ujs-btn-download");
                btn.dataset.url = img.src;

                btn.addEventListener("click", Features._imageClickHandler);

                let anchor = getNearestElementByType(img, "a");
                // if an image is _opened_ "https://twitter.com/UserName/status/1234567890123456789/photo/1" [fake-url]
                if (!anchor) {
                    anchor = img.parentNode;
                }
                anchor.append(btn);

                const downloaded = Features._ImageHistory.isDownloaded({
                    id: Tweet.of(btn).id,
                    url: btn.dataset.url
                });
                if (downloaded) {
                    btn.classList.add("ujs-already-downloaded");
                }
            }
        }
        static async _imageClickHandler(event) {
            event.preventDefault();
            event.stopImmediatePropagation();

            const btn = event.currentTarget;
            const url = handleImgUrl(btn.dataset.url);
            verbose && console.log(url);

            function handleImgUrl(url) {
                const urlObj = new URL(url);
                urlObj.searchParams.set("name", "orig");
                return urlObj.toString();
            }

            const {id, author} = Tweet.of(btn);
            verbose && console.log(id, author);

            async function safeFetchResource(url) {
                let fallbackUsed = false;
                retry:
                while (true) {
                    try {
                        return await fetchResource(url);
                    } catch (e) {
                        if (fallbackUsed) {
                            throw "Fallback URL failed";
                        }
                        const _url = new URL(url);
                        _url.searchParams.set("name", "4096x4096");
                        url = _url.href;
                        verbose && console.warn("[safeFetchResource] Fallback URL:", url);
                        fallbackUsed = true;
                        continue retry;
                    }
                }

            }

            btn.classList.add("ujs-downloading");
            const {blob, lastModifiedDate, extension, name} = await safeFetchResource(url);

            const filename = `[twitter] ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
            download(blob, filename, url);

            const downloaded = btn.classList.contains("already-downloaded");
            if (!downloaded) {
                await Features._ImageHistory.markDownloaded({id, url});
            }
            btn.classList.remove("ujs-downloading");
            btn.classList.add("ujs-downloaded");
        }


        static async videoHandler() {
            const videos = document.querySelectorAll("video");

            for (const vid of videos) {
                if (vid.dataset.handled) {
                    continue;
                }
                verbose && console.log(vid);
                vid.dataset.handled = "true";

                const btn = document.createElement("div");
                btn.classList.add("ujs-btn-download");
                btn.classList.add("ujs-video");
                btn.addEventListener("click", Features._videoClickHandler);

                let elem = vid.parentNode.parentNode.parentNode;
                elem.after(btn);

                const id = Tweet.of(btn).id;
                const downloaded = downloadedVideoTweetIds.hasItem(id);
                if (downloaded) {
                    btn.classList.add("ujs-already-downloaded");
                }
            }
        }
        static async _videoClickHandler(event) {
            event.preventDefault();
            event.stopImmediatePropagation();

            const btn = event.currentTarget;
            const {id, author} = Tweet.of(btn);
            const video = await API.getVideoInfo(id); // {bitrate, content_type, url}
            verbose && console.log(video);

            btn.classList.add("ujs-downloading");
            const url = video.url;
            const {blob, lastModifiedDate, extension, name} = await fetchResource(url);

            const filename = `[twitter] ${author}—${lastModifiedDate}—${id}—${name}.${extension}`;
            download(blob, filename, url);

            const downloaded = btn.classList.contains("ujs-already-downloaded");
            if (!downloaded) {
                await downloadedVideoTweetIds.pushItem(id);
            }
            btn.classList.remove("ujs-downloading");
            btn.classList.add("ujs-downloaded");
        }


        static addRequiredCSS() {
            addCSS(getUserScriptCSS());
        }

        // it depends of `directLinks()` use only it after `directLinks()`
        static handleTitle(title) {          
            if (!I18N.QUOTES) { // Unsupported lang, no QUOTES, ON_TWITTER, TWITTER constants
                return;
            }
          
            // if not a opened tweet
            if (!location.href.match(/twitter\.com\/[^\/]+\/status\/\d+/)) {
                return;
            }

            let titleText = title || document.title;
            if (titleText === Features.lastHandledTitle) {
                return;
            }
            Features.originalTitle = titleText;

            const [OPEN_QUOTE, CLOSE_QUOTE] = I18N.QUOTES;
            const urlsToReplace = [
                ...titleText.matchAll(new RegExp(`https:\\/\\/t\\.co\\/[^ ${CLOSE_QUOTE}]+`, "g"))
            ].map(el => el[0]);
            // the last one may be the URL to the tweet // or to an embedded shared URL

            const map = new Map();
            const anchors = document.querySelectorAll(`a[data-redirect^="https://t.co/"]`);
            for (const anchor of anchors) {
                if (urlsToReplace.includes(anchor.dataset.redirect)) {
                    map.set(anchor.dataset.redirect, anchor.href);
                }
            }

            const lastUrl = urlsToReplace.slice(-1)[0];
            let lastUrlIsAttachment = false;
            let attachmentDescription = "";
            if (!map.has(lastUrl)) {
                const a = document.querySelector(`a[href="${lastUrl}?amp=1"]`);
                if (a) {
                    lastUrlIsAttachment = true;
                    attachmentDescription = document.querySelectorAll(`a[href="${lastUrl}?amp=1"]`)[1].innerText;
                    attachmentDescription = attachmentDescription.replaceAll("\n", " — ");
                }
            }


            for (const [key, value] of map.entries()) {
                titleText = titleText.replaceAll(key, value + ` (${key})`);
            }

            titleText = titleText.replace(new RegExp(`${I18N.ON_TWITTER}(?= ${OPEN_QUOTE})`), ":");
            titleText = titleText.replace(new RegExp(`(?<=${CLOSE_QUOTE}) \\\/ ${I18N.TWITTER}$`), "");
            if (!lastUrlIsAttachment) {
                const regExp = new RegExp(`(?<short> https:\\/\\/t\\.co\\/.{6,14})${CLOSE_QUOTE}$`);
                titleText = titleText.replace(regExp, (match, p1, p2, offset, string) => `${CLOSE_QUOTE} —${p1}`);
            } else {
                titleText = titleText.replace(lastUrl, `${lastUrl} (${attachmentDescription})`);
            }
            document.title = titleText; // Note: some characters will be removed automatically (`\n`, extra spaces)
            Features.lastHandledTitle = document.title;
        }
        static lastHandledTitle = "";
        static originalTitle = "";

        static directLinks() {
            const anchors = xpathAll(`.//a[@dir="ltr" and child::span and not(@data-handled)]`);
            for (const anchor of anchors) {
                const redirectUrl = new URL(anchor.href);
                anchor.dataset.redirect = redirectUrl.origin + redirectUrl.pathname; // remove "?amp=1"
                anchor.dataset.handled = "true";

                const nodes = xpathAll(`./span[text() != "…"]|./text()`, anchor);
                const url = nodes.map(node => node.textContent).join("");
                anchor.href = url;
                anchor.rel = "nofollow noopener noreferrer";
            }
            if (anchors.length) {
                Features.handleTitle(Features.originalTitle);
            }
        }

        // Do NOT throttle it
        static expandSpoilers() {
            const main = document.querySelector("main[role=main]");
            if (!main) {
                return;
            }
          
            if (!I18N.YES_VIEW_PROFILE) { // Unsupported lang, no YES_VIEW_PROFILE, SHOW_NUDITY, VIEW constants
                return;
            }

            const a = main.querySelectorAll("[data-testid=primaryColumn] [role=button]");
            if (a) {
                const elems = [...a];
                const button = elems.find(el => el.textContent === I18N.YES_VIEW_PROFILE);
                if (button) {
                    button.click();
                }
                
                // "Content warning: Nudity"
                // "The Tweet author flagged this Tweet as showing sensitive content.""
                // "Show"
                const buttonShow = elems.find(el => el.textContent === I18N.SHOW_NUDITY);
                if (buttonShow) {
                    //const verifing = a.previousSibling.textContent.includes("Nudity"); // todo?
                    //if (verifing) {
                        buttonShow.click();
                    //}
                }
            }
            
            // todo: expand spoiler commentary in photo view mode (.../photo/1)
            const b = main.querySelectorAll("article [role=presentation] div[role=button]");
            if (b) {
                const elems = [...b];
                const buttons = elems.filter(el => el.textContent === I18N.VIEW);
                if (buttons.length) {
                    buttons.forEach(el => el.click());
                }
            }
        }

        static hideSignUpSection() { // "New to Twitter?"
            if (!I18N.SIGNUP) {// Unsupported lang, no SIGNUP constant
                return;
            }
            const elem = document.querySelector(`section[aria-label="${I18N.SIGNUP}"][role=region]`);
            if (elem) {
                elem.parentNode.classList.add("ujs-hidden");
            }
        }

        // Call it once.
        // "Don’t miss what’s happening" if you are not logged in.
        // It looks that `#layers` is used only for this bar.
        static hideSignUpBottomBarAndMessages(doNotPlayVideosAutomatically) {
            if (doNotPlayVideosAutomatically) {
                addCSS(`
                    #layers > div:nth-child(1) {
                        display: none;
                    }
                `);
            } else {
                addCSS(`
                    #layers > div:nth-child(1) {
                        height: 1px;
                        opacity: 0;
                    }
                `);
            }
        }

        // "Trends for you"
        static hideTrends() {
            if (!I18N.TRENDS) { // Unsupported lang, no TRENDS constant
                return;
            }
            addCSS(`
                [aria-label="${I18N.TRENDS}"]
                {
                    display: none;
                }
            `);
        }
        static highlightVisitedLinks() {
            addCSS(`
                a:visited {
                    color: darkorange;
                }
            `);
        }

        // Hides "TOPICS TO FOLLOW" only in the right column, NOT in timeline.
        // Use it once. To prevent blinking.
        static hideTopicsToFollowInstantly() {
            if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
                return;
            }
            addCSS(`
                div[aria-label="${I18N.TOPICS_TO_FOLLOW}"] {
                    display: none;
                }
            `);
        }
        // Hides container and "separator line"
        static hideTopicsToFollow() {
            if (!I18N.TOPICS_TO_FOLLOW) { // Unsupported lang, no TOPICS_TO_FOLLOW constant
                return;
            }
          
            const elem = xpath(`.//section[@role="region" and child::div[@aria-label="${I18N.TOPICS_TO_FOLLOW}"]]/../..`);
            if (!elem) {
                return;
            }
            elem.classList.add("ujs-hidden");

            elem.previousSibling.classList.add("ujs-hidden"); // a "separator line" (empty element of "TRENDS", for example)
            // in fact it's a hack // todo rework // may hide "You might like" section [bug]
        }

        // todo split to two methods
        // todo fix it, currently it works questionably
        // not tested with non eng langs
        static footerHandled = false;
        static hideAndMoveFooter() { // "Terms of Service   Privacy Policy   Cookie Policy"
            let footer = document.querySelector(`main[role=main] nav[aria-label=${I18N.FOOTER}][role=navigation]`);
            const nav  = document.querySelector("nav[aria-label=Primary][role=navigation]"); // I18N."Primary" [?]

            if (footer) {
                footer = footer.parentNode;
                const separatorLine = footer.previousSibling;

                if (Features.footerHandled) {
                    footer.remove();
                    separatorLine.remove();
                    return;
                }

                nav.append(separatorLine);
                nav.append(footer);
                footer.classList.add("ujs-show-on-hover");
                separatorLine.classList.add("ujs-show-on-hover");

                Features.footerHandled = true;
            }
        }
    }
    return Features;
}

// --- Twitter.RequiredCSS --- //
function getUserScriptCSS() {
    const labelText = I18N.IMAGE || "Image";
    const css = `
        .ujs-hidden {
            display: none;
        }
        .ujs-no-scroll {
            overflow: hidden;
        }
        
        .ujs-show-on-hover:hover {
            opacity: 1;
            transition: opacity 1s ease-out 0.1s;
        }
        .ujs-show-on-hover {
            opacity: 0;
            transition: opacity 0.5s ease-out;
        }
        
        .ujs-btn-download {
            cursor: pointer;
            top: 0.5em;
            left: 0.5em;
            width: 33px;
            height: 33px;
            background: #e0245e; /*red*/
            opacity: 0;
            position: absolute;
            border-radius: 0.3em;
            background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
        }
        article[role=article]:hover .ujs-btn-download {
            opacity: 1;
        }
        div[aria-label="${labelText}"]:hover .ujs-btn-download {
            opacity: 1;
        }
        
        .ujs-btn-download.ujs-downloaded {
            background: #4caf50; /*green*/
            background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
            opacity: 1;
        }
        .ujs-btn-download.ujs-video {
            left: calc(0.5em + 33px + 3px);
        }
        article[role=article]:hover .ujs-already-downloaded:not(.ujs-downloaded) {
            background: #1da1f2; /*blue*/
            background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
        }
        div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded) {
            background: #1da1f2; /*blue*/
            background-image: linear-gradient(to top, rgba(0,0,0,0.15), rgba(0,0,0,0.05));
        }
        
        /* -------------------------------------------------------- */
        /* Shadow the button on hover, active and while downloading */
        .ujs-btn-download:hover {
            background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
        }
        .ujs-btn-download:active {
            background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
        }
        .ujs-btn-download.ujs-downloading {
            background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
        }
        
        article[role=article]:hover  .ujs-already-downloaded:not(.ujs-downloaded):hover {
            background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
        }
        article[role=article]:hover  .ujs-already-downloaded:not(.ujs-downloaded):active {
            background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
        }
        article[role=article]:hover  .ujs-already-downloaded:not(.ujs-downloaded).ujs-downloading {
            background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
        }
        
        div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded):hover {
            background-image: linear-gradient(to top, rgba(0,0,0,0.25), rgba(0,0,0,0.05));
        }
        div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded):active {
            background-image: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.25));
        }
        div[aria-label="${labelText}"]:hover .ujs-already-downloaded:not(.ujs-downloaded).ujs-downloading {
            background-image: linear-gradient(to top, rgba(0,0,0,0.45), rgba(0,0,0,0.15));
        }
        
        /* -------------------------------------------------------- */
        
        `;
    return css.replaceAll(" ".repeat(8), "");
}

/*
Features depend on:

addRequiredCSS: IMAGE

expandSpoilers:     YES_VIEW_PROFILE,   SHOW_NUDITY,  VIEW
handleTitle:        QUOTES,             ON_TWITTER,   TWITTER
hideSignUpSection:  SIGNUP
hideTrends:         TRENDS
hideTopicsToFollowInstantly: TOPICS_TO_FOLLOW, 
hideTopicsToFollow:          TOPICS_TO_FOLLOW, 

[unused]
hideAndMoveFooter:  FOOTER
*/

// --- Twitter.LangConstants --- //
function getLanguageConstants() { //todo: "de", "fr"
    const defaultQuotes = [`"`, `"`];

    const SUPPORTED_LANGUAGES = ["en",                     "ru",                     "es",                                 "zh",               "ja",                       ];  
    // texts
    const VIEW                = ["View",                   "Посмотреть",             "Ver",                                "查看",             "表示",                      ];
    const YES_VIEW_PROFILE    = ["Yes, view profile",      "Да, посмотреть профиль", "Sí, ver perfil",                     "是,查看个人资料",   "プロフィールを表示する",      ];    
    const SHOW_NUDITY         = ["Show",                   "Показать",               "Mostrar",                            "显示",              "表示",                     ];
    // aria-label texts
    const IMAGE               = ["Image",                  "Изображение",            "Imagen",                             "图像",              "画像",                     ];
    const SIGNUP              = ["Sign up",                "Зарегистрироваться",     "Regístrate",                         "注册",             "アカウント作成",              ];
    const TRENDS              = ["Timeline: Trending now", "Лента: Актуальные темы", "Cronología: Tendencias del momento", "时间线:当前趋势",   "タイムライン: トレンド",      ];
    const TOPICS_TO_FOLLOW    = ["Timeline: ",             "Лента: ",                "Cronología: ",                       "时间线:", /*[1]*/  "タイムライン: ",  /*[1]*/    ];
    const WHO_TO_FOLLOW       = ["Who to follow",          "Кого читать",            "A quién seguir",                     "推荐关注",          "おすすめユーザー"             ];
    const FOOTER              = ["Footer",                 "Нижний колонтитул",      "Pie de página",                      "页脚",             "フッター",                   ];
    // *1 — it's a suggestion, need to recheck. But I can't find a page where I can check it. Was it deleted?  
    // document.title "{AUTHOR}{ON_TWITTER} {QUOTES[0]}{TEXT}{QUOTES[1]} / {TWITTER}"
    const QUOTES              = [defaultQuotes,            [`«`, `»`],               defaultQuotes,                        defaultQuotes,      [`「`, `」`],                ];
    const ON_TWITTER          = [" on Twitter:",           " в Твиттере:",           " en Twitter:",                       " 在 Twitter:",      "さんはTwitterを使っています", ];
    const TWITTER             = ["Twitter",                "Твиттер",                "Twitter",                            "Twitter",          "Twitter",                  ];
    
  
    const lang = document.querySelector("html").getAttribute("lang");
    const langIndex = SUPPORTED_LANGUAGES.indexOf(lang);

    return {
        SUPPORTED_LANGUAGES,
        VIEW: VIEW[langIndex],
        YES_VIEW_PROFILE: YES_VIEW_PROFILE[langIndex],
        SIGNUP: SIGNUP[langIndex],
        TRENDS: TRENDS[langIndex],
        TOPICS_TO_FOLLOW: TOPICS_TO_FOLLOW[langIndex],
        WHO_TO_FOLLOW: WHO_TO_FOLLOW[langIndex],
        FOOTER: FOOTER[langIndex],
        QUOTES: QUOTES[langIndex],
        ON_TWITTER: ON_TWITTER[langIndex],
        TWITTER: TWITTER[langIndex],
        IMAGE: IMAGE[langIndex],
        SHOW_NUDITY: SHOW_NUDITY[langIndex],
    }
}

// --- Twitter.Tweet --- //
function hoistTweet() {
    class Tweet {
        constructor(elem) {
            this.elem = elem;
            this.url = Tweet.getUrl(elem);
        }
        static of(innerElem) {
            const elem = getParentWithSiblingDataset(innerElem, "testid", "tweet");
            if (!elem) { // opened image
                verbose && console.log("no-tweet elem");
            }
            return new Tweet(elem);
        }
        static getUrl(elem) {
            if (!elem) { // if opened image
                return location.href;
            }

            const tweetAnchor = [...elem.querySelectorAll("a")].find(el => {
                return el.childNodes[0]?.nodeName === "TIME";
            });

            if (tweetAnchor) {
                return tweetAnchor.href;
            }
            // else if selected tweet
            return location.href;
        }

        get author() {
            return this.url.match(/(?<=twitter\.com\/).+?(?=\/)/)?.[0];
        }
        get id() {
            return this.url.match(/(?<=\/status\/)\d+/)?.[0];
        }
    }
    return Tweet;
}

// --- Twitter.API --- //
function hoistAPI() {
    class API {
        static guestToken = getCookie("gt");
        static csrfToken  = getCookie("ct0");  // todo: lazy — not available at the first run
        // Guest/Suspended account Bearer token
        static guestAuthorization = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";

        static async _requestBearerToken() {
            const scriptSrc = [...document.querySelectorAll("script")]
                .find(el => el.src.match(/https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main[\w\d\.]*\.js/)).src;

            let text;
            try {
                text = await (await fetch(scriptSrc)).text();
            } catch (e) {
                console.error(e, scriptSrc);
                throw e;
            }

            const authorizationKey = text.match(/(?<=")AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D.+?(?=")/)[0];
            const authorization = `Bearer ${authorizationKey}`;

            return authorization;
        }

        static async getAuthorization() {
            if (!API.authorization) {
                API.authorization = await API._requestBearerToken();
            }
            return API.authorization;
        }

        // @return {bitrate, content_type, url}
        static async getVideoInfo(tweetId) {
            // Hm... it always is the same. Even for a logged user.
            // const authorization = API.guestToken ? API.guestAuthorization : await API.getAuthorization();
            const authorization = API.guestAuthorization;

            // for debug
            verbose && sessionStorage.setItem("guestAuthorization", API.guestAuthorization);
            verbose && sessionStorage.setItem("authorization", API.authorization);
            verbose && sessionStorage.setItem("x-csrf-token", API.csrfToken);
            verbose && sessionStorage.setItem("x-guest-token", API.guestToken);

            // const url = new URL(`https://api.twitter.com/2/timeline/conversation/${tweetId}.json`); // only for suspended/anon
            const url = new URL(`https://twitter.com/i/api/2/timeline/conversation/${tweetId}.json`);
            url.searchParams.set("tweet_mode", "extended");

            const headers = new Headers({
                authorization,
                "x-csrf-token": API.csrfToken,
            });
            if (API.guestToken) {
                headers.append("x-guest-token", API.guestToken);
            } else { // may be skipped
                headers.append("x-twitter-active-user", "yes");
                headers.append("x-twitter-auth-type", "OAuth2Session");
            }

            const _url = url.toString();
            let json;
            try {
                const response = await fetch(_url, {headers});
                json = await response.json();
            } catch (e) {
                console.error(e, _url);
                throw e;
            }

            verbose && console.warn(JSON.stringify(json, null, " "));
            // 429 - [{code: 88, message: "Rate limit exceeded"}] — for suspended accounts


            const tweetData = json.globalObjects.tweets[tweetId];
            const videoVariants = tweetData.extended_entities.media[0].video_info.variants;
            verbose && console.log(videoVariants);


            const video = videoVariants
                .filter(el => el.bitrate !== undefined) // if content_type: "application/x-mpegURL" // .m3u8
                .reduce((acc, cur) => cur.bitrate > acc.bitrate ? cur : acc);
            return video;
        }
    }
    return API;
}

// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------------------------------------------
// --- Common Utils --- //

// --- LocalStorage util class --- //
function hoistLS(settings = {}) {
    const {
        verbose, // debug "messages" in the document.title
    } = settings;
    class LS {
        constructor(name) {
            this.name = name;
        }
        getItem(defaultValue) {
            return LS.getItem(this.name, defaultValue);
        }
        setItem(value) {
            LS.setItem(this.name, value);
        }
        removeItem() {
            LS.removeItem(this.name);
        }
        async pushItem(value) {  // array method
            await LS.pushItem(this.name, value);
        }
        async popItem(value) {   // array method
            await LS.popItem(this.name, value);
        }
        hasItem(value) {         // array method
            return LS.hasItem(this.name, value);
        }

        static getItem(name, defaultValue) {
            const value = localStorage.getItem(name);
            if (value === undefined) {
                return undefined;
            }
            if (value === null) { // when there is no such item
                LS.setItem(name, defaultValue);
                return defaultValue;
            }
            return JSON.parse(value);
        }
        static setItem(name, value) {
            localStorage.setItem(name, JSON.stringify(value));
        }
        static removeItem(name) {
            localStorage.removeItem(name);
        }
        static async pushItem(name, value) {
            const array = LS.getItem(name, []);
            array.push(value);
            LS.setItem(name, array);

            //sanity check
            await sleep(50);
            if (!LS.hasItem(name, value)) {
                if (verbose) {
                    document.title = "🟥" + document.title;
                }
                await LS.pushItem(name, value);
            }
        }
        static async popItem(name, value) { // remove from an array
            const array = LS.getItem(name, []);
            if (array.indexOf(value) !== -1) {
                array.splice(array.indexOf(value), 1);
                LS.setItem(name, array);

                //sanity check
                await sleep(50);
                if (LS.hasItem(name, value)) {
                    if (verbose) {
                        document.title = "🟨" + document.title;
                    }
                    await LS.popItem(name, value);
                }
            }
        }
        static hasItem(name, value) { // has in array
            const array = LS.getItem(name, []);
            return array.indexOf(value) !== -1;
        }
    }
    return LS;
}

// --- Just groups them in a function for the convenient code looking --- //
function getUtils({verbose}) {
    function sleep(time) {
        return new Promise(resolve => setTimeout(resolve, time));
    }

    async function fetchResource(url) {
        try {
            const response = await fetch(url, {
                cache: "force-cache",
            });
            const lastModifiedDateSeconds = response.headers.get("last-modified");
            const contentType = response.headers.get("content-type");

            const lastModifiedDate = dateToDayDateString(lastModifiedDateSeconds);
            const extension = extensionFromMime(contentType);
            const blob = await response.blob();

            // https://pbs.twimg.com/media/AbcdEFgijKL01_9?format=jpg&name=orig                                     -> AbcdEFgijKL01_9
            // https://pbs.twimg.com/ext_tw_video_thumb/1234567890123456789/pu/img/Ab1cd2345EFgijKL.jpg?name=orig   -> Ab1cd2345EFgijKL.jpg
            // https://video.twimg.com/ext_tw_video/1234567890123456789/pu/vid/946x720/Ab1cd2345EFgijKL.mp4?tag=10  -> Ab1cd2345EFgijKL.mp4
            const _url = new URL(url);
            const {filename} = (_url.origin + _url.pathname).match(/(?<filename>[^\/]+$)/).groups;

            const {name} = filename.match(/(?<name>^[^\.]+)/).groups;
            return {blob, lastModifiedDate, contentType, extension, name};
        } catch (error) {
            verbose && console.error("[fetchResource]", url, error);
            throw error;
        }
    }

    function extensionFromMime(mimeType) {
        let extension = mimeType.match(/(?<=\/).+/)[0];
        extension = extension === "jpeg" ? "jpg" : extension;
        return extension;
    }

    // the original download url will be posted as hash of the blob url, so you can check it in the download manager's history
    function download(blob, name, url) {
        const anchor = document.createElement("a");
        anchor.setAttribute("download", name || "");
        const blobUrl = URL.createObjectURL(blob);
        anchor.href = blobUrl + (url ? ("#" + url) : "");
        anchor.click();
        setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
    }

    // "Sun, 10 Jan 2021 22:22:22 GMT" -> "2021.01.10"
    function dateToDayDateString(dateValue, utc = true) {
        const _date = new Date(dateValue);
        function pad(str) {
            return str.toString().padStart(2, "0");
        }
        const _utc = utc ? "UTC" : "";
        const year  = _date[`get${_utc}FullYear`]();
        const month = _date[`get${_utc}Month`]() + 1;
        const date  = _date[`get${_utc}Date`]();

        return year + "." + pad(month) + "." + pad(date);
    }


    function addCSS(css) {
        const styleElem = document.createElement("style");
        styleElem.textContent = css;
        document.body.append(styleElem);
        return styleElem;
    }


    function getCookie(name) {
        verbose && console.log(document.cookie);
        const regExp = new RegExp(`(?<=${name}=)[^;]+`);
        return document.cookie.match(regExp)?.[0];
    }

    function throttle(runnable, time = 50) {
        let waiting = false;
        let queued = false;
        let context;
        let args;

        return function() {
            if (!waiting) {
                waiting = true;
                setTimeout(function() {
                    if (queued) {
                        runnable.apply(context, args);
                        context = args = undefined;
                    }
                    waiting = queued = false;
                }, time);
                return runnable.apply(this, arguments);
            } else {
                queued = true;
                context = this;
                args = arguments;
            }
        }
    }
    function throttleWithResult(func, time = 50) {
        let waiting = false;
        let args;
        let context;
        let timeout;
        let promise;

        return async function() {
            if (!waiting) {
                waiting = true;
                timeout = new Promise(async resolve => {
                    await sleep(time);
                    waiting = false;
                    resolve();
                });
                return func.apply(this, arguments);
            } else {
                args = arguments;
                context = this;
            }

            if (!promise) {
                promise = new Promise(async resolve => {
                    await timeout;
                    const result = func.apply(context, args);
                    args = context = promise = undefined;
                    resolve(result);
                });
            }
            return promise;
        }
    }


    function xpath(path, node = document) {
        let xPathResult = document.evaluate(path, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
        return xPathResult.singleNodeValue;
    }
    function xpathAll(path, node = document) {
        let xPathResult = document.evaluate(path, node, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
        const nodes = [];
        try {
            let node = xPathResult.iterateNext();

            while (node) {
                nodes.push(node);
                node = xPathResult.iterateNext();
            }
            return nodes;
        }
        catch (e) {
            // todo need investigate it
            console.error(e); // "The document has mutated since the result was returned."
            return [];
        }
    }


    function getNearestElementByType(elem, type) {
        const parent = elem.parentNode;
        if (parent === document) {
            return null;
        }
        if (parent.nodeName === type.toUpperCase()) {
            return parent;
        }
        return getNearestElementByType(parent, type);
    }
    function getParentWithSiblingDataset(node, name, value) {
        const parent = node.parentNode;
        if (parent === document) {
            return null;
        }
        // console.log(parent, parent.childNodes);
        const elem = [...parent.childNodes].find(el => {
            if (el.dataset?.[name] === value) {
                return true;
            }
        });
        if (!elem) {
            return getParentWithSiblingDataset(parent, name, value);
        }
        return parent;
    }

    return {
        sleep, fetchResource, extensionFromMime, download, dateToDayDateString,
        addCSS,
        getCookie,
        throttle, throttleWithResult,
        xpath, xpathAll,
        getNearestElementByType, getParentWithSiblingDataset,
    }
}


// ---------------------------------------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------------------------------------------