4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

帰ってきたニコニコのニコ生コメントサーバーからのコメント取得備忘録

Last updated at Posted at 2024-08-19

導入

2024年6月のニコニコへのサイバー攻撃による新バージョン、「帰ってきたニコニコ」でコメントサーバーの形式が変わりました。
この記事では、コメントサーバーの接続方法を備忘録として解説していきます。
この記事で例として提示するurlは全て配信が終了したライブのものであり、記事を読んでいる時点で接続することはできません。

uriはtypo(タイプミス)ではありません。

調査が完了し、放送者コメント通常コメント投稿に対応しました。
生放送中の過去コメント取得に対応しました。
ニコニ広告、ギフトのフォーマットを掲載しました。

アーカイブの過去コメント取得は調査するつもりはありません。mujurin1さんの拡張機能が過去コメント取得に対応しているのでそのリポジトリを参考にしてください

宣伝

この実装をMultiCommentViewerに移植しました。

Github issuseTwitter dmdiscord: daisukedaisukeでのフィードバックお待ちしております。

NiconamaCommentViewerが新ニコ生コメントサーバーに対応したようです。
ニコニコ連携が必要ですが、そちらを使うのもコメントを表示する手段の一つです。

この解説では触れませんが、ユーザーidからライブidを取得することができるコードです。
タイトルや概要、オンラインかどうかなども取得できるのでぜひ参考にしてください
(非ログインで使えます。)
https://github.com/DaisukeDaisuke/Nicolive-php-cli-echo/blob/main/main.php#L80

大まかな流れ

  • 放送ページをダウンロード、html解析しwebsocketサーバーurlを取得する
  • websocketサーバーに非同期で接続する
  • websocketサーバーからメッセージサーバーのuriが送られてくる
  • websocketに定期的にpongを送信して接続を維持する
  • メッセージサーバーにhttpストリーミングかつ非同期で接続する
  • ヘッダを解析しチャンクを分解、protocol buffersをデコードし、ChunkdEntryを解析する
  • メッセージサーバーからヘッダとprotocol buffers形式でセグメントサーバーのuriが定期的に送られてくる
  • 新しいメッセージサーバーuriが送られてきたら乗り換える
  • サーバー側から送られてくるnextStreamAtMessageServerClient.nextStreamAtを更新する
  • サーバー側からメッセージサーバーのhttpストリーミングが切断されたら新しいMessageServerClient.nextStreamAtで接続し直す
  • メッセージサーバーからセグメントサーバーのuriが送られてくる
  • 新しいセグメントサーバーを受信しても古い接続はサーバー側から切断されるまで維持する
  • セグメントサーバーにhttpストリーミングかつ非同期で接続する
  • ヘッダを解析しチャンクを分解、protocol buffersをデコードしChunkedMessageを解析する
  • コメントを取得できる

