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
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:
commit
8bd16e6ad0
55 changed files with 4298 additions and 482 deletions
|
|
@ -260,10 +260,12 @@ jobs:
|
|||
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
||||
# не бежали ни разу с момента написания.
|
||||
#
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
|
||||
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
|
||||
# нужен, в отличие от tradein-лэйна.
|
||||
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
|
||||
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
|
||||
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
|
||||
# ST_DWithin по geography, без расширения его не исполнить. На plain
|
||||
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
|
||||
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
|
||||
#
|
||||
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
||||
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
||||
|
|
@ -294,15 +296,14 @@ jobs:
|
|||
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
||||
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
||||
#
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
|
||||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
|
||||
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||
postgres:16
|
||||
postgis/postgis:16-3.4
|
||||
|
||||
ready=""
|
||||
for _ in $(seq 1 45); do
|
||||
|
|
|
|||
|
|
@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
|
|||
"""
|
||||
|
||||
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 в радиусе (до группировки)
|
||||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||||
|
|
|
|||
|
|
@ -587,19 +587,6 @@ class NSPDBulkClient:
|
|||
)
|
||||
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 ───────────────────────────────────────────
|
||||
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
|
||||
# Готов для per-building помещения/парковка фазы.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ Resumable: phase_state в cadastre_jobs показывает прогресс.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
|
@ -260,10 +259,8 @@ async def harvest_quarter(
|
|||
update_progress(done_progress)
|
||||
|
||||
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
||||
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
|
||||
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
|
||||
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
|
||||
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
|
||||
# Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None →
|
||||
# grid-walk следующего прогона молча пропускается. Чиним geom из snapshot.
|
||||
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||
if stats_features:
|
||||
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)
|
||||
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 по всем
|
||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
||||
|
|
@ -679,7 +657,7 @@ def upsert_features(
|
|||
cat = feature.category_id
|
||||
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
|
||||
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
|
||||
# квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234):
|
||||
# квартала. Зеркалит _grid_walk_category:
|
||||
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
|
||||
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
|
||||
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
|
||||
|
||||
|
||||
# ── Утилиты ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1109,9 +1109,6 @@ def get_best_layouts(
|
|||
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]
|
||||
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_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)]
|
||||
|
||||
# Группируем отфильтрованные 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)
|
||||
|
||||
if not all_obj_ids:
|
||||
return _empty_response(
|
||||
radius_km=request.radius_km,
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -344,21 +344,26 @@ _COMPETITORS_SQL = text("""
|
|||
-- важна — velocity питает forecast). Спатиальный join дёшев, т.к.
|
||||
-- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj
|
||||
-- пре-фильтрован (радиус участка применяется ниже, в финальном 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)
|
||||
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
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude 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(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.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(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
|
||||
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only
|
||||
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
||||
|
|
@ -376,7 +395,13 @@ _COMPETITORS_SQL = text("""
|
|||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.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 (
|
||||
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
||||
|
|
@ -384,15 +409,18 @@ _COMPETITORS_SQL = text("""
|
|||
cm.objective_complex_name
|
||||
FROM objective_complex_mapping cm
|
||||
UNION
|
||||
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а
|
||||
-- (у комплекса может быть несколько корпус-project_name → velocity
|
||||
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
|
||||
-- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
|
||||
-- (корпуса у Объектива — corpus_name внутри проекта, не отдельные имена).
|
||||
SELECT nc.obj_id,
|
||||
ol.project_name AS objective_complex_name
|
||||
nc.project_name AS objective_complex_name
|
||||
FROM nearest_cx nc
|
||||
JOIN objective_lots ol
|
||||
ON ol.complex_id = nc.complex_id
|
||||
AND ol.project_name IS NOT NULL
|
||||
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
|
||||
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
|
||||
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
|
||||
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
|
||||
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
|
||||
-- гео-фильтра — 3 с против 0.4 с.
|
||||
WHERE nc.name_ok
|
||||
),
|
||||
velocity AS (
|
||||
SELECT
|
||||
|
|
@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text("""
|
|||
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
||||
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
||||
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
|
||||
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
|
||||
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
|
||||
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
|
||||
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
|
||||
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
|
||||
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
|
||||
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
||||
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||
|
|
@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
nearest_cx AS (
|
||||
SELECT DISTINCT ON (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
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude 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(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.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(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)
|
||||
AND o.latitude IS NOT NULL
|
||||
AND o.longitude IS NOT NULL
|
||||
|
|
@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.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 (
|
||||
SELECT
|
||||
|
|
@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
ol.price_per_m2_rub AS price_per_m2_rub
|
||||
FROM nearest_cx nc
|
||||
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
|
||||
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
|
||||
WHERE nc.name_ok
|
||||
)
|
||||
SELECT
|
||||
p.obj_id,
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
|
|||
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
|
||||
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
|
||||
# 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 логировал
|
||||
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -264,8 +264,10 @@ def test_exclude_competitor_obj_ids_filter() -> None:
|
|||
# После исключения obj_id=20 список пуст → пустой ответ
|
||||
assert body["top_layouts"] == []
|
||||
assert body["data_quality"]["confidence"] == "low"
|
||||
# objects_total_in_radius = 1 (до исключения)
|
||||
assert body["data_quality"]["objects_total_in_radius"] == 1
|
||||
# objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464);
|
||||
# число obj_id в радиусе до исключения — raw_objects_total
|
||||
assert body["data_quality"]["objects_total_in_radius"] == 0
|
||||
assert body["data_quality"]["raw_objects_total"] == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -433,7 +433,43 @@ def test_exclude_competitor_obj_ids() -> None:
|
|||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
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 ──────────────────────
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non
|
|||
db = MagicMock()
|
||||
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk + territorial_zones фазы корректно пропускаются (тест про
|
||||
# grid-walk фаза корректно пропускается (тест про
|
||||
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
|
||||
# db.execute().mappings().first() в этом тесте.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
|
|||
db = MagicMock()
|
||||
# phase_state = None → начинаем с нуля
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -1518,7 +1518,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
|
|||
db = _mock_db_grid_bbox()
|
||||
client = AsyncMock()
|
||||
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
||||
client.get_territorial_zones_in_bbox = AsyncMock(return_value=[])
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@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 ─────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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_total_lots_preserved
|
||||
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) ──────────────────────────────────
|
||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
||||
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from app.schemas.trade_in import (
|
|||
CoverageProbeInput,
|
||||
CoverageProbeResponse,
|
||||
DkpCorridor,
|
||||
ExposureWindow,
|
||||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
HouseInfoForEstimate,
|
||||
|
|
@ -390,6 +391,7 @@ async def _try_revive_dead_estimate(
|
|||
),
|
||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||
)
|
||||
w = result.exposure_window # #2898: окно целиком или NULL целиком
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -418,6 +420,10 @@ async def _try_revive_dead_estimate(
|
|||
ratio_basis = :ratio_basis,
|
||||
relaxations = CAST(:relaxations_json AS jsonb),
|
||||
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()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -452,6 +458,10 @@ async def _try_revive_dead_estimate(
|
|||
"ratio_basis": result.ratio_basis,
|
||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||
"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(
|
||||
|
|
@ -678,7 +688,8 @@ def load_estimate(
|
|||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
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
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND {ESTIMATE_READABLE_SQL}
|
||||
|
|
@ -843,6 +854,19 @@ def load_estimate(
|
|||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||
# ответ AttributeError'ом.
|
||||
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,
|
||||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
|
|
|
|||
|
|
@ -230,6 +230,21 @@ class PriceTrendPoint(BaseModel):
|
|||
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):
|
||||
estimate_id: UUID
|
||||
median_price_rub: int
|
||||
|
|
@ -292,7 +307,11 @@ class AggregatedEstimate(BaseModel):
|
|||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||
# абсолютный timestamp самого свежего парсинга аналогов
|
||||
last_scraped_at: datetime | None = None
|
||||
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
|
||||
# #2898: окно срока экспозиции (p25–p75) — показывать его, а не одно число.
|
||||
exposure_window: ExposureWindow | None = None
|
||||
# Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше —
|
||||
# медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка).
|
||||
est_days_on_market: int | None = None
|
||||
cian_valuation: CianValuationSummary | None = None
|
||||
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
|
||||
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from typing import Any, Literal
|
|||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
|
||||
from scraper_kit.providers.avito.imv import (
|
||||
IMVAddressNotFoundError,
|
||||
IMVAuthError,
|
||||
|
|
@ -63,6 +64,7 @@ from app.schemas.trade_in import (
|
|||
AvitoImvSummary,
|
||||
CianValuationSummary,
|
||||
DkpCorridor,
|
||||
ExposureWindow,
|
||||
PriceTrendPoint,
|
||||
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))
|
||||
|
||||
|
||||
# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов
|
||||
# региона 66) — resolve target-города для city-scoping ДКП-коридора (см.
|
||||
# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc),
|
||||
# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно
|
||||
# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но
|
||||
# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE).
|
||||
# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут
|
||||
# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с
|
||||
# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей
|
||||
# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в
|
||||
# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities
|
||||
# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_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(
|
||||
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"
|
||||
)
|
||||
|
||||
|
|
@ -2131,8 +2147,8 @@ _TARGET_CITY_RE = re.compile(
|
|||
def _resolve_target_city(address_text: str | None) -> str | None:
|
||||
"""Извлекает город региона 66 из геокодированного адреса (best-effort).
|
||||
|
||||
Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по
|
||||
границе слова/фразы — работает и для forward-формата («Екатеринбург, ул.
|
||||
Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города
|
||||
развёрток) по границе слова/фразы — работает и для forward-формата («Екатеринбург, ул.
|
||||
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
|
||||
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
|
||||
важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key).
|
||||
|
|
@ -5502,6 +5518,19 @@ async def estimate_quality(
|
|||
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
|
||||
source_counts = _source_counts([lot.source for lot in analogs_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 = None все колонки идут в DB как NULL (graceful).
|
||||
dadata_metro_json = (
|
||||
|
|
@ -5531,6 +5560,7 @@ async def estimate_quality(
|
|||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis,
|
||||
relaxations, reliability,
|
||||
est_days_p25, est_days_p50, est_days_p75, est_days_n,
|
||||
created_by,
|
||||
expires_at,
|
||||
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,
|
||||
:asking_to_sold_ratio, :ratio_basis,
|
||||
CAST(:relaxations_json AS jsonb), :reliability,
|
||||
:est_days_p25, :est_days_p50, :est_days_p75, :est_days_n,
|
||||
:created_by,
|
||||
:expires_at,
|
||||
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
||||
|
|
@ -5614,6 +5645,11 @@ async def estimate_quality(
|
|||
# пропадал при открытии оценки по сохранённой ссылке (?id=).
|
||||
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
|
||||
"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,
|
||||
"expires_at": expires_at,
|
||||
**_estimate_consent_persist_fields(require_consent, client_ip),
|
||||
|
|
@ -5784,7 +5820,8 @@ async def estimate_quality(
|
|||
data_freshness_minutes=freshness_min,
|
||||
last_scraped_at=last_scraped_at,
|
||||
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=(
|
||||
CianValuationSummary(
|
||||
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)
|
||||
|
||||
|
||||
def _estimate_days_on_market(
|
||||
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
|
||||
) -> int | None:
|
||||
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
|
||||
# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был
|
||||
# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок
|
||||
# до снятия (цензурированная выборка; deals.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 = [
|
||||
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:
|
||||
if len(days) < EXPOSURE_WINDOW_MIN_N:
|
||||
return None
|
||||
values.sort()
|
||||
return values[len(values) // 2]
|
||||
p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive")
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
|
|||
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
|
||||
# пропущенный дом лучше неверно приписанного). Предикат `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(
|
||||
"""
|
||||
WITH gar_scope AS (
|
||||
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
|
||||
WHERE flat_count > 0
|
||||
AND tradein_canon_addr(norm_address) <> ''
|
||||
|
|
@ -622,15 +628,15 @@ _MATCH_SQL = text(
|
|||
),
|
||||
gar_ranked AS (
|
||||
SELECT
|
||||
canon, house_guid, flat_count,
|
||||
canon, house_guid, flat_count, region_code,
|
||||
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,
|
||||
COUNT(*) OVER (PARTITION BY canon) AS canon_hits
|
||||
COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
|
||||
FROM gar_scope
|
||||
),
|
||||
gar_pick AS (
|
||||
SELECT canon, house_guid, flat_count
|
||||
SELECT canon, house_guid, flat_count, region_code
|
||||
FROM gar_ranked
|
||||
WHERE rn = 1
|
||||
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
|
||||
|
|
@ -644,10 +650,33 @@ _MATCH_SQL = text(
|
|||
WHERE tradein_canon_addr(
|
||||
COALESCE(h.short_address, h.full_address, h.address)
|
||||
) = gp.canon
|
||||
AND CAST(h.region_code AS text) = gp.region_code
|
||||
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(
|
||||
db: Session,
|
||||
|
|
@ -666,11 +695,14 @@ def match_houses_to_gar(
|
|||
"""
|
||||
if isinstance(city_filter, CityFilterAutoType):
|
||||
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})
|
||||
matched = result.rowcount
|
||||
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,
|
||||
cleared,
|
||||
region_code,
|
||||
city_filter,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -204,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
|
|||
Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
|
||||
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
|
||||
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
|
||||
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не
|
||||
резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и
|
||||
гейт хинта не создаёт новой связности.
|
||||
(`_names_non_ekb_city` его не увидит) — т.е. новый город области в любом
|
||||
случае добавляется СЮДА, и гейт хинта не создаёт новой связности.
|
||||
(`estimator._resolve_target_city` дополнительно знает города развёрток,
|
||||
#2583 H1 — ему город нужен для фильтра сделок, а не для гейта.)
|
||||
|
||||
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
||||
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""Daily per-source snapshot writer (#570).
|
||||
|
||||
Берёт текущее состояние 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 цены копится СРАЗУ, независимо от
|
||||
(сейчас 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))
|
||||
|
||||
|
||||
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
|
||||
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
||||
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
|
||||
# ── Snapshot upsert: строка только на изменение (#2993) ───────────────────────
|
||||
# Снимок на (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 в пределах окна свежести на момент снимка.
|
||||
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
|
||||
# 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
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
cur.id,
|
||||
CURRENT_DATE,
|
||||
price_rub,
|
||||
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
|
||||
last_seen_at,
|
||||
md5(raw_payload::text),
|
||||
cur.price_rub,
|
||||
cur.is_active,
|
||||
cur.last_seen_at,
|
||||
cur.payload_hash,
|
||||
now(),
|
||||
CAST(:run_id AS bigint)
|
||||
FROM listing_sources
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
price_rub,
|
||||
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
|
||||
last_seen_at,
|
||||
md5(raw_payload::text) AS payload_hash
|
||||
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
|
||||
price_rub = EXCLUDED.price_rub,
|
||||
is_active = EXCLUDED.is_active,
|
||||
|
|
@ -108,6 +146,9 @@ _SNAPSHOT_SQL = text(
|
|||
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
||||
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
||||
# С #2993 здесь только изменившиеся и новые источники: у остальных все четыре
|
||||
# значения равны последнему снимку, а значит ни одно событие ниже сработать
|
||||
# не могло бы — набор событий тот же, что при суточной копии.
|
||||
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
||||
#
|
||||
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
||||
|
|
@ -249,7 +290,8 @@ def snapshot_listing_sources(
|
|||
|
||||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
||||
Два 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 сегодняшнего снимка против последнего предыдущего → три события,
|
||||
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
|
||||
они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ non-200 ответ считается блоком, а его диагноз б
|
|||
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
|
||||
incomplete ⊆ failed — обогащения нет, значит следующий снапшот
|
||||
(detail_enriched_at IS NULL) возьмёт её снова.
|
||||
|
||||
Вечный недогруз (#3191, 13.09–16.09): карточка, которая не догружается НИКОГДА, без
|
||||
счётчика попыток возвращалась в голову каждого снапшота. Пять таких объявлений подряд
|
||||
выбивали брейкер, и 16 прогонов закончились ровно attempted=5 incomplete=5. Теперь
|
||||
недогруз пишется на объявление (миграция 321): сутки его не берём, после
|
||||
INCOMPLETE_MAX_ATTEMPTS не берём совсем, а повторный недогруз уже известной карточки
|
||||
не двигает брейкер — это сведения о карточке, а не о площадке.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -137,6 +144,13 @@ OFFER_URL_PATTERN = "/offer/[0-9]+"
|
|||
OFFER_ID_PATTERN = "^[0-9]+$"
|
||||
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
|
||||
class YandexDetailBackfillResult:
|
||||
|
|
@ -161,6 +175,8 @@ class YandexDetailBackfillResult:
|
|||
url_from_offer_id: int = 0
|
||||
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
|
||||
unenrichable_pending: int = 0
|
||||
# Ждут обогащения, но вышли из очереди после INCOMPLETE_MAX_ATTEMPTS недогрузов.
|
||||
incomplete_given_up: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
|
|
@ -172,6 +188,7 @@ class YandexDetailBackfillResult:
|
|||
"failed": self.failed,
|
||||
"url_from_offer_id": self.url_from_offer_id,
|
||||
"unenrichable_pending": self.unenrichable_pending,
|
||||
"incomplete_given_up": self.incomplete_given_up,
|
||||
"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)
|
||||
THEN source_url
|
||||
ELSE {CANONICAL_URL_SQL}
|
||||
END AS source_url
|
||||
END AS source_url,
|
||||
detail_incomplete_count
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
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)
|
||||
)
|
||||
-- #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
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
"""
|
||||
|
|
@ -249,6 +276,8 @@ async def run_yandex_detail_backfill(
|
|||
"batch_size": batch_size,
|
||||
"offer_url_pattern": OFFER_URL_PATTERN,
|
||||
"offer_id_pattern": OFFER_ID_PATTERN,
|
||||
"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS,
|
||||
"incomplete_retry_hours": INCOMPLETE_RETRY_AFTER_HOURS,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -283,6 +312,19 @@ async def run_yandex_detail_backfill(
|
|||
).one()
|
||||
counters.url_from_offer_id = int(pending.url_from_offer_id)
|
||||
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:
|
||||
logger.info(
|
||||
"yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
|
||||
|
|
@ -443,26 +485,59 @@ async def run_yandex_detail_backfill(
|
|||
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
|
||||
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
|
||||
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
|
||||
# IS NULL). Серия таких страниц двигает consecutive_none — тот же
|
||||
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся
|
||||
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
|
||||
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
|
||||
# IS NULL) — но не раньше чем через сутки: недогруз пишется на
|
||||
# объявление, и после INCOMPLETE_MAX_ATTEMPTS снапшот его не берёт.
|
||||
incomplete_reason = detail_incomplete_reason(
|
||||
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
|
||||
)
|
||||
if incomplete_reason is not None:
|
||||
counters.incomplete += 1
|
||||
counters.failed += 1
|
||||
consecutive_none += 1
|
||||
# Брейкер ловит СИСТЕМНЫЙ недогруз (площадка отдаёт оболочки
|
||||
# всем). Повторный недогруз карточки, уже недогружавшейся
|
||||
# раньше, — сведения о ней, а не о площадке: он брейкер не
|
||||
# двигает, иначе вечные карточки, стоящие в очереди подряд,
|
||||
# рвали бы прогон при каждом повторе (#3191, 16 прогонов
|
||||
# attempted=5 incomplete=5). Первый недогруз двигает, как раньше.
|
||||
prior_incomplete = int(row.get("detail_incomplete_count") or 0)
|
||||
if prior_incomplete == 0:
|
||||
consecutive_none += 1
|
||||
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
|
||||
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(
|
||||
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||||
"-> недогруженная карточка, отказ: %s (consecutive=%d)",
|
||||
"-> недогруженная карточка, отказ: %s (попытка %d/%d, "
|
||||
"consecutive=%d)",
|
||||
run_id,
|
||||
listing_id,
|
||||
source_url,
|
||||
incomplete_reason,
|
||||
prior_incomplete + 1,
|
||||
INCOMPLETE_MAX_ATTEMPTS,
|
||||
consecutive_none,
|
||||
)
|
||||
if consecutive_none >= max_consecutive_blocks:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1674,12 +1674,16 @@ class _HouseIdResolution:
|
|||
total: int = 0 # deals processed in the full-engine path under the flag
|
||||
resolved: int = 0 # deals where match_house_readonly returned an id
|
||||
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]:
|
||||
return {
|
||||
"resolved": self.resolved,
|
||||
"total": self.total,
|
||||
"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)
|
||||
if target_house_id is not None:
|
||||
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:
|
||||
pr = _predict_full_spine(
|
||||
db,
|
||||
|
|
@ -2616,10 +2632,11 @@ def run_backtest_full(
|
|||
if resolution is not None:
|
||||
metrics["house_id_resolution"] = resolution.as_json()
|
||||
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.total,
|
||||
resolution.imv_reachable,
|
||||
resolution.house_type_from_houses,
|
||||
)
|
||||
|
||||
if dump_fixture is not None and capture is not None:
|
||||
|
|
|
|||
911
tradein-mvp/backend/tests/fixtures/cian_serp_ekb_2room_trimmed.html
vendored
Normal file
911
tradein-mvp/backend/tests/fixtures/cian_serp_ekb_2room_trimmed.html
vendored
Normal 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®ion=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>
|
||||
120
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_novostroyki_trimmed.json
vendored
Normal file
120
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_novostroyki_trimmed.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
355
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_vtorichka_trimmed.json
vendored
Normal file
355
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_vtorichka_trimmed.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == {}
|
||||
|
|
@ -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 == (
|
||||
"Россия, Свердловская область, Екатеринбург, жилой район Центральный, "
|
||||
"ЖК Александровский Сад"
|
||||
)
|
||||
|
|
@ -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_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_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_strips_district_glued_to_house_number
|
||||
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.
|
||||
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
|
||||
|
||||
# Вечный недогруз Яндекса (#3191, миграция 321): снапшот-SELECT очереди добора с
|
||||
# суточной паузой и выбыванием после трёх недогрузов судит только Postgres — на
|
||||
# мок-лэйне БД нет. В ci-tradein.yml бежит по-настоящему (postgres-сервис, #2745).
|
||||
tests/test_3191_yandex_perpetual_underloaded.py::test_perpetual_underloaded_cards_leave_queue_head
|
||||
|
|
|
|||
151
tradein-mvp/backend/tests/test_2898_exposure_window.py
Normal file
151
tradein-mvp/backend/tests/test_2898_exposure_window.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""#2898: окно срока экспозиции (p25–p75) вместо одного числа и его персист.
|
||||
|
||||
Раньше 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
|
||||
246
tradein-mvp/backend/tests/test_2993_snapshot_change_only.py
Normal file
246
tradein-mvp/backend/tests/test_2993_snapshot_change_only.py
Normal 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()
|
||||
|
|
@ -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.09–16.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()
|
||||
|
|
@ -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
|
||||
|
|
@ -519,13 +519,23 @@ def test_argparse_resolve_house_id_override() -> None:
|
|||
|
||||
|
||||
def test_house_id_resolution_as_json_shape() -> None:
|
||||
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2)
|
||||
assert res.as_json() == {"resolved": 4, "total": 10, "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,
|
||||
"house_type_from_houses": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_house_id_resolution_defaults_zero() -> None:
|
||||
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("city") == "нижний тагил"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -80,6 +80,11 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
|
|||
# #2899: колонка есть у всех строк после миграции 267; NULL у старых
|
||||
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
|
||||
market_percentile=63,
|
||||
# #2898: окно срока экспозиции (миграция 324); NULL у строк до неё.
|
||||
est_days_p25=31,
|
||||
est_days_p50=58,
|
||||
est_days_p75=121,
|
||||
est_days_n=44,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
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.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
|
||||
|
|
|
|||
|
|
@ -602,3 +602,32 @@ def test_empty_estimate_persists_relaxations_reliability() -> None:
|
|||
src = inspect.getsource(estimator._empty_estimate)
|
||||
assert "relaxations, reliability" 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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -236,7 +236,8 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None:
|
|||
# Канонический ключ (мигр. 144), а не точное равенство norm_address.
|
||||
assert "tradein_canon_addr(norm_address) AS canon" in flat
|
||||
# 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_side = (
|
||||
"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(
|
||||
"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"
|
||||
|
|
@ -524,8 +526,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
|
|||
# (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже).
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO houses (short_address) VALUES "
|
||||
"('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')"
|
||||
"INSERT INTO houses (short_address, region_code) VALUES "
|
||||
"('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)"
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -615,6 +617,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
|
|||
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"
|
||||
|
|
@ -635,7 +638,10 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
|
|||
)
|
||||
)
|
||||
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)
|
||||
|
|
@ -654,3 +660,90 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
|
|||
finally:
|
||||
db.rollback()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@ _PAID_FIELDS = {
|
|||
"actual_deals",
|
||||
"expected_sold_price_rub",
|
||||
"est_days_on_market",
|
||||
"exposure_window",
|
||||
"price_trend",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ Per-provider модель (#1793):
|
|||
|
||||
Переменные окружения:
|
||||
BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000)
|
||||
BROWSER_MAX_INSTANCES — потолок одновременно живых camoufox на весь сайдкар
|
||||
(default: 3, #3412). Перед запуском нового инстанса
|
||||
закрывается простаивающий чужой; занятый — никогда.
|
||||
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный
|
||||
дефолт для провайдеров без код-дефолта (default: 15).
|
||||
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
|
||||
|
|
@ -141,6 +144,7 @@ Per-provider модель (#1793):
|
|||
import asyncio
|
||||
import base64
|
||||
import gzip
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
|
|
@ -164,6 +168,14 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
|
|||
IS_PROD: bool = ENVIRONMENT == "production"
|
||||
|
||||
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_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
|
||||
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
|
||||
|
|
@ -849,6 +861,14 @@ _last_nav_ms: dict[str, int | None] = {}
|
|||
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
||||
# если запрошенный proxy совпадает с текущим. 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 из
|
||||
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
|
||||
|
|
@ -927,12 +947,20 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
|
|||
proxy is not None,
|
||||
_RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK),
|
||||
)
|
||||
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
|
||||
browser = await cm.__aenter__()
|
||||
# #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути
|
||||
# запуска (первый запрос, смена прокси, recycle/краш, фоновый retry).
|
||||
await _trim_instances(provider)
|
||||
_launching.add(provider)
|
||||
try:
|
||||
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
|
||||
browser = await cm.__aenter__()
|
||||
finally:
|
||||
_launching.discard(provider)
|
||||
_browser_cms[provider] = cm
|
||||
_browsers[provider] = browser
|
||||
_page_counters[provider] = 0
|
||||
_launched_proxy[provider] = proxy_url
|
||||
_last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU
|
||||
logger.info(
|
||||
"tradein-browser[%s]: браузер запущен (proxy_override=%s)",
|
||||
provider,
|
||||
|
|
@ -1050,9 +1078,22 @@ async def _close_reusable_context(provider: str) -> None:
|
|||
|
||||
|
||||
async def _close_browser(provider: str) -> None:
|
||||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
|
||||
await _close_reusable_context(provider)
|
||||
cm = _browser_cms.get(provider)
|
||||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).
|
||||
|
||||
Инстанс снимается со словарей ДО первого 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:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
|
||||
|
|
@ -1063,11 +1104,56 @@ async def _close_browser(provider: str) -> None:
|
|||
provider,
|
||||
type(exc).__name__,
|
||||
)
|
||||
_browsers.pop(provider, None)
|
||||
_browser_cms.pop(provider, None)
|
||||
_page_counters[provider] = 0
|
||||
_last_goto_at.pop(provider, None)
|
||||
_launched_proxy.pop(provider, None)
|
||||
|
||||
|
||||
def _pick_victim(provider: str) -> str | None:
|
||||
"""Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412).
|
||||
|
||||
Кандидаты — живые инстансы ЧУЖИХ поставщиков со свободным _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:
|
||||
|
|
@ -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)
|
||||
logger.info("tradein-browser[%s]: перезапуск браузера", provider)
|
||||
await _close_browser(provider)
|
||||
_last_goto_at.pop(provider, None)
|
||||
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)
|
||||
if _browsers.get(provider) is not None:
|
||||
if _launched_proxy.get(provider) == desired_proxy:
|
||||
_last_used[provider] = next(_use_seq)
|
||||
return True
|
||||
# Реальная смена прокси → пересобрать инстанс с новым прокси.
|
||||
logger.info(
|
||||
|
|
@ -1145,6 +1233,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
|
|||
proxy_override is not None,
|
||||
)
|
||||
await _close_browser(provider)
|
||||
_last_goto_at.pop(provider, None)
|
||||
|
||||
await _try_launch_browser(provider, proxy_override=proxy_override)
|
||||
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: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
|
||||
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
|
||||
logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES)
|
||||
_warn_retired_env()
|
||||
|
||||
|
||||
|
|
|
|||
209
tradein-mvp/browser/test_server_instance_limit.py
Normal file
209
tradein-mvp/browser/test_server_instance_limit.py
Normal 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}
|
||||
|
|
@ -82,6 +82,12 @@ services:
|
|||
# (грубо ~2.5g на каждую доп. параллельную страницу).
|
||||
mem_limit: 2560m
|
||||
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(), которое
|
||||
# само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний
|
||||
# shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие
|
||||
|
|
|
|||
|
|
@ -178,10 +178,14 @@ LIMIT 20;
|
|||
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
|
||||
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
|
||||
закрыта сужением по городу, поведение 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
|
||||
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
|
||||
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER
|
||||
BY flat_count DESC, house_guid)`.
|
||||
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon
|
||||
ORDER BY flat_count DESC, house_guid)`.
|
||||
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
|
||||
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
|
||||
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ const TARIFFS: readonly {
|
|||
},
|
||||
{
|
||||
name: "Полный отчёт",
|
||||
note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж",
|
||||
note: "Цена, диапазон, срок экспозиции похожих объявлений, аналоги, факторы. Разовый платёж",
|
||||
price: `${SERVICE_PRICE_RUB} ₽`,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
|
|||
sources_used: ["avito", "cian", "rosreestr"],
|
||||
data_freshness_minutes: 35,
|
||||
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,
|
||||
address_precision: "house",
|
||||
area_m2: 55.3,
|
||||
|
|
|
|||
|
|
@ -246,7 +246,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
const houseType = estimate.house_type ?? input.house_type;
|
||||
const repairState = estimate.repair_state ?? input.repair_state;
|
||||
const hasBalcony = estimate.has_balcony ?? input.has_balcony;
|
||||
const estDaysOnMarket = estimate.est_days_on_market;
|
||||
// #2898: окно p25–p75, а не одно число. est_days_on_market не показываем:
|
||||
// у старых оценок это возраст висящих объявлений, а не срок до снятия.
|
||||
const exposure = estimate.exposure_window ?? null;
|
||||
|
||||
function handleEnrichSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
|
@ -403,9 +405,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
<span className="v mono">{estimate.n_analogs}</span>
|
||||
</div>
|
||||
<div className="meta-row">
|
||||
<span className="k">Срок продажи</span>
|
||||
{estDaysOnMarket !== null ? (
|
||||
<span className="v mono">{estDaysOnMarket} дн.</span>
|
||||
<span className="k">До снятия объявления</span>
|
||||
{exposure ? (
|
||||
<span
|
||||
className="v mono"
|
||||
title={`Средняя половина из ${exposure.n} похожих объявлений рядом висела столько дней до снятия. Снятие объявления не значит продажу.`}
|
||||
>
|
||||
{exposure.p25_days}–{exposure.p75_days} дн.
|
||||
</span>
|
||||
) : (
|
||||
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
|
|||
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
||||
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
||||
{data.price_history.length >= 2 && (
|
||||
<PriceHistoryChart points={data.price_history} />
|
||||
<PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
|
||||
)}
|
||||
{data.recent_sold.length > 0 && (
|
||||
<RecentSoldList items={data.recent_sold} />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import {
|
|||
import type { PriceHistoryYearPoint } from "@/types/trade-in";
|
||||
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 YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
|
||||
|
|
@ -28,7 +30,7 @@ interface PivotRow {
|
|||
n_yandex?: number;
|
||||
}
|
||||
|
||||
export function PriceHistoryChart({ points }: Props) {
|
||||
export function PriceHistoryChart({ points, radiusM }: Props) {
|
||||
const pivoted = useMemo<PivotRow[]>(() => {
|
||||
const byYear: Record<number, PivotRow> = {};
|
||||
for (const p of points) {
|
||||
|
|
@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {
|
|||
<article className="card" style={{ marginTop: 12, padding: 16 }}>
|
||||
<header style={{ marginBottom: 8 }}>
|
||||
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||
История цен в этом доме
|
||||
{radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
|
||||
</h4>
|
||||
<small style={{ color: "var(--muted, #6b7280)" }}>
|
||||
Медиана ₽/м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
Медиана ₽/м² по годам ·{" "}
|
||||
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
|
||||
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
</small>
|
||||
</header>
|
||||
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* #2898 — в hero окно срока до снятия объявления (p25–p75), а не одно число.
|
||||
* Старое 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("До снятия объявления29–146 дн.");
|
||||
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 дн.");
|
||||
});
|
||||
});
|
||||
|
|
@ -357,7 +357,7 @@ export default function AnalyticsView({
|
|||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
История цен в этом доме
|
||||
{data.priceHistory.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{historyNote}
|
||||
|
|
@ -401,9 +401,9 @@ export default function AnalyticsView({
|
|||
style={{ width: "100%", height: 260, marginTop: 12 }}
|
||||
preserveAspectRatio="none"
|
||||
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}>
|
||||
{PH_GRID_Y.map((y) => (
|
||||
<line key={y} x1={40} y1={y} x2={900} y2={y} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1820,7 +1820,16 @@ export function phYearX(i: number, n: number): number {
|
|||
}
|
||||
|
||||
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 = {
|
||||
title,
|
||||
note: "Нет данных по истории цен",
|
||||
years: [],
|
||||
avito: "",
|
||||
|
|
@ -1879,7 +1888,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
|||
.join(" ");
|
||||
|
||||
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,
|
||||
["лот", "лота", "лотов"],
|
||||
)})`;
|
||||
|
|
@ -1890,6 +1899,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
|||
});
|
||||
|
||||
return {
|
||||
title,
|
||||
note,
|
||||
years: yearsNum.map(String),
|
||||
avito: series("avito_imv"),
|
||||
|
|
|
|||
|
|
@ -264,6 +264,8 @@ export interface SellTimeTier {
|
|||
}
|
||||
|
||||
export interface PriceHistory {
|
||||
/** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */
|
||||
title: string;
|
||||
note: string;
|
||||
years: string[];
|
||||
/** SVG polyline points string (viewBox 0 0 900 220). */
|
||||
|
|
|
|||
|
|
@ -128,6 +128,15 @@ export interface PriceTrendPoint {
|
|||
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 {
|
||||
sale_price_rub: number | null;
|
||||
rent_price_rub: number | null;
|
||||
|
|
@ -234,7 +243,10 @@ export interface AggregatedEstimate {
|
|||
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||
est_days_on_market: number | null; // прогноз срока продажи
|
||||
// #2898: окно p25–p75; null — выборка < 30 или оценка старее бэкенда.
|
||||
exposure_window?: ExposureWindow | null;
|
||||
// Устарело: = exposure_window.p50_days. Показывать окно, не это число.
|
||||
est_days_on_market: number | null;
|
||||
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
||||
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
||||
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue