Чтение Markdown и метаданных источника
В первом уроке мы задали шесть источников и разделили текст документа с его описанием. Теперь превратим запись манифеста в проверенный объект: прочитаем Markdown, сохраним публичный адрес, проверим допустимость пути и вычислим отпечаток текста. Это подготовка данных, от которой зависит весь дальнейший поиск. Если загрузчик перепутал файл или разрешил произвольный адрес, последующие цитаты могут быть точными по символам и всё равно вести человека к неправильному источнику.
Продолжаем тот же учебный корпус. В папке lesson-02 находится самостоятельное состояние проекта: app.py, новый loader.py и все исходные документы. Переносить файлы первого шага не нужно. Рабочим каталогом остаётся папка урока. Функция load_documents возвращает список словарей, где прежние метаданные дополнены text и hash. Никакой индекс или языковая модель пока не создаются.
Проверка пути до чтения
Обычная запись urls.md выглядит безобидно, но манифест является входными данными. Если принимать строку без проверки, вместо документа можно указать ../../private.txt. В учебном загрузчике каталог корпуса сначала преобразуется в абсолютный путь, а путь каждого файла разрешается через resolve. Затем проверяем, что получившийся файл находится внутри корпуса. Это особенно важно при символической ссылке: простое сравнение строк до разрешения ссылки не показывает настоящий объект чтения. После проверки допустимым остаётся только файл с расширением .md.
Ограничение размера применяется до загрузки текста. В нашей лаборатории предел составляет сто тысяч байтов на документ. Оно не сообщает, сколько слов войдёт в будущий контекст, и не заменяет ограничения HTTP-запроса. Это отдельный бюджет входного файла, предотвращающий случайное чтение огромной выгрузки вместо небольшой статьи. Если каталог меняется конкурентным недоверенным процессом между проверкой и открытием файла, остаётся отдельная проблема согласованности. Мы работаем с локальным доверенным набором, а не строим защищённый файловый шлюз для произвольных пользователей.
Записи id собираются в множество. Повторение идентификатора немедленно останавливает чтение. Без этого два файла могли бы превратиться в один документ при сохранении в SQLite, а результат зависел бы от порядка манифеста. Внутреннее имя должно быть устойчивым и уникальным. Одноимённые заголовки при этом допустимы: несколько уроков могут называться похожим образом. Человеческий заголовок подходит для отображения, но недостаточно надёжен в качестве первичного ключа. Мы заранее разделяем эти обязанности, чтобы последующие обновления не удаляли чужие фрагменты.
Адрес как часть происхождения
Для каждого источника разбираем URL через urlsplit. Разрешаем HTTPS и точное имя узла professorweb.ru. Проверка не пытается найти слово professorweb внутри произвольной строки. Адрес вида https://professorweb.ru.example.org/article принадлежит другому узлу, а имя в параметре чужого URL не делает его безопасной ссылкой. В примере также не допускается HTTP-путь к локальной панели. Нам нужен ограниченный набор публичных ссылок, которые позже будут выведены пользователю, а не универсальный сборщик ресурсов из интернета.
При этом наличие разрешённого URL не означает, что страница доступна или текст совпадает с её текущим содержимым. Загрузчик ничего не скачивает. Он проверяет форму метаданных и читает собственный локальный Markdown. Поэтому обозначение url означает адрес источника, заданный редактором корпуса, а не результат выполненного сетевого обхода. Для реального импорта понадобятся отдельный журнал происхождения, дата получения и политика обновления. Подмена этих свойств простой проверкой имени узла дала бы ложное чувство подтверждённости.
Видимость ограничена двумя значениями: public и staff. Неизвестная строка приводит к ошибке, вместо того чтобы автоматически считаться публичной. Такой выбор предотвращает опасную неоднозначность: опечатка staf не должна неожиданно открыть служебный текст. Пока функция возвращает обе группы доверенному локальному оператору. Публичный помощник появится позднее и будет фильтровать документы перед ранжированием. Здесь важно сохранить значение без потерь, чтобы дальнейшая операция могла применить его до передачи текста в контекст.
Текст и его отпечаток
Читаем Markdown явно как UTF-8. Автоматическое угадывание кодировки способно незаметно исказить русские буквы и сделать один документ разным на разных машинах. Переводы строк Windows приводим к \n. Затем вычисляем SHA-256 нормализованного текста. Этот отпечаток не является подписью редактора и не доказывает добросовестность документа. Он отвечает на более узкий вопрос: одинаковы ли байты содержимого после принятой нормализации. Такое свойство понадобится для обнаружения изменений и сопоставления фрагментов с версией источника.
Обратите внимание, что в отпечаток текста пока не входят title, url и visibility. Изменение адреса не изменит hash. Это нормально, если система обновления сравнивает текст и метаданные отдельно; опасно, если она считает совпавший hash доказательством полного совпадения документа. В уроке об обновлении индекса мы именно поэтому будем хранить оба признака. Например, перевод документа из публичного состояния в служебное должен сработать даже тогда, когда не поменялась ни одна строка Markdown.
Полный loader.py ниже показывает порядок действий. Сначала проверяются уникальность идентификатора и путь, затем размер, URL, видимость и кодировка. Только после этого объект добавляется в итоговый список. Если один источник ошибочен, функция не возвращает частично подготовленный корпус. Для небольшой лаборатории такой строгий отказ проще объяснить и безопаснее неявного пропуска статьи. В более крупной редакционной системе допустим режим карантина отдельных документов, но тогда нужно явно показывать, какие материалы не попали в индекс и почему.
При самостоятельном запуске python3 app.py ожидается шесть коротких объектов с полями id, url, hash и visibility. Текст загружен, но специально не выводится целиком. Измените одну фразу в urls.md: её hash должен измениться. Затем поменяйте только заголовок в манифесте: текстовый hash ожидаемо останется тем же. Эти два случая помогают отделить содержимое от происхождения. Пример здесь не выполнялся; описанные эффекты следуют из кода и должны быть проверены читателем в выбранной среде.
Если файл отсутствует, программа завершится ошибкой чтения. Не стоит исправлять такую ошибку созданием пустого документа: пустой источник позже выглядит как успешный импорт, хотя основание ответа потеряно. Сначала восстановите связь записи и файла. Когда все документы прочитаны одинаково, можно переходить к смысловым фрагментам. Следующий урок сохранит заголовки разделов, исключит fenced-блоки из простого текстового поиска и объяснит, почему слишком большой фрагмент мешает контексту не меньше, чем слишком маленький.
Полный файл loader.py из состояния lesson-02:
import hashlib
import json
from pathlib import Path
from urllib.parse import urlsplit
def load_documents(root=Path("corpus")):
root = root.resolve()
entries = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
documents, seen = [], set()
for item in entries:
if item["id"] in seen:
raise ValueError("Duplicate document id")
seen.add(item["id"])
path = (root / item["file"]).resolve()
if not path.is_relative_to(root) or path.suffix != ".md":
raise ValueError("Invalid corpus path")
if path.stat().st_size > 100_000:
raise ValueError("Document is too large")
url = urlsplit(item["url"])
if url.scheme != "https" or url.netloc != "professorweb.ru":
raise ValueError("Only ProfessorWeb URLs are allowed")
if item["visibility"] not in {"public", "staff"}:
raise ValueError("Unknown visibility")
text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
documents.append({**item, "text": text,
"hash": hashlib.sha256(text.encode()).hexdigest()})
return documents
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.