Telegram 受限图片视频下载器

从禁止下载的Telegram频道中下载图片、视频及语音消息

  1. // ==UserScript==
  2. // @name Telegram Media Downloader
  3. // @name:en Telegram Media Downloader
  4. // @name:zh-CN Telegram 受限图片视频下载器
  5. // @name:zh-TW Telegram 受限圖片影片下載器
  6. // @name:ru Telegram: загрузчик медиафайлов
  7. // @version 1.206
  8. // @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader
  9. // @description Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content
  10. // @description:en Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content
  11. // @description:ru Загружайте изображения, GIF-файлы, видео и голосовые сообщения в веб-приложении Telegram из частных каналов, которые отключили загрузку и ограничили сохранение контента
  12. // @description:zh-CN 从禁止下载的Telegram频道中下载图片、视频及语音消息
  13. // @description:zh-TW 從禁止下載的 Telegram 頻道中下載圖片、影片及語音訊息
  14. // @author Nestor Qin
  15. // @license GNU GPLv3
  16. // @website https://github.com/Neet-Nestor/Telegram-Media-Downloader
  17. // @match https://web.telegram.org/*
  18. // @match https://webk.telegram.org/*
  19. // @match https://webz.telegram.org/*
  20. // @icon https://img.icons8.com/color/452/telegram-app--v5.png
  21. // ==/UserScript==
  22.  
  23.  
  24. (function () {
  25. const logger = {
  26. info: (message, fileName = null) => {
  27. console.log(
  28. `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
  29. );
  30. },
  31. error: (message, fileName = null) => {
  32. console.error(
  33. `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
  34. );
  35. },
  36. };
  37. // Unicode values for icons (used in /k/ app)
  38. // https://github.com/morethanwords/tweb/blob/master/src/icons.ts
  39. const DOWNLOAD_ICON = "\uE95A";
  40. const FORWARD_ICON = "\uE976";
  41. const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  42. const REFRESH_DELAY = 500;
  43. const hashCode = (s) => {
  44. var h = 0,
  45. l = s.length,
  46. i = 0;
  47. if (l > 0) {
  48. while (i < l) {
  49. h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
  50. }
  51. }
  52. return h >>> 0;
  53. };
  54.  
  55. const createProgressBar = (videoId, fileName) => {
  56. const isDarkMode =
  57. document.querySelector("html").classList.contains("night") ||
  58. document.querySelector("html").classList.contains("theme-dark");
  59. const container = document.getElementById(
  60. "tel-downloader-progress-bar-container"
  61. );
  62. const innerContainer = document.createElement("div");
  63. innerContainer.id = "tel-downloader-progress-" + videoId;
  64. innerContainer.style.width = "20rem";
  65. innerContainer.style.marginTop = "0.4rem";
  66. innerContainer.style.padding = "0.6rem";
  67. innerContainer.style.backgroundColor = isDarkMode
  68. ? "rgba(0,0,0,0.3)"
  69. : "rgba(0,0,0,0.6)";
  70.  
  71. const flexContainer = document.createElement("div");
  72. flexContainer.style.display = "flex";
  73. flexContainer.style.justifyContent = "space-between";
  74.  
  75. const title = document.createElement("p");
  76. title.className = "filename";
  77. title.style.margin = 0;
  78. title.style.color = "white";
  79. title.innerText = fileName;
  80.  
  81. const closeButton = document.createElement("div");
  82. closeButton.style.cursor = "pointer";
  83. closeButton.style.fontSize = "1.2rem";
  84. closeButton.style.color = isDarkMode ? "#8a8a8a" : "white";
  85. closeButton.innerHTML = "&times;";
  86. closeButton.onclick = function () {
  87. container.removeChild(innerContainer);
  88. };
  89.  
  90. const progressBar = document.createElement("div");
  91. progressBar.className = "progress";
  92. progressBar.style.backgroundColor = "#e2e2e2";
  93. progressBar.style.position = "relative";
  94. progressBar.style.width = "100%";
  95. progressBar.style.height = "1.6rem";
  96. progressBar.style.borderRadius = "2rem";
  97. progressBar.style.overflow = "hidden";
  98.  
  99. const counter = document.createElement("p");
  100. counter.style.position = "absolute";
  101. counter.style.zIndex = 5;
  102. counter.style.left = "50%";
  103. counter.style.top = "50%";
  104. counter.style.transform = "translate(-50%, -50%)";
  105. counter.style.margin = 0;
  106. counter.style.color = "black";
  107. const progress = document.createElement("div");
  108. progress.style.position = "absolute";
  109. progress.style.height = "100%";
  110. progress.style.width = "0%";
  111. progress.style.backgroundColor = "#6093B5";
  112.  
  113. progressBar.appendChild(counter);
  114. progressBar.appendChild(progress);
  115. flexContainer.appendChild(title);
  116. flexContainer.appendChild(closeButton);
  117. innerContainer.appendChild(flexContainer);
  118. innerContainer.appendChild(progressBar);
  119. container.appendChild(innerContainer);
  120. };
  121.  
  122. const updateProgress = (videoId, fileName, progress) => {
  123. const innerContainer = document.getElementById(
  124. "tel-downloader-progress-" + videoId
  125. );
  126. innerContainer.querySelector("p.filename").innerText = fileName;
  127. const progressBar = innerContainer.querySelector("div.progress");
  128. progressBar.querySelector("p").innerText = progress + "%";
  129. progressBar.querySelector("div").style.width = progress + "%";
  130. };
  131.  
  132. const completeProgress = (videoId) => {
  133. const progressBar = document
  134. .getElementById("tel-downloader-progress-" + videoId)
  135. .querySelector("div.progress");
  136. progressBar.querySelector("p").innerText = "Completed";
  137. progressBar.querySelector("div").style.backgroundColor = "#B6C649";
  138. progressBar.querySelector("div").style.width = "100%";
  139. };
  140.  
  141. const AbortProgress = (videoId) => {
  142. const progressBar = document
  143. .getElementById("tel-downloader-progress-" + videoId)
  144. .querySelector("div.progress");
  145. progressBar.querySelector("p").innerText = "Aborted";
  146. progressBar.querySelector("div").style.backgroundColor = "#D16666";
  147. progressBar.querySelector("div").style.width = "100%";
  148. };
  149.  
  150. const tel_download_video = (url) => {
  151. let _blobs = [];
  152. let _next_offset = 0;
  153. let _total_size = null;
  154. let _file_extension = "mp4";
  155.  
  156. const videoId =
  157. (Math.random() + 1).toString(36).substring(2, 10) +
  158. "_" +
  159. Date.now().toString();
  160. let fileName = hashCode(url).toString(36) + "." + _file_extension;
  161.  
  162. // Some video src is in format:
  163. // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
  164. try {
  165. const metadata = JSON.parse(
  166. decodeURIComponent(url.split("/")[url.split("/").length - 1])
  167. );
  168. if (metadata.fileName) {
  169. fileName = metadata.fileName;
  170. }
  171. } catch (e) {
  172. // Invalid JSON string, pass extracting fileName
  173. }
  174. logger.info(`URL: ${url}`, fileName);
  175.  
  176. const fetchNextPart = (_writable) => {
  177. fetch(url, {
  178. method: "GET",
  179. headers: {
  180. Range: `bytes=${_next_offset}-`,
  181. },
  182. "User-Agent":
  183. "User-Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
  184. })
  185. .then((res) => {
  186. if (![200, 206].includes(res.status)) {
  187. throw new Error("Non 200/206 response was received: " + res.status);
  188. }
  189. const mime = res.headers.get("Content-Type").split(";")[0];
  190. if (!mime.startsWith("video/")) {
  191. throw new Error("Get non video response with MIME type " + mime);
  192. }
  193. _file_extension = mime.split("/")[1];
  194. fileName =
  195. fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;
  196.  
  197. const match = res.headers
  198. .get("Content-Range")
  199. .match(contentRangeRegex);
  200.  
  201. const startOffset = parseInt(match[1]);
  202. const endOffset = parseInt(match[2]);
  203. const totalSize = parseInt(match[3]);
  204.  
  205. if (startOffset !== _next_offset) {
  206. logger.error("Gap detected between responses.", fileName);
  207. logger.info("Last offset: " + _next_offset, fileName);
  208. logger.info("New start offset " + match[1], fileName);
  209. throw "Gap detected between responses.";
  210. }
  211. if (_total_size && totalSize !== _total_size) {
  212. logger.error("Total size differs", fileName);
  213. throw "Total size differs";
  214. }
  215.  
  216. _next_offset = endOffset + 1;
  217. _total_size = totalSize;
  218.  
  219. logger.info(
  220. `Get response: ${res.headers.get(
  221. "Content-Length"
  222. )} bytes data from ${res.headers.get("Content-Range")}`,
  223. fileName
  224. );
  225. logger.info(
  226. `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`,
  227. fileName
  228. );
  229. updateProgress(
  230. videoId,
  231. fileName,
  232. ((_next_offset * 100) / _total_size).toFixed(0)
  233. );
  234. return res.blob();
  235. })
  236. .then((resBlob) => {
  237. if (_writable !== null) {
  238. _writable.write(resBlob).then(() => {});
  239. } else {
  240. _blobs.push(resBlob);
  241. }
  242. })
  243. .then(() => {
  244. if (!_total_size) {
  245. throw new Error("_total_size is NULL");
  246. }
  247.  
  248. if (_next_offset < _total_size) {
  249. fetchNextPart(_writable);
  250. } else {
  251. if (_writable !== null) {
  252. _writable.close().then(() => {
  253. logger.info("Download finished", fileName);
  254. });
  255. } else {
  256. save();
  257. }
  258. completeProgress(videoId);
  259. }
  260. })
  261. .catch((reason) => {
  262. logger.error(reason, fileName);
  263. AbortProgress(videoId);
  264. });
  265. };
  266.  
  267. const save = () => {
  268. logger.info("Finish downloading blobs", fileName);
  269. logger.info("Concatenating blobs and downloading...", fileName);
  270.  
  271. const blob = new Blob(_blobs, { type: "video/mp4" });
  272. const blobUrl = window.URL.createObjectURL(blob);
  273.  
  274. logger.info("Final blob size: " + blob.size + " bytes", fileName);
  275.  
  276. const a = document.createElement("a");
  277. document.body.appendChild(a);
  278. a.href = blobUrl;
  279. a.download = fileName;
  280. a.click();
  281. document.body.removeChild(a);
  282. window.URL.revokeObjectURL(blobUrl);
  283.  
  284. logger.info("Download triggered", fileName);
  285. };
  286.  
  287. const supportsFileSystemAccess =
  288. "showSaveFilePicker" in unsafeWindow &&
  289. (() => {
  290. try {
  291. return unsafeWindow.self === unsafeWindow.top;
  292. } catch {
  293. return false;
  294. }
  295. })();
  296. if (supportsFileSystemAccess) {
  297. unsafeWindow
  298. .showSaveFilePicker({
  299. suggestedName: fileName,
  300. })
  301. .then((handle) => {
  302. handle
  303. .createWritable()
  304. .then((writable) => {
  305. fetchNextPart(writable);
  306. createProgressBar(videoId);
  307. })
  308. .catch((err) => {
  309. console.error(err.name, err.message);
  310. });
  311. })
  312. .catch((err) => {
  313. if (err.name !== "AbortError") {
  314. console.error(err.name, err.message);
  315. }
  316. });
  317. } else {
  318. fetchNextPart(null);
  319. createProgressBar(videoId);
  320. }
  321. };
  322.  
  323. const tel_download_audio = (url) => {
  324. let _blobs = [];
  325. let _next_offset = 0;
  326. let _total_size = null;
  327. const fileName = hashCode(url).toString(36) + ".ogg";
  328.  
  329. const fetchNextPart = (_writable) => {
  330. fetch(url, {
  331. method: "GET",
  332. headers: {
  333. Range: `bytes=${_next_offset}-`,
  334. },
  335. })
  336. .then((res) => {
  337. if (res.status !== 206 && res.status !== 200) {
  338. logger.error(
  339. "Non 200/206 response was received: " + res.status,
  340. fileName
  341. );
  342. return;
  343. }
  344.  
  345. const mime = res.headers.get("Content-Type").split(";")[0];
  346. if (!mime.startsWith("audio/")) {
  347. logger.error(
  348. "Get non audio response with MIME type " + mime,
  349. fileName
  350. );
  351. throw "Get non audio response with MIME type " + mime;
  352. }
  353.  
  354. try {
  355. const match = res.headers
  356. .get("Content-Range")
  357. .match(contentRangeRegex);
  358.  
  359. const startOffset = parseInt(match[1]);
  360. const endOffset = parseInt(match[2]);
  361. const totalSize = parseInt(match[3]);
  362.  
  363. if (startOffset !== _next_offset) {
  364. logger.error("Gap detected between responses.");
  365. logger.info("Last offset: " + _next_offset);
  366. logger.info("New start offset " + match[1]);
  367. throw "Gap detected between responses.";
  368. }
  369. if (_total_size && totalSize !== _total_size) {
  370. logger.error("Total size differs");
  371. throw "Total size differs";
  372. }
  373.  
  374. _next_offset = endOffset + 1;
  375. _total_size = totalSize;
  376. } finally {
  377. logger.info(
  378. `Get response: ${res.headers.get(
  379. "Content-Length"
  380. )} bytes data from ${res.headers.get("Content-Range")}`
  381. );
  382. return res.blob();
  383. }
  384. })
  385. .then((resBlob) => {
  386. if (_writable !== null) {
  387. _writable.write(resBlob).then(() => {});
  388. } else {
  389. _blobs.push(resBlob);
  390. }
  391. })
  392. .then(() => {
  393. if (_next_offset < _total_size) {
  394. fetchNextPart(_writable);
  395. } else {
  396. if (_writable !== null) {
  397. _writable.close().then(() => {
  398. logger.info("Download finished", fileName);
  399. });
  400. } else {
  401. save();
  402. }
  403. }
  404. })
  405. .catch((reason) => {
  406. logger.error(reason, fileName);
  407. });
  408. };
  409.  
  410. const save = () => {
  411. logger.info(
  412. "Finish downloading blobs. Concatenating blobs and downloading...",
  413. fileName
  414. );
  415.  
  416. let blob = new Blob(_blobs, { type: "audio/ogg" });
  417. const blobUrl = window.URL.createObjectURL(blob);
  418.  
  419. logger.info("Final blob size in bytes: " + blob.size, fileName);
  420.  
  421. blob = 0;
  422.  
  423. const a = document.createElement("a");
  424. document.body.appendChild(a);
  425. a.href = blobUrl;
  426. a.download = fileName;
  427. a.click();
  428. document.body.removeChild(a);
  429. window.URL.revokeObjectURL(blobUrl);
  430.  
  431. logger.info("Download triggered", fileName);
  432. };
  433.  
  434. const supportsFileSystemAccess =
  435. "showSaveFilePicker" in unsafeWindow &&
  436. (() => {
  437. try {
  438. return unsafeWindow.self === unsafeWindow.top;
  439. } catch {
  440. return false;
  441. }
  442. })();
  443. if (supportsFileSystemAccess) {
  444. unsafeWindow
  445. .showSaveFilePicker({
  446. suggestedName: fileName,
  447. })
  448. .then((handle) => {
  449. handle
  450. .createWritable()
  451. .then((writable) => {
  452. fetchNextPart(writable);
  453. })
  454. .catch((err) => {
  455. console.error(err.name, err.message);
  456. });
  457. })
  458. .catch((err) => {
  459. if (err.name !== "AbortError") {
  460. console.error(err.name, err.message);
  461. }
  462. });
  463. } else {
  464. fetchNextPart(null);
  465. }
  466. };
  467.  
  468. const tel_download_image = (imageUrl) => {
  469. const fileName =
  470. (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume jpeg
  471.  
  472. const a = document.createElement("a");
  473. document.body.appendChild(a);
  474. a.href = imageUrl;
  475. a.download = fileName;
  476. a.click();
  477. document.body.removeChild(a);
  478.  
  479. logger.info("Download triggered", fileName);
  480. };
  481.  
  482. logger.info("Initialized");
  483.  
  484. // For webz /a/ webapp
  485. setInterval(() => {
  486. // Stories
  487. const storiesContainer = document.getElementById("StoryViewer");
  488. if (storiesContainer) {
  489. console.log("storiesContainer");
  490. const createDownloadButton = () => {
  491. console.log("createDownloadButton");
  492. const downloadIcon = document.createElement("i");
  493. downloadIcon.className = "icon icon-download";
  494. const downloadButton = document.createElement("button");
  495. downloadButton.className =
  496. "Button TkphaPyQ tiny translucent-white round tel-download";
  497. downloadButton.appendChild(downloadIcon);
  498. downloadButton.setAttribute("type", "button");
  499. downloadButton.setAttribute("title", "Download");
  500. downloadButton.setAttribute("aria-label", "Download");
  501. downloadButton.onclick = () => {
  502. // 1. Story with video
  503. const video = storiesContainer.querySelector("video");
  504. const videoSrc =
  505. video?.src ||
  506. video?.currentSrc ||
  507. video?.querySelector("source")?.src;
  508. if (videoSrc) {
  509. tel_download_video(videoSrc);
  510. } else {
  511. // 2. Story with image
  512. const images = storiesContainer.querySelectorAll("img.PVZ8TOWS");
  513. if (images.length > 0) {
  514. const imageSrc = images[images.length - 1]?.src;
  515. if (imageSrc) tel_download_image(imageSrc);
  516. }
  517. }
  518. };
  519. return downloadButton;
  520. };
  521.  
  522. const storyHeader =
  523. storiesContainer.querySelector(".GrsJNw3y") ||
  524. storiesContainer.querySelector(".DropdownMenu").parentNode;
  525. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  526. console.log("storyHeader");
  527. storyHeader.insertBefore(
  528. createDownloadButton(),
  529. storyHeader.querySelector("button")
  530. );
  531. }
  532. }
  533.  
  534. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  535. const mediaContainer = document.querySelector(
  536. "#MediaViewer .MediaViewerSlide--active"
  537. );
  538. const mediaViewerActions = document.querySelector(
  539. "#MediaViewer .MediaViewerActions"
  540. );
  541. if (!mediaContainer || !mediaViewerActions) return;
  542.  
  543. // Videos in channels
  544. const videoPlayer = mediaContainer.querySelector(
  545. ".MediaViewerContent > .VideoPlayer"
  546. );
  547. const img = mediaContainer.querySelector(".MediaViewerContent > div > img");
  548. // 1. Video player detected - Video or GIF
  549. // container > .MediaViewerSlides > .MediaViewerSlide > .MediaViewerContent > .VideoPlayer > video[src]
  550. const downloadIcon = document.createElement("i");
  551. downloadIcon.className = "icon icon-download";
  552. const downloadButton = document.createElement("button");
  553. downloadButton.className =
  554. "Button smaller translucent-white round tel-download";
  555. downloadButton.setAttribute("type", "button");
  556. downloadButton.setAttribute("title", "Download");
  557. downloadButton.setAttribute("aria-label", "Download");
  558. if (videoPlayer) {
  559. const videoUrl = videoPlayer.querySelector("video").currentSrc;
  560. downloadButton.setAttribute("data-tel-download-url", videoUrl);
  561. downloadButton.appendChild(downloadIcon);
  562. downloadButton.onclick = () => {
  563. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  564. };
  565.  
  566. // Add download button to video controls
  567. const controls = videoPlayer.querySelector(".VideoPlayerControls");
  568. if (controls) {
  569. const buttons = controls.querySelector(".buttons");
  570. if (!buttons.querySelector("button.tel-download")) {
  571. const spacer = buttons.querySelector(".spacer");
  572. spacer.after(downloadButton);
  573. }
  574. }
  575.  
  576. // Add/Update/Remove download button to topbar
  577. if (mediaViewerActions.querySelector("button.tel-download")) {
  578. const telDownloadButton = mediaViewerActions.querySelector(
  579. "button.tel-download"
  580. );
  581. if (
  582. mediaViewerActions.querySelectorAll('button[title="Download"]')
  583. .length > 1
  584. ) {
  585. // There's existing download button, remove ours
  586. mediaViewerActions.querySelector("button.tel-download").remove();
  587. } else if (
  588. telDownloadButton.getAttribute("data-tel-download-url") !== videoUrl
  589. ) {
  590. // Update existing button
  591. telDownloadButton.onclick = () => {
  592. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  593. };
  594. telDownloadButton.setAttribute("data-tel-download-url", videoUrl);
  595. }
  596. } else if (
  597. !mediaViewerActions.querySelector('button[title="Download"]')
  598. ) {
  599. // Add the button if there's no download button at all
  600. mediaViewerActions.prepend(downloadButton);
  601. }
  602. } else if (img && img.src) {
  603. downloadButton.setAttribute("data-tel-download-url", img.src);
  604. downloadButton.appendChild(downloadIcon);
  605. downloadButton.onclick = () => {
  606. tel_download_image(img.src);
  607. };
  608.  
  609. // Add/Update/Remove download button to topbar
  610. if (mediaViewerActions.querySelector("button.tel-download")) {
  611. const telDownloadButton = mediaViewerActions.querySelector(
  612. "button.tel-download"
  613. );
  614. if (
  615. mediaViewerActions.querySelectorAll('button[title="Download"]')
  616. .length > 1
  617. ) {
  618. // There's existing download button, remove ours
  619. mediaViewerActions.querySelector("button.tel-download").remove();
  620. } else if (
  621. telDownloadButton.getAttribute("data-tel-download-url") !== img.src
  622. ) {
  623. // Update existing button
  624. telDownloadButton.onclick = () => {
  625. tel_download_image(img.src);
  626. };
  627. telDownloadButton.setAttribute("data-tel-download-url", img.src);
  628. }
  629. } else if (
  630. !mediaViewerActions.querySelector('button[title="Download"]')
  631. ) {
  632. // Add the button if there's no download button at all
  633. mediaViewerActions.prepend(downloadButton);
  634. }
  635. }
  636. }, REFRESH_DELAY);
  637.  
  638. // For webk /k/ webapp
  639. setInterval(() => {
  640. /* Voice Message or Circle Video */
  641. const pinnedAudio = document.body.querySelector(".pinned-audio");
  642. let dataMid;
  643. let downloadButtonPinnedAudio =
  644. document.body.querySelector("._tel_download_button_pinned_container") ||
  645. document.createElement("button");
  646. if (pinnedAudio) {
  647. dataMid = pinnedAudio.getAttribute("data-mid");
  648. downloadButtonPinnedAudio.className =
  649. "btn-icon tgico-download _tel_download_button_pinned_container";
  650. downloadButtonPinnedAudio.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  651. }
  652. const audioElements = document.body.querySelectorAll("audio-element");
  653. audioElements.forEach((audioElement) => {
  654. const bubble = audioElement.closest(".bubble");
  655. if (
  656. !bubble ||
  657. bubble.querySelector("._tel_download_button_pinned_container")
  658. ) {
  659. return; /* Skip if there's already a download button */
  660. }
  661. if (
  662. dataMid &&
  663. downloadButtonPinnedAudio.getAttribute("data-mid") !== dataMid &&
  664. audioElement.getAttribute("data-mid") === dataMid
  665. ) {
  666. downloadButtonPinnedAudio.onclick = (e) => {
  667. e.stopPropagation();
  668. if (isAudio) {
  669. tel_download_audio(link);
  670. } else {
  671. tel_download_video(link);
  672. }
  673. };
  674. downloadButtonPinnedAudio.setAttribute("data-mid", dataMid);
  675. const link = audioElement.audio && audioElement.audio.getAttribute("src");
  676. const isAudio = audioElement.audio && audioElement.audio instanceof HTMLAudioElement
  677. if (link) {
  678. pinnedAudio
  679. .querySelector(".pinned-container-wrapper-utils")
  680. .appendChild(downloadButtonPinnedAudio);
  681. }
  682. }
  683. });
  684.  
  685. // Stories
  686. const storiesContainer = document.getElementById("stories-viewer");
  687. if (storiesContainer) {
  688. const createDownloadButton = () => {
  689. const downloadButton = document.createElement("button");
  690. downloadButton.className = "btn-icon rp tel-download";
  691. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`;
  692. downloadButton.setAttribute("type", "button");
  693. downloadButton.setAttribute("title", "Download");
  694. downloadButton.setAttribute("aria-label", "Download");
  695. downloadButton.onclick = () => {
  696. // 1. Story with video
  697. const video = storiesContainer.querySelector("video.media-video");
  698. const videoSrc =
  699. video?.src ||
  700. video?.currentSrc ||
  701. video?.querySelector("source")?.src;
  702. if (videoSrc) {
  703. tel_download_video(videoSrc);
  704. } else {
  705. // 2. Story with image
  706. const imageSrc =
  707. storiesContainer.querySelector("img.media-photo")?.src;
  708. if (imageSrc) tel_download_image(imageSrc);
  709. }
  710. };
  711. return downloadButton;
  712. };
  713.  
  714. const storyHeader = storiesContainer.querySelector(
  715. "[class^='_ViewerStoryHeaderRight']"
  716. );
  717. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  718. storyHeader.prepend(createDownloadButton());
  719. }
  720.  
  721. const storyFooter = storiesContainer.querySelector(
  722. "[class^='_ViewerStoryFooterRight']"
  723. );
  724. if (storyFooter && !storyFooter.querySelector(".tel-download")) {
  725. storyFooter.prepend(createDownloadButton());
  726. }
  727. }
  728.  
  729. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  730. const mediaContainer = document.querySelector(".media-viewer-whole");
  731. if (!mediaContainer) return;
  732. const mediaAspecter = mediaContainer.querySelector(
  733. ".media-viewer-movers .media-viewer-aspecter"
  734. );
  735. const mediaButtons = mediaContainer.querySelector(
  736. ".media-viewer-topbar .media-viewer-buttons"
  737. );
  738. if (!mediaAspecter || !mediaButtons) return;
  739.  
  740. // Query hidden buttons and unhide them
  741. const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
  742. let onDownload = null;
  743. for (const btn of hiddenButtons) {
  744. btn.classList.remove("hide");
  745. if (btn.textContent === FORWARD_ICON) {
  746. btn.classList.add("tgico-forward");
  747. }
  748. if (btn.textContent === DOWNLOAD_ICON) {
  749. btn.classList.add("tgico-download");
  750. // Use official download buttons
  751. onDownload = () => {
  752. btn.click();
  753. };
  754. logger.info("onDownload", onDownload);
  755. }
  756. }
  757.  
  758. if (mediaAspecter.querySelector(".ckin__player")) {
  759. // 1. Video player detected - Video and it has finished initial loading
  760. // container > .ckin__player > video[src]
  761.  
  762. // add download button to videos
  763. const controls = mediaAspecter.querySelector(
  764. ".default__controls.ckin__controls"
  765. );
  766. if (controls && !controls.querySelector(".tel-download")) {
  767. const brControls = controls.querySelector(
  768. ".bottom-controls .right-controls"
  769. );
  770. const downloadButton = document.createElement("button");
  771. downloadButton.className =
  772. "btn-icon default__button tgico-download tel-download";
  773. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span>`;
  774. downloadButton.setAttribute("type", "button");
  775. downloadButton.setAttribute("title", "Download");
  776. downloadButton.setAttribute("aria-label", "Download");
  777. if (onDownload) {
  778. downloadButton.onclick = onDownload;
  779. } else {
  780. downloadButton.onclick = () => {
  781. tel_download_video(mediaAspecter.querySelector("video").src);
  782. };
  783. }
  784. brControls.prepend(downloadButton);
  785. }
  786. } else if (
  787. mediaAspecter.querySelector("video") &&
  788. mediaAspecter.querySelector("video") &&
  789. !mediaButtons.querySelector("button.btn-icon.tgico-download")
  790. ) {
  791. // 2. Video HTML element detected, could be either GIF or unloaded video
  792. // container > video[src]
  793. const downloadButton = document.createElement("button");
  794. downloadButton.className = "btn-icon tgico-download tel-download";
  795. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  796. downloadButton.setAttribute("type", "button");
  797. downloadButton.setAttribute("title", "Download");
  798. downloadButton.setAttribute("aria-label", "Download");
  799. if (onDownload) {
  800. downloadButton.onclick = onDownload;
  801. } else {
  802. downloadButton.onclick = () => {
  803. tel_download_video(mediaAspecter.querySelector("video").src);
  804. };
  805. }
  806. mediaButtons.prepend(downloadButton);
  807. } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
  808. // 3. Image without download button detected
  809. // container > img.thumbnail
  810. if (
  811. !mediaAspecter.querySelector("img.thumbnail") ||
  812. !mediaAspecter.querySelector("img.thumbnail").src
  813. ) {
  814. return;
  815. }
  816. const downloadButton = document.createElement("button");
  817. downloadButton.className = "btn-icon tgico-download tel-download";
  818. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  819. downloadButton.setAttribute("type", "button");
  820. downloadButton.setAttribute("title", "Download");
  821. downloadButton.setAttribute("aria-label", "Download");
  822. if (onDownload) {
  823. downloadButton.onclick = onDownload;
  824. } else {
  825. downloadButton.onclick = () => {
  826. tel_download_image(mediaAspecter.querySelector("img.thumbnail").src);
  827. };
  828. }
  829. mediaButtons.prepend(downloadButton);
  830. }
  831. }, REFRESH_DELAY);
  832.  
  833. // Progress bar container setup
  834. (function setupProgressBar() {
  835. const body = document.querySelector("body");
  836. const container = document.createElement("div");
  837. container.id = "tel-downloader-progress-bar-container";
  838. container.style.position = "fixed";
  839. container.style.bottom = 0;
  840. container.style.right = 0;
  841. if (location.pathname.startsWith("/k/")) {
  842. container.style.zIndex = 4;
  843. } else {
  844. container.style.zIndex = 1600;
  845. }
  846. body.appendChild(container);
  847. })();
  848.  
  849. logger.info("Completed script setup.");
  850. })();

QingJ © 2025

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