Google Docs APIを使って、索引を作成する

はじめまして、記事を読んでいただいてありがとうございます。
この記事は『[LITALICO Engineers Advent Calendar 2021](https://qiita.com/advent-calendar/2021/litalico)』の4日目の記事です。
私は、2021年4月にLITALICOに入社をしまして、ドメイン知識が０の状態から、周りの方に助けていただいて、どうにかこうにかプロダクト開発に携わっています。

そんな私としまして、ドメイン知識について、効率的にinputを行いたいと常日頃考えております。
誤解がないように記載しておきますが、周囲からフォローがないという訳ではありません。むしろ、丁寧に教えてくださるのですが、誰かに教わるということは、その方の分のコストも使わせていただくわけで、自分一人で調べて、ちゃんと理解できるなら、コスト効率高いよねと考えた次第です。
（<del>職場へのフォローを忘れない組織人の鏡</del>）

他にも色々方法を考えたのですが、まずは、大量にあるドキュメントから、自分が必要としている情報を探すをゴールに、Google Drive上の索引を作成したいと思い至った次第です。

# 事前準備

- APIが有効になっているGoogleCloudPlatformプロジェクト
    - 今回は、Google Drive APIと、Google Docs APIを使用するので、2つとも有効にしてください
    - 設定の仕方は、ちょこちょこ、GoogleCloudPlatformのUI変わったりするので、[公式のプロジェクトの設定の仕方](https://developers.google.com/workspace/guides/create-project)を確認してくださいませ
- Google APIを利用する際の認証情報
    - こちらも[公式の認証情報の設定の仕方](https://developers.google.com/workspace/guides/create-credentials)を参照してくださいませ
    - また、この認証情報は他の方に教えてはいけません
- わかち書きエンジン
    - この記事では、Mecabを使用しております
- プログラム言語の実行環境
    - この記事では、Python 3系を使用しております
    - [公式](https://developers.google.com/drive/api/v3/search-files#python)の実装サンプルからちょこちょこ変えてるだけなので、nodeでも、Javaでもお好きなものを

# google-authを使用して OAuth 2.0 の認証を通す

```python
import os.path
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials

SCOPES = [
  'https://www.googleapis.com/auth/drive.metadata.readonly',
  'https://www.googleapis.com/auth/documents.readonly'
]

CLIENT_SECRETS_FILE = 'credentials.json'
TOKEN_JSON_FILE = 'token.json'

def get_credentials():
  credentials = None

  if os.path.exists(TOKEN_JSON_FILE):
    credentials = Credentials.from_authorized_user_file(TOKEN_JSON_FILE, SCOPES)

  if not credentials or not credentials.valid:
    if credentials and credentials.expired and credentials.refresh_token:
      credentials.refresh(Request())
    else:
      flow = InstalledAppFlow.from_client_secrets_file(
        CLIENT_SECRETS_FILE, SCOPES)
      credentials = flow.run_local_server(port=0)

    with open(TOKEN_JSON_FILE, 'w') as token:
      token.write(credentials.to_json())

  return credentials
```

## 簡単な解説

```python
SCOPES = [
  'https://www.googleapis.com/auth/drive.metadata.readonly',
  'https://www.googleapis.com/auth/documents.readonly'
]
```

使用するスコープのリストを定義しておきます。
事前準備で用意していただいた認証情報のスコープに、上記で定義したものを含めるようにしてくださいませ。

```python
CLIENT_SECRETS_FILE = 'credentials.json'
TOKEN_JSON_FILE = 'token.json'
```

事前準備で用意していただいた認証情報は、CLIENT_SECRETS_FILEでパスを指定。
TOKEN_JSON_FILEは、2回目以降のために、認証トークンを保持します。

```python
  if os.path.exists(TOKEN_JSON_FILE):
    credentials = Credentials.from_authorized_user_file(TOKEN_JSON_FILE, SCOPES)
```

TOKEN_JSON_FILEが存在する場合に、TOKEN_JSON_FILEから過去に通った認証を取得します

```python
  if not credentials or not credentials.valid:
```

過去に通った認証が有効ならば、そのまま、認証を返します。

以下、認証が無効な場合

```python
    if credentials and credentials.expired and credentials.refresh_token:
      credentials.refresh(Request())
```

認証は無効だけど、有効期限切れ、かつ、リフレッシュトークンがある場合、認証をリフレッシュします

```python
    else:
      flow = InstalledAppFlow.from_client_secrets_file(
        CLIENT_SECRETS_FILE, SCOPES)
      credentials = flow.run_local_server(port=0)
```

そもそも、認証通ってない場合、最初に認証通すとき、
認証情報を元に、承認フローのインスタンスを生成して、ローカルサーバから承認フローを実行します

一連の処理は、以下、importしているGoogleのライブラリーを見ると、処理がわかりやすいと思います。

https://github.com/googleapis/google-auth-library-python
https://github.com/googleapis/google-auth-library-python-oauthlib

```python
    with open(TOKEN_JSON_FILE, 'w') as token:
      token.write(credentials.to_json())
```

再利用するために、有効な認証を、TOKEN_JSON_FILEに保存します。

# Drive APIで、Docsファイルを取得する

```python
import os.environ
import pymysql.cursors
from googleapiclient.discovery import build

DRIVE_API = { 'service_name': 'drive', 'version': 'v3' }

connection = pymysql.connect(host = os.environ['DB_HOST'],
                             user = os.environ['DB_USER'],
                             password = os.environ['DB_PASSWORD'],
                             db = os.environ['DB_NAME'],
                             charset = os.environ['DB_CHARSET'])

INSERT_SQL = "INSERT INTO test.docs (document_id, name) values (%s,%s)"

...

def get_docs():
  creds = get_credentials()

  drive_service = build(DRIVE_API.service_name, DRIVE_API.version, credentials=creds)

  while True:
    values = []

    response = drive_service.files().list(q="mimeType='application/vnd.google-apps.document'",
                                          spaces='drive',
                                          fields='nextPageToken, files(id, name)',
                                          pageToken=page_token).execute()

    for file in response.get('files', []):
      values.append([file.get('id'), file.get('name')])

    cursor = connection.cursor()
    cursor.executemany(INSERT_SQL, values)

    page_token = response.get('nextPageToken', None)
    if page_token is None:
        break
```

## 簡単な解説

```python
  drive_service = build(DRIVE_API.service_name, DRIVE_API.version, credentials=creds)
```

認証を元に、APIと対話するためのResourceオブジェクトを作成します。
ここも、以下、importしているGoogleのライブラリーを見ると、処理がわかりやすいと思います。

[https://github.com/googleapis/google-api-python-client](https://github.com/googleapis/google-api-python-client)

```python
    response = drive_service.files().list(q="mimeType='application/vnd.google-apps.document'",
                                          spaces='drive',
                                          fields='nextPageToken, files(id, name)',
                                          pageToken=page_token).execute()
```

Drive APIを用いて、必要な情報を取得します。
今回は、Docsファイルだけ取得したいので、mimeTypeが、'application/vnd.google-apps.document'のファイルの一覧を取得。Spreadsheetの場合は、mimeTypeが、'application/vnd.google-apps.spreadsheet'。
公式だと、[このページ](https://developers.google.com/drive/api/v3/mime-types)にGoogle特有のmimeTypeがまとめてあります。

```python
    page_token = response.get('nextPageToken', None)
    if page_token is None:
        break
```

nextPageTokenが存在する場合、一覧に次ページが存在するのでループして、次ページについて、処理。
nextPageTokenがNoneの場合、最後のページまで処理が終わったので、break。

# Drive APIから取得したdocumentIDで、Docs APIでドキュメントの本文取得

```python
DOCS_API = { 'service_name': 'docs', 'version': 'v1' }

...

def get_doc_content_lines(document_id):
  creds = get_credentials()

  doc_service = build(DOCS_API.service_name, DOCS_API.version, credentials=creds)

  document = doc_service.documents().get(documentId=document_id).execute()

  return document.get('body')
```

## 簡単な解説

```python
  doc_service = build(DOCS_API.service_name, DOCS_API.version, credentials=creds)
```

Drive APIと同じように、認証を元に、APIと対話するためのResourceオブジェクトを作成します

```python
  document = doc_service.documents().get(documentId=document_id).execute()

  return document.get('body')
```

Docs APIでドキュメントのデータ取得。
現在のAPI仕様だと、page数などは取得できないので、本文のみ取得する

# Mecabで、文章中の名詞のみ取得

```python
import MeCab

tagger = MeCab.Tagger()
tagger.parse('')

...

def extract_nouns(lines, docs_id):
  nouns = {}

  insert_sql = "INSERT INTO test.nonus (docs_id, word, count) values (" + docs_id + ", %s, %d)"

  for line in lines.splitlines():
    for chunk in tagger.parse(line).splitlines()[:-1]:
      (surface, feature) = chunk.split('\t')
        if feature.startswith('名詞'):
          nouns[surface] = nouns.get(surface, 0) + 1

  cursor = connection.cursor()
  cursor.executemany(insert_sql, nouns.items())
```

## 簡単な解説

用途にもよるのですが、今回は、索引なので、名詞だけ抽出しております。
pythonから辞書への追加ができないのが残念ですが、ドメインのワードを辞書追加すると、よりハッピーになれます。（pythonのライブラリにないだけで、頑張ればできそうな気もします。）

```python
    for chunk in tagger.parse(line).splitlines()[:-1]:
      (surface, feature) = chunk.split('\t')
        if feature.startswith('名詞'):
          nouns[surface] = nouns.get(surface, 0) + 1
```

line = '私はLITALICO のシステムエンジニア です。'　という文章に対して、mecab.parseの実行結果は、下記のようになります。

```
私	名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
LITALICO	名詞,固有名詞,組織,*,*,*,*
の	助詞,連体化,*,*,*,*,の,ノ,ノ
システムエンジニア	名詞,一般,*,*,*,*,システムエンジニア,システムエンジニア,システムエンジニア
です	助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
。      記号,句点,*,*,*,*,。,。,。
```

タブ区切りの後段に品詞の情報が出るので、それを元に、名詞か、それ以外か判定しております。
索引を作る際に、一般名詞は無視しても良い場合も多いのですが、辞書に存在しない語、辞書未登録語の場合、一般名詞として処理されます。そのため、一般名詞を除外すると、辞書未登録語を除外する可能性があるため、注意が必要です。

例）　私は、キャラメルフラペチーノが大好きです、キャラメルフラペチーノが辞書未登録語として、一般名詞となる

```
私	名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
キャラメルフラペチーノ	名詞,一般,*,*,*,*,*
が	助詞,格助詞,一般,*,*,*,が,ガ,ガ
大好き	名詞,形容動詞語幹,*,*,*,*,大好き,ダイスキ,ダイスキ
です	助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
```

キャラメルフラペチーノが一般的ではないという意味ではありません。固有名詞のはずなのに、Mecabの辞書に載っていないから、カテゴリー全体をさす単語ではないのに、一般名詞になるのはおかしいよねという意味です。キャラメルフラペチーノは人類史上最も偉大な発明の一つです。そのうち、一般名詞になるかもしれませんね。

# 終わりに

実は、Google Docsの検索機能は、かなり良くて、単に、キーワードが含まれている資料を探すだけだと、Google Docsの検索機能を使った方が早いです。それをわかっていながら、わざわざ、索引を作ろうとしたのは、ゆくゆく文章解析をして、この文章が仕様書であるとか、議事録であるとか、そういったこともやりたいと考えているのと、うまくユビキタス言語が拾えないかなと思ったからです。また、slackや、Backlogなど、他のツールも合わせて、索引をまとめることもできそうですよね。

## 予告

来年は、心理学とベイズ統計のネタか、対話型インターフェースのネタを書きたいです。