Backlog Wiki運用を効率化するAPIスクリプト活用法 – MONEX ENGINEER BLOG │マネックス エンジニアブログ


はじめに

こんにちは。ferciプロジェクトを担当しているNです。

ferci(フェルシー)は、マネックス証券が提供する資産管理アプリで、SNSと投資アプリが融合したサービスです。App Store(iOS版) / Google Play(Android版) / 公式サイト

ferciプロジェクトでは、BacklogのWiki機能でドキュメント管理を行っています。Backlogは2024年9月から、リアルタイム同時編集が可能な新しい「ドキュメント機能」(β版)をリリースしており、この新機能への移行を検討していました。

しかし、調査したところ現時点ではドキュメント機能のAPIが提供されておらず、また公式の移行ツールもまだリリースされていません。そこで、公式の移行ツールが提供されるまでの間、現行のWiki運用を効率化するためのスクリプトを作成しました。

本記事では、Wiki機能の運用で課題となっていた「タイトルの一括変更」と「複数ページのPDF出力」を自動化する2つのスクリプトを紹介します。

背景:なぜスクリプト化が必要だったのか

課題1:Wikiタイトルの一括変更

BacklogのWikiは、タイトルに「/」を含めることで階層構造を表現できます。例えば「プロジェクト/進行中/機能A」というタイトルにすると、右側のディレクトリツリーに自動的に階層が反映されます。

しかし、プロジェクトの進行に伴って構造を変更したい場合、以下のような問題が発生します:

  • 標準UIには一括変更機能がない:1ページずつ編集画面を開いてタイトルを変更する必要がある
  • 大量ページの変更は非現実的:ferciプロジェクトでは100ページ以上のWikiがあり、手作業では数時間かかる
  • 手作業によるミスが発生しやすい:入力ミスや変更漏れのリスク

課題2:外部共有用のPDF出力

他部門や外部パートナーとのレビュー時、Backlogで作成したドキュメントを共有する必要がありました。しかし:

  • Backlogへのアクセス権限を持たない関係者への共有が必要:社外パートナーや一部の部門には権限を付与できない
  • 1ページずつしかPDF化できない:標準機能ではページごとに「その他」→「PDF出力」を実行する必要がある
  • 複数ページの出力は非効率:関連ドキュメント50ページをまとめて共有する際、1時間以上かかっていた

ドキュメント機能への移行は?

新しいドキュメント機能は魅力的ですが、現時点では以下の理由で移行を保留しています:

  • APIが未提供:プログラムからのドキュメント作成・編集ができない
  • 公式移行ツール未リリース:Wikiからドキュメントへの移行サポート機能は開発検討中
  • 100ページ以上の手動移行は現実的でない:1ページずつコピー&ペーストするのは非効率

そこで、公式の移行ツールがリリースされるまでの間、現行のWiki運用を効率化することにしました。

解決策:Backlog APIの活用

Backlog APIを使用することで、これらの課題を解決できます。APIでは、課題やWiki、ファイルの追加・取得など、ブラウザ上でできる操作の大部分を自動化できます。

事前準備:APIキーの取得

スクリプトを使用する前に、BacklogのAPIキーを取得する必要があります。

  1. Backlogにログイン
  2. 右上のユーザーアイコンから「個人設定」を選択
  3. 左メニューの「API」をクリック
  4. メモ欄に任意の説明を入力して「登録」
  5. 表示されたAPIキーをコピー

解決策1:Wikiタイトルの一括変更スクリプト

スクリプトの概要

このスクリプトは、指定したパターンに一致するWikiページのタイトルを一括で変更します。

必要な環境

pip install requests python-dotenv

環境変数の設定

.envファイルを作成してください:

BACKLOG_API_KEY=your_api_key_here
BACKLOG_SPACE_ID=your_space_id
BACKLOG_PROJECT_ID=YOUR_PROJECT

# 置換設定(オプション)
REPLACE_TARGET=レビュー/機能A
REPLACE_STRING=完了/2024年度/機能A/レビュー

