# twogis-service API

Внутренний HTTP-сервис для работы с 2GIS: официальный API и browser-backed
сбор через Chromium/CDP. Один сервис на все проекты — ключ 2GIS, browser-session
и обход блокировок живут здесь, интеграторам достаточно внутреннего токена.

- Базовый URL: `https://twogis.satushi.me`
- Внутри кластера: `http://twogis-service.twogis.svc.cluster.local:8081`
- Формат: JSON в запросе и ответе, UTF-8
- Машинная схема: [`/openapi.json`](/openapi.json)

## Офлайн-каталог (дополнительный контракт v1)

### Разовый исторический переход

Worker поддерживает `--once --regions 68 --require-baseline
--transition-approval /path/approval.json`. Этот файл задаёт согласованный переход:
`id`, `region_id`, `from_version/from_sha256`, `to_version/to_sha256`,
`archive_size`, `counts` (branches/organizations/buildings), `previous_counts`
и `missing` (branch/building). Все значения сверяются точно; старая версия
проверяется внутри транзакции публикации. Изменившийся manifest, checksum или
количества приводят к отказу. Согласование сохраняется в counts импорта.
Режимов loop/due больше нет; обычный порог исчезновения 15% не меняется.
Загрузками управляет Pulse через `/twogis/offline/manifest` и
`/twogis/offline/operations` — контракт в [offline.md](offline.md).
Повторная публикация того же файла идемпотентна. Это операторский CLI, не публичный API.

### Экспериментальный поиск с резервом

`POST /twogis/search` принимает параметры `/twogis/offline/search` и
`mode=auto|local|online` (по умолчанию `auto`). Требуется внутренний токен.
В `auto` сначала читается PostgreSQL; пустая выдача, недоступная БД или пакет
старше 45 дней переключают запрос на прямой Hybrid API 2ГИС. Никаких Netly/MQTT
и браузерных запусков на каждом поиске. `mode=online` позволяет явно запросить
резерв даже при непустой, но нерелевантной локальной выдаче.

```json
{"region_id":"68","query":"магнум","mode":"auto","limit":20}
```

Ответ помечен `source=local|online`, `provider`, `experimental` и
`fallback_reason=null|local_empty|local_unavailable|local_stale`.
Локальный поиск экспериментальный. Онлайн выдаёт **кандидатов Hybrid**, не полную
базу: `total_scope=returned_candidates`, `coverage=provider_candidates`;
`catalog=null` и `revision=null` у онлайн-карточек. Для локальной выдачи
`total_scope=catalog_matches`. Поля онлайн-карточек могут быть беднее офлайн.
Ответ не импортируется в каталог и не участвует в вычислении закрытий бизнеса.

Для следующих страниц передавать `mode`, равный `source` первой страницы:
`auto` с `offset>0` запрещён, чтобы не смешивать выдачи. Это не фиксирует
онлайн-выдачу во времени; стабильного upstream snapshot нет. Hybrid сохраняет
свой порядок результатов, `distance_m` информационный. При отсутствии координат
он равен `null`. `bbox` применяется и к запросу, и к ответу; регион, город,
тип и здание проверяются по данным каждого кандидата, фильтры не снимаются.
Возвращается фактический `viewport`; без bbox используются подсказочные viewport
городов, **это не гарантия полного охвата регионального пакета**.

Резерв пока поддерживает регионы 68/67 и текст от двух символов. Для пустого
поиска по зданию/области использовать `mode=local`; в auto при отсутствии
локальных данных такой запрос вернёт 503, а не результат из другой области.
Невалидный запрос → 422 без обращения к 2ГИС. Ошибка резерва → 502/503 с кодом
`online_search_unavailable`, а не успешный пустой ответ. Таймаут upstream-сокета
8 секунд, без повторов; тело ≤8 MiB, редиректы с ключом запрещены.
Существующие `/twogis/address-search` и `/twogis/catalog` сохраняют свои контракты.

Существующие online/browser маршруты сохранены. Новые маршруты обслуживают
локальную PostgreSQL и требуют тот же внутренний токен:

- `POST /twogis/offline/search`: `region_id` (68 Астана / 67 Алматы), `query`,
  `kind=branch|building`, необязательные `city_id`, `building_id`, `bbox`,
  пара `latitude`/`longitude`, `limit≤50`, `offset≤1000`.
