geocoder: порт из проекта СЕКРЕТАРЬ + кнопка «По адресу»

backend/app/geocoder.py — Python-порт хибридного геокодера из
secretary/lib/geocoder.js: Yandex (если есть YANDEX_GEOCODER_API_KEY
в env) → fallback OSM Nominatim (бесплатный, rate-limit 1/sec).

normalize_address — та же логика, что и в JS-версии: расшифровка
сокращений улиц, срез номера квартиры/этажа/подъезда, корпус → к<N>.

POST /api/geocode — текст адреса → {lat, lng, formatted, source}.

Frontend в логистике замера:
- Новая кнопка «🔍 По адресу» — берёт текст адреса заявки и
  вызывает геокодер. Заполняет GPS автоматически.
- В сводке ссылка на 📍 теперь ведёт на Я.Карты (а не Google) —
  для России лучше: открывает приложение Я.Карты на телефоне.

Cache bust v=20260513w.
This commit is contained in:
wasrusgen 2026-05-13 17:55:34 +03:00
parent e2e17fd5a6
commit effb62a1d8
4 changed files with 280 additions and 17 deletions

187
backend-py/app/geocoder.py Normal file
View File

@ -0,0 +1,187 @@
"""Hybrid Geocoder: Yandex (если есть API-ключ) → fallback OSM Nominatim.
Порт из secretary/lib/geocoder.js та же логика нормализации адреса
и стратегия fallback.
"""
from __future__ import annotations
import os
import re
import time
import logging
import urllib.parse
import httpx
log = logging.getLogger("zov.geocoder")
OSM_HOST = "https://nominatim.openstreetmap.org"
YANDEX_HOST = "https://geocode-maps.yandex.ru"
OSM_RATE_DELAY_SEC = 1.1 # ~1 req/sec по политике Nominatim
_cache: dict[str, dict | None] = {}
_last_osm_at: float = 0.0
_yandex_disabled: bool = False
def normalize_address(s: str) -> str:
"""Готовим адрес для геокодера. Срезаем квартиру/этаж/подъезд/корпус,
раскрываем сокращения улиц. Совпадает с logic из secretary/lib/geocoder.js.
"""
if not s:
return ""
out = str(s)
# Замена сокращений улиц на полные слова (лучше для OSM)
# \b плохо работает с кириллицей в Python — используем lookaround
L = r"[А-Яа-яЁёA-Za-z]"
NL = rf"(?<!{L})"
NLA = rf"(?!{L})"
abbrev = [
(rf"{NL}пр-?к?т{NLA}\.?", "проспект"),
(rf"{NL}пр{NLA}\.?", "проспект"),
(rf"{NL}ул{NLA}\.?", "улица"),
(rf"{NL}пер{NLA}\.?", "переулок"),
(rf"{NL}наб{NLA}\.?", "набережная"),
(rf"{NL}пл{NLA}\.?", "площадь"),
(rf"{NL}ш{NLA}\.?", "шоссе"),
(rf"{NL}б-?р{NLA}\.?", "бульвар"),
(rf"{NL}алл{NLA}\.?", "аллея"),
]
for pat, sub in abbrev:
out = re.sub(pat, sub, out, flags=re.IGNORECASE | re.UNICODE)
# Срезаем номер квартиры/офиса/помещения, парадную, подъезд, этаж
cuts = [
rf"{NL}(кв|квартира|оф|офис|пом|помещение)\.?\s*\d+\w*",
rf"{NL}\d+-?я?\s*парадная",
rf"{NL}парадная\s*\d+",
rf"{NL}(подъезд|этаж|эт)\.?\s*\d+",
]
for pat in cuts:
out = re.sub(pat, "", out, flags=re.IGNORECASE | re.UNICODE)
# "д.9" / "дом 9" → "9"
out = re.sub(rf"{NL}(дом|д)\.?\s*(\d+){NLA}", r" \2", out, flags=re.IGNORECASE | re.UNICODE)
# "стр.1" → "с1"
out = re.sub(rf"{NL}(стр|строение)\.?\s*(\d+)", r"с\2", out, flags=re.IGNORECASE | re.UNICODE)
# "корп.3" / "к. 3" → "к3" (только перед числом)
out = re.sub(rf"{NL}(корпус|корп|к)\.?\s+(\d+)", r"к\2", out, flags=re.IGNORECASE | re.UNICODE)
# Лишняя пунктуация и сжатие
out = re.sub(r"[,;]+", ",", out)
out = re.sub(r"\s*,\s*", ", ", out)
out = re.sub(r"\s+", " ", out)
out = out.replace("", "-").replace("", "-")
out = re.sub(r"\s*-\s*\d+\s*$", "", out) # "- 459" в конце = № квартиры
out = out.strip().strip(",").strip()
return out
def _yandex_lookup(query: str, api_key: str) -> dict | None:
global _yandex_disabled
path = f"/v1/?apikey={urllib.parse.quote(api_key)}&geocode={urllib.parse.quote(query)}&format=json&results=1&lang=ru_RU"
try:
with httpx.Client(timeout=15.0) as cli:
r = cli.get(YANDEX_HOST + path)
except Exception as e:
log.warning("Yandex geocoder error: %s", e)
return None
if r.status_code == 403:
log.warning("Yandex 403 — отключаю на эту сессию")
_yandex_disabled = True
return None
if r.status_code >= 400:
log.warning("Yandex %d", r.status_code)
return None
try:
data = r.json()
except Exception:
return None
features = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
if not features:
return None
obj = features[0].get("GeoObject", {})
pos = (obj.get("Point") or {}).get("pos", "")
try:
lon_s, lat_s = pos.split(" ")
lat = float(lat_s)
lon = float(lon_s)
except (ValueError, AttributeError):
return None
meta = (obj.get("metaDataProperty") or {}).get("GeocoderMetaData", {})
return {
"lat": lat,
"lon": lon,
"formatted": meta.get("text") or obj.get("description") or query,
"precision": meta.get("precision", ""),
"kind": meta.get("kind", ""),
"source": "yandex",
}
def _osm_lookup(query: str, orig: str) -> dict | None:
global _last_osm_at
wait = max(0.0, _last_osm_at + OSM_RATE_DELAY_SEC - time.time())
if wait > 0:
time.sleep(wait)
_last_osm_at = time.time()
path = f"/search?q={urllib.parse.quote(query)}&format=json&limit=1&accept-language=ru"
headers = {"User-Agent": "zov-tech/1.0 (vasrusgen@gmail.com)"}
try:
with httpx.Client(timeout=15.0, headers=headers) as cli:
r = cli.get(OSM_HOST + path)
except Exception as e:
log.warning("OSM error: %s", e)
return None
if r.status_code >= 400:
log.warning("OSM %d", r.status_code)
return None
try:
data = r.json()
except Exception:
return None
if not data:
return None
it = data[0]
try:
lat = float(it.get("lat"))
lon = float(it.get("lon"))
except (TypeError, ValueError):
return None
return {
"lat": lat,
"lon": lon,
"formatted": it.get("display_name") or orig or query,
"precision": it.get("type", ""),
"kind": it.get("addresstype") or it.get("class", ""),
"source": "osm",
}
def geocode(address_text: str, city: str = "Санкт-Петербург") -> dict | None:
"""Прямое геокодирование: текст адреса → {lat, lon, formatted, source, ...}"""
if not address_text:
return None
cleaned = normalize_address(address_text)
q = f"{city}, {cleaned}" if city else cleaned
if q in _cache:
return _cache[q]
api_key = os.environ.get("YANDEX_GEOCODER_API_KEY", "").strip()
if api_key and not _yandex_disabled:
r = _yandex_lookup(q, api_key)
if r:
_cache[q] = r
return r
r = _osm_lookup(q, address_text)
_cache[q] = r
return r
def build_yandex_maps_url(lat: float, lon: float, *, zoom: int = 17, text: str = "") -> str:
"""Deeplink в Я.Карты — открывается в приложении на телефоне."""
params = [f"pt={lon},{lat},pm2rdm", f"z={zoom}", f"ll={lon},{lat}"]
if text:
params.append(f"text={urllib.parse.quote(text)}")
return "https://yandex.ru/maps/?" + "&".join(params)

