// ==UserScript== // @name YouTube RatingBars (Like/Dislike Rating) // @name:ja YouTube RatingBars (Like/Dislike Rating) // @name:zh-CN YouTube RatingBars (Like/Dislike Rating) // @namespace knoa.jp // @description It shows RatingBars which represents Like/Dislike Rating ratio. // @description:ja 動画へのリンクに「高く評価」された比率を示すバーを表示します。 // @description:zh-CN 在与动画的链接中显示表示被“高评价”的比率的栏。 // @include https://www.youtube.com/* // @include https://console.cloud.google.com/* // @version 4.0.3 // @grant none // @noframes // @downloadURL none // ==/UserScript== (function(){ const SCRIPTID = 'YouTubeRatingBars'; const SCRIPTNAME = 'YouTube RatingBars'; const DEBUG = false;/* [update] 4.0.3 Now it can properly launch in background tab. [to do] [to research] スクロールとリサイズだけトリガにして毎秒の処理を軽減する手もあるか 全部にバーを付与した上で中身の幅だけを更新する手も URL変わるたびに中身を一度0幅にすれば更新時のアニメーションも不自然ではないか [memo] 要素はとことん再利用されるので注意。 API Document: https://developers.google.com/youtube/v3/docs/videos/list API Quotas: https://console.developers.google.com/apis/api/youtube.googleapis.com/quotas?project=test-173300 先例があった https://github.com/elliotwaite/thumbnail-rating-bar-for-youtube/issues/17 https://github.com/elliotwaite/thumbnail-rating-bar-for-youtube 各自にAPIキーを取得してもらっているようだ。他の拡張は全滅の様相。 icon: https://www.onlinewebfonts.com/icon/11481 */ if(window === top && console.time) console.time(SCRIPTID); const SECOND = 1000, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY; const INTERVAL = 1*SECOND;/*for core.observeItems*/ const HEIGHT = 2;/*bar height(px)*/ const THINHEIGHT = 1;/*bar height(px) for videos with few ratings*/ const RELIABLECOUNT = 10;/*ratings less than this number has less reliability*/ const STABLECOUNT = 100;/*ratings more than this number has stable reliability*/ const CACHELIMIT = 30*DAY;/*cache limit for stable videos*/ const LIKECOLOR = 'rgb(6, 95, 212)'; const DISLIKECOLOR = 'rgb(204, 204, 204)'; const FLAG = SCRIPTID.toLowerCase();/*dataset name to add for videos to append a RatingBar*/ const MAXRESULTS = 48;/* API limits 50 videos per request */ const API = `https://www.googleapis.com/youtube/v3/videos?id={ids}&part=statistics&fields=items(id,statistics)&maxResults=${MAXRESULTS}&key={apiKey}`; const VIDEOID = /\?v=([^&]+)/;/*video id in URL parameters*/ const RETRY = 10; const sites = { youtube: { url: 'https://www.youtube.com/', targets: { avatarBtn: () => $('#avatar-btn') || $('ytd-button-renderer a[href^="https://accounts.google.com/"]'), }, views: { home: { url: /^https:\/\/www\.youtube\.com\/([?#].+)?$/, videos: () => [...$$('ytd-rich-grid-video-renderer'), ...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')], anchor: (item) => item.querySelector('a'), insertAfter: (item) => item.querySelector('#metadata-line'), }, feed: { url: /^https:\/\/www\.youtube\.com\/feed\//, videos: () => [...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')], anchor: (item) => item.querySelector('a'), insertAfter: (item) => item.querySelector('#metadata-line'), }, results: { url: /^https:\/\/www\.youtube\.com\/results\?/, videos: () => $$('ytd-video-renderer'), anchor: (item) => item.querySelector('a'), insertAfter: (item) => item.querySelector('#metadata-line'), }, watch: { url: /^https:\/\/www\.youtube\.com\/watch\?/, videos: () => $$('ytd-compact-video-renderer'), anchor: (item) => item.querySelector('a'), insertAfter: (item) => item.querySelector('#metadata-line'), }, channel: { url: /^https:\/\/www\.youtube\.com\/channel\//, videos: () => [...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')], anchor: (item) => item.querySelector('a'), insertAfter: (item) => item.querySelector('#metadata-line'), }, default: { default: /^https:\/\/www\.youtube\.com\//, videos: () => [...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')], anchor: (item) => item.querySelector('a'), insertAfter: (item) => item.querySelector('#metadata-line'), }, }, get: { api: (ids) => API.replace('{apiKey}', configs.apiKey).replace('{ids}', ids.join()), bar: (item) => item.querySelector('#container.ytd-sentiment-bar-renderer'), accountMenuItem: () => $('ytd-popup-container a[href="/account"]', (a) => a.parentNode), }, is: { popupped: () => ($('ytd-popup-container > iron-dropdown:not([aria-hidden="true"])') === null), }, }, google: { views: { projectcreate: {/* 1-1. Create a new project */ url: 'https://console.cloud.google.com/projectcreate', targets: { anchor: () => $('body'), projectName: () => $('proj-name-id-input input'), createButton: () => $('.projtest-create-form-submit'), }, styles: { 'width': '400px', 'top': '50%', 'left': '60%', 'transform': 'translate(-50%, -50%)', }, }, dashboard: {/* 1-2. Complete the creation */ url: 'https://console.cloud.google.com/home/dashboard', targets: { anchor: () => $('body'), }, styles: { 'width': '400px', 'top': '50%', 'left': '50%', 'transform': 'translate(-50%, -50%)', }, get: { createdProjects: () => $$('[icon="status-success"]', (icon) => icon.parentNode), }, }, library: {/* 2-1. Enable the API */ url: 'https://console.cloud.google.com/apis/library/youtube.googleapis.com', targets: { anchor: () => $('body'), }, styles: { 'width': '400px', 'top': '50%', 'left': '60%', 'transform': 'translate(-50%, -50%)', }, }, api: {/* 2-2. After the enabling */ url: 'https://console.cloud.google.com/apis/api/', redirect: 'https://console.cloud.google.com/apis/credentials', }, credentials: {/* 3. Create an API Key */ url: 'https://console.cloud.google.com/apis/credentials', targets: { anchor: () => $('body'), createButton: () => $('button#action-bar-create-button'), }, styles: { 'width': '400px', 'top': '50%', 'left': '50%', 'transform': 'translate(-50%, -50%)', }, get: {/* MANY WEAK SELECTORS CAUTION */ apiKeyMenuLabel: () => $('cfc-menu-item[label*="API"]'), apiKeyInput: () => $('span[cfc-code-snippet-key-selector][label*="API"] input'), restrictKeyButton: () => $('.mat-dialog-actions button[tabindex="0"]'),/* SO WEAK */ apiRestrictionRadioButtonLabel: () => $('services-key-api-restrictions mat-radio-button:nth-child(2) label'), apiRestrictionSelect: () => $('services-key-api-restrictions cfc-select'), youtubeDataApiOption: () => Array.from($$('mat-option')).find(o => o.textContent.includes('YouTube Data API v3')), saveButton: () => $('form cfc-progress-button button'), createdKey: () => $('ace-icon[icon="status-success"] + a[href^="/apis/credentials/key/"]'), }, }, quotas: {/* Check your quota */ url: 'https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas', }, error: { url: undefined, targets: { anchor: () => $('body'), }, styles: { 'width': '400px', 'top': '50%', 'left': '50%', 'transform': 'translate(-50%, -50%)', }, }, }, }, }; class Configs{ constructor(configs){ Configs.PROPERTIES = { apiKey: {type: 'string', default: ''}, }; this.data = this.read(configs || {}); return new Proxy(this, { get: function(configs, field){ if(field in configs) return configs[field]; } }); } read(configs){ let newConfigs = {}; Object.keys(Configs.PROPERTIES).forEach(key => { if(configs[key] === undefined) return newConfigs[key] = Configs.PROPERTIES[key].default; switch(Configs.PROPERTIES[key].type){ case('bool'): return newConfigs[key] = (configs[key]) ? 1 : 0; case('int'): return newConfigs[key] = parseInt(configs[key]); case('float'): return newConfigs[key] = parseFloat(configs[key]); default: return newConfigs[key] = configs[key]; } }); return newConfigs; } toJSON(){ let json = {}; Object.keys(this.data).forEach(key => { json[key] = this.data[key]; }); return json; } set apiKey(apiKey){this.data.apiKey = apiKey;} get apiKey(){return this.data.apiKey;} } let elements = {}, timers = {}, site, view, panels, configs; let cache = {};/* each of identical video elements has a reference to its video ID. */ /* {'ID': {commentCount: "123", dislikeCount: "12", favoriteCount: "0", likeCount: "1234", viewCount: "12345", timestamp: 1234567890}} */ let cached = 0;/*cache usage*/ let videoIdTable = {};/* each of identical video elements has a reference to its video ID. */ /* {'ID': [element, element, element]} */ let queue = [];/* each item of the queue has ids to get data from API at once */ const core = { initialize: function(){ elements.html = document.documentElement; elements.html.classList.add(SCRIPTID); switch(true){ case(/^https:\/\/www\.youtube\.com\//.test(location.href)): site = sites.youtube; core.readyForYouTube(); core.addStyle('style'); core.addStyle('panelStyle'); break; case(/^https:\/\/console\.cloud\.google\.com\//.test(location.href)): site = sites.google; core.readyForGoogle(); core.addStyle('guideStyle'); break; default: log('Doesn\'t match any sites:', location.href) break; } }, readyForYouTube: function(){ if(core.commingBack()) return; if(document.hidden) return setTimeout(core.readyForYouTube, 1000); core.getTargets(site.targets, RETRY).then(() => { log("I'm ready for YouTube."); core.configs.prepare(); if(configs.apiKey !== ''){ core.cacheReady(); core.observeItems(); core.export(); }else{ log('No API key.'); } }); }, commingBack: function(){ let commingBack = Storage.read('commingBack'); if(commingBack){ Storage.remove('commingBack'); location.assign(commingBack + location.hash); return true; } }, cacheReady: function(){ let now = Date.now(); cache = Storage.read('cache') || {}; Object.keys(cache).forEach(id => { switch(true){ case(cache[id].timestamp < now - CACHELIMIT): case(parseInt(cache[id].dislikeCount) + parseInt(cache[id].likeCount) < STABLECOUNT): return delete cache[id]; } }); window.addEventListener('unload', function(e){ Storage.save('cache', cache); }); }, observeItems: function(){ let previousUrl = ''; clearInterval(timers.observeItems); timers.observeItems = setInterval(function(){ if(document.hidden) return; /* select the view of the current page */ if(location.href !== previousUrl){ let key = Object.keys(site.views).find(key => site.views[key].url.test(location.href)); view = site.views[key]; previousUrl = location.href; } /* get the target videos of the current page */ if(view){ core.getVideos(view); } /* get ratings from the API */ if(queue[0] && queue[0].length){ core.getRatings(queue.shift()); } }, INTERVAL); }, getVideos: function(view){ let items = view.videos(); if(items.length === 0) return; /* pushes id to the queue */ const push = function(id){ for(let i = 0; true; i++){ if(queue[i] === undefined) queue[i] = []; if(queue[i].length < MAXRESULTS){ queue[i].push(id); break; } } }; /* push ids to the queue */ for(let i = 0, item; item = items[i]; i++){ let a = view.anchor(item); if(!a || !a.href){ log('Not found: anchor.'); continue; } let m = a.href.match(VIDEOID), id = m ? m[1] : null; if(id === null) continue; if(item.dataset[FLAG] === id) continue;/*sometimes DOM was re-used for a different video*/ item.dataset[FLAG] = id;/*flag for video found by the script*/ if(!videoIdTable[id]) videoIdTable[id] = [item]; else videoIdTable[id].push(item); if(cache[id]) core.appendBar(item, cache[id]), cached++; else push(id); } }, getRatings: function(ids){ fetch(site.get.api(ids)) .then(response => response.json()) .then(json => { log('JSON from API:', json); let items = json.items; if(!items || !items.length) return; for(let i = 0, now = Date.now(), item; item = items[i]; i++){ videoIdTable[item.id] = videoIdTable[item.id].filter(v => v.isConnected); videoIdTable[item.id].forEach(v => { core.appendBar(v, item.statistics); }); cache[item.id] = item.statistics; cache[item.id].timestamp = now; } }); }, appendBar: function(item, statistics){ let s = statistics, likes = parseInt(s.likeCount), dislikes = parseInt(s.dislikeCount); if(s.likeCount === undefined) return log('Not found: like count.', item); if(likes === 0 && dislikes === 0) return let height = (RELIABLECOUNT < likes + dislikes) ? HEIGHT : THINHEIGHT; let percentage = (likes / (likes + dislikes)) * 100; let bar = createElement(html.bar(height, percentage)); let insertAfter = view.insertAfter(item); if(insertAfter === null) return log('Not found: insertAfter.'); if(site.get.bar(item)){/*bar already exists*/ insertAfter.parentNode.replaceChild(bar, insertAfter.nextElementSibling); }else{ insertAfter.parentNode.insertBefore(bar, insertAfter.nextElementSibling); } }, export: function(){ if(DEBUG !== true) return; window.save = function(){ log( 'Cache length:', Object.keys(cache).length, 'videoElements:', Object.keys(videoIdTable).map(key => videoIdTable[key].length).reduce((x, y) => x + y), 'videoIds:', Object.keys(videoIdTable).length, 'usage:', cached, 'saved:', ((cached / Object.keys(videoIdTable).length)*100).toFixed(1) + '%', ); }; }, configs: { prepare: function(){ panels = new Panels(document.body.appendChild(createElement(html.panels()))); configs = new Configs(Storage.read('configs') || {}); if(location.hash.includes('#apiKey=')){ configs.apiKey = location.hash.match(/#apiKey=(.+)/)[1]; Storage.save('configs', configs.toJSON()); } core.configs.createPanel(); core.configs.observePopup(); if(configs.apiKey === '' || location.hash.includes('#apiKey=')) panels.show('configs'); }, observePopup: function(){ let button = elements.avatarBtn; button.addEventListener('click', function(e){ if(site.is.popupped() === false) return; let timer = setInterval(function(){ let account = site.get.accountMenuItem(); if(account){ clearInterval(timer); core.configs.appendConfigButton(account); } }, 125); }); }, appendConfigButton: function(account){ let config = elements.configButton = createElement(html.configButton()); config.addEventListener('click', function(e){ panels.show('configs'); }); account.parentNode.insertBefore(config, account.nextElementSibling); }, createPanel: function(){ let panel = createElement(html.configPanel()), items = {}; Array.from(panel.querySelectorAll('[name]')).forEach(e => items[e.name] = e); /* getKeyButton */ let getKeyButton = panel.querySelector(`#${SCRIPTID}-getKeyButton`); getKeyButton.addEventListener('click', function(e){ if(location.href === site.url) return; Storage.save('commingBack', location.href.replace(location.hash, ''), Date.now() + 1*HOUR); }); if(items.apiKey.value === '') getKeyButton.classList.add('active'); items.apiKey.addEventListener('input', function(e){ if(items.apiKey.value === '') getKeyButton.classList.add('active'); else getKeyButton.classList.remove('active'); }); /* cancel */ panel.querySelector('button.cancel').addEventListener('click', function(e){ panels.hide('configs'); core.configs.createPanel();/*clear*/ }); /* save */ panel.querySelector('button.save').addEventListener('click', function(e){ configs = new Configs({ apiKey: items.apiKey.value, }); Storage.save('configs', configs.toJSON()); panels.hide('configs'); core.observeItems(); }); panels.add('configs', panel); }, }, readyForGoogle: function(){ /* check the guidance session */ if(location.search.includes(SCRIPTID)) Storage.save('guiding', true, Date.now() + 1*HOUR); if(Storage.read('guiding') === undefined) return log('Guidance session time out.'); /* choose guidance */ let key = Object.keys(site.views).find(key => location.href.startsWith(site.views[key].url)) || 'error'; view = site.views[key]; /* should be redirected */ if(view.redirect) location.assign(view.redirect); /* can show guidance */ core.getTargets(view.targets, RETRY).then(() => { log("I'm ready for Google."); core.createGuidance(key); }).catch(() => { view = site.views.error; core.createGuidance('error'); }); }, createGuidance: function(key){ let anchor = elements.anchor, guidance = createElement(html[key](view)); Object.keys(view.styles).forEach(key => guidance.style[key] = view.styles[key]); core.prepareGuidances[key](guidance); draggable(guidance); guidance.classList.add('hidden'); anchor.appendChild(guidance); setTimeout(() => guidance.classList.remove('hidden'), 1000); }, prepareGuidances: { projectcreate: function(guidance){ /* default name */ let projectName = elements.projectName; let defaultName = guidance.querySelector('.name.default'); defaultName.textContent = projectName.value; /* auto selection for convenience */ Array.from(guidance.querySelectorAll('.name')).forEach(name => { name.addEventListener('click', function(e){ window.getSelection().selectAllChildren(name); }); }); /* create button */ let createButton = elements.createButton; createButton.addEventListener('click', function(e){ /* it doesn't refresh the page */ Storage.save('projectName', projectName.value); /* hide the guidance */ guidance.classList.add('hidden'); setTimeout(() => guidance.parentNode.removeChild(guidance), 1000); /* append body layer */ let layer = createElement(html.bodyLayer()); document.body.appendChild(layer); /* show new guidance for dashboard */ view = site.views.dashboard; core.createGuidance('dashboard'); }); /* leave the guidance */ let leave = guidance.querySelector(`a[href="${sites.google.views.projectcreate.url}"]`); leave.addEventListener('click', function(e){ guidance.parentNode.removeChild(guidance); Storage.remove('guiding'); }); }, dashboard: function(guidance){ let projectName = (Storage.read('projectName') || '').trim(); let seconds = guidance.querySelector('.secondsLeft'); let timer = setInterval(function(){ /* automatically redirect to next step in 60s */ /* even if project was not created in this page, it will be created on next step */ seconds.textContent = parseInt(seconds.textContent) - 1; if(seconds.textContent === '0') return location.assign(site.views.library.url); /* also automatically redirect when the project surely created */ let projects = view.get.createdProjects(); if(projects.length === 0) return; if(Array.from(projects).some(p => p.textContent.includes(projectName))){ return setTimeout(() => location.assign(site.views.library.url), 2500); } }, 1000); }, library: function(guidance){ /* there're completely different versions of html by unknown conditions, so... */ let timer = setInterval(function(){ if(location.href.startsWith(site.views.api.url) === false) return; location.assign(sites.google.views.credentials.url); }, 1000); }, credentials: function(guidance){ let createButton = elements.createButton, apiKey; /* redirect timer */ let seconds = guidance.querySelector('.secondsLeft'); let timer = setInterval(function(){ /* automatically redirect to YouTube in 60s */ seconds.textContent = parseInt(seconds.textContent) - 1; if(seconds.textContent === '0') return location.assign(sites.youtube.url + `#apiKey=${apiKey}`); }, 1000); /* append body layer */ let layer = createElement(html.bodyLayer()); document.body.appendChild(layer); /* procedure */ wait(2500).then(() => { createButton.click(); return getElement(view.get.apiKeyMenuLabel, RETRY); }).then((apiKeyMenuLabel) => { apiKeyMenuLabel.click(); return getElement(view.get.apiKeyInput, RETRY); }).then(apiKeyInput => { apiKey = apiKeyInput.value; return getElement(view.get.restrictKeyButton, RETRY); }).then(restrictKeyButton => { restrictKeyButton.click(); return getElement(view.get.apiRestrictionRadioButtonLabel, RETRY); }).then(apiRestrictionRadioButtonLabel => { apiRestrictionRadioButtonLabel.click(); return getElement(view.get.apiRestrictionSelect, RETRY); }).then(apiRestrictionSelect => { apiRestrictionSelect.click(); return getElement(view.get.youtubeDataApiOption, RETRY); }).then(youtubeDataApiOption => { if(youtubeDataApiOption.classList.contains('mat-selected') === false) youtubeDataApiOption.click(); return getElement(view.get.saveButton, RETRY); }).then(saveButton => { saveButton.click(); return getElement(view.get.createdKey, RETRY); }).then(createdKey => { Storage.remove('guiding'); log('Automation completed:'); }).catch((selector) => { log('Automation error:', selector); document.body.removeChild(layer); clearInterval(timer); }); }, error: function(guidance){ let restart = guidance.querySelector(`a[href="${sites.google.views.projectcreate.url}?${SCRIPTID}=true"]`); restart.addEventListener('click', function(e){ guidance.parentNode.removeChild(guidance); }); let search = guidance.querySelector(`#${SCRIPTID}-google-how-to`); search.addEventListener('click', function(e){ Storage.remove('guiding'); }); }, }, getTargets: function(targets, retry = 0){ const get = function(resolve, reject, retry){ for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){ let selected = targets[key](); if(selected){ if(selected.length) selected.forEach((s) => s.dataset.selector = key); else selected.dataset.selector = key; elements[key] = selected; }else{ if(--retry < 0) return reject(log(`Not found: ${key} ${targets[key]}, I give up.`)); log(`Not found: ${key}, retrying... (left ${retry})`); return setTimeout(get, 1000, resolve, reject, retry); } } resolve(); }; return new Promise(function(resolve, reject){ get(resolve, reject, retry); }); }, addStyle: function(name = 'style'){ let style = createElement(html[name]()); document.head.appendChild(style); if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]); elements[name] = style; }, }; const texts = { /* common */ '${SCRIPTNAME}': { en: () => `${SCRIPTNAME}`, ja: () => `${SCRIPTNAME}`, zh: () => `${SCRIPTNAME}`, }, /* setup */ '${SCRIPTNAME} setup': { en: () => `${SCRIPTNAME} setup`, ja: () => `${SCRIPTNAME} 設定`, zh: () => `${SCRIPTNAME} 设定`, }, 'YouTube Data API key': { en: () => `YouTube Data API key`, ja: () => `YouTube Data API キー`, zh: () => `YouTube Data API 密钥`, }, 'To make it work properly, you should have a YouTube Data API key. Or you can get it now from Google Cloud Platform for FREE. (I shall guide you!!)': { en: () => `To make it work properly, you should have a YouTube Data API key. Or you can get it now from Google Cloud Platform for FREE. (I shall guide you!!)`, ja: () => `このスクリプトの動作には YouTube Data API キー が必要です。お持ちでなければ無料でいま取得することもできます。(ご案内します!)`, zh: () => `要使其正常工作,您应该有一个 YouTube Data API 密钥。或者你现在可以从 Google Cloud Platform 免费得到它。(我来给你带路!)`, }, 'Create your API key on Google': { en: () => `Create your API key on Google`, ja: () => `Google で API キー を作成する`, zh: () => `在 Google 上创建您的 API 密钥`, }, 'Check your API key already you have': { en: () => `Check your API key already you have`, ja: () => `すでにお持ちの API キー を確認する`, zh: () => `查看您已经拥有的 API 密钥`, }, 'Check your API quota and usage': { en: () => `Check your API quota and usage`, ja: () => `API 割り当て量と使用量を確認する`, zh: () => `检查您的 API 配额和使用情况`, }, 'Cancel': { en: () => `Cancel`, ja: () => `キャンセル`, zh: () => `取消`, }, 'Save': { en: () => `Save`, ja: () => `保存`, zh: () => `保存`, }, /* guidance */ '${SCRIPTNAME} guidance': { en: () => `${SCRIPTNAME} guidance`, ja: () => `${SCRIPTNAME} ガイド`, zh: () => `${SCRIPTNAME} 向导`, }, /* projectcreate */ 'Create a new project': { en: () => `Create a new project`, ja: () => `新しいプロジェクトの作成`, zh: () => `创建新项目`, }, 'Project name: You can input any name such as "${SCRIPTNAME}" or "Private" or just leave it as "default".': { en: () => `Project name: You can input any name such as "${SCRIPTNAME}" or "Private" or just leave it as "default".`, ja: () => `プロジェクト名: 自由な名前をご入力ください。"${SCRIPTNAME}" や "Private" などでも、"デフォルト" のままでもかまいません。`, zh: () => `项目名称: 可以输入 "${SCRIPTNAME}"、"Private" 等任意名称、也可以保留为 "默认"。`, }, 'Location: Leave it as "No organization".': { en: () => `Location: Leave it as "No organization".`, ja: () => `場所: "組織なし" のままで大丈夫です。`, zh: () => `位置: 保留为 "无组织"。`, }, 'Click the CREATE button.': { en: () => `Click the CREATE button.`, ja: () => `作成 ボタンをクリックします。`, zh: () => `单击 创建 按钮。`, }, 'If you already have a project to use, skip this step.': { en: () => `If you already have a project to use, skip this step.`, ja: () => `すでに利用するプロジェクトを作成済みの場合は、このステップを飛ばしてください。`, zh: () => `如果您已经有项目要使用,跳过此步骤。`, }, 'Or you can leave this guidance.': { en: () => `Or you can leave this guidance.`, ja: () => `またはこのガイダンスを終了することもできます。`, zh: () => `或者你可以离开这份向导。`, }, /* dashboard */ 'Wait until the project has been created.': { en: () => `Wait until the project has been created.`, ja: () => `プロジェクトの作成が完了するまでお待ちください。`, zh: () => `等待项目创建完成。`, }, 'Then you can go to the next step. (You will automatically be redirected within 60 seconds at the most)': { en: () => `Then you can go to the next step. (You will automatically be redirected within 60 seconds at the most)`, ja: () => `続いて次のステップにお進みください。 (60秒以内に自動的に移動します)`, zh: () => `那么您就可以进行下一步了。 (您最多会在60秒内自动重定向)`, }, 'Enable the YouTube Data API': { en: () => `Enable the YouTube Data API`, ja: () => `YouTube Data API を有効にする`, zh: () => `启用 YouTube Data API`, }, /* library */ 'Enable the API': { en: () => `Enable the API`, ja: () => `API を有効にします`, zh: () => `启用 API`, }, 'Just click the ENABLE button.': { en: () => `Just click the ENABLE button.`, ja: () => `有効にする ボタンをクリックしてください。`, zh: () => `只需单击 启用 按钮。`, }, 'If a dialog to select a project is shown, select the project you just created.': { en: () => `If a dialog to select a project is shown, select the project you just created.`, ja: () => `もしプロジェクトを選択するダイアログが表示されたら、先ほど作成したプロジェクトを選択します。`, zh: () => `如果显示选择项目的对话框,请选择您刚刚创建的项目。`, }, 'Then wait a moment.': { en: () => `Then wait a moment.`, ja: () => `しばらくお待ちください。`, zh: () => `那么请稍等片刻。`, }, 'If the API is already enabled, you can go to the next step.': { en: () => `If the API is already enabled, you can go to the next step.`, ja: () => `すでに API が有効になっている場合は、次のステップにお進みください。`, zh: () => `如果 API 已经启用,您可以进入下一步。`, }, 'Create an API key': { en: () => `Create an API key`, ja: () => `API キー を作成する`, zh: () => `创建 API 密钥`, }, /* credentials */ 'Now automatically creating API key... (You will be redirected back to YouTube in 60 seconds)': { en: () => `Now automatically creating API key... (You will be redirected back to YouTube in 60 seconds)`, ja: () => `API キー を作成しています... (60秒後に自動的に YouTube に戻ります)`, zh: () => `正在自动创建 API 密钥... (您将在60秒内被重定向回 YouTube)`, }, 'If it fails and stucked, you can check and do the following steps by yourself.': { en: () => `If it fails and stucked, you can check and do the following steps by yourself.`, ja: () => `失敗して処理が止まった場合は、次の手続きをご自身で確認してください。`, zh: () => `如果失败并停止,您可以自行检查并执行以下步骤。`, }, 'Click the + CREATE CREDENTIALS button.': { en: () => `Click the + CREATE CREDENTIALS button.`, ja: () => `+ 認証情報を作成 ボタンをクリックします。`, zh: () => `单击 + 创建凭据 按钮。`, }, 'Click API key on the dropdown menu.': { en: () => `Click API key on the dropdown menu.`, ja: () => `表示されたメニュー内の API キー をクリックします。`, zh: () => `单击下拉菜单上的 API 密钥`, }, 'API key will be created.': { en: () => `API key will be created.`, ja: () => `API キーが作成されます。`, zh: () => `将创建 API 密钥。`, }, 'Click the RESTRICT KEY button.': { en: () => `Click the RESTRICT KEY button.`, ja: () => `キーを制限 ボタンをクリックします。`, zh: () => `单击 限制键 按钮。`, }, 'Click the Restrict key radio button on the API restrictions section.': { en: () => `Click the Restrict key radio button on the API restrictions section.`, ja: () => `API の制限 セクション内の キーを制限 ラジオボタンをクリックします。`, zh: () => `单击 API 限制 部分上的 限制密钥 单选按钮。`, }, 'Click the Select APIs dropdown menu and check YouTube Data API v3 at (probably) the bottom of the menu.': { en: () => `Click the Select APIs dropdown menu and check YouTube Data API v3 at (probably) the bottom of the menu.`, ja: () => `Select APIs ドロップダウンメニューをクリックし、(おそらく)一番下に表示される YouTube Data API v3 にチェックを入れます。`, zh: () => `单击 Select APIs 下拉菜单,然后选中菜单底部(可能)的 YouTube Data API v3。`, }, 'Click the SAVE button.': { en: () => `Click the SAVE button.`, ja: () => `保存 ボタンをクリックします。`, zh: () => `单击 保存 按钮。`, }, 'Copy the created API key with the copy icon button on the right.': { en: () => `Copy the created API key with the copy icon button on the right.`, ja: () => `作成された API キー を、すぐ右隣のコピーアイコンボタンをクリックしてコピーします。`, zh: () => `使用右侧的复制图标按钮复制创建的 API 密钥。`, }, 'Go to YouTube, then paste and save the key on ${SCRIPTNAME} setup panel.': { en: () => `Go to YouTube, then paste and save the key on ${SCRIPTNAME} setup panel.`, ja: () => `YouTube へ移動して、${SCRIPTNAME} 設定 パネル内にキーを貼り付け保存します。`, zh: () => `转到 YouTube,然后在 ${SCRIPTNAME} 设置 面板上粘贴并保存密钥。`, }, /* error */ 'Sorry, no guidance was found for this page.': { en: () => `Sorry, no guidance was found for this page.`, ja: () => `申し訳ありません。このページ向けのガイダンスが見つかりませんでした。`, zh: () => `抱歉,找不到此页的指导。`, }, 'Start over from the first step': { en: () => `Start over from the first step`, ja: () => `最初からやり直す`, zh: () => `从第一步开始`, }, 'You can also get an API key by yourself and enter it on YouTube.': { en: () => `You can also get an API key by yourself and enter it on YouTube.`, ja: () => `独自に API キー を取得してYouTubeで入力することもできます。`, zh: () => `您也可以自己获取 API 密钥,然后在 YouTube 上输入。`, }, 'https://www.google.com/search?q=How+to+get+YouTube+Data+API+key': { en: () => `https://www.google.com/search?q=How+to+get+YouTube+Data+API+key`, ja: () => `https://www.google.com/search?q=YouTube+Data+API+キー+取得`, zh: () => `https://www.google.com/search?q=YouTube+Data+API+密钥+获取`, }, 'Serach how to get an API key': { en: () => `Serach how to get an API key`, ja: () => `API キー の取得の仕方を検索する`, zh: () => `研究如何获取 API 密钥。`, }, 'Your reporting of this error is very welcomed.': { en: () => `Your reporting of this error is very welcomed.`, ja: () => `エラーの報告を歓迎します。`, zh: () => `欢迎报告错误。`, }, }; const html = { bar: (height, percentage) => `
`, configButton: () => `
Svg Vector Icons : http://www.onlinewebfonts.com/icon ${text('${SCRIPTNAME}')}
`, panels: () => `
`, configPanel: () => `

