بايثون بالمثال 21: تنظيم الملفات تلقائيًا حسب النوع والامتداد باستخدام pathlib وshutil

تنظيم الملفات تلقائيًا حسب النوع والامتداد باستخدام بايثون

إذا كنت تستخدم مجلد Downloads يوميًا، فغالبًا ستجد بعد فترة صورًا وملفات PDF وجداول Excel وفيديوهات وملفات ZIP كلها في مكان واحد. بدل ترتيبها يدويًا كل مرة، يمكن تحويل هذه المهمة إلى مشروع عملي باستخدام بايثون.

في هذا الدرس من سلسلة بايثون بالمثال سنبني برنامجًا ينظم الملفات تلقائيًا حسب النوع والامتداد. سيقرأ الملفات الموجودة داخل مجلد تختاره، ثم يحدد امتداد كل ملف باستخدام pathlib، وينشئ مجلدات مثل Images وPDF وExcel وVideos وArchives، ثم ينقل الملفات إلى أماكنها باستخدام shutil.move().

لن نكتفي بالنسخة البسيطة. سنضيف وضع Preview حتى ترى ما الذي سيفعله البرنامج قبل تحريك أي ملف، وسنتعامل مع مشكلة الأسماء المتكررة، والملفات المفتوحة، والامتدادات غير المعروفة، والمجلدات الموجودة داخل مجلد المصدر.

{alertInfo} فكرة المشروع: اقرأ كل ملف داخل المجلد، استخرج امتداده مثل .jpg أو .pdf، اربط الامتداد بفئة مناسبة، أنشئ مجلد الفئة إذا لم يكن موجودًا، ثم انقل الملف بأمان.

{getToc} $title={محتوى المقال}

ماذا سنبني في هذا المشروع؟

لنفترض أن مجلدًا اسمه Downloads يحتوي:

Downloads/
├── photo.jpg
├── logo.png
├── report.pdf
├── invoice.xlsx
├── notes.txt
├── video.mp4
├── backup.zip
└── file_without_extension

بعد تشغيل البرنامج نريد أن يصبح التنظيم قريبًا من:

Downloads/
├── Images/
│   ├── photo.jpg
│   └── logo.png
├── PDF/
│   └── report.pdf
├── Excel/
│   └── invoice.xlsx
├── Text/
│   └── notes.txt
├── Videos/
│   └── video.mp4
├── Archives/
│   └── backup.zip
└── Other/
    └── file_without_extension

المكتبات التي سنستخدمها

المشروع لا يحتاج إلى تثبيت مكتبات خارجية. سنستخدم pathlib للتعامل مع المسارات والملفات، وshutil لنقل الملفات.

from pathlib import Path
import shutil

اختيار مجلد المصدر

في البداية لا تجرب البرنامج مباشرة على Downloads الحقيقي. أنشئ مجلد اختبار يحتوي عدة ملفات:

source_folder = Path("test_downloads")

وعلى Windows تستطيع استخدام مسار كامل:

source_folder = Path(
    r"C:\Users\User\Downloads"
)
{alertWarning} مهم: ابدأ بمجلد تجريبي، ثم استخدم وضع Preview قبل النقل الحقيقي. البرنامج سيغير أماكن الملفات، لذلك يجب التأكد من القواعد أولًا.

قراءة محتويات المجلد باستخدام pathlib

for item in source_folder.iterdir():
    print(item)

iterdir() تمر على العناصر الموجودة مباشرة داخل المجلد. قد يكون العنصر ملفًا أو مجلدًا، لذلك نفلتر الملفات:

for item in source_folder.iterdir():
    if item.is_file():
        print(item.name)
قراءة ملفات مجلد واستخراج امتداد كل ملف باستخدام pathlib في بايثون

استخراج امتداد الملف باستخدام suffix

file_path = Path("photo.jpg")

print(file_path.suffix)

الناتج:

.jpg

ومن الأفضل استخدام lower() حتى نتعامل مع .JPG و.jpg بالطريقة نفسها:

extension = item.suffix.lower()

إنشاء خريطة امتدادات الملفات

CATEGORIES = {
    "Images": {
        ".jpg", ".jpeg", ".png", ".webp", ".gif"
    },
    "PDF": {
        ".pdf"
    },
    "Excel": {
        ".xlsx", ".xls", ".csv"
    },
    "Documents": {
        ".doc", ".docx", ".odt"
    },
    "Text": {
        ".txt", ".md"
    },
    "Videos": {
        ".mp4", ".mkv", ".avi", ".mov"
    },
    "Audio": {
        ".mp3", ".wav", ".m4a", ".flac"
    },
    "Archives": {
        ".zip", ".rar", ".7z", ".tar", ".gz"
    }
}
تصنيف الملفات حسب الامتداد إلى صور ومستندات وفيديو وExcel باستخدام بايثون

دالة تحدد فئة الملف

def get_category(extension):
    for category, extensions in CATEGORIES.items():
        if extension in extensions:
            return category

    return "Other"

إذا كان الامتداد معروفًا، ترجع الدالة اسم الفئة. وإذا لم تجد تطابقًا، ترجع Other.

اختبار التصنيف

print(get_category(".jpg"))
print(get_category(".pdf"))
print(get_category(".xlsx"))
print(get_category(".unknown"))

الناتج:

Images
PDF
Excel
Other

الملفات التي لا تملك امتدادًا

إذا كان الملف اسمه LICENSE، فغالبًا ستكون suffix فارغة. في برنامجنا سيذهب هذا الملف إلى Other ما لم تضف قاعدة خاصة له.

إنشاء مجلد الفئة تلقائيًا

destination_folder = (
    source_folder / "Images"
)

destination_folder.mkdir(
    parents=True,
    exist_ok=True
)

exist_ok=True تمنع ظهور خطأ لمجرد أن المجلد موجود أصلًا.

نقل ملف باستخدام shutil.move

source_file = source_folder / "photo.jpg"
destination_file = (
    source_folder / "Images" / "photo.jpg"
)

shutil.move(
    str(source_file),
    str(destination_file)
)

النسخة البسيطة من منظم الملفات

from pathlib import Path
import shutil


source_folder = Path("test_downloads")

CATEGORIES = {
    "Images": {".jpg", ".jpeg", ".png", ".webp"},
    "PDF": {".pdf"},
    "Excel": {".xlsx", ".xls", ".csv"},
    "Videos": {".mp4", ".mkv", ".avi"},
    "Archives": {".zip", ".rar", ".7z"}
}


def get_category(extension):
    for category, extensions in CATEGORIES.items():
        if extension in extensions:
            return category

    return "Other"


for item in source_folder.iterdir():
    if not item.is_file():
        continue

    extension = item.suffix.lower()
    category = get_category(extension)

    destination_folder = source_folder / category
    destination_folder.mkdir(exist_ok=True)

    destination_file = destination_folder / item.name

    shutil.move(
        str(item),
        str(destination_file)
    )

هذا الكود يشرح الفكرة الأساسية، لكنه يحتاج تحسينًا مهمًا قبل استخدامه على مجلد حقيقي: ماذا يحدث إذا كان اسم الملف موجودًا أصلًا في الوجهة؟

منع تعارض أسماء الملفات

نريد بدل المخاطرة بملف يحمل الاسم نفسه أن تصبح الأسماء مثل:

photo.jpg
photo_1.jpg
photo_2.jpg

نستخدم دالة تبحث عن أول اسم غير مستخدم:

def get_unique_destination(
    destination_folder,
    file_name
):
    destination = destination_folder / file_name

    if not destination.exists():
        return destination

    file_path = Path(file_name)
    counter = 1

    while True:
        new_name = (
            f"{file_path.stem}_{counter}"
            f"{file_path.suffix}"
        )

        destination = destination_folder / new_name

        if not destination.exists():
            return destination

        counter += 1

ما الفرق بين stem وsuffix؟

file_path = Path("report.pdf")

print(file_path.stem)
print(file_path.suffix)

الناتج:

report
.pdf

وهذا يسمح لنا بإنشاء report_1.pdf بدون كسر الامتداد.

إضافة وضع Preview قبل تحريك الملفات

هذه من أهم إضافات المشروع. نبدأ بـ:

PREVIEW = True

ثم بدل النقل مباشرة:

if PREVIEW:
    print(
        f"[PREVIEW] {item.name}"
        f" -> {destination_file}"
    )
else:
    shutil.move(
        str(item),
        str(destination_file)
    )
نقل الملفات بأمان باستخدام shutil.move مع وضع Preview في بايثون

لماذا Preview مهمة؟

لأن الخطأ في قاعدة تصنيف واحدة قد يرسل مجموعة ملفات إلى المكان غير المناسب. المعاينة تسمح لك برؤية المصدر والوجهة قبل أن يتغير أي ملف فعليًا.

تشغيل النقل الحقيقي

بعد مراجعة مخرجات Preview والتأكد أن المسار والتصنيفات صحيحة:

PREVIEW = False

إضافة إحصاءات حسب الفئة

stats = {}

stats[category] = (
    stats.get(category, 0) + 1
)

وفي النهاية:

for category, count in stats.items():
    print(category, count)

اختيار Downloads تلقائيًا

في كثير من الأجهزة يمكن بناء المسار هكذا:

SOURCE_FOLDER = (
    Path.home() / "Downloads"
)

print(SOURCE_FOLDER)

لكن تحقق من المسار قبل التنفيذ، لأن مكان مجلد التنزيلات قد يختلف حسب إعداد النظام.

السؤال عن المسار بدل تثبيته داخل الكود

folder_text = input(
    "أدخل مسار المجلد: "
).strip()

SOURCE_FOLDER = Path(folder_text)

if not SOURCE_FOLDER.is_dir():
    print("المسار غير موجود أو ليس مجلدًا.")
    raise SystemExit

التعامل مع PermissionError

قد يفشل النقل إذا لم تكن لديك صلاحية كافية أو كان الملف مستخدمًا من برنامج آخر في بعض الأنظمة.

try:
    shutil.move(
        str(item),
        str(destination_file)
    )
except PermissionError:
    print(
        "تعذر نقل الملف بسبب الصلاحيات "
        "أو لأنه مستخدم:",
        item.name
    )

التعامل مع FileNotFoundError

قد يختفي ملف أو يتغير بين لحظة قراءته ولحظة نقله:

try:
    shutil.move(
        str(item),
        str(destination_file)
    )
except FileNotFoundError:
    print("لم يعد الملف موجودًا:", item.name)
except PermissionError:
    print("تعذر الوصول إلى الملف:", item.name)
أخطاء شائعة عند تنظيم ونقل الملفات باستخدام بايثون

لماذا لا نستخدم except فارغًا؟

الكود التالي يخفي سبب المشكلة:

try:
    ...
except:
    pass

في برامج إدارة الملفات، إخفاء الخطأ خطر لأنه قد يجعلك تظن أن كل الملفات انتقلت بينما بعضها فشل. التقط الأخطاء المتوقعة واطبع اسم الملف الذي فشل.

تجاهل المجلدات الموجودة داخل المصدر

برنامجنا يستخدم:

if not item.is_file():
    continue

وبذلك يتعامل فقط مع الملفات الموجودة في المستوى الحالي ولا يدخل إلى المجلدات الفرعية.

لماذا لا نستخدم rglob هنا؟

يمكن استخدام rglob() للبحث داخل المجلدات الفرعية، لكن ذلك قد يعيد تنظيم مجلدات مرتبة أصلًا. لذلك هدف هذا المشروع محدد: تنظيم الملفات الموجودة مباشرة داخل المجلد الذي تختاره.

تجاهل الملفات المخفية اختياريًا

if item.name.startswith("."):
    continue

اجعل هذا الخيار مناسبًا لاحتياجك؛ لا يوجد سبب لفرضه في كل مشروع.

تجاهل ملف البرنامج نفسه

إذا وضعت organizer.py داخل مجلد المصدر، احمه من النقل أو الأفضل شغّل البرنامج من مجلد منفصل.

SKIP_FILES = {
    "organizer.py"
}

if item.name in SKIP_FILES:
    continue

تنظيم ملفات Excel فقط

إذا كان هدفك محددًا، لا تحتاج إلى كل الفئات:

EXCEL_EXTENSIONS = {
    ".xlsx",
    ".xls",
    ".csv"
}

for item in SOURCE_FOLDER.iterdir():
    if (
        item.is_file()
        and item.suffix.lower()
        in EXCEL_EXTENSIONS
    ):
        print(item.name)

نقل الصور فقط

IMAGE_EXTENSIONS = {
    ".jpg",
    ".jpeg",
    ".png",
    ".webp"
}

وبنفس المنطق تستطيع نقل ملفات PDF أو الفيديو فقط. هذه الأمثلة تساعدك على تعديل المشروع بدل نسخه كما هو.

ماذا عن الامتداد .tar.gz؟

suffix تعيد آخر امتداد، بينما suffixes تعرض كل الأجزاء:

path = Path("backup.tar.gz")

print(path.suffix)
print(path.suffixes)

وبما أن .gz موجودة في Archives في قواعدنا، سيظل التصنيف مناسبًا للمثال.

التنظيم حسب الامتداد ليس فحصًا لمحتوى الملف

برنامجنا يعتمد على اسم الامتداد. إذا أعيدت تسمية ملف بامتداد لا يمثل محتواه الحقيقي، فقد يصنف ضمن الفئة الخطأ. هذه الطريقة مناسبة لتنظيم الملفات اليومية، وليست أداة للتحقق الأمني من نوع الملف.

النقل أم النسخ؟

shutil.move() تنقل الملف من موقعه الأصلي. إذا كنت تريد تجربة أكثر تحفظًا، يمكنك نسخ الملفات بدل نقلها:

shutil.copy2(
    str(item),
    str(destination_file)
)

بعد التأكد من النتيجة يمكنك استخدام النقل في النسخة النهائية.

نسخة كاملة أكثر أمانًا

from pathlib import Path
import shutil


SOURCE_FOLDER = Path("test_downloads")
PREVIEW = True

SKIP_FILES = {
    "organizer.py"
}

CATEGORIES = {
    "Images": {
        ".jpg", ".jpeg", ".png", ".webp", ".gif"
    },
    "PDF": {
        ".pdf"
    },
    "Excel": {
        ".xlsx", ".xls", ".csv"
    },
    "Documents": {
        ".doc", ".docx", ".odt"
    },
    "Text": {
        ".txt", ".md"
    },
    "Videos": {
        ".mp4", ".mkv", ".avi", ".mov"
    },
    "Audio": {
        ".mp3", ".wav", ".m4a", ".flac"
    },
    "Archives": {
        ".zip", ".rar", ".7z", ".tar", ".gz"
    },
    "Python": {
        ".py", ".pyw"
    }
}


def get_category(extension):
    for category, extensions in CATEGORIES.items():
        if extension in extensions:
            return category

    return "Other"


def get_unique_destination(
    destination_folder,
    file_name
):
    destination = destination_folder / file_name

    if not destination.exists():
        return destination

    file_path = Path(file_name)
    counter = 1

    while True:
        new_name = (
            f"{file_path.stem}_{counter}"
            f"{file_path.suffix}"
        )

        destination = destination_folder / new_name

        if not destination.exists():
            return destination

        counter += 1


def organize_files(folder, preview=True):
    if not folder.is_dir():
        print("المسار غير موجود أو ليس مجلدًا:")
        print(folder)
        return

    stats = {}
    processed = 0
    items = list(folder.iterdir())

    for item in items:
        if not item.is_file():
            continue

        if item.name in SKIP_FILES:
            continue

        if item.name.startswith("."):
            continue

        extension = item.suffix.lower()
        category = get_category(extension)

        destination_folder = folder / category
        destination_file = get_unique_destination(
            destination_folder,
            item.name
        )

        if preview:
            print(
                f"[PREVIEW] {item.name}"
                f" -> {destination_file}"
            )
        else:
            destination_folder.mkdir(
                parents=True,
                exist_ok=True
            )

            try:
                shutil.move(
                    str(item),
                    str(destination_file)
                )

                print(
                    f"[MOVED] {item.name}"
                    f" -> {category}/"
                )

            except PermissionError:
                print(
                    "[ERROR] لا يمكن نقل:",
                    item.name
                )
                continue

            except FileNotFoundError:
                print(
                    "[ERROR] الملف اختفى:",
                    item.name
                )
                continue

        processed += 1
        stats[category] = stats.get(category, 0) + 1

    print()
    print("الملخص:")

    for category, count in sorted(stats.items()):
        print(f"- {category}: {count}")

    print("إجمالي الملفات:", processed)


organize_files(
    SOURCE_FOLDER,
    preview=PREVIEW
)

لماذا نأخذ list من iterdir قبل النقل؟

استخدمنا:

items = list(folder.iterdir())

لأن محتويات المجلد ستتغير أثناء النقل. أخذ قائمة بالعناصر الموجودة في البداية يجعل التنفيذ أسهل في الفهم والمراجعة.

طريقة تشغيل النسخة الكاملة

ابدأ بـ:

PREVIEW = True

وسيظهر شيء مثل:

[PREVIEW] photo.jpg -> test_downloads/Images/photo.jpg
[PREVIEW] report.pdf -> test_downloads/PDF/report.pdf
[PREVIEW] video.mp4 -> test_downloads/Videos/video.mp4

بعد التأكد من كل المسارات، استخدم:

PREVIEW = False

ماذا نفعل مع الملفات غير المعروفة؟

في النسخة الحالية تذهب إلى Other. إذا كنت تفضل عدم تحريكها، اجعل الدالة تعيد None:

def get_category(extension):
    for category, extensions in CATEGORIES.items():
        if extension in extensions:
            return category

    return None

ثم:

category = get_category(extension)

if category is None:
    print("تم تجاهل:", item.name)
    continue

أخطاء شائعة يجب تجنبها

1. اختيار مجلد المصدر الخطأ

اطبع SOURCE_FOLDER.resolve() وراجعه قبل النقل.

2. عدم استخدام Preview

المعاينة يجب أن تكون الخطوة الأولى خصوصًا بعد تعديل قواعد الامتدادات.

3. عدم معالجة الأسماء المتكررة

لا تفترض أن مجلد الوجهة فارغ. أنشئ اسمًا بديلًا عند وجود الملف.

4. نسيان lower()

قد تفوت ملفات مثل PHOTO.JPG.

5. محاولة نقل Folder كأنه File

استخدم is_file() قبل التصنيف.

6. التقاط كل الأخطاء وإخفاؤها

اعرض الأخطاء المتوقعة حتى تعرف الملفات التي لم تنتقل.

تمرين: أضف فئة Fonts

أضف الامتدادات:

"Fonts": {
    ".ttf",
    ".otf",
    ".woff",
    ".woff2"
}

تمرين: تجاهل الملفات التي تبدأ بـtemp_

if item.name.startswith("temp_"):
    continue

تمرين: عرض حجم الملف قبل نقله

size_bytes = item.stat().st_size

print(item.name, size_bytes)

أفكار لتطوير المشروع

  • واجهة رسومية لاختيار المجلد.
  • زر Preview وزر Organize.
  • حفظ قواعد التصنيف في ملف JSON.
  • إنشاء تقرير CSV بكل ملف ومساره القديم والجديد.
  • ميزة Undo تعتمد على سجل النقل.
  • تنظيم اختياري حسب التاريخ بدل الامتداد.
  • تشغيل البرنامج دوريًا بعد اختبار القواعد جيدًا.

روابط داخلية مفيدة من بايثون العرب

مصادر رسمية للتوسع

الخلاصة

بناء برنامج لتنظيم الملفات حسب الامتداد مشروع عملي لتطبيق مهارات بايثون على مشكلة يومية. استخدمنا pathlib لقراءة الملفات ومعرفة الامتدادات وبناء المسارات، واستخدمنا shutil.move() لنقل الملفات إلى الفئات المناسبة.

