Поиск для вашего сайта
Публичный read API намеренно не является поисковым движком (SPEC.md §7). Он отдаёт
опубликованные снимки по модели, slug, локали, связи и тегу — параметра
?q= нет. Это проектное решение, а не пробел: сканирование LIKE по снимкам в D1
было бы медленным, без ранжирования и неподходящим по форме для модели
«одна строка на документ».
Поиск относится к стороне потребителя. Сегодня работают три подхода, в порядке возрастания трудозатрат.
1. Pagefind — статические сайты, без инфраструктуры
Заголовок раздела «1. Pagefind — статические сайты, без инфраструктуры»Если ваш сайт собирается статически (или сборка сгенерированного API выдаёт HTML), проиндексируйте собранный результат:
npm install -D pagefindnpx pagefind --site distPagefind индексирует отрендеренные страницы, отдаёт небольшой WASM UI из того же бакета и не требует сервера. Запускайте его в той же задаче CI, которая собирает сайт. Это правильный вариант по умолчанию для сайтов-визиток.
2. Typesense / Meilisearch — поиск как в приложении
Заголовок раздела «2. Typesense / Meilisearch — поиск как в приложении»Обойдите публичный API и загрузите данные в поисковый сервис, затем выполняйте запросы к нему из фронтенда.
// One page per model per locale, following the cursor. An item is already// projected into the requested locale, so index one locale per pass.const base = 'https://content.example.com/v1';let cursor: string | undefined;do { const url = new URL(`${base}/article`); url.searchParams.set('locale', 'en'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url); const { data, page } = await res.json(); for (const item of data) { await index.upsert({ id: `${item.id}:${item.locale}`, title: item.fields.title ?? '', body: item.fields.body ?? '', slug: item.slug, locale: item.locale, published_at: item.published_at, }); } // Pagination lives in `page`, beside `data`. The cursor is opaque: send back // exactly what you were given. cursor = page.has_more ? page.next_cursor : undefined;} while (cursor);Переиндексируйте по расписанию или по вебхуку развёртывания, который ваш CI уже получает.
Используйте в качестве ключа индекса id элемента (плюс его locale) — slug может меняться, id — нет.
3. Маленький поисковый Worker — без внешнего сервиса
Заголовок раздела «3. Маленький поисковый Worker — без внешнего сервиса»Для небольших сайтов достаточно одного Worker, который держит индекс в памяти/KV и обновляет его по cron:
// search-worker: GET /search?q=recipe// Refresh: cron pulls every public model once per hour.export default { async fetch(req, env): Promise<Response> { const q = new URL(req.url).searchParams.get('q')?.toLowerCase() ?? ''; if (!q) return Response.json({ results: [] }); const index = JSON.parse(await env.SEARCH_INDEX.get('all') ?? '[]'); const results = index .filter((d) => (d.title + ' ' + d.body).toLowerCase().includes(q)) .slice(0, 20); return Response.json({ results }); },
async scheduled(_event, env, _ctx) { const res = await fetch(`${env.PUBLIC_API}/article?locale=en&limit=100`); const { data } = await res.json(); await env.SEARCH_INDEX.put('all', JSON.stringify( data.map((d) => ({ title: d.fields.title, body: strip(d.fields.body), slug: d.slug })) )); },};Это поиск подстроки, а не ранжирование — нормально до нескольких тысяч документов, неправильно выше этого. Переходите на Typesense, когда станет больно.
Правила, общие для всех трёх
Заголовок раздела «Правила, общие для всех трёх»- Индексируйте только опубликованные снимки, через публичный API. Admin API никогда не наполняет поисковый индекс; черновики и неопубликованный контент не должны утекать.
- Используйте в качестве ключа документов
idэлемента, показывайтеslug. - Переиндексируйте после развёртываний, а не после каждой публикации — публичный API помечен cache-тегами, поэтому устаревший на несколько минут индекс — это нормально и безвредно.
- Учитывайте локали:
locale— это параметр запроса, и элемент возвращается уже спроецированным в одну локаль, поэтому обходите данные один раз на каждую локаль и храните по одному документу индекса на локаль или фасет по локали — никогда не один объединённый blob.
Что перенесло бы поиск в продукт
Заголовок раздела «Что перенесло бы поиск в продукт»Сборка «graduation» уже забирает данные сайта, чтобы сгенерировать типизированный API. Выпуск статического поискового индекса как ещё одного артефакта сборки вписывается в основной тезис: индекс становится файлом, которым владеет клиент, и его обновляет тот же CI, который пересобирает его API. Именно в этот момент «поиск» становится функцией Dee Wan, а не рецептом на стороне потребителя.