مقالات مختارة

استكمال تحميل الملفات الكبيرة باستخدام بايثون وrequests | بايثون بعد الأساسيات 16

استكمال تحميل الملفات الكبيرة باستخدام بايثون وrequests ومتابعة التنزيل من آخر بايت

قد يبدأ برنامج بايثون بتنزيل ملف حجمه عدة جيجابايت، ثم ينقطع الإنترنت بعد تنزيل جزء كبير منه. إذا استخدمت طريقة تحميل عادية، فقد تضطر إلى إعادة الملف كاملًا من البداية. لكن بروتوكول HTTP يوفر آلية تسمح بطلب الجزء المتبقي فقط، ويمكننا استخدامها مع مكتبة requests لبناء برنامج يدعم استكمال تحميل الملفات بعد الانقطاع.

في الدرس 16 من سلسلة بايثون بعد الأساسيات سنبدأ بتحميل الملفات الكبيرة دون وضعها كاملة في الذاكرة، ثم نستخدم Range و206 Partial Content للاستكمال من آخر بايت تم حفظه. وبعد ذلك سنعالج الحالات التي قد تسبب تلف الملف مثل إعادة الخادم للحالة 200 OK بدل 206، والخطأ 416 Range Not Satisfiable، وتغير الملف على الخادم أثناء توقف التنزيل.

الخلاصة السريعة: stream=True يحل مشكلة استهلاك الذاكرة، لكنه لا يستكمل التنزيل وحده. للاستكمال نحتاج إلى معرفة حجم الملف الجزئي ثم إرسال Range: bytes=N- والتحقق من أن الخادم أعاد 206 Partial Content.

تحميل مشروع استكمال الملفات الكبيرة

Downloader كامل باستخدام requests وtqdm مع ملفات .part، ودعم Range و206 و416 وETag وIf-Range.

تحميل مشروع بايثون ZIP

لماذا لا نستخدم response.content مع الملفات الكبيرة؟

يمكن تنزيل ملف صغير بهذه الطريقة:

import requests

response = requests.get(
    "https://example.com/file.zip",
    timeout=60,
)

response.raise_for_status()

with open("file.zip", "wb") as file:
    file.write(response.content)

المشكلة أن الوصول إلى response.content يعني قراءة جسم الاستجابة كاملًا قبل كتابته إلى الملف. إذا كان الملف صغيرًا فهذا غالبًا لا يمثل مشكلة، لكن مع ملف حجمه مئات الميجابايت أو عدة جيجابايت نريد معالجة البيانات تدريجيًا.

الحل هو استخدام stream=True ثم قراءة البيانات على أجزاء باستخدام response.iter_content().

تحميل ملف كبير باستخدام stream=True

ثبت مكتبة requests أولًا:

python -m pip install requests
import requests

url = "https://example.com/large-file.zip"

with requests.get(
    url,
    stream=True,
    timeout=(10, 60),
) as response:
    response.raise_for_status()

    with open("large-file.zip", "wb") as file:
        for chunk in response.iter_content(
            chunk_size=1024 * 1024
        ):
            if chunk:
                file.write(chunk)

print("اكتمل التنزيل")
الخيار وظيفته
stream=Trueيمنع قراءة جسم الاستجابة كاملًا مباشرة
iter_content()يقرأ البيانات على أجزاء
chunk_sizeيحدد حجم الجزء المقروء في كل مرة
timeoutيضع حدودًا لانتظار الاتصال والقراءة
تحميل الملفات الكبيرة في بايثون باستخدام requests مع stream=True وiter_content دون تحميل الملف كاملًا في الذاكرة

هل stream=True يعني أن التحميل قابل للاستكمال؟

لا. يوجد فرق بين Streaming وResume Download.

الميزةالهدف
Streamingعدم تحميل الملف كله إلى RAM
Resumeمتابعة التحميل من آخر جزء محفوظ بعد الانقطاع

كيف يعمل HTTP Range؟

