0
0

iCloudにアップされる.pdfファイルの監視・圧縮処理自動化

Last updated at Posted at 2024-09-13

前提

  • 開発環境:MacBook
  • Golangインストール済み
  • 社内でのファイル共有にはiCloudを使用

要件

  • 極力低コストで毎月大量の税務資料ファイルを税理士側(全員Windows)と共有したい→月単位で用意したフォルダの圧縮ファイルをメール送信
  • スキャンした.pdfデータの容量が必要以上に大きく、クラウド容量を圧迫したり税理士側に送る際に圧縮ファイルを直接メール添付出来なかったりする為、.pdfファイルをiCloudにアップされたタイミングで自動圧縮処理したい

Automator(フォルダアクション等)+AppleScriptの設定では自由度が低いのと、MacOSでしか使えないので、環境の変化に対応しやすいGolangで行こう!となりました

手順

Golangfsnotifyパッケージを使用してiCloud上の共有フォルダ内の更新を監視し、.pdfファイルが追加された場合のみシェルスクリプトで該当ファイルのバックアップを保存した上で適宜圧縮処理します

1. 共有フォルダ更新をGolangで監視

(1) .goファイルを置くディレクトリに移動

bash
cd /Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/

(2) fsnotifyパッケージをインストール

ファイルシステムの監視にfsnotifyパッケージを使用します

bash
go get -u github.com/fsnotify/fsnotify

(3) Goコード作成・保存

addWatchDir(path string)で、該当共有フォルダ内に新たに追加されたディレクトリも監視対象として追加されます

主軸になるのは新規ファイル作成イベントですが、ファイルリネーム直後の新規ファイル作成イベントと区別する為にファイルサイズの記録をmake(map[string]int64)で保持する形を取っています

また、iCloudの同期タイミングで同じイベントに対するログ出力等の処理が重複するのを防ぐ目的で5秒待つ様にしています

watch_folder.go
package main

import (
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"syscall"
	"time"

	"github.com/fsnotify/fsnotify"
)

var watcher *fsnotify.Watcher

// 監視ディレクトリ追加
func addWatchDir(path string) error {
	return filepath.Walk(path, func(walkPath string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			err = watcher.Add(walkPath)
			if err != nil {
				log.Printf("Error watching path %s: %v", walkPath, err)
			}
		}
		return nil
	})
}

// ファイルロックを取得する関数
func lockFileHandler(tmpFile *os.File) error {
	return syscall.Flock(int(tmpFile.Fd()), syscall.LOCK_EX)
}

// ファイルロックを解放する関数
func unlockFileHandler(tmpFile *os.File) error {
	return syscall.Flock(int(tmpFile.Fd()), syscall.LOCK_UN)
}

