您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A simple userscript to download YouTube videos in MAX QUALITY
当前为
// ==UserScript== // @name YouTube downloader // @icon https://raw.githubusercontent.com/madkarmaa/youtube-downloader/main/images/icon.png // @namespace aGkgdGhlcmUgOik= // @source https://github.com/madkarmaa/youtube-downloader // @supportURL https://github.com/madkarmaa/youtube-downloader // @version 2.0.4 // @description A simple userscript to download YouTube videos in MAX QUALITY // @author mk_ // @match *://*.youtube.com/* // @connect co.wuk.sh // @connect raw.githubusercontent.com // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlHttpRequest // @grant GM_xmlhttpRequest // @run-at document-end // ==/UserScript== (async () => { 'use strict'; const randomNumber = Math.floor(Math.random() * Date.now()); const buttonId = `yt-downloader-btn-${randomNumber}`; let oldLog = console.log; /** * Custom logging function copied from `console.log` * @param {...any} args `console.log` arguments * @returns {void} */ const logger = (...args) => oldLog.apply(console, ['\x1b[31m[YT Downloader >> INFO]\x1b[0m', ...args]); GM_addStyle(` @import url('https://fonts.googleapis.com/css2?family=Fira+Code:[email protected]&display=swap') #${buttonId}.YOUTUBE > svg { margin-top: 3px; margin-bottom: -3px; } #${buttonId}.SHORTS > svg { margin-left: 3px; } #${buttonId}:hover > svg { fill: #f00; } #yt-downloader-notification-${randomNumber} { background-color: #282828; color: #fff; border: 2px solid #fff; border-radius: 8px; position: fixed; top: 0; right: 0; margin-top: 10px; margin-right: 10px; padding: 15px; z-index: 99999; max-width: 17.5%; } #yt-downloader-notification-${randomNumber} > h3 { color: #f00; font-size: 2.5rem; } #yt-downloader-notification-${randomNumber} > span { font-style: italic; font-size: 1.5rem; } #yt-downloader-notification-${randomNumber} a { color: #f00; } #yt-downloader-notification-${randomNumber} > button { position: absolute; top: 0; right: 0; background: none; border: none; outline: none; width: fit-content; height: fit-content; margin: 5px; padding: 0; } #yt-downloader-notification-${randomNumber} > button > svg { fill: #fff; } #yt-downloader-menu-${randomNumber} { width: 40vw; height: 60vh; background-color: rgba(0, 0, 0, 0.9); position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 999; border-radius: 8px; border: 2px solid rgba(255, 0, 0, 0.9); opacity: 0; display: flex; flex-direction: column; gap: 1.3rem; color: #fff; font-size: 1.5rem !important; padding: 15px; } #yt-downloader-menu-${randomNumber} > textarea { resize: none; width: 100%; background: transparent !important; border: none !important; color: #fff !important; height: 100%; outline: none !important; margin: 0 !important; padding: 0 !important; font-family: "Fira Code", monospace; font-size: 1.5rem; } #yt-downloader-menu-${randomNumber} > textarea::-webkit-scrollbar { display: none; } #yt-downloader-menu-${randomNumber} > button { opacity: 0.25; position: absolute; top: 0; right: 0; border-top-right-radius: 8px; background-color: rgba(255, 0, 0, 0.5); color: #fff; outline: none; border: none; border-bottom: 2px solid #f00; border-left: 2px solid #f00; cursor: pointer; font-family: "Fira Code", monospace; font-size: 1.2rem; transition: all .3s ease-in-out; margin: 0; padding: 3px 5px; } #yt-downloader-menu-${randomNumber} > button:hover { opacity: 1; } #yt-downloader-menu-${randomNumber}.opened { animation: openMenu .3s linear forwards; } #yt-downloader-menu-${randomNumber}.closed { animation: closeMenu .3s linear forwards; } input { accent-color: #f00; } @keyframes openMenu { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes closeMenu { 0% { opacity: 1; } 100% { opacity: 0; } } `); /** * Download a video using the Cobalt API * @param {String} videoUrl The url of the video to download * @param {*} audioOnly Wether to download the video as audio only or not * @returns */ function Cobalt(videoUrl, audioOnly = false) { // Use Promise because GM.xmlHttpRequest is async and behaves differently with different userscript managers return new Promise((resolve, reject) => { // https://github.com/wukko/cobalt/blob/current/docs/api.md GM_xmlhttpRequest({ method: 'POST', url: 'https://co.wuk.sh/api/json', headers: { 'Cache-Control': 'no-cache', Accept: 'application/json', 'Content-Type': 'application/json', }, data: JSON.stringify({ url: encodeURI(videoUrl), // video url vQuality: 'max', // always max quality filenamePattern: 'basic', // file name = video title isAudioOnly: audioOnly, disableMetadata: true, // privacy }), onload: (response) => { const data = JSON.parse(response.responseText); if (data?.url) resolve(data.url); else reject(data); }, onerror: (err) => reject(err), }); }); } /** * https://stackoverflow.com/a/61511955 * @param {String} selector The CSS selector used to select the element * @returns {Promise<Element>} The selected element */ function waitForElement(selector) { return new Promise((resolve) => { if (document.querySelector(selector)) return resolve(document.querySelector(selector)); const observer = new MutationObserver(() => { if (document.querySelector(selector)) { observer.disconnect(); resolve(document.querySelector(selector)); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } /** * Append a notification element to the document * @param {String} title The title of the message * @param {String} message The message to display * @returns {void} */ function notify(title, message) { const notificationContainer = document.createElement('div'); notificationContainer.id = `yt-downloader-notification-${randomNumber}`; const titleElement = document.createElement('h3'); titleElement.textContent = title; const messageElement = document.createElement('span'); messageElement.innerHTML = message; const closeButton = document.createElement('button'); closeButton.innerHTML = '<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>'; closeButton.addEventListener('click', () => { notificationContainer.remove(); }); notificationContainer.append(titleElement, messageElement, closeButton); document.body.appendChild(notificationContainer); } /** * Throw an error after `sec` seconds * @param {number} sec How long to wait before throwing an error (seconds) * @returns {Promise<void>} */ function timeout(sec) { return new Promise((resolve, reject) => { setTimeout(() => { reject('Request timed out after ' + sec + ' seconds'); }, sec * 1000); }); } /** * Detect which YouTube service is being used * @returns {"SHORTS" | "MUSIC" | "YOUTUBE" | null} */ function updateService() { if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/shorts')) return 'SHORTS'; else if (window.location.hostname === 'music.youtube.com') return 'MUSIC'; else if (window.location.hostname === 'www.youtube.com' && window.location.pathname.startsWith('/watch')) return 'YOUTUBE'; else return null; } /** * Left click => download video * @returns {void} */ async function leftClick() { if (!window.location.pathname.slice(1)) return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused if (!VIDEO_DATA) return notify("The video data hasn't been loaded yet", 'Try again in a few seconds...'); try { // window.open(await Cobalt(window.location.href), '_blank'); eval(replacePlaceholders(codeTextArea.value)); } catch (err) { notify('An error occurred!', JSON.stringify(err)); } } /** * Right click => download audio * @param {Event} e The right click event * @returns {void} */ async function rightClick(e) { e.preventDefault(); if (!window.location.pathname.slice(1)) return notify('Hey!', 'The video/song player is not open, I cannot see the link to download!'); // do nothing if video is not focused try { window.open(await Cobalt(window.location.href, true), '_blank'); } catch (err) { notify('An error occurred!', JSON.stringify(err)); } return false; } /** * Middle mouse button click => open menu * @param {MouseEvent} e The mouse event * @returns {false} */ function middleClick(e) { if (e.buttons !== 4) return; e.preventDefault(); menuPopup.style.display = 'block'; menuPopup.classList.add('opened'); menuPopup.classList.remove('closed'); notify( 'Wait! Read this first!', `Here you can set up the code you want to be executed when LEFT CLICKING the download button. <br><br>It requires JavaScript coding knowledge, so proceed only if you know what you are doing. <br><br> You have access to <b>some</b> <a target="_blank" href="https://violentmonkey.github.io/api/gm/">GM API functions</a>, described in the userscript header. <br><br><a target="_blank" href="https://github.com/madkarmaa/youtube-downloader/docs/PLACEHOLDERS.md">Read more</a>` ); return false; } /** * Renderer process * @param {CustomEvent} event The YouTube custom navigation event * @returns {Promise<void>} */ async function RENDERER(event) { logger('Checking if user is watching'); // do nothing if the user isn't watching any media if (!event?.detail?.endpoint?.watchEndpoint?.videoId && !event?.detail?.endpoint?.reelWatchEndpoint?.videoId) { logger('User is not watching'); return; } logger('User is watching'); // wait for the button to copy to appear before continuing logger('Waiting for the button to copy to appear'); let buttonToCopy; switch (YOUTUBE_SERVICE) { case 'YOUTUBE': buttonToCopy = waitForElement( 'div#player div.ytp-chrome-controls div.ytp-right-controls button[aria-label="Settings"]' ); break; case 'MUSIC': buttonToCopy = waitForElement( '[slot="player-bar"] div.middle-controls div.middle-controls-buttons #like-button-renderer #button-shape-dislike button[aria-label="Dislike"]' ); break; case 'SHORTS': buttonToCopy = waitForElement( 'div#actions.ytd-reel-player-overlay-renderer div#comments-button button' ); break; default: break; } // cancel rendering after 5 seconds of the button not appearing in the document buttonToCopy = await Promise.race([timeout(5), buttonToCopy]); logger('Button to copy is:', buttonToCopy); // create the download button const downloadButton = document.createElement('button'); downloadButton.id = buttonId; downloadButton.title = 'Click to download as video\nRight click to download as audio\nMMB to open advanced settings menu'; downloadButton.innerHTML = '<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>'; downloadButton.classList = buttonToCopy.classList; if (YOUTUBE_SERVICE === 'YOUTUBE') downloadButton.classList.add('ytp-hd-quality-badge'); downloadButton.classList.add(YOUTUBE_SERVICE); logger('Download button created:', downloadButton); downloadButton.addEventListener('click', leftClick); downloadButton.addEventListener('contextmenu', rightClick); downloadButton.addEventListener('mousedown', middleClick); logger('Event listeners added to the download button'); switch (YOUTUBE_SERVICE) { case 'YOUTUBE': logger('Waiting for the player buttons row to appear'); const YTButtonsRow = await waitForElement('div#player div.ytp-chrome-controls div.ytp-right-controls'); logger('Buttons row is now available'); if (!YTButtonsRow.querySelector('#' + buttonId)) YTButtonsRow.insertBefore(downloadButton, YTButtonsRow.firstChild); logger('Download button added to the buttons row'); break; case 'MUSIC': logger('Waiting for the player buttons row to appear'); const YTMButtonsRow = await waitForElement( '[slot="player-bar"] div.middle-controls div.middle-controls-buttons' ); logger('Buttons row is now available'); if (!YTMButtonsRow.querySelector('#' + buttonId)) YTMButtonsRow.insertBefore(downloadButton, YTMButtonsRow.firstChild); logger('Download button added to the buttons row'); break; case 'SHORTS': // wait for the first reel to load logger('Waiting for the reels to load'); await waitForElement('div#actions.ytd-reel-player-overlay-renderer div#like-button'); logger('Reels loaded'); document.querySelectorAll('div#actions.ytd-reel-player-overlay-renderer').forEach((buttonsCol) => { if (!buttonsCol.getAttribute('data-button-added') && !buttonsCol.querySelector(buttonId)) { const dlButtonCopy = downloadButton.cloneNode(true); dlButtonCopy.addEventListener('click', leftClick); dlButtonCopy.addEventListener('contextmenu', rightClick); dlButtonCopy.addEventListener('mousedown', middleClick); buttonsCol.insertBefore(dlButtonCopy, buttonsCol.querySelector('div#like-button')); buttonsCol.setAttribute('data-button-added', true); } }); logger('Download buttons added to reels'); break; default: break; } } /** * Replace the placeholders in a string with their values * @param {*} inputString The input string * @returns {String} The string with the parsed placeholders */ function replacePlaceholders(inputString) { return inputString.replace(/{{\s*([^}\s]+)\s*}}/g, (match, placeholder) => VIDEO_DATA[placeholder] || match); } let VIDEO_DATA; document.addEventListener('yt-player-updated', (e) => { const temp_video_data = e.detail.getVideoData(); VIDEO_DATA = { current_time: e.detail.getCurrentTime(), video_duration: e.detail.getDuration(), video_url: e.detail.getVideoUrl(), video_author: temp_video_data?.author, video_title: temp_video_data?.title, video_id: temp_video_data?.video_id, }; logger('Video data updated', VIDEO_DATA); }); let YOUTUBE_SERVICE = updateService(); const menuPopup = document.createElement('div'); menuPopup.id = `yt-downloader-menu-${randomNumber}`; menuPopup.style.display = 'none'; menuPopup.classList.add('closed'); const codeTextArea = document.createElement('textarea'); const resetButton = document.createElement('button'); resetButton.textContent = 'Reset to default'; resetButton.addEventListener('click', () => { codeTextArea.value = `(async () => {\n\n${Cobalt.toString()}\n\nwindow.open(await Cobalt('{{ video_url }}'), '_blank');\n\n})();`; logger('Code reset'); }); menuPopup.append(codeTextArea, resetButton); codeTextArea.value = localStorage.getItem('yt-dl-code') || `(async () => {\n\n${Cobalt.toString()}\n\nwindow.open(await Cobalt('{{ video_url }}'), '_blank');\n\n})();`; localStorage.setItem('yt-dl-code', codeTextArea.value); logger('Code retrieved and set to textarea'); menuPopup.addEventListener('animationend', (e) => { if (e.animationName === 'closeMenu') e.target.style.display = 'none'; }); document.addEventListener('click', (e) => { if (menuPopup.style.display !== 'none' && e.target !== menuPopup && !menuPopup.contains(e.target)) { e.preventDefault(); menuPopup.classList.add('closed'); menuPopup.classList.remove('opened'); logger('Menu closed'); localStorage.setItem('yt-dl-code', codeTextArea.value); logger('Code saved to localStorage'); return false; } }); document.body.appendChild(menuPopup); logger('Menu created', menuPopup); ['yt-navigate', 'yt-navigate-finish'].forEach((evName) => document.addEventListener(evName, (e) => { YOUTUBE_SERVICE = updateService(); logger('Service is:', YOUTUBE_SERVICE); if (!YOUTUBE_SERVICE) return; RENDERER(e); }) ); })();