Bilibili Danmaku Translator

使用 Google Chrome 的翻译工具,自动翻译 bilibili 的用户评论(弹幕)。

目前为 2019-12-03 提交的版本。查看 最新版本

  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://www\.bilibili\.com/bangumi/play/[a-z0-9]+/
  11. // @include /^https://live\.bilibili\.com/[0-9]+/
  12. // @include /^https://live\.bilibili\.com/blanc/[0-9]+/
  13. // @version 2.3.0
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako_inflate.min.js
  15. // @grant none
  16. // ==/UserScript==
  17.  
  18. (function(){
  19. const SCRIPTNAME = 'BilibiliDanmakuTranslator';
  20. const DEBUG = false;/*
  21. [update] 2.3.0
  22. 公式番組(www.bilibili.com/bangumi/...)に対応。
  23.  
  24. [bug]
  25. だはん・だはんからの直播じゃ効いてないかも?
  26. length 50001
  27. videoで? Uncaught TypeError: Cannot read property 'cid' of undefined
  28.  
  29. [to do]
  30. 動画タイトルとか下部のコメントとかデフォルトの翻訳適用したい
  31. 原文を即titleに突っ込んでおけばスマートに解決?
  32. window.topあたりを精査
  33. かなり遅れて翻訳が届くことがあるので弾幕要素の再利用でwaitingsをリセット
  34.  
  35. [to research]
  36. Chrome翻訳負荷制限
  37. キューはクリアしない方針?遅れた翻訳は意義薄い?
  38. 文字列の長さの可能性?
  39. Chromeがサボるだけなら自家製クエリに手を出す手も?
  40. Chromeがどんどん反応を遅くしていった?
  41. 新語に対する複数回クエリなど謎の挙動?
  42. 右の一覧内でも特殊案内は訳したいかも
  43. 主要UI要素を指定翻訳語として登録しておきたい
  44. 動的に生成される要素の対応がめんどくさい
  45. 自分のコメントの翻訳時も逆辞書で節約と蓄積?
  46. 日本語と英語は翻訳しない方針で問題ないよね?
  47. Google翻訳の一般Webユーザーのフリをして各ユーザーにAPIを叩かせる手もあるようだが
  48. https://github.com/andy-portmen/Simple-Translate/blob/master/src/lib/common.js
  49. それが許されるならBaiduのAPIを叩かせることも可能?
  50. 翻訳辞書を共有サーバーに溜め込む仕組み?
  51. pako.deflate + TextDecoder でdictionaryを無理やり圧縮して保存できる?
  52. 動画のタイトル下に翻訳を挿入したいね。
  53. MODIFICATIONSはたまに翻訳の確認が必要。
  54. MODIFICATIONSの活用率も確認したい。
  55.  
  56. [memo]
  57. 1. 翻訳辞書構築の流れ
  58. 1-1. core.listenWebSocketsで弾幕テキストを取得(要素出現より1秒ほど早く取得できる)
  59. 1-2. Translatorに弾幕テキストを登録
  60. 1-3. TranslatorがpriorDanmaku要素に弾幕テキスト要素を設置
  61. 1-4. Chromeが弾幕テキスト要素を自動翻訳してくれる
  62. 1-5. Translatorが察知して辞書として登録
  63.  
  64. 2. 弾幕訳文追加の流れ
  65. 2-1. core.observeVideoDanmakuで弾幕要素を発見
  66. 2-2. Danmakuインスタンスを作成してTranslatorに登録
  67. 2-3. 弾幕テキストに一致する辞書がすでにあればすぐに訳文を追加
  68. 2-4. なければ1-5.のタイミングで訳文を追加
  69.  
  70. 3. 自分の投稿コメント翻訳
  71. Google Apps Script (推定1日7000回(=1回5文字で月100万文字相当)を超えたあたりで制限がかかる)
  72. https://qiita.com/tanabee/items/c79c5c28ba0537112922
  73. */
  74. if(window === top && console.time) console.time(SCRIPTNAME);
  75. const NOW = Date.now();
  76. const ISMAC = (window.navigator.userAgent.match(/Mac/) !== null);
  77. const PLAYERAPI = 'https://api.bilibili.com/x/player.so';/*cid取得用*/
  78. const COMMENTLISTAPI = 'https://comment.bilibili.com/{cid}.xml';/*動画用*/
  79. const CHATSERVER = 'chat.bilibili.com';/*直播用*/
  80. const TRANSLATOR = 'https://script.google.com/macros/s/AKfycby29iFLZ742UEC6TlN8-b4Dxtlu_7XYbVeo2GgiYVWMtuzIcbA/exec?text={text}&source={source}&target={target}';
  81. const TRANSLATIONSATONCE = 64;/*同時最大翻訳リクエスト数(Chrome翻訳負荷の低減)*/
  82. const TRANSLATIONSINTERVAL = 1000;/*最短翻訳リクエスト間隔(ms)(Chrome翻訳負荷の低減)*/
  83. const HISTORYLENGTH = 50000;/*辞書の最大保持数(5万で5MB見込み)*/
  84. const TRANSLATIONEXPIRED = 90*24*60*60*1000;/*翻訳の有効期限(翻訳精度の改善に期待する)*/
  85. const WAITING_LIMIT = 10*1000;/* Chrome翻訳の待機時間(ms)(過負荷時には実質休憩時間となる) */
  86. const BILIBILILANGUAGE = 'zh-CN';
  87. const USERLANGUAGE = window.navigator.language;
  88. const TRANSLATIONS = {
  89. ja: {
  90. inputTranslationKey: ISMAC ? '(Command+Enterで翻訳)' : '(Ctrl+Enterで翻訳)',
  91. },
  92. en: {
  93. inputTranslationKey: ISMAC ? '(Command+Enter to translate)' : '(Ctrl+Enter to translate)',
  94. },
  95. };
  96. const DICTIONARIES = {
  97. ja: {/* original: [translation, count, created] */
  98. '哔哩哔哩 (゜-゜)つロ 干杯~': ['ビリビリ (゜-゜)つロ 乾杯~', 0, NOW],
  99. },
  100. en: {
  101. '哔哩哔哩 (゜-゜)つロ 干杯~': ['bilibili (゜-゜)つロ cheers~', 0, NOW],
  102. },
  103. };
  104. const MODIFICATIONS = {/* およそ5000件で1ms (Core i7-3740QM) */
  105. /* '単語': [/誤訳(削除する)/, '適訳(挿入する)'] */
  106. ja: {
  107. // 日本語
  108. '发言': [/話す|スピーチ|スピーキング|ステートメント/, '発言'],
  109. '残念': [/残り/, '残念'],
  110. '干杯': [/トースト|干杯|乾杯/, '乾杯'],
  111. '乾杯': [/トースト/, '乾杯'],
  112. '万岁': [/長生き(する|させる)?|ロングライブ/, '万歳'],
  113. '大丈夫': [/夫(ですか)?/, '大丈夫'],
  114. '正解': [/ポジティブソリューション/, '正解'],
  115. '無駄': [/イノセント|無実/g, '無駄'],
  116. '草': [/グラス|カオ/, '草'],
  117. '有能': [/エネルギーを持っている/, '有能'],
  118. '神回': [/神が戻ってきた|神様/, '神回'],
  119. '全裸待机': [/フルヌードスタンバイ/, '全裸待機'],
  120. '完全一致': [/完全に一貫性のある|完全に一貫しています/, '完全に一致'],
  121. '上手上手': [/手をつないで/, '上手上手'],
  122. '上手': [/はじめに|始めましょう/, '上手'],
  123. '清楚清楚': [/クリアとクリア/, '清楚清楚'],
  124. '清楚': [/クリア|明らか|明確/, '清楚'],
  125. '理解理解': [/理解(を|し)理解する/, '理解理解'],
  126. '余裕余裕': [/ゆうゆうゆうゆう/, '余裕余裕'],
  127. '兽耳': [/獣(の)?耳|動物の耳|獣|耳|ビーストイヤー/, 'ケモミミ'],
  128. '幻听': [/錯視|錯覚|聴覚幻覚|黙る|ありがとう|イリュージョン|イルルイン|イルリング|(オーディオ)?オーディション|ファンタジー|Illuing/, '幻聴'],
  129. '幻视': [/ファントム|マジック|魔法/, '幻視'],
  130. '错乱': [/(無秩序(に)?|乱雑|疾患|障害|カオス)/, '錯乱'],
  131. '混乱': [/カオス|混沌/, '混乱'],
  132. '认真': [/(本気|真剣に)/, '迫真'],
  133. '确信': [/(確認済み|信じ(る|て|ます)|納得|確信してい(る|ます))/, '確信'],
  134. '狂喜': [/エクスタシー/, '狂喜'],
  135. '震声': [/衝撃|ショック/, '震え声'],
  136. '棒读': [/(素晴らしい|良い|スティック|棒)(読書|リーディング)/, '棒読み'],
  137. '野生': [/ワイルド/, '野生'],
  138. '字幕组': [/字幕グループ/, '字幕組'],
  139. '字幕': [/キャプション/, '字幕'],
  140. '君中国语本当上手': [/6月中国語は始めるための方法です/, '君中国語本当上手'],
  141. '君日本语本当上手': [/6月日本語は始める方法です/, '君日本語本当上手'],
  142. // 中国語
  143. '晚上好': [/おやすみ(なさい)?|おはよう|夜(が|は)(うまい|得意|良い|いい)(です|ね)?/g, 'こんばんは'],
  144. '帅': [/ハンサム(な)?/g, 'カッコイイ'],
  145. '大人': [/(?<!あの|この)(の)?(大人|成人|おとな|テーブル)/g, 'さま'],
  146. '老大': [/上司|ボス/, '老大'],
  147. '加油': [/さあ|さて|燃料補給|燃料を供給|給油|来て|来ます|歓声(を上げる)?|フューエル|ジアオイル|jiao/g, 'がんばれ'],
  148. '厉害厉害': [/非常に強力/, 'すごいすごい'],
  149. '厉害': [/強力(な)?/, 'すごい'],
  150. '表白': [/エクスプレス|(の)?告白$|白|(を)?表現する|Express/, 'すき'],
  151. '来了来了': [/ここに来(る|て)/, '来ました'],
  152. '来了': [/(出て|やって)?(来|く|き)(る|て(います|いる)?|た)|さあ/, '来ました'],
  153. '辛苦了': [/ハードワーク|勤勉|(一生)?懸命(に)?働い(た|ている|ています)|大変(な作業)?です|難しい|つらい/, 'おつかれさま'],
  154. '辛苦啦': [/ハードワーク|勤勉|(一生)?懸命(に)?働い(た|ている|ています)|大変(な作業)?です|難しい|つらい/, 'おつかれさま'],
  155. '辛苦辛苦': [/ハードワーク|勤勉|(一生)?懸命(に)?働い(た|ている|ています)|大変(な作業)?です|難しい|つらい/, 'おつかれさま'],
  156. '冲冲冲': [/急いで/, 'いけいけいけ'],
  157. '见证历史': [/証人の歴史/, '歴史の証人'],
  158. '喷麦': [/スプレー|小麦散布/, '吐息音'],
  159. '配音': [/ダビング|Dubbing|声/, '吹き替え'],
  160. '眨眼': [/点滅/, 'まばたき'],/*ウィンクの場合もあるが点滅よりはマシ*/
  161. 'up主': [/(アップ|up)(マスター|メイン)?|メイン|主/i, 'うp主'],
  162. 'Up主': [/(アップ|up)(マスター|メイン)?|メイン|主/i, 'うp主'],
  163. 'UP主': [/(アップ|up)(マスター|メイン)?|メイン|主/i, 'うp主'],
  164. '主播': [/アンカー|オーナー/, '配信主'],
  165. '虚拟': [/(虚偽|虚似|虚俗|偽|仮想)(の)?/, 'バーチャル'],
  166. '穿模': [/金型/, 'モデル'],
  167. '安全裤': [/安全(ズボン|パンツ)/, 'スパッツ'],
  168. // 当て字
  169. '欧拉': [/(オイラー|オウラ)(、)?/g, 'オラ'],
  170. '木大': [/大きな木|ウッドビッグ/g, '無駄'],
  171. '赛高': [/サイガオ|さいがお|試合高|高(いです)?/, '最高'],
  172. '拜拜': [/さようなら|^バイ$/, 'バイバイ'],
  173. '奶思': [/ミルク思考/, 'ナイス'],
  174. '奶声': [/ミルクの声/, 'ナイス'],
  175. '奶死': [/ミルクデス|(牛乳|ミルク)(が|は)死ん(だ|でいる|でいます)/, 'ナイスデス'],
  176. '牙白': [/歯の白|白い歯|白|ホワイト/, 'やばい'],
  177. '纳尼': [/なな|にに|Nani/ig, 'なに'],
  178. '贴贴': [/ステッカー|それを固執する|投稿|貼付/, 'てぇてぇ'],
  179. '干巴爹': [/ドライ/, 'ガンバレ'],
  180. '霓虹金': [/ネオンゴールド/, 'ニホンジン'],
  181. '斯哈斯哈': [/ハシャ|シハハ|Sihasha/, 'スハスハ'],
  182. '嘶哈嘶哈': [/ヒップホップヒップホップ/, 'スハスハ'],
  183. '昆卡昆卡': [/クエンカクエンカ/, 'クンカクンカ'],
  184. '压力马斯内': [/突然の圧力|プレッシャー(マス(ヌ|ネ)|スニーク)/, 'やりますねぇ'],
  185. '压力马斯奈': [/(プレッシャー|圧力)マスネイ/, 'やりますねぇ'],
  186. '亚拉那一卡': [/ヤラナ(カード)?|Yalanaからのカード/, 'やらないか'],
  187. '妮可妮可妮': [/ニコールニコール/, 'にっこにっこにー'],
  188. // スラング
  189. '谢谢茄子': [/(ナス|茄子)(を)?ありがとう/, 'ありがとナス'],
  190. '謝謝茄子': [/(ナス|茄子)(を)?ありがとう/, 'ありがとナス'],
  191. '太草': [/草が多すぎる/, '大草原'],
  192. '鬼畜': [/ゴースト(アニマル)?|幽霊/, 'MAD'],
  193. '沙雕': [/砂(の)?彫刻|サンドカービング/, 'あほ'],
  194. '口区': [/口の面積|口地域|口元エリア/, 'ヴォエ'],
  195. '手冲': [/ハンドパンチ/, '手コキ'],
  196. '冲国': [/チョング(オ|ァ)|チョン(オ)?|Chongguo|忠国|急ぐ国/, 'チューゴク'],
  197. '康康': [/カンカン(させて(ください)?|が欲しい)/, 'ほら見せろよ'],
  198. '人类一败涂地': [/男は敗北|人間は敗北します|敗北した人間/, '人類は一敗地にまみれた'],
  199. '牛逼': [/ニウビ|ニー|強気|ヤク/, 'すごい'],
  200. '棒棒哒': [/スティック|すごい/, 'しゅごい'],
  201. '白给': [/ホワイト(ギブ|がくれた)?|白/, '無駄死に'],
  202. '什么鬼': [/何ゴースト|なんて幽霊/, 'なんだこれ'],
  203. '单推': [/(シングル|ワン)?(プッシュ|クリック)|单推|片押し/, '単推し'],
  204. '烤肉': [/バーベキュー|肉のグリル/, '焼肉'],
  205. '石油佬': [/油(いかだ)?|いかだ|オイル(佬)?/, '石油王'],
  206. '大佬': [/ダクシー|ダキシー|ダラット|ダウェイ|Daxie|(大|ビッグ)(ショー|big|佬)|大きい|ビッグ|大学ビル/, 'ニキ'],
  207. 'C位': [/C(ビット)?/i, 'センター'],
  208. 'c位': [/C(ビット)?/i, 'センター'],
  209. '打Call': [/(お)?電話(をかける|する|ください)|呼ぶ|コール/, 'コールする'],
  210. '打call': [/(お)?電話(をかける|する|ください)|呼ぶ|コール/, 'コールする'],
  211. // 超意訳
  212. '酸死了': [/痛い/, '裏山死'],
  213. '酸了': [/(私は)?酸っぱい(です)?/, '裏山'],
  214. '才八点': [/たった8(時|つ)/, 'はえーよ'],
  215. '才8点': [/たった8(時|つ)/, 'はえーよ'],
  216. '光速下播': [/光速で放送する/, '急にオワタ'],
  217. '火钳刘明': [/((火)?トング|ファイアークランプ)劉明/g, '記念カキコ'],
  218. '疑车无据': [/証拠がないと疑われる車|車は根拠のないですか/, '下ネタ疑惑'],
  219. '前方高能': [/(先に|今後の)?高エネルギー(先)?(警告)?/, 'くるぞ'],
  220. '这不是演习': [/これは練習では(ありません|ないことに注意してください)/, 'これは演習ではない'],
  221. // 翻訳不能\(^o^)/
  222. '臣卜木曹': [/(チェン・ブカオ|Chen Bumu Cao)/, '(゚Д゚)'],
  223. '卧槽...': [/(横になって(いる)?|横)?トラフ(に横たわって|に横になっています)?/, '(゚Д゚)...'],
  224. '卧槽': [/(横になっている)?トラフ(に横たわって|は|で|の)?|横になって|横溝/, '(゚Д゚)!?'],
  225. // awsl
  226. '阿伟死了': [/魏は死ん(でいる|だ)/, 'awsl'],
  227. '阿伟输了': [/魏は失った/, 'awsl'],
  228. '阿伟爽了': [/魏がかっこいい/, 'awsl'],
  229. '阿伟射了': [/ウェイショット/, 'awsl'],
  230. '阿伟少林': [/魏少林寺/, 'awsl'],
  231. '啊我睡了': [/ああ、私は寝ました。/, 'awsl'],
  232. '爱我苏联': [/(私を愛して)?ソビエト連邦(を愛してください)?/, 'awsl'],
  233. '奥维丝丽': [/オビスリ/, 'awsl'],
  234. '阿伟乱葬岗': [/アウェイマスグレイブ/, 'awsl墓地'],
  235. '阿伟乱葬场': [/魏古墳/, 'awsl墓地'],
  236. // 固有名詞
  237. '谷酱': [/谷/, 'グーグルちゃん'],
  238. 'goo酱': [/グーソース/, 'Googleちゃん'],
  239. '油管': [/(オイル|(石)?油)パイプ|管|チューブ|チュービング/, 'YouTube'],
  240. '京阿尼': [/ジン(・)?アニ|ジンガニ|ジンギ|キナーニ|Jing Ani|Jingani|京阿尼|京安尼|神谷/, '京アニ'],
  241. '诸葛孔明': [/Zhuge Kongming|ジュージュコミング/, '諸葛孔明'],
  242. '孔明': [/Kong Ming|コミング|コングミン/, '孔明'],
  243. '奥特曼': [/アルトマン|Altman/, 'ウルトラマン'],
  244. // キズナアイ
  245. '绊爱酱': [/ラブソース|ソースが大好き(です)?/, 'キズナアイちゃん'],
  246. '爱酱': [/(ラブ)?ソース|Love Sauce/g, 'アイちゃん'],
  247. // 白上フブキ
  248. '白上吹雪': [/白で吹(く)?雪|白雪姫/g, '白上フブキ'],
  249. '吹雪酱': [/吹雪ソース/g, 'フブキちゃん'],
  250. // リブドル
  251. '战斗吧歌姬': [/戦う、歌手|歌手と戦(う|ってください)|歌手との戦い/, 'リブドル'],
  252. 'Swan': [/白鳥/, 'スワン'],
  253. 'swan': [/白鳥/, 'スワン'],
  254. '罗兹': [/ロードス(島)?|ウッチ|Rhodes|Roz/ig, 'ローズ'],
  255. '神宫司': [/神社|神宮課|神宮シー/g, '神宮司'],
  256. '玉藻': [/ゆう(ざお|ぞう)|遊蔵|翡翠(藻)?|玉藻|玉|藻|湯田|遊戯王|裕蔵|湯蔵|ug尾|Yuzao|Yugao/g, '玉藻'],
  257. '清歌': [/清歌|(Li )?Qingge|(清の)?歌|(クリアな)?(曲|歌)|(クリア)?ソング|クリア|チンゲ/g, '清歌'],
  258. '清哥': [/清歌|Qingge|清の(兄弟|兄|弟)|チンゲ/g, '清歌'],
  259. '墨汐': [/インク(兄弟)?|モク|墨汐|Mo (Yan|Zhen)|Moxi/g, 'モーシィ'],
  260. '卡缇娅': [/ケイヤ|カヤ|キャシー|Kayya|Katya|缇卡缇娅|缇卡娅娅/ig, 'カティア'],
  261. '鸭鸭': [/アヒル(と|や|に)アヒル|アヒル|あひる/g, '鴨鴨'],
  262. '伊莎贝拉': [/イザベラ/g, 'イザベラ'],
  263. '贝拉拉': [/ベララ|ベッラーラ|Bellala/g, 'ベララ'],
  264. // Overidea
  265. '张京华': [/張晶華|チャン静華|チャンジンファ|(Zhang )?Jinghua/, '張京華'],
  266. '京华': [/晶華|景華|清華|ジンファ|Jinghua/, '京華'],
  267. '谢拉': [/シ(ー)?ラ|シェ(イ)?ラ|セラ|ゼラ|Sheila|Shera|Sierra|Xie( La)?|謝(La|ラ)?|谢拉/i, 'シエラ'],
  268. '謝拉': [/シ(ー)?ラ|シェ(イ)?ラ|セラ|ゼラ|Sheila|Shera|Sierra|Xie( La)?|謝(La|ラ)?|谢拉/i, 'シエラ'],
  269. '米娅': [/アミア|Mia/i, 'ミア'],
  270. // 織田信姫
  271. '信姬': [/Xin Ji|Xinji|新(地|自)/i, '信姫'],
  272. // 朝ノ姉妹
  273. '瑠璃': [/(着色)?ガラス|Liuli/i, '瑠璃'],
  274. // 神楽めあ
  275. 'mea酱': [/(ミート|ミース)ソース/ig, 'めあちゃん'],
  276. // 湊あくあ
  277. '阿库娅': [/アクア|あくや|阿久谷|Akuya|Akua/ig, 'あくあ'],
  278. // 猫宮ひなた
  279. '猫宫酱': [/猫(の)?宮殿ソース/ig, '猫宮ちゃん'],
  280. '猫宫': [/猫(の)?宮殿|キャットパレス|Cat Palace/ig, '猫宮'],
  281. '貓宮': [/猫(の)?宮殿|キャットパレス|Cat Palace/ig, '猫宮'],
  282. 'Hinata酱': [/(ひなた|日向|Hinata)(ソース|醤油)/ig, 'ひなたちゃん'],
  283. 'hinata酱': [/(ひなた|日向|Hinata)(ソース|醤油)/ig, 'ひなたちゃん'],
  284. 'HINATA酱': [/(ひなた|日向|Hinata)(ソース|醤油)/ig, 'ひなたちゃん'],
  285. // 物述有栖
  286. '爱丽丝酱': [/アリスソース/g, 'ありすちゃん'],
  287. // Paryi
  288. '迷迭迷迭': [/ローズマリー/, '見て見て'],
  289. '帕里桑': [/パリサン|プリッサン|パリジャン|Parrysan/g, 'Paryiさん'],
  290. '帕里': [/パリ(ー)?|Parry/g, 'Paryi'],
  291. 'Paryi': [/パリリ/g, 'Paryi'],
  292. 'paryi': [/パリリ/g, 'Paryi'],
  293. // 米米米
  294. '米米米': [/ミミ/, '米米米'],
  295. '光姬': [/広([智義治地域島姫])|光栄|姫路|グァンジ|ガンジー|ジジ|ジグ|チジ|Guang( )?Ji|Gigi Hime/ig, 'つや姫'],
  296. '光酱': [/(ライト|軽い)ソース/, 'つやちゃん'],
  297. '萌实里': [/メン(シリ|グリ)|Meng Shili/, '萌えみのり'],
  298. 'Milky女王': [/(ミルキー|ミルクの|牛乳)女王/, 'ミルキークイーン'],
  299. 'milky女王': [/(ミルキー|ミルクの|牛乳)女王/, 'ミルキークイーン'],
  300. 'Milky公主': [/プリンセスミルキー|ミルキープリンセス|乳白色の王女/, 'ミルキークイーン'],
  301. 'milky公主': [/プリンセスミルキー|ミルキープリンセス|乳白色の王女/, 'ミルキークイーン'],
  302. // 洛天依
  303. '洛天依': [/羅天(一)?|Luotianyi/, '洛天依'],
  304. // Siva
  305. 'Siva小虾鱼': [/シバエビ|Siva Shrimp Fish/, 'Sivaえび'],
  306. 'siva小虾鱼': [/シバエビ|Siva Shrimp Fish/, 'Sivaえび'],
  307. '虾虾': [/えび(と|や|、)えび|エビ(と|や|、)エビ|海老(と|や|、)海老|Shrimp and Shrimp|えび|エビ/, 'えびえび'],
  308. '雪风': [/雪風|雪の風|雪|風|スノーウィンド/, '雪風'],
  309. // ビリビリ
  310. '哔哩哔哩': [/(哔)+(哩)+|こんにちは|ビ(ブ)?リリ/, 'ビリビリ'],
  311. 'bilibili': [/Bilibili|ビリビリ|ビ(ブ)?リリ/, 'bilibili'],
  312. 'B站': [/駅B|B駅|(B|ビー)ステーション|ステーションB|駅/i, 'ビリビリ'],
  313. 'b站': [/駅b|b駅|(b|ビー)ステーション|ステーションB|駅/i, 'ビリビリ'],
  314. '舰长': [/船長|キャプテン/, '艦長'],
  315. '提督': [/提督/, '提督'],
  316. '总督': [/総督|知事/, '総督'],
  317. '友爱社': [/愛の友達|友情協会/, '友愛社'],
  318. '粉丝勋章': [/ファンメダル/, 'ファンバッジ'],
  319. '金瓜子': [/(ゴールデン|金)メロンの種/, '金瓜子'],
  320. '银瓜子': [/(シルバー|銀)メロンの種/, '銀瓜子'],
  321. '瓜子': [/メロンの種/, '瓜子'],
  322. '辣条': [/(スパイシー|ホット|辛い)(な)?(ストリップ|バー|食べ物)/, '辣条'],
  323. '关注': [/注意|注目|懸念|心配(されている|する)/, 'フォロー'],
  324. // 禁断の一字置換
  325. '酱': [/ソース/g, 'ちゃん'],
  326. },
  327. en: {
  328. '草': [/grass/, 'lol'],
  329. },
  330. };
  331. const REGEXP = {
  332. hasKana: /[ぁ-んァ-ン]/,
  333. allAlphabet: /^[a-zA-Z0-9,.'"!?\s]+$/,
  334. allEmoji: /^(\ud83c[\udf00-\udfff]|\ud83d[\udc00-\ude4f]|\ud83d[\ude80-\udeff]|\ud7c9[\ude00-\udeff]|[\u2600-\u27BF])+$/,
  335. };
  336. const RETRY = 10;
  337. let sites = {
  338. video: {
  339. targets: {
  340. },
  341. translationTargets: [
  342. [false, () => $('title')],
  343. [false, () => $('body')],
  344. ],
  345. get: {
  346. videoDanmaku: () => $('.bilibili-player-video-danmaku'),/* div or canvas */
  347. commentlistApi: (cid) => COMMENTLISTAPI.replace('{cid}', cid),
  348. cid: (url) => /cid:[0-9]+/.test(url) ? url.match(/cid:([0-9]+)/)[1] : '0',
  349. danmakuInput: () => $('input.bilibili-player-video-danmaku-input'),
  350. },
  351. set: {
  352. danmakuTypeCSS: (callback) => {
  353. let danmakuSetting = $('.bilibili-player-video-danmaku-setting');
  354. if(danmakuSetting === null) return log('danmakuSetting not found.');
  355. danmakuSetting.dispatchEvent(new MouseEvent('mouseover'));
  356. danmakuSetting.dispatchEvent(new MouseEvent('mouseout'));
  357. animate(function(){
  358. let danmakuTypeCSS = $('li.bui-select-item[data-value="div"]');
  359. if(danmakuTypeCSS === null) return log('Can\'t find CSS3 setting.');
  360. danmakuTypeCSS.click();
  361. log('Set to CSS3.');
  362. callback();
  363. });
  364. },
  365. },
  366. },
  367. bangumi: {
  368. targets: {
  369. },
  370. translationTargets: [
  371. [false, () => $('title')],
  372. [false, () => $('body')],
  373. ],
  374. get: {
  375. videoDanmaku: () => $('.bilibili-player-video-danmaku'),/* div or canvas */
  376. commentlistApi: (cid) => COMMENTLISTAPI.replace('{cid}', cid),
  377. cid: (url) => /cid:[0-9]+/.test(url) ? url.match(/cid:([0-9]+)/)[1] : '0',
  378. danmakuInput: () => $('input.bilibili-player-video-danmaku-input'),
  379. },
  380. set: {
  381. danmakuTypeCSS: (callback) => {
  382. let danmakuSetting = $('.bilibili-player-setting-btn');
  383. if(danmakuSetting === null) return log('danmakuSetting not found.');
  384. danmakuSetting.click();
  385. animate(function(){
  386. let danmakuSettingClose = $('.bilibili-player-setting .bilibili-player-panel-back');
  387. let danmakuType = $('.bilibili-player-setting-type');
  388. if(danmakuType === null) return log('Can\'t find danmakuType setting.');
  389. danmakuType.click();
  390. animate(function(){
  391. let danmakuTypeCSS = $('li.bpui-selectmenu-list-row[data-value="div"]');
  392. if(danmakuTypeCSS === null) return log('Can\'t find CSS3 setting.');
  393. danmakuTypeCSS.click();
  394. danmakuSettingClose.click();
  395. log('Set to CSS3.');
  396. callback();
  397. });
  398. });
  399. },
  400. },
  401. },
  402. live: {
  403. targets: {
  404. operableContainer: () => $('.bilibili-live-player-video-operable-container'),/*特殊弾幕枠*/
  405. chatHistoryList: () => $('#chat-history-list'),
  406. chatActions: () => $('#chat-control-panel-vm .bottom-actions'),
  407. },
  408. translationTargets: [
  409. [false, () => $('title')],
  410. [false, () => $('body')],
  411. [ true, () => $('.bilibili-live-player-video-controller')],/*プレイヤ内コントローラ*/
  412. [ false, () => $('.bilibili-live-player-video-controller-duration-btn > div > span')],
  413. [ true, () => $('#chat-control-panel-vm')],/*投稿欄内コントローラ*/
  414. [ false, () => $('#chat-control-panel-vm .bottom-actions')],
  415. ],
  416. get: {
  417. videoDanmaku: () => $('.bilibili-live-player-video-danmaku'),
  418. operableSpace: (operableContainer) => operableContainer.querySelector('#pk-vm ~ div[style*="height:"]'),
  419. danmakuInput: () => $('textarea.chat-input'),/*divからtextareaに置換される*/
  420. },
  421. },
  422. };
  423. let html, elements = {}, timers = {}, sizes = {}, site;
  424. let translator, translations = {}, cid;
  425. class Packet{
  426. /* Bilibili Live WebSocket message packet */
  427. /* thanks to:
  428. https://segmentfault.com/a/1190000017328813
  429. https://blog.csdn.net/xuchen16/article/details/81064372
  430. https://github.com/shugen002/userscript/blob/master/BiliBili%20WebSocket%20Proxy%20Rebuild.user.js
  431. */
  432. constructor(buffer){
  433. Packet.VERSION_COMPRESSED = 2;/* protocol version for compressed body */
  434. Packet.OPERATION_COMMAND = 5;/* operation type for command */
  435. Packet.COMMAND_DANMAKU = 'DANMU_MSG';/* command code for 弾幕(danmaku/danmu) */
  436. this.buffer = buffer;
  437. this.dataView = new DataView(buffer);
  438. this.views = {
  439. package: this.dataView.getUint32(0),/* packet length */
  440. header: this.dataView.getUint16(4),/* header length = offset for body */
  441. version: this.dataView.getUint16(6),/* protocol version */
  442. operation: this.dataView.getUint32(8),/* operation type */
  443. };
  444. try{
  445. this.array = this.getArray();
  446. this.messages = this.getMessages();
  447. }catch(e){
  448. log(e, this.views, new Uint8Array(this.buffer));
  449. }
  450. }
  451. getArray(){
  452. return (this.isCompressed)
  453. ? pako.inflate(new Uint8Array(this.buffer, this.views.header))
  454. : new Uint8Array(this.buffer)
  455. ;
  456. }
  457. getMessages(){
  458. let dataView = new DataView(this.array.buffer);
  459. let messages = [], headerLength = this.views.header, decoder = new TextDecoder();
  460. for(let pos = 0, packetLength = 0; pos < this.array.length; pos += packetLength){
  461. packetLength = dataView.getUint32(pos);
  462. let subarray = this.array.subarray(pos + headerLength, pos + packetLength);
  463. let string = decoder.decode(subarray);
  464. messages.push(string[0] === '{' ? JSON.parse(string) : string);
  465. }
  466. return messages;
  467. }
  468. getDanmakuContents(){
  469. return this.getDanmakus().map(d => {
  470. if(d.info === undefined) return log('Unexpected Danmaku JSON.', d), null;
  471. return d.info[1];
  472. });
  473. }
  474. getDanmakus(){
  475. if(this.isCommand === false) return [];
  476. return this.messages.filter(m => {
  477. if(m.cmd === undefined) return log('Unexpected Command JSON:', m), false;
  478. return m.cmd.startsWith(Packet.COMMAND_DANMAKU);
  479. });
  480. }
  481. get isCompressed(){
  482. return (this.views.version === Packet.VERSION_COMPRESSED);
  483. }
  484. get isCommand(){
  485. return (this.views.operation === Packet.OPERATION_COMMAND);
  486. }
  487. }
  488. class Translator{
  489. /* Danmaku translator using the browser's auto translation */
  490. constructor(){
  491. Translator.TRANSLATIONSATONCE = TRANSLATIONSATONCE;
  492. Translator.TRANSLATIONSINTERVAL = TRANSLATIONSINTERVAL;
  493. Translator.HISTORYLENGTH = HISTORYLENGTH;
  494. Translator.TRANSLATIONEXPIRED = TRANSLATIONEXPIRED;
  495. Translator.DICTIONARY = DICTIONARIES[USERLANGUAGE] || DICTIONARIES[USERLANGUAGE.substring(0, 2)] || {};
  496. Translator.MODIFICATIONS = MODIFICATIONS[USERLANGUAGE] || MODIFICATIONS[USERLANGUAGE.substring(0, 2)] || {};
  497. Translator.MODIFICATIONSKEYS = Object.keys(Translator.MODIFICATIONS);
  498. Translator.WAITING_LIMIT = WAITING_LIMIT;
  499. this.counters = {pushes: 0, registerTranslations: 0, fails: 0};
  500. this.dictionary = this.getDictionary();
  501. this.history = Storage.read('history') || [];
  502. this.priorDanmaku = this.createPriorDanmaku();
  503. this.priorDanmakuWaitings = {};/* waiting for getting translated */
  504. this.priorDanmakuRequested = 0;/* last requested time */
  505. this.priorDanmakuQueue = [];/* queue for preventing multiple request in TRANSLATIONSINTERVAL */
  506. this.timer = 0;/* timer to next TRANSLATIONSINTERVAL */
  507. this.danmakuWaitings = {};/* waiting for getting translation */
  508. }
  509. getDictionary(){
  510. /* use browser language dictionary */
  511. let dictionary;
  512. if(Storage.read('USERLANGUAGE') !== USERLANGUAGE) dictionary = Translator.DICTIONARY;
  513. else dictionary = Storage.read('dictionary') || Translator.DICTIONARY;
  514. Storage.save('USERLANGUAGE', USERLANGUAGE);
  515. dictionary = this.updateDictionary(dictionary);
  516. return dictionary;
  517. }
  518. updateDictionary(dictionary){
  519. /* update structure (2019/6/11) */
  520. let keys = Object.keys(dictionary);
  521. if(typeof dictionary[keys[0]] === 'string') keys.forEach(key => {
  522. dictionary[key] = [dictionary[key], 1, NOW];
  523. });
  524. /* update key (2019/6/23) */
  525. let oldKey = 'BilibiliLiveCommentTranslator';
  526. let oldDictionary = localStorage[`${oldKey}-dictionary`], oldHistory = localStorage[`${oldKey}-history`];
  527. if(oldDictionary && oldHistory){
  528. dictionary = JSON.parse(oldDictionary).value;
  529. this.history = JSON.parse(oldHistory).value;
  530. localStorage.removeItem(`${oldKey}-dictionary`);
  531. localStorage.removeItem(`${oldKey}-history`);
  532. }
  533. return dictionary;
  534. }
  535. createPriorDanmaku(){
  536. /* Append danmaku comments from WebSocket for translating by browser as fast as possible */
  537. let priorDanmaku = elements.priorDanmaku = createElement(core.html.priorDanmaku());
  538. window.top.document.body.appendChild(priorDanmaku);
  539. return priorDanmaku;
  540. }
  541. pushAll(originals){
  542. originals.forEach(o => this.push(o));
  543. this.throttle();
  544. }
  545. push(original){
  546. this.counters.pushes++;
  547. if(this.dictionary[original] !== undefined) return this.dictionary[original][1]++;/* already exists in the dictionary */
  548. if(this.priorDanmakuQueue.includes(original) === true) return;/* already queued */
  549. if(this.priorDanmakuWaitings[original] !== undefined) return;/* already waiting for translation */
  550. if(this.shouldBeTranslated(original) === false) return;/* seems not to be Chinese */
  551. this.priorDanmakuQueue.push(original);
  552. }
  553. throttle(){
  554. if(this.priorDanmakuQueue.length === 0) return;
  555. /* throttle for single waiting query to Chrome Translation */
  556. if(this.priorDanmaku.children.length > 0) return;
  557. /* throttle for TRANSLATIONSINTERVAL */
  558. let now = Date.now(), elapsed = now - this.priorDanmakuRequested;
  559. clearTimeout(this.timer);
  560. if(elapsed <= Translator.TRANSLATIONSINTERVAL){
  561. this.timer = setTimeout(() => this.putOnPriorDanmaku(), Translator.TRANSLATIONSINTERVAL - elapsed);
  562. }else{
  563. this.putOnPriorDanmaku();
  564. }
  565. }
  566. putOnPriorDanmaku(){
  567. log('priorDanmakuQueue:', this.priorDanmakuQueue.length, this.priorDanmakuQueue);
  568. this.priorDanmakuRequested = Date.now();
  569. let putOnce = this.putOnPriorDanmaku.putOnce ? true : false;/* it can put more only on first time */
  570. let atOnce = putOnce ? Translator.TRANSLATIONSATONCE : 10*1000;
  571. let fragment = document.createDocumentFragment();
  572. this.priorDanmakuQueue.reverse();/* from latest danmaku */
  573. for(let i = 0, original; original = this.priorDanmakuQueue[i]; i++){
  574. if(atOnce <= i) break;
  575. let li = createElement(core.html.danmakuContent(original));
  576. this.priorDanmakuWaitings[original] = li;
  577. fragment.appendChild(li);
  578. /* Observe auto translation by browser */
  579. let observer = observe(li, (records) => {
  580. //log('Got translated:', original);
  581. this.registerTranslation(original, li.textContent);
  582. this.removeWaiting(original, li, observer);
  583. this.throttle();
  584. });
  585. /* Time to give up */
  586. setTimeout(() => {
  587. if(li && li.isConnected){
  588. log('Give up for waiting translated:', original);
  589. this.counters.fails++;
  590. this.removeWaiting(original, li, observer);
  591. }
  592. }, (putOnce) ? Translator.WAITING_LIMIT : 60*60*1000);
  593. }
  594. //log(Array.from(fragment.children).map(c => c.textContent));
  595. this.priorDanmaku.appendChild(fragment);
  596. this.priorDanmakuQueue = [];/* dropped */
  597. this.putOnPriorDanmaku.putOnce = true;
  598. }
  599. registerTranslation(original, translation){
  600. this.counters.registerTranslations++;
  601. this.dictionary[original] = [translation, 1, Date.now()];
  602. this.history.push(original);
  603. /* append the translation for each streaming danmakus */
  604. if(this.danmakuWaitings[original]){
  605. this.danmakuWaitings[original].forEach(d => this.appendTranslation(d, translation));
  606. delete this.danmakuWaitings[original];
  607. }
  608. }
  609. removeWaiting(original, span, observer){
  610. observer.disconnect();
  611. span.parentNode.removeChild(span);
  612. delete this.priorDanmakuWaitings[original];
  613. }
  614. requestTranslation(danmaku){
  615. if(this.shouldBeTranslated(danmaku.textContent) === false) return;/* seems not to be Chinese */
  616. if(this.dictionary[danmaku.textContent] === undefined){
  617. if(this.danmakuWaitings[danmaku.textContent] === undefined) this.danmakuWaitings[danmaku.textContent] = [];
  618. this.danmakuWaitings[danmaku.textContent].push(danmaku);
  619. }else{
  620. if(danmaku.textContent === this.dictionary[danmaku.textContent][0]) return;/* original and translation are the same */
  621. this.appendTranslation(danmaku, this.dictionary[danmaku.textContent][0]);
  622. }
  623. //log(danmaku.textContent, 'should be translated.')
  624. }
  625. appendTranslation(danmaku, translation){
  626. //log(danmaku.textContent, translation);
  627. /* it's better to modify before writing to dictionary, but MODIFICATIONS may often be updated */
  628. Translator.MODIFICATIONSKEYS.filter(key => danmaku.textContent.includes(key)).forEach(key => {
  629. if(DEBUG && Translator.MODIFICATIONS[key][0].test(translation) === false) log(
  630. 'Doesn\'t match:', danmaku.textContent, key, translation, Translator.MODIFICATIONS[key],
  631. );
  632. translation = translation.replace(Translator.MODIFICATIONS[key][0], Translator.MODIFICATIONS[key][1]);
  633. });
  634. danmaku.appendTranslation(translation);
  635. }
  636. shouldBeTranslated(textContent){
  637. switch(true){
  638. case(this.dictionary[textContent] !== undefined):/* has a translation */
  639. return true;
  640. case(textContent.match(REGEXP.hasKana) !== null):/* seems to be Japanese */
  641. case(textContent.match(REGEXP.allAlphabet) !== null):/* seems to be English */
  642. case(textContent.match(REGEXP.allEmoji) !== null):/* seems to be Emoji */
  643. return false;
  644. default:
  645. return true;
  646. }
  647. }
  648. save(){
  649. /* log usage statistics */
  650. let c = this.counters, saved = (((c.pushes - c.fails - c.registerTranslations)/((c.pushes - c.fails) || 1))*100).toFixed(0) + '%';
  651. log('Total danmaku:', c.pushes, 'Newly translated:', c.registerTranslations, 'Saved:', saved, 'Fails:', c.fails);
  652. /* save the dictionary and the history of latest HISTORYLENGTH pairs */
  653. let newDictionary = {}, newHistory = [];
  654. for(let i = this.history.length - 1, count = 0, now = Date.now(); 0 <= i; i--){
  655. if(this.dictionary[this.history[i]] === undefined){
  656. log('Unknown history', this.history[i]);
  657. continue;
  658. };
  659. if(this.dictionary[this.history[i]][2] < now - Translator.TRANSLATIONEXPIRED) continue;/* old data */
  660. if(newDictionary[this.history[i]] !== undefined) continue;/* duplicated in the history */
  661. newDictionary[this.history[i]] = this.dictionary[this.history[i]];
  662. newHistory[count] = this.history[i];
  663. if(count++ === Translator.HISTORYLENGTH) break;
  664. }
  665. /* keep the default dictionary */
  666. Object.keys(Translator.DICTIONARY).forEach(key => {
  667. newDictionary[key] = newDictionary[key] || Translator.DICTIONARY[key];
  668. });
  669. log('Dictionary length:', newHistory.length, 'Stored size:', toMetric(JSON.stringify(newDictionary).length * 2) + 'bytes');
  670. Storage.save('dictionary', newDictionary);
  671. Storage.save('history', newHistory.reverse());
  672. }
  673. }
  674. class Danmaku{
  675. constructor(danmaku){
  676. Danmaku.zIndex = Danmaku.zIndex || 1;
  677. this.element = danmaku;
  678. this.textContent = danmaku.textContent;
  679. this.modify();
  680. }
  681. modify(){
  682. this.element.style.zIndex = parseInt(this.element.style.zIndex || 0) + Danmaku.zIndex++;/* newer comments have priority */
  683. /* Make space for appending translation text */
  684. this.element.style.top = (() => {
  685. if(this.element.style.top === '') return;
  686. let operableContainer = elements.operableContainer, operableSpace = operableContainer ? site.get.operableSpace(operableContainer) : null;
  687. if(this.element.style.top[0] === '-' || operableSpace === null || operableSpace.children.length === 0 || operableSpace.style.height === ''){
  688. return (parseFloat(this.element.style.top) * 2) + 'px';
  689. }else{
  690. let height = parseFloat(operableSpace.style.height), top = parseFloat(this.element.style.top);
  691. return (height + ((top - height) * 2)) + 'px';
  692. }
  693. })();
  694. /* Even if double long translation text added, keep streaming to completely go away */
  695. this.element.style.transitionDuration = ((transitionDuration) => {
  696. if(transitionDuration === '') return;
  697. let m = transitionDuration.match(/([0-9.]+)(m?s)/);
  698. if(m === null) return log('Unknown transitionDuration format:', transitionDuration), transitionDuration;
  699. return (parseFloat(m[1]) * 2) + m[2];
  700. })(this.element.style.transitionDuration);
  701. this.element.style.transform = ((transform) => {
  702. if(transform === '') return;
  703. let m = transform.match(/(translateX?)\(([-0-9.]+)(px)/);
  704. if(m === null) return log('Unknown transform format:', transform), transform;
  705. return transform.replace(m[0], `${m[1]}(${parseFloat(m[2]) * 2}${m[3]}`);
  706. })(this.element.style.transform);
  707. }
  708. appendTranslation(translation){
  709. let span = createElement(core.html.translation(translation));
  710. this.element.appendChild(span);
  711. span.animate([{opacity: `0`},{opacity: `1`}], {duration: 500, fill: 'forwards'});
  712. this.element.addEventListener('transitionend', (e) => {
  713. span.animate([{opacity: `1`},{opacity: `0`}], {duration: 500, fill: 'forwards'});
  714. }, {once: true});
  715. }
  716. get hasTranslation(){
  717. /* bilibili removes previous translation element when the danmaku element has reused */
  718. return (this.element.querySelector('.translation') === null) ? false : true;
  719. }
  720. }
  721. let core = {
  722. initialize: function(){
  723. html = document.documentElement;
  724. html.classList.add(SCRIPTNAME);
  725. switch(true){
  726. case(location.href.match(/^https:\/\/www\.bilibili\.com\/video\/av[0-9]+/) !== null):
  727. site = sites.video;
  728. translator = new Translator();
  729. core.listenXMLHttpRequests();
  730. core.targetTranslation();
  731. setTimeout(core.readyForVideo, 3000);/*videoDanmaku要素の種類が遅延して反映される*/
  732. break;
  733. case(location.href.match(/^https:\/\/www\.bilibili\.com\/bangumi\/play\/[a-z0-9]+/) !== null):
  734. site = sites.bangumi;
  735. translator = new Translator();
  736. core.listenXMLHttpRequests();
  737. core.targetTranslation();
  738. setTimeout(core.readyForVideo, 3000);/*videoDanmaku要素の種類が遅延して反映される*/
  739. break;
  740. case(location.href.match(/^https:\/\/live\.bilibili\.com\/[0-9]+/) !== null):
  741. case(location.href.match(/^https:\/\/live\.bilibili\.com\/blanc\/[0-9]+/) !== null):
  742. site = sites.live;
  743. translator = new Translator();
  744. core.listenWebSockets();
  745. core.targetTranslation();
  746. core.readyForLive();
  747. break;
  748. default:
  749. return log('Bye.');
  750. }
  751. core.observeHead();
  752. core.readyForUnload();
  753. core.export();
  754. },
  755. readyForVideo: function(){
  756. if(document.hidden) return setTimeout(core.readyForVideo, 1000);
  757. core.getTargets(site.targets, RETRY).then(() => {
  758. log("I'm ready for Video.");
  759. core.translateUserInterface();
  760. core.setDanmakuSettings();
  761. core.observeVideoDanmaku();
  762. core.modifyDanmakuInput();
  763. core.addStyle('topStyle', window.top);
  764. core.addStyle();
  765. });
  766. },
  767. readyForLive: function(){
  768. if(document.hidden) return setTimeout(core.readyForVideo, 1000);
  769. core.getTargets(site.targets, RETRY).then(() => {
  770. log("I'm ready for Live.");
  771. core.translateUserInterface();
  772. core.observeVideoDanmaku();
  773. core.modifyDanmakuInput();
  774. core.addStyle('topStyle', window.top);
  775. core.addStyle();
  776. });
  777. },
  778. observeHead: function(){
  779. /* URL変化の検出の代替 */
  780. let head = $('head'), url = location.href;
  781. let observer = observe(head, function(records){
  782. if(url === location.href) return;
  783. url = location.href;
  784. log(head);
  785. observer.disconnect();
  786. core.initialize();
  787. }, {childList: true, characterData: true, subtree: true});
  788. },
  789. targetTranslation: function(){
  790. const setTranslate = function(element){
  791. element.classList.add('translate');
  792. element.translate = true;
  793. };
  794. const setNoTranslate = function(element){
  795. element.classList.add('notranslate');
  796. element.translate = false;
  797. };
  798. for(let i = 0, target; target = site.translationTargets[i]; i++){
  799. if(target[1]() === null) return setTimeout(core.targetTranslation, 1000);
  800. if(target[0] === true) setTranslate(target[1]());
  801. else setNoTranslate(target[1]());
  802. }
  803. },
  804. translateUserInterface: function(){
  805. translations = TRANSLATIONS[USERLANGUAGE] || TRANSLATIONS[USERLANGUAGE.substring(0, 2)] || TRANSLATIONS.en;
  806. /*置換したりobserveしたりする・・・かもしれない*/
  807. },
  808. listenXMLHttpRequests: function(){
  809. /* 公式の通信内容を取得 */
  810. window.XMLHttpRequest = new Proxy(XMLHttpRequest, {
  811. construct(target, arguments){
  812. const xhr = new target(...arguments);
  813. //log(xhr, arguments);
  814. xhr.addEventListener('load', function(e){
  815. if(xhr.responseURL.startsWith(PLAYERAPI) === false) return;
  816. cid = site.get.cid(xhr.responseURL);
  817. core.getDanmakuList();
  818. });
  819. return xhr;
  820. }
  821. });
  822. },
  823. getDanmakuList: function(){
  824. let api = site.get.commentlistApi(cid);
  825. fetch(api, {credentials: 'include', mode: 'cors'})
  826. .then(response => response.text())
  827. .then(text => new DOMParser().parseFromString(text, 'text/xml'))
  828. .then(d => {
  829. let ds = d.querySelectorAll('d');
  830. if(ds.length === 0) return log('Unknown danmaku format:', d);
  831. let danmakuContents = Array.from(ds).map(d => d.textContent);
  832. translator.pushAll(danmakuContents);
  833. });
  834. },
  835. listenWebSockets: function(){
  836. /* 公式の通信内容を取得 */
  837. window.WebSocket = new Proxy(WebSocket, {
  838. construct(target, arguments){
  839. const ws = new target(...arguments);
  840. //log(ws, arguments);
  841. if(ws.url.includes(CHATSERVER)) ws.addEventListener('message', function(e){
  842. let packet = new Packet(e.data);
  843. //log(packet.views, packet.messages);
  844. if(packet.isCommand === false) return;
  845. let danmakuContents = packet.getDanmakuContents();
  846. if(danmakuContents.length === 0) return;
  847. //log('Danmaku in a packet:', danmakuContents.length, danmakuContents);
  848. translator.pushAll(danmakuContents);
  849. });
  850. return ws;
  851. }
  852. });
  853. },
  854. setDanmakuSettings: function(){
  855. let videoDanmaku = site.get.videoDanmaku();
  856. if(videoDanmaku.localName === 'canvas'){
  857. site.set.danmakuTypeCSS(core.observeVideoDanmaku);
  858. }
  859. },
  860. observeVideoDanmaku: function(){
  861. let videoDanmaku = site.get.videoDanmaku();
  862. let observer = observe(videoDanmaku, function(records){
  863. //log(records);
  864. for(let i = 0; records[i]; i++){
  865. if(records[i].addedNodes.length === 0) continue;
  866. if(records[i].addedNodes[0].classList.contains('bilibili-danmaku') === false) continue;
  867. let danmaku = new Danmaku(records[i].addedNodes[0]);
  868. translator.requestTranslation(danmaku);
  869. observeDanmaku(danmaku);/*danmakuは再利用される!*/
  870. }
  871. });
  872. const observeDanmaku = function(danmaku){
  873. /* 再利用(新規弾幕としての生まれ変わり)を検知したい */
  874. let observer = observe(danmaku.element, function(records){
  875. if(danmaku.hasTranslation) return;/*再利用ではなく翻訳文追加だった*/
  876. danmaku = new Danmaku(danmaku.element);/*上書き*/
  877. translator.requestTranslation(danmaku);
  878. });
  879. };
  880. },
  881. modifyDanmakuInput: function(){
  882. /* 弾幕投稿内容を翻訳する機能を追加 */
  883. let danmakuInput = site.get.danmakuInput(), modifier = ISMAC ? 'metaKey' : 'ctrlKey';
  884. if(danmakuInput === null || danmakuInput.placeholder === undefined) return setTimeout(core.modifyDanmakuInput, 1000);/*属性付与が遅れる場合もあるので*/
  885. danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
  886. observe(danmakuInput, function(record){
  887. if(danmakuInput.placeholder.endsWith(translations.inputTranslationKey)) return;
  888. danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
  889. }, {attributes: true, attributeFilter: ['placeholder']});
  890. window.addEventListener('keydown', function(e){
  891. if(e.target !== danmakuInput) return;
  892. if(e.key === 'Enter' && e[modifier] === true){
  893. e.preventDefault();
  894. e.stopPropagation();
  895. danmakuInput.classList.add('translating');
  896. let api = TRANSLATOR.replace('{text}', danmakuInput.value).replace('{source}', USERLANGUAGE).replace('{target}', BILIBILILANGUAGE);
  897. fetch(api, {mode: 'cors'})
  898. .then(response => response.text())
  899. .then(text => {
  900. //log(text);
  901. danmakuInput.value = text;
  902. danmakuInput.dispatchEvent(new InputEvent('input'));/*実際の送信内容に反映させるために必要*/
  903. danmakuInput.classList.remove('translating');
  904. })
  905. .catch(error => {
  906. log('Error:', error);
  907. danmakuInput.classList.remove('translating');
  908. });
  909. }
  910. }, true);
  911. },
  912. readyForUnload: function(){
  913. window.addEventListener('unload', function(e){
  914. translator.save();
  915. });
  916. },
  917. export: function(){
  918. if(DEBUG === false) return;
  919. let dictionary = translator.dictionary, ratio = (number) => (number*100).toFixed(1) + '%';
  920. window.save = translator.save.bind(translator);
  921. window.list = function(includes, excludes = /DUMMY/){
  922. return Object.keys(dictionary).filter(key => {
  923. return includes.test(key) && !excludes.test(dictionary[key][0]);
  924. }).sort((a, b) => {
  925. return dictionary[b][1] - dictionary[a][1];
  926. }).map(key => {
  927. return [
  928. (new Date(dictionary[key][2])).toLocaleString(),/* used */
  929. dictionary[key][1],/* count */
  930. key,/* original */
  931. dictionary[key][0],/* translation */
  932. ];
  933. }).slice(0,100);
  934. }
  935. window.rank = function(){
  936. return Object.keys(dictionary).sort((a, b) => {
  937. return dictionary[b][1] - dictionary[a][1];
  938. }).map(key => {
  939. return [
  940. (new Date(dictionary[key][2])).toLocaleString(),/* used */
  941. dictionary[key][1],/* count */
  942. key,/* original */
  943. dictionary[key][0],/* translation */
  944. ];
  945. }).slice(0,100);
  946. }
  947. window.usage = function(){
  948. let total = 0, multiple = 0, single = 0, keys = Object.keys(dictionary);
  949. keys.forEach(key => {
  950. total += dictionary[key][1];
  951. if(2 <= dictionary[key][1]) multiple += 1;
  952. else single += 1;
  953. });
  954. log(
  955. 'total:', total,
  956. 'length:', keys.length,
  957. '2+:', multiple, ratio(multiple/keys.length),
  958. '1:', single, ratio(single/keys.length),
  959. 'saved:', ratio((total - keys.length)/total),
  960. );
  961. };
  962. },
  963. getTargets: function(targets, retry = 0){
  964. const get = function(resolve, reject, retry){
  965. for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
  966. let selected = targets[key]();
  967. if(selected){
  968. if(selected.length) selected.forEach((s) => s.dataset.selector = key);
  969. else selected.dataset.selector = key;
  970. elements[key] = selected;
  971. }else{
  972. if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
  973. log(`Not found: ${key}, retrying... (left ${retry})`);
  974. return setTimeout(get, 1000, resolve, reject, retry);
  975. }
  976. }
  977. resolve();
  978. };
  979. return new Promise(function(resolve, reject){
  980. get(resolve, reject, retry);
  981. });
  982. },
  983. addStyle: function(name = 'style', w = window){
  984. let style = createElement(core.html[name]());
  985. w.document.head.appendChild(style);
  986. if(elements[name] && elements[name].isConnected) w.document.head.removeChild(elements[name]);
  987. elements[name] = style;
  988. },
  989. html: {
  990. priorDanmaku: () => `<ul id="${SCRIPTNAME}-prior-danmaku" class="translate" translate="yes"></ul>`,
  991. danmakuContent: (content) => `<li>${content}</li>`,
  992. translation: (translation) => `<span class="translation">${translation}</span>`,
  993. topStyle: () => `
  994. <style type="text/css" id="${SCRIPTNAME}-topStyle">
  995. /* bilibili color: #00A1D6 */
  996. ul#${SCRIPTNAME}-prior-danmaku{
  997. /* 画面内にないと自動翻訳されない */
  998. visibility: hidden;
  999. position: fixed;
  1000. top: 0;
  1001. padding: 0;
  1002. margin: 0;
  1003. white-space: nowrap;
  1004. z-index: 9999;
  1005. }
  1006. ul#${SCRIPTNAME}-prior-danmaku li{
  1007. position: absolute;
  1008. }
  1009. </style>
  1010. `,
  1011. style: () => `
  1012. <style type="text/css" id="${SCRIPTNAME}-style">
  1013. .translation{
  1014. font-size: 75%;
  1015. display: block;
  1016. }
  1017. .translating{
  1018. opacity: .25;
  1019. animation: ${SCRIPTNAME}-blink 250ms step-end infinite;
  1020. }
  1021. @keyframes ${SCRIPTNAME}-blink{
  1022. 50%{opacity: .5}
  1023. }
  1024. /* 放送終了後 */
  1025. .bilibili-live-player[data-live-state=block] .bilibili-live-player-video-danmaku,
  1026. .bilibili-live-player[data-live-state=close] .bilibili-live-player-video-danmaku,
  1027. .bilibili-live-player[data-live-state=end] .bilibili-live-player-video-danmaku,
  1028. .bilibili-live-player[data-live-state=preparing] .bilibili-live-player-video-danmaku{
  1029. height: 100%;
  1030. pointer-events: none;
  1031. }
  1032. </style>
  1033. `,
  1034. },
  1035. };
  1036. const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  1037. const getComputedStyle = window.getComputedStyle, fetch = window.fetch;
  1038. if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  1039. class Storage{
  1040. static key(key){
  1041. return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
  1042. }
  1043. static save(key, value, expire = null){
  1044. key = Storage.key(key);
  1045. localStorage[key] = JSON.stringify({
  1046. value: value,
  1047. saved: Date.now(),
  1048. expire: expire,
  1049. });
  1050. }
  1051. static read(key){
  1052. key = Storage.key(key);
  1053. if(localStorage[key] === undefined) return undefined;
  1054. let data = JSON.parse(localStorage[key]);
  1055. if(data.value === undefined) return data;
  1056. if(data.expire === undefined) return data;
  1057. if(data.expire === null) return data.value;
  1058. if(data.expire < Date.now()) return localStorage.removeItem(key);
  1059. return data.value;
  1060. }
  1061. static delete(key){
  1062. key = Storage.key(key);
  1063. delete localStorage.removeItem(key);
  1064. }
  1065. static saved(key){
  1066. key = Storage.key(key);
  1067. if(localStorage[key] === undefined) return undefined;
  1068. let data = JSON.parse(localStorage[key]);
  1069. if(data.saved) return data.saved;
  1070. else return undefined;
  1071. }
  1072. }
  1073. const $ = function(s, f){
  1074. let target = document.querySelector(s);
  1075. if(target === null) return null;
  1076. return f ? f(target) : target;
  1077. };
  1078. const $$ = function(s){return document.querySelectorAll(s)};
  1079. const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  1080. const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  1081. const createElement = function(html = '<span></span>'){
  1082. let outer = document.createElement('div');
  1083. outer.innerHTML = html;
  1084. return outer.firstElementChild;
  1085. };
  1086. const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
  1087. let observer = new MutationObserver(callback.bind(element));
  1088. observer.observe(element, options);
  1089. return observer;
  1090. };
  1091. const atLeast = function(min, b){
  1092. return Math.max(min, b);
  1093. };
  1094. const atMost = function(a, max){
  1095. return Math.min(a, max);
  1096. };
  1097. const between = function(min, b, max){
  1098. return Math.min(Math.max(min, b), max);
  1099. };
  1100. const toMetric = function(number, decimal = 1){
  1101. switch(true){
  1102. case(number < 1e3 ): return (number);
  1103. case(number < 1e6 ): return (number/1e3 ).toFixed(decimal) + 'K';
  1104. case(number < 1e9 ): return (number/1e6 ).toFixed(decimal) + 'M';
  1105. case(number < 1e12): return (number/1e9 ).toFixed(decimal) + 'G';
  1106. default: return (number/1e12).toFixed(decimal) + 'T';
  1107. }
  1108. };
  1109. const log = function(){
  1110. if(!DEBUG) return;
  1111. let l = log.last = log.now || new Date(), n = log.now = new Date();
  1112. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  1113. //console.log(error.stack);
  1114. console.log(
  1115. SCRIPTNAME + ':',
  1116. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  1117. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  1118. /* :00 */ ':' + line,
  1119. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  1120. /* caller */ (callers[1] || '') + '()',
  1121. ...arguments
  1122. );
  1123. };
  1124. log.formats = [{
  1125. name: 'Firefox Scratchpad',
  1126. detector: /MARKER@Scratchpad/,
  1127. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  1128. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  1129. }, {
  1130. name: 'Firefox Console',
  1131. detector: /MARKER@debugger/,
  1132. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  1133. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  1134. }, {
  1135. name: 'Firefox Greasemonkey 3',
  1136. detector: /\/gm_scripts\//,
  1137. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  1138. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  1139. }, {
  1140. name: 'Firefox Greasemonkey 4+',
  1141. detector: /MARKER@user-script:/,
  1142. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  1143. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  1144. }, {
  1145. name: 'Firefox Tampermonkey',
  1146. detector: /MARKER@moz-extension:/,
  1147. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  1148. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  1149. }, {
  1150. name: 'Chrome Console',
  1151. detector: /at MARKER \(<anonymous>/,
  1152. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  1153. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  1154. }, {
  1155. name: 'Chrome Tampermonkey',
  1156. detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/,
  1157. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
  1158. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  1159. }, {
  1160. name: 'Chrome Extension',
  1161. detector: /at MARKER \(chrome-extension:/,
  1162. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  1163. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  1164. }, {
  1165. name: 'Edge Console',
  1166. detector: /at MARKER \(eval/,
  1167. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  1168. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  1169. }, {
  1170. name: 'Edge Tampermonkey',
  1171. detector: /at MARKER \(Function/,
  1172. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  1173. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  1174. }, {
  1175. name: 'Safari',
  1176. detector: /^MARKER$/m,
  1177. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  1178. getCallers: (e) => e.stack.split('\n'),
  1179. }, {
  1180. name: 'Default',
  1181. detector: /./,
  1182. getLine: (e) => 0,
  1183. getCallers: (e) => [],
  1184. }];
  1185. log.format = log.formats.find(function MARKER(f){
  1186. if(!f.detector.test(new Error().stack)) return false;
  1187. //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
  1188. return true;
  1189. });
  1190. const time = function(label){
  1191. if(!DEBUG) return;
  1192. const BAR = '|', TOTAL = 100;
  1193. switch(true){
  1194. case(label === undefined):/* time() to output total */
  1195. let total = 0;
  1196. Object.keys(time.records).forEach((label) => total += time.records[label].total);
  1197. Object.keys(time.records).forEach((label) => {
  1198. console.log(
  1199. BAR.repeat((time.records[label].total / total) * TOTAL),
  1200. label + ':',
  1201. (time.records[label].total).toFixed(3) + 'ms',
  1202. '(' + time.records[label].count + ')',
  1203. );
  1204. });
  1205. time.records = {};
  1206. break;
  1207. case(!time.records[label]):/* time('label') to create and start the record */
  1208. time.records[label] = {count: 0, from: performance.now(), total: 0};
  1209. break;
  1210. case(time.records[label].from === null):/* time('label') to re-start the lap */
  1211. time.records[label].from = performance.now();
  1212. break;
  1213. case(0 < time.records[label].from):/* time('label') to add lap time to the record */
  1214. time.records[label].total += performance.now() - time.records[label].from;
  1215. time.records[label].from = null;
  1216. time.records[label].count += 1;
  1217. break;
  1218. }
  1219. };
  1220. time.records = {};
  1221. core.initialize();
  1222. if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
  1223. })();

QingJ © 2025

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