mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:44:47 +00:00
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:
parent
e2e17fd5a6
commit
effb62a1d8
187
backend-py/app/geocoder.py
Normal file
187
backend-py/app/geocoder.py
Normal 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)
|
||||
@ -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. Менеджер может скорректировать вручную
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user