Merge remote-tracking branch 'origin/main' into fix/canon-addr-slash
Some checks failed
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m46s

This commit is contained in:
bot-backend 2026-09-17 16:23:58 +05:00
commit 8bd16e6ad0
55 changed files with 4298 additions and 482 deletions

View file

@ -260,10 +260,12 @@ jobs:
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
# не бежали ни разу с момента написания. # не бежали ни разу с момента написания.
# #
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные # postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — # таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не # модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
# нужен, в отличие от tradein-лэйна. # ST_DWithin по geography, без расширения его не исполнить. На plain
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
# #
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
@ -294,15 +296,14 @@ jobs:
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
# #
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные # postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы # схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
# здесь не нужен вовсе, в отличие от tradein-лэйна.
run: | run: |
set -u set -u
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \ docker run -d --name "$CI_PG" \
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
postgres:16 postgis/postgis:16-3.4
ready="" ready=""
for _ in $(seq 1 45); do for _ in $(seq 1 45); do

View file

@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
""" """
objects_with_velocity_data: int # число комплексов с velocity-данными objects_with_velocity_data: int # число комплексов с velocity-данными
objects_total_in_radius: int # число комплексов (сгруппированных obj_id) objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage)
raw_objects_total: int # сырое число obj_id в радиусе (до группировки) raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по # Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id — # нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —

View file

@ -587,19 +587,6 @@ class NSPDBulkClient:
) )
return results return results
async def get_territorial_zones_in_bbox(
self,
bbox: tuple[float, float, float, float],
*,
grid_n: int = 7,
) -> list[dict]:
"""Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны).
Returns: list of feature dicts с полями id, geometry, properties.
Дедуплицирует по feature id.
"""
return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n)
# ── 4. list_objects_in_building ─────────────────────────────────────────── # ── 4. list_objects_in_building ───────────────────────────────────────────
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP. # Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
# Готов для per-building помещения/парковка фазы. # Готов для per-building помещения/парковка фазы.

View file

@ -19,7 +19,6 @@ Resumable: phase_state в cadastre_jobs показывает прогресс.
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import logging import logging
from collections.abc import Callable from collections.abc import Callable
@ -260,10 +259,8 @@ async def harvest_quarter(
update_progress(done_progress) update_progress(done_progress)
# ── Phase 4: quarter stats + auto-heal geom из snapshot ───────────────── # ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе # Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None →
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча # grid-walk следующего прогона молча пропускается. Чиним geom из snapshot.
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS] stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
if stats_features: if stats_features:
upsert_quarter_stats(db, quarter, stats_features[0]) upsert_quarter_stats(db, quarter, stats_features[0])
@ -278,25 +275,6 @@ async def harvest_quarter(
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e) logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
db.commit() db.commit()
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
quarter_bbox = quarter_bbox_3857(db, quarter)
if quarter_bbox is not None:
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
try:
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
tz_count = _save_territorial_zones(db, quarter, tz_features)
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
raise
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем # Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) — # сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в # per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
@ -679,7 +657,7 @@ def upsert_features(
cat = feature.category_id cat = feature.category_id
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case # Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx # GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
# квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234): # квартала. Зеркалит _grid_walk_category:
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик # begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными. # инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
try: try:
@ -1476,114 +1454,6 @@ def upsert_quarter_stats(
) )
# ── cad_territorial_zones upsert ────────────────────────────────────────────
def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) -> int:
"""UPSERT territorial_zones features в cad_territorial_zones по zone_id.
Args:
db: SQLAlchemy session.
quarter_cad: кадастровый номер квартала (3 сегмента).
features: list of raw feature dicts от get_features_in_bbox_grid.
Returns:
Количество успешно upserted строк.
"""
inserted = 0
for f in features:
props: dict = f.get("properties") or {}
geom = f.get("geometry")
geom_geojson: str | None = json.dumps(geom) if geom else None
# zone_id — NSPD feature id или стабильный fallback на основе md5 от properties.
# md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается).
_raw_id = props.get("id") or props.get("zone_id") or f.get("id")
if _raw_id:
zone_id = str(_raw_id)
else:
_props_hash = hashlib.md5(
json.dumps(props, sort_keys=True).encode("utf-8")
).hexdigest()[:12]
zone_id = f"{quarter_cad}_{_props_hash}"
zone_code = (
props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border")
)
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
permitted_use = props.get("permitted_use") or props.get("vri")
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
# Зеркалит фильтр upsert_zouit (~1196).
geom_type = geom.get("type") if isinstance(geom, dict) else None
if geom_type not in ("Polygon", "MultiPolygon"):
if geom_type:
logger.info(
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
" — geom=NULL",
zone_id,
geom_type,
)
geom_geojson = None
try:
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
# db.execute() в этом loop — outer tx гарантирована.
with db.begin_nested():
db.execute(
text("""
INSERT INTO cad_territorial_zones
(quarter_cad, zone_id, zone_code, zone_name,
permitted_use, raw_props, geom)
VALUES (
CAST(:quarter_cad AS text),
CAST(:zone_id AS text),
CAST(:zone_code AS text),
CAST(:zone_name AS text),
CAST(:permitted_use AS text),
CAST(:raw_props AS jsonb),
CASE WHEN CAST(:geom AS text) IS NOT NULL
THEN ST_Multi(
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
4326
)
)::geography
ELSE NULL
END
)
ON CONFLICT (zone_id) DO UPDATE SET
zone_code = EXCLUDED.zone_code,
zone_name = EXCLUDED.zone_name,
permitted_use = EXCLUDED.permitted_use,
raw_props = EXCLUDED.raw_props,
geom = EXCLUDED.geom,
fetched_at = NOW()
"""),
{
"quarter_cad": quarter_cad,
"zone_id": zone_id,
"zone_code": zone_code,
"zone_name": zone_name,
"permitted_use": permitted_use,
"raw_props": json.dumps(props, ensure_ascii=False),
"geom": geom_geojson,
},
)
inserted += 1
except Exception as e:
logger.warning(
"_save_territorial_zones: upsert failed zone_id=%s quarter=%s: %s",
zone_id,
quarter_cad,
e,
)
db.commit()
return inserted
# ── Утилиты ────────────────────────────────────────────────────────────────── # ── Утилиты ──────────────────────────────────────────────────────────────────

View file

@ -1109,9 +1109,6 @@ def get_best_layouts(
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows] id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts] all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
raw_objects_total_in_radius = len(all_obj_ids) raw_objects_total_in_radius = len(all_obj_ids)
# Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо
# со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0).
groups_total_pre_filter = len(group_radius_objects(id_row_dicts))
# Применить exclude / filter из request (и к obj_ids, и к строкам для группировки) # Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
exclude_set = set(request.exclude_competitor_obj_ids) exclude_set = set(request.exclude_competitor_obj_ids)
@ -1132,13 +1129,16 @@ def get_best_layouts(
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)] all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage. # Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
# objects_total_in_radius во ВСЕХ ветках = len(complex_groups) (после фильтра):
# UI и PDF печатают его как N в «покрытие P% (Y из N комплексов)», а P считается
# по отфильтрованным (#2464). Число до фильтра — в raw_objects_total.
complex_groups = group_radius_objects(id_row_dicts) complex_groups = group_radius_objects(id_row_dicts)
if not all_obj_ids: if not all_obj_ids:
return _empty_response( return _empty_response(
radius_km=request.radius_km, radius_km=request.radius_km,
time_window=request.time_window, time_window=request.time_window,
objects_total_in_radius=groups_total_pre_filter, objects_total_in_radius=len(complex_groups),
raw_objects_total=raw_objects_total_in_radius, raw_objects_total=raw_objects_total_in_radius,
) )

View file

