// ==UserScript==
// @name InoReader restore lost images and videos
// @namespace http://tampermonkey.net/
// @version 0.0.5
// @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.kenya-west.workers.dev/?upstream_url=",
corsType: "corsFlare"
}
],
};
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);
}
}
//
//
// FIRST PART - RESTORE IMAGES IN ARTICLE LIST
//
//
//
/**
*
* @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
* @returns {string}
*/
function getTelegramPostUrl(node) {
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} 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;
/**
* @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}`
);
}
}
}
//
//
// 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) {
videoSource.onerror = function () {
replaceVideoSrc(videoSource, telegramPostUrl).then(
() => {
video.load();
}
);
};
}
});
}
/**
*
* @param {HTMLImageElement} img
* @param {string} telegramPostUrl
*/
async function replaceImageSrc(img, telegramPostUrl, index = 0) {
const doc = await commonFetchTgPostEmbed(telegramPostUrl);
const imgLink = commonGetImgUrlFromTgPost(doc, index);
if (!imgLink) {
return;
}
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 {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 | undefined} imageUrl
*/
function commonGetImgUrlFromTgPost(doc, index = 0) {
/**
* @type {NodeListOf<HTMLAnchorElement> | null}
*/
const images = doc.querySelectorAll(
"a[href^='https://t.me/'].tgme_widget_message_photo_wrap"
);
/**
* @type {HTMLAnchorElement | null}
*/
const img = images[index];
// 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;
}
/**
*
* @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();
})();