BookStackの章・ページ骨格をYAML/JSONから一括生成する(手作業ゼロ運用)
🧭 はじめに
このページでは、BookStackで「本の骨格(章+WIPページ)」を作るときに、章IDを手で拾ってシェルのリテラルを書き換える運用をやめて、YAML/JSONの入力ファイルだけで「章作成→配下ページ作成」まで自動化するワークフローをまとめる。 狙いは次の3点。
- 入力はYAML/JSONだけ(=章IDの調査・貼り付けを廃止)
- 章・ページの作成を冪等にする(同名があればスキップ)
- BookStack API仕様(認証・必須項目・フィルタ)に沿って堅牢にする
ブラウザのDevToolsでAPIを叩く運用は、ログイン済みセッションでもAPIが動くため成立している(ドキュメントにもその挙動が明記されている)。ただし自動化したいならサーバ側はAPIトークンが素直。 :contentReference[oaicite:0]{index=0}
✅ 結論:できる(APIで章もページも作れる)
BookStackのREST APIには以下がある。
- 章作成:
POST /api/chapters(book_idとnameが必須) (demo.bookstackapp.com) - ページ作成:
POST /api/pages(name必須、さらにchapter_idかbook_idのどちらか必須。本文はhtmlかmarkdownのどちらか必須) (demo.bookstackapp.com) - 一覧取得は
GET /api/chaptersなどでfilter[...]が使える(名前で存在確認に使える) (demo.bookstackapp.com) - 認証は
Authorization: Token <token_id>:<token_secret>ヘッダ (demo.bookstackapp.com)
したがって「YAML/JSON → 章作成 → 子ページ作成」は、curl+jq/yqの範囲で普通に組める。
🧩 新ワークフロー(提案)
🧱 1) YAML/JSONで“骨格”だけを定義する
- Book(対象の本):
book_id - 章:
name(+任意でdescription_htmlなど) - ページ:
name(本文はとりあえずWIPで固定でもいい)
入力ファイルが唯一の真実になるので、「章IDを拾う」「シェルを書き換える」が消える。
⚙️ 2) サーバ上のシェルは入力ファイルを読むだけにする
create_skeleton.sh skeleton.yamlのように実行- 認証情報は環境変数 or 権限を絞った別ファイルに隔離
TOKEN_ID/TOKEN_SECRETをスクリプトに直書きしない。非公開ページでも、サーバ上の平文は事故る。
🔁 3) 冪等(同名があれば作らない)にして安全運用
GET /api/chapters?filter[book_id]=...&filter[name]=...の検索で存在確認(0件なら作成)- ページも同様に
GET /api/pages?filter[chapter_id]=...&filter[name]=...で確認してから作成 (BookStackのlist APIはdataとtotalを返すのでtotal==0判定が楽) (demo.bookstackapp.com)
🗂️ 入力ファイル例(YAML)
skeleton.yaml の例:
book_id: 123
wip:
html: "<p>WIP</p>"
chapters:
- name: "01_規格の全体像"
pages:
- name: "ISO 26262 / 21434 とA-SPICEの役割分担"
- name: "A-SPICEを軸にした規格全体設計の考え方"
- name: "02_PRM/ PAMの読み方"
pages:
- name: "PRM(Purpose/Outcome)の実務的な読み替え"
- name: "PAM(Practice/Work Product)の集め方"
ページ本文は `html` か `markdown` のどちらかが必須(両方不要、はできない)。WIP用に固定の `
WIP
` を使うのが手堅い。 :contentReference[oaicite:6]{index=6}🧪 スクリプト(YAML入力→章作成→ページ作成)
前提コマンド:
curljqyq(YAML→JSON変換用途。Go版のyqを想定)
yqの方言差がある。ここでは `yq -o=json` が使える系(mikefarah/yq 系)を想定。
create_skeleton.sh:
#!/usr/bin/env bash
set -euo pipefail
INPUT_FILE="${1:-}"
if [[ -z "$INPUT_FILE" || ! -f "$INPUT_FILE" ]]; then
echo "Usage: $0 skeleton.yaml" >&2
exit 1
fi
: "${BOOKSTACK_BASE_URL:?Set BOOKSTACK_BASE_URL, e.g. https://hiden-no-tare.com}"
: "${TOKEN_ID:?Set TOKEN_ID}"
: "${TOKEN_SECRET:?Set TOKEN_SECRET}"
API_CHAPTERS="${BOOKSTACK_BASE_URL%/}/api/chapters"
API_PAGES="${BOOKSTACK_BASE_URL%/}/api/pages"
auth_header="Authorization: Token ${TOKEN_ID}:${TOKEN_SECRET}"
# YAML -> JSON (yq) -> 変数へ
json="$(yq -o=json "$INPUT_FILE")"
book_id="$(jq -r '.book_id' <<<"$json")"
wip_html="$(jq -r '.wip.html // "<p>WIP</p>"' <<<"$json")"
if [[ "$book_id" == "null" || -z "$book_id" ]]; then
echo "book_id is required in YAML" >&2
exit 1
fi
api_get() {
local url="$1"
curl -sS -H "$auth_header" -H "Accept: application/json" "$url"
}
api_post_json() {
local url="$1"
local payload="$2"
curl -sS -X POST "$url" \
-H "$auth_header" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$payload"
}
get_or_create_chapter_id() {
local chapter_name="$1"
# 既存検索: chapters list は data/total を返す :contentReference[oaicite:7]{index=7}
local res
res="$(api_get "${API_CHAPTERS}?filter[book_id]=${book_id}&filter[name]=$(jq -rn --arg s "$chapter_name" '$s|@uri')&count=1")"
local total
total="$(jq -r '.total' <<<"$res")"
if [[ "$total" != "0" ]]; then
jq -r '.data[0].id' <<<"$res"
return
fi
# 作成: chapter create は book_id と name が必須 :contentReference[oaicite:8]{index=8}
local payload
payload="$(jq -n --argjson book_id "$book_id" --arg name "$chapter_name" \
'{book_id:$book_id, name:$name}')"
local created
created="$(api_post_json "$API_CHAPTERS" "$payload")"
jq -r '.id' <<<"$created"
}
create_page_if_absent() {
local chapter_id="$1"
local page_name="$2"
local res
res="$(api_get "${API_PAGES}?filter[chapter_id]=${chapter_id}&filter[name]=$(jq -rn --arg s "$page_name" '$s|@uri')&count=1")"
local total
total="$(jq -r '.total' <<<"$res")"
if [[ "$total" != "0" ]]; then
echo " - skip page (exists): $page_name"
return
fi
# pages create: name必須、chapter_id or book_id必須、html or markdown必須 :contentReference[oaicite:9]{index=9}
local payload
payload="$(jq -n \
--arg name "$page_name" \
--arg html "$wip_html" \
--argjson chapter_id "$chapter_id" \
'{name:$name, html:$html, chapter_id:$chapter_id}')"
api_post_json "$API_PAGES" "$payload" >/dev/null
echo " - created page: $page_name"
}
# chapters を順に処理
chapters_len="$(jq '.chapters | length' <<<"$json")"
if [[ "$chapters_len" == "0" ]]; then
echo "No chapters in YAML" >&2
exit 1
fi
for i in $(seq 0 $((chapters_len - 1))); do
chapter_name="$(jq -r ".chapters[$i].name" <<<"$json")"
echo "== chapter: $chapter_name"
chapter_id="$(get_or_create_chapter_id "$chapter_name")"
echo "chapter_id=$chapter_id"
pages_len="$(jq ".chapters[$i].pages | length" <<<"$json")"
for j in $(seq 0 $((pages_len - 1))); do
page_name="$(jq -r ".chapters[$i].pages[$j].name" <<<"$json")"
create_page_if_absent "$chapter_id" "$page_name"
done
done
echo "Done."
🔐 認証情報の置き場(推奨)
例:~/.config/bookstack/env を作って、実行前に読み込む。
export BOOKSTACK_BASE_URL='https://hiden-no-tare.com'
export TOKEN_ID='XXXXXXX'
export TOKEN_SECRET='YYYYYYY'
実行:
source ~/.config/bookstack/env
./create_skeleton.sh skeleton.yaml
環境変数化しておくと、スクリプトをGit管理しても事故率が下がる。
🧯 よくある落とし穴
⚠️ ページ本文が空だと作成できない
/api/pages は html または markdown のどちらかが必須。WIPなら固定HTMLを入れるのが一番ラク。 (demo.bookstackapp.com)
⚠️ 同名章・同名ページの扱い
このスクリプトは「同名があればスキップ」方針。 もし同名があり得る運用(例:章名を毎回 “WIP” にする等)だと誤判定するので、プレフィックスに番号を含めるなどで一意性を担保した方がいい。
⚠️ API権限
APIトークンを発行したユーザに “Access System API” 権限が必要。 (demo.bookstackapp.com)
管理者権限ユーザのトークンを使い回さない。骨格生成専用ユーザ(必要最小権限)を作る方が安全。
🧱 拡張案(必要になったら)
- 章のdescription_html や tags をYAMLに持たせて、そのまま
POST /api/chaptersに渡す(パラメータはドキュメントに記載あり) (demo.bookstackapp.com) - ページ本文を
wip.html固定ではなく、ページごとにhtml/markdown/fileを指定して差し込む - “章の並び順” を
priorityで固定(章もページもpriorityがある) (demo.bookstackapp.com)
📌 まとめ
- YAML/JSONを唯一の入力にして、章IDの手作業は全廃できる
- BookStack APIの「章作成」「ページ作成」「filter検索」を組み合わせれば、冪等な骨格生成がシェルだけで可能 (demo.bookstackapp.com)
- トークンは直書きせず、環境変数+最小権限が安全