API для разработчиков

Публичный REST-API Naminori — стройте свои клиенты повторений, плагины Anki/Obsidian, виджеты, скрипты майнинга. Все данные — только ваши (владельца токена); лукап и озвучка — общие сервисы.

Авторизация

Все запросы требуют персональный токен в заголовке. Токен создаётся после входа в аккаунт — в настройках → «API-токены». Он даёт доступ ТОЛЬКО к данным вашего аккаунта — храните его как пароль, при утечке отзовите в настройках.

Authorization: Bearer nmn_xxxxxxxxxxxxxxxxxxxxxxxx

Альтернативно — заголовок X-Naminori-Token. CORS открыт (авторизация по токену, не по cookie), так что API можно дёргать из браузерных расширений и локальных приложений.

Формат и лимиты

  • Ответы — JSON (кроме /api/v1/audio и /api/v1/dicts/… — бинарные файлы).
  • Ошибка — это JSON { "error": "код", "message"?: "…", "details"?: {…} }: в error короткий машинный код, в message — готовая русская фраза для показа человеку (там, где она есть), в details — разбор по полям при невалидном теле.
  • Коды: 400 кривой запрос/тело · 401 нет/неверный токен · 404 не найдено или чужое · 409 конфликт (например, удаление непустой колоды) · 413 слишком длинный текст/большой файл · 415 неподдерживаемый тип файла · 429 превышен лимит · 500/502/503 сбой на нашей стороне (озвучка, сборка словаря) — запрос можно повторить позже.
  • Неизвестное значение фильтра — это ошибка, а не «фильтра нет»: /cards, /queue и /due отвечают 400 на чужой type, state, suspended или неверный формат deckId.
  • Лимиты — на пользователя, окно 1 минута. Чтение (/me, /due, /queue, /cards, /lookup, /dict) — 240 запросов/мин. Запись: /review и /vocab/cards — 240/мин, /review/batch — 60/мин (до 500 оценок в каждом), /vocab/add и /audio — 120/мин, /learn-new, создание и правка колод, /media/store — 60/мин.
  • Деструктивные операции доступны только в редакторе личных колод (POST /api/v1/vocab/cards и DELETE /api/v1/vocab/decks/{id}). Удалить аккаунт, изменить настройки или выполнить админ-операцию через API нельзя.

Профиль и статистика

POST/api/media/animate

Собрать АНИМИРОВАННЫЙ кадр сцены из последовательности картинок (multipart/form-data, поле frames повторяется; 2–24 кадра, до 400 КБ каждый). Возвращает такой же относительный URL, как /api/media/store. Зачем отдельно: браузер анимированный AVIF кодировать не умеет — canvas.toBlob отдаёт один кадр. Зачем вообще: две секунды движения весят около 12 КБ, то есть дешевле статичного скриншота, а сцена по ним узнаётся.

curl -X POST https://naminori.fun/api/media/animate   -H "Authorization: Bearer nmn_…"   -F "frames=@f001.webp" -F "frames=@f002.webp" -F "frames=@f003.webp"
→ { "ok": true, "url": "/api/media/9f1c….avif", "bytes": 12043 }
GET/api/v1/me

Профиль: имя, целевой уровень, стрик, сколько выучено грамматики и слов.

{ "name": "...", "targetLevel": "N4", "streak": 16, "grammarLearned": 461, "vocabLearned": 2182 }
GET/api/v1/overview

Весь кабинет ОДНИМ запросом: профиль, стрик (текущий + рекорд), сегодняшний прогресс (сделано / осталось по типам), состояние колоды (всего / новые / изучено), активность за 7 дней, время ближайшей карточки и список личных колод. Собрано для Telegram-бота, но подходит любому клиенту, которому нужен «экран дня» без пяти запросов подряд.

{ "name": "…", "targetLevel": "N4", "streak": { "current": 16, "best": 41 },
  "today": { "doneVocab": 40, "doneGrammar": 12, "doneTotal": 52,
             "remainingVocab": 6, "remainingGrammar": 0, "remainingTotal": 6 },
  "deck": { "vocab": { "total": 2182, "new": 90, "learned": 2092 }, "grammar": { … },
            "totalCards": 2643, "newCards": 114, "learnedCards": 2529 },
  "daily": [ { "date": "2026-07-02", "reviews": 46 } ],
  "nextDue": { "vocab": "2026-07-03T09:12:00.000Z", "grammar": null },
  "decks": [ { "id": "…", "name": "Аниме", "isDefault": false,
               "cardCount": 312, "newCount": 8, "dueCount": 14 } ] }
GET/api/v1/stats

Дневная статистика занятий — для графиков и heatmap (виджеты, Obsidian, дашборды).

Параметры: days=1..90 (30)

{ "days": 30, "streak": 16, "totals": { "reviewsDone": 812, "newGrammar": 24, "newVocab": 310 },
  "daily": [ { "date": "2026-07-02", "reviewsDone": 46, "newGrammar": 2, "newVocab": 25 } ] }
GET/api/v1/stats/today

Сводка дня в стиле Anki для экрана «сессия завершена»: сколько карточек, сколько времени, секунд на карточку и фраза под темп. cards — все повторения за день; minutes/secPerCard считаются по записям с замером времени (elapsedMs в POST /api/v1/review).

