7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Markdown×Confluence APIで設計書の一括同期を自動化する

7
Posted at

はじめに

GMOコネクトの石井です。

Confluenceで設計書を管理していると、こんな悩みが出てきます。

  • 変更履歴がConfluenceのページ履歴頼みで、差分が追いにくい
  • 複数人で同時に編集すると競合する
  • レビューの仕組みがない(誰がいつ何を変えたのかわかりにくい)

じゃあGitで管理しよう、となるのですが、今度は別の問題が出ます。Gitで管理しているMarkdownをConfluenceに反映する作業が手動になるんですよね。ページを開いて、タイトルを設定して、親ページを選んで、Markdownを貼って、draw.ioを添付して…。それが数十ページ。しかも更新のたびに。

「Gitで書いてConfluenceに同期する」を自動化して、約6時間→約7分に減らした話をします。

先にまとめ

項目 Before(手動) After(自動化)
概要ページ(20件) 1ページ5分 × 20 = 100分 約2分
処理設計ページ(90件) 1ページ3分 × 90 = 270分 約5分
draw.io添付(9件) 手動で添付 自動
合計 約6時間 約7分

やったことはシンプルで、以下の3つです。

  1. Markdownファイルの先頭にHTMLコメントでConfluence用メタデータを埋め込む
  2. シェルスクリプトで mark + Confluence REST API を使って一括アップロード
  3. draw.ioマクロの挿入とファイル添付も自動化

全体アーキテクチャ

GitHub上のMarkdown
    │
    ├── 概要/ (20ファイル)
    │     └── drawio/ (9ファイル)
    └── 処理設計/ (16フォルダ, 90ファイル)
          │
          ▼
    ┌─────────────────────────┐
    │ add_confluence_meta.sh  │ ← H1見出しからメタコメントを自動生成
    └─────────────────────────┘
          │
          ▼
    ┌─────────────────────────┐
    │  upload_confluence.sh   │ ← markコマンド + REST API
    │  ┌───────────────────┐  │
    │  │ drawioマクロ挿入  │  │ ← 一時ファイルに書き出し、元MDは汚さない
    │  │ markでアップロード│  │
    │  │ REST APIで添付    │  │
    │  └───────────────────┘  │
    └─────────────────────────┘
          │
          ▼
    Confluence (Space: MYPROJECT)
    └── 親ページ
          ├── ap_001:概要ページ (drawio添付済み)
          │     ├── 初期処理
          │     ├── 入力画面表示
          │     └── 入力画面サブミット
          └── ap_002:概要ページ
                └── ...

2つのスクリプトで役割を分けています。add_confluence_meta.sh がMarkdownにメタデータを付与し、upload_confluence.sh がアップロードとdraw.io添付を担当します。

markツールについて

Confluenceへのアップロードには mark を使っています。MarkdownファイルをConfluenceのページとして直接アップロードできるCLIツールです。

brew install kovetskiy/mark/mark

markはファイル先頭のHTMLコメントでアップロード先を決めます。ここに乗っかる形でメタデータ管理を設計しました。

HTMLコメントによるメタデータ埋め込み

なぜHTMLコメントか

メタデータの埋め込み方法として、YAMLフロントマター(---で囲む形式)も検討しましたが、HTMLコメント形式にしました。

  • markの仕様に合致: markが読み取るメタデータ形式がHTMLコメント
  • Markdownの可読性を維持: GitHubやエディタでプレビューしても表示されない
  • 既存のドキュメントに影響しない: ファイル先頭に2行追加するだけ
<!-- Space: MYPROJECT -->
<!-- Title: ap_001:ユーザー入力フォーム -->

# ap_001:ユーザー入力フォーム

### 概要
| 項目 | 内容 |
| --- | --- |
| ワークフローID | ap_001 |
...

Space でConfluenceのスペースキーを、Title でページタイトルを指定します。

add_confluence_meta.sh:メタコメントの自動挿入

概要ページは手動でメタコメントを付けましたが、処理設計は90ファイルもあるので自動化しました。

H1見出しをタイトルに流用する

