🧑‍🏫

#54 Kibelaの記事をBacklogのWikiへ移行する〜作成したスクリプトの紹介

2024/09/20に公開

はじめに

自社でこれまで作成してきたKibelaの記事をBacklogのWikiページに移行させたい、という要請を受けたため、まとめて記事を移行させるスクリプトをpythonで作成してみました。
こちらでは備忘録として、移行までの調査内容や実際に作成したコードについて、3部に分けてまとめていきたいと思います。
記事の第3部にあたる今回は、実際に作成したスクリプトについて取り上げていきます。
なお、それぞれの処理の詳細について、過去記事で取り上げた部分についてはあまり深く触れておりませんので、気になった方は過去記事をご覧いただければと思います。

本記事に関連するブログ記事

ファイル階層

今回使用する以下4つは全て同じ階層に配置しました。

  • スクリプトファイル
  • Kibelaからダウンロードしたnotesフォルダ
  • Kibelaからダウンロードしたattachmentsフォルダ
  • KibelaのWeb APIを利用して作成したjsonファイル

※KibelaからのダウンロードやAPIの利用方法は第1部の記事をご参考ください

KibelaToBacklog/
    ├ attachments/
        ├ 233.png
        ├ 265.png
        :
    ├ notes/
        ├ 2-猫に小判___豚に真珠.md
        ├ 5-クラゲの風向かい.md
        :
    ├ transfer.py
    └ kibelaExport.json

スクリプトのコード

以下、実際のスクリプトのコードです。

import json
import re
import requests
from pathlib import Path

apikey = "XXX"
projectId = "xxx"
url = "AAAA"

# Kibelaからダウンロードした各フォルダ
BASE_FOLDER = Path("notes") 
ATTACHMENT_FOLDER = Path("attachments") 

def  main():
    # jsonデータを読み込む
    article = open('kibelaExport.json', 'r', encoding='utf-8')
    data = json.load( article )

    for groupCount in range(2): #カテゴリの合計数
        for i in range(50): #最大記事数より多ければ数字はなんでもOK
            totalCount = data["data"]["group"]["folder"]["nodes"][groupCount]["notes"]["totalCount"]
            # 各カテゴリの合計記事数(=totalCount)になったら次カテゴリへ
            if i == totalCount:
                break
            # ①jsonファイルから必要な情報を取得する
            nodesName = data["data"]["group"]["folder"]["nodes"][groupCount]["name"]
            beforeTitle = data["data"]["group"]["folder"]["nodes"][groupCount]["notes"]["nodes"][i]["title"]
            beforeWikiContent = data["data"]["group"]["folder"]["nodes"][groupCount]["notes"]["nodes"][i]["content"]
            # ②Backlog Wiki用に記事タイトルを置換
            wikiTitle = beforeTitle.replace('/','/')
            wikiName = nodesName + '/' + wikiTitle
            wikiContent = beforeWikiContent.replace('```','\n```')
            # ③notesフォルダから画像ファイル名を取得
            reNameForNotes = repFileName(beforeTitle)
            notesLines = []
            notesStr = ''
            for p in BASE_FOLDER.glob("*" + reNameForNotes  +  ".md"):
                with open(r'./{0}' .format(p), encoding='utf-8') as f:
                    notesLines = f.readlines()
                notesStr = ''.join(notesLines)
                notesLists = re.findall('<img title=\'*.* src=\'../attachments/([0-9]*).* width=', notesStr)
                # ④添付ファイルの送信APIを実行し、レスポンスのIDを取得
                attachIdLists = []
                for addFileName in notesLists:
                    attachmentId = backlogSendAttachment(addFileName)
                    #  ⑤記事本文中のimgタグをBacklogのMarkdown記法ルールに合わせて置換
                    if attachmentId != None:
                        attachIdLists.append(int(attachmentId))
                    imgPos = re.search('<img title=\'.+?\' alt=\'.+?\' src=\'/attachments/.+?\' width="[0-9]*" data-meta=\'{"width":[0-9]*,"height":[0-9]*}\'>', wikiContent)
                    try:
                        span = imgPos.span()
                        wikiContent = wikiContent[:span[0]] + '![image](' + addFileName + '.png)' + wikiContent[span[1]:]
                    except AttributeError as ae:
                        print(ae)
                # ⑥Wikiの追加APIに渡すmdファイルを作成
                newFile(wikiTitle, wikiContent)
                # ⑦Wikiの追加APIを実行し、Wiki IDを取得
                wikiId = backlogAddWiki(wikiName, wikiTitle)
                # ⑧添付ファイル追加APIを実行
                backlogAddAttachment(wikiId, attachIdLists)

def  backlogAddWiki(wikiName, wikiTitle):
    uploadUrl = "%s/api/v2/wikis?apiKey=%s&projectId=%s&name=%s" % (url, apikey, projectId, wikiName)
    mfPath = "forAddition/" + wikiTitle + ".md"
    try:
        readF = open(mfPath,'r',encoding="utf-8_sig")
        textFile = readF.read()
        readF.close()
        payload = {'content': textFile, 'mailNotify': 'false'}
        jsonData = requests.post(uploadUrl, params=payload).json()
        id = json.dumps(jsonData["id"], indent=4)
        return  id
    except FileNotFoundError as fnfe:
        print(fnfe)
        return
    except KeyError as ke:
        print(ke)
        return

def backlogSendAttachment(addFileName):
    for a in ATTACHMENT_FOLDER.glob(addFileName + ".png"):
        res = re.search(addFileName, str(a))
        if res == None:
            return
        else:
            fileName = addFileName + '.png'
            filePath = str(a)
            uploadUrl = "%s/api/v2/space/attachment?apiKey=%s" % (url, apikey)
            files = {'file': (fileName ,open(filePath, 'rb'))}
            jsonData = requests.post(uploadUrl, files=files).json()
            id = json.dumps(jsonData["id"], indent=4)
            return id

def backlogAddAttachment(wikiId, attachIdLists):
    attachStr = '&attachmentId[]='
    try:
        for attachIdList in attachIdLists:
            if attachIdList == attachIdLists[0]:
                attachmentId = '{}{}'.format(attachStr, attachIdList)
            else:
                attachmentId = '{}{}{}'.format(attachmentId, attachStr, attachIdList)
        uploadUrl = "%s/api/v2/wikis/%s/attachments?apiKey=%s%s" % (url, wikiId, apikey, attachmentId)
        requests.post(uploadUrl).json()
    except UnboundLocalError as ue:
        print(ue)
        return

def newFile(wikiTitle, wikiContent):
    mfPath = "forAddition/" + wikiTitle + ".md"
    mf = open(mfPath, 'w',encoding="utf-8_sig")
    mf.write(wikiContent)
    mf.close()

def repFileName(beforeTitle):
    r = re.sub(r"[ .:*>/]", "_", beforeTitle)
    return  r

if  __name__  ==  '__main__':
    main()

コードの解説

全体の大まかな流れは以下の通りです。

  1. jsonファイルから必要な情報を取得する
  2. Backlog Wiki 用に記事タイトルを置換
  3. notesフォルダから画像ファイル名を取得
      ・notesフォルダ検索用に記事タイルを置換
      ・記事タイトルでnotesフォルダを検索して画像ファイル名を取得
  4. 添付ファイルの送信APIを実行し、レスポンスのIDを取得
  5. 記事本文中のimgタグをBacklogのMarkdown記法ルールに合わせて置換
  6. Wikiの追加APIに渡すmdファイルを作成
  7. Wikiの追加APIを実行し、Wiki IDを取得
  8. 添付ファイル追加APIを実行

①jsonファイルから必要な情報を取得

以下のようなネストがあるjsonファイルから
 ・記事カテゴリ名(変数:nodesName)
 ・記事タイトル(変数:beforeTitle)
 ・記事本文(変数:beforeWikiContent)
を取得します。
なお、掲載しているjsonファイルは「第1部 KibelaのWeb APIからjsonファイルを作る」にあるものと同じ内容です。

{
    "data": {
        "budget": {
            "cost": "xxxx"
        },
        "group": {
            "name": "NXTED",
            "folder": {
                "nodes": [
                    {
                        "id": 1,
                        "name": "動物のことわざ",
                        "notes": {
                            "totalCount": 2,
                            "nodes": [
                                 {
                                     "id": "[ID]",
                                     "title": "猫に小判 / 豚に真珠",
                                     "content": "## はじめに \n動物のことわざです。  \n以下の画像を参照ください。<img title='スクリーンショット 2022-10-17 123250.png' alt='スクリーンショット 2022-10-17 123250' src='/attachments/d7e3b97b-90dd-4ad3-ab667-11ky7A3r' width="330" data-meta='{"width":330,"height":254}'>,"height":254}'>,"height":254}'>牛に経文も類諺"
                                 },
                                 {
                                     "id": "[ID]",
                                     "title": "クラゲの風向かい",
                                     "content": "抵抗しても無駄なこと"
                                 }
                             ]
                         }
                    },
                    {
                        "id": 2,
                        "name": "植物のことわざ",
                        "notes": {
                            "totalCount": 1,
                            "nodes": [
                                {
                                    "id": "[ID]",
                                    "title": "3/30 飢えに臨みて苗を植える",
                                    "content": "手遅れです。\n<img title='スクリーンショット 2022-10-17 123250.png' alt='スクリーンショット 2022-10-17 123250' src='/attachments/d7e3b97b-90dd-4ad3-a846-14f8681ayy6' width="330" data-meta='{"width":330,"height":254}'>"
                                }
                            ]
                        }
                    }
                ]
            }
        }
    }
}

