InoReader restore lost images

Loads new images from VK and Telegram in InoReader articles

目前為 2024-03-25 提交的版本,檢視 最新版本

// ==UserScript==
// @name         InoReader restore lost images
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  Loads new images 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*
// @icon         https://inoreader.com/favicon.ico?v=8
// @license      MIT
// ==/UserScript==
// @ts-check

(function () {
    "use strict";

    const appConfig = {
        corsProxy: "https://corsproxy.io/?",
    };

    const appState = {
        readerPaneExists: 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 mutation of mutationsList) {
            if (mutation.type === "childList") {
                mutation.addedNodes.forEach(function (node) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                            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);
        }
    }

    /**
     *
     * @param {Node} node
     * @returns {void}
     */
    function restoreImagesInArticleList(node) {
        const readerPane = document.body.querySelector("#reader_pane");
        if (readerPane) {
            if (!appState.readerPaneExists) {
                appState.readerPaneExists = 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) {
                                    if (appState.restoreImagesInListView) {
                                        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
                const tmObserverImageRestoreReaderPane = new MutationObserver(
                    callback
                );

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

        /**
         *
         * @param {Node} node
         */
        function start(node) {
            const imageElement = getImageElement(node);
            if (imageElement) {
                const telegramPostUrl = getTelegramPostUrl(node);
                const imageUrl = getImageLink(imageElement);
                if (imageUrl) {
                    console.log(
                        `Found an image in the article list. Image URL: ${imageUrl}, Telegram post URL: ${telegramPostUrl}`
                    );
                    testImageLink(imageUrl).then(() => {
                        console.log(`Image loaded. Image URL: ${imageUrl}`);
                        replaceImageSrc(imageElement, telegramPostUrl);
                        console.log(`Replaced the image!`);
                    });
                }
            }
        }

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

        /**
         *
         * @param {Node} node
         */
        function getTelegramPostUrl(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;
            /**
             * @type {string | undefined}
             */
            let imageUrl;
            try {
                imageUrl = backgroundImageUrl?.match(/url\("(.*)"\)/)?.[1];
            } catch (error) {
                imageUrl = backgroundImageUrl?.slice(5, -2);
            }

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

        /**
         *
         * @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 {string} telegramPostUrl
         */
        async function replaceImageSrc(div, telegramPostUrl) {
            const doc = await commonFetchTgPostEmbed(telegramPostUrl);
            const imgLink = commonGetImgUrlFromTgPost(doc);
            try {
                div.style.backgroundImage = `url(${imgLink})`;
            } catch (error) {
                console.error(
                    `Error parsing the HTML from the telegram post. Error: ${error}`
                );
            }
        }
    }

    /**
     *
     * @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);
            return;
        }

        /**
         *
         * @param {HTMLDivElement} articleRoot
         */
        function getImageLink(articleRoot) {
            /**
             * @type {HTMLAnchorElement[]}
             */
            const ahrefElementArr = Array.from(
                articleRoot.querySelectorAll("a[href*='t.me']")
            );
            /**
             * @type {HTMLAnchorElement | null}
             */
            const ahrefElement =
                ahrefElementArr[1] ??
                ahrefElementArr[2] ??
                ahrefElementArr[0] ??
                null;
            /**
             * @type {string | undefined} telegramPostUrl
             */
            let telegramPostUrl = ahrefElement?.href ?? "";
            // try to get rid of urlsearchparams. If it fails, get rid of the question mark and everything after it
            try {
                telegramPostUrl =
                    new URL(telegramPostUrl).origin +
                    new URL(telegramPostUrl).pathname;
            } catch (error) {
                telegramPostUrl = telegramPostUrl?.split("?")[0];
            }

            articleRoot.querySelectorAll("img")?.forEach(
                /**
                 *
                 * @param {HTMLImageElement} img
                 */
                function (img) {
                    const attributes = img.attributes;
                    const originalSrcLink =
                        attributes?.getNamedItem("data-original-src")?.value;
                    if (originalSrcLink?.includes("cdn-telegram.org")) {
                        img.onerror = function () {
                            if (telegramPostUrl) {
                                replaceImageSrc(img, telegramPostUrl);
                            } else {
                                console.error(
                                    `The image could not be loaded and no Telegram post found. Telegram post URL: ${telegramPostUrl}. Img original src: ${originalSrcLink}`
                                );
                            }
                        };
                    }
                }
            );
        }

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

    /**
     *
     * @param telegramPostUrl string
     * @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.corsProxy
            ? appConfig.corsProxy +
              encodeURIComponent(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 | undefined} imageUrl
     */
    function commonGetImgUrlFromTgPost(doc) {
        /**
         * @type {HTMLAnchorElement | null}
         */
        const img = doc.querySelector(
            "a[href^='https://t.me/'].tgme_widget_message_photo_wrap"
        );
        // get background-image url from the style attribute
        const backgroundImageUrl = img?.style.backgroundImage;
        /**
         * @type {string | undefined}
         */
        let imageUrl;
        try {
            imageUrl = backgroundImageUrl?.match(/url\("(.*)"\)/)?.[1];
        } catch (error) {
            imageUrl = backgroundImageUrl?.slice(5, -2);
        }
        // any better way?
        if (!imageUrl?.startsWith("http")) {
            console.error(
                `The image could not be parsed. Image URL: ${imageUrl}`
            );
            return;
        }
        return imageUrl;
    }

    // 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();
})();