BacklogのAPIを使ってWikiの添付ファイルごとまるっとダウンロードしてみた

BacklogのWikiの内容は簡単にPDFとして出力できて便利ですね!今回は添付ファイルも丸ごとダウンロードしてみたかったので、Backlog APIを使ってまるっとダウンロードしてみました。APIが充実していると本当に便利ですね。
2023.01.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。

Backlogってとても便利で使いやすいですよね。(数日ぶり)

私の前回の記事にて、Backlog APIを使って課題と課題添付ファイルの取得をやってみました!

前回はあくまで課題の添付ファイルダウンロードということで、Wikiに関してはノータッチでした。

今回は続きとしてプロジェクトのWikiのコンテンツを添付ファイルを含めてダウンロードしてみました!

やってみたことは以下のとおりです。

  1. BacklogのWikiの情報をローカルに保存する方法を検討
  2. 【コーディング】Backlog APIを使ってWikiのページと添付ファイルをダウンロード

※ 以下やってみた内容は公式ドキュメントを拝見しながら自分なりにベストな方法を模索しましたが、あくまで私見です。もっと楽にできる方法があるかもしれませんので、ご留意ください。

先に結論(出力結果イメージ)

Wikiページの内容を出力するだけならWikiページをPDFとして出力する標準機能を利用すれば良いと思います!

ただし、Wikiページが大量にあったり、添付ファイルも含めてダウンロードしておきたい場合は以下APIを利用すれば良いと思います。

今回もGo言語でサクッとCLIツールを作って、Wikiページ本文・添付ファイルのダウンロードができました!

20230115_backlog_wiki_export_export_image

# CLIツールの様子
Wiki一覧情報の取得開始
ページ情報の取得開始: Home
ページ情報の取得開始: キックオフテンプレート
ページ情報の取得開始: 振り返りテンプレート
ページ情報の取得開始: 進捗報告テンプレート
# 子ページにも対応
ページ情報の取得開始: ParentPage
ページ情報の取得開始: ParentPage/ChildPage1
    添付ファイルのダウンロード開始:1m7wlVHg_400x400.jpeg
ページ情報の取得開始: ParentPage/ChildPage2
    添付ファイルのダウンロード開始:20220113_backlog_issue_export_flow.jpg
ページ情報の取得開始: ParameterSheet
ページ情報の取得開始: ParameterSheet/EC2

Step1.料金コストをかけないで要件を満たす方法を検討

まずはWikiの情報をサクッと出力する方法を検討してみました。

20230115_backlog_wiki_export_flow1

いろいろと調べたところ、ノーコードで一番早そうなのが標準のWikiのPDF出力機能の利用でした。

その他にも前回の記事でも確認した有料オプションも利用できそうですが、今回も特定プロジェクト配下のWikiの出力で十分なのでスコープ外としました。

PDFとして出力した場合、WikiページにcsvやPDFファイルなどが添付されていると、当然ながらPDF化しても添付されていたファイルはついてきません。

20230115_backlog_wiki_export_pdf_ketten

また、Wikiのページ数が多い場合は手動でPDF化するのも大変だと思うので、今回はWikiページの内容と添付されたファイルの両方を取得できるようにBacklog APIを利用してみました。

Step2.下準備・留意事項の整理

事前にBacklog APIを利用するためのAPIキーなどの発行が必要です。

APIを利用するにあたりリクエストレートの制限などもありますので、前回の記事のStep2.下準備・留意事項の整理をご確認ください。

Step3.Goで実装してみた

今回もGoでサクッと実装してみました。

この記事ではポイントを掻い摘んで説明するため、コードの全文は以下リポジトリをご参照ください。

なお、以下で記載するコードはテストコードを書けていません。

また、私の環境(OS:macOS Monterey,チップ:Apple M1)でのみ動作確認をしています。

あくまでサンプルとしてご認識いただけますと幸いです。

再送処理などの実装

前回の記事と同様の内容となってしまいますが、再掲します。

以下Backlogのブログに記載されていますが、APIのリクエスト制限に抵触した場合HTTPステータスコード429が返却されます。

そのため429ステータスコードが返却された場合に65秒後に再送をするように設定しています。

func NewRequestClient() *resty.Client {
	client := resty.New()
	client.BaseURL = getBaseUrl()
	client.SetQueryParam("apiKey", ApiKey)
	// json-iteratorをデフォルトのJSOnクライアントに設定
	client.JSONMarshal = jsoniter.Marshal
	// 429リクエストの際にリトライを行う
	// 1分間とする理由(https://backlog.com/ja/blog/backlog-api-rate-limit-announcement/)
	client.AddRetryCondition(
		func(r *resty.Response, err error) bool {
			return r.StatusCode() == http.StatusTooManyRequests
		},
	)
	client.SetRetryCount(3).SetRetryWaitTime(65 * time.Second)
	return client
}

また、コマンドラインツールを楽に作るためにspf13/cobraや、JSONを取り扱うためのjson-iteratorなどのライブラリも引き続き利用しています。

