AI ์๋ฒ, OCR, PDF ๋ถ์ ์์ด
๋ก์ปฌ ๊ท์น ๊ธฐ๋ฐ MVP๋ฅผ ๊ตฌํํ๋ค.
ํต์ฌ ์ฝ๋, ํ์ผ ์ค๋ช ๋ฑ
๊ฐ๋จํ๊ฒ๋ง ๊ธฐ๋ก์ฉ์ผ๋ก ์ ์ด๋ณด๊ฒ ๋ค.
[ GitHub์ ์ฝ๋ ์ค๋ช ์ด ์ ์ ํ์์ต๋๋ค ]
GitHub - hyeong-ing/DesktopCleanAI at cleanAI
๋ฐ์คํฌํฑ ์ ๋ฆฌํด์ฃผ๋ AI . Contribute to hyeong-ing/DesktopCleanAI development by creating an account on GitHub.
github.com
GitHub - hyeong-ing/DesktopCleanAI at deskpilot
๋ฐ์คํฌํฑ ์ ๋ฆฌํด์ฃผ๋ AI . Contribute to hyeong-ing/DesktopCleanAI development by creating an account on GitHub.
github.com
1. ๋ฐฐ๊ฒฝ
- ํ ์คํธ ๋ฐ์คํฌํฑ

์ง์ง ๋ฐ์คํฌํฑ์ ํ ์๊ฐ ์์ด์ ์ด๋ ๊ฒ ํ ์คํธ ๋ฐ์คํฌํฑ์ ๋ง๋ค์๋ค.
- ํฐ๋ฏธ๋์์ ์ฝ๋ ์คํ

ํฐ๋ฏธ๋์์ ํ์ธํ๋ค.
2. ํ์ผ ์ค๋ช
- ์ฃผ์ ํ์ผ
| ํด๋๋ช | ์ค๋ช |
| config.py | ํ๋ก์ ํธ ์ค์ ๊ฐ ๊ด๋ฆฌ, ์ฌ๊ธฐ์ test_desktop ์ค์ ํจ |
| scanner.py | ํ์ผ๊ณผ ํด๋๋ฅผ ์ค์บํจ |
| rules.py | rules.txt ์ฝ์ |
| classifier.py | ํ๊ทธ์ rules.txt ๊ธฐ์ค ๋ถ๋ฅํจ |
| planner.py | ํ์ผ๋ณ ์ ๋ฆฌ ๊ณํ ์์ฑํจ |
| path_planner.py | ์ค์ ์ด๋๋ ๊ฒฝ๋ก ๊ณ์ฐํจ |
| mover.py | ํ์ผ ์ด๋์ ์คํํจ |
| log_store.py | move-log.json ์ ์ฅํ๊ณ ์ฝ์ |
| undo.py | ๋๋๋ฆฌ๊ธฐ ๊ธฐ๋ฅ |
| redo.py | ์์ผ๋ก๊ฐ๊ธฐ ๊ธฐ๋ฅ |
| selected_mover.py | ์ ํํ ํ์ผ๋ง ์ด๋ํจ |
| doctor.py | ํ๋ก์ ํธ ์ํ ์ ๊ฒํจ |
| deskpilot.py | ํตํฉ ์คํ ํ์ผ |
- rules.txt
school: ์ ๋ณด์ฒ๋ฆฌ๊ธฐ์ฌ, ์์ ํ, ๊ธฐ์ฌ
study: ์คํ๋ง, ๊ฐ์, ๊ณต๋ถ, ๊ธฐ๋ณธํธ
project: project, api, test, lotto
์ด ํ์ผ์ ํ ์คํธ ํด๋์ธ test_desktop์ ๋ง๋ ๋ค. ๊ทธ๋ฅ ๋๋ฉด ์๋๊ณ .์ ๋ถ์ธ ์จ๊ฒจ์ง ํด๋๋ฅผ ๋ง๋ค์ด์ ๋ฌ์ผํ๋ค. ๊ทธ ๋ถ๋ถ์ ์๋ ๋งํฌ๋ก ๊ฑธ์ด๋๊ฒ ๋ค. ์จ๊น ํด๋ ์ด๋ฆ์ .deskpilot์ด๊ณ ๊ทธ ์์ rules.txt๋ฅผ ๋ฃ์๋ค.
rules.txt๋ ๋ง๊ทธ๋๋ก ๊ท์น์ ์ ์ด๋๋ค. ์ ๋ณด์ฒ๋ฆฌ๊ธฐ์ฌ, ์์ ํ, ๊ธฐ์ฌ๋ผ๋ ๋จ์ด๊ฐ ์ ๋ชฉ์ ๋ถ์ด์์ผ๋ฉด school ํด๋๋ก ์ด๋ํ๋ค.
[macOS] Finder ์จ๊น ํ์ผ ๋ณด๊ธฐ & ์จ๊น ์ค์ & ์จ๊น ํด์
macOS Finder ์จ๊น ํ์ผ ๋ณด๊ธฐ & ์จ๊น ์ค์ & ์จ๊น ํด์ 1. ํฐ๋ฏธ๋์ ์ด์ฉํ์ฌ ์จ๊ธฐ๊ธฐํฐ๋ฏธ๋ ์ด๊ธฐํฐ๋ฏธ๋ ์ฑ์ ์ฝ๋๋ค. ํฐ๋ฏธ๋์ ์์ฉ ํ๋ก๊ทธ๋จ > ์ ํธ๋ฆฌํฐ ํด๋์์ ์ฐพ์ ์ ์์ต๋๋ค.Spotlight(Commandโ + S
learn-dev.tistory.com
3. ์ค์ํ ์ฝ๋๋ง
- config.py
TEST_DESKTOP_FOLDER_NAME = "test_desktop"
APP_FOLDER_NAME = ".deskpilot"
ARCHIVE_FOLDER_NAME = "archive"
MOVE_CONFIRM_TEXT = "์ ๋ฆฌํ๊ธฐ"
UNDO_CONFIRM_TEXT = "๋๋๋ฆฌ๊ธฐ"
REDO_CONFIRM_TEXT = "์์ผ๋ก๊ฐ๊ธฐ"
EXCLUDED_FILENAMES = {
".DS_Store",
"desktop.ini",
"Thumbs.db",
"rules.txt",
"move-log.json",
}
EXCLUDED_FOLDER_NAMES = {
APP_FOLDER_NAME,
"deskpilot",
"__pycache__",
}
๋์ค์ ํ ์คํธ ํด๋๋ ๋ฐ๊ฟ์ผํํ๊ณ ์ ์ธ ์ํจ ํ์ผ์ด ์ถ๊ฐ๋๊ฑฐ๋ ์ ๊ฑฐ๋ ์ฌ๋ฌ ๊ฐ๋ฅ์ฑ์ ๊ณ ๋ คํด์ ํ๊ณณ์ ๊ธฐ๋ฅ์ ๊ด๋ฆฌํ๋๋ก ๋ชจ์๋ค. ์ด ํ์ผ์์ ํ ์คํธ ํด๋ ์ด๋ฆ, ํ์ธ ๋ฌธ๊ตฌ(ํ์ธ๋ฌธ๊ตฌ๋ฅผ ์ ๋๋ก ์ ์ด์ผ ๊ธฐ๋ฅ์ด ์คํ๋๋๋ก ๋ง๋ค์์), ์ ์ธ ํ์ผ ๋ชฉ๋ก์ ๋ณผ ์ ์๋ค.
- scanner.py
def get_desktop_path():
return Path.home() / "Desktop" / TEST_DESKTOP_FOLDER_NAME
ํ ์คํธ์ฉ ๊ฐ์ง Desktop ํด๋ ๊ฒฝ๋ก๋ฅผ ๊ฐ์ ธ์จ๋ค.
def scan_desktop_files():
desktop_path = get_desktop_path()
validate_desktop_path(desktop_path)
files = []
for item in desktop_path.iterdir():
if not item.is_file():
continue
if is_hidden_path(item):
continue
if is_excluded_file(item):
continue
files.append(item)
return sorted(files, key=lambda path: path.name.lower())
test_desktop ๋ฐ๋ก ์๋์ ์๋ ํ์ผ๋ง ๊ฐ์ ธ์ค๋ ํจ์์ด๋ค. ๊ทธ๋ฐ๋ฐ ์ด๋ ํด๋, ์จ๊น ํ์ผ, ์์คํ ํ์ผ, rules.txt, move-log.json์ ์ ์ธํ๋ค.
def scan_destination_folders():
desktop_path = get_desktop_path()
validate_desktop_path(desktop_path)
folders = []
for item in desktop_path.iterdir():
if not item.is_dir():
continue
if is_hidden_path(item):
continue
if is_excluded_folder(item):
continue
folders.append(item)
return sorted(folders, key=lambda path: path.name.lower())
test_desktop ๋ฐ๋ก ์๋์ ์๋ ํด๋๋ง ๊ฐ์ ธ์ค๋ ํจ์์ด๋ค. ์ด ํด๋๋ค์ ๋์ค์ ํ์ผ์ ์ด๋ํ ๋ชฉ์ ์ง ํ๋ณด๊ฐ ๋๋ค. ํ์ผ, ์จ๊น ํด๋, ์ฑ ๋ด๋ถ ์ค์ ํด๋, ํ์ด์ฌ ์บ์ ํด๋๋ ์ ์ธ๋๋ค.
- rules.py
def get_rules_path():
return get_desktop_path() / APP_FOLDER_NAME / "rules.txt"
rules.txt ํ์ผ ๊ฒฝ๋ก๋ฅผ ๊ฐ์ ธ์จ๋ค.
def read_rules():
rules_path = get_rules_path()
if not rules_path.exists():
return {}
rules = {}
with open(rules_path, "r", encoding="utf-8") as file:
for line in file:
line = line.strip()
if line == "" or line.startswith("#"):
continue
if ":" not in line:
continue
folder_name, keywords_text = line.split(":", 1)
folder_name = folder_name.strip()
keywords = []
for keyword in keywords_text.split(","):
keyword = keyword.strip()
if keyword != "":
keywords.append(keyword)
if folder_name != "" and keywords:
rules[folder_name] = keywords
return rules
rules.txt๋ฅผ ์ฝ์ด์ ํ์ด์ฌ ๋์ ๋๋ฆฌ๋ก ๋ฐ๊พธ๋ ํจ์์ด๋ค.
์ด txt๋ฅผ ์ฝ์ด์ rules.txt ์: school: ์ ๋ณด์ฒ๋ฆฌ๊ธฐ์ฌ, ์์ ํ, ๊ธฐ์ฌ study: ์คํ๋ง, ๊ฐ์, ๊ณต๋ถโ
์ด๋ ๊ฒ ๋ณํํ๋ค. { "school": ["์ ๋ณด์ฒ๋ฆฌ๊ธฐ์ฌ", "์์ ํ", "๊ธฐ์ฌ"], "study": ["์คํ๋ง", "๊ฐ์", "๊ณต๋ถ"] }โ
- classifier.py
def classify_by_tag(file_path, destination_folder_names):
filename = file_path.name
tag_name = extract_front_tag(filename)
if tag_name is None:
return None
if tag_name not in destination_folder_names:
return None
return {
"filename": filename,
"targetFolder": tag_name,
"reason": "ํ์ผ๋ช
์ ํ๊ทธ์ ๊ฐ์ ์ด๋ฆ์ ํด๋๋ฅผ ์ฐพ์์ต๋๋ค.",
"source": "FILENAME_TAG",
}
ํ์ผ๋ช ์์ [ํ๊ทธ]๋ฅผ ๋ณด๊ณ ์ด๋ํ ํด๋๋ฅผ ์ถ์ฒํ๋ ํจ์์ด๋ค.
def classify_by_rules(file_path, rules, destination_folder_names):
filename = file_path.name
normalized_filename = normalize_text(filename)
for folder_name, keywords in rules.items():
if folder_name not in destination_folder_names:
continue
for keyword in keywords:
normalized_keyword = normalize_text(keyword)
if normalized_keyword in normalized_filename:
return {
"filename": filename,
"targetFolder": folder_name,
"reason": f"ํ์ผ๋ช
์ rules.txt ํค์๋ '{keyword}'๊ฐ ํฌํจ๋์ด ์์ต๋๋ค.",
"source": "RULES_TXT",
}
return None
rules.txt ๊ท์น์ ๋ณด๊ณ ์ด๋ํ ํด๋๋ฅผ ์ถ์ฒํ๋ ํจ์์ด๋ค. ์กฐ๊ฑด์ ํ์ผ๋ช ์์ rules.txt์ ํค์๋๊ฐ ํฌํจ๋์ด ์์ด์ผ ํ๊ณ ๊ท์น์ ํด๋ ์ด๋ฆ์ด ์ค์ ์ด๋ ๋ชฉ์ ์ง ํด๋์ ์กด์ฌํด์ผ ํ๋ค.
- planner.py
def create_plan_for_file(file_path, destination_folder_names, rules):
result = classify_by_tag(file_path, destination_folder_names)
if result is not None:
result["action"] = "MOVE"
result["confidence"] = 1.0
return result
result = classify_by_rules(file_path, rules, destination_folder_names)
if result is not None:
result["action"] = "MOVE"
result["confidence"] = 0.8
return result
if ARCHIVE_FOLDER_NAME in destination_folder_names:
return {
"filename": file_path.name,
"targetFolder": ARCHIVE_FOLDER_NAME,
"reason": "ํ๊ทธ์ rules.txt๋ก ๋ถ๋ฅ๋์ง ์์ archive ํด๋๋ก ์ถ์ฒํฉ๋๋ค.",
"source": "FALLBACK_ARCHIVE",
"action": "MOVE",
"confidence": 0.3,
}
return {
"filename": file_path.name,
"targetFolder": None,
"reason": "๋ถ๋ฅ๋์ง ์์๊ณ archive ํด๋๊ฐ ์์ด Desktop์ ๋จ๊น๋๋ค.",
"source": "UNCLASSIFIED",
"action": "KEEP",
"confidence": 0.0,
}
๋ชจ๋ ํ์ผ์ ๋ํด ์ต์ข ์ ๋ฆฌ ๊ณํ์ ๋ง๋ ๋ค. ํ๊ทธ → rules.txt → archive → Desktop ์ ์ง ์์๋ก ๋ง๋ ๋ค.
- path_planner.py
def remove_front_tag(filename):
cleaned_name = re.sub(r"^\[[^\]]+\]\s*", "", filename)
if cleaned_name.strip() == "":
return filename
return cleaned_name.strip()
ํ์ผ๋ช ๋งจ ์์ [ํ๊ทธ]๋ฅผ ์ ๊ฑฐํ๋ค. ์์๋ก [school]์ ๋ณด์ฒ๋ฆฌ๊ธฐ์ฌ ์ ๋ฆฌ๋ณธ.pdf๊ฐ ์ ๋ณด์ฒ๋ฆฌ๊ธฐ์ฌ ์ ๋ฆฌ๋ณธ.pdf๋ก ๋ณ๊ฒฝ๋๋ค.
def make_unique_target_path(target_path, reserved_target_paths):
candidate_path = target_path
parent = target_path.parent
stem = target_path.stem
suffix = target_path.suffix
number = 1
while candidate_path.exists() or candidate_path in reserved_target_paths:
candidate_filename = f"{stem}({number}){suffix}"
candidate_path = parent / candidate_filename
number += 1
return candidate_path
์ด๋ํ๋ ค๋ ํด๋ ์์ ๊ฐ์ ์ด๋ฆ์ ํ์ผ์ด ์์ผ๋ฉด ํ์ผ๋ช ๋ค์ ์ซ์๋ฅผ ๋ถ์ฌ์ ์ค๋ณต์ ํผํ๋ค. ์์๋ก school/์ ๋ฆฌ๋ณธ.pdf ๊ฐ ์ด๋ฏธ ์์ผ๋ฉด school/์ ๋ฆฌ๋ณธ(1).pdf ๋ก ๋ณ๊ฒฝํ๋ค.
- mover.py
def is_inside_base_path(path, base_path):
try:
path.resolve().relative_to(base_path.resolve())
return True
except ValueError:
return False
์ฃผ์ด์ง path๊ฐ base_path ๋ด๋ถ์ ์๋์ง ํ์ธํ๋ ํจ์์ด๋ค. test_desktop ๋ฐ์ ํ์ผ์ ์ค์๋ก ์ด๋ํ์ง ์๊ธฐ ์ํด ์ฌ์ฉํ๋ค.
if dry_run:
results.append(
create_result(
item,
"DRY_RUN",
"์ค์ ์ด๋ ์์ด ์ด๋ ์์ ๋ง ํ์ธํ์ต๋๋ค.",
)
)
continue
๊ธฐ๋ณธ์ dry-run์ด๊ณ , ์ค์ ์ด๋์ --apply์ ํ์ธ ๋ฌธ๊ตฌ๋ฅผ ํต๊ณผํด์ผ ์คํ๋๋๋ก ํ๋ค. ๋์ค์ ์ฌ์ฉ์๊ฐ ์ ํํ ํ์ผ๋ง ์ด๋ํ๋๋ก ํ ๊ฒ์ด๋ค.
- log_store.py
def save_move_session(results):
successful_moves = []
for result in results:
if result.get("status") != "SUCCESS":
continue
successful_moves.append(
{
"filename": result.get("filename"),
"originalPath": result.get("originalPath"),
"movedPath": result.get("targetPath"),
"source": result.get("source"),
"status": result.get("status"),
}
)
if not successful_moves:
return None
log_data = load_log()
session = {
"sessionId": create_session_id(),
"executedAt": datetime.now().isoformat(timespec="seconds"),
"basePath": str(get_desktop_path()),
"moves": successful_moves,
}
log_data["undoStack"].append(session)
log_data["redoStack"] = []
save_log(log_data)
return session
์ค์ ์ด๋์ ์ฑ๊ณตํ ํ์ผ๋ง ๋ก๊ทธ์ ์ ์ฅํด์ undo/redo์ ๊ธฐ์ค์ผ๋ก ์ฌ์ฉํ๋ค.
- undo.py
def create_undo_plan(session):
moves = session.get("moves", [])
undo_plan = []
for move in reversed(moves):
undo_plan.append(
{
"filename": move.get("filename"),
"fromPath": move.get("movedPath"),
"toPath": move.get("originalPath"),
"source": move.get("source"),
}
)
return undo_plan
์ต๊ทผ ์ด๋ ์ธ์ ์ ๋ฐํ์ผ๋ก ๋๋๋ฆฌ๊ธฐ ๊ณํ์ ๋ง๋ ๋ค. movedPath -> originalPath ๋ฐฉํฅ์ผ๋ก ๋๋๋ฆฐ๋ค.
def update_log_after_undo(log_data):
session = log_data["undoStack"].pop()
log_data["redoStack"].append(session)
save_log(log_data)
return session
๋๋๋ฆฌ๊ธฐ ์ฑ๊ณต ํ ๋ก๊ทธ๋ฅผ ์ ๋ฐ์ดํธํ๋ค. undoStack์ ๊ฐ์ฅ ์ต๊ทผ ์ธ์ ์ ๊บผ๋ด redoStack์ผ๋ก ์ฎ๊ธด๋ค.
- redo.py
def create_redo_plan(session):
moves = session.get("moves", [])
redo_plan = []
for move in moves:
redo_plan.append(
{
"filename": move.get("filename"),
"fromPath": move.get("originalPath"),
"toPath": move.get("movedPath"),
"source": move.get("source"),
}
)
return redo_plan
redoStack์ ์ธ์ ์ ๋ฐํ์ผ๋ก ์์ผ๋ก๊ฐ๊ธฐ ๊ณํ์ ๋ง๋ ๋ค. originalPath -> movedPath ๋ฐฉํฅ์ผ๋ก ๋ค์ ์ด๋ํ๋ค.
def update_log_after_redo(log_data):
session = log_data["redoStack"].pop()
log_data["undoStack"].append(session)
save_log(log_data)
return session
์์ผ๋ก๊ฐ๊ธฐ ์ฑ๊ณต ํ ๋ก๊ทธ๋ฅผ ์ ๋ฐ์ดํธํ๋ ํจ์์ด๋ค. redoStack์ ๊ฐ์ฅ ์ต๊ทผ ์ธ์ ์ ๊บผ๋ด undoStack์ผ๋ก ์ฎ๊ธด๋ค.
- selected_mover.py
def parse_selection(selection_text, max_number):
selection_text = selection_text.strip().lower()
if selection_text == "all":
return set(range(1, max_number + 1))
if selection_text == "none" or selection_text == "":
return set()
selected_numbers = set()
parts = selection_text.split(",")
for part in parts:
part = part.strip()
if "-" in part:
start_text, end_text = part.split("-", 1)
start = int(start_text.strip())
end = int(end_text.strip())
for number in range(start, end + 1):
selected_numbers.add(number)
else:
number = int(part)
selected_numbers.add(number)
return selected_numbers
์ ์ฒด ์ด๋์ด ์๋๋ผ ์ฌ์ฉ์๊ฐ ์ ํํ ํ์ผ๋ง ์ด๋ํ ์ ์๊ฒ ํ๋ค.
- deskpilot.py
if args.command == "preview":
run_preview()
elif args.command == "move":
run_move(apply=args.apply)
elif args.command == "undo":
run_undo(apply=args.apply)
elif args.command == "redo":
run_redo(apply=args.apply)
elif args.command == "log":
run_log()
elif args.command == "doctor":
checks = run_checks()
print_checks(checks)
๋ชจ๋ ๊ธฐ๋ฅ์ ํ๋์ ๋ช ๋ น์ด๋ก ์คํํ ์ ์๊ฒ ๋ฌถ์๋ค. preview, move, undo, redo, log, doctor ๊ธฐ๋ฅ์ deskpilot.py ํ๋๋ก ์คํํ ์ ์๋ค.
- doctor.py
def check_move_log_json():
log_path = get_log_path()
if not log_path.exists():
return create_check_result(
"WARN",
"move-log.json ํ์ผ์ด ์์ง ์์ต๋๋ค. ์ค์ ์ด๋์ ํ๋ฉด ์์ฑ๋ฉ๋๋ค.",
)
try:
with open(log_path, "r", encoding="utf-8") as file:
log_data = json.load(file)
except json.JSONDecodeError:
return create_check_result(
"ERROR",
"move-log.json ํ์ผ์ JSON ํ์์ด ๊นจ์ ธ ์์ต๋๋ค.",
)
if "undoStack" not in log_data:
return create_check_result(
"ERROR",
"move-log.json์ undoStack์ด ์์ต๋๋ค.",
)
if "redoStack" not in log_data:
return create_check_result(
"ERROR",
"move-log.json์ redoStack์ด ์์ต๋๋ค.",
)
return create_check_result(
"OK",
"move-log.json ํ์ผ์ด ์ ์์
๋๋ค.",
)
ํ๋ก์ ํธ ์ํ๋ฅผ ๊ฒ์ฌํ๋ค. ์คํ ์ ํด๋, rules.txt, archive, move-log.json ์ํ๋ฅผ ์ ๊ฒํด์ ๋ฌธ์ ๋ฅผ ๋ฏธ๋ฆฌ ์ฐพ๋๋ค.
4. ์คํํ๋ฉด
- doctor ์คํ

- ์ ํํด์ ํ์ผ ์ฎ๊ธฐ๊ธฐ


- ๋๋๋ฆฌ๊ธฐ

- ์์ผ๋ก ๋๋๋ฆฌ๊ธฐ
