233 lines
8.1 KiB
Python
233 lines
8.1 KiB
Python
"""Клиент городского геопортала Екатеринбурга (геопортал.екатеринбург.рф).
|
||
|
||
Открытый GeoServer WFS, без авторизации. Слой `portal:portal_geo_topo_build` —
|
||
футпринты зданий ЕКБ (намного полнее чем NSPD cad_buildings, который пропускает
|
||
~70% зданий города).
|
||
|
||
ВАЖНО: сервер отдаёт 403 на дефолтный User-Agent curl/httpx. Нужны браузерные
|
||
заголовки (UA + Referer + Accept) — тогда 200.
|
||
|
||
Геометрия `geoloc` в EPSG:4326 (MultiPolygon footprint). BBOX axis order =
|
||
lon_min,lat_min,lon_max,lat_max. Сервер режет каждый ответ на 20000 features —
|
||
лоадер тайлит мелко, чтобы не упереться.
|
||
|
||
Центроид считается чистым Python'ом (area-weighted, без shapely — shapely не входит
|
||
в зависимости tradein-mvp backend).
|
||
|
||
Используется лоадером app.tasks.ekb_geoportal_ingest.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Punycode-хост геопортал.екатеринбург.рф
|
||
GEOPORTAL_HOST = "https://xn--80afgznagjs.xn--80acgfbsl1azdqr.xn--p1ai"
|
||
WFS_PATH = "/api/gis/ows/3/portal/wfs"
|
||
BUILD_LAYER = "portal:portal_geo_topo_build"
|
||
|
||
# Браузерные заголовки — без них хост отдаёт 403.
|
||
_BROWSER_HEADERS = {
|
||
"User-Agent": (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/148.0.0.0 Safari/537.36"
|
||
),
|
||
"Referer": f"{GEOPORTAL_HOST}/",
|
||
"Accept": "application/json",
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class TopoBuilding:
|
||
"""Одна часть-фича здания из слоя portal_geo_topo_build.
|
||
|
||
Здание может быть разбито на несколько part-features (одинаковые street/house,
|
||
разные key) — лоадер группирует их по (street_norm, house_norm).
|
||
"""
|
||
|
||
street: str
|
||
house: str
|
||
floors: int | None
|
||
purpose: str | None
|
||
material: str | None
|
||
key: int | None
|
||
lat: float
|
||
lon: float
|
||
|
||
|
||
def _parse_int(value: object) -> int | None:
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return int(value)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _parse_str(value: object) -> str | None:
|
||
if value is None:
|
||
return None
|
||
s = str(value).strip()
|
||
return s or None
|
||
|
||
|
||
def _ring_centroid(ring: list) -> tuple[float, float, float] | None:
|
||
"""Area-weighted centroid of one linear ring → (cx, cy, signed_area2).
|
||
|
||
signed_area2 = 2× signed area (используется как вес при усреднении колец/полигонов).
|
||
Возвращает None если кольцо вырождено (нулевая площадь).
|
||
"""
|
||
n = len(ring)
|
||
if n < 3:
|
||
return None
|
||
a2 = 0.0 # 2× signed area
|
||
cx = 0.0
|
||
cy = 0.0
|
||
for i in range(n):
|
||
x0, y0 = ring[i][0], ring[i][1]
|
||
x1, y1 = ring[(i + 1) % n][0], ring[(i + 1) % n][1]
|
||
cross = x0 * y1 - x1 * y0
|
||
a2 += cross
|
||
cx += (x0 + x1) * cross
|
||
cy += (y0 + y1) * cross
|
||
if a2 == 0.0:
|
||
return None
|
||
cx /= 3.0 * a2
|
||
cy /= 3.0 * a2
|
||
return (cx, cy, a2)
|
||
|
||
|
||
def _multipolygon_centroid(coords: list) -> tuple[float, float] | None:
|
||
"""Центроид GeoJSON MultiPolygon/Polygon-частей: усреднение по |площади| внешних колец.
|
||
|
||
coords — список полигонов, каждый = [outer_ring, hole1, ...]. Берём только
|
||
внешнее кольцо каждого полигона (дыр у footprint'ов зданий практически нет,
|
||
их вклад в положение центра пренебрежим). Возвращает (lon, lat) или None.
|
||
"""
|
||
sum_w = 0.0
|
||
sum_x = 0.0
|
||
sum_y = 0.0
|
||
for polygon in coords:
|
||
if not polygon:
|
||
continue
|
||
outer = polygon[0]
|
||
c = _ring_centroid(outer)
|
||
if c is None:
|
||
continue
|
||
cx, cy, a2 = c
|
||
w = abs(a2)
|
||
sum_w += w
|
||
sum_x += cx * w
|
||
sum_y += cy * w
|
||
if sum_w == 0.0:
|
||
return None
|
||
return (sum_x / sum_w, sum_y / sum_w)
|
||
|
||
|
||
def _geometry_centroid(geom: dict) -> tuple[float, float] | None:
|
||
"""Центроид GeoJSON geometry (MultiPolygon или Polygon) → (lon, lat) или None."""
|
||
gtype = geom.get("type")
|
||
coords = geom.get("coordinates")
|
||
if not coords:
|
||
return None
|
||
if gtype == "MultiPolygon":
|
||
return _multipolygon_centroid(coords)
|
||
if gtype == "Polygon":
|
||
# Polygon coordinates = [outer_ring, hole, ...] → обернём в один полигон.
|
||
return _multipolygon_centroid([coords])
|
||
return None
|
||
|
||
|
||
def _feature_to_building(feature: dict) -> TopoBuilding | None:
|
||
"""GeoJSON Feature → TopoBuilding. None если нет улицы/дома или геометрии."""
|
||
props = feature.get("properties") or {}
|
||
street = _parse_str(props.get("topo_street"))
|
||
house = _parse_str(props.get("topo_num_house"))
|
||
if not street or not house:
|
||
return None
|
||
|
||
geom = feature.get("geometry")
|
||
if not isinstance(geom, dict):
|
||
return None
|
||
try:
|
||
centroid = _geometry_centroid(geom)
|
||
except Exception:
|
||
logger.warning("geoportal: bad geometry for street=%r house=%r", street, house)
|
||
return None
|
||
if centroid is None:
|
||
return None
|
||
lon, lat = centroid
|
||
|
||
return TopoBuilding(
|
||
street=street,
|
||
house=house,
|
||
floors=_parse_int(props.get("topo_floor")),
|
||
purpose=_parse_str(props.get("topo_purpose")),
|
||
material=_parse_str(props.get("topo_material")),
|
||
key=_parse_int(props.get("key")),
|
||
lat=lat,
|
||
lon=lon,
|
||
)
|
||
|
||
|
||
def parse_feature_collection(payload: dict) -> list[TopoBuilding]:
|
||
"""GeoJSON FeatureCollection → list[TopoBuilding]. Пропускает невалидные."""
|
||
features = payload.get("features")
|
||
if not isinstance(features, list):
|
||
return []
|
||
out: list[TopoBuilding] = []
|
||
for feature in features:
|
||
if not isinstance(feature, dict):
|
||
continue
|
||
building = _feature_to_building(feature)
|
||
if building is not None:
|
||
out.append(building)
|
||
return out
|
||
|
||
|
||
class EkbGeoportalClient:
|
||
"""Async WFS-клиент геопортала ЕКБ. Один клиент переиспользуется лоадером."""
|
||
|
||
def __init__(self, *, timeout: float = 30.0) -> None:
|
||
self._timeout = timeout
|
||
|
||
async def fetch_topo_buildings(
|
||
self,
|
||
bbox: tuple[float, float, float, float],
|
||
*,
|
||
count: int = 20000,
|
||
) -> list[TopoBuilding]:
|
||
"""Запрашивает здания в bbox (lon_min, lat_min, lon_max, lat_max).
|
||
|
||
Graceful: на non-200/parse-error логирует warning и возвращает [].
|
||
"""
|
||
lon_min, lat_min, lon_max, lat_max = bbox
|
||
cql = f"BBOX(geoloc,{lon_min},{lat_min},{lon_max},{lat_max})"
|
||
params = {
|
||
"service": "WFS",
|
||
"version": "2.0.0",
|
||
"request": "GetFeature",
|
||
"typeNames": BUILD_LAYER,
|
||
"outputFormat": "application/json",
|
||
"CQL_FILTER": cql,
|
||
"count": str(count),
|
||
}
|
||
url = f"{GEOPORTAL_HOST}{WFS_PATH}"
|
||
try:
|
||
async with httpx.AsyncClient(timeout=self._timeout, headers=_BROWSER_HEADERS) as client:
|
||
resp = await client.get(url, params=params)
|
||
if resp.status_code != 200:
|
||
logger.warning("geoportal WFS → HTTP %d for bbox=%s", resp.status_code, bbox)
|
||
return []
|
||
payload = resp.json()
|
||
except Exception as exc:
|
||
logger.warning("geoportal WFS fetch/parse failed for bbox=%s: %s", bbox, exc)
|
||
return []
|
||
|
||
return parse_feature_collection(payload)
|