ニコニコ実況SDKがリリースされたそうです。
実は、以前にニコニコ実況のコメントを取得して表示するプログラムを書いたのですが、これでおそらくお蔵入りになると思います。SDKをダウンロードしておらず、中身は詳しく知りませんがおそらくコメントの取得も可能でしょう。そのため、せめてここに記録を残しておこうと思います。
さかのぼること3–4ヶ月前、高専カンファ in 長野へとりあえず発表申込みしていたところ、ニコニコ実況が発表されました。とうとう出てきたかという思いましたが、残念な点はコメント表示とテレビ映像の表示は別ウィンドウ・別アプリケーションという仕様、これを一体化させたものを作って発表に持ち込もうと考えました。改造元はTVTestです。当時の最新版の0.6.4を使っていたと思います。
ニコニコ実況でコメントを取得するには次の手順が必要でした。
- ログイン
- コメントサーバの情報を取得
- コメントを取得
ググっていて、どこかでニコ生と同じという話を見たので、主にニコ生についてググってそれに従いました。
異なる点は、動画ID(ニコニコ動画で言うところのsmXXXX)に相当するところです。
以下、コードをぺたぺた貼っていきます。2週間ほどの突貫工事で作ったため非常に汚い残念なソースコードになっています。それをそのまま載せていてごめんなさい。
CCritSec MsgQueueLock;
std::queue<std::wstring> MsgQueue;
DWORD WINAPI CommentReceivingThreadEntry(void*)
{
HINTERNET hinet = InternetOpen(TEXT("NicoChanJikkyo"),
INTERNET_OPEN_TYPE_PRECONFIG, NULL, NULL, 0);
if (!hinet)
return 1;
tstring s = Login(hinet);
CommentServerInfo info = GetCommentServerInfo(hinet, s);
InternetCloseHandle(hinet);
MsgQueueは取得したコメントを放り込むキュー(描画するスレッドから読み出す)、MsgQueueLockはその排他制御用のクリティカルセクションです。CCritSecはDirectShowのBaseClassesライブラリで定義されていたと思います。今だったら、Intel TBBでも使って排他制御を別途行う必要のないスレッドセーフなキューを使うでしょう。
このCommentReceivingThreadEntry関数は、WinMain関数でCreateThreadされることで実行されます。ここに載せた部分はログイン(Login関数)して、コメントサーバの情報を取得(GetCommentServerInfo関数)するところです。それぞれの関数の詳細はこの後に示します。ところで、User Agentの指定のNicoChanJikkyo、仮称です。それにしてもInternetCloseHandleで手動で閉じているのがまず信じられないです。
この関数の続きを載せる前に、LoginとGetCommentServerInfoの2つの関数の中身を載せていきます。
tstring Login(HINTERNET hinet)
{
typedef boost::xpressive::basic_regex<PCTSTR> tcregex;
typedef boost::xpressive::match_results<PCTSTR> tcmatch;
HINTERNET hinConnect = InternetConnect(hinet, TEXT("secure.nicovideo.jp"),
INTERNET_DEFAULT_HTTPS_PORT, NULL, NULL, INTERNET_SERVICE_HTTP, 0, 0);
static PCTSTR acceptType[] = {TEXT("*/*"), NULL};
HINTERNET hinReq = HttpOpenRequest(hinConnect, TEXT("POST"),
TEXT("secure/login?site=niconico"), NULL, NULL, acceptType,
INTERNET_FLAG_SECURE | INTERNET_FLAG_NO_COOKIES | INTERNET_FLAG_NO_AUTO_REDIRECT, 0);
static const TCHAR ContentType[] =
TEXT("Content-Type: application/x-www-form-urlencoded");
BOOL ret = HttpAddRequestHeaders(hinReq, ContentType, ARRAYSIZE(ContentType) - 1,
HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE);
string sendData = GetSendData("ユーザ名", "パスワード");
tstring contentLength = boost::str(boost::basic_format<TCHAR>(
TEXT("Content-Length: %1%")) % sendData.length());
ret = HttpAddRequestHeaders(hinReq, contentLength.c_str(), contentLength.length(),
HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE);
ret = HttpSendRequest(hinReq, NULL, 0, &sendData[0], sendData.length());
TCHAR buf[4096];
DWORD index = 0;
static const tcregex r = tcregex::compile(TEXT("user_session=(user_session\w+);.*"));
do
{
DWORD bufSize = ARRAYSIZE(buf);
ret = HttpQueryInfo(hinReq,
HTTP_QUERY_SET_COOKIE, buf, &bufSize, &index);
const TCHAR userSession[] = TEXT("user_session");
tcmatch m;
if (regex_match(buf, buf + bufSize, m, r))
{
return m.str(1);
}
} while (index != ERROR_HTTP_HEADER_NOT_FOUND && ret != FALSE);
return tstring();
}
何よりも戻り値を殆ど無視しているのが大変いけないコードとなっています。それはさておき、ログインするにはhttps://secure.nicovideo.jp/secure/login?site=niconicoに「mail=メールアドレス&password=パスワード」という文字列をPOSTします。そして、レスポンス中のCookieからuser_sessionを探し出します。その値がログインの証であり、この関数の戻り値です。user_sessionの抽出も本当はSpiritで綺麗にやりたいなあと思いつつ、Xpressive(しかも静的ですらない)で適当にやっつけています。ここではやっていませんが、user_sessionの有効期限も検出して保存する仕様にするのがよいと思います。
CommentServerInfo GetCommentServerInfo(HINTERNET hinet, tstring const& session)
{
using namespace boost::xpressive;
tstring cookie = TEXT("Cookie: ") + session;
HINTERNET hinetFile = InternetOpenUrl(hinet,
TEXT("http://jk.nicovideo.jp/api/getflv?v=jk1"), cookie.c_str(),
boost::numeric_cast(cookie.length()), INTERNET_FLAG_NO_COOKIES, 0);
char buf[16384];
DWORD read;
BOOL ret = InternetReadFile(hinetFile, buf, sizeof buf, &read);
CommentServerInfo info;
cmatch m;
static const cregex messageServerExp = cregex::compile(”ms=((\w|\.)+)”);
if (regex_search(buf, buf + read, m, messageServerExp))
{
info.MessageServer = m.str(1);
}
static const cregex messageServerPortExp = cregex::compile(”ms_port=(\d*)”);
if (regex_search(buf, buf + read, m, messageServerPortExp))
{
info.MessageServerPort = m.str(1);
}
static const cregex threadIdExp = cregex::compile(”thread_id=(\d*)”);
if (regex_search(buf, buf + read, m, threadIdExp))
{
info.ThreadId = m.str(1);
}
InternetCloseHandle(hinetFile);
return info;
}
http://jk.nicovideo.jp/api/getflv?v=jk1にアクセスするとNHK総合についての情報が返ってきます。最後の数字はチャンネル番号に対応して、jk1, jk2, jk4–jk9があります。得られる情報のうち、コメントサーバへの接続に必要なサーバ名・ポート番号・スレッドIDの3つだけさくっと探してこの関数の戻り値としています(ここもSpirit使いたかった……)。
では、CommentReceivingThreadEntry(void*)の続きです。
using namespace boost::asio;
using ip::tcp;
io_service ios;
tcp::resolver resolver(ios);
tcp::resolver::query query(
info.MessageServer.c_str(), info.MessageServerPort.c_str());
boost::system::error_code error = error::host_not_found;
tcp::socket socket(ios);
tcp::resolver::iterator endpoint_iterator = resolver.resolve(query);
tcp::resolver::iterator end;
while (endpoint_iterator != end)
{
socket.close();
socket.connect(*endpoint_iterator, error);
if (!error)
break;
endpoint_iterator++;
}
if (error)
return 1;
int lastComment = -10;
string req = boost::str(boost::format("<thread res_from=\"%1%\" version=\"20061206\" thread=\"%2%\" />") % lastComment % info.ThreadId);
socket.send(const_buffers_1((const void*)req.c_str(), req.length() + 1));
std::stringstream tmp;
streambuf buf;
for (;;)
{
tmp.str("");
std::size_t read;
read = read_until(socket, buf, ' ', error);
if (error)
break;
std::istream is(&buf);
for (int i = 0; i < read; ++i)
{
char c;
is.get(c);
if (c == ' ')
break;
tmp.put(c);
}
OutputDebugStringA(tmp.str().c_str());
OutputDebugStringA("rn");
tmp.seekg(0, std::ios_base::beg);
using boost::property_tree::ptree;
ptree pt;
read_xml(tmp, pt);
if (boost::optional<string> msg = pt.get_optional<std::string>("chat"))
{
std::vector<wchar_t> buf(msg->size());
int len = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
msg->data(), msg->size(), &buf[0], buf.size());
if (len > 0)
{
std::wstring wmsg(&buf[0], len);
CAutoLock lock(&MsgQueueLock);
MsgQueue.push(wmsg);
}
}
}
急にBoost.Asioになりました。コメントサーバとのやりとりにHTTPを使わずTCP/IP上で直接XMLのやり取りとなるためです。さっき取得したサーバ・ポート番号へ接続して、変数reqのようなXMLを送りつけるとコメントが取得できるようになります。lastComment = -10で最新10件をいますぐ取得する意味になりますが、ソケットを切断しない限り、放っておけば新しいコメントも次から次へとやってきます。ここら辺もニコ生と同じだと思います。受信する個々のコメントの情報は、ナル文字(’\0′)区切りのXMLになっています。それをBoost.PropertyTreeに放り込んでコメント本文(chat要素)を取得(get_optinalメンバ関数)。それをUTF-8からUTF-16LEへ変換し、MsgQueueに送り込んでいます。最初に書いたように、これは画面描画を担当する別のスレッドで読み取られます。それは次の記事に分けます。
TVTestはGPLを採用していますので、次の記事あたりで差分まとめてzipにします。
2010年3月4日追記: コメントサーバへの接続後、reqのようなXMLを送信すると書いてありますが、コメントサーバとのやり取りは受信データ同様、送信データでもナル文字を最後に付けることが必要です。上記ソースコードではそのことが明確ではないと思い、ここに追記します。あのコードでは、c_str()がナル文字終端の文字配列(の先頭要素を指すポインタ)を指すことを利用し、そのナル文字の部分まで含めて送信するようになっています。const_buffers_1((const void*)req.c_str(), req.length() + 1)ではそのために+1しているわけです。こういうことはきちんとソース中にコメントとして残しましょう( to 自分)。