{ "cards": 52, "timeMs": 684000, "minutes": 11.4, "secPerCard": 13.2,
  "phrase": "Ровный темп — так и держите." }

Повторения (SRS)

Полный цикл внешнего клиента повторений: очередь → показ карточки → оценка. Расписание — тот же FSRS, что на сайте.

GET/api/v1/due

Сколько карточек ждёт повторения сейчас (грамматика + слова). Необязательный deckId сужает счётчик слов до одной личной колоды.

Параметры: deckId=<uuid>

{ "grammar": 26, "vocab": 46, "total": 72 }
GET/api/v1/queue

Содержимое очереди повторений. У type=vocab ДВЕ формы элемента, различайте их по полю kind: "vocab" — слово из словаря (jp/kana/ru + уровень, часть речи, пример, питч и контекст майнинга), "anki" — карточка из импорта .apkg или созданная вручную, у неё front/back/reading вместо jp/kana/ru. Клиент, написанный только под "vocab", покажет на каждой импортированной карточке undefined. type=grammar — cloze-вопросы, форма совсем другая (см. второй пример). Среди них идут ДОБИВАНИЯ («призраки», ghost: true) — повторения проваленных пунктов на коротких сроках 12 ч → 1 д → 3 д, исчезающие после трёх успехов. У ДОБИВАНИЯ ЕСТЬ СРОК ЖИЗНИ: брошенное больше чем на 30 суток просрочки, оно списывается и из очереди пропадает — насовсем, пока тот же пункт не провалят снова. Часы просрочки стоят, пока человек в режиме каникул. Отдельного поля у этого нет: списанное добивание просто не приходит ни в /queue, ни в счётчик /due. cardId у них всегда null (оценивается не карточка пункта, а само добивание); добавьте ghosts=1 — и у них появится ghostId, которым их оценивает POST /api/v1/review { ghostId, rating }. Без этого параметра ответ ровно прежний, поле не появляется нигде: клиент, который про добивания не знает, отбросит их по пустому cardId, как отбрасывал вчера.

Параметры: type=vocab|grammar (vocab) · limit=1..100 (50; в режиме order=stable — до 500) · deckId=<uuid> · order=stable — устойчивая выдача с курсором (см. ниже) · cursor=<строка из nextCursor> · ghosts=1 — отдавать ghostId у добиваний грамматики (иначе их нечем оценить) · context=1 — приложить к каждому слову живое предложение из корпуса (режим «в предложении»: фраза подбирается так, чтобы незнакомых слов в ней было как можно меньше). По запросу, а не всегда: клиенту вроде бота лишний текст в каждой карточке ни к чему. Поле context равно null, если для слова корпус ничего не нашёл — тогда режим просто не предлагают.

{ "type": "vocab", "total": 46, "items": [
  { "cardId": "…", "kind": "vocab", "jp": "勉強", "kana": "べんきょう", "ru": "учёба",
    "level": "N5", "partOfSpeech": "сущ.", "exampleJp": "…", "exampleRu": "…",
    "accent": 0, "pitch": { "reading": "べんきょう", "type": "平板", "accent": 0 },
    "video": { "videoId": "…", "startMs": 61200, "endMs": 63400, "subtitle": "…" },
    "textContext": { "kind": "reading", "sourceTitle": "…", "snippet": "…" },
    "context": { "jp": "毎日日本語を勉強しています。", "kana": "まいにち…", "ru": "Каждый день учу японский." } },
  { "cardId": "…", "kind": "anki", "front": "猫", "back": "кошка", "reading": "ねこ" } ] }

// type=grammar — другая форма элемента:
{ "type": "grammar", "total": 26, "items": [
  { "cardId": "…", "ghost": false,
    "point": { "slug": "te-iru", "title": "ている", "meaningRu": "длительное действие" },
    "question": "今、本を___。", "questionKana": "いま、ほんを___。",
    "sentenceJp": "今、本を読んでいます。", "sentenceRu": "Сейчас я читаю книгу.",
    "answer": "読んでいます", "alternatives": [ "読んでいる" ], "hintRu": "…" },
  // добивание проваленного пункта — только при ghosts=1 у него есть ghostId
  { "cardId": null, "ghost": true, "ghostId": "…", "point": { … }, "question": "…", … } ] }
GET/api/v1/queue?order=stable

СКАЧАТЬ ОЧЕРЕДЬ ЦЕЛИКОМ — для клиента, который занимается без сети. Обычная выдача перемешана и обрывается на limit, поэтому «взять следующие сто» через offset дало бы случайные сто, а не следующие — offset у этой ручки нет вовсе и он молча игнорируется. С order=stable сервер перестаёт тасовать (повторения по сроку, затем новые по порядку колоды), а в ответе появляются hasMore и nextCursor — непрозрачная строка, которую надо вернуть как есть в параметре cursor. Повторяйте, пока nextCursor не станет null; limit шлите на КАЖДОЙ странице (без него страница снова 50). Курсор помнит выборку: с чужим или испорченным курсором ответ 400 invalid cursor, а не тихий кусок чужой очереди. СКЛАДЫВАЙТЕ ПО cardId: пока очередь не меняется, страницы стыкуются без дублей и без дыр, но если между страницами вы отвечаете на сайте, карточка с новым сроком может приехать второй раз (а появившаяся в уже пройденном отрезке — приедет на следующей выгрузке). total — полный долг очереди, знаменатель прогресса; в обычном (перемешанном) режиме то же поле означает длину выданной порции, так что два режима дадут разные числа. Порядок здесь всегда «повторения по сроку, затем новые по колоде» и НЕ повторяет настройки очереди с сайта (перемешивание/порядок новых) — у вас на руках вся очередь, раскладывайте её как нужно. Дневной лимит новых слов общий на все страницы, а не на каждую.