プロジェクト配下のWikiページ一覧の取得

次のAPIを使用してプロジェクト配下のWikiページの一覧を取得する処理を準備します。

type wikiInfo struct {
	id   string
	name string
}

// Wikiページの一覧を取得
func getWikiList(client *resty.Client) []wikiInfo {
	// https://developer.nulab.com/ja/docs/backlog/api/2/get-wiki-page-list/#
	client.SetQueryParam("projectIdOrKey", ProjectID)
	resp, err := client.R().
		Get(client.BaseURL + "/api/v2/wikis")
	if err != nil {
		log.Fatal(err)
	}
	if resp.StatusCode() != http.StatusOK {
		log.Fatalln("Request Fail:" + resp.String())
	}
	fileListResponse := jsoniter.Get(resp.Body())
	fileList := fileListResponse.Get()
	var wikiList []wikiInfo
	for i := 0; i < fileList.Size(); i++ {
		elm := fileList.Get(i)
		wikiID := elm.Get("id").ToString()
		wikiName := elm.Get("name").ToString()
		wi := wikiInfo{id: wikiID, name: wikiName}
		wikiList = append(wikiList, wi)
	}
	return wikiList
}

APIのレスポンスを参考にしながら、必要なWikiページのIDとWikiページ名のみを保持します。

[
    {
        "id": 112, //これと
        "projectId": 103,
        "name": "Home", //この情報だけで良い
        "tags": [
            {
                "id": 12,
                "name": "議事録"
            }
        ],
        "createdUser": {
            "id": 1,
            "userId": "admin",
            "name": "admin",
            "roleType": 1,
            "lang": "ja",
            "mailAddress": "eguchi@nulab.example",
            "lastLoginTime": "2022-09-01T06:35:39Z"
        },
        "created": "2013-05-30T09:11:36Z",
        "updatedUser": {
            "id": 1,
            "userId": "admin",
            "name": "admin",
            "roleType": 1,
            "lang": "ja",
            "mailAddress": "eguchi@nulab.example",
            "lastLoginTime": "2022-09-01T06:35:39Z"
        },
        "updated": "2013-05-30T09:11:36Z"
    },
    ...

]

Wikiページの詳細・添付ファイル一覧の取得

次に以下のAPIを使ってWikiページの詳細情報(添付ファイルの一覧を含む)を取得する処理を準備します。

type wikiAttachment struct {
	id   string
	name string
}

type wikiDetail struct {
	id          string
	content     string
	attachments []wikiAttachment
}

// Wikiページの詳細情報を取得
func getWikiDetail(client *resty.Client, wikiID string) wikiDetail {
	resp, err := client.R().
		Get(client.BaseURL + fmt.Sprintf("/api/v2/wikis/%s", wikiID))
	if err != nil {
		log.Fatal(err)
	}
	if resp.StatusCode() != http.StatusOK {
		log.Fatalln("Get WikiDetail Request Fail:" + resp.String())
	}
	fileListResponse := jsoniter.Get(resp.Body())
	elm := fileListResponse.Get()
	wikiContent := elm.Get("content").ToString()
	fileList := elm.Get("attachments")
	// Wikiページの添付ファイル情報を取得
	var attachmentList []wikiAttachment
	for i := 0; i < fileList.Size(); i++ {
		elm := fileList.Get(i)
		attachmenID := elm.Get("id").ToString()
		attachmentName := elm.Get("name").ToString()
		wa := wikiAttachment{id: attachmenID, name: attachmentName}
		attachmentList = append(attachmentList, wa)
	}
	wd := wikiDetail{id: wikiID, content: wikiContent, attachments: attachmentList}
	return wd
}

以下はレスポンスのサンプルですが、Wikiページの内容の他、attachmentsとして添付ファイルの情報が含まれているため、取得しています。

{
    "id": 1,
    "projectId": 1,
    "name": "Home",
    // Wikiページに書かれている内容(本文)
    "content": "test",
    "tags": [
        {
            "id": 12,
            "name": "議事録"
        }
    ],
    // 添付ファイルの情報
    "attachments": [
        {
            "id": 1,
            "name": "test.json",
            "size": 8857,
            "createdUser": {
                "id": 1,
                "userId": "admin",
                "name": "admin",
                "roleType": 1,
                "lang": "ja",
                "mailAddress": "eguchi@nulab.example",
                "lastLoginTime": "2022-09-01T06:35:39Z"
            },
            "created": "2014-01-06T11:10:45Z"
        },
        ...
    ],
    "sharedFiles": [
        {
            "id": 454403,
            "type": "file",
            "dir": "/ユーザアイコン/",
            "name": "01_サラリーマン.png",
            "size": 2735,
            "createdUser": {
                "id": 5686,
                "userId": "takada",
                "name": "takada",
                "roleType":2,
                "lang":"ja",
                "mailAddress":"takada@nulab.example",
                "lastLoginTime": "2022-09-01T06:35:39Z"
            },
            "created": "2009-02-27T03:26:15Z",
            "updatedUser": {
                "id": 5686,
                "userId": "takada",
                "name": "takada",
                "roleType": 2,
                "lang": "ja",
                "mailAddress": "takada@nulab.example",
                "lastLoginTime": "2022-09-01T06:35:39Z"
            },
            "updated":"2009-03-03T16:57:47Z"
        },
        ...
    ],
    "stars": [],
    "createdUser": {
        "id": 1,
        "userId": "admin",
        "name": "admin",
        "roleType": 1,
        "lang": "ja",
        "mailAddress": "eguchi@nulab.example",
        "lastLoginTime": "2022-09-01T06:35:39Z"
    },
    "created": "2012-07-23T06:09:48Z",
    "updatedUser": {
        "id": 1,
        "userId": "admin",
        "name": "admin",
        "roleType": 1,
        "lang": "ja",
        "mailAddress": "eguchi@nulab.example",
        "lastLoginTime": "2022-09-01T06:35:39Z"
    },
    "updated": "2012-07-23T06:09:48Z"
}

添付ファイルのダウンロード

次に以下のAPIを使ってWikiページの添付ファイルをダウンロードします。

// Wiki添付ファイルのダウンロード
func downLoadWikiFile(
	client *resty.Client,
	detail wikiDetail,
	attachment wikiAttachment,
	dir string,
) {
	// https://developer.nulab.com/ja/docs/backlog/api/2/get-wiki-page-attachment/#
	url := fmt.Sprintf(
		client.BaseURL+
			"/api/v2/wikis/%s/attachments/%s",
		detail.id,
		attachment.id,
	)
	_, err := client.R().SetOutput(path.Join(dir, attachment.name)).Get(url)
	if err != nil {
		log.Fatalln("DownLoad WikiAttachedFile Fail ", err)
	}
}

作ったコードをビルドして実行してみる

ここまで紹介したコードを呼び出すメイン部分はこのような形となっています。

// getWikiCmd represents the getWiki command
var getWikiCmd = &cobra.Command{
	Use:   "wiki",
	Short: "get wiki contents.",
	Long:  `get wiki contents.`,
	Run: func(cmd *cobra.Command, args []string) {
		client := NewRequestClient()
		fmt.Println("Wiki一覧情報の取得開始")
		wikiList := getWikiList(client)
		for _, w := range wikiList {
			// Wikiページの詳細取得
			client = NewRequestClient()
			fmt.Println("ページ情報の取得開始:", w.name)
			detail := getWikiDetail(client, w.id)
			dir := getContentDir(w)
			// Wikiのコンテンツをローカルに保存する
			os.MkdirAll(dir, os.ModePerm)
			cf, err := os.Create(path.Join(dir, "content.md"))
			if err != nil {
				log.Fatalln("Wiki content file create error: ", err)
			}
			defer cf.Close()
			cf.WriteString(detail.content)
			// 添付ファイルのダウンロード
			for _, attachment := range detail.attachments {
				fmt.Println(fmt.Sprintf("    添付ファイルのダウンロード開始:%s", attachment.name))
				client = NewRequestClient()
				downLoadWikiFile(client, detail, attachment, dir)
			}
		}
	},
}

// Wikiコンテンツを保存するディレクトリパスの作成
func getContentDir(wi wikiInfo) string {
	cwd, _ := os.Getwd()
	baseDir := filepath.Join(cwd, "wiki")
	wikiPathList := strings.Split(wi.name, "/")
	wikiPath := filepath.Join(wikiPathList...)
	dir := filepath.Join(baseDir, wikiPath)
	return dir
}

実際にビルドして実行してみます。

go build -ldflags="-s -w" -trimpath
# ビルド完了後
./backlog-backup-sample wiki --apikey ${APIキー} --space ${スペース名} --project ${プロジェクト名}

以下のように処理が始まります。

Wiki一覧情報の取得開始
ページ情報の取得開始: Home
ページ情報の取得開始: キックオフテンプレート
ページ情報の取得開始: 振り返りテンプレート
ページ情報の取得開始: 進捗報告テンプレート
ページ情報の取得開始: ParentPage
ページ情報の取得開始: ParentPage/ChildPage1
    添付ファイルのダウンロード開始:1m7wlVHg_400x400.jpeg
ページ情報の取得開始: ParentPage/ChildPage2
    添付ファイルのダウンロード開始:20220113_backlog_issue_export_flow.jpg
ページ情報の取得開始: ParameterSheet
ページ情報の取得開始: ParameterSheet/EC2
ページ情報の取得開始: ParentPage/ChildPage3
    添付ファイルのダウンロード開始:comment.csv

冒頭でお見せしたように添付ファイルも含めたコンテンツがダウンロードできました。

20230115_backlog_wiki_export_export_image

最後に

今回もBacklog APIを使ってWikiコンテンツを取得してみました。

Backlog APIの一覧を眺めているだけでいろいろなことができそうでとても楽しいですね。

ぜひみなさんも「あれ?これって自動化できないかな?」と思った時はこのページを参照してみてください!

以上、この記事がどなたかの参考になればとてもうれしいです。