// .pdf圧縮シェルスクリプトの実行とバックアップ
func runCompressPDFScript(filePath string) error {
	// ロックファイルを作成
	lockFilePath := "/tmp/compress_pdfs.lock"
	lockFile, err := os.OpenFile(lockFilePath, os.O_CREATE|os.O_RDWR, 0666)
	if err != nil {
		return err
	}
	defer lockFile.Close()

	// ファイルをロック
	if err := lockFileHandler(lockFile); err != nil {
		return err
	}
	defer unlockFileHandler(lockFile) //処理終了時に必ずロックを解放

	// 圧縮処理の実行
	cmd := exec.Command("/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/compress_pdfs.sh", filePath)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

// ファイルサイズの記録を保持するマップ
var fileSizes = make(map[string]int64)

// ファイルサイズを取得する関数
func getFileSize(filename string) (int64, error) {
	info, err := os.Stat(filename)
	if err != nil {
		return 0, err
	}
	return info.Size(), nil
}

// サイズに基づいて既存のファイルを探す関数
func findFileBySize(size int64) (string, bool) {
	for fileName, fileSize := range fileSizes {
		if fileSize == size {
			return fileName, true
		}
	}
	return "", false
}

// 新規ファイルを処理する関数
func handleNewFile(fileName string, size int64) {
	log.Printf("新規PDFファイルが作成されました: %s, サイズ: %d", fileName, size)
	fileSizes[fileName] = size

	// ファイルの情報を取得
	fileInfo, err := os.Stat(fileName)
	if err != nil {
		log.Printf("ファイル情報の取得に失敗しました (%s): %v", fileName, err)
		return
	}

	// ファイル作成から5秒以上経過しているか(リネームによる新規作成ではないか)確認
	if time.Since(fileInfo.ModTime()) > 5*time.Second {
		// 日付形式のファイル名を生成
		dateFormat := time.Now().Format("20060102")
		originalFileName := fileName
		baseFileName := strings.TrimSuffix(filepath.Base(originalFileName), filepath.Ext(originalFileName)) // 拡張子を除いたファイル名
		newFileName := filepath.Join(filepath.Dir(originalFileName), dateFormat+"_"+baseFileName+filepath.Ext(originalFileName))

		// ファイル名を変更
		err := os.Rename(fileName, newFileName)
		if err != nil {
			log.Println("ファイルのリネームに失敗しました:", err)
		} else {
			log.Println("ファイルをリネームしました:", newFileName)
		}

		// PDF圧縮スクリプトを実行
		err = runCompressPDFScript(newFileName) // リネーム後のファイル名を使用
		if err != nil {
			log.Printf("compress_pdfs.sh 実行エラー (%s): %v", newFileName, err)
			return
		}
		log.Printf("PDFファイルの圧縮が完了しました: %s", newFileName)

		// 圧縮後のファイルサイズを再取得
		newSize, err := getFileSize(newFileName)
		if err != nil {
			log.Printf("圧縮後のファイルサイズ取得エラー (%s): %v", newFileName, err)
			return
		}

		// 圧縮後のサイズでマップを更新
		fileSizes[newFileName] = newSize
		log.Printf("圧縮後のファイルサイズ: %s, %d bytes", newFileName, newSize)

	} else {
		log.Printf("ファイル作成から5秒経過していないため圧縮をスキップ: %s", fileName)
	}
}

// メイン処理のループ内で使用する関数
func handleCreateEvent(event fsnotify.Event) {
	if filepath.Ext(event.Name) == ".pdf" {
		// ファイルサイズ取得
		currentSize, err := getFileSize(event.Name)
		if err != nil {
			log.Printf("ファイルサイズ取得エラー (%s): %v", event.Name, err)
			return
		}
		// ファイルサイズ情報を比較
		existingName, renamed := findFileBySize(currentSize)
		if renamed {
			handleRenamedFile(event.Name, existingName, currentSize)
		} else {
			handleNewFile(event.Name, currentSize)
		}
	}
}

// リネームされたファイルを処理する関数
func handleRenamedFile(newFileName, oldName string, size int64) {
	// ファイルの情報を取得
	fileInfo, err := os.Stat(newFileName)
	if err != nil {
		log.Printf("ファイル情報の取得に失敗しました (%s): %v", newFileName, err)
		return
	}

	// ファイルの作成から5秒経過しているか確認
	if time.Since(fileInfo.ModTime()) > 5*time.Second {
		log.Printf("ファイルがリネームされた可能性があります(新しい名前: %s, 元の名前: %s, サイズ: %d)", newFileName, oldName, size)
		delete(fileSizes, oldName)
		fileSizes[newFileName] = size
	} else {
		// 新規作成されたファイルとして処理
		fileSizes[newFileName] = size
	}
}

func main() {
	// ログファイル
	logFile := "/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/log/compress_pdfs.log"
	file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	log.SetOutput(file)

	// 監視対象ディレクトリ
	watchDir := "/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/税務関係"

	watcher, err = fsnotify.NewWatcher()
	if err != nil {
		log.Fatal(err)
	}
	defer watcher.Close()

	err = addWatchDir(watchDir)
	if err != nil {
		log.Fatal(err)
	}

	log.Println("監視を開始しました:", watchDir)

	// イベント監視用のgoroutine
	go func() {
		for {
			select {
			case event, ok := <-watcher.Events:
				if !ok {
					return
				}
				if event.Op&fsnotify.Create == fsnotify.Create {
					handleCreateEvent(event)
				}
			case err, ok := <-watcher.Errors:
				if !ok {
					return
				}
				log.Println("エラー:", err)
			}
		}
	}()

	// 無限ループで待機
	done := make(chan bool)
	<-done
}

(4) Goモジュール初期化・必要な依存関係を追加

プロジェクトのルートディレクトリに移動し、下記を実行

bash
go mod init compress_pdfs
go get github.com/fsnotify/fsnotify

(5) Goプログラムをビルド

bash
go build -o watch_folder watch_folder.go

2. シェルスクリプトで.pdf圧縮処理を自動化

(1) Ghostscript導入

.pdf圧縮の為にGhostscriptを使用します(Homebrewでインストール出来ます)

bash
brew install ghostscript

(2) .shコード作成・保存

ファイルサイズを取得して、一定以上のサイズの時のみ圧縮処理が走る様にしています(ここは.go側のファイルサイズのマップ処理とまとめてしまっても良いかもしれませんが・・・まあ、おいおいって事で)

圧縮処理対象の.pdfに対しては、念の為バックアップを別ディレクトリに取っておきます

テキストが埋め込まれた.pdfに対するGhostscriptでの処理時の文字化けを防ぐ為、-dPDFSETTINGS=/screenを設定しています

compress_pdfs.sh
#!/bin/bash

# PATH環境変数を修正
export PATH="/opt/homebrew/bin:$PATH"

# ログファイルのパスを設定
log_file="/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/log/compress_pdfs.log"

echo "Script started at $(date)" >> "$log_file"

# 標準エラー出力
exec 2>>"$log_file"

# バックアップディレクトリを設定
backup_dir="/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/log/pdf_backups"

# バックアップディレクトリが存在しない場合は作成
mkdir -p "$backup_dir"

# 引数で渡されたファイルを処理
file="$1"

# 同期完了を待つ
max_retries=10
retries=0
previous_size=0
wait_time=5  # 秒

# ファイルサイズが変わらなくなるまで待つ
while [ $retries -lt $max_retries ]; do
  if [ -f "$file" ]; then
    current_size=$(stat -f%z "$file")
    if [ "$current_size" -ne "$previous_size" ]; then
      previous_size="$current_size"
      sleep $wait_time
      retries=$((retries + 1))
    else
      break
    fi
  fi
done

# ファイルの最終更新時刻を取得
file_mod_time=$(stat -f %m "$file")

# 現在の時刻を取得
current_time=$(date +%s)

# ファイルが変更されてからの経過時間を計算
time_diff=$((current_time - file_mod_time))

# デバッグ用
echo "Starting script" >> "$log_file"

# 圧縮対象かチェック
file_size=$(stat -f%z "$file")
file_size_kb=$((file_size / 1024))  # KBに変換

# ファイルが変更されてから60秒未満なら処理をスキップ
if [ $time_diff -lt 60 ]; then
  echo "Skipping $file, too soon after modification (time diff: ${time_diff}s)" >> "$log_file"
  exit 0
elif xattr -p user.compressed "$file" &> /dev/null || [ $file_size_kb -le 200 ]; then
  echo "Skipping $file (size: ${file_size_kb}KB, already compressed or small enough)" >> "$log_file"
  exit 0
else
  # バックアップファイル名を決定
  backup_file="$backup_dir/$(basename "${file%.pdf}").pdf"

  # バックアップを作成
  cp "$file" "$backup_file"
  echo "Backed up $file to $backup_file" >> "$log_file"

  # 一時的な圧縮ファイルのパスを設定
  temp_file="${file%.pdf}_compressed.pdf"

  # Ghostscriptを使ってPDFを圧縮
  gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.5 \
    -dPDFSETTINGS=/screen \
    -dNOPAUSE -dBATCH \
    -dDownsampleColorImages=true \
    -dDownsampleGrayImages=true \
    -dDownsampleMonoImages=true \
    -dColorImageResolution=80 \
    -dGrayImageResolution=80 \
    -dMonoImageResolution=80 \
    -dJPEGQ=60 \
    -dEmbedAllFonts=true \
    -dSubsetFonts=true \
    -dRemoveMetadata=false \
    -sOutputFile="$temp_file" "$file"
  
  # 圧縮後のファイルサイズを取得
  compressed_size=$(stat -f%z "$temp_file")
  compressed_size_kb=$((compressed_size / 1024))  # 圧縮後のサイズをKBに変換

  # 圧縮後のサイズが元のサイズより小さい場合のみ置き換え
  if [ $compressed_size -lt $file_size ]; then
    mv "$temp_file" "$file"
    echo "Successfully compressed $file (${file_size_kb}KB -> ${compressed_size_kb}KB)" >> "$log_file"
    # 圧縮済みの属性を設定
    xattr -w user.compressed true "$file"
  else
    rm "$temp_file"
    echo "Compression did not reduce file size for $file, original file kept" >> "$log_file"
  fi
fi

# ログに終了時刻を記録
echo "Compression process completed at $(date)" >> "$log_file"

3. launchdでジョブ設定

launchdを使用する事で、ジョブがハングしたり異常終了した場合に自動的に再起動させる事が出来ます

(1) launchdの設定ファイルを作成する

com.user.watch_folder.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<!-- 環境変数 -->
	<key>EnvironmentVariables</key>
	<dict>
		<key>PATH</key>
		<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
	</dict>

	<key>Label</key>
	<string>com.user.watch_folder</string>

	<!-- ファイルの完全な変更を監視 -->
	<key>WatchPaths</key>
	<array>
		<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/税務関係</string>
	</array>

	<!-- 実行するコマンド -->
	<key>ProgramArguments</key>
	<array>
		<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/watch_folder</string>
	</array>

	<!-- 複数回トリガーされるのを防ぐためにThrottleIntervalを追加 -->
	<key>ThrottleInterval</key>
	<integer>60</integer> <!-- 60秒間、同じイベントを抑制 -->

	<!-- システム起動時に自動実行 -->
	<key>RunAtLoad</key>
	<true/>

	<!-- 異常終了時に自動再起動 -->
	<key>KeepAlive</key>
	<dict>
		<key>SuccessfulExit</key>
		<false/>
	</dict>

	<!-- 標準出力・エラー出力のログ -->
	<key>StandardOutPath</key>
	<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/log/watch_folder.stdout</string>
	<key>StandardErrorPath</key>
	<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs/log/watch_folder.stderr</string>

	<!-- 作業ディレクトリ -->
	<key>WorkingDirectory</key>
	<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/{{dirName}}/compress_pdfs</string>
</dict>
</plist>

(2) LaunchAgentsとして保存

com.user.watch_folderという名前で.plistとして下記の場所に保存

スクリーンショット 2024-09-13 17.16.25.png

4. 動かしてみる

単にMacBookを再起動するだけでも良いですが、下記コマンドでも起動出来ます

LaunchAgentをロードする

bash
launchctl unload ~/Library/LaunchAgents/com.user.watch_folder.plist
launchctl load ~/Library/LaunchAgents/com.user.watch_folder.plist

画面上に、この様なシステム通知が表示されたら成功です

スクリーンショット 2024-09-07 16.56.41.png

5. プロセスを終了する場合

下記で、実行中の全てのwatch_folderプロセスを確認出来ます

bash
ps aux | grep watch_folder

コマンドラインに "watch_folder" を含むすべてのプロセスを終了させる

bash
pkill -f watch_folder
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0