0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

伝家の宝刀;オフラインで backlog の wiki を閲覧する

0
Last updated at Posted at 2026-02-27

Backlog Wiki をローカルへ安全にバックアップする必要性

Wiki に保存した Password? ここ圏外ですけど。

気づけばメンバの顔ぶれまで四季折々みたいな状況、よくありませんか。
新しく参画したメンバのために過去の知恵や戦いの歴史をナレッジ化したり
属人化に終止符を打つべく手順書をこつこつ積み上げたり。
そういう積み上げの心を全部受け止めてくれる存在として、Backlog に助けられてるチーム
たぶん全国にたくさんあるのかと思います。

私も例外ではなく某プロジェクトでは Backlog を年単位で愛用しまくってきました。
本当感謝しかありません。
メンバの離任や退職で記憶が霧のように薄まりそうな瞬間、そのすべてを守るのが Wiki。
私たちの記憶のアーカイブ。未来へのタイムカプセル。
でも。
社外移動中とか、電車のトンネルとか、ふと Wiki を参照したいタイミングに限って
「ネットつながらんの、なんで?」となるときありませんか。
あれ、本当に困るのですよね。

そこで提案。

「ネットワークに繋がらなくても Wiki 見れたら最強では?」

答えはシンプル。

バックアップすればよし。バックログの Wiki を。
そう Backlog だけに Backup・・。
誰か私に座布団を運べといいましたか。バックアップさえ取れればしあわせも運べます。

はじめに

Backlog の Wiki は、プロジェクトの設計情報や運用手順、ナレッジ共有のハブとして
活用されるケースが多くあります。
一方で、以下のような課題を感じたことはないでしょうか。

  • Wiki を一括でバックアップしたい
  • 添付ファイルも含めてローカルに保存したい
  • 将来的な移行や監査のためにオフラインで保管しておきたい

Backlog には Wiki をまとめてエクスポートする標準機能が用意されていないため
API を利用したバックアップツールを作成しました。

本記事では、Backlog API を利用して Wiki の本文および添付ファイルをローカルへ安全にバックアップする Python ツールの実装と、その際に考慮した設計ポイントについて紹介します。


実現したいこと

今回のツールでは以下を目的としました。

  • プロジェクト配下の Wiki を一括取得する
  • Wiki 本文を Markdown 形式で保存する
  • 添付ファイルをダウンロードする
  • Wiki の階層構造をローカルフォルダとして再現する
  • OS 差異(Windows/macOS/Linux)による保存失敗を防止する

特に、Wiki タイトルに含まれる / をフォルダ階層として扱うことで、Backlog 上の情報構造をローカルに近い形で再現することを目指しました。
なお Backlog API を用いて Wiki 情報を取得する方法については、既に公開されている技術記事も存在しますが、本記事では取得後のローカル保存時に発生し得るファイル名制約やフォルダ衝突といった運用上の課題への対応に焦点を当てています。


システム構成

本ツールは以下のシンプルな構成となっています。

Backlog API
   │
 HTTPS
   │
Python Script(CLI)
   │
Local File System
  • 実行方式:ローカル CLI
  • ランタイム:Python
  • 利用ライブラリ:requests
  • 認証方式:API Key

Backlog API の利用方法

Wiki の取得には Backlog API v2 を利用します。

本ツールで使用している主なエンドポイントは以下です。

処理内容 API
Wiki一覧取得 GET /api/v2/wikis
Wiki本文取得 GET /api/v2/wikis/{wikiId}
添付一覧取得 GET /api/v2/wikis/{wikiId}/attachments
添付DL GET /api/v2/wikis/{wikiId}/attachments/{attachmentId}

認証はクエリパラメータに apiKey を付与することで実施します。

https://{space}.{domain}/api/v2/wikis?projectIdOrKey=XXX&apiKey=XXXX

フォルダ構造の再現方法

Backlog の Wiki タイトルは / を用いて階層的に運用されることが多いため、

設計/API/認証

のようなタイトルを以下のようなフォルダ構成として出力します。

wiki/
 └ 設計/
    └ API/
       └ 認証/
          ├ content.md
          ├ architecture__770001.png
          └ openapi_spec__770002.yaml
  • content.md :Wiki 本文
  • 添付ファイル :同一階層に保存

ファイル保存時の技術的な工夫

Wiki タイトルは自由入力であるため、そのままローカルのフォルダ名として使用すると OS によってはエラーが発生します。

Unicode 正規化(NFKC)