# Dry-runモード(オプション: 'true'で実際の変更を行わない)
DRY_RUN=false

スクリプトコード

完全なコードを表示(クリックして展開)
"""
Backlog Wiki Title Bulk Rename Tool

BacklogのWikiページのタイトルを一括で変更するスクリプト
"""

import os
import sys
from typing import List, Dict, Optional
import requests
from dotenv import load_dotenv


class BacklogConfig:
    """Backlog接続設定を管理するクラス"""
    
    def __init__(self):
        load_dotenv()
        self.api_key = os.getenv('BACKLOG_API_KEY')
        self.space_id = os.getenv('BACKLOG_SPACE_ID')
        self.project_id = os.getenv('BACKLOG_PROJECT_ID')
        
        self._validate()
    
    def _validate(self):
        """必須設定項目のバリデーション"""
        required_fields = {
            'BACKLOG_API_KEY': self.api_key,
            'BACKLOG_SPACE_ID': self.space_id,
            'BACKLOG_PROJECT_ID': self.project_id
        }
        
        missing_fields = [key for key, value in required_fields.items() if not value]
        if missing_fields:
            raise ValueError(f"Missing required environment variables: {', '.join(missing_fields)}")


class BacklogWikiRenamer:
    """Backlog Wikiのタイトルを一括変更するクラス"""
    
    def __init__(self, config: BacklogConfig, replace_target: str, replace_string: str, dry_run: bool = False):
        self.config = config
        self.replace_target = replace_target
        self.replace_string = replace_string
        self.dry_run = dry_run
        self.base_url = f'https://{config.space_id}.backlog.jp/api/v2'
    
    def _get_wiki_pages(self) -> List[Dict]:
        """プロジェクトの全Wikiページを取得"""
        url = f'{self.base_url}/wikis'
        params = {
            'projectIdOrKey': self.config.project_id,
            'apiKey': self.config.api_key
        }
        
        print(f"Fetching wiki pages for project: {self.config.project_id}")
        
        try:
            response = requests.get(url, params=params, timeout=30)
            response.raise_for_status()
            wikis = response.json()
            print(f"Found {len(wikis)} wiki pages\n")
            return wikis
        except requests.exceptions.RequestException as e:
            print(f"Failed to retrieve wiki pages: {e}")
            return []
    
    def _rename_wiki(self, wiki_id: int, new_name: str) -> bool:
        """Wikiページのタイトルを変更"""
        url = f'{self.base_url}/wikis/{wiki_id}'
        params = {
            'apiKey': self.config.api_key,
            'name': new_name
        }
        
        try:
            response = requests.patch(url, params=params, timeout=30)
            response.raise_for_status()
            return True
        except requests.exceptions.RequestException as e:
            print(f"  ✗ Error: {e}")
            return False
    
    def rename_all(self) -> Dict[str, int]:
        """タイトル一括変更を実行"""
        print("=" * 70)
        print("Backlog Wiki Title Bulk Rename Tool")
        print("=" * 70)
        print(f"Replace target: '{self.replace_target}'")
        print(f"Replace with:   '{self.replace_string}'")
        if self.dry_run:
            print("\n*** DRY RUN MODE - No changes will be made ***")
        print("=" * 70)
        print()
        
        # Wikiページ一覧を取得
        wikis = self._get_wiki_pages()
        
        if not wikis:
            print("No wiki pages found.")
            return {'total': 0, 'matched': 0, 'success': 0, 'failed': 0}
        
        # 対象ページを抽出
        target_wikis = [
            wiki for wiki in wikis 
            if self.replace_target in wiki['name']
        ]
        
        if not target_wikis:
            print(f"No pages matching '{self.replace_target}' found.")
            return {'total': len(wikis), 'matched': 0, 'success': 0, 'failed': 0}
        
        print(f"Found {len(target_wikis)} pages to rename:")
        print("-" * 70)
        
        # タイトル変更を実行
        success_count = 0
        failed_count = 0
        
        for idx, wiki in enumerate(target_wikis, 1):
            old_name = wiki['name']
            new_name = old_name.replace(self.replace_target, self.replace_string)
            
            print(f"[{idx}/{len(target_wikis)}]")
            print(f"  ID: {wiki['id']}")
            print(f"  Before: {old_name}")
            print(f"  After:  {new_name}")
            
            if self.dry_run:
                print("  [DRY RUN] Skipping actual rename")
                success_count += 1
            else:
                if self._rename_wiki(wiki['id'], new_name):
                    print("  ✓ Successfully renamed")
                    success_count += 1
                else:
                    print("  ✗ Failed to rename")
                    failed_count += 1
            
            print()
        
        # サマリー表示
        print("-" * 70)
        print("Summary:")
        print(f"  Total wiki pages: {len(wikis)}")
        print(f"  Matched pages:    {len(target_wikis)}")
        print(f"  Successfully renamed: {success_count}")
        if failed_count > 0:
            print(f"  Failed:          {failed_count}")
        print("-" * 70)
        
        return {
            'total': len(wikis),
            'matched': len(target_wikis),
            'success': success_count,
            'failed': failed_count
        }


