GeminiでPDFを要約し、Markmapでマインドマップ風表示！Obsidian/VS Code連携も可能なPythonスクリプト

# GeminiでPDFを要約し、Markmapでマインドマップ風表示！Obsidian/VS Code連携も可能なPythonスクリプト

## はじめに

この記事では、PDFファイルからテキストを抽出し、GoogleのGemini APIを使って要約し、Markdown形式で出力するPythonスクリプトを紹介します。さらに、**ObsidianやVS Codeの拡張機能であるMarkmapと連携することで、要約結果をマインドマップ風に表示できる**というメリットも紹介します。このスクリプトを使うことで、大量のPDFドキュメントの内容を効率的に把握し、整理するだけでなく、**視覚的に情報を捉えやすくなります**。

## 必要なもの

*   Python 3.6以上
*   以下のPythonライブラリ
    *   `os`
    *   `google-generativeai`
    *   `pdfminer.six`
*   Google Cloud PlatformのAPIキー (`GOOGLE_API_KEY` 環境変数に設定)
*   ObsidianまたはVS Code (Markmap拡張機能を使用する場合)

## インストール

必要なライブラリは、以下のコマンドでインストールできます。

```bash
pip install google-generativeai pdfminer.six
```

## コード

以下がスクリプトの全体コードです。

```python
import os
import google.generativeai as genai # Google Gemini APIを扱うためのライブラリ
from pdfminer.high_level import extract_text # PDFファイルからテキストを抽出するためのライブラリ

class PdfToMarkdown:
    """
    PDFファイルから情報を取得し、Markdown形式で出力するクラスです。
    """
    def __init__(self):
        """
        初期化処理を行います。Gemini APIの初期化と、上書きやスキップに関するフラグを初期化します。
        """
        self._init_gemini_api() # Gemini APIを初期化します。
        self.all_overwrite = False # 全てのファイルを上書きするかどうかのフラグ。初期値はFalse（上書きしない）。
        self.skip_all = False # 全てのファイルの処理をスキップするかどうかのフラグ。初期値はFalse（スキップしない）。

    def _init_gemini_api(self):
        """
        Gemini APIクライアントを初期化します。
        """
        self.gemini_api_key = os.getenv('GOOGLE_API_KEY') # 環境変数からGemini APIキーを取得します。
        if not self.gemini_api_key: # APIキーが設定されていない場合
            raise ValueError("GOOGLE_API_KEY 環境変数が設定されていません。") # エラーを発生させます。
        genai.configure(api_key=self.gemini_api_key) # Gemini APIキーを設定します。
        self.model = genai.GenerativeModel('gemini-1.5-flash') # Geminiモデルを指定します。

    def extract_text_from_pdf(self, pdf_path):
        """
        PDFファイルからテキストを抽出します。

        Args:
            pdf_path (str): PDFファイルのパス。

        Returns:
            str: 抽出されたテキスト。エラーが発生した場合はNone。
        """
        try:
            return extract_text(pdf_path) # PDFからテキストを抽出します。
        except Exception as e: # エラーが発生した場合
            print(f"エラーが発生しました: {e}") # エラーメッセージを表示します。
            return None # Noneを返します。

    def create_markdown(self, pdf_path, summary):
        """
        PDFファイルのパスと要約からMarkdown形式の文字列を生成します。

        Args:
            pdf_path (str): PDFファイルのパス。
            summary (str): PDFの要約。

        Returns:
            str: Markdown形式の文字列。
        """
        pdf_filename = os.path.basename(pdf_path) # PDFファイル名を取得します。
        markdown = f"""# {pdf_filename}

## 基本情報
- ファイルパス: [[{pdf_filename}]]

## AI要約
{summary}

#pdf2md #自動生成
""" # Markdown形式の文字列を生成します。
        return markdown # Markdown形式の文字列を返します。

    def summarize_content(self, pdf_text):
        """
        PDFのテキストを要約します。

        Args:
            pdf_text (str): PDFから抽出されたテキスト。

        Returns:
            str: 要約されたテキスト。テキストがない場合はエラーメッセージ。
        """
        if not pdf_text: # PDFテキストが空の場合
            return "PDFテキストが利用できません。" # エラーメッセージを返します。

        prompt = f"""
以下のPDFコンテンツを階層的に要約してください：

テキスト:
{pdf_text}

要約の際は以下の点に注意してください：
1. Markdown形式で出力
2. # (h1)では出力せず、"タイトル: *PDFファイル名*"とする
2. 主要なトピックは### (h3)で区切る（セクションの判定ができるのであればセクションで区切る）
3. サブトピックは箇条書きで表現
4. 重要なキーワードは**太字**で強調
5. 説明文中のURLやハッシュタグは保持
6. 誤認識や変換ミスの可能性があるので全体の文脈を見て修正
7. 言い間違いや事実誤認の可能性があるのでファクトチェックを実施
8. 全体を考慮しトピック的なキーワードを抽出しハッシュタグを追加
""" # Gemini APIに渡すプロンプトを作成します。

        try:
            response = self.model.generate_content(prompt) # Gemini APIで要約を生成します。
            return response.text # 要約されたテキストを返します。
        except Exception as e: # エラーが発生した場合
            return f"要約生成エラー: {e}" # エラーメッセージを返します。

    def analyze_pdf(self, pdf_path):
        """
        PDFファイルのパスから情報を取得し、Markdown形式でファイルに保存します。

        Args:
            pdf_path (str): PDFファイルのパス。

        Returns:
            str: 出力ファイル名。
        """
        print(f"analyze_pdf: {pdf_path} の分析を開始します。") # 分析開始メッセージを表示します。
        pdf_text = self.extract_text_from_pdf(pdf_path) # PDFからテキストを抽出します。
        if not pdf_text: # テキストが抽出できなかった場合
            print(f"PDFファイル '{pdf_path}' からテキストを抽出できませんでした。スキップします。") # スキップメッセージを表示します。
            return None # Noneを返します。
        summary = self.summarize_content(pdf_text) # PDFテキストを要約します。
        markdown = self.create_markdown(pdf_path, summary) # Markdown形式の文字列を生成します。

        output_file = os.path.splitext(pdf_path)[0] + ".pdf.md" # 出力ファイル名を生成します。
        
        with open(output_file, 'w', encoding='utf-8') as f: # ファイルを書き込みモードで開きます。
            f.write(markdown) # Markdown形式の文字列をファイルに書き込みます。
        
        print(f"analyze_pdf: {pdf_path} の分析が完了しました。出力ファイル: {output_file}") # 分析完了メッセージを表示します。
        return output_file # 出力ファイル名を返します。

    def _get_user_choice(self, output_file):
        """ユーザーに上書きの選択を求めるヘルパー関数です。"""
        while True:
            choice = input(f"ファイル '{output_file}' は既に存在します。上書きしますか？ (Yes/No/All/All No/Default=Yes): ").lower() # ユーザーに選択を促します。
            print(f"選択結果: {choice}") # 選択結果を表示します。
            if choice in ['no', 'n']: # Noが選択された場合
                return 'no' # 'no'を返します。
            elif choice == 'all': # Allが選択された場合
                return 'all' # 'all'を返します。
            elif choice == 'all no': # All Noが選択された場合
                return 'all no' # 'all no'を返します。
            elif choice in ['yes', 'y', '']: # Yesまたは何も入力されなかった場合
                return 'yes' # 'yes'を返します。
            else: # 無効な入力の場合
                print("無効な入力です。Yes, No, All, All No のいずれかを入力してください。") # エラーメッセージを表示します。

    def process_pdf(self, pdf_file):
        """
        個々のPDFファイルを処理します。

        Args:
            pdf_file (str): PDFファイルのパス。
        """
        output_file = os.path.splitext(pdf_file)[0] + ".pdf.md" # 出力ファイル名を生成します。
        print(f"process_pdf: {pdf_file} の処理を開始します。") # 処理開始メッセージを表示します。

        if os.path.exists(output_file) and not self.all_overwrite and not self.skip_all: # 出力ファイルが存在し、上書きとスキップのフラグがFalseの場合
            choice = self._get_user_choice(output_file) # ユーザーに上書きの選択を求めます。
            if choice in ['no', 'n']: # Noが選択された場合
                print(f"process_pdf: {pdf_file} をスキップします。") # スキップメッセージを表示します。
                return # 処理を終了します。
            elif choice == 'all': # Allが選択された場合
                print(f"process_pdf: 今後、上書き確認を省略します。") # メッセージを表示します。
                self.all_overwrite = True # 上書きフラグをTrueにします。
            elif choice == 'all no': # All Noが選択された場合
                print(f"process_pdf: 今後、上書きをスキップします。") # メッセージを表示します。
                self.skip_all = True # スキップフラグをTrueにします。
                return # 処理を終了します。
            else: # Yesが選択された場合
                print(f"process_pdf: {pdf_file} を上書きします。") # 上書きメッセージを表示します。
        
        if self.skip_all: # スキップフラグがTrueの場合
            print(f"process_pdf: {pdf_file} の処理をスキップします。") # スキップメッセージを表示します。
            return # 処理を終了します。
        
        output_file = self.analyze_pdf(pdf_file) # PDFファイルを分析します。
        if output_file: # 分析が成功した場合
            print(f"分析結果を {output_file} に保存しました。") # 保存メッセージを表示します。
            with open(output_file, 'r', encoding='utf-8') as f: # ファイルを読み込みモードで開きます。
                content = f.read() # ファイルの内容を読み込みます。
                if len(content) > 500: # ファイルの内容が500文字より長い場合
                    print(f"\n--- {output_file} の内容 (抜粋) ---\n") # 抜粋メッセージを表示します。
                    print(content[:250] + "\n...\n" + content[-250:]) # ファイルの内容を抜粋して表示します。
                else: # ファイルの内容が500文字以下の場合
                    print(f"\n--- {output_file} の内容 ---\n") # 全体表示メッセージを表示します。
                    print(content) # ファイルの内容を全て表示します。
        print(f"process_pdf: {pdf_file} の処理が完了しました。") # 処理完了メッセージを表示します。

    def process_pdfs_in_directory(self, target_dir):
        """
        指定されたディレクトリ内のすべてのPDFファイルを処理します。

        Args:
            target_dir (str): PDFファイルが格納されたディレクトリのパス。
        """
        print(f"process_pdfs_in_directory: ディレクトリ '{target_dir}' のPDFファイル処理を開始します。") # 処理開始メッセージを表示します。
        pdf_files = [os.path.join(root, file) # ディレクトリ内のPDFファイルのリストを作成します。
                     for root, _, files in os.walk(target_dir)
                     for file in files if file.lower().endswith('.pdf')]
        
        if not pdf_files: # PDFファイルが見つからなかった場合
            print(f"ディレクトリ '{target_dir}' にPDFファイルが見つかりませんでした。") # メッセージを表示します。
            return # 処理を終了します。
        
        for pdf_file in pdf_files: # PDFファイルごとに処理を行います。
            if self.skip_all: # スキップフラグがTrueの場合
                print(f"process_pdfs_in_directory: 今後、全てのPDFファイルの処理をスキップします。") # メッセージを表示します。
                break # ループを抜けます。
            self.process_pdf(pdf_file) # PDFファイルを処理します。
            if self.skip_all: # スキップフラグがTrueの場合
                print(f"process_pdfs_in_directory: 今後、全てのPDFファイルの処理をスキップします。") # メッセージを表示します。
                break # ループを抜けます。
        print(f"process_pdfs_in_directory: ディレクトリ '{target_dir}' のPDFファイル処理が完了しました。") # 処理完了メッセージを表示します。


if __name__ == '__main__':
    import sys
    try:
        
        analyzer = PdfToMarkdown() # PdfToMarkdownクラスのインスタンスを作成します。
        
        target_dir = "" # ターゲットディレクトリを初期化します。
        
        if len(sys.argv) > 1: # コマンドライン引数が存在する場合
            if sys.argv[1] == '-f': # 引数が'-f'の場合
                if 'ipykernel' in sys.modules: # Jupyter Notebook環境の場合
                    print("Jupyter Notebook環境では '-f' が渡されてきます。テスト用のディレクトリを使用します") # メッセージを表示します。
                    target_dir = "./202501" # テスト用ディレクトリを設定します。
                else: # Jupyter Notebook環境でない場合
                    print("'-f' は無効な引数です。カレントディレクトリを使用します。") # メッセージを表示します。
                    target_dir = "." # カレントディレクトリを設定します。
            else: # 引数が'-f'でない場合
                target_dir = sys.argv[1] # 引数をターゲットディレクトリに設定します。
        
        target_dir = os.path.abspath(target_dir) # ターゲットディレクトリの絶対パスを取得します。
        if not os.path.exists(target_dir): # ターゲットディレクトリが存在しない場合
            print(f"指定されたディレクトリ '{target_dir}' は存在しません。カレントディレクトリを使用します。") # メッセージを表示します。
            target_dir = "." # カレントディレクトリを設定します。
        
        is_jupyter = 'ipykernel' in sys.modules # Jupyter Notebook環境かどうかを判定します。
        if is_jupyter: # Jupyter Notebook環境の場合
            print(f"Jupyter Notebook環境を検出。ディレクトリ '{target_dir}' のPDFファイルを処理します。") # メッセージを表示します。
        else: # Jupyter Notebook環境でない場合
            print(f"コマンドライン引数またはデフォルトで、ディレクトリ '{target_dir}' のPDFファイルを処理します。") # メッセージを表示します。
        
        analyzer.process_pdfs_in_directory(target_dir) # ディレクトリ内のPDFファイルを処理します。


    except Exception as e: # エラーが発生した場合
        print(f"エラーが発生しました: {e}") # エラーメッセージを表示します。
```