コメントサーバーからコメントを取得するために必要な技術と前提要件

  • 非同期対応 websocket クライアント
  • 非同期対応 http streaming クライアント(接続が切れるまで待たずに、リアルタイムでhttp streamingからメッセージ受け取れるクライアント)
  • jsonデコード
  • google/protocol buffers (データーフォーマットであって通信規格ではない、概要)
  • google/protocol buffersで公式にサポートされている言語であること、または(例:)非公式のランタイムとprotoファイルコンパイラがあること
  • Byte[]や、バイナリ文字列のバイナリセーフ部分取得
  • htmlの解析(正規表現で可)
  • 非同期処理(いわゆるawait, C#のawait、phpだとReactPHP)
  • sudo apt install -y protobuf-compiler (Github Codespace内で使います、公式にサポートされている言語ではprotoファイルをコンパイルするのに必要。実行環境ではコンパイラは不要(逆にprotocol buffers ランタイムの依存関係は必要))
  • GIthub アカウント(Github Codespaceを使う場合)
      
    旧方式ではwebsocketの2重接続とjsonが使われていたようですが、新サーバーではhttpストリーミングを使ったメッセージサーバーや、httpストリーミングのセグメントサーバーが採用されており、データーフォーマットはgoogleのprotocol bufferが使用されるようになったのが特徴です。
    また、新仕様ではなぜかユーザーエージェントが不要になったようです。
    また、複数の接続とイベントを同時に裁く必要があり、非同期処理は必須です。

過去のコメント取得API参考元:

実装

コードによる文献が必要な場合下記のリポジトリを参考にしてください。

TypeScript実装(先駆者様)

C#実装(MultiCommentViewerに移植、旧実装も残ってるので注意)

php実装(ReactPHP製)

websocketサーバーのurl取得

ネットワーク切断時の例外処理については触れません。適切に実装してください

前提として、コメントサーバーへの通信はすべて非ログイン状態で行うことができます。

まず取得したい放送ページを普通にダウンロードします(live.でもlive2.でもいい)
テストは活発かつライブ時間の長いニコニコニュース実況をお勧めします

https://live2.nicovideo.jp/watch/lv345581403

次に、放送ページhtmlに置かれてるdata-propsのjsonを取得して解析します。

 "<script [^>]+ data-props=\"([^>]+)\"></script>"

次に、&quot;"に置換します。

ブラウザコンソール(js)で書く場合こんな感じです

// #embedded-data要素を取得
var element = document.getElementById("embedded-data");

// data-props属性の値を取得
var jsonData = element.getAttribute("data-props");
var dataObject;

try {
  // JSONをパースしてJavaScriptオブジェクトに変換
  dataObject = JSON.parse(jsonData);
  console.log("Parsed JSON data:", dataObject);
} catch (e) {
  console.error("Failed to parse JSON data:", e);
}

次に、置換したurlをjsonとしてパースし、

json["site"]["relive"]["webSocketUrl"]

を取得して接続先のurlを取得します。
終了した生放送にはwebSocketUrlというキーはないので、ぬるぽに注意する必要があります。
記事内で[data-props]と書かれている場合、このjsonのことを指します。

websocketサーバーへの接続

ログイン状態でページを要求すると、認証されたwssurlが送られてきます。その場合、urlを外部に公開しないでください。

用意するクラス

  • wssクライアントと後記する各種クライアントを管理するクラス

websocketサーバーへの接続は以前のニコ生と同じであり、コードを使い回すことができます。
取得したurlに普通のwebsocketクライアントを使用して接続し、ウェルカムメッセージを送信します
(urlはwss://a.live2.nicovideo.jp/wsapi/v2/watch/から始まります。)

追記: startWatchingのqualityはhighではなくabrのようです。

{"type":"startWatching","data":{"stream":{"quality":"abr","protocol":"hls","latency":"high","chasePlay":false},"room":{"protocol":"webSocket","commentable":true},"reconnect":false}}

例:

var s = "{\"type\":\"startWatching\",\"data\":{\"stream\":{\"quality\":\"high\",\"protocol\":\"hls\",\"latency\":\"low\",\"chasePlay\":false},\"room\":{\"protocol\":\"webSocket\",\"commentable\":true},\"reconnect\":false}}";
try
{
    _ws.Send(s);
}
catch (Exception ex)
{

}
			$message = json_encode([
				"type" => "startWatching",
				"data" => [
					"stream" => [
						"quality" => "abr",
						"protocol" => "hls",
						"latency" => "low",
						"chasePlay" => false
					],
					"room" => [
						"protocol" => "webSocket",
						"commentable" => false
					],
					"reconnect" => false
				]
			]);
			$conn->send($message);

ウェルカムメッセージを送信すると、サーバー側からいろんなjsonでイベントが送られてくるようになるので、それを解析します。
この時点ではコメント取得はできません
roomというイベントは帰ってきたニコニコでは廃止されました。

{"type":"serverTime","data":{"currentMs":"2024-08-18T16:25:21.416+09:00"}}
{"type":"stream","data":{"uri":"https://liveedge277.dmc.nico/hlslive/ht2_nicolive/nicolive-production-pg30119184826949_932c9fdf16e4dfd411d603c0b9e033698677bf606807c164cdef567c893f0124/master.m3u8?ht2_nicolive=anonymous-user-999e6d60-f874-4c30-9ae3-4c03e3cdbd59.spuupor1hd_siekm9_spzb6misbcd3","syncUri":"https://liveedge277.dmc.nico/hlslive/ht2_nicolive/nicolive-production-pg30119184826949_932c9fdf16e4dfd411d603c0b9e033698677bf606807c164cdef567c893f0124/stream_sync.json?ht2_nicolive=anonymous-user-999e6d60-f874-4c30-9ae3-4c03e3cdbd59.spuupor1hd_siekm9_spzb6misbcd3","quality":"high","availableQualities":["abr","super_high","high","normal","low","super_low","audio_high","audio_only"],"protocol":"hls"}}
{"type":"statistics","data":{"viewers":41,"comments":1}}
{"type":"schedule","data":{"begin":"2024-08-18T12:28:17+09:00","end":"2024-08-18T18:58:17+09:00"}}
{"type":"postCommentResult","data":{"chat":{"content":"解説","mail":"184","anonymity":1,"restricted":false}}}

送られてくる中で特記するべきイベントは、下記の通りです。

seat

このイベントを受信した場合、クライアントは「keepIntervalSec」秒毎に、切断するまでkeepSeatイベントを送信する必要があります。
ウェルカムメッセージ送信後に次のjsonが送られてきます。

{"type":"seat","data":{"keepIntervalSec":30}}

クライアントは30秒事に、切断するまで次のjsonを送信する必要があります。

{"type":"keepSeat"}
json_encode(["type" => "keepSeat"]);

ping

このイベントを受け取った場合、クライアントは即座にpongで応答する必要があります。

{"type":"ping"}

クライアントは即座に次のメッセージを送信する必要があります。

{"type":"pong"}
json_encode(["type" => "pong"])

disconnect

このイベントを受信した場合、次のイベントが発生したことを表しています。

  • websocketの接続に異常があった。
  • 配信が終了した。

data.reason(string)に理由が掲載されており、END_PROGRAMの場合は配信が終了したため、websocket、後記するhttpストリームをエラーなく全て切断する必要があります。

$json["data"]["reason"] === "END_PROGRAM"

の場合は配信が終了したことを表しています。
それ以外の場合はwebsocket接続に異常があることを表しています。

error

配信のwssに接続できなかった場合、クライアントが異常なメッセージを送信した場合このメッセージが送られてきます。
errorが送られてきた場合。次のイベントが発生しました:

  • 配信が終了したwebsocketurlに接続しようとした(CONNECT_ERROR)
  • クライアントが異常なjsonを送信した(INVALID_MESSAGE)
  • 要求した操作を行う権限がない(COMMENT_POST_NOT_ALLOWEDなど)

終了した生放送のwss urlに接続しようとした場合に送られてきます
(アーカイブ状態の生放送にはwebSocketUrlはないため、通常は発生しません。)
body.code (string)に理由が書かれており、CONNECT_ERRORの場合は通常、配信が終了したwssに接続を試みたことを表しています。

$json["body"]["code"] === "CONNECT_ERROR"

messageServer

メッセージサーバー(http ストリームサーバー)の接続先を指定するイベントです。
通常ウェルカムメッセージを送ると1秒以内に送られてきます。
基本的にメッセージサーバーurlの乗り換えは発生しませんが(再接続は発生する)、いつでもurlを切り替えれるように実装することをお勧めします
(たぶん新しいメッセージサーバーurlをを受信したら即座に切断して新しいメッセージサーバーに乗り換えてよい)

{"type":"messageServer","data":{"viewUri":"https://mpn.live.nicovideo.jp/api/view/v4/BBwFGn4L-Q3u-BT6S05zM5GeYcUSOgjVvCfKkO0zH2QwGCo05Z69mlsiKB76iiG2mJaIFSQVzte59ioZU2c","vposBaseTime":"2024-08-18T12:27:43+09:00"}}

クライアントは、data.viewUri(string)にhttpストリームで接続するか、既に接続がある場合は乗り換える必要があります。

メッセージサーバーへの接続

メッセージサーバーからはコメントは受け取れません。さらにセグメントサーバーに同時接続してやっとコメントを受け取れます。

チャンクについて

MessageServer接続中はwebsocket接続は切断しないでください。

これ以降のデータ受信は、メッセージサーバーから1つのhttpストリームでprotobufデーターを複数送信できるように、protobufに独自形式のヘッダが付与された状態で送られてきます

(ここでは1つのprotobufの塊のことをチャンクといいます。送られてくるバイト列のことをデーターといいます。)
まずはそのデーターのヘッダをデコードしてチャンクに分割する処理を書く必要があります。

データーのサンプルとして、1回目の接続時に送られてくる次回配信時時刻を表すデーターを掲載します。
https://www.dropbox.com/scl/fi/rxcvd55c92krnysu3dlc0/BBw7jL_9uu_0.bin?rlkey=9ojazixslyaix4t92io1a8v47&st=kx039kyb&dl=1

つまるところ、チャンクを分割するコードは下記のようなC#、php、jsコードでありchatGPTに翻訳してもらった方が早いと思います。

c#実装

js実装

dmcaされるかもしれませんが、pc-watchの該当コードです。
this.fromBinaryはバイナリからprotobufのオブジェクトを生成する処理です。
decodeVarintのeはUint8Array、tは多分現在のオフセットです。

* read() {
    let e, t = 0;
    for (; null !== (e = this.decodeVarint(this.buffer, t));) {
        const {value: a, offset: n} = e, o = n + 1, r = o + a;
        if (this.buffer.length < r) break;
        t = r;
        const i = this.fromBinary(this.buffer.subarray(o, t));
        yield i
    }
    t && (this.buffer = this.buffer.slice(t))
}

decodeVarint(e, t) {
    let a, n = 0;
    const o = e.length;
    let r = !1, i = 0;
    do {
        if (o < t) return null;
        a = e[t], r = Boolean(128 & a), n |= (127 & a) << i, r && (t++, i += 7)
    } while (r);
    return {value: n, offset: t}
}

php実装(例であり本番コードではない)

phpに翻訳するとこうなります。
この場合はライブラリを使うのを避けるために配列を使っていますが、配列は遅いので文字列を直接処理できるライブラリや機能を使った方が早いです

todo: 例を非ライブラリの文字列処理に書き換える

(php実装ではこのコードではなく、文字列を処理できるライブラリ使ってます)
protobufランタイムは大抵の場合文字列や、Byte[]を要求するため、このphp実装は不適切です。

<?php

class BinaryStream {
	private $buffer;
	private $offset;

	public function __construct($data) {
		$this->buffer = array_values(unpack('C*', $data));//ここは文字列$dataが空でもエラーが発生しないようにしないといけない
		$this->offset = 0;
	}

	private function decodeVarint(&$offset) {
		$value = 0;
		$shift = 0;
		$length = count($this->buffer)-1;
		$more = false;

		do {
			if ($length < $offset) {
				return null;
			}
			$byte = $this->buffer[$offset];
			$more = ($byte & 128) !== 0;
			$value |= ($byte & 127) << $shift;
			if ($more) {
				$offset++;
				$shift += 7;
			}
		} while ($more);

		return ['value' => $value, 'offset' => $offset];
	}

    //配列より文字列で処理した方が数倍速いのでそうしたほうがいい
	public function read() {
		$offset = 0;
		while (true) {
			$result = $this->decodeVarint($offset);
			if (!is_array($result)){
				break;
			}

			//decodeVarintの結果を取得する
			$value = $result['value'];
			$newOffset = $result['offset'];
			$start = $newOffset + 1;//1バイト消費した場合は0が返ってくる
			$end = $start + $value;

			if (count($this->buffer) < $end) break;//ストリームデーターが不足している場合は中止

			$offset = $end;//もしストリームデーターが不足している場合は、新しいオフセットを採用しない
			$binaryData = array_slice($this->buffer, $start, $offset - $start);//ストリームデーターを切り取り
			yield $binaryData;//分割したチャンクを返す
		}
  
		if ($offset) {
			$this->buffer = array_slice($this->buffer, $offset);//tryClearBufferを実装する場合、この処理はいらない
		}
	}
 	public function addBuffer(string $data) : void{
		$this->buffer .= [...$this->buffer, ...array_values(unpack('C*', $data))];//配列(文字列)をappendする処理、文字で処理すれば単に接続するだけでいい。
	}

     public function tryClearBuffer(): void{
		if(strlen($this->buffer) === $this->offset){
			$this->buffer = [];
			$this->offset = 0;
		}
	}
}

$stream = new BinaryStream(base64_decode("CCIGCPXk+7UG"));
foreach($stream->read() as $chunk){
	echo count($chunk);//count = 配列の個数を調べる関数、strlenが文字列の長さを調べる関数
}

チャンクを分割するコードができたら、下記のファイルでテストして、データーを次の長さに分割できれば完璧です。
https://www.dropbox.com/scl/fi/megwqkrfwa17vhxstj9bs/BBw7jL_9uu_1.bin?rlkey=r7wh2duh1tjm4ut8axlhmcm4h&st=0m0nv36e&dl=1

294
156
156
156
156

http ストリーミング

messageServerのhttpストリーミングコードとチャンクスピリッターは、同じhttp ストリーミングサーバーであるsegmentServerで使いまわせるように設計してください。また、3つ以上のインスタンスが同時に接続できるようにしてください。

サーバーに接続しないとデーターは受け取れないので、httpストリーミングについて触れます。

用意するクラス

  • MessageServerClient
  • HttpSteamReceiver
  • ChunkSpiriter(BinaryStreamChunk)
  • websocketクラスへのコールバック

用意するプロパティ

  • nextStreamAt: 次の配信時刻をサーバー側に送る為に保存しておくプロパティ(messageServerのみ、segmentServerでは不要)
  • messageServerUri: uriである事に注意。メッセージサーバーのuri

websocketクライアントで受信できるメッセージサーバーのuriはhttps://mpn.live.nicovideo.jp/api/view/v4/BB*であり、何回もリクエストを投げるのでプロパティに保存する必要があります。
サーバーからデーターが下りてくる時刻を表すプロパティnextStreamAt(string)の初期値はnowであり、初回接続のuriは「messageServerUri(websocketサーバーから降ってきたuriそのまま) + "&at=" + nextStreamAt」= 「https://mpn.live.nicovideo.jp/api/view/v4/BB...&at=now」です。
無くても受信できますが、公式クライアントは謎のヘッダを送信しており、

"header" => "u=1, i",

をつけてGetリクエストを送るとデーターをhttp ストリーミングで受信することができます。
初回接続は即座に接続が切れ、次の配信時刻がprotobufの塊で降ってくるので、protobufを解析してnextStreamAtを更新して即座に次の接続を繋げる必要があります。

配信が終了するまで、サーバー側から切断された場合、再度同じuriにhttpリクエストを投げます(正しくprotobufを解析できれば、nextStreamAtは更新されているはずです。)
nextStreamAt(&at=)が未来の場合は、その時刻になるまで接続を繋げたまま待機するので、即座につなぎ直して問題ありません。
httpclientが例外を吐かないコードで、nextStreamAtが前回の接続と同じ場合は、接続に異常があったことを示しているので、切断して再接続することをお勧めします。

nextStreamAtの更新を忘れた場合、サーバーにdosすることになるので、必ずnextStreamAtは更新してください
&at=のつけ忘れに注意してください。つけ忘れた場合http 400が返ってきます

次はprotobufのコンパイル/解析について触れます。

protobufのコンパイルについて

Rustなどの公式で対応していない言語の場合、非公式のコンパイラ、ランタイムの手順に従ってください。

protobufのprotoフォーマットのコンパイルと、ランタイムによる解析はライブラリを使うのが普通であり、この記事でも公式ライブラリとランタイムを使ってデコードします。

protoファイルはここにあり、コンパイルはGithubのコードスペースを使用して行います。
https://github.com/Kiikurage/Nicolive-API/tree/master/src/proto/dwango/nicolive/chat
(Kiikurage/Nicolive-APIのprotoファイルはコンパイルできないので、フォークのprotoファイルを使ってください。)

対応言語は下記の通りです。
https://github.com/protocolbuffers/protobuf?tab=readme-ov-file#protobuf-runtime-installation

Github codespaceはgithubアカウントがあれば、一定時間は無料で使うことができ、フリープランでの課金は不可能なので、安全にWindowsでクリーンなUbuntu環境を使うことができます。

Nicolive-APIのフォークのcodeボタンからcodespaceの環境を作り、コマンドパネルに移動し、下記のコマンドを実行します。
https://github.com/DaisukeDaisukeTeam/Nicolive-API

image.png

クリップボードの使用許可は「許可する」をクリックします。

sudo apt update
sudo apt install -y protobuf-compiler
mkdir generated

左のエクスプローラーからbuild.shを作り、bashスクリプトbuild.shに貼り付け、--php_out=の部分を下記のどれかに変更して、実行(sh ./build.sh)します。

  --cpp_out=OUT_DIR           Generate C++ header and source.
  --csharp_out=OUT_DIR        Generate C# source file.
  --java_out=OUT_DIR          Generate Java source file.
  --js_out=OUT_DIR            Generate JavaScript source.
  --objc_out=OUT_DIR          Generate Objective C header and source.
  --php_out=OUT_DIR           Generate PHP source file.
  --python_out=OUT_DIR        Generate Python source file.
  --ruby_out=OUT_DIR          Generate Ruby source file.
#!/bin/bash

# 現在のディレクトリを取得
BASE_DIR=$(pwd)

# プロトファイルのディレクトリに移動
cd /workspaces/Nicolive-API/src/proto

# 全ての.protoファイルを再帰的に検索し、相対パスでprotocコマンドを実行
find . -name "*.proto" | while read -r proto_file; do
  protoc --php_out="$BASE_DIR/generated" "$proto_file"
done

# 元のディレクトリに戻る
cd $BASE_DIR

するとその言語に対応したコードが生成されるので、zipコマンドで圧縮してダウンロードします

zip -r generated.zip ./generated

次に、左のエクスプローラーからgenerated.zipを探し、左クリックメニューの「ダウンロード」をクリックします。

generated.zipを見つけれない場合はページを再読み込みしてください。
sh ./buildでは動作しません。sh ./build.shを実行する必要があります。

ファイルを生成できてダウンロードできたら、codespaceの管理ページに行き、Activeとなってるカラムを探し、「...」をクリックし、delete > deleteを選択してcodespaceを完全に削除します。
https://github.com/codespaces

protobufランタイムのインストール

この表のリンク先でパッケージマネージャによるインストール方法が書かれているので、それに従います。
https://github.com/protocolbuffers/protobuf?tab=readme-ov-file#protobuf-runtime-installation

phpの場合

composer req google/protobuf

です。
C#の場合

dotnet add package Google.Protobuf
dotnet add package Grpc.Tools

のように、ランタイムはメジャーなパッケージマネージャでのインストール方法が用意されています。

protobufのパース

protoファイルをコンパイラでコンパイルし、「http ストリーミング」の章でダウンロードしたデーターを、「チャンクについて」の章で触れた分割方法を使用して分割したら、いよいよprotobufをデコードする時が来ました!
generatedフォルダをプロジェクトに貼り付け、必要に応じてパスの調整や、autoloadの構成などを行います。

メッセージサーバーから送られてくるフォーマットは常にChunkedEntryというフォーマットであり、生成したクラスに付属するパーサーにかけると、オブジェクトが出来上がるので、それを解析します。
チャンクスピリッターの例では配列を使いましたが、多くの場合byte[]や文字列が要求されているので、文字列に対応したチャンクスピリッターであるBinaryStreamChunk()で解説します。
まず、protobufコンパイラによって生成されたコードにChunkedEntryというクラスがあるので、これを生成するか、静的クラスでパース関数を探します。
phpの場合

use Dwango\Nicolive\Chat\Service\Edge\ChunkedEntry;

$chunkedEntry = new ChunkedEntry();
$chunkedEntry->mergeFromString($chunk);

です。
c#の場合、

var entry = ChunkedEntry.Parser.ParseFrom(item);

です。

生成できたオブジェクトを、親関数へ渡しますが、今回は突然登場するコールバックを採用します。

$stream = new BinaryStreamChunk($a);
foreach($stream->read() as $chunk){//$chunkは文字列
	$chunkedEntry = new ChunkedEntry();
	$chunkedEntry->mergeFromString($chunk);
    ($this->callback)($chunkedEntry);
}

コールバックはこんな感じであり、

	private function processChuknkEntry(ChunkedEntry $entry) : void{
		switch(true){
			case $entry->getBackward() !== null:
			case $entry->getPrevious() !== null;
				//無視
				break;
			case $entry->getSegment() !== null:
				$uri = $entry->getSegment()->getUri();
				if($uri === null){
					break;
				}
				$segmentServerClient = new  segmentServerClient($this->loop, $this->htmlname, fn(ChunkedMessage $message) => $this->processChunkedMessage($message));
				$segmentServerClient->ConnectToSegment($uri);
				$this->segmentServerClient[] = $segmentServerClient;
				break;
			case $entry->getNext() !== null:
				$this->messageServerClient->setNextStreamAt($entry->getNext()->getAt());
				break;
		}
	}

特記すべきイベントは下記の通りです。

null安全が利用できない言語では、entry.caseで判定します。

Next

次のサーバー側の配信時刻であるentry.Next.Atでクライアント側のnextStreamAtに更新します。
これはMessageServerのhttpストリーミングが切断される前に送られてきます。
これにより、メッセージサーバーの接続先uriが変わる事になります。
これが送られてきた後にサーバー側から切断されるので、新しいnextStreamAt(&at=)で即座に再接続します。

Segment

コメント配信サーバーであるsegmentServerのuriがentry.Segment.Uriで送られてきたことを意味します。
uriは、https://mpn.live.nicovideo.jp/data/segment/v4/BB*です。
メッセージサーバーのコードを使いまわしてhttpストリーミングサーバーに接続し、チャンク分割までは同じですが、protobufのパース先(データーフォーマット)がChunkedMessageになってる点が異なります。

セグメントサーバーは、たとえ新しいサーバーuriが送られてきたとしても、サーバー側から切断されるまで受信する必要があります
(定期的に新しい配信先uriが送られてきます)
つまり、2個以上のSegmentServerClientが同時に通信できる設計にする必要があります。

(即座に乗り換えると切断してから新しいサーバーUriで配信が始まるまでのコメントが受信できない)

生放送中の過去コメント取得に必要なイベント

Backward

過去コメントを取得するためのuri(2つ前のsegmentまで)、最後の放送者コメントを取得するためのuri(protoファイルがないためでデコード不可)が送られてきます。後ほど解説します。

Previous

Backwardと最新のセグメントの間のコメントを埋めるために、前回のセグメントが送られてきます。後ほど解説します。

SegmentServerへの接続

SegmentServer受信中はMessageServerへの接続は切断しないでください。

用意するクラス

  • SegmentServerClient
  • HttpSteamReceiver
  • ChunkSpiriter(BinaryStreamChunk)
  • websocketクラスへのコールバック

用意するプロパティ

  • SegmentServerUri

SegmentServerではヘッダーもnextStreamAt(&at=)は不要です

MessageServerと同じコードベースを使いまわして、http ストリーミングサーバーであるSegmentServerへ接続してください。protobuf形式のChunkedMessageの塊が送られてきます。
これをChunkSpiriterで分割し、ChunkedMessageクラスを使用して解析をします。
この接続はリアルタイムで受信して処理することが必須です。
SegmentServerの乗り換えは定期的に発生しますが、新しいSegmentServer uriを受信したら新しいサーバーに接続しますが、古い接続もサーバー側から切断されるまで保ったままにしてください。
そうしないと、古い接続で配信された情報を受け取れません。
サーバー側から切断されたSegmentServerClientは、ガーベッジコレクションできる状態に破壊してください。そうしないと、メモリリークが発生します。

ChunkedMessageの解析

ここまで実装できれば、あとはchunkedMessageを解析するだけです!
セグメントサーバーの例は次の通りであり、チャンクスピリッターとprotobufコンパイラが生成したChunkedMessageがあればデコードできます。

単体テスト用に適当なパケットダンプを掲載します
https://www.dropbox.com/scl/fi/syzlymkxonuwt9l6v9819/segment_BBwPRLiwjh_1.bin?rlkey=2cl5k2lo5yugqbk74y650do7w&st=x84fopti&dl=1

function processChunkedMessage(ChunkedMessage $message){
    
}
$data = file_get_contents(__DIR__."/segment_BBwPRLiwjh_1.bin");
$stream = new BinaryStreamChunk($data);
foreach($stream->read() as $chunk){//$chunk = string
    $chunkedMessage = new \Dwango\Nicolive\Chat\Service\Edge\ChunkedMessage();
	$chunkedMessage->mergeFromString($chunk);
    processChunkedMessage($chunkedMessage);
}
$stream->tryClearBuffer();

ストリーミングの最初にFlushedシグナルが送られてくるので、ぬるぽに注意する必要があります。

object(Dwango\Nicolive\Chat\Service\Edge\ChunkedMessage)#1280 (1) {
  ["signal"]=>
  string(7) "Flushed"
}

ChunkedMessageのデコード処理はnull安全や空文字列安全である必要があり、たとえ要素が欠けていたとしても例外が発生しないように実装する必要があります。

データーをチャンクに分割したテスト用コードを掲載します。現時点で送られてくる可能性のあるChunkedMessageはコメントロックなどのシステムイベントを除きこのパターンだけです。
ギフトで無いのはadvertiserNameと書いてますがadvertiserUserIdの間違いです。

chunkedMessageの解析は実コード読んでください。

ログイン判定

wssurlの取得先であるdata-propsにログインしてるかどうかの情報も掲載されており、下記のキーで取得することができます。
クライアントは、isBroadcasterを見て、コメントの送信先を切り替える必要があります。

[data-props].user.isLoggedIn: ログインしてるかどうか
[data-props].user.isBroadcaster: このライブ配信を放送してるアカウントかどうか

コメント投稿(放送者以外)

放送者以外のコメント投稿の仕様は以前と同じであり、以前のコードを使いまわすことができます。
ここでは、現時点の仕様を解説します。
ログイン状態のクッキーで放送ページをリクエストすると、認証されたwssurlが送られてくるので、そこに接続してコメント投稿することができます。
通常コメントは、認証されたwssurlで、wssサーバーにpostCommentイベントを送る事によって行う事が出来ます。
ただし、放送者コメントはこのエンドポイントから受け付けなくなっており、放送者の認証があるwssurlでpostCommentを送信すると無視されます。
また、非ログインのwssurlでpostCommentを送ると、errorイベント(理由: COMMENT_POST_NOT_ALLOWED)が発生します。
また、ニコ生のコメント投稿にはおそらくbot対策が実装されており、ログイン状態でも非正規クライアントからのコメント投稿をCOMMENT_POST_NOT_ALLOWEDで弾くことがあるようです。
↑素のwebsocketでも投稿できたりできなかったりするので、条件は不明です。
↑そのアカウントでコメント投稿する放送ページを開いてると発生しにくい(憶測)ようです。
また、外部ツールからコメントを投稿した場合、ニコ生を同じアカウントで視聴しているとリロードするまでコメントは表示されない仕様があります。

  • text: コメントテキスト
  • vpos 開始からの秒数を100倍にした値
  • isAnonymous: 絶対にfalse、名札でコメントをする場合要素を送らない。trueにした場合errorイベントのINVALID_MESSAGEで怒られる
  • color: 色
  • size: 大きさ(プレミアムじゃないと試せないので詳しい事は不明)
  • position: 場所(プレミアムじゃないと試せないので詳しい事は不明)
		$json = json_encode([
			"type" => "postComment",
			"data" => [
				"text" => $text,
				"vpos" => $vpos * 100,
				//"isAnonymous" => false,
			],
		]);

vpos計算

vposはライブ開始からの秒数を100倍した数値です。

_vposはdata-props、[data-props].program.vposBaseTimeから計算するか
(私の実装ではDiffTimeを考慮してます。DiffTimeはcalculateTimeDifferenceから計算できます。)

var elapsed = DateTime.Now.AddHours(-9) - _vposBaseTime.Value;
var ms = elapsed.TotalMilliseconds + _DiffTime;
var vpos = (long)Math.Round(ms / 10);

calculateTimeDifferenceを実装して、

	function calculateTimeDifference(DateTime $begin, DateTime $end, float $diff = 0.0) : float{
		// 差分を計算
		$interval = $end->diff($begin);

		// 差分をミリ秒単位で取得
		$differenceInSeconds = ($interval->days * 24 * 60 * 60) +
			($interval->h * 60 * 60) +
			($interval->i * 60) +
			$interval->s +
			($interval->f);  // ミリ秒部分も含める

		// 正負を判定
		if($begin < $end){
			$differenceInSeconds = -$differenceInSeconds;
		}

		// 差分と追加の秒数を加算し、小数点以下2桁まで丸める
		return round($differenceInSeconds + $diff, 2);
	}

websocketのscheduleのbegin、serverTimeイベントから計算するかのどちらかになります。

			case "serverTime":
				$this->difftime = $this->calculateTimeDifference($this->purseDataitem($message["data"]["currentMs"]), new DateTime(), 0);
				break;
			case "schedule":
				$this->begin = $this->purseDataitem($message["data"]["begin"]);
				break;
$vpos = $this->calculateTimeDifference(new DateTime(), $this->begin, $this->difftime);

放送者コメント

放送者コメントはwebsocketのpostCommentイベントから送信することはできず、
[data-props].site.broadcastRequest.apiBaseUrl + unama/api/v3/programs/ + LvId + /broadcaster_comment
= https://live2.nicovideo.jp/unama/api/v3/programs/lv345596630/broadcaster_comment
に認証ヘッダを加えてContent-Type: application/jsonでPUTリクエストを投げる必要があります。

ヘッターには次の情報が必要です。

  • ブラウザから取得したログイン状態のクッキー Cookie:
  • X-Public-Api-Token: (csrfToken)
  • Content-Type: application/json

csrfTokenは、[data-props].site.relive.csrfTokenから認証状態にかかわらず取得することができます。

ボディではjsonで次の情報を投げる必要があります。

$body = json_encode([
	"text" => "テキスト",
	"name" => "",
	"isPermanent" => false,
	"command" => "",
]);

これらの情報とともに、PUTリクエストを投げることによって、放送者コメントを投稿することができます。

ブラウザから取得した認証情報を使って放送者コメントを投稿することに成功したコードを備忘録として掲載します。

class http{

	public static function get($url,$data = false,$request = false){
		echo "\n";
		if($request !== false){
			var_dump($request.": ".$url);
		}else if($data !== false){
			var_dump("POST: ".$url);
		}else{
			var_dump("GET: ".$url);
		}

		$curl = curl_init($url);
		//curl_setopt($curl, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:17.0) Gecko/20100101 Firefox/17.0");

		curl_setopt($curl,CURLOPT_SSL_VERIFYPEER, FALSE); // オレオレ証明書対策
		curl_setopt($curl,CURLOPT_FOLLOWLOCATION, TRUE);// Locationヘッダを追跡



		if($request !== false) curl_setopt($curl,CURLOPT_CUSTOMREQUEST,$request);
		if($data !== false){
			curl_setopt($curl,CURLOPT_POST, TRUE);
			curl_setopt($curl, CURLOPT_POSTFIELDS,json_encode($data));
		}

		curl_setopt($curl,CURLOPT_USERAGENT,      "USER_AGENT");
		curl_setopt($curl, CURLOPT_HTTPHEADER, [
			'X-Public-Api-Token: aaaaaaaaaaaaa',
			'Cookie: ',
			"Content-Type: application/json",
		]);
		curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
		$return = curl_exec($curl);
		$errno = curl_errno($curl);
		$error = curl_error($curl);
		if($errno !== CURLE_OK){
			throw new \RuntimeException($error, $errno);
		}
		var_dump("StatusCode: ".curl_getinfo($curl, CURLINFO_RESPONSE_CODE));
		curl_close($curl);
		$test = json_decode($return,true);
		var_dump($data);
		var_dump($test);
		sleep(1);
		return $test;
	}
}

http::get("https://live2.nicovideo.jp/unama/api/v3/programs/lv345596630/broadcaster_comment", [
	"text" => "phpからテスト",
	"name" => "",
	"isPermanent" => false,
	"command" => "",
], "PUT");

成功した場合次のjsonが返ってきます。

array(1) {
  ["meta"]=>
  array(2) {
    ["status"]=>
    int(200)
    ["errorCode"]=>
    string(2) "OK"
  }
}

失敗した場合、ステータスコードとともに次のjsonが返ってきます。

array(1) {
  ["meta"]=>
  array(2) {
    ["status"]=>
    int(401)
    ["errorCode"]=>
    string(21) "AUTHENTICATION_FAILED"
  }
}
array(1) {
  ["meta"]=>
  array(3) {
    ["status"]=>
    int(400)
    ["errorCode"]=>
    string(11) "BAD_REQUEST"
    ["errorMessage"]=>
    string(35) "tokenが指定されていません"
  }
}

過去コメント取得

最新のセグメントサーバーからは新たに発生したコメントしか送られてこないので、過去コメントを取得するにはメッセージサーバーのBackward、Previousを取得して処理する必要があります。

Backward

メッセージサーバーから送られてくるChunkedEntryのentry.Backward?.Segment.UriはPackedSegmentの受信先uriであり、このエンドポイントから2つ前のセグメントまでのコメントを取得することができます。
もう片方は最新の放送者コメント取得uriですが、protoファイルがなくデコードできないので触れません。
Chunkedが付いていない事から分かるように、このuriから受信したデータはチャンク分割の必要はありません。

まずgetAsyncでもHttpSteamReceiverを使いまわしてもいいので、uriからデーターを取得します(リアルタイム取得は必要ありません)
次のように処理します

var segment = PackedSegment.Parser.ParseFrom(_streamReceiver.getBuffers());//デコード
 foreach (var chunkedMessage in segment.Messages)//foreach方法は言語によって違う
{
    await ProcessChunkedMessage(chunkedMessage, true);//既存のProcessChunkedMessageを使いまわす。trueは過去コメントかどうかのフラグ
}

さらに過去コメント取得したい場合は、PackedSegmentのnextを取得すればいいと思います(未検証)

これで過去コメントを取得してデコードできました。

Previous

Backwardは2つ前のセグメントのデータより先のコメントは取得できないので、最新のセグメントとBackwardの間を埋めるために、Previousを受信する必要があります。
といっても普通のセグメントサーバーなので、既存のコードを使いまわすだけでokです(過去コメントフラグをお忘れなく)

var Uri = entry.Previous.Uri;
var segmentServer = new SegmentServerClient(Uri, ProcessChunkedMessage, true);//trueは過去コメントフラグ
_segmentServers.Add(segmentServer);
var task = segmentServer.doConnect();
_toAdd.Add(task);

ニコニ広告について

テスト用コードで掲載しましたが、ニコニ広告イベントは次のフォーマットで送られてきます。
恐らく使われないであろうV0ニコニ広告に注意する必要があります

object(Dwango\Nicolive\Chat\Service\Edge\ChunkedMessage)#17 (2) {
  ["meta"]=>
  array(3) {
    ["id"]=>
    string(36) "EhkKEglpdzl3JHmRAREZQBPv7eRxqhDOvKEO"
    ["at"]=>
    string(27) "2024-08-22T08:11:58.649044Z"
    ["origin"]=>
    array(1) {
      ["chat"]=>
      array(1) {
        ["liveId"]=>
        string(9) "345611545"
      }
    }
  }
  ["message"]=>
  array(1) {
    ["nicoad"]=>
    array(1) {
      ["v1"]=>
      array(2) {
        ["totalAdPoint"]=>
        int(1500)
        ["message"]=>
        string(96) "【広告貢献2位】マルチコメントビュワーさんが500ptニコニ広告しました"
      }
    }
  }
}