@ -344,21 +344,26 @@ _COMPETITORS_SQL = text("""
-- важна velocity питает forecast). Спатиальный join дёшев, т.к. -- важна velocity питает forecast). Спатиальный join дёшев, т.к.
-- complexes мал (~1.5k строк с geom) НЕ потому что latest_obj -- complexes мал (~1.5k строк с geom) НЕ потому что latest_obj
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT). -- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
--
-- #2962: complex → проект Объектива берём из complex_sources (source=
-- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен
-- один раз миграцией 76 на загрузке 10.05, а еженедельный
-- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает
-- project_name и не трогает complex_id под одним complex_id лежат чужие ЖК
-- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов).
SELECT DISTINCT ON (o.obj_id) SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.obj_id,
c.id AS complex_id cs.source_id AS project_name,
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM latest_obj o FROM latest_obj o
JOIN complexes c JOIN complexes c
ON c.latitude IS NOT NULL ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL AND c.canonical_name IS NOT NULL
-- #968: только complexes, у которых ЕСТЬ objective velocity-данные →
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
-- без objective_lots съедал бы матч (prod: +37 наивно +181 так).
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin( AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -369,6 +374,20 @@ _COMPETITORS_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
) )
JOIN complex_sources cs
ON cs.complex_id = c.id
AND cs.source = 'objective'
-- #968: только complexes, у которых ЕСТЬ objective-данные →
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
-- без objective_lots съедал бы матч (prod: +37 наивно +181 так).
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id
)
CROSS JOIN LATERAL (
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
) k
WHERE o.comm_name IS NOT NULL WHERE o.comm_name IS NOT NULL
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping) AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
@ -376,7 +395,13 @@ _COMPETITORS_SQL = text("""
ST_Distance( ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC ) ASC,
-- #2962: 1:1 complex → objective-проект держится только на данных
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
-- брал любой, сверка его отвергала, и верный терялся.
name_ok DESC,
cs.source_id
), ),
mapped AS ( mapped AS (
-- PRIMARY (unchanged): 148 явных маппингов velocity для них byte-identical. -- PRIMARY (unchanged): 148 явных маппингов velocity для них byte-identical.
@ -384,15 +409,18 @@ _COMPETITORS_SQL = text("""
cm.objective_complex_name cm.objective_complex_name
FROM objective_complex_mapping cm FROM objective_complex_mapping cm
UNION UNION
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а -- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
-- (у комплекса может быть несколько корпус-project_name velocity -- (корпуса у Объектива corpus_name внутри проекта, не отдельные имена).
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
SELECT nc.obj_id, SELECT nc.obj_id,
ol.project_name AS objective_complex_name nc.project_name AS objective_complex_name
FROM nearest_cx nc FROM nearest_cx nc
JOIN objective_lots ol -- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
ON ol.complex_id = nc.complex_id -- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит»
AND ol.project_name IS NOT NULL -- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
-- гео-фильтра 3 с против 0.4 с.
WHERE nc.name_ok
), ),
velocity AS ( velocity AS (
SELECT SELECT
@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text("""
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает # fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots: # avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name # PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id → # GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и # (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК). # ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ # obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен. # mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
_OBJECTIVE_PRICE_FALLBACK_SQL = text(""" _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
nearest_cx AS ( nearest_cx AS (
SELECT DISTINCT ON (o.obj_id) SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.obj_id,
c.id AS complex_id cs.source_id AS project_name,
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM domrf_kn_objects o FROM domrf_kn_objects o
JOIN complexes c JOIN complexes c
ON c.latitude IS NOT NULL ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL AND c.canonical_name IS NOT NULL
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin( AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -644,6 +675,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
) )
JOIN complex_sources cs
ON cs.complex_id = c.id
AND cs.source = 'objective'
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id
)
CROSS JOIN LATERAL (
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
) k
WHERE o.obj_id = ANY(:obj_ids) WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL AND o.longitude IS NOT NULL
@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ST_Distance( ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC ) ASC,
-- #2962: 1:1 complex → objective-проект держится только на данных
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
-- брал любой, сверка его отвергала, и верный терялся.
name_ok DESC,
cs.source_id
), ),
gapfill_price AS ( gapfill_price AS (
SELECT SELECT
@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ol.price_per_m2_rub AS price_per_m2_rub ol.price_per_m2_rub AS price_per_m2_rub
FROM nearest_cx nc FROM nearest_cx nc
JOIN objective_lots ol JOIN objective_lots ol
ON ol.complex_id = nc.complex_id ON ol.project_name = nc.project_name
AND ol.price_per_m2_rub IS NOT NULL AND ol.price_per_m2_rub IS NOT NULL
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
WHERE nc.name_ok
) )
SELECT SELECT
p.obj_id, p.obj_id,

View file

@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни # вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём: # разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD # zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
# territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat # territorial_zones в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал # каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
# ошибку как error → рекуррентил GlitchTip BACKEND-1B. # ошибку как error → рекуррентил GlitchTip BACKEND-1B.
# #

View file

@ -264,8 +264,10 @@ def test_exclude_competitor_obj_ids_filter() -> None:
# После исключения obj_id=20 список пуст → пустой ответ # После исключения obj_id=20 список пуст → пустой ответ
assert body["top_layouts"] == [] assert body["top_layouts"] == []
assert body["data_quality"]["confidence"] == "low" assert body["data_quality"]["confidence"] == "low"
# objects_total_in_radius = 1 (до исключения) # objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464);
assert body["data_quality"]["objects_total_in_radius"] == 1 # число obj_id в радиусе до исключения — raw_objects_total
assert body["data_quality"]["objects_total_in_radius"] == 0
assert body["data_quality"]["raw_objects_total"] == 1
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()

View file

@ -1,198 +0,0 @@
"""Тесты для _save_territorial_zones (bulk_harvest.py) — mock-based.
Проверяет:
- Успешный UPSERT 3 features 3 строки вставлены
- Повторный вызов ON CONFLICT обновляет, не дублирует
- Feature без geometry строка вставлена с geom=NULL, без краша
- Feature без zone_id синтетический fallback zone_id используется
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from app.services.cadastre.bulk_harvest import _save_territorial_zones
def _make_feature(
feature_id: Any = "zone_1",
zone_code: str = "Ж-1",
zone_name: str = "Жилая смешанная",
permitted_use: str = "ИЖС",
has_geometry: bool = True,
) -> dict:
"""Создать raw feature dict в формате get_features_in_bbox_grid."""
geom = (
{
"type": "Polygon",
"coordinates": [
[
[6090000.0, 7590000.0],
[6090100.0, 7590000.0],
[6090100.0, 7590100.0],
[6090000.0, 7590100.0],
[6090000.0, 7590000.0],
]
],
}
if has_geometry
else None
)
return {
"id": feature_id,
"geometry": geom,
"properties": {
"zone_code": zone_code,
"zone_name": zone_name,
"permitted_use": permitted_use,
},
}
def _make_db_mock() -> MagicMock:
"""Mock SQLAlchemy Session с begin_nested() savepoint support."""
db = MagicMock()
# begin_nested() используется как context manager
savepoint_ctx = MagicMock()
savepoint_ctx.__enter__ = MagicMock(return_value=savepoint_ctx)
savepoint_ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = savepoint_ctx
return db
class TestSaveTerritorialZones:
"""Тесты для _save_territorial_zones."""
def test_three_features_inserted(self) -> None:
"""3 features → returned count == 3, execute вызван 3 раза."""
db = _make_db_mock()
features = [
_make_feature("z1", "Ж-1"),
_make_feature("z2", "ОД-1"),
_make_feature("z3", "П-1"),
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 3
assert db.execute.call_count == 3
db.commit.assert_called_once()
def test_empty_features_list(self) -> None:
"""Пустой список → 0 inserted, commit всё равно вызван."""
db = _make_db_mock()
result = _save_territorial_zones(db, "66:41:0204016", [])
assert result == 0
db.execute.assert_not_called()
db.commit.assert_called_once()
def test_feature_without_geometry_no_crash(self) -> None:
"""Feature без geometry → geom=NULL, строка вставлена без краша."""
db = _make_db_mock()
features = [_make_feature("zone_no_geom", has_geometry=False)]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
# Проверяем что geom параметр передан как None
call_kwargs: dict = db.execute.call_args[0][1]
assert call_kwargs["geom"] is None
def test_feature_without_zone_id_uses_fallback(self) -> None:
"""Feature без id → md5-based fallback zone_id (stable между runs)."""
db = _make_db_mock()
features = [
{
"id": None,
"geometry": None,
"properties": {"zone_code": "Ж-2"},
}
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
call_kwargs = db.execute.call_args[0][1]
zone_id: str = call_kwargs["zone_id"]
# fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars)
assert zone_id.startswith("66:41:0204016_")
suffix = zone_id.split("_", 3)[-1]
assert len(suffix) == 12
assert all(c in "0123456789abcdef" for c in suffix)
# Второй вызов с теми же данными → тот же zone_id (идемпотентность)
db2 = _make_db_mock()
_save_territorial_zones(db2, "66:41:0204016", features)
call_kwargs2 = db2.execute.call_args[0][1]
assert call_kwargs2["zone_id"] == zone_id
def test_zone_id_from_props_id(self) -> None:
"""Если feature.id=None, но props['id'] есть — используется props['id']."""
db = _make_db_mock()
features = [
{
"id": None,
"geometry": None,
"properties": {"id": "props_id_42", "zone_code": "Ж-3"},
}
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["zone_id"] == "props_id_42"
def test_execute_error_logged_not_raised(self) -> None:
"""Exception в execute → строка не вставлена, warning залогирован, не re-raise."""
db = _make_db_mock()
db.execute.side_effect = RuntimeError("DB error")
features = [_make_feature("z_err")]
# Не должен бросить исключение
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 0
db.commit.assert_called_once()
def test_savepoint_used_per_row(self) -> None:
"""begin_nested() вызывается для каждой строки (SAVEPOINT паттерн)."""
db = _make_db_mock()
features = [_make_feature(f"z{i}") for i in range(3)]
_save_territorial_zones(db, "66:41:0204016", features)
assert db.begin_nested.call_count == 3
def test_quarter_cad_param_passed(self) -> None:
"""quarter_cad правильно передаётся в SQL параметры."""
db = _make_db_mock()
features = [_make_feature("zone_check")]
_save_territorial_zones(db, "66:41:9999999", features)
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["quarter_cad"] == "66:41:9999999"
def test_raw_props_serialized(self) -> None:
"""raw_props — JSON строка из properties dict."""
import json
db = _make_db_mock()
features = [
{
"id": "z_props",
"geometry": None,
"properties": {"zone_code": "ОД-2", "extra": "value"},
}
]
_save_territorial_zones(db, "66:41:0204016", features)
call_kwargs = db.execute.call_args[0][1]
raw = json.loads(call_kwargs["raw_props"])
assert raw["zone_code"] == "ОД-2"
assert raw["extra"] == "value"

View file

@ -0,0 +1,246 @@
"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962).
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354
проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в
медиане в 39 раз.
Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и
`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии.
Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть
(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается
никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI,
на машине без базы, и объявлен в skip_allowlist.txt.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _postgis_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
return True, ""
return False, "нет расширения postgis"
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _postgis_reachable()
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
pytestmark = pytest.mark.skipif(
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
)
_SCHEMA = [
"""CREATE TEMP TABLE domrf_kn_objects (
obj_id bigint, comm_name text, dev_name text, obj_class text,
obj_class_fallback text, latitude double precision, longitude double precision,
flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_complex_mapping (
domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""",
"""CREATE TEMP TABLE complexes (
id bigint, canonical_name text, latitude double precision,
longitude double precision) ON COMMIT DROP""",
"""CREATE TEMP TABLE complex_sources (
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_lots (
project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_corpus_room_month (
project_name text, report_month date, deals_total_count int) ON COMMIT DROP""",
]
MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8
_DATA = [
# obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга
"""INSERT INTO domrf_kn_objects VALUES
(1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 500, 'Строящиеся', CURRENT_DATE),
(2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE),
(3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500,
'Строящиеся', CURRENT_DATE),
(4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE),
(5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся',
CURRENT_DATE),
(6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE),
(7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE),
(8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""",
"INSERT INTO objective_complex_mapping VALUES (4, 'Явный')",
"""INSERT INTO complexes VALUES
(10, 'ЖК Мичуринский', 56.840, 60.600),
(20, 'ЖК VEER PARK', 56.850, 60.620),
(30, 'СтудияПарк', 56.830, 60.580),
(50, 'Квартал "Татлин"', 56.835, 60.610),
(60, 'Парковый', 56.855, 60.590),
(70, 'Роща', 56.825, 60.630),
(71, 'Роща Парк', 56.8259, 60.630),
(80, 'ЖК Сосны', 56.860, 60.650)""",
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
# 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м).
# 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает.
"""INSERT INTO complex_sources VALUES
(10, 'objective', 'Мичуринский'),
(20, 'objective', 'Clever Park'),
(30, 'objective', 'Студия Парк'),
(50, 'objective', 'Квартал Татлин'),
(60, 'objective', 'Парковый квартал'),
(70, 'objective', 'Роща'),
(71, 'objective', 'Роща Парк'),
(80, 'objective', 'Клён'),
(80, 'objective', 'Сосны')""",
# Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим
# complex_id; два своих лота новые — complex_id у них NULL.
"""INSERT INTO objective_lots VALUES
('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000),
('Мичуринский', NULL, 120000),
('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000),
('Clever Park', NULL, 150000),
('Студия Парк', NULL, 90000),
('Явный', NULL, 200000),
('Квартал Татлин', NULL, 95000),
('Парковый квартал', NULL, 130000),
('Роща Парк', NULL, 105000),
('Клён', NULL, 250000),
('Сосны', NULL, 115000)""",
"""INSERT INTO objective_corpus_room_month VALUES
('Мичуринский', date_trunc('month', now())::date, 30),
('Малахит', date_trunc('month', now())::date, 300),
('Clever Park', date_trunc('month', now())::date, 90),
('Студия Парк', date_trunc('month', now())::date, 15),
('Явный', date_trunc('month', now())::date, 60),
('Квартал Татлин', date_trunc('month', now())::date, 18),
('Парковый квартал', date_trunc('month', now())::date, 12),
('Роща Парк', date_trunc('month', now())::date, 21),
('Клён', date_trunc('month', now())::date, 240),
('Сосны', date_trunc('month', now())::date, 27)""",
]
@pytest.fixture(scope="module")
def result() -> tuple[dict[int, float], dict[int, float]]:
"""(velocity по obj_id, медианная цена objective-fallback по obj_id)."""
from app.services.site_finder.competitors import (
_COMPETITORS_SQL,
_OBJECTIVE_PRICE_FALLBACK_SQL,
_VELOCITY_MATCH_RADIUS_M,
)
session = sessionmaker(bind=create_engine(_dsn()))()
try:
for stmt in _SCHEMA + _DATA:
session.execute(text(stmt))
vel_rows = session.execute(
_COMPETITORS_SQL,
{
"center_lon": 60.6,
"center_lat": 56.84,
"radius_m": 5000.0,
"time_window_months": 3.0,
"window_interval": "3 months",
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
).mappings()
velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows}
price_rows = session.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES],
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
).mappings()
price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows}
return velocity, price
finally:
session.rollback()
session.close()
def test_gapfill_velocity_counts_only_own_project(result) -> None:
"""30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110."""
velocity, _ = result
assert velocity[MICH] == pytest.approx(10.0), velocity
def test_gapfill_price_is_median_of_own_lots(result) -> None:
"""Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс."""
_, price = result
assert price[MICH] == pytest.approx(110000.0), price
def test_wrong_fuzzy_link_gives_no_numbers(result) -> None:
"""«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём."""
velocity, price = result
assert velocity[VEER] == 0.0, velocity
assert VEER not in price, price
def test_space_difference_still_matches(result) -> None:
"""«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс."""
velocity, price = result
assert velocity[STUDIO] == pytest.approx(5.0), velocity
assert price[STUDIO] == pytest.approx(90000.0), price
def test_explicit_mapping_unchanged(result) -> None:
"""Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс."""
velocity, price = result
assert velocity[EXPLICIT] == pytest.approx(20.0), velocity
assert price[EXPLICIT] == pytest.approx(200000.0), price
def test_quotes_inside_name_still_match(result) -> None:
"""«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают.
Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в
друга. 18 сделок / 3 = 6, цена 95 тыс.
"""
velocity, price = result
assert velocity[TATLIN] == pytest.approx(6.0), velocity
assert price[TATLIN] == pytest.approx(95000.0), price
def test_project_name_longer_than_object_name_matches(result) -> None:
"""Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4."""
velocity, price = result
assert velocity[PARK] == pytest.approx(4.0), velocity
assert price[PARK] == pytest.approx(130000.0), price
def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None:
"""#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м.
Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс.
"""
velocity, price = result
assert velocity[GROVE] == pytest.approx(7.0), velocity
assert price[GROVE] == pytest.approx(105000.0), price
def test_complex_with_two_projects_takes_the_matching_one(result) -> None:
"""У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени.
Без этого DISTINCT ON брал любой из двух (здесь «Клён»), сверка его отвергала,
и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс.
"""
velocity, price = result
assert velocity[PINES] == pytest.approx(9.0), velocity
assert price[PINES] == pytest.approx(115000.0), price

View file

@ -433,7 +433,43 @@ def test_exclude_competitor_obj_ids() -> None:
resp = get_best_layouts(db, CAD_NUM, req) resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts == [] assert resp.top_layouts == []
assert resp.data_quality.objects_total_in_radius == 1 # после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id
assert resp.data_quality.objects_total_in_radius == 0
assert resp.data_quality.raw_objects_total == 1
@pytest.mark.parametrize(
("filter_ids", "vel_obj_ids", "expected_total", "expected_with_data"),
[
([99], None, 0, 0), # фильтр не оставил никого → ранний пустой ответ
([20], None, 1, 0), # остался 1 комплекс без velocity → пустой ответ
([20], [20], 1, 1), # остался 1 комплекс с velocity → штатная ветка
],
ids=["all-filtered", "no-velocity", "normal"],
)
def test_objects_total_is_coverage_denominator_in_every_branch(
filter_ids: list[int],
vel_obj_ids: list[int] | None,
expected_total: int,
expected_with_data: int,
) -> None:
"""#2464 (best_layouts:1113): objects_total_in_radius значит одно во всех ветках.
UI и PDF печатают «покрытие P% (Y из N комплексов)», значит N обязан быть
знаменателем P, а P считается по комплексам ПОСЛЕ filter. Раньше ранний пустой
ответ отдавал число ДО фильтра (2), две другие ветки после.
"""
id_rows = [_obj_id_row(20), _obj_id_row(30)]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=vel_obj_ids)] if vel_obj_ids else []
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
resp = get_best_layouts(db, CAD_NUM, _request(filter_competitor_obj_ids=filter_ids))
dq = resp.data_quality
assert dq.objects_total_in_radius == expected_total
assert dq.objects_with_velocity_data == expected_with_data
assert dq.raw_objects_total == 2 # число до фильтра — отдельным полем
expected_pct = expected_with_data / expected_total * 100.0 if expected_total else 0.0
assert dq.velocity_coverage_pct == pytest.approx(expected_pct)
# ── Тест: total_sold_in_window совпадает с deals_window ────────────────────── # ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────

View file

@ -489,7 +489,7 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non
db = MagicMock() db = MagicMock()
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter. # Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы корректно пропускаются (тест про # grid-walk фаза корректно пропускается (тест про
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ # snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
# db.execute().mappings().first() в этом тесте. # db.execute().mappings().first() в этом тесте.
db.execute = MagicMock( db.execute = MagicMock(
@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
db = MagicMock() db = MagicMock()
# phase_state = None → начинаем с нуля # phase_state = None → начинаем с нуля
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / # grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ # per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы. # db.execute().mappings().first() вызовы.
db.execute = MagicMock( db.execute = MagicMock(
@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
db = MagicMock() db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / # grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ # per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы. # db.execute().mappings().first() вызовы.
db.execute = MagicMock( db.execute = MagicMock(
@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
db = MagicMock() db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / # grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ # per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы. # db.execute().mappings().first() вызовы.
db.execute = MagicMock( db.execute = MagicMock(
@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
db = MagicMock() db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / # grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ # per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы. # db.execute().mappings().first() вызовы.
db.execute = MagicMock( db.execute = MagicMock(
@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
db = MagicMock() db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / # grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ # per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы. # db.execute().mappings().first() вызовы.
db.execute = MagicMock( db.execute = MagicMock(
@ -1518,7 +1518,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
db = _mock_db_grid_bbox() db = _mock_db_grid_bbox()
client = AsyncMock() client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=snapshot) client.search_by_quarter = AsyncMock(return_value=snapshot)
client.get_territorial_zones_in_bbox = AsyncMock(return_value=[])
progress_states: list[dict[str, Any]] = [] progress_states: list[dict[str, Any]] = []
@ -1557,6 +1556,42 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
assert progress_states[-1]["phase"] == "done" assert progress_states[-1]["phase"] == "done"
@pytest.mark.asyncio
async def test_harvest_quarter_makes_no_territorial_zones_request_2985() -> None:
"""#2985: Phase 2.5 удалена. Квартал без overflow с валидным bbox стоит ровно один
запрос к НСПД (search_by_quarter) отдельного grid-walk за ПЗЗ больше нет."""
from app.services.cadastre.bulk_harvest import harvest_quarter
snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:00+00:00",
features=[_make_parcel_feature()],
meta_counts={},
)
client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=snapshot)
progress_states: list[dict[str, Any]] = []
with (
patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert,
patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox,
):
mock_bbox.return_value = (6735845.0, 8329000.0, 6736595.0, 8329750.0)
mock_upsert.return_value = dict.fromkeys(
("parcels", "buildings", "constructions", "oncs", "enks", "zouit", "skipped"), 0
)
await harvest_quarter(
db=_mock_db_grid_bbox(),
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=progress_states.append,
)
assert [c[0] for c in client.mock_calls] == ["search_by_quarter"]
assert [s["phase"] for s in progress_states] == ["snapshot_started", "snapshot_done", "done"]
# ── Issue #200: geom backfill для участков с geom IS NULL ───────────────────── # ── Issue #200: geom backfill для участков с geom IS NULL ─────────────────────

View file

@ -144,6 +144,23 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ─────────
# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют
# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по
# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься
# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны
# только для машины без базы.
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one
# ── #2464: backfill act_date (миграция 191) ────────────────────────────────── # ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме # Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции # (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции

View file

@ -31,6 +31,7 @@ from app.schemas.trade_in import (
CoverageProbeInput, CoverageProbeInput,
CoverageProbeResponse, CoverageProbeResponse,
DkpCorridor, DkpCorridor,
ExposureWindow,
HouseAnalyticsKpi, HouseAnalyticsKpi,
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseInfoForEstimate, HouseInfoForEstimate,
@ -390,6 +391,7 @@ async def _try_revive_dead_estimate(
), ),
{"orig": str(estimate_id), "temp": str(temp_id)}, {"orig": str(estimate_id), "temp": str(temp_id)},
) )
w = result.exposure_window # #2898: окно целиком или NULL целиком
db.execute( db.execute(
text( text(
""" """
@ -418,6 +420,10 @@ async def _try_revive_dead_estimate(
ratio_basis = :ratio_basis, ratio_basis = :ratio_basis,
relaxations = CAST(:relaxations_json AS jsonb), relaxations = CAST(:relaxations_json AS jsonb),
reliability = :reliability, reliability = :reliability,
est_days_p25 = :est_days_p25,
est_days_p50 = :est_days_p50,
est_days_p75 = :est_days_p75,
est_days_n = :est_days_n,
revival_completed_at = NOW() revival_completed_at = NOW()
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
""" """
@ -452,6 +458,10 @@ async def _try_revive_dead_estimate(
"ratio_basis": result.ratio_basis, "ratio_basis": result.ratio_basis,
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False), "relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
"reliability": result.reliability, "reliability": result.reliability,
"est_days_p25": w.p25_days if w else None,
"est_days_p50": w.p50_days if w else None,
"est_days_p75": w.p75_days if w else None,
"est_days_n": w.n if w else None,
}, },
) )
db.execute( db.execute(
@ -678,7 +688,8 @@ def load_estimate(
expected_sold_price, expected_sold_range_low, expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2, expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by, created_at, asking_to_sold_ratio, ratio_basis, created_by, created_at,
relaxations, reliability relaxations, reliability,
est_days_p25, est_days_p50, est_days_p75, est_days_n
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
AND {ESTIMATE_READABLE_SQL} AND {ESTIMATE_READABLE_SQL}
@ -843,6 +854,19 @@ def load_estimate(
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет # миграции 267) деградирует в None — «позиции не знаем», — а не роняет
# ответ AttributeError'ом. # ответ AttributeError'ом.
market_percentile=getattr(row, "market_percentile", None), market_percentile=getattr(row, "market_percentile", None),
# #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке.
# getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError.
exposure_window=(
ExposureWindow(
p25_days=row.est_days_p25,
p50_days=row.est_days_p50,
p75_days=row.est_days_p75,
n=row.est_days_n,
)
if getattr(row, "est_days_n", None) is not None
else None
),
est_days_on_market=getattr(row, "est_days_p50", None),
period_months=12, period_months=12,
analogs=analogs, analogs=analogs,
actual_deals=actual_deals, actual_deals=actual_deals,

View file

@ -230,6 +230,21 @@ class PriceTrendPoint(BaseModel):
ppm2: int # медиана ₽/м² за месяц ppm2: int # медиана ₽/м² за месяц
class ExposureWindow(BaseModel):
"""#2898: сколько дней похожие квартиры рядом висели до снятия объявления.
Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%,
дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ
объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы.
Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`).
"""
p25_days: int
p50_days: int
p75_days: int
n: int # сколько снятых объявлений в выборке
class AggregatedEstimate(BaseModel): class AggregatedEstimate(BaseModel):
estimate_id: UUID estimate_id: UUID
median_price_rub: int median_price_rub: int
@ -292,7 +307,11 @@ class AggregatedEstimate(BaseModel):
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов # абсолютный timestamp самого свежего парсинга аналогов
last_scraped_at: datetime | None = None last_scraped_at: datetime | None = None
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам) # #2898: окно срока экспозиции (p25p75) — показывать его, а не одно число.
exposure_window: ExposureWindow | None = None
# Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше —
# медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка).
est_days_on_market: int | None = None
cian_valuation: CianValuationSummary | None = None cian_valuation: CianValuationSummary | None = None
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ── # ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) → # ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →

View file

@ -34,6 +34,7 @@ from typing import Any, Literal
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
from scraper_kit.providers.avito.imv import ( from scraper_kit.providers.avito.imv import (
IMVAddressNotFoundError, IMVAddressNotFoundError,
IMVAuthError, IMVAuthError,
@ -63,6 +64,7 @@ from app.schemas.trade_in import (
AvitoImvSummary, AvitoImvSummary,
CianValuationSummary, CianValuationSummary,
DkpCorridor, DkpCorridor,
ExposureWindow,
PriceTrendPoint, PriceTrendPoint,
TradeInEstimateInput, TradeInEstimateInput,
) )
@ -2115,15 +2117,29 @@ def _sber_time_factor(series: dict[date, float], deal_month: date) -> float:
return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor)) return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor))
# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов # #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут
# региона 66) — resolve target-города для city-scoping ДКП-коридора (см. # развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с
# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc), # ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей
# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно # области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в
# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но # listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities
# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE). # НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS
# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а
# deals.city хранит е-формы («Артемовский»).
_TARGET_CITIES: frozenset[str] = SVERDLOVSK_OBLAST_CITIES | frozenset(
name.lower().replace("ё", "е")
for name in CITY_DISPLAY_NAMES.values()
if (region := regions_mod.region_by_city(name)) is None or region.code == 66
)
# Word/phrase-boundary regex поверх _TARGET_CITIES — resolve target-города для
# city-scoping ДКП-коридора (см. _resolve_target_city). Длинные имена раньше коротких
# (сортировка по len desc), чтобы «нижний тагил» матчился целиком, а не как «тагил»
# после «нижний» отдельно (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ
# именам городов, но сохраняем ту же дисциплину сортировки, что и
# geocoder._OBLAST_CITY_RE).
_TARGET_CITY_RE = re.compile( _TARGET_CITY_RE = re.compile(
r"\b(?:" r"\b(?:"
+ "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True)) + "|".join(re.escape(c) for c in sorted(_TARGET_CITIES, key=len, reverse=True))
+ r")\b" + r")\b"
) )
@ -2131,8 +2147,8 @@ _TARGET_CITY_RE = re.compile(
def _resolve_target_city(address_text: str | None) -> str | None: def _resolve_target_city(address_text: str | None) -> str | None:
"""Извлекает город региона 66 из геокодированного адреса (best-effort). """Извлекает город региона 66 из геокодированного адреса (best-effort).
Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города
границе слова/фразы работает и для forward-формата («Екатеринбург, ул. развёрток) по границе слова/фразы работает и для forward-формата («Екатеринбург, ул.
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
важна. ёе нормализация ДО поиска (симметрично _normalize_building_key). важна. ёе нормализация ДО поиска (симметрично _normalize_building_key).
@ -5502,6 +5518,19 @@ async def estimate_quality(
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags) sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
source_counts = _source_counts([lot.source for lot in analogs_lots]) source_counts = _source_counts([lot.source for lot in analogs_lots])
freshness_pre = _compute_freshness_minutes(metadata_lots) freshness_pre = _compute_freshness_minutes(metadata_lots)
# #2898: окно считается один раз здесь и персистится — отчёт по ссылке через месяц
# покажет то же окно, что в момент расчёта, а не пересчёт по новой истории.
exposure_window = _exposure_window(
await run_db_thread(
_fetch_exposure_days,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=search_radius_m,
)
)
# DaData enrichment (PR Q1) — заполняется только если service отработал. # DaData enrichment (PR Q1) — заполняется только если service отработал.
# При DaData = None все колонки идут в DB как NULL (graceful). # При DaData = None все колонки идут в DB как NULL (graceful).
dadata_metro_json = ( dadata_metro_json = (
@ -5531,6 +5560,7 @@ async def estimate_quality(
expected_sold_range_high, expected_sold_per_m2, expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, asking_to_sold_ratio, ratio_basis,
relaxations, reliability, relaxations, reliability,
est_days_p25, est_days_p50, est_days_p75, est_days_n,
created_by, created_by,
expires_at, expires_at,
consent, client_ip, consent_policy_version, consent_text_snapshot consent, client_ip, consent_policy_version, consent_text_snapshot
@ -5554,6 +5584,7 @@ async def estimate_quality(
:expected_sold_range_high, :expected_sold_per_m2, :expected_sold_range_high, :expected_sold_per_m2,
:asking_to_sold_ratio, :ratio_basis, :asking_to_sold_ratio, :ratio_basis,
CAST(:relaxations_json AS jsonb), :reliability, CAST(:relaxations_json AS jsonb), :reliability,
:est_days_p25, :est_days_p50, :est_days_p75, :est_days_n,
:created_by, :created_by,
:expires_at, :expires_at,
:consent, CAST(:client_ip AS inet), :consent_policy_version, :consent, CAST(:client_ip AS inet), :consent_policy_version,
@ -5614,6 +5645,11 @@ async def estimate_quality(
# пропадал при открытии оценки по сохранённой ссылке (?id=). # пропадал при открытии оценки по сохранённой ссылке (?id=).
"relaxations_json": json.dumps(relaxations, ensure_ascii=False), "relaxations_json": json.dumps(relaxations, ensure_ascii=False),
"reliability": reliability, "reliability": reliability,
# #2898: окно пишем целиком или NULL целиком (n < порога).
"est_days_p25": exposure_window.p25_days if exposure_window else None,
"est_days_p50": exposure_window.p50_days if exposure_window else None,
"est_days_p75": exposure_window.p75_days if exposure_window else None,
"est_days_n": exposure_window.n if exposure_window else None,
"created_by": created_by, "created_by": created_by,
"expires_at": expires_at, "expires_at": expires_at,
**_estimate_consent_persist_fields(require_consent, client_ip), **_estimate_consent_persist_fields(require_consent, client_ip),
@ -5784,7 +5820,8 @@ async def estimate_quality(
data_freshness_minutes=freshness_min, data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at, last_scraped_at=last_scraped_at,
price_trend=price_trend, price_trend=price_trend,
est_days_on_market=_estimate_days_on_market(metadata_lots, deals), exposure_window=exposure_window,
est_days_on_market=exposure_window.p50_days if exposure_window else None,
cian_valuation=( cian_valuation=(
CianValuationSummary( CianValuationSummary(
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None, sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
@ -5976,23 +6013,88 @@ def _manual_review(
return (bool(reasons), reasons) return (bool(reasons), reasons)
def _estimate_days_on_market( # #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был
listings: list[dict[str, Any]], deals: list[dict[str, Any]] # медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок
) -> int | None: # до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам. # снятых объявлений — house_placement_history.exposure_days.
#
# Порог 30 — замер 17.09.2026 на 51 прод-когорте с n>=80 (радиус 1 км): подвыборка
# в 10 лотов даёт среднюю ошибку края окна 34-40%, 20 — 24%, 30 — 18%, 50 — 12%.
# При 30 окно есть у 94 из 217 недавних оценок (при 20 — у 109).
EXPOSURE_WINDOW_MIN_N = 30
EXPOSURE_WINDOW_MONTHS = 24
Возвращает None если ни у одного аналога нет данных о сроке экспозиции
(наши парсеры не всегда его отдают честно показываем «нет данных»). def _exposure_window(days: list[int]) -> ExposureWindow | None:
"""Квартили срока экспозиции; None, если выборка меньше EXPOSURE_WINDOW_MIN_N.
method="inclusive" та же линейная интерполяция, что percentile_cont в
sell-time-sensitivity, чтобы одно и то же окно не расходилось на единицу.
""" """
values = [ if len(days) < EXPOSURE_WINDOW_MIN_N:
int(lot["days_on_market"])
for lot in (*listings, *deals)
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
]
if len(values) < 3:
return None return None
values.sort() p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive")
return values[len(values) // 2] return ExposureWindow(
p25_days=int(p25 + 0.5),
p50_days=int(p50 + 0.5),
p75_days=int(p75 + 0.5),
n=len(days),
)
def _fetch_exposure_days(
db: Session,
*,
lat: float | None,
lon: float | None,
rooms: int,
area: float,
radius_m: int,
) -> list[int]:
"""exposure_days снятых объявлений той же комнатности и площади ±AREA_TOLERANCE
в домах в радиусе подбора аналогов. Best-effort: ошибка [] (окна нет).
ponytail: без дедупа avito_imv/yandex_valuation прод 17.09: у 926 из 9482
yandex-строк есть avito-двойник (~3% всей выборки), на квартили не влияет.
"""
if lat is None or lon is None or not area:
return []
try:
# SAVEPOINT на месте глушения: упавший SELECT не должен отравить транзакцию,
# в которой следом идёт INSERT оценки.
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT h.exposure_days
FROM house_placement_history h
JOIN houses ho ON ho.id = h.house_id
WHERE ST_DWithin(
ho.geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
)
AND h.rooms = :rooms
AND h.area_m2 BETWEEN :area_min AND :area_max
AND h.exposure_days IS NOT NULL
AND h.removed_date IS NOT NULL
AND h.removed_date <= CURRENT_DATE
AND h.removed_date > CURRENT_DATE
- make_interval(months => CAST(:months AS integer))
"""
),
{
"lat": lat,
"lon": lon,
"radius": radius_m,
"rooms": rooms,
"area_min": area * (1 - AREA_TOLERANCE),
"area_max": area * (1 + AREA_TOLERANCE),
"months": EXPOSURE_WINDOW_MONTHS,
},
).all()
except Exception as exc:
logger.warning("exposure_window lookup failed (graceful): %s", exc)
return []
return [int(r[0]) for r in rows]
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None: def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:

View file

@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация — # НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` → # пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
# повторный прогон не трогает уже совпавшие строки. # повторный прогон не трогает уже совпавшие строки.
#
# #2583 H5: фильтры выше стоят только на стороне ГАР, а UPDATE houses шёл по одному канону
# по ВСЕМ домам всех регионов. После #3523 (матч для 77/50) прогон региона 50 проставил
# подмосковные guid екатеринбургским домам с тем же каноном (прод 2026-09-17: 578 домов
# региона 66 с guid региона 50, 340 — региона 77, 1371 дом 50 с guid 77, 497 домов 77 с
# guid 50). Дом получает guid только своего региона; канон ранжируется внутри региона.
_MATCH_SQL = text( _MATCH_SQL = text(
""" """
WITH gar_scope AS ( WITH gar_scope AS (
SELECT SELECT
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code
FROM gar_house_flats FROM gar_house_flats
WHERE flat_count > 0 WHERE flat_count > 0
AND tradein_canon_addr(norm_address) <> '' AND tradein_canon_addr(norm_address) <> ''
@ -622,15 +628,15 @@ _MATCH_SQL = text(
), ),
gar_ranked AS ( gar_ranked AS (
SELECT SELECT
canon, house_guid, flat_count, canon, house_guid, flat_count, region_code,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY canon ORDER BY flat_count DESC, house_guid PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid
) AS rn, ) AS rn,
COUNT(*) OVER (PARTITION BY canon) AS canon_hits COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
FROM gar_scope FROM gar_scope
), ),
gar_pick AS ( gar_pick AS (
SELECT canon, house_guid, flat_count SELECT canon, house_guid, flat_count, region_code
FROM gar_ranked FROM gar_ranked
WHERE rn = 1 WHERE rn = 1
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1) AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
@ -644,10 +650,33 @@ _MATCH_SQL = text(
WHERE tradein_canon_addr( WHERE tradein_canon_addr(
COALESCE(h.short_address, h.full_address, h.address) COALESCE(h.short_address, h.full_address, h.address)
) = gp.canon ) = gp.canon
AND CAST(h.region_code AS text) = gp.region_code
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
""" """
) )
# #2583 H5: снять уже проставленные guid чужого региона (см. коммент у _MATCH_SQL). Сам
# _MATCH_SQL их не перезапишет, если у дома нет пары в своём регионе, — а по чужому guid
# ЖКХ/ФРТ/капремонт-загрузчики продолжат тянуть данные другого дома. Только canon_addr и
# только доказуемо чужие (регион дома известен и не совпадает) — дом без региона не трогаем.
_CLEAR_FOREIGN_REGION_SQL = text(
"""
UPDATE houses h
SET gar_house_guid = NULL,
gar_flat_count = NULL,
gar_matched_at = NULL,
gar_match_method = NULL
FROM gar_house_flats g
WHERE g.house_guid = h.gar_house_guid
AND h.gar_match_method = 'canon_addr'
AND CAST(h.region_code AS text) <> g.region_code
AND (
CAST(:region AS text) IS NULL
OR CAST(h.region_code AS text) = CAST(:region AS text)
)
"""
)
def match_houses_to_gar( def match_houses_to_gar(
db: Session, db: Session,
@ -666,11 +695,14 @@ def match_houses_to_gar(
""" """
if isinstance(city_filter, CityFilterAutoType): if isinstance(city_filter, CityFilterAutoType):
city_filter = default_city_filter_for_region(region_code) city_filter = default_city_filter_for_region(region_code)
cleared = db.execute(_CLEAR_FOREIGN_REGION_SQL, {"region": region_code}).rowcount
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter}) result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount matched = result.rowcount
logger.info( logger.info(
"gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)", "gar match: проставлено gar_flat_count домам=%d, снято guid чужого региона=%d"
" (region=%s, city=%s)",
matched, matched,
cleared,
region_code, region_code,
city_filter, city_filter,
) )

View file

@ -204,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
Словарь `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские Словарь `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город, гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не (`_names_non_ekb_city` его не увидит) т.е. новый город области в любом
резолвит) т.е. новый город области в любом случае добавляется СЮДА, и случае добавляется СЮДА, и гейт хинта не создаёт новой связности.
гейт хинта не создаёт новой связности. (`estimator._resolve_target_city` дополнительно знает города развёрток,
#2583 H1 — ему город нужен для фильтра сделок, а не для гейта.)
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по («Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по

View file

@ -1,7 +1,8 @@
"""Daily per-source snapshot writer (#570). """Daily per-source snapshot writer (#570).
Берёт текущее состояние listing_sources (последний снимок на canonical listing × source) Берёт текущее состояние listing_sources (последний снимок на canonical listing × source)
и пишет ежедневный ряд в listing_source_snapshots + change-log price_change в и раз в сутки пишет в listing_source_snapshots строку на КАЖДОЕ ИЗМЕНЕНИЕ (#2993, см.
_SNAPSHOT_SQL; раньше полная копия каждые сутки) + change-log в
listing_source_events. Так история per-source цены копится СРАЗУ, независимо от listing_source_events. Так история per-source цены копится СРАЗУ, независимо от
(сейчас DORMANT) скраперов см. шапку data/sql/079_listing_source_history.sql. (сейчас DORMANT) скраперов см. шапку data/sql/079_listing_source_history.sql.
@ -72,9 +73,28 @@ def _clamp_budget_sec(raw: Any) -> float:
return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC)) return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC))
# ── Daily snapshot upsert ───────────────────────────────────────────────────── # ── Snapshot upsert: строка только на изменение (#2993) ───────────────────────
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день # Снимок на (listing_source_id, CURRENT_DATE) пишется, только если у источника ещё нет
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями). # ни одного снимка ИЛИ хоть одно из четырёх значений (price_rub, is_active, last_seen_at,
# payload_hash) отличается от его ПОСЛЕДНЕГО снимка. Было: полная копия listing_sources
# каждые сутки, прод 14-17.09 — 290-293 тыс. строк в сутки, из них отличались от
# предыдущего снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23.
#
# Состояние источника на дату D = его последний снимок с snapshot_date <= D. Для всех
# четырёх колонок это то же значение, что записала бы суточная модель: сутки, в которые
# ни одна из них не менялась, и есть пропущенные строки. Поэтому last_seen_at обязан
# быть в сравнении: пол переобхода (deactivate_stale_avito._revisit_floor_from_where_sql,
# PR-A #3056) берёт last_seen_at последнего снимка не позже якоря — без него пары
# «прошлое наблюдение → текущее» считались бы от устаревшей свежести. is_active там же:
# он выводится из now(), и переход «свежий → протух» при неизменном last_seen_at — тоже
# изменение состояния на дату.
#
# p — последний снимок ВКЛЮЧАЯ сегодняшний: повторный прогон в те же сутки сравнивает
# с тем, что уже записано сегодня, и перезаписывает строку только если значение снова
# сдвинулось (ON CONFLICT → last-write-wins, как раньше). Пер-строчный LATERAL
# point-lookup по PK, как в event-diff (#2607); прод 17.09: 293 602 lookup'а — 1.0 с.
# Старые суточные снимки не трогаются — история до перехода остаётся как есть.
#
# is_active derived: last_seen_at в пределах окна свежести на момент снимка. # is_active derived: last_seen_at в пределах окна свежести на момент снимка.
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param). # payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
# run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint). # run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint).
@ -85,15 +105,33 @@ _SNAPSHOT_SQL = text(
last_seen_at, payload_hash, observed_at, run_id last_seen_at, payload_hash, observed_at, run_id
) )
SELECT SELECT
id, cur.id,
CURRENT_DATE, CURRENT_DATE,
cur.price_rub,
cur.is_active,
cur.last_seen_at,
cur.payload_hash,
now(),
CAST(:run_id AS bigint)
FROM (
SELECT
id,
price_rub, price_rub,
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active, (last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
last_seen_at, last_seen_at,
md5(raw_payload::text), md5(raw_payload::text) AS payload_hash
now(),
CAST(:run_id AS bigint)
FROM listing_sources FROM listing_sources
) cur
LEFT JOIN LATERAL (
SELECT s.snapshot_date, s.price_rub, s.is_active, s.last_seen_at, s.payload_hash
FROM listing_source_snapshots s
WHERE s.listing_source_id = cur.id
ORDER BY s.snapshot_date DESC
LIMIT 1
) p ON true
WHERE p.snapshot_date IS NULL
OR (cur.price_rub, cur.is_active, cur.last_seen_at, cur.payload_hash)
IS DISTINCT FROM (p.price_rub, p.is_active, p.last_seen_at, p.payload_hash)
ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET
price_rub = EXCLUDED.price_rub, price_rub = EXCLUDED.price_rub,
is_active = EXCLUDED.is_active, is_active = EXCLUDED.is_active,
@ -108,6 +146,9 @@ _SNAPSHOT_SQL = text(
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с # Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE). # самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции). # today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
# С #2993 здесь только изменившиеся и новые источники: у остальных все четыре
# значения равны последнему снимку, а значит ни одно событие ниже сработать
# не могло бы — набор событий тот же, что при суточной копии.
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607). # p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
# #
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change, # #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
@ -249,7 +290,8 @@ def snapshot_listing_sources(
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp). Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
Два set-based statement'а в одной транзакции: Два set-based statement'а в одной транзакции:
1. upsert снимка на (listing_source_id, CURRENT_DATE) last-write-wins. 1. upsert снимка на (listing_source_id, CURRENT_DATE) только для источников,
изменившихся с последнего снимка (#2993) — last-write-wins.
2. diff сегодняшнего снимка против последнего предыдущего три события, 2. diff сегодняшнего снимка против последнего предыдущего три события,
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
они НЕ выводимы при покрытии обхода 10-35% см. _EVENT_DIFF_SQL. они НЕ выводимы при покрытии обхода 10-35% см. _EVENT_DIFF_SQL.

View file

@ -52,6 +52,13 @@ non-200 ответ считается блоком, а его диагноз б
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
incomplete failed обогащения нет, значит следующий снапшот incomplete failed обогащения нет, значит следующий снапшот
(detail_enriched_at IS NULL) возьмёт её снова. (detail_enriched_at IS NULL) возьмёт её снова.
Вечный недогруз (#3191, 13.0916.09): карточка, которая не догружается НИКОГДА, без
счётчика попыток возвращалась в голову каждого снапшота. Пять таких объявлений подряд
выбивали брейкер, и 16 прогонов закончились ровно attempted=5 incomplete=5. Теперь
недогруз пишется на объявление (миграция 321): сутки его не берём, после
INCOMPLETE_MAX_ATTEMPTS не берём совсем, а повторный недогруз уже известной карточки
не двигает брейкер это сведения о карточке, а не о площадке.
""" """
from __future__ import annotations from __future__ import annotations
@ -137,6 +144,13 @@ OFFER_URL_PATTERN = "/offer/[0-9]+"
OFFER_ID_PATTERN = "^[0-9]+$" OFFER_ID_PATTERN = "^[0-9]+$"
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'" CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
# Недогруз на объявлении (#3191). Разовый недогруз (1,8 МБ вместо 3,9 в замере 28.08)
# с другого захода отдаётся целиком, поэтому пауза, а не приговор. Вечный — пустая
# SPA-оболочка ~381 КБ без INITIAL_STATE, одинаковая с разных IP (проба 17.09) — после
# трёх суточных попыток из очереди выходит и считается в incomplete_given_up.
INCOMPLETE_RETRY_AFTER_HOURS = 24
INCOMPLETE_MAX_ATTEMPTS = 3
@dataclass @dataclass
class YandexDetailBackfillResult: class YandexDetailBackfillResult:
@ -161,6 +175,8 @@ class YandexDetailBackfillResult:
url_from_offer_id: int = 0 url_from_offer_id: int = 0
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id. # Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
unenrichable_pending: int = 0 unenrichable_pending: int = 0
# Ждут обогащения, но вышли из очереди после INCOMPLETE_MAX_ATTEMPTS недогрузов.
incomplete_given_up: int = 0
duration_sec: float = field(default=0.0) duration_sec: float = field(default=0.0)
def to_dict(self) -> dict[str, int]: def to_dict(self) -> dict[str, int]:
@ -172,6 +188,7 @@ class YandexDetailBackfillResult:
"failed": self.failed, "failed": self.failed,
"url_from_offer_id": self.url_from_offer_id, "url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending, "unenrichable_pending": self.unenrichable_pending,
"incomplete_given_up": self.incomplete_given_up,
"duration_sec": int(self.duration_sec), "duration_sec": int(self.duration_sec),
} }
@ -228,7 +245,8 @@ async def run_yandex_detail_backfill(
WHEN source_url ~ CAST(:offer_url_pattern AS text) WHEN source_url ~ CAST(:offer_url_pattern AS text)
THEN source_url THEN source_url
ELSE {CANONICAL_URL_SQL} ELSE {CANONICAL_URL_SQL}
END AS source_url END AS source_url,
detail_incomplete_count
FROM listings FROM listings
WHERE source = 'yandex' WHERE source = 'yandex'
AND detail_enriched_at IS NULL AND detail_enriched_at IS NULL
@ -239,6 +257,15 @@ async def run_yandex_detail_backfill(
) )
OR source_id ~ CAST(:offer_id_pattern AS text) OR source_id ~ CAST(:offer_id_pattern AS text)
) )
-- #3191: недогруженную карточку не берём сутки и не берём
-- совсем после INCOMPLETE_MAX_ATTEMPTS.
AND detail_incomplete_count < CAST(:incomplete_max_attempts AS int)
AND (
detail_incomplete_at IS NULL
OR detail_incomplete_at < now() - make_interval(
hours => CAST(:incomplete_retry_hours AS int)
)
)
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int) LIMIT CAST(:batch_size AS int)
""" """
@ -249,6 +276,8 @@ async def run_yandex_detail_backfill(
"batch_size": batch_size, "batch_size": batch_size,
"offer_url_pattern": OFFER_URL_PATTERN, "offer_url_pattern": OFFER_URL_PATTERN,
"offer_id_pattern": OFFER_ID_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN,
"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS,
"incomplete_retry_hours": INCOMPLETE_RETRY_AFTER_HOURS,
}, },
) )
.mappings() .mappings()
@ -283,6 +312,19 @@ async def run_yandex_detail_backfill(
).one() ).one()
counters.url_from_offer_id = int(pending.url_from_offer_id) counters.url_from_offer_id = int(pending.url_from_offer_id)
counters.unenrichable_pending = int(pending.unenrichable_pending) counters.unenrichable_pending = int(pending.unenrichable_pending)
counters.incomplete_given_up = int(
db.execute(
text(
"""
SELECT count(*) FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND detail_incomplete_count >= CAST(:incomplete_max_attempts AS int)
"""
),
{"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS},
).scalar_one()
)
if counters.url_from_offer_id: if counters.url_from_offer_id:
logger.info( logger.info(
"yandex_detail_backfill: run_id=%dу %d объявлений сохранённый " "yandex_detail_backfill: run_id=%dу %d объявлений сохранённый "
@ -443,26 +485,59 @@ async def run_yandex_detail_backfill(
# объявление уедет в БД с detail_enriched_at, выбыв из очереди # объявление уедет в БД с detail_enriched_at, выбыв из очереди
# навсегда. Здесь оно исхода 'enriched' не получает, значит в # навсегда. Здесь оно исхода 'enriched' не получает, значит в
# следующем прогоне снова попадёт в снапшот (detail_enriched_at # следующем прогоне снова попадёт в снапшот (detail_enriched_at
# IS NULL). Серия таких страниц двигает consecutive_none — тот же # IS NULL) — но не раньше чем через сутки: недогруз пишется на
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся # объявление, и после INCOMPLETE_MAX_ATTEMPTS снапшот его не берёт.
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
incomplete_reason = detail_incomplete_reason( incomplete_reason = detail_incomplete_reason(
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
) )
if incomplete_reason is not None: if incomplete_reason is not None:
counters.incomplete += 1 counters.incomplete += 1
counters.failed += 1 counters.failed += 1
# Брейкер ловит СИСТЕМНЫЙ недогруз (площадка отдаёт оболочки
# всем). Повторный недогруз карточки, уже недогружавшейся
# раньше, — сведения о ней, а не о площадке: он брейкер не
# двигает, иначе вечные карточки, стоящие в очереди подряд,
# рвали бы прогон при каждом повторе (#3191, 16 прогонов
# attempted=5 incomplete=5). Первый недогруз двигает, как раньше.
prior_incomplete = int(row.get("detail_incomplete_count") or 0)
if prior_incomplete == 0:
consecutive_none += 1 consecutive_none += 1
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196). # Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
consecutive_blocks = 0 consecutive_blocks = 0
try:
db.execute(
text(
"""
UPDATE listings
SET detail_incomplete_count = detail_incomplete_count + 1,
detail_incomplete_at = now()
WHERE id = CAST(:listing_id AS bigint)
"""
),
{"listing_id": listing_id},
)
db.commit()
except Exception as mark_exc:
# Не записали — карточка просто вернётся в следующий
# снапшот без паузы (поведение до #3191-счётчика).
db.rollback()
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d "
"недогруз не записан: %s",
run_id,
listing_id,
mark_exc,
)
logger.warning( logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s " "yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
"-> недогруженная карточка, отказ: %s (consecutive=%d)", "-> недогруженная карточка, отказ: %s (попытка %d/%d, "
"consecutive=%d)",
run_id, run_id,
listing_id, listing_id,
source_url, source_url,
incomplete_reason, incomplete_reason,
prior_incomplete + 1,
INCOMPLETE_MAX_ATTEMPTS,
consecutive_none, consecutive_none,
) )
if consecutive_none >= max_consecutive_blocks: if consecutive_none >= max_consecutive_blocks:

View file

@ -0,0 +1,52 @@
-- 321_listings_detail_incomplete_attempts.sql
-- Счётчик недогруженных карточек Яндекса на объявлении (#3191, вечный недогруз).
--
-- Apply after: 310_yandex_seed_decimal_slips.sql
-- Deploy order: схема первой (деплой применяет миграции до пересоздания контейнеров);
-- старый код новые колонки не читает, поэтому окно между шагами безопасно.
--
-- ── ЧТО НЕ ТАК ────────────────────────────────────────────────────────────────
-- PR #3364 сделал недогруженную карточку отказом: detail_enriched_at не ставится,
-- объявление остаётся в очереди yandex_detail_backfill. Попыток на объявлении при
-- этом никто не считал, и карточка, которая не догружается НИКОГДА, возвращалась в
-- голову каждого снапшота. Прод 13.09 21:14 16.09 18:52: пять объявлений
-- (10776456, 10775964, 10775945, 10775747, 10775594; scraped_at 12.09 15:02, подряд
-- в порядке очереди) давали 381 КБ без encryptedPhones/redirectPhones в каждом
-- прогоне, пять недогрузов подряд выбивали брейкер consecutive_none=5, и 16 прогонов
-- закончились ровно attempted=5 incomplete=5, а остальные обрывались, дойдя до них.
-- Страница — пустая SPA-оболочка без INITIAL_STATE (id оффера в ней не встречается),
-- та же с другого IP (проба 17.09): это свойство объявления, а не сети.
--
-- ── ЧТО ДЕЛАЕТ ФАЙЛ ───────────────────────────────────────────────────────────
-- detail_incomplete_count — сколько раз карточка пришла недогруженной;
-- detail_incomplete_at — когда последний раз. Читает и пишет только
-- app/tasks/yandex_detail_backfill.py: снапшот не берёт карточку в течение суток
-- после недогруза и не берёт совсем после трёх.
--
-- ── СТОИМОСТЬ ─────────────────────────────────────────────────────────────────
-- ADD COLUMN с константным DEFAULT в PostgreSQL 11+ не переписывает heap (значение
-- лежит в каталоге), поэтому 19 ГБ listings не копируются. ACCESS EXCLUSIVE берётся
-- на миллисекунды; ожидание лока ограничено lock_timeout (.claude/rules/sql.md).
--
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS. Данные существующих строк не трогаются.
--
-- Критерий приёмки (записан ДО применения):
-- 1. Запись в _schema_migrations по имени этого файла.
-- 2. information_schema.columns: обе колонки у listings.
-- 3. После первого прогона yandex_detail_backfill, дошедшего до пяти объявлений:
-- у них detail_incomplete_count = 1, и следующий прогон их не запрашивает.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS detail_incomplete_count smallint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS detail_incomplete_at timestamptz;
COMMENT ON COLUMN listings.detail_incomplete_count IS
'Сколько раз detail-страница приходила недогруженной (#3191). Пишет yandex_detail_backfill; после 3 объявление выходит из очереди добора.';
COMMENT ON COLUMN listings.detail_incomplete_at IS
'Время последнего недогруза detail-страницы (#3191). Суточная пауза перед повтором в yandex_detail_backfill.';
COMMIT;

View file

@ -0,0 +1,31 @@
-- #2898: окно срока экспозиции (p25/p50/p75 и размер выборки) вместо одного числа.
--
-- ЗАЧЕМ КОЛОНКИ. Окно считается по house_placement_history на момент оценки, а
-- история пополняется каждый день: пересчёт на чтении показал бы по ссылке через
-- месяц другое окно, чем в момент покупки отчёта. Поэтому значение хранится.
--
-- Все четыре либо заполнены, либо NULL: NULL — выборка меньше
-- EXPOSURE_WINDOW_MIN_N (30) или оценка посчитана до этой миграции. Бэкфилла нет:
-- историю размещений на дату старой оценки не восстановить.
--
-- Старое поле ответа est_days_on_market не колонка и не удаляется: GET отдаёт в нём
-- est_days_p50, старые строки дают null, как и раньше.
--
-- ПРО ЛОКИ (#2752): ADD COLUMN без DEFAULT не переписывает таблицу, но ждать
-- ACCESS EXCLUSIVE может долго и держать очередь запросов — lock_timeout.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS est_days_p25 integer,
ADD COLUMN IF NOT EXISTS est_days_p50 integer,
ADD COLUMN IF NOT EXISTS est_days_p75 integer,
ADD COLUMN IF NOT EXISTS est_days_n integer;
COMMENT ON COLUMN trade_in_estimates.est_days_p50 IS
'#2898: медиана дней до СНЯТИЯ объявления (не продажи) у похожих квартир рядом, '
'house_placement_history.exposure_days. p25/p75 — края окна, n — размер выборки. '
'NULL — выборка < 30.';
COMMIT;

View file

@ -0,0 +1,29 @@
-- 325_listing_source_snapshots_change_only_comment.sql
-- #2993 — listing_source_snapshots переходит на «строку на изменение»
-- (app/tasks/listing_source_snapshot.py, _SNAPSHOT_SQL; решение владельца 2026-08-23).
--
-- Схема не меняется, меняется смысл строк — поэтому только комментарии. Прежние
-- («Ежедневный снимок…», «на дату снимка») после перехода стали бы неправдой:
-- `WHERE snapshot_date = D` отдаёт теперь не состояние всех источников на D, а только
-- изменившиеся в D. Состояние на дату — последний снимок с snapshot_date <= D.
-- Суточные строки до перехода остаются в таблице как есть.
--
-- COMMENT берёт ShareUpdateExclusiveLock (конфликтует с autovacuum/ANALYZE, не с
-- чтением и записью) — lock_timeout, чтобы деплой не повис за VACUUM 8-млн таблицы.
BEGIN;
SET LOCAL lock_timeout = '5s';
COMMENT ON TABLE listing_source_snapshots IS
'Снимок listing_sources на источник (#570), строка только при изменении (#2993): '
'пишется, если price_rub/is_active/last_seen_at/payload_hash отличаются от последнего '
'снимка источника или снимка ещё нет. Состояние на дату D — последняя строка с '
'snapshot_date <= D. До перехода (#2993) — полная суточная копия. '
'PK (listing_source_id, snapshot_date), last-write-wins за день.';
COMMENT ON VIEW v_listing_source_price_on_date IS
'Цена/активность источника с даты снимка (#570): listing_source_snapshots ⋈ listing_sources. '
'С #2993 строки только на даты изменений — цена на дату D = последняя строка с snapshot_date <= D.';
COMMIT;

View file

@ -1674,12 +1674,16 @@ class _HouseIdResolution:
total: int = 0 # deals processed in the full-engine path under the flag total: int = 0 # deals processed in the full-engine path under the flag
resolved: int = 0 # deals where match_house_readonly returned an id resolved: int = 0 # deals where match_house_readonly returned an id
imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired
# #2862: сделкам, у которых тип дома подставлен из `houses` (deals.house_type пуст
# у всех строк). Ноль здесь = штраф за тип дома в прогоне не участвовал вовсе.
house_type_from_houses: int = 0
def as_json(self) -> dict[str, int]: def as_json(self) -> dict[str, int]:
return { return {
"resolved": self.resolved, "resolved": self.resolved,
"total": self.total, "total": self.total,
"imv_reachable": self.imv_reachable, "imv_reachable": self.imv_reachable,
"house_type_from_houses": self.house_type_from_houses,
} }
@ -2550,6 +2554,18 @@ def run_backtest_full(
target_house_id = _resolve_house_id_for_deal(db, deal) target_house_id = _resolve_house_id_for_deal(db, deal)
if target_house_id is not None: if target_house_id is not None:
resolution.resolved += 1 resolution.resolved += 1
# #2862: боевой estimate_quality (#3234) берёт тип дома из `houses`, когда
# его нет в форме — по резолвленному house_id, иначе ближайший дом в 60 м.
# deals.house_type пуст у всех сделок, так что без этого штраф за тип дома
# в прогоне не срабатывал ни разу. Только тип: год и этажность из `houses`
# сдвинули бы метрики по другим признакам и смешали бы замер.
if deal.house_type is None:
facts = est.m._lookup_house_facts(
db, target_house_id=target_house_id, lat=deal.lat, lon=deal.lon
)
if facts is not None and facts.house_type is not None:
deal = dataclasses.replace(deal, house_type=facts.house_type)
resolution.house_type_from_houses += 1
try: try:
pr = _predict_full_spine( pr = _predict_full_spine(
db, db,
@ -2616,10 +2632,11 @@ def run_backtest_full(
if resolution is not None: if resolution is not None:
metrics["house_id_resolution"] = resolution.as_json() metrics["house_id_resolution"] = resolution.as_json()
logger.info( logger.info(
"house_id resolution: resolved=%d/%d imv_reachable=%d", "house_id resolution: resolved=%d/%d imv_reachable=%d house_type_from_houses=%d",
resolution.resolved, resolution.resolved,
resolution.total, resolution.total,
resolution.imv_reachable, resolution.imv_reachable,
resolution.house_type_from_houses,
) )
if dump_fixture is not None and capture is not None: if dump_fixture is not None and capture is not None:

View file

@ -0,0 +1,911 @@
<!doctype html>
<!--
#2422: живая выдача Циана, снята 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200, ~3 МБ):
GET https://ekb.cian.ru/cat.php?deal_type=sale&engine_version=2&offer_type=flat&region=4743&room2=1
Урезано до того, что читает CianScraper._parse_serp_html / _offer_to_lot:
- из 154 записей _cianConfig['frontend-serp'] оставлены projectName и initialState;
- в initialState только results.totalOffers и 4 из 28 offers, по одному на ветку:
331661795 вторичка, 333599038 новостройка с домом в geo.address,
333279363 новостройка с decoration=fine, 321628098 новостройка без улицы и дома;
- у offer только ключи, которые читает парсер; phones/user/userId убраны (PII),
photos урезаны до 3 fullUrl, у geo.address/undergrounds только читаемые поля.
Форма записи (.concat([...]) с JSON-массивом) и значения полей не менялись.
-->
<html><head><meta charset="utf-8"></head><body>
<script type="text/javascript">
window._cianConfig = window._cianConfig || {};
window._cianConfig['frontend-serp'] = (window._cianConfig['frontend-serp'] || []).concat([
{
"key": "projectName",
"value": "frontend-serp",
"priority": 1000,
"filter": {}
},
{
"key": "initialState",
"value": {
"results": {
"totalOffers": 13895,
"offers": [
{
"cianId": 331661795,
"id": 331661795,
"fullUrl": "https://ekb.cian.ru/sale/flat/331661795/?context=4.JppPDee85nA.3hAoeZtPv9-CCGqBSDAI8EqioM8KBdEBjsgdLaVHspglG86Z8YBFyLQQuLYOzUIJ9UfTORZd8UX_kw&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"bargainAllowed": null,
"vatType": null,
"agentFee": null,
"clientFee": null,
"paymentPeriod": null,
"leaseType": null,
"deposit": null,
"mortgageAllowed": true,
"vatPrice": null,
"utilitiesTerms": null,
"price": 7990000,
"leaseTermType": null,
"priceRur": 7990000,
"tags": [],
"priceType": "all",
"priceForWorkplace": null,
"saleType": "alternative",
"currency": "rur",
"agentBonus": null
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "47.2",
"livingArea": "28.0",
"kitchenArea": "7.5",
"floorNumber": 2,
"building": {
"floorsCount": 17,
"parking": {
"locationType": null,
"type": "ground",
"priceEntry": null,
"purposeType": null,
"isFree": null,
"isAvailable": null,
"currency": null,
"priceMonthly": null,
"placesCount": null
},
"deadline": null,
"type": null,
"materialType": "panel",
"heatingType": null,
"accessType": null,
"classType": null,
"buildYear": 1980,
"cargoLiftsCount": 1,
"passengerLiftsCount": 1
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lat": 56.820035,
"lng": 60.634287
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Октябрьский",
"fullName": "р-н Октябрьский",
"id": 290
},
{
"type": "mikroraion",
"geoType": "district",
"name": "Парковый",
"fullName": "мкр. Парковый",
"id": 1489
},
{
"type": "metro",
"geoType": "underground",
"name": "Геологическая",
"fullName": "м. Геологическая",
"id": 346
},
{
"type": "street",
"geoType": "street",
"name": "Большакова",
"fullName": "улица Большакова",
"id": 1486289
},
{
"type": "house",
"geoType": "house",
"name": "13",
"fullName": "13",
"id": 10385250
}
],
"undergrounds": [
{
"name": "Геологическая",
"time": 5,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Чкаловская",
"time": 5,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": false
},
{
"name": "Площадь 1905 года",
"time": 6,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": false
}
]
},
"newbuilding": null,
"balconiesCount": 0,
"loggiasCount": 1,
"bedroomsCount": null,
"hasFurniture": true,
"isSoldFurnished": true,
"decoration": null,
"isByHomeowner": false,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "В продаже двухкомнатная квартира с качественным ремонтом. \n\nРайон расположения квартиры с развитой инфраструктурой. Рядом множество детских садов и школ, городской парк развлечений \"Маяковского\" Различные бесплатные спортивные школы и секции. \n\nВо дворе дома есть бесплатные парковочные места. Места общего пользования в доме после капитального ремонта, на стенах первого этажа керамогранит, оба лифта новые. Установлена качественная сейф-дверь. В прихожей на полу керамогранитная плитка. Имеется теплый пол в прихожей. Удобно содержать пол в прихожей в чистоте. \nОстается шкаф купе в прихожей. На кухне установлен кухонный гарнитур, оставляем бытовую технику - варочную панель, духовой шкаф, вытяжку, посудомоечную машину. Окна во всей квартире и на лоджии заменены на новые пластиковые. Сан узел раздельный, в кафеле. Стояки ГВС И ХВС заменены в 2017 году.\n\nВ квартире заменена вся электропроводка. Утеплена лоджия, на полу кафельная плитка, на лоджии имеется теплый пол. Так же в стоимость квартиры входят все светильники и шторы. Рассмотрим трейд ин: у Вас не продана своя недвижимость - у нас есть кому ее предложить.\n Найдем покупателя на Вашу квартиру с фиксацией цены на наш объект. Звоните и записывайтесь на просмотр! \nДобавляйте объявление в избранное, что бы не потерять его. Просто звоните или пишите и мы найдем способ как Вам приобрести квартиру в Вашем бюджете! 2-комнатная квартира в Екатеринбурге.",
"descriptionMinhash": [
16147461,
5781654,
4548911,
115530169,
2088518,
13989882,
5236906,
29626041,
89222106,
31854558,
1755821,
30053968,
4178831,
69970867,
23526500,
79279,
22630780,
105139689,
7157020,
7498881,
439139,
40231865,
5793994,
2284150,
9460550,
20249216,
16249532,
8291340,
9226251,
55362858,
4609260,
18591719
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/kvartira-ekaterinburg-bolshakova-ulica-2915101466-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/kvartira-ekaterinburg-bolshakova-ulica-2914203429-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/kvartira-ekaterinburg-bolshakova-ulica-2915101469-1.jpg"
}
],
"addedTimestamp": 1789387127,
"offerType": "flat",
"category": "flatSale",
"fromDeveloper": false,
"buildersIds": [],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
},
{
"cianId": 333599038,
"id": 333599038,
"fullUrl": "https://ekb.cian.ru/sale/flat/333599038/?context=4.RIlzprvdmBE.EoIb2G8IrpAJRWmWSKXZB54SBE-BI5E9ffyir3i1BCpfutwDcbFaqH8RXGcSSW2eGb_oYww9PzpC-g&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"price": 4834000,
"vatPrice": null,
"deposit": null,
"saleType": "fz214",
"bargainAllowed": null,
"vatType": null,
"agentBonus": null,
"priceForWorkplace": null,
"agentFee": null,
"priceRur": 4834000,
"leaseType": null,
"mortgageAllowed": null,
"clientFee": null,
"paymentPeriod": null,
"tags": [],
"priceType": "all",
"utilitiesTerms": null,
"leaseTermType": null,
"currency": "rur"
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "36.04",
"livingArea": "19.8",
"kitchenArea": null,
"floorNumber": 22,
"building": {
"accessType": null,
"materialType": "monolith",
"cargoLiftsCount": null,
"deadline": {
"quarterEnd": "2028-12-31",
"year": 2028,
"quarter": "fourth",
"isComplete": false
},
"type": null,
"buildYear": null,
"heatingType": null,
"passengerLiftsCount": 7,
"classType": null,
"floorsCount": 32,
"parking": null
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lng": 60.538293335967175,
"lat": 56.875540746307834
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Железнодорожный",
"fullName": "р-н Железнодорожный",
"id": 287
},
{
"type": "mikroraion",
"geoType": "district",
"name": "Старая Сортировка",
"fullName": "мкр. Старая Сортировка",
"id": 1494
},
{
"type": "metro",
"geoType": "underground",
"name": "Уральская",
"fullName": "м. Уральская",
"id": 343
},
{
"type": "street",
"geoType": "street",
"name": "Проходной",
"fullName": "переулок Проходной",
"id": 1486939
},
{
"type": "house",
"geoType": "house",
"name": "1Б",
"fullName": "1Б",
"id": 13406495
}
],
"undergrounds": [
{
"name": "Уральская",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Уралмаш",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
},
{
"name": "Машиностроителей",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
}
]
},
"newbuilding": {
"showJkReliableFlag": false,
"isFromDeveloper": true,
"name": "Астон. Движение Вверх",
"isFromLeadFactory": false,
"isFichering": true,
"isFromBuilder": true,
"isPremium": false,
"house": {
"finishDate": {
"year": 2028,
"quarter": 4
},
"section": null,
"name": "Дом 5",
"isFinished": false,
"id": 10930182
},
"hasFlatTourBooking": false,
"comparisonStatus": null,
"id": 5779142,
"isSalesLeader": null,
"isFromSeller": false,
"countPromos": 2,
"snippetType": "advanced"
},
"balconiesCount": null,
"loggiasCount": null,
"bedroomsCount": null,
"hasFurniture": null,
"decoration": null,
"isByHomeowner": null,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "2-комнатная квартира с предчистовой отделкой в ЖК \"Астон. Движение вверх\" на 22 этаже. Общая площадь 36.04 м², жилая 19.83 м². \n\n\"Астон. Движение вверх\" два монолитно-блочных дома на Новой Сортировке в Екатеринбурге: один высотой 16 этажей, другой 32 этажа. Проект продолжает историю соседнего квартала \"Астон. Движение\", который уже полюбился жителям района. Строительство ведётся на месте бывшего Виншампанкомбината у этого места есть своя душа и почти вековая история.\n\nАрхитектура обыгрывает наследие территории: палитра фасадов, плавные линии и изгибы переосмысляют фактуры продукции комбината. В проекте много внимания уделено свету увеличенное остекление, продуманные планировки без глухих коридоров и тёмных углов. Из окон верхних этажей открывается панорама города это самый высокий комфорт-класс в микрорайоне.\n\nВ квартирах просторные кухни-гостиные, мастер-спальни с гардеробными, балконы и тёплые лоджии. В некоторых планировках окна в ванной комнате, что наполняет утро солнечным светом. В 32-этажной секции пять грузопассажирских лифтов, в 16-этажной по два лифта на этаж.\n\nДвор закрыт от машин, с зонами для детей разного возраста: для малышей песочницы и качели, для подростков пространство для встреч с друзьями. В просторных входных группах на первых этажах есть место для колясок. Первая очередь квартала уже сдана по утрам здесь пахнет кофе из кафе на первых этажах, вечерами гуляют с колясками и собаками.\n\nРасположение пересечение улицы Пехотинцев и Проходного переулка, в 5 минутах от Бебеля. Рядом детские сады, школы, поликлиники, торговые и фитнес-центры, остановки общественного транспорта. До центра Екатеринбурга 2530 минут на машине. Срок сдачи IV квартал 2028 года.\n\nЗастройщик: федеральный девелопер Астон, реализующий проекты в пяти городах России. Уже передано более 400 000 м² жилья. Продам 2-комнатную квартиру в Екатеринбурге.",
"descriptionMinhash": [
18308017,
8359824,
15811915,
9618444,
2088518,
6143519,
1770676,
52418947,
12009655,
11955293,
15731187,
1006418,
4178831,
5064683,
5165062,
28709808,
27942,
105139689,
37372265,
6351486,
31972117,
53400966,
5793994,
30731728,
9460550,
5740363,
24836331,
794253,
37707718,
2676229,
24769842,
1768206
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/2957954042-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2957954049-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2957954053-1.jpg"
}
],
"addedTimestamp": 1789377541,
"offerType": "flat",
"category": "newBuildingFlatSale",
"fromDeveloper": true,
"buildersIds": [
10137
],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
},
{
"cianId": 333279363,
"id": 333279363,
"fullUrl": "https://ekb.cian.ru/sale/flat/333279363/?context=4.eJtxwNSILJE.YF5Ute3CO5a6ojZp8vw4v4wbhDX3F1ibU5rfq6T0Mt7TEHmtX3_CR5L7_Brx_JZMKAgoly3KHiIsxg&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"tags": [],
"currency": "rur",
"priceType": "all",
"paymentPeriod": null,
"mortgageAllowed": null,
"leaseTermType": null,
"priceForWorkplace": null,
"clientFee": null,
"agentBonus": null,
"saleType": "pdkp",
"price": 8523568,
"priceRur": 8523568,
"deposit": null,
"vatPrice": null,
"vatType": null,
"leaseType": null,
"agentFee": null,
"utilitiesTerms": null,
"bargainAllowed": null
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "56.03",
"livingArea": "21.8",
"kitchenArea": null,
"floorNumber": 4,
"building": {
"floorsCount": 8,
"deadline": {
"year": 2026,
"isComplete": true,
"quarterEnd": "2026-09-30",
"quarter": "third"
},
"cargoLiftsCount": null,
"type": null,
"accessType": null,
"passengerLiftsCount": null,
"classType": null,
"parking": null,
"materialType": null,
"heatingType": null,
"buildYear": null
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lat": 56.833388,
"lng": 60.520165
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Верх-Исетский",
"fullName": "рВерх-Исетский",
"id": 286
},
{
"type": "mikroraion",
"geoType": "district",
"name": "ВИЗ",
"fullName": "мкр. ВИЗ",
"id": 1468
},
{
"type": "metro",
"geoType": "underground",
"name": "Геологическая",
"fullName": "м. Геологическая",
"id": 346
},
{
"type": "street",
"geoType": "street",
"name": "Крауля",
"fullName": "улица Крауля",
"id": 1486179
},
{
"type": "house",
"geoType": "house",
"name": "170Б",
"fullName": "170Б",
"id": 16773087
}
],
"undergrounds": [
{
"name": "Геологическая",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Площадь 1905 года",
"time": 10,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
},
{
"name": "Уральская",
"time": 13,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
}
]
},
"newbuilding": {
"id": 49143,
"showJkReliableFlag": false,
"isFromSeller": false,
"isFichering": true,
"name": "Янтарная Долина",
"isPremium": true,
"house": {
"id": 10931059,
"name": "6 очередь",
"section": "Секция 2",
"finishDate": {
"quarter": 3,
"year": 2026
},
"isFinished": true
},
"hasFlatTourBooking": false,
"comparisonStatus": null,
"isSalesLeader": null,
"isFromDeveloper": true,
"isFromBuilder": true,
"isFromLeadFactory": false,
"countPromos": 3,
"snippetType": "maximum"
},
"balconiesCount": 1,
"loggiasCount": 1,
"bedroomsCount": null,
"hasFurniture": null,
"decoration": "fine",
"isByHomeowner": null,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "Жилой квартал 'Янтарная долина' расположен в районе Новый ВИЗ, вблизи Верх-Исетского пруда. Предлагаем вам квартиры в шестой очереди строительства — секциях 6.2-6.4 по адресу улица Крауля, 170Б. Все секции 8-этажные, в доме 147 квартир. Дом сдан. Продажа по ДКП.\nРайон\nРайон Новый ВИЗ сочетает городской комфорт и близость природных зон. Это одно из самых перспективных направлений в Екатеринбурге, идеальное для семейного проживания.\n\n5 минут пешком до остановки транспорта\n5 школ и 4 детсада поблизости\n10 минут пешком до набережной Верх-Исетского пруда\n15 минут на машине до центра города\n16 минут на машине до ТЦ 'МЕГА'\n\nДвор\nВ центре жилого квартала — зелёный двор для насыщенной жизни, игр и отдыха. Озеленение включает лиственные и хвойные деревья, декоративные кустарники и цветники.\n\nКонцепция 'двор без машин'\nЗакрытая территория с пропускной системой и без посторонних\nКруглосуточное видеонаблюдение и освещение территории в тёмное время суток\nДетские площадки для разного возраста, футбол и уличный баскетбол\n \n\nПодъезды\nПодъезд в 'Янтарной долине' — уютное и светлое пространство, в котором комфортно.\n\nДоступ в подъезды на уровне земли — без барьеров\nДомофоны в тёплых остеклённых тамбурах\nВместительный грузопассажирский лифт во всех секциях\nПомещения для колясок и велосипедов на 1 этаже\nУдобная навигация с номерами этажей и квартир\n\nПланировки\nНаш подход 'разумные метры' при проектировании квартир учитывает состав семьи, число жильцов, их потребности, а также эргономику пространства.\n\n58 уникальных планировок\nнесколько вариантов для каждого типа квартир: от студий до 3-комнатных\nобъединённые кухни-гостиные для простора и общения\nрациональное использование каждого метра\nквартиры с гардеробными и тёплыми лоджиями\n\nОтделка\nВ каждой квартире 'Янтарной долины' мы позаботились о чистовой отделке, чтобы ваш переезд прошёл быстро и без лишних хлопот.\n\nПрочный ламинат на полу в комнатах, прихожих и кухнях, плитка в санузлах, виниловые обои на флизелиновой основе на стенах, белые натяжные потолки\nМежкомнатные двери светлых тонов и сейф-дверь на входе в квартиру\nРадиаторы с терморегуляторами и горизонтальной разводкой, двухкамерные стеклопакеты с приточными клапанами вентиляции, розетки и выключатели\nВ санузлах установлены раковины и унитазы Продается 2-комнатная квартира в Екатеринбурге.",
"descriptionMinhash": [
1799783,
5781654,
15811915,
4788959,
35289122,
6143519,
18432622,
45820160,
2258400,
11955293,
25662122,
7185657,
23168773,
14391071,
12464540,
13379849,
41127868,
40846940,
36828281,
6351486,
53971654,
349072,
10292969,
2066855,
30469210,
5740363,
14611990,
794253,
929833,
501391,
122143391,
5031640
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/2950950202-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2950950220-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2950950228-1.jpg"
}
],
"addedTimestamp": 1789408293,
"offerType": "flat",
"category": "newBuildingFlatSale",
"fromDeveloper": true,
"buildersIds": [
10188
],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
},
{
"cianId": 321628098,
"id": 321628098,
"fullUrl": "https://ekb.cian.ru/sale/flat/321628098/?context=4.ZmmZlV8JbZQ.lF2e-AgOjLa29ue-yfljyTo7TzR6qFjcVKH6jOXZzubqAE_l5Ll_VyYwlGbvrZUPDs24HvZ1DfBOXg&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"bargainAllowed": null,
"vatType": null,
"agentFee": null,
"clientFee": null,
"paymentPeriod": null,
"leaseType": null,
"deposit": null,
"mortgageAllowed": null,
"vatPrice": null,
"utilitiesTerms": null,
"price": 7815000,
"leaseTermType": null,
"priceRur": 7815000,
"tags": [],
"priceType": "all",
"priceForWorkplace": null,
"saleType": "free",
"currency": "rur",
"agentBonus": null
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "54.3",
"livingArea": "25.2",
"kitchenArea": "16.6",
"floorNumber": 6,
"building": {
"floorsCount": 31,
"parking": null,
"deadline": {
"quarter": "fourth",
"year": 2028,
"isComplete": false,
"quarterEnd": "2028-12-31"
},
"type": null,
"materialType": null,
"heatingType": null,
"accessType": null,
"classType": null,
"buildYear": null,
"cargoLiftsCount": null,
"passengerLiftsCount": 8
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lat": 56.789383109239054,
"lng": 60.60020415337982
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Чкаловский",
"fullName": "р-н Чкаловский",
"id": 291
},
{
"type": "mikroraion",
"geoType": "district",
"name": "Вторчермет",
"fullName": "мкр. Вторчермет",
"id": 1509
},
{
"type": "metro",
"geoType": "underground",
"name": "Ботаническая",
"fullName": "м. Ботаническая",
"id": 348
}
],
"undergrounds": [
{
"name": "Ботаническая",
"time": 6,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Чкаловская",
"time": 6,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
},
{
"name": "Геологическая",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
}
]
},
"newbuilding": {
"isFromBuilder": true,
"isFichering": true,
"name": "Стадиум",
"id": 5705732,
"house": {
"section": "Секция 4",
"name": "Корпус 1",
"id": 10906188,
"isFinished": false,
"finishDate": {
"quarter": 4,
"year": 2028
}
},
"hasFlatTourBooking": false,
"isSalesLeader": null,
"comparisonStatus": null,
"isPremium": true,
"showJkReliableFlag": false,
"isFromSeller": false,
"isFromLeadFactory": false,
"isFromDeveloper": true,
"countPromos": 10,
"snippetType": "maximum"
},
"balconiesCount": null,
"loggiasCount": null,
"bedroomsCount": null,
"hasFurniture": null,
"decoration": null,
"isByHomeowner": null,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "ЖК \"Стадиум\" — это ваш шанс жить в одном из самых зеленых уголков Екатеринбурга, но при этом в шаговой доступности от всего важного. Комплекс строится между улицами 8 Марта, Рижским переулком, Московской и Объездной дорогой — в сердце развивающегося Чкаловского района на пересечении границ микрорайонов Ботаники, Автовокзала и Вторчермета. \n\nПредставьте: утром вы выходите в свой закрытый двор-парк с зонами для игр и спорта, а через 10 минут уже в ТЦ \"Ботаника Молл\" или деловом центре \"Астор\". Рядом — детсады, школы, поликлиники, и главное: буквально рукой подать до Парка завода РТИ, Юго-Западного лесопарка и легендарного Ботанического сада. Четыре корпуса разной этажности (от 9 до 31) спроектированы так, чтобы из квартир открывались виды на лесные массивы. \n\nЗдесь воздух по-настоящему свежий, а квартиры — это и евроформаты, и мастер-спальни с ванной комнатой, варианты с террасами и эксклюзивные планировочные решения с зенитными фонарями и панорамными окнами. Современные холлы с 4-метровыми потолками и зоной ресепшна, подземный паркинг и продуманное озеленение создают ощущение, что вы живете за городом, но со всеми плюсами мегаполиса. \n\n\"Стадиум\" — для тех, кто хочет просыпаться под пение птиц, но не готов отказываться от ритма большого города. Это баланс, который так ценят екатеринбуржцы.\" \n\nПреимущества: \n\nВидовые квартиры с террасами \n\nПодземный двухуровневый паркинг \n\nЭксклюзивные варианты с зенитными фонарями \n\nПанорамное остекление \n\nКрышная газовая котельная \n\nПодготовка под установку бризеров \n\nВытяжная вентиляция с механическим побуждением \n\nЧистая питьевая вода в каждой квартире \n\nЛобби с зоной ресепшн и хранением посылок \n\nРаздельные велосипедные и колясочные \n\nСобственная сервисная компания \n\nДизайнерская архитектура фасадов \n\nКонцепция спорта и здорового образа жизни \n\nМесто покоя и природы среди шума города\nЕкатеринбург, 8 Марта , 01 кв.324",
"descriptionMinhash": [
41920473,
15036167,
44672376,
14416152,
2088757,
6143519,
11414134,
2885405,
2746117,
42658346,
10643968,
39685766,
19735224,
5057571,
13742308,
95802641,
4256809,
38329502,
105651759,
59650532,
8686384,
35024833,
4368848,
38907129,
9460550,
6586538,
9615691,
3940137,
1195648,
10860617,
8412860,
37661809
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/2622692186-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2626766693-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2626766766-1.jpg"
}
],
"addedTimestamp": 1789634608,
"offerType": "flat",
"category": "newBuildingFlatSale",
"fromDeveloper": true,
"buildersIds": [
8624
],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
}
]
}
},
"priority": 10,
"filter": {}
}
]);
</script>
</body></html>

View file

@ -0,0 +1,120 @@
{
"_comment": "#2422: живой ответ gate-API Яндекс Недвижимости, снят 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200): rgid=559132 type=SELL category=APARTMENT newFlat=YES page=1. Урезано: 1 из 23 entities, у entity только читаемые _entity_to_lot ключи, author без agentName (PII), mainImages до 6. Значения полей не менялись.",
"response": {
"search": {
"offers": {
"entities": [
{
"offerId": "6216493807134609867",
"url": "//realty.yandex.ru/offer/6216493807134609867",
"price": {
"currency": "RUR",
"value": 27620850,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "UNCHANGED",
"hasPriceHistory": false,
"valuePerPart": 366859,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 27620850,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 27620850,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 366859,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 27620850,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"minMonthlyMortgagePayment": 282584,
"mortgageInfo": {
"bestMortgage": {
"id": "2974521",
"payment": "282584"
},
"minPreferentialMortgageRate": 6
}
},
"area": {
"value": 75.29,
"unit": "SQUARE_METER"
},
"livingSpace": {
"value": 29.03,
"unit": "SQUARE_METER"
},
"roomsTotal": 2,
"floorsOffered": [
12
],
"floorsTotal": 16,
"building": {
"builtYear": 2026,
"siteId": 2006795,
"siteName": "Александровский сад"
},
"location": {
"point": {
"latitude": 56.821068,
"longitude": 60.609924,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, жилой район Центральный, ЖК Александровский Сад",
"streetAddress": "Свердловская область, Екатеринбург, Декабристов",
"metro": {
"metroGeoId": 101986,
"metroCityRgid": 559132,
"name": "Геологическая",
"metroTransport": "ON_FOOT",
"timeToMetro": 9,
"latitude": 56.827534,
"longitude": 60.602486,
"minTimeToMetro": 8,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Геологическая",
"timeToMetro": 9
}
]
},
"creationDate": "2026-03-17T11:04:44Z",
"description": "Александровский сад настоящий бизнес-класс в центре Екатеринбурга от девелопера Forum. Забудьте о суете города. Ваш двор это пейзажный сад, где царят умиротворение и гармония. Прогулочные дорожки с мягкой вечерней подсветкой проведут по уединенным",
"author": {
"category": "DEVELOPER"
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/20177950/9476e26d-8cbc-4b69-a7bd-03fefa0a960a/main",
"//avatars.mds.yandex.net/get-realty-offers/16258130/d185b4dc-8386-4fa1-bdf9-0865da1f8e5d/main",
"//avatars.mds.yandex.net/get-realty-offers/13970442/da0f5415-d014-466b-b5e9-0fe70d5e8680/main",
"//avatars.mds.yandex.net/get-realty-offers/14824781/f882caeb-4d40-4775-9f0a-acb01177ff0e/main",
"//avatars.mds.yandex.net/get-realty-offers/20657783/1359e480-6c21-4c07-b1de-9e36149f83fd/main",
"//avatars.mds.yandex.net/get-realty-offers/14671802/38d6dc24-8366-4460-9e99-06a1605e1919/main"
]
}
],
"pager": {
"page": 0,
"pageSize": 20,
"sitesPageSize": 20,
"totalItems": 32234,
"totalPages": 25
}
}
}
}
}

View file

@ -0,0 +1,355 @@
{
"_comment": "#2422: живой ответ gate-API Яндекс Недвижимости, снят 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200): rgid=559132 type=SELL category=APARTMENT newFlat=NO page=1. Урезано: 3 из 23 entities, у entity только читаемые _entity_to_lot ключи, author без agentName (PII), mainImages до 6. Значения полей не менялись.",
"response": {
"search": {
"offers": {
"entities": [
{
"offerId": "5228381943217465492",
"url": "https://novostroi-ekb.ru/catalog/sale/vtorichka/object/1064",
"price": {
"currency": "RUR",
"value": 4400000,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "DECREASED",
"previous": 4450000,
"hasPriceHistory": true,
"valuePerPart": 129412,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 4400000,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 4400000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 129412,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 4400000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
}
},
"area": {
"value": 34,
"unit": "SQUARE_METER"
},
"kitchenSpace": {
"value": 7,
"unit": "SQUARE_METER"
},
"roomsTotal": 1,
"floorsOffered": [
8
],
"floorsTotal": 9,
"ceilingHeight": 2.5,
"building": {
"builtYear": 1978,
"buildingType": "BRICK"
},
"location": {
"point": {
"latitude": 56.903275,
"longitude": 60.6229,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, Шефская улица, 85",
"streetAddress": "Шефская улица, 85",
"metro": {
"metroGeoId": 101947,
"metroCityRgid": 559132,
"name": "Проспект Космонавтов",
"metroTransport": "ON_FOOT",
"timeToMetro": 7,
"latitude": 56.901844,
"longitude": 60.61394,
"minTimeToMetro": 5,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Проспект Космонавтов",
"timeToMetro": 7
},
{
"name": "Машиностроителей",
"timeToMetro": 14
}
]
},
"creationDate": "2026-08-19T06:23:51Z",
"description": "1 комнатная квартира 34 кв .м с развитой инфраструктурой , станция метро «КОСМАНАВТОВ» в шаговой доступности . Фукциональная планировка правильной формы, дополнительное место хранения гардеробная , в квартире сделана качественная предчистовая отделка",
"author": {
"category": "AGENCY",
"organization": "САМОЛЕТ ПЛЮС"
},
"predictions": {
"predictedPrice": {
"min": "3795000",
"max": "4638000",
"value": "4217000"
}
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/14333116/89f721ff-ad4b-49f1-8cec-432ad11baeab/main",
"//avatars.mds.yandex.net/get-realty-offers/13853239/caf4740f-dca8-47a3-8005-56968d20a3b1/main",
"//avatars.mds.yandex.net/get-realty-offers/14244677/f1943334-731e-4539-98f6-9d38d4a725ce/main",
"//avatars.mds.yandex.net/get-realty-offers/12726755/346626db-ba69-4a49-8940-76f08d6aa924/main",
"//avatars.mds.yandex.net/get-realty-offers/20074534/021cc849-6554-4059-9423-19193e3006b9/main",
"//avatars.mds.yandex.net/get-realty-offers/13912669/b240dbf0-1adc-4dbd-9ce8-c0ca6c478fc8/main"
]
},
{
"offerId": "6282460993454547968",
"url": "//realty.yandex.ru/offer/6282460993454547968",
"price": {
"currency": "RUR",
"value": 10290000,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "UNCHANGED",
"hasPriceHistory": false,
"valuePerPart": 294000,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 10290000,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 10290000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 294000,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 10290000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
}
},
"area": {
"value": 35,
"unit": "SQUARE_METER"
},
"livingSpace": {
"value": 11,
"unit": "SQUARE_METER"
},
"kitchenSpace": {
"value": 14,
"unit": "SQUARE_METER"
},
"roomsTotal": 1,
"floorsOffered": [
27
],
"floorsTotal": 27,
"ceilingHeight": 2.75,
"building": {
"builtYear": 2023,
"buildingType": "MONOLIT",
"siteId": 1668651,
"siteName": "Парковый квартал"
},
"location": {
"point": {
"latitude": 56.815243,
"longitude": 60.62593,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, Машинная улица, 4",
"streetAddress": "Машинная улица, 4",
"metro": {
"metroGeoId": 116399,
"metroCityRgid": 559132,
"name": "Чкаловская",
"metroTransport": "ON_FOOT",
"timeToMetro": 17,
"latitude": 56.807995,
"longitude": 60.610184,
"minTimeToMetro": 12,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Чкаловская",
"timeToMetro": 17
},
{
"name": "Ботаническая",
"timeToMetro": 13
},
{
"name": "Площадь 1905 Года",
"timeToMetro": 16
},
{
"name": "Геологическая",
"timeToMetro": 20
}
]
},
"creationDate": "2026-08-02T14:04:19Z",
"description": "Продам квартиру в 10 минутах от центра города. Рядом Парк Маяковского. Хорошая транспортная доступность: до метро можно дойти пешком. Великолепный вид из окна.Хороший обзор. Высокие потолки, 2,75.Нет соседей сверху. Качественный",
"author": {
"category": "OWNER"
},
"predictions": {
"predictedPrice": {
"min": "8741000",
"max": "10683000",
"value": "9712000"
}
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/10211679/add.69def35c26379a08babb742069d310dc.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/14373170/add.e2e2b76847d9b69e3abe0c64efee75c4.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/14717586/add.c65fd15bb15736448bac2a597ffd0bae.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/11512705/add.b894c4e448f696251deaed148ee9c30e.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/13847384/add.f13072669ebf54647b1682fc70fa9e99.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/14527622/add.1708ed4ada10c2d407f64b0e744d8047.realty-api-vos/main"
]
},
{
"offerId": "8530722463088927619",
"url": "//realty.yandex.ru/offer/8530722463088927619",
"price": {
"currency": "RUR",
"value": 5600000,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "UNCHANGED",
"hasPriceHistory": false,
"valuePerPart": 269231,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 5600000,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 5600000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 269231,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 5600000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
}
},
"area": {
"value": 20.8,
"unit": "SQUARE_METER"
},
"livingSpace": {
"value": 12,
"unit": "SQUARE_METER"
},
"floorsOffered": [
8
],
"floorsTotal": 11,
"ceilingHeight": 2.7,
"building": {
"builtYear": 2021,
"buildingType": "MONOLIT_BRICK",
"siteId": 1948687,
"siteName": "Северный квартал"
},
"location": {
"point": {
"latitude": 56.89804,
"longitude": 60.6199,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, переулок Ритслянда, 15",
"streetAddress": "переулок Ритслянда, 15",
"metro": {
"metroGeoId": 101947,
"metroCityRgid": 559132,
"name": "Проспект Космонавтов",
"metroTransport": "ON_FOOT",
"timeToMetro": 5,
"latitude": 56.901844,
"longitude": 60.61394,
"minTimeToMetro": 5,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Проспект Космонавтов",
"timeToMetro": 5
},
{
"name": "Уралмаш",
"timeToMetro": 13
},
{
"name": "Машиностроителей",
"timeToMetro": 12
}
]
},
"creationDate": "2026-09-15T06:17:51Z",
"description": "Продается прекрасная студия в ЖК Северные кварталы, застройщик Брусника. Дом комфорт класса Площадь студии 20,8 плюс теплая лоджия 4кв.м (убрана перегородка) -благодаря этому площадь студии увеличена до 24,8кв.м - компактно и функционально для одного",
"author": {
"category": "AGENCY",
"organization": "DOMOS CLUB"
},
"predictions": {
"predictedPrice": {
"min": "4562000",
"max": "5576000",
"value": "5069000"
}
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/14727395/1635cddb-a57c-4cce-a6c1-c70b9aef13cd/main",
"//avatars.mds.yandex.net/get-realty-offers/14621926/3ae41eaf-5b30-46ec-b844-b40af2bb697a/main",
"//avatars.mds.yandex.net/get-realty-offers/12719313/6921877e-51f6-4d02-93ef-0395b99776bb/main",
"//avatars.mds.yandex.net/get-realty-offers/13297592/8a8d583b-d205-460e-aec8-c9cb827eae73/main",
"//avatars.mds.yandex.net/get-realty-offers/10246351/8eee4902-aa24-42dc-aaa2-9259c82146d8/main",
"//avatars.mds.yandex.net/get-realty-offers/14621926/116dc1df-3288-4236-acfe-2f660fb2ac8b/main"
]
}
],
"pager": {
"page": 0,
"pageSize": 20,
"sitesPageSize": 20,
"totalItems": 4077,
"totalPages": 25
}
}
}
}
}