Wiki タイトルには全角英数字が含まれる場合があります。

API設計書 → API設計書
2026年 → 2026年

Python の

unicodedata.normalize("NFKC", title)

を用いて表記ゆれを吸収しています。


Windows 非対応文字の除去

以下の文字は Windows のファイル名として使用できません。

\ / : * ? " < > |

これらを検出し、安全な文字へ置換しています。

例:

API:Login → API_Login
DB<定義> → DB_定義_

Windows 予約語の回避

Windows では以下の名称はフォルダとして作成できません。

CON
PRN
AUX
NUL
COM1~9
LPT1~9

Wiki タイトルがこれらに該当する場合はサフィックスを付与して回避しています。

CON → CON_

添付ファイル名の衝突回避

Backlog では同名ファイルを複数添付できます。

design.png
design.png

ローカルでは上書き事故となるため、

design__876543.png

のように attachmentId を付与して保存しています。


Sticky ID によるフォルダ衝突回避

Wiki タイトルの階層構造において、同一階層に同名フォルダが存在するケースがあります。
Wiki タイトルが重複すると、ローカル保存時にフォルダが衝突し処理が異常終了します。
これを回避するため、各フォルダ名に Wiki ID を付与します。

設計__14001/
基本設計__14002/

さらに、

wiki/.folder_id_map.json

に対応関係を保存することで、再実行時も同一構造を再現可能にしています。


レート制限(HTTP 429)への対応

Backlog API にはレート制限があります。

そのため、HTTP 429 が返却された場合は一定時間待機し、再試行する仕組みを実装しています。


実行環境の注意点(OpenSSL 問題)

macOS 標準の Python は LibreSSL でビルドされており、
urllib3 v2 では OpenSSL 1.1.1 以上が必要となります。

そのため、Homebrew 版 Python を利用することで回避可能です。


まとめ

本ツールにより、

  • Wiki 本文
  • 添付ファイル
  • 階層構造

上記を含めた形で Backlog Wiki をローカルへ安全にバックアップすることが可能となりました。
OS 差異や文字コード差による保存失敗を防止し、長期保管や将来的な移行にも対応できる構成となっています。

以上、Backlog をナレッジベースとして活用している方の参考になればと思ってのご案内でした。

サンプルコード(長いので折りたたみ式)
# -*- coding: utf-8 -*-
"""
Backlog Wiki Local Download Script

■ Included Features
- Normalize wiki title (fix full-width chars, extra spaces, slashes, etc.)
- Create folder tree based on title parts split by "/"
- Add "__<stickyID>" to all folder names (same parent & same name = reuse ID)
- Sticky-ID saved in "wiki/.folder_id_map.json" and also learned from existing folders
- Sanitize file/folder names (remove Windows NG characters)
- Save attachments (filename__attachmentID.ext)
- Auto retry when 429 (Too Many Requests) or network error
- Supports input by prompt or environment variables:
  BACKLOG_API_KEY / BACKLOG_SPACE_ID / BACKLOG_PROJECT_ID

■ Usage
$ python backlog_backup.py
  → Enter API key / Space ID / Project ID in prompt

$ BACKLOG_API_KEY=xxxx BACKLOG_SPACE_ID=xxxx BACKLOG_PROJECT_ID=xxxx \
  python backlog_backup.py

※ A "wiki/" folder is created in current directory.
"""

from __future__ import annotations
import os
import re
import time
import json
import unicodedata
from pathlib import Path
from typing import Any, Dict, List, Tuple
import requests

# ===== Sanitize Config =====
# Patterns of invalid characters for Windows filenames
INVALID_CHARS_FILE = r'[<>:"/\\|?*\x00-\x1F]'
INVALID_FILE_RE = re.compile(INVALID_CHARS_FILE)

INVALID_CHARS_PART = r'[<>:"\\|?*\x00-\x1F]'
INVALID_PART_RE = re.compile(INVALID_CHARS_PART)

# Windows reserved names
WINDOWS_RESERVED = {
    "CON", "PRN", "AUX", "NUL",
    *{f"COM{i}" for i in range(1, 10)},
    *{f"LPT{i}" for i in range(1, 10)},
}

# Pattern to detect "name__1234" folder
FOLDER_WITH_ID_RE = re.compile(r"^(?P<base>.*)__(?P<id>\d+)$")


