gendesign/tradein-mvp/backend/app/services/scrapers/ekb_geoportal_client.py
bot-backend 112e5cc2f1
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein): verify=False for EKB geoportal WFS (Russian-CA cert)
The geoportal serves a Russian national-CA (Минцифры) TLS cert absent from
httpx's default trust store → CERTIFICATE_VERIFY_FAILED, loader fetched 0.
Public read-only data → verify=False (same as the curl -k used in recon).
2026-06-19 18:19:03 +03:00

238 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Клиент городского геопортала Екатеринбурга (геопортал.екатеринбург.рф).
Открытый 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)