- `GET /twogis/offline/status?region_id=68`: версия, дата пакета и последняя попытка.
- `GET /twogis/offline/entity?region_id=68&kind=branch&id=…`: текущая карточка.
- `GET /twogis/offline/snapshot?region_id=68&limit=100`: страницы bootstrap;
  следующие запросы передают `next`. Смена ревизии между страницами → 409/restart.
- `GET /twogis/offline/changes?region_id=68&cursor=0:0&limit=100`: события с
  `revision`, `ordinal`, `operation`, `patch`, `data_as_of`, `source_version`.
  Сохранять `next_cursor`, учитывать `has_more` и публиковать только полную ревизию.

Ответы содержат `catalog.contract_version=1`, `data_as_of` и признак `stale`.
Пакеты региональные, с окрестностями; это не live-спрос и не фактическая дата
открытия бизнеса. `missing` ≠ подтверждённое закрытие. Ошибка базы/неимпортированный
регион → 503, не пустая выдача. Поиск не обращается к 2ГИС или LLM.

## Аутентификация запросов

Все рабочие endpoint требуют заголовок:

```
X-Internal-Token: <token>
```

Токен лежит в Infisical: проект `twogis`, env `prod`, ключ `TWOGIS_INTERNAL_TOKEN`.
В Kubernetes он приезжает через External Secrets в secret `twogis-secrets` —
подключается в под как `envFrom`, отдельно копировать не надо.

Проверки состояния (`/health`, `/healthz`) и документация (`/docs`, `/llms.txt`)
работают без токена.

## Endpoints

| Метод | Путь | Назначение |
| --- | --- | --- |
| `POST` | `/twogis/address-search` | подсказки адресов внутри города |
| `POST` | `/twogis/apartment` | подъезд, этаж и диапазоны квартир |
| `POST` | `/twogis/place` | карточка организации: рейтинг и число отзывов |
| `POST` | `/twogis/reviews` | полная выгрузка отзывов с пагинацией |
| `POST` | `/twogis/session` | живая browser-session для Catalog API |
| `POST` | `/twogis/search-pages` | browser-захват страниц поиска и сырых Catalog items |
| `POST` | `/twogis/catalog` | нормализованный Catalog/HTML/browser API |
| `POST` | `/scrape` | запустить задание parser-2gis |
| `GET` | `/status/{run_id}`, `/rows/{run_id}`, `/result/{run_id}` | статус, поток и CSV |
| `POST` | `/pause/{run_id}`, `/resume/{run_id}`, `/cancel/{run_id}` | управление заданием |
| `GET` | `/health`, `/healthz` | liveness/readiness |
| `GET` | `/`, `/docs`, `/llms.txt` | лендинг и эта документация |

### POST /twogis/session

Открывает страницу 2GIS в headless Chromium, перехватывает реальные Catalog
запросы и возвращает параметры живой browser-session. Контракт совместим с
бывшим endpoint Pulse scraper. Pulse теперь обращается сюда только через
standalone-провайдера; скрытого отката на старый scraper нет.

```json
{
  "source_url": "https://2gis.kz/astana/search/кофе",
  "timeout_sec": 45
}
```

Ответ содержит `api_key`, `base_url`, `shv`, `stat_sid`, `stat_user`,
`search_user_hash`, `user_token`, viewpoint, browser headers и метаданные
захвата. Эти значения являются внутренними credentials провайдера и не должны
передаваться пользовательским клиентам или писаться в публичные логи.

### POST /twogis/search-pages

Открывает точный search URL в headless Chromium и собирает сырые элементы
Catalog с последовательных страниц. Используется Pulse для полного покрытия,
когда обычный parser пропускает часть поисковой выдачи.

```json
{
  "source_url": "https://2gis.kz/astana/search/кофе",
  "max_pages": 10,
  "timeout_sec": 600
}
```

`max_pages=0` означает все страницы, но действует жёсткий предел 300. Ответ:
`total`, `page_size`, `pages_captured`, массив сырых `items`, `elapsed_ms` и
нефатальный `error`, если после успешных страниц следующую страницу получить
не удалось. HTTP 502 означает, что не удалось получить базовый результат.

### POST /twogis/address-search

Подсказки адресов, улиц и организаций. Город обязателен: 2GIS ищет внутри
прямоугольника города, а не по тексту запроса.

Запрос:

