前書き的なもの
ニコニコ実況(生放送)のコメントをNodejsで取得してみた - Qiita
約1月前にNodejsでとりあえずニコニコ実況のコメントを取得できるようにできました。
ここからEPGStation側の調査を行い、コメント取得アプリの改良アップデートを四苦八苦に右往左往しながら何とか済ませ実用レベルになったので一旦記事にまとめて公開してみました。
2021/06/04-追記
Task List出力に調整を行いました。
10分に1回は頻繁過ぎるかなぁと30分毎に変更し時間も出力するように、
あとIF条件間違えてた......。
2021/07/09-追記
少し前にラズパイをUSB-HDDブートに移行して環境構築し直したら、puppeteer-coreがbrowser.newPage()で止まるようになりました...。
(Promiseが返ってきてない感じ?)
立ち上げ直後は発現せず、立ち上げ後40H以内での症状もなかったので毎朝4時にrestartで応急処置してます。
エラー出力が何もなく、Stackやgitを参考にnewPage().catch()してみてもエラー出ないので対応できずです。
本職や詳しい方いましたらアドバイスお願いします。
コメント取得アプリのアップデート内容
・EPGStationの外部コマンド実行機能を利用した連携
・httpのpost通信による簡単なコメント取得管理
・マルチチューナ向け複数番組のコメント同時取得対応
現状連携させるにはEPGStationのソースを書き換える他だと「外部コマンド実行機能」しかないのでこれを利用しましたが、これが中々の曲者でした。
この「外部コマンド実行機能」は録画前や録画開始時のスクリプト実行を目的としているためSocketコネクションまでの一連のコードを実行後にSocket通信の待ち受けをせず即終了してしまいます。
そのため、コメント取得アプリは簡単なサーバー機能を組み込んでpm2で独立実行するようにしました。
EPGStationとの連携はコメント取得アプリへコメント取得開始、停止と番組情報をhttpのpost通信で送信するNodejsを作り実現しました。
また、Socket通信によるコメント取得部をインスタンス化しマルチチューナ環境などで複数番組のコメントを同時取得できるように変更。
各アプリのソース
ソースカスタマイズ前程の個人用途限定で基本的に最低限必要な情報やEPGStationから環境変数で受け渡される番組情報は存在を期待してコードを組んでいるのでエラーや例外などは最低限のみです。
↓コメント取得アプリ
const express = require("express");
const app = express();
const port = 12525;
const bodyParser = require('body-parser');
const puppeteer = require('puppeteer-core');
const WebSocketClient = require('websocket').client;
const fs = require('fs');
require('date-utils');
let dir_Brwsr;
let dir_filesave = __dirname+'/files';
let worker = {};
let tasklist;
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';
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}}';
setInterval(()=>{
getdate = new Date();
tasklist = '****Task List('+getdate.toFormat("HH24:MI:SS")+')****\n';
if(Object.keys(worker).length != 0){
for(let k in worker){
tasklist += '@['+worker[k].channel_name+']'+k+'\n';
}
}
else{
tasklist += '@Non Task\n';
}
tasklist += '************End************';
console.log(tasklist);
}, 1800000);
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
app.get("/", (req, res) =>{
getdate = new Date();
tasklist = '****Task List('+getdate.toFormat("HH24:MI:SS")+')****\n';
if(Object.keys(worker).length != 0){
for(let k in worker){
tasklist += '@['+worker[k].channel_name+']'+k+'\n';
}
}
else{
tasklist += '@Non Task\n';
}
tasklist += '************End************';
res.send('Welcome! "Nico Coment Chaptuer"...\n\n@http://address/start -> Chaptuer Start!\n---POST DATA(json){\n - c_name : "Channel Name"\n - p_name : "Program Name"\n - s_time : "Start Time"\n - e_time : "End Time"\n - p_url : "niconico URL"(OPTION)\n}\n\n@http://address/stop -> Chaptuer Stop!\n---POST DATA(json){\n - p_name : "Program Name"\n}\n\n'+tasklist);
});
app.post("/start", (req, res) =>{
let channel_name;
let url_page;
if(req.body.c_name) {
channel_name = req.body.c_name;
console.log(channel_name);
url_page = ch_url_conv(channel_name);
console.log(url_page);
}
else if(url_page) {
url_page = req.body.p_url;
console.log(channel_name);
console.log(url_page);
}
else {
url_page = 'https://live.nicovideo.jp/watch/ch2646485';
}
program_name = req.body.p_name;
prg_str_time = req.body.s_time;
prg_end_time = req.body.e_time;
sv_file_name = req.body.p_name+'.json';
flg_end = 0;
if(!worker[program_name]){
worker[program_name] = new nico_come_reader(dir_filesave,sv_file_name,url_page,channel_name,program_name,prg_str_time,prg_end_time);
res.send('Comment Logging Start! -->"'+program_name+'"');
console.log('New Task -->"'+program_name+'"');
}
else {
res.send('Error: Already exists! -->"'+program_name+'"');
console.log('Error: Already exists! -->"'+program_name+'"');
}
});
app.post("/stop", (req, res) =>{
program_name = req.body.p_name;
if(worker[program_name]){
worker[program_name].nico_come_reader_close();
delete worker[program_name];
res.send('Comment Logging End! -->"'+program_name+'"');
console.log('End Task -->"'+program_name+'"');
}
else{
res.send('Error: Not Found! -->"'+program_name+'"');
console.log('Error: Not Found!! -->"'+program_name+'"');
}
});
app.listen(port, () => {
console.log(`listening at http://localhost:${port}`);
});
function ch_url_conv(channel_name) {
switch(true) {
case /^NHK総合1.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646436';
case /^NHKEテレ.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646437';
case /^日テレ.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646438';
case /^テレビ朝日.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646439';
case /^TBS1.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646440';
case /^テレビ東京.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646441';
case /^フジテレビ.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646442';
case /^TOKYO MX.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646485';
case /^BS11イレブン.*$/.test(channel_name):
return 'https://live.nicovideo.jp/watch/ch2646846';
}
}
let nico_come_reader = function(file_dir,file_name,page_url,channel,program,s_time,e_time) {
this.dir_filesave = file_dir;
this.sv_file_name = file_name;
this.url_page = page_url;
this.channel_name = channel;
this.program_name = program;
this.prg_str_time = s_time;
this.prg_end_time = e_time;
this.client;
this.comclient;
this.socket_view;
this.socket_come;
this.flg_end = 0;
this.lncbrwser(this.url_page);
}
//nico_come_reader.prototype.dir_Brwsr = dir_Brwsr;
//nico_come_reader.prototype.message_system_1 = '{"type":"startWatching","data":{"stream":{"quality":"abr","protocol":"hls","latency":"low","chasePlay":false},"room":{"protocol":"webSocket","commentable":true},"reconnect":false}}';
//nico_come_reader.prototype.message_system_2 = '{"type":"getAkashic","data":{"chasePlay":false}}';
nico_come_reader.prototype.nico_come_reader_close = function() {
this.flg_end = 1;
this.socket_come.close();
}
nico_come_reader.prototype.re_con_socket = function() {
this.client = new WebSocketClient();
this.comclient = new WebSocketClient();
}
nico_come_reader.prototype.lncbrwser = async function(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_view;
while(true){
url_view = await this.getLatestDate(page, url);
if(url_view) break;
}
browser.close();
// filesave
getdate = new Date();
if (getdate.toFormat("HH24")-0 <= 3) {
fs.writeFile(this.dir_filesave+'/'+(getdate.toFormat("YYYYMMDD")-1)+this.sv_file_name, '{"ChannelName":"'+this.channel_name+'","ProgramName":"'+this.program_name+'","StartTime":'+this.prg_str_time+',"EndTime":'+this.prg_end_time+',"logStartDate":'+getdate.getTime()+',"nico_URL":"'+this.url_page+'"}'+'\n', (err) => {
if (err) throw err;
});
}
else {//getdate.toFormat("YYYY/MM/DD HH24時MI分SS秒")
fs.writeFile(this.dir_filesave+'/'+getdate.toFormat("YYYYMMDD")+this.sv_file_name, '{"ChannelName":"'+this.channel_name+'","ProgramName":"'+this.program_name+'","StartTime":'+this.prg_str_time+',"EndTime":'+this.prg_end_time+',"logStartDate":'+getdate.getTime()+',"nico_URL":"'+this.url_page+'"}'+'\n', (err) => {
if (err) throw err;
});
}
console.log("WebSocket Connection ==> " + url_view);
// Media WebSocket Connection
this.re_con_socket();
this.viewsockcnct(url_view);
} catch(e) {
console.error(e);
}
}
nico_come_reader.prototype.getLatestDate = async function(page, url){
// Open URL Page
await page.goto(url);
// Browser JavaScript
return await page.evaluate(() => JSON.parse(document.getElementById("embedded-data").getAttribute("data-props")).site.relive.webSocketUrl);
}
nico_come_reader.prototype.viewsockcnct = function(url_view) {
const that = this;
this.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);
this.client.on('connectFailed', function(error) {
console.log('View Session Connect Error: ' + error.toString());
});
this.client.on('connect', function(connection) {
console.log('WebSocket Client Connected[View Session]: '+that.channel_name);
that.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]: '+that.channel_name);
if(that.socket_come)that.socket_come.close();
getdate = new Date();
if(that.prg_end_time >= getdate.getTime() && that.flg_end == 0)that.lncbrwser(that.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
that.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
nico_come_reader.prototype.comesockcnct = function(uri_comment,message_comment) {
const that = this;
this.comclient.connect(uri_comment, 'niconama', {
headers: {
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits',
'Sec-WebSocket-Protocol': 'msg.nicovideo.jp#json',
},
});
this.comclient.on('connectFailed', function(comerror) {
console.log('Comment Session Connect Error: ' + comerror.toString());
});
this.comclient.on('connect', function(connection) {
console.log('WebSocket Client Connected[Comment Session]: '+that.program_name);
that.socket_come = connection;
connection.sendUTF(message_comment);
// Comment Session Keep Alive
setInterval((connection)=>{connection.sendUTF("");}, 60000, connection);
setInterval((connection, end_time)=>{
getdate = new Date();
if(end_time < getdate.getTime() && that.flg_end == 0){
connection.close();
}
}, 60000, connection, that.prg_end_time);
connection.on('error', function(error) {
console.log("Comment Session Connection Error: " + error.toString());
});
connection.on('close', function() {
console.log('WebSocket Client Closed[Comment Session]: '+that.program_name);
that.socket_view.close();
/*fs.writeFile(this.dir_filesave+'/'+getdate.toFormat("YYYYMMDD")+this.sv_file_name, 'close', {flag:'a'}, (err) => {
if (err) throw err;
});*/
});
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){
getdate = new Date();
if (that.prg_str_time <= baff.chat.date*1000 && baff.chat.date*1000 <= that.prg_end_time) {
if (getdate.toFormat("HH24")-0 <= 3) {
fs.writeFile(that.dir_filesave+'/'+(getdate.toFormat("YYYYMMDD")-1)+that.sv_file_name, message.utf8Data+'\n', {flag:'a'}, (err) => {
if (err) throw err;
});
}
else {
fs.writeFile(that.dir_filesave+'/'+getdate.toFormat("YYYYMMDD")+that.sv_file_name, message.utf8Data+'\n', {flag:'a'}, (err) => {
if (err) throw err;
});
}
}
//console.log('Received Coment: (TIME: '+baff.chat.date+', OPT: '+baff.chat.mail +') '+baff.chat.content);
}
}
}
});
});
}
↓EPGStation用番組登録アプリ
const request = require('request');
let options = {
uri: "http://localhost:12525/start",
headers: {
"Content-type": "application/json",
},
json: {
"c_name": process.env.HALF_WIDTH_CHANNELNAME,
"p_name": process.env.HALF_WIDTH_NAME,
"s_time": process.env.STARTAT,
"e_time": process.env.ENDAT,
"p_url": process.argv[3]
}
};
if(process.argv[2] == 'stop')options.uri = "http://localhost:12525/stop";
request.post(options, function(error, response, body){});
使い方
最低限Nodejsとnpm、今回使用しているモジュールのインストールで使用可能。
アプリの自動起動や管理も考えるとpm2の利用がおすすめ。
$ curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
$ apt update
$ sudo apt install -y nodejs Chromium
$ sudo npm install pm2 -g
環境の準備が整ったら適当な場所に上記serverソースを保存してください。
デフォルトのコメントjsonの保存場所はアプリと同一ディレクトリにある"files"ディレクトリになっているので変更するか事前に作成してください。
gitに上げているのでとりあえず使ってみたい方はそちらからどうぞ。
$ mkdir nico_come
$ cd nico_come
$ mkdir files
$ wget https://raw.githubusercontent.com/nisiharayosi/nico_coment_capt_server/main/nico_come_capt_server.js
$ npm init -y
$ npm install express body-parser puppeteer-core websocket date-utils
$ pm2 start nico_come_capt_server.js --name nico_come_capt
上記実行後"pm2 list"や"pm2 logs nico_come_capt"でコメント取得アプリが立ち上がりエラー等もないことを確認できればEPGStation側の設定をします。
$ cd EPGStation
$ mkdir node
$ cd node
$ wget https://raw.githubusercontent.com/nisiharayosi/nico_coment_capt_server/main/nico_come_capt_ctrl.js
$ vi ../config/config.yml
~~~~~~~~~~ - vim - ~~~~~~~~~~
@config.yml(以下2行を追加)
recordingPreStartCommand: '/bin/node ./node/nico_come_capt_ctrl.js start'
recordingFinishCommand: '/bin/node ./node/nico_come_capt_ctrl.js stop'
~~~~~~~~~~ - vim - ~~~~~~~~~~
$ pm2 restart epgstation
EPGStationがアクセスできるディレクトリに"nico_come_capt_ctrl.js"を置いてください。
上記ではEPGStationのディレクトリに"node"ディレクトリを作ってそこに"nico_come_capt_ctrl.js"を置いてます。
nodeアプリの設置が終わったらEPGStationのconfig.ymlに外部コマンドを設定して適用させるために再起動してください。
ここまで終われば、EPGStationで録画予約すると自動でニコニコ実況のコメントを取得保存します。
番組の録画終了時にはコメント取得終了指示を送り終了させます。
途中で録画をキャンセルしたり、異常がありコメント取得終了指示が飛ばなくても開始指示の際に送ってある番組終了時刻に自動で終了するようにもしてあります。
コメント取得終了の際にはインスタンスを消去して簡単に掃除してるので長期運用でもある程度は問題ないはずです......。
後書き的なもの
EPGStation連携でのニコニコ実況コメント取得までできたので、次は録画視聴時のコメント表示に取り組んでみようと思います。
また現状は放送時間固定のアニメ専用みたいなものなので、試合の生放送みたいな放送延長に対応した方がいいのかどうかなど検討中。