-- 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ファイルずつのコミット制御
大量のPDFをまとめて処理するときに「全部終わるまでコミットしない」設計にすると、途中の1つのファイルでエラーが起きたときに、それまで処理した何十件ものデータがすべて消えてしまいます。そのため、1つのPDFの登録が完了するごとに conn.commit() を呼び出し、安全に処理を進める構成にしています。 - 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 ファイル)」を自動生成させます。
- ローカルのPythonで処理する
MySQLへ直接繋ぐ代わりに、画面やファイルに INSERT INTO pdf_documents … や INSERT INTO pdf_sections … というテキスト(SQL文)をドバッと書き出します。 - サーバーへインポートする
書き出された .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();