ギフト

通常ギフト

通常ギフトは次のフォーマットで送られてきます。
advertiser_user_idcontribution_rankはない事があるようなので、ぬるぽ注意する必要があります(言語によっては初期値0が入ってることがあります)

object(Dwango\Nicolive\Chat\Service\Edge\ChunkedMessage)#17 (2) {
  ["meta"]=>
  array(3) {
    ["id"]=>
    string(36) "EhkKEglTfXI_LXmRARHIsmncCQ-PghDOvKEO"
    ["at"]=>
    string(27) "2024-08-22T08:21:34.194075Z"
    ["origin"]=>
    array(1) {
      ["chat"]=>
      array(1) {
        ["liveId"]=>
        string(9) "345611545"
      }
    }
  }
  ["message"]=>
  array(1) {
    ["gift"]=>
    array(6) {
      ["itemId"]=>
      string(20) "stamp_sayonarabaibai"
      ["advertiserUserId"]=>
      string(8) "48495285"
      ["advertiserName"]=>
      string(12) "だいこん"
      ["point"]=>
      string(2) "50"
      ["itemName"]=>
      string(24) "さよならバイバイ"
      ["contributionRank"]=>
      int(1)
    }
  }
}

匿名ギフト

匿名ギフトは次のフォーマットで送られてきます。
advertiserUserIdがありません。そのため「ぬるぽ」に注意する必要があります

