Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 5 из 10 Средний 40 мин 120 XP

Скачивание файлов в requests: картинка, архив и запись на диск

Скачивание в requests — это два шага: получить байты из .content и записать их в файл в бинарном режиме. Для больших файлов включаем stream=True.

Редакция Питоники

Прошлый урок разделил ответ на три лица, и для скачивания важно только одно из них: .content — голые байты. Картинка, zip-архив, pdf-отчёт — это всё байты, и Python пишет на диск тоже байты. Строки здесь ни при чём, а значит, и вся возня с кодировками мимо: качаем .content, пишем в бинарном режиме, готово.

В песочнице сети нет, поэтому настоящий requests.get здесь не выполнится. Но записывать байты в файл и читать их обратно — чистый Python без сети: всё в этом уроке исполняется на странице по-настоящему. Для записи используем уникальные имена и убираем файлы за собой — песочница общая. Настоящий код requests, как всегда, рядом — для твоего компьютера.

Скачивание — это всегда два шага

Какой бы файл ты ни качал, схема не меняется. Скачивание в requests — это всегда два шага: получить байты ответа и записать их в файл в бинарном режиме. Шаг первый: получить ответ и вытащить .content. Шаг второй: открыть файл в режиме "wb" (write binary) и записать байты. Прогоним оба шага на тренажёре из урока 4 — за отсутствием сети «сервер» отдаёт нам готовые байты:

Получить байты - записать на диск
import os

class FakeResponse:
    def __init__(self, status_code, text="", content=b"", headers=None):
        self.status_code = status_code
        self.text = text
        self.content = content
        self.headers = headers or {}
    def json(self):
        import json
        return json.loads(self.text)
    @property
    def ok(self):
        return self.status_code < 400

# "Скачали" маленький файл: настоящие PNG начинаются с этой сигнатуры
png = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + b"\x00" * 37

r = FakeResponse(200, content=png, headers={"Content-Type": "image/png"})
print(r.ok, len(r.content), "байт")

with open("test-download.png", "wb") as f:   # wb - писать байтами
    f.write(r.content)

print("На диске:", os.path.getsize("test-download.png"), "байт")
os.remove("test-download.png")               # убираем за собой
print("Файл удалён:", not os.path.exists("test-download.png"))
Вывод
True 45 байт
На диске: 45 байт
Файл удалён: True

Разберём строчку за строчкой. open("test-download.png", "wb") создаёт файл в текущей папке; "wb" — значит «пишу байты, а не текст». Записали r.content — и os.path.getsize показывает на диске ровно столько же байт, сколько было в ответе: 45 против 45, ничего не потерялось и не дополнилось. with закрывает файл автоматически, даже если посередине вылетит исключение, — приучись открывать файлы только так.

Настоящий requests: маленький файл целиком
import requests

url = "https://example.com/photo.jpg"

r = requests.get(url, timeout=10)
r.raise_for_status()

with open("photo.jpg", "wb") as f:
    f.write(r.content)

print(len(r.content), "байт на диске")
Сеть в песочнице недоступна - это код для твоего компьютера. Схема идентична блоку выше: только вместо FakeResponse байты приезжают из сети. raise_for_status из урока 3 отсечёт ответ с 404, чтобы не сохранить HTML-страницу об ошибке вместо картинки.

stream=True: скачиваем по кускам

Схема «скачал целиком — записал» работает, пока файл лезет в память. Десять мегабайт — пожалуйста. Десять гигабайт дамп-архива — уже нет: requests.get без параметров сначала утащит всё тело в оперативку, и только потом ты запишешь его на диск. Память при этом уйдёт вся: браузер упадёт у пользователя, скрипт улетит по OOM на сервере.

Лекарство — stream=True. С этим флагом requests получает только заголовки ответа, а тело не забирает: оно лежит на сервере и отдаётся по запросу, кусками заданного размера. Итерация по r.iter_content(chunk_size=8192) выдаёт байтовые куски — их пишут в файл один за другим. Сымитируем это прямо здесь, на кусках, которые «приехали из сети»:

Итерация по кускам - как iter_content
import os

# Сервер отдал тело кусками - ровно так работает stream=True
chunks = [b"first-chunk-", b"second-chunk-", b"third"]

total = 0
with open("test-download.bin", "wb") as f:
    for chunk in chunks:    # iter_content(chunk_size=...) даёт такие же куски
        print("Кусок:", len(chunk), "байт")
        f.write(chunk)
        total += len(chunk)

print("Собрано:", total, "байт")
print("На диске:", os.path.getsize("test-download.bin"), "байт")
os.remove("test-download.bin")
Вывод
Кусок: 12 байт
Кусок: 13 байт
Кусок: 5 байт
Собрано: 30 байт
На диске: 30 байт

В каждый момент времени в памяти живёт один кусок — в примере от 5 до 13 байт, в реальности 8192, но принцип тот же. На диске — все 30 байт подряд, порядок сохраняется. Вот этот цикл и есть весь секрет скачивания больших файлов.

Настоящий requests: большой файл по кускам
import requests

url = "https://example.com/big-dump.zip"

with requests.get(url, stream=True, timeout=30) as r:
    r.raise_for_status()
    with open("big-dump.zip", "wb") as f:
        for chunk in r.iter_content(chunk_size=8192):
            f.write(chunk)
Снова код для локального запуска: сети в песочнице нет. Обрати внимание на двойной with: requests.get с stream=True нужно закрыть (r.close), иначе соединение повиснет - закрывать через with проще всего. chunk_size=8192 - восемь килобайт на кусок, проверенная величина.

Пара практических подробностей про докачку. timeout в requests ограничивает не общую длительность скачивания, а паузу между байтами: пока сервер шлёт куски, таймер обновляется, и долгая закачка не оборвётся. А вот обрыв связи посередине случается, и наивный цикл умрёт с ConnectionError на полпути. Продвинутый приём — докачка с заголовком Range: bytes=N-: сервер, если он умеет, отдаст тело с N-го байта, и ты дозапишешь хвост в файл, открытый в режиме "ab" (append binary). Для учебного скрипта проще ловить исключение и начинать заново; для серьёзного загрузчика — писать счётчик скачанных байт и просить у сервера продолжение.

Какое имя дать файлу: расширение из Content-Type

URL не всегда содержит имя: https://example.com/d/9f3a2c не подсказывает, картинка это или архив. Зато сервер почти всегда сообщает тип в заголовке Content-Type: image/png. Оттуда и берём расширение: словарь соответствий плюс небольшой разбор — в заголовке бывают параметры через точку с запятой, их надо отсечь:

Content-Type -> расширение файла
MIME_EXT = {
    "image/jpeg": ".jpg",
    "image/png": ".png",
    "application/zip": ".zip",
    "application/pdf": ".pdf",
}

headers = {"Content-Type": "image/png"}
ctype = headers["Content-Type"].split(";")[0].strip()

ext = MIME_EXT.get(ctype, ".bin")   # .bin - на случай незнакомого типа
print(ctype, "->", "download" + ext)
Вывод
image/png -> download.png

Разбор честный: split(";")[0] отрезает параметры вроде charset=utf-8, strip() убирает пробелы, а dict.get с запасным .bin не даёт упасть на экзотическом типе. В реальных парсерах такой словарь разрастается до десятков MIME-типов, но механика не меняется никогда.

Проверяем сигнатуру первых байт
def looks_like_png(data):
    return data[:4] == b"\x89PNG"

png = b"\x89PNG" + b"\x00" * 41
fake = b"<html>404</html>"

print(looks_like_png(png))
print(looks_like_png(fake))
Вывод
True
False

Куда сохранять: папки и пути

Реальный скрипт не сваливает файлы в текущую папку, а раскладывает по папкам: downloads, reports, архив за такую-то дату. Две функции из модуля os закрывают вопрос: os.makedirs(folder, exist_ok=True) создаёт папку, не ругаясь, если она уже есть, а os.path.join(folder, name) склеивает путь правильными разделителями — на Windows и Linux они разные, и ручная склейка через / однажды стрельнёт. Прогоним на тренажёре, попутно записав файл с сигнатурой настоящего ZIP:

Папка + путь + запись - полный цикл
import os

folder = "test-downloads"
os.makedirs(folder, exist_ok=True)
path = os.path.join(folder, "report.zip")

with open(path, "wb") as f:
    f.write(b"PK\x03\x04" + b"\x00" * 30)

print("Файл:", os.path.basename(path), "-", os.path.getsize(path), "байт")
os.remove(path)
os.rmdir(folder)
print("Папка пуста и удалена:", not os.path.exists(folder))
Вывод
Файл: report.zip - 34 байт
Папка пуста и удалена: True

os.path.getsize возвращает размер файла в байтах — та же валюта, в которой измеряется len(r.content), поэтому проверка «докачалось ли всё» записывается в одну строку: размер файла совпал с длиной байтов ответа. А если путей и папок в проекте станет много, посмотри на модуль pathlib: его объект Path умеет то же самое с более аккуратным синтаксисом — path.write_bytes(r.content) записывает байты одним вызовом. Для этого курса хватит и open — он работает везде и его узнают все.

Не проще ли всё грузить в память?

Пока файл маленький — проще: меньше кода, меньше мест, где можно ошибиться. Вопрос стоит включать stream=True или нет. Практическое правило: знаешь, что файл крупный или не знаешь размер заранее, — качай по кускам. Размер сервер сообщает заранее, в заголовке Content-Length, так что решение можно принять ещё до скачивания первого байта:

Проверяем размер до записи
MAX_BYTES = 1_000_000   # не качаем больше мегабайта

headers = {"Content-Length": "2457600"}

size = int(headers.get("Content-Length", 0))
print("Размер:", size, "байт")
if size > MAX_BYTES:
    print("Слишком большой, пропускаем")
else:
    print("Качаем")
Вывод
Размер: 2457600 байт
Слишком большой, пропускаем

Тот же приём защищает от сюрпризов: ссылка «на аватарку» внезапно отдала двухгигабайтный архив — твой скрипт вежливо откажется, а не съест память. Ограничение сверху — дешёвая привычка, которая однажды спасёт продакшн. Да, заголовок сервер тоже может соврать — поэтому в паранойе полагаются на счётчик записанных байт внутри цикла по кускам, но для стартового кода Content-Length достаточно.

Файл в памяти: BytesIO
from io import BytesIO

content = b"\x00\x01\x02" * 100

buf = BytesIO(content)          # "файл" в памяти, диск не трогаем
print(buf.getbuffer().nbytes)
print(buf.read(3))
Вывод
300
b'\x00\x01\x02'

BytesIO принимает байты и отдаёт файлоподобный объект: .read(), .seek(), размер через .getbuffer().nbytes. Любая функция, которая умеет читать из файла, съест и его. Для текста есть брат-близнец StringIO — туда кладут строки вместо байтов.

И ещё одна деталь, о которой узнают постфактум: requests.get по умолчанию ходит за перенаправлениями. Ссылка «на картинку» может ответить 302 и увести на другой домен — ты получишь файл с конечного адреса и даже не заметите. Обычно это удобно, но когда конечный URL важен (логи, имена файлов), посмотри на r.url — там лежит адрес последнего ответа, а r.history хранит всю цепочку переходов. Это та же механика, о которой говорилось в уроке про статусы: 3xx — не ошибка, а приглашение пройти дальше.

Что дальше

Ты умеешь качать и маленькие файлы целиком, и большие — по кускам, умеешь придумывать файлу расширение и защищать память лимитом. Дальше по курсу запросы пойдут в обратную сторону: в следующем уроке ты отправишь первую форму через POST, отправишь JSON в API и загрузишь файл на сервер. А когда скачанных страниц станет много, пригодится цикл по страницам — пагинация, и сохранение добычи в CSV и sqlite — урок про результаты парсинга.

Скачивание — это не магия библиотеки, а два шага: байты из ответа и бинарная запись на диск.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import os

with open("probe.txt", "wb") as f:
    f.write(b"12345")
    f.write(b"67")

print(os.path.getsize("probe.txt"))
os.remove("probe.txt")
ctype = "application/pdf; name=report.pdf"
ext_map = {"application/pdf": ".pdf", "image/png": ".png"}

ctype = ctype.split(";")[0].strip()
print(ext_map.get(ctype, ".bin"))
from io import BytesIO

buf = BytesIO(b"0123456789")
buf.read(4)
print(len(buf.read(3)))
Проверь себя
0 / 5

1. В каком режиме открывают файл, чтобы записать туда r.content?

2. Что делает stream=True в requests.get?

3. Сервер прислал Content-Type: image/png. Как получить расширение файла?

4. Сервер обещает гигабайтный файл. Как качать, чтобы не забить память?

5. Зачем проверять первые байты скачанного файла?

Карточки терминов
Запомнено: 0 / 6
Практика

Напиши функцию save(resp, filename), которая проверяет resp.ok, записывает resp.content в файл в бинарном режиме и возвращает размер файла на диске. Для неуспешного ответа функция должна вернуть -1 и ничего не писать.

practice.py
Вопросы и ответы по уроку

Как скачать файл по URL в Python?

Самый частый способ — requests: r = requests.get(url, timeout=10), затем with open("file", "wb") as f: f.write(r.content). Бинарный режим обязателен, потому что r.content — это bytes. Для больших файлов добавь stream=True и цикл по r.iter_content(chunk_size=8192).

Зачем нужен stream=True, если работает обычный requests.get?

Обычный get кладёт всё тело ответа в оперативку целиком. Для маленьких картинок это незаметно, а гигабайтный архив уронит скрипт. stream=True оставляет тело на сервере и отдаёт его кусками через iter_content — в памяти в любой момент один кусок на 8 КБ.

Как узнать расширение скачанного файла?

Из заголовка ответа Content-Type: отрежь параметры через split(";")[0].strip() и переведи тип в расширение словарём — image/png даёт .png, application/zip даёт .zip. Для надёжности проверь первые байты: у PNG сигнатура \x89PNG, у ZIP — PK. Заголовок врёт чаще, чем байты.

Сохранённая картинка из requests не открывается. Что сломалось?

Чаще всего файл записали в текстовом режиме или сохранили r.text вместо r.content: строка прошла через декодирование, и байты необратимо испортились. Перепиши схему честно: open(name, "wb") и f.write(r.content) — и проверь, что размер файла совпадает с len(r.content).

Понравился урок? Сошлитесь на него

«Скачивание в requests — это всегда два шага: получить байты ответа и записать их в файл в бинарном режиме.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.