エクスポートされた Scrapbox の JSON ファイルから Markdown を生成するツールを作った

## はじめに

「エンジニアになってもう 5 年だというのに何のアウトプットもしていないのはどうなんだ？」
という危機感のようなものを抱いたため、Qiita に投稿することにしました。
初めての記事ということで読みづらい点が多々あるかもしれませんが、どうかご容赦ください。

## 概要

社内の活動で Scrapbox を使うことになったのですが、社内の資産として残すために、最終的には Scrapbox 内のページ達をファイルサーバに置きたいという話になりました。
Scrapbox には、全ページ内容を JSON ファイルとしてエクスポートする機能があるのですが、流石にそのままでは読みづらいです。
そこで、Markdown に変換して保存してくれるツールを探しましたが、いい感じのツールを見つけられなかったので、Python を使って自作してみました。

エクスポートされた JSON ファイルはこのような形式になっています。（metadata なしでエクスポートしています。）

```john-project.json
{
  "name": "john-project",
  "displayName": "john-project",
  "exported": 1598595295,
  "pages": [
    {
      "title": "Scrapboxの使い方",
      "created": 1598594744,
      "updated": 1598594744,
      "id": "000000000000000000000000",
      "lines": [
        "Scrapboxの使い方",
        "Scrapboxへようこそ。このページは自由に編集して活用できます。",
        "",
        "このプロジェクトにメンバーを招待しましょう",
        // 中略
        " [企業の利用事例を掲載しています https://scrapbox.io/case]",
        ""
      ]
    },
    {
      "title": "最初の行は見出し",
      "created": 1598594777,
      "updated": 1598595231,
      "id": "111111111111111111111111",
      "lines": [
        "最初の行は見出し",
        "[** アスタリスク2つは見出し風]",
        " インデントで箇条書きリスト",
        " \t個数を増やすと更に字下げ",
        " [[太字]]や[/ italic]、[- 打消し線]が使える",
        " \tこのように[-/* italic]組み合わせも可能",
        "[ページのリンク]や[外部リンク https://scrapbox.io]",
        "code:test.py",
        "　for i in range(5):",
        "     print('[* コードブロック内は無視]')",
        "",
        "`[- code]`も無視",
        "",
        "table:表形式",
        " aaa\tbbb\tccc",
        " あああ\t\tううう",
        "\t111\t222\t333",
        ""
      ]
    }
  ]
}
```

作成したツールを使うと、以下のような Markdown ファイルに変換されます。（※Qiita 上の見た目を整えるために、後からコードブロックと表の行末に全角スペースを加えている箇所があります）

````最初の行は見出し.md
# 最初の行は見出し
### アスタリスク2つは見出し風
- インデントで箇条書きリスト
  - 個数を増やすと更に字下げ
- **太字**や _italic_ 、 ~~打消し線~~ が使える
  - このように _~~**italic**~~_ 組み合わせも可能
