Bilibili Danmaku Translator

Add translations on streaming user comments(弾幕;danmaku) of bilibili, with the translation of Google Chrome.

目前為 2019-06-30 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Bilibili Danmaku Translator
  3. // @name:ja Bilibili Danmaku Translator
  4. // @name:zh-CN Bilibili Danmaku Translator
  5. // @namespace knoa.jp
  6. // @description Add translations on streaming user comments(弾幕;danmaku) of bilibili, with the translation of Google Chrome.
  7. // @description:ja Google Chrome の翻訳ツールを使って、ビリビリのユーザーコメント(弾幕)を自動翻訳します。
  8. // @description:zh-CN 使用 Google Chrome 的翻译工具,自动翻译 bilibili 的用户评论(弹幕)。
  9. // @include /^https://www\.bilibili\.com/video/av[0-9]+/
  10. // @include /^https://live\.bilibili\.com/[0-9]+/
  11. // @version 2.1.0
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako_inflate.min.js
  13. // @grant none
  14. // ==/UserScript==
  15.  
  16. (function(){
  17. const SCRIPTNAME = 'BilibiliDanmakuTranslator';
  18. const DEBUG = false;/*
  19. [update] 2.1.0
  20. 大量弾幕時の継続力を改善。一部の単語に固定訳を用意。ほか、不具合の修正。
  21.  
  22. [bug]
  23.  
  24. [to do]
  25.  
  26. [to research]
  27. Chrome翻訳負荷制限
  28. キューはクリアしない方針?遅れた翻訳は意義薄い?
  29. 文字列の長さの可能性?
  30. Chromeがサボるだけなら自家製クエリに手を出す手も?
  31. Chromeがどんどん反応を遅くしていった?
  32. 新語に対する複数回クエリなど謎の挙動?
  33. 右の一覧内でも特殊案内は訳したいかも
  34. 主要UI要素を指定翻訳語として登録しておきたい
  35. 動的に生成される要素の対応がめんどくさい
  36. 頻出コメントほかにもたくさん登録しとく?
  37. 頻出だけなら人力や腾讯を使う手も。
  38. 統計用には英語弾幕とかも含めたいけど。
  39. 自分のコメントの翻訳時も逆辞書で節約と蓄積?
  40. 日本語と英語は翻訳しない方針で問題ないよね?
  41. Google翻訳の一般Webユーザーのフリをして各ユーザーにAPIを叩かせる手もあるようだが
  42. https://github.com/andy-portmen/Simple-Translate/blob/master/src/lib/common.js
  43. それが許されるならBaiduのAPIを叩かせることも可能?
  44. 翻訳文をただ置き換えてしまう設定項目は趣旨に反する?
  45. 翻訳辞書を共有サーバーに溜め込む仕組み?
  46. iframe内で映像配信する放送に対応できていない。
  47. https://live.bilibili.com/76?visit_id=6kwlti59xlg0
  48. pako.deflate + TextDecoder でdictionaryを無理やり圧縮して保存できる?
  49. 動画のタイトル下に翻訳を挿入したいね
  50.  
  51. [memo]
  52. 1. 翻訳辞書構築の流れ
  53. 1-1. core.listenWebSocketsで弾幕テキストを取得(要素出現より1秒ほど早く取得できる)
  54. 1-2. Translatorに弾幕テキストを登録
  55. 1-3. TranslatorがpriorDanmaku要素に弾幕テキスト要素を設置
  56. 1-4. Chromeが弾幕テキスト要素を自動翻訳してくれる
  57. 1-5. Translatorが察知して辞書として登録
  58.  
  59. 2. 弾幕訳文追加の流れ
  60. 2-1. core.observeVideoDanmakuで弾幕要素を発見
  61. 2-2. Danmakuインスタンスを作成してTranslatorに登録
  62. 2-3. 弾幕テキストに一致する辞書がすでにあればすぐに訳文を追加
  63. 2-4. なければ1-5.のタイミングで訳文を追加
  64.  
  65. 3. 自分の投稿コメント翻訳
  66. Google Apps Script (推定1日7000回(=1回5文字で月100万文字相当)を超えたあたりで制限がかかる)
  67. https://qiita.com/tanabee/items/c79c5c28ba0537112922
  68. */
  69. if(window === top && console.time) console.time(SCRIPTNAME);
  70. const NOW = Date.now();
  71. const ISMAC = (window.navigator.userAgent.match(/Mac/) !== null);
  72. const VIDEOINFOAPI = 'https://api.bilibili.com/x/web-interface/view'
  73. const COMMENTLISTAPI = 'https://comment.bilibili.com/{cid}.xml';/*動画用*/
  74. const CHATSERVER = 'chat.bilibili.com';/*直播用*/
  75. const TRANSLATOR = 'https://script.google.com/macros/s/AKfycby29iFLZ742UEC6TlN8-b4Dxtlu_7XYbVeo2GgiYVWMtuzIcbA/exec?text={text}&source={source}&target={target}';
  76. const TRANSLATIONSATONCE = 4;/*同時最大翻訳リクエスト数(Chrome翻訳負荷の低減)*/
  77. const TRANSLATIONSINTERVAL = 1250;/*最短翻訳リクエスト間隔(ms)(Chrome翻訳負荷の低減)*/
  78. const HISTORYLENGTH = 50000;/*辞書の最大保持数(5万で5MB見込み)*/
  79. const TRANSLATIONEXPIRED = 90*24*60*60*1000;/*翻訳の有効期限(翻訳精度の改善に期待する)*/
  80. const BILIBILILANGUAGE = 'zh-CN';
  81. const USERLANGUAGE = window.navigator.language;
  82. const TRANSLATIONS = {
  83. ja: {
  84. inputTranslationKey: ISMAC ? '(Command+Enterで翻訳)' : '(Ctrl+Enterで翻訳)',
  85. },
  86. en: {
  87. inputTranslationKey: ISMAC ? '(Command+Enter to translate)' : '(Ctrl+Enter to translate)',
  88. },
  89. };
  90. const DICTIONARIES = {
  91. ja: {/* original: [translation, count, created] */
  92. '哔哩哔哩 (゜-゜)つロ 干杯~': ['ビリビリ (゜-゜)つロ 乾杯~', 0, NOW],
  93. },
  94. en: {
  95. '哔哩哔哩 (゜-゜)つロ 干杯~': ['bilibili (゜-゜)つロ cheers~', 0, NOW],
  96. },
  97. };
  98. const MODIFICATIONS = {
  99. /* '単語': ['誤訳(削除する)', '適訳(挿入する)'] */
  100. ja: {
  101. // 日本語
  102. '上手': ['はじめに', '上手'],
  103. '上手上手': ['手をつないで', '上手上手'],
  104. // スラング
  105. '单推': ['シングルプッシュ', '単推し'],
  106. '木大': [/大きな木|ウッドビッグ/g, '無駄'],
  107. '拜拜': ['さようなら', 'バイバイ'],
  108. '才八点': ['たった8時', 'まだ8時'],
  109. // awsl
  110. '奥维丝丽': ['オビスリ', 'awsl'],
  111. '阿伟少林': ['魏少林寺', 'awsl'],
  112. '阿伟爽了': ['魏がかっこいい', 'awsl'],
  113. '阿伟死了': ['魏は死んでいる', 'awsl'],
  114. '阿伟射了': ['ウェイショット', 'awsl'],
  115. '啊我睡了': ['ああ、私は寝ました。', 'awsl'],
  116. '爱我苏联': ['私を愛してソビエト連邦', 'awsl'],
  117. '阿伟乱葬岗': ['アウェイマスグレイブ', 'awsl墓地'],
  118. // 固有名詞
  119. '谷酱': ['谷', 'グーグルちゃん'],
  120. 'goo酱': ['グーソース', 'Googleちゃん'],
  121. '油管': ['オイルパイプ', 'YouTube'],
  122. '爱酱': ['ラブソース', 'アイちゃん'],
  123. '诸葛孔明': [/Zhuge Kongming|ジュージュコミング/, '諸葛孔明'],
  124. '孔明': [/Kong Ming|コミング|コングミン/, '孔明'],
  125. },
  126. en: {
  127. '草': ['grass', 'lol'],
  128. },
  129. };
  130. const REGEXP = {
  131. hasKana: /[ぁ-んァ-ン]/,
  132. allAlphabet: /^[a-zA-Z0-9,.'"!?\s]+$/,
  133. allEmoji: /^(\ud83c[\udf00-\udfff]|\ud83d[\udc00-\ude4f]|\ud83d[\ude80-\udeff]|\ud7c9[\ude00-\udeff]|[\u2600-\u27BF])+$/,
  134. };
  135. const RETRY = 10;
  136. let sites = {
  137. video: {
  138. targets: {
  139. danmakuSetting: () => $('.bilibili-player-video-danmaku-setting'),/*弾幕設定*/
  140. videoDanmaku: () => $('.bilibili-player-video-danmaku'),/* div or canvas */
  141. },
  142. translationTargets: [
  143. [false, () => $('title')],
  144. [false, () => $('body')],
  145. ],
  146. get: {
  147. commentlistApi: (videoInfo) => COMMENTLISTAPI.replace('{cid}', videoInfo.cid),
  148. danmakuTypeCSS: (danmakuSetting) => danmakuSetting.querySelector('li.bui-select-item[data-value="div"]'),
  149. danmakuInput: () => $('input.bilibili-player-video-danmaku-input'),
  150. },
  151. },
  152. live: {
  153. targets: {
  154. operableContainer: () => $('.bilibili-live-player-video-operable-container'),/*特殊弾幕枠*/
  155. videoDanmaku: () => $('.bilibili-live-player-video-danmaku'),
  156. chatHistoryList: () => $('#chat-history-list'),
  157. chatActions: () => $('#chat-control-panel-vm .bottom-actions'),
  158. },
  159. translationTargets: [
  160. [false, () => $('title')],
  161. [false, () => $('body')],
  162. [ true, () => $('.bilibili-live-player-video-controller')],/*プレイヤ内コントローラ*/
  163. [ false, () => $('.bilibili-live-player-video-controller-duration-btn > div > span')],
  164. [ true, () => $('#chat-control-panel-vm')],/*投稿欄内コントローラ*/
  165. [ false, () => $('#chat-control-panel-vm .bottom-actions')],
  166. ],
  167. get: {
  168. operableSpace: (operableContainer) => operableContainer.querySelector('#pk-vm ~ div[style*="height:"]'),
  169. danmakuInput: () => $('textarea.chat-input'),/*divからtextareaに置換される*/
  170. },
  171. },
  172. };
  173. let html, elements = {}, storages = {}, timers = {}, sizes = {}, site;
  174. let translator, translations = {}, videoInfo;
  175. class Packet{
  176. /* Bilibili Live WebSocket message packet */
  177. /* thanks to:
  178. https://segmentfault.com/a/1190000017328813
  179. https://blog.csdn.net/xuchen16/article/details/81064372
  180. https://github.com/shugen002/userscript/blob/master/BiliBili%20WebSocket%20Proxy%20Rebuild.user.js
  181. */
  182. constructor(buffer){
  183. Packet.VERSION_COMPRESSED = 2;/* protocol version for compressed body */
  184. Packet.OPERATION_COMMAND = 5;/* operation type for command */
  185. Packet.COMMAND_DANMAKU = 'DANMU_MSG';/* command code for 弾幕(danmaku/danmu) */
  186. this.buffer = buffer;
  187. this.dataView = new DataView(buffer);
  188. this.views = {
  189. package: this.dataView.getUint32(0),/* packet length */
  190. header: this.dataView.getUint16(4),/* header length = offset for body */
  191. version: this.dataView.getUint16(6),/* protocol version */
  192. operation: this.dataView.getUint32(8),/* operation type */
  193. };
  194. try{
  195. this.array = this.getArray();
  196. this.messages = this.getMessages();
  197. }catch(e){
  198. log(e, this.views, new Uint8Array(this.buffer));
  199. }
  200. }
  201. getArray(){
  202. return (this.isCompressed)
  203. ? pako.inflate(new Uint8Array(this.buffer, this.views.header))
  204. : new Uint8Array(this.buffer)
  205. ;
  206. }
  207. getMessages(){
  208. let dataView = new DataView(this.array.buffer);
  209. let messages = [], headerLength = this.views.header, decoder = new TextDecoder();
  210. for(let pos = 0, packetLength = 0; pos < this.array.length; pos += packetLength){
  211. packetLength = dataView.getUint32(pos);
  212. let subarray = this.array.subarray(pos + headerLength, pos + packetLength);
  213. let string = decoder.decode(subarray);
  214. messages.push(string[0] === '{' ? JSON.parse(string) : string);
  215. }
  216. return messages;
  217. }
  218. getDanmakuContents(){
  219. return this.getDanmakus().map(d => {
  220. if(d.info === undefined) return log('Unexpected Danmaku JSON.', d), null;
  221. return d.info[1];
  222. });
  223. }
  224. getDanmakus(){
  225. if(this.isCommand === false) return [];
  226. return this.messages.filter(m => {
  227. if(m.cmd === undefined) return log('Unexpected Command JSON:', m), false;
  228. return m.cmd.startsWith(Packet.COMMAND_DANMAKU);
  229. });
  230. }
  231. get isCompressed(){
  232. return (this.views.version === Packet.VERSION_COMPRESSED);
  233. }
  234. get isCommand(){
  235. return (this.views.operation === Packet.OPERATION_COMMAND);
  236. }
  237. }
  238. class Translator{
  239. /* Danmaku translator using the browser's auto translation */
  240. constructor(){
  241. Translator.TRANSLATIONSATONCE = TRANSLATIONSATONCE;
  242. Translator.TRANSLATIONSINTERVAL = TRANSLATIONSINTERVAL;
  243. Translator.HISTORYLENGTH = HISTORYLENGTH;
  244. Translator.TRANSLATIONEXPIRED = TRANSLATIONEXPIRED;
  245. Translator.DICTIONARY = DICTIONARIES[USERLANGUAGE] || DICTIONARIES[USERLANGUAGE.substring(0, 2)] || {};
  246. Translator.MODIFICATIONS = MODIFICATIONS[USERLANGUAGE] || MODIFICATIONS[USERLANGUAGE.substring(0, 2)] || {};
  247. Translator.MODIFICATIONSKEYS = Object.keys(Translator.MODIFICATIONS);
  248. Translator.PRIOR_WAITING_LIMIT = 10*1000;/* waiting limit for auto translation by browser */
  249. this.counters = {pushes: 0, registerTranslations: 0, fails: 0};
  250. this.readDictionary();
  251. this.updateDictionary();
  252. this.history = Storage.read('history') || [];
  253. this.priorDanmaku = this.createPriorDanmaku();
  254. this.priorDanmakuWaitings = {};/* waiting for getting translated */
  255. this.priorDanmakuRequested = 0;/* last requested time */
  256. this.priorDanmakuQueue = [];/* queue for preventing multiple request in TRANSLATIONSINTERVAL */
  257. this.timer = 0;/* timer to next TRANSLATIONSINTERVAL */
  258. this.danmakuWaitings = {};/* waiting for getting translation */
  259. }
  260. readDictionary(){
  261. /* use browser language dictionary */
  262. if(Storage.read('USERLANGUAGE') !== USERLANGUAGE) this.dictionary = Translator.DICTIONARY;
  263. else this.dictionary = Storage.read('dictionary') || Translator.DICTIONARY;
  264. Storage.save('USERLANGUAGE', USERLANGUAGE);
  265. }
  266. updateDictionary(){
  267. /* update structure (2019/6/11) */
  268. let keys = Object.keys(this.dictionary);
  269. if(typeof this.dictionary[keys[0]] === 'string') keys.forEach(key => {
  270. this.dictionary[key] = [this.dictionary[key], 1, NOW];
  271. });
  272. /* update key (2019/6/23) */
  273. let oldKey = 'BilibiliLiveCommentTranslator';
  274. let oldDictionary = localStorage[`${oldKey}-dictionary`], oldHistory = localStorage[`${oldKey}-history`];
  275. if(oldDictionary && oldHistory){
  276. this.dictionary = JSON.parse(oldDictionary).value;
  277. this.history = JSON.parse(oldHistory).value;
  278. localStorage.removeItem(`${oldKey}-dictionary`);
  279. localStorage.removeItem(`${oldKey}-history`);
  280. }
  281. }
  282. createPriorDanmaku(){
  283. /* Append danmaku comments from WebSocket for translating by browser as fast as possible */
  284. let priorDanmaku = elements.priorDanmaku = createElement(core.html.priorDanmaku());
  285. document.body.appendChild(priorDanmaku);
  286. return priorDanmaku;
  287. }
  288. pushAll(originals){
  289. originals.forEach(o => this.push(o));
  290. this.throttle();
  291. }
  292. push(original){
  293. this.counters.pushes++;
  294. if(this.dictionary[original] !== undefined) return this.dictionary[original][1]++;/* already exists in the dictionary */
  295. if(this.priorDanmakuQueue.includes(original) === true) return;/* already queued */
  296. if(this.priorDanmakuWaitings[original] !== undefined) return;/* already waiting for translation */
  297. if(this.shouldBeTranslated(original) === false) return;/* seems not to be Chinese */
  298. this.priorDanmakuQueue.push(original);
  299. }
  300. throttle(){
  301. if(this.priorDanmakuQueue.length === 0) return;
  302. /* throttle for single waiting query to Chrome Translation */
  303. if(this.priorDanmaku.children.length > 0) return;
  304. /* throttle for TRANSLATIONSINTERVAL */
  305. let now = Date.now(), elapsed = now - this.priorDanmakuRequested;
  306. clearTimeout(this.timer);
  307. if(elapsed <= Translator.TRANSLATIONSINTERVAL){
  308. this.timer = setTimeout(() => this.putOnPriorDanmaku(), Translator.TRANSLATIONSINTERVAL - elapsed);
  309. }else{
  310. this.putOnPriorDanmaku();
  311. }
  312. }
  313. putOnPriorDanmaku(){
  314. //log(this.priorDanmakuQueue);
  315. this.priorDanmakuRequested = Date.now();
  316. let putOnce = this.putOnPriorDanmaku.putOnce ? true : false;/* it can put more only on first time */
  317. let fragment = document.createDocumentFragment();
  318. for(let i = 0, original; (original = this.priorDanmakuQueue[i]) && i < (putOnce ? Translator.TRANSLATIONSATONCE : 10*1000); i++){
  319. let li = createElement(core.html.danmakuContent(original));
  320. this.priorDanmakuWaitings[original] = li;
  321. fragment.appendChild(li);
  322. /* Observe auto translation by browser */
  323. let observer = observe(li, (records) => {
  324. //log('Got translated:', original);
  325. this.registerTranslation(original, li.textContent);
  326. this.removeWaiting(original, li, observer);
  327. this.throttle();
  328. });
  329. /* Time to give up */
  330. setTimeout(() => {
  331. if(li && li.isConnected){
  332. log('Give up for waiting translated:', original);
  333. this.counters.fails++;
  334. this.removeWaiting(original, li, observer);
  335. }
  336. }, (putOnce) ? Translator.PRIOR_WAITING_LIMIT : 60*60*1000);
  337. }
  338. //log(Array.from(fragment.children).map(c => c.textContent));
  339. this.priorDanmaku.appendChild(fragment);
  340. this.priorDanmakuQueue = [];/* dropped */
  341. this.putOnPriorDanmaku.putOnce = true;
  342. }
  343. registerTranslation(original, translation){
  344. this.counters.registerTranslations++;
  345. this.dictionary[original] = [translation, 1, Date.now()];
  346. this.history.push(original);
  347. /* append the translation for each streaming danmakus */
  348. if(this.danmakuWaitings[original]){
  349. this.danmakuWaitings[original].forEach(d => this.appendTranslation(d, translation));
  350. delete this.danmakuWaitings[original];
  351. }
  352. }
  353. removeWaiting(original, span, observer){
  354. observer.disconnect();
  355. span.parentNode.removeChild(span);
  356. delete this.priorDanmakuWaitings[original];
  357. }
  358. requestTranslation(danmaku){
  359. if(this.shouldBeTranslated(danmaku.textContent) === false) return;/* seems not to be Chinese */
  360. if(this.dictionary[danmaku.textContent] === undefined){
  361. if(this.danmakuWaitings[danmaku.textContent] === undefined) this.danmakuWaitings[danmaku.textContent] = [];
  362. this.danmakuWaitings[danmaku.textContent].push(danmaku);
  363. }else{
  364. if(danmaku.textContent === this.dictionary[danmaku.textContent][0]) return;/* original and translation are the same */
  365. this.appendTranslation(danmaku, this.dictionary[danmaku.textContent][0]);
  366. }
  367. }
  368. appendTranslation(danmaku, translation){
  369. Translator.MODIFICATIONSKEYS.filter(key => {
  370. return danmaku.textContent.includes(key) && translation.includes(Translator.MODIFICATIONS[key][0]);
  371. }).forEach(key => {
  372. translation = translation.replace(Translator.MODIFICATIONS[key][0], Translator.MODIFICATIONS[key][1]);
  373. });
  374. danmaku.appendTranslation(translation);
  375. }
  376. shouldBeTranslated(textContent){
  377. switch(true){
  378. case(this.dictionary[textContent] !== undefined):/* has a translation */
  379. return true;
  380. case(textContent.match(REGEXP.hasKana) !== null):/* seems to be Japanese */
  381. case(textContent.match(REGEXP.allAlphabet) !== null):/* seems to be English */
  382. case(textContent.match(REGEXP.allEmoji) !== null):/* seems to be Emoji */
  383. return false;
  384. default:
  385. return true;
  386. }
  387. }
  388. save(){
  389. /* log usage statistics */
  390. let c = this.counters, saved = (((c.pushes - c.fails - c.registerTranslations)/((c.pushes - c.fails) || 1))*100).toFixed(0) + '%';
  391. log('Total danmaku:', c.pushes, 'Newly translated:', c.registerTranslations, 'Saved:', saved, 'Fails:', c.fails);
  392. /* save the dictionary and the history of latest HISTORYLENGTH pairs */
  393. let newDictionary = {}, newHistory = [];
  394. for(let i = this.history.length - 1, count = 0, now = Date.now(); 0 <= i; i--){
  395. if(this.dictionary[this.history[i]] === undefined){
  396. log('Unknown history', this.history[i]);
  397. continue;
  398. };
  399. if(this.dictionary[this.history[i]][2] < now - Translator.TRANSLATIONEXPIRED) continue;/* old data */
  400. if(newDictionary[this.history[i]] !== undefined) continue;
  401. newDictionary[this.history[i]] = this.dictionary[this.history[i]];
  402. newHistory[count] = this.history[i];
  403. if(count++ === Translator.HISTORYLENGTH) break;
  404. }
  405. /* keep the default dictionary */
  406. Object.keys(Translator.DICTIONARY).forEach(key => {
  407. newDictionary[key] = newDictionary[key] || Translator.DICTIONARY[key];
  408. });
  409. log('Dictionary length:', newHistory.length, 'Stored size:', toMetric(JSON.stringify(newDictionary).length * 2) + 'bytes');
  410. Storage.save('dictionary', newDictionary);
  411. Storage.save('history', newHistory.reverse());
  412. }
  413. }
  414. class Danmaku{
  415. constructor(danmaku){
  416. Danmaku.zIndex = Danmaku.zIndex || 1;
  417. this.element = danmaku;
  418. this.textContent = danmaku.textContent;
  419. this.modify();
  420. }
  421. modify(){
  422. this.element.style.zIndex = parseInt(this.element.style.zIndex || 0) + Danmaku.zIndex++;/* newer comments have priority */
  423. /* Make space for appending translation text */
  424. this.element.style.top = (() => {
  425. if(this.element.style.top === '') return;
  426. let operableContainer = elements.operableContainer, operableSpace = operableContainer ? site.get.operableSpace(operableContainer) : null;
  427. if(this.element.style.top[0] === '-' || operableSpace === null || operableSpace.children.length === 0 || operableSpace.style.height === ''){
  428. return (parseFloat(this.element.style.top) * 2) + 'px';
  429. }else{
  430. let height = parseFloat(operableSpace.style.height), top = parseFloat(this.element.style.top);
  431. return (height + ((top - height) * 2)) + 'px';
  432. }
  433. })();
  434. /* Even if double long translation text added, keep streaming to completely go away */
  435. this.element.style.transitionDuration = ((transitionDuration) => {
  436. if(transitionDuration === '') return;
  437. let m = transitionDuration.match(/([0-9.]+)(m?s)/);
  438. if(m === null) return log('Unknown transitionDuration format:', transitionDuration), transitionDuration;
  439. return (parseFloat(m[1]) * 2) + m[2];
  440. })(this.element.style.transitionDuration);
  441. this.element.style.transform = ((transform) => {
  442. if(transform === '') return;
  443. let m = transform.match(/(translateX?)\(([-0-9.]+)(px)/);
  444. if(m === null) return log('Unknown transform format:', transform), transform;
  445. return transform.replace(m[0], `${m[1]}(${parseFloat(m[2]) * 2}${m[3]}`);
  446. })(this.element.style.transform);
  447. }
  448. appendTranslation(translation){
  449. let span = createElement(core.html.translation(translation));
  450. this.element.appendChild(span);
  451. span.animate([{opacity: `0`},{opacity: `1`}], {duration: 500, fill: 'forwards'});
  452. this.element.addEventListener('transitionend', (e) => {
  453. span.animate([{opacity: `1`},{opacity: `0`}], {duration: 500, fill: 'forwards'});
  454. }, {once: true});
  455. }
  456. get hasTranslation(){
  457. /* bilibili removes previous translation element when the danmaku element has reused */
  458. return (this.element.querySelector('.translation') === null) ? false : true;
  459. }
  460. }
  461. let core = {
  462. initialize: function(){
  463. html = document.documentElement;
  464. html.classList.add(SCRIPTNAME);
  465. switch(true){
  466. case(location.href.match(/^https:\/\/www\.bilibili\.com\/video\/av[0-9]+/) !== null):
  467. site = sites.video;
  468. translator = new Translator();
  469. core.listenXMLHttpRequests();
  470. core.targetTranslation();
  471. core.readyForVideo();
  472. break;
  473. case(location.href.match(/^https:\/\/live\.bilibili\.com\/[0-9]+/) !== null):
  474. site = sites.live;
  475. translator = new Translator();
  476. core.listenWebSockets();
  477. core.targetTranslation();
  478. core.readyForLive();
  479. break;
  480. default:
  481. log('Bye.');
  482. break;
  483. }
  484. },
  485. readyForVideo: function(){
  486. if(document.hidden) return setTimeout(core.readyForVideo, 1000);
  487. core.getTargets(site.targets, RETRY).then(() => {
  488. log("I'm ready for Video.");
  489. core.translateUserInterface();
  490. core.setDanmakuSettings();
  491. core.observeVideoDanmaku();
  492. core.modifyDanmakuInput();
  493. core.addStyle();
  494. core.readyForUnload();
  495. core.exportLog();
  496. });
  497. },
  498. readyForLive: function(){
  499. if(document.hidden) return setTimeout(core.readyForVideo, 1000);
  500. core.getTargets(site.targets, RETRY).then(() => {
  501. log("I'm ready for Live.");
  502. core.translateUserInterface();
  503. core.observeVideoDanmaku();
  504. core.modifyDanmakuInput();
  505. core.addStyle();
  506. core.readyForUnload();
  507. core.exportLog();
  508. });
  509. },
  510. targetTranslation: function(){
  511. const setTranslate = function(element){
  512. element.classList.add('translate');
  513. element.translate = true;
  514. };
  515. const setNoTranslate = function(element){
  516. element.classList.add('notranslate');
  517. element.translate = false;
  518. };
  519. for(let i = 0, target; target = site.translationTargets[i]; i++){
  520. if(target[1]() === null) return setTimeout(core.targetTranslation, 1000);
  521. if(target[0] === true) setTranslate(target[1]());
  522. else setNoTranslate(target[1]());
  523. }
  524. },
  525. translateUserInterface: function(){
  526. translations = TRANSLATIONS[USERLANGUAGE] || TRANSLATIONS[USERLANGUAGE.substring(0, 2)] || TRANSLATIONS.en;
  527. /*置換したりobserveしたりする・・・かもしれない*/
  528. },
  529. listenXMLHttpRequests: function(){
  530. /* 公式の通信内容を取得 */
  531. window.XMLHttpRequest = new Proxy(XMLHttpRequest, {
  532. construct(target, arguments){
  533. const xhr = new target(...arguments);
  534. //log(xhr, arguments);
  535. xhr.addEventListener('load', function(e){
  536. if(xhr.responseURL.startsWith(VIDEOINFOAPI) === false) return;
  537. if(xhr.response[0] !== '{') return;
  538. videoInfo = JSON.parse(xhr.response).data;
  539. //log(videoInfo);
  540. core.getDanmakuList();
  541. });
  542. return xhr;
  543. }
  544. });
  545. },
  546. getDanmakuList: function(){
  547. let api = site.get.commentlistApi(videoInfo);
  548. fetch(api, {credentials: 'include', mode: 'cors'})
  549. .then(response => response.text())
  550. .then(text => new DOMParser().parseFromString(text, 'text/xml'))
  551. .then(d => {
  552. let ds = d.querySelectorAll('d');
  553. if(ds.length === 0) return log('Unknown danmaku format:', d);
  554. let danmakuContents = Array.from(ds).map(d => d.textContent);
  555. translator.pushAll(danmakuContents);
  556. });
  557. },
  558. listenWebSockets: function(){
  559. /* 公式の通信内容を取得 */
  560. window.WebSocket = new Proxy(WebSocket, {
  561. construct(target, arguments){
  562. const ws = new target(...arguments);
  563. //log(ws, arguments);
  564. if(ws.url.includes(CHATSERVER)) ws.addEventListener('message', function(e){
  565. let packet = new Packet(e.data);
  566. //log(packet.views, packet.messages);
  567. if(packet.isCommand === false) return;
  568. let danmakuContents = packet.getDanmakuContents();
  569. if(danmakuContents.length === 0) return;
  570. //log(danmakuContents.length, danmakuContents);
  571. translator.pushAll(danmakuContents);
  572. });
  573. return ws;
  574. }
  575. });
  576. },
  577. setDanmakuSettings: function(){
  578. if(elements.videoDanmaku.localName === 'canvas'){
  579. let danmakuSetting = elements.danmakuSetting;
  580. danmakuSetting.dispatchEvent(new MouseEvent('mouseover'));
  581. danmakuSetting.dispatchEvent(new MouseEvent('mouseout'));
  582. animate(function(){
  583. let danmakuTypeCSS = site.get.danmakuTypeCSS(danmakuSetting);
  584. if(danmakuTypeCSS) danmakuTypeCSS.click();
  585. else log('Can\'t find CSS3 setting.', danmakuSetting);
  586. });
  587. }
  588. },
  589. observeVideoDanmaku: function(){
  590. let videoDanmaku = elements.videoDanmaku;
  591. let observer = observe(videoDanmaku, function(records){
  592. //log(records);
  593. for(let i = 0; records[i]; i++){
  594. if(records[i].addedNodes.length === 0) continue;
  595. if(records[i].addedNodes[0].classList.contains('bilibili-danmaku') === false) continue;
  596. let danmaku = new Danmaku(records[i].addedNodes[0]);
  597. translator.requestTranslation(danmaku);
  598. observeDanmaku(danmaku);/*danmakuは再利用される!*/
  599. }
  600. });
  601. const observeDanmaku = function(danmaku){
  602. /* 再利用(新規弾幕としての生まれ変わり)を検知したい */
  603. let observer = observe(danmaku.element, function(records){
  604. if(danmaku.hasTranslation) return;/*再利用ではなく翻訳文追加だった*/
  605. danmaku = new Danmaku(danmaku.element);/*上書き*/
  606. translator.requestTranslation(danmaku);
  607. });
  608. };
  609. },
  610. modifyDanmakuInput: function(){
  611. /* 弾幕投稿内容を翻訳する機能を追加 */
  612. let danmakuInput = site.get.danmakuInput(), modifier = ISMAC ? 'metaKey' : 'ctrlKey';
  613. if(danmakuInput === null || danmakuInput.placeholder === undefined) return setTimeout(core.modifyDanmakuInput, 1000);/*属性付与が遅れる場合もあるので*/
  614. danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
  615. observe(danmakuInput, function(record){
  616. if(danmakuInput.placeholder.endsWith(translations.inputTranslationKey)) return;
  617. danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
  618. }, {attributes: true, attributeFilter: ['placeholder']});
  619. window.addEventListener('keydown', function(e){
  620. if(e.target !== danmakuInput) return;
  621. if(e.key === 'Enter' && e[modifier] === true){
  622. e.preventDefault();
  623. e.stopPropagation();
  624. danmakuInput.classList.add('translating');
  625. let api = TRANSLATOR.replace('{text}', danmakuInput.value).replace('{source}', USERLANGUAGE).replace('{target}', BILIBILILANGUAGE);
  626. fetch(api, {mode: 'cors'})
  627. .then(response => response.text())
  628. .then(text => {
  629. //log(text);
  630. danmakuInput.value = text;
  631. danmakuInput.dispatchEvent(new InputEvent('input'));/*実際の送信内容に反映させるために必要*/
  632. danmakuInput.classList.remove('translating');
  633. })
  634. .catch(error => {
  635. log('Error:', error);
  636. danmakuInput.classList.remove('translating');
  637. });
  638. }
  639. }, true);
  640. },
  641. readyForUnload: function(){
  642. window.addEventListener('unload', function(e){
  643. translator.save();
  644. });
  645. },
  646. exportLog: function(){
  647. if(DEBUG === false) return;
  648. window.translatorLog = translator.save.bind(translator);
  649. },
  650. getTargets: function(targets, retry = 0){
  651. const get = function(resolve, reject, retry){
  652. for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
  653. let selected = targets[key]();
  654. if(selected){
  655. if(selected.length) selected.forEach((s) => s.dataset.selector = key);
  656. else selected.dataset.selector = key;
  657. elements[key] = selected;
  658. }else{
  659. if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
  660. log(`Not found: ${key}, retrying... (left ${retry})`);
  661. return setTimeout(get, 1000, resolve, reject, retry);
  662. }
  663. }
  664. resolve();
  665. };
  666. return new Promise(function(resolve, reject){
  667. get(resolve, reject, retry);
  668. });
  669. },
  670. addStyle: function(name = 'style'){
  671. let style = createElement(core.html[name]());
  672. document.head.appendChild(style);
  673. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  674. elements[name] = style;
  675. },
  676. html: {
  677. priorDanmaku: () => `<ul id="${SCRIPTNAME}-prior-danmaku" class="translate" translate="yes"></ul>`,
  678. danmakuContent: (content) => `<li>${content}</li>`,
  679. translation: (translation) => `<span class="translation">${translation}</span>`,
  680. style: () => `
  681. <style type="text/css">
  682. /* bilibili color: #00A1D6 */
  683. ul#${SCRIPTNAME}-prior-danmaku{
  684. /* 画面内にないと自動翻訳されない */
  685. visibility: hidden;
  686. position: fixed;
  687. top: 0;
  688. padding: 0;
  689. margin: 0;
  690. white-space: nowrap;
  691. z-index: 9999;
  692. }
  693. ul#${SCRIPTNAME}-prior-danmaku li{
  694. position: absolute;
  695. }
  696. .translation{
  697. font-size: 75%;
  698. display: block;
  699. }
  700. .translating{
  701. opacity: .25;
  702. animation: ${SCRIPTNAME}-blink 250ms step-end infinite;
  703. }
  704. @keyframes ${SCRIPTNAME}-blink{
  705. 50%{opacity: .5}
  706. }
  707. </style>
  708. `,
  709. },
  710. };
  711. const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  712. const getComputedStyle = window.getComputedStyle, fetch = window.fetch;
  713. if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  714. class Storage{
  715. static key(key){
  716. return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
  717. }
  718. static save(key, value, expire = null){
  719. key = Storage.key(key);
  720. localStorage[key] = JSON.stringify({
  721. value: value,
  722. saved: Date.now(),
  723. expire: expire,
  724. });
  725. }
  726. static read(key){
  727. key = Storage.key(key);
  728. if(localStorage[key] === undefined) return undefined;
  729. let data = JSON.parse(localStorage[key]);
  730. if(data.value === undefined) return data;
  731. if(data.expire === undefined) return data;
  732. if(data.expire === null) return data.value;
  733. if(data.expire < Date.now()) return localStorage.removeItem(key);
  734. return data.value;
  735. }
  736. static delete(key){
  737. key = Storage.key(key);
  738. delete localStorage.removeItem(key);
  739. }
  740. static saved(key){
  741. key = Storage.key(key);
  742. if(localStorage[key] === undefined) return undefined;
  743. let data = JSON.parse(localStorage[key]);
  744. if(data.saved) return data.saved;
  745. else return undefined;
  746. }
  747. }
  748. const $ = function(s){return document.querySelector(s)};
  749. const $$ = function(s){return document.querySelectorAll(s)};
  750. const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  751. const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  752. const createElement = function(html = '<span></span>'){
  753. let outer = document.createElement('div');
  754. outer.innerHTML = html;
  755. return outer.firstElementChild;
  756. };
  757. const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
  758. let observer = new MutationObserver(callback.bind(element));
  759. observer.observe(element, options);
  760. return observer;
  761. };
  762. const atLeast = function(min, b){
  763. return Math.max(min, b);
  764. };
  765. const atMost = function(a, max){
  766. return Math.min(a, max);
  767. };
  768. const between = function(min, b, max){
  769. return Math.min(Math.max(min, b), max);
  770. };
  771. const toMetric = function(number, decimal = 1){
  772. switch(true){
  773. case(number < 1e3 ): return (number);
  774. case(number < 1e6 ): return (number/1e3 ).toFixed(decimal) + 'K';
  775. case(number < 1e9 ): return (number/1e6 ).toFixed(decimal) + 'M';
  776. case(number < 1e12): return (number/1e9 ).toFixed(decimal) + 'G';
  777. default: return (number/1e12).toFixed(decimal) + 'T';
  778. }
  779. };
  780. const log = function(){
  781. if(!DEBUG) return;
  782. let l = log.last = log.now || new Date(), n = log.now = new Date();
  783. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  784. //console.log(error.stack);
  785. console.log(
  786. SCRIPTNAME + ':',
  787. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  788. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  789. /* :00 */ ':' + line,
  790. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  791. /* caller */ (callers[1] || '') + '()',
  792. ...arguments
  793. );
  794. };
  795. log.formats = [{
  796. name: 'Firefox Scratchpad',
  797. detector: /MARKER@Scratchpad/,
  798. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  799. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  800. }, {
  801. name: 'Firefox Console',
  802. detector: /MARKER@debugger/,
  803. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  804. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  805. }, {
  806. name: 'Firefox Greasemonkey 3',
  807. detector: /\/gm_scripts\//,
  808. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  809. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  810. }, {
  811. name: 'Firefox Greasemonkey 4+',
  812. detector: /MARKER@user-script:/,
  813. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  814. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  815. }, {
  816. name: 'Firefox Tampermonkey',
  817. detector: /MARKER@moz-extension:/,
  818. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  819. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  820. }, {
  821. name: 'Chrome Console',
  822. detector: /at MARKER \(<anonymous>/,
  823. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  824. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  825. }, {
  826. name: 'Chrome Tampermonkey',
  827. detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/,
  828. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
  829. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  830. }, {
  831. name: 'Chrome Extension',
  832. detector: /at MARKER \(chrome-extension:/,
  833. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  834. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  835. }, {
  836. name: 'Edge Console',
  837. detector: /at MARKER \(eval/,
  838. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  839. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  840. }, {
  841. name: 'Edge Tampermonkey',
  842. detector: /at MARKER \(Function/,
  843. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  844. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  845. }, {
  846. name: 'Safari',
  847. detector: /^MARKER$/m,
  848. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  849. getCallers: (e) => e.stack.split('\n'),
  850. }, {
  851. name: 'Default',
  852. detector: /./,
  853. getLine: (e) => 0,
  854. getCallers: (e) => [],
  855. }];
  856. log.format = log.formats.find(function MARKER(f){
  857. if(!f.detector.test(new Error().stack)) return false;
  858. //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
  859. return true;
  860. });
  861. const time = function(label){
  862. if(!DEBUG) return;
  863. const BAR = '|', TOTAL = 100;
  864. switch(true){
  865. case(label === undefined):/* time() to output total */
  866. let total = 0;
  867. Object.keys(time.records).forEach((label) => total += time.records[label].total);
  868. Object.keys(time.records).forEach((label) => {
  869. console.log(
  870. BAR.repeat((time.records[label].total / total) * TOTAL),
  871. label + ':',
  872. (time.records[label].total).toFixed(3) + 'ms',
  873. '(' + time.records[label].count + ')',
  874. );
  875. });
  876. time.records = {};
  877. break;
  878. case(!time.records[label]):/* time('label') to create and start the record */
  879. time.records[label] = {count: 0, from: performance.now(), total: 0};
  880. break;
  881. case(time.records[label].from === null):/* time('label') to re-start the lap */
  882. time.records[label].from = performance.now();
  883. break;
  884. case(0 < time.records[label].from):/* time('label') to add lap time to the record */
  885. time.records[label].total += performance.now() - time.records[label].from;
  886. time.records[label].from = null;
  887. time.records[label].count += 1;
  888. break;
  889. }
  890. };
  891. time.records = {};
  892. core.initialize();
  893. if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
  894. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址