Compare commits
19 commits
5ed4a4ec25
...
66660bd366
| Author | SHA1 | Date | |
|---|---|---|---|
| 66660bd366 | |||
| 2390eec740 | |||
| a5ca2a326a | |||
| ca3f073b0e | |||
| 4e3279d448 | |||
| 272d807f55 | |||
| f3401badd0 | |||
| 7b12b07935 | |||
| 8f0c2ce1f4 | |||
| 6dd8d131ae | |||
| 3d51c08e44 | |||
| 8f3912cdf6 | |||
|
|
c5784e85bc | ||
|
|
3059f09e53 | ||
| 9e243fd643 | |||
|
|
e13374e319 | ||
| 84505b1e0b | |||
| d43d8b0c60 | |||
|
|
5391a36880 |
49 changed files with 3093 additions and 650 deletions
|
|
@ -150,13 +150,6 @@ NOISE_L_BASE: dict[str, float] = {
|
|||
}
|
||||
|
||||
|
||||
def _wind_label(deg: float) -> str:
|
||||
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
|
||||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||||
idx = round(deg / 45) % 8
|
||||
return rose[idx]
|
||||
|
||||
|
||||
# Координаты центра ЕКБ — Площадь 1905 года
|
||||
EKB_CENTER_LAT: float = 56.838011
|
||||
EKB_CENTER_LON: float = 60.597474
|
||||
|
|
|
|||
|
|
@ -6,29 +6,6 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||
# ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────
|
||||
|
||||
|
||||
class RecentPermit(BaseModel):
|
||||
"""Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""
|
||||
|
||||
permit_type: str
|
||||
permit_number: str
|
||||
issue_date: str | None
|
||||
developer_name: str | None
|
||||
developer_inn: str | None
|
||||
object_name: str | None
|
||||
object_type: str | None
|
||||
construction_address: str | None
|
||||
total_area_sqm: float | None
|
||||
|
||||
|
||||
class PermitsSummary(BaseModel):
|
||||
"""Агрегированная сводка по разрешениям в квартале."""
|
||||
|
||||
rns_count: int
|
||||
rve_count: int
|
||||
rns_total_area_sqm: float
|
||||
by_developer: list[dict[str, Any]]
|
||||
|
||||
|
||||
# ── Connection points schemas (issue #115) ────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -564,16 +541,6 @@ class DeveloperAttributionResult(BaseModel):
|
|||
# ── Layout analysis (Issue #113) ───────────────────────────────────────────
|
||||
|
||||
|
||||
class LayoutSignature(BaseModel):
|
||||
"""Минимальная сигнатура планировки = (room_bucket, area_bin).
|
||||
|
||||
Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
|
||||
"""
|
||||
|
||||
room_bucket: Literal["studio", "1", "2", "3", "4+"]
|
||||
area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]
|
||||
|
||||
|
||||
class BestLayoutsRequest(BaseModel):
|
||||
"""Параметры запроса top-планировок в радиусе вокруг участка."""
|
||||
|
||||
|
|
|
|||
|
|
@ -181,13 +181,3 @@ def upsert_documents(db: Session, obj_id: int, docs: list[dict[str, Any]]) -> tu
|
|||
|
||||
# ── Stub for future Celery download task ──────────────────────────────────────
|
||||
|
||||
|
||||
def download_document_stub(obj_id: int, doc_id: int, file_url: str) -> None:
|
||||
"""Placeholder для будущей Celery-задачи download_domrf_documents.
|
||||
|
||||
Скачивает PDF, сохраняет в data/raw/domrf_docs/{obj_id}/{filename},
|
||||
обновляет domrf_kn_documents.local_path + downloaded_at.
|
||||
|
||||
Реализация — отдельный PR (future task).
|
||||
"""
|
||||
raise NotImplementedError("PDF download not implemented in 22i. See issue #297 future PR.")
|
||||
|
|
|
|||
|
|
@ -1807,55 +1807,6 @@ async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict
|
|||
return _flatten_table(payload)
|
||||
|
||||
|
||||
async def probe_endpoint(
|
||||
url_or_path: str,
|
||||
region_code: int = 66,
|
||||
headed: bool = False,
|
||||
load_state: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Quick one-shot probe through a real Playwright session for debugging.
|
||||
|
||||
Returns {status, content_type, body_preview, full_url}. Use to verify that
|
||||
a URL works in our session before/after a failing scraper run.
|
||||
"""
|
||||
if url_or_path.startswith("http"):
|
||||
# split into path+query for BrowserSession.get_json
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
s = urlsplit(url_or_path)
|
||||
path = s.path
|
||||
params = dict(parse_qsl(s.query, keep_blank_values=True))
|
||||
else:
|
||||
path = url_or_path.split("?", 1)[0]
|
||||
params = {}
|
||||
if "?" in url_or_path:
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
params = dict(parse_qsl(url_or_path.split("?", 1)[1], keep_blank_values=True))
|
||||
|
||||
async with BrowserSession(
|
||||
region_code=region_code, headed=headed, load_state=load_state
|
||||
) as sess:
|
||||
try:
|
||||
payload = await sess.get_json(path, params)
|
||||
body = json.dumps(payload, ensure_ascii=False)[:2000]
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
full_url = f"{BASE_URL}{path}" + (f"?{qs}" if qs else "")
|
||||
return {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"body_preview": body,
|
||||
"full_url": full_url,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": _http_status_of(e),
|
||||
"content_type": "error",
|
||||
"body_preview": str(e)[:2000],
|
||||
"full_url": f"{BASE_URL}{path}",
|
||||
}
|
||||
|
||||
|
||||
def log_progress(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
|
|
|
|||
|
|
@ -133,21 +133,6 @@ def fetch_geoportal(
|
|||
raise NspdLiteError(f"Network error: {e}") from e
|
||||
|
||||
|
||||
def fetch_quarter(quarter_cad_num: str, **kwargs) -> dict[str, Any]:
|
||||
"""Конкретный кадастровый квартал (typed wrapper)."""
|
||||
return fetch_geoportal(quarter_cad_num, thematic_id=THEMATIC["quarter"], **kwargs)
|
||||
|
||||
|
||||
def fetch_parcel(parcel_cad_num: str, **kwargs) -> dict[str, Any]:
|
||||
"""Конкретный участок ЗУ (typed wrapper)."""
|
||||
return fetch_geoportal(parcel_cad_num, thematic_id=THEMATIC["parcel"], **kwargs)
|
||||
|
||||
|
||||
def fetch_building(building_cad_num: str, **kwargs) -> dict[str, Any]:
|
||||
"""Конкретное здание ОКС (typed wrapper)."""
|
||||
return fetch_geoportal(building_cad_num, thematic_id=THEMATIC["building"], **kwargs)
|
||||
|
||||
|
||||
# ── Bulk через rosreestr2coord library ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import logging
|
|||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -557,36 +557,3 @@ class ObjectiveClient:
|
|||
|
||||
# ── удобный one-shot helper ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fetch_with_raw_log(
|
||||
fn_name: str,
|
||||
*,
|
||||
save_dir: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[Any, str | None]:
|
||||
"""Вызывает client.<fn_name>(**kwargs), сохраняет raw JSON в файл если задан save_dir.
|
||||
Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill."""
|
||||
from pathlib import Path
|
||||
|
||||
client = ObjectiveClient()
|
||||
try:
|
||||
method = getattr(client, fn_name)
|
||||
data = method(**kwargs)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
file_path: str | None = None
|
||||
if save_dir:
|
||||
Path(save_dir).mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
slug = (
|
||||
"_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v)
|
||||
or "all"
|
||||
)
|
||||
slug = slug.replace(" ", "_").replace("/", "_")
|
||||
file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json"
|
||||
Path(file_path).write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return data, file_path
|
||||
|
|
|
|||
|
|
@ -61,18 +61,6 @@ _KRT_AT_SQL = text("""
|
|||
""")
|
||||
|
||||
|
||||
def _functional_zone_at(
|
||||
client: EKBGeoportalClient, lon: float, lat: float
|
||||
) -> dict[str, Any] | None:
|
||||
"""Функц.зона генплана (#1058) в точке — properties первой WFS-фичи; None при сбое/пусто."""
|
||||
try:
|
||||
feats = client.features_at_point("functional_zone", lon, lat)
|
||||
except Exception as exc: # внешний геопортал, graceful
|
||||
logger.warning("ird_analyze: functional_zone WFS failed: %s", exc)
|
||||
return None
|
||||
return feats[0].properties if feats else None
|
||||
|
||||
|
||||
def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
|
||||
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ Graceful: нет таблицы / пустой WKT / исключение БД
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
|
@ -98,12 +97,6 @@ _PLANNING_NO_MATCH_SQL = text(
|
|||
)
|
||||
|
||||
|
||||
def _extract_paga_num(full_name: str) -> str | None:
|
||||
"""Извлечь номер ПАГЕ из заголовка planning_project.full_name."""
|
||||
m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
|
||||
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал.
|
||||
|
||||
|
|
|
|||
|
|
@ -472,18 +472,6 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]:
|
|||
return result_dict
|
||||
|
||||
|
||||
async def load_utility_infrastructure(db: Session | None = None) -> dict[str, int]:
|
||||
"""Async-обёртка для прямого вызова из async-контекста (#1746 spec).
|
||||
|
||||
Тянет Overpass и UPSERT-ит в таблицу. ``db`` принимается для сигнатурной
|
||||
совместимости со спекой, но UPSERT идёт через собственную SessionLocal
|
||||
(как noise/poi sync) — Overpass-fetch async, запись в БД sync. Возвращает
|
||||
те же per-kind counts, что ``sync_utility_infrastructure_to_db``.
|
||||
"""
|
||||
elements = await fetch_overpass_utility()
|
||||
return _upsert_elements(elements)
|
||||
|
||||
|
||||
# Junk-значения OSM tags, которые не несут смысла для попапа.
|
||||
_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"})
|
||||
|
||||
|
|
|
|||
|
|
@ -150,69 +150,6 @@ def _build_beat_schedule_from_db() -> dict:
|
|||
return schedule
|
||||
|
||||
|
||||
def _build_beat_schedule_fallback() -> dict:
|
||||
"""Fallback-расписание из env vars — используется если БД недоступна на старте."""
|
||||
logger.warning("beat_schedule: строим fallback из env vars")
|
||||
schedule: dict = {}
|
||||
|
||||
# scrape_kn
|
||||
try:
|
||||
for rc in _default_regions():
|
||||
schedule[f"kn-region-{rc}"] = {
|
||||
"task": "tasks.scrape_kn.scrape_kn_region",
|
||||
"schedule": _parse_cron(settings.scrape_kn_cron),
|
||||
"args": [rc, None],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("beat_schedule fallback: scrape_kn failed: %s", e)
|
||||
|
||||
# objective_sync
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.objective_sync_config import get_cron_schedule_safe
|
||||
|
||||
cron_str = get_cron_schedule_safe(SessionLocal)
|
||||
except Exception:
|
||||
cron_str = settings.objective_sync_cron
|
||||
|
||||
try:
|
||||
schedule["objective-sync"] = {
|
||||
"task": "tasks.scrape_objective.sync_all_groups",
|
||||
"schedule": _parse_cron(cron_str),
|
||||
"kwargs": {"triggered_by": "beat"},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("beat_schedule fallback: objective_sync failed: %s", e)
|
||||
|
||||
# refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК
|
||||
try:
|
||||
schedule["refresh-ekb-districts-medians"] = {
|
||||
"task": "tasks.refresh_analytics.refresh_ekb_districts_medians",
|
||||
"schedule": _parse_cron("0 4 5 * *"),
|
||||
"kwargs": {"window_months": 24, "min_deals": 50},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
|
||||
|
||||
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
|
||||
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
|
||||
schedule["ekb-krt-sync-weekly"] = {
|
||||
"task": "tasks.ekb_krt_sync.sync_krt_sites",
|
||||
"schedule": _parse_cron("0 5 * * wed"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
|
||||
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
|
||||
schedule["riasurt-sverdl-harvest-quarterly"] = {
|
||||
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
|
||||
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
def build_beat_schedule() -> dict:
|
||||
"""Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries.
|
||||
|
||||
|
|
|
|||
57
data/sql/195_tradein_osm_roads_view.sql
Normal file
57
data/sql/195_tradein_osm_roads_view.sql
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
-- 195_tradein_osm_roads_view.sql
|
||||
-- Улицы ЕКБ на витрину сделок МЕРЫ: сторона gendesign (PR 1 из 2).
|
||||
--
|
||||
-- Карточка игры на лэндинге показывала ПОЛИГОН РАЙОНА — единственную геометрию,
|
||||
-- которая у tradein была. Улицы живут здесь, в `osm_noise_sources_ekb`, а у
|
||||
-- tradein нет ни foreign table, ни гранта. Эта миграция открывает вьюху ровно
|
||||
-- по образцу соседей (`v_tradein_cad_buildings`, `v_tradein_osm_poi_ekb`,
|
||||
-- 185_tradein_osm_poi_view.sql): плоский срез нужных колонок + GRANT SELECT
|
||||
-- уже существующей роли `tradein_fdw_reader` (100_tradein_fdw_role.sql).
|
||||
-- Новых ролей и user mapping здесь нет.
|
||||
--
|
||||
-- ГРАНТ СТОИТ РЯДОМ С CREATE, В ЭТОМ ЖЕ ФАЙЛЕ — И ЭТО НЕ СТИЛЬ. 2026-08-29
|
||||
-- (#3227) объект пересоздали, а GRANT жил отдельной миграцией и потерялся:
|
||||
-- пересоздание снимает права молча, и чтение с той стороны падает на
|
||||
-- `permission denied`. То же было с `ekb_districts_geom` (194) и
|
||||
-- `mv_quarter_price_index` (188). Кто будет менять эту вьюху — меняйте файл
|
||||
-- целиком, вместе с грантом.
|
||||
--
|
||||
-- ЧТО В СРЕЗЕ И ЧЕГО В НЁМ НЕТ (замер на проде 2026-08-29):
|
||||
-- * 10 025 линий, 1377 различных названий улиц. Выгрузка собиралась как
|
||||
-- «источники шума», поэтому именованные улицы покрыты хорошо, а дворовые
|
||||
-- проезды и служебные ОТСУТСТВУЮТ. Потребитель обязан это знать: схема из
|
||||
-- этих данных — улицы района, а не полная уличная сеть.
|
||||
-- * `source_type IN ('highway','water')` — только то, что рисуется на схеме.
|
||||
-- Железные дороги, ЛЭП, трубопроводы, подстанции и промзона (ещё ~4 400
|
||||
-- строк) сюда НЕ едут: на карточке 320 px они дают шум, а не смысл.
|
||||
-- * ЗДАНИЙ ЗДЕСЬ НЕТ И НЕ БУДЕТ. `cad_buildings` — 18 307 контуров на весь
|
||||
-- город; в плотном центре это 51 здание на радиус 450 м, где их в разы
|
||||
-- больше. Нарисованная застройка заявляла бы полноту, которой в данных
|
||||
-- нет, и показывала бы пустые кварталы там, где стоят дома.
|
||||
--
|
||||
-- Идемпотентна: CREATE OR REPLACE VIEW + повторяемый GRANT.
|
||||
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752). CREATE OR REPLACE VIEW и GRANT берут лок на объект;
|
||||
-- ждать его выдачи за чужой сессией — значит собрать очередь перед приложением.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE OR REPLACE VIEW v_tradein_osm_roads_ekb AS
|
||||
SELECT
|
||||
name,
|
||||
road_class,
|
||||
geom
|
||||
FROM osm_noise_sources_ekb
|
||||
WHERE source_type IN ('highway', 'water');
|
||||
|
||||
GRANT SELECT ON v_tradein_osm_roads_ekb TO tradein_fdw_reader;
|
||||
|
||||
COMMENT ON VIEW v_tradein_osm_roads_ekb IS
|
||||
'FDW-источник для tradein-mvp: улицы и вода ЕКБ для схемы улицы на витрине '
|
||||
'сделок лэндинга МЕРЫ. Срез osm_noise_sources_ekb по source_type '
|
||||
'IN (highway, water) — 10 025 линий, 1377 названий. Выгрузка фильтрованная '
|
||||
'(«источники шума»): дворовых и служебных проездов в ней нет. Зданий нет '
|
||||
'намеренно, см. шапку 195_tradein_osm_roads_view.sql. GRANT живёт в том же '
|
||||
'файле — пересоздание вьюхи снимает права молча (#3227).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -174,25 +174,38 @@ check "www.meraocenka.ru — 301 to canonical" "https://www.meraocenka.ru/" 301
|
|||
check "www.merahome.ru — 301 to canonical" "https://www.merahome.ru/" 301
|
||||
check "www.meraotsenka.ru — 301 to canonical" "https://www.meraotsenka.ru/" 301
|
||||
|
||||
# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
|
||||
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
|
||||
# СТОРОН прямо сейчас:
|
||||
# 6. Платёжный периметр. Заведён в PR-D2 как канарейка «ожидания обновляют
|
||||
# вместе с тем PR, который путь открывает». Канарейка отработала: PR-D3
|
||||
# (#3231, роутер) смержен 29.08 и внёс notify в `_PUBLIC_PATHS`, ожидание
|
||||
# здесь обновлено тем же днём — но уже ПОСЛЕ красного деплоя, а не вместе с
|
||||
# ним. Кто будет делать PR-D4 — правьте ожидания в одном PR с ним.
|
||||
#
|
||||
# Состояние на 29.08, чего ждём сейчас:
|
||||
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
|
||||
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
|
||||
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
|
||||
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
|
||||
# платёжные пути и требует X-Authenticated-User → 401 анониму.
|
||||
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
|
||||
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
|
||||
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
|
||||
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
|
||||
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит. Это ВСЁ ЕЩЁ
|
||||
# канарейка: PR-D4 не смержен, 404 должен держаться;
|
||||
# - gendsgn.ru: `checkout` по-прежнему требует X-Authenticated-User → 401
|
||||
# анониму (он не публичный и не станет им);
|
||||
# - gendsgn.ru: `notify` — публичный по построению (вебхук банка), см.
|
||||
# комментарий у самой проверки ниже.
|
||||
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
|
||||
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
|
||||
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
|
||||
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
|
||||
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
|
||||
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
|
||||
# notify — ЕДИНСТВЕННЫЙ платёжный путь, который PR-D3 (#3231) открыл анониму
|
||||
# осознанно: это вебхук банка, он обязан быть достижим без наших заголовков, и
|
||||
# потому внесён в `_PUBLIC_PATHS`. Канарейка выше отработала как задумано — 401
|
||||
# здесь стал бы теперь признаком ПОЛОМКИ (банк получил бы отказ), а не защиты.
|
||||
#
|
||||
# Проверяем POST и ждём 503: это строже прежнего 401 — утверждает сразу, что
|
||||
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
|
||||
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
|
||||
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
|
||||
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
|
||||
check "trade-in payments/notify — 405 на GET (только POST)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
|
||||
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
|
||||
|
||||
echo "========================================"
|
||||
|
|
|
|||
|
|
@ -393,8 +393,36 @@ class ShowcaseDeal(BaseModel):
|
|||
|
||||
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
|
||||
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
|
||||
правдоподобную подстановку.
|
||||
|
||||
`street_name` / `street_scheme` — УЛИЦА, А НЕ ДОМ. Номер дома есть у 2.7%
|
||||
сделок (разбор в миграции 276), поэтому дома в витрине нет и не будет.
|
||||
Оба поля НУЛЛАБЕЛЬНЫ, и null — штатный случай: название сматчилось с OSM у
|
||||
550 названий из 654 (92.3% сделок, замер 2026-08-29), остальным схемы нет и
|
||||
фронт показывает район.
|
||||
|
||||
`street_scheme` — уже спроецированные SVG-пути окна 840×840 м вокруг центра
|
||||
улицы::
|
||||
|
||||
{"street": "улица Краснолесья", "w": 1000, "h": 1000,
|
||||
"target": ["M…L…"], # подсвеченная улица
|
||||
"roads": [{"c": "primary", "d": "M…L…"}], # фон, c = класс дороги
|
||||
"water": ["M…L…"],
|
||||
"labels": [{"t": "Чкалова", "x": 431.2, "y": 88.0}]}
|
||||
|
||||
Координат окна и констант проекции в схеме НЕТ намеренно: по ней нельзя
|
||||
положить (lon, lat) в её систему координат, то есть нарисовать точку дома
|
||||
невозможно даже случайно. Схема готовая, а не геометрия, потому что GeoJSON
|
||||
того же окна — 7-8 КБ на строку против 2.8-2.9 КБ схемы (замер 2026-08-29).
|
||||
правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома
|
||||
есть у 2.7% сделок (разбор в миграции 276).
|
||||
|
||||
`lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
|
||||
сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов
|
||||
(замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
|
||||
и НЕ честна на масштабе дома — то же самое написано в `note` строки,
|
||||
которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
|
||||
такая строка остаётся на витрине без точки, а не выбрасывается.
|
||||
"""
|
||||
|
||||
district: str | None
|
||||
|
|
@ -408,6 +436,10 @@ class ShowcaseDeal(BaseModel):
|
|||
err_pct: float
|
||||
n_analogs: int
|
||||
note: str
|
||||
street_name: str | None
|
||||
street_scheme: dict | None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
|
||||
|
||||
class ShowcaseStats(BaseModel):
|
||||
|
|
@ -458,7 +490,8 @@ _SHOWCASE_RUN_SQL = text(
|
|||
_SHOWCASE_SQL = text(
|
||||
"""
|
||||
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note,
|
||||
lat, lon, street_name, street_scheme
|
||||
FROM landing_showcase_deals
|
||||
WHERE computed_at = CAST(:computed_at AS timestamptz)
|
||||
ORDER BY id
|
||||
|
|
@ -515,6 +548,13 @@ def public_showcase(
|
|||
err_pct=float(r["err_pct"]),
|
||||
n_analogs=int(r["n_analogs"]),
|
||||
note=r["note"],
|
||||
street_name=r["street_name"],
|
||||
street_scheme=r["street_scheme"],
|
||||
# Порядок не менять: lat — широта (~56.8 в ЕКБ), lon — долгота
|
||||
# (~60.6). Перепутанные местами координаты остаются валидными
|
||||
# float и уедут на карту точкой в другой стране.
|
||||
lat=(float(r["lat"]) if r["lat"] is not None else None),
|
||||
lon=(float(r["lon"]) if r["lon"] is not None else None),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2405,6 +2405,112 @@ def _fetch_anchor_comps(
|
|||
return [], None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _HouseFacts:
|
||||
"""Характеристики дома из справочника `houses` (#3234).
|
||||
|
||||
total_floors / year_built / house_type — fallback для пропущенных
|
||||
payload-полей ПЕРЕД OSM Overpass (houses на два порядка полнее и не тратит
|
||||
внешний вызов). material_walls читается на будущее — потребителя ниже по
|
||||
estimator пока нет, но данные есть: 7838 домов из 10221 (77%) на проде после
|
||||
наполнения из ГИС ЖКХ + DOM.РФ 2026-08-29.
|
||||
"""
|
||||
|
||||
house_id: int
|
||||
total_floors: int | None
|
||||
year_built: int | None
|
||||
house_type: str | None
|
||||
material_walls: str | None
|
||||
|
||||
|
||||
def _lookup_house_facts(
|
||||
db: Session,
|
||||
*,
|
||||
target_house_id: int | None,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
) -> _HouseFacts | None:
|
||||
"""Читает total_floors/year_built/house_type/material_walls из `houses`.
|
||||
|
||||
Ключ — canonical target_house_id (уже резолвлен match_house_readonly выше
|
||||
по estimate-функции); если он None — фолбэк на ближайший дом в радиусе 60м
|
||||
от subject lat/lon (ST_DWithin + сортировка по расстоянию, LIMIT 1).
|
||||
|
||||
Best-effort: любая ошибка БД → None + WARNING, оценка не должна падать
|
||||
(тот же паттерн try/except + db.rollback(), см. _fetch_anchor_comps выше).
|
||||
"""
|
||||
try:
|
||||
if target_house_id is not None:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, total_floors, year_built, house_type, material_walls
|
||||
FROM houses
|
||||
WHERE id = CAST(:hid AS bigint)
|
||||
"""
|
||||
),
|
||||
{"hid": target_house_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
elif lat is not None and lon is not None:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, total_floors, year_built, house_type, material_walls
|
||||
FROM houses
|
||||
WHERE geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_MakePoint(:lon, :lat)::geography,
|
||||
60
|
||||
)
|
||||
ORDER BY geom::geography <-> ST_MakePoint(:lon, :lat)::geography
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"lon": lon, "lat": lat},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
return None
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.warning("house facts lookup failed (graceful): %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
# Guard типов строки: houses.id — NOT NULL bigint, любая другая форма (в т.ч.
|
||||
# тестовый db-double без явного мока запроса) трактуется как «строки нет» —
|
||||
# тот же best-effort, что и явная ошибка выше. Остальные поля best-effort
|
||||
# каждое по отдельности — неожиданный тип поля не должен ронять facts целиком.
|
||||
house_id = row["id"]
|
||||
if not isinstance(house_id, int):
|
||||
return None
|
||||
|
||||
def _as_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
def _as_str(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
return _HouseFacts(
|
||||
house_id=house_id,
|
||||
total_floors=_as_int(row["total_floors"]),
|
||||
year_built=_as_int(row["year_built"]),
|
||||
house_type=_as_str(row["house_type"]),
|
||||
material_walls=_as_str(row["material_walls"]),
|
||||
)
|
||||
|
||||
|
||||
def _band_haircut(anchor_ppm2: float) -> float:
|
||||
"""asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену).
|
||||
|
||||
|
|
@ -4040,7 +4146,39 @@ async def estimate_quality(
|
|||
# gateway-таймауту; деградируем в None при превышении budget.
|
||||
target_year = payload.year_built
|
||||
target_house_type = payload.house_type
|
||||
if target_year is None or target_house_type is None:
|
||||
target_total_floors = payload.total_floors
|
||||
|
||||
# 2a. #3234: houses — наш справочник, приоритет ПЕРЕД OSM Overpass (year
|
||||
# 86.8% / floors 98.3% покрытия на проде против 68 строк в
|
||||
# house_metadata-кэше). payload остаётся главнее в любом случае — houses
|
||||
# заполняет только то, что пользователь не указал.
|
||||
if target_year is None or target_house_type is None or target_total_floors is None:
|
||||
house_facts = await asyncio.to_thread(
|
||||
_lookup_house_facts,
|
||||
db,
|
||||
target_house_id=target_house_id,
|
||||
lat=geo.lat,
|
||||
lon=geo.lon,
|
||||
)
|
||||
if house_facts is not None:
|
||||
filled: list[str] = []
|
||||
if target_year is None and house_facts.year_built is not None:
|
||||
target_year = house_facts.year_built
|
||||
filled.append(f"year_built={target_year}")
|
||||
if target_house_type is None and house_facts.house_type is not None:
|
||||
target_house_type = house_facts.house_type
|
||||
filled.append(f"house_type={target_house_type}")
|
||||
if target_total_floors is None and house_facts.total_floors is not None:
|
||||
target_total_floors = house_facts.total_floors
|
||||
filled.append(f"total_floors={target_total_floors}")
|
||||
if filled:
|
||||
logger.info(
|
||||
"estimate: house facts fallback house_id=%s → %s",
|
||||
house_facts.house_id,
|
||||
", ".join(filled),
|
||||
)
|
||||
|
||||
if target_year is None or target_house_type is None or target_total_floors is None:
|
||||
house_meta = await _with_budget(
|
||||
get_house_metadata(geo.lat, geo.lon, db),
|
||||
settings.estimate_house_meta_timeout_s,
|
||||
|
|
@ -4051,6 +4189,8 @@ async def estimate_quality(
|
|||
target_year = house_meta.year_built
|
||||
if target_house_type is None:
|
||||
target_house_type = house_meta.house_type
|
||||
if target_total_floors is None:
|
||||
target_total_floors = house_meta.total_floors
|
||||
|
||||
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
|
||||
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
|
||||
|
|
@ -4097,7 +4237,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
cohort_year_min=cy_min,
|
||||
cohort_year_max=cy_max,
|
||||
)
|
||||
|
|
@ -4122,7 +4262,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
# #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
|
||||
# surfaced later as the "снят фильтр по году постройки" relaxation label.
|
||||
|
|
@ -4142,7 +4282,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
if len(listings_wide) > len(listings):
|
||||
listings = listings_wide
|
||||
|
|
@ -4166,7 +4306,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
if len(listings_widearea) > len(listings):
|
||||
listings = listings_widearea
|
||||
|
|
@ -4225,7 +4365,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
if len(candidate) > len(listings):
|
||||
return candidate, tier
|
||||
|
|
|
|||
328
tradein-mvp/backend/app/services/street_scheme.py
Normal file
328
tradein-mvp/backend/app/services/street_scheme.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
"""Схема улицы сделки: готовые SVG-пути для карточки лэндинга.
|
||||
|
||||
ЗАЧЕМ ГОТОВАЯ СХЕМА, А НЕ ГЕОМЕТРИЯ. Отдать фронту GeoJSON окна 840×840 м —
|
||||
это десятки килобайт на КАЖДУЮ строку витрины плюс проекция в рантайме. Здесь
|
||||
всё считается один раз при пересчёте витрины и хранится строкой SVG-путей;
|
||||
порядок величины на прототипе — 4 КБ на сделку.
|
||||
|
||||
ЧТО ЭТА СХЕМА ЗАЯВЛЯЕТ, А ЧТО НЕТ
|
||||
---------------------------------
|
||||
* ДОМА В НЕЙ НЕТ И БЫТЬ НЕ МОЖЕТ. `deals.address` — уровня улицы
|
||||
(«Екатеринбург, Краснолесья»), номер дома известен у 2.7% сделок. Точка
|
||||
дома была бы выдумана, поэтому её не рисует даже фронт: в схеме нет ни
|
||||
координат окна, ни констант проекции — по ней физически нельзя положить
|
||||
(lon, lat) в её систему координат. Это не забывчивость, а замок.
|
||||
* ЗДАНИЙ В НЕЙ НЕТ. Замер 2026-08-29: `cad_buildings` содержит 18 307
|
||||
контуров на весь ЕКБ, в плотном центре это 51 здание на радиус 450 м, где
|
||||
их в разы больше. Нарисованная застройка заявляла бы полноту, которой в
|
||||
данных нет, и показывала бы пустые кварталы там, где стоят дома.
|
||||
* УЛИЦЫ — ФИЛЬТРОВАННАЯ ВЫГРУЗКА. `osm_noise_sources_ekb` собиралась как
|
||||
«источники шума»: 10 025 линий, 1377 различных названий. Именованные улицы
|
||||
покрыты хорошо, дворовые проезды и служебные отсутствуют. Схема — это
|
||||
именно улицы района, а не полная уличная сеть.
|
||||
* УЛИЦА СМАТЧИЛАСЬ НЕ ВСЕГДА. Нормализация даёт совпадение у 550 названий из
|
||||
654 — это 31 410 сделок из 34 021 (92.3%, замер на проде 2026-08-29 по той
|
||||
же выборке, из которой набирается витрина). Оставшиеся 8% — штатный
|
||||
случай: схемы нет, фронт показывает район.
|
||||
|
||||
ПРОЕКЦИЯ — та же равнопромежуточная с поправкой cos(широты), что в
|
||||
`scripts/export_ekb_districts_svg.py`. Второй проекции в проекте нет намеренно.
|
||||
Окно квадратное в метрах, поэтому viewBox тоже квадратный: 1000×1000.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Полуразмер окна в метрах. 420 м на 1000 единиц viewBox — это ~1.2 м на
|
||||
# единицу: улица читается как улица, а соседние кварталы попадают в кадр.
|
||||
RADIUS_M = 420.0
|
||||
VIEWBOX = 1000.0
|
||||
# Метров в градусе широты (сфера). Долгота — то же, делённое на cos(широты).
|
||||
M_PER_DEG_LAT = 111_320.0
|
||||
# Допуск упрощения в градусах ≈ 3 м: меньше трёх единиц viewBox, то есть тоньше
|
||||
# линии. Смена значения обязана сопровождаться замером размера схемы.
|
||||
SIMPLIFY_DEG = 0.00003
|
||||
# Сколько подписей соседних улиц кладём в схему. Больше — каша на карточке
|
||||
# 320 px и лишние байты; отбираем самые длинные в кадре.
|
||||
MAX_LABELS = 7
|
||||
|
||||
# Родовые слова, которые в `deals.address` и в OSM пишутся по-разному
|
||||
# («Краснолесья» ↔ «улица Краснолесья»). Снимаем с обеих сторон.
|
||||
_STREET_TYPES = frozenset(
|
||||
{
|
||||
"улица",
|
||||
"ул",
|
||||
"проспект",
|
||||
"пр",
|
||||
"пр-кт",
|
||||
"пркт",
|
||||
"проспкт",
|
||||
"переулок",
|
||||
"пер",
|
||||
"бульвар",
|
||||
"бул",
|
||||
"б-р",
|
||||
"шоссе",
|
||||
"ш",
|
||||
"проезд",
|
||||
"набережная",
|
||||
"наб",
|
||||
"тракт",
|
||||
"аллея",
|
||||
"площадь",
|
||||
"пл",
|
||||
"тупик",
|
||||
}
|
||||
)
|
||||
|
||||
_KEEP = re.compile(r"[^0-9a-zа-я\s-]")
|
||||
|
||||
|
||||
def normalize_street(address: str | None) -> str | None:
|
||||
"""`'Екатеринбург, ул. Краснолесья'` → ``'краснолесья'``. Нет улицы — None.
|
||||
|
||||
Что делает и почему именно так:
|
||||
* берёт часть ПОСЛЕ последней запятой — в `deals.address` город стоит
|
||||
первым («Екатеринбург, 8 Марта»);
|
||||
* ПЕРЕКРЁСТОК берёт первой улицей. «Екатеринбург, Челюскинцев/Шейнкмана»
|
||||
→ «челюскинцев». Таких адресов в выборке ТРИ на 34 021 сделку (по
|
||||
одной сделке на каждый, замер 2026-08-29), и обе улицы одинаково
|
||||
верны: адрес и так уровня улицы, а не дома. Рисовать перекрёсток
|
||||
точкой значило бы заявить точность, которой в остальных 34 018 строках
|
||||
нет, ради трёх;
|
||||
* складывает ё→е: «Королёва» в адресе и «Королева» в OSM — одна улица;
|
||||
* снимает родовое слово (улица/проспект/…), потому что в адресе его
|
||||
обычно нет, а в OSM обычно есть;
|
||||
* цифры оставляет: «8 Марта» и «22 Партсъезда» — названия, а не номера.
|
||||
"""
|
||||
if not address:
|
||||
return None
|
||||
tail = address.rsplit(",", 1)[-1]
|
||||
tail = tail.split("/")[0]
|
||||
tail = tail.replace("ё", "е").replace("Ё", "Е").lower()
|
||||
tail = _KEEP.sub(" ", tail)
|
||||
words = [w for w in tail.split() if w.strip("-") not in _STREET_TYPES]
|
||||
return " ".join(words) or None
|
||||
|
||||
|
||||
# ── Запросы ──────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Обе идут в foreign table `gendesign_osm_roads_ekb` (миграция 281) над вьюхой
|
||||
# `v_tradein_osm_roads_ekb` чужой базы. Сервер `gendesign_remote` объявлен с
|
||||
# OPTIONS (extensions 'postgis'), поэтому PostGIS-операторы shippable: `&&` с
|
||||
# готовым конвертом уходит на ту сторону и попадает в GiST-индекс, а обратно
|
||||
# едет уже обрезанный GeoJSON, а не десять тысяч линий.
|
||||
|
||||
_NAMES_SQL = text("SELECT DISTINCT name FROM gendesign_osm_roads_ekb WHERE name IS NOT NULL")
|
||||
|
||||
_CENTER_SQL = text(
|
||||
"""
|
||||
SELECT ST_X(p) AS lon, ST_Y(p) AS lat
|
||||
FROM (
|
||||
SELECT ST_ClosestPoint(g, ST_Centroid(g)) AS p
|
||||
FROM (
|
||||
SELECT ST_Collect(geom) AS g
|
||||
FROM gendesign_osm_roads_ekb
|
||||
WHERE name = ANY(CAST(:names AS text[]))
|
||||
) t
|
||||
WHERE g IS NOT NULL
|
||||
) q
|
||||
"""
|
||||
)
|
||||
|
||||
_WINDOW_SQL = text(
|
||||
"""
|
||||
SELECT name, road_class,
|
||||
ST_AsGeoJSON(
|
||||
ST_SimplifyPreserveTopology(
|
||||
ST_Intersection(
|
||||
geom,
|
||||
ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326)
|
||||
),
|
||||
CAST(:tol AS double precision)
|
||||
), 6
|
||||
) AS gj
|
||||
FROM gendesign_osm_roads_ekb
|
||||
WHERE geom && ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326)
|
||||
"""
|
||||
)
|
||||
|
||||
# Классы из `osm_noise_sources_ekb`, которые рисуются водой. Остальные (trunk /
|
||||
# primary / secondary / tertiary / residential) — дороги. Разделение
|
||||
# перечислением, а не «всё, что не дорога»: во вьюху могут доехать новые классы,
|
||||
# и тогда лучше не нарисовать, чем нарисовать реку поверх улицы.
|
||||
_WATER_CLASSES = frozenset({"river", "canal", "stream", "lake_or_pond"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreetIndex:
|
||||
"""Нормализованное название → как эта улица называется в OSM.
|
||||
|
||||
Строится ОДИН раз на прогон: 1377 названий на всю базу, тянуть их на каждую
|
||||
из двадцати сделок незачем.
|
||||
"""
|
||||
|
||||
by_norm: dict[str, list[str]]
|
||||
|
||||
def lookup(self, address: str | None) -> tuple[str, list[str]] | None:
|
||||
key = normalize_street(address)
|
||||
if key is None:
|
||||
return None
|
||||
raw = self.by_norm.get(key)
|
||||
return (key, raw) if raw else None
|
||||
|
||||
|
||||
def load_street_index(db: Session) -> StreetIndex:
|
||||
"""Список названий улиц из FDW-вьюхи. Недоступна — пустой индекс.
|
||||
|
||||
SAVEPOINT ИМЕННО ЗДЕСЬ, на месте глушения: провалившийся SELECT переводит
|
||||
транзакцию в aborted, и следующий запрос упал бы уже не по своей вине.
|
||||
Гранты на той стороне уже терялись при пересоздании объекта (#3227).
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_NAMES_SQL).all()
|
||||
except Exception as exc:
|
||||
logger.warning("улицы не резолвятся (витрина будет без схем): %s", exc)
|
||||
return StreetIndex(by_norm={})
|
||||
by_norm: dict[str, list[str]] = {}
|
||||
for (name,) in rows:
|
||||
key = normalize_street(name)
|
||||
if key:
|
||||
by_norm.setdefault(key, []).append(name)
|
||||
return StreetIndex(by_norm=by_norm)
|
||||
|
||||
|
||||
def _parts(gj: dict[str, Any]) -> list[list[list[float]]]:
|
||||
"""GeoJSON любой из встречающихся форм → список цепочек точек."""
|
||||
t = gj.get("type")
|
||||
c = gj.get("coordinates") or []
|
||||
if t == "LineString":
|
||||
return [c]
|
||||
if t == "MultiLineString":
|
||||
return list(c)
|
||||
if t == "Polygon":
|
||||
return list(c)
|
||||
if t == "MultiPolygon":
|
||||
return [ring for poly in c for ring in poly]
|
||||
if t == "GeometryCollection":
|
||||
return [p for g in gj.get("geometries", []) for p in _parts(g)]
|
||||
return []
|
||||
|
||||
|
||||
def _length(chain: list[list[float]]) -> float:
|
||||
return sum(math.dist(chain[i], chain[i + 1]) for i in range(len(chain) - 1))
|
||||
|
||||
|
||||
def build_street_scheme(db: Session, index: StreetIndex, address: str | None) -> dict | None:
|
||||
"""Схема улицы для одной сделки, либо None — и None это ШТАТНО.
|
||||
|
||||
None означает ровно одно: «улицы сделки в геометрии нет» (нет адреса, не
|
||||
сматчилось название, вьюха недоступна, у улицы нет геометрии). Витрина в
|
||||
этом случае отдаёт строку без схемы, а фронт показывает район — запасной
|
||||
вариант, который для этого и оставлен.
|
||||
"""
|
||||
found = index.lookup(address)
|
||||
if found is None:
|
||||
return None
|
||||
key, raw_names = found
|
||||
try:
|
||||
with db.begin_nested():
|
||||
center = db.execute(_CENTER_SQL, {"names": raw_names}).mappings().first()
|
||||
if center is None:
|
||||
return None
|
||||
lat, lon = float(center["lat"]), float(center["lon"])
|
||||
dlat = RADIUS_M / M_PER_DEG_LAT
|
||||
k = math.cos(math.radians(lat))
|
||||
dlon = dlat / k
|
||||
bounds = {
|
||||
"min_lon": lon - dlon,
|
||||
"max_lon": lon + dlon,
|
||||
"min_lat": lat - dlat,
|
||||
"max_lat": lat + dlat,
|
||||
"tol": SIMPLIFY_DEG,
|
||||
}
|
||||
rows = db.execute(_WINDOW_SQL, bounds).mappings().all()
|
||||
except Exception as exc:
|
||||
logger.warning("схема улицы %r не собралась: %s", key, exc)
|
||||
return None
|
||||
|
||||
min_lon, max_lat = bounds["min_lon"], bounds["max_lat"]
|
||||
# Окно квадратное в метрах, значит после поправки cos(широты) его ширина и
|
||||
# высота в единицах viewBox равны: масштаб один на обе оси.
|
||||
scale = VIEWBOX / (2 * dlat)
|
||||
|
||||
def path(chain: list[list[float]]) -> str:
|
||||
out = []
|
||||
for i, (plon, plat) in enumerate(chain):
|
||||
x = round((plon - min_lon) * k * scale, 1)
|
||||
y = round((max_lat - plat) * scale, 1)
|
||||
out.append(f"{'M' if i == 0 else 'L'}{x} {y}")
|
||||
return "".join(out)
|
||||
|
||||
target: list[str] = []
|
||||
roads: list[dict[str, str]] = []
|
||||
water: list[str] = []
|
||||
label_pool: list[tuple[float, str, list[float]]] = []
|
||||
targets = set(raw_names)
|
||||
|
||||
for row in rows:
|
||||
if not row["gj"]:
|
||||
continue
|
||||
chains = [c for c in _parts(json.loads(row["gj"])) if len(c) > 1]
|
||||
if not chains:
|
||||
continue
|
||||
cls = row["road_class"]
|
||||
is_target = row["name"] in targets
|
||||
for chain in chains:
|
||||
d = path(chain)
|
||||
if is_target:
|
||||
target.append(d)
|
||||
elif cls in _WATER_CLASSES:
|
||||
water.append(d)
|
||||
else:
|
||||
roads.append({"c": cls or "residential", "d": d})
|
||||
if row["name"] and not is_target and cls not in _WATER_CLASSES:
|
||||
longest = max(chains, key=_length)
|
||||
label_pool.append((_length(longest), row["name"], longest[len(longest) // 2]))
|
||||
|
||||
if not target:
|
||||
return None
|
||||
|
||||
# Подписи: самые длинные в кадре, по одной на название.
|
||||
labels: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for _, name, point in sorted(label_pool, key=lambda t: -t[0]):
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
labels.append(
|
||||
{
|
||||
"t": name,
|
||||
"x": round((point[0] - min_lon) * k * scale, 1),
|
||||
"y": round((max_lat - point[1]) * scale, 1),
|
||||
}
|
||||
)
|
||||
if len(labels) >= MAX_LABELS:
|
||||
break
|
||||
|
||||
return {
|
||||
"street": raw_names[0],
|
||||
"w": VIEWBOX,
|
||||
"h": VIEWBOX,
|
||||
"target": target,
|
||||
"roads": roads,
|
||||
"water": water,
|
||||
"labels": labels,
|
||||
}
|
||||
|
|
@ -37,6 +37,13 @@
|
|||
----------------------------------------------------------
|
||||
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район +
|
||||
2-к, 54 м², 5 эт.», и никогда не улица с домом.
|
||||
* ТОЧКА НА КАРТЕ — ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ,
|
||||
с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на
|
||||
34 017 сделок с координатой — ≈34 сделки в одной точке, при 2.7% известных
|
||||
номеров дома. Точка честна на масштабе района и улицы и НЕ честна на
|
||||
масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок
|
||||
(миграция 280), потому что докстринг на фронт не едет, а зумить карту
|
||||
будет тот, кто его не читал.
|
||||
* ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений
|
||||
на всю таблицу), поэтому в витрине только «II квартал 2026».
|
||||
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
|
||||
|
|
@ -56,6 +63,13 @@
|
|||
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
||||
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
||||
Неработающая проверка читается как работающая, поэтому её нет.
|
||||
* КАРТА ПОКАЗЫВАЕТ УЛИЦУ, А НЕ ДОМ. `deals.address` — уровня улицы
|
||||
(«Екатеринбург, Краснолесья»), поэтому в строку кладётся схема окна вокруг
|
||||
ЦЕНТРА улицы (`app/services/street_scheme.py`), и в этой схеме намеренно
|
||||
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
|
||||
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий —
|
||||
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
|
||||
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
|
||||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
||||
|
|
@ -74,6 +88,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
|
@ -82,6 +97,8 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.street_scheme import build_street_scheme, load_street_index
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
||||
|
|
@ -102,7 +119,12 @@ NOTE = (
|
|||
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
|
||||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||||
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
|
||||
"строка выглядит как промах оценщика, хотя врёт документ."
|
||||
"строка выглядит как промах оценщика, хотя врёт документ. "
|
||||
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
|
||||
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
|
||||
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
|
||||
"координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
|
||||
"у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
|
||||
)
|
||||
|
||||
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
|
||||
|
|
@ -131,6 +153,10 @@ class ShowcaseRow:
|
|||
fact_rub: int
|
||||
err_pct: float
|
||||
n_analogs: int
|
||||
# Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE).
|
||||
# None штатно: у части сделок координаты нет, подставлять туда нечего.
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
|
||||
|
||||
def completeness(row: ShowcaseRow) -> int:
|
||||
|
|
@ -168,6 +194,8 @@ def build_row(
|
|||
predicted_rub: float | None,
|
||||
fact_ppm2: float,
|
||||
n_analogs: int,
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
) -> ShowcaseRow | None:
|
||||
"""Кандидат → строка витрины, либо None если считать не из чего.
|
||||
|
||||
|
|
@ -175,6 +203,11 @@ def build_row(
|
|||
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
||||
(делить не на что). Величина отклонения причиной НЕ является ни при каких
|
||||
значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
|
||||
|
||||
ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на
|
||||
витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание
|
||||
сделки из-за отсутствия координаты — отбор по признаку, не связанному с
|
||||
качеством оценки, то есть та же порча витрины, что и отбор по ошибке.
|
||||
"""
|
||||
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
|
||||
return None
|
||||
|
|
@ -200,6 +233,8 @@ def build_row(
|
|||
fact_rub=round(fact_rub),
|
||||
err_pct=round(err_pct, 2),
|
||||
n_analogs=n_analogs,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -255,17 +290,41 @@ _INSERT_SQL = text(
|
|||
"""
|
||||
INSERT INTO landing_showcase_deals
|
||||
(district, rooms, area_m2, floor, total_floors, deal_quarter,
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note)
|
||||
predicted_rub, fact_rub, err_pct, n_analogs, note,
|
||||
lat, lon, street_name, street_scheme)
|
||||
VALUES
|
||||
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
|
||||
CAST(:floor AS integer), CAST(:total_floors AS integer),
|
||||
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
|
||||
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text))
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text),
|
||||
CAST(:lat AS double precision), CAST(:lon AS double precision),
|
||||
CAST(:street_name AS text), CAST(:street_scheme AS jsonb))
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict:
|
||||
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
|
||||
|
||||
Считаем после отбора, а не до: схема — это два пространственных запроса на
|
||||
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
|
||||
лишних запросов в чужую базу.
|
||||
|
||||
НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор
|
||||
по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина
|
||||
показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на
|
||||
OSM. Схема — украшение строки, а не причина её показать.
|
||||
"""
|
||||
index = load_street_index(db)
|
||||
out = {}
|
||||
for row in chosen:
|
||||
scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
|
||||
if scheme is not None:
|
||||
out[row.deal_id] = scheme
|
||||
return out
|
||||
|
||||
|
||||
def refresh_landing_showcase_deals(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -328,6 +387,11 @@ def refresh_landing_showcase_deals(
|
|||
predicted_rub=pr.expected_sold_price,
|
||||
fact_ppm2=deal.sold_ppm2,
|
||||
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
|
||||
# Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота
|
||||
# (~60.6). Перепутать местами — это точка в другой стране, и никакой
|
||||
# тип этого не поймает: обе величины float.
|
||||
lat=deal.lat,
|
||||
lon=deal.lon,
|
||||
)
|
||||
if row is None:
|
||||
n_incomplete += 1
|
||||
|
|
@ -335,13 +399,17 @@ def refresh_landing_showcase_deals(
|
|||
candidates.append(row)
|
||||
|
||||
chosen = select_rows(candidates, limit)
|
||||
schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals})
|
||||
|
||||
db.execute(_DELETE_SQL)
|
||||
db.execute(_DELETE_RUNS_SQL)
|
||||
for row in chosen:
|
||||
scheme = schemes.get(row.deal_id)
|
||||
db.execute(
|
||||
_INSERT_SQL,
|
||||
{
|
||||
"street_name": scheme["street"] if scheme else None,
|
||||
"street_scheme": json.dumps(scheme, ensure_ascii=False) if scheme else None,
|
||||
"district": row.district,
|
||||
"rooms": row.rooms,
|
||||
"area_m2": row.area_m2,
|
||||
|
|
@ -353,6 +421,8 @@ def refresh_landing_showcase_deals(
|
|||
"err_pct": row.err_pct,
|
||||
"n_analogs": row.n_analogs,
|
||||
"note": NOTE,
|
||||
"lat": row.lat,
|
||||
"lon": row.lon,
|
||||
},
|
||||
)
|
||||
counters = {
|
||||
|
|
@ -364,6 +434,10 @@ def refresh_landing_showcase_deals(
|
|||
"written": len(chosen),
|
||||
"with_district": sum(1 for r in chosen if r.district is not None),
|
||||
}
|
||||
# Схем — не счётчик в `landing_showcase_runs` намеренно: у КАЖДОЙ строки
|
||||
# витрины street_scheme либо есть, либо null, и это едет на фронт вместе со
|
||||
# строкой. Отдельное число повторяло бы то, что посетитель и так видит.
|
||||
logger.info("схем улиц собрано: %d из %d показанных", len(schemes), len(chosen))
|
||||
db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
|
||||
db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,13 @@
|
|||
-- same-site SERP-origin navigation cadence from PR #2430 -- QRATOR is
|
||||
-- reputation-based, not purely rate-based, but a more human cadence
|
||||
-- reduces the odds of tripping it in the first place.
|
||||
-- max_consecutive_blocks -- 3 (vs Avito's 5): DomClick has no IP-rotation recovery
|
||||
-- on block (one dedicated residential proxy, not a pool) -- aborting
|
||||
-- sooner avoids hammering an already-reputation-damaged proxy/session
|
||||
-- and preserves it for the next scheduled window.
|
||||
-- max_consecutive_blocks -- 3 (vs Avito's 5): aborting sooner avoids hammering an
|
||||
-- already-reputation-damaged proxy/session and preserves it for the
|
||||
-- next scheduled window. NB the original rationale here ("one
|
||||
-- dedicated residential proxy, not a pool") is obsolete: migration
|
||||
-- 253 (#2800) dropped the reservation, and the backfill was wired
|
||||
-- into the shared pool in PR #3222 -- all four nodes carry
|
||||
-- provider_affinity='any'.
|
||||
--
|
||||
-- Seeded with enabled=false: activation is a deliberate, separate manual step after
|
||||
-- this migration deploys and the orchestrator is smoke-tested against a live cookie
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
-- 280: координата сделки в витрине лэндинга — чтобы карта могла показать точку.
|
||||
--
|
||||
-- ЧТО ЭТО ЗА КООРДИНАТА. Не координата ДОМА. Замер по той самой выборке,
|
||||
-- из которой набирается витрина (deals, city='Екатеринбург', deal_date >=
|
||||
-- '2025-01-01'; прод, 2026-08-29):
|
||||
--
|
||||
-- 34 021 сделка, из них 34 017 с координатой (без неё — 4),
|
||||
-- но РАЗЛИЧНЫХ точек (lat, lon) всего 991 → ≈34 сделки на одну точку,
|
||||
-- номер дома в адресе известен у 2.7% сделок.
|
||||
--
|
||||
-- Тридцать четыре сделки в одной точке при 2.7% известных домов — это не дома,
|
||||
-- это центроиды улиц (тот же разбор, из-за которого в миграции 276 нет колонки
|
||||
-- адреса). Отсюда граница честности: точка верна на масштабе района и улицы и
|
||||
-- НЕВЕРНА на масштабе дома. Карта, зумящаяся до дома, покажет 34 разные сделки
|
||||
-- в одном подъезде — это будет враньё интерфейса, а не данных.
|
||||
--
|
||||
-- Поэтому: то же самое написано в `note` каждой строки витрины (note едет на
|
||||
-- фронт вместе с числами, а комментарий к колонке — нет), и это единственное
|
||||
-- место, которое увидит следующий, кто возьмётся зумить карту.
|
||||
--
|
||||
-- NULLABLE — потому что у части сделок координаты нет (4 из 34 021 в замере
|
||||
-- выше). Подставлять туда центр города или район значило бы поставить точку
|
||||
-- там, где её в данных нет. Строка без координаты остаётся на витрине:
|
||||
-- выбрасывать сделку из-за отсутствия точки — это отбор по признаку, не
|
||||
-- связанному с качеством оценки, ровно то, что в этой витрине уже чинили.
|
||||
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): ADD COLUMN на существующей таблице берёт
|
||||
-- ACCESS EXCLUSIVE и без lock_timeout встанет в очередь за чужой сессией,
|
||||
-- утащив за собой запросы приложения.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE landing_showcase_deals
|
||||
ADD COLUMN IF NOT EXISTS lat double precision,
|
||||
ADD COLUMN IF NOT EXISTS lon double precision;
|
||||
|
||||
COMMENT ON COLUMN landing_showcase_deals.lat IS
|
||||
'Широта сделки — ЦЕНТРОИД УЛИЦЫ, не дом. Замер 2026-08-29 по выборке '
|
||||
'витрины (ЕКБ, с 2025-01-01): 991 различная точка на 34 017 сделок с '
|
||||
'координатой (≈34 сделки на точку), номер дома известен у 2.7%. Честно '
|
||||
'на масштабе района/улицы, НЕ честно на масштабе дома. NULL = координаты '
|
||||
'нет (4 сделки из 34 021); строка при этом остаётся на витрине.';
|
||||
|
||||
COMMENT ON COLUMN landing_showcase_deals.lon IS
|
||||
'Долгота сделки — ЦЕНТРОИД УЛИЦЫ, не дом; см. COMMENT на lat: 991 точка '
|
||||
'на 34 017 сделок, дом известен у 2.7%. NULL = координаты нет.';
|
||||
|
||||
COMMIT;
|
||||
86
tradein-mvp/backend/data/sql/281_street_scheme.sql
Normal file
86
tradein-mvp/backend/data/sql/281_street_scheme.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
-- 281_street_scheme.sql
|
||||
-- Улицы ЕКБ на витрине сделок: сторона tradein (PR 2 из 2).
|
||||
--
|
||||
-- ДВА ДЕЙСТВИЯ В ОДНОМ ФАЙЛЕ, И ЭТО ОСОЗНАННО:
|
||||
-- 1) foreign table над `v_tradein_osm_roads_ekb` (миграция gendesign 195);
|
||||
-- 2) колонки `street_name` / `street_scheme` в `landing_showcase_deals`.
|
||||
-- Порознь у них разные зависимости, но смысл один: без геометрии колонки некому
|
||||
-- заполнить, без колонок геометрию некуда положить. Один файл = один
|
||||
-- транзакционный исход: если 195 на той стороне ещё не задеплоена, падает
|
||||
-- ВСЁ и не остаётся полусостояния «колонка есть, данных не будет никогда».
|
||||
-- Файл идемпотентен, поэтому повтор после деплоя 195 доводит дело до конца.
|
||||
--
|
||||
-- ПОЧЕМУ КОЛОНКА В `landing_showcase_deals`, А НЕ ОТДЕЛЬНАЯ ТАБЛИЦА. Схема
|
||||
-- живёт ровно столько же, сколько строка витрины: пересчёт делает
|
||||
-- DELETE+INSERT всей витрины, и схема обязана умереть вместе со своей строкой.
|
||||
-- Отдельная таблица дала бы собственный жизненный цикл (то есть возможность
|
||||
-- пережить свой батч и уехать на фронт рядом с ЧУЖИМИ числами), join в
|
||||
-- публичной ручке и вторую точку отказа — в обмен ни на что: связь строго 1:1.
|
||||
--
|
||||
-- ЧТО ЛЕЖИТ В `street_scheme` — ГОТОВЫЕ SVG-ПУТИ, А НЕ ГЕОМЕТРИЯ. Окно
|
||||
-- 840×840 м в GeoJSON — это 7-8 КБ на сделку плюс проекция в рантайме у
|
||||
-- посетителя. Уже спроецированная схема того же окна — 2.8-2.9 КБ (замер
|
||||
-- 2026-08-29 на «улица Краснолесья» и «улица 8 Марта»).
|
||||
--
|
||||
-- ГРАНИЦА ЧЕСТНОСТИ, ЗАПИСАННАЯ ЗДЕСЬ, А НЕ ТОЛЬКО В ГОЛОВЕ АВТОРА:
|
||||
-- * ЭТО УЛИЦА, А НЕ ДОМ. `deals.address` — уровня улицы («Екатеринбург,
|
||||
-- Краснолесья»), номер дома известен у 2.7% сделок. В схеме намеренно НЕТ
|
||||
-- ни координат окна, ни констант проекции: по ней физически нельзя
|
||||
-- положить (lon, lat) в её систему координат, то есть нарисовать точку
|
||||
-- дома нельзя даже случайно.
|
||||
-- * `street_name` NULLABLE, и NULL — ШТАТНЫЙ СЛУЧАЙ. Нормализация названия
|
||||
-- даёт совпадение с OSM у 550 названий из 654, это 31 410 сделок из
|
||||
-- 34 021 — 92.3% (замер 2026-08-29 по той же выборке, из которой
|
||||
-- набирается витрина). Оставшиеся 8% схемы не получают, и фронт
|
||||
-- показывает район — запасной вариант, который для этого и оставлен.
|
||||
--
|
||||
-- Зависимости:
|
||||
-- - 060_postgres_fdw_extension.sql (SERVER gendesign_remote + USER MAPPING,
|
||||
-- пароль подставляется на старте бэкенда). Сервер объявлен с
|
||||
-- OPTIONS (extensions 'postgis') — именно поэтому `&&` с конвертом уходит
|
||||
-- на ту сторону в GiST-индекс, а не тянет сюда десять тысяч линий.
|
||||
-- - gendesign: вьюха public.v_tradein_osm_roads_ekb + GRANT SELECT роли
|
||||
-- tradein_fdw_reader (миграция 195, деплоится ПЕРВОЙ).
|
||||
-- - 276_landing_showcase_deals.sql (сама витрина).
|
||||
--
|
||||
-- Идемпотентна: DROP FOREIGN TABLE IF EXISTS + ADD COLUMN IF NOT EXISTS.
|
||||
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): ALTER TABLE берёт ACCESS EXCLUSIVE и, ожидая его
|
||||
-- за чужой сессией, встаёт в очередь ПЕРЕД запросами приложения.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE landing_showcase_deals
|
||||
ADD COLUMN IF NOT EXISTS street_name text,
|
||||
ADD COLUMN IF NOT EXISTS street_scheme jsonb;
|
||||
|
||||
COMMENT ON COLUMN landing_showcase_deals.street_name IS
|
||||
'Улица сделки как она называется в OSM. NULL — штатно: адрес не сматчился '
|
||||
'(8% сделок), схемы нет, фронт показывает район. Это УЛИЦА, а не дом: '
|
||||
'номер дома известен у 2.7% сделок.';
|
||||
|
||||
COMMENT ON COLUMN landing_showcase_deals.street_scheme IS
|
||||
'Готовые SVG-пути окна 840x840 м вокруг центра улицы: {street, w, h, '
|
||||
'target[], roads[{c,d}], water[], labels[{t,x,y}]}. Координат окна и '
|
||||
'констант проекции в ней НЕТ намеренно — по схеме нельзя положить точку '
|
||||
'дома, которого в данных нет. Зданий нет тоже (cad_buildings покрывает '
|
||||
'город на 18 307 контуров и в центре показывает пустые кварталы).';
|
||||
|
||||
DROP FOREIGN TABLE IF EXISTS gendesign_osm_roads_ekb;
|
||||
|
||||
CREATE FOREIGN TABLE gendesign_osm_roads_ekb (
|
||||
name text,
|
||||
road_class text,
|
||||
geom geometry(Geometry, 4326)
|
||||
)
|
||||
SERVER gendesign_remote
|
||||
OPTIONS (schema_name 'public', table_name 'v_tradein_osm_roads_ekb');
|
||||
|
||||
COMMENT ON FOREIGN TABLE gendesign_osm_roads_ekb IS
|
||||
'Живой срез gendesign.osm_noise_sources_ekb (улицы и вода ЕКБ). Выгрузка '
|
||||
'фильтрованная («источники шума»): именованные улицы покрыты хорошо, '
|
||||
'дворовые и служебные проезды отсутствуют. Читает '
|
||||
'app/services/street_scheme.py при пересчёте витрины лэндинга; в публичной '
|
||||
'ручке НЕ используется — там читаются готовые схемы из landing_showcase_deals.';
|
||||
|
||||
COMMIT;
|
||||
108
tradein-mvp/backend/scripts/export_ekb_districts_svg.py
Normal file
108
tradein-mvp/backend/scripts/export_ekb_districts_svg.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Экспорт границ районов Екатеринбурга в SVG-пути для лэндинга МЕРЫ.
|
||||
|
||||
ЗАЧЕМ ГЕНЕРАТОР, А НЕ РУЧНОЙ БЛОБ. Файл
|
||||
`frontend/src/app/mera-public/_components/v3/ekb-districts.ts` — данные, а у
|
||||
данных обязано быть проверяемое происхождение. Этот скрипт единственный
|
||||
источник того файла: запусти его и получишь тот же результат.
|
||||
|
||||
ПОЧЕМУ ГЕОМЕТРИЯ ЕДЕТ СТАТИКОЙ, А НЕ ТАЙЛАМИ.
|
||||
1. Публичное дерево `app/mera-public/**` держится без единого внешнего запроса
|
||||
(см. шапку layout.tsx: шрифты self-hosted, Leaflet с unpkg сюда намеренно не
|
||||
тянули). Тайловый провайдер — это внешний CDN на каждой загрузке.
|
||||
2. Тайлы утекают провайдеру IP посетителя и то, ЧТО он смотрит, — на странице,
|
||||
которая обещает «без звонков и регистрации». Своя геометрия такого не делает.
|
||||
3. Границы районов не меняются годами: раздавать их запросом в рантайме незачем.
|
||||
|
||||
ТОЧНОСТЬ. Упрощение 0.0012° (~70-130 м на широте Екатеринбурга) выбрано под
|
||||
масштаб карточки: она показывает район целиком, где такая ошибка меньше
|
||||
толщины линии. Для более крупного масштаба этот файл не годится — перегенерируй
|
||||
с меньшим допуском и проверь размер.
|
||||
|
||||
ПРОЕКЦИЯ. Равнопромежуточная с поправкой cos(широты): на масштабе одного города
|
||||
искажение незаметно, а взамен фронту не нужна ни библиотека, ни рантайм-математика
|
||||
сложнее двух умножений. Константы проекции едут вместе с путями, чтобы точку по
|
||||
(lon, lat) можно было положить в ту же систему координат.
|
||||
|
||||
Запуск (только чтение):
|
||||
docker exec tradein-backend python scripts/export_ekb_districts_svg.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
|
||||
# Допуск упрощения в градусах. Смена значения обязана сопровождаться замером
|
||||
# длины путей: файл едет в бандл каждому посетителю.
|
||||
SIMPLIFY_DEG = 0.0012
|
||||
# Ширина системы координат. Высота считается из формы города.
|
||||
VIEWBOX_WIDTH = 1000.0
|
||||
|
||||
SQL = """
|
||||
SELECT district_name,
|
||||
ST_AsGeoJSON(ST_SimplifyPreserveTopology(geom, %(tol)s)) AS gj
|
||||
FROM gendesign_ekb_districts_geom
|
||||
ORDER BY district_name
|
||||
"""
|
||||
|
||||
|
||||
def _rings(geojson: dict[str, Any]) -> list[list[list[float]]]:
|
||||
"""Полигон и мультиполигон — к одному виду: список колец."""
|
||||
if geojson["type"] == "Polygon":
|
||||
return geojson["coordinates"]
|
||||
return [ring for poly in geojson["coordinates"] for ring in poly]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
|
||||
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(SQL, {"tol": SIMPLIFY_DEG})
|
||||
rows = [(name, _rings(json.loads(gj))) for name, gj in cur.fetchall()]
|
||||
|
||||
points = [p for _, rings in rows for ring in rings for p in ring]
|
||||
min_lon = min(p[0] for p in points)
|
||||
max_lat = max(p[1] for p in points)
|
||||
lat_mid = (min(p[1] for p in points) + max_lat) / 2
|
||||
k = math.cos(math.radians(lat_mid))
|
||||
|
||||
width_deg = max((p[0] - min_lon) * k for p in points)
|
||||
scale = VIEWBOX_WIDTH / width_deg
|
||||
height = round(max((max_lat - p[1]) for p in points) * scale, 1)
|
||||
|
||||
def path(rings: list[list[list[float]]]) -> str:
|
||||
out: list[str] = []
|
||||
for ring in rings:
|
||||
for i, (lon, lat) in enumerate(ring):
|
||||
x = round((lon - min_lon) * k * scale, 1)
|
||||
y = round((max_lat - lat) * scale, 1)
|
||||
out.append(f"{'M' if i == 0 else 'L'}{x} {y}")
|
||||
out.append("Z")
|
||||
return "".join(out)
|
||||
|
||||
districts = [{"name": name, "d": path(rings)} for name, rings in rows]
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"meta": {
|
||||
"minLon": min_lon,
|
||||
"maxLat": max_lat,
|
||||
"k": round(k, 10),
|
||||
"scale": round(scale, 6),
|
||||
"width": VIEWBOX_WIDTH,
|
||||
"height": height,
|
||||
"simplifyDeg": SIMPLIFY_DEG,
|
||||
},
|
||||
"districts": districts,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -36,7 +36,9 @@ import json
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||
from scraper_kit.providers.domclick.detail import (
|
||||
|
|
@ -438,6 +440,61 @@ async def test_fetch_detail_transport_failure_does_not_report_ban() -> None:
|
|||
bf.report_ban.assert_not_called()
|
||||
|
||||
|
||||
# ── #3239: бан-страница ОТ САЙДКАРА — тоже генуинный маркер-детект ────────────
|
||||
#
|
||||
# #3237 перенёс распознавание статического отказа Домклика из parse_detail_html в
|
||||
# сайдкар (иначе незавершённое QRATOR-рукопожатие считалось блоком). Отказ стал
|
||||
# приезжать обычной 500-кой и попадать в транспортную ветку, где report_ban по
|
||||
# построению НЕ зовётся → ban_kind 'unknown' вместо 'platform' → ротация IP не
|
||||
# запускалась вовсе. Тесты ниже держат обе ветки РАЗДЕЛЬНО: подтверждённый отказ
|
||||
# банит, голый транспортный сбой — нет (тест выше).
|
||||
|
||||
|
||||
def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPageError:
|
||||
return SidecarBanPageError(
|
||||
"Server error '500' | tradein-browser: BanPageDetectedError: статический отказ",
|
||||
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||
response=httpx.Response(500, request=httpx.Request("POST", "http://x/fetch")),
|
||||
upstream_status=upstream_status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
|
||||
bf = MagicMock()
|
||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
||||
with pytest.raises(DomClickBlockedError):
|
||||
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||
bf.report_ban.assert_called_once()
|
||||
assert _CARD_URL in bf.report_ban.call_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_detail_sidecar_ban_page_carries_upstream_status() -> None:
|
||||
"""Статус берётся ИЗ ИСКЛЮЧЕНИЯ, а не из last_response_status.
|
||||
|
||||
Несущая деталь #3239: на error-пути fetch() обнуляет last_response_status,
|
||||
поэтому у Домклика (отказ приходит с 401) классификатор без этого поля
|
||||
поставил бы 'unknown'. MagicMock отдаёт last_response_status как Mock —
|
||||
если бы код читал его, ассерт ниже упал бы.
|
||||
"""
|
||||
bf = MagicMock()
|
||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(401))
|
||||
with pytest.raises(DomClickBlockedError) as excinfo:
|
||||
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||
assert excinfo.value.status == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_detail_sidecar_ban_page_without_status_stays_none() -> None:
|
||||
"""Сайдкар не отдал статус → None, а не выдуманный код (#2764)."""
|
||||
bf = MagicMock()
|
||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(None))
|
||||
with pytest.raises(DomClickBlockedError) as excinfo:
|
||||
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||
assert excinfo.value.status is None
|
||||
|
||||
|
||||
# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ─────────────
|
||||
|
||||
|
||||
|
|
|
|||
294
tradein-mvp/backend/tests/test_3234_house_facts_fallback.py
Normal file
294
tradein-mvp/backend/tests/test_3234_house_facts_fallback.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"""#3234: `houses` как fallback-источник total_floors/year_built/house_type в estimate.
|
||||
|
||||
Приоритет: payload (пользователь) → houses (наш справочник, локальный, ~87%
|
||||
покрытия года / ~98% этажности на проде) → OSM Overpass (get_house_metadata,
|
||||
внешний вызов с таймаутом, house_metadata-кэш — всего 68 строк на базу).
|
||||
|
||||
payload НИКОГДА не переопределяется: houses/OSM заполняют только то, что
|
||||
payload не задал. get_house_metadata должен вызываться ТОЛЬКО если после
|
||||
payload+houses что-то всё ещё неизвестно (экономия внешнего вызова).
|
||||
|
||||
year_built из houses обязан пройти через тот же _sanitize_build_year, что и
|
||||
payload/OSM (Mera-audit 2026-08-02, прод-инцидент year_built=1829 — см.
|
||||
test_estimator_implausible_year.py).
|
||||
|
||||
Два уровня тестов:
|
||||
1. _lookup_house_facts() изолированно — маршрутизация SQL (house_id vs
|
||||
геофолбэк) + best-effort на ошибке БД (без сети).
|
||||
2. estimate_quality() целиком, offline — зеркалит паттерн
|
||||
test_estimator_client_coords.py: все внешние вызовы замоканы,
|
||||
_fetch_analogs захватывает kwargs, чтобы проверить что именно уехало
|
||||
вниз по пайплайну.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
# Settings требует DATABASE_URL при импорте app.core.config — до любого app-импорта.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
# ── Level 1: _lookup_house_facts() изолированно (без estimate_quality) ─────
|
||||
|
||||
|
||||
def test_lookup_house_facts_uses_house_id_when_present() -> None:
|
||||
"""target_house_id задан → SQL идёт по id, геофолбэк (ST_DWithin) не участвует."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"id": 42,
|
||||
"total_floors": 9,
|
||||
"year_built": 1975,
|
||||
"house_type": "panel",
|
||||
"material_walls": None,
|
||||
}
|
||||
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
|
||||
|
||||
assert facts is not None
|
||||
assert (facts.house_id, facts.total_floors, facts.year_built, facts.house_type) == (
|
||||
42,
|
||||
9,
|
||||
1975,
|
||||
"panel",
|
||||
)
|
||||
sql_text = str(db.execute.call_args.args[0])
|
||||
assert "WHERE id = " in sql_text
|
||||
assert "ST_DWithin" not in sql_text
|
||||
|
||||
|
||||
def test_lookup_house_facts_falls_back_to_geo_radius_when_no_house_id() -> None:
|
||||
"""target_house_id=None → SQL идёт по ST_DWithin(60м) + ближайший к lat/lon."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"id": 7,
|
||||
"total_floors": 5,
|
||||
"year_built": 1968,
|
||||
"house_type": "brick",
|
||||
"material_walls": None,
|
||||
}
|
||||
facts = _lookup_house_facts(db, target_house_id=None, lat=56.838, lon=60.595)
|
||||
|
||||
assert facts is not None
|
||||
assert facts.house_id == 7
|
||||
sql_text = str(db.execute.call_args.args[0])
|
||||
assert "ST_DWithin" in sql_text
|
||||
assert "60" in sql_text
|
||||
|
||||
|
||||
def test_lookup_house_facts_no_key_returns_none_without_query() -> None:
|
||||
"""Ни house_id, ни lat/lon — запроса к БД нет вообще, сразу None."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
facts = _lookup_house_facts(db, target_house_id=None, lat=None, lon=None)
|
||||
|
||||
assert facts is None
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_lookup_house_facts_db_error_is_graceful() -> None:
|
||||
"""Любая ошибка БД → None + rollback, estimate не должен падать (best-effort)."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
|
||||
|
||||
assert facts is None
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
# ── Level 2: estimate_quality() целиком, offline ────────────────────────────
|
||||
|
||||
|
||||
def _make_fake_geo():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload(*, year_built=None, house_type=None, total_floors=None):
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Учителей, 18",
|
||||
area_m2=38.8,
|
||||
rooms=1,
|
||||
floor=4,
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
year_built=year_built,
|
||||
house_type=house_type,
|
||||
total_floors=total_floors,
|
||||
)
|
||||
|
||||
|
||||
def _house_facts(*, house_id=777, total_floors=9, year_built=1975, house_type="panel"):
|
||||
from app.services.estimator import _HouseFacts
|
||||
|
||||
return _HouseFacts(
|
||||
house_id=house_id,
|
||||
total_floors=total_floors,
|
||||
year_built=year_built,
|
||||
house_type=house_type,
|
||||
material_walls=None,
|
||||
)
|
||||
|
||||
|
||||
def _house_meta(*, total_floors, year_built, house_type):
|
||||
from app.services.house_metadata import HouseMetadata
|
||||
|
||||
return HouseMetadata(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
year_built=year_built,
|
||||
total_floors=total_floors,
|
||||
house_type=house_type,
|
||||
total_units=None,
|
||||
source="osm",
|
||||
)
|
||||
|
||||
|
||||
def _run_estimate(payload, *, house_facts, house_meta):
|
||||
"""Прогоняет estimate_quality offline. Возвращает (result, _fetch_analogs mock,
|
||||
get_house_metadata mock) — mocks выжившие после await для инспекции call_args."""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
||||
house_facts_mock = MagicMock(return_value=house_facts)
|
||||
house_meta_mock = AsyncMock(return_value=house_meta)
|
||||
analogs_mock = MagicMock(return_value=([], False, "W"))
|
||||
|
||||
async def _go():
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in (
|
||||
patch("app.services.estimator.geocode", new=geocode_mock),
|
||||
patch("app.services.estimator._lookup_house_facts", new=house_facts_mock),
|
||||
patch("app.services.estimator.get_house_metadata", new=house_meta_mock),
|
||||
patch("app.services.estimator._fetch_analogs", new=analogs_mock),
|
||||
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)),
|
||||
):
|
||||
stack.enter_context(cm)
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
result = anyio.run(_go)
|
||||
return result, analogs_mock, house_meta_mock
|
||||
|
||||
|
||||
def _floors_seen(mock) -> set:
|
||||
return {c.kwargs["total_floors"] for c in mock.call_args_list}
|
||||
|
||||
|
||||
def _years_seen(mock) -> set:
|
||||
return {c.kwargs["year_built"] for c in mock.call_args_list}
|
||||
|
||||
|
||||
def _house_types_seen(mock) -> set:
|
||||
return {c.kwargs["house_type"] for c in mock.call_args_list}
|
||||
|
||||
|
||||
def test_empty_payload_uses_houses_facts() -> None:
|
||||
"""payload пустой, houses знает всё → его значения уезжают в _fetch_analogs."""
|
||||
payload = _make_payload()
|
||||
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
assert analogs_mock.call_args_list, "estimate должен вызвать _fetch_analogs хотя бы раз"
|
||||
assert _floors_seen(analogs_mock) == {9}
|
||||
assert _years_seen(analogs_mock) == {1975}
|
||||
assert _house_types_seen(analogs_mock) == {"panel"}
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_payload_wins_over_conflicting_houses_facts() -> None:
|
||||
"""payload задан, houses знает ДРУГОЕ → побеждает payload (user главнее houses)."""
|
||||
payload = _make_payload(year_built=2015, house_type="brick", total_floors=5)
|
||||
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
assert _floors_seen(analogs_mock) == {5}
|
||||
assert _years_seen(analogs_mock) == {2015}
|
||||
assert _house_types_seen(analogs_mock) == {"brick"}
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_neither_payload_nor_houses_falls_back_to_osm() -> None:
|
||||
"""Ни payload, ни houses ничего не знают → OSM get_house_metadata вызывается
|
||||
(старое поведение сохранено, houses тут ничем не помогает)."""
|
||||
payload = _make_payload()
|
||||
meta = _house_meta(total_floors=5, year_built=1968, house_type="brick")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=None, house_meta=meta
|
||||
)
|
||||
|
||||
house_meta_mock.assert_awaited()
|
||||
assert _floors_seen(analogs_mock) == {5}
|
||||
assert _years_seen(analogs_mock) == {1968}
|
||||
assert _house_types_seen(analogs_mock) == {"brick"}
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_payload_and_houses_cover_everything_skips_osm() -> None:
|
||||
"""payload частично + houses добивает остальное → OSM НЕ вызывается (экономия
|
||||
внешнего бюджетируемого вызова, п.5 задачи)."""
|
||||
payload = _make_payload(year_built=2015) # house_type/total_floors придут из houses
|
||||
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert _floors_seen(analogs_mock) == {9}
|
||||
assert _years_seen(analogs_mock) == {2015} # payload побеждает для года
|
||||
assert _house_types_seen(analogs_mock) == {"panel"}
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_implausible_year_from_houses_is_sanitized() -> None:
|
||||
"""houses отдал неправдоподобный год (1829, прод-паттерн) → _sanitize_build_year
|
||||
отбивает его в None; total_floors/house_type из houses при этом всё равно
|
||||
применяются (год — независимый гейт, не блокирует остальные поля)."""
|
||||
payload = _make_payload()
|
||||
facts = _house_facts(total_floors=9, year_built=1829, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
assert _years_seen(analogs_mock) == {None}
|
||||
assert _floors_seen(analogs_mock) == {9}
|
||||
assert _house_types_seen(analogs_mock) == {"panel"}
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
|
@ -27,8 +27,14 @@ from __future__ import annotations
|
|||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
|
||||
from scraper_kit.browser_fetcher import (
|
||||
BrowserFetcher,
|
||||
SidecarBanPageError,
|
||||
_raise_for_sidecar_status,
|
||||
ban_kind_from_status,
|
||||
)
|
||||
|
||||
|
||||
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
|
||||
|
|
@ -158,3 +164,73 @@ def test_ban_kind_values_fit_scrape_runs_check() -> None:
|
|||
allowed = {"platform", "infra", "unknown", None}
|
||||
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
|
||||
assert ban_kind_from_status(status) in allowed
|
||||
|
||||
|
||||
# ── #3239: подтверждённая бан-страница отличима от прочих 500-ок ──────────────
|
||||
#
|
||||
# Тип, а не подстрока: _raise_for_sidecar_status обрезает тело до 300 символов, и
|
||||
# формулировка отказа у сайдкара менялась дважды за месяц. upstream_status нужен
|
||||
# отдельным полем — на error-пути fetch() обнуляет last_response_status, и без
|
||||
# него у DomClick (отказ приходит с 401) диагноз стал бы 'unknown' вместо
|
||||
# 'platform', то есть ротация IP не запустилась бы вовсе.
|
||||
|
||||
|
||||
def _sidecar_response(body: Any) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
500, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||
)
|
||||
|
||||
|
||||
def test_ban_page_body_raises_typed_error_with_upstream_status() -> None:
|
||||
resp = _sidecar_response(
|
||||
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401}
|
||||
)
|
||||
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert excinfo.value.upstream_status == 401
|
||||
|
||||
|
||||
def test_ban_page_error_is_httpx_status_error() -> None:
|
||||
"""Подкласс — иначе retry-политика fetch() и ловля у прочих поставщиков сломались бы."""
|
||||
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": 403})
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_raise_for_sidecar_status(resp)
|
||||
|
||||
|
||||
def test_plain_500_stays_plain_status_error() -> None:
|
||||
resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"})
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert not isinstance(excinfo.value, SidecarBanPageError)
|
||||
|
||||
|
||||
def test_ban_page_without_status_gives_none() -> None:
|
||||
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": None})
|
||||
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert excinfo.value.upstream_status is None
|
||||
|
||||
|
||||
def test_ban_page_true_as_bool_status_is_rejected() -> None:
|
||||
"""JSON true не должен уехать статусом: bool — подтип int (та же ловушка, что в #3196)."""
|
||||
resp = _sidecar_response({"error": "x", "ban_page": True, "status": True})
|
||||
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert excinfo.value.upstream_status is None
|
||||
|
||||
|
||||
def test_non_json_error_body_does_not_break() -> None:
|
||||
resp = httpx.Response(
|
||||
500, text="not json", request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert not isinstance(excinfo.value, SidecarBanPageError)
|
||||
|
||||
|
||||
def test_success_response_raises_nothing() -> None:
|
||||
resp = httpx.Response(
|
||||
200, json={"html": "<html/>", "status": 200},
|
||||
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||
)
|
||||
_raise_for_sidecar_status(resp)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ def _build(**over: object) -> ShowcaseRow | None:
|
|||
"predicted_rub": 5_000_000.0,
|
||||
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
|
||||
"n_analogs": 30,
|
||||
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
|
||||
"lon": 60.6055, # поэтому перестановка ловится по значению.
|
||||
}
|
||||
kwargs.update(over)
|
||||
return build_row(**kwargs) # type: ignore[arg-type]
|
||||
|
|
@ -169,3 +171,34 @@ def test_unknown_quarter_is_not_invented() -> None:
|
|||
assert _build(deal_date=None) is None
|
||||
assert quarter_label(None) is None
|
||||
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026"
|
||||
|
||||
|
||||
# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ──
|
||||
|
||||
|
||||
def test_coords_are_carried_through_and_not_swapped() -> None:
|
||||
"""Широта остаётся широтой. Обе величины float, тип перестановку не поймает.
|
||||
|
||||
Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` — тест
|
||||
покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме.
|
||||
"""
|
||||
row = _build()
|
||||
assert row is not None
|
||||
assert row.lat == 56.8386, "широта уехала не в ту колонку"
|
||||
assert row.lon == 60.6055, "долгота уехала не в ту колонку"
|
||||
|
||||
|
||||
def test_row_without_coords_stays_on_showcase() -> None:
|
||||
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
|
||||
|
||||
Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не
|
||||
связанному с качеством оценки: та же порча витрины, что и отбор по
|
||||
величине ошибки, просто по другому полю. Карта переживёт строку без точки.
|
||||
|
||||
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
|
||||
None` — тест покраснеет на None вместо строки.
|
||||
"""
|
||||
row = _build(lat=None, lon=None)
|
||||
assert row is not None, "сделка выброшена из витрины за отсутствие координаты"
|
||||
assert row.lat is None and row.lon is None, "координата придумана там, где её нет"
|
||||
assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали"
|
||||
|
|
|
|||
|
|
@ -179,6 +179,20 @@ _SHOWCASE_ROW = {
|
|||
"err_pct": 3.39,
|
||||
"n_analogs": 41,
|
||||
"note": "не point-in-time",
|
||||
"street_name": "улица Краснолесья",
|
||||
"street_scheme": {
|
||||
"street": "улица Краснолесья",
|
||||
"w": 1000,
|
||||
"h": 1000,
|
||||
"target": ["M10 20L30 40"],
|
||||
"roads": [{"c": "primary", "d": "M0 0L100 100"}],
|
||||
"water": [],
|
||||
"labels": [{"t": "улица Чкалова", "x": 431.2, "y": 88.0}],
|
||||
},
|
||||
# Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 —
|
||||
# величины намеренно непохожие, чтобы перестановка ловилась по значению.
|
||||
"lat": 56.8386,
|
||||
"lon": 60.6055,
|
||||
}
|
||||
_SHOWCASE_RUN = {
|
||||
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
|
||||
|
|
@ -217,6 +231,62 @@ def test_anonymous_gets_showcase(client: TestClient) -> None:
|
|||
assert body["deals"][0]["fact_rub"] == 5_900_000
|
||||
# Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк.
|
||||
assert "address" not in body["deals"][0]
|
||||
# Схема улицы едет готовыми SVG-путями, а не геометрией.
|
||||
assert body["deals"][0]["street_name"] == "улица Краснолесья"
|
||||
assert body["deals"][0]["street_scheme"]["target"] == ["M10 20L30 40"]
|
||||
|
||||
|
||||
def test_showcase_row_without_street_stays_null(client: TestClient) -> None:
|
||||
"""Несматчившаяся улица — 8% сделок — проходит насквозь как null.
|
||||
|
||||
Ни подстановки соседней улицы, ни падения: фронт по этому null показывает
|
||||
район. Ломать так: сделать street_name/street_scheme обязательными в
|
||||
`ShowcaseDeal` — тест покраснеет на 500.
|
||||
"""
|
||||
row = {**_SHOWCASE_ROW, "street_name": None, "street_scheme": None}
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
|
||||
|
||||
body = client.get(f"{PREFIX}/showcase").json()
|
||||
assert body["deals"][0]["street_name"] is None
|
||||
assert body["deals"][0]["street_scheme"] is None
|
||||
|
||||
|
||||
def test_showcase_carries_coords_without_swapping_them(client: TestClient) -> None:
|
||||
"""Координата доезжает до JSON и не переставлена местами.
|
||||
|
||||
Обе величины float, поэтому перестановку не поймает ни схема, ни тип —
|
||||
только значение: 56.8 (широта ЕКБ) против 60.6 (долгота). Ломать так:
|
||||
поменять местами `lat=` и `lon=` в сборке `ShowcaseDeal` — покраснеет здесь.
|
||||
|
||||
Точка при этом — ЦЕНТРОИД УЛИЦЫ, а не дом (991 различная координата на
|
||||
34 017 сделок выборки); ровно это написано в `note`, который едет рядом.
|
||||
"""
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db()
|
||||
|
||||
deal = client.get(f"{PREFIX}/showcase").json()["deals"][0]
|
||||
assert deal["lat"] == 56.8386, "широта приехала не в то поле"
|
||||
assert deal["lon"] == 60.6055, "долгота приехала не в то поле"
|
||||
|
||||
|
||||
def test_showcase_row_without_coords_reaches_the_client_as_null(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Сделка без координаты доезжает до ответа целиком, с lat=lon=null.
|
||||
|
||||
Карта переживёт строку без точки; выбрасывание такой сделки было бы
|
||||
отбором по признаку, не связанному с качеством оценки. Подстановка центра
|
||||
города вместо отсутствующей точки — тем более враньё.
|
||||
|
||||
Ломать так: заменить null на любую подстановку или отфильтровать строку в
|
||||
ручке — покраснеет по значению, а не по форме.
|
||||
"""
|
||||
row = {**_SHOWCASE_ROW, "lat": None, "lon": None}
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
|
||||
|
||||
deals = client.get(f"{PREFIX}/showcase").json()["deals"]
|
||||
assert len(deals) == 1, "строка без координаты выброшена из витрины"
|
||||
assert deals[0]["lat"] is None and deals[0]["lon"] is None
|
||||
assert deals[0]["fact_rub"] == 5_900_000, "остальные величины строки не пострадали"
|
||||
|
||||
|
||||
def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
|
||||
|
|
|
|||
214
tradein-mvp/backend/tests/test_street_scheme.py
Normal file
214
tradein-mvp/backend/tests/test_street_scheme.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Схема улицы для витрины лэндинга: нормализация, отказ и размер.
|
||||
|
||||
Каждый тест здесь сломан вручную перед коммитом и покраснел — иначе он не
|
||||
проверка, а декорация. Что именно ловится:
|
||||
* нормализация названий на РЕАЛЬНЫХ строках `deals.address` и OSM;
|
||||
* несматчившаяся улица не роняет пересчёт (штатные 8%);
|
||||
* схема не раздувается: без округления координат она вырастает в разы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import street_scheme as ss
|
||||
|
||||
|
||||
# ── Нормализация ────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
# Реальные значения deals.address (прод, ЕКБ).
|
||||
("Екатеринбург, Краснолесья", "краснолесья"),
|
||||
("Екатеринбург, 8 Марта", "8 марта"),
|
||||
("Екатеринбург, 22 Партсъезда", "22 партсъезда"),
|
||||
("Екатеринбург, Академика Сахарова", "академика сахарова"),
|
||||
# Перекрёсток: берём ПЕРВУЮ улицу (обоснование — в докстринге функции).
|
||||
("Екатеринбург, Челюскинцев/Шейнкмана", "челюскинцев"),
|
||||
("Екатеринбург, Шаумяна/Чкалова", "шаумяна"),
|
||||
("Екатеринбург, пр-кт Космонавтов/ Ильича", "космонавтов"),
|
||||
# Реальные значения name в osm_noise_sources_ekb.
|
||||
("улица Красных Командиров", "красных командиров"),
|
||||
("Амбулаторная улица", "амбулаторная"),
|
||||
("проспект Космонавтов", "космонавтов"),
|
||||
("Сибирский тракт", "сибирский"),
|
||||
# ё → е: адрес и OSM пишут по-разному, улица одна.
|
||||
("Екатеринбург, Королёва", "королева"),
|
||||
("улица Королева", "королева"),
|
||||
# Пусто — значит пусто, а не выдуманная улица.
|
||||
(None, None),
|
||||
("", None),
|
||||
("Екатеринбург, улица", None),
|
||||
],
|
||||
)
|
||||
def test_normalize_street(raw: str | None, expected: str | None) -> None:
|
||||
assert ss.normalize_street(raw) == expected
|
||||
|
||||
|
||||
def test_normalize_matches_address_to_osm_name() -> None:
|
||||
"""Смысл нормализации: адрес сделки и название OSM сходятся в один ключ."""
|
||||
assert ss.normalize_street("Екатеринбург, Краснолесья") == ss.normalize_street(
|
||||
"улица Краснолесья"
|
||||
)
|
||||
assert ss.normalize_street("Екатеринбург, Королёва") == ss.normalize_street("улица Королева")
|
||||
|
||||
|
||||
# ── Тестовый двойник сессии ─────────────────────────────────────────────────
|
||||
class _Result:
|
||||
def __init__(self, rows: list) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list:
|
||||
return self._rows
|
||||
|
||||
def mappings(self) -> _Result:
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Отвечает на три запроса модуля. `fail` — имитация недоступной вьюхи."""
|
||||
|
||||
def __init__(self, *, names: list[str], center=None, window=None, fail: bool = False) -> None:
|
||||
self.names = names
|
||||
self.center = center
|
||||
self.window = window or []
|
||||
self.fail = fail
|
||||
self.calls = 0
|
||||
|
||||
@contextlib.contextmanager
|
||||
def begin_nested(self):
|
||||
yield
|
||||
|
||||
def execute(self, stmt, params=None):
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
raise RuntimeError("permission denied for view v_tradein_osm_roads_ekb")
|
||||
sql = str(stmt)
|
||||
if "DISTINCT name" in sql:
|
||||
return _Result([(n,) for n in self.names])
|
||||
if "ST_ClosestPoint" in sql:
|
||||
return _Result([self.center] if self.center else [])
|
||||
return _Result(self.window)
|
||||
|
||||
|
||||
def _line(n: int, lon0: float = 60.53, lat0: float = 56.78) -> str:
|
||||
pts = [[round(lon0 + i * 0.0002, 6), round(lat0 + i * 0.0001, 6)] for i in range(n)]
|
||||
return json.dumps({"type": "LineString", "coordinates": pts})
|
||||
|
||||
|
||||
# ── Несматчившаяся улица — штатный случай, а не авария ──────────────────────
|
||||
def test_unmatched_street_returns_none_without_touching_db() -> None:
|
||||
db = _FakeDB(names=["улица Краснолесья"])
|
||||
index = ss.load_street_index(db)
|
||||
before = db.calls
|
||||
assert ss.build_street_scheme(db, index, "Екатеринбург, Такой Улицы Нет") is None
|
||||
# Ни одного лишнего запроса: не сматчилось — не ходим в чужую базу.
|
||||
assert db.calls == before
|
||||
|
||||
|
||||
def test_missing_address_returns_none() -> None:
|
||||
db = _FakeDB(names=["улица Краснолесья"])
|
||||
index = ss.load_street_index(db)
|
||||
assert ss.build_street_scheme(db, index, None) is None
|
||||
|
||||
|
||||
def test_unavailable_view_gives_empty_index_not_exception() -> None:
|
||||
"""Грант на той стороне терялся уже трижды. Пересчёт обязан это пережить."""
|
||||
db = _FakeDB(names=[], fail=True)
|
||||
index = ss.load_street_index(db)
|
||||
assert index.by_norm == {}
|
||||
assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
|
||||
|
||||
|
||||
def test_query_failure_gives_none_not_exception() -> None:
|
||||
class _FailOnSecond(_FakeDB):
|
||||
def execute(self, stmt, params=None):
|
||||
if "DISTINCT name" not in str(stmt):
|
||||
raise RuntimeError("FDW connection refused")
|
||||
return super().execute(stmt, params)
|
||||
|
||||
db = _FailOnSecond(names=["улица Краснолесья"])
|
||||
index = ss.load_street_index(db)
|
||||
assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
|
||||
|
||||
|
||||
# ── Форма схемы и её размер ─────────────────────────────────────────────────
|
||||
def _scheme_with(rows: list[dict]) -> dict:
|
||||
db = _FakeDB(
|
||||
names=["улица Краснолесья"],
|
||||
center={"lon": 60.537283, "lat": 56.784694},
|
||||
window=rows,
|
||||
)
|
||||
index = ss.load_street_index(db)
|
||||
scheme = ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья")
|
||||
assert scheme is not None
|
||||
return scheme
|
||||
|
||||
|
||||
def test_scheme_shape() -> None:
|
||||
scheme = _scheme_with(
|
||||
[
|
||||
{"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(5)},
|
||||
{"name": "улица Чкалова", "road_class": "primary", "gj": _line(4, 60.534)},
|
||||
{"name": "Патрушиха", "road_class": "river", "gj": _line(3, 60.535)},
|
||||
]
|
||||
)
|
||||
assert scheme["street"] == "улица Краснолесья"
|
||||
assert scheme["w"] == scheme["h"] == ss.VIEWBOX
|
||||
assert len(scheme["target"]) == 1 and scheme["target"][0].startswith("M")
|
||||
assert [r["c"] for r in scheme["roads"]] == ["primary"]
|
||||
assert len(scheme["water"]) == 1
|
||||
assert [label["t"] for label in scheme["labels"]] == ["улица Чкалова"]
|
||||
# Ни координат окна, ни констант проекции: по схеме нельзя поставить дом.
|
||||
assert not {"minLon", "maxLat", "k", "scale", "lat", "lon"} & set(scheme)
|
||||
|
||||
|
||||
def _dense_window() -> list[dict]:
|
||||
"""Плотность как в реальном окне (замер 2026-08-29 по «улица 8 Марта» и
|
||||
«улица Краснолесья»): 56-58 отрезков в кадре, 114-118 точек после упрощения
|
||||
на 3 м, то есть 2-2.7 точки на отрезок. Берём 3 — чуть плотнее замера."""
|
||||
rows = [
|
||||
{"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(3, 60.537 + i * 0.0002)}
|
||||
for i in range(16)
|
||||
]
|
||||
rows += [
|
||||
{
|
||||
"name": f"улица №{i}",
|
||||
"road_class": "residential",
|
||||
"gj": _line(3, 60.5340 + i * 0.0004, 56.7820 + i * 0.0002),
|
||||
}
|
||||
for i in range(40)
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
def test_scheme_stays_small() -> None:
|
||||
"""Порядок величины — 4 КБ. Замер на проде 2026-08-29: 2.8-2.9 КБ.
|
||||
|
||||
Сломать: убрать `round(..., 1)` в `path()` — координаты станут длинными
|
||||
float'ами, и схема на том же окне перевалит за потолок.
|
||||
"""
|
||||
scheme = _scheme_with(_dense_window())
|
||||
size = len(json.dumps(scheme, ensure_ascii=False, separators=(",", ":")).encode())
|
||||
assert size < 4400, f"схема раздулась до {size} байт"
|
||||
assert len(scheme["labels"]) == ss.MAX_LABELS
|
||||
|
||||
|
||||
def test_coordinates_carry_one_decimal() -> None:
|
||||
"""Прямая проверка того, чем держится размер: одна цифра после точки.
|
||||
|
||||
Потолок в байтах ловит раздутие только на той плотности, что в тесте; этот
|
||||
ассерт ловит саму потерю округления на любой.
|
||||
"""
|
||||
scheme = _scheme_with(_dense_window())
|
||||
blob = " ".join(scheme["target"] + [r["d"] for r in scheme["roads"]])
|
||||
assert not re.search(r"\.\d\d", blob), "координаты перестали округляться до 0.1"
|
||||
for label in scheme["labels"]:
|
||||
assert not re.search(r"\.\d\d", f"{label['x']} {label['y']}")
|
||||
|
|
@ -122,7 +122,7 @@ import asyncio
|
|||
import base64
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -961,7 +961,17 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
||||
error_body: dict = {"error": f"{type(exc).__name__}: {exc}"}
|
||||
if isinstance(exc, BanPageDetectedError):
|
||||
# #3239: бан-страница — ПОДТВЕРЖДЁННЫЙ маркер-детект, а не сбой
|
||||
# транспорта, и только за первым стоит report_ban на клиенте.
|
||||
# До этой правки оба случая приезжали одинаковой 500-кой, клиент
|
||||
# различить их не мог и настоящий отказ площадки переставал
|
||||
# ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500
|
||||
# завязана classify_browser_probe, признак несёт тело.
|
||||
error_body["ban_page"] = True
|
||||
error_body["status"] = _last_response_status.get(provider)
|
||||
return web.json_response(error_body, status=500)
|
||||
|
||||
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
|
||||
# только его, ничего не заметит. "status" может быть null (goto вернул None).
|
||||
|
|
@ -1230,6 +1240,55 @@ def _is_ban_page(html: str) -> bool:
|
|||
return any(marker in lower for marker in _BAN_MARKERS)
|
||||
|
||||
|
||||
# ── QRATOR-рукопожатие DomClick (#3196, замер прода 29.08.2026) ─────────────────
|
||||
# У DomClick, в отличие от Авито, у промежуточной (нерендеренной) PoW-страницы НЕТ
|
||||
# ни одного стабильного маркера вообще — ни __qrator, ни startpow, ни текста отказа
|
||||
# (замер: 3 запроса подряд на здоровом узле — 1-й вернул 401/6898 байт без единого
|
||||
# опознаваемого признака, 2-й и 3-й — уже 200/готовая карточка). Строить детектор
|
||||
# «это челлендж» тут не на чем: любой угаданный маркер протухнет на следующей смене
|
||||
# вёрстки, а до тех пор будет ловить призрак несуществующего состояния.
|
||||
#
|
||||
# Поэтому логика инвертирована: опознаём положительно два КРАЙНИХ состояния —
|
||||
# «точно готово» и «точно отказ», а всё остальное (загрузчик рукопожатия на 279
|
||||
# байт, нерендеренная PoW-страница на ~6.9-7.1 КБ) считаем «рукопожатие ещё идёт»
|
||||
# и уходим в _wait_out_pow_challenge вместо того, чтобы гадать по размеру —
|
||||
# отказ у DomClick тоже статическая страница фиксированного размера (~26.6 КБ),
|
||||
# см. _DOMCLICK_REFUSAL_MARKERS, поэтому размер как единственный признак ненадёжен.
|
||||
#
|
||||
# Признак успеха — JS-глобаль __SSR_STATE__, литеральная строка, которую кладёт
|
||||
# в разметку SSR-рендер готовой карточки (та же строка, из-за отсутствия которой
|
||||
# парсер бэкфилла поднимал ложный «блок», см. #3196). Сайдкар в остальном
|
||||
# провайдер-агностичен и не обязан знать про внутренний контракт парсера, но
|
||||
# альтернатива — «отсутствие маркеров отказа + размер» — здесь СЛАБЕЕ, а не чище:
|
||||
# и загрузчик (279 байт), и промежуточная PoW-страница (~7 КБ), и будущая
|
||||
# перекроенная вёрстка отказа тоже прошли бы такую проверку как «успех». Прямой
|
||||
# маркер успеха — единственный вариант, который не размывается на промежуточных
|
||||
# состояниях.
|
||||
_DOMCLICK_SUCCESS_MARKER = "__SSR_STATE__"
|
||||
|
||||
# Признак ОТКАЗА площадки (не рукопожатия) — статическая страница «403 | Домклик»
|
||||
# с текстом про подозрительный запрос, ~26.6 КБ, приходит с HTTP 401 (см.
|
||||
# _REFUSAL_STATUSES ниже — статус НЕ используется как сигнал для DomClick, тело
|
||||
# приходит с тем же кодом, что и у здорового рукопожатия и у одной из успешных
|
||||
# страниц в замере). Ждать тут нечего — статическая страница сама себя не
|
||||
# перезагрузит.
|
||||
_DOMCLICK_REFUSAL_MARKERS: tuple[str, ...] = (
|
||||
"похоже, ваш запрос выглядит необычно",
|
||||
"<title>403 | домклик</title>",
|
||||
)
|
||||
|
||||
|
||||
def _is_domclick_success(html: str) -> bool:
|
||||
"""True, если HTML — отрендеренная карточка DomClick (см. _DOMCLICK_SUCCESS_MARKER)."""
|
||||
return _DOMCLICK_SUCCESS_MARKER in html
|
||||
|
||||
|
||||
def _is_domclick_refusal(html: str) -> bool:
|
||||
"""True, если HTML — статический отказ площадки DomClick (см. _DOMCLICK_REFUSAL_MARKERS)."""
|
||||
lower = html.lower()
|
||||
return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS)
|
||||
|
||||
|
||||
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
|
||||
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
|
||||
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
|
||||
|
|
@ -1261,8 +1320,14 @@ async def _content_during_navigation(page: object) -> str | None:
|
|||
raise
|
||||
|
||||
|
||||
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
||||
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
|
||||
async def _wait_out_pow_challenge(
|
||||
page: object,
|
||||
provider: str,
|
||||
url: str,
|
||||
*,
|
||||
is_pending: Callable[[str], bool] = _is_pow_challenge,
|
||||
) -> str:
|
||||
"""Опрашивает page.content() пока `is_pending(html)` не станет False.
|
||||
|
||||
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
|
||||
решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
||||
|
|
@ -1270,18 +1335,24 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
|||
BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача
|
||||
challenge-страницы как будто это валидный контент.
|
||||
|
||||
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
|
||||
провайдеров не меняется) — предикат «страница ещё не устоялась, опроси
|
||||
снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного
|
||||
маркера самого челленджа, есть только «точно успех» / «точно отказ»
|
||||
(#3196), поэтому pending = «ни то, ни другое».
|
||||
|
||||
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
|
||||
каким ждём обычную навигацию (второй таймаут не изобретаем).
|
||||
"""
|
||||
poll_interval_ms = 1000
|
||||
elapsed_ms = 0
|
||||
html: str | None = await _content_during_navigation(page)
|
||||
while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
||||
while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
||||
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||||
elapsed_ms += poll_interval_ms
|
||||
html = await _content_during_navigation(page)
|
||||
|
||||
if html is None or _is_pow_challenge(html):
|
||||
if html is None or is_pending(html):
|
||||
raise ChallengeTimeoutError(
|
||||
f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
|
||||
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
|
||||
|
|
@ -1432,9 +1503,37 @@ async def _fetch_once(
|
|||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
|
||||
)
|
||||
if provider == "domclick":
|
||||
# DomClick — своя ветка (#3196): нет отдельного маркера самого
|
||||
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
|
||||
# статус НЕ смотрим вовсе (в отличие от общей ветки ниже) — 401
|
||||
# приходит и у здорового рукопожатия, и у отказа, и у успешной
|
||||
# страницы в замере 29.08.2026, различать нужно ТОЛЬКО по телу.
|
||||
if _is_domclick_refusal(html):
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: статический отказ площадки "
|
||||
f"url={url!r}"
|
||||
)
|
||||
if not _is_domclick_success(html):
|
||||
html = await _wait_out_pow_challenge(
|
||||
page,
|
||||
provider,
|
||||
url,
|
||||
is_pending=lambda h: not _is_domclick_success(h)
|
||||
and not _is_domclick_refusal(h),
|
||||
)
|
||||
if _is_domclick_refusal(html):
|
||||
# Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса —
|
||||
# is_pending вернул False (страница устоялась), но устоялась
|
||||
# она в состояние «отказ», а не «успех». Наверх идёт то же
|
||||
# исключение, что и для мгновенного отказа выше.
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: отказ площадки после "
|
||||
f"ожидания рукопожатия url={url!r}"
|
||||
)
|
||||
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
|
||||
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
|
||||
if _is_pow_challenge(html):
|
||||
elif _is_pow_challenge(html):
|
||||
status = _last_response_status.get(provider)
|
||||
if status in _REFUSAL_STATUSES:
|
||||
# Маркеры челленджа при 403/429 — это отказ, а не проверка:
|
||||
|
|
@ -1443,7 +1542,9 @@ async def _fetch_once(
|
|||
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
|
||||
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
|
||||
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
|
||||
# BanPageDetectedError как блок.
|
||||
# BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда —
|
||||
# у DomClick (единственного, кто отдаёт 401) отказ распознаётся
|
||||
# выше по телу, а не по коду, см. ветку provider == "domclick".
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа "
|
||||
f"— отказ площадки, ждать нечего url={url!r}"
|
||||
|
|
|
|||
284
tradein-mvp/browser/test_server_domclick_challenge.py
Normal file
284
tradein-mvp/browser/test_server_domclick_challenge.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
"""test_server_domclick_challenge.py — QRATOR-рукопожатие DomClick в /fetch (#3196).
|
||||
|
||||
Проблема, установленная замером прода 29.08.2026: у DomClick, в отличие от
|
||||
Авито, промежуточная (нерендеренная) PoW-страница НЕ содержит ни одного
|
||||
стабильного маркера вообще — ни `__qrator`, ни `startpow`, ни текста отказа.
|
||||
`_CHALLENGE_MARKERS` (сняты живьём с Авито, #3045) на DomClick никогда не
|
||||
матчатся, поэтому ветка `_wait_out_pow_challenge` для него не включалась —
|
||||
недосчитанная страница уезжала наверх как валидный контент, парсер не находил
|
||||
`__SSR_STATE__` и поднимал ложный "блок" (двухнедельный
|
||||
`attempted=3, blocked=3, enriched=0` у `domclick_detail_backfill`).
|
||||
|
||||
Логика для DomClick инвертирована: опознаём положительно ДВА крайних
|
||||
состояния — «точно успех» (`_DOMCLICK_SUCCESS_MARKER`, JS-глобаль
|
||||
`__SSR_STATE__`) и «точно отказ» (`_DOMCLICK_REFUSAL_MARKERS`, статическая
|
||||
страница «403 | Домклик» ~26.6 КБ) — а всё остальное (загрузчик рукопожатия
|
||||
279 байт, нерендеренная PoW-страница ~7 КБ) трактуется как «рукопожатие ещё
|
||||
идёт» и уходит в существующий `_wait_out_pow_challenge` с кастомным
|
||||
`is_pending`.
|
||||
|
||||
Тестируется _fetch_once целиком (зеркалит test_server_pow_challenge.py),
|
||||
camoufox не запускается — фейковые browser/page возвращают заранее заданную
|
||||
последовательность content(). wait_for_timeout на фейковой page — no-op.
|
||||
|
||||
Запуск (из tradein-mvp/browser/)::
|
||||
|
||||
python -m pytest test_server_domclick_challenge.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
server = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(server)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py)."""
|
||||
monkeypatch.setattr(server, "_browsers", {})
|
||||
monkeypatch.setattr(server, "_browser_cms", {})
|
||||
monkeypatch.setattr(server, "_page_counters", {})
|
||||
monkeypatch.setattr(server, "_locks", {})
|
||||
monkeypatch.setattr(server, "_retry_tasks", {})
|
||||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||
|
||||
|
||||
# Приблизительные образцы трёх состояний, снятых живьём 29.08.2026 (см. докстринг
|
||||
# задачи) — точный размер в байтах для теста не важен, важно отсутствие/наличие
|
||||
# опознаваемых строк.
|
||||
_HANDSHAKE_LOADER_HTML = (
|
||||
'<html><head><script src="/__qrator/qauth_utm_v2d_v9118.js"></script></head>'
|
||||
"<body></body></html>"
|
||||
)
|
||||
_RENDERED_POW_HTML = (
|
||||
"<html><body><script>window.location = location.href;</script>"
|
||||
"<div>ожидание...</div></body></html>"
|
||||
)
|
||||
_REFUSAL_HTML = (
|
||||
"<html><head><title>403 | Домклик</title></head>"
|
||||
"<body>Похоже, ваш запрос выглядит необычно. Мы усилили защиту.</body></html>"
|
||||
)
|
||||
_SUCCESS_HTML = '<html><body><script>window.__SSR_STATE__ = {"card": {}};</script></body></html>'
|
||||
|
||||
|
||||
class _DomclickPage:
|
||||
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
|
||||
|
||||
После исчерпания списка повторяет последний элемент (имитирует «страница
|
||||
осталась в этом состоянии»). Зеркалит _ChallengePage из
|
||||
test_server_pow_challenge.py.
|
||||
"""
|
||||
|
||||
def __init__(self, html_sequence: list[str]) -> None:
|
||||
self._html_sequence = html_sequence
|
||||
self._call_count = 0
|
||||
self.goto_urls: list[str] = []
|
||||
self.wait_for_timeout_calls: list[int] = []
|
||||
self.closed = 0
|
||||
|
||||
async def route(self, pattern: str, handler: Any) -> None:
|
||||
return None
|
||||
|
||||
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||
self.goto_urls.append(url)
|
||||
|
||||
async def wait_for_timeout(self, ms: int) -> None:
|
||||
self.wait_for_timeout_calls.append(ms)
|
||||
|
||||
async def content(self) -> str:
|
||||
idx = min(self._call_count, len(self._html_sequence) - 1)
|
||||
item = self._html_sequence[idx]
|
||||
self._call_count += 1
|
||||
return item
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed += 1
|
||||
|
||||
|
||||
class _DomclickBrowser:
|
||||
def __init__(self, page: _DomclickPage) -> None:
|
||||
self._page = page
|
||||
|
||||
async def new_page(self) -> _DomclickPage:
|
||||
return self._page
|
||||
|
||||
|
||||
def _install(
|
||||
monkeypatch: pytest.MonkeyPatch, page: _DomclickPage, provider: str = "domclick"
|
||||
) -> None:
|
||||
server._browsers[provider] = _DomclickBrowser(page)
|
||||
monkeypatch.setattr(
|
||||
server, "_RECYCLE_PAGES_BY_PROVIDER",
|
||||
dict.fromkeys(server.PROVIDERS, 10_000),
|
||||
)
|
||||
|
||||
|
||||
# ── детекторы: чистые функции ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_domclick_success_true_on_ssr_state_marker() -> None:
|
||||
assert server._is_domclick_success(_SUCCESS_HTML) is True
|
||||
|
||||
|
||||
def test_is_domclick_success_false_on_handshake_loader() -> None:
|
||||
assert server._is_domclick_success(_HANDSHAKE_LOADER_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_success_false_on_rendered_pow_page() -> None:
|
||||
"""Ключевой случай задачи: у рендеренной PoW-страницы НЕТ стабильных маркеров
|
||||
вообще — детектор успеха не должен ложно сработать на ней."""
|
||||
assert server._is_domclick_success(_RENDERED_POW_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_success_false_on_refusal_page() -> None:
|
||||
assert server._is_domclick_success(_REFUSAL_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_refusal_true_on_title_and_text_markers() -> None:
|
||||
assert server._is_domclick_refusal(_REFUSAL_HTML) is True
|
||||
|
||||
|
||||
def test_is_domclick_refusal_false_on_handshake_loader() -> None:
|
||||
assert server._is_domclick_refusal(_HANDSHAKE_LOADER_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_refusal_false_on_rendered_pow_page() -> None:
|
||||
assert server._is_domclick_refusal(_RENDERED_POW_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_refusal_false_on_success_page() -> None:
|
||||
assert server._is_domclick_refusal(_SUCCESS_HTML) is False
|
||||
|
||||
|
||||
# ── _fetch_once: сценарии DomClick ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fetch_once_refusal_page_raises_immediately_without_waiting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Страница отказа → BanPageDetectedError СРАЗУ, без ухода в опрос (пункт 1)."""
|
||||
page = _DomclickPage([_REFUSAL_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
with pytest.raises(server.BanPageDetectedError):
|
||||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||||
|
||||
assert page.closed == 1
|
||||
# Единственная пауза — обычная гидро-пауза ДО детекта; цикла опроса (доп.
|
||||
# паузы по 1000мс) не было — бюджет ожидания не потрачен.
|
||||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||
|
||||
|
||||
def test_fetch_once_page_without_markers_waits_then_returns_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ни успеха, ни отказа (загрузчик/рендеренная PoW-страница) → уходит в
|
||||
ожидание, а не отдаёт недосчитанную страницу как контент (пункт 2)."""
|
||||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _RENDERED_POW_HTML, _SUCCESS_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
html = asyncio.run(
|
||||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||||
)
|
||||
|
||||
assert html == _SUCCESS_HTML
|
||||
assert page.closed == 1
|
||||
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
|
||||
assert 1000 in page.wait_for_timeout_calls # шаг опроса — бюджет реально тратился
|
||||
|
||||
|
||||
def test_fetch_once_success_page_returned_immediately_without_waiting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Успех с первого раза → отдаётся сразу, БЕЗ ухода в polling-ветку (пункт 3)."""
|
||||
page = _DomclickPage([_SUCCESS_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
html = asyncio.run(
|
||||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||||
)
|
||||
|
||||
assert html == _SUCCESS_HTML
|
||||
assert page.closed == 1
|
||||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||
|
||||
|
||||
def test_fetch_once_settles_into_refusal_during_wait_raises_ban_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса → тоже BanPageDetectedError,
|
||||
а не отдача отказной страницы как валидного контента."""
|
||||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _REFUSAL_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
with pytest.raises(server.BanPageDetectedError):
|
||||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||||
|
||||
assert page.closed == 1
|
||||
|
||||
|
||||
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ни успех, ни отказ так и не наступают → ChallengeTimeoutError по истечении бюджета."""
|
||||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
|
||||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML]) # повторяется бесконечно
|
||||
_install(monkeypatch, page)
|
||||
|
||||
with pytest.raises(server.ChallengeTimeoutError):
|
||||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||||
|
||||
assert page.closed == 1
|
||||
|
||||
|
||||
def test_fetch_once_401_status_is_not_used_as_refusal_signal_for_domclick(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""HTTP 401 — статус здорового рукопожатия DomClick (замер 29.08.2026), НЕ должен
|
||||
трактоваться как отказ: только тело страницы решает."""
|
||||
|
||||
class _Response:
|
||||
status = 401
|
||||
|
||||
class _StatusPage(_DomclickPage):
|
||||
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
|
||||
self.goto_urls.append(url)
|
||||
return _Response()
|
||||
|
||||
page = _StatusPage([_HANDSHAKE_LOADER_HTML, _SUCCESS_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
html = asyncio.run(
|
||||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||||
)
|
||||
|
||||
assert html == _SUCCESS_HTML
|
||||
|
||||
|
||||
# ── не-домклик провайдеры: поведение не изменилось (пункт 4) ────────────────────
|
||||
|
||||
|
||||
def test_fetch_once_non_domclick_provider_ignores_domclick_markers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""avito с HTML, который матчил бы _is_domclick_success/_is_domclick_refusal,
|
||||
не должен уходить ни в какую специальную ветку — DomClick-логика провайдер-
|
||||
скопирована строго под `provider == "domclick"`."""
|
||||
page = _DomclickPage([_REFUSAL_HTML]) # содержит "похоже, ваш запрос..." — не avito-маркер
|
||||
_install(monkeypatch, page, provider="avito")
|
||||
|
||||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||||
|
||||
assert html == _REFUSAL_HTML # отдан как обычный контент — avito его не распознаёт
|
||||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||
|
|
@ -293,3 +293,62 @@ def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPa
|
|||
body = _json_body(response)
|
||||
assert body["html"] == _REAL_HTML
|
||||
assert body["status"] is None
|
||||
|
||||
|
||||
# ── #3239: 500-ка бан-страницы отличима от 500-ки транспорта ──────────────────
|
||||
|
||||
|
||||
def _fetch_handler_error_body(
|
||||
monkeypatch: pytest.MonkeyPatch, exc: Exception, upstream_status: int | None
|
||||
) -> tuple[int, dict]:
|
||||
monkeypatch.setattr(server, "IS_PROD", False)
|
||||
|
||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||
return True
|
||||
|
||||
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
|
||||
server._last_response_status[provider] = upstream_status
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||||
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"})))
|
||||
return response.status, _json_body(response)
|
||||
|
||||
|
||||
def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Бан-страница несёт ban_page + апстрим-статус.
|
||||
|
||||
Без этого признака клиент видит обычную 500-ку, уводит отказ в транспортную
|
||||
ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел —
|
||||
регрессия, которую #3237 внёс, а #3239 чинит.
|
||||
"""
|
||||
status, body = _fetch_handler_error_body(
|
||||
monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401
|
||||
)
|
||||
assert status == 500 # код НЕ меняем: на него завязана classify_browser_probe
|
||||
assert body["ban_page"] is True
|
||||
assert body["status"] == 401
|
||||
assert "BanPageDetectedError" in body["error"]
|
||||
|
||||
|
||||
def test_fetch_handler_transport_error_has_no_ban_page_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Обычный сбой — признака нет вовсе, клиент трактует его как раньше."""
|
||||
status, body = _fetch_handler_error_body(
|
||||
monkeypatch, RuntimeError("NS_ERROR_PROXY_BAD_GATEWAY"), None
|
||||
)
|
||||
assert status == 500
|
||||
assert "ban_page" not in body
|
||||
|
||||
|
||||
def test_fetch_handler_ban_page_without_status_stays_null(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Апстрим-статуса нет — отдаём null, а не выдуманный код (#2764)."""
|
||||
_status, body = _fetch_handler_error_body(
|
||||
monkeypatch, server.BanPageDetectedError("бан-страница"), None
|
||||
)
|
||||
assert body["ban_page"] is True
|
||||
assert body["status"] is None
|
||||
|
|
|
|||
|
|
@ -73,7 +73,12 @@ class _FakePage:
|
|||
return None
|
||||
|
||||
async def content(self) -> str:
|
||||
return "<html>ok</html>"
|
||||
# Содержит __SSR_STATE__ (#3196): для provider="domclick" это единственный
|
||||
# признак "успешной" страницы (см. server._is_domclick_success) — без него
|
||||
# generic-контент теста уходил бы в ветку ожидания рукопожатия и падал по
|
||||
# ChallengeTimeoutError. avito/cian игнорируют эту строку (их детекторы
|
||||
# ищут другие маркеры), так что для них поведение не меняется.
|
||||
return "<html>ok<script>window.__SSR_STATE__={}</script></html>"
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed += 1
|
||||
|
|
@ -205,7 +210,7 @@ def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None:
|
|||
server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True)
|
||||
)
|
||||
|
||||
assert html == "<html>ok</html>"
|
||||
assert html == "<html>ok<script>window.__SSR_STATE__={}</script></html>"
|
||||
assert browser.new_page_calls == 1
|
||||
assert browser.contexts_created == []
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Карта сделки: что она обязана показать и о чём обязана промолчать.
|
||||
*
|
||||
* Проверяется не «рисуется ли svg», а три решения, каждое из которых при поломке
|
||||
* даёт правдоподобную картинку с неверным смыслом:
|
||||
* · кадр берётся по району сделки, а не по городу (иначе район — пятно);
|
||||
* · точка ставится ТОЛЬКО когда координата попала в кадр (иначе далёкая
|
||||
* координата прижмётся к краю и будет прочитана как место сделки);
|
||||
* · подпись говорит, что точка — середина улицы, а не дом, и не называет её
|
||||
* адресом объекта.
|
||||
*/
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DealMapV3 } from "../_components/v3/DealMapV3";
|
||||
import { GuessGameV3 } from "../_components/v3/GuessGameV3";
|
||||
import { StreetMapV3 } from "../_components/v3/StreetMapV3";
|
||||
import { EKB_MAP } from "../_components/v3/ekb-districts";
|
||||
import { dealTitle, toGameRound } from "../_components/v3/deal-view";
|
||||
import type { ShowcaseDeal, StreetScheme } from "../public-api";
|
||||
|
||||
/** Ширина кадра из атрибута viewBox отрисованной карты. */
|
||||
function frameWidth(container: HTMLElement): number {
|
||||
const box = container.querySelector("svg")!.getAttribute("viewBox")!;
|
||||
return Number(box.split(" ")[2]);
|
||||
}
|
||||
|
||||
const dots = (container: HTMLElement) => container.querySelectorAll("circle").length;
|
||||
|
||||
describe("карта сделки", () => {
|
||||
it("кадрируется по району сделки, а не по городу", () => {
|
||||
const home = render(
|
||||
<DealMapV3 district="Кировский" districtLabel="Кировский" lat={null} lon={null} />,
|
||||
);
|
||||
const nowhere = render(
|
||||
<DealMapV3 district={null} districtLabel="район не указан" lat={null} lon={null} />,
|
||||
);
|
||||
expect(frameWidth(home.container)).toBeLessThan(frameWidth(nowhere.container));
|
||||
// Без района показывается город целиком — это и есть «кадра нет».
|
||||
expect(frameWidth(nowhere.container)).toBeGreaterThanOrEqual(EKB_MAP.width);
|
||||
});
|
||||
|
||||
it("кадр разный у разных районов — иначе карта не про эту сделку", () => {
|
||||
const frames = ["Кировский", "Чкаловский", "Академический"].map((name) => {
|
||||
const { container } = render(
|
||||
<DealMapV3 district={name} districtLabel={name} lat={null} lon={null} />,
|
||||
);
|
||||
return container.querySelector("svg")!.getAttribute("viewBox")!;
|
||||
});
|
||||
expect(new Set(frames).size).toBe(frames.length);
|
||||
});
|
||||
|
||||
it("точка внутри кадра рисуется, а подпись говорит, что это улица, а не дом", () => {
|
||||
// Точка внутри полигона Чкаловского — проверена тем же `project()`, что
|
||||
// рисует карту.
|
||||
const { container, getByText } = render(
|
||||
<DealMapV3 district="Чкаловский" districtLabel="Чкаловский" lat={56.79} lon={60.62} />,
|
||||
);
|
||||
expect(dots(container)).toBeGreaterThan(0);
|
||||
expect(getByText(/СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · ЧКАЛОВСКИЙ/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("координата вне кадра не рисуется и не выдаётся за место сделки", () => {
|
||||
// Такие строки в данных есть: город записан «Екатеринбург», координата — за
|
||||
// сотни километров. Прижать её к краю кадра значило бы показать место,
|
||||
// которого в данных нет.
|
||||
const { container, getByText } = render(
|
||||
<DealMapV3 district="Чкаловский" districtLabel="Чкаловский" lat={54.524601} lon={40.92939} />,
|
||||
);
|
||||
expect(dots(container)).toBe(0);
|
||||
expect(getByText(/КООРДИНАТЫ НЕТ · ЧКАЛОВСКИЙ/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("без координаты карта остаётся, район подсвечен, прочерка нет", () => {
|
||||
const { container, getByText } = render(
|
||||
<DealMapV3 district="Ленинский" districtLabel="Ленинский" lat={null} lon={null} />,
|
||||
);
|
||||
expect(dots(container)).toBe(0);
|
||||
expect(container.querySelectorAll("path").length).toBeGreaterThan(0);
|
||||
expect(getByText(/КООРДИНАТЫ НЕТ · ЛЕНИНСКИЙ/u)).toBeTruthy();
|
||||
expect(container.textContent).not.toMatch(/—\s*$/u);
|
||||
});
|
||||
|
||||
it("карта не называет себя адресом объекта", () => {
|
||||
const { container } = render(
|
||||
<DealMapV3 district="Кировский" districtLabel="Кировский" lat={56.85} lon={60.63} />,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Студия — это тип квартиры, а не ноль комнат ─────────────────────────────
|
||||
//
|
||||
// В витрине на проде (29.08.2026) 3 строки из 20 имеют rooms = 0. Пока
|
||||
// dealTitle клеил «${rooms}-к», карточка писала «0-к, 25,9 м²» — это читается
|
||||
// как ошибка выгрузки, а не как студия. Тест держит обе ветки: сломай любую —
|
||||
// покраснеет по значению.
|
||||
describe("подпись сделки различает студию и комнатные", () => {
|
||||
const deal = (rooms: number, area: number): ShowcaseDeal => ({
|
||||
district: "Кировский",
|
||||
rooms,
|
||||
area_m2: area,
|
||||
floor: 9,
|
||||
total_floors: null,
|
||||
deal_quarter: "II квартал 2026",
|
||||
predicted_rub: 5_000_000,
|
||||
fact_rub: 4_800_000,
|
||||
err_pct: 4.2,
|
||||
n_analogs: 24,
|
||||
note: "",
|
||||
lat: null,
|
||||
lon: null,
|
||||
street_name: null,
|
||||
street_scheme: null,
|
||||
});
|
||||
|
||||
it("rooms = 0 подписывается студией, а не «0-к»", () => {
|
||||
const title = dealTitle(deal(0, 25.9));
|
||||
expect(title).toContain("Студия");
|
||||
expect(title).not.toContain("0-к");
|
||||
});
|
||||
|
||||
it("комнатные подписываются числом комнат", () => {
|
||||
expect(dealTitle(deal(2, 52))).toContain("2-к");
|
||||
expect(dealTitle(deal(2, 52))).not.toContain("Студия");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Схема улиц: что она обязана сказать и чего не должна заявлять ───────────
|
||||
//
|
||||
// Схема приходит готовыми путями из ручки — рисовать тут нечего, и «рисуется
|
||||
// ли svg» проверять бессмысленно. Ломаются другие вещи, и каждая ломается
|
||||
// тихо: пропадает иерархия дорог (схема превращается в решётку), теряется
|
||||
// подсветка целевой улицы (карта перестаёт быть про эту сделку), подпись
|
||||
// начинает выдавать улицу за адрес объекта, а выбор между двумя картами
|
||||
// съезжает — и 8% строк без схемы получают пустоту вместо района.
|
||||
|
||||
const SCHEME: StreetScheme = {
|
||||
street: "улица Краснолесья",
|
||||
w: 1000,
|
||||
h: 1000,
|
||||
target: ["M431.2 88L500.1 210.4", "M500.1 210.4L512 640"],
|
||||
roads: [
|
||||
{ c: "trunk", d: "M0 0L100 100" },
|
||||
{ c: "residential", d: "M0 900L100 950" },
|
||||
{ c: "unknown_class", d: "M10 10L20 20" },
|
||||
],
|
||||
water: ["M0 500L1000 520"],
|
||||
labels: [{ t: "улица Чкалова", x: 431.2, y: 88 }],
|
||||
};
|
||||
|
||||
const widths = (container: HTMLElement, cls: string): number[] =>
|
||||
[...container.querySelectorAll(`path[class*="${cls}"]`)].map((n) =>
|
||||
Number(n.getAttribute("stroke-width")),
|
||||
);
|
||||
|
||||
describe("схема улиц в карточке сделки", () => {
|
||||
it("кадр берётся из данных, а не из константы фронта", () => {
|
||||
const { container } = render(<StreetMapV3 scheme={{ ...SCHEME, w: 800, h: 800 }} />);
|
||||
expect(container.querySelector("svg")!.getAttribute("viewBox")).toBe("0 0 800 800");
|
||||
// slice, а не meet: окно квадратное, карточка — нет. Целевая улица лежит в
|
||||
// центре окна по построению, поэтому обрезка краёв её не трогает.
|
||||
expect(container.querySelector("svg")!.getAttribute("preserveAspectRatio")).toContain("slice");
|
||||
});
|
||||
|
||||
it("целевая улица подсвечена и несёт ореол — по одному на каждый путь", () => {
|
||||
const { container } = render(<StreetMapV3 scheme={SCHEME} />);
|
||||
expect(widths(container, "streetTarget")).toHaveLength(SCHEME.target.length);
|
||||
const halo = widths(container, "streetHalo");
|
||||
expect(halo).toHaveLength(SCHEME.target.length);
|
||||
// Ореол обязан быть ШИРЕ линии — иначе он не ореол, а вторая линия.
|
||||
expect(Math.min(...halo)).toBeGreaterThan(Math.max(...widths(container, "streetTarget")));
|
||||
});
|
||||
|
||||
it("иерархия дорог сохраняется, а незнакомый класс не даёт нулевой толщины", () => {
|
||||
const { container } = render(<StreetMapV3 scheme={SCHEME} />);
|
||||
const [trunk, residential, unknown] = widths(container, "streetRoad");
|
||||
expect(trunk).toBeGreaterThan(residential);
|
||||
expect(unknown).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("вода нарисована и отделена от дорог", () => {
|
||||
const { container } = render(<StreetMapV3 scheme={SCHEME} />);
|
||||
expect(widths(container, "streetWater")).toHaveLength(SCHEME.water.length);
|
||||
});
|
||||
|
||||
it("подпись называет улицу и сразу говорит, что дом не известен", () => {
|
||||
const { container, getByText } = render(<StreetMapV3 scheme={SCHEME} />);
|
||||
expect(getByText(/УЛИЦА КРАСНОЛЕСЬЯ · ДОМ НЕ ИЗВЕСТЕН/u)).toBeTruthy();
|
||||
expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u);
|
||||
});
|
||||
|
||||
it("схема ничего не сообщает скринридеру — всё, что она знает, стоит рядом текстом", () => {
|
||||
const { container } = render(<StreetMapV3 scheme={SCHEME} />);
|
||||
expect(container.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("карточка игры выбирает карту по наличию схемы", () => {
|
||||
const deal = (scheme: StreetScheme | null): ShowcaseDeal => ({
|
||||
district: "Ленинский",
|
||||
rooms: 2,
|
||||
area_m2: 52,
|
||||
floor: 3,
|
||||
total_floors: 9,
|
||||
deal_quarter: "II кв. 2026",
|
||||
predicted_rub: 5_210_000,
|
||||
fact_rub: 5_180_000,
|
||||
err_pct: 0.58,
|
||||
n_analogs: 12,
|
||||
note: "",
|
||||
lat: null,
|
||||
lon: null,
|
||||
street_name: scheme ? scheme.street : null,
|
||||
street_scheme: scheme,
|
||||
});
|
||||
|
||||
it("схема есть — показана улица", () => {
|
||||
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(SCHEME), 0)]} />);
|
||||
expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН");
|
||||
});
|
||||
|
||||
it("схемы нет — остаётся район, а не пустое место", () => {
|
||||
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(null), 0)]} />);
|
||||
expect(container.textContent).toContain("ЛЕНИНСКИЙ");
|
||||
expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН");
|
||||
});
|
||||
});
|
||||
|
|
@ -64,10 +64,25 @@ const V3_DIR = "_components/v3";
|
|||
* Компонент, положенный ВНЕ каталога, каталогом не ловится — за это отвечает
|
||||
* отдельная проверка «всё, что импортирует корень, попало в список».
|
||||
*/
|
||||
/**
|
||||
* Единственное исключение — таблица координат, СГЕНЕРИРОВАННАЯ из боевой
|
||||
* геометрии (`backend/scripts/export_ekb_districts_svg.py`). Её числа — не
|
||||
* величины о рынке, а границы районов: «56.8» в пути полигона ничего не
|
||||
* утверждает и источник у неё проверяемый, записанный в шапке файла. Правило
|
||||
* (1) на ней даёт шум, за которым правило перестают читать.
|
||||
*
|
||||
* Исключение НЕ бланковое: ниже отдельная проверка требует, чтобы каждый
|
||||
* исключённый файл нёс маркер генератора. Иначе строчка в этом списке стала бы
|
||||
* способом занести в компонент любое число, сославшись на генератор, которого
|
||||
* нет.
|
||||
*/
|
||||
const GENERATED = ["ekb-districts.ts"];
|
||||
const GENERATED_MARK = "СГЕНЕРИРОВАН";
|
||||
|
||||
const V3_SOURCES = [
|
||||
LANDING_ROOT,
|
||||
...readdirSync(join(ROOT, V3_DIR))
|
||||
.filter((name) => /\.tsx?$/u.test(name))
|
||||
.filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
|
||||
.sort()
|
||||
.map((name) => `${V3_DIR}/${name}`),
|
||||
];
|
||||
|
|
@ -140,6 +155,10 @@ describe("гейт витринных чисел (#2904)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each(GENERATED)("исключённый из скана %s действительно сгенерирован, а не написан руками", (name) => {
|
||||
expect(read(`${V3_DIR}/${name}`)).toContain(GENERATED_MARK);
|
||||
});
|
||||
|
||||
it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => {
|
||||
// Контроль на инструмент. Слева — ровно то, что гейт обязан ловить
|
||||
// (величины из прежнего marketing-v3.ts), справа — код, который в
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ const SHOWCASE: ShowcaseResponse = {
|
|||
err_pct: 0.58,
|
||||
n_analogs: 12,
|
||||
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
|
||||
lat: null,
|
||||
lon: null,
|
||||
// Схемы у этой строки нет — рендер идёт запасным путём, картой района.
|
||||
street_name: null,
|
||||
street_scheme: null,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73).
|
||||
*
|
||||
* Презентационный клиентский остров: сегодня публичный расчёт выключен
|
||||
* (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь
|
||||
* город никуда не отправляется — состояние живёт только в этом компоненте и
|
||||
* показывает посетителю, что сервис знает больше одного города области.
|
||||
* Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора
|
||||
* наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/
|
||||
* sticky-баром, самой формы здесь нет.
|
||||
*
|
||||
* Список городов и деление на «полное покрытие» / «данных меньше» берутся из
|
||||
* `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно
|
||||
* объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса»
|
||||
* по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет,
|
||||
* а гадать нельзя (правило честности в content.ts).
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
|
||||
|
||||
import { PRIMARY_CITY, REGION_NAME } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function CityPicker() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [city, setCity] = useState<string>(DEFAULT_CITY.label);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelId = useId();
|
||||
|
||||
// Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// Escape — закрыть и вернуть фокус на кнопку.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [open]);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filtered = normalizedQuery
|
||||
? OBLAST_CITIES.filter((c) =>
|
||||
c.label.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
: OBLAST_CITIES;
|
||||
|
||||
return (
|
||||
<div className={styles.hdrCityWrap} ref={wrapRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={styles.hdrCityBtn}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span className={styles.hdrCityDot} aria-hidden="true" />
|
||||
<span>{city}</span>
|
||||
<span className={styles.hdrCityCaret} aria-hidden="true">
|
||||
{open ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
id={panelId}
|
||||
className={styles.hdrCityPanel}
|
||||
aria-label="Выбор города"
|
||||
>
|
||||
<div className={styles.hdrCitySearchRow}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.hdrCityInput}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск города"
|
||||
aria-label="Поиск города"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hdrCityList}>
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={styles.hdrCityItem}
|
||||
data-active={c.label === city ? "true" : undefined}
|
||||
onClick={() => {
|
||||
setCity(c.label);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
buttonRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className={styles.hdrCityItemName}>{c.label}</span>
|
||||
<span className={styles.hdrCityItemNote}>
|
||||
{c.label === PRIMARY_CITY
|
||||
? "Полное покрытие"
|
||||
: "Данных меньше"}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className={styles.hdrCityEmpty}>
|
||||
Города нет в списке. Мы работаем там, где хватает данных по
|
||||
сделкам — напишите нам, и добавим.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.hdrCityFooter}>
|
||||
ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
|
||||
{REGION_NAME.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* DealMapV3 — карта сделки в карточке игры «Сыграйте против МЕРЫ».
|
||||
*
|
||||
* Заменяет декорацию, которая стояла тут раньше: CSS-сетку и две «дороги»,
|
||||
* нарисованные прямоугольниками. Географии в них не было вовсе, а подпись
|
||||
* «ОБЪЕКТ · РАЙОН» заявляла местоположение объекта — то, чего в данных нет.
|
||||
*
|
||||
* БЕЗ ЕДИНОГО ВНЕШНЕГО ЗАПРОСА. Геометрия районов приезжает статикой из
|
||||
* `ekb-districts.ts` (сгенерирована из боевой OSM-таблицы), рисуется инлайновым
|
||||
* <svg>. Тайлы сюда не тянули сознательно: провайдер получал бы IP посетителя и
|
||||
* то, что он смотрит, на странице, которая обещает «без звонков и регистрации»
|
||||
* (то же правило, по которому в дереве self-hosted шрифты — см. шапку
|
||||
* layout.tsx).
|
||||
*
|
||||
* КАДР — ПО РАЙОНУ, А НЕ ПО ГОРОДУ. Административные границы Екатеринбурга
|
||||
* включают огромные незастроенные территории: в кадре «весь город» район сделки
|
||||
* занимает несколько процентов площади и читается как пятно. Кадрируем по bbox
|
||||
* района с запасом PAD, соседние районы остаются контуром — они и дают понять,
|
||||
* какая это часть города. Кадр раздвигается до пропорции карточки, а
|
||||
* `preserveAspectRatio="…meet"` показывает его ЦЕЛИКОМ: `slice` обрезал бы
|
||||
* края, и точка у границы района могла бы уехать за поле зрения — то есть
|
||||
* проверка «точка в кадре» перестала бы совпадать с тем, что видно.
|
||||
*
|
||||
* ТОЧКА — И ЧТО ОНА ЗНАЧИТ. Координата в витрине — ЦЕНТРОИД УЛИЦЫ, не дом
|
||||
* (замер на проде 29.08.2026 по выборке витрины: 34 021 сделка, 34 017 с
|
||||
* координатой, но различных точек 991 — около тридцати сделок в одной точке
|
||||
* при 2.7% известных номеров дома; та же граница записана в `note` каждой
|
||||
* строки). Поэтому подпись говорит «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ», а не «объект» и
|
||||
* не «адрес»: точка верна на масштабе улицы и неверна на масштабе дома, и
|
||||
* карточка обязана произносить это вслух, а не оставлять читателю догадываться
|
||||
* по размеру маркера.
|
||||
*
|
||||
* КОГДА ТОЧКИ НЕТ. Два случая, оба дают карту без точки и с подсвеченным
|
||||
* районом — ни прочерка, ни подстановки центра района:
|
||||
* · координаты нет (`lat`/`lon` = null);
|
||||
* · координата есть, но лежит ВНЕ кадра. Это не теоретический случай: в той
|
||||
* же прод-выборке 2 132 строки из 34 021 (6.3%) имеют координату за
|
||||
* пределами города — встречаются точки за сотни километров. Рисовать их
|
||||
* прижатыми к краю значило бы показать место, которого в данных нет.
|
||||
* Подпись в обоих случаях меняется на «КООРДИНАТЫ НЕТ», чтобы пустая карта не
|
||||
* читалась как «объект где-то тут, просто не отметили».
|
||||
*
|
||||
* ДОСТУПНОСТЬ. <svg> помечен `aria-hidden`: всё, что он сообщает — район сделки
|
||||
* и характер точки, — стоит рядом текстом (подпись под картой плюс район в
|
||||
* мете сделки). Дублировать это ещё и в `role="img"`+`aria-label` значило бы
|
||||
* прочитать одно и то же дважды подряд; ничего, кроме района, карта не знает.
|
||||
*
|
||||
* Компонент чистый и без состояния — директивы "use client" в нём нет. В
|
||||
* клиентский бандл он всё равно попадает: карточка игры листает раунды
|
||||
* состоянием, и карта живёт внутри её дерева.
|
||||
*/
|
||||
|
||||
import { EKB_DISTRICTS, EKB_MAP, project } from "./ekb-districts";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
/** Запас вокруг района: без него граница упирается в край кадра. */
|
||||
const PAD = 0.45;
|
||||
/**
|
||||
* Полоса пустого кадра СНИЗУ. Нижнюю треть плашки занимает подпись объекта
|
||||
* (комнаты, площадь, этаж, квартал) — она лежит поверх карты. Без этой полосы
|
||||
* район приходится на середину плашки, и точка примерно в трети случаев
|
||||
* оказывается ЗА текстом: видно её или нет, решал бы район сделки.
|
||||
*/
|
||||
const BAND = 0.3;
|
||||
/** Пропорция кадра под карточку — шире, чем выше. */
|
||||
const RATIO = 1.35;
|
||||
|
||||
interface Box {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly w: number;
|
||||
readonly h: number;
|
||||
}
|
||||
|
||||
const CITY: Box = { x: 0, y: 0, w: EKB_MAP.width, h: EKB_MAP.height };
|
||||
|
||||
/**
|
||||
* bbox готового атрибута `d`. Пути генератора — только абсолютные `M`/`L` с
|
||||
* парами координат, поэтому чисел всегда чётное число и разбор пар точен; для
|
||||
* дуг и относительных команд он бы не годился, но их там нет.
|
||||
*/
|
||||
function pathBox(d: string): Box {
|
||||
const nums = (d.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number);
|
||||
const xs = nums.filter((_, i) => i % 2 === 0);
|
||||
const ys = nums.filter((_, i) => i % 2 === 1);
|
||||
const x = Math.min(...xs);
|
||||
const y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
|
||||
/** Расширяет кадр на PAD, добавляет полосу под подпись и доводит до RATIO. */
|
||||
function frameFor(box: Box): Box {
|
||||
const padW = box.w * (1 + PAD * 2);
|
||||
const padH = box.h * (1 + PAD * 2);
|
||||
const x = box.x + box.w / 2 - padW / 2;
|
||||
const y = box.y + box.h / 2 - padH / 2;
|
||||
const bandH = padH * (1 + BAND);
|
||||
let w = padW;
|
||||
let h = bandH;
|
||||
if (w / h < RATIO) w = h * RATIO;
|
||||
else h = w / RATIO;
|
||||
// Центр по горизонтали сохраняется, по вертикали — нет: район должен сидеть
|
||||
// ВЫШЕ середины, освобождая низ под подпись.
|
||||
return { x: x + padW / 2 - w / 2, y, w, h };
|
||||
}
|
||||
|
||||
const inside = (f: Box, x: number, y: number): boolean =>
|
||||
x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h;
|
||||
|
||||
export interface DealMapProps {
|
||||
/** Район сделки как он записан в данных, либо `null`. */
|
||||
readonly district: string | null;
|
||||
/** Готовая подпись района — «район не указан», когда его нет. */
|
||||
readonly districtLabel: string;
|
||||
readonly lat: number | null;
|
||||
readonly lon: number | null;
|
||||
}
|
||||
|
||||
export function DealMapV3({ district, districtLabel, lat, lon }: DealMapProps) {
|
||||
const home = EKB_DISTRICTS.find((d) => d.name === district);
|
||||
const frame = frameFor(home ? pathBox(home.d) : CITY);
|
||||
|
||||
const p = lat !== null && lon !== null ? project(lon, lat) : null;
|
||||
const dot = p && inside(frame, p.x, p.y) ? p : null;
|
||||
// Толщина линий и радиусы задаются в единицах кадра: кадр района вчетверо
|
||||
// мельче городского, и постоянная в пикселях выглядела бы в нём вчетверо
|
||||
// толще.
|
||||
const u = frame.w / CITY.w;
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
className={styles.gameMapSvg}
|
||||
viewBox={`${frame.x} ${frame.y} ${frame.w} ${frame.h}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{EKB_DISTRICTS.map((d) => (
|
||||
<path
|
||||
key={d.name}
|
||||
d={d.d}
|
||||
className={d === home ? styles.gameMapHome : styles.gameMapAround}
|
||||
strokeWidth={(d === home ? 4 : 2.5) * u}
|
||||
/>
|
||||
))}
|
||||
{dot && (
|
||||
<g>
|
||||
<circle cx={dot.x} cy={dot.y} r={42 * u} className={styles.gameMapHalo} />
|
||||
<circle cx={dot.x} cy={dot.y} r={16 * u} strokeWidth={5 * u} className={styles.gameMapCore} />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
<div className={styles.gameMapLabel}>
|
||||
{`${dot ? "СЕРЕДИНА УЛИЦЫ, НЕ ДОМ" : "КООРДИНАТЫ НЕТ"} · ${districtLabel.toUpperCase()}`}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,10 +28,11 @@
|
|||
* Правило из шапки `lib/city-registry.ts`: город считается известным, только
|
||||
* если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте
|
||||
* адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия
|
||||
* — тот же и дословно тот же, что в v1. Выпадашка города в шапке
|
||||
* (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором
|
||||
* не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал
|
||||
* рядом с адресом.
|
||||
* — тот же и дословно тот же, что в v1. Выпадашки города в шапке больше нет
|
||||
* вовсе (`CityPicker` удалён 29.08.2026, см. JSDoc HeaderV3): она была
|
||||
* презентационной и своим выбором не владела, то есть значение задавалось в
|
||||
* двух местах, а на расчёт влияло только это. Гейт обязан читать то, что
|
||||
* человек указал рядом с адресом, — и теперь это единственное такое место.
|
||||
*
|
||||
* СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его
|
||||
* значение никуда не клалось — человек выбирал состояние квартиры, и мы его
|
||||
|
|
|
|||
|
|
@ -10,11 +10,18 @@
|
|||
* реальных сделках» была неправдой. Сделок мало — игру не рендерит родитель:
|
||||
* играть на одной строке под заголовком «три квартиры» нельзя.
|
||||
*
|
||||
* ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных
|
||||
* витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте — это
|
||||
* утверждение о конкретном месте. Фон-сетка осталась декорацией, объект
|
||||
* подписан тем, что известно: район (или прямое «район не указан»), комнаты,
|
||||
* площадь, этаж, квартал сделки.
|
||||
* КАРТА — настоящая, и их две. Есть схема улиц (`street_scheme`, 92.1% строк
|
||||
* витрины) — рисуется `StreetMapV3`: окрестности улицы сделки. Нет — остаётся
|
||||
* `DealMapV3` с полигоном района, как было. Выбор делается ЗДЕСЬ, чтобы обе
|
||||
* карты оставались тупыми: каждая рисует то, что ей дали, и ни одна не
|
||||
* подставляет вместо отсутствующих данных правдоподобное.
|
||||
*
|
||||
* Обе подписывают себя сами и обе говорят про масштаб: схема улиц — «улица ·
|
||||
* ДОМ НЕ ИЗВЕСТЕН», район — «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ». Раньше тут стоял
|
||||
* декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», заявлявший
|
||||
* местоположение объекта — то, чего в данных нет. Ниже карты объект
|
||||
* по-прежнему подписан только тем, что известно: комнаты, площадь, этаж,
|
||||
* район, квартал сделки. Адреса среди них нет.
|
||||
*
|
||||
* Ошибка игрока считается медианой |ответ − факт| / факт по сыгранным раундам
|
||||
* — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
|
||||
|
|
@ -26,6 +33,8 @@ import { useState } from "react";
|
|||
|
||||
import styles from "../../landing-v3.module.css";
|
||||
import type { GameRound } from "./deal-view";
|
||||
import { DealMapV3 } from "./DealMapV3";
|
||||
import { StreetMapV3 } from "./StreetMapV3";
|
||||
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
|
|
@ -96,11 +105,11 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
|
|||
<div className={styles.gameGrid}>
|
||||
<div className={styles.gameMap}>
|
||||
<div className={styles.gameMapGrid} aria-hidden="true" />
|
||||
<div className={styles.gameMapRoadH} aria-hidden="true" />
|
||||
<div className={styles.gameMapRoadV} aria-hidden="true" />
|
||||
<div className={styles.gameMapLabel}>
|
||||
{`ОБЪЕКТ · ${r.districtLabel.toUpperCase()}`}
|
||||
</div>
|
||||
{r.streetScheme ? (
|
||||
<StreetMapV3 scheme={r.streetScheme} />
|
||||
) : (
|
||||
<DealMapV3 district={r.district} districtLabel={r.districtLabel} lat={r.lat} lon={r.lon} />
|
||||
)}
|
||||
<div className={styles.gameMapBottom}>
|
||||
<div className={styles.gameAddr}>{r.title}</div>
|
||||
<div className={styles.gameMeta}>{r.meta}</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,34 @@
|
|||
/**
|
||||
* HeaderV3 — шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
|
||||
* навигация + селектор города + CTA.
|
||||
* навигация + CTA.
|
||||
*
|
||||
* Логотип НЕ ссылка: на публичном домене корень — это сам лэндинг, то есть
|
||||
* ссылка вела бы на текущую же страницу.
|
||||
*
|
||||
* СЕЛЕКТОРА ГОРОДА ЗДЕСЬ БОЛЬШЕ НЕТ (29.08.2026). `CityPicker` жил в этой
|
||||
* шапке с тех пор, когда карточка проверки города не спрашивала вовсе, и
|
||||
* своим выбором он НЕ владел: состояние оставалось внутри компонента и никуда
|
||||
* не отправлялось (см. его прежний JSDoc). Город у формы появился —
|
||||
* `FreeCheckCard` спрашивает его сама и на нём стоит гео-гейт расчёта (#2576),
|
||||
* — и с этого момента шапка стала вторым местом, задающим одно значение, но
|
||||
* ни на что не влияющим: человек выбирал в шапке Нижний Тагил и всё равно
|
||||
* заполнял город в форме. Правило то же, по которому селектора нет во
|
||||
* внутренней шапке (`_components/InnerHeader.tsx`). Граница покрытия, ради
|
||||
* которой выпадашку когда-то читали, теперь стоит текстом в герое (плашка
|
||||
* «полное покрытие / данных меньше», HeroV3), то есть видна без клика.
|
||||
*
|
||||
* НА УЗКОМ ЭКРАНЕ ШАПКА — ОДНА СТРОКА: лого и «Для бизнеса». Навигация по
|
||||
* секциям и CTA скрыты в CSS (`landing-v3.module.css`, блок шапки) и
|
||||
* возвращаются с той ширины, на которой шапка макета замерена как
|
||||
* однострочная, — а не с общего для файла шага в 720px, где она всё ещё
|
||||
* переносится. На 375px переносилось всё, и липкая шапка занимала 29%
|
||||
* экрана — эти проценты отъедались при любой прокрутке. Куда делись пункты: «Точность»
|
||||
* и «Проверьте себя» — секции этой же страницы, до них доезжают прокруткой
|
||||
* (ради чего на мобильном и скроллят); «Статьи» и «МЕРА для бизнеса» стоят
|
||||
* ссылками в подвале (`FooterV3`); «Продажа под ключ» и на десктопе не
|
||||
* ссылка, а заглушка. CTA не потерян: снизу висит `StickyCtaV3` — на экране
|
||||
* остаётся ОДИН призыв вместо двух одинаковых.
|
||||
*
|
||||
* НЕАКТИВНЫЕ ПУНКТЫ (29.08.2026). Осталась одна «Продажа под ключ» —
|
||||
* партнёрской формы нет ни в коде, ни у владельца, и пункт остаётся видимым,
|
||||
* но некликабельным. «Проверьте себя» и «Статьи» раньше стояли рядом с ней по
|
||||
|
|
@ -21,16 +45,14 @@
|
|||
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
|
||||
* публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе.
|
||||
*
|
||||
* Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск).
|
||||
* Сама шапка серверная.
|
||||
* Шапка целиком серверная: клиентских островов в ней не осталось (последним
|
||||
* был `CityPicker`).
|
||||
*/
|
||||
|
||||
import { B2B_URL } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
import { CityPicker } from "./CityPicker";
|
||||
|
||||
export function HeaderV3() {
|
||||
return (
|
||||
<header className={styles.hdrRoot}>
|
||||
|
|
@ -65,7 +87,6 @@ export function HeaderV3() {
|
|||
</nav>
|
||||
|
||||
<div className={styles.hdrRight}>
|
||||
<CityPicker />
|
||||
<a href={B2B_URL} className={styles.hdrBizLink}>
|
||||
Для бизнеса
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@
|
|||
* того, куда именно в дерево страницы его вставят (ожидаемое место — сразу
|
||||
* после подвала, как Header/Footer в текущем `layout.tsx`).
|
||||
*
|
||||
* НА УЗКОМ ЭКРАНЕ ПАНЕЛЬ — ТОЛЬКО КНОПКА (29.08.2026). Строка «Узнайте цену
|
||||
* своей квартиры…» на 375px переносилась и панель занимала 13% экрана, а
|
||||
* вместе с шапкой хром съедал 42% при любой прокрутке. Текст скрыт в CSS
|
||||
* (`.barText`), а не удалён: с той же ширины, что возвращает шапку макета
|
||||
* (1000px — там строка и кнопка встают рядом), панель снова целиком. То же
|
||||
* обещание на самой странице остаётся словами героя («Бесплатно: сколько
|
||||
* похожих квартир продаётся рядом и как давно они висят»), то есть уходит
|
||||
* повтор, а не мысль. Призыв на мобильном теперь ОДИН: `HeaderV3` свою
|
||||
* кнопку на этой ширине не показывает.
|
||||
*
|
||||
* НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» — замера времени
|
||||
* заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло
|
||||
* из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* StreetMapV3 — схема улицы сделки в карточке игры.
|
||||
*
|
||||
* ЧТО ЭТО ЗАМЕНЯЕТ. `DealMapV3` показывал полигон РАЙОНА — единственную
|
||||
* геометрию, до которой у tradein был доступ. Район — это десятки квадратных
|
||||
* километров; на нём одинаково выглядят сделка у парка и сделка у ТЭЦ. С
|
||||
* 29.08.2026 строка витрины несёт готовую схему улиц (`street_scheme`), и
|
||||
* карточка показывает окрестности улицы, а не пятно в четверть города.
|
||||
*
|
||||
* DealMapV3 ОСТАЁТСЯ. Адрес сделки нормализуется и матчится с названиями OSM у
|
||||
* 550 названий из 654, что покрывает 92.1% строк витрины (замер на проде
|
||||
* 29.08.2026). У остальных схемы нет — там по-прежнему район. Выбор делает
|
||||
* родитель: схема есть → эта карта, нет → та.
|
||||
*
|
||||
* КАРТА ГОВОРИТ, ЧТО ОНА ПОКАЗЫВАЕТ. Подпись — «<улица> · ДОМ НЕ ИЗВЕСТЕН».
|
||||
* Адрес сделки в ДКП-данных уровня улицы, номер дома известен у 2.7% сделок, и
|
||||
* координата окна — центроид улицы. Точки дома тут нет и быть не может: в
|
||||
* `street_scheme` НАМЕРЕННО нет ни констант проекции, ни координат окна —
|
||||
* поставить точку по этой схеме нельзя даже по ошибке. Поэтому карточка не
|
||||
* произносит слов «объект» и «адрес»: она показывает улицу и так и говорит.
|
||||
*
|
||||
* ЗДАНИЙ НЕТ. `cad_buildings` — 18 307 контуров на город; в плотном центре это
|
||||
* 51 здание на радиус 450 м, где их в разы больше. Нарисованная застройка
|
||||
* заявляла бы полноту, которой в данных нет, и пустые кварталы читались бы как
|
||||
* пустыри. Улицы — тоже фильтрованная выгрузка («источники шума»): именованные
|
||||
* покрыты хорошо, дворовые и служебные проезды отсутствуют, поэтому фоновые
|
||||
* дороги нарисованы приглушённо — как контекст, а не как план квартала.
|
||||
*
|
||||
* НИ ОДНОГО ВНЕШНЕГО ЗАПРОСА, как и у соседа: тайлы отдали бы провайдеру IP
|
||||
* посетителя и то, что он смотрит, на странице, которая обещает «без звонков и
|
||||
* регистрации» (то же правило, по которому в дереве self-hosted шрифты — см.
|
||||
* шапку layout.tsx). Всё, что нужно, уже приехало строкой витрины.
|
||||
*
|
||||
* КАДР. `slice`, а не `meet`: окно квадратное, карточка — нет, и `meet`
|
||||
* оставил бы поля по бокам, а карта на плашке читается как карта, только когда
|
||||
* доходит до краёв. Обрезать безопасно ровно потому, что целевая улица лежит в
|
||||
* ЦЕНТРЕ окна по построению (окно строится вокруг её центроида) — при любой
|
||||
* пропорции карточки центр остаётся в кадре. Обрезаются края: часть подписей
|
||||
* соседних улиц. Они контекст, а не содержание.
|
||||
*
|
||||
* ДОСТУПНОСТЬ — как у DealMapV3, `aria-hidden`. Всё, что схема сообщает —
|
||||
* какая это улица и что дом не известен, — стоит рядом текстом, подписью под
|
||||
* картой. `role="img"` с `aria-label` заставил бы скринридер прочитать то же
|
||||
* самое дважды подряд; названий соседних улиц он не сообщит всё равно —
|
||||
* геометрию словами не передать, а список из семи улиц без их взаимного
|
||||
* расположения ничего не значит.
|
||||
*
|
||||
* Компонент чистый и без состояния — "use client" в нём нет. В клиентский
|
||||
* бандл он попадает вместе с карточкой игры, которая листает раунды.
|
||||
*/
|
||||
|
||||
import type { StreetScheme } from "../../public-api";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
/**
|
||||
* Толщина фоновых дорог по классу, в единицах кадра (окно 840 м = 1000
|
||||
* единиц). Иерархия магистраль → проезд, а не одна линия на всё: без неё
|
||||
* схема превращается в решётку, на которой не видно, куда улица ведёт.
|
||||
*/
|
||||
const ROAD_W: Record<string, number> = {
|
||||
trunk: 7,
|
||||
primary: 6,
|
||||
secondary: 4.6,
|
||||
tertiary: 3.4,
|
||||
residential: 2.2,
|
||||
};
|
||||
const ROAD_W_FALLBACK = 2.2;
|
||||
|
||||
/** Целевая улица: акцент поверх мягкого ореола той же формы. */
|
||||
const TARGET_W = 6;
|
||||
const HALO_W = 18;
|
||||
/** Вода одной толщиной: ручей и река различаются шириной, которой у нас нет. */
|
||||
const WATER_W = 5;
|
||||
const LABEL_SIZE = 21;
|
||||
|
||||
export function StreetMapV3({ scheme }: { readonly scheme: StreetScheme }) {
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
className={styles.gameMapSvg}
|
||||
viewBox={`0 0 ${scheme.w} ${scheme.h}`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{scheme.water.map((d, i) => (
|
||||
<path key={`w${i}`} d={d} className={styles.streetWater} strokeWidth={WATER_W} />
|
||||
))}
|
||||
{scheme.roads.map((r, i) => (
|
||||
<path
|
||||
key={`r${i}`}
|
||||
d={r.d}
|
||||
className={styles.streetRoad}
|
||||
strokeWidth={ROAD_W[r.c] ?? ROAD_W_FALLBACK}
|
||||
/>
|
||||
))}
|
||||
{scheme.target.map((d, i) => (
|
||||
<path key={`h${i}`} d={d} className={styles.streetHalo} strokeWidth={HALO_W} />
|
||||
))}
|
||||
{scheme.target.map((d, i) => (
|
||||
<path key={`t${i}`} d={d} className={styles.streetTarget} strokeWidth={TARGET_W} />
|
||||
))}
|
||||
{scheme.labels.map((l) => (
|
||||
<text key={l.t} x={l.x} y={l.y} className={styles.streetLabel} fontSize={LABEL_SIZE}>
|
||||
{l.t}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
<div className={styles.gameMapLabel}>{`${scheme.street.toUpperCase()} · ДОМ НЕ ИЗВЕСТЕН`}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
/**
|
||||
* Форматирование строк витрины `/showcase` — общее для ленты, таблицы сверок и
|
||||
* игры. Держится в одном месте, потому что «объект» в этих данных описывается
|
||||
* ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). Улицы и
|
||||
* дома в данных нет вовсе — номер дома известен у 2.7% сделок, — и три
|
||||
* компонента, каждый со своей склейкой, рано или поздно разъехались бы в том,
|
||||
* что именно они выдают за адрес.
|
||||
* ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). НАЗВАНИЯ
|
||||
* улицы и номера дома в данных нет — номер дома известен у 2.7% сделок, — и
|
||||
* три компонента, каждый со своей склейкой, рано или поздно разъехались бы в
|
||||
* том, что именно они выдают за адрес.
|
||||
*
|
||||
* С 29.08.2026 у строки есть координата, но адресом она не становится: это
|
||||
* центроид УЛИЦЫ (около тридцати сделок в одной точке), он годится карте
|
||||
* масштаба района и не годится подписи «объект находится здесь». Поэтому
|
||||
* координата уезжает только в карту, а в текстовые склейки — нет.
|
||||
*/
|
||||
|
||||
import type { ShowcaseDeal } from "../../public-api";
|
||||
import type { ShowcaseDeal, StreetScheme } from "../../public-api";
|
||||
|
||||
const RUB = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
||||
const PCT = new Intl.NumberFormat("ru-RU", {
|
||||
|
|
@ -25,8 +30,17 @@ export const count = (value: number): string => RUB.format(value);
|
|||
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
||||
|
||||
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
|
||||
export const dealTitle = (deal: ShowcaseDeal): string =>
|
||||
`${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`;
|
||||
/**
|
||||
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
|
||||
* называет и наш собственный бэктест (`per_rooms.label` в
|
||||
* scripts/backtest_estimator.py), и рынок. На витрине таких 3 из 20 (замер на
|
||||
* проде 29.08.2026), и «0-к, 25,9 м²» читалось бы как ошибка выгрузки, а не
|
||||
* как тип квартиры.
|
||||
*/
|
||||
export const dealTitle = (deal: ShowcaseDeal): string => {
|
||||
const area = `${AREA.format(deal.area_m2)} м²`;
|
||||
return deal.rooms === 0 ? `Студия, ${area}` : `${deal.rooms}-к, ${area}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а
|
||||
|
|
@ -51,6 +65,21 @@ export interface GameRound {
|
|||
readonly meta: string;
|
||||
/** Район или прямое «район не указан» — подстановки нет. */
|
||||
readonly districtLabel: string;
|
||||
/**
|
||||
* Район как он записан в данных — им карта ищет полигон, и подставлять
|
||||
* вместо `null` подпись «район не указан» нельзя: совпадений с именами
|
||||
* районов у неё не будет, но выглядеть это будет как поиск.
|
||||
*/
|
||||
readonly district: string | null;
|
||||
/** Центроид УЛИЦЫ сделки, не дом (см. `note` строки витрины). */
|
||||
readonly lat: number | null;
|
||||
readonly lon: number | null;
|
||||
/**
|
||||
* Готовая схема улиц вокруг центра улицы сделки — либо `null` у тех строк,
|
||||
* чей адрес не сматчился с OSM. Раунду хватает одного поля: район в нём уже
|
||||
* есть и остаётся запасным вариантом карты, подставлять вместо схемы нечего.
|
||||
*/
|
||||
readonly streetScheme: StreetScheme | null;
|
||||
readonly quarter: string;
|
||||
/** Границы слайдера, млн ₽. */
|
||||
readonly min: number;
|
||||
|
|
@ -86,6 +115,10 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
|
|||
title: dealTitle(deal),
|
||||
meta: dealMeta(deal),
|
||||
districtLabel: deal.district ?? "район не указан",
|
||||
district: deal.district,
|
||||
lat: deal.lat,
|
||||
lon: deal.lon,
|
||||
streetScheme: deal.street_scheme,
|
||||
quarter: deal.deal_quarter,
|
||||
min,
|
||||
max,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Границы восьми районов Екатеринбурга в координатах SVG — данные для карты в
|
||||
* секции «Сыграйте против МЕРЫ».
|
||||
*
|
||||
* ЭТОТ ФАЙЛ СГЕНЕРИРОВАН, РУКАМИ НЕ ПРАВИТЬ. Источник — боевая геометрия
|
||||
* (`gendesign_ekb_districts_geom`, OSM через FDW), генератор —
|
||||
* `backend/scripts/export_ekb_districts_svg.py`. Там же записано, почему
|
||||
* геометрия едет статикой, а не тайлами: публичное дерево держится без единого
|
||||
* внешнего запроса, а тайловый провайдер получал бы IP посетителя и то, что он
|
||||
* смотрит, — на странице, которая обещает «без звонков и регистрации».
|
||||
*
|
||||
* ТОЧНОСТЬ. Упрощение 0.0012° (~70-130 м на этой широте) выбрано под масштаб
|
||||
* карточки: она показывает район целиком, где такая ошибка меньше толщины
|
||||
* линии. Для более крупного масштаба файл не годится — перегенерируй.
|
||||
*
|
||||
* ПРОЕКЦИЯ. Равнопромежуточная с поправкой cos(широты). `project()` кладёт
|
||||
* произвольную точку (lon, lat) в ту же систему координат, что и пути, — без
|
||||
* гео-библиотеки и без математики сложнее двух умножений.
|
||||
*/
|
||||
|
||||
export interface EkbDistrict {
|
||||
/** Имя ровно как в БД: по нему витрина сопоставляет сделку с полигоном. */
|
||||
readonly name: string;
|
||||
/** Готовый атрибут `d` для <path>. */
|
||||
readonly d: string;
|
||||
}
|
||||
|
||||
/** Константы проекции: едут вместе с путями, иначе точку не положить рядом. */
|
||||
export const EKB_MAP = {
|
||||
width: 1000.0,
|
||||
height: 758.5,
|
||||
minLon: 60.0070815,
|
||||
maxLat: 56.9826914,
|
||||
/** cos(широты центра) — поправка на сжатие долготы. */
|
||||
k: 0.5477352775,
|
||||
scale: 1950.117127,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Точка (lon, lat) → координаты внутри `EKB_MAP`. Возвращает точку и за
|
||||
* пределами города: обрезать решает вызывающий, здесь молча зажимать нельзя —
|
||||
* сделка за границей карты означает ошибку данных, а не край экрана.
|
||||
*/
|
||||
export function project(lon: number, lat: number): { x: number; y: number } {
|
||||
return {
|
||||
x: (lon - EKB_MAP.minLon) * EKB_MAP.k * EKB_MAP.scale,
|
||||
y: (EKB_MAP.maxLat - lat) * EKB_MAP.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export const EKB_DISTRICTS: readonly EkbDistrict[] = [
|
||||
{ name: "Академический", d: "M559.5 342.4L604.2 372.8L589.1 395.3L572.0 412.3L542.1 435.0L529.7 439.1L475.0 402.6L470.7 396.8L402.4 355.9L416.1 351.7L422.2 341.2L432.5 339.1L436.7 330.4L446.2 323.6L468.4 329.2L484.5 325.7L494.4 318.5L526.5 314.5L559.5 342.4Z" },
|
||||
{ name: "Верх-Исетский", d: "M637.0 281.5L614.0 285.5L621.2 325.3L603.8 325.8L589.3 345.4L576.1 336.4L573.7 340.1L564.2 335.0L559.5 342.4L526.5 314.5L494.4 318.5L484.5 325.7L468.4 329.2L446.9 323.5L441.9 325.7L432.5 339.1L421.6 341.8L416.1 351.7L402.4 355.9L470.7 396.8L475.0 402.6L507.3 422.8L504.3 434.5L486.7 431.1L484.7 440.6L475.7 439.0L477.7 429.4L448.8 423.2L422.7 446.4L416.3 457.0L406.7 463.3L375.3 455.5L369.0 494.5L313.4 484.4L308.6 509.6L297.8 512.2L291.2 499.1L297.2 494.7L296.4 491.8L300.1 482.1L291.5 480.4L286.4 496.5L271.0 505.7L204.2 484.9L179.9 446.0L175.6 446.6L174.6 459.1L172.5 459.0L170.7 453.9L158.8 447.2L155.2 440.6L143.2 433.2L140.9 428.3L136.8 428.0L118.8 413.2L117.2 408.6L119.9 405.3L116.0 402.1L114.1 395.8L102.0 398.6L99.9 395.3L95.3 398.7L86.3 393.7L81.3 383.4L74.2 376.6L68.6 377.1L67.4 387.0L56.0 389.3L53.5 386.9L52.7 375.1L50.2 374.0L55.3 365.4L61.3 363.7L58.9 357.1L60.3 351.1L70.6 355.9L75.6 349.4L67.9 342.3L69.5 338.6L68.1 335.0L59.3 328.7L57.2 329.4L56.7 334.6L59.3 343.8L52.5 342.9L51.9 346.4L49.3 346.3L40.1 337.7L28.7 346.5L19.2 343.9L14.7 349.8L17.8 358.6L4.8 351.7L0.0 344.8L4.2 341.1L7.5 346.9L15.3 337.0L37.5 331.6L45.8 331.6L49.5 335.4L57.1 315.7L67.3 309.8L85.1 318.1L89.6 316.7L88.4 323.0L93.1 322.5L99.6 315.0L104.9 315.3L109.1 319.6L104.8 327.0L105.2 334.0L111.3 333.9L115.5 325.7L122.4 325.9L128.5 331.6L136.3 329.7L129.3 334.1L129.6 334.2L140.2 328.5L128.4 335.7L145.1 331.3L148.1 335.4L147.3 341.2L149.5 345.3L156.8 346.3L157.4 349.6L152.1 352.4L156.8 352.1L154.9 355.8L160.1 360.7L161.0 368.1L157.2 372.2L163.1 378.7L161.4 387.5L165.3 390.5L162.8 393.7L169.3 393.6L170.1 396.7L167.8 402.8L171.9 408.2L176.9 405.3L176.8 410.4L184.5 410.6L189.6 403.3L190.9 396.1L197.7 389.6L324.3 411.0L346.0 287.6L340.7 287.3L337.1 278.8L328.3 269.9L302.8 269.0L281.7 262.2L297.8 257.5L315.5 245.6L334.4 248.6L390.2 233.4L419.3 213.7L425.4 205.9L428.8 206.2L432.2 213.3L426.7 222.4L440.1 236.1L452.9 222.1L461.6 223.1L459.7 211.9L463.4 205.5L483.5 214.5L485.2 211.4L498.5 211.2L505.7 219.7L514.0 216.5L531.2 234.2L541.9 234.7L552.8 241.8L599.1 243.1L606.9 246.8L601.6 255.1L603.4 258.6L600.9 259.9L603.8 262.2L601.8 264.8L619.4 261.3L635.1 273.2L637.0 281.5Z" },
|
||||
{ name: "Железнодорожный", d: "M645.0 206.7L646.8 237.5L653.7 239.8L660.6 259.8L642.2 263.3L635.1 273.2L619.4 261.3L601.8 264.8L603.8 262.2L600.9 259.9L603.4 258.6L601.6 255.1L606.9 246.8L599.1 243.1L552.8 241.8L541.9 234.7L531.2 234.2L514.0 216.5L505.7 219.7L498.5 211.2L485.2 211.4L483.5 214.5L463.4 205.5L459.7 211.9L461.6 223.1L452.9 222.1L440.1 236.1L426.7 222.4L432.2 213.3L428.8 206.2L425.4 205.9L419.3 213.7L390.2 233.4L334.4 248.6L315.5 245.6L299.4 256.7L282.6 262.1L276.5 250.1L270.7 251.6L280.2 197.8L282.6 198.0L291.0 141.8L325.6 147.0L341.2 35.1L378.0 41.2L372.4 78.3L391.4 82.1L389.6 99.0L423.0 108.8L425.6 116.9L434.2 124.1L491.5 130.9L497.9 116.8L501.1 114.8L497.6 111.0L510.5 101.4L531.4 93.6L538.4 84.3L534.6 127.6L538.5 147.0L551.5 165.7L581.1 187.1L579.4 196.8L592.4 214.6L600.8 223.1L606.2 223.0L628.3 216.2L645.0 206.7Z" },
|
||||
{ name: "Кировский", d: "M668.3 275.1L637.0 281.5L635.1 273.2L642.2 263.3L660.6 259.8L657.8 254.8L656.7 245.3L649.7 237.2L658.3 230.8L656.2 227.8L669.5 223.7L669.7 220.3L675.0 222.3L678.9 219.8L680.0 215.0L690.4 215.6L704.2 225.6L711.5 225.4L714.3 206.0L720.8 196.2L717.7 194.7L726.0 182.8L725.3 176.5L720.7 168.0L722.6 156.1L722.1 139.7L740.0 124.6L747.7 107.6L760.3 94.4L763.0 87.7L764.3 93.6L781.5 102.0L780.8 117.7L782.3 120.0L779.0 123.6L782.9 124.5L778.1 150.4L773.7 150.2L777.7 151.9L777.3 154.5L765.3 161.4L764.9 176.8L789.6 181.8L784.8 213.8L797.1 213.4L818.4 221.6L818.5 224.9L826.8 222.5L833.6 228.6L838.2 227.6L835.9 234.9L816.6 239.0L815.9 243.4L1000.0 277.7L998.2 288.1L993.3 288.6L779.6 298.0L789.7 298.8L789.8 312.9L804.4 315.8L800.9 335.0L732.3 308.7L709.2 296.4L692.7 298.7L671.8 288.0L668.3 275.1Z" },
|
||||
{ name: "Ленинский", d: "M529.7 439.1L542.1 435.0L572.0 412.3L589.1 395.3L604.2 372.8L559.5 342.4L564.2 335.0L573.7 340.1L576.1 336.4L589.3 345.4L603.8 325.8L621.2 325.3L614.0 285.5L645.6 279.8L648.6 300.9L639.6 303.7L645.3 306.5L650.9 316.3L667.1 325.1L673.3 333.9L632.0 341.9L634.6 356.7L627.9 357.7L631.3 386.6L604.1 390.5L601.9 400.7L595.7 405.4L595.6 411.6L601.1 425.2L614.7 432.9L616.9 446.7L609.7 452.9L606.5 460.1L593.9 451.1L551.5 444.4L549.6 449.3L529.7 439.1Z" },
|
||||
{ name: "Октябрьский", d: "M657.9 337.0L673.3 333.9L667.1 325.1L650.9 316.3L645.3 306.5L639.6 303.7L648.6 300.9L645.6 279.8L668.3 275.1L671.8 288.0L692.7 298.7L709.2 296.4L732.3 308.7L800.9 335.0L804.4 315.8L789.8 312.9L789.7 298.8L779.6 298.0L998.0 288.1L969.6 425.2L973.6 421.0L976.1 424.1L973.8 425.4L974.9 427.4L966.4 430.9L963.2 427.8L953.3 429.9L947.6 434.3L942.4 444.4L936.2 447.1L931.3 476.4L928.6 477.3L917.0 500.9L923.3 504.7L943.2 502.1L958.9 515.4L969.1 518.7L956.7 527.2L925.5 559.6L916.9 545.7L916.7 541.2L913.7 540.7L894.9 510.1L878.8 512.8L869.1 520.3L831.1 493.0L831.3 488.7L824.7 485.2L823.8 474.8L817.5 470.4L818.5 463.0L798.9 449.1L809.0 437.1L807.4 431.5L784.8 422.3L744.2 394.2L683.5 361.1L677.9 368.5L667.0 358.5L657.9 337.0Z" },
|
||||
{ name: "Орджоникидзевский", d: "M649.7 237.2L646.8 237.5L646.1 233.6L645.0 206.7L626.2 217.0L600.8 223.1L579.4 196.8L581.1 187.1L545.1 159.0L537.2 143.7L534.8 131.6L538.4 84.3L543.8 88.8L559.5 83.0L609.4 76.8L644.6 81.2L648.0 77.3L660.1 75.2L693.9 76.6L704.5 57.0L700.8 54.4L709.9 46.5L707.9 40.4L707.7 13.3L728.0 16.8L730.9 0.0L766.8 6.8L765.1 16.3L779.1 16.5L776.5 24.5L783.1 29.4L784.8 23.8L791.3 24.8L793.6 13.4L787.0 14.3L792.7 11.8L802.7 13.8L798.7 34.2L801.8 34.8L800.5 41.7L809.3 42.8L812.4 39.8L811.8 46.9L809.3 46.8L807.9 53.5L779.3 48.0L771.8 55.2L772.6 59.7L757.9 66.3L763.0 87.7L760.3 94.4L747.7 107.6L740.0 124.6L722.1 139.7L722.6 156.1L720.7 168.0L725.3 176.5L726.0 182.8L717.7 194.7L720.8 196.2L714.3 206.0L711.5 225.4L704.2 225.6L690.4 215.6L680.0 215.0L678.9 219.8L675.0 222.3L669.7 220.3L669.5 223.7L656.2 227.8L658.3 230.8L649.7 237.2Z" },
|
||||
{ name: "Чкаловский", d: "M616.5 447.2L613.9 431.4L601.1 425.2L595.0 406.9L601.9 400.7L604.1 390.5L631.3 386.6L627.9 357.7L634.6 356.7L632.0 341.9L657.9 337.0L667.0 358.5L677.9 368.5L683.5 361.1L744.2 394.2L784.8 422.3L807.4 431.5L809.0 437.1L798.9 449.1L813.4 460.7L804.3 470.8L794.9 488.1L786.0 487.1L784.5 481.9L776.0 475.8L760.9 491.4L765.8 495.8L779.7 497.8L780.2 507.2L787.8 519.4L780.6 527.8L791.9 544.5L795.7 559.4L809.6 561.4L813.6 549.8L829.8 549.3L829.8 551.7L824.6 552.0L824.3 561.6L804.7 561.9L793.9 560.0L779.0 551.1L779.3 555.6L694.2 601.2L692.5 646.3L645.1 642.3L641.6 655.2L657.8 660.3L655.2 674.0L650.7 678.4L665.2 703.5L689.4 708.5L679.3 733.5L635.6 713.6L626.6 703.8L622.0 707.1L613.6 700.8L608.3 727.6L616.4 731.0L611.8 735.3L604.6 733.4L592.5 740.1L586.2 736.6L589.3 719.6L572.7 716.3L571.3 723.0L563.7 721.5L561.8 730.9L572.9 748.5L570.7 758.5L566.5 746.9L559.9 740.3L535.5 735.7L551.1 732.6L547.8 724.5L526.5 730.1L524.0 740.3L460.6 715.5L471.9 695.3L428.2 661.8L432.8 653.8L428.8 651.2L456.0 624.4L450.6 621.3L451.6 613.0L455.3 607.3L441.4 609.5L427.4 619.6L368.9 636.9L362.1 633.3L360.9 640.1L358.2 641.1L348.3 635.8L338.7 638.0L335.8 634.4L337.4 625.2L334.7 621.0L306.0 611.1L301.8 603.6L303.6 602.8L302.2 593.1L296.7 584.1L279.5 584.8L274.9 580.4L274.3 577.0L277.9 570.8L276.4 570.0L281.9 561.8L279.6 557.2L272.6 558.5L273.2 548.2L263.1 531.5L265.3 522.2L243.9 508.5L211.1 499.9L192.7 487.0L186.2 490.9L170.1 468.6L173.0 468.5L171.7 462.2L174.6 459.1L175.6 446.6L179.9 446.0L204.2 484.9L271.0 505.7L286.4 496.5L291.5 480.4L300.1 482.1L296.4 491.8L297.2 494.7L291.2 499.1L297.8 512.2L308.6 509.6L313.4 484.4L369.0 494.5L375.3 455.5L406.7 463.3L416.3 457.0L422.7 446.4L448.8 423.2L477.7 429.4L475.7 439.0L484.7 440.6L486.7 431.1L504.3 434.5L507.3 422.8L533.9 442.9L549.6 449.3L551.5 444.4L593.9 451.1L606.5 460.1L609.7 452.9L616.5 447.2Z" },
|
||||
];
|
||||
|
|
@ -47,6 +47,28 @@ export const b2c = {
|
|||
line: "#CBD7DA",
|
||||
/** Тонкие внутренние разделители. */
|
||||
lineSoft: "#EAF5F7",
|
||||
/**
|
||||
* Линия НА ТЁМНОЙ плашке (`surfaceDark`): рамки тегов, контуры соседних
|
||||
* районов на карте сделки. Тот же оттенок, что уже стоял литералом в
|
||||
* `landing-v3.module.css`; заведён токеном, когда карта потребовала его
|
||||
* второй раз — правило «hex только здесь» иначе разъезжается.
|
||||
*/
|
||||
lineDark: "#2C3A3F",
|
||||
|
||||
/**
|
||||
* Приглушённый текст НА ТЁМНОЙ плашке. `muted` (#667579) на `surfaceDark`
|
||||
* даёт 3.2:1 и для подписей не годится; этот — 6.5:1. Заведён под подписи
|
||||
* соседних улиц на схеме карты сделки.
|
||||
*/
|
||||
mutedDark: "#8C9CA1",
|
||||
|
||||
/**
|
||||
* Вода на схеме улиц (реки, каналы, ручьи, пруды). Отдельный оттенок, а не
|
||||
* `line-dark`: вода и дорога — разные вещи, и одинаковая линия склеила бы
|
||||
* их в одну сетку. Синий увод от акцента, чтобы вода не читалась как
|
||||
* подсвеченная улица.
|
||||
*/
|
||||
water: "#2A5A78",
|
||||
|
||||
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
|
||||
accent: "#16A9BC",
|
||||
|
|
|
|||
|
|
@ -17,14 +17,15 @@
|
|||
*
|
||||
* ------------------------------------------------------------------------
|
||||
* ЭТОТ БЛОК (шапка / подвал / sticky-CTA) — префиксы hdr / ftr / bar.
|
||||
* Секция «Шапка» = HeaderV3.tsx + CityPicker.tsx.
|
||||
* Секция «Шапка» = HeaderV3.tsx (селектор города из неё убран 29.08.2026:
|
||||
* город спрашивает сама форма проверки, FreeCheckCard).
|
||||
* Секция «Подвал» = FooterV3.tsx.
|
||||
* Секция «Sticky CTA» = StickyCtaV3.tsx.
|
||||
* ------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
* Шапка (HeaderV3 + CityPicker)
|
||||
* Шапка (HeaderV3)
|
||||
* ========================================================================== */
|
||||
|
||||
.hdrRoot {
|
||||
|
|
@ -40,11 +41,15 @@
|
|||
border-bottom: 1px solid var(--b2c-line);
|
||||
}
|
||||
|
||||
/* База — узкий экран: в шапке остаются только лого и «Для бизнеса», они
|
||||
помещаются в одну строку, поэтому и высота узкая (`flex-wrap` тут страховка,
|
||||
а не режим работы). Навигация и CTA возвращаются в @media-блоке в конце
|
||||
секции. */
|
||||
.hdrInner {
|
||||
max-width: 1280px;
|
||||
margin-inline: auto;
|
||||
padding: 10px clamp(20px, 5vw, 56px);
|
||||
min-height: 64px;
|
||||
padding: 8px clamp(20px, 5vw, 56px);
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px 26px;
|
||||
|
|
@ -101,7 +106,10 @@
|
|||
}
|
||||
|
||||
.hdrNav {
|
||||
display: flex;
|
||||
/* База — узкий экран, где навигации по секциям НЕТ: четыре пункта
|
||||
переносились в две строки и вместе с остальным делали липкую шапку в 29%
|
||||
экрана. Куда делись пункты — в шапке HeaderV3.tsx. */
|
||||
display: none;
|
||||
gap: 10px 22px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14.5px;
|
||||
|
|
@ -119,8 +127,9 @@
|
|||
color: var(--b2c-accent-text);
|
||||
}
|
||||
|
||||
/* «Статьи» — раздела нет в коде (ни маршрута, ни контента): пункт нарочно
|
||||
неинтерактивен (aria-disabled, без href), а не ссылка в 404. */
|
||||
/* «Продажа под ключ» — раздела нет в коде (ни маршрута, ни контента): пункт
|
||||
нарочно неинтерактивен (aria-disabled, без href), а не ссылка в 404.
|
||||
«Статьи» стояли здесь же, пока не появился `articles/`. */
|
||||
.hdrNavDisabled {
|
||||
color: var(--b2c-muted);
|
||||
opacity: 0.65;
|
||||
|
|
@ -164,7 +173,11 @@
|
|||
color: var(--b2c-on-accent);
|
||||
}
|
||||
|
||||
/* На узком экране призыв в шапке скрыт: он вёл туда же, куда липкая нижняя
|
||||
панель (`.barRoot`), и два одинаковых призыва на одном экране — это ровно то
|
||||
«много всплывашек», на которое жалуются. Панель остаётся, шапка отдаёт. */
|
||||
.hdrCta {
|
||||
display: none;
|
||||
padding: 11px 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
@ -174,146 +187,24 @@
|
|||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
/* -- Селектор города (CityPicker) ---------------------------------------- */
|
||||
/* Шапка макета целиком — с той ширины, на которой она РЕАЛЬНО влезает в одну
|
||||
строку (замерено: 1000px — лого, четыре пункта, «Для бизнеса» и кнопка;
|
||||
на 720px то же самое переносится в две строки и даёт 109px липкого хрома,
|
||||
то есть ровно тот дефект, от которого узкий экран и лечили). Отсюда шаг
|
||||
1000, а не общий для файла 720. */
|
||||
@media (min-width: 1000px) {
|
||||
.hdrInner {
|
||||
padding-block: 10px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.hdrCityWrap {
|
||||
position: relative;
|
||||
}
|
||||
.hdrNav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hdrCityBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 6px;
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
font-family: var(--b2c-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--b2c-ink);
|
||||
}
|
||||
|
||||
.hdrCityBtn:hover {
|
||||
border-color: var(--b2c-accent);
|
||||
color: var(--b2c-accent-text);
|
||||
}
|
||||
|
||||
.hdrCityDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: var(--b2c-accent);
|
||||
}
|
||||
|
||||
.hdrCityCaret {
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
.hdrCityPanel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: 0;
|
||||
z-index: 80;
|
||||
width: min(84vw, 340px);
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
background: var(--b2c-surface);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 24px 64px color-mix(in srgb, var(--b2c-ink) 18%, transparent);
|
||||
}
|
||||
|
||||
.hdrCitySearchRow {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--b2c-line-soft);
|
||||
}
|
||||
|
||||
.hdrCityInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 6px;
|
||||
padding: 11px 14px;
|
||||
font-family: var(--b2c-font-sans);
|
||||
font-size: 14.5px;
|
||||
color: var(--b2c-ink);
|
||||
background: var(--b2c-page-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hdrCityInput:focus {
|
||||
border-color: var(--b2c-accent);
|
||||
background: var(--b2c-surface);
|
||||
}
|
||||
|
||||
.hdrCityList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.hdrCityItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 11px 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: var(--b2c-font-sans);
|
||||
}
|
||||
|
||||
.hdrCityItem:hover {
|
||||
background: var(--b2c-page-bg);
|
||||
}
|
||||
|
||||
/* Текущий выбранный город (проставляется из CityPicker.tsx как data-атрибут,
|
||||
не класс — состояние живёт в JS, а не в CSS-модуле). */
|
||||
.hdrCityItem[data-active="true"] {
|
||||
background: var(--b2c-surface-soft);
|
||||
}
|
||||
|
||||
.hdrCityItem[data-active="true"] .hdrCityItemName {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hdrCityItemName {
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--b2c-ink);
|
||||
}
|
||||
|
||||
.hdrCityItemNote {
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--b2c-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hdrCityEmpty {
|
||||
margin: 0;
|
||||
padding: 22px 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
.hdrCityFooter {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--b2c-line-soft);
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--b2c-muted);
|
||||
.hdrCta {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
|
|
@ -472,16 +363,10 @@
|
|||
/* Компенсация fixed-позиционирования `.barRoot` (тот вырезан из потока
|
||||
документа): без спейсера панель легла бы поверх последних пикселей
|
||||
страницы при докрутке до конца (реквизиты в подвале, последний вопрос
|
||||
FAQ). Высота — на мобильном тексте+кнопка чаще переносятся на две строки
|
||||
(flex-wrap), поэтому база выше; на 720px+ строка одна. */
|
||||
FAQ). Высота одна на все ширины: панель везде в одну строку — на узком
|
||||
экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
|
||||
.barSpacer {
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.barSpacer {
|
||||
height: 68px;
|
||||
}
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
.barRoot {
|
||||
|
|
@ -495,14 +380,20 @@
|
|||
justify-content: center;
|
||||
gap: 16px 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px clamp(16px, 4vw, 32px);
|
||||
padding: 10px clamp(16px, 4vw, 32px);
|
||||
/* Фоллбэк на непрозрачный surfaceDark для браузеров без color-mix(). */
|
||||
background: var(--b2c-surface-dark);
|
||||
background: color-mix(in srgb, var(--b2c-surface-dark) 97%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* На узком экране панель — только кнопка. Строка «Узнайте цену своей
|
||||
квартиры…» переносилась на две-три строки и делала постоянную панель втрое
|
||||
выше кнопки; то же самое сказано словами в герое (заголовок, лид и перк
|
||||
«Бесплатно: сколько похожих квартир продаётся рядом»), то есть со страницы
|
||||
мысль не пропадает — пропадает её третий повтор поверх контента. */
|
||||
.barText {
|
||||
display: none;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
|
|
@ -518,6 +409,18 @@
|
|||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
/* Панель макета целиком — с той же ширины, что и шапка (1000px): ниже неё
|
||||
текст и кнопка тоже переносились, и панель становилась вдвое выше. */
|
||||
@media (min-width: 1000px) {
|
||||
.barRoot {
|
||||
padding-block: 12px;
|
||||
}
|
||||
|
||||
.barText {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* HERO (hero*) — первый экран: герой-текст + карточка «Бесплатная проверка».
|
||||
* Секция «Герой» = HeroV3.tsx + FreeCheckCard.tsx + FreeResultV3.tsx.
|
||||
|
|
@ -1643,61 +1546,83 @@
|
|||
.gameMapGrid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.16;
|
||||
opacity: 0.08;
|
||||
background-image:
|
||||
linear-gradient(var(--b2c-accent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--b2c-accent) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
}
|
||||
.gameMapRoadH {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 38%;
|
||||
height: 14px;
|
||||
background: #2c3a3f;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.gameMapRoadV {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 62%;
|
||||
width: 12px;
|
||||
background: #2c3a3f;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.gameMapDot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
}
|
||||
.gameMapPing {
|
||||
.gameMapSvg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: var(--b2c-accent);
|
||||
animation: gamePing 2.4s ease-out infinite;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Район сделки: заливка акцентом поверх фон-сетки плюс сплошная обводка. */
|
||||
.gameMapHome {
|
||||
fill: var(--b2c-accent);
|
||||
fill-opacity: 0.16;
|
||||
stroke: var(--b2c-accent);
|
||||
}
|
||||
/* Соседние районы — контекст, а не содержание: только контур на тёмном. */
|
||||
.gameMapAround {
|
||||
fill: none;
|
||||
stroke: var(--b2c-line-dark);
|
||||
}
|
||||
.gameMapHalo {
|
||||
fill: var(--b2c-accent);
|
||||
fill-opacity: 0.28;
|
||||
}
|
||||
.gameMapCore {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: var(--b2c-accent);
|
||||
border: 3px solid var(--b2c-surface-dark);
|
||||
fill: var(--b2c-accent);
|
||||
stroke: var(--b2c-surface-dark);
|
||||
}
|
||||
@keyframes gamePing {
|
||||
0% { transform: scale(1); opacity: 0.55; }
|
||||
70%, 100% { transform: scale(2.6); opacity: 0; }
|
||||
/* --- Схема улиц (StreetMapV3). Порядок в разметке = порядок слоёв. --- */
|
||||
/* Вода: под дорогами, своим оттенком — иначе сливается с сеткой улиц. */
|
||||
.streetWater {
|
||||
fill: none;
|
||||
stroke: var(--b2c-water);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gameMapPing { animation: none; }
|
||||
/* Фоновые дороги — контекст. Выгрузка неполна (нет дворовых проездов),
|
||||
поэтому линия приглушена: это не план квартала. `line-dark` на плашке
|
||||
почти не виден — берём приглушённый текстовый оттенок и гасим прозрачностью,
|
||||
чтобы сеть читалась, но не спорила с подсвеченной улицей. */
|
||||
.streetRoad {
|
||||
fill: none;
|
||||
stroke: var(--b2c-muted-dark);
|
||||
stroke-opacity: 0.45;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
/* Ореол целевой улицы — та же форма, шире и почти прозрачно. */
|
||||
.streetHalo {
|
||||
fill: none;
|
||||
stroke: var(--b2c-accent);
|
||||
stroke-opacity: 0.18;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.streetTarget {
|
||||
fill: none;
|
||||
stroke: var(--b2c-accent);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.streetLabel {
|
||||
fill: var(--b2c-muted-dark);
|
||||
font-family: var(--b2c-font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.gameMapLabel {
|
||||
position: relative;
|
||||
/* Плашка под подписью. Карта доходит до краёв, и подпись ложится прямо на
|
||||
улицу — на акцентной линии акцентный же текст не читается вовсе. */
|
||||
align-self: flex-start;
|
||||
padding: 5px 9px;
|
||||
border-radius: 4px;
|
||||
background: var(--b2c-surface-dark);
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.14em;
|
||||
|
|
@ -1706,6 +1631,20 @@
|
|||
.gameMapBottom {
|
||||
position: relative;
|
||||
}
|
||||
/* Затемнение под подписью объекта: она лежит ПОВЕРХ карты, и без него белый
|
||||
текст пересекается с подсвеченной улицей. Растягивается за padding плашки,
|
||||
чтобы не было видно шва. */
|
||||
.gameMapBottom::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -46px calc(-1 * clamp(24px, 3vw, 34px)) calc(-1 * clamp(24px, 3vw, 34px));
|
||||
background: linear-gradient(to bottom, transparent, var(--b2c-surface-dark) 45%);
|
||||
}
|
||||
/* Затемнение — ПОЗИЦИОНИРОВАННЫЙ псевдоэлемент, а сам текст нет: без этой
|
||||
строки он красится ПОВЕРХ подписи и стирает её. */
|
||||
.gameMapBottom > * {
|
||||
position: relative;
|
||||
}
|
||||
.gameAddr {
|
||||
font-weight: 800;
|
||||
font-size: clamp(19px, 2.2vw, 24px);
|
||||
|
|
@ -1727,7 +1666,7 @@
|
|||
font-family: var(--b2c-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: #9fb0b4;
|
||||
border: 1px solid #2c3a3f;
|
||||
border: 1px solid var(--b2c-line-dark);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,31 @@ export interface LandingStat {
|
|||
*/
|
||||
export type LandingStats = Readonly<Record<string, LandingStat | undefined>>;
|
||||
|
||||
/**
|
||||
* Схема улиц вокруг центра улицы сделки — уже спроецированные SVG-пути.
|
||||
* Приходит готовой из `/showcase`: на фронте геометрии нет, есть картинка.
|
||||
*
|
||||
* ПОЛЕЙ ПРОЕКЦИИ И КООРДИНАТ ОКНА ЗДЕСЬ НЕТ НАМЕРЕННО. По этой схеме нельзя
|
||||
* поставить точку дома — а дом известен у 2.7% сделок, и ставить её было бы
|
||||
* нечем и незачем. Это замок, а не забывчивость: см. шапку
|
||||
* `backend/app/services/street_scheme.py`.
|
||||
*/
|
||||
export interface StreetScheme {
|
||||
/** Название улицы как в OSM — дубль `street_name`. */
|
||||
street: string;
|
||||
/** Размеры квадратного окна viewBox; `h` всегда равно `w`. */
|
||||
w: number;
|
||||
h: number;
|
||||
/** Подсвеченная улица сделки. Непуста, если схема вообще есть. */
|
||||
target: string[];
|
||||
/** Фоновые дороги: `c` — класс OSM (trunk…residential). */
|
||||
roads: { c: string; d: string }[];
|
||||
/** Реки, каналы, ручьи, пруды. Пруды приходят замкнутыми кольцами. */
|
||||
water: string[];
|
||||
/** Подписи соседних улиц, не более семи. */
|
||||
labels: { t: string; x: number; y: number }[];
|
||||
}
|
||||
|
||||
export interface ShowcaseDeal {
|
||||
district: string | null;
|
||||
rooms: number;
|
||||
|
|
@ -206,6 +231,21 @@ export interface ShowcaseDeal {
|
|||
err_pct: number;
|
||||
n_analogs: number;
|
||||
note: string;
|
||||
/**
|
||||
* Координата сделки — ЦЕНТРОИД УЛИЦЫ, не дом (граница честности записана в
|
||||
* `note` каждой строки и в COMMENT колонок). `null` — координаты нет;
|
||||
* подставлять на её место соседнюю строку или центр района нельзя.
|
||||
*/
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
/**
|
||||
* Улица сделки, как она названа в OSM. `null` примерно у 8% строк — адрес
|
||||
* не сматчился с OSM; это штатный случай, а не сбой, и подставлять на его
|
||||
* место район нельзя: район не улица.
|
||||
*/
|
||||
street_name: string | null;
|
||||
/** Схема улиц. `null` ровно тогда же, когда `street_name`. */
|
||||
street_scheme: StreetScheme | null;
|
||||
}
|
||||
|
||||
/** Подпись под витриной: из чего отобраны показанные строки. */
|
||||
|
|
|
|||
|
|
@ -95,9 +95,12 @@ export const b2cVars = {
|
|||
|
||||
"--b2c-ink": b2c.ink,
|
||||
"--b2c-muted": b2c.muted,
|
||||
"--b2c-muted-dark": b2c.mutedDark,
|
||||
"--b2c-water": b2c.water,
|
||||
|
||||
"--b2c-line": b2c.line,
|
||||
"--b2c-line-soft": b2c.lineSoft,
|
||||
"--b2c-line-dark": b2c.lineDark,
|
||||
|
||||
// accent — только заливки/обводки; текст красится accentText (5.8:1).
|
||||
// Разбор контраста — в шапке b2c-tokens.ts.
|
||||
|
|
|
|||
|
|
@ -130,6 +130,51 @@ _PROXY_FAIL_MARKERS: tuple[str, ...] = (
|
|||
_LEASE_ROTATE_AFTER_FAILS: int = 3
|
||||
|
||||
|
||||
class SidecarBanPageError(httpx.HTTPStatusError):
|
||||
"""Сайдкар подтвердил бан-страницу по маркерам её тела (#3239).
|
||||
|
||||
Подкласс ``HTTPStatusError``, а не самостоятельный тип: ловля по
|
||||
``httpx.HTTPError`` у всех прочих поставщиков и retry-политика
|
||||
``fetch()`` продолжают работать не зная о нём. Отличать его нужно ровно
|
||||
там, где решается судьба узла: за подтверждённым маркером стоит
|
||||
``report_ban`` (площадка отказала этому IP), за обычной 500-кой — нет
|
||||
(сбой транспорта, #2600 п.4).
|
||||
|
||||
``upstream_status`` — HTTP-код САМОЙ целевой навигации, а не ответа
|
||||
сайдкара. На error-пути ``fetch()`` обнуляет ``last_response_status``,
|
||||
поэтому иначе диагноз узнать неоткуда: у DomClick отказ приходит с 401
|
||||
и без него классификатор ставит 'unknown' вместо 'platform'.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
request: httpx.Request,
|
||||
response: httpx.Response,
|
||||
upstream_status: int | None,
|
||||
) -> None:
|
||||
super().__init__(message, request=request, response=response)
|
||||
self.upstream_status = upstream_status
|
||||
|
||||
|
||||
def _sidecar_ban_page_status(resp: httpx.Response) -> tuple[bool, int | None]:
|
||||
"""(это бан-страница?, апстрим-статус) из тела ошибки сайдкара (#3239).
|
||||
|
||||
Тело — ``{"error": ..., "ban_page": true, "status": <int|null>}``. Сайдкар
|
||||
старой сборки ключей не отдаёт → (False, None), поведение как до правки.
|
||||
``bool`` отсекаем явно: он подтип ``int`` и JSON ``true`` уехал бы статусом.
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
return False, None
|
||||
if not isinstance(body, dict) or body.get("ban_page") is not True:
|
||||
return False, None
|
||||
raw = body.get("status")
|
||||
return True, raw if isinstance(raw, int) and not isinstance(raw, bool) else None
|
||||
|
||||
|
||||
def _raise_for_sidecar_status(resp: httpx.Response) -> None:
|
||||
"""`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
|
||||
|
||||
|
|
@ -153,6 +198,17 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None:
|
|||
# Тело не прочиталось/не декодируется — причина не обязана быть; отдаём
|
||||
# исходную ошибку, а не роняем вызывающего на разборе тела.
|
||||
raise exc from None
|
||||
is_ban_page, upstream_status = _sidecar_ban_page_status(resp)
|
||||
if is_ban_page:
|
||||
# #3239: тип несёт диагноз наверх — подстрокой в тексте его искать
|
||||
# нельзя, detail обрезан до 300 символов и формулировка отказа
|
||||
# менялась дважды за месяц.
|
||||
raise SidecarBanPageError(
|
||||
f"{exc} | tradein-browser: {detail or 'ban page'}",
|
||||
request=exc.request,
|
||||
response=exc.response,
|
||||
upstream_status=upstream_status,
|
||||
) from exc
|
||||
if not detail:
|
||||
raise
|
||||
raise httpx.HTTPStatusError(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from scraper_kit.domclick_exceptions import (
|
|||
DomClickBlockedError,
|
||||
DomClickParseError,
|
||||
)
|
||||
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||
from scraper_kit.repair_state_normalizer import (
|
||||
infer_repair_state_from_text,
|
||||
|
|
@ -544,6 +545,20 @@ async def fetch_detail(
|
|||
# но НЕ сообщаем пулу здесь: неизвестно, был ли это реальный маркер-бан или
|
||||
# обёртка сетевой ошибки — не смешиваем "сеть" с "бан" (issue #2600 п.4).
|
||||
raise
|
||||
except SidecarBanPageError as exc:
|
||||
# #3239: сайдкар опознал статический отказ площадки по маркерам тела
|
||||
# (#3237 перенёс это распознавание из parse_detail_html в сайдкар — иначе
|
||||
# незавершённое QRATOR-рукопожатие считалось блоком). Это ГЕНУИННЫЙ
|
||||
# ban-сигнал, ровно того же рода, что и ветка parse_detail_html ниже,
|
||||
# поэтому здесь — report_ban, в отличие от транспортной ветки следом.
|
||||
# Статус берём из исключения: на error-пути fetch() обнулил
|
||||
# last_response_status, а без 401 классификатор поставил бы 'unknown'
|
||||
# вместо 'platform' и ротация IP не запустилась бы вовсе.
|
||||
browser_fetcher.report_ban(f"domclick detail: sidecar ban page for {card_url}")
|
||||
raise DomClickBlockedError(
|
||||
f"DomClick detail: sidecar detected platform refusal for {card_url}: {exc}",
|
||||
status=exc.upstream_status,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
# Сетевая/инфраструктурная ошибка самого fetch() (timeout/5xx/transport) —
|
||||
# НЕ репортим mark_banned: это не подтверждённый маркер-бан площадки, а
|
||||
|
|
|
|||
|
|
@ -327,10 +327,12 @@ class DomClickScraper(BaseScraper):
|
|||
комнатности 2+ не собирались никогда. Замер 19.08 по активным
|
||||
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
|
||||
площадок 2+ комнат — около двух третей выдачи.
|
||||
Сдвиг НЕ лечит блок, он распределяет то, что успевает собраться, по
|
||||
всем корзинам вместо одной. Работает при единственном свободном узле —
|
||||
в отличие от ротации lease, которой сейчас упираться некуда: в пуле 3
|
||||
включённых узла, 2 забанены Домкликом.
|
||||
Сдвиг НЕ лечит обрыв, он распределяет то, что успевает собраться,
|
||||
по всем корзинам вместо одной, и работает даже когда свободен один
|
||||
узел. Ротация lease тут ни при чём: свип кладёт не счётчик провалов,
|
||||
а break ниже по первому же DomClickBlockedError (#2854). В пуле
|
||||
четыре включённых узла, у всех provider_affinity='any' —
|
||||
выделенного домклик-прокси нет с миграции 253 (#2800).
|
||||
skip_buckets: корзины, уже собранные предыдущим оборванным прогоном
|
||||
(#3118, чекпоинт done_buckets) — по ним ни одного запроса. Вместе
|
||||
со сдвигом #2854 это превращает случайную ротацию в систематический
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue