ニコニコ動画

ニコニコ動画のコメントをJSONで取得したり投稿したり

追記 (2018/11/19)

  • 動画情報ページの動画内部情報にコメント取得に関わる commentComposite パラメータが追加されたことに合わせて内容を改めました。(commentCompositeを利用することで取得操作がだいぶ扱いやすくなったと思います)
  • コメント取得時でリクエストする際のコンテンツjsonの user_id が 数値ではなく文字列に変わっていたため変更に追従。

前略、ニコニコ動画の http://nmsg.nicovideo.jp/api.json/ を通じてコメント取得をする方法を説明していきます。

ここで紹介する内容はニコニコ動画のHTML5プレイヤーページを調べて得られる範囲の情報です。

httpにおけるGETやPOSTを理解している前提の内容になってます。

コメント取得までの流れ

  1. ニコニコ動画とログインセッションを張る
  2. 動画情報ページからコメント取得や投稿に必要な情報を取得
  3. http://nmsg.nicovideo.jp/api.json/ からコメント取得
  4. コメント情報を解析
  5. (アプリ等でコメント情報を利用する)

まずはログイン

https://secure.nicovideo.jp/secure/login?site=niconico

にメールアドレスとパスワードをPOSTします

C#+.Netの環境ではWindows.Web.HttpClientを使ってログインすることで、それ以降ログインセッションをHttpClientに保ってやり取りできます。

詳しくは別の方の記事を参照してください

【WindowsApp】ニコニコ動画をストリーミング再生してみる - garicchi.com

動画情報ページからコメント取得に必要な情報を取り出す

動画情報ページにアクセスしてdiv#js-initial-watch-dataの値にある文字列をjsonとして解析します。

json文字列はHTML Decodeが必要なので自分はこちらのページで変換させてもらってます (HTML Decode → UTF8 と変換必要かも)
 → https://so-zou.jp/web-app/text/encode-decode/

そうして出てきたjsonが ↓ のリンクです。このリンクを別ウィンドウで並べて表示しておくと見やすいかもしれません。
https://gist.github.com/tor4kichi/ec6e95137f04d1ac4cd3580498232fdc

その中で特にコメント取得周りで重要な部分を抜粋したものが以下のコードです。

js-initial-watch-dataの一部
{
    "thread": {
        "commentCount": 13472,
        "hasOwnerThread": "1",
        "mymemoryLanguage": null,
        "serverUrl": "https:\/\/nmsg.nicovideo.jp\/api\/",
        "subServerUrl": "https:\/\/nmsg.nicovideo.jp\/api\/",
        "ids": {
            "default": "1300727909",
            "nicos": null,
            "community": null
        }
    },
    "commentComposite": {
        "threads": [{
            "id": 1300727909,
            "fork": 1,
            "isActive": true,
            "postkeyStatus": 0,
            "isDefaultPostTarget": false,
            "isThreadkeyRequired": false,
            "isLeafRequired": false,
            "label": "owner",
            "isOwnerThread": true,
            "hasNicoscript": true
        }, {
            "id": 1300727909,
            "fork": 0,
            "isActive": true,
            "postkeyStatus": 0,
            "isDefaultPostTarget": true,
            "isThreadkeyRequired": false,
            "isLeafRequired": true,
            "label": "default",
            "isOwnerThread": false,
            "hasNicoscript": false
        }],
        "layers": [{
            "index": 0,
            "isTranslucent": false,
            "threadIds": [{
                "id": 1300727909,
                "fork": 1
            }]
        }, {
            "index": 1,
            "isTranslucent": false,
            "threadIds": [{
                "id": 1300727909,
                "fork": 0
            }]
        }]
    },
    "context": {
        "isVideoOwner": false,
        "isThreadOwner": false,
        "isOwnerThreadEditable": null,
        "csrfToken": "53842185-1542627809-84dc37065d2ddade2d774cb9509b4ecdbff3fedd",

        "userkey": "1542543208.~1~gNdO9DobwaQ4nJEH-KM_EjPdGTQivB11xEcIKXRDMJ0",

        "categoryName": "エンターテイメント",
        "categoryKey": "ent",
        "categoryGroupName": "エンタメ・音楽",
        "categoryGroupKey": "g_ent2",
        "isAllowEmbedPlayer": true
    }
}

