(7日目) Sphinx 拡張を作ってみよう (sphinxcontrib.nicovideo)

昨日の記事ではさまざまな Sphinx 拡張を紹介しました。
その中で YouTube の動画を埋め込む Sphinx 拡張を紹介しましたが、
その中には日本の誇るおもしろ動画サービス、ニコニコ動画用の Sphinx 拡張が見当たりませんでした。

ということで、今日は sphinxcontrib.youtube に対抗して sphinxcontrib.nicovideo を作りながら、
Sphinx 拡張の作り方を紹介したいと思います。

使い方

まずは sphinxcontrib.nicovideo の使い方から紹介します。
sphinxcontrib.nicovideo は easy_install でインストールできます。

$ easy_install sphinxcontrib-nicovideo

次に conf.py で sphinxcontrib-nicovideo モジュールを有効にします。

# sphinxcontrib-nicovideo モジュールを読み込む
extensions += ['sphinxcontrib.nicovideio']

sphinxcontrib-nicovideo モジュールを読み込むと nicovideo ロールと
nicovideo ディレクティブが利用できるようになります。

ニコニコ動画用のロールです。 :nicovideo:`sm14912041` と書くとリンクになります。
こうやって :nicovideo:`リンク <sm14912041>` を作ることができます。

次のように書くと動画を埋め込むことができます。

.. nicovideo:: sm14912041

サムネイル表示もできます。

.. nicovideo:: sm14912041
   :thumb:

ロールの作り方

それでは sphinxcontrib-nicovideo の中身を見ていきましょう。
バージョン 0.1.0 のソースコードここから参照することができます

まずは nicovideo ロールの定義部分です。
ロールの定義はハンドラ関数 nicovideo_role() で実現しています。
nicovideo_role() は 7個の引数を取っていますが、それぞれの詳しい定義については
docutils のリファレンスを参考にしてください。


reST でロールを利用するときは :[ロール名]:`[ロール文字列]` という書き方をしますが、
このロール文字列は 2種類の書き方を持っています。

  • `ID`
  • `リンクタイトル `

このロール文字列の処理は split_explicit_title() というユーティリティ関数が提供されているので、
ここでは、パース結果をもとにリンク(nodes.reference ノード)を生成しています。

def nicovideo_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
    """Role for linking to nicovideo pages."""
    text = utils.unescape(text)
    has_explicit, title, movie_id = split_explicit_title(text)

    try:
        movie = NicoVideo(movie_id)
        if has_explicit == False:
            title = movie.title

        ref = nodes.reference(rawtext, title, refuri=movie.url)
        return [ref], []
    except:
        msg = inliner.reporter.error('fail to load nicovideo: %s' % movie_id,
                                     line=lineno)
        prb = inliner.problematic(rawtext, rawtext, msg)
        return [prb], [msg]


最後に作成したハンドラ関数を setup() で nicovideo というロール名で登録します。

def setup(app):
    app.add_role('nicovideo', nicovideo_role)

ここまでが nicovideo ロールの定義です。

ディレクティブの作り方

次に nicovideo ディレクティブを読み解いていきましょう。
ディレクティブはロールと比べると構造が複雑になっています。

Sphinx は docutils というライブラリをベースにしており、

  • reST 文書をノードに変換
  • 出力の際にノードを実形式に変換

という二段階の処理から構成されています。*1

では、まずソースコードのうち「reST 文書をノードに変換」に関わる部分から見ていきます。
まず、nicovideo ノードを定義しています。
この nicovideo ノードは reST 文書の「nicovideo ディレクティブ」を表現するためのノードです。

class nicovideo(nodes.General, nodes.Element):
    pass

次にディレクティブの定義をしています。
NicoVideoDirective クラスは「nicovideo ディレクティブ」を読み込み、
nicovideo ノードに変換するためのクラスです。

クラス変数としてディレクティブを定義(引数を取るか、どのようなオプションを取るか等)しています。
NicoVideoDirective クラスでは

  • 引数を必須にする(required_argument = 1)
  • オプション引数を有効にする(optional_arguments = 1)
  • パラメータを取らないオプション引数 thumb を定義 (option_spec)

という定義をしています。

そして、run() メソッドで reST での定義を nicovideo ノードに変換します。
ここでは引数と thumb オプションの有無をパラメータとして nicovideo ノートを生成しています。

class NicoVideoDirective(Directive):
    """Directive for embedding nico-videos"""

    has_content = False
    required_arguments = 1
    optional_arguments = 1
    final_argument_whitespace = True
    option_spec = {
        'thumb': directives.flag,
    }

    def run(self):
        node = nicovideo(movie_id=self.arguments[0], thumb=('thumb' in self.options))
        return [node]

次に「出力の際にノードを実形式に変換」するためのハンドラを用意します。
visit_nicovideo_node() は指定された動画 ID を元に HTML タグを生成しています。

def visit_nicovideo_node(self, node):
    movie = NicoVideo(node['movie_id'])

    try:
        if node['thumb']:
            # embed movie as thumbnail
            attrs = dict(width=312, height=176, src=movie.thumb_url,
                         scrolling='no', style='border:solid 1px #CCC;',
                         frameborder='0')
            self.body.append(self.starttag(node, 'iframe', **attrs))
            self.body.append(self.starttag(node, 'noscript'))
            self.body.append(self.starttag(node, 'a', href=movie.url))
            self.body.append(movie.title)
            self.body.append('</a></noscript></iframe>')
        else:
            # embed movie using player
            attrs = dict(type='text/javascript', src=movie.thumbjs_url)
            self.body.append(self.starttag(node, 'script', **attrs))
            self.body.append('</script>')
            self.body.append(self.starttag(node, 'noscript'))
            self.body.append(self.starttag(node, 'a', href=movie.url))
            self.body.append(movie.title)
            self.body.append('</a></noscript>')
    except:
        self.builder.warn('fail to load nicovideo: %r' % node['movie_id'])
        raise nodes.SkipNode


def depart_nicovideo_node(self, node):
    pass

最後に setup() 関数でノードとハンドラ、ディレクティブを登録します。

def setup(app):
    app.add_node(nicovideo, html=(visit_nicovideo_node, depart_nicovideo_node))
    app.add_directive('nicovideo', NicoVideoDirective)

ここまでが nicovideo ディレクティブの定義です。

Sphinx 拡張は Sphinx と docutils のルールが把握できればかなりシンプルに作ることができます。
実際、この sphinxcontrib-nicovideo もおおよそ 1時間ぐらいで書き上げています。
sphinx-contrib リポジトリには各種 Sphinx 拡張が登録されているので、
Sphinx 拡張を作るのに興味がある人は一度読んでみることをおすすめします。

*1:正確にはもう少し細かいフェーズがあるようですが、ここでは説明を割愛します。