View file

@ -0,0 +1,45 @@
"""scraper_kit не импортирует app.* (#2422 п.2).
Инвариант держал test_kit_avito_has_no_app_imports, удалённый вместе с golden-parity
(#2419). Проверка обходит весь пакет, а не только providers: импорт app внутри kit —
цикл зависимостей, где бы он ни стоял. Смотрит AST, поэтому находит и ленивый импорт
внутри функции, и не срабатывает на слово «app» в комментарии.
"""
from __future__ import annotations
import ast
import os
from pathlib import Path
import scraper_kit
# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
KIT_ROOT = Path(scraper_kit.__file__).parent
def _app_imports(path: Path) -> list[str]:
found: list[str] = []
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))):
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.level == 0:
names = [node.module or ""]
else:
continue
found += [f"{n} (строка {node.lineno})" for n in names if n.split(".")[0] == "app"]
return found
def test_kit_has_no_app_imports() -> None:
files = sorted(KIT_ROOT.rglob("*.py"))
# Обход не пустой и дошёл до провайдеров — иначе тест зелёный по построению.
assert KIT_ROOT / "providers" / "avito" / "houses.py" in files
assert len(files) > 40
offenders = {
str(p.relative_to(KIT_ROOT)): imports for p in files if (imports := _app_imports(p))
}
assert offenders == {}

View file

@ -0,0 +1,401 @@
"""Разбор kit-парсеров на сохранённых страницах площадок: фикстура → лот (#2422, #2408).
Golden-parity тесты сверяли kit с legacy-копией и ушли вместе с ней (#2419). После
этого разбор avito.houses, cian.serp и yandex.serp не проверял никто: правка
извлечения поля в kit проходила бы зелёной. Здесь ожидания записаны прямо значениями
из сохранённых страниц, без второй реализации в роли эталона.
Фикстуры:
- avito_house_572538.html, avito_houses_sample.html карточки домов Авито
(вторичка), рейтинг только в reviews.filterEntries (#1789);
- avito_novostroyka_12879380.html карточка ЖК (developmentPage);
- avito_serp_sample.html выдача Авито, собранная из живой страницы (#726);
- cian_serp_ekb_2room_trimmed.html, yandex_gate_ekb_*_trimmed.json живые выдачи
2026-09-17, урезанные до читаемых парсером полей (что и как урезано в шапке
каждого файла; разбор урезанной и полной выдачи совпал поле в поле, кроме числа
фото).
Не покрыто здесь, потому что уже покрыто на фикстурах в своих файлах: avito.detail
(test_avito_detail_fields_3047.py), cian.detail (test_cian_views_parse.py,
test_cian_bti_house_persist.py), domclick (test_domclick_sweep.py,
scrapers/test_domclick_detail.py), base (scrapers/test_base_clamp_date.py).
"""
from __future__ import annotations
import json
import os
from datetime import date
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.houses import (
HouseCatalogEnrichment,
_extract_preloaded_state,
parse_houses_state,
)
from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.providers.cian.serp import CianScraper
from scraper_kit.providers.yandex.serp import _extract_gate_data, _parse_gate_json
FIXTURES = Path(__file__).parent.parent / "fixtures"
def _read(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
# ── avito.houses: карточка дома (вторичка) ─────────────────────────────────────
def _house(name: str, url: str) -> HouseCatalogEnrichment:
state = _extract_preloaded_state(_read(name))
assert state is not None, name
return parse_houses_state(state, url)
@pytest.fixture(scope="module")
def house_572538() -> HouseCatalogEnrichment:
return _house("avito_house_572538.html", "https://www.avito.ru/catalog/houses/572538")
@pytest.fixture(scope="module")
def house_115188() -> HouseCatalogEnrichment:
return _house("avito_houses_sample.html", "https://www.avito.ru/catalog/houses/115188")
def test_avito_house_characteristics(house_572538: HouseCatalogEnrichment) -> None:
h = house_572538.house
assert h.ext_id == 572538
assert h.ext_id_hash == "MTE3LjQ1Nzk0Mg"
assert h.full_address == "Свердловская область, Екатеринбург, ул. Сони Морозовой, 190"
assert (h.lat, h.lon) == (56.832359, 60.625583)
assert h.year_built == 1994
assert h.total_floors == 10
assert h.house_type == "brick"
assert h.material_floors == "Железобетонные"
assert h.hot_water == "Центральное"
assert h.passenger_elevators == 1
assert h.cargo_elevators is None # параметра нет на странице — не 0
assert h.has_concierge is True
assert h.has_playground is True
assert h.closed_yard is True
def test_avito_house_rating_from_reviews_filter_entries_1789(
house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment
) -> None:
"""В обеих карточках housePage отдаёт бейдж без оценки (scoreString «—»), оценка
есть только в reviews.filterEntries[type=score]. Без запасного пути #1789
рейтинг и число отзывов были бы None."""
h = house_572538.house
assert h.rating_score == pytest.approx(14 / 3)
assert h.rating_string == "4,7"
assert h.reviews_count == 3
assert [(r["score"], r["count"]) for r in h.rating_distribution] == [
(5, 2),
(4, 1),
(3, 0),
(2, 0),
(1, 0),
]
h2 = house_115188.house
assert (h2.rating_score, h2.rating_string, h2.reviews_count) == (4.0, "4,0", 4)
def test_avito_house_reviews(house_572538: HouseCatalogEnrichment) -> None:
reviews = {r.ext_review_id: r for r in house_572538.reviews}
assert list(reviews) == [2360701, 2018777, 1757422]
full = reviews[2018777]
assert full.author_name == "Иван"
assert full.score == 5
assert full.rated_date == date(2025, 9, 14)
assert full.text_main is not None and full.text_main.startswith("Желательно добавить")
assert full.text_pros is not None and full.text_pros.startswith("Удобное расположение")
assert full.text_cons is not None and full.text_cons.startswith("Возможно, проблемы")
# Секции без заголовка нет — основной текст пуст, плюсы/минусы на месте.
no_main = reviews[1757422]
assert no_main.rated_date == date(2025, 7, 9)
assert no_main.text_main is None
assert no_main.text_pros is not None and no_main.text_pros.startswith("Двор закрытый")
# Отзыв 2360701 датирован «16 апреля» без года — такую дату разбор сейчас не
# читает (rated_date=None). Это дефект, а не контракт, поэтому здесь не закреплён.
def test_avito_house_reviews_next_page(
house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment
) -> None:
assert house_572538.reviews_next_page_url is None # запись pages без nextPageUrl
assert house_115188.reviews_next_page_url == (
"/web/3/rating/model?categoryId=24&catalogId=283&paths[]=115188&limit=10&cursor=3"
"&sortBy[]=modelTypeId&sortBy[]=publishedAt&modelTypeId=14&developmentId=115188"
)
def test_avito_house_placement_history(house_572538: HouseCatalogEnrichment) -> None:
history = house_572538.placement_history
assert len(history) == 8
first = history[0]
assert first.ext_item_id == "2304054124"
assert first.title == "3-к. квартира, 108\xa0м², 5/10\xa0эт."
assert (first.start_price, first.last_price) == (11_000_000, 14_000_000)
assert first.start_price_date == date(2022, 1, 8)
assert first.last_price_date == date(2026, 1, 17)
assert first.exposure_days == 677
assert first.removed_date is None # виджет даты снятия не отдаёт
assert first.raw_payload is not None and "itemImage" not in first.raw_payload
def test_avito_house_mini_serp_and_seller(house_572538: HouseCatalogEnrichment) -> None:
# Цена в виджете — строка «21 700 000 ₽» с неразрывными пробелами.
assert [(m.ext_item_id, m.price_rub) for m in house_572538.mini_serp] == [
(8007602481, 21_700_000),
(8029678809, 21_600_000),
]
first = house_572538.mini_serp[0]
assert first.metro_text == "Геологическая, 1,5 км"
assert first.metro_color == "#0A6F20"
assert first.seller is not None
assert first.seller.ext_seller_id == "i251003466"
assert first.seller.name == "Самолет Плюс Центральный"
assert first.seller.seller_type == "Компания"
def test_avito_house_recommendations(house_115188: HouseCatalogEnrichment) -> None:
recs = house_115188.recommendations
assert len(recs) == 14
assert recs[0].title == "1-к. квартира, 30,3\xa0м², 3/5\xa0эт."
assert recs[0].price_text == "4\xa0000\xa0000 ₽"
assert recs[0].address == "Свердловская область, Екатеринбург, улица 40-летия Октября, 32А"
assert recs[0].image_url is not None and recs[0].image_url.startswith(
"https://50.img.avito.st/"
)
# ── avito.houses: карточка ЖК (developmentPage) ────────────────────────────────
def test_avito_novostroyka_development_page() -> None:
e = _house(
"avito_novostroyka_12879380.html",
"https://www.avito.ru/catalog/novostroyki/ekaterinburg/semeynyy_kvartal_beregovoy/12879380",
)
h = e.house
assert h.ext_id == 12879380
assert h.ext_id_hash == "MGDKHN70PQ"
assert h.title == "Семейный квартал «Береговой»"
assert h.short_address == "ул. Щербакова, жилые дома"
assert h.full_address is None # на странице пустая строка
assert (h.lat, h.lon) == (56.77005, 60.67244)
assert h.house_type == "monolith"
assert h.house_class == "comfort"
assert (h.passenger_elevators, h.cargo_elevators) == (42, 60)
assert (h.developer_name, h.developer_key) == ("Талан", "6749804656-3468412387")
assert h.year_built is None # дом строится: «Срок сдачи», а не год постройки
assert h.rating_score is None
assert e.reviews == [] and e.placement_history == []
# ── avito.serp ─────────────────────────────────────────────────────────────────
def test_avito_serp_cards_to_lots() -> None:
scraper = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True)) # type: ignore[arg-type]
lots = scraper._parse_html(
_read("avito_serp_sample.html"),
source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/prodam?p=1",
)
got = [
(lot.source_id, lot.price_rub, lot.rooms, lot.area_m2, lot.floor, lot.total_floors)
for lot in lots
]
assert got == [
("8043936560", 5_500_000, 2, 54.0, 5, 9),
("8163084615", 4_200_000, 1, 38.0, 3, 16),
]
assert lots[0].source_url == (
"https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_54m_59et._8043936560"
)
# sortTimeStamp в миллисекундах, дата по Москве (#726).
assert [lot.listing_date for lot in lots] == [date(2023, 11, 15), date(2023, 11, 16)]
assert {lot.listing_segment for lot in lots} == {"vtorichka"}
assert scraper.parse_failures == 0
# ── cian.serp ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def cian_lots() -> dict[str, Any]:
scraper = CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type]
html = _read("cian_serp_ekb_2room_trimmed.html")
lots = scraper._parse_serp_html(html)
assert scraper._extract_total_offers(html) == 13_895
assert (scraper.state_extraction_attempts, scraper.state_extraction_failures) == (2, 0)
return {lot.source_id: lot for lot in lots}
def test_cian_serp_all_offers_parsed(cian_lots: dict[str, Any]) -> None:
assert list(cian_lots) == ["331661795", "333599038", "333279363", "321628098"]
def test_cian_serp_secondary_offer(cian_lots: dict[str, Any]) -> None:
lot = cian_lots["331661795"]
assert lot.source == "cian"
assert lot.source_url.startswith("https://ekb.cian.ru/sale/flat/331661795/")
assert lot.listing_segment == "vtorichka"
assert (lot.house_source, lot.house_ext_id) == ("cian", None)
assert lot.price_rub == 7_990_000
assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == (
2,
47.2,
28.0,
7.5,
)
assert (lot.floor, lot.total_floors, lot.year_built) == (2, 17, 1980)
assert lot.house_type == "panel"
assert lot.address == "р-н Октябрьский, мкр. Парковый, улица Большакова, 13"
assert (lot.lat, lot.lon) == (56.820035, 60.634287)
assert lot.listing_date == date(2026, 9, 14)
assert (lot.mortgage_available, lot.sale_type) == (True, "alternative")
assert (lot.is_homeowner, lot.is_pro_seller) == (False, True)
assert lot.phones == [] # #2217: телефоны не сохраняем
assert lot.metro_stations[0] == {
"name": "Геологическая",
"time": 5,
"mode": "transport",
"line_color": "00701A",
"line_id": 26,
"is_default": True,
}
# descriptionMinhash приходит списком — склеен через запятую в исходном порядке.
assert lot.description_minhash.startswith("16147461,5781654,4548911,")
assert len(lot.photo_urls) == 3
def test_cian_serp_newbuilding_with_house(cian_lots: dict[str, Any]) -> None:
lot = cian_lots["333599038"]
assert lot.listing_segment == "novostroyki"
assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5779142")
assert lot.address == "р-н Железнодорожный, мкр. Старая Сортировка, переулок Проходной, 1Б"
assert lot.year_built == 2028 # buildYear нет, берётся год сдачи из deadline
assert lot.house_type == "monolith"
assert (lot.price_rub, lot.area_m2, lot.floor, lot.total_floors) == (4_834_000, 36.04, 22, 32)
assert lot.sale_type == "fz214"
def test_cian_serp_newbuilding_decoration_and_balcony(cian_lots: dict[str, Any]) -> None:
lot = cian_lots["333279363"]
assert lot.repair_state == "good" # decoration=fine
assert lot.has_balcony is True # balconiesCount=1
assert lot.house_type is None # materialType на странице null
assert lot.year_built == 2026
def test_cian_serp_newbuilding_without_street_keeps_address_houseless(
cian_lots: dict[str, Any],
) -> None:
"""В geo.address нет ни улицы, ни дома; в названии ЖК номера нет, кадастра нет.
Номер дома не выдумывается (#1773)."""
lot = cian_lots["321628098"]
assert lot.address == "р-н Чкаловский, мкр. Вторчермет"
assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5705732")
# ── yandex.serp ────────────────────────────────────────────────────────────────
def _gate(name: str) -> dict[str, Any]:
return json.loads(_read(name))
@pytest.fixture(scope="module")
def yandex_secondary() -> dict[str, Any]:
lots = _parse_gate_json(_gate("yandex_gate_ekb_vtorichka_trimmed.json"), 1, "NO")
return {lot.source_id: lot for lot in lots}
def test_yandex_gate_pager() -> None:
extracted = _extract_gate_data(_gate("yandex_gate_ekb_vtorichka_trimmed.json"))
assert extracted is not None
entities, pager = extracted
assert len(entities) == 3
assert (pager["totalItems"], pager["totalPages"]) == (4077, 25)
def test_yandex_agency_offer_with_partner_url(yandex_secondary: dict[str, Any]) -> None:
lot = yandex_secondary["5228381943217465492"]
# url в выдаче — сайт агентства; source_url строится из offerId (#2235).
assert lot.source_url == "https://realty.yandex.ru/offer/5228381943217465492/"
assert lot.yandex_offer_id == "5228381943217465492"
assert lot.listing_segment == "vtorichka"
assert (lot.price_rub, lot.price_per_m2) == (4_400_000, 129_412)
assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == (1, 34.0, None, 7.0)
assert (lot.floor, lot.total_floors, lot.year_built) == (8, 9, 1978)
assert lot.house_type == "brick" # BRICK
assert lot.ceiling_height_m == 2.5
assert lot.address == "Россия, Свердловская область, Екатеринбург, Шефская улица, 85"
assert (lot.lat, lot.lon) == (56.903275, 60.6229)
assert (lot.house_source, lot.house_ext_id) == (None, None)
assert lot.listing_date == lot.publish_date == date(2026, 8, 19)
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, True, "САМОЛЕТ ПЛЮС")
assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == (
4_217_000,
3_795_000,
4_638_000,
)
assert (lot.price_trend, lot.price_previous_rub) == ("DECREASED", 4_450_000)
assert lot.metro_stations[0] == {"name": "Проспект Космонавтов", "min": 7}
# В фикстуре 6 фото с «//»: берутся первые 5 со схемой.
assert len(lot.photo_urls) == 5
assert all(u.startswith("https://avatars.mds.yandex.net/") for u in lot.photo_urls)
def test_yandex_owner_offer_in_complex(yandex_secondary: dict[str, Any]) -> None:
lot = yandex_secondary["6282460993454547968"]
assert lot.source_url == "https://realty.yandex.ru/offer/6282460993454547968"
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (True, None, None)
assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "1668651")
assert lot.house_type == "monolith" # MONOLIT
assert (lot.floor, lot.total_floors) == (27, 27)
assert (lot.living_area_m2, lot.kitchen_area_m2) == (11.0, 14.0)
def test_yandex_studio_without_rooms_total(yandex_secondary: dict[str, Any]) -> None:
lot = yandex_secondary["8530722463088927619"]
assert lot.rooms == 0 # roomsTotal отсутствует — студия
assert lot.area_m2 == 20.8
assert lot.kitchen_area_m2 is None
assert lot.house_type == "monolith_brick" # MONOLIT_BRICK
def test_yandex_newbuilding_without_valuation() -> None:
lots = _parse_gate_json(_gate("yandex_gate_ekb_novostroyki_trimmed.json"), 1, "YES")
assert len(lots) == 1
lot = lots[0]
assert lot.source_id == "6216493807134609867"
assert lot.listing_segment == "novostroyki"
assert lot.price_rub == 27_620_850
assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == (
None,
None,
None,
)
assert lot.ceiling_height_m is None
assert lot.house_type is None
assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "2006795")
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, None, None)
assert lot.address == (
"Россия, Свердловская область, Екатеринбург, жилой район Центральный, "
"ЖК Александровский Сад"
)

