pythonでMicrosoft Graphを操作する（OneNoteのページ取得）



# Microsopf GraphはMSサービスのAPI
- [Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer#)

利用できるAPIを色々お試しできます。

- [認証のpythonSDK公式ドキュメント](https://msal-python.readthedocs.io/en/latest/)

利用するパターンをクリックするとgit上のサンプルコードが開かれるようになっています。
パターンによってOauth2.0でのトークン付与方法が異なります。
今回の場合はmacbookからpythonで個人アカウントのAPIを叩きます。
何のアプリも通さずに、pythonから直接APIを叩くのでBrowserless Appになります。

![Screenshot_2022-09-05 14.42.29_2lKLoW.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230192/b2dab7b3-eaf3-3645-d4c4-fbd2ff4a5323.png)

サンプルコードでinitiate_device_flow()を利用していたことからトークン付与の分類としては[devicecode flow](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v2-oauth2-device-code)になるようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230192/214c4e83-e3e8-080e-e4ba-c957bd63ae51.png)

# AzureのGUI操作

GUI操作は先人の知恵に頼るということで（丸投げ）
まずは[こちらの動画の42秒から2分30秒あたりまで](https://www.youtube.com/watch?v=7ywUs54eGBo&t=42s)を参考にのGUI操作を行います。
それ以降はライブコーディングの動画なのでスキップしてもらってOKです。
MicrosoftのアカウントでAzureポータルにログインし、APIのアプリを作成し必要な設定をしていきます。

### 動画中の注意

- 動画中ではMethod #1: CredentialCrilentApplicationという方法とMethod #2: PublicClientApplicationの二つのtoken付与方法が紹介されていますが、今回は#2の方法でtoken付与を行います。
- GUI操作から情報を得られるもののうち、**#2で必要なのはAPPLICATION_ID（CLIENT_IDと同義）とSCOPESの二つのみです。
メモパッドなどに情報をコピペしてきましょう。** CLIENT_SECRETは利用しません。
- APIのアクセス許可（API premissions）の中で必要な権限を許可します。
今回の場合だと、OneNoteの読み取りを行うNotes.Readなどです。

### 動画でカバーされていない操作
- 動画にはありませんでしたが、認証の一番下にあるパブリッククライアントフローを許可するを"はい"にします。
（これをしないとflow実行時にエラーが出ました。）
![Screenshot_2022-09-04 19.52.57_pV87Kj.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230192/1f5e7e67-7171-72b8-b825-a571e82f72ad.png)



# python実行前の準備
- `pip install msal pyperclip`で必要なパッケージのインストール
- GUIからAPP_IDをコピペ
- GUIで選択したSCOPESをList[str]型で書く

``` main.py
APP_ID = 'Your APP_ID'
SCOPES = ['User.Read', 'Notes.Read', 'Notes.ReadWrite', 'Notes.Create']
```


# pythonの実行
[上記のYouTubeの別動画](https://www.youtube.com/watch?v=AjOfAQCZsJU)と[Mediumの記事](https://medium.com/@marian.reha/query-ms-graph-api-in-python-e8e04490b04e)の合わせ技になっています。

``` main.py
import os
from pprint import pprint as pp
import requests
import webbrowser
import msal
import pyperclip

APP_ID = 'Your APP_ID'
SCOPES = ['User.Read', 'Notes.Read', 'Notes.ReadWrite', 'Notes.Create']


def generate_access_token(app_id=APP_ID, scopes=SCOPES):

    # read access_token_cache.json in current dir if exists
    access_token_cache = msal.SerializableTokenCache()
    if os.path.exists('access_token_cache.json'):
        access_token_cache.deserialize(
            open('access_token_cache.json', 'r').read())

    # init auth client
    client = msal.PublicClientApplication(
        client_id=app_id, token_cache=access_token_cache)

    # use access_token_cache.json
    accouts = client.get_accounts()
    if accouts:
        access_token = client.acquire_token_silent(scopes, accouts[0])
        pp('access_token_cache.json is used.')

    # generate access_token
    else:
        flow = client.initiate_device_flow(scopes)
        pyperclip.copy(flow['user_code'])
        print('User code is copied on your clipboard.\n')
        print(flow['user_code'])
        print('\n')

        # open vertification_uri
        # need to authenticate on GUI
        webbrowser.open(flow['verification_uri'])
        access_token = client.acquire_token_by_device_flow(flow)

        # write access_token_cache.json
        with open('access_token_cache.json', 'w') as _f:
            _f.write(access_token_cache.serialize())

    return access_token


def get_pages(token, pagination=True):
    url = 'https://graph.microsoft.com/v1.0/me//onenote/pages'
    headers = {'Authorization': 'Bearer ' + token['access_token']}
    graph_results = []

    # get data while nextLink exists
    while url:
        try:
            response = requests.get(url=url, headers=headers).json()
            graph_results.extend(response['value'])

            if pagination:
                url = response['@odata.nextLink']
            else:
                url = None

        except BaseException as e:
            pp(e)
            pp('Fail to get')
            break

    else:
        pass

    return graph_results


if __name__ == '__main__':
    access_token = generate_access_token()
    res = get_pages(access_token)
    print(len(res))


```

### token発行
- token取得のためのclientで、要求するスコープ（どのアプリで何をするか）を渡してflow認証を開始
``` example.py
client = msal.PublicClientApplication(client_id=app_id, token_cache=access_token_cache)
...
flow = client.initiate_device_flow(scopes)
```
- 認証・認可の画面で最初に求められるuser_codeをクリップボードにコピーする
- ブラウザを自動で起動し、認証・認可の画面を開く
- tokenのcacheをaccess_token_cache.jsonとして保存
- 次回以降は、tokenのcacheが有効であれば再利用する


### OneNoteのページ情報取得
- requests.getでAPIを叩く
- 追加ページがなくなるまで繰り返しgetする

大量のデータがあるものについてはクエリに対して返すオブジェクトの数が限られています。
onenote/pagesでは20個が上限です。
データが続く場合はresponseに'@odata.nextlinkが含まれるのでそのリンクを使って続きからデータを取得します。
その挙動について参考になったのは[こちら](https://qiita.com/kenakamu/items/63a8d5d136223ad1939a#odatadeltalink)。


# やりたかったこと
私はOneNoteに勉強したことや知識のまとめ、ちょっとした日記を溜め込むようにしています。
細かく整理はせずにとにかくページを作っては書く、という使い方をしているため、
改めて眺めていると異なるセクションで同じ関連するページが存在することがわかりました。

例えば、、
NW, Cisco, Junos, Linux, pythonなどのセクションがあるのですが、
- NWのセクションの中にはBGPの基礎知識のページ
- Ciscoのセクションの中にはBGPのconfig例
- Linuxのセクションの中にはbgpdumpのページ
- pythonでBGPのshowコマンドを実行するページ

のように、各セクションにBGPに関するページが散乱しています。
各セクションに紐づいている状態は正しいのですが、
BGPという一つのテーマを横断的に見ていくのにはあまり良くない構造になっています。

このままだと、BGPの知識を引っ張り出したい時に
複数のセクションをGUIでチェックしていくことになり手間がかかります。。
そこで、BGP関連のページのリンク集を作る、あるいはマインドマップ的なものを作る、
ということができたらいいなと思ったのがGraphを触ることになったきっかけです。


# はまったところ
### そもそもAPIの認可ってなんだ
トークンが必要そうなのはわかるけど、なんでこんなに手間かかるねん、と思いながら試行錯誤していたので苦労しましたが、[こちらのOAuthのわかりやすい解説記事](https://qiita.com/TakahikoKawasaki/items/e37caf50776e00e733be)でOAuthのイメージを掴んでおけばよかったです。

### 認証方式が多く、どれを使えばいいのかわからない
これは前述のpythonSDKのページを早く見つけられれば解決した気はしますが、、
公式ドキュメントを上から読んでいくのは気が遠くなる作業だったのでとりあえずyoutubeを参考に進めていった結果、何が正解か分からない中でたくさんのエラーに遭遇してなかなかストレスでした。。

### Azureに不慣れでGUI操作がわかりづらい
APIのtoken発行にAzure上でAPIアプリの作成が必要になりますが、Azureを普段触ることもないので少し苦労しました。

### pythonのスクリプト例が少ない
Graph Explorerのcode snippetsにpythonがありません。。
また、github上で使えそうなモジュールはいくつか出てくるものの、認証・認可について詳細な説明がなく、最終的には利用しませんでした。


# 遭遇したエラー

### 

### scopeのエラー
tokenを発行するために、認可して欲しい内容をSCOPESとして渡します。 
通常は`SCOPES = ['https://graph.microsoft.com/.default']`とすれば、
GUI上で設定したスコープを読み込んでくれる？ようですが、私の場合だと以下のようなエラーが出てきてしまいました。
エラーコードのリファレンスは[こちら](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/reference-aadsts-error-codes)。ちなみにMSサービス全体として、[ここでエラーコードの検索](https://login.microsoftonline.com/error)をかけられるようです。

> AADSTS70011: The provided value for the input parameter 'scope' is not valid. One or more scopes in 'https://graph.microsoft.com/.default **openid offline_access**' are not compatible with each other.

追加した覚えのない**openid**と**offline_access**がscopesの引数として渡されているようです。　
そもそもの/.defaultについての説明は[こちらの公式ドキュメント](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v2-permissions-and-consent#the-default-scope)にありますが、解読は諦めました。。
initiate_device_flow()を利用した場合の[サンプルコード](https://github.com/Azure-Samples/ms-identity-python-devicecodeflow)でSCOPESは具体的に書いていたため、
.defaultは利用せず以下のように直で書くので良しとしました。。

``` main.py
SCOPES = ['User.Read', 'Notes.Read', 'Notes.ReadWrite', 'Notes.Create']
```


### ConfidentialClientApplicationだとAPIを叩けない

[序盤で参考にしたMediumの記事](https://medium.com/@marian.reha/query-ms-graph-api-in-python-e8e04490b04e)ではmsal.ConfidentialClientApplicationを利用していました。tokenの付与はできたものの、エラーメッセージで「この認証方法ではAPI叩けない」と言われて悲しかったです。。
結局、msal.PublicClientApplicationを利用しています。