Greasy Fork 还支持 简体中文。

YouTube downloader

A simple userscript to download YouTube videos in MAX QUALITY

目前為 2023-12-21 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name YouTube downloader
  3. // @icon https://raw.githubusercontent.com/madkarmaa/youtube-downloader/main/images/icon.png
  4. // @namespace aGkgdGhlcmUgOik=
  5. // @source https://github.com/madkarmaa/youtube-downloader
  6. // @supportURL https://github.com/madkarmaa/youtube-downloader
  7. // @version 1.5.0
  8. // @description A simple userscript to download YouTube videos in MAX QUALITY
  9. // @author mk_
  10. // @match *://*.youtube.com/*
  11. // @connect co.wuk.sh
  12. // @connect raw.githubusercontent.com
  13. // @grant GM_addStyle
  14. // @grant GM.xmlHttpRequest
  15. // @grant GM.xmlhttpRequest
  16. // @run-at document-end
  17. // ==/UserScript==
  18.  
  19. (async () => {
  20. 'use strict';
  21.  
  22. const randomNumber = Math.floor(Math.random() * Date.now());
  23. const buttonId = `yt-downloader-btn-${randomNumber}`;
  24.  
  25. let oldLog = console.log;
  26. /**
  27. * Custom logging function copied from `console.log`
  28. * @param {...any} args `console.log` arguments
  29. * @returns {void}
  30. */
  31. const logger = (...args) => oldLog.apply(console, ['\x1b[31m[YT Downloader >> INFO]\x1b[0m', ...args]);
  32.  
  33. GM_addStyle(`
  34. #${buttonId}.YOUTUBE > svg {
  35. margin-top: 3px;
  36. margin-bottom: -3px;
  37. }
  38.  
  39. #${buttonId}.SHORTS > svg {
  40. margin-left: 3px;
  41. }
  42.  
  43. #${buttonId}:hover > svg {
  44. fill: #f00;
  45. }
  46.  
  47. #yt-downloader-notification-${randomNumber} {
  48. background-color: #282828;
  49. color: #fff;
  50. border: 2px solid #fff;
  51. border-radius: 8px;
  52. position: fixed;
  53. top: 0;
  54. right: 0;
  55. margin-top: 10px;
  56. margin-right: 10px;
  57. padding: 15px;
  58. z-index: 999;
  59. }
  60.  
  61. #yt-downloader-notification-${randomNumber} > h3 {
  62. color: #f00;
  63. font-size: 2.5rem;
  64. }
  65.  
  66. #yt-downloader-notification-${randomNumber} > span {
  67. font-style: italic;
  68. font-size: 1.5rem;
  69. }
  70.  
  71. #yt-downloader-notification-${randomNumber} > button {
  72. position: absolute;
  73. top: 0;
  74. right: 0;
  75. background: none;
  76. border: none;
  77. outline: none;
  78. width: fit-content;
  79. height: fit-content;
  80. margin: 5px;
  81. padding: 0;
  82. }
  83.  
  84. #yt-downloader-notification-${randomNumber} > button > svg {
  85. fill: #fff;
  86. }
  87. `);
  88.  
  89. function Cobalt(videoUrl, audioOnly = false) {
  90. // Use Promise because GM.xmlHttpRequest is async and behaves differently with different userscript managers
  91. return new Promise((resolve, reject) => {
  92. // https://github.com/wukko/cobalt/blob/current/docs/api.md
  93. GM.xmlHttpRequest({
  94. method: 'POST',
  95. url: 'https://co.wuk.sh/api/json',
  96. headers: {
  97. 'Cache-Control': 'no-cache',
  98. Accept: 'application/json',
  99. 'Content-Type': 'application/json',
  100. },
  101. data: JSON.stringify({
  102. url: encodeURI(videoUrl), // video url
  103. vQuality: 'max', // always max quality
  104. filenamePattern: 'basic', // file name = video title
  105. isAudioOnly: audioOnly,
  106. disableMetadata: true, // privacy
  107. }),
  108. onload: (response) => {
  109. const data = JSON.parse(response.responseText);
  110. if (data?.url) resolve(data.url);
  111. else reject(data);
  112. },
  113. onerror: (err) => reject(err),
  114. });
  115. });
  116. }
  117.  
  118. /**
  119. * https://stackoverflow.com/a/61511955
  120. * @param {String} selector The CSS selector used to select the element
  121. * @returns {Promise<Element>} The selected element
  122. */
  123. function waitForElement(selector) {
  124. return new Promise((resolve) => {
  125. if (document.querySelector(selector)) return resolve(document.querySelector(selector));
  126.  
  127. const observer = new MutationObserver(() => {
  128. if (document.querySelector(selector)) {
  129. observer.disconnect();
  130. resolve(document.querySelector(selector));
  131. }
  132. });
  133.  
  134. observer.observe(document.body, { childList: true, subtree: true });
  135. });
  136. }
  137.  
  138. /**
  139. * Append a notification element to the document
  140. * @param {String} title The title of the message
  141. * @param {String} message The message to display
  142. * @returns {void}
  143. */
  144. function notify(title, message) {
  145. const notificationContainer = document.createElement('div');
  146. notificationContainer.id = `yt-downloader-notification-${randomNumber}`;
  147.  
  148. const titleElement = document.createElement('h3');
  149. titleElement.textContent = title;
  150.  
  151. const messageElement = document.createElement('span');
  152. messageElement.textContent = message;
  153.  
  154. const closeButton = document.createElement('button');
  155. closeButton.innerHTML =
  156. '<svg xmlns="http://www.w3.org/2000/svg" height="1.5rem" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>';
  157. closeButton.addEventListener('click', () => {
  158. notificationContainer.remove();
  159. });
  160.  
  161. notificationContainer.append(titleElement, messageElement, closeButton);
  162. document.body.appendChild(notificationContainer);
  163. }
  164.  
  165. /**
  166. * Throw an error after `sec` seconds
  167. * @param {number} sec How long to wait before throwing an error (seconds)
  168. * @returns {Promise<void>}
  169. */
  170. function timeout(sec) {
  171. return new Promise((resolve, reject) => {
  172. setTimeout(() => {
  173. reject('Request timed out after ' + sec + ' seconds');
  174. }, sec * 1000);
  175. });
  176. }
  177.  
  178. /**
  179. * Detect which YouTube service is being used
  180. * @returns {"SHORTS" | "MUSIC" | "YOUTUBE" | null}
  181. */
  182. function updateService() {
  183. if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts'))
  184. return 'SHORTS';
  185. else if (window.location.hostname === 'music.youtube.com') return 'MUSIC';
  186. else if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch'))
  187. return 'YOUTUBE';
  188. else return null;
  189. }
  190.  
  191. let YOUTUBE_SERVICE = updateService();
  192.  
  193. /**
  194. * Renderer process
  195. * @param {CustomEvent} event The YouTube custom navigation event
  196. * @returns {Promise<void>}
  197. */
  198. async function RENDERER(event) {
  199. logger('Checking if user is watching');
  200. // do nothing if the user isn't watching any media
  201. if (!event?.detail?.endpoint?.watchEndpoint?.videoId && !event?.detail?.endpoint?.reelWatchEndpoint?.videoId) {
  202. logger('User is not watching');
  203. return;
  204. }
  205. logger('User is watching');
  206.  
  207. // wait for the button to copy to appear before continuing
  208. logger('Waiting for the button to copy to appear');
  209. let buttonToCopy;
  210. switch (YOUTUBE_SERVICE) {
  211. case 'YOUTUBE':
  212. buttonToCopy = waitForElement(
  213. 'div#player div.ytp-chrome-controls div.ytp-right-controls button[aria-label="Settings"]'
  214. );
  215. break;
  216. case 'MUSIC':
  217. buttonToCopy = waitForElement(
  218. '[slot="player-bar"] div.middle-controls div.middle-controls-buttons #like-button-renderer #button-shape-dislike button[aria-label="Dislike"]'
  219. );
  220. break;
  221. case 'SHORTS':
  222. buttonToCopy = waitForElement(
  223. 'div#actions.ytd-reel-player-overlay-renderer div#comments-button button'
  224. );
  225. break;
  226.  
  227. default:
  228. break;
  229. }
  230.  
  231. // cancel rendering after 5 seconds of the button not appearing in the document
  232. buttonToCopy = await Promise.race([timeout(5), buttonToCopy]);
  233. logger('Button to copy is:', buttonToCopy);
  234.  
  235. // create the download button
  236. const downloadButton = document.createElement('button');
  237. downloadButton.id = buttonId;
  238. downloadButton.title = 'Click to download as video\nRight click to download as audio';
  239. downloadButton.innerHTML =
  240. '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="24" viewBox="0 0 24 24" width="24" focusable="false" style="pointer-events: none; display: block; width: 100%; height: 100%;"><path d="M17 18v1H6v-1h11zm-.5-6.6-.7-.7-3.8 3.7V4h-1v10.4l-3.8-3.8-.7.7 5 5 5-4.9z"></path></svg>';
  241. downloadButton.classList = buttonToCopy.classList;
  242.  
  243. if (YOUTUBE_SERVICE === 'YOUTUBE') downloadButton.classList.add('ytp-hd-quality-badge');
  244. downloadButton.classList.add(YOUTUBE_SERVICE);
  245. logger('Download button created:', downloadButton);
  246.  
  247. /**
  248. * Left click => download video
  249. * @returns {void}
  250. */
  251. async function leftClick() {
  252. if (!window.location.pathname.slice(1))
  253. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  254.  
  255. try {
  256. window.open(await Cobalt(window.location.href), '_blank');
  257. } catch (err) {
  258. notify('An error occurred!', JSON.stringify(err));
  259. }
  260. }
  261.  
  262. /**
  263. * Right click => download audio
  264. * @param {Event} e The right click event
  265. * @returns {void}
  266. */
  267. async function rightClick(e) {
  268. e.preventDefault();
  269.  
  270. if (!window.location.pathname.slice(1))
  271. return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused
  272.  
  273. try {
  274. window.open(await Cobalt(window.location.href, true), '_blank');
  275. } catch (err) {
  276. notify('An error occurred!', JSON.stringify(err));
  277. }
  278.  
  279. return false;
  280. }
  281.  
  282. downloadButton.addEventListener('click', leftClick);
  283. downloadButton.addEventListener('contextmenu', rightClick);
  284. logger('Event listeners added to the download button');
  285.  
  286. switch (YOUTUBE_SERVICE) {
  287. case 'YOUTUBE':
  288. logger('Waiting for the player buttons row to appear');
  289. const YTButtonsRow = await waitForElement('div#player div.ytp-chrome-controls div.ytp-right-controls');
  290. logger('Buttons row is now available');
  291.  
  292. if (!YTButtonsRow.querySelector('#' + buttonId))
  293. YTButtonsRow.insertBefore(downloadButton, YTButtonsRow.firstChild);
  294. logger('Download button added to the buttons row');
  295.  
  296. break;
  297. case 'MUSIC':
  298. logger('Waiting for the player buttons row to appear');
  299. const YTMButtonsRow = await waitForElement(
  300. '[slot="player-bar"] div.middle-controls div.middle-controls-buttons'
  301. );
  302. logger('Buttons row is now available');
  303.  
  304. if (!YTMButtonsRow.querySelector('#' + buttonId))
  305. YTMButtonsRow.insertBefore(downloadButton, YTMButtonsRow.firstChild);
  306. logger('Download button added to the buttons row');
  307.  
  308. break;
  309. case 'SHORTS':
  310. // wait for the first reel to load
  311. logger('Waiting for the reels to load');
  312. await waitForElement('div#actions.ytd-reel-player-overlay-renderer div#like-button');
  313. logger('Reels loaded');
  314.  
  315. document.querySelectorAll('div#actions.ytd-reel-player-overlay-renderer').forEach((buttonsCol) => {
  316. if (!buttonsCol.getAttribute('data-button-added') && !buttonsCol.querySelector(buttonId)) {
  317. const dlButtonCopy = downloadButton.cloneNode(true);
  318. dlButtonCopy.addEventListener('click', leftClick);
  319. dlButtonCopy.addEventListener('contextmenu', rightClick);
  320.  
  321. buttonsCol.insertBefore(dlButtonCopy, buttonsCol.querySelector('div#like-button'));
  322. buttonsCol.setAttribute('data-button-added', true);
  323. }
  324. });
  325. logger('Download buttons added to reels');
  326.  
  327. break;
  328.  
  329. default:
  330. break;
  331. }
  332. }
  333.  
  334. ['yt-navigate', 'yt-navigate-finish'].forEach((evName) =>
  335. document.addEventListener(evName, (e) => {
  336. YOUTUBE_SERVICE = updateService();
  337. logger('Service is:', YOUTUBE_SERVICE);
  338. if (!YOUTUBE_SERVICE) return;
  339. RENDERER(e);
  340. })
  341. );
  342. })();