Сделаем полноценный поиск: добавим маршрут /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=...)
Наш план:
/search (GET).q, page, tag, sort.documents.search(...).search.html на Bootstrap.Файл 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, })
Базовые параметры:
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)
Что тут происходит:
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">«</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">»</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, если заголовок и так короткий).group_by, если они не нужны.Плюс: