{
  "openapi": "3.1.0",
  "info": {
    "title": "ВордЧекер API",
    "version": "1.0.0",
    "description": "Публичный API ВордЧекера: проекты, фразы, позиции, сводка, частотность и запуск сканов — те же данные, что в интерфейсе.\n\n## Быстрый старт\n\n1. Создайте ключ: **Общие настройки → Доступ по API → Добавить ключ**, задайте название и права. Скопировать ключ можно в любой момент — кнопкой в списке ключей.\n2. Проверьте ключ:\n\n```bash\ncurl -s https://wordchecker.ru/api/v1/me \\\n  -H \"Authorization: Bearer $WC_API_KEY\"\n```\n\n3. Возьмите `id` проекта и его цели:\n\n```bash\ncurl -s https://wordchecker.ru/api/v1/projects -H \"Authorization: Bearer $WC_API_KEY\"\n```\n\n4. Выгрузите позиции на последнюю дату (`target` — ключ цели из ответа шага 3; нужен, если у проекта больше одной цели):\n\n```bash\ncurl -s \"https://wordchecker.ru/api/v1/projects/123/positions/latest?target=yandex_213\" \\\n  -H \"Authorization: Bearer $WC_API_KEY\"\n```\n\n## Ключ и права\n\nКлюч передается в заголовке `Authorization: Bearer wc_live_…`. Только https: запрос по http отклоняем (`400 HTTPS_REQUIRED`) — если ключ ушел по http, отзовите его.\n\n| Право | Что разрешает |\n|---|---|\n| `read` | чтение всего; есть у любого ключа |\n| `write` | добавление, перенос и архивирование фраз. Денег не тратит; фраза уходит в архив вместе с историей, и ее можно вернуть |\n| `scan` | смета, запуск и отмена сканов — тратит баланс |\n\nНе хватает права — `403 SCOPE_REQUIRED`. На аккаунт — до 10 активных ключей, отзыв действует сразу.\n\n## Формат ответов\n\n- JSON, поля в `snake_case`. Даты — `YYYY-MM-DD` по Москве, время — ISO-8601 UTC.\n- Успех: `{\"data\": …}`, у списков еще `\"meta\": {\"limit\", \"offset\", \"total\"}`.\n- Ошибка: HTTP-статус и `{\"error\": {\"code\": \"PROJECT_NOT_FOUND\", \"message\": \"…\"}}`. Ориентируйтесь на `code` — он стабилен; `message` — по-русски, для человека.\n- Позиция `null` с `not_in_top: true` — проверили, сайта нет в глубине. Даты нет в ответе — в этот день не проверяли.\n- Внутри `/v1` поля и коды только добавляются. Неизвестное поле в ответе пропускайте. Неизвестный `code` ошибки обрабатывайте по HTTP-статусу: это ошибка, а не успех.\n\n**Пагинация** — `limit` и `offset`: фразы до 1000 на страницу, джобы до 100, позиции до 500 фраз, окно дат до 366 дней. Фразы отдаем по возрастанию `id`, даты — по возрастанию. Добавили фразу между страницами — она встанет в конец (новый `id` больше), сдвига не будет; удалили — следующая страница сдвинется на одну фразу.\n\n**Кэш.** `ETag` несут позиции (`/positions`, `/positions/latest`), сводка (`/summary`, `/summary/chart`) и частотность (`/frequency`). Пришлите его в `If-None-Match` — если данные не менялись, ответим `304` без тела. Остальные методы `ETag` не отдают. Ответы сжаты gzip.\n\n## Лимиты\n\n| Что | Предел | Ответ при превышении |\n|---|---|---|\n| Запросы на ключ | 2 в секунду, всплеск до 5 | `429 RATE_LIMITED` + `Retry-After` |\n| Запросы на ключ в сутки | 10 000 (сброс в 00:00 МСК) | `429 RATE_LIMITED` |\n| Тяжелые запросы (позиции, сводка, выдача, сметы) | 2 одновременно на весь сервис | `503 BUSY` + `Retry-After` |\n| Запуски сканов | 60 в час на ключ | `429 SCAN_RATE_LIMITED` |\n\nОстаток суток — в заголовках `X-RateLimit-Limit-Day` и `X-RateLimit-Remaining-Day`. Выгружайте данные после завершения скана, а не в фиксированное время — тогда не встанете в очередь вместе со всеми.\n\n## Данные по дням\n\n- **Дата** — день по Москве, когда скан поставил запрос в очередь, а не когда пришел ответ. Скан, начатый в 23:50, пишет в этот день, даже если закончил после полуночи.\n- **Несколько проверок за день.** Из двух полных сканов (`scan_mode: full`) в ячейке остается последний. Если в тот же день был еще частичный (`missing_only`), берем лучшую позицию из полного и частичного.\n- **Пока скан идет**, в день его старта ячейки фраз, которые он проверяет, пусты: старое значение скрываем, чтобы не смешивать его с новым. Это действует и после отмены скана.\n- **Прошлые дни могут измениться.** Поздний ответ провайдера дописывается в день постановки запроса, удаленные в интерфейсе даты пропадают. Перечитывайте последние 2–3 дня, а не только вчера.\n- **`/positions/latest`** — одна последняя дата для всей цели, не для каждой фразы. Фраза, которую в тот день не проверяли, придет с пустым `positions`.\n- **`job_id` ячейки** — скан, чья позиция осталась в ячейке (при двух проверках за день — победившая). `null` — данные из импорта или старого пути расчета.\n- **Что обновилось.** `GET /projects?updated_since=…` — только проекты, где после этого момента завершился скан позиций или частотности; время последнего — `last_scan_finished_at`. Выгружайте позиции только по ним.\n\n## Как считаем показатели\n\n| Показатель | Формула |\n|---|---|\n| `total` | фразы, проверенные в этот день: есть место или «вне топа» |\n| `found_count` | фразы с местом 1–100 |\n| `avg_position` | среднее место среди найденных (1–100), до 0,1. «Вне топа» и непроверенные не участвуют, штрафа нет. Никого не нашли — `null` |\n| `top3`, `top5`, `top10`, `top20`, `top30`, `top50`, `top100` | число фраз с местом не ниже N; накопительно: `top10` включает `top3` |\n| `visibility_percent` | `Σ(not_ws × вес места) / Σ not_ws × 100`, до 0,01. Знаменатель — точная частотность всех выбранных фраз, у которых она собрана, проверены они в этот день или нет. Частотности нет ни у одной — `0` |\n\nВес места: 1–3 → 1; 4 → 0,85; 5 → 0,6; 6–7 → 0,5; 8–9 → 0,3; 10 → 0,2; 11–15 → 0,1; 16–20 → 0,05; дальше — 0. Частотность для Яндекса — по региону цели, для Google — `all`.\n\n`visibility_percent`, `visibility_delta` и `top10_count` в `GET /projects` — быстрый обзор, как в списке проектов интерфейса: одна сводная цель (регион проекта в Яндексе плюс Google, по каждой фразе — лучшее место из поисковиков), последние 15 дат проверок. `visibility_delta` — разница между последней и первой из них. Для отчетов берите `/summary` по нужной цели.\n\n## Частотность и баланс\n\n- `ws` — базовая частотность Вордстата, `not_ws` — точная («!слово»), `google_ws` — объем Google Ads. `0` — собрали, запросов нет; `null` — не собирали.\n- `balance_rub` — баланс, как в интерфейсе. Резерв идущих сканов списываем при запуске, он уже вычтен. `reserved_rub` — сколько сейчас заморожено под идущие сканы. `available_balance_rub` — сколько можно потратить на новые сканы.\n\n## Сканы: смета, запуск, результат\n\n1. **Смета.** `POST /projects/{projectId}/scans` с `\"dry_run\": true` — цена и хватит ли баланса, ничего не списываем. Нужен потолок списания — `POST /projects/{projectId}/scans/quote`: вернем `quote_id` на 15 минут. Смету можно использовать несколько раз, пока не истекла. Деньги она не резервирует.\n2. **Запуск.** `POST /projects/{projectId}/scans` с параметрами скана или с `quote_id`. Ответ — `202` и `job.id`. По смете спишем не больше ее суммы. Цену пересчитываем при запуске: если она выросла (добавили фразы, сменился тариф) — `409 QUOTE_STALE`, запросите новую смету; если упала — запустим по новой. Без `phrase_ids` смета не фиксирует набор фраз: при запуске берем фразы проекта на этот момент.\n3. **Результат.** Передайте `callback_url` (https) — по завершении пришлем `POST {\"event\": \"job.finished\", \"job_id\": 412, \"status\": \"completed\"}`. Без вебхука опрашивайте `GET /jobs/{jobId}` не чаще раза в 10 секунд.\n4. **Данные.** Когда джоб завершен, выгрузите позиции. В `GET /jobs/{jobId}` после завершения — фактическое списание (`charge_settled: true`) и `errors_count`: сколько запросов к провайдеру упало окончательно. `has_errors: true` — часть данных за этот скан не собрали.\n5. **Все джобы.** `GET /jobs` — сканы аккаунта, новые первыми, с фильтрами по проекту, статусу и дате создания.\n\n**Вебхук.** Одно событие `job.finished` на любой финал: `completed`, `failed`, `cancelled`. Он не подписан и не несет данных — получив его, заберите статус своим ключом и проверьте, что `job_id` ваш. Доставлено — любой ответ `2xx` в течение 5 секунд; редиректы не проходим. Не доставили — повторяем через 1, 5 и 30 минут после предыдущей попытки, всего 4 попытки. Возможны дубли — обрабатывайте событие идемпотентно.\n\n**Статусы джоба.** `queued` → `running` (или `waiting_provider`, пока провайдер недоступен) → финал `completed`, `failed` или `cancelled`. Пока джоб не завершен, ответ несет `Retry-After: 10`. `charge_rub` до финала — замороженная сумма. После финала — фактическое списание, когда `charge_settled: true`. За запросы, которые не успели начаться (отмена) или упали не по вине провайдера, деньги возвращаем.\n\n**Яндекс днем.** Если провайдер отключил обычную выдачу Яндекса (код 101, бывает днем), фразу собираем через Яндекс Live с той же глубиной и списываем по страницам: ТОП-100 — 10 страниц по цене запроса. Доплату резервируем по ходу скана; не хватило баланса — фраза остается без проверки, деньги за нее возвращаем. Позиция пишется в ту же цель. Скан по смете (`quote_id`) так не делаем: смета — потолок списания, такие фразы падают с возвратом.\n\n**Режимы.** `scan_mode: full` — все выбранные фразы. `missing_only` — только фразы, у которых по какой-то из выбранных целей никогда не было успешной проверки; такую фразу проверяем по всем выбранным целям. Вместе с `phrase_ids` и для `type: frequency` `missing_only` не действует — проверяем все переданное.\n\n**Цели.** `targets` — ключи любых целей проекта, в том числе из разных регионов и поисковиков: это один скан и один джоб, регионы проверяем по очереди. Без `targets` проверяем все цели проекта.\n\n**Повтор без двойного списания.** Передавайте заголовок `Idempotency-Key` (любая уникальная строка). Повтор с тем же ключом в течение суток вернет тот же джоб (`Idempotent-Replayed: true`), а не запустит второй скан. Потеряли ответ запуска — найдите джоб: `GET /jobs?idempotency_key=…`. Ищем тем же ключом API, пока храним журнал ключей — около 7 дней после запуска.\n\n```bash\ncurl -s https://wordchecker.ru/api/v1/projects/123/scans \\\n  -H \"Authorization: Bearer $WC_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: nightly-2026-11-05\" \\\n  -d '{\"type\": \"serp\", \"scan_mode\": \"full\", \"callback_url\": \"https://example.com/wc-hook\"}'\n```\n\n## **MCP-сервер**\n\nУ API есть **MCP-сервер** (Model Context Protocol, транспорт Streamable HTTP) — те же данные и сканы, что у методов выше, в виде инструментов для AI-агентов. Адрес: `https://wordchecker.ru/api/v1/mcp`. Ключ передается в `Authorization: Bearer …`, как у обычных запросов; права ключа (`read`/`write`/`scan`) определяют набор инструментов — `tools/list` отдает только доступные.\n\nИнструменты — по группам:\n\n- **Чтение** (`read`): `get_account`, `list_projects`, `get_project` (здесь ключи целей `target`), `list_groups`, `list_phrases`, `get_latest_positions`, `get_positions`, `get_phrase_serp`, `get_summary`, `get_summary_chart`, `get_frequency`, `list_jobs`, `get_job`\n- **Фразы** (`write`): `add_phrases`, `archive_phrases`, `restore_phrases`, `update_phrase`\n- **Сканы** (`scan`): `estimate_scan` — смета с `quote_id` на 15 минут: покажите пользователю цену и `available_balance_rub` и получите согласие; `start_scan` — запуск по `quote_id` (повтор той же сметы в течение суток вернет тот же джоб — ретрай безопасен); `cancel_job`\n\nПодключение. Cursor (`~/.cursor/mcp.json`):\n\n```json\n{\n  \"mcpServers\": {\n    \"wordchecker\": {\n      \"url\": \"https://wordchecker.ru/api/v1/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer wc_live_…\" }\n    }\n  }\n}\n```\n\nClaude Code:\n\n```bash\nclaude mcp add --transport http wordchecker https://wordchecker.ru/api/v1/mcp \\\n  --header \"Authorization: Bearer $WC_API_KEY\"\n```\n\nВ Claude.ai и Claude Desktop коннектор с ключом в заголовке доступен не всем организациям (заголовки запроса — в бете); вход через OAuth пока не поддерживаем.\n\n## Ошибки\n\n| Статус | `code` | Что делать |\n|---|---|---|\n| 400 | `INVALID_PARAMS`, `INVALID_JSON`, `HTTPS_REQUIRED` | исправить запрос; что не так — в `message` |\n| 401 | `UNAUTHORIZED` | проверить ключ: он не отозван и скопирован целиком |\n| 403 | `SCOPE_REQUIRED` | выдать ключу право |\n| 404 | `PROJECT_NOT_FOUND`, `PHRASE_NOT_FOUND`, `PHRASES_NOT_FOUND`, `JOB_NOT_FOUND`, `QUOTE_NOT_FOUND` | проверить id; смета живет 15 минут |\n| 409 | `QUOTE_STALE`, `QUOTE_USED`, `IDEMPOTENCY_IN_PROGRESS` | взять новую смету / дождаться первого запроса с этим ключом |\n| 413 | `PAYLOAD_TOO_LARGE` | разбить тело на части (до 1 МБ) |\n| 422 | `WC_BALANCE_INSUFFICIENT` | не хватает баланса на скан — пополнить |\n| 422 | `WC_BALANCE_COMMISSION_INSUFFICIENT` | скан на своих ключах провайдера, но не хватает баланса на комиссию сервиса — пополнить |\n| 422 | `SCAN_REJECTED` | скан не встал по настройкам: не выбран сервис, не настроен ключ провайдера, нет поисковиков в проекте — причина в `message` |\n| 422 | `IDEMPOTENCY_KEY_REUSED` | новый `Idempotency-Key` для другого тела запроса |\n| 429 | `RATE_LIMITED`, `SCAN_RATE_LIMITED` | повторить через `Retry-After` |\n| 503 | `BUSY`, `QUOTE_UNAVAILABLE` | повторить через `Retry-After` |\n| 500 | `INTERNAL` | повторить позже; не проходит — написать в поддержку |\n\nВ обращении в поддержку пришлите заголовок `X-WC-Build` из ответа — по нему видно, какая сборка отвечала."
  },
  "servers": [
    {
      "url": "https://wordchecker.ru/api/v1",
      "description": "API ВордЧекера"
    }
  ],
  "security": [
    {
      "bearer": []
    }
  ],
  "tags": [
    {
      "name": "Аккаунт",
      "description": "Проверка ключа, баланс и доступность API"
    },
    {
      "name": "Проекты",
      "description": "Проекты аккаунта, их цели (поисковик + регион + устройство) и группы фраз"
    },
    {
      "name": "Фразы",
      "description": "Список фраз проекта, добавление и удаление (право `write`)"
    },
    {
      "name": "Позиции и выдача",
      "description": "Позиции сайта по датам, последняя дата и топ-100 выдачи по фразе. Позиции — по одной цели за запрос"
    },
    {
      "name": "Сводка и частотность",
      "description": "Средняя позиция, топ-3…топ-100 и видимость по датам; частотность фраз"
    },
    {
      "name": "Сканы",
      "description": "Смета, запуск, статус и отмена сканов (право `scan`, тратит баланс)"
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "operationId": "health",
        "summary": "Проверка доступности",
        "x-internal": true,
        "security": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "status": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "tags": [
          "Аккаунт"
        ]
      }
    },
    "/me": {
      "get": {
        "operationId": "getMe",
        "summary": "Аккаунт ключа и баланс",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "account_id": {
                          "type": "integer"
                        },
                        "email": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "balance_rub": {
                          "type": "number",
                          "description": "Баланс, как в интерфейсе; резерв идущих сканов уже вычтен"
                        },
                        "reserved_rub": {
                          "type": "number",
                          "description": "Заморожено под идущие сканы"
                        },
                        "available_balance_rub": {
                          "type": "number",
                          "description": "Можно потратить на новые сканы"
                        },
                        "key": {
                          "type": "object",
                          "description": "Ключ, которым сделан запрос",
                          "properties": {
                            "name": {
                              "type": "string"
                            },
                            "scopes": {
                              "type": "array",
                              "items": {
                                "type": "string",
                                "enum": [
                                  "read",
                                  "write",
                                  "scan"
                                ]
                              }
                            }
                          },
                          "required": [
                            "name",
                            "scopes"
                          ]
                        }
                      },
                      "required": [
                        "account_id",
                        "balance_rub",
                        "reserved_rub",
                        "available_balance_rub",
                        "key"
                      ],
                      "example": {
                        "account_id": 7,
                        "email": "seo@example.ru",
                        "balance_rub": 1250.4,
                        "reserved_rub": 36.5,
                        "available_balance_rub": 1250.4,
                        "key": {
                          "name": "Отчеты",
                          "scopes": [
                            "read",
                            "scan"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Аккаунт"
        ]
      }
    },
    "/projects": {
      "get": {
        "operationId": "listProjects",
        "summary": "Список проектов",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Размер страницы, до 1000",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Сдвиг",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "updated_since",
            "in": "query",
            "required": false,
            "description": "Только проекты, где после этого момента завершился скан позиций или частотности (любой финал). Время ISO-8601, например `2026-11-04T00:00:00Z`",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "example": "2026-11-04T00:00:00Z"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Project"
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Проекты"
        ]
      }
    },
    "/projects/{projectId}": {
      "get": {
        "operationId": "getProject",
        "summary": "Проект",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Project"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Проекты"
        ],
        "description": "Проект с целями, числом фраз и включенным расписанием — как в списке"
      }
    },
    "/projects/{projectId}/groups": {
      "get": {
        "operationId": "listGroups",
        "summary": "Группы фраз",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Group"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Проекты"
        ]
      }
    },
    "/projects/{projectId}/phrases": {
      "get": {
        "operationId": "listPhrases",
        "summary": "Фразы",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "group_id",
            "in": "query",
            "required": false,
            "description": "Только фразы группы",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "archived",
            "in": "query",
            "required": false,
            "description": "true — фразы из архива вместо активных",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Размер страницы, до 1000",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Сдвиг",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Phrase"
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Фразы"
        ]
      },
      "post": {
        "operationId": "createPhrases",
        "summary": "Добавить фразы (право write)",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "phrases": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 1000,
                    "items": {
                      "oneOf": [
                        {
                          "type": "string",
                          "maxLength": 255
                        },
                        {
                          "type": "object",
                          "properties": {
                            "text": {
                              "type": "string",
                              "maxLength": 255
                            },
                            "group": {
                              "type": [
                                "string",
                                "null"
                              ],
                              "description": "Имя группы: нет такой — создадим. Вместе с group_id — 400"
                            },
                            "group_id": {
                              "type": [
                                "integer",
                                "null"
                              ],
                              "minimum": 1,
                              "description": "id группы этого проекта (GET /projects/{projectId}/groups). Чужой или несуществующий — 400"
                            }
                          },
                          "required": [
                            "text"
                          ]
                        }
                      ]
                    }
                  },
                  "lowercase": {
                    "type": "boolean",
                    "default": true
                  }
                },
                "required": [
                  "phrases"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Добавлено",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "created": {
                          "type": "integer",
                          "description": "Сколько фраз создали"
                        },
                        "skipped_existing": {
                          "type": "integer",
                          "description": "Уже были в проекте — не трогаем, группу не меняем"
                        },
                        "skipped_limit": {
                          "type": "integer",
                          "description": "Не влезли в лимит фраз тарифа. Это не ошибка: остальные добавлены"
                        },
                        "groups_created": {
                          "type": "integer",
                          "description": "Сколько групп создали по group"
                        },
                        "created_phrases": {
                          "type": "array",
                          "description": "Созданные и возвращенные из архива фразы в порядке запроса. Пропущенных (skipped_*) здесь нет",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "integer"
                              },
                              "text": {
                                "type": "string"
                              },
                              "group_id": {
                                "type": [
                                  "integer",
                                  "null"
                                ]
                              },
                              "restored": {
                                "type": "boolean",
                                "description": "true — фраза вернулась из архива, id и история прежние"
                              }
                            },
                            "required": [
                              "id",
                              "text",
                              "group_id"
                            ]
                          }
                        },
                        "restored": {
                          "type": "integer",
                          "description": "Сколько фраз вернули из архива: текст совпал с архивной фразой — возвращаем ее с историей, а не создаем новую. Считаются в лимите"
                        }
                      },
                      "required": [
                        "created",
                        "skipped_existing",
                        "skipped_limit",
                        "groups_created"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "403": {
            "$ref": "#/components/responses/403"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Фразы"
        ],
        "description": "Добавляем фразы пачкой до 1000. Повтор безопасен: фраза, которая уже есть, не создается второй раз.\n\n**Дубли.** Фразы сравниваем без учета регистра и без операторов: кавычки, `!`, `[ ]`, `« »`, `„ “ ”` считаем пробелами, лишние пробелы схлопываем. `\"Купить диван\"`, `!купить !диван` и `купить диван` — одна фраза. `+` и `-` — часть фразы. `е` и `е` различаем. Дубль внутри запроса пропускаем молча — он не попадет ни в один счетчик.\n\n**Хранение.** Новая фраза сохраняется как пришла, с операторами; при `lowercase: true` (по умолчанию) — строчными.\n\n**Группы.** `group` — по имени (точное совпадение, нет — создадим), `group_id` — по id группы проекта. Группа ставится только новой фразе.\n\n**Не атомарно.** Фразы создаем по одной: сверх лимита тарифа — в `skipped_limit`, остальное добавлено"
      },
      "delete": {
        "operationId": "deletePhrases",
        "summary": "Убрать фразы в архив (право write)",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "ids",
            "in": "query",
            "required": true,
            "description": "id через запятую, до 100",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "archived": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "Убрали в архив"
                        },
                        "deleted": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "То же, что archived: поле первой версии метода"
                        },
                        "not_found": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "Нет в проекте или уже в архиве"
                        }
                      },
                      "required": [
                        "archived",
                        "deleted",
                        "not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "403": {
            "$ref": "#/components/responses/403"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Фразы"
        ],
        "description": "Фразы не удаляются: уходят в архив вместе с историей позиций и частотности. Архивная фраза не сканируется, не видна в списках, позициях и сводке и не занимает лимит фраз. Вернуть — POST /projects/{projectId}/phrases/restore или добавить фразу с тем же текстом"
      }
    },
    "/projects/{projectId}/positions": {
      "get": {
        "operationId": "getPositions",
        "summary": "Позиции по датам",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Ключ цели (см. targets проекта); обязателен, если целей больше одной. Принимаются и алиасы ключей Google",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "Начало окна (МСК), по умолчанию 30 дней до date_to",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "Конец окна (МСК), по умолчанию сегодня; окно до 366 дней",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "group_id",
            "in": "query",
            "required": false,
            "description": "Только фразы группы",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "phrase_ids",
            "in": "query",
            "required": false,
            "description": "id фраз через запятую, до 1000",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Размер страницы, до 500",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Сдвиг",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "competitors",
            "in": "query",
            "required": false,
            "description": "Добавить позиции конкурентов проекта",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "description": "ETag прошлого ответа → 304, если данные не менялись",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "target": {
                          "$ref": "#/components/schemas/Target"
                        },
                        "dates": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "format": "date"
                          }
                        },
                        "competitors": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          },
                          "description": "Только при competitors=true"
                        },
                        "phrases": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/PhrasePositions"
                          }
                        }
                      },
                      "required": [
                        "target",
                        "dates",
                        "phrases"
                      ]
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    }
                  }
                }
              }
            },
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "Хэш тела ответа"
              }
            }
          },
          "304": {
            "description": "Не изменилось (If-None-Match)"
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Позиции и выдача"
        ]
      }
    },
    "/projects/{projectId}/positions/latest": {
      "get": {
        "operationId": "getLatestPositions",
        "summary": "Позиции на последнюю дату цели",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Ключ цели (см. targets проекта); обязателен, если целей больше одной. Принимаются и алиасы ключей Google",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "group_id",
            "in": "query",
            "required": false,
            "description": "Только фразы группы",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "phrase_ids",
            "in": "query",
            "required": false,
            "description": "id фраз через запятую, до 1000",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Размер страницы, до 500",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Сдвиг",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "description": "ETag прошлого ответа → 304, если данные не менялись",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "target": {
                          "$ref": "#/components/schemas/Target"
                        },
                        "dates": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "format": "date"
                          },
                          "maxItems": 1
                        },
                        "phrases": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/PhrasePositions"
                          }
                        }
                      },
                      "required": [
                        "target",
                        "dates",
                        "phrases"
                      ]
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    }
                  }
                }
              }
            },
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "Хэш тела ответа"
              }
            }
          },
          "304": {
            "description": "Не изменилось (If-None-Match)"
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Позиции и выдача"
        ]
      }
    },
    "/projects/{projectId}/positions/{phraseId}/serp": {
      "get": {
        "operationId": "getPhraseSerp",
        "summary": "Топ-100 выдачи по фразе",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "phraseId",
            "in": "path",
            "required": true,
            "description": "id фразы",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Ключ цели (см. targets проекта); обязателен, если целей больше одной. Принимаются и алиасы ключей Google",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date",
            "in": "query",
            "required": false,
            "description": "Последний снимок не позже конца этого дня (МСК); без даты — последний",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "phrase_id": {
                          "type": "integer"
                        },
                        "text": {
                          "type": "string"
                        },
                        "target": {
                          "$ref": "#/components/schemas/Target"
                        },
                        "collected_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time"
                        },
                        "results": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "position": {
                                "type": "integer"
                              },
                              "url": {
                                "type": "string"
                              },
                              "title": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "own": {
                                "type": "boolean",
                                "description": "URL сайта проекта"
                              }
                            },
                            "required": [
                              "position",
                              "url",
                              "own"
                            ]
                          }
                        }
                      },
                      "required": [
                        "phrase_id",
                        "target",
                        "collected_at",
                        "results"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Позиции и выдача"
        ]
      }
    },
    "/projects/{projectId}/summary": {
      "get": {
        "operationId": "getSummary",
        "summary": "Сводка по датам",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Ключ цели (см. targets проекта); обязателен, если целей больше одной. Принимаются и алиасы ключей Google",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "Начало окна (МСК), по умолчанию 30 дней до date_to",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "Конец окна (МСК), по умолчанию сегодня; окно до 366 дней",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "group_id",
            "in": "query",
            "required": false,
            "description": "Только фразы группы",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "phrase_ids",
            "in": "query",
            "required": false,
            "description": "id фраз через запятую, до 1000",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "description": "ETag прошлого ответа → 304, если данные не менялись",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "target": {
                          "$ref": "#/components/schemas/Target"
                        },
                        "phrases_count": {
                          "type": "integer"
                        },
                        "dates": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/SummaryRow"
                          }
                        }
                      },
                      "required": [
                        "target",
                        "phrases_count",
                        "dates"
                      ]
                    }
                  }
                }
              }
            },
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "Хэш тела ответа"
              }
            }
          },
          "304": {
            "description": "Не изменилось (If-None-Match)"
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Сводка и частотность"
        ]
      }
    },
    "/projects/{projectId}/summary/chart": {
      "get": {
        "operationId": "getSummaryChart",
        "summary": "Сводка колонками для графика",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Ключ цели (см. targets проекта); обязателен, если целей больше одной. Принимаются и алиасы ключей Google",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "Начало окна (МСК), по умолчанию 30 дней до date_to",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "Конец окна (МСК), по умолчанию сегодня; окно до 366 дней",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "group_id",
            "in": "query",
            "required": false,
            "description": "Только фразы группы",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "phrase_ids",
            "in": "query",
            "required": false,
            "description": "id фраз через запятую, до 1000",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "description": "ETag прошлого ответа → 304, если данные не менялись",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "target": {
                          "$ref": "#/components/schemas/Target"
                        },
                        "phrases_count": {
                          "type": "integer"
                        },
                        "dates": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "format": "date"
                          }
                        },
                        "visibility_percent": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "avg_position": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "found_count": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "total": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "top3": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "top5": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "top10": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "top20": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "top30": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "top50": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        },
                        "top100": {
                          "type": "array",
                          "items": {
                            "type": [
                              "number",
                              "null"
                            ]
                          }
                        }
                      },
                      "required": [
                        "target",
                        "phrases_count",
                        "dates"
                      ]
                    }
                  }
                }
              }
            },
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "Хэш тела ответа"
              }
            }
          },
          "304": {
            "description": "Не изменилось (If-None-Match)"
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Сводка и частотность"
        ]
      }
    },
    "/projects/{projectId}/frequency": {
      "get": {
        "operationId": "getFrequency",
        "summary": "Частотность: текущая или история по дням",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "region",
            "in": "query",
            "required": false,
            "description": "lr Яндекса или all (по умолчанию)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "С датами — история по дням МСК",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "Конец окна истории",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "group_id",
            "in": "query",
            "required": false,
            "description": "Только фразы группы",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "phrase_ids",
            "in": "query",
            "required": false,
            "description": "id фраз через запятую, до 1000",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Размер страницы, до 1000",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Сдвиг",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "region": {
                          "type": "string"
                        },
                        "dates": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "format": "date"
                          },
                          "description": "Только с date_from/date_to"
                        },
                        "phrases": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "phrase_id": {
                                "type": "integer"
                              },
                              "text": {
                                "type": "string"
                              },
                              "frequency": {
                                "$ref": "#/components/schemas/Frequency"
                              },
                              "history": {
                                "type": "object",
                                "additionalProperties": {
                                  "type": "object",
                                  "properties": {
                                    "ws": {
                                      "type": "number"
                                    },
                                    "not_ws": {
                                      "type": "number"
                                    },
                                    "google_ws": {
                                      "type": "number"
                                    }
                                  }
                                },
                                "description": "Только с date_from/date_to"
                              }
                            },
                            "required": [
                              "phrase_id",
                              "text"
                            ]
                          }
                        }
                      },
                      "required": [
                        "region",
                        "phrases"
                      ]
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Сводка и частотность"
        ]
      }
    },
    "/projects/{projectId}/scans": {
      "post": {
        "operationId": "createScan",
        "summary": "Запустить скан, по смете или получить смету (право scan)",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Повтор с тем же ключом в течение суток вернет тот же джоб",
            "schema": {
              "type": "string",
              "maxLength": 255
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "serp",
                      "frequency"
                    ],
                    "description": "Обязателен без quote_id"
                  },
                  "phrase_ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "minItems": 1,
                    "description": "До 50 000. Без поля — все фразы проекта на момент запуска"
                  },
                  "targets": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "description": "Только serp: ключи целей проекта, можно из разных регионов и поисковиков — один скан. Без поля — все цели"
                  },
                  "scan_mode": {
                    "type": "string",
                    "enum": [
                      "full",
                      "missing_only"
                    ],
                    "default": "full",
                    "description": "missing_only — только фразы без успешной проверки по какой-то из целей; с phrase_ids и для frequency не действует"
                  },
                  "frequency_mode": {
                    "type": "string",
                    "enum": [
                      "yandex",
                      "google_ws"
                    ],
                    "default": "yandex"
                  },
                  "dry_run": {
                    "type": "boolean",
                    "default": false
                  },
                  "callback_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uri",
                    "description": "https, публичный адрес; по завершении — POST WebhookEvent"
                  },
                  "quote_id": {
                    "type": "string",
                    "pattern": "^q_[0-9a-f]{32}$",
                    "description": "Запуск по смете из POST /scans/quote: параметры скана берем из нее, рядом допустим только callback_url; спишем не больше сметы. Смета одноразовая — повтор тем же quote_id отвечает 409 QUOTE_USED"
                  }
                },
                "description": "Два варианта: по смете (quote_id и, по желанию, callback_url) или с параметрами (type обязателен, quote_id нет)",
                "oneOf": [
                  {
                    "title": "По смете",
                    "type": "object",
                    "required": [
                      "quote_id"
                    ],
                    "additionalProperties": false,
                    "properties": {
                      "quote_id": {
                        "type": "string"
                      },
                      "callback_url": {
                        "type": [
                          "string",
                          "null"
                        ]
                      }
                    }
                  },
                  {
                    "title": "С параметрами",
                    "type": "object",
                    "required": [
                      "type"
                    ],
                    "not": {
                      "required": [
                        "quote_id"
                      ]
                    }
                  }
                ]
              }
            }
          }
        },
        "callbacks": {
          "jobFinished": {
            "{$request.body#/callback_url}": {
              "post": {
                "requestBody": {
                  "content": {
                    "application/json": {
                      "schema": {
                        "$ref": "#/components/schemas/WebhookEvent"
                      }
                    }
                  }
                },
                "responses": {
                  "200": {
                    "description": "Любой 2xx — доставлено; иначе повтор через 1, 5 и 30 минут"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Смета (dry_run)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "estimated_charge_rub": {
                          "type": "number"
                        },
                        "available_balance_rub": {
                          "type": [
                            "number",
                            "null"
                          ]
                        },
                        "can_start": {
                          "type": "boolean"
                        },
                        "request_count": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "phrase_count": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "estimated_charge_rub",
                        "can_start"
                      ]
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Скан поставлен",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "job": {
                          "type": "object",
                          "properties": {
                            "id": {
                              "type": "integer"
                            },
                            "status": {
                              "type": "string"
                            },
                            "charge_rub": {
                              "type": "number"
                            }
                          },
                          "required": [
                            "id",
                            "status",
                            "charge_rub"
                          ]
                        }
                      },
                      "required": [
                        "job"
                      ]
                    }
                  }
                }
              }
            },
            "headers": {
              "Idempotent-Replayed": {
                "schema": {
                  "type": "string"
                },
                "description": "true — ответ на повтор с тем же Idempotency-Key"
              }
            }
          },
          "409": {
            "description": "Запрос с этим Idempotency-Key еще обрабатывается; QUOTE_STALE — цена выросла после сметы; QUOTE_USED — смета уже использована для запуска",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Скан не принят: баланс, сервис, ключ провайдера; или Idempotency-Key с другим телом",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "403": {
            "$ref": "#/components/responses/403"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Сканы"
        ]
      }
    },
    "/projects/{projectId}/scans/quote": {
      "post": {
        "operationId": "createScanQuote",
        "summary": "Смета с гарантией цены (право scan)",
        "description": "Запуск с этим quote_id в течение 15 минут не спишет больше estimated_charge_rub; если цена выросла — 409 QUOTE_STALE. Смета одноразовая — повторный запуск по ней вернет 409 QUOTE_USED\n\nСкан по смете не переезжает на Яндекс Live при недоступной обычной выдаче: фразы, которые провайдер не отдал, падают с возвратом денег",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "serp",
                      "frequency"
                    ]
                  },
                  "phrase_ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "minItems": 1,
                    "description": "Без поля — все фразы проекта"
                  },
                  "targets": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "description": "Только serp, цели одного региона; без поля — все цели"
                  },
                  "scan_mode": {
                    "type": "string",
                    "enum": [
                      "full",
                      "missing_only"
                    ],
                    "default": "full"
                  },
                  "frequency_mode": {
                    "type": "string",
                    "enum": [
                      "yandex",
                      "google_ws"
                    ],
                    "default": "yandex"
                  }
                },
                "required": [
                  "type"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Смета выдана",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "quote_id": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "null — стартовать нельзя (см. can_start, message)"
                        },
                        "expires_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time",
                          "description": "Через 15 минут после сметы"
                        },
                        "estimated_charge_rub": {
                          "type": "number"
                        },
                        "available_balance_rub": {
                          "type": [
                            "number",
                            "null"
                          ]
                        },
                        "can_start": {
                          "type": "boolean"
                        },
                        "request_count": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "phrase_count": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "quote_id",
                        "expires_at",
                        "estimated_charge_rub",
                        "can_start"
                      ]
                    }
                  }
                }
              }
            }
          },
          "200": {
            "description": "Стартовать нельзя — quote_id null",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "quote_id": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "null — стартовать нельзя (см. can_start, message)"
                        },
                        "expires_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time",
                          "description": "Через 15 минут после сметы"
                        },
                        "estimated_charge_rub": {
                          "type": "number"
                        },
                        "available_balance_rub": {
                          "type": [
                            "number",
                            "null"
                          ]
                        },
                        "can_start": {
                          "type": "boolean"
                        },
                        "request_count": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "phrase_count": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "quote_id",
                        "expires_at",
                        "estimated_charge_rub",
                        "can_start"
                      ]
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Смету не посчитать: сервис, ключ провайдера",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "403": {
            "$ref": "#/components/responses/403"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          },
          "503": {
            "$ref": "#/components/responses/503"
          }
        },
        "tags": [
          "Сканы"
        ]
      }
    },
    "/jobs": {
      "get": {
        "operationId": "listJobs",
        "summary": "Список джобов",
        "description": "Сканы и сборы частотности аккаунта, новые первыми (по убыванию `id`). Сумма — как в `GET /jobs/{jobId}`. Здесь нет `progress` (всегда `null`), состава скана и ошибок (`targets`, `phrase_count`, `scan_mode`, `errors_count`) — за ними в `GET /jobs/{jobId}`.\n\n`idempotency_key` находит джоб, запущенный с этим `Idempotency-Key` тем же ключом API, пока храним журнал ключей — около 7 дней после запуска. Другой ключ API или запись уже удалена — пустой список",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Только джобы проекта",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Публичный статус. `running` и `waiting_provider` выбирают одно и то же — идущие джобы",
            "schema": {
              "type": "string",
              "enum": [
                "queued",
                "running",
                "waiting_provider",
                "completed",
                "failed",
                "cancelled"
              ]
            }
          },
          {
            "name": "idempotency_key",
            "in": "query",
            "required": false,
            "description": "Значение `Idempotency-Key` запуска: до 255 печатных ASCII-символов",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255
            }
          },
          {
            "name": "created_from",
            "in": "query",
            "required": false,
            "description": "Созданы не раньше этой даты (МСК), включительно",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "created_to",
            "in": "query",
            "required": false,
            "description": "Созданы не позже этой даты (МСК), включительно",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Размер страницы, до 100",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Сдвиг",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Job"
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/PageMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Сканы"
        ]
      }
    },
    "/jobs/{jobId}": {
      "get": {
        "operationId": "getJob",
        "summary": "Статус джоба",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "id джоба",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Job"
                    }
                  }
                }
              }
            },
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Пока джоб не завершен: опрашивайте не чаще"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Сканы"
        ],
        "description": "Статус, сумма и состав скана. Ошибки запросов (`errors_count`, `has_errors`) — после финала"
      }
    },
    "/jobs/{jobId}/cancel": {
      "post": {
        "operationId": "cancelJob",
        "summary": "Отменить джоб (право scan)",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "id джоба",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        },
                        "status": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "id",
                        "status"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "403": {
            "$ref": "#/components/responses/403"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Сканы"
        ]
      }
    },
    "/projects/{projectId}/phrases/restore": {
      "post": {
        "operationId": "restorePhrases",
        "summary": "Вернуть фразы из архива (право write)",
        "description": "Возвращаем в пределах лимита фраз тарифа: восстановленная фраза снова занимает лимит. История остается прежней",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "ids",
            "in": "query",
            "required": true,
            "description": "id через запятую, до 100",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "restored": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          }
                        },
                        "skipped_limit": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "Не влезли в лимит фраз — остались в архиве"
                        },
                        "not_found": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          },
                          "description": "Нет в архиве проекта"
                        }
                      },
                      "required": [
                        "restored",
                        "skipped_limit",
                        "not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "403": {
            "$ref": "#/components/responses/403"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Фразы"
        ]
      }
    },
    "/projects/{projectId}/phrases/{phraseId}": {
      "patch": {
        "operationId": "updatePhrase",
        "summary": "Перенести фразу в группу или задать целевой URL (право write)",
        "description": "История позиций не меняется. Поле не передано — не трогаем, null — очищаем (group_id: null — без группы). Архивную фразу сначала верните из архива",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "id проекта",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "phraseId",
            "in": "path",
            "required": true,
            "description": "id фразы",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "minProperties": 1,
                "properties": {
                  "group_id": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "id группы этого проекта или null"
                  },
                  "target_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Целевая страница, http(s); без схемы добавим https://. null или пустая строка — очистить"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Phrase"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/400"
          },
          "401": {
            "$ref": "#/components/responses/401"
          },
          "403": {
            "$ref": "#/components/responses/403"
          },
          "404": {
            "$ref": "#/components/responses/404"
          },
          "429": {
            "$ref": "#/components/responses/429"
          }
        },
        "tags": [
          "Фразы"
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "Ключ wc_live_… из вкладки «Доступ по API»"
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string"
              },
              "message": {
                "type": "string"
              }
            },
            "required": [
              "code",
              "message"
            ]
          }
        },
        "required": [
          "error"
        ]
      },
      "PageMeta": {
        "type": "object",
        "properties": {
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          }
        },
        "required": [
          "limit",
          "offset",
          "total"
        ]
      },
      "Target": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "description": "Непрозрачный ключ цели"
          },
          "engine": {
            "type": "string",
            "enum": [
              "yandex",
              "google"
            ]
          },
          "region_id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Яндекс — lr (null = все регионы), Google — location code"
          },
          "region_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "device": {
            "type": "string",
            "enum": [
              "desktop",
              "mobile"
            ]
          },
          "domain": {
            "type": [
              "string",
              "null"
            ],
            "description": "Только Google"
          },
          "depth": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Только Google: глубина проверки"
          },
          "source": {
            "type": "string",
            "enum": [
              "xml",
              "live"
            ],
            "description": "Только Яндекс"
          }
        },
        "required": [
          "key",
          "engine",
          "region_id",
          "device"
        ]
      },
      "Schedule": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "frequency": {
            "type": "string",
            "enum": [
              "daily",
              "weekly",
              "monthly"
            ]
          },
          "time": {
            "type": "string",
            "description": "ЧЧ:ММ в часовом поясе расписания (по умолчанию Москва)",
            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
          },
          "week_days": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Для weekly: 0 — воскресенье … 6 — суббота"
          },
          "month_days": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Для monthly: числа 1–31"
          }
        },
        "description": "Расписание из интерфейса, только чтение; null — выключено. Отдается в GET /projects и GET /projects/{projectId}"
      },
      "Project": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "domain": {
            "type": [
              "string",
              "null"
            ]
          },
          "phrases_count": {
            "type": "integer"
          },
          "subdomain_check": {
            "type": "boolean"
          },
          "targets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Target"
            }
          },
          "schedule": {
            "$ref": "#/components/schemas/Schedule"
          },
          "visibility_percent": {
            "type": [
              "number",
              "null"
            ]
          },
          "visibility_delta": {
            "type": [
              "number",
              "null"
            ]
          },
          "top10_count": {
            "type": [
              "integer",
              "null"
            ]
          },
          "last_scan_finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Когда завершился последний скан позиций или частотности (любой финал); `null` — сканов не было"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "name",
          "domain",
          "subdomain_check",
          "targets"
        ]
      },
      "Group": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "color": {
            "type": [
              "string",
              "null"
            ]
          },
          "sort_order": {
            "type": [
              "integer",
              "null"
            ]
          }
        },
        "required": [
          "id",
          "name"
        ]
      },
      "Phrase": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "text": {
            "type": "string"
          },
          "group_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "target_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "archived_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Когда фразу убрали в архив; null — фраза активна"
          }
        },
        "required": [
          "id",
          "text",
          "group_id"
        ]
      },
      "PositionCell": {
        "oneOf": [
          {
            "type": "object",
            "properties": {
              "position": {
                "type": "integer",
                "minimum": 1
              },
              "url": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "job_id": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Скан, чья позиция в ячейке; `null` — импорт или старые данные"
              }
            },
            "required": [
              "position"
            ]
          },
          {
            "type": "object",
            "properties": {
              "position": {
                "type": "null"
              },
              "not_in_top": {
                "const": true
              },
              "job_id": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Скан, чья позиция в ячейке; `null` — импорт или старые данные"
              }
            },
            "required": [
              "position",
              "not_in_top"
            ]
          }
        ],
        "description": "Нет ключа даты — фраза в этот день не проверялась"
      },
      "PositionsByDate": {
        "type": "object",
        "additionalProperties": {
          "$ref": "#/components/schemas/PositionCell"
        },
        "description": "Ключ — дата YYYY-MM-DD (МСК)"
      },
      "PhrasePositions": {
        "type": "object",
        "properties": {
          "phrase_id": {
            "type": "integer"
          },
          "text": {
            "type": "string"
          },
          "group_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "positions": {
            "$ref": "#/components/schemas/PositionsByDate"
          },
          "competitors": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/PositionsByDate"
            },
            "description": "Только при competitors=true: домен → позиции по датам"
          }
        },
        "required": [
          "phrase_id",
          "text",
          "group_id",
          "positions"
        ]
      },
      "SummaryRow": {
        "type": "object",
        "properties": {
          "date": {
            "type": "string",
            "format": "date"
          },
          "total": {
            "type": "integer",
            "description": "Проверенные фразы (место или «вне топа»)"
          },
          "found_count": {
            "type": "integer",
            "description": "Фразы с местом 1–100"
          },
          "avg_position": {
            "type": [
              "number",
              "null"
            ],
            "description": "Среднее место среди найденных, до 0,1; «вне топа» не участвует; null — никого не нашли"
          },
          "top3": {
            "type": "integer",
            "description": "Фраз с местом 1–3 (накопительно)"
          },
          "top5": {
            "type": "integer",
            "description": "Фраз с местом 1–5 (накопительно)"
          },
          "top10": {
            "type": "integer",
            "description": "Фраз с местом 1–10 (накопительно)"
          },
          "top20": {
            "type": "integer",
            "description": "Фраз с местом 1–20 (накопительно)"
          },
          "top30": {
            "type": "integer",
            "description": "Фраз с местом 1–30 (накопительно)"
          },
          "top50": {
            "type": "integer",
            "description": "Фраз с местом 1–50 (накопительно)"
          },
          "top100": {
            "type": "integer",
            "description": "Фраз с местом 1–100 (накопительно)"
          },
          "visibility_percent": {
            "type": "number",
            "description": "Только в /summary/chart: Σ(not_ws × вес места) / Σ not_ws × 100; веса — в «Как считаем показатели»"
          }
        },
        "required": [
          "date",
          "total",
          "found_count",
          "avg_position"
        ]
      },
      "Frequency": {
        "type": "object",
        "properties": {
          "ws": {
            "type": [
              "number",
              "null"
            ],
            "description": "Базовая частотность Вордстата; 0 — собрали, запросов нет; null — не собирали"
          },
          "not_ws": {
            "type": [
              "number",
              "null"
            ],
            "description": "Точная частотность Вордстата («!слово»)"
          },
          "google_ws": {
            "type": [
              "number",
              "null"
            ],
            "description": "Объем Google Ads"
          },
          "collected_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "Job": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "project_id": {
            "type": "integer"
          },
          "type": {
            "type": "string",
            "enum": [
              "serp",
              "frequency"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "waiting_provider",
              "completed",
              "failed",
              "cancelled"
            ],
            "description": "Финал — completed, failed, cancelled; waiting_provider — ждем, пока провайдер снова начнет отвечать"
          },
          "progress": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "done": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "total": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "percent": {
                "type": [
                  "number",
                  "null"
                ]
              }
            }
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "charge_rub": {
            "type": "number",
            "description": "Пока скан идет — замороженная сумма; после финала — фактическое списание (неудачные запросы возвращаются)"
          },
          "charge_settled": {
            "type": "boolean",
            "description": "true — charge_rub окончательный (после финала)"
          },
          "targets": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Ключи целей скана. Только в GET /jobs/{jobId}; null — сбор частотности"
          },
          "phrase_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Сколько фраз выбрано в скан. Только в GET /jobs/{jobId}"
          },
          "scan_mode": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "full",
              "missing_only",
              null
            ],
            "description": "Режим скана, как в запуске. Только в GET /jobs/{jobId}"
          },
          "errors_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Запросов к провайдеру, упавших окончательно. Только в GET /jobs/{jobId}; до финала null — упавшие еще повторяем"
          },
          "has_errors": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "errors_count > 0: джоб завершен, но часть данных не собрали. До финала null"
          }
        },
        "required": [
          "id",
          "project_id",
          "type",
          "status",
          "progress",
          "charge_rub",
          "charge_settled"
        ],
        "example": {
          "id": 412,
          "project_id": 123,
          "type": "serp",
          "status": "completed",
          "progress": {
            "done": 250,
            "total": 250,
            "percent": 100
          },
          "created_at": "2026-11-05T21:00:02Z",
          "started_at": "2026-11-05T21:00:05Z",
          "finished_at": "2026-11-05T21:14:40Z",
          "charge_rub": 11.75,
          "charge_settled": true,
          "targets": [
            "yandex_213"
          ],
          "phrase_count": 250,
          "scan_mode": "full",
          "errors_count": 0,
          "has_errors": false
        }
      },
      "WebhookEvent": {
        "type": "object",
        "properties": {
          "event": {
            "const": "job.finished"
          },
          "job_id": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "description": "completed, failed или cancelled"
          }
        },
        "required": [
          "event",
          "job_id",
          "status"
        ],
        "example": {
          "event": "job.finished",
          "job_id": 412,
          "status": "completed"
        }
      }
    },
    "responses": {
      "400": {
        "description": "Неверные параметры (INVALID_PARAMS, HTTPS_REQUIRED)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "401": {
        "description": "Нет ключа или ключ неверен (UNAUTHORIZED)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "403": {
        "description": "Нет права у ключа (SCOPE_REQUIRED) или лимит тарифа",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "404": {
        "description": "Не найдено (PROJECT_NOT_FOUND, PHRASE_NOT_FOUND, JOB_NOT_FOUND, NOT_FOUND)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "429": {
        "description": "Лимит запросов (RATE_LIMITED, SCAN_RATE_LIMITED), см. Retry-After",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "503": {
        "description": "Сервер занят (BUSY), см. Retry-After",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
