Перейти к содержимому
Pre-MVP. Кодовая база 1.0 не является выпущенным продуктом — см. границы продукта 1.0.

Поиск для вашего сайта

Публичный read API намеренно не является поисковым движком (SPEC.md §7). Он отдаёт опубликованные снимки по модели, slug, локали, связи и тегу — параметра ?q= нет. Это проектное решение, а не пробел: сканирование LIKE по снимкам в D1 было бы медленным, без ранжирования и неподходящим по форме для модели «одна строка на документ».

Поиск относится к стороне потребителя. Сегодня работают три подхода, в порядке возрастания трудозатрат.

1. Pagefind — статические сайты, без инфраструктуры

Заголовок раздела «1. Pagefind — статические сайты, без инфраструктуры»

Если ваш сайт собирается статически (или сборка сгенерированного API выдаёт HTML), проиндексируйте собранный результат:

Окно терминала
npm install -D pagefind
npx pagefind --site dist

Pagefind индексирует отрендеренные страницы, отдаёт небольшой WASM UI из того же бакета и не требует сервера. Запускайте его в той же задаче CI, которая собирает сайт. Это правильный вариант по умолчанию для сайтов-визиток.

Обойдите публичный 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, а не рецептом на стороне потребителя.