YDiary

メモ的な

Qiita:TeamをやめてCrowiに移行した話 ~移行編~

この記事は,Silbird Advent Calendar 2017の14日目の記事です.

はじめに

qiita.com qiita.com

に引き続きのCrowiネタです.

さて,上の記事で述べられている通り,Silbirdでは社内のドキュメントサービスをQiita:TeamからCrowiに移行しました.
移行に際して問題となるのが,これまでQiita:Teamに書いてきたドキュメントをどうするのかという点です.

この記事では,Qiita:TeamからCrowiへ記事を移行する方法について紹介します.

Qiita:TeamからCrowiへ記事を移行する

次のような手順をたどることで,Qiita:TeamからCrowiへ記事を移行することが出来ます.

  1. Qiita:Teamから記事の本文を取得する
  2. Qiita:Teamから記事の画像を取得する
  3. Crowiに記事の本文を投稿する
  4. Crowiに記事の画像を投稿する
  5. Crowiに投降した記事の本文の古い画像Urlを投稿した画像Urlに置き換える
  6. 全ての記事に対して 1. から 5. まで完了したら,記事中のQiita:TeamのUrlをCrowiに投降した記事のUrlに置き換える

これらの手順について,コードを例に挙げながら順に説明していきます.

なお,Qiita:Teamの記事をCrowiに移行するツールとしてb4b4r07/qiita2crowiが既に存在しますが,試したところメンテされていないようで,うまく動作しませんでした.
そのため,処理を参考にさせて頂きつつ,例のごとくC#で一から実装しました.記事の最後にて公開しておりますので,是非お使いください.

1. Qiita:Teamから記事の本文を取得する

幸いなことに,Qiita:Teamでは記事のエクスポートを行うことができます.

そのため,わざわざ記事をスクレイピングする必要は無いのですが,エクスポートされるJSONファイルに含まれているのは記事の本文のみであり,画像はQiita:Teamのサーバへのリンクのままとなっています.

したがって,記事をエクスポートしたからと言って安心してQiita:Teamを解約してしまうと,記事の画像をすべて失うことになるため注意が必要です*1

エクスポートしたJSONの構造

エクスポートしたJSONは次のような構造になっています(2017年10月11日現在).

{
  "articles": [
    {
      "rendered_body": "<p>ここでは、Qiita:Teamをチーム内に浸透させる...",
      "body": "ここでは、Qiita:Teamをチーム内に浸透させる...",
      "coediting": true,
      "comments_count": 0,
      "created_at": "2014-07-15T12:54:39+09:00",
      "group": null,
      "id": "51a3236299...",
      "likes_count": 0,
      "private": false,
      "reactions_count": 0,
      "tags": [
        {
          "name": "チュートリアル",
          "versions": []
        }
      ],
      "title": "運用のポイントを紹介します",
      "updated_at": "2014-07-15T12:54:39+09:00",
      "url": "https://silbird.qiita.com/shared/items/51a3236299...",
      "user": {
        "id": "kaneck",
        "permanent_id": 48982,
        "profile_image_url": "https://qiita-image-store.s3.amazonaws.com/0/..."
      },
      "comments": []
    },
    {
      "rendered_body": "<p>Qiita:Teamへようこそ!<br>\nこのサービスの特長は...",
...

このうち,bodyMarkdown形式の記事本文が格納されています.
また,rendered_bodyにはHTML形式の記事本文が格納されています.

2. Qiita:Teamから記事の画像を取得する

記事の画像は,上に示したJSONrendered_body 中からimgタグを抽出するのが簡単です. ホスト名で抽出しても良いのですが,Qiita:Teamでは記事を投稿した時期によって画像のURLのホストが異なる点に注意が必要です.

今回は,

new Regex("<img.*src=\"(https?://.*?)\"");

のような正規表現を使用して画像URLの抽出を行いました.

画像のURLを抽出したら後はダウンロードするだけなのですが,Qiita:Teamではしっかりと画像に対してもアクセス制御が行われているため,未ログインの状態では画像をダウンロードすることができません.
今回のためだけにログイン処理を実装するのも面倒なので,今回は単純に次に示すようにログイン中のブラウザのCookieから取得したセッションキーを使用することで画像のダウンロードを行いました.

var client = new HttpClient(new HttpClientHandler { UseCookies = false });
client.DefaultRequestHeaders.Add("Cookie", "secure_token=hoge; user_session_key=fuga");//QiitaのセッションCookie
var response = await client.GetAsync(imageUrl);
var image = await response.Content.ReadAsByteArrayAsync();

3. Crowiに記事の本文を投稿する

記事の本文と画像を入手したら,いよいよCrowiに記事を投稿していきます.

Crowiの操作に関しては,API経由で色々出来そうなことは分かっていたのですが,探してみたところ,どうも公式のドキュメント類は存在しないようです*2
そのため,Google先生に尋ねたり,Crowiのソースコードを見たり,リクエストを解析したりしながら試行錯誤しました.

CrowiへのAPI経由での記事の投稿は,こんな感じで /_api/pages.create にPOSTすることで行えます.

var param = new Dictionary<string, string>
{
  { "access_token", accessToken },//Crowiへのアクセストークン
  { "body", article.body },//Markdown形式の記事本文
  { "path", titlePrefix + article.user.id + "/" + ReplaceTitle(article.title) }//記事のパス
};

var content = new FormUrlEncodedContent(param);
var result = await client.PostAsync("https://crowi.example.jp/_api/pages.create", content);
var res = DynamicJson.Parse(await result.Content.ReadAsStringAsync());

if (res.ok == true) {
  //成功
} else {
  //失敗
}

Crowiへ記事を投稿する際の注意として,Qiita:Teamでの記事のタイトルをそのままCrowiの記事のパスに使用しようとした場合,次に示すような問題が発生する可能性があります.

  • 同名のタイトルの記事を作成しようとして失敗する
    Qiita:Teamでは同名の記事を作成可能ですが,Crowiでは同一のパスの記事を複数作成できないため.
  • タイトル中に変な記号があって記事の作成に失敗する
    上と同じく,Qiita:Teamではタイトル扱いでも,Crowiではパス扱いとなり,そのまま記事のUrlとなるためです.
    また,/に関してはディレクトリとして認識されてしまいます.

これらの問題に対応するには,次に示すようにタイトル中の一部の半角記号を全角記号に置換してしまうのがおススメです.

private static string ReplaceTitle(string title)
{
  return title
    .Replace('^', '^')
    .Replace('$', '$')
    .Replace('*', '*')
    .Replace('%', '%')
    .Replace('?', '?')
    .Replace('/', '/');
}

また,今回はQiita:Teamの記事に書き込まれたコメントに関しても移行させることにしました. コメントは,上に示したエクスポートしたJSONcommentsに格納されています.

CrowiにAPI経由でコメントを書き込むには,次のように /_api/comments.add にPOSTすることで行えます*3

if (article.comments_count > 0)
{
  foreach (dynamic comment in (object[])article.comments)
  {
    param = new Dictionary<string, string>
    {
      { "access_token", accessToken },//Crowiへのアクセストークン
      { "commentForm[page_id]", createdPageId },//作成したページのID(レスポンスに含まれます)
      { "commentForm[revision_id]", (BigInteger.Parse(createdPageId, NumberStyles.HexNumber) + 1).ToString("X") },//ちょっとよく分からなかった.ページのリビジョンを指定してる?
      { "commentForm[comment]", $"{comment.user.id} commented at {comment.updated_at}\n{comment.body}" }//コメント
    };
    content = new FormUrlEncodedContent(param);
    result = await client.PostAsync("https://crowi.example.jp/_api/comments.add", content);
    res = DynamicJson.Parse(await result.Content.ReadAsStringAsync());
  }
}

4. Crowiに記事の画像を投稿する

CrowiへのAPI経由での画像の投稿は,次に示すように /_api/attachments.add にPOSTすることで行えます.

public static async Task<string> Upload(string pageId, string filename, byte[] image)
{
  using (var client = new HttpClient())
  {
    client.DefaultRequestHeaders.ExpectContinue = false;
    using (var content = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)))
    {
      content.Add(new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(accessToken))), "access_token");//アクセストークン
      content.Add(new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(pageId))), "page_id");//画像を投稿するページID
      content.Add(new StreamContent(new MemoryStream(image)), "file", filename);//画像ファイル名

      var message = await client.PostAsync("https://crowi.example.jp/_api/attachments.add", content);
      var input = await message.Content.ReadAsStringAsync();

      return !string.IsNullOrWhiteSpace(input) ? Regex.Match(input, @"https://crowi\.example\.jp/files/[0-9a-f]*").Value : null;
    }
  }
}

