Условная загрузка HTTP
Оглавление серии «Агрегатор лент» · Урок 4 из 10.
Предыдущий урок научил «Сигнал» отличать запись от новой версии её метаданных. Теперь добавим загрузку фиксированного HTTP-источника, сохранив прежние локальные RSS и Atom fixtures. Постоянно получать одно и то же тело из сети необязательно: сервер может подтвердить, что представление не изменилось. В этом уроке введём условные заголовки, результат 304 и ограничения загрузчика, а сохранение полученных данных передадим следующему этапу.
Состояние lesson-04 добавляет fetcher.py. По умолчанию он читает только собственные fixtures, а для HTTP требует явного разрешения network. Адрес берётся из SOURCES, а не из параметра произвольного внешнего запроса. Таким образом функция знает, какой именно ресурс настроил владелец проекта. Она не становится универсальным сервисом, который загружает любой URL, переданный посетителем.
Представление и валидатор
ETag — метка представления, которую выдаёт сервер. Мы храним её как непрозрачную строку и возвращаем в If-None-Match. Не нужно самостоятельно превращать её в число версии или сравнивать с fingerprint отдельной записи. ETag относится к HTTP-представлению всей ленты, тогда как fingerprint описывает выбранные нормализованные метаданные конкретного материала. Изменение этих объектов может происходить независимо.
Другой поддерживаемый заголовок — Last-Modified, который используется в If-Modified-Since. Загрузчик передаёт сохранённое значение и не подставляет текущее время вместо неизвестного серверного значения. При наличии обеих меток в запрос идут оба условия; сервер применяет правила HTTP. Нам не требуется писать собственную процедуру угадывания, какой выпуск ленты появился по календарю.
Ниже показан полный fetcher.py четвёртого состояния. Объект FetchResult разделяет статус, необязательное тело и метки ответа. BASE, MAX_BYTES и SOURCES приходят из существующего config.py. Полученный 200 ещё предстоит разобрать нормализатором и преобразовать через identify; загрузчик сам не фиксирует новые валидаторы в базе.
from dataclasses import dataclass
import time
from urllib.error import HTTPError
from urllib.request import Request, build_opener, HTTPRedirectHandler, ProxyHandler
from config import BASE, MAX_BYTES, SOURCES
@dataclass(frozen=True)
class FetchResult:
status: int
body: bytes | None
etag: str | None
modified: str | None
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): return None
def header_value(value):
if value is None: return None
if len(value) > 1024 or "\r" in value or "\n" in value: return None
return value
def fetch(source_id, state=None, network=False):
source = SOURCES[source_id]
if "fixture" in source:
body = (BASE / "fixtures" / source["fixture"]).read_bytes()
if len(body) > MAX_BYTES: raise ValueError("Лента больше 1 MiB")
return FetchResult(200, body, None, None)
if not network: raise ValueError("HTTP требует явного --network")
state = state or {}
headers = {"User-Agent": "SignalLearning/1.0", "Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml", "Accept-Encoding": "identity"}
for field, name in (("etag", "If-None-Match"), ("modified", "If-Modified-Since")):
value = header_value(state.get(field))
if value: headers[name] = value
opener = build_opener(ProxyHandler({}), NoRedirect())
started = time.monotonic()
try:
response = opener.open(Request(source["url"], headers=headers), timeout=10)
except HTTPError as error:
if error.code == 304:
error.close()
return FetchResult(304, None, state.get("etag"), state.get("modified"))
error.close()
raise ValueError(f"HTTP {error.code}") from None
with response:
if response.status != 200: raise ValueError("Ожидался HTTP 200")
if response.headers.get_content_type() not in {"application/rss+xml", "application/atom+xml", "application/xml", "text/xml"}:
raise ValueError("Неожиданный Content-Type")
if response.headers.get("Content-Encoding", "identity") != "identity":
raise ValueError("Сжатый ответ не поддерживается")
chunks, size = [], 0
while True:
block = response.read(32768)
if time.monotonic() - started > 20: raise ValueError("Превышено время чтения")
if not block: break
size += len(block)
if size > MAX_BYTES: raise ValueError("Лента больше 1 MiB")
chunks.append(block)
return FetchResult(200, b"".join(chunks), header_value(response.headers.get("ETag")), header_value(response.headers.get("Last-Modified")))
Что означает 304
Ответ 304 Not Modified не является пустой новой лентой. Он предлагает использовать ранее сохранённое представление, соответствующее условному запросу. В нашем результате тело равно None, поэтому передавать его в обычный разбор RSS не нужно. Перезаписать подборку пустым списком из-за отсутствия тела было бы потерей смысла протокола: отсутствие новых байтов не означает исчезновение прежних материалов.
В urllib такой статус попадает в обработку HTTPError. Код выделяет его как ожидаемый результат условного запроса, закрывает ресурс ответа и возвращает сохранённые метки. Остальные HTTP-ошибки превращаются в отказ загрузки. Значит, исключение транспортной библиотеки не всегда означает одинаковую бизнес-ошибку; приложение интерпретирует известный статус в рамках своего контракта.
В четвёртом состоянии самостоятельная CLI-загрузка ещё не имеет базы прошлых представлений и обычно начинает без валидаторов. Обработка 304 в самой функции подготовлена для следующего урока, который передаст сохранённое состояние. Если локальной копии нет, принять 304 как успешную новую загрузку невозможно. Проверку наличия снимка выполнит слой хранения, поскольку именно он знает, что уже зафиксировано.
Почему метки не сохраняются сразу
Представьте, что сервер вернул 200 и новый ETag, но внутри оказался неподдерживаемый или повреждённый XML. Если записать ETag до разбора, следующий запрос может получить 304, хотя пригодного представления с этой меткой у нас нет. Поэтому загрузчик лишь возвращает кандидатов на новые валидаторы. Они станут сохранённым состоянием только после успешного разбора и записи всего набора.
То же касается отказа базы. Получить корректное тело недостаточно, если его фиксация не состоялась. HTTP-метка и принятые строки должны двигаться вместе. В следующем уроке это выразится одной транзакцией SQLite. На данном этапе полезно увидеть, что функция загрузки не скрывает принятие данных за фактом успешного ответа сети.
Ограничение байтов
Ответ читается блоками, а суммарный размер сравнивается с MAX_BYTES, равным одному MiB. Это ограничение тела, которое действительно поступает загрузчику, а не доверие заголовку Content-Length. Сервер способен не прислать длину или прислать неверное значение. При превышении бюджета чтение прекращается с ошибкой; уже накопленные части не передаются нормализатору как будто это законченная лента.
Дополнительно загрузчик запрашивает Accept-Encoding: identity и отвергает неподдерживаемый сжатый ответ. Такой контракт проще рассуждения о распаковке неизвестного объёма: в учебном проекте нет отдельного бюджета декомпрессии. Не всякая реальная лента будет соответствовать этому выбору. Если понадобится gzip, следует добавить ограниченную распаковку и объяснить пределы, а не просто убрать проверку из кода.
Проверяется также Content-Type. Даже успешный 200 может содержать HTML страницы входа, рекламный документ или JSON, не относящийся к выбранному контракту. Мы принимаем ограниченный набор XML-типов, после чего нормализатор ещё проверит RSS 2.0 или Atom 1.0. Сетевой статус и правильный MIME сами по себе не доказывают пригодность структуры.
Время и направление запроса
Параметр timeout=10 ограничивает блокирующие операции библиотеки, а монотонные часы контролируют прошедшее время чтения. Проверка двадцатисекундного бюджета выполняется после получения очередного блока. Поэтому это не жёсткое обещание завершить любой DNS- или сетевой случай ровно за двадцать секунд. Для строгого общего срока потребовалась бы более управляемая отмена и отдельная политика разрешения имён.
Перенаправления отключены. Если владелец источника поменял адрес, загрузчик сообщит отказ вместо незаметного перехода к неизвестному назначению. Автоматические proxy-настройки окружения также отключены для понятного локального маршрута. Эти решения уменьшают неопределённость учебной загрузки, но не превращаются в универсальную защиту любого сетевого сервиса; источники остаются управляемым фиксированным списком.
Строки сохранённых заголовков ограничены по длине и не допускают переносов. Это предотвращает возврат явно непригодного значения в следующий запрос. Проверка не исправляет чужую метку и не придумывает новый ETag. Если значение отброшено, следующая попытка может стать безусловной; это понятнее, чем скрытая нормализация, меняющая смысл серверного валидатора.
После самостоятельного разрешённого наблюдения ожидаются разные ветви: 200 с полным ограниченным телом, 304 без нового тела при сохранённой метке или отказ по известному ограничению. При авторстве внешняя лента не загружалась и значения ETag не измерялись. Результат урока — явный транспортный контракт, который сохраняет старое представление при 304 и не продвигает метки раньше успешного принятия данных.
Полное состояние шага — папка lesson-04 архива «Сигнал». Код, сеть, база данных и учебный сервер при подготовке не запускались; описанное поведение ожидается из разбора исходников.