object(Dwango\Nicolive\Chat\Service\Edge\ChunkedMessage)#17 (2) {
  ["meta"]=>
  array(3) {
    ["id"]=>
    string(36) "EhkKEgkvcF5xEXmRARE1Om-JGQFmixDOvKEO"
    ["at"]=>
    string(27) "2024-08-22T07:51:11.966812Z"
    ["origin"]=>
    array(1) {
      ["chat"]=>
      array(1) {
        ["liveId"]=>
        string(9) "345611545"
      }
    }
  }
  ["message"]=>
  array(1) {
    ["gift"]=>
    array(5) {
      ["itemId"]=>
      string(15) "ball_basketball"
      ["advertiserName"]=>
      string(9) "名無し"
      ["point"]=>
      string(2) "30"
      ["itemName"]=>
      string(24) "バスケットボール"
      ["contributionRank"]=>
      int(1)
    }
  }
}

複数人でギフト投げまくった場合

複数人がギフトを大量に投げると、contributionRankが消えます。そのため「ぬるぽ」に注意する必要があります。
(ランキング1位の配信の過去ログから取得)

object(Dwango\Nicolive\Chat\Service\Edge\ChunkedMessage)#6566 (2) {
  ["meta"]=>
  array(3) {
    ["id"]=>
    string(36) "EhkKEgmQfbxYa3mRARHx_A4ZXF0uthDJz6AO"
    ["at"]=>
    string(27) "2024-08-22T09:29:23.900947Z"
    ["origin"]=>
    array(1) {
      ["chat"]=>
      array(1) {
        ["liveId"]=>
        string(9) "345611436"
      }
    }
  }
  ["message"]=>
  array(1) {
    ["gift"]=>
    array(5) {
      ["itemId"]=>
      string(15) "yumemyan_roomba"
      ["advertiserUserId"]=>
      string(8) ""
      ["advertiserName"]=>
      string(12) ""
      ["point"]=>
      string(3) "500"
      ["itemName"]=>
      string(36) "闇ロボット掃除機にゃんこ"
    }
  }
}
"Cj4KJEVoa0tFZ21RZmJ4WWEzbVJBUkh4X0E0WlhGMHV0aERKejZBTxIMCPOJnLYGELi4za0DGggKBgisueakARJPQk0KD3l1bWVteWFuX3Jvb21iYRCv/c0QGgzjgZnjgajjgozjgpMg9AMyJOmXh+ODreODnOODg+ODiOaOg+mZpOapn+OBq+OCg+OCk+OBkw==";

切断

切断したい場合、MessageServerのhttpストリーミング、SegmentServerのhttpストリーミング、websocketクライアントを同時に切断する必要があります。そうしないと、切断後にコメントが漏れる場合があります。

連絡先

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?