PDFのままそのまま放置されているドキュメントを印刷フォーマットに頼るのではなく.yaml形式で抽出し、構造化されたドキュメントとしてマークダウンファイルにしてObsidianで管理運用する方法を考えてます。
PDFからテキスト抽出
PDFからテキストを抽出するシェルスクリプトについて説明いたします。
基本的な方法
1. pdftotext を使用する方法
最も一般的で信頼性の高い方法です。
#!/bin/bash
# PDFテキスト抽出スクリプト
# 使用方法チェック
if [ $# -eq 0 ]; then
echo "使用方法: $0 <PDFファイル> [出力ファイル]"
exit 1
fi
PDF_FILE="$1"
OUTPUT_FILE="${2:-${PDF_FILE%.pdf}.txt}"
# PDFファイルの存在確認
if [ ! -f "$PDF_FILE" ]; then
echo "エラー: ファイル '$PDF_FILE' が見つかりません"
exit 1
fi
# pdftotextの存在確認
if ! command -v pdftotext &> /dev/null; then
echo "エラー: pdftotextがインストールされていません"
echo "Ubuntu/Debian: sudo apt install poppler-utils"
echo "CentOS/RHEL: sudo yum install poppler-utils"
echo "macOS: brew install poppler"
exit 1
fi
# テキスト抽出実行
echo "PDFからテキストを抽出中: $PDF_FILE"
pdftotext "$PDF_FILE" "$OUTPUT_FILE"
if [ $? -eq 0 ]; then
echo "抽出完了: $OUTPUT_FILE"
echo "ファイルサイズ: $(wc -c < "$OUTPUT_FILE") バイト"
echo "行数: $(wc -l < "$OUTPUT_FILE") 行"
else
echo "エラー: テキスト抽出に失敗しました"
exit 1
fi
2. 複数のPDFを一括処理する版
#!/bin/bash
# 複数PDF一括テキスト抽出スクリプト
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="$SCRIPT_DIR/pdf_extract.log"
# ログ関数
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# 使用方法
if [ $# -eq 0 ]; then
echo "使用方法:"
echo " $0 <PDFディレクトリ> # ディレクトリ内の全PDFを処理"
echo " $0 file1.pdf file2.pdf # 指定したPDFファイルを処理"
exit 1
fi
# pdftotextの存在確認
if ! command -v pdftotext &> /dev/null; then
log_message "エラー: pdftotextがインストールされていません"
exit 1
fi
# 処理開始
log_message "PDF テキスト抽出処理を開始"
# 引数がディレクトリの場合
if [ -d "$1" ]; then
PDF_DIR="$1"
log_message "ディレクトリを処理: $PDF_DIR"
# PDFファイルを検索
find "$PDF_DIR" -name "*.pdf" -type f | while read -r pdf_file; do
output_file="${pdf_file%.pdf}.txt"
log_message "処理中: $(basename "$pdf_file")"
if pdftotext "$pdf_file" "$output_file" 2>/dev/null; then
log_message "成功: $(basename "$output_file") ($(wc -l < "$output_file") 行)"
else
log_message "失敗: $(basename "$pdf_file")"
fi
done
else
# 個別ファイルを処理
for pdf_file in "$@"; do
if [ ! -f "$pdf_file" ]; then
log_message "スキップ: $pdf_file (ファイルが存在しません)"
continue
fi
output_file="${pdf_file%.pdf}.txt"
log_message "処理中: $(basename "$pdf_file")"
if pdftotext "$pdf_file" "$output_file" 2>/dev/null; then
log_message "成功: $(basename "$output_file")"
else
log_message "失敗: $(basename "$pdf_file")"
fi
done
fi
log_message "処理完了"
用語解説
シェルスクリプト: Unix/Linux系OSで使用されるコマンドラインインタープリター(shell)で実行される『スクリプトファイル』のことです。複数のコマンドを順次実行できます。
pdftotext: Poppler(PDFレンダリングライブラリ)に含まれるコマンドラインツールで、PDFファイルからプレーンテキストを抽出する『専用プログラム』です。
$#: シェルスクリプトで使用される『特殊変数』で、スクリプトに渡された引数の個数を表します。
$1, $2: 『位置パラメータ』と呼ばれ、スクリプトの第1引数、第2引数を表します。
${PDF_FILE%.pdf}: 『パラメータ展開』という機能で、変数PDF_FILEから末尾の「.pdf」を除去した文字列を取得します。
インストール方法
macOS
brew install poppler
使用例
# スクリプトを実行可能にする
chmod +x pdf_extract.sh
# 単一ファイルを処理
./pdf_extract.sh document.pdf
# 出力ファイル名を指定
./pdf_extract.sh document.pdf output.txt
# ディレクトリ内の全PDFを処理
./pdf_extract.sh /path/to/pdf_directory
このスクリプトは、PDFファイルからテキストを効率的に抽出し、エラーハンドリングとログ機能も備えた実用的なツールとして使用できます。
PDFから抽出したテキストを構造化YAMLデータとしてMarkdownで保存し、ObsidianのVaultに移動する統合スクリプトを作成いたします。
統合スクリプト
#!/bin/bash
# PDF→構造化YAML→Markdown→Obsidian統合スクリプト
set -euo pipefail # エラー時に停止
# 設定
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMP_DIR="$SCRIPT_DIR/temp"
LOG_FILE="$SCRIPT_DIR/pdf_to_obsidian.log"
# デフォルト設定(環境に合わせて変更)
DEFAULT_VAULT_PATH="$HOME/Documents/ObsidianVault"
DEFAULT_OUTPUT_DIR="PDFImports"
# 設定ファイルの読み込み
CONFIG_FILE="$SCRIPT_DIR/config.conf"
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
fi
OBSIDIAN_VAULT="${OBSIDIAN_VAULT:-$DEFAULT_VAULT_PATH}"
OUTPUT_DIR="${OUTPUT_DIR:-$DEFAULT_OUTPUT_DIR}"
# ログ関数
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# 使用方法表示
show_usage() {
cat << EOF
使用方法: $0 [オプション] <PDFファイル>
オプション:
-v, --vault PATH Obsidian Vault のパス (デフォルト: $OBSIDIAN_VAULT)
-o, --output DIR 出力ディレクトリ名 (デフォルト: $OUTPUT_DIR)
-t, --tags TAGS 追加するタグ (カンマ区切り)
-c, --category CAT カテゴリ名
-h, --help このヘルプを表示
例:
$0 document.pdf
$0 -t "research,ai" -c "技術資料" document.pdf
EOF
}
# 必要なコマンドの確認
check_dependencies() {
local deps=("pdftotext" "pdfinfo")
for cmd in "${deps[@]}"; do
if ! command -v "$cmd" &> /dev/null; then
log_message "エラー: $cmd がインストールされていません"
echo "インストール方法:"
echo " Ubuntu/Debian: sudo apt install poppler-utils"
echo " CentOS/RHEL: sudo yum install poppler-utils"
echo " macOS: brew install poppler"
exit 1
fi
done
}
# PDFメタデータ抽出
extract_pdf_metadata() {
local pdf_file="$1"
local temp_info="$TEMP_DIR/pdf_info.txt"
pdfinfo "$pdf_file" > "$temp_info" 2>/dev/null || {
log_message "警告: PDFメタデータの取得に失敗"
return 1
}
# メタデータをパース
TITLE=$(grep "^Title:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")
AUTHOR=$(grep "^Author:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")
SUBJECT=$(grep "^Subject:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")
CREATOR=$(grep "^Creator:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")
PAGES=$(grep "^Pages:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "0")
CREATION_DATE=$(grep "^CreationDate:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")
rm -f "$temp_info"
}
# テキストを章立てに分割
structure_content() {
local content_file="$1"
local structured_file="$TEMP_DIR/structured_content.txt"
# 簡単な章立て検出(改良可能)
awk '
BEGIN {
chapter_num = 0
current_chapter = "序文"
content = ""
}
# 章タイトルの検出パターン
/^[0-9]+\./ || /^第[0-9]+章/ || /^Chapter [0-9]+/ || /^[A-Z][^a-z]*$/ {
if (content != "") {
print "---CHAPTER:" current_chapter "---"
print content
print "---END_CHAPTER---"
}
current_chapter = $0
content = ""
next
}
# 空行をスキップ
/^[[:space:]]*$/ { next }
# 内容を蓄積
{
if (content != "") content = content "\n"
content = content $0
}
END {
if (content != "") {
print "---CHAPTER:" current_chapter "---"
print content
print "---END_CHAPTER---"
}
}
' "$content_file" > "$structured_file"
echo "$structured_file"
}
# YAMLフロントマターとMarkdown生成
generate_markdown() {
local pdf_file="$1"
local content_file="$2"
local output_file="$3"
local tags="$4"
local category="$5"
local filename=$(basename "$pdf_file" .pdf)
local current_date=$(date '+%Y-%m-%d')
local current_datetime=$(date '+%Y-%m-%d %H:%M:%S')
# タグの処理
local formatted_tags=""
if [ -n "$tags" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$tags"
for tag in "${TAG_ARRAY[@]}"; do
tag=$(echo "$tag" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') # トリム
if [ -n "$formatted_tags" ]; then
formatted_tags="$formatted_tags, \"$tag\""
else
formatted_tags="\"$tag\""
fi
done
fi
# Markdownファイル生成
cat > "$output_file" << EOF
---
title: "${TITLE:-$filename}"
author: "${AUTHOR}"
subject: "${SUBJECT}"
category: "${category}"
tags: [${formatted_tags}]
source_file: "${pdf_file}"
creator: "${CREATOR}"
pages: ${PAGES}
creation_date: "${CREATION_DATE}"
imported_date: "${current_datetime}"
type: "pdf_import"
---
# ${TITLE:-$filename}
## メタデータ
| 項目 | 内容 |
|------|------|
| **タイトル** | ${TITLE:-$filename} |
| **著者** | ${AUTHOR:-"不明"} |
| **作成者** | ${CREATOR:-"不明"} |
| **ページ数** | ${PAGES} |
| **作成日** | ${CREATION_DATE:-"不明"} |
| **カテゴリ** | ${category:-"未分類"} |
| **インポート日** | ${current_datetime} |
## 概要
${SUBJECT:-"PDFから抽出されたコンテンツです。"}
## 目次
EOF
# 構造化されたコンテンツを処理
local structured_file=$(structure_content "$content_file")
local toc_generated=false
# 目次生成
grep "^---CHAPTER:" "$structured_file" | while read -r line; do
chapter_name=$(echo "$line" | sed 's/^---CHAPTER://' | sed 's/---$//')
chapter_anchor=$(echo "$chapter_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "- [${chapter_name}](#${chapter_anchor})" >> "$output_file"
done
echo "" >> "$output_file"
echo "## 内容" >> "$output_file"
echo "" >> "$output_file"
# 章ごとのコンテンツ追加
awk '
BEGIN { in_chapter = 0; chapter_name = "" }
/^---CHAPTER:/ {
chapter_name = $0
gsub(/^---CHAPTER:/, "", chapter_name)
gsub(/---$/, "", chapter_name)
in_chapter = 1
print "\n### " chapter_name "\n"
next
}
/^---END_CHAPTER---/ {
in_chapter = 0
print "\n---\n"
next
}
in_chapter {
print $0
}
' "$structured_file" >> "$output_file"
# フッター追加
cat >> "$output_file" << EOF
## 関連ノート
-
## 参考リンク
-
---
*このノートは PDFファイル「${pdf_file}」から自動生成されました*
*生成日時: ${current_datetime}*
EOF
rm -f "$structured_file"
}
# メイン処理
main() {
local pdf_file=""
local tags=""
local category=""
# オプション解析
while [[ $# -gt 0 ]]; do
case $1 in
-v|--vault)
OBSIDIAN_VAULT="$2"
shift 2
;;
-o|--output)
OUTPUT_DIR="$2"
shift 2
;;
-t|--tags)
tags="$2"
shift 2
;;
-c|--category)
category="$2"
shift 2
;;
-h|--help)
show_usage
exit 0
;;
-*)
echo "不明なオプション: $1"
show_usage
exit 1
;;
*)
pdf_file="$1"
shift
;;
esac
done
# PDFファイルの確認
if [ -z "$pdf_file" ]; then
echo "エラー: PDFファイルが指定されていません"
show_usage
exit 1
fi
if [ ! -f "$pdf_file" ]; then
echo "エラー: ファイル '$pdf_file' が見つかりません"
exit 1
fi
# 依存関係チェック
check_dependencies
# 作業ディレクトリ準備
mkdir -p "$TEMP_DIR"
# Obsidian Vault ディレクトリの確認・作成
local vault_output_dir="$OBSIDIAN_VAULT/$OUTPUT_DIR"
mkdir -p "$vault_output_dir"
log_message "処理開始: $(basename "$pdf_file")"
# PDFからテキスト抽出
local temp_txt="$TEMP_DIR/extracted_text.txt"
if ! pdftotext "$pdf_file" "$temp_txt" 2>/dev/null; then
log_message "エラー: PDFからのテキスト抽出に失敗"
exit 1
fi
# メタデータ抽出
extract_pdf_metadata "$pdf_file"
# 出力ファイル名生成
local filename=$(basename "$pdf_file" .pdf)
local safe_filename=$(echo "$filename" | sed 's/[^a-zA-Z0-9._-]/_/g')
local output_file="$vault_output_dir/${safe_filename}.md"
# Markdown生成
generate_markdown "$pdf_file" "$temp_txt" "$output_file" "$tags" "$category"
# 一時ファイル削除
rm -rf "$TEMP_DIR"
log_message "完了: $output_file"
echo ""
echo "✅ 処理完了!"
echo "📁 出力先: $output_file"
echo "📖 Obsidianで確認してください"
}
# スクリプト実行
main "$@"
用語解説
YAML(ヤムル): 『Yet Another Markup Language』の略で、人間が読みやすいデータ『シリアライゼーション』形式です。設定ファイルやメタデータによく使用されます。
フロントマター: Markdownファイルの先頭に配置される『YAMLブロック』で、ファイルのメタデータを格納します。Obsidianではこの情報でノートを分類・検索できます。
set -euo pipefail: シェルスクリプトの『エラーハンドリング』オプションで、エラー発生時にスクリプトを停止させます。
AWK: テキスト処理用の『プログラミング言語』で、パターンマッチングとデータ抽出に優れています。
IFS: 『Internal Field Separator』の略で、シェルが単語を分割する際の区切り文字を指定する『環境変数』です。
使用方法
# スクリプトを実行可能にする
chmod +x pdf_to_obsidian.sh
# 基本的な使用
./pdf_to_obsidian.sh document.pdf
# タグとカテゴリを指定
./pdf_to_obsidian.sh -t "research,ai,技術" -c "AI研究" document.pdf
# Vaultパスを指定
./pdf_to_obsidian.sh -v "/path/to/your/vault" document.pdf
# 出力ディレクトリを指定
./pdf_to_obsidian.sh -o "研究資料" document.pdf
生成されるMarkdownの構造
---
title: "文書タイトル"
author: "著者名"
tags: ["research", "ai"]
category: "技術資料"
type: "pdf_import"
---
# 文書タイトル
## メタデータ
(表形式でPDF情報を表示)
## 概要
(PDFの概要)
## 目次
(自動生成された章立て)
## 内容
(構造化されたテキスト内容)
## 関連ノート
(手動で追加するリンク領域)
このスクリプトにより、PDFファイルが構造化されたObsidianノートとして自動変換され、効率的な『ナレッジベース』構築が可能になります。
これらの処理を指定のフォルダごとにくりかえし行うスクリプト
PDF一括処理 Obsidian スクリプト
#!/bin/bash
# PDF一括処理→Obsidian統合スクリプト
# フォルダ内のPDFファイルを再帰的に処理してObsidian Vaultに保存
set -euo pipefail
# 設定
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMP_DIR="$SCRIPT_DIR/temp"
LOG_FILE="$SCRIPT_DIR/pdf_batch_obsidian.log"
REPORT_FILE="$SCRIPT_DIR/processing_report.txt"
# デフォルト設定
DEFAULT_VAULT_PATH="$HOME/Documents/ObsidianVault"
DEFAULT_OUTPUT_DIR="PDFImports"
MAX_PARALLEL_JOBS=4
# 設定ファイルの読み込み
CONFIG_FILE="$SCRIPT_DIR/batch_config.conf"
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
fi
OBSIDIAN_VAULT="${OBSIDIAN_VAULT:-$DEFAULT_VAULT_PATH}"
OUTPUT_DIR="${OUTPUT_DIR:-$DEFAULT_OUTPUT_DIR}"
PARALLEL_JOBS="${PARALLEL_JOBS:-$MAX_PARALLEL_JOBS}"
# 統計変数
TOTAL_PDFS=0
PROCESSED_PDFS=0
FAILED_PDFS=0
SKIPPED_PDFS=0
# ログ関数
log_message() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}
# 進捗表示関数
show_progress() {
local current="$1"
local total="$2"
local percentage=$((current * 100 / total))
local bar_length=50
local filled_length=$((percentage * bar_length / 100))
printf "\r進捗: ["
printf "%*s" $filled_length | tr ' ' '='
printf "%*s" $((bar_length - filled_length)) | tr ' ' '-'
printf "] %d%% (%d/%d)" $percentage $current $total
}
# 使用方法表示
show_usage() {
cat << 'EOF'
使用方法: ./pdf_batch_obsidian.sh [オプション] <対象フォルダ>
オプション:
-v, --vault PATH Obsidian Vault のパス
-o, --output DIR 出力ディレクトリ名(Vault内)
-t, --tags TAGS 全PDFに追加するタグ(カンマ区切り)
-c, --category CAT デフォルトカテゴリ
-j, --jobs NUMBER 並列処理数(デフォルト: 4)
-r, --recursive サブフォルダも再帰的に処理
-f, --force 既存ファイルを上書き
-d, --dry-run 実際の処理は行わず、対象ファイルのみ表示
-q, --quiet 詳細出力を抑制
--skip-existing 既存のMarkdownファイルをスキップ
--organize-by-folder フォルダ構造を保持して整理
--max-size SIZE 処理するPDFの最大サイズ(MB)
-h, --help このヘルプを表示
例:
# 基本的な使用
./pdf_batch_obsidian.sh /path/to/pdf_folder
# サブフォルダも含めて処理
./pdf_batch_obsidian.sh -r /path/to/pdf_folder
# フォルダ構造を保持して整理
./pdf_batch_obsidian.sh -r --organize-by-folder /path/to/pdf_folder
# タグとカテゴリを指定
./pdf_batch_obsidian.sh -t "research,batch" -c "研究資料" /path/to/pdf_folder
# 並列処理数を指定
./pdf_batch_obsidian.sh -j 8 -r /path/to/pdf_folder
# 事前確認(実行しない)
./pdf_batch_obsidian.sh -d -r /path/to/pdf_folder
EOF
}
# 依存関係チェック
check_dependencies() {
local deps=("pdftotext" "pdfinfo" "find" "parallel")
local missing_deps=()
for cmd in "${deps[@]}"; do
if ! command -v "$cmd" &> /dev/null; then
missing_deps+=("$cmd")
fi
done
if [ ${#missing_deps[@]} -gt 0 ]; then
log_message "ERROR" "以下のコマンドがインストールされていません: ${missing_deps[*]}"
echo ""
echo "インストール方法:"
echo " Ubuntu/Debian:"
echo " sudo apt update"
echo " sudo apt install poppler-utils parallel"
echo ""
echo " CentOS/RHEL:"
echo " sudo yum install poppler-utils parallel"
echo ""
echo " macOS:"
echo " brew install poppler parallel"
exit 1
fi
}
# PDFファイル検索
find_pdf_files() {
local search_dir="$1"
local recursive="$2"
local max_size="$3"
local find_cmd="find '$search_dir'"
if [ "$recursive" != "true" ]; then
find_cmd="$find_cmd -maxdepth 1"
fi
find_cmd="$find_cmd -type f -iname '*.pdf'"
# ファイルサイズ制限
if [ -n "$max_size" ]; then
find_cmd="$find_cmd -size -${max_size}M"
fi
eval "$find_cmd" | sort
}
# PDFメタデータ抽出
extract_pdf_metadata() {
local pdf_file="$1"
local temp_info="$TEMP_DIR/pdf_info_$$.txt"
if ! pdfinfo "$pdf_file" > "$temp_info" 2>/dev/null; then
log_message "WARN" "PDFメタデータ取得失敗: $(basename "$pdf_file")"
echo "TITLE="
echo "AUTHOR="
echo "SUBJECT="
echo "CREATOR="
echo "PAGES=0"
echo "CREATION_DATE="
return 1
fi
# メタデータ抽出
echo "TITLE=$(grep "^Title:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")"
echo "AUTHOR=$(grep "^Author:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")"
echo "SUBJECT=$(grep "^Subject:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")"
echo "CREATOR=$(grep "^Creator:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")"
echo "PAGES=$(grep "^Pages:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "0")"
echo "CREATION_DATE=$(grep "^CreationDate:" "$temp_info" 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || echo "")"
rm -f "$temp_info"
}
# テキスト構造化
structure_content() {
local content_file="$1"
local structured_file="$TEMP_DIR/structured_content_$$.txt"
awk '
BEGIN {
chapter_num = 0
current_chapter = "序文"
content = ""
line_count = 0
}
# 章タイトルの検出パターン(改良版)
/^[0-9]+\.[0-9]*[[:space:]]/ || /^第[0-9]+章/ || /^Chapter [0-9]+/ ||
/^[0-9]+[[:space:]]*[A-Za-z]/ || /^[A-Z][A-Z\s]*$/ && length($0) < 50 ||
/^[0-9]+\.[[:space:]]*[A-Z]/ {
if (content != "" && line_count > 3) { # 最低3行以上の内容がある場合のみ
print "---CHAPTER:" current_chapter "---"
print content
print "---END_CHAPTER---"
}
current_chapter = $0
content = ""
line_count = 0
next
}
# 空行や短い行をスキップ
/^[[:space:]]*$/ || length($0) < 3 { next }
# ページ番号らしい行をスキップ
/^[0-9]+$/ && length($0) < 4 { next }
# 内容を蓄積
{
if (content != "") content = content "\n"
content = content $0
line_count++
}
END {
if (content != "" && line_count > 3) {
print "---CHAPTER:" current_chapter "---"
print content
print "---END_CHAPTER---"
}
}
' "$content_file" > "$structured_file"
echo "$structured_file"
}
# 単一PDFファイル処理
process_single_pdf() {
local pdf_file="$1"
local base_tags="$2"
local base_category="$3"
local output_base_dir="$4"
local organize_by_folder="$5"
local source_dir="$6"
local force_overwrite="$7"
local skip_existing="$8"
local pdf_basename=$(basename "$pdf_file")
local pdf_dir=$(dirname "$pdf_file")
local safe_filename=$(echo "${pdf_basename%.pdf}" | sed 's/[^a-zA-Z0-9._-]/_/g')
# 出力ディレクトリの決定
local final_output_dir="$output_base_dir"
if [ "$organize_by_folder" = "true" ]; then
local relative_dir=$(realpath --relative-to="$source_dir" "$pdf_dir")
if [ "$relative_dir" != "." ]; then
final_output_dir="$output_base_dir/$relative_dir"
fi
fi
mkdir -p "$final_output_dir"
local output_file="$final_output_dir/${safe_filename}.md"
# 既存ファイルのチェック
if [ -f "$output_file" ]; then
if [ "$skip_existing" = "true" ]; then
log_message "INFO" "スキップ(既存): $pdf_basename"
return 2 # スキップを示す特別なリターンコード
elif [ "$force_overwrite" != "true" ]; then
log_message "WARN" "スキップ(上書き禁止): $pdf_basename"
return 2
fi
fi
# テンポラリディレクトリ作成
local temp_work_dir="$TEMP_DIR/work_$$"
mkdir -p "$temp_work_dir"
# PDFからテキスト抽出
local temp_txt="$temp_work_dir/extracted_text.txt"
if ! pdftotext "$pdf_file" "$temp_txt" 2>/dev/null; then
log_message "ERROR" "テキスト抽出失敗: $pdf_basename"
rm -rf "$temp_work_dir"
return 1
fi
# 抽出されたテキストが空でないかチェック
if [ ! -s "$temp_txt" ]; then
log_message "WARN" "空のテキスト: $pdf_basename"
rm -rf "$temp_work_dir"
return 1
fi
# メタデータ抽出
local metadata
metadata=$(extract_pdf_metadata "$pdf_file")
eval "$metadata"
# フォルダベースのタグ追加
local folder_tag=""
if [ "$organize_by_folder" = "true" ]; then
folder_tag=$(basename "$pdf_dir" | sed 's/[^a-zA-Z0-9]/_/g' | tr '[:upper:]' '[:lower:]')
if [ -n "$folder_tag" ] && [ "$folder_tag" != "." ]; then
base_tags="${base_tags:+$base_tags,}folder_$folder_tag"
fi
fi
# カテゴリの決定(フォルダ名を使用する場合)
local final_category="$base_category"
if [ -z "$final_category" ] && [ "$organize_by_folder" = "true" ]; then
final_category=$(basename "$pdf_dir")
fi
# Markdown生成
generate_markdown "$pdf_file" "$temp_txt" "$output_file" "$base_tags" "$final_category" "$temp_work_dir"
# 一時ファイル削除
rm -rf "$temp_work_dir"
log_message "SUCCESS" "完了: $pdf_basename → $(basename "$output_file")"
return 0
}
# Markdown生成(改良版)
generate_markdown() {
local pdf_file="$1"
local content_file="$2"
local output_file="$3"
local tags="$4"
local category="$5"
local temp_dir="$6"
local filename=$(basename "$pdf_file" .pdf)
local current_date=$(date '+%Y-%m-%d')
local current_datetime=$(date '+%Y-%m-%d %H:%M:%S')
local file_size=$(stat -f%z "$pdf_file" 2>/dev/null || stat -c%s "$pdf_file" 2>/dev/null || echo "0")
local file_size_mb=$((file_size / 1024 / 1024))
# タグの処理
local formatted_tags=""
if [ -n "$tags" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$tags"
for tag in "${TAG_ARRAY[@]}"; do
tag=$(echo "$tag" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -n "$formatted_tags" ]; then
formatted_tags="$formatted_tags, \"$tag\""
else
formatted_tags="\"$tag\""
fi
done
fi
# 相対パス計算
local relative_pdf_path=$(realpath --relative-to="$(dirname "$output_file")" "$pdf_file" 2>/dev/null || echo "$pdf_file")
# Markdownファイル生成
cat > "$output_file" << EOF
---
title: "${TITLE:-$filename}"
author: "${AUTHOR}"
subject: "${SUBJECT}"
category: "${category}"
tags: [${formatted_tags}]
source_file: "${relative_pdf_path}"
source_file_absolute: "${pdf_file}"
creator: "${CREATOR}"
pages: ${PAGES}
file_size_mb: ${file_size_mb}
creation_date: "${CREATION_DATE}"
imported_date: "${current_datetime}"
type: "pdf_import"
status: "imported"
---
# ${TITLE:-$filename}
> [!info] 文書情報
> - **著者**: ${AUTHOR:-"不明"}
> - **ページ数**: ${PAGES}ページ
> - **ファイルサイズ**: ${file_size_mb}MB
> - **作成日**: ${CREATION_DATE:-"不明"}
> - **インポート日**: ${current_datetime}
## 📋 メタデータ
| 項目 | 内容 |
|------|------|
| **タイトル** | ${TITLE:-$filename} |
| **著者** | ${AUTHOR:-"不明"} |
| **作成者** | ${CREATOR:-"不明"} |
| **ページ数** | ${PAGES} |
| **ファイルサイズ** | ${file_size_mb}MB |
| **作成日** | ${CREATION_DATE:-"不明"} |
| **カテゴリ** | ${category:-"未分類"} |
| **ソースファイル** | \`${relative_pdf_path}\` |
## 📝 概要
${SUBJECT:-"PDFから抽出されたコンテンツです。"}
## 📖 目次
EOF
# 構造化されたコンテンツを処理
local structured_file=$(structure_content "$content_file")
# 目次生成
if [ -s "$structured_file" ]; then
grep "^---CHAPTER:" "$structured_file" | while read -r line; do
chapter_name=$(echo "$line" | sed 's/^---CHAPTER://' | sed 's/---$//')
chapter_anchor=$(echo "$chapter_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "- [${chapter_name}](#${chapter_anchor})" >> "$output_file"
done
fi
echo "" >> "$output_file"
echo "## 📄 内容" >> "$output_file"
echo "" >> "$output_file"
# 章ごとのコンテンツ追加
if [ -s "$structured_file" ]; then
awk '
BEGIN { in_chapter = 0; chapter_name = "" }
/^---CHAPTER:/ {
chapter_name = $0
gsub(/^---CHAPTER:/, "", chapter_name)
gsub(/---$/, "", chapter_name)
in_chapter = 1
print "\n### " chapter_name "\n"
next
}
/^---END_CHAPTER---/ {
in_chapter = 0
print "\n"
next
}
in_chapter {
print $0
}
' "$structured_file" >> "$output_file"
else
# 構造化できない場合は元のテキストを使用
echo "### 抽出されたテキスト" >> "$output_file"
echo "" >> "$output_file"
cat "$content_file" >> "$output_file"
fi
# フッター追加
cat >> "$output_file" << EOF
---
## 🔗 関連ノート
-
## 📚 参考リンク
-
## 🏷️ タグ
${tags}
---
> [!note] 生成情報
> このノートはPDFファイル「${pdf_file}」から自動生成されました
> 生成日時: ${current_datetime}
> スクリプト: PDF Batch to Obsidian Processor
EOF
rm -f "$structured_file"
}
# 処理統計レポート生成
generate_report() {
local start_time="$1"
local end_time="$2"
local source_dir="$3"
local duration=$((end_time - start_time))
local hours=$((duration / 3600))
local minutes=$(((duration % 3600) / 60))
local seconds=$((duration % 60))
cat > "$REPORT_FILE" << EOF
PDF一括処理レポート
==================
処理日時: $(date '+%Y-%m-%d %H:%M:%S')
対象フォルダ: $source_dir
出力先: $OBSIDIAN_VAULT/$OUTPUT_DIR
処理統計:
- 総PDF数: $TOTAL_PDFS
- 処理成功: $PROCESSED_PDFS
- 処理失敗: $FAILED_PDFS
- スキップ: $SKIPPED_PDFS
処理時間:
- 開始: $(date -d @$start_time '+%H:%M:%S' 2>/dev/null || date -r $start_time '+%H:%M:%S' 2>/dev/null || echo "N/A")
- 終了: $(date -d @$end_time '+%H:%M:%S' 2>/dev/null || date -r $end_time '+%H:%M:%S' 2>/dev/null || echo "N/A")
- 所要時間: ${hours}時間${minutes}分${seconds}秒
出力先フォルダ:
$OBSIDIAN_VAULT/$OUTPUT_DIR
設定:
- 並列処理数: $PARALLEL_JOBS
- フォルダ構造保持: $ORGANIZE_BY_FOLDER
- 既存ファイル上書き: $FORCE_OVERWRITE
- 再帰処理: $RECURSIVE
EOF
if [ $FAILED_PDFS -gt 0 ]; then
echo "失敗したファイル:" >> "$REPORT_FILE"
grep "ERROR" "$LOG_FILE" | tail -n $FAILED_PDFS >> "$REPORT_FILE"
fi
log_message "INFO" "レポート生成完了: $REPORT_FILE"
}
# メイン処理
main() {
local source_dir=""
local tags=""
local category=""
local recursive=false
local dry_run=false
local quiet=false
local force_overwrite=false
local skip_existing=false
local organize_by_folder=false
local max_size=""
# オプション解析
while [[ $# -gt 0 ]]; do
case $1 in
-v|--vault)
OBSIDIAN_VAULT="$2"
shift 2
;;
-o|--output)
OUTPUT_DIR="$2"
shift 2
;;
-t|--tags)
tags="$2"
shift 2
;;
-c|--category)
category="$2"
shift 2
;;
-j|--jobs)
PARALLEL_JOBS="$2"
shift 2
;;
-r|--recursive)
recursive=true
shift
;;
-f|--force)
force_overwrite=true
shift
;;
-d|--dry-run)
dry_run=true
shift
;;
-q|--quiet)
quiet=true
shift
;;
--skip-existing)
skip_existing=true
shift
;;
--organize-by-folder)
organize_by_folder=true
shift
;;
--max-size)
max_size="$2"
shift 2
;;
-h|--help)
show_usage
exit 0
;;
-*)
echo "不明なオプション: $1"
show_usage
exit 1
;;
*)
source_dir="$1"
shift
;;
esac
done
# 引数チェック
if [ -z "$source_dir" ]; then
echo "エラー: 対象フォルダが指定されていません"
show_usage
exit 1
fi
if [ ! -d "$source_dir" ]; then
echo "エラー: フォルダ '$source_dir' が見つかりません"
exit 1
fi
# 依存関係チェック
check_dependencies
# ログ初期化
> "$LOG_FILE"
log_message "INFO" "PDF一括処理開始"
log_message "INFO" "対象フォルダ: $source_dir"
log_message "INFO" "出力先: $OBSIDIAN_VAULT/$OUTPUT_DIR"
# 作業ディレクトリ準備
mkdir -p "$TEMP_DIR"
# Vault準備
local vault_output_dir="$OBSIDIAN_VAULT/$OUTPUT_DIR"
mkdir -p "$vault_output_dir"
# PDFファイル検索
log_message "INFO" "PDFファイル検索中..."
local pdf_files
mapfile -t pdf_files < <(find_pdf_files "$source_dir" "$recursive" "$max_size")
TOTAL_PDFS=${#pdf_files[@]}
if [ $TOTAL_PDFS -eq 0 ]; then
log_message "WARN" "処理対象のPDFファイルが見つかりません"
exit 0
fi
log_message "INFO" "発見されたPDF数: $TOTAL_PDFS"
# Dry run モード
if [ "$dry_run" = true ]; then
echo "=== 処理対象ファイル一覧 ==="
printf "%s\n" "${pdf_files[@]}"
echo ""
echo "総数: $TOTAL_PDFS ファイル"
echo "出力先: $vault_output_dir"
exit 0
fi
# 処理開始
local start_time=$(date +%s)
log_message "INFO" "一括処理開始(並列数: $PARALLEL_JOBS)"
# 並列処理用の関数をエクスポート
export -f process_single_pdf extract_pdf_metadata structure_content generate_markdown log_message
export TEMP_DIR OBSIDIAN_VAULT OUTPUT_DIR
export TITLE AUTHOR SUBJECT CREATOR PAGES CREATION_DATE
# 並列処理実行
printf "%s\n" "${pdf_files[@]}" | parallel -j "$PARALLEL_JOBS" --bar \
process_single_pdf {} "$tags" "$category" "$vault_output_dir" "$organize_by_folder" "$source_dir" "$force_overwrite" "$skip_existing" \
'&&' 'echo "SUCCESS"' '||' 'echo "FAILED"' | \
while read result; do
case $result in
"SUCCESS")
((PROCESSED_PDFS++))
;;
"FAILED")
((FAILED_PDFS++))
;;
*)
((SKIPPED_PDFS++))
;;
esac
if [ "$quiet" != true ]; then
show_progress $((PROCESSED_PDFS + FAILED_PDFS + SKIPPED_PDFS)) $TOTAL_PDFS
fi
done
echo "" # 進捗バーの後の改行
# 処理完了
local end_time=$(date +%s)
log_message "INFO" "一括処理完了"
log_message "INFO" "成功: $PROCESSED_PDFS, 失敗: $FAILED_PDFS, スキップ: $SKIPPED_PDFS"
# 統計レポート生成
generate_report "$start_time" "$end_time" "$source_dir"
# 一時ディレクトリ削除
rm -rf "$TEMP_DIR"
# 結果表示
echo ""
echo "✅ 処理完了!"
echo "📊 統計:"
echo " - 処理成功: $PROCESSED_PDFS/$TOTAL_PDFS"
echo " - 処理失敗: $FAILED_PDFS/$TOTAL_PDFS"
echo " - スキップ: $SKIPPED_PDFS/$TOTAL_PDFS"
echo "📁 出力先: $vault_output_dir"
echo "📄 詳細レポート: $REPORT_FILE"
echo "📋 ログファイル: $LOG_FILE"
}
# トラップ設定(Ctrl+C対応)
trap 'echo ""; log_message "WARN" "処理が中断されました"; rm -rf "$TEMP_DIR"; exit 130' INT TERM
# スクリプト実行
main "$@"
バッチ処理設定ファイル
# PDF一括処理→Obsidian 設定ファイル
# このファイルをスクリプトと同じディレクトリに配置
# Obsidian Vault のパス
OBSIDIAN_VAULT="$HOME/Documents/MyObsidianVault"
# 出力ディレクトリ名(Vault内)
OUTPUT_DIR="ImportedPDFs"
# デフォルトタグ(カンマ区切り)
DEFAULT_TAGS="pdf,imported,batch"
# デフォルトカテゴリ
DEFAULT_CATEGORY="資料"
# 並列処理数(CPUコア数に応じて調整)
PARALLEL_JOBS=4
# ファイルサイズ制限(MB)- 大きすぎるファイルをスキップ
MAX_FILE_SIZE_MB=100
# ログ保持日数
LOG_RETENTION_DAYS=30
# フォルダ別の処理設定
# フォルダ名をキーとして、そのフォルダ内のPDFに適用する設定
declare -A FOLDER_CONFIGS
FOLDER_CONFIGS["研究論文"]="tags:paper,research,academic category:論文"
FOLDER_CONFIGS["技術資料"]="tags:tech,manual,reference category:技術文書"
FOLDER_CONFIGS["会議資料"]="tags:meeting,presentation category:会議"
FOLDER_CONFIGS["契約書"]="tags:contract,legal,document category:法務"
FOLDER_CONFIGS["マニュアル"]="tags:manual,howto,guide category:マニュアル"
# 除外パターン(正規表現)
EXCLUDE_PATTERNS=(
".*temp.*"
".*backup.*"
".*~$"
".*\.tmp$"
)
# 処理対象外のファイル名パターン
SKIP_FILE_PATTERNS=(
"draft_*"
"old_*"
"*_backup*"
)
フォルダ監視・自動処理スクリプト
#!/bin/bash
# PDF監視・自動処理スクリプト
# 指定フォルダを監視し、新しいPDFファイルが追加されたら自動的にObsidianに取り込む
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WATCH_CONFIG="$SCRIPT_DIR/watch_config.conf"
WATCH_LOG="$SCRIPT_DIR/pdf_watcher.log"
PID_FILE="$SCRIPT_DIR/pdf_watcher.pid"
# デフォルト設定
DEFAULT_WATCH_DIRS=()
DEFAULT_POLL_INTERVAL=30
# 設定読み込み
if [ -f "$WATCH_CONFIG" ]; then
source "$WATCH_CONFIG"
fi
WATCH_DIRS=("${WATCH_DIRS[@]:-${DEFAULT_WATCH_DIRS[@]}}")
POLL_INTERVAL="${POLL_INTERVAL:-$DEFAULT_POLL_INTERVAL}"
# ログ関数
log_watch() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | tee -a "$WATCH_LOG"
}
# 使用方法表示
show_watch_usage() {
cat << 'EOF'
PDF監視・自動処理スクリプト
使用方法:
./pdf_watcher.sh start # 監視開始
./pdf_watcher.sh stop # 監視停止
./pdf_watcher.sh status # 実行状態確認
./pdf_watcher.sh restart # 再起動
./pdf_watcher.sh test <dir> # テスト実行
./pdf_watcher.sh add-watch <dir> # 監視対象追加
./pdf_watcher.sh list-watches # 監視対象一覧
設定ファイル: watch_config.conf
ログファイル: pdf_watcher.log
EOF
}
# 監視対象ディレクトリの追加
add_watch_dir() {
local new_dir="$1"
if [ ! -d "$new_dir" ]; then
echo "エラー: ディレクトリ '$new_dir' が存在しません"
return 1
fi
# 設定ファイルに追加
if ! grep -q "\"$new_dir\"" "$WATCH_CONFIG" 2>/dev/null; then
echo "WATCH_DIRS+=(\"$new_dir\")" >> "$WATCH_CONFIG"
echo "監視対象に追加しました: $new_dir"
else
echo "既に監視対象です: $new_dir"
fi
}
# 監視対象ディレクトリ一覧表示
list_watch_dirs() {
echo "監視対象ディレクトリ:"
if [ ${#WATCH_DIRS[@]} -eq 0 ]; then
echo " 設定されていません"
else
for dir in "${WATCH_DIRS[@]}"; do
echo " - $dir"
done
fi
}
# プロセス状態確認
check_status() {
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE")
if ps -p "$pid" > /dev/null 2>&1; then
echo "実行中 (PID: $pid)"
return 0
else
echo "停止中 (PIDファイルが残存)"
rm -f "$PID_FILE"
return 1
fi
else
echo "停止中"
return 1
fi
}
# ファイル変更検出
detect_new_pdfs() {
local watch_dir="$1"
local state_file="$SCRIPT_DIR/.watch_state_$(echo "$watch_dir" | sed 's|/|_|g')"
local current_state_file="$SCRIPT_DIR/.current_state_$$"
# 現在のPDFファイル一覧を取得
find "$watch_dir" -type f -iname "*.pdf" -exec stat -f "%m %N" {} \; 2>/dev/null | sort > "$current_state_file" || \
find "$watch_dir" -type f -iname "*.pdf" -exec stat -c "%Y %n" {} \; 2>/dev/null | sort > "$current_state_file"
# 前回の状態ファイルが存在しない場合は作成
if [ ! -f "$state_file" ]; then
cp "$current_state_file" "$state_file"
rm -f "$current_state_file"
return 0
fi
# 差分を検出
local new_files
new_files=$(comm -13 "$state_file" "$current_state_file" | cut -d' ' -f2-)
if [ -n "$new_files" ]; then
echo "$new_files"
# 状態更新
cp "$current_state_file" "$state_file"
fi
rm -f "$current_state_file"
}
# 単一PDFファイルの処理
process_detected_pdf() {
local pdf_file="$1"
local watch_dir="$2"
log_watch "INFO" "新しいPDFを検出: $(basename "$pdf_file")"
# フォルダベースの設定を適用
local folder_name=$(basename "$watch_dir")
local tags=""
local category=""
# 設定ファイルからフォルダ別設定を取得
if declare -p FOLDER_CONFIGS > /dev/null 2>&1; then
if [[ -v FOLDER_CONFIGS["$folder_name"] ]]; then
local config="${FOLDER_CONFIGS[$folder_name]}"
tags=$(echo "$config" | grep -o 'tags:[^[:space:]]*' | cut -d: -f2)
category=$(echo "$config" | grep -o 'category:[^[:space:]]*' | cut -d: -f2)
fi
fi
# メインスクリプトを呼び出し
if "$SCRIPT_DIR/pdf_batch_obsidian.sh" -t "$tags" -c "$category" --skip-existing "$pdf_file"; then
log_watch "SUCCESS" "処理完了: $(basename "$pdf_file")"
# 通知(オプション)
if command -v notify-send > /dev/null 2>&1; then
notify-send "PDF取り込み完了" "$(basename "$pdf_file") をObsidianに取り込みました"
fi
else
log_watch "ERROR" "処理失敗: $(basename "$pdf_file")"
fi
}
# メイン監視ループ
watch_loop() {
log_watch "INFO" "PDF監視開始"
log_watch "INFO" "監視間隔: ${POLL_INTERVAL}秒"
# 監視対象確認
if [ ${#WATCH_DIRS[@]} -eq 0 ]; then
log_watch "ERROR" "監視対象ディレクトリが設定されていません"
exit 1
fi
for dir in "${WATCH_DIRS[@]}"; do
log_watch "INFO" "監視対象: $dir"
done
# 無限ループで監視
while true; do
for watch_dir in "${WATCH_DIRS[@]}"; do
if [ ! -d "$watch_dir" ]; then
log_watch "WARN" "監視対象が存在しません: $watch_dir"
continue
fi
# 新しいPDFファイルを検出
local new_pdfs
new_pdfs=$(detect_new_pdfs "$watch_dir")
if [ -n "$new_pdfs" ]; then
while IFS= read -r pdf_file; do
if [ -f "$pdf_file" ]; then
# 少し待機(ファイルの書き込み完了を待つ)
sleep 2
process_detected_pdf "$pdf_file" "$watch_dir" &
fi
done <<< "$new_pdfs"
fi
done
# 指定間隔で待機
sleep "$POLL_INTERVAL"
done
}
# 監視開始
start_watch() {
if check_status > /dev/null 2>&1; then
echo "既に実行中です"
return 1
fi
echo "PDF監視を開始します..."
nohup "$0" --daemon > /dev/null 2>&1 &
echo $! > "$PID_FILE"
echo "監視開始 (PID: $(cat "$PID_FILE"))"
}
# 監視停止
stop_watch() {
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE")
if ps -p "$pid" > /dev/null 2>&1; then
kill "$pid"
rm -f "$PID_FILE"
echo "監視を停止しました"
else
rm -f "$PID_FILE"
echo "プロセスが見つかりません"
fi
else
echo "監視は実行されていません"
fi
}
# テスト実行
test_watch() {
local test_dir="$1"
if [ ! -d "$test_dir" ]; then
echo "エラー: テスト対象ディレクトリが存在しません: $test_dir"
return 1
fi
echo "テスト実行: $test_dir"
WATCH_DIRS=("$test_dir")
POLL_INTERVAL=5
echo "5秒間隔で監視します(Ctrl+Cで停止)"
watch_loop
}
# メイン処理
case "${1:-}" in
start)
start_watch
;;
stop)
stop_watch
;;
restart)
stop_watch
sleep 1
start_watch
;;
status)
check_status
;;
test)
if [ -z "${2:-}" ]; then
echo "エラー: テスト対象ディレクトリを指定してください"
exit 1
fi
test_watch "$2"
;;
add-watch)
if [ -z "${2:-}" ]; then
echo "エラー: 監視対象ディレクトリを指定してください"
exit 1
fi
add_watch_dir "$2"
;;
list-watches)
list_watch_dirs
;;
--daemon)
# デーモンモード(内部使用)
watch_loop
;;
*)
show_watch_usage
exit 1
;;
esac