## コードの解説

1.  **ライブラリのインポート:**
    *   `os`: ファイルシステム操作
    *   `google.generativeai`: Google Gemini APIの利用
    *   `pdfminer.high_level`: PDFからのテキスト抽出

2.  **`PdfToMarkdown` クラス:**
    *   **`__init__`:**
        *   Gemini APIの初期化 (`_init_gemini_api`)
        *   `all_overwrite` (すべて上書きするか) と `skip_all` (すべてスキップするか) フラグの初期化
    *   **`_init_gemini_api`:**
        *   環境変数 `GOOGLE_API_KEY` からAPIキーを取得
        *   Gemini APIクライアントの初期化
    *   **`extract_text_from_pdf`:**
        *   `pdfminer.high_level.extract_text` を使用してPDFからテキストを抽出
        *   エラーが発生した場合はエラーメッセージを表示し、`None` を返す
    *   **`create_markdown`:**
        *   PDFファイルパスと要約からMarkdown形式の文字列を生成
    *   **`summarize_content`:**
        *   Gemini APIを使用してPDFテキストを要約
        *   プロンプトには、Markdown形式での出力、見出しレベル、箇条書き、キーワードの強調などの指示が含まれる
    *   **`analyze_pdf`:**
        *   PDFファイルのパスを受け取り、テキスト抽出、要約、Markdown形式でのファイル保存を行う
    *   **`process_pdf`:**
        *   個々のPDFファイルを処理
        *   ファイルが既に存在する場合は、上書きするかどうかをユーザーに確認
        *   `all` を選択すると、以降のファイルは上書き確認なしで上書き
        *   `all no` を選択すると、以降のファイルは処理をスキップ
    *   **`process_pdfs_in_directory`:**
        *   指定されたディレクトリ内のすべてのPDFファイルを処理
        *   `skip_all` フラグが `True` の場合は、以降のファイル処理をスキップ