View file

@ -37,6 +37,7 @@ tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_t
tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
@ -186,3 +187,8 @@ tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_call
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09. # снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.
tests/test_3385_migration_310_yandex_seed_slips.py::test_deletes_only_seed_off_by_order_and_is_idempotent tests/test_3385_migration_310_yandex_seed_slips.py::test_deletes_only_seed_off_by_order_and_is_idempotent
tests/test_3385_migration_310_yandex_seed_slips.py::test_stops_and_rolls_back_when_too_many tests/test_3385_migration_310_yandex_seed_slips.py::test_stops_and_rolls_back_when_too_many
# Вечный недогруз Яндекса (#3191, миграция 321): снапшот-SELECT очереди добора с
# суточной паузой и выбыванием после трёх недогрузов судит только Postgres — на
# мок-лэйне БД нет. В ci-tradein.yml бежит по-настоящему (postgres-сервис, #2745).
tests/test_3191_yandex_perpetual_underloaded.py::test_perpetual_underloaded_cards_leave_queue_head

View file

@ -0,0 +1,151 @@
"""#2898: окно срока экспозиции (p25p75) вместо одного числа и его персист.
Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не
сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых
объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET.
Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция
(n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией.
"""
from __future__ import annotations
import contextlib
import os
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import (
EXPOSURE_WINDOW_MIN_N,
_exposure_window,
_fetch_exposure_days,
)
# 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское
# округление дало бы 16); p75 на 21.75 → 23.75 → 24.
_DAYS_30 = list(range(2, 32))
def test_quartiles_match_percentile_cont() -> None:
w = _exposure_window(_DAYS_30)
assert w is not None
assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30)
def test_order_of_input_does_not_matter() -> None:
w = _exposure_window(list(reversed(_DAYS_30)))
assert w is not None
assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24)
def test_skewed_sample_window() -> None:
"""Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост."""
days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400]
w = _exposure_window(days)
assert w is not None
# позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62
assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62)
def test_below_threshold_no_window() -> None:
assert EXPOSURE_WINDOW_MIN_N == 30
assert _exposure_window(_DAYS_30[:-1]) is None
assert _exposure_window([]) is None
def test_fetch_failure_is_graceful() -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
def test_fetch_without_coords_does_not_query() -> None:
db = MagicMock()
assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
assert not db.execute.called
# ── POST: окно уходит и в ответ, и в INSERT ───────────────────────────────────
def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]:
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult
db = MagicMock()
payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1)
geo = GeocodeResult(
lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim"
)
fetch = MagicMock(return_value=exposure_days)
patches = (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation", new=AsyncMock(return_value=None)
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
patch("app.services.estimator._fetch_exposure_days", new=fetch),
)
async def _run() -> Any:
with contextlib.ExitStack() as stack:
for cm in patches:
stack.enter_context(cm)
return await estimate_quality(payload, db)
result = anyio.run(_run)
# Выборка берётся по координатам и параметрам оцениваемой квартиры.
kwargs = fetch.call_args.kwargs
assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == (
56.838,
60.595,
1,
40.0,
)
for call in db.execute.call_args_list:
sql = str(getattr(call.args[0], "text", call.args[0]))
if "INSERT INTO trade_in_estimates" in sql:
return result, call.args[1]
raise AssertionError("INSERT INTO trade_in_estimates не вызван")
def test_post_persists_window_and_returns_it() -> None:
result, params = _run_estimate(_DAYS_30)
assert (
params["est_days_p25"],
params["est_days_p50"],
params["est_days_p75"],
params["est_days_n"],
) == (9, 17, 24, 30)
assert result.exposure_window is not None
assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24)
# Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений.
assert result.est_days_on_market == 17
def test_post_small_sample_persists_nulls() -> None:
result, params = _run_estimate(_DAYS_30[:-1])
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
None,
None,
None,
None,
]
assert result.exposure_window is None
assert result.est_days_on_market is None

View file

