API и страница подборки
Оглавление серии «Агрегатор лент» · Урок 8 из 10.
Сборщик сохраняет историю, а редактор подтверждает конкретную версию записи через её отпечаток. Теперь покажем результат в браузере. Создадим локальный сервер с API только для чтения и небольшую страницу «Сигнала». Она получает двадцать отобранных ссылок за раз, выводит заголовки как текст и позволяет перейти к следующей порции. Неподтверждённые изменения в этот список не попадут.
Мы не переносим редакторскую команду подтверждения в браузер. Изменения по-прежнему выполняются локально через review.py, а веб-интерфейс показывает уже принятые решения. Так можно отдельно разобраться в безопасном отображении и пагинации, прежде чем добавлять аккаунты, формы, CSRF-защиту и полномочия нескольких редакторов.
Читаем только подтверждённую версию
API использует условие reviewed_hash=fingerprint. Одного факта, что запись когда-то была просмотрена, недостаточно: заголовок или ссылка могли измениться после подтверждения. Если сборщик записал другую нормализованную версию, карточка исчезает из текущей подборки до нового решения. Внутренняя история при этом сохраняется.
Ниже функция выборки из полного server.py. Это фрагмент; импорты и обработчик HTTP находятся в архиве шага:
def list_page(cursor=""):
con = sqlite3.connect(DB.as_uri() + "?mode=ro", uri=True, isolation_level=None)
con.row_factory = sqlite3.Row
try:
rows = con.execute(
"SELECT id,source,title,url,published,updated FROM entries "
"WHERE reviewed_hash=fingerprint AND id>? ORDER BY id LIMIT 21",
(cursor,),
).fetchall()
items = [dict(row) for row in rows[:20]]
for item in items:
item["url"] = safe_link(item["url"])
return {"items": items, "next": items[-1]["id"] if len(rows) > 20 else None}
finally:
con.close()
Соединение открывает существующую базу в режиме ro. Запрос HTTP не создаёт новую пустую базу, если рабочий файл потерян, и не изменяет решения редактора. Отдельное соединение на запрос упрощает его жизненный цикл: данные выбраны, соединение закрыто, затем сформирован JSON. Долгая загрузка внешнего источника не происходит внутри обработки этой страницы.
Курсор задаёт стабильный порядок
Для списка используем внутренний ID, а не изменяемую дату публикации. Запрос читает записи с ключом больше предыдущего и сортирует их по тому же ключу. Двадцать первый элемент нужен лишь для определения продолжения; клиент получает первые двадцать и ID последнего из них как next.
Это технический порядок, он не означает «самые свежие статьи сверху». Идентификатор построен хешированием, поэтому его лексикографическое положение не связано с временем. Такой выбор удобен для первого объяснения курсора: изменение заголовка не переносит строку между страницами. Хронологический обзор потребовал бы другого составного ключа, например неизменяемого first_seen и ID для разрешения совпадений.
Каждый запрос видит актуальную подборку на момент своей выборки, но все страницы вместе не являются замороженным снимком. Если между переходами редактор подтвердил запись с ID меньше текущего курсора, она появится при новом просмотре с начала. Если запись перестала быть подтверждённой, она исчезнет из будущих запросов. Для строгого экспорта всей подборки позже используем одну выборку и отдельный файл состава.
Обработчик принимает только параметр cursor, один раз, в форме шестидесяти четырёх шестнадцатеричных символов. Значение передаётся через SQL-параметр, а не вставляется в текст запроса. Пользователь не выбирает имя таблицы, колонку сортировки или величину LIMIT. Ограниченная форма API облегчает чтение кода и не даёт случайному запросу превратиться в неограниченный отчёт.
Страница собирает DOM по данным
Добавим полный static/app.js:
const list = document.querySelector('#entries');
const more = document.querySelector('#more');
const status = document.querySelector('#status');
let cursor = '', busy = false, started = false;
async function load() {
if (busy) return;
busy = true; more.disabled = true; status.textContent = 'Читаем подборку…';
try {
const response = await fetch('/api/entries?cursor=' + encodeURIComponent(cursor));
if (!response.ok) throw new Error('Не удалось получить страницу');
const value = await response.json();
if (!Array.isArray(value.items) || (value.next !== null && !/^[0-9a-f]{64}$/.test(value.next))) throw new Error('Неверный ответ');
const fragment = document.createDocumentFragment();
for (const item of value.items) {
const li = document.createElement('li');
const link = document.createElement('a');
link.textContent = item.title;
if (item.url) {
const url = new URL(item.url);
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Неверная ссылка');
link.href = url.href;
link.rel = 'noreferrer noopener';
}
const meta = document.createElement('small');
meta.textContent = item.source + ' · ' + (item.published || 'Дата публикации неизвестна');
li.append(link, meta); fragment.append(li);
}
list.append(fragment); cursor = value.next; started = true;
status.textContent = list.children.length ? 'Загружено ссылок: ' + list.children.length : 'Пока нет отобранных ссылок';
} catch (error) { status.textContent = error.message; }
finally { busy = false; more.disabled = started && cursor === null; }
}
more.addEventListener('click', load);
load();
Заголовок присваивается textContent. Если издатель прислал текст, похожий на HTML, браузер показывает текст, а не создаёт из него разметку. Свойство ссылки устанавливаем отдельно, после повторной проверки HTTPS и отсутствия встроенных учётных данных. Не используем строку вида innerHTML = '<a ...>' + title: в такой конструкции данные и синтаксис документа смешиваются.
В описании textContent объясняется работа с текстом узла. Для нашего проекта важно следствие этого выбора: заголовок не получает права определять теги, обработчики событий или структуру соседних карточек. Ссылка остаётся самостоятельным действием человека. Страница не скачивает материал по каждому найденному URL и не встраивает удалённые изображения.
Сначала создаём DocumentFragment, полностью собираем новую порцию и только затем добавляем её в список. Если во время обработки ответа возникает ошибка, частично сформированные элементы не остаются на странице. Курсор также меняется после добавления порции. Повторное нажатие поэтому снова запрашивает ту же страницу, а не перескакивает через данные, которые человек ещё не увидел.
Флаг busy блокирует параллельные нажатия, а кнопка временно отключается. Это небольшой клиент с последовательной загрузкой; здесь не нужно строить сложную систему отмены конкурирующих запросов. Сообщение с role="status" сообщает о загрузке, ошибке или количестве показанных ссылок. Пустая подборка получает отдельную подпись, потому что отсутствие отбора не является поломкой HTTP.
Локальный сервер имеет узкую поверхность
HTTPServer слушает только 127.0.0.1:4394. Обработчик отдаёт три конкретных статических файла и один маршрут API. Он не наследует выдачу произвольных файлов текущей директории: база, конфигурация и архивы не должны становиться доступными через угадывание имени. Заголовок Host допускает только loopback-имена с выбранным портом.
Ответы получают Cache-Control: no-store, чтобы браузер не показывал отозванную подборку из сохранённого HTTP-кеша. Политика CSP разрешает локальные ресурсы, запрещает объекты и встраивание страницы в чужие фреймы. Эти меры дополняют ограниченный интерфейс, но не создают аккаунты и не защищают от другого процесса на той же машине. Документация Python рассматривает этот сервер как базовый инструмент; наш пример остаётся локальным стендом.
Для самостоятельного прохождения сначала соберите фикстуры рабочим процессом, выведите редакционную очередь и подтвердите выбранную запись. После запуска server.py ожидается карточка с названием, кодом источника и известной либо неизвестной датой. Изменив фикстуру и повторно собрав источник после наступления next_due, читатель должен увидеть исчезновение изменённой версии до нового подтверждения. Следующий урок превратит такой точный отбор в управляемый набор Markdown-заготовок.
Для страницы достаточно обычного списка ссылок и одной кнопки. Ссылки доступны с клавиатуры, а контур фокуса в CSS помогает увидеть выбранный элемент. Текст о количестве не следует прятать в визуальную анимацию: человеку нужно понять, завершилась ли загрузка и появились ли новые карточки. На узком экране тот же список остаётся читаемым благодаря ограниченной ширине и боковым отступам.
В интерфейсе нет бесконечной прокрутки. Человек сам решает загрузить продолжение, а браузер удерживает только просмотренные порции. Для действительно большого каталога потребуется ограничивать число элементов DOM или переходить между отдельными страницами. Наш серверный предел двадцать строк защищает один ответ, но не ограничивает суммарную память вкладки после сотен нажатий. Эти два уровня нельзя считать одной и той же квотой.
Полное состояние шага — папка lesson-08 архива «Сигнал». Код, сеть, база данных и учебный сервер при подготовке не запускались; описанное поведение ожидается из разбора исходников.