Telegram Media Downloader

Allow you to download images, GIFs, videos, and voice messages on Telegram webapp from private channels which disable downloading and restrict saving content

目前為 2023-11-09 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Telegram Media Downloader
  3. // @name:zh-CN Telegram下载器
  4. // @version 1.053
  5. // @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader
  6. // @description Allow you to download images, GIFs, videos, and voice messages on Telegram webapp from private channels which disable downloading and restrict saving content
  7. // @description:zh-cn 从禁止下载的Telegram频道中下载图片、视频及语音消息
  8. // @author Nestor Qin
  9. // @license GNU GPLv3
  10. // @website https://github.com/Neet-Nestor/Telegram-Media-Downloader
  11. // @match https://web.telegram.org/*
  12. // @match https://webk.telegram.org/*
  13. // @match https://webz.telegram.org/*
  14. // @icon https://img.icons8.com/color/452/telegram-app--v5.png
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. const logger = {
  19. info: (message, fileName = null) => {
  20. console.log(
  21. `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
  22. );
  23. },
  24. error: (message, fileName = null) => {
  25. console.error(
  26. `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
  27. );
  28. },
  29. };
  30. const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  31. const REFRESH_DELAY = 500;
  32.  
  33. const tel_download_video = (url) => {
  34. let _blobs = [];
  35. let _next_offset = 0;
  36. let _total_size = null;
  37. let _file_extension = "mp4";
  38.  
  39. let fileName =
  40. (Math.random() + 1).toString(36).substring(2, 10) + "." + _file_extension;
  41.  
  42. // Some video src is in format:
  43. // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
  44. try {
  45. const metadata = JSON.parse(
  46. decodeURIComponent(url.split("/")[url.split("/").length - 1])
  47. );
  48. if (metadata.fileName) {
  49. fileName = metadata.fileName;
  50. }
  51. } catch (e) {
  52. // Invalid JSON string, pass extracting fileName
  53. }
  54. logger.info(`URL: ${url}`, fileName);
  55.  
  56. const fetchNextPart = () => {
  57. fetch(url, {
  58. method: "GET",
  59. headers: {
  60. Range: `bytes=${_next_offset}-`,
  61. },
  62. "User-Agent":
  63. "User-Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
  64. })
  65. .then((res) => {
  66. if (![200, 206].includes(res.status)) {
  67. throw new Error("Non 200/206 response was received: " + res.status);
  68. }
  69. const mime = res.headers.get("Content-Type").split(";")[0];
  70. if (!mime.startsWith("video/")) {
  71. throw new Error("Get non video response with MIME type " + mime);
  72. }
  73. _file_extension = mime.split("/")[1];
  74. fileName =
  75. fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;
  76.  
  77. const match = res.headers
  78. .get("Content-Range")
  79. .match(contentRangeRegex);
  80.  
  81. const startOffset = parseInt(match[1]);
  82. const endOffset = parseInt(match[2]);
  83. const totalSize = parseInt(match[3]);
  84.  
  85. if (startOffset !== _next_offset) {
  86. logger.error("Gap detected between responses.", fileName);
  87. logger.info("Last offset: " + _next_offset, fileName);
  88. logger.info("New start offset " + match[1], fileName);
  89. throw "Gap detected between responses.";
  90. }
  91. if (_total_size && totalSize !== _total_size) {
  92. logger.error("Total size differs", fileName);
  93. throw "Total size differs";
  94. }
  95.  
  96. _next_offset = endOffset + 1;
  97. _total_size = totalSize;
  98.  
  99. logger.info(
  100. `Get response: ${res.headers.get(
  101. "Content-Length"
  102. )} bytes data from ${res.headers.get("Content-Range")}`,
  103. fileName
  104. );
  105. logger.info(
  106. `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`,
  107. fileName
  108. );
  109. return res.blob();
  110. })
  111. .then((resBlob) => {
  112. _blobs.push(resBlob);
  113. })
  114. .then(() => {
  115. if (!_total_size) {
  116. throw new Error("_total_size is NULL");
  117. }
  118.  
  119. if (_next_offset < _total_size) {
  120. fetchNextPart();
  121. } else {
  122. save();
  123. }
  124. })
  125. .catch((reason) => {
  126. logger.error(reason, fileName);
  127. });
  128. };
  129.  
  130. const save = () => {
  131. logger.info("Finish downloading blobs", fileName);
  132. logger.info("Concatenating blobs and downloading...", fileName);
  133.  
  134. const blob = new Blob(_blobs, { type: "video/mp4" });
  135. const blobUrl = window.URL.createObjectURL(blob);
  136.  
  137. logger.info("Final blob size: " + blob.size + " bytes", fileName);
  138.  
  139. const a = document.createElement("a");
  140. document.body.appendChild(a);
  141. a.href = blobUrl;
  142. a.download = fileName;
  143. a.click();
  144. document.body.removeChild(a);
  145. window.URL.revokeObjectURL(blobUrl);
  146.  
  147. logger.info("Download triggered", fileName);
  148. };
  149.  
  150. fetchNextPart();
  151. };
  152.  
  153. const tel_download_audio = (url) => {
  154. let _blobs = [];
  155. let _next_offset = 0;
  156. let _total_size = null;
  157. const fileName = (Math.random() + 1).toString(36).substring(2, 10) + ".ogg";
  158.  
  159. const fetchNextPart = () => {
  160. fetch(url, {
  161. method: "GET",
  162. headers: {
  163. Range: `bytes=${_next_offset}-`,
  164. },
  165. })
  166. .then((res) => {
  167. if (res.status !== 206 && res.status !== 200) {
  168. logger.error(
  169. "Non 200/206 response was received: " + res.status,
  170. fileName
  171. );
  172. return;
  173. }
  174.  
  175. const mime = res.headers.get("Content-Type").split(";")[0];
  176. if (!mime.startsWith("audio/")) {
  177. logger.error(
  178. "Get non audio response with MIME type " + mime,
  179. fileName
  180. );
  181. throw "Get non audio response with MIME type " + mime;
  182. }
  183.  
  184. try {
  185. const match = res.headers
  186. .get("Content-Range")
  187. .match(contentRangeRegex);
  188.  
  189. const startOffset = parseInt(match[1]);
  190. const endOffset = parseInt(match[2]);
  191. const totalSize = parseInt(match[3]);
  192.  
  193. if (startOffset !== _next_offset) {
  194. logger.error("Gap detected between responses.");
  195. logger.info("Last offset: " + _next_offset);
  196. logger.info("New start offset " + match[1]);
  197. throw "Gap detected between responses.";
  198. }
  199. if (_total_size && totalSize !== _total_size) {
  200. logger.error("Total size differs");
  201. throw "Total size differs";
  202. }
  203.  
  204. _next_offset = endOffset + 1;
  205. _total_size = totalSize;
  206. } finally {
  207. logger.info(
  208. `Get response: ${res.headers.get(
  209. "Content-Length"
  210. )} bytes data from ${res.headers.get("Content-Range")}`
  211. );
  212. return res.blob();
  213. }
  214. })
  215. .then((resBlob) => {
  216. _blobs.push(resBlob);
  217. })
  218. .then(() => {
  219. if (_next_offset < _total_size) {
  220. fetchNextPart();
  221. } else {
  222. save();
  223. }
  224. })
  225. .catch((reason) => {
  226. logger.error(reason, fileName);
  227. });
  228. };
  229.  
  230. const save = () => {
  231. logger.info(
  232. "Finish downloading blobs. Concatenating blobs and downloading...",
  233. fileName
  234. );
  235.  
  236. let blob = new Blob(_blobs, { type: "audio/ogg" });
  237. const blobUrl = window.URL.createObjectURL(blob);
  238.  
  239. logger.info("Final blob size in bytes: " + blob.size, fileName);
  240.  
  241. blob = 0;
  242.  
  243. const a = document.createElement("a");
  244. document.body.appendChild(a);
  245. a.href = blobUrl;
  246. a.download = fileName;
  247. a.click();
  248. document.body.removeChild(a);
  249. window.URL.revokeObjectURL(blobUrl);
  250.  
  251. logger.info("Download triggered", fileName);
  252. };
  253.  
  254. fetchNextPart();
  255. };
  256.  
  257. const tel_download_image = (imageUrl) => {
  258. const fileName =
  259. (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume jpeg
  260.  
  261. const a = document.createElement("a");
  262. document.body.appendChild(a);
  263. a.href = imageUrl;
  264. a.download = fileName;
  265. a.click();
  266. document.body.removeChild(a);
  267.  
  268. logger.info("Download triggered", fileName);
  269. };
  270.  
  271. logger.info("Initialized");
  272.  
  273. // For webz /a/ webapp
  274. setInterval(() => {
  275. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  276. const mediaContainer = document.querySelector(
  277. "#MediaViewer .MediaViewerSlide--active"
  278. );
  279. if (!mediaContainer) return;
  280. const mediaViewerActions = document.querySelector(
  281. "#MediaViewer .MediaViewerActions"
  282. );
  283. if (!mediaViewerActions) return;
  284.  
  285. const videoPlayer = mediaContainer.querySelector(
  286. ".MediaViewerContent > .VideoPlayer"
  287. );
  288. const img = mediaContainer.querySelector(".MediaViewerContent > div > img");
  289. // 1. Video player detected - Video or GIF
  290. // container > .MediaViewerSlides > .MediaViewerSlide > .MediaViewerContent > .VideoPlayer > video[src]
  291. const downloadIcon = document.createElement("i");
  292. downloadIcon.className = "icon icon-download";
  293. const downloadButton = document.createElement("button");
  294. downloadButton.className =
  295. "Button smaller translucent-white round tel-download";
  296. downloadButton.setAttribute("type", "button");
  297. downloadButton.setAttribute("title", "Download");
  298. downloadButton.setAttribute("aria-label", "Download");
  299. if (videoPlayer) {
  300. const videoUrl = videoPlayer.querySelector("video").currentSrc;
  301. downloadButton.setAttribute("data-tel-download-url", videoUrl);
  302. downloadButton.appendChild(downloadIcon);
  303. downloadButton.onclick = () => {
  304. tel_download_video(videoUrl);
  305. };
  306.  
  307. // Add download button to video controls
  308. const controls = videoPlayer.querySelector(".VideoPlayerControls");
  309. if (controls) {
  310. const buttons = controls.querySelector(".buttons");
  311. if (!buttons.querySelector("button.tel-download")) {
  312. const spacer = buttons.querySelector(".spacer");
  313. spacer.after(downloadButton);
  314. }
  315. }
  316.  
  317. // Add/Update/Remove download button to topbar
  318. if (mediaViewerActions.querySelector("button.tel-download")) {
  319. const telDownloadButton = mediaViewerActions.querySelector(
  320. "button.tel-download"
  321. );
  322. if (
  323. mediaViewerActions.querySelectorAll('button[title="Download"]')
  324. .length > 1
  325. ) {
  326. // There's existing download button, remove ours
  327. mediaViewerActions.querySelector("button.tel-download").remove();
  328. } else if (
  329. telDownloadButton.getAttribute("data-tel-download-url") !== videoUrl
  330. ) {
  331. // Update existing button
  332. telDownloadButton.onclick = () => {
  333. tel_download_video(videoUrl);
  334. };
  335. telDownloadButton.setAttribute("data-tel-download-url", videoUrl);
  336. }
  337. } else if (
  338. !mediaViewerActions.querySelector('button[title="Download"]')
  339. ) {
  340. // Add the button if there's no download button at all
  341. mediaViewerActions.prepend(downloadButton);
  342. }
  343. } else if (img && img.src) {
  344. downloadButton.setAttribute("data-tel-download-url", img.src);
  345. downloadButton.appendChild(downloadIcon);
  346. downloadButton.onclick = () => {
  347. tel_download_image(img.src);
  348. };
  349.  
  350. // Add/Update/Remove download button to topbar
  351. if (mediaViewerActions.querySelector("button.tel-download")) {
  352. const telDownloadButton = mediaViewerActions.querySelector(
  353. "button.tel-download"
  354. );
  355. if (
  356. mediaViewerActions.querySelectorAll('button[title="Download"]')
  357. .length > 1
  358. ) {
  359. // There's existing download button, remove ours
  360. mediaViewerActions.querySelector("button.tel-download").remove();
  361. } else if (
  362. telDownloadButton.getAttribute("data-tel-download-url") !== img.src
  363. ) {
  364. // Update existing button
  365. telDownloadButton.onclick = () => {
  366. tel_download_image(img.src);
  367. };
  368. telDownloadButton.setAttribute("data-tel-download-url", img.src);
  369. }
  370. } else if (
  371. !mediaViewerActions.querySelector('button[title="Download"]')
  372. ) {
  373. // Add the button if there's no download button at all
  374. mediaViewerActions.prepend(downloadButton);
  375. }
  376. }
  377. }, REFRESH_DELAY);
  378.  
  379. // For webk /k/ webapp
  380. setInterval(() => {
  381. /* Voice Message */
  382. const pinnedAudio = document.body.querySelector(".pinned-audio");
  383. let dataMid;
  384. let downloadButtonPinnedAudio =
  385. document.body.querySelector("._tel_download_button_pinned_container") ||
  386. document.createElement("button");
  387. if (pinnedAudio) {
  388. dataMid = pinnedAudio.getAttribute("data-mid");
  389. downloadButtonPinnedAudio.className =
  390. "btn-icon tgico-download _tel_download_button_pinned_container";
  391. downloadButtonPinnedAudio.innerHTML =
  392. '<span class="tgico button-icon">\uE93E</span>';
  393. }
  394. const voiceMessages = document.body.querySelectorAll("audio-element");
  395. voiceMessages.forEach((voiceMessage) => {
  396. const bubble = voiceMessage.closest(".bubble");
  397. if (
  398. !bubble ||
  399. bubble.querySelector("._tel_download_button_pinned_container")
  400. ) {
  401. return; /* Skip if there's already a download button */
  402. }
  403. if (
  404. dataMid &&
  405. downloadButtonPinnedAudio.getAttribute("data-mid") !== dataMid &&
  406. voiceMessage.getAttribute("data-mid") === dataMid
  407. ) {
  408. downloadButtonPinnedAudio.onclick = (e) => {
  409. e.stopPropagation();
  410. tel_download_audio(link);
  411. };
  412. downloadButtonPinnedAudio.setAttribute("data-mid", dataMid);
  413. const link =
  414. voiceMessage.audio && voiceMessage.audio.getAttribute("src");
  415. if (link) {
  416. pinnedAudio
  417. .querySelector(".pinned-container-wrapper-utils")
  418. .appendChild(downloadButtonPinnedAudio);
  419. }
  420. }
  421. });
  422.  
  423. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  424. const mediaContainer = document.querySelector(".media-viewer-whole");
  425. if (!mediaContainer) return;
  426. const mediaAspecter = mediaContainer.querySelector(
  427. ".media-viewer-movers .media-viewer-aspecter"
  428. );
  429. const mediaButtons = mediaContainer.querySelector(
  430. ".media-viewer-topbar .media-viewer-buttons"
  431. );
  432. if (!mediaAspecter || !mediaButtons) return;
  433.  
  434. // If the download button is hidden, we can simply unhide it
  435. if (mediaButtons.querySelector(".btn-icon.tgico-download")) {
  436. const button = mediaButtons.querySelector(
  437. "button.btn-icon.tgico-download"
  438. );
  439. if (button.classList.contains("hide")) {
  440. button.classList.remove("hide");
  441. }
  442. }
  443. // If forward button is hidden, we can simply unhide it too
  444. if (mediaButtons.querySelector("button.btn-icon.tgico-forward")) {
  445. const button = mediaButtons.querySelector(
  446. "button.btn-icon.tgico-forward"
  447. );
  448. if (button.classList.contains("hide")) {
  449. button.classList.remove("hide");
  450. }
  451. }
  452.  
  453. if (mediaAspecter.querySelector(".ckin__player")) {
  454. // 1. Video player detected - Video and it has finished initial loading
  455. // container > .ckin__player > video[src]
  456.  
  457. // add download button to videos
  458. const controls = mediaAspecter.querySelector(
  459. ".default__controls.ckin__controls"
  460. );
  461. const videoUrl = mediaAspecter.querySelector("video").src;
  462.  
  463. if (controls && !controls.querySelector(".tel-download")) {
  464. const brControls = controls.querySelector(
  465. ".bottom-controls .right-controls"
  466. );
  467. const downloadButton = document.createElement("button");
  468. downloadButton.className =
  469. "btn-icon default__button tgico-download tel-download";
  470. downloadButton.innerHTML =
  471. '<span class="tgico button-icon">\uE93E</span>';
  472. downloadButton.setAttribute("type", "button");
  473. downloadButton.setAttribute("title", "Download");
  474. downloadButton.setAttribute("aria-label", "Download");
  475. downloadButton.onclick = () => {
  476. tel_download_video(videoUrl);
  477. };
  478. brControls.prepend(downloadButton);
  479. }
  480. } else if (
  481. mediaAspecter.querySelector("video") &&
  482. mediaAspecter.querySelector("video") &&
  483. !mediaButtons.querySelector("button.btn-icon.tgico-download")
  484. ) {
  485. // 2. Video HTML element detected, could be either GIF or unloaded video
  486. // container > video[src]
  487. const videoUrl = mediaAspecter.querySelector("video").src;
  488. const downloadButton = document.createElement("button");
  489. downloadButton.className = "btn-icon tgico-download tel-download";
  490. downloadButton.innerHTML =
  491. '<span class="tgico button-icon">\uE93E</span>';
  492. downloadButton.setAttribute("type", "button");
  493. downloadButton.setAttribute("title", "Download");
  494. downloadButton.setAttribute("aria-label", "Download");
  495. downloadButton.onclick = () => {
  496. tel_download_video(videoUrl);
  497. };
  498. mediaButtons.prepend(downloadButton);
  499. } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
  500. // 3. Image without download button detected
  501. // container > img.thumbnail
  502. if (!mediaAspecter.querySelector("img.thumbnail")) {
  503. return;
  504. }
  505. const imageUrl = mediaAspecter.querySelector("img.thumbnail").src;
  506. if (!imageUrl) {
  507. return;
  508. }
  509. const downloadButton = document.createElement("button");
  510. downloadButton.className = "btn-icon tgico-download tel-download";
  511. downloadButton.innerHTML =
  512. '<span class="tgico button-icon">\uE93E</span>';
  513. downloadButton.setAttribute("type", "button");
  514. downloadButton.setAttribute("title", "Download");
  515. downloadButton.setAttribute("aria-label", "Download");
  516. downloadButton.onclick = () => {
  517. tel_download_image(imageUrl);
  518. };
  519. mediaButtons.prepend(downloadButton);
  520. }
  521. }, REFRESH_DELAY);
  522. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址