Mendeley で Watched Folder の階層構造を保ってインポート
動機
論文管理のため Mendeley を愛用しており、手持ちの MacBook の適当なフォルダを Watched Folder に設定することによりダウンロードした論文を自動でインポートするよう設定している。この方法の場合、Watched Folder 内に作成したサブフォルダ内に論文 PDF を格納した際、インポート自体はされるものの Mendeley 側では階層構造の情報が喪失しライブラリが無秩序な状態になってしまうという問題がある。
そこで、本記事では Watched Folder 内のサブフォルダ名に基づいて Mendeley 上でも自動でフォルダ分けを行う手順を説明する。概要は以下の通り。実行環境は macOS 10.14.6, Python 3.7.3 である。
- 論文がローカルの Watched Folder に DL されたのを Mac の Automator で検知。以下の作業を行う Python スクリプトを実行
- Mendeley の API を叩いて当該ファイルをインポート
- 論文が格納されているローカルのサブフォルダ名を取得。Mendeley の API を叩いて、2 でインポートしたドキュメントを一致する名前のフォルダに登録 (存在しない場合は新規作成)
Mendeley API を利用する
アプリケーションの登録
こちら から Mendeley アカウントにログインし、新規アプリケーションの登録を行う。今回は認証をローカルにサーバーを立てて行うため、Redirect URL
は http://localhost:8000/mendeley.py
としておいた。Generate Secret をクリックして得られた Secret をコピーしておく。
認証プロセス
Mendeley API には Python SDK が用意されているものの、ぱっと見た感じフォルダを操作するメソッドが提供されていないようだったので こちら を直接叩くことにした。認証に用いたファイルは以下の2つである。ざっくりとした流れとしては、print_authorization_url()
で認証 URL を作成し、認証プロセスが完了した際 callback 関数により access_token と refresh_token が settings.json
に保存されるようになっている。実際に必要な作業としては、
CallbackServer.py
とmendeley.py
を同じディレクトリに置くpython mendeley.py
を実行- コンソールに表示された URL にブラウザでアクセスする
- 認証プロセスを完了。ブラウザに「Completed!」が表示されていることを確認
settings.json
が作成され、access_token と refresh_token が保存されていることを確認
が挙げられる。Mendeley の認証プロセスについては こちら を、Python でローカルサーバーを立てる方法については こちら を参考にした。
# CallbackServer.py import requests from http.server import HTTPServer from http.server import BaseHTTPRequestHandler from urllib.parse import urlparse def start(port, callback): def handler(*args): CallbackServer(callback, *args) server = HTTPServer(('', int(port)), handler) server.serve_forever() class CallbackServer(BaseHTTPRequestHandler): def __init__(self, callback, *args): self.callback = callback BaseHTTPRequestHandler.__init__(self, *args) def printToWindow(self, message): self.wfile.write(message.encode('utf-8')) return def do_GET(self): parsed_path = urlparse(self.path) query = parsed_path.query self.send_response(200) self.end_headers() self.callback(query) self.wfile.write("Completed!".encode()) return
# mendeley.py import requests import sys import CallbackServer import json client_id = "xxxx" client_secret = "xxxxxxxxxxx" redirect_uri = "http://localhost:8000/mendeley.py" port = "8000" setting_path = "/path/to/the/setting/settings.json" # setting を保存するファイルのパスを指定 def print_authorization_url(): param = { "response_type": 'code', "client_id": client_id, "redirect_uri": redirect_uri, "state": xxxxxx, # 適当な数字でよい "scope": 'all' } param_str = "&".join(["{0}={1}".format(key, value) for (key, value) in param.items()]) url = 'https://api.mendeley.com/oauth/authorize?' + param_str print(url) return def callback(query): code = query.split("&")[0].split("=")[1] credentials = json.loads(fetchAccessToken(code)) try: json_f = open(setting_path, 'r') setting = json.load(json_f) except: setting = {} setting['access_token'] = credentials['access_token'] setting['refresh_token'] = credentials['refresh_token'] setting_f = open(setting_path, 'w') json.dump(setting, setting_f) def fetchAccessToken(code): url = 'https://api.mendeley.com/oauth/token' payload = { "code": code, "client_id": client_id, "client_secret": client_secret, "redirect_uri": redirect_uri, "grant_type": "authorization_code" } res = requests.post(url, data=payload) return res.text if __name__ == '__main__': print_authorization_url() CallbackServer.start(port, callback)
Mendeley API を叩いてみる
以上のプロセスで認証が完了したため、Reference を見ながら「動機」の 2, 3 に記載された動作を行うスクリプトを書いた。上記と重複しない内容を書き出すと以下のようになる。なお、access_token の有効期限は1時間であるようなので、リクエストが 401 や 403 を返した際は refresh_token を用いて更新を行わなくてはならないことに注意。
# mendeley.py def refreshAccessToken(): url = 'https://api.mendeley.com/oauth/token' json_f = open(setting_path, 'r') setting = json.load(json_f) payload = { "client_id": client_id, "client_secret": client_secret, "refresh_token": setting['refresh_token'], "grant_type": 'refresh_token' } res = requests.post(url, data=payload) credentials = json.loads(res.text) access_token = credentials['access_token'] setting['access_token'] = access_token setting_f = open(setting_path, 'w') json.dump(setting, setting_f) return def getFolders(): url = 'https://api.mendeley.com/folders' json_f = open(setting_path, 'r') setting = json.load(json_f) header = { "authorization": 'Bearer ' + setting['access_token'], "contentType": 'application/json' } res = requests.get(url, headers=header) if ((res.status_code == 401) | (res.status_code == 403)): refreshAccessToken() getFolders() folder_lists = json.loads(res.text) return folder_lists def importFile(path, folder_lists): url = 'https://api.mendeley.com/documents' json_f = open(setting_path, 'r') setting = json.load(json_f) pdf_data = open(path, "r+b").read() file_name = path.split("/")[-1] folder_name = path.split("/")[-2] header = { "authorization": 'Bearer ' + setting['access_token'], "Content-Disposition": 'attachment; filename="' + file_name + '"', 'Content-Type': 'application/pdf' } files = { "file": pdf_data } res = requests.post(url, headers=header, files=files) status_code = res.status_code if ((status_code == 401) | (status_code == 403)): refreshAccessToken() importFile(path, folder_lists) file_id = json.loads(res.text)["id"] folder_id = getFldId(folder_lists, folder_name) retrieveToFld(file_id, folder_id) def getFldId(folder_lists, folder_name): if len(folder_lists) == 0: folder_lists = getFolders() for i in range(len(folder_lists)): print(folder_lists[i]) if folder_lists[i]['name'] == folder_name: folder_id = folder_lists[i]['id'] return folder_id folder_id = createFolder(folder_name) return folder_id def createFolder(folder_name): url = 'https://api.mendeley.com/folders' json_f = open(setting_path, 'r') setting = json.load(json_f) header = { "authorization": 'Bearer ' + setting['access_token'], 'Content-type': 'application/vnd.mendeley-folder.1+json' } payload = { "name": folder_name } res = requests.post(url, headers=header, data=json.dumps(payload)) status_code = res.status_code if ((status_code == 401) | (status_code == 403)): refreshAccessToken() createFolder(folder_name) folder_id = json.loads(res.text)["id"] return folder_id def retrieveToFld(file_id, folder_id): url = "https://api.mendeley.com/folders/" + folder_id + "/documents" json_f = open(setting_path, 'r') setting = json.load(json_f) header = { "authorization": 'Bearer ' + setting['access_token'], 'Content-type': 'application/vnd.mendeley-document.1+json' } payload = { "id": file_id } res = requests.post(url, headers=header, data=json.dumps(payload)) status_code = res.status_code if ((status_code == 401) | (status_code == 403)): refreshAccessToken() retrieveToFld(file_id, folder_id) return folder_id if __name__ == '__main__': argvs = sys.argv path = argvs[1] folder_lists = getFolders() importFile(path, folder_lists)
Mac の Automator に登録
悲しいことに Automator のフォルダアクションは登録したフォルダ直下での変化しか検知してくれないようで、監視対象のフォルダ内のサブフォルダに追加されたファイルには対応していない。そのため、Watched Folder 内の各フォルダに対して以下の作業を行う必要がある。
Automator への登録内容
ファイルの追加をトリガーとして、完成した Python スクリプトを実行するよう Automator に登録する。具体的には、Automator のフォルダアクションを新規作成し、監視したいサブフォルダを選択。アクションは「シェルスクリプトを実行」とし、シェルを bin/bash
, 入力の引渡し方法を 引数として
に設定して
for f in "$@" do python path/to/the/script.py "${f}" done
を貼り付ける。
Watched Folder 内に新規作成されたフォルダの取り扱い
既存のサブフォルダには上記の作業を行えば良いとして、今後サブフォルダを増やした際に確実に登録を忘れる予感がしたので以下の対策を行った。以下のスクリプトは、引数がフォルダだった場合「フォルダアクション設定」を起動して登録を促すものである。Watched Folder に対してこれを実行するフォルダアクションを設定し、登録忘れの防止とした。
PASSED=$1 if [[ -d $PASSED ]]; then open -a "/System/Library/CoreServices/Applications/Folder Actions Setup.app" else exit 1 fi