您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动翻译bilibili直播的用户评语(弹幕)。
当前为
// ==UserScript== // @name Bilibili Live Comment Translator // @name:ja Bilibili Live Comment Translator // @name:zh-CN Bilibili Live Comment Translator // @namespace knoa.jp // @description Add translation on streaming user comments(弾幕) on bilibili live(直播). // @description:ja ビリビリ生放送(直播)のユーザーコメント(弾幕)を自動翻訳します。 // @description:zh-CN 自动翻译bilibili直播的用户评语(弹幕)。 // @include /^https://live\.bilibili\.com/[0-9]+/ // @version 1.2.0 // @require https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako_inflate.min.js // @grant none // ==/UserScript== (function(){ const SCRIPTNAME = 'BilibiliLiveCommentTranslator'; const DEBUG = false;/* [update] 1.2.0 追加翻訳の制限量を、1250msごとに最大8弾幕に調整。 [bug] たまに弾幕top位置がマイナスになる? [to do] ページ遷移次第で不要要素まで翻訳しすぎる target_blank してなければセーフ? target_blank 無効時の対策でURL変化を検出して再度処理する? [to research] 主要UI要素を指定翻訳語として登録しておきたい 動的に生成される要素の対応がめんどくさい 頻出コメントほかにもたくさん登録しとく? 頻出だけなら人力や腾讯を使う手も。 統計用には英語弾幕とかも含めたいけど。 自分のコメントの翻訳時も逆辞書で節約と蓄積? 日本語と英語は翻訳しない方針で問題ないよね? Google翻訳の一般Webユーザーのフリをして各ユーザーにAPIを叩かせる手もあるようだが https://github.com/andy-portmen/Simple-Translate/blob/master/src/lib/common.js それが許されるならBaiduのAPIを叩かせることも可能? 翻訳文をただ置き換えてしまう設定項目は趣旨に反する? 翻訳辞書を共有サーバーに溜め込む仕組み? ブラウザの言語設定の変更に対応すべき? iframe内で映像配信する放送に対応できていない。 https://live.bilibili.com/76?visit_id=6kwlti59xlg0 [memo] 1. 翻訳辞書構築の流れ 1-1. core.listenWebSocketsで弾幕テキストを取得(要素出現より1秒ほど早く取得できる) 1-2. Translatorに弾幕テキストを登録 1-3. TranslatorがpriorDanmaku要素に弾幕テキスト要素を設置 1-4. Chromeが弾幕テキスト要素を自動翻訳してくれる 1-5. Translatorが察知して辞書として登録 2. 弾幕訳文追加の流れ 2-1. core.observeVideoDanmakuで弾幕要素を発見 2-2. Danmakuインスタンスを作成してTranslatorに登録 2-3. 弾幕テキストに一致する辞書がすでにあればすぐに訳文を追加 2-4. なければ1-5.のタイミングで訳文を追加 3. 自分の投稿コメント翻訳 Google Apps Script (推定1日7000回(=1回5文字で月100万文字相当)を超えたあたりで制限がかかる) https://qiita.com/tanabee/items/c79c5c28ba0537112922 */ if(window === top && console.time) console.time(SCRIPTNAME); const NOW = Date.now(); const ISMAC = (window.navigator.userAgent.match(/Mac/) !== null); const CHATSERVER = 'chat.bilibili.com'; const TRANSLATOR = 'https://script.google.com/macros/s/AKfycby29iFLZ742UEC6TlN8-b4Dxtlu_7XYbVeo2GgiYVWMtuzIcbA/exec?text={text}&source={source}&target={target}'; const TRANSLATIONSATONCE = 8;/*同時最大翻訳リクエスト数(Chrome翻訳負荷の低減)*/ const TRANSLATIONSINTERVAL = 1250;/*最短翻訳リクエスト間隔(ms)(Chrome翻訳負荷の低減)*/ const HISTORYLENGTH = 100000;/*辞書の最大保持数(10万で5MB見込み)*/ const TRANSLATIONEXPIRED = 90*24*60*60*1000;/*翻訳の有効期限(翻訳精度の改善に期待する)*/ const BILIBILILANGUAGE = 'zh-CN'; const USERLANGUAGE = window.navigator.language; const TRANSLATIONS = { ja: { inputTranslationKey: ISMAC ? '(Command+Enterで翻訳)' : '(Ctrl+Enterで翻訳)', }, en: { inputTranslationKey: ISMAC ? '(Command+Enter to translate)' : '(Ctrl+Enter to translate)', }, }; const DICTIONARIES = { ja: {/* original: [translation, count, created] */ '哔哩哔哩 (゜-゜)つロ 干杯~': ['ビリビリ (゜-゜)つロ 乾杯~', 0, NOW], }, en: { '哔哩哔哩 (゜-゜)つロ 干杯~': ['bilibili (゜-゜)つロ cheers~', 0, NOW], }, }; const REGEXP = { hasKana: /[ぁ-んァ-ン]/, allAlphabet: /^[a-zA-Z0-9,.'"!?\s]+$/, allEmoji: /^(\ud83c[\udf00-\udfff]|\ud83d[\udc00-\ude4f]|\ud83d[\ude80-\udeff]|\ud7c9[\ude00-\udeff]|[\u2600-\u27BF])+$/, }; const RETRY = 10; let site = { targets: { operableContainer: () => $('.bilibili-live-player-video-operable-container'),/*特殊弾幕枠*/ videoDanmaku: () => $('.bilibili-live-player-video-danmaku'), chatHistoryList: () => $('#chat-history-list'), chatInput: () => $('.chat-input'), }, translationTargets: [ [false, () => $('title')], [false, () => $('body')], [ true, () => $('.bilibili-live-player-video-controller')],/*プレイヤ内コントローラ*/ [ false, () => $('.bilibili-live-player-video-controller-duration-btn > div > span')], [ true, () => $('#chat-control-panel-vm')],/*投稿欄内コントローラ*/ [ false, () => $('#chat-control-panel-vm .bottom-actions')], ], get: { operableSpace: (operableContainer) => operableContainer.querySelector('#pk-vm ~ div[style*="height:"]'), } }; let html, elements = {}, storages = {}, timers = {}, sizes = {}; let translator, translations = {}; class Packet{ /* Bilibili Live WebSocket message packet */ /* thanks to: https://segmentfault.com/a/1190000017328813 https://blog.csdn.net/xuchen16/article/details/81064372 https://github.com/shugen002/userscript/blob/master/BiliBili%20WebSocket%20Proxy%20Rebuild.user.js */ constructor(buffer){ Packet.VERSION_COMPRESSED = 2;/* protocol version for compressed body */ Packet.OPERATION_COMMAND = 5;/* operation type for command */ Packet.COMMAND_DANMAKU = 'DANMU_MSG';/* command code for 弾幕(danmaku/danmu) */ this.buffer = buffer; this.dataView = new DataView(buffer); this.views = { package: this.dataView.getUint32(0),/* packet length */ header: this.dataView.getUint16(4),/* header length = offset for body */ version: this.dataView.getUint16(6),/* protocol version */ operation: this.dataView.getUint32(8),/* operation type */ }; try{ this.array = this.getArray(); this.messages = this.getMessages(); }catch(e){ log(e, this.views, new Uint8Array(this.buffer)); } } getArray(){ return (this.isCompressed) ? pako.inflate(new Uint8Array(this.buffer, this.views.header)) : new Uint8Array(this.buffer) ; } getMessages(){ let dataView = new DataView(this.array.buffer); let messages = [], headerLength = this.views.header, decoder = new TextDecoder(); for(let pos = 0, packetLength = 0; pos < this.array.length; pos += packetLength){ packetLength = dataView.getUint32(pos); let subarray = this.array.subarray(pos + headerLength, pos + packetLength); let string = decoder.decode(subarray); messages.push(string[0] === '{' ? JSON.parse(string) : string); } return messages; } getDanmakuContents(){ return this.getDanmakus().map(d => { if(d.info === undefined) return log('Unexpected Danmaku JSON.', d), null; return d.info[1]; }); } getDanmakus(){ if(this.isCommand === false) return []; return this.messages.filter(m => { if(m.cmd === undefined) return log('Unexpected Command JSON:', m), false; return m.cmd.startsWith(Packet.COMMAND_DANMAKU); }); } get isCompressed(){ return (this.views.version === Packet.VERSION_COMPRESSED); } get isCommand(){ return (this.views.operation === Packet.OPERATION_COMMAND); } } class Translator{ /* Danmaku translator using the browser's auto translation */ constructor(){ Translator.TRANSLATIONSATONCE = TRANSLATIONSATONCE; Translator.TRANSLATIONSINTERVAL = TRANSLATIONSINTERVAL; Translator.HISTORYLENGTH = HISTORYLENGTH; Translator.TRANSLATIONEXPIRED = TRANSLATIONEXPIRED; Translator.DICTIONARY = DICTIONARIES[USERLANGUAGE] || DICTIONARIES[USERLANGUAGE.substring(0, 2)] || DICTIONARIES.en; Translator.PRIOR_WAITING_LIMIT = 10*1000;/* waiting limit for auto translation by browser */ this.counters = {push: 0, registerTranslation: 0}; this.dictionary = Storage.read('dictionary') || Translator.DICTIONARY; this.updateDictionary(); this.history = Storage.read('history') || []; this.priorDanmaku = this.createPriorDanmaku(); this.priorDanmakuWaitings = {};/* waiting for getting translated */ this.priorDanmakuRequested = 0;/* last requested time */ this.priorDanmakuQueue = [];/* queue for preventing multiple request in TRANSLATIONSINTERVAL */ this.timer = 0;/* timer to next TRANSLATIONSINTERVAL */ this.danmakuWaitings = {};/* waiting for getting translation */ } updateDictionary(){ let keys = Object.keys(this.dictionary); if(typeof this.dictionary[keys[0]] === 'string') keys.forEach(key => { this.dictionary[key] = [this.dictionary[key], 1, NOW]; }); } createPriorDanmaku(){ /* Append danmaku comments from WebSocket for translating by browser as fast as possible */ let priorDanmaku = elements.priorDanmaku = createElement(core.html.priorDanmaku()); document.body.appendChild(priorDanmaku); return priorDanmaku; } pushAll(originals){ originals.forEach(o => this.push(o)); if(this.priorDanmakuQueue.length === 0) return; /* throttle for TRANSLATIONSINTERVAL */ let now = Date.now(), elapsed = now - this.priorDanmakuRequested; clearTimeout(this.timer); if(elapsed <= Translator.TRANSLATIONSINTERVAL){ this.timer = setTimeout(() => this.putOnPriorDanmaku(), Translator.TRANSLATIONSINTERVAL - elapsed); }else{ this.putOnPriorDanmaku(); } } push(original){ this.counters.push++; if(this.dictionary[original] !== undefined) return this.dictionary[original][1]++;/* already exists in the dictionary */ if(this.priorDanmakuWaitings[original] !== undefined) return;/* already waiting for translation */ if(this.shouldBeTranslated(original) === false) return;/* seems not to be Chinese */ this.priorDanmakuQueue.push(original); } putOnPriorDanmaku(){ log(this.priorDanmakuQueue); this.priorDanmakuRequested = Date.now(); for(let i = 0, original; (original = this.priorDanmakuQueue[i]) && i < Translator.TRANSLATIONSATONCE; i++){ let span = createElement(core.html.danmakuContent(original)); this.priorDanmaku.insertBefore(span, this.priorDanmaku.firstElementChild); this.priorDanmakuWaitings[original] = span; /* Observe auto translation by browser */ let observer = observe(span, (records) => { //log('Got translated:', original); this.registerTranslation(original, span.textContent); this.removeWaiting(original, span, observer); }); /* Time to give up */ setTimeout(() => { if(span && span.isConnected){ log('Give up for waiting translated:', original); this.removeWaiting(original, span, observer); } }, Translator.PRIOR_WAITING_LIMIT); } this.priorDanmakuQueue = [];/* dropped */ } registerTranslation(original, translation){ this.counters.registerTranslation++; this.dictionary[original] = [translation, 1, Date.now()]; this.history.push(original); /* append the translation for each streaming danmakus */ if(this.danmakuWaitings[original]){ this.danmakuWaitings[original].forEach(d => this.appendTranslation(d, translation)); delete this.danmakuWaitings[original]; } } removeWaiting(original, span, observer){ observer.disconnect(); span.parentNode.removeChild(span); delete this.priorDanmakuWaitings[original]; } requestTranslation(danmaku){ if(this.shouldBeTranslated(danmaku.textContent) === false) return;/* seems not to be Chinese */ if(this.dictionary[danmaku.textContent] === undefined){ if(this.danmakuWaitings[danmaku.textContent] === undefined) this.danmakuWaitings[danmaku.textContent] = []; this.danmakuWaitings[danmaku.textContent].push(danmaku); }else{ if(danmaku.textContent === this.dictionary[danmaku.textContent][0]) return;/* original and translation are the same */ this.appendTranslation(danmaku, this.dictionary[danmaku.textContent][0]); } } appendTranslation(danmaku, translation){ danmaku.appendTranslation(translation); } shouldBeTranslated(textContent){ switch(true){ case(this.dictionary[textContent] !== undefined):/* has a translation */ return true; case(textContent.match(REGEXP.hasKana) !== null):/* seems to be Japanese */ case(textContent.match(REGEXP.allAlphabet) !== null):/* seems to be English */ case(textContent.match(REGEXP.allEmoji) !== null):/* seems to be Emoji */ return false; default: return true; } } save(){ /* log usage statistics */ let c = this.counters, saved = (((c.push - c.registerTranslation)/(c.push || 1))*100).toFixed(0) + '%'; log('Total danmaku:', c.push, 'Newly translated:', c.registerTranslation, 'Saved:', saved); /* save the dictionary and the history of latest HISTORYLENGTH pairs */ let newDictionary = {}, newHistory = []; for(let i = this.history.length - 1, count = 0, now = Date.now(); 0 <= i; i--){ if(this.dictionary[this.history[i]][2] < now - Translator.TRANSLATIONEXPIRED) continue;/* old data */ if(newDictionary[this.history[i]] !== undefined) continue; newDictionary[this.history[i]] = this.dictionary[this.history[i]]; newHistory[count] = this.history[i]; if(count++ === Translator.HISTORYLENGTH) break; } /* keep the default dictionary */ Object.keys(Translator.DICTIONARY).forEach(key => { newDictionary[key] = newDictionary[key] || Translator.DICTIONARY[key]; }); log('Dictionary length:', newHistory.length, 'Stored size:', toMetric(JSON.stringify(newDictionary).length * 2) + 'bytes'); Storage.save('dictionary', newDictionary); Storage.save('history', newHistory.reverse()); } } class Danmaku{ constructor(danmaku){ Danmaku.zIndex = Danmaku.zIndex || 1; this.element = danmaku; this.textContent = danmaku.textContent; this.modify(); } modify(){ this.element.style.zIndex = Danmaku.zIndex++;/* newer comments have priority */ /* Make space for appending translation text */ this.element.style.top = (() => { let operableContainer = elements.operableContainer, operableSpace = site.get.operableSpace(operableContainer); if(operableSpace === null || operableSpace.children.length === 0 || operableSpace.style.height === ''){ return (parseInt(this.element.style.top) * 2) + 'px'; }else{ let height = parseInt(operableSpace.style.height), top = parseInt(this.element.style.top); return (height + ((top - height) * 2)) + 'px'; } })(); /* Even if double long translation text added, keep streaming to fully go away */ this.element.style.transitionDuration = ((transitionDuration) => { let m = transitionDuration.match(/([0-9.]+)(m?s)/); if(m === null) return log('Unknown transitionDuration format:', transitionDuration), transitionDuration; return (parseFloat(m[1]) * 2) + m[2]; })(this.element.style.transitionDuration); this.element.style.transform = ((transform) => { let m = transform.match(/(translateX?)\(([-0-9.]+)(px)/); if(m === null) return log('Unknown transform format:', transform), transform; return transform.replace(m[0], `${m[1]}(${parseFloat(m[2]) * 2}${m[3]}`); })(this.element.style.transform); } appendTranslation(translation){ let span = createElement(core.html.translation(translation)); this.element.appendChild(span); span.animate([{opacity: `0`},{opacity: `1`}], {duration: 500, fill: 'forwards'}); this.element.addEventListener('transitionend', (e) => { span.animate([{opacity: `1`},{opacity: `0`}], {duration: 500, fill: 'forwards'}); }, {once: true}); } get hasTranslation(){ /* bilibili removes previous translation element when the danmaku element has reused */ return (this.element.querySelector('.translation') === null) ? false : true; } } let core = { initialize: function(){ html = document.documentElement; html.classList.add(SCRIPTNAME); core.listenWebSockets(); core.ready(); }, ready: function(){ core.getTargets(site.targets, RETRY).then(() => { log("I'm ready."); translator = new Translator(); core.translateUserInterface(); core.targetTranslation(); core.observeVideoDanmaku(); core.modifyChatInput(); core.addStyle(); core.readyForUnload(); }); }, translateUserInterface: function(){ translations = TRANSLATIONS[USERLANGUAGE] || TRANSLATIONS[USERLANGUAGE.substring(0, 2)] || TRANSLATIONS.en; }, targetTranslation: function(){ const setTranslate = function(element){ element.classList.add('translate'); element.translate = true; }; const setNoTranslate = function(element){ element.classList.add('notranslate'); element.translate = false; }; site.translationTargets.forEach(target => { if(target[0] === true) setTranslate(target[1]()); else setNoTranslate(target[1]()); }); }, listenWebSockets: function(){ /* 公式の通信内容を取得 */ window.WebSocket = new Proxy(WebSocket, { construct(target, arguments){ const ws = new target(...arguments); //log(ws, arguments); if(ws.url.includes(CHATSERVER)) ws.addEventListener('message', function(e){ let packet = new Packet(e.data); //log(packet.views, packet.messages); if(packet.isCommand === false) return; let danmakuContents = packet.getDanmakuContents(); if(danmakuContents.length === 0) return; //log(danmakuContents.length, danmakuContents); translator.pushAll(danmakuContents); }); return ws; } }); }, observeVideoDanmaku: function(){ let videoDanmaku = elements.videoDanmaku; let observer = observe(videoDanmaku, function(records){ //log(records); for(let i = 0; records[i]; i++){ if(records[i].addedNodes.length === 0) continue; if(records[i].addedNodes[0].classList.contains('bilibili-danmaku') === false) continue; let danmaku = new Danmaku(records[i].addedNodes[0]); translator.requestTranslation(danmaku); observeDanmaku(danmaku);/*danmakuは再利用される!*/ } }); const observeDanmaku = function(danmaku){ /* 再利用(新規弾幕としての生まれ変わり)を検知したい */ let observer = observe(danmaku.element, function(records){ if(danmaku.hasTranslation) return;/*再利用ではなく翻訳文追加だった*/ danmaku = new Danmaku(danmaku.element);/*上書き*/ translator.requestTranslation(danmaku); }); }; }, modifyChatInput: function(){ /* 弾幕投稿内容を翻訳する機能を追加 */ let chatInput = elements.chatInput, modifier = ISMAC ? 'metaKey' : 'ctrlKey'; if(chatInput.placeholder === undefined) return setTimeout(core.modifyChatInput, 1000);/*属性付与が遅れる場合もあるので*/ chatInput.placeholder += '\n' + translations.inputTranslationKey; window.addEventListener('keydown', function(e){ if(e.target !== chatInput) return; if(e.key === 'Enter' && e[modifier] === true){ e.preventDefault(); e.stopPropagation(); chatInput.classList.add('translating'); let api = TRANSLATOR.replace('{text}', chatInput.value).replace('{source}', USERLANGUAGE).replace('{target}', BILIBILILANGUAGE); fetch(api, {mode: 'cors'}) .then(response => response.text()) .then(text => { //log(text); chatInput.value = text; chatInput.dispatchEvent(new InputEvent('input'));/*実際の送信内容に反映させるために必要*/ chatInput.classList.remove('translating'); }) .catch(error => { log('Error:', error); chatInput.classList.remove('translating'); }); } }, true); }, readyForUnload: function(){ window.addEventListener('unload', function(e){ translator.save(); }); }, 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}, 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(core.html[name]()); document.head.appendChild(style); if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]); elements[name] = style; }, html: { priorDanmaku: () => `<ul id="${SCRIPTNAME}-prior-danmaku" class="translate" translate="yes"></ul>`, danmakuContent: (content) => `<li>${content}</li>`, translation: (translation) => `<span class="translation">${translation}</span>`, style: () => ` <style type="text/css"> ul#${SCRIPTNAME}-prior-danmaku{ /* 画面内にないと自動翻訳されない */ visibility: hidden; position: fixed; top: 0; } ul#${SCRIPTNAME}-prior-danmaku li{ display: inline; } .translation{ font-size: 75%; display: block; } .translating{ opacity: .25; animation: ${SCRIPTNAME}-blink 250ms step-end infinite; } @keyframes ${SCRIPTNAME}-blink{ 50%{opacity: .5} } </style> `, }, }; const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame; const getComputedStyle = window.getComputedStyle, fetch = window.fetch; if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}}); class Storage{ static key(key){ return (SCRIPTNAME) ? (SCRIPTNAME + '-' + 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); return data.value; } static delete(key){ key = Storage.key(key); delete localStorage.removeItem(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; } } const $ = function(s){return document.querySelector(s)}; const $$ = function(s){return document.querySelectorAll(s)}; const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))}; const createElement = function(html = '<span></span>'){ let outer = document.createElement('div'); outer.innerHTML = html; return outer.firstElementChild; }; const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); return observer; }; const atLeast = function(min, b){ return Math.max(min, b); }; const atMost = function(a, max){ return Math.min(a, max); }; const between = function(min, b, max){ return Math.min(Math.max(min, b), max); }; const toMetric = function(number, decimal = 1){ switch(true){ case(number < 1e3 ): return (number); case(number < 1e6 ): return (number/1e3 ).toFixed(decimal) + 'K'; case(number < 1e9 ): return (number/1e6 ).toFixed(decimal) + 'M'; case(number < 1e12): return (number/1e9 ).toFixed(decimal) + 'G'; default: return (number/1e12).toFixed(decimal) + 'T'; } }; 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( SCRIPTNAME + ':', /* 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 \(<anonymous>/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm), }, { name: 'Chrome Tampermonkey', detector: /at MARKER \((userscript\.html|chrome-extension:)/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 6, getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|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, 'wants', 85, '\n' + new Error().stack); return true; }); const time = function(label){ if(!DEBUG) return; const BAR = '|', TOTAL = 100; switch(true){ case(label === undefined):/* time() to output total */ let total = 0; Object.keys(time.records).forEach((label) => total += time.records[label].total); Object.keys(time.records).forEach((label) => { console.log( BAR.repeat((time.records[label].total / total) * TOTAL), label + ':', (time.records[label].total).toFixed(3) + 'ms', '(' + time.records[label].count + ')', ); }); time.records = {}; break; case(!time.records[label]):/* time('label') to create and start the record */ time.records[label] = {count: 0, from: performance.now(), total: 0}; break; case(time.records[label].from === null):/* time('label') to re-start the lap */ time.records[label].from = performance.now(); break; case(0 < time.records[label].from):/* time('label') to add lap time to the record */ time.records[label].total += performance.now() - time.records[label].from; time.records[label].from = null; time.records[label].count += 1; break; } }; time.records = {}; core.initialize(); if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME); })();