3.  **`if __name__ == '__main__':` ブロック:**
    *   スクリプトが直接実行された場合に実行される
    *   `PdfToMarkdown` クラスのインスタンスを作成
    *   コマンドライン引数を処理し、処理対象のディレクトリを決定
        *   `-f` 引数が指定された場合、Jupyter Notebook環境ではテスト用ディレクトリを使用し、それ以外の場合はカレントディレクトリを使用
        *   その他の引数が指定された場合は、その引数をディレクトリとして使用
    *   ディレクトリの存在チェックを行い、存在しない場合はカレントディレクトリを使用
    *   Jupyter Notebook環境かどうかを判定し、メッセージを表示
    *   `process_pdfs_in_directory` を呼び出して、指定されたディレクトリ内のPDFファイルを処理
    *   エラーが発生した場合は、エラーメッセージを表示

## 使い方

1.  スクリプトを保存します（例：`pdf_to_markdown.py`）。
2.  `GOOGLE_API_KEY` 環境変数を設定します。
3.  コマンドラインからスクリプトを実行します。

    ```bash
    python pdf_to_markdown.py <PDFファイルが格納されたディレクトリ>
    ```

    または、Jupyter Notebook環境で実行する場合は、`-f` オプションを使用します。

    ```bash
    python pdf_to_markdown.py -f
    ```

    `-f` オプションを指定すると、Jupyter Notebook環境ではテスト用のディレクトリが使用されます。

