Изменения и редакционная очередь
Оглавление серии «Агрегатор лент» · Урок 7 из 10.
Worker уже повторяет сбор и обновляет записи «Сигнала». Теперь отделим полученный материал от материала, выбранного редактором. Новая запись должна появиться в очереди, а изменение ранее выбранной записи должно потребовать нового просмотра. Для этого одобрение будет связано не только с устойчивым ID, но и с fingerprint текущих нормализованных метаданных.
У источника может сохраниться GUID, но измениться заголовок, адрес или дата. Устойчивый ID говорит, что это та же запись в модели ленты; fingerprint говорит, какое её представление сейчас сохранено. Эти значения отвечают на разные вопросы. Одобрить ID навсегда означало бы автоматически согласиться со всеми будущими изменениями, которых редактор ещё не видел.
Идентичность записи и версия
В третьем уроке ID строился из source ID и удалённого идентификатора, с допустимой ссылкой в качестве запасного значения. В пятом уроке fingerprint вычислялся по нормализованным полям title, url, published и updated. Тело статьи не загружается и не участвует в fingerprint. Поэтому совпадение метаданных не является доказательством неизменности страницы по внешней ссылке; это версия именно нашей карточки подборки.
Представим учебную запись release-1. Первый сбор создаёт ID A и fingerprint F1, а reviewed_hash остаётся пустым. Редактор читает карточку и выбирает F1. Затем источник меняет заголовок, сохраняя GUID. Следующий сбор получает тот же A, но fingerprint F2. Старая версия остаётся в истории, текущая запись обновляется, а одобрение сбрасывается, потому что оно относилось к другому представлению.
Неизменившийся повтор ведёт себя иначе. Если fingerprint совпадает, reviewed_hash сохраняется. Таким образом, регулярный сбор не заставляет редактора заново выбирать каждую запись только из-за времени обращения к ленте. Решение зависит от значимого изменения нормализованных полей, а не от last_seen. Эта граница удерживает очередь небольшой и объясняет, почему отдельно нужны время сбора и версия содержимого карточки.
В store.py это уже выражено условием сохранения одобрения. Следующий листинг — фрагмент SQL существующего UPSERT, а не новая самостоятельная схема:
reviewed_hash = CASE
WHEN entries.fingerprint = excluded.fingerprint
THEN entries.reviewed_hash
ELSE NULL
END
Обратите внимание на порядок смысла: сначала сравнивается прежний fingerprint с новым, а не уже заменённое значение с самим собой. Если бездумно выполнить отдельный UPDATE fingerprint, а затем проверить равенство новой версии, одобрение может ошибочно сохраниться. Единый UPSERT позволяет выразить проверку на границе изменения записи и оставить её в той же транзакции сохранения источника.
Очередь редактора
Файл review.py выводит записи с пустым reviewed_hash, упорядоченные по first_seen и ID. В список попадают как впервые найденные, так и изменившиеся карточки. first_seen означает первое обнаружение нашим агрегатором, а не время публикации издателя. Источник может не сообщить дату, а изменённая старая запись может иметь ранний first_seen; это ожидаемые особенности порядка, а не потеря обновления.
Полный файл review.py седьмого состояния:
import argparse, re
from store import connect, transaction
KEY = re.compile(r"[0-9a-f]{64}\Z")
def approve(con, entry_id, expected):
if not KEY.fullmatch(entry_id) or not KEY.fullmatch(expected): raise ValueError("Неверный ID или hash")
with transaction(con):
row = con.execute("SELECT fingerprint,url FROM entries WHERE id=?", (entry_id,)).fetchone()
if row is None or row["fingerprint"] != expected: raise ValueError("Запись изменилась: перечитайте очередь")
if not row["url"]: raise ValueError("Нельзя отобрать запись без безопасной ссылки")
con.execute("UPDATE entries SET reviewed_hash=? WHERE id=?", (expected,entry_id))
if __name__ == "__main__":
cli = argparse.ArgumentParser()
cli.add_argument("--approve")
cli.add_argument("--hash")
args = cli.parse_args()
con = connect()
try:
if args.approve:
if not args.hash: cli.error("Укажите --hash из перечитанной очереди")
approve(con, args.approve, args.hash)
else:
for row in con.execute("SELECT id,title,fingerprint FROM entries WHERE reviewed_hash IS NULL ORDER BY first_seen,id"):
print(dict(row))
finally:
con.close()
Вывод содержит ID, заголовок и fingerprint. Редактору нужно просмотреть актуальную карточку и первоисточник перед выбором, а не подтверждать все строки по одному заголовку. Учебная команда управляет решением, но не доказывает права на копирование текста издателя. «Сигнал» хранит метаданные и ссылку; дальнейшее авторское описание создаётся после изучения материала и остаётся отдельной редакционной работой.
CLI получает --approve и --hash. ID и hash проверяются на точный вид: шестьдесят четыре шестнадцатеричных символа. Это ранняя проверка формы аргументов, а не авторизация пользователя. В локальной модели право запуска команды определяется доступом к папке и базе. Для общего многопользовательского сервиса потребовалась бы отдельная система входа и аудита решений; текущая команда её не изображает.
Защита от устаревшего решения
Между чтением очереди и командой approve worker может обновить источник. Поэтому функция снова читает fingerprint внутри BEGIN IMMEDIATE и сравнивает его с ожидаемым значением. Если версия уже другая, выдаётся ошибка «Запись изменилась: перечитайте очередь». Решение для F1 не будет записано поверх F2. Такой приём обычно называют проверкой ожидаемой версии или compare-and-set.
SQLite начинает транзакцию изменения до чтения карточки, чтобы конкурентный writer не мог незаметно заменить её между проверкой и записью решения. Это локальная гарантия выбранной операции, а не распределённая блокировка издательской страницы. Документация SQLite о транзакциях объясняет BEGIN IMMEDIATE и возможность отказа при занятом writer. Timeout подключения влияет на ожидание конкуренции и не отменяет проверку версии.
Если запись исчезла либо hash отличается, функция не устанавливает reviewed_hash. Пользователь должен прочитать новое состояние и принять новое решение. Не нужно автоматически подставлять текущий fingerprint при ошибке: это означало бы согласие с версией, которую человек ещё не оценивал. Удобство одной команды не должно скрывать смысл редакторского одобрения.
Отдельно проверяется наличие допустимой ссылки. Запись без url может быть сохранена для диагностики метаданных, но не отбирается в ссылочную подборку. Формальная корректность ID недостаточна. Функция использует уже нормализованное значение из базы, а не принимает произвольный URL как дополнительный аргумент approve. Таким образом, редакторское действие не расширяет сетевую область сборщика и не обходит policy ссылок.
Что увидят следующие операции
Будущий API и exporter выберут строки, у которых reviewed_hash = fingerprint. Это точное условие актуального одобрения, а не просто проверка непустого флага approved. Если текущая версия изменится, условие перестанет выполняться сразу после транзакции worker. Сама историческая версия остаётся в revisions по правилам хранения, но не становится текущим отобранным материалом автоматически.
В этой модели нет отдельного постоянного статуса «отклонено». Неодобренная карточка остаётся в очереди, а интерфейс CLI не предоставляет скрытия записи навсегда. Это конкретное ограничение локального редакторского процесса. Если редакции нужен отказ с причиной и последующий пересмотр при изменении, вводят отдельную версионированную модель решений, вместо использования NULL сразу для нескольких несовместимых смыслов.
При прекращении присутствия записи в очередном feed «Сигнал» не удаляет её немедленно: это архив обнаруженных метаданных, а не точное зеркало текущего XML. Поэтому исчезновение из ленты само по себе не отзывает уже сделанный выбор. Для продукта, который должен отражать только актуальную ленту, потребуется отдельная политика отсутствия и срок наблюдения. Нельзя вывести удаление по одному ограниченному ответу, где издатель мог просто сократить число элементов.
Ожидаемый результат седьмого состояния — новое и изменённое поступает на просмотр, неизменившийся повтор сохраняет выбор, устаревший approve отклоняется, актуальный approve связывает решение с текущим fingerprint. Команды и база при подготовке не выполнялись, поэтому эти переходы описаны как контракт исходников. Следующий урок покажет выбранные версии на read-only странице, сохраняя то же условие отбора и безопасный текстовый вывод.
Полное состояние шага — папка lesson-07 архива «Сигнал». Код, сеть, база данных и учебный сервер при подготовке не запускались; описанное поведение ожидается из разбора исходников.