前書き的なもの
PX-S1UD-1を手に入れたのでRaspberryPi4を有効活用してみようと視聴環境を構築したものの、物足りなさにニコニコ実況を実装しようと思いその事前調査した記録。
何番煎じだろうかw
出先でアニメ視聴用に地デジチューナーの低遅延HLS配信を多重Proxyと端末認証で垂れ流していた際はリソース余りまくりのラックサーバーでページの動画領域取得して無理矢理クロマキーしたりしていたが、スマートじゃないなと思いコメントのSocket取得を簡単にできるようにしてみようと思ったのも動機の一つ。
2021/05/24-追記
windows環境のnodejsでも利用し始めたので毎朝4時の放送終了時に再接続するように書き加えました。
また、録画時の再生用にコメント情報のjsonをファイルに出力するようにしたのと保存場所やファイル名などの引数指定に対応。
あとついでにMac用のChromeブラウザのパスも追加
コメントの取得に関わる通信
以下のページとその参照元、引用元を覗いてみるとWebSocketで動画情報、コメントを配信しているらしい。
ニコ生新配信の放送をアプリで再生するための覚書き - Qiita
ニコ生のコメント送受信をWebSocket+JSONでやる方法ざっくり解説 - Qiita
これらを大雑把にまとめると以下の流れでコメント取得できそう。
動画ページ ==> Socket用アドレス取得
↓
コンテンツ用Socketのセッション確立
↓
コンテンツ用Socketでコンテンツ取得要求 ==> 動画再生の為の情報が送られてくる
↓
コメント用Socketのアドレスと必要な情報の抜き出し
↓
コメント用Socketのセッション確立
↓
コメント用Socketにコンテンツ用Socketで発行されたIDを用いてコメント取得要求
↓
コメントが送られてくる
これをNodejs辺りで行えば簡単にコメント取得できそう。
Nodejsでコメント取得
Chromeブラウザのデベロッパツールとjavascriptで実際にコメント取得の手順を説明している記事を発見。
ニコニコ生放送のコメントを取得して色々するための第一歩(前編:JavaScript版) - Qiita
超分かり易かったのでこれを参考にさせてもらってNodejsで組んでみた。
使ったパッケージは「websocket」と「puppeteer-core」にLinux環境はブラウザとして「chromium」
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開通時に適当なブラウザのユーザーエージェントをヘッダーに付与してブラウザからのアクセスを装う事と、コンテンツ用Socketで「Ping」に応答しつつ「keepSeat」も一緒に送信して動画を視聴しているような状態にしてセッションを確立し続ける事。
さらにコメントセッション確立後はコメント送信しない場合、約1分おきにコメントSocketでメッセージを送信して通信が生きていることを示す事。
ちなみにコメント用Socketはコンテンツ用SocketとIDで紐づけられており、コンテンツSocketが閉じるとコメントSocketも閉じるようになっている。
逆に放送が終了していないのにコメント用Socketが何らかの影響で閉じてしまってもコンテンツ用Socketは閉じずに生きているので、上記のプログラムではコンテンツ用Socketのコネクションを変数に格納してコメント用Socketが終了した際にコンテンツ用Socketも終了するようにしている。
####2021/05/24-追記
放送終了時の再接続、コメントのjson形式でファイル出力に対応
デフォルトの保存先はルートディレクトリで、変数"dir_filesave"を編集することで任意の場所に指定できます。
nodejs実行時の引数で"-d"を付けて指定も可能
ファイル名は"YYYYMMDD_P-NAME_test.json"のjson形式で日付は朝4時~翌朝4時までの1放送(24時間)毎に同一日付の1ファイルとなってます。
こちらも引数で"-f"を付けて指定可能。
また、放送開始直後や再接続が早すぎて配信ページからSocketアドレスが取得できないことが多々あったのでループ処理でSocketアドレス取得まで配信ページを更新してSocketアドレスを取得し続けるように修正。
短期間での連続リロードはサーバー側に負荷かけてしまうかと思ったが、「puppeteer-core」がブラウザ立ち上げてページロード、情報取得までにおよそ3~5秒かかるのでそのままにしてあります。
早朝のアクセス少ない時間で大抵2回目のロードでアドレス取得できるので特に問題ないかと。
-u: URL
-b: Browser Path
-d: Save Directory
-f: File name
-p: Programe name
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);
}
}
}
});
});
}
一応、使い方説明
使い方は「nodejs」インストール後、今回使用している必要なパッケージもnpmインストールして実行。
今回WebページからSocketアドレスを取得するのに「puppeteer-core」を使用しているが、これは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ファイルで保存してnodejsで実行。
実行時にニコニコ実況、生放送のURLを指定するとそこからコメントをリアルタイムに取得する。
URLの指定がない場合は初期値として設定されているニコニコ実況の「TOKYO MX」のコメントをリアルタイム取得する。
####2021/05/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
後書き的なもの
今回のプログラムではとりあえずコンソールにコメントを出力するようにしているので必要な方は個々人で取得したコメントの処理を書いてください。
あと、nodejs単体で実行した場合はニコニコ実況だと朝4時に一度放送が終了して切り替わる際にSocketも閉じてプログラムも終了します。
うちの場合はサーバーのDockerコンテナでReStart="Always"設定で運用しているのでプログラム終了してもコンテナがRestartして再接続するので再接続処理書いてません。
####2021/05/23-追記
今回明示的にニコニコ実況、生放送へ再接続するようにしたので環境に関係なくプログラムを終了するまで再接続してコメントを取得し続けるようになりました。
また、ファイルへコメント生データ(json)を吐き出すようにしてるのでEPGStationなどの録画時前、終了後のコマンド指定で設定すれば番組時間中のコメントをファイルに取得保存できます。
番組名などはEPGStationからどう受け取ればいいのかまだ調査中で必要な方は各個人で対応願います。