def main():
    """メイン処理"""
    try:
        # 設定の読み込み
        config = BacklogConfig()
        
        # 置換設定(環境変数または直接指定)
        replace_target = os.getenv('REPLACE_TARGET', 'レビュー/機能A')
        replace_string = os.getenv('REPLACE_STRING', '完了/2024年度/機能A/レビュー')
        
        # Dry-runモードの確認(環境変数で制御)
        dry_run = os.getenv('DRY_RUN', 'false').lower() == 'true'
        
        # リネーム実行
        renamer = BacklogWikiRenamer(
            config, 
            replace_target=replace_target,
            replace_string=replace_string,
            dry_run=dry_run
        )
        result = renamer.rename_all()
        
        # 失敗があった場合は終了コード1で終了
        if result['failed'] > 0:
            sys.exit(1)
        
    except ValueError as e:
        print(f"Configuration error: {e}")
        print("\nPlease set the following environment variables:")
        print("  BACKLOG_API_KEY")
        print("  BACKLOG_SPACE_ID")
        print("  BACKLOG_PROJECT_ID")
        print("\nOptional:")
        print("  REPLACE_TARGET (default: 'レビュー/機能A')")
        print("  REPLACE_STRING (default: '完了/2024年度/機能A/レビュー')")
        print("  DRY_RUN (set to 'true' for dry-run mode)")
        sys.exit(1)
    except Exception as e:
        print(f"Error: {e}")
        raise


if __name__ == "__main__":
    main()

実行方法

通常実行(実際にタイトルを変更)

python backlog_wiki_renamer.py

Dry-run(変更内容を確認するだけ)

DRY_RUN=true python backlog_wiki_renamer.py

動作の流れ

  1. 環境変数から設定を読み込み
  2. API経由でプロジェクトの全Wikiページを取得
  3. 置換対象の文字列を含むページを抽出
  4. 各ページのタイトルを置換
  5. Dry-runモードでない場合、API経由でタイトルを更新
  6. 処理結果のサマリーを表示

ユースケース例

プロジェクト完了時の整理

変更前: レビュー/機能A/議事録
変更後: 完了/2024年度/機能A/レビュー/議事録

プロジェクト完了時に「完了」フォルダに移動することで、アクティブなプロジェクトと完了したプロジェクトを明確に分離できます。

年度ごとの整理

REPLACE_TARGET=開発/機能B
REPLACE_STRING=2024年度/開発/機能B

年度が変わるタイミングで、前年度のドキュメントをまとめて整理できます。

命名規則の統一

REPLACE_TARGET=仕様書/
REPLACE_STRING=設計書/

プロジェクト途中で命名規則が変更になった場合も、一括で修正できます。

解決策2:Wikiの一括PDF化スクリプト

スクリプトの概要

