Сообщить об ошибке.

Маршрут /search и запросы к Typesense

Содержание:

Сделаем полноценный поиск: добавим маршрут /search во Flask, настроим запросы к Typesense (q, query_by, filter_by, sort_by, пагинацию, подсветку совпадений) и сверстаем Bootstrap-шаблон с формой и результатами поиска.

Общая схема работы поиска

Пользователь => форма поиска => Flask => Typesense => Flask => HTML-страница:

[браузер]
   |
GET /search?q=...
   |
   v
[Flask] ---- запрос к ----> [Typesense /collections/pages/documents/search]
   |
   v
render_template("search.html", results=...)

Наш план:

  1. Маршрут /search (GET).
  2. Считываем параметры: q, page, tag, sort.
  3. Собираем словарь параметров для documents.search(...).
  4. Получаем результат, подготавливаем данные.
  5. Рендерим search.html на Bootstrap.

Настройка клиента Typesense (повторим для контекста)

Файл ts_client.py у нас уже есть, напомню:

# ts_client.py
import typesense

TYPESENSE_API_KEY = "SUPER_SECRET_ADMIN_KEY" # вынести в env на проде

client = typesense.Client({
    "nodes": [
        {
            "host": "127.0.0.1", # или Tailscale IP, если Typesense на другом сервере
            "port": "8108",
            "protocol": "http",
        }
    ],
    "api_key": TYPESENSE_API_KEY,
    "connection_timeout_seconds": 2,
})

Параметры поиска Typesense, которые мы будем использовать

Базовые параметры:

  • q - строка запроса.
    • q="*" - вернуть все документы (полезно вместе с filter_by).
  • query_by - по каким текстовым полям искать, например: "title,content".
  • page, per_page - пагинация.
  • filter_by - фильтры по числам/фасетам (tags, published_at, popularity).
  • sort_by - сортировка (например published_at:desc).
  • highlight_full_fields - по каким полям подсвечивать совпадения.
  • highlight_start_tag, highlight_end_tag - какие HTML-теги использовать для выделения совпадений (мы поставим <mark>).

Python-клиент вызывает это примерно так:

result = client.collections["pages"].documents.search({
    "q": "поиск",
    "query_by": "title,content",
    ...
})

Маршрут /search во Flask

Создадим (или дополним) app.py.

# app.py
from flask import Flask, request, render_template
from ts_client import client as ts_client

app = Flask(__name__)

COLLECTION_NAME = "pages"


