fix(week-review): аудит код-ревью — 181 фиксов (label week ревью 1) #1543
33 changed files with 4510 additions and 191 deletions
|
|
@ -22,6 +22,7 @@ from app.schemas.parcel import (
|
|||
AnalyzeResponse,
|
||||
BestLayoutsRequest,
|
||||
BestLayoutsResponse,
|
||||
CompetitorsParkingResponse,
|
||||
CompetitorsRequest,
|
||||
CompetitorsResponse,
|
||||
ConnectionPointsResponse,
|
||||
|
|
@ -58,7 +59,7 @@ from app.services.site_finder.cadastre_fetch import (
|
|||
from app.services.site_finder.cadastre_fetch import (
|
||||
fetch_status as _fetch_status,
|
||||
)
|
||||
from app.services.site_finder.competitors import get_competitors
|
||||
from app.services.site_finder.competitors import get_competitors, get_competitors_parking
|
||||
from app.services.site_finder.custom_pois import (
|
||||
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
||||
)
|
||||
|
|
@ -75,6 +76,7 @@ from app.services.site_finder.quarter_dump_lookup import (
|
|||
get_quarter_dump_data,
|
||||
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.weight_profiles import (
|
||||
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
|
||||
|
|
@ -1599,7 +1601,23 @@ def analyze_parcel(
|
|||
WHERE latitude IS NOT NULL
|
||||
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 (
|
||||
-- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id.
|
||||
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
|
||||
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
|
||||
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
|
||||
SELECT
|
||||
cm.domrf_obj_id,
|
||||
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
|
||||
|
|
@ -1611,6 +1629,7 @@ def analyze_parcel(
|
|||
) AS lots_with_price
|
||||
FROM objective_complex_mapping cm
|
||||
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
|
||||
)
|
||||
SELECT o.obj_id,
|
||||
|
|
@ -1632,13 +1651,8 @@ def analyze_parcel(
|
|||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS distance_m
|
||||
FROM latest_obj o
|
||||
FROM nearby_obj o
|
||||
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,
|
||||
distance_m ASC
|
||||
LIMIT 20
|
||||
|
|
@ -2700,11 +2714,31 @@ def analyze_parcel(
|
|||
"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] = {
|
||||
"cad_num": cad_num,
|
||||
"source": source,
|
||||
"geom_geojson": json.loads(geom_geojson) if geom_geojson 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_without_center": round(score, 2),
|
||||
"score_label": _score_label(score_final),
|
||||
|
|
@ -3161,6 +3195,36 @@ async def get_parcel_competitors(
|
|||
) 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) domrf→cad_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)
|
||||
async def get_parcel_best_layouts(
|
||||
cad_num: str,
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@ class Competitor(BaseModel):
|
|||
sold_pct: float | None
|
||||
velocity_per_month: float
|
||||
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
|
||||
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
|
||||
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /
|
||||
|
|
@ -220,6 +225,43 @@ class CompetitorsResponse(BaseModel):
|
|||
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-форсайт») ─────────────────────────────────
|
||||
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
|
||||
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
|
||||
|
|
|
|||
|
|
@ -24,14 +24,27 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.schemas.parcel import (
|
||||
Competitor,
|
||||
CompetitorParking,
|
||||
CompetitorsParkingResponse,
|
||||
CompetitorsRequest,
|
||||
CompetitorsResponse,
|
||||
CompetitorsSummary,
|
||||
)
|
||||
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__)
|
||||
|
||||
# #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, ...]:
|
||||
"""Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод.
|
||||
|
|
@ -50,6 +63,7 @@ def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) ->
|
|||
tuple(sorted(request.exclude_obj_ids)),
|
||||
)
|
||||
|
||||
|
||||
# Маппинг time_window → число месяцев (float для деления velocity)
|
||||
_TIME_WINDOW_MONTHS: dict[str, float] = {
|
||||
"last_month": 1.0,
|
||||
|
|
@ -456,6 +470,37 @@ _AVG_PRICE_SQL = text("""
|
|||
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 руб/м² (диапазон 80k–250k для ЕКБ ✓).
|
||||
#
|
||||
# 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:
|
||||
"""Извлечь кадастровый квартал из номера участка/здания.
|
||||
|
|
@ -579,6 +624,7 @@ def get_competitors(
|
|||
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
|
||||
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
|
||||
avg_price_map: dict[int, float] = {}
|
||||
price_source_map: dict[int, str] = {}
|
||||
sold_count_map: dict[int, int] = {}
|
||||
try:
|
||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||
|
|
@ -587,12 +633,36 @@ def get_competitors(
|
|||
price = _row_get(r, "avg_price_per_m2")
|
||||
if price is not None:
|
||||
avg_price_map[oid] = float(price)
|
||||
price_source_map[oid] = "domrf"
|
||||
sold = _row_get(r, "flats_sold")
|
||||
if sold is not None:
|
||||
sold_count_map[oid] = int(sold)
|
||||
except Exception:
|
||||
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 (медиана цены, доминирующий класс) ─
|
||||
radius_m = request.radius_km * 1000.0
|
||||
local_median_ppm2 = _median(list(avg_price_map.values()))
|
||||
|
|
@ -656,6 +726,7 @@ def get_competitors(
|
|||
sold_pct=sold_pct,
|
||||
velocity_per_month=velocity,
|
||||
avg_price_per_m2=avg_price_map.get(obj_id),
|
||||
price_source=price_source_map.get(obj_id),
|
||||
is_active=is_active,
|
||||
relevance_weight=round(relevance_weight, 3),
|
||||
relevance_breakdown=relevance_breakdown,
|
||||
|
|
@ -692,3 +763,80 @@ def get_competitors(
|
|||
)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ from __future__ import annotations
|
|||
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from app.services.site_finder.network_obremenenie import (
|
||||
classify_network_zone,
|
||||
network_kind_label,
|
||||
)
|
||||
|
||||
# ── Zone matching ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
|
||||
|
|
@ -155,9 +160,17 @@ def compute_gate_verdict(
|
|||
# cad_zouit fallback path: classify by type_zone keywords (#232).
|
||||
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
|
||||
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
|
||||
# СЗЗ-маркер специфичнее широких blocker-подстрок ('газ'/'электр'),
|
||||
# поэтому проверяем warning-keywords ПЕРВЫМИ (#1341). СЗЗ газораспределительной
|
||||
# станции/электроподстанции — ограничение, а не hard-blocker.
|
||||
# #1070: классификация «сетевого обременения» (охранная зона инж.сети).
|
||||
# Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind);
|
||||
# если overlap из старого пути без флага — определяем здесь же.
|
||||
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):
|
||||
warnings.append(
|
||||
Warning(
|
||||
|
|
@ -167,6 +180,17 @@ 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):
|
||||
blockers.append(
|
||||
Blocker(
|
||||
|
|
|
|||
129
backend/app/services/site_finder/network_obremenenie.py
Normal file
129
backend/app/services/site_finder/network_obremenenie.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Классификация «сетевых обременений» из ЗОУИТ (#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",
|
||||
]
|
||||
|
|
@ -30,6 +30,10 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.schemas.nspd_bulk import ObjectsListing
|
||||
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
|
||||
|
|
@ -41,6 +45,123 @@ logger = logging.getLogger(__name__)
|
|||
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
|
||||
_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:
|
||||
"""objdoc_id здания по cad_num через NSPD geoportal search.
|
||||
|
|
@ -127,4 +248,38 @@ def get_building_premises(cad_num: str) -> ObjectsListing | None:
|
|||
return listing
|
||||
|
||||
|
||||
__all__ = ["get_building_premises"]
|
||||
def get_building_premises_for_match(match: BuildingMatch) -> ObjectsListing | None:
|
||||
"""Помещения/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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ from sqlalchemy import text
|
|||
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.site_finder.network_obremenenie import (
|
||||
classify_network_zone,
|
||||
network_kind_label,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Порог свежести дампа — 180 дней. Совпадает с beat-расписанием (раз в квартал
|
||||
|
|
@ -405,6 +410,10 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
|
|||
result: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
type_zone: str = r[0] or ""
|
||||
# #1070: выделяем «сетевые обременения» — охранные зоны инж.сетей
|
||||
# (тепло/электро/газ/вода/связь/трубопровод/общие инж.коммуникации).
|
||||
# Pure-function классификация по type_zone; None если зона не сетевая.
|
||||
net_kind = classify_network_zone(type_zone)
|
||||
result.append(
|
||||
{
|
||||
"group_key": "cad_zouit",
|
||||
|
|
@ -419,6 +428,12 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
|
|||
},
|
||||
"source": "cad_zouit",
|
||||
"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>).
|
||||
# geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry).
|
||||
"geom_geojson": r[5],
|
||||
|
|
@ -969,8 +984,8 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
|||
harvest_error: str | None = dump_row[2]
|
||||
is_stale = False
|
||||
if fetched_at is not None and getattr(fetched_at, "tzinfo", "missing") != "missing":
|
||||
fetched_at_aware = fetched_at if fetched_at.tzinfo is not None else fetched_at.replace(
|
||||
tzinfo=UTC
|
||||
fetched_at_aware = (
|
||||
fetched_at if fetched_at.tzinfo is not None else fetched_at.replace(tzinfo=UTC)
|
||||
)
|
||||
is_stale = (datetime.now(UTC) - fetched_at_aware) > timedelta(days=_DUMP_MAX_AGE_DAYS)
|
||||
if harvest_error is not None or is_stale:
|
||||
|
|
|
|||
297
backend/app/services/site_finder/saturation.py
Normal file
297
backend/app/services/site_finder/saturation.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""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.5–0.85 — дефицит | 0.85–1.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,
|
||||
}
|
||||
|
|
@ -5,13 +5,18 @@ Mock-based — не требуют живой БД.
|
|||
- поля site_status и ready_dt присутствуют в каждом элементе competitors
|
||||
- первые позиции занимают строящиеся ЖК (site_status='Строящиеся')
|
||||
- сданные ЖК идут после строящихся
|
||||
- obj_pricing CTE ограничен ближними obj_id (pushdown perf-guard, #70/#307)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import inspect
|
||||
import re
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.v1 import parcels as parcels_module
|
||||
|
||||
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -120,3 +125,61 @@ class TestCompetitorsSortOrder:
|
|||
# Старая логика: первым шёл ЖК с flat_count=800 (Сданные)
|
||||
assert first_old["flat_count"] == 800
|
||||
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
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ def _price_row(obj_id: int, price: float) -> MagicMock:
|
|||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -60,13 +67,17 @@ def _make_db(
|
|||
coord: MagicMock | None = None,
|
||||
obj_rows: list[MagicMock] | None = None,
|
||||
price_rows: list[MagicMock] | None = None,
|
||||
obj_price_rows: list[MagicMock] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Сконструировать mock Session.
|
||||
|
||||
Порядок вызовов execute:
|
||||
1. centroid query → coord
|
||||
2. competitors query → obj_rows
|
||||
3. avg_price query → price_rows
|
||||
3. domrf avg_price query → price_rows
|
||||
4. objective price fallback (OBJ-3 #307) → obj_price_rows
|
||||
(вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда,
|
||||
т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
|
|
@ -75,6 +86,7 @@ def _make_db(
|
|||
(coord, True),
|
||||
(obj_rows or [], False),
|
||||
(price_rows or [], False),
|
||||
(obj_price_rows or [], False),
|
||||
]:
|
||||
result = MagicMock()
|
||||
if is_first:
|
||||
|
|
@ -390,6 +402,97 @@ def test_competitors_avg_price_populated() -> None:
|
|||
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
|
||||
"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:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
|
|
|||
286
backend/tests/services/site_finder/test_competitors_parking.py
Normal file
286
backend/tests/services/site_finder/test_competitors_parking.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""Тесты 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")
|
||||
|
|
@ -124,12 +124,17 @@ def test_pzz_unknown_warning_when_no_zoning():
|
|||
|
||||
|
||||
def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
||||
"""cad_zouit overlap с 'охранная зона трубопроводов' → ZOUIT_CAD_BLOCKER → Нельзя."""
|
||||
"""cad_zouit 'охранная зона трубопроводов' → сетевое обременение (газ) → Нельзя (#1070).
|
||||
|
||||
После #1070 трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE
|
||||
(более специфичный код, чем прежний ZOUIT_CAD_BLOCKER) — поведение «блокирует МКД»
|
||||
сохранено.
|
||||
"""
|
||||
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
{
|
||||
"source": "cad_zouit",
|
||||
"type_zone": "Охранная зона трубопроводов",
|
||||
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
|
||||
"layer": "Охранная зона трубопроводов",
|
||||
"name": "Газопровод высокого давления",
|
||||
}
|
||||
|
|
@ -137,11 +142,11 @@ def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
|||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
assert verdict["can_build_mkd"] is False
|
||||
assert verdict["verdict_label"] == "Нельзя"
|
||||
assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
|
||||
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"])
|
||||
|
||||
|
||||
def test_cad_zouit_elektr_blocks():
|
||||
"""cad_zouit overlap с 'электр' substring в type_zone → blocker."""
|
||||
"""cad_zouit электросетевая охранная зона → сетевое обременение (электро) → blocker (#1070)."""
|
||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||
overlaps = [
|
||||
{
|
||||
|
|
@ -153,7 +158,7 @@ def test_cad_zouit_elektr_blocks():
|
|||
]
|
||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||
assert verdict["can_build_mkd"] is False
|
||||
assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
|
||||
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"])
|
||||
|
||||
|
||||
def test_cad_zouit_szz_is_warning_not_blocker():
|
||||
|
|
|
|||
141
backend/tests/test_network_obremenenie.py
Normal file
141
backend/tests/test_network_obremenenie.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""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
|
||||
241
backend/tests/test_saturation.py
Normal file
241
backend/tests/test_saturation.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""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)
|
||||
107
data/sql/154_ekb_district_demographics.sql
Normal file
107
data/sql/154_ekb_district_demographics.sql
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
-- Контекст: 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;
|
||||
259
data/sql/155_objective_mapping_trgm_backfill.sql
Normal file
259
data/sql/155_objective_mapping_trgm_backfill.sql
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
-- 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;
|
||||
270
data/sql/156_objective_mapping_corroborated_v4.sql
Normal file
270
data/sql/156_objective_mapping_corroborated_v4.sql
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
-- 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;
|
||||
122
data/sql/157_analyze_hotpath_indexes.sql
Normal file
122
data/sql/157_analyze_hotpath_indexes.sql
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
-- 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;
|
||||
|
|
@ -409,6 +409,36 @@ export interface paths {
|
|||
patch?: 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) domrf→cad_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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3040,6 +3070,8 @@ export interface components {
|
|||
velocity_per_month: number;
|
||||
/** Avg Price Per M2 */
|
||||
avg_price_per_m2: number | null;
|
||||
/** Price Source */
|
||||
price_source?: string | null;
|
||||
/** Is Active */
|
||||
is_active: boolean;
|
||||
/** Relevance Weight */
|
||||
|
|
@ -3049,6 +3081,44 @@ export interface components {
|
|||
[key: string]: number;
|
||||
} | 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: {
|
||||
/**
|
||||
|
|
@ -5052,6 +5122,41 @@ 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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -1470,8 +1470,9 @@ async def scrape_yandex_newbuilding(
|
|||
|
||||
URL: /{city}/kupit/novostrojka/<slug>-<id>/
|
||||
"""
|
||||
async with YandexNewbuildingScraper() as scraper:
|
||||
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
||||
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен
|
||||
scraper = YandexNewbuildingScraper()
|
||||
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
||||
if result is None:
|
||||
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
||||
return YandexNewbuildingTriggerResp(
|
||||
|
|
@ -1488,6 +1489,91 @@ 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):
|
||||
ok: bool
|
||||
address: str
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ Sources:
|
|||
Idempotent: skips houses already enriched, per-house SAVEPOINT, so
|
||||
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)
|
||||
- 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
|
||||
|
|
@ -63,6 +69,7 @@ from app.core.db import SessionLocal
|
|||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.scrape_pipeline import (
|
||||
run_avito_city_sweep,
|
||||
run_cian_city_sweep,
|
||||
run_n1_city_sweep,
|
||||
run_yandex_city_sweep,
|
||||
)
|
||||
|
|
@ -407,6 +414,41 @@ async def trigger_cian_backfill_run(db: Session, schedule_row: dict[str, Any]) -
|
|||
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(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -459,8 +501,7 @@ async def _execute_cian_backfill(
|
|||
}
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
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,
|
||||
result.listings_succeeded,
|
||||
result.listings_total,
|
||||
|
|
@ -769,6 +810,55 @@ async def trigger_newbuilding_enrich_run(db: Session, schedule_row: dict[str, An
|
|||
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:
|
||||
"""Создать scrape_runs + launch recompute_asking_to_sold_ratios в executor (sync DB-only task).
|
||||
|
||||
|
|
@ -1079,6 +1169,8 @@ async def scheduler_loop() -> None:
|
|||
await trigger_n1_city_sweep_run(db, sch)
|
||||
elif source == "cian_history_backfill":
|
||||
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":
|
||||
await trigger_rosreestr_dkp_run(db, sch)
|
||||
elif source == "listing_source_snapshot":
|
||||
|
|
@ -1097,6 +1189,8 @@ async def scheduler_loop() -> None:
|
|||
await trigger_rosreestr_quarter_poll_run(db, sch)
|
||||
elif source == "newbuilding_enrich":
|
||||
await trigger_newbuilding_enrich_run(db, sch)
|
||||
elif source == "yandex_newbuilding_sweep":
|
||||
await trigger_yandex_newbuilding_sweep_run(db, sch)
|
||||
else:
|
||||
logger.warning("scheduler: unknown source=%s, skip", source)
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from typing import Any
|
|||
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -120,116 +121,98 @@ class NewbuildingEnrichment:
|
|||
async def fetch_newbuilding(
|
||||
zhk_url: str,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
|
||||
) -> NewbuildingEnrichment | None:
|
||||
"""Fetch ЖК catalog page и extract все containers.
|
||||
|
||||
HTML страница ЖК теперь тянется через BrowserFetcher (camoufox), потому что
|
||||
curl_cffi получает страницу без initialState под анти-ботом Cian (#972).
|
||||
tradein-browser верифицирован живым: 1.17 MB, initialState присутствует.
|
||||
|
||||
Args:
|
||||
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
|
||||
session: optional shared curl_cffi session (caller owns lifecycle)
|
||||
session: зарезервирован для обратной совместимости с вызывающими;
|
||||
больше не используется для fetch страницы ЖК. Resolve-функции
|
||||
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
|
||||
|
||||
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
|
||||
"""
|
||||
close_session = False
|
||||
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
|
||||
async with BrowserFetcher() as browser:
|
||||
html = await browser.fetch(zhk_url)
|
||||
|
||||
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"
|
||||
nb_state = extract_state(html, mfe=mfe, key="initialState")
|
||||
if nb_state is None:
|
||||
logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url)
|
||||
return None
|
||||
|
||||
nb_state = extract_state(html, mfe=mfe, key="initialState")
|
||||
if nb_state is None:
|
||||
logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url)
|
||||
return None
|
||||
# Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4)
|
||||
nb = nb_state.get("newbuilding") or {}
|
||||
if not isinstance(nb, dict):
|
||||
nb = {}
|
||||
|
||||
# Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4)
|
||||
nb = nb_state.get("newbuilding") or {}
|
||||
if not isinstance(nb, dict):
|
||||
nb = {}
|
||||
result = NewbuildingEnrichment(
|
||||
cian_internal_house_id=nb.get("id"),
|
||||
cian_zhk_url=zhk_url,
|
||||
name=nb.get("name") or nb.get("displayName"),
|
||||
address=_extract_address(nb),
|
||||
year_built=nb.get("yearBuilt") or nb.get("buildYear"),
|
||||
deadline_year=_extract_deadline_year(nb),
|
||||
floors_count_min=nb.get("floorsCountMin"),
|
||||
floors_count_max=nb.get("floorsCountMax"),
|
||||
building_class=nb.get("newbuildingClassName") or nb.get("buildingClass"),
|
||||
management_company=nb.get("managementCompany"),
|
||||
transport_accessibility_rate=_extract_transport_rate(nb),
|
||||
advantages=_extract_advantages(nb),
|
||||
builders=_safe_list(nb.get("builders")),
|
||||
banks=_safe_list(nb.get("banks")),
|
||||
houses_by_turn=_safe_list(nb.get("housesByTurn")),
|
||||
raw_state=nb_state,
|
||||
)
|
||||
|
||||
result = NewbuildingEnrichment(
|
||||
cian_internal_house_id=nb.get("id"),
|
||||
cian_zhk_url=zhk_url,
|
||||
name=nb.get("name") or nb.get("displayName"),
|
||||
address=_extract_address(nb),
|
||||
year_built=nb.get("yearBuilt") or nb.get("buildYear"),
|
||||
deadline_year=_extract_deadline_year(nb),
|
||||
floors_count_min=nb.get("floorsCountMin"),
|
||||
floors_count_max=nb.get("floorsCountMax"),
|
||||
building_class=nb.get("newbuildingClassName") or nb.get("buildingClass"),
|
||||
management_company=nb.get("managementCompany"),
|
||||
transport_accessibility_rate=_extract_transport_rate(nb),
|
||||
advantages=_extract_advantages(nb),
|
||||
builders=_safe_list(nb.get("builders")),
|
||||
banks=_safe_list(nb.get("banks")),
|
||||
houses_by_turn=_safe_list(nb.get("housesByTurn")),
|
||||
raw_state=nb_state,
|
||||
)
|
||||
# Sister containers — all live under the same MFE initialState top-level keys
|
||||
# (not separate push() entries) per schema sec 15.3
|
||||
all_states = extract_all_states(html)
|
||||
mfe_states = all_states.get(mfe, {})
|
||||
# initialState top-level keys contain sister data: realtyValuation, reliability, offers
|
||||
top = nb_state
|
||||
|
||||
# Sister containers — all live under the same MFE initialState top-level keys
|
||||
# (not separate push() entries) per schema sec 15.3
|
||||
all_states = extract_all_states(html)
|
||||
mfe_states = all_states.get(mfe, {})
|
||||
# initialState top-level keys contain sister data: realtyValuation, reliability, offers
|
||||
top = nb_state
|
||||
# realtyValuation 7-month chart (Stage 6 main deliverable)
|
||||
# Located at initialState.realtyValuation (sec 15.5)
|
||||
rv_state = top.get("realtyValuation") or mfe_states.get("realtyValuation")
|
||||
if rv_state and isinstance(rv_state, dict):
|
||||
result.realty_valuation_chart = _extract_chart(rv_state)
|
||||
result.raw_sister_states["realtyValuation"] = rv_state
|
||||
|
||||
# realtyValuation 7-month chart (Stage 6 main deliverable)
|
||||
# Located at initialState.realtyValuation (sec 15.5)
|
||||
rv_state = top.get("realtyValuation") or mfe_states.get("realtyValuation")
|
||||
if rv_state and isinstance(rv_state, dict):
|
||||
result.realty_valuation_chart = _extract_chart(rv_state)
|
||||
result.raw_sister_states["realtyValuation"] = rv_state
|
||||
# reliability (наш.дом.рф) — located at initialState.reliability (sec 15.6)
|
||||
rel_state = top.get("reliability") or mfe_states.get("reliability")
|
||||
if rel_state and isinstance(rel_state, dict):
|
||||
result.reliability_checks = _extract_reliability_checks(rel_state)
|
||||
result.raw_sister_states["reliability"] = rel_state
|
||||
|
||||
# reliability (наш.дом.рф) — located at initialState.reliability (sec 15.6)
|
||||
rel_state = top.get("reliability") or mfe_states.get("reliability")
|
||||
if rel_state and isinstance(rel_state, dict):
|
||||
result.reliability_checks = _extract_reliability_checks(rel_state)
|
||||
result.raw_sister_states["reliability"] = rel_state
|
||||
# reviews — located at initialState or separate state
|
||||
reviews_state = top.get("reviews") or mfe_states.get("reviews")
|
||||
if reviews_state and isinstance(reviews_state, dict):
|
||||
items = reviews_state.get("items") or reviews_state.get("data") or []
|
||||
result.reviews = items if isinstance(items, list) else []
|
||||
result.raw_sister_states["reviews"] = reviews_state
|
||||
|
||||
# reviews — located at initialState or separate state
|
||||
reviews_state = top.get("reviews") or mfe_states.get("reviews")
|
||||
if reviews_state and isinstance(reviews_state, dict):
|
||||
items = reviews_state.get("items") or reviews_state.get("data") or []
|
||||
result.reviews = items if isinstance(items, list) else []
|
||||
result.raw_sister_states["reviews"] = reviews_state
|
||||
# offers (grouped by roomType) — located at initialState.offers (sec 15.7)
|
||||
offers_state = top.get("offers") or mfe_states.get("offers") or mfe_states.get("miniSerp")
|
||||
if offers_state and isinstance(offers_state, dict):
|
||||
result.nested_offers = _extract_nested_offers(offers_state)
|
||||
result.raw_sister_states["offers"] = offers_state
|
||||
|
||||
# offers (grouped by roomType) — located at initialState.offers (sec 15.7)
|
||||
offers_state = top.get("offers") or mfe_states.get("offers") or mfe_states.get("miniSerp")
|
||||
if offers_state and isinstance(offers_state, dict):
|
||||
result.nested_offers = _extract_nested_offers(offers_state)
|
||||
result.raw_sister_states["offers"] = offers_state
|
||||
|
||||
logger.info(
|
||||
"Cian newbuilding %s parsed: id=%s chart=%d reliability=%d offers=%d",
|
||||
zhk_url,
|
||||
result.cian_internal_house_id,
|
||||
len(result.realty_valuation_chart),
|
||||
len(result.reliability_checks),
|
||||
len(result.nested_offers),
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
if close_session:
|
||||
await session.close()
|
||||
logger.info(
|
||||
"Cian newbuilding %s parsed: id=%s chart=%d reliability=%d offers=%d",
|
||||
zhk_url,
|
||||
result.cian_internal_house_id,
|
||||
len(result.realty_valuation_chart),
|
||||
len(result.reliability_checks),
|
||||
len(result.nested_offers),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---- private extractors ----
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
URL pattern: /{city}/kupit/novostrojka/<slug>-<id>/
|
||||
Reference target: ЖК Татлин (id=1592987, slug=tatlin) — comfort+, June 2023,
|
||||
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
|
||||
|
|
@ -98,6 +104,12 @@ RE_METRO_INLINE = re.compile(
|
|||
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)
|
||||
LAT_RANGE = (55.5, 57.5)
|
||||
LON_RANGE = (59.5, 61.5)
|
||||
|
|
@ -129,9 +141,7 @@ class YandexNewbuildingScraper(BaseScraper):
|
|||
super().__init__()
|
||||
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(
|
||||
"YandexNewbuildingScraper is JK-slug-based; use fetch_jk(slug, id) instead."
|
||||
)
|
||||
|
|
@ -139,22 +149,31 @@ class YandexNewbuildingScraper(BaseScraper):
|
|||
async def fetch_jk(
|
||||
self, jk_slug: str, jk_id: str, city: str = "ekaterinburg"
|
||||
) -> 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}/"
|
||||
try:
|
||||
response = await self._http_get(url)
|
||||
async with BrowserFetcher() as fetcher:
|
||||
html = await fetcher.fetch(url)
|
||||
except Exception:
|
||||
logger.exception("yandex nb fetch failed: %s", url)
|
||||
logger.exception("yandex nb browser fetch failed: %s", url)
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
logger.warning("yandex nb returned %d for %s", response.status_code, url)
|
||||
if not html or len(html) < 500:
|
||||
logger.warning(
|
||||
"yandex nb browser returned empty/tiny HTML (%d bytes): %s",
|
||||
len(html) if html else 0,
|
||||
url,
|
||||
)
|
||||
return None
|
||||
result = self.parse(response.text, jk_slug=jk_slug, jk_id=jk_id, source_url=url)
|
||||
await self.sleep_between_requests()
|
||||
result = self.parse(html, jk_slug=jk_slug, jk_id=jk_id, source_url=url)
|
||||
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)
|
||||
body = tree.body
|
||||
body_text = body.text(strip=True) if body else ""
|
||||
|
|
@ -208,9 +227,7 @@ class YandexNewbuildingScraper(BaseScraper):
|
|||
href = dev_link.attributes.get("href", "")
|
||||
if href:
|
||||
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
|
||||
|
|
@ -269,6 +286,60 @@ 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -354,4 +425,5 @@ __all__ = [
|
|||
"JKMetroStation",
|
||||
"YandexNewbuildingInfo",
|
||||
"YandexNewbuildingScraper",
|
||||
"resolve_yandex_jk_slug",
|
||||
]
|
||||
|
|
|
|||
423
tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py
Normal file
423
tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"""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 используется для resolve→enrich gap внутри одного дома.
|
||||
"""
|
||||
if delay <= 0:
|
||||
return
|
||||
if not force and idx >= total - 1:
|
||||
return
|
||||
await asyncio.sleep(delay * random.uniform(0.8, 1.2))
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
-- 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;
|
||||
397
tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py
Normal file
397
tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
"""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
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
"""Tests for cian_newbuilding scraper (Stage 6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
|
@ -247,42 +248,72 @@ def test_dataclass_defaults():
|
|||
|
||||
|
||||
# ---- 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
|
||||
async def test_fetch_newbuilding_returns_none_on_no_state(monkeypatch):
|
||||
"""Если state extraction fails → return None gracefully."""
|
||||
_make_browser_fetcher_mock("<html></html>", monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.cian_newbuilding.extract_state",
|
||||
lambda html, mfe, key: None,
|
||||
)
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
session = MagicMock()
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.text = "<html></html>"
|
||||
session.get = AsyncMock(return_value=response)
|
||||
session.close = AsyncMock()
|
||||
|
||||
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session)
|
||||
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_newbuilding_returns_none_on_http_error(monkeypatch):
|
||||
"""HTTP non-200 → return None gracefully."""
|
||||
async def test_fetch_newbuilding_browser_error_propagates(monkeypatch):
|
||||
"""BrowserFetcher.fetch raises → fetch_newbuilding propagates the exception."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
session = MagicMock()
|
||||
response = MagicMock()
|
||||
response.status_code = 403
|
||||
response.text = "Forbidden"
|
||||
session.get = AsyncMock(return_value=response)
|
||||
session.close = AsyncMock()
|
||||
mock_fetcher = MagicMock()
|
||||
mock_fetcher.fetch = AsyncMock(side_effect=RuntimeError("browser timeout"))
|
||||
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,
|
||||
)
|
||||
|
||||
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session)
|
||||
with pytest.raises(RuntimeError, match="browser timeout"):
|
||||
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_newbuilding_session_param_ignored(monkeypatch):
|
||||
"""session= param принимается для backward-compat, но не используется для page-fetch."""
|
||||
_make_browser_fetcher_mock("<html></html>", monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.cian_newbuilding.extract_state",
|
||||
lambda html, mfe, key: None,
|
||||
)
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sentinel_session = MagicMock(name="should_not_be_called")
|
||||
# Передаём session= — должно работать без ошибок, session.get НЕ вызывается
|
||||
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=sentinel_session)
|
||||
assert result is None
|
||||
sentinel_session.get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -318,6 +349,7 @@ async def test_fetch_newbuilding_parses_state(monkeypatch):
|
|||
},
|
||||
}
|
||||
|
||||
_make_browser_fetcher_mock("<html>fake</html>", monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.cian_newbuilding.extract_state",
|
||||
lambda html, mfe, key: fake_state,
|
||||
|
|
@ -327,16 +359,7 @@ async def test_fetch_newbuilding_parses_state(monkeypatch):
|
|||
lambda html: {},
|
||||
)
|
||||
|
||||
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)
|
||||
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
|
||||
|
||||
assert result is not None
|
||||
assert result.cian_internal_house_id == 54016
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Offline smoke for scheduler logic."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
|
@ -170,3 +172,195 @@ def test_claim_run_rechecks_running_under_lock(monkeypatch: pytest.MonkeyPatch)
|
|||
assert _sched._claim_run(db, _SCHED_ROW) is None
|
||||
assert calls["n"] == 0
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ Covers:
|
|||
- None/empty → direct connection (no proxies dict)
|
||||
- _avito_proxies() helper returns correct dict shape
|
||||
- per-scraper proxy wiring: YandexRealty, YandexValuation, CianValuation,
|
||||
CianNewbuilding (fetch_newbuilding + resolve_cian_zhk_url), AvitoIMV, AvitoDetail
|
||||
CianNewbuilding (fetch_newbuilding — now BrowserFetcher, proxy is browser-level),
|
||||
AvitoIMV, AvitoDetail
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -267,59 +268,81 @@ async def test_yandex_valuation_session_no_proxies_when_none():
|
|||
|
||||
|
||||
# ── CianNewbuilding — fetch_newbuilding ───────────────────────────────────────
|
||||
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
|
||||
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
|
||||
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_newbuilding_own_session_receives_proxies():
|
||||
"""fetch_newbuilding creates own session with proxies= when no session passed."""
|
||||
"""fetch_newbuilding использует BrowserFetcher для page-fetch, не AsyncSession.
|
||||
|
||||
Проверяем:
|
||||
- BrowserFetcher.fetch(url) вызывается с правильным URL
|
||||
- curl_cffi AsyncSession НЕ создаётся для page-fetch (прокси — browser-level)
|
||||
"""
|
||||
from app.services.scrapers import cian_newbuilding
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
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()
|
||||
zhk_url = "https://zhk-test.cian.ru/"
|
||||
fetch_calls: list[str] = []
|
||||
session_ctor_calls: list[dict] = []
|
||||
|
||||
captured_kwargs: dict = {}
|
||||
mock_fetcher = MagicMock()
|
||||
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_session(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return mock_session
|
||||
def _fake_async_session(*args, **kwargs):
|
||||
session_ctor_calls.append(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
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/")
|
||||
with patch.object(cian_newbuilding, "BrowserFetcher", lambda: mock_fetcher):
|
||||
with patch.object(cian_newbuilding, "AsyncSession", _fake_async_session):
|
||||
await cian_newbuilding.fetch_newbuilding(zhk_url)
|
||||
|
||||
assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES
|
||||
# BrowserFetcher.fetch вызван с ЖК-url
|
||||
assert fetch_calls == [zhk_url]
|
||||
# curl_cffi AsyncSession НЕ создавался для page-fetch
|
||||
assert session_ctor_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_newbuilding_shared_session_not_recreated():
|
||||
"""fetch_newbuilding does NOT create own session when one is passed."""
|
||||
"""fetch_newbuilding принимает session= для backward-compat, но использует BrowserFetcher.
|
||||
|
||||
Даже когда передан внешний session — страница ЖК тянется через BrowserFetcher,
|
||||
AsyncSession не пересоздаётся, session.get не вызывается.
|
||||
"""
|
||||
from app.services.scrapers import cian_newbuilding
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = ""
|
||||
mock_session.get = AsyncMock(return_value=mock_resp)
|
||||
mock_session.close = AsyncMock()
|
||||
zhk_url = "https://zhk-test.cian.ru/"
|
||||
fetch_calls: list[str] = []
|
||||
session_ctor_calls: list[dict] = []
|
||||
|
||||
session_ctor_calls = []
|
||||
mock_fetcher = MagicMock()
|
||||
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_session(*args, **kwargs):
|
||||
def _fake_async_session(*args, **kwargs):
|
||||
session_ctor_calls.append(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
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
|
||||
)
|
||||
sentinel_session = MagicMock(name="external_session")
|
||||
|
||||
# No own session should have been created (shared session was passed)
|
||||
with patch.object(cian_newbuilding, "BrowserFetcher", lambda: mock_fetcher):
|
||||
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 == []
|
||||
sentinel_session.get.assert_not_called()
|
||||
|
||||
|
||||
# ── AvitoDetail — fetch_detail own-session ───────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ _browser_cm: object | None = None # AsyncCamoufox context manager (для
|
|||
_page_counter: int = 0 # страниц с момента последнего (пере)запуска
|
||||
_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:
|
||||
"""Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
|
||||
|
|
@ -147,16 +156,105 @@ async def _relaunch_browser() -> None:
|
|||
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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _on_startup(app: web.Application) -> None:
|
||||
global _fetch_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:
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -164,7 +262,12 @@ async def _on_cleanup(app: web.Application) -> None:
|
|||
|
||||
|
||||
async def health_handler(request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
# Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер,
|
||||
# хотя процесс жив и ретраит браузер. 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:
|
||||
|
|
@ -187,6 +290,18 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
assert _fetch_lock is not None, "_fetch_lock not initialised"
|
||||
|
||||
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:
|
||||
html = await _do_fetch(url)
|
||||
except Exception as exc:
|
||||
|
|
@ -290,6 +405,16 @@ async def login_handler(request: web.Request) -> web.Response:
|
|||
assert _fetch_lock is not None, "_fetch_lock not initialised"
|
||||
|
||||
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:
|
||||
cookies = await _do_login(body)
|
||||
except LoginError as exc:
|
||||
|
|
|
|||
124
tradein-mvp/browser/test_server_smoke.py
Normal file
124
tradein-mvp/browser/test_server_smoke.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""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
|
||||
Loading…
Add table
Reference in a new issue