View File

@ -16,7 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse
from .config import get_config
from .auth import verify_init_data
from . import sheets, ai, telegram as tg, proxy_pool, catalog
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder
from . import parsers
from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl
@ -113,6 +113,7 @@ async def _dispatch_post(request: Request):
"measurement_schedule": _handle_measurement_schedule,
"measurement_next_no": _handle_measurement_next_no,
"measurement_logistics": _handle_measurement_logistics,
"geocode": _handle_geocode,
"ping": lambda b: {"pong": True, "time": _now_iso()},
"seed_admin": lambda b: _handle_seed_admin(),
"test_ai": lambda b: _handle_test_ai(),
@ -215,6 +216,12 @@ async def api_measurement_logistics(request: Request):
return _handle_measurement_logistics(body)
@app.post("/api/geocode")
async def api_geocode(request: Request):
body = await _safe_json(request)
return _handle_geocode(body)
@app.post("/api/grant_role")
async def api_grant_role(request: Request):
"""Админ выдаёт роль другому пользователю.
@ -1366,6 +1373,41 @@ def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "id": measurement_id, "logistics": updates}
def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]:
"""Прямое геокодирование: текст адреса → lat/lon.
Использует Yandex (если есть YANDEX_GEOCODER_API_KEY в env) с fallback на OSM.
body: {initData, address, city?}"""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
unsafe = body.get("initDataUnsafe") or {}
if not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")):
return {"error": "invalid_init_data"}
address = (body.get("address") or "").strip()
if not address:
return {"error": "missing_address"}
city = (body.get("city") or "Санкт-Петербург").strip()
result = geocoder.geocode(address, city=city)
if not result:
return {"ok": False, "error": "not_found", "address": address}
return {
"ok": True,
"address": address,
"result": {
"lat": result.get("lat"),
"lng": result.get("lon"), # фронт использует lng
"formatted": result.get("formatted"),
"precision": result.get("precision"),
"kind": result.get("kind"),
"source": result.get("source"),
},
"yandex_maps_url": geocoder.build_yandex_maps_url(
result["lat"], result["lon"], text=result.get("formatted") or address,
),
}
def _handle_measurement_next_no(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает следующий свободный номер замера (max существующих + 1).
Если в Sheets ничего нет стартуем с 1. Менеджер может скорректировать вручную

View File

@ -766,11 +766,12 @@ function renderLogisticsBlock(m) {
<div class="form-row">
<label class="field">
<span class="field-label">GPS координаты</span>
<div style="display:flex;gap:8px;align-items:center;">
<input type="text" id="logGps" value="${m.gps_lat && m.gps_lng ? `${m.gps_lat}, ${m.gps_lng}` : ""}" placeholder="широта, долгота" style="flex:1;">
<button class="btn-secondary" id="getGps" type="button" style="white-space:nowrap;padding:8px 14px;">📍 Сейчас</button>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<input type="text" id="logGps" value="${m.gps_lat && m.gps_lng ? `${m.gps_lat}, ${m.gps_lng}` : ""}" placeholder="широта, долгота" style="flex:1;min-width:140px;">
<button class="btn-secondary" id="getGps" type="button" style="white-space:nowrap;padding:8px 12px;">📍 Сейчас</button>
<button class="btn-secondary" id="getGpsAddr" type="button" style="white-space:nowrap;padding:8px 12px;">🔍 По адресу</button>
</div>
<span class="field-hint" id="gpsHint">Тап «Сейчас» возьмёт координаты с устройства</span>
<span class="field-hint" id="gpsHint">«Сейчас» с устройства. «По адресу» геокодер по адресу заявки.</span>
</label>
</div>
@ -819,8 +820,8 @@ function renderLogisticsBlock(m) {
if (curM.entrance) lines.push(`Подъезд <b>${escHtml(curM.entrance)}</b>`);
if (curM.floor) lines.push(`этаж <b>${escHtml(curM.floor)}</b>`);
if (curM.gps_lat && curM.gps_lng) {
const url = `https://maps.google.com/?q=${curM.gps_lat},${curM.gps_lng}`;
lines.push(`<a href="${url}" target="_blank" rel="noopener">📍 ${curM.gps_lat}, ${curM.gps_lng}</a>`);
const ymUrl = `https://yandex.ru/maps/?pt=${curM.gps_lng},${curM.gps_lat},pm2rdm&z=17&ll=${curM.gps_lng},${curM.gps_lat}`;
lines.push(`<a href="${ymUrl}" target="_blank" rel="noopener">📍 ${curM.gps_lat}, ${curM.gps_lng}</a>`);
}
if (curM.parking_type && parkingLabels[curM.parking_type]) {
let p = parkingLabels[curM.parking_type];
@ -872,6 +873,39 @@ function renderLogisticsBlock(m) {
);
});
// GPS «По адресу» — геокодирование через backend
section.querySelector("#getGpsAddr").addEventListener("click", async () => {
const hint = section.querySelector("#gpsHint");
const addr = (m.address || "").trim();
if (!addr) {
hint.textContent = "В заявке нет адреса — нужен текст адреса для геокодера.";
return;
}
hint.textContent = "Ищем по адресу...";
try {
const res = await fetch(`${BACKEND_URL}/api/geocode`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
address: addr,
}),
});
const data = await res.json();
if (!data.ok || !data.result) {
hint.textContent = "Адрес не найден геокодером — введите GPS вручную.";
return;
}
const r = data.result;
section.querySelector("#logGps").value = `${r.lat.toFixed(6)}, ${r.lng.toFixed(6)}`;
const srcLabel = r.source === "yandex" ? "Я.Геокодер" : "OSM";
hint.textContent = `Найдено: ${r.formatted || addr} · источник ${srcLabel}`;
haptic && haptic("success");
} catch (e) {
hint.textContent = "Сеть: " + e.message;
}
});
// Сохранение
section.querySelector("#logSave").addEventListener("click", async () => {
const btn = section.querySelector("#logSave");

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513v">
<link rel="stylesheet" href="assets/podbor.css?v=20260513v">
<link rel="stylesheet" href="assets/styles.css?v=20260513w">
<link rel="stylesheet" href="assets/podbor.css?v=20260513w">
</head>
<body>
<!-- Splash — за пределами #app, render-функции его не смывают -->
@ -31,13 +31,13 @@
<div class="loader-tagline">Сделано с душой!</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260513v"></script>
<script src="assets/podbor.config.js?v=20260513v"></script>
<script src="assets/podbor.picts.js?v=20260513v"></script>
<script src="assets/podbor.js?v=20260513v"></script>
<script src="assets/clients.js?v=20260513v"></script>
<script src="assets/measurements.js?v=20260513v"></script>
<script src="assets/request.js?v=20260513v"></script>
<script src="assets/app.js?v=20260513v"></script>
<script src="assets/icons.js?v=20260513w"></script>
<script src="assets/podbor.config.js?v=20260513w"></script>
<script src="assets/podbor.picts.js?v=20260513w"></script>
<script src="assets/podbor.js?v=20260513w"></script>
<script src="assets/clients.js?v=20260513w"></script>
<script src="assets/measurements.js?v=20260513w"></script>
<script src="assets/request.js?v=20260513w"></script>
<script src="assets/app.js?v=20260513w"></script>
</body>
</html>