スクリプトで該当するコード↓

# 1記事目だと「動物のことわざ」が該当
nodesName = data["data"]["group"]["folder"]["nodes"][groupCount]["name"]

# 1記事目だと「猫に小判 / 豚に真珠」が該当
beforeTitle = data["data"]["group"]["folder"]["nodes"][groupCount]["notes"]["nodes"][i]["title"]

# 1記事目だと「## はじめに\動物のことわざです。  \n以下の画像を・・・」が該当
beforeWikiContent = data["data"]["group"]["folder"]["nodes"][groupCount]["notes"]["nodes"][i]["content"]

②Backlog Wiki 用に記事タイトルを置換

Backlog Wikiタイトルで階層によるグループ分けをするため、記事タイトルを置換します。
(参考:第1部 Backlog-Wikiのツリー表示を利用する

# 記事タイトル中の「/(半角)」を「/(全角)」にして意図しないツリー表示を回避
# 1記事目だと「猫に小判 / 豚に真珠」 → 「猫に小判 / 豚に真珠」
wikiTitle = beforeTitle.replace('/','/')

# カテゴリ名と記事タイトルでツリー表示されるタイトル名にする
# 1記事目だと「動物のことわざ/猫に小判 / 豚に真珠」となる
wikiName = nodesName + '/' + wikiTitle

# コードの前に改行を追加してコード表示されるようにする(※)
wikiContent = beforeWikiContent.replace('```','\n```')

※実際にスクリプトを実行してみた際、Backlogのmd記法ルールのためか、コードブロック(バッククォート×3)の前の改行の有無で正しくコードブロックにならないことがわかりました。
そのため、記事本文のコードブロックに対して先頭に改行を入れることで期待通りにコードブロック化するようにしています。

③notesフォルダから画像ファイル名を取得

記事本文で使用されているimgタグのファイルパスのままではattachmentsフォルダの画像ファイル名と一致せず、対象のファイルを取得できません。
そのため、notesフォルダ内の記事本文から画像ファイル名を取得します。
(参考:第1部 画像を挿入するimgタグを置換〜)

# notes検索用にreplace
reNameForNotes = repFileName(beforeTitle)
# 詳細は第1部の「対応②」を参照
notesLines = []
notesStr = ''
# notesフォルダ内をnotes検索用に置換した記事タイトルで検索
for p in BASE_FOLDER.glob("*" + reNameForNotes  +  ".md"):
    with open(r'./{0}' .format(p), encoding='utf-8') as f:
        notesLines = f.readlines()
    notesStr = ''.join(notesLines)
    # 画像ファイル名を取得
    notesLists = re.findall('<img title=\'*.* src=\'../attachments/([0-9]*).* width=', notesStr)

def repFileName(beforeTitle):
    # 1記事目だと「猫に小判 / 豚に真珠」 → 「猫に小判___豚に真珠」
    r = re.sub(r"[ .:*>/]", "_", beforeTitle)
    return  r

④添付ファイルの送信APIを実行し、レスポンスのIDを取得

Backlog Wiki に画像ファイルを添付するAPIを実行するために必要なIDを取得します。
(参考:第2部 Backlog APIとは

attachIdLists = []
for addFileName in notesLists:
    # 添付ファイルの送信APIを実行、レスポンスIDを取得
    attachmentId = backlogSendAttachment(addFileName)

# 詳細は第2部の「添付ファイルの送信」を参照
def backlogSendAttachment(addFileName):
    for a in ATTACHMENT_FOLDER.glob(addFileName + ".png"):
        res = re.search(addFileName, str(a))
        if res == None:
            return
        else:
            fileName = addFileName + '.png'
            filePath = str(a)
            uploadUrl = "%s/api/v2/space/attachment?apiKey=%s" % (url, apikey)
            files = {'file': (fileName ,open(filePath, 'rb'))}
            jsonData = requests.post(uploadUrl, files  =  files).json()
            id = json.dumps(jsonData["id"], indent=4)
            return id

⑤記事本文中のimgタグをBacklogのMarkdown記法ルールに合わせて置換

BacklogのMarkdown記法ルールに合わせ、imgタグを![image]()の形式になるように置換します。
(参考:過去記事<移行のための調査と準備>  文字列の先頭からマッチするかを調べる

# 記事本文に添付画像がある場合、レスポンスIDをリストに格納
if attachmentId != None:
    attachIdLists.append(int(attachmentId))
#先頭から一致するものがあるか調べる
imgPos = re.search('<img title=\'.+?\' alt=\'.+?\' src=\'/attachments/.+?\' width="[0-9]*" data-meta=\'{"width":[0-9]*,"height":[0-9]*}\'>', wikiContent)
try:
    # マッチした文字列前後で切り分けて置換
    span = imgPos.span()
    wikiContent = wikiContent[:span[0]] + '![image](' + addFileName + '.png)' + wikiContent[span[1]:]
except AttributeError as ae:
    print(ae)

⑥Wikiの追加APIに渡すmdファイルを作成

Wikiの追加APIに渡すためのmdファイルを作成します。
このmdファイルの本文がそのままBacklog Wikiとして追加されます。

newFile(wikiTitle, wikiContent)

def newFile(wikiTitle, wikiContent):
    # 任意のファルダに作成したmdを格納
    mfPath = "forAddition/" + wikiTitle + ".md"
    mf = open(mfPath, 'w',encoding="utf-8_sig")
    mf.write(wikiContent)
    mf.close()

⑦Wikiの追加APIを実行し、Wiki IDを取得

⑥のファイル情報を元に、Backlog Wikiを追加します。
返却されるidは次の添付ファイル追加APIで必要となるので、取得しておきます。
(参考:第2部 Wikiページの追加

wikiId = backlogAddWiki(wikiName, wikiTitle)

def  backlogAddWiki(wikiName, wikiTitle):
    uploadUrl = "%s/api/v2/wikis?apiKey=%s&projectId=%s&name=%s" % (url, apikey, projectId, wikiName)
    # ⑥で作成したmdファイルを読み込んでクエリパラメータに入れる
    # 第2部「requests.post()の引数にクエリパラメータを指定する」を参考
    mfPath = "forAddition/" + wikiTitle + ".md"
    try:
        readF = open(mfPath,'r',encoding="utf-8_sig")
        textFile = readF.read()
        readF.close()
        payload = {'content': textFile, 'mailNotify': 'false'}
        jsonData = requests.post(uploadUrl, params=payload).json()
        id = json.dumps(jsonData["id"], indent=4)
        return  id
    except FileNotFoundError as fnfe:
        print(fnfe)
        return
    except KeyError as ke:
        print(ke)
        return

⑧添付ファイル追加APIを実行

⑦で取得したWikiページのIDと添付ファイルの送信APIから返却されたIDを引数に渡すことで添付ファイル追加APIを実行します。
(参考:第2部 Wiki添付ファイルの追加

backlogAddAttachment(wikiId, attachIdLists)

def backlogAddAttachment(wikiId, attachIdLists):
    attachStr = '&attachmentId[]='
    try:
        for attachIdList in attachIdLists:
            if attachIdList == attachIdLists[0]:
                attachmentId = '{}{}'.format(attachStr, attachIdList)
            else:
                attachmentId = '{}{}{}'.format(attachmentId, attachStr, attachIdList)
        uploadUrl = "%s/api/v2/wikis/%s/attachments?apiKey=%s%s" % (url, wikiId, apikey, attachmentId)
        requests.post(uploadUrl).json()
    except UnboundLocalError as ue:
        print(ue)
        return

おわりに

KibelaからBacklogに記事を移行について、調査の段階から実際にスクリプトを作成するまでの内容を備忘録としてまとめさせていただきました。
pythonを使用したスクリプトを作成するのはこれが初めての試みであったため、とても良い勉強になりました。
また、Backlog独自のmd記法など、考慮しなければならない点も多くあり、調査の段階から順序立てて必要な要件をまとめることができたのも、自分としては良かった点に感じています。

一方で、コードの内容としては決して満足のできるものではなく、より見やすく、より処理時間のかからない書き方ができるのだろうなと思っています。
第2部でも触れたように、書式化演算子%や文字列型の format メソッドを使った方法やフォーマット済み文字列リテラルなど、それぞれの書き方を比較したかったがために統一性のないコードとなったままです。
こちらについても、それぞれについてより詳しく解説を加えつつ、統一した場合のコードについても取り上げていきたいと思います。


以上です。最後まで閲覧いただきありがとうございました。



参考:

Discussion