gendesign/tradein-mvp/backend/app/services/zhkh_flats_loader.py
bot-backend 078dadc440
All checks were successful
CI / changes (pull_request) Successful in 7s
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): cadastre ЖКХ-fallback + COALESCE merge-key + median-floors guard
Серия sale-share data-quality (по находкам юзера):
- house_dedup_merge: cluster_key → tradein_canon_addr(COALESCE(short_address,
  full_address, address)) — ловит дубли с район-noise в address (Вайнера 66).
- zhkh_flats_loader: cadastre-fallback (searchByAddress по cadastreNumber,
  guid-независимо) — резолвит дома где gar_house_guid ≠ ЖКХ houseGuid
  (Переходный 6: guid→0, cadastre→165). CLI --cadastre.
- мигр.153: плаузибилити-гард по медиане этажности листингов — denom >=
  GREATEST(total_floors, median(listings.total_floors), 8); отсекает gar-недосчёт
  башен без ЖКХ (gar=9 vs листинги 19-26 эт → NULL).

ruff clean, pytest 36 green.
2026-06-29 01:32:48 +03:00

604 lines
26 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.

"""ГИС ЖКХ loader: «квартир на дом» (знаменатель «доли квартир дома в продаже»).
Тянет точный счёт квартир (residentialPremiseCount) из публичного API ГИС ЖКХ
(dom.gosuslugi.ru) и проставляет `houses.zhkh_*` (мигр. 146/149). В отличие от ГАР-loader'а
(стриминговый парс многогигабайтного XML + fuzzy canon-match адреса) здесь матч ТРИВИАЛЬНЫЙ:
`houses.gar_house_guid` — это И ЕСТЬ ЖКХ `houseGuid` (проверено), поэтому ищем дом строго по
этому guid, без нормализации/канона адреса.
ГИС ЖКХ — точнее ГАР (ГАР дико недосчитывает квартиры в МКД, см. мигр. 149): резолвит ~90%
вторички и даёт год ввода (operationYear) + этажность (maxFloorCount) как бонус.
API-рецепт (verified, прод):
POST searchByAddress?pageIndex=1&elementsPerPage=100 — БЕЗ auth/cookies.
· pageIndex — 1-based: pageIndex=0 молча отдаёт пусто (200). Всегда 1.
· elementsPerPage cap = 100 (≥200 → 403) → батч ≤100 guid на запрос, одна страница.
· Тело — ПОЛНЫЙ DTO (все поля, неиспользуемые = None); fiasHouseCodeList — батч guid.
· Ответ {"total":N,"items":[...]}; per item residentialPremiseCount (строка) = счёт квартир,
operationYear/buildingYear = год, maxFloorCount (строка-диапазон "10-30") = этажность,
address.house.houseGuid = входной guid (ключ для обратного маппинга).
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
import logging
import re
import time
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Константы API
# ─────────────────────────────────────────────────────────────────────────────
# pageIndex/elementsPerPage — query-параметры (НЕ в теле). pageIndex 1-based (=0 → пусто),
# elementsPerPage cap=100 (≥200 → 403).
ENDPOINT = (
"https://dom.gosuslugi.ru/homemanagement/api/rest/services/houses/public/"
"searchByAddress?pageIndex=1&elementsPerPage=100"
)
# FIAS GUID Свердловской обл. (region 66) — обязателен в теле DTO.
REGION_GUID = "92b30014-4d52-4e2e-892d-928142b924bf"
# Batch = elementsPerPage cap: ≤100 guid/запрос, одна страница (pageIndex=1).
BATCH_SIZE = 100
# Группировка UPDATE в один SAVEPOINT: сбойный ЧАНК (≤200 строк) откатывается изолированно,
# остальные чанки доезжают; повторный прогон идемпотентен (IS DISTINCT FROM gate).
SAVEPOINT_CHUNK = 200
DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# ─────────────────────────────────────────────────────────────────────────────
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
# ─────────────────────────────────────────────────────────────────────────────
# NB: maxFloorCount хранится СЫРЫМ ("10-30"/"17,23") в zhkh_floors_raw — числовой парс
# этажности отложен до PR фильтра новостроек (#2), который и решит, как трактовать диапазон.
def parse_int(raw: object) -> int | None:
"""Устойчивый str/int → int|None.
None/«»/«0»/нечисло → None (0 квартир — не пригодный знаменатель; ≤0 тоже отбрасываем).
"""
if raw is None or isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw if raw > 0 else None
s = str(raw).strip()
if not s:
return None
try:
n = int(s)
except ValueError:
return None
return n if n > 0 else None
_FLOORS_RE = re.compile(r"\d+")
def _parse_floors_max(floors_raw: str | None) -> int | None:
"""Макс. этаж из сырой maxFloorCount («10-30»/«17,23»/«9») → int|None.
Зеркалит мигр. 152 (max((m[1])::int) по regexp '[0-9]+' над zhkh_floors_raw): берём все
числовые прогоны и возвращаем максимум; нет цифр/None → None.
"""
if not floors_raw:
return None
nums = [int(m) for m in _FLOORS_RE.findall(floors_raw)]
return max(nums) if nums else None
@dataclass(slots=True)
class ZhkhHouse:
"""Распарсенная ЖКХ-строка дома (ключ обратного маппинга — house_guid)."""
house_guid: str
flat_count: int | None
year: int | None
floors_raw: str | None
def _parse_item(item: dict[str, Any]) -> ZhkhHouse | None:
"""Извлечь guid + поля из item ответа ГИС ЖКХ. None, если нет houseGuid.
year = operationYear (предпочтительно) иначе parse_int(buildingYear);
flat_count = parse_int(residentialPremiseCount); floors_raw = сырая maxFloorCount.
"""
address = item.get("address") or {}
house = address.get("house") or {}
guid = house.get("houseGuid")
if not guid:
return None
year = parse_int(item.get("operationYear"))
if year is None:
year = parse_int(item.get("buildingYear"))
floors_value = item.get("maxFloorCount")
floors_raw = None if floors_value is None else str(floors_value)
return ZhkhHouse(
house_guid=str(guid),
flat_count=parse_int(item.get("residentialPremiseCount")),
year=year,
floors_raw=floors_raw,
)
# ─────────────────────────────────────────────────────────────────────────────
# HTTP: заголовки + тело + fetch
# ─────────────────────────────────────────────────────────────────────────────
def _headers() -> dict[str, str]:
"""Заголовки запроса. request-guid/session-guid — свежие random uuid4 (random OK)."""
return {
"content-type": "application/json;charset=UTF-8",
"accept": "application/json; charset=utf-8",
"state-guid": "/houses",
"request-guid": str(uuid.uuid4()),
"session-guid": str(uuid.uuid4()),
"user-agent": DEFAULT_UA,
"referer": "https://dom.gosuslugi.ru/",
"origin": "https://dom.gosuslugi.ru",
}
def _build_body(batch: list[str]) -> dict[str, Any]:
"""ПОЛНЫЙ DTO searchByAddress (15 полей; неиспользуемые = None). batch → fiasHouseCodeList."""
return {
"regionCode": REGION_GUID,
"fiasHouseCodeList": batch,
"estStatus": None,
"strStatus": None,
"calcCount": True,
"houseConditionRefList": None,
"houseTypeRefList": None,
"houseManagementTypeRefList": None,
"cadastreNumber": None,
"oktmo": None,
"statuses": ["APPROVED"],
"regionProperty": None,
"municipalProperty": None,
"hostelTypeCodes": None,
"useReadOnlyDataSource": True,
}
def _body_by_cadastre(cad: str) -> dict[str, Any]:
"""Тот же полный DTO searchByAddress, но поиск по кадастру: fiasHouseCodeList=None,
cadastreNumber=cad (ОДИНОЧНОЕ значение, НЕ список — verified, прод). Остальные 15 ключей
идентичны _build_body."""
return {
"regionCode": REGION_GUID,
"fiasHouseCodeList": None,
"estStatus": None,
"strStatus": None,
"calcCount": True,
"houseConditionRefList": None,
"houseTypeRefList": None,
"houseManagementTypeRefList": None,
"cadastreNumber": cad,
"oktmo": None,
"statuses": ["APPROVED"],
"regionProperty": None,
"municipalProperty": None,
"hostelTypeCodes": None,
"useReadOnlyDataSource": True,
}
def _chunk_guids(guids: list[str], size: int) -> Iterator[list[str]]:
for i in range(0, len(guids), size):
yield guids[i : i + size]
def _fetch_batch(
batch: list[str], *, client: httpx.Client, max_retries: int
) -> list[dict[str, Any]]:
"""POST одного батча guid (pageIndex=1) → items. Ретрай на 403/5xx/httpx-ошибках.
Backoff 1,2,4с между попытками. На финальном провале — warning + пустой список (батч
пропускается, весь прогон не срывается). Прочие 4xx (битый запрос) — не ретраим.
"""
body = _build_body(batch)
last_err = ""
for attempt in range(1, max_retries + 1):
try:
resp = client.post(ENDPOINT, json=body, headers=_headers())
if resp.status_code != 403 and resp.status_code < 500:
resp.raise_for_status() # прочие 4xx → невосстановимый HTTPStatusError
data = resp.json()
items = data.get("items")
return items if isinstance(items, list) else []
last_err = f"HTTP {resp.status_code}"
except httpx.HTTPStatusError as exc:
logger.warning(
"zhkh fetch: батч %d guid — невосстановимый %s (пропущен)", len(batch), exc
)
return []
except (httpx.HTTPError, ValueError) as exc:
last_err = repr(exc)
if attempt < max_retries:
backoff = 2 ** (attempt - 1)
logger.warning(
"zhkh fetch: батч %d guid сбой (%s), retry %d/%d через %dс",
len(batch),
last_err,
attempt,
max_retries,
backoff,
)
time.sleep(backoff)
logger.warning(
"zhkh fetch: батч %d guid сбойнул после %d попыток (%s), пропущен",
len(batch),
max_retries,
last_err,
)
return []
def fetch_zhkh(
guids: list[str],
*,
client: httpx.Client,
sleep_sec: float = 0.4,
max_retries: int = 3,
) -> dict[str, ZhkhHouse]:
"""Бьёт guid на батчи по BATCH_SIZE, POST'ит каждый, парсит items → dict по house_guid.
Между батчами — пауза sleep_sec (вежливость). Прогресс логируется каждые 10 батчей.
Сбойный батч пропускается (см. _fetch_batch), а не срывает весь прогон.
"""
result: dict[str, ZhkhHouse] = {}
batches = list(_chunk_guids(guids, BATCH_SIZE))
total = len(batches)
for idx, batch in enumerate(batches, start=1):
for item in _fetch_batch(batch, client=client, max_retries=max_retries):
parsed = _parse_item(item)
if parsed is not None:
result[parsed.house_guid] = parsed
if idx % 10 == 0 or idx == total:
logger.info("zhkh fetch: батч %d/%d, резолвлено домов=%d", idx, total, len(result))
if idx < total and sleep_sec > 0:
time.sleep(sleep_sec)
return result
def fetch_by_cadastre(cad: str, *, client: httpx.Client, max_retries: int = 3) -> ZhkhHouse | None:
"""POST searchByAddress по кадастру (одиночный cadastreNumber) → первый item → ZhkhHouse|None.
Тот же ретрай-паттерн, что и _fetch_batch: 403/5xx/httpx-ошибки → backoff 1,2,4с и повтор;
прочие 4xx (битый запрос) → None без ретрая; пустой ответ / нет items → None.
"""
body = _body_by_cadastre(cad)
last_err = ""
for attempt in range(1, max_retries + 1):
try:
resp = client.post(ENDPOINT, json=body, headers=_headers())
if resp.status_code != 403 and resp.status_code < 500:
resp.raise_for_status() # прочие 4xx → невосстановимый HTTPStatusError
data = resp.json()
items = data.get("items")
if not isinstance(items, list) or not items:
return None
return _parse_item(items[0])
last_err = f"HTTP {resp.status_code}"
except httpx.HTTPStatusError as exc:
logger.warning("zhkh cadastre: %s — невосстановимый %s (пропущен)", cad, exc)
return None
except (httpx.HTTPError, ValueError) as exc:
last_err = repr(exc)
if attempt < max_retries:
backoff = 2 ** (attempt - 1)
logger.warning(
"zhkh cadastre: %s сбой (%s), retry %d/%d через %dс",
cad,
last_err,
attempt,
max_retries,
backoff,
)
time.sleep(backoff)
logger.warning(
"zhkh cadastre: %s сбойнул после %d попыток (%s), пропущен", cad, max_retries, last_err
)
return None
# ─────────────────────────────────────────────────────────────────────────────
# UPDATE houses по gar_house_guid (== ЖКХ houseGuid)
# ─────────────────────────────────────────────────────────────────────────────
# Идемпотентный UPDATE: gate `IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие
# строки. CAST(:x AS type) — psycopg v3 discipline (никогда :x::type).
_UPDATE_SQL = text(
"""
UPDATE houses
SET zhkh_flat_count = CAST(:fc AS int),
zhkh_house_guid = CAST(:g AS text),
zhkh_matched_at = now(),
zhkh_year = CAST(:yr AS smallint),
zhkh_floors_raw = CAST(:fr AS text)
WHERE gar_house_guid = CAST(:g AS text)
AND (
zhkh_flat_count IS DISTINCT FROM CAST(:fc AS int)
OR zhkh_year IS DISTINCT FROM CAST(:yr AS smallint)
OR zhkh_floors_raw IS DISTINCT FROM CAST(:fr AS text)
)
"""
)
_SELECT_GUIDS_SQL = text(
"""
SELECT DISTINCT gar_house_guid
FROM houses
WHERE gar_house_guid IS NOT NULL
"""
)
# Тот же запрос с LIMIT для сэмплинга (--limit). Отдельный text() — psycopg v3 не любит
# условную инъекцию LIMIT в строку.
_SELECT_GUIDS_LIMIT_SQL = text(
"""
SELECT DISTINCT gar_house_guid
FROM houses
WHERE gar_house_guid IS NOT NULL
LIMIT CAST(:limit AS int)
"""
)
def _chunk_houses(items: list[ZhkhHouse], size: int) -> Iterator[list[ZhkhHouse]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def match_houses_to_zhkh(
db: Session,
*,
region_code: str = "66",
limit: int | None = None,
dry_run: bool = False,
sleep_sec: float = 0.4,
) -> dict[str, int]:
"""ГИС ЖКХ → houses.zhkh_* по gar_house_guid (== ЖКХ houseGuid). НЕ коммитит (caller).
region_code — информационно/в лог (все наши guid — region 66; параметр для паритета с
ГАР-CLI). limit (если задан) ограничивает выборку домов (сэмпл). dry_run — НОЛЬ записей,
только подсчёт. Возвращает {houses_seen, zhkh_resolved, zhkh_with_flatcount, houses_updated}.
"""
if limit is not None:
rows = db.execute(_SELECT_GUIDS_LIMIT_SQL, {"limit": limit}).scalars().all()
else:
rows = db.execute(_SELECT_GUIDS_SQL).scalars().all()
guids = [str(g) for g in rows if g]
houses_seen = len(guids)
logger.info(
"zhkh match: домов с gar_house_guid=%d (region=%s, limit=%s, dry_run=%s)",
houses_seen,
region_code,
limit,
dry_run,
)
with httpx.Client(timeout=40) as client:
zhkh_by_guid = fetch_zhkh(guids, client=client, sleep_sec=sleep_sec)
zhkh_resolved = len(zhkh_by_guid)
with_flatcount = [
z for z in zhkh_by_guid.values() if z.flat_count is not None and z.flat_count > 0
]
zhkh_with_flatcount = len(with_flatcount)
houses_updated = 0
if dry_run:
logger.info(
"zhkh match DRY-RUN: резолвлено=%d с flat_count=%d (записей не делаем)",
zhkh_resolved,
zhkh_with_flatcount,
)
else:
for chunk in _chunk_houses(with_flatcount, SAVEPOINT_CHUNK):
chunk_updated = 0
try:
with db.begin_nested():
for z in chunk:
res = db.execute(
_UPDATE_SQL,
{
"fc": z.flat_count,
"g": z.house_guid,
"yr": z.year,
"fr": z.floors_raw,
},
)
chunk_updated += res.rowcount
houses_updated += chunk_updated
except Exception:
logger.warning(
"zhkh match: чанк из %d UPDATE сбойнул (откат savepoint)",
len(chunk),
exc_info=True,
)
result = {
"houses_seen": houses_seen,
"zhkh_resolved": zhkh_resolved,
"zhkh_with_flatcount": zhkh_with_flatcount,
"houses_updated": houses_updated,
}
logger.info(
"zhkh match DONE: seen=%d resolved=%d with_flatcount=%d updated=%d (dry_run=%s)",
houses_seen,
zhkh_resolved,
zhkh_with_flatcount,
houses_updated,
dry_run,
)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Cadastre-fallback: резолв ЖКХ по listings.building_cadastral_number (UPDATE по houses.id)
# ─────────────────────────────────────────────────────────────────────────────
# Гид-loader промахивается по домам, чей gar_house_guid ≠ ЖКХ houseGuid (напр. Переходный 6:
# наш guid → ЖКХ total 0, но кадастр 66:41:0604007:747 → ЖКХ 165). Здесь матчим по кадастру
# листингов и UPDATE'им строго по houses.id (ЖКХ houseGuid отличается → match по guid невозможен).
# Идемпотентность: gate `zhkh_flat_count IS NULL` → повторный прогон не трогает уже резолвленные.
_UPDATE_BY_ID_SQL = text(
"""
UPDATE houses
SET zhkh_flat_count = CAST(:fc AS int),
zhkh_house_guid = CAST(:g AS text),
zhkh_matched_at = now(),
zhkh_year = CAST(:yr AS smallint),
zhkh_floors_raw = CAST(:fr AS text),
zhkh_floors = CAST(:zf AS smallint)
WHERE id = CAST(:hid AS bigint)
AND zhkh_flat_count IS NULL
"""
)
# Дома без ЖКХ-счёта, у которых есть кадастр здания в листингах: берём САМЫЙ ЧАСТЫЙ непустой
# building_cadastral_number на дом (DISTINCT ON + ORDER BY count(*) DESC).
_SELECT_CADASTRE_SQL = text(
"""
SELECT DISTINCT ON (l.house_id_fk)
l.house_id_fk, l.building_cadastral_number
FROM listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.zhkh_flat_count IS NULL
AND l.building_cadastral_number IS NOT NULL
GROUP BY l.house_id_fk, l.building_cadastral_number
ORDER BY l.house_id_fk, count(*) DESC
"""
)
# Тот же запрос с LIMIT (сэмпл): LIMIT применяется ПОСЛЕ DISTINCT ON → ограничивает число домов.
_SELECT_CADASTRE_LIMIT_SQL = text(
"""
SELECT DISTINCT ON (l.house_id_fk)
l.house_id_fk, l.building_cadastral_number
FROM listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.zhkh_flat_count IS NULL
AND l.building_cadastral_number IS NOT NULL
GROUP BY l.house_id_fk, l.building_cadastral_number
ORDER BY l.house_id_fk, count(*) DESC
LIMIT CAST(:limit AS int)
"""
)
def _chunk_targets(targets: list[tuple[int, str]], size: int) -> Iterator[list[tuple[int, str]]]:
for i in range(0, len(targets), size):
yield targets[i : i + size]
def match_houses_to_zhkh_by_cadastre(
db: Session,
*,
limit: int | None = None,
dry_run: bool = False,
sleep_sec: float = 0.5,
cooldown: int = 150,
chunk: int = 15,
) -> dict[str, int]:
"""Cadastre-fallback резолв ЖКХ для домов, чей gar_house_guid ≠ ЖКХ houseGuid.
Выбирает дома без zhkh_flat_count, у которых есть building_cadastral_number в листингах
(самый частый непустой кадастр на дом), ищет каждый дом в ГИС ЖКХ по cadastreNumber и при
flat_count>0 UPDATE'ит houses ПО id (ЖКХ houseGuid отличается → match по guid невозможен,
см. Переходный 6).
Throttle-aware: лукапы режутся на чанки по `chunk`, между чанками — time.sleep(cooldown)
(публичное API лимитит ~per-window, как и батч-путь); sleep_sec — пауза между отдельными
запросами. Коммит per-chunk (не dry-run) → прогресс длинного прогона переживает обрыв.
dry_run → НОЛЬ записей (только подсчёт резолвимых). Идемпотентно (gate zhkh_flat_count IS NULL).
Возвращает {houses_seen, resolved, updated}.
"""
if limit is not None:
rows = db.execute(_SELECT_CADASTRE_LIMIT_SQL, {"limit": limit}).all()
else:
rows = db.execute(_SELECT_CADASTRE_SQL).all()
targets: list[tuple[int, str]] = [
(int(r.house_id_fk), str(r.building_cadastral_number)) for r in rows
]
houses_seen = len(targets)
logger.info(
"zhkh cadastre match: домов без ЖКХ с кадастром=%d (limit=%s, dry_run=%s, chunk=%d, "
"cooldown=%dс, sleep=%.2fс)",
houses_seen,
limit,
dry_run,
chunk,
cooldown,
sleep_sec,
)
resolved = 0
updated = 0
batches = list(_chunk_targets(targets, chunk))
total_chunks = len(batches)
with httpx.Client(timeout=40) as client:
for chunk_idx, batch in enumerate(batches, start=1):
for hid, cad in batch:
zh = fetch_by_cadastre(cad, client=client)
if zh is not None and zh.flat_count is not None and zh.flat_count > 0:
resolved += 1
if not dry_run:
zf = _parse_floors_max(zh.floors_raw)
try:
with db.begin_nested():
res = db.execute(
_UPDATE_BY_ID_SQL,
{
"fc": zh.flat_count,
"g": zh.house_guid,
"yr": zh.year,
"fr": zh.floors_raw,
"zf": zf,
"hid": hid,
},
)
updated += res.rowcount
except Exception:
logger.warning(
"zhkh cadastre match: UPDATE house_id=%d cad=%s сбойнул "
"(откат savepoint)",
hid,
cad,
exc_info=True,
)
if sleep_sec > 0:
time.sleep(sleep_sec)
if not dry_run:
db.commit() # фиксируем прогресс чанка до cooldown
logger.info(
"zhkh cadastre match: чанк %d/%d, резолвлено=%d, updated=%d",
chunk_idx,
total_chunks,
resolved,
updated,
)
if chunk_idx < total_chunks and cooldown > 0:
time.sleep(cooldown)
result = {"houses_seen": houses_seen, "resolved": resolved, "updated": updated}
logger.info(
"zhkh cadastre match DONE: seen=%d resolved=%d updated=%d (dry_run=%s)",
houses_seen,
resolved,
updated,
dry_run,
)
return result