| Поле | Тип | Обяз. | По умолчанию | Ограничения |
| --- | --- | --- | --- | --- |
| `query` | string | да | — | 2–300 символов |
| `city` | string | да | — | из списка поддерживаемых, регистр не важен |
| `locale` | string | нет | `ru_KZ` | 2–16 символов |
| `limit` | int | нет | `10` | 1–50 |
| `timeout_sec` | int | нет | `20` | 5–60 |

```bash
curl -sS -X POST https://twogis.satushi.me/twogis/address-search \
  -H 'Content-Type: application/json' \
  -H "X-Internal-Token: $TWOGIS_INTERNAL_TOKEN" \
  --data '{"query":"Улы Дала 14","city":"Астана","limit":5}'
```

Ответ:

```json
{
  "query": "Улы Дала 14",
  "city": "Астана",
  "locale": "ru_KZ",
  "items": [
    {
      "id": "70000001089125216_muke2u...",
      "type": "branch",
      "name": "14, магазин разливных напитков",
      "address": "Астана, проспект Улы Дала, 29",
      "city": "Астана",
      "latitude": 51.101405,
      "longitude": 71.392033,
      "source_url": "https://2gis.kz/firm/70000001089125216"
    }
  ],
  "total": 1,
  "captured_at": "2026-09-06T15:04:58.358902+00:00",
  "resolution_method": "hybrid_api"
}
```

Поля ответа:

| Поле | Описание |
| --- | --- |
| `city` | канонический город из справочника, а не то, что прислали |
| `items[].id` | id объекта 2GIS; у филиалов составной (`<branch_id>_<хеш>`) |
| `items[].type` | `building`, `street`, `branch`, `adm_div.*` и т.п. |
| `items[].source_url` | ссылка на 2GIS: `/firm/<branch_id>` для филиалов, иначе `/geo/<id>` |
| `total` | сколько объектов реально отдано после фильтрации |
| `resolution_method` | `hybrid_api`; `official_catalog` только при явном выборе legacy-транспорта |

Пустой `items` — нормальный ответ 200, а не ошибка: 2GIS отвечает
`itemNotFound`, если ничего не подошло. Так бывает и для существующих улиц,
если указать номер дома, которого нет в подсказках.

**Поддерживаемые города.** Границы и `region_id` берутся из таблицы `core_city`
базы cabin-backend; поддержаны те города, где эта разметка заполнена:

Актау, Актобе, Алматы, Астана, Атырау, Караганда, Каскелен, Кентау, Кокшетау,
Костанай, Павлодар, Петропавловск, Семей, Талгар, Тараз, Темиртау, Уральск,
Усть-Каменогорск, Шымкент.

Понимаются и алиасы: `Нур-Султан`, `Astana`, `Almaty`, `Shymkent` и подобные.
Незнакомый город — `400` с текстом `city is not supported: <город>`.

Один `region_id` покрывает и пригороды, поэтому выдача дополнительно
отфильтрована: по запросу для Алматы не придут объекты, явно принадлежащие
Талгару или Каскелену.

### POST /twogis/apartment

Требует `X-Internal-Token` и настроенный ключ 2ГИС.
Город задаётся `cityId` из cabin `core_city` (например, Астана `710000000`,
Алматы `750000000`). Viewpoint и `region_id` сервис выбирает из собственного
справочника 19 городов в `collector/twogis_cities.py`; клиент не передаёт координаты.
Старые запросы с городом в адресе или полем `city` сохраняют поддержку.
Неизвестный ID, отсутствие города или конфликт ID и названия дают HTTP 400
без запроса к провайдеру. Квартира — отдельным
строковым полем `apartment` либо в конце адреса с меткой `кв`, `квартира`,
`пәтер`, `apartment`, `apt`. Противоречащие друг другу номера отклоняются.

```bash
curl -sS https://twogis.satushi.me/twogis/apartment \
  -H 'Content-Type: application/json' \
  -H "X-Internal-Token: $TWOGIS_INTERNAL_TOKEN" \
  --data '{"address":"Сауран 3","cityId":"710000000","apartment":"12"}'
```

Эквивалентный запрос: `{"address":"Астана, Сауран, 3, кв 12"}`.
Необязательный `timeout_sec`: 5–60 секунд, по умолчанию 20.

Пример ответа, основанный на проверке Hybrid API от 10 сентября 2026:

