Vocab Bloom Hub

Поверхности API: публичный /api/v1 и админский API

Сервер отдаёт две поверхности на одном хосте:

ПоверхностьПрефиксыАутентификацияНазначение
Публичная/api/v1/*, /api/compat/dictionaryapi/*, /api/compat/freedictionaryapi/*нетRead-only контракт для приложений-потребителей
Админская/api/en/*, /api/settings, /api/authJWT админа (cookie / Bearer-токен)Всё, что делает админка: редактирование, импорт / экспорт, статистика, вход

Ничто под /api/v1 не меняет данные и не требует логина. Адаптеры совместимости под /api/compat/* тоже публичные и доступны только для чтения. Публичный контракт отдаётся и как OpenAPI-документ; Swagger UI, справочник на сайте и другие способы прочитать API сравниваются в api-tools.md.

Переход с внешних API: dictionaryapi.dev и freedictionaryapi.com. Подробное описание полей адаптеров есть в английской версии документации API.

Ответы адаптеров по всем датасетам

Суффикс /datasets у пути адаптера возвращает результаты из всех установленных датасетов без смены активного. Обычные пути адаптеров читают активный датасет; эти расширения Vocab Bloom Hub предназначены для сравнения и инструментов интеграции.

МетодПутьQuery
GET/api/compat/dictionaryapi/v2/entries/{language}/{word}/datasetsнет
GET/api/compat/dictionaryapi/v1/entries/{language}/{word}/datasetsнет
GET/api/compat/freedictionaryapi/v1/entries/{language}/{word}/datasetstranslations, pretty

Ответ — { data, meta }. Каждая группа в data содержит условия своего датасета: dataset, title, active, source, dataset_version, атрибуцию, уведомления, текст лицензии и происхождение, а также два поля:

Полеdictionaryapi.dev v1/v2freedictionaryapi.com v1
status200, если слово найдено, иначе 404200, включая отсутствие слова
responseПолный массив v1/v2 или ошибка { title, message, resolution }Полный объект { word, entries, source, vocabBloom }, где entries может быть пустым

В meta — запрошенные word, language, число групп datasets и found: сколько датасетов нашли записи. HTTP-статус всего запроса — 200 даже при found: 0. Пустые группы сохраняются. Сбой чтения базы завершает весь запрос ошибкой, а не выдаётся за отсутствие слова.

Каждый датасет читается по правилам обычного адаптера и через тот же преобразователь ответа. dictionaryapi.dev поддерживает en, en_US, en_GB без учёта регистра кода и нативные правила поиска заголовочного слова и словоформ. Другой язык даёт общую ошибку 404, query — 400. freedictionaryapi.com ищет точное написание с учётом регистра, разрешает словоформы и альтернативные написания на один переход. en и all читают английский; другой язык даёт пустые группы. translations и pretty принимают только строчные true/false, по умолчанию оба false; при повторе используется первое значение. pretty форматирует всю обёртку. Оба адаптера отклоняют dataset: читаются все установленные датасеты, включая собственные и форки. На SQLite возвращается только default.

В response сохраняются источники слов, признак изменения и полные условия в vocabBloom. Поле active указывает действительно активный датасет. Для пустых ответов freedictionaryapi.com резервные ссылки на источник/условия ведут на маршрут по всем датасетам, поскольку /api/v1/meta описывает только активный. Сохраняйте условия каждой использованной группы.

Запрос занимает одну единицу общего лимита публичного API. Действуют PUBLIC_API_ENABLED, анонимный CORS и формат ошибок соответствующего адаптера. GET/HEAD поддерживают ETag и Last-Modified по всем датасетам, включая правки неактивного, изменения реестра и удаление. У компактного и форматированного JSON разные ETag. Внешние сервисы не вызываются, записи разных датасетов не объединяются. Маршруты описаны в OpenAPI, справочнике API и Playground сайта; оба SDK содержат сгенерированные типы без отдельных методов нативного клиента.

curl -fsS 'http://localhost:3240/api/compat/dictionaryapi/v2/entries/en/hello/datasets'
curl -fsS 'http://localhost:3240/api/compat/freedictionaryapi/v1/entries/en/hello/datasets?translations=true'

Публичный контракт

  • Версионированный префикс. Формы ответов под /api/v1 меняются только с новым префиксом (/api/v2). Типы, на которые опираются потребители, лежат в apps/server/types/public/v1/.

  • X-API-Version: 1 в каждом ответе префикса, включая ошибки.

  • Конверт: каждый успешный ответ — это { "data": ..., "meta": { ... } } — полезная нагрузка под data (список или один объект), пагинация и счётчики под meta, никогда не вперемешку с элементами.

  • Ошибки везде под префиксом имеют форму ErrorResT, чем бы они ни были вызваны (валидация, неизвестный маршрут, rate limit):

    { "statusCode": 429, "message": "too_many_requests", "error": true }
    
  • Языки. Заголовочные слова английские, и префикс не несёт языкового сегмента: второй исходный язык вне скоупа, поэтому /api/v1/words/run не станет /api/v1/en/words/run. Язык перевода — это фильтр ответа, и он передаётся многозначным query-параметром language (/api/v1/words/run/translations?language=ru); так же работает translation_languages детального поиска (повторяющийся query-ключ). Без параметра возвращаются все языки. GET /api/v1/meta перечисляет языки, которые отдаёт экземпляр, в available_languages; потребителям стоит читать его, а не предполагать ru.

  • Rate limit. Один бюджет на IP клиента на весь префикс, PUBLIC_API_RATE_LIMIT (<запросов>/<секунд>, по умолчанию 100/60); каждый запрос стоит одну единицу, пакетный поиск включительно. Превышение отвечает 429 с ошибкой выше. API-ключей пока нет; если нужны квоты на клиента, поставьте экземпляр за reverse proxy. Единственное исключение — собственный сайт экземпляра: его серверные запросы несут INTERNAL_API_TOKEN в X-Internal-Token и не считаются (environment.md).

  • Кэшируемость. Каждый успешный GET несёт ETag, Last-Modified и Cache-Control: public, max-age=<PUBLIC_API_CACHE_MAX_AGE>, stale-while-revalidate=<то же>; условные запросы отвечают 304 — см. Кэширование. Именно поэтому у поиска есть форма GET.

Эндпоинты

Каждый успешный ответ — конверт: полезная нагрузка под data, пагинация и счётчики под meta. Типы ответов — в apps/server/types/public/v1/index.ts.

МетодПутьQuery / телоОтвет
GET/api/v1/meta—{ data: { api_version, app_version, dataset, source, dataset_version, license, …, counts, available_languages } }
GET/api/v1/openapi.json—OpenAPI 3-документ этого контракта (без конверта; см. OpenAPI-документ)
GET/api/v1/searchsearch, dataset?, type?, limit?{ data: PublicSearchWordV1T[], meta: { count, fuzzy, short_term } }
GET/api/v1/search/detailedsearch, dataset?, type?, limit?, page?, with_meanings?, with_translations?, translation_languages?{ data: PublicWordV1T[], meta: { page, limit, has_more, fuzzy, short_term } }
GET/api/v1/words/{word}—{ data: PublicWordV1T[], meta: { word, count } }
GET/api/v1/words/{word}/meanings—{ data: PublicMeaningV1T[], meta: { word, count } }
GET/api/v1/words/{word}/translationslanguage?{ data: { short_translations, meaning_translations }, meta }
GET/api/v1/words/{word}/forms—{ data: PublicWordFormV1T[], meta: { word, count } }
GET/api/v1/words/{word}/synonyms—{ data: PublicWordLinkV1T[], meta: { word, count } }
GET/api/v1/words/{word}/antonyms—{ data: PublicWordLinkV1T[], meta: { word, count } }
GET/api/v1/words/{word}/history—{ data: PublicChangeV1T[], meta: { word, count } }
GET/api/v1/words/{word}/datasets—{ data: PublicWordDatasetV1T[], meta: { word, datasets, found } }
GET/api/v1/words/{word}/datasets/{dataset}/history—{ data: PublicChangeV1T[], meta: { word, count } }
GET/api/v1/words/id/{id}—{ data: PublicWordV1T }
GET/api/v1/wordsфильтры, cursor?, limit?, with_meanings?, with_translations?{ data: PublicWordV1T[], meta: { limit, has_more, next_cursor } }
GET/api/v1/randomфильтры{ data: PublicWordV1T }
POST/api/v1/words/batch{ words: string[] } (1–50){ data: { word, count, entries: PublicWordV1T[] }[], meta: { count, not_found } }
POST/api/v1/suggestions{ headword, dataset?, word_id?, message?, kind?, edits?, author_name?, author_consent? }201 { data: { id, status } }

Те же эндпоинты можно попробовать в плейграунде сайта и на страницах Documentation в админке (api-tools.md); машиночитаемый контракт — OpenAPI-документ.

POST /api/v1/suggestions — единственная запись публичной поверхности: читатель страниц слов на сайте отправляет отзыв в очередь модерации экземпляра (страница Suggestions в админке). Эндпоинт делят два вида:

  • kind: "report" (по умолчанию) — свободный текст message о заголовочном слове; word_id по желанию называет запись из ответа о слове.
  • kind: "edit" — читатель правит форму слова целиком; предложение, которое админ может применить одним кликом, передаётся как edits, список элементов { target_type, target_id, changes }, покрывающих каждую затронутую часть: target_type — word | meaning | meaning_translation | short_translation, target_id берётся из ответов о слове, changes содержит предлагаемые значения полей (description / transcription для слова, title / definition для значения или его перевода, description для краткого перевода). Сервер в момент отправки снимает текущие значения в diff «до / после»; неизвестные поля, пустые значения, цели другого заголовочного слова и предложение, ничего не меняющее, отклоняются. Применение проводит каждый элемент через те же сервисы редактирования, что и админка, поэтому изменения записываются в историю датасета как исправление читателя и помечают запись user_modified.

Отправитель, который хочет быть указан автором, передаёт author_name (до 80 символов) вместе с author_consent: true: имя публикуется вместе с исправлением — на странице слова, в /words/{word}/history, в экспортах данных. Имя без согласия отвечает 400; согласие без имени никого не называет. По просьбе владелец удаляет имя (data.md).

Заголовочное слово должно существовать в словаре. У эндпоинта собственный rate limit — SUGGESTIONS_RATE_LIMIT, по умолчанию 5/3600 (пять сообщений в час с клиента), отдельный от общего бюджета /api/v1, — и он отвечает 503 suggestion_queue_full, когда админа ждут 500 сообщений. См. data.md. Предложение, которое админ применил, становится частью данных словаря и распространяется вместе с ними под лицензией датасета, который оно исправляет, — CC BY 4.0 у собственного датасета проекта (DATA_LICENSE.md); страницы слов говорят об этом рядом с формой.

В примерах localhost:3010 — порт запуска без Docker; установка через docker compose по умолчанию публикует API на localhost:3240 (SERVER_PORT).

curl 'http://localhost:3010/api/v1/search?search=run&limit=5'
curl 'http://localhost:3010/api/v1/search/detailed?search=run&with_meanings=true'

curl 'http://localhost:3010/api/v1/words/run'
curl 'http://localhost:3010/api/v1/words?part_of_speech=noun&word_level=B1&word_level=B2&limit=50'
curl 'http://localhost:3010/api/v1/random?part_of_speech=verb&word_level=A2'

Поиск в выбранных датасетах (#652)

GET /api/v1/search и /search/detailed принимают необязательный dataset=<имя>. Без него ответ и поведение остаются прежними: поиск в активном словаре. Явный выбор добавляет meta.dataset и не переключает активный датасет. Закрытый, удалённый или неизвестный датасет отвечает одинаковым 404 dataset_not_found, без подмены активным. Ошибка чтения выбранного словаря — 503 dataset_unavailable.

GET /api/v1/datasets возвращает публичные датасеты с их условиями в порядке установки, без счётчиков слов и открытия соединений со словарями. meta.max_search_datasets — 5. GET /api/v1/search/datasets?search=run&datasets=default&datasets=wiktionary ищет в 1–5 уникальных публичных датасетах. Группы идут в порядке установки; каждая содержит название, источник, версию, лицензию и атрибуцию. Одинаковое написание в разных словарях не объединяется.

  • status: "ok": data и meta подробного поиска; пустой data означает отсутствие совпадений.
  • status: "unavailable": error: "dataset_unavailable", без результатов и пагинации. Частичная ошибка — HTTP 200 с meta.partial: true; недоступный по публичности адресат отклоняет весь запрос с 404. Внутренние сведения об ошибке не возвращаются.
  • Фильтры, ранжирование, идентификаторы и пагинация действуют внутри каждой группы: limit 1–20 (по умолчанию 10), page 1–20 (по умолчанию 1), не более 100 записей в ответе. Чтобы перелистнуть одну группу, запросите только её имя и следующую страницу.
  • Каждый выбранный датасет расходует одну единицу общего публичного лимита, включая неудачные чтения. Бюджет списывается до поиска; при нехватке — 429 без выполнения поиска, уже списанные единицы не возвращаются. Группы читаются последовательно.
  • Выбранный поиск и список датасетов требуют проверки ETag при каждом использовании (public, no-cache, must-revalidate). Last-Modified информационный: изменения видимости могут произойти в одну секунду. Частичные ошибки — no-store, никогда 304.

На сайте раздел «Словари» позволяет выбрать до пяти словарей; без выбора используется активный. Запрос сохраняется в q, один выбор — в dataset, несколько — в повторяющемся datasets, включая смену языка интерфейса. Ссылки ведут на вкладку словаря-источника, например /ru/word/Amber?dataset=my_words. Неудачные группы показаны отдельно от пустых.

Тиры поиска и допуск опечаток

Оба поиска — чтения GET: поля передаются в строке запроса (translation_languages повторяющимся ключом, булевы как true / false), ответ несёт кэш-заголовки префикса, а сам поиск можно вставить в браузер или передать ссылкой. limit — 1–100 (по умолчанию 10) у поиска и 1–20 у детального, чей page начинается с 1. translation_languages либо опущен (все языки), либо непустой список — пустой список отвечает 400. Формы POST из альфы убраны (Удалённые алиасы).

Оба эндпоинта поиска ранжируют ответ по тирам: точное заголовочное слово, фразовые варианты, начинается с термина, фразы, содержащие его как слово, оканчивается на него, содержит где угодно. Каждый тир на Postgres обслуживается индексом (btree в байтовом порядке для префиксов, триграммный GIN для остального). Внутри тира записи «начинается с» идут в байтовом порядке (автодополнение читает их как набрано), остальные — сначала самое короткое заголовочное слово: language раньше body language для guag. Словоформа в каждом тире сводится к своей базовой записи, поэтому заголовочное слово появляется один раз, сколько бы его форм ни совпало; каждый тир заполняет оставшуюся ему часть limit разными заголовочными словами, а type действует во всех тирах, включая точный.

Когда ни один тир не совпал, вместо них отвечает нечёткий тир: заголовочные слова, чьи триграммы достаточно похожи на термин (pg_trgm, похожесть ≥ 0.3), лучшее совпадение первым. Такой ответ несёт meta.fuzzy: true и similarity (0–1) у каждого элемента — сигнал «возможно, вы имели в виду» для UI или SDK:

{ "data": [{ "word": "relieve", "similarity": 0.45, "…": "…" }], "meta": { "count": 8, "fuzzy": true } }

Примечание

fuzzy равен false, когда точные тиры что-то нашли, и когда ничего похожего нет вовсе (пустой data). Нечёткий тир есть только на экземплярах с Postgres (pg_trgm); экземпляр на SQLite на опечатку отвечает пустым списком с fuzzy: false.

Термин из одного-двух символов (после обрезки пробелов) ищется только в точном и префиксном тирах — само заголовочное слово, его фразовые варианты, базовая запись словоформы и заголовочные слова, начинающиеся с термина, — и отвечает с meta.short_term: true. Тиры по суффиксу, подстроке, фразам и нечёткий пропускаются: любую букву содержит половина словаря, так что эти тиры вернули бы произвольный срез, а не совпадение, и триграммному индексу нечего искать при менее чем трёх символах. a, I, ok, TV по-прежнему отвечают как точные заголовочные слова; пустой термин отвечает пустым списком.

Чтение по заголовочному слову

GET /api/v1/words/{word} отвечает каждой записью заголовочного слова — по элементу на часть речи, каждая с формами, значениями (определения, примеры, переводы, синонимы, антонимы) и краткими переводами. Написание сравнивается без учёта регистра; пробелы во фразах кодируйте в URL (/api/v1/words/put%20up%20with). Словоформа приводится к базовой записи: /api/v1/words/ran отвечает глаголом run (с ran среди его forms). Неизвестное написание отвечает 404 с word_doesnt_found.

Частичные чтения (/meanings, /translations, /forms) раскладывают те же записи в один список; каждый элемент несёт word_id и part_of_speech, чтобы его можно было привязать к записи. /translations делится на short_translations (по записи) и meaning_translations (по значению, с meaning_id); ?language=ru оставляет один язык. /synonyms и /antonyms перечисляют связанные заголовочные слова каждого значения — { word, meaning_id, word_id, part_of_speech }, каждое word читается через /words/{word}, — так что тезаурусу не нужна полная запись; слово без связей отвечает пустым списком.

Написания, которые различаются регистром

Собственный датасет проекта записывает заголовочные слова строчными, и поиск не зависит от регистра: Run, RUN и run отвечают одними и теми же записями с meta.word: "run". В датасете публичного источника есть слова, которые больше ничем не различаются: Polish, язык, и polish, полировать; Test, матч по крикету, и test. Это отдельные слова:

ЗапросОтветmeta.wordmeta.variants
/words/polishзаписи с написанием polishpolish["Polish"]
/words/Polishзаписи с написанием PolishPolish["polish"]
/words/POLISHзаписи обоих: слова с таким написанием в словаре нетpolish["Polish", "polish"]
/words/Run, /RUNзаписи run: в словаре одно написаниеrun[]

meta.variants перечисляет другие написания слова, каждое читается через /words/{word}, а каждая запись называет своё в поле word. Частичные чтения, история и пакетный запрос сопоставляют написание так же. Поле variants появилось после 1.0 и в контракте необязательно.

GET /api/v1/words/id/{id} — та же запись по её числовому id (id любого элемента выше).

POST /api/v1/words/batch с { "words": ["run", "ran", "put up with"] } ищет до 50 написаний за один запрос — для потребителя, обогащающего список слов, который иначе потратил бы весь бюджет rate limit на GET по одному слову. Каждое написание сравнивается как в одиночном чтении; ответ сохраняет порядок запроса, по элементу на написание — word (нормализованное написание), count и entries, ровно то, что GET /api/v1/words/{word} отдаёт под data, — схлопывая дубликаты и регистр, а написания без записи перечисляет в meta.not_found вместо ошибки. Пакет считается одним запросом для rate limit независимо от размера; экземпляры, открытые в интернет, подбирают PUBLIC_API_RATE_LIMIT с учётом этого. Будучи POST, он не несёт кэш-валидаторов; потребителю, который перечитывает одни и те же слова, выгоднее кэшируемые одиночные чтения.

Элементы слов — явная проекция строк словаря: поля PublicWordV1T и его частей в apps/server/types/public/v1/index.ts — всё обещание целиком, каждое присваивается по имени из строки (src/modules/PublicApiModule/utils/projection.ts), так что колонка, добавленная в базу, не становится публичной случайно. Редакторское состояние экземпляра — generated, generated_by_model, version, user_modified — не входит в v1; оно остаётся в админском API (GET /api/en/{id}), откуда его читает админка.

Каждое слово называет свой source — откуда взяты данные записи: vocab-bloom-hub для собственного датасета проекта, wiktionary, wordnet, princeton-wordnet, opengloss или имя собственного датасета. Это идентификатор отдающего датасета, а не вся история происхождения слова. Экземпляр отдаёт один датасет за раз (datasets.md), поэтому у всех слов ответа источник один; условия использования, которые к нему относятся, — в GET /api/v1/meta. Единственное чтение, которое отвечает из нескольких датасетов, /words/{word}/datasets, группирует записи по датасетам и называет условия каждой группы. Поле появилось после 1.0 и в контракте необязательно: сервер 1.0 его не присылает.

source есть в каждом ответе, который несёт запись, её часть или правку записи: в чтении по слову, по id, пакетном, в списке и случайной записи, в обоих поисках, в элементах /meanings, /forms, /translations, /synonyms и /antonyms, в правках /history и в записях каждой группы /words/{word}/datasets. Элемент, взятый отдельно от ответа, по-прежнему говорит, чему он атрибутирован.

Каждое слово сообщает, было ли оно изменено (modified): изменено или добавлено на этом экземпляре, то есть это не то, или не только то, что говорит источник. Лицензии датасетов требуют сообщать об этом читателю; показывайте отметку везде, где показываете запись. Отметка относится к записи — существительное может быть изменено, а глагол того же слова нет — и возвращается в false, когда обновление датасета заменяет запись содержимым источника или когда правку откатывают. В контракте поле необязательно, как и source.

Отметка есть в каждом ответе, который несёт запись или её часть: в чтении по слову, по id, пакетном, в списке и случайной записи, в обоих поисках и в частичных чтениях — элемент /meanings, /forms, /translations, /synonyms и /antonyms несёт modified записи, к которой он относится, рядом с word_id и part_of_speech. GET /api/v1/meta сообщает то же о датасете в целом: modified_entries.

История слова

GET /api/v1/words/{word}/history перечисляет, что именно изменено: правки записей слова, которые видны в отдаваемых данных, от новых к старым. История датасета начинается пустой: данные считаются такими, какими их опубликовал источник, пока не записана правка. Поэтому для слова, которое отдаётся как в источнике, ответ пустой; для неизвестного написания — 404; словоформа отвечает историей своей базовой записи.

{
  "data": [
    {
      "created_at": "2026-09-27T10:00:00.000Z",
      "word": "lamp",
      "part_of_speech": "noun",
      "entity": "meaning",
      "action": "update",
      "record": { "title": "a light", "sort_order": 1 },
      "diff": { "definition": { "before": "A light.", "after": "A device that gives light." } },
      "origin": "suggestion",
      "author": "Ada Lovelace",
      "source": "wiktionary"
    }
  ],
  "meta": { "word": "lamp", "count": 1 }
}
ПолеЧто означает
entityчто правили: word, word_form, meaning, meaning_translation, short_translation
actioncreate, update или delete
wordнаписание записи, к которой относится правка, как его называет поле word записи: вместе с part_of_speech оно указывает запись среди тех, что отдаёт /words/{word}
part_of_speechзапись слова, к которой относится правка
recordчто называет изменённую часть внутри записи — форму, заголовок значения, перевод; null для самой записи. Без идентификаторов
diff{ поле: { before, after } }: изменившиеся поля; все поля добавленной (before: null) или удалённой (after: null) части
originadmin — правка владельца; suggestion — исправление читателя, применённое владельцем
authorчитатель, приславший исправление, если он просил себя указать; иначе null
sourceисточник датасета, в котором сделана правка: чему атрибутирована изменённая запись, наряду с владельцем экземпляра

Значения — то, что говорят сами записи: связанные слова названы написанием, без идентификаторов и временных меток базы, поэтому та же история читается одинаково на другом экземпляре. Редакционное состояние экземпляра (generated, generated_by_model) в историю не попадает. Отдаётся не больше 200 правок, самые новые. Как история ведётся и как откатывается правка: datasets.md.

Слово во всех датасетах

Экземпляр может держать несколько датасетов, а отдаёт один. GET /api/v1/words/{word}/datasets читает слово из каждого датасета экземпляра: для потребителя, который сравнивает, что говорят источники (запись датасета проекта рядом с записью Wiktionary или WordNet), и владельцу ничего не нужно переключать. Ответ состоит из групп, по одной на датасет, в порядке установки датасетов, и группы никогда не объединяются:

{
  "data": [
    {
      "dataset": "default",
      "active": false,
      "source": "vocab-bloom-hub",
      "dataset_version": "1.0.0",
      "license": "CC-BY-4.0",
      "license_url": "https://creativecommons.org/licenses/by/4.0/",
      "attribution": "…",
      "attribution_url": "…",
      "notice": "…",
      "license_text": "",
      "word": "polish",
      "variants": [],
      "count": 2,
      "entries": [{ "id": 17, "word": "polish", "source": "vocab-bloom-hub", "modified": false, "…": "…" }]
    },
    {
      "dataset": "wiktionary",
      "active": true,
      "source": "wiktionary",
      "dataset_version": null,
      "license": "CC-BY-SA-4.0",
      "license_url": "https://creativecommons.org/licenses/by-sa/4.0/",
      "attribution": "…",
      "attribution_url": "https://en.wiktionary.org",
      "notice": "",
      "license_text": "",
      "word": "polish",
      "variants": ["Polish"],
      "count": 3,
      "entries": [{ "id": 90412, "word": "polish", "source": "wiktionary", "modified": true, "…": "…" }]
    }
  ],
  "meta": { "word": "polish", "datasets": 2, "found": 2 }
}
ПолеЧто оно говорит
datasetимя датасета на экземпляре: default, wiktionary, wordnet, wordnet_princeton, opengloss или имя собственного датасета/форка
activeтот ли это датасет, который отдают остальные маршруты API
source, dataset_version, license, license_url, attribution, attribution_url, notice, license_textусловия этого датасета, те же, что /meta называет для отдаваемого
word, variantsслово, которому принадлежат записи в этом датасете, и другие его написания там (по правилу чтения слова); запрошенное написание строчными и [] для датасета без такого слова
count, entriesто, что GET /words/{word} ответил бы в data, будь этот датасет отдаваемым; 0 и [] для датасета без такого слова
meta.wordнаписание, которое было запрошено
meta.datasets, meta.foundсколько датасетов держит экземпляр и в скольких из них слово есть
  • Написание сопоставляется внутри каждого датасета. polish называет одно слово в датасете, который пишет слова строчными, и одно из двух в датасете, где есть ещё и Polish, поэтому у каждой группы свои word и variants.
  • Запись говорит то, что о ней говорит её датасет: source называет источник группы, а modified сообщает, изменена ли запись на экземпляре в этом датасете. Что именно изменено, отдаёт GET /api/v1/words/{word}/datasets/{dataset}/history: ответ истории слова, прочитанный из названного датасета; 404 dataset_not_found для датасета, которого на экземпляре нет.
  • Id свои у каждого датасета: id записи имеет смысл только в её группе. /words/id/{id} читает отдаваемый датасет.
  • Условия свои у каждой группы. Запись используется на условиях группы, из которой взята, и потребитель, взявший записи из нескольких групп, связан условиями каждой: лицензия share-alike одной группы не распространяется на записи другой и не покидает свои.
  • 404 word_doesnt_found, когда слова нет ни в одном датасете. Датасет без такого слова отвечает пустой группой: то, что в источнике слова нет, тоже сведение.
  • Один запрос в счёт лимита для этого чтения слова, сколько бы ни было датасетов. Групповой поиск отдельно расходует единицу бюджета за каждый выбранный датасет.
  • Читаются только публично открытые датасеты, активные и неактивные, с историей правок и указанными авторами. Новые установки и собственные датасеты изначально закрыты.
  • Сообщение об ошибке указывает просматриваемый датасет. Передавайте dataset группы в POST /api/v1/suggestions: все IDs проверяются в этом датасете и для указанного слова. Без dataset используется активный словарь. Закрытый, неизвестный или удалённый датасет возвращает 404 dataset_not_found, без подмены цели. Лимит запросов клиента общий для всех датасетов, ограничение очереди — отдельное для каждого. После закрытия датасета администратор по-прежнему может проверить и применить полученные исправления.

Экземпляр с одним датасетом, то есть любой экземпляр на SQLite, отвечает одной группой.

Что с этим делает потребитель

Отметка и история существуют потому, что лицензии данных требуют сообщать об изменениях, и эта обязанность переходит к тому, кто показывает данные дальше:

Вы читаетеВы делаете
attribution, license_url из /metaпоказываете строку атрибуции и ссылку на лицензию везде, где показываете данные
modified: true у слова или его частисообщаете рядом с записью, что она изменена: это не то, или не только то, что опубликовал источник
/words/{word}/historyпо желанию: показываете, что изменено, и указываете автора исправления (author), если он назван
группу из /words/{word}/datasetsделаете всё перечисленное для каждой группы, на условиях этой группы: записи двух групп показываются с двумя атрибуциями
license из /meta с окончанием -SA-4.0сохраняете под той же лицензией то, что построено на данных, включая изменённые записи
origins, contributions, licenses словасохраняете атрибуцию, уведомления и связанные лицензии каждого источника и вклада; основная лицензия датасета их не заменяет
непустой license_text из /metaдержите этот текст при каждой копии данных: показываете его или ссылку на страницу с ним
modified_entries из /meta больше 0знаете, что экземпляр отдаёт данные, отличающиеся от источника

Читайте эти значения из API, а не зашивайте в код: владелец в любой момент может активировать другой датасет, изменить запись или откатить правку. Условия полностью: DATA_LICENSE.md.

Список с фильтрами и курсорная пагинация

GET /api/v1/words перечисляет записи в порядке (word, id) — заголовочное слово по байтам без учёта регистра (LOWER(word) COLLATE "C" на Postgres, LOWER(word) на SQLite: a bag of wind раньше aaron burr, какой бы ни была локаль базы, а грамматический шаблон с заглавной буквы — It’s the first time … — стоит среди i, а не раньше a), затем id. Фильтры: part_of_speech, word_level, language_register, category, area_variant, form_of_word, плюс search и is_obsolete. Каждый enum-фильтр принимает одно значение или повторяющийся ключ; значения одного фильтра объединяются через OR, разные фильтры — через AND (?word_level=B1&word_level=B2&part_of_speech=noun — существительные B1 или B2). search оставляет заголовочные слова, начинающиеся с префикса, без учёта регистра (?search=ru — run, rung, runner, …; префикс фразы сохраняет пробелы); упорядоченный как список и кэшируемый как любой GET, он и есть то, по чему стоит листать автодополнению или алфавитному указателю вместо поиска. is_obsolete=true / false оставляет только устаревшие или только актуальные записи. Без form_of_word перечисляются только базовые формы; словоформы доступны через forms базовой записи или явно (?form_of_word=past_simple). Элементы не несут значений и кратких переводов, пока не передано with_meanings=true / with_translations=true.

Страницы читаются курсором: возьмите meta.next_cursor страницы и передайте его обратно как ?cursor= (с теми же фильтрами), чтобы получить следующую; next_cursor равен null на последней странице, а has_more говорит, есть ли она. В отличие от номеров страниц курсор никогда не повторяет и не пропускает элемент, пока словарь редактируется. limit — от 1 до 100, по умолчанию 20.

Важно

Курсор непрозрачный — не собирайте его руками; нераспознанное значение отвечает 400 с invalid_cursor, как и курсор, взятый со страницы, прочитанной с другими фильтрами (он несёт их отпечаток); ?cursor= без значения — первая страница.

Случайная запись

GET /api/v1/random отвечает одной случайной записью, подходящей под те же фильтры, что и список (базовые формы, если не задан form_of_word); 404, когда ничего не подходит. Выбор — это поиск по индексу, а не ORDER BY random(), дёшево на словаре в 300 тысяч строк; записи сразу после пропуска в id выпадают чуть чаще, что для «слова дня» не имеет значения.

Meta

GET /api/v1/meta описывает, что отдаёт экземпляр: api_version ("1"), app_version (package.json сервера), датасет — dataset (его имя на экземпляре, default у того, с которым экземпляр появился), source (откуда его данные: vocab-bloom-hub, wiktionary, wordnet, princeton-wordnet, opengloss или имя собственного датасета) и dataset_version (версия датасета, из которого словарь импортирован в последний раз, либо версия собственного датасета или форка, заданная владельцем; null, если версия неизвестна; это строка для показа, формат не обещан: 1.0.0 у датасета проекта, день выгрузки у Wiktionary, 2026.09.25, редакция у WordNet, 2025, или 2.4 у OpenGloss: datasets.md), — условия использования данных — license (идентификатор SPDX, "CC-BY-4.0" у датасета проекта, "CC-BY-SA-4.0" у Wiktionary), license_url, attribution (строка, которую потребитель обязан показывать, см. DATA_LICENSE.md) и attribution_url (куда ведёт эта строка, null, когда датасет ссылку не называет), notice (уведомление о происхождении, которое стоит передать читателям, — у датасета проекта: данные сгенерированы языковыми моделями и не проверены людьми, data.md; пустая строка, когда у датасета его нет), — counts (записи, слова, фразы, грамматические шаблоны, словоформы, значения, переводы значений и краткие переводы; счётчики обновляются не чаще раза в минуту) и available_languages: source, язык заголовочных слов (["en"]), и translations, языки, которые может нести перевод в этой сборке (["ru", "es", "fr", "de", "pt", "zh", "ar"]), — значения, которые принимает ?language=. Они описывают схему, а не данные: язык перечислен независимо от того, импортирован ли уже хоть один перевод на него.

Условия относятся к активному датасету и меняются, когда владелец активирует другой (datasets.md): читайте их из meta, а не зашивайте в код лицензию датасета проекта. license_text содержит полный текст уведомлений, которые источник датасета требует хранить вместе с данными, — лицензию WordNet, лицензию CMU Pronouncing Dictionary; пустая строка у датасета, лицензия которого называется одной ссылкой. Показывайте этот текст или ссылку на страницу с ним везде, где лицензия требует свой текст на каждой копии. dataset, source, attribution_url и license_text появились после 1.0 и в контракте необязательны.

modified_entries считает заголовочные слова, которые экземпляр отдаёт с записями, изменёнными или добавленными на нём: 0 означает, что данные такие, какими их опубликовал источник, любое другое число означает, что потребитель показывает данные, отличающиеся от источника, и должен об этом сообщать (История слова). Значение обновляется вместе со счётчиками, не чаще раза в минуту. В контракте поле необязательно.

OpenAPI-документ

Контракт выше — это ещё и документ OpenAPI 3, собранный из контроллеров, DTO и типов ответов работающего кода — ничего не написано руками, поэтому расхождение невозможно. GET /api/v1/openapi.json отдаёт его с любого экземпляра, с кэш-заголовками префикса; apps/server/openapi/public-v1.json — закоммиченная копия, из которой генерируются SDK и сайт. Где что используется и цепочка регенерации после изменения: api-tools.md. Генератор поднимает приложение без прослушивания порта, на SQLite в памяти, поэтому ему не нужен .env, а его вывод зависит только от исходного кода; admin.json — весь API вместе с админской поверхностью — пишется рядом и игнорируется git.

Схемы ответов. Контроллеры типизируют ответы TypeScript-контрактом в apps/server/types/public/v1, который Swagger не видит, поэтому генератор читает эти типы через ts-json-schema-generator, преобразует результат в компонентные схемы OpenAPI 3.0 (nullable для | null, enum'ы, дженерики раскрыты; src/openapi/json-schema-to-openapi.ts) и коммитит их в openapi/public-v1.schemas.json. src/openapi/public-responses.ts сопоставляет каждой публичной операции тип ответа и статусы ошибок; сборка документа падает для маршрута, которого там нет, так что эндпоинт не может выйти нетипизированным. Работающий сервер отдаёт закоммиченные схемы — без TypeScript во время выполнения. test/public-schemas.e2e-spec.ts вызывает каждую операцию на заполненном словаре и строго проверяет реальные тела по отдаваемым схемам (неизвестные поля — ошибка), что и делает схемы надёжными для генераторов SDK.

SDK

  • Node.js / TypeScript — @vocab-bloom-hub/client: метод на эндпоинт (включая пакетный поиск и тезаурус), типы, сгенерированные из openapi/public-v1.json, типизированные ошибки, итерация по курсору и по страницам, необязательный ETag-кэш, опциональные повторы на 429 / 5xx с учётом Retry-After, версионированный User-Agent. ESM и CommonJS, у каждого свой файл деклараций. В npm с первой альфы.
  • Python — vocab-bloom-hub: sync и async клиенты на httpx, pydantic-модели из того же спека, типизированные исключения, опции на запрос, опциональные повторы, итерация по курсору и по страницам, ETag-кэш, words_dataframe() для ноутбуков. В PyPI с первой альфы.

Кэширование

Данные словаря меняются редко, поэтому публичные чтения GET рассчитаны на кэширование браузерами, CDN и reverse proxy. Каждый успешный ответ GET несёт:

ЗаголовокЗначение
ETagслабый, хэш JSON-тела: W/"…". Меняется ровно тогда, когда меняется ответ
Last-Modifiedсамое свежее изменение где угодно в словаре (записи, слова, значения, переводы), обновляется раз в минуту; для чтений из всех датасетов: самое свежее изменение любого датасета экземпляра и их набора (установка, активация, удаление)
Cache-Controlpublic, max-age=<PUBLIC_API_CACHE_MAX_AGE>, stale-while-revalidate=<то же> (по умолчанию 3600): общий кэш держит ответ час и ещё час может отдавать его устаревшим, пока перепроверяет в фоне; public, no-cache, когда переменная равна 0

HEAD отвечает теми же тремя заголовками, что и GET этого адреса, поэтому кэш, проверяющий свежесть через HEAD, видит те валидаторы, которые сохранил. Клиент, отправляющий тег обратно, перепроверяет ответ одним обменом без тела:

curl -i 'http://localhost:3010/api/v1/words/run'                                   # 200, ETag: W/"…"
curl -i -H 'If-None-Match: W/"…"' 'http://localhost:3010/api/v1/words/run'         # 304, без тела
curl -i -H 'If-Modified-Since: <Last-Modified>' 'http://localhost:3010/api/v1/words/run'

Инвалидация неявная: ETag — хэш содержимого, поэтому первый ответ после правки или импорта несёт новый тег, и 304 никогда не отдаётся для изменённых данных. Last-Modified информационный (отстаёт не больше чем на минуту) — когда отправлены оба валидатора, решает ETag. CDN или прокси впереди держит ответ max-age, затем перепроверяет; на экземпляре, словарь которого редактируется вживую, уменьшите PUBLIC_API_CACHE_MAX_AGE (или поставьте 0).

Примечание

Не кэшируются: два запроса POST, пакетный поиск и предложение правки (HTTP-кэши не хранят POST), каждая ошибка под префиксом (Cache-Control: no-store, так что промах или 429 никогда не отдаются из кэша) и всё под админскими префиксами (no-store на каждом ответе, включая 401 и 404 выключенной поверхности).

Удалённые алиасы

POST /api/en/search и POST /api/en/search/detailed — поисковые маршруты времён до публичного API, которые всю альфу отвечали голыми телами и заголовком Deprecation: true, — удалены с v0.2.0-beta.1, как и формы POST /api/v1/search и POST /api/v1/search/detailed, служившие мостом в альфе (те же поля в JSON-теле). Все четыре отвечают 404, как любой неизвестный маршрут; преемники — GET /api/v1/search и GET /api/v1/search/detailed с конвертом { data, meta }.

Экземпляр только с публичным или только с админским API

Две переменные решают, какие поверхности отдаёт экземпляр; обе по умолчанию включены:

ПеременнаяЭффект при false
PUBLIC_API_ENABLED/api/v1/* отвечает 404, как будто маршрутов нет
ADMIN_API_ENABLED/api/en/*, /api/settings, /api/auth отвечают 404; админка не может войти

Внимание

Выключить обе — ошибка конфигурации, и сервер отказывается стартовать.

Демо или встроенный экземпляр работает с ADMIN_API_ENABLED=false (данные правят в другом месте и переносят экспортом / импортом датасета, см. offline-import.md); внутренний редакторский экземпляр, который не должен читаться снаружи, работает с PUBLIC_API_ENABLED=false.

Пробы: /api/health и /api/ready

Два маршрута живут под /api, но не принадлежат ни одной поверхности: liveness-проба GET /api/health (200 { status: 'ok', version }) и readiness-проба GET /api/ready (200 { status: 'ok' } или 503 { status: 'error', reason }, пока база не отвечает (database_unreachable), идёт остановка с завершением запросов (shutting_down), ещё идёт автоматическая загрузка словаря при первом старте (importing, с percent и stage) или эта загрузка упала (import_failed, с error)). Им не нужен логин, они не ограничены rate limit, игнорируют обе переменные выше и отправляются с Cache-Control: no-store. Они не входят в контракт /api/v1 — публичный OpenAPI-документ их не перечисляет, SDK их не оборачивают; они для менеджеров процессов, оркестраторов и reverse proxy (deployment/README.md).

Приватный админский API за reverse proxy

Когда один экземпляр отдаёт обе поверхности, а из интернета должен быть доступен только словарь, откройте /api/v1 и отгородите админские префиксы (/api/en, /api/settings, /api/auth) на прокси — списком адресов или basic auth — либо отдавайте их только из приватной сети. Проверенные конфиги Caddy и nginx для этого, TLS, настройка TRUST_PROXY, нужная rate limit за прокси, и другие профили экспозиции — в deployment/reverse-proxy.md.

Задайте CORS_ORIGINS origin'ами, которым можно вызывать API из браузера; клиенты вроде curl CORS не затрагивает.

Источники слова и связанные лицензии

Ответы со словами, результатами поиска, формами и отдельными частями слова могут содержать необязательные origins[], contributions[] и licenses[]. Источники относятся к базовому слову и части речи. Каждый источник хранит название, версию (или null), ссылки url и record_url, атрибуцию, обязательные уведомления и лицензии: идентификатор, если известен, название, URL и полный текст, когда он требуется. origin_id связывает лицензию с источником или вкладом; сохраняйте эту связь при показе атрибуции.

contributions содержит условия тех содержательных правок, которые ещё действуют для слова. Неизменённая копия или слово в форке сохраняет исходные источники без добавления лицензии принимающего датасета. Реальная правка добавляет снимок условий вклада и его лицензию; откат всех таких правок убирает вклад из текущего ответа. Смена названия, версии или лицензии датасета не переписывает ранее сохранённые снимки.

scope: "dataset" означает, что источник не уточнил распределение материала по словам. method: "manual" обозначает сведения, внесённые пользователем, а "dataset" — условия, сохранённые из датасета. Дата recorded_at может быть неизвестна (null). Повторные копирования и форки записываются в acquisitions[]: method ("copy" или "fork"), дата и ревизия. Поле via может назвать промежуточный датасет, не добавляя его лицензию к неизменённому слову. inherited защищает унаследованные условия от обычного редактирования. license_relation: "all" означает совместное применение условий, "any" — варианты, явно предложенные источником. Сам список или произвольная лицензия не гарантируют совместимость.

Группы датасетов и /meta передают источники и описание; основная license остаётся лицензией по умолчанию и лицензией вклада датасета. source обозначает отдающий датасет. История может содержать inherited_from, причину исправления сведений reason и снимок contribution. У каждого слова OpenGloss сохраняется его источник; у слов с отметкой происхождения из WordNet добавляются источник Princeton WordNet 3.0 и его лицензия. Одного source: "opengloss" или license из /meta недостаточно, чтобы определить все условия конкретного слова.

Поля необязательны для совместимости со старыми серверами v1 и включены в модели обоих SDK. Редактирование описано в руководстве по датасетам, перенос и совместимость — в руководстве по импорту.