您需要先安装一个扩展,例如 篡改猴、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.208 // Увеличена версия из-за изменений // @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 // @grant unsafeWindow // Добавлено явно для использования unsafeWindow // ==/UserScript== (function () { 'use strict'; // Всегда полезно для чистого кода const logger = { info: (message, fileName = '') => { // fileName по умолчанию пустая строка console.log( `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}` ); }, error: (message, fileName = '') => { // fileName по умолчанию пустая строка 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; // Интервал для setInterval 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; }; // --- Функции для управления прогресс-баром --- const createProgressBar = (videoId, fileName) => { const isDarkMode = document.documentElement.classList.contains("night") || document.documentElement.classList.contains("theme-dark"); let container = document.getElementById("tel-downloader-progress-bar-container"); if (!container) { // Создаем контейнер, если его нет setupProgressBar(); container = document.getElementById("tel-downloader-progress-bar-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 = "0.5rem"; // Добавим немного скругления 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 0 0.5rem 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.innerHTML = "×"; closeButton.onclick = function () { innerContainer.remove(); // Используем .remove() вместо parent.removeChild }; 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"; 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.style.fontWeight = "bold"; // Сделать текст прогресса жирным 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.2s ease-out"; // Плавное изменение прогресса progressBar.appendChild(counter); progressBar.appendChild(progress); flexContainer.appendChild(title); flexContainer.appendChild(closeButton); innerContainer.appendChild(flexContainer); innerContainer.appendChild(progressBar); container.appendChild(innerContainer); updateProgress(videoId, fileName, 0); // Инициализируем прогресс на 0% }; const updateProgress = (videoId, fileName, progress) => { const innerContainer = document.getElementById( "tel-downloader-progress-" + videoId ); if (!innerContainer) return; // Проверка на существование элемента innerContainer.querySelector("p.filename").innerText = fileName; const progressBar = innerContainer.querySelector("div.progress"); if (!progressBar) return; // Проверка на существование элемента progressBar.querySelector("p").innerText = `${progress}%`; progressBar.querySelector("div").style.width = `${progress}%`; }; const completeProgress = (videoId) => { const innerContainer = document.getElementById("tel-downloader-progress-" + videoId); if (!innerContainer) return; const progressBar = innerContainer.querySelector("div.progress"); if (!progressBar) return; progressBar.querySelector("p").innerText = "Completed"; progressBar.querySelector("div").style.backgroundColor = "#B6C649"; progressBar.querySelector("div").style.width = "100%"; setTimeout(() => { innerContainer.remove(); // Удалить после завершения через 3 секунды }, 3000); }; const abortProgress = (videoId) => { // Переименована для соответствия стилю const innerContainer = document.getElementById("tel-downloader-progress-" + videoId); if (!innerContainer) return; const progressBar = innerContainer.querySelector("div.progress"); if (!progressBar) return; progressBar.querySelector("p").innerText = "Aborted"; progressBar.querySelector("div").style.backgroundColor = "#D16666"; progressBar.querySelector("div").style.width = "100%"; setTimeout(() => { innerContainer.remove(); // Удалить после ошибки через 3 секунды }, 3000); }; // --- Конец функций для управления прогресс-баром --- // --- Функции загрузки --- const tel_download_video = (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}`; // Improved filename extraction try { const urlObj = new URL(url); const lastSegment = urlObj.pathname.split('/').pop(); if (lastSegment) { try { const decodedLastSegment = decodeURIComponent(lastSegment); const metadata = JSON.parse(decodedLastSegment); if (metadata.fileName) { fileName = metadata.fileName; _file_extension = fileName.split('.').pop() || _file_extension; } } catch (e) { // Not a JSON string, try to extract extension from last segment const parts = lastSegment.split('.'); if (parts.length > 1) { const potentialExt = parts.pop(); if (potentialExt.length <= 4 && /^[a-zA-Z0-9]+$/.test(potentialExt)) { // Basic check for valid extension _file_extension = potentialExt; fileName = `${hashCode(url).toString(36)}.${_file_extension}`; // Re-form filename with new extension } } } } } catch (e) { logger.error(`Error parsing URL or metadata: ${e.message}`, url); } logger.info(`URL: ${url}`, fileName); createProgressBar(videoId, fileName); // Создаем прогресс-бар здесь const fetchNextPart = (writable) => { // writable вместо _writable fetch(url, { method: "GET", headers: { Range: `bytes=${_next_offset}-`, "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0", }, }) .then((res) => { 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]; // Optional chaining if (!mime || !mime.startsWith("video/")) { throw new Error(`Received non-video response with MIME type ${mime || 'N/A'}`); } _file_extension = mime.split("/")[1]; // Обновляем расширение файла в имени, если оно изменилось if (!fileName.endsWith(`.${_file_extension}`)) { const nameWithoutExt = fileName.includes('.') ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName; fileName = `${nameWithoutExt}.${_file_extension}`; } const contentRangeHeader = res.headers.get("Content-Range"); if (!contentRangeHeader) { // If Content-Range is missing, assume it's a full download (status 200) if (res.status === 200) { _total_size = parseInt(res.headers.get("Content-Length")); _next_offset = _total_size; // Mark as complete logger.info(`Full download detected, total size: ${_total_size}`, fileName); return res.blob(); } else { throw new Error("Content-Range header missing for partial content."); } } const match = contentRangeHeader.match(contentRangeRegex); if (!match) { throw new Error("Invalid Content-Range header format."); } 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 new Error("Gap detected between responses."); } if (_total_size && totalSize !== _total_size) { // self-comparison is always false for non-NaN logger.error("Total size differs", fileName); // Probably meant totalSize !== _total_size throw new Error("Total size differs"); } _next_offset = endOffset + 1; _total_size = totalSize; logger.info( `Get response: ${res.headers.get("Content-Length")} bytes data from ${res.headers.get("Content-Range")}`, fileName ); const progress = ((_next_offset * 100) / _total_size).toFixed(0); logger.info(`Progress: ${progress}%`, fileName); updateProgress(videoId, fileName, progress); return res.blob(); }) .then((resBlob) => { if (writable) { // Проверка на null return writable.write(resBlob); } else { _blobs.push(resBlob); return Promise.resolve(); // Возвращаем Promise для цепочки } }) .then(() => { if (_total_size === null) { throw new Error("_total_size is NULL after first fetch (should not happen for valid content-range)"); } if (_next_offset < _total_size) { fetchNextPart(writable); } else { if (writable) { writable.close().then(() => { logger.info("Download finished (FileSystemAccess API)", fileName); completeProgress(videoId); }).catch(err => { logger.error(`Error closing writable: ${err.message}`, fileName); abortProgress(videoId); }); } else { saveVideoBlob(); // Переименована функция сохранения } } }) .catch((reason) => { logger.error(`Download failed: ${reason.message || reason}`, fileName); abortProgress(videoId); }); }; const saveVideoBlob = () => { // Переименована для ясности logger.info("Finish downloading blobs. Concatenating blobs and downloading...", fileName); 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 (Blob URL)", fileName); completeProgress(videoId); // Завершаем прогресс-бар }; const supportsFileSystemAccess = "showSaveFilePicker" in unsafeWindow && (() => { try { return unsafeWindow.self === unsafeWindow.top; } catch { return false; } })(); if (supportsFileSystemAccess) { unsafeWindow .showSaveFilePicker({ suggestedName: fileName, types: [{ description: 'Video Files', accept: { 'video/*': ['.mp4', '.webm', '.ogg', '.mov'] } }] }) .then((handle) => { handle .createWritable() .then((writable) => { fetchNextPart(writable); }) .catch((err) => { logger.error(`Error creating writable: ${err.name} - ${err.message}`, fileName); abortProgress(videoId); }); }) .catch((err) => { if (err.name !== "AbortError") { logger.error(`Error showing save file picker: ${err.name} - ${err.message}`, fileName); abortProgress(videoId); } else { logger.info("File save dialog aborted by user.", fileName); abortProgress(videoId); // Отмечаем как отмененное } }); } else { fetchNextPart(null); } }; const tel_download_audio = (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}`; // Можно добавить логику для извлечения имени файла из URL, если оно есть try { const urlObj = new URL(url); const lastSegment = urlObj.pathname.split('/').pop(); if (lastSegment) { const parts = lastSegment.split('.'); if (parts.length > 1) { const potentialExt = parts.pop(); if (potentialExt.length <= 4 && /^[a-zA-Z0-9]+$/.test(potentialExt)) { _file_extension = potentialExt; fileName = `${hashCode(url).toString(36)}.${_file_extension}`; } } } } catch (e) { logger.error(`Error parsing audio URL: ${e.message}`, url); } createProgressBar(audioId, fileName); // Создаем прогресс-бар для аудио const fetchNextPart = (writable) => { fetch(url, { method: "GET", headers: { Range: `bytes=${_next_offset}-`, }, }) .then((res) => { 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(`Received non-audio response with MIME type ${mime || 'N/A'}`); } _file_extension = mime.split("/")[1]; if (!fileName.endsWith(`.${_file_extension}`)) { const nameWithoutExt = fileName.includes('.') ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName; fileName = `${nameWithoutExt}.${_file_extension}`; } const contentRangeHeader = res.headers.get("Content-Range"); if (!contentRangeHeader) { if (res.status === 200) { _total_size = parseInt(res.headers.get("Content-Length")); _next_offset = _total_size; logger.info(`Full download detected for audio, total size: ${_total_size}`, fileName); return res.blob(); } else { throw new Error("Content-Range header missing for partial audio content."); } } const match = contentRangeHeader.match(contentRangeRegex); if (!match) { throw new Error("Invalid Content-Range header format for audio."); } 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}`); throw new Error("Gap detected between audio responses."); } if (_total_size && totalSize !== _total_size) { logger.error("Total audio size differs"); throw new Error("Total audio size differs"); } _next_offset = endOffset + 1; _total_size = totalSize; logger.info( `Get audio response: ${res.headers.get("Content-Length")} bytes data from ${res.headers.get("Content-Range")}`, fileName ); const progress = ((_next_offset * 100) / _total_size).toFixed(0); updateProgress(audioId, fileName, progress); return res.blob(); }) .then((resBlob) => { if (writable) { return writable.write(resBlob); } else { _blobs.push(resBlob); return Promise.resolve(); } }) .then(() => { if (_total_size === null) { throw new Error("_total_size is NULL for audio after first fetch"); } if (_next_offset < _total_size) { fetchNextPart(writable); } else { if (writable) { writable.close().then(() => { logger.info("Audio download finished (FileSystemAccess API)", fileName); completeProgress(audioId); }).catch(err => { logger.error(`Error closing audio writable: ${err.message}`, fileName); abortProgress(audioId); }); } else { saveAudioBlob(); } } }) .catch((reason) => { logger.error(`Audio download failed: ${reason.message || reason}`, fileName); abortProgress(audioId); }); }; const saveAudioBlob = () => { logger.info( "Finish downloading audio blobs. Concatenating blobs and downloading...", fileName ); const blob = new Blob(_blobs, { type: `audio/${_file_extension}` }); // Использование определенного расширения const blobUrl = window.URL.createObjectURL(blob); logger.info(`Final audio blob size in bytes: ${blob.size}`, fileName); // Нет необходимости в blob = 0; GC позаботится об этом // blob = 0; 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); }; const supportsFileSystemAccess = "showSaveFilePicker" in unsafeWindow && (() => { try { return unsafeWindow.self === unsafeWindow.top; } catch { return false; } })(); if (supportsFileSystemAccess) { unsafeWindow .showSaveFilePicker({ suggestedName: fileName, types: [{ description: 'Audio Files', accept: { 'audio/*': ['.ogg', '.mp3', '.wav', '.flac'] } }] }) .then((handle) => { handle .createWritable() .then((writable) => { fetchNextPart(writable); }) .catch((err) => { logger.error(`Error creating audio writable: ${err.name} - ${err.message}`, fileName); abortProgress(audioId); }); }) .catch((err) => { if (err.name !== "AbortError") { logger.error(`Error showing audio save file picker: ${err.name} - ${err.message}`, fileName); abortProgress(audioId); } else { logger.info("Audio file save dialog aborted by user.", fileName); abortProgress(audioId); } }); } else { fetchNextPart(null); } }; const tel_download_image = (imageUrl) => { // Попытаемся определить расширение из URL, если нет - по умолчанию 'jpeg' let fileName = (Math.random() + 1).toString(36).substring(2, 10); try { const urlObj = new URL(imageUrl); const pathSegments = urlObj.pathname.split('/'); const lastSegment = pathSegments[pathSegments.length - 1]; if (lastSegment && lastSegment.includes('.')) { const parts = lastSegment.split('.'); const potentialExt = parts[parts.length - 1]; if (potentialExt.length <= 5 && /^[a-zA-Z0-9]+$/.test(potentialExt)) { // Простая проверка на валидность fileName += `.${potentialExt}`; } else { fileName += ".jpeg"; // Fallback } } else { fileName += ".jpeg"; // Fallback } } catch (e) { logger.error(`Error processing image URL for filename: ${e.message}`, imageUrl); fileName += ".jpeg"; // Fallback в случае ошибки URL } const imageId = (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString(); createProgressBar(imageId, fileName); // Создаем прогресс-бар для изображений fetch(imageUrl) .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const contentType = response.headers.get("Content-Type"); if (contentType && !fileName.includes('.')) { // Если расширение не определено из URL const mimeTypeParts = contentType.split('/'); if (mimeTypeParts.length > 1) { const ext = mimeTypeParts[1]; if (ext === "jpeg") fileName += ".jpg"; // Общепринятое расширение else fileName += `.${ext}`; } } return response.blob(); }) .then(blob => { const blobUrl = window.URL.createObjectURL(blob); 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("Image download triggered", fileName); completeProgress(imageId); }) .catch(error => { logger.error(`Image download failed: ${error.message}`, fileName); abortProgress(imageId); }); }; // --- Конец функций загрузки --- logger.info("Initialized script."); // --- Функции для добавления кнопок --- const addDownloadButton = (container, type, url, prepend = false) => { // Проверяем, существует ли уже кнопка с этим URL, чтобы избежать дубликатов if (container.querySelector(`.tel-download[data-tel-download-url="${url}"]`)) { return; } const downloadIcon = document.createElement("i"); // Используем класс icon, если он есть, иначе просто span if (container.closest('#MediaViewer') || container.closest('#StoryViewer')) { // Для webz downloadIcon.className = "icon icon-download"; } else { // Для webk downloadIcon.className = "tgico button-icon"; downloadIcon.innerHTML = DOWNLOAD_ICON; } const downloadButton = document.createElement("button"); downloadButton.className = "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); // Добавляем специфичные классы для стилизации в webz/webk if (container.closest('#MediaViewer') || container.closest('#StoryViewer')) { // webz downloadButton.classList.add("Button", "smaller", "translucent-white", "round"); } else { // webk downloadButton.classList.add("btn-icon"); if (type === 'audio') { // Для аудио кнопок в webk, используем другой стиль downloadButton.classList.add("_tel_download_button_pinned_container"); } else { downloadButton.classList.add("tgico-download"); } downloadButton.innerHTML += '<div class="c-ripple"></div>'; // Для эффекта нажатия в webk } downloadButton.onclick = (e) => { e.stopPropagation(); // Предотвращаем всплытие события, которое может закрыть медиапросмотр if (type === 'video') { tel_download_video(url); } else if (type === 'audio') { tel_download_audio(url); } else if (type === 'image') { tel_download_image(url); } }; if (prepend) { container.prepend(downloadButton); } else { container.appendChild(downloadButton); } return downloadButton; }; // Использование MutationObserver для более эффективного добавления кнопок // и предотвращения дублирования setInterval const setupObservers = () => { // Observer для webz /a/ app const webzObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList' || mutation.type === 'attributes') { // Истории (webz) const storiesContainer = document.getElementById("StoryViewer"); if (storiesContainer) { const storyHeader = storiesContainer.querySelector(".GrsJNw3y") || storiesContainer.querySelector(".DropdownMenu")?.parentNode; if (storyHeader) { const video = storiesContainer.querySelector("video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; const images = storiesContainer.querySelectorAll("img.PVZ8TOWS"); const imageSrc = images.length > 0 ? images[images.length - 1]?.src : null; if (videoSrc) { addDownloadButton(storyHeader, 'video', videoSrc, true); } else if (imageSrc) { addDownloadButton(storyHeader, 'image', imageSrc, true); } } } // Медиапросмотр (webz) const mediaContainer = document.querySelector("#MediaViewer .MediaViewerSlide--active"); const mediaViewerActions = document.querySelector("#MediaViewer .MediaViewerActions"); if (mediaContainer && mediaViewerActions) { const videoPlayer = mediaContainer.querySelector(".MediaViewerContent > .VideoPlayer"); const img = mediaContainer.querySelector(".MediaViewerContent > div > img"); if (videoPlayer) { const videoUrl = videoPlayer.querySelector("video")?.currentSrc; if (videoUrl) { // Кнопка в контролах видео const controls = videoPlayer.querySelector(".VideoPlayerControls"); if (controls) { const buttons = controls.querySelector(".buttons"); if (buttons) { addDownloadButton(buttons, 'video', videoUrl); } } // Кнопка в верхней панели MediaViewerActions addDownloadButton(mediaViewerActions, 'video', videoUrl, true); } } else if (img && img.src) { addDownloadButton(mediaViewerActions, 'image', img.src, true); } } } }); }); webzObserver.observe(document.body, { childList: true, subtree: true, attributes: true }); // Observer для webk /k/ app const webkObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList' || mutation.type === 'attributes') { // Аудиосообщения и кружочки (webk) document.querySelectorAll("audio-element").forEach((audioElement) => { const bubble = audioElement.closest(".bubble"); const link = audioElement.audio?.src; if (bubble && link) { // Проверяем bubble и link const isAudio = audioElement.audio instanceof HTMLAudioElement; const container = bubble.querySelector(".message-body-wrapper .bubble-content"); // Или другой подходящий контейнер if (container) { // Для голосовых сообщений и кружочков, добавляем кнопку рядом // Проверяем, что кнопка еще не добавлена в этот конкретный пузырь if (!container.querySelector('.tel-download[data-tel-download-url]')) { addDownloadButton(container, isAudio ? 'audio' : 'video', link); } } } }); // Истории (webk) const storiesContainer = document.getElementById("stories-viewer"); if (storiesContainer) { const storyHeader = storiesContainer.querySelector("[class^='_ViewerStoryHeaderRight']"); const storyFooter = storiesContainer.querySelector("[class^='_ViewerStoryFooterRight']"); const video = storiesContainer.querySelector("video.media-video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; const imageSrc = storiesContainer.querySelector("img.media-photo")?.src; if (storyHeader) { if (videoSrc) addDownloadButton(storyHeader, 'video', videoSrc, true); else if (imageSrc) addDownloadButton(storyHeader, 'image', imageSrc, true); } if (storyFooter) { if (videoSrc) addDownloadButton(storyFooter, 'video', videoSrc, true); else if (imageSrc) addDownloadButton(storyFooter, 'image', imageSrc, true); } } // Медиапросмотр (webk) const mediaContainer = document.querySelector(".media-viewer-whole"); if (mediaContainer) { const mediaAspecter = mediaContainer.querySelector(".media-viewer-movers .media-viewer-aspecter"); const mediaButtons = mediaContainer.querySelector(".media-viewer-topbar .media-viewer-buttons"); if (mediaAspecter && mediaButtons) { // Раскрываем скрытые официальные кнопки const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide"); for (const btn of hiddenButtons) { btn.classList.remove("hide"); if (btn.textContent === FORWARD_ICON) { btn.classList.add("tgico-forward"); } if (btn.textContent === DOWNLOAD_ICON) { btn.classList.add("tgico-download"); } } const videoElement = mediaAspecter.querySelector("video"); const imgElement = mediaAspecter.querySelector("img.thumbnail"); if (videoElement && videoElement.src) { addDownloadButton(mediaButtons, 'video', videoElement.src, true); // Дополнительная кнопка в контролах видеоплеера const controls = mediaAspecter.querySelector(".default__controls.ckin__controls"); if (controls) { const brControls = controls.querySelector(".bottom-controls .right-controls"); if (brControls) { addDownloadButton(brControls, 'video', videoElement.src, true); } } } else if (imgElement && imgElement.src) { addDownloadButton(mediaButtons, 'image', imgElement.src, true); } } } } }); }); webkObserver.observe(document.body, { childList: true, subtree: true, attributes: true }); }; // End of setupObservers // Прогресс-бар контейнер function setupProgressBar() { const body = document.querySelector("body"); let container = document.getElementById("tel-downloader-progress-bar-container"); if (container) return; // Уже существует container = document.createElement("div"); container.id = "tel-downloader-progress-bar-container"; container.style.position = "fixed"; container.style.bottom = "1rem"; // Отступ от низа container.style.right = "1rem"; // Отступ от права container.style.display = "flex"; container.style.flexDirection = "column"; container.style.gap = "0.5rem"; // Расстояние между прогресс-барами if (location.pathname.startsWith("/k/")) { container.style.zIndex = 4; } else { container.style.zIndex = 1600; } body.appendChild(container); logger.info("Progress bar container created."); } // Запускаем наблюдателей после инициализации скрипта // Вместо setInterval для добавления кнопок, используем MutationObserver // Однако, так как Telegram динамически загружает контент, // мы можем оставить setInterval для периодической проверки, // или использовать observer более специфично. // Для простоты, пока оставим setInterval, но с учетом, что addDownloadButton // предотвратит дубликаты. В более сложной реализации MutationObserver будет лучше. // Используем setInterval для периодического вызова функции, // которая будет запускать логику добавления кнопок. // Это менее эффективно, чем MutationObserver, но проще в реализации для динамически // появляющихся элементов в Telegram Web. Функция addDownloadButton предотвратит дублирование. // NOTE: Original script used setInterval, I will keep that pattern, // but wrap the logic into a single function to avoid redundancy. setInterval(() => { // webz /a/ app const storiesContainerWebz = document.getElementById("StoryViewer"); if (storiesContainerWebz) { const storyHeader = storiesContainerWebz.querySelector(".GrsJNw3y") || storiesContainerWebz.querySelector(".DropdownMenu")?.parentNode; if (storyHeader) { const video = storiesContainerWebz.querySelector("video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; const images = storiesContainerWebz.querySelectorAll("img.PVZ8TOWS"); const imageSrc = images.length > 0 ? images[images.length - 1]?.src : null; if (videoSrc) { addDownloadButton(storyHeader, 'video', videoSrc, true); } else if (imageSrc) { addDownloadButton(storyHeader, 'image', imageSrc, true); } } } const mediaContainerWebz = document.querySelector("#MediaViewer .MediaViewerSlide--active"); const mediaViewerActionsWebz = document.querySelector("#MediaViewer .MediaViewerActions"); if (mediaContainerWebz && mediaViewerActionsWebz) { const videoPlayer = mediaContainerWebz.querySelector(".MediaViewerContent > .VideoPlayer"); const img = mediaContainerWebz.querySelector(".MediaViewerContent > div > img"); if (videoPlayer) { const videoUrl = videoPlayer.querySelector("video")?.currentSrc; if (videoUrl) { const controls = videoPlayer.querySelector(".VideoPlayerControls"); if (controls) { const buttons = controls.querySelector(".buttons"); if (buttons) addDownloadButton(buttons, 'video', videoUrl); } addDownloadButton(mediaViewerActionsWebz, 'video', videoUrl, true); } } else if (img && img.src) { addDownloadButton(mediaViewerActionsWebz, 'image', img.src, true); } } // webk /k/ app document.querySelectorAll("audio-element").forEach((audioElement) => { const bubble = audioElement.closest(".bubble"); const link = audioElement.audio?.src; if (bubble && link) { const isAudio = audioElement.audio instanceof HTMLAudioElement; const container = bubble.querySelector(".message-body-wrapper .bubble-content"); if (container) { // Это добавит кнопку для каждого аудиоэлемента, который еще не имеет кнопки addDownloadButton(container, isAudio ? 'audio' : 'video', link); } } }); const storiesContainerWebk = document.getElementById("stories-viewer"); if (storiesContainerWebk) { const storyHeader = storiesContainerWebk.querySelector("[class^='_ViewerStoryHeaderRight']"); const storyFooter = storiesContainerWebk.querySelector("[class^='_ViewerStoryFooterRight']"); const video = storiesContainerWebk.querySelector("video.media-video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; const imageSrc = storiesContainerWebk.querySelector("img.media-photo")?.src; if (storyHeader) { if (videoSrc) addDownloadButton(storyHeader, 'video', videoSrc, true); else if (imageSrc) addDownloadButton(storyHeader, 'image', imageSrc, true); } if (storyFooter) { if (videoSrc) addDownloadButton(storyFooter, 'video', videoSrc, true); else if (imageSrc) addDownloadButton(storyFooter, 'image', imageSrc, true); } } const mediaContainerWebk = document.querySelector(".media-viewer-whole"); if (mediaContainerWebk) { const mediaAspecter = mediaContainerWebk.querySelector(".media-viewer-movers .media-viewer-aspecter"); const mediaButtons = mediaContainerWebk.querySelector(".media-viewer-topbar .media-viewer-buttons"); if (mediaAspecter && mediaButtons) { // Раскрываем скрытые официальные кнопки const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide"); for (const btn of hiddenButtons) { btn.classList.remove("hide"); if (btn.textContent === FORWARD_ICON) { btn.classList.add("tgico-forward"); } if (btn.textContent === DOWNLOAD_ICON) { btn.classList.add("tgico-download"); // Если есть официальная кнопка, мы можем просто нажать ее // Но для единообразия и прогресс-бара, все равно используем наш метод // btn.onclick = () => btn.click(); // Или можно оставить это } } const videoElement = mediaAspecter.querySelector("video"); const imgElement = mediaAspecter.querySelector("img.thumbnail"); if (videoElement && videoElement.src) { addDownloadButton(mediaButtons, 'video', videoElement.src, true); const controls = mediaAspecter.querySelector(".default__controls.ckin__controls"); if (controls) { const brControls = controls.querySelector(".bottom-controls .right-controls"); if (brControls) addDownloadButton(brControls, 'video', videoElement.src, true); } } else if (imgElement && imgElement.src) { addDownloadButton(mediaButtons, 'image', imgElement.src, true); } } } }, REFRESH_DELAY); setupProgressBar(); // Убедимся, что контейнер прогресс-бара создан при запуске logger.info("Completed script setup and started monitoring."); })();