唯物是真 @Scaled_Wurm

プログラミング(主にPython2.7)とか機械学習とか

ニコニコ動画 『スナップショット検索API』 に触ってみた

一人アドベントカレンダーの一日目です(違

10月にニコニコ動画から『スナップショット検索API』というのが出ていたので触ってみました
毎日朝5時の時点のデータのスナップショットから検索できるAPIニコニコ動画のコンテンツを解析する目的で検索/取得する際に利用できますとのことです

ニコニコ動画 『スナップショット検索API』 ガイド
APIhttp://api.search.nicovideo.jp/api/snapshot/に以下のようなJSONをPOSTする方式になっています(各々フィールドの詳細はドキュメントを参照)

{
  "query" : 検索キーワード
  "service" : 検索対象サービスリスト,
  "search" : 検索対象フィールドリスト,
  "join" : 取得対象フィールドリスト,
  "filters" : フィルタ指定リスト(オプション),
  "sort_by" :  並べ替えフィールド名(オプション),
  "order" : 並べ替え順序 "desc" もしくは "asc"(オプション、デフォルト: "desc"),
  "from" : N (数値指定、オプション、デフォルト: 0),
  "size" : M (数値指定、オプション、デフォルト: 10, 最大: 100),
  "issuer" : サービス/アプリケーション名 (最大: 40文字)
}
ニコニコ動画 『スナップショット検索API』 ガイド

1回のリクエストで最大100件までの動画のタイトルや説明文、タグ、投稿日時、再生数、時間などの情報が得られます
投稿日時などでフィルタしたり、最新コメントの時間などでソートしたりもできます
結果は1行1列のJSON複数行という形式で返ってきます(動画ごとに1行ではないので注意)

{"dqnid":"e542b31f-036d-49fb-a0b5-2925b6cb0b6c","type":"stats","values":[{"_rowid":0,"service":"video","total":10660}]}
{"dqnid":"e542b31f-036d-49fb-a0b5-2925b6cb0b6c","endofstream":true,"type":"stats"}
{"dqnid":"e542b31f-036d-49fb-a0b5-2925b6cb0b6c","type":"hits","values":[{"_rowid":0,"cmsid":"sm16539814","title":"【初音ミク・巡音ルカ】リンちゃんなう!【鏡音生誕祭2011】","view_counter":1983881},{"_rowid":1,"cmsid":"sm2972481","title":"【初音ミク】トルコ行進曲 - オワタ\(^o^)/【アレンジ】","view_counter":1363194},{"_rowid":2,"cmsid":"sm17239967","title":"[初音ミク] paranoia [オリジナル]","view_counter":920589}]}
{"dqnid":"e542b31f-036d-49fb-a0b5-2925b6cb0b6c","endofstream":true,"type":"hits"}
ニコニコ動画 『スナップショット検索API』 ガイド

type==hitsでendofstreamでないJSONのvaluesの部分に検索結果が入っています

ラッパーを作ってみた

ちょっとわかりづらいのでPythonで簡単なラッパーを作ってみました
たまにAPIが失敗して、503が返ってくるのでリトライもできるようにしています

HTTPリクエストの部分にRequestsというライブラリを使用しています(Python標準のものより使いやすいです)

入れていなければ、以下のコードを使う前にpip install requestsしてください

# -*- coding: utf-8 -*-

import requests
import json
import time
import collections

class NiconicoSnapshotAPIWrapper(object):
    endpoint = 'http://api.search.nicovideo.jp/api/snapshot/'
    endpoint_last_modified = 'http://api.search.nicovideo.jp/api/snapshot/version'
    
    Result = collections.namedtuple('NiconicoSnapshotAPIResult', 'total hits errid')
    default_result = Result(None, None, None)
    
    def __init__(self, issuer, **default_parameter):
        self.default_parameter = {
            'service': ['video'],
            'search': ['title', 'description', 'tags'],
            'join': 'cmsid title description tags start_time thumbnail_url view_counter comment_counter mylist_counter last_res_body length_seconds'.split()
        }
        self.default_parameter.update(default_parameter)
        self.default_parameter['issuer'] = issuer
        
    def getLastModified(self):
        return requests.get(self.endpoint_last_modified).json()['last_modified']
    
    def makeFilterEqual(self, field, value):
        ret = {}
        ret['type'] = 'equal'
        ret['field'] = field
        ret['value'] = value
        return ret
    
    def makeFilterRange(self, field, start = None, end = None, include_lower = True, include_upper = True):
        ret = {}
        ret['type'] = 'range'
        ret['field'] = field
        if start is not None:
            ret['from'] = start
        if end is not None:
            ret['to'] = end
        ret['include_lower'] = include_lower
        ret['include_upper'] = include_upper
        return ret
    
    def query(self, query, retry = 1, wait = 1, **kwargs):
        parameter = self._makeQueryParameter(query, kwargs)
        for i in xrange(retry):
            r = requests.post(u'http://api.search.nicovideo.jp/api/snapshot/', json=parameter)
            if r.status_code == requests.codes.ok:
                break
            else:
                time.sleep(wait)
        if r.status_code != requests.codes.ok:
            print r
        return self._parseResponse(r.text)
    
    def _makeQueryParameter(self, query, kwargs):
        parameter = self.default_parameter.copy()
        parameter['query'] = query
        parameter.update(kwargs)
        
        return parameter
    
    def _parseResponse(self, response):
        ret = {}
        for i, line in enumerate(response.splitlines()):
            data = json.loads(line)
            if 'endofstream' in data:
                continue
            if 'errid' in data:
                return self.default_result._replace(errid = data['errid'])
            if 'type' not in data:
                return self.default_result._replace(errid = -1)
            if data['type'] == 'stats':
                ret['total'] = data['values'][0]['total']
            if data['type'] == 'hits':
                ret['hits'] = data['values']
        return self.default_result._replace(**ret)

ちなみにRequestsではレスポンスがJSONの場合.json()でJSONに変換したものを得られるのですが、このAPIの場合は1行に1つのJSONが並んだ状態になっているので、複数JSONをデコードしようとしてしまい "ValueError: Extra data: " から始まるエラーが出てうまく変換できません(PythonJSONのライブラリの仕様っぽい)
なので一行ずつJSONに変換しています

使用例

ラッパーのquery関数は、検索ワードを含む動画数(total)、検索に引っかかった動画のJSONをデコードした辞書のlist(hits)、あとはエラーID(errid)があれば返します

# -*- coding: utf-8 -*-

from NiconicoSnapshotAPIWrapper import *

if __name__ == '__main__':
#issuer(アプリ名)を指定してください
    api = NiconicoSnapshotAPIWrapper('NiconicoSnapshotAPIWrapper')#
#単純な使い方(検索結果の内1件だけ取得)
    result = api.query(u'東方', size = 1)
    print u'件数: {}, 結果: {}'.format(result.total, result.hits)
#件数だけ取得
    print api.query(u'東方', size = 0).total
#5回までリトライする(リトライの間には1秒開ける)
    api.query(u'東方', retry = 5, wait = 1, size = 0).total
#パラメータを指定する
    print api.query(u'東方', size = 1, sort_by = 'mylist_counter').hits[0]['last_res_body']
#フィルタのパラメータはJSONを与えないといけない
    print api.query('東方', size = 0, filters=[{'type': 'range', 'field': 'start_time', 'from': '2014-01-01 00:00:00'}])
#フィルタの指定のヘルパー関数
    print api.query('東方', size = 0, filters=[api.makeFilterRange('start_time', '2014-01-01 00:00:00')])
#データの更新日時の取得(AM5:00の時点のスナップショットをその日の何時に切り替えたか)
    print api.getLastModified()

東方キャラの動画数上位を調べてみた

今年投稿された東方キャラの動画数、上位10キャラを調べてみました

# -*- coding: utf-8 -*-

from NiconicoSnapshotAPIWrapper import *

if __name__ == '__main__':
    api = NiconicoSnapshotAPIWrapper('NiconicoSnapshotAPIWrapper')#

    count = collections.Counter()

    data = u"""博麗霊夢
    霧雨魔理沙
    ルーミア
    大妖精
    チルノ
    紅美鈴
    小悪魔
    パチュリー・ノーレッジ
    十六夜咲夜
    レミリア・スカーレット
    フランドール・スカーレット
    レティ・ホワイトロック

    アリス・マーガトロイド
    リリーホワイト
    ルナサ・プリズムリバー
    メルラン・プリズムリバー
    リリカ・プリズムリバー
    魂魄妖夢
    西行寺幽々子
    八雲藍
    八雲紫
    伊吹萃香
    リグル・ナイトバグ
    ミスティア・ローレライ
    上白沢慧音
    因幡てゐ
    鈴仙・優曇華院・イナバ
    八意永琳
    蓬莱山輝夜
    藤原妹紅
    メディスン・メランコリー
    風見幽香
    小野塚小町
    四季映姫・ヤマザナドゥ
    射命丸文
    秋静葉
    秋穣子
    鍵山雛
    河城にとり
    犬走椛
    東風谷早苗
    八坂神奈子
    洩矢諏訪子
    永江衣玖
    比那名居天子
    キスメ
    黒谷ヤマメ
    水橋パルスィ
    星熊勇儀
    古明地さとり
    火焔猫燐
    霊烏路空
    古明地こいし
    ナズーリン
    多々良小傘
    雲居一輪
    雲山
    村紗水蜜
    寅丸星
    聖白蓮
    封獣ぬえ
    姫海棠はたて
    幽谷響子
    宮古芳香
    霍青娥
    蘇我屠自古
    物部布都
    豊聡耳神子
    二ッ岩マミゾウ
    秦こころ
    わかさぎ姫
    赤蛮奇
    今泉影狼
    九十九弁々
    九十九八橋
    鬼人正邪
    少名針妙丸
    堀川雷鼓
    綿月豊姫
    綿月依姫
    レイセン
    サニーミルク
    ルナチャイルド
    スターサファイア
    茨木華扇
    本居小鈴
    森近霖之助
    稗田阿求
    宇佐見蓮子
    マエリベリー・ハーン""".split()
    for word in data:
        time.sleep(1)
        count[word] = api.query(word, retry = 3, size = 0, filters=[api.makeFilterRange('start_time', '2014-01-01 00:00:00', '2014-12-31 23:59:59')]).total
    
    for word, freq in count.most_common(10):
        print word, freq

チルノが妙に多くなりましたが、原因はよくわかりません
実はキャラ名だけの検索には問題があって、その検索ワードを含むか見ているだけなので、特に「橙」は東方と関係ない動画が多いはずです

順位 キャラ名 動画数
1 チルノ 3403
2 博麗霊夢 1392
3 1236
4 小悪魔 1087
5 十六夜咲夜 1008
6 霧雨魔理沙 975
7 ルーミア 911
8 レミリア・スカーレット 749
9 フランドール・スカーレット 682
10 東風谷早苗 602

「東方」タグとキャラ名のタグを両方含む動画の件数を調べてみると、チルノや橙の数が少なくなりました
東方メインじゃない動画にチルノはたくさん出ているのか、何か被っている単語があるのかなんなんでしょう……?(ニコニコ動画に関するドメイン知識が足りない

順位 キャラ名 動画数
1 博麗霊夢 1190
2 チルノ 1157
3 十六夜咲夜 881
4 霧雨魔理沙 857
5 ルーミア 717
6 レミリア・スカーレット 698
7 フランドール・スカーレット 584
8 東風谷早苗 549
9 八雲紫 544
10 小悪魔 536

まとめ

ニコニコ動画 『スナップショット検索API』という面白そうなものが出ていたので触ってみた。
動画数だけ見てると目当てのものでないものが引っかかってることがあるので、検索ワードの選び方には注意。

……pixivあたりも早くAPI出してくれないですかねー?