各MarkdownファイルにはすでにH1見出し(# タイトル)が存在します。これをそのまま <!-- Title: --> に使うことで、タイトルの二重管理を避けました。

# H1タイトルを取得
title=$(grep -m1 '^# ' "$f" | sed 's/^# //')

冪等性の確保

スクリプトを何度実行しても同じ結果になるよう、すでにメタコメントが存在するファイルはスキップします。

# すでにメタコメントがある場合はスキップ
if head -1 "$f" | grep -q '<!-- Space:'; then
  echo "  [スキップ] $(basename "$f") (メタコメント済み)"
  continue
fi

これにより、新しくファイルが追加されたときだけ処理が走ります。チーム内で「とりあえず実行しておけば大丈夫」と言える安心感は大きいです。

ドライランモード

実際にファイルを変更する前に、何が変わるかを確認できます。

# 確認のみ(ファイルは変更しない)
./add_confluence_meta.sh --dry-run 処理設計/ap_001_ユーザー入力フォーム

# 実際に挿入
./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム

出力例:

  [追加] ap_001_ユーザー入力フォーム_処理設計_初期処理.md
         Title: ap_001:ユーザー入力フォーム:初期処理
  [追加] ap_001_ユーザー入力フォーム_処理設計_入力画面表示.md
         Title: ap_001:ユーザー入力フォーム:入力画面表示
  [スキップ] ap_001_ユーザー入力フォーム_処理設計_入力画面サブミット.md (メタコメント済み)

完了: 追加=2 スキップ=1 警告=0

draw.ioマクロの動的挿入

設計書にはdraw.ioで作成した画面遷移図が含まれています。Confluenceでdraw.ioを表示するには、Confluence Storage Format(XHTML)のマクロを挿入し、さらに .drawio ファイルをページに添付する必要があります。

一時ファイル方式で元Markdownを守る

最初に考えたのは、Markdownファイルに直接マクロを書き込む方法でした。しかし、これには2つの問題がありました。

  1. Markdownの可読性が崩れる: Confluence固有のXMLマクロがMarkdownに混入する
  2. git diffがノイジーになる: アップロードのたびに差分が発生する

そこで、一時ファイルを使う方式にしました。

# 一時ファイルにdraw.ioマクロを埋め込む
local tmp="${TMPDIR_WORK}/${basename}.md"
local macro='<ac:structured-macro ac:name="drawio" ac:schema-version="1">'
macro+='<ac:parameter ac:name="diagramName">'"${drawio_filename}"'</ac:parameter>'
macro+='</ac:structured-macro>'

perl -0777 -pe \
  "s|(### 画面遷移図\n)([^\n#][^\n]*\n)?|\$1\n${macro}\n\n|" \
  "$src" > "$tmp"

### 画面遷移図 セクションの直後にマクロを挿入しています。perlのスラープモード(-0777)を使って、改行をまたいだ置換を実現しました。

元のMarkdownファイルは一切変更されず、一時ファイルはアップロード後に自動削除されます。

REST APIでdrawioファイルを添付

markではファイル添付ができないため、アップロード後にREST APIで .drawio ファイルをページに添付します。

# ページタイトルからページIDを取得
get_page_id() {
  local title="$1"
  local encoded_title
  encoded_title=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${title}'))")
  curl -sf \
    "${BASE_URL}/rest/api/content?spaceKey=${SPACE}&title=${encoded_title}" \
    -u "${USERNAME}:${PASSWORD}" \
    | python3 -c "import json,sys; r=json.load(sys.stdin)['results']; print(r[0]['id'] if r else '')"
}

# drawioファイルをページに添付
attach_drawio() {
  local page_id="$1"
  local drawio_file="$2"
  curl -sf -X POST \
    "${BASE_URL}/rest/api/content/${page_id}/child/attachment" \
    -u "${USERNAME}:${PASSWORD}" \
    -H "X-Atlassian-Token: no-check" \
    -F "file=@${drawio_file};type=application/octet-stream" \
    -F "minorEdit=true"
}

X-Atlassian-Token: no-check ヘッダーはCSRF対策の回避に必要です。また、minorEdit=true を指定することで、ウォッチャーに通知を飛ばさないようにしています。一括アップロードで全員に通知が飛ぶと大惨事なので、ここは地味に重要です。

親子ページの自動導出

設計書は「概要ページ → 処理設計ページ」の2層構造になっています。処理設計ページをアップロードする際、対応する概要ページを自動で親ページとして設定する仕組みが必要でした。

フォルダ名から親ページ名を変換

ファイルシステムのフォルダ名とConfluenceのページ名には命名規則の違いがあります。

ファイルシステム Confluence
ap_001_ユーザー入力フォーム ap_001:ユーザー入力フォーム

アンダースコア _ をコロン に変換するのですが、ap_001 のようにID部分にもアンダースコアがあるため、単純な一括置換はできません。

# ap_001_ユーザー入力フォーム → ap_001:ユーザー入力フォーム
parent_page=$(echo "$src_dirbase" | sed -E 's/^(ap_[0-9]+)_/\1:/')

正規表現で「IDパターン(ap_NNN)の直後のアンダースコアだけ」をコロンに変換しています。

--auto-parents オプション

この機能は --auto-parents フラグで有効化します。概要ページのアップロードでは不要(固定の親ページを使う)なので、処理設計のときだけ使う設計です。

# 概要ページ(親ページ固定)
./upload_confluence.sh --auth-file .confluence_auth \
  -f '概要/ap_001_*.md'

# 処理設計ページ(親ページ自動導出)
./upload_confluence.sh --auth-file .confluence_auth \
  --auto-parents \
  -f '処理設計/ap_001_ユーザー入力フォーム/*.md'

一括同期の実行フロー

実際の運用は2ステップです。

Step 1: メタコメント挿入(初回のみ)

./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム

Step 2: アップロード

# 概要
./upload_confluence.sh --auth-file .confluence_auth \
  -f '概要/ap_00[1-9]_*.md' \
  -f '概要/ap_01[0-5]_*.md'

# 処理設計
./upload_confluence.sh --auth-file .confluence_auth \
  --auto-parents \
  -f '処理設計/ap_001_ユーザー入力フォーム/*.md'

glob展開で複数ファイルを一発指定でき、-f オプションを複数回指定することで柔軟なファイル選択が可能です。

認証情報の分離

APIトークンをコマンドラインに直書きするのはセキュリティ上の問題があります。.confluence_auth ファイルに分離し、.gitignore で除外しています。

# .confluence_auth
USERNAME=your-email@example.com
PASSWORD=your-api-token
# .gitignore
.confluence_auth

チーム内では chmod 600 .confluence_auth を推奨しています。

運用で詰まったポイントと対策

ハマり1: 親ページが「フォルダ型」だとmarkが使えない

Confluenceのページにはページ型とフォルダ型があり、フォルダ型の配下にmarkでページを作ることができません。Confluence上でページ型に変更するか、REST APIを直接使う必要があります。

ハマり2: タイトルに / を含むとParentメタコメントが壊れる

markの <!-- Parent: --> コメントはパス区切りに / を使います。タイトルに / が含まれるとパースが壊れるので、親ページの指定はメタコメントではなくコマンドの --parents オプションで行う設計にしました。

ハマり3: 添付済みdrawioの再アップロード

同名ファイルがすでに添付されている場合、REST APIは新しいバージョンとして上書きします。ただし、初回と2回目以降でHTTPステータスが異なることがあるため、エラーハンドリングではフォールバックメッセージを出すようにしています。

curl ... > /dev/null \
  && echo "  [添付完了]" \
  || echo "  [添付失敗 - すでに添付済みの可能性あり]"

Before / After(再掲)

冒頭のテーブルの再掲ですが、改めて。

項目 Before(手動) After(自動化)
概要ページアップロード 1ページ5分 × 20 = 100分 コマンド1つで約2分
処理設計アップロード 1ページ3分 × 90 = 270分 Step 1 + Step 2 で約5分
draw.io添付 手動で9ファイル添付 自動で完了
合計 約6時間 約7分
再実行コスト 毎回同じ作業 コマンド実行のみ

特に大きいのは再実行コストです。設計書が更新されるたびに手動で同じ作業を繰り返す必要がなくなりました。git pull してスクリプトを実行するだけで、Confluenceが最新状態になります。

まとめ

  1. HTMLコメントでメタデータを埋め込む: Markdownの可読性を壊さずに、Confluence用の情報を付与する
  2. 一時ファイル方式でdraw.ioマクロを挿入する: 元ファイルを汚さず、git diffもクリーンに保つ
  3. フォルダ名から親ページを自動導出する: 命名規則とsedの正規表現で、手動設定を排除する
  4. 冪等性を確保する: 何度実行しても同じ結果になる安心感
  5. 認証情報を分離する: .gitignore との組み合わせで事故を防ぐ

ドキュメントの「鮮度」を維持するのは、書くこと以上に仕組みの問題です。次はCI/CDパイプラインに組み込んで、mainブランチへのマージをトリガーにした自動同期を狙っています。


付録: スクリプト全文

add_confluence_meta.sh

#!/usr/bin/env bash
# 処理設計MarkdownファイルにConfluenceメタコメントを追加するスクリプト
# H1見出しをページタイトルとして <!-- Space: --> / <!-- Title: --> をファイル先頭に挿入する
# すでにメタコメントが存在するファイルはスキップする
#
# 使い方:
#   ./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム
#   ./add_confluence_meta.sh 処理設計/ap_0*
#
# ドライラン(挿入せず確認のみ):
#   ./add_confluence_meta.sh --dry-run 処理設計/ap_0*

set -euo pipefail

SPACE="MYPROJECT"
DRY_RUN=false

DIRS=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run|-n) DRY_RUN=true; shift ;;
    *) DIRS+=("$1"); shift ;;
  esac