[ページのリンク]()や[外部リンク](https://scrapbox.io)
code:test.py
```　
　for i in range(5):
     print('[* コードブロック内は無視]')
```　

`[- code]`も無視

table:表形式
|aaa|bbb|ccc|　
|-----|-----|-----|-----|
|あああ||ううう|
|111|222|333|


````

見た目は以下のように変換されます。少々改行の扱いが甘いですが、そこそこ見やすくなっています。![scrapbox_sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608187/bb4adaba-6924-398a-3b25-38c7b823c1a4.png)

![最初の行は見出し.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608187/27065cea-a102-5d57-16f8-98e19b62accb.png)

## 方針

Scrapbox を初めて利用するメンバー（私含む）が多く、あまり凝った書き方はしなさそうだったので、完璧な変換は目指さずに使いそうな記法についてのみ変換することにしました。
変換手法は単純で、正規表現を用いて Scrapbox の記法で書かれている箇所を見つけ、それらをひたすら Markdown の形式に置換していくだけです。
Python をインストールしていない人も使えるように、最後に exe 化します。

## 環境

Windows10、Python3.7 を使いました。

## 実装

### ファイル読み込み

第一引数で JSON のファイル名を受け取るようにしておきます。
こうしておくと、exe 化したファイルに JSON ファイルをドラッグ&ドロップするだけで使えるようになります。
また、Markdown を出力するためのフォルダも作っておきます。

```python
filename = sys.argv[1]
with open(filename, 'r', encoding='utf-8') as fr:
    sb = json.load(fr)
    outdir = 'markdown/'
    if not os.path.exists(outdir):
        os.mkdir(outdir)
```

### 変換

ここからは、各ページ・各行を順番に変換していきます。
各見出しの（）内に変換対象を書いておきます。

#### 見出し（先頭行）

Scrapbox では最初の行が見出しと解釈されるので、1 行目は先頭に`# `（シャープ+半角スペース）を付けて見出しにします。

```python
for p in sb['pages']:
    title = p['title']
    lines = p['lines']
    is_in_codeblock = False
    with open(f'{outdir}{title}.md', 'w', encoding='utf-8') as fw:
        for i, l in enumerate(lines):
            if i == 0:
                l = '# ' + l
```

#### コードブロック（`` `code:hoge.ext` ``）

Scrapbox では`code:hoge.ext`でコードブロックが表現できます。
行の先頭が空白である限り、コードブロックが続きます。
コードブロック内は変換したくないので、今見ている行がコードブロック内であるかを判定しつつ処理を進めます。
コードブロックに入った時とコードブロックから出た時に、Markdown の記法である` ``` `を加えます。

````python
# コードブロックの処理
if l.startswith('code:'):
    is_in_codeblock = True
    l += f'\n```'
elif is_in_codeblock and not l.startswith(('\t', ' ', '　')):
    is_in_codeblock = False
    fw.write('```\n')
########
# 中略
########
# コードブロックでなければ変換
if not is_in_codeblock:
    l = convert(l)
````

#### 表（`` `table:hoge` ``）

Scrapbox では`table:hoge`で表が表現できます。
行の先頭が空白である限り、表が続きます。
Scrapbox の表にはヘッダがないのですが、Markdown ではヘッダなしの表を表現できないので、強制的に 1 行目をヘッダと解釈します。
セルはタブで区切られているので、`|`に変換します。
行頭の空白は、タブ・半角スペース・全角スペースがあり得るので、泥臭く変換していきます。

```python
if l.startswith('table:'):
    is_in_table = True
elif is_in_table and not l.startswith(('\t', ' ', '　')):
    is_in_table = False
if is_in_table:
    row += 1
    if row != 0:
        l = l.replace('\t', '|') + '|'
        if l.startswith(' '):
            l = l.replace(' ', '|', 1)
    if row == 1:
        col = l.count('|')
        l += f'\n{"|-----" * col}|'
```

#### コード（`` `hoge` ``）

コード内は変換したくないので、各記法の変換処理の前に、コード箇所を削除する処理を挟みます。Markdown と同じ書き方なので、単に削除するだけで OK です。

```python
def ignore_code(l: str) -> str:
    for m in re.finditer(r'`.+?`', l):
        l = l.replace(m.group(0), '')
    return l
```

#### ハッシュタグ（`#hoge`）

これが文字列の先頭に書いてあると Markdown では見出しと解釈されてしまう可能性があります（ビューアーによって見え方が異なるようです）。
そのため、`` ` ``で囲んでコード扱いにしています。

```python
def escape_hash_tag(l: str) -> str:
    for m in re.finditer(r'#(.+?)[ \t]', ignore_code(l)):
        l = l.replace(m.group(0), '`' + m.group(0) + '`')
    if l.startswith('#'):  # 1行全てタグの場合
        l = '`' + l + '`'
    return l
```

#### 箇条書きリスト（インデント）

インデントの個数を数えて Markdown の形式に置換しています。

```python
def convert_list(l: str) -> str:
    m = re.match(r'[ \t　]+', l)
    if m:
        l = l.replace(m.group(0),
                      (len(m.group(0)) - 1) * '  ' + '- ', 1)
    return l
```

#### 太字（`[[hoge]]`、`[** hoge]`、`[*** hoge]`）

Scrapbox では`[[hoge]]`もしくは`[* hoge]`のようにすると太字になります。
また、後者の記法で`[** hoge]`のようにアスタリスクを増やすと、文字が大きくなります。

後者の記法の内、アスタリスクが 2 個と 3 個の記法はMarkdown の見出しのように使っていたので、それに合わせて変換しています。それ以外については、他の装飾と同時に使われる場合があるので、別途まとめて変換します。

```python
def convert_bold(l: str) -> str:
    for m in re.finditer(r'\[\[(.+?)\]\]', ignore_code(l)):
        l = l.replace(m.group(0), '**' + m.group(1) + '**')
    m = re.match(r'\[(\*\*|\*\*\*) (.+?)\]', ignore_code(l))  # おそらく見出し
    if m:
        l = '#' * (5 - len(m.group(1))) + ' ' + \
            m.group(2)  # Scrapboxは*が多い方が大きい
    return l
```

#### 文字装飾（`[* hoge]`、`[/ hoge]`、`[- hoge]`、`[-/* hoge]`等）

Scrapbox では太字以外に、斜体`[/ hoge]`や打消し線`[- hoge]`が使えます。これらは組み合わせて`[-/* hoge]`のように使うことができるので、同時に処理します。

```python
def convert_decoration(l: str) -> str:
    for m in re.finditer(r'\[([-\*/]+) (.+?)\]', ignore_code(l)):
        deco_s, deco_e = ' ', ' '
        if '/' in m.group(0):
            deco_s += '_'
            deco_e = '_' + deco_e
        if '-' in m.group(0):
            deco_s += '~~'
            deco_e = '~~' + deco_e
        if '*' in m.group(0):
            deco_s += '**'
            deco_e = '**' + deco_e
        l = l.replace(m.group(0), deco_s + m.group(2) + deco_e)
    return l
