InoReader restore lost images and videos

Loads new images and videos from VK and Telegram in InoReader articles

// ==UserScript==
// @name         InoReader restore lost images and videos
// @namespace    http://tampermonkey.net/
// @version      0.0.10
// @description  Loads new images and videos from VK and Telegram in InoReader articles
// @author       Kenya-West
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @match        https://*.inoreader.com/feed*
// @match        https://*.inoreader.com/article*
// @match        https://*.inoreader.com/folder*
// @match        https://*.inoreader.com/starred*
// @match        https://*.inoreader.com/library*
// @match        https://*.inoreader.com/dashboard*
// @match        https://*.inoreader.com/web_pages*
// @match        https://*.inoreader.com/trending*
// @match        https://*.inoreader.com/commented*
// @match        https://*.inoreader.com/recent*
// @match        https://*.inoreader.com/search*
// @match        https://*.inoreader.com/channel*
// @match        https://*.inoreader.com/teams*
// @match        https://*.inoreader.com/dashboard*
// @match        https://*.inoreader.com/pocket*
// @match        https://*.inoreader.com/liked*
// @match        https://*.inoreader.com/tags*
// @icon         https://inoreader.com/favicon.ico?v=8
// @license      MIT
// ==/UserScript==
// @ts-check

(function () {
    "use strict";

    /**
     * @typedef {Object} appConfig
     * @property {Array<{
     *     prefixUrl: string,
     *     corsType: "direct" | "corsSh" | "corsAnywhere" | "corsFlare",
     *     token?: string,
     *     hidden?: boolean
     * }>} corsProxies
     */
    const appConfig = {
        corsProxies: [
            {
                prefixUrl: "https://corsproxy.io/?",
                corsType: "direct",
            },
            {
                prefixUrl: "https://proxy.cors.sh/",
                corsType: "corsSh",
                token: undefined,
                hidden: true,
            },
            {
                prefixUrl: "https://cors-anywhere.herokuapp.com/",
                corsType: "corsAnywhere",
                hidden: true,
            },
            {
                prefixUrl: "https://cors-1.kenyawest.workers.dev/?upstream_url=",
                corsType: "corsFlare",
            },
        ],
    };

    const appState = {
        readerPaneMutationObserverLinked: false,
        restoreImagesInListView: false,
        restoreImagesInArticleView: false,
    };

    // Select the node that will be observed for mutations
    const targetNode = document.body;

    // Options for the observer (which mutations to observe)
    const mutationObserverGlobalConfig = {
        attributes: false,
        childList: true,
        subtree: true,
    };

    const querySelectorPathArticleRoot = ".article_full_contents .article_content";

    /**
     * Callback function to execute when mutations are observed
     * @param {MutationRecord[]} mutationsList - List of mutations observed
     * @param {MutationObserver} observer - The MutationObserver instance
     */
    const callback = function (mutationsList, observer) {
        for (let i = 0; i < mutationsList.length; i++) {
            if (mutationsList[i].type === "childList") {
                mutationsList[i].addedNodes.forEach(function (node) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (appState.restoreImagesInListView) {
                            restoreImagesInArticleList(node);
                        }
                        runRestoreImagesInArticleView(node);
                    }
                });
            }
        }
    };

    function registerCommands() {
        let enableImageRestoreInListViewCommand;
        let disableImageRestoreInListViewCommand;
        let enableImageRestoreInArticleViewCommand;
        let disableImageRestoreInArticleViewCommand;

        const restoreImageListView = localStorage.getItem("restoreImageListView") ?? "false";
        const restoreImageArticleView = localStorage.getItem("restoreImageArticleView") ?? "true";

        if (restoreImageListView === "false") {
            appState.restoreImagesInListView = false;
            // @ts-ignore
            enableImageRestoreInListViewCommand = GM_registerMenuCommand("Enable image restore in article list", () => {
                localStorage.setItem("restoreImageListView", "true");
                appState.restoreImagesInListView = true;
                if (enableImageRestoreInListViewCommand) {
                    unregisterAllCommands();
                    registerCommands();
                }
            });
        } else {
            appState.restoreImagesInListView = true;
            // @ts-ignore
            disableImageRestoreInListViewCommand = GM_registerMenuCommand("Disable image restore in article list", () => {
                localStorage.setItem("restoreImageListView", "false");
                appState.restoreImagesInListView = false;
                if (disableImageRestoreInListViewCommand) {
                    unregisterAllCommands();
                    registerCommands();
                }
            });
        }

        if (restoreImageArticleView === "false") {
            appState.restoreImagesInArticleView = false;
            // @ts-ignore
            enableImageRestoreInArticleViewCommand = GM_registerMenuCommand("Enable image restore in article view", () => {
                localStorage.setItem("restoreImageArticleView", "true");
                appState.restoreImagesInArticleView = true;
                if (enableImageRestoreInArticleViewCommand) {
                    unregisterAllCommands();
                    registerCommands();
                }
            });
        } else {
            appState.restoreImagesInArticleView = true;
            // @ts-ignore
            disableImageRestoreInArticleViewCommand = GM_registerMenuCommand("Disable image restore in article view", () => {
                localStorage.setItem("restoreImageArticleView", "false");
                appState.restoreImagesInArticleView = false;
                if (disableImageRestoreInArticleViewCommand) {
                    unregisterAllCommands();
                    registerCommands();
                }
            });
        }

        function unregisterCommand(command) {
            // @ts-ignore
            GM_unregisterMenuCommand(command);
        }

        function unregisterAllCommands() {
            // @ts-ignore
            GM_unregisterMenuCommand(enableImageRestoreInListViewCommand);
            // @ts-ignore
            GM_unregisterMenuCommand(disableImageRestoreInListViewCommand);
            // @ts-ignore
            GM_unregisterMenuCommand(enableImageRestoreInArticleViewCommand);
            // @ts-ignore
            GM_unregisterMenuCommand(disableImageRestoreInArticleViewCommand);
        }
    }

    //
    //
    // FIRST PART - RESTORE IMAGES IN ARTICLE LIST
    //
    //
    //

    /**
     *
     * @param {Node} node
     * @returns {void}
     */
    function restoreImagesInArticleList(node) {
        /**
         * @type {MutationObserver | undefined}
         */
        let tmObserverImageRestoreReaderPane;
        const readerPane = document.body.querySelector("#reader_pane");
        if (readerPane) {
            if (!appState.readerPaneMutationObserverLinked) {
                appState.readerPaneMutationObserverLinked = true;

                /**
                 * Callback function to execute when mutations are observed
                 * @param {MutationRecord[]} mutationsList - List of mutations observed
                 * @param {MutationObserver} observer - The MutationObserver instance
                 */
                const callback = function (mutationsList, observer) {
                    for (let mutation of mutationsList) {
                        if (mutation.type === "childList") {
                            mutation.addedNodes.forEach(function (node) {
                                if (node.nodeType === Node.ELEMENT_NODE) {
                                    setTimeout(() => {
                                        start(node);
                                    }, 500);
                                }
                            });
                        }
                    }
                };

                // Options for the observer (which mutations to observe)
                const mutationObserverLocalConfig = {
                    attributes: false,
                    childList: true,
                    subtree: false,
                };

                // Create an observer instance linked to the callback function
                tmObserverImageRestoreReaderPane = new MutationObserver(callback);

                // Start observing the target node for configured mutations
                tmObserverImageRestoreReaderPane.observe(readerPane, mutationObserverLocalConfig);
            }
        } else {
            appState.readerPaneMutationObserverLinked = false;
            tmObserverImageRestoreReaderPane?.disconnect();
        }

        /**
         *
         * @param {Node} node
         */
        function start(node) {
            /**
             * @type {Node & HTMLDivElement}
             */
            // @ts-ignore
            const element = node;
            if (element.hasChildNodes() && element.id.includes("article_") && element.classList.contains("ar")) {
                const imageElement = getImageElement(element);
                if (imageElement) {
                    const telegramPostUrl = getTelegramPostUrl(element);
                    const imageUrl = getImageLink(imageElement);
                    if (imageUrl) {
                        testImageLink(imageUrl).then(async () => {
                            const tgPost = await commonFetchTgPostEmbed(telegramPostUrl);
                            await replaceImageSrc(imageElement, tgPost);
                            await placeMediaCount(element, tgPost);
                        });
                    }
                }
            }
        }

        /**
         *
         * @param {Node & HTMLDivElement} node
         * @returns {HTMLDivElement | null}
         */
        function getImageElement(node) {
            const nodeElement = node;
            /**
             * @type {HTMLDivElement | null}
             */
            const divImageElement = nodeElement.querySelector("a[href*='t.me'] > div[style*='background-image']");
            return divImageElement ?? null;
        }

        /**
         *
         * @param {Node & HTMLDivElement} node
         * @returns {string}
         */
        function getTelegramPostUrl(node) {
            if (!node) {
                return "";
            }
            return getFromNode(node) ?? "";

            /**
             *
             * @param {Node & HTMLDivElement} node
             * @returns {string}
             */
            function getFromNode(node) {
                /**
                 * @type {HTMLDivElement}
                 */
                // @ts-ignore
                const nodeElement = node;
                /**
                 * @type {HTMLAnchorElement | null}
                 */
                const ahrefElement = nodeElement.querySelector("a[href*='t.me']");
                const telegramPostUrl = ahrefElement?.href ?? "";
                // try to get rid of urlsearchparams. If it fails, get rid of the question mark and everything after it
                try {
                    return new URL(telegramPostUrl).origin + new URL(telegramPostUrl).pathname;
                } catch (error) {
                    return telegramPostUrl?.split("?")[0];
                }
            }
        }

        /**
         *
         * @param {HTMLDivElement} div
         */
        function getImageLink(div) {
            const backgroundImageUrl = div?.style.backgroundImage;
            return commonGetUrlFromBackgroundImage(backgroundImageUrl);
        }

        /**
         *
         * @param {string} imageUrl
         * @returns {Promise<void>}
         */
        function testImageLink(imageUrl) {
            return new Promise((resolve, reject) => {
                const img = new Image();
                img.src = imageUrl;
                img.onload = function () {
                    reject();
                };
                img.onerror = function () {
                    resolve();
                };
            });
        }

        /**
         *
         * @param {HTMLDivElement} div
         * @param {Document} tgPost
         * @returns {Promise<void>}
         */
        async function replaceImageSrc(div, tgPost) {
            const doc = tgPost;
            const imgLink = commonGetImgUrlsFromTgPost(doc) ?? [];
            if (imgLink?.length > 0) {
                try {
                    div.style.backgroundImage = `url(${imgLink})`;
                } catch (error) {
                    console.error(`Error parsing the HTML from the telegram post. Error: ${error}`);
                }
            } else {
                console.error("No image link found in the telegram post");
            }
        }

        /**
         * 
         * @param {HTMLDivElement} node
         * @param {Document} tgPost
         */
        async function placeMediaCount(node, tgPost) {
            const mediaCount = commonGetImgUrlsFromTgPost(tgPost);
            if (mediaCount.length > 1) {
                placeElement(mediaCount.length);
            }

            /**
             * @param {string | number} total
             */
            function placeElement(total) {
                // Create the new element
                const mediaCountElement = document.createElement("span");
                mediaCountElement.className = "article_tile_comments";
                mediaCountElement.title = "";
                mediaCountElement.style.backgroundColor = "rgba(0,0,0,0.5)";
                mediaCountElement.style.padding = "0.1rem";
                mediaCountElement.style.borderRadius = "5px";
                mediaCountElement.style.marginLeft = "0.5rem";
                mediaCountElement.textContent = `1/${total}`;

                // Find the target wrapper
                let wrapper = node.querySelector(".article_tile_comments_wrapper.flex");

                // If the wrapper doesn't exist, create it
                if (!wrapper) {
                    wrapper = document.createElement("div");
                    wrapper.className = "article_tile_comments_wrapper flex";

                    // Find the parent element and append the new wrapper to it
                    const parent = node.querySelector(".article_tile_content_wraper");
                    if (parent) {
                        parent.appendChild(wrapper);
                    } else {
                        console.error("Parent element not found");
                        return;
                    }
                }

                // Append the new element to the wrapper
                wrapper.appendChild(mediaCountElement);
            }
        }
    }

    //
    //
    // SECOND PART - RESTORE IMAGES IN ARTICLE VIEW
    //
    //
    //

    /**
     *
     * @param {Node} node
     * @returns {void}
     */
    function runRestoreImagesInArticleView(node) {
        if (!appState.restoreImagesInArticleView) {
            return;
        }
        /**
         * @type {HTMLDivElement}
         */
        // @ts-ignore
        const nodeElement = node;

        /**
         * @type {HTMLDivElement | null}
         */
        const articleRoot = nodeElement?.querySelector(querySelectorPathArticleRoot);
        if (articleRoot) {
            getImageLink(articleRoot);
            getVideoLink(articleRoot);
            return;
        }

        /**
         *
         * @param {HTMLDivElement} articleRoot
         */
        function getImageLink(articleRoot) {
            /**
             * @type {NodeListOf<HTMLAnchorElement> | null}
             */
            const ahrefElementArr = articleRoot.querySelectorAll("a[href*='t.me']:has(img[data-original-src*='cdn-telegram.org'])");
            const telegramPostUrl = commonGetTelegramPostUrl(node);

            ahrefElementArr.forEach((ahrefElement, index) => {
                /**
                 * @type {HTMLImageElement | null}
                 */
                const img = ahrefElement.querySelector("img[data-original-src*='cdn-telegram.org']");
                if (img && telegramPostUrl) {
                    img.onerror = function () {
                        replaceImageSrc(img, telegramPostUrl, index);
                    };
                }
            });
        }

        /**
         *
         * @param {HTMLDivElement} articleRoot
         */
        function getVideoLink(articleRoot) {
            /**
             * @type {NodeListOf<HTMLVideoElement> | null}
             */
            const videos = articleRoot.querySelectorAll("video[poster*='cdn-telegram.org']");
            videos?.forEach((video) => {
                /**
                 * @type {HTMLSourceElement | null}
                 */
                const videoSource = video.querySelector("source");
                const telegramPostUrl = commonGetTelegramPostUrl(node);
                if (videoSource && telegramPostUrl) {
                    if (checkIfArticleRootExistsAndHasSamePostOpened(telegramPostUrl)) {
                        videoSource.onerror = function () {
                            if (checkIfArticleRootExistsAndHasSamePostOpened(telegramPostUrl)) {
                                replaceVideoSrc(videoSource, telegramPostUrl).then(() => {
                                    if (checkIfArticleRootExistsAndHasSamePostOpened(telegramPostUrl)) {
                                        video.load();
                                    }
                                });
                            }
                        };
                    }
                }
            });

            /**
             * 
             * @param {string} telegramPostUrl 
             * @returns 
             */
            function checkIfArticleRootExistsAndHasSamePostOpened(telegramPostUrl) {
                if (document.querySelector(querySelectorPathArticleRoot) && commonGetTelegramPostUrl() === telegramPostUrl) {
                    return true;
                }
                return false;
            }
        }

        /**
         *
         * @param {HTMLImageElement} img
         * @param {string} telegramPostUrl
         */
        async function replaceImageSrc(img, telegramPostUrl, index = 0) {
            const doc = await commonFetchTgPostEmbed(telegramPostUrl);
            const imgLink = commonGetImgUrlsFromTgPost(doc);
            if (!imgLink) {
                return;
            }
            try {
                img.src = imgLink[index] ?? "";
                img.setAttribute("data-original-src", imgLink[index] ?? "");
            } catch (error) {
                console.error(`Error parsing the HTML from the telegram post. Error: ${error}`);
            }
        }

        /**
         *
         * @param {HTMLSourceElement} source
         * @param {string} telegramPostUrl
         * @returns {Promise<void>}
         */
        async function replaceVideoSrc(source, telegramPostUrl) {
            const doc = await commonFetchTgPostEmbed(telegramPostUrl);
            const videoLink = commonGetVideoUrlFromTgPost(doc);
            try {
                source.src = videoLink ?? "";
                return Promise.resolve();
            } catch (error) {
                console.error(`Error parsing the HTML from the telegram post. Error: ${error}`);
                return Promise.reject(error);
            }
        }
    }

    /**
     *
     * @param {string} telegramPostUrl
     * @returns {Promise<Document>}
     */
    async function commonFetchTgPostEmbed(telegramPostUrl) {
        // add ?embed=1 to the end of the telegramPostUrl by constructing URL object
        const telegramPostUrlObject = new URL(telegramPostUrl);
        telegramPostUrlObject.searchParams.append("embed", "1");

        const requestUrl = appConfig.corsProxies[3].prefixUrl ? appConfig.corsProxies[3].prefixUrl + telegramPostUrlObject.toString() : telegramPostUrlObject;
        const response = await fetch(requestUrl);
        try {
            const html = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, "text/html");
            return Promise.resolve(doc);
        } catch (error) {
            console.error(`Error parsing the HTML from the telegram post. Error: ${error}`);
            return Promise.reject(error);
        }
    }

    /**
     *
     * @param {Document} doc
     * @returns {string[]} imageUrl
     */
    function commonGetImgUrlsFromTgPost(doc) {
        const imagesQuerySelectors = [
            ".tgme_widget_message_grouped_layer > a",
            "a[href^='https://t.me/'].tgme_widget_message_photo_wrap",
            ".tgme_widget_message_video_player[href^='https://t.me/'] > i[style*='background-image'].tgme_widget_message_video_thumb",
            ".tgme_widget_message_link_preview > i[style*='background-image'].link_preview_image",
        ];

        const imgUrls = [];

        for (let i = 0; i < imagesQuerySelectors.length; i++) {
            const images = doc.querySelectorAll(imagesQuerySelectors[i]);
            images.forEach((image) => {
                /**
                 * @type {HTMLAnchorElement}
                 */
                // @ts-ignore
                const element = image;
                const imageUrl = mediaElementParsingChooser(element);
                if (imageUrl) {
                    if (!imgUrls.includes(imageUrl)) {
                        imgUrls.push(imageUrl);
                    }
                }
            });
        }

        /**
         * @param {HTMLAnchorElement} element
         *
         * @returns {string | undefined} imageUrl
         */
        function mediaElementParsingChooser(element) {
            let link;

            if (element.classList?.contains("tgme_widget_message_photo_wrap") && element.href?.includes("https://t.me/")) {
                const url = getUrlFromPhoto(element);
                if (url) {
                    link = url;
                }
            } else if (element.classList?.contains("tgme_widget_message_video_thumb") && element.style.backgroundImage?.includes("cdn-telegram.org")) {
                const url = getUrlFromVideo(element);
                if (url) {
                    link = url;
                }
            } else if (element.classList?.contains("link_preview_image") && element.style.backgroundImage?.includes("cdn-telegram.org")) {
                const url = getUrlFromLinkPreview(element);
                if (url) {
                    link = url;
                }
            }

            return link;
        }

        /**
         *
         * @param {HTMLAnchorElement} element
         * @returns {string | undefined}
         */
        function getUrlFromPhoto(element) {
            const backgroundImageUrl = element?.style.backgroundImage;
            return commonGetUrlFromBackgroundImage(backgroundImageUrl);
        }

        /**
         *
         * @param {HTMLAnchorElement} element
         * @returns {string | undefined}
         */
        function getUrlFromVideo(element) {
            const backgroundImageUrl = element?.style.backgroundImage;
            return commonGetUrlFromBackgroundImage(backgroundImageUrl || "");
        }

        /**
         *
         * @param {HTMLElement} element
         * @returns
         */
        function getUrlFromLinkPreview(element) {
            const backgroundImageUrl = element?.style.backgroundImage;
            return commonGetUrlFromBackgroundImage(backgroundImageUrl);
        }

        return imgUrls;
    }

    /**
     *
     * @param {string} backgroundImageUrl
     * @returns {string | undefined}
     */
    function commonGetUrlFromBackgroundImage(backgroundImageUrl) {
        /**
         * @type {string | undefined}
         */
        let imageUrl;
        try {
            imageUrl = backgroundImageUrl?.match(/url\("(.*)"\)/)?.[1];
        } catch (error) {
            imageUrl = backgroundImageUrl?.slice(5, -2);
        }

        if (!imageUrl || imageUrl == "undefined") {
            return;
        }

        if (!imageUrl?.startsWith("http")) {
            console.error(`The image could not be parsed. Image URL: ${imageUrl}`);
            return;
        }
        return imageUrl;
    }

    /**
     *
     * @param {Document} doc
     * @returns {string | undefined} imageUrl
     */
    function commonGetVideoUrlFromTgPost(doc) {
        /**
         * @type {HTMLVideoElement | null}
         */
        const video = doc.querySelector("video[src*='cdn-telegram.org']");
        const videoUrl = video?.src;
        return videoUrl;
    }

    /**
     *
     * @param {Node | undefined} node
     * @returns {string}
     */
    function commonGetTelegramPostUrl(node = undefined) {
        return getFromArticleView() ?? getFromNode(node) ?? "";

        /**
         *
         * @returns {string | undefined}
         */
        function getFromArticleView() {
            /**
             * @type {HTMLAnchorElement | null}
             */
            const element = document.querySelector(".article_title > a[href^='https://t.me/']");
            return element?.href;
        }

        /**
         *
         * @param {Node | undefined} node
         * @returns {string}
         */
        function getFromNode(node) {
            /**
             * @type {HTMLDivElement}
             */
            // @ts-ignore
            const nodeElement = node;
            /**
             * @type {HTMLAnchorElement | null}
             */
            const ahrefElement = nodeElement.querySelector("a[href*='t.me']");
            const telegramPostUrl = ahrefElement?.href ?? "";
            // try to get rid of urlsearchparams. If it fails, get rid of the question mark and everything after it
            try {
                return new URL(telegramPostUrl).origin + new URL(telegramPostUrl).pathname;
            } catch (error) {
                return telegramPostUrl?.split("?")[0];
            }
        }
    }

    // Create an observer instance linked to the callback function
    const tmObserverImageRestore = new MutationObserver(callback);

    // Start observing the target node for configured mutations
    tmObserverImageRestore.observe(targetNode, mutationObserverGlobalConfig);

    registerCommands();
})();