Параметры: order=stable · cursor=<nextCursor> · limit=1..500 (50) · type, deckId, context — как обычно

# страница за страницей, пока nextCursor не станет null
curl "https://naminori.fun/api/v1/queue?type=vocab&order=stable&limit=500" -H "Authorization: Bearer nmn_…"
→ { "type": "vocab", "total": 1072, "items": [ … 500 … ],
    "hasMore": true, "nextCursor": "eyJ2IjoxLCJxIjoi…" }

curl "https://naminori.fun/api/v1/queue?type=vocab&limit=500&cursor=eyJ2IjoxLCJxIjoi…" -H "Authorization: Bearer nmn_…"
→ { …, "hasMore": false, "nextCursor": null }
POST/api/v1/review

Оценить карточку: FSRS-перепланирование + лог + дневная статистика. rating: 1 Again · 2 Hard · 3 Good · 4 Easy. Необязательный elapsedMs (миллисекунды, 0…30 минут) — сколько времени ушло на карточку; без него «сводка дня» у вашего клиента всегда покажет 0 с/карточку. Необязательный reviewedAt (ISO с зоной, не дальше 72 часов назад) — когда человек ответил на самом деле: FSRS считает интервал от него, а не от момента приёма. День активности (серия, топ) при этом считается по дню ПРИЁМА оценки: чинить серию задним числом нельзя. ДОБИВАНИЯ («призраки»). Тело { ghostId, rating } оценивает добивающее повторение проваленного пункта грамматики — идентификатор берётся из /api/v1/queue?type=grammar&ghosts=1. Правила те же, что на сайте: успех — это оценка ≥ 3 (двойка «Hard» для добивания считается провалом), провал ОБНУЛЯЕТ счётчик успехов и возвращает короткий срок, три успеха закрывают добивание навсегда. Ответ у него другой: вместо состояния FSRS — successes, resolved и due. Уже закрытое добивание отвечает 404 — как и удалённое: в очереди его больше нет, а провальная оценка со старым списком иначе воскрешала бы его и обнуляла три сделанных успеха. Тем же 404 отвечает ПРОСРОЧЕННОЕ БОЛЬШЕ ЧЕМ НА 30 СУТОК добивание: у него вышел срок жизни, и принять оценку значило бы вернуть списанное в очередь. Для вас это одно событие — «в очереди его нет», — поэтому и ответ один. Скачанную очередь держите не дольше суток: срок добивания считается на момент ПРИЁМА оценки, а не на reviewedAt. ЗАВОДИТ добивания клиент, приславший "ghosts": true в теле: тогда второй Again подряд по грамматике порождает призрака, как на сайте, а его id уезжает в undo.spawnedGhostId, чтобы откат ответа убрал за собой и добивание. Без этого признака не заводится ничего — и это защита от долга, который вам нечем закрыть: ghostId без ghosts=1 в очереди не появляется, а /api/v1/due добивания считает, так что счётчик на телефоне не погас бы никогда. Признак один и тот же в обе стороны: показываете добивания — получаете их. cardId и ghostId можно слать явным null (строгим клиентам так проще) — null равнозначен отсутствию поля, но ровно одно из двух должно быть заполнено. Ответ содержит undo-токен для отката оценки. Накопленное за офлайн отправляйте ПАКЕТОМ — /api/v1/review/batch: по одной оценке на запрос пятьсот повторений упираются в лимит 240 запросов в минуту.

curl -X POST https://naminori.fun/api/v1/review \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"cardId":"<uuid>","rating":3,"elapsedMs":4200}'
→ { "ok": true, "state": "review", "due": "2026-07-06T…", "stability": 4.2,
    "undo": { "cardId": "…", "logId": "…", "snapshot": { … } } }

# «умею добивания»: провал грамматики может завести призрака
  -d '{"cardId":"<uuid>","rating":1,"ghosts":true}'
// у оценки, заведшей добивание, в undo добавится "spawnedGhostId": "<uuid>"

# добивание («призрак») — ghostId вместо cardId, и ответ другой формы
curl -X POST https://naminori.fun/api/v1/review   -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json"   -d '{"ghostId":"<uuid>","rating":3}'
→ { "ok": true, "ghostId": "…", "ghost": true, "successes": 2,
    "resolved": false, "due": "2026-09-12T…", "undo": { … } }
POST/api/v1/review/batch

