twogis-service API

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

Офлайн-каталог (дополнительный контракт 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. Повторная публикация того же файла идемпотентна. Это операторский CLI, не публичный API.

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

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

{"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 и требуют тот же внутренний токен:

Ответы содержат 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 нет.

{
  "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 пропускает часть поисковой выдачи.

{
  "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
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}'

Ответ:

{
  "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. Противоречащие друг другу номера отклоняются.

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:

{
  "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. Формат 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.

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"}'
{
  "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
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}'
{
  "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

{"status": "ok"}

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

Ошибки

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

{"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:

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):

@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 и параметрами:

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-тестах.