前提
- 開発環境:MacBook
- Golangインストール済み
- 社内でのファイル共有にはiCloudを使用
要件
- 極力低コストで毎月大量の税務資料ファイルを税理士側(全員Windows)と共有したい
→月単位で用意したフォルダの圧縮ファイルをメール送信 - スキャンした.pdfデータの容量が必要以上に大きく、税理士側に送る際.zipファイルを直接メール添付出来なかったりする為、.pdfファイルをiCloudにアップされたタイミングで自動圧縮処理したい
Automator(フォルダアクション等)+AppleScriptの設定では自由度が低く、またMacOSでしか使えないので、比較的環境の変化に対応しやすいGolangで行こう!となりました
手順
Golang
でfsnotify
パッケージを使用してiCloud上の共有フォルダ内の更新を監視し、.pdfファイルが追加された場合のみ、該当ファイルが特定のディレクトリに存在する場合はリネームを行い、バックアップを保存した上で適宜圧縮処理します
また、このスクリプトをMacOS launchd
に設定する事により自動化します
1. 共有フォルダ更新をfsnotifyで監視
.goファイルを置くディレクトリに移動しておく
cd /Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/compress_pdfs/
(1) Goモジュール初期化・必要なパッケージをインストール
ファイルシステムの監視にfsnotify
を使用します
go mod init watch_folder
go get github.com/fsnotify/fsnotify
(2) Goコード作成・保存
addWatchDir(path string)
で、該当共有フォルダ内に新たに追加されたディレクトリも監視対象として追加されます
また、iCloudの同期タイミングで同じイベントに対する処理が重複するのを防ぐ目的で10秒待つ様にしています
package main
import (
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
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に変換
}
// .pdf圧縮処理
func compressPDF(filePath string) error {
// PATH環境変数を修正
os.Setenv("PATH", "/opt/homebrew/bin:"+os.Getenv("PATH"))
// バックアップディレクトリを設定
backupDir := "/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/compress_pdfs/log/pdf_backups"
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("バックアップディレクトリ作成失敗: %v", err)
}
// 同期完了を待つ(※重要)
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
}
if fileSize <= 200 {
log.Printf("圧縮処理をスキップ %s (size: %dKB, already compressed or small enough)\n", filePath, fileSize)
return nil
} else {
// バックアップファイル名を決定
backupFile := filepath.Join(backupDir, filepath.Base(filePath))
// バックアップを作成
if err := copyFile(filePath, backupFile); err != nil {
return fmt.Errorf("バックアップ作成エラー: %v", err)
} else {
log.Printf("バックアップ完了 %s -> %s\n", filePath, backupFile)
// 一時的な圧縮ファイルのパスを設定
tempFile := strings.TrimSuffix(filePath, ".pdf") + "_compressed.pdf"
// Ghostscriptを使ってPDFを圧縮
if err := 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).Run(); err != nil {
return err
}
// 圧縮後のファイルサイズを取得
compressedSize, err := getFileSize(tempFile)
if err != nil {
return err
}
// 圧縮後のサイズが元のサイズより小さい場合のみ置き換え
if compressedSize < fileSize {
if err := os.Rename(tempFile, filePath); err != nil {
return fmt.Errorf("ファイル置き換えエラー: %v", err)
}
log.Printf("圧縮プロセス成功 %s (%dKB -> %dKB)\n", filePath, fileSize, compressedSize)
} else {
os.Remove(tempFile)
log.Printf("Compression did not reduce file size for %s, original file kept\n", filePath)
}
}
}
return nil
}
// バックアップ作成用
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
}
func main() {
// ログファイルをグローバルに定義
var err error
logFile, err = os.OpenFile("/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/compress_pdfs/log/compress_pdfs.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)
// .pdf且つ未処理の(完了マップに保存されていない)ファイル
if filepath.Ext(event.Name) == ".pdf" && !processedFiles[event.Name] {
// 初期のファイル名(最初は元のファイル名を使う)
inFileName := event.Name
fileName := filepath.Base(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)
// ファイル名冒頭がYYYYMMDD_形式でない&領収書フォルダ内に存在するファイルのみリネーム
if !(regexp.MustCompile(`^[0-9]{8}_`).MatchString(fileName)) && regexp.MustCompile(watchDir+`/\d{4}/\d{6}/spending/receipt/`).MatchString(event.Name) {
// 日付形式のファイル名を生成
dateFormat := time.Now().Format("20060102")
baseFileName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
newFileName := filepath.Join(filepath.Dir(event.Name), dateFormat+"_"+baseFileName+filepath.Ext(event.Name))
// ファイル名を変更
err = os.Rename(event.Name, newFileName)
if err != nil {
log.Println("ファイルのリネームに失敗しました:", err)
return
} else {
log.Println("ファイルをリネームしました:", newFileName)
// ファイル名を再取得
inFileName = newFileName
}
}
// PDF圧縮
err = compressPDF(inFileName)
if err != nil {
log.Printf("compressPDF() 実行エラー (%s): %v", inFileName, err)
} else {
// 圧縮後のファイルサイズを再取得
newSize, err := getFileSize(inFileName)
if err != nil {
log.Printf("圧縮後ファイルサイズ取得エラー (%s): %v", inFileName, err)
return
}
// 処理済みとしてマーク
processedFiles[inFileName] = true
log.Printf("現状ファイルサイズ: %s, %dKB", inFileName, newSize)
}
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("エラー:", err)
}
}
}()
// 無限ループで待機
done := make(chan bool)
<-done
}
圧縮処理対象の.pdfに対しては、念の為バックアップを別ディレクトリに取っておきます
テキストが埋め込まれた.pdfに対するGhostscript
での処理時の文字化けを防ぐ為、-dPDFSETTINGS=/screen
を設定しています
(3) 依存関係を整理しビルド
プロジェクトのルートディレクトリで下記を実行
go mod tidy
go build watch_folder.go
2. launchdでジョブ設定
launchd
を使用する事で、ジョブがハングしたり異常終了した場合に自動的に再起動させる事が出来ます
(1) launchd
の設定ファイルを作成する
<?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/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/compress_pdfs/log/watch_folder.stdout</string>
<key>StandardErrorPath</key>
<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/compress_pdfs/log/watch_folder.stderr</string>
<!-- 作業ディレクトリ -->
<key>WorkingDirectory</key>
<string>/Users/{{userName}}/Library/Mobile Documents/com~apple~CloudDocs/compress_pdfs</string>
</dict>
</plist>
(2) LaunchAgentsとして保存
com.user.watch_folder
という名前で.plist
として下記の場所に保存
3. 動かしてみる
単にMacBookを再起動するだけでも良いですが、下記コマンドでも起動出来ます
LaunchAgent
をロードする
launchctl unload ~/Library/LaunchAgents/com.user.watch_folder.plist
launchctl load ~/Library/LaunchAgents/com.user.watch_folder.plist
画面上に、この様なシステム通知が表示されればOK
4. プロセスを終了する場合
下記で、実行中の全てのwatch_folder
プロセスを確認出来ます
ps aux | grep watch_folder
コマンドラインに "watch_folder" を含むすべてのプロセスを終了させる
pkill -f watch_folder