// ==UserScript== // @name Local YouTube Downloader // @name:zh-TW 本地 YouTube 下載器 // @name:zh-CN 本地 YouTube 下载器 // @namespace https://blog.maple3142.net/ // @version 0.9.8 // @description Get youtube raw link without external service. // @description:zh-TW 不需要透過第三方的服務就能下載 YouTube 影片。 // @description:zh-CN 不需要透过第三方的服务就能下载 YouTube 影片。 // @author maple3142 // @match https://*.youtube.com/* // @require https://unpkg.com/vue@2.6.10/dist/vue.js // @require https://unpkg.com/xfetch-js@0.3.4/xfetch.min.js // @compatible firefox >=52 // @compatible chrome >=55 // @license MIT // @downloadURL none // ==/UserScript== ;(function() { 'use strict' const DEBUG = true const RESTORE_ORIGINAL_TITLE_FOR_CURRENT_VIDEO = true const createLogger = (console, tag) => Object.keys(console) .map(k => [k, (...args) => (DEBUG ? console[k](tag + ': ' + args[0], ...args.slice(1)) : void 0)]) .reduce((acc, [k, fn]) => ((acc[k] = fn), acc), {}) const logger = createLogger(console, 'YTDL') const LANG_FALLBACK = 'en' const LOCALE = { en: { togglelinks: 'Show/Hide Links', stream: 'Stream', adaptive: 'Adaptive', videoid: 'Video Id: ', thumbnail: 'Thumbnail', inbrowser_adaptive_merger: 'In browser adaptive video & audio merger' }, 'zh-tw': { togglelinks: '顯示 / 隱藏連結', stream: '串流 Stream', adaptive: '自適應 Adaptive', videoid: '影片 ID: ', thumbnail: '影片縮圖', inbrowser_adaptive_merger: '瀏覽器版自適應影片及聲音合成器' }, zh: { togglelinks: '显示 / 隐藏链接', stream: '串流 Stream', adaptive: '自适应 Adaptive', videoid: '视频 ID: ', thumbnail: '视频缩图', inbrowser_adaptive_merger: '浏览器版自适应视频及声音合成器' }, kr: { togglelinks: '링크 보이기/숨기기', stream: '스트리밍', adaptive: '조정 가능한', videoid: 'Video Id: {{id}}' }, he: { togglelinks: 'הצג/הסתר קישורים', stream: 'סטרים', adaptive: 'אדפטיבי', videoid: 'מזהה סרטון: {{id}}' } } const findLang = l => { // language resolution logic: zh-tw --(if not exists)--> zh --(if not exists)--> LANG_FALLBACK(en) l = l.toLowerCase().replace('_', '-') if (l in LOCALE) return l else if (l.length > 2) return findLang(l.split('-')[0]) else return LANG_FALLBACK } const $ = (s, x = document) => x.querySelector(s) const $el = (tag, opts) => { const el = document.createElement(tag) Object.assign(el, opts) return el } const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const parseDecsig = data => { try { if (data.startsWith('var script')) { // they inject the script via script tag const obj = {} const document = { createElement: () => obj, head: { appendChild: () => {} } } eval(data) data = obj.innerHTML } const fnnameresult = /\.set\([^,]*,encodeURIComponent\(([^(]*)\(/.exec(data) const fnname = fnnameresult[1] const _argnamefnbodyresult = new RegExp(escapeRegExp(fnname) + '=function\\((.+?)\\){(.+?)}').exec(data) const [_, argname, fnbody] = _argnamefnbodyresult const helpernameresult = /;(.+?)\..+?\(/.exec(fnbody) const helpername = helpernameresult[1] const helperresult = new RegExp('var ' + escapeRegExp(helpername) + '={[\\s\\S]+?};').exec(data) const helper = helperresult[0] logger.log(`parsedecsig result: %s=>{%s\n%s}`, argname, helper, fnbody) return new Function([argname], helper + '\n' + fnbody) } catch (e) { logger.error('parsedecsig error: %o', e) logger.info('script content: %s', data) logger.info( 'If you encounter this error, please copy the full "script content" to https://pastebin.com/ for me.' ) } } const parseQuery = s => [...new URLSearchParams(s).entries()].reduce((acc, [k, v]) => ((acc[k] = v), acc), {}) const getVideo = async (id, decsig) => { return xf .get(`https://www.youtube.com/get_video_info?video_id=${id}&el=detailpage`) .text() .then(async data => { const obj = parseQuery(data) const playerResponse = JSON.parse(obj.player_response) logger.log(`video %s data: %o`, id, obj) logger.log(`video %s playerResponse: %o`, id, playerResponse) if (obj.status === 'fail') { throw obj } let stream = [] if (playerResponse.streamingData.formats) { stream = playerResponse.streamingData.formats.map(x => Object.assign(x, parseQuery(x.cipher))) logger.log(`video %s stream: %o`, id, stream) if (stream[0].sp && stream[0].sp.includes('sig')) { stream = stream .map(x => ({ ...x, s: decsig(x.s) })) .map(x => ({ ...x, url: x.url + `&sig=${x.s}` })) } } let adaptive = [] if (playerResponse.streamingData.adaptiveFormats) { adaptive = playerResponse.streamingData.adaptiveFormats.map(x => Object.assign(x, parseQuery(x.cipher)) ) logger.log(`video %s adaptive: %o`, id, adaptive) if (adaptive[0].sp && adaptive[0].sp.includes('sig')) { adaptive = adaptive .map(x => ({ ...x, s: decsig(x.s) })) .map(x => ({ ...x, url: x.url + `&sig=${x.s}` })) } } logger.log(`video %s result: %o`, id, { stream, adaptive }) return { stream, adaptive, meta: obj } }) } const getVideoDetails = id => xf .get('https://www.googleapis.com/youtube/v3/videos', { qs: { key: 'AIzaSyBk6o0igFl-P4Qe4ouVlRTPlqX7kruWdUg', part: 'snippet', id } }) .json(r => r.items[0]) const getHighresThumbnail = id => getVideoDetails(id).then( details => Object.values(details.snippet.thumbnails) .map(d => { const x = {} x.url = d.url x.size = d.width * d.height return x }) .sort((a, b) => b.size - a.size)[0].url ) const workerMessageHandler = async e => { const decsig = await xf.get(e.data.path).text(parseDecsig) const result = await getVideo(e.data.id, decsig) self.postMessage(result) } const ytdlWorkerCode = ` importScripts('https://unpkg.com/xfetch-js@0.3.4/xfetch.min.js') const DEBUG=${DEBUG} const logger=(${createLogger})(console, 'YTDL') const escapeRegExp=${escapeRegExp} const parseQuery=${parseQuery} const parseDecsig=${parseDecsig} const getVideo=${getVideo} self.onmessage=${workerMessageHandler}` const ytdlWorker = new Worker(URL.createObjectURL(new Blob([ytdlWorkerCode]))) const workerGetVideo = (id, path) => { logger.log(`workerGetVideo start: %s %s`, id, path) return new Promise((res, rej) => { const callback = e => { ytdlWorker.removeEventListener('message', callback) logger.log('workerGetVideo end: %o', e.data) res(e.data) } ytdlWorker.addEventListener('message', callback) ytdlWorker.postMessage({ id, path }) }) } const template = `
`.slice(1) const app = new Vue({ data() { return { hide: true, id: '', stream: [], adaptive: [], dark: false, thumbnail: null, lang: findLang(navigator.language) } }, computed: { strings() { return LOCALE[this.lang.toLowerCase()] } }, watch: { async hide() { if (this.thumbnail == null) { app.thumbnail = await getHighresThumbnail(this.id) } } }, template }) logger.log(`default language: %s`, app.lang) // attach element const shadowHost = $el('div') const shadow = shadowHost.attachShadow ? shadowHost.attachShadow({ mode: 'closed' }) : shadowHost // no shadow dom logger.log('shadowHost: %o', shadowHost) const container = $el('div') shadow.appendChild(container) app.$mount(container) if (DEBUG && typeof unsafeWindow !== 'undefined') { // expose some functions for debugging unsafeWindow.$app = app unsafeWindow.parseQuery = parseQuery unsafeWindow.parseDecsig = parseDecsig unsafeWindow.getVideo = getVideo } const getLangCode = () => { if (typeof ytplayer !== 'undefined') { return ytplayer.config.args.host_language } else if (typeof yt !== 'undefined') { return yt.config_.GAPI_LOCALE } return null } const textToHtml = t => { // URLs starting with http://, https:// t = t.replace( /(\b(https?):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim, '$1' ) t = t.replace(/\n/g, '