Last active
May 4, 2024 13:04
-
-
Save Kenya-West/416ea1e65e27b8cbbf9e1e3e5f894940 to your computer and use it in GitHub Desktop.
InoReader restore lost images - this script restores images from old Telegram posts for feeds generated by RSS-Bridge
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment