أتمتة ترتيب Downloads بـ Python بدون حذف ملف بالغلط
هتطلع من المقال بسكربت آمن يرتب مجلد Downloads تلقائيًا، ويقلل تنظيف أسبوعي من 45 دقيقة تقريبًا إلى 6 دقائق متابعة فقط.
مستوى القارئ: مبتدئ إلى متوسط
المشكلة باختصار
اللي بيحصل فعلاً إن Downloads بيجمع كل شيء: فواتير PDF، صور WhatsApp Web، ملفات ZIP، exports من أدوات SaaS، وملفات كود مؤقتة. بعد أسبوعين بتلاقي 300 ملف، وتبدأ تنقل يدويًا. الطريقة دي بتفشل لأنها تعتمد على مزاجك، وممكن تنقل ملف مهم أو تحذف ملف لسه محتاجه.
الافتراض إنك على Windows، وعندك Python 3.10 أو أحدث، ومجلد Downloads فيه أقل من 10 آلاف ملف. لو عندك أكثر من كده، نفس الفكرة تشتغل، لكن هتحتاج تقسيم وتشغيل أهدأ.
الفكرة: انقل، لا تحذف
ركز: أول نسخة من أي أوتوميشن ملفات لازم تكون دفاعية. يعني تعمل dry-run الأول، تكتب log، وتنقل الملفات بدل ما تحذفها. مثال بسيط: ملف invoice.pdf يروح إلى Downloads/Sorted/Documents، وصورة screenshot.png تروح إلى Downloads/Sorted/Images. لو اسم الملف موجود، السكربت يضيف رقم بدل ما يستبدله.
الـ trade-off هنا واضح. هتكسب ترتيب يومي ثابت ومخاطر أقل. هتخسر شوية مرونة، لأن التصنيف مبني على الامتداد وليس على محتوى الملف. ده مناسب كبداية، وبعدها ممكن تضيف OCR أو قواعد حسب الاسم.
السكربت العملي
اعمل ملف اسمه sort_downloads.py. شغّله أول مرة بـ --dry-run. لو المخرجات منطقية، شيله في التشغيل المجدول.
from pathlib import Path
import argparse
import shutil
import logging
RULES = {
"Documents": {".pdf", ".docx", ".xlsx", ".csv", ".txt"},
"Images": {".png", ".jpg", ".jpeg", ".webp", ".gif"},
"Archives": {".zip", ".rar", ".7z", ".tar", ".gz"},
"Code": {".py", ".js", ".ts", ".json", ".sql"},
}
def category_for(path: Path) -> str:
suffix = path.suffix.lower()
for category, extensions in RULES.items():
if suffix in extensions:
return category
return "Other"
def unique_target(target: Path) -> Path:
if not target.exists():
return target
stem, suffix = target.stem, target.suffix
for i in range(1, 1000):
candidate = target.with_name(f"{stem}-{i}{suffix}")
if not candidate.exists():
return candidate
raise RuntimeError(f"Too many duplicates for {target.name}")
def sort_downloads(downloads: Path, dry_run: bool) -> None:
sorted_root = downloads / "Sorted"
log_file = sorted_root / "sort-downloads.log"
sorted_root.mkdir(exist_ok=True)
logging.basicConfig(
filename=log_file,
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
for item in downloads.iterdir():
if item.name == "Sorted" or item.is_dir():
continue
category = category_for(item)
target_dir = sorted_root / category
target = unique_target(target_dir / item.name)
message = f"{item.name} -> {target.relative_to(downloads)}"
print("DRY" if dry_run else "MOVE", message)
logging.info(("DRY " if dry_run else "MOVE ") + message)
if not dry_run:
target_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(item), str(target))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--downloads", default=str(Path.home() / "Downloads"))
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
sort_downloads(Path(args.downloads), args.dry_run)