4.  **ObsidianまたはVS CodeでMarkmap拡張機能をインストールします。**
5.  **スクリプトで生成されたMarkdownファイルを開くと、Markmapによってマインドマップ風に表示されます。**

## 実行例

```
process_pdfs_in_directory: ディレクトリ './202501' のPDFファイル処理を開始します。
process_pdf: ./202501/test.pdf の処理を開始します。
ファイル './202501/test.pdf.md' は既に存在します。上書きしますか？ (Yes/No/All/All No/Default=Yes): yes
選択結果: yes
analyze_pdf: ./202501/test.pdf の分析を開始します。
analyze_pdf: ./202501/test.pdf の分析が完了しました。出力ファイル: ./202501/test.pdf.md
分析結果を ./202501/test.pdf.md に保存しました。

--- ./202501/test.pdf.md の内容 (抜粋) ---

# test.pdf

## 基本情報
- ファイルパス: [[test.pdf]]

## AI要約
### テストドキュメント
- これは**テスト**用のドキュメントです
- **PDF**から**テキスト**を抽出して**要約**するテストです
- **Gemini**を使って**AI**で**要約**します
- **Markdown**形式で出力します

#pdf2md #自動生成
...
process_pdf: ./202501/test.pdf の処理が完了しました。
process_pdfs_in_directory: ディレクトリ './202501' のPDFファイル処理が完了しました。
```

## まとめ

このスクリプトを使うことで、PDFドキュメントの要約を自動化し、効率的に情報を整理することができます。Gemini APIの活用により、高度な要約が可能になり、Markdown形式で出力することで、**ObsidianやVS CodeのMarkmap拡張機能と連携し、マインドマップ風に表示することで、より視覚的に情報を捉えやすくなります。**

## 今後の課題

* `summarize_content` のプロンプトを外部ファイルから読み込むように変更し、要約のスタイルを柔軟に変更できるようにする
* `process_pdf` でのファイル上書き確認の処理をよりシンプルにする
* `extract_text_from_pdf` でエラーが発生した場合の処理をより丁寧にする
* ログ出力機能を追加し、処理状況をより詳細に把握できるようにする