النسخة الجيدة من البرنامج لا تنقل الملفات بشكل أعمى. استخدم Preview، وتحقق أن المصدر مجلد صحيح، وتجاهل المجلدات، ووحّد الامتداد باستخدام lower()، وتعامل مع الأسماء المكررة بدل المخاطرة باستبدال الملفات.

بعد اختبار المشروع على مجلد تجريبي، تستطيع تخصيص الفئات حسب احتياجاتك واستخدامه لتنظيم Downloads أو أي مجلد شخصي آخر، ثم تطويره لاحقًا إلى تطبيق بواجهة رسومية أو إضافة سجل وميزة Undo.

{alertSuccess} القاعدة العملية: في برامج إدارة الملفات، اجعل المعاينة هي الخطوة الأولى. اعرض خطة النقل، راجعها، ثم فعّل التنفيذ الحقيقي بعد التأكد أن القواعد والمسار صحيحان.

أسئلة شائعة

كيف أنظم الملفات تلقائيًا باستخدام بايثون؟

استخدم Path.iterdir() لقراءة الملفات، وPath.suffix لمعرفة الامتداد، ثم اربط كل امتداد بمجلد مناسب وانقل الملف باستخدام shutil.move().

كيف أنظم الملفات حسب الامتداد في بايثون؟

استخرج امتداد كل ملف باستخدام item.suffix.lower()، ثم قارنه بمجموعة امتدادات مثل الصور أو PDF أو Excel وحدد مجلد الوجهة.

كيف أنظم مجلد Downloads باستخدام بايثون؟

يمكن استخدام Path.home() / "Downloads" كنقطة بداية إذا كان هذا هو المسار الصحيح على جهازك، ثم تشغيل Preview قبل النقل الحقيقي.

كيف أنقل ملفًا من مجلد إلى مجلد؟

استخدم shutil.move(source, destination)، ويفضل بناء المصدر والوجهة باستخدام pathlib.Path.

كيف أعرف امتداد الملف باستخدام pathlib؟

استخدم Path("photo.jpg").suffix، وستحصل على .jpg.

كيف أنشئ مجلدًا إذا لم يكن موجودًا؟

استخدم folder.mkdir(parents=True, exist_ok=True).

كيف أمنع استبدال ملف بالاسم نفسه؟

تحقق من وجود مسار الوجهة، وإذا كان الاسم مستخدمًا أنشئ اسمًا مثل photo_1.jpg ثم photo_2.jpg.

كيف أعاين التنظيم قبل نقل الملفات؟

استخدم متغيرًا مثل PREVIEW = True واطبع المصدر والوجهة بدل تنفيذ shutil.move(). بعد المراجعة غيّره إلى False.

لماذا يظهر PermissionError أثناء النقل؟

قد تكون الصلاحيات غير كافية أو يكون الملف مستخدمًا من برنامج آخر حسب النظام. أغلق البرنامج الذي يستخدم الملف وتحقق من المسار والصلاحيات.

ماذا أفعل بالملفات التي لا أعرف امتدادها؟

يمكن نقلها إلى Other أو تجاهلها. اختر قاعدة واضحة تناسب استخدامك.

هل يمكن تنظيم الصور فقط؟

نعم. اجعل مجموعة الامتدادات تحتوي فقط على .jpg و.png و.webp وغيرها من امتدادات الصور.

ما الفرق بين shutil.move وshutil.copy2؟

move() تنقل الملف من مكانه، بينما copy2() تنشئ نسخة وتحاول الحفاظ على metadata التي يدعمها النظام.

هل pathlib أفضل من os لهذا المشروع؟

يمكن تنفيذ المشروع بكلتا الطريقتين. استخدمنا pathlib لأنها تجمع بناء المسارات وفحص الملفات والامتدادات بصياغة واضحة.

هل يمكن تشغيل منظم الملفات تلقائيًا كل يوم؟

نعم بعد اختبار السكربت وقواعده جيدًا. يمكن لاحقًا جدولته بأدوات نظام التشغيل، لكن الأفضل إبقاء Preview وإجراءات منع تعارض الملفات في النسخة النهائية.

إرسال تعليق

أحدث أقدم