إذا كان الملف الجزئي الموجود على جهازنا حجمه 52428800 بايت، يمكن إرسال:

Range: bytes=52428800-

إذا دعم الخادم النطاق وأمكن تنفيذه، نتوقع عادة:

HTTP/1.1 206 Partial Content

وقد نجد:

Content-Range: bytes 52428800-104857599/104857600
استكمال تنزيل ملف في بايثون باستخدام HTTP Range واستجابة 206 Partial Content للمتابعة من آخر بايت

حالات HTTP المهمة أثناء استكمال التحميل

الحالةالمعنىما الذي سنفعله؟
206الخادم أعاد الجزء المطلوبنستكمل الكتابة باستخدام ab
200الخادم أعاد الملف كاملًالا نضيفه للملف الجزئي؛ نبدأ من الصفر
416النطاق المطلوب غير صالحنفحص حجم المورد وحالة الملف الجزئي

لا تستخدم وضع ab لمجرد أنك أرسلت رأس Range. يجب أولًا التأكد من أن الخادم أعاد 206 Partial Content وأن Content-Range يبدأ من الموضع الذي تتوقعه.

لماذا قد يؤدي 200 OK إلى تلف الملف؟

إذا كان لدينا جزء قديم من الملف ثم أرسل الخادم الملف كاملًا مع 200 OK، فإن إضافته إلى الجزء الموجود باستخدام ab ستنتج ملفًا تالفًا وأكبر من الأصل.

نستخدم ab فقط بعد استلام 206 والتحقق من أن بداية Content-Range تطابق حجم الملف الجزئي المحلي.

حفظ التحميل الجزئي داخل ملف .part

من الأفضل ألا نكتب مباشرة إلى الاسم النهائي. فإذا كان الاسم النهائي ubuntu.iso، نستخدم أثناء التنزيل:

ubuntu.iso.part

وبعد اكتمال التنزيل فقط نعيد تسميته إلى ubuntu.iso.

كيف نعرف من أين نستكمل التنزيل؟

from pathlib import Path

part_file = Path("large-file.zip.part")

if part_file.exists():
    downloaded = part_file.stat().st_size
else:
    downloaded = 0

print(downloaded)

ثم نرسل:

headers = {
    "Range": f"bytes={downloaded}-",
}

لماذا نحتاج ETag وIf-Range؟

إذا تغير الملف على الخادم أثناء توقف التنزيل، فقد نحصل على نصف من نسخة قديمة ونصف من نسخة جديدة. لذلك نحفظ قيمة ETag أو Last-Modified عندما تتوفر، ثم نرسلها باستخدام If-Range عند محاولة الاستكمال.

ETag ليس بديلًا عن SHA-256. نستخدمه هنا لمعرفة ما إذا كانت نسخة المورد على الخادم تغيرت أثناء عملية الاستكمال.

التعامل مع استجابات HTTP 200 و206 و416 واستخدام ETag وIf-Range عند استكمال تحميل الملفات في بايثون

تثبيت مكتبات المشروع الكامل

python -m pip install requests tqdm

مشروع كامل: Downloader يدعم استكمال تحميل الملفات

أنشئ ملفًا باسم downloader.py وضع فيه الكود التالي:

import json
import re
import sys
from pathlib import Path

import requests
from tqdm import tqdm


CHUNK_SIZE = 1024 * 1024
TIMEOUT = (10, 60)


def strong_etag(value):
    if not value or value.startswith("W/"):
        return None
    return value


def parse_content_range(value):
    match = re.fullmatch(
        r"bytes (\d+)-(\d+)/(\d+|\*)",
        value or "",
    )

    if not match:
        return None

    start, end, total = match.groups()

    return (
        int(start),
        int(end),
        None if total == "*" else int(total),
    )


def parse_416_total(value):
    match = re.fullmatch(
        r"bytes \*/(\d+)",
        value or "",
    )

    return int(match.group(1)) if match else None


def load_metadata(path):
    if not path.exists():
        return {}

    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}


def save_metadata(path, url, response):
    data = {
        "url": url,
        "etag": response.headers.get("ETag"),
        "last_modified": response.headers.get("Last-Modified"),
    }

    path.write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


def open_stream(url, headers):
    return requests.get(
        url,
        headers=headers,
        stream=True,
        timeout=TIMEOUT,
        allow_redirects=True,
    )


def download_file(url, filename):
    destination = Path(filename)
    part_path = destination.with_name(destination.name + ".part")
    meta_path = destination.with_name(destination.name + ".part.json")

    metadata = load_metadata(meta_path)

    if metadata.get("url") != url:
        metadata = {}

    offset = part_path.stat().st_size if part_path.exists() else 0

    headers = {
        "Accept-Encoding": "identity",
    }

    validator = None

    if offset:
        headers["Range"] = f"bytes={offset}-"

        validator = (
            strong_etag(metadata.get("etag"))
            or metadata.get("last_modified")
        )

        if validator:
            headers["If-Range"] = validator

        print(f"تم العثور على تحميل جزئي: {offset:,} بايت")
        print(f"طلب الاستكمال من البايت: {offset:,}")

    response = open_stream(url, headers)

    if offset and response.status_code == 416:
        remote_size = parse_416_total(
            response.headers.get("Content-Range")
        )
        response.close()

        if remote_size == offset and offset > 0 and validator:
            part_path.replace(destination)
            meta_path.unlink(missing_ok=True)

            print("الملف الجزئي يساوي الحجم الكامل وتحققنا من نسخة المورد.")
            print(f"اكتمل الملف: {destination}")
            return

        print("تعذر تأكيد أن الملف الجزئي هو النسخة الحالية.")
        print("سنبدأ من الصفر لتجنب اعتماد ملف غير صحيح.")

        offset = 0
        response = open_stream(
            url,
            {"Accept-Encoding": "identity"},
        )

    mode = "wb"

    if offset and response.status_code == 206:
        content_range = parse_content_range(
            response.headers.get("Content-Range")
        )

        if not content_range or content_range[0] != offset:
            response.close()

            print("Content-Range لا يطابق حجم الملف الجزئي.")
            print("سيبدأ التنزيل من الصفر لتجنب تلف الملف.")

            offset = 0
            response = open_stream(
                url,
                {"Accept-Encoding": "identity"},
            )
        else:
            mode = "ab"
            print("الخادم أعاد 206 Partial Content: سيتم الاستكمال.")

    elif offset and response.status_code == 200:
        print("الخادم أعاد 200 OK بدل 206.")
        print("لن نضيف البيانات إلى الملف الجزئي؛ سنستبدله بتنزيل كامل.")

        offset = 0
        mode = "wb"

    response.raise_for_status()

    save_metadata(meta_path, url, response)

    content_range = parse_content_range(
        response.headers.get("Content-Range")
    )

    if response.status_code == 206 and content_range:
        total_size = content_range[2]
    else:
        content_length = response.headers.get("Content-Length")

        total_size = (
            int(content_length)
            if content_length and content_length.isdigit()
            else None
        )

    downloaded = offset

    try:
        with part_path.open(mode) as file, tqdm(
            total=total_size,
            initial=offset,
            unit="B",
            unit_scale=True,
            unit_divisor=1024,
            desc=destination.name,
        ) as progress:
            for chunk in response.iter_content(
                chunk_size=CHUNK_SIZE
            ):
                if not chunk:
                    continue

                file.write(chunk)
                downloaded += len(chunk)
                progress.update(len(chunk))
    finally:
        response.close()

    if total_size is not None and downloaded != total_size:
        raise RuntimeError(
            f"التنزيل غير مكتمل: {downloaded:,} من "
            f"{total_size:,} بايت. بقي ملف .part للاستكمال."
        )

    part_path.replace(destination)
    meta_path.unlink(missing_ok=True)

    print(f"اكتمل التنزيل: {destination}")


def main():
    if len(sys.argv) != 3:
        print("الاستخدام:")
        print("python downloader.py URL FILE_NAME")
        raise SystemExit(2)

    url = sys.argv[1]
    filename = sys.argv[2]

    try:
        download_file(url, filename)

    except KeyboardInterrupt:
        print(
            "\nتم إيقاف التنزيل. "
            "بقي ملف .part للاستكمال لاحقًا."
        )
        raise SystemExit(130)

    except requests.RequestException as error:
        print(f"\nخطأ في الشبكة: {error}")
        print(
            "سيبقى الملف الجزئي للاستكمال "
            "في المحاولة القادمة."
        )
        raise SystemExit(1)

    except (OSError, RuntimeError) as error:
        print(f"\nخطأ: {error}")
        raise SystemExit(1)


if __name__ == "__main__":
    main()

طريقة تشغيل Downloader

python downloader.py "FILE_URL" large-file.zip

عند إيقاف البرنامج باستخدام Ctrl+C سيبقى ملف .part. وعند تشغيل الأمر نفسه مرة أخرى سيحاول البرنامج الاستكمال من آخر بايت إذا كان الخادم يدعم Range.

كيف يتحقق البرنامج من Content-Range؟

لا يكفي فحص status_code == 206. نقرأ أيضًا Content-Range ونتأكد أن بدايته تطابق حجم الملف الجزئي المحلي قبل استخدام ab.

ماذا يحدث إذا أعاد الخادم 200 بدل 206؟

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

ماذا يعني 416 Range Not Satisfiable؟

يظهر عندما لا يستطيع الخادم تنفيذ النطاق المطلوب. وقد يرسل:

Content-Range: bytes */104857600

يقارن البرنامج هذا الحجم بحجم ملف .part، ولا يعتبر 416 دليلًا مؤكدًا على اكتمال الملف دون تحقق إضافي.

لماذا نستخدم Accept-Encoding: identity؟

يستخدم Range مواضع بايتات المورد الذي يتعامل معه HTTP، لذلك يطلب البرنامج identity لتقليل التعقيد الناتج عن Content-Encoding مثل gzip عندما يسمح الخادم بذلك.

كيف يعمل شريط التقدم باستخدام tqdm؟

نمرر الحجم الكامل إلى total، ونمرر حجم الجزء الموجود سابقًا إلى initial. لهذا يبدأ الشريط من موضع الاستكمال بدل الصفر.

ماذا إذا لم يرسل الخادم Content-Length؟

يمكن أن يستمر التنزيل، لكن شريط التقدم قد لا يستطيع حساب النسبة المئوية بدقة. سيظل بإمكانه عرض كمية البيانات التي تم تنزيلها.

ماذا يحدث عند انقطاع الشبكة؟

يبقى ملف .part على القرص. وعند المحاولة التالية يستخدم البرنامج حجمه لتحديد Range الجديد.

إذا كنت تريد إضافة Retry للأخطاء المؤقتة، راجع:

إعادة محاولة طلبات API الفاشلة في بايثون باستخدام requests وRetry | بايثون بعد الأساسيات 13

كيف أتحقق من سلامة الملف بعد اكتمال التحميل؟

إذا كان المصدر ينشر بصمة SHA-256 للملف، فمن الأفضل حسابها ومقارنتها بالقيمة الرسمية بعد اكتمال التنزيل.

راجع:

حساب SHA-256 لملف والتحقق من سلامته باستخدام بايثون | بايثون بالمثال 26

أخطاء شائعة عند استكمال تحميل الملفات ببايثون

1. استخدام response.content مع ملف ضخم

استخدم stream=True وiter_content().

2. فتح الملف بـ ab دون فحص Status Code

إذا أعاد الخادم 200 فقد تكون الاستجابة ملفًا كاملًا.

3. افتراض أن إرسال Range يضمن 206

افحص الاستجابة الفعلية دائمًا.

4. فحص 206 دون Content-Range

تحقق من أن بداية Content-Range تطابق عدد البايتات الموجودة محليًا.

5. اعتبار 416 دليلًا مؤكدًا أن الملف مكتمل