Оценить до 500 карточек ОДНИМ запросом — для клиента, который копил оценки без сети. Позиции применяются строго по массиву: одна карточка может встретиться дважды (шаги заучивания), и вторая оценка ложится поверх первой. reviewedAt — момент ответа, а не приёма (окно: 72 часа назад, до 5 минут вперёд на убегающие часы); он же ключ повтора: тройка (вид позиции, карточка, reviewedAt) — вид (карточка или добивание) в ключе не для красоты: лог добивания пишется на карточку его пункта, и без вида карточка со своим же добиванием в один момент времени съедали бы друг друга. Оборвалась связь на ОТВЕТЕ — шлите ту же пачку снова: принятые позиции ответят duplicate, расписание второй раз не сдвинется, счётчик дня не удвоится, а добивание не потеряет успех (три успеха закрывают его навсегда — зачтённый дважды повтор съел бы его за один заход). Оценка без reviewedAt такой защиты не имеет. Ответ всегда 200: судьба каждой позиции лежит в results НА ЕЁ МЕСТЕ по индексу — applied | duplicate | not_found (карточку удалили на другом устройстве) | out_of_range (момент вне окна — перешлите без reviewedAt). undo-токен применённой позиции принимает та же /review/undo — но учтите: отмена стирает запись журнала, то есть сам ключ повтора, и присланная ПОСЛЕ отмены та же оценка применится заново. elapsedMs: до 30 минут на карточку и не больше 6 часов суммарно на запрос — что сверх, применяется без времени. ДОБИВАНИЯ идут в том же пакете: позиция { ghostId, rating } вместо { cardId, rating }; в results у неё стоит ghostId, а вместо состояния FSRS — successes, resolved и due; закрытое добивание отвечает not_found, как и удалённое, и так же — просроченное больше чем на 30 суток (у добивания есть срок жизни, см. POST /review). ЗАВОДЯТСЯ добивания по признаку "ghosts": true рядом с reviews (на весь пакет) — тот же признак, которым вы просите ghostId у очереди; без него провалы грамматики призраков не порождают. Битая позиция отвергает весь запрос (400), но в details теперь виден её НОМЕР — ключ вида reviews.317.cardId. День активности (серия, топ) считается по дню приёма пачки, даже если reviewedAt вчерашние. Лимит 60 запросов в минуту.

Параметры: тело: { reviews: [ { cardId, rating, elapsedMs?, reviewedAt? } … ], ghosts?: true } — от 1 до 500 позиций; вместо cardId у позиции может стоять ghostId (добивание), но не оба сразу (незаполненное поле допускается как null); ghosts: true разрешает провалам грамматики заводить добивания

curl -X POST https://naminori.fun/api/v1/review/batch \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"reviews":[
        {"cardId":"<uuid>","rating":3,"elapsedMs":4200,"reviewedAt":"2026-09-08T09:12:44.000Z"},
        {"cardId":"<uuid>","rating":1,"reviewedAt":"2026-09-08T09:13:02.000Z"} ]}'
→ { "ok": true, "applied": 2, "duplicate": 0, "failed": 0,
    "results": [ { "status": "applied", "cardId": "…", "state": "review",
                   "due": "2026-09-15T…", "stability": 7.1, "undo": { … } },
                 { "status": "applied", … } ] }
POST/api/v1/review/undo

Отменить последнюю оценку («↩ как в Anki»): тело — объект undo из ответа POST /review или из результата пакета. Восстанавливает прежнее FSRS-состояние, стирает лог, −1 к дневному счётчику за сегодня; если отменяемый ответ завёл добивание (spawnedGhostId в токене) — убирает и его. Токен добивания ({ ghostId, logId, successes, due, resolvedAt }) принимается той же ручкой и возвращает счётчик успехов, срок и признак «добит». Ключ повтора живёт в этой же записи журнала, поэтому оценка, присланная снова уже ПОСЛЕ отмены, применится как новая — не ретрайте очередь отправки после отката.

curl -X POST https://naminori.fun/api/v1/review/undo \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"cardId":"<uuid>","logId":"<uuid>","snapshot":{…}}'
→ { "ok": true, "cardId": "…" }
POST/api/v1/learn-new

Добавить порцию НОВЫХ слов или пунктов грамматики в SRS (аналог «Учить новое» на сайте) с дневными лимитами vocabPerDay/newPerDay. added>0 → перезапросите /queue; added=0 и remaining=0 — лимит дня; added=0 и remaining>0 — новый контент закончился.

Параметры: type=vocab|grammar · count=1..50 (10) · level=N5..N1 · deckId (только vocab)

curl -X POST https://naminori.fun/api/v1/learn-new \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"type":"vocab","count":10}'
→ { "ok": true, "added": 10, "remaining": 15 }
GET/api/v1/cards

Мои SRS-карточки с фильтрами и пагинацией. Для vocab/grammar в ответе есть «лицо» (слово/пункт). Неизвестное значение фильтра — 400 с details, а не «молча вся колода»: опечатка в type/state диагностируется сразу.

Параметры: type=vocab|grammar|kana|custom|pitch · state=new|learning|review|relearning · suspended=true|false · deckId=<uuid> · limit=1..200 (50) · offset

{ "cards": [ { "cardId": "…", "type": "vocab", "state": "review", "due": "…",
  "suspended": false, "word": { "jp": "猫", "kana": "ねこ", "ru": "кошка" } } ], "limit": 50, "offset": 0 }
POST/api/v1/cards

Управление карточкой: suspend/unsuspend (убрать из очереди, обратимо), bury/unbury (спрятать до следующего учебного дня). Удаления здесь нет сознательно — оно живёт в редакторе личных колод (POST /api/v1/vocab/cards).

curl -X POST https://naminori.fun/api/v1/cards \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"cardId":"<uuid>","action":"suspend"}'

Личные колоды

