"""Клиент городского геопортала Екатеринбурга (геопортал.екатеринбург.рф). Открытый 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: # Геопортал ЕКБ отдаёт сертификат российского CA (Минцифры), которого нет в # стандартном trust store httpx → TLS verify падает. Данные публичные, # read-only → verify=False (тот же подход, что curl -k при разведке). async with httpx.AsyncClient( timeout=self._timeout, headers=_BROWSER_HEADERS, verify=False ) 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)