- // ==UserScript==
- // @name Bilibili Danmaku Translator
- // @name:ja Bilibili Danmaku Translator
- // @name:zh-CN Bilibili Danmaku Translator
- // @namespace knoa.jp
- // @description Add translations on streaming user comments(弾幕;danmaku) of bilibili, with the translation of Google Chrome.
- // @description:ja Google Chrome の翻訳ツールを使って、ビリビリのユーザーコメント(弾幕)を自動翻訳します。
- // @description:zh-CN 使用 Google Chrome 的翻译工具,自动翻译 bilibili 的用户评论(弹幕)。
- // @include /^https://www\.bilibili\.com/video/av[0-9]+/
- // @include /^https://live\.bilibili\.com/[0-9]+/
- // @version 2.1.0
- // @require https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako_inflate.min.js
- // @grant none
- // ==/UserScript==
-
- (function(){
- const SCRIPTNAME = 'BilibiliDanmakuTranslator';
- const DEBUG = false;/*
- [update] 2.1.0
- 大量弾幕時の継続力を改善。一部の単語に固定訳を用意。ほか、不具合の修正。
-
- [bug]
-
- [to do]
-
- [to research]
- Chrome翻訳負荷制限
- キューはクリアしない方針?遅れた翻訳は意義薄い?
- 文字列の長さの可能性?
- Chromeがサボるだけなら自家製クエリに手を出す手も?
- Chromeがどんどん反応を遅くしていった?
- 新語に対する複数回クエリなど謎の挙動?
- 右の一覧内でも特殊案内は訳したいかも
- 主要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
- pako.deflate + TextDecoder でdictionaryを無理やり圧縮して保存できる?
- 動画のタイトル下に翻訳を挿入したいね
-
- [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 VIDEOINFOAPI = 'https://api.bilibili.com/x/web-interface/view'
- const COMMENTLISTAPI = 'https://comment.bilibili.com/{cid}.xml';/*動画用*/
- 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 = 4;/*同時最大翻訳リクエスト数(Chrome翻訳負荷の低減)*/
- const TRANSLATIONSINTERVAL = 1250;/*最短翻訳リクエスト間隔(ms)(Chrome翻訳負荷の低減)*/
- const HISTORYLENGTH = 50000;/*辞書の最大保持数(5万で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 MODIFICATIONS = {
- /* '単語': ['誤訳(削除する)', '適訳(挿入する)'] */
- ja: {
- // 日本語
- '上手': ['はじめに', '上手'],
- '上手上手': ['手をつないで', '上手上手'],
- // スラング
- '单推': ['シングルプッシュ', '単推し'],
- '木大': [/大きな木|ウッドビッグ/g, '無駄'],
- '拜拜': ['さようなら', 'バイバイ'],
- '才八点': ['たった8時', 'まだ8時'],
- // awsl
- '奥维丝丽': ['オビスリ', 'awsl'],
- '阿伟少林': ['魏少林寺', 'awsl'],
- '阿伟爽了': ['魏がかっこいい', 'awsl'],
- '阿伟死了': ['魏は死んでいる', 'awsl'],
- '阿伟射了': ['ウェイショット', 'awsl'],
- '啊我睡了': ['ああ、私は寝ました。', 'awsl'],
- '爱我苏联': ['私を愛してソビエト連邦', 'awsl'],
- '阿伟乱葬岗': ['アウェイマスグレイブ', 'awsl墓地'],
- // 固有名詞
- '谷酱': ['谷', 'グーグルちゃん'],
- 'goo酱': ['グーソース', 'Googleちゃん'],
- '油管': ['オイルパイプ', 'YouTube'],
- '爱酱': ['ラブソース', 'アイちゃん'],
- '诸葛孔明': [/Zhuge Kongming|ジュージュコミング/, '諸葛孔明'],
- '孔明': [/Kong Ming|コミング|コングミン/, '孔明'],
- },
- en: {
- '草': ['grass', 'lol'],
- },
- };
- 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 sites = {
- video: {
- targets: {
- danmakuSetting: () => $('.bilibili-player-video-danmaku-setting'),/*弾幕設定*/
- videoDanmaku: () => $('.bilibili-player-video-danmaku'),/* div or canvas */
- },
- translationTargets: [
- [false, () => $('title')],
- [false, () => $('body')],
- ],
- get: {
- commentlistApi: (videoInfo) => COMMENTLISTAPI.replace('{cid}', videoInfo.cid),
- danmakuTypeCSS: (danmakuSetting) => danmakuSetting.querySelector('li.bui-select-item[data-value="div"]'),
- danmakuInput: () => $('input.bilibili-player-video-danmaku-input'),
- },
- },
- live: {
- targets: {
- operableContainer: () => $('.bilibili-live-player-video-operable-container'),/*特殊弾幕枠*/
- videoDanmaku: () => $('.bilibili-live-player-video-danmaku'),
- chatHistoryList: () => $('#chat-history-list'),
- chatActions: () => $('#chat-control-panel-vm .bottom-actions'),
- },
- 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:"]'),
- danmakuInput: () => $('textarea.chat-input'),/*divからtextareaに置換される*/
- },
- },
- };
- let html, elements = {}, storages = {}, timers = {}, sizes = {}, site;
- let translator, translations = {}, videoInfo;
- 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)] || {};
- Translator.MODIFICATIONS = MODIFICATIONS[USERLANGUAGE] || MODIFICATIONS[USERLANGUAGE.substring(0, 2)] || {};
- Translator.MODIFICATIONSKEYS = Object.keys(Translator.MODIFICATIONS);
- Translator.PRIOR_WAITING_LIMIT = 10*1000;/* waiting limit for auto translation by browser */
- this.counters = {pushes: 0, registerTranslations: 0, fails: 0};
- this.readDictionary();
- 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 */
- }
- readDictionary(){
- /* use browser language dictionary */
- if(Storage.read('USERLANGUAGE') !== USERLANGUAGE) this.dictionary = Translator.DICTIONARY;
- else this.dictionary = Storage.read('dictionary') || Translator.DICTIONARY;
- Storage.save('USERLANGUAGE', USERLANGUAGE);
- }
- updateDictionary(){
- /* update structure (2019/6/11) */
- let keys = Object.keys(this.dictionary);
- if(typeof this.dictionary[keys[0]] === 'string') keys.forEach(key => {
- this.dictionary[key] = [this.dictionary[key], 1, NOW];
- });
- /* update key (2019/6/23) */
- let oldKey = 'BilibiliLiveCommentTranslator';
- let oldDictionary = localStorage[`${oldKey}-dictionary`], oldHistory = localStorage[`${oldKey}-history`];
- if(oldDictionary && oldHistory){
- this.dictionary = JSON.parse(oldDictionary).value;
- this.history = JSON.parse(oldHistory).value;
- localStorage.removeItem(`${oldKey}-dictionary`);
- localStorage.removeItem(`${oldKey}-history`);
- }
- }
- 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));
- this.throttle();
- }
- push(original){
- this.counters.pushes++;
- if(this.dictionary[original] !== undefined) return this.dictionary[original][1]++;/* already exists in the dictionary */
- if(this.priorDanmakuQueue.includes(original) === true) return;/* already queued */
- 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);
- }
- throttle(){
- if(this.priorDanmakuQueue.length === 0) return;
- /* throttle for single waiting query to Chrome Translation */
- if(this.priorDanmaku.children.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();
- }
- }
- putOnPriorDanmaku(){
- //log(this.priorDanmakuQueue);
- this.priorDanmakuRequested = Date.now();
- let putOnce = this.putOnPriorDanmaku.putOnce ? true : false;/* it can put more only on first time */
- let fragment = document.createDocumentFragment();
- for(let i = 0, original; (original = this.priorDanmakuQueue[i]) && i < (putOnce ? Translator.TRANSLATIONSATONCE : 10*1000); i++){
- let li = createElement(core.html.danmakuContent(original));
- this.priorDanmakuWaitings[original] = li;
- fragment.appendChild(li);
- /* Observe auto translation by browser */
- let observer = observe(li, (records) => {
- //log('Got translated:', original);
- this.registerTranslation(original, li.textContent);
- this.removeWaiting(original, li, observer);
- this.throttle();
- });
- /* Time to give up */
- setTimeout(() => {
- if(li && li.isConnected){
- log('Give up for waiting translated:', original);
- this.counters.fails++;
- this.removeWaiting(original, li, observer);
- }
- }, (putOnce) ? Translator.PRIOR_WAITING_LIMIT : 60*60*1000);
- }
- //log(Array.from(fragment.children).map(c => c.textContent));
- this.priorDanmaku.appendChild(fragment);
- this.priorDanmakuQueue = [];/* dropped */
- this.putOnPriorDanmaku.putOnce = true;
- }
- registerTranslation(original, translation){
- this.counters.registerTranslations++;
- 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){
- Translator.MODIFICATIONSKEYS.filter(key => {
- return danmaku.textContent.includes(key) && translation.includes(Translator.MODIFICATIONS[key][0]);
- }).forEach(key => {
- translation = translation.replace(Translator.MODIFICATIONS[key][0], Translator.MODIFICATIONS[key][1]);
- });
- 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.pushes - c.fails - c.registerTranslations)/((c.pushes - c.fails) || 1))*100).toFixed(0) + '%';
- log('Total danmaku:', c.pushes, 'Newly translated:', c.registerTranslations, 'Saved:', saved, 'Fails:', c.fails);
- /* 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]] === undefined){
- log('Unknown history', this.history[i]);
- continue;
- };
- 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 = parseInt(this.element.style.zIndex || 0) + Danmaku.zIndex++;/* newer comments have priority */
- /* Make space for appending translation text */
- this.element.style.top = (() => {
- if(this.element.style.top === '') return;
- let operableContainer = elements.operableContainer, operableSpace = operableContainer ? site.get.operableSpace(operableContainer) : null;
- if(this.element.style.top[0] === '-' || operableSpace === null || operableSpace.children.length === 0 || operableSpace.style.height === ''){
- return (parseFloat(this.element.style.top) * 2) + 'px';
- }else{
- let height = parseFloat(operableSpace.style.height), top = parseFloat(this.element.style.top);
- return (height + ((top - height) * 2)) + 'px';
- }
- })();
- /* Even if double long translation text added, keep streaming to completely go away */
- this.element.style.transitionDuration = ((transitionDuration) => {
- if(transitionDuration === '') return;
- 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) => {
- if(transform === '') return;
- 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);
- switch(true){
- case(location.href.match(/^https:\/\/www\.bilibili\.com\/video\/av[0-9]+/) !== null):
- site = sites.video;
- translator = new Translator();
- core.listenXMLHttpRequests();
- core.targetTranslation();
- core.readyForVideo();
- break;
- case(location.href.match(/^https:\/\/live\.bilibili\.com\/[0-9]+/) !== null):
- site = sites.live;
- translator = new Translator();
- core.listenWebSockets();
- core.targetTranslation();
- core.readyForLive();
- break;
- default:
- log('Bye.');
- break;
- }
- },
- readyForVideo: function(){
- if(document.hidden) return setTimeout(core.readyForVideo, 1000);
- core.getTargets(site.targets, RETRY).then(() => {
- log("I'm ready for Video.");
- core.translateUserInterface();
- core.setDanmakuSettings();
- core.observeVideoDanmaku();
- core.modifyDanmakuInput();
- core.addStyle();
- core.readyForUnload();
- core.exportLog();
- });
- },
- readyForLive: function(){
- if(document.hidden) return setTimeout(core.readyForVideo, 1000);
- core.getTargets(site.targets, RETRY).then(() => {
- log("I'm ready for Live.");
- core.translateUserInterface();
- core.observeVideoDanmaku();
- core.modifyDanmakuInput();
- core.addStyle();
- core.readyForUnload();
- core.exportLog();
- });
- },
- targetTranslation: function(){
- const setTranslate = function(element){
- element.classList.add('translate');
- element.translate = true;
- };
- const setNoTranslate = function(element){
- element.classList.add('notranslate');
- element.translate = false;
- };
- for(let i = 0, target; target = site.translationTargets[i]; i++){
- if(target[1]() === null) return setTimeout(core.targetTranslation, 1000);
- if(target[0] === true) setTranslate(target[1]());
- else setNoTranslate(target[1]());
- }
- },
- translateUserInterface: function(){
- translations = TRANSLATIONS[USERLANGUAGE] || TRANSLATIONS[USERLANGUAGE.substring(0, 2)] || TRANSLATIONS.en;
- /*置換したりobserveしたりする・・・かもしれない*/
- },
- listenXMLHttpRequests: function(){
- /* 公式の通信内容を取得 */
- window.XMLHttpRequest = new Proxy(XMLHttpRequest, {
- construct(target, arguments){
- const xhr = new target(...arguments);
- //log(xhr, arguments);
- xhr.addEventListener('load', function(e){
- if(xhr.responseURL.startsWith(VIDEOINFOAPI) === false) return;
- if(xhr.response[0] !== '{') return;
- videoInfo = JSON.parse(xhr.response).data;
- //log(videoInfo);
- core.getDanmakuList();
- });
- return xhr;
- }
- });
- },
- getDanmakuList: function(){
- let api = site.get.commentlistApi(videoInfo);
- fetch(api, {credentials: 'include', mode: 'cors'})
- .then(response => response.text())
- .then(text => new DOMParser().parseFromString(text, 'text/xml'))
- .then(d => {
- let ds = d.querySelectorAll('d');
- if(ds.length === 0) return log('Unknown danmaku format:', d);
- let danmakuContents = Array.from(ds).map(d => d.textContent);
- translator.pushAll(danmakuContents);
- });
- },
- 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;
- }
- });
- },
- setDanmakuSettings: function(){
- if(elements.videoDanmaku.localName === 'canvas'){
- let danmakuSetting = elements.danmakuSetting;
- danmakuSetting.dispatchEvent(new MouseEvent('mouseover'));
- danmakuSetting.dispatchEvent(new MouseEvent('mouseout'));
- animate(function(){
- let danmakuTypeCSS = site.get.danmakuTypeCSS(danmakuSetting);
- if(danmakuTypeCSS) danmakuTypeCSS.click();
- else log('Can\'t find CSS3 setting.', danmakuSetting);
- });
- }
- },
- 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);
- });
- };
- },
- modifyDanmakuInput: function(){
- /* 弾幕投稿内容を翻訳する機能を追加 */
- let danmakuInput = site.get.danmakuInput(), modifier = ISMAC ? 'metaKey' : 'ctrlKey';
- if(danmakuInput === null || danmakuInput.placeholder === undefined) return setTimeout(core.modifyDanmakuInput, 1000);/*属性付与が遅れる場合もあるので*/
- danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
- observe(danmakuInput, function(record){
- if(danmakuInput.placeholder.endsWith(translations.inputTranslationKey)) return;
- danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
- }, {attributes: true, attributeFilter: ['placeholder']});
- window.addEventListener('keydown', function(e){
- if(e.target !== danmakuInput) return;
- if(e.key === 'Enter' && e[modifier] === true){
- e.preventDefault();
- e.stopPropagation();
- danmakuInput.classList.add('translating');
- let api = TRANSLATOR.replace('{text}', danmakuInput.value).replace('{source}', USERLANGUAGE).replace('{target}', BILIBILILANGUAGE);
- fetch(api, {mode: 'cors'})
- .then(response => response.text())
- .then(text => {
- //log(text);
- danmakuInput.value = text;
- danmakuInput.dispatchEvent(new InputEvent('input'));/*実際の送信内容に反映させるために必要*/
- danmakuInput.classList.remove('translating');
- })
- .catch(error => {
- log('Error:', error);
- danmakuInput.classList.remove('translating');
- });
- }
- }, true);
- },
- readyForUnload: function(){
- window.addEventListener('unload', function(e){
- translator.save();
- });
- },
- exportLog: function(){
- if(DEBUG === false) return;
- window.translatorLog = translator.save.bind(translator);
- },
- 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">
- /* bilibili color: #00A1D6 */
- ul#${SCRIPTNAME}-prior-danmaku{
- /* 画面内にないと自動翻訳されない */
- visibility: hidden;
- position: fixed;
- top: 0;
- padding: 0;
- margin: 0;
- white-space: nowrap;
- z-index: 9999;
- }
- ul#${SCRIPTNAME}-prior-danmaku li{
- position: absolute;
- }
- .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 \(chrome-extension:.*?\/userscript.html\?id=/,
- getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
- getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
- }, {
- name: 'Chrome Extension',
- detector: /at MARKER \(chrome-extension:/,
- getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
- 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, 'wants', 0/*line*/, '\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);
- })();