0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-09-13

前提

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

要件

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

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

手順

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

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

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

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

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

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

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

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

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

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

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

watch_folder.go
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) 依存関係を整理しビルド

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

bash
go mod tidy
go build watch_folder.go

2. 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/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として下記の場所に保存

スクリーンショット 2024-09-25 18.18.42.png

3. 動かしてみる

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

LaunchAgentをロードする

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

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

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

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

下記で、実行中の全ての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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?