Telegram Media Downloader

Used to download streaming videos on Telegram

2023-06-07 일자. 최신 버전을 확인하세요.

질문, 리뷰하거나, 이 스크립트를 신고하세요.
  1. // ==UserScript==
  2. // @name Telegram Media Downloader
  3. // @version 1.0
  4. // @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader
  5. // @description Used to download streaming videos on Telegram
  6. // @author Nestor Qin
  7. // @license GNU GPLv3
  8. // @website https://github.com/Neet-Nestor/Telegram-Media-Downloader
  9. // @match https://web.telegram.org/*
  10. // @match https://webk.telegram.org/*
  11. // @match https://webz.telegram.org/*
  12. // @icon https://img.icons8.com/color/452/telegram-app--v5.png
  13. // @grant none
  14. // ==/UserScript==
  15.  
  16. (function () {
  17. const logger = {
  18. info: (message) => {
  19. console.log("[Tel Download] " + message);
  20. },
  21. error: (message) => {
  22. console.error("[Tel Download] " + message);
  23. },
  24. };
  25. const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  26. const backgroundImageUrlPattern = /background-image:\surl\("blob:(.+)"\)/;
  27. const REFRESH_DELAY = 500;
  28.  
  29. const tel_download_video = (url) => {
  30. let _blobs = [];
  31. let _next_offset = 0;
  32. let _total_size = null;
  33. let _file_extension = "mp4";
  34.  
  35. const fetchNextPart = () => {
  36. fetch(url, {
  37. method: "GET",
  38. headers: {
  39. Range: `bytes=${_next_offset}-`,
  40. },
  41. })
  42. .then((res) => {
  43. logger.info("get response ", res);
  44. if (![200, 206].includes(res.status)) {
  45. logger.error("Non 200/206 response was received: " + res.status);
  46. return;
  47. }
  48.  
  49. const mime = res.headers.get("Content-Type").split(";")[0];
  50. if (!mime.startsWith("video/")) {
  51. logger.error("Get non video response with MIME type " + mime);
  52. throw "Get non video response with MIME type " + mime;
  53. }
  54. _file_extension = mime.split("/")[1];
  55.  
  56. const match = res.headers
  57. .get("Content-Range")
  58. .match(contentRangeRegex);
  59.  
  60. const startOffset = parseInt(match[1]);
  61. const endOffset = parseInt(match[2]);
  62. const totalSize = parseInt(match[3]);
  63.  
  64. if (startOffset !== _next_offset) {
  65. logger.error("Gap detected between responses.");
  66. logger.info("Last offset: " + _next_offset);
  67. logger.info("New start offset " + match[1]);
  68. throw "Gap detected between responses.";
  69. }
  70. if (_total_size && totalSize !== _total_size) {
  71. logger.error("Total size differs");
  72. throw "Total size differs";
  73. }
  74.  
  75. _next_offset = endOffset + 1;
  76. _total_size = totalSize;
  77.  
  78. logger.info(
  79. `Get response: ${res.headers.get(
  80. "Content-Length"
  81. )} bytes data from ${res.headers.get("Content-Range")}`
  82. );
  83. logger.info(
  84. `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`
  85. );
  86. return res.blob();
  87. })
  88. .then((resBlob) => {
  89. _blobs.push(resBlob);
  90. })
  91. .then(() => {
  92. if (_next_offset < _total_size) {
  93. fetchNextPart();
  94. } else {
  95. save();
  96. }
  97. })
  98. .catch((reason) => {
  99. logger.error(reason);
  100. });
  101. };
  102.  
  103. const save = () => {
  104. logger.info("Finish downloading blobs");
  105. logger.info("Concatenating blobs and downloading...");
  106.  
  107. let fileName =
  108. (Math.random() + 1).toString(36).substring(2, 10) +
  109. "." +
  110. _file_extension;
  111.  
  112. // Some video src is in format:
  113. // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
  114. try {
  115. const metadata = JSON.parse(
  116. decodeURIComponent(url.split("/")[url.split("/").length - 1])
  117. );
  118. logger.info(metadata);
  119. if (metadata.fileName) {
  120. fileName = metadata.fileName;
  121. }
  122. } catch (e) {
  123. // Invalid JSON string, pass extracting filename
  124. }
  125.  
  126. const blob = new Blob(_blobs, { type: "video/mp4" });
  127. const blobUrl = window.URL.createObjectURL(blob);
  128.  
  129. logger.info("Final blob size: " + blob.size + " bytes");
  130.  
  131. const a = document.createElement("a");
  132. document.body.appendChild(a);
  133. a.href = blobUrl;
  134. a.download = fileName;
  135. a.click();
  136. document.body.removeChild(a);
  137. window.URL.revokeObjectURL(blobUrl);
  138.  
  139. logger.info("Download triggered");
  140. };
  141.  
  142. fetchNextPart();
  143. };
  144.  
  145. const tel_download_image = (imageUrl) => {
  146. const fileName =
  147. (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume jpeg
  148.  
  149. const a = document.createElement("a");
  150. document.body.appendChild(a);
  151. a.href = imageUrl;
  152. a.download = fileName;
  153. a.click();
  154. document.body.removeChild(a);
  155.  
  156. logger.info("Download triggered");
  157. };
  158.  
  159. logger.info("Initialized");
  160.  
  161. // For webz /a/ webapp
  162. setInterval(() => {
  163. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  164. const mediaContainer = document.querySelector("#MediaViewer .MediaViewerSlide--active");
  165. if (!mediaContainer) return;
  166. const mediaViewerActions = document.querySelector(
  167. "#MediaViewer .MediaViewerActions"
  168. );
  169. if (!mediaViewerActions) return;
  170.  
  171. const videoPlayer = mediaContainer.querySelector(".MediaViewerContent > .VideoPlayer");
  172. const img = mediaContainer.querySelector('.MediaViewerContent > div > img');
  173. // 1. Video player detected - Video or GIF
  174. // container > .MediaViewerSlides > .MediaViewerSlide > .MediaViewerContent > .VideoPlayer > video[src]
  175. if (videoPlayer) {
  176. const videoUrl = videoPlayer.querySelector("video").currentSrc;
  177. const downloadIcon = document.createElement("i");
  178. downloadIcon.className = "icon icon-download";
  179. const downloadButton = document.createElement("button");
  180. downloadButton.className =
  181. "Button smaller translucent-white round tel-download";
  182. downloadButton.setAttribute("type", "button");
  183. downloadButton.setAttribute("title", "Download");
  184. downloadButton.setAttribute("aria-label", "Download");
  185. downloadButton.setAttribute("data-tel-download-url", videoUrl);
  186. downloadButton.appendChild(downloadIcon);
  187. downloadButton.onclick = () => {
  188. tel_download_video(videoUrl);
  189. };
  190.  
  191. // Add download button to video controls
  192. const controls = videoPlayer.querySelector(".VideoPlayerControls");
  193. if (controls) {
  194. const buttons = controls.querySelector(".buttons");
  195. if (!buttons.querySelector("button.tel-download")) {
  196. const spacer = buttons.querySelector(".spacer");
  197. spacer.after(downloadButton);
  198. }
  199. }
  200.  
  201. // Add/Update/Remove download button to topbar
  202. if (mediaViewerActions.querySelector("button.tel-download")) {
  203. const telDownloadButton = mediaViewerActions.querySelector(
  204. "button.tel-download"
  205. );
  206. if (
  207. mediaViewerActions.querySelectorAll('button[title="Download"]')
  208. .length > 1
  209. ) {
  210. // There's existing download button, remove ours
  211. mediaViewerActions.querySelector("button.tel-download").remove();
  212. } else if (
  213. telDownloadButton.getAttribute("data-tel-download-url") !== videoUrl
  214. ) {
  215. // Update existing button
  216. telDownloadButton.onclick = () => {
  217. tel_download_video(videoUrl);
  218. };
  219. telDownloadButton.setAttribute("data-tel-download-url", videoUrl);
  220. }
  221. } else if (
  222. !mediaViewerActions.querySelector('button[title="Download"]')
  223. ) {
  224. // Add the button if there's no download button at all
  225. mediaViewerActions.prepend(downloadButton);
  226. }
  227. } else if (img && img.src) {
  228. const downloadIcon = document.createElement("i");
  229. downloadIcon.className = "icon icon-download";
  230. const downloadButton = document.createElement("button");
  231. downloadButton.className =
  232. "Button smaller translucent-white round tel-download";
  233. downloadButton.setAttribute("type", "button");
  234. downloadButton.setAttribute("title", "Download");
  235. downloadButton.setAttribute("aria-label", "Download");
  236. downloadButton.setAttribute("data-tel-download-url", img.src);
  237. downloadButton.appendChild(downloadIcon);
  238. downloadButton.onclick = () => {
  239. tel_download_image(img.src);
  240. };
  241.  
  242. // Add/Update/Remove download button to topbar
  243. if (mediaViewerActions.querySelector("button.tel-download")) {
  244. const telDownloadButton = mediaViewerActions.querySelector(
  245. "button.tel-download"
  246. );
  247. if (
  248. mediaViewerActions.querySelectorAll('button[title="Download"]')
  249. .length > 1
  250. ) {
  251. // There's existing download button, remove ours
  252. mediaViewerActions.querySelector("button.tel-download").remove();
  253. } else if (
  254. telDownloadButton.getAttribute("data-tel-download-url") !== img.src
  255. ) {
  256. // Update existing button
  257. telDownloadButton.onclick = () => {
  258. tel_download_image(img.src);
  259. };
  260. telDownloadButton.setAttribute("data-tel-download-url", img.src);
  261. }
  262. } else if (
  263. !mediaViewerActions.querySelector('button[title="Download"]')
  264. ) {
  265. // Add the button if there's no download button at all
  266. mediaViewerActions.prepend(downloadButton);
  267. }
  268. }
  269. }, REFRESH_DELAY);
  270.  
  271. // For webk /k/ webapp
  272. setInterval(() => {
  273. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  274. const mediaContainer = document.querySelector(".media-viewer-whole");
  275. if (!mediaContainer) return;
  276. const mediaAspecter = mediaContainer.querySelector(
  277. ".media-viewer-movers .media-viewer-aspecter"
  278. );
  279. const mediaButtons = mediaContainer.querySelector(
  280. ".media-viewer-topbar .media-viewer-buttons"
  281. );
  282. if (!mediaAspecter || !mediaButtons) return;
  283.  
  284. // If the download button is hidden, we can simply unhide it
  285. if (mediaButtons.querySelector(".btn-icon.tgico-download")) {
  286. const button = mediaButtons.querySelector(
  287. "button.btn-icon.tgico-download"
  288. );
  289. if (button.classList.contains("hide")) {
  290. button.classList.remove("hide");
  291. }
  292. }
  293. // If forward button is hidden, we can simply unhide it too
  294. if (mediaButtons.querySelector("button.btn-icon.tgico-forward")) {
  295. const button = mediaButtons.querySelector(
  296. "button.btn-icon.tgico-forward"
  297. );
  298. if (button.classList.contains("hide")) {
  299. button.classList.remove("hide");
  300. }
  301. }
  302.  
  303. if (mediaAspecter.querySelector(".ckin__player")) {
  304. // 1. Video player detected - Video and it has finished initial loading
  305. // container > .ckin__player > video[src]
  306.  
  307. // add download button to videos
  308. const controls = mediaAspecter.querySelector(
  309. ".default__controls.ckin__controls"
  310. );
  311. const videoUrl = mediaAspecter.querySelector("video").src;
  312.  
  313. if (controls && !controls.querySelector(".tel-download")) {
  314. const brControls = controls.querySelector(
  315. ".bottom-controls .right-controls"
  316. );
  317. const downloadButton = document.createElement("button");
  318. downloadButton.className =
  319. "btn-icon default__button tgico-download tel-download";
  320. downloadButton.setAttribute("type", "button");
  321. downloadButton.setAttribute("title", "Download");
  322. downloadButton.setAttribute("aria-label", "Download");
  323. downloadButton.onclick = () => {
  324. tel_download_video(videoUrl);
  325. };
  326. brControls.prepend(downloadButton);
  327. }
  328. } else if (
  329. mediaAspecter.querySelector("video") &&
  330. mediaAspecter.querySelector("video") &&
  331. !mediaButtons.querySelector("button.btn-icon.tgico-download")
  332. ) {
  333. // 2. Video HTML element detected, could be either GIF or unloaded video
  334. // container > video[src]
  335. const videoUrl = mediaAspecter.querySelector("video").src;
  336. const downloadButton = document.createElement("button");
  337. downloadButton.className = "btn-icon tgico-download tel-download";
  338. downloadButton.setAttribute("type", "button");
  339. downloadButton.setAttribute("title", "Download");
  340. downloadButton.setAttribute("aria-label", "Download");
  341. downloadButton.onclick = () => {
  342. tel_download_video(videoUrl);
  343. };
  344. mediaButtons.prepend(downloadButton);
  345. } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
  346. // 3. Image without download button detected
  347. // container > img.thumbnail
  348. const imageUrl = mediaAspecter.querySelector("img.thumbnail").src;
  349. const downloadButton = document.createElement("button");
  350. downloadButton.className = "btn-icon tgico-download tel-download";
  351. downloadButton.setAttribute("type", "button");
  352. downloadButton.setAttribute("title", "Download");
  353. downloadButton.setAttribute("aria-label", "Download");
  354. downloadButton.onclick = () => {
  355. tel_download_image(imageUrl);
  356. };
  357. mediaButtons.prepend(downloadButton);
  358. }
  359. }, REFRESH_DELAY);
  360. })();