${text('${SCRIPTNAME} setup')}

${text('YouTube Data API key')}:

${text('To make it work properly, you should have a YouTube Data API key. Or you can get it now from Google Cloud Platform for FREE. (I shall guide you!!)')}

${text('Create your API key on Google')}

${text('Check your API key already you have')}

${text('Check your API quota and usage')}

`, projectcreate: () => `

${text('${SCRIPTNAME} guidance')}

${text('Create a new project')}

  1. ${text('Project name: You can input any name such as "${SCRIPTNAME}" or "Private" or just leave it as "default".')}
  2. ${text('Location: Leave it as "No organization".')}
  3. ${text('Click the CREATE button.')}

${text('If you already have a project to use, skip this step.')}

${text('Or you can leave this guidance.')}

`, bodyLayer: () => `
`, dashboard: () => `

${text('${SCRIPTNAME} guidance')}

  1. ${text('Wait until the project has been created.')}
  2. ${text('Then you can go to the next step. (You will automatically be redirected within 60 seconds at the most)')} ${text('Enable the YouTube Data API')}
`, library: () => `

${text('${SCRIPTNAME} guidance')}

${text('Enable the API')}

  1. ${text('Just click the ENABLE button.')}
  2. ${text('If a dialog to select a project is shown, select the project you just created.')}
  3. ${text('Then wait a moment.')}