@ -0,0 +1,246 @@
"""#2993: listing_source_snapshots получает строку только при изменении источника.
Проверки идут по ЗНАЧЕНИЯМ в живом Postgres: какие источники получили строку за
сегодня, с какими значениями, и какие события записал event-diff поверх них.
Без реальной БД файл self-skip'ается, как соседние live-тесты (в CI есть
postgres, см. ci-tradein.yml). Вся синтетика живёт в одной транзакции, очистка
rollback.
"""
from __future__ import annotations
import os
import uuid
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import text
from app.tasks import listing_source_snapshot as snap_mod
def _live_session() -> Any | None:
"""Тот же контракт, что у test_revisit_floor_lateral_lookup.py."""
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
pytestmark = pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
_PRICE = 5_000_000
def _source(db: Any, tag: str, *, price: int, payload: str, seen_days_ago: int) -> int:
ext_id = f"zzz2993-{tag}-{uuid.uuid4().hex[:8]}"
listing_id = db.execute(
text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, price_rub,
is_active, scraped_at, last_seen_at)
VALUES ('cian', :url, :ext_id, :ext_id, :price, true, now(), now())
RETURNING id
"""
),
{"url": f"https://example.test/2993/{ext_id}", "ext_id": ext_id, "price": price},
).scalar_one()
return int(
db.execute(
text(
"""
INSERT INTO listing_sources
(listing_id, ext_source, ext_id, confidence, matched_method,
price_rub, raw_payload, last_seen_at)
VALUES (:listing_id, 'cian', :ext_id, 1.0, 'test',
:price, CAST(:payload AS jsonb),
now() - make_interval(days => :seen))
RETURNING id
"""
),
{
"listing_id": listing_id,
"ext_id": ext_id,
"price": price,
"payload": payload,
"seen": seen_days_ago,
},
).scalar_one()
)
def _snapshot(
db: Any,
lsid: int,
*,
days_ago: int,
price: int,
payload: str,
seen_days_ago: int,
is_active: bool = True,
) -> None:
# now() постоянен внутри транзакции — last_seen_at снимка совпадает с
# listing_sources.last_seen_at до микросекунды, если seen_days_ago тот же.
db.execute(
text(
"""
INSERT INTO listing_source_snapshots
(listing_source_id, snapshot_date, price_rub, is_active,
last_seen_at, payload_hash)
VALUES (:lsid, CURRENT_DATE - :days_ago, :price, :is_active,
now() - make_interval(days => :seen),
md5(CAST(:payload AS jsonb)::text))
"""
),
{
"lsid": lsid,
"days_ago": days_ago,
"price": price,
"is_active": is_active,
"seen": seen_days_ago,
"payload": payload,
},
)
def _run_writer(db: Any) -> None:
db.execute(
snap_mod._SNAPSHOT_SQL,
{"freshness_days": snap_mod.FRESHNESS_WINDOW_DAYS, "run_id": None},
)
def _today(db: Any, ids: list[int]) -> dict[int, tuple[int, bool]]:
rows = db.execute(
text(
"""
SELECT listing_source_id, price_rub, is_active
FROM listing_source_snapshots
WHERE snapshot_date = CURRENT_DATE AND listing_source_id = ANY(:ids)
"""
),
{"ids": ids},
).fetchall()
return {int(r[0]): (int(r[1]), bool(r[2])) for r in rows}
def _seed(db: Any) -> dict[str, int]:
"""Семь источников: по одному на каждую ветку условия записи."""
p = '{"v": 1}'
ids = {
"same": _source(db, "same", price=_PRICE, payload=p, seen_days_ago=1),
"same_gap": _source(db, "same_gap", price=_PRICE, payload=p, seen_days_ago=1),
"price": _source(db, "price", price=_PRICE, payload=p, seen_days_ago=1),
"payload": _source(db, "payload", price=_PRICE, payload=p, seen_days_ago=1),
"seen": _source(db, "seen", price=_PRICE, payload=p, seen_days_ago=1),
"active": _source(db, "active", price=_PRICE, payload=p, seen_days_ago=10),
"new": _source(db, "new", price=_PRICE, payload=p, seen_days_ago=1),
}
_snapshot(db, ids["same"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=1)
# Последний снимок пятидневной давности совпадает, а более старый — нет:
# сравнивать обязаны с ПОСЛЕДНИМ, а не с любым из прошлых.
_snapshot(db, ids["same_gap"], days_ago=9, price=_PRICE - 1, payload=p, seen_days_ago=9)
_snapshot(db, ids["same_gap"], days_ago=5, price=_PRICE, payload=p, seen_days_ago=1)
_snapshot(db, ids["price"], days_ago=1, price=4_900_000, payload=p, seen_days_ago=1)
_snapshot(db, ids["payload"], days_ago=1, price=_PRICE, payload='{"v": 0}', seen_days_ago=1)
_snapshot(db, ids["seen"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=3)
# last_seen_at тот же, но 10 суток > окна свежести: вчера снимок был активным.
_snapshot(db, ids["active"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=10)
return ids
def test_only_changed_or_new_sources_get_a_row_today() -> None:
db = _live_session()
try:
ids = _seed(db)
_run_writer(db)
today = _today(db, list(ids.values()))
by_tag = {tag: today.get(lsid) for tag, lsid in ids.items()}
assert by_tag == {
"same": None,
"same_gap": None,
"price": (_PRICE, True),
"payload": (_PRICE, True),
"seen": (_PRICE, True),
"active": (_PRICE, False),
"new": (_PRICE, True),
}
finally:
db.rollback()
db.close()
def test_events_survive_change_only_writes() -> None:
"""event-diff видит только записанные сегодня строки — и не теряет ни одного события."""
db = _live_session()
try:
ids = _seed(db)
_run_writer(db)
db.execute(snap_mod._EVENT_DIFF_SQL).fetchall()
rows = db.execute(
text(
"""
SELECT listing_source_id, event_type, diff_percent
FROM listing_source_events
WHERE listing_source_id = ANY(:ids)
"""
),
{"ids": list(ids.values())},
).fetchall()
tag_of = {lsid: tag for tag, lsid in ids.items()}
events = {(tag_of[int(r[0])], r[1], None if r[2] is None else float(r[2])) for r in rows}
assert events == {
("price", "price_change", 2.0408), # round((5.0-4.9)/4.9*100, 4)
("payload", "edited", None),
("new", "first_seen", None),
}
finally:
db.rollback()
db.close()
def test_rerun_same_day_compares_with_todays_row() -> None:
"""Второй прогон в те же сутки сравнивает с уже записанной сегодня строкой.
price вернулся к вчерашней цене сегодняшняя строка обязана перезаписаться
(иначе на сегодня осталась бы цена, которой у источника больше нет); same
изменился после первого прогона строка появляется; seen не менялся после
первого прогона строка остаётся как была.
"""
db = _live_session()
try:
ids = _seed(db)
_run_writer(db)
db.execute(
text("UPDATE listing_sources SET price_rub = 4900000 WHERE id = :id"),
{"id": ids["price"]},
)
db.execute(
text("UPDATE listing_sources SET price_rub = 5100000 WHERE id = :id"),
{"id": ids["same"]},
)
_run_writer(db)
today = _today(db, list(ids.values()))
assert today.get(ids["price"]) == (4_900_000, True)
assert today.get(ids["same"]) == (5_100_000, True)
assert today.get(ids["seen"]) == (_PRICE, True)
assert ids["same_gap"] not in today
finally:
db.rollback()
db.close()

View file

@ -0,0 +1,230 @@
"""Вечно недогружаемые карточки Яндекса не держат очередь добора (#3191).
Прод 13.09 21:14 16.09 18:52: пять объявлений (10776456, 10775964, 10775945,
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
SPA-оболочкой ~381 КБ без блока контактов. Пять недогрузов подряд выбивали брейкер
consecutive_none=5: 16 прогонов закончились ровно attempted=5 incomplete=5, а
«успешные» обрывались, едва дойдя до них. За ними 20 тыс. необогащённых объявлений.
Проверки ПО ЗНАЧЕНИЮ:
* мок-часть какие карточки запрошены и какие счётчики вышли;
* живой Postgres (в CI есть, локально skip) что НАСТОЯЩИЙ снапшот-SELECT после
недогруза не берёт карточку сутки, берёт её после паузы и выбрасывает после трёх.
"""
from __future__ import annotations
import os
import sys
import uuid
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from sqlalchemy import text # noqa: E402
from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill # noqa: E402
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
# Оболочка как на проде: 381 КБ, контактов нет. Полная — 3,9 МБ с маркером.
SHELL_HTML = "<html>" + "x" * 381_157 + "</html>"
FULL_HTML = "<html>" + "x" * 3_920_119 + '"encryptedPhones":["a"]</html>'
PERPETUAL = 5
NORMAL = 3
def _resp(html: str) -> MagicMock:
resp = MagicMock()
resp.status_code = 200
resp.text = html
return resp
async def _run(db: Any, perpetual_urls: set[str]) -> tuple[Any, list[str]]:
"""Один прогон; оболочку отдаём по URL, всё остальное — полная карточка."""
requested: list[str] = []
async def get(url: str, **_kw: Any) -> MagicMock:
requested.append(url)
return _resp(SHELL_HTML if url in perpetual_urls else FULL_HTML)
session = AsyncMock()
session.get = AsyncMock(side_effect=get)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
with (
patch(_ASYNC_SESSION, MagicMock(return_value=ctx)),
patch(_PARSE, return_value=MagicMock()),
patch(_SAVE, MagicMock(return_value=True)),
patch(_RUNS, MagicMock()),
patch(_SLEEP, new_callable=AsyncMock),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
counters = await run_yandex_detail_backfill(
db, run_id=3191, params={"batch_size": PERPETUAL + NORMAL, "budget_sec": 3600}
)
return counters, requested
# ── 1. Брейкер: повтор известной карточки не рвёт прогон, первый недогруз — рвёт ──
def _mock_db(prior_incomplete: int) -> tuple[MagicMock, set[str], list[str]]:
rows = [
{
"id": i + 1,
"source_url": f"https://realty.yandex.ru/offer/{i + 1}/",
"detail_incomplete_count": prior_incomplete if i < PERPETUAL else 0,
}
for i in range(PERPETUAL + NORMAL)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = rows
sel.one.return_value = MagicMock(url_from_offer_id=0, unenrichable_pending=0)
sel.scalar_one.return_value = 0
db.execute.return_value = sel
urls = [r["source_url"] for r in rows]
return db, set(urls[:PERPETUAL]), urls[PERPETUAL:]
@pytest.mark.asyncio
async def test_retry_of_known_underloaded_cards_does_not_abort_run() -> None:
"""Пять карточек, уже недогружавшихся раньше, в голове снапшота — прогон идёт дальше."""
db, perpetual, normal = _mock_db(prior_incomplete=1)
counters, requested = await _run(db, perpetual)
assert set(normal) <= set(requested), (
f"запрошено {len(requested)} из {PERPETUAL + NORMAL}: повторный недогруз известных "
"карточек оборвал прогон раньше, чем очередь дошла до следующих"
)
assert (counters.attempted, counters.enriched, counters.incomplete) == (8, 3, 5)
@pytest.mark.asyncio
async def test_first_underload_series_still_aborts_run() -> None:
"""Защита от системного недогруза жива: пять ПЕРВЫХ недогрузов подряд — обрыв."""
db, perpetual, normal = _mock_db(prior_incomplete=0)
counters, requested = await _run(db, perpetual)
assert not set(normal) & set(requested), (
"пять первых недогрузов подряд не оборвали прогон — брейкер системного "
"недогруза больше не срабатывает"
)
assert (counters.attempted, counters.enriched, counters.incomplete) == (5, 0, 5)
# ── 2. Очередь на живом Postgres ─────────────────────────────────────────────
def _live_session() -> Any | None:
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
@pytest.mark.asyncio
async def test_perpetual_underloaded_cards_leave_queue_head() -> None:
db = _live_session()
tag = f"t3191-{uuid.uuid4().hex[:8]}"
try:
ids: list[int] = []
urls: list[str] = []
for i in range(PERPETUAL + NORMAL):
url = f"https://realty.yandex.ru/offer/3191{uuid.uuid4().int % 10**12}/"
# Вечные — самые свежие (голова очереди), как 12.09 15:02 на проде.
# Дата в будущем: чужие необогащённые строки тестовой БД не встанут впереди.
row_id = db.execute(
text(
"""
INSERT INTO listings (source, source_url, source_id, dedup_hash,
price_rub, is_active, scraped_at)
VALUES ('yandex', :url, :sid, :sid, 5000000, true,
TIMESTAMPTZ '2100-01-01' - make_interval(mins => :i))
RETURNING id
"""
),
{"url": url, "sid": f"{tag}-{i}", "i": i},
).scalar_one()
ids.append(row_id)
urls.append(url)
db.commit()
perpetual = set(urls[:PERPETUAL])
normal = urls[PERPETUAL:]
# Прогон 1: карточки ещё не недогружались — первый недогруз, обрыв на пятой.
_c1, req1 = await _run(db, perpetual)
assert set(req1) == perpetual, req1
# Прогон 2 сразу следом: голова очереди больше не держит — берутся следующие.
c2, req2 = await _run(db, perpetual)
assert not set(req2) & perpetual, (
"недогруженные час назад карточки снова в снапшоте — прогон опять "
"упрётся в них, как 16 прогонов 13.0916.09"
)
assert set(normal) <= set(req2)
assert c2.enriched == NORMAL
# Сутки спустя: повтор (разовый недогруз мог пройти) и прогон не обрывается.
def age(hours: int) -> None:
db.execute(
text(
"UPDATE listings SET detail_incomplete_at = now() - make_interval(hours => :h) "
"WHERE id = ANY(:ids)"
),
{"h": hours, "ids": ids[:PERPETUAL]},
)
db.commit()
age(25)
c3, req3 = await _run(db, perpetual)
assert perpetual <= set(req3), "после суток паузы карточки обязаны вернуться в очередь"
assert set(normal) <= set(req3), "повтор известных карточек оборвал прогон"
assert c3.incomplete == PERPETUAL
age(25)
await _run(db, perpetual) # третья попытка
age(25)
c5, req5 = await _run(db, perpetual)
assert not set(req5) & perpetual, "после трёх недогрузов карточка всё ещё в очереди"
assert c5.incomplete_given_up >= PERPETUAL, (
f"incomplete_given_up={c5.incomplete_given_up}: выбывшие из очереди не посчитаны"
)
counts = db.execute(
text("SELECT detail_incomplete_count FROM listings WHERE id = ANY(:ids) ORDER BY id"),
{"ids": ids},
).scalars()
assert list(counts) == [3] * PERPETUAL + [0] * NORMAL
finally:
db.rollback()
db.execute(text("DELETE FROM listings WHERE source_id LIKE :p"), {"p": f"{tag}-%"})
db.commit()
db.close()

View file

@ -0,0 +1,24 @@
"""#3412: у tradein-browser PID 1 — init, который собирает осиротевших детей camoufox.
Без `init: true` PID 1 контейнера `python server.py`; дочерние процессы закрытого
браузера переходят к нему и остаются зомби (прод 17.09.2026: 2296 процессов в Z за 14 ч).
Проверка по значению: разобранный compose, сервис с образом tradein-browser.
"""
from pathlib import Path
import yaml
_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml"
def test_browser_service_runs_under_init() -> None:
services = yaml.safe_load(_COMPOSE.read_text())["services"]
browser = {
name: svc
for name, svc in services.items()
if "gendesign-tradein-browser" in svc.get("image", "")
}
# Контроль разбора: пустой словарь дал бы зелёный тест ни о чём.
assert set(browser) == {"browser"}, sorted(browser)
assert browser["browser"].get("init") is True

View file

@ -519,13 +519,23 @@ def test_argparse_resolve_house_id_override() -> None:
def test_house_id_resolution_as_json_shape() -> None: def test_house_id_resolution_as_json_shape() -> None:
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2) res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2, house_type_from_houses=3)
assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2} assert res.as_json() == {
"resolved": 4,
"total": 10,
"imv_reachable": 2,
"house_type_from_houses": 3,
}
def test_house_id_resolution_defaults_zero() -> None: def test_house_id_resolution_defaults_zero() -> None:
res = bt._HouseIdResolution() res = bt._HouseIdResolution()
assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0} assert res.as_json() == {
"resolved": 0,
"total": 0,
"imv_reachable": 0,
"house_type_from_houses": 0,
}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -1006,3 +1016,108 @@ def test_predict_full_spine_passes_resolved_city_to_corridor(
assert captured.get("called") is True assert captured.get("called") is True
assert captured.get("city") == "нижний тагил" assert captured.get("city") == "нижний тагил"
assert pred is not None assert pred is not None
# --------------------------------------------------------------------------- #
# #2862: тип дома из `houses` доезжает до отбора аналогов, как в estimate_quality.
# --------------------------------------------------------------------------- #
def _run_full_with_house_facts(
monkeypatch: pytest.MonkeyPatch, *, resolve_house_id: bool
) -> tuple[dict[int, set[object]], list[dict[str, object]], dict[str, object]]:
from types import SimpleNamespace
from app.services import estimator as est_mod
def _deal(deal_id: int, house_type: str | None) -> object:
return bt.DealSample(
id=deal_id,
lon=60.6,
lat=56.8,
rooms=2,
sold_ppm2=100_000.0,
deal_date=None,
area_m2=50.0,
address="Екатеринбург, ул. Ленина, 5",
floor=3,
year_built=None,
house_type=house_type,
)
# Сделка 1 — как все сделки на проде (house_type пуст); сделка 2 — тип уже есть.
monkeypatch.setattr(bt, "_load_sample", lambda *a, **kw: [_deal(1, None), _deal(2, "brick")])
monkeypatch.setattr(bt, "_resolve_house_id_for_deal", lambda _db, _deal: 77)
lookups: list[dict[str, object]] = []
def _fake_lookup(_db: object, **kw: object) -> object:
lookups.append(kw)
return est_mod._HouseFacts(
house_id=77, total_floors=16, year_built=1985, house_type="panel", material_walls=None
)
seen: dict[int, set[object]] = {}
current: dict[str, int] = {}
def _fake_fetch_analogs(_db: object, **kw: object) -> tuple[list[object], bool, str]:
seen.setdefault(current["id"], set()).add(kw["house_type"])
return [], False, "W"
def _fake_price_from_inputs(**_kw: object) -> SimpleNamespace:
return SimpleNamespace(
median_price=5_000_000.0,
median_ppm2=100_000.0,
confidence="low",
anchor_tier=None,
expected_sold_per_m2=95_000.0,
expected_sold_price=4_750_000.0,
expected_sold_range_low=4_000_000.0,
expected_sold_range_high=5_500_000.0,
)
real_predict = bt._predict_full_spine
def _tracking_predict(db: object, deal: object, est: object, **kw: object) -> object:
current["id"] = deal.id # type: ignore[attr-defined]
return real_predict(db, deal, est, **kw) # type: ignore[arg-type]
monkeypatch.setattr(est_mod, "_lookup_house_facts", _fake_lookup)
monkeypatch.setattr(est_mod, "_fetch_analogs", _fake_fetch_analogs)
monkeypatch.setattr(est_mod, "_resolve_target_city", lambda _addr: None)
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", lambda *a, **kw: None)
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
monkeypatch.setattr(bt, "_predict_full_spine", _tracking_predict)
metrics = bt.run_backtest_full(
None, # type: ignore[arg-type]
sample=2,
since="2025-06-01",
resolve_house_id=resolve_house_id,
)
return seen, lookups, metrics
def test_full_backtest_takes_house_type_from_houses_like_prod(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=True)
# Пустой тип сделки заменён типом дома — именно он уходит в каждый отбор аналогов.
assert seen[1] == {"panel"}
# Свой тип сделки главнее справочника, как форма главнее houses на проде.
assert seen[2] == {"brick"}
assert lookups == [{"target_house_id": 77, "lat": 56.8, "lon": 60.6}]
assert metrics["house_id_resolution"]["house_type_from_houses"] == 1 # type: ignore[index]
def test_full_backtest_without_flag_keeps_house_type_untouched(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=False)
assert seen[1] == {None}
assert lookups == []
assert "house_id_resolution" not in metrics

View file

@ -80,6 +80,11 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
# #2899: колонка есть у всех строк после миграции 267; NULL у старых # #2899: колонка есть у всех строк после миграции 267; NULL у старых
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить). # (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
market_percentile=63, market_percentile=63,
# #2898: окно срока экспозиции (миграция 324); NULL у строк до неё.
est_days_p25=31,
est_days_p50=58,
est_days_p75=121,
est_days_n=44,
analogs=[], analogs=[],
actual_deals=[], actual_deals=[],
sources_used=["avito"], sources_used=["avito"],
@ -801,3 +806,36 @@ def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["market_percentile"] is None assert resp.json()["market_percentile"] is None
def test_get_estimate_surfaces_exposure_window(trade_in_app: FastAPI) -> None:
"""#2898: окно срока поднимается из колонок — по ссылке то же, что при оценке.
До #2898 GET отдавал est_days_on_market=null всегда: значение жило только в POST.
"""
row = _make_estimate_row(created_by="kopylov")
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["exposure_window"] == {"p25_days": 31, "p50_days": 58, "p75_days": 121, "n": 44}
assert body["est_days_on_market"] == 58
def test_get_estimate_old_row_without_window(trade_in_app: FastAPI) -> None:
"""Контроль: строка до миграции 324 (все четыре NULL) — окна нет, старое поле null."""
row = _make_estimate_row(created_by="kopylov")
row.est_days_p25 = row.est_days_p50 = row.est_days_p75 = row.est_days_n = None
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["exposure_window"] is None
assert resp.json()["est_days_on_market"] is None

View file

@ -602,3 +602,32 @@ def test_empty_estimate_persists_relaxations_reliability() -> None:
src = inspect.getsource(estimator._empty_estimate) src = inspect.getsource(estimator._empty_estimate)
assert "relaxations, reliability" in src assert "relaxations, reliability" in src
assert "'very_low'" in src assert "'very_low'" in src
def test_revival_persists_exposure_window(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""#2898: пересчитанное окно срока пишется в исходную строку — иначе следующий
GET по той же ссылке отдал бы окно null, хотя ответ ревайвла его показал."""
from app.schemas.trade_in import ExposureWindow
window = ExposureWindow(p25_days=31, p50_days=58, p75_days=121, n=44)
async def _fake_estimate_quality(payload, db, **kwargs):
return _fake_revived_result(exposure_window=window, est_days_on_market=58)
_estimator_stub.estimate_quality = _fake_estimate_quality
db = _dispatch_db(_make_dead_row(), claim_result=SimpleNamespace(id=_ESTIMATE_ID))
resp = _client_with(trade_in_app, db).get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
params = _calls_containing(db, "UPDATE trade_in_estimates SET")[0].args[1]
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
31,
58,
121,
44,
]

View file

@ -0,0 +1,115 @@
"""#2583 H1 — город развёртки обязан попадать в городской фильтр ДКП-коридора.
Словарь региона 66 (24 имени) не знал 23 города, по которым уже идут развёртки
(Реж, Карпинск, Кушва, Лесной, Тавда). `_resolve_target_city` возвращал None,
`_fetch_dkp_corridor` собирал сделки по одной улице во всей области «Ленина» там
в основном екатеринбургская, и коридор Режа получался ценой Екатеринбурга.
Замер прода 2026-09-17 (tradein-postgres, deals source='rosreestr', region 66): по
всем 23 городам сделки есть под теми же е-формами (Реж 745, из них 189 за 12 мес.;
Карпинск 521/161; Лесной 1215/402), а среди сделок Екатеринбурга ни один адрес не
содержит этих имён отдельным словом ложного городского фильтра на ЕКБ-адресах нет.
"""
from __future__ import annotations
import os
from datetime import date
from typing import Any
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
from app.services.estimator import _fetch_dkp_corridor, _resolve_target_city
_REGION_66_SWEEP_CITIES = sorted(
name for slug, name in CITY_DISPLAY_NAMES.items() if slug != "moskva"
)
@pytest.mark.parametrize(
("address", "city"),
[
("Реж, Ленина, 12", "реж"),
("Карпинск, Ленина, 5", "карпинск"),
("Россия, Свердловская область, Артёмовский, улица Ленина, 5", "артемовский"),
("Свердловская обл., Нижнетуринский г.о., Нижняя Тура, ул. Ленина, 3", "нижняя тура"),
],
)
def test_sweep_city_from_the_audit_is_resolved(address: str, city: str) -> None:
assert _resolve_target_city(address) == city
@pytest.mark.parametrize("name", _REGION_66_SWEEP_CITIES)
def test_every_region_66_sweep_city_is_resolved(name: str) -> None:
"""Новый город развёртки не должен снова выпасть в «коридор по всей области»."""
assert _resolve_target_city(f"{name}, ул. Ленина, 12") == name.lower().replace("ё", "е")
def test_moscow_is_not_pulled_into_the_oblast_resolver() -> None:
"""Москва — город развёртки, но другого региона: для неё прежний None."""
assert _resolve_target_city("Москва, ул. Тверская, 1") is None
def test_ekb_district_named_like_a_city_still_not_a_city() -> None:
"""Прежний гард «мкр Заречный» не сломан расширением словаря."""
assert _resolve_target_city("рВерх-Исетский, мкр. Заречный, улица Опалихинская, 20А") is None
# ── коридор: значение, а не параметр ─────────────────────────────────────────
_EKB_PPM2 = 150_000
_REZH_PPM2 = 55_000
def _deals_db() -> Any:
"""Двойник сессии, исполняющий городской фильтр так же, как SQL коридора.
Улица «Ленина» есть и в Екатеринбурге (10 сделок), и в Реже (4 сделки). Без
`LOWER(d.city) = :target_city` запрос отдаёт обе, с ним только свой город.
"""
rows = [{"city": "екатеринбург", "ppm2": _EKB_PPM2}] * 10 + [
{"city": "реж", "ppm2": _REZH_PPM2}
] * 4
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
result = MagicMock()
sql = str(query)
picked: list[dict[str, Any]] = []
if "FROM deals d" in sql:
city = (params or {}).get("target_city")
filtered = "LOWER(d.city) = CAST(:target_city AS text)" in sql
picked = [
{"price_per_m2": r["ppm2"], "deal_date": date(2026, 1, 1)}
for r in rows
if not filtered or r["city"] == city
]
result.mappings.return_value.all.return_value = picked
return result
db = MagicMock()
db.execute.side_effect = _execute
return db
def test_rezh_corridor_is_built_from_rezh_deals_not_the_whole_oblast() -> None:
"""«Реж, Ленина, 12»: медиана коридора — цена Режа, а не Екатеринбурга.
На main город не распознан фильтра нет 10 екатеринбургских сделок из 14
и медиана 150 000 /м².
"""
address = "Реж, Ленина, 12"
raw = _fetch_dkp_corridor(
_deals_db(),
address=address,
rooms=2,
area=45.0,
city=_resolve_target_city(address),
)
assert raw is not None
assert raw["count"] == 4
assert raw["median_ppm2"] == _REZH_PPM2

View file

@ -236,7 +236,8 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None:
# Канонический ключ (мигр. 144), а не точное равенство norm_address. # Канонический ключ (мигр. 144), а не точное равенство norm_address.
assert "tradein_canon_addr(norm_address) AS canon" in flat assert "tradein_canon_addr(norm_address) AS canon" in flat
# Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid. # Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid.
assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat # #2583 H5: ранжирование внутри региона — канон «ленина5» 66-го и 50-го не соревнуются.
assert "PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid" in flat
# houses-сторона: тот же канон поверх COALESCE(short/full/address). # houses-сторона: тот же канон поверх COALESCE(short/full/address).
houses_side = ( houses_side = (
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon" "tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
@ -514,6 +515,7 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
text( text(
"CREATE TEMP TABLE houses (" "CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text," " id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text" " gar_match_method text"
") ON COMMIT DROP" ") ON COMMIT DROP"
@ -524,8 +526,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
# (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже). # (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже).
db.execute( db.execute(
text( text(
"INSERT INTO houses (short_address) VALUES " "INSERT INTO houses (short_address, region_code) VALUES "
"('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')" "('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)"
) )
) )
@ -615,6 +617,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
text( text(
"CREATE TEMP TABLE houses (" "CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text," " id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text" " gar_match_method text"
") ON COMMIT DROP" ") ON COMMIT DROP"
@ -635,7 +638,10 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
) )
) )
db.execute( db.execute(
text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')") text(
"INSERT INTO houses (short_address, region_code) VALUES "
"('ул. Ленина,5', 50), ('ул. Мира,1', 50)"
)
) )
matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None) matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None)
@ -654,3 +660,90 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
finally: finally:
db.rollback() db.rollback()
db.close() db.close()
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
def test_house_gets_gar_guid_only_from_its_own_region() -> None:
"""#2583 H5: UPDATE houses ограничен регионом дома, чужие guid снимаются.
Прод 2026-09-17, после матча регионов 77/50 (#3523): 578 домов региона 66 несли guid
региона 50 и 340 региона 77. Канон не несёт ни город, ни регион, поэтому
екатеринбургская «Ленина, 5» и подмосковная «Ленина, 5» один ключ.
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
raw = db.connection()
raw.exec_driver_sql(_NORMALIZER_FN)
raw.exec_driver_sql(_CANON_FN)
db.execute(
text(
"CREATE TEMP TABLE gar_house_flats ("
" house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL,"
" flat_count int, full_address text, norm_address text, street_name text,"
" house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text"
") ON COMMIT DROP"
)
)
db.execute(
text(
"CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text"
") ON COMMIT DROP"
)
)
db.execute(
text(
"INSERT INTO gar_house_flats "
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
"('g66-lenina', '66', 60, 'обл Свердловская, г Екатеринбург, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('g50-lenina', '50', 400, 'обл Московская, г Химки, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('g50-mira', '50', 250, 'обл Московская, г Химки, ул Мира, 7', 'ул Мира, 7')"
)
)
# ЕКБ «Ленина, 5» и «Мира, 7» уже несут подмосковные guid (как на проде после
# прогона региона 50); подмосковная «Ленина, 5» и дом без региона — не матчены.
db.execute(
text(
"INSERT INTO houses (short_address, region_code, gar_house_guid, gar_flat_count, "
"gar_match_method) VALUES "
"('ул. Ленина,5', 66, 'g50-lenina', 400, 'canon_addr'), "
"('ул. Мира,7', 66, 'g50-mira', 250, 'canon_addr'), "
"('ул. Ленина,5', 50, NULL, NULL, NULL), "
"('ул. Мира,7', NULL, 'g50-mira', 250, 'canon_addr')"
)
)
def _state() -> set[tuple[Any, ...]]:
rows = db.execute(
text(
"SELECT short_address, region_code, gar_house_guid, gar_flat_count FROM houses"
)
).all()
return {tuple(r) for r in rows}
assert gfl.match_houses_to_gar(db, region_code="66") == 1
assert _state() == {
("ул. Ленина,5", 66, "g66-lenina", 60), # свой ЕКБ-дом вместо подмосковного
("ул. Мира,7", 66, None, None), # пары в 66 нет — чужой guid снят, не оставлен
("ул. Ленина,5", 50, None, None), # прогон 66 не трогает дома 50
("ул. Мира,7", None, "g50-mira", 250), # регион дома неизвестен — не доказуемо
}
assert gfl.match_houses_to_gar(db, region_code="50", city_filter=None) == 1
assert _state() == {
("ул. Ленина,5", 66, "g66-lenina", 60), # прогон 50 не перетирает ЕКБ
("ул. Мира,7", 66, None, None),
("ул. Ленина,5", 50, "g50-lenina", 400),
("ул. Мира,7", None, "g50-mira", 250),
}
finally:
db.rollback()
db.close()

View file

@ -261,6 +261,7 @@ _PAID_FIELDS = {
"actual_deals", "actual_deals",
"expected_sold_price_rub", "expected_sold_price_rub",
"est_days_on_market", "est_days_on_market",
"exposure_window",
"price_trend", "price_trend",
} }

View file

@ -34,6 +34,9 @@ Per-provider модель (#1793):
Переменные окружения: Переменные окружения:
BROWSER_PORT TCP-порт HTTP-сервера (default: 3000) BROWSER_PORT TCP-порт HTTP-сервера (default: 3000)
BROWSER_MAX_INSTANCES потолок одновременно живых camoufox на весь сайдкар
(default: 3, #3412). Перед запуском нового инстанса
закрывается простаивающий чужой; занятый никогда.
BROWSER_RECYCLE_PAGES страниц в одном сеансе браузера до перезапуска, глобальный BROWSER_RECYCLE_PAGES страниц в одном сеансе браузера до перезапуска, глобальный
дефолт для провайдеров без код-дефолта (default: 15). дефолт для провайдеров без код-дефолта (default: 15).
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}. per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
@ -141,6 +144,7 @@ Per-provider модель (#1793):
import asyncio import asyncio
import base64 import base64
import gzip import gzip
import itertools
import logging import logging
import os import os
import random import random
@ -164,6 +168,14 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
IS_PROD: bool = ENVIRONMENT == "production" IS_PROD: bool = ENVIRONMENT == "production"
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
# #3412: до потолка инстанс поднимался лениво на поставщика и не закрывался никогда —
# пять поставщиков давали пять вечных camoufox в контейнере с mem_limit 2560m.
# Откуда 3: cadvisor working_set tradein-browser за 41 ч (15.09 16:55 → 17.09 09:53),
# сопоставленный с числом живых инстансов по логу запусков/закрытий: медиана 1311 МиБ
# при 2 живых, 1494 при 3, 1956 при 4, 2108 при 5; 5 живых простояли 2.4 ч прямо перед
# резким рестартом контейнера 16.09 19:08. Потолок не тормозит работу: занятый инстанс
# не закрывается, лишний запуск идёт сверх потолка (см. _trim_instances).
BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "3")))
# Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и # Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и
# живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается — # живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали # внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
@ -849,6 +861,14 @@ _last_nav_ms: dict[str, int | None] = {}
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
# если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое). # если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое).
_launched_proxy: dict[str, str | None] = {} _launched_proxy: dict[str, str | None] = {}
# #3412: поставщики, чей camoufox поднимается прямо сейчас. Считаются живыми для
# потолка: запуск длится секунды, и два параллельных запуска иначе оба увидели бы
# свободное место.
_launching: set[str] = set()
# #3412: provider → номер последнего использования (LRU выбора жертвы). Счётчик, а не
# часы: два обращения в один тик часов неразличимы.
_last_used: dict[str, int] = {}
_use_seq = itertools.count(1)
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из # Guard на ленивое создание per-provider локов: setdefault на обычном dict из
# разных корутин гонок не даёт (нет await между read-modify-write), но держим # разных корутин гонок не даёт (нет await между read-modify-write), но держим
@ -927,12 +947,20 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
proxy is not None, proxy is not None,
_RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK), _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK),
) )
# #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути
# запуска (первый запрос, смена прокси, recycle/краш, фоновый retry).
await _trim_instances(provider)
_launching.add(provider)
try:
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type] cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
browser = await cm.__aenter__() browser = await cm.__aenter__()
finally:
_launching.discard(provider)
_browser_cms[provider] = cm _browser_cms[provider] = cm
_browsers[provider] = browser _browsers[provider] = browser
_page_counters[provider] = 0 _page_counters[provider] = 0
_launched_proxy[provider] = proxy_url _launched_proxy[provider] = proxy_url
_last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU
logger.info( logger.info(
"tradein-browser[%s]: браузер запущен (proxy_override=%s)", "tradein-browser[%s]: браузер запущен (proxy_override=%s)",
provider, provider,
@ -1050,9 +1078,22 @@ async def _close_reusable_context(provider: str) -> None:
async def _close_browser(provider: str) -> None: async def _close_browser(provider: str) -> None:
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).""" """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).
await _close_reusable_context(provider)
cm = _browser_cms.get(provider) Инстанс снимается со словарей ДО первого await (#3412): закрыть его может и чужой
запуск по потолку (_trim_instances), не держащий _locks[provider]. Запрос этого
поставщика, вошедший под лок во время close(), должен увидеть «инстанса нет» и
поднять свой, а не получить полузакрытый браузер.
_last_goto_at НЕ трогает: пейсинг ритм обращений к площадке, он переживает
инстанс. Сбрасывают его те, кто перезапускает инстанс сам (_relaunch_browser,
смена прокси в _ensure_browser), ровно как было до #3412.
"""
cm = _browser_cms.pop(provider, None)
_browsers.pop(provider, None)
_page_counters[provider] = 0
_launched_proxy.pop(provider, None)
await _close_reusable_context(provider) # context/якорь снимает до своего await
if cm is not None: if cm is not None:
try: try:
await cm.__aexit__(None, None, None) # type: ignore[attr-defined] await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
@ -1063,11 +1104,56 @@ async def _close_browser(provider: str) -> None:
provider, provider,
type(exc).__name__, type(exc).__name__,
) )
_browsers.pop(provider, None)
_browser_cms.pop(provider, None)
_page_counters[provider] = 0 def _pick_victim(provider: str) -> str | None:
_last_goto_at.pop(provider, None) """Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412).
_launched_proxy.pop(provider, None)
Кандидаты живые инстансы ЧУЖИХ поставщиков со свободным _locks: вся работа
поставщика идёт под его локом, занятый лок = страница в работе. Первыми идут
инстансы без context'а и якорной вкладки: их перезапуск стоит только процесса.
С context'ом (avito/domclick) теряются прогретые куки площадки и вкладка выдачи —
4-5 с на карточку против 17-52 с без неё (замер 29.08, см. _anchor_pages). Внутри
класса давнее всех использованный. None закрыть некого.
"""
idle = [
p
for p in _browsers
if p != provider and not (p in _locks and _locks[p].locked())
]
if not idle:
return None
return min(idle, key=lambda p: (p in _contexts or p in _anchor_pages, _last_used.get(p, 0)))
async def _trim_instances(provider: str) -> None:
"""Освобождает место под новый инстанс ``provider``, пока живых >= потолка (#3412).
ponytail: потолок мягкий если все прочие инстансы заняты, поднимаемся сверх него
с предупреждением, а не отказываем запросу и не рвём чужую навигацию. Очередь на
слот если предупреждение станет регулярным. Цена мягкости и места проверки: запуск,
который потом упадёт (InvalidIP), успевает закрыть один простаивающий инстанс.
"""
while len(_browsers.keys() | _launching) >= BROWSER_MAX_INSTANCES:
victim = _pick_victim(provider)
if victim is None:
logger.warning(
"tradein-browser[%s]: живых инстансов %d при потолке %d, все заняты — "
"запуск сверх потолка",
provider,
len(_browsers.keys() | _launching),
BROWSER_MAX_INSTANCES,
)
return
logger.info(
"tradein-browser[%s]: живых инстансов %d при потолке %d — закрываем "
"простаивающий %s",
provider,
len(_browsers.keys() | _launching),
BROWSER_MAX_INSTANCES,
victim,
)
await _close_browser(victim)
async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None: async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None:
@ -1083,6 +1169,7 @@ async def _relaunch_browser(provider: str, proxy_override: str | None = None) ->
effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider) effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider)
logger.info("tradein-browser[%s]: перезапуск браузера", provider) logger.info("tradein-browser[%s]: перезапуск браузера", provider)
await _close_browser(provider) await _close_browser(provider)
_last_goto_at.pop(provider, None)
await _launch_browser(provider, proxy_override=effective) await _launch_browser(provider, proxy_override=effective)
@ -1137,6 +1224,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider) desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider)
if _browsers.get(provider) is not None: if _browsers.get(provider) is not None:
if _launched_proxy.get(provider) == desired_proxy: if _launched_proxy.get(provider) == desired_proxy:
_last_used[provider] = next(_use_seq)
return True return True
# Реальная смена прокси → пересобрать инстанс с новым прокси. # Реальная смена прокси → пересобрать инстанс с новым прокси.
logger.info( logger.info(
@ -1145,6 +1233,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
proxy_override is not None, proxy_override is not None,
) )
await _close_browser(provider) await _close_browser(provider)
_last_goto_at.pop(provider, None)
await _try_launch_browser(provider, proxy_override=proxy_override) await _try_launch_browser(provider, proxy_override=proxy_override)
if _browsers.get(provider) is not None: if _browsers.get(provider) is not None:
@ -1211,6 +1300,7 @@ async def _on_startup(app: web.Application) -> None:
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER) logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER) logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES)
_warn_retired_env() _warn_retired_env()