افحص الحجم والـValidator قبل اتخاذ القرار.

6. تجاهل تغير الملف على الخادم

استخدم ETag أو Last-Modified مع If-Range عندما تتوفر.

7. الكتابة مباشرة إلى الاسم النهائي

استخدم ملف .part ثم أعد تسميته بعد النجاح.

8. عدم وضع Timeout

ضع Timeout مناسبًا حتى لا يبقى اتصال معلقًا بلا حدود.

9. افتراض أن كل خادم يدعم Resume

إذا لم يدعم الخادم Range فقد يكون البدء من الصفر هو الخيار الآمن الوحيد.

Streaming أم Resume أم Retry؟

المفهومالمشكلة التي يحلها
stream=Trueمنع تحميل الملف كله إلى الذاكرة
Rangeطلب الجزء المتبقي من الملف
If-Rangeتجنب دمج أجزاء من نسخ مختلفة
Retryإعادة الطلب عند فشل شبكي مناسب
SHA-256التحقق من محتوى الملف بعد التنزيل

هيكل ملفات المشروع

resume-downloader/
├── downloader.py
├── requirements.txt
└── README.txt

ثبّت المتطلبات:

python -m pip install -r requirements.txt

متى لا يمكن استكمال التحميل؟

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

مصادر رسمية وروابط مكملة

الخلاصة

تحميل ملف كبير واستكماله بعد الانقطاع مشكلتان مختلفتان. نستخدم stream=True وiter_content() لتقليل استهلاك الذاكرة، ثم نستخدم HTTP Range لطلب الجزء المتبقي.

الجزء الأهم هو التحقق من استجابة الخادم: 206 Partial Content يسمح بالاستكمال بعد فحص Content-Range، بينما 200 OK يعني أننا لا يجب أن نضيف البيانات إلى الملف الجزئي. ويجب التعامل مع 416 بحذر، مع الاستفادة من ETag أو Last-Modified وIf-Range لتقليل خطر دمج نسخ مختلفة.

أسئلة شائعة

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

احسب حجم الملف الجزئي، ثم أرسل طلب GET يحتوي Range: bytes=N-. إذا أعاد الخادم 206 Partial Content وتطابق Content-Range مع الموضع المطلوب، يمكنك إضافة البيانات الجديدة إلى الملف.

هل stream=True يستكمل التنزيل تلقائيًا؟

لا. هو يجعل قراءة الاستجابة تدريجية لتقليل استهلاك الذاكرة، لكنه لا يرسل Range تلقائيًا في التشغيل التالي.

ما معنى 206 Partial Content؟

يعني أن الخادم أعاد جزءًا من المورد استجابة لطلب Range.

ما معنى 416 Range Not Satisfiable؟

يعني أن النطاق المطلوب لا يمكن للخادم تلبيته، مثل أن يبدأ بعد نهاية المورد الحالي.

ماذا أفعل إذا أعاد الخادم 200 بعد إرسال Range؟

لا تضف الاستجابة إلى الملف الجزئي. تعامل معها كتنزيل كامل واكتبها من بداية الملف باستخدام wb.

هل كل المواقع تدعم استكمال تحميل الملفات؟

لا. يعتمد Resume على دعم الخادم لطلبات Range وسلوكه مع المورد المطلوب.

كيف أعرف إذا كان الخادم يدعم Range؟

قد يرسل Accept-Ranges: bytes، لكن الاختبار الفعلي واستجابة 206 وContent-Range أهم.

لماذا نستخدم ملف .part؟

حتى يبقى واضحًا أن الملف لم يكتمل بعد، ثم نعيد تسميته إلى الاسم النهائي بعد نجاح التنزيل.

ما فائدة If-Range؟

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

هل ETag يضمن سلامة الملف مثل SHA-256؟

لا. ETag أداة HTTP للتحقق من نسخة المورد، وليس بديلًا عامًا لبصمة تشفير مثل SHA-256.

Admin
Admin
تعليقات