Перейти к содержанию

Подготовка Markdown для поисковой записи

В предыдущем уроке отделили редакционный исходник от поисковой копии. Теперь подготовим все документы до первого сетевого запроса. Результат — список проверенных JSON-записей с устойчивыми идентификаторами и контрольными суммами. Он понадобится и первоначальному импорту, и последующим обновлениям.

Полный загрузчик находится в corpus.py итогового проекта «Индекс». Работает с небольшой собственной папкой documents, не сканирует весь ProfessorWeb и не изменяет существующие статьи сайта. Пример не запускался. Отказ загрузчика, количество документов и вычисленные хеши следует рассматривать как будущие наблюдения читателя, а не измерения автора.

Ограниченный формат исходника

Наши файлы содержат JSON-объект между двумя строками ---, затем обычный Markdown. Мы сознательно не разбираем все возможности YAML: якоря, произвольные типы, вложенные схемы и многострочные значения не входят в договор. Узкий формат облегчает объяснение механизма и не требует дополнительной библиотеки Python. В настоящей редакционной системе формат нужно согласовать с её существующим парсером.

Для каждого документа необходимы doc_id, заголовок, адрес, раздел, метки, технические обозначения, видимость, номер редакции и дата изменения. Набор полей фиксирован. Неизвестное поле считается ошибкой, а не автоматически включается в индекс. Если редактор добавил внутренний комментарий, загрузчик должен остановиться и потребовать осознанного решения, следует ли передавать комментарий поисковому сервису.

Следующий листинг является фрагментом полной функции documents; переменные path и seen определены в цикле обхода файлов этого модуля:

text = path.read_text(encoding='utf-8')
parts = text.split('---\n', 2)
if len(parts) != 3 or parts[0] != '':
    raise ValueError(f'{path.name}: JSON frontmatter required')
meta = json.loads(parts[1])
if not ID.fullmatch(meta['doc_id']) or meta['doc_id'] in seen:
    raise ValueError(f'{path.name}: invalid or duplicate doc_id')

Разделение выполняется только дважды. Поэтому дальнейшая строка --- в теле статьи не обрывает текст. Условие parts[0] == '' требует, чтобы метаданные начинались с первой строки. Файл с произвольным вступлением перед метаданными не будет молча прочитан иначе. Ошибка содержит имя файла, но не полный текст документа.

В проверке редакции используется точное условие type(value) is int. В Python логические значения являются разновидностью целых, поэтому обычная проверка isinstance(True, int) дала бы положительный результат. Для редакции true не имеет предметного смысла. Подобная деталь кажется мелкой, пока некорректная запись не дошла до сервера и не смешалась с частично принятым пакетом.

Что остаётся от Markdown

Учебный корпус специально простой: заголовки и связные абзацы без сложных HTML-вставок. Загрузчик удаляет начальные символы заголовков, а остальной текст сохраняет. Это не полноценное преобразование Markdown в читаемый текст. Ссылки, списки и код могут содержать технически полезные слова, но синтаксис сложного документа потребует дополнительного разбора.

Например, фраза «Проверка ETag перед повторной загрузкой» полезна поиску целиком. Имена HTTP, ETag, Node.js и C# имеют другую природу: знаки препинания могут быть частью обозначения. Поэтому метаданные содержат identifiers, где редактор явно перечисляет такие значения. Позже они получат точный тип keyword, независимо от токенизации основного текста.

Не следует извлекать технические обозначения случайной регулярной формулой и объявлять результат авторитетным словарём. Строка с точкой может быть доменом, версией, именем метода или концом предложения. Для маленькой библиотеки явный список даёт понятный контроль. Для большого корпуса можно разработать отдельный извлекатель, но его ошибки нужно наблюдать отдельно от ошибок ранжирования.

Полная функция возвращает список, а не генератор, который начинает отправлять записи сразу при чтении первого файла. Благодаря этому сначала выявляем ошибку в любом документе и только затем меняем индекс. В памяти хранится весь небольшой корпус; это ограничение учебной реализации. Для тысяч больших файлов нужны промежуточные данные или поток с предварительной фазой проверки.

Контрольная сумма подготовленного документа

Изменение текста, адреса или видимости должно приводить к новому хешу. Порядок ключей в исходных метаданных не должен создавать ложное изменение. Поэтому перед вычислением SHA-256 формируем JSON с сортировкой ключей и фиксированными разделителями. Следующий фрагмент продолжает функцию после добавления поля body:

doc = {**meta, 'body': body}
fingerprint = json.dumps(
    doc, ensure_ascii=False, sort_keys=True, separators=(',', ':')
)
doc['content_hash'] = hashlib.sha256(fingerprint.encode()).hexdigest()

Хеш не включает самого себя. Иначе возникла бы бессмысленная попытка найти значение, которое совпадает с хешем объекта, уже содержащего это значение. Параметр ensure_ascii=False сохраняет кириллицу в канонической строке, а кодирование по умолчанию даёт UTF-8. Обе стороны сравнения используют один и тот же алгоритм этого загрузчика.

Два запуска на неизменных данных должны подготовить одинаковые контрольные суммы. Но это не обещание универсальной канонизации всех возможных JSON. Наши значения имеют ограниченные типы; вещественных чисел, пользовательских объектов и нестандартных дат в договоре нет. При расширении формата необходимо заново решить, какие различия являются содержательными.

Ожидаемое состояние после чтения корпуса можно описать так:

documents() → шесть подготовленных документов
для каждого doc_id существует только одна запись
для каждой записи заполнены body и content_hash
ошибка любого файла → сетевой импорт ещё не начался

Количество относится к приложенным учебным файлам. Добавление собственного седьмого файла закономерно меняет результат. Значения хешей в статье не приводим: их важное свойство — повторяемость при одинаковой подготовленной записи, а не конкретная последовательность символов.

Ошибки до удаления данных

Пустая папка вызывает отдельный отказ. Позже импортёр станет удалять из индекса идентификаторы, отсутствующие в текущем корпусе. Если папка оказалась пустой из-за ошибочного пути или временно недоступного диска, автоматическое толкование «редактор удалил всё» привело бы к неожиданному очищению поиска. В учебном проекте полное удаление требует отдельного осознанного действия.

Частичный отказ чтения также не означает удаление. Нельзя пропускать повреждённый файл через except: continue, а затем сравнивать сокращённый список с прошлым манифестом. Отсутствие в результате тогда будет следствием ошибки парсера, а не редакционного решения. Поэтому проверки и чтение завершаются полностью до вычисления списка удаляемых записей.

Адрес разрешён только в форме локальной статьи /articles/...html. Такая проверка не создаёт саму страницу, но не позволяет отправить в карточку произвольную внешнюю ссылку или опасную схему URL. В архиве есть самостоятельный учебный корпус; переход к настоящим страницам сайта предполагает отдельное согласование их адресов. Поисковый результат и опубликованный материал связаны договором, а не наличием одной строки.

Для самостоятельного разбора добавьте второму файлу идентификатор article-107. Ожидаемый эффект — отказ подготовки всего корпуса. Затем верните уникальный идентификатор и поменяйте порядок ключей JSON: содержательный хеш останется тем же. Теперь можно создать явную схему индекса, чтобы сервер трактовал наши поля предсказуемо.

Оглавление серии