您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
从禁止下载的Telegram频道中下载图片、视频及语音消息
// ==UserScript== // @name Telegram Media Downloader // @name:en Telegram Media Downloader // @name:zh-CN Telegram 受限图片视频下载器 // @name:zh-TW Telegram 受限圖片影片下載器 // @name:ru Telegram: загрузчик медиафайлов // @version 1.207 // @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader // @description Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content // @description:en Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content // @description:ru Загружайте изображения, GIF-файлы, видео и голосовые сообщения в веб-приложении Telegram из частных каналов, которые отключили загрузку и ограничили сохранение контента // @description:zh-CN 从禁止下载的Telegram频道中下载图片、视频及语音消息 // @description:zh-TW 從禁止下載的 Telegram 頻道中下載圖片、影片及語音訊息 // @author Nestor Qin // @license GNU GPLv3 // @website https://github.com/Neet-Nestor/Telegram-Media-Downloader // @match https://web.telegram.org/* // @match https://webk.telegram.org/* // @match https://webz.telegram.org/* // @icon https://img.icons8.com/color/452/telegram-app--v5.png // ==/UserScript== (function () { const logger = { info: (message, fileName = null) => { console.log( `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}` ); }, error: (message, fileName = null) => { console.error( `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}` ); }, }; // Unicode values for icons (used in /k/ app) // https://github.com/morethanwords/tweb/blob/master/src/icons.ts const DOWNLOAD_ICON = "\uE95B"; const FORWARD_ICON = "\uE976"; // Это не используется в предоставленном коде, но оставлено для справки const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/; const REFRESH_DELAY = 500; const hashCode = (s) => { let h = 0, l = s.length, i = 0; if (l > 0) { while (i < l) { h = ((h << 5) - h + s.charCodeAt(i++)) | 0; } } return h >>> 0; }; /** * Создает элемент прогресс-бара для отображения статуса загрузки. * @param {string} videoId Уникальный ID для прогресс-бара. * @param {string} fileName Имя файла для отображения. */ const createProgressBar = (videoId, fileName) => { const isDarkMode = document.querySelector("html").classList.contains("night") || document.querySelector("html").classList.contains("theme-dark"); let container = document.getElementById("tel-downloader-progress-bar-container"); // Создаем контейнер, если он еще не существует if (!container) { container = document.createElement("div"); container.id = "tel-downloader-progress-bar-container"; container.style.position = "fixed"; container.style.bottom = "0"; container.style.right = "0"; container.style.zIndex = location.pathname.startsWith("/k/") ? "4" : "1600"; document.body.appendChild(container); } const innerContainer = document.createElement("div"); innerContainer.id = "tel-downloader-progress-" + videoId; innerContainer.style.width = "20rem"; innerContainer.style.marginTop = "0.4rem"; innerContainer.style.padding = "0.6rem"; innerContainer.style.backgroundColor = isDarkMode ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.6)"; innerContainer.style.borderRadius = "5px"; // Добавим немного скругления const flexContainer = document.createElement("div"); flexContainer.style.display = "flex"; flexContainer.style.justifyContent = "space-between"; flexContainer.style.alignItems = "center"; // Выравнивание по центру const title = document.createElement("p"); title.className = "filename"; title.style.margin = 0; title.style.color = "white"; title.style.whiteSpace = "nowrap"; // Предотвратить перенос текста title.style.overflow = "hidden"; // Обрезать, если слишком длинно title.style.textOverflow = "ellipsis"; // Добавить многоточие title.innerText = fileName; const closeButton = document.createElement("div"); closeButton.style.cursor = "pointer"; closeButton.style.fontSize = "1.2rem"; closeButton.style.color = isDarkMode ? "#8a8a8a" : "white"; closeButton.style.marginLeft = "10px"; // Отступ от названия файла closeButton.innerHTML = "×"; closeButton.onclick = function () { innerContainer.remove(); // Удаляем весь innerContainer }; const progressBar = document.createElement("div"); progressBar.className = "progress"; progressBar.style.backgroundColor = "#e2e2e2"; progressBar.style.position = "relative"; progressBar.style.width = "100%"; progressBar.style.height = "1.6rem"; progressBar.style.borderRadius = "2rem"; progressBar.style.overflow = "hidden"; progressBar.style.marginTop = "0.4rem"; // Отступ от названия const counter = document.createElement("p"); counter.style.position = "absolute"; counter.style.zIndex = 5; counter.style.left = "50%"; counter.style.top = "50%"; counter.style.transform = "translate(-50%, -50%)"; counter.style.margin = 0; counter.style.color = "black"; counter.innerText = "0%"; // Начальное значение const progress = document.createElement("div"); progress.style.position = "absolute"; progress.style.height = "100%"; progress.style.width = "0%"; progress.style.backgroundColor = "#6093B5"; progress.style.transition = "width 0.1s linear"; // Плавный переход для прогресса progressBar.appendChild(counter); progressBar.appendChild(progress); flexContainer.appendChild(title); flexContainer.appendChild(closeButton); innerContainer.appendChild(flexContainer); innerContainer.appendChild(progressBar); container.appendChild(innerContainer); }; /** * Обновляет прогресс загрузки для существующего прогресс-бара. * @param {string} videoId ID прогресс-бара. * @param {string} fileName Обновленное имя файла (если изменилось). * @param {number} progress Прогресс в процентах. */ const updateProgress = (videoId, fileName, progress) => { const innerContainer = document.getElementById( "tel-downloader-progress-" + videoId ); if (!innerContainer) { logger.error(`Progress bar with ID ${videoId} not found for update.`, fileName); return; } innerContainer.querySelector("p.filename").innerText = fileName; const progressBar = innerContainer.querySelector("div.progress"); progressBar.querySelector("p").innerText = progress + "%"; progressBar.querySelector("div").style.width = progress + "%"; }; /** * Завершает прогресс загрузки, отмечая его как выполненный. * @param {string} videoId ID прогресс-бара. */ const completeProgress = (videoId) => { const innerContainer = document.getElementById( "tel-downloader-progress-" + videoId ); if (!innerContainer) { logger.error(`Progress bar with ID ${videoId} not found for completion.`); return; } const progressBar = innerContainer.querySelector("div.progress"); progressBar.querySelector("p").innerText = "Completed"; progressBar.querySelector("div").style.backgroundColor = "#B6C649"; progressBar.querySelector("div").style.width = "100%"; setTimeout(() => innerContainer.remove(), 2000); // Удалить через 2 секунды после завершения }; /** * Отмечает прогресс загрузки как прерванный. * @param {string} videoId ID прогресс-бара. */ const AbortProgress = (videoId) => { const innerContainer = document.getElementById( "tel-downloader-progress-" + videoId ); if (!innerContainer) { logger.error(`Progress bar with ID ${videoId} not found for abort.`); return; } const progressBar = innerContainer.querySelector("div.progress"); progressBar.querySelector("p").innerText = "Aborted"; progressBar.querySelector("div").style.backgroundColor = "#D16666"; progressBar.querySelector("div").style.width = "100%"; setTimeout(() => innerContainer.remove(), 2000); // Удалить через 2 секунды после прерывания }; /** * Загружает видео по частям, используя Range-запросы. * Поддерживает File System Access API. * @param {string} url URL видео для загрузки. */ const tel_download_video = async (url) => { let _blobs = []; let _next_offset = 0; let _total_size = null; let _file_extension = "mp4"; const videoId = (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString(); let fileName = hashCode(url).toString(36) + "." + _file_extension; // Некоторые video src имеют формат: // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}' try { const metadataPart = url.split("/").pop(); // Получаем последнюю часть URL if (metadataPart) { const decodedMetadata = decodeURIComponent(metadataPart); const metadata = JSON.parse(decodedMetadata); if (metadata.fileName) { fileName = metadata.fileName; // Обновляем расширение файла, если оно есть в метаданных const parts = fileName.split('.'); if (parts.length > 1) { _file_extension = parts.pop(); } } } } catch (e) { // Invalid JSON string, pass extracting fileName logger.info(`Could not parse metadata from URL part: ${url.split('/').pop()}`, fileName); } logger.info(`Starting download for URL: ${url}`, fileName); createProgressBar(videoId, fileName); let _writable = null; const supportsFileSystemAccess = "showSaveFilePicker" in unsafeWindow && (() => { try { return unsafeWindow.self === unsafeWindow.top; } catch { return false; } })(); if (supportsFileSystemAccess) { try { const handle = await unsafeWindow.showSaveFilePicker({ suggestedName: fileName, }); _writable = await handle.createWritable(); logger.info('File System Access API writable created.', fileName); } catch (err) { if (err.name !== "AbortError") { logger.error(`Error with File System Access API: ${err.name} - ${err.message}`, fileName); } else { logger.info('File save operation aborted by user.', fileName); } AbortProgress(videoId); return; // Прекращаем выполнение, если пользователь отменил сохранение } } try { while (_writable ? true : _next_offset < (_total_size || Infinity)) { // Продолжаем, пока не загрузим все или пока writable не закрыт const headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0", }; if (_total_size) { // Добавляем Range только если известен общий размер headers.Range = `bytes=${_next_offset}-`; } const res = await fetch(url, { method: "GET", headers }); if (![200, 206].includes(res.status)) { throw new Error("Non 200/206 response was received: " + res.status); } const mime = res.headers.get("Content-Type")?.split(";")[0]; if (!mime || !mime.startsWith("video/")) { throw new Error("Get non video response with MIME type " + mime); } const currentFileExtension = mime.split("/")[1]; if (_file_extension !== currentFileExtension) { _file_extension = currentFileExtension; fileName = fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension; logger.info(`File extension updated to: ${_file_extension}`, fileName); } const contentRangeHeader = res.headers.get("Content-Range"); if (contentRangeHeader) { const match = contentRangeHeader.match(contentRangeRegex); if (!match) { throw new Error(`Invalid Content-Range header: ${contentRangeHeader}`); } const startOffset = parseInt(match[1]); const endOffset = parseInt(match[2]); const totalSize = parseInt(match[3]); if (startOffset !== _next_offset) { logger.error(`Gap detected between responses. Last offset: ${_next_offset}, New start offset: ${startOffset}`, fileName); throw "Gap detected between responses."; } if (_total_size && totalSize !== _total_size) { logger.error("Total size differs", fileName); throw "Total size differs"; } _next_offset = endOffset + 1; _total_size = totalSize; } else if (res.status === 200) { // Если 200 OK без Content-Range, значит это полный файл _total_size = parseInt(res.headers.get("Content-Length")); _next_offset = _total_size; logger.info(`Received full file (200 OK), total size: ${_total_size} bytes`, fileName); } else { throw new Error("Missing Content-Range header for 206 Partial Content response."); } const resBlob = await res.blob(); if (_writable) { await _writable.write(resBlob); } else { _blobs.push(resBlob); } const progressPercent = _total_size ? ((_next_offset * 100) / _total_size).toFixed(0) : 0; logger.info(`Progress: ${progressPercent}%`, fileName); updateProgress(videoId, fileName, progressPercent); if (_next_offset >= _total_size) { break; // Загрузка завершена } } if (_writable) { await _writable.close(); logger.info("Download finished (File System Access API)", fileName); } else { const blob = new Blob(_blobs, { type: `video/${_file_extension}` }); const blobUrl = window.URL.createObjectURL(blob); logger.info("Final blob size: " + blob.size + " bytes", fileName); const a = document.createElement("a"); document.body.appendChild(a); a.href = blobUrl; a.download = fileName; a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl); logger.info("Download triggered", fileName); } completeProgress(videoId); } catch (reason) { logger.error(`Download failed: ${reason}`, fileName); AbortProgress(videoId); if (_writable) { try { await _writable.close(); } catch (closeErr) { logger.error(`Error closing writable after download failure: ${closeErr.message}`, fileName); } } } }; /** * Загружает аудиофайл по частям, используя Range-запросы. * Поддерживает File System Access API. * @param {string} url URL аудиофайла для загрузки. */ const tel_download_audio = async (url) => { let _blobs = []; let _next_offset = 0; let _total_size = null; let _file_extension = "ogg"; // Предполагаемое расширение по умолчанию const audioId = (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString(); let fileName = hashCode(url).toString(36) + "." + _file_extension; logger.info(`Starting download for URL: ${url}`, fileName); createProgressBar(audioId, fileName); // Используем audioId для прогресс-бара let _writable = null; const supportsFileSystemAccess = "showSaveFilePicker" in unsafeWindow && (() => { try { return unsafeWindow.self === unsafeWindow.top; } catch { return false; } })(); if (supportsFileSystemAccess) { try { const handle = await unsafeWindow.showSaveFilePicker({ suggestedName: fileName, }); _writable = await handle.createWritable(); logger.info('File System Access API writable created for audio.', fileName); } catch (err) { if (err.name !== "AbortError") { logger.error(`Error with File System Access API for audio: ${err.name} - ${err.message}`, fileName); } else { logger.info('Audio file save operation aborted by user.', fileName); } AbortProgress(audioId); return; } } try { while (_writable ? true : _next_offset < (_total_size || Infinity)) { const headers = {}; if (_total_size) { headers.Range = `bytes=${_next_offset}-`; } const res = await fetch(url, { method: "GET", headers }); if (![200, 206].includes(res.status)) { throw new Error("Non 200/206 response was received: " + res.status); } const mime = res.headers.get("Content-Type")?.split(";")[0]; if (!mime || !mime.startsWith("audio/")) { throw new Error("Get non audio response with MIME type " + mime); } const currentFileExtension = mime.split("/")[1]; if (_file_extension !== currentFileExtension) { _file_extension = currentFileExtension; fileName = fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension; logger.info(`Audio file extension updated to: ${_file_extension}`, fileName); } const contentRangeHeader = res.headers.get("Content-Range"); if (contentRangeHeader) { const match = contentRangeHeader.match(contentRangeRegex); if (!match) { throw new Error(`Invalid Content-Range header: ${contentRangeHeader}`); } const startOffset = parseInt(match[1]); const endOffset = parseInt(match[2]); const totalSize = parseInt(match[3]); if (startOffset !== _next_offset) { logger.error(`Gap detected between audio responses. Last offset: ${_next_offset}, New start offset: ${startOffset}`, fileName); throw "Gap detected between responses."; } if (_total_size && totalSize !== _total_size) { logger.error("Total audio size differs", fileName); throw "Total size differs"; } _next_offset = endOffset + 1; _total_size = totalSize; } else if (res.status === 200) { _total_size = parseInt(res.headers.get("Content-Length")); _next_offset = _total_size; logger.info(`Received full audio file (200 OK), total size: ${_total_size} bytes`, fileName); } else { throw new Error("Missing Content-Range header for 206 Partial Content response for audio."); } const resBlob = await res.blob(); if (_writable) { await _writable.write(resBlob); } else { _blobs.push(resBlob); } const progressPercent = _total_size ? ((_next_offset * 100) / _total_size).toFixed(0) : 0; logger.info(`Audio Progress: ${progressPercent}%`, fileName); updateProgress(audioId, fileName, progressPercent); if (_next_offset >= _total_size) { break; } } if (_writable) { await _writable.close(); logger.info("Audio download finished (File System Access API)", fileName); } else { const blob = new Blob(_blobs, { type: `audio/${_file_extension}` }); const blobUrl = window.URL.createObjectURL(blob); logger.info("Final audio blob size: " + blob.size + " bytes", fileName); const a = document.createElement("a"); document.body.appendChild(a); a.href = blobUrl; a.download = fileName; a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl); logger.info("Audio download triggered", fileName); } completeProgress(audioId); } catch (reason) { logger.error(`Audio download failed: ${reason}`, fileName); AbortProgress(audioId); if (_writable) { try { await _writable.close(); } catch (closeErr) { logger.error(`Error closing writable after audio download failure: ${closeErr.message}`, fileName); } } } }; /** * Загружает изображение. * @param {string} imageUrl URL изображения для загрузки. */ const tel_download_image = (imageUrl) => { // Попытка определить расширение из URL, иначе по умолчанию .jpeg let fileName = (Math.random() + 1).toString(36).substring(2, 10); const urlParts = imageUrl.split('.'); if (urlParts.length > 1) { const potentialExtension = urlParts.pop().split('?')[0].split('#')[0]; if (potentialExtension.length <= 4 && /^[a-zA-Z0-9]+$/.test(potentialExtension)) { fileName += `.${potentialExtension}`; } else { fileName += ".jpeg"; } } else { fileName += ".jpeg"; } const a = document.createElement("a"); document.body.appendChild(a); a.href = imageUrl; a.download = fileName; a.click(); document.body.removeChild(a); logger.info("Image download triggered", fileName); }; logger.info("Initialized script."); // For webz /a/ webapp (Telegram Web Z) setInterval(() => { // Stories const storiesContainer = document.getElementById("StoryViewer"); if (storiesContainer) { // logger.info("Found storiesContainer"); const createDownloadButtonForStory = () => { // Переименовал для ясности const downloadIcon = document.createElement("i"); downloadIcon.className = "icon icon-download"; const downloadButton = document.createElement("button"); downloadButton.className = "Button TkphaPyQ tiny translucent-white round tel-download"; downloadButton.appendChild(downloadIcon); downloadButton.setAttribute("type", "button"); downloadButton.setAttribute("title", "Download"); downloadButton.setAttribute("aria-label", "Download"); downloadButton.onclick = () => { // 1. Story with video const video = storiesContainer.querySelector("video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; if (videoSrc) { tel_download_video(videoSrc); } else { // 2. Story with image // Используем querySelectorAll и берем последний, так как могут быть превью const images = storiesContainer.querySelectorAll("img.PVZ8TOWS"); if (images.length > 0) { const imageSrc = images[images.length - 1]?.src; if (imageSrc) tel_download_image(imageSrc); } } }; return downloadButton; }; const storyHeader = storiesContainer.querySelector(".GrsJNw3y") || storiesContainer.querySelector(".DropdownMenu")?.parentNode; // Использование optional chaining if (storyHeader && !storyHeader.querySelector(".tel-download")) { // logger.info("Adding download button to story header."); // Проверяем, существует ли уже кнопка загрузки от Telegram const existingDownloadButton = storyHeader.querySelector('button[title="Download"], button[aria-label="Download"]'); if (!existingDownloadButton) { storyHeader.insertBefore( createDownloadButtonForStory(), storyHeader.querySelector("button") // Вставляем перед первой кнопкой ); } } } // All media opened are located in .media-viewer-movers > .media-viewer-aspecter const mediaContainer = document.querySelector( "#MediaViewer .MediaViewerSlide--active" ); const mediaViewerActions = document.querySelector( "#MediaViewer .MediaViewerActions" ); if (!mediaContainer || !mediaViewerActions) return; // logger.info("Found active media viewer."); // Создаем кнопку загрузки, которая будет использоваться const createMediaDownloadButton = (url, type) => { const downloadIcon = document.createElement("i"); downloadIcon.className = "icon icon-download"; const downloadButton = document.createElement("button"); downloadButton.className = "Button smaller translucent-white round tel-download"; downloadButton.setAttribute("type", "button"); downloadButton.setAttribute("title", "Download"); downloadButton.setAttribute("aria-label", "Download"); downloadButton.setAttribute("data-tel-download-url", url); downloadButton.appendChild(downloadIcon); downloadButton.onclick = () => { if (type === 'video') { tel_download_video(url); } else if (type === 'image') { tel_download_image(url); } }; return downloadButton; }; const videoPlayer = mediaContainer.querySelector( ".MediaViewerContent > .VideoPlayer" ); const img = mediaContainer.querySelector(".MediaViewerContent > div > img"); let targetUrl = null; let mediaType = null; if (videoPlayer) { const videoElement = videoPlayer.querySelector("video"); targetUrl = videoElement?.currentSrc || videoElement?.src; mediaType = 'video'; } else if (img && img.src) { targetUrl = img.src; mediaType = 'image'; } if (targetUrl) { const existingOurButton = mediaViewerActions.querySelector("button.tel-download"); const existingOfficialButton = mediaViewerActions.querySelector('button[title="Download"]'); // Если есть официальная кнопка, не добавляем свою, а используем ее if (existingOfficialButton) { // Проверяем, если наша кнопка уже есть, но официальная появилась, удаляем свою if (existingOurButton) { existingOurButton.remove(); // logger.info("Removed custom download button, official one is present."); } // Если официальная кнопка есть, мы не управляем ее кликом. // Предполагается, что она работает корректно. return; } // Если официальной кнопки нет, добавляем или обновляем нашу if (existingOurButton) { if (existingOurButton.getAttribute("data-tel-download-url") !== targetUrl) { // Обновляем URL и функцию клика, если URL изменился (т.е. переключили медиа) existingOurButton.setAttribute("data-tel-download-url", targetUrl); existingOurButton.onclick = () => { if (mediaType === 'video') { tel_download_video(targetUrl); } else if (mediaType === 'image') { tel_download_image(targetUrl); } }; // logger.info("Updated custom download button for new media."); } } else { // Если нет ни нашей, ни официальной кнопки, создаем и добавляем новую const newDownloadButton = createMediaDownloadButton(targetUrl, mediaType); mediaViewerActions.prepend(newDownloadButton); // logger.info("Added custom download button."); } // Добавление кнопки в контролы видеоплеера (только для видео) if (videoPlayer) { const controls = videoPlayer.querySelector(".VideoPlayerControls"); if (controls) { const buttons = controls.querySelector(".buttons"); if (buttons && !buttons.querySelector("button.tel-download")) { const spacer = buttons.querySelector(".spacer"); if (spacer) { const newDownloadButton = createMediaDownloadButton(targetUrl, mediaType); spacer.after(newDownloadButton); // logger.info("Added custom download button to video controls."); } } } } } else { // Если медиа нет, убедимся, что наша кнопка удалена const existingOurButton = mediaViewerActions.querySelector("button.tel-download"); if (existingOurButton) { existingOurButton.remove(); // logger.info("Removed custom download button as no media found."); } } }, REFRESH_DELAY); // For webk /k/ webapp (Telegram Web K) setInterval(() => { /* Voice Message or Circle Video */ const audioElements = document.body.querySelectorAll("audio-element"); audioElements.forEach((audioElement) => { const bubble = audioElement.closest(".bubble"); // Пропускаем, если нет родительского элемента .bubble или кнопка уже существует if (!bubble || bubble.querySelector("._tel_download_button_pinned_container")) { return; } const link = audioElement.audio?.src; if (!link) { return; } const isAudio = audioElement.audio instanceof HTMLAudioElement; // Создаем кнопку загрузки const downloadButton = document.createElement("button"); downloadButton.className = "btn-icon tgico-download _tel_download_button_pinned_container"; downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`; downloadButton.setAttribute("type", "button"); downloadButton.setAttribute("title", "Download"); downloadButton.setAttribute("aria-label", "Download"); downloadButton.onclick = (e) => { e.stopPropagation(); // Предотвратить всплытие события, чтобы не закрывать медиаплеер if (isAudio) { tel_download_audio(link); } else { tel_download_video(link); } }; // Ищем место для вставки кнопки const utilsContainer = bubble.querySelector(".message-utils-container") || bubble.querySelector(".bubble-tools-wrapper"); if (utilsContainer && !utilsContainer.querySelector("._tel_download_button_pinned_container")) { utilsContainer.appendChild(downloadButton); // logger.info("Added download button for voice message/circle video.", link); } }); // Stories (webk) const storiesContainer = document.getElementById("stories-viewer"); if (storiesContainer) { const createDownloadButtonForStoryK = () => { const downloadButton = document.createElement("button"); downloadButton.className = "btn-icon rp tel-download"; downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`; downloadButton.setAttribute("type", "button"); downloadButton.setAttribute("title", "Download"); downloadButton.setAttribute("aria-label", "Download"); downloadButton.onclick = () => { const video = storiesContainer.querySelector("video.media-video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; if (videoSrc) { tel_download_video(videoSrc); } else { const imageSrc = storiesContainer.querySelector("img.media-photo")?.src; if (imageSrc) tel_download_image(imageSrc); } }; return downloadButton; }; const storyHeader = storiesContainer.querySelector( "[class^='_ViewerStoryHeaderRight']" ); if (storyHeader && !storyHeader.querySelector(".tel-download")) { storyHeader.prepend(createDownloadButtonForStoryK()); // logger.info("Added download button to story header (webk)."); } const storyFooter = storiesContainer.querySelector( "[class^='_ViewerStoryFooterRight']" ); if (storyFooter && !storyFooter.querySelector(".tel-download")) { storyFooter.prepend(createDownloadButtonForStoryK()); // logger.info("Added download button to story footer (webk)."); } } // All media opened are located in .media-viewer-movers > .media-viewer-aspecter (webk) const mediaContainer = document.querySelector(".media-viewer-whole"); if (!mediaContainer) return; const mediaAspecter = mediaContainer.querySelector( ".media-viewer-movers .media-viewer-aspecter" ); const mediaButtons = mediaContainer.querySelector( ".media-viewer-topbar .media-viewer-buttons" ); if (!mediaAspecter || !mediaButtons) return; // logger.info("Found active media viewer (webk)."); // Query hidden buttons and unhide them const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide"); let onOfficialDownloadClick = null; for (const btn of hiddenButtons) { btn.classList.remove("hide"); if (btn.textContent === FORWARD_ICON) { // Хотя FORWARD_ICON не используется для кнопки загрузки, оставил для справки btn.classList.add("tgico-forward"); } if (btn.textContent === DOWNLOAD_ICON) { btn.classList.add("tgico-download"); onOfficialDownloadClick = () => { btn.click(); }; // logger.info("Found and unhidden official download button."); } } const videoElement = mediaAspecter.querySelector("video"); const imageElement = mediaAspecter.querySelector("img.thumbnail"); const existingCustomButton = mediaButtons.querySelector("button.tel-download"); const existingOfficialDownloadButton = mediaButtons.querySelector("button.btn-icon.tgico-download"); // Если есть официальная кнопка загрузки, то наша кнопка не нужна if (existingOfficialDownloadButton) { if (existingCustomButton) { existingCustomButton.remove(); // logger.info("Removed custom download button as official one is present (webk)."); } return; } // Если нет официальной кнопки, добавляем свою let targetUrl = null; let mediaType = null; if (videoElement && (videoElement.src || videoElement.currentSrc)) { targetUrl = videoElement.src || videoElement.currentSrc; mediaType = 'video'; } else if (imageElement && imageElement.src) { targetUrl = imageElement.src; mediaType = 'image'; } if (targetUrl) { if (existingCustomButton) { // Если наша кнопка уже есть, обновляем ее, если URL изменился if (existingCustomButton.getAttribute("data-tel-download-url") !== targetUrl) { existingCustomButton.setAttribute("data-tel-download-url", targetUrl); existingCustomButton.onclick = () => { if (mediaType === 'video') { tel_download_video(targetUrl); } else if (mediaType === 'image') { tel_download_image(targetUrl); } }; // logger.info("Updated custom download button for new media (webk)."); } } else { // Создаем новую кнопку, если ее нет const downloadButton = document.createElement("button"); downloadButton.className = "btn-icon tgico-download tel-download"; downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`; downloadButton.setAttribute("type", "button"); downloadButton.setAttribute("title", "Download"); downloadButton.setAttribute("aria-label", "Download"); downloadButton.setAttribute("data-tel-download-url", targetUrl); // Сохраняем URL if (onOfficialDownloadClick) { downloadButton.onclick = onOfficialDownloadClick; // Используем официальный клик, если найден } else { downloadButton.onclick = () => { if (mediaType === 'video') { tel_download_video(targetUrl); } else if (mediaType === 'image') { tel_download_image(targetUrl); } }; } mediaButtons.prepend(downloadButton); // logger.info("Added custom download button (webk)."); } // Добавление кнопки в контролы видеоплеера (только для видео) if (videoElement && mediaAspecter.querySelector(".ckin__player")) { // Проверяем, что это активный видеоплеер const controls = mediaAspecter.querySelector( ".default__controls.ckin__controls" ); if (controls && !controls.querySelector(".tel-download")) { const brControls = controls.querySelector( ".bottom-controls .right-controls" ); if (brControls) { const downloadButton = document.createElement("button"); downloadButton.className = "btn-icon default__button tgico-download tel-download"; downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span>`; downloadButton.setAttribute("type", "button"); downloadButton.setAttribute("title", "Download"); downloadButton.setAttribute("aria-label", "Download"); downloadButton.setAttribute("data-tel-download-url", targetUrl); // Сохраняем URL if (onOfficialDownloadClick) { downloadButton.onclick = onOfficialDownloadClick; } else { downloadButton.onclick = () => { tel_download_video(targetUrl); }; } brControls.prepend(downloadButton); // logger.info("Added custom download button to video controls (webk)."); } } } } else { // Если медиа нет, убедимся, что наша кнопка удалена if (existingCustomButton) { existingCustomButton.remove(); // logger.info("Removed custom download button as no media found (webk)."); } } }, REFRESH_DELAY); logger.info("Script setup completed."); })();