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

Поиск без серверного обработчика

В предыдущем уроке мы связали три статьи в последовательность. Читатель может пройти её от первой страницы до последней, а каталог позволяет выбрать материал по теме. Однако у этих способов навигации есть ограничение: посетителю нужно заранее понимать, в каком разделе находится нужное объяснение. Для запроса «исходник» удобнее поиск по тексту.

Добавим его в тот же проект markdown-site. Сборщик подготовит JSON со статьями, а небольшая программа JavaScript прочитает этот файл и покажет совпадения. Сервер будет отдавать обычные файлы. Выполнять Python или обращаться к базе данных во время запроса не потребуется.

Что включать в поисковый индекс

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

Возьмём уже полученный список documents. Он содержит опубликованные исходники и исключает черновики. Реестр pages шире: туда входят также страницы каталога и проекта. Если индексировать весь реестр, одна статья несколько раз появится в результатах через разные списки. Поэтому индекс строится из documents, а страница поиска добавляется в pages.

HTML нельзя просто удалить выражением, которое вырезает всё между угловыми скобками. В доверенной старой разметке могут быть комментарии, атрибуты с такими символами и содержимое служебных элементов. Используем стандартный HTMLParser: он различает текст и элементы. В начало build.py добавьте два импорта:

from html.parser import HTMLParser
import json

Перед main() разместите следующий новый класс и две функции целиком:

class ArticleText(HTMLParser):
    boundaries = {
        "p", "div", "section", "article", "li", "ul", "ol", "pre",
        "h1", "h2", "h3", "h4", "h5", "h6", "table", "tr", "td", "th", "br",
    }

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.parts = []
        self.hidden = 0

    def handle_starttag(self, tag, attrs):
        if tag in {"script", "style"}:
            self.hidden += 1
        elif tag in self.boundaries and not self.hidden:
            self.parts.append(" ")

    def handle_endtag(self, tag):
        if tag in {"script", "style"} and self.hidden:
            self.hidden -= 1
        elif tag in self.boundaries and not self.hidden:
            self.parts.append(" ")

    def handle_data(self, data):
        if not self.hidden:
            self.parts.append(data)


def article_text(body):
    parser = ArticleText()
    parser.feed(body)
    parser.close()
    return " ".join("".join(parser.parts).split())


def write_search_index(documents):
    records = [
        {
            "title": document["title"],
            "url": document["permalink"],
            "text": article_text(document["body"]),
        }
        for document in documents
    ]
    target = DIST / "assets" / "search-index.json"
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(
        json.dumps(records, ensure_ascii=False, separators=(",", ":")),
        encoding="utf-8",
    )

Обратите внимание на источник body. Это HTML только статьи, до вставки в общий шаблон. Поэтому названия пунктов меню, футер и кнопки соседних уроков не повторяются в каждой записи. Сущности вроде " преобразуются в обычные символы. Последнее выражение с split() убирает последовательности пробелов и переводов строки, чтобы отрывок результата занимал одну строку текста.

Такое извлечение не воспроизводит всю модель отображения браузера: например, оно не вычисляет CSS и не учитывает произвольный display: none. Для контролируемых учебных материалов этого достаточно. Мы явно исключаем script и style, но сохраняем текст кода: по названиям функций тоже полезно искать. Сложную обработку можно позднее добавить в эту единственную функцию.

Страница поиска в общем шаблоне

Добавим ещё одну функцию перед main(). Она создаёт страницу с формой и местом для результатов. В ней используется прежняя add_page(), поэтому совпадение адреса поиска с адресом статьи завершит сборку ошибкой, а не перезапишет материал.

