Compare commits

..

No commits in common. "479f01ae1ad2b2a370c37fab97e0f915629d6264" and "7fd69e90cded2de5d6211e9030c7df6ef39055f5" have entirely different histories.

33 changed files with 191 additions and 4510 deletions

View file

@ -22,7 +22,6 @@ from app.schemas.parcel import (
AnalyzeResponse, AnalyzeResponse,
BestLayoutsRequest, BestLayoutsRequest,
BestLayoutsResponse, BestLayoutsResponse,
CompetitorsParkingResponse,
CompetitorsRequest, CompetitorsRequest,
CompetitorsResponse, CompetitorsResponse,
ConnectionPointsResponse, ConnectionPointsResponse,
@ -59,7 +58,7 @@ from app.services.site_finder.cadastre_fetch import (
from app.services.site_finder.cadastre_fetch import ( from app.services.site_finder.cadastre_fetch import (
fetch_status as _fetch_status, fetch_status as _fetch_status,
) )
from app.services.site_finder.competitors import get_competitors, get_competitors_parking from app.services.site_finder.competitors import get_competitors
from app.services.site_finder.custom_pois import ( from app.services.site_finder.custom_pois import (
get_overlaps_for_scoring as _get_custom_poi_overlaps, get_overlaps_for_scoring as _get_custom_poi_overlaps,
) )
@ -76,7 +75,6 @@ from app.services.site_finder.quarter_dump_lookup import (
get_quarter_dump_data, get_quarter_dump_data,
make_empty_result, make_empty_result,
) )
from app.services.site_finder.saturation import compute_district_saturation
from app.services.site_finder.velocity import compute_velocity from app.services.site_finder.velocity import compute_velocity
from app.services.site_finder.weight_profiles import ( from app.services.site_finder.weight_profiles import (
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS, _SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
@ -1601,23 +1599,7 @@ def analyze_parcel(
WHERE latitude IS NOT NULL WHERE latitude IS NOT NULL
ORDER BY obj_id, snapshot_date DESC NULLS LAST ORDER BY obj_id, snapshot_date DESC NULLS LAST
), ),
nearby_obj AS (
-- Ближние конкуренты (3 км) единственные obj_id, чьи цены реально
-- попадают в выдачу. Считаем геофильтр один раз, переиспользуем в
-- obj_pricing (pushdown scope) и в финальном SELECT.
SELECT *
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
),
obj_pricing AS ( obj_pricing AS (
-- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id.
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
SELECT SELECT
cm.domrf_obj_id, cm.domrf_obj_id,
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub, ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
@ -1629,7 +1611,6 @@ def analyze_parcel(
) AS lots_with_price ) AS lots_with_price
FROM objective_complex_mapping cm FROM objective_complex_mapping cm
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)
GROUP BY cm.domrf_obj_id GROUP BY cm.domrf_obj_id
) )
SELECT o.obj_id, SELECT o.obj_id,
@ -1651,8 +1632,13 @@ def analyze_parcel(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m ) AS distance_m
FROM nearby_obj o FROM latest_obj o
LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END, ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
distance_m ASC distance_m ASC
LIMIT 20 LIMIT 20
@ -2714,31 +2700,11 @@ def analyze_parcel(
"top_sellers": _top_sellers_list, "top_sellers": _top_sellers_list,
} }
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
# только если у участка резолвится район; ошибка/нет демографии → None (не роняет
# analyze). SAVEPOINT изолирует от outer-сессии (как success_recommendation выше).
saturation_block: dict[str, Any] | None = None
if district_row and district_row["district_name"]:
try:
with db.begin_nested():
saturation_block = compute_district_saturation(db, district_row["district_name"])
except Exception as e:
logger.warning(
"saturation block failed for %s (%s): %s",
cad_num,
district_row["district_name"],
e,
)
result_payload: dict[str, Any] = { result_payload: dict[str, Any] = {
"cad_num": cad_num, "cad_num": cad_num,
"source": source, "source": source,
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None, "geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
"district": dict(district_row) if district_row else None, "district": dict(district_row) if district_row else None,
# #42: per-capita обеспеченность соцобъектами района (saturation) + влияние
# на POI-score (score_multiplier). None если район не резолвится / нет демографии.
"infra": {"saturation": saturation_block},
"score": round(score_final, 2), "score": round(score_final, 2),
"score_without_center": round(score, 2), "score_without_center": round(score, 2),
"score_label": _score_label(score_final), "score_label": _score_label(score_final),
@ -3195,36 +3161,6 @@ async def get_parcel_competitors(
) from exc ) from exc
@router.post("/{cad_num}/competitors-parking", response_model=CompetitorsParkingResponse)
def get_parcel_competitors_parking(
cad_num: str,
body: CompetitorsRequest,
db: Annotated[Session, Depends(get_db)],
) -> CompetitorsParkingResponse:
"""parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze.
Резолвит top-3 конкурента (relevance_weight DESC) domrfcad_buildings через
PostGIS geom-match, затем on-demand НСПД premises-lookup parking_ratio.
Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы
секунды в хот-путь и убил p95 фронт дёргает этот эндпоинт лениво после
отрисовки списка конкурентов.
sync `def` (не async): premises_lookup изолирует НСПД-async через
asyncio.run() его нельзя вызвать из async-handler (running loop). FastAPI
исполняет sync-handler в threadpool, не блокируя event loop.
"""
try:
return get_competitors_parking(db=db, cad_num=cad_num, request=body)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
logger.error("competitors-parking endpoint failed for %s: %s", cad_num, exc)
raise HTTPException(
status_code=500,
detail="Ошибка расчёта паркинга конкурентов",
) from exc
@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse) @router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse)
async def get_parcel_best_layouts( async def get_parcel_best_layouts(
cad_num: str, cad_num: str,

View file

@ -198,11 +198,6 @@ class Competitor(BaseModel):
sold_pct: float | None sold_pct: float | None
velocity_per_month: float velocity_per_month: float
avg_price_per_m2: float | None avg_price_per_m2: float | None
# OBJ-3 (#307): источник avg_price_per_m2 для прозрачности инвест-рекомендаций.
# "domrf" — per-flat цена domrf_kn_flats (приоритет); "objective" — медиана
# objective_lots.price_per_m2_rub через objective_complex_mapping (additive
# fallback, заполняет пробелы где domrf пуст); None — цены нет ни там, ни там.
price_source: str | None = None
is_active: bool is_active: bool
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] — # #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
# взвешенная комбинация geo_proximity / class_similarity / price_similarity / # взвешенная комбинация geo_proximity / class_similarity / price_similarity /
@ -225,43 +220,6 @@ class CompetitorsResponse(BaseModel):
summary: CompetitorsSummary summary: CompetitorsSummary
# ── #96 wiring: parking_ratio конкурентов (lazy-endpoint, НЕ inline в analyze) ──
# domrf-конкуренты живут в lat/lon без cad_num → geom-match на cad_buildings →
# on-demand НСПД premises-lookup. Дорого (N×НСПД ~0.6-2с) → отдельный ленивый
# эндпоинт /{cad}/competitors-parking, строгий top-N, фронт дёргает после
# отрисовки конкурентов. НЕ блокирует analyze/forecast p95.
class CompetitorParking(BaseModel):
"""parking_ratio одного конкурента (#96). obj_id связывает с Competitor.
matched_cad_num здание cad_buildings, на которое сматчился domrf-центроид
(None если geom-match не нашёл здание в радиусе). parking_ratio /
parking_count / flats_count из НСПД tab-group (None если здание не
сматчилось ИЛИ НСПД недоступен graceful, НЕ нулевой паркинг).
match_distance_m расстояние domrf-центроид footprint (диагностика).
"""
obj_id: int
matched_cad_num: str | None = None
match_distance_m: float | None = None
parking_ratio: float | None = None
parking_count: int | None = None
flats_count: int | None = None
class CompetitorsParkingResponse(BaseModel):
"""Ответ ленивого /{cad}/competitors-parking (#96).
items по одному CompetitorParking на top-N конкурентов (по relevance_weight
DESC из get_competitors). matched_count сколько реально получили
parking_ratio (диагностика покрытия для фронта / мониторинга).
"""
items: list[CompetitorParking]
matched_count: int
# ── Developer attribution (#1088 «GG-форсайт») ───────────────────────────────── # ── Developer attribution (#1088 «GG-форсайт») ─────────────────────────────────
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка # Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel / # + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /

View file

@ -24,27 +24,14 @@ from sqlalchemy.orm import Session
from app.schemas.parcel import ( from app.schemas.parcel import (
Competitor, Competitor,
CompetitorParking,
CompetitorsParkingResponse,
CompetitorsRequest, CompetitorsRequest,
CompetitorsResponse, CompetitorsResponse,
CompetitorsSummary, CompetitorsSummary,
) )
from app.services.forecast_request_cache import cached from app.services.forecast_request_cache import cached
from app.services.site_finder.premises_lookup import (
get_building_premises_for_match,
resolve_cad_for_domrf,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# #96 wiring: сколько top-конкурентов (по relevance_weight DESC) обогащаем
# parking_ratio в ленивом эндпоинте. Строгий top-N: каждый = geom-match SQL
# (~19ms) + НСПД premises (~0.6-2с). 3 — баланс «достаточно для сравнения дефицита
# паркинга» vs суммарная latency (~2-6с на ленивый вызов, ВНЕ analyze/forecast
# хот-пути). Расширять с осторожностью — линейно множит НСПД-запросы.
_PARKING_TOP_N: int = 3
def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]: def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод. """Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод.
@ -63,7 +50,6 @@ def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) ->
tuple(sorted(request.exclude_obj_ids)), tuple(sorted(request.exclude_obj_ids)),
) )
# Маппинг time_window → число месяцев (float для деления velocity) # Маппинг time_window → число месяцев (float для деления velocity)
_TIME_WINDOW_MONTHS: dict[str, float] = { _TIME_WINDOW_MONTHS: dict[str, float] = {
"last_month": 1.0, "last_month": 1.0,
@ -470,37 +456,6 @@ _AVG_PRICE_SQL = text("""
GROUP BY f.obj_id GROUP BY f.obj_id
""") """)
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
# Замер прода 2026-06: на ПОСЛЕДНЕМ snapshot domrf_kn_flats.price_per_m2 = 0 строк
# (скрапер перестал отдавать per-flat цены), поэтому _AVG_PRICE_SQL выше даёт
# avg_price_per_m2=None почти всем конкурентам. objective_lots.price_per_m2_rub
# покрывает ~80% (919795/1123771 строк) и мостится к domrf-obj_id через
# objective_complex_mapping (то же звено, что _INLINE_VELOCITY_SQL в best_layouts).
#
# Мост: domrf_kn_objects.obj_id == objective_complex_mapping.domrf_obj_id
# → objective_complex_mapping.objective_complex_name == objective_lots.project_name.
# Маппинг 1:1 (прод: 263 строки, 263 distinct obj_id, 0 obj_id с >1 именем), поэтому
# на obj_id приходится РОВНО один project_name; корпуса проекта агрегируются в один
# проект естественно. Берём МЕДИАНУ price_per_m2_rub по всем лотам проекта
# (percentile_cont 0.5) — устойчива к хвостовым выбросам пентхаусов/студий, в отличие
# от AVG. Прод-санити: медиана медиан 136k руб/м² (диапазон 80k250k для ЕКБ ✓).
#
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
SELECT
cm.domrf_obj_id AS obj_id,
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
AS median_price_per_m2
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND ol.price_per_m2_rub IS NOT NULL
GROUP BY cm.domrf_obj_id
""")
def _quarter_from_cad(cad_num: str) -> str: def _quarter_from_cad(cad_num: str) -> str:
"""Извлечь кадастровый квартал из номера участка/здания. """Извлечь кадастровый квартал из номера участка/здания.
@ -624,7 +579,6 @@ def get_competitors(
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2, # Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage. # отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
avg_price_map: dict[int, float] = {} avg_price_map: dict[int, float] = {}
price_source_map: dict[int, str] = {}
sold_count_map: dict[int, int] = {} sold_count_map: dict[int, int] = {}
try: try:
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all() price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
@ -633,36 +587,12 @@ def get_competitors(
price = _row_get(r, "avg_price_per_m2") price = _row_get(r, "avg_price_per_m2")
if price is not None: if price is not None:
avg_price_map[oid] = float(price) avg_price_map[oid] = float(price)
price_source_map[oid] = "domrf"
sold = _row_get(r, "flats_sold") sold = _row_get(r, "flats_sold")
if sold is not None: if sold is not None:
sold_count_map[oid] = int(sold) sold_count_map[oid] = int(sold)
except Exception: except Exception:
logger.warning("competitors: avg_price query failed, continuing without prices") logger.warning("competitors: avg_price query failed, continuing without prices")
# ── 4a. OBJ-3 (#307) additive fallback: где domrf-цены НЕТ → objective_lots ──
# Заполняем ТОЛЬКО пробелы (obj_id без domrf-цены). Существующие domrf-выводы
# не трогаем (precedence: domrf > objective). Прод 2026-06: domrf на последнем
# snapshot = 0 цен → почти все obj_id попадают в этот fallback (~80% покрытие).
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
if missing_price_ids:
try:
obj_price_rows = (
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
.mappings()
.all()
)
for r in obj_price_rows:
oid = int(r["obj_id"])
price = _row_get(r, "median_price_per_m2")
if price is not None and oid not in avg_price_map:
avg_price_map[oid] = float(price)
price_source_map[oid] = "objective"
except Exception:
logger.warning(
"competitors: objective price fallback failed, continuing with domrf only"
)
# ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─ # ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─
radius_m = request.radius_km * 1000.0 radius_m = request.radius_km * 1000.0
local_median_ppm2 = _median(list(avg_price_map.values())) local_median_ppm2 = _median(list(avg_price_map.values()))
@ -726,7 +656,6 @@ def get_competitors(
sold_pct=sold_pct, sold_pct=sold_pct,
velocity_per_month=velocity, velocity_per_month=velocity,
avg_price_per_m2=avg_price_map.get(obj_id), avg_price_per_m2=avg_price_map.get(obj_id),
price_source=price_source_map.get(obj_id),
is_active=is_active, is_active=is_active,
relevance_weight=round(relevance_weight, 3), relevance_weight=round(relevance_weight, 3),
relevance_breakdown=relevance_breakdown, relevance_breakdown=relevance_breakdown,
@ -763,80 +692,3 @@ def get_competitors(
) )
return CompetitorsResponse(competitors=competitors, summary=summary) return CompetitorsResponse(competitors=competitors, summary=summary)
def get_competitors_parking(
db: Session,
cad_num: str,
request: CompetitorsRequest,
*,
top_n: int = _PARKING_TOP_N,
) -> CompetitorsParkingResponse:
"""parking_ratio top-N конкурентов через geom-match + НСПД premises (#96).
Ленивый путь (НЕ inline в analyze/forecast см. parcel.py CompetitorParking
docstring про p95). Для каждого из top-N конкурентов (get_competitors уже
сортирует по relevance_weight DESC):
1. resolve_cad_for_domrf(lat, lon) ближайшее cad_buildings-здание (PostGIS
KNN радиуса). Промах parking отсутствует, НЕ падаем.
2. get_building_premises_for_match(match) НСПД tab-group parking_ratio.
(objdoc_id из cad_buildings экономит НСПД search-запрос.)
Каждый конкурент изолирован try/except: сбой одного (геометрия/НСПД/WAF) не
рушит остальные и не валит эндпоинт (graceful, ТЗ §15). Конкуренты без
geom-match всё равно попадают в items с None-полями (стабильный контракт +
фронт видит «искали, не нашли»).
Raises:
ValueError: если центроид участка не найден (из get_competitors caller
возвращает 404).
"""
response = get_competitors(db=db, cad_num=cad_num, request=request)
top = response.competitors[:top_n]
items: list[CompetitorParking] = []
matched_count = 0
for competitor in top:
item = CompetitorParking(obj_id=competitor.obj_id)
try:
match = resolve_cad_for_domrf(db, lat=competitor.lat, lon=competitor.lng)
except Exception:
# resolve_cad_for_domrf уже глотает SQL-сбои → None; этот guard на
# случай неожиданного исключения — один конкурент без паркинга, не 500.
logger.exception(
"competitors_parking: geom-match raised for obj_id=%s", competitor.obj_id
)
match = None
if match is not None:
item = item.model_copy(
update={
"matched_cad_num": match.cad_num,
"match_distance_m": round(match.distance_m, 1),
}
)
try:
listing = get_building_premises_for_match(match)
except Exception:
# НСПД-клиент уже graceful (NspdLiteError/NspdBulkError → None);
# неожиданное — логируем и продолжаем без parking для этого ЖК.
logger.exception(
"competitors_parking: premises lookup raised for obj_id=%s cad_num=%s",
competitor.obj_id,
match.cad_num,
)
listing = None
if listing is not None:
item = item.model_copy(
update={
"parking_ratio": listing.parking_ratio,
"parking_count": listing.parking_count,
"flats_count": listing.flats_count,
}
)
if listing.parking_ratio is not None:
matched_count += 1
items.append(item)
return CompetitorsParkingResponse(items=items, matched_count=matched_count)

View file

@ -9,11 +9,6 @@ from __future__ import annotations
from typing import Literal, TypedDict from typing import Literal, TypedDict
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
network_kind_label,
)
# ── Zone matching ───────────────────────────────────────────────────────────── # ── Zone matching ─────────────────────────────────────────────────────────────
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5) # Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
@ -160,17 +155,9 @@ def compute_gate_verdict(
# cad_zouit fallback path: classify by type_zone keywords (#232). # cad_zouit fallback path: classify by type_zone keywords (#232).
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется. # subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower() type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
# #1070: классификация «сетевого обременения» (охранная зона инж.сети). # СЗЗ-маркер специфичнее широких blocker-подстрок ('газ'/'электр'),
# Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind); # поэтому проверяем warning-keywords ПЕРВЫМИ (#1341). СЗЗ газораспределительной
# если overlap из старого пути без флага — определяем здесь же. # станции/электроподстанции — ограничение, а не hard-blocker.
net_kind = overlap.get("network_kind")
if net_kind is None and overlap.get("is_network_zone") is None:
net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer"))
# Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная'
# специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной
# станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое
# обременение (#1070, выделенный код раньше широких подстрок), затем общие
# blocker-keywords, иначе — generic warning.
if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS): if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
warnings.append( warnings.append(
Warning( Warning(
@ -180,17 +167,6 @@ def compute_gate_verdict(
), ),
) )
) )
elif net_kind is not None:
kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind)
blockers.append(
Blocker(
code="ZOUIT_NETWORK_OBREMENENIE",
detail=(
f"Сетевое обременение ({kind_label}): "
f"{overlap.get('type_zone', '')} {overlap.get('name', '')}".strip()
),
)
)
elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS): elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
blockers.append( blockers.append(
Blocker( Blocker(

View file

@ -1,129 +0,0 @@
"""Классификация «сетевых обременений» из ЗОУИТ (#1070).
Pure-function классификатор: по тексту ``type_zone`` ЗОУИТ (cad_zouit / НСПД-дамп)
определяет, является ли зона *охранной зоной инженерной сети* и если да, к какому
виду сети относится (тепло/электро/газ/водоснабжение/связь/общая инженерная).
Контекст (#1070 ООО «Брик»): теплосеть 66:41:0000000:90310 проходит через пятно
застройки реальное обременение. Эпик #1067 (слой ИРД для analyze) НЕ содержал явного
среза «сетевые обременения». ЗОУИТ-данные cad_zouit уже текут в analyze
(``quarter_dump_lookup._get_cad_zouit_overlaps``, #232/#243) и классифицируются в
gate_verdict, но как ОБЩИЕ ЗОУИТ-overlaps без выделенного «сетевого» среза.
Этот модуль выделяет сетевые охранные зоны в отдельную типизированную категорию, чтобы
analyze/фронт могли показать «сетевое обременение (теплосеть)» как самостоятельный флаг.
VERIFY против PROD (cad_zouit, 2026-06-14): сетевые охранные зоны РЕАЛЬНО присутствуют
~1930 строк из 3485:
«Охранная зона инженерных коммуникаций» 1281
«Охранная зона тепловых сетей» 548
«Охранная зона объектов электроэнергетики » 69
«Охранная зона трубопроводов (газопроводов )» 23
«Охранная зона линий и сооружений связи» 9
Зачем pure-function (без БД): тестируемо без фикстур, переиспользуемо и в
``_get_cad_zouit_overlaps`` (теггинг overlap-строк), и в ``gate_verdict`` (выделенный
warning-код), без дублирования keyword-таблиц.
"""
from __future__ import annotations
from typing import Literal
# Виды сетевого обременения. 'general' — «инженерные коммуникации» без указания вида сети.
NetworkKind = Literal[
"thermal", # теплоснабжение
"electricity", # электросети / объекты электроэнергетики
"gas", # газопроводы (входит в «трубопроводы …»)
"water", # водоснабжение / водоотведение / канализация
"communication", # линии и сооружения связи
"pipeline", # трубопроводы (нефте-/нефтепродукто-/аммиако- — не газ, но сетевое)
"general", # «охранная зона инженерных коммуникаций» (вид сети не уточнён)
]
# Человекочитаемые подписи (RU) для UI/детали обременения.
NETWORK_KIND_LABELS: dict[str, str] = {
"thermal": "теплоснабжение",
"electricity": "электроснабжение",
"gas": "газоснабжение",
"water": "водоснабжение/водоотведение",
"communication": "связь",
"pipeline": "трубопровод",
"general": "инженерные коммуникации",
}
# Упорядоченные правила «подстрока type_zone → вид сети». Порядок ВАЖЕН: более
# специфичные виды проверяются раньше «general» (инженерные коммуникации).
# Все подстроки lowercase; матч по `substring in type_zone.lower()`.
# Сверено с distinct type_zone из cad_zouit PROD (2026-06-14).
_NETWORK_RULES: tuple[tuple[NetworkKind, tuple[str, ...]], ...] = (
("thermal", ("тепл",)), # «… тепловых сетей»
("electricity", ("электроэнерг", "электросет", "лэп", "линий электропередач")),
("gas", ("газопров", "газоснаб")),
("water", ("водопров", "водоснаб", "водоотвед", "канализ")),
("communication", ("линий и сооружений связи", "сооружений связи", "линий связи")),
# «Охранная зона трубопроводов (газопроводов, нефтепроводов …)» — если газ не
# сматчился выше, относим к pipeline (нефте-/нефтепродукто-/аммиако-проводы).
("pipeline", ("трубопров", "нефтепров", "нефтепродуктопров", "аммиакопров")),
# Самое общее — «охранная зона инженерных коммуникаций» (вид сети не уточнён).
("general", ("инженерных коммуникаций", "инженерной инфраструктур", "инженерных сет")),
)
# Зона должна быть именно ОХРАННОЙ (обременение), а не, например, «зона публичного
# сервитута» без сетевой привязки. Требуем маркер охранной/защитной зоны ИЛИ явный
# сетевой вид в тексте — иначе «инженерные коммуникации» в произвольной зоне дало бы
# ложные срабатывания. На практике cad_zouit использует «Охранная зона …».
_PROTECTION_MARKERS: tuple[str, ...] = ("охранная зона", "охранн")
def classify_network_zone(type_zone: str | None) -> NetworkKind | None:
"""Определить вид сетевого обременения по тексту ``type_zone`` ЗОУИТ.
Args:
type_zone: текст типа зоны (cad_zouit.type_zone / НСПД-дамп). None/пусто None.
Returns:
``NetworkKind`` если зона охранная зона инженерной сети; иначе ``None``
(зона не сетевая: СЗЗ, ЗРЗ, сервитут без сети, природные ЗОУИТ и т.п.).
"""
if not type_zone:
return None
tz = type_zone.lower()
is_protection = any(m in tz for m in _PROTECTION_MARKERS)
# ЗСО (зона санитарной охраны ИСТОЧНИКА водоснабжения) — это зона вокруг водозабора,
# НЕ охранная зона водопроводной СЕТИ → не сетевое обременение. Иначе water-needles
# («водоснаб») ложно отнесли бы ЗСО к сети и флипнули verdict на blocker.
is_sanitary_source = "санитарн" in tz
for kind, needles in _NETWORK_RULES:
if any(n in tz for n in needles):
# «general» (инженерные коммуникации) принимаем только при маркере охранной
# зоны — иначе слишком широкая ловля. Специфичные виды (тепло/газ/…) сами по
# себе достаточно сетевые, но в cad_zouit они тоже всегда «Охранная зона …».
if kind == "general" and not is_protection:
continue
# water-зона санитарной охраны источника — не сеть, пропускаем.
if kind == "water" and is_sanitary_source:
continue
return kind
return None
def network_kind_label(kind: NetworkKind | None) -> str | None:
"""RU-подпись вида сети для UI/детали (None → None)."""
if kind is None:
return None
return NETWORK_KIND_LABELS.get(kind, kind)
def is_network_obremenenie(type_zone: str | None) -> bool:
"""True если ЗОУИТ — сетевое обременение (любой вид сети)."""
return classify_network_zone(type_zone) is not None
__all__ = [
"NETWORK_KIND_LABELS",
"NetworkKind",
"classify_network_zone",
"is_network_obremenenie",
"network_kind_label",
]

View file

@ -30,10 +30,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.nspd_bulk import ObjectsListing from app.schemas.nspd_bulk import ObjectsListing
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
@ -45,123 +41,6 @@ logger = logging.getLogger(__name__)
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу. # docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
_THEMATIC_BUILDING = 5 _THEMATIC_BUILDING = 5
# #96 wiring: дефолтный радиус geom-match domrf↔cad_buildings (метры). Подобран по
# прод-замеру (1543 domrf latest-snapshot vs 38016 cad_buildings): nearest-footprint
# медиана 56м, within-50м покрывает 47%, within-100м 65%. 50м — консервативный
# дефолт: domrf lat/lon = центроид ЖК-ПЛОЩАДКИ (несколько домов + дворы), поэтому
# смещён от ближайшего building-footprint; шире радиус ловит больше, но рискует
# приписать СОСЕДНЕЕ здание (precision важнее recall — parking_ratio чужого дома
# хуже отсутствия). ST_Contains (точка внутри footprint) всегда матчит независимо
# от радиуса. Caller может расширить через max_dist_m.
_DEFAULT_MATCH_RADIUS_M: float = 50.0
@dataclass(frozen=True, slots=True)
class BuildingMatch:
"""Результат geom-match domrf-объекта на здание ЕГРН (#96 wiring).
cad_num кадастровый номер сматченного здания (ключ к premises-lookup).
objdoc_id НСПД objdoc этого здания ИЗ cad_buildings (может быть None для
части строк). Если он есть get_building_premises может ПРОПУСТИТЬ НСПД
search_by_cad и сразу дёрнуть tab-group (1 НСПД-запрос вместо 2).
distance_m расстояние от domrf-центроида до footprint (0.0 = точка внутри
площадного footprint).
"""
cad_num: str
objdoc_id: int | None
distance_m: float
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
_RESOLVE_CAD_SQL = text("""
SELECT
b.cad_num,
b.objdoc_id,
ST_Distance(
b.geom::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:lon AS double precision),
CAST(:lat AS double precision)
),
4326
)::geography
) AS distance_m
FROM cad_buildings b
WHERE b.geom IS NOT NULL
ORDER BY b.geom <-> ST_SetSRID(
ST_MakePoint(
CAST(:lon AS double precision),
CAST(:lat AS double precision)
),
4326
)
LIMIT 1
""")
def resolve_cad_for_domrf(
db: Session,
*,
lat: float,
lon: float,
max_dist_m: float = _DEFAULT_MATCH_RADIUS_M,
) -> BuildingMatch | None:
"""Резолв domrf-объекта (lat/lon центроид) → здание cad_buildings (#96 wiring).
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
footprint max_dist_m. Точка внутри площадного footprint distance 0.
Returns:
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
радиусе, иначе None. None трактуется как «здание не сопоставлено»
caller продолжает без parking_ratio (graceful, не выдумываем).
Note:
objdoc_id из cad_buildings возвращается для short-circuit НСПД search в
get_building_premises_for_match может быть None (тогда фоллбэк на
cad_num search).
"""
try:
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
except Exception:
# SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
# (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).
logger.exception("premises_lookup: resolve_cad_for_domrf failed lat=%s lon=%s", lat, lon)
return None
if row is None:
return None
distance_m = float(row["distance_m"])
if distance_m > max_dist_m:
logger.info(
"premises_lookup: nearest building %.1fm > %.1fm radius for lat=%s lon=%s — no match",
distance_m,
max_dist_m,
lat,
lon,
)
return None
raw_objdoc = row["objdoc_id"]
objdoc_id = int(raw_objdoc) if raw_objdoc is not None else None
return BuildingMatch(cad_num=row["cad_num"], objdoc_id=objdoc_id, distance_m=distance_m)
def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None: def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None:
"""objdoc_id здания по cad_num через NSPD geoportal search. """objdoc_id здания по cad_num через NSPD geoportal search.
@ -248,38 +127,4 @@ def get_building_premises(cad_num: str) -> ObjectsListing | None:
return listing return listing
def get_building_premises_for_match(match: BuildingMatch) -> ObjectsListing | None: __all__ = ["get_building_premises"]
"""Помещения/parking_ratio для уже-сматченного здания (#96 wiring).
Тонкая обёртка над get_building_premises: если geom-match дал objdoc_id ИЗ
cad_buildings, ПРОПУСКАЕМ НСПД search_by_cad (1 НСПД-запрос вместо 2 search
тратит rate-limit и иногда промахивается на не-канон cad_num). Иначе фоллбэк
на cad_num-резолв через get_building_premises.
Graceful: НСПД недоступен / objdoc не отдал помещения None (как у
get_building_premises). Изолирует async в asyncio.run() вызывается из
sync-контекста (FastAPI threadpool handler / Celery task).
"""
if match.objdoc_id is not None:
listing = asyncio.run(_fetch_listing(match.objdoc_id))
if listing is not None:
logger.info(
"premises_lookup: match cad_num=%s objdoc_id=%d (cad_buildings) "
"flats=%d parking=%d ratio=%s",
match.cad_num,
match.objdoc_id,
listing.flats_count,
listing.parking_count,
listing.parking_ratio,
)
return listing
# objdoc неизвестен в cad_buildings — полный путь по cad_num (2 НСПД-запроса).
return get_building_premises(match.cad_num)
__all__ = [
"BuildingMatch",
"get_building_premises",
"get_building_premises_for_match",
"resolve_cad_for_domrf",
]

View file

@ -23,11 +23,6 @@ from sqlalchemy import text
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
network_kind_label,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Порог свежести дампа — 180 дней. Совпадает с beat-расписанием (раз в квартал # Порог свежести дампа — 180 дней. Совпадает с beat-расписанием (раз в квартал
@ -410,10 +405,6 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for r in rows: for r in rows:
type_zone: str = r[0] or "" type_zone: str = r[0] or ""
# #1070: выделяем «сетевые обременения» — охранные зоны инж.сетей
# (тепло/электро/газ/вода/связь/трубопровод/общие инж.коммуникации).
# Pure-function классификация по type_zone; None если зона не сетевая.
net_kind = classify_network_zone(type_zone)
result.append( result.append(
{ {
"group_key": "cad_zouit", "group_key": "cad_zouit",
@ -428,12 +419,6 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
}, },
"source": "cad_zouit", "source": "cad_zouit",
"type_zone": type_zone, "type_zone": type_zone,
# #1070 «сетевые обременения»: флаг + вид сети + RU-подпись.
# is_network_zone=True → охранная зона инж.сети (обременение);
# network_kind ∈ NetworkKind | None; network_kind_label — RU.
"is_network_zone": net_kind is not None,
"network_kind": net_kind,
"network_kind_label": network_kind_label(net_kind),
# #255: geom для ZouitLayer на Leaflet (frontend JSON.parse → <GeoJSON>). # #255: geom для ZouitLayer на Leaflet (frontend JSON.parse → <GeoJSON>).
# geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry). # geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry).
"geom_geojson": r[5], "geom_geojson": r[5],
@ -984,8 +969,8 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
harvest_error: str | None = dump_row[2] harvest_error: str | None = dump_row[2]
is_stale = False is_stale = False
if fetched_at is not None and getattr(fetched_at, "tzinfo", "missing") != "missing": if fetched_at is not None and getattr(fetched_at, "tzinfo", "missing") != "missing":
fetched_at_aware = ( fetched_at_aware = fetched_at if fetched_at.tzinfo is not None else fetched_at.replace(
fetched_at if fetched_at.tzinfo is not None else fetched_at.replace(tzinfo=UTC) tzinfo=UTC
) )
is_stale = (datetime.now(UTC) - fetched_at_aware) > timedelta(days=_DUMP_MAX_AGE_DAYS) is_stale = (datetime.now(UTC) - fetched_at_aware) > timedelta(days=_DUMP_MAX_AGE_DAYS)
if harvest_error is not None or is_stale: if harvest_error is not None or is_stale:

View file

@ -1,297 +0,0 @@
"""POI saturation per capita — обеспеченность района социнфраструктурой (#42).
Идея (Issue #42 / Limit_POI_Factor_Tiny): «10 школ vs 1 школа на 5000 детей» в
straight-count POI-scoring дают одинаковый сигнал это неверно. Насыщенность
*на душу целевой когорты* важнее абсолютного числа объектов.
Что считаем для района (одного из 8 админ-районов ЕКБ):
фактическое число объектов категории (школа / детсад / поликлиника),
привязанных к району spatial-join'ом `ST_Contains(district.geom, poi.geom)`
по таблице `osm_poi_ekb` (нормализованные категории);
население целевой когорты = population × age_share (детсад 0-7,
школа 7-18, поликлиника всё население);
норматив СП 42.13330 (мест/учреждений на 1000 чел. когорты);
provision_ratio = факт / норматив severity (дефицит / норма / профицит).
ВАЖНО про данные:
population/area района ФАКТ (Росстат 01.01.2025, миграция 154).
Возрастные когорты ОЦЕНКА (региональные доли, age_cohorts_estimated=TRUE).
Поэтому в ответе всегда `cohort_estimated`-флаг: число «детей школьного
возраста» производная оценка, НЕ район-факт переписи.
Влияние на POI-score (acceptance #42): saturation_multiplier(category, district):
дефицитный район ×1.2 (incentive строить school-adjacent), перенасыщенный
×0.5. Чистая функция drop-in для poi_score без изменения его сигнатур.
psycopg v3: все binds через CAST(:name AS type), а не postfix double-colon. Read-only SELECT.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ── Нормативы СП 42.13330.2016 (Свод правил «Градостроительство») ──────────────
# Источник значений — issue #42 + vault A3. Знаменатель когорты различается:
# - школа/детсад нормируются на 1000 ДЕТЕЙ соответствующего возраста,
# - поликлиника — на 10000 ВСЕХ жителей.
# capacity_per_1000 — мест (школа/сад) либо учреждений (поликлиника) на 1000 чел.
# cohort — какую возрастную долю населения брать как знаменатель.
@dataclass(frozen=True)
class _Norm:
"""Норматив обеспеченности одной POI-категории по СП 42.13330."""
cohort: str # 'preschool' | 'school' | 'all'
capacity_per_1000: float # мест/учреждений на 1000 чел. когорты
unit: str # человекочитаемая единица норматива
# poi_loader-категории (osm_poi_ekb.category) → норматив.
_NORMS: dict[str, _Norm] = {
# Школа: 92 места на 1000 детей школьного возраста (7-18).
"school": _Norm(cohort="school", capacity_per_1000=92.0, unit="мест/1000 школьников"),
# Детсад: 35 мест на 1000 детей дошкольного возраста (0-7).
"kindergarten": _Norm(
cohort="preschool", capacity_per_1000=35.0, unit="мест/1000 дошкольников"
),
# Поликлиника: 1 учреждение на 10000 жителей = 0.1 на 1000 всех жителей.
"hospital": _Norm(cohort="all", capacity_per_1000=0.1, unit="учр./1000 жителей"),
}
# Сколько «мест» условно даёт один объект OSM. У OSM нет вместимости (только точка),
# поэтому считаем число ОБЪЕКТОВ и переводим в условные «места» множителем-капасити.
# Для поликлиник 1 объект = 1 учреждение. Для школ/садов — усреднённая вместимость
# типового объекта (оценка, vault A3); меняем здесь, если появится OSM `capacity`.
_OBJECT_CAPACITY: dict[str, float] = {
"school": 600.0, # мест в типовой школе (оценка)
"kindergarten": 200.0, # мест в типовом детсаду (оценка)
"hospital": 1.0, # 1 объект = 1 учреждение
}
# ── Severity-пороги по provision_ratio (факт/норматив) ───────────────────────
# < 0.5 — острый дефицит | 0.50.85 — дефицит | 0.851.3 — норма | > 1.3 — профицит.
_SEVERITY_ACUTE_DEFICIT = 0.5
_SEVERITY_DEFICIT = 0.85
_SEVERITY_SURPLUS = 1.3
# ── POI-score множитель (acceptance #42): дефицит → boost, профицит → штраф ───
_MULT_DEFICIT = 1.2 # дефицитный район: incentive строить такую соцобъект-adjacent
_MULT_SURPLUS = 0.5 # перенасыщенный район: сигнал «ещё одна школа мало что даёт»
_MULT_NEUTRAL = 1.0
def classify_severity(provision_ratio: float | None) -> str | None:
"""provision_ratio (факт/норматив) → ярлык severity. PURE.
None None (нет данных: нет когорты / нет норматива НЕ «острый дефицит»).
"""
if provision_ratio is None:
return None
if provision_ratio < _SEVERITY_ACUTE_DEFICIT:
return "острый дефицит"
if provision_ratio < _SEVERITY_DEFICIT:
return "дефицит"
if provision_ratio <= _SEVERITY_SURPLUS:
return "норма"
return "профицит"
def saturation_multiplier(category: str, provision_ratio: float | None) -> float:
"""Множитель для POI-score по обеспеченности района категорией. PURE.
Acceptance #42: дефицитный район → ×1.2 (стимул строить школо-adjacent),
перенасыщенный ×0.5. Норма / нет данных / категория без норматива ×1.0
(нейтрально: не наказываем за отсутствие сигнала).
"""
if category not in _NORMS or provision_ratio is None:
return _MULT_NEUTRAL
if provision_ratio < _SEVERITY_DEFICIT:
return _MULT_DEFICIT
if provision_ratio > _SEVERITY_SURPLUS:
return _MULT_SURPLUS
return _MULT_NEUTRAL
def compute_provision_ratio(
*,
poi_count: int,
population: int,
age_share: float | None,
category: str,
) -> float | None:
"""Коэффициент обеспеченности района категорией = факт / норматив. PURE.
факт_мест_на_1000 = poi_count × object_capacity / (cohort_pop / 1000)
норматив_мест_на_1000 = _NORMS[category].capacity_per_1000
provision_ratio = факт / норматив
cohort_pop = population × age_share (для 'all' age_share=1.0).
None если: неизвестная категория / нет населения / нулевая когорта.
"""
norm = _NORMS.get(category)
if norm is None or population <= 0:
return None
share = 1.0 if norm.cohort == "all" else age_share
if share is None or share <= 0:
return None
cohort_pop = population * share
if cohort_pop <= 0:
return None
object_capacity = _OBJECT_CAPACITY.get(category, 1.0)
actual_per_1000 = (poi_count * object_capacity) / (cohort_pop / 1000.0)
if norm.capacity_per_1000 <= 0:
return None
return actual_per_1000 / norm.capacity_per_1000
# ──────────────────────────────────────────────────────────────────────────────
# DB-зависимый слой: spatial POI-count по району + join демографии.
# ──────────────────────────────────────────────────────────────────────────────
# POI-count категорий в полигоне района + демография района. ekb_districts.geom —
# полигон (миграция 56/74), osm_poi_ekb.geom — Point. ST_Contains(poly, point).
# LEFT JOIN osm_poi_ekb, чтобы район без POI давал 0, а не отсутствие строки.
_DISTRICT_SATURATION_SQL = text(
"""
SELECT
d.population,
d.area_km2,
d.age_share_preschool,
d.age_share_school,
d.age_share_elderly,
d.age_cohorts_estimated,
d.source,
d.as_of_date,
COUNT(*) FILTER (WHERE p.category = 'school') AS n_school,
COUNT(*) FILTER (WHERE p.category = 'kindergarten') AS n_kindergarten,
COUNT(*) FILTER (WHERE p.category = 'hospital') AS n_hospital
FROM ekb_district_demographics d
JOIN ekb_districts g
ON g.district_name = d.district_name
AND g.geom IS NOT NULL
LEFT JOIN osm_poi_ekb p
ON p.category IN ('school', 'kindergarten', 'hospital')
AND ST_Contains(g.geom, p.geom)
WHERE d.district_name = CAST(:dn AS text)
GROUP BY
d.population, d.area_km2, d.age_share_preschool, d.age_share_school,
d.age_share_elderly, d.age_cohorts_estimated, d.source, d.as_of_date
"""
)
# Какую возрастную долю брать для категории (для отчёта по когорте).
_CATEGORY_SHARE_COLUMN: dict[str, str] = {
"school": "age_share_school",
"kindergarten": "age_share_preschool",
"hospital": "age_share_elderly", # информативно; норматив hospital — на всё население
}
_CATEGORY_COUNT_COLUMN: dict[str, str] = {
"school": "n_school",
"kindergarten": "n_kindergarten",
"hospital": "n_hospital",
}
def _round_or_none(value: float | None, digits: int = 3) -> float | None:
return round(value, digits) if value is not None else None
def compute_district_saturation(db: Session, district_name: str) -> dict[str, Any] | None:
"""Saturation per capita района — блок для analyze (`infra.saturation`).
Возвращает dict (JSON-готовый) либо None, если у района нет демографии
(нет строки в ekb_district_demographics / нет geom-полигона).
Структура:
{
"district": "Чкаловский",
"population": 286277,
"as_of_date": "2025-01-01",
"source": "...",
"cohorts_estimated": true, # возрастные доли — оценка, НЕ факт
"categories": {
"school": {
"poi_count": 12,
"cohort_population": 32922, # оценка (population × age_share)
"cohort_estimated": true,
"provision_ratio": 0.68, # факт/норматив
"severity": "дефицит",
"score_multiplier": 1.2, # влияние на POI-score (#42)
"norm": {"capacity_per_1000": 92.0, "unit": "мест/1000 школьников"}
}, ...
}
}
Graceful: на ошибке БД логирует и возвращает None (не роняет analyze).
psycopg v3: bind через CAST(:dn AS text). Read-only.
"""
try:
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
except Exception:
logger.exception("saturation: query failed (district=%s)", district_name)
return None
if row is None or row["population"] is None:
return None
population = int(row["population"])
cohorts_estimated = bool(row["age_cohorts_estimated"])
categories: dict[str, Any] = {}
for category, norm in _NORMS.items():
poi_count = int(row[_CATEGORY_COUNT_COLUMN[category]] or 0)
share_col = _CATEGORY_SHARE_COLUMN[category]
raw_share = row[share_col]
age_share = float(raw_share) if raw_share is not None else None
provision = compute_provision_ratio(
poi_count=poi_count,
population=population,
age_share=age_share,
category=category,
)
# Когорта-знаменатель для отчёта: для hospital норматив на всё население.
if norm.cohort == "all":
cohort_pop: float | None = float(population)
cohort_is_estimate = False # всё население — факт
elif age_share is not None:
cohort_pop = population * age_share
cohort_is_estimate = cohorts_estimated
else:
cohort_pop = None
cohort_is_estimate = cohorts_estimated
categories[category] = {
"poi_count": poi_count,
"cohort_population": int(cohort_pop) if cohort_pop is not None else None,
"cohort_estimated": cohort_is_estimate,
"provision_ratio": _round_or_none(provision),
"severity": classify_severity(provision),
"score_multiplier": saturation_multiplier(category, provision),
"norm": {
"capacity_per_1000": norm.capacity_per_1000,
"unit": norm.unit,
},
}
as_of = row["as_of_date"]
return {
"district": district_name,
"population": population,
"as_of_date": as_of.isoformat() if as_of is not None else None,
"source": row["source"],
"cohorts_estimated": cohorts_estimated,
"categories": categories,
}

View file

@ -5,18 +5,13 @@ Mock-based — не требуют живой БД.
- поля site_status и ready_dt присутствуют в каждом элементе competitors - поля site_status и ready_dt присутствуют в каждом элементе competitors
- первые позиции занимают строящиеся ЖК (site_status='Строящиеся') - первые позиции занимают строящиеся ЖК (site_status='Строящиеся')
- сданные ЖК идут после строящихся - сданные ЖК идут после строящихся
- obj_pricing CTE ограничен ближними obj_id (pushdown perf-guard, #70/#307)
""" """
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import inspect
import re
from unittest.mock import MagicMock from unittest.mock import MagicMock
from app.api.v1 import parcels as parcels_module
# ── Вспомогательные фабрики ─────────────────────────────────────────────────── # ── Вспомогательные фабрики ───────────────────────────────────────────────────
@ -125,61 +120,3 @@ class TestCompetitorsSortOrder:
# Старая логика: первым шёл ЖК с flat_count=800 (Сданные) # Старая логика: первым шёл ЖК с flat_count=800 (Сданные)
assert first_old["flat_count"] == 800 assert first_old["flat_count"] == 800
assert first_old["site_status"] == "Сданные" assert first_old["site_status"] == "Сданные"
class TestObjPricingPushdown:
"""obj_pricing CTE считается ТОЛЬКО для ближних obj_id (perf-guard, #70/#307).
Старая версия (#1332 OBJ-3) агрегировала objective_lots (2.1 ГБ) по ВСЕМ
~286 маппингам и выбрасывала далёкие медианы в LEFT JOIN. Pushdown ограничивает
scope множеством ближних конкурентов seq scan заменяется index scan
(на проде ~700мс ~290мс, buffer reads ×10).
Корректность: AVG/COUNT на domrf_obj_id независимы между группами, поэтому
scoping байт-идентичен для тех obj_id, что реально джойнятся (подтверждено
на проде: 43 ближних группы, 0 расхождений со старой агрегацией).
Тест защищает от тихого реверта на полнотабличную агрегацию.
"""
@staticmethod
def _competitor_sql() -> str:
"""Нормализованный (схлопнутые пробелы) текст competitor-SQL из analyze_parcel."""
src = inspect.getsource(parcels_module.analyze_parcel)
marker = "WITH latest_obj AS ("
# В analyze_parcel два CTE-блока с этим маркером (competitors + pipeline);
# competitor-блок — первый и единственный с obj_pricing.
start = src.index(marker)
block = src[start : src.index('"""', start)]
return re.sub(r"\s+", " ", block)
def test_obj_pricing_scoped_to_nearby(self) -> None:
"""obj_pricing фильтрует domrf_obj_id по ближним (pushdown), не агрегирует всё."""
sql = self._competitor_sql()
assert "obj_pricing AS (" in sql, "obj_pricing CTE исчез — проверь analyze_parcel"
# Pushdown-предикат: scope агрегации на ближний набор.
assert "cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)" in sql, (
"obj_pricing должен ограничивать domrf_obj_id ближними obj_id (pushdown), "
"иначе objective_lots (2.1ГБ) сканируется целиком — perf-регрессия #70/#307"
)
def test_final_select_reuses_nearby_cte(self) -> None:
"""Финальный SELECT и obj_pricing берут один и тот же nearby_obj — scope == consumed."""
sql = self._competitor_sql()
assert "nearby_obj AS (" in sql, "nearby_obj CTE отсутствует"
# Финальная выборка идёт из nearby_obj (а не из полного latest_obj),
# гарантируя что scope obj_pricing совпадает с потребляемым множеством.
assert "FROM nearby_obj o" in sql, (
"финальный SELECT должен читать из nearby_obj, чтобы scope цен "
"совпадал с выводимыми конкурентами"
)
def test_aggregation_unchanged(self) -> None:
"""Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope."""
sql = self._competitor_sql()
# Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id.
assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace(
"\n", " "
) or "lots_with_price" in sql
assert "GROUP BY cm.domrf_obj_id" in sql

View file

@ -53,13 +53,6 @@ def _price_row(obj_id: int, price: float) -> MagicMock:
return r return r
def _obj_price_row(obj_id: int, price: float) -> MagicMock:
"""Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307)."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k]
return r
# ── Построение mock DB ──────────────────────────────────────────────────────── # ── Построение mock DB ────────────────────────────────────────────────────────
@ -67,17 +60,13 @@ def _make_db(
coord: MagicMock | None = None, coord: MagicMock | None = None,
obj_rows: list[MagicMock] | None = None, obj_rows: list[MagicMock] | None = None,
price_rows: list[MagicMock] | None = None, price_rows: list[MagicMock] | None = None,
obj_price_rows: list[MagicMock] | None = None,
) -> MagicMock: ) -> MagicMock:
"""Сконструировать mock Session. """Сконструировать mock Session.
Порядок вызовов execute: Порядок вызовов execute:
1. centroid query coord 1. centroid query coord
2. competitors query obj_rows 2. competitors query obj_rows
3. domrf avg_price query price_rows 3. avg_price query price_rows
4. objective price fallback (OBJ-3 #307) → obj_price_rows
(вызывается ТОЛЬКО если у части obj_id нет domrf-цены почти всегда,
т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
""" """
db = MagicMock() db = MagicMock()
@ -86,7 +75,6 @@ def _make_db(
(coord, True), (coord, True),
(obj_rows or [], False), (obj_rows or [], False),
(price_rows or [], False), (price_rows or [], False),
(obj_price_rows or [], False),
]: ]:
result = MagicMock() result = MagicMock()
if is_first: if is_first:
@ -402,97 +390,6 @@ def test_competitors_avg_price_populated() -> None:
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), ( assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
) )
# OBJ-3 #307: domrf-hit → price_source='domrf'.
assert comp["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
# ── OBJ-3 (#307) additive fallback из objective_lots ────────────────────────────
def test_competitors_objective_price_fallback_fills_gap() -> None:
"""domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307).
Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк,
поэтому domrf даёт None почти всем. objective_lots (через
objective_complex_mapping) покрывает ~80% этот fallback восстанавливает цену.
"""
rows = [_obj_row(obj_id=1)]
# domrf price пуст → fallback на objective median.
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
db = _make_db(
coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(136_000.0)
assert comp["price_source"] == "objective"
finally:
app.dependency_overrides.clear()
def test_competitors_domrf_price_wins_over_objective() -> None:
"""domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод.
Additive-контракт OBJ-3: где domrf есть не трогаем; objective только пробелы.
"""
rows = [_obj_row(obj_id=1)]
price_rows = [_price_row(obj_id=1, price=150_000.0)]
# objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует.
obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)]
db = _make_db(
coord=_coord_row(),
obj_rows=rows,
price_rows=price_rows,
obj_price_rows=obj_price_rows,
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0)
assert comp["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
def test_competitors_no_price_anywhere_is_none() -> None:
"""Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful)."""
rows = [_obj_row(obj_id=1)]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] is None
assert comp["price_source"] is None
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()

View file

@ -1,286 +0,0 @@
"""Тесты geom-match domrf↔cad_buildings + wire parking_ratio (#96 wiring).
Без живой БД и без сети: мокаем db.execute (PostGIS KNN-строка), get_competitors,
resolve_cad_for_domrf и get_building_premises_for_match. Проверяем:
resolve_cad_for_domrf точка внутри footprint cad_num; вне радиуса None;
нет зданий None; objdoc_id из cad_buildings прокидывается.
get_competitors_parking top-N конкурентов получают parking_ratio; промах
geom-match / None-premises поля None (graceful, не падаем), matched_count.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.schemas.nspd_bulk import ObjectsListing
from app.schemas.parcel import (
Competitor,
CompetitorsRequest,
CompetitorsResponse,
CompetitorsSummary,
)
from app.services.site_finder import competitors as competitors_mod
from app.services.site_finder import premises_lookup as pl
from app.services.site_finder.premises_lookup import BuildingMatch, resolve_cad_for_domrf
# ── resolve_cad_for_domrf (geom-match) ─────────────────────────────────────────
def _db_returning(row: dict[str, Any] | None) -> MagicMock:
"""Мок Session.execute(...).mappings().first() → row (или None)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = row
return db
def test_resolve_cad_inside_footprint_returns_cad() -> None:
"""Точка внутри footprint (distance 0) → BuildingMatch с cad_num + objdoc_id."""
db = _db_returning({"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 0.0})
match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6)
assert match is not None
assert match.cad_num == "66:41:0106036:183"
assert match.objdoc_id == 40995027
assert match.distance_m == 0.0
def test_resolve_cad_within_radius_returns_cad() -> None:
"""Ближайшее здание в пределах радиуса (30м ≤ 50м) → матч."""
db = _db_returning({"cad_num": "66:41:0106036:9", "objdoc_id": None, "distance_m": 30.0})
match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6)
assert match is not None
assert match.cad_num == "66:41:0106036:9"
# objdoc_id NULL в cad_buildings → None (фоллбэк на cad_num search в lookup).
assert match.objdoc_id is None
assert match.distance_m == 30.0
def test_resolve_cad_beyond_radius_returns_none() -> None:
"""Ближайшее здание дальше радиуса (80м > 50м) → None (не приписываем чужое)."""
db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0})
assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None
def test_resolve_cad_custom_radius_accepts_far_match() -> None:
"""max_dist_m=100 принимает 80м-матч, который дефолтные 50м отвергли бы."""
db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0})
match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6, max_dist_m=100.0)
assert match is not None
assert match.cad_num == "66:41:0106036:1"
def test_resolve_cad_no_buildings_returns_none() -> None:
"""Нет зданий с geom (пустой результат) → None."""
db = _db_returning(None)
assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None
def test_resolve_cad_sql_error_returns_none() -> None:
"""SQL/PostGIS-сбой → None (graceful, lazy-endpoint не падает)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("postgis boom")
assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None
# ── get_competitors_parking (wire) ──────────────────────────────────────────────
def _competitor(obj_id: int, lat: float = 56.84, lng: float = 60.6) -> Competitor:
"""Минимальный Competitor для wire-теста (только нужные wire поля заполнены)."""
return Competitor(
obj_id=obj_id,
comm_name=f"ЖК-{obj_id}",
dev_name=None,
obj_class="комфорт",
distance_m=100.0,
lat=lat,
lng=lng,
stage="Строящиеся",
flats_total=200,
flats_sold=None,
sold_pct=None,
velocity_per_month=5.0,
avg_price_per_m2=None,
is_active=True,
)
def _competitors_response(*obj_ids: int) -> CompetitorsResponse:
comps = [_competitor(oid) for oid in obj_ids]
return CompetitorsResponse(
competitors=comps,
summary=CompetitorsSummary(
total_competitors=len(comps),
active_count=len(comps),
weighted_avg_velocity=5.0,
radius_km=1.0,
time_window="last_quarter",
),
)
def test_parking_wire_happy_path(monkeypatch: Any) -> None:
"""top-конкурент сматчился на здание → получает parking_ratio из НСПД."""
monkeypatch.setattr(
competitors_mod, "get_competitors", lambda **_: _competitors_response(101)
)
monkeypatch.setattr(
competitors_mod,
"resolve_cad_for_domrf",
lambda *_a, **_k: BuildingMatch("66:41:0106036:183", 40995027, 12.3),
)
listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100)
monkeypatch.setattr(
competitors_mod, "get_building_premises_for_match", lambda _m: listing
)
out = competitors_mod.get_competitors_parking(
MagicMock(), "66:41:0303001:1", CompetitorsRequest()
)
assert out.matched_count == 1
assert len(out.items) == 1
item = out.items[0]
assert item.obj_id == 101
assert item.matched_cad_num == "66:41:0106036:183"
assert item.match_distance_m == 12.3
assert item.parking_ratio == 0.5
assert item.parking_count == 100
assert item.flats_count == 200
def test_parking_wire_limits_to_top_n(monkeypatch: Any) -> None:
"""Из 5 конкурентов обогащаются только первые top_n (строгий лимит)."""
monkeypatch.setattr(
competitors_mod,
"get_competitors",
lambda **_: _competitors_response(1, 2, 3, 4, 5),
)
calls: list[float] = []
def _resolve(_db: Any, *, lat: float, lon: float) -> BuildingMatch:
calls.append(lat)
return BuildingMatch("66:41:0106036:1", 1, 0.0)
monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", _resolve)
monkeypatch.setattr(
competitors_mod,
"get_building_premises_for_match",
lambda _m: ObjectsListing(objdoc_id=1, flats_count=10, parking_count=5),
)
out = competitors_mod.get_competitors_parking(
MagicMock(), "66:41:0303001:1", CompetitorsRequest(), top_n=2
)
assert len(out.items) == 2
assert len(calls) == 2 # geom-match вызван ровно top_n раз — не на всех 5
assert {i.obj_id for i in out.items} == {1, 2}
def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None:
"""geom-match промахнулся → конкурент в items с None-полями, matched_count=0."""
monkeypatch.setattr(
competitors_mod, "get_competitors", lambda **_: _competitors_response(7)
)
monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: None)
premises = MagicMock()
monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", premises)
out = competitors_mod.get_competitors_parking(
MagicMock(), "66:41:0303001:1", CompetitorsRequest()
)
assert out.matched_count == 0
assert out.items[0].obj_id == 7
assert out.items[0].matched_cad_num is None
assert out.items[0].parking_ratio is None
premises.assert_not_called() # без матча НСПД не дёргаем
def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None:
"""Здание сматчилось, но НСПД premises=None → cad есть, parking_ratio None."""
monkeypatch.setattr(
competitors_mod, "get_competitors", lambda **_: _competitors_response(9)
)
monkeypatch.setattr(
competitors_mod,
"resolve_cad_for_domrf",
lambda *_a, **_k: BuildingMatch("66:41:0106036:5", None, 40.0),
)
monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", lambda _m: None)
out = competitors_mod.get_competitors_parking(
MagicMock(), "66:41:0303001:1", CompetitorsRequest()
)
assert out.matched_count == 0
assert out.items[0].matched_cad_num == "66:41:0106036:5"
assert out.items[0].match_distance_m == 40.0
assert out.items[0].parking_ratio is None
def test_parking_wire_premises_exception_graceful(monkeypatch: Any) -> None:
"""Неожиданное исключение в premises-lookup → конкурент без паркинга, не 500."""
monkeypatch.setattr(
competitors_mod, "get_competitors", lambda **_: _competitors_response(11)
)
monkeypatch.setattr(
competitors_mod,
"resolve_cad_for_domrf",
lambda *_a, **_k: BuildingMatch("66:41:0106036:5", 1, 0.0),
)
def _boom(_m: Any) -> Any:
raise RuntimeError("nspd exploded")
monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", _boom)
out = competitors_mod.get_competitors_parking(
MagicMock(), "66:41:0303001:1", CompetitorsRequest()
)
assert out.matched_count == 0
assert out.items[0].matched_cad_num == "66:41:0106036:5"
assert out.items[0].parking_ratio is None
# ── get_building_premises_for_match (short-circuit objdoc) ──────────────────────
def test_premises_for_match_uses_objdoc_skips_search(monkeypatch: Any) -> None:
"""objdoc_id из cad_buildings → пропускаем НСПД search, сразу tab-group."""
listing = ObjectsListing(objdoc_id=555, flats_count=50, parking_count=60)
monkeypatch.setattr(pl.asyncio, "run", lambda coro: (coro.close(), listing)[1])
# search_by_cad НЕ должен вызываться при known objdoc_id.
full_path = MagicMock()
monkeypatch.setattr(pl, "get_building_premises", full_path)
out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", 555, 0.0))
assert out is listing
full_path.assert_not_called()
def test_premises_for_match_no_objdoc_falls_back(monkeypatch: Any) -> None:
"""objdoc_id None → фоллбэк на get_building_premises(cad_num) (2 НСПД-запроса)."""
listing = ObjectsListing(objdoc_id=1, flats_count=10, parking_count=3)
fallback = MagicMock(return_value=listing)
monkeypatch.setattr(pl, "get_building_premises", fallback)
out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", None, 0.0))
assert out is listing
fallback.assert_called_once_with("66:41:0106036:5")

View file

@ -124,17 +124,12 @@ def test_pzz_unknown_warning_when_no_zoning():
def test_cad_zouit_ohranaya_zona_truboprovodov_blocks(): def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
"""cad_zouit 'охранная зона трубопроводов' → сетевое обременение (газ) → Нельзя (#1070). """cad_zouit overlap с 'охранная зона трубопроводов' → ZOUIT_CAD_BLOCKER → Нельзя."""
После #1070 трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE
(более специфичный код, чем прежний ZOUIT_CAD_BLOCKER) поведение «блокирует МКД»
сохранено.
"""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
overlaps = [ overlaps = [
{ {
"source": "cad_zouit", "source": "cad_zouit",
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)", "type_zone": "Охранная зона трубопроводов",
"layer": "Охранная зона трубопроводов", "layer": "Охранная зона трубопроводов",
"name": "Газопровод высокого давления", "name": "Газопровод высокого давления",
} }
@ -142,11 +137,11 @@ def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False assert verdict["can_build_mkd"] is False
assert verdict["verdict_label"] == "Нельзя" assert verdict["verdict_label"] == "Нельзя"
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"]) assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
def test_cad_zouit_elektr_blocks(): def test_cad_zouit_elektr_blocks():
"""cad_zouit электросетевая охранная зона → сетевое обременение (электро) → blocker (#1070).""" """cad_zouit overlap с 'электр' substring в type_zone → blocker."""
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"} nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
overlaps = [ overlaps = [
{ {
@ -158,7 +153,7 @@ def test_cad_zouit_elektr_blocks():
] ]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False assert verdict["can_build_mkd"] is False
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"]) assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
def test_cad_zouit_szz_is_warning_not_blocker(): def test_cad_zouit_szz_is_warning_not_blocker():

View file

@ -1,141 +0,0 @@
"""Tests for network-obremenenie классификатор (#1070) — pure function, no DB.
type_zone-строки взяты из реальных distinct-значений cad_zouit (PROD, 2026-06-14).
"""
from app.services.site_finder.gate_verdict import compute_gate_verdict
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
is_network_obremenenie,
network_kind_label,
)
# ── classify_network_zone: реальные type_zone из cad_zouit ────────────────────
def test_thermal_zone():
assert classify_network_zone("Охранная зона тепловых сетей") == "thermal"
def test_electricity_zone():
tz = (
"Охранная зона объектов электроэнергетики (объектов электросетевого хозяйства "
"и объектов по производству электрической энергии)"
)
assert classify_network_zone(tz) == "electricity"
def test_pipeline_zone_gas_first():
# «трубопроводов (газопроводов, нефтепроводов …)» — газ ловится раньше pipeline.
tz = (
"Охранная зона трубопроводов (газопроводов, нефтепроводов "
"и нефтепродуктопроводов, аммиакопроводов)"
)
assert classify_network_zone(tz) == "gas"
def test_communication_zone():
assert classify_network_zone("Охранная зона линий и сооружений связи") == "communication"
def test_general_engineering_zone():
assert classify_network_zone("Охранная зона инженерных коммуникаций") == "general"
# ── негативные: НЕ сетевые ЗОУИТ ──────────────────────────────────────────────
def test_servitude_not_network():
assert classify_network_zone("Зона публичного сервитута") is None
def test_szz_not_network():
assert classify_network_zone("Санитарно-защитная зона") is None
def test_natural_zouit_not_network():
assert classify_network_zone("Зоны с особыми условиями использования территории") is None
def test_none_and_empty():
assert classify_network_zone(None) is None
assert classify_network_zone("") is None
def test_general_keyword_without_protection_marker_is_skipped():
# «инженерные коммуникации» без «охранная зона» → не ловим (избегаем ложных).
assert classify_network_zone("Размещение инженерных коммуникаций") is None
# ── helpers ───────────────────────────────────────────────────────────────────
def test_is_network_obremenenie():
assert is_network_obremenenie("Охранная зона тепловых сетей") is True
assert is_network_obremenenie("Зона публичного сервитута") is False
def test_network_kind_label():
assert network_kind_label("thermal") == "теплоснабжение"
assert network_kind_label("general") == "инженерные коммуникации"
assert network_kind_label(None) is None
# ── gate_verdict: сетевое обременение → выделенный blocker ─────────────────────
def _dump_available() -> dict:
return {"available": True, "stale": False}
def test_gate_verdict_thermal_network_blocker():
"""Теплосеть (#1070 ООО Брик) → ZOUIT_NETWORK_OBREMENENIE blocker."""
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Охранная зона тепловых сетей",
"name": "ТС-123",
"is_network_zone": True,
"network_kind": "thermal",
"network_kind_label": "теплоснабжение",
}
]
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
codes = [b["code"] for b in verdict["blockers"]]
assert "ZOUIT_NETWORK_OBREMENENIE" in codes
assert verdict["can_build_mkd"] is False
detail = next(
b["detail"] for b in verdict["blockers"] if b["code"] == "ZOUIT_NETWORK_OBREMENENIE"
)
assert "теплоснабжение" in detail
def test_gate_verdict_servitude_not_network_blocker():
"""Сервитут (не сеть) → НЕ ZOUIT_NETWORK_OBREMENENIE."""
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Зона публичного сервитута",
"name": "",
"is_network_zone": False,
"network_kind": None,
"network_kind_label": None,
}
]
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
codes = [b["code"] for b in verdict["blockers"]]
assert "ZOUIT_NETWORK_OBREMENENIE" not in codes
def test_gate_verdict_network_classified_when_flag_absent():
"""Back-compat: overlap без флага → gate_verdict классифицирует сам."""
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Охранная зона объектов электроэнергетики",
"name": "ВЛ-10кВ",
}
]
verdict = compute_gate_verdict(None, overlaps, None, _dump_available())
codes = [b["code"] for b in verdict["blockers"]]
assert "ZOUIT_NETWORK_OBREMENENIE" in codes

View file

@ -1,241 +0,0 @@
"""Tests for POI saturation per capita (#42).
Pure-функции (severity / multiplier / provision_ratio) без БД.
DB-слой compute_district_saturation через минимальный mock Session
(тот же приём, что в test_poi_score.py).
"""
from datetime import date
import pytest
from app.services.site_finder.saturation import (
_MULT_DEFICIT,
_MULT_NEUTRAL,
_MULT_SURPLUS,
classify_severity,
compute_district_saturation,
compute_provision_ratio,
saturation_multiplier,
)
# ── classify_severity ─────────────────────────────────────────────────────────
def test_severity_none_is_none():
"""Нет данных (None) → None, НЕ «острый дефицит»."""
assert classify_severity(None) is None
def test_severity_buckets():
assert classify_severity(0.2) == "острый дефицит"
assert classify_severity(0.49) == "острый дефицит"
assert classify_severity(0.5) == "дефицит"
assert classify_severity(0.84) == "дефицит"
assert classify_severity(0.85) == "норма"
assert classify_severity(1.0) == "норма"
assert classify_severity(1.3) == "норма"
assert classify_severity(1.31) == "профицит"
assert classify_severity(3.0) == "профицит"
# ── saturation_multiplier (acceptance #42) ─────────────────────────────────────
def test_multiplier_deficit_boosts():
"""Дефицитный район → ×1.2 (incentive строить school-adjacent)."""
assert saturation_multiplier("school", 0.6) == _MULT_DEFICIT
def test_multiplier_surplus_penalises():
"""Перенасыщенный район → ×0.5."""
assert saturation_multiplier("school", 2.0) == _MULT_SURPLUS
def test_multiplier_norm_neutral():
assert saturation_multiplier("school", 1.0) == _MULT_NEUTRAL
def test_multiplier_none_and_unknown_category_neutral():
"""Нет данных или категория без норматива → нейтрально (×1.0)."""
assert saturation_multiplier("school", None) == _MULT_NEUTRAL
assert saturation_multiplier("metro_stop", 0.1) == _MULT_NEUTRAL
# ── compute_provision_ratio ─────────────────────────────────────────────────────
def test_provision_unknown_category_none():
assert (
compute_provision_ratio(
poi_count=5, population=100000, age_share=0.1, category="park"
)
is None
)
def test_provision_zero_population_none():
assert (
compute_provision_ratio(
poi_count=5, population=0, age_share=0.1, category="school"
)
is None
)
def test_provision_missing_age_share_none():
"""Школа/детсад без age_share → None (когорту не оценить)."""
assert (
compute_provision_ratio(
poi_count=5, population=100000, age_share=None, category="school"
)
is None
)
def test_provision_hospital_ignores_age_share():
"""Поликлиника нормируется на ВСЁ население — age_share не нужен."""
# 100000 жителей, норматив 0.1 учр./1000 = 10 учреждений эталон.
# 10 объектов × capacity 1.0 / (100000/1000) = 10/100 = 0.1 на 1000 = ровно норматив.
ratio = compute_provision_ratio(
poi_count=10, population=100000, age_share=None, category="hospital"
)
assert ratio == pytest.approx(1.0)
def test_provision_more_poi_higher_ratio():
"""Больше объектов при той же когорте → выше обеспеченность (монотонность)."""
few = compute_provision_ratio(
poi_count=2, population=100000, age_share=0.115, category="school"
)
many = compute_provision_ratio(
poi_count=10, population=100000, age_share=0.115, category="school"
)
assert few is not None and many is not None
assert many > few
def test_provision_smaller_cohort_higher_ratio():
"""#42 суть: та же школа на МЕНЬШЕЕ число детей → выше обеспеченность per capita."""
big_cohort = compute_provision_ratio(
poi_count=3, population=300000, age_share=0.115, category="school"
)
small_cohort = compute_provision_ratio(
poi_count=3, population=100000, age_share=0.115, category="school"
)
assert big_cohort is not None and small_cohort is not None
assert small_cohort > big_cohort
# ── compute_district_saturation (mock DB) ────────────────────────────────────
class _MockMappings:
def __init__(self, row: dict | None) -> None:
self._row = row
def first(self) -> dict | None:
return self._row
class _MockResult:
def __init__(self, row: dict | None) -> None:
self._row = row
def mappings(self) -> "_MockMappings":
return _MockMappings(self._row)
class _MockDb:
"""Минимальный мок SQLAlchemy Session (как в test_poi_score.py)."""
def __init__(self, row: dict | None, *, raise_on_execute: bool = False) -> None:
self._row = row
self._raise = raise_on_execute
def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
if self._raise:
raise RuntimeError("simulated DB failure")
return _MockResult(self._row)
def _chkalovsky_row(n_school: int = 12) -> dict:
"""Чкаловский — крупнейший район ЕКБ (286277 чел.), типичный дефицит школ."""
return {
"population": 286277,
"area_km2": 389.81,
"age_share_preschool": 0.08,
"age_share_school": 0.115,
"age_share_elderly": 0.16,
"age_cohorts_estimated": True,
"source": "Росстат 2025-01-01",
"as_of_date": date(2025, 1, 1),
"n_school": n_school,
"n_kindergarten": 20,
"n_hospital": 5,
}
def test_saturation_no_demographics_returns_none():
"""Район без строки демографии (population NULL) → None."""
assert compute_district_saturation(_MockDb(None), "Неизвестный") is None
def test_saturation_db_error_returns_none():
"""Ошибка БД не роняет analyze — graceful None."""
db = _MockDb(_chkalovsky_row(), raise_on_execute=True)
assert compute_district_saturation(db, "Чкаловский") is None
def test_saturation_shape_and_flags():
db = _MockDb(_chkalovsky_row())
out = compute_district_saturation(db, "Чкаловский")
assert out is not None
assert out["district"] == "Чкаловский"
assert out["population"] == 286277
assert out["as_of_date"] == "2025-01-01"
assert out["cohorts_estimated"] is True
assert set(out["categories"]) == {"school", "kindergarten", "hospital"}
school = out["categories"]["school"]
# Школьная когорта — оценка (региональная доля), помечена флагом.
assert school["cohort_estimated"] is True
assert school["cohort_population"] == int(286277 * 0.115)
assert school["poi_count"] == 12
assert school["norm"]["capacity_per_1000"] == 92.0
def test_saturation_hospital_cohort_is_factual():
"""Поликлиника нормируется на всё население → cohort НЕ оценка (факт)."""
out = compute_district_saturation(_MockDb(_chkalovsky_row()), "Чкаловский")
assert out is not None
hospital = out["categories"]["hospital"]
assert hospital["cohort_estimated"] is False
assert hospital["cohort_population"] == 286277
def test_saturation_deficit_district_school_gets_boost():
"""#42 acceptance: дефицитный по школам район → school POI score_multiplier=1.2.
Чкаловский, 12 школ, школьники 32922 чел. (286277×0.115).
Эталон-места = 32922/1000 × 92 3029. Факт = 12 × 600 = 7200
при таком capacity это профицит. Сужаем когорту: мало школ на много детей
проверяем boundary через малое число объектов.
"""
# 1 школа на крупный район → острый дефицит → ×1.2.
out = compute_district_saturation(_MockDb(_chkalovsky_row(n_school=1)), "Чкаловский")
assert out is not None
school = out["categories"]["school"]
assert school["severity"] in ("дефицит", "острый дефицит")
assert school["score_multiplier"] == _MULT_DEFICIT
def test_saturation_ratio_matches_pure_helper():
"""DB-слой использует ту же чистую формулу, что и compute_provision_ratio."""
row = _chkalovsky_row(n_school=4)
out = compute_district_saturation(_MockDb(row), "Чкаловский")
assert out is not None
expected = compute_provision_ratio(
poi_count=4, population=286277, age_share=0.115, category="school"
)
assert expected is not None
assert out["categories"]["school"]["provision_ratio"] == round(expected, 3)

View file

@ -1,107 +0,0 @@
-- Контекст: Issue #42 (Infra I1) — POI saturation per capita. Чтобы считать
-- обеспеченность инфраструктурой района (школа/детсад/поликлиника на
-- 1000 чел. целевой когорты vs норматив СП 42.13330), нужна демография
-- по 8 внутригородским районам ЕКБ.
--
-- Что делает:
-- 1. Заполняет ekb_districts.population (была NULL) точными числами Росстата.
-- 2. Создаёт таблицу-справочник ekb_district_demographics — насел./площадь/ОКАТО
-- + ДОКУМЕНТИРОВАННЫЙ источник/дата + ОЦЕНОЧНЫЕ возрастные когорты (помечены).
--
-- ИСТОЧНИК населения (источник истины):
-- Wikipedia «Административно-территориальное деление Екатеринбурга» →
-- первоисточник «Численность постоянного населения РФ по муниципальным
-- образованиям на 1 января 2025», Росстат. as_of_date = 2025-01-01.
--
-- ВОЗРАСТНЫЕ КОГОРТЫ — ОЦЕНКА, НЕ ФАКТ. На уровне внутригородского района Росстат
-- публичных половозрастных срезов НЕ даёт. Применены РЕГИОНАЛЬНЫЕ доли (РФ/Свердл.
-- обл., перепись-2020 / EMISS, порядок величин):
-- • дошкольники 0-7 ≈ 8.0 % (age_share_preschool)
-- • школьники 7-18 ≈ 11.5 % (age_share_school)
-- • пожилые 65+ ≈ 16.0 % (age_share_elderly)
-- Это ГРУБАЯ оценка для saturation-расчёта (estimate-флаг в API). Реальные
-- район-уровневые доли потребуют отдельного источника (см. issue #42 risks).
--
-- Deploy order: после 56_schema_ekb_districts_geom (FK на ekb_districts) — стоит.
-- Idempotent: CREATE TABLE IF NOT EXISTS + INSERT … ON CONFLICT DO UPDATE
-- (re-apply перезаписывает данными из этой миграции — single source).
-- Deploy: auto-applied by deploy.yml через _schema_migrations tracking (ровно один раз по NN).
BEGIN;
-- ── 1. Справочник демографии района ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ekb_district_demographics (
district_name TEXT PRIMARY KEY
REFERENCES ekb_districts(district_name),
population INTEGER NOT NULL, -- чел., Росстат 2025-01-01
area_km2 NUMERIC(8, 2) NOT NULL, -- км²
okato TEXT, -- код ОКАТО района
-- Возрастные доли — ОЦЕНКА (региональный fallback), а не район-факт.
age_share_preschool NUMERIC(5, 4), -- доля 0-7 лет (детсад)
age_share_school NUMERIC(5, 4), -- доля 7-18 лет (школа)
age_share_elderly NUMERIC(5, 4), -- доля 65+ лет (поликлиника)
age_cohorts_estimated BOOLEAN NOT NULL DEFAULT TRUE, -- TRUE = когорты оценочные
source TEXT NOT NULL, -- человекочитаемый источник
as_of_date DATE NOT NULL, -- дата актуальности населения
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ekb_district_demographics IS
'Демография 8 внутригородских районов ЕКБ для POI saturation per capita (#42). '
'population/area — Росстат 2025-01-01 (факт). Возрастные когорты — ОЦЕНКА '
'(региональные доли РФ/Свердл.), см. age_cohorts_estimated.';
COMMENT ON COLUMN ekb_district_demographics.age_cohorts_estimated IS
'TRUE: age_share_* — оценка региональными долями, НЕ район-факт Росстата (#42).';
-- ── 2. Seed: 8 районов (Росстат на 1 января 2025) ────────────────────────────
-- population/area/okato — ФАКТ. age_share_* — единая региональная оценка (см. шапку).
INSERT INTO ekb_district_demographics
(district_name, population, area_km2, okato,
age_share_preschool, age_share_school, age_share_elderly,
age_cohorts_estimated, source, as_of_date)
VALUES
('Академический', 125000, 46.01, '65401362', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01'),
('Верх-Исетский', 240822, 219.79, '65401364', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01'),
('Железнодорожный', 158675, 125.65, '65401368', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01'),
('Кировский', 220749, 86.25, '65401373', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01'),
('Ленинский', 222258, 22.19, '65401377', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01'),
('Октябрьский', 151775, 158.60, '65401380', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01'),
('Орджоникидзевский', 263820, 99.30, '65401385', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01'),
('Чкаловский', 286277, 389.81, '65401390', 0.0800, 0.1150, 0.1600, TRUE,
'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)',
DATE '2025-01-01')
ON CONFLICT (district_name) DO UPDATE SET
population = EXCLUDED.population,
area_km2 = EXCLUDED.area_km2,
okato = EXCLUDED.okato,
age_share_preschool = EXCLUDED.age_share_preschool,
age_share_school = EXCLUDED.age_share_school,
age_share_elderly = EXCLUDED.age_share_elderly,
age_cohorts_estimated = EXCLUDED.age_cohorts_estimated,
source = EXCLUDED.source,
as_of_date = EXCLUDED.as_of_date,
loaded_at = now();
-- ── 3. Backfill ekb_districts.population (была NULL) — по name-match ──────────
-- ekb_districts.area_m2 уже есть в схеме (NUMERIC) — конвертируем км² → м².
UPDATE ekb_districts d
SET population = s.population,
area_m2 = COALESCE(d.area_m2, s.area_km2 * 1000000.0)
FROM ekb_district_demographics s
WHERE d.district_name = s.district_name;
COMMIT;

View file

@ -1,259 +0,0 @@
-- 155_objective_mapping_trgm_backfill.sql
-- Issue #307 OBJ-2 — КОНСЕРВАТИВНЫЙ fuzzy (pg_trgm) backfill of
-- objective_complex_mapping. Третий проход (после 116/150): добавляет ТОЛЬКО
-- near-exact distinctive-name матчи, которые 150 (dev-corroboration-only gate)
-- НЕ взял из-за Latin/Cyrillic dev-noise (TEN↔ТЭН, FORTIS↔Фортис) и юр.префиксов.
--
-- CONTEXT
-- objective_complex_mapping связывает Objective-ЖК (objective_complex_name) →
-- domrf_kn_objects.obj_id. До этой миграции — 263 строки (все objective_group=
-- 'Екатеринбург'; match_method fuzzy/auto_fuzzy_v2/multi_feature_v1/fuzzy_v2/manual,
-- все is_reviewed=false кроме 2 manual).
--
-- mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name =
-- objective_complex_mapping.objective_complex_name WHERE objective_group=
-- 'Екатеринбург' AND domrf_obj_id IS NOT NULL (verified pg_get_viewdef
-- 2026-06-14). То есть unique-ключ матчинга — project_name (НЕ project_id):
-- новые строки с этим именем + group='Екатеринбург' немедленно расширяют MV.
--
-- ПОЧЕМУ ИМЕНА, А НЕ project_id
-- objective_lots: 302 distinct objective_project_id ещё не в mapping, НО лишь
-- 156 distinct project_name не в mapping под group='Екатеринбург' (многие
-- project_id делят одно имя; многие имена уже сматчены под другим id). MV и
-- UNIQUE-констрейнт работают по имени, 150 дедупил по имени — поэтому здесь
-- тоже дедуп по project_name. Матчить 302 id создало бы дубль-имя конфликты,
-- которые ON CONFLICT молча отбросит.
--
-- НОРМАЛИЗАЦИЯ (идентична 150; калибровка prod read-only 2026-06-14, snapshot
-- 2026-06-10): strip ЖК-boilerplate ('Жилой комплекс/квартал/район/дом',
-- 'Клубный дом', 'Микрорайон', 'Квартал', 'ЖК', 'Дом' …), кавычки,
-- stage-суффиксы ('N этап/очередь') ДО similarity. Strip-токены по
-- word-boundary (\m…\M) чтобы не резать середину ('Домино'→'ино').
-- Dev нормализуется (strip юр.префиксов СЗ/ГК/ООО/Девелопмент/Development/Group…)
-- и dsim = trigram ИЛИ substring-containment.
--
-- ПРАВИЛО ПРИЁМКИ — PRECISION OVER RECALL (P0: ложный mapping напрямую портит
-- velocity/prices, поэтому держим precision≈1.0 на авто-наборе):
-- nsim = similarity(norm(project_name), norm(comm_name))
-- dsim = trigram(norm dev) OR substring-containment
-- corroborated = (dsim >= 0.5) OR (district точно совпал)
-- generic_guarded = norm(project_name) ∈ стоплист generic single-token имён,
-- где межзастройщицкие коллизии РЕАЛЬНЫ (наблюдённые FP в band'е 1.0:
-- «Солнечный», «Прекрасный», «Невский», «Стрелки», «Парковый» (over-strip
-- «Жилой квартал Парковый»→'парковый' ≡ obj 'Парковый квартал' PRINZIP),
-- «Тихий центр», плюс risk-слова центральный/самоцветы/южные/теплые/
-- московский/сириус/нескучный сад).
-- accept =
-- (nsim >= 0.85 AND NOT generic_guarded) -- A: near-exact + distinctive
-- OR (nsim >= 0.85 AND generic_guarded AND corroborated) -- A': generic, но dev/district подтверждён
-- OR (nsim >= 0.62 AND nsim < 0.85 AND corroborated) -- B: good name + dev/district
-- Калибровка по реальным band'ам (prod, BEGIN…ROLLBACK 2026-06-14):
-- • Band A нетронутый дал 29 near-exact (все nsim=1.000). 6 — generic FP с
-- чужим застройщиком → отсечены guard'ом. Остаток 23 — distinctive
-- (RedRock/Геометрия/Башня Времени/…), межзастройщицкая коллизия
-- практически невозможна → авто без dev. Precision на этих 23 ≈ 1.0.
-- • Band B (0.62-0.85): 10 кандидатов, единственный corroborated — «Рио-3»
-- (dsim=1.0 Стройтэк) — но его obj_id 31874 уже занят 150 ('Рио'); «Рио-3»
-- это фаза того же ЖК → исключён obj_id-guard'ом. Прочие 9 uncorroborated
-- (чужой dev / Kurgan-застройщик / уже-сматченный «Самоцветы Урала») →
-- отброшены. ИТОГ band B = 0 авто (намеренно: ни одного безопасного).
-- ИТОГ: 23 новых mapping (все nsim=1.000, decision=A_auto). 133 имени остаются
-- на manual (Anton): 19 без кандидата в DOM.РФ (города-спутники / нет ПД),
-- 6 generic-guarded near-exact, 108 низко-/средне-confident без подтверждения.
--
-- COLLISION HANDLING
-- 1. Лучший domrf-obj на каждый objective project: DISTINCT ON (project_name)
-- по nsim DESC, dsim DESC.
-- 2. Один obj_id ≤ один objective project: ROW_NUMBER per obj_id
-- ORDER BY corroborated DESC, nsim DESC.
-- 3. obj_id, уже занятые существующими 263 mapping, исключаются (NOT IN).
--
-- ПОЧЕМУ ТРЕТИЙ ПРОХОД, А НЕ ПЕРЕДЕЛКА 150
-- 150 уже применён на prod (_schema_migrations) — переписывать его нельзя
-- (auto-apply once). 155 — аддитивный: только INSERT новых имён, НИКАКИХ
-- DROP/UPDATE существующих 263 строк.
--
-- GIN-ИНДЕКС
-- Существующий idx_domrf_kn_objects_comm_name_trgm — GIST partial WHERE is_ekb=true,
-- а ВСЕ 1543 строки latest-snapshot region_cd=66 имеют is_ekb=false (verified) →
-- индекс покрывает 0 строк, для backfill бесполезен. Добавляем НЕпартиальный
-- GIN trgm индекс (IF NOT EXISTS) на comm_name: он используется %-оператором
-- (verified EXPLAIN: Bitmap Index Scan) для будущих/runtime ad-hoc match-запросов.
-- Честно: САМ backfill ниже использует similarity()>порог в CTE-join (не %),
-- что НЕ триггерит trgm-индекс — но это one-shot 156×1543 Nested Loop (~4 s,
-- измерено на 150), приемлемо. Индекс добавлен под требование задачи и runtime,
-- не под эту миграцию. Таблица 9 MB / 10699 строк → обычный CREATE INDEX
-- (не CONCURRENTLY — нельзя в транзакции) строится мгновенно, lock тривиален.
--
-- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects,
-- pg_trgm (1.6, установлен), mv_layout_velocity (UNIQUE pk mv_layout_velocity_pk
-- на (obj_id, room_bucket) → CONCURRENTLY валиден).
-- DEPLOY: auto-applied by deploy.yml через _schema_migrations (ровно один раз по NN).
-- Применяется ДО compose up -d. ORDER: schema/data first → backend deploy second.
-- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING +
-- CREATE INDEX IF NOT EXISTS. Re-apply добавит 0 строк (verified BEGIN/INSERT
-- x2/ROLLBACK на prod: INSERT 0 23, затем INSERT 0 0).
BEGIN;
-- Non-partial GIN trgm index for %-operator / runtime fuzzy match (см. шапку).
CREATE INDEX IF NOT EXISTS idx_domrf_kn_objects_comm_name_gin_trgm
ON domrf_kn_objects USING gin (comm_name gin_trgm_ops);
WITH
-- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация).
latest AS (
SELECT max(snapshot_date) AS sd FROM domrf_kn_objects
),
domrf AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
o.comm_name,
o.dev_name,
o.district_name,
-- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки.
btrim(regexp_replace(
regexp_replace(
regexp_replace(lower(coalesce(o.comm_name, '')),
'\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
' ', 'g'),
'\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
btrim(regexp_replace(
regexp_replace(lower(coalesce(o.dev_name, '')),
'(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
FROM domrf_kn_objects o
WHERE o.snapshot_date = (SELECT sd FROM latest)
AND o.region_cd = 66
AND o.comm_name IS NOT NULL
ORDER BY o.obj_id
),
-- 2. Distinct Objective projects (один ряд на project_name; dominant dev/district),
-- исключая уже сматченные в group='Екатеринбург'.
obj AS (
SELECT
l.project_name,
mode() WITHIN GROUP (ORDER BY l.developer) AS developer,
mode() WITHIN GROUP (ORDER BY l.district) AS district,
max(l.objective_project_id) AS objective_project_id
FROM objective_lots l
WHERE l.project_name IS NOT NULL
GROUP BY l.project_name
),
obj_unmapped AS (
SELECT
o.*,
btrim(regexp_replace(
regexp_replace(
regexp_replace(lower(coalesce(o.project_name, '')),
'\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
' ', 'g'),
'\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
btrim(regexp_replace(
regexp_replace(lower(coalesce(o.developer, '')),
'(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
FROM obj o
WHERE NOT EXISTS (
SELECT 1 FROM objective_complex_mapping m
WHERE m.objective_complex_name = o.project_name
AND m.objective_group = 'Екатеринбург'
)
),
-- 3. Best DOM.РФ candidate per objective project (нормализованный trigram).
pairs AS (
SELECT
u.project_name,
u.developer AS obj_dev,
u.district AS obj_district,
u.objective_project_id,
-- normalized objective name, whitespace-collapsed (для generic-guard сравнения)
btrim(regexp_replace(u.name_norm, '\s+', ' ', 'g')) AS obj_nn,
d.obj_id,
d.comm_name,
d.dev_name AS domrf_dev,
d.district_name AS domrf_district,
similarity(u.name_norm, d.name_norm) AS nsim,
GREATEST(
similarity(u.dev_norm, d.dev_norm),
(char_length(btrim(u.dev_norm)) > 2
AND char_length(btrim(d.dev_norm)) > 2
AND (u.dev_norm ILIKE '%' || d.dev_norm || '%'
OR d.dev_norm ILIKE '%' || u.dev_norm || '%'))::int::double precision
) AS dsim,
(u.district IS NOT NULL AND d.district_name IS NOT NULL
AND lower(btrim(u.district)) = lower(btrim(d.district_name)))::int AS district_match
FROM obj_unmapped u
JOIN domrf d
ON similarity(u.name_norm, d.name_norm) > 0.2 -- prefilter
),
best_per_proj AS (
SELECT DISTINCT ON (project_name) *
FROM pairs
ORDER BY project_name, nsim DESC, dsim DESC, obj_id
),
classified AS (
SELECT
b.*,
(b.dsim >= 0.5 OR b.district_match = 1) AS corroborated,
(b.obj_nn = ANY (ARRAY[
'солнечный','прекрасный','невский','стрелки','парковый','центральный',
'тихий центр','самоцветы','южные','теплые','московский','сириус','нескучный сад'
])) AS generic_guarded
FROM best_per_proj b
),
accepted AS (
SELECT *
FROM (
SELECT
c.*,
ROW_NUMBER() OVER (
PARTITION BY c.obj_id
ORDER BY c.corroborated DESC, c.nsim DESC, c.project_name
) AS rn_obj
FROM classified c
WHERE (
(c.nsim >= 0.85 AND NOT c.generic_guarded)
OR (c.nsim >= 0.85 AND c.generic_guarded AND c.corroborated)
OR (c.nsim >= 0.62 AND c.nsim < 0.85 AND c.corroborated)
)
AND c.obj_id NOT IN (
SELECT domrf_obj_id FROM objective_complex_mapping
WHERE domrf_obj_id IS NOT NULL
)
) s
WHERE s.rn_obj = 1
)
INSERT INTO objective_complex_mapping
(objective_complex_name, objective_project_id, objective_group,
domrf_obj_id, is_reviewed, match_method, match_score, note)
SELECT
a.project_name,
a.objective_project_id,
'Екатеринбург',
a.obj_id,
FALSE, -- ВСЕ авто-матчи на ручной review (Anton)
'auto_trgm_v3',
round(a.nsim::numeric, 3),
'OBJ-2 conservative trgm backfill #307'
|| ' | nsim=' || round(a.nsim::numeric, 3)
|| ' dsim=' || round(a.dsim::numeric, 3)
|| ' | domrf="' || coalesce(a.comm_name, '') || '"'
|| ' dev_obj="' || coalesce(a.obj_dev, '') || '"'
|| ' dev_domrf="' || coalesce(a.domrf_dev, '') || '"'
FROM accepted a
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING;
COMMIT;
-- REFRESH ВНЕ транзакции (после COMMIT): CONCURRENTLY требует non-tx-блок и
-- UNIQUE index (mv_layout_velocity_pk на (obj_id, room_bucket) существует) и не
-- блокирует читателей. MV уже populated → CONCURRENTLY безопасен. deploy.yml
-- прогоняет файл через `psql < file` без --single-transaction, поэтому COMMIT
-- выше закрывает txn и этот стейтмент исполняется в собственной транзакции
-- (тот же приём, что в 150_objective_mapping_backfill.sql).
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity;

View file

@ -1,270 +0,0 @@
-- 156_objective_mapping_corroborated_v4.sql
-- Issue #307 OBJ-2 — РАУНД-2: КОРРОБОРИРОВАННЫЙ fuzzy (pg_trgm) backfill of
-- objective_complex_mapping. Четвёртый проход (после 116/150/155). 155 (v3) был
-- НАМЕРЕННО строгим: только near-exact distinctive-name матчи (nsim=1.000, +23).
-- v4 безопасно добирает СРЕДНИЙ band (nsim 0.50-0.85), где имя одно не дотягивает
-- до уверенности, но КОРРОБОРАЦИЯ застройщиком/районом добивает precision до ≈1.0.
--
-- CONTEXT
-- objective_complex_mapping связывает Objective-ЖК (objective_complex_name) →
-- domrf_kn_objects.obj_id. До этой миграции — 286 строк (group='Екатеринбург';
-- match_method fuzzy/auto_fuzzy_v2/auto_trgm_v3/multi_feature_v1/manual; все
-- is_reviewed=false кроме 2 manual). Verified prod 2026-06-14: 286/286/286
-- (total/ekb/with_obj). 155 применён (_schema_migrations).
--
-- mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name =
-- objective_complex_mapping.objective_complex_name WHERE objective_group=
-- 'Екатеринбург' AND domrf_obj_id IS NOT NULL. Unique-ключ матчинга — имя
-- (project_name), не project_id: новые строки с этим именем + group='Екатеринбург'
-- немедленно расширяют MV. Дедуп по project_name (как 150/155).
--
-- НОРМАЛИЗАЦИЯ (идентична 150/155): strip ЖК-boilerplate ('Жилой комплекс/квартал/
-- район/дом','Клубный дом','Микрорайон','Квартал','ЖК','Дом'…), кавычки,
-- stage-суффиксы ('N этап/очередь') ДО similarity. Strip-токены по word-boundary
-- (\m…\M), чтобы не резать середину ('Домино'→'ино'). Dev нормализуется (strip
-- юр.префиксов СЗ/ГК/ООО/Девелопмент/Development/Group…).
--
-- КОРРОБОРАЦИЯ (РАСШИРЕНА vs 155): три независимых сигнала равенства застройщика —
-- dsim = trigram(norm dev) OR substring-containment (как 150/155)
-- dev_tok = ОБЩИЙ distinctive dev-токен длиной ≥4 после нормализации
-- dm = район Objective точно совпал с district_name DOM.РФ
-- corroborated = (dsim >= 0.6) OR dev_tok OR (dm = 1)
-- ПОЧЕМУ dev_tok: trigram/substring НЕ ловит аббревиатуру-vs-расшифровку. Реальный
-- кейс — «АСЦ Правобережный» (Objective) vs «Архитектурно-строительный центр
-- Правобережный» (DOM.РФ): dsim=0.326 (<0.6), substring fail, НО общий distinctive
-- токен 'правобережный' (len 12) → тот же застройщик. Задача явно зовёт
-- «Мкр. Центральный»→obj 47127 (АСЦ Правобережный, almost-certainly true);
-- dev_tok ловит его И структурно-идентичный «Мкр. Садовый»→obj 35542 через ОДНО
-- общее правило, а не hardcoded obj_id. (Калибровка prod read-only 2026-06-14:
-- dev_tok НЕ добавил ни одного FP в band'е — он только разрешает abbreviation
-- кейсы; все остальные 0.50-0.85 уже имели dsim=1.0 или district.)
--
-- ПРАВИЛО ПРИЁМКИ v4 — PRECISION OVER RECALL (P0: ложный mapping напрямую портит
-- velocity/prices, держим precision≈1.0 на авто-наборе):
-- accept = nsim >= 0.50 AND nsim <= 0.85 AND corroborated
-- Band <0.50 НЕ берём (низкоконфидентный хвост). Band >0.85 здесь НЕ берём —
-- он покрыт 155 (distinctive) либо остаётся generic-guarded (см. ниже). Этот
-- проход — РОВНО средний band, где корроборация необходима и достаточна.
--
-- GENERIC-GUARDED (6 имён из v3): «Солнечный»,«Прекрасный»,«Невский»,«Стрелки»,
-- «Жилой квартал Парковый»(→'парковый'),«Тихий центр». В band'е 1.0 они дают
-- FP (одинаковое generic-имя, РАЗНЫЙ застройщик). Задача: принять ТОЛЬКО если
-- developer корроборирует. ПРОВЕРЕНО (prod 2026-06-14): у ВСЕХ шести dsim=0,
-- dev_tok=false, district НЕ совпал — застройщики реально РАЗНЫЕ (НКС vs Астра;
-- Вавилон vs PRINZIP; Синара vs Актив; Вавилон vs Аркада; СКОН vs Фортис;
-- Клевер vs Астра). Корроборации НЕТ → НИ ОДИН не принят. Guard держит; они
-- остаются на manual. (Дополнительно их nsim=1.000 вне band'а 0.50-0.85, так что
-- они и так не попали бы в accept — двойная защита.)
--
-- COLLISION HANDLING
-- 1. Лучший domrf-obj на каждый objective project: DISTINCT ON (project_name)
-- по nsim DESC, dsim DESC, obj_id.
-- 2. Один obj_id ≤ один objective project: ROW_NUMBER per obj_id
-- ORDER BY corroborated DESC, nsim DESC, project_name.
-- 3. obj_id, уже занятые существующими 286 mapping (incl. 155), исключаются.
-- Реальные коллизии, отсечённые guard'ом (prod 2026-06-14):
-- • obj 58129 «Толк Академический» (уже в mapping) — на него целятся ТРИ
-- разных Objective-проекта «Близкий/Eleven/Школьный (Академический)» лишь
-- по district=Академический (dsim=0). Это РАЗНЫЕ дома в районе, не «Толк».
-- obj_id-taken-guard убирает все три. (Урок: district-only корроборация в
-- плотном районе шумит — но taken-guard + dedup её гасят.)
-- • obj 31874 «Рио» (уже в mapping, 150) — «Рио-3» это ФАЗА того же ЖК,
-- dsim=1.0 Стройтэк, но obj_id занят → исключён.
--
-- РЕЗУЛЬТАТ (verified prod, BEGIN…ROLLBACK 2026-06-14, snapshot 2026-06-10):
-- 17 новых mapping (nsim 0.500-0.750, ВСЕ корроборированы dev/dev_tok/district,
-- ВСЕ obj_id свободны). is_reviewed=FALSE. После: 286 → 303.
-- Из 133 unmapped имён остаётся 116 на manual (Anton):
-- • 19 без кандидата в DOM.РФ (города-спутники / нет ПД)
-- • 6 generic-guarded near-exact (застройщик НЕ подтвердил)
-- • 4 in-band corroborated, но obj_id уже занят (Близкий/Eleven/Школьный→58129,
-- Рио-3→31874 — фазы/соседи уже-сматченных)
-- • 16 in-band БЕЗ корроборации (другой застройщик, район не совпал)
-- • 71 nsim<0.50 низкоконфидентный хвост
-- Честно: Objective шире DOM.РФ (сателлиты, проекты без объявленной ПД) — 100%
-- автоматом недостижимо by design; хвост — ручная разметка.
--
-- ПОЧЕМУ ОТДЕЛЬНЫЙ ПРОХОД, А НЕ ПЕРЕДЕЛКА 155
-- 150 и 155 уже на prod (_schema_migrations, auto-apply once) — переписывать
-- нельзя. 156 — аддитивный: только INSERT новых имён, НИКАКИХ DROP/UPDATE
-- существующих 286 строк.
--
-- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects,
-- pg_trgm (1.6), mv_layout_velocity (UNIQUE pk mv_layout_velocity_pk на
-- (obj_id, room_bucket) → REFRESH CONCURRENTLY валиден). GIN trgm индекс на
-- domrf_kn_objects.comm_name создан в 155 (idx_domrf_kn_objects_comm_name_gin_trgm).
-- Сам backfill использует similarity()>порог в CTE-join (не %-оператор), что НЕ
-- триггерит trgm-индекс — но это one-shot Nested Loop (~4 s, как 150/155),
-- приемлемо.
-- DEPLOY: auto-applied by deploy.yml через _schema_migrations (ровно один раз по NN).
-- Применяется ДО compose up -d. ORDER: schema/data first → backend deploy second.
-- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING.
-- Re-apply добавит 0 строк (verified BEGIN/INSERT x2/ROLLBACK на prod:
-- INSERT 0 17, затем INSERT 0 0).
BEGIN;
WITH
-- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация).
latest AS (
SELECT max(snapshot_date) AS sd FROM domrf_kn_objects
),
domrf AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
o.comm_name,
o.dev_name,
o.district_name,
-- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки.
btrim(regexp_replace(
regexp_replace(
regexp_replace(lower(coalesce(o.comm_name, '')),
'\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
' ', 'g'),
'\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
btrim(regexp_replace(
regexp_replace(lower(coalesce(o.dev_name, '')),
'(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
FROM domrf_kn_objects o
WHERE o.snapshot_date = (SELECT sd FROM latest)
AND o.region_cd = 66
AND o.comm_name IS NOT NULL
ORDER BY o.obj_id
),
-- 2. Distinct Objective projects (один ряд на project_name; dominant dev/district),
-- исключая уже сматченные в group='Екатеринбург'.
obj AS (
SELECT
l.project_name,
mode() WITHIN GROUP (ORDER BY l.developer) AS developer,
mode() WITHIN GROUP (ORDER BY l.district) AS district,
max(l.objective_project_id) AS objective_project_id
FROM objective_lots l
WHERE l.project_name IS NOT NULL
GROUP BY l.project_name
),
obj_unmapped AS (
SELECT
o.*,
btrim(regexp_replace(
regexp_replace(
regexp_replace(lower(coalesce(o.project_name, '')),
'\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
' ', 'g'),
'\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
btrim(regexp_replace(
regexp_replace(lower(coalesce(o.developer, '')),
'(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
' ', 'g'),
'[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
FROM obj o
WHERE NOT EXISTS (
SELECT 1 FROM objective_complex_mapping m
WHERE m.objective_complex_name = o.project_name
AND m.objective_group = 'Екатеринбург'
)
),
-- 3. Best DOM.РФ candidate per objective project (нормализованный trigram).
pairs AS (
SELECT
u.project_name,
u.developer AS obj_dev,
u.district AS obj_district,
u.objective_project_id,
d.obj_id,
d.comm_name,
d.dev_name AS domrf_dev,
d.district_name AS domrf_district,
similarity(u.name_norm, d.name_norm) AS nsim,
GREATEST(
similarity(u.dev_norm, d.dev_norm),
(char_length(btrim(u.dev_norm)) > 2
AND char_length(btrim(d.dev_norm)) > 2
AND (u.dev_norm ILIKE '%' || d.dev_norm || '%'
OR d.dev_norm ILIKE '%' || u.dev_norm || '%'))::int::double precision
) AS dsim,
-- ОБЩИЙ distinctive dev-токен (len>=4): ловит аббревиатуру-vs-расшифровку
-- (АСЦ Правобережный ↔ Архитектурно-строительный центр Правобережный).
EXISTS (
SELECT 1 FROM (
SELECT unnest(regexp_split_to_array(btrim(regexp_replace(u.dev_norm, '\s+', ' ', 'g')), ' ')) AS t
INTERSECT
SELECT unnest(regexp_split_to_array(btrim(regexp_replace(d.dev_norm, '\s+', ' ', 'g')), ' ')) AS t
) x
WHERE char_length(t) >= 4
) AS dev_tok_shared,
(u.district IS NOT NULL AND d.district_name IS NOT NULL
AND lower(btrim(u.district)) = lower(btrim(d.district_name)))::int AS district_match
FROM obj_unmapped u
JOIN domrf d
ON similarity(u.name_norm, d.name_norm) > 0.2 -- prefilter
),
best_per_proj AS (
SELECT DISTINCT ON (project_name) *
FROM pairs
ORDER BY project_name, nsim DESC, dsim DESC, obj_id
),
classified AS (
SELECT
b.*,
(b.dsim >= 0.6 OR b.dev_tok_shared OR b.district_match = 1) AS corroborated
FROM best_per_proj b
),
accepted AS (
SELECT *
FROM (
SELECT
c.*,
ROW_NUMBER() OVER (
PARTITION BY c.obj_id
ORDER BY c.corroborated DESC, c.nsim DESC, c.project_name
) AS rn_obj
FROM classified c
WHERE c.nsim >= 0.50 AND c.nsim <= 0.85 -- ровно средний band
AND c.corroborated -- обязательная корроборация
AND c.obj_id NOT IN (
SELECT domrf_obj_id FROM objective_complex_mapping
WHERE domrf_obj_id IS NOT NULL
)
) s
WHERE s.rn_obj = 1
)
INSERT INTO objective_complex_mapping
(objective_complex_name, objective_project_id, objective_group,
domrf_obj_id, is_reviewed, match_method, match_score, note)
SELECT
a.project_name,
a.objective_project_id,
'Екатеринбург',
a.obj_id,
FALSE, -- ВСЕ авто-матчи на ручной review (Anton)
'auto_trgm_corroborated_v4',
round(a.nsim::numeric, 3),
'OBJ-2 corroborated trgm backfill #307 (round-2)'
|| ' | nsim=' || round(a.nsim::numeric, 3)
|| ' dsim=' || round(a.dsim::numeric, 3)
|| ' corrob=' || CASE
WHEN a.dsim >= 0.6 THEN 'dev'
WHEN a.dev_tok_shared THEN 'dev_token'
WHEN a.district_match = 1 THEN 'district'
ELSE '?'
END
|| ' | domrf="' || coalesce(a.comm_name, '') || '"'
|| ' dev_obj="' || coalesce(a.obj_dev, '') || '"'
|| ' dev_domrf="' || coalesce(a.domrf_dev, '') || '"'
FROM accepted a
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING;
COMMIT;
-- REFRESH ВНЕ транзакции (после COMMIT): CONCURRENTLY требует non-tx-блок и
-- UNIQUE index (mv_layout_velocity_pk на (obj_id, room_bucket) существует), не
-- блокирует читателей. MV уже populated → CONCURRENTLY безопасен. deploy.yml
-- прогоняет файл через `psql < file` без --single-transaction, поэтому COMMIT
-- выше закрывает txn и этот стейтмент исполняется в собственной транзакции
-- (тот же приём, что в 150/155).
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity;

View file

@ -1,122 +0,0 @@
-- 157_analyze_hotpath_indexes.sql
-- Perf/index аудит горячего analyze-пути Site Finder (POST /parcels/{cad}/analyze).
-- Прецедент: migration 153 (#70) — partial covering index objective_lots(district,
-- price_per_m2_rub) → cold 5.3s→87ms Index Only Scan. Этот файл — аналогичные wins,
-- найденные EXPLAIN (ANALYZE, BUFFERS) на проде (2026-06-14, district='Академический',
-- centroid POINT(60.4869 56.7992), objective_lots ~1.12M / 2.1 ГБ, cad_buildings 38k / 88 МБ).
--
-- КОРНЕВАЯ ПРИЧИНА (3 из 4 индексов): запросы фильтруют по `X.geom::geography` для
-- точного метрового ST_DWithin (radius в метрах). Существующие GIST построены на ПЛОСКОМ
-- `geom` (geometry, SRID 4326) → планировщик НЕ может ими воспользоваться для
-- geography-предиката → Seq Scan всей таблицы на КАЖДЫЙ analyze. Функциональный GIST на
-- выражении `(geom::geography)` закрывает именно этот предикат (Index Cond через `&&`
-- + _st_expand). Плоские geom-GIST НЕ трогаем — они обслуживают ST_Intersects(geom,…)
-- (geometry) ветки (например overlap_rows в _neighbors_summary) и остаются нужны.
--
-- ИЗМЕРЕНИЯ (EXPLAIN ANALYZE до/после; «после» — warm 2-й прогон с индексом в BEGIN…ROLLBACK):
--
-- 1) osm_poi_ekb (POI в 1км, parcels.py:1488 «сек.3», 1× на analyze):
-- ДО: Seq Scan, Rows Removed by Filter 4824, 538 buf, ~36 ms
-- ПОСЛЕ: Index Scan на (geom::geography), 12 buf, ~0.2 ms (≈190x)
-- Индекс ~384 кБ, build 28 мс.
--
-- 2) osm_noise_sources_ekb (шум/гидрология/инж.сети, parcels.py:1773/1840/1889, 4× на analyze):
-- ДО: Parallel Seq Scan, Rows Removed 5122, 1701 buf, ~92 ms КАЖДЫЙ
-- ПОСЛЕ: Index Scan на (geom::geography), 176 buf, ~3.8 ms (≈24x)
-- Индекс ~816 кБ, build 55 мс. Кумулятивно ~4×88 ≈ 350 мс с одного analyze.
-- NB: geotech-запрос (parcels.py:779, source_type='industrial') уже идёт по
-- osm_noise_sources_ekb_source_idx (селективный source_type) — этот индекс его не
-- ухудшает (планировщик выберет дешевле из двух).
--
-- 3) cad_buildings (_neighbors_summary соседи в 100м, parcels.py:612, 1× на analyze):
-- ДО: Parallel Seq Scan, Rows Removed 12670, 11235 buf, ~314 ms
-- ПОСЛЕ: Index Scan на (geom::geography), 18 buf, ~0.3 ms (≈1000x)
-- Индекс ~2.9 МБ, build 338 мс. САМЫЙ крупный единичный выигрыш analyze-пути.
-- overlap_rows CTE (ST_Intersects(b.geom, wkt), geometry) по-прежнему обслуживается
-- существующим cad_buildings_geom_gist — без регрессии.
--
-- 4) objective_lots covering (obj_pricing CTE в competitor-блоке, parcels.py:1614, 1× на analyze):
-- Запрос агрегирует AVG(price)/AVG(area)/COUNT(is_sold) по ol.project_name =
-- cm.objective_complex_name (objective_complex_mapping, 286 строк).
-- ДО: Parallel Seq Scan ВСЕХ objective_lots, 215761 buf (~1.7 ГБ), Hash Join,
-- warm ~493 ms (cold ~681 ms).
-- ПОСЛЕ: Nested Loop + Index Only Scan на (project_name) INCLUDE(price,area,is_sold),
-- 286 проб по имени, 16586 buf, warm ~355 ms (≈1.4x, 13x buffers).
-- Индекс ~74 МБ, build 1.75 c. Существующий objective_lots_project_idx
-- (project_name, snapshot_date DESC) НЕ покрывает payload-колонки → не давал
-- Index Only Scan; этот — covering, поэтому не дубль.
-- ОГРАНИЧЕНИЕ: запрос всё равно агрегирует ВСЕ лоты ВСЕХ маппингов (не только
-- ~245 конкурентов в 3км). Полноценный фикс — переписать obj_pricing с
-- пушдауном domrf_obj_id IN (nearby) — это backend-engineer (правка SQL в
-- parcels.py), вне scope index-аудита. Индекс — выигрыш «здесь и сейчас» без
-- правки кода. Heap Fetches ~64k остаются (visibility map тёплый после VACUUM;
-- objective_lots dead-tup 1.25% — well-vacuumed).
--
-- ЧТО НЕ ДОБАВЛЕНО (доказанно не нужно):
-- • rosreestr_deals (market_trend, parcels.py:2211): warm ~254 ms, уже partition-pruned
-- (5 subplans removed) + per-partition BitmapAnd по 3 индексам. Композитный
-- (region_code,doc_type,realestate_type_code,period_start_date) пришлось бы строить на
-- ~20+ партициях, каждая ACCESS EXCLUSIVE на ПИШУЩЕЙСЯ таблице → высокий lock-риск,
-- несоразмерный ~2x-выигрышу. Партиционированную таблицу под индекс-аудит не трогаем.
-- • pipeline-блок (parcels.py:1663): warm ~37 мс, корр. подзапрос уже идёт по
-- objective_lots_project_idx (Bitmap Index Scan). Индекс не нужен.
-- • ekburg_construction_permits (LEFT(cadastral_number,…) prefix): уже покрыт
-- idx_ekburg_permits_cad_cpat (text_pattern_ops).
-- • geom-resolution UNION (parcels.py:1334): 15 мс, всё по PK index scan.
-- • district_price_block (parcels.py:2123): уже покрыт idx_objective_lots_district_price (153).
--
-- LOCK-ПРОФИЛЬ: обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE на таблицу — блокирует
-- ТОЛЬКО writes (loaders/objective_etl/NSPD on-demand fetch), читателей analyze НЕ
-- блокирует. CONCURRENTLY здесь невозможен: deploy.yml гоняет файл в транзакции
-- (BEGIN/COMMIT), а CREATE INDEX CONCURRENTLY в tx запрещён. Суммарный build ~2.2 c
-- (74 МБ objective_lots — доминирует, как и в 153 на той же таблице). Приемлемо в
-- deploy-окне (тот же класс риска, что 153/87/97). Не запускать одновременно с тяжёлым ETL.
--
-- Dependencies: osm_poi_ekb, osm_noise_sources_ekb, cad_buildings, objective_lots,
-- objective_complex_mapping (все существуют). PostgreSQL 16.4 + PostGIS 3.4.
-- Idempotent: CREATE INDEX IF NOT EXISTS (re-apply безопасен).
-- Applied: автоматически через deploy.yml в NN-порядке (трекер _schema_migrations).
-- Backend code change НЕ требуется — индексы прозрачны для существующих запросов.
BEGIN;
-- 1) POI в 1км (analyze сек.3) — geography-предикат ST_DWithin(geom::geography,…,1000).
CREATE INDEX IF NOT EXISTS idx_osm_poi_ekb_geom_geog
ON osm_poi_ekb USING gist ((geom::geography));
COMMENT ON INDEX idx_osm_poi_ekb_geom_geog IS
'Geography GIST для analyze POI-блока (#157): ST_DWithin(geom::geography,…,1000м). '
'Плоский osm_poi_ekb_geom_gist не обслуживает geography-cast → был Seq Scan ~36мс, '
'стал Index Scan ~0.2мс. См. parcels.py:1488.';
-- 2) Шумовые источники в 2км (analyze сек.7/9c/9d) — geography ST_DWithin, 4× на analyze.
CREATE INDEX IF NOT EXISTS idx_osm_noise_sources_ekb_geom_geog
ON osm_noise_sources_ekb USING gist ((geom::geography));
COMMENT ON INDEX idx_osm_noise_sources_ekb_geom_geog IS
'Geography GIST для analyze noise/hydro/utility-блоков (#157): '
'ST_DWithin(geom::geography,…,2000м), 4 запроса на analyze. Был Parallel Seq Scan '
'~92мс каждый, стал Index Scan ~3.8мс. См. parcels.py:1773/1840/1889.';
-- 3) Соседние здания в 100м (_neighbors_summary) — geography ST_DWithin на 88МБ-таблице.
CREATE INDEX IF NOT EXISTS idx_cad_buildings_geom_geog
ON cad_buildings USING gist ((geom::geography));
COMMENT ON INDEX idx_cad_buildings_geom_geog IS
'Geography GIST для analyze _neighbors_summary (#157): ST_DWithin(geom::geography,…,100м). '
'Плоский cad_buildings_geom_gist обслуживает ST_Intersects(geom,…) (overlap_rows), но не '
'geography-cast → был Parallel Seq Scan ~314мс, стал Index Scan ~0.3мс. См. parcels.py:612.';
-- 4) Covering-index для obj_pricing competitor-агрегации (analyze сек.5).
CREATE INDEX IF NOT EXISTS idx_objective_lots_pname_pricing
ON objective_lots (project_name)
INCLUDE (price_per_m2_rub, area_pd, is_sold);
COMMENT ON INDEX idx_objective_lots_pname_pricing IS
'Covering-index для analyze competitor obj_pricing (#157): агрегаты по '
'project_name = objective_complex_name через Index Only Scan (286 проб), без '
'Parallel Seq Scan ~1.7ГБ. Существующий objective_lots_project_idx без payload не '
'давал Index Only Scan. Long-term: пушдаун domrf_obj_id IN(nearby) в SQL (backend). '
'См. parcels.py:1614.';
COMMIT;

View file

@ -409,36 +409,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/parcels/{cad_num}/competitors-parking": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Get Parcel Competitors Parking
* @description parking_ratio top-N конкурентов (#96 wiring) ЛЕНИВЫЙ путь, вне analyze.
*
* Резолвит top-3 конкурента (relevance_weight DESC) domrfcad_buildings через
* PostGIS geom-match, затем on-demand НСПД premises-lookup parking_ratio.
* Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы
* секунды в хот-путь и убил p95 фронт дёргает этот эндпоинт лениво после
* отрисовки списка конкурентов.
*
* sync `def` (не async): premises_lookup изолирует НСПД-async через
* asyncio.run() его нельзя вызвать из async-handler (running loop). FastAPI
* исполняет sync-handler в threadpool, не блокируя event loop.
*/
post: operations["get_parcel_competitors_parking_api_v1_parcels__cad_num__competitors_parking_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/parcels/{cad_num}/best-layouts": { "/api/v1/parcels/{cad_num}/best-layouts": {
parameters: { parameters: {
query?: never; query?: never;
@ -3070,8 +3040,6 @@ export interface components {
velocity_per_month: number; velocity_per_month: number;
/** Avg Price Per M2 */ /** Avg Price Per M2 */
avg_price_per_m2: number | null; avg_price_per_m2: number | null;
/** Price Source */
price_source?: string | null;
/** Is Active */ /** Is Active */
is_active: boolean; is_active: boolean;
/** Relevance Weight */ /** Relevance Weight */
@ -3081,44 +3049,6 @@ export interface components {
[key: string]: number; [key: string]: number;
} | null; } | null;
}; };
/**
* CompetitorParking
* @description parking_ratio одного конкурента (#96). obj_id связывает с Competitor.
*
* matched_cad_num здание cad_buildings, на которое сматчился domrf-центроид
* (None если geom-match не нашёл здание в радиусе). parking_ratio /
* parking_count / flats_count из НСПД tab-group (None если здание не
* сматчилось ИЛИ НСПД недоступен graceful, НЕ нулевой паркинг).
* match_distance_m расстояние domrf-центроид footprint (диагностика).
*/
CompetitorParking: {
/** Obj Id */
obj_id: number;
/** Matched Cad Num */
matched_cad_num?: string | null;
/** Match Distance M */
match_distance_m?: number | null;
/** Parking Ratio */
parking_ratio?: number | null;
/** Parking Count */
parking_count?: number | null;
/** Flats Count */
flats_count?: number | null;
};
/**
* CompetitorsParkingResponse
* @description Ответ ленивого /{cad}/competitors-parking (#96).
*
* items по одному CompetitorParking на top-N конкурентов (по relevance_weight
* DESC из get_competitors). matched_count сколько реально получили
* parking_ratio (диагностика покрытия для фронта / мониторинга).
*/
CompetitorsParkingResponse: {
/** Items */
items: components["schemas"]["CompetitorParking"][];
/** Matched Count */
matched_count: number;
};
/** CompetitorsRequest */ /** CompetitorsRequest */
CompetitorsRequest: { CompetitorsRequest: {
/** /**
@ -5122,41 +5052,6 @@ export interface operations {
}; };
}; };
}; };
get_parcel_competitors_parking_api_v1_parcels__cad_num__competitors_parking_post: {
parameters: {
query?: never;
header?: never;
path: {
cad_num: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CompetitorsRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CompetitorsParkingResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_parcel_best_layouts_api_v1_parcels__cad_num__best_layouts_post: { get_parcel_best_layouts_api_v1_parcels__cad_num__best_layouts_post: {
parameters: { parameters: {
query?: never; query?: never;

View file

@ -1470,8 +1470,7 @@ async def scrape_yandex_newbuilding(
URL: /{city}/kupit/novostrojka/<slug>-<id>/ URL: /{city}/kupit/novostrojka/<slug>-<id>/
""" """
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен async with YandexNewbuildingScraper() as scraper:
scraper = YandexNewbuildingScraper()
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city) result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
if result is None: if result is None:
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}") raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
@ -1489,91 +1488,6 @@ async def scrape_yandex_newbuilding(
) )
class YandexNewbuildingSweepStartRequest(BaseModel):
limit: int = Field(default=5, ge=1, le=200)
request_delay_sec: float = Field(default=8.0, ge=3.0, le=30.0)
force: bool = False
city: str = Field(default="ekaterinburg", min_length=1, max_length=64)
class YandexNewbuildingSweepStartResponse(BaseModel):
run_id: int
status: str
limit: int
detail: str
@router.post("/scrape/yandex-newbuilding-sweep", response_model=YandexNewbuildingSweepStartResponse)
async def start_yandex_newbuilding_sweep(
payload: YandexNewbuildingSweepStartRequest,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
) -> YandexNewbuildingSweepStartResponse:
"""Запустить Yandex newbuilding enrichment sweep в background (#974). Returns run_id.
Один прогон: SELECT pending yandex_realty_nb houses resolve slug (если NULL)
fetch_jk через BrowserFetcher UPSERT market.yandex_jk_enrichment.
Shipped DORMANT (scheduler seed enabled=false). Этот endpoint для ручного запуска.
Coop cancel через DELETE scrape_runs или cancel endpoint.
"""
from app.tasks.yandex_newbuilding_sweep import enrich_yandex_newbuilding_sweep
run_id = runs_mod.create_run(db, source="yandex_newbuilding_sweep", params=payload.model_dump())
async def _sweep_task() -> None:
sweep_db = SessionLocal()
try:
result = await enrich_yandex_newbuilding_sweep(
sweep_db,
limit=payload.limit,
force=payload.force,
request_delay_sec=payload.request_delay_sec,
city=payload.city,
)
runs_mod.mark_done(sweep_db, run_id, result.to_dict())
except Exception:
logger.exception("yandex-nb-sweep background task run_id=%d crashed", run_id)
try:
runs_mod.mark_failed(sweep_db, run_id, "crashed", {})
except Exception:
pass
finally:
sweep_db.close()
background_tasks.add_task(_sweep_task)
logger.info("yandex-nb-sweep queued run_id=%d params=%s", run_id, payload.model_dump())
return YandexNewbuildingSweepStartResponse(
run_id=run_id,
status="running",
limit=payload.limit,
detail=f"yandex_newbuilding_sweep started: limit={payload.limit} city={payload.city}",
)
@router.get("/scrape/yandex-newbuilding-sweep/runs", response_model=list[ScrapeRunRow])
def list_yandex_newbuilding_sweep_runs(
db: Annotated[Session, Depends(get_db)],
limit: int = 10,
) -> list[ScrapeRunRow]:
"""Список последних N yandex newbuilding sweep runs (для UI polling). Default limit=10."""
rows = runs_mod.list_recent(db, source="yandex_newbuilding_sweep", limit=limit)
return [
ScrapeRunRow(
run_id=r["run_id"],
source=r["source"],
status=r["status"],
params=r.get("params"),
counters=r.get("counters"),
error=r.get("error"),
started_at=r["started_at"].isoformat() if r.get("started_at") else None,
finished_at=r["finished_at"].isoformat() if r.get("finished_at") else None,
heartbeat_at=r["heartbeat_at"].isoformat() if r.get("heartbeat_at") else None,
)
for r in rows
]
class YandexValuationTriggerResp(BaseModel): class YandexValuationTriggerResp(BaseModel):
ok: bool ok: bool
address: str address: str

View file

@ -45,12 +45,6 @@ Sources:
Idempotent: skips houses already enriched, per-house SAVEPOINT, so Idempotent: skips houses already enriched, per-house SAVEPOINT, so
each fire drains the next `limit` pending houses; window 00:00-01:00 each fire drains the next `limit` pending houses; window 00:00-01:00
UTC = 03:00-04:00 МСК, before the 01:00+ UTC sweep block) UTC = 03:00-04:00 МСК, before the 01:00+ UTC sweep block)
- yandex_newbuilding_sweep enrich_yandex_newbuilding_sweep
(tasks/yandex_newbuilding_sweep.py, #974; enrichment ЖК в
market.yandex_jk_enrichment через BrowserFetcher; shipped DORMANT
enable manually after deploy verified; window 02:00-05:00 UTC)
- cian_city_sweep run_cian_city_sweep (newbuilding Phase 4, #973; shipped DORMANT —
proxy dead, enable manually after proxy restored)
""" """
from __future__ import annotations from __future__ import annotations
@ -69,7 +63,6 @@ from app.core.db import SessionLocal
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
from app.services.scrape_pipeline import ( from app.services.scrape_pipeline import (
run_avito_city_sweep, run_avito_city_sweep,
run_cian_city_sweep,
run_n1_city_sweep, run_n1_city_sweep,
run_yandex_city_sweep, run_yandex_city_sweep,
) )
@ -414,41 +407,6 @@ async def trigger_cian_backfill_run(db: Session, schedule_row: dict[str, Any]) -
return run_id return run_id
async def trigger_cian_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
"""Создать scrape_runs + launch run_cian_city_sweep в asyncio.create_task (#973).
Несёт Phase 4 newbuilding-enrichment (enrich_houses). Shipped DORMANT seed
enabled=false (107), включается оператором вручную после восстановления прокси.
Returns run_id (или None если skip есть running run).
"""
run_id = _claim_run(db, schedule_row)
if run_id is None:
return None
params = schedule_row.get("default_params") or {}
async def _run() -> None:
run_db = SessionLocal()
try:
await run_cian_city_sweep(
run_db,
run_id=run_id,
pages_per_anchor=int(params.get("pages_per_anchor", 3)),
request_delay_sec=float(params.get("request_delay_sec", 5.0)),
radius_m=int(params.get("radius_m", 1500)),
detail_top_n=int(params.get("detail_top_n", 10)),
enrich_houses=bool(params.get("enrich_houses", True)),
)
except Exception:
logger.exception("scheduler: run_cian_city_sweep crashed run_id=%d", run_id)
finally:
run_db.close()
task = asyncio.create_task(_run())
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
logger.info("scheduler: triggered cian_city_sweep run_id=%d", run_id)
return run_id
async def _execute_cian_backfill( async def _execute_cian_backfill(
db: Session, db: Session,
*, *,
@ -501,7 +459,8 @@ async def _execute_cian_backfill(
} }
runs_mod.mark_done(db, run_id, counters) runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info(
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", "scheduler: cian_history_backfill run_id=%d done — "
"listings=%d/%d houses=%d/%d %.1fs",
run_id, run_id,
result.listings_succeeded, result.listings_succeeded,
result.listings_total, result.listings_total,
@ -810,55 +769,6 @@ async def trigger_newbuilding_enrich_run(db: Session, schedule_row: dict[str, An
return run_id return run_id
async def trigger_yandex_newbuilding_sweep_run(
db: Session, schedule_row: dict[str, Any]
) -> int | None:
"""Создать scrape_runs + launch enrich_yandex_newbuilding_sweep в asyncio.create_task (#974).
Nightly enrichment sweep: SELECT pending yandex_realty_nb houses resolve slug
(BrowserFetcher SERP) fetch_jk (BrowserFetcher) UPSERT market.yandex_jk_enrichment.
Shipped DORMANT (seed 106, enabled=false). Включается оператором вручную:
UPDATE scrape_schedules SET enabled = true WHERE source = 'yandex_newbuilding_sweep';
Mirrors trigger_newbuilding_enrich_run: claim run create_task mark_done/failed
делегируется tasks/yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep.
Returns run_id или None (skip already running).
"""
run_id = _claim_run(db, schedule_row)
if run_id is None:
return None
params = schedule_row.get("default_params") or {}
async def _run() -> None:
run_db = SessionLocal()
try:
from app.tasks.yandex_newbuilding_sweep import enrich_yandex_newbuilding_sweep
result = await enrich_yandex_newbuilding_sweep(
run_db,
limit=int(params.get("limit", 5)),
request_delay_sec=float(params.get("request_delay_sec", 8.0)),
city=str(params.get("city", "ekaterinburg")),
)
runs_mod.mark_done(run_db, run_id, result.to_dict())
except Exception:
logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id)
try:
runs_mod.mark_failed(run_db, run_id, "crashed", {})
except Exception:
pass
finally:
run_db.close()
task = asyncio.create_task(_run())
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
logger.info("scheduler: triggered yandex_newbuilding_sweep run_id=%d", run_id)
return run_id
async def trigger_asking_to_sold_ratio_run(db: Session, schedule_row: dict[str, Any]) -> int | None: async def trigger_asking_to_sold_ratio_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
"""Создать scrape_runs + launch recompute_asking_to_sold_ratios в executor (sync DB-only task). """Создать scrape_runs + launch recompute_asking_to_sold_ratios в executor (sync DB-only task).
@ -1169,8 +1079,6 @@ async def scheduler_loop() -> None:
await trigger_n1_city_sweep_run(db, sch) await trigger_n1_city_sweep_run(db, sch)
elif source == "cian_history_backfill": elif source == "cian_history_backfill":
await trigger_cian_backfill_run(db, sch) await trigger_cian_backfill_run(db, sch)
elif source == "cian_city_sweep":
await trigger_cian_city_sweep_run(db, sch)
elif source == "rosreestr_dkp_import": elif source == "rosreestr_dkp_import":
await trigger_rosreestr_dkp_run(db, sch) await trigger_rosreestr_dkp_run(db, sch)
elif source == "listing_source_snapshot": elif source == "listing_source_snapshot":
@ -1189,8 +1097,6 @@ async def scheduler_loop() -> None:
await trigger_rosreestr_quarter_poll_run(db, sch) await trigger_rosreestr_quarter_poll_run(db, sch)
elif source == "newbuilding_enrich": elif source == "newbuilding_enrich":
await trigger_newbuilding_enrich_run(db, sch) await trigger_newbuilding_enrich_run(db, sch)
elif source == "yandex_newbuilding_sweep":
await trigger_yandex_newbuilding_sweep_run(db, sch)
else: else:
logger.warning("scheduler: unknown source=%s, skip", source) logger.warning("scheduler: unknown source=%s, skip", source)
finally: finally:

View file

@ -25,7 +25,6 @@ from typing import Any
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped] from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
from app.core.config import settings from app.core.config import settings
from app.services.scrapers.browser_fetcher import BrowserFetcher
from app.services.scrapers.cian_state_parser import extract_all_states, extract_state from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -121,24 +120,39 @@ class NewbuildingEnrichment:
async def fetch_newbuilding( async def fetch_newbuilding(
zhk_url: str, zhk_url: str,
*, *,
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch session: AsyncSession | None = None,
) -> NewbuildingEnrichment | None: ) -> NewbuildingEnrichment | None:
"""Fetch ЖК catalog page и extract все containers. """Fetch ЖК catalog page и extract все containers.
HTML страница ЖК теперь тянется через BrowserFetcher (camoufox), потому что
curl_cffi получает страницу без initialState под анти-ботом Cian (#972).
tradein-browser верифицирован живым: 1.17 MB, initialState присутствует.
Args: Args:
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/' zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
session: зарезервирован для обратной совместимости с вызывающими; session: optional shared curl_cffi session (caller owns lifecycle)
больше не используется для fetch страницы ЖК. Resolve-функции
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
Returns: NewbuildingEnrichment, or None если fetch / parse failed. Returns: NewbuildingEnrichment, or None если fetch / parse failed.
""" """
async with BrowserFetcher() as browser: close_session = False
html = await browser.fetch(zhk_url) if session is None:
# Mobile proxy wiring (#806 follow-up): Cian ЖК страница — datacenter-IP бан.
# proxy=None → прямое подключение (dev без прокси).
_proxy_url = settings.cian_proxy_url
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
session = AsyncSession(
impersonate="chrome120",
timeout=30.0,
proxies=_proxies,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
},
)
close_session = True
try:
resp = await session.get(zhk_url, allow_redirects=True)
if resp.status_code != 200:
logger.warning("Cian newbuilding fetch %s → HTTP %d", zhk_url, resp.status_code)
return None
html = resp.text
mfe = "newbuilding-card-desktop-fichering-frontend" mfe = "newbuilding-card-desktop-fichering-frontend"
@ -213,6 +227,9 @@ async def fetch_newbuilding(
len(result.nested_offers), len(result.nested_offers),
) )
return result return result
finally:
if close_session:
await session.close()
# ---- private extractors ---- # ---- private extractors ----

View file

@ -3,12 +3,6 @@
URL pattern: /{city}/kupit/novostrojka/<slug>-<id>/ URL pattern: /{city}/kupit/novostrojka/<slug>-<id>/
Reference target: ЖК Татлин (id=1592987, slug=tatlin) comfort+, June 2023, Reference target: ЖК Татлин (id=1592987, slug=tatlin) comfort+, June 2023,
PRINZIP, rating 4.3, 1505 ratings, 353 text reviews, coords (56.855312, 60.576668). PRINZIP, rating 4.3, 1505 ratings, 353 text reviews, coords (56.855312, 60.576668).
Fetch strategy (#974):
- Все network-запросы (ЖК-лендинг + SERP slug-resolve) идут через BrowserFetcher
(tradein-browser camoufox), а НЕ через httpx/_http_get.
- Yandex Realty JS-heavy / anti-bot; curl_cffi / httpx не получают данные.
- BrowserFetcher.fetch(url) str (полный HTML); вызывающий код парсит через parse().
""" """
from __future__ import annotations from __future__ import annotations
@ -104,12 +98,6 @@ RE_METRO_INLINE = re.compile(
r"([А-ЯЁ][А-Яа-яё\s-]{2,30}?)\s+(\d+)\s*мин", r"([А-ЯЁ][А-Яа-яё\s-]{2,30}?)\s+(\d+)\s*мин",
) )
# Slug из href: /{city}/kupit/novostrojka/<slug>-<id>/
# Используется в resolve_yandex_jk_slug для извлечения slug из SERP href.
_JK_SLUG_RE = re.compile(
r"/(?:ekaterinburg|moskva|spb|[a-z-]+)/kupit/novostrojka/([a-z0-9-]+)-(\d+)/"
)
# Ekb-specific coord ranges (extend per-city later) # Ekb-specific coord ranges (extend per-city later)
LAT_RANGE = (55.5, 57.5) LAT_RANGE = (55.5, 57.5)
LON_RANGE = (59.5, 61.5) LON_RANGE = (59.5, 61.5)
@ -141,7 +129,9 @@ class YandexNewbuildingScraper(BaseScraper):
super().__init__() super().__init__()
self.request_delay_sec = get_scraper_delay(self.name) self.request_delay_sec = get_scraper_delay(self.name)
async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list: # type: ignore[override] async def fetch_around(
self, lat: float, lon: float, radius_m: int = 1000
) -> list: # type: ignore[override]
raise NotImplementedError( raise NotImplementedError(
"YandexNewbuildingScraper is JK-slug-based; use fetch_jk(slug, id) instead." "YandexNewbuildingScraper is JK-slug-based; use fetch_jk(slug, id) instead."
) )
@ -149,31 +139,22 @@ class YandexNewbuildingScraper(BaseScraper):
async def fetch_jk( async def fetch_jk(
self, jk_slug: str, jk_id: str, city: str = "ekaterinburg" self, jk_slug: str, jk_id: str, city: str = "ekaterinburg"
) -> YandexNewbuildingInfo | None: ) -> YandexNewbuildingInfo | None:
"""Загрузить ЖК-лендинг через BrowserFetcher и распарсить.
Yandex Realty JS/anti-bot: curl/httpx не получают данные. Запрос идёт
через tradein-browser (camoufox) контейнер единственный рабочий путь (#974).
"""
from app.services.scrapers.browser_fetcher import BrowserFetcher
url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/" url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/"
try: try:
async with BrowserFetcher() as fetcher: response = await self._http_get(url)
html = await fetcher.fetch(url)
except Exception: except Exception:
logger.exception("yandex nb browser fetch failed: %s", url) logger.exception("yandex nb fetch failed: %s", url)
return None return None
if not html or len(html) < 500: if response.status_code != 200:
logger.warning( logger.warning("yandex nb returned %d for %s", response.status_code, url)
"yandex nb browser returned empty/tiny HTML (%d bytes): %s",
len(html) if html else 0,
url,
)
return None return None
result = self.parse(html, jk_slug=jk_slug, jk_id=jk_id, source_url=url) result = self.parse(response.text, jk_slug=jk_slug, jk_id=jk_id, source_url=url)
await self.sleep_between_requests()
return result return result
def parse(self, html: str, jk_slug: str, jk_id: str, source_url: str) -> YandexNewbuildingInfo: def parse(
self, html: str, jk_slug: str, jk_id: str, source_url: str
) -> YandexNewbuildingInfo:
tree = HTMLParser(html) tree = HTMLParser(html)
body = tree.body body = tree.body
body_text = body.text(strip=True) if body else "" body_text = body.text(strip=True) if body else ""
@ -227,7 +208,9 @@ class YandexNewbuildingScraper(BaseScraper):
href = dev_link.attributes.get("href", "") href = dev_link.attributes.get("href", "")
if href: if href:
developer_url = ( developer_url = (
href if href.startswith("http") else f"https://realty.yandex.ru{href}" href
if href.startswith("http")
else f"https://realty.yandex.ru{href}"
) )
# Developer's other JKs # Developer's other JKs
@ -286,60 +269,6 @@ class YandexNewbuildingScraper(BaseScraper):
) )
# ── Slug resolution via SERP ──────────────────────────────────────────────────
async def resolve_yandex_jk_slug(
jk_id: str,
city: str = "ekaterinburg",
) -> str | None:
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id) через SERP.
Стратегия (#974 — зеркало resolve_cian_zhk_url_via_search):
1. Запросить поисковую страницу Yandex Realty через BrowserFetcher.
URL: /ekaterinburg/kupit/novostrojka/?siteId=<jk_id>
2. В HTML найти первую ссылку вида /<city>/kupit/novostrojka/<slug>-<jk_id>/
через regex _JK_SLUG_RE.
3. Вернуть slug или None при любой ошибке.
Caller несёт ответственность за anti-bot sleep (зеркало cian_newbuilding.py).
BrowserFetcher обязателен Yandex Realty JS/anti-bot, httpx/curl не работают.
Returns:
slug (str без id-суффикса), или None при ошибке / не найден.
"""
from app.services.scrapers.browser_fetcher import BrowserFetcher
# Yandex Realty SERP: фильтр по siteId → первый результат = нужный ЖК.
# Альтернативный путь через Яндекс Поиск (web SERP) менее надёжен из-за
# вариативности разметки. Прямой realty.yandex.ru SERP — стабильнее.
serp_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/?siteId={jk_id}"
try:
async with BrowserFetcher() as fetcher:
html = await fetcher.fetch(serp_url)
except Exception as exc:
logger.warning("resolve_yandex_jk_slug jk_id=%s browser fetch failed: %s", jk_id, exc)
return None
if not html:
logger.warning("resolve_yandex_jk_slug jk_id=%s: empty HTML from browser", jk_id)
return None
# Ищем ссылку вида /{city}/kupit/novostrojka/<slug>-<id>/
# Ограничиваем: id в ссылке должен совпадать с искомым jk_id.
for m in _JK_SLUG_RE.finditer(html):
if m.group(2) == str(jk_id):
slug = m.group(1)
logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug)
return slug
logger.warning(
"resolve_yandex_jk_slug jk_id=%s: no matching slug in SERP HTML (markup drift?)",
jk_id,
)
return None
# ── helpers ─────────────────────────────────────────────────────────────────── # ── helpers ───────────────────────────────────────────────────────────────────
@ -425,5 +354,4 @@ __all__ = [
"JKMetroStation", "JKMetroStation",
"YandexNewbuildingInfo", "YandexNewbuildingInfo",
"YandexNewbuildingScraper", "YandexNewbuildingScraper",
"resolve_yandex_jk_slug",
] ]

View file

@ -1,423 +0,0 @@
"""Yandex Newbuilding sweep-task (#974): enrichment ЖК в market.yandex_jk_enrichment.
Context
-------
309 house_sources строк с ext_source='yandex_realty_nb' имеют ext_id (= yandex_jk_id),
но у большинства NULL yandex_jk_slug и не заполнены данные в market.yandex_jk_enrichment.
Для каждого дома цепочка:
1. resolve_yandex_jk_slug(jk_id) slug (через BrowserFetcher SERP)
Результат сохраняется в houses.yandex_jk_slug (SAVEPOINT) resumable.
2. YandexNewbuildingScraper.fetch_jk(slug, jk_id) YandexNewbuildingInfo
Через BrowserFetcher (tradein-browser camoufox) единственный рабочий путь.
3. UPSERT в market.yandex_jk_enrichment (ON CONFLICT (ext_id) DO UPDATE).
4. UPDATE houses.yandex_jk_id WHERE yandex_jk_slug = slug.
Idempotency
-----------
- force=False: пропускает дома у которых уже есть строка в market.yandex_jk_enrichment.
- UPSERT через ON CONFLICT (ext_id) DO UPDATE безопасен при повторном запуске.
- SAVEPOINT per house один сбойный fetch не прерывает батч.
- dry_run: подсчёт популяции без fetch.
Anti-bot / resilience
---------------------
- request_delay_sec (default из get_scraper_delay('yandex_realty_nb')) с ±20% jitter.
- SAVEPOINT per house: resolve-фаза и enrich-фаза отдельные savepoint'ы.
Execution
---------
- Чистый async callable NO Celery. Триггер: admin endpoint или scheduler.
- psycopg v3 conventions: CAST(:x AS type) в SQL (никогда ::`); logger (никогда print).
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
from dataclasses import dataclass, field, fields
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scraper_settings import get_scraper_delay
logger = logging.getLogger(__name__)
__all__ = [
"YandexNewbuildingSweepResult",
"count_yandex_newbuilding_houses",
"enrich_yandex_newbuilding_sweep",
]
_ENRICHMENT_SOURCE = "yandex_realty_nb"
@dataclass
class YandexNewbuildingSweepResult:
"""Per-run счётчики yandex newbuilding sweep."""
# Sizing (независимо от limit).
total: int = 0 # дома с ext_source='yandex_realty_nb'
fetchable: int = 0 # из них: с yandex_jk_slug ИЛИ с ext_id
pending: int = 0 # fetchable И ещё не обогащены (force=False)
# Processing (ограничен limit).
processed: int = 0
skipped_already_enriched: int = 0
succeeded: int = 0
resolved_slug: int = 0 # ext_id → slug разрезолвлен + сохранён
failed_resolve: int = 0 # slug не удалось разрезолвить
failed_fetch: int = 0 # fetch_jk вернул None / упал
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
duration_sec: float = field(default=0.0)
def to_dict(self) -> dict[str, int | float]:
return {f.name: getattr(self, f.name) for f in fields(self)}
# ── SQL ────────────────────────────────────────────────────────────────────────
_COUNT_TOTAL = """
SELECT COUNT(DISTINCT h.id)
FROM houses h
JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'yandex_realty_nb'
"""
_COUNT_FETCHABLE = """
SELECT COUNT(DISTINCT h.id)
FROM houses h
JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'yandex_realty_nb'
AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL)
"""
_COUNT_PENDING = """
SELECT COUNT(DISTINCT h.id)
FROM houses h
JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'yandex_realty_nb'
AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL)
AND NOT EXISTS (
SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id
)
"""
# DISTINCT ON (h.id) — у дома может быть >1 house_sources строки; берём любую ext_id.
_SELECT_PENDING_HOUSES = """
SELECT DISTINCT ON (h.id)
h.id AS house_id,
h.yandex_jk_slug,
h.yandex_jk_id,
hs.ext_id
FROM houses h
JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'yandex_realty_nb'
AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL)
AND (
CAST(:force AS boolean) = TRUE
OR NOT EXISTS (
SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id
)
)
ORDER BY h.id, hs.ext_id NULLS LAST
LIMIT :lim
"""
_UPDATE_SLUG = """
UPDATE houses
SET yandex_jk_slug = CAST(:slug AS text)
WHERE id = CAST(:hid AS bigint)
"""
_UPSERT_ENRICHMENT = """
INSERT INTO market.yandex_jk_enrichment (
ext_id, name, developer_name, address,
lat, lon, rating, ratings_count, text_reviews_count,
raw_payload, updated_at
) VALUES (
CAST(:ext_id AS text),
CAST(:name AS text),
CAST(:developer_name AS text),
CAST(:address AS text),
CAST(:lat AS float8),
CAST(:lon AS float8),
CAST(:rating AS float4),
CAST(:ratings_count AS int),
CAST(:text_reviews_count AS int),
CAST(:raw_payload AS jsonb),
NOW()
)
ON CONFLICT (ext_id) DO UPDATE SET
name = EXCLUDED.name,
developer_name = EXCLUDED.developer_name,
address = EXCLUDED.address,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
rating = EXCLUDED.rating,
ratings_count = EXCLUDED.ratings_count,
text_reviews_count = EXCLUDED.text_reviews_count,
raw_payload = EXCLUDED.raw_payload,
updated_at = NOW()
"""
def count_yandex_newbuilding_houses(db: Session) -> dict[str, int]:
"""Sizing: сколько yandex_realty_nb домов существует / fetchable / pending.
Дешёвый (3 COUNT) безопасен для dry_run.
"""
total = int(db.execute(text(_COUNT_TOTAL)).scalar_one())
fetchable = int(db.execute(text(_COUNT_FETCHABLE)).scalar_one())
pending = int(db.execute(text(_COUNT_PENDING)).scalar_one())
return {"total": total, "fetchable": fetchable, "pending": pending}
async def enrich_yandex_newbuilding_sweep(
db: Session,
*,
limit: int = 5,
force: bool = False,
request_delay_sec: float | None = None,
dry_run: bool = False,
city: str = "ekaterinburg",
) -> YandexNewbuildingSweepResult:
"""Enrichment sweep для yandex_realty_nb домов → market.yandex_jk_enrichment.
Per house:
- resolve slug если NULL (persist в houses.yandex_jk_slug, SAVEPOINT)
- fetch_jk через BrowserFetcher
- UPSERT market.yandex_jk_enrichment (ON CONFLICT ext_id)
- UPDATE houses.yandex_jk_id (если изменился)
Args:
db: tradein-mvp SQLAlchemy session (НЕ gendesign DB).
limit: max домов за один прогон. Default 5 bounded start.
force: переобработать уже обогащённые (UPSERT-safe). Default False.
request_delay_sec: пауза между домами. None get_scraper_delay('yandex_realty_nb').
dry_run: считать популяцию без fetch/write.
city: город для Yandex Realty URL (ekaterinburg).
Returns:
YandexNewbuildingSweepResult со счётчиками.
"""
from app.services.scrapers.yandex_newbuilding import (
YandexNewbuildingScraper,
resolve_yandex_jk_slug,
)
result = YandexNewbuildingSweepResult()
t0 = time.time()
delay = (
request_delay_sec
if request_delay_sec is not None
else get_scraper_delay(_ENRICHMENT_SOURCE)
)
# ── Sizing ──────────────────────────────────────────────────────────────
sizing = count_yandex_newbuilding_houses(db)
result.total = sizing["total"]
result.fetchable = sizing["fetchable"]
result.pending = sizing["pending"]
rows = db.execute(text(_SELECT_PENDING_HOUSES), {"force": force, "lim": limit}).mappings().all()
logger.info(
"yandex-nb-sweep: total=%d fetchable=%d pending=%d; "
"selected=%d (limit=%d force=%s dry_run=%s delay=%.1fs city=%s)",
result.total,
result.fetchable,
result.pending,
len(rows),
limit,
force,
dry_run,
delay,
city,
)
if dry_run:
logger.info(
"dry_run: would process %d yandex_realty_nb houses (ids=%s)",
len(rows),
[r["house_id"] for r in rows],
)
result.duration_sec = time.time() - t0
return result
for idx, row in enumerate(rows):
house_id: int = int(row["house_id"])
jk_slug: str | None = row["yandex_jk_slug"]
ext_id: str | None = row["ext_id"]
result.processed += 1
# Idempotency fast-path (belt-and-braces под concurrent writer)
if not force and jk_slug:
already = db.execute(
text(
"SELECT 1 FROM market.yandex_jk_enrichment WHERE ext_id = CAST(:ext_id AS text)"
),
{"ext_id": ext_id},
).fetchone()
if already is not None:
result.skipped_already_enriched += 1
logger.debug("skip house_id=%s — already enriched ext_id=%s", house_id, ext_id)
continue
# ── Resolve slug ─────────────────────────────────────────────────
if not jk_slug:
if not ext_id:
logger.warning("house_id=%s: нет yandex_jk_slug и нет ext_id — skip", house_id)
result.failed_resolve += 1
continue
try:
resolved = await resolve_yandex_jk_slug(ext_id, city=city)
except Exception as exc:
logger.warning(
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
house_id,
ext_id,
exc,
)
resolved = None
# Anti-bot sleep после resolve-fetch
await _sleep_with_jitter(delay, idx, len(rows), force=True)
if not resolved:
logger.warning("slug unresolved house_id=%s ext_id=%s — skip", house_id, ext_id)
result.failed_resolve += 1
continue
# Persist slug под SAVEPOINT
sp = db.begin_nested()
try:
db.execute(text(_UPDATE_SLUG), {"slug": resolved, "hid": house_id})
sp.commit()
db.commit()
except Exception as exc:
sp.rollback()
logger.warning(
"persist slug failed house_id=%s ext_id=%s: %s", house_id, ext_id, exc
)
result.failed_resolve += 1
continue
jk_slug = resolved
result.resolved_slug += 1
# ── Guard: ext_id обязателен для fetch_jk (формирует URL /{slug}-{id}/) ──
if not ext_id:
logger.warning(
"house_id=%s: jk_slug=%s известен, но ext_id пустой — "
"fetch_jk пропущен (пустой jk_id даёт битый URL)",
house_id,
jk_slug,
)
result.failed_fetch += 1
continue
# ── Fetch через BrowserFetcher ────────────────────────────────────
info = None
try:
scraper = YandexNewbuildingScraper()
info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city)
except Exception as exc:
logger.warning(
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
house_id,
jk_slug,
ext_id,
exc,
)
result.failed_fetch += 1
await _sleep_with_jitter(delay, idx, len(rows))
continue
if info is None:
logger.warning(
"fetch_jk returned None house_id=%s jk_slug=%s (anti-bot / parse miss?)",
house_id,
jk_slug,
)
result.failed_fetch += 1
await _sleep_with_jitter(delay, idx, len(rows))
continue
# ── UPSERT market.yandex_jk_enrichment под SAVEPOINT ─────────────
sp = db.begin_nested()
try:
db.execute(
text(_UPSERT_ENRICHMENT),
{
"ext_id": info.ext_id,
"name": info.name,
"developer_name": info.developer_name,
"address": info.address,
"lat": info.lat,
"lon": info.lon,
"rating": info.rating,
"ratings_count": info.ratings_count,
"text_reviews_count": info.text_reviews_count,
"raw_payload": json.dumps(info.raw_payload or {}, ensure_ascii=False),
},
)
sp.commit()
db.commit()
result.rows_inserted += 1
result.succeeded += 1
logger.info(
"enriched house_id=%s ext_id=%s slug=%s name=%r",
house_id,
info.ext_id,
jk_slug,
info.name,
)
except Exception as exc:
sp.rollback()
logger.warning(
"UPSERT yandex_jk_enrichment failed house_id=%s ext_id=%s: %s",
house_id,
ext_id,
exc,
)
try:
db.rollback()
except Exception as rb_exc:
logger.warning("rollback failed house_id=%s: %s", house_id, rb_exc)
await _sleep_with_jitter(delay, idx, len(rows))
result.duration_sec = time.time() - t0
logger.info(
"yandex-nb-sweep done: processed=%d ok=%d skip=%d resolved=%d "
"resolve_fail=%d fetch_fail=%d rows_inserted=%d | %.1fs",
result.processed,
result.succeeded,
result.skipped_already_enriched,
result.resolved_slug,
result.failed_resolve,
result.failed_fetch,
result.rows_inserted,
result.duration_sec,
)
return result
async def _sleep_with_jitter(delay: float, idx: int, total: int, *, force: bool = False) -> None:
"""Polite anti-bot sleep с ±20% jitter.
Пропускается после последнего элемента (нет следующего fetch) ЕСЛИ force=False.
force=True используется для resolveenrich gap внутри одного дома.
"""
if delay <= 0:
return
if not force and idx >= total - 1:
return
await asyncio.sleep(delay * random.uniform(0.8, 1.2))

View file

@ -1,82 +0,0 @@
-- 105_market_schema_yandex_enrichment.sql
-- Market-схема + таблица yandex_jk_enrichment + houses.yandex_jk_slug (#974).
--
-- Архитектурное решение (ADR):
-- Новые скрейперы пишут в схему market в СУЩЕСТВУЮЩЕМ tradein-postgres.
-- Существующие listings/houses не выносятся — market является дополнительным слоем
-- для cross-product ЖК-аналитики (Site Finder v2 / GG-форсайт).
--
-- Таблица market.yandex_jk_enrichment хранит parsed payload с Yandex Realty
-- ЖК-лендинга (slug, coords, rating, developer). Ключ — ext_id (= yandex_jk_id).
-- UPSERT через ON CONFLICT (ext_id) DO UPDATE.
--
-- houses.yandex_jk_slug: персистируется при resolve_yandex_jk_slug, используется
-- для возобновления sweep без повторного запроса к SERP.
--
-- ЗАВИСИМОСТИ:
-- 009_houses.sql (таблица houses), 031_houses_alter_yandex.sql (yandex_jk_id),
-- 101_gendesign_reader_role.sql (роль gendesign_reader).
-- Idempotent: IF NOT EXISTS + ADD COLUMN IF NOT EXISTS.
-- AUTO-APPLIED на прод при деплое через deploy-tradein.yml/_schema_migrations.
BEGIN;
-- ── Схема market ─────────────────────────────────────────────────────────────
CREATE SCHEMA IF NOT EXISTS market;
COMMENT ON SCHEMA market IS
'Cross-product ЖК-аналитика: yandex/cian newbuilding enrichment, '
'Site Finder v2 / GG-форсайт (#974). '
'Читается gendesign_reader через GRANT ниже.';
-- ── market.yandex_jk_enrichment ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS market.yandex_jk_enrichment (
id bigserial PRIMARY KEY,
ext_id text NOT NULL,
name text,
developer_name text,
address text,
lat float8,
lon float8,
rating float4,
ratings_count int,
text_reviews_count int,
raw_payload jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_market_yandex_jk_ext_id UNIQUE (ext_id)
);
COMMENT ON TABLE market.yandex_jk_enrichment IS
'Parsed payload с Yandex Realty ЖК-лендинга (#974). '
'Ключ ext_id = yandex_jk_id (string). '
'UPSERT через ON CONFLICT (ext_id) DO UPDATE.';
CREATE INDEX IF NOT EXISTS idx_market_yandex_jk_ext_id
ON market.yandex_jk_enrichment (ext_id);
-- ── houses.yandex_jk_slug ────────────────────────────────────────────────────
-- Примечание: yandex_jk_id уже существует из миграции 031_houses_alter_yandex.sql.
-- yandex_jk_slug — новый столбец, добавляем только если нет.
ALTER TABLE houses ADD COLUMN IF NOT EXISTS yandex_jk_slug text;
COMMENT ON COLUMN houses.yandex_jk_slug IS
'Yandex Realty ЖК slug из URL /{city}/kupit/novostrojka/<slug>-<id>/. '
'Персистируется sweep-таской при resolve_yandex_jk_slug (#974). '
'NULL = slug ещё не разрезолвлен.';
CREATE INDEX IF NOT EXISTS idx_houses_yandex_jk_slug
ON houses (yandex_jk_slug)
WHERE yandex_jk_slug IS NOT NULL;
-- ── Доступ для gendesign_reader ───────────────────────────────────────────────
-- Оба продукта (tradein + gendesign Site Finder v2) читают market-слой через роль
-- gendesign_reader (создана в 101_gendesign_reader_role.sql).
GRANT USAGE ON SCHEMA market TO gendesign_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA market TO gendesign_reader;
COMMIT;

View file

@ -1,53 +0,0 @@
-- 106_scrape_schedules_seed_yandex_newbuilding_sweep.sql
-- Seed row для Yandex newbuilding sweep (#974).
--
-- !!! DORMANT BY DESIGN !!! enabled = false.
-- Sweep обращается к Yandex Realty через tradein-browser (camoufox).
-- Засеян ВЫКЛЮЧЕННЫМ — включается оператором вручную:
-- UPDATE scrape_schedules SET enabled = true
-- WHERE source = 'yandex_newbuilding_sweep';
-- Зеркалит cian_city_sweep (103, тоже enabled = false, dormant).
--
-- default_params:
-- limit — дома за один прогон (старт консервативно 5).
-- request_delay_sec — пауза между ЖК (поверх browser wait).
-- city — город для Yandex Realty URL (ekaterinburg).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
--
-- yandex_newbuilding_sweep — окно 02:00-05:00 UTC (05:00-08:00 МСК).
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'yandex_newbuilding_sweep',
false, -- DORMANT (#974) — enable manually
2,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"limit": 5, "request_delay_sec": 8, "city": "ekaterinburg"}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). '
'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), '
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), '
'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), '
'yandex_address_backfill (#855, EKB pilot), '
'sber_index_pull (#887, monthly СберИндекс city-level price index), '
'rosreestr_quarter_poll (#888, monthly Rosreestr new-quarter availability check), '
'cian_city_sweep (dormant, #973), '
'yandex_newbuilding_sweep (dormant, #974).';
COMMIT;

View file

@ -1,64 +0,0 @@
-- 107_scrape_schedules_seed_cian_city_sweep.sql
-- Seed row для Cian периодического city-sweep с newbuilding Phase 4 (#973).
--
-- !!! DORMANT BY DESIGN !!! enabled = false.
-- Sweep бьёт Cian с prod-IP через proxy — proxy сейчас мёртв → anti-bot риск.
-- Capability полностью wired (run_cian_city_sweep + trigger + dispatch),
-- но schedule засеян ВЫКЛЮЧЕННЫМ. Live-поведение намеренно НЕ verified.
-- ВКЛЮЧАТЬ ВРУЧНУЮ оператором ПОСЛЕ восстановления прокси:
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep';
-- Зеркалит yandex_city_sweep (078, тоже enabled = false, dormant).
--
-- Phase 4 (newbuilding enrichment) включается через enrich_houses=true в default_params.
-- В отличие от yandex_city_sweep, cian sweep несёт detail + houses-фазы —
-- поэтому default_params включает detail_top_n и enrich_houses.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
--
-- cian_city_sweep — окно 02:00-05:00 UTC (05:00-08:00 МСК), как avito_city_sweep.
-- default_params (консервативно, под анти-бот):
-- pages_per_anchor — страниц SERP на anchor.
-- request_delay_sec — пауза между anchor'ами (поверх per-page sleep scraper'а).
-- radius_m — радиус поиска вокруг anchor'а в метрах.
-- detail_top_n — кол-во объявлений на detail-обогащение (Phase 3).
-- enrich_houses — включить Phase 4 newbuilding enrichment (true).
BEGIN;
-- next_run_at = следующее наступление night-window (tomorrow + window_start_hour).
-- При enabled=false get_due_schedules() всё равно не подхватит строку, но заполняем
-- по образцу 078: когда оператор включит schedule, next_run_at уже валидный и
-- запуск не выстрелит мгновенно (вне окна). AT TIME ZONE 'UTC' фиксирует wall-clock
-- час в UTC независимо от session TimeZone GUC.
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'cian_city_sweep',
false, -- DORMANT (#973) — enable manually after proxy restored
2,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). '
'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), '
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), '
'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), '
'yandex_address_backfill (#855, EKB pilot), '
'sber_index_pull (#887, monthly СберИндекс city-level price index), '
'rosreestr_quarter_poll (#888, monthly Rosreestr new-quarter availability check), '
'newbuilding_enrich (#973), yandex_newbuilding_sweep (dormant, #974), '
'cian_city_sweep (dormant, #973).';
COMMIT;

View file

@ -1,397 +0,0 @@
"""Tests for yandex_newbuilding_sweep task (#974).
No real network, no real Postgres. BrowserFetcher, resolve_yandex_jk_slug and
fetch_jk are mocked. The DB session is an in-memory fake that models
market.yandex_jk_enrichment + houses.yandex_jk_slug persistence assertions.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
# DATABASE_URL required by config before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed in CI without GTK.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.tasks.yandex_newbuilding_sweep import ( # noqa: E402
YandexNewbuildingSweepResult,
count_yandex_newbuilding_houses,
enrich_yandex_newbuilding_sweep,
)
# ── In-memory fake DB ─────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, *, scalar=None, rows=None, fetchone=None):
self._scalar = scalar
self._rows = rows or []
self._fetchone = fetchone
def scalar_one(self):
return self._scalar
def mappings(self):
return self
def all(self):
return self._rows
def fetchone(self):
return self._fetchone
class FakeDB:
"""Minimal SQLAlchemy-Session fake for yandex_newbuilding_sweep.
Tracks:
- enrichment: set of ext_id strings (inserted via UPSERT)
- slug_updates: dict house_id slug (persisted via UPDATE houses)
- savepoints: int (# of begin_nested calls)
"""
def __init__(self, pending_rows: list[dict]):
self._pending_rows = pending_rows
# market.yandex_jk_enrichment keyed by ext_id
self.enrichment: dict[str, dict] = {}
# houses.yandex_jk_slug written during resolve
self.slug_updates: dict[int, str] = {}
self.commits = 0
self.rollbacks = 0
self._sp_stack: list[_FakeSavepoint] = []
def execute(self, statement, params=None):
sql = str(statement)
params = params or {}
# COUNT queries
if "COUNT(DISTINCT h.id)" in sql and "yandex_realty_nb" in sql:
if "NOT EXISTS" in sql:
# _COUNT_PENDING
pending = sum(
1 for r in self._pending_rows if r.get("ext_id") not in self.enrichment
)
return _FakeResult(scalar=pending)
if "yandex_jk_slug IS NOT NULL" in sql:
# _COUNT_FETCHABLE
return _FakeResult(scalar=len(self._pending_rows))
# _COUNT_TOTAL
return _FakeResult(scalar=len(self._pending_rows))
# SELECT pending houses
if "SELECT DISTINCT ON (h.id)" in sql and "yandex_realty_nb" in sql:
lim = params.get("lim", len(self._pending_rows))
force = params.get("force", False)
out = []
for r in self._pending_rows[:lim]:
ext_id = r.get("ext_id")
if not force and ext_id in self.enrichment:
continue
# Reflect persisted slug
slug = self.slug_updates.get(r["house_id"]) or r.get("yandex_jk_slug")
out.append({**r, "yandex_jk_slug": slug})
return _FakeResult(rows=out)
# UPDATE houses SET yandex_jk_slug
if "UPDATE houses" in sql and "yandex_jk_slug" in sql:
self.slug_updates[params["hid"]] = params["slug"]
return _FakeResult()
# Idempotency check: SELECT 1 FROM market.yandex_jk_enrichment
if "SELECT 1 FROM market.yandex_jk_enrichment" in sql:
ext_id = params.get("ext_id")
if ext_id in self.enrichment:
return _FakeResult(fetchone=object())
return _FakeResult(fetchone=None)
# UPSERT market.yandex_jk_enrichment
if "INSERT INTO market.yandex_jk_enrichment" in sql:
self.enrichment[params["ext_id"]] = dict(params)
return _FakeResult()
return _FakeResult()
def begin_nested(self):
sp = _FakeSavepoint(self)
self._sp_stack.append(sp)
return sp
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
class _FakeSavepoint:
def __init__(self, db: FakeDB):
self._db = db
def commit(self):
pass
def rollback(self):
pass
# ── Tests: count_yandex_newbuilding_houses ───────────────────────────────────
def test_count_returns_sizing_dict():
db = FakeDB([{"house_id": 1, "ext_id": "100", "yandex_jk_slug": None}])
sizing = count_yandex_newbuilding_houses(db)
assert "total" in sizing
assert "fetchable" in sizing
assert "pending" in sizing
assert sizing["total"] >= 0
# ── Tests: enrich_yandex_newbuilding_sweep ───────────────────────────────────
@pytest.mark.asyncio
async def test_dry_run_returns_counts_without_fetch():
"""dry_run: популяция заполнена, fetch не вызван."""
db = FakeDB(
[
{"house_id": 1, "ext_id": "111", "yandex_jk_slug": None, "yandex_jk_id": None},
]
)
result = await enrich_yandex_newbuilding_sweep(db, limit=5, dry_run=True)
assert isinstance(result, YandexNewbuildingSweepResult)
assert result.processed == 0 # dry_run = no processing
assert result.rows_inserted == 0
assert result.total >= 0
@pytest.mark.asyncio
async def test_sweep_with_existing_slug_upserts_enrichment():
"""House с уже известным slug: fetch_jk вызван, UPSERT market + счётчик."""
db = FakeDB(
[
{
"house_id": 10,
"ext_id": "999",
"yandex_jk_slug": "tatlin",
"yandex_jk_id": "999",
}
]
)
from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo
fake_info = YandexNewbuildingInfo(
ext_id="999",
ext_slug="tatlin",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-999/",
name="ЖК Татлин",
developer_name="ПРИНЦИПЗ",
lat=56.855,
lon=60.576,
rating=4.3,
ratings_count=100,
text_reviews_count=25,
)
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=fake_info)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.processed == 1
assert result.succeeded == 1
assert result.rows_inserted == 1
assert result.failed_fetch == 0
assert result.failed_resolve == 0
assert "999" in db.enrichment
assert db.enrichment["999"]["name"] == "ЖК Татлин"
@pytest.mark.asyncio
async def test_sweep_resolves_slug_when_missing():
"""House без slug: resolve_yandex_jk_slug вызван, slug сохранён, fetch_jk вызван."""
db = FakeDB(
[
{
"house_id": 20,
"ext_id": "888",
"yandex_jk_slug": None,
"yandex_jk_id": None,
}
]
)
from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo
fake_info = YandexNewbuildingInfo(
ext_id="888",
ext_slug="testjk",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/testjk-888/",
name="ЖК Тест",
lat=56.8,
lon=60.5,
)
with (
patch(
"app.services.scrapers.yandex_newbuilding.resolve_yandex_jk_slug",
new_callable=AsyncMock,
return_value="testjk",
) as mock_resolve,
patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper,
):
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=fake_info)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
mock_resolve.assert_called_once_with("888", city="ekaterinburg")
assert result.resolved_slug == 1
assert result.succeeded == 1
assert result.rows_inserted == 1
# Slug persisted to houses
assert db.slug_updates.get(20) == "testjk"
assert "888" in db.enrichment
@pytest.mark.asyncio
async def test_sweep_failed_resolve_counts():
"""resolve_yandex_jk_slug returns None → failed_resolve += 1, no upsert."""
db = FakeDB(
[
{
"house_id": 30,
"ext_id": "777",
"yandex_jk_slug": None,
"yandex_jk_id": None,
}
]
)
with patch(
"app.services.scrapers.yandex_newbuilding.resolve_yandex_jk_slug",
new_callable=AsyncMock,
return_value=None,
):
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.failed_resolve == 1
assert result.succeeded == 0
assert result.rows_inserted == 0
assert "777" not in db.enrichment
@pytest.mark.asyncio
async def test_sweep_failed_fetch_counts():
"""fetch_jk returns None → failed_fetch += 1, no upsert."""
db = FakeDB(
[
{
"house_id": 40,
"ext_id": "666",
"yandex_jk_slug": "existingslug",
"yandex_jk_id": "666",
}
]
)
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=None)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.failed_fetch == 1
assert result.succeeded == 0
assert "666" not in db.enrichment
@pytest.mark.asyncio
async def test_sweep_idempotent_skip_already_enriched():
"""force=False: дом уже в enrichment → пропускается без fetch."""
db = FakeDB(
[
{
"house_id": 50,
"ext_id": "555",
"yandex_jk_slug": "existingslug",
"yandex_jk_id": "555",
}
]
)
# Pre-populate enrichment
db.enrichment["555"] = {"ext_id": "555", "name": "Already enriched"}
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=None)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
# The SELECT filters out already-enriched rows (force=False in SQL),
# so processed=0 (SELECT returns empty)
assert result.succeeded == 0
scraper_instance.fetch_jk.assert_not_called()
@pytest.mark.asyncio
async def test_sweep_no_ext_id_fails_resolve():
"""House без ext_id и без slug → failed_resolve (нечего резолвить)."""
db = FakeDB(
[
{
"house_id": 60,
"ext_id": None,
"yandex_jk_slug": None,
"yandex_jk_id": None,
}
]
)
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.failed_resolve == 1
assert result.succeeded == 0
@pytest.mark.asyncio
async def test_sweep_slug_present_but_ext_id_null_skips_fetch():
"""House с jk_slug но ext_id=NULL → guard срабатывает, fetch_jk НЕ вызван.
M1 review: пустой jk_id даёт URL /{slug}-/, который является битым.
Ожидаем failed_fetch += 1 и scraper.fetch_jk не вызван.
"""
db = FakeDB(
[
{
"house_id": 70,
"ext_id": None,
"yandex_jk_slug": "tatlin",
"yandex_jk_id": None,
}
]
)
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock()
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
scraper_instance.fetch_jk.assert_not_called()
assert result.failed_fetch == 1
assert result.succeeded == 0
assert result.rows_inserted == 0

View file

@ -1,5 +1,4 @@
"""Tests for cian_newbuilding scraper (Stage 6).""" """Tests for cian_newbuilding scraper (Stage 6)."""
from __future__ import annotations from __future__ import annotations
import pytest import pytest
@ -248,72 +247,42 @@ def test_dataclass_defaults():
# ---- async integration-style tests ---- # ---- async integration-style tests ----
# Transport: fetch_newbuilding теперь использует BrowserFetcher (camoufox) вместо
# curl_cffi AsyncSession (#972). Мокируем BrowserFetcher.fetch через monkeypatch.
def _make_browser_fetcher_mock(html: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""Мокирует BrowserFetcher так, чтобы fetch() возвращал заданный html."""
from unittest.mock import AsyncMock, MagicMock
mock_fetcher = MagicMock()
mock_fetcher.fetch = AsyncMock(return_value=html)
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
monkeypatch.setattr(
"app.services.scrapers.cian_newbuilding.BrowserFetcher",
lambda: mock_fetcher,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fetch_newbuilding_returns_none_on_no_state(monkeypatch): async def test_fetch_newbuilding_returns_none_on_no_state(monkeypatch):
"""Если state extraction fails → return None gracefully.""" """Если state extraction fails → return None gracefully."""
_make_browser_fetcher_mock("<html></html>", monkeypatch)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.scrapers.cian_newbuilding.extract_state", "app.services.scrapers.cian_newbuilding.extract_state",
lambda html, mfe, key: None, lambda html, mfe, key: None,
) )
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
assert result is None
@pytest.mark.asyncio
async def test_fetch_newbuilding_browser_error_propagates(monkeypatch):
"""BrowserFetcher.fetch raises → fetch_newbuilding propagates the exception."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
mock_fetcher = MagicMock() session = MagicMock()
mock_fetcher.fetch = AsyncMock(side_effect=RuntimeError("browser timeout")) response = MagicMock()
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) response.status_code = 200
mock_fetcher.__aexit__ = AsyncMock(return_value=None) response.text = "<html></html>"
monkeypatch.setattr( session.get = AsyncMock(return_value=response)
"app.services.scrapers.cian_newbuilding.BrowserFetcher", session.close = AsyncMock()
lambda: mock_fetcher,
)
with pytest.raises(RuntimeError, match="browser timeout"): result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session)
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/") assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fetch_newbuilding_session_param_ignored(monkeypatch): async def test_fetch_newbuilding_returns_none_on_http_error(monkeypatch):
"""session= param принимается для backward-compat, но не используется для page-fetch.""" """HTTP non-200 → return None gracefully."""
_make_browser_fetcher_mock("<html></html>", monkeypatch) from unittest.mock import AsyncMock, MagicMock
monkeypatch.setattr(
"app.services.scrapers.cian_newbuilding.extract_state",
lambda html, mfe, key: None,
)
from unittest.mock import MagicMock session = MagicMock()
response = MagicMock()
response.status_code = 403
response.text = "Forbidden"
session.get = AsyncMock(return_value=response)
session.close = AsyncMock()
sentinel_session = MagicMock(name="should_not_be_called") result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session)
# Передаём session= — должно работать без ошибок, session.get НЕ вызывается
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=sentinel_session)
assert result is None assert result is None
sentinel_session.get.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -349,7 +318,6 @@ async def test_fetch_newbuilding_parses_state(monkeypatch):
}, },
} }
_make_browser_fetcher_mock("<html>fake</html>", monkeypatch)
monkeypatch.setattr( monkeypatch.setattr(
"app.services.scrapers.cian_newbuilding.extract_state", "app.services.scrapers.cian_newbuilding.extract_state",
lambda html, mfe, key: fake_state, lambda html, mfe, key: fake_state,
@ -359,7 +327,16 @@ async def test_fetch_newbuilding_parses_state(monkeypatch):
lambda html: {}, lambda html: {},
) )
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/") from unittest.mock import AsyncMock, MagicMock
session = MagicMock()
response = MagicMock()
response.status_code = 200
response.text = "<html>fake</html>"
session.get = AsyncMock(return_value=response)
session.close = AsyncMock()
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session)
assert result is not None assert result is not None
assert result.cian_internal_house_id == 54016 assert result.cian_internal_house_id == 54016

View file

@ -1,8 +1,6 @@
"""Offline smoke for scheduler logic.""" """Offline smoke for scheduler logic."""
import asyncio
import os import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -172,195 +170,3 @@ def test_claim_run_rechecks_running_under_lock(monkeypatch: pytest.MonkeyPatch)
assert _sched._claim_run(db, _SCHED_ROW) is None assert _sched._claim_run(db, _SCHED_ROW) is None
assert calls["n"] == 0 assert calls["n"] == 0
assert db.rollbacks == 1 # лок освобождён assert db.rollbacks == 1 # лок освобождён
# ── #974: trigger_yandex_newbuilding_sweep_run + dispatch ────────────────────
_YANDEX_NB_SWEEP_ROW = {
"source": "yandex_newbuilding_sweep",
"window_start_hour": 2,
"window_end_hour": 5,
"default_params": {
"limit": 5,
"request_delay_sec": 8.0,
"city": "ekaterinburg",
},
}
@pytest.mark.asyncio
async def test_trigger_yandex_newbuilding_sweep_creates_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""trigger_yandex_newbuilding_sweep_run claims run + launches create_task (#974)."""
db = _FakeSchedulerDB()
calls: dict[str, list] = {"create_run": [], "sweep": []}
def fake_create_run(_d, *, source, params):
calls["create_run"].append(source)
db.running[source] = True
return 301
monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run)
# Patch enrich_yandex_newbuilding_sweep to a coroutine that records call
sweep_result = MagicMock()
sweep_result.to_dict.return_value = {}
async def fake_sweep(run_db, *, limit, **kwargs):
calls["sweep"].append(limit)
return sweep_result
# Patch inside the _run closure (lazy import)
monkeypatch.setattr(
"app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep",
fake_sweep,
)
monkeypatch.setattr(_sched, "SessionLocal", lambda: db)
# mark_done is called inside _run; mock it so it doesn't fail on fake db
monkeypatch.setattr(_sched.runs_mod, "mark_done", lambda *a, **kw: None)
monkeypatch.setattr(_sched.runs_mod, "mark_failed", lambda *a, **kw: None)
run_id = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW)
assert run_id == 301
assert calls["create_run"] == ["yandex_newbuilding_sweep"]
# Drain event-loop so the asyncio.create_task runs
await asyncio.sleep(0)
assert calls["sweep"] == [5]
@pytest.mark.asyncio
async def test_trigger_yandex_newbuilding_sweep_skips_when_running(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""trigger_yandex_newbuilding_sweep_run returns None when source already running."""
db = _FakeSchedulerDB()
db.running["yandex_newbuilding_sweep"] = True
calls: dict[str, int] = {"n": 0}
def fake_create_run(_d, *, source, params):
calls["n"] += 1
return 999
monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run)
result = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW)
assert result is None
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_scheduler_dispatch_routes_yandex_newbuilding_sweep(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Dispatch block в scheduler_loop роутит yandex_newbuilding_sweep → trigger."""
triggered: list[str] = []
async def fake_trigger(db, sch):
triggered.append(sch["source"])
return 1
monkeypatch.setattr(_sched, "trigger_yandex_newbuilding_sweep_run", fake_trigger)
db = object()
sch = _YANDEX_NB_SWEEP_ROW
source = sch["source"]
# Replicate the elif chain from scheduler_loop for yandex_newbuilding_sweep
if source == "yandex_newbuilding_sweep":
await _sched.trigger_yandex_newbuilding_sweep_run(db, sch)
assert "yandex_newbuilding_sweep" in triggered
# ── #973: trigger_cian_city_sweep_run + dispatch ─────────────────────────────
_CIAN_SWEEP_ROW = {
"source": "cian_city_sweep",
"window_start_hour": 2,
"window_end_hour": 5,
"default_params": {
"pages_per_anchor": 3,
"request_delay_sec": 5.0,
"radius_m": 1500,
"detail_top_n": 10,
"enrich_houses": True,
},
}
@pytest.mark.asyncio
async def test_trigger_cian_city_sweep_creates_run(monkeypatch: pytest.MonkeyPatch) -> None:
"""trigger_cian_city_sweep_run claims run + launches create_task (#973)."""
db = _FakeSchedulerDB()
calls: dict[str, list] = {"create_run": [], "sweep": []}
def fake_create_run(_d, *, source, params):
calls["create_run"].append(source)
db.running[source] = True
return 201
monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run)
# Patch run_cian_city_sweep to a coroutine that records call
async def fake_sweep(run_db, *, run_id, **kwargs):
calls["sweep"].append(run_id)
monkeypatch.setattr(_sched, "run_cian_city_sweep", fake_sweep)
# Patch SessionLocal to return the same db
monkeypatch.setattr(_sched, "SessionLocal", lambda: db)
run_id = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW)
assert run_id == 201
assert calls["create_run"] == ["cian_city_sweep"]
# Drain event-loop so the asyncio.create_task runs
await asyncio.sleep(0)
assert calls["sweep"] == [201]
@pytest.mark.asyncio
async def test_trigger_cian_city_sweep_skips_when_running(monkeypatch: pytest.MonkeyPatch) -> None:
"""trigger_cian_city_sweep_run returns None when source already running."""
db = _FakeSchedulerDB()
db.running["cian_city_sweep"] = True
calls: dict[str, int] = {"n": 0}
def fake_create_run(_d, *, source, params):
calls["n"] += 1
return 999
monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run)
result = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW)
assert result is None
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_scheduler_dispatch_routes_cian_city_sweep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Dispatch block в scheduler_loop роутит cian_city_sweep → trigger_cian_city_sweep_run."""
triggered: list[str] = []
async def fake_trigger(db, sch):
triggered.append(sch["source"])
return 1
monkeypatch.setattr(_sched, "trigger_cian_city_sweep_run", fake_trigger)
# Simulate the dispatch block for one due schedule
db = object()
sch = _CIAN_SWEEP_ROW
source = sch["source"]
# Replicate the elif chain from scheduler_loop for cian_city_sweep
if source == "cian_city_sweep":
await _sched.trigger_cian_city_sweep_run(db, sch)
assert "cian_city_sweep" in triggered

View file

@ -6,8 +6,7 @@ Covers:
- None/empty direct connection (no proxies dict) - None/empty direct connection (no proxies dict)
- _avito_proxies() helper returns correct dict shape - _avito_proxies() helper returns correct dict shape
- per-scraper proxy wiring: YandexRealty, YandexValuation, CianValuation, - per-scraper proxy wiring: YandexRealty, YandexValuation, CianValuation,
CianNewbuilding (fetch_newbuilding now BrowserFetcher, proxy is browser-level), CianNewbuilding (fetch_newbuilding + resolve_cian_zhk_url), AvitoIMV, AvitoDetail
AvitoIMV, AvitoDetail
""" """
from __future__ import annotations from __future__ import annotations
@ -268,81 +267,59 @@ async def test_yandex_valuation_session_no_proxies_when_none():
# ── CianNewbuilding — fetch_newbuilding ─────────────────────────────────────── # ── CianNewbuilding — fetch_newbuilding ───────────────────────────────────────
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cian_newbuilding_own_session_receives_proxies(): async def test_cian_newbuilding_own_session_receives_proxies():
"""fetch_newbuilding использует BrowserFetcher для page-fetch, не AsyncSession. """fetch_newbuilding creates own session with proxies= when no session passed."""
Проверяем:
- BrowserFetcher.fetch(url) вызывается с правильным URL
- curl_cffi AsyncSession НЕ создаётся для page-fetch (прокси browser-level)
"""
from app.services.scrapers import cian_newbuilding from app.services.scrapers import cian_newbuilding
zhk_url = "https://zhk-test.cian.ru/" mock_session = MagicMock()
fetch_calls: list[str] = [] mock_resp = MagicMock()
session_ctor_calls: list[dict] = [] mock_resp.status_code = 200
mock_resp.text = "" # empty HTML → extract_state returns None → result None (ok)
mock_session.get = AsyncMock(return_value=mock_resp)
mock_session.close = AsyncMock()
mock_fetcher = MagicMock() captured_kwargs: dict = {}
mock_fetcher.fetch = AsyncMock(
side_effect=lambda url: fetch_calls.append(url) or "<html></html>"
)
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
def _fake_async_session(*args, **kwargs): def _fake_session(*args, **kwargs):
session_ctor_calls.append(kwargs) captured_kwargs.update(kwargs)
return MagicMock() return mock_session
with patch.object(cian_newbuilding, "BrowserFetcher", lambda: mock_fetcher): with patch.object(cian_newbuilding, "AsyncSession", _fake_session):
with patch.object(cian_newbuilding, "AsyncSession", _fake_async_session): with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)):
await cian_newbuilding.fetch_newbuilding(zhk_url) await cian_newbuilding.fetch_newbuilding("https://zhk-test.cian.ru/")
# BrowserFetcher.fetch вызван с ЖК-url assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES
assert fetch_calls == [zhk_url]
# curl_cffi AsyncSession НЕ создавался для page-fetch
assert session_ctor_calls == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cian_newbuilding_shared_session_not_recreated(): async def test_cian_newbuilding_shared_session_not_recreated():
"""fetch_newbuilding принимает session= для backward-compat, но использует BrowserFetcher. """fetch_newbuilding does NOT create own session when one is passed."""
Даже когда передан внешний session страница ЖК тянется через BrowserFetcher,
AsyncSession не пересоздаётся, session.get не вызывается.
"""
from app.services.scrapers import cian_newbuilding from app.services.scrapers import cian_newbuilding
zhk_url = "https://zhk-test.cian.ru/" mock_session = MagicMock()
fetch_calls: list[str] = [] mock_resp = MagicMock()
session_ctor_calls: list[dict] = [] mock_resp.status_code = 200
mock_resp.text = ""
mock_session.get = AsyncMock(return_value=mock_resp)
mock_session.close = AsyncMock()
mock_fetcher = MagicMock() session_ctor_calls = []
mock_fetcher.fetch = AsyncMock(
side_effect=lambda url: fetch_calls.append(url) or "<html></html>"
)
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
def _fake_async_session(*args, **kwargs): def _fake_session(*args, **kwargs):
session_ctor_calls.append(kwargs) session_ctor_calls.append(kwargs)
return MagicMock() return MagicMock()
sentinel_session = MagicMock(name="external_session") with patch.object(cian_newbuilding, "AsyncSession", _fake_session):
with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)):
await cian_newbuilding.fetch_newbuilding(
"https://zhk-test.cian.ru/", session=mock_session
)
with patch.object(cian_newbuilding, "BrowserFetcher", lambda: mock_fetcher): # No own session should have been created (shared session was passed)
with patch.object(cian_newbuilding, "AsyncSession", _fake_async_session):
await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session)
# BrowserFetcher.fetch вызван — именно через него тянется страница
assert fetch_calls == [zhk_url]
# Ни нового AsyncSession, ни вызова переданного session.get — proxy browser-level
assert session_ctor_calls == [] assert session_ctor_calls == []
sentinel_session.get.assert_not_called()
# ── AvitoDetail — fetch_detail own-session ─────────────────────────────────── # ── AvitoDetail — fetch_detail own-session ───────────────────────────────────

View file

@ -92,15 +92,6 @@ _browser_cm: object | None = None # AsyncCamoufox context manager (для
_page_counter: int = 0 # страниц с момента последнего (пере)запуска _page_counter: int = 0 # страниц с момента последнего (пере)запуска
_fetch_lock: asyncio.Lock | None = None # сериализация fetch-запросов _fetch_lock: asyncio.Lock | None = None # сериализация fetch-запросов
# Resilience (#crash-storm): если прокси (mproxy.site) лежит, camoufox делает
# eager public_ip(proxy) для geoip-fingerprint и бросает InvalidProxy. Раньше это
# валило app.startup → exit(1) → docker restart → ∞ (RestartCount 3000+). Теперь
# фейл launch'а НЕ фатален: контейнер остаётся Up, а фоновая задача retry'ит
# launch с backoff пока прокси не вернётся. proxy+geoip СОХРАНЕНЫ (анти-бан).
_BROWSER_RETRY_MIN_S: float = 30.0 # стартовый интервал retry launch'а
_BROWSER_RETRY_MAX_S: float = 60.0 # потолок backoff'а
_browser_retry_task: asyncio.Task[None] | None = None # handle фоновой retry-задачи
async def _launch_browser() -> None: async def _launch_browser() -> None:
"""Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных.""" """Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
@ -156,105 +147,16 @@ async def _relaunch_browser() -> None:
await _launch_browser() await _launch_browser()
async def _try_launch_browser() -> bool:
"""Одна попытка launch'а с санитизированным логом фейла.
НЕ пробрасывает исключение наружу возвращает True/False. Критично для
resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить
startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит
``user:pass@host`` в тексте своего исключения, поэтому мы логируем только
тип + санитизированное сообщение, без ``str(exc)``.
При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние
через _close_browser, чтобы следующий retry стартовал с чистого листа.
"""
try:
await _launch_browser()
return True
except Exception as exc:
logger.warning(
"tradein-browser: browser launch failed: proxy unreachable "
"(exc_type=%s) — контейнер остаётся Up, retry в фоне",
type(exc).__name__,
)
# Подчистить возможный частичный CM перед следующей попыткой.
await _close_browser()
return False
async def _browser_retry_loop() -> None:
"""Фоновый retry launch'а браузера с backoff, пока прокси не вернётся.
Идемпотентна: если браузер уже поднят (_browser не None) сразу выходит.
Иначе ретраит _try_launch_browser с возрастающим интервалом
(_BROWSER_RETRY_MIN_S _BROWSER_RETRY_MAX_S). Завершается после первого
успешного launch'а. Отменяется в _on_cleanup (CancelledError пробрасывается).
"""
if _browser is not None:
return
delay = _BROWSER_RETRY_MIN_S
while _browser is None:
await asyncio.sleep(delay)
# Лок сериализует launch с in-flight /fetch lazy-launch и recycle —
# иначе два __aenter__ на один глобал _browser затрут друг друга.
assert _fetch_lock is not None, "_fetch_lock not initialised"
async with _fetch_lock:
if _browser is not None:
return # handler уже поднял браузер — задача отработала
logger.info("tradein-browser: фоновый retry launch'а браузера")
launched = await _try_launch_browser()
if launched:
logger.info("tradein-browser: браузер поднят после retry, задача завершена")
return
delay = min(delay * 1.5, _BROWSER_RETRY_MAX_S)
def _start_retry_task(app: web.Application) -> None:
"""Запускает (если ещё нет) фоновую retry-задачу и кладёт handle в app."""
global _browser_retry_task
existing = _browser_retry_task
if existing is not None and not existing.done():
return # задача уже бежит — не плодим дубли
task = asyncio.create_task(_browser_retry_loop())
_browser_retry_task = task
app["browser_retry_task"] = task
# ── aiohttp lifecycle hooks ──────────────────────────────────────────────────── # ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
async def _on_startup(app: web.Application) -> None: async def _on_startup(app: web.Application) -> None:
global _fetch_lock global _fetch_lock
_fetch_lock = asyncio.Lock() _fetch_lock = asyncio.Lock()
await _launch_browser()
# Launch НЕ фатален: если прокси лежит, camoufox бросит InvalidProxy. Раньше
# это валило startup → exit(1) → restart-loop. Теперь ловим, остаёмся Up и
# ретраим в фоне. proxy+geoip сохранены — никакого direct-fetch fallback.
if not await _try_launch_browser():
logger.warning(
"tradein-browser: старт без браузера (launch failed) — запуск фонового retry"
)
_start_retry_task(app)
async def _on_cleanup(app: web.Application) -> None: async def _on_cleanup(app: web.Application) -> None:
global _browser_retry_task
task = _browser_retry_task
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as exc:
logger.warning(
"tradein-browser: ошибка при отмене retry-задачи: %s", type(exc).__name__
)
_browser_retry_task = None
await _close_browser() await _close_browser()
@ -262,12 +164,7 @@ async def _on_cleanup(app: web.Application) -> None:
async def health_handler(request: web.Request) -> web.Response: async def health_handler(request: web.Request) -> web.Response:
# Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер, return web.json_response({"status": "ok"})
# хотя процесс жив и ретраит браузер. browser_ready — для observability:
# видно лежит ли прокси, не теряя контейнер. Текущий prod compose НЕ имеет
# healthcheck на browser (depends_on: condition: service_started), так что
# 200-без-браузера никого не рестартит.
return web.json_response({"status": "ok", "browser_ready": _browser is not None})
async def fetch_handler(request: web.Request) -> web.Response: async def fetch_handler(request: web.Request) -> web.Response:
@ -290,18 +187,6 @@ async def fetch_handler(request: web.Request) -> web.Response:
assert _fetch_lock is not None, "_fetch_lock not initialised" assert _fetch_lock is not None, "_fetch_lock not initialised"
async with _fetch_lock: async with _fetch_lock:
# Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка
# под локом — вдруг прокси уже вернулся. НИКОГДА не фетчим без прокси/
# браузера: если всё ещё None → 503 (caller словит как HTTPStatusError
# и обработает как failed fetch, а не как crash).
if _browser is None:
await _try_launch_browser()
if _browser is None:
logger.warning("tradein-browser: /fetch 503 — браузер недоступен (proxy may be down)")
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
try: try:
html = await _do_fetch(url) html = await _do_fetch(url)
except Exception as exc: except Exception as exc:
@ -405,16 +290,6 @@ async def login_handler(request: web.Request) -> web.Response:
assert _fetch_lock is not None, "_fetch_lock not initialised" assert _fetch_lock is not None, "_fetch_lock not initialised"
async with _fetch_lock: async with _fetch_lock:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка под локом, иначе 503 — без прокси не логинимся.
if _browser is None:
await _try_launch_browser()
if _browser is None:
logger.warning("tradein-browser: /login 503 — браузер недоступен (proxy may be down)")
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
try: try:
cookies = await _do_login(body) cookies = await _do_login(body)
except LoginError as exc: except LoginError as exc:

View file

@ -1,124 +0,0 @@
"""test_server_smoke.py — смоук для resilience-логики tradein-browser.
Полный браузер (camoufox subprocess) юнит-тестить непрактично, поэтому здесь
только смоук: (1) модуль импортируется; (2) /fetch и /login отдают 503 когда
браузер не поднят (прокси лежит) критичный resilience-инвариант, защищающий
от crash-storm. camoufox в окружении теста НЕ нужен: launch замокан no-op'ом,
имитируя «прокси всё ещё недоступен».
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_smoke.py -q
aiohttp.test_utils.make_mocked_request не поднимает реальный сервер/сокет
хендлер вызывается напрямую, без event-loop-сервера.
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
from pathlib import Path
from typing import Any
import pytest
from aiohttp.test_utils import make_mocked_request
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
def _json_body(response: Any) -> dict[str, Any]:
"""Достаёт JSON-тело из aiohttp Response (body — bytes после json_response)."""
return json.loads(response.body.decode())
def test_module_imports() -> None:
"""Смоук: модуль импортируется и экспонирует ключевые хендлеры."""
assert callable(server.fetch_handler)
assert callable(server.login_handler)
assert callable(server.health_handler)
def test_health_reports_browser_not_ready(monkeypatch: pytest.MonkeyPatch) -> None:
"""/health всегда 200, browser_ready=False когда браузер не поднят."""
monkeypatch.setattr(server, "_browser", None)
request = make_mocked_request("GET", "/health")
response = asyncio.run(server.health_handler(request))
assert response.status == 200
body = _json_body(response)
assert body["status"] == "ok"
assert body["browser_ready"] is False
def test_fetch_returns_503_when_browser_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""/fetch отдаёт 503 (не 500/краш) когда браузер не поднят и launch не удался.
Имитирует «прокси лежит»: _browser=None + _try_launch_browser no-op,
оставляющий None. Никакого реального camoufox-launch'а / direct-fetch.
"""
monkeypatch.setattr(server, "_browser", None)
monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock())
async def _no_op_launch() -> bool:
return False # прокси всё ещё недоступен → браузер остаётся None
monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch)
request = make_mocked_request(
"POST", "/fetch", payload=b'{"url": "https://example.com"}'
)
async def _run() -> Any:
# make_mocked_request не парсит тело — подменяем .json() на нужный payload.
request.json = lambda: _coro({"url": "https://example.com"}) # type: ignore[method-assign]
return await server.fetch_handler(request)
response = asyncio.run(_run())
assert response.status == 503
body = _json_body(response)
assert "browser unavailable" in body["error"]
def test_login_returns_503_when_browser_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""/login тоже отдаёт 503 когда браузер не поднят (без credential-логирования)."""
monkeypatch.setattr(server, "_browser", None)
monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock())
async def _no_op_launch() -> bool:
return False
monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch)
login_body = {
"url": "https://cian.example/login",
"email": "u@example.com",
"password": "secret",
"email_selector": "#email",
"password_selector": "#password",
"submit_selector": "#submit",
}
request = make_mocked_request("POST", "/login")
async def _run() -> Any:
request.json = lambda: _coro(login_body) # type: ignore[method-assign]
return await server.login_handler(request)
response = asyncio.run(_run())
assert response.status == 503
body = _json_body(response)
assert "browser unavailable" in body["error"]
async def _coro(value: Any) -> Any:
"""Хелпер: оборачивает значение в awaitable для подмены request.json()."""
return value