```

（ハイライトがおかしいですが直せませんでした）

#### リンク（`[URL タイトル]`、`[タイトル URL]`、`[hoge]`）

Scrapbox では`[URL タイトル]`または`[タイトル URL]`で外部へのリンクを表現します。
厳密なことは考えず、`http`で始まっている方を URL と解釈することにしました。
また、`[hoge]`のような形式は、Scrapbox 内の別ページへのリンクとなっています。Markdown 出力後はこのリンクは使えませんが、後ろに`()`を付けることで、見た目だけリンクっぽくしています。

```python
def convert_link(l: str) -> str:
    for m in re.finditer(r'\[(.+?)\]', ignore_code(l)):
        tmp = m.group(1).split(' ')
        if len(tmp) == 2:
            if tmp[0].startswith('http'):
                link, title = tmp
            else:
                title, link = tmp
            l = l.replace(m.group(0), f'[{title}]({link})')
        else:
            l = l.replace(m.group(0), m.group(0) + '()')
    return l
```

### exe 化

最後に pyinstaller を用いて exe 化します。
コンソール表示なしで、1 つの exe ファイルにします。

```shell
pip install pyinstaller
pyinstaller sb2md.py -wF
```

exe ファイルに JSON ファイルをドラッグ&ドロップすればプログラムが実行できます。

## 最後に

今回作成したコードは[GitHub](https://github.com/john-question/sb2md)に置いてあります。
こういうちょっとした処理を書くときには、やはり Python は便利だなと感じます。

Scrapbox を使い始めたのはつい先日のことであり、今はあまり使いこなせていませんので、別の使い方が出てくるようになったら順次アップデートしていく予定です。