Подготовка данных из Markdown
В первом уроке мы определили материалы и контрольные запросы. Теперь нужно получить данные, с которыми сможет работать поисковая программа. Поиску не требуется весь HTML страницы с меню, футером и повторяющимися ссылками. Ему нужен документ: заголовок, содержательный текст, рубрика и адрес, по которому посетитель откроет материал.
В ProfessorWeb исходник статьи и публичный URL существуют отдельно. Этот принцип сохраним в учебном проекте search-lab. Имя Markdown-файла удобно редактору, а permalink сохраняет адрес для читателя. Результатом урока станет экспортёр, создающий JSON только из опубликованных материалов и не угадывающий путь по названию файла.
Какие сведения переносить
Общая запись содержит id, url, title, headings, text, section и topic. ID нужен индексу, URL — навигации, остальные поля — совпадениям, ранжированию и фильтрам. Это не копия всех метаданных статьи. Внешние токены, редакционные комментарии и внутренние заметки не должны попадать в публичный поисковый файл.
У заголовка и текста разные роли. Точное совпадение с названием урока обычно полезнее случайного употребления слова в длинном примере. Поэтому не будем сразу склеивать всё в одну строку: отдельные поля понадобятся в уроке о ранжировании.
Заголовки разделов помогают понять устройство материала. Извлекаем их в отдельный массив, хотя они также могут встречаться в основном тексте. Позднее объясним, как учитывать такие совпадения без бесконтрольного удвоения веса. Сейчас важно не потерять смысловую структуру.
Подготовьте папки content и public внутри search-lab. Полный минимальный исходник content/xml.md выглядит так:
---
id: xml
title: Запросы LINQ to XML
permalink: /my/LINQ/linq_xml/level7/7_1.php
draft: false
section: backend
topic: xml
---
## Выбор элементов
LINQ помогает создавать запросы к XML. Рассмотрим выбор
элементов документа и получение значений атрибутов.
Это учебный текст с адресом старого урока, а не полный оригинальный материал. Остальные документы из нашей таблицы имеют такие же поля и собственные ID. Дополнительный файл draft.md содержит draft: true: он останется исходником и не появится в экспорте.
Чтение Markdown
Для преобразования используем Python-Markdown, а для чтения полученного HTML — Beautiful Soup. Преобразование Markdown описано в справочнике Python-Markdown. Парсер позволяет извлечь текст без попытки убрать разметку одним регулярным выражением.
Ниже полный файл export.py для нашего проекта. Он использует PyYAML, Markdown и beautifulsoup4. Зависимости относятся к учебному окружению, а не добавляются автоматически в настоящий движок ProfessorWeb.
from pathlib import Path
from urllib.parse import urlsplit
import hashlib
import json
import re
import markdown
import yaml
from bs4 import BeautifulSoup
ROOT = Path(__file__).resolve().parent
def read_document(path):
source = path.read_text(encoding="utf-8")
match = re.match(r"\A---\r?\n(.*?)\r?\n---\r?\n", source, re.S)
if not match:
raise ValueError(f"Нет метаданных: {path.name}")
meta = yaml.safe_load(match.group(1))
if not isinstance(meta, dict):
raise ValueError(f"Неверные метаданные: {path.name}")
draft = meta.get("draft", True)
if not isinstance(draft, bool):
raise ValueError(f"draft должен быть boolean: {path.name}")
if draft:
return None
url = meta.get("permalink", "")
if not isinstance(url, str) or not url.startswith("/") or url.startswith("//"):
raise ValueError(f"Неверный permalink: {path.name}")
parts = urlsplit(url)
if parts.query or parts.fragment or parts.netloc or "/../" in url:
raise ValueError(f"Нужен постоянный локальный путь: {path.name}")
html = markdown.markdown(source[match.end():], extensions=["fenced_code", "tables"])
soup = BeautifulSoup(html, "html.parser")
for node in soup.select("script, style"):
node.decompose()
title = str(meta.get("title", "")).strip()
if not title:
raise ValueError(f"Нет заголовка: {path.name}")
doc_id = str(meta.get("id") or hashlib.sha256(url.encode()).hexdigest()[:16])
if not re.fullmatch(r"[A-Za-z0-9_-]+", doc_id):
raise ValueError(f"Неверный id: {path.name}")
return {
"id": doc_id,
"url": url,
"title": title,
"headings": [h.get_text(" ", strip=True) for h in soup.select("h1,h2,h3")],
"text": soup.get_text(" ", strip=True),
"section": str(meta.get("section", "")),
"topic": str(meta.get("topic", "")),
}
def export():
documents = []
for path in sorted((ROOT / "content").glob("*.md")):
doc = read_document(path)
if doc is not None:
documents.append(doc)
for field in ("id", "url"):
values = [doc[field] for doc in documents]
if len(values) != len(set(values)):
raise ValueError(f"Повторяющиеся {field}")
documents.sort(key=lambda doc: doc["id"])
encoded = json.dumps(documents, ensure_ascii=False, sort_keys=True).encode("utf-8")
version = hashlib.sha256(encoded).hexdigest()[:16]
payload = {"schemaVersion": 1, "version": version, "documents": documents}
target = ROOT / "public" / "documents.json"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
export()
Проверка draft происходит до извлечения текста. При отсутствии поля материал считается черновиком; строка "false" не принимается за логическое значение. Это помогает заметить ошибку метаданных вместо тихой публикации.
Тип permalink проверяется до разбора адреса. В наших исходниках пути являются строками. Не заменяйте сохранённый путь на нижний регистр: регистр каталогов может иметь значение для маршрута.
Постоянный адрес и стабильная личность
У xml.md публичный адрес заканчивается на .php. Экспортёр сохраняет его буквально, хотя исходник Markdown и результат сайта статический. Поисковый индекс не должен создавать новый URL ради удобства собственной структуры.
В учебных исходниках ID указан явно: xml, markdown, nginx и далее. Если его нет, программа использует хеш URL. Такой запасной вариант стабилен при изменении текста, но изменится при настоящем переезде. Для долгоживущей базы с переездами лучше хранить отдельный ID и карту соответствий.
Повторяющийся URL проверяется отдельно от повторяющегося ID. Два разных файла могут случайно объявить один путь. Если молча сохранить оба, выдача начнёт показывать две карточки одной страницы. Экспорт завершается ошибкой, чтобы редактор разобрался с источником.
Версия вычисляется из упорядоченных документов. Она не зависит от сегодняшней даты и не выдаёт очередную сборку за содержательное обновление. Изменение текста, рубрики или URL меняет версию; перестановка файлов в папке не должна её менять.
Что должно появиться в JSON
После выполнения экспортёра ожидается объект с версией схемы, версией содержимого и шестью документами. Черновик отсутствует. У xml сохранён старый адрес, в headings находится «Выбор элементов», а в text — текст учебного материала.
Откройте файл как данные и сравните одну запись с исходником. Если туда попало меню всего сайта, значит, экспортируется не содержимое статьи. Если исчез технический пример, проверьте, не удаляется ли целиком элемент code: в технической библиотеке код иногда содержит ключевой искомый термин.
Не все Markdown-конструкции одинаково полезны для поиска. Большие таблицы и листинги могут создавать шум; картинки содержат смысл в подписи или альтернативном тексте. Наша первая версия извлекает простой текст, а политика отдельных полей развивается вместе с библиотекой и контрольными запросами.
Не следует объявлять экспорт успешным только потому, что JSON синтаксически корректен. Важно, какие документы туда включены и куда ведут их адреса. Сформированный файл — публичный набор данных, а не резервная копия всех редакционных исходников.
Рассмотрим обычное редакционное изменение. Файл xml.md переименован в linq-xml.md, но его ID и permalink сохранены. Для поиска это тот же документ. Если содержание тоже осталось прежним, сортировка по ID позволит получить прежнюю версию корпуса. Именно поэтому имя исходника не включается в публичную запись: служебная перестановка папок не должна создавать новую личность статьи.
Другой случай — два исходника после объединения веток разработки. В одном находится старый урок, в другом его новая редакция, и оба объявляют ID xml. Экспортёр остановится до записи нового файла. Не исправляйте ситуацию случайным суффиксом у второго ID: сначала определите, какой текст должен быть единственным источником страницы. Для действительно разных уроков потребуются разные ID и разные публичные адреса.
Также сравните опубликованный документ и черновик с одинаковым словом в заголовке. В итоговом массиве должен присутствовать только первый. Отсутствие второго связано с редакционным статусом, а не с алгоритмом будущего поиска. Пока файл не экспортирован, ни подстрока, ни инвертированный индекс не могут его найти. Такое разделение помогает диагностировать проблему на правильном этапе: сначала состав корпуса, затем обработка запроса, после этого порядок карточек.
Теперь библиотека имеет один понятный формат. Следующий урок прочитает documents.json в браузере и покажет первый работающий поиск без дополнительного сервера.