def build_search(documents, pages):
    assets = ROOT / "public" / "assets"
    if assets.exists() and not assets.is_dir():
        raise ValueError("public/assets должна быть папкой для поискового индекса")
    reserved = ROOT / "public" / "assets" / "search-index.json"
    if reserved.exists() or reserved.is_symlink():
        raise ValueError("search-index.json создаёт сборщик; уберите его из public")
    add_page(pages, {
        "title": "Поиск по статьям",
        "description": "Поиск по материалам учебного сайта.",
        "permalink": "/search/index.html",
        "noindex": True,
        "body": """<form id="search-form" method="get">
  <label for="search-query">Что найти?</label>
  <input id="search-query" name="q" type="search">
  <button type="submit">Найти</button>
</form>
<p id="search-status" role="status" aria-live="polite">
  Введите слово или несколько слов из статьи.
</p>
<ol id="search-results"></ol>
<noscript>Для поиска нужен JavaScript. Материалы доступны в каталоге.</noscript>
<script src="/assets/search.js" defer></script>""",
    })

Поле noindex пока хранится в словаре страницы. В следующем уроке общий шаблон начнёт превращать его в метатег для поисковых роботов. Сейчас оно не влияет на собственный поиск сайта. Это разные задачи: внутренняя форма помогает посетителю, а запрет индексации служебной страницы относится к внешним поисковым системам.

Замените main() целиком, сохранив остальные определения:

def main():
    documents = load_documents()
    pages = {document["permalink"]: document for document in documents}
    build_catalog(documents, pages)
    build_search(documents, pages)
    navigation = build_navigation(documents)
    validate_public(pages)

    if DIST.exists():
        shutil.rmtree(DIST)
    DIST.mkdir()
    copy_public()
    for page in pages.values():
        write_page(page, navigation.get(page["permalink"], ""))
    write_search_index(documents)
    print(f"Создано страниц: {len(pages)}")

Страница поиска входит в реестр до очистки dist. JSON записывается позже, когда папка результатов уже существует. Сам файл индекса не кладём в public: иначе там могла бы остаться устаревшая копия. В общий layouts/header.html добавьте обычную ссылку <a href="/search/index.html">Поиск</a> рядом со ссылкой каталога.

Получение JSON и вывод совпадений

Создайте файл public/assets/search.js со следующим полным содержимым. Он обслуживает только новую страницу: скрипт подключён в её теле, а статьи продолжают загружаться без дополнительной поисковой программы.

const form = document.querySelector("#search-form");
const input = document.querySelector("#search-query");
const status = document.querySelector("#search-status");
const results = document.querySelector("#search-results");
const button = form.querySelector("button");
let indexPromise = null;

function normalized(text) {
  return text.toLocaleLowerCase("ru").replaceAll("ё", "е");
}

function validRecord(item) {
  if (!item || typeof item.title !== "string" ||
      typeof item.text !== "string" || typeof item.url !== "string") {
    return false;
  }
  if (!item.url.startsWith("/") || item.url.startsWith("//") ||
      /[?#%\\]/.test(item.url) || !/\.(html|php)$/.test(item.url)) {
    return false;
  }
  const url = new URL(item.url, location.origin);
  return url.origin === location.origin && url.pathname === item.url;
}

async function loadIndex() {
  if (!indexPromise) {
    indexPromise = (async () => {
      const response = await fetch("/assets/search-index.json", {
        mode: "same-origin",
      });
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      const records = await response.json();
      if (!Array.isArray(records) || !records.every(validRecord)) {
        throw new Error("Неверный формат поискового индекса");
      }
      return records;
    })();
  }
  try {
    return await indexPromise;
  } catch (error) {
    indexPromise = null;
    throw error;
  }
}

function excerpt(text, word) {
  const position = normalized(text).indexOf(word);
  const start = Math.max(0, position - 45);
  const end = Math.min(text.length, start + 180);
  return (start ? "…" : "") + text.slice(start, end) +
    (end < text.length ? "…" : "");
}

async function search(event) {
  if (event) event.preventDefault();
  const query = input.value.trim();
  results.replaceChildren();
  const url = new URL(location.href);
  if (!query) {
    url.searchParams.delete("q");
    history.replaceState(null, "", url);
    status.textContent = "Введите поисковый запрос.";
    return;
  }
  url.searchParams.set("q", query);
  history.replaceState(null, "", url);
  button.disabled = true;
  status.textContent = "Загружаем материалы…";

  try {
    const records = await loadIndex();
    const words = normalized(query).split(/\s+/);
    const matches = records.filter((item) => {
      const haystack = normalized(`${item.title} ${item.text}`);
      return words.every((word) => haystack.includes(word));
    });
    matches.sort((a, b) =>
      Number(normalized(b.title).includes(normalized(query))) -
      Number(normalized(a.title).includes(normalized(query))));

    for (const item of matches.slice(0, 50)) {
      const row = document.createElement("li");
      const link = document.createElement("a");
      link.href = item.url;
      link.textContent = item.title;
      const paragraph = document.createElement("p");
      paragraph.textContent = excerpt(item.text, words[0]);
      row.append(link, paragraph);
      results.append(row);
    }
    status.textContent = matches.length
      ? `Найдено: ${matches.length}. Показано: ${Math.min(matches.length, 50)}.`
      : "Ничего не найдено. Попробуйте другое слово.";
  } catch (error) {
    status.textContent = "Не удалось загрузить поиск. Попробуйте ещё раз.";
    console.error(error);
  } finally {
    button.disabled = false;
  }
}

form.addEventListener("submit", search);
input.value = new URL(location.href).searchParams.get("q") || "";
if (input.value.trim()) search();

fetch() получает файл с того же сайта. Обратите внимание на response.ok: ответ сервера с кодом 404 сам по себе не отклоняет обещание fetch. Поэтому перед чтением JSON мы явно проверяем успешный статус. Именно такое поведение описывает документация MDN по Fetch API.

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

Для вывода мы создаём элементы и присваиваем textContent. Строка статьи не превращается в HTML: даже текст <img src=x> будет показан как текст, без создания изображения. Такое отличие от innerHTML объясняет справочник MDN по textContent. Адрес результата дополнительно проверяется как путь своего сайта; в него нельзя подставить внешнюю ссылку или javascript:.

Как меняется поведение поиска

После сборки ожидается восемь страниц: три статьи, четыре страницы каталога и страница поиска. В dist/assets/search-index.json должны находиться три записи: перед продолжением мы восстановили публичный статус всех трёх документов. Если добавить отдельный четвёртый исходник с уникальным адресом и draft: true, он не должен попасть в индекс. Для просмотра запустите .venv/bin/python serve.py из корня проекта: этот сервер из урока о переносе HTML корректно отдаёт и сохранённые адреса .php. Открытие HTML как file:// не подходит для обычной загрузки JSON через fetch.

Введите «исходник». Первая статья содержит это слово, поэтому ожидается ссылка на /index.html и фрагмент её собственного текста. Введите заведомо отсутствующее слово: список должен остаться пустым, а пояснение сообщит, что совпадений нет. Ошибка загрузки индекса отличается от отсутствия результата: при ней отображается сообщение о невозможности загрузить поиск. Это помогает посетителю понять, стоит ли менять запрос.

Мы ищем все слова запроса в любом порядке. «Markdown исходник» подходит странице, в которой оба слова присутствуют, даже если находятся в разных абзацах. Замена «ё» на «е» снимает одно частое различие русского ввода, но окончания слов программа не анализирует. «Статья» и «статьи» остаются разными подстроками; это ограничение простого поиска, а не ошибка сборки.

Для нескольких страниц такой подход понятен и достаточен. При библиотеке из двадцати тысяч материалов полный текст всех статей может стать тяжёлым для загрузки и поиска в основном потоке браузера. Тогда измеряют размер индекса, время ответа на запрос и использование памяти, а после выбирают сокращённый индекс, разделение по разделам или готовый поисковый движок. Сначала полезно сохранить понятный контракт title, url, text: интерфейс результатов сможет остаться прежним даже при замене способа поиска.

В учебном проекте поиск теперь ведёт непосредственно к исходным адресам статей. Следующая задача относится уже к поисковым роботам: зададим единый домен, канонические адреса и карту страниц, не включая в неё служебную форму.