```json
{
  "address": "Сауран 3",
  "apartment": "12",
  "city": "Астана",
  "status": "resolved",
  "building": {
    "id": "9570784863330957",
    "address": "улица Сауран, 3",
    "city": "Астана",
    "latitude": 51.125736,
    "longitude": 71.422064,
    "source_url": "https://2gis.kz/geo/9570784863330957"
  },
  "matches": [{
    "entrance_id": "70030076158799869",
    "entrance": "1",
    "floor": "4",
    "floor_source": "calculated",
    "calculated": true,
    "floor_range": {"start": "1", "end": "4"},
    "apartment_ranges": [{"start": "1", "end": "12"}],
    "floor_apartments": [{"start": "10", "end": "12"}]
  }],
  "candidates": [],
  "captured_at": "2026-09-10T00:00:00+00:00",
  "resolution_method": "hybrid_api"
}
```

`matches` содержит все совпадения квартиры внутри **одного точно совпавшего
дома**. Поиск проверяет город, улицу и номер дома по структурированному ответу:
дом `3/1` не считается домом `3`. Нормализуются регистр, пробелы, запятые и
типовые обозначения улицы/города/дома. Произвольные опечатки, транслитерация,
районы и названия ЖК в адресе могут потребовать уточнения по `candidates`.
Без города запрос отклоняется до обращения к провайдеру. Если точного
совпадения нет, допускаются склонения фамилий (Кошкарбаев/Кошкарбаева),
опущенное имя человека и пробелы в полном номере дома (38вк5/38в к5).
Разные имена людей, города, номера, дроби и корпуса не объединяются;
несколько подходящих домов остаются неоднозначными. Проверенные варианты
написания имён (например, Алихана Бокейханова / Алихан Бокейхан,
Рахимжана Кошкарбаева / Ракымжан Кошкарбаев) сопоставляются по явному
словарю внутри города. Отдельный префикс квартала перед улицей не входит
в её имя. Это не произвольный нечёткий поиск и не замена номера дома.

| Поле совпадения | Значение |
| --- | --- |
| `entrance` | Номер подъезда из 2ГИС; `null`, если отсутствует либо вместо номера пришёл внутренний ID |
| `floor` | Этаж строкой, включая текстовые обозначения; `null`, если неизвестен |
| `floor_source` | `reported` — конкретный этаж передан API; `calculated` — вычислен из диапазонов; `unknown` — определить нельзя |
| `calculated` | Исходный флаг 2ГИС; `null`, если провайдер его не прислал. Даже `reported` может иметь `calculated: true` |
| `floor_range` | Исходный диапазон этажей для найденной группы; `null`, если отсутствует |
| `apartment_ranges` | Квартиры всей найденной группы, а не обязательно одного этажа |
| `floor_apartments` | Квартиры определённого этажа; пустой список, если этаж неизвестен |

Все границы диапазонов включительны и представлены строками. Для диапазона
этажей точный этаж рассчитывается **при предположении равномерного размещения
квартир**: только для одного непрерывного числового диапазона квартир, положительных
последовательных этажей, деления без остатка и не более 20 квартир на этаж.
Этот консервативный предел исключает часть разреженной/кодированной нумерации;
он не доказывает равномерную планировку. Например, 12 квартир на 4 этажах
дают по 3 квартиры на этаж. Это расчётная подсказка, не подтверждение планировки.
При пропусках, неравномерном количестве, неподдерживаемой форме или неизвестном
этаже сервис сохраняет доступные диапазоны, но не придумывает этаж.

| `status` | Значение |
| --- | --- |
| `resolved` | Найдено одно совпадение с определённым этажом |
| `floor_unknown` | Квартира найдена, точный этаж неизвестен |
| `apartment_not_found` | Дом найден, сведений об этой квартире нет |
| `ambiguous_apartment` | Несколько совпадений квартиры; выбирать автоматически нельзя |
| `building_not_found` | Точное совпадение дома не найдено; возможные результаты в `candidates` |
| `ambiguous_building` | Несколько домов точно соответствуют запросу; требуется уточнение |

Эти статусы возвращаются с HTTP 200. Ошибки: 400 — отсутствующая/неверная квартира
или конфликт параметров; 401 — неверный внутренний токен; 422 — ошибка схемы
запроса; 503 — отсутствующая конфигурация; 502 — ошибка, отказ доступа, лимит или
таймаут провайдера. Ключи и полный ответ провайдера клиенту не возвращаются.

