1641 lines
52 KiB
Markdown
1641 lines
52 KiB
Markdown
# PDFスキャン監視・自動振り分けツール Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 既存のCLI型OCR氏名/日付抽出ツールを、バッチダブルクリック起動・固定フォルダ構成・同梱Python完結配布パッケージ型に全面改修する。
|
||
|
||
**Architecture:** `box_detector.py`(テンプレート枠検出)はそのまま流用。設定読込・ファイル操作・ロック管理・ログ出力を責任ごとに独立モジュールへ分離し、`extract_and_rename.py` はそれらを組み合わせるオーケストレーション層とする。配布用の`python/`・`tools/`(tesseract/poppler)はビルドスクリプトが別途生成する。
|
||
|
||
**Tech Stack:** Python 3.11系(配布用embeddable、開発機は3.12.1で代用)、opencv-python、pdf2image、pytesseract、pillow、numpy、pytest(開発依存)、Windows batファイル。
|
||
|
||
## Global Constraints
|
||
|
||
- 対象OS: Windows専用(`msvcrt`・`ctypes.windll`使用前提)
|
||
- 配布用Python: 3.11.9 embeddable amd64(バージョン固定)。開発・テストは開発機のシステムPython 3.12.1を使う(別物、配布物には含めない)
|
||
- pipパッケージ: opencv-python, pdf2image, pytesseract, pillow, numpy
|
||
- 外部バイナリ: tesseract-ocr(UB-Mannheim版 5.4.0.20240606固定)、poppler for Windows(poppler-windows 24.02.0-0固定)
|
||
- ログ出力エンコーディング: UTF-8 BOM付き(`utf-8-sig`)、出力先 `ログ/YYYY-MM-DD.log`
|
||
- ファイル名衝突回避ルール: `stem(2).suffix`, `stem(3).suffix` ... の形式(既存の `_1, _2` 形式ではない)
|
||
- `config.txt` は必須ファイル。存在しなければエラー終了(自動生成しない)
|
||
- リポジトリ内のコード配置は `スクリプト/` フォルダ配下(`OCR仕分け実行.bat` と `config.txt` のみリポジトリ直下)
|
||
- 全てのファイル・フォルダ・ログの文言は日本語
|
||
|
||
---
|
||
|
||
## Task 1: リポジトリ構造整備
|
||
|
||
**Files:**
|
||
- Create: `スクリプト/`(新規フォルダ)
|
||
- Move: `box_detector.py` → `スクリプト/box_detector.py`
|
||
- Move: `extract_and_rename.py` → `スクリプト/extract_and_rename.py`
|
||
- Create: `.gitignore`
|
||
- Create: `スクリプト/tests/conftest.py`
|
||
- Create: `スクリプト/tests/test_smoke.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `スクリプト/tests/conftest.py` が `sys.path` に `スクリプト/` を追加する。以降の全テストファイルはこれを前提に `from config_loader import ...` のような直接importができる。
|
||
|
||
- [ ] **Step 1: 既存ファイルをスクリプト/フォルダへ移動**
|
||
|
||
```bash
|
||
cd "c:/Users/k.nogi/#GitHub/ken_nogi/PythonProj/ScanOCR"
|
||
mkdir -p スクリプト
|
||
git mv box_detector.py スクリプト/box_detector.py
|
||
git mv extract_and_rename.py スクリプト/extract_and_rename.py
|
||
```
|
||
|
||
- [ ] **Step 2: .gitignore作成**
|
||
|
||
`.gitignore` に以下を書く:
|
||
|
||
```gitignore
|
||
dist/
|
||
__pycache__/
|
||
*.pyc
|
||
.pytest_cache/
|
||
.lock
|
||
ログ/
|
||
```
|
||
|
||
- [ ] **Step 3: pytestインストール・conftest.py作成**
|
||
|
||
```bash
|
||
python -m pip install pytest
|
||
mkdir -p スクリプト/tests
|
||
```
|
||
|
||
`スクリプト/tests/conftest.py`:
|
||
|
||
```python
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
```
|
||
|
||
`スクリプト/tests/test_smoke.py`:
|
||
|
||
```python
|
||
def test_smoke():
|
||
assert True
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests -v`
|
||
Expected: `test_smoke.py::test_smoke PASSED`、1 passed
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add .gitignore スクリプト/
|
||
git commit -m "chore: スクリプト配置をスクリプト/フォルダへ整理しテスト基盤を追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: config_loader.py(config.txt読込)
|
||
|
||
**Files:**
|
||
- Create: `スクリプト/config_loader.py`
|
||
- Test: `スクリプト/tests/test_config_loader.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `class ConfigError(Exception)`
|
||
- `REQUIRED_KEYS: tuple[str, ...]`
|
||
- `load_config(config_path: Path) -> dict[str, str]` — 全項目を文字列のまま返す。数値変換は呼び出し側の責務
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く**
|
||
|
||
`スクリプト/tests/test_config_loader.py`:
|
||
|
||
```python
|
||
import pytest
|
||
from pathlib import Path
|
||
|
||
from config_loader import ConfigError, load_config
|
||
|
||
|
||
VALID_CONTENT = """\
|
||
スクリプトフォルダ=スクリプト
|
||
テンプレートフォルダ=テンプレート
|
||
スキャンフォルダ=スキャン
|
||
アウトプットフォルダ=アウトプット
|
||
成功フォルダ=成功
|
||
失敗フォルダ=失敗
|
||
ログフォルダ=ログ
|
||
margin=0.10
|
||
DPI=300
|
||
ファイル安定待ち秒=1
|
||
ファイル安定待ちリトライ回数=5
|
||
"""
|
||
|
||
|
||
def test_load_config_missing_file_raises(tmp_path):
|
||
missing_path = tmp_path / "config.txt"
|
||
with pytest.raises(ConfigError):
|
||
load_config(missing_path)
|
||
|
||
|
||
def test_load_config_parses_all_keys(tmp_path):
|
||
config_path = tmp_path / "config.txt"
|
||
config_path.write_text(VALID_CONTENT, encoding="utf-8")
|
||
|
||
config = load_config(config_path)
|
||
|
||
assert config["スクリプトフォルダ"] == "スクリプト"
|
||
assert config["テンプレートフォルダ"] == "テンプレート"
|
||
assert config["margin"] == "0.10"
|
||
assert config["ファイル安定待ちリトライ回数"] == "5"
|
||
|
||
|
||
def test_load_config_missing_required_key_raises(tmp_path):
|
||
config_path = tmp_path / "config.txt"
|
||
config_path.write_text("スクリプトフォルダ=スクリプト\n", encoding="utf-8")
|
||
|
||
with pytest.raises(ConfigError):
|
||
load_config(config_path)
|
||
|
||
|
||
def test_load_config_ignores_blank_lines_and_comments(tmp_path):
|
||
config_path = tmp_path / "config.txt"
|
||
content = "# comment\n\n" + VALID_CONTENT
|
||
config_path.write_text(content, encoding="utf-8")
|
||
|
||
config = load_config(config_path)
|
||
|
||
assert config["スクリプトフォルダ"] == "スクリプト"
|
||
```
|
||
|
||
- [ ] **Step 2: テスト実行し失敗を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_config_loader.py -v`
|
||
Expected: `ModuleNotFoundError: No module named 'config_loader'` で全件FAIL
|
||
|
||
- [ ] **Step 3: config_loader.py実装**
|
||
|
||
`スクリプト/config_loader.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
"""config.txt(key=value形式)読込モジュール。"""
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
|
||
class ConfigError(Exception):
|
||
pass
|
||
|
||
|
||
REQUIRED_KEYS = (
|
||
"スクリプトフォルダ",
|
||
"テンプレートフォルダ",
|
||
"スキャンフォルダ",
|
||
"アウトプットフォルダ",
|
||
"成功フォルダ",
|
||
"失敗フォルダ",
|
||
"ログフォルダ",
|
||
"margin",
|
||
"DPI",
|
||
"ファイル安定待ち秒",
|
||
"ファイル安定待ちリトライ回数",
|
||
)
|
||
|
||
|
||
def load_config(config_path: Path) -> dict[str, str]:
|
||
if not config_path.exists():
|
||
raise ConfigError(f"config.txt が見つかりません: {config_path}")
|
||
|
||
config: dict[str, str] = {}
|
||
for raw_line in config_path.read_text(encoding="utf-8").splitlines():
|
||
line = raw_line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, _, value = line.partition("=")
|
||
config[key.strip()] = value.strip()
|
||
|
||
missing = [key for key in REQUIRED_KEYS if key not in config]
|
||
if missing:
|
||
raise ConfigError(
|
||
f"config.txt に必須項目が不足しています: {', '.join(missing)}"
|
||
)
|
||
|
||
return config
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行し成功を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_config_loader.py -v`
|
||
Expected: 4 passed
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/config_loader.py スクリプト/tests/test_config_loader.py
|
||
git commit -m "feat: config.txt読込モジュールを追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: file_ops.py(ファイル名衝突回避コピー・移動)
|
||
|
||
**Files:**
|
||
- Create: `スクリプト/file_ops.py`
|
||
- Test: `スクリプト/tests/test_file_ops.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `resolve_unique_path(dest_dir: Path, stem: str, suffix: str) -> Path`
|
||
- `copy_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path`
|
||
- `move_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く**
|
||
|
||
`スクリプト/tests/test_file_ops.py`:
|
||
|
||
```python
|
||
from pathlib import Path
|
||
|
||
from file_ops import copy_with_unique_name, move_with_unique_name, resolve_unique_path
|
||
|
||
|
||
def test_resolve_unique_path_no_collision(tmp_path):
|
||
result = resolve_unique_path(tmp_path, "氏名A_20260101", ".pdf")
|
||
assert result == tmp_path / "氏名A_20260101.pdf"
|
||
|
||
|
||
def test_resolve_unique_path_with_collision(tmp_path):
|
||
(tmp_path / "氏名A_20260101.pdf").write_bytes(b"x")
|
||
|
||
result = resolve_unique_path(tmp_path, "氏名A_20260101", ".pdf")
|
||
|
||
assert result == tmp_path / "氏名A_20260101(2).pdf"
|
||
|
||
|
||
def test_resolve_unique_path_with_multiple_collisions(tmp_path):
|
||
(tmp_path / "氏名A_20260101.pdf").write_bytes(b"x")
|
||
(tmp_path / "氏名A_20260101(2).pdf").write_bytes(b"x")
|
||
|
||
result = resolve_unique_path(tmp_path, "氏名A_20260101", ".pdf")
|
||
|
||
assert result == tmp_path / "氏名A_20260101(3).pdf"
|
||
|
||
|
||
def test_copy_with_unique_name_creates_dest_dir(tmp_path):
|
||
src = tmp_path / "src.pdf"
|
||
src.write_bytes(b"content")
|
||
dest_dir = tmp_path / "output" / "氏名A"
|
||
|
||
result = copy_with_unique_name(src, dest_dir, "氏名A_20260101", ".pdf")
|
||
|
||
assert result == dest_dir / "氏名A_20260101.pdf"
|
||
assert result.read_bytes() == b"content"
|
||
assert src.exists() # コピーなので元ファイルは残る
|
||
|
||
|
||
def test_move_with_unique_name_moves_source(tmp_path):
|
||
src = tmp_path / "src.pdf"
|
||
src.write_bytes(b"content")
|
||
dest_dir = tmp_path / "success"
|
||
|
||
result = move_with_unique_name(src, dest_dir, "src", ".pdf")
|
||
|
||
assert result == dest_dir / "src.pdf"
|
||
assert result.read_bytes() == b"content"
|
||
assert not src.exists() # 移動なので元ファイルは消える
|
||
```
|
||
|
||
- [ ] **Step 2: テスト実行し失敗を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_file_ops.py -v`
|
||
Expected: `ModuleNotFoundError: No module named 'file_ops'` で全件FAIL
|
||
|
||
- [ ] **Step 3: file_ops.py実装(衝突回避部分)**
|
||
|
||
`スクリプト/file_ops.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
"""ファイル名衝突回避コピー・移動、ファイルサイズ安定待ちモジュール。"""
|
||
from __future__ import annotations
|
||
|
||
import shutil
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
|
||
def resolve_unique_path(dest_dir: Path, stem: str, suffix: str) -> Path:
|
||
candidate = dest_dir / f"{stem}{suffix}"
|
||
if not candidate.exists():
|
||
return candidate
|
||
|
||
counter = 2
|
||
while True:
|
||
candidate = dest_dir / f"{stem}({counter}){suffix}"
|
||
if not candidate.exists():
|
||
return candidate
|
||
counter += 1
|
||
|
||
|
||
def copy_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path:
|
||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||
dest_path = resolve_unique_path(dest_dir, stem, suffix)
|
||
shutil.copy2(str(src), str(dest_path))
|
||
return dest_path
|
||
|
||
|
||
def move_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path:
|
||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||
dest_path = resolve_unique_path(dest_dir, stem, suffix)
|
||
shutil.move(str(src), str(dest_path))
|
||
return dest_path
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行し成功を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_file_ops.py -v`
|
||
Expected: 5 passed
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/file_ops.py スクリプト/tests/test_file_ops.py
|
||
git commit -m "feat: ファイル名衝突回避コピー・移動モジュールを追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: file_ops.py(ファイルサイズ安定待ち)
|
||
|
||
**Files:**
|
||
- Modify: `スクリプト/file_ops.py`(Task 3で作成したファイルに追記)
|
||
- Test: `スクリプト/tests/test_file_ops.py`(追記)
|
||
|
||
**Interfaces:**
|
||
- Consumes: なし(Task 3の関数とは独立)
|
||
- Produces: `wait_until_stable(path: Path, interval_sec: float, retries: int, sleep_func: Callable[[float], None] = time.sleep) -> bool`
|
||
- ファイルが存在しなければ即 `False`
|
||
- `interval_sec` 秒待って直前サイズと比較、一致すれば `True`
|
||
- `retries` 回試行しても一致しなければ `False`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く**
|
||
|
||
`スクリプト/tests/test_file_ops.py` に追記:
|
||
|
||
```python
|
||
from file_ops import wait_until_stable
|
||
|
||
|
||
def test_wait_until_stable_missing_file_returns_false(tmp_path):
|
||
missing = tmp_path / "missing.pdf"
|
||
|
||
result = wait_until_stable(missing, interval_sec=0, retries=3, sleep_func=lambda s: None)
|
||
|
||
assert result is False
|
||
|
||
|
||
def test_wait_until_stable_stable_file_returns_true(tmp_path):
|
||
path = tmp_path / "stable.pdf"
|
||
path.write_bytes(b"1234")
|
||
|
||
result = wait_until_stable(path, interval_sec=0, retries=3, sleep_func=lambda s: None)
|
||
|
||
assert result is True
|
||
|
||
|
||
def test_wait_until_stable_growing_file_returns_false(tmp_path):
|
||
path = tmp_path / "growing.pdf"
|
||
path.write_bytes(b"1")
|
||
|
||
call_count = {"n": 0}
|
||
|
||
def fake_sleep(_seconds):
|
||
call_count["n"] += 1
|
||
path.write_bytes(b"1" * (call_count["n"] + 1))
|
||
|
||
result = wait_until_stable(path, interval_sec=0, retries=3, sleep_func=fake_sleep)
|
||
|
||
assert result is False
|
||
```
|
||
|
||
- [ ] **Step 2: テスト実行し失敗を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_file_ops.py -v -k wait_until_stable`
|
||
Expected: `ImportError: cannot import name 'wait_until_stable'` で全件FAIL
|
||
|
||
- [ ] **Step 3: wait_until_stable実装**
|
||
|
||
`スクリプト/file_ops.py` の末尾に追記:
|
||
|
||
```python
|
||
def wait_until_stable(
|
||
path: Path,
|
||
interval_sec: float,
|
||
retries: int,
|
||
sleep_func: Callable[[float], None] = time.sleep,
|
||
) -> bool:
|
||
if not path.exists():
|
||
return False
|
||
|
||
previous_size = path.stat().st_size
|
||
for _ in range(retries):
|
||
sleep_func(interval_sec)
|
||
if not path.exists():
|
||
return False
|
||
current_size = path.stat().st_size
|
||
if current_size == previous_size:
|
||
return True
|
||
previous_size = current_size
|
||
|
||
return False
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行し成功を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_file_ops.py -v`
|
||
Expected: 8 passed
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/file_ops.py スクリプト/tests/test_file_ops.py
|
||
git commit -m "feat: ファイルサイズ安定待ちロジックを追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: lock_manager.py(二重起動防止ロック)
|
||
|
||
**Files:**
|
||
- Create: `スクリプト/lock_manager.py`
|
||
- Test: `スクリプト/tests/test_lock_manager.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `class LockAcquisitionError(Exception)`
|
||
- `acquire_lock(lock_path: Path, pid_checker: Callable[[int], bool] = _is_pid_running_windows) -> None`
|
||
- `release_lock(lock_path: Path) -> None`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く**
|
||
|
||
`スクリプト/tests/test_lock_manager.py`:
|
||
|
||
```python
|
||
import os
|
||
|
||
import pytest
|
||
|
||
from lock_manager import LockAcquisitionError, acquire_lock, release_lock
|
||
|
||
|
||
def test_acquire_lock_creates_file_with_pid(tmp_path):
|
||
lock_path = tmp_path / ".lock"
|
||
|
||
acquire_lock(lock_path, pid_checker=lambda pid: True)
|
||
|
||
assert lock_path.exists()
|
||
assert lock_path.read_text(encoding="utf-8").strip() == str(os.getpid())
|
||
|
||
|
||
def test_acquire_lock_raises_when_existing_pid_running(tmp_path):
|
||
lock_path = tmp_path / ".lock"
|
||
lock_path.write_text("12345", encoding="utf-8")
|
||
|
||
with pytest.raises(LockAcquisitionError):
|
||
acquire_lock(lock_path, pid_checker=lambda pid: True)
|
||
|
||
|
||
def test_acquire_lock_replaces_stale_lock(tmp_path):
|
||
lock_path = tmp_path / ".lock"
|
||
lock_path.write_text("12345", encoding="utf-8")
|
||
|
||
acquire_lock(lock_path, pid_checker=lambda pid: False)
|
||
|
||
assert lock_path.read_text(encoding="utf-8").strip() == str(os.getpid())
|
||
|
||
|
||
def test_release_lock_removes_file(tmp_path):
|
||
lock_path = tmp_path / ".lock"
|
||
lock_path.write_text("12345", encoding="utf-8")
|
||
|
||
release_lock(lock_path)
|
||
|
||
assert not lock_path.exists()
|
||
|
||
|
||
def test_release_lock_missing_file_no_error(tmp_path):
|
||
lock_path = tmp_path / ".lock"
|
||
|
||
release_lock(lock_path) # 例外が出ないことを確認するだけ
|
||
```
|
||
|
||
- [ ] **Step 2: テスト実行し失敗を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_lock_manager.py -v`
|
||
Expected: `ModuleNotFoundError: No module named 'lock_manager'` で全件FAIL
|
||
|
||
- [ ] **Step 3: lock_manager.py実装**
|
||
|
||
`スクリプト/lock_manager.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
"""二重起動防止ロックファイル管理モジュール(Windows専用)。"""
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
|
||
class LockAcquisitionError(Exception):
|
||
pass
|
||
|
||
|
||
def _is_pid_running_windows(pid: int) -> bool:
|
||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||
if handle:
|
||
ctypes.windll.kernel32.CloseHandle(handle)
|
||
return True
|
||
return False
|
||
|
||
|
||
def acquire_lock(
|
||
lock_path: Path,
|
||
pid_checker: Callable[[int], bool] = _is_pid_running_windows,
|
||
) -> None:
|
||
if lock_path.exists():
|
||
try:
|
||
recorded_pid = int(lock_path.read_text(encoding="utf-8").strip())
|
||
except ValueError:
|
||
recorded_pid = None
|
||
|
||
if recorded_pid is not None and pid_checker(recorded_pid):
|
||
raise LockAcquisitionError(f"既に実行中です(PID={recorded_pid})")
|
||
|
||
lock_path.unlink()
|
||
|
||
lock_path.write_text(str(os.getpid()), encoding="utf-8")
|
||
|
||
|
||
def release_lock(lock_path: Path) -> None:
|
||
if lock_path.exists():
|
||
lock_path.unlink()
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行し成功を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_lock_manager.py -v`
|
||
Expected: 5 passed
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/lock_manager.py スクリプト/tests/test_lock_manager.py
|
||
git commit -m "feat: 二重起動防止ロック管理モジュールを追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: logger.py(実行ログ出力)
|
||
|
||
**Files:**
|
||
- Create: `スクリプト/logger.py`
|
||
- Test: `スクリプト/tests/test_logger.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `LOG_ENCODING = "utf-8-sig"`
|
||
- `log_path_for_today(log_dir: Path, now_func: Callable[[], datetime] = datetime.now) -> Path`
|
||
- `append_log(log_dir: Path, result: str, original_filename: str, name: str | None, date: str | None, note: str = "", now_func: Callable[[], datetime] = datetime.now) -> None`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く**
|
||
|
||
`スクリプト/tests/test_logger.py`:
|
||
|
||
```python
|
||
from datetime import datetime
|
||
|
||
from logger import LOG_ENCODING, append_log, log_path_for_today
|
||
|
||
|
||
def fixed_now():
|
||
return datetime(2026, 8, 2, 10, 30, 15)
|
||
|
||
|
||
def test_log_path_for_today_uses_now_func(tmp_path):
|
||
result = log_path_for_today(tmp_path, now_func=fixed_now)
|
||
assert result == tmp_path / "2026-08-02.log"
|
||
|
||
|
||
def test_append_log_creates_dir_and_file(tmp_path):
|
||
log_dir = tmp_path / "ログ"
|
||
|
||
append_log(
|
||
log_dir,
|
||
result="成功",
|
||
original_filename="scan001.pdf",
|
||
name="山田太郎",
|
||
date="20260802",
|
||
note="",
|
||
now_func=fixed_now,
|
||
)
|
||
|
||
log_path = log_dir / "2026-08-02.log"
|
||
assert log_path.exists()
|
||
|
||
content = log_path.read_text(encoding=LOG_ENCODING)
|
||
assert "[2026-08-02 10:30:15]" in content
|
||
assert "結果=成功" in content
|
||
assert "元ファイル=scan001.pdf" in content
|
||
assert "氏名=山田太郎" in content
|
||
assert "日付=20260802" in content
|
||
|
||
|
||
def test_append_log_handles_none_name_and_date(tmp_path):
|
||
log_dir = tmp_path / "ログ"
|
||
|
||
append_log(
|
||
log_dir,
|
||
result="失敗",
|
||
original_filename="scan002.pdf",
|
||
name=None,
|
||
date=None,
|
||
note="抽出不十分",
|
||
now_func=fixed_now,
|
||
)
|
||
|
||
content = (log_dir / "2026-08-02.log").read_text(encoding=LOG_ENCODING)
|
||
assert "氏名=" in content
|
||
assert "日付=" in content
|
||
assert "備考=抽出不十分" in content
|
||
|
||
|
||
def test_append_log_appends_multiple_lines(tmp_path):
|
||
log_dir = tmp_path / "ログ"
|
||
|
||
append_log(log_dir, "成功", "a.pdf", "氏名A", "20260801", "", now_func=fixed_now)
|
||
append_log(log_dir, "成功", "b.pdf", "氏名B", "20260802", "", now_func=fixed_now)
|
||
|
||
content = (log_dir / "2026-08-02.log").read_text(encoding=LOG_ENCODING)
|
||
assert content.count("結果=成功") == 2
|
||
```
|
||
|
||
- [ ] **Step 2: テスト実行し失敗を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_logger.py -v`
|
||
Expected: `ModuleNotFoundError: No module named 'logger'` で全件FAIL
|
||
|
||
- [ ] **Step 3: logger.py実装**
|
||
|
||
`スクリプト/logger.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
"""実行ログ出力モジュール。日付別ファイル・UTF-8 BOM付き。"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
LOG_ENCODING = "utf-8-sig"
|
||
|
||
|
||
def log_path_for_today(
|
||
log_dir: Path, now_func: Callable[[], datetime] = datetime.now
|
||
) -> Path:
|
||
return log_dir / f"{now_func().strftime('%Y-%m-%d')}.log"
|
||
|
||
|
||
def append_log(
|
||
log_dir: Path,
|
||
result: str,
|
||
original_filename: str,
|
||
name: str | None,
|
||
date: str | None,
|
||
note: str = "",
|
||
now_func: Callable[[], datetime] = datetime.now,
|
||
) -> None:
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
now = now_func()
|
||
line = (
|
||
f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] "
|
||
f"結果={result} | 元ファイル={original_filename} | "
|
||
f"氏名={name or ''} | 日付={date or ''} | 備考={note}\n"
|
||
)
|
||
path = log_path_for_today(log_dir, now_func)
|
||
with open(path, "a", encoding=LOG_ENCODING) as f:
|
||
f.write(line)
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行し成功を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_logger.py -v`
|
||
Expected: 4 passed
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/logger.py スクリプト/tests/test_logger.py
|
||
git commit -m "feat: 実行ログ出力モジュールを追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: extract_and_rename.py — 初期化系ヘルパー関数(テンプレート選定・依存チェック)
|
||
|
||
**Files:**
|
||
- Modify: `スクリプト/extract_and_rename.py`
|
||
- Test: `スクリプト/tests/test_extract_and_rename.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: なし
|
||
- Produces:
|
||
- `TEMPLATE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"}`
|
||
- `select_template_file(template_dir: Path) -> Path`
|
||
- `REQUIRED_MODULES = ("cv2", "numpy", "PIL", "pytesseract", "pdf2image")`
|
||
- `check_dependencies(modules: tuple[str, ...] = REQUIRED_MODULES) -> list[str]`
|
||
- `check_external_tools(tesseract_exe: Path, poppler_exe: Path) -> list[str]`
|
||
|
||
この時点では既存の `FieldBox` / `load_field_boxes` / `ocr_region` / `clean_name` / `clean_date` / `process_pdf` はそのまま残す(Task 8で `main()` を書き換える際に整理する)。既存の `safe_move` 関数と旧 `main()`(argparse版)はこのTaskではまだ削除しない。
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く**
|
||
|
||
`スクリプト/tests/test_extract_and_rename.py`:
|
||
|
||
```python
|
||
from pathlib import Path
|
||
|
||
from extract_and_rename import (
|
||
check_dependencies,
|
||
check_external_tools,
|
||
select_template_file,
|
||
)
|
||
|
||
|
||
def test_select_template_file_picks_first_by_name(tmp_path):
|
||
(tmp_path / "b_template.png").write_bytes(b"x")
|
||
(tmp_path / "a_template.png").write_bytes(b"x")
|
||
(tmp_path / "note.txt").write_bytes(b"x") # 対象外拡張子
|
||
|
||
result = select_template_file(tmp_path)
|
||
|
||
assert result == tmp_path / "a_template.png"
|
||
|
||
|
||
def test_select_template_file_no_candidates_raises(tmp_path):
|
||
(tmp_path / "note.txt").write_bytes(b"x")
|
||
|
||
try:
|
||
select_template_file(tmp_path)
|
||
assert False, "FileNotFoundErrorが送出されるべき"
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
|
||
def test_check_dependencies_detects_missing_module():
|
||
result = check_dependencies(("this_module_does_not_exist_xyz",))
|
||
assert result == ["this_module_does_not_exist_xyz"]
|
||
|
||
|
||
def test_check_dependencies_detects_installed_module():
|
||
result = check_dependencies(("os",))
|
||
assert result == []
|
||
|
||
|
||
def test_check_external_tools_detects_missing(tmp_path):
|
||
tesseract = tmp_path / "tools" / "tesseract" / "tesseract.exe"
|
||
poppler = tmp_path / "tools" / "poppler" / "pdftoppm.exe"
|
||
|
||
result = check_external_tools(tesseract, poppler)
|
||
|
||
assert str(tesseract) in result
|
||
assert str(poppler) in result
|
||
|
||
|
||
def test_check_external_tools_all_present(tmp_path):
|
||
tesseract = tmp_path / "tesseract.exe"
|
||
poppler = tmp_path / "pdftoppm.exe"
|
||
tesseract.write_bytes(b"x")
|
||
poppler.write_bytes(b"x")
|
||
|
||
result = check_external_tools(tesseract, poppler)
|
||
|
||
assert result == []
|
||
```
|
||
|
||
- [ ] **Step 2: テスト実行し失敗を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v`
|
||
Expected: `ImportError`(`select_template_file` 等が存在しない)で全件FAIL
|
||
|
||
- [ ] **Step 3: ヘルパー関数を実装**
|
||
|
||
`スクリプト/extract_and_rename.py` の先頭 import 群の直後に追記(既存コードは変更しない):
|
||
|
||
```python
|
||
import importlib
|
||
|
||
TEMPLATE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"}
|
||
|
||
|
||
def select_template_file(template_dir: Path) -> Path:
|
||
candidates = sorted(
|
||
p for p in template_dir.iterdir()
|
||
if p.is_file() and p.suffix.lower() in TEMPLATE_EXTENSIONS
|
||
)
|
||
if not candidates:
|
||
raise FileNotFoundError(
|
||
f"テンプレートフォルダに画像ファイルが見つかりません: {template_dir}"
|
||
)
|
||
return candidates[0]
|
||
|
||
|
||
REQUIRED_MODULES = ("cv2", "numpy", "PIL", "pytesseract", "pdf2image")
|
||
|
||
|
||
def check_dependencies(modules: tuple[str, ...] = REQUIRED_MODULES) -> list[str]:
|
||
missing = []
|
||
for mod in modules:
|
||
try:
|
||
importlib.import_module(mod)
|
||
except ImportError:
|
||
missing.append(mod)
|
||
return missing
|
||
|
||
|
||
def check_external_tools(tesseract_exe: Path, poppler_exe: Path) -> list[str]:
|
||
missing = []
|
||
if not tesseract_exe.exists():
|
||
missing.append(str(tesseract_exe))
|
||
if not poppler_exe.exists():
|
||
missing.append(str(poppler_exe))
|
||
return missing
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行し成功を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v`
|
||
Expected: 5 passed
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/extract_and_rename.py スクリプト/tests/test_extract_and_rename.py
|
||
git commit -m "feat: テンプレート選定・依存チェックヘルパーを追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: extract_and_rename.py — キュー処理ループ(run_queue)
|
||
|
||
**Files:**
|
||
- Modify: `スクリプト/extract_and_rename.py`
|
||
- Test: `スクリプト/tests/test_extract_and_rename.py`(追記)
|
||
|
||
**Interfaces:**
|
||
- Consumes:
|
||
- `file_ops.copy_with_unique_name`, `file_ops.move_with_unique_name`, `file_ops.wait_until_stable`
|
||
- `logger.append_log`
|
||
- 既存 `process_pdf(pdf_path: Path, boxes: list[FieldBox], margin: float) -> tuple[str | None, str | None]`
|
||
- Produces:
|
||
- `key_pressed() -> bool`(`msvcrt.kbhit()` の薄いラッパー)
|
||
- `run_queue(scan_dir: Path, output_dir: Path, success_dir: Path, failed_dir: Path, log_dir: Path, boxes: list[FieldBox], margin: float, stable_wait_sec: float, stable_retries: int, process_pdf_func=process_pdf, key_check=key_pressed, sleep_func=time.sleep) -> None`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く**
|
||
|
||
`スクリプト/tests/test_extract_and_rename.py` に追記:
|
||
|
||
```python
|
||
from extract_and_rename import run_queue
|
||
|
||
|
||
def _make_dirs(tmp_path):
|
||
dirs = {
|
||
"scan": tmp_path / "スキャン",
|
||
"output": tmp_path / "アウトプット",
|
||
"success": tmp_path / "成功",
|
||
"failed": tmp_path / "失敗",
|
||
"log": tmp_path / "ログ",
|
||
}
|
||
dirs["scan"].mkdir()
|
||
return dirs
|
||
|
||
|
||
def test_run_queue_success_moves_and_copies(tmp_path):
|
||
dirs = _make_dirs(tmp_path)
|
||
pdf = dirs["scan"] / "scan001.pdf"
|
||
pdf.write_bytes(b"dummy")
|
||
|
||
def fake_process_pdf(path, boxes, margin):
|
||
return ("氏名A", "20260802")
|
||
|
||
run_queue(
|
||
dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"],
|
||
boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1,
|
||
process_pdf_func=fake_process_pdf,
|
||
key_check=lambda: False,
|
||
sleep_func=lambda s: None,
|
||
)
|
||
|
||
assert (dirs["output"] / "氏名A" / "氏名A_20260802.pdf").exists()
|
||
assert (dirs["success"] / "scan001.pdf").exists()
|
||
assert not pdf.exists()
|
||
log_content = (dirs["log"] / f"{__import__('datetime').datetime.now().strftime('%Y-%m-%d')}.log").read_text(encoding="utf-8-sig")
|
||
assert "結果=成功" in log_content
|
||
|
||
|
||
def test_run_queue_missing_name_moves_to_failed(tmp_path):
|
||
dirs = _make_dirs(tmp_path)
|
||
pdf = dirs["scan"] / "scan002.pdf"
|
||
pdf.write_bytes(b"dummy")
|
||
|
||
def fake_process_pdf(path, boxes, margin):
|
||
return (None, "20260802")
|
||
|
||
run_queue(
|
||
dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"],
|
||
boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1,
|
||
process_pdf_func=fake_process_pdf,
|
||
key_check=lambda: False,
|
||
sleep_func=lambda s: None,
|
||
)
|
||
|
||
assert (dirs["failed"] / "scan002.pdf").exists()
|
||
assert not pdf.exists()
|
||
|
||
|
||
def test_run_queue_exception_moves_to_failed(tmp_path):
|
||
dirs = _make_dirs(tmp_path)
|
||
pdf = dirs["scan"] / "scan003.pdf"
|
||
pdf.write_bytes(b"dummy")
|
||
|
||
def fake_process_pdf(path, boxes, margin):
|
||
raise RuntimeError("OCRエラー")
|
||
|
||
run_queue(
|
||
dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"],
|
||
boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1,
|
||
process_pdf_func=fake_process_pdf,
|
||
key_check=lambda: False,
|
||
sleep_func=lambda s: None,
|
||
)
|
||
|
||
assert (dirs["failed"] / "scan003.pdf").exists()
|
||
|
||
|
||
def test_run_queue_stops_on_key_press(tmp_path):
|
||
dirs = _make_dirs(tmp_path)
|
||
pdf = dirs["scan"] / "scan004.pdf"
|
||
pdf.write_bytes(b"dummy")
|
||
|
||
calls = []
|
||
|
||
def fake_process_pdf(path, boxes, margin):
|
||
calls.append(path)
|
||
return ("氏名A", "20260802")
|
||
|
||
run_queue(
|
||
dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"],
|
||
boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1,
|
||
process_pdf_func=fake_process_pdf,
|
||
key_check=lambda: True, # 常に押下扱い
|
||
sleep_func=lambda s: None,
|
||
)
|
||
|
||
assert calls == []
|
||
assert pdf.exists() # 処理されず残る
|
||
|
||
|
||
def test_run_queue_skips_file_deleted_before_processing(tmp_path):
|
||
dirs = _make_dirs(tmp_path)
|
||
pdf = dirs["scan"] / "scan005.pdf"
|
||
pdf.write_bytes(b"dummy")
|
||
|
||
calls = []
|
||
|
||
def fake_process_pdf(path, boxes, margin):
|
||
calls.append(path)
|
||
return ("氏名A", "20260802")
|
||
|
||
def key_check_and_delete():
|
||
if pdf.exists():
|
||
pdf.unlink()
|
||
return False
|
||
|
||
run_queue(
|
||
dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"],
|
||
boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1,
|
||
process_pdf_func=fake_process_pdf,
|
||
key_check=key_check_and_delete,
|
||
sleep_func=lambda s: None,
|
||
)
|
||
|
||
assert calls == []
|
||
|
||
|
||
def test_run_queue_empty_scan_dir_returns_immediately(tmp_path):
|
||
dirs = _make_dirs(tmp_path)
|
||
|
||
run_queue(
|
||
dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"],
|
||
boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1,
|
||
process_pdf_func=lambda p, b, m: ("氏名A", "20260802"),
|
||
key_check=lambda: False,
|
||
sleep_func=lambda s: None,
|
||
)
|
||
# 例外が出ずに戻ることのみ確認
|
||
|
||
|
||
def test_run_queue_name_collision_gets_suffix(tmp_path):
|
||
dirs = _make_dirs(tmp_path)
|
||
existing_output_dir = dirs["output"] / "氏名A"
|
||
existing_output_dir.mkdir(parents=True)
|
||
(existing_output_dir / "氏名A_20260802.pdf").write_bytes(b"old")
|
||
|
||
pdf = dirs["scan"] / "scan006.pdf"
|
||
pdf.write_bytes(b"dummy")
|
||
|
||
run_queue(
|
||
dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"],
|
||
boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1,
|
||
process_pdf_func=lambda p, b, m: ("氏名A", "20260802"),
|
||
key_check=lambda: False,
|
||
sleep_func=lambda s: None,
|
||
)
|
||
|
||
assert (existing_output_dir / "氏名A_20260802(2).pdf").exists()
|
||
```
|
||
|
||
- [ ] **Step 2: テスト実行し失敗を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v -k run_queue`
|
||
Expected: `ImportError: cannot import name 'run_queue'` で全件FAIL
|
||
|
||
- [ ] **Step 3: run_queue実装**
|
||
|
||
`スクリプト/extract_and_rename.py` の Task 7 で追加した関数群の直後に追記。合わせてファイル先頭の import 群に `import time`、`import traceback`、`from file_ops import copy_with_unique_name, move_with_unique_name, wait_until_stable`、`from logger import append_log` を追加する:
|
||
|
||
```python
|
||
import time
|
||
import traceback
|
||
|
||
from file_ops import copy_with_unique_name, move_with_unique_name, wait_until_stable
|
||
from logger import append_log
|
||
|
||
|
||
def key_pressed() -> bool:
|
||
import msvcrt
|
||
|
||
return msvcrt.kbhit()
|
||
|
||
|
||
def run_queue(
|
||
scan_dir: Path,
|
||
output_dir: Path,
|
||
success_dir: Path,
|
||
failed_dir: Path,
|
||
log_dir: Path,
|
||
boxes: list[FieldBox],
|
||
margin: float,
|
||
stable_wait_sec: float,
|
||
stable_retries: int,
|
||
process_pdf_func=process_pdf,
|
||
key_check=key_pressed,
|
||
sleep_func=time.sleep,
|
||
) -> None:
|
||
while True:
|
||
queue = sorted(scan_dir.glob("*.pdf"))
|
||
if not queue:
|
||
break
|
||
|
||
pdf_path = queue[0]
|
||
|
||
if key_check():
|
||
append_log(
|
||
log_dir, "エラー", pdf_path.name, None, None,
|
||
"ユーザー操作により停止しました",
|
||
)
|
||
break
|
||
|
||
if not pdf_path.exists():
|
||
continue
|
||
|
||
if not wait_until_stable(pdf_path, stable_wait_sec, stable_retries, sleep_func):
|
||
append_log(
|
||
log_dir, "スキップ", pdf_path.name, None, None,
|
||
"サイズ不安定のためスキップ",
|
||
)
|
||
continue
|
||
|
||
try:
|
||
name, date = process_pdf_func(pdf_path, boxes, margin)
|
||
except Exception as e:
|
||
append_log(
|
||
log_dir, "エラー", pdf_path.name, None, None,
|
||
f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
||
)
|
||
move_with_unique_name(pdf_path, failed_dir, pdf_path.stem, pdf_path.suffix)
|
||
continue
|
||
|
||
if not name or not date:
|
||
append_log(log_dir, "失敗", pdf_path.name, name, date, "")
|
||
move_with_unique_name(pdf_path, failed_dir, pdf_path.stem, pdf_path.suffix)
|
||
continue
|
||
|
||
copy_with_unique_name(pdf_path, output_dir / name, f"{name}_{date}", ".pdf")
|
||
move_with_unique_name(pdf_path, success_dir, pdf_path.stem, pdf_path.suffix)
|
||
append_log(log_dir, "成功", pdf_path.name, name, date, "")
|
||
```
|
||
|
||
- [ ] **Step 4: テスト実行し成功を確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v`
|
||
Expected: 12 passed(Task 7の5件 + 本Taskの7件)
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/extract_and_rename.py スクリプト/tests/test_extract_and_rename.py
|
||
git commit -m "feat: キュー処理メインループ(run_queue)を追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: extract_and_rename.py — main()統合・旧CLI削除
|
||
|
||
**Files:**
|
||
- Modify: `スクリプト/extract_and_rename.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `config_loader.load_config`, `lock_manager.acquire_lock/release_lock/LockAcquisitionError`, `box_detector.detect_template_fields`, Task 7/8で作った全ヘルパー
|
||
- Produces: `main() -> None`(配布パッケージ実行時のエントリーポイント)
|
||
|
||
このTaskはオーケストレーション層のみでユニットテストは書かない(下位関数は既にTask 2〜8でテスト済みのため)。既存の `argparse` ベースの旧 `main()` と `safe_move` 関数を削除し、固定フォルダ構成版に置き換える。
|
||
|
||
- [ ] **Step 1: 旧mainとsafe_moveを削除**
|
||
|
||
`スクリプト/extract_and_rename.py` から `def safe_move(...)` 関数全体と、旧 `def main():`(argparseを使う版)を削除する。`if __name__ == "__main__":` ブロックも一旦削除する(Step 2で再作成)。
|
||
|
||
- [ ] **Step 2: 新main()を実装**
|
||
|
||
ファイル末尾に追記:
|
||
|
||
```python
|
||
from config_loader import load_config
|
||
from lock_manager import LockAcquisitionError, acquire_lock, release_lock
|
||
|
||
HOME_DIR = Path(__file__).resolve().parent.parent
|
||
RUNTIME_FOLDER_KEYS = (
|
||
"テンプレートフォルダ",
|
||
"スキャンフォルダ",
|
||
"アウトプットフォルダ",
|
||
"成功フォルダ",
|
||
"失敗フォルダ",
|
||
"ログフォルダ",
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
config_path = HOME_DIR / "config.txt"
|
||
try:
|
||
config = load_config(config_path)
|
||
except Exception as e:
|
||
print(f"設定エラー: {e}")
|
||
input("何かキーを押すと終了します...")
|
||
return
|
||
|
||
folders = {key: HOME_DIR / config[key] for key in RUNTIME_FOLDER_KEYS}
|
||
for folder in folders.values():
|
||
folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
missing_modules = check_dependencies()
|
||
if missing_modules:
|
||
print(f"配布パッケージが壊れています。不足モジュール: {', '.join(missing_modules)}")
|
||
input("何かキーを押すと終了します...")
|
||
return
|
||
|
||
script_dir = HOME_DIR / config["スクリプトフォルダ"]
|
||
tesseract_exe = script_dir / "tools" / "tesseract" / "tesseract.exe"
|
||
poppler_exe = script_dir / "tools" / "poppler" / "pdftoppm.exe"
|
||
missing_tools = check_external_tools(tesseract_exe, poppler_exe)
|
||
if missing_tools:
|
||
print(f"配布パッケージが壊れています。不足ファイル: {', '.join(missing_tools)}")
|
||
input("何かキーを押すと終了します...")
|
||
return
|
||
|
||
lock_path = HOME_DIR / ".lock"
|
||
try:
|
||
acquire_lock(lock_path)
|
||
except LockAcquisitionError as e:
|
||
print(str(e))
|
||
input("何かキーを押すと終了します...")
|
||
return
|
||
|
||
try:
|
||
try:
|
||
template_path = select_template_file(folders["テンプレートフォルダ"])
|
||
except FileNotFoundError as e:
|
||
print(str(e))
|
||
return
|
||
|
||
print(f"テンプレート採用: {template_path.name}")
|
||
boxes = load_field_boxes(str(template_path))
|
||
print(f"検出フィールド: {[b.label for b in boxes]}")
|
||
|
||
margin = float(config["margin"])
|
||
stable_wait_sec = float(config["ファイル安定待ち秒"])
|
||
stable_retries = int(config["ファイル安定待ちリトライ回数"])
|
||
|
||
run_queue(
|
||
folders["スキャンフォルダ"],
|
||
folders["アウトプットフォルダ"],
|
||
folders["成功フォルダ"],
|
||
folders["失敗フォルダ"],
|
||
folders["ログフォルダ"],
|
||
boxes,
|
||
margin,
|
||
stable_wait_sec,
|
||
stable_retries,
|
||
)
|
||
finally:
|
||
release_lock(lock_path)
|
||
|
||
print("処理完了。")
|
||
input("何かキーを押すと終了します...")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
- [ ] **Step 3: DPI定数の重複確認**
|
||
|
||
ファイル冒頭の既存 `DPI = 300` 定数はそのまま残す(`config.txt` の `DPI` 項目は将来的な外部化用として保持するが、このTaskでは `process_pdf` 内の `convert_from_path` 呼び出しは既存の `DPI` 定数を使い続ける。config由来のDPI値を実際に反映するのは本Taskのスコープ外とし、次Task 10のREADMEに既知の制約として明記する)。
|
||
|
||
- [ ] **Step 4: 全テスト実行し既存テストが壊れていないことを確認**
|
||
|
||
Run: `python -m pytest スクリプト/tests -v`
|
||
Expected: 全件PASSED(Task 1〜8で書いた全テストが引き続き通る)
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
git add スクリプト/extract_and_rename.py
|
||
git commit -m "feat: 固定フォルダ構成・ロック・ログ統合のmain()に置き換え"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: OCR仕分け実行.bat作成
|
||
|
||
**Files:**
|
||
- Create: `OCR仕分け実行.bat`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `config.txt`(`スクリプトフォルダ=` 行)、`<スクリプトフォルダ>/python/python.exe`、`<スクリプトフォルダ>/extract_and_rename.py`
|
||
|
||
- [ ] **Step 1: バッチファイル作成**
|
||
|
||
`OCR仕分け実行.bat`:
|
||
|
||
```bat
|
||
@echo off
|
||
chcp 65001 >nul
|
||
cd /d "%~dp0"
|
||
|
||
if not exist "config.txt" (
|
||
echo config.txt が見つかりません。配布パッケージが不完全です。
|
||
pause
|
||
exit /b 1
|
||
)
|
||
|
||
set "SCRIPT_DIR="
|
||
for /f "usebackq tokens=1,2 delims==" %%A in ("config.txt") do (
|
||
if "%%A"=="スクリプトフォルダ" set "SCRIPT_DIR=%%B"
|
||
)
|
||
|
||
if "%SCRIPT_DIR%"=="" (
|
||
echo config.txt に スクリプトフォルダ の設定が見つかりません。
|
||
pause
|
||
exit /b 1
|
||
)
|
||
|
||
set "PYTHON_EXE=%~dp0%SCRIPT_DIR%\python\python.exe"
|
||
set "MAIN_PY=%~dp0%SCRIPT_DIR%\extract_and_rename.py"
|
||
|
||
if not exist "%PYTHON_EXE%" (
|
||
echo 配布パッケージが不完全です(pythonフォルダが見つかりません)。管理者に確認してください。
|
||
pause
|
||
exit /b 1
|
||
)
|
||
|
||
if not exist "%MAIN_PY%" (
|
||
echo 配布パッケージが不完全です(extract_and_rename.pyが見つかりません)。管理者に確認してください。
|
||
pause
|
||
exit /b 1
|
||
)
|
||
|
||
"%PYTHON_EXE%" "%MAIN_PY%"
|
||
```
|
||
|
||
- [ ] **Step 2: 手動動作確認(config.txt無しケース)**
|
||
|
||
`OCR仕分け実行.bat` をリポジトリルート(`config.txt` がまだ存在しない状態、Task 11で作成予定)でダブルクリックし、「config.txt が見つかりません」メッセージが出て一時停止することを目視確認する。この時点ではまだTask 11未実施のため確認できない場合は、Task 11完了後にまとめて確認してよい。
|
||
|
||
- [ ] **Step 3: コミット**
|
||
|
||
```bash
|
||
git add "OCR仕分け実行.bat"
|
||
git commit -m "feat: 起動バッチOCR仕分け実行.batを追加"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: config.txt雛形・README更新
|
||
|
||
**Files:**
|
||
- Create: `config.txt`
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: config.txt雛形作成**
|
||
|
||
`config.txt`:
|
||
|
||
```
|
||
スクリプトフォルダ=スクリプト
|
||
テンプレートフォルダ=テンプレート
|
||
スキャンフォルダ=スキャン
|
||
アウトプットフォルダ=アウトプット
|
||
成功フォルダ=成功
|
||
失敗フォルダ=失敗
|
||
ログフォルダ=ログ
|
||
margin=0.10
|
||
DPI=300
|
||
ファイル安定待ち秒=1
|
||
ファイル安定待ちリトライ回数=5
|
||
```
|
||
|
||
- [ ] **Step 2: README.md更新**
|
||
|
||
`README.md` の内容を、旧CLI手順(`--template` 等の引数説明)から新しい運用手順に置き換える:
|
||
|
||
```markdown
|
||
# PDFスキャン監視・自動振り分けツール
|
||
|
||
テンプレート画像(赤枠=氏名欄、青枠=日付欄)を基準に、"スキャン"フォルダ内のPDFから
|
||
氏名・日付をOCRで抽出し、リネーム・振り分けを行うツールです。
|
||
|
||
## 使い方
|
||
|
||
1. `config.txt` で各フォルダ名・動作パラメータを確認(通常は初期値のまま利用可)
|
||
2. `テンプレート/` フォルダにテンプレート画像を1つ入れる(複数ある場合はファイル名が最も早いもの)
|
||
- 画像編集ソフト等で、氏名欄に赤い四角枠、日付欄に青い四角枠を重ねて保存(PNG推奨)
|
||
- テンプレートは実際の対象PDFと同じ用紙サイズ・向きで作成すること
|
||
3. `スキャン/` フォルダに処理対象PDFを入れる
|
||
4. `OCR仕分け実行.bat` をダブルクリック
|
||
5. 処理結果は以下に振り分けられる:
|
||
- 成功: `アウトプット/氏名/氏名_YYYYMMDD.pdf` にコピー、元ファイルは `成功/` へ移動
|
||
- 失敗: `失敗/` フォルダへ元ファイルのまま移動
|
||
6. 実行ログは `ログ/YYYY-MM-DD.log` に記録される
|
||
7. 処理を途中で止めたい場合は、コンソールが表示されている間に何かキーを押す(1件処理する度にチェックされる)
|
||
|
||
## 開発者向け(配布パッケージのビルド)
|
||
|
||
配布用の同梱Python・tesseract・popplerは `build/build_package.py` で自動生成する。
|
||
詳細は同スクリプトのコメントを参照。ネットワーク接続必須、実行には数分〜数十分かかる。
|
||
|
||
## 開発者向け(テスト実行)
|
||
|
||
```bash
|
||
python -m pip install pytest
|
||
python -m pytest スクリプト/tests -v
|
||
```
|
||
|
||
## 注意点
|
||
|
||
- OCRは完璧ではありません。運用初期は `アウトプット/` の結果を必ず目視確認してください
|
||
- 日付の年が2桁の場合は "20XX年" と仮定して補完します。和暦は非対応です
|
||
- 複数ページPDFは1ページ目のみ対象です
|
||
- ファイル名衝突時は `氏名_YYYYMMDD(2).pdf` のように連番が付与されます
|
||
```
|
||
|
||
- [ ] **Step 3: コミット**
|
||
|
||
```bash
|
||
git add config.txt README.md
|
||
git commit -m "docs: config.txt雛形とREADMEを新運用フローに合わせて更新"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: build/build_package.py(配布パッケージ自動生成)
|
||
|
||
**Files:**
|
||
- Create: `build/build_package.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: リポジトリ内の `OCR仕分け実行.bat`、`config.txt`、`スクリプト/*.py`
|
||
- Produces: `dist/ScanOCR/` 配下の配布用フォルダ一式
|
||
|
||
このスクリプトはネットワークダウンロード・外部インストーラー実行を伴うため自動テスト対象外とする。実行には7-Zip(`7z` コマンド)がPATHに通っていることが前提(tesseract-ocrインストーラーの展開に使用)。**実際のダウンロード・展開が最後まで成功するかは、この計画のコードレビュー時点では検証できない未知のリスクとして扱う。** Step 3でユーザーが実機で1回実行し、失敗した場合はURLやインストーラーの展開方法を個別に調整する前提とする。
|
||
|
||
- [ ] **Step 1: build_package.py実装**
|
||
|
||
`build/build_package.py`:
|
||
|
||
```python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
配布パッケージ生成スクリプト。
|
||
|
||
開発者PC上で1回実行すると、dist/ScanOCR/ 配下に
|
||
Python embeddable版・依存pipパッケージ・tesseract-ocr・poppler一式を含む
|
||
配布用フォルダを生成する。
|
||
|
||
前提:
|
||
- ネットワーク接続必須
|
||
- 7-Zip(7zコマンド)がPATHに通っていること(tesseractインストーラー展開用)
|
||
|
||
使い方:
|
||
python build/build_package.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
import urllib.request
|
||
import zipfile
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
DIST_DIR = REPO_ROOT / "dist" / "ScanOCR"
|
||
|
||
PYTHON_VERSION = "3.11.9"
|
||
PYTHON_EMBED_URL = (
|
||
f"https://www.python.org/ftp/python/{PYTHON_VERSION}/"
|
||
f"python-{PYTHON_VERSION}-embed-amd64.zip"
|
||
)
|
||
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
|
||
PIP_PACKAGES = ["opencv-python", "pdf2image", "pytesseract", "pillow", "numpy"]
|
||
|
||
TESSERACT_VERSION = "5.4.0.20240606"
|
||
TESSERACT_INSTALLER_URL = (
|
||
"https://github.com/UB-Mannheim/tesseract/releases/download/"
|
||
f"v{TESSERACT_VERSION}/tesseract-ocr-w64-setup-{TESSERACT_VERSION}.exe"
|
||
)
|
||
|
||
POPPLER_VERSION = "24.02.0-0"
|
||
POPPLER_ZIP_URL = (
|
||
"https://github.com/oschwartz10612/poppler-windows/releases/download/"
|
||
f"v{POPPLER_VERSION}/Release-{POPPLER_VERSION}.zip"
|
||
)
|
||
|
||
CODE_FILES = (
|
||
"extract_and_rename.py",
|
||
"box_detector.py",
|
||
"config_loader.py",
|
||
"file_ops.py",
|
||
"lock_manager.py",
|
||
"logger.py",
|
||
)
|
||
|
||
RUNTIME_FOLDERS = ("テンプレート", "スキャン", "アウトプット", "成功", "失敗", "ログ")
|
||
|
||
|
||
def download(url: str, dest: Path) -> None:
|
||
print(f"ダウンロード: {url}")
|
||
urllib.request.urlretrieve(url, dest)
|
||
|
||
|
||
def prepare_dist_dir() -> None:
|
||
if DIST_DIR.exists():
|
||
shutil.rmtree(DIST_DIR)
|
||
DIST_DIR.mkdir(parents=True)
|
||
|
||
|
||
def copy_code_files() -> Path:
|
||
script_dst = DIST_DIR / "スクリプト"
|
||
script_dst.mkdir(parents=True)
|
||
for name in CODE_FILES:
|
||
shutil.copy2(REPO_ROOT / "スクリプト" / name, script_dst / name)
|
||
shutil.copy2(REPO_ROOT / "OCR仕分け実行.bat", DIST_DIR / "OCR仕分け実行.bat")
|
||
shutil.copy2(REPO_ROOT / "config.txt", DIST_DIR / "config.txt")
|
||
return script_dst
|
||
|
||
|
||
def setup_python(script_dst: Path, tmp_dir: Path) -> None:
|
||
python_dir = script_dst / "python"
|
||
python_dir.mkdir(parents=True)
|
||
|
||
zip_path = tmp_dir / "python-embed.zip"
|
||
download(PYTHON_EMBED_URL, zip_path)
|
||
with zipfile.ZipFile(zip_path) as zf:
|
||
zf.extractall(python_dir)
|
||
|
||
pth_files = list(python_dir.glob("python*._pth"))
|
||
if not pth_files:
|
||
raise RuntimeError("python*._pth が見つかりません(embeddable zipの構成が変わった可能性)")
|
||
pth_path = pth_files[0]
|
||
content = pth_path.read_text(encoding="utf-8")
|
||
content = content.replace("#import site", "import site")
|
||
pth_path.write_text(content, encoding="utf-8")
|
||
|
||
get_pip_path = tmp_dir / "get-pip.py"
|
||
download(GET_PIP_URL, get_pip_path)
|
||
python_exe = python_dir / "python.exe"
|
||
subprocess.run([str(python_exe), str(get_pip_path)], check=True)
|
||
subprocess.run(
|
||
[str(python_exe), "-m", "pip", "install", *PIP_PACKAGES],
|
||
check=True,
|
||
)
|
||
|
||
|
||
def setup_tesseract(script_dst: Path, tmp_dir: Path) -> None:
|
||
tools_dst = script_dst / "tools" / "tesseract"
|
||
tools_dst.mkdir(parents=True)
|
||
|
||
installer_path = tmp_dir / "tesseract-setup.exe"
|
||
download(TESSERACT_INSTALLER_URL, installer_path)
|
||
|
||
extract_dir = tmp_dir / "tesseract-extract"
|
||
extract_dir.mkdir()
|
||
subprocess.run(
|
||
["7z", "x", str(installer_path), f"-o{extract_dir}", "-y"],
|
||
check=True,
|
||
)
|
||
for item in extract_dir.iterdir():
|
||
shutil.move(str(item), str(tools_dst / item.name))
|
||
|
||
|
||
def setup_poppler(script_dst: Path, tmp_dir: Path) -> None:
|
||
tools_parent = script_dst / "tools"
|
||
tools_parent.mkdir(parents=True, exist_ok=True)
|
||
tools_dst = tools_parent / "poppler"
|
||
|
||
zip_path = tmp_dir / "poppler.zip"
|
||
download(POPPLER_ZIP_URL, zip_path)
|
||
extract_dir = tmp_dir / "poppler-extract"
|
||
with zipfile.ZipFile(zip_path) as zf:
|
||
zf.extractall(extract_dir)
|
||
|
||
inner_items = list(extract_dir.iterdir())
|
||
source_dir = inner_items[0] if len(inner_items) == 1 else extract_dir
|
||
shutil.move(str(source_dir), str(tools_dst))
|
||
|
||
|
||
def create_runtime_folders() -> None:
|
||
for name in RUNTIME_FOLDERS:
|
||
(DIST_DIR / name).mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def main() -> None:
|
||
prepare_dist_dir()
|
||
script_dst = copy_code_files()
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
tmp_dir = Path(tmp)
|
||
setup_python(script_dst, tmp_dir)
|
||
setup_tesseract(script_dst, tmp_dir)
|
||
setup_poppler(script_dst, tmp_dir)
|
||
|
||
create_runtime_folders()
|
||
print(f"配布パッケージ生成完了: {DIST_DIR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
- [ ] **Step 2: .gitignoreにdist/が含まれていることを確認**
|
||
|
||
Task 1で作成した `.gitignore` に既に `dist/` が含まれているため追加作業は不要。`git status` で `dist/` がトラッキング対象外になっていることを確認する。
|
||
|
||
Run: `git status --short`
|
||
Expected: `dist/` 配下のファイルが(生成されていても)一覧に出ない
|
||
|
||
- [ ] **Step 3: コミット**
|
||
|
||
```bash
|
||
git add build/build_package.py
|
||
git commit -m "feat: 配布パッケージ自動生成スクリプトを追加"
|
||
```
|
||
|
||
**注記(ユーザー向け確認事項):** このタスクはコードとして実装するが、実際に最後まで実行してdist/ScanOCR/配下が正しく動く状態になるかは、7-Zipインストール状況・各ダウンロードURLの生存・tesseractインストーラーの展開可否に依存する。初回実行時にエラーが出た場合は、エラーメッセージを元に該当関数(`setup_tesseract`が最もリスクが高い)を調整する。
|
||
|
||
---
|
||
|
||
## 全体自己レビュー結果
|
||
|
||
- **spec網羅性**: 設計書の全セクション(配布パッケージ構成/config.txt仕様/起動フロー/メインループ/終了処理/ログ仕様/ビルドスクリプト/対象外事項)に対応するタスクを配置済み
|
||
- **placeholder**: なし。全ステップに実コードを記載
|
||
- **型・シグネチャ一貫性**: `run_queue`・`copy_with_unique_name`・`move_with_unique_name`・`append_log`・`acquire_lock` の引数名はTask間で統一済み
|
||
- **既知の未実装事項**: `config.txt` の `DPI` 項目は雛形には存在するが `process_pdf` には未接続(Task 9 Step 3に明記)。将来対応が必要ならフォローアップタスクとする
|