@app.route("/search")
def search():
    """
    Основной маршрут поиска.
    Принимает параметры:
    - q: поисковая строка
    - page: номер страницы (1..N)
    - tag: фильтр по тегу
    - sort: способ сортировки (relevance / new / popular)
    """
    q = (request.args.get("q") or "").strip()
    page = request.args.get("page", "1")
    tag = (request.args.get("tag") or "").strip()
    sort = (request.args.get("sort") or "").strip()

    try:
        page = int(page)
        if page < 1:
            page = 1
    except ValueError:
        page = 1

    per_page = 10 # сколько результатов на страницу

    # Базовые параметры поиска
    search_params = {
        # если запрос пустой — берём "*", чтобы можно было, например, просматривать по тегу
        "q": q or "*",
        "query_by": "title,content",
        "page": page,
        "per_page": per_page,
        # подсветка совпадений
        "highlight_full_fields": "title,content",
        "highlight_start_tag": "<mark>",
        "highlight_end_tag": "</mark>",
    }

    # Фильтры
    filter_clauses = []

    # Пример фильтра по тегу (tags — у нас string[] facet)
    # Допустим, теги у нас без пробелов: 'python', 'flask'.
    if tag:
        # tags:=[python] — искать документы, где в массиве tags есть 'python'
        filter_clauses.append(f"tags:=[{tag}]")

    if filter_clauses:
        search_params["filter_by"] = " && ".join(filter_clauses)

    # Сортировка
    # По умолчанию Typesense сортирует по релевантности (_text_match)
    # + default_sorting_field (у нас popularity).
    if sort == "new":
        search_params["sort_by"] = "published_at:desc"
    elif sort == "popular":
        search_params["sort_by"] = "popularity:desc"
    # sort == "" или 'relevance' — ничего не задаём, пусть работает сортировка по релевантности

    # Выполняем поиск в Typesense
    try:
        result = ts_client.collections[COLLECTION_NAME].documents.search(search_params)
    except Exception as e:
        # В проде логируем ошибку, здесь просто покажем её пользователю
        return render_template(
            "search.html",
            q=q,
            tag=tag,
            sort=sort,
            error=str(e),
            hits=[],
            found=0,
            page=page,
            pages_total=0,
        )

    # Структура ответа: result["hits"] — список результатов,
    # result["found"] — общее число найденных документов.
    hits_raw = result.get("hits", [])
    found = result.get("found", 0)

    # Подготовим данные для шаблона: документ + удобные поля подсветки
    hits = []
    for hit in hits_raw:
        doc = hit.get("document", {})
        highlights = hit.get("highlights", [])

        # По умолчанию будем показывать:
        title = doc.get("title", "")
        content = doc.get("content", "")

        # Если Typesense отдал подсвеченные поля — заменим ими
        # В highlights примерно вот что:
        # [{"field": "title", "snippet": "найденный <mark>текст</mark>"}]
        title_hl = title
        content_hl = content

        for h in highlights:
            field = h.get("field")
            snippet = h.get("snippet")
            if field == "title" and snippet:
                title_hl = snippet
            elif field == "content" and snippet:
                content_hl = snippet

        hits.append({
            "id": doc.get("id"),
            "slug": doc.get("slug"),
            "title": title,
            "title_hl": title_hl,
            "content": content,
            "content_hl": content_hl,
            "tags": doc.get("tags", []),
            "published_at": doc.get("published_at"),
            "popularity": doc.get("popularity"),
        })

    # считаем количество страниц
    pages_total = (found + per_page - 1) // per_page if found else 0

    return render_template(
        "search.html",
        q=q,
        tag=tag,
        sort=sort,
        hits=hits,
        found=found,
        page=page,
        pages_total=pages_total,
        per_page=per_page,
    )


if __name__ == "__main__":
    app.run(debug=True)

Что тут происходит:

  • читаем query-параметры, приводим page к int;
  • собираем search_params для Typesense;
  • добавляем filter_by, если задан tag;
  • добавляем sort_by, если задан sort;
  • дергаем documents.search(...);
  • из результата забираем hits и found, готовим структуру для шаблона;
  • считаем число страниц для пагинации.

Шаблон templates/search.html на Bootstrap

Пример довольно минималистичный, но рабочий.