Транспорт: `GET https://hybrid-api.2giskz.app/v1/search/markers`, параметр `s_q`
содержит адрес дома, `s_V=viewpoint1,viewpoint2` — область карты города
именно обрабатываемого дома в очереди. Это поисковый контекст, не жёсткая
геометрическая граница; город в структурированном ответе проверяется отдельно.
`fields=address,adm_div,name,type,links.database_entrances`.
Номер квартиры сопоставляется локально по `apartments_info.floors`. Ключ Hybrid
API может не работать на `catalog.api.2gis.com`; при необходимости отдельный ключ задаётся через `TWOGIS_HYBRID_API_KEY` (по умолчанию используется `TWOGIS_API_KEY`).
[Документация 2ГИС по структуре полей Catalog API](https://docs.2gis.com/api/search/places/reference/3.0/items/byid).
Формат Hybrid API дополнительно проверен живым запросом; покрытие зависит от дома.

### POST /twogis/place

Карточка организации: рейтинг и число отзывов.

| Поле | Тип | Обяз. | По умолчанию | Ограничения |
| --- | --- | --- | --- | --- |
| `branch_id` | string | да, если нет `source_url` | `""` | только цифры, 10–30 знаков |
| `source_url` | string | да, если нет `branch_id` | `""` | ссылка `https://2gis.kz/...` или короткая `https://go.2gis.com/...` |
| `timeout_sec` | int | нет | `20` | 5–60 |

Можно прислать любое одно поле. Если пришли оба и `branch_id` не совпадает с
id из ссылки — `400`.

```bash
curl -sS -X POST https://twogis.satushi.me/twogis/place \
  -H 'Content-Type: application/json' \
  -H "X-Internal-Token: $TWOGIS_INTERNAL_TOKEN" \
  --data '{"branch_id":"70000001089125216"}'
```

```json
{
  "branch_id": "70000001089125216",
  "source_url": "https://2gis.kz/firm/70000001089125216",
  "name": null,
  "address": null,
  "rating": 4.8,
  "reviews_count": 30,
  "verified_at": "2026-09-06T18:53:13.824038+00:00"
}
```

`name` и `address` заполняются не всегда — они приходят только из публичного
профиля, когда тот доступен. Рейтинг и число отзывов есть всегда.

### POST /twogis/reviews

Полная выгрузка отзывов с пагинацией.

| Поле | Тип | Обяз. | По умолчанию | Ограничения |
| --- | --- | --- | --- | --- |
| `branch_id` | string | да, если нет `source_url` | `""` | только цифры, 10–30 знаков |
| `source_url` | string | да, если нет `branch_id` | `""` | ссылка 2GIS, в том числе короткая |
| `max_reviews` | int | нет | `0` (все) | 0–10000 |
| `timeout_sec` | int | нет | `120` | 10–900 |

```bash
curl -sS -X POST https://twogis.satushi.me/twogis/reviews \
  -H 'Content-Type: application/json' \
  -H "X-Internal-Token: $TWOGIS_INTERNAL_TOKEN" \
  --data '{"branch_id":"70000001089125216","max_reviews":3}'
```

```json
{
  "branch_id": "70000001089125216",
  "source_url": "https://2gis.kz/firm/70000001089125216/tab/reviews",
  "reviews": [
    {
      "id": "269197433",
      "rating": 1,
      "text": "…",
      "date_created": "2026-08-16T17:02:48.308537+07:00",
      "date_edited": null,
      "author_id": "…",
      "author_name": "…",
      "official_answer_text": null,
      "official_answer_date": null,
      "url": "…",
      "provider": "2gis",
      "is_hidden": false
    }
  ],
  "total_count": 29,
  "branch_rating": 4.8,
  "branch_reviews_count": 30,
  "pages_fetched": 1,
  "complete": false,
  "captured_at": "2026-09-06T18:55:10.101010+00:00",
  "elapsed_ms": 337
}
```

`complete: false` означает, что выгружены не все отзывы — сработал
`max_reviews` или лимит страниц. Ставьте `max_reviews: 0`, чтобы забрать всё, и
поднимайте `timeout_sec`: полная выгрузка популярной точки занимает минуты.

`total_count` — сколько отзывов реально отдано, `branch_reviews_count` — счётчик
самого 2GIS; они расходятся, потому что 2GIS считает и скрытые отзывы.

### GET /health, /healthz

```json
{"status": "ok"}
```

Без токена, без обращений к 2GIS. Годится для liveness и readiness.

## Ошибки

Формат один для всех ошибок — FastAPI-конверт:

```json
{"detail": "city is not supported: Атлантида"}
```

| Код | Когда | Что делать |
| --- | --- | --- |
| `400` | невалидный ввод: короткий `query`, неизвестный город, кривой `branch_id`, конфликт `branch_id` и `source_url` | починить запрос, ретрай не поможет |
| `401` | нет заголовка `X-Internal-Token` или он неверный | проверить токен |
| `422` | тело не проходит схему (нет обязательного поля, тип не тот) | починить запрос |
| `502` | 2GIS ответил ошибкой или не ответил | ретрай с задержкой |
| `503` | в поде нет `TWOGIS_INTERNAL_TOKEN` | проблема развёртывания, чинится не на стороне клиента |

Ретраить имеет смысл только `502`. Сервис уже делает 3 внутренние попытки с
нарастающей паузой, поэтому клиенту хватит одного повтора через несколько секунд.

## Интеграция

Таймауты клиента ставьте больше, чем `timeout_sec` в теле запроса: сервис ждёт
2GIS до указанного предела, а сверху добавляется сеть. Для `/twogis/reviews`
без `max_reviews` закладывайте минуты.

Ограничений по частоте на стороне сервиса нет, но ключ 2GIS общий на все
проекты — не выгружайте отзывы пачками в несколько потоков.

Пример на Python:

```python
import os
import requests

BASE = "https://twogis.satushi.me"
HEADERS = {"X-Internal-Token": os.environ["TWOGIS_INTERNAL_TOKEN"]}


def search_address(query: str, city: str, limit: int = 10) -> list[dict]:
    response = requests.post(
        f"{BASE}/twogis/address-search",
        json={"query": query, "city": city, "limit": limit},
        headers=HEADERS,
        timeout=30,
    )
    response.raise_for_status()
    return response.json()["items"]
```

Пример на Java (Spring, как в cabin-backend):

```java
@FeignClient(name = "twogis", url = "${twogis.base-url}")
public interface TwoGisClient {

    @PostMapping("/twogis/address-search")
    AddressSearchResponse searchAddresses(
            @RequestHeader("X-Internal-Token") String token,
            @RequestBody AddressSearchRequest request);
}
```

## Конфигурация сервиса

Нужна только тем, кто разворачивает сервис, а не интеграторам.

| Переменная | Обязательна | Описание |
| --- | --- | --- |
| `TWOGIS_INTERNAL_TOKEN` | да | токен доступа к рабочим endpoint |
| `TWOGIS_API_KEY` | для address search и apartment | ключ 2GIS |
| `TWOGIS_HYBRID_API_KEY` | нет | отдельный ключ Hybrid API; по умолчанию `TWOGIS_API_KEY` |
| `TWOGIS_ADDRESS_PROVIDER` | нет | `hybrid` по умолчанию, `catalog` для явно выбранного legacy-транспорта |
| `TWOGIS_CATALOG_BASE_URL` | нет | Catalog, по умолчанию `https://catalog.api.2gis.ru`; не URL Hybrid |
| `TWOGIS_LOG_LEVEL` | нет | уровень логов, по умолчанию `INFO` |
| `TWOGIS_REVIEWS_MAX_PAGES` | нет | лимит страниц отзывов, по умолчанию `200` |
| `TWOGIS_REVIEWS_PAGE_DELAY_SEC` | нет | пауза между страницами, по умолчанию `0.25` |

Подсказки и `building-address` идут напрямую в Hybrid `/v1/search/markers`.
`TWOGIS_API_KEY` используется для внешнего провайдера. `TWOGIS_INTERNAL_TOKEN`
передаётся Pulsary в заголовке `X-Internal-Token` для авторизации в нашем сервисе.
Catalog и browser-capture — отдельный канал со своей browser-session; Hybrid
не подставляется вместо `/3.0/items`, `/byid` или `/indoor`.

Ingress пропускает наружу только явно перечисленные пути. Новый endpoint не
станет доступен снаружи, пока не добавлен в `deploy/ingress.yaml`.


### Кэш домов для /twogis/apartment

Все подъезды и диапазоны дома сохраняются в common DB (схема twogis, база app) на стороне сервиса.
Можно повторно запрашивать разные квартиры: сервис проверит сохранённые диапазоны,
не обращаясь к 2ГИС снова. Ключ — нормализованный город/улица/дом без квартиры;
пустые/неоднозначные ответы тоже сохраняются без автоматического обновления.
`captured_at` отражает время загрузки дома, а не время чтения кэша.

HTTP 202 с `Retry-After: 5` означает ожидание в очереди. Повторите тот же POST
через указанное время; это не ошибка провайдера, резервный канал пока не нужен.
Внешние запросы идут по одному, минимум через 5 секунд после ответа.
Клиентские опросы продвигают FIFO-очередь; без опросов задачи остаются в очереди.
Нужен `TWOGIS_DATABASE_URL` к общей PostgreSQL БД. Ошибки провайдера
и прерванные попытки сохраняются без автоматического повтора (HTTP 502).

### Повторное обогащение после исправления области поиска

Поколение кеша `city-viewpoint-v2` использует отдельные ключи и `search_version=2`.
Прежние payload остаются в common DB для аудита, но не выдаются и не попадают
в новую очередь. Каждый дом запрашивается заново один раз, по общей очереди
с интервалом минимум 5 секунд. Все подъезды/диапазоны сохраняются для будущих
квартир. Смена поколения — намеренный однократный сброс по согласованной миграции;
обычные рестарты не сбрасывают результаты и не повторяют запросы.

## Collection API — перенос из Pulse

Все перечисленные операции требуют X-Internal-Token. Health endpoints публичные.

| Метод | Путь | Ответ |
| --- | --- | --- |
| POST | /scrape | Запустить сбор, получить run_id и текущее состояние |
| GET | /status/{run_id} | Статус, прогресс, хвост логов, checkpoint |
| GET | /rows/{run_id}?after=0&limit=500 | rows, next_after, total, checkpoint |
| POST | /pause/{run_id} | Пауза процесса и Chromium |
| POST | /resume/{run_id} | Продолжение приостановленного процесса |
| POST | /cancel/{run_id} | Остановка, сохранение уже полученных данных |
| GET | /result/{run_id} | CSV без изменения исходных колонок |
| POST | /twogis/catalog | Нормализованные операции Catalog/HTML/browser search |

Тело /scrape: city, category, source_url, resume_from_run_id, paginate_pages.
Последние три поля опциональны. Новый запуск с resume_from_run_id использует
сохранённую контрольную точку и профиль предыдущего запуска. POST /resume
продолжает только ещё существующий приостановленный процесс; после перезапуска
нужно новое задание с resume_from_run_id. Файлы прерванного задания доступны.
Допустимые статусы: pending, running, paused, completed, failed, cancelled.
Мок-CSV при отсутствии команды больше не создаётся: по умолчанию запускается parser-2gis.

Catalog принимает JSON с operation и параметрами:

- public-page / public-pages: source_url, query, page, page_size / max_pages;
- catalog-page / catalog-pages: query, source_url для браузерной сессии, page, page_size / max_pages;
- browser-search: source_url, query, max_pages — браузерные страницы с нормализацией;
- building / branch-building / indoor: id;
- suggests: query, viewport, необязательные session и body;
- byid: id, необязательные fields, viewport, session;
- building-address: query и viewport; прямой Hybrid с точным выбором здания,
  `source=hybrid_api`, оригинальным `hybrid_raw` и прежним `parsed_building`.
  `items_byid` здесь — нормализованная карточка, не сетевой ответ Catalog.

viewport: Lon1, Lat1, Lon2, Lat2. session: StatSID, StatUser, Shv.
Ответ: {"data": ..., "error": ""}. Для частичной выдачи data остаётся заполненным,
а error описывает ошибку. В SearchPreview отдельно может быть partial_error.
Нельзя использовать неполную выдачу для объявления всех отсутствующих организаций закрытыми.
Договорённость page=2 у browser-search сохранена: Pulse объединяет её с первой HTML-страницей.
raw_payload сохраняет оригинальный JSON каждого элемента.

Сервис запускается одним Uvicorn worker и одной репликой для файловых заданий.
DATA_DIR=/app/data должен находиться на постоянном volume с правами UID/GID 10001.
Структура старого DATA_DIR Pulse совместима; перед переключением необходима резервная копия.
Go worker входит в тот же Docker-образ и не имеет внешнего порта.
TWOGIS_CATALOG_WORKER=0 используется только в изолированных unit-тестах.