def _sanitize_base(name: Any, re_obj: re.Pattern, replacement: str = "_", max_len: int = 120) -> str:
    # Convert to safe string for file/folder name
    if not isinstance(name, str):
        name = str(name)
    name = name.replace("\n", " ").replace("\r", " ").replace("\t", " ")
    name = re_obj.sub(replacement, name)
    name = name.strip(" .")

    if not name:
        name = "untitled"

    if name.upper() in WINDOWS_RESERVED:
        name = f"_{name}"

    if max_len > 0 and len(name) > max_len:
        name = name[:max_len].rstrip()

    return name


def sanitize_filename(name: Any, replacement: str = "_", max_len: int = 120) -> str:
    """Sanitize filename (replace invalid chars including '/')"""
    return _sanitize_base(name, INVALID_FILE_RE, replacement=replacement, max_len=max_len)


def sanitize_path_component(name: Any, replacement: str = "_", max_len: int = 80) -> str:
    """Sanitize each folder part (slash is allowed because it is separator)"""
    return _sanitize_base(name, INVALID_PART_RE, replacement=replacement, max_len=max_len)


# ===== Normalize Wiki Title =====
TITLE_SLASH_SPACES = re.compile(r"\s*/\s*")
MULTI_SPACES = re.compile(r"\s+")


def normalize_title(raw: str) -> str:
    """Normalize title: fix full-width chars, spaces, and slash variations"""
    if not isinstance(raw, str):
        raw = str(raw)

    s = unicodedata.normalize("NFKC", raw)
    s = s.replace(" ", " ")
    s = s.replace("/", "/")
    s = TITLE_SLASH_SPACES.sub("/", s)
    s = s.strip()
    s = MULTI_SPACES.sub(" ", s)
    return s


# ===== Backlog API =====
API_KEY: str
SPACE_ID: str
PROJECT_ID: str
BASE_URL: str


class BacklogApiError(Exception):
    pass


def _get(url: str, params: Dict[str, Any], *, stream: bool = False, timeout: int = 30) -> requests.Response:
    # Simple GET wrapper with retry logic
    headers = {"Content-Type": "application/json"}
    for attempt in range(4):
        try:
            resp = requests.get(url, params=params, headers=headers, stream=stream, timeout=timeout)
        except requests.RequestException as e:
            wait = 5 * (attempt + 1)
            print(f"[WARN] Request error: {e}. Retry in {wait}s ({attempt+1}/4)")
            time.sleep(wait)
            continue

        if resp.status_code == 200:
            return resp

        if resp.status_code == 429:
            # Retry when API rate limit is hit
            wait = 65
            print(f"[INFO] Rate limit (429). Waiting {wait}s... ({attempt+1}/4)")
            time.sleep(wait)
            continue

        break

    return resp


def get_wiki_list() -> List[Dict[str, Any]]:
    # Get wiki list
    url = f"{BASE_URL}/api/v2/wikis"
    params = {"apiKey": API_KEY, "projectIdOrKey": PROJECT_ID}
    resp = _get(url, params)

    if resp.status_code != 200:
        raise BacklogApiError(f"Failed to get wiki list: {resp.status_code} {resp.text}")

    try:
        return resp.json()
    except json.JSONDecodeError as e:
        raise BacklogApiError(f"Invalid JSON in wiki list: {e}")


def get_wiki_detail(wiki_id: int) -> Dict[str, Any]:
    # Get wiki detail including attachments & content
    url = f"{BASE_URL}/api/v2/wikis/{wiki_id}"
    params = {"apiKey": API_KEY}
    resp = _get(url, params)

    if resp.status_code != 200:
        raise BacklogApiError(f"Failed to get wiki detail({wiki_id}): {resp.status_code} {resp.text}")

    try:
        return resp.json()
    except json.JSONDecodeError as e:
        raise BacklogApiError(f"Invalid JSON in wiki detail({wiki_id}): {e}")


def download_wiki_attachment(wiki_id: int, attachment: Dict[str, Any], file_path: Path) -> None:
    # Download a single attachment
    url = f"{BASE_URL}/api/v2/wikis/{wiki_id}/attachments/{attachment['id']}"
    params = {"apiKey": API_KEY}
    resp = _get(url, params, stream=True)

    if resp.status_code != 200:
        raise BacklogApiError(
            f"Failed to download attachment({attachment['id']}): {resp.status_code} {resp.text}"
        )

    file_path.parent.mkdir(parents=True, exist_ok=True)
    with open(file_path, "wb") as f:
        for chunk in resp.iter_content(chunk_size=8192):
            if chunk:
                f.write(chunk)