View file

@ -0,0 +1,209 @@
"""test_server_instance_limit.py — потолок живых camoufox и выбор жертвы (#3412).
До #3412 инстанс поставщика поднимался лениво и не закрывался никогда: пять
поставщиков = пять вечных браузеров в контейнере с mem_limit 2560m. Каждый тест
ниже закрывает один дефект ревью прошлой попытки (ветка fix/3412-cian-instance-
relaunch) или сам потолок; проверка по тому, какие инстансы ОСТАЛИСЬ живыми.
camoufox не запускается: модуль camoufox.async_api подменён заглушкой.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_instance_limit.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import sys
import types
from pathlib import Path
import pytest
_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)
class _FakeCM:
"""AsyncCamoufox-заглушка: запуск по желанию медленный, закрытие пишет журнал."""
launch_delay = 0.0
closed: list[str] = []
# provider → был ли он ещё в server._browsers в момент СВОЕГО закрытия
visible_while_closing: dict[str, bool] = {}
def __init__(self, **kwargs: object) -> None:
pass
async def __aenter__(self) -> object:
await asyncio.sleep(_FakeCM.launch_delay)
return object()
async def __aexit__(self, *args: object) -> None:
provider = next(p for p, cm in _cms_by_provider.items() if cm is self)
_FakeCM.visible_while_closing[provider] = provider in server._browsers
await asyncio.sleep(0)
_FakeCM.closed.append(provider)
_cms_by_provider: dict[str, _FakeCM] = {}
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
for name in ("_browsers", "_browser_cms", "_contexts", "_anchor_pages", "_anchor_origins",
"_page_counters", "_locks", "_retry_tasks", "_last_goto_at",
"_launched_proxy", "_last_used"):
monkeypatch.setattr(server, name, {})
monkeypatch.setattr(server, "_launching", set())
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2)
_FakeCM.launch_delay = 0.0
_FakeCM.closed = []
_FakeCM.visible_while_closing = {}
_cms_by_provider.clear()
fake_mod = types.ModuleType("camoufox.async_api")
fake_mod.AsyncCamoufox = _FakeCM # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
async def _launch(provider: str) -> None:
await server._launch_browser(provider, proxy_override="http://u:p@pool:8080")
_cms_by_provider[provider] = server._browser_cms[provider]
def _live() -> set[str]:
return set(server._browsers)
def test_launch_at_cap_closes_idle_instance() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _live() == {"yandex", "generic"}
assert _FakeCM.closed == ["cian"]
def test_victim_is_cheap_instance_not_avito_with_anchor() -> None:
"""Дефект ревью 1: LRU закрывал avito с якорной вкладкой, оставляя cian без context'а."""
async def _run() -> None:
await _launch("avito") # использован ДАВНЕЕ всех, но с прогретым context'ом
server._contexts["avito"] = types.SimpleNamespace(close=_noop)
server._anchor_pages["avito"] = object()
await _launch("cian")
await _launch("yandex")
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
assert _FakeCM.closed == ["cian"]
async def _noop() -> None:
return None
def test_eviction_keeps_victim_pacing() -> None:
"""Дефект ревью 2: вытеснение сбрасывало _last_goto_at чужого поставщика."""
async def _run() -> None:
await _launch("cian")
server._last_goto_at["cian"] = 12345.0
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert "cian" not in _live()
assert server._last_goto_at.get("cian") == 12345.0
def test_background_retry_path_respects_cap() -> None:
"""Дефект ревью 3: retry-путь _try_launch_browser поднимал инстанс мимо потолка."""
async def _run() -> bool:
await _launch("cian")
await _launch("yandex")
return await server._try_launch_browser("avito")
assert asyncio.run(_run()) is True
assert len(_live()) == 2
assert "avito" in _live()
def test_fresh_instance_is_not_the_next_victim() -> None:
"""Дефект ревью 4: свежий инстанс без отметки использования уходил первым."""
async def _run() -> None:
await _launch("cian")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("yandex") # свежий, после последнего обращения к cian
await _launch("generic") # должен закрыть давний cian, а не свежий yandex
asyncio.run(_run())
assert _FakeCM.closed == ["cian"]
def test_use_refreshes_lru_order() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == ["yandex"]
def test_busy_instance_is_never_closed() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
for provider in ("cian", "yandex"):
await (await server._lock_for(provider)).acquire()
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == []
assert _live() == {"cian", "yandex", "generic"}
def test_parallel_launches_reserve_their_slot() -> None:
"""Два запуска разных поставщиков одновременно не должны оба увидеть свободное место."""
async def _run() -> None:
await _launch("cian")
_FakeCM.launch_delay = 0.01
await asyncio.gather(_launch("avito"), _launch("yandex"))
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
def test_evicted_instance_disappears_before_close_awaits() -> None:
"""Жертву закрывает чужая корутина без её лока: во время close() браузер уже снят."""
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.visible_while_closing == {"cian": False}

View file

@ -82,6 +82,12 @@ services:
# (грубо ~2.5g на каждую доп. параллельную страницу). # (грубо ~2.5g на каждую доп. параллельную страницу).
mem_limit: 2560m mem_limit: 2560m
memswap_limit: 3g memswap_limit: 3g
# init (#3412): tini как PID 1. Без него PID 1 — `python server.py`, который не
# собирает осиротевших детей: дочерние процессы закрытого camoufox (forkserver,
# Utility, Socket, RDD) переходят к PID 1 и висят зомби навсегда. Прод 17.09.2026
# за 14 ч с рестарта: 2296 процессов в состоянии Z (по 574 каждого вида),
# pids.current 2722 при трёх живых браузерах.
init: true
# stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое # stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое
# само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний # само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний
# shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие # shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие

View file

@ -178,10 +178,14 @@ LIMIT 20;
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
закрыта сужением по городу, поведение byte-for-byte прежнее. закрыта сужением по городу, поведение byte-for-byte прежнее.
- **Регион дома (#2583 H5).** Дом получает guid только ГАР-строки своего региона
(`houses.region_code`), канон ранжируется внутри региона. Перед матчем снимаются уже
проставленные `canon_addr`-guid чужого региона (дом без `region_code` не трогаем) — лог
пишет «снято guid чужого региона=N». После выкатки прогнать `--match-only` для 66, 77 и 50.
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon - **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве — однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon
BY flat_count DESC, house_guid)`. ORDER BY flat_count DESC, house_guid)`.
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`). - **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера. `gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.

View file

@ -74,7 +74,7 @@ const TARIFFS: readonly {
}, },
{ {
name: "Полный отчёт", name: "Полный отчёт",
note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж", note: "Цена, диапазон, срок экспозиции похожих объявлений, аналоги, факторы. Разовый платёж",
price: `${SERVICE_PRICE_RUB}`, price: `${SERVICE_PRICE_RUB}`,
}, },
{ {

View file

@ -139,6 +139,7 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
sources_used: ["avito", "cian", "rosreestr"], sources_used: ["avito", "cian", "rosreestr"],
data_freshness_minutes: 35, data_freshness_minutes: 35,
last_scraped_at: "2026-05-30T12:00:00Z", last_scraped_at: "2026-05-30T12:00:00Z",
exposure_window: { p25_days: 31, p50_days: 48, p75_days: 104, n: 57 },
est_days_on_market: 48, est_days_on_market: 48,
address_precision: "house", address_precision: "house",
area_m2: 55.3, area_m2: 55.3,

View file

@ -246,7 +246,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const houseType = estimate.house_type ?? input.house_type; const houseType = estimate.house_type ?? input.house_type;
const repairState = estimate.repair_state ?? input.repair_state; const repairState = estimate.repair_state ?? input.repair_state;
const hasBalcony = estimate.has_balcony ?? input.has_balcony; const hasBalcony = estimate.has_balcony ?? input.has_balcony;
const estDaysOnMarket = estimate.est_days_on_market; // #2898: окно p25p75, а не одно число. est_days_on_market не показываем:
// у старых оценок это возраст висящих объявлений, а не срок до снятия.
const exposure = estimate.exposure_window ?? null;
function handleEnrichSubmit(e: React.FormEvent) { function handleEnrichSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@ -403,9 +405,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
<span className="v mono">{estimate.n_analogs}</span> <span className="v mono">{estimate.n_analogs}</span>
</div> </div>
<div className="meta-row"> <div className="meta-row">
<span className="k">Срок продажи</span> <span className="k">До снятия объявления</span>
{estDaysOnMarket !== null ? ( {exposure ? (
<span className="v mono">{estDaysOnMarket} дн.</span> <span
className="v mono"
title={`Средняя половина из ${exposure.n} похожих объявлений рядом висела столько дней до снятия. Снятие объявления не значит продажу.`}
>
{exposure.p25_days}{exposure.p75_days} дн.
</span>
) : ( ) : (
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span> <span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
)} )}

View file

@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
<HouseAnalyticsKpiRow kpi={data.kpi} /> <HouseAnalyticsKpiRow kpi={data.kpi} />
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />} {sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
{data.price_history.length >= 2 && ( {data.price_history.length >= 2 && (
<PriceHistoryChart points={data.price_history} /> <PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
)} )}
{data.recent_sold.length > 0 && ( {data.recent_sold.length > 0 && (
<RecentSoldList items={data.recent_sold} /> <RecentSoldList items={data.recent_sold} />

View file

@ -13,7 +13,9 @@ import {
import type { PriceHistoryYearPoint } from "@/types/trade-in"; import type { PriceHistoryYearPoint } from "@/types/trade-in";
import { sourcePublicLabel } from "@/lib/source-registry"; import { sourcePublicLabel } from "@/lib/source-registry";
type Props = { points: PriceHistoryYearPoint[] }; // radiusM — radius_m из /house-analytics: 0 = выборка ровно по дому, >0 = дом и соседние
// в этом радиусе (M2 audit #2583).
type Props = { points: PriceHistoryYearPoint[]; radiusM: number };
const AVITO_LABEL = sourcePublicLabel("avito_imv"); const AVITO_LABEL = sourcePublicLabel("avito_imv");
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation"); const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
@ -28,7 +30,7 @@ interface PivotRow {
n_yandex?: number; n_yandex?: number;
} }
export function PriceHistoryChart({ points }: Props) { export function PriceHistoryChart({ points, radiusM }: Props) {
const pivoted = useMemo<PivotRow[]>(() => { const pivoted = useMemo<PivotRow[]>(() => {
const byYear: Record<number, PivotRow> = {}; const byYear: Record<number, PivotRow> = {};
for (const p of points) { for (const p of points) {
@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {
<article className="card" style={{ marginTop: 12, padding: 16 }}> <article className="card" style={{ marginTop: 12, padding: 16 }}>
<header style={{ marginBottom: 8 }}> <header style={{ marginBottom: 8 }}>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}> <h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
История цен в этом доме {radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
</h4> </h4>
<small style={{ color: "var(--muted, #6b7280)" }}> <small style={{ color: "var(--muted, #6b7280)" }}>
Медиана /м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов) Медиана /м² по годам ·{" "}
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
</small> </small>
</header> </header>
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */} {/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}

View file

@ -0,0 +1,46 @@
/**
* #2898 в hero окно срока до снятия объявления (p25p75), а не одно число.
* Старое est_days_on_market без окна не показываем: у оценок до #2898 это возраст
* висящих объявлений, и «48 дн.» читалось бы как срок продажи.
*/
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { HeroSummary } from "../HeroSummary";
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture";
import type { AggregatedEstimate } from "@/types/trade-in";
vi.mock("@/lib/useBrand", () => ({
useActiveBrandSlug: () => null,
useBrand: () => ({ data: null }),
}));
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
function heroText(estimate: AggregatedEstimate): string {
const { container } = render(
<HeroSummary estimate={estimate} input={FIXTURE_INPUT} onResubmit={() => {}} />,
);
return container.textContent ?? "";
}
describe("#2898 окно срока в v1 HeroSummary", () => {
it("показывает края окна", () => {
const text = heroText({
...FIXTURE_ESTIMATE,
exposure_window: { p25_days: 29, p50_days: 61, p75_days: 146, n: 44 },
est_days_on_market: 61,
});
expect(text).toContain("До снятия объявления29146 дн.");
expect(text).not.toContain("Срок продажи");
});
it("без окна — «нет данных», старое число не всплывает", () => {
const text = heroText({
...FIXTURE_ESTIMATE,
exposure_window: null,
est_days_on_market: 48,
});
expect(text).toContain("До снятия объявлениянет данных");
expect(text).not.toContain("48 дн.");
});
});

View file

@ -357,7 +357,7 @@ export default function AnalyticsView({
> >
<div> <div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}> <div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме {data.priceHistory.title}
</div> </div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}> <div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{historyNote} {historyNote}
@ -401,9 +401,9 @@ export default function AnalyticsView({
style={{ width: "100%", height: 260, marginTop: 12 }} style={{ width: "100%", height: 260, marginTop: 12 }}
preserveAspectRatio="none" preserveAspectRatio="none"
role="img" role="img"
aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`} aria-label={`${data.priceHistory.title}: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
> >
<title>История цен в этом доме медиана /м² по годам</title> <title>{`${data.priceHistory.title} — медиана ₽/м² по годам`}</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}> <g stroke={tokens.lineSoft2} strokeWidth={1}>
{PH_GRID_Y.map((y) => ( {PH_GRID_Y.map((y) => (
<line key={y} x1={40} y1={y} x2={900} y2={y} /> <line key={y} x1={40} y1={y} x2={900} y2={y} />

View file

@ -0,0 +1,75 @@
// M2 audit #2583 — подпись «История цен в этом доме · только этот дом» была
// безусловной, хотя /house-analytics при <8 архивных лотах расширяет выборку до
// соседних домов в радиусе 300 м (radius_m в ответе). На том же экране рядом
// стояло «в радиусе 300 м» — две взаимоисключающие подписи.
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PriceHistoryChart } from "../../PriceHistoryChart";
import AnalyticsView from "../AnalyticsView";
import { mapAnalytics } from "../mappers";
import type {
HouseAnalyticsResponse,
PriceHistoryYearPoint,
} from "@/types/trade-in";
const points: PriceHistoryYearPoint[] = [
{ year: 2024, source: "avito_imv", median_price_per_m2: 120_000, n_lots: 3, median_price_rub: 6_000_000 },
{ year: 2025, source: "avito_imv", median_price_per_m2: 130_000, n_lots: 4, median_price_rub: 6_500_000 },
];
function analytics(radius_m: number): HouseAnalyticsResponse {
return {
house_ids: [1, 2, 3],
radius_m,
price_history: points,
recent_sold: [],
kpi: {
total_lots: 7,
sold_count: 0,
sold_rate_pct: 0,
median_exposure_days: null,
median_bargain_pct: null,
},
};
}
describe("v2 история цен: подпись следует radius_m", () => {
it("выборка расширена соседями — не «только этот дом»", () => {
const { priceHistory } = mapAnalytics(analytics(300), null, null);
expect(priceHistory.title).toBe("История цен: дом и соседние");
expect(priceHistory.note).toContain("дом и соседние в радиусе 300 м");
expect(priceHistory.note).not.toContain("только этот дом");
});
it("экран v2 печатает подпись из данных, а не константу", () => {
render(<AnalyticsView data={mapAnalytics(analytics(300), null, null)} />);
expect(screen.getByText("История цен: дом и соседние")).toBeTruthy();
expect(screen.queryByText(/в этом доме/)).toBeNull();
});
it("выборка ровно по дому — прежняя подпись", () => {
const { priceHistory } = mapAnalytics(analytics(0), null, null);
expect(priceHistory.title).toBe("История цен в этом доме");
expect(priceHistory.note).toContain("только этот дом");
});
});
describe("старый экран: PriceHistoryChart", () => {
it("radiusM>0 — заголовок и подпись про соседей", () => {
// recharts ResponsiveContainer требует ResizeObserver, которого нет в jsdom.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
render(<PriceHistoryChart points={points} radiusM={300} />);
expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние");
expect(screen.queryByText(/только этот дом/)).toBeNull();
expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy();
});
});

View file

@ -1820,7 +1820,16 @@ export function phYearX(i: number, n: number): number {
} }
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
// M2 audit #2583: при <8 архивных лотах /house-analytics подмешивает до 30 соседних
// домов в радиусе radius_m — «только этот дом» тогда неправда (рядом на экране
// стоит «в радиусе 300 м»). radius_m=0 — выборка ровно по дому.
const radiusM = a?.radius_m ?? 0;
const title =
radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме";
const scope =
radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом";
const empty: PriceHistory = { const empty: PriceHistory = {
title,
note: "Нет данных по истории цен", note: "Нет данных по истории цен",
years: [], years: [],
avito: "", avito: "",
@ -1879,7 +1888,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
.join(" "); .join(" ");
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0); const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu( const note = `Медиана ₽/м² по годам · ${scope} · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
totalLots, totalLots,
["лот", "лота", "лотов"], ["лот", "лота", "лотов"],
)})`; )})`;
@ -1890,6 +1899,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
}); });
return { return {
title,
note, note,
years: yearsNum.map(String), years: yearsNum.map(String),
avito: series("avito_imv"), avito: series("avito_imv"),

View file

@ -264,6 +264,8 @@ export interface SellTimeTier {
} }
export interface PriceHistory { export interface PriceHistory {
/** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */
title: string;
note: string; note: string;
years: string[]; years: string[];
/** SVG polyline points string (viewBox 0 0 900 220). */ /** SVG polyline points string (viewBox 0 0 900 220). */

View file

@ -128,6 +128,15 @@ export interface PriceTrendPoint {
ppm2: number; // ₽/м² ppm2: number; // ₽/м²
} }
// #2898: сколько дней похожие квартиры рядом висели до СНЯТИЯ объявления
// (не подтверждённой продажи) — квартили house_placement_history.exposure_days.
export interface ExposureWindow {
p25_days: number;
p50_days: number;
p75_days: number;
n: number; // снятых объявлений в выборке
}
export interface CianValuationSummary { export interface CianValuationSummary {
sale_price_rub: number | null; sale_price_rub: number | null;
rent_price_rub: number | null; rent_price_rub: number | null;
@ -234,7 +243,10 @@ export interface AggregatedEstimate {
created_at?: string | null; // ISO datetime — «отчёт от DD.MM» created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
data_freshness_minutes: number | null; // «обновлено N минут назад» data_freshness_minutes: number | null; // «обновлено N минут назад»
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
est_days_on_market: number | null; // прогноз срока продажи // #2898: окно p25p75; null — выборка < 30 или оценка старее бэкенда.
exposure_window?: ExposureWindow | null;
// Устарело: = exposure_window.p50_days. Показывать окно, не это число.
est_days_on_market: number | null;
// address_precision — точность гео-привязки адреса (из DaData qc_geo): // address_precision — точность гео-привязки адреса (из DaData qc_geo):
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы), // «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан). // «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).