Колоды слов в стиле Anki — те же, что на /vocab/decks. Не путать с /api/v1/decks: там публичные колоды сообщества. Этими ручками пользуются наш десктоп, мобильное приложение и расширение (выбор колоды при майнинге).

GET/api/v1/vocab/decks

Мои колоды со счётчиками: всего карточек, новых, к повторению.

{ "decks": [ { "id": "…", "name": "Аниме", "description": null, "isDefault": false,
  "cardCount": 312, "newCount": 8, "dueCount": 14 } ] }
POST/api/v1/vocab/decks

Создать колоду. Тело: { name (1..80), description? (до 300) }.

curl -X POST https://naminori.fun/api/v1/vocab/decks \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"name":"Аниме","description":"слова из сабов"}'
→ { "ok": true, "id": "<uuid>" }
PUT/api/v1/vocab/decks/{id}

Переименовать колоду / сменить описание. Тело: { name?, description? }.

→ { "ok": true }   ·   404 { "error": "not found", "message": "Колода не найдена" }
PATCH/api/v1/vocab/decks/{id}

Сделать колоду колодой по умолчанию (туда падают слова из майнинга без явного deckId).

DELETE/api/v1/vocab/decks/{id}

Удалить колоду. Колоду по умолчанию удалить нельзя. Если в колоде есть карточки — 409 { error: "deck not empty", cardCount }; передайте moveToDeckId, чтобы перенести их в другую колоду, либо deleteCards=1, чтобы снести их вместе с колодой (необратимо; в ответе deletedCards). Оба параметра сразу — 400 (судим по наличию moveToDeckId, а не по тому, разобрался ли он: кривой uuid рядом с deleteCards тоже 400, а не молчаливый снос). Кривой moveToDeckId сам по себе — 400.

Параметры: moveToDeckId=<uuid> | deleteCards=1

GET/api/v1/vocab/decks/{id}/cards

Карточки колоды — это и есть данные редактора колод в десктоп-приложении. Все параметры необязательны: без них приходит весь список, как раньше. q ищет по слову, чтению, переводу и имени исходной колоды Anki; state/pause/type/ankiDeck — те же разрезы, что у фильтров на сайте; perPage включает страницы (page — с единицы, номер за краем зажимается к последней). В ответе: total (всего в колоде), filtered (под фильтром), page, pageCount и facets со счётчиками для каждого разреза — счётчик показывает, сколько останется ПОСЛЕ включения этого фильтра.

Параметры: q=<строка> & state=all|new|learning|review|relearning & pause=all|active|paused & type=all|vocab|anki & ankiDeck=<имя> & page=<n> & perPage=<1..1000>

{ "deck": { "id": "…", "name": "Аниме", "isDefault": false },
  "cards": [ { "cardId": "…", "front": "猫", "back": "кошка", "state": "review", "due": "…" } ],
  "total": 4512, "filtered": 3, "page": 1, "pageCount": 1, "perPage": 200,
  "facets": { "state": { "new": 1, "learning": 0, "review": 2, "relearning": 0 },
    "paused": 3, "active": 4509, "vocab": 120, "anki": 4392,
    "ankiDecks": [ { "name": "日本語::N5", "count": 4392 } ] } }
POST/api/v1/vocab/decks/{id}/cards

Создать СВОЮ карточку в колоде (Anki-стиль, itemType=custom) — как кнопка «Добавить карточку» на сайте. Тело: { front (1..500), back (до 2000) } и необязательные reading, partOfSpeech, exampleJp, exampleRu, note. С ними front трактуется как слово, back — как перевод, а лицо и оборот собирает сервер.

→ { "ok": true, "card": { "cardId": "…", "front": "…", "back": "…" } }
POST/api/v1/vocab/decks/{id}/bulk

Вставить список слов в колоду — как панель «Добавить слова списком» на сайте: каждое слово ищется в словаре и заводится полноценной карточкой. Тело: { words: string[] } (можно и одной строкой — режем по запятым, 、, точке с запятой, переносам и табам). За раз берётся 200 слов, куски длиннее 32 символов пропускаются — и то, и другое возвращается в skipped, а не выбрасывается молча. Различать их обязательно: tooLong — подмножество skipped, которое не пройдёт НИКОГДА (слать повторно бессмысленно), остальное в skipped — хвост сверх порции, его и досылают вторым запросом. elsewhere — слова, чья карточка у вас уже есть, но лежит в ДРУГОЙ колоде: сюда они не переехали, перенести можно через action=move у /api/v1/vocab/cards. Порог частоты ниже обычного: 10 отправок в минуту.

curl -X POST https://naminori.fun/api/v1/vocab/decks/<uuid>/bulk \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"words":["母","父","兄, 姉, 弟"]}'
→ { "ok": true, "added": 4, "exists": 1, "notFound": [], "skipped": [], "tooLong": [], "total": 5,
    "elsewhere": [ { "word": "母", "cardId": "<uuid>" } ] }

Редактор карточек

POST/api/v1/vocab/cards

Операции над своими карточками слов: move (перенести в другую колоду), forget (сбросить прогресс, state=new), delete (удалить из повторений), update (личная версия текста), suspend (снять с повторений и вернуть — тоггл над ОДНОЙ карточкой), setSuspended (массовая пауза/возврат: cardIds + suspended, направление задаётся явно), bury (отложить до следующего учебного дня), scene (правка контекста «откуда слово»: предложение, подпись источника, кадр, звук). Пакетные — move, forget, delete и setSuspended, до 1000 id за раз; остальные работают с одной карточкой. Массовую паузу делайте именно setSuspended: одиночный suspend на тысяче карточек упрётся в порог частоты и бросит работу на середине. Единственное место, где API удаляет карточки — сделано под редактор колод; общий /api/v1/cards по-прежнему без деструктивных операций. На чужой или несуществующей карточке операции отвечают ok: false, а не молчаливым успехом.

curl -X POST https://naminori.fun/api/v1/vocab/cards \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"action":"move","cardIds":["<uuid>"],"toDeckId":"<uuid>"}'
→ { "ok": true, "moved": 1 }

# личная версия текста: "" снимает правку и возвращает оригинал словаря,
# отсутствие поля — «не трогать» (как Edit на сайте)
  -d '{"action":"update","cardId":"<uuid>","back":"кошка (разг.)","note":"из 3 серии"}'

# снять с повторений (тоггл) и отложить до завтра
  -d '{"action":"suspend","cardId":"<uuid>"}'   → { "ok": true, "suspended": true }
# массово — направление явное, changed = сколько строк реально изменилось
  -d '{"action":"setSuspended","cardIds":["<uuid>"],"suspended":true}'
                                              → { "ok": true, "changed": 1 }
  -d '{"action":"bury","cardId":"<uuid>"}'      → { "ok": true, "buriedUntil": "…" }
# «до завтра» двигает buriedUntil, а НЕ due: расписание FSRS не трогается,
# карточка лишь прячется из сегодняшней очереди.

# правка сцены: "" убирает кадр или звук, отсутствие поля — «не трогать».
# image/audio принимают ТОЛЬКО наш путь /api/media/<файл> из POST /api/media/store
  -d '{"action":"scene","cardId":"<uuid>","snippet":"…","image":"/api/media/9f1c….webp"}'

Словарь и лукап

GET/api/v1/lookup?q=食べた

«Умный» лукап как в расширении: деинфлексия (食べた→食べる), совпадения всех длин секциями (Yomitan-style), питч. Скан идёт слева направо и продолжается после первого слова, поэтому 今学期 отдаёт и 今, и 学期. matchLength — сколько символов запроса покрыло ПЕРВОЕ слово (то, что подсвечивают под курсором), а не весь разобранный кусок. ОБЯЗАТЕЛЬНО смотрите headGroups: столько ПЕРВЫХ элементов groups относятся к слову под курсором, остальные — следующие слова строки; клиент, который этого не знает, покажет чужие статьи как значения того же слова. contextual: true — длина выбрана с оглядкой на продолжение (結んで в 「結んでもらおうか」, а не жадное 結んでも); такой ответ нельзя кэшировать по «форме + одному следующему символу». Пустой q — не ошибка: 200 и hit: null.

Параметры: q=слово (до 64 символов; длиннее — 400 { error: "query too long", maxLength: 64 }) · full=1 — статья ЦЕЛИКОМ (по умолчанию каждое значение режется до 350 знаков с пометкой «показана не полностью»: для попапа по наведению это правильно, для карточки повторения — нет) · ws=слово1,слово2 — пакет вместо q, чтобы вытянуть весь абзац заранее одним запросом (до 80 слов и 5200 символов строки; лишние слова отбрасываются молча, слишком длинная строка — 400 batch too long) · src=jitendex_ru,jmdict,warodai,jitendex — ограничить словари; БЕЗ src отдаются все, настройку пользователя ручка не читает, неизвестные имена отбрасываются

{ "query": "食べた", "hit": { "jp": "食べる", "kana": "たべる", "matchLength": 3,
  "headGroups": 1, "ru": "есть; кушать",
  "groups": [ { "jp": "食べる", "kana": "たべる", "accent": 2, "senses": [ { "source": "jmdict", "ru": "есть; кушать" } ] } ] } }

пакет на абзац (ключ в hits — ровно та строка, что пришла в ws=; null означает
«слова нет в словаре» — это ответ, его и надо класть в свой кэш):
GET /api/v1/lookup?ws=猫,が,存在しない単語
{ "queries": ["猫","が","存在しない単語"],
  "hits": { "猫": { … }, "が": { … }, "存在しない単語": null } }
GET/api/v1/dict?q=ねこ

Простой поиск по словарю: до 30 статей. Намеренно облегчённый в сравнении с /api/v1/lookup — без деинфлексии и питча, зато ищет и по русскому значению: запрос кириллицей автоматически превращается в поиск по переводу (сначала «значение начинается с этого слова», в конце — случайные вхождения подстроки). Форма ответа НЕ такая, как у лукапа: в каждом hit ровно одна группа, значения не обрезаются, поиск по префиксу запускается только если точное совпадение ничего не дало. Словари всегда все — настройка пользователя здесь не учитывается.

{ "query": "ねこ", "hits": [ { "jp": "猫", "kana": "ねこ", "ru": "кошка" } ] }
POST/api/vocab/add

Добавить слово в SRS (майнинг). Опционально text-контекст «откуда слово» — покажется на повторении. Поля text.image / text.audio принимают ТОЛЬКО наш относительный путь вида /api/media/<файл> — его выдаёт POST /api/media/store (см. ниже). Необязательный deckId кладёт слово в конкретную личную колоду. Кроме text есть второй вид контекста — video: сцена YouTube с тайм-кодом реплики, на повторении карточка покажет её.