# ===== Sticky ID Map =====
BASE_SAVE_DIR = Path("wiki")
ID_MAP_FILE = BASE_SAVE_DIR / ".folder_id_map.json"

# Structure example:
# { "version":1, "map": { parent_path: { base_name: sticky_id } } }


def load_id_map() -> Dict[str, Dict[str, int]]:
    # Load saved sticky-ID map. If not found, learn from existing folders.
    if ID_MAP_FILE.exists():
        try:
            obj = json.loads(ID_MAP_FILE.read_text(encoding="utf-8"))
            mp = obj.get("map", {})
            return {str(k): {str(n): int(i) for n, i in v.items()} for k, v in mp.items()}
        except Exception as e:
            print(f"[WARN] Failed to read id map: {e}. Rebuild from folders...")

    # Build new map from existing directories
    mp: Dict[str, Dict[str, int]] = {}

    def ensure_bucket(key: str) -> Dict[str, int]:
        if key not in mp:
            mp[key] = {}
        return mp[key]

    if BASE_SAVE_DIR.exists():
        for path in BASE_SAVE_DIR.rglob('*'):
            if not path.is_dir():
                continue

            rel = path.relative_to(BASE_SAVE_DIR)
            parts = list(rel.parts)

            logical_parent = ""
            base_parts: List[str] = []

            for part in parts:
                m = FOLDER_WITH_ID_RE.match(part)
                if not m:
                    base_name = part
                    assigned_id = None
                else:
                    base_name = m.group('base')
                    assigned_id = int(m.group('id'))

                base_name = sanitize_path_component(base_name)
                bucket = ensure_bucket(logical_parent)

                if assigned_id is not None and base_name not in bucket:
                    bucket[base_name] = assigned_id

                base_parts.append(base_name)
                logical_parent = "/".join(base_parts)

    return mp


def save_id_map(mp: Dict[str, Dict[str, int]]) -> None:
    # Save sticky-ID map
    ID_MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
    obj = {"version": 1, "map": mp}
    ID_MAP_FILE.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")


ID_MAP: Dict[str, Dict[str, int]] = {}


def assign_sticky_id_for_parts(parts: List[str], wiki_id: int) -> List[Tuple[str, int]]:
    """
    Assign sticky ID for each folder part.
    If part is first time → use current wiki_id as sticky ID.
    If seen before → reuse same sticky ID.
    """
    global ID_MAP
    out: List[Tuple[str, int]] = []
    parent_key = ""

    for base in parts:
        bucket = ID_MAP.setdefault(parent_key, {})

        if base not in bucket:
            bucket[base] = int(wiki_id)

        sticky = bucket[base]
        out.append((base, sticky))

        parent_key = f"{parent_key}/{base}" if parent_key else base

    return out


# ===== Save Wiki =====
def split_title_to_parts(title: str) -> List[str]:
    # Clean title and split by "/" to create folder tree
    title = normalize_title(title)
    raw_parts = [p.strip() for p in title.split('/')]
    parts = [sanitize_path_component(p) for p in raw_parts if p]
    return parts or ["untitled"]


def build_dir_path_for_wiki_sticky(wiki: Dict[str, Any]) -> Path:
    # Build full directory path using sticky IDs
    parts = split_title_to_parts(wiki.get("name", "untitled"))
    sticky = assign_sticky_id_for_parts(parts, int(wiki['id']))

    p = BASE_SAVE_DIR
    for base, sid in sticky:
        p = p / f"{base}__{sid}"

    return p


def save_wiki_content(wiki: Dict[str, Any]) -> None:
    # Download wiki content and attachments
    detail = get_wiki_detail(int(wiki["id"]))
    content: str = detail.get("content") or ""
    attachments: List[Dict[str, Any]] = detail.get("attachments", [])

    dir_path = build_dir_path_for_wiki_sticky(wiki)
    dir_path.mkdir(parents=True, exist_ok=True)

    # Save main content
    (dir_path / "content.md").write_text(content, encoding="utf-8")

    # Save attachments (insert attachment ID before extension)
    for attachment in attachments:
        orig_name = attachment.get("name", f"att_{attachment.get('id','')}")
        base, ext = os.path.splitext(orig_name)

        safe_base = sanitize_filename(base) or "untitled"
        safe_ext = (ext or "").strip()

        composed = f"{safe_base}__{attachment['id']}{safe_ext}"
        file_path = dir_path / composed

        print(f"[INFO] Downloading attachment: {file_path.name}")
        download_wiki_attachment(int(detail["id"]), attachment, file_path)