${text('If the API is already enabled, you can go to the next step.')} ${text('Create an API key')}

`, credentials: () => `

${text('${SCRIPTNAME} guidance')}

${text('Now automatically creating API key... (You will be redirected back to YouTube in 60 seconds)')}

${text('If it fails and stucked, you can check and do the following steps by yourself.')}

  1. ${text('Click the + CREATE CREDENTIALS button.')}
  2. ${text('Click API key on the dropdown menu.')}
  3. ${text('API key will be created.')}
  4. ${text('Click the RESTRICT KEY button.')}
  5. ${text('Click the Restrict key radio button on the API restrictions section.')}
  6. ${text('Click the Select APIs dropdown menu and check YouTube Data API v3 at (probably) the bottom of the menu.')}
  7. ${text('Click the SAVE button.')}
  8. ${text('Copy the created API key with the copy icon button on the right.')}
  9. ${text('Go to YouTube, then paste and save the key on ${SCRIPTNAME} setup panel.')}
`, error: () => `

${text('${SCRIPTNAME} guidance')}

${text('Sorry, no guidance was found for this page.')}

${text('Start over from the first step')}

${text('You can also get an API key by yourself and enter it on YouTube.')}

${text('Serach how to get an API key')}

${text('Your reporting of this error is very welcomed.')}

