Mendeley で Watched Folder の階層構造を保ってインポート

動機

論文管理のため Mendeley を愛用しており、手持ちの MacBook の適当なフォルダを Watched Folder に設定することによりダウンロードした論文を自動でインポートするよう設定している。この方法の場合、Watched Folder 内に作成したサブフォルダ内に論文 PDF を格納した際、インポート自体はされるものの Mendeley 側では階層構造の情報が喪失しライブラリが無秩序な状態になってしまうという問題がある。

そこで、本記事では Watched Folder 内のサブフォルダ名に基づいて Mendeley 上でも自動でフォルダ分けを行う手順を説明する。概要は以下の通り。実行環境は macOS 10.14.6, Python 3.7.3 である。

  1. 論文がローカルの Watched Folder に DL されたのを MacAutomator で検知。以下の作業を行う Python スクリプトを実行
  2. Mendeley の API を叩いて当該ファイルをインポート
  3. 論文が格納されているローカルのサブフォルダ名を取得。Mendeley の API を叩いて、2 でインポートしたドキュメントを一致する名前のフォルダに登録 (存在しない場合は新規作成)

Mendeley API を利用する

アプリケーションの登録

こちら から Mendeley アカウントにログインし、新規アプリケーションの登録を行う。今回は認証をローカルにサーバーを立てて行うため、Redirect URLhttp://localhost:8000/mendeley.py としておいた。Generate Secret をクリックして得られた Secret をコピーしておく。

認証プロセス

Mendeley API には Python SDK が用意されているものの、ぱっと見た感じフォルダを操作するメソッドが提供されていないようだったので こちら を直接叩くことにした。認証に用いたファイルは以下の2つである。ざっくりとした流れとしては、print_authorization_url() で認証 URL を作成し、認証プロセスが完了した際 callback 関数により access_token と refresh_token が settings.json に保存されるようになっている。実際に必要な作業としては、

  1. CallbackServer.pymendeley.py を同じディレクトリに置く
  2. python mendeley.py を実行
  3. コンソールに表示された URL にブラウザでアクセスする
  4. 認証プロセスを完了。ブラウザに「Completed!」が表示されていることを確認
  5. 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)

MacAutomator に登録

悲しいことに 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