上に示した Upload メソッドは,画像の投稿が成功すると投稿された画像のURLを返します.

5. Crowiに投降した記事の本文の古い画像Urlを投稿した画像Urlに置き換える

4.に示した Upload メソッドで取得したUrlで,記事中の古い画像Urlを置き換えます. 置き換えが完了した記事は,次のように /_api/pages.update にPOSTすることで更新することが可能です.

param = new Dictionary<string, string>
{
  { "access_token", accessToken },
  { "body", newBody },
  { "page_id", createdPageId }
};

content = new FormUrlEncodedContent(param);
result = await client.PostAsync("https://crowi.example.jp/_api/pages.update", content);
res = DynamicJson.Parse(await result.Content.ReadAsStringAsync());

6. 記事中のQiita:TeamのUrlをCrowiに投降した記事のUrlに置き換える

上記の, 1. から 6. までの操作を全ての記事に対して実行したら,最後の仕上げとして記事中のQiita:TeamへのUrlをCrowiでのUrlで置き換えます.

Crowiにログインした状態で https://crowi.example.jp/_api/pages.list?user=hoge にアクセスすると,指定したユーザが作成したページの一覧を取得することができます. ここでは,hogeの代わりにCrowiのアクセストークンを取得したユーザを指定します.

そして,取得したページの全てに対し,Qiita:TeamからエクスポートしたJSONに含まれる記事Urlを,CrowiでのUrlに置き換えて記事を更新すれば完成です.

移行プログラム

さて,長々と手順を説明してきましたが,この通り*4に記事を移行するプログラムを作成しましたので,是非お使いください.
JSON操作には,DynamicJsonを使用させて頂きました.

Qiita2Crowi.cs

このプログラムでは,手順の 1. から 5. までを行います.同名の記事が存在した場合はパスの最後に通番を付けて再作成を試みます.
記事のパスは,/{prefix}/{Qiitaでのユーザ名}/{記事タイトル}となります.

QiitaLinkFixer.cs

このプログラムでは,手順の 6. を行います.なお,こちらのプログラムは通番を付けて再作成された記事を想定していないため,そのような記事に対しては個別に対応してください.

おわりに

上記のようにすることで,Qiita:Teamの記事を全てCrowiに移行することが出来ました.

Crowiを使ってみた感想としては,Qiita:Teamよりも使いやすく,ユーザごとの階層も用意されているため,ちょっとした記事でも積極的に投稿するようになりました.
また,全員にユーザが付与されたため,これまでQiita:Teamに書き込む機会が無かった人でも書き込むようになりました.
ほかにも,プラグインなどで気軽に機能を拡張することが出来るのも,とても魅力的です.
皆さんもこれを機にCrowiを使ってみてはいかがでしょうか.

*1:試したところ,特に猶予期間があるわけでもなく,解約した瞬間にすべての画像ファイルにアクセスできなくなりました

*2:こんな記述が https://github.com/crowi/crowi/issues/248#issuecomment-336180216

*3:revision_idに関してはよく分からなかったので適当です.一応これで問題なく動作しました.

*4:処理の都合上順番は異なります