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

# 前提
- 開発環境：MacBook
- [Golang](https://go.dev/)インストール済み
- 社内でのファイル共有にはiCloudを使用

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

Automator（フォルダアクション等）＋AppleScriptの設定では自由度が低く、またMacOSでしか使えないので、比較的環境の変化に対応しやすいGolangで行こう！となりました

# 手順
`Golang`で`fsnotify`パッケージを使用してiCloud上の共有フォルダ内の更新を監視し、.pdfファイルが追加された場合のみ、該当ファイルが特定のディレクトリに存在する場合はリネームを行い、バックアップを保存した上で適宜圧縮処理します

また、このスクリプトを`MacOS launchd`に設定する事により自動化します

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

.goファイルを置くディレクトリに移動しておく

```bash:bash
cd /Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/check_pdf
```

### (1) Goモジュール初期化・必要なパッケージをインストール

ファイルシステムの監視に`fsnotify`を使用します

```bash:bash
go mod init check_pdf
go get github.com/fsnotify/fsnotify
```

### (2) Goコード作成・保存

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

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

```go:process_pdf.go
package main

import (
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/fsnotify/fsnotify"
)

// バックアップディレクトリ
var backupDir = "/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/check_pdf/log/pdf_backups"

var (
	processedFiles = make(map[string]bool)
	watcher        *fsnotify.Watcher
	logFile        *os.File
)

// 監視ディレクトリ追加
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 getFileSize(filePath string) (int64, error) {
	info, err := os.Stat(filePath)
	if err != nil {
		return 0, err
	}
	return info.Size() / 1024, nil //KBに変換
}

// バックアップ作成用
func copyFile(src, dst string) error {
	input, err := os.Open(src)
	if err != nil {
		return err
	}
	defer input.Close()

	output, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer output.Close()

	_, err = io.Copy(output, input)
	return err
}

// .pdf圧縮処理
func compressPDF(filePath string) error {
	// PATH環境変数
	os.Setenv("PATH", "/opt/homebrew/bin:"+os.Getenv("PATH"))

	// 同期完了を待つ
	maxRetries := 10
	retries := 0
	previousSize := int64(0)

	for retries < maxRetries {
		if _, err := os.Stat(filePath); err == nil {
			currentSize, _ := getFileSize(filePath)
			if currentSize != previousSize {
				previousSize = currentSize
				time.Sleep(5 * time.Second)
				retries++
			} else {
				break
			}
		}
	}

	// ファイルサイズ取得
	fileSize, err := getFileSize(filePath)
	if err != nil {
		return err
	}
	log.Printf("元のファイルサイズ: %dKB", fileSize)

	if fileSize <= 200 {
		log.Printf("圧縮処理をスキップ %s (size: %dKB, 既に圧縮済みまたは小さいファイル)", filePath, fileSize)
		return nil
	}

	// 一時ファイル
	tempFile := strings.TrimSuffix(filePath, ".pdf") + "_compressed.pdf"

	// Ghostscript実行
	cmd := exec.Command("gs",
		"-sDEVICE=pdfwrite",
		"-dCompatibilityLevel=1.5",
		"-dPDFSETTINGS=/screen",
		"-dNOPAUSE", "-dBATCH",
		"-dDownsampleColorImages=true",
		"-dDownsampleGrayImages=true",
		"-dDownsampleMonoImages=true",
		"-dColorImageResolution=150",
		"-dGrayImageResolution=150",
		"-dMonoImageResolution=150",
		"-dJPEGQ=80",
		"-dEmbedAllFonts=true",
		"-dSubsetFonts=true",
		"-dRemoveMetadata=false",
		"-sOutputFile="+tempFile,
		filePath,
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		log.Printf("Ghostscript実行エラー: %v", err)
		log.Printf("Ghostscript出力:\n%s", string(output))
		os.Remove(tempFile) //エラー時空ファイル削除
		return fmt.Errorf("ghostscript失敗: %w", err)
	}

	// 圧縮後ファイルサイズ取得
	compressedSize, err := getFileSize(tempFile)
	if err != nil {
		os.Remove(tempFile)
		return fmt.Errorf("圧縮ファイル読み込み失敗: %v", err)
	}
	if compressedSize == 0 {
		os.Remove(tempFile)
		log.Printf("圧縮結果が0バイトのため削除: %s", tempFile)
		return fmt.Errorf("圧縮ファイルが空です")
	}
	log.Printf("圧縮後ファイルサイズ: %dKB", compressedSize)

	// 圧縮効果がある場合のみ置き換え
	if compressedSize < fileSize {
		backupFile := filepath.Join(backupDir, filepath.Base(filePath))
		if err := copyFile(filePath, backupFile); err != nil {
			return fmt.Errorf("バックアップ作成エラー: %v", err)
		}
		log.Printf("バックアップ完了: %s → %s", filePath, backupFile)

		if err := os.Rename(tempFile, filePath); err != nil {
			return fmt.Errorf("ファイル置き換えエラー: %v", err)
		}
		log.Printf("圧縮成功: %s (%dKB → %dKB)", filePath, fileSize, compressedSize)
	} else {
		os.Remove(tempFile)
		log.Printf("圧縮効果がなかったため元ファイルを維持: %s", filePath)
	}

	return nil
}

func main() {
	// ログファイルをグローバルに定義
	var err error
	logFile, err = os.OpenFile("/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/check_pdf/log/check_pdf.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
	if err != nil {
		log.Fatal(err)
	}
	defer logFile.Close()

	log.SetOutput(logFile)

	// 監視対象ディレクトリ
	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 {

					// 新しいフォルダが作成された場合の処理
					info, err := os.Stat(event.Name)
					if err == nil && info.IsDir() {
						err = addWatchDir(event.Name)
						if err != nil {
							log.Printf("新規監視ディレクトリ追加エラー (%s): %v", event.Name, err)
							return
						}
						log.Printf("新規監視ディレクトリ追加成功: %s", event.Name)
					}

					// 初期のファイル名（最初は元のファイル名を使う）
					fileName := filepath.Base(event.Name)

					// .pdf且つ未処理の（完了マップに保存されていない）ファイル
					if filepath.Ext(event.Name) == ".pdf" && !processedFiles[event.Name] {

						// ファイルサイズ取得
						currentSize, err := getFileSize(event.Name)
						if err != nil {
							log.Printf("ファイルサイズ取得エラー (%s): %v", event.Name, err)
							return
						}
						log.Printf("新規PDFファイルが作成されました: %s, サイズ: %dKB", event.Name, currentSize)

						// PDF圧縮
						if err = compressPDF(event.Name); err != nil {
							log.Printf("compressPDF() 実行エラー (%s): %v", event.Name, err)
							return
						}
						newSize, err := getFileSize(event.Name)
						if err != nil {
							log.Printf("圧縮後ファイルサイズ取得エラー (%s): %v", event.Name, err)
							return
						}
						processedFiles[event.Name] = true //マップに記録
						log.Printf("現状ファイルサイズ: %s, %dKB", event.Name, newSize)
					}
				}
			case err, ok := <-watcher.Errors:
				if !ok {
					return
				}
				log.Println("エラー:", err)
			}
		}
	}()

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

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

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

### (3) 依存関係を整理しビルド

プロジェクトのルートディレクトリで下記を実行

```bash:bash
go mod tidy
go build process_pdf.go
```

## 2. launchdでジョブ設定

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

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

```xml:com.user.check_pdf.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/check_pdf/process_pdf</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/check_pdf/log/process_pdf.stdout</string>
	<key>StandardErrorPath</key>
	<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/check_pdf/log/process_pdf.stderr</string>

	<!-- 作業ディレクトリ -->
	<key>WorkingDirectory</key>
	<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/check_pdf</string>
</dict>
</plist>
```
### (2) LaunchAgentsとして保存

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

![スクリーンショット 2024-12-10 17.42.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3472245/531f0dc7-fc0a-1c3f-b9e2-51816a4e1c6e.png)

## 3. 動かしてみる

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

`LaunchAgent`をロードする

```bash:bash
launchctl unload ~/Library/LaunchAgents/com.user.check_pdf.plist
launchctl load ~/Library/LaunchAgents/com.user.check_pdf.plist
```

画面上に、この様なシステム通知が表示されればOK

![スクリーンショット 2024-12-10 17.57.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3472245/fa8520c0-d77c-acf4-f883-8ffdf7c27455.png)

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

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

```bash:bash
ps aux | grep check_pdf
```

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

```bash:bash
pkill -f check_pdf
```