目次
esa から Confluence へのデータを移行するプログラムを作成してみました。
Google 先生から様々な方のプログラムを見つけてくることが出来ますが、こちらは、集大成版になります。
やりたいこと
esa から 「エクスポート」で出力した md ファイル を
Confluence の Rest API を用いて、Confluenceのスペース宛に入れます。
言語
ConfluenceのAPIを叩けばいいので、様々な言語で行えます。
- Ruby
- Python
- PHP
などで作成して紆余曲折を経ましたが、最終的には
node.js ( Javascript )
を用いて実装しました。
どの言語でも大丈夫そうなのですが、こちら実際にやってみると、様々なライブラリの癖に悩まされることになります。
今回やりたいことが全て、そして綺麗に出来たので、node.js を採用しました。
esaの固有の問題
「esaからエクスポートされたマークダウンファイルが、完全なマークダウンのデータではない」
ことが、esaのデータファイル固有の問題となります。
esaは大変柔軟な表現力を持っています。md ( マークダウン ) と、HTMLを両方同時に記述し、表現することが出来ます。それゆえ、出力された記事ファイルには、マークダウンと、普通のHTMLが含まれています。
それだけであればいいのですが、このHTMLが、大変不完全なものでも問題なく表示されるようになっています。
esaでは表示時にマークダウンだけ対応し、HTMLはそのまま出力しているのでしょう。ブラウザは不完全なHTMLでも表示することができるのです。
しかしConfluenceはXHTML準拠の独自規格になります。これは不完全なHTMLを全く受け付けません。
1 2 3 |
<span font=black>this in span this is not a link</a> <a> this is link </a> |
例えばこちらをみる限り、一行目はspanタグが閉じられていません。二行目は、aタグが始まっていないのに、閉じています。しかしこれはesaでは普通に表示されます。エラーは発生しません。
恐ろしいですね、ブラウザの曖昧さ加減。驚愕します。
当然ながら、Confluebnceではエラーです。
必要な機能
必要な機能を簡単に切り出します。
・マークダウンを正しく変換し、正しくXHTMLに正規化する
・Confluenceに正しくデータを入れる
その上、今回は、画像もなんとかします。ですので、アップロード済みの内容を読み取り、さらに画像URLを抽出し、画像をページにアップロードして、アップロードした後のURLに書き換えて再度保存する。
をやる必要があります。これを分解すると、
・アップロード済みの内容を読み取る。
・画像URLを抽出する
・画像データをページにアップロードする。
・再保存する。
手順が必要ですね。
機能
全体の構成をまるごと掲載はしません。ですが、特徴的ないくつかの機能ごとに、どのように行うか、関数を掲載していきます。
実際には、これらの関数をきちんと呼び出すために、いかにプログラムを構成するかが、テクニカルなところだと思います。javascriptは基本的に非同期で、便利なのですが、うまく扱うことが必要です。
confluenceの記事を、正しいXHTMLにする。
これには様々なやり方がありますが、こちらの方法で、ある程度、きちんとしたものが完成します。
お試しするとわかりますが、いくつか完璧ではないです。markdown-itというライブラリを利用していますが、そのリンク自動検出が壊れている影響です。執筆時点では、この不具合は直っていないようでしたので、最低限、img タグのURLは直しています。また、タスクリストについて変換し、Confluenceでは使えないタグも除去しています。
このライブラリは、僕が様々な言語で試した中では、一番綺麗にマークダウンと、XHTML正規化を行うライブラリです。
また、制御文字が入っていても、Confluenceが拒否するので、これを除去します。(ブラウザではもちろんエラーが起きません)
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 |
function markdown2html(markdownText){ // markdown-itのインスタンスを作成 const md = new MarkdownIt({ // HTMLタグをエスケープしない(セキュリティに注意) html: false, // 改行を<br>タグに変換 breaks: false, // エンティティを数値文字参照に変換 numericEntities: true, // タグをautocloseする(例:<img>) xhtmlOut: true, // リンクの自動検出を有効化 linkify:true, // セキュリティモード(falseの場合、セキュリティに注意) safe: false, // プレースホルダー文字(例:'[空白]') placeholder: ' ' }); // console.log(markdownText) markdownText = markdownText.replace(/<detail>/g,"<code>") markdownText = markdownText.replace(/<\/detail>/g,"</code>") markdownText = markdownText.replace(/<summary>/g,"") markdownText = markdownText.replace(/<\/summary>/g,"") markdownText = removeControlCharacters(markdownText) html = md.render(markdownText); html = html.replace(/<img(.*)>/g, (t,m)=>{ m = m.replace(new RegExp(""","g"), '"') pattern = "" if( m.includes("href")){ pattern = /href="([^"]+)"/; }else{ pattern = /src="([^"]+)"/; } const matches = m.match(pattern); return '<img src="'+matches[1]+'" width="100%" />' }); html = html .replace(/<li>\[\] (.*?)<\/li>/g, '<li><ac:task-list><ac:task><ac:task-status>incomplete</ac:task-status><ac:task-body><span class="placeholder-inline-tasks">$1</span></ac:task-body></ac:task></ac:task-list></li>') .replace(/<li>\[x\] (.*?)<\/li>/g, '<li><ac:task-list><ac:task><ac:task-status>complete</ac:task-status><ac:task-body><span class="placeholder-inline-tasks">$1</span></ac:task-body></ac:task></ac:task-list></li>') return html } function removeControlCharacters(inputString) { // 改行以外の制御文字を正規表現で検索して削除 return inputString.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, ''); } |
Confluenceに記事をUPLOADする
記事をきちんとUPLOADするには、
・すでに同名の記事があるか調べる
・記事があれば、そのIDを取得する。
・ID取得できたなら、そのバージョンを取得する
・上書きする
・なければ、そのまま記事を追加する
というフローになります。
Confluenceはスペース内部の記事名が、重複することが出来ません。それゆえ、記事タイトル名がkeyになります。
さらに、上書きするには、その記事のバージョン情報を事前に取得し、きちんとインクリメントする必要があります。
このコードでは、updatePage( “タイトル”, “HTMLデータ”, 紐づける親ID, 完了後に実行する関数) と呼び出すだけで、全て行います。
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 |
function updatePage(title,data,parentId,f){ findPage(title, function(id){ if( id == null ){ postPage(title, data, parentId, f) }else{ getVer(id, function(ver){ putPage(id, ver, title, data,parentId,f) }) } }) } function findPage(title,f){ if( !f ){ f = function(){} } token = Buffer.from(userName+":"+bearerToken).toString('base64'); title = encodeURIComponent(title) axios.get(confluenceApiUrl+'/wiki/rest/api/content?spaceKey='+spaceName+'&title='+title, { headers: { 'Authorization': `Basic ${token}`, 'Content-Type' : 'application/json' } }) .then(response => { if( !!response && !!response.data && !!response.data.results && !!response.data.results[0] && !!response.data.results[0].id ){ f(response.data.results[0].id) }else{ f(null) } }) .catch(error => { f(null) }); } function putPage(id, ver, title, data, parentId,f){ if( !f ){ f = function(){}} const requestBody = { title: title, type: 'page', space: { key: spaceName // Confluenceのスペースのキーを指定 }, body: { storage: { value: data, representation: 'storage' } }, version: {number:(ver + 1)} }; if( parentId > 0 ){ requestBody.ancestors = [{id: parentId }] } // POSTリクエストを送信 token = Buffer.from(userName+":"+bearerToken).toString('base64'); axios.put(confluenceApiUrl+"/wiki/rest/api/content/"+id, requestBody, { headers: { 'Authorization': `Basic ${token}`, 'Content-Type' : 'application/json' } }) .then(response => { f(id) }) .catch(error => { }); } function getVer(id,f){ token = Buffer.from(userName+":"+bearerToken).toString('base64'); axios.get(confluenceApiUrl+'/wiki/rest/api/content/'+id+'/version', { headers: { 'Authorization': `Basic ${token}`, 'Content-Type' : 'application/json' } }) .then(response => { ver = response.data.results[0].number f(ver) }) .catch(error => { }); } function postPage(title, data, parentId, f){ if( !f ){ f = function(){}} const requestBody = { title: title, type: 'page', space: { key: spaceName }, body: { storage: { value: data, representation: 'storage' } } }; if( parentId > 0 ){ requestBody.ancestors = [{id: parentId }] } // POSTリクエストを送信 token = Buffer.from(userName+":"+bearerToken).toString('base64'); axios.post(confluenceApiUrl+"/wiki/rest/api/content/", requestBody, { headers: { 'Authorization': `Basic ${token}`, 'Content-Type' : 'application/json' } }) .then(response => { if( !!response && !!response.data && !!response.data.id ){ f(response.data.id) } }) .catch(error => { // console.error(error.response.data.message, toLog(title)); }); } |
confluenceApiUrl、userName、bearerToken、spaceNameはそれぞれ、自分のConfluenceのURL、ユーザー名(メールアドレス) トークン(Conflueneの、ユーザーに紐づいているトークン)、スペース名です。
axios というライブラリを利用しています。
ページ内の画像URLから、ページに画像をUPLOADする。
今回の場合、画像はURLさえわかれば誰でも見れる場所にあります。
もしそうでない場合は、認証情報をつけて画像を取得する必要があります。このコードでは、ローカルにDLせず、そのままstreamで受け取り、ページにUPLOADします。
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 |
async function uploadImage(imageURL, id) { const token = Buffer.from(userName + ":" + bearerToken).toString('base64'); const apiUrl = `${confluenceApiUrl}/wiki/rest/api/content/${id}/child/attachment`; const imageResponse = await axios.get(imageURL, { responseType: 'stream' }); // ファイルの拡張子に基づいてファイル名を設定 const fileExtension = imageURL.split('.').pop().toLowerCase(); let name = imageURL.replace('https://img.esa.io/uploads/production/attachments/','') filename = name.replace(/\//g,'_') const formData = new FormData(); formData.append('file', imageResponse.data, filename); try { await axios({ method: 'POST', url: apiUrl, headers: { 'Authorization': `Basic ${token}`, 'X-Atlassian-Token': 'nocheck', 'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}` }, data: formData }) .then((response) => { console.log('Image uploaded successfully !') }) .catch((error) => console.error('Error uploading image:', error.message)); } catch (error) { console.error('Error with the axios request:', error.message); } } |
注意点ですが、このContent-Typeをこのように設定しないと、失敗します。
あるスペース内にある、全ての記事を見つけ出す。
最後に、あるスペース内にある、全ての記事を取得するコードです。
これと上記の関数を組み合わせることで、Confluenceの記事の書き換えができますね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
function findPageRecursive(space,f,nextUrl){ if( !f ){ f = function(){} } token = Buffer.from(userName+":"+bearerToken).toString('base64'); theUrl = confluenceApiUrl+'/wiki/rest/api/content?limit=1&spaceKey='+spaceName; if( nextUrl ){ theUrl = confluenceApiUrl+'/wiki/'+nextUrl } axios.get(theUrl, { headers: { 'Authorization': `Basic ${token}`, // Bearerトークンをヘッダーに含める 'Content-Type' : 'application/json' } }) .then(response => { if( !!response && !!response.data && !!response.data.results && !!response.data.results[0] && !!response.data.results[0].id ){ f(response.data.results[0].title, response.data.results[0].id, response.data._links.next, response.data.results[0]._expandable.ancestors ) }else{ f(null, null) } }) .catch(error => { f(null) }); } |
この関数は扱いにコツがあり、ご覧の通り、再起的にこのfindPageRecursiveを呼ぶことを想定しています。渡された関数の第三引数としてnextURLが渡されますので、これを利用して、再度同じ関数を呼びます。すると、次のページが取得できます。
移行はイバラの道
一見簡単そうなこのデータ移行ですが、完全に行うには、様々な知見が必要になります。
どこまでの完全性と、コストを取るべきか、最初に考える方がいいでしょう。
ちなみに、全てのタグを削除して、文字データだけ取得すると、ほとんどの苦労はなくなります。