Python MySQL

-- 1. 親テーブル:PDFのメタデータを管理
CREATE TABLE pdf_documents (
    id INT AUTO_INCREMENT PRIMARY KEY,
    file_name VARCHAR(255) NOT NULL COMMENT 'ファイル名(一意の識別用)',
    title VARCHAR(255) COMMENT 'PDFのタイトル(AIまたはメタデータから抽出)',
    author VARCHAR(255) COMMENT '著者・作成者',
    summary TEXT COMMENT 'AIが生成したドキュメント全体の要約',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '登録日時',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新日時'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 2. 子テーブル:単元ごとに分解されたMarkdown本文を管理
CREATE TABLE pdf_sections (
    id INT AUTO_INCREMENT PRIMARY KEY,
    document_id INT NOT NULL COMMENT '親PDFのID',
    section_title VARCHAR(255) COMMENT '章や見出しのタイトル',
    markdown_content TEXT NOT NULL COMMENT '分解されたMarkdown形式の本文',
    page_number INT COMMENT '該当の開始ページ番号',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    -- [重要] 親PDFが削除されたら、紐づく単元データも自動で消える設定
    CONSTRAINT fk_pdf_document 
        FOREIGN KEY (document_id) 
        REFERENCES pdf_documents(id) 
        ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 3. 日本語の高速検索用インデックス(MySQL特有の機能)
-- これを貼ることで、PHPから大量の文字を検索しても一瞬でヒットするようになります。
ALTER TABLE pdf_sections ADD FULLTEXT INDEX fx_markdown_content (markdown_content) WITH PARSER ngram;
ALTER TABLE pdf_sections ADD FULLTEXT INDEX fx_section_title (section_title) WITH PARSER ngram;
  • ON DELETE CASCADE の設定
    「このPDFは不要になったから削除しよう」となったとき、親(pdf_documents)の行を1つ消すだけで、子(pdf_sections)にバラバラに保存されていたMarkdownデータもMySQLが自動でまとめて削除してくれます。プログラム側で2回DELETE文を走らせる必要がありません。
  • utf8mb4_unicode_ci の採用
    MySQLで絵文字や、特殊な漢字(環境依存文字など)を文字化けさせずにPDFからそのまま保存するための標準的な文字コードです。
  • WITH PARSER ngram の追加
    MySQLで日本語を「2文字ずつ」に区切ってインデックス化する設定です。これにより、PHP側から LIKE 検索よりも圧倒的に速いスピードで本文検索ができるようになります。

. PHP側での検索SQL

従来のLIKE検索の場合

SELECT 
    d.title AS doc_title, 
    s.section_title AS section_title, 
    s.markdown_content, 
    s.page_number
FROM pdf_sections s
INNER JOIN pdf_documents d ON s.document_id = d.id
WHERE s.markdown_content LIKE :keyword OR s.section_title LIKE :keyword;
高速版

SELECT 
    d.title AS doc_title, 
    s.section_title AS section_title, 
    s.markdown_content, 
    s.page_number
FROM pdf_sections s
INNER JOIN pdf_documents d ON s.document_id = d.id
WHERE MATCH(s.markdown_content) AGAINST(:keyword IN BOOLEAN MODE);

参考

import os
import pypdf
import mysql.connector

# =====================================================================
# 1. 各種設定(環境に合わせて変更してください)
# =====================================================================
# MySQL接続設定
DB_CONFIG = {
    "host": "localhost",
    "user": "root",
    "password": "your_password",
    "database": "your_db"
}

# 処理対象のPDFが格納されているフォルダのパス
TARGET_FOLDER = "./pdf_folder"

# =====================================================================
# 2. 単一PDFの処理とDB保存
# =====================================================================
def process_single_pdf(file_path, cursor):
    file_name = os.path.basename(file_path)
    print(f"📄 処理開始: {file_name}")
    
    try:
        reader = pypdf.PdfReader(file_path)
    except Exception as e:
        print(f"   ❌ PDFの読み込みに失敗しました(スキップします): {e}")
        return False

    # ① PDFの標準メタデータを取得
    meta = reader.metadata
    title = meta.title if meta and meta.title else file_name  # タイトルがなければファイル名で代用
    author = meta.author if meta and meta.author else "不明"
    summary = "AIによる要約なし(直接インポート)"

    # ② 親テーブル(pdf_documents)への保存
    insert_doc_query = """
    INSERT INTO pdf_documents (file_name, title, author, summary)
    VALUES (%s, %s, %s, %s)
    """
    cursor.execute(insert_doc_query, (file_name, title, author, summary))
    document_id = cursor.lastrowid  # 発行された親IDを取得

    # ③ 各ページをMarkdownチャンクとして子テーブル(pdf_sections)へ保存
    insert_section_query = """
    INSERT INTO pdf_sections (document_id, section_title, markdown_content, page_number)
    VALUES (%s, %s, %s, %s)
    """
    
    total_pages = len(reader.pages)
    for i, page in enumerate(reader.pages):
        page_num = i + 1
        text = page.extract_text()
        
        if not text.strip():
            # スキャンされた画像PDFなどでテキストが空の場合はスキップ
            continue
            
        # AIを使わないため、機械的に「第Xページ」を見出しとし、テキストをMD風に整形
        section_title = f"{title} - 第{page_num}ページ"
        markdown_content = f"## 第{page_num}ページ / 全{total_pages}ページ\n\n{text}"
        
        cursor.execute(insert_section_query, (
            document_id,
            section_title,
            markdown_content,
            page_num
        ))
        
    print(f"   -> データベースへの書き込み準備完了(Document ID: {document_id})")
    return True

# =====================================================================
# 3. メイン処理:フォルダ内の全PDFを順次自動処理
# =====================================================================
def main():
    # ターゲットフォルダが存在しない場合は作成
    if not os.path.exists(TARGET_FOLDER):
        os.makedirs(TARGET_FOLDER)
        print(f"📁 フォルダ「{TARGET_FOLDER}」を作成しました。ここにPDFを配置してください。")
        return

    # フォルダ内のPDFファイル一覧を取得
    pdf_files = [f for f in os.listdir(TARGET_FOLDER) if f.lower().endswith('.pdf')]
    
    if not pdf_files:
        print(f"ℹ️ フォルダ「{TARGET_FOLDER}」の中にPDFファイルが見つかりませんでした。")
        return
        
    print(f"🚀 合計 {len(pdf_files)} 件のPDFを順次処理します。")

    conn = None
    cursor = None
    
    try:
        # MySQLに接続(一括処理の間、接続を維持)
        conn = mysql.connector.connect(**DB_CONFIG)
        cursor = conn.cursor()

        success_count = 0
        for pdf_file in pdf_files:
            file_path = os.path.join(TARGET_FOLDER, pdf_file)
            
            # トランザクション制御:1ファイルごとにコミット(確定)する
            try:
                # 1ファイル分の処理を実行
                if process_single_pdf(file_path, cursor):
                    conn.commit()  # 1ファイル成功するごとに確定
                    print(f"   ✅ 保存完了: {pdf_file}\n")
                    success_count += 1
                else:
                    conn.rollback()
            except mysql.connector.Error as err:
                print(f"   ❌ データベースエラー(このファイルをスキップします): {err}")
                conn.rollback()  # このファイルの処理だけを取り消す

        print(f"🎉 すべての処理が終了しました。成功: {success_count} / 全体: {len(pdf_files)} 件")

    except mysql.connector.Error as e:
        print(f"❌ データベース接続で致命的なエラーが発生しました: {e}")
    finally:
        if cursor:
            cursor.close()
        if conn:
            conn.close()

if __name__ == "__main__":
    main()

シンプル運用のポイント

  1. 1ファイルずつのコミット制御
    大量のPDFをまとめて処理するときに「全部終わるまでコミットしない」設計にすると、途中の1つのファイルでエラーが起きたときに、それまで処理した何十件ものデータがすべて消えてしまいます。そのため、1つのPDFの登録が完了するごとに conn.commit() を呼び出し、安全に処理を進める構成にしています。
  2. AIなしでのMarkdown化の割り切り
    AIを使わない場合、見出し構造の自動判別は困難です。そのため、今回はシンプルに 「## 第Xページ / 全Yページ」というMarkdown見出しをプログラム側で自動付与 して保存しています。これだけでも、PHPから全文検索したときに「どのファイルの何ページ目にヒットしたか」が綺麗に整理されて画面に表示できるようになります。

解決策1:ローカルのPythonからサーバーのMySQLへ直結する(一番おすすめ)
Pythonコードの接続情報を以下のように書き換えて、手元のPC(ローカル)でスクリプトを実行します。

DB_CONFIG = {
    "host": "サーバーのMySQLホスト名(mysqlXXXX.db.sakura.ne.jp など)",
    "user": "サーバーのデータベースユーザー名",
    "password": "サーバーのデータベースパスワード",
    "database": "サーバーのデータベース名",
    "port": 3306 # 基本は3306ですが、サーバー指定があれば変更
}

解決策2:外部接続が禁止されている場合(SQLファイル経由)
もしサーバーのMySQLがセキュリティ上、絶対に外部からの接続を許さない仕様(同一サーバー内のPHPからしか繋がらない設定)の場合、Python側で「SQLのインサート文(.sql ファイル)」を自動生成させます。

  1. ローカルのPythonで処理する
    MySQLへ直接繋ぐ代わりに、画面やファイルに INSERT INTO pdf_documents … や INSERT INTO pdf_sections … というテキスト(SQL文)をドバッと書き出します。
  2. サーバーへインポートする
    書き出された .sql ファイルを、サーバーの phpMyAdmin(データベース管理画面)の「インポート」機能 を使って一発で流し込みます。
    この方法であれば、サーバー上でPythonが動かなくても、MySQLに直接繋げなくても、データを完全に移行できます。

解決策3:PHPだけで完結させる(Pythonを使わない)
そもそもPythonを使わず、PDFの読み込みからMySQLへの保存まで、すべてPHPで書いてサーバー上で実行するというアプローチです。
最近のPHPは非常に進化しており、Smalot/PdfParser という有名なライブラリ(Composerで導入可能)を使えば、Pythonの pypdf とまったく同じようにPDFのテキストやメタデータをPHPだけで抽出できます。
これなら、サーバー上の特定のフォルダにFTPでPDFをアップロードし、PHPの画面にアクセスするだけで自動的にMySQLに格納される仕組みが作れます。

1.bash

composer require smalot/pdfparser

もしレンタルサーバーなどでComposerが使えない場合は、ローカルPCの環境で上のコマンドを実行して生成された vendor/ フォルダごと、サーバーにFTPで丸ごとアップロードすればそのまま動きます。

2. PHP版:PDF自動読み込み・MySQL保存スクリプト

<?php
// 1. ライブラリの読み込み
require 'vendor/autoload.php';

// 2. MySQL接続設定
$dsn      = 'mysql:host=localhost;dbname=your_db;charset=utf8mb4';
$username = 'root';
$password = 'your_password';

$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // エラー時に例外を投げる
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,       // 連想配列で結果を取得
    PDO::ATTR_EMULATE_PREPARES   => false,                  // SQLインジェクション対策
];

// 処理対象のフォルダ
$target_folder = './pdf_folder';

try {
    // データベース接続
    $pdo = new PDO($dsn, $username, $password, $options);
    
    // フォルダ内のPDFファイル一覧を取得
    $pdf_files = glob($target_folder . '/*.{pdf,PDF}', GLOB_BRACE);
    
    if (empty($pdf_files)) {
        echo "ℹ️ フォルダ内にPDFファイルが見つかりませんでした。\n";
        exit;
    }
    
    echo "🚀 合計 " . count($pdf_files) . " 件のPDFを順次処理します。<br><br>";
    
    // PDFパーサーの初期化
    $parser = new \Smalot\PdfParser\Parser();
    $success_count = 0;

    // 各PDFファイルをループ処理
    foreach ($pdf_files as $file_path) {
        $file_name = basename($file_path);
        echo "📄 処理開始: {$file_name}<br>";
        
        try {
            // トランザクション開始(1ファイル単位でコミット制御)
            $pdo->beginTransaction();
            
            // PDFを解析
            $pdf = $parser->parseFile($file_path);
            
            // ① メタデータの取得
            $details = $pdf->getDetails();
            $title   = !empty($details['Title'])  ? $details['Title']  : $file_name;
            $author  = !empty($details['Author']) ? $details['Author'] : '不明';
            $summary = 'PHPによる直接インポート(AI要約なし)';
            
            // ② 親テーブル(pdf_documents)への保存
            $stmt_doc = $pdo->prepare("
                INSERT INTO pdf_documents (file_name, title, author, summary)
                VALUES (:file_name, :title, :author, :summary)
            ");
            $stmt_doc->execute([
                ':file_name' => $file_name,
                ':title'     => $title,
                ':author'    => $author,
                ':summary'   => $summary
            ]);
            
            // 新しく発行された親のIDを取得
            $document_id = $pdo->lastInsertId();
            echo "   -> 親テーブルに登録完了 (ID: {$document_id})<br>";
            
            // ③ 各ページをMarkdownチャンクとして子テーブル(pdf_sections)へ保存
            $pages       = $pdf->getPages();
            $total_pages = count($pages);
            
            $stmt_sec = $pdo->prepare("
                INSERT INTO pdf_sections (document_id, section_title, markdown_content, page_number)
                VALUES (:document_id, :section_title, :markdown_content, :page_number)
            ");
            
            foreach ($pages as $index => $page) {
                $page_num = $index + 1;
                $text     = $page->getText();
                
                // 空のページ(画像のみなど)はスキップ
                if (trim($text) === '') {
                    continue;
                }
                
                // 機械的にMarkdown風の見出しと内容を整形
                $section_title    = "{$title} - 第{$page_num}ページ";
                $markdown_content = "## 第{$page_num}ページ / 全{$total_pages}ページ\n\n{$text}";
                
                $stmt_sec->execute([
                    ':document_id'       => $document_id,
                    ':section_title'     => $section_title,
                    ':markdown_content'  => $markdown_content,
                    ':page_number'       => $page_num
                ]);
            }
            
            // 1ファイル分すべての処理が成功したらデータベースを確定
            $pdo->commit();
            echo "   ✅ 保存完了: {$file_name}<br><br>";
            $success_count++;
            
        } catch (Exception $e) {
            // このファイルの処理中にエラーが起きたら、このファイル分だけロールバック(取り消し)して次へ
            $pdo->rollBack();
            echo "   ❌ エラーが発生したためこのファイルをスキップします: " . $e->getMessage() . "<br><br>";
        }
    }
    
    echo "🎉 すべての処理が終了しました。成功: {$success_count} / 全体: " . count($pdf_files) . " 件<br>";

} catch (PDOException $e) {
    echo "❌ データベース接続で致命的なエラーが発生しました: " . $e->getMessage();
}

このPHP実装のメリット

  • サーバー移動が不要: PDFをサーバーにアップロードしてPHPを動かすだけなので、「ローカルで変換してデータを移行する」という手間が一切ありません。
  • 同じトランザクション機能: Python版と同様、$pdo->beginTransaction() と rollBack() を使っているため、万が一途中のページでエラーが起きてもデータベースにゴミデータが残りません。

PHP版のインポートスクリプトに、「処理が終わったPDFファイルを processed フォルダへ自動移動させ、次回以降の二重処理を完全に防ぐ機能」を組み込み。

<?php
// 1. ライブラリの読み込み
require 'vendor/autoload.php';

// 2. MySQL接続設定
$dsn      = 'mysql:host=localhost;dbname=your_db;charset=utf8mb4';
$username = 'root';
$password = 'your_password';

$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];

// 処理対象フォルダと、処理済みファイルの移動先フォルダ
$target_folder    = './pdf_folder';
$processed_folder = './processed';

// 移動先フォルダが存在しない場合は自動作成
if (!is_dir($processed_folder)) {
    mkdir($processed_folder, 0755, true);
}

try {
    // データベース接続
    $pdo = new PDO($dsn, $username, $password, $options);
    
    // フォルダ内のPDFファイル一覧を取得
    $pdf_files = glob($target_folder . '/*.{pdf,PDF}', GLOB_BRACE);
    
    if (empty($pdf_files)) {
        echo "ℹ️ 処理待ちのPDFファイルは見つかりませんでした。\n";
        exit;
    }
    
    echo "🚀 合計 " . count($pdf_files) . " 件のPDFを順次処理します。<br><br>";
    
    // PDFパーサーの初期化
    $parser = new \Smalot\PdfParser\Parser();
    $success_count = 0;

    // 各PDFファイルをループ処理
    foreach ($pdf_files as $file_path) {
        $file_name = basename($file_path);
        echo "📄 処理開始: {$file_name}<br>";
        
        try {
            // トランザクション開始
            $pdo->beginTransaction();
            
            // PDFを解析
            $pdf = $parser->parseFile($file_path);
            
            // ① メタデータの取得
            $details = $pdf->getDetails();
            $title   = !empty($details['Title'])  ? $details['Title']  : $file_name;
            $author  = !empty($details['Author']) ? $details['Author'] : '不明';
            $summary = 'PHPによる直接インポート(AI要約なし)';
            
            // ② 親テーブルへの保存
            $stmt_doc = $pdo->prepare("
                INSERT INTO pdf_documents (file_name, title, author, summary)
                VALUES (:file_name, :title, :author, :summary)
            ");
            $stmt_doc->execute([
                ':file_name' => $file_name,
                ':title'     => $title,
                ':author'    => $author,
                ':summary'   => $summary
            ]);
            
            $document_id = $pdo->lastInsertId();
            
            // ③ 各ページを子テーブルへ保存
            $pages       = $pdf->getPages();
            $total_pages = count($pages);
            
            $stmt_sec = $pdo->prepare("
                INSERT INTO pdf_sections (document_id, section_title, markdown_content, page_number)
                VALUES (:document_id, :section_title, :markdown_content, :page_number)
            ");
            
            foreach ($pages as $index => $page) {
                $page_num = $index + 1;
                $text     = $page->getText();
                
                if (trim($text) === '') {
                    continue;
                }
                
                $section_title    = "{$title} - 第{$page_num}ページ";
                $markdown_content = "## 第{$page_num}ページ / 全{$total_pages}ページ\n\n{$text}";
                
                $stmt_sec->execute([
                    ':document_id'       => $document_id,
                    ':section_title'     => $section_title,
                    ':markdown_content'  => $markdown_content,
                    ':page_number'       => $page_num
                ]);
            }
            
            // すべてのDB書き込みが成功したらコミット(確定)
            $pdo->commit();
            echo "   ➡️ データベース保存完了<br>";

            // ---------------------------------------------------------
            // 【新機能】DB保存が成功した後にファイルを移動(二重処理防止)
            // ---------------------------------------------------------
            $dest_path = $processed_folder . '/' . $file_name;
            
            // 同名ファイルが既に移動先にある場合は、上書きを防ぐためタイムスタンプを付与
            if (file_exists($dest_path)) {
                $path_info = pathinfo($file_name);
                $new_name  = $path_info['filename'] . '_' . time() . '.' . $path_info['extension'];
                $dest_path = $processed_folder . '/' . $new_name;
            }
            
            // ファイルを移動
            if (rename($file_path, $dest_path)) {
                echo "   ✅ 処理済みフォルダへ移動しました: " . basename($dest_path) . "<br><br>";
                $success_count++;
            } else {
                echo "   ⚠️ DB保存は成功しましたが、ファイルの移動に失敗しました。<br><br>";
            }
            
        } catch (Exception $e) {
            // DBエラー、またはPDF解析エラーが発生した場合はロールバック
            $pdo->rollBack();
            echo "   ❌ 処理に失敗したため、このファイルをスキップします: " . $e->getMessage() . "<br><br>";
        }
    }
    
    echo "🎉 すべての処理が終了しました。成功: {$success_count} / 全体: " . count($pdf_files) . " 件<br>";

} catch (PDOException $e) {
    echo "❌ データベース接続で致命的なエラーが発生しました: " . $e->getMessage();
}
  • 同名ファイルの衝突回避 (time())
    数ヶ月後に「同じファイル名の異なるPDF(例:manual.pdfなど)」を再度インポートしようとした際、移動先で上書きされて消えてしまわないよう、すでに同名ファイルがある場合は manual_1712345678.pdf のように自動で後ろに実行時間を付与して別名保存します。
  • rename() による一瞬の移動
    PHPの rename() 関数は、同じサーバー内(同じディスク内)の移動であれば、ファイルサイズが数十MBあっても一瞬で完了するため、サーバーの処理スピードを落としません。

検索(念のため)

// 1. 画面の <input type="text" name="keyword"> から送られてきた単語を受け取る
$keyword = $_GET['keyword'] ?? '';

// 2. 全文検索用のSQLを準備(見た目は特殊ですが、PDOの書き方は同じです)
$stmt = $pdo->prepare("
    SELECT 
        d.title, 
        s.page_number, 
        s.markdown_content 
    FROM pdf_sections s
    INNER JOIN pdf_documents d ON s.document_id = d.id
    WHERE MATCH(s.markdown_content) AGAINST(:keyword IN BOOLEAN MODE)
");

// 3. 安全にキーワードをバインドして実行
$stmt->execute([':keyword' => $keyword]);
$results = $stmt->fetchAll();