NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name XDown YouTube Downloader // @name:zh-TW XDown-YouTube 下載器 // @name:zh-CN XDown-YouTube 下载器 // @namespace https://xdown.org/ // @version 0.0.1 // @description Used XDown YouTube // @description:zh-TW 使用XDown便捷下载YouTube // @description:zh-CN 使用XDown便捷下载YouTube // @author xdown.org // @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 // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @compatible firefox >=52 // @compatible chrome >=55 // @license MIT // ==/UserScript== ;(function() { 'use strict' const XYouTubeVersion = "0.0.1"; 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 Download', stream: 'Stream', adaptive: 'Adaptive', videoid: 'Video Id: ', videoExt: 'Video Format', thumbnail: 'Thumbnail', inbrowser_adaptive_merger: 'In browser adaptive video & audio merger' }, 'zh-tw': { togglelinks: '顯示 / 隱藏下載', stream: '串流 Stream', adaptive: '自適應 Adaptive', videoid: '影片 ID: ', videoExt: 'Video Format', thumbnail: '影片縮圖', inbrowser_adaptive_merger: '瀏覽器版自適應影片及聲音合成器' }, zh: { togglelinks: '显示 / 隐藏下载', stream: '串流 Stream', adaptive: '自适应 Adaptive', videoid: '视频 ID: ', videoExt: 'Video Format', thumbnail: '视频缩图', inbrowser_adaptive_merger: '浏览器版自适应视频及声音合成器' }, kr: { togglelinks: '링크 보이기/숨기기', stream: '스트리밍', adaptive: '조정 가능한', videoid: 'Video Id: {{id}}', videoExt: 'Video Format' }, es: { togglelinks: 'Mostrar/Ocultar Links', stream: 'Stream', adaptive: 'Adaptable', videoid: 'Id del Video: ', videoExt: 'Video Format', thumbnail: 'Miniatura', inbrowser_adaptive_merger: 'Acoplar Audio a Video ' }, he: { togglelinks: 'הצג/הסתר קישורים', stream: 'סטרים', adaptive: 'אדפטיבי', videoid: 'מזהה סרטון: {{id}}', videoExt: 'Video Format' } } 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 ---', e.data); res(e.data) } ytdlWorker.addEventListener('message', callback) ytdlWorker.postMessage({ id, path }) }) } const template = ` <div class="xdown-box" :class="{'dark':dark}"> <div class="t-center fs-14px" v-text="xdownVersion"></div> <div @click="hide=!hide" class="box-toggle t-center fs-14px" v-text="strings.togglelinks"></div> <div :class="{'hide':hide}"> <div class="t-center fs-14px" v-text="strings.videoid+videoId"></div> <div class="d-flex"> <div class="f-1 of-h"> <div class="t-center fs-14px xdown-file-name" v-text="xdownFileName"> </div> <div class="video-item-div"> <div class="file-ext-div" v-for="(item,index) in xdownExt['video']"> <input type="radio" :id="item" name="videoFormat" :value="item" v-model="xdownVideoValue"> <label :for="item">{{ item }}</label> </div> <div class="f-1 of-h" v-for="linkItem in xdownVideo"> <button class="ytdl-link-btn fs-14px" @click="startDownVideoItem(linkItem)"> {{ displayDownVideoItem(linkItem) }} </button> </div> </div> <div class="audio-item-div"> <div class="file-ext-div" v-for="(item,index) in xdownExt['audio']"> <input type="radio" :id="item" name="audioFormat" :value="item" v-model="xdownAudioValue"> <label :for="item">{{ item }}</label> </div> <div class="f-1 of-h" v-for="linkItem in xdownAudio"> <button class="ytdl-link-btn fs-14px" @click="startDownAudioItem(linkItem)"> {{ displayDownAudioItem(linkItem) }} </button> </div> </div> </div> </div> </div> </div> `.slice(1) const app = new Vue({ data() { return { hide: true, videoId: '', stream: [], adaptive: [], xdownVersion: '', xdownFileName: 'unknown', xdownLengthSeconds: 0, xdownVideo: [], xdownVideoValue: 'mkv', xdownAudio: [], xdownAudioValue: 'm4a', xdownMediumType: 'video', xdownExt: { 'video': [ "mkv" ,"mp4" ], 'audio': ["m4a", "mp3"] }, dark: false, thumbnail: null, lang: findLang(navigator.language) } }, methods: { checkPluginVersion: function() { let curTimestamp = (new Date()).valueOf(); let xTimesamp = localStorage.getItem('xdown-timestamp'); let xVersion = localStorage.getItem('xdown-version'); GM_setValue('current_version', XYouTubeVersion); let getCheckTimestamp = GM_getValue('current_checktimestamp'); console.log('getCheckTimestamp==', getCheckTimestamp || 0); if(!getCheckTimestamp || getCheckTimestamp < 0 || getCheckTimestamp - curTimestamp > (60 * 60 * 2 * 1000) ) { xf.get('https://update.xdown.org/a/xdown-youtube-version.json', { qs: { 'xdown-youtube': XYouTubeVersion, 'chrome-crx': xVersion, 't': curTimestamp }, method: 'GET', mode: 'cors' }).then(res => { return res.json(); }).then(json => { console.log('xdown-youtube-version==', json); let chkXVersion = json['chrome-crx']['version']; let chkXYouTubeVersion = json['xdown-youtube']['version']; let chkXUpdateURL = json['updateurl']; if( XYouTubeVersion != chkXYouTubeVersion) { alert('XDown-YouTube发现新版本,请更新!'); location.href = chkXUpdateURL; } else { GM_setValue('current_checktimestamp', (new Date()).valueOf()); } return json; }).catch(err => { console.log('请求错误', err); }) } }, formatFileLength: function(fileLengthVal) { if(!fileLengthVal) { return '-'; } if(fileLengthVal < 1024) { return `${fileLengthVal}B`; } else if (fileLengthVal < 1024 * 1024 ) { return (fileLengthVal / 1024.0).toFixed(2) + 'KB'; } else if (fileLengthVal < 1024 * 1024 * 1024 ) { return (fileLengthVal / (1024 * 1024) ).toFixed(2) + 'MB'; } else { return (fileLengthVal / (1024 * 1024 * 1024) ).toFixed(2) + 'GB'; } }, startDownVideoItem: function(linkItem) { var evt = document.createEvent("CustomEvent"); var xDownData = { linkType: 4, linkList: [ { linkTxt: linkItem.videoItem.url, fileName: `${this.xdownFileName}.${linkItem.videoItem.fileExt}`, fileSize: linkItem.videoItem.contentLength }, { linkTxt: linkItem.audioItem.url, fileName: `${this.xdownFileName}.${linkItem.audioItem.fileExt}`, fileSize: linkItem.audioItem.contentLength } ], convertFileSize: linkItem.fileLengthVal, convertFileName: `${this.xdownFileName}.${this.xdownVideoValue}`, httpHeaders: { 'User-Agent': navigator.userAgent } } evt.initCustomEvent('ADD-XDOWN-EVENT', true, false, JSON.stringify(xDownData)); document.dispatchEvent(evt); }, displayDownVideoItem: function(linkItem) { return `${linkItem.videoItem.qualityLabel}/${linkItem.videoItem.codecs}/${linkItem.audioItem.codecs} | ${this.formatFileLength(linkItem.fileLengthVal)} | to XDown`; }, startDownAudioItem: function(linkItem) { var evt = document.createEvent("CustomEvent"); var xDownData = { linkType: 3, linkList: [ { linkTxt: linkItem.url, fileName: `${this.xdownFileName}.${linkItem.fileExt}`, fileSize: linkItem.contentLength, convertFileName: `${this.xdownFileName}.${this.xdownAudioValue}` } ], httpHeaders: { 'User-Agent': navigator.userAgent } } evt.initCustomEvent('ADD-XDOWN-EVENT', true, false, JSON.stringify(xDownData)); document.dispatchEvent(evt); }, displayDownAudioItem: function(linkItem) { return `${linkItem.codecs} | ${this.formatFileLength(linkItem.contentLength)} | to XDown`; } }, computed: { strings() { return LOCALE[this.lang.toLowerCase()] } }, watch: { async hide() { if (this.thumbnail == null) { app.thumbnail = await getHighresThumbnail(this.id) } } }, mounted: function() { this.checkPluginVersion(); }, 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, '<a href="$1" target="_blank">$1</a>' ) t = t.replace(/\n/g, '<br>') return t } const applyOriginalTitle = meta => { const data = eval(`(${meta.player_response})`).videoDetails // not a valid json, so JSON.parse won't work if ($('#eow-title')) { // legacy youtube $('#eow-title').textContent = data.title $('#eow-description').innerHTML = textToHtml(data.shortDescription) } else if ($('h1.title')) { // new youtube (polymer) $('h1.title').textContent = data.title $('yt-formatted-string.content').innerHTML = textToHtml(data.shortDescription) } } const loadParse = async videoId => { try { let xTimesamp = localStorage.getItem('xdown-timestamp'); let xVersion = localStorage.getItem('xdown-version'); if(!xVersion) { app.xdownVersion = '未安装xdown浏览器CRX插件!'; } else { app.xdownVersion = 'CRX插件版本:' + xVersion if(xTimesamp && xTimesamp.length == 13 && !isNaN(xTimesamp)) { let tmpDate = new Date(parseFloat(xTimesamp)); if(tmpDate) { app.xdownVersion = app.xdownVersion + ',插件加载日期:' + formatDate(tmpDate); } } } const basejs = typeof ytplayer !== 'undefined' ? 'https://' + location.host + ytplayer.config.assets.js : $('script[src$="base.js"]').src const data = await workerGetVideo(videoId, basejs) logger.log('video loaded: %s', videoId) if (RESTORE_ORIGINAL_TITLE_FOR_CURRENT_VIDEO) { try { applyOriginalTitle(data.meta) } catch (e) { // just make sure the main function will work even if original title applier doesn't work } } app.videoId = videoId; app.stream = data.stream; app.adaptive = data.adaptive; app.meta = data.meta; function formatDate(curDate) { var year = curDate.getFullYear(); var month = curDate.getMonth()+1; if(month < 10) { month = '0' + month; } var date = curDate.getDate(); if(date < 10) { date = '0' + date; } var hour = curDate.getHours(); if(hour < 10) { hour = '0' + hour; } var minute = curDate.getMinutes(); if(minute < 10) { minute = '0' + minute; } var second = curDate.getSeconds(); if (second < 10) { second = '0' + second; } return year + "-" + month + "-" + date + " " + hour + ":" + minute+":"+second; } app.xdownFileName = ''; if (data.meta.player_response && data.meta.player_response) { let playerObj = JSON.parse(data.meta.player_response); if(playerObj.videoDetails && playerObj.videoDetails.title) { app.xdownFileName = playerObj.videoDetails.title; app.xdownLengthSeconds = playerObj.videoDetails.lengthSeconds; } } if(app.xdownFileName) { app.xdownFileName = app.xdownFileName.replace(/\./g,'').replace(/\//g,'').replace(/\\/g,'').trim(); } if(app.adaptive) { let audioKeyDict = { "mp4a.40.2": 1000, "vorbis": 800, "opus":500 }; let videoKeyDict = { "p60": 5000, "p": 4000 }; let vcodecKeyDict = { "vp9.2": 800, "vp9": 500}; let audioArrayList = []; let videoArrayList = []; for(let idx = 0; idx < app.adaptive.length; idx++) { let curItem = app.adaptive[idx]; let qualityLabel = curItem.qualityLabel || ''; if (curItem.mimeType && curItem.mimeType.indexOf(';') != -1) { let groupKey = ''; let orderIdx = 0; let extPos = 0; let nSplitArray = curItem.mimeType.split(';'); if(nSplitArray && nSplitArray.length >= 2) { let curFormat = nSplitArray[0].trim(); let curExt = ''; extPos = curFormat.indexOf('/'); if(extPos != -1) { curExt = curFormat.substr(extPos); } curExt = curExt.replace(/\//g,''); let curCodecs = nSplitArray[1].trim().replace('codecs=','').replace(/\"/g,''); if (curFormat.indexOf('audio') != -1 ) { // audio curItem.format = curFormat; curItem.codecs = curCodecs; if (curCodecs.indexOf('mp4a') != -1) { curExt = 'm4a'; } else if(curExt.indexOf('webm') != -1) { curExt = "weba"; } curItem.fileExt = curExt; curItem.groupKey = curCodecs; curItem.orderIdx = audioKeyDict[curCodecs] || 0; audioArrayList.push(curItem); } else if (qualityLabel && qualityLabel.indexOf('p') > 0) { // video let nTmpPos = qualityLabel.indexOf('p'); let groupKey = qualityLabel.substr(0,nTmpPos); let orderKey = qualityLabel.substr(nTmpPos); if (orderKey.indexOf('hdr') != -1) { orderIdx = 6000; } else { orderIdx = videoKeyDict[orderKey] || 0; } curItem.orderKey = orderKey; curItem.fileExt = curExt; curItem.format = curFormat; curItem.codecs = curCodecs; curItem.orderIdx = orderIdx + (vcodecKeyDict[curCodecs] || 0); curItem.groupKey = groupKey; videoArrayList.push(curItem); } } } } function groupByKey( array , f ) { let groups = {}; array.forEach( function( o ) { let group = JSON.stringify( f(o) ); groups[group] = groups[group] || []; groups[group].push( o ); }); return Object.keys(groups).map( function( group ) { return groups[group]; }); } let groupVideoArray = groupByKey(videoArrayList, function(item) { return item.groupKey; }); // console.log('groupVideoArray--',groupVideoArray); let filterXDownMap = {}; let filetrXDownKey = []; for(let videoIdx in groupVideoArray) { let videoList = groupVideoArray[videoIdx]; if(videoList && Array.isArray(videoList) && videoList.length > 0) { let sortVideoList = videoList.sort(function(a,b) { return b.orderIdx - a.orderIdx }); let findVideoItem = sortVideoList[0]; filetrXDownKey.push(findVideoItem.groupKey); filterXDownMap[findVideoItem.groupKey.toString()] = { 'videoItem': findVideoItem }; } } app.xdownVideo.splice(0); app.xdownAudio.splice(0); let videoIdx = 0 let audioListSize = audioArrayList.length; let audioSortArrayList = audioArrayList.sort(function(a,b) { return b.orderIdx - a.orderIdx}); let sortedKeyList = filetrXDownKey.sort(function(a,b) { return b - a}); for(let tmpIdx in sortedKeyList) { let tmpKey = sortedKeyList[tmpIdx]; let tmpVal = filterXDownMap[tmpKey]; if (audioSortArrayList && audioSortArrayList.length > 0) { if (tmpKey >= 720 ) { tmpVal['audioItem'] = audioSortArrayList[0]; } else { videoIdx = videoIdx + 1; if(audioListSize > videoIdx) { tmpVal['audioItem'] = audioSortArrayList[videoIdx]; } else { tmpVal['audioItem'] = audioSortArrayList[audioListSize-1]; } } tmpVal.fileLengthVal = parseFloat(tmpVal['videoItem'].contentLength || 0) + parseFloat(tmpVal['audioItem'].contentLength || 0 ); app.xdownVideo.push(tmpVal); } } //console.log('---app.xdownVideo--',app.xdownVideo); let audioCodecsMap = {}; for(let tmpIdx in audioArrayList) { let tmpKey = audioArrayList[tmpIdx].codecs; if(tmpKey) { if(!audioCodecsMap[tmpKey]) { audioCodecsMap[tmpKey] = '1'; app.xdownAudio.push(audioArrayList[tmpIdx]); } } } //console.log('---app.xdownAudio--',app.xdownAudio); } // lazy load thumbnail to save quota, so it will only load thumbnail when expanding // app.thumbnail = await getHighresThumbnail(videoId) app.thumbnail = null const actLang = getLangCode() if (actLang !== null) { const lang = findLang(actLang) logger.log('youtube ui lang: %s', actLang) logger.log('ytdl lang:', lang) app.lang = lang } } catch (err) { logger.error('load', err) } } let prev = null setInterval(() => { const el = $('#info-contents') || $('#watch-header') || $('.page-container:not([hidden]) ytm-item-section-renderer>lazy-list') if (el && !el.contains(shadowHost)) { el.appendChild(shadowHost) } if (location.href !== prev) { logger.log(`page change: ${prev} -> ${location.href}`) prev = location.href if (location.pathname === '/watch') { shadowHost.style.display = 'block' const videoId = parseQuery(location.search).v logger.log('start loading new video: %s', videoId) app.hide = true; // fold it loadParse(videoId) } else { shadowHost.style.display = 'none' } } }, 1000) // listen to dark mode toggle const $html = $('html') new MutationObserver(() => { app.dark = $html.getAttribute('dark') === 'true' }).observe($html, { attributes: true }) app.dark = $html.getAttribute('dark') === 'true' const css = ` .hide{ display: none; } .t-center{ text-align: center; } .d-flex{ display: flex; } .f-1{ flex: 1; } .fs-14px{ font-size: 14px; } .of-h{ overflow: hidden; } .xdown-box{ border-bottom: 1px solid var(--yt-border-color); font-family: Arial; border: solid 1px #3153b3; } .xdown-file-name { margin-top: 5px; margin-bottom: 5px; } .video-item-div { margin-left: 4px; margin-bottom: 4px; vertical-align: top; border: solid 1px #065ed6; float: left; width: 48%; height: 100%; } .audio-item-div { margin-right: 4px; margin-bottom: 4px; vertical-align: top; border: solid 1px #065ed6; float: right; width: 48%; height: 100%; } .box-toggle{ margin: 3px; user-select: none; -moz-user-select: -moz-none; } .box-toggle:hover{ color: blue; } .ytdl-link-btn{ display: block; border: 1px solid !important; border-radius: 3px; text-decoration: none !important; outline: 0; text-align: center; padding: 2px; margin: 5px; color: black; } a.ytdl-link-btn{ text-decoration: none; } a.ytdl-link-btn:hover{ color: blue; } .box.dark{ color: var(--ytd-video-primary-info-renderer-title-color, var(--yt-primary-text-color)); } .box.dark .ytdl-link-btn{ color: var(--ytd-video-primary-info-renderer-title-color, var(--yt-primary-text-color)); } .box.dark .ytdl-link-btn:hover{ color: rgba(200, 200, 255, 0.8); } .box.dark .box-toggle:hover{ color: rgba(200, 200, 255, 0.8); } .file-ext-div { display: inline-block; margin: 4px; } ` shadow.appendChild($el('style', { textContent: css })) })()