gendesign/tradein-mvp/backend/app/tasks/ekb_geoportal_ingest.py
bot-backend eb564cfc01 feat(tradein/scraper-kit): migrate backfill-task imports to kit, Group C (#2310)
Migrates legacy app.services.scrapers.* imports to scraper_kit equivalents for
house_imv_backfill.py, avito_detail_backfill.py, cian_history_backfill.py,
ekb_geoportal_ingest.py, and yandex_detail_backfill.py, proving parity via
tests/support/parity.assert_parity per the epic's gate (#2304).

newbuilding_enrich_backfill.py and yandex_newbuilding_sweep.py are left fully
on legacy imports: both call into scraper_kit.providers.{cian,yandex}.newbuilding,
which construct BrowserFetcher(source=...) without the now-mandatory endpoint=
kwarg (issue #2322, verified still open against the actual provider source, not
just issue status) -- no caller-side fix is possible, matching Group A's (#2305)
precedent for the same bug in admin.py.

Config-gated kit function footguns found and fixed (Group B #2306 pattern):
  - avito fetch_detail's backconnect-on-403 retry silently drops when config=
    is omitted -- now passes config=RealScraperConfig() explicitly, with a
    regression test proving the gate.
  - kit AvitoScraper's constructor now requires ScraperConfig positionally --
    wired via RealScraperConfig(), which _rotate_ip() reads for
    avito_proxy_rotate_url.
  - BrowserFetcher(source=...) call sites (house_imv_backfill, avito/cian
    detail backfills) now pass the mandatory endpoint=settings.browser_http_endpoint.

New footgun discovered (NOT #2322, flagged for follow-up): kit's
build_warmed_session() builds its curl_cffi session via _build_detail_session()
with no config parameter at all, unlike fetch_detail -- migrating it would
silently drop the sticky MGTS-proxy egress on avito_detail_backfill's
warm-batch path (the prod default). Left build_warmed_session/
_AVITO_WARM_SEARCH_URL on legacy imports, documented inline.

Refs #2310
2026-07-04 01:19:34 +03:00

273 lines
9.3 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.

"""Лоадер реестра зданий ЕКБ из городского геопортала в ekb_geoportal_buildings.
Запуск: `python -m app.tasks.ekb_geoportal_ingest`
Тайлит bbox ЕКБ (lon 60.4560.85, lat 56.7056.95) на мелкую сетку (~250-300 м),
чтобы каждый тайл укладывался в server-cap 20000 features. Для каждого тайла
запрашивает слой portal_geo_topo_build, группирует part-features по
(street_norm, house_norm), считает центроид и UPSERT'ит в ekb_geoportal_buildings.
Нормализация ОБЯЗАНА совпадать с geocoder._geoportal_house_match:
street_norm = lower(trim(street))
house_norm = lower(house) с удалёнными внутренними пробелами («7 б» → «7б»)
Configurable через argv/env (для частичного/тестового прогона):
--lon-min/--lon-max/--lat-min/--lat-max или env BBOX="lon_min,lat_min,lon_max,lat_max"
По умолчанию — полный ЕКБ.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import re
from scraper_kit.providers.ekb_geoportal.client import (
EkbGeoportalClient,
TopoBuilding,
)
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
# Полный bbox ЕКБ.
EKB_LON_MIN = 60.45
EKB_LON_MAX = 60.85
EKB_LAT_MIN = 56.70
EKB_LAT_MAX = 56.95
# Шаг сетки: ~0.004° lon × ~0.003° lat (≈250-300 м). Центральный тайл 0.005×0.004
# давал 6825 зданий → 0.004×0.003 заведомо < server-cap 20000.
TILE_LON_STEP = 0.004
TILE_LAT_STEP = 0.003
# Пауза между запросами — гос-портал, вежливый rate-limit.
REQUEST_SLEEP_SEC = 0.1
# Каждые N тайлов — лог прогресса + commit.
PROGRESS_EVERY = 50
_INTERNAL_SPACES = re.compile(r"\s+")
def normalize_street(street: str) -> str:
"""street_norm = lower(trim(street))."""
return street.strip().lower()
def normalize_house(house: str) -> str:
"""house_norm = lower(house) с удалёнными внутренними пробелами («7 б» → «7б»)."""
return _INTERNAL_SPACES.sub("", house.strip().lower())
def _tile_grid(
lon_min: float, lat_min: float, lon_max: float, lat_max: float
) -> list[tuple[float, float, float, float]]:
"""Сетка тайлов (lon_min, lat_min, lon_max, lat_max), шаг TILE_LON/LAT_STEP."""
tiles: list[tuple[float, float, float, float]] = []
lon = lon_min
while lon < lon_max:
lon_hi = min(lon + TILE_LON_STEP, lon_max)
lat = lat_min
while lat < lat_max:
lat_hi = min(lat + TILE_LAT_STEP, lat_max)
tiles.append((lon, lat, lon_hi, lat_hi))
lat = lat_hi
lon = lon_hi
return tiles
def _group_buildings(
buildings: list[TopoBuilding],
) -> dict[tuple[str, str], dict]:
"""Группирует part-features по (street_norm, house_norm).
centroid = среднее центроидов частей; floors = max; purpose/material = первый
непустой; source_keys = собранные key.
"""
grouped: dict[tuple[str, str], dict] = {}
for b in buildings:
key = (normalize_street(b.street), normalize_house(b.house))
if key not in grouped:
grouped[key] = {
"street": b.street.strip(),
"house": b.house.strip(),
"lat_sum": 0.0,
"lon_sum": 0.0,
"n": 0,
"floors": None,
"purpose": None,
"material": None,
"source_keys": [],
}
g = grouped[key]
g["lat_sum"] += b.lat
g["lon_sum"] += b.lon
g["n"] += 1
if b.floors is not None:
g["floors"] = b.floors if g["floors"] is None else max(g["floors"], b.floors)
if g["purpose"] is None and b.purpose is not None:
g["purpose"] = b.purpose
if g["material"] is None and b.material is not None:
g["material"] = b.material
if b.key is not None and b.key not in g["source_keys"]:
g["source_keys"].append(b.key)
return grouped
def _upsert_building(
db: Session,
*,
street_norm: str,
house_norm: str,
agg: dict,
) -> None:
"""UPSERT одного здания в ekb_geoportal_buildings (ON CONFLICT DO UPDATE)."""
lat = agg["lat_sum"] / agg["n"]
lon = agg["lon_sum"] / agg["n"]
db.execute(
text("""
INSERT INTO ekb_geoportal_buildings (
street, house, street_norm, house_norm,
lat, lon, floors, purpose, material, source_keys, updated_at
) VALUES (
:street, :house, :street_norm, :house_norm,
CAST(:lat AS double precision), CAST(:lon AS double precision),
CAST(:floors AS integer), :purpose, :material,
CAST(:source_keys AS bigint[]), now()
)
ON CONFLICT (street_norm, house_norm) DO UPDATE SET
street = EXCLUDED.street,
house = EXCLUDED.house,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
floors = EXCLUDED.floors,
purpose = EXCLUDED.purpose,
material = EXCLUDED.material,
source_keys = EXCLUDED.source_keys,
updated_at = now()
"""),
{
"street": agg["street"],
"house": agg["house"],
"street_norm": street_norm,
"house_norm": house_norm,
"lat": lat,
"lon": lon,
"floors": agg["floors"],
"purpose": agg["purpose"],
"material": agg["material"],
"source_keys": agg["source_keys"],
},
)
async def ingest(
db: Session,
*,
lon_min: float,
lat_min: float,
lon_max: float,
lat_max: float,
) -> int:
"""Тайлит bbox, тянет здания, UPSERT'ит. Возвращает число upsert'нутых зданий."""
client = EkbGeoportalClient()
tiles = _tile_grid(lon_min, lat_min, lon_max, lat_max)
total_tiles = len(tiles)
logger.info(
"geoportal ingest: bbox=(%.4f,%.4f,%.4f,%.4f) → %d тайлов",
lon_min,
lat_min,
lon_max,
lat_max,
total_tiles,
)
upserted = 0
for i, tile in enumerate(tiles, start=1):
buildings = await client.fetch_topo_buildings(tile)
if buildings:
grouped = _group_buildings(buildings)
try:
with db.begin_nested():
for (street_norm, house_norm), agg in grouped.items():
_upsert_building(
db,
street_norm=street_norm,
house_norm=house_norm,
agg=agg,
)
upserted += len(grouped)
except Exception:
logger.warning("geoportal ingest: batch failed for tile=%s", tile, exc_info=True)
await asyncio.sleep(REQUEST_SLEEP_SEC)
if i % PROGRESS_EVERY == 0 or i == total_tiles:
db.commit()
logger.info(
"geoportal ingest: %d/%d тайлов, upsert'нуто %d зданий, tile=%s",
i,
total_tiles,
upserted,
tile,
)
db.commit()
logger.info("geoportal ingest: готово — upsert'нуто %d зданий", upserted)
return upserted
def _resolve_bbox(args: argparse.Namespace) -> tuple[float, float, float, float]:
"""Разрешает bbox: argv → env BBOX → дефолтный ЕКБ."""
env_bbox = os.environ.get("BBOX")
if env_bbox:
parts = [float(p) for p in env_bbox.split(",")]
if len(parts) == 4:
return (parts[0], parts[1], parts[2], parts[3])
logger.warning("BBOX env malformed (%r) — игнорирую", env_bbox)
return (
args.lon_min if args.lon_min is not None else EKB_LON_MIN,
args.lat_min if args.lat_min is not None else EKB_LAT_MIN,
args.lon_max if args.lon_max is not None else EKB_LON_MAX,
args.lat_max if args.lat_max is not None else EKB_LAT_MAX,
)
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(description="EKB geoportal building ingest")
parser.add_argument("--lon-min", type=float, default=None)
parser.add_argument("--lon-max", type=float, default=None)
parser.add_argument("--lat-min", type=float, default=None)
parser.add_argument("--lat-max", type=float, default=None)
args = parser.parse_args()
lon_min, lat_min, lon_max, lat_max = _resolve_bbox(args)
db = SessionLocal()
try:
asyncio.run(
ingest(
db,
lon_min=lon_min,
lat_min=lat_min,
lon_max=lon_max,
lat_max=lat_max,
)
)
finally:
db.close()
if __name__ == "__main__":
main()