done

if [[ ${#DIRS[@]} -eq 0 ]]; then
  echo "ERROR: 対象ディレクトリを指定してください" >&2
  echo "  例: ./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム" >&2
  exit 1
fi

[[ "$DRY_RUN" == "true" ]] && echo "[ドライラン] ファイルは変更しません"

added=0
skipped=0
warned=0

for dir in "${DIRS[@]}"; do
  [[ -d "$dir" ]] || { echo "  [スキップ] ディレクトリが存在しません: $dir"; continue; }

  for f in "$dir"/*.md; do
    [[ -f "$f" ]] || continue

    # すでにメタコメントがある場合はスキップ
    if head -1 "$f" | grep -q '<!-- Space:'; then
      echo "  [スキップ] $(basename "$f") (メタコメント済み)"
      (( skipped++ )) || true
      continue
    fi

    # H1タイトルを取得
    title=$(grep -m1 '^# ' "$f" | sed 's/^# //')
    if [[ -z "$title" ]]; then
      echo "  [警告] H1タイトルが見つかりません: $f"
      (( warned++ )) || true
      continue
    fi

    echo "  [追加] $(basename "$f")"
    echo "         Title: ${title}"

    if [[ "$DRY_RUN" == "false" ]]; then
      tmpfile=$(mktemp)
      {
        echo "<!-- Space: ${SPACE} -->"
        echo "<!-- Title: ${title} -->"
        echo ""
        cat "$f"
      } > "$tmpfile"
      mv "$tmpfile" "$f"
    fi

    (( added++ )) || true
  done
done

echo ""
echo "完了: 追加=${added} スキップ=${skipped} 警告=${warned}"

upload_confluence.sh

#!/usr/bin/env bash
# Confluenceアップロードスクリプト
# drawioファイルが存在する場合:
#   1. mark でページをアップロード(画面遷移図セクションにファイル名参照マクロを挿入)
#   2. REST API で .drawio ファイルをページに添付
# 元のMarkdownファイルは変更しない
#
# 使い方:
#   ./upload_confluence.sh --auth-file .confluence_auth -f '概要/ap_001_*.md'
#   ./upload_confluence.sh --auth-file .confluence_auth -f '概要/ap_0*.md'

set -euo pipefail

BASE_URL="https://your-domain.atlassian.net/wiki"
USERNAME=""
SPACE="MYPROJECT"
PARENTS="親ページ名"
PARENTS_DELIMITER=" | "
DRAWIO_DIR="概要/drawio"
TMPDIR_WORK=$(mktemp -d)
AUTH_FILE=".confluence_auth"
AUTO_PARENTS=false

# 引数解析
PASSWORD=""
FILES=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --password|-p)     PASSWORD="$2"; shift 2 ;;
    --username|-u)     USERNAME="$2"; shift 2 ;;
    --auth-file|-a)    AUTH_FILE="$2"; shift 2 ;;
    -f)                FILES+=("$2"); shift 2 ;;
    --parents)         PARENTS="$2"; shift 2 ;;
    --auto-parents)    AUTO_PARENTS=true; shift ;;
    *) shift ;;
  esac
done

load_auth_file() {
  local file="$1"
  [[ -f "$file" ]] || return 0

  while IFS='=' read -r key value; do
    key=$(echo "$key" | tr -d '[:space:]')
    value=$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')

    # Remove surrounding quotes when present.
    value="${value#\"}"
    value="${value%\"}"
    value="${value#\'}"
    value="${value%\'}"

    case "$key" in
      USERNAME) [[ -z "$USERNAME" ]] && USERNAME="$value" ;;
      PASSWORD) [[ -z "$PASSWORD" ]] && PASSWORD="$value" ;;
      ""|\#*) ;;
    esac
  done < "$file"
}

load_auth_file "$AUTH_FILE"

if [[ -z "$USERNAME" ]]; then
  echo "ERROR: USERNAME が未設定です (--username または ${AUTH_FILE} の USERNAME)" >&2
  exit 1
fi
if [[ -z "$PASSWORD" ]]; then
  echo "ERROR: PASSWORD が未設定です (--password または ${AUTH_FILE} の PASSWORD)" >&2
  exit 1
fi
if [[ ${#FILES[@]} -eq 0 ]]; then
  echo "ERROR: -f でファイルを指定してください" >&2
  exit 1
fi

cleanup() {
  rm -rf "$TMPDIR_WORK"
}
trap cleanup EXIT

# ページIDをタイトルで取得
get_page_id() {
  local title="$1"
  local encoded_title
  encoded_title=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${title}'))")
  curl -sf \
    "${BASE_URL}/rest/api/content?spaceKey=${SPACE}&title=${encoded_title}&expand=ancestors" \
    -u "${USERNAME}:${PASSWORD}" \
    | python3 -c "import json,sys; results=json.load(sys.stdin)['results']; print(results[0]['id'] if results else '')"
}

# drawioファイルをページに添付
attach_drawio() {
  local page_id="$1"
  local drawio_file="$2"
  local filename
  filename=$(basename "$drawio_file")

  echo "  [添付] $filename -> page_id=$page_id"
  curl -sf -X POST \
    "${BASE_URL}/rest/api/content/${page_id}/child/attachment" \
    -u "${USERNAME}:${PASSWORD}" \
    -H "X-Atlassian-Token: no-check" \
    -F "file=@${drawio_file};type=application/octet-stream" \
    -F "minorEdit=true" \
    > /dev/null && echo "  [添付完了]" || echo "  [添付失敗 - すでに添付済みの可能性あり]"
}

upload_file() {
  local src="$1"
  local basename
  basename=$(basename "$src" .md)

  # ap_001 などのID部分を抽出
  local ap_id
  ap_id=$(echo "$basename" | grep -oE '^ap_[0-9]+')

  local drawio_file="${DRAWIO_DIR}/${ap_id}.drawio"
  local target="$src"

  if [[ "$AUTO_PARENTS" == "false" && -f "$drawio_file" ]]; then
    local drawio_filename
    drawio_filename=$(basename "$drawio_file")

    # 一時ファイルにファイル名参照マクロを埋め込む
    local tmp="${TMPDIR_WORK}/${basename}.md"
    local macro
    macro='<ac:structured-macro ac:name="drawio" ac:schema-version="1"><ac:parameter ac:name="diagramName">'"${drawio_filename}"'</ac:parameter></ac:structured-macro>'

    perl -0777 -pe \
      "s|(### 画面遷移図\n)([^\n#][^\n]*\n)?|\$1\n${macro}\n\n|" \
      "$src" > "$tmp"

    target="$tmp"
    echo "  [drawioマクロ挿入] $drawio_filename"
  else
    echo "  [drawioなし] $src"
  fi

  # 親ページを解決(--auto-parents 時はディレクトリ名から導出)
  local effective_parents="$PARENTS"
  if [[ "$AUTO_PARENTS" == "true" ]]; then
    local src_dir src_dirbase parent_page
    src_dir=$(dirname "$src")
    src_dirbase=$(basename "$src_dir")
    # ap_001_ユーザー入力フォーム → ap_001:ユーザー入力フォーム
    parent_page=$(echo "$src_dirbase" | sed -E 's/^(ap_[0-9]+)_/\1:/')
    if [[ "$parent_page" != "$src_dirbase" ]]; then
      effective_parents="${PARENTS}${PARENTS_DELIMITER}${parent_page}"
    fi
  fi

  # markでアップロード
  mark \
    --base-url "$BASE_URL" \
    --username "$USERNAME" \
    --password "$PASSWORD" \
    --parents "$effective_parents" \
    --parents-delimiter "$PARENTS_DELIMITER" \
    -f "$target"

  # drawioがある場合、ページIDを取得して添付(概要のみ)
  if [[ "$AUTO_PARENTS" == "false" && -f "$drawio_file" ]]; then
    # <!-- Title: xxx --> からタイトルを取得
    local title
    title=$(grep -m1 '<!-- Title:' "$src" | sed 's/<!-- Title: \(.*\) -->/\1/')
    if [[ -n "$title" ]]; then
      local page_id
      page_id=$(get_page_id "$title")
      if [[ -n "$page_id" ]]; then
        attach_drawio "$page_id" "$drawio_file"
      else
        echo "  [警告] ページIDが取得できませんでした: $title"
      fi
    fi
  fi
}

# glob展開してアップロード
for pattern in "${FILES[@]}"; do
  if [[ -f "$pattern" ]]; then
    echo "==> $pattern"
    upload_file "$pattern"
  else
    _dir=$(dirname "$pattern")
    _glob=$(basename "$pattern")
    while IFS= read -r -d '' f; do
      echo "==> $f"
      upload_file "$f"
    done < <(find "$_dir" -maxdepth 1 -name "$_glob" -type f -print0 | sort -z)
  fi
done

echo ""
echo "完了"
7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?