ニコ生立ち見開放コメビュ

立ち見に飛ばされても気分はアリーナ。簡易的なNG機能あり。

// ==UserScript==
// @name         ニコ生立ち見開放コメビュ
// @namespace    https://greasyfork.org/ja/users/292779-kinako
// @version      1.5
// @description  立ち見に飛ばされても気分はアリーナ。簡易的なNG機能あり。
// @author       kinako
// @match        https://live2.nicovideo.jp/watch/*
// @grant        none
// @run-at       document-start
// @compatible   chrome
// ==/UserScript==

(function() {
    'use strict';

    class Controller
    {
        constructor(model)
        {
            this._model = model;
        }

        router(flag = null, data = null)
        {
            switch (flag)
            {
                case 'comment_post':
                    this._model.postComment(data);
                    break;
                case 'ng_comment_user':
                    this._model.addNgUser(data);
                    break;
                default:
                    this._model.openSocket();
                    break;
            }
        }
    }


    class Model
    {
        constructor()
        {
            this.ng_comment_users = [];
            this.ng_comment_keyword = new RegExp(
                '[まマ].*[こコ古].*[じジ事].*[きキ記]|' +
                '[mMmM].*[aAaA].*[kKkKcCcC].*[oOoO].*[jJjJ].*[iIiI].*[kKkK].*[iIiI]');

            this.liveOpenTime = null;
            this.liveId = null;
            this.commentAPI = null;
            this.defaultComment = null;
            this.isHook = null;
        }

        dispatchEvent(eventType, target, regArray=[])
        {
            if (target)
            {
                let result = false;
                for (const reg of regArray)
                {
                    switch (reg) {
                        case 'comment_user':
                            result = this.commentUserRegEx(target);
                            break;
                        case 'comment_keyword':
                            result = this.commentKeywordRegEx(target);
                            break;
                    }
                    if (result) break;
                }
                if (result)
                {
                    eventType += 'FilterMatched';
                }
                target.dispatchEvent(new Event(eventType, {"bubbles":true}));
            }
        }

        commentUserRegEx(target)
        {
            const data = JSON.parse(target.getAttribute('data'));
            return (this.ng_comment_users.includes(data.user_id))? true: false;
        }

        commentKeywordRegEx(target)
        {
            const data = JSON.parse(target.getAttribute('data'));
            return (this.ng_comment_keyword.test(data.content))? true: false;
        }

        addNgUser(data)
        {
            if (/\w+/.test(data)) this.ng_comment_users.push(data);
        }

        openSocket()
        {
            const programId = /lv[0-9]+/.exec(location.href);
            if (programId)
            {
                const xhr = new XMLHttpRequest();

                xhr.onreadystatechange = (e)=> {
                    if (xhr.readyState == 4) {
                        if (xhr.status == 200) {
                            const res = JSON.parse(xhr.responseText);
                            //console.log(res);
                            this.liveOpenTime = Date.parse(res.data.onAirTime.beginAt) / 1000;
                            this.liveId = programId;
                            this.commentAPI = {messageServer: res.data.messageServer,
                                               threads: res.data.threads};

                            if (res.data.liveCycle == 'on_air')
                            {
                                document.addEventListener("DOMContentLoaded", (e)=>{
                                    this.dispatchEvent('commentDefault', document);
                                });
                                this.initComment();
                                window.WebSocket = new Proxy(WebSocket, this.openSocketHandler());
                            }
                        } else {
                            console.log("status = " + xhr.status);
                        }
                    }
                };
                // 番組情報取得
                xhr.open("GET", `https://api.cas.nicovideo.jp/v1/services/live/programs/${programId}`);
                xhr.withCredentials = true;
                xhr.send();
            }
        }

        openSocketHandler()
        {
            return {
                construct: function(target, args) {

                    const ws = new target(...args);

                    ws.onmessage = (e)=>{

                        if (Object.prototype.toString.call(e.data) == '[object String]') {
                            const data = JSON.parse(e.data);

                            if (data.ping && data.ping.content == 'rf:0') {
                                ws.close();

                            } else if (data.thread){
                                if (data.thread.resultcode !== 0) this.dispatchEvent('openSocketError', document);

                            } else if (data.chat) {
                                this.chatEvent(data.chat);
                            }
                        }
                    };

                    ws.onclose = (e)=> {
                        window.WebSocket = new Proxy(WebSocket,this.commentApiHandler());
                    };

                    //ws.onerror = function(e) {console.log(e);};
                    return ws;
                }.bind(this)
            }
        }

        commentApiHandler()
        {
            return {
                construct: function(target, args)
                {
                    const ms = this.commentAPI.messageServer;
                    const td = this.commentAPI.threads;

                    if (!this.isHook) {
                        this.isHook = true;

                        args = [ms.wss, "msg.nicovideo.jp#json"];
                        const ws = new target(...args);

                        ws.onopen = (e) =>
                        {
                            const req = [{ thread: { version:ms.version, thread:td.chat, service:ms.service }},
                                         { thread: { version:ms.version, thread:td.store, service:ms.service }}];
                            ws.send(JSON.stringify(req));
                            setInterval((e)=>{ws.send('')}, 50000);
                        };

                        ws.onmessage = (e)=> {
                            if (Object.prototype.toString.call(e.data) == '[object String]') {
                                const data = JSON.parse(e.data);

                                if (data.length == 2 && data[0].thread){
                                    if (data[0].thread.resultcode !== 0) this.dispatchEvent('commentApiError', document);

                                } else if (data.chat) {
                                    this.chatEvent(data.chat);
                                }
                            }
                        };
                        //ws.onerror = function(e) {console.log('error', e);};
                        //ws.onclose = function(e) {console.log('close', e)};
                    }
                }.bind(this)
            };
        }

        chatEvent(data)
        {
            const chatData = {no:data.no, content:data.content,
                              user_id:data.user_id, premium:data.premium };
            const chatContainer = document.getElementById('commentdata');
            chatContainer.setAttribute('data', JSON.stringify(chatData));
            this.dispatchEvent('chat', chatContainer, ['comment_user', 'comment_keyword']);
        }

        initComment()
        {
            const mo_option = {childList: true, subtree: true},
                  mo = new MutationObserver((mr, mo)=>{
                // 通信エラーダイアログ
                const button = document.querySelector('div[class^="___dialog-layer-old___"] div[class^="___body___"] button');
                if (button){
                    mo.disconnect();
                    button.click();
                }
                // 部屋名
                const room = document.querySelector('p[class^="___room-name___"]:not([loaded])');
                if (room && !/\-|(co|ch)[0-9]+|アリーナ($|[\s ]?最前列)/.test(room.textContent)) {
                    room.setAttribute('loaded', '');
                    this.dispatchEvent('roomName', room);
                }
            });
            mo.observe(document, mo_option);

            let mo2 = new MutationObserver((mr2, mo2)=>{
                const parent = document.querySelector('div[id^="root"]');
                if (parent)
                {
                    mo2.disconnect();
                    mo2 = new MutationObserver((_mr, _mo)=>{
                        for (const r of _mr)
                        {
                            if (/^___leo-player___/.test(r.target.className) && r.addedNodes.length > 0
                                && /^___player-status___/.test(r.addedNodes[0].className))
                            {
                                this.dispatchEvent('chatVisibley', document);
                            }
                        }
                    });
                    mo2.observe(parent, mo_option);
                }
            });
            mo2.observe(document, mo_option);
        }

        postComment(data)
        {
            if (data.message)
            {
                let cmmd = (data.command.length== 0)? []: data.command.split(' ');
                if (data.anonymous) cmmd.unshift('184');
                cmmd = cmmd.join(' ');

                const vpos = (Math.floor(new Date().getTime() / 1000) - this.liveOpenTime) * 100;

                const xhr = new XMLHttpRequest();
                const req = {
                    message: data.message,
                    command: cmmd,
                    vpos: vpos
                };
                //console.log(req);

                xhr.open('POST', `https://api.cas.nicovideo.jp/v1/services/live/programs/${this.liveId}/comments`);
                xhr.withCredentials = true;
                xhr.setRequestHeader('Content-type', 'application/json');
                xhr.send(JSON.stringify(req));

                xhr.onreadystatechange = (e)=> {
                    if(xhr.readyState === 4 && xhr.status === 200)
                    {
                        this.dispatchEvent('commentPosted', document.querySelector('form[class^="___comment-post-form___"]'));

                    } else if(xhr.status !== 200){
                        console.log("status = " +xhr.status);
                    }
                }
            }
        }
    }


    class View
    {
        constructor(controller)
        {
            this._controller = controller;
            this.css_prefix = 'sacv';

            this.setStyle();
            this.comment();
        }

        setStyle()
        {
            let css = document.createElement('style')
            let rule = document.createTextNode(`
#show-all-comment-outer {
    height:100%;
    overflow-y: auto;
}
#show-all-comment-viewer {
    margin: 0px;
    padding: 0 0 0 1em;
    display: table;
    list-style: none;
}
#show-all-comment-viewer li {
    margin-top: 1em;
}
#show-all-comment-viewer .number {
    display: table-cell;
    vertical-align: middle;
    color: #b1b1b1;
    font-size: smaller;
}
#show-all-comment-viewer .content,
#show-all-comment-viewer .nh-content
{
    display: table-cell;
    padding-left:1em;
    word-break: break-all;
}
#show-all-comment-viewer .nh-content {
    padding-left:0em;
}

#show-all-comment-viewer span[class$="-icon"] {
    color: #fff;
    text-decoration: none;
    margin-right: 1em;
    border-radius: 4px;
    padding: 0 0.3em;
    font-size: 0.8em;
}
#show-all-comment-viewer .nicoad {
    color: #d0bda0;
}
#show-all-comment-viewer .nicoad-icon {
    background-color: #d0bda0;
    padding: 0 0.5em !important;
}
#show-all-comment-viewer .quote {
    color: #76a5ce;
}
#show-all-comment-viewer .quote-icon {
    background-color: #76a5ce;
}
#show-all-comment-viewer .cruise {
    color: #76a5ce;
}
#show-all-comment-viewer .cruise-icon {
    background-color: #76a5ce;
}
#show-all-comment-viewer .info {
    color: #96C79A;
}
#show-all-comment-viewer .info-icon {
    background-color: #96C79A;
}
#show-all-comment-viewer .spi {
    color: #002856;
}
#show-all-comment-viewer .spi-icon {
    background-color: #002856;
}

#show-all-comment-viewer .author {
    color: #e40074;
}
#show-all-comment-viewer .author-icon {
    background-color: #e40074;
}
#show-all-comment-viewer .gift {
    color: #002bff;
}
#show-all-comment-viewer .gift-icon {
    background-color: #002bff;
}

/* コメントリンク */
a.${this.css_prefix}-comment-url{
    background-color: #3194da;
    border-radius: 5px;
    color: #fff;
    width: 3em;
    display: inline-block;
    text-align: center;
    text-decoration: none;
    margin-left: 0.5em;
}
a.${this.css_prefix}-comment-url:hover {
    background-color: #000;
}
#${this.css_prefix}-error {
    color: #fff;
    text-align: center;
    background-color: #a71846;
}
`);
            css.media = 'screen';
            css.type = 'text/css';
            if (css.styleSheet) {
                css.styleSheet.cssText = rule.nodeValue;
            } else {
                css.appendChild(rule);
            };
            document.getElementsByTagName('head')[0].appendChild(css);
        }

        comment()
        {
            document.addEventListener('roomName', (e)=>{
                e.target.textContent = `\${e.target.textContent}開放中/`;
            });

            document.addEventListener('commentDefault', (e)=>{
                // 184状態
                let anonymousToggle = document.querySelector('div[class^="___anonymous-comment-post-toggle-button-field____"] button');
                let anonymous;
                if (!anonymousToggle)
                {
                    const setiingButton = document.querySelector('button[class^="___setting-button___"]');
                    setiingButton.click();
                    setiingButton.click();
                    anonymousToggle = document.querySelector('div[class^="___anonymous-comment-post-toggle-button-field____"] button');
                }

                // コメント投稿
                const commentButton = document.querySelector('form[class^="___comment-post-form___"] button');
                commentButton.addEventListener('click', (_e)=> {
                    const data = {command:_e.target.parentNode.querySelector('input[class^="___command-text-box___"]').value,
                                  message:_e.target.parentNode.querySelector('input[class^="___comment-text-box___"]').value,
                                  anonymous: (anonymousToggle.getAttribute('data-toggle-state') == 'true')? true: false
                                 };
                    this._controller.router('comment_post', data);
                });

                // リロードボタン
                const reloadButton = document.querySelector('button[class^="___reload-button___"]');
                reloadButton.addEventListener('click', (_e)=> {
                    location.reload();
                });

                // デフォルトコメント非表示
                const parent = document.querySelector('div[class^="___comment-data-grid___"]')
                parent.querySelector('div[class^="___body___"]').style.display = 'none';
                // 新規コメビュ用
                const outer = document.createElement('div');
                outer.id = 'show-all-comment-outer';
                const viewer = document.createElement('ul');
                viewer.id = 'show-all-comment-viewer';
                outer.appendChild(viewer);
                parent.appendChild(outer);

                const chat = document.createElement('script');
                chat.id = 'commentdata';
                document.body.appendChild(chat);

                // 詳細設定画面を表示時に退避
                const settingButton = document.querySelector('button[class^="___detail-setting-button___"]');
                settingButton.addEventListener('click', (e)=>{
                    outer.style.display = 'none';
                    document.body.appendChild(outer);
                });

                // フルスクリーン時に退避
                const fullScreenButton = document.querySelector('button[class^="___fullscreen-button___"]');
                fullScreenButton.addEventListener('click', (e)=>{
                    if (outer.parentNode.localName !== 'body')
                    {
                        outer.style.display = 'none';
                        document.body.appendChild(outer);
                    }
                });
            });

            document.addEventListener('commentPosted', (e)=>{
                e.target.parentNode.querySelector('input[class^="___command-text-box___"]').value = '';
                e.target.parentNode.querySelector('input[class^="___comment-text-box___"]').value = '';
            });

            document.addEventListener('chat', (e)=>{
                const data = JSON.parse(e.target.getAttribute('data'));

                if (this.ignoreCommand(data)) return;

                const parent = document.getElementById('show-all-comment-viewer'),
                      li = document.createElement('li'),
                      head = document.createElement('span'),
                      content = document.createElement('span');

                li.setAttribute('user', data.user_id);

                const uc = this.convertCommand(data);
                if(uc) {
                    head.setAttribute('class', uc.class+'-icon');
                    head.textContent = uc.icon;
                    content.setAttribute('class', uc.class);
                    content.textContent = uc.message;

                    li.appendChild(head);
                } else {
                    head.setAttribute('class', 'number');
                    head.textContent = data.no;
                    content.setAttribute('class', 'content');
                    content.textContent = data.content;

                    if (head.textContent !== '') {
                        this.setNgEvent(head);
                        li.appendChild(head);
                    } else {
                        content.setAttribute('class', 'nh-content');
                        this.setNgEvent(content);
                    }
                }
                this.link(content);
                li.appendChild(content);
                parent.appendChild(li);

                const limit = 50;
                if (parent.children.length > limit) {
                    const count = parent.children.length;
                    for (let i = 0; i < count-limit; i++)
                    {
                        parent.children[i].remove();
                    }
                }

                const root = parent.parentNode;
                const rCR = root.getBoundingClientRect();
                const over = parent.querySelector('span[over]');
                //最後の子要素の一つ手前
                if (parent.lastElementChild.previousElementSibling && !over)
                {
                    const lCR = parent.lastElementChild.previousElementSibling.getBoundingClientRect();
                    if(lCR.bottom < rCR.bottom) root.scrollTop =root.scrollHeight;
                }
            });

            document.addEventListener('chatFilterMatched', (e)=>{
            });

            document.addEventListener('chatVisibley', (e)=>{
                const parent = document.querySelector('div[class^="___comment-data-grid___"]');
                if (!parent.querySelector('#show-all-comment-outer'))
                {
                    parent.querySelector('div[class^="___body___"]').style.display = 'none';

                    const outer = document.getElementById('show-all-comment-outer');
                    outer.removeAttribute('style');
                    parent.appendChild(outer);
                    outer.scrollTop = outer.scrollHeight;
                }
            });

            document.addEventListener('openSocketError', (e)=>{
                const outer = document.getElementById('show-all-comment-outer'),
                      parent = document.querySelector('#show-all-comment-viewer'),
                      error = document.createElement('li');
                error.textContent = '⚠ 初期コメントサーバーエラー';
                error.id = `${this.css_prefix}-error`;
                parent.appendChild(error);
                outer.scrollTop = outer.scrollHeight;
            });

            document.addEventListener('commentApiError', (e)=>{
                const outer = document.getElementById('show-all-comment-outer'),
                      parent = document.querySelector('#show-all-comment-viewer'),
                      error = document.createElement('li');
                error.textContent = '⚠ コメントAPIサーバーエラー';
                error.id = `${this.css_prefix}-error`;
                parent.appendChild(error);
                outer.scrollTop = outer.scrollHeight;
            });
        }

        setNgEvent(node)
        {
            node.addEventListener('click', (e)=>{
                const user = e.target.parentNode.getAttribute('user');
                this._controller.router('ng_comment_user', user);

                const result = document.querySelectorAll(`#show-all-comment-viewer li[user="${user}"]`);
                for (const r of result) r.remove();
            });

            node.addEventListener('mouseover', (e)=>{
                e.target.setAttribute('over', '');
                const user = e.target.parentNode.getAttribute('user');
                const className = e.target.getAttribute('class');
                const result = document.querySelectorAll(`#show-all-comment-viewer li[user="${user}"] span[class="${className}"]`);
                for (const r of result)
                {
                    r.style.backgroundColor = '#B3B3B3';
                    r.style.borderRadius = '4px';
                    r.style.color = '#fff';
                }
            });

            node.addEventListener('mouseout', (e)=>{
                e.target.removeAttribute('over');
                const user = e.target.parentNode.getAttribute('user');
                const className = e.target.getAttribute('class');
                const result = document.querySelectorAll(`#show-all-comment-viewer li[user="${user}"] span[class="${className}"]`);
                for (const r of result) r.removeAttribute('style');
            });
        }

        ignoreCommand(data)
        {
            return (/^\/(uadpoint|hb|coe|clear)\s?/.test(data.content))? true: false;
        }

        convertCommand(data)
        {
            const text = data.content
            let match = /\/(nicoad|quote|cruise|info|spi|gift|perm) (.*)/.exec(text);
            const messageTrim = (str)=>{return str.replace(/^"|"$/g, '')};
            let result;
            if (match) {
                switch (match[1])
                {
                    case 'nicoad':
                        result = {class:'nicoad', message:JSON.parse(match[2]).message, icon:'広告'};
                        break;
                    case 'gift':
                        match = match[2].split(' ');
                        match = {name: messageTrim(match[2])+'さん',
                                 point: match[3]+'pt',
                                 gift: messageTrim(match[5])};
                        match = `${match.gift}(${match.point}) by${match.name}`;
                        result = {class:'gift', message:match, icon:'ギフト'};
                        break;
                    case 'quote':
                        match = messageTrim(match[2]).replace('(生放送クルーズさんの番組)', '');
                        result = {class:'quote', message:match, icon:'クルーズ'};
                        break;
                    case 'cruise':
                        result = {class:'cruise', message:messageTrim(match[2]), icon:'クルーズ'};
                        break;
                    case 'info':
                        result = {class:'info', message:match[2].replace(/[0-9]+ /, ''), icon:'運コメ'};
                        break;

                    case 'spi':
                        result = {class:'spi', message:messageTrim(match[2]), icon:'アイテム'};
                        break;

                    case 'perm':
                        result = {class:'author', message:match[2], icon:'主コメ'};
                        break;

                    default:
                        break;
                }
            } else if (data.premium == 3) {
                result = {class:'author', message:text, icon:'主コメ'};
            }
            return result;
        }

        link(node)
        {
            const pttn = /http(s)?:\/\/(([\w-]+\.)+)([\w-]+)(\/[\w-./?%&=#]*)?/;
            const link = pttn.exec(node.textContent);
            if (link)
            {
                node.innerHTML += `<a href="${new URL(link[0])}" target="_blank" class="${this.css_prefix}-comment-url">開く</a>`;
            }
        }
    }


    const model = new Model();
    const con = new Controller(model);
    const view = new View(con);
    con.router();


})();