Last active
May 3, 2024 07:53
-
-
Save Kenya-West/5f067902765af2911e9d2810cd691efa to your computer and use it in GitHub Desktop.
InoReader autoplay video in card view - autoplays Telegram video generated by RSS-Bridge feed when user chooses "card view" (press `4`)
This file contains hidden or 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 autoplay video in card view | |
// @namespace http://tampermonkey.net/ | |
// @version 0.0.2 | |
// @description Autoplays Telegram video generated by RSS-Bridge feed when user chooses "card view" (press `4`) | |
// @author Kenya-West | |
// @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", | |
}, | |
], | |
}; | |
/** | |
* Represents the application state. | |
* @typedef {Object} AppState | |
* @property {boolean} readerPaneMutationObserverLinked - Indicates whether the reader pane mutation observer is linked. | |
* @property {boolean} articleViewOpened - Indicates whether the article view is opened. | |
* @property {boolean} videoPlacingInProgress - Indicates whether the video placing is in progress. | |
* @property {Object} videoNowPlaying - Represents the currently playing video. | |
* @property {HTMLVideoElement | null} videoNowPlaying.currentVideoElement - The current video element being played. | |
* @property {function} videoNowPlaying.set - Sets the current video element and pauses the previous one. | |
* @property {function} videoNowPlaying.get - Retrieves the current video element. | |
*/ | |
const appState = { | |
readerPaneMutationObserverLinked: false, | |
articleViewOpened: false, | |
videoPlacingInProgress: false, | |
videoNowPlaying: { | |
/** | |
* Represents the currently playing video. | |
* @type {HTMLVideoElement | null} | |
*/ | |
currentVideoElement: null, | |
/** | |
* | |
* @param {HTMLVideoElement | null} video | |
*/ | |
set: (video) => { | |
const previousVideo = appState.videoNowPlaying.currentVideoElement; | |
appState.videoNowPlaying.currentVideoElement?.pause(); | |
appState.videoNowPlaying.currentVideoElement = video; | |
appState.videoNowPlaying.currentVideoElement?.play(); | |
}, | |
/** | |
* | |
* @returns {HTMLVideoElement | null} | |
*/ | |
get: () => { | |
return appState.videoNowPlaying.currentVideoElement; | |
}, | |
stopPlaying: () => { | |
appState.videoNowPlaying.currentVideoElement?.pause(); | |
appState.videoNowPlaying.currentVideoElement = null; | |
}, | |
}, | |
}; | |
// 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) { | |
autoplayVideoInArticleList(node); | |
stopVideoInArticleList(); | |
} | |
}); | |
} | |
} | |
}; | |
// | |
// | |
// FIRST PART - RESTORE IMAGES IN ARTICLE LIST | |
// | |
// | |
// | |
/** | |
* | |
* @param {Node} node | |
* @returns {void} | |
*/ | |
function autoplayVideoInArticleList(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) { | |
// filter mutations by having id on target and to have only unique id attribute values | |
let filteredMutations = mutationsList | |
// @ts-ignore | |
.filter((mutation) => mutation.target?.id.includes("article_")) | |
// @ts-ignore | |
.filter((mutation, index, self) => self.findIndex((t) => t.target?.id === mutation.target?.id) === index); | |
if (filteredMutations.length === 2) { | |
// check to have only two mutations: one that has .article_current class and one should not | |
const firstMutation = filteredMutations[0]; | |
const secondMutation = filteredMutations[1]; | |
// sort by abscence of .article_current class | |
filteredMutations = [firstMutation, secondMutation].sort((a, b) => { | |
// @ts-ignore | |
return a.target?.classList?.contains("article_current") ? 1 : -1; | |
}); | |
// @ts-ignore | |
if (firstMutation.target?.classList?.contains("article_current") && !secondMutation.target?.classList?.contains("article_current")) { | |
filteredMutations = []; | |
} | |
} | |
for (let mutation of filteredMutations) { | |
if (mutation.type === "attributes") { | |
if (mutation.attributeName === "class") { | |
/** | |
* @type {HTMLDivElement} | |
*/ | |
// @ts-ignore | |
const target = mutation.target; | |
if ( | |
target.classList.contains("article_current") && | |
target.querySelector(".article_tile_content_wraper [class*='icon-youtube']") | |
) { | |
const videoElement = checkVideoIsPlaced(target); | |
if (!videoElement) { | |
if (!appState.videoPlacingInProgress) { | |
appState.videoPlacingInProgress = true; | |
checkVideoExistingInTgPost(target) | |
.then((videoUrl) => { | |
const videoElement = createVideoElement(videoUrl); | |
placeVideo(target, videoElement); | |
if (target.classList.contains("article_current")) { | |
playVideo(videoElement); | |
} | |
}) | |
.finally(() => { | |
appState.videoPlacingInProgress = false; | |
}); | |
} | |
} else { | |
playVideo(videoElement); | |
} | |
} else if ( | |
!target.classList.contains("article_current") && | |
target.querySelector(".article_tile_content_wraper [class*='icon-youtube']") | |
) { | |
if (checkVideoIsPlaced(target)) { | |
/** | |
* @type {HTMLVideoElement | null} | |
*/ | |
const videoElement = checkVideoIsPlaced(target); | |
if (videoElement) { | |
stopVideo(videoElement); | |
} | |
} | |
} | |
/** | |
* | |
* @param {HTMLDivElement} article | |
* @returns {HTMLVideoElement | null} | |
*/ | |
function checkVideoIsPlaced(article) { | |
return article.querySelector(".article_tile_content_wraper > a[href*='t.me'] > video[src*='cdn-telegram.org']"); | |
} | |
/** | |
* | |
* @param {HTMLDivElement} target | |
* @returns {Promise<string>} | |
*/ | |
async function checkVideoExistingInTgPost(target) { | |
const telegramPostUrl = commonGetTelegramPostUrl(target); | |
if (telegramPostUrl) { | |
return commonFetchTgPostEmbed(telegramPostUrl).then((tgPost) => { | |
const videoUrl = commonGetVideoUrlFromTgPost(tgPost); | |
if (videoUrl) { | |
return videoUrl; | |
} else { | |
return Promise.reject("No video found in the telegram post"); | |
} | |
}); | |
} else { | |
return Promise.reject("No telegram post found in the article"); | |
} | |
} | |
/** | |
* | |
* @param {string} videoUrl | |
* @returns {HTMLVideoElement} | |
*/ | |
function createVideoElement(videoUrl) { | |
const videoElement = document.createElement("video"); | |
videoElement.src = videoUrl; | |
videoElement.autoplay = false; | |
videoElement.loop = true; | |
videoElement.muted = false; | |
videoElement.volume = 0.6; | |
videoElement.style.width = "100%"; | |
videoElement.style.height = "100%"; | |
videoElement.style.pointerEvents = "none"; | |
videoElement.style.display = "none"; | |
return videoElement; | |
} | |
/** | |
* | |
* @param {HTMLDivElement} article | |
* @param {HTMLVideoElement} videoElement | |
*/ | |
function placeVideo(article, videoElement) { | |
/** | |
* @type {HTMLAnchorElement | null} | |
*/ | |
const poster = article.querySelector(".article_tile_content_wraper > a[href*='t.me']"); | |
/** | |
* @type {HTMLDivElement | null} | |
*/ | |
const cover = article.querySelector( | |
".article_tile_content_wraper > a[href*='t.me'] > .article_tile_picture[style*='background-image']" | |
); | |
if (poster) { | |
poster.appendChild(videoElement); | |
if (cover?.style) { | |
cover.style.display = "none"; | |
} | |
videoElement.style.display = "block"; | |
} | |
} | |
/** | |
* | |
* @param {HTMLVideoElement} videoElement | |
*/ | |
function playVideo(videoElement) { | |
const video = videoElement; | |
if (video && !appState.articleViewOpened) { | |
appState.videoNowPlaying.set(video); | |
} | |
} | |
/** | |
* | |
* @param {HTMLVideoElement} videoElement | |
*/ | |
function stopVideo(videoElement) { | |
const video = videoElement; | |
if (video) { | |
video.pause(); | |
} | |
} | |
} | |
} | |
} | |
}; | |
// Options for the observer (which mutations to observe) | |
const mutationObserverLocalConfig = { | |
attributes: true, | |
attributeFilter: ["class"], | |
childList: false, | |
subtree: true, | |
}; | |
// 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); | |
} | |
} | |
} | |
/** | |
* | |
* @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; | |
} | |
// | |
// | |
// SECOND PART - STOP VIDEO IN ARTICLE LIST IF ARTICLE VIEW IS OPENED | |
// | |
// | |
// | |
function stopVideoInArticleList() { | |
const articleRoot = document.querySelector(querySelectorPathArticleRoot); | |
if (articleRoot) { | |
appState.articleViewOpened = true; | |
appState.videoNowPlaying.stopPlaying(); | |
} else { | |
appState.articleViewOpened = false; | |
} | |
} | |
/** | |
* | |
* @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 & HTMLDivElement} node | |
* @returns {string} | |
*/ | |
function commonGetTelegramPostUrl(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]; | |
} | |
} | |
} | |
// 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); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment