Crowiを使っていると文書のバックアップが取りたくなることがあります。
GitlabなどのGithubクローンではWiki機能をgitで管理できたりしますが、Crowiでもバックアップやローカルでの編集ができれば便利ですよね。
そこで、今回はCrowiのAPIを使って記事を編集するツールを作成します。
はじめに
Crowi にはドキュメント化・サポートはされていませんが Web 経由でたたける API があります。そしてエンハンスされたcrowi-plus/Growiでも同じものが使えるようですね。
複雑になるわりに私はあまり使わないので、基本的な本文のアップロード・ダウンロードが出来るようにし、添付やコメントなどの操作は考えないことにします。
一応本家 Crowi だけではなくcrowi-plus/Growi でも動作を確認しています。環境は Windows 10 Pro + Python 3.6 です。
[追記]
Growi の最近のバージョンで API の仕様が若干変わったようなので新しい方にも対応できるよう修正しました。
スクリプト
今回もまずはスクリプトを記載しておきます。
なお、Webクライアントはrequests
モジュールを使っていますので、なければインストールする必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | import requests import os import hashlib class CrClient: def __init__(self, crowihost, apitoken, username, local_dir): if not os.path.isdir(local_dir): raise Exception("invalid directory path: {}".format(local_dir)) self.host_ = crowihost self.path_ = local_dir self.params_ = {"access_token": apitoken, "user": username} self.meta_ = None def fetch(self, download=False): # sub function for calling pages.get API def _separated_get(id_): params_ = self.params_ params_["page_id"] = id_ res = requests.get(self.__url("pages.get"), params=params_) return res.json()["page"]["revision"]["body"].replace("\r", "") # sub function for handling data object def _process(page_): # skip unexpected object if "id" not in page_.keys(): print("skip item:", page_) return buf = {"id": page_["id"], "path": page_["path"]} if isinstance(page_["revision"], dict): body = page_["revision"]["body"].replace("\r", "") buf["revision_id"] = page_["revision"]["_id"] else: # fix for Growi API changes body = _separated_get(page_["id"]) buf["revision_id"] = page_["revision"] buf["digest"] = hashlib.sha256(body.encode('utf-8')).hexdigest() # update property self.meta_.append(buf) # download to md file if download: self.__write(body, page_["path"]) # get pages list res = requests.get(self.__url("pages.list"), params=self.params_) data = res.json() self.meta_ = [] for page in data["pages"]: if isinstance(page, list): # fix for API changes on Growi for item in page: _process(item) else: _process(page) def upload(self): # get meta data self.fetch() # get markdown files (having extension ".md") mdfiles = [] for root, dirs, files in os.walk(self.path_): for file_name in files: if file_name.endswith(".md"): mdfiles.append(os.path.join(root, file_name)) for item in mdfiles: body = self.__read(item) path_ = os.path.relpath(item, self.path_) if os.path.basename(item) == "(portal).md": path_ = os.path.dirname(path_) + "/" else: path_ = os.path.splitext(path_)[0] path_ = path_.replace("\\", "/") if not path_.startswith("/"): path_ = "/" + path_ # check if exists duplicate = [x for x in self.meta_ if x["path"] == path_] if len(duplicate) > 0: digest = hashlib.md5(body.encode('utf-8')).hexdigest() if duplicate[0]["digest"] != digest: # update existing path with updated body verb_ = "pages.update" payload_ = {"body": body, "page_id": duplicate[0]["id"], "revision_id": duplicate[0]["revision_id"]} else: continue else: # create new page verb_ = "pages.create" payload_ = {"body": body, "path": path_} print("uploading {}...".format(path_)) requests.post(self.__url(verb_), data=payload_, params=self.params_) def __url(self, verb): return "http://{}:3000/_api/{}".format(self.host_, verb) def __read(self, file_path): with open(file_path, 'r', encoding='utf-8') as f: return f.read() def __write(self, body, raw_path): if raw_path == "/": dir_path = self.path_ file_path = os.path.join(self.path_, "(portal).md") else: dir_path = os.path.join(self.path_, os.path.dirname(raw_path[1:])) file_name = os.path.basename(raw_path[1:]) if file_name == "": file_path = os.path.join(dir_path, "(portal).md") else: file_path = os.path.join(dir_path, file_name + ".md") if not os.path.isdir(dir_path): os.makedirs(dir_path, exist_ok=True) # save content with open(file_path, 'w', encoding='utf-8') as f: f.write(body) |
使い方
基本はスクリプトで定義しているCrClient
クラスを使います。
設定を渡してインスタンス化したのち、各メソッドを呼びます。エラーハンドリングはしていないので、問題があれば例外を出して止まるはずです。
1 2 3 4 5 | from crclient import CrClient _client = CrClient("(crowi host ip address or name)", "(api token...)", "(user name)", "(path to local dir)") # get page list _client.fetch() |
ユーザ名は記事の編集権限を持ったユーザ名、API tokenはCrowiの管理画面で発行したもの、Webクライアントに渡すためのCrowiのIP アドレス(解決できる場合はホスト名)、およびローカルの作業ディレクトリへのパス、がここでの設定項目になっています。
ポート名は決め打ち(3000番)になっているので、別の番号を指定している場合は private メソッド__url
を変更して下さい。
基本的な動作は、ローカルの作業ディレクトリと Crowi で当該ユーザが作成したページとを同期する、というものになっています。
ローカルの作業ディレクトリについては、パスは相対パスでも絶対パスでも構いませんが、スクリプト実行時に実在する必要があります。ローカルの作業ディレクトリを markdown の原稿作成用として git 等で管理しておくと便利だと思います。
本文として認識されるのは作業ディレクトリにある markdown ファイル(とディレクトリ構造)のみなので、その他のファイルがあっても一切無視されます。
以降に詳細を記載しておきます。
APIを使ったCrowiの操作
本家Crowiのソースで言うと、routeを見れば定義されているAPIがわかります。
記事(ページ)一覧の取得
CrClient.fetch()
メソッドで、Crowiに作成されているページ一覧を取得できます。
一覧として取得できるのは指定したユーザが作成した記事のみのようです。この動作は以降の操作でも同じなようで、例えば、試していませんが、他のユーザが既に作成してしまっているページと同じ名前のページを作ろうとするとエラーになるはず。
Crowi の/_api/pages.list
の URL を Get すると、JSON でページ一覧が返ってきます。
本文など全部コミコミなので、Crowi にたくさんページを作成している場合には結構ツライかも。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | def fetch(self, download=False): # sub function for calling pages.get API def _separated_get(id_): params_ = self.params_ params_["page_id"] = id_ res = requests.get(self.__url("pages.get"), params=params_) return res.json()["page"]["revision"]["body"].replace("\r", "") # sub function for handling data object def _process(page_): # skip unexpected object if "id" not in page_.keys(): print("skip item:", page_) return buf = {"id": page_["id"], "path": page_["path"]} if isinstance(page_["revision"], dict): body = page_["revision"]["body"].replace("\r", "") buf["revision_id"] = page_["revision"]["_id"] else: # fix for Growi API changes body = _separated_get(page_["id"]) buf["revision_id"] = page_["revision"] buf["digest"] = hashlib.sha256(body.encode('utf-8')).hexdigest() # update property self.meta_.append(buf) # download to md file if download: self.__write(body, page_["path"]) # get pages list res = requests.get(self.__url("pages.list"), params=self.params_) data = res.json() self.meta_ = [] for page in data["pages"]: if isinstance(page, list): # fix for API changes on Growi for item in page: _process(item) else: _process(page) |
内部動作としては本文やページの ID を取り出しているだけです。
後ほどアップロード時の変更管理に使うため、ハッシュ(MD5)も計算しています。
ダウンロード
CrClient.fetch()
メソッドの引数にdownload=True
を渡すと、ダウンロードの動作になります。
コンストラクタに渡したパスをローカルの作業ディレクトリとし、Crowiサーバから作業ディレクトリへ、取得したページ本文のみをツリー構造そのままにダウンロードします。作業ディレクトリに配置する場合は拡張子.md
が付きます。
ローカルの作業ディレクトリに同じ名前の Markdown ファイルがあった場合には、上書きされますので注意。
Crowi の場合、ポータルページがサポートされている(crowi-plus は設定に依存)ので、存在する場合は”(portal).md”の名前でカテゴリの直下にダウンロードされます。逆に、この仕様では”(portal)”という名前の記事を作成することはできません。
処理の途中までは上に書いた一覧の取得と同じで、private のCrClient.__write()
メソッドを使ってファイル保存を実行するか否かのみ異なります。
ローカルの作業ディレクトリに markdownファイルを書き込む際に、ファイルパスを計算します。
まあ何やらゴニョゴニョやっていますが、URLをローカルのパス+.md
に直したり、ポータルページのファイル名を指定する処理をしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def __write(self, body, raw_path): if raw_path == "/": dir_path = self.path_ file_path = os.path.join(self.path_, "(portal).md") else: dir_path = os.path.join(self.path_, os.path.dirname(raw_path[1:])) file_name = os.path.basename(raw_path[1:]) if file_name == "": file_path = os.path.join(dir_path, "(portal).md") else: file_path = os.path.join(dir_path, file_name + ".md") if not os.path.isdir(dir_path): os.makedirs(dir_path, exist_ok=True) # save content with open(file_path, 'w', encoding='utf-8') as f: f.write(body) |
ファイル出力は UTF-8、なので Windows のメモ帳では開かないほうが無難です。
Atom やら VS Code で Markdown を編集する分には困らない、、、はず。
アップロード
CrClient.upload()
メソッドがアップロードを実装しています。
ダウンロードとは逆に、ローカルの作業ディレクトリにある markdown ファイルを Crowi の同じパスへアップロードします。
既に同じパスに記事が存在する場合は更新、存在しない場合は新規作成になります。
アップロードのややこしいのはこの更新 or 新規作成でAPIを投げ分けなければいけないところ。
両方とも POSTリクエストですが、それぞれ"_api/pages.update"
と"_api/pages.create"
と、別の URI(API)を使う必要があります。
特に、更新(update)の場合は、Crowi 上のパスではなく当該ページの ID("page_id"
)を指定しなければなりません。
直打ちする根性のある方以外の私のようなひとは、事前に取得 API をスクリプト中で呼んで指定します。今回の実装では、一覧の取得と同じくCrClient.fetch()
を使って ID を取得させています。
また、更新の有無はハッシュを計算し、比較することで判別します。MD5ですがまあ実用上は十分でしょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | def upload(self): # get meta data self.fetch() # get markdown files (having extension ".md") mdfiles = [] for root, dirs, files in os.walk(self.path_): for file_name in files: if file_name.endswith(".md"): mdfiles.append(os.path.join(root, file_name)) for item in mdfiles: body = self.__read(item) path_ = os.path.relpath(item, self.path_) if os.path.basename(item) == "(portal).md": path_ = os.path.dirname(path_) + "/" else: path_ = os.path.splitext(path_)[0] path_ = path_.replace("\\", "/") if not path_.startswith("/"): path_ = "/" + path_ # check if exists duplicate = [x for x in self.meta_ if x["path"] == path_] if len(duplicate) > 0: digest = hashlib.md5(body.encode('utf-8')).hexdigest() if duplicate[0]["digest"] != digest: # update existing path with updated body verb_ = "pages.update" payload_ = {"body": body, "page_id": duplicate[0]["id"], "revision_id": duplicate[0]["revision_id"]} else: continue else: # create new page verb_ = "pages.create" payload_ = {"body": body, "path": path_} print("uploading {}...".format(path_)) requests.post(self.__url(verb_), data=payload_, params=self.params_) |
ダウンロード時と同じく、ポータルページは”(portal).md”という名前のファイルとして管理されます。
このアップロードの操作は、ローカルのファイルでCrowi側のページを上書きします。が、ダウンロード時と異なりCrowi自身がページの変更履歴を管理しているので、うっかり想定外の上書きをしても(手動で)復元はできます。
おわり。