Сделаем быстрый JSON-API для поиска, подключим "живой" поиск (search-as-you-type) и подсказки заголовков. Покажу, как настроить префиксный поиск и ограничения, чтобы не положить Typesense на слабом сервере.
Сейчас у нас есть HTML-маршрут /search, который:
q, tag, sort;Для подсказок и "живого" поиска удобнее иметь отдельный лёгкий JSON-endpoint, который:
id, slug, title);С таким API мы сможем:
/api/searchСделаем простой маршрут /api/search, который:
q, limit;# app_api.py (или внутри app.py) from flask import Flask, request, jsonify from ts_client_search import client as ts_client # search-only ключ, если вынесено # если не выносили — можешь использовать ts_client из предыдущих частей app = Flask(__name__) COLLECTION_NAME = "pages" @app.route("/api/search") def api_search(): """ Лёгкий JSON-API для поиска. Предназначен для AJAX / fetch-запросов на фронтенде. """ q = (request.args.get("q") or "").strip() if not q: # Пустой запрос — ничего не ищем, возвращаем пустой список return jsonify({"hits": [], "found": 0}) try: limit = int(request.args.get("limit", "5")) except ValueError: limit = 5 # Ограничиваем лимит сверху, чтобы не положить Typesense limit = max(1, min(limit, 20)) search_params = { "q": q, "query_by": "title,content", "per_page": limit, "page": 1, # для подсказок нам не нужны подсветки и фасеты "include_fields": "id,slug,title", # Typesense вернёт только эти поля # усилим роль заголовка "query_by_weights": "3,1", # хотим искать по префиксу (для "живого" поиска) "prefix": "true", # поведение зависит от версии Typesense, но идея такая } try: result = ts_client.collections[COLLECTION_NAME].documents.search(search_params) except Exception as e: # В проде лучше отдавать обобщённую ошибку и логировать детали return jsonify({"error": str(e)}), 500 hits = [] for hit in result.get("hits", []): doc = hit.get("document", {}) hits.append({ "id": doc.get("id"), "slug": doc.get("slug"), "title": doc.get("title"), }) return jsonify({ "hits": hits, "found": result.get("found", 0), })
Что важно:
include_fields - Typesense не будет таскать лишние поля;per_page небольшой (5–10);prefix="true" - позволяет работать как автодополнение;/api/suggestИногда нужно именно подсказки по заголовкам, а не общий поиск.Сделаем специализированный endpoint, который ищет только по title.
@app.route("/api/suggest") def api_suggest(): """ Подсказки по заголовкам страниц. Например, для выпадающего списка под полем поиска. """ q = (request.args.get("q") or "").strip() if not q or len(q) < 2: # Не начинаем подсказывать с одной буквы — слишком жирно return jsonify({"suggestions": []}) try: limit = int(request.args.get("limit", "8")) except ValueError: limit = 8 limit = max(1, min(limit, 20)) search_params = { "q": q, "query_by": "title", "per_page": limit, "page": 1, "include_fields": "id,slug,title", "query_by_weights": "1", # одно поле, просто для явности "prefix": "true", # префиксный поиск по заголовкам # Опционально чуть ослабляем опечатки для коротких запросов "num_typos": 1, "min_len_1typo": 4, "min_len_2typo": 8, } try: result = ts_client.collections[COLLECTION_NAME].documents.search(search_params) except Exception as e: return jsonify({"error": str(e)}), 500 suggestions = [] for hit in result.get("hits", []): doc = hit.get("document", {}) suggestions.append({ "id": doc.get("id"), "slug": doc.get("slug"), "title": doc.get("title"), }) return jsonify({"suggestions": suggestions})
Логика:
title => быстрый и предсказуемый результат;title и slug, этого достаточно для ссылок.Сделаем простой пример:
Добавим в шаблон (например, в base.html или на конкретной странице):
<form class="form-inline my-2 my-lg-0 position-relative" onsubmit="return onSearchSubmit(event)"> <input id="live-search-input" class="form-control mr-sm-2" type="search" placeholder="Поиск по сайту" aria-label="Поиск" autocomplete="off" oninput="onSearchInput(this.value)" > <div id="live-search-results" class="list-group position-absolute" style="top: 100%; left: 0; right: 0; z-index: 1000;" ></div> </form>
position-relative на форме и position-absolute на блоке подсказок - делаем выпадающий список под полем.<script> let searchTimer = null; function onSearchInput(value) { const container = document.getElementById('live-search-results'); // Чистим, если пусто if (!value || value.trim().length < 2) { container.innerHTML = ''; return; } // Дебаунс: ждём 300 мс после последнего ввода if (searchTimer) { clearTimeout(searchTimer); } searchTimer = setTimeout(function () { fetch(`/api/suggest?q=${encodeURIComponent(value.trim())}&limit=8`) .then(response => response.json()) .then(data => { renderSuggestions(data.suggestions || []); }) .catch(err => { console.error('Search error', err); }); }, 300); } function renderSuggestions(suggestions) { const container = document.getElementById('live-search-results'); container.innerHTML = ''; if (!suggestions.length) { return; } suggestions.forEach(item => { const a = document.createElement('a'); a.className = 'list-group-item list-group-item-action'; a.textContent = item.title || '(без заголовка)'; a.href = `/pages/${item.slug}`; // или url_for-путь, если хочешь динамически подставлять container.appendChild(a); }); } function onSearchSubmit(event) { event.preventDefault(); const input = document.getElementById('live-search-input'); const value = input.value.trim(); if (!value) { return false; } // Просто редиректим на обычную страницу поиска window.location.href = `/search?q=${encodeURIComponent(value)}`; return false; } // Закрывать подсказки при клике вне document.addEventListener('click', function (e) { const container = document.getElementById('live-search-results'); const input = document.getElementById('live-search-input'); if (!container.contains(e.target) && e.target !== input) { container.innerHTML = ''; } }); </script>
Что делаем:
list-group Bootstrap.Иногда хочется не только подсказки, но и список результатов прямо под строкой (без перехода на /search):
/search для "полной" выдачи.Для этого можно:
/api/search;renderSuggestions рисовать карточки с коротким описанием.Пример адаптации renderSuggestions:
<div id="live-search-list"></div> <script> function renderLiveResults(hits) { const container = document.getElementById('live-search-list'); container.innerHTML = ''; if (!hits.length) { return; } hits.forEach(item => { const div = document.createElement('div'); div.className = 'mb-2'; const a = document.createElement('a'); a.href = `/pages/${item.slug}`; a.textContent = item.title || '(без заголовка)'; a.className = 'font-weight-bold d-block'; const p = document.createElement('p'); p.textContent = item.content || ''; p.className = 'mb-0 text-muted small'; div.appendChild(a); div.appendChild(p); container.appendChild(div); }); } // тогда в onSearchInput вместо /api/suggest дергаем /api/search // и передаём renderLiveResults(data.hits) </script>
На слабом сервере важно:
limit 5–10).Для облегчения нагрузки:
Ограничить поля через include_fields.
Снизить per_page до 5–10.
Подкрутить опечатки:
search_params.update({ "num_typos": 1, "min_len_1typo": 4, "min_len_2typo": 8, })
Так мы не будем слишком активно исправлять короткие слова (типично для русского).
Проверить, что prefix включён только там, где нужен search-as-you-type (в обычном /search он не всегда желателен - может дать слишком много срабатываний).
/api/search и /api/suggest.