diff --git a/api.md b/api.md index 3f66dc1..39f2f22 100644 --- a/api.md +++ b/api.md @@ -3,7 +3,7 @@ > **Версия API:** `v1` > **Базовый URL:** `https://markclean.online/api` > **Формат данных:** JSON -> **Аутентификация:** API-ключ (Bearer token / X-API-Key) +> **Аутентификация:** API-ключ (`X-API-Key` / `Authorization: Bearer`) для `/convert` и `/converter/history`; HTTP-сессия (cookie `session_id`) для учётной, API-key и биллинга --- @@ -15,10 +15,13 @@ 4. [Конвертация URL в Markdown](#-конвертация-url-в-markdown) 5. [История конвертаций](#-история-конвертаций) 6. [Опции конвертации](#-опции-конвертации) -7. [Обработка ошибок](#-обработка-ошибок) -8. [Квоты и лимиты](#-квоты-и-лимиты) -9. [Примеры кода](#-примеры-кода) -10. [FAQ](#-faq) +7. [API-ключи](#-api-ключи) +8. [Биллинг: пакеты и подписки](#-биллингпакеты-и-подписки) +9. [Проверка баланса](#-проверка-баланса) +10. [Обработка ошибок](#-обработка-ошибок) +11. [Квоты, лимиты и кэширование](#-квотылимиты-и-кэширование) +12. [Примеры кода](#-примеры-кода) +13. [FAQ](#-faq) --- @@ -30,8 +33,11 @@ MarkClean API позволяет программно конвертироват - 🌐 Конвертация любой HTML-страницы в Markdown - 🧹 Автоматическая очистка от мусора (реклама, навигация, футеры) через Readability - ⚡ Двухуровневое кэширование — повторные запросы не списывают кредиты -- 🎛️ Гибкие опции вывода (заголовок, frontmatter, удаление картинок/ссылок) +- 🎛️ Гибкие опции вывода (заголовок, frontmatter, оглавление, удаление картинок/ссылок) - 📊 История всех конвертаций с дедупликацией +- 💳 Система кредитов + ежедневный лимит + подписка +- 🗝️ Управление API-ключами (создание, чтение, удаление) +- 📦 Пакеты кредитов и подписки --- @@ -50,7 +56,7 @@ API-ключ можно создать **только** через веб-инт ``` mk_live_Xk9mP2vQ7rT4wL8nZ3sY6tB1cD5fG0hJ -│ └── 32 символа base64url (криптографически случайные) +│ └── 32 символа base64url (криптографически случайные, 24 байта) └── префикс (8 символов) ``` @@ -62,27 +68,40 @@ mk_live_Xk9mP2vQ7rT4wL8nZ3sY6tB1cD5fG0hJ ## 🛡️ Аутентификация +API поддерживает два независимых способа авторизации: + +### 1. API-ключ (для API-клиентов) + Все запросы к защищённым эндпоинтам должны содержать API-ключ в одном из заголовков: -### Способ 1: `X-API-Key` (рекомендуется) +#### Способ 1: `X-API-Key` (рекомендуется) ```http X-API-Key: mk_live_Xk9mP2vQ7rT4wL8nZ3sY6tB1cD5fG0hJ ``` -### Способ 2: `Authorization: Bearer` +#### Способ 2: `Authorization: Bearer` ```http Authorization: Bearer mk_live_Xk9mP2vQ7rT4wL8nZ3sY6tB1cD5fG0hJ ``` -Оба способа равнозначны. При отсутствии или невалидности ключа сервер вернёт `401 Unauthorized`. +Оба способа равнозначны. При отсутствии или невалидности ключа сервер вернёт `401 Unauthorized` с ошибкой `invalid_api_key`. + +### 2. Сессия (для веб-интерфейса и защищённых эндпоинтов) + +Для веб-интерфейса и защищённых эндпоинтов (API-ключи, биллинг, история, профиль) используется HTTP-сессия: + +- Cookie: `session_id` (UUID) +- Устанавливается при регистрации/логине +- Живёт 30 дней +- Передаётся автоматически браузером --- ## 🔄 Конвертация URL в Markdown -Основной эндпоинт API. +Основной эндпоинт API. Доступен **анонимам** и **пользователям с API-ключом**. ### Запрос @@ -92,6 +111,8 @@ Content-Type: application/json X-API-Key: mk_live_YOUR_KEY_HERE ``` +> **Примечание:** Для анонимов API-ключ не нужен, но в этом случае применяется IP-лимит (3 запроса/24 часа). + ### Тело запроса ```json @@ -99,6 +120,7 @@ X-API-Key: mk_live_YOUR_KEY_HERE "url": "https://example.com/article", "include_title": true, "include_frontmatter": false, + "include_toc": false, "strip_images": false, "strip_links": false } @@ -109,6 +131,7 @@ X-API-Key: mk_live_YOUR_KEY_HERE | `url` | string | ✅ | — | URL веб-страницы для конвертации (http/https) | | `include_title` | bool | ❌ | `false` | Добавить заголовок страницы как H1 (`# Title`) | | `include_frontmatter` | bool | ❌ | `false` | Добавить YAML-шапку с метаданными | +| `include_toc` | bool | ❌ | `false` | Добавить оглавление (Table of Contents) | | `strip_images` | bool | ❌ | `false` | Удалить все `` теги | | `strip_links` | bool | ❌ | `false` | Удалить `` теги (текст сохраняется) | @@ -118,6 +141,11 @@ X-API-Key: mk_live_YOUR_KEY_HERE { "title": "Example Article", "content": "# Example Article\n\nThis is the converted content...", + "metadata": { + "words": 4521, + "tokens": 1234, + "bytes": 4521 + }, "quota_incremented": true } ``` @@ -126,6 +154,9 @@ X-API-Key: mk_live_YOUR_KEY_HERE |------|-----|----------| | `title` | string | Заголовок страницы (может отсутствовать) | | `content` | string | Результат конвертации в Markdown | +| `metadata.words` | int | Количество слов в контенте | +| `metadata.tokens` | int | Количество токенов (для LLM, cl100k) | +| `metadata.bytes` | int | Размер в байтах | | `quota_incremented` | bool | `true` — списан кредит; `false` — cache hit или лимит | ### Пример cURL @@ -137,7 +168,9 @@ curl -X POST https://markclean.online/api/convert \ -d '{ "url": "https://metanit.com/web/html5/2.6.php", "include_title": true, - "strip_images": true + "include_frontmatter": true, + "strip_images": true, + "strip_links": false }' ``` @@ -147,6 +180,8 @@ curl -X POST https://markclean.online/api/convert \ Получить список всех конвертаций текущего пользователя с пагинацией. +**Требует авторизации** (API-ключ или сессия). + ### Запрос ```http @@ -168,8 +203,6 @@ X-API-Key: mk_live_YOUR_KEY_HERE "id": "27c1e865-a77e-4d99-87d0-1179aef2a3e6", "title": "Example Article", "target_url": "https://example.com/article", - "count_usage": 3, - "created_at": "2026-09-27T10:00:00Z", "updated_at": "2026-09-27T14:30:00Z" } ], @@ -186,15 +219,13 @@ X-API-Key: mk_live_YOUR_KEY_HERE | `items[].id` | string | UUID записи | | `items[].title` | string | Заголовок страницы (или пустая строка) | | `items[].target_url` | string | Исходный URL | -| `items[].count_usage` | int | Сколько раз этот URL конвертировался | -| `items[].created_at` | string | Первая конвертация (ISO 8601) | | `items[].updated_at` | string | Последняя конвертация (ISO 8601) | -| `total_count` | int | Общее количество записей | +| `total_count` | int | Общее количество записей (только успешные, status_code = 200) | | `page` | int | Текущая страница | | `page_size` | int | Размер страницы | | `has_more` | bool | Есть ли следующие страницы | -> 💡 **Дедупликация:** Один URL = одна запись. Повторные конвертации увеличивают `count_usage` и обновляют `updated_at`, перемещая запись наверх. +> 💡 **Дедупликация:** Один URL = одна запись. Повторные конвертации обновляют `updated_at` и перемещают запись наверх. --- @@ -218,8 +249,12 @@ This is the content... --- title: "Example Article" source: "https://example.com/article" -bytes: 4521 +language: "English" +words: 4521 tokens: 1234 +bytes: 4521 +reading_time: "23 min" +extracted_at: "2026-09-27" --- # Example Article @@ -227,6 +262,25 @@ tokens: 1234 This is the content... ``` +Поле `language` определяется автоматически через `whatlanggo` (только если включён `include_toc` или `include_frontmatter`). + +### `include_toc: true` + +Добавляет оглавление (Table of Contents) из заголовков H1–H3: + +```markdown +## Table of Contents + +- [Main Section](<#Main Section>) +- [Subsection](<#Subsection>) + +# Main Section + +Content... +``` + +> ⚠️ Оглавление добавляется только если заголовков H1–H3 **не менее 2**. Иначе оглавление не включается. + ### `strip_images: true` Полностью удаляет все `` теги. Полезно для: @@ -259,6 +313,261 @@ Visit our site for more info. --- +## 🔑 API-ключи + +Управление API-ключами — **только через сессию** (веб-интерфейс). API-ключами нельзя управлять через API. + +### Создание ключа + +**POST** `/api/keys` + +**Запрос:** +```json +{ + "name": "My Key" +} +``` +`name` необязательно (макс. 50 символов). Если не указан, используется `"Default Key"`. + +**Ответ (201 Created):** +```json +{ + "id": "27c1e865-a77e-4d99-87d0-1179aef2a3e6", + "name": "My Key", + "api_key": "mk_live_Xk9mP2vQ7rT4wL8nZ3sY6tB1cD5fG0hJ", + "warning": "Save this key now. It will not be shown again." +} +``` + +> ⭐ Сырой ключ возвращается **только в этом ответе**. Больше его никто не покажет. + +**Ошибки:** +- `409 Conflict` + `key_already_exists` — ключ уже существует (у пользователя только 1 ключ) +- `500 Internal Server Error` + `internal_error` + +### Получение метаданных ключа + +**GET** `/api/keys` + +**Ответ (200 OK):** +```json +{ + "id": "27c1e865-a77e-4d99-87d0-1179aef2a3e6", + "name": "My Key", + "key_prefix": "mk_live_a1b2c3d4", + "last_used_at": "2026-09-27 10:30:00", + "is_active": true, + "created_at": "2026-09-27 09:00:00" +} +``` + +**Ошибки:** +- `404 Not Found` + `key_not_found` — у пользователя нет ключа + +### Удаление ключа + +**DELETE** `/api/keys/:key_id` + +**Ответ (200 OK):** +```json +{ + "message": "API key deleted successfully" +} +``` + +**Ошибки:** +- `400 Bad Request` + `invalid_key_id` — некорректный UUID +- `404 Not Found` + `key_not_found` — ключ не найден +- `500 Internal Server Error` + `internal_error` + +--- + +## 💳 Биллинг: пакеты и подписки + +### Получение доступных пакетов + +**GET** `/api/billing/packages` — **публичный** эндпоинт (авторизация не требуется). + +**Ответ (200 OK):** +```json +{ + "free": { + "name": "Free", + "daily_limit": 10 + }, + "credits": [ + { + "code": "micro", + "name": "Micro", + "credits": 300, + "price": 49, + "price_per_100_requests": 17, + "discount": 0, + "savings_rub": 0, + "is_popular": false + }, + { + "code": "start", + "name": "Start", + "credits": 1000, + "price": 99, + "price_per_100_requests": 9, + "discount": 32, + "savings_rub": 17, + "is_popular": false + }, + { + "code": "optimal", + "name": "Optimal", + "credits": 3000, + "price": 199, + "price_per_100_requests": 7, + "discount": 42, + "savings_rub": 27, + "is_popular": true + }, + { + "code": "pro", + "name": "Pro", + "credits": 8000, + "price": 399, + "price_per_100_requests": 5, + "discount": 51, + "savings_rub": 57, + "is_popular": false + }, + { + "code": "ultra", + "name": "Ultra", + "credits": 20000, + "price": 799, + "price_per_100_requests": 4, + "discount": 63, + "savings_rub": 124, + "is_popular": false + } + ], + "subscriptions": [ + { + "months": 1, + "discount_percent": 0, + "total_price": 499, + "monthly_price": 499, + "savings_rub": 0 + }, + { + "months": 3, + "discount_percent": 5, + "total_price": 1419, + "monthly_price": 473, + "savings_rub": 78 + }, + { + "months": 6, + "discount_percent": 10, + "total_price": 2690, + "monthly_price": 448, + "savings_rub": 304 + }, + { + "months": 12, + "discount_percent": 19, + "total_price": 4790, + "monthly_price": 399, + "savings_rub": 1198 + } + ] +} +``` + +| Поле | Описание | +|------|----------| +| `free` | Бесплатный план: дневной лимит | +| `credits[]` | Пакеты кредитов (от дешёвого к дорогому) | +| `credits[].code` | Код пакета (`micro`, `start`, `optimal`, `pro`, `ultra`) | +| `credits[].credits` | Количество кредитов в пакете | +| `credits[].price` | Цена в рублях | +| `credits[].price_per_100_requests` | Стоимость 100 запросов | +| `credits[].discount` | Процент выгоды (от базового тарифа Micro) | +| `credits[].savings_rub` | Экономия в рублях | +| `credits[].is_popular` | Флаг «Хит» | +| `subscriptions[]` | Пакеты подписки (от короткого к длинному) | + +### Покупка пакета + +**POST** `/api/billing/purchase` — **требует авторизации** (API-ключ или сессия). + +> ⚠️ **Временный endpoint!** В продакшене будет заменён на реальную интеграцию с платёжной системой. + +**Запрос:** +```json +{ + "package_code": "optimal" +} +``` + +**Ответ (200 OK):** +```json +{ + "message": "Credits added successfully", + "package": "optimal", + "credits_added": 3000, + "new_balance": 3000 +} +``` + +**Ошибки:** +- `400 Bad Request` + `invalid_package` — некорректный код пакета +- `401 Unauthorized` + `unauthorized` — нет аутентификации +- `500 Internal Server Error` + `purchase_failed` — ошибка начисления кредитов + +--- + +## 📊 Проверка баланса + +### Получение баланса кредитов и подписки + +**GET** `/api/billing/usage` — **требует авторизации** (API-ключ или сессия). + +**Ответ (200 OK):** +```json +{ + "credits": { + "credits": 2500, + "daily_used": 3, + "daily_limit": 10, + "daily_remaining": 7, + "has_credits": true, + "daily_reset_at": "2026-09-27T14:00:00Z" + }, + "subscription": { + "is_active": true, + "ends_at": "2026-10-01T10:00:00Z", + "days_remaining": 4, + "is_unlimited": true + }, + "is_subscriber": true +} +``` + +| Поле | Тип | Описание | +|------|-----|----------| +| `credits` | object | Баланс кредитов | +| `credits.credits` | int | Платные кредиты (не сгорают) | +| `credits.daily_used` | int | Использовано запросов сегодня | +| `credits.daily_limit` | int | Дневной лимит (10) | +| `credits.daily_remaining` | int | Осталось запросов сегодня | +| `credits.has_credits` | bool | Есть ли платные кредиты | +| `credits.daily_reset_at` | time | Время следующего сброса дневного лимита | +| `subscription` | object/null | Информация об активной подписке | +| `subscription.is_active` | bool | Подписка активна | +| `subscription.ends_at` | time | Дата окончания подписки | +| `subscription.days_remaining` | int | Осталось дней | +| `subscription.is_unlimited` | bool | Безлимит (всегда true) | +| `is_subscriber` | bool | Есть ли активная подписка | + +--- + ## ⚠️ Обработка ошибок Все ошибки возвращаются в едином формате: @@ -276,15 +585,28 @@ Visit our site for more info. |:------:|------------|----------|----------| | `400` | `invalid_payload` | Битый JSON или отсутствует `url` | Проверьте тело запроса | | `400` | `invalid_url` | Некорректный формат URL | Должен начинаться с `http://` или `https://` | +| `400` | `invalid_request` | Некорректный запрос (биллинг) | Проверьте параметры | +| `400` | `invalid_package` | Неверный код пакета | Используйте валидный код | | `401` | `invalid_api_key` | Ключ отсутствует, невалиден или удалён | Проверьте заголовок `X-API-Key` | -| `401` | `unauthorized` | Нет аутентификации | Передайте API-ключ | +| `401` | `unauthorized` | Нет аутентификации | Передайте API-ключ или cookie | | `402` | `quota_exceeded` | Кредиты закончились + дневной лимит | Купите кредиты в веб-интерфейсе | -| `404` | `key_not_found` | Ключ не найден (для истории) | Создайте ключ заново | +| `404` | `key_not_found` | Ключ не найден | Создайте ключ заново | +| `404` | `key_not_found` | Ключ пользователя не найден (GET /api/keys) | Создайте ключ | +| `404` | `user_not_found` | Пользователь не найден | Проверьте данные | +| `409` | `key_already_exists` | У пользователя уже есть API-ключ | Удалите существующий ключ | +| `409` | `user_exists` | Пользователь с этим email уже зарегистрирован | Используйте другой email | +| `409` | `account_already_deleted` | Аккаунт уже удалён | — | +| `410` | `email_previously_deleted` | Email был использован для удалённого аккаунта | Используйте другой email | | `415` | `unsupported_content` | URL не возвращает HTML | API работает только с веб-страницами | | `422` | `conversion_failed` | Не удалось распознать контент | Попробуйте другой URL | | `429` | `rate_limit_exceeded` | Превышен лимит запросов | Подождите или войдите в аккаунт | -| `502` | `fetch_failed` | Не удалось загрузить страницу | Сайт недоступен или заблокировал запрос | +| `429` | `registration_ip_limit` | Превышен лимит регистраций с IP | Подождите | +| `429` | `resend_cooldown` | Слишком часто запрашивается OTP | Подождите | +| `429` | `otp_blocked` | Слишком много неудачных попыток OTP | Запросите новый код | +| `429` | `registration_ip_limit` | Превышен лимит регистраций с IP | Подождите | +| `429` | `registration_ip_limit` | Превышен лимит регистраций с IP | Подождите | | `500` | `internal_server_error` | Внутренняя ошибка сервера | Повторите позже | +| `502` | `fetch_failed` | Не удалось загрузить страницу | Сайт недоступен или заблокировал запрос | ### Пример обработки ошибки @@ -303,13 +625,14 @@ HTTP/1.1 401 Unauthorized --- -## 📊 Квоты и лимиты +## 📊 Квоты, лимиты и кэширование ### Система кредитов - 💳 **Платные кредиты** — не сгорают, не имеют срока действия -- 📅 **Дневной лимит** (5 запросов/сутки) — сбрасывается каждые 24 часа +- 📅 **Дневной лимит** (10 запросов/сутки) — сбрасывается каждые 24 часа - ⚡ **Приоритет списания:** сначала платные кредиты, затем дневной лимит +- 📊 **Максимальный баланс:** 500 000 кредитов ### Кэширование и экономия @@ -318,7 +641,7 @@ API использует **двухуровневое кэширование**: | Уровень | Что кэшируется | TTL | Экономия | |---------|----------------|-----|----------| | **L1** | Сырой HTML | 30 мин | Нет HTTP-запроса к внешнему сайту | -| **L2** | Финальный Markdown | 5 мин | Мгновенный ответ | +| **L2** | Финальный Markdown | 15 мин | Мгновенный ответ | **Важно:** Кредиты списываются **только** при реальном HTTP-запросе к внешнему сайту. Повторные конвертации того же URL (даже с другими опциями) **бесплатны**, если HTML ещё в кэше. @@ -331,9 +654,20 @@ API использует **двухуровневое кэширование**: | Повтор с другими опциями | ❌ | 0 | ~200 мс | | Спустя 30+ минут | ✅ | −1 | ~2-5 сек | +### IP-лимиты (для анонимов) + +- **Конвертация:** 3 запроса / 24 часа с одного IP +- **Регистрация:** 3 регистрации / 1 час с одного IP (глобально), 3 регистрации / 2 минуты с одного IP +- **Логин:** 10 попыток / 30 минут с одного IP, 5 попыток / 15 минут на email +- **Recovery OTP:** 10 попыток / 1 час с одного IP +- **OTP Verify:** 20 попыток / 5 минут с одного IP +- **Смена пароля:** 5 попыток / 1 час с одного IP, 3 попытки / 1 час на пользователя + ### Проверка баланса -Для проверки баланса кредитов используйте веб-интерфейс (эндпоинт `GET /api/billing/credits` доступен только через сессию, не через API-ключ). +Для проверки баланса кредитов используйте: +- Веб-интерфейс (страница **Billing**) +- API: **GET** `/api/billing/usage` (требует авторизации) --- @@ -359,13 +693,17 @@ response = requests.post( json={ "url": "https://example.com/article", "include_title": True, + "include_frontmatter": True, "strip_images": True, + "strip_links": False, }, ) if response.ok: data = response.json() print(f"Title: {data.get('title')}") + print(f"Words: {data['metadata']['words']}") + print(f"Tokens: {data['metadata']['tokens']}") print(f"Credits used: {data['quota_incremented']}") print(data["content"][:500]) else: @@ -428,6 +766,7 @@ async function convertUrl(url, options = {}) { url, include_title: options.title ?? false, include_frontmatter: options.frontmatter ?? false, + include_toc: options.toc ?? false, strip_images: options.stripImages ?? false, strip_links: options.stripLinks ?? false, }), @@ -445,6 +784,7 @@ async function convertUrl(url, options = {}) { try { const result = await convertUrl('https://example.com/article', { title: true, + frontmatter: true, stripImages: true, }); console.log(result.content); @@ -475,21 +815,31 @@ type ConvertRequest struct { URL string `json:"url"` IncludeTitle bool `json:"include_title"` IncludeFrontmatter bool `json:"include_frontmatter"` + IncludeTOC bool `json:"include_toc"` StripImages bool `json:"strip_images"` StripLinks bool `json:"strip_links"` } +type Metadata struct { + Words int `json:"words"` + Tokens int `json:"tokens"` + Bytes int `json:"bytes"` +} + type ConvertResponse struct { Title string `json:"title"` Content string `json:"content"` + Metadata Metadata `json:"metadata"` QuotaIncremented bool `json:"quota_incremented"` } func main() { reqBody, _ := json.Marshal(ConvertRequest{ - URL: "https://example.com/article", - IncludeTitle: true, - StripImages: true, + URL: "https://example.com/article", + IncludeTitle: true, + IncludeFrontmatter: true, + StripImages: true, + StripLinks: false, }) req, _ := http.NewRequest("POST", apiBase+"/convert", bytes.NewReader(reqBody)) @@ -544,9 +894,27 @@ curl -X POST https://markclean.online/api/convert \ "include_frontmatter": true }' +# Для документации (с оглавлением) +curl -X POST https://markclean.online/api/convert \ + -H "X-API-Key: mk_live_YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://example.com", + "include_title": true, + "include_frontmatter": true, + "include_toc": true + }' + # История конвертаций curl -X GET "https://markclean.online/api/converter/history?page=1&page_size=10" \ -H "X-API-Key: mk_live_YOUR_KEY" + +# Проверка баланса +curl -X GET "https://markclean.online/api/billing/usage" \ + -H "X-API-Key: mk_live_YOUR_KEY" + +# Получить доступные пакеты +curl -X GET "https://markclean.online/api/billing/packages" ``` --- @@ -572,14 +940,14 @@ curl -X GET "https://markclean.online/api/converter/history?page=1&page_size=10" ### 💳 Можно ли использовать API без кредитов? -Да, у каждого пользователя есть **бесплатный дневной лимит** (5 запросов/сутки), который сбрасывается каждые 24 часа. После исчерпания и дневного лимита, и кредитов — API вернёт `402 quota_exceeded`. +Да, у каждого пользователя есть **бесплатный дневной лимит** (10 запросов/сутки), который сбрасывается каждые 24 часа. После исчерпания и дневного лимита, и кредитов — API вернёт `402 quota_exceeded`. ### ⚡ Почему `quota_incremented: false`? Это нормально в трёх случаях: 1. **Cache hit** — этот URL уже конвертировался недавно (бесплатно) 2. **Анонимный запрос** — без API-ключа (лимит по IP) -3. **Ошибка инкремента** — редкая системная ошибка (запрос всё равно выполнен) +3. **Подписка** — у пользователя активна подписка, кредиты не списываются ### 🌍 Работает ли API с любым сайтом? @@ -644,5 +1012,5 @@ X-API-Key: {{apiKey}} --- -**Последнее обновление:** 27 сентября 2026 -**Версия API:** `0.5.6-beta` \ No newline at end of file +**Последнее обновление:** 10 октября 2026 +**Версия API:** `v1`