// ==UserScript== // @name * Streaming Comment Reader chan // @name:ja * 配信コメント読み上げちゃん // @name:zh-CN * 朗读直播评论酱 // @namespace knoa.jp // @description It reads comment text on streaming sites by speech synthesis. // @description:ja ライブ配信サイトの新着コメントを音声で読み上げます。 // @description:zh-CN 用声音朗读直播网站的新到来评论。 // @include https://abema.tv/* // @include https://live.bilibili.com/* // @include https://www.douyu.com/* // @include https://live.fc2.com/* // @include https://www.huajiao.com/l/* // @include https://www.huya.com/* // @include http*://www.inke.cn/live* // @include https://live.line.me/channels/*/broadcast/* // @include https://live*.nicovideo.jp/watch/* // @include https://www.openrec.tv/live/* // @include https://www.pscp.tv/w/* // @include https://www.showroom-live.com/* // @include https://twitcasting.tv/* // @include https://www.twitch.tv/* // @include https://whowatch.tv/viewer/* // @include https://www.yizhibo.com/l/* // @include https://www.youtube.com/live_chat* // @include https://www.yy.com/* // @version 1.0.1 // @grant none // @downloadURL none // ==/UserScript== (function(){ const SCRIPTID = 'StreamingCommentReader-chan'; const SCRIPTNAME = 'Streaming Comment Reader chan'; const DEBUG = false;/* [update] 1.0.1 small fixes. [to do] [possible] [research] ふわっち (デフォであるw) Flash: ドキドキ mirrativ BIGO */ if(window === top && console.time) console.time(SCRIPTID); if(!('speechSynthesis' in window)) return console.log(SCRIPTID, 'speechSynthesis undefined.'); const USERLANGUAGE = 'zh-CN' || window.navigator.language; const SITELANGUAGE = document.documentElement.lang || USERLANGUAGE; const _TEXTS = { en: { scriptname: () => `${SCRIPTNAME}`, configs: () => `${SCRIPTNAME} configs`, test: () => 'Trial', text: () => 'this is a test ABC', speech: () => 'Speech', volume: () => 'volume', pitch: () => 'pitch', voice: () => 'voice', fast: () => 'When comments flow fast', fastest: () => 'fastest', buffer: () => 'catch up latest', bufferNote: () => '* To cut off more than this number of comments for catching up latest ones.', translators: () => 'Domain specific terms', translatorsEmpty: () => 'No terms available now.', dictionary: () => 'Replacement dictionary', dictionaryNote: () => '[/source(RegExp)/, \'destination\', \'memo(optional)\'],... as Array', professional: () => '(for professional)', ng: () => 'NG words', ngNote: () => 'comma(,) separated list', reset: () => 'reset', cancel: () => 'Cancel', save: () => 'Save', dictionaryParseError: () => `Replacement dictionary error:\nrequired ${TEXTS.dictionaryNote()},\nor you can reset all preferences.`, resetConfirmation: () => `All preferences will be reset to defaults. Are you sure?`, }, ja: { scriptname: () => `配信コメント読み上げちゃん`, configs: () => `配信コメント読み上げちゃん 設定`, test: () => '試し読み', text: () => 'これはテストです ABC', speech: () => '読み上げの声', volume: () => '音量', pitch: () => '高さ', voice: () => '種類', fast: () => 'コメント混雑時', fastest: () => '速読み', buffer: () => '追いかけコメント数', bufferNote: () => '※これ以上古いコメントを切り捨てることで、読み上げがいつまでも追いつかなくなるのを防ぎます。', translators: () => '専門用語モード', translatorsEmpty: () => '専門用語が用意されていません。', dictionary: () => '置換辞書', dictionaryNote: () => '[/置換元(正規表現)/, \'置換先\', \'メモ(任意)\'],... の配列', professional: () => '(上級者向け)', ng: () => 'NGワード', ngNote: () => 'カンマ(,)区切りのリスト', reset: () => 'リセット', cancel: () => 'キャンセル', save: () => '保存', dictionaryParseError: () => `置換辞書の形式が正しくありません:\n${TEXTS.dictionaryNote()}にするか、\nまたは全ての設定値をリセットしてください。`, resetConfirmation: () => 'すべての設定が初期化されます。よろしいですか?', }, zh: { scriptname: () => `发布评论朗读`, configs: () => `发布评论阅读设置`, test: () => '试读', text: () => '这是测试ABC', speech: () => '朗读的声音', volume: () => '音量', pitch: () => '高度', voice: () => '种类', fast: () => '评论拥挤时', fastest: () => '速读', buffer: () => '追随评论数', bufferNote: () => '※通过舍弃更旧的评论,防止朗读永远跟不上。', translators: () => '术语模式', translatorsEmpty: () => '未提供专业术语', dictionary: () => '替换词典', dictionaryNote: () => '[/替换自(正则表达式)/, \'替换为\', \'注释(可选)\'],... 的数组。', professional: () => '(高级)', ng: () => 'NG字', ngNote: () => '以逗号(,)分隔的列表', reset: () => '重置', cancel: () => '取消', save: () => '保存', dictionaryParseError: () => `替换词典的格式不正确: \n${TEXTS.dictionaryNote()},或者\n将所有的设定值复位。`, resetConfirmation: () => '所有设置都将被初始化。可以吗?', }, }; const TEXTS = _TEXTS[USERLANGUAGE] || _TEXTS[USERLANGUAGE.substring(0, 2)] || _TEXTS.en; const _DICTIONARIES = { /* 置換元, 置換先, 説明(任意) */ en: { default: [ [/http:\/\/[^\s]+/, 'URL'], ], }, ja: { default: [ [/http:\/\/[^\s]+/, 'URL'], [/[88]{3,}/, 'パチパチパチ'], [/[ww]{3,}/, 'ワラワラワラ'], [/[ww]{2}/, 'ワラワラ'], [/[ww]$/, 'ワラ', '文末のみ1文字でも'], [/w/g, 'ワラ', '全角のみ1文字でも'], [/(.{1})\1{4,}/ug, '$1$1$1$1$1', '1文字の5回以上の繰り返しはカット'], [/(.{2})\1{3,}/ug, '$1$1$1$1', '2文字の4回以上の繰り返しはカット'], [/(.{3})\1{2,}/ug, '$1$1', '3文字の3回以上の繰り返しはカット'], [/(.{4,})\1{1,}/ug, '$1', '4文字以上の繰り返しはカット'], [/([あ-ん~])[~〜]/g, '$1ー', 'から => 長音'], [/はよ$/, 'ハヨ'], [/初見/, 'ショケン'], [/AbemaTV/, 'アベマティーヴィー'], [/Abema/, 'アベマ'], [/ニコ生/, 'ニコナマ'], ], niconico: [ [/^(【広告貢献[0-9]位】)?(.+)さんが([0-9]+)ptニコニ広告しました(「(.+)」)?$/, '$1、$2さんが、$3ポイント、ニコニ広告しました。$4。'], [/^(【ニコニコ新市場】)「(.+)」が貼られました$/, '$1、$2、が貼られました'], ], } }; const DICTIONARIES = _DICTIONARIES[SITELANGUAGE] || _DICTIONARIES[SITELANGUAGE.substring(0, 2)] || _DICTIONARIES.en; const _TRANSLATORS = { en: { }, ja: { '将棋': (text) => { const POSITIONS = [ [/[11一]/g, 'イチ'], [/[22二]/g, 'ニー'], [/[33三]/g, 'サン'], [/[44四]/g, 'ヨン'], [/[55五]/g, 'ゴー'], [/[66六]/g, 'ロク'], [/[77七]/g, 'ナナ'], [/[88八]/g, 'ハチ'], [/[99九]/g, 'キュー'], ]; const PIECES = [ [/王/, 'オー'], [/玉/, 'ギョク'], [/飛車/, 'ヒシャ'], [/飛/, 'ヒ'], [/角/, 'カク'], [/金/, 'キン'], [/銀/, 'ギン'], [/桂馬/, 'ケーマ'], [/桂/, 'ケー'], [/香/, 'キョー'], [/歩/, 'フ'], [/龍|竜/, 'リュー'], [/馬/, 'ウマ'], [/不成/, 'ナラズ'], [/成(?![ら-ろ])/, 'ナリ'], [/と/, 'ト'], [/同/, 'ドウ'], [/打/, 'ウツ'], [/右/, 'ミギ'], [/左/, 'ヒダリ'], [/上/, 'アガル'], [/寄/, 'ヨル'], [/引/, 'ヒク'], [/直/, 'スグ'], ]; const MOVES = [{ regexp: /([1-91-9])([1-91-9一二三四五六七八九])([王玉飛車角金銀桂香歩龍竜馬成と同不打右左上寄引直]+).?/g, replacement: [...POSITIONS, ...PIECES], }, { regexp: /([1-91-9])([1-91-9一二三四五六七八九])(?=[あ-ん指取成走入跳突叩攻守]|$)/g, replacement: [...POSITIONS], }, { regexp: /([王玉飛車角金銀桂香歩龍竜馬成と同不打右左上寄引直]{2,}).?/g, replacement: [...PIECES], }]; const MODIFICATIONS = [ /* 固有名詞 */ [/大山/, 'オーヤマ'], [/中原/, 'ナカハラ'], [/羽生/, 'ハブ'], [/豊島/, 'トヨシマ'], [/天彦/, 'アマヒコ'], [/高見/, 'タカミ'], [/イトシン(TV|TV)/i, 'イトシンティーヴィー'], [/朝日杯/, 'アサヒハイ'], [/NHK杯/, 'エネーチケーハイ'], [/棋神/, 'キシン'], [/elmo/, 'エルモ'], /* 用語 */ [/評価値/, 'ヒョーカチ'], [/AI/, 'エーアイ'], [/将棋星人/, 'ショーギセージン'], [/級位者/, 'キューイシャ'], [/先手/g, 'センテ'], [/後手/g, 'ゴテ'], [/一手/g, 'イッテ'], [/早指し/, 'ハヤザシ'], [/早逃げ/, 'ハヤニゲ'], [/最善手/, 'サイゼンシュ'], [/筋悪/, 'スジワル'], [/長手数/, 'チョーテスー'], [/余詰(め)?/, 'ヨヅメ'], [/([1-91-9一-九])冠/, '$1カン'], [/\s対\s/, ' タイ '], [/vs|vs/, 'ブイエス'], /* 戦型 */ [/定跡型/, 'ジョーセキケー'], [/力戦型/, 'リキセンケー'], [/戦型/, 'センケー'], [/右玉/, 'ミギギョク'], [/相居飛車/, 'アイイビシャ'], [/相(掛|懸)(かり)?/, 'アイガカリ'], [/横歩取り/, 'ヨコフドリ'], [/居飛車/, 'イビシャ'], [/振(り)?飛車/, 'フリビシャ'], [/中飛車/, 'ナカビシャ'], [/四間飛車/, 'シケンビシャ'], [/四間/, 'シケン'], [/三間飛車/, 'サンケンビシャ'], [/三間/, 'サンケン'], [/向(かい)?飛車/, 'ムカイビシャ'], [/早石田/, 'ハヤイシダ'], [/角(換|替)わり/, 'カクガワリ'], [/角交換/, 'カクコーカン'], [/一手損/, 'イッテゾン'], /* 囲い */ [/居玉/, 'イギョク'], [/中住まい/, 'ナカズマイ'], [/(舟|船)囲い/, 'フナガコイ'], /* 駒(1文字は特に最後へ) */ [/大駒/, 'オーゴマ'], [/金駒/, 'カナゴマ'], [/小駒/, 'コゴマ'], [/玉頭/, 'ギョクトー'], [/王手飛車/, 'オーテビシャ'], [/角頭/, 'カクトー'], [/桂頭/, 'ケートー'], [/二歩/, 'ニフ'], [/と金/, 'とキン'], [/金底の歩/, 'キンゾコのフ'], [/玉/g, 'ギョク'],/*(? { let tes = text.match(p.regexp); if(tes !== null) tes.forEach(te => { let yomi = te; p.replacement.forEach(p => yomi = yomi.replace(p[0], p[1])); text = text.replace(te, yomi); }); }); /* 用語 */ MODIFICATIONS.forEach(m => text = text.replace(m[0], m[1])); return text; }, }, }; const TRANSLATORS = _TRANSLATORS[SITELANGUAGE] || _TRANSLATORS[SITELANGUAGE.substring(0, 2)] || _TRANSLATORS.en; const UNKNOWNPITCHRATIO = .5;/* 不明コメントのピッチ係数 */ const RETRY = 10; let sites = { abema: { id: 'abema', url: /^https:\/\/abema\.tv/, reverse: false, insertBefore: false, targets: { board: () => $('.com-a-OnReachTop > div'), settingAnchor: () => $('.com-tv-TVController__volume'), }, addedNodes: { name: (node) => null, content: (node) => node.querySelector('div > p > span'), read: [ [1.0, (node) => (node.querySelector('time[datetime]') !== null)], ], ignore: [], } }, bilibili: { id: 'bilibili', url: /^https:\/\/live\.bilibili\.com\/[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('#chat-history-list'), settingAnchor: () => $('.icon-right-part > *:last-child'), }, addedNodes: { name: (node) => node.querySelector('.user-name'), content: (node) => node.querySelector('.danmaku-content'), read: [ [1.500, (node) => node.classList.contains('guard-level-3')], [1.250, (node) => node.classList.contains('guard-level-2')], [1.125, (node) => node.classList.contains('guard-danmaku')], [1.000, (node) => node.classList.contains('danmaku-item')], ], ignore: [ [0.0, (node) => node.classList.contains('system-msg')], [0.0, (node) => node.classList.contains('welcome-msg')], ], } }, douyu: { id: 'douyu', url: /^https:\/\/www\.douyu\.com\/.+/, reverse: false, insertBefore: false, targets: { board: () => $('#js-barrage-list'), settingAnchor: () => $('.ChatToolBar > *:last-child'), }, addedNodes: { name: (node) => node.querySelector('.Barrage-nickName'), content: (node) => node.querySelector('.Barrage-content'), read: [ [1.25, (node) => (node.querySelector('.Barrage-message') !== null)], [1.00, (node) => (node.querySelector('.Barrage-notice--normalBarrage') !== null)], ], ignore: [ [0.0, (node) => (node.querySelector('.Barrage-userEnter') !== null)], [0.0, (node) => (node.querySelector('.Barrage-notice') !== null)], ], } }, fc2: { id: 'fc2', url: /^https:\/\/live\.fc2\.com\/[0-9]+/, reverse: false, insertBefore: true, targets: { board: () => $('#js-commentListContainer'), settingAnchor: () => $('.chat_tab-control > *:first-child'), }, addedNodes: { name: (node) => node.querySelector('.js-commentUserName'), content: (node) => node.querySelector('.js-commentText'), read: [ [1.0, (node) => node.classList.contains('js-commentLine')], ], ignore: [], } }, huajiao: { id: 'huajiao', url: /^https:\/\/www\.huajiao\.com\/l\/[0-9]+/, reverse: false, insertBefore: true, targets: { board: () => $('.tt-msg-list'), settingAnchor: () => $('.tt-type-form'), }, addedNodes: { name: (node) => node.querySelector('.tt-msg-nickname'), content: (node) => node.querySelector('.tt-msg-content-h5'), read: [ [1.0, (node) => node.classList.contains('.tt-msg-message')], ], ignore: [], } }, huya: { id: 'huya', url: /^https:\/\/www\.huya\.com\/.+/, reverse: false, insertBefore: true, targets: { board: () => $('#chat-room__list'), settingAnchor: () => $('.room-chat-tools > *:first-child'), }, addedNodes: { name: (node) => node.querySelector('.name'), content: (node) => node.querySelector('.msg'), read: [ [1.25, (node) => (node.querySelector('.msg-nobleSpeak') !== null)], [1.00, (node) => (node.querySelector('.msg') !== null)], ], ignore: [ [0.0, (node) => (node.querySelector('.msg-nobleEnter') !== null)], ], } }, inke: { id: 'inke', url: /^https?:\/\/www\.inke\.cn\/live.+/, reverse: false, insertBefore: true, targets: { board: () => $('.comments_list > ul'), settingAnchor: () => $('.comments_box > input[type="text"]'), }, addedNodes: { name: (node) => node.querySelector('li > span'), content: (node) => node.querySelector('.comments_text') || node.querySelector('.comments_gift'), read: [ [1.0, (node) => (node.querySelector('img + span + span.comments_text') !== null)], [1.0, (node) => (node.querySelector('img + span + span.comments_gift') !== null)], ], ignore: [], }, }, line: { id: 'line', url: /^https:\/\/live\.line\.me\/channels\/[0-9]+\/broadcast\/[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('[class*="Comment"] > div + div > [class*="Scroll"]'), settingAnchor: () => $('[class*="Notice"] > [class*="Desc"] > span'), }, addedNodes: { name: (node) => node.querySelector('[class*="Head"]'), content: (node) => node.querySelector('[class*="Heart"]') || node.querySelector('[class*="Desc"]') || node, read: [ [1.0, (node) => node.className.includes('Label')], [1.0, (node) => node.className.includes('Chat')], ], ignore: [], } }, niconico: { id: 'niconico', url: /^https:\/\/live[0-9]+\.nicovideo\.jp\/watch\/[a-z]+[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('[class*="_comment-panel_"] [class*="_table_"]'), settingAnchor: () => $('[class*="_setting-button_"]'), }, addedNodes: { name: (node) => node.querySelector('[class*="_comment-author-name_"]'), content: (node) => node.querySelector('[class*="_comment-text_"]'), read: [ [1.0, (node) => (node.dataset.commentType === 'nicoad')], [1.0, (node) => (node.dataset.commentType === 'normal')], [0.9, (node) => (node.dataset.commentType === 'trialWatch')], [0.5, (node) => (node.dataset.commentType === 'operator')], ], ignore: [], } }, openrec: { id: 'openrec', url: /^https:\/\/www\.openrec\.tv\/live\/.+/, reverse: false, insertBefore: true, targets: { board: () => $('.chat-list-content'), settingAnchor: () => $('[class*="InputArea__ToolbarItem-"]'), }, addedNodes: { name: (node) => node.querySelector('[class*="UserName__Name-"]'), content: (node) => node.querySelector('.chat-content'), read: [ [1.0, (node) => node.className.includes('ChatList__CellContainer-')], ], ignore: [ [0.0, (node) => node.className.includes('system-chat')], ], } }, periscope: { id: 'periscope', url: /^https:\/\/www\.pscp\.tv\/w\/.+/, reverse: false, insertBefore: false, targets: { board: () => $('.Chat > div[style] > div[style]'), settingAnchor: () => $('.VideoOverlayRedesign-BottomBar-Right > *:last-child'), }, addedNodes: { name: (node) => node.querySelector('.CommentMessage-username'), content: (node) => node.querySelector('.CommentMessage-message'), read: [ [1.0, (node) => (node.querySelector('.CommentMessage') !== null)], ], ignore: [ [0.0, (node) => (node.querySelector('.ParticipantMessage') !== null)], ], } }, showroom: { id: 'showroom', url: /^https:\/\/www\.showroom-live\.com\/.+/, reverse: true, insertBefore: true, targets: { board: () => $('#room-comment-log-list'), settingAnchor: () => $('#js-room-head-other-select-box').parentNode, }, addedNodes: { name: (node) => node.querySelector('.comment-log-name'), content: (node) => node.querySelector('.comment-log-comment'), read: [ [1.0, (node) => node.classList.contains('commentlog-row')], ], ignore: [], } }, twitcasting: { id: 'twitcasting', url: /^https:\/\/twitcasting\.tv\/.+/, reverse: true, insertBefore: false, targets: { board: () => $('.tw-player-comment-list'), settingAnchor: () => $('#commentnumarea'), }, addedNodes: { name: (node) => node.querySelector('.tw-comment-item-name'), content: (node) => node.querySelector('.tw-comment-item-comment'), read: [ [1.0, (node) => node.className.includes('tw-comment-item')], ], ignore: [], } }, twitch: { id: 'twitch', url: /^https:\/\/www\.twitch\.tv/, reverse: false, insertBefore: true, targets: { board: () => $('[role="log"]'), settingAnchor: () => $('.chat-input__buttons-container [aria-describedby]'), }, addedNodes: { name: (node) => node.querySelector('.chat-author__display-name'), content: (node) => node.querySelector('.text-fragment'), read: [ [1.0, (node) => node.className.includes('chat-line__message')], ], ignore: [], } }, whowatch: { id: 'whowatch', url: /^https:\/\/whowatch\.tv\/viewer\/[0-9]+/, reverse: true, insertBefore: true, targets: { board: () => $('.normal-comment-list > div'), settingAnchor: () => $('.limit'), }, addedNodes: { name: (node) => node.querySelector('.user-name'), content: (node) => node.querySelector('.message'), read: [ [1.0, (node) => node.classList.contains('comment-box')], ], ignore: [], }, }, yizhibo: { id: 'yizhibo', url: /^https:\/\/www\.yizhibo\.com\/l\/.+/, reverse: false, insertBefore: true, targets: { board: () => $('#J_msglist'), settingAnchor: () => $('#J_send_danmu'), }, addedNodes: { name: (node) => node.querySelector('.nickname'), content: (node) => node.querySelector('.content'), read: [ [1.0, (node) => node.classList.contains('msg_1')], ], ignore: [ [0.0, (node) => node.classList.contains('msg_2')], [0.0, (node) => node.classList.contains('msg_3')], ], }, }, youtube: { id: 'youtube', url: /^https:\/\/www\.youtube\.com\/live_chat/, reverse: false, insertBefore: true, targets: { board: () => $('#item-offset > #items'), settingAnchor: () => $('yt-live-chat-header-renderer yt-icon-button'), }, addedNodes: { name: (node) => node.querySelector('#author-name'), content: (node) => node.querySelector('#message'), read: [ [1.5, (node) => (node.localName === 'yt-live-chat-paid-message-renderer'), 'スパチャ'], [1.0, (node) => node.classList.contains('yt-live-chat-item-list-renderer')], ], ignore: [ [0.0, (node) => (node.localName === 'yt-live-chat-viewer-engagement-message-renderer')], ], }, }, yy: { id: 'yy', url: /^https:\/\/www\.yy\.com\/[0-9]+\/[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('.chatroom-list'), settingAnchor: () => $('.chat-room-ft'), }, addedNodes: { name: (node) => node.querySelector('.nickname'), content: (node) => node.querySelector('.nickname + span'), read: [ [1.0, (node) => node.classList.contains('phizbox')], ], ignore: [], }, }, }; class Configs{ constructor(configs){ Configs.DICTIONARY = [...DICTIONARIES.default, ...(DICTIONARIES[site.id] || [])]; Configs.TRANSLATORS = Object.keys(TRANSLATORS); Configs.PROPERTIES = { text: {type: 'string', default: TEXTS.text()}, volume: {type: 'int', default: 25},/* 0-100 => 0.0-1.0 */ pitch: {type: 'int', default: 100},/* 0-200 => 0.0-2.0 */ voice: {type: 'string', default: ''},/* name of voice */ fastest: {type: 'int', default: 150},/* 100-250 => 1.0-2.5 */ buffer: {type: 'int', default: 5},/* 1- 25 */ dictionary: {type: 'array', default: Configs.DICTIONARY},/* replacement pairs */ translators: {type: 'array', default: []},/* name of translators */ ngs: {type: 'array', default: []},/* ng word list */ }; this.data = this.read(configs || {}); } read(configs){ let newConfigs = {}; Object.keys(Configs.PROPERTIES).forEach(key => { if(configs[key] === undefined) return newConfigs[key] = Configs.PROPERTIES[key].default; if(key === 'dictionary') return newConfigs[key] = configs[key].map(entry => { if(entry[0] instanceof RegExp) return entry; let parts = entry[0].match(/^\/(.*)\/([a-z]*)$/); if(parts === null) entry[0] = new RegExp(entry[0]); else entry[0] = new RegExp(parts[1], parts[2]); return entry; }); switch(Configs.PROPERTIES[key].type){ case('bool'): return newConfigs[key] = (configs[key]) ? 1 : 0; case('int'): return newConfigs[key] = parseInt(configs[key]); case('float'): return newConfigs[key] = parseFloat(configs[key]); default: return newConfigs[key] = configs[key]; } }); return newConfigs; } toJSON(){ let json = {}; Object.keys(this.data).forEach(key => { switch(key){ case('dictionary'): return json[key] = this.data[key].map(entry => { if(entry[2] === undefined) return [entry[0].toString(), entry[1]]; else return [entry[0].toString(), entry[1], entry[2]]; }); default: return json[key] = this.data[key]; } }); return json; } parseDictionaryString(string){ let wrapper = string.trim().match(/^\[([\S\s]+)\]$/); if(wrapper === null) return false; let entries = wrapper[1].trim().match(/\[(.+)\]\s*,/g); if(entries === null) return false; let lines = wrapper[1].trim().match(/.{3,}(\n|$)/g); if(lines.length !== entries.length) return false; let dictionary = []; for(let i = 0; entries[i]; i++){ let parts = entries[i].trim().match(/\[\s*\/(.*)\/([a-z]*)\s*,\s*'(.*?[^\\])'(?:\s*,\s*'(.*[^\\])')?\s*\]\s*,/); if(parts === null) return false; dictionary[i] = [new RegExp(parts[1], parts[2]), parts[3]]; if(parts[4] !== undefined) dictionary[i].push(parts[4]); } return dictionary; } parseNgsString(string){ if(string.trim() === '') return []; else return string.trim().split(','); } get text(){return this.data.text;} get volume(){return this.data.volume / 100;} get pitch(){return this.data.pitch / 100;} get voice(){return this.data.voice;} get fastest(){return this.data.fastest / 100;} get buffer(){return this.data.buffer;} get dictionary(){return this.data.dictionary;} get translators(){return this.data.translators;} get ngs(){return this.data.ngs;} get dictionaryString(){ let dictionary = this.data.dictionary, string = ''; let quote = (s) => '\'' + s.replace('\'', '\\\'') + '\''; dictionary.forEach(entry => { string += ' ['; string += entry[0].toSource(); string += ', '; string += quote(entry[1]); if(entry[2] !== undefined){ string += ', '; string += quote(entry[2]); } string += '],\n'; }); return '[\n' + string + ']'; } get ngsString(){ return this.data.ngs.join(','); } } class Speaker{ constructor(configs){ Speaker.TRANSLATORS = TRANSLATORS; this.speechSynthesis = speechSynthesis; this.voices = this.getVoices(); this.configs = configs; this.queue = []; this.interval = 250; } getVoices(){ let voices = {}, array = this.speechSynthesis.getVoices(); if(array.length) array.forEach(v => voices[v.name] = v); else this.speechSynthesis.addEventListener('voiceschanged', () => this.voices = this.getVoices()); return voices; } request(text, ratio, node){ let utterance = new SpeechSynthesisUtterance(this.modify(text)); utterance.pitch = this.configs.pitch * ratio; utterance.node = node; this.queue.push(utterance); if(this.queue.length === 1){/* 2個以上あるならすでに連続発話が始まっている */ setTimeout(() => this.speak(), 0);/* 一度に複数リクエストを受け取った際に合計数をrateに反映させたい */ } } modify(text){ this.configs.dictionary.forEach(d => text = text.replace(d[0], d[1])); this.configs.translators.forEach(key => text = Speaker.TRANSLATORS[key](text)); return text; } speak(){ if(this.queue.length === 0) return; if(this.configs.ngs.some(ng => this.queue[0].text.includes(ng))) return this.queue.shift(), this.speak(); if(this.queue.length > this.configs.buffer) this.queue = this.queue.slice(-this.configs.buffer);/*古いものは切り捨てる*/ let utterance = this.queue[0]; utterance.volume = this.configs.volume; utterance.rate = 1 + ((this.queue.length - 1) / ((this.configs.buffer - 1) || 1))*(this.configs.fastest - 1); utterance.voice = this.voices[this.configs.voice]; utterance.node.dataset.speaking = 'true'; utterance.addEventListener('end', (e) => { utterance.node.dataset.speaking = 'false'; this.queue.shift(); if(this.queue.length) setTimeout(() => this.speak(), this.interval); }); log(utterance); this.speechSynthesis.speak(utterance); } cancel(){ this.queue = []; this.speechSynthesis.cancel(); } test(text, volume, pitch, voice, rate){ let utterance = new SpeechSynthesisUtterance(this.modify(text)); utterance.volume = volume; utterance.pitch = pitch; utterance.voice = this.voices[voice]; utterance.rate = rate; this.speechSynthesis.speak(utterance); } } let html, elements = {}, timers = {}, site, configs, speaker; let core = { initialize: function(){ html = document.documentElement; html.classList.add(SCRIPTID); core.site(RETRY); }, site: function(retry){ site = sites[Object.keys(sites).find(key => sites[key].url.test(location.href))]; if(site === undefined) return log('Doesn\'t match any sites:', location.href); core.read(); core.observeElements(); core.addStyle(); core.addStyle(site.id); core.addStyle('stylePanels', window.top.document); }, observeElements: function(){ /* 開閉する要素に対応。結局インターバルがいちばん負荷が軽い */ setInterval(function(){ new Promise(function(resolve, reject){ if(elements.settingAnchor && elements.settingAnchor.isConnected) return resolve(); elements.settingAnchor = site.targets.settingAnchor(); if(elements.settingAnchor){ core.configs.createButton(); log("Configs button ready."); return resolve(); }else{ return reject(); } }).then(() => { if(elements.board && elements.board.isConnected) return; elements.board = site.targets.board(); if(elements.board){ core.observeBoard(elements.board); log("Board ready."); } }); }, 1000); }, read: function(){ panels = new Panels(window.top.document.body.appendChild(createElement(core.html.panels()))); configs = new Configs(Storage.read('configs') || {}); speaker = new Speaker(configs); }, observeBoard: function(board){ let configButton = elements.configButton; let isNewer = function(node){ if(site.reverse){ for(let i = 0; board.children[i]; i++){ if(node === board.children[i]) return true; if(i >= configs.buffer) return false; } }else{ for(let i = board.children.length - 1; board.children[i]; i--){ if(node === board.children[i]) return true; if(board.children.length - i >= configs.buffer) return false; } } }; observe(board, function(records){ //log(records); if(configButton.classList.contains('active') === false) return; if(site.reverse) records.reverse(); records.forEach(r => { r.addedNodes.forEach(n => { if(isNewer(n) === false) return;/*最後のbuffer個数分でなければ無視してよい*/ let name = site.addedNodes.name(n); let content = site.addedNodes.content(n); if(content === null || content.textContent.trim() === '') return; let read = site.addedNodes.read.find(r => r[1](n)); if(read) return speaker.request(content.textContent, read[0], content); if(site.addedNodes.ignore.some(i => i[1](n))) return; speaker.request(content.textContent, UNKNOWNPITCHRATIO, content); }); }); }); }, configs: { createButton: function(){ let anchor = elements.settingAnchor, before = site.insertBefore; let node, configButton = elements.configButton = createElement(core.html.configButton(core.html.configButtonProperties[site.id])); if(core.html.configButtonWrappers[site.id]){ node = createElement(core.html.configButtonWrappers[site.id]()); node.appendChild(configButton); }else{ node = configButton; } node.className = [node.className, anchor.className].join(' '); configButton.addEventListener('click', function(e){ configButton.classList.toggle('active'); if(configButton.classList.contains('active') === false) speaker.cancel(); }); configButton.addEventListener('contextmenu', function(e){ e.preventDefault(); panels.toggle('configs'); }); anchor.parentNode.insertBefore(node, (before ? anchor : anchor.nextElementSibling)); core.configs.createPanel(); }, createPanel: function(){ let panel = createElement(core.html.configPanel()), itemElements = panel.querySelectorAll('[name]'), items = {}; Array.from(itemElements).forEach(e => items[e.name] = e); /* リセット */ panel.querySelector('button.reset').addEventListener('click', function(e){ if(confirm(TEXTS.resetConfirmation())){ panels.hide('configs'); configs = new Configs({}); core.configs.createPanel(); panels.show('configs'); } }); /* 試し読み */ let normal = panel.querySelector('button.normal'), fast = panel.querySelector('button.fast'); let getValue = (node) => (parseInt(node.value) / 100); normal.addEventListener('click', function(e){ speaker.test(items.text.value, getValue(items.volume), getValue(items.pitch), items.voice.value, 1); }); fast.addEventListener('click', function(e){ speaker.test(items.text.value, getValue(items.volume), getValue(items.pitch), items.voice.value, getValue(items.fastest)); }); /* 声 */ let currentVoice = speaker.voices[configs.voice || Object.keys(speaker.voices).find(key => speaker.voices[key].default)], languages = [], voices = []; Object.keys(speaker.voices).forEach(key => { if(languages.includes(speaker.voices[key].lang) === false) languages.push(speaker.voices[key].lang); voices.push(key); }); languages.sort().forEach(l => { let option = createElement(core.html.option(l)); if(l === currentVoice.lang) option.selected = true; items.language.appendChild(option); }); voices.sort().forEach(v => { let option = createElement(core.html.option(v)); if(speaker.voices[v].lang !== currentVoice.lang) option.classList.add('hidden'); if(v === currentVoice.name) option.selected = true; items.voice.appendChild(option); }); items.language.addEventListener('change', function(e){ Array.from(items.voice.children).reverse().forEach(o => { if(speaker.voices[o.value].lang === e.target.value){ o.classList.remove('hidden'); o.selected = true; } else o.classList.add('hidden'); }); }); /* 専門用語モード */ let translatorTemplate = createElement(core.html.checkbox('translators', 'template')), translatorsEmpty = panel.querySelector('.translatorsEmpty'); items.translators = []; Object.keys(TRANSLATORS).forEach(key => { let label = translatorTemplate.cloneNode(true), input = label.querySelector('input[type="checkbox"]'); label.dataset.translator = key; input.value = key; input.checked = configs.translators.some(t => (t === key)); translatorsEmpty.parentNode.insertBefore(label, translatorsEmpty.parentNode.firstElementChild); items.translators.push(input); }); /* キャンセル */ panel.querySelector('button.cancel').addEventListener('click', function(e){ panels.hide('configs'); core.configs.createPanel();/*クリアしておく*/ }); /* 保存 */ panel.querySelector('button.save').addEventListener('click', function(e){ let dictionary = configs.parseDictionaryString(items.dictionary.value); if(dictionary === false) return alert(TEXTS.dictionaryParseError()); configs = new Configs({ text: items.text.value, volume: items.volume.value, pitch: items.pitch.value, voice: items.voice.value, fastest: items.fastest.value, buffer: items.buffer.value, translators: Array.from(items.translators).filter(t => t.checked).map(t => t.value), dictionary: dictionary, ngs: configs.parseNgsString(items.ngs.value), }); speaker.cancel(); speaker = new Speaker(configs); Storage.save('configs', configs.toJSON()); panels.hide('configs'); core.configs.createPanel();/*クリアしておく*/ }); panels.add('configs', panel); }, }, addStyle: function(name = 'style', d = document){ if(core.html[name] === undefined) return; let style = createElement(core.html[name]()); d.head.appendChild(style); if(elements[name] && elements[name].isConnected) d.head.removeChild(elements[name]); elements[name] = style; }, html: { configButtonWrappers: { showroom: () => `
  • `, }, configButtonProperties: { niconico: 'aria-label', }, configButton: (property = 'title') => ` `, configPanel: () => `

    ${TEXTS.configs()}

    ${TEXTS.test()}

    ${TEXTS.speech()}

    ${TEXTS.fast()}

    ${TEXTS.translators()}

    ${TEXTS.translatorsEmpty()}

    ${TEXTS.dictionary()}${TEXTS.professional()}

    ${TEXTS.dictionaryNote()}

    ${TEXTS.ng()}

    ${TEXTS.ngNote()}

    `, option: (value) => ``, checkbox: (key, value) => ``, panels: () => `
    `, stylePanels: () => ` `, style: () => ` `, abema: () => ` `, bilibili: () => ` `, douyu: () => ` `, fc2: () => ` `, huajiao: () => ` `, huya: () => ` `, inke: () => ` `, line: () => ` `, niconico: () => ` `, openrec: () => ` `, periscope: () => ` `, showroom: () => ` `, twitcasting: () => ` `, twitch: () => ` `, whowatch: () => ` `, yizhibo: () => ` `, youtube: () => ` `, yy: () => ` `, }, }; const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame; const alert = window.alert, confirm = window.confirm, getComputedStyle = window.getComputedStyle, fetch = window.fetch, speechSynthesis = window.speechSynthesis; if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}}); class Storage{ static key(key){ return (SCRIPTID) ? (SCRIPTID + '-' + 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; } } class Panels{ constructor(parent){ this.parent = parent; this.panels = {}; this.listen(); } listen(){ window.addEventListener('keydown', (e) => { if(e.key !== 'Escape') return; if(['input', 'textarea'].includes(document.activeElement.localName)) return; Object.keys(this.panels).forEach(key => this.hide(key)); }, true); } add(name, panel){ this.panels[name] = panel; } toggle(name){ let panel = this.panels[name]; if(panel.isConnected === false || panel.classList.contains('hidden')) this.show(name); else this.hide(name); } show(name){ let panel = this.panels[name]; if(panel.isConnected) return; panel.classList.add('hidden'); this.parent.appendChild(panel); this.parent.dataset.panels = parseInt(this.parent.dataset.panels) + 1; animate(() => panel.classList.remove('hidden')); } hide(name){ let panel = this.panels[name]; if(panel.classList.contains('hidden')) return; panel.classList.add('hidden'); panel.addEventListener('transitionend', (e) => { this.parent.removeChild(panel); this.panels.dataset.panels = parseInt(this.panels.dataset.panels) - 1; }, {once: true}); } } const $ = function(s, f){ let target = document.querySelector(s); if(target === null) return null; return f ? f(target) : target; }; const $$ = function(s){return document.querySelectorAll(s)}; const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; const createElement = function(html = ''){ 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 normalize = function(string){ return string.replace(/[!-~]/g, function(s){ return String.fromCharCode(s.charCodeAt(0) - 0xFEE0); }).replace(normalize.RE, function(s){ return normalize.KANA[s]; }).replace(/ /g, ' ').replace(/~/g, '〜'); }; normalize.KANA = { ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ', ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ', ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド', バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ', パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ', ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ', ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ', カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ', サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ', タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト', ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ', ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ', マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ', ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ', ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ', ワ:'ワ', ヲ:'ヲ', ン:'ン', ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ', ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ', "。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・', }; normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g'); 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( (SCRIPTID || '') + ':', /* 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 \(/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \()/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(SCRIPTID); })();