我尝试使用Nodejs获取了Niconico实況(直播)的评论

序言或前言

由于获得了PX-S1UD-1,我决定充分利用RaspberryPi4来打造观看环境,但觉得还不够,便想要添加Niconico直播功能,并记录了相关的预先调查。

几次了呢w

当我在外出地方观看动画时,我使用了低延迟的地面数字电视调谐器通过多重代理和设备认证进行流媒体传输。当时我使用了资源非常充裕的机架服务器来获取页面的视频区域并强行使用绿幕技术。但是我觉得这样不够聪明,所以我想尝试简化获取评论Socket的过程,这也是我的一个动机之一。

2021/05/24 – 补充说明

我开始在Windows环境中使用Node.js,所以我添加了代码,以便在每天早上4点广播结束时重新连接。

此外,为了录制时的播放需要,我还添加了将注释信息以JSON格式输出到文件的功能,并且还支持指定保存位置和文件名等参数。

顺便添加Mac版本的Chrome浏览器的路径。

获取与评论相关的通信

当查看以下页面及其参考来源和引用来源时,似乎是通过WebSocket传输视频信息和评论。

在Qiita上记下了使用应用程序播放新的Nico生直播的备忘录。

用WebSocket + JSON发送和接收Niconico直播的评论的方法,简单解释一下。

这些可以粗略概括为以下步骤,可以获取评论。

    動画ページ ==> Socket用アドレス取得
    ↓
    コンテンツ用Socketのセッション確立
    ↓
    コンテンツ用Socketでコンテンツ取得要求 ==> 動画再生の為の情報が送られてくる
    ↓
    コメント用Socketのアドレスと必要な情報の抜き出し
    ↓
    コメント用Socketのセッション確立
    ↓
    コメント用Socketにコンテンツ用Socketで発行されたIDを用いてコメント取得要求
    ↓
    コメントが送られてくる

如果使用Node.js之类的工具实现,应该很容易获取到评论。

使用Node.js获取评论

在 Chrome 浏览器的开发者工具和 Javascript 中,我找到了一篇详细解释如何获取评论的文章。

【前編:JavaScript版】- 获取niconico直播的评论以进行各种操作- Qiita

由于这是非常易懂的,所以我参考了这个,尝试用Node.js进行编写。

我使用了在Linux环境下作为浏览器的“chromium”软件包中的“websocket”和“puppeteer-core”。

const puppeteer = require('puppeteer-core')
let dir_Brwsr = '';
let url_page = (process.argv[2] || 'https://live.nicovideo.jp/watch/ch2646485');
let channel_name = "";
let socket_view = '';

const message_system_1 = '{"type":"startWatching","data":{"stream":{"quality":"abr","protocol":"hls","latency":"low","chasePlay":false},"room":{"protocol":"webSocket","commentable":true},"reconnect":false}}';
const message_system_2 ='{"type":"getAkashic","data":{"chasePlay":false}}'

let uri_comment
let threadID

//Browser Directory (WinはEdge、LinuxはChromiumの判別)
if(process.platform==='win32') dir_Brwsr = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
else if(process.platform==='darwin') dir_Brwsr = '';
else if(process.platform==='linux') dir_Brwsr = '/usr/bin/chromium';

//Browser Controle
async function getLatestDate(page, url){
  await page.goto(url) // Open URL Page
  // Browser JavaScript
  channel_name = await page.evaluate(() => JSON.parse(document.getElementById("embedded-data").getAttribute("data-props")).socialGroup.name);
  return await page.evaluate(() => JSON.parse(document.getElementById("embedded-data").getAttribute("data-props")).site.relive.webSocketUrl);  //ヘッドレスブラウザで開いてsocketアドレス取得
}

!(async() => {
  try {
    const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox'],executablePath: (dir_Brwsr),ignoreDefaultArgs: ['--disable-extensions']});
    const page = await browser.newPage();
    const url_view = await getLatestDate(page, url_page);
    console.log(channel_name);
    console.log("WebSocket Connection ==> " + url_view);
    client.connect(url_view, null, null, {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36'}, null);

    browser.close()
  } catch(e) {
    console.error(e)
  }
})()

//View Session: WebSocket Connection
let WebSocketClient = require('websocket').client;
let client = new WebSocketClient();
client.on('connectFailed', function(error) {
    console.log('View Session Connect Error: ' + error.toString());
});
client.on('connect', function(connection) {
    console.log('WebSocket Client Connected[View Session]');
    socket_view = connection;  //コメントSocketから閉じられるようにコネクション格納

    connection.sendUTF(message_system_1);  //コンテンツ情報要求
    connection.sendUTF(message_system_2);

    connection.on('error', function(error) {
        console.log("View Session Connection Error: " + error.toString());
    });

    connection.on('close', function() {
        console.log('WebSocket Client Closed[View Session]');
    });

    connection.on('message', function(message) {
        if (message.type === 'utf8') {

            // Get Comment WWS Addres & Option Data
            if(message.utf8Data.indexOf("room")>0) {  //色々データ抜いてコメントsocketに接続
                evt_data_json = JSON.parse(message.utf8Data);
                uri_comment = evt_data_json.data.messageServer.uri
                threadID = evt_data_json.data.threadId
                message_comment = '[{"ping":{"content":"rs:0"}},{"ping":{"content":"ps:0"}},{"thread":{"thread":"'+threadID+'","version":"20061206","user_id":"guest","res_from":-150,"with_global":1,"scores":1,"nicoru":0}},{"ping":{"content":"pf:0"}},{"ping":{"content":"rf:0"}}]'
                console.log("WebSocket Connection ==> " + uri_comment);
                // Comment WebSocket Connection
                comclient.connect(uri_comment, 'niconama', {
                  headers: {
                    'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits',
                    'Sec-WebSocket-Protocol': 'msg.nicovideo.jp#json',
                  },
                });
            }

            // Keep View Session
            if(message.utf8Data.indexOf("ping")>0) {  //pingに応答
              connection.sendUTF('{"type":"pong"}');
              connection.sendUTF('{"type":"keepSeat"}');
            }
        }
    });
});

// Comment Session: WebSocket Connection
let comclient = new WebSocketClient();
comclient.on('connectFailed', function(comerror) {
    console.log('Comment Session Connect Error: ' + comerror.toString());
});
comclient.on('connect', function(connection) {
  console.log('WebSocket Client Connected[Comment Session]');
  connection.sendUTF(message_comment);

  // Comment Session Keep Alive
  setInterval((connection)=>{connection.sendUTF("");}, 60000, connection);  //コメントSocketの生存確認送信

  connection.on('error', function(error) {
    console.log("Comment Session Connection Error: " + error.toString());
  });

  connection.on('close', function() {
    console.log('WebSocket Client Closed[Comment Session]');
    socket_view.close();  //コメントSocket終了時、コンテンツsocketも終了
  });

  connection.on('message', function(message) {
    if (message.type === 'utf8') {
      if (message.utf8Data.indexOf("chat")>0){  //コメント以外スルー
        let baff = JSON.parse(message.utf8Data);
        if (baff.chat.content.indexOf('spi')<=0 && baff.chat.content.indexOf('nicoad')<=0){  //広告コメントスルー
          //console.log('Received:' + message.utf8Data);  //コメントのjson(コメントの色、位置などのコマンドあり)をコンソール出力
          console.log('Received Coment: ' + baff.chat.content);  //コメント文字のみをコンソール出力
        }
      }
    }
  });
});

作为要点,正如其他文章中所提到的,首先在连接内容用的Socket时,需要在头部添加一个伪装成浏览器的User-Agent,并且装作从浏览器发起访问的方式。其次,在内容用的Socket中,除了响应”ping”请求外,还需要一同发送”keepSeat”,以保持会话并继续建立连接,使其看起来像在观看视频。

而且,若在评论会话建立后没有发送评论,则每隔约1分钟通过评论Socket发送消息,以证明通信仍在保持活动状态。

顺便提一下,评论用的Socket与内容用的Socket以及ID互相关联,当内容Socket关闭时,评论Socket也会关闭。

即使评论的Socket由于某种原因在广播结束之前被关闭,内容的Socket仍然保持打开,因此在上述程序中,将内容的Socket连接存储在变量中,以便在评论的Socket结束时也关闭内容的Socket。

2021年5月24日-附言

在节目结束时重新连接,支持以json格式将评论输出到文件中。

默认保存位置位于根目录中,通过编辑变量“dir_filesave”可以将其指定到任意位置。

可以在Node.js执行时的参数中加上”-d”来指定。

文件名以”YYYYMMDD_P-NAME_test.json”的json格式表示,日期为每天早上4点至第二天早上4点之间的一次广播(为期24小时),每天产生一个相同日期的文件。

这里也可以通过在参数中加上”-f”来进行指定。

另外,由于广播开始后连接速度太快,经常导致无法从传输页面获取到Socket地址,所以通过循环处理来更新传输页面并持续获取Socket地址进行修正。

我认为在短时间内连续重新加载会给服务器带来负担,但由于“puppeteer-core”需要启动浏览器并加载页面,获取信息大约需要3到5秒的时间,所以保持原样。

在早晨鲜少使用的时间段,通常第二次加载即可获得地址,所以并没有特别的问题。

-u: 网址
-b: 浏览器路径
-d: 保存目录
-f: 文件名
-p: 程序名

const puppeteer = require('puppeteer-core');
const WebSocketClient = require('websocket').client;
const fs = require('fs');
require('date-utils');
let getdate = new Date();

let dir_filesave = '';
let sv_filename = '_P-NAME_test.json';

let dir_Brwsr = '';
let url_page = 'https://live.nicovideo.jp/watch/ch2646485';
let channel_name = "";
let program_name = "";
let socket_view;
let socket_come;


const message_system_1 = '{"type":"startWatching","data":{"stream":{"quality":"abr","protocol":"hls","latency":"low","chasePlay":false},"room":{"protocol":"webSocket","commentable":true},"reconnect":false}}';
const message_system_2 ='{"type":"getAkashic","data":{"chasePlay":false}}';

let uri_comment;
let threadID;

if(process.platform==='win32') dir_Brwsr = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
else if(process.platform==='darwin') dir_Brwsr = '/Application/Google Chrome.app';
else if(process.platform==='linux') dir_Brwsr = '/usr/bin/chromium';

// arg Options ->[ -u: URL, -b: Browser Path, -d: Save Directory, -f: File name, -p: Programe name ]
if(process.argv.length <= 3) url_page = process.argv[2];
else if (process.argv.length >= 4) {
  for (let i = 2; i < process.argv.length; i++) {
    switch(process.argv[i]){
    case '-u':
      url_page = process.argv[++i];
      break;
    case '-b':
      dir_Brwsr = process.argv[++i];
      break;
    case '-d':
      dir_filesave = process.argv[++i];
      break;
    case '-f':
      sv_filename = process.argv[++i];
      break;
    case '-p':
      program_name = process.argv[++i];
      break;
    default:
      console.log('error: Invalid argument => '+process.argv[i]);
      break;
    }
  }
}

!(()=> {lncbrwser(url_page);})()

//Browser Controle
async function getLatestDate(page, url){
  await page.goto(url) // Open URL Page
  // Browser JavaScript
  channel_name = await page.evaluate(() => JSON.parse(document.getElementById("embedded-data").getAttribute("data-props")).socialGroup.name);
  return await page.evaluate(() => JSON.parse(document.getElementById("embedded-data").getAttribute("data-props")).site.relive.webSocketUrl);
}

async function lncbrwser(url) {
  try {
    const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox'],executablePath: (dir_Brwsr),ignoreDefaultArgs: ['--disable-extensions']});
    const page = await browser.newPage();
    let url_viewl;
    while(true){
      url_view = await getLatestDate(page, url);
      if(url_view != '') break;
    }
    browser.close();

    // filesave
    console.log(channel_name);
    getdate = new Date();
    if (getdate.toFormat("HH24")-0 <= 3) {
      fs.writeFile(dir_filesave+'/'+(getdate.toFormat("YYYYMMDD")-1)+sv_filename, '{"ChannelName":"'+channel_name+'","ProgramName":"'+program_name+'","StartDate":"'+getdate.toFormat("YYYY/MM/DD HH24時MI分SS秒")+'"}'+'\n', (err) => {
        if (err) throw err;
      });
    }
    else {
      fs.writeFile(dir_filesave+'/'+getdate.toFormat("YYYYMMDD")+sv_filename, '{"ChannelName":"'+channel_name+'","ProgramName":"'+program_name+'","StartDate":"'+getdate.toFormat("YYYY/MM/DD HH24時MI分SS秒")+'"}'+'\n', (err) => {
        if (err) throw err;
      });
    }
    console.log("WebSocket Connection ==> " + url_view);
    // Comment WebSocket Connection
    viewsockcnct(url_view);
  } catch(e) {
    console.error(e);
  }
}

//View Session: WebSocket Connection
function viewsockcnct(url_view) {
  let client = new WebSocketClient();
  client.connect(url_view, null, null, {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36'}, null);
  client.on('connectFailed', function(error) {
      console.log('View Session Connect Error: ' + error.toString());
  });
  client.on('connect', function(connection) {
      console.log('WebSocket Client Connected[View Session]');
      socket_view = connection;

      connection.sendUTF(message_system_1);
      connection.sendUTF(message_system_2);

      connection.on('error', function(error) {
          console.log("View Session Connection Error: " + error.toString());
      });

      connection.on('close', function() {
          console.log('WebSocket Client Closed[View Session]');
          if(socket_come)socket_come.close();
          lncbrwser(url_page);
      });

      connection.on('message', function(message) {
          if (message.type === 'utf8') {

              // Get Comment WWS Addres & Option Data
              if(message.utf8Data.indexOf("room")>0) {
                  evt_data_json = JSON.parse(message.utf8Data);
                  uri_comment = evt_data_json.data.messageServer.uri
                  threadID = evt_data_json.data.threadId
                  message_comment = '[{"ping":{"content":"rs:0"}},{"ping":{"content":"ps:0"}},{"thread":{"thread":"'+threadID+'","version":"20061206","user_id":"guest","res_from":-150,"with_global":1,"scores":1,"nicoru":0}},{"ping":{"content":"pf:0"}},{"ping":{"content":"rf:0"}}]';
                  console.log("WebSocket Connection ==> " + uri_comment);
                  // Comment WebSocket Connection
                  comesockcnct(uri_comment,message_comment);
              }

              // Keep View Session
              if(message.utf8Data.indexOf("ping")>0) {
                connection.sendUTF('{"type":"pong"}');
                connection.sendUTF('{"type":"keepSeat"}');
              }
          }
      });
  });
}

// Comment Session: WebSocket Connection
function comesockcnct(uri_comment,message_comment) {
  let comclient = new WebSocketClient();
  comclient.connect(uri_comment, 'niconama', {
    headers: {
      'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits',
      'Sec-WebSocket-Protocol': 'msg.nicovideo.jp#json',
    },
  });
  comclient.on('connectFailed', function(comerror) {
      console.log('Comment Session Connect Error: ' + comerror.toString());
  });
  comclient.on('connect', function(connection) {
    console.log('WebSocket Client Connected[Comment Session]');
    socket_come = connection;

    connection.sendUTF(message_comment);

    // Comment Session Keep Alive
    setInterval((connection)=>{connection.sendUTF("");}, 60000, connection);

    connection.on('error', function(error) {
      console.log("Comment Session Connection Error: " + error.toString());
    });

    connection.on('close', function() {
      console.log('WebSocket Client Closed[Comment Session]');
      socket_view.close();
    });

    connection.on('message', function(message) {
      if (message.type === 'utf8') {
        if (message.utf8Data.indexOf("chat")>0){
          let baff = JSON.parse(message.utf8Data);
          if (baff.chat.content.indexOf('spi')<=0 && baff.chat.content.indexOf('nicoad')<=0){
            //console.log('Received:' + message.utf8Data);
            getdate = new Date();
            if (getdate.toFormat("HH24")-0 <= 3) {
              fs.writeFile(dir_filesave+'/'+(getdate.toFormat("YYYYMMDD")-1)+sv_filename, message.utf8Data+'\n', {flag:'a'}, (err) => {
                if (err) throw err;
              });
            }
            else {
              fs.writeFile(dir_filesave+'/'+getdate.toFormat("YYYYMMDD")+sv_filename, message.utf8Data+'\n', {flag:'a'}, (err) => {
                if (err) throw err;
              });
            }

            console.log('Received Coment: (' + baff.chat.mail + ') ' + baff.chat.content);
          }
        }
      }
    });
  });
}

暂且解释用法

在安装了Node.js后,需要使用npm来安装本次使用所需的包,并运行。

本次使用了“puppeteer-core”从网页获取Socket地址,但由于该工具需要使用Chrome系列浏览器,因此也需要安装Chrome或Chromium。

在Windows环境中,可以使用默认安装的”Edge”浏览器,无需进行浏览器安装。

在Mac环境中,安装Chrome系列浏览器后,需要将执行文件的路径在程序中进行记录。

2021/05/23-补充记录

我在Mac环境中添加了Chrome浏览器的路径,所以只要安装了Chrome,就不需要特别进行路径描述了。

需要使用「date-utils」来获取追加的日期。

$ curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
$ apt update
$ sudo apt install -y nodejs Chromium
$ sudo npm install -g websocket puppeteer-core date-utils

使用一个合适的名称将该JS文件保存,并在Node.js中执行。

在运行时,如果指定了Niconico直播和实况直播的URL,则可以实时获取从那里的评论。

如果没有指定URL,将获得设定为默认值的“TOKYO MX”实时评论的Niconico实况。

2021年5月23日-补充说明

将传统的运行时参数修改为可通过选项进行一些设置的修正。

//TOKYO MX 実況コメント取得
$ nodejs nico_comment.js

//TBS 実況コメント取得(アドレスのみ指定)
$ nodejs nico_comment.js https://live.nicovideo.jp/watch/ch2646440

//TBS 実況コメント取得(ファイルの保存場所、ファイル名、プログラム名、アドレスの指定)
$ nodejs nico_comment.js -d /home/node -f _TBS_come.json -p Anime -u https://live.nicovideo.jp/watch/ch2646440

后记式的东西

在这个程序中,我们暂时将注释输出到控制台上,所以如果需要的话,请各自编写处理获取的注释。

另外,如果在 Node.js 单独运行时,在ニコニコ直播活动中,当早上4点结束一次广播并切换时,Socket 也会关闭并且程序也会终止。

我们的情况是在服务器的Docker容器上使用ReStart=”Always”设置来运行,所以即使程序结束了,容器也会重新启动并重新连接,因此不需要编写重新连接处理。

2021年5月23日-更新

由于在这次更新中,我们明确地要求将程序重新连接到Niconico实况和直播,所以无论环境如何,程序将持续重新连接并获取评论,直到程序结束为止。

此外,还可通过指定EPGStation等录制前和录制后的命令来设置将评论数据(json格式)输出到文件中,以便在节目播放期间将评论保存到文件中。

如果你还不清楚如何从EPGStation接收节目名称等信息,请自行调查并进行必要的处理。

bannerAds