Устойчивые идентификаторы и дубли
Оглавление серии «Агрегатор лент» · Урок 3 из 10.
«Сигнал» уже преобразует RSS и Atom в одинаковые поля: заголовок, ссылку, даты и внешний идентификатор. Теперь нужно понять, какие строки представляют одну запись при повторном чтении. Без устойчивой идентичности каждая загрузка становится новой подборкой дублей, а исправление заголовка выглядит как появление другого материала. В этом уроке введём ключ записи и отдельный отпечаток её текущих метаданных.
Продолжим собственные учебные данные. В demo-rss находятся две записи с одинаковым названием «Заметки о выпуске», но разными значениями release-1 и release-2. Это специально выбранный пример: совпавшее название не означает совпавший материал. В demo-atom идентификатор имеет вид urn:signal:layout-1; он пригоден для определения записи, хотя не является адресом страницы для перехода.
Идентификатор и название
Название предназначено человеку. Редактор может исправить его пунктуацию, добавить номер выпуска или заменить формулировку, сохранив сам материал. Если сделать заголовок ключом, исправление породит новую строку и оставит старую рядом. Обратная ошибка возникает при одинаковых заголовках разных выпусков: система объединит два независимых материала. Поэтому название участвует в описании версии, а не в устойчивой идентичности.
Feedparser предоставляет поле entry.id, которое может происходить из RSS guid или Atom id. В предыдущем нормализаторе оно получило имя remote_id. Это идентификатор, заявленный источником; наш код не обращается по нему в сеть. Даже когда значение похоже на URL, его смысл не обязан совпадать со ссылкой публикации. Поле url остаётся отдельным адресом, разрешённым политикой нормализатора.
Сам идентификатор издателя мы дополняем source_id. Два независимых источника способны использовать одинаковую короткую строку release-1. В «Сигнале» они должны остаться разными записями с понятным происхождением. Система не пытается автоматически определить, что две редакции перепечатали одну статью. Межисточниковое объединение потребовало бы отдельного решения об атрибуции и приоритетах, которого текущий учебный ключ не принимает.
Ниже дан полный файл identity.py третьего состояния. Функция identify получает уже нормализованные строки из parse_entries и подтверждённое имя источника из config.py. Сетевые адреса или произвольные пользовательские имена источников внутри неё не создаются. Основная программа передаёт результат нормализации непосредственно в эту функцию.
import hashlib, json
def digest(value):
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def identify(source_id, rows):
found = {}
for row in rows:
remote_key = row["remote_id"] or row["url"]
if not remote_key: continue
key = digest([source_id, remote_key])
payload = {k: row[k] for k in ("title", "url", "published", "updated")}
current = {**row, "id": key, "source": source_id, "fingerprint": digest(payload)}
if key in found and found[key]["fingerprint"] != current["fingerprint"]:
raise ValueError("Противоречивые записи с одним ID в одной ленте")
found[key] = current
return list(found.values())
Ключ из двух частей
Функция digest сериализует пару значений в JSON и вычисляет SHA-256. JSON-массив сохраняет границы частей: пары строк не смешиваются из-за случайно выбранного разделителя. При одинаковых исходных значениях сериализация получается одинаковой, а итоговый ключ удобен для хранения в базе и будущего имени экспортируемого файла. Криптографический hash здесь помогает адресовать данные, но не подтверждает достоверность издателя.
Не считайте ключ подписью публикации. Сервер источника может выдать другой текст под прежним ID, а чужой клиент — сформировать такое же значение входной строки. Наша модель лишь определяет, что приложение считает одной записью внутри выбранного источника. Для доверия к материалу важны происхождение, редакционное рассмотрение и политика источников. Эти вопросы не решаются длиной шестнадцатеричной строки.
Если remote_id пуст, код использует нормализованную безопасную ссылку. Это запасной путь, а не равнозначная гарантия устойчивого ID. Издатель может изменить URL, и тогда запись получит другой ключ. Позднее источник также способен добавить ID прежде безымянному материалу; автоматическое сопоставление старого URL-ключа с новым здесь не реализовано. Такая миграция требует явной политики, чтобы случайно не объединить разные записи.
Если отсутствуют и ID, и пригодная ссылка, строка пропускается. Генерировать случайный ключ нельзя: после каждого чтения он создавал бы новую запись. Использовать дату также ненадёжно: дата может отсутствовать, исправляться или совпадать у нескольких материалов. Текущая политика предпочитает отказаться от неадресуемой строки, сохраняя понятную границу. Для реального источника полезно отдельно сообщать число таких пропусков.
Отпечаток версии
Ключ отвечает на вопрос «какая запись», а fingerprint — «какие метаданные у неё сейчас». В отпечаток входят нормализованные title, url, published и updated. Когда заголовок исправлен при прежнем ID, ключ остаётся прежним, а отпечаток меняется. Так будущая база сможет обновить текущую строку и сохранить отдельное представление изменения, не объявляя исправление новым материалом.
Отсутствующая дата остаётся отсутствующей. Мы не подставляем время загрузки вместо неизвестной даты публикации: это исказило бы и сведения для читателя, и отпечаток. Время фактического обнаружения понадобится базе отдельно. Также fingerprint описывает только выбранные поля подборки. Изменение полного текста статьи, которое не отражено в этих полях, этот учебный механизм не обнаружит и не выдаст за проверенное изменение содержания.
Сериализация словаря сортирует имена полей и задаёт постоянные разделители. Это защищает вычисление от случайного порядка ключей и форматирования JSON. При изменении правил нормализации значения могут измениться даже без редакторской правки издателя. Поэтому версия нормализатора является частью сопровождения: нельзя незаметно поменять правила и считать каждое новое значение fingerprint свидетельством внешнего обновления.
Дубли одного ответа
Словарь found собирает записи по вычисленному ключу. Полностью одинаковые представления одного ID сводятся к одной строке. Но если один ответ содержит разные отпечатки для одного ключа, функция отказывается принимать весь набор. Выбирать «первую» или «последнюю» строку было бы произвольным разрешением противоречия, зависящим от порядка XML, а не от понятной политики источника.
После самостоятельного разбора третьего состояния обе записи «Заметки о выпуске» должны получить разные ключи. Два одинаковых повторения release-1 должны дать одну результирующую строку. Две разные версии release-1 в одном ответе должны привести к ошибке о противоречии. Это ожидаемые эффекты исходного кода; пример не выполнялся, поэтому конкретные hash-строки не приводятся как полученный вывод.
Политика URL остаётся отдельной: нормализатор разрешает HTTPS, убирает fragment и отвергает некоторые явно непригодные адреса. Он не удаляет все query-параметры, поскольку они могут различать реальные материалы. Также синтаксическая проверка ссылки не доказывает, куда разрешится домен при DNS-запросе. Сборщик никогда не загружает ссылку записи как новый источник, поэтому её нельзя превратить в обход фиксированного списка загрузок.
Результат урока — разделение идентичности, версии и происхождения. Для самостоятельного анализа исправьте только название одного учебного выпуска и объясните, почему меняется отпечаток, но сохраняется ключ. Затем сравните одинаковый внешний ID в двух источниках: происхождение должно оставить две записи. Следующим шагом сохраним ту же модель при ограниченной HTTP-загрузке, где повторное чтение не обязательно возвращает тело ленты.
Полное состояние шага — папка lesson-03 архива «Сигнал». Код, сеть, база данных и учебный сервер при подготовке не запускались; описанное поведение ожидается из разбора исходников.