`, style: () => ` `, panelStyle: () => ` `, guideStyle: () => ` `, }; const setTimeout = window.setTimeout.bind(window), clearTimeout = window.clearTimeout.bind(window), setInterval = window.setInterval.bind(window), clearInterval = window.clearInterval.bind(window), requestAnimationFrame = window.requestAnimationFrame.bind(window); const alert = window.alert.bind(window), confirm = window.confirm.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window); if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}}); class Storage{ static key(key){ return (SCRIPTID) ? (SCRIPTID + '-' + key) : key; } static save(key, value, expire = null){ key = Storage.key(key); localStorage[key] = JSON.stringify({ value: value, saved: Date.now(), expire: expire, }); } static read(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.value === undefined) return data; if(data.expire === undefined) return data; if(data.expire === null) return data.value; if(data.expire < Date.now()) return localStorage.removeItem(key);/*undefined*/ return data.value; } static remove(key){ key = Storage.key(key); delete localStorage.removeItem(key); } static delete(key){ Storage.remove(key); } static saved(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.saved) return data.saved; else return undefined; } } class Panels{ constructor(parent){ this.parent = parent; this.panels = {}; this.listen(); } listen(){ window.addEventListener('keydown', (e) => { if(e.key !== 'Escape') return; if(['input', 'textarea'].includes(document.activeElement.localName)) return; Object.keys(this.panels).forEach(key => this.hide(key)); }, true); } add(name, panel){ this.panels[name] = panel; } toggle(name){ let panel = this.panels[name]; if(panel.isConnected === false || panel.classList.contains('hidden')) this.show(name); else this.hide(name); } show(name){ let panel = this.panels[name]; if(panel.isConnected) return; panel.classList.add('hidden'); this.parent.appendChild(panel); this.parent.dataset.panels = parseInt(this.parent.dataset.panels) + 1; animate(() => panel.classList.remove('hidden')); } hide(name){ let panel = this.panels[name]; if(panel.classList.contains('hidden')) return; panel.classList.add('hidden'); panel.addEventListener('transitionend', (e) => { this.parent.removeChild(panel); this.parent.dataset.panels = parseInt(this.parent.dataset.panels) - 1; }, {once: true}); } } const text = function(key, ...args){ if(text.texts[key] === undefined){ log('Not found text key:', key); return key; }else return text.texts[key](args); }; text.defaultlanguage = 'en'; text.setup = function(texts, languages){ languages = languages.map(l => l.toLowerCase()); if(languages.includes(text.defaultlanguage) === false) languages.push(text.defaultlanguage); Object.keys(texts).forEach(key => { Object.keys(texts[key]).forEach(l => texts[key][l.toLowerCase()] = texts[key][l]); texts[key] = texts[key][languages.find(l => texts[key][l] !== undefined)] || (() => key); }); text.texts = texts; }; text.setup(texts, window.navigator.languages); const $ = function(s, f){ let target = document.querySelector(s); if(target === null) return null; return f ? f(target) : target; }; const $$ = function(s, f){ let targets = document.querySelectorAll(s); return f ? Array.from(targets).map(t => f(t)) : targets; }; const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))}; const createElement = function(html = ''){ let outer = document.createElement('div'); outer.innerHTML = html; return outer.firstElementChild; }; const getElement = function(querySelector, retry = 10){ const get = function(resolve, reject, retry){ let element = querySelector(); if(element) resolve(element); else if(retry--) setTimeout(get, 1000, resolve, reject, retry); else reject(querySelector); }; return new Promise(function(resolve, reject){ get(resolve, reject, retry); }) }; const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); return observer; }; const draggable = function(element){ const DELAY = 125;/* catching up mouse position while fast dragging (ms) */ const mousedown = function(e){ if(e.button !== 0) return; element.classList.add('dragging'); [screenX, screenY] = [e.screenX, e.screenY]; [a,b,c,d,tx,ty] = (getComputedStyle(element).transform.match(/[-0-9.]+/g) || [1,0,0,1,0,0]).map((n) => parseFloat(n)); window.addEventListener('mousemove', mousemove); window.addEventListener('mouseup', mouseup, {once: true}); document.body.addEventListener('mouseleave', mouseup, {once: true}); element.addEventListener('mouseleave', mouseleave, {once: true}); }; const mousemove = function(e){ element.style.transform = `matrix(${a},${b},${c},${d},${tx + (e.screenX - screenX)},${ty + (e.screenY - screenY)})`; }; const mouseup = function(e){ element.classList.remove('dragging'); window.removeEventListener('mousemove', mousemove); }; const mouseleave = function(e){ let timer = setTimeout(mouseup, DELAY); element.addEventListener('mouseenter', clearTimeout.bind(window, timer), {once: true}); }; let screenX, screenY, a, b, c, d, tx, ty; element.classList.add('draggable'); element.addEventListener('mousedown', mousedown); }; const log = function(){ if(!DEBUG) return; let l = log.last = log.now || new Date(), n = log.now = new Date(); let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error); //console.log(error.stack); console.log( SCRIPTID + ':', /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3), /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's', /* :00 */ ':' + line, /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') + /* caller */ (callers[1] || '') + '()', ...arguments ); }; log.formats = [{ name: 'Firefox Scratchpad', detector: /MARKER@Scratchpad/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Console', detector: /MARKER@debugger/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 3', detector: /\/gm_scripts\//, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 4+', detector: /MARKER@user-script:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Tampermonkey', detector: /MARKER@moz-extension:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Chrome Console', detector: /at MARKER \(/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \()/gm), }, { name: 'Chrome Tampermonkey', detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 3, getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm), }, { name: 'Edge Console', detector: /at MARKER \(eval/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm), }, { name: 'Edge Tampermonkey', detector: /at MARKER \(Function/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4, getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm), }, { name: 'Safari', detector: /^MARKER$/m, getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/ getCallers: (e) => e.stack.split('\n'), }, { name: 'Default', detector: /./, getLine: (e) => 0, getCallers: (e) => [], }]; log.format = log.formats.find(function MARKER(f){ if(!f.detector.test(new Error().stack)) return false; //console.log('//// ' + f.name + '\n' + new Error().stack); return true; }); core.initialize(); if(window === top && console.timeEnd) console.timeEnd(SCRIPTID); })();