قد يبدأ برنامج بايثون بتنزيل ملف حجمه عدة جيجابايت، ثم ينقطع الإنترنت بعد تنزيل جزء كبير منه. إذا استخدمت طريقة تحميل عادية، فقد تضطر إلى إعادة الملف كاملًا من البداية. لكن بروتوكول 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.
لماذا لا نستخدم 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 | يضع حدودًا لانتظار الاتصال والقراءة |
هل 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 المهمة أثناء استكمال التحميل
| الحالة | المعنى | ما الذي سنفعله؟ |
|---|---|---|
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. نستخدمه هنا لمعرفة ما إذا كانت نسخة المورد على الخادم تغيرت أثناء عملية الاستكمال.
تثبيت مكتبات المشروع الكامل
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، أو كان الرابط مؤقتًا وانتهت صلاحيته، أو تغير المورد بطريقة لا نستطيع التحقق منها، فقد يكون إعادة التنزيل من البداية هو الخيار الآمن.
مصادر رسمية وروابط مكملة
- Requests — Body Content Workflow
- MDN — HTTP Range requests
- MDN — If-Range
- RFC 9110 — HTTP Semantics
- بايثون العرب — Retry لطلبات API
- بايثون العرب — التحقق من الملفات باستخدام SHA-256
الخلاصة
تحميل ملف كبير واستكماله بعد الانقطاع مشكلتان مختلفتان. نستخدم 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.