このjsonに含まれる commentComposite がコメント取得(及び投稿)に重要な部分です。

commentComposite.threads がコメントの格納されているスレッド一覧です。このthreads配列の各要素の中から isActive が true のスレッドについてコメント取得を掛けていくことになります。

取得の際には commentComposite.threadsのisOwnerThreadや、チャンネル動画であるかどうかで場合分けが必要になります。

ということで、ここからは場合分けのパターンごとに説明していきます。

  • ユーザー動画(投コメ無し)
  • ユーザー動画(投コメ有り)
  • チャンネル動画

の順番で見ていきたいと思います。

コメントを取得する

http://nmsg.nicovideo.jp/api.json/

に対して後述のJSON文字列をPOSTで送ることでコメント情報がJSONとして帰ってきます。

ユーザー動画と公式動画等でリクエストする内容を変える必要がありますが、まずはユーザー動画でのリクエストの例を見ていきましょう。

基本は commentComposite.threads の内容を元にリクエスト内容のjsonを作成していきます。

ユーザー動画のコメント取得

以下はユーザー動画向けのコメント取得するためのJSONの例です(途中の//コメントは不要です)

ユーザー動画コメントリクエスト
[{
    "ping": {
            "content": "rs:0"
        }
    }, {
        "ping": {
            "content": "ps:0"
        }
    }, {
        "thread": {
            "thread": "1463483922",
            "version": "20090904",
            "language": 0,
            "user_id": "53842185",
            "with_global": 1,
            "scores": 1,
            "nicoru": 0,
            "userkey": "1502173042.~1~MzCxfaTZL7rDZztXT4fhmR3fXdyv-_24iGol36KOkRA"
        }
    }, {
        "ping": {
            "content": "pf:0"
        }
    }, {
        "ping": {
            "content": "ps:1"
        }
    }, {
        "thread_leaves": {
            "thread": "1463483922",
            "language": 0,
            "user_id": "53842185",
            "content": "0-22:100,1000", //0-2222は分単位の動画時間(秒は切り上げ)
            "scores": 1,
            "nicoru": 0,
            "userkey": "1502173042.~1~MzCxfaTZL7rDZztXT4fhmR3fXdyv-_24iGol36KOkRA"
        }
    }, {
        "ping": {
            "content": "pf:1"
        }
    }, {
        "ping": {
            "content": "rf:0"
        }
}]

thread や thread_leaves を配置するかどうかは commentComposite.threads によって決定します。

commentComposite.threads[0].isActive が true の場合は thread コマンドを追加します。
さらにcommentComposite.threads[0].isLeafRequired が true となっている指定されている場合には thread に続けて thread_leaves を追加します。

  • thread
    • fork
    • thread
    • user_id
    • userkey
  • thread_leaves
    • thread
    • user_id
    • content
    • userkey

thread.fork には commentComposite.threads[0].fork の値を与えます。

両方共通に登場する userkey には js-initial-watch-data の video.context.userkey の値を与えます。

thread_leaves.contentは、動画時間に合わせて調整します。(動画時間が21分49秒の動画であれば "0-22:100,1000" 、5分05秒であれば "0-6:100,1000")

そして上記JSONを http://nmsg.nicovideo.jp/api.json/ に対してPOSTメソッドでリクエストを送ると、以下のようなJSONが返ってきます(長いのでleafとchatを一部省略しています)

レスポンス
[{
    "ping": {
        "content": "rs:0"
    }
}, {
    "ping": {
        "content": "ps:0"
    }
}, {
    "thread": {
        "resultcode": 0,
        "thread": "1502018914",
        "server_time": 1502183168,
        "last_res": 3118,
        "ticket": "0x3af37077",
        "revision": 1
    }
}, {
    "leaf": {
        "thread": "1502018914",
        "count": 739
    }
}, {
    "leaf": {
        "thread": "1502018914",
        "leaf": 1,
        "count": 491
    }
}, {
    "leaf": {
        "thread": "1502018914",
        "leaf": 2,
        "count": 219
    }
}, {
    "leaf": {
        "thread": "1502018914",
        "leaf": 3,
        "count": 230
    }
}, {
    "global_num_res": {
        "thread": "1502018914",
        "num_res": 3119
    }
}, {
    "ping": {
        "content": "pf:0"
    }
}, {
    "ping": {
        "content": "ps:1"
    }
}, {
    "thread": {
        "resultcode": 0,
        "thread": "1502018914",
        "server_time": 1502183168,
        "last_res": 3118,
        "ticket": "0x3af37077",
        "revision": 1
    }
}, {
    "chat": {
        "thread": "1502018914",
        "no": 3114,
        "vpos": 17782,
        "leaf": 2,
        "date": 1502182372,
        "date_usec": 812369,
        "anonymity": 1,
        "user_id": "kBsTsJso_Os-H_O6j8xgFvN6RpA",
        "mail": "184",
        "content": "ケンイチは本当にいい熱血漫画でしたね・・・(関係無"
    }
}, {
    "chat": {
        "thread": "1502018914",
        "no": 3115,
        "vpos": 804,
        "date": 1502182766,
        "date_usec": 253520,
        "anonymity": 1,
        "user_id": "JRomz5W87ktOOEi04dXZiMiFacU",
        "mail": "184",
        "content": "遅かったじゃないか……"
    }
}, {
    "chat": {
        "thread": "1502018914",
        "no": 3116,
        "vpos": 86247,
        "leaf": 14,
        "date": 1502182931,
        "date_usec": 46671,
        "anonymity": 1,
        "deleted": 2
    }
}, {
    "chat": {
        "thread": "1502018914",
        "no": 3117,
        "vpos": 4928,
        "date": 1502182981,
        "date_usec": 164100,
        "anonymity": 1,
        "user_id": "GPNKoD4bG83hcwyZxBROyY3QAeI",
        "mail": "184",
        "content": "この人にとっての普通は俺達の常識ではない"
    }
}, {
    "chat": {
        "thread": "1502018914",
        "no": 3118,
        "vpos": 6632,
        "leaf": 1,
        "date": 1502183038,
        "date_usec": 311032,
        "anonymity": 1,
        "user_id": "GPNKoD4bG83hcwyZxBROyY3QAeI",
        "mail": "184 shita red big",
        "content": "尼デウス"
    }
}, {
    "ping": {
        "content": "pf:1"
    }
}, {
    "ping": {
        "content": "rf:0"
    }
}]

レスポンスのthread

"resultcode": 0,        // 0 で成功、それ以外は失敗
"thread": "1502018914",    // スレッドID
"server_time": 1502183168,  // 取得したサーバー時間(UNIX時間)
"last_res": 3118,       // コメント数
"ticket": "0x3af37077",    // コメント投稿するためのチケット
"revision": 1         // ?

レスポンスのchat

"thread": "1502018914", // スレッドID
"no": 168,        // コメント番号
"vpos": 58161,      // コメントの動画時間上の位置 1vpos=10ミリ秒 100vposで1秒
"leaf": 9,         // ?
"date": 1502019822,   // 投稿時間のUNIX時間
"date_usec": 257855,   // 投稿時間の1秒以下の時間 例ではdate+0.257885秒に投稿された
"premium": 1, // コメント投稿ユーザーがプレミアム会員であれば 1
"anonymity": 1,     // 匿名コメント
"user_id": "NOYnNqmzAwmdb6duA4IO0ogncNM", // ユーザーID(匿名の場合は1週間でリセットされる?)
"mail": "184",      // コメントのコマンド
"content": "草生える"   // コメント本文
"deleted": 2       // 1以上で 削除済み、数字は削除理由によって異なるが詳細不明

ユーザー動画(投コメ有り)のコメント取得

投コメの参考動画 http://www.nicovideo.jp/watch/sm32843532

commentComposite.isOwnerThread が true の動画では、先述のJSONの代わりに以下のようなJSONを送ることで投稿者コメントを含んだコメント一覧を取得できます。

通常動画で投コメ含むコメントを取得する際にPOSTするjson
[{
    "ping": {
        "content": "rs:0"
    }
}, {
    "ping": {
        "content": "ps:0"
    }
}, {
    "thread": {
        "thread": "1300727909",
        "version": "20061206",
        "fork": 1,
        "language": 0,
        "user_id": "53842185",
        "res_from": -1000,
        "with_global": 1,
        "scores": 1,
        "nicoru": 0,
        "userkey": "1542544291.~1~LrdrgoVcXogmEHE4as0iRWYY69ADlNX5I7tmCHxkt6E"
    }
}, {
    "ping": {
        "content": "pf:0"
    }
}, {
    "ping": {
        "content": "ps:1"
    }
}, {
    "thread": {
        "thread": "1300727909",
        "version": "20090904",
        "fork": 0,
        "language": 0,
        "user_id": "53842185",
        "with_global": 1,
        "scores": 1,
        "nicoru": 0,
        "userkey": "1542544291.~1~LrdrgoVcXogmEHE4as0iRWYY69ADlNX5I7tmCHxkt6E"
    }
}, {
    "ping": {
        "content": "pf:1"
    }
}, {
    "ping": {
        "content": "ps:2"
    }
}, {
    "thread_leaves": {
        "thread": "1300727909",
        "language": 0,
        "user_id": "53842185",
        "content": "0-11:100,1000",
        "scores": 1,
        "nicoru": 0,
        "userkey": "1542544291.~1~LrdrgoVcXogmEHE4as0iRWYY69ADlNX5I7tmCHxkt6E"
    }
}, {
    "ping": {
        "content": "pf:2"
    }
}, {
    "ping": {
        "content": "rf:0"
    }
}]

投コメ無しの場合と同様にこちらも video.commentComposite.threads の内容を元に決めていきます。isOwnerThread が true の場合には送信するjsonのthreadコマンドの内容が若干異なります。最初に出てくる thread について…

  • res_from : -1000
  • version : "20061206"

といったパラメータの変化があります。

投稿者コメントを判定する

投稿者コメントを判定する場合は user_id と deleted が chat に含まれないことをチェックすることで判定できそうです。

C#
if (chat.user_id == null && chat.deleted == null)
{
    // 投コメとして処理
}

ユーザーコメントでも削除済みの場合は user_id が null になるためこの2条件で判定する必要がありそうです。

チャンネル動画のコメント取得

チャンネル動画でも commentComposite.threads の内容に従ってリクエストのjsonを作っていきます。

チャンネル動画での commentComposite は以下のような内容になっています。

so34146929のjs-initial-watch-dataの一部
"commentComposite": {
    "threads": [{
        "id": 1541993102,
        "fork": 1,
        "isActive": false,
        "postkeyStatus": 0,
        "isDefaultPostTarget": false,
        "isThreadkeyRequired": false,
        "isLeafRequired": false,
        "label": "owner",
        "isOwnerThread": true,
        "hasNicoscript": true
    }, {
        "id": 1541993102,
        "fork": 0,
        "isActive": true,
        "postkeyStatus": 0,
        "isDefaultPostTarget": false,
        "isThreadkeyRequired": false,
        "isLeafRequired": true,
        "label": "default",
        "isOwnerThread": false,
        "hasNicoscript": false
    }, {
        "id": 1541993103,
        "fork": 0,
        "isActive": true,
        "postkeyStatus": 0,
        "isDefaultPostTarget": true,
        "isThreadkeyRequired": true,
        "isLeafRequired": true,
        "label": "community",
        "isOwnerThread": false,
        "hasNicoscript": false
    }],
    "layers": [{
        "index": 0,
        "isTranslucent": false,
        "threadIds": [{
            "id": 1541993102,
            "fork": 1
        }]
    }, {
        "index": 1,
        "isTranslucent": false,
        "threadIds": [{
            "id": 1541993103,
            "fork": 0
        }]
    }, {
        "index": 2,
        "isTranslucent": true,
        "threadIds": [{
            "id": 1541993102,
            "fork": 0
        }]
    }]
},

isActive が trueのものが2つあって、さらにその両方ともが isLeafRequired が true になっているため (thread + thread_leaves) * 2 が並ぶことになります。

チャンネル動画等でのリクエスト
[{
    "ping": {
        "content": "rs:0"
    }
    }, {
        "ping": {
            "content": "ps:0"
        }
    }, {
        "thread": {
            "thread": "1501742473",
            "version": "20090904",
            "language": 0,
            "user_id": "53842185",
            "with_global": 1,
            "scores": 1,
            "nicoru": 0,
            "userkey": "1502173804.~1~fwFqcTlwtEbO4ggddXkZLdbowXV9TrcE_NTbhDTmFlo"
        }
    }, {
        "ping": {
            "content": "pf:0"
        }
    }, {
        "ping": {
            "content": "ps:1"
        }
    }, {
        "thread_leaves": {
            "thread": "1501742473",
            "language": 0,
            "user_id": 53842185,
            "content": "0-13:100,1000",
            "scores": 1,
            "nicoru": 0,
            "userkey": "1502173804.~1~fwFqcTlwtEbO4ggddXkZLdbowXV9TrcE_NTbhDTmFlo"
        }
    }, {
        "ping": {
            "content": "pf:1"
        }
    }, {
        "ping": {
            "content": "ps:2"
        }
    }, {
        "thread": {
            "thread": "1501742474",
            "version": "20090904",
            "language": 0,
            "user_id": "53842185",
            "force_184": "1",
            "with_global": 1,
            "scores": 1,
            "nicoru": 0,
            "threadkey": "1502173806.e_qPpM9yX3kUgW80nVYo32EdDCU"
        }
    }, {
        "ping": {
            "content": "pf:2"
        }
    }, {
        "ping": {
            "content": "ps:3"
        }
    },
    {
        "thread_leaves": {
            "thread": "1501742474",
            "language": 0,
            "user_id": "53842185",
            "content": "0-13:100,1000",
            "scores": 1,
            "nicoru": 0,
            "force_184": "1",
            "threadkey": "1502173806.e_qPpM9yX3kUgW80nVYo32EdDCU"
        }
    }, {
        "ping": {
            "content": "pf:3"
        }
    }, {
        "ping": {
            "content": "rf:0"
        }
    }
]

commentComposite.threads[0].isThreadkeyRequired が true になっている場合は…

  • ThreadKey と force184 を与えてやる必要がありますが、これらは別途APIを叩いて取得する必要があります。
  • userkey が省かれています。この点において場合分けが必要になりそうです。

チャンネル動画でのコメントレスポンスはユーザー動画とほぼ同じのようです。(詳しく見てないですが同一の解析機で対処可能でした)

コメント投稿

これまでに扱ってきたコメント取得に近い要領でコメントの投稿も行えます。コメント投稿には以下のjsonを作成します

[
    {
        "ping": {
            "content": "rs:1"
        }
    },
    {
        "ping": {
            "content": "ps:11"
        }
    },
    {
        "chat": {
            "thread": "1520342061",
            "vpos": 555,
            "mail": "184",
            "ticket": "0xe488af0a",
            "user_id": "53842185",
            "content": "うぽつ",
            "postkey": ".dMQ..vMTo2.~1~cCMYiMmB3SP5U3ckWSYcv630Lk9-qUpheri-WKtucAo",
            "premium": "0"
        }
    },
    {
        "ping": {
            "content": "pf:11"
        }
    },
    {
        "ping": {
            "content": "rf:1"
        }
    }
]

chatの内容がキモになります。

chatパラメータ名 説明
thread commentComposite.threadsのうちで isDefaultPostTarget が true のスレッドIDを与える。
vpos 動画時間 を 10ミリ秒単位で表現した値。100vpos=1秒。
mail コメントのコマンド(公式のヘルプページ)
ticket "レスポンスのthread" で説明した中の ticket の値を与える
user_id ユーザーID。数値ではなく文字列なので注意。
content コメント本文
postkey 別途APIを叩いて取得が必要(旧コメント投稿解説記事)
premium プレミアム会員かのフラグを文字列。(一般="0" プレミアム="1")

このchatリクエストをPOSTメソッドで送信すると、以下のようなレスポンスが返ってきます。

コメント投稿時のレスポンス
[
    {
        "ping": {
            "content": "rs:1"
        }
    },
    {
        "ping": {
            "content": "ps:11"
        }
    },
    {
        "chat_result": {
            "thread": "1520342061",
            "status": 0,
            "no": 529
        }
    },
    {
        "ping": {
            "content": "pf:11"
        }
    },
    {
        "ping": {
            "content": "rf:1"
        }
    }
]

status は わかってる範囲では

Success = 0,
Failure = 1,
InvalidThread = 2,
InvalidTicket = 3,
InvalidPostkey = 4,
Locked = 5,
Readonly = 6,
TooLong = 8

3 InvalidTicketの場合はコメントを取得してticketを更新して再度投稿します。
4 InvalidPostkeyの場合は postkey を再取得して再度投稿します。
8 TooLong はコメント本文が長すぎる場合です。コメントの最大文字数は80文字以下です。

いずれの場合でも再度投稿する場合は ping.content で扱う数字もそれだけ加算された値になります。(ping周りについて詳しくは後述の補足をご覧ください。)

コメント投稿後にコメントの差分を取得する

コメント投稿先と同じスレッドIDを使ってコメント取得をしていきます。

ポイントは thread.res_from に対して、コメント投稿時のレスポンスにある chat.no のコメント番号を与える点です。

[
    {
        "ping": {
            "content": "rs:2"
        }
    },
    {
        "ping": {
            "content": "ps:13"
        }
    },
    {
        "thread": {
            "thread": "1522395138",
            "version": "20061206",
            "language": 0,
            "user_id": "53842185",
            "res_from": 427,
            "with_global": 1,
            "scores": 0,
            "nicoru": 0,
            "userkey": "1522485008.~1~gp81luCVYK1lnKmYggifsbYSZlswOewTMDv3Niat-3k"
        }
    },
    {
        "ping": {
            "content": "pf:13"
        }
    },
    {
        "ping": {
            "content": "rf:2"
        }
    }
]

補足:ping.content の値について

正解は中の人のみぞ知る、と思いますが法則性についてだけ補足します。

rs と rf

各コマンドリストの先頭 rs と後尾 rf に付いています。
これは単純に何回目のコマンド送信なのかという回数でいいのかなと思います。
コメント取得、投稿などコマンド送信ごとにインクリメントしていく形で管理していきます(0 → 1 → 2 → ...)

ps と pf

threadやthread_leavesを囲むように ps と pf が対となって配置されているようです。
コマンドリストに複数のthreadやthread_leavesを並べていく場合は「1ずつ」加算していくようです。

別のコマンドリストを送る場合の先頭 psの値は「コマンドリストのアイテム数」を毎回加算していくことで求めることができるようです。
例えば「ユーザー動画 投コメ無し」だと 先頭のpsは以下のように変化していきます。

タイミング 先頭psの値
初回取得時 0
コメント投稿時 8
コメント投稿後の差分コメント取得時 13

「初回取得時にはping 6個 thread 1個 thread_leaves 1個 と合計8個のコマンドリストのアイテムがある、なので次は8からpsを始めます」ということかと思います。

コメント投稿時も同様に ping 4個 chat 1個 で合計5個あるので、次の差分コメ取得時に 8 + 5 = 13 となっていると考えられます。

このようにコマンドリストをやり取りする際に送ったコマンドのアイテム数を保持しておく変数がコメント送受信処理には必要になると思います。

参考

ニコニコ動画・ニコニコ生放送のコメント取得 備忘録 - まぢぽん製作所
http://blog.livedoor.jp/mgpn/archives/51886270.html