Seleniumを使用してBacklogにログインし、指定したプロジェクトの全WikiページをPDF化するスクリプトです。外部共有やアーカイブ用途に活用できます。

必要な環境

pip install selenium requests python-dotenv

環境変数の設定

セキュリティのため、認証情報は環境変数で管理します。.envファイルを作成してください:

BACKLOG_API_KEY=your_api_key_here
BACKLOG_SPACE_ID=your_space_id
BACKLOG_PROJECT_ID=YOUR_PROJECT
BACKLOG_USERNAME=your_username
BACKLOG_PASSWORD=your_password
CHROMEDRIVER_PATH=/usr/local/bin/chromedriver

重要.envファイルは.gitignoreに追加して、Gitにコミットしないようにしてください。

スクリプトコード

完全なコードを表示(クリックして展開)
"""
Backlog Wiki PDF Export Tool

BacklogのWikiページを一括でPDF出力するスクリプト
"""

import os
import time
from typing import List, Dict, Optional
from pathlib import Path
import requests
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.common.exceptions import WebDriverException, TimeoutException
from dotenv import load_dotenv


class BacklogConfig:
    """Backlog接続設定を管理するクラス"""
    
    def __init__(self):
        load_dotenv()
        self.api_key = os.getenv('BACKLOG_API_KEY')
        self.space_id = os.getenv('BACKLOG_SPACE_ID')
        self.project_id = os.getenv('BACKLOG_PROJECT_ID')
        self.username = os.getenv('BACKLOG_USERNAME')
        self.password = os.getenv('BACKLOG_PASSWORD')
        self.chromedriver_path = os.getenv('CHROMEDRIVER_PATH', '/usr/local/bin/chromedriver')
        
        self._validate()
    
    def _validate(self):
        """必須設定項目のバリデーション"""
        required_fields = {
            'BACKLOG_API_KEY': self.api_key,
            'BACKLOG_SPACE_ID': self.space_id,
            'BACKLOG_PROJECT_ID': self.project_id,
            'BACKLOG_USERNAME': self.username,
            'BACKLOG_PASSWORD': self.password
        }
        
        missing_fields = [key for key, value in required_fields.items() if not value]
        if missing_fields:
            raise ValueError(f"Missing required environment variables: {', '.join(missing_fields)}")


class BacklogWikiExporter:
    """Backlog WikiをPDFにエクスポートするクラス"""
    
    def __init__(self, config: BacklogConfig, output_dir: str = "output"):
        self.config = config
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(parents=True, exist_ok=True)
        self.driver: Optional[webdriver.Chrome] = None
        self.base_url = f'https://{config.space_id}.backlog.jp'
    
    def _setup_chrome_driver(self) -> webdriver.Chrome:
        """ChromeDriverのセットアップ"""
        chrome_options = Options()
        chrome_options.add_argument('--disable-gpu')
        chrome_options.add_argument('--no-sandbox')
        chrome_options.add_argument('--disable-dev-shm-usage')
        chrome_options.add_argument('--window-size=1280,800')
        
        # PDF保存の設定
        chrome_options.add_experimental_option('prefs', {
            'printing.print_preview_sticky_settings.appState': 
                '{"recentDestinations":[{"id":"Save as PDF","origin":"local","account":""}],'
                '"selectedDestinationId":"Save as PDF","version":2}',
            'savefile.default_directory': str(self.output_dir.absolute())
        })
        chrome_options.add_argument('--kiosk-printing')
        
        # ヘッドレスモードを有効にする場合はコメントを外す
        # chrome_options.add_argument('--headless')
        
        try:
            service = Service(executable_path=self.config.chromedriver_path)
            driver = webdriver.Chrome(service=service, options=chrome_options)
            return driver
        except Exception as e:
            raise RuntimeError(f"Failed to initialize Chrome driver: {e}")
    
    def _login(self):
        """Backlogにログイン"""
        if not self.driver:
            raise RuntimeError("Chrome driver not initialized")
        
        login_url = f'{self.base_url}/LoginDisplay.action'
        print(f"Logging in to {login_url}...")
        
        try:
            self.driver.get(login_url)
            time.sleep(2)
            
            self.driver.find_element(By.ID, "userId").send_keys(self.config.username)
            self.driver.find_element(By.ID, "password").send_keys(self.config.password)
            self.driver.find_element(By.ID, "submit").click()
            
            time.sleep(3)
            print("Login successful")
        except Exception as e:
            raise RuntimeError(f"Login failed: {e}")
    
    def _get_wiki_pages(self) -> List[Dict]:
        """プロジェクトの全Wikiページを取得"""
        url = f'{self.base_url}/api/v2/wikis'
        params = {
            'apiKey': self.config.api_key,
            'projectIdOrKey': self.config.project_id
        }
        
        print(f"Fetching wiki pages for project: {self.config.project_id}")
        
        try:
            response = requests.get(url, params=params, timeout=30)
            response.raise_for_status()
            wikis = response.json()
            print(f"Found {len(wikis)} wiki pages")
            return wikis
        except requests.exceptions.RequestException as e:
            print(f"Failed to retrieve wiki pages: {e}")
            return []
    
    def _sanitize_filename(self, filename: str) -> str:
        """ファイル名として使用できない文字を除去"""
        # 使用可能な文字のみを残す
        invalid_chars=":"/\\|?*"
        for char in invalid_chars:
            filename = filename.replace(char, '_')
        
        # 長すぎるファイル名を切り詰める(拡張子を除いて200文字まで)
        max_length = 200
        if len(filename) > max_length:
            filename = filename[:max_length]
        
        return filename.strip()
    
    def _convert_wiki_to_pdf(self, wiki: Dict):
        """WikiページをPDFに変換"""
        if not self.driver:
            raise RuntimeError("Chrome driver not initialized")
        
        wiki_id = wiki['id']
        wiki_title = wiki['name']
        wiki_url = f'{self.base_url}/alias/wiki/{wiki_id}'
        
        print(f"Converting '{wiki_title}' to PDF...")
        
        try:
            self.driver.get(wiki_url)
            time.sleep(5)  # ページが完全にロードされるまで待機
            
            # ダウンロードディレクトリの設定を再確認
            self.driver.execute_cdp_cmd("Page.setDownloadBehavior", {
                "behavior": "allow",
                "downloadPath": str(self.output_dir.absolute())
            })
            
            # ページを印刷
            self.driver.execute_script('window.print();')
            
            # PDF生成の待機時間
            time.sleep(2)
            
            print(f"✓ Saved '{wiki_title}'")
            
        except Exception as e:
            print(f"✗ Failed to convert '{wiki_title}': {e}")
    
    def export_all(self):
        """全WikiページをPDFにエクスポート"""
        try:
            print("=" * 60)
            print("Backlog Wiki PDF Exporter")
            print("=" * 60)
            
            # WebDriverの初期化
            self.driver = self._setup_chrome_driver()
            
            # ログイン
            self._login()
            
            # Wikiページ一覧を取得
            wikis = self._get_wiki_pages()
            
            if not wikis:
                print("No wiki pages found.")
                return
            
            print(f"\nStarting export to: {self.output_dir.absolute()}")
            print("-" * 60)
            
            # 各WikiページをPDFに変換
            for idx, wiki in enumerate(wikis, 1):
                print(f"[{idx}/{len(wikis)}] ", end="")
                self._convert_wiki_to_pdf(wiki)
                
                # APIレート制限への配慮
                time.sleep(0.5)
            
            print("-" * 60)
            print(f"✓ Export completed: {len(wikis)} pages exported")
            print(f"Output directory: {self.output_dir.absolute()}")
            
        except Exception as e:
            print(f"\n✗ Export failed: {e}")
            raise
        
        finally:
            if self.driver:
                self.driver.quit()
                print("Chrome driver closed")