{# templates/search.html #}
<!doctype html>
<html lang="ru">
<head>
    <meta charset="utf-8">
    <title>Поиск по сайту</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS (пример с CDN) -->
    <link
        rel="stylesheet"
        href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
        integrity="sha384-MECnH8gJ5isty5fYjL3IuF1VkF2rd9gMaMgm7r88cz2PCycP0FdPpeWZxtmw93K2"
        crossorigin="anonymous"
    >
</head>
<body>
<div class="container mt-4">
    <h1 class="mb-4">Поиск по сайту</h1>

    <!-- Форма поиска -->
    <form method="get" action="{{ url_for('search') }}" class="mb-4">
        <div class="form-row">
            <div class="col-md-6 mb-2">
                <input
                    type="text"
                    name="q"
                    class="form-control"
                    placeholder="Что ищем?"
                    value="{{ q }}"
                >
            </div>

            <div class="col-md-3 mb-2">
                <input
                    type="text"
                    name="tag"
                    class="form-control"
                    placeholder="Тег (например, flask)"
                    value="{{ tag }}"
                >
            </div>

            <div class="col-md-2 mb-2">
                <select name="sort" class="form-control">
                    <option value="" {% if not sort %}selected{% endif %}>
                        По релевантности
                    </option>
                    <option value="new" {% if sort == 'new' %}selected{% endif %}>
                        Сначала новые
                    </option>
                    <option value="popular" {% if sort == 'popular' %}selected{% endif %}>
                        По популярности
                    </option>
                </select>
            </div>

            <div class="col-md-1 mb-2">
                <button type="submit" class="btn btn-primary btn-block">
                    Найти
                </button>
            </div>
        </div>
    </form>

    {% if error %}
        <div class="alert alert-danger">
            Ошибка при обращении к поиску: {{ error }}
        </div>
    {% endif %}

    {% if found is not none %}
        <p class="text-muted">
            Найдено документов: {{ found }}
            {% if q %} для запроса <strong>{{ q }}</strong>{% endif %}
            {% if tag %} с тегом <strong>{{ tag }}</strong>{% endif %}
        </p>
    {% endif %}

    <!-- Список результатов -->
    {% if hits %}
        <div class="list-group mb-4">
            {% for hit in hits %}
                <a
                    href="{{ url_for('page_view', slug=hit.slug) }}"
                    class="list-group-item list-group-item-action"
                >
                    <h5 class="mb-1">
                        {# title_hl уже содержит <mark>...</mark>, помечаем как safe #}
                        {{ hit.title_hl|safe }}
                    </h5>
                    <p class="mb-1 text-muted">
                        {{ hit.content_hl|safe }}
                    </p>
                    <small>
                        {% if hit.tags %}
                            Теги:
                            {% for t in hit.tags %}
                                <span class="badge badge-secondary">{{ t }}</span>
                            {% endfor %}
                        {% endif %}
                    </small>
                </a>
            {% endfor %}
        </div>
    {% else %}
        {% if q or tag %}
            <p>Ничего не найдено.</p>
        {% endif %}
    {% endif %}

    <!-- Пагинация -->
    {% if pages_total and pages_total > 1 %}
        <nav aria-label="Навигация по страницам результатов">
            <ul class="pagination">
                {# Кнопка "Назад" #}
                <li class="page-item {% if page <= 1 %}disabled{% endif %}">
                    <a class="page-link"
                       href="{{ url_for('search', q=q, tag=tag, sort=sort, page=page-1) }}"
                       aria-label="Предыдущая">
                        <span aria-hidden="true">&laquo;</span>
                    </a>
                </li>

                {# Номера страниц (упрощённый вариант, все подряд) #}
                {% for p in range(1, pages_total + 1) %}
                    <li class="page-item {% if p == page %}active{% endif %}">
                        <a class="page-link"
                           href="{{ url_for('search', q=q, tag=tag, sort=sort, page=p) }}">
                            {{ p }}
                        </a>
                    </li>
                {% endfor %}

                {# Кнопка "Вперёд" #}
                <li class="page-item {% if page >= pages_total %}disabled{% endif %}">
                    <a class="page-link"
                       href="{{ url_for('search', q=q, tag=tag, sort=sort, page=page+1) }}"
                       aria-label="Следующая">
                        <span aria-hidden="true">&raquo;</span>
                    </a>
                </li>
            </ul>
        </nav>
    {% endif %}

</div>

<!-- Bootstrap JS (опционально для некоторых компонентов) -->
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
        integrity="sha384-DfXdJi14BfORnlOCxv9XyM54q9WlH0I1GtIsfRSAxEPPGjBJOCaOQGLjmVn1N3k"
        crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"
        integrity="sha384-9/reFTGAW83EW2RDuqS0hJpNQCBtOrtVjZW5+I0lqvumtuN1PyfuzMdGII4XESyq"
        crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"
        integrity="sha384-B0UglyR+LyA6cQmJrVN6M8GN28l5soMgqd7qV3aaJdEYBLETymFZ343SUsyPHEf7"
        crossorigin="anonymous"></script>
</body>
</html>

Пара замечаний:

  • page_view в url_for('page_view', slug=hit.slug) - это предполагаемый маршрут, который по slug отдаёт страницу (его реализуешь в своём приложении).
  • |safe на подсвеченных полях - мы сознательно позволяем HTML (<mark>) из Typesense. Сам Typesense экранирует текст, кроме наших тегов подсветки.

Мини-оптимизации под слабый сервер

Для экономии ресурсов:

  • per_page держать небольшим (10–20).
  • highlight_full_fields оставить только там, где подсветка реально нужна (например, убрать title, если заголовок и так короткий).
  • Стремиться, чтобы HTTP-запрос к Typesense делал только то, что нужно:
    • не запрашивать лишние фасеты, если они не используются;
    • не включать тяжёлые фичи вроде group_by, если они не нужны.

Плюс:

  • включить gzip в веб-сервере (nginx / gunicorn), чтобы отдача больших списков результатов была дешевле по трафику.