HTTP-микросервис на Go: собирает карточку глэмпинга для фронтенда aframedomik.ru. По домену VK достаёт фотографии и данные домиков, структурирует описание (удобства, цены, доп.услуги, правила) и отдаёт единым JSON-ом — как страница Сабадури: объект + список А-фрейм домиков.
Источники данных по объекту:
- фото — из VK (
photos.get, оставляем 15 лучших по разрешению); - домики — товары VK (
market.getByIdпо прямым id) и/или «ручные» домики из конфига (например, описание с Avito, который ботами не парсится); - координаты — параметр запроса → конфиг → геокодер по адресу (Nominatim).
- Go 1.24, стандартный
net/http(сервер и клиент к VK),log/slog(логи); github.com/joho/godotenv— загрузка.env;github.com/anthropics/anthropic-sdk-go— опциональное LLM-извлечение.
app/ точка входа: main / types / handler / helpers / deps
internal/config/ конфигурация из окружения (.env)
internal/vk/ клиент VK API (resolveScreenName, photos.get, market.getById)
internal/extract/ структурирование описания (Extractor: Heuristic | LLM)
internal/objects/ пер-объектные конфиги data/<domain>.json
internal/geocode/ адрес → координаты через Nominatim (OSM)
internal/cache/ in-memory TTL-кэш ответов
internal/images/ конвейер фото (альбом → дедуп → отбор → webp)
data/ конфиги объектов (<domain>.json)
cp .env.example .env # подставь свой VK_TOKEN
go mod tidy
go run ./app # сервис слушает :8080
go build -o bin/parser ./app # сборка бинаря (bare `go build ./app` нельзя:
# имя бинаря совпало бы с папкой app/)
go test ./... # тесты (без сети)Готовит галерею объекта для фронта: photo-1.webp … photo-15.webp (макс.
сторона 1280, q80). Требует установленного imagemagick (magick).
go run ./app -export elkidom37 -out ../iv-iframes/public/elkidom
# без -out кладёт в export/<domain>/Конвейер (internal/images):
- Источник — альбом дома, а не стена. Из описаний товаров (config
items) достаётся ссылка «ВСЕ ФОТО ДОМА:vk.com/album-<owner>_<album>» → тянемphotos.getэтого альбома. Фоллбэк на стену, если альбома нет (напр. страница-пользователь). Стена даёт повторы и обложки товаров с текстом. - Дедуп по перцептивному хэшу (
goimagehash) — убирает повторяющиеся кадры. - Порядок и обложка. Кадры без людей — вперёд (детекция лиц
pigo, чистый Go), с людьми — в конец (первые ~10 без людей). Внутри — экстерьеры (высокая «уличность»: доля зелени/неба) раньше интерьеров, поэтому обложка обзорная. - Ресайз + webp через
magick(шелл-аут, без cgo).
Ограничения (честно): детекция людей — фронтальные лица (кадр со спины может пройти), «уличность» — эвристика по цвету. Главный рычаг чистоты — источник-альбом.
GET /api/glamping?domain=<screen_name>[&items=...][&coords=lat,lon][&map=URL]
| Параметр | Обяз. | Описание |
|---|---|---|
domain |
да | VK screen name (буквы/цифры/._). Прочее → 400 |
items |
нет | товары-домики: полные ссылки или id через запятую |
coords |
нет | координаты "lat,lon", если VK не отдал |
map |
нет | ссылка на карту |
Параметры приоритетнее значений из конфига data/<domain>.json. Минимально
достаточно ?domain=..., если объект описан конфигом.
curl "http://localhost:8080/api/glamping?domain=elkidom37"Ответ — три уровня: объект → домики (cabins) → структура домика (property).
Объект-уровень:
| Поле | Тип | Описание |
|---|---|---|
title |
string | название глэмпинга (имя VK-группы) |
about |
string | описание сообщества |
location |
string | строка для показа; исторический формат, разбирать обратно нельзя — для этого поля ниже |
region |
string | регион/направление источника; не гарантированно субъект РФ; отсутствует, если неизвестен |
locality |
string | населённый пункт объекта; отсутствует, если реально неизвестен — пустое честнее чужого города |
nearCity |
string | опорный город направления (откуда ехать), не адрес объекта |
houseTypes |
string[] | формы жилья объекта: A-frame, Купольный дом, Барнхаус… Список, потому что у объекта бывают корпуса разной формы |
surroundings |
string[] | что вокруг: лес, река, озеро, склон. Из тегов источника, не из разбора описания |
petsAllowed |
bool | можно ли с питомцем. Отсутствует = источник не сказал; false = сказал «нельзя» |
rating / reviewsCount |
number | оценка и число отзывов числами — для сортировки каталога |
distanceKm / distanceFrom |
number / string | сколько километров и от чего их считать: источник меряет то от МКАД, то от города, то от аэропорта. Без точки отсчёта цифра ничего не значит, и это НЕ nearCity |
highway |
string | шоссе, канонизированное: хвост «(Направление: Казань)» отброшен, иначе одна дорога даёт две строки фильтра |
priceValue |
number | цена числом (cabins[0].price остаётся строкой «7 360 ₽» для показа) |
guestsMax |
number | вместимость самого большого домика; отсутствует, если источник промолчал |
coords |
{lat,lon} |
координаты (опционально) |
mapUrl |
string | ссылка на карту (опционально) |
contact |
string | телефон (опционально) |
photos |
string[] | галерея, до 15 лучших фото |
cabins |
Cabin[] | домики |
Поля для фильтров — числами и списками, а не строками. rating, priceValue, guestsMax, distanceKm дублируют то, что и раньше лежало в cabins[].property.facts строками вида «до 6 гостей» и «4.8 · 129 отзывов». Строка годится показать, но не отфильтровать: разбирать её регуляркой на стороне сайта — значит завести четвёртое место, где формат источника трактуется, и получить четвёртый источник расхождений.
Пустое поле — это ответ. Ни одно из них не имеет дефолта. Вместимость раньше подставлялась как «до 4» и стояла почти у всего каталога при реальном разбросе от 2 до 27: гость видел «до 4», ехал вчетвером, а его ждала одна двуспальная кровать. Фильтр обязан пропускать «неизвестно» мимо, а не выдавать за подходящее.
Адрес. Поля заполняет тот провайдер, чей источник их различает. У глэмпинги.рф это регион и опорный город — населённого пункта источник в списке не отдаёт вовсе:
"location": "Московская область, Москва",
"region": "Московская область",
"nearCity": "Москва"«Москва» здесь означает точку отсчёта расстояния, а не адрес (подробности и цифры — в комментарии к apiCity, providers/glamping_rf/types.go). Поэтому locality отсутствует — это правильное состояние, а не пробел в данных. У VK-объектов источник даёт одну свободную строку, поэтому заполнен только location.
В Preview (облегчённая карточка для списков) из адреса есть только location и region: по региону идёт фильтрация, а location нужен для полнотекстового поиска — искать по нему подстрокой можно и нужно, запрет касается только разбора его на составляющие.
Поля фильтров в Preview. Кроме адреса, превью несёт surroundings, petsAllowed, guestsMax, priceValue, rating. Это единственное исключение из правила «превью облегчённое», и у него есть причина: каталог фильтруется на клиенте по уже загруженному списку, а список — это Preview. Чего в нём нет, по тому и не отфильтруешь; ровно так чипсы каталога и оказались мёртвыми — они читали tags, которых в превью нет и не было.
houseTypes в превью не берём: фильтр по форме дома отложен, а поле заполнено у 133 объектов из 309 — больше половины каталога промолчало бы, и раздел показывал бы не «домов такой формы нет», а «мы не знаем».
Новые поля появляются в выдаче после пересбора: go run ./app --provider glamping и обновление generated/. Мержа кода недостаточно — до пересбора API отдаёт прежний набор полей.
Домик (cabins[i]):
| Поле | Тип | Описание |
|---|---|---|
title / price / description |
string | сырые данные товара |
property |
Property | структурированная карточка (ниже) |
variants |
string[] | названия схлопнутых дублей (напр. «AFRAME тёмный») |
Структура домика (property):
| Поле | Тип | Описание |
|---|---|---|
summary |
string | краткое описание |
priceFrom |
string | цена |
facts |
{label,value}[] |
факты (вместимость, площадь…) |
amenityGroups |
{title,items[]}[] |
удобства по группам |
extras |
{name,price}[] |
платные доп.услуги |
rules |
string[] | правила проживания |
Пример (сокращён):
{
"title": "ЁLKI.DOM ГЛЭМПИНГ / БАНЯ / Отдых Иваново",
"location": "Иваново",
"coords": { "lat": 57.070886, "lon": 41.01518 },
"mapUrl": "https://yandex.ru/maps/org/elki_dom/205090190510/",
"photos": ["https://sun9-...userapi.com/...jpg"],
"cabins": [
{
"title": "AFRAME светлый (аренда )",
"price": "7,000–9,500 ₽",
"property": {
"summary": "АРЕНДА светлого ДОМА…",
"priceFrom": "7,000–9,500 ₽",
"facts": [{ "label": "Вместимость", "value": "до 4 чел." }],
"amenityGroups": [
{ "title": "В домике", "items": ["Кухня (оборудованная)", "Интернет"] }
],
"extras": [{ "name": "Растопка Фурако / чана", "price": "" }],
"rules": ["заезд в 15:00, выезд до 12:00"]
},
"variants": ["AFRAME тёмный (аренда)"]
}
]
}Заголовок X-Cache: HIT|MISS показывает, отдан ли ответ из кэша.
| Код | Когда |
|---|---|
| 200 | успех |
| 400 | не передан или невалиден domain |
| 502 | ошибка при обращении к VK API |
Один файл на глэмпинг — ручные данные, которых нет в VK. Все поля необязательны.
{
"address": "Ивановская обл., д. Крюково, Славянская ул., 6",
"coords": "57.082342,40.876057",
"map": "https://yandex.ru/maps/org/scandi/237456869090/",
"items": ["https://vk.com/market/product/aframe-211011668-6377368"],
"cabins": [
{ "title": "Scandi Villa", "price": "", "description": "<текст из Avito>" }
]
}items— товары-домики VK (грузятся черезmarket.getById);cabins— «ручные» домики (источники, недоступные API, например Avito).
В репозитории лежит только шаблон
data/example.json. Реальные конфиги объектов (data/<domain>.json) не коммитятся (см..gitignore) — они содержат чужой контент (описания с VK/Avito) и хранятся локально.
Если каталог VK скрыт (market.get пуст) — id товаров берутся отсюда или из
параметра items.
| Переменная | Обяз. | Описание |
|---|---|---|
VK_TOKEN |
да | токен доступа к VK API |
ANTHROPIC_API_KEY |
нет | если задан — структурирование через Claude, иначе бесплатная эвристика |
SERVER_ADDR |
нет | адрес сервера (по умолчанию :8080) |
DATA_DIR |
нет | каталог конфигов объектов (по умолчанию data) |
.env в репозиторий не коммитится (см. .gitignore).
internal/extract прячет движок за интерфейсом Extractor:
- Heuristic (по умолчанию) — бесплатно, по словарю ключевых слов
(
dictionary.go) и регэкспам. Работает офлайн, без ключей. - LLMClient — Claude (strict tool-use), включается при наличии
ANTHROPIC_API_KEY. Качественнее, но платно.
Переключение — один if в main; остальной код не меняется.