Параметры: тело: jp · kana · ru · deckId? · text? {kind: reading|manga|grammar|asbplayer, sourceUrl, sourceTitle, snippet, image?, audio?} · video? {videoId, startMs, endMs, subtitle}

curl -X POST https://naminori.fun/api/vocab/add \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"jp":"猫","kana":"ねこ","ru":"кошка","deckId":"<uuid>",
       "text":{"kind":"reading","sourceUrl":"https://…","sourceTitle":"…","snippet":"…"}}'

то же, но контекст — сцена YouTube:
  -d '{"jp":"猫","kana":"ねこ","ru":"кошка",
       "video":{"videoId":"dQw4w9WgXcQ","startMs":61200,"endMs":63400,"subtitle":"猫が好きです。"}}'
POST/api/media/store

Загрузить кадр сцены или аудио реплики (multipart/form-data, поле file, до 8 МБ; jpg/png/webp/avif, mp3/m4a/aac/ogg/weba/wav — тип проверяется по содержимому). Возвращает относительный URL, который нужно положить в text.image / text.audio у POST /api/vocab/add. Без этого шага прикрепить медиа-контекст к намайненному слову нельзя: внешние ссылки схема не принимает. Звук в форматах webm/ogg/wav перекодируется в mp3 — иначе на iPhone он молча не играет.

curl -X POST https://naminori.fun/api/media/store \
  -H "Authorization: Bearer nmn_…" -F "file=@scene.jpg"
→ { "ok": true, "url": "/api/media/9f1c….jpg", "name": "9f1c….jpg" }
GET/api/v1/audio?q=学校

Озвучка любого японского текста (MP3, edge-tts, голос Nanami). До 200 символов, 120 запросов/мин.

POST/api/v1/translate

Перевод одной японской реплики на русский силами сервера (Gemini). Нужен клиентам, которые показывают перевод на лету — например, оверлею для визуальных новелл. Почему через нас, а не напрямую: бесключевой веб-эндпоинт Google Translate отвечает антибот-страницей и 429 на каждый запрос (режется адрес, а не HTTP-клиент), у сервера же Gemini ходит через прокси с ретраями. Повтор той же реплики отдаётся из общего кэша на 2000 строк — мгновенно и не тратя квоту.

Параметры: тело: text (japanese, режется до 400 символов). Пустой — 400 empty text, нераспарсенное тело — 400 bad json, отказ Gemini — 502 с причиной в поле error

curl -X POST https://naminori.fun/api/v1/translate \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"text":"また明日ね"}'
→ { "ok": true, "text": "До завтра!" }        # повтор: + "cached": true
GET/api/v1/grammar/lookup?q=てもいい

«Что это за конструкция»: по куску японского находит грамматические пункты Naminori. Нужен клиенту, который разбирает предложение и хочет показать не только слова, но и грамматику. Поле total — это число ВОЗВРАЩЁННЫХ пунктов (после limit), а не сколько всего совпало.

Параметры: q — до 80 символов (в ответе возвращается уже обрезанным) · limit

GET/api/v1/dicts/naminori-grammar

Словарь грамматики Naminori (конструкции N5–N1 с размеченными поверхностями) в формате Yomitan v3 zip — тем же способом, что и словарь слов. Параметр v НЕ выбирает версию: собирается и отдаётся всегда текущий архив, а v влияет только на заголовки кэширования (совпал с текущей ревизией — immutable, иначе no-cache).

Параметры: v — ревизия для кэш-заголовков (необязательно)

GET/api/v1/dicts/naminori-ru/meta

«Паспорт» словарного пакета: ревизия и РАСКЛАД ПО ИСТОЧНИКАМ (сколько статей из каждого словаря). По нему клиент понимает, что скачанный когда-то пакет устарел. Судить надо именно по раскладу, а не по числу статей: 23.08.2026 с сервера пропал основной русский словарь, пакет пересобрался без него, и число термов осталось прежним — подмену по количеству заметить было нельзя. Публично, без токена.

GET/api/media/{файл}

Отдаёт намайненный кадр или звук реплики, ранее загруженный через POST /api/media/store. Публично (адрес — случайный UUID). ВНИМАНИЕ: у этой ручки, в отличие от всех /api/v1/*, НЕТ CORS — кросс-доменный fetch из расширения или веб-клиента упрётся в политику; в <img> и <audio> файл работает как обычно. Поддерживает Range: без этого <audio> в iOS Safari не проигрывает файл.

GET/api/v1/dicts/naminori-ru

Готовый словарь Naminori (Yomitan v3 zip, питч Kanjium и частотность форм) — для импорта в Yomitan-совместимые клиенты и наше расширение. Основной режим — пакет на один словарь: src=jitendex_ru (Колобок + грамматика Naminori), src=jmdict, src=jitendex; у таких пакетов постоянные названия. Без src — полный пакет, src=a,b — комбинированный (оба для старых клиентов); грамматика Naminori только там, где есть Колобок. Готовый пакет держится в кэше сутки; если его пришлось собирать заново — не чаще трёх сборок за 10 минут с адреса (иначе 429), а одновременные сборки встают в очередь (503, если очередь занята).

Контент

GET/api/v1/grammar

Каталог опубликованной грамматики N5–N1 в методическом порядке. total — сколько пунктов подошло ВСЕГО (не размер страницы), так что каталог можно листать по offset.

Параметры: level=N5..N1 · q=поиск (название/кана/ромадзи/перевод) · limit=1..600 (100) · offset=0…

{ "total": 552, "limit": 100, "offset": 0, "points": [ { "slug": "temo-ii", "level": "N5", "title": "〜てもいい", "meaningRu": "можно; разрешено" } ] }
GET/api/v1/grammar/temo-ii

Полный пункт: объяснение (markdown), нюансы, структура, похожие конструкции + до 30 примеров. Разметка фуриганы `{漢|かな}`, целевая конструкция в `<g>…</g>`.

{ "point": { "slug": "temo-ii", "title": "〜てもいい", "explanationMd": "…", "structure": […] },
  "examples": [ { "jp": "{食|た}べ<g>てもいい</g>ですか。", "ru": "Можно поесть?", "answer": "てもいい" } ] }
GET/api/v1/grammar/lookup?q=食べてもいいですか

«Что это за конструкция»: запрос нормализуется (тильда-заполнитель, многоточие, скобочные пояснения, полноширинные пробелы) и результаты ранжируются — точное совпадение, начало, вхождение в любую сторону. Главное отличие от каталожного ?q= — вхождение «наоборот»: живая фраза 食べてもいいですか находит пункт 〜てもいい, чего ILIKE по названию не сделает никогда.

Параметры: q=конструкция (до 80 символов) · limit=1..10 (5)

POST/api/v1/grammar/check

Проверить ответ на грамматическую клозу ТОЧНО как на сайте: сначала ромадзи → кана (как поле ввода в тренажёре), затем общая проверка (кандзи↔кана, эквивалентные формы, вежливость). Позволяет принимать ответ, набранный латиницей, и не переписывать выверенную логику приёма у себя. Тело: { input, answer, alternatives?, pointSlug? } — answer и alternatives приходят из GET /api/v1/queue?type=grammar.

curl -X POST https://naminori.fun/api/v1/grammar/check \
  -H "Authorization: Bearer nmn_…" -H "Content-Type: application/json" \
  -d '{"input":"temoii","answer":"てもいい","pointSlug":"temo-ii"}'
GET/api/v1/decks

Публичные колоды сообщества (по популярности, до 100). В каждой строке — id, title, description, level, saves (сколько раз сохранили), items (карточек) и url — адрес колоды на сайте. Дальнейшая работа с колодой (посмотреть состав, сохранить себе) возможна только на сайте: ручек /decks/{id} в API нет.

{ "decks": [ { "id": "…", "title": "Кухня и еда", "level": "N4",
  "saves": 128, "items": 300, "url": "/decks/shared/…" } ] }

Служебное

GET/api/v1/extension/version

Версия расширения в готовом zip и ссылка на скачивание — установленная копия сравнивает её со своей и зовёт обновиться. Единственная ручка без токена. 503, если zip ещё не собран.

{ "version": "3.2.10", "url": "https://naminori.fun/apps/naminori-extension.zip" }

Пример: мини-клиент повторений на JS

const API = "https://naminori.fun";
const H = { Authorization: "Bearer nmn_…", "Content-Type": "application/json" };

// 1. Скачиваем очередь ЦЕЛИКОМ — страницами по курсору, чтобы работать без сети.
//    Складываем по cardId: если между страницами вы отвечаете на сайте,
//    карточка с новым сроком может приехать второй раз.
const byId = new Map();
let url = `${API}/api/v1/queue?type=vocab&order=stable&limit=500`;
while (url) {
  const page = await fetch(url, { headers: H }).then((r) => r.json());
  for (const card of page.items) byId.set(card.cardId, card);
  // limit повторяем на каждой странице: без него страница снова 50.
  url = page.nextCursor
    ? `${API}/api/v1/queue?type=vocab&limit=500&cursor=${encodeURIComponent(page.nextCursor)}`
    : null;
}
const queue = [...byId.values()];

// 2. Занимаемся хоть в самолёте. Момент ответа запоминаем СВОЙ — по нему
//    считается интервал FSRS, и он же защищает от двойной отправки.
const done = [];
for (const card of queue) {
  console.log(card.jp, "→ ваш ответ…", card.ru);
  done.push({ cardId: card.cardId, rating: 3, reviewedAt: new Date().toISOString() });
}

// 3. Появилась связь — отправляем накопленное пачками по 500.
for (let i = 0; i < done.length; i += 500) {
  const r = await fetch(`${API}/api/v1/review/batch`, {
    method: "POST", headers: H,
    body: JSON.stringify({ reviews: done.slice(i, i + 500) }),
  }).then((r) => r.json());
  // Ответ потерялся по дороге? Пошлите тот же кусок ещё раз: уже принятые
  // позиции вернутся как duplicate и расписание второй раз не сдвинут.
  console.log(r.applied, "принято,", r.duplicate, "повтор,", r.failed, "не вышло");
}

Базовый URL: https://naminori.fun. По вопросам интеграций пишите в Telegram @s1n3off или на hello@naminori.fun. API стабилен в пределах v1; о breaking changes предупредим здесь.