// ==UserScript==
// @name Telegram 受限媒体下载器
// @namespace https://github.com/weiruankeji2025/weiruan-Telegram
// @version 1.5.6
// @description 下载 Telegram Web 中的受限图片和视频
// @author WeiRuan Tech
// @match https://web.telegram.org/*
// @match https://*.web.telegram.org/*
// @icon https://telegram.org/favicon.ico
// @grant GM_download
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_notification
// @grant GM_registerMenuCommand
// @run-at document-start
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/560901/Telegram%20%E5%8F%97%E9%99%90%E5%AA%92%E4%BD%93%E4%B8%8B%E8%BD%BD%E5%99%A8.user.js
// @updateURL https://update.greasyfork.icu/scripts/560901/Telegram%20%E5%8F%97%E9%99%90%E5%AA%92%E4%BD%93%E4%B8%8B%E8%BD%BD%E5%99%A8.meta.js
// ==/UserScript==
(function() {
'use strict';
// 配置
const CONFIG = {
downloadPath: GM_getValue('downloadPath', 'Telegram'),
notifyOnDownload: GM_getValue('notifyOnDownload', true),
buttonPosition: GM_getValue('buttonPosition', 'top-right'), // top-right, top-left, bottom-right, bottom-left
};
// 保存配置
function saveConfig() {
GM_setValue('downloadPath', CONFIG.downloadPath);
GM_setValue('notifyOnDownload', CONFIG.notifyOnDownload);
GM_setValue('buttonPosition', CONFIG.buttonPosition);
}
// Content-Range 正则
const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
// 正在下载的视频集合(防止重复下载)
const downloadingVideos = new Set();
// Hash函数
const hashCode = (s) => {
var h = 0, l = s.length, i = 0;
if (l > 0) {
while (i < l) {
h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
}
}
return h >>> 0;
};
// 通知
function notify(title, message) {
if (!CONFIG.notifyOnDownload) return;
GM_notification({
title: title,
text: message,
timeout: 2000
});
}
// 创建进度条
function createProgressBar(videoId, fileName) {
const isDarkMode = document.querySelector('html').classList.contains('night') ||
document.querySelector('html').classList.contains('theme-dark');
const container = document.getElementById('tg-progress-container');
const item = document.createElement('div');
item.id = 'tg-progress-' + videoId;
item.style.cssText = `width:20rem;margin-top:0.4rem;padding:0.6rem;background-color:${isDarkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.6)'};border-radius:8px;`;
const header = document.createElement('div');
header.style.cssText = 'display:flex;justify-content:space-between;margin-bottom:8px;';
const title = document.createElement('p');
title.className = 'filename';
title.style.cssText = 'margin:0;color:white;font-size:13px;max-width:16rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
title.innerText = fileName;
const closeBtn = document.createElement('div');
closeBtn.style.cssText = `cursor:pointer;font-size:1.2rem;color:${isDarkMode ? '#8a8a8a' : 'white'};`;
closeBtn.innerHTML = '×';
closeBtn.onclick = () => container.removeChild(item);
const progressBar = document.createElement('div');
progressBar.style.cssText = 'background-color:#e2e2e2;position:relative;width:100%;height:1.6rem;border-radius:2rem;overflow:hidden;';
const counter = document.createElement('p');
counter.style.cssText = 'position:absolute;z-index:5;left:50%;top:50%;transform:translate(-50%,-50%);margin:0;color:black;font-size:12px;font-weight:bold;';
counter.innerText = '0%';
const progress = document.createElement('div');
progress.style.cssText = 'position:absolute;height:100%;width:0%;background-color:#6093B5;transition:width 0.3s ease;';
progressBar.appendChild(counter);
progressBar.appendChild(progress);
header.appendChild(title);
header.appendChild(closeBtn);
item.appendChild(header);
item.appendChild(progressBar);
container.appendChild(item);
}
// 更新进度
function updateProgress(videoId, fileName, percent) {
const item = document.getElementById('tg-progress-' + videoId);
if (!item) return;
item.querySelector('p.filename').innerText = fileName;
const bar = item.querySelector('div div:last-child');
const text = item.querySelector('div p');
text.innerText = percent + '%';
bar.style.width = percent + '%';
}
// 完成进度
function completeProgress(videoId) {
const item = document.getElementById('tg-progress-' + videoId);
if (!item) return;
const bar = item.querySelector('div div:last-child');
const text = item.querySelector('div p');
text.innerText = '完成';
bar.style.backgroundColor = '#B6C649';
bar.style.width = '100%';
}
// 中止进度
function abortProgress(videoId) {
const item = document.getElementById('tg-progress-' + videoId);
if (!item) return;
const bar = item.querySelector('div div:last-child');
const text = item.querySelector('div p');
text.innerText = '失败';
bar.style.backgroundColor = '#D16666';
bar.style.width = '100%';
}
// 分块下载视频(优化版:并发下载提速)
async function downloadVideo(url) {
// 使用URL的hash作为唯一ID
const videoId = hashCode(url).toString(36);
// 检查是否已经在下载中(使用Set防止重复)
if (downloadingVideos.has(videoId)) {
console.log('[下载] 该视频已在下载中,跳过');
return;
}
// 添加到下载中集合
downloadingVideos.add(videoId);
let fileExtension = 'mp4';
let fileName = videoId + '.' + fileExtension;
// 提取文件名
try {
const metadata = JSON.parse(decodeURIComponent(url.split('/')[url.split('/').length - 1]));
if (metadata.fileName) fileName = metadata.fileName;
if (metadata.mimeType) fileExtension = metadata.mimeType.split('/')[1];
} catch (e) {}
createProgressBar(videoId, fileName);
try {
// 发送第一个Range请求获取文件信息(避免HEAD请求失败)
console.log('[下载] 开始下载...');
const firstRes = await fetch(url, {
method: 'GET',
headers: { 'Range': 'bytes=0-' },
credentials: 'include'
});
if (![200, 206].includes(firstRes.status)) {
throw new Error(`HTTP ${firstRes.status}`);
}
// 获取MIME类型
const mime = firstRes.headers.get('Content-Type')?.split(';')[0];
if (mime && mime.startsWith('video/')) {
fileExtension = mime.split('/')[1];
if (!fileName.includes('.')) {
fileName = videoId + '.' + fileExtension;
}
}
// 检查是否支持Range请求
const contentRange = firstRes.headers.get('Content-Range');
// 不支持Range或返回200(完整文件),直接下载
if (firstRes.status === 200 || !contentRange) {
console.log('[下载] 使用直接下载模式');
const blob = await firstRes.blob();
completeProgress(videoId);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
notify('下载完成', fileName);
downloadingVideos.delete(videoId);
return;
}
// 解析Content-Range获取总大小
const rangeMatch = contentRange.match(contentRangeRegex);
if (!rangeMatch) {
throw new Error('无法解析Content-Range');
}
const totalSize = parseInt(rangeMatch[3]);
console.log(`[下载] 文件大小: ${(totalSize / 1024 / 1024).toFixed(2)}MB`);
// 小文件(<5MB)直接下载,不并发
if (totalSize < 5 * 1024 * 1024) {
console.log('[下载] 小文件,使用直接下载');
const blob = await firstRes.blob();
completeProgress(videoId);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
notify('下载完成', fileName);
downloadingVideos.delete(videoId);
return;
}
// 大文件:使用并发分块下载(提速)
console.log('[下载] 大文件,使用并发分块下载');
const chunkSize = 2 * 1024 * 1024; // 2MB per chunk
const chunks = [];
const totalChunks = Math.ceil(totalSize / chunkSize);
// 第一个chunk已经下载了,保存它
chunks[0] = await firstRes.blob();
let downloadedSize = chunks[0].size;
updateProgress(videoId, fileName, Math.round((downloadedSize * 100) / totalSize));
// 并发下载剩余chunk(最多4个并发)
const concurrency = 4;
for (let i = 1; i < totalChunks; i += concurrency) {
const batchPromises = [];
for (let j = 0; j < concurrency && (i + j) < totalChunks; j++) {
const chunkIndex = i + j;
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize - 1, totalSize - 1);
const promise = fetch(url, {
method: 'GET',
headers: { 'Range': `bytes=${start}-${end}` },
credentials: 'include'
}).then(res => {
if (![200, 206].includes(res.status)) {
throw new Error(`HTTP ${res.status}`);
}
return res.blob();
}).then(blob => {
chunks[chunkIndex] = blob;
downloadedSize += blob.size;
updateProgress(videoId, fileName, Math.round((downloadedSize * 100) / totalSize));
});
batchPromises.push(promise);
}
await Promise.all(batchPromises);
}
// 合并所有分块
console.log('[下载] 合并分块中...');
const blob = new Blob(chunks, { type: `video/${fileExtension}` });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
completeProgress(videoId);
notify('下载完成', fileName);
} catch (error) {
console.error('[下载错误]', error);
abortProgress(videoId);
notify('下载失败', error.message);
} finally {
// 从下载中集合移除
downloadingVideos.delete(videoId);
}
}
// Canvas捕获图片
async function captureImage(imgElement) {
return new Promise((resolve, reject) => {
try {
const canvas = document.createElement('canvas');
canvas.width = imgElement.naturalWidth || imgElement.width;
canvas.height = imgElement.naturalHeight || imgElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgElement, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob));
} else {
reject(new Error('Canvas转换失败'));
}
}, 'image/png', 1.0);
} catch (error) {
reject(error);
}
});
}
// 备用下载
async function fallbackDownload(url, filename, mediaType, sourceElement) {
try {
let blobUrl;
// 视频 - 使用分块下载
if (mediaType === 'video') {
await downloadVideo(url);
return;
}
// 图片 - 使用Canvas
if (mediaType === 'image' && sourceElement && sourceElement.tagName === 'IMG') {
if (!sourceElement.complete) {
await new Promise((resolve, reject) => {
sourceElement.onload = resolve;
sourceElement.onerror = () => reject(new Error('图片加载失败'));
setTimeout(() => reject(new Error('超时')), 10000);
});
}
blobUrl = await captureImage(sourceElement);
}
// Blob URL
else if (url.startsWith('blob:') || url.startsWith('data:')) {
blobUrl = url;
}
// 普通下载
else {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
blobUrl = URL.createObjectURL(blob);
}
// 下载
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
if (!url.startsWith('data:') && !url.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
}
}, 100);
notify('下载完成', filename);
} catch (error) {
console.error('[下载错误]', error);
throw error;
}
}
// 下载媒体
async function downloadMedia(url, mediaType, sourceElement = null) {
const timestamp = Date.now();
const ext = mediaType === 'video' ? 'mp4' : 'jpg';
const baseFilename = `telegram_${mediaType}_${timestamp}.${ext}`;
try {
await fallbackDownload(url, baseFilename, mediaType, sourceElement);
} catch (error) {
notify('下载失败', error.message);
}
}
// 获取最佳质量URL
function getBestQualityUrl(element, mediaType) {
if (mediaType === 'video') {
const source = element.querySelector('source');
if (source?.src) return source.src;
if (element.src) return element.src;
return element.getAttribute('data-src') || element.getAttribute('data-video');
} else {
if (element.src) return element.src;
const srcset = element.srcset;
if (srcset) {
const sources = srcset.split(',').map(s => s.trim().split(' '));
const sorted = sources.sort((a, b) => {
const sizeA = parseInt(a[1]) || 0;
const sizeB = parseInt(b[1]) || 0;
return sizeB - sizeA;
});
if (sorted.length > 0) return sorted[0][0];
}
return element.getAttribute('data-src') || element.getAttribute('data-image');
}
}
// 检测聊天列表
function isInChatListOrSidebar(element) {
return element.closest('.chat-list') ||
element.closest('.chatlist') ||
element.closest('[class*="ChatList"]') ||
element.closest('[class*="DialogList"]') ||
element.closest('.sidebar') ||
element.closest('[class*="Sidebar"]');
}
// 检测真实媒体内容
function isActualMediaContent(element, container) {
return container.closest('.media-viewer') ||
container.closest('.MediaViewer') ||
container.closest('.message-media') ||
container.closest('[class*="MediaViewer"]') ||
container.closest('[class*="MessageMedia"]');
}
// 创建下载按钮
function createDownloadButton(mediaElement, mediaUrl, mediaType, container) {
const button = document.createElement('button');
button.className = 'tg-download-btn';
button.innerHTML = `
下载${mediaType === 'video' ? '视频' : '图片'}
`;
// 根据配置设置按钮位置
const positions = {
'top-right': 'top:10px;right:10px;',
'top-left': 'top:10px;left:10px;',
'bottom-right': 'bottom:10px;right:10px;',
'bottom-left': 'bottom:10px;left:10px;'
};
const positionStyle = positions[CONFIG.buttonPosition] || positions['top-right'];
button.style.cssText = `position:absolute;${positionStyle}padding:8px 16px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;border:none;border-radius:20px;cursor:pointer;font-size:14px;font-weight:bold;display:flex;align-items:center;gap:8px;box-shadow:0 4px 6px rgba(0,0,0,0.1);z-index:1000;opacity:0.9;transition:all 0.3s;`;
button.addEventListener('click', async (e) => {
e.stopPropagation();
e.preventDefault();
await downloadMedia(mediaUrl, mediaType, mediaElement);
});
button.addEventListener('mouseenter', () => {
button.style.opacity = '1';
button.style.transform = 'scale(1.05)';
});
button.addEventListener('mouseleave', () => {
button.style.opacity = '0.9';
button.style.transform = 'scale(1)';
});
return button;
}
// 处理媒体元素
function processMediaElement(element, mediaType) {
if (element.hasAttribute('data-tg-processed')) return;
element.setAttribute('data-tg-processed', 'true');
if (isInChatListOrSidebar(element)) return;
let container = element.closest('.media-viewer-content') ||
element.closest('.media-viewer') ||
element.closest('.message-media') ||
element.closest('[class*="Media"]') ||
element.parentElement;
if (!container) container = element;
if (!isActualMediaContent(element, container)) return;
const url = getBestQualityUrl(element, mediaType);
if (!url) return;
if (window.getComputedStyle(container).position === 'static') {
container.style.position = 'relative';
}
const button = createDownloadButton(element, url, mediaType, container);
container.appendChild(button);
}
// 观察器
function startObserving() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// 图片
if (node.tagName === 'IMG') {
processMediaElement(node, 'image');
}
node.querySelectorAll?.('img').forEach(img => {
processMediaElement(img, 'image');
});
// 视频
if (node.tagName === 'VIDEO') {
processMediaElement(node, 'video');
}
node.querySelectorAll?.('video').forEach(video => {
processMediaElement(video, 'video');
});
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// 初始扫描
document.querySelectorAll('img').forEach(img => processMediaElement(img, 'image'));
document.querySelectorAll('video').forEach(video => processMediaElement(video, 'video'));
}
// CSS样式
function addStyles() {
const style = document.createElement('style');
style.textContent = `
.tg-download-btn-icon { width: 16px; height: 16px; fill: currentColor; }
`;
document.head.appendChild(style);
}
// 进度条容器
function setupProgressContainer() {
const container = document.createElement('div');
container.id = 'tg-progress-container';
container.style.cssText = 'position:fixed;bottom:0;right:0;z-index:9999;';
document.body.appendChild(container);
}
// 注册菜单命令
function registerMenuCommands() {
GM_registerMenuCommand('📍 按钮位置: 右上角', () => {
CONFIG.buttonPosition = 'top-right';
saveConfig();
alert('✅ 按钮位置已设置为:右上角\n\n刷新页面后生效');
});
GM_registerMenuCommand('📍 按钮位置: 左上角', () => {
CONFIG.buttonPosition = 'top-left';
saveConfig();
alert('✅ 按钮位置已设置为:左上角\n\n刷新页面后生效');
});
GM_registerMenuCommand('📍 按钮位置: 右下角', () => {
CONFIG.buttonPosition = 'bottom-right';
saveConfig();
alert('✅ 按钮位置已设置为:右下角\n\n刷新页面后生效');
});
GM_registerMenuCommand('📍 按钮位置: 左下角', () => {
CONFIG.buttonPosition = 'bottom-left';
saveConfig();
alert('✅ 按钮位置已设置为:左下角\n\n刷新页面后生效');
});
}
// 初始化
function init() {
addStyles();
setupProgressContainer();
startObserving();
registerMenuCommands();
console.log('[Telegram下载器] v1.5.5 已加载');
console.log('[配置] 按钮位置:', CONFIG.buttonPosition);
}
// 启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();