def main():
    """メイン処理"""
    try:
        # 設定の読み込み
        config = BacklogConfig()
        
        # エクスポート実行
        exporter = BacklogWikiExporter(config, output_dir="output_pdf")
        exporter.export_all()
        
    except ValueError as e:
        print(f"Configuration error: {e}")
        print("\nPlease set the following environment variables:")
        print("  BACKLOG_API_KEY")
        print("  BACKLOG_SPACE_ID")
        print("  BACKLOG_PROJECT_ID")
        print("  BACKLOG_USERNAME")
        print("  BACKLOG_PASSWORD")
        print("  CHROMEDRIVER_PATH (optional)")
    except Exception as e:
        print(f"Error: {e}")
        raise


if __name__ == "__main__":
    main()

実行方法

python backlog_wiki_exporter.py

動作の流れ

  1. 環境変数から設定を読み込み、バリデーション
  2. ChromeDriverを初期化
  3. Backlogにログイン
  4. API経由でWikiページ一覧を取得
  5. 各Wikiページにアクセスしてブラウザの印刷機能でPDF生成
  6. 指定フォルダに保存
  7. 完了後、WebDriverをクリーンアップ

技術的な課題と対応

試行錯誤したアプローチ

当初は以下の方法も検討しました:

  • APIで本文(Markdown)を取得して変換
  • markdown2ライブラリでHTML化
  • pdfkitを使用してPDF生成

しかし、以下の理由で採用を見送りました:

  • 日本語の文字化けが発生
  • Backlogのスタイルやレイアウトの再現が困難
  • 画像や添付ファイルの扱いが複雑

最終的に、ブラウザ経由でのPDF生成が最も確実であると判断しました。

ヘッドレスモードの制約

バックグラウンド実行(ヘッドレスモード)では、window.print() が正常に動作しないケースがありました。これは、印刷ダイアログが表示されないため、印刷コマンドが完了しない場合があるためです。

デフォルトでは画面表示モードを採用し、確実なPDF生成を優先しています。高速化が必要な場合は、コード内のコメントを外してヘッドレスモードを試すことができます。

導入効果

スクリプト導入により、大幅な効率化を実現できました。100ページのタイトル変更が手作業3時間からスクリプト実行5分に短縮(約97%削減)、50ページのPDF化が手作業1時間からスクリプト実行15分に短縮(約75%削減)。手作業によるミスもゼロになりました。

単純作業からの解放により、より価値の高い業務に集中できるようになり、Backlogアクセス権のない関係者へもスムーズに資料共有できるようになりました。また、「手間がかかる」という理由で後回しにしていたプロジェクト整理作業も気軽に実行できるようになりました。

今後の展望:公式移行ツールへの期待

Backlogの公式ブログによると、ドキュメント機能の利用拡大を図り、サービスの安定性が確認でき次第、Wikiからドキュメントへの移行をサポートする機能の開発・統合を検討するとのことです。

新しいドキュメント機能はリアルタイム同時編集(最大12名)、直感的な操作性、充実したコメント機能など魅力的な特徴を備えています。APIが提供され、公式の移行ツールがリリースされれば、ferciプロジェクトのWikiもスムーズに移行できると期待しています。

それまでの間は、今回作成したスクリプトでWiki運用を効率化していきます。

まとめ

Backlog APIを活用することで、標準UIでは困難だった運用タスクを自動化できました。本記事で紹介した2つのスクリプトは:

  1. Wikiタイトル一括変更: プロジェクト再編成時の階層構造変更を効率化
  2. Wiki一括PDF化: 外部共有やアーカイブ作業を効率化

これらは、ドキュメント機能への公式移行ツールがリリースされるまでの「つなぎ」の効率化施策として開発しましたが、想定以上の効果が得られました。

API活用による自動化は、単なる時間削減だけでなく、人的ミスの削減や、より価値の高い業務への集中を可能にします。同じような課題をお持ちの方は、ぜひ参考にしてみてください。

また、ドキュメント機能のAPIがリリースされ、公式の移行ツールが提供されましたら、改めて移行体験をレポートしたいと思います。


関連リンク




元の記事を確認する

関連記事