// ==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.8
// @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.8
適訳辞書を更新。
[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
pako.deflate + TextDecoder でdictionaryを無理やり圧縮して保存できる?
動画のタイトル下に翻訳を挿入したいね。
MODIFICATIONSはたまに翻訳の確認が必要。
MODIFICATIONSの活用率も確認したい。
[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 = 1000;/*最短翻訳リクエスト間隔(ms)(Chrome翻訳負荷の低減)*/
const HISTORYLENGTH = 50000;/*辞書の最大保持数(5万で5MB見込み)*/
const TRANSLATIONEXPIRED = 90*24*60*60*1000;/*翻訳の有効期限(翻訳精度の改善に期待する)*/
const WAITING_LIMIT = 10*1000;/* Chrome翻訳の待機時間(ms)(過負荷時には実質休憩時間となる) */
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 = {/* およそ5000件で1ms (Core i7-3740QM) */
/* '単語': [/誤訳(削除する)/, '適訳(挿入する)'] */
ja: {
// 日本語
'发言': [/話す|スピーチ|スピーキング|ステートメント/, '発言'],
'残念': [/残り/, '残念'],
'干杯': [/トースト|干杯/, '乾杯'],
'乾杯': [/トースト/, '乾杯'],
'万岁': [/長生き(する|させる)?|ロングライブ/, '万歳'],
'大丈夫': [/夫(ですか)?/, '大丈夫'],
'正解': [/ポジティブソリューション/, '正解'],
'無駄': [/イノセント|無実/g, '無駄'],
'草': [/グラス|カオ/, '草'],
'有能': [/エネルギーを持っている/, '有能'],
'神回': [/神が戻ってきた|神様/, '神回'],
'全裸待机': [/フルヌードスタンバイ/, '全裸待機'],
'完全一致': [/完全に一貫性のある|完全に一貫しています/, '完全に一致'],
'上手上手': [/手をつないで/, '上手上手'],
'上手': [/はじめに/, '上手'],
'清楚清楚': [/クリアとクリア/, '清楚清楚'],
'清楚': [/クリア|明らか|明確/, '清楚'],
'理解理解': [/理解(を|し)理解する/, '理解理解'],
'余裕余裕': [/ゆうゆうゆうゆう/, '余裕余裕'],
'兽耳': [/獣(の)?耳|動物の耳|獣|耳|ビーストイヤー/, 'ケモミミ'],
'幻听': [/錯視|錯覚|聴覚幻覚|黙る|ありがとう|イリュージョン|イルルイン|イルリング|(オーディオ)?オーディション|ファンタジー|Illuing/, '幻聴'],
'幻视': [/ファントム|マジック|魔法/, '幻視'],
'错乱': [/(無秩序(に)?|乱雑|疾患|障害|カオス)/, '錯乱'],
'混乱': [/カオス|混沌/, '混乱'],
'认真': [/(本気|真剣に)/, '迫真'],
'确信': [/(確認済み|信じ(る|て|ます)|納得|確信してい(る|ます))/, '確信'],
'狂喜': [/エクスタシー/, '狂喜'],
'震声': [/衝撃|ショック/, '震え声'],
'棒读': [/(素晴らしい|良い|スティック|棒)(読書|リーディング)/, '棒読み'],
'野生': [/ワイルド/, '野生'],
'字幕组': [/字幕グループ/, '字幕組'],
'字幕': [/キャプション/, '字幕'],
'君中国语本当上手': [/6月中国語は始めるための方法です/, '君中国語本当上手'],
'君日本语本当上手': [/6月日本語は始める方法です/, '君日本語本当上手'],
// 中国語
'帅': [/ハンサム(な)?/g, 'カッコイイ'],
'大人': [/(?<!あの|この)(の)?(大人|成人|おとな|テーブル)/g, 'さま'],
'老大': [/上司|ボス/, '老大'],
'加油': [/さあ|さて|燃料補給|燃料を供給|給油|来て|来ます|歓声を上げる/g, 'がんばれ'],
'厉害厉害': [/非常に強力/, 'すごいすごい'],
'厉害': [/強力(な)?/, 'すごい'],
'表白': [/エクスプレス|(の)?告白$|白|(を)?表現する|Express/, 'すき'],
'来了来了': [/ここに来(る|て)/, '来ました'],
'来了': [/来(る|て(います|いる)?)|さあ/, '来ました'],
'辛苦了': [/ハードワーク|勤勉|(一生)?懸命(に)?働い(た|ている|ています)|大変です|難しい|つらい/, 'おつかれさま'],
'冲冲冲': [/急いで/, 'いけいけいけ'],
'见证历史': [/証人の歴史/, '歴史の証人'],
'喷麦': [/スプレー|小麦散布/, '吐息音'],
'配音': [/ダビング|Dubbing|声/, '吹き替え'],
'眨眼': [/点滅/, 'まばたき'],/*ウィンクの場合もあるが点滅よりはマシ*/
'up主': [/アップ(マスター|メイン)?/, 'うp主'],
'主播': [/アンカー/, '配信主'],
'在现场': [/現場で/, '生で見てた'],
'虚拟': [/(虚偽|虚似|虚俗|偽|仮想)(の)?/, 'バーチャル'],
'穿模': [/金型/, 'モデル'],
'安全裤': [/安全(ズボン|パンツ)/, 'スパッツ'],
// 当て字
'欧拉': [/オイラー|オウラ/g, 'オラ'],
'木大': [/大きな木|ウッドビッグ/g, '無駄'],
'赛高': [/サイガオ|さいがお|試合高|高(いです)?/, '最高'],
'拜拜': [/さようなら|^バイ$/, 'バイバイ'],
'奶思': [/ミルク思考/, 'ナイス'],
'奶声': [/ミルクの声/, 'ナイス'],
'奶死': [/ミルクデス|(牛乳|ミルク)(が|は)死ん(だ|でいる|でいます)/, 'ナイスデス'],
'牙白': [/歯の白|白い歯|白|ホワイト/, 'やばい'],
'纳尼': [/なな|にに|Nani/ig, 'なに'],
'贴贴': [/ステッカー|それを固執する|投稿|貼付/, 'てぇてぇ'],
'斯哈斯哈': [/ハシャ|シハハ|Sihasha/, 'スハスハ'],
'嘶哈嘶哈': [/ヒップホップヒップホップ/, 'スハスハ'],
'昆卡昆卡': [/クエンカクエンカ/, 'クンカクンカ'],
'压力马斯内': [/突然の圧力|プレッシャー(マス(ヌ|ネ)|スニーク)/, 'やりますねぇ'],
'压力马斯奈': [/(プレッシャー|圧力)マスネイ/, 'やりますねぇ'],
'亚拉那一卡': [/ヤラナ(カード)?|Yalanaからのカード/, 'やらないか'],
'妮可妮可妮': [/ニコールニコール/, 'にっこにっこにー'],
// スラング
'谢谢茄子': [/(ナス|茄子)(を)?ありがとう/, 'ありがとナス'],
'謝謝茄子': [/(ナス|茄子)(を)?ありがとう/, 'ありがとナス'],
'太草': [/草が多すぎる/, '大草原'],
'鬼畜': [/ゴースト(アニマル)?|幽霊/, 'MAD'],
'沙雕': [/砂(の)?彫刻|サンドカービング/, 'あほ'],
'口区': [/口の面積|口地域|口元エリア/, 'ヴォエ'],
'手冲': [/ハンドパンチ/, '手コキ'],
'冲国': [/チョング(オ|ァ)|チョン(オ)?|Chongguo|忠国|急ぐ国/, 'チューゴク'],
'康康': [/カンカン(させて(ください)?|が欲しい)/, 'ほら見せろよ'],
'人类一败涂地': [/男は敗北|人間は敗北します|敗北した人間/, '人類は一敗地にまみれた'],
'牛逼': [/ニウビ|ニー|強気/, 'すごい'],
'棒棒哒': [/スティック|すごい/, 'しゅごい'],
'白给': [/ホワイト(ギブ|がくれた)?|白/, '無駄死に'],
'什么鬼': [/何ゴースト|なんて幽霊/, 'なんだこれ'],
'单推': [/(シングル|ワン)?(プッシュ|クリック)|单推/, '単推し'],
'烤肉': [/バーベキュー|肉のグリル/, '焼肉'],
'石油佬': [/油(いかだ)?|いかだ|オイル(佬)?/, '石油王'],
'大佬': [/ダクシー|ダキシー|ダラット|ダウェイ|Daxie|(大|ビッグ)(ショー|big|佬)|大きい|大学ビル/, 'ニキ'],
'C位': [/C(ビット)?/i, 'センター'],
'c位': [/C(ビット)?/i, 'センター'],
'打Call': [/(お)?電話(をかける|する|ください)|呼ぶ|コール/, 'コールする'],
'打call': [/(お)?電話(をかける|する|ください)|呼ぶ|コール/, 'コールする'],
// 超意訳
'酸死了': [/痛い/, '裏山死'],
'酸了': [/(私は)?酸っぱい(です)?/, '裏山'],
'才八点': [/たった8(時|つ)/, 'はえーよ'],
'才8点': [/たった8(時|つ)/, 'はえーよ'],
'光速下播': [/光速で放送する/, '急にオワタ'],
'火钳刘明': [/((火)?トング|ファイアークランプ)劉明/g, '記念カキコ'],
'前方高能': [/(先に|今後の)?高エネルギー(先)?(警告)?/, 'くるぞ'],
'这不是演习': [/これは練習では(ありません|ないことに注意してください)/, 'これは演習ではない'],
// 翻訳不能\(^o^)/
'臣卜木曹': [/(チェン・ブカオ|Chen Bumu Cao)/, '(゚Д゚)'],
'卧槽...': [/(横になって(いる)?|横)?トラフ(に横たわって|に横になっています)?/, '(゚Д゚)...'],
'卧槽': [/(横になっている)?トラフ(に横たわって|は|で|の)?|横になって|横溝/, '(゚Д゚)!?'],
// awsl
'阿伟死了': [/魏は死ん(でいる|だ)/, 'awsl'],
'阿伟输了': [/魏は失った/, 'awsl'],
'阿伟爽了': [/魏がかっこいい/, 'awsl'],
'阿伟射了': [/ウェイショット/, 'awsl'],
'阿伟少林': [/魏少林寺/, 'awsl'],
'啊我睡了': [/ああ、私は寝ました。/, 'awsl'],
'爱我苏联': [/(私を愛して)?ソビエト連邦(を愛してください)?/, 'awsl'],
'奥维丝丽': [/オビスリ/, 'awsl'],
'阿伟乱葬岗': [/アウェイマスグレイブ/, 'awsl墓地'],
'阿伟乱葬场': [/魏古墳/, 'awsl墓地'],
// 固有名詞
'谷酱': [/谷/, 'グーグルちゃん'],
'goo酱': [/グーソース/, 'Googleちゃん'],
'油管': [/(オイル|(石)?油)パイプ|管|チューブ|チュービング/, 'YouTube'],
'诸葛孔明': [/Zhuge Kongming|ジュージュコミング/, '諸葛孔明'],
'孔明': [/Kong Ming|コミング|コングミン/, '孔明'],
// キズナアイ
'绊爱酱': [/ラブソース|ソースが大好き(です)?/, 'キズナアイちゃん'],
'爱酱': [/(ラブ)?ソース|Love Sauce/g, 'アイちゃん'],
// 白上フブキ
'白上吹雪': [/白で吹(く)?雪|白雪姫/g, '白上フブキ'],
'吹雪酱': [/吹雪ソース/g, 'フブキちゃん'],
// リブドル
'战斗吧歌姬': [/戦う、歌手|歌手と戦(う|ってください)|歌手との戦い/, 'リブドル'],
'Swan': [/白鳥/, 'スワン'],
'swan': [/白鳥/, 'スワン'],
'罗兹': [/ロードス(島)?|ウッチ|Rhodes|Roz/ig, 'ローズ'],
'神宫司': [/神社|神宮課|神宮シー/g, '神宮司'],
'玉藻': [/ゆう(ざお|ぞう)|遊蔵|翡翠(藻)?|玉藻|玉|藻|湯田|遊戯王|裕蔵|湯蔵|ug尾|Yuzao|Yugao/g, '玉藻'],
'清歌': [/(Li )?Qingge|(清の)?歌|(クリアな)?(曲|歌)|(クリア)?ソング|クリア|チンゲ/g, '清歌'],
'清哥': [/Qingge|清の(兄弟|兄|弟)|チンゲ/g, '清歌'],
'墨汐': [/インク(兄弟)?|Mo (Yan|Zhen)|Moxi/g, 'モーシィ'],
'卡缇娅': [/ケイヤ|カヤ|キャシー|Kayya|Katya|缇卡缇娅|缇卡娅娅/ig, 'カティア'],
'鸭鸭': [/アヒル(と|や|に)アヒル|アヒル|あひる/g, '鴨鴨'],
'伊莎贝拉': [/イザベラ/g, 'イザベラ'],
'贝拉拉': [/ベララ|ベッラーラ|Bellala/g, 'ベララ'],
// Overidea
'张京华': [/張晶華|チャン静華|チャンジンファ|(Zhang )?Jinghua/, '張京華'],
'京华': [/晶華|景華|清華|ジンファ|Jinghua/, '京華'],
'谢拉': [/シ(ー)?ラ|シェ(イ)?ラ|セラ|ゼラ|Sheila|Shera|Sierra|Xie( La)?|謝(La|ラ)?|谢拉/i, 'シエラ'],
'謝拉': [/シ(ー)?ラ|シェ(イ)?ラ|セラ|ゼラ|Sheila|Shera|Sierra|Xie( La)?|謝(La|ラ)?|谢拉/i, 'シエラ'],
'米娅': [/アミア|Mia/i, 'ミア'],
// 織田信姫
'信姬': [/Xin Ji|Xinji|新(地|自)/i, '信姫'],
// 朝ノ姉妹
'瑠璃': [/(着色)?ガラス|Liuli/i, '瑠璃'],
// 神楽めあ
'mea酱': [/(ミート|ミース)ソース/ig, 'めあちゃん'],
// 湊あくあ
'阿库娅': [/アクア|あくや|阿久谷|Akuya|Akua/ig, 'あくあ'],
// 猫宮ひなた
'猫宫酱': [/猫(の)?宮殿ソース/ig, '猫宮ちゃん'],
'猫宫': [/猫(の)?宮殿|キャットパレス|Cat Palace/ig, '猫宮'],
'貓宮': [/猫(の)?宮殿|キャットパレス|Cat Palace/ig, '猫宮'],
'Hinata酱': [/(ひなた|日向|Hinata)(ソース|醤油)/ig, 'ひなたちゃん'],
'hinata酱': [/(ひなた|日向|Hinata)(ソース|醤油)/ig, 'ひなたちゃん'],
'HINATA酱': [/(ひなた|日向|Hinata)(ソース|醤油)/ig, 'ひなたちゃん'],
// 物述有栖
'爱丽丝酱': [/アリスソース/g, 'ありすちゃん'],
// Paryi
'迷迭迷迭': [/ローズマリー/, '見て見て'],
'帕里桑': [/パリサン|プリッサン|パリジャン|Parrysan/g, 'Paryiさん'],
'帕里': [/パリ(ー)?|Parry/g, 'Paryi'],
'Paryi': [/パリリ/g, 'Paryi'],
'paryi': [/パリリ/g, 'Paryi'],
// 米米米
'米米米': [/ミミ/, '米米米'],
'光姬': [/広智|広義|グァンジ|ジジ|Guang()?Ji|Gigi Hime/, 'つや姫'],
'光酱': [/(ライト|軽い)ソース/, 'つやちゃん'],
'萌实里': [/メン(シリ|グリ)|Meng Shili/, '萌えみのり'],
'Milky女王': [/(ミルキー|ミルクの|牛乳)女王/, 'ミルキークイーン'],
// 洛天依
'洛天依': [/羅天(一)?|Luotianyi/, '洛天依'],
// Siva
'Siva小虾鱼': [/シバエビ|Siva Shrimp Fish/, 'Sivaえび'],
'siva小虾鱼': [/シバエビ|Siva Shrimp Fish/, 'Sivaえび'],
'虾虾': [/えび(と|や|、)えび|エビ(と|や|、)エビ|海老(と|や|、)海老|Shrimp and Shrimp|えび|エビ/, 'えびえび'],
'雪风': [/雪の風|雪|風|スノーウィンド/, '雪風'],
// ビリビリ
'哔哩哔哩': [/(哔)+(哩)+|こんにちは|ビ(ブ)?リリ/, 'ビリビリ'],
'bilibili': [/Bilibili|ビリビリ|ビ(ブ)?リリ/, 'bilibili'],
'B站': [/駅B|B駅|(B|ビー)ステーション|ステーションB|駅/i, 'ビリビリ'],
'b站': [/駅b|b駅|(b|ビー)ステーション|ステーションB|駅/i, 'ビリビリ'],
'舰长': [/船長|キャプテン/, '艦長'],
'提督': [/提督/, '提督'],
'总督': [/総督|知事/, '総督'],
'友爱社': [/愛の友達|友情協会/, '友愛社'],
'粉丝勋章': [/ファンメダル/, 'ファンバッジ'],
'金瓜子': [/(ゴールデン|金)メロンの種/, '金瓜子'],
'银瓜子': [/(シルバー|銀)メロンの種/, '銀瓜子'],
'瓜子': [/メロンの種/, '瓜子'],
'辣条': [/(スパイシー|ホット)(な)?(ストリップ|バー)/, '辣条'],
'关注': [/注意|注目|懸念|心配(されている|する)/, 'フォロー'],
// 禁断の一字置換
'酱': [/ソース/g, 'ちゃん'],
},
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.pages[sites.video.get.page() - 1].cid),
page: () => location.href.includes('?p=') ? parseInt(location.href.match(/\?p=([0-9]+)/)[1]) : 1,
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 = WAITING_LIMIT;
this.counters = {pushes: 0, registerTranslations: 0, fails: 0};
this.dictionary = this.getDictionary();
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 */
}
getDictionary(){
/* use browser language dictionary */
let dictionary;
if(Storage.read('USERLANGUAGE') !== USERLANGUAGE) dictionary = Translator.DICTIONARY;
else dictionary = Storage.read('dictionary') || Translator.DICTIONARY;
Storage.save('USERLANGUAGE', USERLANGUAGE);
dictionary = this.updateDictionary(dictionary);
return dictionary;
}
updateDictionary(dictionary){
/* update structure (2019/6/11) */
let keys = Object.keys(dictionary);
if(typeof dictionary[keys[0]] === 'string') keys.forEach(key => {
dictionary[key] = [dictionary[key], 1, NOW];
});
/* update key (2019/6/23) */
let oldKey = 'BilibiliLiveCommentTranslator';
let oldDictionary = localStorage[`${oldKey}-dictionary`], oldHistory = localStorage[`${oldKey}-history`];
if(oldDictionary && oldHistory){
dictionary = JSON.parse(oldDictionary).value;
this.history = JSON.parse(oldHistory).value;
localStorage.removeItem(`${oldKey}-dictionary`);
localStorage.removeItem(`${oldKey}-history`);
}
return dictionary;
}
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 atOnce = putOnce ? Translator.TRANSLATIONSATONCE : 10*1000;
let fragment = document.createDocumentFragment();
this.priorDanmakuQueue.reverse();/* from latest danmaku */
for(let i = 0, original; original = this.priorDanmakuQueue[i]; i++){
if(atOnce <= i) break;
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){
log(danmaku.textContent, translation);
/* it's better to modify before writing to dictionary, but MODIFICATIONS may often be updated */
Translator.MODIFICATIONSKEYS.filter(key => danmaku.textContent.includes(key)).forEach(key => {
if(DEBUG && Translator.MODIFICATIONS[key][0].test(translation) === false) log(
'Doesn\'t match:', danmaku.textContent, key, translation, Translator.MODIFICATIONS[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;
}
core.observeHead();
},
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.export();
});
},
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.export();
});
},
observeHead: function(){
/* URL変化の検出の代替 */
let head = $('head'), url = location.href;
let observer = observe(head, function(records){
if(url === location.href) return;
url = location.href;
log(head);
observer.disconnect();
core.initialize();
}, {childList: true, characterData: true, subtree: true});
},
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();
});
},
export: function(){
if(DEBUG === false) return;
window.save = translator.save.bind(translator);
window.list = function(includes, excludes = /DUMMY/){
let dictionary = translator.dictionary;
return Object.keys(dictionary).filter(key => {
return includes.test(key) && !excludes.test(dictionary[key][0]);
}).sort((a, b) => {
return dictionary[b][1] - dictionary[a][1];
}).map(key => {
return [
(new Date(dictionary[key][2])).toLocaleString(),/* used */
dictionary[key][1],/* count */
key,/* original */
dictionary[key][0],/* translation */
];
}).slice(0,100);
}
window.ranking = function(){
let dictionary = translator.dictionary;
return Object.keys(dictionary).sort((a, b) => {
return dictionary[b][1] - dictionary[a][1];
}).map(key => {
return [
(new Date(dictionary[key][2])).toLocaleString(),/* used */
dictionary[key][1],/* count */
key,/* original */
dictionary[key][0],/* translation */
];
}).slice(0,100);
};
},
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}
}
/* 放送終了後の黒背景案内 */
.bilibili-live-player-video-round-counter{
background: transparent;
}
</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);
})();