# ===== Main =====
def mask_api_key(key: str, show_last: int = 4) -> str:
    if not key:
        return ""
    if len(key) <= show_last:
        return "*" * len(key)
    return "*" * (len(key) - show_last) + key[-show_last:]


def resolve_base_url(space_id: str, api_key: str) -> str:
    """Resolve Backlog base URL.

    Priority:
      1) BACKLOG_BASE_URL (full URL, e.g. https://xxx.backlog.com)
      2) BACKLOG_DOMAIN + SPACE_ID (e.g. backlog.jp / backlog.com / backlogtool.com)
      3) Auto-detect by probing /api/v2/space against common domains
    """
    base = os.environ.get("BACKLOG_BASE_URL", "").strip()
    if base:
        return base.rstrip("/")

    preferred_domain = os.environ.get("BACKLOG_DOMAIN", "").strip()
    common_domains = ["backlog.jp", "backlog.com", "backlogtool.com"]

    domains = []
    if preferred_domain:
        domains.append(preferred_domain)
    domains.extend([d for d in common_domains if d not in domains])

    for domain in domains:
        candidate = f"https://{space_id}.{domain}"
        try:
            resp = requests.get(
                f"{candidate}/api/v2/space",
                params={"apiKey": api_key},
                timeout=20,
            )
            # 200: OK, 401/403: space exists but API key may be wrong or restricted
            if resp.status_code in (200, 401, 403):
                return candidate
        except requests.RequestException:
            continue

    # Fallback: use preferred or default .jp
    fallback_domain = preferred_domain or "backlog.jp"
    return f"https://{space_id}.{fallback_domain}"

def read_input_with_env(prompt: str, env_key: str) -> str:
    # Read from environment variable if exists. Otherwise ask user.
    val = os.environ.get(env_key)
    if val:
        print(f"{prompt}{val} (from ${env_key})")
        return val
    return input(prompt)


def main() -> None:
    global API_KEY, SPACE_ID, PROJECT_ID, BASE_URL, ID_MAP

    print("\n--- Backlog Wiki Exporter ---")

    API_KEY = read_input_with_env("Enter Your API key: ", "BACKLOG_API_KEY").strip()
    if not API_KEY:
        print("API key is required!")
        return

    SPACE_ID = read_input_with_env("Enter Your SPACE_ID (ex, taro-yamada): ", "BACKLOG_SPACE_ID").strip()
    if not SPACE_ID:
        print("SPACE_ID is required!")
        return

    PROJECT_ID = read_input_with_env("Enter Your PROJECT_ID (ex, qiita): ", "BACKLOG_PROJECT_ID").strip()
    if not PROJECT_ID:
        print("PROJECT_ID is required!")
        return

    BASE_URL = resolve_base_url(SPACE_ID, API_KEY)

    print("\n--- Configuration ---")
    print(f"API Key: {mask_api_key(API_KEY)}")
    print(f"Space ID: {SPACE_ID}")
    print(f"Project ID: {PROJECT_ID}")
    print(f"Base URL: {BASE_URL}")
    print("---------------------\n")

    BASE_SAVE_DIR.mkdir(parents=True, exist_ok=True)

    # Load existing sticky-ID map (or build from folders)
    ID_MAP = load_id_map()

    wikis = get_wiki_list()
    print(f"[INFO] {len(wikis)} wiki(s) found.")

    for w in wikis:
        parts = split_title_to_parts(w.get("name", "untitled"))
        sticky = assign_sticky_id_for_parts(parts, int(w['id']))
        preview = "/".join([f"{b}__{sid}" for b, sid in sticky])

        print(f"[INFO] Saving wiki: {preview}")
        save_wiki_content(w)

        # Save ID map frequently (safer for long export)
        save_id_map(ID_MAP)

    # Final save
    save_id_map(ID_MAP)


if __name__ == "__main__":
    main()


補足;セキュリティに関する注意事項

なお本ツールは API キーを利用して Backlog 上の Wiki 情報を取得し
本文および添付ファイルをローカル環境へ保存します。

API キーはクエリパラメータとして通信時に付与されるため
プロキシログやアクセスログ等に記録される可能性があります。
そのため、本ツールは CLI 環境での利用を前提としています。

またバックアップデータには Wiki 本文に記載されている
機密情報が含まれる可能性があります。

  • API キーの適切な管理
  • 保存先ディレクトリのアクセス制御
  • バックアップデータの保管方法

ご利用時には上記の取り扱いもどうか十分ご注意ください。
そして、いつも心に太陽を。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?