Graphviz in your browserであるViz.jsを用いて構文解析結果のグラフをブラウザで簡単に見えるようにしてみた

# やったこと
「[COTOHA API:構文解析の出力結果をグラフ化してみた](https://qiita.com/yukihon_lab/items/13b0514af13b348d564f "Qiita記事へのリンクです")」で作成されるグラフを[Viz.js](http://viz-js.com/ "Viz.jsページへのリンクです")を用いてブラウザで見られるようにしました。

# 環境
* macOS 10.14.2
* Python 3.6.5 :: Anaconda, Inc.
* Chrome 72.0.3626.109（Official Build） （64 ビット）

# 動作の様子
![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.amazonaws.com/0/361903/787e6d26-7020-0d2a-fe2a-ae1114f55ebc.gif)

## ソースコード
<details><summary>ディレクトリ構成（クリックで展開）</summary><div>

```
.
├── config.ini
├── cotoha_api.py
├── graph.py
├── cotoha_graph_api.py
├── index.html
├── css
│   └── bootstrap.min.css
└── js
    ├── bootstrap.bundle.min.js
    ├── jquery-3.3.1.min.js
    ├── full.render.js
    └── viz.js
```
</div></details><details><summary>cotoha.ini（クリックで展開）</summary><div>

```cotoha.ini
[COTOHA API]
Developer API Base URL: https://api.ce-cotoha.com/api/dev/nlp/
Developer Client id: AAAAAAAAAAAAAAAAAAAAAAAAAA
Developer Client secret: aaaaaaaaaaaaaaaaaaaaaaaaaa
Access Token Publish URL: https://api.ce-cotoha.com/v1/oauth/accesstokens
```

「[自然言語処理を簡単に扱えると噂のCOTOHA APIをPythonで使ってみた](https://qiita.com/gossy5454/items/83072418fb0c5f3e269f "Qiita記事へのリンクです")」の「設定ファイル」です。
[COTOHA API](https://api.ce-cotoha.com/contents/ "COTOHA API公式ページへのリンクです")のアカウントを取得して、[アカウントホーム](https://api.ce-cotoha.com/home "COTOHA API アカウントホームページへのリンクです")に表示される`Developer Client id`、`Developer Client secret`を記入して下さい。

</div></details><details><summary>cotoha_api.py（クリックで展開）</summary><div>

```python:cotoha_api.py
# -*- coding:utf-8 -*-

import os
import urllib.request
import json
import configparser
import codecs


# COTOHA API操作用クラス
class CotohaApi:
    # 初期化
    def __init__(self, client_id, client_secret, developer_api_base_url, access_token_publish_url):
        self.client_id = client_id
        self.client_secret = client_secret
        self.developer_api_base_url = developer_api_base_url
        self.access_token_publish_url = access_token_publish_url
        self.getAccessToken()

    # アクセストークン取得
    def getAccessToken(self):
        # アクセストークン取得URL指定
        url = self.access_token_publish_url

        # ヘッダ指定
        headers={
            "Content-Type": "application/json;charset=UTF-8"
        }

        # リクエストボディ指定
        data = {
            "grantType": "client_credentials",
            "clientId": self.client_id,
            "clientSecret": self.client_secret
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()

        # リクエスト生成
        req = urllib.request.Request(url, data, headers)

        # リクエストを送信し、レスポンスを受信
        res = urllib.request.urlopen(req)

        # レスポンスボディ取得
        res_body = res.read()

        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)

        # レスポンスボディからアクセストークンを取得
        self.access_token = res_body["access_token"]


    # 構文解析API
    def parse(self, sentence):
        # 構文解析API URL指定
        url = self.developer_api_base_url + "v1/parse"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "sentence": sentence
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                raise e

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body


    # 固有表現抽出API
    def ne(self, sentence):
        # 固有表現抽出API URL指定
        url = self.developer_api_base_url + "v1/ne"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "sentence": sentence
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                print ("<Error> " + e.reason)

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body


    # 照応解析API
    def coreference(self, document):
        # 照応解析API 取得URL指定
        url = self.developer_api_base_url + "beta/coreference"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "document": document
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                print ("<Error> " + e.reason)

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body


    # キーワード抽出API
    def keyword(self, document):
        # キーワード抽出API URL指定
        url = self.developer_api_base_url + "v1/keyword"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "document": document
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                print ("<Error> " + e.reason)

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body


    # 類似度算出API
    def similarity(self, s1, s2):
        # 類似度算出API URL指定
        url = self.developer_api_base_url + "v1/similarity"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "s1": s1,
            "s2": s2
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                print ("<Error> " + e.reason)

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body


    # 文タイプ判定API
    def sentenceType(self, sentence):
        # 文タイプ判定API URL指定
        url = self.developer_api_base_url + "v1/sentence_type"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "sentence": sentence
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                print ("<Error> " + e.reason)

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body


    # ユーザ属性推定API
    def userAttribute(self, document):
        # ユーザ属性推定API URL指定
        url = self.developer_api_base_url + "beta/user_attribute"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "document": document
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                print ("<Error> " + e.reason)

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body



if __name__ == '__main__':
    # ソースファイルの場所取得
    APP_ROOT = os.path.dirname(os.path.abspath( __file__)) + "/"

    # 設定値取得
    config = configparser.ConfigParser()
    config.read(APP_ROOT + "config.ini")
    CLIENT_ID = config.get("COTOHA API", "Developer Client id")
    CLIENT_SECRET = config.get("COTOHA API", "Developer Client secret")
    DEVELOPER_API_BASE_URL = config.get("COTOHA API", "Developer API Base URL")
    ACCESS_TOKEN_PUBLISH_URL = config.get("COTOHA API", "Access Token Publish URL")

    # COTOHA APIインスタンス生成
    cotoha_api = CotohaApi(CLIENT_ID, CLIENT_SECRET, DEVELOPER_API_BASE_URL, ACCESS_TOKEN_PUBLISH_URL)

    # 解析対象文
    sentence = "吾輩は猫である"

    # 構文解析API実行
    result = cotoha_api.parse(sentence)

    # 出力結果を見やすく整形
    result_formated = json.dumps(result, indent=4, separators=(',', ': '))
    print (codecs.decode(result_formated, 'unicode-escape'))
```

「[自然言語処理を簡単に扱えると噂のCOTOHA APIをPythonで使ってみた](https://qiita.com/gossy5454/items/83072418fb0c5f3e269f "Qiita記事へのリンクです")」の`cotoha_api_python3.py`です。

</div></details><details><summary>graph.py（クリックで展開）</summary><div>

```python:graph.py
from graphviz import Digraph

def graphviz_parse(jsonfile):
    dependencies,chunks = extract_dependency_info(jsonfile)

    # グラフの初期化
    G = Digraph()
    G.attr('node', shape="circle")
    G.attr(rankdir='LR')

    for k in chunks:
      G.node(chunks[k])

    # 係り受け情報よりノードとエッジを追加
    for dependency in dependencies:
        G.edge(chunks[dependency[0]],chunks[dependency[1]],label=dependency[2])

    # グラフ描写
    return G.source.replace("\n", "").replace("\t", " ")


def extract_dependency_info(jsonfile):
    chunkid_text_dict = dict()
    dependency_info = list()

    # 解析結果(json)から係り受け情報を抽出
    for chunk in jsonfile["result"]:
        chunk_id = chunk["chunk_info"]["id"]
        tokens = [token["form"] for token in chunk["tokens"]]
        chunkid_text_dict[chunk_id] = " ".join(tokens)
        for link in chunk["chunk_info"]["links"]:
            dependency_info.append([chunk_id,link["link"],link["label"]])

    return dependency_info,chunkid_text_dict
```

「[COTOHA API:構文解析の出力結果をグラフ化してみた](https://qiita.com/yukihon_lab/items/13b0514af13b348d564f "Qiita記事へのリンクです")」のコードを少し修正しています。

</div></details><div><details><summary>cotoha_graph_api.py（クリックで展開）</summary><div>

```python:cotoha_graph_api.py
import cotoha_api
import graph
from bottle import get, request, static_file, run
from json import dumps


@get('/<filepath:path>')
def root(filepath):
  return static_file(filepath, root=APP_ROOT)


@get('/get_dot')
def get_dot():
  sentence = request.query.sentence

  d = {}
  if (sentence):
    result = api.parse(sentence)
    dot = graph.graphviz_parse(result)
    d['text'] = sentence
    d['dot'] = dot
  else:
    d['text'] = ""
    d['dot'] = ""

  return dumps(d)


# ソースファイルの場所取得
APP_ROOT = cotoha_api.os.path.dirname(cotoha_api.os.path.abspath( __file__)) + "/"

# 設定値取得
config = cotoha_api.configparser.ConfigParser()
config.read(APP_ROOT + "config.ini")
CLIENT_ID = config.get("COTOHA API", "Developer Client id")
CLIENT_SECRET = config.get("COTOHA API", "Developer Client secret")
DEVELOPER_API_BASE_URL = config.get("COTOHA API", "Developer API Base URL")
ACCESS_TOKEN_PUBLISH_URL = config.get("COTOHA API", "Access Token Publish URL")

# COTOHA APIインスタンス生成
api = cotoha_api.CotohaApi(CLIENT_ID, CLIENT_SECRET, DEVELOPER_API_BASE_URL, ACCESS_TOKEN_PUBLISH_URL)

run(host="0.0.0.0", port=int(cotoha_api.os.environ.get("PORT", 8080)))
```
</div></details><details><summary>index.html（クリックで展開）</summary><div>

```html:index.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="./css/bootstrap.min.css">
    <title>構文解析グラフ</title>
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
      <span class="navbar-brand">構文解析グラフ</span>
    </nav>
    <div class="container">
      <form class="my-4">
        <input type="text" class="form-control mb-1" placeholder="文を入力して「解析」を押す">
        <button type="submit" class="btn btn-outline-primary">解析</button>
      </form>
      <div class="my-1" id="text"></div>
      <div id="graph"></div>
    </div>
    <script src="js/viz.js"></script>
    <script src="js/full.render.js"></script>
    <script src="./js/jquery-3.3.1.min.js"></script>
    <script src="./js/bootstrap.bundle.min.js"></script>
    <script>
      var url = './get_dot';
      var viz = new Viz();

      function renderSVG(dot) {
        viz.renderImageElement(dot)
        .then(function(element) {
          $("#graph").html(element);
        });
      };

      function getDot(sentence) {
        $.ajax({
          url: url + '?sentence=' + sentence,
          dataType: 'JSON',
        }).done(function(data){
          if (data.text != "") {
            $("#text").text(data.text);
            renderSVG(data.dot);
            $("input").val("");
          }
        });
      };

      $("button").click(function(){
        getDot($("input").val());
        return false;
      });
    </script>
  </body>
</html>
```
</div></details><details><summary>CSS/JSファイルの入手先（クリックで展開）</summary><div><dl><dt>bootstrap.min.css / bootstrap.bundle.min.js</dt><dd><a href="https://getbootstrap.com/docs/4.3/getting-started/download/">Bootstrapのダウンロードページ</a>から入手</dd>
<dt>jquery-3.3.1.min.js</dt><dd><a href="https://jquery.com/">jQueryのページ</a>から入手</dd>
<dt>viz.js / full.render.js</dt><dd><a href="https://github.com/mdaines/viz.js/releases">Viz.jsのリリースページ</a>から入手</dd></dl></div></details><details><summary>Pythonライブラリ（クリックで展開）</summary><div>

```
pip install bottle
pip install graphviz
```

`bottle`と`graphviz`を使います。</div></details>

# 動かし方
1. ソースコードをおいたディレクトリ（cotoha_api_graph.pyがあるディレクトリ）で`python cotoha_api_graph.py`を実行
1. ブラウザから`http://localhost:8080/index.html`でアクセス

# グラフを縦長にする
`grapy.py`の9行目を以下のように変更するとグラフが縦長になります。
スマホからアクセスする場合は、縦長の方がみやすいです。

```grapy.pyの9行目
    G.attr(rankdir='TB')
```

# そういえば……
[COTOHA APIの公式ページに解析デモ](https://api.ce-cotoha.com/demo?query=%E6%AF%8E%E6%97%A5%E9%81%8A%E3%82%93%E3%81%A7%E6%9A%AE%E3%82%89%E3%81%97%E3%81%9F%E3%81%84 "COTOHA API 解析デモページへのリンクです")あったなあと、ここまで作って気付きました。