Скачивание файлов в 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 закрывает файл автоматически, даже если посередине вылетит исключение, — приучись открывать файлы только так.
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), "байт на диске")
stream=True: скачиваем по кускам
Схема «скачал целиком — записал» работает, пока файл лезет в память. Десять мегабайт — пожалуйста. Десять гигабайт дамп-архива — уже нет: requests.get без параметров сначала утащит всё тело в оперативку, и только потом ты запишешь его на диск. Память при этом уйдёт вся: браузер упадёт у пользователя, скрипт улетит по OOM на сервере.
Лекарство — stream=True. С этим флагом requests получает только заголовки ответа, а тело не забирает: оно лежит на сервере и отдаётся по запросу, кусками заданного размера. Итерация по r.iter_content(chunk_size=8192) выдаёт байтовые куски — их пишут в файл один за другим. Сымитируем это прямо здесь, на кусках, которые «приехали из сети»:
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 байт подряд, порядок сохраняется. Вот этот цикл и есть весь секрет скачивания больших файлов.
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)
Пара практических подробностей про докачку. timeout в requests ограничивает не общую длительность скачивания, а паузу между байтами: пока сервер шлёт куски, таймер обновляется, и долгая закачка не оборвётся. А вот обрыв связи посередине случается, и наивный цикл умрёт с ConnectionError на полпути. Продвинутый приём — докачка с заголовком Range: bytes=N-: сервер, если он умеет, отдаст тело с N-го байта, и ты дозапишешь хвост в файл, открытый в режиме "ab" (append binary). Для учебного скрипта проще ловить исключение и начинать заново; для серьёзного загрузчика — писать счётчик скачанных байт и просить у сервера продолжение.
Какое имя дать файлу: расширение из Content-Type
URL не всегда содержит имя: https://example.com/d/9f3a2c не подсказывает, картинка это или архив. Зато сервер почти всегда сообщает тип в заголовке Content-Type: image/png. Оттуда и берём расширение: словарь соответствий плюс небольшой разбор — в заголовке бывают параметры через точку с запятой, их надо отсечь:
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 достаточно.
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)))
1. В каком режиме открывают файл, чтобы записать туда r.content?
2. Что делает stream=True в requests.get?
3. Сервер прислал Content-Type: image/png. Как получить расширение файла?
4. Сервер обещает гигабайтный файл. Как качать, чтобы не забить память?
5. Зачем проверять первые байты скачанного файла?
Напиши функцию save(resp, filename), которая проверяет resp.ok, записывает resp.content в файл в бинарном режиме и возвращает размер файла на диске. Для неуспешного ответа функция должна вернуть -1 и ничего не писать.
Как скачать файл по 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
requests · Урок 4
Ответ requests: text, content и json — три лица ответа
У ответа requests три лица: .text — строка, .content — байты, .json() — готовый объект Python. Разбираемся, когда какое брать и откуда берутся кракозябры.
BeautifulSoup / Scrapy · Урок 7
Сохранение результатов: CSV, JSON и базы данных
Список словарей собран — теперь доведём его до файла: CSV для таблиц, JSON для вложенных структур, sqlite3 для больших объёмов. Плюс две классические ловушки: пустые строки и кракозябры в Excel.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
json · Урок 4
Файлы JSON: json.dump и json.load
Данные, которые переживают скрипт: json.dump пишет словарь в файл, json.load читает обратно, а round-trip подтверждает — сохранил, прочитал, совпало.
json файл python сохранитьсохранить словарь в файл python
aiogram · Урок 5
Фото и файлы в aiogram: отправка и приём медиа
Отправляем ботом фото с диска и из памяти, разбираемся с file_id, принимаем файлы от пользователя и не упираемся в лимиты Telegram.
aiogram файлaiogram скачать файл от пользователя
requests · Урок 1
Библиотека requests Python с нуля: первый GET-запрос
Первый GET-запрос на requests: что улетает по сети, что возвращается и как разобрать ответ на статус, заголовки и тело — механику проверяем прямо в браузере.
requests pythonбиблиотека requests