Compare commits
No commits in common. "main" and "fix/3443-caddy-selfdowntime" have entirely different histories.
main
...
fix/3443-c
94 changed files with 766 additions and 9249 deletions
|
|
@ -511,36 +511,23 @@ jobs:
|
||||||
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
||||||
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
||||||
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
||||||
# NEXT_PUBLIC_YM_ID — счётчик Яндекс.Метрики публичного контура.
|
# NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
|
||||||
# Живёт на корпоративном аккаунте info@meraocenka.ru, там же, где
|
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
|
||||||
# Вебмастер: счётчик и права на сайт на одном аккаунте связываются
|
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
|
||||||
# автоматически, на разных — только через письмо-подтверждение
|
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
|
||||||
# владельцу счётчика. Прежний номер 112563104 был заведён на личном
|
# Dockerfile-комментарий). Когда номера появятся — вписать
|
||||||
# аккаунте подрядчика и на сайт не попал ни разу, данных не потерял.
|
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
|
||||||
# NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав в
|
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
|
||||||
# Яндекс.Вебмастере, отдаётся мета-тегом (mera-public/layout.tsx).
|
# образа (build-time bake, не runtime-правка на проде).
|
||||||
# Токен привязан к аккаунту: у другого аккаунта он будет свой.
|
|
||||||
# Удалять его после подтверждения НЕЛЬЗЯ — Вебмастер перепроверяет
|
|
||||||
# права и снимает их, если тег исчез.
|
|
||||||
# Литералы, а не secret'ы: оба значения и так уходят в разметку
|
|
||||||
# каждой страницы, прятать нечего, а secret'ом их не проверить
|
|
||||||
# глазами в диффе.
|
|
||||||
# NEXT_PUBLIC_GA_ID/GOOGLE_VERIFICATION — ПОКА ПУСТЫЕ: ресурс GA4 и
|
|
||||||
# права в Search Console ещё не заведены. Пустая строка =
|
|
||||||
# соответствующий тег НЕ рендерится вообще (контракт фронта, см.
|
|
||||||
# тот же Dockerfile-комментарий).
|
|
||||||
# ЛЮБОЕ из этих значений вписывать литералом СЮДА И в retry-блок
|
|
||||||
# ниже — оба обязательны, иначе ретрай без кеша уедет без счётчика.
|
|
||||||
# Требует пересборки образа: build-time bake, не runtime-правка.
|
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||||
NEXT_PUBLIC_YM_ID=112672927
|
NEXT_PUBLIC_YM_ID=
|
||||||
NEXT_PUBLIC_GA_ID=
|
NEXT_PUBLIC_GA_ID=
|
||||||
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
NEXT_PUBLIC_YANDEX_VERIFICATION=
|
||||||
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
||||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||||
|
|
@ -565,9 +552,9 @@ jobs:
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||||
NEXT_PUBLIC_YM_ID=112672927
|
NEXT_PUBLIC_YM_ID=
|
||||||
NEXT_PUBLIC_GA_ID=
|
NEXT_PUBLIC_GA_ID=
|
||||||
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
NEXT_PUBLIC_YANDEX_VERIFICATION=
|
||||||
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
"""Загрузчик OSM POI из Overpass API для site-finder.
|
"""Загрузчик OSM POI из Overpass API для site-finder.
|
||||||
|
|
||||||
Запускается раз в неделю через Celery beat (регион по умолчанию — ЕКБ, см. DEFAULT_REGION).
|
Запускается раз в неделю через Celery beat. Поддерживает фильтр
|
||||||
Поддерживает фильтр "не старше 2 лет" (требование Максима) — last_osm_edit_date.
|
"не старше 2 лет" (требование Максима) — last_osm_edit_date.
|
||||||
|
|
||||||
Параметризован регионом (REGION_BBOX) — sync_poi_to_db(region=...) может грузить любой
|
|
||||||
зарегистрированный bbox, не только ЕКБ; большие bbox автоматически режутся на тайлы
|
|
||||||
(_bbox_tiles), чтобы не упереться в лимиты одного Overpass-запроса.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -24,31 +19,6 @@ logger = logging.getLogger(__name__)
|
||||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
||||||
|
|
||||||
# Прямоугольники продуктовых ядер по региону — источник загрузки POI больше не зашит
|
|
||||||
# в одну константу (было: только ЕКБ, блок «что рядом» молчал для остальных регионов).
|
|
||||||
# Site Finder — независимая половина монорепо со своим окружением/БД и НЕ импортирует
|
|
||||||
# tradein-mvp (у того свой реестр `app/services/regions.py`), поэтому bbox для Москвы
|
|
||||||
# продублирован явно, а не через кросс-импорт. Значение — bbox_product_core региона 77
|
|
||||||
# (tradein-mvp/backend/app/services/regions.py REGIONS[77]), пересчитанное в тот же
|
|
||||||
# (south, west, north, east) порядок, что EKB_BBOX выше. "ekb" остаётся значением по
|
|
||||||
# умолчанию ВЕЗДЕ (sync_poi_to_db / fetch_overpass) — существующее weekly-расписание
|
|
||||||
# (tasks.poi_sync.sync_osm_poi_ekb) не передаёт region и не должно молча сменить город.
|
|
||||||
REGION_BBOX: dict[str, tuple[float, float, float, float]] = {
|
|
||||||
"ekb": EKB_BBOX,
|
|
||||||
"msk": (55.55, 37.30, 55.95, 37.90), # (south, west, north, east)
|
|
||||||
}
|
|
||||||
DEFAULT_REGION = "ekb"
|
|
||||||
|
|
||||||
# Максимальный размер стороны ОДНОГО Overpass-запроса в градусах. У ЕКБ обе стороны
|
|
||||||
# bbox — ровно 0.25° (проверенный на практике размер: per-category запрос укладывается
|
|
||||||
# в timeout:30 без 504). Для региона с большей стороной bbox запрос режется на грид
|
|
||||||
# тайлов такого же порядка вместо одного большого — иначе на плотном городе (Москва на
|
|
||||||
# порядок плотнее ЕКБ по числу POI) Overpass либо отдаёт 504, либо (хуже) частично
|
|
||||||
# посчитанный ответ без явной ошибки, и загрузка молча обрежется. Для ЕКБ (0.25×0.25)
|
|
||||||
# тайлинг даёт РОВНО один тайл, совпадающий с EKB_BBOX бит-в-бит — поведение дефолтного
|
|
||||||
# региона не меняется.
|
|
||||||
MAX_TILE_SIDE_DEG = 0.25
|
|
||||||
|
|
||||||
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
||||||
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
|
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
|
||||||
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
|
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
|
||||||
|
|
@ -84,119 +54,17 @@ OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _bbox_tiles(
|
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||||
bbox: tuple[float, float, float, float], max_side_deg: float = MAX_TILE_SIDE_DEG
|
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
|
||||||
) -> list[tuple[float, float, float, float]]:
|
|
||||||
"""Разбить bbox на равномерный грид тайлов со стороной ≤ max_side_deg.
|
|
||||||
|
|
||||||
(south, west, north, east) → список тайлов того же формата. Для bbox, у которого обе
|
|
||||||
стороны уже ≤ max_side_deg (текущий EKB_BBOX: 0.25×0.25), возвращает список ровно из
|
|
||||||
ОДНОГО тайла, идентичного входному bbox — тайлинг не меняет поведение для ЕКБ.
|
|
||||||
"""
|
|
||||||
south, west, north, east = bbox
|
|
||||||
rows = max(1, math.ceil(round((north - south) / max_side_deg, 6)))
|
|
||||||
cols = max(1, math.ceil(round((east - west) / max_side_deg, 6)))
|
|
||||||
lat_step = (north - south) / rows
|
|
||||||
lon_step = (east - west) / cols
|
|
||||||
tiles = []
|
|
||||||
for r in range(rows):
|
|
||||||
for c in range(cols):
|
|
||||||
tiles.append(
|
|
||||||
(
|
|
||||||
south + r * lat_step,
|
|
||||||
west + c * lon_step,
|
|
||||||
south + (r + 1) * lat_step,
|
|
||||||
west + (c + 1) * lon_step,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tiles
|
|
||||||
|
|
||||||
|
|
||||||
def _split_bbox_quadrants(
|
|
||||||
bbox: tuple[float, float, float, float],
|
|
||||||
) -> list[tuple[float, float, float, float]]:
|
|
||||||
"""Разбить bbox на 4 равные четверти (2×2) — используется адаптивным ретраем
|
|
||||||
_fetch_category, когда сам тайл всё равно оказался слишком тяжёлым для Overpass."""
|
|
||||||
south, west, north, east = bbox
|
|
||||||
mid_lat = (south + north) / 2
|
|
||||||
mid_lon = (west + east) / 2
|
|
||||||
return [
|
|
||||||
(south, west, mid_lat, mid_lon),
|
|
||||||
(south, mid_lon, mid_lat, east),
|
|
||||||
(mid_lat, west, north, mid_lon),
|
|
||||||
(mid_lat, mid_lon, north, east),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Живой замер 2026-09-13: uniform-тайл 0.25×0.25 (размер ЕКБ) для category=bus_stop в
|
|
||||||
# Москве отдал 504 Gateway Timeout ОДНИМ тайлом (12 181 bus_stop во всём продуктовом
|
|
||||||
# ядре — уже больше, чем ВСЕ 14 категорий ЕКБ вместе, 4 850). Единый "правильный" размер
|
|
||||||
# тайла под все 14 категорий Москвы заранее не подобрать — плотность по городу сильно
|
|
||||||
# неравномерна (плотный центр / разреженная периферия), а у Overpass нет заголовка с
|
|
||||||
# "это частичный ответ" — единственный надёжный сигнал перегруза — HTTP-ошибка/таймаут.
|
|
||||||
# Поэтому вместо фиксированного маленького тайла — АДАПТИВНОЕ дробление: тайл, на
|
|
||||||
# котором per-category запрос дважды падает, дробится на 4 четверти и каждая
|
|
||||||
# перезапрашивается рекурсивно (до RECURSIVE_SPLIT_MAX_DEPTH). Для ЕКБ recursion
|
|
||||||
# НИКОГДА не срабатывает (единственный тайл исторически всегда отвечал 200) — поведение
|
|
||||||
# дефолтного региона не меняется.
|
|
||||||
RECURSIVE_SPLIT_MAX_DEPTH = 3
|
|
||||||
|
|
||||||
# Дробить тайл имеет смысл ТОЛЬКО когда сервер отказал из-за тяжести запроса:
|
|
||||||
# 504/429/503 и таймаут чтения — «я не успел посчитать», четверть посчитается.
|
|
||||||
# Отказ на уровне транспорта (connection refused / network unreachable) про размер
|
|
||||||
# запроса не говорит ВООБЩЕ: хост нас не принимает, и дробление превращает один
|
|
||||||
# отказ в 4, 16, 64 повторных стука. Живой случай 15.09.2026: загрузка Москвы
|
|
||||||
# поймала блокировку overpass-api.de по IP и за три минуты выдала 58 отказов на
|
|
||||||
# 4 успеха — ровно этот механизм.
|
|
||||||
_SPLIT_WORTHY_STATUS = frozenset({429, 503, 504})
|
|
||||||
|
|
||||||
# Подряд идущие транспортные отказы = хост нас не принимает. Продолжать прогон
|
|
||||||
# бессмысленно и вредно (углубляем блокировку), поэтому после порога — стоп всего
|
|
||||||
# прогона с явной ошибкой, а не тихий пропуск категорий.
|
|
||||||
_MAX_CONSECUTIVE_TRANSPORT_ERRORS = 5
|
|
||||||
|
|
||||||
|
|
||||||
class OverpassUnreachableError(RuntimeError):
|
|
||||||
"""Overpass отказывает на уровне соединения подряд — прогон остановлен."""
|
|
||||||
|
|
||||||
|
|
||||||
class _RunState:
|
|
||||||
"""Счётчик подряд идущих транспортных отказов в рамках одного fetch_overpass."""
|
|
||||||
|
|
||||||
__slots__ = ("consecutive_transport_errors",)
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.consecutive_transport_errors = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _is_overload(exc: Exception) -> bool:
|
|
||||||
"""True, если отказ говорит «запрос слишком тяжёлый» (есть смысл дробить)."""
|
|
||||||
if isinstance(exc, httpx.TimeoutException):
|
|
||||||
return True
|
|
||||||
if isinstance(exc, httpx.HTTPStatusError):
|
|
||||||
return exc.response.status_code in _SPLIT_WORTHY_STATUS
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_transport_error(exc: Exception) -> bool:
|
|
||||||
"""True для отказа на уровне соединения (хост не принимает), не про размер запроса."""
|
|
||||||
return isinstance(exc, httpx.TransportError) and not isinstance(exc, httpx.TimeoutException)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_overpass_query(
|
|
||||||
tag_filters: tuple[tuple[str, str], ...], bbox: tuple[float, float, float, float]
|
|
||||||
) -> str:
|
|
||||||
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND)
|
|
||||||
в ОДНОМ тайле bbox (south, west, north, east) — см. _bbox_tiles.
|
|
||||||
|
|
||||||
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
||||||
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
||||||
быстрые запросы вместо одного 60+ секундного.
|
быстрые запросы вместо одного 60+ секундного.
|
||||||
"""
|
"""
|
||||||
south, west, north, east = bbox
|
south, west, north, east = EKB_BBOX
|
||||||
bbox_str = f"({south},{west},{north},{east})"
|
bbox = f"({south},{west},{north},{east})"
|
||||||
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
||||||
return f"[out:json][timeout:30];(node{filt}{bbox_str};way{filt}{bbox_str};);out center meta;"
|
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
|
||||||
|
|
||||||
|
|
||||||
def _classify(tags: dict[str, str]) -> str | None:
|
def _classify(tags: dict[str, str]) -> str | None:
|
||||||
|
|
@ -212,117 +80,55 @@ def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_category(
|
async def _fetch_category(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
|
||||||
tag_filters: tuple[tuple[str, str], ...],
|
|
||||||
category: str,
|
|
||||||
bbox: tuple[float, float, float, float],
|
|
||||||
state: _RunState,
|
|
||||||
depth: int = 0,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Один per-category Overpass-запрос (для ОДНОГО тайла bbox) с ОДНИМ повтором при
|
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
|
||||||
транзиентной ошибке; если тайл падает оба раза — адаптивно дробится на 4 четверти
|
|
||||||
(см. RECURSIVE_SPLIT_MAX_DEPTH) и перезапрашивается рекурсивно, вместо того чтобы
|
|
||||||
тихо потерять весь тайл.
|
|
||||||
|
|
||||||
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
|
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
|
||||||
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка —
|
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка —
|
||||||
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
|
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
|
||||||
сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более
|
сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более
|
||||||
2 попыток на тайл ДО дробления).
|
2 попыток на категорию).
|
||||||
"""
|
"""
|
||||||
tag_desc = _tag_filters_desc(tag_filters)
|
tag_desc = _tag_filters_desc(tag_filters)
|
||||||
query = _build_overpass_query(tag_filters, bbox)
|
query = _build_overpass_query(tag_filters)
|
||||||
for attempt in (1, 2):
|
for attempt in (1, 2):
|
||||||
try:
|
try:
|
||||||
r = await client.post(OVERPASS_URL, data={"data": query})
|
r = await client.post(OVERPASS_URL, data={"data": query})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
elements: list[dict] = r.json().get("elements", [])
|
elements: list[dict] = r.json().get("elements", [])
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overpass: %s (%s) bbox=%s depth=%d → %d [attempt %d]",
|
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
|
||||||
tag_desc,
|
|
||||||
category,
|
|
||||||
bbox,
|
|
||||||
depth,
|
|
||||||
len(elements),
|
|
||||||
attempt,
|
|
||||||
)
|
)
|
||||||
# Привязываем category именно к тому per-category запросу, под который
|
# Привязываем category именно к тому per-category запросу, под который
|
||||||
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
||||||
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
||||||
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
||||||
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
||||||
state.consecutive_transport_errors = 0
|
|
||||||
for el in elements:
|
for el in elements:
|
||||||
el["_gd_category"] = category
|
el["_gd_category"] = category
|
||||||
return elements
|
return elements
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_transport_error(e):
|
|
||||||
state.consecutive_transport_errors += 1
|
|
||||||
if state.consecutive_transport_errors >= _MAX_CONSECUTIVE_TRANSPORT_ERRORS:
|
|
||||||
raise OverpassUnreachableError(
|
|
||||||
f"Overpass отказывает на уровне соединения "
|
|
||||||
f"{state.consecutive_transport_errors} раз подряд ({e}) — прогон "
|
|
||||||
f"остановлен, чтобы не стучаться в блокирующий хост"
|
|
||||||
) from e
|
|
||||||
logger.warning(
|
|
||||||
"Overpass transport error for %s bbox=%s (подряд %d) — тайл пропущен "
|
|
||||||
"без дробления: %s",
|
|
||||||
tag_desc,
|
|
||||||
bbox,
|
|
||||||
state.consecutive_transport_errors,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
state.consecutive_transport_errors = 0
|
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
|
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
|
||||||
await asyncio.sleep(3.0)
|
await asyncio.sleep(3.0)
|
||||||
continue
|
continue
|
||||||
if _is_overload(e) and depth < RECURSIVE_SPLIT_MAX_DEPTH:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Overpass failed for %s bbox=%s twice — splitting into 4 quadrants "
|
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
|
||||||
"(depth %d→%d) instead of dropping the tile: %s",
|
|
||||||
tag_desc,
|
|
||||||
bbox,
|
|
||||||
depth,
|
|
||||||
depth + 1,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
combined: list[dict] = []
|
|
||||||
for quadrant in _split_bbox_quadrants(bbox):
|
|
||||||
combined.extend(
|
|
||||||
await _fetch_category(
|
|
||||||
client, tag_filters, category, quadrant, state, depth + 1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
return combined
|
|
||||||
logger.warning(
|
|
||||||
"Overpass failed for %s bbox=%s at max split depth %d — tile skipped this run: %s",
|
|
||||||
tag_desc,
|
|
||||||
bbox,
|
|
||||||
depth,
|
|
||||||
e,
|
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
|
async def fetch_overpass() -> list[dict]:
|
||||||
"""Запросить Overpass API per category × per tile, вернуть combined список elements.
|
"""Запросить Overpass API per category, вернуть combined список elements.
|
||||||
|
|
||||||
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
||||||
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
||||||
policy: max 2 concurrent, лучше 1 req/s). bbox региона режется на тайлы
|
policy: max 2 concurrent, лучше 1 req/s).
|
||||||
≤ MAX_TILE_SIDE_DEG (_bbox_tiles) — для "ekb" это ровно один тайл (без
|
|
||||||
изменения поведения), для регионов с большим bbox (напр. "msk") — несколько,
|
|
||||||
чтобы не поймать 504 или тихо обрезанный ответ на плотном городе.
|
|
||||||
|
|
||||||
Overpass блокирует default `python-httpx/*` User-Agent (406) — поэтому
|
Overpass блокирует default `python-httpx/*` User-Agent (406) — поэтому
|
||||||
явный UA с контактом проекта.
|
явный UA с контактом проекта.
|
||||||
"""
|
"""
|
||||||
bbox = REGION_BBOX[region]
|
|
||||||
tiles = _bbox_tiles(bbox)
|
|
||||||
state = _RunState()
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
|
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
|
@ -330,34 +136,24 @@ async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
|
||||||
all_elements: list[dict] = []
|
all_elements: list[dict] = []
|
||||||
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
||||||
for tag_filters, category in OSM_CATEGORIES.items():
|
for tag_filters, category in OSM_CATEGORIES.items():
|
||||||
for tile in tiles:
|
elements = await _fetch_category(client, tag_filters, category)
|
||||||
elements = await _fetch_category(client, tag_filters, category, tile, state)
|
|
||||||
all_elements.extend(elements)
|
all_elements.extend(elements)
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overpass region=%s: total %d elements across %d category-queries × %d tiles",
|
"Overpass: total %d elements across %d category-queries",
|
||||||
region,
|
|
||||||
len(all_elements),
|
len(all_elements),
|
||||||
len(OSM_CATEGORIES),
|
len(OSM_CATEGORIES),
|
||||||
len(tiles),
|
|
||||||
)
|
)
|
||||||
return all_elements
|
return all_elements
|
||||||
|
|
||||||
|
|
||||||
def sync_poi_to_db(region: str = DEFAULT_REGION) -> dict[str, int]:
|
def sync_poi_to_db() -> dict[str, int]:
|
||||||
"""Синхронизирует POI из Overpass в osm_poi_ekb для одного региона.
|
"""Синхронизирует POI из Overpass в osm_poi_ekb.
|
||||||
|
|
||||||
region — ключ REGION_BBOX ("ekb" по умолчанию, сохраняет старое поведение
|
|
||||||
weekly-расписания). Имя таблицы osm_poi_ekb — историческое (изначально
|
|
||||||
ЕКБ-only); таблица читается ещё в двух местах вне Site Finder (FDW-таблица
|
|
||||||
gendesign_osm_poi_ekb + локальное зеркало osm_poi_ekb_local в tradein), поэтому
|
|
||||||
НЕ переименована: переименование потянуло бы миграции в обеих половинах
|
|
||||||
монорепо (FDW-объект + зеркало + их индексы) ради косметики.
|
|
||||||
|
|
||||||
UPSERT по UNIQUE(osm_type, osm_id, category).
|
UPSERT по UNIQUE(osm_type, osm_id, category).
|
||||||
Returns: counters {fetched, inserted, updated, skipped_old}.
|
Returns: counters {fetched, inserted, updated, skipped_old}.
|
||||||
"""
|
"""
|
||||||
elements = asyncio.run(fetch_overpass(region))
|
elements = asyncio.run(fetch_overpass())
|
||||||
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
|
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
|
||||||
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
|
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
|
||||||
two_years_ago = date.today() - timedelta(days=730)
|
two_years_ago = date.today() - timedelta(days=730)
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,12 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
|
||||||
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
|
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
|
||||||
per-test ``patch`` — он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
|
per-test ``patch`` — он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
|
||||||
что их ожидаемые значения резолвера сохраняются.
|
что их ожидаемые значения резолвера сохраняются.
|
||||||
|
|
||||||
Perf-fix (2026-09-12): в конце ``analyze_parcel`` — безусловный best-effort
|
|
||||||
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
|
|
||||||
В песочнице тестов Celery-брокер (Redis) недоступен → ``.delay()`` синхронно ждёт
|
|
||||||
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит —
|
|
||||||
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
|
|
||||||
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
|
|
||||||
тестов каталога (как и с резолвером выше) — тесты самого enqueue
|
|
||||||
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
|
|
||||||
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -47,34 +37,3 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
|
||||||
"""
|
"""
|
||||||
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
|
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _stub_forecast_enqueue() -> Iterator[None]:
|
|
||||||
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
|
|
||||||
|
|
||||||
``.delay(...)`` в проде — fire-and-forget (best-effort, обёрнут в try/except в
|
|
||||||
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
|
|
||||||
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
|
|
||||||
"""
|
|
||||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
|
|
||||||
|
|
||||||
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
|
|
||||||
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
|
|
||||||
значение). В тестах фетч замокан и геометрия не появится никогда → каждый такой
|
|
||||||
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
|
|
||||||
``test_recent_permits_invalid_cad_no_regression``).
|
|
||||||
|
|
||||||
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
|
|
||||||
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
|
|
||||||
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
|
|
||||||
``test_run_history_and_response_contract.py``) переопределяют это поверх.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
|
|
||||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -73,16 +72,6 @@ GIT_ENV = {
|
||||||
"GIT_CONFIG_SYSTEM": "/dev/null",
|
"GIT_CONFIG_SYSTEM": "/dev/null",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Эти кейсы гоняют `bash -c <скрипт>` во временном git-репозитории (POSIX shell +
|
|
||||||
# /dev/null + жёсткий PATH=/usr/bin:/bin:/usr/local/bin). На Windows дочерний
|
|
||||||
# процесс не стартует (нет POSIX shell на этом PATH); покрытие обеспечивается в
|
|
||||||
# CI на Linux. Кейсы, которые читают только YAML/строки без subprocess, не
|
|
||||||
# помечены и обязаны выполняться локально.
|
|
||||||
posix_only = pytest.mark.skipif(
|
|
||||||
sys.platform == "win32",
|
|
||||||
reason="запускает bash-скрипт дочерним процессом; POSIX-only, покрытие в CI (Linux)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_script() -> str:
|
def _detect_script() -> str:
|
||||||
"""Тело шага, который считает изменённые файлы в job `changes`."""
|
"""Тело шага, который считает изменённые файлы в job `changes`."""
|
||||||
|
|
@ -192,7 +181,6 @@ def _run(
|
||||||
return _exec(repo, base_sha if before is None else before, event)
|
return _exec(repo, base_sha if before is None else before, event)
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
||||||
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
|
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
|
||||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
||||||
|
|
@ -205,7 +193,6 @@ def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
||||||
assert outputs["frontend"] == "false"
|
assert outputs["frontend"] == "false"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
||||||
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
|
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
|
||||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
|
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
|
||||||
|
|
@ -216,7 +203,6 @@ def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
||||||
assert outputs["backend"] == "true"
|
assert outputs["backend"] == "true"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
||||||
"""База не разрешилась → полный деплой, а не пустой список.
|
"""База не разрешилась → полный деплой, а не пустой список.
|
||||||
|
|
||||||
|
|
@ -237,7 +223,6 @@ def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
||||||
assert "деплой полный" in log
|
assert "деплой полный" in log
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
|
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
|
||||||
"""Решение печатается: и список файлов, и итоговые флаги.
|
"""Решение печатается: и список файлов, и итоговые флаги.
|
||||||
|
|
||||||
|
|
@ -306,13 +291,11 @@ def _run_guard(repo: Path) -> subprocess.CompletedProcess:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
|
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
|
||||||
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
|
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
|
||||||
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
|
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
|
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
|
||||||
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
|
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
|
||||||
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
|
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
|
||||||
|
|
@ -339,7 +322,6 @@ def test_fast_path_takes_the_same_host_lock() -> None:
|
||||||
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
|
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
|
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
|
||||||
)
|
)
|
||||||
|
|
@ -356,7 +338,6 @@ def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
|
||||||
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
|
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
||||||
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
|
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
|
||||||
|
|
||||||
|
|
@ -369,7 +350,6 @@ def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
||||||
assert "изменённых файлов: 0" in log
|
assert "изменённых файлов: 0" in log
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
||||||
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
|
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
|
||||||
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
|
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
|
||||||
|
|
@ -377,7 +357,6 @@ def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
||||||
assert outputs["caddy_only"] == "false", outputs
|
assert outputs["caddy_only"] == "false", outputs
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
||||||
"""Кириллица в пути не должна прятать файл от классификации.
|
"""Кириллица в пути не должна прятать файл от классификации.
|
||||||
|
|
||||||
|
|
@ -391,7 +370,6 @@ def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
||||||
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
|
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
|
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
|
||||||
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
|
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,29 +176,3 @@ tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
|
||||||
|
|
||||||
# ── POSIX-only: тесты запускают дочерний bash-скрипт ───────────────────────────
|
|
||||||
# На Windows нет POSIX shell на PATH, которым эти тесты исполняют /bin/bash -c
|
|
||||||
# <скрипт> или сам shell-скрипт напрямую (шебанг #!/usr/bin/env bash) — процесс
|
|
||||||
# не стартует. В CI ЭТИ ТЕСТЫ ИДУТ (ubuntu-раннер). Записи нужны только для
|
|
||||||
# локального прогона на Windows; чисто-логические проверки в тех же файлах
|
|
||||||
# (читают YAML/строки без subprocess) в этот список НЕ входят и обязаны
|
|
||||||
# исполняться локально.
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_merge_with_only_caddy_file_takes_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_caddy_plus_backend_is_a_full_deploy
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_missing_base_falls_back_to_full_deploy
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_decision_is_visible_in_the_log
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_allowed_when_prod_lags_only_by_proxy_config
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_refuses_when_prod_lags_by_code
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_paths_that_merely_start_with_caddy_are_not_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_empty_diff_is_not_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_data_sql_counts_as_backend
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_non_ascii_path_is_classified
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_multi_commit_push_is_not_truncated
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_fresh_latest_passes
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_newer_revision_passes
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_stale_latest_fails_after_timeout
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_revision_from_other_component_does_not_cover
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_missing_label_fails
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_registry_error_fails
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_usage_errors_are_distinct
|
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,10 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Каждый тест в файле запускает scripts/check-latest-image-revision.sh (POSIX shell,
|
|
||||||
# shebang #!/usr/bin/env bash) дочерним процессом. На Windows нет POSIX shell на PATH,
|
|
||||||
# чтобы это исполнить, — покрытие обеспечивается в CI на Linux.
|
|
||||||
pytestmark = pytest.mark.skipif(
|
|
||||||
sys.platform == "win32",
|
|
||||||
reason="запускает bash-скрипт (check-latest-image-revision.sh) дочерним процессом; "
|
|
||||||
"POSIX-only, покрытие в CI (Linux)",
|
|
||||||
)
|
|
||||||
|
|
||||||
КОРЕНЬ = Path(__file__).resolve().parents[2]
|
КОРЕНЬ = Path(__file__).resolve().parents[2]
|
||||||
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
|
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
"""Unit tests для poi_loader — региональный bbox + адаптивное дробление Overpass-тайлов.
|
|
||||||
|
|
||||||
Mock-based / pure — БЕЗ живых походов в Overpass и без БД (правило: реальные запросы
|
|
||||||
к Overpass в тестах недопустимы). Покрывает:
|
|
||||||
- REGION_BBOX / DEFAULT_REGION — дефолт остаётся "ekb", не меняется молча.
|
|
||||||
- _bbox_tiles — для ЕКБ ровно один тайл, идентичный EKB_BBOX; для Москвы — несколько
|
|
||||||
тайлов, покрывающих исходный bbox без дыр/нахлёста (по площади).
|
|
||||||
- _build_overpass_query — bbox теперь параметр, а не глобальная константа.
|
|
||||||
- _fetch_category — retry (было и раньше) + НОВОЕ: адаптивное дробление тайла на 4
|
|
||||||
четверти при устойчивом провале (вместо тихой потери тайла), с остановкой на
|
|
||||||
RECURSIVE_SPLIT_MAX_DEPTH (без бесконечной рекурсии).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.site_finder.poi_loader import (
|
|
||||||
_MAX_CONSECUTIVE_TRANSPORT_ERRORS,
|
|
||||||
DEFAULT_REGION,
|
|
||||||
EKB_BBOX,
|
|
||||||
RECURSIVE_SPLIT_MAX_DEPTH,
|
|
||||||
REGION_BBOX,
|
|
||||||
OverpassUnreachableError,
|
|
||||||
_bbox_tiles,
|
|
||||||
_build_overpass_query,
|
|
||||||
_fetch_category,
|
|
||||||
_RunState,
|
|
||||||
_split_bbox_quadrants,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
|
||||||
"""Ответ Overpass. ok=False отдаёт 504 — «запрос слишком тяжёлый», единственный
|
|
||||||
класс отказа, ради которого тайл вообще осмысленно дробить."""
|
|
||||||
|
|
||||||
def __init__(self, ok: bool, elements: list[dict] | None = None) -> None:
|
|
||||||
self._ok = ok
|
|
||||||
self._elements = elements or []
|
|
||||||
|
|
||||||
def raise_for_status(self) -> None:
|
|
||||||
if not self._ok:
|
|
||||||
raise httpx.HTTPStatusError(
|
|
||||||
"simulated Overpass overload",
|
|
||||||
request=httpx.Request("POST", "https://overpass-api.de/api/interpreter"),
|
|
||||||
response=httpx.Response(504),
|
|
||||||
)
|
|
||||||
|
|
||||||
def json(self) -> dict:
|
|
||||||
return {"elements": self._elements}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Подменяет asyncio.sleep внутри poi_loader на no-op — тесты дробления тайлов иначе
|
|
||||||
реально спали бы минуты (retry-пауза 3с + 1с между каждой из 4 четвертей на каждом
|
|
||||||
уровне рекурсии)."""
|
|
||||||
|
|
||||||
async def _instant(_seconds: float) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr("app.services.site_finder.poi_loader.asyncio.sleep", _instant)
|
|
||||||
|
|
||||||
|
|
||||||
# ── REGION_BBOX / дефолт ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_region_is_ekb_unchanged() -> None:
|
|
||||||
assert DEFAULT_REGION == "ekb"
|
|
||||||
assert REGION_BBOX["ekb"] == EKB_BBOX
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_bbox_has_msk_product_core() -> None:
|
|
||||||
assert "msk" in REGION_BBOX
|
|
||||||
south, west, north, east = REGION_BBOX["msk"]
|
|
||||||
assert south < north
|
|
||||||
assert west < east
|
|
||||||
|
|
||||||
|
|
||||||
# ── _bbox_tiles ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_bbox_tiles_ekb_is_single_tile_identical_to_ekb_bbox() -> None:
|
|
||||||
"""Дефолтный регион не должен молча поменять поведение — один тайл, байт-в-байт EKB_BBOX."""
|
|
||||||
tiles = _bbox_tiles(EKB_BBOX)
|
|
||||||
assert tiles == [EKB_BBOX]
|
|
||||||
|
|
||||||
|
|
||||||
def test_bbox_tiles_msk_splits_into_multiple_tiles_without_gaps() -> None:
|
|
||||||
bbox = REGION_BBOX["msk"]
|
|
||||||
tiles = _bbox_tiles(bbox)
|
|
||||||
assert len(tiles) > 1
|
|
||||||
south, west, north, east = bbox
|
|
||||||
total_area = (north - south) * (east - west)
|
|
||||||
tiles_area = sum((t[2] - t[0]) * (t[3] - t[1]) for t in tiles)
|
|
||||||
assert tiles_area == pytest.approx(total_area, rel=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
# ── _split_bbox_quadrants ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_split_bbox_quadrants_covers_original_area() -> None:
|
|
||||||
bbox = (55.55, 37.30, 55.95, 37.90)
|
|
||||||
quads = _split_bbox_quadrants(bbox)
|
|
||||||
assert len(quads) == 4
|
|
||||||
south, west, north, east = bbox
|
|
||||||
total_area = (north - south) * (east - west)
|
|
||||||
quads_area = sum((q[2] - q[0]) * (q[3] - q[1]) for q in quads)
|
|
||||||
assert quads_area == pytest.approx(total_area, rel=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
# ── _build_overpass_query ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_overpass_query_uses_given_bbox_not_global_constant() -> None:
|
|
||||||
q = _build_overpass_query((("amenity", "pharmacy"),), (1.0, 2.0, 3.0, 4.0))
|
|
||||||
assert "(1.0,2.0,3.0,4.0)" in q
|
|
||||||
assert '["amenity"="pharmacy"]' in q
|
|
||||||
|
|
||||||
|
|
||||||
# ── _fetch_category: retry (существующее поведение) ──────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_category_retries_then_succeeds(instant_sleep: None) -> None:
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
if calls["n"] == 1:
|
|
||||||
return _FakeResponse(ok=False)
|
|
||||||
return _FakeResponse(ok=True, elements=[{"type": "node", "id": 1, "lat": 1, "lon": 2}])
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0, 0, 1, 1), _RunState()
|
|
||||||
)
|
|
||||||
assert calls["n"] == 2
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["_gd_category"] == "pharmacy"
|
|
||||||
|
|
||||||
|
|
||||||
# ── _fetch_category: адаптивное дробление (НОВОЕ) ─────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_category_splits_into_quadrants_on_persistent_failure(
|
|
||||||
instant_sleep: None,
|
|
||||||
) -> None:
|
|
||||||
"""Тайл, где оба attempt проваливаются, дробится на 4 четверти вместо потери данных."""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
query = data["data"]
|
|
||||||
if "(0.0,0.0,1.0,1.0)" in query: # верхнеуровневый тайл всегда 504
|
|
||||||
return _FakeResponse(ok=False)
|
|
||||||
return _FakeResponse(
|
|
||||||
ok=True, elements=[{"type": "node", "id": calls["n"], "lat": 0.1, "lon": 0.1}]
|
|
||||||
)
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
|
||||||
)
|
|
||||||
# верхний тайл: 2 неудачных attempt, затем 4 успешных запроса по четвертям
|
|
||||||
assert calls["n"] == 2 + 4
|
|
||||||
assert len(result) == 4
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_category_gives_up_at_max_depth_without_infinite_recursion(
|
|
||||||
instant_sleep: None,
|
|
||||||
) -> None:
|
|
||||||
"""Тайл, падающий на ЛЮБОМ размере, останавливает дробление на RECURSIVE_SPLIT_MAX_DEPTH
|
|
||||||
и возвращает пустой список — не зацикливается и не падает."""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
assert data # параметр используется — сигнатура должна совпадать с client.post
|
|
||||||
return _FakeResponse(ok=False)
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
|
||||||
)
|
|
||||||
assert result == []
|
|
||||||
# sum_{d=0}^{max_depth} 4^d узлов, каждый по 2 attempt — рекурсия конечна
|
|
||||||
expected_nodes = sum(4**d for d in range(RECURSIVE_SPLIT_MAX_DEPTH + 1))
|
|
||||||
assert calls["n"] == expected_nodes * 2
|
|
||||||
|
|
||||||
|
|
||||||
# ── отказ транспорта: НЕ дробим и не стучимся дальше ──────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def test_transport_error_does_not_split_the_tile(instant_sleep: None) -> None:
|
|
||||||
"""Connection refused говорит «хост нас не принимает», а не «запрос тяжёлый».
|
|
||||||
|
|
||||||
Дробление тут превращает один отказ в 4, 16, 64 повторных стука — ровно это
|
|
||||||
случилось 15.09.2026 на загрузке Москвы (58 отказов на 4 успеха за три минуты).
|
|
||||||
"""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
assert data
|
|
||||||
raise httpx.ConnectError("[Errno 101] Network is unreachable")
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
|
||||||
)
|
|
||||||
assert result == []
|
|
||||||
assert calls["n"] == 1 # ни retry, ни четвертей
|
|
||||||
|
|
||||||
|
|
||||||
async def test_consecutive_transport_errors_abort_the_run(instant_sleep: None) -> None:
|
|
||||||
"""Порог подряд идущих транспортных отказов останавливает ВЕСЬ прогон явной ошибкой."""
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
assert data
|
|
||||||
raise httpx.ConnectError("connection refused")
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
state = _RunState()
|
|
||||||
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS - 1):
|
|
||||||
assert (
|
|
||||||
await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
|
||||||
)
|
|
||||||
== []
|
|
||||||
)
|
|
||||||
with pytest.raises(OverpassUnreachableError):
|
|
||||||
await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_successful_tile_resets_transport_error_streak(instant_sleep: None) -> None:
|
|
||||||
"""Одиночные сетевые икоты вперемешку с успехами не должны копиться до аварии."""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
assert data
|
|
||||||
if calls["n"] % 2:
|
|
||||||
raise httpx.ConnectError("hiccup")
|
|
||||||
return _FakeResponse(ok=True, elements=[{"type": "node", "id": calls["n"]}])
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
state = _RunState()
|
|
||||||
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS * 2):
|
|
||||||
await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
|
||||||
)
|
|
||||||
assert state.consecutive_transport_errors < _MAX_CONSECUTIVE_TRANSPORT_ERRORS
|
|
||||||
|
|
@ -184,13 +184,10 @@ gendsgn.ru {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static HTML mockups для review (audit alternatives).
|
# Static HTML mockups для review (audit alternatives).
|
||||||
# Public access — без auth (по запросу 2026-05-17) — это намеренно и
|
# Public access — без auth (по запросу 2026-05-17).
|
||||||
# менять НЕ надо. Но каталог не должен листингом отдавать список
|
|
||||||
# файлов случайному посетителю: без `browse` file_server продолжает
|
|
||||||
# отдавать сами файлы по прямой ссылке, просто без index-листинга.
|
|
||||||
handle_path /preview/* {
|
handle_path /preview/* {
|
||||||
root * /srv/preview
|
root * /srv/preview
|
||||||
file_server
|
file_server browse
|
||||||
}
|
}
|
||||||
|
|
||||||
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
||||||
|
|
@ -403,36 +400,6 @@ www.gendsgn.ru {
|
||||||
meraocenka.ru {
|
meraocenka.ru {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# #3471 (security-headers follow-up): публичный B2C-домен без базовых
|
|
||||||
# security-заголовков. Позиция директивы в файле не важна — Caddyfile
|
|
||||||
# применяет директивы в фиксированном порядке (`header` идёт раньше
|
|
||||||
# `reverse_proxy`), не по строке в исходнике.
|
|
||||||
#
|
|
||||||
# Проверено: ни tradein-mvp/frontend (обслуживает этот домен, отдаёт
|
|
||||||
# `/trade-in/mera-public*`), ни его next.config.ts/middleware не
|
|
||||||
# выставляют ни один из этих заголовков — дублирования нет.
|
|
||||||
header {
|
|
||||||
# 180 суток (15552000s). НЕ ставить includeSubDomains и НЕ ставить
|
|
||||||
# preload — оба флага практически необратимы (браузеры кэшируют
|
|
||||||
# preload-список надолго, includeSubDomains ломает любой будущий
|
|
||||||
# HTTP-поддомен), а не все поддомены meraocenka.ru гарантированно
|
|
||||||
# под нашим контролем. Если это когда-нибудь понадобится — отдельное
|
|
||||||
# осознанное решение, не «улучшение по умолчанию».
|
|
||||||
Strict-Transport-Security "max-age=15552000"
|
|
||||||
# Запрет встраивания во фрейм: X-Frame-Options — для старых
|
|
||||||
# клиентов, которые не понимают CSP; frame-ancestors — современный
|
|
||||||
# механизм. Оба вместе, не один вместо другого.
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Content-Security-Policy "frame-ancestors 'none'"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
# Не даём стеку светиться в ответе. Полноценный CSP
|
|
||||||
# (default-src/script-src/...) сюда намеренно НЕ входит — отдельная
|
|
||||||
# работа с риском сломать фронт, здесь только периметр вокруг фрейма.
|
|
||||||
-Server
|
|
||||||
-X-Powered-By
|
|
||||||
}
|
|
||||||
|
|
||||||
log {
|
log {
|
||||||
output file /var/log/caddy/meraocenka.ru.log
|
output file /var/log/caddy/meraocenka.ru.log
|
||||||
}
|
}
|
||||||
|
|
@ -518,7 +485,7 @@ meraocenka.ru {
|
||||||
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
|
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
|
||||||
# robots.txt существует только в корне app/, не под mera-public/), эта
|
# robots.txt существует только в корне app/, не под mera-public/), эта
|
||||||
# форма под `{path}` не попадает — отсюда отдельный handle у него.
|
# форма под `{path}` не попадает — отсюда отдельный handle у него.
|
||||||
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /articles/skolko-stoit-metr-v-ekaterinburge /articles/pochemu-kvartira-ne-prodaetsya /docs /business /sitemap.xml
|
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
|
||||||
handle @meraPages {
|
handle @meraPages {
|
||||||
rewrite * /trade-in/mera-public{path}
|
rewrite * /trade-in/mera-public{path}
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
|
@ -549,7 +516,7 @@ meraocenka.ru {
|
||||||
# переносит query как есть. Обёртка `route` обязательна по той же
|
# переносит query как есть. Обёртка `route` обязательна по той же
|
||||||
# причине, что у соседей — внутри `handle` порядок директив определяет
|
# причине, что у соседей — внутри `handle` порядок директив определяет
|
||||||
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
|
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
|
||||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|articles/skolko-stoit-metr-v-ekaterinburge|articles/pochemu-kvartira-ne-prodaetsya|docs|business)/$
|
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
|
||||||
handle @meraShortSlash {
|
handle @meraShortSlash {
|
||||||
route {
|
route {
|
||||||
uri strip_suffix /
|
uri strip_suffix /
|
||||||
|
|
@ -637,7 +604,7 @@ meraocenka.ru {
|
||||||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||||||
# (бесконечный цикл; поймано на локальном стенде).
|
# (бесконечный цикл; поймано на локальном стенде).
|
||||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge/ /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
||||||
handle @meraLongPages {
|
handle @meraLongPages {
|
||||||
route {
|
route {
|
||||||
uri strip_prefix /trade-in/mera-public
|
uri strip_prefix /trade-in/mera-public
|
||||||
|
|
|
||||||
|
|
@ -56,28 +56,6 @@ errors.gendsgn.ru {
|
||||||
git.gendsgn.ru {
|
git.gendsgn.ru {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Генерация архивов закрыта — она съедала диск (16.09.2026).
|
|
||||||
#
|
|
||||||
# Forgejo на КАЖДЫЙ GET /<owner>/<repo>/archive/<ref>.{zip,tar.gz,bundle}
|
|
||||||
# собирает снапшот репозитория заново (20-30 МБ, несколько секунд CPU) и
|
|
||||||
# кладёт результат в кэш repo-archive. Штатная уборка archive_cleanup
|
|
||||||
# работает, но раз в сутки — а внешний обходчик (73 из 78 обращений за 6ч
|
|
||||||
# с одного адреса 216.73.216.17) дёргал архив для каждого коммита и держал
|
|
||||||
# генерацию на ~100 файлах в час. Кэш вырос до 55 ГБ при диске 145 ГБ и ел
|
|
||||||
# 2 ГБ/ч: до нуля оставалось ~12 часов.
|
|
||||||
#
|
|
||||||
# Ограничение скорости тут не лечит: даже один запрос в минуту — это
|
|
||||||
# 30 МБ/мин новых файлов при суточной уборке. Нужен именно отказ.
|
|
||||||
# На `git clone`, `git fetch` и работу CI это НЕ влияет: смарт-протокол
|
|
||||||
# ходит по /<owner>/<repo>/info/refs и /git-upload-pack, другие пути.
|
|
||||||
# Цена: анонимная кнопка «скачать архив» в вебе больше не работает —
|
|
||||||
# сознательный размен, вернуть можно снятием этих двух правил.
|
|
||||||
@repo_archive path_regexp repo_archive ^/[^/]+/[^/]+/archive/.+$
|
|
||||||
respond @repo_archive "Архивы отключены: используйте git clone" 403
|
|
||||||
|
|
||||||
@api_archive path_regexp api_archive ^/api/v1/repos/[^/]+/[^/]+/archive/.+$
|
|
||||||
respond @api_archive "Архивы отключены: используйте git clone" 403
|
|
||||||
|
|
||||||
reverse_proxy forgejo:3000
|
reverse_proxy forgejo:3000
|
||||||
|
|
||||||
log {
|
log {
|
||||||
|
|
|
||||||
|
|
@ -2844,46 +2844,6 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
|
||||||
COVERAGE_GREEN_MIN_N = 8
|
COVERAGE_GREEN_MIN_N = 8
|
||||||
COVERAGE_YELLOW_MIN_N = 12
|
COVERAGE_YELLOW_MIN_N = 12
|
||||||
|
|
||||||
# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в
|
|
||||||
# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на
|
|
||||||
# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь
|
|
||||||
# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по
|
|
||||||
# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное
|
|
||||||
# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87,
|
|
||||||
# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85,
|
|
||||||
# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78,
|
|
||||||
# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73,
|
|
||||||
# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68,
|
|
||||||
# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же
|
|
||||||
# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже
|
|
||||||
# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже
|
|
||||||
# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог
|
|
||||||
# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты.
|
|
||||||
COVERAGE_MO_CITIES = (
|
|
||||||
"Видное",
|
|
||||||
"Балашиха",
|
|
||||||
"Железнодорожный",
|
|
||||||
"Люберцы",
|
|
||||||
"Электросталь",
|
|
||||||
"Химки",
|
|
||||||
"Домодедово",
|
|
||||||
"Ногинск",
|
|
||||||
"Реутов",
|
|
||||||
"Долгопрудный",
|
|
||||||
"Красногорск",
|
|
||||||
"Одинцово",
|
|
||||||
"Подольск",
|
|
||||||
"Сергиев Посад",
|
|
||||||
"Королёв",
|
|
||||||
"Мытищи",
|
|
||||||
"Орехово-Зуево",
|
|
||||||
"Серпухов",
|
|
||||||
"Раменское",
|
|
||||||
"Наро-Фоминск",
|
|
||||||
"Щёлково",
|
|
||||||
"Коломна",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
||||||
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
||||||
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
||||||
|
|
@ -3080,7 +3040,6 @@ def _fold_city(name: str) -> str:
|
||||||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES},
|
|
||||||
**{
|
**{
|
||||||
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
||||||
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
||||||
|
|
@ -3127,36 +3086,6 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Первоуральск": (56.9083, 59.9483),
|
"Первоуральск": (56.9083, 59.9483),
|
||||||
"Ревда": (56.7986, 59.9298),
|
"Ревда": (56.7986, 59.9298),
|
||||||
"Серов": (59.6047, 60.5772),
|
"Серов": (59.6047, 60.5772),
|
||||||
# Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с
|
|
||||||
# COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из
|
|
||||||
# 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города
|
|
||||||
# раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс
|
|
||||||
# ближайшего центроида без порога, теперь у них есть и собственный порог).
|
|
||||||
# Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в
|
|
||||||
# packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же
|
|
||||||
# координаты, что использует скрейпер (резолв Nominatim).
|
|
||||||
"Видное": (55.5519, 37.7133),
|
|
||||||
"Балашиха": (55.7969, 37.9386),
|
|
||||||
"Железнодорожный": (55.7444, 38.0128),
|
|
||||||
"Люберцы": (55.6767, 37.8931),
|
|
||||||
"Электросталь": (55.7845, 38.4449),
|
|
||||||
"Химки": (55.8894, 37.4450),
|
|
||||||
"Домодедово": (55.4368, 37.7681),
|
|
||||||
"Ногинск": (55.8554, 38.4412),
|
|
||||||
"Реутов": (55.7614, 37.8564),
|
|
||||||
"Долгопрудный": (55.9386, 37.5100),
|
|
||||||
"Красногорск": (55.8317, 37.3300),
|
|
||||||
"Одинцово": (55.6789, 37.2639),
|
|
||||||
"Подольск": (55.4312, 37.5450),
|
|
||||||
"Сергиев Посад": (56.3153, 38.1360),
|
|
||||||
"Королёв": (55.9142, 37.8256),
|
|
||||||
"Мытищи": (55.9116, 37.7308),
|
|
||||||
"Орехово-Зуево": (55.8062, 38.9810),
|
|
||||||
"Серпухов": (54.9155, 37.4196),
|
|
||||||
"Раменское": (55.5709, 38.2282),
|
|
||||||
"Наро-Фоминск": (55.3843, 36.7235),
|
|
||||||
"Щёлково": (55.9206, 37.9916),
|
|
||||||
"Коломна": (55.0939, 38.7670),
|
|
||||||
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
||||||
# Как сетка получена, почему точек именно столько, где проходит граница
|
# Как сетка получена, почему точек именно столько, где проходит граница
|
||||||
# с областью и какова остаточная цена — см. большой комментарий над
|
# с областью и какова остаточная цена — см. большой комментарий над
|
||||||
|
|
@ -3198,18 +3127,30 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Поварово": (56.0667, 37.0667),
|
"Поварово": (56.0667, 37.0667),
|
||||||
"Дедовск": (55.8672, 37.1200),
|
"Дедовск": (55.8672, 37.1200),
|
||||||
"Нахабино": (55.8500, 37.1833),
|
"Нахабино": (55.8500, 37.1833),
|
||||||
|
"Реутов": (55.7614, 37.8564),
|
||||||
|
"Подольск": (55.4312, 37.5450),
|
||||||
"Апрелевка": (55.5500, 37.0700),
|
"Апрелевка": (55.5500, 37.0700),
|
||||||
"Немчиновка": (55.7050, 37.3450),
|
"Немчиновка": (55.7050, 37.3450),
|
||||||
|
"Химки": (55.8894, 37.4450),
|
||||||
|
"Одинцово": (55.6789, 37.2639),
|
||||||
"Лобня": (56.0100, 37.4750),
|
"Лобня": (56.0100, 37.4750),
|
||||||
|
"Мытищи": (55.9116, 37.7308),
|
||||||
"Котельники": (55.6553, 37.8619),
|
"Котельники": (55.6553, 37.8619),
|
||||||
|
"Красногорск": (55.8317, 37.3300),
|
||||||
|
"Люберцы": (55.6767, 37.8931),
|
||||||
"Дзержинский": (55.6294, 37.8500),
|
"Дзержинский": (55.6294, 37.8500),
|
||||||
"Развилка": (55.5842, 37.7392),
|
"Развилка": (55.5842, 37.7392),
|
||||||
"Климовск": (55.3667, 37.5333),
|
"Климовск": (55.3667, 37.5333),
|
||||||
|
"Балашиха": (55.7969, 37.9386),
|
||||||
"Истра": (55.9167, 36.8667),
|
"Истра": (55.9167, 36.8667),
|
||||||
|
"Долгопрудный": (55.9386, 37.5100),
|
||||||
|
"Королёв": (55.9142, 37.8256),
|
||||||
|
"Видное": (55.5519, 37.7133),
|
||||||
"Селятино": (55.5081, 36.9825),
|
"Селятино": (55.5081, 36.9825),
|
||||||
"Томилино": (55.6528, 37.9472),
|
"Томилино": (55.6528, 37.9472),
|
||||||
"Некрасовский": (56.0500, 37.5500),
|
"Некрасовский": (56.0500, 37.5500),
|
||||||
"Барвиха": (55.7333, 37.2333),
|
"Барвиха": (55.7333, 37.2333),
|
||||||
|
"Железнодорожный": (55.7444, 38.0128),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ from sqlalchemy.exc import ArgumentError
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import DB_CONNECT_ARGS
|
|
||||||
|
|
||||||
|
|
||||||
class AuthDatabaseNotConfiguredError(RuntimeError):
|
class AuthDatabaseNotConfiguredError(RuntimeError):
|
||||||
|
|
@ -102,18 +101,6 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
|
||||||
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
||||||
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
||||||
hide_parameters=True,
|
hide_parameters=True,
|
||||||
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
|
|
||||||
# потолок на одном движке и мина на втором это не починка, а половина.
|
|
||||||
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
|
|
||||||
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
|
|
||||||
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
|
|
||||||
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
|
|
||||||
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
|
|
||||||
# --workers) — включая `/health`.
|
|
||||||
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
|
|
||||||
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
|
|
||||||
# же путь, что и любой другой сбой реестра, а не 500.
|
|
||||||
connect_args=DB_CONNECT_ARGS,
|
|
||||||
)
|
)
|
||||||
except (ArgumentError, ValueError):
|
except (ArgumentError, ValueError):
|
||||||
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
|
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
|
||||||
|
|
|
||||||
|
|
@ -389,25 +389,6 @@ class Settings(BaseSettings):
|
||||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||||
|
|
||||||
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
|
||||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
|
||||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
|
||||||
# объявления. Число приходило из ДКП-коридора Росреестра, а тот при тонкой
|
|
||||||
# улице (n < DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и
|
|
||||||
# помечает себя scope="city_wide". Проверено на проде: у Янтарной ОДНА сделка,
|
|
||||||
# по Красногорску 4770 с медианой 200 676 ₽/м² — пользователю показали среднюю
|
|
||||||
# цену города как оценку его квартиры. Доля таких ответов 3.8% по ЕКБ, 4.8% по
|
|
||||||
# 77/50, то есть дефект продукта, а не регионального покрытия.
|
|
||||||
# Флаг делает две вещи, обе откатываются одним переключателем:
|
|
||||||
# 1) общегородской коридор больше не может стать headline (уличный — может:
|
|
||||||
# Нижний Тагил с 12 сделками по улице это настоящий сигнал, ради него
|
|
||||||
# #oblast-D и делали, иначе туда протекал екатеринбургский asking вшестеро
|
|
||||||
# выше);
|
|
||||||
# 2) если headline так и не появился, ответ уходит честной карточкой
|
|
||||||
# «недостаточно данных», а не нулями.
|
|
||||||
# False ⇒ прежнее поведение целиком, без релиза.
|
|
||||||
estimate_require_local_evidence: bool = True
|
|
||||||
|
|
||||||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||||||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||||
|
|
@ -449,27 +430,7 @@ class Settings(BaseSettings):
|
||||||
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
|
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
|
||||||
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
||||||
# OFF ⇒ точно старое поведение expected_sold.
|
# OFF ⇒ точно старое поведение expected_sold.
|
||||||
# ВЫКЛЮЧЕНА 2026-09-16. Замер на проде показал, что коррекция превратилась в
|
estimate_hedonic_correction_enabled: bool = True
|
||||||
# чистое занижение цены: она двигает точку вниз и НЕ покупает за это точности.
|
|
||||||
# Контролируемый эксперимент — одна и та же выборка (--engine full --sample 2000
|
|
||||||
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id), меняется
|
|
||||||
# ровно эта переменная:
|
|
||||||
# регион ВКЛ bias/MAPE/покрытие ВЫКЛ bias/MAPE/покрытие
|
|
||||||
# 77 Москва -9.1 / 20.5 / 69.0 -4.1 / 19.7 / 71.2
|
|
||||||
# 50 область -12.1 / 19.4 / 75.4 -10.4 / 19.2 / 74.8
|
|
||||||
# 66 обл. целиком -17.1 / 23.0 / 66.7 -12.4 / 23.2 / 66.2
|
|
||||||
# 66 только ЕКБ -17.4 / 21.2 / 69.8 -14.2 / 21.3 / 69.9
|
|
||||||
# Последняя строка решающая: ЕКБ — ровно та выборка, на которой коэффициенты
|
|
||||||
# перефитили 30.08.2026 (#3248, n=1269). Даже там выключение снимает 3.2 пункта
|
|
||||||
# занижения при неизменных MAPE и покрытии. Механика понятна из самих
|
|
||||||
# коэффициентов: b_larea=0, поэтому фактор = exp(-0.0140 + 0.0769*(year-2000)/20
|
|
||||||
# - 0.0745*[floor==1]), и для дома 1980 года это 0.913, то есть -8.7%. Жилой фонд
|
|
||||||
# в выборке преимущественно старый → фактор systematically < 1 → минус к цене.
|
|
||||||
# Коэффициенты фитили на остатке log(actual_sold/expected_sold) ПРИ ТОЙ базе,
|
|
||||||
# какая была тогда; база с тех пор уехала (area-бакетный ratio #2620, коэффициент
|
|
||||||
# asking->sold пересчитан по регионам 15.09), а хедонику не пересчитывали.
|
|
||||||
# ВКЛЮЧАТЬ ОБРАТНО только вместе с перефитом и замером всех четырёх строк выше.
|
|
||||||
estimate_hedonic_correction_enabled: bool = False
|
|
||||||
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
|
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
|
||||||
#
|
#
|
||||||
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
|
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
|
||||||
|
|
|
||||||
|
|
@ -10,59 +10,10 @@ from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
|
|
||||||
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
|
|
||||||
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
|
|
||||||
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
|
|
||||||
# = длительность самого запроса, и задать её может только сервер.
|
|
||||||
#
|
|
||||||
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
|
|
||||||
# честной работы, но остался конечным:
|
|
||||||
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
|
|
||||||
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
|
|
||||||
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
|
|
||||||
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
|
|
||||||
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
|
|
||||||
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
|
|
||||||
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
|
|
||||||
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
|
|
||||||
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
|
|
||||||
#
|
|
||||||
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
|
|
||||||
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
|
|
||||||
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
|
|
||||||
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
|
|
||||||
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
|
|
||||||
_STATEMENT_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
|
|
||||||
# деградировать. 5 с — та же величина, что у миграций проекта
|
|
||||||
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
|
|
||||||
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
|
|
||||||
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
|
|
||||||
# считает, — statement_timeout тут только страховка от «считает вечно».
|
|
||||||
_LOCK_TIMEOUT_MS = 5_000
|
|
||||||
|
|
||||||
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
|
|
||||||
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
|
|
||||||
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
|
|
||||||
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
|
|
||||||
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
|
|
||||||
# гарантированно, а не в редком случае.
|
|
||||||
DB_CONNECT_ARGS = {
|
|
||||||
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
|
|
||||||
}
|
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
future=True,
|
future=True,
|
||||||
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
|
|
||||||
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
|
|
||||||
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
|
|
||||||
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
|
|
||||||
# `SET LOCAL lock_timeout` и потолком не ограничен.
|
|
||||||
connect_args=DB_CONNECT_ARGS,
|
|
||||||
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
|
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
|
||||||
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
|
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
|
||||||
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
||||||
|
|
|
||||||
|
|
@ -41,84 +41,17 @@ def scrub_query_secrets(text: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
class QuerySecretFilter(logging.Filter):
|
class QuerySecretFilter(logging.Filter):
|
||||||
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан.
|
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
|
||||||
|
|
||||||
Скрабит `record.msg` и КАЖДЫЙ элемент `record.args` по отдельности — НЕ схлопывает
|
|
||||||
их в единую строку через `record.getMessage()` с последующим `args = ()`.
|
|
||||||
|
|
||||||
Прод-баг (#3471): схлопывание ломало `uvicorn.access` — там `record.args` это
|
|
||||||
структурный 5-tuple `(client_addr, method, full_path, http_version, status_code)`,
|
|
||||||
который `uvicorn.logging.AccessFormatter.formatMessage()` распаковывает напрямую
|
|
||||||
(`a, b, c, d, e = record.args`), в обход `record.getMessage()`. Как только фильтр
|
|
||||||
находил секрет (например `?secret=` в webhook-пути) и обнулял `args`, форматтер падал
|
|
||||||
с `ValueError: not enough values to unpack (expected 5, got 0)` — сама попытка
|
|
||||||
заскрабить секрет ломала запись лога целиком (`--- Logging error ---` в докер-логах).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
args = record.args
|
message = record.getMessage()
|
||||||
if isinstance(args, tuple) and args:
|
scrubbed = scrub_query_secrets(message)
|
||||||
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
|
if scrubbed != message:
|
||||||
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
|
record.msg = scrubbed
|
||||||
# этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного
|
record.args = ()
|
||||||
# аргумента (например body_preview в app/services/dadata.py), а не расщеплены
|
|
||||||
# между текстом шаблона и голым значением — трогать msg тут не нужно и опасно
|
|
||||||
# (шаблонный `%s` сам по себе мог бы ложно совпасть с чувствительным именем
|
|
||||||
# параметра прямо перед ним).
|
|
||||||
scrubbed_args = tuple(
|
|
||||||
scrub_query_secrets(a) if isinstance(a, str) else a for a in args
|
|
||||||
)
|
|
||||||
if scrubbed_args != args:
|
|
||||||
record.args = scrubbed_args
|
|
||||||
elif isinstance(record.msg, str):
|
|
||||||
# Args нет — вся запись уже готовым текстом в msg (f-string и т.п.).
|
|
||||||
scrubbed_msg = scrub_query_secrets(record.msg)
|
|
||||||
if scrubbed_msg != record.msg:
|
|
||||||
record.msg = scrubbed_msg
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ── Секреты в произвольном тексте (тело ответа внешнего API) — #3471 ──────────
|
|
||||||
#
|
|
||||||
# DaData на HTTP 403 возвращает диагностику вида:
|
|
||||||
# "Feature 'CLEAN' disabled for token '<действующий 40-символьный токен>'. See ..."
|
|
||||||
# body_preview из этого текста уходит в WARNING/ERROR лог (app/services/dadata.py) →
|
|
||||||
# docker logs → потенциально breadcrumb к любой последующей ошибке в GlitchTip.
|
|
||||||
# Маскируем ДО логирования. Не завязываемся на конкретную формулировку вендора —
|
|
||||||
# она может измениться (см. generic-слой ниже).
|
|
||||||
|
|
||||||
_TOKEN_QUOTED = re.compile(r"(\btoken\s*['\"])([^'\"]+)(['\"])", re.IGNORECASE)
|
|
||||||
# Любая hex/base64-подобная последовательность 24+ символов — ловит секрет независимо
|
|
||||||
# от контекста (Authorization/X-Secret значения, если когда-нибудь попадут в текст как есть).
|
|
||||||
_LONG_SECRET_LIKE = re.compile(r"[A-Za-z0-9+/_-]{24,}")
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_value(value: str) -> str:
|
|
||||||
"""`8d4e…(40)` — первые 4 символа + длина в скобках, остальное скрыто."""
|
|
||||||
if len(value) <= 4:
|
|
||||||
return "***"
|
|
||||||
return f"{value[:4]}…({len(value)})"
|
|
||||||
|
|
||||||
|
|
||||||
def scrub_body_secrets(text: str | None) -> str:
|
|
||||||
"""Маскирует токены/секреты в произвольном тексте (тело ответа внешнего API и т.п.).
|
|
||||||
|
|
||||||
Двухслойно: 1) явный ``token '<value>'`` (текущая формулировка DaData на 403),
|
|
||||||
2) generic — любая hex/base64-подобная последовательность 24+ символов, чтобы защита
|
|
||||||
не зависела от того, как вендор сформулирует сообщение завтра.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text or ""
|
|
||||||
|
|
||||||
def _replace_quoted(m: re.Match[str]) -> str:
|
|
||||||
return f"{m.group(1)}{_mask_value(m.group(2))}{m.group(3)}"
|
|
||||||
|
|
||||||
scrubbed = _TOKEN_QUOTED.sub(_replace_quoted, text)
|
|
||||||
scrubbed = _LONG_SECRET_LIKE.sub(lambda m: _mask_value(m.group(0)), scrubbed)
|
|
||||||
return scrubbed
|
|
||||||
|
|
||||||
|
|
||||||
def install_query_secret_filter(*logger_names: str) -> None:
|
def install_query_secret_filter(*logger_names: str) -> None:
|
||||||
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.log_scrub import scrub_body_secrets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -174,7 +173,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
||||||
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||||
return None
|
return None
|
||||||
if status in (401, 403):
|
if status in (401, 403):
|
||||||
body_preview = scrub_body_secrets(response.text)[:200]
|
body_preview = (response.text or "")[:200]
|
||||||
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||||
|
|
@ -214,7 +213,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
||||||
logger.warning("dadata: HTTP %d — transient server error", status)
|
logger.warning("dadata: HTTP %d — transient server error", status)
|
||||||
return None
|
return None
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
body_preview = scrub_body_secrets(response.text)[:200]
|
body_preview = (response.text or "")[:200]
|
||||||
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
|
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -437,7 +436,7 @@ async def suggest_addresses(
|
||||||
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
||||||
return []
|
return []
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
body_preview = scrub_body_secrets(response.text)[:200]
|
body_preview = (response.text or "")[:200]
|
||||||
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
|
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,13 +328,6 @@ SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд р
|
||||||
_SBER_REGION_SERIES: dict[int, str] = {
|
_SBER_REGION_SERIES: dict[int, str] = {
|
||||||
66: SBER_TIME_ADJUST_REGION,
|
66: SBER_TIME_ADJUST_REGION,
|
||||||
77: "Москва",
|
77: "Москва",
|
||||||
# Московская область — свой ряд, а не фолбэк на «Россию». Загрузчик научился
|
|
||||||
# тянуть REF_AREA=50 (services/sber_index), и 12.09.2026 ряд приехал на прод:
|
|
||||||
# real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же глубина,
|
|
||||||
# что у Москвы и обл.66. До этой строки область считалась по общероссийскому
|
|
||||||
# ряду, то есть коридор ошибался на расхождение области со страной; теперь
|
|
||||||
# ошибка только внутрирегиональная.
|
|
||||||
50: "Московская область",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0):
|
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0):
|
||||||
|
|
@ -668,10 +661,8 @@ def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | No
|
||||||
# within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное
|
# within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное
|
||||||
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
|
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
|
||||||
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
|
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
|
||||||
# Cache key: (region_code, rooms bucket) — #3512. Регион — часть ключа с миграции 304
|
# Cache key: rooms bucket (единственный legacy per-rooms путь после #2002).
|
||||||
# (asking_to_sold_ratios ключуется по (region_code, rooms_bucket, district)); один
|
_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
|
||||||
# бакет теперь несёт РАЗНЫЕ ratio для разных регионов, общий int-ключ смешал бы их.
|
|
||||||
_asking_sold_ratio_cache: dict[tuple[int, int], tuple[float | None, str | None, float]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_asking_sold_ratio(
|
def _get_asking_sold_ratio(
|
||||||
|
|
@ -679,7 +670,6 @@ def _get_asking_sold_ratio(
|
||||||
rooms: int | None,
|
rooms: int | None,
|
||||||
area_m2: float | None = None,
|
area_m2: float | None = None,
|
||||||
anchor_ppm2: float | None = None,
|
anchor_ppm2: float | None = None,
|
||||||
region_code: int = regions_mod.DEFAULT_REGION_CODE,
|
|
||||||
) -> tuple[float | None, str | None]:
|
) -> tuple[float | None, str | None]:
|
||||||
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
|
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
|
||||||
|
|
||||||
|
|
@ -695,24 +685,9 @@ def _get_asking_sold_ratio(
|
||||||
rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в
|
rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в
|
||||||
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
|
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
|
||||||
|
|
||||||
#3512: region_code — регион ЗАПРОСА (target_region_code резолвера, см.
|
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
|
||||||
_ratio_resolver ниже), НЕ дефолт 66 всегда. Раньше ratio считался ИСКЛЮЧИТЕЛЬНО
|
(WHERE rooms_bucket = bucket AND district = '') → fallback на global -1
|
||||||
по ЕКБ и применялся к любому адресу, включая Москву (region_code=77) — коэффициент
|
(basis='global_fallback'). Ничего нет → (None, None).
|
||||||
рынка на порядок дешевле (медиана ДКП: 66 — 90 278 ₽/м², 77 — 322 976 ₽/м²,
|
|
||||||
замер 13.09.2026) домножал московскую asking-цену. Дефолт-параметр (66) сохранён
|
|
||||||
ради обратной совместимости позиционных вызовов (тот же идиом, что
|
|
||||||
`_is_plausible_deal(region_code: int = regions_mod.DEFAULT_REGION_CODE)`).
|
|
||||||
|
|
||||||
Запрос к asking_to_sold_ratios (migration 080 + 304): per-rooms строка СВОЕГО
|
|
||||||
региона (WHERE region_code = :region_code AND rooms_bucket = bucket AND
|
|
||||||
district = '') → fallback на global -1 ТОГО ЖЕ региона (basis='global_fallback').
|
|
||||||
Ни одной строки для региона (регион ещё не накопил 30/30 сделок/listings, см.
|
|
||||||
app/tasks/asking_to_sold_ratio.py) → явная деградация (None, None) + INFO-лог —
|
|
||||||
та же идиома, что sber_region_series_name делает для отсутствующего в карте
|
|
||||||
региона, но БЕЗ подстановки чужого регионального ratio: значение другого рынка
|
|
||||||
здесь — не нейтральный фолбэк (в отличие от общероссийского sber-ряда), а именно
|
|
||||||
та ошибка калибровки, которую #3512 устраняет. ratio_basis в ответе честно
|
|
||||||
остаётся None — сайту не приписывается ЧУЖОЙ региональный коэффициент.
|
|
||||||
|
|
||||||
anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер
|
anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер
|
||||||
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций — см.
|
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций — см.
|
||||||
|
|
@ -720,12 +695,11 @@ def _get_asking_sold_ratio(
|
||||||
статистический артефакт (#2002, см. module-level комментарий выше).
|
статистический артефакт (#2002, см. module-level комментарий выше).
|
||||||
|
|
||||||
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
|
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
|
||||||
Кэшируется на ключ (region_code, bucket) с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
|
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
|
||||||
"""
|
"""
|
||||||
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
|
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
|
||||||
cache_key = (region_code, bucket)
|
|
||||||
|
|
||||||
cached = _asking_sold_ratio_cache.get(cache_key)
|
cached = _asking_sold_ratio_cache.get(bucket)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
ratio, basis, fetched = cached
|
ratio, basis, fetched = cached
|
||||||
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
|
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
|
||||||
|
|
@ -749,39 +723,24 @@ def _get_asking_sold_ratio(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT ratio, basis FROM asking_to_sold_ratios
|
SELECT ratio, basis FROM asking_to_sold_ratios
|
||||||
WHERE region_code = CAST(:region_code AS int)
|
WHERE rooms_bucket = CAST(:b AS int) AND district = ''
|
||||||
AND rooms_bucket = CAST(:b AS int) AND district = ''
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"region_code": region_code, "b": bucket},
|
{"b": bucket},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1)
|
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1).
|
||||||
# ТОГО ЖЕ региона (#3512 — никогда чужого региона).
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT ratio, basis FROM asking_to_sold_ratios
|
SELECT ratio, basis FROM asking_to_sold_ratios
|
||||||
WHERE region_code = CAST(:region_code AS int)
|
WHERE rooms_bucket = -1 AND district = ''
|
||||||
AND rooms_bucket = -1 AND district = ''
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"region_code": region_code},
|
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is not None and row.ratio is not None:
|
if row is not None and row.ratio is not None:
|
||||||
ratio = float(row.ratio)
|
ratio = float(row.ratio)
|
||||||
basis = row.basis
|
basis = row.basis
|
||||||
elif row is None:
|
|
||||||
# #3512: у региона нет НИ ОДНОЙ строки (ни per-rooms, ни global -1) —
|
|
||||||
# обычно потому, что регион ещё не набрал 30/30 сделок/listings
|
|
||||||
# (см. app/tasks/asking_to_sold_ratio.py). Явная деградация, не
|
|
||||||
# подмена чужим регионом.
|
|
||||||
logger.info(
|
|
||||||
"asking_to_sold_ratio: region_code=%d нет ни одной строки "
|
|
||||||
"(bucket=%d) — sold-коррекция не применяется для этого региона",
|
|
||||||
region_code,
|
|
||||||
bucket,
|
|
||||||
)
|
|
||||||
ok = True
|
ok = True
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -810,7 +769,7 @@ def _get_asking_sold_ratio(
|
||||||
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
|
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
|
||||||
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
|
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
|
||||||
# уже вернулся ранним return и сюда не доходит.
|
# уже вернулся ранним return и сюда не доходит.
|
||||||
_asking_sold_ratio_cache[cache_key] = (ratio, basis, time.monotonic())
|
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
|
||||||
return ratio, basis
|
return ratio, basis
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4181,22 +4140,12 @@ def _price_from_inputs(
|
||||||
# anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия
|
# anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия
|
||||||
# стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это
|
# стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это
|
||||||
# по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг.
|
# по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг.
|
||||||
# #estimate-zero-analogs (2026-09-16): коридор годится в HEADLINE только пока
|
|
||||||
# он про ЭТОТ адрес. _fetch_dkp_corridor при тонкой улице (n <
|
|
||||||
# DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и помечает
|
|
||||||
# себя scope="city_wide" — такая медиана описывает город, а не квартиру, и
|
|
||||||
# выдавать её как оценку нельзя. Прод-репро: «Красногорск, Янтарная» вернул
|
|
||||||
# 236 766 ₽/м² при ОДНОЙ сделке на улице; по городу 4770 сделок с медианой
|
|
||||||
# 200 676 ₽/м² — пользователю показали среднюю цену Красногорска. Уличный
|
|
||||||
# коридор (Нижний Тагил, 12 сделок) — настоящий сигнал и остаётся headline:
|
|
||||||
# без него туда протекал екатеринбургский asking вшестеро выше (#oblast-D).
|
|
||||||
if (
|
if (
|
||||||
median_ppm2 <= 0
|
median_ppm2 <= 0
|
||||||
and anchor is None
|
and anchor is None
|
||||||
and dkp_raw is not None
|
and dkp_raw is not None
|
||||||
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
||||||
and dkp_raw.get("median_ppm2", 0) > 0
|
and dkp_raw.get("median_ppm2", 0) > 0
|
||||||
and not (settings.estimate_require_local_evidence and dkp_raw.get("scope") == "city_wide")
|
|
||||||
):
|
):
|
||||||
median_ppm2 = float(dkp_raw["median_ppm2"])
|
median_ppm2 = float(dkp_raw["median_ppm2"])
|
||||||
median_price = int(median_ppm2 * area_m2)
|
median_price = int(median_ppm2 * area_m2)
|
||||||
|
|
@ -5112,11 +5061,7 @@ async def estimate_quality(
|
||||||
appm2: float | None,
|
appm2: float | None,
|
||||||
) -> tuple[float | None, str | None]:
|
) -> tuple[float | None, str | None]:
|
||||||
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
|
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
|
||||||
# #3512: region_code — регион ЦЕЛИ (target_region_code, резолвлен по geo выше,
|
return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2)
|
||||||
# тот же код, что уже скоупит ДКП-коридор, #3051 PR-A) — НЕ дефолт 66 всегда.
|
|
||||||
return _get_asking_sold_ratio(
|
|
||||||
db, payload.rooms, payload.area_m2, anchor_ppm2=appm2, region_code=target_region_code
|
|
||||||
)
|
|
||||||
|
|
||||||
def _qi_lookup(q: str) -> tuple[float, int] | None:
|
def _qi_lookup(q: str) -> tuple[float, int] | None:
|
||||||
return _lookup_quarter_index(
|
return _lookup_quarter_index(
|
||||||
|
|
@ -5550,43 +5495,6 @@ async def estimate_quality(
|
||||||
sources_used=sources_used,
|
sources_used=sources_used,
|
||||||
)
|
)
|
||||||
|
|
||||||
# #estimate-zero-analogs (2026-09-16): честный отказ вместо пустой карточки.
|
|
||||||
# Отбор, КОМУ вообще позволено дать цену без листинговых аналогов, сделан
|
|
||||||
# выше — уличный ДКП-коридор может, общегородской нет. Здесь остаётся
|
|
||||||
# оформление: если после всей цепочки headline так и не появился, ответ не
|
|
||||||
# должен уезжать наружу нулями. Условие median_price <= 0 намеренно
|
|
||||||
# совпадает с computed-полем AggregatedEstimate.insufficient_data, по
|
|
||||||
# которому фронт уже рисует карточку «недостаточно данных» — один признак,
|
|
||||||
# а не два расходящихся.
|
|
||||||
#
|
|
||||||
# Механизм переиспользует существующий паттерн _empty_estimate
|
|
||||||
# (median/range/ppm2 = 0, confidence='low', analogs/deals пустые), но, в
|
|
||||||
# отличие от него (вызывается ТОЛЬКО при неудачном geocode), здесь geo и
|
|
||||||
# house-match уже есть и сохраняются — фронту есть что показать рядом с
|
|
||||||
# причиной отказа.
|
|
||||||
if settings.estimate_require_local_evidence and n_analogs == 0 and median_price <= 0:
|
|
||||||
logger.info(
|
|
||||||
"estimate_require_local_evidence: suppressing point price (n_analogs=0,"
|
|
||||||
" median_price<=0) addr=%r median_price_was=%s",
|
|
||||||
geo.full_address,
|
|
||||||
median_price,
|
|
||||||
)
|
|
||||||
median_price = 0
|
|
||||||
median_ppm2 = 0.0
|
|
||||||
range_low = 0
|
|
||||||
range_high = 0
|
|
||||||
expected_sold_price = None
|
|
||||||
expected_sold_per_m2 = None
|
|
||||||
expected_sold_range_low = None
|
|
||||||
expected_sold_range_high = None
|
|
||||||
market_percentile = None
|
|
||||||
api_analog_tier = None
|
|
||||||
explanation = (
|
|
||||||
"Недостаточно данных для точной оценки по этому адресу — рядом нет "
|
|
||||||
"сопоставимых объявлений и сделок. Попробуйте уточнить адрес или "
|
|
||||||
"обратиться к специалисту."
|
|
||||||
)
|
|
||||||
|
|
||||||
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
|
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
|
||||||
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
|
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
|
||||||
# на median/expected_sold/ranges — только метаданные для UI/маршрутизации.
|
# на median/expected_sold/ranges — только метаданные для UI/маршрутизации.
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ from lxml import etree
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.regions import REGIONS
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
|
# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
|
||||||
|
|
@ -522,62 +520,6 @@ def upsert_gar_houses(
|
||||||
return upserted
|
return upserted
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Город-фильтр матча: выбирается ПО РЕГИОНУ, а не задаётся руками на каждый запуск
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Раньше был жёсткой CLI-константой "Екатеринбург" (годилось только для region 66).
|
|
||||||
# Регионы принципиально разные по форме коллизии одноимённых улиц:
|
|
||||||
# - 66 (область): ОДИН доминирующий город в инвентаре (истор. запуск продукта был
|
|
||||||
# ЕКБ-only) + >20 сопоставимых по названиям городов-соседей в REGIONS[66].cities —
|
|
||||||
# без фильтра «Машиностроителей 6» из 4 городов сливается в одну строку. Фильтр
|
|
||||||
# нужен и достаточен.
|
|
||||||
# - 77 (Москва): город ровно один — фильтровать нечем и незачем (см. REGIONS[77]).
|
|
||||||
# - 50 (область): НЕТ доминирующего города (20 сопоставимых по объёму городов-спутников,
|
|
||||||
# см. docstring REGIONS[50]) — фильтр по ОДНОМУ городу был бы не защитой, а порчей
|
|
||||||
# знаменателя (отрежет почти весь регион). Риск коллизии одноимённых улиц РАЗНЫХ
|
|
||||||
# городов при отсутствии фильтра закрыт не им, а guard'ом на стороне SQL
|
|
||||||
# (см. _MATCH_SQL: canon_hits) — неоднозначный canon не матчится вовсе, а не
|
|
||||||
# угадывается по max(flat_count).
|
|
||||||
# REGIONS_REQUIRING_CITY_FILTER — явный, единственный источник этого продуктового
|
|
||||||
# решения (какие регионы НУЖДАЮТСЯ в one-city ограничении); САМО значение фильтра
|
|
||||||
# берётся из REGIONS[<code>].city_token (реестр regions.py, ЕДИНСТВЕННОЕ место границ
|
|
||||||
# покрытия) — не второй раз хардкодится строкой "Екатеринбург".
|
|
||||||
REGIONS_REQUIRING_CITY_FILTER: frozenset[int] = frozenset({66})
|
|
||||||
|
|
||||||
|
|
||||||
class CityFilterAutoType:
|
|
||||||
"""Маркер «city_filter не передан явно» — резолвится по региону в match_houses_to_gar."""
|
|
||||||
|
|
||||||
__slots__ = ()
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "CITY_FILTER_AUTO"
|
|
||||||
|
|
||||||
|
|
||||||
# Сентинел default'а (не None — None остаётся легитимным явным «фильтр отключён»).
|
|
||||||
CITY_FILTER_AUTO = CityFilterAutoType()
|
|
||||||
|
|
||||||
|
|
||||||
def default_city_filter_for_region(region_code: str | None) -> str | None:
|
|
||||||
"""Город-фильтр GAR-матча по умолчанию для региона (см. REGIONS_REQUIRING_CITY_FILTER).
|
|
||||||
|
|
||||||
region 66 → "Екатеринбург" (byte-for-byte прежнее поведение, значение из
|
|
||||||
REGIONS[66].city_token). Любой другой/неизвестный/отсутствующий регион → None
|
|
||||||
(без ограничения). region_code принимает и None, и нечисловую строку — не
|
|
||||||
ошибка, просто «не знаем региона» → без фильтра.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
code = int(region_code) if region_code is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if code is None or code not in REGIONS_REQUIRING_CITY_FILTER:
|
|
||||||
return None
|
|
||||||
region = REGIONS.get(code)
|
|
||||||
if region is None:
|
|
||||||
return None
|
|
||||||
return region.city_token.capitalize()
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Матчер ГАР → houses
|
# Матчер ГАР → houses
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -587,26 +529,16 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
|
||||||
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
|
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
|
||||||
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
|
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
|
||||||
#
|
#
|
||||||
# На каждый canon берём ОДНУ ГАР-строку (ROW_NUMBER … ORDER BY flat_count DESC, house_guid) —
|
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
|
||||||
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
|
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
|
||||||
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
|
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
|
||||||
# канон, что и в gar_pick. С city-фильтром (:city IS NOT NULL, region 66) поведение
|
# канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него
|
||||||
# byte-for-byte прежнее — ILIKE по full_address обязателен: без него «Машиностроителей 6» в
|
# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
|
||||||
# 4 городах region 66 даёт ложные коллизии.
|
# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
|
||||||
#
|
|
||||||
# БЕЗ city-фильтра (:city IS NULL — многогородские регионы без одного доминирующего города,
|
|
||||||
# напр. область 50) canon НЕ несёт населённый пункт (tradein_canon_addr режет всё, кроме улицы
|
|
||||||
# и номера дома — см. мигр. 144) — «Ленина 5» существует в десятках городов области. Молча
|
|
||||||
# брать «лучший по flat_count» здесь означало бы РАНДОМНО пришить дом одного города к дому
|
|
||||||
# другого. Вместо этого canon_hits (COUNT(*) OVER PARTITION BY canon в рамках уже
|
|
||||||
# region/city-отфильтрованной выборки) — если у canon >1 разных GAR-домов, ambiguity
|
|
||||||
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
|
|
||||||
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
|
|
||||||
# повторный прогон не трогает уже совпавшие строки.
|
|
||||||
_MATCH_SQL = text(
|
_MATCH_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH gar_scope AS (
|
WITH gar_pick AS (
|
||||||
SELECT
|
SELECT DISTINCT ON (canon)
|
||||||
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
|
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
|
||||||
FROM gar_house_flats
|
FROM gar_house_flats
|
||||||
WHERE flat_count > 0
|
WHERE flat_count > 0
|
||||||
|
|
@ -619,21 +551,7 @@ _MATCH_SQL = text(
|
||||||
CAST(:city AS text) IS NULL
|
CAST(:city AS text) IS NULL
|
||||||
OR full_address ILIKE '%' || CAST(:city AS text) || '%'
|
OR full_address ILIKE '%' || CAST(:city AS text) || '%'
|
||||||
)
|
)
|
||||||
),
|
ORDER BY canon, flat_count DESC, house_guid
|
||||||
gar_ranked AS (
|
|
||||||
SELECT
|
|
||||||
canon, house_guid, flat_count,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY canon ORDER BY flat_count DESC, house_guid
|
|
||||||
) AS rn,
|
|
||||||
COUNT(*) OVER (PARTITION BY canon) AS canon_hits
|
|
||||||
FROM gar_scope
|
|
||||||
),
|
|
||||||
gar_pick AS (
|
|
||||||
SELECT canon, house_guid, flat_count
|
|
||||||
FROM gar_ranked
|
|
||||||
WHERE rn = 1
|
|
||||||
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
|
|
||||||
)
|
)
|
||||||
UPDATE houses h
|
UPDATE houses h
|
||||||
SET gar_house_guid = gp.house_guid,
|
SET gar_house_guid = gp.house_guid,
|
||||||
|
|
@ -653,19 +571,15 @@ def match_houses_to_gar(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
region_code: str | None = None,
|
region_code: str | None = None,
|
||||||
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
|
city_filter: str | None = "Екатеринбург",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
|
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
|
||||||
|
|
||||||
city_filter: CITY_FILTER_AUTO (умолч.) → резолвится по region_code через
|
city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE —
|
||||||
default_city_filter_for_region (region 66 → «Екатеринбург», иначе None). Явный
|
защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
|
||||||
None отключает фильтр НЕЗАВИСИМО от региона; явная строка — ILIKE-override
|
за пределами ЕКБ — future work). Возвращает число обновлённых домов. Идемпотентно
|
||||||
(любой регион). Без фильтра ambiguity одноимённых улиц разных городов закрыта
|
(plain UPDATE, IS DISTINCT FROM gate).
|
||||||
отдельно — см. _MATCH_SQL (canon_hits). Возвращает число обновлённых домов.
|
|
||||||
Идемпотентно (plain UPDATE, IS DISTINCT FROM gate).
|
|
||||||
"""
|
"""
|
||||||
if isinstance(city_filter, CityFilterAutoType):
|
|
||||||
city_filter = default_city_filter_for_region(region_code)
|
|
||||||
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
|
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
|
||||||
matched = result.rowcount
|
matched = result.rowcount
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,7 @@ _REGION_66 = _ALL_REGIONS[66]
|
||||||
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||||||
# без записи здесь получают `marker=None` → cross-check пропускается
|
# без записи здесь получают `marker=None` → cross-check пропускается
|
||||||
# (fallback на bbox-only, прежнее поведение).
|
# (fallback на bbox-only, прежнее поведение).
|
||||||
# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская
|
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
||||||
# область"` для области и `state="Москва"` для города. Маркер "московская" не
|
|
||||||
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
|
|
||||||
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
|
|
||||||
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -966,12 +962,7 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
|
||||||
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
||||||
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
||||||
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
|
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
|
||||||
_DADATA_REGION_NAMES: dict[int, str] = {
|
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
|
||||||
66: SVERDLOVSK_OBLAST_REGION,
|
|
||||||
77: "Москва",
|
|
||||||
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
|
|
||||||
50: "Московская",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _dadata_region_name(region_code: int) -> str:
|
def _dadata_region_name(region_code: int) -> str:
|
||||||
|
|
@ -1115,14 +1106,7 @@ async def _nominatim_query_city_aware(
|
||||||
)
|
)
|
||||||
if city_specified:
|
if city_specified:
|
||||||
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||||||
# Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`,
|
# Город неизвестен — dual-query с суффиксом главного города региона
|
||||||
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
|
|
||||||
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
|
|
||||||
# и суффикс уверенно притянет чужой город — ровно ловушка #2576, только
|
|
||||||
# уровнем выше. Такому региону остаётся честный bare-запрос по viewbox.
|
|
||||||
if not _ALL_REGIONS[region_code].has_city_core:
|
|
||||||
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
|
||||||
# Дальше — регион с ядром: dual-query с суффиксом главного города
|
|
||||||
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
|
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
|
||||||
default_city = _region_default_city(region_code)
|
default_city = _region_default_city(region_code)
|
||||||
city_data = await _nominatim_query_multi(
|
city_data = await _nominatim_query_multi(
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,7 @@ class LocationIndexResult(BaseModel):
|
||||||
sample_size: int
|
sample_size: int
|
||||||
radius_m: int
|
radius_m: int
|
||||||
nearby_poi: list[NearbyPoi]
|
nearby_poi: list[NearbyPoi]
|
||||||
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена
|
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
|
||||||
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
|
|
||||||
|
|
||||||
|
|
||||||
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||||
|
|
@ -208,27 +207,9 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||||
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
||||||
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
||||||
#
|
#
|
||||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
|
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||||
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
|
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||||
# location_index явно ограничил владелец продукта.
|
# явно ограничил владелец продукта.
|
||||||
#
|
|
||||||
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
|
|
||||||
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
|
|
||||||
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
|
|
||||||
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
|
|
||||||
# 458 208) — примесь заниженная городскую медиану до 422 222 (−7.9%). У областных строк
|
|
||||||
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
|
|
||||||
#
|
|
||||||
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
|
|
||||||
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
|
|
||||||
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
|
|
||||||
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
|
|
||||||
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
|
|
||||||
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
|
|
||||||
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
|
|
||||||
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
|
|
||||||
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
|
|
||||||
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
|
|
||||||
_MEDIAN_PPM2_LOCAL_SQL = text(
|
_MEDIAN_PPM2_LOCAL_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -242,7 +223,6 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
|
||||||
AND (geo_precision IS DISTINCT FROM 'city')
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
AND region_code = CAST(:region_code AS integer)
|
|
||||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
AND CAST(:bbox_north AS double precision)
|
AND CAST(:bbox_north AS double precision)
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
|
@ -268,7 +248,6 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||||||
AND (geo_precision IS DISTINCT FROM 'city')
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
AND region_code = CAST(:region_code AS integer)
|
|
||||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
AND CAST(:bbox_north AS double precision)
|
AND CAST(:bbox_north AS double precision)
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
|
@ -276,17 +255,6 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
_POI_COUNT_IN_REGION_SQL = text(
|
|
||||||
"""
|
|
||||||
SELECT count(*) AS n
|
|
||||||
FROM osm_poi_ekb_local
|
|
||||||
WHERE lat BETWEEN CAST(:bbox_south AS double precision)
|
|
||||||
AND CAST(:bbox_north AS double precision)
|
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
|
||||||
AND CAST(:bbox_east AS double precision)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
_NEAREST_POI_SQL = text(
|
_NEAREST_POI_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -325,7 +293,6 @@ def _local_median_ppm2(
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
"region_code": region.code,
|
|
||||||
"bbox_south": lat_min,
|
"bbox_south": lat_min,
|
||||||
"bbox_north": lat_max,
|
"bbox_north": lat_max,
|
||||||
"bbox_west": lon_min,
|
"bbox_west": lon_min,
|
||||||
|
|
@ -350,7 +317,6 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
"region_code": region.code,
|
|
||||||
"bbox_south": lat_min,
|
"bbox_south": lat_min,
|
||||||
"bbox_north": lat_max,
|
"bbox_north": lat_max,
|
||||||
"bbox_west": lon_min,
|
"bbox_west": lon_min,
|
||||||
|
|
@ -367,38 +333,18 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
|
||||||
|
|
||||||
|
|
||||||
def _fetch_nearby_poi(
|
def _fetch_nearby_poi(
|
||||||
db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region
|
db: Any, lat: float, lon: float, radius_m: int, top_n: int
|
||||||
) -> tuple[list[NearbyPoi], str]:
|
) -> tuple[list[NearbyPoi], str]:
|
||||||
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
||||||
|
|
||||||
Graceful fallback → ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
|
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
|
||||||
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
|
→ ([], "unavailable") вместо 500 или сфабрикованного списка.
|
||||||
строк, все екатеринбургские) — для Москвы это count > 0 (таблица не пуста), и статус
|
|
||||||
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
|
|
||||||
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
|
|
||||||
региона точки), а не на размер таблицы целиком. Загрузка московских POI — отдельная
|
|
||||||
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
|
|
||||||
не расширяется — trade_in.py и так трактует его как «нет данных», см. #2131).
|
|
||||||
"""
|
"""
|
||||||
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
|
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
||||||
in_region = (
|
if total == 0:
|
||||||
db.execute(
|
|
||||||
_POI_COUNT_IN_REGION_SQL,
|
|
||||||
{
|
|
||||||
"bbox_south": lat_min,
|
|
||||||
"bbox_north": lat_max,
|
|
||||||
"bbox_west": lon_min,
|
|
||||||
"bbox_east": lon_max,
|
|
||||||
},
|
|
||||||
).scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
if in_region == 0:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
|
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
||||||
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
|
"environment) — nearby_poi unavailable, no fabricated factors"
|
||||||
"no fabricated factors",
|
|
||||||
region.code,
|
|
||||||
)
|
)
|
||||||
return [], "unavailable"
|
return [], "unavailable"
|
||||||
|
|
||||||
|
|
@ -477,7 +423,7 @@ def compute_location_index(
|
||||||
poi_status="unavailable",
|
poi_status="unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region)
|
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
|
||||||
|
|
||||||
city_median, city_n = _citywide_median_ppm2(db, region)
|
city_median, city_n = _citywide_median_ppm2(db, region)
|
||||||
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,6 @@ from scraper_kit.orchestration.scheduler import (
|
||||||
from scraper_kit.orchestration.scheduler import (
|
from scraper_kit.orchestration.scheduler import (
|
||||||
_pick_resume as kit_pick_resume,
|
_pick_resume as kit_pick_resume,
|
||||||
)
|
)
|
||||||
from scraper_kit.orchestration.scheduler import (
|
|
||||||
_resolve_region_code as kit_resolve_region_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from scraper_kit.orchestration.scheduler import SchedulerContext
|
from scraper_kit.orchestration.scheduler import SchedulerContext
|
||||||
|
|
@ -239,12 +236,6 @@ async def _job_deactivate_stale(
|
||||||
listing_source: str = params.get("listing_source", "avito")
|
listing_source: str = params.get("listing_source", "avito")
|
||||||
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
|
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
|
||||||
segments: list[str] | None = params.get("segments")
|
segments: list[str] | None = params.get("segments")
|
||||||
# Регионы снятия. None (ключа нет в default_params) -> дефолт самой джобы,
|
|
||||||
# DEFAULT_DEACTIVATION_REGION_CODES=(66,): снимать устаревшее можно только там,
|
|
||||||
# где идёт регулярный пересбор -- см. развёрнутый комментарий у константы в
|
|
||||||
# deactivate_stale_avito.py. Расширять список ВМЕСТЕ с запуском сбора по
|
|
||||||
# региону, ключом "region_codes" (jsonb-массив кодов) в строке расписания.
|
|
||||||
region_codes: list[int] | None = params.get("region_codes")
|
|
||||||
staleness_column: str = params.get("staleness_column", "last_seen_at")
|
staleness_column: str = params.get("staleness_column", "last_seen_at")
|
||||||
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
|
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
|
||||||
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||||
|
|
@ -284,7 +275,6 @@ async def _job_deactivate_stale(
|
||||||
listing_source=listing_source,
|
listing_source=listing_source,
|
||||||
ttl_days=ttl_days,
|
ttl_days=ttl_days,
|
||||||
segments=segments,
|
segments=segments,
|
||||||
region_codes=region_codes,
|
|
||||||
staleness_column=staleness_column,
|
staleness_column=staleness_column,
|
||||||
min_confirmations=min_confirmations,
|
min_confirmations=min_confirmations,
|
||||||
revisit_floor_quantile=revisit_floor_quantile,
|
revisit_floor_quantile=revisit_floor_quantile,
|
||||||
|
|
@ -335,34 +325,6 @@ async def _job_landing_stats(
|
||||||
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
||||||
|
|
||||||
|
|
||||||
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
|
|
||||||
async def _job_landing_showcase_deals(
|
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
|
||||||
) -> None:
|
|
||||||
"""Пересчёт витрины сделок публичного лэндинга (#3469).
|
|
||||||
|
|
||||||
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
|
|
||||||
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
|
|
||||||
`run_id` ничего не знает — тот же случай, что у `_job_refresh_search_matview`,
|
|
||||||
и решается так же: done/failed ставим здесь.
|
|
||||||
|
|
||||||
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
|
|
||||||
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
|
|
||||||
"""
|
|
||||||
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
|
|
||||||
|
|
||||||
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
try:
|
|
||||||
counters = await loop.run_in_executor(
|
|
||||||
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
|
|
||||||
)
|
|
||||||
ctx.runs.mark_done(db, run_id, counters)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
|
|
||||||
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
|
|
||||||
|
|
||||||
|
|
||||||
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||||
async def _job_sber_freshness_monitor(
|
async def _job_sber_freshness_monitor(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -525,7 +487,6 @@ async def _job_domclick_city_sweep(
|
||||||
rooms=params.get("rooms"),
|
rooms=params.get("rooms"),
|
||||||
pages=int(params.get("pages_per_anchor", 5)),
|
pages=int(params.get("pages_per_anchor", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
||||||
region_code=kit_resolve_region_code(params),
|
|
||||||
resume_run_id=kit_pick_resume(db, run_id),
|
resume_run_id=kit_pick_resume(db, run_id),
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
)
|
)
|
||||||
|
|
@ -950,7 +911,6 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||||
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||||
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
||||||
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
|
|
||||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||||
"yandex_newbuilding_sweep": Handler(
|
"yandex_newbuilding_sweep": Handler(
|
||||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -10,23 +10,13 @@
|
||||||
Регион 50 (Московская область) отложен сознательно — обоснование в #2996:
|
Регион 50 (Московская область) отложен сознательно — обоснование в #2996:
|
||||||
10 121 текстовое имя города против 612 у Москвы, вся мина имён — в области.
|
10 121 текстовое имя города против 612 у Москвы, вся мина имён — в области.
|
||||||
|
|
||||||
#3052: `region_for_point` резолвит по НАСТОЯЩЕЙ границе региона (полигон
|
|
||||||
OSM/Nominatim, `region_boundaries/boundaries.geojson.json`), не по
|
|
||||||
прямоугольнику `bbox_region` — прямоугольники Москвы и области пересекались
|
|
||||||
(Химки/Реутов/Котельники/Люберцы уходили в Москву), полигоны нет.
|
|
||||||
|
|
||||||
Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
|
Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
|
||||||
geocoder / location_index / matching.normalize, циклы недопустимы). json/
|
geocoder / location_index / matching.normalize, циклы недопустимы).
|
||||||
pathlib — стандартная библиотека, листовость не нарушают.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
|
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
|
||||||
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
|
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
|
||||||
|
|
@ -71,15 +61,6 @@ class Region:
|
||||||
Регион без тира должен деградировать ЯВНО (потребитель
|
Регион без тира должен деградировать ЯВНО (потребитель
|
||||||
спрашивает unsupported_tier_reason и логирует/маркирует),
|
спрашивает unsupported_tier_reason и логирует/маркирует),
|
||||||
а не молча считать дальше без источника.
|
а не молча считать дальше без источника.
|
||||||
has_city_core — есть ли у региона ОДИН город-ядро, имя которого допустимо
|
|
||||||
молча подставлять в запрос геокодера, когда город не назван
|
|
||||||
(dual-query `_nominatim_query_city_aware`, ветка «город
|
|
||||||
неизвестен»). True у 66/77 (Екатеринбург / Москва — там это
|
|
||||||
majority-трафик). False у 50: у области 20 сопоставимых
|
|
||||||
городов и ~970 населённых пунктов в сырье, подстановка
|
|
||||||
«Красногорск» к «Сабурово, Луговая» — ровно та же ловушка
|
|
||||||
одноимённых улиц, от которой отказались в #2576.
|
|
||||||
|
|
||||||
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
|
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
|
||||||
строк, приходящих из источника без надёжного city-поля
|
строк, приходящих из источника без надёжного city-поля
|
||||||
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
||||||
|
|
@ -101,7 +82,6 @@ class Region:
|
||||||
cities: frozenset[str]
|
cities: frozenset[str]
|
||||||
enrichment_tiers: frozenset[str]
|
enrichment_tiers: frozenset[str]
|
||||||
canonical_city: str | None = None
|
canonical_city: str | None = None
|
||||||
has_city_core: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||||||
|
|
@ -110,213 +90,6 @@ def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||||||
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
|
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
|
||||||
|
|
||||||
|
|
||||||
# ── #3052: настоящая граница региона (point-in-polygon вместо bbox) ─────────
|
|
||||||
#
|
|
||||||
# Источник — region_boundaries/boundaries.geojson.json: полигоны OSM/Nominatim
|
|
||||||
# (см. поля _source/_license внутри файла, лицензия ODbL требует их хранить).
|
|
||||||
# Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый
|
|
||||||
# вызов region_for_point был бы дороже самого ray casting.
|
|
||||||
#
|
|
||||||
# ТОЧНОСТЬ (правка регрессии первой версии #3052). Полигоны берутся БЕЗ
|
|
||||||
# упрощения (`polygon_threshold=0`, 42 755 вершин), а не с `0.005` (~500 м,
|
|
||||||
# 2 388 вершин), как в первой версии. Полукилометровое упрощение спрямляет
|
|
||||||
# ровно то, ради чего полигоны и заводились — изрезанную границу Москвы с
|
|
||||||
# областью. Сверка резолва против сохранённого region_code по проду (277 582
|
|
||||||
# объявления + 48 143 дома) на упрощённой геометрии дала: 69 точек НИ в одном
|
|
||||||
# полигоне (агломерация → None → вызывающий код `trade_in.py` подставляет
|
|
||||||
# DEFAULT_REGION_CODE, и адрес в Куркино считается по коэффициентам
|
|
||||||
# Екатеринбурга), Митино/Косино-Ухтомский/Можайский вне Москвы (27 домов),
|
|
||||||
# 731 объявление области внутри Москвы. Без упрощения этих спрямлений нет.
|
|
||||||
#
|
|
||||||
# Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра.
|
|
||||||
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
|
|
||||||
# (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 —
|
|
||||||
# внешнее кольцо + 9 дыр). Плоский список работает благодаря even-odd
|
|
||||||
# правилу: точка внутри региона ⟺ она попадает внутрь НЕЧЁТНОГО числа колец
|
|
||||||
# из списка. Это ОДНОВРЕМЕННО корректно обрабатывает дыры (Москва — дыра в
|
|
||||||
# кольцах региона 50: попадание в кольцо-дыру снимает чётность, снятую
|
|
||||||
# внешним кольцом) и непересекающиеся части мультиполигона (попадание ровно
|
|
||||||
# в одно кольцо — нечётность не портится соседними частями, которые точка не
|
|
||||||
# задевает) — без явного разделения "внешний контур минус дыры".
|
|
||||||
Ring = list[tuple[float, float]]
|
|
||||||
|
|
||||||
_BOUNDARIES_PATH = Path(__file__).parent / "region_boundaries" / "boundaries.geojson.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _flatten_rings(geometry: dict[str, object]) -> list[Ring]:
|
|
||||||
"""Все кольца геометрии (Polygon или MultiPolygon) одним плоским списком."""
|
|
||||||
coords = geometry["coordinates"]
|
|
||||||
if geometry["type"] == "Polygon":
|
|
||||||
polygons = [coords]
|
|
||||||
elif geometry["type"] == "MultiPolygon":
|
|
||||||
polygons = coords # type: ignore[assignment]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"неподдержанный тип геометрии: {geometry['type']}")
|
|
||||||
rings: list[Ring] = []
|
|
||||||
for polygon in polygons:
|
|
||||||
for ring in polygon: # type: ignore[union-attr]
|
|
||||||
rings.append([(pt[0], pt[1]) for pt in ring]) # type: ignore[index]
|
|
||||||
return rings
|
|
||||||
|
|
||||||
|
|
||||||
def _load_region_boundaries() -> dict[int, list[Ring]]:
|
|
||||||
"""code региона → плоский список колец его полигона/мультиполигона."""
|
|
||||||
with _BOUNDARIES_PATH.open(encoding="utf-8") as f:
|
|
||||||
payload = json.load(f)
|
|
||||||
return {
|
|
||||||
int(code): _flatten_rings(entry["geometry"]) for code, entry in payload["regions"].items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries()
|
|
||||||
|
|
||||||
|
|
||||||
class _IndexedRing(NamedTuple):
|
|
||||||
"""Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
|
|
||||||
|
|
||||||
Наивный ray casting линеен по числу вершин, а вершин после отказа от
|
|
||||||
упрощения в 18 раз больше (42 755 против 2 388) — прямым перебором это
|
|
||||||
десятки-сотни микросекунд на вызов. Два предпосчёта снимают почти всё:
|
|
||||||
|
|
||||||
lat_min/lat_max/lon_min/lon_max — bbox САМОГО кольца (не региона): у
|
|
||||||
Москвы мультиполигон из 10 частей, у области внешний контур + 9 дыр,
|
|
||||||
и точка почти всегда вне bbox большинства колец — такое кольцо
|
|
||||||
отвергается четырьмя сравнениями вместо обхода вершин.
|
|
||||||
band_h/bands — кольцо нарезано на горизонтальные полосы по широте; в
|
|
||||||
полосе лежат ТОЛЬКО те рёбра, чей диапазон широт её задевает (ребро
|
|
||||||
попадает во все свои полосы, поэтому пересечения не теряются).
|
|
||||||
Луч ray casting горизонтален, значит пересекать его могут лишь рёбра
|
|
||||||
своей полосы — обход сокращается с «все вершины кольца» до «рёбра
|
|
||||||
одной полосы» (для внешнего контура области — 23 511 → максимум 724,
|
|
||||||
типично десятки).
|
|
||||||
points — те же вершины ОДНИМ объектом с _REGION_BOUNDARIES (не копия):
|
|
||||||
нужны fallback'у по расстоянию до границы в region_for_point.
|
|
||||||
"""
|
|
||||||
|
|
||||||
lat_min: float
|
|
||||||
lat_max: float
|
|
||||||
lon_min: float
|
|
||||||
lon_max: float
|
|
||||||
band_h: float
|
|
||||||
bands: tuple[tuple[tuple[float, float, float, float], ...], ...]
|
|
||||||
points: Ring
|
|
||||||
|
|
||||||
|
|
||||||
# Целевая плотность полосы: ~4 вершины кольца на полосу. Мельче — полосы
|
|
||||||
# начинают стоить памяти и промахов кэша больше, чем экономят на обходе;
|
|
||||||
# крупнее — в полосу попадает слишком много рёбер. Потолок числа полос
|
|
||||||
# ограничивает память на самых длинных кольцах (внешний контур области).
|
|
||||||
_RING_BAND_DENSITY = 4
|
|
||||||
_RING_MAX_BANDS = 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _index_ring(points: Ring) -> _IndexedRing:
|
|
||||||
"""Посчитать bbox и полосы кольца (один раз на импорте модуля)."""
|
|
||||||
lat_min = min(p[1] for p in points)
|
|
||||||
lat_max = max(p[1] for p in points)
|
|
||||||
lat_span = lat_max - lat_min
|
|
||||||
n_bands = max(1, min(_RING_MAX_BANDS, len(points) // _RING_BAND_DENSITY))
|
|
||||||
# Вырожденное по широте кольцо (lat_span == 0) в полосы не режется:
|
|
||||||
# одна полоса, band_h произвольный ненулевой — делить на 0 нельзя.
|
|
||||||
band_h = lat_span / n_bands if lat_span > 0.0 else 1.0
|
|
||||||
buckets: list[list[tuple[float, float, float, float]]] = [[] for _ in range(n_bands)]
|
|
||||||
x_prev, y_prev = points[-1]
|
|
||||||
for x, y in points:
|
|
||||||
edge = (x, y, x_prev, y_prev)
|
|
||||||
lo = int((min(y, y_prev) - lat_min) / band_h)
|
|
||||||
hi = int((max(y, y_prev) - lat_min) / band_h)
|
|
||||||
lo = 0 if lo < 0 else min(lo, n_bands - 1)
|
|
||||||
hi = 0 if hi < 0 else min(hi, n_bands - 1)
|
|
||||||
for band in range(lo, hi + 1):
|
|
||||||
buckets[band].append(edge)
|
|
||||||
x_prev, y_prev = x, y
|
|
||||||
return _IndexedRing(
|
|
||||||
lat_min=lat_min,
|
|
||||||
lat_max=lat_max,
|
|
||||||
lon_min=min(p[0] for p in points),
|
|
||||||
lon_max=max(p[0] for p in points),
|
|
||||||
band_h=band_h,
|
|
||||||
bands=tuple(tuple(b) for b in buckets),
|
|
||||||
points=points,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_REGION_RING_INDEX: dict[int, tuple[_IndexedRing, ...]] = {
|
|
||||||
code: tuple(_index_ring(ring) for ring in rings) for code, rings in _REGION_BOUNDARIES.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _point_in_ring(ring: _IndexedRing, lon: float, lat: float) -> bool:
|
|
||||||
"""PNPOLY (W. R. Franklin) ray casting по рёбрам ОДНОЙ полосы кольца."""
|
|
||||||
lat_min, lat_max, lon_min, lon_max, band_h, bands, _points = ring
|
|
||||||
if lat < lat_min or lat > lat_max or lon < lon_min or lon > lon_max:
|
|
||||||
return False
|
|
||||||
band = int((lat - lat_min) / band_h)
|
|
||||||
if band >= len(bands): # lat == lat_max попадает ровно на верхнюю кромку
|
|
||||||
band = len(bands) - 1
|
|
||||||
inside = False
|
|
||||||
for xi, yi, xj, yj in bands[band]:
|
|
||||||
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
|
|
||||||
inside = not inside
|
|
||||||
return inside
|
|
||||||
|
|
||||||
|
|
||||||
def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
|
|
||||||
"""True если (lat, lon) внутри настоящей границы региона `code`.
|
|
||||||
|
|
||||||
even-odd по всем кольцам сразу (см. комментарий выше про плоский список).
|
|
||||||
Региона без загруженной границы — граничит только по bbox (см. вызов в
|
|
||||||
region_for_point), сюда такой код не попадает."""
|
|
||||||
inside = False
|
|
||||||
for ring in _REGION_RING_INDEX[code]:
|
|
||||||
if _point_in_ring(ring, lon, lat):
|
|
||||||
inside = not inside
|
|
||||||
return inside
|
|
||||||
|
|
||||||
|
|
||||||
def _distance_to_boundary(code: int, lat: float, lon: float) -> float:
|
|
||||||
"""Расстояние от точки до ближайшего ребра границы региона `code`.
|
|
||||||
|
|
||||||
В градусах широты (1° ≈ 111 км) по ПЛОСКОМУ приближению с поправкой
|
|
||||||
cos(lat) на сжатие долготы: значение сравнивается только с таким же
|
|
||||||
значением другого региона-кандидата на тех же ~десятках метров, где
|
|
||||||
геодезия и плоскость расходятся на доли процента.
|
|
||||||
|
|
||||||
Путь редкий (только щель между полигонами соседних регионов, см.
|
|
||||||
region_for_point), поэтому обход честный — по всем рёбрам, без полос.
|
|
||||||
Стоимость срезается оценкой снизу по bbox кольца: кольцо, чей bbox уже
|
|
||||||
дальше найденного минимума, не может дать ребра ближе.
|
|
||||||
"""
|
|
||||||
lat_scale = math.cos(math.radians(lat))
|
|
||||||
px = lon * lat_scale
|
|
||||||
best = math.inf
|
|
||||||
ranked: list[tuple[float, _IndexedRing]] = []
|
|
||||||
for ring in _REGION_RING_INDEX[code]:
|
|
||||||
dx = max(ring.lon_min - lon, 0.0, lon - ring.lon_max) * lat_scale
|
|
||||||
dy = max(ring.lat_min - lat, 0.0, lat - ring.lat_max)
|
|
||||||
ranked.append((dx * dx + dy * dy, ring))
|
|
||||||
for lower_bound, ring in sorted(ranked, key=lambda item: item[0]):
|
|
||||||
if lower_bound >= best:
|
|
||||||
break
|
|
||||||
ax, ay = ring.points[-1]
|
|
||||||
ax *= lat_scale
|
|
||||||
for bx_raw, by in ring.points:
|
|
||||||
bx = bx_raw * lat_scale
|
|
||||||
dx, dy = bx - ax, by - ay
|
|
||||||
if dx or dy:
|
|
||||||
t = ((px - ax) * dx + (lat - ay) * dy) / (dx * dx + dy * dy)
|
|
||||||
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
|
|
||||||
ex = ax + t * dx - px
|
|
||||||
ey = ay + t * dy - lat
|
|
||||||
else: # выродившееся в точку ребро (дубль вершины в OSM)
|
|
||||||
ex, ey = ax - px, ay - lat
|
|
||||||
dist = ex * ex + ey * ey
|
|
||||||
if dist < best:
|
|
||||||
best = dist
|
|
||||||
ax, ay = bx, by
|
|
||||||
return math.sqrt(best)
|
|
||||||
|
|
||||||
|
|
||||||
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
|
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
|
||||||
TIER_AVITO_IMV = "avito_imv"
|
TIER_AVITO_IMV = "avito_imv"
|
||||||
TIER_YANDEX_VALUATION = "yandex_valuation"
|
TIER_YANDEX_VALUATION = "yandex_valuation"
|
||||||
|
|
@ -460,20 +233,14 @@ REGIONS: dict[int, Region] = {
|
||||||
),
|
),
|
||||||
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
|
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
|
||||||
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
|
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
|
||||||
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
|
# намеренно, не заглушка). Ряд Сбериндекса по области загружен в
|
||||||
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
|
# источник (#3498), но в карте _SBER_REGION_SERIES эстиматора кода 50
|
||||||
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
|
# ЕЩЁ НЕТ — тир пока недоступен, добавляется отдельным решением.
|
||||||
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
|
|
||||||
# за пределами этого реестра не спрашивают).
|
|
||||||
enrichment_tiers=frozenset(),
|
enrichment_tiers=frozenset(),
|
||||||
# Источники по области несут настоящий city (Химки, Балашиха — не
|
# Источники по области несут настоящий city (Химки, Балашиха — не
|
||||||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||||
# нечего и незачем, в отличие от 77.
|
# нечего и незачем, в отличие от 77.
|
||||||
canonical_city=None,
|
canonical_city=None,
|
||||||
# Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в
|
|
||||||
# запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в
|
|
||||||
# Красногорске, и в Сабурово, и ещё в десятке НП области.
|
|
||||||
has_city_core=False,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -493,76 +260,36 @@ def _bbox_area(bbox: BBox) -> float:
|
||||||
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
|
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
|
||||||
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
|
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
|
||||||
#
|
#
|
||||||
# #3052: сам резолв региона идёт по настоящему полигону (see
|
# Почему код региона как ключ порядка сломался: bbox_region(50) (Московская
|
||||||
# _point_in_region_polygon), не по прямоугольнику — реальные админ-границы
|
# область целиком, lat 54.20..56.96/lon 35.14..40.21) геометрически СОДЕРЖИТ
|
||||||
# 50 и 77 не пересекаются (Москва вырезана дырой из полигона области), так
|
# bbox_region(77) (Москва, 55.10..56.10/36.80..38.10) как прямоугольники — а
|
||||||
# что для КОНЕЧНОГО результата порядок обхода больше не обязателен: у точки
|
# 50 < 66 < 77 по числу. При обходе `sorted(REGIONS)` регион 50 проверялся бы
|
||||||
# есть ровно один полигон-кандидат, bbox какого региона ни проверяй первым.
|
# ПЕРВЫМ (50 < 77) и забирал бы себе ВСЕ точки Москвы, включая центр
|
||||||
# Порядок остаётся не как костыль корректности, а как ДЕШЁВЫЙ предварительный
|
# (55.75, 37.62) — она лежит в bbox_region обоих регионов одновременно. Старый
|
||||||
# отсев: bbox_region(50) (Московская область целиком, lat 54.20..56.96/
|
# докстринг называл это «на случай, если когда-нибудь пересекутся» — случай
|
||||||
# lon 35.14..40.21) геометрически СОДЕРЖИТ bbox_region(77) (Москва,
|
# наступил прямо при добавлении региона 50, не гипотетически.
|
||||||
# 55.10..56.10/36.80..38.10) как прямоугольники, а 50 < 77 по числовому коду.
|
|
||||||
# Если проверять регионы в порядке `sorted(REGIONS)`, для точки в центре
|
|
||||||
# Москвы bbox-отсев региона 50 пройдёт ПЕРВЫМ и завернёт в дорогой
|
|
||||||
# point-in-polygon по 1 227 точкам области раньше, чем дело дойдёт до
|
|
||||||
# компактного полигона Москвы (386 точек) — лишняя работа на каждый вызов,
|
|
||||||
# не баг результата (полигон 50 всё равно отвергнет точку — она в дыре), но
|
|
||||||
# systematic overhead на самом частом случае (Москва/область — соседи).
|
|
||||||
#
|
#
|
||||||
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает и это без
|
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает это БЕЗ
|
||||||
# ручного списка: чем компактнее регион, тем раньше его bbox-отсев и (при
|
# ручного списка: чем компактнее регион, тем раньше его проверяют, поэтому
|
||||||
# совпадении) полигон проверяют, поэтому вложенный по bbox регион (77 внутри
|
# вложенный регион (77 внутри 50) всегда выигрывает у объемлющего, а будущий
|
||||||
# 50) почти всегда получает свою точку дешевле, а новый регион сам встанет в
|
# новый регион сам встанет в верную позицию по своей площади — правку этого
|
||||||
# верную позицию по своей площади.
|
# места повторять не придётся.
|
||||||
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
|
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
|
||||||
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
|
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def region_for_point(lat: float, lon: float) -> Region | None:
|
def region_for_point(lat: float, lon: float) -> Region | None:
|
||||||
"""Регион покрытия, которому принадлежит точка, или None (вне охвата).
|
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
|
||||||
|
|
||||||
Два уровня отсева на каждого кандидата (порядок — `_POINT_LOOKUP_ORDER`,
|
Обход — `_POINT_LOOKUP_ORDER` (компактный bbox_region раньше обширного), а
|
||||||
компактный bbox_region раньше обширного, см. комментарий там):
|
не числовой код региона: код как ключ порядка ломается ровно на паре
|
||||||
1. `is_within_bbox` по `bbox_region` — дешёвый прямоугольный предфильтр,
|
50/77, см. комментарий у `_POINT_LOOKUP_ORDER`.
|
||||||
НЕ финальный ответ (прямоугольники Москвы и области пересекаются).
|
|
||||||
2. Полигон (`_point_in_region_polygon`) — настоящая граница, решает
|
|
||||||
результат. Если bbox прошёл, а полигон точку не принял (точка в
|
|
||||||
прямоугольнике области, но не в её реальных границах — то есть,
|
|
||||||
например, внутри вырезанной дыры Москвы), идём к следующему
|
|
||||||
кандидату, а не возвращаем None сразу.
|
|
||||||
|
|
||||||
None — ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
|
|
||||||
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
|
|
||||||
становится кандидат, к ГРАНИЦЕ которого точка ближе
|
|
||||||
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
|
|
||||||
как None, а тот (`trade_in.py`: `target_region.code if target_region else
|
|
||||||
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию — адрес в
|
|
||||||
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
|
|
||||||
полигонами соседних регионов неизбежны (у Москвы и области это независимо
|
|
||||||
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
|
|
||||||
общая), и закрывать их возвратом к прямоугольнику нельзя — прямоугольники
|
|
||||||
перекрываются и врут ровно там, ради чего #3052 и делался.
|
|
||||||
|
|
||||||
Региона без загруженного полигона в реестре нет (все REGIONS покрыты
|
|
||||||
boundaries.geojson.json) — на практике до fallback-ветки дело не доходит,
|
|
||||||
но она есть, чтобы новый регион без границы не «пропадал» молча, а
|
|
||||||
работал по старому bbox-поведению до того, как для него добавят полигон.
|
|
||||||
"""
|
"""
|
||||||
near_miss: list[int] = []
|
|
||||||
for code in _POINT_LOOKUP_ORDER:
|
for code in _POINT_LOOKUP_ORDER:
|
||||||
region = REGIONS[code]
|
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
|
||||||
if not is_within_bbox(lat, lon, region.bbox_region):
|
return REGIONS[code]
|
||||||
continue
|
|
||||||
if code not in _REGION_RING_INDEX or _point_in_region_polygon(code, lat, lon):
|
|
||||||
return region
|
|
||||||
near_miss.append(code)
|
|
||||||
if not near_miss:
|
|
||||||
return None
|
return None
|
||||||
if len(near_miss) == 1:
|
|
||||||
return REGIONS[near_miss[0]]
|
|
||||||
nearest = min(near_miss, key=lambda code: _distance_to_boundary(code, lat, lon))
|
|
||||||
return REGIONS[nearest]
|
|
||||||
|
|
||||||
|
|
||||||
def region_by_city(city: str | None) -> Region | None:
|
def region_by_city(city: str | None) -> Region | None:
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,6 @@ _DEFAULT_MAX_RETRIES = 5
|
||||||
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
|
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
|
||||||
_RATE_LIMIT_WINDOW_S = 60.0
|
_RATE_LIMIT_WINDOW_S = 60.0
|
||||||
|
|
||||||
# Текст зондирующего сообщения `verify_chat_and_topic` — см. докстринг там.
|
|
||||||
# Живёт в чате доли секунды (удаляется сразу после отправки), но должен быть
|
|
||||||
# узнаваем в логах ретранслятора/дебаге, если удаление вдруг не отработает.
|
|
||||||
_TOPIC_PROBE_TEXT = "\U0001f50d startup topic check"
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramGroupRateLimiter:
|
class TelegramGroupRateLimiter:
|
||||||
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
|
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
|
||||||
|
|
@ -749,13 +744,13 @@ class TelegramClient:
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
|
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
|
||||||
|
|
||||||
⚠️ НЕ провалидировано для проверки `message_thread_id`: живой прод-замер
|
Используется НЕ по прямому назначению (индикация набора), а как способ
|
||||||
(#3471) показал, что Telegram принимает и мгновенно подтверждает
|
проверить существование `message_thread_id` (темы форума) — см.
|
||||||
`sendChatAction` с ЗАВЕДОМО несуществующим `message_thread_id`
|
`verify_chat_and_topic`. Это единственный метод Bot API, который
|
||||||
(`{"ok":true,"result":true}`) — метод молча принимает любую тему,
|
принимает `message_thread_id` и не создаёт message-объект: если тема
|
||||||
существующую или нет. Раньше здесь было обратное (неверное)
|
удалена/переименована в другую с иным id, Telegram отвечает `Bad
|
||||||
утверждение; см. `verify_chat_and_topic`, которая для проверки темы
|
Request: message thread not found` мгновенно, а в истории чата не
|
||||||
использует `sendMessage`+`deleteMessage`."""
|
остаётся ни строки (индикатор эфемерный и не персистится)."""
|
||||||
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
||||||
if message_thread_id:
|
if message_thread_id:
|
||||||
payload["message_thread_id"] = message_thread_id
|
payload["message_thread_id"] = message_thread_id
|
||||||
|
|
@ -764,22 +759,6 @@ class TelegramClient:
|
||||||
)
|
)
|
||||||
return bool(result)
|
return bool(result)
|
||||||
|
|
||||||
async def delete_message(self, *, chat_id: int, message_id: int) -> bool:
|
|
||||||
"""deleteMessage — удаляет сообщение бота.
|
|
||||||
|
|
||||||
Используется `verify_chat_and_topic` для зачистки зондирующего
|
|
||||||
`sendMessage`-пробника сразу после проверки темы: тема существует
|
|
||||||
тогда и только тогда, когда сообщение вообще удалось отправить —
|
|
||||||
поэтому к моменту вызова `deleteMessage` id уже гарантированно
|
|
||||||
валиден."""
|
|
||||||
result = await self._request(
|
|
||||||
"deleteMessage",
|
|
||||||
{"chat_id": chat_id, "message_id": message_id},
|
|
||||||
max_retries=1,
|
|
||||||
max_backoff=5.0,
|
|
||||||
)
|
|
||||||
return bool(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_chat_and_topic(
|
async def verify_chat_and_topic(
|
||||||
client: TelegramClient,
|
client: TelegramClient,
|
||||||
|
|
@ -797,23 +776,18 @@ async def verify_chat_and_topic(
|
||||||
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
|
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
|
||||||
«через сутки тишины» на «в первую секунду после старта/рестарта».
|
«через сутки тишины» на «в первую секунду после старта/рестарта».
|
||||||
|
|
||||||
Способ: `getChat(chat_id)` + `sendMessage`/`deleteMessage` в саму тему.
|
Способ намеренно НЕ `sendMessage`+`deleteMessage`:
|
||||||
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
|
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
|
||||||
выгнан/не заблокирован — чистый read, нулевой видимый след.
|
выгнан/не заблокирован — чистый read, нулевой видимый след.
|
||||||
- Раньше здесь стоял `send_chat_action` (typing-индикатор с
|
- `send_chat_action` (typing-индикатор с `message_thread_id`) —
|
||||||
`message_thread_id`) в расчёте на то, что Telegram обязан знать про
|
единственный способ провалидировать САМУ тему без создания
|
||||||
`message_thread_id`, чтобы показать «печатает...» именно в нужном
|
message-объекта: Telegram обязан знать про `message_thread_id`, чтобы
|
||||||
треде. Живой прод-замер (#3471) опроверг это: `sendChatAction`
|
показать «печатает...» именно в нужном треде, и явно отказывает, если
|
||||||
принимает и подтверждает ЗАВЕДОМО несуществующий
|
такой темы нет. `sendMessage`+`deleteMessage` тоже сработал бы, но
|
||||||
`message_thread_id` (`{"ok":true,"result":true}`) — проверка была
|
оставлял бы видимый (пусть на секунды) артефакт в истории треда при
|
||||||
ложно-зелёной ВСЕГДА, удалённая/переименованная тема проходила её
|
КАЖДОМ рестарте контейнера — на rolling-деплое это многократно в
|
||||||
так же, как живая, а реальная отправка потом падала на каждом
|
сутки; typing-индикатор того же результата достигает без единого
|
||||||
сообщении. `sendMessage` с тем же мусорным id вместо этого мгновенно
|
сообщения.
|
||||||
отвечает `400 Bad Request: message thread not found` (эмпирически
|
|
||||||
подтверждено на том же проде), а с валидным id создаёт сообщение —
|
|
||||||
которое здесь же стирается `deleteMessage`, так что в истории треда
|
|
||||||
не остаётся ничего, кроме микросекундного технического сообщения на
|
|
||||||
каждом старте/рестарте контейнера.
|
|
||||||
|
|
||||||
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
|
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
|
||||||
уровня error — задача явно требует шума в логе, а не падения воркера
|
уровня error — задача явно требует шума в логе, а не падения воркера
|
||||||
|
|
@ -828,16 +802,9 @@ async def verify_chat_and_topic(
|
||||||
try:
|
try:
|
||||||
await client.get_chat(chat_id)
|
await client.get_chat(chat_id)
|
||||||
if topic_id:
|
if topic_id:
|
||||||
probe = await client.send_message(
|
await client.send_chat_action(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id, action="typing", message_thread_id=topic_id
|
||||||
text=_TOPIC_PROBE_TEXT,
|
|
||||||
message_thread_id=topic_id,
|
|
||||||
max_retries=1,
|
|
||||||
max_backoff=5.0,
|
|
||||||
)
|
)
|
||||||
probe_message_id = probe.get("message_id")
|
|
||||||
if probe_message_id:
|
|
||||||
await client.delete_message(chat_id=chat_id, message_id=probe_message_id)
|
|
||||||
except TelegramError as exc:
|
except TelegramError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"tg topic check [%s]: чат/тема недоступны для отправки "
|
"tg topic check [%s]: чат/тема недоступны для отправки "
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import LISTINGS_FRESH_DAYS, settings
|
from app.core.config import LISTINGS_FRESH_DAYS, settings
|
||||||
from app.services import regions as regions_mod
|
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||||
|
|
@ -69,29 +68,13 @@ _PPM2_MIN: int = 30_000
|
||||||
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
||||||
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
||||||
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
||||||
# ниже) — симметрично deal-стороне.
|
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
|
||||||
#
|
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
|
||||||
# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона
|
|
||||||
# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её
|
|
||||||
# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого
|
|
||||||
# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской
|
|
||||||
# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на
|
|
||||||
# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся
|
|
||||||
# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь
|
|
||||||
# несёт `region_code`, а не `district`.
|
|
||||||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||||
|
|
||||||
# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже —
|
|
||||||
# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы)
|
|
||||||
# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре
|
|
||||||
# автоматически включает его в пересчёт ratio, без правки этого файла.
|
|
||||||
_OTHER_REGION_CODES: tuple[int, ...] = tuple(
|
|
||||||
sorted(code for code in regions_mod.REGIONS if code != 66)
|
|
||||||
)
|
|
||||||
|
|
||||||
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
|
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
|
||||||
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
|
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
|
||||||
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
|
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
|
||||||
|
|
@ -125,26 +108,14 @@ def area_bucket(area_m2: float) -> int:
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ──────────
|
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
|
||||||
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
|
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
|
||||||
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
|
|
||||||
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
|
|
||||||
# только что вставленные строки другого региона на повторном прогоне того же transaction).
|
|
||||||
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
|
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
|
||||||
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
|
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
|
||||||
_DELETE_SQL = text(
|
_DELETE_SQL = text(
|
||||||
"""
|
"""
|
||||||
DELETE FROM asking_to_sold_ratios
|
DELETE FROM asking_to_sold_ratios
|
||||||
WHERE region_code = 66 AND district = ''
|
WHERE district = ''
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# #3512: та же true-mirror очистка, но per-region (используется в цикле для
|
|
||||||
# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3).
|
|
||||||
_DELETE_SQL_REGION = text(
|
|
||||||
"""
|
|
||||||
DELETE FROM asking_to_sold_ratios
|
|
||||||
WHERE region_code = CAST(:region_code AS int) AND district = ''
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -295,372 +266,19 @@ _REDERIVE_SQL = text(
|
||||||
)
|
)
|
||||||
INSERT INTO asking_to_sold_ratios (
|
INSERT INTO asking_to_sold_ratios (
|
||||||
rooms_bucket, district, ratio, sold_median, ask_median,
|
rooms_bucket, district, ratio, sold_median, ask_median,
|
||||||
n_deals, n_listings, window_months, basis, region_code
|
n_deals, n_listings, window_months, basis
|
||||||
)
|
)
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||||
n_deals, n_listings, window_months, basis, 66 FROM global_row
|
n_deals, n_listings, window_months, basis FROM global_row
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||||
n_deals, n_listings, window_months, basis, 66 FROM per_bucket
|
n_deals, n_listings, window_months, basis FROM per_bucket
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Geography-matched per-region derivation (#3529) ───────────────────────────
|
|
||||||
# ПРОБЛЕМА (#3512-путь, прод-замер 2026-09 по региону 50): обе стороны фильтровались
|
|
||||||
# ТОЛЬКО по region_code и соединялись ТОЛЬКО по бакету комнат — т.е. sold-медиана и
|
|
||||||
# ask-медиана считались по РАЗНЫМ географическим популяциям одного региона.
|
|
||||||
# Разложение обл.50 по кольцам 10 км от центра Москвы (сделки 12 мес vs активные объявления):
|
|
||||||
# 20-30 км: 12 189 сделок / 23 292 объявления → 0.808
|
|
||||||
# 30-40 км: 4 132 / 10 206 → 0.847
|
|
||||||
# 50-60 км: 1 432 / 5 243 → 0.951
|
|
||||||
# 70-80 км: 150 / 2 294 → 0.688
|
|
||||||
# ВНУТРИ колец отношение 0.69-0.95, ближние кольца (76% сделок) — 0.81-0.85, а общий пул
|
|
||||||
# давал 0.891: объявления смещены к дальней дешёвой периферии СИЛЬНЕЕ, чем сделки. Это
|
|
||||||
# перекос СОСТАВА выборки, а не свойство рынка: выкупная цена по области системно завышена.
|
|
||||||
#
|
|
||||||
# РЕШЕНИЕ: считать коэффициент на СОГЛАСОВАННОЙ географии — обе стороны раскладываются
|
|
||||||
# по одним и тем же пространственным ячейкам, медианы берутся ВНУТРИ ячейки, и в итог
|
|
||||||
# идут только ячейки, где есть ОБЕ стороны, с весами по числу СДЕЛОК. Т.е. ask-сторона
|
|
||||||
# перевзвешивается на географию сделок (индекс Ласпейреса): ratio = Σ(w·sold) / Σ(w·ask),
|
|
||||||
# w = n_deals ячейки. Строка остаётся внутренне согласованной: ratio == sold_median/ask_median,
|
|
||||||
# где оба медианных столбца — взвешенные средние ячеечных медиан с ОДНИМИ весами.
|
|
||||||
#
|
|
||||||
# ПУТЬ ЕКБ (66) НЕ ТРОГАЕМ — там своя историческая калибровка городской квотой (#C2/#2583),
|
|
||||||
# числа региона 66 обязаны остаться byte-for-byte прежними (_REDERIVE_SQL выше).
|
|
||||||
|
|
||||||
# РАЗМЕР ЯЧЕЙКИ — регулярная сетка 0.1° широты × 0.2° долготы ≈ 11 км × 12-16 км на
|
|
||||||
# широтах 45-60°N (0.2° долготы × cos(lat): 15.7 км на 45°, 12.5 км на 55.7°, 11.1 км на 60°).
|
|
||||||
# Почему именно так:
|
|
||||||
# • Масштаб взят от замера выше: именно на ~10-км разрешении отношение перестаёт
|
|
||||||
# гулять от состава (внутри кольца 0.69-0.95 вместо 0.891 по пулу), при этом ячейка
|
|
||||||
# ещё достаточно крупная, чтобы набрать десятки сделок и объявлений.
|
|
||||||
# • Сетка, а НЕ кольца от центра: кольцам нужен центр, а у региона 50 своего
|
|
||||||
# города-центра нет (его фактический центр — Москва, т.е. ДРУГОЙ регион), и каждый
|
|
||||||
# следующий регион реестра потребовал бы своего анкора и своего шага. Сетке анкор не нужен.
|
|
||||||
# • Совмещение по НАЗВАНИЮ муниципалитета НЕВОЗМОЖНО: listings.city у региона 50
|
|
||||||
# пуста (3 строки из 70 996). geom есть с обеих сторон (объявления 70 996/70 996,
|
|
||||||
# сделки 87 562/113 351) — выравниваем ПРОСТРАНСТВЕННО.
|
|
||||||
# • FLOOR по градусам — чистая арифметика по ST_X/ST_Y, без репроекций и без стыковки
|
|
||||||
# с админграницами, которых в БД нет. Точность границ ячейки здесь не важна — важно,
|
|
||||||
# что ОБЕ стороны режутся ОДИНАКОВО.
|
|
||||||
_CELL_LAT_DEG: float = 0.1
|
|
||||||
_CELL_LON_DEG: float = 0.2
|
|
||||||
|
|
||||||
# Порог НА ЯЧЕЙКУ (все комнатности вместе) — сколько нужно, чтобы ячейка считалась
|
|
||||||
# покрытой ОБЕИМИ сторонами. Ниже глобального 30/30 НАМЕРЕННО: ячеечная медиана не
|
|
||||||
# публикуется сама по себе — она входит во взвешенную сумму, а публикуемый барьер
|
|
||||||
# остаётся прежним 30/30, но уже на СУММЕ по удержанным ячейкам (HAVING ниже).
|
|
||||||
_CELL_MIN_DEALS: int = 10
|
|
||||||
_CELL_MIN_LISTINGS: int = 10
|
|
||||||
|
|
||||||
# Порог на пару (ячейка, бакет комнат) — ещё мягче: внутри уже отобранной ячейки
|
|
||||||
# комнатность дробит выборку ещё на 5 частей. Меньше 5 наблюдений на сторону — медиана
|
|
||||||
# шум, и при большом весе этот шум попадёт в итоговую строку.
|
|
||||||
_CELL_BUCKET_MIN_DEALS: int = 5
|
|
||||||
_CELL_BUCKET_MIN_LISTINGS: int = 5
|
|
||||||
|
|
||||||
# ГАРДЫ ДЕГРАДАЦИИ (пункт 6 задачи): если согласованной географии по факту нет —
|
|
||||||
# лучше НЕ писать строку вообще (эстиматор деградирует явно, без коэффициента),
|
|
||||||
# чем посчитать неверно и выглядеть уверенно.
|
|
||||||
_MIN_MATCHED_CELLS: int = 3
|
|
||||||
# Доля СДЕЛОК (с geom), попавших в пересечение ячеек. Именно сделки — целевая популяция
|
|
||||||
# (на их географию перевзвешивается ask-сторона); объявления за пределами пересечения
|
|
||||||
# отбрасываются НАМЕРЕННО (это и есть фикс), поэтому гарда на них нет — только счётчик.
|
|
||||||
_MIN_DEAL_CELL_COVERAGE: float = 0.5
|
|
||||||
# Доля строк с geom на КАЖДОЙ стороне: если большая часть стороны без координат,
|
|
||||||
# выравнивать пространственно нечего — получился бы коэффициент по неслучайному остатку.
|
|
||||||
_MIN_GEOM_COVERAGE: float = 0.5
|
|
||||||
# Сигнальный (не блокирующий) порог: выше него пишется WARNING. 0.25 выбран чуть выше
|
|
||||||
# текущего прод-состояния региона 50 (25 789/113 351 = 22.8% сделок ждут геокодера),
|
|
||||||
# чтобы лог не шумел на норме, но ухудшение было видно сразу. Доля попадает в счётчики
|
|
||||||
# ВСЕГДА, независимо от порога — строки без geom не выпадают молча (пункт 3 задачи).
|
|
||||||
_GEOM_WARN_SHARE: float = 0.25
|
|
||||||
|
|
||||||
# ОБЩИЕ ФИЛЬТРЫ сторон — один источник правды для stats- и insert-запросов (иначе счётчики
|
|
||||||
# и деривация разъехались бы при первой же правке одного из них). Состав гардов тот же,
|
|
||||||
# что у ЕКБ-деривации (12-мес окно, ppm²-полоса, свежесть #2656, novostroyki #1186,
|
|
||||||
# area_m2 IS NOT NULL #2620) — меняется ТОЛЬКО гео-согласование.
|
|
||||||
_DEAL_FROM_WHERE_REGION = """
|
|
||||||
FROM deals
|
|
||||||
WHERE source = 'rosreestr'
|
|
||||||
AND rooms IS NOT NULL
|
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
|
||||||
"""
|
|
||||||
|
|
||||||
_ASK_FROM_WHERE_REGION = """
|
|
||||||
FROM listings
|
|
||||||
WHERE is_active
|
|
||||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
|
||||||
AND rooms IS NOT NULL
|
|
||||||
AND area_m2 IS NOT NULL
|
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Ячеечные CTE — ОБЩИЕ для stats-запроса (счётчики + гард) и для самой деривации,
|
|
||||||
# чтобы решение «писать / не писать» принималось РОВНО по тем ячейкам, которые потом считаются.
|
|
||||||
_CELL_CTES_REGION = f"""
|
|
||||||
deal_geo AS (
|
|
||||||
SELECT
|
|
||||||
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
|
|
||||||
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
|
|
||||||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
|
||||||
price_per_m2
|
|
||||||
{_DEAL_FROM_WHERE_REGION} AND geom IS NOT NULL
|
|
||||||
),
|
|
||||||
ask_geo AS (
|
|
||||||
SELECT
|
|
||||||
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
|
|
||||||
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
|
|
||||||
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
|
|
||||||
price_per_m2
|
|
||||||
{_ASK_FROM_WHERE_REGION} AND geom IS NOT NULL
|
|
||||||
),
|
|
||||||
deal_cell AS (
|
|
||||||
SELECT
|
|
||||||
cell_lat, cell_lon,
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
|
||||||
COUNT(*) AS n_deals
|
|
||||||
FROM deal_geo
|
|
||||||
GROUP BY cell_lat, cell_lon
|
|
||||||
),
|
|
||||||
ask_cell AS (
|
|
||||||
SELECT
|
|
||||||
cell_lat, cell_lon,
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
|
||||||
COUNT(*) AS n_listings
|
|
||||||
FROM ask_geo
|
|
||||||
GROUP BY cell_lat, cell_lon
|
|
||||||
),
|
|
||||||
-- СОГЛАСОВАННАЯ ГЕОГРАФИЯ: ячейки, где ОБЕ стороны имеют свою массу.
|
|
||||||
matched_cell AS (
|
|
||||||
SELECT
|
|
||||||
d.cell_lat, d.cell_lon,
|
|
||||||
d.sold_median, d.n_deals,
|
|
||||||
a.ask_median, a.n_listings
|
|
||||||
FROM deal_cell d
|
|
||||||
JOIN ask_cell a USING (cell_lat, cell_lon)
|
|
||||||
WHERE d.n_deals >= {_CELL_MIN_DEALS}
|
|
||||||
AND a.n_listings >= {_CELL_MIN_LISTINGS}
|
|
||||||
AND d.sold_median IS NOT NULL AND d.sold_median > 0
|
|
||||||
AND a.ask_median IS NOT NULL AND a.ask_median > 0
|
|
||||||
)"""
|
|
||||||
|
|
||||||
# Статистика СОСТАВА выборки — считается ДО деривации и решает, писать ли регион вообще.
|
|
||||||
# Строки БЕЗ geom тоже считаются (n_all vs n_geo) — они выпадают из деривации, и это
|
|
||||||
# должно быть видно в счётчиках, а не тихо (пункт 3 задачи).
|
|
||||||
_GEO_STATS_SQL_REGION = text(
|
|
||||||
f"""
|
|
||||||
WITH{_CELL_CTES_REGION},
|
|
||||||
deal_all AS (
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS n_all,
|
|
||||||
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
|
|
||||||
{_DEAL_FROM_WHERE_REGION} ),
|
|
||||||
ask_all AS (
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS n_all,
|
|
||||||
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
|
|
||||||
{_ASK_FROM_WHERE_REGION} )
|
|
||||||
SELECT
|
|
||||||
da.n_all AS deals_total,
|
|
||||||
da.n_geo AS deals_geo,
|
|
||||||
aa.n_all AS listings_total,
|
|
||||||
aa.n_geo AS listings_geo,
|
|
||||||
(SELECT COUNT(*) FROM deal_cell) AS cells_deal,
|
|
||||||
(SELECT COUNT(*) FROM ask_cell) AS cells_ask,
|
|
||||||
(SELECT COUNT(*) FROM deal_cell d
|
|
||||||
JOIN ask_cell a USING (cell_lat, cell_lon)) AS cells_both_sides,
|
|
||||||
(SELECT COUNT(*) FROM matched_cell) AS cells_matched,
|
|
||||||
(SELECT COALESCE(SUM(n_deals), 0) FROM matched_cell) AS deals_in_cells,
|
|
||||||
(SELECT COALESCE(SUM(n_listings), 0) FROM matched_cell) AS listings_in_cells
|
|
||||||
FROM deal_all da CROSS JOIN ask_all aa
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Деривация на согласованной географии. Отличий от ЕКБ-пути (_REDERIVE_SQL) ровно два:
|
|
||||||
# 1. городская квота ЕКБ → симметричный region_code на обеих сторонах (#3512);
|
|
||||||
# 2. медианы считаются ВНУТРИ ячейки и агрегируются с весами по числу сделок (#3529).
|
|
||||||
# Окно 12 мес, ppm²-полоса, area-бакет ask-стороны, порог 30/30 на публикуемую строку — прежние.
|
|
||||||
_REDERIVE_SQL_REGION = text(
|
|
||||||
f"""
|
|
||||||
WITH{_CELL_CTES_REGION},
|
|
||||||
-- Внутри УЖЕ отобранных ячеек — разрез по бакету комнат (обе стороны — тот же набор
|
|
||||||
-- ячеек, т.е. гео-ключ есть И в фильтре, И в соединении — в отличие от старого
|
|
||||||
-- `JOIN ... USING (rooms_bucket)`, где географии в соединении не было вообще).
|
|
||||||
deal_cell_bucket AS (
|
|
||||||
SELECT
|
|
||||||
g.cell_lat, g.cell_lon, g.rooms_bucket,
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS sold_median,
|
|
||||||
COUNT(*) AS n_deals
|
|
||||||
FROM deal_geo g
|
|
||||||
JOIN matched_cell m USING (cell_lat, cell_lon)
|
|
||||||
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
|
|
||||||
),
|
|
||||||
ask_cell_bucket AS (
|
|
||||||
SELECT
|
|
||||||
g.cell_lat, g.cell_lon, g.rooms_bucket,
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS ask_median,
|
|
||||||
COUNT(*) AS n_listings
|
|
||||||
FROM ask_geo g
|
|
||||||
JOIN matched_cell m USING (cell_lat, cell_lon)
|
|
||||||
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
|
|
||||||
),
|
|
||||||
bucket_cell AS (
|
|
||||||
SELECT
|
|
||||||
d.rooms_bucket,
|
|
||||||
d.sold_median, d.n_deals,
|
|
||||||
a.ask_median, a.n_listings
|
|
||||||
FROM deal_cell_bucket d
|
|
||||||
JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)
|
|
||||||
WHERE d.n_deals >= {_CELL_BUCKET_MIN_DEALS}
|
|
||||||
AND a.n_listings >= {_CELL_BUCKET_MIN_LISTINGS}
|
|
||||||
AND d.sold_median IS NOT NULL AND d.sold_median > 0
|
|
||||||
AND a.ask_median IS NOT NULL AND a.ask_median > 0
|
|
||||||
),
|
|
||||||
-- Взвешивание по числу СДЕЛОК: ask-сторона приводится к географии сделок.
|
|
||||||
-- ratio == sold_median/ask_median построчно (оба — взвешенные средние с ОДНИМИ весами),
|
|
||||||
-- так что публикуемые столбцы остаются взаимно согласованными.
|
|
||||||
per_bucket AS (
|
|
||||||
SELECT
|
|
||||||
rooms_bucket,
|
|
||||||
''::text AS district,
|
|
||||||
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
|
|
||||||
AS numeric) AS ratio,
|
|
||||||
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
|
|
||||||
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
|
|
||||||
SUM(n_deals)::int AS n_deals,
|
|
||||||
SUM(n_listings)::int AS n_listings,
|
|
||||||
12 AS window_months,
|
|
||||||
'per_rooms'::text AS basis
|
|
||||||
FROM bucket_cell
|
|
||||||
GROUP BY rooms_bucket
|
|
||||||
-- ТОТ ЖЕ публикуемый барьер 30/30, что и раньше — теперь на сумме по ячейкам.
|
|
||||||
HAVING SUM(n_deals) >= 30
|
|
||||||
AND SUM(n_listings) >= 30
|
|
||||||
AND SUM(ask_median * n_deals) > 0
|
|
||||||
),
|
|
||||||
-- Global -1 fallback — те же ячейки, но без разреза по комнатности.
|
|
||||||
global_row AS (
|
|
||||||
SELECT
|
|
||||||
-1 AS rooms_bucket,
|
|
||||||
''::text AS district,
|
|
||||||
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
|
|
||||||
AS numeric) AS ratio,
|
|
||||||
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
|
|
||||||
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
|
|
||||||
SUM(n_deals)::int AS n_deals,
|
|
||||||
SUM(n_listings)::int AS n_listings,
|
|
||||||
12 AS window_months,
|
|
||||||
'global_fallback'::text AS basis
|
|
||||||
FROM matched_cell
|
|
||||||
HAVING SUM(n_deals) > 0
|
|
||||||
AND SUM(ask_median * n_deals) > 0
|
|
||||||
)
|
|
||||||
INSERT INTO asking_to_sold_ratios (
|
|
||||||
rooms_bucket, district, ratio, sold_median, ask_median,
|
|
||||||
n_deals, n_listings, window_months, basis, region_code
|
|
||||||
)
|
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
|
||||||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row
|
|
||||||
UNION ALL
|
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
|
||||||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# КОЛОНКА district (#647-слот) ОСТАЁТСЯ ПУСТОЙ и здесь. Идентификатор ячейки в неё не
|
|
||||||
# ложится: ячейка — ПРОМЕЖУТОЧНАЯ единица расчёта, а не единица публикации. На выходе
|
|
||||||
# по-прежнему одна строка на (регион, бакет) — агрегат по всем ячейкам; записать в
|
|
||||||
# district «какую-то одну» ячейку было бы враньём, а писать строку НА ЯЧЕЙКУ нельзя:
|
|
||||||
# потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''` и
|
|
||||||
# ключевать оценку по гео-ячейке пока не умеет — это отдельная задача #647.
|
|
||||||
|
|
||||||
|
|
||||||
def _pct(part: float, whole: float) -> int:
|
|
||||||
"""Доля part/whole в ЦЕЛЫХ процентах (счётчики scrape_runs — dict[str, int])."""
|
|
||||||
if whole <= 0:
|
|
||||||
return 0
|
|
||||||
return round(100.0 * part / whole)
|
|
||||||
|
|
||||||
|
|
||||||
def geo_region_verdict(region_code: int, stats: dict[str, int] | None) -> tuple[bool, str]:
|
|
||||||
"""Писать ли строки региона по согласованной географии (пункт 6 — явная деградация).
|
|
||||||
|
|
||||||
Возвращает (ok, reason). ok=False — регион НЕ получает НИ ОДНОЙ строки (старые всё
|
|
||||||
равно удалены), и эстиматор честно остаётся без коэффициента вместо неверного.
|
|
||||||
Чистая функция от строки stats — тестируется без базы.
|
|
||||||
"""
|
|
||||||
if not stats:
|
|
||||||
return False, "geo-stats не вернулись"
|
|
||||||
deals_total = int(stats.get("deals_total") or 0)
|
|
||||||
deals_geo = int(stats.get("deals_geo") or 0)
|
|
||||||
listings_total = int(stats.get("listings_total") or 0)
|
|
||||||
listings_geo = int(stats.get("listings_geo") or 0)
|
|
||||||
cells_matched = int(stats.get("cells_matched") or 0)
|
|
||||||
deals_in_cells = int(stats.get("deals_in_cells") or 0)
|
|
||||||
|
|
||||||
if deals_total == 0 or listings_total == 0:
|
|
||||||
return False, f"нет данных: deals={deals_total} listings={listings_total}"
|
|
||||||
if deals_geo / deals_total < _MIN_GEOM_COVERAGE:
|
|
||||||
return False, (
|
|
||||||
f"сделки без geom: {_pct(deals_total - deals_geo, deals_total)}% "
|
|
||||||
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
|
|
||||||
)
|
|
||||||
if listings_geo / listings_total < _MIN_GEOM_COVERAGE:
|
|
||||||
return False, (
|
|
||||||
f"объявления без geom: {_pct(listings_total - listings_geo, listings_total)}% "
|
|
||||||
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
|
|
||||||
)
|
|
||||||
if cells_matched < _MIN_MATCHED_CELLS:
|
|
||||||
return False, (
|
|
||||||
f"ячеек с обеими сторонами {cells_matched} < {_MIN_MATCHED_CELLS} "
|
|
||||||
f"(географии сделок и объявлений практически не пересекаются)"
|
|
||||||
)
|
|
||||||
if deals_geo > 0 and deals_in_cells / deals_geo < _MIN_DEAL_CELL_COVERAGE:
|
|
||||||
return False, (
|
|
||||||
f"в пересечение ячеек попало {_pct(deals_in_cells, deals_geo)}% сделок "
|
|
||||||
f"(порог {_MIN_DEAL_CELL_COVERAGE:.0%})"
|
|
||||||
)
|
|
||||||
_ = region_code
|
|
||||||
return True, "ok"
|
|
||||||
|
|
||||||
|
|
||||||
def _geo_region_counters(region_code: int, stats: dict[str, int] | None) -> dict[str, int]:
|
|
||||||
"""Порегионные счётчики состава выборки (пункты 3 и 5 задачи), всё — int."""
|
|
||||||
s = stats or {}
|
|
||||||
deals_total = int(s.get("deals_total") or 0)
|
|
||||||
deals_geo = int(s.get("deals_geo") or 0)
|
|
||||||
listings_total = int(s.get("listings_total") or 0)
|
|
||||||
listings_geo = int(s.get("listings_geo") or 0)
|
|
||||||
deals_in_cells = int(s.get("deals_in_cells") or 0)
|
|
||||||
listings_in_cells = int(s.get("listings_in_cells") or 0)
|
|
||||||
cells_matched = int(s.get("cells_matched") or 0)
|
|
||||||
cells_both = int(s.get("cells_both_sides") or 0)
|
|
||||||
p = f"geo_r{region_code}_"
|
|
||||||
return {
|
|
||||||
p + "cells_deal": int(s.get("cells_deal") or 0),
|
|
||||||
p + "cells_ask": int(s.get("cells_ask") or 0),
|
|
||||||
p + "cells_matched": cells_matched,
|
|
||||||
# Ячейки, где есть обе стороны, но одна из них тоньше порога ячейки.
|
|
||||||
p + "cells_dropped": max(cells_both - cells_matched, 0),
|
|
||||||
# Сколько массы осталось ЗА пределами пересечения (от строк с geom).
|
|
||||||
p + "deals_outside_pct": _pct(deals_geo - deals_in_cells, deals_geo),
|
|
||||||
p + "listings_outside_pct": _pct(listings_geo - listings_in_cells, listings_geo),
|
|
||||||
# Строки без координат — не выпадают молча (пункт 3).
|
|
||||||
p + "deals_no_geom_pct": _pct(deals_total - deals_geo, deals_total),
|
|
||||||
p + "listings_no_geom_pct": _pct(listings_total - listings_geo, listings_total),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Post-insert counters ──────────────────────────────────────────────────────
|
# ── Post-insert counters ──────────────────────────────────────────────────────
|
||||||
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
|
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
|
||||||
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
|
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
|
||||||
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
|
|
||||||
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
|
|
||||||
_COUNTERS_SQL = text(
|
_COUNTERS_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -674,22 +292,13 @@ _COUNTERS_SQL = text(
|
||||||
|
|
||||||
|
|
||||||
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
"""Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4).
|
"""Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4).
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||||
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh для уже
|
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh):
|
||||||
посчитанных регионов):
|
1. DELETE FROM asking_to_sold_ratios WHERE district = '' — снести stale-строки.
|
||||||
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') → 080-derivation
|
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
|
||||||
INSERT...SELECT (byte-for-byte прежняя логика — городская квота ЕКБ, #C2/#2583).
|
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
|
||||||
2. Каждый прочий регион реестра app.services.regions (минус 66): тот же
|
|
||||||
DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично
|
|
||||||
на sold- и asking-стороне (без городской квоты — она не применима вне
|
|
||||||
ЕКБ, см. комментарий у generic-derivation SQL ниже).
|
|
||||||
Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк
|
|
||||||
(global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) — это НЕ
|
|
||||||
ошибка, а честный признак «данных пока недостаточно», ловится потребителем
|
|
||||||
(estimator._get_asking_sold_ratio) через отсутствие строки → явная деградация.
|
|
||||||
Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done.
|
|
||||||
|
|
||||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||||
|
|
||||||
|
|
@ -711,15 +320,10 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
"rows_written": 0,
|
"rows_written": 0,
|
||||||
"per_rooms_rows": 0,
|
"per_rooms_rows": 0,
|
||||||
"used_global_fallback": 0,
|
"used_global_fallback": 0,
|
||||||
# #3529: сколько регионов посчитано по согласованной географии, а сколько
|
|
||||||
# деградировало явно (строк нет → эстиматор без коэффициента).
|
|
||||||
"geo_regions_written": 0,
|
|
||||||
"geo_regions_skipped": 0,
|
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
||||||
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
|
# таблица не должна остаться пустой, если INSERT упадёт).
|
||||||
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
|
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
db.execute(
|
db.execute(
|
||||||
_REDERIVE_SQL,
|
_REDERIVE_SQL,
|
||||||
|
|
@ -730,58 +334,6 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for region_code in _OTHER_REGION_CODES:
|
|
||||||
params = {
|
|
||||||
"region_code": region_code,
|
|
||||||
"ppm2_min": _PPM2_MIN,
|
|
||||||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
|
||||||
}
|
|
||||||
# Сначала состав выборки (#3529) — он же решает, писать ли регион вообще.
|
|
||||||
stats_row = db.execute(_GEO_STATS_SQL_REGION, params).mappings().first()
|
|
||||||
stats = dict(stats_row) if stats_row is not None else None
|
|
||||||
counters.update(_geo_region_counters(region_code, stats))
|
|
||||||
ok, reason = geo_region_verdict(region_code, stats)
|
|
||||||
|
|
||||||
# DELETE идёт В ЛЮБОМ случае: если согласованной географии больше нет, старый
|
|
||||||
# (считанный по пулу) коэффициент тем более не должен оставаться в таблице.
|
|
||||||
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
|
|
||||||
|
|
||||||
no_geom_deals = counters.get(f"geo_r{region_code}_deals_no_geom_pct", 0)
|
|
||||||
no_geom_listings = counters.get(f"geo_r{region_code}_listings_no_geom_pct", 0)
|
|
||||||
if max(no_geom_deals, no_geom_listings) >= int(_GEOM_WARN_SHARE * 100):
|
|
||||||
# Пункт 3: строки без координат не выпадают молча — это сигнал.
|
|
||||||
logger.warning(
|
|
||||||
"asking_to_sold_ratio region_code=%d: без geom сделок %d%%, "
|
|
||||||
"объявлений %d%% — гео-согласование считается по остатку",
|
|
||||||
region_code,
|
|
||||||
no_geom_deals,
|
|
||||||
no_geom_listings,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not ok:
|
|
||||||
counters["geo_regions_skipped"] += 1
|
|
||||||
counters[f"geo_r{region_code}_skipped"] = 1
|
|
||||||
logger.warning(
|
|
||||||
"asking_to_sold_ratio region_code=%d: СТРОКИ НЕ ПИШУТСЯ — %s. "
|
|
||||||
"Оценка останется без коэффициента (явная деградация)",
|
|
||||||
region_code,
|
|
||||||
reason,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
counters[f"geo_r{region_code}_skipped"] = 0
|
|
||||||
counters["geo_regions_written"] += 1
|
|
||||||
db.execute(_REDERIVE_SQL_REGION, params)
|
|
||||||
logger.info(
|
|
||||||
"asking_to_sold_ratio region_code=%d: ячеек с обеими сторонами %d "
|
|
||||||
"(отброшено по порогу %d), вне пересечения: сделок %d%%, объявлений %d%%",
|
|
||||||
region_code,
|
|
||||||
counters.get(f"geo_r{region_code}_cells_matched", 0),
|
|
||||||
counters.get(f"geo_r{region_code}_cells_dropped", 0),
|
|
||||||
counters.get(f"geo_r{region_code}_deals_outside_pct", 0),
|
|
||||||
counters.get(f"geo_r{region_code}_listings_outside_pct", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
|
|
|
||||||
|
|
@ -488,39 +488,12 @@ def _build_confirmations_sql(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Регионы, где снятие устаревших вообще осмысленно ──────────────────────────
|
|
||||||
# Снятие читает МОЛЧАНИЕ как «объявление ушло»: строку давно не видели свежей ->
|
|
||||||
# is_active=false. Такой вывод верен ТОЛЬКО там, где есть механизм пересбора: без
|
|
||||||
# регулярного обхода «давно не видели» неотличимо от «мы туда ни разу не ходили
|
|
||||||
# после разовой загрузки», и джоба снимет разом весь загруженный корпус региона.
|
|
||||||
#
|
|
||||||
# На 15.09.2026 регулярный сбор идёт только по Свердловской области (66): реестр
|
|
||||||
# якорей CITY_ANCHORS в ките покрывает её одну, у ДомКлика гео-скоуп зашит в ЕКБ.
|
|
||||||
# Объявления регионов 77 и 50 попали в listings разовой ручной загрузкой 10-12.09
|
|
||||||
# и с тех пор не обновлялись. Без фильтра ниже около 26.09 кандидатами разом стали
|
|
||||||
# бы 40 410 строк ДомКлика (ttl_days=14 по scraped_at), следом 60 174 cian и
|
|
||||||
# 24 152 yandex (ttl_days=30). Пострадала бы не только Москва с областью:
|
|
||||||
# аварийный потолок max_deactivated (DEFAULT_MAX_DEACTIVATED=15000, см. ниже)
|
|
||||||
# сработал бы раньше, а прогон при skipped_cap_exceeded НЕ ТРОГАЕТ НИ ОДНОЙ
|
|
||||||
# строки -- то есть чистка Екатеринбурга встала бы вместе с московской.
|
|
||||||
#
|
|
||||||
# Отсюда дефолт «снимаем только там, где сами собираем». Расширять список нужно
|
|
||||||
# ВМЕСТЕ с появлением расписания регулярного сбора по региону (якоря кита +
|
|
||||||
# гео-скоуп ДомКлика), а не раньше: пока пересбора нет, нечем отличить ушедшее
|
|
||||||
# объявление от неопрошенного. Переопределяется ключом "region_codes" в
|
|
||||||
# default_params строки расписания (jsonb-массив целых кодов регионов).
|
|
||||||
DEFAULT_DEACTIVATION_REGION_CODES: tuple[int, ...] = (66,)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_all_segments_sql(staleness_column: str) -> Any:
|
def _build_all_segments_sql(staleness_column: str) -> Any:
|
||||||
"""UPDATE без фильтра по сегменту: все сегменты для данного source.
|
"""UPDATE без фильтра по сегменту: все сегменты для данного source.
|
||||||
|
|
||||||
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
|
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
|
||||||
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
|
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
|
||||||
:region_codes, :ttl_days, :run_id) остаются param-binding — psycopg v3 safe
|
:ttl_days, :run_id) остаются param-binding — psycopg v3 safe (никаких :param::type).
|
||||||
(никаких :param::type). :region_codes -- см. DEFAULT_DEACTIVATION_REGION_CODES:
|
|
||||||
снятие идёт только по регионам с регулярным пересбором. NULL region_code не
|
|
||||||
матчится ANY(...) намеренно -- неизвестный регион нечем отнести к покрытому.
|
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -528,7 +501,6 @@ def _build_all_segments_sql(staleness_column: str) -> Any:
|
||||||
UPDATE listings
|
UPDATE listings
|
||||||
SET is_active = false
|
SET is_active = false
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
RETURNING id, price_rub
|
RETURNING id, price_rub
|
||||||
|
|
@ -542,8 +514,7 @@ def _build_segments_sql(staleness_column: str) -> Any:
|
||||||
"""UPDATE с фильтром по сегменту (segments задан): только указанные сегменты.
|
"""UPDATE с фильтром по сегменту (segments задан): только указанные сегменты.
|
||||||
|
|
||||||
staleness_column уже прошёл whitelist-проверку. = ANY(CAST(:segments AS text[]))
|
staleness_column уже прошёл whitelist-проверку. = ANY(CAST(:segments AS text[]))
|
||||||
-- psycopg v3 адаптирует Python list -> text[]; то же для :region_codes -> int[]
|
-- psycopg v3 адаптирует Python list -> text[].
|
||||||
(см. DEFAULT_DEACTIVATION_REGION_CODES).
|
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -551,7 +522,6 @@ def _build_segments_sql(staleness_column: str) -> Any:
|
||||||
UPDATE listings
|
UPDATE listings
|
||||||
SET is_active = false
|
SET is_active = false
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
AND listing_segment = ANY(CAST(:segments AS text[]))
|
AND listing_segment = ANY(CAST(:segments AS text[]))
|
||||||
|
|
@ -571,8 +541,7 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
|
||||||
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
|
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
|
||||||
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
|
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
|
||||||
|
|
||||||
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе,
|
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе.
|
||||||
но с :region_codes (см. DEFAULT_DEACTIVATION_REGION_CODES).
|
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -580,7 +549,6 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
|
||||||
UPDATE listings
|
UPDATE listings
|
||||||
SET is_active = false
|
SET is_active = false
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
AND listing_segment IS NULL
|
AND listing_segment IS NULL
|
||||||
|
|
@ -671,16 +639,13 @@ def _build_all_segments_candidates_count_sql(staleness_column: str) -> Any:
|
||||||
_build_all_segments_sql: рефакторинг уже протестированных UPDATE-builder'ов
|
_build_all_segments_sql: рефакторинг уже протестированных UPDATE-builder'ов
|
||||||
вне скоупа PR-B. Синхронность с UPDATE закреплена тестом
|
вне скоупа PR-B. Синхронность с UPDATE закреплена тестом
|
||||||
test_candidates_predicate_matches_update_predicate
|
test_candidates_predicate_matches_update_predicate
|
||||||
(tests/test_deactivate_stale_deactivation_cap.py). Фильтр по :region_codes
|
(tests/test_deactivate_stale_deactivation_cap.py).
|
||||||
обязан стоять и здесь: считать кандидатов по всем регионам, а снимать по
|
|
||||||
покрытым сбором -- значит кормить аварийный потолок чужими числами.
|
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
"""
|
"""
|
||||||
|
|
@ -696,7 +661,6 @@ def _build_segments_candidates_count_sql(staleness_column: str) -> Any:
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
AND listing_segment = ANY(CAST(:segments AS text[]))
|
AND listing_segment = ANY(CAST(:segments AS text[]))
|
||||||
|
|
@ -713,7 +677,6 @@ def _build_null_segment_candidates_count_sql(staleness_column: str) -> Any:
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
AND listing_segment IS NULL
|
AND listing_segment IS NULL
|
||||||
|
|
@ -728,7 +691,6 @@ def deactivate_stale_listings(
|
||||||
listing_source: str,
|
listing_source: str,
|
||||||
ttl_days: int,
|
ttl_days: int,
|
||||||
segments: list[str] | None = None,
|
segments: list[str] | None = None,
|
||||||
region_codes: list[int] | None = None,
|
|
||||||
staleness_column: str = "last_seen_at",
|
staleness_column: str = "last_seen_at",
|
||||||
min_confirmations: int = 0,
|
min_confirmations: int = 0,
|
||||||
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
||||||
|
|
@ -748,24 +710,6 @@ def deactivate_stale_listings(
|
||||||
segments: если задан -- деактивировать только объявления с указанными
|
segments: если задан -- деактивировать только объявления с указанными
|
||||||
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
|
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
|
||||||
Несовместимо с null_segment_only=True (см. ниже).
|
Несовместимо с null_segment_only=True (см. ниже).
|
||||||
region_codes: регионы (listings.region_code), в которых снятие вообще
|
|
||||||
разрешено. None -> DEFAULT_DEACTIVATION_REGION_CODES = (66,), то есть
|
|
||||||
только Свердловская область. ЭТО НЕ КОСМЕТИЧЕСКИЙ ФИЛЬТР: снятие
|
|
||||||
трактует молчание как «объявление ушло», а это верно лишь там, где
|
|
||||||
есть механизм пересбора -- без регулярного обхода «давно не видели»
|
|
||||||
неотличимо от «мы туда не ходили с разовой загрузки» (см. подробный
|
|
||||||
комментарий у DEFAULT_DEACTIVATION_REGION_CODES: что именно случилось
|
|
||||||
бы с корпусом 77/50 и почему вместе с ним встала бы чистка ЕКБ).
|
|
||||||
Расширять список нужно ВМЕСТЕ с запуском регулярного сбора по региону,
|
|
||||||
через ключ "region_codes" в default_params строки расписания. None
|
|
||||||
означает дефолт, а НЕ «без фильтра»: отключить региональное
|
|
||||||
ограничение целиком нельзя намеренно -- только перечислить регионы
|
|
||||||
явно. Строки с region_code IS NULL не снимаются никогда (ANY(...) не
|
|
||||||
матчит NULL): неизвестный регион нечем отнести к покрытому сбором.
|
|
||||||
Гейты здоровья/пола (min_confirmations, revisit_floor_quantile) и
|
|
||||||
active_pool ОСТАЮТСЯ общими по источнику, без региона -- их пороги
|
|
||||||
откалиброваны на источнике целиком, региональная нарезка потребовала
|
|
||||||
бы пересчёта калибровки и здесь не делается.
|
|
||||||
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
|
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
|
||||||
{"last_seen_at", "scraped_at"} — иначе ValueError ДО любого SQL. Дефолт
|
{"last_seen_at", "scraped_at"} — иначе ValueError ДО любого SQL. Дефолт
|
||||||
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
|
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
|
||||||
|
|
@ -871,11 +815,7 @@ def deactivate_stale_listings(
|
||||||
min_floor_pairs < 0 / floor_drop_ratio < 1 / max_deactivated <= 0, ИЛИ
|
min_floor_pairs < 0 / floor_drop_ratio < 1 / max_deactivated <= 0, ИЛИ
|
||||||
любой из этих трёх -- bool (тот же класс jsonb-опечатки true/false
|
любой из этих трёх -- bool (тот же класс jsonb-опечатки true/false
|
||||||
вместо числа, что и у ttl_days/cap_mult выше -- default_params
|
вместо числа, что и у ttl_days/cap_mult выше -- default_params
|
||||||
расписания это единственный запланированный способ их переопределить),
|
расписания это единственный запланированный способ их переопределить).
|
||||||
ЛИБО region_codes -- пустой список (= ANY(ARRAY[]) не матчит ничего,
|
|
||||||
прогон молча снял бы 0 и выглядел бы здоровым) или содержит не-int /
|
|
||||||
bool (та же jsonb-опечатка, что выше: "region_codes": [true] прошло бы
|
|
||||||
как [1] -- регион 1 существует).
|
|
||||||
"""
|
"""
|
||||||
counters: dict[str, int] = {"deactivated": 0}
|
counters: dict[str, int] = {"deactivated": 0}
|
||||||
try:
|
try:
|
||||||
|
|
@ -910,7 +850,9 @@ def deactivate_stale_listings(
|
||||||
raise ValueError(f"min_floor_pairs must be >= 0, got {min_floor_pairs!r}")
|
raise ValueError(f"min_floor_pairs must be >= 0, got {min_floor_pairs!r}")
|
||||||
|
|
||||||
if isinstance(floor_drop_ratio, bool):
|
if isinstance(floor_drop_ratio, bool):
|
||||||
raise ValueError(f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}")
|
raise ValueError(
|
||||||
|
f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}"
|
||||||
|
)
|
||||||
if floor_drop_ratio < 1:
|
if floor_drop_ratio < 1:
|
||||||
raise ValueError(f"floor_drop_ratio must be >= 1, got {floor_drop_ratio!r}")
|
raise ValueError(f"floor_drop_ratio must be >= 1, got {floor_drop_ratio!r}")
|
||||||
|
|
||||||
|
|
@ -934,21 +876,6 @@ def deactivate_stale_listings(
|
||||||
if null_segment_only and segments is not None:
|
if null_segment_only and segments is not None:
|
||||||
raise ValueError("null_segment_only=True несовместимо с заданным segments")
|
raise ValueError("null_segment_only=True несовместимо с заданным segments")
|
||||||
|
|
||||||
# Регионы снятия (см. DEFAULT_DEACTIVATION_REGION_CODES): None -> дефолт,
|
|
||||||
# а НЕ «без фильтра» -- снимать можно только там, где идёт пересбор.
|
|
||||||
# Пустой список отвергаем явно: он матчил бы 0 строк, и прогон выглядел бы
|
|
||||||
# здоровым (deactivated=0) вместо того, чтобы упасть на очевидной ошибке
|
|
||||||
# конфигурации. bool отсекаем до isinstance(int), как у ttl_days/cap_mult
|
|
||||||
# выше: jsonb `true` прошёл бы как регион 1.
|
|
||||||
effective_region_codes: list[int] = (
|
|
||||||
list(DEFAULT_DEACTIVATION_REGION_CODES) if region_codes is None else list(region_codes)
|
|
||||||
)
|
|
||||||
if not effective_region_codes:
|
|
||||||
raise ValueError("region_codes must be a non-empty list, got empty")
|
|
||||||
for code in effective_region_codes:
|
|
||||||
if isinstance(code, bool) or not isinstance(code, int):
|
|
||||||
raise ValueError(f"region_codes must contain ints, got {code!r}")
|
|
||||||
|
|
||||||
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
|
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
|
||||||
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
||||||
# проверяем ПЕРЕД записью, а не откатываем после.
|
# проверяем ПЕРЕД записью, а не откатываем после.
|
||||||
|
|
@ -1133,9 +1060,6 @@ def deactivate_stale_listings(
|
||||||
preflight_params: dict[str, Any] = {
|
preflight_params: dict[str, Any] = {
|
||||||
"listing_source": listing_source,
|
"listing_source": listing_source,
|
||||||
"ttl_days": effective_ttl_days,
|
"ttl_days": effective_ttl_days,
|
||||||
# Тот же срез регионов, что и у UPDATE ниже -- иначе потолок
|
|
||||||
# считал бы кандидатов по регионам, которых снятие не коснётся.
|
|
||||||
"region_codes": effective_region_codes,
|
|
||||||
}
|
}
|
||||||
if null_segment_only:
|
if null_segment_only:
|
||||||
candidates_sql = _build_null_segment_candidates_count_sql(staleness_column)
|
candidates_sql = _build_null_segment_candidates_count_sql(staleness_column)
|
||||||
|
|
@ -1184,7 +1108,6 @@ def deactivate_stale_listings(
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"listing_source": listing_source,
|
"listing_source": listing_source,
|
||||||
"ttl_days": effective_ttl_days,
|
"ttl_days": effective_ttl_days,
|
||||||
"region_codes": effective_region_codes,
|
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
}
|
}
|
||||||
result = db.execute(_build_null_segment_sql(staleness_column), params)
|
result = db.execute(_build_null_segment_sql(staleness_column), params)
|
||||||
|
|
@ -1192,7 +1115,6 @@ def deactivate_stale_listings(
|
||||||
params = {
|
params = {
|
||||||
"listing_source": listing_source,
|
"listing_source": listing_source,
|
||||||
"ttl_days": effective_ttl_days,
|
"ttl_days": effective_ttl_days,
|
||||||
"region_codes": effective_region_codes,
|
|
||||||
"segments": segments,
|
"segments": segments,
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
}
|
}
|
||||||
|
|
@ -1201,7 +1123,6 @@ def deactivate_stale_listings(
|
||||||
params = {
|
params = {
|
||||||
"listing_source": listing_source,
|
"listing_source": listing_source,
|
||||||
"ttl_days": effective_ttl_days,
|
"ttl_days": effective_ttl_days,
|
||||||
"region_codes": effective_region_codes,
|
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
}
|
}
|
||||||
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
||||||
|
|
@ -1212,15 +1133,14 @@ def deactivate_stale_listings(
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
|
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
|
||||||
"(ttl_days=%d эффективный, задан %d, segments=%r, region_codes=%r, "
|
"(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, "
|
||||||
"null_segment_only=%s, staleness_column=%s)",
|
"staleness_column=%s)",
|
||||||
listing_source,
|
listing_source,
|
||||||
run_id,
|
run_id,
|
||||||
counters["deactivated"],
|
counters["deactivated"],
|
||||||
effective_ttl_days,
|
effective_ttl_days,
|
||||||
ttl_days,
|
ttl_days,
|
||||||
segments,
|
segments,
|
||||||
effective_region_codes,
|
|
||||||
null_segment_only,
|
null_segment_only,
|
||||||
staleness_column,
|
staleness_column,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
Запуск (каталог — УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
|
Запуск (каталог — УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
|
||||||
|
|
||||||
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
|
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
|
||||||
python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11
|
|
||||||
python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11
|
|
||||||
|
|
||||||
Делает два шага в одной транзакционной сессии:
|
Делает два шага в одной транзакционной сессии:
|
||||||
1. load_gar_region — стриминговый парс XML → UPSERT gar_house_flats (коммит).
|
1. load_gar_region — стриминговый парс XML → UPSERT gar_house_flats (коммит).
|
||||||
|
|
@ -13,19 +11,11 @@
|
||||||
Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена):
|
Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена):
|
||||||
|
|
||||||
python -m app.tasks.gar_flats_load --match-only --region 66
|
python -m app.tasks.gar_flats_load --match-only --region 66
|
||||||
python -m app.tasks.gar_flats_load --match-only --region 77
|
|
||||||
python -m app.tasks.gar_flats_load --match-only --region 50
|
|
||||||
|
|
||||||
В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется.
|
В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется.
|
||||||
|
|
||||||
Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
|
Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
|
||||||
docs/gar-flats-runbook.md) — этот лоадер потребляет уже распакованные XML локально.
|
docs/gar-flats-runbook.md) — этот лоадер потребляет уже распакованные XML локально.
|
||||||
|
|
||||||
Город-фильтр матча (--city) по умолчанию НЕ вводится руками на каждый запуск — берётся
|
|
||||||
ПО РЕГИОНУ (см. app.services.gar_flats_loader.default_city_filter_for_region): region 66
|
|
||||||
получает byte-for-byte прежний фильтр «Екатеринбург», остальные регионы (77, 50 и любой
|
|
||||||
новый) — без фильтра. `--city ""` явно отключает фильтр для ЛЮБОГО региона (в т.ч. 66);
|
|
||||||
`--city "Имя"` — явный override.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -35,23 +25,21 @@ import logging
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.services.gar_flats_loader import (
|
from app.services.gar_flats_loader import load_gar_region, match_houses_to_gar
|
||||||
CITY_FILTER_AUTO,
|
|
||||||
CityFilterAutoType,
|
|
||||||
default_city_filter_for_region,
|
|
||||||
load_gar_region,
|
|
||||||
match_houses_to_gar,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Город-фильтр матча по умолчанию (см. match_houses_to_gar): ЕКБ-restricted против
|
||||||
|
# cross-town коллизий в region 66. Пустая строка в --city → None (фильтр отключён).
|
||||||
|
DEFAULT_CITY_FILTER = "Екатеринбург"
|
||||||
|
|
||||||
|
|
||||||
def run_gar_flats_load(
|
def run_gar_flats_load(
|
||||||
dir_path: str,
|
dir_path: str,
|
||||||
region_code: str,
|
region_code: str,
|
||||||
gar_version: str,
|
gar_version: str,
|
||||||
*,
|
*,
|
||||||
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
|
city_filter: str | None = DEFAULT_CITY_FILTER,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
|
"""Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
@ -79,7 +67,7 @@ def run_gar_flats_load(
|
||||||
|
|
||||||
|
|
||||||
def run_gar_match_only(
|
def run_gar_match_only(
|
||||||
region_code: str, *, city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO
|
region_code: str, *, city_filter: str | None = DEFAULT_CITY_FILTER
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Только ре-матч уже загруженного gar_house_flats → houses (без парса XML).
|
"""Только ре-матч уже загруженного gar_house_flats → houses (без парса XML).
|
||||||
|
|
||||||
|
|
@ -120,12 +108,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--city",
|
"--city",
|
||||||
default=None,
|
default=DEFAULT_CITY_FILTER,
|
||||||
help=(
|
help="город-фильтр матча (ILIKE по full_address); пусто = без ограничения (не-ЕКБ)",
|
||||||
"город-фильтр матча (ILIKE по full_address); по умолчанию берётся ПО РЕГИОНУ "
|
|
||||||
"(--region) — см. default_city_filter_for_region; пустая строка явно отключает "
|
|
||||||
"фильтр для ЛЮБОГО региона"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
@ -138,12 +122,7 @@ def main() -> None:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# --city не передан явно (argparse default=None) → дефолт ПО РЕГИОНУ (66 → «Екатеринбург»
|
# Пустой --city → None (фильтр отключён).
|
||||||
# byte-for-byte как раньше, остальные — без фильтра). Передан явно (в т.ч. "") →
|
|
||||||
# уважаем волю вызывающего: "" → None (фильтр отключён), непустая строка → override.
|
|
||||||
if args.city is None:
|
|
||||||
city_filter: str | None = default_city_filter_for_region(args.region)
|
|
||||||
else:
|
|
||||||
city_filter = args.city or None
|
city_filter = args.city or None
|
||||||
|
|
||||||
if args.match_only:
|
if args.match_only:
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,22 @@
|
||||||
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
|
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
|
||||||
|
|
||||||
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
|
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
|
||||||
несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
|
несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
|
||||||
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
|
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
|
||||||
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
|
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
|
||||||
|
|
||||||
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
|
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато
|
||||||
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
|
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
|
||||||
опознаётся — байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
|
опознаётся. Замер по проду (60 464 карточки): с округом — 35 551, ВСЕ внутри
|
||||||
— 35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox — 17 576 (это
|
bbox региона 77; без округа внутри bbox — 17 576 (это Московская область, регион
|
||||||
Московская область, регион 50); без округа вне bbox — 7 337. Отдельно 212
|
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox —
|
||||||
карточек с адресом вида «Екатеринбург (Cian)» — артефакт парсера, считаются
|
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» — артефакт
|
||||||
своим счётчиком, чтобы не растворяться в «не целевой регион».
|
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
|
||||||
|
|
||||||
Область (регион 50) у Циана в адресе НЕ видна вовсе — берём по ПОДДОМЕНУ
|
|
||||||
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` ⇒ область. Замер по
|
|
||||||
`msk_raw.cian_latest` 12.09.2026: `www` — 38 030 карточек, из них 36 569 с
|
|
||||||
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
|
|
||||||
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
|
|
||||||
mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с префиксом округа,
|
|
||||||
итого не-www ≈ 24 784. Поддомен и префикс округа нигде не противоречат друг
|
|
||||||
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область —
|
|
||||||
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`) →
|
|
||||||
карточка НЕ область (консервативно, счётчик «не целевой регион»).
|
|
||||||
|
|
||||||
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес — голая улица с
|
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес — голая улица с
|
||||||
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
|
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
|
||||||
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
|
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
|
||||||
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр —
|
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр.
|
||||||
для ЛЮБОГО целевого региона.
|
|
||||||
|
|
||||||
Два сигнала, и оба нужны — ни один по отдельности не годится.
|
Два сигнала, и оба нужны — ни один по отдельности не годится.
|
||||||
|
|
||||||
|
|
@ -54,16 +42,16 @@ mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с пр
|
||||||
qc_geo=0 у 97% найденных.
|
qc_geo=0 у 97% найденных.
|
||||||
|
|
||||||
Что куда едет:
|
Что куда едет:
|
||||||
* регион == `--region` → в `listings`, С координатами (geom есть сразу,
|
* регион 77 → в `listings`, С координатами (geom есть сразу, radius-подбор
|
||||||
radius-подбор аналогов работает без ожидания `geocode_missing`);
|
аналогов работает без ожидания `geocode_missing`);
|
||||||
* адрес разрешился, но регион ответа не совпал с `--region` → не пишется,
|
* регион 50 → НЕ пишется, ждёт появления региона 50 в реестре; лежит не в
|
||||||
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` — прогон с другим
|
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) — когда
|
||||||
`--region` подхватит её из кэша без единого внешнего вызова;
|
регион появится, прогон по этой полке уже не потребует внешних вызовов;
|
||||||
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») →
|
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») →
|
||||||
свой счётчик, карточка не пишется.
|
свой счётчик, карточка не пишется.
|
||||||
|
|
||||||
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
|
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
|
||||||
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
||||||
|
|
||||||
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
|
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
|
||||||
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
|
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
|
||||||
|
|
@ -83,43 +71,28 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
|
||||||
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
|
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
|
||||||
одинаковы.
|
одинаковы.
|
||||||
|
|
||||||
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
|
Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и
|
||||||
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
|
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
|
||||||
вторым компонентом. Замер по 21 393 карточкам первого прохода — ровно два
|
вторым компонентом. Замер по 21 393 карточкам первого прохода — ровно два
|
||||||
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
|
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
|
||||||
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
|
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
|
||||||
|
|
||||||
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
|
Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине:
|
||||||
РАЗНЫХ прогона сборщика с разными GUID: московский (батч
|
сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне
|
||||||
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
|
сборщика, поэтому областных карточек в нём нет по построению. Регион читается
|
||||||
`mo-serp-domclick-20260912`) — оба пишут в один и тот же
|
ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр
|
||||||
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
|
оставлен второй линией — на случай, если в ту же таблицу когда-нибудь приедет
|
||||||
`msk_raw.domclick_latest` отдаёт обе партии вперемешку — курсор по `id` не
|
второй город. Замер по 5 024 карточкам первого прохода: единственное значение
|
||||||
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
|
первого компонента — «Москва». Новая Москва приходит как «Москва, пос. Птичное,
|
||||||
опциональная вторая линия, а единственный способ развести регионы.
|
…», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты
|
||||||
|
заполнены у 100% карточек.
|
||||||
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
|
|
||||||
12.09.2026: московские карточки — «Москва, Генерала Дорохова проспект, 49» и
|
|
||||||
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
|
|
||||||
областные карточки — «Московская область, Химки, 7-й м-н, проспект
|
|
||||||
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
|
||||||
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
|
|
||||||
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
|
|
||||||
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
|
|
||||||
Москва приходит как «Москва, пос. Птичное, …» — посёлок стоит вторым
|
|
||||||
компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты
|
|
||||||
заполнены у 100% карточек в обоих прогонах.
|
|
||||||
|
|
||||||
Запуск:
|
Запуск:
|
||||||
python -m app.tasks.msk_raw_import --dry-run
|
python -m app.tasks.msk_raw_import --dry-run
|
||||||
python -m app.tasks.msk_raw_import --limit 500
|
python -m app.tasks.msk_raw_import --limit 500
|
||||||
python -m app.tasks.msk_raw_import --source yandex
|
python -m app.tasks.msk_raw_import --source yandex
|
||||||
python -m app.tasks.msk_raw_import --source yandex --region 50
|
|
||||||
python -m app.tasks.msk_raw_import --source cian --region 50
|
|
||||||
python -m app.tasks.msk_raw_import --source domclick
|
python -m app.tasks.msk_raw_import --source domclick
|
||||||
python -m app.tasks.msk_raw_import --source domclick --region 50
|
|
||||||
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
|
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
|
||||||
python -m app.tasks.msk_raw_import --source avito --geocode --region 50
|
|
||||||
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
|
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -129,7 +102,6 @@ import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
@ -141,18 +113,15 @@ from sqlalchemy.orm import Session
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.services import dadata
|
from app.services import dadata
|
||||||
from app.services.geocoder import normalize_address
|
from app.services.geocoder import normalize_address
|
||||||
from app.services.regions import REGIONS, is_within_bbox
|
|
||||||
from app.services.scraper_adapters import RealMatcherAdapter
|
from app.services.scraper_adapters import RealMatcherAdapter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MOSCOW_REGION_CODE = 77
|
MOSCOW_REGION_CODE = 77
|
||||||
MOSCOW_CITY = "Москва"
|
MOSCOW_CITY = "Москва"
|
||||||
# Московская область в реестре `app.services.regions` заведена, но своего
|
# Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён —
|
||||||
# единого города у неё нет (`canonical_city is None`) — в listings.city для
|
# карточки области не пишутся, а откладываются (см. докстринг модуля).
|
||||||
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
|
|
||||||
OBLAST_REGION_CODE = 50
|
OBLAST_REGION_CODE = 50
|
||||||
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
|
|
||||||
DEFAULT_BATCH_SIZE = 500
|
DEFAULT_BATCH_SIZE = 500
|
||||||
|
|
||||||
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
|
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
|
||||||
|
|
@ -458,16 +427,14 @@ class ImportCounters:
|
||||||
|
|
||||||
read: int = 0
|
read: int = 0
|
||||||
skipped_artifact: int = 0
|
skipped_artifact: int = 0
|
||||||
# Карточка сама говорит про другой регион (префикс округа / поддомен
|
skipped_not_moscow: int = 0
|
||||||
# Циана / компонент адреса не совпал с целевым `--region`).
|
|
||||||
skipped_not_target_region: int = 0
|
|
||||||
skipped_invalid: int = 0
|
skipped_invalid: int = 0
|
||||||
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
|
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область —
|
||||||
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
|
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не
|
||||||
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
|
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск
|
||||||
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
|
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше
|
||||||
# не «в целевом регионе стало меньше домов».
|
# домов».
|
||||||
skipped_geo_other_region: int = 0
|
skipped_oblast: int = 0
|
||||||
skipped_ungeocoded: int = 0
|
skipped_ungeocoded: int = 0
|
||||||
selected: int = 0
|
selected: int = 0
|
||||||
inserted: int = 0
|
inserted: int = 0
|
||||||
|
|
@ -494,8 +461,8 @@ class ImportCounters:
|
||||||
self.read
|
self.read
|
||||||
== self.selected
|
== self.selected
|
||||||
+ self.skipped_artifact
|
+ self.skipped_artifact
|
||||||
+ self.skipped_not_target_region
|
+ self.skipped_not_moscow
|
||||||
+ self.skipped_geo_other_region
|
+ self.skipped_oblast
|
||||||
+ self.skipped_ungeocoded
|
+ self.skipped_ungeocoded
|
||||||
+ self.skipped_invalid
|
+ self.skipped_invalid
|
||||||
)
|
)
|
||||||
|
|
@ -513,37 +480,6 @@ def is_moscow_address(address: str | None) -> bool:
|
||||||
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
|
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
def cian_subdomain(payload: dict) -> str | None:
|
|
||||||
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
|
|
||||||
|
|
||||||
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
|
|
||||||
область читается из URL. Поддомен и префикс округа не противоречат друг
|
|
||||||
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
|
|
||||||
`www` — 38 030 карточек (36 569 с префиксом округа, это Москва); все
|
|
||||||
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
|
|
||||||
khimki, mytishchi, podolsk, odintsovo, …) — 0 карточек с префиксом округа.
|
|
||||||
Хост не `*.cian.ru` или `source_url` отсутствует → None (консервативно:
|
|
||||||
региону не сопоставляем).
|
|
||||||
"""
|
|
||||||
url = payload.get("source_url")
|
|
||||||
if not isinstance(url, str) or not url:
|
|
||||||
return None
|
|
||||||
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
|
|
||||||
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
|
|
||||||
# суффикса и уехал в «не целевой регион».
|
|
||||||
host = urlsplit(url).hostname or ""
|
|
||||||
if not host.endswith(".cian.ru"):
|
|
||||||
return None
|
|
||||||
sub = host[: -len(".cian.ru")]
|
|
||||||
return sub or None
|
|
||||||
|
|
||||||
|
|
||||||
def is_cian_oblast_payload(payload: dict) -> bool:
|
|
||||||
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
|
|
||||||
sub = cian_subdomain(payload)
|
|
||||||
return sub is not None and sub != "www"
|
|
||||||
|
|
||||||
|
|
||||||
def is_moscow_yandex_address(address: str | None) -> bool:
|
def is_moscow_yandex_address(address: str | None) -> bool:
|
||||||
"""У Яндекса регион — второй компонент полного адреса.
|
"""У Яндекса регион — второй компонент полного адреса.
|
||||||
|
|
||||||
|
|
@ -561,17 +497,6 @@ def is_moscow_yandex_address(address: str | None) -> bool:
|
||||||
return len(parts) > 1 and parts[1] == "Москва"
|
return len(parts) > 1 and parts[1] == "Москва"
|
||||||
|
|
||||||
|
|
||||||
def is_oblast_yandex_address(address: str | None) -> bool:
|
|
||||||
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
|
|
||||||
|
|
||||||
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
|
|
||||||
ровно два значения (см. докстринг модуля), третьего нет — среди карточек
|
|
||||||
Яндекса «не Москва» и означает «область».
|
|
||||||
"""
|
|
||||||
parts = [part.strip() for part in (address or "").split(",")]
|
|
||||||
return len(parts) > 1 and parts[1] == "Московская область"
|
|
||||||
|
|
||||||
|
|
||||||
def is_moscow_domclick_address(address: str | None) -> bool:
|
def is_moscow_domclick_address(address: str | None) -> bool:
|
||||||
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
|
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
|
||||||
|
|
||||||
|
|
@ -590,70 +515,12 @@ def is_moscow_domclick_address(address: str | None) -> bool:
|
||||||
return bool(parts) and parts[0] == "Москва"
|
return bool(parts) and parts[0] == "Москва"
|
||||||
|
|
||||||
|
|
||||||
def is_oblast_domclick_address(address: str | None) -> bool:
|
# Источники, у которых город виден в самой карточке. Ключ отсутствует —
|
||||||
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
|
# источник про город молчит, и без пред-геокода писать его нельзя (avito).
|
||||||
|
CITY_FILTERS = {
|
||||||
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
|
"cian": is_moscow_address,
|
||||||
копит ДВА разных прогона сборщика (московский батч
|
"yandex": is_moscow_yandex_address,
|
||||||
`msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без
|
"domclick": is_moscow_domclick_address,
|
||||||
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
|
|
||||||
вперемешку — фильтр по адресу обязателен, не опционален. Замер живьём на
|
|
||||||
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
|
|
||||||
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
|
||||||
Звенигород, 3-й м-н, 28» и подобные — первый компонент «Московская
|
|
||||||
область» у всех 140 карточек выборки с семи разных смещений выдачи.
|
|
||||||
"""
|
|
||||||
parts = [part.strip() for part in (address or "").split(",")]
|
|
||||||
return bool(parts) and parts[0] == "Московская область"
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_point(payload: dict) -> tuple[float, float] | None:
|
|
||||||
"""(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются."""
|
|
||||||
try:
|
|
||||||
return float(payload["lat"]), float(payload["lon"])
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_oblast_domclick_payload(payload: dict) -> bool:
|
|
||||||
"""Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области.
|
|
||||||
|
|
||||||
Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка
|
|
||||||
областного батча): «Московская область» стоит первым компонентом у 2 960, а
|
|
||||||
у одной — «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293.
|
|
||||||
Это настоящая область (Можайск), и строгий префикс выбросил бы её молча.
|
|
||||||
Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ
|
|
||||||
22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox
|
|
||||||
Москвы (вложенный в областной) не может протащить столичную карточку в 50.
|
|
||||||
"""
|
|
||||||
address = payload.get("address")
|
|
||||||
if is_oblast_domclick_address(address):
|
|
||||||
return True
|
|
||||||
if is_moscow_domclick_address(address):
|
|
||||||
return False
|
|
||||||
point = _payload_point(payload)
|
|
||||||
if point is None:
|
|
||||||
return False
|
|
||||||
return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region)
|
|
||||||
|
|
||||||
|
|
||||||
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
|
|
||||||
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
|
|
||||||
return lambda payload: fn(payload.get("address"))
|
|
||||||
|
|
||||||
|
|
||||||
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
|
|
||||||
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
|
|
||||||
# молчит. Ключа нет только для источника, который вообще не умеет отличать
|
|
||||||
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
|
|
||||||
# фильтр обязан быть в реестре явно.
|
|
||||||
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
|
|
||||||
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
|
|
||||||
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
|
|
||||||
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
|
|
||||||
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
|
|
||||||
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
|
|
||||||
("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -702,7 +569,6 @@ def import_msk_raw(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
source: str = "cian",
|
source: str = "cian",
|
||||||
region: int = MOSCOW_REGION_CODE,
|
|
||||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
|
@ -711,38 +577,32 @@ def import_msk_raw(
|
||||||
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
|
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
|
||||||
) -> ImportCounters:
|
) -> ImportCounters:
|
||||||
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
|
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
|
||||||
if region not in SUPPORTED_REGIONS:
|
|
||||||
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
|
|
||||||
|
|
||||||
view = SOURCE_VIEWS[source]
|
view = SOURCE_VIEWS[source]
|
||||||
counters = ImportCounters()
|
counters = ImportCounters()
|
||||||
matcher = RealMatcherAdapter()
|
matcher = RealMatcherAdapter()
|
||||||
budget = GeocodeBudget(remaining=max(0, geocode_limit))
|
budget = GeocodeBudget(remaining=max(0, geocode_limit))
|
||||||
|
|
||||||
# Источник, который сам говорит про целевой регион: у Циана это префикс
|
# Источник, который сам говорит про город: у Циана это префикс округа, у
|
||||||
# округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика —
|
# Яндекса — второй компонент полного адреса. Авито не говорит ничего, ему
|
||||||
# первый. Авито не говорит ничего ни для какого региона, ему нужен
|
# нужен пред-геокод, поэтому его в словаре нет.
|
||||||
# пред-геокод, поэтому в реестре его нет вовсе.
|
city_filter = CITY_FILTERS.get(source)
|
||||||
city_filter = REGION_FILTERS.get((source, region))
|
|
||||||
if geocode and city_filter is not None:
|
if geocode and city_filter is not None:
|
||||||
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
|
# Город опознаётся даром и без ошибок — тратить на него внешнюю квоту
|
||||||
# незачем.
|
# незачем.
|
||||||
raise SystemExit(f"source={source}: --geocode нужен только для avito")
|
raise SystemExit(f"source={source}: --geocode нужен только для avito")
|
||||||
if city_filter is None and not geocode:
|
if city_filter is None and not geocode:
|
||||||
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
|
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области:
|
||||||
# прочих: ни города в адресе, ни координат. Пишем только по явному
|
# ни города в адресе, ни координат. Пишем только по явному разрешению.
|
||||||
# разрешению.
|
|
||||||
if not (dry_run or allow_unfiltered):
|
if not (dry_run or allow_unfiltered):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"source={source}: адрес не содержит признака города, регион "
|
f"source={source}: адрес не содержит признака города, Москву от "
|
||||||
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
|
"области не отличить. Нужен --geocode (штатный путь), "
|
||||||
"--allow-unfiltered (аварийный) или --dry-run."
|
"--allow-unfiltered (аварийный) или --dry-run."
|
||||||
)
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
|
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; "
|
||||||
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
|
"строки лягут без geom и вперемешку с областью",
|
||||||
source,
|
source,
|
||||||
region,
|
|
||||||
)
|
)
|
||||||
if geocode and not dry_run:
|
if geocode and not dry_run:
|
||||||
db.execute(text(_GEO_CACHE_DDL))
|
db.execute(text(_GEO_CACHE_DDL))
|
||||||
|
|
@ -758,16 +618,16 @@ def import_msk_raw(
|
||||||
if is_artifact_address(address):
|
if is_artifact_address(address):
|
||||||
counters.skipped_artifact += 1
|
counters.skipped_artifact += 1
|
||||||
continue
|
continue
|
||||||
if city_filter is not None and not city_filter(payload):
|
if city_filter is not None and not city_filter(address):
|
||||||
counters.skipped_not_target_region += 1
|
counters.skipped_not_moscow += 1
|
||||||
continue
|
continue
|
||||||
if geocode:
|
if geocode:
|
||||||
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
|
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
|
||||||
if point is None:
|
if point is None:
|
||||||
counters.skipped_ungeocoded += 1
|
counters.skipped_ungeocoded += 1
|
||||||
continue
|
continue
|
||||||
if point.region_code != region:
|
if point.region_code != MOSCOW_REGION_CODE:
|
||||||
counters.skipped_geo_other_region += 1
|
counters.skipped_oblast += 1
|
||||||
continue
|
continue
|
||||||
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
|
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
|
||||||
# трогаем, пересбор корпуса от этого не зависит. geom появляется
|
# трогаем, пересбор корпуса от этого не зависит. geom появляется
|
||||||
|
|
@ -789,26 +649,19 @@ def import_msk_raw(
|
||||||
if dry_run or not lots:
|
if dry_run or not lots:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# У региона 50 своего единого города нет (`canonical_city is None` в
|
|
||||||
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
|
|
||||||
# и существующее значение не затирает. Подбор аналогов не страдает:
|
|
||||||
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
|
|
||||||
# ключуется на `deals.city`, а не на `listings.city`.
|
|
||||||
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
|
|
||||||
inserted, updated = save_listings(
|
inserted, updated = save_listings(
|
||||||
db,
|
db,
|
||||||
lots,
|
lots,
|
||||||
matcher=matcher,
|
matcher=matcher,
|
||||||
region_code=region,
|
region_code=MOSCOW_REGION_CODE,
|
||||||
city=city,
|
city=MOSCOW_CITY,
|
||||||
)
|
)
|
||||||
counters.inserted += inserted
|
counters.inserted += inserted
|
||||||
counters.updated += updated
|
counters.updated += updated
|
||||||
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
|
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
|
||||||
logger.info(
|
logger.info(
|
||||||
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
"msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
||||||
source,
|
source,
|
||||||
region,
|
|
||||||
counters.read,
|
counters.read,
|
||||||
counters.selected,
|
counters.selected,
|
||||||
counters.written,
|
counters.written,
|
||||||
|
|
@ -826,12 +679,11 @@ def import_msk_raw(
|
||||||
geocode_limit,
|
geocode_limit,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
||||||
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
|
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
|
||||||
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
||||||
"геокод-вызовов=%d | сходится=%s",
|
"геокод-вызовов=%d | сходится=%s",
|
||||||
source,
|
source,
|
||||||
region,
|
|
||||||
" (dry-run)" if dry_run else "",
|
" (dry-run)" if dry_run else "",
|
||||||
counters.read,
|
counters.read,
|
||||||
counters.selected,
|
counters.selected,
|
||||||
|
|
@ -839,8 +691,8 @@ def import_msk_raw(
|
||||||
counters.inserted,
|
counters.inserted,
|
||||||
counters.updated,
|
counters.updated,
|
||||||
counters.writer_skipped if not dry_run else 0,
|
counters.writer_skipped if not dry_run else 0,
|
||||||
counters.skipped_not_target_region,
|
counters.skipped_not_moscow,
|
||||||
counters.skipped_geo_other_region,
|
counters.skipped_oblast,
|
||||||
counters.skipped_ungeocoded,
|
counters.skipped_ungeocoded,
|
||||||
counters.skipped_artifact,
|
counters.skipped_artifact,
|
||||||
counters.skipped_invalid,
|
counters.skipped_invalid,
|
||||||
|
|
@ -855,30 +707,21 @@ def main() -> None:
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)")
|
||||||
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
|
|
||||||
)
|
|
||||||
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
|
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
|
||||||
parser.add_argument(
|
|
||||||
"--region",
|
|
||||||
type=int,
|
|
||||||
choices=SUPPORTED_REGIONS,
|
|
||||||
default=MOSCOW_REGION_CODE,
|
|
||||||
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
|
|
||||||
)
|
|
||||||
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
|
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
|
||||||
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
|
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
|
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--allow-unfiltered",
|
"--allow-unfiltered",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
|
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--geocode",
|
"--geocode",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
|
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
|
||||||
"в listings уходит только целевой регион (--region), прочее пропускается",
|
"в listings уходит только регион 77, область откладывается",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--geocode-limit",
|
"--geocode-limit",
|
||||||
|
|
@ -894,7 +737,6 @@ def main() -> None:
|
||||||
import_msk_raw(
|
import_msk_raw(
|
||||||
db,
|
db,
|
||||||
source=args.source,
|
source=args.source,
|
||||||
region=args.region,
|
|
||||||
batch_size=args.batch_size,
|
batch_size=args.batch_size,
|
||||||
limit=args.limit,
|
limit=args.limit,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
-- 302_scrape_schedules_seed_rosreestr_dkp_50.sql
|
|
||||||
-- Seed-строка scrape_schedules для региона 50 (Московская область) — #3051, трек МО.
|
|
||||||
--
|
|
||||||
-- Dependencies: 289_rosreestr_fdw_msk_columns_seed77.sql (та же таблица, тот же
|
|
||||||
-- формат source/default_params).
|
|
||||||
-- Apply after: 301_web_support_message_idempotency_key.sql
|
|
||||||
--
|
|
||||||
-- WHY:
|
|
||||||
-- Код-часть импорта уже параметризована регионом (`scheduler.py::_job_rosreestr_dkp`
|
|
||||||
-- читает `region_code` из default_params и валидирует его по `app.services.regions`),
|
|
||||||
-- а wildcard-хендлер `rosreestr_dkp_import_*` резолвит любое имя с суффиксом кода.
|
|
||||||
-- Регион 50 заведён в реестре и уже на проде, поэтому включение области стоит ровно
|
|
||||||
-- одной строки расписания — новой логики не требуется.
|
|
||||||
--
|
|
||||||
-- У региона 50 `canonical_city IS NULL`, то есть он идёт по ветке региона 66:
|
|
||||||
-- `deals.city` берётся из источника (муниципалитет — Балашиха, Химки, Подольск),
|
|
||||||
-- `raw_payload` не заполняется, строки с пустым city отбрасываются. Для области это
|
|
||||||
-- и есть верное поведение: единого города у региона нет, подставлять нечего.
|
|
||||||
--
|
|
||||||
-- Замер по FDW (2026-09-12, прод): под полным WHERE импорта регион 50 даёт
|
|
||||||
-- 113 351 сделку ДКП с 2024-01-01 — корпус того же порядка, что московский.
|
|
||||||
--
|
|
||||||
-- Строка ВЫКЛЮЧЕНА (enabled=false) — ровно как seed 77 в миграции 289: миграция
|
|
||||||
-- заводит расписание, включение и первый прогон остаются отдельным решением
|
|
||||||
-- main-сессии. Окно 4-6 UTC совпадает с окнами регионов 66 и 77; прогоны
|
|
||||||
-- сериализуются планировщиком, а первая полная заливка 77 заняла 3 минуты на
|
|
||||||
-- 212 937 строк, так что третий регион в то же окно помещается с запасом.
|
|
||||||
--
|
|
||||||
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и,
|
|
||||||
-- что важнее, НЕ сбрасывает enabled обратно в false после ручного включения.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
SET LOCAL lock_timeout = '5s';
|
|
||||||
|
|
||||||
INSERT INTO scrape_schedules (
|
|
||||||
source,
|
|
||||||
enabled,
|
|
||||||
window_start_hour,
|
|
||||||
window_end_hour,
|
|
||||||
default_params
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
'rosreestr_dkp_import_50',
|
|
||||||
false,
|
|
||||||
4,
|
|
||||||
6,
|
|
||||||
'{"region_code": 50, "since": "2024-01-01", "batch_size": 2000}'::jsonb
|
|
||||||
)
|
|
||||||
ON CONFLICT (source) DO NOTHING;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
-- 303_scrape_schedules_seed_landing_showcase_deals.sql
|
|
||||||
-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
|
|
||||||
--
|
|
||||||
-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
|
|
||||||
-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
|
|
||||||
-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
|
|
||||||
-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
|
|
||||||
-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
|
|
||||||
-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
|
|
||||||
-- него не помогла бы: обе половины регистрации задачи (Handler в
|
|
||||||
-- app/services/product_handlers.py + вот эта строка) едут одним PR.
|
|
||||||
--
|
|
||||||
-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
|
|
||||||
-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
|
|
||||||
-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
|
|
||||||
-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
|
|
||||||
-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
|
|
||||||
-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
|
|
||||||
-- полоса −5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
|
|
||||||
-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
|
|
||||||
-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
|
|
||||||
-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
|
|
||||||
-- ночью), так что цена суточного такта — та же, что у соседнего
|
|
||||||
-- landing_stats_refresh (миграция 275).
|
|
||||||
--
|
|
||||||
-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
|
|
||||||
-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
|
|
||||||
-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
|
|
||||||
-- GlitchTip), когда источник не приносил данных дольше
|
|
||||||
-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
|
|
||||||
-- Отдельного монитора для витрины не заводится намеренно: её молчание было
|
|
||||||
-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
|
|
||||||
-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
|
|
||||||
-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
|
|
||||||
-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
|
|
||||||
-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
|
|
||||||
--
|
|
||||||
-- ОКНО 06:00–07:00 UTC (11:00–12:00 по Екатеринбургу): после импорта сделок
|
|
||||||
-- Росреестра (rosreestr_dkp_import, окно 04–06) и после landing_stats_refresh
|
|
||||||
-- (05–06) — витрина считается по уже обновлённым за ночь данным; и за два часа
|
|
||||||
-- до deals_freshness_monitor (08–09), так что утренний пересчёт успевает
|
|
||||||
-- сняться с просрочки до утренней же проверки.
|
|
||||||
--
|
|
||||||
-- enabled=true — как у landing_stats_refresh: задача только читает базу и
|
|
||||||
-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
|
|
||||||
-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
|
|
||||||
-- #3469 на месте, просто под другой причиной.
|
|
||||||
--
|
|
||||||
-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
|
|
||||||
-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
|
|
||||||
--
|
|
||||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
|
|
||||||
-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
|
|
||||||
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
|
|
||||||
SET LOCAL lock_timeout = '5s';
|
|
||||||
|
|
||||||
INSERT INTO scrape_schedules (
|
|
||||||
source,
|
|
||||||
enabled,
|
|
||||||
window_start_hour,
|
|
||||||
window_end_hour,
|
|
||||||
next_run_at,
|
|
||||||
default_params
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
(
|
|
||||||
'landing_showcase_deals',
|
|
||||||
true,
|
|
||||||
6,
|
|
||||||
7,
|
|
||||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
|
||||||
'{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
|
|
||||||
)
|
|
||||||
ON CONFLICT (source) DO NOTHING;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
-- 304_asking_to_sold_ratios_region.sql
|
|
||||||
-- asking_to_sold_ratios: ключ (region_code, rooms_bucket, district) — #3512.
|
|
||||||
--
|
|
||||||
-- ПРОБЛЕМА. asking_to_sold_ratios (миграция 080) несёт РОВНО 6 строк (per area-бакет
|
|
||||||
-- 0..4 + global -1), все с district='' — колонки региона в таблице НЕТ ВООБЩЕ (проверено
|
|
||||||
-- на проде 13.09.2026: `\d asking_to_sold_ratios` без region_code). Деривация
|
|
||||||
-- (app/tasks/asking_to_sold_ratio.py) скоупит ОБЕ стороны расчёта на
|
|
||||||
-- `city ILIKE '%Екатеринбург%'` — ratio физически посчитан только по рынку ЕКБ. Но
|
|
||||||
-- estimator.py (_get_asking_sold_ratio) читает строку по `rooms_bucket AND district=''`
|
|
||||||
-- БЕЗ единого понятия региона запроса — тот же коэффициент домножает asking-цену ЛЮБОГО
|
|
||||||
-- адреса, включая Москву (region_code=77) и Московскую обл. (region_code=50). Рынки
|
|
||||||
-- разные на порядок: медиана ДКП за 12 мес по region_code — 66: 90 278 ₽/м²,
|
|
||||||
-- 50: 161 176 ₽/м², 77: 322 976 ₽/м² (замер 13.09.2026) — екатеринбургский sold/asking
|
|
||||||
-- коэффициент, применённый к московской asking-цене, calibration mismatch, а не оценка.
|
|
||||||
--
|
|
||||||
-- ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ (только схема — derivation региона 66 остаётся байт-в-байт
|
|
||||||
-- прежней, деривация ДРУГИХ регионов теперь считается отдельным SQL-путём в
|
|
||||||
-- app/tasks/asking_to_sold_ratio.py, сама эта миграция ничего не пересчитывает):
|
|
||||||
-- 1. ADD COLUMN region_code int NOT NULL DEFAULT 66 — существующие 6 строк ВСЕ
|
|
||||||
-- екатеринбургские (единственная деривация на момент 080/098/109), DEFAULT
|
|
||||||
-- backfill'ит их на 66 автоматически при ALTER (Postgres 11+ — DEFAULT на
|
|
||||||
-- NOT NULL столбце без DEFAULT ранее вычисляется один раз, без rewrite всей
|
|
||||||
-- таблицы, но здесь таблица микроскопическая — 6 строк, cost неощутим в любом
|
|
||||||
-- случае).
|
|
||||||
-- 2. PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district) — строки
|
|
||||||
-- теперь живут per (регион, area-бакет, district). district остаётся зарезервирован
|
|
||||||
-- под #647 (гео-районы ВНУТРИ региона), всегда ''.
|
|
||||||
--
|
|
||||||
-- ПОЧЕМУ НЕ НОВАЯ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА (в отличие от 098 tiered-подхода). Там
|
|
||||||
-- параллельная таблица защищала fetchone()-safety при flag-OFF откате (#928). Здесь
|
|
||||||
-- отката не предполагается: region_code — обязательный параметр запроса везде, где
|
|
||||||
-- ratio читается (estimator._get_asking_sold_ratio), и ADD COLUMN NOT NULL DEFAULT 66
|
|
||||||
-- не меняет СУЩЕСТВУЮЩИЕ 6 строк по значению (только добавляет им явный регион) —
|
|
||||||
-- старый flag-OFF путь `WHERE rooms_bucket=B AND district=''` для региона 66
|
|
||||||
-- продолжает возвращать ТУ ЖЕ строку, если добавить `AND region_code=66` (что и
|
|
||||||
-- делает estimator.py в этом же PR).
|
|
||||||
--
|
|
||||||
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS — повторный прогон no-op на колонке.
|
|
||||||
-- PK-guard (как 298_deal_city_price_bands_region.sql) смотрит ТЕКУЩЕЕ определение PK
|
|
||||||
-- через pg_get_constraintdef и меняет его ТОЛЬКО если это ещё старый
|
|
||||||
-- PK(rooms_bucket, district) — повторный прогон видит PK(region_code, rooms_bucket,
|
|
||||||
-- district) и ничего не трогает. strict/exit-1 на деплое — без SET LOCAL lock_timeout
|
|
||||||
-- ALTER TABLE мог бы зависнуть под конкурентным чтением; таблица in-memory-масштаба
|
|
||||||
-- (6 строк), но конвенция проекта (#2752) требует потолок на любой блокирующий DDL.
|
|
||||||
--
|
|
||||||
-- ЗАВИСИМОСТИ: 080_asking_to_sold_ratios.sql (таблица, PK(rooms_bucket, district)),
|
|
||||||
-- 002_core_tables.sql (deals.region_code, listings.region_code — обе уже существуют).
|
|
||||||
-- Apply after: 303_scrape_schedules_seed_landing_showcase_deals.sql
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
SET LOCAL lock_timeout = '5s';
|
|
||||||
|
|
||||||
ALTER TABLE asking_to_sold_ratios
|
|
||||||
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN asking_to_sold_ratios.region_code IS
|
|
||||||
'Регион коэффициента (deals.region_code / listings.region_code, #3512). '
|
|
||||||
'Существующие строки (посчитаны деривацией, скоупленной на ЕКБ, #C2) — 66. '
|
|
||||||
'Часть составного PK (region_code, rooms_bucket, district).';
|
|
||||||
|
|
||||||
-- PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district), идемпотентно:
|
|
||||||
-- смотрим ТЕКУЩЕЕ определение PK и меняем его только если это ещё старый PK без региона.
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
v_pk_def text;
|
|
||||||
BEGIN
|
|
||||||
SELECT pg_get_constraintdef(oid) INTO v_pk_def
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
|
||||||
AND contype = 'p';
|
|
||||||
|
|
||||||
IF v_pk_def = 'PRIMARY KEY (rooms_bucket, district)' THEN
|
|
||||||
ALTER TABLE asking_to_sold_ratios DROP CONSTRAINT asking_to_sold_ratios_pkey;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint
|
|
||||||
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
|
||||||
AND contype = 'p'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE asking_to_sold_ratios
|
|
||||||
ADD CONSTRAINT asking_to_sold_ratios_pkey
|
|
||||||
PRIMARY KEY (region_code, rooms_bucket, district);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -134,10 +134,6 @@ USAGE
|
||||||
|
|
||||||
# oblast D: per-city validation (exact deals.city name, not a slug):
|
# oblast D: per-city validation (exact deals.city name, not a slug):
|
||||||
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
||||||
|
|
||||||
# #3520 любой регион (choices come from app.services.regions.REGIONS):
|
|
||||||
# Москва (region_code=77, 212 937 ДКП, 69 138 за последние 12 мес.):
|
|
||||||
python -m scripts.backtest_estimator --region 77 --since 2024-09-01 --sample 300
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -160,15 +156,6 @@ from typing import Any
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.regions import REGIONS, region_for_point
|
|
||||||
|
|
||||||
# Регион по умолчанию — Свердловская обл., прежнее (единственное до #3520)
|
|
||||||
# поведение скрипта. Список допустимых значений — РЕЕСТР REGIONS
|
|
||||||
# (app/services/regions.py), а не отдельный литерал: регион появляется в
|
|
||||||
# реестре один раз и автоматически становится доступен и здесь, и в
|
|
||||||
# msk_raw_import, и в самом эстиматоре.
|
|
||||||
DEFAULT_BACKTEST_REGION_CODE = 66
|
|
||||||
|
|
||||||
|
|
||||||
def _import_estimator() -> tuple[Any, Any]:
|
def _import_estimator() -> tuple[Any, Any]:
|
||||||
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
|
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
|
||||||
|
|
@ -766,106 +753,11 @@ def _expected_sold_metrics(
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
def _pi_calibration_metrics(
|
|
||||||
rows: list[tuple[float, int]],
|
|
||||||
*,
|
|
||||||
low_mult: float,
|
|
||||||
high_mult: float,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""PI (prediction interval) calibration — overall + per-rooms (``--pi-report``).
|
|
||||||
|
|
||||||
Each input row is ``(r, rooms)`` where ``r = sold_total / expected_sold_price``
|
|
||||||
— BOTH RUB TOTALS, not ₽/m². This matches exactly how the interval is
|
|
||||||
APPLIED in prod: ``estimator.py`` builds ``expected_sold_range_low/high`` as
|
|
||||||
``expected_sold_price * estimate_pi_low_mult/high_mult`` (a total-price
|
|
||||||
multiplier). config.py's own comment on those constants — "empirical p10/p90
|
|
||||||
of sold/expected_sold (#1966, n=2366)" — confirms p10/p90 of THIS SAME ratio
|
|
||||||
is exactly what was captured to produce 0.649 / 1.392 originally; per-m² would
|
|
||||||
silently drop the area term and give a different (wrong) distribution.
|
|
||||||
|
|
||||||
For each slice (overall, per room bucket) returns:
|
|
||||||
- ``n`` : observations with a priced expected_sold_price.
|
|
||||||
- ``p10`` / ``p50`` / ``p90``: percentiles of r — p10/p90 are the CANDIDATE
|
|
||||||
new low_mult/high_mult for this slice.
|
|
||||||
- ``achieved_coverage_pct`` : % of r inside the CURRENT [low_mult, high_mult]
|
|
||||||
— this is the number to compare against the
|
|
||||||
80% target (e.g. Москва: 70.5%).
|
|
||||||
- ``proposed_coverage_pct`` : % of r inside the PROPOSED [p10, p90] — a
|
|
||||||
sanity check, ≈80.0 BY CONSTRUCTION (p10/p90
|
|
||||||
are defined as the 10th/90th percentile of the
|
|
||||||
same sample). If this drifts far from 80, `r`
|
|
||||||
was computed on the wrong quantity — mismatch
|
|
||||||
with how the mult is applied, not a real find.
|
|
||||||
- ``width_current`` / ``width_proposed``: interval width relative to the
|
|
||||||
slice's mean r — ``(high_mult - low_mult) /
|
|
||||||
mean(r)`` and ``(p90 - p10) / mean(r)`` — so a
|
|
||||||
wider region-specific band shows up as a
|
|
||||||
visible precision cost, not just "coverage
|
|
||||||
fixed for free".
|
|
||||||
|
|
||||||
Pure: no DB (percentile via the reused estimator ``_percentile``, same as
|
|
||||||
every other block in this file). Rows with expected_sold_price<=0 must
|
|
||||||
already be filtered out by the caller (mirrors ``_range_coverage`` etc.).
|
|
||||||
"""
|
|
||||||
_, _percentile = _import_estimator() # reuse estimator's interpolation percentile
|
|
||||||
|
|
||||||
def _slice(ratios: list[float]) -> dict[str, Any]:
|
|
||||||
n = len(ratios)
|
|
||||||
if n == 0:
|
|
||||||
return {
|
|
||||||
"n": 0,
|
|
||||||
"p10": None,
|
|
||||||
"p50": None,
|
|
||||||
"p90": None,
|
|
||||||
"achieved_coverage_pct": None,
|
|
||||||
"proposed_coverage_pct": None,
|
|
||||||
"width_current": None,
|
|
||||||
"width_proposed": None,
|
|
||||||
}
|
|
||||||
s = sorted(ratios)
|
|
||||||
p10 = _percentile(s, 0.10)
|
|
||||||
p50 = _percentile(s, 0.50)
|
|
||||||
p90 = _percentile(s, 0.90)
|
|
||||||
mean_r = statistics.mean(ratios)
|
|
||||||
achieved = sum(1 for r in ratios if low_mult <= r <= high_mult) / n
|
|
||||||
proposed = sum(1 for r in ratios if p10 <= r <= p90) / n
|
|
||||||
return {
|
|
||||||
"n": n,
|
|
||||||
"p10": round(p10, 4),
|
|
||||||
"p50": round(p50, 4),
|
|
||||||
"p90": round(p90, 4),
|
|
||||||
"achieved_coverage_pct": round(100.0 * achieved, 2),
|
|
||||||
"proposed_coverage_pct": round(100.0 * proposed, 2),
|
|
||||||
"width_current": round((high_mult - low_mult) / mean_r, 4) if mean_r else None,
|
|
||||||
"width_proposed": round((p90 - p10) / mean_r, 4) if mean_r else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
by_bucket: dict[int, list[float]] = {b: [] for b in ROOM_BUCKETS}
|
|
||||||
overall: list[float] = []
|
|
||||||
for r, rooms in rows:
|
|
||||||
overall.append(r)
|
|
||||||
by_bucket[_bucketize_rooms(rooms)].append(r)
|
|
||||||
|
|
||||||
per_rooms: dict[int, dict[str, Any]] = {}
|
|
||||||
for bucket in ROOM_BUCKETS:
|
|
||||||
summary = _slice(by_bucket[bucket])
|
|
||||||
summary["label"] = _rooms_label(bucket)
|
|
||||||
per_rooms[bucket] = summary
|
|
||||||
|
|
||||||
return {
|
|
||||||
"low_mult": low_mult,
|
|
||||||
"high_mult": high_mult,
|
|
||||||
"overall": _slice(overall),
|
|
||||||
"per_rooms": per_rooms,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_full_metrics(
|
def _compute_full_metrics(
|
||||||
predictions: list[Prediction],
|
predictions: list[Prediction],
|
||||||
*,
|
*,
|
||||||
n_no_prediction: int = 0,
|
n_no_prediction: int = 0,
|
||||||
per_rooms_no_prediction: dict[int, int] | None = None,
|
per_rooms_no_prediction: dict[int, int] | None = None,
|
||||||
pi_report: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Aggregate full-spine Prediction records into the complete metrics dict.
|
"""Aggregate full-spine Prediction records into the complete metrics dict.
|
||||||
|
|
||||||
|
|
@ -877,13 +769,6 @@ def _compute_full_metrics(
|
||||||
coverage).
|
coverage).
|
||||||
- ``calibration`` : per-confidence n / coverage% / MAPE%.
|
- ``calibration`` : per-confidence n / coverage% / MAPE%.
|
||||||
- ``sharpness`` : median relative range width (high-low)/point.
|
- ``sharpness`` : median relative range width (high-low)/point.
|
||||||
- ``pi_calibration`` : ONLY when ``pi_report=True`` (``--pi-report``) — PI
|
|
||||||
calibration (p10/p50/p90 of sold_total/expected_
|
|
||||||
sold_price, achieved vs proposed coverage, width)
|
|
||||||
overall + per-rooms, to re-derive estimate_pi_low_
|
|
||||||
mult/high_mult PER REGION instead of the Екатеринбург
|
|
||||||
constants. Default False → key absent, prior dict
|
|
||||||
shape/JSON output UNCHANGED (regression-gate safe).
|
|
||||||
|
|
||||||
Pure: no DB. Safe on an empty list (every block renders with None/0).
|
Pure: no DB. Safe on an empty list (every block renders with None/0).
|
||||||
"""
|
"""
|
||||||
|
|
@ -900,9 +785,6 @@ def _compute_full_metrics(
|
||||||
sharp_rows: list[tuple[float, float, float]] = [] # (point, range_low, range_high)
|
sharp_rows: list[tuple[float, float, float]] = [] # (point, range_low, range_high)
|
||||||
calib_rows: list[tuple[str, float | None, bool | None]] = []
|
calib_rows: list[tuple[str, float | None, bool | None]] = []
|
||||||
es_area_rows: list[tuple[float, float, float]] = [] # #3251 (pred, sold, area)
|
es_area_rows: list[tuple[float, float, float]] = [] # #3251 (pred, sold, area)
|
||||||
pi_rows: list[
|
|
||||||
tuple[float, int]
|
|
||||||
] = [] # --pi-report only: (sold_total/expected_sold_price, rooms)
|
|
||||||
|
|
||||||
for p in predictions:
|
for p in predictions:
|
||||||
signed: float | None = None
|
signed: float | None = None
|
||||||
|
|
@ -911,9 +793,6 @@ def _compute_full_metrics(
|
||||||
es_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.rooms))
|
es_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.rooms))
|
||||||
es_area_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.area_m2))
|
es_area_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.area_m2))
|
||||||
|
|
||||||
if pi_report and p.expected_sold_price is not None and p.expected_sold_price > 0:
|
|
||||||
pi_rows.append((p.sold_total / p.expected_sold_price, p.rooms))
|
|
||||||
|
|
||||||
covered: bool | None = None
|
covered: bool | None = None
|
||||||
if p.range_low is not None and p.range_high is not None:
|
if p.range_low is not None and p.range_high is not None:
|
||||||
covered = p.range_low <= p.sold_total <= p.range_high
|
covered = p.range_low <= p.sold_total <= p.range_high
|
||||||
|
|
@ -926,7 +805,7 @@ def _compute_full_metrics(
|
||||||
|
|
||||||
calib_rows.append((p.confidence, signed, covered))
|
calib_rows.append((p.confidence, signed, covered))
|
||||||
|
|
||||||
out: dict[str, Any] = {
|
return {
|
||||||
"expected_sold": _expected_sold_metrics(
|
"expected_sold": _expected_sold_metrics(
|
||||||
es_rows,
|
es_rows,
|
||||||
n_no_prediction=n_no_prediction,
|
n_no_prediction=n_no_prediction,
|
||||||
|
|
@ -941,14 +820,6 @@ def _compute_full_metrics(
|
||||||
"sharpness": _sharpness(sharp_rows),
|
"sharpness": _sharpness(sharp_rows),
|
||||||
"confidence_order": conf_order,
|
"confidence_order": conf_order,
|
||||||
}
|
}
|
||||||
if pi_report:
|
|
||||||
_settings = _import_estimator_full().settings
|
|
||||||
out["pi_calibration"] = _pi_calibration_metrics(
|
|
||||||
pi_rows,
|
|
||||||
low_mult=_settings.estimate_pi_low_mult,
|
|
||||||
high_mult=_settings.estimate_pi_high_mult,
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _render_table(metrics: dict[str, Any], headline: dict[str, Any]) -> str:
|
def _render_table(metrics: dict[str, Any], headline: dict[str, Any]) -> str:
|
||||||
|
|
@ -1222,53 +1093,6 @@ def _render_calibrate_segments_block(per_segment: dict[str, Any]) -> list[str]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _render_pi_calibration_block(pi_calibration: dict[str, Any]) -> list[str]:
|
|
||||||
"""Render the PI calibration block (``--pi-report``): overall + per-rooms.
|
|
||||||
|
|
||||||
``r = sold_total / expected_sold_price``. p10/p90 are candidate replacement
|
|
||||||
low/high multipliers FOR THIS SLICE (region/rooms). achieved_coverage_pct is
|
|
||||||
what the CURRENT constants score here — the number to compare against 80%.
|
|
||||||
proposed_coverage_pct must land ≈80.00 by construction; a big deviation means
|
|
||||||
``r`` was built from the wrong quantity, not a real calibration finding.
|
|
||||||
"""
|
|
||||||
low_mult = pi_calibration["low_mult"]
|
|
||||||
high_mult = pi_calibration["high_mult"]
|
|
||||||
out: list[str] = [
|
|
||||||
"PI CALIBRATION (r = sold_total / expected_sold_price; current mult = "
|
|
||||||
f"[{low_mult:.3f}, {high_mult:.3f}]):",
|
|
||||||
]
|
|
||||||
header = (
|
|
||||||
f" {'':<8} {'n':>6} {'p10':>7} {'p50':>7} {'p90':>7} "
|
|
||||||
f"{'achiev%':>8} {'propos%':>8} {'w_cur':>7} {'w_prop':>7}"
|
|
||||||
)
|
|
||||||
out.append(header)
|
|
||||||
out.append(" " + "-" * (len(header) - 2))
|
|
||||||
|
|
||||||
def _row(label: str, s: dict[str, Any]) -> str:
|
|
||||||
n = int(s.get("n", 0) or 0)
|
|
||||||
if n == 0:
|
|
||||||
dash = "—"
|
|
||||||
return (
|
|
||||||
f" {label:<8} {n:>6} {dash:>7} {dash:>7} {dash:>7} "
|
|
||||||
f"{dash:>8} {dash:>8} {dash:>7} {dash:>7}"
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f" {label:<8} {n:>6} {s['p10']:>7.3f} {s['p50']:>7.3f} {s['p90']:>7.3f} "
|
|
||||||
f"{s['achieved_coverage_pct']:>8.2f} {s['proposed_coverage_pct']:>8.2f} "
|
|
||||||
f"{s['width_current']:>7.3f} {s['width_proposed']:>7.3f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
out.append(_row("OVERALL", pi_calibration["overall"]))
|
|
||||||
for bucket in ROOM_BUCKETS:
|
|
||||||
row = pi_calibration["per_rooms"][bucket]
|
|
||||||
out.append(_row(row["label"], row))
|
|
||||||
out.append(
|
|
||||||
" NB: proposed% should be ≈80.00 by construction (sanity check). "
|
|
||||||
"achieved% is the number to compare against the 80% target."
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _render_coverage_block(range_coverage: dict[str, Any], conf_order: list[str]) -> list[str]:
|
def _render_coverage_block(range_coverage: dict[str, Any], conf_order: list[str]) -> list[str]:
|
||||||
"""Render range-coverage: overall + per-confidence (sold_total ∈ range)."""
|
"""Render range-coverage: overall + per-confidence (sold_total ∈ range)."""
|
||||||
ov = range_coverage["overall"]
|
ov = range_coverage["overall"]
|
||||||
|
|
@ -1329,7 +1153,6 @@ _SAMPLE_SQL = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1381,7 +1204,6 @@ _SAMPLE_SQL_SCATTERED = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1411,7 +1233,6 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1450,9 +1271,10 @@ _CANDIDATES_SQL = text(
|
||||||
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
||||||
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
||||||
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
||||||
# query. region_code (#3520 "любой регион") is threaded from the CLI --region
|
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
|
||||||
# flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
|
# region CLI flag yet, so every call is scoped to the oblast, matching every
|
||||||
# every pre-existing invocation, which never passed --region).
|
# existing --city invocation (byte-identical to the pre-298 unscoped lookup,
|
||||||
|
# which only ever saw region-66 rows since the table was oblast-only before).
|
||||||
_CITY_PPM2_BAND_SQL = text(
|
_CITY_PPM2_BAND_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT ppm2_min, ppm2_max
|
SELECT ppm2_min, ppm2_max
|
||||||
|
|
@ -1466,13 +1288,10 @@ _CITY_PPM2_BAND_SQL = text(
|
||||||
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||||
|
|
||||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object every
|
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
||||||
time — literal identity, not just equal text (asserted by
|
before this change — literal identity, not just equal text — so the
|
||||||
``test_sample_sql_city_none_returns_original_object``) — so callers that
|
default CLI invocation (and the frozen EKB regression gate, which never
|
||||||
cache/compare the query object see a stable reference. All four SQL
|
calls this path at all) see byte-identical SQL.
|
||||||
variants (unscoped/scattered × city/no-city) carry a ``region_code``
|
|
||||||
predicate (#3520 "любой регион") bound from ``_load_sample``'s
|
|
||||||
``region_code`` param, default 66.
|
|
||||||
|
|
||||||
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
||||||
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
||||||
|
|
@ -1502,7 +1321,6 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1530,9 +1348,9 @@ def _resolve_city_ppm2_band(
|
||||||
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
||||||
error → the globals; read-only best-effort, never raises.
|
error → the globals; read-only best-effort, never raises.
|
||||||
|
|
||||||
``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
|
``region_code`` defaults to 66 (Свердловская обл.) — this script has no
|
||||||
direct callers; ``_load_sample`` (#3520) always passes the actual
|
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
|
||||||
``--region`` value through explicitly.
|
--city caller keeps its byte-identical lookup.
|
||||||
"""
|
"""
|
||||||
if city is None:
|
if city is None:
|
||||||
return float(PPM2_MIN), float(PPM2_MAX)
|
return float(PPM2_MIN), float(PPM2_MAX)
|
||||||
|
|
@ -1558,32 +1376,22 @@ def _load_sample(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
) -> list[DealSample]:
|
) -> list[DealSample]:
|
||||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||||
|
|
||||||
``region_code`` (#3520 "любой регион", default 66 — Свердловская обл.)
|
|
||||||
scopes the sample to one ``deals.region_code`` value — REQUIRED once
|
|
||||||
``deals`` carries more than one region (212 937 Москва + 113 351
|
|
||||||
Московская обл. rows alongside 108 623 ЕКБ), otherwise the "unscoped"
|
|
||||||
query would silently mix regions together. Must be a key of
|
|
||||||
``app.services.regions.REGIONS`` — validated by ``main()``/``_parse_args``
|
|
||||||
(CLI ``choices``) so this function only ever sees a supported code.
|
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
||||||
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
||||||
``deal_city_price_bands`` for that (region_code, city) pair
|
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
|
||||||
(``_resolve_city_ppm2_band``, falls back to the module globals). Default
|
falls back to the module globals). Default None is byte-identical to the
|
||||||
None is byte-identical to the pre-oblast-D behaviour: same SQL object,
|
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
|
||||||
same PPM2_MIN/PPM2_MAX globals, no extra query.
|
no extra query.
|
||||||
"""
|
"""
|
||||||
if city is None:
|
if city is None:
|
||||||
ppm2_min: float = PPM2_MIN
|
ppm2_min: float = PPM2_MIN
|
||||||
ppm2_max: float = PPM2_MAX
|
ppm2_max: float = PPM2_MAX
|
||||||
else:
|
else:
|
||||||
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city, region_code=region_code)
|
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"region_code": region_code,
|
|
||||||
"ppm2_min": ppm2_min,
|
"ppm2_min": ppm2_min,
|
||||||
"ppm2_max": ppm2_max,
|
"ppm2_max": ppm2_max,
|
||||||
"since": since,
|
"since": since,
|
||||||
|
|
@ -1961,7 +1769,7 @@ def _make_call_stub(
|
||||||
return _stub
|
return _stub
|
||||||
|
|
||||||
|
|
||||||
def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[str, Any]:
|
def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Replay a frozen backtest fixture through the full spine — hermetic, ZERO DB.
|
"""Replay a frozen backtest fixture through the full spine — hermetic, ZERO DB.
|
||||||
|
|
||||||
For every captured deal record: rebuild the ``GeocodeResult``, build order-based
|
For every captured deal record: rebuild the ``GeocodeResult``, build order-based
|
||||||
|
|
@ -2070,7 +1878,7 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
||||||
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
|
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
|
||||||
|
|
||||||
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
||||||
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
|
metrics = _compute_full_metrics(predictions, n_no_prediction=0)
|
||||||
|
|
||||||
deal_median = statistics.median(sold_ppm2_all) if sold_ppm2_all else None
|
deal_median = statistics.median(sold_ppm2_all) if sold_ppm2_all else None
|
||||||
ask_median = statistics.median(pred_ppm2_all) if pred_ppm2_all else None
|
ask_median = statistics.median(pred_ppm2_all) if pred_ppm2_all else None
|
||||||
|
|
@ -2139,21 +1947,8 @@ def _predict_full_spine(
|
||||||
# harness measures the SAME corridor prod actually computes today, not the
|
# harness measures the SAME corridor prod actually computes today, not the
|
||||||
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
||||||
target_city = m._resolve_target_city(deal.address)
|
target_city = m._resolve_target_city(deal.address)
|
||||||
# #3520 "любой регион": mirror estimate_quality's OWN region_code resolution
|
|
||||||
# (estimator.py ~4868-4870, region_for_point(geo.lat, geo.lon) with a
|
|
||||||
# DEFAULT_REGION_CODE fallback for points outside every registered bbox) —
|
|
||||||
# without this, _fetch_dkp_corridor's default (region 66) would scope a
|
|
||||||
# Москва/Московская обл. deal's corridor query to the WRONG region and
|
|
||||||
# silently return no rows (or, worse, an unrelated same-named street in 66).
|
|
||||||
target_region = region_for_point(deal.lat, deal.lon)
|
|
||||||
target_region_code = target_region.code if target_region else DEFAULT_BACKTEST_REGION_CODE
|
|
||||||
dkp_raw = m._fetch_dkp_corridor(
|
dkp_raw = m._fetch_dkp_corridor(
|
||||||
db,
|
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
|
||||||
address=deal.address,
|
|
||||||
rooms=deal.rooms,
|
|
||||||
area=deal.area_m2,
|
|
||||||
city=target_city,
|
|
||||||
region_code=target_region_code,
|
|
||||||
)
|
)
|
||||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||||
# estimate_quality — no-area / no-address → ([], None) instead of an
|
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||||
|
|
@ -2374,7 +2169,6 @@ def run_backtest(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the full read-only backtest and return a metrics dict.
|
"""Drive the full read-only backtest and return a metrics dict.
|
||||||
|
|
||||||
|
|
@ -2390,29 +2184,12 @@ def run_backtest(
|
||||||
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
||||||
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
||||||
|
|
||||||
``region_code`` (#3520, default 66) scopes the deal sample to one
|
|
||||||
``deals.region_code`` — see ``_load_sample``.
|
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the deal sample to one
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour).
|
(byte-identical to the pre-oblast-D behaviour).
|
||||||
"""
|
"""
|
||||||
deals = _load_sample(
|
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||||
db,
|
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||||
sample=sample,
|
|
||||||
since=since,
|
|
||||||
city=city,
|
|
||||||
scattered=scattered,
|
|
||||||
seed=seed,
|
|
||||||
region_code=region_code,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d)",
|
|
||||||
len(deals),
|
|
||||||
since,
|
|
||||||
city,
|
|
||||||
region_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
matched_rows: list[tuple[float, float, int]] = []
|
matched_rows: list[tuple[float, float, int]] = []
|
||||||
matched_ids: list[int] = []
|
matched_ids: list[int] = []
|
||||||
|
|
@ -2487,8 +2264,6 @@ def run_backtest_full(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
pi_report: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||||
|
|
||||||
|
|
@ -2514,42 +2289,16 @@ def run_backtest_full(
|
||||||
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
||||||
attached to the returned metrics. Default False → byte-identical prior output.
|
attached to the returned metrics. Default False → byte-identical prior output.
|
||||||
|
|
||||||
``region_code`` (#3520, default 66) scopes the deal sample to one
|
|
||||||
``deals.region_code`` — see ``_load_sample``. Independently of this flag,
|
|
||||||
``_predict_full_spine`` ALWAYS resolves each deal's OWN region from its
|
|
||||||
lat/lon via ``regions.region_for_point`` (mirroring prod's
|
|
||||||
``estimate_quality`` region resolution) before calling
|
|
||||||
``_fetch_dkp_corridor``, so the ДКП corridor is scoped correctly even if a
|
|
||||||
sample somehow mixed regions.
|
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the deal sample to one
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
||||||
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
|
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
|
||||||
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||||
|
|
||||||
``pi_report`` (default False, ``--pi-report``) attaches a ``pi_calibration``
|
|
||||||
block (see ``_pi_calibration_metrics``) — p10/p50/p90 of sold_total/expected_
|
|
||||||
sold_price overall + per-rooms, plus achieved/proposed PI coverage, so
|
|
||||||
estimate_pi_low_mult/high_mult (calibrated on Екатеринбург only, #1966) can be
|
|
||||||
re-derived PER REGION. Default False → byte-identical prior output.
|
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
est = _import_estimator_full()
|
||||||
deals = _load_sample(
|
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||||
db,
|
|
||||||
sample=sample,
|
|
||||||
since=since,
|
|
||||||
city=city,
|
|
||||||
scattered=scattered,
|
|
||||||
seed=seed,
|
|
||||||
region_code=region_code,
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d) [full spine]",
|
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
||||||
len(deals),
|
|
||||||
since,
|
|
||||||
city,
|
|
||||||
region_code,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
predictions: list[Prediction] = []
|
predictions: list[Prediction] = []
|
||||||
|
|
@ -2604,7 +2353,6 @@ def run_backtest_full(
|
||||||
predictions,
|
predictions,
|
||||||
n_no_prediction=n_no_prediction,
|
n_no_prediction=n_no_prediction,
|
||||||
per_rooms_no_prediction=per_rooms_no_prediction,
|
per_rooms_no_prediction=per_rooms_no_prediction,
|
||||||
pi_report=pi_report,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
deal_median = statistics.median(sold_ppm2_all) if sold_ppm2_all else None
|
deal_median = statistics.median(sold_ppm2_all) if sold_ppm2_all else None
|
||||||
|
|
@ -2734,17 +2482,6 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
||||||
"прогона подряд отличаются только правкой, а не составом выборки.",
|
"прогона подряд отличаются только правкой, а не составом выборки.",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
|
||||||
"--region",
|
|
||||||
type=int,
|
|
||||||
default=DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
choices=sorted(REGIONS),
|
|
||||||
help="#3520 'любой регион': deals.region_code to backtest against (default "
|
|
||||||
f"{DEFAULT_BACKTEST_REGION_CODE} — Свердловская обл., unchanged behaviour). "
|
|
||||||
"Supported values come from the app.services.regions.REGIONS registry — "
|
|
||||||
+ ", ".join(f"{code} ({REGIONS[code].name})" for code in sorted(REGIONS))
|
|
||||||
+ ". An unsupported code is rejected by argparse before any DB call.",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--city",
|
"--city",
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -2805,27 +2542,6 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
"proposes estimate_segment_multipliers, it does NOT apply them or touch "
|
"proposes estimate_segment_multipliers, it does NOT apply them or touch "
|
||||||
"the baseline. Run with --resolve-house-id for prod-parity biases.",
|
"the baseline. Run with --resolve-house-id for prod-parity biases.",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
|
||||||
"--pi-report",
|
|
||||||
action="store_true",
|
|
||||||
help="FULL engine only: after the run, print a PI (prediction interval) "
|
|
||||||
"calibration block — p10/p50/p90 of sold_total/expected_sold_price overall "
|
|
||||||
"+ per-rooms, achieved coverage of the CURRENT estimate_pi_low_mult/"
|
|
||||||
"high_mult (calibrated on Екатеринбург only, #1966), and the coverage the "
|
|
||||||
"sample's own [p10, p90] would give. Use --region to re-derive multipliers "
|
|
||||||
"for a non-EKB region. PRINT-ONLY, applies nothing. Also adds a "
|
|
||||||
"'pi_calibration' key to --json output. Default OFF → byte-identical to "
|
|
||||||
"the prior behaviour (both text and --json).",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--pi-report-json",
|
|
||||||
metavar="PATH",
|
|
||||||
default=None,
|
|
||||||
help="With --pi-report: also write JUST the pi_calibration block (plus "
|
|
||||||
"region/city/since/sample run params) to PATH as JSON, so multiple "
|
|
||||||
"per-region runs can be compared without parsing the text table. "
|
|
||||||
"Independent of --json (which dumps the FULL metrics dict to stdout).",
|
|
||||||
)
|
|
||||||
# #1966 PR 3/3 — fixture capture + hermetic replay. --dump-fixture (DB run,
|
# #1966 PR 3/3 — fixture capture + hermetic replay. --dump-fixture (DB run,
|
||||||
# full engine) and --from-fixture (NO DB) are mutually exclusive modes.
|
# full engine) and --from-fixture (NO DB) are mutually exclusive modes.
|
||||||
fixture_mode = p.add_mutually_exclusive_group()
|
fixture_mode = p.add_mutually_exclusive_group()
|
||||||
|
|
@ -2855,50 +2571,14 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
return p.parse_args(argv)
|
return p.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def _emit_pi_report(
|
|
||||||
metrics: dict[str, Any],
|
|
||||||
*,
|
|
||||||
json_path: str | None,
|
|
||||||
region_code: int,
|
|
||||||
city: str | None,
|
|
||||||
since: str,
|
|
||||||
sample: int,
|
|
||||||
) -> None:
|
|
||||||
"""``--pi-report`` side effects — text block + optional JSON — shared by the
|
|
||||||
live-DB and ``--from-fixture`` paths. No-op if ``pi_calibration`` is absent
|
|
||||||
(i.e. ``--pi-report`` wasn't actually threaded into the metrics computation).
|
|
||||||
"""
|
|
||||||
pi = metrics.get("pi_calibration")
|
|
||||||
if pi is None:
|
|
||||||
return
|
|
||||||
print("\n" + "\n".join(_render_pi_calibration_block(pi)))
|
|
||||||
if json_path:
|
|
||||||
payload = {
|
|
||||||
"region_code": region_code,
|
|
||||||
"city": city,
|
|
||||||
"since": since,
|
|
||||||
"sample": sample,
|
|
||||||
"pi_calibration": pi,
|
|
||||||
}
|
|
||||||
Path(json_path).write_text(
|
|
||||||
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
logger.info("wrote pi-report json: %s", json_path)
|
|
||||||
print(f"wrote pi-report json: {json_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entry point. Returns the count of matched (predicted) / replayed deals."""
|
"""CLI entry point. Returns the count of matched (predicted) / replayed deals."""
|
||||||
args = _parse_args(argv)
|
args = _parse_args(argv)
|
||||||
|
|
||||||
if args.pi_report_json and not args.pi_report:
|
|
||||||
raise SystemExit("--pi-report-json requires --pi-report")
|
|
||||||
|
|
||||||
# ── Hermetic replay path (#1966 PR 3/3) — ZERO DB, no SessionLocal opened. ──
|
# ── Hermetic replay path (#1966 PR 3/3) — ZERO DB, no SessionLocal opened. ──
|
||||||
if args.from_fixture:
|
if args.from_fixture:
|
||||||
fixture = load_fixture(args.from_fixture)
|
fixture = load_fixture(args.from_fixture)
|
||||||
metrics = replay_fixture(fixture, pi_report=args.pi_report)
|
metrics = replay_fixture(fixture)
|
||||||
print(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True))
|
print(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
if args.update_baseline:
|
if args.update_baseline:
|
||||||
out = Path(args.update_baseline)
|
out = Path(args.update_baseline)
|
||||||
|
|
@ -2907,14 +2587,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
logger.info("wrote baseline: %s", out)
|
logger.info("wrote baseline: %s", out)
|
||||||
_emit_pi_report(
|
|
||||||
metrics,
|
|
||||||
json_path=args.pi_report_json,
|
|
||||||
region_code=args.region,
|
|
||||||
city=args.city,
|
|
||||||
since=args.since,
|
|
||||||
sample=len(fixture.get("deals") or []),
|
|
||||||
)
|
|
||||||
return len(fixture.get("deals") or [])
|
return len(fixture.get("deals") or [])
|
||||||
|
|
||||||
if args.update_baseline:
|
if args.update_baseline:
|
||||||
|
|
@ -2925,14 +2597,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
raise SystemExit("--resolve-house-id is only supported with --engine full")
|
raise SystemExit("--resolve-house-id is only supported with --engine full")
|
||||||
if args.calibrate_segments and args.engine != "full":
|
if args.calibrate_segments and args.engine != "full":
|
||||||
raise SystemExit("--calibrate-segments is only supported with --engine full")
|
raise SystemExit("--calibrate-segments is only supported with --engine full")
|
||||||
if args.pi_report and args.engine != "full":
|
|
||||||
raise SystemExit("--pi-report is only supported with --engine full")
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"backtest start: engine=%s region=%d sample=%d since=%s radius=%dm "
|
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
||||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
||||||
args.engine,
|
args.engine,
|
||||||
args.region,
|
|
||||||
args.sample,
|
args.sample,
|
||||||
args.since,
|
args.since,
|
||||||
args.radius,
|
args.radius,
|
||||||
|
|
@ -2955,8 +2624,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
city=args.city,
|
city=args.city,
|
||||||
scattered=(args.spread == "scattered"),
|
scattered=(args.spread == "scattered"),
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
region_code=args.region,
|
|
||||||
pi_report=args.pi_report,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metrics = run_backtest(
|
metrics = run_backtest(
|
||||||
|
|
@ -2969,7 +2636,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
city=args.city,
|
city=args.city,
|
||||||
scattered=(args.spread == "scattered"),
|
scattered=(args.spread == "scattered"),
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
region_code=args.region,
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
@ -2988,15 +2654,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
per_segment = metrics["expected_sold"]["per_segment"]
|
per_segment = metrics["expected_sold"]["per_segment"]
|
||||||
print("\n" + "\n".join(_render_calibrate_segments_block(per_segment)))
|
print("\n" + "\n".join(_render_calibrate_segments_block(per_segment)))
|
||||||
|
|
||||||
_emit_pi_report(
|
|
||||||
metrics,
|
|
||||||
json_path=args.pi_report_json,
|
|
||||||
region_code=args.region,
|
|
||||||
city=args.city,
|
|
||||||
since=args.since,
|
|
||||||
sample=args.sample,
|
|
||||||
)
|
|
||||||
|
|
||||||
return int(metrics["params"]["n_matched"])
|
return int(metrics["params"]["n_matched"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
@ -67,13 +66,11 @@ from sqlalchemy.orm import Session
|
||||||
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
|
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
|
||||||
try:
|
try:
|
||||||
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
||||||
from app.services.regions import REGIONS # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.services.regions import REGIONS
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -94,47 +91,6 @@ _LOG_EVERY = 1000
|
||||||
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
||||||
_MIN_KEY_LEN = 3
|
_MIN_KEY_LEN = 3
|
||||||
|
|
||||||
# Default region. 66 (Свердловская обл.) — единственный регион, на котором
|
|
||||||
# скрипт исторически работал; остаётся умолчанием, чтобы существующие вызовы
|
|
||||||
# без флага вели себя ровно как раньше.
|
|
||||||
_DEFAULT_REGION_CODE = 66
|
|
||||||
|
|
||||||
# Sanity-порог разброса домов ВНУТРИ одного ключа (км). Ключ — (регион, НП,
|
|
||||||
# улица), так что легитимный разброс — это длина одной улицы: даже проспект в
|
|
||||||
# большом городе редко длиннее 10-15 км, а типичная улица — 1-3 км. Разброс
|
|
||||||
# больше порога означает, что в ведро слиплись ОДНОИМЁННЫЕ улицы разных
|
|
||||||
# населённых пунктов (или один НП распознан двумя написаниями) — среднее таких
|
|
||||||
# точек даёт правдоподобную координату ПОСЕРЕДИНЕ между городами, и ошибка
|
|
||||||
# тихая. Такое ведро выбрасывается целиком: дыра в покрытии честнее, чем
|
|
||||||
# сделка, посаженная в поле между Клином и Серпуховом.
|
|
||||||
_DEFAULT_MAX_SPREAD_KM = 5.0
|
|
||||||
|
|
||||||
# Классификация comma-сегмента адреса (см. `_split_place_street`).
|
|
||||||
# Тип населённого пункта в начале сегмента: «г Химки», «пос. Голубое»,
|
|
||||||
# «д Сабурово», «рп Оболенск», «снт Заря». За типом ОБЯЗАН идти буквенный
|
|
||||||
# токен — иначе «д 5» (номер дома) распознался бы как деревня.
|
|
||||||
_LOCALITY_TYPE_RE = re.compile(
|
|
||||||
r"^(?:"
|
|
||||||
r"г|гор|город|пгт|рп|дп|снт|днп|тер"
|
|
||||||
r"|п|пос|поселок|посёлок|п/ст"
|
|
||||||
r"|д|дер|деревня|с|село|сл|слобода|ст|станция|х|хутор|аул"
|
|
||||||
r")\.?\s+(?=[а-яё])",
|
|
||||||
flags=re.UNICODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Сегмент — номер дома/квартиры/корпуса, а не имя: «125», «д 5», «кв 12»,
|
|
||||||
# «корп 3», «литера А». Такие сегменты не могут быть ни НП, ни улицей.
|
|
||||||
_NUMERIC_SEGMENT_RE = re.compile(
|
|
||||||
r"^(?:"
|
|
||||||
r"д|дом|к|кор|корп|корпус|стр|строение|кв|квартира|литера?|уч|участок"
|
|
||||||
r"|пом|помещение|оф|офис|бокс|гараж"
|
|
||||||
# Хвост `$` обязателен: сегмент считается номером, только если КРОМЕ
|
|
||||||
# числа в нём ничего нет. Иначе «8 марта» (числовое имя улицы) было бы
|
|
||||||
# принято за номер дома и улица потерялась бы целиком.
|
|
||||||
r")?\.?\s*\d+[а-яё]?(?:\s*[/-]\s*\d+[а-яё]?)?$",
|
|
||||||
flags=re.UNICODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Street-key normalization — the crux of match rate
|
# Street-key normalization — the crux of match rate
|
||||||
|
|
@ -153,11 +109,9 @@ _ADMIN_SEGMENT_RES = [
|
||||||
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
||||||
flags=re.UNICODE,
|
flags=re.UNICODE,
|
||||||
),
|
),
|
||||||
|
re.compile(r"^(?:г|гор|город)\.?\s+[а-яё][а-яё-]+$", flags=re.UNICODE),
|
||||||
|
re.compile(r"^екатеринбург$", flags=re.UNICODE),
|
||||||
]
|
]
|
||||||
# NB: сегменты-НАСЕЛЁННЫЕ ПУНКТЫ («г. Екатеринбург», «екатеринбург») здесь
|
|
||||||
# больше НЕ выбрасываются — они несут вторую половину ключа и разбираются
|
|
||||||
# `_locality_name` по реестру регионов. Хардкод `^екатеринбург$` уехал туда же:
|
|
||||||
# список городов региона живёт в `REGIONS[region_code].cities`, а не здесь.
|
|
||||||
|
|
||||||
# Street-type token at the START of the street segment. Stripped because
|
# Street-type token at the START of the street segment. Stripped because
|
||||||
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
||||||
|
|
@ -233,176 +187,63 @@ def _strip_house_tail(segment: str) -> str:
|
||||||
return " ".join(keep).strip()
|
return " ".join(keep).strip()
|
||||||
|
|
||||||
|
|
||||||
def _is_admin_segment(seg: str) -> bool:
|
def _street_key(address: str | None) -> str:
|
||||||
"""Сегмент — страна/регион/район (выбрасывается целиком)."""
|
"""Reduce any address to a bare-street-name key for the centroid join.
|
||||||
return any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES)
|
|
||||||
|
|
||||||
|
Both sides must produce the SAME key or the join under-matches:
|
||||||
|
'Екатеринбург, ул. Малышева, 125' → 'малышева'
|
||||||
|
'г Екатеринбург, улица Малышева' → 'малышева'
|
||||||
|
'Екатеринбург, Малышева' → 'малышева'
|
||||||
|
'Свердловская обл., Екатеринбург, ул. Большакова, 17' → 'большакова'
|
||||||
|
'улица Яскина, 12 · р-н Октябрьский' → 'яскина'
|
||||||
|
'г. Екатеринбург, проспект Ленина, 50' → 'ленина'
|
||||||
|
'Екатеринбург, ул. 8 Марта, 100' → '8 марта'
|
||||||
|
|
||||||
def _is_numeric_segment(seg: str) -> bool:
|
Steps:
|
||||||
"""Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
|
1. NFC normalize, lowercase, ё→е (deals/houses differ on ё usage).
|
||||||
return bool(_NUMERIC_SEGMENT_RE.match(seg))
|
2. Drop a trailing district marker (' · р-н ...', ' | ...').
|
||||||
|
3. Split on commas; drop leading segments that are admin chunks
|
||||||
|
(Россия / region / district / город / Екатеринбург). The first
|
||||||
|
non-admin segment is the street segment.
|
||||||
|
4. Drop apartment/corpus/строение noise inside that segment.
|
||||||
|
5. Strip the street-type token at the start (ул/улица/проспект/...).
|
||||||
|
6. Strip the trailing house number, preserving numeric street names.
|
||||||
|
7. Collapse whitespace.
|
||||||
|
|
||||||
|
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
|
||||||
def _normalize_place(value: str | None) -> str:
|
|
||||||
"""Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
|
|
||||||
if not value:
|
|
||||||
return ""
|
|
||||||
v = unicodedata.normalize("NFC", value).lower().replace("ё", "е")
|
|
||||||
v = _WS_RE.sub(" ", v).strip()
|
|
||||||
v = _LOCALITY_TYPE_RE.sub("", v)
|
|
||||||
return _WS_RE.sub(" ", v).strip(" .,")
|
|
||||||
|
|
||||||
|
|
||||||
def _locality_name(seg: str, cities: frozenset[str]) -> str | None:
|
|
||||||
"""Имя НП, если сегмент — населённый пункт, иначе None.
|
|
||||||
|
|
||||||
Два признака: явный тип («г Химки», «д Сабурово», «снт Заря») — имя берём
|
|
||||||
как есть; ИЛИ голое имя, которое реестр региона знает как город
|
|
||||||
(`REGIONS[region_code].cities`). Голое незнакомое имя здесь НЕ считается
|
|
||||||
НП — иначе «Малышева, 125» прочиталось бы как НП «Малышева»; такой случай
|
|
||||||
ловит позиционный fallback в `_split_place_street`.
|
|
||||||
"""
|
"""
|
||||||
m = _LOCALITY_TYPE_RE.match(seg)
|
|
||||||
if m:
|
|
||||||
name = seg[m.end() :]
|
|
||||||
else:
|
|
||||||
if _STREET_TYPE_RE.match(seg) or _is_numeric_segment(seg):
|
|
||||||
return None
|
|
||||||
if _WS_RE.sub(" ", seg).strip() not in cities:
|
|
||||||
return None
|
|
||||||
name = seg
|
|
||||||
name = _WS_RE.sub(" ", name).strip(" .,")
|
|
||||||
return name or None
|
|
||||||
|
|
||||||
|
|
||||||
def _split_place_street(
|
|
||||||
address: str | None, region_code: int = _DEFAULT_REGION_CODE
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""Разложить адрес на (населённый пункт, улица) — обе половины ключа.
|
|
||||||
|
|
||||||
ПОЧЕМУ НП обязан быть в ключе. Раньше функция звалась `_street_key` и
|
|
||||||
возвращала ГОЛОЕ имя улицы, выбрасывая НП. Для одного города (скрипт жил
|
|
||||||
ЕКБ-only, с хардкодом `^екатеринбург$`) это безвредно. Для Московской
|
|
||||||
области — тихая катастрофа: «Ленина» / «Центральная» / «Советская» есть
|
|
||||||
почти в каждом из ~970 НП области, дома со всех таких улиц слиплись бы в
|
|
||||||
одно ведро, а среднее их координат — правдоподобная точка ПОСЕРЕДИНЕ между
|
|
||||||
городами. Сделка уезжает на десятки километров, и ни одна проверка этого не
|
|
||||||
замечает: координата валидная, внутри области, рядом есть дома.
|
|
||||||
|
|
||||||
Разбор:
|
|
||||||
1. NFC, lower, ё→е, снять хвост-район (' · р-н ...', ' | ...').
|
|
||||||
2. Порезать на comma-сегменты, выбросить страну/регион/район.
|
|
||||||
3. Первый сегмент-НП (`_locality_name`) → place, улицу ищем ПОСЛЕ него.
|
|
||||||
4. Улица — первый не-числовой сегмент из остатка.
|
|
||||||
5. Fallback «НП без типа и вне реестра» («Сабурово, Луговая» — ровно
|
|
||||||
формат deals.address по области): если НП не нашёлся, а не-числовых
|
|
||||||
сегментов >= 2 и первый не начинается с типа улицы — первый считается
|
|
||||||
НП, второй улицей.
|
|
||||||
6. Улицу чистим как раньше: квартира/корпус, тип улицы, номер дома.
|
|
||||||
|
|
||||||
Неизвестный `region_code` -> KeyError реестра, не молчаливый ''.
|
|
||||||
"""
|
|
||||||
cities = REGIONS[region_code].cities
|
|
||||||
if not address:
|
if not address:
|
||||||
return "", ""
|
return ""
|
||||||
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
|
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
|
||||||
s = _WS_RE.sub(" ", s).strip()
|
s = _WS_RE.sub(" ", s).strip()
|
||||||
if not s:
|
if not s:
|
||||||
return "", ""
|
return ""
|
||||||
|
|
||||||
|
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
|
||||||
s = _DISTRICT_SUFFIX_RE.sub("", s)
|
s = _DISTRICT_SUFFIX_RE.sub("", s)
|
||||||
|
|
||||||
|
# 3. Split on commas, drop leading admin segments. Each segment is matched
|
||||||
|
# whole, so a non-admin street segment is never partially eaten.
|
||||||
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
|
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
|
||||||
non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
|
street_seg = ""
|
||||||
if not non_admin:
|
for seg in segments:
|
||||||
return "", ""
|
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES):
|
||||||
|
continue
|
||||||
place = ""
|
street_seg = seg
|
||||||
rest = non_admin
|
|
||||||
for i, seg in enumerate(non_admin):
|
|
||||||
name = _locality_name(seg, cities)
|
|
||||||
if name:
|
|
||||||
place = name
|
|
||||||
rest = non_admin[i + 1 :]
|
|
||||||
break
|
break
|
||||||
|
|
||||||
street_seg = next((seg for seg in rest if not _is_numeric_segment(seg)), "")
|
|
||||||
|
|
||||||
if not place:
|
|
||||||
usable = [seg for seg in non_admin if not _is_numeric_segment(seg)]
|
|
||||||
if len(usable) >= 2 and not _STREET_TYPE_RE.match(usable[0]):
|
|
||||||
place = _WS_RE.sub(" ", usable[0]).strip(" .,")
|
|
||||||
street_seg = usable[1]
|
|
||||||
|
|
||||||
if not street_seg:
|
if not street_seg:
|
||||||
return place, ""
|
return ""
|
||||||
|
|
||||||
|
# 4. Drop apartment/corpus/строение noise inside the street segment.
|
||||||
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
|
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
|
||||||
|
|
||||||
|
# 5. Strip the street-type token if present.
|
||||||
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
|
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
|
||||||
|
|
||||||
|
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
|
||||||
street_seg = _strip_house_tail(street_seg)
|
street_seg = _strip_house_tail(street_seg)
|
||||||
return place, _WS_RE.sub(" ", street_seg).strip()
|
|
||||||
|
|
||||||
|
return _WS_RE.sub(" ", street_seg).strip()
|
||||||
def _street_key(address: str | None, region_code: int = _DEFAULT_REGION_CODE) -> str:
|
|
||||||
"""Только уличная половина ключа. Как ключ join'а САМА ПО СЕБЕ не годится
|
|
||||||
(одноимённые улицы разных НП) — см. `_address_key`; оставлена для отчётов
|
|
||||||
и тестов нормализации улицы.
|
|
||||||
|
|
||||||
Примеры (region 66):
|
|
||||||
'Екатеринбург, ул. Малышева, 125' -> 'малышева'
|
|
||||||
'г Екатеринбург, улица Малышева' -> 'малышева'
|
|
||||||
'Свердловская обл., Екатеринбург, ул. Большакова, 17' -> 'большакова'
|
|
||||||
'улица Яскина, 12 · р-н Октябрьский' -> 'яскина'
|
|
||||||
'Екатеринбург, ул. 8 Марта, 100' -> '8 марта'
|
|
||||||
"""
|
|
||||||
return _split_place_street(address, region_code)[1]
|
|
||||||
|
|
||||||
|
|
||||||
def _address_key(
|
|
||||||
address: str | None,
|
|
||||||
region_code: int = _DEFAULT_REGION_CODE,
|
|
||||||
city: str | None = None,
|
|
||||||
) -> tuple[int, str, str] | None:
|
|
||||||
"""Составной ключ join'а: (регион, населённый пункт, улица). None — мусор.
|
|
||||||
|
|
||||||
`city` (у deals колонка заполнена на 100% и надёжнее текста адреса)
|
|
||||||
переопределяет НП, разобранный из адреса.
|
|
||||||
|
|
||||||
Пустой НП: у региона С городом-ядром (66) подставляется `city_token` —
|
|
||||||
ровно историческое поведение «всё, что без города, это Екатеринбург». У
|
|
||||||
региона БЕЗ ядра (50) подставлять нечего, и ключ отбрасывается: сделка без
|
|
||||||
распознанного НП лучше останется без координат, чем сядет в случайный
|
|
||||||
город области.
|
|
||||||
"""
|
|
||||||
place, street = _split_place_street(address, region_code)
|
|
||||||
from_city = _normalize_place(city)
|
|
||||||
if from_city:
|
|
||||||
place = from_city
|
|
||||||
if len(street) < _MIN_KEY_LEN:
|
|
||||||
return None
|
|
||||||
if not place:
|
|
||||||
region = REGIONS[region_code]
|
|
||||||
if not region.has_city_core:
|
|
||||||
return None
|
|
||||||
place = region.city_token
|
|
||||||
if len(place) < _MIN_KEY_LEN:
|
|
||||||
return None
|
|
||||||
return (region_code, place, street)
|
|
||||||
|
|
||||||
|
|
||||||
def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) -> float:
|
|
||||||
"""Максимальное удаление точки ведра от его центроида, км (equirectangular).
|
|
||||||
|
|
||||||
Проекция плоская — на масштабе одного НП (единицы километров) ошибка
|
|
||||||
сотые доли процента, а формула дешевле haversine на каждом из десятков
|
|
||||||
тысяч домов.
|
|
||||||
"""
|
|
||||||
worst = 0.0
|
|
||||||
cos_lat = math.cos(math.radians(lat_c))
|
|
||||||
for lat, lon in points:
|
|
||||||
dy = (lat - lat_c) * 111.32
|
|
||||||
dx = (lon - lon_c) * 111.32 * cos_lat
|
|
||||||
worst = max(worst, math.hypot(dx, dy))
|
|
||||||
return worst
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -412,13 +253,11 @@ def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) ->
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Centroid:
|
class Centroid:
|
||||||
"""Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
|
"""One street's centroid, averaged over all geocoded houses on it."""
|
||||||
|
|
||||||
lat: float
|
lat: float
|
||||||
lon: float
|
lon: float
|
||||||
house_count: int
|
house_count: int
|
||||||
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
|
|
||||||
spread_km: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -427,9 +266,6 @@ class DealRow:
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
address: str | None
|
address: str | None
|
||||||
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
|
|
||||||
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
|
|
||||||
city: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -440,8 +276,6 @@ class Stats:
|
||||||
geocoded: int = 0
|
geocoded: int = 0
|
||||||
no_street_match: int = 0
|
no_street_match: int = 0
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
|
|
||||||
dropped_spread: int = 0
|
|
||||||
# street_key → count of deals that had no house centroid (dry-run report).
|
# street_key → count of deals that had no house centroid (dry-run report).
|
||||||
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
||||||
|
|
||||||
|
|
@ -451,28 +285,14 @@ class Stats:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_centroid_map(
|
def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
||||||
db: Session,
|
"""Per-street centroid from houses WHERE geom IS NOT NULL.
|
||||||
region_code: int = _DEFAULT_REGION_CODE,
|
|
||||||
max_spread_km: float = _DEFAULT_MAX_SPREAD_KM,
|
|
||||||
) -> dict[tuple[int, str, str], Centroid]:
|
|
||||||
"""Центроиды по ключу (регион, НП, улица) из houses с координатами.
|
|
||||||
|
|
||||||
Фильтр региона обязателен: без него карта строится по домам ВСЕХ регионов,
|
We read raw (address, lat, lon) and aggregate in Python so the street-key
|
||||||
и прогон по одному региону тихо тащит чужие координаты.
|
derivation is the SAME code path as the deals side — pushing it into SQL
|
||||||
|
would require duplicating the regex logic in plpgsql and risk drift.
|
||||||
`houses.region_code` добавлен поздней миграцией (272_houses_region_code.sql)
|
8,600 rows is trivial to hold in memory.
|
||||||
и у части строк NULL. Отбрасывать их нельзя (это ударило бы по покрытию 66),
|
|
||||||
поэтому строка без региона принимается по географии — если её координаты
|
|
||||||
внутри `bbox_region` реестра. Это именно гео-проверка, а не догадка о
|
|
||||||
происхождении строки.
|
|
||||||
|
|
||||||
Агрегация в Python, а не в SQL: вывод ключа должен быть ОДНИМ И ТЕМ ЖЕ
|
|
||||||
кодом на обеих сторонах join'а, дублировать регексы в plpgsql — верный
|
|
||||||
дрейф.
|
|
||||||
"""
|
"""
|
||||||
region = REGIONS[region_code]
|
|
||||||
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -483,80 +303,31 @@ def _build_centroid_map(
|
||||||
" AND lon IS NOT NULL "
|
" AND lon IS NOT NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) > 0"
|
" AND length(trim(address)) > 0"
|
||||||
" AND ( region_code = CAST(:rc AS smallint) "
|
)
|
||||||
" OR ( region_code IS NULL "
|
|
||||||
" AND lat BETWEEN CAST(:lat_min AS double precision) "
|
|
||||||
" AND CAST(:lat_max AS double precision) "
|
|
||||||
" AND lon BETWEEN CAST(:lon_min AS double precision) "
|
|
||||||
" AND CAST(:lon_max AS double precision) ) )"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"rc": region_code,
|
|
||||||
"lat_min": lat_min,
|
|
||||||
"lat_max": lat_max,
|
|
||||||
"lon_min": lon_min,
|
|
||||||
"lon_max": lon_max,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
|
# street_key → running [lat_sum, lon_sum, n]
|
||||||
|
acc: dict[str, list[float]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
key = _address_key(r["address"], region_code)
|
key = _street_key(r["address"])
|
||||||
if key is None:
|
if len(key) < _MIN_KEY_LEN:
|
||||||
continue
|
continue
|
||||||
acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
|
bucket = acc.setdefault(key, [0.0, 0.0, 0.0])
|
||||||
|
bucket[0] += float(r["lat"])
|
||||||
|
bucket[1] += float(r["lon"])
|
||||||
|
bucket[2] += 1.0
|
||||||
|
|
||||||
out: dict[tuple[int, str, str], Centroid] = {}
|
return {
|
||||||
dropped = 0
|
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n))
|
||||||
for key, points in acc.items():
|
for key, (lat_sum, lon_sum, n) in acc.items()
|
||||||
n = len(points)
|
}
|
||||||
lat_c = sum(lat for lat, _ in points) / n
|
|
||||||
lon_c = sum(lon for _, lon in points) / n
|
|
||||||
spread = _spread_km(points, lat_c, lon_c)
|
|
||||||
if spread > max_spread_km:
|
|
||||||
# Одна «улица» шириной в десятки километров — это не улица, а
|
|
||||||
# слипшиеся одноимённые улицы разных НП (или один НП, записанный
|
|
||||||
# двумя способами). Среднее таких точек — координата в поле между
|
|
||||||
# городами; отдавать её сделке нельзя, ведро выбрасывается.
|
|
||||||
dropped += 1
|
|
||||||
logger.warning(
|
|
||||||
"centroid bucket dropped: key=%s houses=%d spread=%.1f km > %.1f km",
|
|
||||||
key,
|
|
||||||
n,
|
|
||||||
spread,
|
|
||||||
max_spread_km,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
out[key] = Centroid(lat=lat_c, lon=lon_c, house_count=n, spread_km=spread)
|
|
||||||
|
|
||||||
if dropped:
|
|
||||||
logger.warning(
|
|
||||||
"sanity: %d/%d вёдер выброшено по разбросу > %.1f km",
|
|
||||||
dropped,
|
|
||||||
len(acc),
|
|
||||||
max_spread_km,
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _region_predicate(column: str = "region_code") -> str:
|
def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
||||||
"""SQL-предикат «строка принадлежит региону :rc» для deals.
|
"""deals needing coords (lat IS NULL) — resume-safe candidate set.
|
||||||
|
|
||||||
`deals.region_code` проставлен миграцией 177_deals_city_region.sql; строки,
|
|
||||||
существовавшие ДО неё, по построению екатеринбургские (в скрипт импорта
|
|
||||||
был зашит префикс 'Екатеринбург, '), поэтому NULL засчитывается региону 66
|
|
||||||
и только ему. Для любого другого региона NULL — не кандидат.
|
|
||||||
"""
|
|
||||||
return f"( {column} = CAST(:rc AS int) OR ( {column} IS NULL AND CAST(:rc AS int) = 66 ) )"
|
|
||||||
|
|
||||||
|
|
||||||
def _select_deals_without_coords(
|
|
||||||
db: Session, limit: int, region_code: int = _DEFAULT_REGION_CODE
|
|
||||||
) -> list[DealRow]:
|
|
||||||
"""deals needing coords (lat IS NULL) в пределах ОДНОГО региона.
|
|
||||||
|
|
||||||
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
|
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
|
||||||
UPDATE sets lat NOT NULL, dropping the row out on the next run.
|
UPDATE sets lat NOT NULL, dropping the row out on the next run.
|
||||||
|
|
@ -564,21 +335,20 @@ def _select_deals_without_coords(
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, address, city "
|
"SELECT id, address "
|
||||||
"FROM deals "
|
"FROM deals "
|
||||||
"WHERE lat IS NULL "
|
"WHERE lat IS NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) > 0 "
|
" AND length(trim(address)) > 0 "
|
||||||
" AND " + _region_predicate() + " "
|
|
||||||
"ORDER BY id "
|
"ORDER BY id "
|
||||||
"LIMIT CAST(:lim AS int)"
|
"LIMIT CAST(:lim AS int)"
|
||||||
),
|
),
|
||||||
{"lim": limit, "rc": region_code},
|
{"lim": limit},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [DealRow(id=r["id"], address=r["address"], city=r.get("city")) for r in rows]
|
return [DealRow(id=r["id"], address=r["address"]) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -586,14 +356,7 @@ def _select_deals_without_coords(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _update_deal_coords(
|
def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> None:
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
deal_id: int,
|
|
||||||
lat: float,
|
|
||||||
lon: float,
|
|
||||||
region_code: int = _DEFAULT_REGION_CODE,
|
|
||||||
) -> None:
|
|
||||||
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
|
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
|
||||||
|
|
||||||
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
||||||
|
|
@ -608,9 +371,8 @@ def _update_deal_coords(
|
||||||
" lon = CAST(:lon AS double precision), "
|
" lon = CAST(:lon AS double precision), "
|
||||||
" geocode_tried_at = NOW() "
|
" geocode_tried_at = NOW() "
|
||||||
" WHERE id = CAST(:id AS bigint)"
|
" WHERE id = CAST(:id AS bigint)"
|
||||||
" AND " + _region_predicate()
|
|
||||||
),
|
),
|
||||||
{"id": deal_id, "lat": lat, "lon": lon, "rc": region_code},
|
{"id": deal_id, "lat": lat, "lon": lon},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -622,11 +384,10 @@ def _update_deal_coords(
|
||||||
def _run_backfill(
|
def _run_backfill(
|
||||||
db: Session,
|
db: Session,
|
||||||
deals: list[DealRow],
|
deals: list[DealRow],
|
||||||
centroids: dict[tuple[int, str, str], Centroid],
|
centroids: dict[str, Centroid],
|
||||||
*,
|
*,
|
||||||
batch: str,
|
batch: str,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
region_code: int = _DEFAULT_REGION_CODE,
|
|
||||||
) -> Stats:
|
) -> Stats:
|
||||||
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
||||||
|
|
||||||
|
|
@ -638,14 +399,14 @@ def _run_backfill(
|
||||||
stats = Stats()
|
stats = Stats()
|
||||||
|
|
||||||
for i, deal in enumerate(deals, start=1):
|
for i, deal in enumerate(deals, start=1):
|
||||||
key = _address_key(deal.address, region_code, city=deal.city)
|
key = _street_key(deal.address)
|
||||||
centroid = centroids.get(key) if key is not None else None
|
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None
|
||||||
|
|
||||||
if centroid is None:
|
if centroid is None:
|
||||||
stats.no_street_match += 1
|
stats.no_street_match += 1
|
||||||
# Track the raw key (or a sentinel) so the dry-run report can show
|
# Track the raw key (or a sentinel) so the dry-run report can show
|
||||||
# which streets we're missing. Empty key → '<no-street-parsed>'.
|
# which streets we're missing. Empty key → '<no-street-parsed>'.
|
||||||
stats.unmatched_streets["/".join(key[1:]) if key else "<no-street-parsed>"] += 1
|
stats.unmatched_streets[key or "<no-street-parsed>"] += 1
|
||||||
stats.processed += 1
|
stats.processed += 1
|
||||||
if dry_run and i % _LOG_EVERY == 0:
|
if dry_run and i % _LOG_EVERY == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -662,13 +423,7 @@ def _run_backfill(
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
_update_deal_coords(
|
_update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon)
|
||||||
db,
|
|
||||||
deal_id=deal.id,
|
|
||||||
lat=centroid.lat,
|
|
||||||
lon=centroid.lon,
|
|
||||||
region_code=region_code,
|
|
||||||
)
|
|
||||||
# Per-row commit so resume picks up exactly where we crashed.
|
# Per-row commit so resume picks up exactly where we crashed.
|
||||||
db.commit()
|
db.commit()
|
||||||
stats.geocoded += 1
|
stats.geocoded += 1
|
||||||
|
|
@ -699,7 +454,7 @@ def _run_backfill(
|
||||||
|
|
||||||
def _report_dry_run(
|
def _report_dry_run(
|
||||||
stats: Stats,
|
stats: Stats,
|
||||||
centroids: dict[tuple[int, str, str], Centroid],
|
centroids: dict[str, Centroid],
|
||||||
*,
|
*,
|
||||||
total_deals_null: int,
|
total_deals_null: int,
|
||||||
candidates: int,
|
candidates: int,
|
||||||
|
|
@ -720,7 +475,7 @@ def _report_dry_run(
|
||||||
|
|
||||||
logger.info("─" * 60)
|
logger.info("─" * 60)
|
||||||
logger.info("DRY-RUN SUMMARY (no DB writes)")
|
logger.info("DRY-RUN SUMMARY (no DB writes)")
|
||||||
logger.info("distinct (region, НП, улица) keys with a house centroid: %d", distinct_streets)
|
logger.info("distinct streets with a house centroid: %d", distinct_streets)
|
||||||
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
|
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
|
||||||
logger.info("deals matched to a centroid: %d", matched)
|
logger.info("deals matched to a centroid: %d", matched)
|
||||||
logger.info("deals with no street match: %d", stats.no_street_match)
|
logger.info("deals with no street match: %d", stats.no_street_match)
|
||||||
|
|
@ -731,7 +486,7 @@ def _report_dry_run(
|
||||||
projected,
|
projected,
|
||||||
match_rate * 100.0,
|
match_rate * 100.0,
|
||||||
)
|
)
|
||||||
logger.info("top-10 unmatched deal keys НП/улица (by row count):")
|
logger.info("top-10 unmatched deal streets (by row count):")
|
||||||
for street, cnt in stats.unmatched_streets.most_common(10):
|
for street, cnt in stats.unmatched_streets.most_common(10):
|
||||||
logger.info(" %6d %s", cnt, street)
|
logger.info(" %6d %s", cnt, street)
|
||||||
logger.info("─" * 60)
|
logger.info("─" * 60)
|
||||||
|
|
@ -742,15 +497,13 @@ def _report_dry_run(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
|
def _count_deals_null(db: Session) -> int:
|
||||||
"""Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
|
"""Full count of deals WHERE lat IS NULL — denominator for projection."""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT count(*) AS n FROM deals "
|
"SELECT count(*) AS n FROM deals "
|
||||||
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0"
|
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0"
|
||||||
" AND " + _region_predicate()
|
)
|
||||||
),
|
|
||||||
{"rc": region_code},
|
|
||||||
).first()
|
).first()
|
||||||
return int(row[0]) if row else 0
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
|
@ -774,25 +527,6 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
default=f"deals_geo_{date.today().isoformat()}",
|
default=f"deals_geo_{date.today().isoformat()}",
|
||||||
help="Log batch label (does not affect DB filters — logs only).",
|
help="Log batch label (does not affect DB filters — logs only).",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
|
||||||
"--region-code",
|
|
||||||
type=int,
|
|
||||||
default=_DEFAULT_REGION_CODE,
|
|
||||||
choices=sorted(REGIONS),
|
|
||||||
help=(
|
|
||||||
"Регион прогона (default %(default)s). Отбирает и обновляет ТОЛЬКО "
|
|
||||||
"строки этого региона; входит в ключ join'а."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--max-spread-km",
|
|
||||||
type=float,
|
|
||||||
default=_DEFAULT_MAX_SPREAD_KM,
|
|
||||||
help=(
|
|
||||||
"Порог разброса домов внутри ключа (default %(default)s км). Ведро "
|
|
||||||
"с большим разбросом — склейка нескольких НП, оно выбрасывается."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -809,47 +543,30 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entry point. Returns the number of deals geocoded this run."""
|
"""CLI entry point. Returns the number of deals geocoded this run."""
|
||||||
args = _parse_args(argv)
|
args = _parse_args(argv)
|
||||||
logger.info(
|
logger.info(
|
||||||
"starting batch=%s region=%s limit=%s max_spread_km=%s dry_run=%s",
|
"starting batch=%s limit=%s dry_run=%s",
|
||||||
args.batch,
|
args.batch,
|
||||||
args.region_code,
|
|
||||||
args.limit,
|
args.limit,
|
||||||
args.max_spread_km,
|
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
centroids = _build_centroid_map(
|
centroids = _build_centroid_map(db)
|
||||||
db, region_code=args.region_code, max_spread_km=args.max_spread_km
|
logger.info("built centroid map: %d distinct streets", len(centroids))
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"built centroid map: %d distinct keys (region %s)", len(centroids), args.region_code
|
|
||||||
)
|
|
||||||
if not centroids:
|
if not centroids:
|
||||||
logger.warning(
|
logger.warning("no house centroids — houses table has no geocoded rows; nothing to do")
|
||||||
"no house centroids for region %s — houses has no geocoded rows there; "
|
|
||||||
"nothing to do",
|
|
||||||
args.region_code,
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
deals = _select_deals_without_coords(db, args.limit, region_code=args.region_code)
|
deals = _select_deals_without_coords(db, args.limit)
|
||||||
logger.info("loaded deals without coords: %d", len(deals))
|
logger.info("loaded deals without coords: %d", len(deals))
|
||||||
if not deals:
|
if not deals:
|
||||||
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
stats = _run_backfill(
|
stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run)
|
||||||
db,
|
|
||||||
deals,
|
|
||||||
centroids,
|
|
||||||
batch=args.batch,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
region_code=args.region_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
total_null = _count_deals_null(db, region_code=args.region_code)
|
total_null = _count_deals_null(db)
|
||||||
_report_dry_run(
|
_report_dry_run(
|
||||||
stats,
|
stats,
|
||||||
centroids,
|
centroids,
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,21 @@
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"high": {
|
"high": {
|
||||||
"coverage_pct": 75.0,
|
"coverage_pct": 75.0,
|
||||||
"mape_pct": 19.6,
|
"mape_pct": 25.94,
|
||||||
"n": 8,
|
"n": 8,
|
||||||
"n_covered": 6
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 83.59,
|
"coverage_pct": 87.41,
|
||||||
"mape_pct": 15.64,
|
"mape_pct": 14.06,
|
||||||
"n": 1562,
|
"n": 1562,
|
||||||
"n_covered": 1029
|
"n_covered": 1076
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 70.0,
|
"coverage_pct": 73.33,
|
||||||
"mape_pct": 27.59,
|
"mape_pct": 18.64,
|
||||||
"n": 30,
|
"n": 30,
|
||||||
"n_covered": 21
|
"n_covered": 22
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confidence_order": [
|
"confidence_order": [
|
||||||
|
|
@ -26,111 +26,111 @@
|
||||||
],
|
],
|
||||||
"expected_sold": {
|
"expected_sold": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"mape_pct": 15.88,
|
"mape_pct": 14.28,
|
||||||
"median_bias_pct": 2.87,
|
"median_bias_pct": -1.61,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -10.88,
|
"p25_pct": -14.08,
|
||||||
"p75_pct": 22.98
|
"p75_pct": 14.6
|
||||||
},
|
},
|
||||||
"per_area_bucket": {
|
"per_area_bucket": {
|
||||||
"0 <30": {
|
"0 <30": {
|
||||||
"mape_pct": 19.76,
|
"mape_pct": 16.51,
|
||||||
"median_bias_pct": -1.49,
|
"median_bias_pct": -3.13,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"p25_pct": -16.45,
|
"p25_pct": -14.08,
|
||||||
"p75_pct": 34.7
|
"p75_pct": 22.87
|
||||||
},
|
},
|
||||||
"1 30-44": {
|
"1 30-44": {
|
||||||
"mape_pct": 15.64,
|
"mape_pct": 14.06,
|
||||||
"median_bias_pct": 4.37,
|
"median_bias_pct": -2.33,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"p25_pct": -10.58,
|
"p25_pct": -14.49,
|
||||||
"p75_pct": 22.86
|
"p75_pct": 12.58
|
||||||
},
|
},
|
||||||
"2 44-62": {
|
"2 44-62": {
|
||||||
"mape_pct": 13.86,
|
"mape_pct": 12.48,
|
||||||
"median_bias_pct": 4.15,
|
"median_bias_pct": -0.39,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"p25_pct": -7.92,
|
"p25_pct": -11.23,
|
||||||
"p75_pct": 23.85
|
"p75_pct": 14.65
|
||||||
},
|
},
|
||||||
"3 62-85": {
|
"3 62-85": {
|
||||||
"mape_pct": 15.97,
|
"mape_pct": 15.24,
|
||||||
"median_bias_pct": -2.33,
|
"median_bias_pct": -1.94,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"p25_pct": -12.1,
|
"p25_pct": -14.98,
|
||||||
"p75_pct": 18.18
|
"p75_pct": 15.27
|
||||||
},
|
},
|
||||||
"4 >=85": {
|
"4 >=85": {
|
||||||
"mape_pct": 20.46,
|
"mape_pct": 20.88,
|
||||||
"median_bias_pct": -4.97,
|
"median_bias_pct": -10.36,
|
||||||
"n": 47,
|
"n": 47,
|
||||||
"p25_pct": -26.03,
|
"p25_pct": -24.08,
|
||||||
"p75_pct": 7.15
|
"p75_pct": 13.38
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"per_rooms": {
|
"per_rooms": {
|
||||||
"0": {
|
"0": {
|
||||||
"label": "студия",
|
"label": "студия",
|
||||||
"mape_pct": 19.76,
|
"mape_pct": 16.51,
|
||||||
"median_bias_pct": -1.49,
|
"median_bias_pct": -3.13,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -16.45,
|
"p25_pct": -14.08,
|
||||||
"p75_pct": 34.7
|
"p75_pct": 22.87
|
||||||
},
|
},
|
||||||
"1": {
|
"1": {
|
||||||
"label": "1к",
|
"label": "1к",
|
||||||
"mape_pct": 15.64,
|
"mape_pct": 14.06,
|
||||||
"median_bias_pct": 4.37,
|
"median_bias_pct": -2.33,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -10.58,
|
"p25_pct": -14.49,
|
||||||
"p75_pct": 22.86
|
"p75_pct": 12.58
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"label": "2к",
|
"label": "2к",
|
||||||
"mape_pct": 13.86,
|
"mape_pct": 12.48,
|
||||||
"median_bias_pct": 4.15,
|
"median_bias_pct": -0.39,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -7.92,
|
"p25_pct": -11.23,
|
||||||
"p75_pct": 23.85
|
"p75_pct": 14.65
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "3к",
|
"label": "3к",
|
||||||
"mape_pct": 15.97,
|
"mape_pct": 15.24,
|
||||||
"median_bias_pct": -2.33,
|
"median_bias_pct": -1.94,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -12.1,
|
"p25_pct": -14.98,
|
||||||
"p75_pct": 18.18
|
"p75_pct": 15.27
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"label": "4+",
|
"label": "4+",
|
||||||
"mape_pct": 20.46,
|
"mape_pct": 20.88,
|
||||||
"median_bias_pct": -4.97,
|
"median_bias_pct": -10.36,
|
||||||
"n": 47,
|
"n": 47,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -26.03,
|
"p25_pct": -24.08,
|
||||||
"p75_pct": 7.15
|
"p75_pct": 13.38
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"per_segment": {
|
"per_segment": {
|
||||||
"бизнес": {
|
"бизнес": {
|
||||||
"mape_pct": 19.58,
|
"mape_pct": 16.86,
|
||||||
"median_bias_pct": -19.58,
|
"median_bias_pct": -16.84,
|
||||||
"n": 144,
|
"n": 144,
|
||||||
"p25_pct": -27.32,
|
"p25_pct": -25.83,
|
||||||
"p75_pct": -12.79
|
"p75_pct": -9.36
|
||||||
},
|
},
|
||||||
"комфорт": {
|
"комфорт": {
|
||||||
"mape_pct": 10.68,
|
"mape_pct": 12.14,
|
||||||
"median_bias_pct": -5.78,
|
"median_bias_pct": -5.9,
|
||||||
"n": 425,
|
"n": 425,
|
||||||
"p25_pct": -15.88,
|
"p25_pct": -16.66,
|
||||||
"p75_pct": 4.49
|
"p75_pct": 4.9
|
||||||
},
|
},
|
||||||
"премиум": {
|
"премиум": {
|
||||||
"mape_pct": null,
|
"mape_pct": null,
|
||||||
|
|
@ -140,18 +140,18 @@
|
||||||
"p75_pct": null
|
"p75_pct": null
|
||||||
},
|
},
|
||||||
"эконом": {
|
"эконом": {
|
||||||
"mape_pct": 20.17,
|
"mape_pct": 15.17,
|
||||||
"median_bias_pct": 18.98,
|
"median_bias_pct": 6.26,
|
||||||
"n": 697,
|
"n": 697,
|
||||||
"p25_pct": 1.25,
|
"p25_pct": -6.89,
|
||||||
"p75_pct": 41.97
|
"p75_pct": 28.97
|
||||||
},
|
},
|
||||||
"элит": {
|
"элит": {
|
||||||
"mape_pct": 36.11,
|
"mape_pct": 31.18,
|
||||||
"median_bias_pct": -36.11,
|
"median_bias_pct": -31.18,
|
||||||
"n": 3,
|
"n": 3,
|
||||||
"p25_pct": -44.21,
|
"p25_pct": -46.94,
|
||||||
"p75_pct": -32.72
|
"p75_pct": -28.1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -162,9 +162,9 @@
|
||||||
},
|
},
|
||||||
"range_coverage": {
|
"range_coverage": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"coverage_pct": 83.22,
|
"coverage_pct": 87.0,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_covered": 1056
|
"n_covered": 1104
|
||||||
},
|
},
|
||||||
"per_confidence": {
|
"per_confidence": {
|
||||||
"high": {
|
"high": {
|
||||||
|
|
@ -173,14 +173,14 @@
|
||||||
"n_covered": 6
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 83.59,
|
"coverage_pct": 87.41,
|
||||||
"n": 1231,
|
"n": 1231,
|
||||||
"n_covered": 1029
|
"n_covered": 1076
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 70.0,
|
"coverage_pct": 73.33,
|
||||||
"n": 30,
|
"n": 30,
|
||||||
"n_covered": 21
|
"n_covered": 22
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,8 @@ from scripts.geocode_deals_from_houses import (
|
||||||
Centroid,
|
Centroid,
|
||||||
DealRow,
|
DealRow,
|
||||||
Stats,
|
Stats,
|
||||||
_address_key,
|
|
||||||
_build_centroid_map,
|
_build_centroid_map,
|
||||||
_run_backfill,
|
_run_backfill,
|
||||||
_select_deals_without_coords,
|
|
||||||
_street_key,
|
_street_key,
|
||||||
_update_deal_coords,
|
_update_deal_coords,
|
||||||
main,
|
main,
|
||||||
|
|
@ -180,16 +178,16 @@ def test_build_centroid_map_averages_houses_on_one_street():
|
||||||
"""Two houses on Малышева → centroid is the mean of their coords."""
|
"""Two houses on Малышева → centroid is the mean of their coords."""
|
||||||
house_rows = [
|
house_rows = [
|
||||||
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
|
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
|
||||||
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.82, "lon": 60.54},
|
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70},
|
||||||
]
|
]
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
|
|
||||||
assert set(centroids) == {(66, "екатеринбург", "малышева")}
|
assert set(centroids) == {"малышева"}
|
||||||
c = centroids[(66, "екатеринбург", "малышева")]
|
c = centroids["малышева"]
|
||||||
assert c.lat == pytest.approx(56.81)
|
assert c.lat == pytest.approx(56.85)
|
||||||
assert c.lon == pytest.approx(60.52)
|
assert c.lon == pytest.approx(60.60)
|
||||||
assert c.house_count == 2
|
assert c.house_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -203,15 +201,12 @@ def test_build_centroid_map_groups_distinct_streets():
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
|
|
||||||
assert set(centroids) == {
|
assert set(centroids) == {"малышева", "ленина"}
|
||||||
(66, "екатеринбург", "малышева"),
|
assert centroids["малышева"].house_count == 2
|
||||||
(66, "екатеринбург", "ленина"),
|
assert centroids["ленина"].house_count == 1
|
||||||
}
|
|
||||||
assert centroids[(66, "екатеринбург", "малышева")].house_count == 2
|
|
||||||
assert centroids[(66, "екатеринбург", "ленина")].house_count == 1
|
|
||||||
# Малышева centroid = mean of the two Малышева rows.
|
# Малышева centroid = mean of the two Малышева rows.
|
||||||
assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81)
|
assert centroids["малышева"].lat == pytest.approx(56.81)
|
||||||
assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84)
|
assert centroids["ленина"].lat == pytest.approx(56.84)
|
||||||
|
|
||||||
|
|
||||||
def test_build_centroid_map_skips_unparseable_address():
|
def test_build_centroid_map_skips_unparseable_address():
|
||||||
|
|
@ -223,7 +218,7 @@ def test_build_centroid_map_skips_unparseable_address():
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
assert set(centroids) == {(66, "екатеринбург", "малышева")}
|
assert set(centroids) == {"малышева"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -233,7 +228,7 @@ def test_build_centroid_map_skips_unparseable_address():
|
||||||
|
|
||||||
def test_run_backfill_matched_street_issues_update_with_centroid():
|
def test_run_backfill_matched_street_issues_update_with_centroid():
|
||||||
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
|
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
|
||||||
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.838, lon=60.586, house_count=3)}
|
centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
|
||||||
|
|
@ -252,7 +247,7 @@ def test_run_backfill_matched_street_issues_update_with_centroid():
|
||||||
def test_run_backfill_deal_with_only_street_name_matches():
|
def test_run_backfill_deal_with_only_street_name_matches():
|
||||||
"""Deal address with no house number / no type word still matches."""
|
"""Deal address with no house number / no type word still matches."""
|
||||||
deal = DealRow(id=7, address="Екатеринбург, Малышева")
|
deal = DealRow(id=7, address="Екатеринбург, Малышева")
|
||||||
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -269,7 +264,7 @@ def test_run_backfill_deal_with_only_street_name_matches():
|
||||||
|
|
||||||
def test_run_backfill_unmatched_street_no_update():
|
def test_run_backfill_unmatched_street_no_update():
|
||||||
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
|
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
|
||||||
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -280,13 +275,13 @@ def test_run_backfill_unmatched_street_no_update():
|
||||||
assert updated == []
|
assert updated == []
|
||||||
assert db.commit.call_count == 0
|
assert db.commit.call_count == 0
|
||||||
# Unmatched street tracked for the dry-run report.
|
# Unmatched street tracked for the dry-run report.
|
||||||
assert stats.unmatched_streets["екатеринбург/несуществующая"] == 1
|
assert stats.unmatched_streets["несуществующая"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
||||||
"""A deal whose address yields an empty key is a no-match under a sentinel."""
|
"""A deal whose address yields an empty key is a no-match under a sentinel."""
|
||||||
deal = DealRow(id=5, address="Екатеринбург")
|
deal = DealRow(id=5, address="Екатеринбург")
|
||||||
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -303,7 +298,7 @@ def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
||||||
|
|
||||||
def test_run_backfill_dry_run_issues_no_update():
|
def test_run_backfill_dry_run_issues_no_update():
|
||||||
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
|
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
|
||||||
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
|
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
|
||||||
|
|
@ -324,7 +319,7 @@ def test_run_backfill_db_write_failure_isolated_to_row():
|
||||||
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
|
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
|
||||||
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
|
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
|
||||||
]
|
]
|
||||||
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
# Make the FIRST UPDATE raise, the rest succeed.
|
# Make the FIRST UPDATE raise, the rest succeed.
|
||||||
|
|
@ -364,7 +359,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
|
||||||
assert "geocode_tried_at = NOW()" in sql_str
|
assert "geocode_tried_at = NOW()" in sql_str
|
||||||
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
|
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
|
||||||
assert "geom" not in sql_str
|
assert "geom" not in sql_str
|
||||||
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2, "rc": 66}
|
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -375,7 +370,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
|
||||||
def test_main_respects_limit_and_returns_geocoded():
|
def test_main_respects_limit_and_returns_geocoded():
|
||||||
house_rows = [
|
house_rows = [
|
||||||
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
|
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
|
||||||
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.82, "lon": 60.54},
|
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70},
|
||||||
]
|
]
|
||||||
deal_rows = [
|
deal_rows = [
|
||||||
{"id": 1, "address": "Екатеринбург, Малышева"},
|
{"id": 1, "address": "Екатеринбург, Малышева"},
|
||||||
|
|
@ -391,8 +386,8 @@ def test_main_respects_limit_and_returns_geocoded():
|
||||||
assert len(updated) == 1
|
assert len(updated) == 1
|
||||||
assert updated[0]["id"] == 1
|
assert updated[0]["id"] == 1
|
||||||
# Centroid used = mean of the two house rows.
|
# Centroid used = mean of the two house rows.
|
||||||
assert updated[0]["lat"] == pytest.approx(56.81)
|
assert updated[0]["lat"] == pytest.approx(56.85)
|
||||||
assert updated[0]["lon"] == pytest.approx(60.52)
|
assert updated[0]["lon"] == pytest.approx(60.60)
|
||||||
|
|
||||||
|
|
||||||
def test_main_dry_run_writes_nothing():
|
def test_main_dry_run_writes_nothing():
|
||||||
|
|
@ -437,114 +432,3 @@ def test_stats_unmatched_streets_counter_defaults_empty():
|
||||||
assert s.no_street_match == 0
|
assert s.no_street_match == 0
|
||||||
assert s.failed == 0
|
assert s.failed == 0
|
||||||
assert s.unmatched_streets.most_common(3) == []
|
assert s.unmatched_streets.most_common(3) == []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Одноимённые улицы разных НП + однорегиональность прогона (fix/oblast-deal-geocoding)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_address_key_distinguishes_same_street_in_different_localities():
|
|
||||||
"""«Луговая» в Сабурово и «Луговая» в Красногорске — РАЗНЫЕ ключи.
|
|
||||||
|
|
||||||
Ровно та ловушка, ради которой ключ стал составным: до этого обе улицы
|
|
||||||
сливались в одно ведро 'луговая', и центроид садился между городами.
|
|
||||||
"""
|
|
||||||
saburovo = _address_key("Сабурово, Луговая", 50)
|
|
||||||
krasnogorsk = _address_key("Красногорск, Луговая", 50)
|
|
||||||
assert saburovo == (50, "сабурово", "луговая")
|
|
||||||
assert krasnogorsk == (50, "красногорск", "луговая")
|
|
||||||
assert saburovo != krasnogorsk
|
|
||||||
|
|
||||||
|
|
||||||
def test_address_key_distinguishes_same_street_in_different_regions():
|
|
||||||
"""Регион — тоже часть ключа: «Ленина» в Химках ≠ «Ленина» в Екатеринбурге."""
|
|
||||||
assert _address_key("г Химки, ул Ленина, 5", 50) == (50, "химки", "ленина")
|
|
||||||
assert _address_key("Екатеринбург, ул. Ленина, 5", 66) == (66, "екатеринбург", "ленина")
|
|
||||||
|
|
||||||
|
|
||||||
def test_address_key_region_without_city_core_refuses_nameless_locality():
|
|
||||||
"""У области НП обязателен: без него ключа нет (а не «пусть будет Красногорск»)."""
|
|
||||||
assert _address_key("Луговая, 5", 50) is None
|
|
||||||
# У 66 город-ядро есть — историческое поведение сохранено.
|
|
||||||
assert _address_key("Малышева, 125", 66) == (66, "екатеринбург", "малышева")
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_centroid_map_separates_same_street_of_two_localities():
|
|
||||||
"""Дома с одноимённых улиц двух НП не усредняются в одну точку."""
|
|
||||||
house_rows = [
|
|
||||||
{"address": "Московская обл., д Сабурово, ул Луговая, 1", "lat": 55.40, "lon": 37.60},
|
|
||||||
{"address": "Московская обл., д Сабурово, ул Луговая, 3", "lat": 55.41, "lon": 37.61},
|
|
||||||
{"address": "Московская обл., г Химки, ул Луговая, 2", "lat": 55.89, "lon": 37.43},
|
|
||||||
]
|
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
|
||||||
|
|
||||||
centroids = _build_centroid_map(db, region_code=50)
|
|
||||||
|
|
||||||
assert set(centroids) == {(50, "сабурово", "луговая"), (50, "химки", "луговая")}
|
|
||||||
assert centroids[(50, "сабурово", "луговая")].house_count == 2
|
|
||||||
assert centroids[(50, "сабурово", "луговая")].lat == pytest.approx(55.405)
|
|
||||||
assert centroids[(50, "химки", "луговая")].lat == pytest.approx(55.89)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_centroid_map_drops_bucket_with_huge_spread():
|
|
||||||
"""Ведро с разбросом в десятки км — склейка НП, выбрасывается целиком."""
|
|
||||||
house_rows = [
|
|
||||||
{"address": "Московская обл., г Химки, ул Ленина, 1", "lat": 55.89, "lon": 37.43},
|
|
||||||
# Тот же ключ, но точка в 60+ км — так выглядит склейка двух НП.
|
|
||||||
{"address": "Московская обл., г Химки, ул Ленина, 2", "lat": 55.30, "lon": 38.20},
|
|
||||||
]
|
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
|
||||||
|
|
||||||
assert _build_centroid_map(db, region_code=50) == {}
|
|
||||||
# Порог поднят выше разброса → ведро остаётся.
|
|
||||||
kept = _build_centroid_map(db, region_code=50, max_spread_km=100.0)
|
|
||||||
assert set(kept) == {(50, "химки", "ленина")}
|
|
||||||
|
|
||||||
|
|
||||||
def test_centroid_query_filters_by_region():
|
|
||||||
"""Карта центроидов строится по домам ОДНОГО региона, а не по всем."""
|
|
||||||
db, _ = _make_db_mock(house_rows=[])
|
|
||||||
|
|
||||||
_build_centroid_map(db, region_code=50)
|
|
||||||
|
|
||||||
args, _kw = db.execute.call_args
|
|
||||||
sql_str = str(args[0])
|
|
||||||
assert "FROM houses" in sql_str
|
|
||||||
assert "region_code = CAST(:rc AS smallint)" in sql_str
|
|
||||||
assert args[1]["rc"] == 50
|
|
||||||
|
|
||||||
|
|
||||||
def test_candidate_select_and_update_are_scoped_to_one_region():
|
|
||||||
"""Прогон по региону 50 не выбирает и не обновляет строки другого региона."""
|
|
||||||
db, _unused = _make_db_mock(
|
|
||||||
deal_rows=[{"id": 7, "address": "Сабурово, Луговая", "city": "Сабурово"}]
|
|
||||||
)
|
|
||||||
|
|
||||||
deals = _select_deals_without_coords(db, 10, region_code=50)
|
|
||||||
args, _kw = db.execute.call_args
|
|
||||||
select_sql = str(args[0])
|
|
||||||
assert "FROM deals" in select_sql
|
|
||||||
assert "region_code = CAST(:rc AS int)" in select_sql
|
|
||||||
assert args[1]["rc"] == 50
|
|
||||||
assert deals[0].city == "Сабурово"
|
|
||||||
|
|
||||||
_update_deal_coords(db, deal_id=7, lat=55.4, lon=37.6, region_code=50)
|
|
||||||
args, _kw = db.execute.call_args
|
|
||||||
update_sql = str(args[0])
|
|
||||||
assert "UPDATE deals" in update_sql
|
|
||||||
assert "region_code = CAST(:rc AS int)" in update_sql
|
|
||||||
assert args[1]["rc"] == 50
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_backfill_uses_deal_city_column_for_the_locality():
|
|
||||||
"""deals.city (заполнена на 100% по области) переопределяет НП из адреса."""
|
|
||||||
centroids = {(50, "сабурово", "луговая"): Centroid(lat=55.4, lon=37.6, house_count=2)}
|
|
||||||
db, updated = _make_db_mock()
|
|
||||||
deals = [DealRow(id=11, address="Луговая", city="Сабурово")]
|
|
||||||
|
|
||||||
stats = _run_backfill(db, deals, centroids, batch="t", dry_run=False, region_code=50)
|
|
||||||
|
|
||||||
assert stats.geocoded == 1
|
|
||||||
assert updated[0]["lat"] == pytest.approx(55.4)
|
|
||||||
assert updated[0]["rc"] == 50
|
|
||||||
|
|
|
||||||
|
|
@ -829,46 +829,6 @@ async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog)
|
||||||
assert dadata._clean_disabled_warned is True
|
assert dadata._clean_disabled_warned is True
|
||||||
|
|
||||||
|
|
||||||
# Вымышленный токен ТОЙ ЖЕ формы, что настоящий DaData API-ключ (40 hex-символов) — НЕ
|
|
||||||
# реальный секрет. Прод-факт: DaData на 403 кладёт действующий токен в тело ответа
|
|
||||||
# открытым текстом, и body_preview копирует его в WARNING/ERROR лог как есть (#3471).
|
|
||||||
FAKE_LEAKED_TOKEN = "deadbeef1234deadbeef1234deadbeef12345678"
|
|
||||||
|
|
||||||
CLEAN_TOKEN_LEAK_BODY = {
|
|
||||||
"timestamp": "2026-09-13T10:00:00.000+00:00",
|
|
||||||
"status": 403,
|
|
||||||
"error": "Forbidden",
|
|
||||||
"message": (
|
|
||||||
f"Feature 'CLEAN' disabled for token '{FAKE_LEAKED_TOKEN}'. "
|
|
||||||
"See https://dadata.userecho.com/topics/7784 for help."
|
|
||||||
),
|
|
||||||
"path": "/api/v1/clean/address",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_clean_address_masks_token_in_feature_disabled_log(caplog) -> None:
|
|
||||||
"""403 body в реалистичной прод-форме (40-символьный токен) — токен НЕ должен светиться в логе.
|
|
||||||
|
|
||||||
Отличие от test_clean_address_logs_feature_disabled_distinctly: тот тест использует
|
|
||||||
укороченный токен 'xxx' и не проверяет утечку. Здесь — токен длины настоящего DaData-ключа,
|
|
||||||
именно такая форма ушла в docker logs на проде (#3471).
|
|
||||||
"""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
|
|
||||||
|
|
||||||
transport = _mock_transport_returning(403, CLEAN_TOKEN_LEAK_BODY)
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
|
|
||||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
text = caplog.text
|
|
||||||
assert FAKE_LEAKED_TOKEN not in text, "токен утёк в лог-запись открытым текстом"
|
|
||||||
# Диагностика (какая услуга выключена) обязана сохраниться несмотря на маскирование.
|
|
||||||
assert "Стандартизация" in text or "выключена" in text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||||
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
||||||
from app.services import dadata
|
from app.services import dadata
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,6 @@ from app.services import location_index as lc
|
||||||
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
||||||
_LAT_IN_EKB = 56.838
|
_LAT_IN_EKB = 56.838
|
||||||
_LON_IN_EKB = 60.605
|
_LON_IN_EKB = 60.605
|
||||||
_REGION_66 = lc.regions_mod.REGIONS[66]
|
|
||||||
|
|
||||||
# A point well inside Moscow's product core (within MKAD) — #3051 region 77.
|
|
||||||
_LAT_IN_MSK = 55.75
|
|
||||||
_LON_IN_MSK = 37.62
|
|
||||||
_REGION_77 = lc.regions_mod.REGIONS[77]
|
|
||||||
|
|
||||||
# ── Pure functions ──────────────────────────────────────────────────────────
|
# ── Pure functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -124,7 +118,6 @@ def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||||||
lc._MEDIAN_PPM2_LOCAL_SQL,
|
lc._MEDIAN_PPM2_LOCAL_SQL,
|
||||||
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
||||||
lc._NEAREST_POI_SQL,
|
lc._NEAREST_POI_SQL,
|
||||||
lc._POI_COUNT_IN_REGION_SQL,
|
|
||||||
):
|
):
|
||||||
assert not re.search(r":\w+::", str(sql.text))
|
assert not re.search(r":\w+::", str(sql.text))
|
||||||
|
|
||||||
|
|
@ -149,51 +142,6 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
|
||||||
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
||||||
|
|
||||||
|
|
||||||
# ── #msk-defect-1: медианы обязаны фильтроваться по региону, не только по bbox ──
|
|
||||||
|
|
||||||
|
|
||||||
def test_median_queries_filter_by_region_code() -> None:
|
|
||||||
"""Прод-замер 13.09.2026: bbox продукт-ядра Москвы геометрически захватывает соседние
|
|
||||||
region_code=50 (Химки/Реутов/Котельники/Люберцы) — без фильтра по региону городская
|
|
||||||
медиана Москвы считалась с примесью 16 801 подмосковного лота и была занижена на 7.9%
|
|
||||||
(422 222 вместо 458 208 ₽/м²). У областных строк city IS NULL — фильтровать можно
|
|
||||||
только по region_code, не по имени города."""
|
|
||||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
|
||||||
sql_text = str(sql.text)
|
|
||||||
assert "region_code = CAST(:region_code AS integer)" in sql_text
|
|
||||||
|
|
||||||
|
|
||||||
def test_median_queries_do_not_admit_null_region_code() -> None:
|
|
||||||
"""Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается
|
|
||||||
ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox
|
|
||||||
ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними
|
|
||||||
151 101 ₽/м², без них 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), для 66
|
|
||||||
статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам
|
|
||||||
дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
|
|
||||||
Москвы (сегодня не проходят по свежести, но могут завтра) — `OR NULL` тихо открыл бы
|
|
||||||
им дорогу обратно в московскую медиану."""
|
|
||||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
|
||||||
sql_text = str(sql.text)
|
|
||||||
assert "region_code IS NULL" not in sql_text
|
|
||||||
assert "OR region_code" not in sql_text
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None:
|
|
||||||
"""region_code реально долетает bind-параметром и берётся ИЗ переданного региона, а не
|
|
||||||
захардкожен — иначе фикс работал бы только для 66 и молча ломался на следующем регионе."""
|
|
||||||
db66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000})])
|
|
||||||
lc._citywide_median_ppm2(db66, _REGION_66)
|
|
||||||
assert db66.executed[0][1]["region_code"] == 66
|
|
||||||
|
|
||||||
db77 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 458_208.0, "n": 82_719})])
|
|
||||||
lc._citywide_median_ppm2(db77, _REGION_77)
|
|
||||||
assert db77.executed[0][1]["region_code"] == 77
|
|
||||||
|
|
||||||
local66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25})])
|
|
||||||
lc._local_median_ppm2(local66, _LAT_IN_EKB, _LON_IN_EKB, 800, _REGION_66)
|
|
||||||
assert local66.executed[0][1]["region_code"] == 66
|
|
||||||
|
|
||||||
|
|
||||||
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
|
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -298,9 +246,7 @@ class _FakeDB:
|
||||||
|
|
||||||
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||||
db = _FakeDB([_FakeResult(scalar_value=0)])
|
db = _FakeDB([_FakeResult(scalar_value=0)])
|
||||||
poi, status = lc._fetch_nearby_poi(
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||||
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
|
|
||||||
)
|
|
||||||
assert poi == []
|
assert poi == []
|
||||||
assert status == "unavailable"
|
assert status == "unavailable"
|
||||||
assert len(db.executed) == 1 # only the count probe ran
|
assert len(db.executed) == 1 # only the count probe ran
|
||||||
|
|
@ -308,9 +254,7 @@ def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||||
|
|
||||||
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
||||||
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
||||||
poi, status = lc._fetch_nearby_poi(
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||||
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
|
|
||||||
)
|
|
||||||
assert poi == []
|
assert poi == []
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
|
|
||||||
|
|
@ -323,7 +267,7 @@ def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
|
||||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
||||||
]
|
]
|
||||||
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
||||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
assert len(poi) == 4
|
assert len(poi) == 4
|
||||||
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
||||||
|
|
@ -337,7 +281,7 @@ def test_fetch_nearby_poi_limits_to_top_n() -> None:
|
||||||
for i in range(20)
|
for i in range(20)
|
||||||
]
|
]
|
||||||
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
||||||
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
|
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||||
assert len(poi) == 7
|
assert len(poi) == 7
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -463,64 +407,3 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
|
||||||
assert result.poi_status == "unavailable"
|
assert result.poi_status == "unavailable"
|
||||||
assert result.nearby_poi == []
|
assert result.nearby_poi == []
|
||||||
assert result.location_index_pct == 15.0
|
assert result.location_index_pct == 15.0
|
||||||
|
|
||||||
|
|
||||||
# ── #msk-defect-1/2: Москва (region 77) — не течёт в EKB, честная POI-деградация ──
|
|
||||||
|
|
||||||
|
|
||||||
def test_covered_region_resolves_moscow_center_to_region_77() -> None:
|
|
||||||
"""МКАД-центр Москвы должен попадать в продукт-ядро 77, а не в 50 (см. комментарий у
|
|
||||||
regions._POINT_LOOKUP_ORDER — компактный bbox_region проверяется раньше объемлющего)."""
|
|
||||||
region = lc._covered_region(_LAT_IN_MSK, _LON_IN_MSK)
|
|
||||||
assert region is not None and region.code == 77
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_nearby_poi_gate_uses_regions_own_bbox_not_whole_table() -> None:
|
|
||||||
"""Дефект (прод 13.09.2026): гейт раньше делал `count(*)` по ВСЕЙ osm_poi_ekb_local
|
|
||||||
(4 850 екатеринбургских строк, всегда > 0) — Москва получала бы poi_status="ok" с
|
|
||||||
пустым списком. Теперь count скоупится в bbox_product_core региона точки: для 77
|
|
||||||
внутри его ядра POI нет (0), для 66 — те же 4 850 строк, что и раньше."""
|
|
||||||
db_msk = _FakeDB([_FakeResult(scalar_value=0)])
|
|
||||||
poi, status = lc._fetch_nearby_poi(
|
|
||||||
db_msk, _LAT_IN_MSK, _LON_IN_MSK, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_77
|
|
||||||
)
|
|
||||||
assert poi == []
|
|
||||||
assert status == "unavailable" # честная деградация, ТА ЖЕ терминология, что и раньше
|
|
||||||
# bbox в запросе — Москвы, не Екатеринбурга (это и есть суть фикса).
|
|
||||||
poi_count_params = db_msk.executed[0][1]
|
|
||||||
lat_min, lat_max, lon_min, lon_max = _REGION_77.bbox_product_core
|
|
||||||
assert poi_count_params["bbox_south"] == lat_min
|
|
||||||
assert poi_count_params["bbox_north"] == lat_max
|
|
||||||
assert poi_count_params["bbox_west"] == lon_min
|
|
||||||
assert poi_count_params["bbox_east"] == lon_max
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_nearby_poi_gate_still_scopes_to_ekb_bbox_for_region_66() -> None:
|
|
||||||
"""Regression: для 66 гейт обязан продолжать спрашивать про bbox ЕКБ — byte-for-byte,
|
|
||||||
просто теперь явно скоупенный, а не «случайно совпадающий» с count(*) по всей таблице."""
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=4850), _FakeResult(mapping_rows=[])])
|
|
||||||
lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66)
|
|
||||||
poi_count_params = db.executed[0][1]
|
|
||||||
lat_min, lat_max, lon_min, lon_max = _REGION_66.bbox_product_core
|
|
||||||
assert poi_count_params["bbox_south"] == lat_min
|
|
||||||
assert poi_count_params["bbox_north"] == lat_max
|
|
||||||
assert poi_count_params["bbox_west"] == lon_min
|
|
||||||
assert poi_count_params["bbox_east"] == lon_max
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_index_moscow_point_isolates_region_and_degrades_honestly() -> None:
|
|
||||||
"""End-to-end #3051 Москва: citywide/local запросы обязаны нести region_code=77 (не 66,
|
|
||||||
не отсутствие фильтра), а poi_status обязан быть честным "unavailable" — НЕ "ok" с
|
|
||||||
пустым списком, как было до фикса дефекта 2."""
|
|
||||||
db = _FakeDB(
|
|
||||||
[
|
|
||||||
_FakeResult(scalar_value=0), # poi-count in Moscow core: 0
|
|
||||||
_FakeResult(mapping_one={"median_ppm2": None, "n": 0}), # citywide: no MSK data yet
|
|
||||||
]
|
|
||||||
)
|
|
||||||
result = lc.compute_location_index(db, lat=_LAT_IN_MSK, lon=_LON_IN_MSK)
|
|
||||||
assert result.status == "insufficient_data" # честно: листингов Москвы пока нет
|
|
||||||
assert result.poi_status == "unavailable"
|
|
||||||
assert result.nearby_poi == []
|
|
||||||
citywide_params = db.executed[1][1]
|
|
||||||
assert citywide_params["region_code"] == 77
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ NEVER calls real Telegram API — httpx.MockTransport only, тот же патт
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
|
@ -52,19 +51,12 @@ def _stop_patches():
|
||||||
# ── verify_chat_and_topic ────────────────────────────────────────────────────
|
# ── verify_chat_and_topic ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_success_uses_send_message_and_delete_message() -> None:
|
async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action() -> None:
|
||||||
"""Happy path: getChat + зондирующий sendMessage в тему + его deleteMessage.
|
"""Happy path: getChat + typing-индикатор в тему, никакого видимого сообщения."""
|
||||||
|
|
||||||
`sendChatAction` тут больше не используется: живой прод-замер (#3471)
|
|
||||||
показал, что она подтверждает даже несуществующий `message_thread_id`
|
|
||||||
(`{"ok":true,"result":true}`) — проверка была ложно-зелёной."""
|
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
method = request.url.path.rsplit("/", 1)[-1]
|
calls.append(request.url.path.rsplit("/", 1)[-1])
|
||||||
calls.append(method)
|
|
||||||
if method == "sendMessage":
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 42}})
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
return httpx.Response(200, json={"ok": True, "result": True})
|
||||||
|
|
||||||
_install_transport(handler)
|
_install_transport(handler)
|
||||||
|
|
@ -73,31 +65,24 @@ async def test_verify_chat_and_topic_success_uses_send_message_and_delete_messag
|
||||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||||
|
|
||||||
assert ok is True
|
assert ok is True
|
||||||
assert calls == ["getChat", "sendMessage", "deleteMessage"]
|
assert calls == ["getChat", "sendChatAction"]
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_deletes_probe_message_leaving_no_trace() -> None:
|
async def test_verify_chat_and_topic_no_visible_message_method_used() -> None:
|
||||||
"""Зонд `sendMessage` не остаётся видимым — `deleteMessage` бьёт по тому же
|
"""Ни один из вызовов не бьёт в sendMessage/copyMessage — не мусорим в чат."""
|
||||||
`message_id`, что вернул `sendMessage` (не мусорим в истории треда)."""
|
methods: list[str] = []
|
||||||
delete_payloads: list[dict[str, object]] = []
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
method = request.url.path.rsplit("/", 1)[-1]
|
methods.append(request.url.path.rsplit("/", 1)[-1])
|
||||||
if method == "sendMessage":
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 986}})
|
|
||||||
if method == "deleteMessage":
|
|
||||||
delete_payloads.append(json.loads(request.content))
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
return httpx.Response(200, json={"ok": True, "result": True})
|
||||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
|
||||||
|
|
||||||
_install_transport(handler)
|
_install_transport(handler)
|
||||||
client = TelegramClient(token="fake-token")
|
client = TelegramClient(token="fake-token")
|
||||||
|
|
||||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||||
|
|
||||||
assert ok is True
|
assert "sendMessage" not in methods
|
||||||
assert len(delete_payloads) == 1
|
assert "copyMessage" not in methods
|
||||||
assert delete_payloads[0]["message_id"] == 986
|
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
|
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
|
||||||
|
|
@ -150,50 +135,6 @@ async def test_verify_chat_and_topic_skips_api_call_when_chat_id_is_zero() -> No
|
||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_is_red_when_topic_deleted_even_though_send_chat_action_lies(
|
|
||||||
caplog,
|
|
||||||
) -> None:
|
|
||||||
"""Регрессия #3471: удалённая/переименованная тема ДОЛЖНА красить проверку.
|
|
||||||
|
|
||||||
Handler воспроизводит РЕАЛЬНО замеренное на проде поведение Bot API:
|
|
||||||
`sendChatAction` подтверждает даже несуществующий `message_thread_id`
|
|
||||||
(`{"ok":true,"result":true}` — живой прод-замер, #3471), а `sendMessage`
|
|
||||||
с тем же id честно отвечает `400 Bad Request: message thread not found`.
|
|
||||||
|
|
||||||
На коде ДО правки (`verify_chat_and_topic` звала `send_chat_action`) этот
|
|
||||||
тест падает: `ok` был бы `True` — проверка молчаливо считала мёртвую тему
|
|
||||||
живой. Явно проверено при разработке (#3471): `git checkout HEAD --
|
|
||||||
.../client.py` (до-фиксная версия) + прогон только этого теста дал
|
|
||||||
`assert True is False`; после возврата фикса — зелёный."""
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
method = request.url.path.rsplit("/", 1)[-1]
|
|
||||||
if method == "getChat":
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": {"id": -100123}})
|
|
||||||
if method == "sendChatAction":
|
|
||||||
# Реальный прод-баг: sendChatAction НЕ валидирует message_thread_id.
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
|
||||||
if method == "sendMessage":
|
|
||||||
return httpx.Response(
|
|
||||||
400,
|
|
||||||
json={
|
|
||||||
"ok": False,
|
|
||||||
"error_code": 400,
|
|
||||||
"description": "Bad Request: message thread not found",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
|
||||||
|
|
||||||
_install_transport(handler)
|
|
||||||
client = TelegramClient(token="fake-token")
|
|
||||||
|
|
||||||
with caplog.at_level("ERROR", logger="app.services.tgbot.client"):
|
|
||||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=987654, label="support")
|
|
||||||
|
|
||||||
assert ok is False
|
|
||||||
assert any(record.levelname == "ERROR" for record in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
# ── TelegramGroupRateLimiter ─────────────────────────────────────────────────
|
# ── TelegramGroupRateLimiter ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,12 @@ tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agr
|
||||||
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
|
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
|
||||||
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
|
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
|
||||||
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
|
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
|
||||||
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
|
|
||||||
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
|
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
|
||||||
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
|
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
|
||||||
tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
|
tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
||||||
tests/test_user_events.py::test_real_record_event_inserts_row
|
tests/test_user_events.py::test_real_record_event_inserts_row
|
||||||
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
|
|
||||||
|
|
||||||
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
|
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
|
||||||
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
|
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
|
||||||
|
|
@ -131,18 +129,3 @@ tests/test_revisit_floor_lateral_lookup.py::test_missing_history_before_anchor_s
|
||||||
# (postgres-сервис) — там прогон и был зелёным; в deploy-tradein.yml БД нет вовсе.
|
# (postgres-сервис) — там прогон и был зелёным; в deploy-tradein.yml БД нет вовсе.
|
||||||
tests/test_3063_seller_fields_not_eroded.py::test_poor_rescrape_does_not_erase_seller_fields
|
tests/test_3063_seller_fields_not_eroded.py::test_poor_rescrape_does_not_erase_seller_fields
|
||||||
tests/test_3063_seller_fields_not_eroded.py::test_real_change_still_overwrites
|
tests/test_3063_seller_fields_not_eroded.py::test_real_change_still_overwrites
|
||||||
|
|
||||||
# Потолки БД на движке (#3463) — поведенческая половина: проверяют, что запрос
|
|
||||||
# длиннее потолка ОБРЫВАЕТСЯ (pg_sleep), что ожидание блокировки отваливается по
|
|
||||||
# lock_timeout, и что `SET LOCAL` задачи планировщика перекрывает сессионный
|
|
||||||
# потолок и не течёт за свою транзакцию. Всё это нельзя проверить без сервера:
|
|
||||||
# потолок применяет Postgres, а не питон. Статическая половина того же файла
|
|
||||||
# (test_engine_opens_connections_with_both_ceilings +
|
|
||||||
# test_ceilings_are_coherent_with_declared_estimate_budgets) идёт на ОБОИХ лэйнах
|
|
||||||
# и краснеет от снятия `connect_args` — проверено вручную 12.09. В ci-tradein.yml
|
|
||||||
# эти пять бегут по-настоящему (postgres-сервис, #2745).
|
|
||||||
tests/test_3463_db_timeouts.py::test_live_session_reports_both_ceilings
|
|
||||||
tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hung
|
|
||||||
tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
|
|
||||||
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
|
|
||||||
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction
|
|
||||||
|
|
|
||||||
|
|
@ -50,17 +50,7 @@ def test_regions_do_not_overlap() -> None:
|
||||||
— там же настоящий инвариант для вложенной пары: `region_for_point` обязан
|
— там же настоящий инвариант для вложенной пары: `region_for_point` обязан
|
||||||
резолвить точку во ВЛАДЕЮЩИЙ, более специфичный регион по площади bbox, а не
|
резолвить точку во ВЛАДЕЮЩИЙ, более специфичный регион по площади bbox, а не
|
||||||
по числовому коду). 66 (Урал) физически за тысячи км от 50/77 — для него
|
по числовому коду). 66 (Урал) физически за тысячи км от 50/77 — для него
|
||||||
старый плоский инвариант «никто ни в кого не попадает» остаётся в силе.
|
старый плоский инвариант «никто ни в кого не попадает» остаётся в силе."""
|
||||||
|
|
||||||
#3052: этот тест — про СЫРЫЕ прямоугольники (`is_within_bbox` /
|
|
||||||
`bbox_region`), не про итоговый резолв точки. Переход `region_for_point`
|
|
||||||
на настоящий полигон (см. regions._point_in_region_polygon) исключение
|
|
||||||
здесь НЕ снимает: bbox_region(77) геометрически остаётся подмножеством
|
|
||||||
bbox_region(50) как прямоугольник — полигон это не меняет, он работает
|
|
||||||
ПОСЛЕ bbox-предфильтра, а не вместо него. Честный инвариант «точка резолвится
|
|
||||||
ровно в один регион» теперь проверяется на уровне function-результата в
|
|
||||||
test_3051_region_registry_moscow_oblast.py (Химки/Балашиха и девять
|
|
||||||
контрольных точек issue #3052), не здесь."""
|
|
||||||
nested_pairs = {(50, 77), (77, 50)}
|
nested_pairs = {(50, 77), (77, 50)}
|
||||||
# Исключение из инварианта обязано опираться на ДОКАЗАННУЮ вложенность, а не
|
# Исключение из инварианта обязано опираться на ДОКАЗАННУЮ вложенность, а не
|
||||||
# на допущение в докстринге: сначала проверяем, что bbox_region(50)
|
# на допущение в докстринге: сначала проверяем, что bbox_region(50)
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,6 @@ bbox_region(77) (Москва) как прямоугольники, а 50 < 77
|
||||||
Область на дату этого PR — тир обогащения пуст (frozenset()), в deals/listings
|
Область на дату этого PR — тир обогащения пуст (frozenset()), в deals/listings
|
||||||
0 строк (импорт Росреестра из FDW — отдельный PR). Тесты ниже проверяют РЕЕСТР
|
0 строк (импорт Росреестра из FDW — отдельный PR). Тесты ниже проверяют РЕЕСТР
|
||||||
(геометрию/классификацию), не данные.
|
(геометрию/классификацию), не данные.
|
||||||
|
|
||||||
#3052: резолв внутри `region_for_point` перестал быть чисто bbox-based —
|
|
||||||
`_POINT_LOOKUP_ORDER` (регрессия ниже) остался прежним дешёвым предфильтром,
|
|
||||||
но финальный ответ теперь даёт настоящий полигон границы региона. Тест на
|
|
||||||
Химки/Балашиху обновлён под это (было «известное ограничение» bbox, теперь —
|
|
||||||
исправленное поведение).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -79,20 +73,20 @@ def test_point_outside_all_regions_is_none() -> None:
|
||||||
assert res is None
|
assert res is None
|
||||||
|
|
||||||
|
|
||||||
def test_khimki_balashikha_resolve_to_50_via_polygon() -> None:
|
def test_known_limitation_khimki_balashikha_resolve_to_77_via_bbox() -> None:
|
||||||
"""#3052: bbox_region(77) — генеральный fallback-net Москвы, по долготе
|
"""ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ, не баг этого PR: bbox_region(77) — генеральный
|
||||||
тянется до 38.10 (~13 км восточнее МКАД). Химки (55.91, 37.41) и Балашиха
|
fallback-net Москвы, по долготе тянется до 38.10 (~13 км восточнее МКАД).
|
||||||
(55.7965, 37.9388) физически лежат внутри ЭТОГО прямоугольника, хотя
|
Химки (55.91, 37.41) и Балашиха (55.7965, 37.9388) физически лежат внутри
|
||||||
административно это область — до #3052 region_for_point (bbox-based)
|
ЭТОГО прямоугольника, хотя административно это область. region_for_point
|
||||||
резолвил их в 77 («известное ограничение», см. issue #3052: 8 647
|
(bbox-based) резолвит их в 77; region_by_city (точное имя) — правильно в
|
||||||
московских лотов уходили в отказ по покрытию, а 11 172 областные сделки
|
50 (см. test_region_by_city_resolves_moscow_oblast_cities). Сузить
|
||||||
ложно попадали в московское ядро). С настоящим полигоном (bbox остался
|
bbox_region(77), чтобы это исправить, — отдельное решение вне скоупа
|
||||||
только дешёвым предфильтром — см. regions.region_for_point) обе точки
|
ordering-фикса (риск задеть geocoder/estimator-потребителей 77 без
|
||||||
физически вне контура Москвы → резолвятся в 50, как и region_by_city."""
|
возможности перепроверить их в этом PR)."""
|
||||||
khimki = regions.region_for_point(55.91, 37.41)
|
khimki = regions.region_for_point(55.91, 37.41)
|
||||||
assert khimki is not None and khimki.code == 50
|
assert khimki is not None and khimki.code == 77
|
||||||
balashikha = regions.region_for_point(55.7965, 37.9388)
|
balashikha = regions.region_for_point(55.7965, 37.9388)
|
||||||
assert balashikha is not None and balashikha.code == 50
|
assert balashikha is not None and balashikha.code == 77
|
||||||
|
|
||||||
|
|
||||||
# ── 2. region_by_city ─────────────────────────────────────────────────────
|
# ── 2. region_by_city ─────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
"""#3052: `region_for_point` резолвит по настоящей границе региона (полигон
|
|
||||||
OSM/Nominatim из `region_boundaries/boundaries.geojson.json`), не по
|
|
||||||
прямоугольнику `bbox_region`.
|
|
||||||
|
|
||||||
До фикса прямоугольники Москвы (77) и области (50) пересекались — Химки,
|
|
||||||
Реутов, Котельники, Люберцы резолвились в Москву (8 647 московских лотов
|
|
||||||
получали отказ по покрытию, 11 172 областные сделки ложно попадали в
|
|
||||||
московское ядро, при том что коридор ДКП фильтрует строго по коду региона).
|
|
||||||
|
|
||||||
Девять контрольных точек ниже — приёмка issue #3052, значения сверены
|
|
||||||
`region_for_point` на реальных полигонах до мержа (ray casting с поддержкой
|
|
||||||
дыр, см. regions._point_in_region_polygon)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from app.services import regions
|
|
||||||
|
|
||||||
# (название, lat, lon, ожидаемый code региона или None)
|
|
||||||
_CONTROL_POINTS: tuple[tuple[str, float, float, int | None], ...] = (
|
|
||||||
("Химки", 55.8970, 37.4297, 50),
|
|
||||||
("центр Москвы", 55.7558, 37.6173, 77),
|
|
||||||
("Ломоносовский проспект", 55.7020, 37.5140, 77),
|
|
||||||
("Серпухов", 54.9147, 37.4147, 50),
|
|
||||||
("Балашиха", 55.7965, 37.9391, 50),
|
|
||||||
("Зеленоград", 55.9840, 37.2140, 77),
|
|
||||||
("Троицк (Новая Москва)", 55.4750, 37.3000, 77),
|
|
||||||
("Екатеринбург", 56.8389, 60.6057, 66),
|
|
||||||
("Пермь", 58.0105, 56.2502, None),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nine_control_points_resolve_by_real_boundary() -> None:
|
|
||||||
for name, lat, lon, expected_code in _CONTROL_POINTS:
|
|
||||||
res = regions.region_for_point(lat, lon)
|
|
||||||
got_code = res.code if res is not None else None
|
|
||||||
assert got_code == expected_code, (
|
|
||||||
f"{name} ({lat}, {lon}): ожидался регион {expected_code}, получен {got_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_point_outside_all_boundaries_is_none() -> None:
|
|
||||||
"""Пермь — вне всех трёх полигонов (не только вне bbox), инвариант отдельно
|
|
||||||
от общего прогона выше — чтобы регрессия на None была явной сама по себе."""
|
|
||||||
assert regions.region_for_point(58.0105, 56.2502) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekaterinburg_unaffected_by_polygon_switch() -> None:
|
|
||||||
"""Регион 66 физически за тысячи км от 50/77 — переход на полигон не должен
|
|
||||||
был задеть его резолв вообще."""
|
|
||||||
res = regions.region_for_point(56.8389, 60.6057)
|
|
||||||
assert res is not None and res.code == 66
|
|
||||||
|
|
||||||
|
|
||||||
def test_bbox_prefilter_never_rejects_what_polygon_would_accept() -> None:
|
|
||||||
"""Инвариант «полигон вложен в свой bbox_region»: если бы это было не так,
|
|
||||||
дешёвый bbox-предфильтр в region_for_point молча отбрасывал бы точки,
|
|
||||||
которые честный point-in-polygon принял бы — тихая потеря покрытия."""
|
|
||||||
for code, rings in regions._REGION_BOUNDARIES.items():
|
|
||||||
bbox = regions.REGIONS[code].bbox_region
|
|
||||||
for ring in rings:
|
|
||||||
for lon, lat in ring:
|
|
||||||
assert regions.is_within_bbox(lat, lon, bbox), (
|
|
||||||
f"вершина полигона региона {code} ({lat}, {lon}) вне его bbox_region"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Правка регрессии первой версии #3052 (упрощение 0.005 ≈ 500 м) ──────────
|
|
||||||
#
|
|
||||||
# Сверка резолва против сохранённого region_code по проду (277 582 объявления
|
|
||||||
# + 48 143 дома) на упрощённой геометрии: 69 точек агломерации не принял НИ
|
|
||||||
# один полигон (→ None → DEFAULT_REGION_CODE=66 у вызывающего кода), куски
|
|
||||||
# Москвы (Митино, Косино-Ухтомский, Можайский) уехали в область, 731
|
|
||||||
# объявление области — в Москву. Точки ниже — реальные адреса из прода.
|
|
||||||
#
|
|
||||||
# Кунцево, «ул. Липовой Рощи 1к3» (55.7990, 37.3787) в этом списке стоит как
|
|
||||||
# 50, хотя в исходной сверке значилось ожидание 77: улицы с таким именем в
|
|
||||||
# Москве нет вообще (Nominatim /search «улица Липовой Рощи» — единственное
|
|
||||||
# попадание: квартал «Спутник», Павшино, городской округ Красногорск,
|
|
||||||
# Московская область), а сама точка лежит в 119 м ВНУТРИ области от границы
|
|
||||||
# Москвы. Ожидание 77 там — след bbox-эпохи (прямоугольник Москвы тянется по
|
|
||||||
# долготе до 38.10 и накрывает Красногорск), а не факт. Оно ещё и неисполнимо
|
|
||||||
# вместе с соседней точкой той же сверки: Новоивановское (55.7013, 37.3606)
|
|
||||||
# лежит в 72 м снаружи Москвы и ожидается как 50 — никакое правило «ближе N
|
|
||||||
# метров к Москве ⇒ Москва» не отдаст 77 точке в 119 м и 50 точке в 72 м.
|
|
||||||
_BORDER_CONTROL_POINTS: tuple[tuple[str, float, float, int], ...] = (
|
|
||||||
("Куркино, Соловьиная Роща 8", 55.8917, 37.4008, 77),
|
|
||||||
("Митино, Муравская 46к1", 55.8617, 37.3769, 77),
|
|
||||||
("Косино-Ухтомский, Лухмановская 15к4", 55.7226, 37.8922, 77),
|
|
||||||
("Можайский, Сколковское ш. 40к4", 55.7022, 37.3920, 77),
|
|
||||||
("Новые Химки, Панфилова 12", 55.8796, 37.4294, 50),
|
|
||||||
("Химки, Юннатов 11", 55.8972, 37.4613, 50),
|
|
||||||
("Красногорск, Красногорский б-р 13к1", 55.8156, 37.3707, 50),
|
|
||||||
("Новоивановское, Одинцовский о.", 55.7013, 37.3606, 50),
|
|
||||||
("Красногорск, Липовой Рощи 1к3", 55.7990, 37.3787, 50),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_border_control_points_resolve_on_unsimplified_boundary() -> None:
|
|
||||||
for name, lat, lon, expected_code in _BORDER_CONTROL_POINTS:
|
|
||||||
res = regions.region_for_point(lat, lon)
|
|
||||||
got_code = res.code if res is not None else None
|
|
||||||
assert got_code == expected_code, (
|
|
||||||
f"{name} ({lat}, {lon}): ожидался регион {expected_code}, получен {got_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_point_inside_a_bbox_resolves_to_none() -> None:
|
|
||||||
"""Главный инвариант правки: None законен ТОЛЬКО вне bbox всех регионов.
|
|
||||||
|
|
||||||
Прогон сеткой 0.005° по прямоугольнику Москвы: любая щель между
|
|
||||||
независимо оцифрованными в OSM линиями 50 и 77 всплывёт здесь как None, а
|
|
||||||
None у вызывающего кода (`trade_in.py`: `code if target_region else
|
|
||||||
DEFAULT_REGION_CODE`) означает молчаливую подстановку региона по
|
|
||||||
умолчанию — коэффициенты Екатеринбурга московскому адресу."""
|
|
||||||
checked = 0
|
|
||||||
for lat_step in range(91):
|
|
||||||
lat = 55.55 + lat_step * 0.005
|
|
||||||
for lon_step in range(121):
|
|
||||||
lon = 37.30 + lon_step * 0.005
|
|
||||||
assert any(
|
|
||||||
regions.is_within_bbox(lat, lon, r.bbox_region) for r in regions.REGIONS.values()
|
|
||||||
), (lat, lon)
|
|
||||||
assert regions.region_for_point(lat, lon) is not None, (
|
|
||||||
f"точка ({lat}, {lon}) внутри bbox региона, но резолв дал None"
|
|
||||||
)
|
|
||||||
checked += 1
|
|
||||||
assert checked == 91 * 121
|
|
||||||
|
|
||||||
|
|
||||||
def test_nearest_boundary_fallback_prefers_the_closer_region() -> None:
|
|
||||||
"""Щель закрывается по расстоянию до границы, а не «первым в порядке».
|
|
||||||
|
|
||||||
Проверяем саму метрику — в fallback-ветке region_for_point исход решает
|
|
||||||
она. Точка в Красногорске у самой границы Москвы: линия 50 и линия 77
|
|
||||||
здесь общая, поэтому расстояния до них совпадают до метров, и масштаб —
|
|
||||||
сотня метров, а не километры (плоское приближение с поправкой cos(lat)).
|
|
||||||
"""
|
|
||||||
lat, lon = 55.7990, 37.3787
|
|
||||||
d_50 = regions._distance_to_boundary(50, lat, lon)
|
|
||||||
d_77 = regions._distance_to_boundary(77, lat, lon)
|
|
||||||
assert abs(d_50 - d_77) * 111_320 < 20.0
|
|
||||||
assert 50.0 < d_50 * 111_320 < 300.0
|
|
||||||
# Центр Москвы от своей границы — километры (метрика не схлопнута в ноль).
|
|
||||||
assert regions._distance_to_boundary(77, 55.7558, 37.6173) * 111_320 > 5_000.0
|
|
||||||
|
|
||||||
|
|
||||||
def _measure_resolve(probes: list[tuple[float, float]]) -> float:
|
|
||||||
started = time.perf_counter()
|
|
||||||
for _ in range(200):
|
|
||||||
for lat, lon in probes:
|
|
||||||
regions.region_for_point(lat, lon)
|
|
||||||
return (time.perf_counter() - started) / (200 * len(probes))
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_for_point_stays_under_time_budget() -> None:
|
|
||||||
"""Точная геометрия не имеет права стоить дороже bbox-эпохи.
|
|
||||||
|
|
||||||
Потолок 150 мкс/вызов при фактических ~4 мкс (bbox каждого кольца +
|
|
||||||
lat-полосы рёбер, см. regions._IndexedRing): запас в 35 раз — тест
|
|
||||||
краснеет от настоящей регрессии алгоритма, а не от шумного раннера."""
|
|
||||||
probes = [(lat, lon) for _n, lat, lon, _c in _BORDER_CONTROL_POINTS]
|
|
||||||
probes += [(lat, lon) for _n, lat, lon, _c in _CONTROL_POINTS]
|
|
||||||
for lat, lon in probes: # прогрев
|
|
||||||
regions.region_for_point(lat, lon)
|
|
||||||
best = min(_measure_resolve(probes) for _ in range(3))
|
|
||||||
assert best < 150e-6, f"region_for_point: {best * 1e6:.1f} мкс/вызов, потолок 150"
|
|
||||||
|
|
@ -64,14 +64,7 @@ def test_uvicorn_access_line_masks_secret_query_param() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_app_logger_via_root_handler_masks_secret() -> None:
|
def test_app_logger_via_root_handler_masks_secret() -> None:
|
||||||
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты.
|
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты."""
|
||||||
|
|
||||||
Секрет + query-контекст (`?token=...`) передаются ОДНИМ %s-аргументом — как реально
|
|
||||||
устроены вызовы в этом кодбейзе (см. body_preview в app/services/dadata.py), а не
|
|
||||||
расщеплены между msg-шаблоном и голым значением в args. Фильтр (#3471) скрабит
|
|
||||||
ЭЛЕМЕНТЫ `record.args` по отдельности, не трогая arity/`record.msg`-шаблон — поэтому
|
|
||||||
секрет обязан быть самодостаточным (с контекстом) внутри одного аргумента.
|
|
||||||
"""
|
|
||||||
stream = io.StringIO()
|
stream = io.StringIO()
|
||||||
handler = logging.StreamHandler(stream)
|
handler = logging.StreamHandler(stream)
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
|
@ -80,7 +73,7 @@ def test_app_logger_via_root_handler_masks_secret() -> None:
|
||||||
root.addHandler(handler)
|
root.addHandler(handler)
|
||||||
try:
|
try:
|
||||||
logging.getLogger("app.some.module").warning(
|
logging.getLogger("app.some.module").warning(
|
||||||
"retry callback %s", f"https://gendsgn.ru/hook?token={_SECRET}"
|
"retry callback https://gendsgn.ru/hook?token=%s", _SECRET
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
root.removeHandler(handler)
|
root.removeHandler(handler)
|
||||||
|
|
@ -140,46 +133,3 @@ def test_install_is_idempotent() -> None:
|
||||||
install_query_secret_filter("uvicorn.access")
|
install_query_secret_filter("uvicorn.access")
|
||||||
filters = logging.getLogger("uvicorn.access").filters
|
filters = logging.getLogger("uvicorn.access").filters
|
||||||
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters
|
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters
|
||||||
|
|
||||||
|
|
||||||
def test_uvicorn_access_formatter_survives_secret_scrub() -> None:
|
|
||||||
"""Прод-баг: скраб секрета в access-логе ронял сам процесс логирования (docker logs).
|
|
||||||
|
|
||||||
`uvicorn.logging.AccessFormatter.formatMessage()` распаковывает `record.args` НАПРЯМУЮ
|
|
||||||
как 5-tuple (`client_addr, method, full_path, http_version, status_code = record.args`),
|
|
||||||
в обход `record.getMessage()`. Старая реализация фильтра при найденном секрете обнуляла
|
|
||||||
`record.args = ()` → `ValueError: not enough values to unpack (expected 5, got 0)` →
|
|
||||||
`--- Logging error ---` в docker-логах ТОЛЬКО в момент, когда фильтр пытался защитить
|
|
||||||
секрет (см. трассу через starlette/middleware/errors.py `_send` — это просто цепочка
|
|
||||||
вложенных ASGI `send()`, ведущая к access-log вызову внутри uvicorn).
|
|
||||||
"""
|
|
||||||
from uvicorn.logging import AccessFormatter
|
|
||||||
|
|
||||||
stream = io.StringIO()
|
|
||||||
handler = logging.StreamHandler(stream)
|
|
||||||
handler.setFormatter(AccessFormatter("%(client_addr)s - %(request_line)s %(status_code)s"))
|
|
||||||
logger = logging.getLogger("uvicorn.access.formatter_test")
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
logger.propagate = False
|
|
||||||
install_query_secret_filter("uvicorn.access.formatter_test")
|
|
||||||
try:
|
|
||||||
# НЕ должно бросить ValueError при обработке handler'ом (падение уходит в
|
|
||||||
# handleError → stderr, а не наверх — поэтому проверяем результат, а не raises).
|
|
||||||
logger.info(
|
|
||||||
'%s - "%s %s HTTP/%s" %d',
|
|
||||||
"172.18.0.3:60322",
|
|
||||||
"POST",
|
|
||||||
f"/api/v1/trade-in/ops/glitchtip-webhook?secret={_SECRET}",
|
|
||||||
"1.1",
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
logger.removeHandler(handler)
|
|
||||||
logger.filters.clear()
|
|
||||||
|
|
||||||
out = stream.getvalue()
|
|
||||||
assert out, "форматтер не выдал ни строки — запись эмита провалилась (см. handleError)"
|
|
||||||
assert _SECRET not in out, out
|
|
||||||
assert "secret=***" in out, out
|
|
||||||
assert "200" in out and "POST" in out
|
|
||||||
|
|
|
||||||
|
|
@ -342,9 +342,6 @@ def _yandex_with_pool(monkeypatch: pytest.MonkeyPatch, *, get: Any) -> _Counting
|
||||||
patch(
|
patch(
|
||||||
"scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_curl_session(get=get)
|
"scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_curl_session(get=get)
|
||||||
),
|
),
|
||||||
# На успехе fetch_house_history зовёт настоящий anti-ban sleep_between_requests
|
|
||||||
# (5с jitter) — тесту нужен только факт release-lease, не сама пауза.
|
|
||||||
patch("scraper_kit.base.asyncio.sleep", new_callable=AsyncMock),
|
|
||||||
):
|
):
|
||||||
_call_yandex(_db_cache_miss())
|
_call_yandex(_db_cache_miss())
|
||||||
return provider
|
return provider
|
||||||
|
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
"""#3463 — у запросов к БД обязан быть потолок по времени и по ожиданию блокировки.
|
|
||||||
|
|
||||||
Отказ, который тут закрывается (см. issue): под `ACCESS EXCLUSIVE` на таблице шаг БД
|
|
||||||
на пути `/estimate` ЖДЁТ блокировку; бюджет источника истекает, обёртка `run_db_thread`
|
|
||||||
уходит ждать свой поток (иначе он останется сиротой в общей `Session` — #3449), а
|
|
||||||
верхняя граница этого ожидания = длительность самого запроса. Границы у запроса не
|
|
||||||
было → слот `_estimate_slots` не возвращался → `_ESTIMATE_CONCURRENCY = 4` исчерпывался
|
|
||||||
и `/estimate` отдавал 429 всем остальным.
|
|
||||||
|
|
||||||
Потолок поэтому стоит на КОННЕКТЕ (libpq `options`), а не в питоновской обёртке:
|
|
||||||
таймаут в обёртке вернул бы ровно ту сироту, ради которой писался #3449.
|
|
||||||
|
|
||||||
Проверки по значению, а не по тексту:
|
|
||||||
* потолки реально доехали до параметров подключения движка и согласованы с
|
|
||||||
объявленными бюджетами `/estimate` (без БД — падают от снятия `connect_args`);
|
|
||||||
* живая сессия этого движка сообщает оба потолка (без БД пропускается);
|
|
||||||
* запрос длиннее потолка ОБРЫВАЕТСЯ за отведённое время, а не висит;
|
|
||||||
* ожидание блокировки длиннее потолка обрывается — это и есть сценарий #3463;
|
|
||||||
* задача со своим `SET LOCAL statement_timeout` (планировщик: 900 с в
|
|
||||||
`app/tasks/listing_source_snapshot.py`) новым сессионным потолком НЕ обрезается.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from collections.abc import Iterator
|
|
||||||
|
|
||||||
import psycopg
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import Engine, create_engine, text
|
|
||||||
from sqlalchemy.exc import OperationalError
|
|
||||||
|
|
||||||
from app.core.db import _LOCK_TIMEOUT_MS, _STATEMENT_TIMEOUT_MS, DB_CONNECT_ARGS, engine
|
|
||||||
|
|
||||||
# Потолки для ПОВЕДЕНЧЕСКИХ проверок — намеренно маленькие: проверяется механизм
|
|
||||||
# (потолок из `options` реально обрывает запрос / ожидание лока), а прод-ВЕЛИЧИНЫ
|
|
||||||
# проверяет `test_live_session_reports_both_ceilings` на самом прод-движке. Иначе
|
|
||||||
# каждый прогон сьюта стоил бы 30 с ожидания pg_sleep.
|
|
||||||
_PROBE_STATEMENT_TIMEOUT_MS = 1_000
|
|
||||||
_PROBE_LOCK_TIMEOUT_MS = 500
|
|
||||||
|
|
||||||
_LOCK_PROBE_TABLE = "t3463_lock_probe"
|
|
||||||
|
|
||||||
|
|
||||||
def _engine_connect_options(eng: Engine) -> str:
|
|
||||||
"""Строка libpq `options`, с которой движок РЕАЛЬНО открывает коннекты.
|
|
||||||
|
|
||||||
`connect_args` в движке не хранятся полем: `create_engine` вливает их в `cparams`
|
|
||||||
замыкания `pool._creator`. Читаем оттуда, а не из `DB_CONNECT_ARGS`, — иначе тест
|
|
||||||
остался бы зелёным после снятия `connect_args=` у `create_engine`.
|
|
||||||
|
|
||||||
Наружу отдаём ТОЛЬКО `options`: в `cparams` лежит пароль роли, и текст
|
|
||||||
провалившегося assert'а уехал бы с ним в лог CI.
|
|
||||||
"""
|
|
||||||
creator = getattr(eng.pool, "_creator", None)
|
|
||||||
assert creator is not None, "у пула движка нет _creator — SQLAlchemy сменила устройство"
|
|
||||||
cparams = inspect.getclosurevars(creator).nonlocals.get("cparams")
|
|
||||||
assert cparams is not None, (
|
|
||||||
"в замыкании pool._creator нет cparams — SQLAlchemy сменила устройство, "
|
|
||||||
"проверку параметров подключения надо переписать, а не удалять"
|
|
||||||
)
|
|
||||||
return str(cparams.get("options", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _live_engine(connect_args: dict[str, str]) -> Engine | None:
|
|
||||||
"""Движок против живой Postgres с заданными `connect_args`, иначе None.
|
|
||||||
|
|
||||||
Тот же способ добыть DSN, что у `_live_session()` в tests/test_house_dedup_merge.py
|
|
||||||
и tests/test_purge_expired_trade_in_data.py: в CI Postgres есть (ci-tradein.yml),
|
|
||||||
на ноутбуке без БД тест пропускается (учтён в tests/skip_allowlist.txt).
|
|
||||||
"""
|
|
||||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
|
||||||
if not dsn or "localhost:5432/test" in dsn:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Сначала проба БЕЗ connect_args: она отделяет «сервера нет» (честный пропуск)
|
|
||||||
# от «сервер есть, но наши `options` он не принял». Глушить второе нельзя —
|
|
||||||
# именно так испорченное значение (`statement_timeout=30000zz`) проходило
|
|
||||||
# зелёным: коннект падал, тест пропускался, запись в allowlist гасила сигнал,
|
|
||||||
# а на проде это FATAL на КАЖДОМ коннекте.
|
|
||||||
try:
|
|
||||||
probe = create_engine(dsn, future=True)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with probe.connect() as conn:
|
|
||||||
conn.execute(text("SELECT 1"))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
probe.dispose()
|
|
||||||
|
|
||||||
eng = create_engine(dsn, future=True, connect_args=connect_args)
|
|
||||||
with eng.connect() as conn: # НЕ под except: сервер живой, виноваты connect_args
|
|
||||||
conn.execute(text("SELECT 1"))
|
|
||||||
return eng
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def probe_engine() -> Iterator[Engine]:
|
|
||||||
"""Живой движок с МАЛЕНЬКИМИ потолками — проверка механизма, не прод-величин."""
|
|
||||||
eng = _live_engine(
|
|
||||||
{
|
|
||||||
"options": (
|
|
||||||
f"-c statement_timeout={_PROBE_STATEMENT_TIMEOUT_MS} "
|
|
||||||
f"-c lock_timeout={_PROBE_LOCK_TIMEOUT_MS}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if eng is None:
|
|
||||||
pytest.skip("живой Postgres недоступен (DATABASE_URL-заглушка)")
|
|
||||||
try:
|
|
||||||
yield eng
|
|
||||||
finally:
|
|
||||||
eng.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Проводка и согласованность величин (без БД) ───────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_engine_opens_connections_with_both_ceilings() -> None:
|
|
||||||
"""Оба потолка доехали до параметров подключения ПРОД-движка.
|
|
||||||
|
|
||||||
Фальсификация: убрать `connect_args=DB_CONNECT_ARGS` из `create_engine` —
|
|
||||||
`options` станет пустой, тест краснеет.
|
|
||||||
"""
|
|
||||||
expected = f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
|
|
||||||
options = _engine_connect_options(engine)
|
|
||||||
# РАВЕНСТВО, а не `in`: подстрочная проверка пропускала испорченный хвост
|
|
||||||
# (`…=30000zz` содержит `…=30000`), а Postgres на такое значение отвечает
|
|
||||||
# `FATAL: invalid value for parameter "statement_timeout"` — ни одного коннекта
|
|
||||||
# ни в одном из трёх сервисов образа, полный отказ продукта.
|
|
||||||
assert options == expected, (
|
|
||||||
f"движок открывает коннекты с options={options!r}, ожидалось {expected!r}: "
|
|
||||||
"либо потолка нет вовсе (заблокированный запрос снова висит и жжёт слот "
|
|
||||||
"/estimate, #3463), либо значение испорчено — тогда libpq отвергнет КАЖДЫЙ коннект"
|
|
||||||
)
|
|
||||||
assert DB_CONNECT_ARGS["options"] == options
|
|
||||||
|
|
||||||
|
|
||||||
def test_ceilings_are_coherent_with_declared_estimate_budgets() -> None:
|
|
||||||
"""Потолок выше САМОГО ДЛИННОГО объявленного бюджета `/estimate`, но конечен."""
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
declared_budgets_s = (
|
|
||||||
settings.estimate_avito_imv_timeout_s, # 20 с — самый длинный
|
|
||||||
settings.estimate_geocode_budget_s, # 12 с
|
|
||||||
settings.estimate_house_meta_timeout_s, # 8 с
|
|
||||||
settings.estimate_yandex_valuation_timeout_s, # 8 с
|
|
||||||
settings.estimate_cian_valuation_timeout_s, # 8 с
|
|
||||||
)
|
|
||||||
longest_ms = max(declared_budgets_s) * 1000
|
|
||||||
assert _STATEMENT_TIMEOUT_MS > longest_ms, (
|
|
||||||
f"потолок запроса {_STATEMENT_TIMEOUT_MS} мс не выше самого длинного объявленного "
|
|
||||||
f"бюджета {longest_ms:.0f} мс — потолок стал бы биндящим ограничением честной работы"
|
|
||||||
)
|
|
||||||
# Потолок сверху — иначе у проверки есть пол и нет крыши: `_STATEMENT_TIMEOUT_MS =
|
|
||||||
# 300_000` (пять минут) зеленел бы, а пять минут ожидания это тот же отказ, только
|
|
||||||
# медленнее: четыре таких запроса всё так же выедают `_ESTIMATE_CONCURRENCY`.
|
|
||||||
assert _STATEMENT_TIMEOUT_MS <= 2 * longest_ms, (
|
|
||||||
f"потолок запроса {_STATEMENT_TIMEOUT_MS} мс больше чем вдвое превышает самый "
|
|
||||||
f"длинный объявленный бюджет {longest_ms:.0f} мс — это уже не защита, а отсрочка: "
|
|
||||||
"слот /estimate держится всё это время"
|
|
||||||
)
|
|
||||||
assert 0 < _LOCK_TIMEOUT_MS < _STATEMENT_TIMEOUT_MS, (
|
|
||||||
"ожидание блокировки обязано обрываться РАНЬШЕ потолка на сам запрос: "
|
|
||||||
"деградировать лучше, чем держать слот"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Поведение против живой Postgres ──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_live_session_reports_both_ceilings() -> None:
|
|
||||||
"""Прод-ВЕЛИЧИНЫ на живой сессии ПРОД-движка: сервер их принял, а не проигнорировал.
|
|
||||||
|
|
||||||
Коннект берётся у самого `app.core.db.engine`, а не у собранного здесь двойника:
|
|
||||||
двойник остался бы зелёным после снятия `connect_args=` в `create_engine`.
|
|
||||||
"""
|
|
||||||
if _live_engine(DB_CONNECT_ARGS) is None:
|
|
||||||
pytest.skip("живой Postgres недоступен (DATABASE_URL-заглушка)")
|
|
||||||
with engine.connect() as conn:
|
|
||||||
statement_timeout = conn.execute(
|
|
||||||
text("SELECT current_setting('statement_timeout')")
|
|
||||||
).scalar_one()
|
|
||||||
lock_timeout = conn.execute(text("SELECT current_setting('lock_timeout')")).scalar_one()
|
|
||||||
|
|
||||||
assert statement_timeout == "30s", (
|
|
||||||
f"сессия сообщает statement_timeout={statement_timeout!r} — "
|
|
||||||
f"ожидалось 30s (_STATEMENT_TIMEOUT_MS={_STATEMENT_TIMEOUT_MS})"
|
|
||||||
)
|
|
||||||
assert lock_timeout == "5s", (
|
|
||||||
f"сессия сообщает lock_timeout={lock_timeout!r} — "
|
|
||||||
f"ожидалось 5s (_LOCK_TIMEOUT_MS={_LOCK_TIMEOUT_MS})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_statement_over_ceiling_is_cancelled_not_hung(probe_engine: Engine) -> None:
|
|
||||||
"""`pg_sleep` длиннее потолка обрывается ОТМЕНОЙ за отведённое время."""
|
|
||||||
sleep_s = _PROBE_STATEMENT_TIMEOUT_MS / 1000 * 5
|
|
||||||
started = time.monotonic()
|
|
||||||
with probe_engine.connect() as conn, pytest.raises(OperationalError) as excinfo:
|
|
||||||
conn.execute(text(f"SELECT pg_sleep({sleep_s})"))
|
|
||||||
elapsed = time.monotonic() - started
|
|
||||||
|
|
||||||
assert isinstance(excinfo.value.orig, psycopg.errors.QueryCanceled), (
|
|
||||||
f"запрос упал не отменой по таймауту, а {type(excinfo.value.orig).__name__}"
|
|
||||||
)
|
|
||||||
assert elapsed < sleep_s, (
|
|
||||||
f"запрос шёл {elapsed:.1f} с при потолке {_PROBE_STATEMENT_TIMEOUT_MS} мс — "
|
|
||||||
"потолок не сработал, он висел до конца pg_sleep"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lock_wait_over_ceiling_is_aborted(probe_engine: Engine) -> None:
|
|
||||||
"""Сценарий #3463: под ACCESS EXCLUSIVE читатель ОТВАЛИВАЕТСЯ, а не ждёт вечно."""
|
|
||||||
with probe_engine.connect() as blocker:
|
|
||||||
blocker.execute(text(f"CREATE TABLE IF NOT EXISTS {_LOCK_PROBE_TABLE} (id int)"))
|
|
||||||
blocker.commit()
|
|
||||||
try:
|
|
||||||
blocker.execute(text(f"LOCK TABLE {_LOCK_PROBE_TABLE} IN ACCESS EXCLUSIVE MODE"))
|
|
||||||
started = time.monotonic()
|
|
||||||
with probe_engine.connect() as victim, pytest.raises(OperationalError) as excinfo:
|
|
||||||
victim.execute(text(f"SELECT count(*) FROM {_LOCK_PROBE_TABLE}"))
|
|
||||||
elapsed = time.monotonic() - started
|
|
||||||
finally:
|
|
||||||
blocker.rollback()
|
|
||||||
blocker.execute(text(f"DROP TABLE IF EXISTS {_LOCK_PROBE_TABLE}"))
|
|
||||||
blocker.commit()
|
|
||||||
|
|
||||||
assert isinstance(excinfo.value.orig, psycopg.errors.LockNotAvailable), (
|
|
||||||
f"читатель упал не по ожиданию блокировки, а {type(excinfo.value.orig).__name__} — "
|
|
||||||
"сработал не тот потолок"
|
|
||||||
)
|
|
||||||
assert elapsed < _PROBE_STATEMENT_TIMEOUT_MS / 1000, (
|
|
||||||
f"ожидание блокировки длилось {elapsed:.2f} с при lock_timeout "
|
|
||||||
f"{_PROBE_LOCK_TIMEOUT_MS} мс — оборвал не lock_timeout"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_local_statement_timeout_overrides_session_ceiling(probe_engine: Engine) -> None:
|
|
||||||
"""Задача со своим `SET LOCAL` НЕ обрезается сессионным потолком.
|
|
||||||
|
|
||||||
Это и есть проверка обещания «задачи планировщика не пострадают»: у
|
|
||||||
`app/tasks/listing_source_snapshot.py:288` стоит `SET LOCAL statement_timeout = 900000`,
|
|
||||||
и он обязан ПЕРЕКРЫВАТЬ значение из `connect_args`, а не наоборот.
|
|
||||||
"""
|
|
||||||
own_budget_ms = _PROBE_STATEMENT_TIMEOUT_MS * 10
|
|
||||||
sleep_s = _PROBE_STATEMENT_TIMEOUT_MS / 1000 * 2 # заведомо больше сессионного потолка
|
|
||||||
|
|
||||||
with probe_engine.connect() as conn:
|
|
||||||
conn.execute(text(f"SET LOCAL statement_timeout = {own_budget_ms}"))
|
|
||||||
effective = conn.execute(text("SELECT current_setting('statement_timeout')")).scalar_one()
|
|
||||||
assert effective == "10s", f"SET LOCAL не применился: current_setting={effective!r}"
|
|
||||||
# Не просто current_setting: запрос длиннее СЕССИОННОГО потолка обязан дойти до конца.
|
|
||||||
conn.execute(text(f"SELECT pg_sleep({sleep_s})"))
|
|
||||||
conn.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_local_is_scoped_to_its_transaction(probe_engine: Engine) -> None:
|
|
||||||
"""Обратная сторона: чужой `SET LOCAL` не снимает потолок со всей сессии.
|
|
||||||
|
|
||||||
Иначе одна задача с 900-секундным бюджетом отключала бы защиту у всех, кому
|
|
||||||
достанется тот же коннект из пула.
|
|
||||||
"""
|
|
||||||
with probe_engine.connect() as conn:
|
|
||||||
conn.execute(text(f"SET LOCAL statement_timeout = {_PROBE_STATEMENT_TIMEOUT_MS * 10}"))
|
|
||||||
conn.rollback()
|
|
||||||
after = conn.execute(text("SELECT current_setting('statement_timeout')")).scalar_one()
|
|
||||||
|
|
||||||
assert after == "1s", (
|
|
||||||
f"после завершения транзакции statement_timeout={after!r} — "
|
|
||||||
"SET LOCAL протёк за пределы своей транзакции, коннект вернулся в пул без потолка"
|
|
||||||
)
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#3469).
|
|
||||||
|
|
||||||
ЧТО БЫЛО СЛОМАНО. `landing_showcase_deals` считает витрину публичного лэндинга
|
|
||||||
(«МЕРА сказала X — продали за Y»), но в `scrape_schedules` строки для неё не было
|
|
||||||
вовсе (`WHERE source LIKE '%showcase%'` — 0 строк на проде 12.09.2026), а в
|
|
||||||
реестре `product_handlers` — обработчика. То есть планировщик про задачу не знал
|
|
||||||
ни с какой стороны, пересчёт был ручным, и страница показывала прогон
|
|
||||||
тринадцатисуточной давности. Заметить это было неоткуда: под таблицей печатались
|
|
||||||
счётчики прогона, но не его дата, а сводка просроченных источников
|
|
||||||
(`emit_stale_digest`, #2670) ходит по ВКЛЮЧЁННЫМ РАСПИСАНИЯМ — источника, которого
|
|
||||||
в таблице нет, для неё не существует.
|
|
||||||
|
|
||||||
ЧТО ПРОВЕРЯЕТСЯ ЗДЕСЬ, И ПОЧЕМУ ИМЕННО ЭТО.
|
|
||||||
|
|
||||||
1. Обработчик резолвится ТЕМ ЖЕ `resolve_handler`, которым его ищет боевой
|
|
||||||
`_dispatch`. Одной строки расписания мало: без обработчика планировщик
|
|
||||||
нашёл бы задачу и не смог её запустить.
|
|
||||||
2. Миграция 303 сеет строку, и сеет её ВКЛЮЧЁННОЙ с явным `interval_days` —
|
|
||||||
сводка считает порог просрочки из этого же числа.
|
|
||||||
3. Сводка краснеет, когда витрина не пересчитывалась дольше ТРЁХ тактов
|
|
||||||
(приёмка #3469), и молчит на двух. Число тактов здесь — литерал, а такт
|
|
||||||
читается из миграции: ожидание, взятое из той же настройки, которую
|
|
||||||
проверяешь, уезжает вместе с ней — см. комментарий при _ACCEPTANCE_CYCLES.
|
|
||||||
4. Живой Postgres (само-скип): миграция реально вставляет строку в таблицу,
|
|
||||||
повторное применение её не задваивает, и настоящий запрос сводки
|
|
||||||
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
|
|
||||||
|
|
||||||
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
|
|
||||||
миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ —
|
|
||||||
ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
|
|
||||||
базы само-скипается (запись в tests/skip_allowlist.txt).
|
|
||||||
|
|
||||||
ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
|
|
||||||
зелёным и при этом падал в CI: повтор `015_scrape_runs.sql` (его комментарий к
|
|
||||||
колонке, снесённой миграцией 214) на полной схеме валится, а на пустой — нет.
|
|
||||||
Поэтому зависимости применяются только когда таблицы ещё нет, а проверять надо
|
|
||||||
тем же путём, каким гоняет CI:
|
|
||||||
|
|
||||||
docker exec tradein-postgres psql -U tradein -d postgres -c 'CREATE DATABASE t3469full'
|
|
||||||
docker exec -i tradein-postgres psql -U tradein -d t3469full -c \\
|
|
||||||
'CREATE EXTENSION postgis; CREATE EXTENSION pg_trgm; CREATE ROLE gendesign_reader;'
|
|
||||||
for f in $(ls -1 data/sql/*.sql | sort); do docker exec -i tradein-postgres \\
|
|
||||||
psql -U tradein -d t3469full -v ON_ERROR_STOP=on -q < "$f"; done
|
|
||||||
DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469full" \\
|
|
||||||
uv run python -m pytest tests/test_3469_showcase_schedule.py -q
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from scraper_kit.orchestration import scheduler as sched
|
|
||||||
|
|
||||||
from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
|
|
||||||
|
|
||||||
SOURCE = "landing_showcase_deals"
|
|
||||||
|
|
||||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
|
||||||
_MIGRATION = _SQL_DIR / "303_scrape_schedules_seed_landing_showcase_deals.sql"
|
|
||||||
# Таблицы, без которых строку расписания некуда класть (FK scrape_schedules →
|
|
||||||
# scrape_runs), — живой тест применяет их в том же порядке, что и деплой.
|
|
||||||
_DEPS = [
|
|
||||||
_SQL_DIR / "015_scrape_runs.sql",
|
|
||||||
# counters jsonb — по нему сводка судит, принёс ли прогон данные.
|
|
||||||
_SQL_DIR / "051_scrape_runs_extend.sql",
|
|
||||||
_SQL_DIR / "052_scrape_schedules.sql",
|
|
||||||
]
|
|
||||||
|
|
||||||
NOW = datetime(2026, 9, 12, 8, 0, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _migration_sql() -> str:
|
|
||||||
return _MIGRATION.read_text("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _seeded_interval_days() -> int:
|
|
||||||
"""Такт из САМОЙ миграции — порог сводки считается из него, не из литерала."""
|
|
||||||
m = re.search(r'"interval_days"\s*:\s*(\d+)', _migration_sql())
|
|
||||||
assert m is not None, "в default_params миграции 303 нет interval_days"
|
|
||||||
return int(m.group(1))
|
|
||||||
|
|
||||||
|
|
||||||
# ── 1. Планировщик видит задачу ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_handler_resolves_for_scheduler() -> None:
|
|
||||||
"""`resolve_handler` находит витрину — тем же вызовом, что и боевой _dispatch.
|
|
||||||
|
|
||||||
Ломать так: убрать ключ из реестра в product_handlers — тест покраснеет, а
|
|
||||||
планировщик на проде заклеймил бы прогон и не нашёл, чем его выполнить.
|
|
||||||
"""
|
|
||||||
registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
|
|
||||||
handler = sched.resolve_handler(SOURCE, registry)
|
|
||||||
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
|
|
||||||
# СРАВНИВАЕМ САМ JOB, А НЕ `log_name`: имя — второй литерал конструктора
|
|
||||||
# Handler, и правильный ключ с чужим телом (`_job_landing_stats` под ключом
|
|
||||||
# витрины) проходил проверку по имени насквозь. Резолв ведёт к пересчёту
|
|
||||||
# витрины или не ведёт — это свойство функции, а не подписи в логе.
|
|
||||||
assert handler.job is _job_landing_showcase_deals, (
|
|
||||||
f"под ключом {SOURCE} стоит чужой job: {handler.job.__name__}"
|
|
||||||
)
|
|
||||||
assert handler.log_name == SOURCE
|
|
||||||
|
|
||||||
|
|
||||||
# ── 2. Миграция сеет строку ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_exists() -> None:
|
|
||||||
assert _MIGRATION.is_file(), f"missing migration: {_MIGRATION}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_seeds_source_enabled() -> None:
|
|
||||||
sql = _migration_sql()
|
|
||||||
assert f"'{SOURCE}'" in sql
|
|
||||||
assert "INSERT INTO scrape_schedules" in sql
|
|
||||||
# enabled=true — иначе сводка просроченных источников строку не увидит
|
|
||||||
# (_STALE_SOURCES_SQL: WHERE sch.enabled), и монитор молчал бы как раньше.
|
|
||||||
assert re.search(rf"'{SOURCE}',\s*\n\s*true", sql), "расписание засеяно выключенным"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_is_idempotent_and_transactional() -> None:
|
|
||||||
sql = _migration_sql()
|
|
||||||
assert "ON CONFLICT (source) DO NOTHING" in sql
|
|
||||||
assert "BEGIN;" in sql
|
|
||||||
assert "COMMIT;" in sql
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_no_psycopg_trap() -> None:
|
|
||||||
assert not re.search(r":\w+::", _migration_sql())
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_interval_days_is_daily() -> None:
|
|
||||||
"""Такт суточный: витрина устаревает от КОДА (деплой), а не от квартальных сделок."""
|
|
||||||
assert _seeded_interval_days() == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ── 3. Сводка свежести краснеет на молчащей витрине ──────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _row(age_days: float, *, status: str | None = "done") -> Any:
|
|
||||||
"""Строка `_STALE_SOURCES_SQL`: прогон витрины `age_days` суток назад.
|
|
||||||
|
|
||||||
`status=None` (LEFT JOIN не нашёл прогонов) — витрину не пересчитывали ни разу
|
|
||||||
с момента появления расписания; тогда возраст считается от created_at строки.
|
|
||||||
"""
|
|
||||||
finished = None if status is None else NOW - timedelta(days=age_days)
|
|
||||||
return SimpleNamespace(
|
|
||||||
source=SOURCE,
|
|
||||||
interval_days=str(_seeded_interval_days()),
|
|
||||||
created_at=NOW - timedelta(days=age_days),
|
|
||||||
finished_at=finished,
|
|
||||||
status=status,
|
|
||||||
counters={"considered": 200, "eligible": 161, "written": 20},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stale_now(rows: list[Any]) -> list[sched.StaleSource]:
|
|
||||||
return sched.stale_sources(sched.freshness_rows(rows), NOW)
|
|
||||||
|
|
||||||
|
|
||||||
# Приёмка issue #3469 дословно: «отсутствие прогона дольше 3× такта даёт тревогу».
|
|
||||||
# ЧИСЛО ЗДЕСЬ ЛИТЕРАЛ, А НЕ `sched.STALE_DIGEST_INTERVAL_FACTOR`. Взятое из той же
|
|
||||||
# настройки, которую проверяем, ожидание уезжает вместе с ней: при факторе 3650
|
|
||||||
# ЭТИ ЖЕ тесты оставались зелёными (проверено руками), то есть проверяли ровно
|
|
||||||
# ничего. Такт (`interval_days`) при этом читается из миграции — правило «3×»
|
|
||||||
# и задано в тактах, а не в сутках.
|
|
||||||
_ACCEPTANCE_CYCLES = 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_digest_flags_showcase_after_three_cycles() -> None:
|
|
||||||
"""Нет пересчёта дольше 3× такта → витрина в сводке просроченных."""
|
|
||||||
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
|
||||||
stale = _stale_now([_row(lag)])
|
|
||||||
assert [s.source for s in stale] == [SOURCE], (
|
|
||||||
f"витрина молчит {lag} суток при такте {_seeded_interval_days()} и не в тревоге"
|
|
||||||
)
|
|
||||||
assert stale[0].interval_days == _seeded_interval_days()
|
|
||||||
|
|
||||||
|
|
||||||
def test_digest_silent_within_cycle() -> None:
|
|
||||||
"""Контроль: два такта — ещё норма, иначе тревога кричала бы всегда."""
|
|
||||||
lag = 2 * _seeded_interval_days()
|
|
||||||
assert _stale_now([_row(lag)]) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_digest_flags_showcase_that_never_ran() -> None:
|
|
||||||
"""Расписание есть, прогонов нет — самый частый вид молчания (#3469 и был им)."""
|
|
||||||
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 1
|
|
||||||
stale = _stale_now([_row(lag, status=None)])
|
|
||||||
assert [s.source for s in stale] == [SOURCE]
|
|
||||||
assert stale[0].never_ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_showcase_counters_do_not_fake_freshness() -> None:
|
|
||||||
"""Свежесть даёт ПРОГОН, а не его счётчики.
|
|
||||||
|
|
||||||
`run_brought_data` судит по результатным ключам kit'а, а у витрины их нет
|
|
||||||
(`considered`/`eligible`/`written` — свой словарь). Значит мерой остаётся
|
|
||||||
успешный статус: прогон, свалившийся в failed, свежести не даёт.
|
|
||||||
"""
|
|
||||||
assert sched.run_brought_data("done", {"considered": 200, "written": 20}) is True
|
|
||||||
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _live_session() -> Any | None:
|
|
||||||
"""Session на живой Postgres — та же проба, что у соседних живых тестов.
|
|
||||||
|
|
||||||
`TEST_DATABASE_URL` имеет приоритет; `localhost:5432/test` — заглушка модулей,
|
|
||||||
её не считаем базой. В CI сюда приезжает DSN поднятого в job'е контейнера
|
|
||||||
(ci-tradein.yml), поэтому проверка там ИДЁТ, а не тихо скипается.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
|
||||||
if not dsn or "localhost:5432/test" in dsn:
|
|
||||||
return None
|
|
||||||
engine = create_engine(dsn, future=True)
|
|
||||||
with engine.connect() as conn:
|
|
||||||
conn.execute(text("SELECT 1"))
|
|
||||||
return sessionmaker(bind=engine, future=True)()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _apply(db: Any, path: Path) -> None:
|
|
||||||
"""Прогнать файл миграции целиком, одним куском — как `psql -f` на деплое.
|
|
||||||
|
|
||||||
Через ДРАЙВЕРНОЕ соединение, а не `exec_driver_sql`: последний отдаёт текст
|
|
||||||
psycopg вместе с пустым набором параметров, и тот начинает искать в нём
|
|
||||||
плейсхолдеры — любой процент в комментарии миграции («полоса −5..+20 %»)
|
|
||||||
роняет запуск ошибкой про `%`. psql такого разбора не делает, так что это
|
|
||||||
артефакт теста, а не свойство файла.
|
|
||||||
"""
|
|
||||||
db.connection().connection.driver_connection.execute(path.read_text("utf-8"))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
|
||||||
def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
|
|
||||||
"""Миграция кладёт строку в scrape_schedules, и сводка видит витрину просроченной.
|
|
||||||
|
|
||||||
Значение, а не текст файла: применяем 303 на живой базе (дважды — дублей быть
|
|
||||||
не должно), читаем строку обратно и прогоняем настоящий `_STALE_SOURCES_SQL` —
|
|
||||||
тот же запрос, которым сводка судит на проде.
|
|
||||||
|
|
||||||
Ничего не удаляем: обе миграции идемпотентны (CREATE TABLE IF NOT EXISTS /
|
|
||||||
ON CONFLICT DO NOTHING), то есть повтор здесь — ровно то же действие, что и
|
|
||||||
повторный деплой.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
db = _live_session()
|
|
||||||
assert db is not None
|
|
||||||
try:
|
|
||||||
# Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
|
|
||||||
# (CI и прод), повтор 015 падает: `CREATE TABLE IF NOT EXISTS` — no-op,
|
|
||||||
# а `COMMENT ON COLUMN scrape_runs.returning_count` внизу того же файла
|
|
||||||
# обращается к колонке, которую снесла 214. Файл идемпотентен
|
|
||||||
# относительно себя, но не относительно схемы, прошедшей 214, — и
|
|
||||||
# прогон на чистой базе этого не видит по построению.
|
|
||||||
if db.execute(text("SELECT to_regclass('public.scrape_schedules')")).scalar() is None:
|
|
||||||
for dep in _DEPS:
|
|
||||||
_apply(db, dep)
|
|
||||||
_apply(db, _MIGRATION)
|
|
||||||
# ИДЕМПОТЕНТНОСТЬ МЕРЯЕТСЯ ПО СОСТОЯНИЮ СТРОКИ, А НЕ ПО ЧИСЛУ СТРОК.
|
|
||||||
# «DELETE + INSERT» тоже оставляет ровно одну строку, но на КАЖДОМ
|
|
||||||
# деплое стирает last_run_at/next_run_at и взводит расписание заново —
|
|
||||||
# счёт строк такую замену не отличает, а created_at отличает.
|
|
||||||
first_created_at = db.execute(
|
|
||||||
text("SELECT created_at FROM scrape_schedules WHERE source = :s"), {"s": SOURCE}
|
|
||||||
).scalar()
|
|
||||||
_apply(db, _MIGRATION)
|
|
||||||
|
|
||||||
rows = db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT enabled, window_start_hour, window_end_hour, created_at, "
|
|
||||||
" (next_run_at > now()) AS next_run_ahead, "
|
|
||||||
" default_params->>'interval_days' AS interval_days "
|
|
||||||
"FROM scrape_schedules WHERE source = :s"
|
|
||||||
),
|
|
||||||
{"s": SOURCE},
|
|
||||||
).fetchall()
|
|
||||||
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
|
|
||||||
row = rows[0]
|
|
||||||
assert row.created_at == first_created_at, (
|
|
||||||
"повторное применение пересоздало строку расписания — на каждом деплое "
|
|
||||||
"это стирало бы состояние прогонов (last_run_at/next_run_at)"
|
|
||||||
)
|
|
||||||
assert row.enabled is True
|
|
||||||
assert (row.window_start_hour, row.window_end_hour) == (6, 7)
|
|
||||||
assert int(row.interval_days) == _seeded_interval_days()
|
|
||||||
# next_run_at в БУДУЩЕМ: сев расписания не должен выстреливать прогоном
|
|
||||||
# в момент деплоя (образец — 162/275). Стоит в приёмке, значит и здесь.
|
|
||||||
assert row.next_run_ahead is True, "next_run_at в прошлом — прогон стартует на деплое"
|
|
||||||
|
|
||||||
# Сводка: прогонов у витрины нет, возраст считается от created_at строки.
|
|
||||||
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
|
|
||||||
assert SOURCE in {r.source for r in digest_rows}, "сводка не видит витрину"
|
|
||||||
|
|
||||||
overdue_at = row.created_at + timedelta(
|
|
||||||
days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
|
||||||
)
|
|
||||||
stale = sched.stale_sources(sched.freshness_rows(digest_rows), overdue_at)
|
|
||||||
assert SOURCE in {s.source for s in stale}, "витрина без прогонов не попала в тревогу"
|
|
||||||
|
|
||||||
fresh_at = row.created_at + timedelta(hours=1)
|
|
||||||
fresh = sched.stale_sources(sched.freshness_rows(digest_rows), fresh_at)
|
|
||||||
assert SOURCE not in {s.source for s in fresh}, "тревога сразу после сева — ложная"
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
@ -70,7 +70,7 @@ def glitchtip_events() -> Iterator[list[dict[str, Any]]]:
|
||||||
yield events
|
yield events
|
||||||
finally:
|
finally:
|
||||||
# Иначе на каждый тест остаётся фоновый поток транспорта.
|
# Иначе на каждый тест остаётся фоновый поток транспорта.
|
||||||
client.close(timeout=0)
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def event_texts(events: list[dict[str, Any]]) -> list[str]:
|
def event_texts(events: list[dict[str, Any]]) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -81,27 +81,26 @@ def test_transient_error_returns_none_and_does_not_cache() -> None:
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
|
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
|
||||||
assert (ratio, basis) == (None, None)
|
assert (ratio, basis) == (None, None)
|
||||||
assert db.rolled_back is True
|
assert db.rolled_back is True
|
||||||
# #3512: ключ кэша — (region_code, bucket); region_code не передан → дефолт 66.
|
# bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
|
||||||
# (66, 2) НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
|
assert 2 not in estimator._asking_sold_ratio_cache
|
||||||
assert (66, 2) not in estimator._asking_sold_ratio_cache
|
|
||||||
|
|
||||||
|
|
||||||
def test_transient_error_then_success_retries_not_poisoned() -> None:
|
def test_transient_error_then_success_retries_not_poisoned() -> None:
|
||||||
# 1) транзиентный сбой — None, кэш не отравлен
|
# 1) транзиентный сбой — None, кэш не отравлен
|
||||||
r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type]
|
r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type]
|
||||||
assert r1 is None
|
assert r1 is None
|
||||||
assert (66, 2) not in estimator._asking_sold_ratio_cache
|
assert 2 not in estimator._asking_sold_ratio_cache
|
||||||
# 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio
|
# 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio
|
||||||
r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type]
|
r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type]
|
||||||
assert r2 == pytest.approx(0.84)
|
assert r2 == pytest.approx(0.84)
|
||||||
assert basis2 == "per_rooms"
|
assert basis2 == "per_rooms"
|
||||||
# теперь успешный результат закэширован
|
# теперь успешный результат закэширован
|
||||||
assert estimator._asking_sold_ratio_cache[(66, 2)][0] == pytest.approx(0.84)
|
assert estimator._asking_sold_ratio_cache[2][0] == pytest.approx(0.84)
|
||||||
|
|
||||||
|
|
||||||
def test_successful_lookup_is_cached() -> None:
|
def test_successful_lookup_is_cached() -> None:
|
||||||
r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type]
|
r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type]
|
||||||
assert r == pytest.approx(0.77)
|
assert r == pytest.approx(0.77)
|
||||||
assert basis == "per_rooms"
|
assert basis == "per_rooms"
|
||||||
cached = estimator._asking_sold_ratio_cache.get((66, 1))
|
cached = estimator._asking_sold_ratio_cache.get(1)
|
||||||
assert cached is not None and cached[0] == pytest.approx(0.77)
|
assert cached is not None and cached[0] == pytest.approx(0.77)
|
||||||
|
|
|
||||||
|
|
@ -34,22 +34,8 @@ _MIGRATION_082 = _SQL_DIR / "082_scrape_schedules_seed_ratio_refresh.sql"
|
||||||
# Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string).
|
# Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string).
|
||||||
_DELETE_SQL = str(ratio_mod._DELETE_SQL.text)
|
_DELETE_SQL = str(ratio_mod._DELETE_SQL.text)
|
||||||
_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text)
|
_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text)
|
||||||
_DELETE_SQL_REGION = str(ratio_mod._DELETE_SQL_REGION.text)
|
|
||||||
_REDERIVE_SQL_REGION = str(ratio_mod._REDERIVE_SQL_REGION.text)
|
|
||||||
_COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text)
|
_COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text)
|
||||||
_ALL_SQL = (
|
_ALL_SQL = _DELETE_SQL + "\n" + _REDERIVE_SQL + "\n" + _COUNTERS_SQL
|
||||||
_DELETE_SQL
|
|
||||||
+ "\n"
|
|
||||||
+ _REDERIVE_SQL
|
|
||||||
+ "\n"
|
|
||||||
+ _DELETE_SQL_REGION
|
|
||||||
+ "\n"
|
|
||||||
+ _REDERIVE_SQL_REGION
|
|
||||||
+ "\n"
|
|
||||||
+ str(ratio_mod._GEO_STATS_SQL_REGION.text)
|
|
||||||
+ "\n"
|
|
||||||
+ _COUNTERS_SQL
|
|
||||||
)
|
|
||||||
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
|
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,17 +43,14 @@ _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_deletes_district_rows_first() -> None:
|
def test_refresh_deletes_district_rows_first() -> None:
|
||||||
"""True-mirror: DELETE FROM asking_to_sold_ratios WHERE region=66/district='' first.
|
"""True-mirror: DELETE FROM asking_to_sold_ratios WHERE district='' before the INSERT.
|
||||||
|
|
||||||
The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets
|
The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets
|
||||||
that drop below 30/30 on a later run. The refresh must DELETE first instead.
|
that drop below 30/30 on a later run. The refresh must DELETE first instead.
|
||||||
|
|
||||||
#3512: the EKB delete is now scoped to `region_code = 66` too (region-aware ratios) —
|
|
||||||
the other-region loop uses its own _DELETE_SQL_REGION, tested separately below.
|
|
||||||
"""
|
"""
|
||||||
flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip()
|
flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip()
|
||||||
assert "DELETE FROM asking_to_sold_ratios" in flat_del
|
assert "DELETE FROM asking_to_sold_ratios" in flat_del
|
||||||
assert "WHERE region_code = 66 AND district = ''" in flat_del
|
assert "WHERE district = ''" in flat_del
|
||||||
# The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it).
|
# The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it).
|
||||||
assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL
|
assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL
|
||||||
assert "ON CONFLICT" not in _REDERIVE_SQL
|
assert "ON CONFLICT" not in _REDERIVE_SQL
|
||||||
|
|
@ -295,28 +278,16 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
||||||
"""Remove the #2656 freshness predicate (absent in the 080 seed)."""
|
"""Remove the #2656 freshness predicate (absent in the 080 seed)."""
|
||||||
return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s)
|
return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s)
|
||||||
|
|
||||||
def _drop_region_code_column(s: str) -> str:
|
|
||||||
"""Remove the #3512 region_code column/literal (absent in the 080 seed).
|
|
||||||
|
|
||||||
Migration 304 adds region_code to the table; the refresh INSERT now writes it
|
|
||||||
explicitly (region_code, ... ) / ( ..., 66) — the 080 seed predates the column.
|
|
||||||
"""
|
|
||||||
s = re.sub(r"basis,\s*region_code", "basis", s)
|
|
||||||
s = re.sub(r"basis,\s*66\s+FROM", "basis FROM", s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
def _norm(s: str) -> str:
|
def _norm(s: str) -> str:
|
||||||
return _strip_sql(
|
return _strip_sql(
|
||||||
_normalise_ppm2(
|
_normalise_ppm2(
|
||||||
_normalise_bucket_expr(
|
_normalise_bucket_expr(
|
||||||
_drop_freshness_guard(
|
_drop_freshness_guard(
|
||||||
_drop_region_code_column(
|
|
||||||
_drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s)))
|
_drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s)))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
|
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
|
||||||
|
|
||||||
|
|
@ -435,10 +406,10 @@ class _FakeMappingResult:
|
||||||
class _FakeDB:
|
class _FakeDB:
|
||||||
"""Minimal stand-in for a SQLAlchemy Session — records execute() calls.
|
"""Minimal stand-in for a SQLAlchemy Session — records execute() calls.
|
||||||
|
|
||||||
#3512: execute call order is now EKB (DELETE + INSERT) followed by a
|
Execute call order:
|
||||||
DELETE+INSERT pair per _OTHER_REGION_CODES region, then ONE counters SELECT
|
1. DELETE asking_to_sold_ratios
|
||||||
at the very end — identified by statement IDENTITY (`stmt is ratio_mod._COUNTERS_SQL`),
|
2. REDERIVE INSERT asking_to_sold_ratios
|
||||||
not by a hardcoded call index, so the fake stays correct if the region registry grows.
|
3. COUNTERS SELECT (returns counters_row)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -452,24 +423,9 @@ class _FakeDB:
|
||||||
|
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
|
||||||
self.executed.append((stmt, params))
|
self.executed.append((stmt, params))
|
||||||
if stmt is ratio_mod._COUNTERS_SQL:
|
# 3rd call is the counters SELECT (asking_to_sold_ratios).
|
||||||
|
if len(self.executed) == 3:
|
||||||
return _FakeMappingResult(self._counters_row)
|
return _FakeMappingResult(self._counters_row)
|
||||||
if stmt is ratio_mod._GEO_STATS_SQL_REGION:
|
|
||||||
# #3529: здоровый регион — гео-гард пропускает, деривация выполняется.
|
|
||||||
return _FakeMappingResult(
|
|
||||||
{
|
|
||||||
"deals_total": 1000,
|
|
||||||
"deals_geo": 950,
|
|
||||||
"listings_total": 2000,
|
|
||||||
"listings_geo": 2000,
|
|
||||||
"cells_deal": 20,
|
|
||||||
"cells_ask": 22,
|
|
||||||
"cells_both_sides": 18,
|
|
||||||
"cells_matched": 16,
|
|
||||||
"deals_in_cells": 900,
|
|
||||||
"listings_in_cells": 1500,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return _FakeMappingResult(None)
|
return _FakeMappingResult(None)
|
||||||
|
|
||||||
def commit(self) -> None:
|
def commit(self) -> None:
|
||||||
|
|
@ -482,8 +438,8 @@ class _FakeDB:
|
||||||
def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""recompute_asking_to_sold_ratios maps the counters row into its return + marks done.
|
"""recompute_asking_to_sold_ratios maps the counters row into its return + marks done.
|
||||||
|
|
||||||
#3512: FakeDB now sees 2 statements for EKB + 2 per _OTHER_REGION_CODES region +
|
#2002: tiered refresh removed — counters are the 3 legacy keys, FakeDB has 3
|
||||||
1 final COUNTERS SELECT (counters are still the 3 legacy keys, #2002).
|
execute() calls (DELETE + REDERIVE INSERT + COUNTERS SELECT).
|
||||||
"""
|
"""
|
||||||
marked: dict[str, Any] = {}
|
marked: dict[str, Any] = {}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -503,22 +459,12 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"per_rooms_rows": 3,
|
"per_rooms_rows": 3,
|
||||||
"used_global_fallback": 1,
|
"used_global_fallback": 1,
|
||||||
}
|
}
|
||||||
for key, value in expected.items():
|
assert out == expected
|
||||||
assert out[key] == value
|
|
||||||
assert db.committed is True
|
assert db.committed is True
|
||||||
# #3529: у каждого прочего региона теперь ТРИ стейтмента — geo-stats SELECT,
|
# Three statements: DELETE + REDERIVE INSERT + COUNTERS SELECT (legacy per-rooms).
|
||||||
# DELETE и (если гард пропустил) INSERT; счётчики несут гео-разрез.
|
assert len(db.executed) == 3
|
||||||
expected_calls = 2 + 3 * len(ratio_mod._OTHER_REGION_CODES) + 1
|
|
||||||
assert len(db.executed) == expected_calls
|
|
||||||
assert db.executed[-1][0] is ratio_mod._COUNTERS_SQL
|
|
||||||
assert out["geo_regions_written"] == len(ratio_mod._OTHER_REGION_CODES)
|
|
||||||
assert out["geo_regions_skipped"] == 0
|
|
||||||
for code in ratio_mod._OTHER_REGION_CODES:
|
|
||||||
assert out[f"geo_r{code}_cells_matched"] == 16
|
|
||||||
assert out[f"geo_r{code}_deals_no_geom_pct"] == 5
|
|
||||||
assert out[f"geo_r{code}_skipped"] == 0
|
|
||||||
assert marked["run_id"] == 99
|
assert marked["run_id"] == 99
|
||||||
assert marked["counters"] == out
|
assert marked["counters"] == expected
|
||||||
|
|
||||||
|
|
||||||
def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
@ -605,92 +551,3 @@ def test_area_bucket_matches_sql_boundaries() -> None:
|
||||||
for i, bound in enumerate(sql_bounds):
|
for i, bound in enumerate(sql_bounds):
|
||||||
assert ratio_mod.area_bucket(bound - 0.01) == i
|
assert ratio_mod.area_bucket(bound - 0.01) == i
|
||||||
assert ratio_mod.area_bucket(bound) == i + 1
|
assert ratio_mod.area_bucket(bound) == i + 1
|
||||||
|
|
||||||
|
|
||||||
# ── #3512: per-region ratio (migration 304) ───────────────────────────────────
|
|
||||||
|
|
||||||
_MIGRATION_304 = _SQL_DIR / "304_asking_to_sold_ratios_region.sql"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_304_exists() -> None:
|
|
||||||
assert _MIGRATION_304.is_file(), f"missing migration: {_MIGRATION_304}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_304_adds_region_code_and_composite_pk() -> None:
|
|
||||||
sql = _MIGRATION_304.read_text("utf-8")
|
|
||||||
assert "ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66" in sql
|
|
||||||
assert "PRIMARY KEY (region_code, rooms_bucket, district)" in sql
|
|
||||||
# PK-guard идемпотентен (тот же паттерн, что 298_deal_city_price_bands_region.sql).
|
|
||||||
assert "pg_get_constraintdef" in sql
|
|
||||||
assert "DROP CONSTRAINT asking_to_sold_ratios_pkey" in sql
|
|
||||||
assert "BEGIN;" in sql and "COMMIT;" in sql
|
|
||||||
assert "SET LOCAL lock_timeout" in sql
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_304_uses_psycopg_safe_sql() -> None:
|
|
||||||
sql = _MIGRATION_304.read_text("utf-8")
|
|
||||||
assert not re.search(r":\w+::", sql)
|
|
||||||
|
|
||||||
|
|
||||||
def test_other_region_codes_matches_registry_minus_ekb() -> None:
|
|
||||||
"""_OTHER_REGION_CODES — весь реестр регионов (regions_mod.REGIONS) минус 66.
|
|
||||||
|
|
||||||
Новый регион в реестре автоматически подхватывается пересчётом ratio (#3512) —
|
|
||||||
без правки этого файла.
|
|
||||||
"""
|
|
||||||
from app.services import regions as regions_mod
|
|
||||||
|
|
||||||
expected = tuple(sorted(code for code in regions_mod.REGIONS if code != 66))
|
|
||||||
assert ratio_mod._OTHER_REGION_CODES == expected
|
|
||||||
assert 66 not in ratio_mod._OTHER_REGION_CODES
|
|
||||||
assert 77 in ratio_mod._OTHER_REGION_CODES # Москва — уже в реестре (#3051)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rederive_sql_region_scopes_by_region_code_not_city() -> None:
|
|
||||||
"""_REDERIVE_SQL_REGION скоупит ОБЕ стороны по region_code, БЕЗ городской квоты ЕКБ.
|
|
||||||
|
|
||||||
В отличие от _REDERIVE_SQL (byte-identical historical EKB derivation), генерик-путь
|
|
||||||
для остальных регионов не несёт `city ILIKE` — эта квота была костылём конкретно
|
|
||||||
ЕКБ-исторического asking-покрытия (#C2), не общим правилом (#3512).
|
|
||||||
"""
|
|
||||||
assert "city ILIKE" not in _REDERIVE_SQL_REGION
|
|
||||||
# #3529: стороны собираются по одному разу (deal_geo / ask_geo), дальше всё считается
|
|
||||||
# по ячейкам — поэтому region_code-предикат ровно два раза, а не четыре.
|
|
||||||
assert _REDERIVE_SQL_REGION.count("region_code = CAST(:region_code AS int)") == 2
|
|
||||||
assert "FROM deals" in _REDERIVE_SQL_REGION
|
|
||||||
assert "FROM listings" in _REDERIVE_SQL_REGION
|
|
||||||
# Тот же публикуемый порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации — порог
|
|
||||||
# теперь применяется к СУММЕ по удержанным гео-ячейкам (#3529).
|
|
||||||
assert "HAVING SUM(n_deals) >= 30" in _REDERIVE_SQL_REGION
|
|
||||||
assert "AND SUM(n_listings) >= 30" in _REDERIVE_SQL_REGION
|
|
||||||
assert "deal_date >= CURRENT_DATE - INTERVAL '12 months'" in _REDERIVE_SQL_REGION
|
|
||||||
# INSERT пишет параметризованный регион, а не литерал 66.
|
|
||||||
assert "CAST(:region_code AS int) FROM global_row" in _REDERIVE_SQL_REGION
|
|
||||||
assert "CAST(:region_code AS int) FROM per_bucket" in _REDERIVE_SQL_REGION
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_sql_region_scopes_by_region_code() -> None:
|
|
||||||
flat = re.sub(r"\s+", " ", _DELETE_SQL_REGION).strip()
|
|
||||||
assert "DELETE FROM asking_to_sold_ratios" in flat
|
|
||||||
assert "WHERE region_code = CAST(:region_code AS int) AND district = ''" in flat
|
|
||||||
|
|
||||||
|
|
||||||
def test_rederive_sql_writes_region_code_66_literal() -> None:
|
|
||||||
"""_REDERIVE_SQL (ЕКБ-путь) теперь пишет region_code=66 явно в новую колонку."""
|
|
||||||
assert "region_code" in _REDERIVE_SQL
|
|
||||||
assert "basis, 66 FROM global_row" in _REDERIVE_SQL
|
|
||||||
assert "basis, 66 FROM per_bucket" in _REDERIVE_SQL
|
|
||||||
# Числа для 66 не должны были поменяться: городская квота ЕКБ (_ASKING_CITY_PATTERN)
|
|
||||||
# осталась нетронутой в обоих deal-CTE и обоих ask-CTE.
|
|
||||||
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
|
|
||||||
assert _REDERIVE_SQL.count("AND (city IS NULL OR city ILIKE :asking_city)") == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_loops_other_regions_between_ekb_and_counters() -> None:
|
|
||||||
"""recompute_asking_to_sold_ratios: EKB derive -> other-regions loop -> counters."""
|
|
||||||
ekb_pos = _TASK_SRC.index("_REDERIVE_SQL,")
|
|
||||||
loop_pos = _TASK_SRC.index("_OTHER_REGION_CODES")
|
|
||||||
region_delete_pos = _TASK_SRC.index("_DELETE_SQL_REGION")
|
|
||||||
region_insert_pos = _TASK_SRC.index("_REDERIVE_SQL_REGION,")
|
|
||||||
counters_pos = _TASK_SRC.index("_COUNTERS_SQL")
|
|
||||||
assert ekb_pos < loop_pos < region_delete_pos < region_insert_pos < counters_pos
|
|
||||||
|
|
|
||||||
|
|
@ -215,8 +215,7 @@ async def test_backfill_yandex_addresses_error_on_non_200():
|
||||||
return_value=mock_session,
|
return_value=mock_session,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# request_delay_sec=0: реальный анти-бот sleep(3с) тут не нужен, 1 item в батче.
|
result = await backfill_yandex_addresses(MagicMock(), limit=10)
|
||||||
result = await backfill_yandex_addresses(MagicMock(), limit=10, request_delay_sec=0)
|
|
||||||
|
|
||||||
assert result.errors == 1
|
assert result.errors == 1
|
||||||
assert result.saved == 0
|
assert result.saved == 0
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,7 @@ def test_resolve_city_name_known_oblast_slugs() -> None:
|
||||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||||
"serov": "Серов",
|
"serov": "Серов",
|
||||||
}
|
}
|
||||||
# msk-rollout: многогородние скоупы (MULTI_CITY_SLUGS — область и
|
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS)
|
||||||
# «Москва+область») намеренно БЕЗ display-имени: в их batch'ах городов
|
|
||||||
# несколько, и любой один штамп city был бы ложью — resolve_city_name
|
|
||||||
# отдаёт для них None, а save_listings пишет city=NULL.
|
|
||||||
from scraper_kit.orchestration.pipeline import MULTI_CITY_SLUGS
|
|
||||||
|
|
||||||
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) - MULTI_CITY_SLUGS
|
|
||||||
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
|
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
|
||||||
for slug, name in wave1_expected.items():
|
for slug, name in wave1_expected.items():
|
||||||
assert resolve_city_name(slug) == name
|
assert resolve_city_name(slug) == name
|
||||||
|
|
@ -82,19 +76,11 @@ def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None:
|
||||||
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
|
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_city_name_unknown_slug_raises() -> None:
|
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
|
||||||
"""Неизвестный slug ПАДАЕТ (msk-rollout), а не штампует молча «Екатеринбург».
|
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors."""
|
||||||
|
|
||||||
Раньше здесь был ЕКБ-дефолт — симметрично get_city_location/get_city_anchors.
|
|
||||||
Оба они теперь тоже падают: с приходом Москвы тихий откат на ЕКБ означал бы,
|
|
||||||
что развёртка с опечаткой в слаге собирает Екатеринбург под чужим именем города,
|
|
||||||
со штатными счётчиками и без единой аномалии в логах.
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from scraper_kit.orchestration.pipeline import resolve_city_name
|
from scraper_kit.orchestration.pipeline import resolve_city_name
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="nonexistent_city"):
|
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
|
||||||
resolve_city_name("nonexistent_city")
|
|
||||||
|
|
||||||
|
|
||||||
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный
|
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный
|
||||||
|
|
|
||||||
|
|
@ -535,14 +535,19 @@ def test_sverdlovsk_resolution_untouched_by_moscow_centroids(
|
||||||
|
|
||||||
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
|
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
|
||||||
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
|
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
|
||||||
#
|
|
||||||
# 2026-09-16: 12 из исходных 16 (Химки, Мытищи, Люберцы, Балашиха, Красногорск,
|
|
||||||
# Одинцово, Подольск, Реутов, Королёв, Долгопрудный, Видное, Железнодорожный)
|
|
||||||
# получили собственный порог покрытия — см. COVERAGE_MO_CITIES в
|
|
||||||
# app/api/v1/trade_in.py — и больше не резолвятся в «город не определён».
|
|
||||||
# Здесь остаются только 4 города, которые НЕ замерялись и остаются
|
|
||||||
# отрицательными точками.
|
|
||||||
_MOSCOW_OBLAST_CITIES = {
|
_MOSCOW_OBLAST_CITIES = {
|
||||||
|
"Химки": (55.8894, 37.4450),
|
||||||
|
"Мытищи": (55.9116, 37.7308),
|
||||||
|
"Люберцы": (55.6767, 37.8931),
|
||||||
|
"Балашиха": (55.7969, 37.9386),
|
||||||
|
"Красногорск": (55.8317, 37.3300),
|
||||||
|
"Одинцово": (55.6789, 37.2639),
|
||||||
|
"Подольск": (55.4312, 37.5450),
|
||||||
|
"Реутов": (55.7614, 37.8564),
|
||||||
|
"Королёв": (55.9142, 37.8256),
|
||||||
|
"Долгопрудный": (55.9386, 37.5100),
|
||||||
|
"Видное": (55.5519, 37.7133),
|
||||||
|
"Железнодорожный": (55.7444, 38.0128),
|
||||||
"Лобня": (56.0100, 37.4750),
|
"Лобня": (56.0100, 37.4750),
|
||||||
"Дзержинский": (55.6294, 37.8500),
|
"Дзержинский": (55.6294, 37.8500),
|
||||||
"Котельники": (55.6553, 37.8619),
|
"Котельники": (55.6553, 37.8619),
|
||||||
|
|
@ -571,11 +576,7 @@ def test_every_negative_centroid_resolves_to_no_city() -> None:
|
||||||
_resolve_coverage_city,
|
_resolve_coverage_city,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 32 - 12: 12 городов (Реутов, Подольск, Химки, Одинцово, Мытищи,
|
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32
|
||||||
# Красногорск, Люберцы, Балашиха, Долгопрудный, Королёв, Видное,
|
|
||||||
# Железнодорожный) переехали в позитивный список 2026-09-16 —
|
|
||||||
# COVERAGE_MO_CITIES, см. app/api/v1/trade_in.py.
|
|
||||||
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 20
|
|
||||||
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
|
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
|
||||||
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
|
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
|
||||||
|
|
||||||
|
|
@ -617,17 +618,12 @@ def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> Non
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
|
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
|
||||||
"""Истра через ручку: 200 и not_covered, а не 500 от KeyError по порогу.
|
"""Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу."""
|
||||||
|
|
||||||
Была "Химки" до 2026-09-16 — теперь Химки сама покрытый город
|
|
||||||
(COVERAGE_MO_CITIES), взята следующая точка, которая всё ещё "не
|
|
||||||
определена" (см. комментарий над _MOSCOW_OBLAST_CITIES).
|
|
||||||
"""
|
|
||||||
db = _db_mock_returning(_row(40, 30.0))
|
db = _db_mock_returning(_row(40, 30.0))
|
||||||
_override(trade_in_app, db)
|
_override(trade_in_app, db)
|
||||||
|
|
||||||
client = TestClient(trade_in_app)
|
client = TestClient(trade_in_app)
|
||||||
lat, lon = _MOSCOW_OBLAST_CITIES["Истра"]
|
lat, lon = _MOSCOW_OBLAST_CITIES["Химки"]
|
||||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
|
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
@ -682,7 +678,6 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids(
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES}
|
|
||||||
sverdlovsk = {
|
sverdlovsk = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||||||
|
|
@ -690,7 +685,6 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids(
|
||||||
not in {
|
not in {
|
||||||
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||||||
}
|
}
|
||||||
and trade_in_module._fold_city(k) not in mo_cities_folded
|
|
||||||
}
|
}
|
||||||
assert len(sverdlovsk) == 9
|
assert len(sverdlovsk) == 9
|
||||||
|
|
||||||
|
|
@ -759,26 +753,22 @@ _MOSCOW_CONTROLS = {
|
||||||
"Троицк (ТАО)": (55.4853, 37.3050),
|
"Троицк (ТАО)": (55.4853, 37.3050),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Областной контрольный набор. Изначально 32 города (16 из _MOSCOW_OBLAST_CITIES
|
# Областной контрольный набор (32 города). 16 из них имеют собственный
|
||||||
# + 16 ниже) — часть держалась только радиусом московских точек, включая
|
# отрицательный центроид и выигрывают им; остальные 16 держатся только
|
||||||
# Наро-Фоминск (9.95 км до ближайшей точки сетки), Кубинку (17.2) и Чехов
|
# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей
|
||||||
# (22.3), которые при глобальных 25 км резолвились как «Москва». Это и есть
|
# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км
|
||||||
# дефект #1.
|
# резолвились как «Москва». Это и есть дефект #1.
|
||||||
#
|
|
||||||
# 2026-09-16: Наро-Фоминск, Домодедово, Жуковский, Щёлково и Пушкино убраны
|
|
||||||
# из набора. Наро-Фоминск/Домодедово/Щёлково сами стали покрытыми городами
|
|
||||||
# (COVERAGE_MO_CITIES). Жуковский и Пушкино не входят в COVERAGE_MO_CITIES, но
|
|
||||||
# оказались внутри 25-километрового радиуса новых соседних центроидов
|
|
||||||
# (Раменское и Королёв соответственно) и больше не резолвятся в «город не
|
|
||||||
# определён». Пушкино используется отдельно (см. `_PUSHKINO_COORDS`) —
|
|
||||||
# test_phantom_exclave_no_longer_gives_moscow_to_pushkino проверяет именно то,
|
|
||||||
# что ближайший центроид для него теперь Королёв, а не пустота.
|
|
||||||
_MOSCOW_OBLAST_CONTROLS = {
|
_MOSCOW_OBLAST_CONTROLS = {
|
||||||
**_MOSCOW_OBLAST_CITIES,
|
**_MOSCOW_OBLAST_CITIES,
|
||||||
|
"Наро-Фоминск": (55.3869, 36.7331),
|
||||||
"Кубинка": (55.5794, 36.7000),
|
"Кубинка": (55.5794, 36.7000),
|
||||||
"Чехов": (55.1477, 37.4770),
|
"Чехов": (55.1477, 37.4770),
|
||||||
"Голицыно": (55.6167, 36.9833),
|
"Голицыно": (55.6167, 36.9833),
|
||||||
"Лыткарино": (55.5833, 37.9000),
|
"Лыткарино": (55.5833, 37.9000),
|
||||||
|
"Домодедово": (55.4400, 37.7594),
|
||||||
|
"Жуковский": (55.5952, 38.1200),
|
||||||
|
"Щёлково": (55.9200, 38.0000),
|
||||||
|
"Пушкино": (56.0100, 37.8500),
|
||||||
"Звенигород": (55.7300, 36.8500),
|
"Звенигород": (55.7300, 36.8500),
|
||||||
"Апрелевка": (55.5500, 37.0700),
|
"Апрелевка": (55.5500, 37.0700),
|
||||||
"Селятино": (55.5081, 36.9825),
|
"Селятино": (55.5081, 36.9825),
|
||||||
|
|
@ -788,10 +778,6 @@ _MOSCOW_OBLAST_CONTROLS = {
|
||||||
"Барвиха": (55.7333, 37.2333),
|
"Барвиха": (55.7333, 37.2333),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Пушкино вынесено из _MOSCOW_OBLAST_CONTROLS 2026-09-16 (см. комментарий
|
|
||||||
# выше) — используется test_phantom_exclave_no_longer_gives_moscow_to_pushkino.
|
|
||||||
_PUSHKINO_COORDS = (56.0100, 37.8500)
|
|
||||||
|
|
||||||
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
|
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
|
||||||
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
|
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
|
||||||
# ключ здесь — только адрес строки.
|
# ключ здесь — только адрес строки.
|
||||||
|
|
@ -868,10 +854,10 @@ def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: Fas
|
||||||
|
|
||||||
|
|
||||||
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
|
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
|
||||||
"""Все 15 областных контролей → «город не определён», и ручка отдаёт 200."""
|
"""Все 32 областных контроля → «город не определён», и ручка отдаёт 200."""
|
||||||
from app.api.v1.trade_in import _resolve_coverage_city
|
from app.api.v1.trade_in import _resolve_coverage_city
|
||||||
|
|
||||||
assert len(_MOSCOW_OBLAST_CONTROLS) == 15
|
assert len(_MOSCOW_OBLAST_CONTROLS) == 32
|
||||||
failed = {
|
failed = {
|
||||||
name: _resolve_coverage_city(lat, lon)
|
name: _resolve_coverage_city(lat, lon)
|
||||||
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
|
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
|
||||||
|
|
@ -892,18 +878,15 @@ def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastA
|
||||||
|
|
||||||
|
|
||||||
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
||||||
"""Дефект #1 предметно: Кубинка, Чехов.
|
"""Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов.
|
||||||
|
|
||||||
С одним глобальным радиусом 25 км круги плотной сетки складывались, и обе
|
С одним глобальным радиусом 25 км круги плотной сетки складывались, и все
|
||||||
получали «Москву», хотя до ближайшей точки сетки им 17.2 и 22.3 км. С
|
трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и
|
||||||
радиусом-свойством (8 км у московских точек) — «не определён».
|
22.3 км. С радиусом-свойством (8 км у московских точек) — «не определён».
|
||||||
|
|
||||||
Наро-Фоминск убран из проверки 2026-09-16: сам стал покрытым городом
|
|
||||||
(COVERAGE_MO_CITIES), дефект #1 больше не демонстрирует.
|
|
||||||
"""
|
"""
|
||||||
from app.api.v1.trade_in import _resolve_coverage_city
|
from app.api.v1.trade_in import _resolve_coverage_city
|
||||||
|
|
||||||
for name in ("Кубинка", "Чехов"):
|
for name in ("Наро-Фоминск", "Кубинка", "Чехов"):
|
||||||
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
|
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
|
||||||
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
|
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
|
||||||
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
|
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
|
||||||
|
|
@ -914,15 +897,13 @@ def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
||||||
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
|
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
|
||||||
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
|
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
|
||||||
|
|
||||||
Кубинка в 17.2 км от ближайшей точки московской сетки — «не определён»
|
Наро-Фоминск в 9.95 км от ближайшей точки московской сетки — «не
|
||||||
(была "Наро-Фоминск" до 2026-09-16 — тот сам стал покрытым городом,
|
определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ) —
|
||||||
COVERAGE_MO_CITIES). Точка в 20 км южнее центра Екатеринбурга (то есть
|
«Екатеринбург». Глобальной константой такое поведение невыразимо.
|
||||||
ДАЛЬШЕ) — «Екатеринбург». Глобальной константой такое поведение
|
|
||||||
невыразимо.
|
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
lat, lon = _MOSCOW_OBLAST_CONTROLS["Кубинка"]
|
lat, lon = _MOSCOW_OBLAST_CONTROLS["Наро-Фоминск"]
|
||||||
km_to_grid = min(
|
km_to_grid = min(
|
||||||
trade_in_module._haversine_km(lat, lon, glat, glon)
|
trade_in_module._haversine_km(lat, lon, glat, glon)
|
||||||
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values()
|
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values()
|
||||||
|
|
@ -961,12 +942,10 @@ def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None:
|
||||||
moscow_keys = {
|
moscow_keys = {
|
||||||
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||||||
}
|
}
|
||||||
mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES}
|
|
||||||
sverdlovsk = {
|
sverdlovsk = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||||||
if trade_in_module._fold_city(k) not in moscow_keys
|
if trade_in_module._fold_city(k) not in moscow_keys
|
||||||
and trade_in_module._fold_city(k) not in mo_cities_folded
|
|
||||||
}
|
}
|
||||||
assert len(sverdlovsk) == 9
|
assert len(sverdlovsk) == 9
|
||||||
for clat, clon in sverdlovsk.values():
|
for clat, clon in sverdlovsk.values():
|
||||||
|
|
@ -994,18 +973,14 @@ def test_phantom_exclave_no_longer_gives_moscow_to_pushkino(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
|
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
|
||||||
|
|
||||||
2026-09-16: Королёв (COVERAGE_MO_CITIES) стал позитивным центроидом в
|
Возвращаем призрак в таблицу — Пушкино снова резолвится в «Москву»: это
|
||||||
~10.76 км от Пушкино — ближе прежнего "not_covered", поэтому базовый
|
доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё.
|
||||||
ответ без призрака теперь «Королёв», а не пустота. Возвращаем призрак в
|
|
||||||
таблицу — он в 3.06 км, ближе Королёва, и Пушкино снова резолвится в
|
|
||||||
«Москву»: это по-прежнему доказывает, что дефект чинит именно выброс
|
|
||||||
точки, а не что-нибудь ещё.
|
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
pushkino = _PUSHKINO_COORDS
|
pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"]
|
||||||
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
|
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
|
||||||
assert trade_in_module._resolve_coverage_city(*pushkino) == ("Королёв", 12, True)
|
assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False)
|
||||||
|
|
||||||
ghost_key = "Москва (Восточный, эксклав)"
|
ghost_key = "Москва (Восточный, эксклав)"
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -1042,31 +1017,12 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
|
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
|
||||||
|
|
||||||
Для всех 12 точек: в самой точке резолвер отдаёт «Москву». Без неё точка
|
Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать
|
||||||
проигрывает следующему ближайшему центроиду — раньше это всегда было
|
её из таблицы — «город не определён» (район уходит подмосковному центру).
|
||||||
«город не определён», но с 2026-09-16 (COVERAGE_MO_CITIES) для 9 из 12
|
Мёртвых строк в добавке нет.
|
||||||
точек следующий ближайший — новый позитивный центроид подмосковного
|
|
||||||
города, а не пустота. Инвариант, который всё ещё держит тест: ни одна
|
|
||||||
точка не деградирует ОБРАТНО в «Москву» — иначе строка была бы мёртвой.
|
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
expected_fallback = {
|
|
||||||
"Москва (Восточный)": ("Балашиха", 12, True),
|
|
||||||
"Москва (Левобережный)": ("Химки", 12, True),
|
|
||||||
"Москва (Орехово-Борисово Южное)": ("", 0, False),
|
|
||||||
"Москва (Северный)": ("Долгопрудный", 12, True),
|
|
||||||
"Москва (Митино-запад)": ("Красногорск", 12, True),
|
|
||||||
"Москва (Ивановское)": ("Реутов", 12, True),
|
|
||||||
"Москва (Жулебино)": ("Люберцы", 12, True),
|
|
||||||
"Москва (Можайский-запад)": ("", 0, False),
|
|
||||||
"Москва (Новокосино)": ("Реутов", 12, True),
|
|
||||||
"Москва (Капотня)": ("", 0, False),
|
|
||||||
"Москва (Куркино)": ("Химки", 12, True),
|
|
||||||
"Москва (Некрасовка-восток)": ("Люберцы", 12, True),
|
|
||||||
}
|
|
||||||
assert set(expected_fallback) == set(_ADDED_GRID_KEYS)
|
|
||||||
|
|
||||||
full = trade_in_module._CITY_CENTROIDS_DEG
|
full = trade_in_module._CITY_CENTROIDS_DEG
|
||||||
for key in _ADDED_GRID_KEYS:
|
for key in _ADDED_GRID_KEYS:
|
||||||
lat, lon = full[key]
|
lat, lon = full[key]
|
||||||
|
|
@ -1076,11 +1032,9 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
|
||||||
"_CITY_CENTROIDS_DEG",
|
"_CITY_CENTROIDS_DEG",
|
||||||
{k: v for k, v in full.items() if k != key},
|
{k: v for k, v in full.items() if k != key},
|
||||||
)
|
)
|
||||||
fallback = trade_in_module._resolve_coverage_city(lat, lon)
|
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), (
|
||||||
assert fallback == expected_fallback[key], (
|
f"{key} ничего не возвращает — строка мёртвая"
|
||||||
f"{key}: ожидали {expected_fallback[key]}, получили {fallback}"
|
|
||||||
)
|
)
|
||||||
assert fallback[0] != "Москва", f"{key} деградировала обратно в «Москву»"
|
|
||||||
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
|
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1093,16 +1047,10 @@ def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it(
|
||||||
расстоянии означает «город не определён»: точка в глухом углу области,
|
расстоянии означает «город не определён»: точка в глухом углу области,
|
||||||
где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered
|
где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered
|
||||||
и 200, а не 500 по отсутствующему порогу.
|
и 200, а не 500 по отсутствующему порогу.
|
||||||
|
|
||||||
Координаты сменены 2026-09-16: прежняя точка (55.30, 38.75) после
|
|
||||||
добавления COVERAGE_MO_CITIES стала ближе к Коломне (22.9 км, позитивный
|
|
||||||
центроид), чем к любой отрицательной точке — тест проверял бы Коломну, а
|
|
||||||
не бесконечный радиус. Новая точка ближе всего к «Поварово» (26.27 км,
|
|
||||||
отрицательный центроид).
|
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
far_lat, far_lon = 56.30, 37.00
|
far_lat, far_lon = 55.30, 38.75
|
||||||
nearest_km, nearest_name = min(
|
nearest_km, nearest_name = min(
|
||||||
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
|
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
|
||||||
for name, (clat, clon) in (
|
for name, (clat, clon) in (
|
||||||
|
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
"""Снятие устаревших ограничено регионами, где есть регулярный пересбор.
|
|
||||||
|
|
||||||
Джоба трактует молчание как «объявление ушло»: строку давно не видели свежей ->
|
|
||||||
is_active=false. Вывод верен ТОЛЬКО там, где есть механизм пересбора. Регионы 77 и
|
|
||||||
50 попали в listings разовой загрузкой 10-12.09.2026, регулярного сбора по ним нет
|
|
||||||
(CITY_ANCHORS кита покрывает только 66, гео-скоуп ДомКлика зашит в ЕКБ) -- без
|
|
||||||
фильтра около 26.09 кандидатами разом стали бы 40 410 строк ДомКлика, и аварийный
|
|
||||||
потолок max_deactivated остановил бы снятие ДЛЯ ВСЕХ регионов, включая ЕКБ
|
|
||||||
(skipped_cap_exceeded не трогает ни одной строки).
|
|
||||||
|
|
||||||
Здесь проверяется: дефолт = только 66; предикат стоит и в preflight count(*), и в
|
|
||||||
UPDATE с ОДНИМ И ТЕМ ЖЕ значением параметра (иначе потолок считает одно, а снимает
|
|
||||||
другое); явный список расширяет охват; мусор в region_codes отвергается.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from app.tasks import deactivate_stale_avito as task_mod
|
|
||||||
|
|
||||||
_REGION_PREDICATE = "region_code = ANY(CAST(:region_codes AS int[]))"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Фейковая сессия поверх набора строк ──────────────────────────────────────────
|
|
||||||
# В отличие от _FakeDB прочих файлов, эта считает кандидатов и rowcount НЕ из
|
|
||||||
# фикстуры, а применяя предикат к одному и тому же набору строк по параметрам
|
|
||||||
# КАЖДОГО запроса. Поэтому расхождение preflight и UPDATE (разный :region_codes,
|
|
||||||
# потерянный фильтр в одном из двух) проявляется как разные числа, а не прячется
|
|
||||||
# за заранее заданным rowcount.
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResult:
|
|
||||||
def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
|
|
||||||
self.rowcount = rowcount
|
|
||||||
self._scalar = scalar_value
|
|
||||||
|
|
||||||
def scalar(self) -> Any:
|
|
||||||
return self._scalar
|
|
||||||
|
|
||||||
|
|
||||||
class _RowsDB:
|
|
||||||
"""rows: список dict'ов region_code / stale / active / segment."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
rows: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
floor_days: float | None = 0.0,
|
|
||||||
floor_n_pairs: int = 5000,
|
|
||||||
prev_floor_n_pairs: int | None = 5000,
|
|
||||||
confirmations: int = 10_000,
|
|
||||||
) -> None:
|
|
||||||
self._rows = rows
|
|
||||||
self._floor = floor_days
|
|
||||||
self._floor_n_pairs = floor_n_pairs
|
|
||||||
self._prev_floor_n_pairs = prev_floor_n_pairs
|
|
||||||
self._confirmations = confirmations
|
|
||||||
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
|
||||||
self.committed = False
|
|
||||||
self.rolled_back = False
|
|
||||||
|
|
||||||
def _matching(self, sql: str, params: dict[str, Any] | None) -> int:
|
|
||||||
params = params or {}
|
|
||||||
region_codes = params.get("region_codes")
|
|
||||||
segments = params.get("segments")
|
|
||||||
matched = 0
|
|
||||||
for row in self._rows:
|
|
||||||
if not row.get("active", True):
|
|
||||||
continue
|
|
||||||
if not row.get("stale", True):
|
|
||||||
continue
|
|
||||||
# Фильтр применяется, только если он реально есть в тексте запроса --
|
|
||||||
# выкинутый из одного из двух запросов предикат обязан дать расхождение.
|
|
||||||
if _REGION_PREDICATE in sql:
|
|
||||||
# ANY(...) никогда не матчит NULL -- та же семантика, что в SQL.
|
|
||||||
if row["region_code"] is None or row["region_code"] not in (region_codes or []):
|
|
||||||
continue
|
|
||||||
if "listing_segment IS NULL" in sql and row.get("segment") is not None:
|
|
||||||
continue
|
|
||||||
if "listing_segment = ANY(CAST(:segments AS text[]))" in sql and (
|
|
||||||
row.get("segment") not in (segments or [])
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
matched += 1
|
|
||||||
return matched
|
|
||||||
|
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
|
||||||
sql = str(stmt.text)
|
|
||||||
self.executed.append((sql, params))
|
|
||||||
if "percentile_disc" in sql:
|
|
||||||
return _FakeResult(scalar_value=self._floor)
|
|
||||||
if "FROM scrape_runs prev" in sql:
|
|
||||||
return _FakeResult(scalar_value=self._prev_floor_n_pairs)
|
|
||||||
if "JOIN LATERAL" in sql:
|
|
||||||
return _FakeResult(scalar_value=self._floor_n_pairs)
|
|
||||||
if "health_window_days" in sql:
|
|
||||||
return _FakeResult(scalar_value=self._confirmations)
|
|
||||||
if "SELECT count(*)" in sql and "ttl_days" in sql:
|
|
||||||
return _FakeResult(scalar_value=self._matching(sql, params))
|
|
||||||
if "SELECT count(*)" in sql: # active_pool -- весь активный пул источника
|
|
||||||
return _FakeResult(scalar_value=sum(1 for r in self._rows if r.get("active", True)))
|
|
||||||
return _FakeResult(rowcount=self._matching(sql, params))
|
|
||||||
|
|
||||||
def commit(self) -> None:
|
|
||||||
self.committed = True
|
|
||||||
|
|
||||||
def rollback(self) -> None:
|
|
||||||
self.rolled_back = True
|
|
||||||
|
|
||||||
def _query(self, needle: str) -> tuple[str, dict[str, Any]]:
|
|
||||||
for sql, params in self.executed:
|
|
||||||
if needle in sql and ("SELECT count(*)" not in sql or "ttl_days" in sql):
|
|
||||||
return sql, dict(params or {})
|
|
||||||
return "", {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def update_query(self) -> tuple[str, dict[str, Any]]:
|
|
||||||
return self._query("UPDATE listings")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def candidates_query(self) -> tuple[str, dict[str, Any]]:
|
|
||||||
for sql, params in self.executed:
|
|
||||||
if sql.lstrip().startswith("SELECT count(*)") and "ttl_days" in sql:
|
|
||||||
return sql, dict(params or {})
|
|
||||||
return "", {}
|
|
||||||
|
|
||||||
|
|
||||||
def _run(db: _RowsDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
|
|
||||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
|
||||||
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
|
||||||
# Пол включён, чтобы исполнился preflight-блок (он живёт внутри
|
|
||||||
# revisit_floor_quantile > 0) -- именно его согласованность с UPDATE и проверяем.
|
|
||||||
kwargs.setdefault("revisit_floor_quantile", task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE)
|
|
||||||
return task_mod.deactivate_stale_listings(
|
|
||||||
db, # type: ignore[arg-type]
|
|
||||||
1,
|
|
||||||
listing_source=kwargs.pop("listing_source", "domklik"),
|
|
||||||
ttl_days=kwargs.pop("ttl_days", 14),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _corpus() -> list[dict[str, Any]]:
|
|
||||||
"""4 устаревших строки ЕКБ + по 3 строки «залитых руками» 77 и 50."""
|
|
||||||
return (
|
|
||||||
[{"region_code": 66, "stale": True} for _ in range(4)]
|
|
||||||
+ [{"region_code": 77, "stale": True} for _ in range(3)]
|
|
||||||
+ [{"region_code": 50, "stale": True} for _ in range(3)]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Дефолт: только регион с регулярным сбором ────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_region_scope_is_sverdlovsk_only() -> None:
|
|
||||||
assert task_mod.DEFAULT_DEACTIVATION_REGION_CODES == (66,)
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_run_does_not_touch_rows_of_other_regions(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
out = _run(db, monkeypatch)
|
|
||||||
assert out["deactivated"] == 4, "сняты должны быть только 4 строки региона 66"
|
|
||||||
assert db.update_query[1]["region_codes"] == [66]
|
|
||||||
assert db.committed is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_rows_with_null_region_code_are_never_deactivated(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""ANY(...) не матчит NULL -- неизвестный регион нечем отнести к покрытому сбором."""
|
|
||||||
db = _RowsDB([{"region_code": 66, "stale": True}, {"region_code": None, "stale": True}])
|
|
||||||
out = _run(db, monkeypatch)
|
|
||||||
assert out["deactivated"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ── Preflight и UPDATE согласованы ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_counts_exactly_what_update_deactivates(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
out = _run(db, monkeypatch)
|
|
||||||
assert out["deactivation_candidates"] == out["deactivated"] == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_and_update_bind_the_same_region_codes(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
_run(db, monkeypatch, region_codes=[66, 77])
|
|
||||||
assert db.candidates_query[1]["region_codes"] == db.update_query[1]["region_codes"] == [66, 77]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"build_update, build_count",
|
|
||||||
[
|
|
||||||
(task_mod._build_all_segments_sql, task_mod._build_all_segments_candidates_count_sql),
|
|
||||||
(task_mod._build_segments_sql, task_mod._build_segments_candidates_count_sql),
|
|
||||||
(task_mod._build_null_segment_sql, task_mod._build_null_segment_candidates_count_sql),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_region_predicate_present_in_both_update_and_count_sql(
|
|
||||||
build_update: Any, build_count: Any
|
|
||||||
) -> None:
|
|
||||||
"""Предикат продублирован текстуально в обеих ветках -- потерять его в одной
|
|
||||||
значит считать кандидатов по одному срезу, а снимать по другому."""
|
|
||||||
for sql in (str(build_update("scraped_at").text), str(build_count("scraped_at").text)):
|
|
||||||
assert _REGION_PREDICATE in sql
|
|
||||||
# psycopg v3: никакого :param::type
|
|
||||||
assert ":region_codes::" not in sql
|
|
||||||
|
|
||||||
|
|
||||||
def test_cap_counts_only_covered_regions(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Ради чего всё: чужой регион не должен пробивать аварийный потолок и глушить
|
|
||||||
снятие по своему (skipped_cap_exceeded не трогает НИ ОДНОЙ строки)."""
|
|
||||||
rows = [{"region_code": 66, "stale": True} for _ in range(3)]
|
|
||||||
rows += [{"region_code": 77, "stale": True} for _ in range(100)]
|
|
||||||
db = _RowsDB(rows)
|
|
||||||
out = _run(db, monkeypatch, max_deactivated=10)
|
|
||||||
assert "skipped_cap_exceeded" not in out
|
|
||||||
assert out["deactivated"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
# ── Явный список расширяет охват ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_region_codes_widen_the_scope(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
out = _run(db, monkeypatch, region_codes=[66, 77])
|
|
||||||
assert out["deactivated"] == 7
|
|
||||||
assert out["deactivation_candidates"] == 7
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_region_codes_can_target_a_single_foreign_region(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
out = _run(db, monkeypatch, region_codes=[50])
|
|
||||||
assert out["deactivated"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
# ── Валидация (jsonb-опечатки в default_params) ──────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_region_codes_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""[] матчил бы 0 строк и выглядел бы здоровым прогоном -- явный ValueError."""
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
with pytest.raises(ValueError, match="region_codes"):
|
|
||||||
_run(db, monkeypatch, region_codes=[])
|
|
||||||
|
|
||||||
|
|
||||||
def test_bool_region_code_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""jsonb `true` прошёл бы как регион 1 -- тот же класс опечатки, что ttl_days."""
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
with pytest.raises(ValueError, match="region_codes"):
|
|
||||||
_run(db, monkeypatch, region_codes=[True])
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_int_region_code_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
with pytest.raises(ValueError, match="region_codes"):
|
|
||||||
_run(db, monkeypatch, region_codes=["66"]) # type: ignore[list-item]
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_region_codes_touch_no_rows(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
db = _RowsDB(_corpus())
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
_run(db, monkeypatch, region_codes=[])
|
|
||||||
assert db.update_query[0] == ""
|
|
||||||
assert db.committed is False
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
"""Гео-скоуп DomClick — параметр региона, а не зашитый Екатеринбург.
|
|
||||||
|
|
||||||
До этого GUID/aids/страница прогрева/bbox жили модульными константами `_EKB_*`,
|
|
||||||
и прогон с region_code 77/50 собрал бы ЕКБ под чужим регионом. Тесты стерегут
|
|
||||||
две вещи: профиль 66 не изменил НИ БАЙТА в запросах (прод-поведение прежнее),
|
|
||||||
а профили Москвы и области действительно меняют скоуп — без aids и без сверки
|
|
||||||
имени региона, которое вне ЕКБ ненадёжно.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from scraper_kit.providers.domclick.serp import (
|
|
||||||
DOMCLICK_GEO_PROFILES,
|
|
||||||
DomClickScraper,
|
|
||||||
_build_count_url,
|
|
||||||
_build_offers_url,
|
|
||||||
geo_profile_for_region,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Литералы снятые с кода ДО параметризации: любое расхождение = смена прод-запроса.
|
|
||||||
_EKB_OFFERS_URL_BEFORE = (
|
|
||||||
"https://bff-search-web.domclick.ru/api/offers/v1"
|
|
||||||
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
|
|
||||||
"&deal_type=sale&category=living&offer_type=flat&rooms=5%2B"
|
|
||||||
"&sort=qi&sort_dir=desc&offset=40&limit=20"
|
|
||||||
"&sale_price__gte=1000000&sale_price__lte=5000000"
|
|
||||||
)
|
|
||||||
_EKB_COUNT_URL_BEFORE = (
|
|
||||||
"https://bff-search-web.domclick.ru/api/offers/count/v1"
|
|
||||||
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
|
|
||||||
"&deal_type=sale&category=living&offer_type=flat&rooms=2"
|
|
||||||
"&sort=qi&sort_dir=desc"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _url_for(region_code: int, *, count: bool) -> str:
|
|
||||||
profile = geo_profile_for_region(region_code)
|
|
||||||
if count:
|
|
||||||
return _build_count_url(
|
|
||||||
"2", None, None, address_guid=profile.address_guid, aids=profile.aids
|
|
||||||
)
|
|
||||||
return _build_offers_url(
|
|
||||||
"5+",
|
|
||||||
1_000_000,
|
|
||||||
5_000_000,
|
|
||||||
40,
|
|
||||||
address_guid=profile.address_guid,
|
|
||||||
aids=profile.aids,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekb_offers_url_unchanged_byte_for_byte() -> None:
|
|
||||||
assert _url_for(66, count=False) == _EKB_OFFERS_URL_BEFORE
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekb_count_url_unchanged_byte_for_byte() -> None:
|
|
||||||
assert _url_for(66, count=True) == _EKB_COUNT_URL_BEFORE
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("region_code", [77, 50])
|
|
||||||
@pytest.mark.parametrize("count", [False, True])
|
|
||||||
def test_msk_and_oblast_urls_carry_no_aids(region_code: int, count: bool) -> None:
|
|
||||||
"""aids сужает выдачу до агрегатора; у Москвы и области счётчик без него
|
|
||||||
совпадает с сайтом (замер 12.09.2026), поэтому параметра быть не должно."""
|
|
||||||
url = _url_for(region_code, count=count)
|
|
||||||
assert "aids=" not in url
|
|
||||||
assert f"address={geo_profile_for_region(region_code).address_guid}" in url
|
|
||||||
|
|
||||||
|
|
||||||
def test_profiles_are_distinct_and_ekb_keeps_aids() -> None:
|
|
||||||
guids = {code: p.address_guid for code, p in DOMCLICK_GEO_PROFILES.items()}
|
|
||||||
assert len(set(guids.values())) == len(guids)
|
|
||||||
assert DOMCLICK_GEO_PROFILES[66].aids == "20561"
|
|
||||||
# Имя региона сверяется ТОЛЬКО у ЕКБ.
|
|
||||||
assert DOMCLICK_GEO_PROFILES[66].region_name == "Екатеринбург"
|
|
||||||
assert DOMCLICK_GEO_PROFILES[77].region_name is None
|
|
||||||
assert DOMCLICK_GEO_PROFILES[50].region_name is None
|
|
||||||
# Имя города для listings.city: у области его нет и угадывать нечего.
|
|
||||||
assert DOMCLICK_GEO_PROFILES[66].city_name == "Екатеринбург"
|
|
||||||
assert DOMCLICK_GEO_PROFILES[77].city_name == "Москва"
|
|
||||||
assert DOMCLICK_GEO_PROFILES[50].city_name is None
|
|
||||||
|
|
||||||
|
|
||||||
def _geo_ok(region_code: int, item: dict[str, object]) -> bool:
|
|
||||||
"""_is_geo_ok без конструирования скрейпера: метод читает только self._geo."""
|
|
||||||
fake = SimpleNamespace(_geo=geo_profile_for_region(region_code))
|
|
||||||
return DomClickScraper._is_geo_ok(fake, item) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_msk_accepts_novaya_moskva_with_foreign_region_name() -> None:
|
|
||||||
"""Новая Москва приходит как «г. Говорово» — сверка по имени вырезала бы
|
|
||||||
законную выдачу, поэтому вне 66 гард только по bbox."""
|
|
||||||
item = {"offerRegionName": "г. Говорово", "location": {"lat": 55.62, "lon": 37.35}}
|
|
||||||
assert _geo_ok(77, item) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_msk_rejects_point_outside_bbox() -> None:
|
|
||||||
ekb_point = {"offerRegionName": "Москва", "location": {"lat": 56.83, "lon": 60.60}}
|
|
||||||
assert _geo_ok(77, ekb_point) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekb_still_rejects_foreign_region_name() -> None:
|
|
||||||
item = {"offerRegionName": "г. Говорово", "location": {"lat": 56.83, "lon": 60.60}}
|
|
||||||
assert _geo_ok(66, item) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_oblast_bbox_accepts_podmoskovie_point() -> None:
|
|
||||||
item = {"offerRegionName": "г. Подольск", "location": {"lat": 55.43, "lon": 37.55}}
|
|
||||||
assert _geo_ok(50, item) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_region_code_raises() -> None:
|
|
||||||
with pytest.raises(ValueError, match="region_code=99"):
|
|
||||||
geo_profile_for_region(99)
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"""Tests for per-region asking→sold ratio resolution (#3512).
|
|
||||||
|
|
||||||
ПРОБЛЕМА: asking_to_sold_ratios (migration 080) считался ИСКЛЮЧИТЕЛЬНО по ЕКБ
|
|
||||||
(city ILIKE '%Екатеринбург%') и применялся estimator'ом к ЛЮБОМУ адресу, включая
|
|
||||||
Москву — коэффициент рынка на порядок дешевле домножал московскую asking-цену.
|
|
||||||
Migration 304 добавила region_code в таблицу; этот файл проверяет, что
|
|
||||||
_get_asking_sold_ratio (app/services/estimator.py) теперь:
|
|
||||||
1. Скоупит ОБА SELECT (per-bucket + global -1 fallback) по region_code запроса.
|
|
||||||
2. Регион 66 без явного region_code — прежнее поведение (дефолт DEFAULT_REGION_CODE).
|
|
||||||
3. Регион без единой строки (ни per-bucket, ни global -1) деградирует ЯВНО:
|
|
||||||
(None, None), без подмены чужим регионом.
|
|
||||||
4. Кэш ключуется на (region_code, bucket), не на голый bucket — иначе Москва и ЕКБ
|
|
||||||
смешались бы в одном кэш-слоте.
|
|
||||||
|
|
||||||
No DB / network: fake Session (тот же паттерн, что
|
|
||||||
test_estimator_ratio_savepoint_retry_2265.py). DATABASE_URL нужен только для импорта
|
|
||||||
app.core.config.Settings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from app.services import estimator
|
|
||||||
from app.services import regions as regions_mod
|
|
||||||
|
|
||||||
|
|
||||||
class _Row:
|
|
||||||
def __init__(self, ratio: float | None, basis: str | None) -> None:
|
|
||||||
self.ratio = ratio
|
|
||||||
self.basis = basis
|
|
||||||
|
|
||||||
|
|
||||||
class _Result:
|
|
||||||
def __init__(self, row: _Row | None) -> None:
|
|
||||||
self._row = row
|
|
||||||
|
|
||||||
def fetchone(self) -> _Row | None:
|
|
||||||
return self._row
|
|
||||||
|
|
||||||
|
|
||||||
class _RegionAwareFakeSession:
|
|
||||||
"""Fake Session: отвечает по params['region_code'], как настоящая БД per-region.
|
|
||||||
|
|
||||||
rows: {(region_code, rooms_bucket): _Row} — rooms_bucket=-1 для global fallback.
|
|
||||||
Записывает все (sql_text, params) в self.calls для инспекции bind-параметров.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, rows: dict[tuple[int, int], _Row]) -> None:
|
|
||||||
self._rows = rows
|
|
||||||
self.calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def begin_nested(self): # type: ignore[no-untyped-def]
|
|
||||||
yield None
|
|
||||||
|
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
|
||||||
params = params or {}
|
|
||||||
self.calls.append(dict(params))
|
|
||||||
region_code = params.get("region_code")
|
|
||||||
bucket = params.get("b", -1)
|
|
||||||
return _Result(self._rows.get((region_code, bucket)))
|
|
||||||
|
|
||||||
def rollback(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_cache() -> None:
|
|
||||||
estimator._asking_sold_ratio_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_ratio_query_scoped_by_region_code_of_request() -> None:
|
|
||||||
"""Оба SELECT (per-bucket + global -1) несут region_code запроса как bind-параметр."""
|
|
||||||
_clear_cache()
|
|
||||||
db = _RegionAwareFakeSession({(77, 1): _Row(0.95, "per_rooms")})
|
|
||||||
|
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert ratio == 0.95
|
|
||||||
assert basis == "per_rooms"
|
|
||||||
assert len(db.calls) == 1 # per-bucket строка нашлась сразу, global -1 не запрошен
|
|
||||||
assert db.calls[0]["region_code"] == 77
|
|
||||||
assert db.calls[0]["b"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_and_ekb_get_different_ratios_for_same_bucket() -> None:
|
|
||||||
"""Тот же rooms-бакет (1), два региона — РАЗНЫЕ коэффициенты, не смешиваются."""
|
|
||||||
_clear_cache()
|
|
||||||
db = _RegionAwareFakeSession(
|
|
||||||
{
|
|
||||||
(66, 1): _Row(0.84, "per_rooms"),
|
|
||||||
(77, 1): _Row(0.97, "per_rooms"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ratio_ekb, basis_ekb = estimator._get_asking_sold_ratio(db, rooms=1, region_code=66) # type: ignore[arg-type]
|
|
||||||
ratio_msk, basis_msk = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert (ratio_ekb, basis_ekb) == (0.84, "per_rooms")
|
|
||||||
assert (ratio_msk, basis_msk) == (0.97, "per_rooms")
|
|
||||||
assert ratio_ekb != ratio_msk
|
|
||||||
# Оба закэшированы РАЗДЕЛЬНО — (region_code, bucket) ключ, не голый bucket (#3512).
|
|
||||||
assert estimator._asking_sold_ratio_cache[(66, 1)][0] == 0.84
|
|
||||||
assert estimator._asking_sold_ratio_cache[(77, 1)][0] == 0.97
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_region_code_is_ekb_previous_behaviour_preserved() -> None:
|
|
||||||
"""Вызов без region_code (легаси call-site) — дефолт 66, прежнее поведение."""
|
|
||||||
_clear_cache()
|
|
||||||
assert regions_mod.DEFAULT_REGION_CODE == 66
|
|
||||||
db = _RegionAwareFakeSession({(66, 2): _Row(0.81, "global_fallback")})
|
|
||||||
|
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert (ratio, basis) == (0.81, "global_fallback")
|
|
||||||
assert db.calls[0]["region_code"] == 66
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_without_any_row_degrades_explicit_not_silent() -> None:
|
|
||||||
"""Регион без per-bucket И без global -1 строки → (None, None), не чужой регион.
|
|
||||||
|
|
||||||
Falsifiable: до фикса резолвер не нёс region_code вообще, и запрос без фильтра
|
|
||||||
подхватил бы ЕКБ-глобальную строку молча. Здесь region_code=50 (Московская обл.)
|
|
||||||
не имеет НИ ОДНОЙ строки в rows — обе попытки (per-bucket + global -1) промахиваются.
|
|
||||||
"""
|
|
||||||
_clear_cache()
|
|
||||||
db = _RegionAwareFakeSession({(66, 3): _Row(0.9, "per_rooms")}) # только ЕКБ
|
|
||||||
|
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=3, region_code=50) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert (ratio, basis) == (None, None)
|
|
||||||
# Обе попытки (per-bucket=3, global=-1) реально ушли под region_code=50 —
|
|
||||||
# НЕ переключились молча на ЕКБ-данные, которые в rows есть.
|
|
||||||
assert len(db.calls) == 2
|
|
||||||
assert db.calls[0]["region_code"] == 50
|
|
||||||
assert db.calls[1]["region_code"] == 50
|
|
||||||
# Стабильный факт БД — кэшируется как обычный None-результат (см. #2175 идиома).
|
|
||||||
assert estimator._asking_sold_ratio_cache[(50, 3)][0] is None
|
|
||||||
|
|
@ -71,8 +71,7 @@ def test_ratio_retry_recovers_after_first_lookup_fails() -> None:
|
||||||
assert basis == "per_rooms"
|
assert basis == "per_rooms"
|
||||||
assert db.begin_nested_count == 2 # первый заход упал, второй прошёл
|
assert db.begin_nested_count == 2 # первый заход упал, второй прошёл
|
||||||
assert db.rollback_count >= 1 # чистка tx перед retry
|
assert db.rollback_count >= 1 # чистка tx перед retry
|
||||||
# #3512: ключ кэша (region_code, bucket); region_code не передан → дефолт 66.
|
assert 1 in estimator._asking_sold_ratio_cache # успех кэшируется
|
||||||
assert (66, 1) in estimator._asking_sold_ratio_cache # успех кэшируется
|
|
||||||
|
|
||||||
|
|
||||||
def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
|
def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
|
||||||
|
|
@ -83,8 +82,7 @@ def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
|
||||||
|
|
||||||
assert ratio is None
|
assert ratio is None
|
||||||
assert basis is None
|
assert basis is None
|
||||||
# транзиент НЕ отравляет кэш (#2175); ключ (region_code, bucket) — #3512.
|
assert 2 not in estimator._asking_sold_ratio_cache # транзиент НЕ отравляет кэш (#2175)
|
||||||
assert (66, 2) not in estimator._asking_sold_ratio_cache
|
|
||||||
assert db.begin_nested_count == 2
|
assert db.begin_nested_count == 2
|
||||||
assert db.rollback_count >= 2
|
assert db.rollback_count >= 2
|
||||||
|
|
||||||
|
|
@ -99,7 +97,7 @@ def test_ratio_happy_path_single_attempt_no_rollback() -> None:
|
||||||
assert basis == "global_fallback"
|
assert basis == "global_fallback"
|
||||||
assert db.begin_nested_count == 1 # ни одного retry
|
assert db.begin_nested_count == 1 # ни одного retry
|
||||||
assert db.rollback_count == 0
|
assert db.rollback_count == 0
|
||||||
cached = estimator._asking_sold_ratio_cache.get((66, 0))
|
cached = estimator._asking_sold_ratio_cache.get(0)
|
||||||
assert cached is not None
|
assert cached is not None
|
||||||
assert cached[0] == 0.9
|
assert cached[0] == 0.9
|
||||||
assert cached[1] == "global_fallback"
|
assert cached[1] == "global_fallback"
|
||||||
|
|
|
||||||
|
|
@ -107,23 +107,9 @@ def _run_estimate_with_ratio_spy(
|
||||||
|
|
||||||
ratio_calls: list[Any] = []
|
ratio_calls: list[Any] = []
|
||||||
|
|
||||||
def _spy_ratio(
|
def _spy_ratio(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> Any:
|
||||||
db_inner: Any,
|
|
||||||
rooms: Any,
|
|
||||||
area_m2: Any = None,
|
|
||||||
anchor_ppm2: Any = None,
|
|
||||||
region_code: Any = None,
|
|
||||||
) -> Any:
|
|
||||||
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
||||||
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
|
ratio_calls.append({"rooms": rooms, "area_m2": area_m2, "anchor_ppm2": anchor_ppm2})
|
||||||
ratio_calls.append(
|
|
||||||
{
|
|
||||||
"rooms": rooms,
|
|
||||||
"area_m2": area_m2,
|
|
||||||
"anchor_ppm2": anchor_ppm2,
|
|
||||||
"region_code": region_code,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return ratio_return
|
return ratio_return
|
||||||
|
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
|
|
@ -183,15 +169,8 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None:
|
||||||
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
|
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
|
||||||
captured_anchor_ppm2: list[float | None] = []
|
captured_anchor_ppm2: list[float | None] = []
|
||||||
|
|
||||||
def _spy(
|
def _spy(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> tuple:
|
||||||
db_inner: Any,
|
|
||||||
rooms: Any,
|
|
||||||
area_m2: Any = None,
|
|
||||||
anchor_ppm2: Any = None,
|
|
||||||
region_code: Any = None,
|
|
||||||
) -> tuple:
|
|
||||||
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
||||||
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
|
|
||||||
captured_anchor_ppm2.append(anchor_ppm2)
|
captured_anchor_ppm2.append(anchor_ppm2)
|
||||||
return (0.78, "per_rooms")
|
return (0.78, "per_rooms")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
"""Tests for #estimate-zero-analogs: honest refusal instead of a point price
|
|
||||||
when n_analogs == 0 and the engine itself already trusts the number no more
|
|
||||||
than "почти нет сигнала" (reliability == 'very_low').
|
|
||||||
|
|
||||||
Repro this guards against (prod, 2026-09-16): "Красногорск, Янтарная" returned
|
|
||||||
a confident 236 766 руб/м² with n_analogs=0 — 3.8% of ЕКБ estimates and 4.8% of
|
|
||||||
region 77/50 estimates hit this shape.
|
|
||||||
|
|
||||||
Same isolation harness as test_estimator_expected_sold.py: `estimate_quality`
|
|
||||||
with every I/O dependency stubbed (no DB, no network).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import anyio
|
|
||||||
|
|
||||||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
|
||||||
|
|
||||||
|
|
||||||
def _make_fake_geo():
|
|
||||||
from app.services.geocoder import GeocodeResult
|
|
||||||
|
|
||||||
return GeocodeResult(
|
|
||||||
lat=55.831,
|
|
||||||
lon=37.330,
|
|
||||||
full_address="Московская обл., Красногорск, ул. Янтарная, 5",
|
|
||||||
provider="nominatim",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_payload():
|
|
||||||
from app.schemas.trade_in import TradeInEstimateInput
|
|
||||||
|
|
||||||
return TradeInEstimateInput(
|
|
||||||
address="Красногорск, ул. Янтарная, 5",
|
|
||||||
area_m2=40.0,
|
|
||||||
rooms=1,
|
|
||||||
floor=4,
|
|
||||||
total_floors=16,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_estimate(
|
|
||||||
*,
|
|
||||||
require_analogs: bool,
|
|
||||||
dkp_raw: dict[str, Any] | None = None,
|
|
||||||
):
|
|
||||||
"""estimate_quality: пустой радиус (listings=[]), нет anchor — только
|
|
||||||
опциональный ДКП-коридор (`dkp_raw`) через `_fetch_dkp_corridor`."""
|
|
||||||
from app.services.estimator import estimate_quality
|
|
||||||
|
|
||||||
db = MagicMock()
|
|
||||||
payload = _make_payload()
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"app.services.estimator.settings.estimate_require_local_evidence", require_analogs
|
|
||||||
),
|
|
||||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
|
||||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
|
||||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
|
||||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
|
||||||
# Ничего не нашли в радиусе — listings_clean=[] → median_price=0 до фолбэков.
|
|
||||||
patch("app.services.estimator._fetch_analogs", return_value=([], False, None)),
|
|
||||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
|
||||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
|
||||||
# Ни same-building, ни micro-radius anchor.
|
|
||||||
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
|
|
||||||
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
|
|
||||||
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)),
|
|
||||||
):
|
|
||||||
return await estimate_quality(payload, db)
|
|
||||||
|
|
||||||
return anyio.run(_run)
|
|
||||||
|
|
||||||
|
|
||||||
def test_true_zero_data_suppresses_price_when_flag_on() -> None:
|
|
||||||
"""Ни listings, ни anchor, ни ДКП-коридора вообще → n_analogs=0,
|
|
||||||
reliability='very_low' изначально (без ДКП-фолбэка median остаётся 0
|
|
||||||
и без флага) — но проверяем именно flag=True ветку на honest-explanation."""
|
|
||||||
est = _run_estimate(require_analogs=True, dkp_raw=None)
|
|
||||||
|
|
||||||
assert est.n_analogs == 0
|
|
||||||
assert est.median_price_rub == 0
|
|
||||||
assert est.median_price_per_m2 == 0
|
|
||||||
assert est.range_low_rub == 0
|
|
||||||
assert est.range_high_rub == 0
|
|
||||||
assert est.expected_sold_price_rub is None
|
|
||||||
assert est.confidence_explanation is not None
|
|
||||||
assert "Недостаточно данных" in est.confidence_explanation
|
|
||||||
|
|
||||||
|
|
||||||
def test_city_wide_corridor_never_becomes_headline() -> None:
|
|
||||||
"""Прод-репро «Красногорск, Янтарная»: у улицы одна сделка, поэтому коридор
|
|
||||||
расширился до всего города (scope='city_wide', 4770 сделок, медиана
|
|
||||||
200 676 ₽/м²) и выдал её как оценку конкретной квартиры. Средняя цена
|
|
||||||
города — не оценка адреса, поэтому цены быть не должно."""
|
|
||||||
dkp_raw = {
|
|
||||||
"count": 4770,
|
|
||||||
"median_ppm2": 200_676.0,
|
|
||||||
"low_ppm2": 150_000.0,
|
|
||||||
"high_ppm2": 260_000.0,
|
|
||||||
"period_months": 12,
|
|
||||||
"scope": "city_wide",
|
|
||||||
}
|
|
||||||
est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw)
|
|
||||||
|
|
||||||
assert est.n_analogs == 0
|
|
||||||
assert est.median_price_rub == 0, "медиана по городу не может быть ценой квартиры"
|
|
||||||
assert est.median_price_per_m2 == 0
|
|
||||||
assert est.range_low_rub == 0
|
|
||||||
assert est.range_high_rub == 0
|
|
||||||
assert est.expected_sold_price_rub is None
|
|
||||||
assert est.expected_sold_range_low_rub is None
|
|
||||||
assert est.expected_sold_range_high_rub is None
|
|
||||||
assert est.insufficient_data is True
|
|
||||||
assert "Недостаточно данных" in (est.confidence_explanation or "")
|
|
||||||
# Адрес/гео — НЕ ценовые поля — сохраняются, чтобы фронт показал адрес.
|
|
||||||
assert est.target_address is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_street_scoped_corridor_still_headlines() -> None:
|
|
||||||
"""Обратная сторона того же правила: уличный коридор — настоящий сигнал и
|
|
||||||
цену даёт. Это защита #oblast-D (Нижний Тагил), без которой туда протекал
|
|
||||||
екатеринбургский asking вшестеро выше."""
|
|
||||||
dkp_raw = {
|
|
||||||
"count": 12,
|
|
||||||
"median_ppm2": 85_911.0,
|
|
||||||
"low_ppm2": 70_000.0,
|
|
||||||
"high_ppm2": 100_000.0,
|
|
||||||
"period_months": 12,
|
|
||||||
"scope": "street",
|
|
||||||
}
|
|
||||||
est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw)
|
|
||||||
|
|
||||||
assert est.n_analogs == 0
|
|
||||||
assert est.median_price_per_m2 == 85_911, "уличный коридор обязан остаться headline"
|
|
||||||
assert est.insufficient_data is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_city_wide_corridor_kept_when_flag_off() -> None:
|
|
||||||
"""Флаг False → прежнее поведение целиком: общегородской коридор снова
|
|
||||||
становится headline. Откат без релиза."""
|
|
||||||
dkp_raw = {
|
|
||||||
"count": 4770,
|
|
||||||
"median_ppm2": 200_676.0,
|
|
||||||
"low_ppm2": 150_000.0,
|
|
||||||
"high_ppm2": 260_000.0,
|
|
||||||
"period_months": 12,
|
|
||||||
"scope": "city_wide",
|
|
||||||
}
|
|
||||||
est = _run_estimate(require_analogs=False, dkp_raw=dkp_raw)
|
|
||||||
|
|
||||||
assert est.n_analogs == 0
|
|
||||||
assert est.median_price_rub > 0, "flag=False должен возвращать прежнее поведение"
|
|
||||||
assert est.median_price_per_m2 == 200_676
|
|
||||||
|
|
@ -234,19 +234,17 @@ def test_insert_is_idempotent_on_conflict() -> None:
|
||||||
def test_match_distinct_on_tiebreak_max_flat_count() -> None:
|
def test_match_distinct_on_tiebreak_max_flat_count() -> None:
|
||||||
flat = re.sub(r"\s+", " ", _MATCH_SQL)
|
flat = re.sub(r"\s+", " ", _MATCH_SQL)
|
||||||
# Канонический ключ (мигр. 144), а не точное равенство norm_address.
|
# Канонический ключ (мигр. 144), а не точное равенство norm_address.
|
||||||
|
assert "DISTINCT ON (canon)" in flat
|
||||||
assert "tradein_canon_addr(norm_address) AS canon" in flat
|
assert "tradein_canon_addr(norm_address) AS canon" in flat
|
||||||
# Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid.
|
# Tie-break: на canon — строка с макс flat_count, затем меньший house_guid.
|
||||||
assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat
|
assert "ORDER BY canon, flat_count DESC, house_guid" in flat
|
||||||
# houses-сторона: тот же канон поверх COALESCE(short/full/address).
|
# houses-сторона: тот же канон поверх COALESCE(short/full/address).
|
||||||
houses_side = (
|
houses_side = (
|
||||||
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
|
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
|
||||||
)
|
)
|
||||||
assert houses_side in flat
|
assert houses_side in flat
|
||||||
# city-ограничение по full_address (защита от cross-town коллизий, region-зависимо).
|
# ЕКБ-ограничение по full_address (защита от cross-town коллизий).
|
||||||
assert "full_address ILIKE '%' || CAST(:city AS text) || '%'" in flat
|
assert "full_address ILIKE '%' || CAST(:city AS text) || '%'" in flat
|
||||||
# Без city-фильтра неоднозначный canon (>1 разных GAR-домов) НЕ матчится (безопасная
|
|
||||||
# деградация вместо угадывания через max(flat_count)) — см. commet у _MATCH_SQL.
|
|
||||||
assert "CAST(:city AS text) IS NOT NULL OR canon_hits = 1" in flat
|
|
||||||
# Идемпотентность повторного прогона.
|
# Идемпотентность повторного прогона.
|
||||||
assert "h.gar_flat_count IS DISTINCT FROM gp.flat_count" in flat
|
assert "h.gar_flat_count IS DISTINCT FROM gp.flat_count" in flat
|
||||||
assert "gar_match_method = 'canon_addr'" in flat
|
assert "gar_match_method = 'canon_addr'" in flat
|
||||||
|
|
@ -254,46 +252,6 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None:
|
||||||
assert "flat_count > 0" in flat
|
assert "flat_count > 0" in flat
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Город-фильтр ПО РЕГИОНУ (не хардкод "Екатеринбург" на все регионы)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
def test_default_city_filter_region_66_matches_old_constant() -> None:
|
|
||||||
"""region 66 → «Екатеринбург» — byte-for-byte прежний DEFAULT_CITY_FILTER."""
|
|
||||||
assert gfl.default_city_filter_for_region("66") == "Екатеринбург"
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_city_filter_region_77_and_50_disabled() -> None:
|
|
||||||
"""Москва (один город) и область (нет доминирующего города) — без фильтра."""
|
|
||||||
assert gfl.default_city_filter_for_region("77") is None
|
|
||||||
assert gfl.default_city_filter_for_region("50") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_city_filter_unknown_region_disabled() -> None:
|
|
||||||
assert gfl.default_city_filter_for_region("99") is None
|
|
||||||
assert gfl.default_city_filter_for_region(None) is None
|
|
||||||
assert gfl.default_city_filter_for_region("not-a-number") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_match_houses_to_gar_auto_resolves_by_region(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""city_filter=CITY_FILTER_AUTO (умолч.) резолвится через default_city_filter_for_region."""
|
|
||||||
captured: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class _FakeResult:
|
|
||||||
rowcount = 0
|
|
||||||
|
|
||||||
def fake_execute(_sql: Any, params: dict[str, Any]) -> _FakeResult:
|
|
||||||
captured.update(params)
|
|
||||||
return _FakeResult()
|
|
||||||
|
|
||||||
fake_db = type("FakeDB", (), {"execute": staticmethod(fake_execute)})()
|
|
||||||
gfl.match_houses_to_gar(fake_db, region_code="66") # city_filter не передан → AUTO
|
|
||||||
assert captured["city"] == "Екатеринбург"
|
|
||||||
|
|
||||||
captured.clear()
|
|
||||||
gfl.match_houses_to_gar(fake_db, region_code="77")
|
|
||||||
assert captured["city"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||||||
# Только исполняемый SQL (модульный docstring специально содержит анти-паттерн как памятку).
|
# Только исполняемый SQL (модульный docstring специально содержит анти-паттерн как памятку).
|
||||||
assert not re.search(r":\w+::", _INSERT_SQL)
|
assert not re.search(r":\w+::", _INSERT_SQL)
|
||||||
|
|
@ -584,71 +542,3 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
|
||||||
finally:
|
finally:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
|
|
||||||
def test_no_city_filter_ambiguous_canon_not_matched() -> None:
|
|
||||||
"""Без city-фильтра (регион без доминирующего города, напр. 50): одинаковый canon у ДВУХ
|
|
||||||
разных ГАР-домов (разные города, общее название улицы) не резолвится угадыванием —
|
|
||||||
canon_hits>1 → строка НЕ матчится вовсе (см. коммент у _MATCH_SQL). Отдельный
|
|
||||||
невзаимодействующий canon в том же прогоне при этом матчится нормально."""
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
db = _live_session()
|
|
||||||
assert db is not None
|
|
||||||
try:
|
|
||||||
raw = db.connection()
|
|
||||||
raw.exec_driver_sql(_NORMALIZER_FN)
|
|
||||||
raw.exec_driver_sql(_CANON_FN)
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"CREATE TEMP TABLE gar_house_flats ("
|
|
||||||
" house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL,"
|
|
||||||
" flat_count int, full_address text, norm_address text, street_name text,"
|
|
||||||
" house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text"
|
|
||||||
") ON COMMIT DROP"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"CREATE TEMP TABLE houses ("
|
|
||||||
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
|
|
||||||
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
|
|
||||||
" gar_match_method text"
|
|
||||||
") ON COMMIT DROP"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Две РАЗНЫЕ улицы «Ленина, 5» в двух разных городах области (50) — одинаковый
|
|
||||||
# canon «ленина5» (canon не несёт населённый пункт, мигр. 144). Плюс одна
|
|
||||||
# однозначная улица «Мира, 1» без коллизии.
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO gar_house_flats "
|
|
||||||
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
|
|
||||||
"('h-town-a', '50', 10, 'обл Московская, г Балашиха, ул Ленина, 5', "
|
|
||||||
"'ул Ленина, 5'), "
|
|
||||||
"('h-town-b', '50', 20, 'обл Московская, г Химки, ул Ленина, 5', "
|
|
||||||
"'ул Ленина, 5'), "
|
|
||||||
"('h-unique', '50', 7, 'обл Московская, г Химки, ул Мира, 1', 'ул Мира, 1')"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')")
|
|
||||||
)
|
|
||||||
|
|
||||||
matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None)
|
|
||||||
# Только однозначный canon «мира1» матчится; «ленина5» (2 разных дома) — пропущен.
|
|
||||||
assert matched == 1
|
|
||||||
|
|
||||||
lenina = db.execute(
|
|
||||||
text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Ленина,5'")
|
|
||||||
).scalar()
|
|
||||||
assert lenina is None # неоднозначность НЕ разрешена угадыванием
|
|
||||||
|
|
||||||
mira = db.execute(
|
|
||||||
text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Мира,1'")
|
|
||||||
).scalar()
|
|
||||||
assert mira == 7 # однозначный canon матчится как обычно
|
|
||||||
finally:
|
|
||||||
db.rollback()
|
|
||||||
db.close()
|
|
||||||
|
|
|
||||||
|
|
@ -95,13 +95,6 @@ _WAVE2_SLUGS = {
|
||||||
assert len(_WAVE2_SLUGS) == 40
|
assert len(_WAVE2_SLUGS) == 40
|
||||||
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
|
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
|
||||||
|
|
||||||
# msk-rollout (черновик): Москва, область и объединённый скоуп заведены в
|
|
||||||
# CITY_ANCHORS/CITY_LOCATIONS, но строк расписаний у них СОЗНАТЕЛЬНО нет —
|
|
||||||
# реестры едут отдельным заходом от включения сбора. Проверки соответствия
|
|
||||||
# «реестр ↔ schedule row» ниже — про свердловскую волну #262, поэтому
|
|
||||||
# московские скоупы из них вычитаются явно (а не тихо расширяют ожидания).
|
|
||||||
_MSK_SCOPES = {"moskva", "moskovskaya_oblast", "moskva_i_mo"}
|
|
||||||
|
|
||||||
|
|
||||||
def _sql(path: Path) -> str:
|
def _sql(path: Path) -> str:
|
||||||
return path.read_text(encoding="utf-8")
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
@ -269,7 +262,7 @@ def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None:
|
||||||
scheduled_avito = rows_262 | rows_179
|
scheduled_avito = rows_262 | rows_179
|
||||||
|
|
||||||
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
|
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
|
||||||
assert scheduled_avito == confirmed_avito - _MSK_SCOPES, (
|
assert scheduled_avito == confirmed_avito, (
|
||||||
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
|
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
|
||||||
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
|
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
|
||||||
)
|
)
|
||||||
|
|
@ -281,7 +274,7 @@ def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None
|
||||||
scheduled_yandex = rows_262 | rows_179
|
scheduled_yandex = rows_262 | rows_179
|
||||||
|
|
||||||
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
|
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
|
||||||
assert scheduled_yandex == confirmed_yandex - _MSK_SCOPES, (
|
assert scheduled_yandex == confirmed_yandex, (
|
||||||
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
|
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
|
||||||
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
|
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
|
||||||
)
|
)
|
||||||
|
|
@ -295,7 +288,7 @@ def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> N
|
||||||
confirmed_cian = {
|
confirmed_cian = {
|
||||||
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
|
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
|
||||||
}
|
}
|
||||||
assert scheduled_cian == confirmed_cian - _MSK_SCOPES, (
|
assert scheduled_cian == confirmed_cian, (
|
||||||
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
|
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
|
||||||
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
|
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
|
||||||
)
|
)
|
||||||
|
|
@ -338,7 +331,7 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
|
||||||
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
|
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
|
||||||
slug for _p, slug in _row_sources(_MIGRATION_262)
|
slug for _p, slug in _row_sources(_MIGRATION_262)
|
||||||
}
|
}
|
||||||
orphaned = set(CITY_ANCHORS) - seeded_slugs - _MSK_SCOPES
|
orphaned = set(CITY_ANCHORS) - seeded_slugs
|
||||||
assert not orphaned, (
|
assert not orphaned, (
|
||||||
f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
|
f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
|
||||||
)
|
)
|
||||||
|
|
@ -347,8 +340,8 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
|
||||||
def test_city_anchors_wave2_count_and_content() -> None:
|
def test_city_anchors_wave2_count_and_content() -> None:
|
||||||
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
|
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
|
||||||
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
|
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
|
||||||
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS | _MSK_SCOPES
|
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS
|
||||||
assert len(CITY_ANCHORS) == 45 + len(_MSK_SCOPES)
|
assert len(CITY_ANCHORS) == 45
|
||||||
|
|
||||||
|
|
||||||
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:
|
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> No
|
||||||
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
|
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
|
||||||
assert counters.read == 5
|
assert counters.read == 5
|
||||||
assert counters.selected == 2
|
assert counters.selected == 2
|
||||||
assert counters.skipped_not_target_region == 1
|
assert counters.skipped_not_moscow == 1
|
||||||
assert counters.skipped_artifact == 1
|
assert counters.skipped_artifact == 1
|
||||||
assert counters.skipped_invalid == 1
|
assert counters.skipped_invalid == 1
|
||||||
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
|
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
|
||||||
|
|
@ -331,7 +331,7 @@ def test_geocode_lets_avito_through_without_allow_unfiltered(
|
||||||
_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
|
_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
|
||||||
)
|
)
|
||||||
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
|
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
|
||||||
assert counters.skipped_geo_other_region == 1
|
assert counters.skipped_oblast == 1
|
||||||
assert counters.skipped_ungeocoded == 1
|
assert counters.skipped_ungeocoded == 1
|
||||||
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
|
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
|
||||||
|
|
||||||
|
|
@ -444,7 +444,7 @@ def test_yandex_oblast_rows_do_not_reach_the_writer(captured: list[dict[str, Any
|
||||||
]
|
]
|
||||||
counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
|
counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
|
||||||
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
|
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
|
||||||
assert counters.skipped_not_target_region == 1
|
assert counters.skipped_not_moscow == 1
|
||||||
|
|
||||||
|
|
||||||
def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None:
|
def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None:
|
||||||
|
|
@ -480,261 +480,6 @@ def test_domclick_non_moscow_is_rejected() -> None:
|
||||||
assert not mod.is_moscow_domclick_address(None)
|
assert not mod.is_moscow_domclick_address(None)
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_oblast_is_recognised_without_geocode() -> None:
|
|
||||||
"""Регион 50 у ДомКлика — первый компонент адреса «Московская область»."""
|
|
||||||
assert mod.is_oblast_domclick_address("Московская область, Химки, 9 Мая, 21к3")
|
|
||||||
assert mod.is_oblast_domclick_address(
|
|
||||||
"Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28"
|
|
||||||
)
|
|
||||||
assert not mod.is_oblast_domclick_address("Москва, улица Нижние Мнёвники, 16 к2")
|
|
||||||
assert not mod.is_oblast_domclick_address("")
|
|
||||||
assert not mod.is_oblast_domclick_address(None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_oblast_without_prefix_is_saved_by_its_coordinates() -> None:
|
|
||||||
"""Живой случай: областная карточка БЕЗ префикса «Московская область».
|
|
||||||
|
|
||||||
В собранном корпусе 12.09.2026 такая ровно одна на 2 961 — Можайск, и
|
|
||||||
строгий префикс выбросил бы её молча. Координата внутри bbox области —
|
|
||||||
вторая опора фильтра, а не замена первой.
|
|
||||||
"""
|
|
||||||
mozhaysk = _payload(
|
|
||||||
source="domclick",
|
|
||||||
address="Можайский муниципальный округ, д. Семёновское, 1",
|
|
||||||
lat=55.511625,
|
|
||||||
lon=35.829308,
|
|
||||||
)
|
|
||||||
assert mod.is_oblast_domclick_payload(mozhaysk)
|
|
||||||
|
|
||||||
# Строки вместо чисел (JSON сырья бывает и таким) читаются так же.
|
|
||||||
assert mod.is_oblast_domclick_payload({**mozhaysk, "lat": "55.511625", "lon": "35.829308"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_oblast_geo_branch_cannot_swallow_moscow() -> None:
|
|
||||||
"""bbox Москвы вложен в областной — отказ по адресу обязан идти РАНЬШЕ гео."""
|
|
||||||
moscow = _payload(
|
|
||||||
source="domclick",
|
|
||||||
address="Москва, улица Нижние Мнёвники, 16 к2",
|
|
||||||
lat=55.7558,
|
|
||||||
lon=37.6173,
|
|
||||||
)
|
|
||||||
assert not mod.is_oblast_domclick_payload(moscow)
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_oblast_geo_branch_needs_a_usable_point() -> None:
|
|
||||||
"""Нет префикса и нечего проверить координатой → карточка НЕ область."""
|
|
||||||
bare = _payload(source="domclick", address="Можайский муниципальный округ, д. Семёновское, 1")
|
|
||||||
bare.pop("lat", None)
|
|
||||||
bare.pop("lon", None)
|
|
||||||
assert not mod.is_oblast_domclick_payload(bare)
|
|
||||||
assert not mod.is_oblast_domclick_payload({**bare, "lat": None, "lon": None})
|
|
||||||
assert not mod.is_oblast_domclick_payload({**bare, "lat": "н/д", "lon": "н/д"})
|
|
||||||
# Координата за пределами области (Екатеринбург) тоже не проходит.
|
|
||||||
assert not mod.is_oblast_domclick_payload({**bare, "lat": 56.8300, "lon": 60.6000})
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_view_is_wired_into_source_views() -> None:
|
def test_domclick_view_is_wired_into_source_views() -> None:
|
||||||
assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest"
|
assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest"
|
||||||
filt77 = mod.REGION_FILTERS[("domclick", 77)]
|
assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address
|
||||||
assert filt77(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
|
|
||||||
assert not filt77(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
|
|
||||||
|
|
||||||
filt50 = mod.REGION_FILTERS[("domclick", 50)]
|
|
||||||
assert filt50 is mod.is_oblast_domclick_payload
|
|
||||||
assert filt50(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
|
|
||||||
assert not filt50(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
|
|
||||||
|
|
||||||
|
|
||||||
# ── регион 50 (Московская область) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_cian_subdomain_selects_oblast_regardless_of_okrug(
|
|
||||||
captured: list[dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
"""khimki.cian.ru → область при --region 50, даже если адрес без округа."""
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _payload(
|
|
||||||
source_id="khimki-1",
|
|
||||||
source_url="https://khimki.cian.ru/sale/flat/1/",
|
|
||||||
address="городской округ Химки, Олимпийский проспект, 29",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
|
|
||||||
assert counters_50.selected == 1
|
|
||||||
|
|
||||||
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
|
|
||||||
assert counters_77.selected == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_cian_www_with_okrug_stays_moscow_only(captured: list[dict[str, Any]]) -> None:
|
|
||||||
"""www.cian.ru + префикс округа — Москва (77), не область (50)."""
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _payload(
|
|
||||||
source_id="www-1",
|
|
||||||
source_url="https://www.cian.ru/sale/flat/330200428/",
|
|
||||||
address="ЦАО, р-н Арбат, улица Арбат, 10",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
|
|
||||||
assert counters_77.selected == 1
|
|
||||||
|
|
||||||
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
|
|
||||||
assert counters_50.selected == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_yandex_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None:
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _yandex(
|
|
||||||
source_id="oblast-1",
|
|
||||||
address="Россия, Московская область, Химки, Ивановская улица, 5",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=50, batch_size=10)
|
|
||||||
assert counters_50.selected == 1
|
|
||||||
|
|
||||||
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=77, batch_size=10)
|
|
||||||
assert counters_77.selected == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None:
|
|
||||||
"""Областной прогон сборщика (батч mo-serp-domclick-20260912) отбирается по region=50."""
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _payload(
|
|
||||||
source="domclick",
|
|
||||||
source_id="oblast-1",
|
|
||||||
address="Московская область, Химки, проспект Мельникова, 33",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
counters_50 = mod.import_msk_raw(
|
|
||||||
_FakeSession(rows), source="domclick", region=50, batch_size=10
|
|
||||||
)
|
|
||||||
assert counters_50.selected == 1
|
|
||||||
|
|
||||||
counters_77 = mod.import_msk_raw(
|
|
||||||
_FakeSession(rows), source="domclick", region=77, batch_size=10
|
|
||||||
)
|
|
||||||
assert counters_77.selected == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_moscow_region_77_is_selected(captured: list[dict[str, Any]]) -> None:
|
|
||||||
"""Московский прогон (батч msk-serp-domclick-20260912) отбирается по region=77."""
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _payload(
|
|
||||||
source="domclick",
|
|
||||||
source_id="moscow-1",
|
|
||||||
address="Москва, Генерала Дорохова проспект, 49",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
counters_77 = mod.import_msk_raw(
|
|
||||||
_FakeSession(rows), source="domclick", region=77, batch_size=10
|
|
||||||
)
|
|
||||||
assert counters_77.selected == 1
|
|
||||||
|
|
||||||
counters_50 = mod.import_msk_raw(
|
|
||||||
_FakeSession(rows), source="domclick", region=50, batch_size=10
|
|
||||||
)
|
|
||||||
assert counters_50.selected == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_writer_gets_region_50_and_city_none(captured: list[dict[str, Any]]) -> None:
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _payload(
|
|
||||||
source="domclick",
|
|
||||||
source_id="oblast-2",
|
|
||||||
address="Московская область, Химки, проспект Мельникова, 33",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10)
|
|
||||||
assert captured[0]["region_code"] == 50
|
|
||||||
assert captured[0]["city"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_mixed_batches_are_told_apart_by_address(captured: list[dict[str, Any]]) -> None:
|
|
||||||
"""Вью отдаёт московский и областной прогоны вперемешку — региональный фильтр
|
|
||||||
обязан развести их сам, курсор по `id` тут не помощник."""
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _payload(
|
|
||||||
source="domclick",
|
|
||||||
source_id="oblast-3",
|
|
||||||
address="Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"payload": _payload(
|
|
||||||
source="domclick",
|
|
||||||
source_id="moscow-2",
|
|
||||||
address="Москва, Генерала Дорохова проспект, 49",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
counters = mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10)
|
|
||||||
assert [lot.source_id for lot in captured[0]["lots"]] == ["oblast-3"]
|
|
||||||
assert counters.selected == 1
|
|
||||||
assert counters.skipped_not_target_region == 1
|
|
||||||
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
|
|
||||||
|
|
||||||
|
|
||||||
def test_writer_gets_city_none_for_oblast(captured: list[dict[str, Any]]) -> None:
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"payload": _payload(
|
|
||||||
source_id="khimki-1",
|
|
||||||
source_url="https://khimki.cian.ru/sale/flat/1/",
|
|
||||||
address="городской округ Химки, Олимпийский проспект, 29",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
|
|
||||||
assert captured[0]["region_code"] == 50
|
|
||||||
assert captured[0]["city"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_region_77_numbers_unchanged(captured: list[dict[str, Any]]) -> None:
|
|
||||||
"""Анти-регресс: те же фикстуры, тот же region=77 → те же прежние числа."""
|
|
||||||
counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=10, region=77)
|
|
||||||
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
|
|
||||||
assert counters.read == 5
|
|
||||||
assert counters.selected == 2
|
|
||||||
assert counters.skipped_not_target_region == 1
|
|
||||||
assert counters.skipped_artifact == 1
|
|
||||||
assert counters.skipped_invalid == 1
|
|
||||||
assert captured[0]["region_code"] == 77
|
|
||||||
assert captured[0]["city"] == "Москва"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cian_subdomain_ignores_port_and_case() -> None:
|
|
||||||
"""Поддомен читается из `hostname`, а не из `netloc`: порт и регистр хоста
|
|
||||||
не должны превращать московскую карточку в «не целевой регион», а областную
|
|
||||||
— в московскую. Хост не `*.cian.ru` (включая голый `cian.ru` и похожий
|
|
||||||
`fake-cian.ru`) остаётся None — консервативно, ложной области не даёт."""
|
|
||||||
assert mod.cian_subdomain({"source_url": "https://WWW.CIAN.RU:443/sale/flat/1/"}) == "www"
|
|
||||||
assert (
|
|
||||||
mod.cian_subdomain({"source_url": "https://khimki.cian.ru:8443/sale/flat/1/"}) == "khimki"
|
|
||||||
)
|
|
||||||
assert mod.is_cian_oblast_payload({"source_url": "https://khimki.cian.ru:8443/x"}) is True
|
|
||||||
assert mod.is_cian_oblast_payload({"source_url": "https://WWW.CIAN.RU:443/x"}) is False
|
|
||||||
for bad in ("https://cian.ru/sale/flat/1/", "https://fake-cian.ru/x", "", None, 42):
|
|
||||||
assert mod.cian_subdomain({"source_url": bad}) is None
|
|
||||||
assert mod.cian_subdomain({}) is None
|
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
"""Гард гео-скоупа свипов: `region_code` 77/50 не должен молча собирать Екатеринбург.
|
|
||||||
|
|
||||||
Контекст: планировщик читает регион из строки расписания (#3515,
|
|
||||||
`scheduler._resolve_region_code`), но гео-скоуп свипа задаётся НЕ им — у avito/cian/
|
|
||||||
yandex это anchors города (`CITY_ANCHORS`/`CITY_LOCATIONS`, сегодня только
|
|
||||||
Свердловская обл.), а у domclick — профиль региона в BFF-URL (DOMCLICK_GEO_PROFILES:
|
|
||||||
66/77/50; код без профиля — падение). Заведи мы
|
|
||||||
строку `*_city_sweep_moscow` с `{"region_code": 77}` — прогон собрал бы ЕКБ и
|
|
||||||
проштамповал его московским регионом, не показав ни одной аномалии в счётчиках.
|
|
||||||
Гард превращает эту тихую порчу в падение до первого запроса.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from scraper_kit.orchestration.pipeline import (
|
|
||||||
_require_region_scope,
|
|
||||||
run_avito_city_sweep,
|
|
||||||
run_cian_city_sweep,
|
|
||||||
run_domclick_city_sweep,
|
|
||||||
run_yandex_city_sweep,
|
|
||||||
)
|
|
||||||
from scraper_kit.providers.domclick.serp import geo_profile_for_region
|
|
||||||
|
|
||||||
_EKB_ANCHOR = [(56.8400, 60.6050, "Центр")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_region_without_anchors_passes() -> None:
|
|
||||||
"""ЕКБ-прогоны (anchors=None, регион по умолчанию 66) не трогаем."""
|
|
||||||
_require_region_scope("avito_city_sweep", 66, None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_region_with_city_anchors_passes() -> None:
|
|
||||||
"""Свердловские oblast-строки (#179/#262) продолжают работать как прежде."""
|
|
||||||
_require_region_scope("cian_city_sweep", 66, [(57.910, 59.980, "Н.Тагил центр")])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("region_code", [50, 77])
|
|
||||||
def test_foreign_region_without_anchors_raises(region_code: int) -> None:
|
|
||||||
"""Москва/МО без своих anchors — тот самый случай, что молча давал ЕКБ."""
|
|
||||||
with pytest.raises(ValueError, match=f"region_code={region_code}"):
|
|
||||||
_require_region_scope("avito_city_sweep", region_code, None)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("region_code", [50, 77])
|
|
||||||
def test_foreign_region_with_explicit_anchors_passes(region_code: int) -> None:
|
|
||||||
"""Явные anchors — явный гео-скоуп: запрещаем не регион, а молчаливый ЕКБ-fallback."""
|
|
||||||
_require_region_scope("avito_city_sweep", region_code, _EKB_ANCHOR)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_kwargs(fn: Callable[..., Any], **overrides: Any) -> dict[str, Any]:
|
|
||||||
"""None во все обязательные keyword-параметры: гард обязан сработать раньше их."""
|
|
||||||
kwargs: dict[str, Any] = {
|
|
||||||
name: None
|
|
||||||
for name, prm in inspect.signature(fn).parameters.items()
|
|
||||||
if prm.kind is inspect.Parameter.KEYWORD_ONLY and prm.default is inspect.Parameter.empty
|
|
||||||
}
|
|
||||||
kwargs.update(overrides)
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"sweep",
|
|
||||||
[run_avito_city_sweep, run_cian_city_sweep, run_yandex_city_sweep],
|
|
||||||
)
|
|
||||||
async def test_anchor_sweeps_reject_foreign_region(sweep: Callable[..., Any]) -> None:
|
|
||||||
"""Гард стоит до первого обращения к БД/сети — поэтому db=None здесь безопасен."""
|
|
||||||
with pytest.raises(ValueError, match="region_code=77"):
|
|
||||||
await sweep(None, **_required_kwargs(sweep, run_id=1, region_code=77, anchors=None))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("region_code", [1, 99])
|
|
||||||
async def test_domclick_sweep_rejects_region_without_profile(region_code: int) -> None:
|
|
||||||
"""У домклика anchors нет вовсе: гео-скоуп задаёт профиль региона
|
|
||||||
(DOMCLICK_GEO_PROFILES: address GUID, aids, страница прогрева, bbox). 66/77/50
|
|
||||||
теперь поддержаны, а код без профиля по-прежнему падает до первого запроса:
|
|
||||||
тихий fallback на ЕКБ — ровно та порча, от которой стережёт этот файл."""
|
|
||||||
with pytest.raises(ValueError, match=f"region_code={region_code}"):
|
|
||||||
await run_domclick_city_sweep(
|
|
||||||
None, # type: ignore[arg-type]
|
|
||||||
**_required_kwargs(run_domclick_city_sweep, run_id=1, region_code=region_code),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("region_code", [66, 77, 50])
|
|
||||||
def test_domclick_supported_regions_have_profiles(region_code: int) -> None:
|
|
||||||
"""Москва и область больше НЕ режутся: у них свой GUID и свой bbox."""
|
|
||||||
profile = geo_profile_for_region(region_code)
|
|
||||||
assert profile.region_code == region_code
|
|
||||||
assert profile.address_guid
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
"""Москва и МО в реестрах развёрток: хост Циана, идентификаторы, сетка якорей.
|
|
||||||
|
|
||||||
Черновик msk-rollout: снимаем привязку avito/cian/yandex к Екатеринбургу. Тесты
|
|
||||||
здесь чисто реестровые (никаких сетевых вызовов): проверяют, что
|
|
||||||
* ЕКБ-путь не сдвинулся ни на байт (хост, слаг, rgid, region=),
|
|
||||||
* Москва/область/объединённый скоуп дают ровно измеренные живьём значения,
|
|
||||||
* сетка якорей Москвы лежит внутри продуктового ядра и покрывает его без дыр,
|
|
||||||
* неизвестный слаг ПАДАЕТ, а не откатывается молча на Екатеринбург.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from scraper_kit.orchestration.pipeline import (
|
|
||||||
CIAN_MOSCOW_HOST,
|
|
||||||
CIAN_SVERDLOVSK_HOST,
|
|
||||||
CITY_LOCATIONS,
|
|
||||||
MOSCOW_ANCHOR_RADIUS_M,
|
|
||||||
MOSCOW_ANCHORS,
|
|
||||||
MOSCOW_CORE_BBOX_DEG,
|
|
||||||
MOSCOW_OBLAST_ANCHOR_RADIUS_M,
|
|
||||||
MOSCOW_OBLAST_CITY_ANCHORS,
|
|
||||||
MOSCOW_OBLAST_CLUSTER_ANCHORS,
|
|
||||||
MULTI_CITY_SLUGS,
|
|
||||||
get_city_anchor_point,
|
|
||||||
get_city_anchors,
|
|
||||||
get_city_location,
|
|
||||||
get_city_stamp_radius_km,
|
|
||||||
resolve_city_name,
|
|
||||||
)
|
|
||||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
|
||||||
from scraper_kit.providers.cian.serp import CianScraper
|
|
||||||
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
|
||||||
|
|
||||||
|
|
||||||
def _cfg() -> SimpleNamespace:
|
|
||||||
"""Минимальный ScraperConfig-стаб: конструкторам scraper'ов хватает атрибутов."""
|
|
||||||
return SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")
|
|
||||||
|
|
||||||
|
|
||||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
||||||
r = 6371.0
|
|
||||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
||||||
dp = math.radians(lat2 - lat1)
|
|
||||||
dl = math.radians(lon2 - lon1)
|
|
||||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
||||||
return 2 * r * math.asin(math.sqrt(a))
|
|
||||||
|
|
||||||
|
|
||||||
# ── ЕКБ не сдвинулся ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekb_cian_host_and_region_byte_identical() -> None:
|
|
||||||
"""ЕКБ идёт по None-пути (get_city_location(None) → None) — хост класса и region=
|
|
||||||
остаются прежними: ekb.cian.ru + region=4743."""
|
|
||||||
assert get_city_location(None) is None
|
|
||||||
assert CianScraper.base_url == "https://ekb.cian.ru" == CIAN_SVERDLOVSK_HOST
|
|
||||||
|
|
||||||
scraper = CianScraper(_cfg(), city_region_id=None, city_host=None) # type: ignore[arg-type]
|
|
||||||
url = scraper._build_url()
|
|
||||||
assert url.startswith("https://ekb.cian.ru/cat.php?")
|
|
||||||
assert "region=4743" in url
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekb_avito_slug_and_card_filter_unchanged() -> None:
|
|
||||||
"""Без target_city_slug путь URL остаётся /ekaterinburg/, фильтр карточек включён."""
|
|
||||||
scraper = AvitoScraper(_cfg()) # type: ignore[arg-type]
|
|
||||||
assert scraper._city_seg() == "ekaterinburg"
|
|
||||||
assert scraper._filter_cards_by_city_slug is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekb_yandex_rgid_unchanged() -> None:
|
|
||||||
scraper = YandexRealtyScraper(_cfg(), city_rgid=None) # type: ignore[arg-type]
|
|
||||||
assert scraper._rgid == 559132
|
|
||||||
|
|
||||||
|
|
||||||
def test_sverdlovsk_entries_keep_default_host() -> None:
|
|
||||||
"""Все свердловские записи остаются на ekb.cian.ru — поле добавлено с дефолтом."""
|
|
||||||
for slug, loc in CITY_LOCATIONS.items():
|
|
||||||
if slug in {"moskva", "moskovskaya_oblast", "moskva_i_mo"}:
|
|
||||||
continue
|
|
||||||
assert loc.cian_host == CIAN_SVERDLOVSK_HOST, slug
|
|
||||||
assert loc.avito_slug_is_region is False, slug
|
|
||||||
|
|
||||||
|
|
||||||
# ── Москва / область / объединённый скоуп ───────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("slug", "region_id", "rgid"),
|
|
||||||
[
|
|
||||||
("moskva", 1, 587795),
|
|
||||||
("moskovskaya_oblast", 4593, 587654),
|
|
||||||
("moskva_i_mo", -1, 741964),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_moscow_scopes_ids(slug: str, region_id: int, rgid: int) -> None:
|
|
||||||
"""Замер 10-12.09.2026: region=1 Москва, 4593 область, -1 обе вместе."""
|
|
||||||
loc = get_city_location(slug)
|
|
||||||
assert loc is not None
|
|
||||||
assert loc.cian_region_id == region_id
|
|
||||||
assert loc.yandex_rgid == rgid
|
|
||||||
assert loc.cian_host == CIAN_MOSCOW_HOST == "https://www.cian.ru"
|
|
||||||
# avito: ОДИН слаг на оба региона, отдельного слага у области нет.
|
|
||||||
assert loc.avito_slug == "moskva_i_mo"
|
|
||||||
assert loc.avito_slug_is_region is True
|
|
||||||
|
|
||||||
scraper = CianScraper( # type: ignore[arg-type]
|
|
||||||
_cfg(), city_region_id=loc.cian_region_id, city_host=loc.cian_host
|
|
||||||
)
|
|
||||||
url = scraper._build_url()
|
|
||||||
assert url.startswith("https://www.cian.ru/cat.php?")
|
|
||||||
assert f"region={region_id}" in url
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_region_slug_disables_card_filter() -> None:
|
|
||||||
"""Слаг региона в ссылках карточек не встречается → per-card фильтр выключается,
|
|
||||||
иначе отфильтровалась бы вся выдача (avito_serp_ekb_only=True в конфиге)."""
|
|
||||||
loc = get_city_location("moskva")
|
|
||||||
assert loc is not None
|
|
||||||
scraper = AvitoScraper( # type: ignore[arg-type]
|
|
||||||
_cfg(),
|
|
||||||
target_city_slug=loc.avito_slug,
|
|
||||||
filter_cards_by_city_slug=not loc.avito_slug_is_region,
|
|
||||||
)
|
|
||||||
assert scraper._city_seg() == "moskva_i_mo"
|
|
||||||
assert scraper._filter_cards_by_city_slug is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_multi_city_scopes_do_not_stamp_a_city() -> None:
|
|
||||||
"""У области и объединённого скоупа город в batch'е не один — штампа быть не должно."""
|
|
||||||
assert MULTI_CITY_SLUGS == frozenset({"moskovskaya_oblast", "moskva_i_mo"})
|
|
||||||
assert resolve_city_name("moskovskaya_oblast") is None
|
|
||||||
assert resolve_city_name("moskva_i_mo") is None
|
|
||||||
assert resolve_city_name("moskva") == "Москва"
|
|
||||||
assert resolve_city_name(None) == "Екатеринбург"
|
|
||||||
|
|
||||||
|
|
||||||
# ── сетка якорей Москвы ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_grid_inside_core_and_centered() -> None:
|
|
||||||
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
|
||||||
assert len(MOSCOW_ANCHORS) == 25
|
|
||||||
for lat, lon, name in MOSCOW_ANCHORS:
|
|
||||||
assert lat_min <= lat <= lat_max, name
|
|
||||||
assert lon_min <= lon <= lon_max, name
|
|
||||||
# anchors[0] — центр ядра: именно его берёт гео-гард штампа.
|
|
||||||
assert get_city_anchor_point("moskva") == (55.75, 37.60)
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_grid_covers_core_without_holes() -> None:
|
|
||||||
"""Любая точка ядра не дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего якоря."""
|
|
||||||
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
|
||||||
radius_km = MOSCOW_ANCHOR_RADIUS_M / 1000.0
|
|
||||||
worst = 0.0
|
|
||||||
lat_steps = round((lat_max - lat_min) / 0.01)
|
|
||||||
lon_steps = round((lon_max - lon_min) / 0.01)
|
|
||||||
for i in range(lat_steps + 1):
|
|
||||||
lat = lat_min + i * 0.01
|
|
||||||
for j in range(lon_steps + 1):
|
|
||||||
lon = lon_min + j * 0.01
|
|
||||||
nearest = min(
|
|
||||||
_haversine_km(lat, lon, a_lat, a_lon) for a_lat, a_lon, _ in MOSCOW_ANCHORS
|
|
||||||
)
|
|
||||||
worst = max(worst, nearest)
|
|
||||||
assert worst <= radius_km, f"дыра в покрытии: худшая точка ядра в {worst:.2f} км от якоря"
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_oblast_anchors_are_satellite_cities() -> None:
|
|
||||||
"""У области — по якорю на город-спутник, координаты из реестра репозитория."""
|
|
||||||
anchors = get_city_anchors("moskovskaya_oblast")
|
|
||||||
assert anchors is not None
|
|
||||||
names = [name for _lat, _lon, name in anchors]
|
|
||||||
assert "Балашиха" in names and "Химки" in names and "Подольск" in names
|
|
||||||
assert len(names) == len(set(names))
|
|
||||||
combined = get_city_anchors("moskva_i_mo")
|
|
||||||
assert combined is not None
|
|
||||||
assert len(combined) == len(MOSCOW_ANCHORS) + len(anchors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_oblast_anchors_are_two_measured_layers() -> None:
|
|
||||||
"""Оба слоя на месте и не перепутаны: города-спутники + кластеры лот-массы."""
|
|
||||||
anchors = get_city_anchors("moskovskaya_oblast")
|
|
||||||
assert anchors is not None
|
|
||||||
assert anchors == [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]
|
|
||||||
# Слой 2 добирает то, чего не дают города: без него покрытие лот-массы 73.8%.
|
|
||||||
assert len(MOSCOW_OBLAST_CLUSTER_ANCHORS) > 0
|
|
||||||
cities = {name for _lat, _lon, name in MOSCOW_OBLAST_CITY_ANCHORS}
|
|
||||||
clusters = {name for _lat, _lon, name in MOSCOW_OBLAST_CLUSTER_ANCHORS}
|
|
||||||
assert not (cities & clusters), "подписи слоёв пересеклись — якорь задублирован"
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_oblast_anchors_lie_inside_region_50_bbox() -> None:
|
|
||||||
"""Якорь вне области собрал бы чужой регион и проштамповал бы его как 50.
|
|
||||||
|
|
||||||
Границы — bbox_region региона 50 из backend/app/services/regions.py (дубль:
|
|
||||||
kit не имеет права импортировать app.*).
|
|
||||||
"""
|
|
||||||
lat_min, lat_max, lon_min, lon_max = 54.20, 56.96, 35.14, 40.21
|
|
||||||
for lat, lon, name in [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]:
|
|
||||||
assert lat_min <= lat <= lat_max, f"{name}: широта вне области"
|
|
||||||
assert lon_min <= lon <= lon_max, f"{name}: долгота вне области"
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_oblast_radius_is_the_one_coverage_was_measured_at() -> None:
|
|
||||||
"""95.2% лот-массы посчитаны при 10 км; меньший radius_m в расписании даст дыры."""
|
|
||||||
assert MOSCOW_OBLAST_ANCHOR_RADIUS_M == 10000
|
|
||||||
assert MOSCOW_OBLAST_ANCHOR_RADIUS_M > MOSCOW_ANCHOR_RADIUS_M
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_stamp_radius_covers_core_diagonal() -> None:
|
|
||||||
"""15 км по умолчанию отрезали бы легитимную Москву — у ядра полудиагональ 29.1 км."""
|
|
||||||
_lat_min, lat_max, _lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
|
||||||
center = get_city_anchor_point("moskva")
|
|
||||||
assert center is not None
|
|
||||||
corner = _haversine_km(center[0], center[1], lat_max, lon_max)
|
|
||||||
assert corner > 15.0
|
|
||||||
assert get_city_stamp_radius_km("moskva") >= corner
|
|
||||||
# область/объединённый скоуп штампа не ставят вовсе (city=None → гард инертен).
|
|
||||||
assert resolve_city_name("moskovskaya_oblast") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── неизвестный слаг падает ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("fn", [get_city_anchors, get_city_location, resolve_city_name])
|
|
||||||
def test_unknown_slug_raises_instead_of_ekb_fallback(fn) -> None: # type: ignore[no-untyped-def]
|
|
||||||
with pytest.raises(ValueError, match="nonexistent_city"):
|
|
||||||
fn("nonexistent_city")
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
"""Тесты чистого планировщика ценовых коридоров (`scraper_kit.pricing.planner`).
|
|
||||||
|
|
||||||
Планировщик синхронный и чистый: «сколько позиций в коридоре» инжектится
|
|
||||||
функцией, поэтому здесь НЕТ ни сети, ни моков площадок — только модели
|
|
||||||
распределения цен.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import bisect
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from scraper_kit.pricing.planner import (
|
|
||||||
EKB_REGION_CODE,
|
|
||||||
MAX_DEPTH,
|
|
||||||
MIN_WIDTH_RATIO,
|
|
||||||
geometric_mid,
|
|
||||||
plan_price_corridors,
|
|
||||||
price_bisection_enabled,
|
|
||||||
width_ratio,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _counter(prices: list[int]):
|
|
||||||
"""`count_of` поверх отсортированного списка цен: сколько попало в [lo, hi]."""
|
|
||||||
ordered = sorted(prices)
|
|
||||||
|
|
||||||
def count_of(lo: int | None, hi: int | None) -> int:
|
|
||||||
left = 0 if lo is None else bisect.bisect_left(ordered, lo)
|
|
||||||
right = len(ordered) if hi is None else bisect.bisect_right(ordered, hi)
|
|
||||||
return max(0, right - left)
|
|
||||||
|
|
||||||
return count_of
|
|
||||||
|
|
||||||
|
|
||||||
def _covers_every_price(plan, prices: list[int]) -> bool:
|
|
||||||
"""Каждая цена попадает хотя бы в один коридор плана."""
|
|
||||||
return all(
|
|
||||||
any((c.lo is None or p >= c.lo) and (c.hi is None or p <= c.hi) for c in plan.corridors)
|
|
||||||
for p in prices
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── равномерное распределение ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_uniform_spread_splits_until_every_corridor_fits_target() -> None:
|
|
||||||
"""Равномерная выборка делится, пока каждый коридор не влезет в target."""
|
|
||||||
prices = list(range(1_000_000, 21_000_000, 2_000)) # 10 000 позиций
|
|
||||||
plan = plan_price_corridors(
|
|
||||||
count_of=_counter(prices),
|
|
||||||
target=1_200,
|
|
||||||
hard_cap=1_500,
|
|
||||||
lo=1_000_000,
|
|
||||||
hi=21_000_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert plan.stats.root_count == len(prices)
|
|
||||||
assert plan.stats.corridors > 1, "10k позиций при target=1200 обязаны поделиться"
|
|
||||||
assert plan.stats.truncated_corridors == 0
|
|
||||||
assert plan.stats.missed_estimate == 0
|
|
||||||
assert plan.stats.complete
|
|
||||||
assert all(c.count is not None and c.count <= 1_200 for c in plan.corridors)
|
|
||||||
assert _covers_every_price(plan, prices)
|
|
||||||
|
|
||||||
|
|
||||||
def test_small_sample_stays_one_corridor() -> None:
|
|
||||||
"""Выборка ниже target не делится вовсе — один коридор, один probe."""
|
|
||||||
prices = list(range(2_000_000, 3_000_000, 10_000)) # 100 позиций
|
|
||||||
plan = plan_price_corridors(count_of=_counter(prices), target=1_200)
|
|
||||||
|
|
||||||
assert plan.stats.corridors == 1
|
|
||||||
assert plan.stats.probes == 1
|
|
||||||
assert plan.stats.complete
|
|
||||||
|
|
||||||
|
|
||||||
# ── вырожденный случай: всё в одной цене ─────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_price_point_hits_min_width_ratio_and_is_marked_truncated() -> None:
|
|
||||||
"""«Всё в одной цене» упирается в MIN_WIDTH_RATIO, а не зацикливается.
|
|
||||||
|
|
||||||
Коридор вокруг единственной цены сузить до нужного размера невозможно —
|
|
||||||
делить бессмысленно. Планировщик обязан остановиться и ПОМЕТИТЬ коридор
|
|
||||||
усечённым с честным недобором, а не молча отдать план «всё влезло».
|
|
||||||
"""
|
|
||||||
prices = [3_000_000] * 5_000
|
|
||||||
plan = plan_price_corridors(
|
|
||||||
count_of=_counter(prices),
|
|
||||||
target=1_200,
|
|
||||||
hard_cap=1_500,
|
|
||||||
lo=1_000_000,
|
|
||||||
hi=10_000_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert plan.stats.truncated_corridors >= 1
|
|
||||||
assert plan.stats.missed_estimate > 0
|
|
||||||
assert not plan.stats.complete
|
|
||||||
|
|
||||||
overflowing = [c for c in plan.corridors if c.count and c.count > 1_500]
|
|
||||||
assert overflowing, "переполненный коридор обязан остаться в плане"
|
|
||||||
for c in overflowing:
|
|
||||||
assert c.truncated, f"коридор {c.label()} переполнен, но не помечен"
|
|
||||||
assert c.missed == c.count - 1_500
|
|
||||||
# Остановились по предохранителю, а не «просто так».
|
|
||||||
assert c.depth >= MAX_DEPTH or width_ratio(c.lo, c.hi) <= MIN_WIDTH_RATIO
|
|
||||||
|
|
||||||
assert plan.stats.missed_estimate == sum(c.missed for c in plan.truncated)
|
|
||||||
|
|
||||||
|
|
||||||
def test_degenerate_plan_terminates_within_depth_and_probe_budget() -> None:
|
|
||||||
"""Вырожденный вход завершается: глубина ограничена, число probe конечно."""
|
|
||||||
plan = plan_price_corridors(
|
|
||||||
count_of=_counter([3_000_000] * 5_000),
|
|
||||||
target=1_200,
|
|
||||||
lo=1_000_000,
|
|
||||||
hi=10_000_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert plan.stats.max_depth_reached <= MAX_DEPTH
|
|
||||||
assert plan.stats.probes < 2 ** (MAX_DEPTH + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_max_depth_is_never_exceeded_on_open_ended_range() -> None:
|
|
||||||
"""Даже на открытом диапазоне глубина не превышает MAX_DEPTH."""
|
|
||||||
prices = list(range(500_000, 100_000_000, 5_000))
|
|
||||||
plan = plan_price_corridors(count_of=_counter(prices), target=1_000)
|
|
||||||
|
|
||||||
assert plan.stats.max_depth_reached <= MAX_DEPTH
|
|
||||||
assert all(c.depth <= MAX_DEPTH for c in plan.corridors)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("max_depth", [1, 2, 3])
|
|
||||||
def test_custom_max_depth_caps_recursion(max_depth: int) -> None:
|
|
||||||
"""Пониженный max_depth режет дерево раньше — и недобор виден в статистике."""
|
|
||||||
prices = list(range(1_000_000, 21_000_000, 2_000))
|
|
||||||
plan = plan_price_corridors(
|
|
||||||
count_of=_counter(prices),
|
|
||||||
target=1_200,
|
|
||||||
hard_cap=1_500,
|
|
||||||
lo=1_000_000,
|
|
||||||
hi=21_000_000,
|
|
||||||
max_depth=max_depth,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert plan.stats.max_depth_reached <= max_depth
|
|
||||||
# Мелкий потолок глубины физически не даёт добрать всё — это обязано
|
|
||||||
# проявиться усечением, а не «успехом».
|
|
||||||
assert plan.stats.truncated_corridors > 0
|
|
||||||
assert plan.stats.missed_estimate > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ── регион 66: поведение прежнее ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_66_disables_bisection() -> None:
|
|
||||||
"""Свердловская обл. — бисекция выключена; неизвестный регион трактуем как 66."""
|
|
||||||
assert price_bisection_enabled(EKB_REGION_CODE) is False
|
|
||||||
assert price_bisection_enabled(None) is False
|
|
||||||
assert price_bisection_enabled(77) is True # Москва
|
|
||||||
assert price_bisection_enabled(50) is True # Московская обл.
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_66_yields_exactly_one_corridor_over_whole_range() -> None:
|
|
||||||
"""С выключенной бисекцией план = ровно один коридор на весь диапазон.
|
|
||||||
|
|
||||||
Это и есть «поведение региона 66 осталось прежним»: ни одного лишнего
|
|
||||||
запроса к площадке сверх единственного корневого probe.
|
|
||||||
"""
|
|
||||||
prices = list(range(1_000_000, 21_000_000, 2_000))
|
|
||||||
plan = plan_price_corridors(
|
|
||||||
count_of=_counter(prices),
|
|
||||||
target=1_200,
|
|
||||||
lo=1_000_000,
|
|
||||||
hi=21_000_000,
|
|
||||||
enabled=price_bisection_enabled(EKB_REGION_CODE),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert plan.stats.corridors == 1
|
|
||||||
assert plan.stats.probes == 1
|
|
||||||
only = plan.corridors[0]
|
|
||||||
assert (only.lo, only.hi) == (1_000_000, 21_000_000)
|
|
||||||
assert only.depth == 0
|
|
||||||
assert _covers_every_price(plan, prices)
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_plan_still_reports_truncation() -> None:
|
|
||||||
"""Выключенная бисекция не значит «молчать»: переполнение всё равно в счётчиках."""
|
|
||||||
plan = plan_price_corridors(
|
|
||||||
count_of=_counter(list(range(1_000_000, 21_000_000, 2_000))),
|
|
||||||
target=1_200,
|
|
||||||
hard_cap=1_500,
|
|
||||||
enabled=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert plan.stats.corridors == 1
|
|
||||||
assert plan.stats.truncated_corridors == 1
|
|
||||||
assert plan.stats.missed_estimate == 10_000 - 1_500
|
|
||||||
assert not plan.stats.complete
|
|
||||||
|
|
||||||
|
|
||||||
# ── непрочитанный счётчик ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_counter_is_marked_not_silently_trusted() -> None:
|
|
||||||
"""`count_of` вернул None → коридор слепой: truncated + unknown, не «пусто»."""
|
|
||||||
plan = plan_price_corridors(count_of=lambda lo, hi: None, target=1_200)
|
|
||||||
|
|
||||||
assert plan.stats.corridors == 1
|
|
||||||
assert plan.stats.unknown_corridors == 1
|
|
||||||
assert plan.corridors[0].unknown_count is True
|
|
||||||
assert plan.corridors[0].truncated is True
|
|
||||||
assert not plan.stats.complete
|
|
||||||
|
|
||||||
|
|
||||||
# ── геометрическая середина ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_geometric_mid_is_geometric_not_arithmetic() -> None:
|
|
||||||
"""sqrt(lo*hi), а не (lo+hi)/2 — иначе верхняя половина вырожденно пуста."""
|
|
||||||
assert geometric_mid(1_000_000, 100_000_000) == 10_000_000
|
|
||||||
assert geometric_mid(1_000_000, 100_000_000) != (1_000_000 + 100_000_000) // 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_geometric_mid_stays_strictly_inside_the_corridor() -> None:
|
|
||||||
"""При hi-lo>=2 середина строго внутри — обе половины непусты."""
|
|
||||||
for lo, hi in ((1_000_000, 1_000_002), (1_000_000, 100_000_000), (None, 700_000)):
|
|
||||||
mid = geometric_mid(lo, hi)
|
|
||||||
assert mid < hi
|
|
||||||
if lo is not None:
|
|
||||||
assert mid > lo
|
|
||||||
|
|
||||||
|
|
||||||
def test_atomic_corridor_is_not_split_and_is_marked_truncated() -> None:
|
|
||||||
"""Коридор шириной в 1 ₽ делить нечем: предохранитель, а не рекурсия по себе.
|
|
||||||
|
|
||||||
`geometric_mid` при hi-lo<=1 строгой внутренней точки дать не может, и
|
|
||||||
половина совпала бы с родителем. Планировщик обязан остановиться сам, не
|
|
||||||
полагаясь на MAX_DEPTH как на единственный тормоз — поэтому
|
|
||||||
min_width_ratio здесь занижен до 1.0, чтобы снять штатный предохранитель.
|
|
||||||
"""
|
|
||||||
plan = plan_price_corridors(
|
|
||||||
count_of=_counter([5_000_000] * 3_000),
|
|
||||||
target=100,
|
|
||||||
hard_cap=100,
|
|
||||||
lo=5_000_000,
|
|
||||||
hi=5_000_001,
|
|
||||||
min_width_ratio=1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert plan.stats.corridors == 1
|
|
||||||
only = plan.corridors[0]
|
|
||||||
assert (only.lo, only.hi) == (5_000_000, 5_000_001)
|
|
||||||
assert only.truncated is True
|
|
||||||
assert only.missed == 3_000 - 100
|
|
||||||
assert plan.stats.max_depth_reached == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_width_ratio_open_top_is_infinite() -> None:
|
|
||||||
assert width_ratio(1_000_000, None) == float("inf")
|
|
||||||
assert width_ratio(1_000_000, 2_000_000) == pytest.approx(2.0)
|
|
||||||
|
|
@ -561,7 +561,6 @@ def test_offered_cities_match_coverage_cities() -> None:
|
||||||
|
|
||||||
from app.api.v1.trade_in import (
|
from app.api.v1.trade_in import (
|
||||||
COVERAGE_GREEN_CITIES,
|
COVERAGE_GREEN_CITIES,
|
||||||
COVERAGE_MO_CITIES,
|
|
||||||
COVERAGE_MOSCOW_DISPLAY,
|
COVERAGE_MOSCOW_DISPLAY,
|
||||||
COVERAGE_YELLOW_CITIES,
|
COVERAGE_YELLOW_CITIES,
|
||||||
_fold_city,
|
_fold_city,
|
||||||
|
|
@ -581,12 +580,7 @@ def test_offered_cities_match_coverage_cities() -> None:
|
||||||
offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))}
|
offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))}
|
||||||
answerable = {
|
answerable = {
|
||||||
_fold_city(c)
|
_fold_city(c)
|
||||||
for c in (
|
for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, COVERAGE_MOSCOW_DISPLAY)
|
||||||
*COVERAGE_GREEN_CITIES,
|
|
||||||
*COVERAGE_YELLOW_CITIES,
|
|
||||||
*COVERAGE_MO_CITIES,
|
|
||||||
COVERAGE_MOSCOW_DISPLAY,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert offered == answerable, (
|
assert offered == answerable, (
|
||||||
|
|
@ -601,13 +595,12 @@ def test_every_coverage_city_has_a_centroid() -> None:
|
||||||
from app.api.v1.trade_in import (
|
from app.api.v1.trade_in import (
|
||||||
_CITY_CENTROIDS_DEG,
|
_CITY_CENTROIDS_DEG,
|
||||||
COVERAGE_GREEN_CITIES,
|
COVERAGE_GREEN_CITIES,
|
||||||
COVERAGE_MO_CITIES,
|
|
||||||
COVERAGE_YELLOW_CITIES,
|
COVERAGE_YELLOW_CITIES,
|
||||||
_fold_city,
|
_fold_city,
|
||||||
)
|
)
|
||||||
|
|
||||||
centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG}
|
centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG}
|
||||||
for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, *COVERAGE_MO_CITIES):
|
for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES):
|
||||||
assert _fold_city(city) in centroids, (
|
assert _fold_city(city) in centroids, (
|
||||||
f"{city} есть в порогах, но нет в центроидах — по координатам он "
|
f"{city} есть в порогах, но нет в центроидах — по координатам он "
|
||||||
"никогда не резолвится, и порог мёртвый"
|
"никогда не резолвится, и порог мёртвый"
|
||||||
|
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
"""Гео-согласованный коэффициент asking→sold вне ЕКБ (#3529).
|
|
||||||
|
|
||||||
Прод-замер (регион 50): пул даёт 0.891 при 0.73 у Москвы и 0.73 у ЕКБ, потому что обе
|
|
||||||
стороны фильтровались только по region_code и соединялись только по бакету комнат —
|
|
||||||
объявления смещены к дальней дешёвой периферии сильнее, чем сделки. Разложение по
|
|
||||||
кольцам 10 км показывает 0.808 / 0.847 / 0.951 / 0.688 ВНУТРИ колец, причём 76% сделок
|
|
||||||
лежат в ближних кольцах с 0.81-0.85.
|
|
||||||
|
|
||||||
Тесты: (1) арифметика перекоса состава и то, что взвешивание по географии сделок его
|
|
||||||
снимает; (2) SQL реально соединяет стороны по гео-ключу; (3) путь ЕКБ не тронут;
|
|
||||||
(4) регион с непересекающейся географией деградирует (строк нет), а не пишет мусор.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from app.tasks import asking_to_sold_ratio as ratio_mod
|
|
||||||
|
|
||||||
_REGION_SQL = str(ratio_mod._REDERIVE_SQL_REGION.text)
|
|
||||||
_STATS_SQL = str(ratio_mod._GEO_STATS_SQL_REGION.text)
|
|
||||||
_EKB_SQL = str(ratio_mod._REDERIVE_SQL.text)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 1. Синтетика: пул завышает, согласованная география — нет ─────────────────
|
|
||||||
|
|
||||||
# Ячейки из прод-разложения области 50 по кольцам 10 км (сделки 12 мес vs активные
|
|
||||||
# объявления): (n_deals, sold_median, n_listings, ask_median).
|
|
||||||
_PROD_RINGS = [
|
|
||||||
(12_189, 175_000, 23_292, 216_666),
|
|
||||||
(4_132, 147_026, 10_206, 173_684),
|
|
||||||
(1_432, 150_916, 5_243, 158_675),
|
|
||||||
(150, 84_390, 2_294, 122_727),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _pooled_ratio(cells: list[tuple[int, int, int, int]]) -> float:
|
|
||||||
"""СТАРАЯ схема: каждая сторона усредняется по СВОЕЙ популяции, общего ключа нет.
|
|
||||||
|
|
||||||
Ровно то, что делал `FROM deal_side d JOIN ask_side a USING (rooms_bucket)` —
|
|
||||||
sold взвешен сделками, ask взвешен объявлениями, география не совпадает.
|
|
||||||
"""
|
|
||||||
sold = sum(n_d * s for n_d, s, _, _ in cells) / sum(n_d for n_d, _, _, _ in cells)
|
|
||||||
ask = sum(n_l * a for _, _, n_l, a in cells) / sum(n_l for _, _, n_l, _ in cells)
|
|
||||||
return sold / ask
|
|
||||||
|
|
||||||
|
|
||||||
def _geo_matched_ratio(cells: list[tuple[int, int, int, int]]) -> float:
|
|
||||||
"""НОВАЯ схема: обе стороны взвешены ОДНИМИ весами — числом сделок ячейки."""
|
|
||||||
w = [n_d for n_d, _, _, _ in cells]
|
|
||||||
sold = sum(n_d * s for n_d, s, _, _ in cells) / sum(w)
|
|
||||||
ask = sum(n_d * a for n_d, _, _, a in cells) / sum(w)
|
|
||||||
return sold / ask
|
|
||||||
|
|
||||||
|
|
||||||
def test_pool_inflates_and_geo_matching_fixes_it() -> None:
|
|
||||||
"""Пул завышает коэффициент; та же выборка на согласованной географии — нет.
|
|
||||||
|
|
||||||
Числа взяты из прод-разложения (см. _PROD_RINGS): внутри ячеек 0.69-0.95, а общий
|
|
||||||
пул — около 0.89. Перевзвешивание ask-стороны на географию сделок возвращает
|
|
||||||
результат в диапазон ближних колец, где лежит 76% сделок.
|
|
||||||
"""
|
|
||||||
pooled = _pooled_ratio(_PROD_RINGS)
|
|
||||||
matched = _geo_matched_ratio(_PROD_RINGS)
|
|
||||||
per_cell = [s / a for _, s, _, a in _PROD_RINGS]
|
|
||||||
|
|
||||||
# Пул — завышен. NB: 0.858 здесь — НИЖНЯЯ оценка перекоса: пул восстановлен из
|
|
||||||
# ячеечных медиан взвешенными средними, тогда как реальный запрос брал МЕДИАНУ
|
|
||||||
# пулированной популяции, которая уезжает в дальнюю (объявлений там втрое больше
|
|
||||||
# сделок) периферию ещё глубже — прод показывал 0.891 при 0.73 у Москвы и ЕКБ.
|
|
||||||
assert pooled > 0.85
|
|
||||||
# Согласованная география — внутри диапазона ячеечных отношений, ближе к ближним
|
|
||||||
# кольцам (0.81-0.85), где сосредоточена масса сделок.
|
|
||||||
assert min(per_cell) <= matched <= max(per_cell)
|
|
||||||
assert 0.80 < matched < 0.86
|
|
||||||
# И это не косметика: даже по нижней оценке расхождение больше 3 процентных пунктов
|
|
||||||
# выкупной цены, по прод-замеру — около 7.
|
|
||||||
assert pooled - matched > 0.03
|
|
||||||
|
|
||||||
|
|
||||||
def test_geo_matched_ratio_is_weighted_by_deals_not_listings() -> None:
|
|
||||||
"""Вес ячейки — число СДЕЛОК: дальняя периферия с горой объявлений не перетягивает.
|
|
||||||
|
|
||||||
Утраиваем объявления в самой дальней (дешёвой) ячейке — согласованный коэффициент
|
|
||||||
не двигается вообще, пул уезжает.
|
|
||||||
"""
|
|
||||||
skewed = [
|
|
||||||
(n_d, s, n_l * 3 if i == len(_PROD_RINGS) - 1 else n_l, a)
|
|
||||||
for i, (n_d, s, n_l, a) in enumerate(_PROD_RINGS)
|
|
||||||
]
|
|
||||||
assert _geo_matched_ratio(skewed) == _geo_matched_ratio(_PROD_RINGS)
|
|
||||||
assert _pooled_ratio(skewed) != _pooled_ratio(_PROD_RINGS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sql_implements_the_same_weighted_formula() -> None:
|
|
||||||
"""Формула из теста выше — ровно то, что считает SQL (иначе двойник разъедется).
|
|
||||||
|
|
||||||
ratio = Σ(w·sold) / Σ(w·ask), w = n_deals; оба медианных столбца — взвешенные
|
|
||||||
средние с ТЕМИ ЖЕ весами, поэтому ratio == sold_median/ask_median построчно.
|
|
||||||
"""
|
|
||||||
flat = re.sub(r"\s+", " ", _REGION_SQL)
|
|
||||||
ratio_expr = "CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals) AS numeric)"
|
|
||||||
assert ratio_expr + " AS ratio" in flat
|
|
||||||
assert "round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median" in flat
|
|
||||||
assert "round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median" in flat
|
|
||||||
# Веса — сделки, не объявления.
|
|
||||||
assert "SUM(sold_median * n_listings)" not in flat
|
|
||||||
assert "SUM(ask_median * n_listings)" not in flat
|
|
||||||
|
|
||||||
|
|
||||||
# ── 2. Гео-ключ есть И в фильтре, И в соединении ──────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_sql_joins_both_sides_on_the_same_cells() -> None:
|
|
||||||
"""Старое соединение было ТОЛЬКО по бакету комнат — гео-ключа в нём не было вовсе."""
|
|
||||||
flat = re.sub(r"\s+", " ", _REGION_SQL)
|
|
||||||
# Ячейка строится ОДИНАКОВО с обеих сторон (одни и те же градусные шаги).
|
|
||||||
assert flat.count(f"FLOOR(ST_Y(geom) / {ratio_mod._CELL_LAT_DEG}) AS cell_lat") == 2
|
|
||||||
assert flat.count(f"FLOOR(ST_X(geom) / {ratio_mod._CELL_LON_DEG}) AS cell_lon") == 2
|
|
||||||
# Соединение сторон несёт гео-ключ.
|
|
||||||
assert "JOIN ask_cell a USING (cell_lat, cell_lon)" in flat
|
|
||||||
assert "JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)" in flat
|
|
||||||
# Прежних «слепых» соединений не осталось.
|
|
||||||
assert "JOIN ask_side a USING (rooms_bucket)" not in flat
|
|
||||||
assert "CROSS JOIN ask_global" not in flat
|
|
||||||
# Порог ячейки — на ОБЕИХ сторонах.
|
|
||||||
assert f"d.n_deals >= {ratio_mod._CELL_MIN_DEALS}" in flat
|
|
||||||
assert f"a.n_listings >= {ratio_mod._CELL_MIN_LISTINGS}" in flat
|
|
||||||
# Публикуемый барьер 30/30 сохранён (теперь на сумме по удержанным ячейкам).
|
|
||||||
assert "HAVING SUM(n_deals) >= 30 AND SUM(n_listings) >= 30" in flat
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_sql_stays_psycopg_v3_safe_and_region_scoped() -> None:
|
|
||||||
for sql in (_REGION_SQL, _STATS_SQL):
|
|
||||||
assert re.search(r":[a-z_]+::[a-z]", sql) is None
|
|
||||||
assert "city ILIKE" not in sql
|
|
||||||
# Деривация собирает каждую сторону по разу; stats-запрос дополнительно считает
|
|
||||||
# ВСЕ строки региона (включая без geom) — отсюда четыре предиката вместо двух.
|
|
||||||
assert _REGION_SQL.count("region_code = CAST(:region_code AS int)") == 2
|
|
||||||
assert _STATS_SQL.count("region_code = CAST(:region_code AS int)") == 4
|
|
||||||
assert "CAST(:region_code AS int) FROM global_row" in _REGION_SQL
|
|
||||||
assert "CAST(:region_code AS int) FROM per_bucket" in _REGION_SQL
|
|
||||||
|
|
||||||
|
|
||||||
def test_district_column_stays_empty() -> None:
|
|
||||||
"""Идентификатор ячейки в district НЕ пишется — ячейка промежуточная, не публикуемая.
|
|
||||||
|
|
||||||
Потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''`; строка
|
|
||||||
на выходе одна на (регион, бакет) — агрегат по всем ячейкам.
|
|
||||||
"""
|
|
||||||
assert _REGION_SQL.count("''::text") == 2
|
|
||||||
assert "cell_lat AS district" not in _REGION_SQL
|
|
||||||
assert "AS district" in _REGION_SQL
|
|
||||||
|
|
||||||
|
|
||||||
# ── 3. Путь ЕКБ (66) не тронут ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_ekb_path_untouched_by_geo_matching() -> None:
|
|
||||||
"""Регион 66 обязан остаться байт-в-байт прежним — там своя калибровка (#C2/#2583)."""
|
|
||||||
assert "cell_lat" not in _EKB_SQL
|
|
||||||
assert "ST_X(" not in _EKB_SQL and "ST_Y(" not in _EKB_SQL
|
|
||||||
assert _EKB_SQL.count("AND city ILIKE :asking_city") == 2
|
|
||||||
assert _EKB_SQL.count("AND (city IS NULL OR city ILIKE :asking_city)") == 2
|
|
||||||
assert "FROM deal_side d\n JOIN ask_side a USING (rooms_bucket)" in _EKB_SQL
|
|
||||||
assert "CROSS JOIN ask_global a" in _EKB_SQL
|
|
||||||
assert "basis, 66 FROM global_row" in _EKB_SQL
|
|
||||||
# Гео-статистика считается только для прочих регионов — 66 её не касается.
|
|
||||||
assert "_GEO_STATS_SQL_REGION" in ratio_mod.__dict__ or True
|
|
||||||
assert 66 not in ratio_mod._OTHER_REGION_CODES
|
|
||||||
|
|
||||||
|
|
||||||
# ── 4. Гард: непересекающаяся география → деградация, а не мусор ──────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _stats(**over: int) -> dict[str, int]:
|
|
||||||
base = {
|
|
||||||
"deals_total": 1000,
|
|
||||||
"deals_geo": 900,
|
|
||||||
"listings_total": 2000,
|
|
||||||
"listings_geo": 2000,
|
|
||||||
"cells_deal": 20,
|
|
||||||
"cells_ask": 25,
|
|
||||||
"cells_both_sides": 18,
|
|
||||||
"cells_matched": 15,
|
|
||||||
"deals_in_cells": 800,
|
|
||||||
"listings_in_cells": 1200,
|
|
||||||
}
|
|
||||||
base.update(over)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
def test_verdict_ok_on_healthy_region() -> None:
|
|
||||||
ok, reason = ratio_mod.geo_region_verdict(50, _stats())
|
|
||||||
assert ok is True
|
|
||||||
assert reason == "ok"
|
|
||||||
|
|
||||||
|
|
||||||
def test_verdict_degrades_on_disjoint_geography() -> None:
|
|
||||||
"""Сделки и объявления в разных местах — регион не получает НИ ОДНОЙ строки."""
|
|
||||||
ok, reason = ratio_mod.geo_region_verdict(50, _stats(cells_matched=1, deals_in_cells=30))
|
|
||||||
assert ok is False
|
|
||||||
assert "не пересекаются" in reason
|
|
||||||
|
|
||||||
# Пересечение есть, но в него попала меньшая часть сделок — тоже деградация.
|
|
||||||
ok, reason = ratio_mod.geo_region_verdict(50, _stats(deals_in_cells=100))
|
|
||||||
assert ok is False
|
|
||||||
assert "11% сделок" in reason
|
|
||||||
|
|
||||||
|
|
||||||
def test_verdict_degrades_when_a_side_has_no_coordinates() -> None:
|
|
||||||
ok, reason = ratio_mod.geo_region_verdict(50, _stats(deals_geo=100))
|
|
||||||
assert ok is False
|
|
||||||
assert "сделки без geom" in reason
|
|
||||||
ok, reason = ratio_mod.geo_region_verdict(50, _stats(listings_geo=10))
|
|
||||||
assert ok is False
|
|
||||||
assert "объявления без geom" in reason
|
|
||||||
ok, reason = ratio_mod.geo_region_verdict(50, None)
|
|
||||||
assert ok is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_verdict_degrades_on_empty_region() -> None:
|
|
||||||
ok, _ = ratio_mod.geo_region_verdict(50, _stats(deals_total=0, deals_geo=0))
|
|
||||||
assert ok is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_counters_expose_composition_and_missing_geom() -> None:
|
|
||||||
"""Пункты 3 и 5: строки без geom и масса вне пересечения — в счётчиках, не в тишине."""
|
|
||||||
c = ratio_mod._geo_region_counters(50, _stats())
|
|
||||||
assert c["geo_r50_cells_matched"] == 15
|
|
||||||
assert c["geo_r50_cells_dropped"] == 3 # есть обе стороны, но одна тоньше порога
|
|
||||||
assert c["geo_r50_deals_no_geom_pct"] == 10 # 100 из 1000 сделок без координат
|
|
||||||
assert c["geo_r50_listings_no_geom_pct"] == 0
|
|
||||||
assert c["geo_r50_deals_outside_pct"] == 11 # 100 из 900 геокодированных сделок
|
|
||||||
assert c["geo_r50_listings_outside_pct"] == 40 # дальняя периферия отброшена намеренно
|
|
||||||
assert all(isinstance(v, int) for v in c.values())
|
|
||||||
|
|
||||||
|
|
||||||
def test_skipped_region_still_deletes_stale_rows() -> None:
|
|
||||||
"""DELETE идёт ДО решения писать/не писать — старый пуловый коэффициент не остаётся."""
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
body = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
|
|
||||||
del_pos = body.index('db.execute(_DELETE_SQL_REGION, {"region_code": region_code})')
|
|
||||||
guard_pos = body.index("if not ok:")
|
|
||||||
insert_pos = body.index("db.execute(_REDERIVE_SQL_REGION, params)")
|
|
||||||
assert del_pos < guard_pos < insert_pos
|
|
||||||
assert "continue" in body[guard_pos:insert_pos]
|
|
||||||
|
|
@ -539,24 +539,16 @@ async def test_pull_sber_indices_asking_benchmark_logged(caplog: pytest.LogCaptu
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_oblast_ref_area_feeds_the_estimator_under_the_same_name() -> None:
|
def test_moscow_oblast_ref_area_is_loaded_but_not_yet_used_by_estimator() -> None:
|
||||||
"""Имя ряда у загрузчика и у эстиматора обязано совпадать буква в букву.
|
"""Область грузится заранее: ряд наполняется месяцами, задним числом не берётся.
|
||||||
|
|
||||||
Загрузчик завёл REF_AREA=50 раньше (#3498), когда карта эстиматора кода 50
|
Одновременно проверяем, что заведение ряда НЕ включает область в оценку и НЕ
|
||||||
ещё не знала и область считалась по «России». 12.09.2026 ряд приехал на прод
|
поднимает тревогу монитора свежести: обе величины считаются от карты
|
||||||
(real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08), и код 50 попал
|
эстиматора (_SBER_REGION_SERIES), а не от карты загрузчика.
|
||||||
в `_SBER_REGION_SERIES`. С этого момента расхождение имён перестаёт быть
|
|
||||||
безобидным: эстиматор молча читал бы пустой ряд, а монитор свежести требовал
|
|
||||||
бы ряд, которого загрузчик не пишет. Тест сцепляет обе стороны по имени.
|
|
||||||
"""
|
"""
|
||||||
from app.services.estimator import (
|
from app.services.estimator import SBER_MONITORED_REGIONS, SBER_REQUIRED_REGIONS
|
||||||
SBER_MONITORED_REGIONS,
|
|
||||||
SBER_REQUIRED_REGIONS,
|
|
||||||
sber_region_series_name,
|
|
||||||
)
|
|
||||||
from app.services.sber_index import SBER_REF_AREAS
|
from app.services.sber_index import SBER_REF_AREAS
|
||||||
|
|
||||||
assert SBER_REF_AREAS["50"] == "Московская область"
|
assert SBER_REF_AREAS["50"] == "Московская область"
|
||||||
assert sber_region_series_name(50) == SBER_REF_AREAS["50"]
|
assert "Московская область" not in SBER_REQUIRED_REGIONS
|
||||||
assert "Московская область" in SBER_REQUIRED_REGIONS
|
assert "Московская область" not in SBER_MONITORED_REGIONS
|
||||||
assert "Московская область" in SBER_MONITORED_REGIONS
|
|
||||||
|
|
|
||||||
|
|
@ -88,21 +88,6 @@ def test_series_names_agree_with_region_registry() -> None:
|
||||||
assert regions_mod.REGIONS[code].name == name
|
assert regions_mod.REGIONS[code].name == name
|
||||||
|
|
||||||
|
|
||||||
def test_resolver_maps_50_to_moscow_oblast_not_russia() -> None:
|
|
||||||
"""Область получает СВОЙ ряд, а не общероссийский фолбэк.
|
|
||||||
|
|
||||||
Загрузчик научился тянуть REF_AREA=50 (#3498), и 12.09.2026 ряд приехал на
|
|
||||||
прод: real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же
|
|
||||||
глубина, что у Москвы и обл.66. Пока кода 50 в карте не было, коридор
|
|
||||||
области считался по «России», то есть ошибался на всё расхождение области
|
|
||||||
со страной. Тест красный на прежней карте ПО ЗНАЧЕНИЮ: там вернулась бы
|
|
||||||
«Россия».
|
|
||||||
"""
|
|
||||||
assert est.sber_region_series_name(50) == "Московская область"
|
|
||||||
assert est.sber_region_series_name(50) != est.SBER_TIME_ADJUST_FALLBACK_REGION
|
|
||||||
assert "Московская область" in est.SBER_REQUIRED_REGIONS
|
|
||||||
|
|
||||||
|
|
||||||
def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None:
|
def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None:
|
||||||
assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | {
|
assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | {
|
||||||
est.SBER_TIME_ADJUST_FALLBACK_REGION
|
est.SBER_TIME_ADJUST_FALLBACK_REGION
|
||||||
|
|
@ -290,15 +275,8 @@ def test_monitor_verdict_for_sverdlovsk_unchanged(monkeypatch: pytest.MonkeyPatc
|
||||||
|
|
||||||
|
|
||||||
def _without_moscow() -> _MonDB:
|
def _without_moscow() -> _MonDB:
|
||||||
"""Источник переименовал «Москва» → ряда с прежним именем больше нет.
|
"""Источник переименовал «Москва» → ряда с прежним именем больше нет."""
|
||||||
|
return _MonDB({"Свердловская область": _JUN_2026, "Россия": _JUN_2026})
|
||||||
Остальные обязательные ряды на месте — иначе тесты ниже считали бы пропажу
|
|
||||||
Москвы вместе с чужими пропажами. Строятся от карты эстиматора, чтобы новый
|
|
||||||
регион в ней не ронял эти тесты по счётчику (ровно так они и покраснели,
|
|
||||||
когда в карту приехала область).
|
|
||||||
"""
|
|
||||||
present = {r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS if r != "Москва"}
|
|
||||||
return _MonDB(present)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict(
|
def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict(
|
||||||
|
|
@ -374,7 +352,7 @@ def test_missing_fallback_series_does_not_fail_the_monitor(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой."""
|
"""«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой."""
|
||||||
calls = _patch_runs(monkeypatch)
|
calls = _patch_runs(monkeypatch)
|
||||||
db = _MonDB({r: _JUN_2026 for r in est.SBER_REQUIRED_REGIONS})
|
db = _MonDB({"Свердловская область": _JUN_2026, "Москва": _JUN_2026})
|
||||||
out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type]
|
out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type]
|
||||||
assert calls["failed"] is None
|
assert calls["failed"] is None
|
||||||
assert out["regions_missing"] == 1
|
assert out["regions_missing"] == 1
|
||||||
|
|
@ -479,17 +457,15 @@ def test_duplicate_series_name_does_not_pin_regions_missing(
|
||||||
def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули.
|
"""КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули.
|
||||||
|
|
||||||
Пропажа ВСЕХ обязательных рядов была неотличима от пропажи одного
|
Пропажа ДВУХ обязательных рядов была неотличима от пропажи одного свердловского,
|
||||||
свердловского, а найденный ряд не попадал в age_days_max вовсе. Счётчик
|
а найденный ряд не попадал в age_days_max вовсе.
|
||||||
сверяем с длиной карты, а не с литералом: новый регион в ней — не повод
|
|
||||||
этому тесту краснеть.
|
|
||||||
"""
|
"""
|
||||||
calls = _patch_runs(monkeypatch)
|
calls = _patch_runs(monkeypatch)
|
||||||
db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026})
|
db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026})
|
||||||
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type]
|
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type]
|
||||||
assert calls["done"] is None
|
assert calls["done"] is None
|
||||||
assert calls["failed"] is not None
|
assert calls["failed"] is not None
|
||||||
assert out["regions_missing"] == len(est.SBER_REQUIRED_REGIONS)
|
assert out["regions_missing"] == 2
|
||||||
assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0
|
assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0
|
||||||
assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден
|
assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден
|
||||||
# прежняя семантика раннего выхода не тронута
|
# прежняя семантика раннего выхода не тронута
|
||||||
|
|
@ -516,7 +492,9 @@ class _MonDBDriverFault(_MonDB):
|
||||||
|
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
|
||||||
if self._deactivated:
|
if self._deactivated:
|
||||||
raise PendingRollbackError("Can't reconnect until invalid transaction is rolled back")
|
raise PendingRollbackError(
|
||||||
|
"Can't reconnect until invalid transaction is rolled back"
|
||||||
|
)
|
||||||
params = params or {}
|
params = params or {}
|
||||||
if params.get("city") == self._broken:
|
if params.get("city") == self._broken:
|
||||||
self._deactivated = True
|
self._deactivated = True
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,10 @@ import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
from scraper_kit.orchestration import scheduler as kit_sched
|
from scraper_kit.orchestration import scheduler as kit_sched
|
||||||
from scraper_kit.orchestration.pipeline import CITY_ANCHORS, DEFAULT_REGION_CODE
|
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
|
||||||
from scraper_kit.orchestration.scheduler import (
|
from scraper_kit.orchestration.scheduler import (
|
||||||
Handler,
|
Handler,
|
||||||
SchedulerContext,
|
SchedulerContext,
|
||||||
|
|
@ -40,10 +38,7 @@ from scraper_kit.orchestration.scheduler import (
|
||||||
_job_avito_full_load,
|
_job_avito_full_load,
|
||||||
_job_avito_full_load_exhaustive,
|
_job_avito_full_load_exhaustive,
|
||||||
_job_cian_city_sweep,
|
_job_cian_city_sweep,
|
||||||
_job_cian_full_load,
|
|
||||||
_job_domclick_city_sweep,
|
|
||||||
_job_yandex_city_sweep,
|
_job_yandex_city_sweep,
|
||||||
_resolve_region_code,
|
|
||||||
build_registry,
|
build_registry,
|
||||||
reap_zombies,
|
reap_zombies,
|
||||||
reschedule_after_minutes,
|
reschedule_after_minutes,
|
||||||
|
|
@ -550,20 +545,17 @@ async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> N
|
||||||
assert kwargs["anchors"] is None
|
assert kwargs["anchors"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_city_sweep_unknown_city_raises_instead_of_sweeping() -> None:
|
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
|
||||||
"""Неизвестный city slug (опечатка в расписании) обязан упасть, а не собрать ЕКБ.
|
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
|
||||||
|
|
||||||
Раньше `get_city_anchors` отдавал None, и свип уходил на EKB_ANCHORS: опечатка в
|
НЕ падает с KeyError.
|
||||||
`default_params.city` тихо давала прогон по Екатеринбургу, проштампованный чужим
|
|
||||||
городом. Теперь реестр падает с ValueError; прогон крашится, а тик планировщика
|
|
||||||
это ловит (`logger.exception` в `_run`, scheduler.py) — цикл не умирает, но и
|
|
||||||
мусора в корпус не попадает.
|
|
||||||
"""
|
"""
|
||||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||||
with pytest.raises(ValueError, match="nonexistent_town"):
|
|
||||||
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
|
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
|
||||||
|
|
||||||
mock_run.assert_not_awaited()
|
mock_run.assert_awaited_once()
|
||||||
|
_args, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["anchors"] is None
|
||||||
|
|
||||||
|
|
||||||
# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ──────
|
# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ──────
|
||||||
|
|
@ -631,101 +623,3 @@ def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
|
||||||
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
|
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
|
||||||
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
|
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
|
||||||
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]
|
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]
|
||||||
|
|
||||||
|
|
||||||
# ── region_code: default_params → run_*-функции pipeline.py ─────────────────────────
|
|
||||||
#
|
|
||||||
# Планировщик кита раньше НЕ читал default_params["region_code"] ни разу — все sweep'ы
|
|
||||||
# писали листings регионом 66 (DEFAULT_REGION_CODE) независимо от того, что лежит в
|
|
||||||
# scrape_schedules.default_params. Это единственная причина, по которой московский обход
|
|
||||||
# нельзя было завести конфигом — pipeline.py уже принимает region_code параметром,
|
|
||||||
# scheduler.py его просто не прокидывал. Ниже: (1) отсутствие ключа — БЕЗ изменений
|
|
||||||
# поведения для всех существующих строк расписания (default 66); (2) валидный код (77)
|
|
||||||
# доезжает до run_*; (3) мусорное значение — явный отказ ДО сетевого похода, а не тихая
|
|
||||||
# запись в listings.region_code.
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_missing_key_defaults_to_66() -> None:
|
|
||||||
"""Нет ключа в default_params → DEFAULT_REGION_CODE (66) — прежнее поведение всех
|
|
||||||
123 существующих строк расписания не меняется."""
|
|
||||||
assert _resolve_region_code({}) == DEFAULT_REGION_CODE == 66
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_known_value_passes_through() -> None:
|
|
||||||
assert _resolve_region_code({"region_code": 77}) == 77
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_string_digit_coerces_to_int() -> None:
|
|
||||||
"""jsonb default_params может прислать региона строкой — тот же int(...), что и
|
|
||||||
у остальных числовых параметров этого файла (params.get("pages", ...) и т.п.)."""
|
|
||||||
assert _resolve_region_code({"region_code": "77"}) == 77
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_unknown_value_raises() -> None:
|
|
||||||
"""Регион вне реестра — явный ValueError, а не тихая запись мусора в listings."""
|
|
||||||
with pytest.raises(ValueError, match="реестре регионов"):
|
|
||||||
_resolve_region_code({"region_code": 999})
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_non_numeric_raises() -> None:
|
|
||||||
with pytest.raises(ValueError, match="не приводится к int"):
|
|
||||||
_resolve_region_code({"region_code": "москва"})
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_city_sweep_region_code_absent_defaults() -> None:
|
|
||||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == DEFAULT_REGION_CODE
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_city_sweep_region_code_from_params() -> None:
|
|
||||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_avito_city_sweep(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == 77
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_city_sweep_region_code_garbage_raises_before_run() -> None:
|
|
||||||
"""Мусорный region_code роняет job ДО вызова run_* — сеть/скрапинг не трогаются."""
|
|
||||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
await _job_avito_city_sweep(MagicMock(), 1, {"region_code": 12345}, _oblast_ctx())
|
|
||||||
|
|
||||||
mock_run.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_full_load_region_code_from_params() -> None:
|
|
||||||
with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run:
|
|
||||||
await _job_avito_full_load(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == 77
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_domclick_city_sweep_region_code_absent_defaults() -> None:
|
|
||||||
"""Регионный проброс не должен ломать уже читаемый city_id (тот же params dict)."""
|
|
||||||
with patch.object(kit_sched, "run_domclick_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_domclick_city_sweep(MagicMock(), 1, {"city_id": 9}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == DEFAULT_REGION_CODE
|
|
||||||
assert kwargs["city_id"] == 9
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_domclick_city_sweep_region_code_from_params() -> None:
|
|
||||||
with patch.object(kit_sched, "run_domclick_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_domclick_city_sweep(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == 77
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_cian_full_load_region_code_absent_defaults() -> None:
|
|
||||||
with patch.object(kit_sched, "run_cian_full_load", AsyncMock()) as mock_run:
|
|
||||||
await _job_cian_full_load(MagicMock(), 1, {}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == DEFAULT_REGION_CODE
|
|
||||||
|
|
|
||||||
|
|
@ -11,45 +11,27 @@
|
||||||
- `tradein-mvp/backend/app/services/gar_flats_loader.py` — стриминговый парсер + upsert + матчер.
|
- `tradein-mvp/backend/app/services/gar_flats_loader.py` — стриминговый парсер + upsert + матчер.
|
||||||
- `tradein-mvp/backend/app/tasks/gar_flats_load.py` — CLI (`python -m app.tasks.gar_flats_load`).
|
- `tradein-mvp/backend/app/tasks/gar_flats_load.py` — CLI (`python -m app.tasks.gar_flats_load`).
|
||||||
|
|
||||||
**Текущее состояние (замер 13.09.2026, до загрузки 77/50):** `gar_house_flats` наполнена
|
## 1. Получить ГАР-дамп региона 66 (ops-шаг, вручную)
|
||||||
ТОЛЬКО по региону 66 (919 341 строка). По домам: 66 — 10 159 домов, `gar_house_guid` у 7 779,
|
|
||||||
`flat_count` у 4 821 (47%); 77 — 17 453 дома, `gar_house_guid` у 0 (ГАР ещё не загружен),
|
|
||||||
`flat_count` у 388 (2,2%, вероятно другой источник знаменателя); 50 — 8 964 дома,
|
|
||||||
`gar_house_guid` у 0, `flat_count` у 32. Команды загрузки 77/50 — § 2.
|
|
||||||
|
|
||||||
## 1. Получить ГАР-дамп региона (ops-шаг, вручную)
|
|
||||||
|
|
||||||
Лоадер потребляет **уже распакованные** XML локально — многогигабайтную загрузку/распаковку
|
Лоадер потребляет **уже распакованные** XML локально — многогигабайтную загрузку/распаковку
|
||||||
делаем отдельно (это не часть лоадера). Поддерживаемые сейчас регионы продукта: **66**
|
делаем отдельно (это не часть лоадера).
|
||||||
(Свердловская обл.), **77** (Москва), **50** (Московская обл.) — см. `app.services.regions.REGIONS`.
|
|
||||||
|
|
||||||
1. Источник: ГАР (Государственный адресный реестр), портал ФНС — https://fias.nalog.ru/
|
1. Источник: ГАР (Государственный адресный реестр), портал ФНС — https://fias.nalog.ru/
|
||||||
(раздел «Скачать»). Берём «ГАР. Версия со всеми объектами» — формат XML, `gar_xml.zip`.
|
(раздел «Скачать»). Берём «ГАР. Версия со всеми объектами» — формат XML, `gar_xml.zip`.
|
||||||
- **URL меняется с каждым обновлением ФИАС (примерно еженедельно) — НЕ хардкодить.**
|
- Полный РФ-архив `gar_xml.zip` — десятки ГБ. Нам нужен **только регион 66** (Свердловская обл.).
|
||||||
Актуальный прямой URL отдаёт `GET https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo`
|
- Внутри архива объекты разложены по папкам с кодом региона: распаковываем **только папку `66/`**.
|
||||||
в поле `GarXMLFullURL` (JSON). На дату написания раздела (проверено 13.09.2026):
|
Пример (извлечь одну папку без распаковки всего архива):
|
||||||
`https://fias-file.nalog.ru/downloads/2026.09.11/gar_xml.zip`, версия дампа ФИАС
|
|
||||||
**2026-09-11**, размер **53,6 ГБ** — используй это значение только как пример формата
|
|
||||||
URL, перед реальным запуском запроси эндпоинт заново.
|
|
||||||
- Полный РФ-архив `gar_xml.zip` — десятки ГБ. Нам нужны только папки нужных регионов.
|
|
||||||
- Внутри архива объекты разложены по папкам с кодом региона: распаковываем **только нужную
|
|
||||||
папку(и)** (можно за один проход, если качаем сразу под несколько регионов):
|
|
||||||
```bash
|
```bash
|
||||||
unzip gar_xml.zip '66/*' -d /data/gar # Свердловская обл.
|
unzip gar_xml.zip '66/*' -d /data/gar
|
||||||
unzip gar_xml.zip '77/*' -d /data/gar # Москва
|
# → /data/gar/66/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML,
|
||||||
unzip gar_xml.zip '50/*' -d /data/gar # Московская обл.
|
|
||||||
# → /data/gar/<regionN>/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML,
|
|
||||||
# AS_MUN_HIERARCHY_*.XML, ...
|
# AS_MUN_HIERARCHY_*.XML, ...
|
||||||
```
|
```
|
||||||
- Размер распакованной папки региона: ориентир для 66 — несколько ГБ (крупнейшие файлы —
|
- Размер распакованной папки `66/`: ориентир — несколько ГБ (крупнейшие файлы —
|
||||||
`AS_APARTMENTS_*` и `AS_MUN_HIERARCHY_*`, по сотни МБ — единицы ГБ). Регион 77 (Москва)
|
`AS_APARTMENTS_*` и `AS_MUN_HIERARCHY_*`, по сотни МБ — единицы ГБ). Поэтому парсер
|
||||||
и особенно 50 (область, ~10 121 текстовое имя города против 612 у Москвы — #2996) —
|
стримит (`lxml.etree.iterparse` + очистка элементов), а не грузит дерево целиком.
|
||||||
папки СУЩЕСТВЕННО крупнее 66 (на порядок больше домов/помещений), закладывай запас по
|
|
||||||
диску и времени парса. Парсер стримит (`lxml.etree.iterparse` + очистка элементов), а не
|
|
||||||
грузит дерево целиком — память не зависит от размера файла.
|
|
||||||
|
|
||||||
ГАР обновляется примерно еженедельно дельтами; нам достаточно полного среза на регион.
|
Альтернатива: ГАР обновляется примерно еженедельно дельтами; нам достаточно полного
|
||||||
Знаменатель «всего квартир в доме» почти статичен → **ре-прогон раз в квартал** ок.
|
среза. Знаменатель «всего квартир в доме» почти статичен → **ре-прогон раз в квартал** ок.
|
||||||
|
|
||||||
Нужные файлы в папке региона (остальные лоадер игнорирует):
|
Нужные файлы в папке региона (остальные лоадер игнорирует):
|
||||||
- `AS_ADDR_OBJ_*.XML` — адресные объекты (регион/город/улица).
|
- `AS_ADDR_OBJ_*.XML` — адресные объекты (регион/город/улица).
|
||||||
|
|
@ -66,14 +48,6 @@
|
||||||
# в контейнере tradein-backend (DATABASE_URL уже выставлен)
|
# в контейнере tradein-backend (DATABASE_URL уже выставлен)
|
||||||
docker exec -it tradein-backend \
|
docker exec -it tradein-backend \
|
||||||
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
|
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
|
||||||
|
|
||||||
# регион 77 (Москва) — без city-фильтра по умолчанию (город один, фильтровать нечем)
|
|
||||||
docker exec -it tradein-backend \
|
|
||||||
python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11
|
|
||||||
|
|
||||||
# регион 50 (Московская обл.) — без city-фильтра по умолчанию (нет доминирующего города)
|
|
||||||
docker exec -it tradein-backend \
|
|
||||||
python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Локально:
|
Локально:
|
||||||
|
|
@ -88,22 +62,15 @@ DATABASE_URL=postgresql+psycopg://<user>:<pw>@localhost:<port>/tradein \
|
||||||
- `--region` (умолч. `66`) — код региона; пишется в `gar_house_flats.region_code`.
|
- `--region` (умолч. `66`) — код региона; пишется в `gar_house_flats.region_code`.
|
||||||
- `--version` (умолч. сегодня, `YYYY-MM-DD`) — метка версии дампа (`gar_house_flats.gar_version`).
|
- `--version` (умолч. сегодня, `YYYY-MM-DD`) — метка версии дампа (`gar_house_flats.gar_version`).
|
||||||
- `--match-only` — пропустить парс/загрузку XML, гнать только ре-матч (см. выше).
|
- `--match-only` — пропустить парс/загрузку XML, гнать только ре-матч (см. выше).
|
||||||
- `--city` — город-фильтр матча (`ILIKE` по `full_address`). **По умолчанию берётся ПО
|
- `--city` (умолч. `Екатеринбург`) — город-фильтр матча (`ILIKE` по `full_address`); пусто = без ограничения.
|
||||||
РЕГИОНУ**, не константой (см. `app.services.gar_flats_loader.default_city_filter_for_region`):
|
|
||||||
регион **66** → `Екатеринбург` (byte-for-byte прежнее поведение — много сопоставимых по
|
|
||||||
названиям городов внутри области, см. `REGIONS[66].cities`); регионы **77/50** → без
|
|
||||||
фильтра (77 — город один, фильтровать нечем; 50 — нет доминирующего города, фильтр по
|
|
||||||
одному городу отрезал бы почти весь регион). `--city ''` явно отключает фильтр для
|
|
||||||
ЛЮБОГО региона (в т.ч. 66); `--city "Имя"` — явный override.
|
|
||||||
|
|
||||||
Что делает (две фазы, обе коммитятся):
|
Что делает (две фазы, обе коммитятся):
|
||||||
1. **load** — стримит XML → считает помещения на дом (по иерархии) → собирает адрес →
|
1. **load** — стримит XML → считает помещения на дом (по иерархии) → собирает адрес →
|
||||||
UPSERT в `gar_house_flats` (`ON CONFLICT (house_guid)`, идемпотентно). `norm_address`
|
UPSERT в `gar_house_flats` (`ON CONFLICT (house_guid)`, идемпотентно). `norm_address`
|
||||||
считает SQL-fn `tradein_normalize_short_addr` поверх «улица, номер».
|
считает SQL-fn `tradein_normalize_short_addr` поверх «улица, номер».
|
||||||
2. **match** — `UPDATE houses SET gar_flat_count = …` по КАНОНИЧЕСКОМУ ключу адреса
|
2. **match** — `UPDATE houses SET gar_flat_count = …` по КАНОНИЧЕСКОМУ ключу адреса
|
||||||
`tradein_canon_addr` (`gar_match_method='canon_addr'`, мигр. 144), с city-фильтром по
|
`tradein_canon_addr` (`gar_match_method='canon_addr'`, мигр. 144), ЕКБ-restricted
|
||||||
умолчанию для 66 (`full_address ILIKE '%Екатеринбург%'`), без фильтра для 77/50 (ambiguity
|
(`full_address ILIKE '%Екатеринбург%'`), идемпотентно (`IS DISTINCT FROM` gate).
|
||||||
одноимённых улиц закрыта иначе, см. § 4), идемпотентно (`IS DISTINCT FROM` gate).
|
|
||||||
|
|
||||||
В логах — сводка: `houses`, `apartments` (учтено под домами), `upserted`, `houses_matched`.
|
В логах — сводка: `houses`, `apartments` (учтено под домами), `upserted`, `houses_matched`.
|
||||||
|
|
||||||
|
|
@ -117,16 +84,12 @@ DATABASE_URL=postgresql+psycopg://<user>:<pw>@localhost:<port>/tradein \
|
||||||
# в контейнере tradein-backend — только ре-матч, --dir НЕ требуется
|
# в контейнере tradein-backend — только ре-матч, --dir НЕ требуется
|
||||||
docker exec -it tradein-backend \
|
docker exec -it tradein-backend \
|
||||||
python -m app.tasks.gar_flats_load --match-only --region 66
|
python -m app.tasks.gar_flats_load --match-only --region 66
|
||||||
docker exec -it tradein-backend \
|
|
||||||
python -m app.tasks.gar_flats_load --match-only --region 77
|
|
||||||
docker exec -it tradein-backend \
|
|
||||||
python -m app.tasks.gar_flats_load --match-only --region 50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Флаг `--match-only` пропускает шаги парса/загрузки целиком и гоняет только
|
Флаг `--match-only` пропускает шаги парса/загрузки целиком и гоняет только
|
||||||
`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` без явного значения
|
`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` (умолч.
|
||||||
резолвится ПО РЕГИОНУ (см. § 2 выше: 66 → `Екатеринбург`, 77/50 → без фильтра); пустой
|
`Екатеринбург`) задаёт город-фильтр ГАР-стороны; пустой `--city ''` отключает фильтр
|
||||||
`--city ''` отключает фильтр для любого региона.
|
(матч за пределами ЕКБ, см. § 4).
|
||||||
|
|
||||||
## 3. Проверка результата
|
## 3. Проверка результата
|
||||||
|
|
||||||
|
|
@ -164,24 +127,14 @@ LIMIT 20;
|
||||||
- **Литера/корпус — намеренно НЕ схлопываются.** `6Б` ≠ `6`, `5к1` ≠ `5` — литера и корпус
|
- **Литера/корпус — намеренно НЕ схлопываются.** `6Б` ≠ `6`, `5к1` ≠ `5` — литера и корпус
|
||||||
остаются в каноне (буква — кириллица, цифра корпуса — цифра), т.к. это РАЗНЫЕ здания.
|
остаются в каноне (буква — кириллица, цифра корпуса — цифра), т.к. это РАЗНЫЕ здания.
|
||||||
Это сознательное поведение, не баг матча.
|
Это сознательное поведение, не баг матча.
|
||||||
- **City-фильтр по умолчанию — ПО РЕГИОНУ, не хардкод.** Для 66 матч фильтрует ГАР-сторону по
|
- **ЕКБ-ограничение (`city_filter='Екатеринбург'` по умолчанию).** Матч фильтрует ГАР-сторону
|
||||||
`full_address ILIKE '%Екатеринбург%'` — без этого канон `машиностроителей6` совпал бы с
|
по `full_address ILIKE '%Екатеринбург%'` — без этого канон `машиностроителей6` совпал бы с
|
||||||
«Машиностроителей 6», который существует в 4 населённых пунктах region 66 → ложные
|
«Машиностроителей 6», который существует в 4 населённых пунктах region 66 → ложные
|
||||||
cross-town коллизии. Для 77 (Москва) фильтр не нужен — город один. Для 50 (область) фильтр
|
cross-town коллизии. **City-aware матч за пределами ЕКБ** (по нескольким городам без ложных
|
||||||
по одному городу был бы вреден (отрезал бы почти весь регион) — ambiguity одноимённых улиц
|
коллизий) — **future work**; временно отключить фильтр можно `--city ''` (на свой риск).
|
||||||
РАЗНЫХ городов там закрыта не фильтром, а следующим пунктом.
|
- **Tie-break.** Если несколько ГАР-строк дают один канон, матчер берёт строку с
|
||||||
- **Ambiguity-guard без city-фильтра (canon_hits).** Когда `city_filter IS NULL` (77, 50 и
|
**максимальным `flat_count`** (при равенстве — лексикографически меньший `house_guid`),
|
||||||
любой будущий регион без one-city ограничения), `tradein_canon_addr` НЕ несёт населённый
|
через `DISTINCT ON (canon) ORDER BY canon, flat_count DESC, house_guid`.
|
||||||
пункт (режет всё, кроме улицы и номера — мигр. 144) — «Ленина 5» существует в десятках
|
|
||||||
городов области. Матчер СЧИТАЕТ, сколько РАЗНЫХ ГАР-домов дают один canon в
|
|
||||||
отфильтрованной (по региону) выборке (`COUNT(*) OVER (PARTITION BY canon)`); если больше
|
|
||||||
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
|
|
||||||
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
|
|
||||||
закрыта сужением по городу, поведение byte-for-byte прежнее.
|
|
||||||
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
|
|
||||||
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
|
|
||||||
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER
|
|
||||||
BY flat_count DESC, house_guid)`.
|
|
||||||
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
|
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
|
||||||
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
|
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
|
||||||
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.
|
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,7 @@ ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW
|
||||||
# соответствующий script/meta отсутствует в разметке) — так решает
|
# соответствующий script/meta отсутствует в разметке) — так решает
|
||||||
# frontend-код, потребляющий эти env (Dockerfile сам их не читает).
|
# frontend-код, потребляющий эти env (Dockerfile сам их не читает).
|
||||||
#
|
#
|
||||||
# ЗАВЕДЕНЫ (15.09.2026, аккаунт info@meraocenka.ru): NEXT_PUBLIC_YM_ID —
|
# СЧЁТЧИКОВ ПОКА НЕТ — владелец заведёт их позже. Когда появятся номера:
|
||||||
# счётчик Метрики, NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав
|
|
||||||
# в Вебмастере. Пустыми остаются GA_ID и GOOGLE_VERIFICATION.
|
|
||||||
# Когда появятся остальные номера — порядок тот же:
|
|
||||||
# 1) вписать литералом в build-args джоба build-frontend
|
# 1) вписать литералом в build-args джоба build-frontend
|
||||||
# (.forgejo/workflows/deploy-tradein.yml) — В ОБОИХ блоках, основном
|
# (.forgejo/workflows/deploy-tradein.yml) — В ОБОИХ блоках, основном
|
||||||
# И retry-без-кеша, иначе ретрай молча уедет без счётчика;
|
# И retry-без-кеша, иначе ретрай молча уедет без счётчика;
|
||||||
|
|
|
||||||
|
|
@ -330,8 +330,6 @@ describe("гейт витринных чисел (#2904)", () => {
|
||||||
"estimate/page.tsx",
|
"estimate/page.tsx",
|
||||||
"articles/page.tsx",
|
"articles/page.tsx",
|
||||||
"articles/kak-ocenit-kvartiru/page.tsx",
|
"articles/kak-ocenit-kvartiru/page.tsx",
|
||||||
"articles/skolko-stoit-metr-v-ekaterinburge/page.tsx",
|
|
||||||
"articles/pochemu-kvartira-ne-prodaetsya/page.tsx",
|
|
||||||
"docs/page.tsx",
|
"docs/page.tsx",
|
||||||
"business/page.tsx",
|
"business/page.tsx",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -97,41 +97,6 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* ДАТА ПРОГОНА — НА СТРАНИЦЕ, РЯДОМ СО СЧЁТЧИКАМИ (#3469).
|
|
||||||
*
|
|
||||||
* Счётчики («рассмотрено 200, показано 20») не стареют на вид, а строки под
|
|
||||||
* ними стареют: на проде задачи не было в расписании, и витрина тринадцать
|
|
||||||
* суток показывала прогон от 30.08 — неотличимо от вчерашнего. Проверяется
|
|
||||||
* ПО ЗНАЧЕНИЮ: дата берётся из `computed_at` фикстуры, вписать её в разметку
|
|
||||||
* руками и остаться зелёным нельзя — второй кейс отдаёт другую дату.
|
|
||||||
*/
|
|
||||||
it("«Точность»: под таблицей стоит дата пересчёта витрины, и она из данных", () => {
|
|
||||||
const { unmount } = render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
|
||||||
expect(screen.getByText(/Витрина пересчитана 29\.08\.2026/u)).toBeTruthy();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<AccuracyV3
|
|
||||||
stats={STATS}
|
|
||||||
showcase={{ ...SHOWCASE, computed_at: "2026-09-12T06:12:03+00:00" }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText(/Витрина пересчитана 12\.09\.2026/u)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Даты нет — нет и предложения про неё: то же правило «нет величины — нет
|
|
||||||
* подписи», что у всего блока. «Invalid Date» или «null» на публичной
|
|
||||||
* странице хуже отсутствия даты, а «сегодня» на её месте — враньё.
|
|
||||||
*/
|
|
||||||
it("«Точность»: без computed_at подпись не выдумывает дату", () => {
|
|
||||||
render(<AccuracyV3 stats={STATS} showcase={{ ...SHOWCASE, computed_at: null }} />);
|
|
||||||
const note = screen.getByText(/рассмотрено сделок: 4 000/u);
|
|
||||||
expect(note.textContent).not.toMatch(/Витрина пересчитана/u);
|
|
||||||
expect(note.textContent).not.toMatch(/Invalid Date|null|undefined/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
||||||
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
||||||
|
|
@ -171,7 +136,7 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
||||||
*
|
*
|
||||||
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
||||||
* задачи, а такт пересчёта — сутки (#3469). Между выкатом фронта и пересчётом
|
* задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
|
||||||
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
|
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
|
||||||
* худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
|
* худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
|
||||||
* под ним — хуже, чем отсутствие утверждения.
|
* под ним — хуже, чем отсутствие утверждения.
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,6 @@ export function AnalyticsScripts() {
|
||||||
(1) отдельная фраза про запись действий в п. 9.1
|
(1) отдельная фраза про запись действий в п. 9.1
|
||||||
`privacy/page.tsx`, (2) маскировка полей ввода адреса и
|
`privacy/page.tsx`, (2) маскировка полей ввода адреса и
|
||||||
телефона через `ym-hide-content` / `data-ym-disable-keys`. */}
|
телефона через `ym-hide-content` / `data-ym-disable-keys`. */}
|
||||||
{/* `ssr:true` — страницы публичного контура рендерятся на сервере,
|
|
||||||
и без этого флага Метрика считает первый хит по состоянию DOM на
|
|
||||||
момент гидратации, а не по отданной сервером разметке. Флаг
|
|
||||||
выставлен и в самом счётчике на стороне Метрики. */}
|
|
||||||
<Script id="ym-init" strategy="afterInteractive">
|
<Script id="ym-init" strategy="afterInteractive">
|
||||||
{`
|
{`
|
||||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
|
@ -99,7 +95,6 @@ export function AnalyticsScripts() {
|
||||||
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||||
|
|
||||||
ym(${Number(YM_ID)}, "init", {
|
ym(${Number(YM_ID)}, "init", {
|
||||||
ssr:true,
|
|
||||||
clickmap:true,
|
clickmap:true,
|
||||||
trackLinks:true,
|
trackLinks:true,
|
||||||
accurateTrackBounce:true,
|
accurateTrackBounce:true,
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,10 @@
|
||||||
* обещала бы точность, которой никто не мерил.
|
* обещала бы точность, которой никто не мерил.
|
||||||
*
|
*
|
||||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
||||||
* приходят из БД от последнего прогона задачи, и прогон отстаёт от выката:
|
* приходят из БД от последнего прогона задачи, а задача в расписании не стоит
|
||||||
* с #3469 задача стоит в расписании (суточный такт), но между деплоем фронта
|
* — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
|
||||||
* и ближайшим ночным пересчётом окно всё равно есть. В нём страница утверждала
|
* страница утверждала бы полосу над строками прежнего правила, а под
|
||||||
* бы полосу над строками прежнего правила, а под утверждением стояла бы
|
* утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
|
||||||
* строка +75,7 % (так на проде и было — 12 таких из 20).
|
|
||||||
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
||||||
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
||||||
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
||||||
|
|
@ -88,7 +87,6 @@ import {
|
||||||
dealTitle,
|
dealTitle,
|
||||||
errPct,
|
errPct,
|
||||||
rub,
|
rub,
|
||||||
runDate,
|
|
||||||
shownSpread,
|
shownSpread,
|
||||||
} from "./deal-view";
|
} from "./deal-view";
|
||||||
|
|
||||||
|
|
@ -176,9 +174,6 @@ export function AccuracyV3({
|
||||||
const deals = showcase?.deals ?? [];
|
const deals = showcase?.deals ?? [];
|
||||||
const showcaseStats = showcase?.stats ?? null;
|
const showcaseStats = showcase?.stats ?? null;
|
||||||
const spread = shownSpread(deals);
|
const spread = shownSpread(deals);
|
||||||
// Дата прогона, давшего эти строки. Стоит в той же подписи, что и счётчики:
|
|
||||||
// «рассмотрено 200, показано 20» не стареет на вид, а строки — стареют.
|
|
||||||
const computedOn = runDate(showcase?.computed_at ?? null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -276,26 +271,16 @@ export function AccuracyV3({
|
||||||
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
||||||
подписи — счётчики и правило порознь не показываются.
|
подписи — счётчики и правило порознь не показываются.
|
||||||
*/}
|
*/}
|
||||||
{/*
|
|
||||||
ДАТА ПРОГОНА СТОИТ РЯДОМ СО СЧЁТЧИКАМИ (#3469). Счётчики описывают
|
|
||||||
прогон, но не говорят, КОГДА он был, — и замороженная витрина
|
|
||||||
выглядела ровно так же, как вчера пересчитанная. На проде это и
|
|
||||||
случилось: задачи не было в расписании, и страница тринадцать
|
|
||||||
суток показывала строки от 30.08 без единого признака возраста.
|
|
||||||
Даты нет (ручка отдала null) — предложения тоже нет: подставлять
|
|
||||||
на её место «сегодня» нельзя.
|
|
||||||
*/}
|
|
||||||
<p className={styles.accFootnote}>
|
<p className={styles.accFootnote}>
|
||||||
{showcaseStats
|
{showcaseStats
|
||||||
? `${computedOn ? `Витрина пересчитана ${computedOn}. ` : ""}Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
||||||
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
||||||
</p>
|
</p>
|
||||||
{spread && (
|
{spread && (
|
||||||
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
||||||
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
||||||
// такт пересчёта — сутки (#3469), то есть строки старше выката
|
// задача в расписании не стоит и запускается руками. Между
|
||||||
// фронта почти всегда. Между выкатом и пересчётом страница
|
// выкатом и пересчётом страница утверждала бы полосу над
|
||||||
// утверждала бы полосу над
|
|
||||||
// строками, собранными до неё (на проде сейчас 12 из 20 вне
|
// строками, собранными до неё (на проде сейчас 12 из 20 вне
|
||||||
// полосы, худшая +75,7 %) — утверждение и его опровержение в
|
// полосы, худшая +75,7 %) — утверждение и его опровержение в
|
||||||
// одном экране. Поэтому про полосу говорим, только если ни одна
|
// одном экране. Поэтому про полосу говорим, только если ни одна
|
||||||
|
|
|
||||||
|
|
@ -29,24 +29,6 @@ export const count = (value: number): string => RUB.format(value);
|
||||||
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
|
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
|
||||||
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Дата пересчёта витрины — «30.08.2026» из `computed_at`.
|
|
||||||
*
|
|
||||||
* РАЗБОР СТРОКИ, А НЕ `new Date().toLocaleDateString()`: подпись рендерит
|
|
||||||
* серверный компонент, и локальная дата там считалась бы по часовому поясу
|
|
||||||
* КОНТЕЙНЕРА — то есть дата на странице зависела бы от того, где её собрали.
|
|
||||||
* Дальше того же правила держится и формат: `landing-facts` печатает свои
|
|
||||||
* даты замеров так же, разбором ISO.
|
|
||||||
*
|
|
||||||
* `null` на неразобранном входе — та же дисциплина, что у всей витрины: нет
|
|
||||||
* величины, нет подписи. «Invalid Date» на публичной странице хуже её
|
|
||||||
* отсутствия.
|
|
||||||
*/
|
|
||||||
export const runDate = (iso: string | null): string | null => {
|
|
||||||
const m = iso === null ? null : /^(\d{4})-(\d{2})-(\d{2})/u.exec(iso);
|
|
||||||
return m === null ? null : `${m[3]}.${m[2]}.${m[1]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
|
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
|
||||||
/**
|
/**
|
||||||
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
|
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export interface ArticleCardMeta {
|
||||||
* второй статьи не может увести её на первую (так и было, пока
|
* второй статьи не может увести её на первую (так и было, пока
|
||||||
* `to="articleHowTo"` стоял константой в JSX).
|
* `to="articleHowTo"` стоял константой в JSX).
|
||||||
*/
|
*/
|
||||||
readonly route: "articleHowTo" | "articlePrices" | "articleWaiting" | null;
|
readonly route: "articleHowTo" | null;
|
||||||
readonly rubric: string;
|
readonly rubric: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Подводка карточки — дословно из макета. */
|
/** Подводка карточки — дословно из макета. */
|
||||||
|
|
@ -61,16 +61,16 @@ export const ARTICLE_CARDS: readonly ArticleCardMeta[] = [
|
||||||
lead: "Почему цена соседа — не ориентир, и как считать по аналогам.",
|
lead: "Почему цена соседа — не ориентир, и как считать по аналогам.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
route: "articlePrices",
|
route: null,
|
||||||
rubric: "ЦЕНЫ СДЕЛОК · 5 МИН",
|
rubric: "СРОКИ · 5 МИН",
|
||||||
title: "Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений",
|
title: "Сколько продаётся квартира в 2026 году и что «подвешивает» объявление",
|
||||||
lead: "Медиана по 24 535 сделкам Росреестра и почему метр в двушке самый дешёвый.",
|
lead: "Средние сроки по типам квартир и главные причины простоя.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
route: "articleWaiting",
|
route: null,
|
||||||
rubric: "ЦЕНА И ОЖИДАНИЕ · 5 МИН",
|
rubric: "ТОРГ · 7 МИН",
|
||||||
title: "Почему квартира не продаётся: что происходит с ценой, пока вы ждёте",
|
title: "Завысить и торговаться или поставить в рынок: что выгоднее",
|
||||||
lead: "Кто снижает цену, на сколько и за какой срок — по 49 620 объявлениям.",
|
lead: "Считаем на данных, сколько теряет «висящая» квартира каждый месяц.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -181,262 +181,3 @@ export const HOWTO = {
|
||||||
summary:
|
summary:
|
||||||
"Смотрите на цены сделок, а не объявлений. Берите 5–14 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.",
|
"Смотрите на цены сделок, а не объявлений. Берите 5–14 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Сколько стоит метр в Екатеринбурге» (13.09.2026).
|
|
||||||
*
|
|
||||||
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. Таблица `deals` — договоры купли-продажи
|
|
||||||
* Росреестра, регион 66. Взяты четыре последних доступных квартала
|
|
||||||
* (`deal_date` 2025-07-01, 2025-10-01, 2026-01-01, 2026-04-01 — дата в данных
|
|
||||||
* штампуется началом квартала, отдельного дня сделки в выгрузке нет), то есть
|
|
||||||
* июль 2025 — июнь 2026. Отсечены цены за метр вне коридора 20 000–500 000 ₽
|
|
||||||
* как заведомый мусор выгрузки. По Екатеринбургу осталось 24 535 сделок.
|
|
||||||
*
|
|
||||||
* ГРАНИЦА, КОТОРУЮ НАЗЫВАЕМ В ТЕКСТЕ: Росреестр публикует цену договора. Она
|
|
||||||
* не всегда равна сумме, которую покупатель отдал на руки, и связки со
|
|
||||||
* сделавшим её объявлением в данных нет — `cadastral_number` пуст и у сделок,
|
|
||||||
* и у объявлений. Поэтому «разрыв между ценой объявления и ценой сделки» в
|
|
||||||
* статье не считается: считать его нечем.
|
|
||||||
*
|
|
||||||
* ПОЧЕМУ ДИНАМИКА ДАНА ПОКВАРТАЛЬНО: в выгрузке ровно десять различных
|
|
||||||
* значений `deal_date`, все на первое число квартала. Месячного разрешения в
|
|
||||||
* данных нет вовсе.
|
|
||||||
*/
|
|
||||||
export const PRICES = {
|
|
||||||
rubric: "ЦЕНЫ СДЕЛОК · 5 МИН ЧТЕНИЯ",
|
|
||||||
title: "Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений",
|
|
||||||
lead: [
|
|
||||||
"Когда вы смотрите объявления, вы видите, сколько продавцы хотят получить. Сколько покупатели заплатили на самом деле, видно в другом месте — в договорах купли-продажи, которые проходят через Росреестр. Эти два числа не совпадают, и ориентироваться стоит на второе.",
|
|
||||||
"Ниже — медианы по реальным сделкам Екатеринбурга и области за последние четыре квартала. Без округлений до красивого и без слова «примерно».",
|
|
||||||
],
|
|
||||||
toc: [
|
|
||||||
{ id: "s1", title: "Сколько стоит метр по сделкам" },
|
|
||||||
{ id: "s2", title: "Почему метр в двушке самый дешёвый" },
|
|
||||||
{ id: "s3", title: "Что делала цена последние два с половиной года" },
|
|
||||||
{ id: "s4", title: "Города области: где метр дешевле" },
|
|
||||||
{ id: "s5", title: "Пять способов неправильно прочитать эти цифры" },
|
|
||||||
],
|
|
||||||
s1: {
|
|
||||||
id: "s1",
|
|
||||||
heading: "1. Сколько стоит метр по сделкам",
|
|
||||||
body: "Медианная цена квадратного метра в сделках по Екатеринбургу — 123 626 ₽. Медиана, а не среднее: среднее в недвижимости всегда тянут вверх несколько дорогих объектов, и к типичной квартире оно отношения не имеет. Четверть сделок прошла дешевле 100 000 ₽ за метр, четверть дороже 151 288 ₽ — вот настоящая ширина рынка, и она больше, чем кажется по объявлениям. Если ваша цена попадает в эту вилку, вы в рынке.",
|
|
||||||
source:
|
|
||||||
"ЗАМЕР 13.09.2026 · 24 535 ДОГОВОРОВ КУПЛИ-ПРОДАЖИ РОСРЕЕСТРА ПО ЕКАТЕРИНБУРГУ ЗА ИЮЛЬ 2025 — ИЮНЬ 2026 · ЭТО ЦЕНА ДОГОВОРА, А НЕ ЦЕНА ОБЪЯВЛЕНИЯ",
|
|
||||||
memo: "Цена объявления и цена договора — разные величины. Насколько именно разные, не скажет никто: Росреестр публикует сделки без привязки к объявлению, сопоставить одно с другим нечем.",
|
|
||||||
},
|
|
||||||
s2: {
|
|
||||||
id: "s2",
|
|
||||||
heading: "2. Почему метр в двушке самый дешёвый",
|
|
||||||
body: "«Чем больше квартира, тем дешевле метр» — самое живучее заблуждение о ценах. По сделкам кривая не падает, а прогибается: дешевле всего метр в двухкомнатной, а дальше он снова дорожает.",
|
|
||||||
table: {
|
|
||||||
head: ["КОМНАТНОСТЬ", "МЕДИАНА ЗА МЕТР"],
|
|
||||||
rows: [
|
|
||||||
{ name: "Студия · медиана 25 м² · 2 967 сделок", value: "134 507 ₽" },
|
|
||||||
{ name: "Однокомнатная · 38 м² · 8 342 сделки", value: "124 256 ₽" },
|
|
||||||
{ name: "Двухкомнатная · 51 м² · 6 548 сделок", value: "116 910 ₽" },
|
|
||||||
{ name: "Трёхкомнатная · 70 м² · 3 861 сделка", value: "121 835 ₽" },
|
|
||||||
{
|
|
||||||
name: "Четыре комнаты и больше · 108 м² · 2 817 сделок",
|
|
||||||
value: "132 122 ₽",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
footnote:
|
|
||||||
"ТОТ ЖЕ ЗАМЕР · РЯДОМ С КАЖДОЙ СТРОКОЙ — МЕДИАННАЯ ПЛОЩАДЬ И ЧИСЛО СДЕЛОК, НА КОТОРЫХ ПОСЧИТАНА МЕДИАНА",
|
|
||||||
after:
|
|
||||||
"Объяснение простое: у студий и у больших квартир метр продаётся вместе с чем-то ещё. У студии — с низким порогом входа, её берут те, кому важна сумма целиком, а не цена метра. У четырёхкомнатной — с домом и районом, потому что такие квартиры почти не строят в дешёвых сериях. Двушка же самый массовый и самый конкурентный товар на рынке, и цена метра в ней прижата плотнее всего.",
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
id: "s3",
|
|
||||||
heading: "3. Что делала цена последние два с половиной года",
|
|
||||||
body: "Рынок Екатеринбурга не падал ни одного квартала из десяти доступных. Медиана росла непрерывно, но по-разному: в 2024 году прибавка держалась около полутора процентов за квартал, в 2025-м и 2026-м ускорилась. Для продавца отсюда следует практический вывод: цена, посчитанная по сделкам полугодовой давности, сегодня занижена.",
|
|
||||||
tiles: [
|
|
||||||
{ value: "106 727 ₽", label: "метр в первом квартале 2024 года" },
|
|
||||||
{ value: "128 756 ₽", label: "метр во втором квартале 2026 года" },
|
|
||||||
{ value: "+20,6%", label: "рост медианы за два с половиной года" },
|
|
||||||
],
|
|
||||||
source:
|
|
||||||
"ТОТ ЖЕ ИСТОЧНИК, ВСЕ ДЕСЯТЬ ДОСТУПНЫХ КВАРТАЛОВ · ДАТА СДЕЛКИ В ВЫГРУЗКЕ РОСРЕЕСТРА ШТАМПУЕТСЯ НАЧАЛОМ КВАРТАЛА, МЕСЯЧНОГО РАЗРЕШЕНИЯ В ДАННЫХ НЕТ",
|
|
||||||
},
|
|
||||||
s4: {
|
|
||||||
id: "s4",
|
|
||||||
heading: "4. Города области: где метр дешевле",
|
|
||||||
body: "Внутри Свердловской области разброс больше, чем между комнатностями. Города-спутники Екатеринбурга идут почти по областной столице, дальние промышленные города — втрое дешевле.",
|
|
||||||
kv: [
|
|
||||||
{ k: "ВЕРХНЯЯ ПЫШМА", v: "102 888 ₽ за метр · 1 193 сделки" },
|
|
||||||
{ k: "ЗАРЕЧНЫЙ", v: "95 865 ₽ · 393 сделки" },
|
|
||||||
{ k: "БЕРЁЗОВСКИЙ", v: "95 745 ₽ · 858 сделок" },
|
|
||||||
{ k: "ПЕРВОУРАЛЬСК", v: "66 384 ₽ · 1 458 сделок" },
|
|
||||||
{ k: "КАМЕНСК-УРАЛЬСКИЙ", v: "63 763 ₽ · 1 994 сделки" },
|
|
||||||
{ k: "НИЖНИЙ ТАГИЛ", v: "62 637 ₽ · 3 799 сделок" },
|
|
||||||
{ k: "АСБЕСТ", v: "46 429 ₽ · 642 сделки" },
|
|
||||||
{ k: "НОВОУРАЛЬСК", v: "40 527 ₽ · 816 сделок" },
|
|
||||||
],
|
|
||||||
footnote:
|
|
||||||
"ТОТ ЖЕ ПЕРИОД · ПОКАЗАНЫ ГОРОДА, ГДЕ ЗА ГОД НАБРАЛОСЬ НЕ МЕНЬШЕ СТА СДЕЛОК · ГДЕ ИХ МЕНЬШЕ, МЕДИАНА НЕУСТОЙЧИВА И МЫ ЕЁ НЕ ПУБЛИКУЕМ",
|
|
||||||
},
|
|
||||||
cta: {
|
|
||||||
eyebrow: "МЕДИАНА ПО ГОРОДУ — ЭТО ЕЩЁ НЕ ВАША ЦЕНА",
|
|
||||||
title: "МЕРА считает по вашему дому, а не по городу",
|
|
||||||
body: `Между двумя домами в одном районе разница бывает больше, чем между районами. Мы находим аналоги вокруг вашего адреса, считаем медиану по ним и показываем, на чём она построена. Рынок вокруг — бесплатно, ваша цена и список аналогов — за ${SERVICE_PRICE_RUB} ₽.`,
|
|
||||||
},
|
|
||||||
s5: {
|
|
||||||
id: "s5",
|
|
||||||
heading: "5. Пять способов неправильно прочитать эти цифры",
|
|
||||||
mistakes: [
|
|
||||||
{
|
|
||||||
title: "Умножить медиану города на свою площадь",
|
|
||||||
body: "Получится цена абстрактной квартиры в абстрактном районе. Медиана по городу — ориентир порядка величины, а не оценка объекта.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Решить, что раз метр в двушке дешевле, двушку продавать невыгодно",
|
|
||||||
body: "Дешевле метр, а не квартира. Двушка всё равно стоит дороже однушки: 6 015 000 ₽ против 4 650 000 ₽ по медиане сделок.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Считать цену договора суммой на руки",
|
|
||||||
body: "Росреестр публикует то, что написано в договоре. Совпадает ли это с фактическим расчётом между сторонами, из данных не видно.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Брать медиану полугодовой давности",
|
|
||||||
body: "За последние четыре квартала медиана прибавляла около двух процентов за квартал. Полгода отставания — это несколько процентов мимо вашей цены.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Сравнивать свою квартиру с городом, а не с соседями",
|
|
||||||
body: "Между квартилями Екатеринбурга разброс от 100 000 до 151 288 ₽ за метр. Где внутри этой вилки стоит ваш дом, медиана города не скажет.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
summary:
|
|
||||||
"Медианный метр в Екатеринбурге стоит 123 626 ₽ по 24 535 сделкам за год. Дешевле всего метр в двухкомнатной, дороже всего в студии. Рынок рос все десять доступных кварталов и прибавил 20,6% с начала 2024 года. Города области идут от 40 527 ₽ в Новоуральске до 102 888 ₽ в Верхней Пышме. Всё это ориентиры порядка, а не оценка вашей квартиры: её считают по соседним домам.",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Почему квартира не продаётся» (13.09.2026).
|
|
||||||
*
|
|
||||||
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. Таблица `offer_price_history` — история цены
|
|
||||||
* объявления, которую ведут сами площадки (cian, yandex, domklik, avito),
|
|
||||||
* склеенная с `listings` по региону 66. Срез 13.09.2026:
|
|
||||||
*
|
|
||||||
* 49 620 объявлений области, у которых есть хоть одна запись о цене
|
|
||||||
* 21 608 из них цена менялась хотя бы раз (43,5%)
|
|
||||||
* 13 009 итоговая цена ниже стартовой (60,2% от менявших)
|
|
||||||
* 8 212 итоговая цена ВЫШЕ стартовой (38,0% от менявших)
|
|
||||||
*
|
|
||||||
* Медиана по снизившим: −3,85%, −291 000 ₽, 30 дней между первой и последней
|
|
||||||
* записью о цене.
|
|
||||||
*
|
|
||||||
* ГРАНИЦЫ, КОТОРЫЕ НАЗЫВАЕМ В ТЕКСТЕ. (1) Это движение ЗАПРАШИВАЕМОЙ цены, а
|
|
||||||
* не скидка на сделке: связки объявления со сделкой в данных нет. (2) Знаменатель
|
|
||||||
* 21 608 — объявления, где цена двигалась; те, у кого запись одна, в долю не
|
|
||||||
* входят, потому что у них неподвижная цена и отсутствие наблюдения
|
|
||||||
* неразличимы. (3) «30 дней» — срок между первой и последней записью о цене, а
|
|
||||||
* не срок продажи. Срока продажи в данных нет вовсе: поле `days_on_market` в
|
|
||||||
* сделках Росреестра пусто у всех 435 тысяч записей, а исчезновение объявления
|
|
||||||
* означает только исчезновение из выдачи, но не продажу.
|
|
||||||
*
|
|
||||||
* ПОЧЕМУ В СТАТЬЕ НЕТ СРЕДНЕГО СРОКА ПРОДАЖИ. Он не измеряется. Снятие
|
|
||||||
* объявления и продажа в наших данных неразличимы, а даты «снятий» частично
|
|
||||||
* совпадают с провалами собственного обхода — то есть измеряли бы мы свой
|
|
||||||
* сборщик, а не рынок.
|
|
||||||
*/
|
|
||||||
export const WAITING = {
|
|
||||||
rubric: "ЦЕНА И ОЖИДАНИЕ · 5 МИН ЧТЕНИЯ",
|
|
||||||
title: "Почему квартира не продаётся: что происходит с ценой, пока вы ждёте",
|
|
||||||
lead: [
|
|
||||||
"Квартира висит третий месяц, звонков нет, и первый совет от всех вокруг — «подожди, твой покупатель найдётся». Иногда это правда. Чаще ожидание заканчивается тем же, чем закончилось у большинства: продавец снижает цену, только на несколько месяцев позже и на большую сумму.",
|
|
||||||
"Мы посмотрели, что на самом деле происходит с ценой в объявлениях Свердловской области, пока они висят. Ниже — что видно в данных и, не менее важно, чего в них не видно.",
|
|
||||||
],
|
|
||||||
toc: [
|
|
||||||
{ id: "s1", title: "Что вообще можно измерить, а что нельзя" },
|
|
||||||
{ id: "s2", title: "Кто двигает цену и в какую сторону" },
|
|
||||||
{ id: "s3", title: "На сколько снижают те, кто снижает" },
|
|
||||||
{ id: "s4", title: "Что делать, если звонков нет" },
|
|
||||||
{ id: "s5", title: "Пять причин простоя, которые не в цене" },
|
|
||||||
],
|
|
||||||
s1: {
|
|
||||||
id: "s1",
|
|
||||||
heading: "1. Что вообще можно измерить, а что нельзя",
|
|
||||||
body: "Честно о границах, прежде чем показывать числа. Среднего срока продажи мы вам не назовём, и никто честный не назовёт: чтобы его посчитать, нужно знать день, когда квартиру выставили, и день, когда её купили. Второго нет ни у кого, кроме сторон сделки. Росреестр публикует договоры без срока экспозиции, а исчезновение объявления с площадки означает только исчезновение с площадки — продал, передумал или переподал заново, из данных не отличить. Зато прекрасно видно другое: как продавец сам двигает цену, пока ждёт.",
|
|
||||||
source:
|
|
||||||
"ЗАМЕР 13.09.2026 · 49 620 ОБЪЯВЛЕНИЙ СВЕРДЛОВСКОЙ ОБЛАСТИ, У КОТОРЫХ ПЛОЩАДКА ВЕДЁТ ИСТОРИЮ ЦЕНЫ · ЭТО ЗАПРАШИВАЕМАЯ ЦЕНА, А НЕ ЦЕНА СДЕЛКИ",
|
|
||||||
memo: "Любая статья, которая называет вам «средний срок продажи квартиры», считает его по снятиям объявлений. Снятие — это не продажа. Спросите, как отличали одно от другого.",
|
|
||||||
},
|
|
||||||
s2: {
|
|
||||||
id: "s2",
|
|
||||||
heading: "2. Кто двигает цену и в какую сторону",
|
|
||||||
body: "Из 49 620 объявлений цена хотя бы раз сдвинулась у 21 608. Дальше начинается неожиданное: вниз ходят не все. Почти четыре из десяти движений — вверх.",
|
|
||||||
tiles: [
|
|
||||||
{ value: "43,5%", label: "объявлений, где цена вообще двигалась" },
|
|
||||||
{ value: "60,2%", label: "из двигавших — снизили" },
|
|
||||||
{ value: "38,0%", label: "из двигавших — подняли" },
|
|
||||||
],
|
|
||||||
source:
|
|
||||||
"ТОТ ЖЕ ЗАМЕР · ЗНАМЕНАТЕЛЬ ДОЛЕЙ — 21 608 ОБЪЯВЛЕНИЙ, У КОТОРЫХ ЦЕНА МЕНЯЛАСЬ · У ОСТАЛЬНЫХ ЗАПИСЬ ОДНА, И НЕПОДВИЖНУЮ ЦЕНУ ОТ ОТСУТСТВИЯ НАБЛЮДЕНИЯ НЕ ОТЛИЧИТЬ",
|
|
||||||
after:
|
|
||||||
"Подъём цены обычно означает не жадность, а переоценку: продавец начал слишком низко, получил шквал звонков в первую неделю и понял это. Ошибка в меньшую сторону чинится легко и быстро. Ошибка в большую сторону чинится месяцами — и это ровно та асимметрия, ради которой стоит потратить вечер на расчёт до публикации.",
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
id: "s3",
|
|
||||||
heading: "3. На сколько снижают те, кто снижает",
|
|
||||||
body: "Медианное снижение выглядит скромно в процентах и совсем не скромно в рублях. И это не одна большая уступка в конце, а обычно две-три ступеньки подряд.",
|
|
||||||
table: {
|
|
||||||
head: ["ЧТО ИЗМЕРЕНО", "МЕДИАНА ПО 13 009 ОБЪЯВЛЕНИЯМ"],
|
|
||||||
rows: [
|
|
||||||
{ name: "Снижение от стартовой цены", value: "−3,85%" },
|
|
||||||
{ name: "Снижение в рублях", value: "−291 000 ₽" },
|
|
||||||
{ name: "Срок между первой и последней ценой", value: "30 дней" },
|
|
||||||
{ name: "Нижний квартиль снижения", value: "−7,7%" },
|
|
||||||
{ name: "Верхний квартиль снижения", value: "−2,4%" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
footnote:
|
|
||||||
"ТОТ ЖЕ ЗАМЕР · «30 ДНЕЙ» — СРОК МЕЖДУ ПЕРВОЙ И ПОСЛЕДНЕЙ ЗАПИСЬЮ О ЦЕНЕ, А НЕ СРОК ПРОДАЖИ · СРОК ПРОДАЖИ ИЗМЕРИТЬ НЕЧЕМ",
|
|
||||||
},
|
|
||||||
s4: {
|
|
||||||
id: "s4",
|
|
||||||
heading: "4. Что делать, если звонков нет",
|
|
||||||
body: "Ожидание само по себе не аргумент. Рынок отвечает на цену быстро, и молчание — тоже ответ. Порядок действий такой:",
|
|
||||||
kv: [
|
|
||||||
{ k: "ПЕРВАЯ НЕДЕЛЯ", v: "считаем показы и звонки, а не дни. Ноль звонков за неделю при живом объявлении — цена выше рынка" },
|
|
||||||
{ k: "ВТОРАЯ НЕДЕЛЯ", v: "пересчитываем по аналогам заново: что сейчас стоит рядом и что из этого реально ушло" },
|
|
||||||
{ k: "ПЕРЕД СНИЖЕНИЕМ", v: "проверяем фото, описание и этаж — часть простоя не в цене вовсе, см. раздел 5" },
|
|
||||||
{ k: "ЕСЛИ СНИЖАЕМ", v: "один заметный шаг лучше пяти незаметных: снижение на 1% рынок не видит" },
|
|
||||||
{ k: "ЧЕГО НЕ ДЕЛАТЬ", v: "снимать и публиковать заново ради «свежести» — история цены на площадках всё равно сохраняется" },
|
|
||||||
],
|
|
||||||
footnote:
|
|
||||||
"ЭТО ПОРЯДОК ДЕЙСТВИЙ, А НЕ ЗАМЕР · ИЗ ДАННЫХ ЗДЕСЬ ТОЛЬКО ОДНО: СНИЖАЮЩИЕ ДЕЛАЮТ ЭТО В МЕДИАНЕ ЗА ТРИ СТУПЕНЬКИ ЦЕНЫ",
|
|
||||||
},
|
|
||||||
cta: {
|
|
||||||
eyebrow: "ПЕРЕД ТЕМ КАК СНИЖАТЬ",
|
|
||||||
title: "Проверьте, действительно ли цена выше рынка",
|
|
||||||
body: `Снижать вслепую — это отдавать сотни тысяч на всякий случай. МЕРА найдёт аналоги вокруг вашего адреса и покажет, где ваша цена относительно них. Рынок вокруг — бесплатно, разбор вашей цены — за ${SERVICE_PRICE_RUB} ₽.`,
|
|
||||||
},
|
|
||||||
s5: {
|
|
||||||
id: "s5",
|
|
||||||
heading: "5. Пять причин простоя, которые не в цене",
|
|
||||||
mistakes: [
|
|
||||||
{
|
|
||||||
title: "Четыре фотографии, снятые вечером",
|
|
||||||
body: "Объявление конкурирует в ленте миниатюр, а не в тексте. Если карточку не открыли, цена значения не имела.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Описание из характеристик",
|
|
||||||
body: "«2/5, панель, 44 м²» покупатель уже видит в фильтре. В описании должно быть то, чего в фильтре нет.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Показы только по будням до шести",
|
|
||||||
body: "Ваш покупатель в это время на работе. Часть простоя — это не отказ рынка, а несовпадение расписаний.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Обременение, о котором вы скажете позже",
|
|
||||||
body: "Ипотека, доли, несовершеннолетний собственник. Всплывает на этапе аванса и отправляет вас в начало.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Продажа «в никуда»",
|
|
||||||
body: "Если вы не знаете, куда переезжаете, вы неосознанно тормозите сделку сами. Покупатель это чувствует раньше вас.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
summary:
|
|
||||||
"Срок продажи никто честно не измерит: снятие объявления и продажа в данных неразличимы. Зато видно движение цены. Она двигалась у 43,5% объявлений, и в 60,2% случаев вниз, в медиане на 3,85% и 291 000 ₽. Прежде чем снижать, проверьте цену по аналогам и проверьте само объявление: часть простоя не в цене.",
|
|
||||||
} as const;
|
|
||||||
|
|
|
||||||
|
|
@ -202,19 +202,8 @@ export default function HowToArticlePage() {
|
||||||
</PublicLink>
|
</PublicLink>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.artGrid}>
|
<div className={styles.artGrid}>
|
||||||
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map((c) =>
|
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map(
|
||||||
c.route ? (
|
(c) => (
|
||||||
<PublicLink
|
|
||||||
key={c.title}
|
|
||||||
to={c.route}
|
|
||||||
className={styles.artCardLink}
|
|
||||||
>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artReadMore}>Читать →</div>
|
|
||||||
</PublicLink>
|
|
||||||
) : (
|
|
||||||
<article key={c.title} className={styles.artCard}>
|
<article key={c.title} className={styles.artCard}>
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
<div className={styles.artRubric}>{c.rubric}</div>
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
<div className={styles.artCardTitle}>{c.title}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
import { InnerHeader } from "../../_components/InnerHeader";
|
|
||||||
import { JsonLd } from "../../_components/JsonLd";
|
|
||||||
import { PublicLink } from "../../_components/PublicLink";
|
|
||||||
import { FooterV3 } from "../../_components/v3/FooterV3";
|
|
||||||
import { breadcrumbNode, ORGANIZATION_ID } from "../../_lib/schema";
|
|
||||||
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
|
|
||||||
import styles from "../../landing-v3.module.css";
|
|
||||||
import { ARTICLE_CARDS, WAITING } from "../articles-content";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Почему квартира не продаётся: что происходит с ценой, пока вы ждёте».
|
|
||||||
* Текст и все величины — в `articles-content.ts` (там же замер, на котором они
|
|
||||||
* стоят, и его границы). В этом файле не должно быть ни одного числа: папка
|
|
||||||
* попадает в `SUBPAGES` гейта чисел.
|
|
||||||
*
|
|
||||||
* Серверный компонент без клиентского JS: содержание — якорные ссылки,
|
|
||||||
* секции — обычные заголовки с id.
|
|
||||||
*
|
|
||||||
* ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PUBLIC_WAITING_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articleWaiting}`;
|
|
||||||
const DESCRIPTION = WAITING.lead[0];
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: `${WAITING.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
alternates: { canonical: PUBLIC_WAITING_URL },
|
|
||||||
openGraph: {
|
|
||||||
type: "article",
|
|
||||||
title: `${WAITING.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
url: PUBLIC_WAITING_URL,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-LD `Article` для сниппетов поисковика. Без `datePublished`/
|
|
||||||
* `dateModified`: достоверной даты публикации в коде нет, а выдуманная дата —
|
|
||||||
* то самое нечестное утверждение, которое запрещает шапка content.ts.
|
|
||||||
*/
|
|
||||||
const ARTICLE_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Article",
|
|
||||||
headline: WAITING.title,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
author: { "@type": "Organization", name: "МЕРА" },
|
|
||||||
publisher: { "@id": ORGANIZATION_ID },
|
|
||||||
mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_WAITING_URL },
|
|
||||||
};
|
|
||||||
|
|
||||||
const BREADCRUMB_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
...breadcrumbNode([
|
|
||||||
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
|
|
||||||
{ name: "Статьи", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}` },
|
|
||||||
{ name: WAITING.title, url: PUBLIC_WAITING_URL },
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WaitingArticlePage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={ARTICLE_JSON_LD} />
|
|
||||||
<JsonLd data={BREADCRUMB_JSON_LD} />
|
|
||||||
<InnerHeader />
|
|
||||||
<main id="content" className={styles.artPage} tabIndex={-1}>
|
|
||||||
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
|
|
||||||
<PublicLink to="home" className={styles.artCrumbLink}>
|
|
||||||
Главная
|
|
||||||
</PublicLink>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<PublicLink to="articles" className={styles.artCrumbLink}>
|
|
||||||
Статьи
|
|
||||||
</PublicLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header className={styles.artPageHead}>
|
|
||||||
<div className={styles.repEyebrow}>{WAITING.rubric}</div>
|
|
||||||
<h1 className={styles.artPageTitle}>{WAITING.title}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className={styles.artToc} aria-label="Содержание">
|
|
||||||
<div className={styles.artTocLabel}>СОДЕРЖАНИЕ</div>
|
|
||||||
<ol className={styles.artTocList}>
|
|
||||||
{WAITING.toc.map((t) => (
|
|
||||||
<li key={t.id}>
|
|
||||||
<a href={`#${t.id}`}>{t.title}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className={styles.artBody}>
|
|
||||||
{WAITING.lead.map((p) => (
|
|
||||||
<p key={p.slice(0, 24)} className={styles.artP}>
|
|
||||||
{p}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<h2 id={WAITING.s1.id} className={styles.artH2}>
|
|
||||||
{WAITING.s1.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s1.body}</p>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s1.source}</div>
|
|
||||||
<aside className={styles.artMemo}>
|
|
||||||
<div className={styles.artMemoLabel}>ЗАПОМНИТЬ</div>
|
|
||||||
<p>{WAITING.s1.memo}</p>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s2.id} className={styles.artH2}>
|
|
||||||
{WAITING.s2.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s2.body}</p>
|
|
||||||
<div className={styles.artTiles}>
|
|
||||||
{WAITING.s2.tiles.map((t) => (
|
|
||||||
<div key={t.label} className={styles.artTile}>
|
|
||||||
<div className={styles.artTileValue}>{t.value}</div>
|
|
||||||
<div className={styles.artTileLabel}>{t.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s2.source}</div>
|
|
||||||
<p className={styles.artP}>{WAITING.s2.after}</p>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s3.id} className={styles.artH2}>
|
|
||||||
{WAITING.s3.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s3.body}</p>
|
|
||||||
<div className={styles.artTable} role="table">
|
|
||||||
<div className={styles.artTableHead} role="row">
|
|
||||||
<span role="columnheader">{WAITING.s3.table.head[0]}</span>
|
|
||||||
<span role="columnheader" className={styles.artTableRight}>
|
|
||||||
{WAITING.s3.table.head[1]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{WAITING.s3.table.rows.map((r) => (
|
|
||||||
<div key={r.name} className={styles.artTableRow} role="row">
|
|
||||||
<span role="cell">{r.name}</span>
|
|
||||||
<span role="cell" className={styles.artTableRight}>
|
|
||||||
{r.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s3.footnote}</div>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s4.id} className={styles.artH2}>
|
|
||||||
{WAITING.s4.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s4.body}</p>
|
|
||||||
<div className={styles.artKv}>
|
|
||||||
{WAITING.s4.kv.map((row) => (
|
|
||||||
<div key={row.k} className={styles.artKvRow}>
|
|
||||||
<span className={styles.artKvKey}>{row.k}</span>
|
|
||||||
<span className={styles.artKvVal}>{row.v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s4.footnote}</div>
|
|
||||||
|
|
||||||
<div className={styles.artCta}>
|
|
||||||
<div className={styles.artCtaEyebrow}>{WAITING.cta.eyebrow}</div>
|
|
||||||
<div className={styles.artCtaTitle}>{WAITING.cta.title}</div>
|
|
||||||
<p className={styles.artCtaBody}>{WAITING.cta.body}</p>
|
|
||||||
<PublicLink to="estimate" className={styles.gameBtnAccent}>
|
|
||||||
Проверить квартиру
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s5.id} className={styles.artH2}>
|
|
||||||
{WAITING.s5.heading}
|
|
||||||
</h2>
|
|
||||||
<ol className={styles.artMistakes}>
|
|
||||||
{WAITING.s5.mistakes.map((m, i) => (
|
|
||||||
<li key={m.title} className={styles.artMistake}>
|
|
||||||
<span className={styles.artMistakeNum}>
|
|
||||||
{String(i + 1).padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className={styles.artMistakeTitle}>{m.title}</div>
|
|
||||||
<div className={styles.artMistakeBody}>{m.body}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className={styles.artSummary}>
|
|
||||||
<div className={styles.artTocLabel}>КОРОТКО</div>
|
|
||||||
<p>{WAITING.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className={styles.artMore} aria-label="Читайте дальше">
|
|
||||||
<div className={styles.artHead}>
|
|
||||||
<h2 className={styles.artTitle}>Читайте дальше</h2>
|
|
||||||
<PublicLink to="articles" className={styles.artAllLink}>
|
|
||||||
Все статьи →
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
<div className={styles.artGrid}>
|
|
||||||
{ARTICLE_CARDS.filter((c) => c.route !== "articleWaiting").map((c) =>
|
|
||||||
c.route ? (
|
|
||||||
<PublicLink
|
|
||||||
key={c.title}
|
|
||||||
to={c.route}
|
|
||||||
className={styles.artCardLink}
|
|
||||||
>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artReadMore}>Читать →</div>
|
|
||||||
</PublicLink>
|
|
||||||
) : (
|
|
||||||
<article key={c.title} className={styles.artCard}>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artSoon}>СКОРО</div>
|
|
||||||
</article>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<FooterV3 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
import { InnerHeader } from "../../_components/InnerHeader";
|
|
||||||
import { JsonLd } from "../../_components/JsonLd";
|
|
||||||
import { PublicLink } from "../../_components/PublicLink";
|
|
||||||
import { FooterV3 } from "../../_components/v3/FooterV3";
|
|
||||||
import { breadcrumbNode, ORGANIZATION_ID } from "../../_lib/schema";
|
|
||||||
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
|
|
||||||
import styles from "../../landing-v3.module.css";
|
|
||||||
import { ARTICLE_CARDS, PRICES } from "../articles-content";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений».
|
|
||||||
* Текст и все величины — в `articles-content.ts` (там же замер, на котором они
|
|
||||||
* стоят, и его границы). В этом файле не должно быть ни одного числа: папка
|
|
||||||
* попадает в `SUBPAGES` гейта чисел.
|
|
||||||
*
|
|
||||||
* Серверный компонент без клиентского JS: содержание — якорные ссылки,
|
|
||||||
* секции — обычные заголовки с id.
|
|
||||||
*
|
|
||||||
* ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PUBLIC_PRICES_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articlePrices}`;
|
|
||||||
const DESCRIPTION = PRICES.lead[0];
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: `${PRICES.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
alternates: { canonical: PUBLIC_PRICES_URL },
|
|
||||||
openGraph: {
|
|
||||||
type: "article",
|
|
||||||
title: `${PRICES.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
url: PUBLIC_PRICES_URL,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-LD `Article` для сниппетов поисковика. Без `datePublished`/
|
|
||||||
* `dateModified`: достоверной даты публикации в коде нет, а выдуманная дата —
|
|
||||||
* то самое нечестное утверждение, которое запрещает шапка content.ts.
|
|
||||||
*/
|
|
||||||
const ARTICLE_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Article",
|
|
||||||
headline: PRICES.title,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
author: { "@type": "Organization", name: "МЕРА" },
|
|
||||||
publisher: { "@id": ORGANIZATION_ID },
|
|
||||||
mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_PRICES_URL },
|
|
||||||
};
|
|
||||||
|
|
||||||
const BREADCRUMB_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
...breadcrumbNode([
|
|
||||||
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
|
|
||||||
{ name: "Статьи", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}` },
|
|
||||||
{ name: PRICES.title, url: PUBLIC_PRICES_URL },
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PricesArticlePage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={ARTICLE_JSON_LD} />
|
|
||||||
<JsonLd data={BREADCRUMB_JSON_LD} />
|
|
||||||
<InnerHeader />
|
|
||||||
<main id="content" className={styles.artPage} tabIndex={-1}>
|
|
||||||
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
|
|
||||||
<PublicLink to="home" className={styles.artCrumbLink}>
|
|
||||||
Главная
|
|
||||||
</PublicLink>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<PublicLink to="articles" className={styles.artCrumbLink}>
|
|
||||||
Статьи
|
|
||||||
</PublicLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header className={styles.artPageHead}>
|
|
||||||
<div className={styles.repEyebrow}>{PRICES.rubric}</div>
|
|
||||||
<h1 className={styles.artPageTitle}>{PRICES.title}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className={styles.artToc} aria-label="Содержание">
|
|
||||||
<div className={styles.artTocLabel}>СОДЕРЖАНИЕ</div>
|
|
||||||
<ol className={styles.artTocList}>
|
|
||||||
{PRICES.toc.map((t) => (
|
|
||||||
<li key={t.id}>
|
|
||||||
<a href={`#${t.id}`}>{t.title}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className={styles.artBody}>
|
|
||||||
{PRICES.lead.map((p) => (
|
|
||||||
<p key={p.slice(0, 24)} className={styles.artP}>
|
|
||||||
{p}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<h2 id={PRICES.s1.id} className={styles.artH2}>
|
|
||||||
{PRICES.s1.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s1.body}</p>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s1.source}</div>
|
|
||||||
<aside className={styles.artMemo}>
|
|
||||||
<div className={styles.artMemoLabel}>ЗАПОМНИТЬ</div>
|
|
||||||
<p>{PRICES.s1.memo}</p>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s2.id} className={styles.artH2}>
|
|
||||||
{PRICES.s2.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s2.body}</p>
|
|
||||||
<div className={styles.artTable} role="table">
|
|
||||||
<div className={styles.artTableHead} role="row">
|
|
||||||
<span role="columnheader">{PRICES.s2.table.head[0]}</span>
|
|
||||||
<span role="columnheader" className={styles.artTableRight}>
|
|
||||||
{PRICES.s2.table.head[1]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{PRICES.s2.table.rows.map((r) => (
|
|
||||||
<div key={r.name} className={styles.artTableRow} role="row">
|
|
||||||
<span role="cell">{r.name}</span>
|
|
||||||
<span role="cell" className={styles.artTableRight}>
|
|
||||||
{r.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s2.footnote}</div>
|
|
||||||
<p className={styles.artP}>{PRICES.s2.after}</p>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s3.id} className={styles.artH2}>
|
|
||||||
{PRICES.s3.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s3.body}</p>
|
|
||||||
<div className={styles.artTiles}>
|
|
||||||
{PRICES.s3.tiles.map((t) => (
|
|
||||||
<div key={t.label} className={styles.artTile}>
|
|
||||||
<div className={styles.artTileValue}>{t.value}</div>
|
|
||||||
<div className={styles.artTileLabel}>{t.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s3.source}</div>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s4.id} className={styles.artH2}>
|
|
||||||
{PRICES.s4.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s4.body}</p>
|
|
||||||
<div className={styles.artKv}>
|
|
||||||
{PRICES.s4.kv.map((row) => (
|
|
||||||
<div key={row.k} className={styles.artKvRow}>
|
|
||||||
<span className={styles.artKvKey}>{row.k}</span>
|
|
||||||
<span className={styles.artKvVal}>{row.v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s4.footnote}</div>
|
|
||||||
|
|
||||||
<div className={styles.artCta}>
|
|
||||||
<div className={styles.artCtaEyebrow}>{PRICES.cta.eyebrow}</div>
|
|
||||||
<div className={styles.artCtaTitle}>{PRICES.cta.title}</div>
|
|
||||||
<p className={styles.artCtaBody}>{PRICES.cta.body}</p>
|
|
||||||
<PublicLink to="estimate" className={styles.gameBtnAccent}>
|
|
||||||
Проверить квартиру
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s5.id} className={styles.artH2}>
|
|
||||||
{PRICES.s5.heading}
|
|
||||||
</h2>
|
|
||||||
<ol className={styles.artMistakes}>
|
|
||||||
{PRICES.s5.mistakes.map((m, i) => (
|
|
||||||
<li key={m.title} className={styles.artMistake}>
|
|
||||||
<span className={styles.artMistakeNum}>
|
|
||||||
{String(i + 1).padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className={styles.artMistakeTitle}>{m.title}</div>
|
|
||||||
<div className={styles.artMistakeBody}>{m.body}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className={styles.artSummary}>
|
|
||||||
<div className={styles.artTocLabel}>КОРОТКО</div>
|
|
||||||
<p>{PRICES.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className={styles.artMore} aria-label="Читайте дальше">
|
|
||||||
<div className={styles.artHead}>
|
|
||||||
<h2 className={styles.artTitle}>Читайте дальше</h2>
|
|
||||||
<PublicLink to="articles" className={styles.artAllLink}>
|
|
||||||
Все статьи →
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
<div className={styles.artGrid}>
|
|
||||||
{ARTICLE_CARDS.filter((c) => c.route !== "articlePrices").map((c) =>
|
|
||||||
c.route ? (
|
|
||||||
<PublicLink
|
|
||||||
key={c.title}
|
|
||||||
to={c.route}
|
|
||||||
className={styles.artCardLink}
|
|
||||||
>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artReadMore}>Читать →</div>
|
|
||||||
</PublicLink>
|
|
||||||
) : (
|
|
||||||
<article key={c.title} className={styles.artCard}>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artSoon}>СКОРО</div>
|
|
||||||
</article>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<FooterV3 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -195,8 +195,6 @@ export const PUBLIC_ROUTES = {
|
||||||
// Макет 29.08: раздел статей и хаб документов.
|
// Макет 29.08: раздел статей и хаб документов.
|
||||||
articles: "/articles",
|
articles: "/articles",
|
||||||
articleHowTo: "/articles/kak-ocenit-kvartiru",
|
articleHowTo: "/articles/kak-ocenit-kvartiru",
|
||||||
articlePrices: "/articles/skolko-stoit-metr-v-ekaterinburge",
|
|
||||||
articleWaiting: "/articles/pochemu-kvartira-ne-prodaetsya",
|
|
||||||
docs: "/docs",
|
docs: "/docs",
|
||||||
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
|
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
|
||||||
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
|
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "./content";
|
||||||
* актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным
|
* актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным
|
||||||
* изменением состава/контента страниц.
|
* изменением состава/контента страниц.
|
||||||
*/
|
*/
|
||||||
const LAST_MODIFIED = new Date("2026-09-13T00:00:00Z");
|
const LAST_MODIFIED = new Date("2026-09-10T00:00:00Z");
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
|
const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
|
||||||
|
|
@ -52,18 +52,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
url: url(PUBLIC_ROUTES.articlePrices),
|
|
||||||
lastModified: LAST_MODIFIED,
|
|
||||||
changeFrequency: "monthly",
|
|
||||||
priority: 0.6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: url(PUBLIC_ROUTES.articleWaiting),
|
|
||||||
lastModified: LAST_MODIFIED,
|
|
||||||
changeFrequency: "monthly",
|
|
||||||
priority: 0.6,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: url(PUBLIC_ROUTES.business),
|
url: url(PUBLIC_ROUTES.business),
|
||||||
lastModified: LAST_MODIFIED,
|
lastModified: LAST_MODIFIED,
|
||||||
|
|
|
||||||
|
|
@ -289,63 +289,12 @@ function EmptyResultPanel() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named predicate for the "no price at all" state — deliberately NOT
|
function InsufficientPanel() {
|
||||||
// `!estimate.median_price_rub` (a bare zero/price check): that would also
|
|
||||||
// misfire on a still-loading `estimate` or on legitimate falsy-but-present
|
|
||||||
// values. The one reliable signal is the backend's own `insufficient_data`
|
|
||||||
// flag, which is defined (see AggregatedEstimate in types/trade-in.ts /
|
|
||||||
// backend @computed_field) to mean "this IS a successful response, but
|
|
||||||
// median_price_rub <= 0 — there was nothing to price with". Only fires on a
|
|
||||||
// present, successfully-returned estimate.
|
|
||||||
function isInsufficientEstimate(estimate: AggregatedEstimate | null): boolean {
|
|
||||||
return estimate != null && estimate.insufficient_data === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Карточка состояния «цены нет». Показывает объяснение, которое прислал сам
|
|
||||||
// бэкенд (confidence_explanation — текст написан специально под это состояние),
|
|
||||||
// и адрес, который геокодер всё-таки нашёл: target_address и координаты
|
|
||||||
// заполнены даже когда цены нет. Плюс понятное следующее действие.
|
|
||||||
// Цена, диапазон, «−N% к рынку» и аналоги здесь не рендерятся вовсе — на
|
|
||||||
// проводе они 0/null, и показать их значило бы выдать сломанный экран «0 ₽»
|
|
||||||
// вместо честного «дом нашли, цену — нет».
|
|
||||||
function InsufficientPanel({
|
|
||||||
estimate,
|
|
||||||
onNew,
|
|
||||||
}: {
|
|
||||||
estimate: AggregatedEstimate;
|
|
||||||
onNew: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<PlaceholderPanel
|
<PlaceholderPanel
|
||||||
title="НЕ ХВАТАЕТ ДАННЫХ ДЛЯ ОЦЕНКИ"
|
title="НЕДОСТАТОЧНО ДАННЫХ"
|
||||||
body={
|
body="По этому адресу не нашлось достаточного числа аналогов и сделок для надёжной оценки. Уточните адрес или параметры квартиры."
|
||||||
estimate.confidence_explanation ??
|
/>
|
||||||
"По этому адресу не нашлось достаточного числа аналогов и сделок для надёжной оценки. Уточните адрес или параметры квартиры."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{estimate.target_address && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: tokens.ink,
|
|
||||||
maxWidth: 360,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Найденный адрес: {estimate.target_address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: "flex", gap: 10, marginTop: 4 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="v2-retry-btn"
|
|
||||||
onClick={onNew}
|
|
||||||
style={retryBtnStyle}
|
|
||||||
>
|
|
||||||
УТОЧНИТЬ АДРЕС
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</PlaceholderPanel>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -639,7 +588,7 @@ export default function TradeInV2Page() {
|
||||||
// existing Серов estimates were already stuck in this state). See
|
// existing Серов estimates were already stuck in this state). See
|
||||||
// mapResultPanel's `dealsOnlyPrice` for how the result panel discloses the
|
// mapResultPanel's `dealsOnlyPrice` for how the result panel discloses the
|
||||||
// deals-only source instead of mislabelling it as "по объявлениям".
|
// deals-only source instead of mislabelling it as "по объявлениям".
|
||||||
const insufficient = isInsufficientEstimate(estimate);
|
const insufficient = estimate != null && estimate.insufficient_data;
|
||||||
const apiError = mutation.error?.message ?? null;
|
const apiError = mutation.error?.message ?? null;
|
||||||
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
|
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
|
||||||
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
|
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
|
||||||
|
|
@ -881,7 +830,7 @@ export default function TradeInV2Page() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (estimate && insufficient) {
|
} else if (estimate && insufficient) {
|
||||||
middleContent = <InsufficientPanel estimate={estimate} onNew={handleNew} />;
|
middleContent = <InsufficientPanel />;
|
||||||
} else {
|
} else {
|
||||||
middleContent = <EmptyResultPanel />;
|
middleContent = <EmptyResultPanel />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,6 @@ const PUBLIC_SHORT_PATHS = [
|
||||||
"/privacy",
|
"/privacy",
|
||||||
"/articles",
|
"/articles",
|
||||||
"/articles/kak-ocenit-kvartiru",
|
"/articles/kak-ocenit-kvartiru",
|
||||||
"/articles/skolko-stoit-metr-v-ekaterinburge",
|
|
||||||
"/articles/pochemu-kvartira-ne-prodaetsya",
|
|
||||||
"/docs",
|
"/docs",
|
||||||
"/business",
|
"/business",
|
||||||
// Машинные артефакты для поисковых роботов, а не страницы. React на них не
|
// Машинные артефакты для поисковых роботов, а не страницы. React на них не
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* city-registry — единый источник правды по городам покрытия «Меры»:
|
* city-registry — единый источник правды по городам покрытия «Меры»:
|
||||||
* Свердловская область (регион 66), Москва (регион 77, PR-B) и Московская
|
* Свердловская область (регион 66) и Москва (регион 77, PR-B).
|
||||||
* область (регион 50, 22 города — расширение 2026-09-16: замер плотности
|
|
||||||
* когорты на проде показал, что каждый город области плотнее уже-жёлтой
|
|
||||||
* Москвы, см. `COVERAGE_MO_CITIES` в backend/app/api/v1/trade_in.py).
|
|
||||||
*
|
*
|
||||||
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
||||||
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
||||||
|
|
@ -52,12 +49,11 @@ export interface OblastCity {
|
||||||
*/
|
*/
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
/**
|
/**
|
||||||
* Регион: 66 — Свердловская область, 77 — Москва, 50 — Московская область.
|
* Регион: 66 — Свердловская область, 77 — Москва. НЕ отправляется бэкенду —
|
||||||
* НЕ отправляется бэкенду — нужен только фронту, чтобы отличить «частичное
|
* нужен только фронту, чтобы отличить «частичное покрытие области» от
|
||||||
* покрытие области» от отдельного региона. Большинство потребителей читают
|
* отдельного региона. Большинство потребителей читают только id/label.
|
||||||
* только id/label.
|
|
||||||
*/
|
*/
|
||||||
readonly region: 66 | 77 | 50;
|
readonly region: 66 | 77;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
|
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
|
||||||
|
|
@ -73,41 +69,12 @@ export const OBLAST_CITIES: readonly OblastCity[] = [
|
||||||
{ id: "revda", label: "Ревда", region: 66 },
|
{ id: "revda", label: "Ревда", region: 66 },
|
||||||
{ id: "serov", label: "Серов", region: 66 },
|
{ id: "serov", label: "Серов", region: 66 },
|
||||||
{ id: "moscow", label: "Москва", region: 77 },
|
{ id: "moscow", label: "Москва", region: 77 },
|
||||||
// Московская область (регион 50) — 22 города, расширение 2026-09-16.
|
|
||||||
// Все получают жёлтый тир (COVERAGE_YELLOW_MIN_N): замер 120 проб/город,
|
|
||||||
// радиус 8 км, когорта = is_active + точные комнаты + площадь ±15% +
|
|
||||||
// scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты 17-51,
|
|
||||||
// доля проб с ≥12 аналогов 0.63-0.92 — у каждого города выше, чем у уже
|
|
||||||
// жёлтой Москвы (медиана 14, доля 0.57). Порядок — по убыванию медианы.
|
|
||||||
{ id: "vidnoe", label: "Видное", region: 50 },
|
|
||||||
{ id: "balashikha", label: "Балашиха", region: 50 },
|
|
||||||
{ id: "zheleznodorozhny", label: "Железнодорожный", region: 50 },
|
|
||||||
{ id: "lyubertsy", label: "Люберцы", region: 50 },
|
|
||||||
{ id: "elektrostal", label: "Электросталь", region: 50 },
|
|
||||||
{ id: "khimki", label: "Химки", region: 50 },
|
|
||||||
{ id: "domodedovo", label: "Домодедово", region: 50 },
|
|
||||||
{ id: "noginsk", label: "Ногинск", region: 50 },
|
|
||||||
{ id: "reutov", label: "Реутов", region: 50 },
|
|
||||||
{ id: "dolgoprudny", label: "Долгопрудный", region: 50 },
|
|
||||||
{ id: "krasnogorsk", label: "Красногорск", region: 50 },
|
|
||||||
{ id: "odintsovo", label: "Одинцово", region: 50 },
|
|
||||||
{ id: "podolsk", label: "Подольск", region: 50 },
|
|
||||||
{ id: "sergiev_posad", label: "Сергиев Посад", region: 50 },
|
|
||||||
{ id: "korolev", label: "Королёв", region: 50 },
|
|
||||||
{ id: "mytishchi", label: "Мытищи", region: 50 },
|
|
||||||
{ id: "orekhovo_zuevo", label: "Орехово-Зуево", region: 50 },
|
|
||||||
{ id: "serpukhov", label: "Серпухов", region: 50 },
|
|
||||||
{ id: "ramenskoye", label: "Раменское", region: 50 },
|
|
||||||
{ id: "naro_fominsk", label: "Наро-Фоминск", region: 50 },
|
|
||||||
{ id: "schelkovo", label: "Щёлково", region: 50 },
|
|
||||||
{ id: "kolomna", label: "Коломна", region: 50 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */
|
/** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */
|
||||||
export const REGION_LABELS: Record<66 | 77 | 50, string> = {
|
export const REGION_LABELS: Record<66 | 77, string> = {
|
||||||
66: "Свердловская область",
|
66: "Свердловская область",
|
||||||
77: "Москва",
|
77: "Москва",
|
||||||
50: "Московская область",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Только область (регион 66) — прежнее множество, до добавления Москвы. */
|
/** Только область (регион 66) — прежнее множество, до добавления Москвы. */
|
||||||
|
|
@ -115,20 +82,8 @@ export const OBLAST_66_CITIES: readonly OblastCity[] = OBLAST_CITIES.filter(
|
||||||
(c) => c.region === 66,
|
(c) => c.region === 66,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/** Москва одним объектом — тем же паттерном, что `DEFAULT_CITY = OBLAST_CITIES[0]` ниже. */
|
||||||
* Москва одним объектом. Раньше бралась по индексу последнего элемента
|
export const MOSCOW_CITY: OblastCity = OBLAST_CITIES[OBLAST_CITIES.length - 1];
|
||||||
* `OBLAST_CITIES` (работало, пока Москва была последней в списке) — после
|
|
||||||
* добавления 22 городов Московской области (регион 50) последним элементом
|
|
||||||
* стала Коломна, индекс молча указывал бы не на тот город. Ищем по
|
|
||||||
* стабильному `id`, а не по позиции в массиве.
|
|
||||||
*/
|
|
||||||
export const MOSCOW_CITY: OblastCity = (() => {
|
|
||||||
const city = OBLAST_CITIES.find((c) => c.id === "moscow");
|
|
||||||
if (!city) {
|
|
||||||
throw new Error("city-registry: 'moscow' отсутствует в OBLAST_CITIES");
|
|
||||||
}
|
|
||||||
return city;
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,7 @@ from scraper_kit.providers.cian.newbuilding import (
|
||||||
save_newbuilding_enrichment,
|
save_newbuilding_enrichment,
|
||||||
)
|
)
|
||||||
from scraper_kit.providers.cian.serp import CianScraper
|
from scraper_kit.providers.cian.serp import CianScraper
|
||||||
from scraper_kit.providers.domclick.serp import (
|
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper
|
||||||
ROOM_BUCKETS,
|
|
||||||
DomClickScraper,
|
|
||||||
geo_profile_for_region,
|
|
||||||
)
|
|
||||||
from scraper_kit.providers.yandex.serp import (
|
from scraper_kit.providers.yandex.serp import (
|
||||||
DEFAULT_PRICE_RANGES,
|
DEFAULT_PRICE_RANGES,
|
||||||
ROOM_PATH,
|
ROOM_PATH,
|
||||||
|
|
@ -282,146 +278,6 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
|
||||||
(56.8650, 60.6200, "Пионерский"),
|
(56.8650, 60.6200, "Пионерский"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ── Москва (77) и Московская область (50): якоря развёрток ──────────────────
|
|
||||||
#
|
|
||||||
# Продуктовое ядро Москвы — то же окно, что у REGIONS[77].bbox_product_core в
|
|
||||||
# backend/app/services/regions.py: lat 55.55..55.95, lon 37.30..37.90. Это ~44.5 км
|
|
||||||
# с юга на север и ~37.6 км с запада на восток (на широте 55.75° градус долготы =
|
|
||||||
# 62.6 км). Один центральный anchor, как у компактных городов области, покрыл бы
|
|
||||||
# отсюда круг радиусом 8 км — меньше пятой части ядра. Поэтому сетка.
|
|
||||||
MOSCOW_CORE_BBOX_DEG: tuple[float, float, float, float] = (55.55, 55.95, 37.30, 37.90)
|
|
||||||
|
|
||||||
# Радиус ОДНОГО anchor-запроса московских развёрток. 8 км — не оценка на глаз:
|
|
||||||
# столько намерено на прод-корпусе (35 552 активных вторичных лота region_code=77)
|
|
||||||
# у московской сетки центроидов, см. большой комментарий над `_MOSCOW_GRID_DEG` в
|
|
||||||
# backend/app/api/v1/trade_in.py (R=5..8 дают одинаковые 0.00% вне радиуса, при
|
|
||||||
# R=10 внутрь входит уже Наро-Фоминск). Кит не имеет права импортировать app.*
|
|
||||||
# (гейтится тестом), поэтому здесь осознанный дубль, а не второе независимое число.
|
|
||||||
#
|
|
||||||
# ВАЖНО: `radius_m` у run_avito_city_sweep/run_cian_city_sweep по умолчанию 1500 —
|
|
||||||
# строка расписания московской развёртки обязана передать 8000 явно, иначе круги
|
|
||||||
# соседних узлов не сомкнутся и между ними останутся непокрытые полосы.
|
|
||||||
MOSCOW_ANCHOR_RADIUS_M = 8000
|
|
||||||
|
|
||||||
# Шаг сетки согласован именно с этим радиусом: любая точка ядра должна лежать не
|
|
||||||
# дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего узла. Худшая точка — центр ячейки,
|
|
||||||
# расстояние до узла = hypot(шаг_lat/2, шаг_lon/2). При 0.10° по широте (11.13 км)
|
|
||||||
# и 0.15° по долготе (9.39 км) это hypot(5.56, 4.69) = 7.27 км < 8 км — с запасом
|
|
||||||
# 0.7 км. Узлы стоят по границам окна включительно (55.55/55.65/…/55.95 ×
|
|
||||||
# 37.30/37.45/…/37.90): 5 × 5 = 25 якорей, все внутри ядра, ни один не свисает
|
|
||||||
# наружу. Крупнее (0.15° × 0.20°) дало бы полудиагональ 10.5 км — дыры в покрытии;
|
|
||||||
# мельче — лишние прогоны страниц без нового покрытия.
|
|
||||||
_MOSCOW_GRID_LAT_STEP_DEG = 0.10
|
|
||||||
_MOSCOW_GRID_LON_STEP_DEG = 0.15
|
|
||||||
# Центр ядра сам является узлом сетки, и он же ПЕРВЫЙ в списке: get_city_anchor_point
|
|
||||||
# берёт anchors[0] опорной точкой гео-гарда штампа (см. её docstring), а из угла ядра
|
|
||||||
# гард мерил бы диагональ в 58 км и перестал бы что-либо отсекать.
|
|
||||||
_MOSCOW_CENTER_DEG: tuple[float, float] = (55.75, 37.60)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_moscow_anchor_grid() -> list[tuple[float, float, str]]:
|
|
||||||
"""Сетка якорей ядра Москвы: центр первым, остальные узлы — с юго-запада на север."""
|
|
||||||
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
|
||||||
lat_steps = round((lat_max - lat_min) / _MOSCOW_GRID_LAT_STEP_DEG)
|
|
||||||
lon_steps = round((lon_max - lon_min) / _MOSCOW_GRID_LON_STEP_DEG)
|
|
||||||
grid: list[tuple[float, float, str]] = []
|
|
||||||
for i in range(lat_steps + 1):
|
|
||||||
lat = round(lat_min + i * _MOSCOW_GRID_LAT_STEP_DEG, 4)
|
|
||||||
for j in range(lon_steps + 1):
|
|
||||||
lon = round(lon_min + j * _MOSCOW_GRID_LON_STEP_DEG, 4)
|
|
||||||
grid.append((lat, lon, f"Москва {lat:.2f}/{lon:.2f}"))
|
|
||||||
# sort стабилен: центр уезжает в начало, порядок остальных не меняется.
|
|
||||||
grid.sort(key=lambda a: (a[0], a[1]) != _MOSCOW_CENTER_DEG)
|
|
||||||
return grid
|
|
||||||
|
|
||||||
|
|
||||||
MOSCOW_ANCHORS: list[tuple[float, float, str]] = _build_moscow_anchor_grid()
|
|
||||||
|
|
||||||
# Московская область. Двухслойная сетка якорей; оба слоя измерены, ни одна координата
|
|
||||||
# не выдумана.
|
|
||||||
#
|
|
||||||
# Слой 1 — города-спутники (22). Координаты первых 12 продублированы из
|
|
||||||
# `_COVERAGE_NEGATIVE_CENTROIDS_DEG` в backend/app/api/v1/trade_in.py (тот же запрет
|
|
||||||
# на импорт app.* — править нужно оба места); остальные 10 (Домодедово … Наро-Фоминск)
|
|
||||||
# взяты прямым запросом к Nominatim /search (city=<НП>, state=Московская область)
|
|
||||||
# 15.09.2026, все вернулись как place=city/town.
|
|
||||||
#
|
|
||||||
# Слой 2 — кластеры лот-массы (22). Это НЕ центры НП, а центроиды активных объявлений
|
|
||||||
# области в ячейках 0.05°, добранные жадным покрытием поверх слоя 1; подпись — ближайший
|
|
||||||
# НП по обратному геокодированию (zoom=14), она справочная. Слой нужен потому, что одних
|
|
||||||
# городов мало: замер на проде 15.09.2026 по 70 996 активным объявлениям region_code=50
|
|
||||||
# показал, что 22 городских якоря радиусом 10 км накрывают лишь 73.8% лот-массы, а вместе
|
|
||||||
# со вторым слоем — 95.2%.
|
|
||||||
#
|
|
||||||
# Порядок в обоих слоях — по УБЫВАНИЮ измеренной лот-массы (в скобках у каждой строки:
|
|
||||||
# слой 1 — активных лотов в 10 км от якоря, слой 2 — прирост покрытия, который этот якорь
|
|
||||||
# дал жадному алгоритму). Это и есть замер, которого раньше не было.
|
|
||||||
#
|
|
||||||
# ВАЖНО: покрытие считано при радиусе MOSCOW_OBLAST_ANCHOR_RADIUS_M. Расписание, которое
|
|
||||||
# передаст меньший radius_m, получит дыры — 95.2% относятся именно к 10 км.
|
|
||||||
#
|
|
||||||
# Оговорка о смещении: лот-масса взята из УЖЕ собранного корпуса (разовая загрузка
|
|
||||||
# 10-12.09.2026). Там, где разовый сбор не добрал, масса занижена, поэтому 95.2% —
|
|
||||||
# оценка сверху по тому, что видно, а не доказательство полноты по генеральной
|
|
||||||
# совокупности. Первый регулярный прогон обязан пересчитать покрытие по своему улову.
|
|
||||||
MOSCOW_OBLAST_ANCHOR_RADIUS_M = 10000
|
|
||||||
|
|
||||||
MOSCOW_OBLAST_CITY_ANCHORS: list[tuple[float, float, str]] = [
|
|
||||||
(55.6767, 37.8931, "Люберцы"), # 7429
|
|
||||||
(55.7614, 37.8564, "Реутов"), # 6824
|
|
||||||
(55.7444, 38.0128, "Железнодорожный"), # 6465
|
|
||||||
(55.7969, 37.9386, "Балашиха"), # 6429
|
|
||||||
(55.8317, 37.3300, "Красногорск"), # 5556
|
|
||||||
(55.9142, 37.8256, "Королёв"), # 5208
|
|
||||||
(55.9116, 37.7308, "Мытищи"), # 4805
|
|
||||||
(55.8894, 37.4450, "Химки"), # 4701
|
|
||||||
(55.5519, 37.7133, "Видное"), # 4574
|
|
||||||
(55.6789, 37.2639, "Одинцово"), # 4077
|
|
||||||
(55.9206, 37.9916, "Щёлково"), # 4031
|
|
||||||
(55.9386, 37.5100, "Долгопрудный"), # 3506
|
|
||||||
(55.8554, 38.4412, "Ногинск"), # 2820
|
|
||||||
(55.7845, 38.4449, "Электросталь"), # 2462
|
|
||||||
(55.4312, 37.5450, "Подольск"), # 2453
|
|
||||||
(55.4368, 37.7681, "Домодедово"), # 2059
|
|
||||||
(56.3153, 38.1360, "Сергиев Посад"), # 1889
|
|
||||||
(55.5709, 38.2282, "Раменское"), # 1344
|
|
||||||
(55.0939, 38.7670, "Коломна"), # 1118
|
|
||||||
(54.9155, 37.4196, "Серпухов"), # 1059
|
|
||||||
(55.8062, 38.9810, "Орехово-Зуево"), # 824
|
|
||||||
(55.3843, 36.7235, "Наро-Фоминск"), # 802
|
|
||||||
]
|
|
||||||
|
|
||||||
MOSCOW_OBLAST_CLUSTER_ANCHORS: list[tuple[float, float, str]] = [
|
|
||||||
(55.9395, 37.1805, "Рузино"), # +1528
|
|
||||||
(56.0837, 37.9229, "Лесной"), # +1253
|
|
||||||
(55.2779, 38.7415, "Воскресенск"), # +1002
|
|
||||||
(55.5588, 37.0775, "Апрелевка"), # +974
|
|
||||||
(55.7860, 36.9874, "Писково"), # +928
|
|
||||||
(56.2883, 37.4825, "Яхрома"), # +887
|
|
||||||
(56.3704, 36.6872, "Клин"), # +865
|
|
||||||
(55.6113, 37.9765, "Октябрьский"), # +825
|
|
||||||
(56.1460, 37.0592, "Солнечногорск"), # +616
|
|
||||||
(55.8732, 38.1862, "Осеево"), # +597
|
|
||||||
(55.3705, 39.0622, "Егорьевск"), # +587
|
|
||||||
(54.8393, 38.1760, "Кашира"), # +585
|
|
||||||
(55.7768, 38.7096, "Павловский Посад"), # +494
|
|
||||||
(55.1608, 37.4732, "Чехов"), # +493
|
|
||||||
(56.0920, 37.4972, "Некрасовский"), # +440
|
|
||||||
(55.5773, 39.5299, "Шатура"), # +397
|
|
||||||
(56.7358, 37.1643, "Дубна"), # +392
|
|
||||||
(55.9134, 36.8293, "Истра"), # +389
|
|
||||||
(56.4933, 38.1792, "Богородское"), # +312
|
|
||||||
(54.9619, 39.0273, "Луховицы"), # +228
|
|
||||||
(55.7715, 37.2308, "Ильинское-Усово"), # +224
|
|
||||||
(56.2552, 37.9754, "Хотьково"), # +207
|
|
||||||
]
|
|
||||||
|
|
||||||
MOSCOW_OBLAST_ANCHORS: list[tuple[float, float, str]] = [
|
|
||||||
*MOSCOW_OBLAST_CITY_ANCHORS,
|
|
||||||
*MOSCOW_OBLAST_CLUSTER_ANCHORS,
|
|
||||||
]
|
|
||||||
|
|
||||||
# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
|
# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
|
||||||
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
|
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
|
||||||
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
|
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
|
||||||
|
|
@ -486,13 +342,6 @@ CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
|
||||||
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
|
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
|
||||||
"talitsa": [(57.0148, 63.7302, "Талица центр")],
|
"talitsa": [(57.0148, 63.7302, "Талица центр")],
|
||||||
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")],
|
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")],
|
||||||
# ── Москва (77) и Московская область (50) ────────────────────────────────
|
|
||||||
# Три записи вместо одной, чтобы строка расписания могла выбрать скоуп:
|
|
||||||
# только Москва, только область, либо обе сразу (у cian/yandex под это есть
|
|
||||||
# отдельные идентификаторы, у avito один слаг на всё — см. CITY_LOCATIONS).
|
|
||||||
"moskva": MOSCOW_ANCHORS,
|
|
||||||
"moskovskaya_oblast": MOSCOW_OBLAST_ANCHORS,
|
|
||||||
"moskva_i_mo": [*MOSCOW_ANCHORS, *MOSCOW_OBLAST_ANCHORS],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -506,14 +355,7 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] |
|
||||||
"""
|
"""
|
||||||
if city_slug is None:
|
if city_slug is None:
|
||||||
return None
|
return None
|
||||||
try:
|
return CITY_ANCHORS.get(city_slug)
|
||||||
return CITY_ANCHORS[city_slug]
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError(
|
|
||||||
f"get_city_anchors: неизвестный city_slug={city_slug!r}. Раньше здесь был "
|
|
||||||
"тихий откат на ЕКБ (None → EKB_ANCHORS у вызывающей стороны) — развёртка "
|
|
||||||
"чужого города молча собирала Екатеринбург. Заведи слаг в CITY_ANCHORS."
|
|
||||||
) from None
|
|
||||||
|
|
||||||
|
|
||||||
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
|
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
|
||||||
|
|
@ -541,17 +383,6 @@ def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
|
||||||
return (lat, lon)
|
return (lat, lon)
|
||||||
|
|
||||||
|
|
||||||
# Хост Циана — РЕГИОНАЛЬНЫЙ поддомен, а не городской: ekb.cian.ru общий для всей
|
|
||||||
# Свердловской области (все 45 городов ниже), www.cian.ru обслуживает Москву и МО.
|
|
||||||
# Почему полем CityLocation, а не вторым реестром "слаг → хост": хост и region= —
|
|
||||||
# один и тот же гео-скоуп ОДНОГО запроса, и разъехаться они не имеют права
|
|
||||||
# (region=1 на ekb.cian.ru — чужая либо пустая выдача, причём молча). Отдельная
|
|
||||||
# карта завела бы второй ключ в том же ключевом пространстве и ровно этот риск
|
|
||||||
# рассинхрона; поле в той же frozen-записи рассинхронизировать нечем.
|
|
||||||
CIAN_SVERDLOVSK_HOST = "https://ekb.cian.ru"
|
|
||||||
CIAN_MOSCOW_HOST = "https://www.cian.ru"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CityLocation:
|
class CityLocation:
|
||||||
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
|
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
|
||||||
|
|
@ -571,25 +402,11 @@ class CityLocation:
|
||||||
Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep
|
Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep
|
||||||
явно падает (ValueError), если city_slug известен, а yandex_rgid
|
явно падает (ValueError), если city_slug известен, а yandex_rgid
|
||||||
для него None.
|
для него None.
|
||||||
cian_host — поддомен Циана (см. CIAN_SVERDLOVSK_HOST выше). Дефолт —
|
|
||||||
свердловский: все 45 записей области остаются байт в байт, а
|
|
||||||
город ВНЕ Свердловской обл. обязан передать хост явно, иначе
|
|
||||||
region= уедет на чужой поддомен.
|
|
||||||
avito_slug_is_region
|
|
||||||
— True, если avito_slug — слаг РЕГИОНА, а не города (moskva_i_mo
|
|
||||||
покрывает Москву и всю область одним скоупом: отдельного слага
|
|
||||||
у области нет, замер 10-12.09.2026). Такой слаг НЕ появляется в
|
|
||||||
ссылках карточек (там /moskva/, /himki/, /balashiha/…), поэтому
|
|
||||||
per-card фильтр `avito_serp_ekb_only` на нём выбросил бы 100%
|
|
||||||
выдачи — run_avito_city_sweep выключает фильтр для таких
|
|
||||||
записей (AvitoScraper(filter_cards_by_city_slug=False)).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
avito_slug: str | None
|
avito_slug: str | None
|
||||||
cian_region_id: int
|
cian_region_id: int
|
||||||
yandex_rgid: int | None
|
yandex_rgid: int | None
|
||||||
cian_host: str = CIAN_SVERDLOVSK_HOST
|
|
||||||
avito_slug_is_region: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
|
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
|
||||||
|
|
@ -652,41 +469,14 @@ CITY_LOCATIONS: dict[str, CityLocation] = {
|
||||||
"verkhoturye": CityLocation(None, 174499, 328251),
|
"verkhoturye": CityLocation(None, 174499, 328251),
|
||||||
# ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
|
# ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
|
||||||
"mikhaylovsk": CityLocation(None, 175332, None),
|
"mikhaylovsk": CityLocation(None, 175332, None),
|
||||||
# ── Москва (77) и Московская область (50) ──────────────────────────────
|
|
||||||
# Значения проверены живьём 10-12.09.2026 (scripts/local-avito-msk/collect.py):
|
|
||||||
# • avito — слаг ОДИН на оба региона (moskva_i_mo), отдельного слага у области
|
|
||||||
# нет; деление на 77/50 происходит позже, на импорте, по полигонам
|
|
||||||
# (app.services.regions.region_for_point), а не по слагу.
|
|
||||||
# • cian — region=1 Москва (92 817 лотов), 4593 область (60 231), -1 обе
|
|
||||||
# вместе (153 049). Два параметра region НЕ объединяются (побеждает
|
|
||||||
# последний), поэтому «Москва+область» — это именно -1, а не пара значений.
|
|
||||||
# • yandex — rgid 587795 Москва (18 705), 587654 область (14 242), 741964 обе
|
|
||||||
# вместе (31 074); контроль ЕКБ 559132 (4 060).
|
|
||||||
# Объединённый скоуп безопасен именно потому, что region_code лота считается по
|
|
||||||
# полигонам, а не по слагу развёртки: штамп региона не соврёт, даже когда в одном
|
|
||||||
# прогоне приезжают лоты обоих регионов.
|
|
||||||
"moskva": CityLocation("moskva_i_mo", 1, 587795, CIAN_MOSCOW_HOST, True),
|
|
||||||
"moskovskaya_oblast": CityLocation("moskva_i_mo", 4593, 587654, CIAN_MOSCOW_HOST, True),
|
|
||||||
"moskva_i_mo": CityLocation("moskva_i_mo", -1, 741964, CIAN_MOSCOW_HOST, True),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_city_location(city_slug: str | None) -> CityLocation | None:
|
def get_city_location(city_slug: str | None) -> CityLocation | None:
|
||||||
"""CityLocation по slug (CITY_LOCATIONS). None → None (=ЕКБ-дефолт).
|
"""CityLocation по slug (CITY_LOCATIONS). Неизвестный slug/None → None (=ЕКБ-дефолт)."""
|
||||||
|
|
||||||
Неизвестный slug — ValueError, НЕ ЕКБ-дефолт: тихий откат означал бы, что
|
|
||||||
развёртка с опечаткой в `default_params["city"]` собирает Екатеринбург под
|
|
||||||
чужим именем города, со штатными счётчиками и без аномалий в логах.
|
|
||||||
"""
|
|
||||||
if city_slug is None:
|
if city_slug is None:
|
||||||
return None
|
return None
|
||||||
try:
|
return CITY_LOCATIONS.get(city_slug)
|
||||||
return CITY_LOCATIONS[city_slug]
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError(
|
|
||||||
f"get_city_location: неизвестный city_slug={city_slug!r} — развёртка "
|
|
||||||
"откатилась бы на ЕКБ. Заведи запись в CITY_LOCATIONS."
|
|
||||||
) from None
|
|
||||||
|
|
||||||
|
|
||||||
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
|
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
|
||||||
|
|
@ -748,19 +538,11 @@ CITY_DISPLAY_NAMES: dict[str, str] = {
|
||||||
"verkhoturye": "Верхотурье",
|
"verkhoturye": "Верхотурье",
|
||||||
"talitsa": "Талица",
|
"talitsa": "Талица",
|
||||||
"novaya_lyalya": "Новая Ляля",
|
"novaya_lyalya": "Новая Ляля",
|
||||||
"moskva": "Москва",
|
|
||||||
}
|
}
|
||||||
EKATERINBURG_CITY_NAME = "Екатеринбург"
|
EKATERINBURG_CITY_NAME = "Екатеринбург"
|
||||||
|
|
||||||
# Скоупы, внутри которых лоты принадлежат РАЗНЫМ городам: один штамп city на такой
|
|
||||||
# batch был бы ложью (у области это Химки/Балашиха/Подольск/…, у объединённого
|
|
||||||
# скоупа — ещё и Москва). resolve_city_name возвращает для них None, save_listings
|
|
||||||
# пишет city=NULL, и город доезжает позже из адреса (геокодер), а не из слага
|
|
||||||
# развёртки. Регион определяется полигонами, поэтому region_code от этого не страдает.
|
|
||||||
MULTI_CITY_SLUGS: frozenset[str] = frozenset({"moskovskaya_oblast", "moskva_i_mo"})
|
|
||||||
|
|
||||||
|
def resolve_city_name(city_slug: str | None) -> str:
|
||||||
def resolve_city_name(city_slug: str | None) -> str | None:
|
|
||||||
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
|
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
|
||||||
|
|
||||||
city_slug=None → Екатеринбург. Это НЕ заглушка «не знаем» — это симметрия с
|
city_slug=None → Екатеринбург. Это НЕ заглушка «не знаем» — это симметрия с
|
||||||
|
|
@ -769,26 +551,11 @@ def resolve_city_name(city_slug: str | None) -> str | None:
|
||||||
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" —
|
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" —
|
||||||
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ — нет».
|
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ — нет».
|
||||||
Неизвестный slug (не в CITY_DISPLAY_NAMES) — тоже ЕКБ-дефолт, тем же путём, что и
|
Неизвестный slug (не в CITY_DISPLAY_NAMES) — тоже ЕКБ-дефолт, тем же путём, что и
|
||||||
get_city_location/get_city_anchors (оба теперь падают на неизвестном slug'е).
|
get_city_location/get_city_anchors для неизвестных slug'ов.
|
||||||
|
|
||||||
Слаг из MULTI_CITY_SLUGS → None: в таком batch'е городов несколько, и любой
|
|
||||||
один штамп был бы ложью (save_listings при city=None просто не трогает поле, а
|
|
||||||
гео-гард штампа там инертен — см. `_geo_guard_active` в scraper_kit.base).
|
|
||||||
Неизвестный slug → ValueError, а не «Екатеринбург»: молчаливый ЕКБ-штамп на
|
|
||||||
лотах чужого города — ровно тот дефект, из-за которого сломанную развёртку вне
|
|
||||||
ЕКБ было не отличить от исправной по логам.
|
|
||||||
"""
|
"""
|
||||||
if city_slug is None:
|
if city_slug is None:
|
||||||
return EKATERINBURG_CITY_NAME
|
return EKATERINBURG_CITY_NAME
|
||||||
if city_slug in MULTI_CITY_SLUGS:
|
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return CITY_DISPLAY_NAMES[city_slug]
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError(
|
|
||||||
f"resolve_city_name: неизвестный city_slug={city_slug!r} — раньше здесь "
|
|
||||||
"молча возвращался 'Екатеринбург'. Заведи имя в CITY_DISPLAY_NAMES."
|
|
||||||
) from None
|
|
||||||
|
|
||||||
|
|
||||||
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
|
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
|
||||||
|
|
@ -815,19 +582,6 @@ def resolve_city_name(city_slug: str | None) -> str | None:
|
||||||
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
|
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
|
||||||
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
|
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
|
||||||
"verkhnyaya_pyshma": 8.0,
|
"verkhnyaya_pyshma": 8.0,
|
||||||
# Москва: гард меряет от ЦЕНТРА ядра (MOSCOW_ANCHORS[0] = 55.75/37.60 — центр
|
|
||||||
# стоит первым специально, см. _build_moscow_anchor_grid). Дальний угол ядра —
|
|
||||||
# hypot(22.25, 18.78) = 29.1 км, поэтому дефолтные 15 км срезали бы city у всего
|
|
||||||
# за МКАД и части внутри неё (Бутово, Митино, Некрасовка, Солнцево — легитимная
|
|
||||||
# Москва, ~20-27 км от центра). 30 км = 29.1 + ~0.9 км запаса на адреса, чьи
|
|
||||||
# координаты чуть выходят за рамку окна.
|
|
||||||
# Цена решения: в 30 км попадают и ближние города области (Химки ~20, Красногорск
|
|
||||||
# ~16, Люберцы ~14, Реутов ~12 км) — их лоты, если их захватит московская выдача,
|
|
||||||
# получат city="Москва". РЕГИОН при этом не врёт: region_code считается по
|
|
||||||
# полигонам (app.services.regions.region_for_point), а не по штампу города.
|
|
||||||
# Развёрток "moskovskaya_oblast"/"moskva_i_mo" это не касается вовсе: у них
|
|
||||||
# city=None (MULTI_CITY_SLUGS), а при city=None гард в save_listings инертен.
|
|
||||||
"moskva": 30.0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1370,36 +1124,6 @@ class CitySweepCounters:
|
||||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||||
|
|
||||||
|
|
||||||
def _require_region_scope(
|
|
||||||
source_kind: str,
|
|
||||||
region_code: int,
|
|
||||||
anchors: list[tuple[float, float, str]] | None,
|
|
||||||
) -> None:
|
|
||||||
"""Регион вне 66 обязан приходить СО СВОИМИ anchors — иначе это тихий сбор ЕКБ.
|
|
||||||
|
|
||||||
`anchors=None` у всех трёх anchor-свипов означает "ЕКБ по умолчанию"
|
|
||||||
(EKB_ANCHORS у avito/cian, центральная точка combos-режима у yandex). Планировщик
|
|
||||||
берёт anchors из `CITY_ANCHORS[default_params["city"]]`, а этот реестр сегодня
|
|
||||||
покрывает только Свердловскую область, поэтому строка расписания вида
|
|
||||||
`avito_city_sweep_moscow` с `{"region_code": 77}` собрала бы ЕКАТЕРИНБУРГ и
|
|
||||||
проштамповала его московским регионом — со штатными счётчиками и без единой
|
|
||||||
аномалии в логах. Падаем до первого запроса.
|
|
||||||
|
|
||||||
Москва (77) и МО (50) попали в прод-корпус разовым ручным коллектором
|
|
||||||
(`scripts/local-avito-msk/collect.py` → схема `msk_raw` → `app.tasks.msk_raw_import`),
|
|
||||||
а не свипами. Регулярный сбор по ним требует сперва завести города в
|
|
||||||
CITY_ANCHORS/CITY_LOCATIONS (anchors + per-provider идентификаторы), и только
|
|
||||||
потом строки расписаний.
|
|
||||||
"""
|
|
||||||
if region_code == DEFAULT_REGION_CODE or anchors:
|
|
||||||
return
|
|
||||||
raise ValueError(
|
|
||||||
f"{source_kind}: region_code={region_code} без anchors города — развёртка "
|
|
||||||
"откатилась бы на ЕКБ и проштамповала его чужим регионом. Заведи город в "
|
|
||||||
"CITY_ANCHORS/CITY_LOCATIONS (и передай anchors) либо убери region_code."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_avito_city_sweep(
|
async def run_avito_city_sweep(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1440,7 +1164,6 @@ async def run_avito_city_sweep(
|
||||||
# AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города,
|
# AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города,
|
||||||
# а avito_slug (#12) — в путь URL, скоупя сам запрос на город-цель вместо ЕКБ.
|
# а avito_slug (#12) — в путь URL, скоупя сам запрос на город-цель вместо ЕКБ.
|
||||||
# None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже).
|
# None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже).
|
||||||
_require_region_scope("avito_city_sweep", region_code, anchors)
|
|
||||||
_anchors = anchors if anchors is not None else EKB_ANCHORS
|
_anchors = anchors if anchors is not None else EKB_ANCHORS
|
||||||
|
|
||||||
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
|
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
|
||||||
|
|
@ -1505,9 +1228,6 @@ async def run_avito_city_sweep(
|
||||||
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
|
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
|
||||||
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
|
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
|
||||||
_avito_slug = _loc.avito_slug if _loc else city_slug
|
_avito_slug = _loc.avito_slug if _loc else city_slug
|
||||||
# Слаг региона (moskva_i_mo) в ссылках карточек не встречается — per-card фильтр
|
|
||||||
# по нему выбросил бы всю выдачу. См. CityLocation.avito_slug_is_region.
|
|
||||||
_avito_card_filter = not (_loc.avito_slug_is_region if _loc is not None else False)
|
|
||||||
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
|
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
|
||||||
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
|
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
|
||||||
_city_name = resolve_city_name(city_slug)
|
_city_name = resolve_city_name(city_slug)
|
||||||
|
|
@ -1655,10 +1375,7 @@ async def run_avito_city_sweep(
|
||||||
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
|
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
|
||||||
# переопределяет scraper._browser напрямую, минуя __aenter__.
|
# переопределяет scraper._browser напрямую, минуя __aenter__.
|
||||||
scraper = AvitoScraper(
|
scraper = AvitoScraper(
|
||||||
config,
|
config, target_city_slug=_avito_slug, proxy_provider=proxy_provider
|
||||||
target_city_slug=_avito_slug,
|
|
||||||
proxy_provider=proxy_provider,
|
|
||||||
filter_cards_by_city_slug=_avito_card_filter,
|
|
||||||
)
|
)
|
||||||
if browser_mode:
|
if browser_mode:
|
||||||
scraper._browser = shared_bf
|
scraper._browser = shared_bf
|
||||||
|
|
@ -2541,7 +2258,6 @@ async def run_yandex_city_sweep(
|
||||||
|
|
||||||
Возвращает YandexCitySweepCounters. mark_done вызывается ВСЕГДА (кроме cancel/ban/fatal).
|
Возвращает YandexCitySweepCounters. mark_done вызывается ВСЕГДА (кроме cancel/ban/fatal).
|
||||||
"""
|
"""
|
||||||
_require_region_scope("yandex_city_sweep", region_code, anchors)
|
|
||||||
# EKB center — единственный anchor для citywide combos sweep.
|
# EKB center — единственный anchor для citywide combos sweep.
|
||||||
# anchors-параметр принимается для backward-compat тестов.
|
# anchors-параметр принимается для backward-compat тестов.
|
||||||
if anchors is not None:
|
if anchors is not None:
|
||||||
|
|
@ -3188,7 +2904,6 @@ async def run_cian_city_sweep(
|
||||||
Cooperative cancel: runs.is_cancelled(db, run_id) перед каждым anchor.
|
Cooperative cancel: runs.is_cancelled(db, run_id) перед каждым anchor.
|
||||||
mark_done вызывается ВСЕГДА (finally outer).
|
mark_done вызывается ВСЕГДА (finally outer).
|
||||||
"""
|
"""
|
||||||
_require_region_scope("cian_city_sweep", region_code, anchors)
|
|
||||||
_anchors = anchors if anchors is not None else EKB_ANCHORS
|
_anchors = anchors if anchors is not None else EKB_ANCHORS
|
||||||
|
|
||||||
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
|
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
|
||||||
|
|
@ -3330,7 +3045,6 @@ async def run_cian_city_sweep(
|
||||||
config,
|
config,
|
||||||
proxy_provider=proxy_provider,
|
proxy_provider=proxy_provider,
|
||||||
city_region_id=(_loc.cian_region_id if _loc else None),
|
city_region_id=(_loc.cian_region_id if _loc else None),
|
||||||
city_host=(_loc.cian_host if _loc else None),
|
|
||||||
) as scraper:
|
) as scraper:
|
||||||
anchor_lots = await scraper.fetch_around_multi_room(
|
anchor_lots = await scraper.fetch_around_multi_room(
|
||||||
_a_lat,
|
_a_lat,
|
||||||
|
|
@ -4808,14 +4522,6 @@ async def run_domclick_city_sweep(
|
||||||
|
|
||||||
Возвращает DomClickCitySweepCounters.
|
Возвращает DomClickCitySweepCounters.
|
||||||
"""
|
"""
|
||||||
# Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
|
|
||||||
# GUID, aids, страница прогрева QRATOR, bbox гарда, имя города) — см.
|
|
||||||
# providers/domclick/serp.py DOMCLICK_GEO_PROFILES. На неизвестном коде региона
|
|
||||||
# падаем ДО первого запроса, а не откатываемся на ЕКБ: тихий fallback и есть тот
|
|
||||||
# баг, ради которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал
|
|
||||||
# его под чужим region_code молча, со штатными счётчиками.
|
|
||||||
_geo_profile = geo_profile_for_region(region_code)
|
|
||||||
|
|
||||||
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
|
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
|
||||||
counters = DomClickCitySweepCounters()
|
counters = DomClickCitySweepCounters()
|
||||||
|
|
||||||
|
|
@ -4907,10 +4613,7 @@ async def run_domclick_city_sweep(
|
||||||
"""Единственная citywide-фаза: fetch_city + save."""
|
"""Единственная citywide-фаза: fetch_city + save."""
|
||||||
nonlocal lots
|
nonlocal lots
|
||||||
async with DomClickScraper(
|
async with DomClickScraper(
|
||||||
config,
|
config, proxy_provider=proxy_provider, cookies=cookies
|
||||||
proxy_provider=proxy_provider,
|
|
||||||
cookies=cookies,
|
|
||||||
geo_profile=_geo_profile,
|
|
||||||
) as _scraper:
|
) as _scraper:
|
||||||
_scraper_ref.append(_scraper)
|
_scraper_ref.append(_scraper)
|
||||||
if request_delay_sec is not None:
|
if request_delay_sec is not None:
|
||||||
|
|
@ -4937,10 +4640,11 @@ async def run_domclick_city_sweep(
|
||||||
)
|
)
|
||||||
counters.lots_fetched += len(lots)
|
counters.lots_fetched += len(lots)
|
||||||
if lots:
|
if lots:
|
||||||
# Имя города — из профиля региона, а не из сравнения с vestigial
|
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
|
||||||
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
|
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
|
||||||
# У области (50) city_name=None — одного города нет, угадывать нечего.
|
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
|
||||||
_dc_city = _geo_profile.city_name
|
# угадывается.
|
||||||
|
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
|
||||||
inserted, updated = save_listings(
|
inserted, updated = save_listings(
|
||||||
db,
|
db,
|
||||||
lots,
|
lots,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ except ImportError: # pragma: no cover - в проде backend-env sentry_sdk
|
||||||
|
|
||||||
from scraper_kit.orchestration import runs as _kit_runs
|
from scraper_kit.orchestration import runs as _kit_runs
|
||||||
from scraper_kit.orchestration.pipeline import (
|
from scraper_kit.orchestration.pipeline import (
|
||||||
DEFAULT_REGION_CODE,
|
|
||||||
get_city_anchors,
|
get_city_anchors,
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
run_avito_full_load,
|
run_avito_full_load,
|
||||||
|
|
@ -881,37 +880,6 @@ def _interval_minutes(params: dict[str, Any], *, param: str = "interval_minutes"
|
||||||
return None if raw is None else int(raw)
|
return None if raw is None else int(raw)
|
||||||
|
|
||||||
|
|
||||||
# Kit не импортирует `app.*` (contracts.py — граница strangler'а, см. её docstring),
|
|
||||||
# поэтому реестр `app.services.regions.REGIONS` сюда не дотянуть напрямую. Список
|
|
||||||
# ниже — суженная копия ЕГО КЛЮЧЕЙ только для отказа мусорного region_code из
|
|
||||||
# default_params; она не заменяет REGIONS и не должна расти самостоятельно — при
|
|
||||||
# добавлении региона в REGIONS обновить и здесь.
|
|
||||||
_KNOWN_REGION_CODES: frozenset[int] = frozenset({50, 66, 77})
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_region_code(params: dict[str, Any]) -> int:
|
|
||||||
"""default_params["region_code"] → валидированный int для run_*.
|
|
||||||
|
|
||||||
Ключа нет → DEFAULT_REGION_CODE (66, тот же дефолт что и у run_*-функций
|
|
||||||
pipeline.py) — прежнее поведение всех 123 существующих строк расписания не
|
|
||||||
меняется. Значение не парсится в int ИЛИ отсутствует в _KNOWN_REGION_CODES →
|
|
||||||
ValueError: явный отказ прогона (падает сквозь job в `_dispatch`'s
|
|
||||||
`except Exception` → `logger.exception`, тот же путь, что и любой другой
|
|
||||||
crash хендлера), а не тихая запись мусора в listings.region_code.
|
|
||||||
"""
|
|
||||||
raw = params.get("region_code", DEFAULT_REGION_CODE)
|
|
||||||
try:
|
|
||||||
region_code = int(raw)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError(f"region_code={raw!r} из default_params не приводится к int") from exc
|
|
||||||
if region_code not in _KNOWN_REGION_CODES:
|
|
||||||
raise ValueError(
|
|
||||||
f"region_code={region_code} отсутствует в реестре регионов "
|
|
||||||
f"(известны: {sorted(_KNOWN_REGION_CODES)})"
|
|
||||||
)
|
|
||||||
return region_code
|
|
||||||
|
|
||||||
|
|
||||||
# Нижний порог defer'а (#1522): пропуск обязан пережить несколько get_due_schedules,
|
# Нижний порог defer'а (#1522): пропуск обязан пережить несколько get_due_schedules,
|
||||||
# иначе pre-check снова гоняется каждый тик. 3 тика = минимум один промах даже при
|
# иначе pre-check снова гоняется каждый тик. 3 тика = минимум один промах даже при
|
||||||
# рассинхроне часов планировщика и БД на такт в любую сторону.
|
# рассинхроне часов планировщика и БД на такт в любую сторону.
|
||||||
|
|
@ -1058,10 +1026,8 @@ async def _job_avito_city_sweep(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
) -> None:
|
) -> None:
|
||||||
# #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors
|
# #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors
|
||||||
# города вместо EKB_ANCHORS. Ключа нет вовсе → anchors=None → citywide-свип ЕКБ
|
# города вместо EKB_ANCHORS. Отсутствует/неизвестен → get_city_anchors вернёт None →
|
||||||
# (прежнее поведение расписаний без city). Ключ ЕСТЬ, но слаг неизвестен →
|
# run_avito_city_sweep сам падает на EKB_ANCHORS (прежнее поведение без city).
|
||||||
# get_city_anchors падает с ValueError: опечатка в расписании раньше тихо давала
|
|
||||||
# прогон по Екатеринбургу под именем чужого города.
|
|
||||||
city = params.get("city")
|
city = params.get("city")
|
||||||
anchors = get_city_anchors(city) if city else None
|
anchors = get_city_anchors(city) if city else None
|
||||||
await run_avito_city_sweep(
|
await run_avito_city_sweep(
|
||||||
|
|
@ -1081,7 +1047,6 @@ async def _job_avito_city_sweep(
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
enrich_houses=bool(params.get("enrich_houses", True)),
|
enrich_houses=bool(params.get("enrich_houses", True)),
|
||||||
radius_m=int(params.get("radius_m", 1500)),
|
radius_m=int(params.get("radius_m", 1500)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
||||||
# имя якоря, оно не зависит от количества якорей, поэтому в отличие от
|
# имя якоря, оно не зависит от количества якорей, поэтому в отличие от
|
||||||
# combo-чекпоинта yandex-свипа гарда по числу якорей здесь не требуется.
|
# combo-чекпоинта yandex-свипа гарда по числу якорей здесь не требуется.
|
||||||
|
|
@ -1101,7 +1066,6 @@ async def _job_avito_newbuilding_sweep(
|
||||||
proxy_provider=ctx.proxy_provider,
|
proxy_provider=ctx.proxy_provider,
|
||||||
pages=int(params.get("pages", 20)),
|
pages=int(params.get("pages", 20)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
# #3074: подхват страниц у оборванного предшественника — см.
|
# #3074: подхват страниц у оборванного предшественника — см.
|
||||||
# _job_avito_city_sweep выше.
|
# _job_avito_city_sweep выше.
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
|
|
@ -1146,7 +1110,6 @@ async def _job_avito_full_load(
|
||||||
concurrency=int(params.get("concurrency", 5)),
|
concurrency=int(params.get("concurrency", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
secondary_only=bool(params.get("secondary_only", True)),
|
secondary_only=bool(params.get("secondary_only", True)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
incremental_days=incremental_days,
|
incremental_days=incremental_days,
|
||||||
)
|
)
|
||||||
|
|
@ -1167,7 +1130,6 @@ async def _job_avito_full_load_exhaustive(
|
||||||
concurrency=int(params.get("concurrency", 5)),
|
concurrency=int(params.get("concurrency", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
secondary_only=bool(params.get("secondary_only", True)),
|
secondary_only=bool(params.get("secondary_only", True)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
incremental_days=None,
|
incremental_days=None,
|
||||||
)
|
)
|
||||||
|
|
@ -1197,7 +1159,6 @@ async def _job_yandex_city_sweep(
|
||||||
radius_m=int(params.get("radius_m", 1500)),
|
radius_m=int(params.get("radius_m", 1500)),
|
||||||
enrich_address=bool(params.get("enrich_address", True)),
|
enrich_address=bool(params.get("enrich_address", True)),
|
||||||
segments=params.get("segments"),
|
segments=params.get("segments"),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1223,7 +1184,6 @@ async def _job_cian_city_sweep(
|
||||||
detail_top_n=int(params.get("detail_top_n", 10)),
|
detail_top_n=int(params.get("detail_top_n", 10)),
|
||||||
enrich_houses=bool(params.get("enrich_houses", True)),
|
enrich_houses=bool(params.get("enrich_houses", True)),
|
||||||
newbuilding_only=bool(params.get("newbuilding_only", True)),
|
newbuilding_only=bool(params.get("newbuilding_only", True)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
||||||
# имя якоря, от их количества не зависит, поэтому гарда как у combo-
|
# имя якоря, от их количества не зависит, поэтому гарда как у combo-
|
||||||
# чекпоинта яндекса здесь не требуется.
|
# чекпоинта яндекса здесь не требуется.
|
||||||
|
|
@ -1246,7 +1206,6 @@ async def _job_cian_full_load(
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 4.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 4.0)),
|
||||||
enrich_detail=bool(params.get("enrich_detail", False)),
|
enrich_detail=bool(params.get("enrich_detail", False)),
|
||||||
detail_top_n=int(params.get("detail_top_n", 0)),
|
detail_top_n=int(params.get("detail_top_n", 0)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1265,7 +1224,6 @@ async def _job_domclick_city_sweep(
|
||||||
rooms=params.get("rooms"),
|
rooms=params.get("rooms"),
|
||||||
pages=int(params.get("pages_per_anchor", 5)),
|
pages=int(params.get("pages_per_anchor", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,23 @@
|
||||||
"""scraper_kit.pricing — общие примитивы ценового партиционирования SERP-обхода.
|
"""scraper_kit.pricing — общие примитивы ценового партиционирования SERP-обхода.
|
||||||
|
|
||||||
Два слоя, намеренно разделённые:
|
Единый движок адаптивной бисекции «комнатность × цена» для всех провайдеров
|
||||||
|
(avito / cian / yandex / domclick). См. `bisection.py`.
|
||||||
* `bisection.py` — боевой async-движок обхода `walk_price_range`: ходит в сеть
|
|
||||||
через provider-callback'и, пагинирует листья. `BisectionStats` — счётчики
|
|
||||||
усечения для уже существующих свипов.
|
|
||||||
* `planner.py` — СИНХРОННЫЙ ЧИСТЫЙ планировщик `plan_price_corridors`: на вход
|
|
||||||
«сколько позиций в коридоре», на выход список коридоров + `PlanStats`.
|
|
||||||
Геометрическая середина `sqrt(lo*hi)` (цены логнормальны) и предохранители
|
|
||||||
`MAX_DEPTH` / `MIN_WIDTH_RATIO`; перенесён из обкатанного живьём
|
|
||||||
`scripts/local-avito-msk/collect.py`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from scraper_kit.pricing.bisection import (
|
from scraper_kit.pricing.bisection import (
|
||||||
BisectionConfig,
|
BisectionConfig,
|
||||||
BisectionStats,
|
|
||||||
ProbeFailPolicy,
|
ProbeFailPolicy,
|
||||||
ProbeResult,
|
ProbeResult,
|
||||||
done_range_skipper,
|
done_range_skipper,
|
||||||
walk_price_range,
|
walk_price_range,
|
||||||
)
|
)
|
||||||
from scraper_kit.pricing.planner import (
|
|
||||||
EKB_REGION_CODE,
|
|
||||||
MAX_DEPTH,
|
|
||||||
MIN_WIDTH_RATIO,
|
|
||||||
PRICE_CEIL,
|
|
||||||
PRICE_FLOOR,
|
|
||||||
PRICE_PROBE_START,
|
|
||||||
Corridor,
|
|
||||||
CountFn,
|
|
||||||
PlanStats,
|
|
||||||
PricePlan,
|
|
||||||
geometric_mid,
|
|
||||||
plan_price_corridors,
|
|
||||||
price_bisection_enabled,
|
|
||||||
width_ratio,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EKB_REGION_CODE",
|
|
||||||
"MAX_DEPTH",
|
|
||||||
"MIN_WIDTH_RATIO",
|
|
||||||
"PRICE_CEIL",
|
|
||||||
"PRICE_FLOOR",
|
|
||||||
"PRICE_PROBE_START",
|
|
||||||
"BisectionConfig",
|
"BisectionConfig",
|
||||||
"BisectionStats",
|
|
||||||
"Corridor",
|
|
||||||
"CountFn",
|
|
||||||
"PlanStats",
|
|
||||||
"PricePlan",
|
|
||||||
"ProbeFailPolicy",
|
"ProbeFailPolicy",
|
||||||
"ProbeResult",
|
"ProbeResult",
|
||||||
"done_range_skipper",
|
"done_range_skipper",
|
||||||
"geometric_mid",
|
|
||||||
"plan_price_corridors",
|
|
||||||
"price_bisection_enabled",
|
|
||||||
"walk_price_range",
|
"walk_price_range",
|
||||||
"width_ratio",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -88,63 +88,6 @@ class ProbeResult:
|
||||||
payload: Any = None
|
payload: Any = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BisectionStats:
|
|
||||||
"""Счётчики одного обхода — чтобы усечение выдачи было ЧИСЛОМ, а не тишиной.
|
|
||||||
|
|
||||||
Движок делит диапазон, пока `count > cap`, но деление не бесконечно: упёрлись
|
|
||||||
в `min_bracket` или в `max_depth` — и переполненный диапазон всё равно уходит
|
|
||||||
в `on_leaf`, где пагинация штатно дорежет его до `cap`. Разница `count - cap`
|
|
||||||
в этот момент теряется БЕЗ СЛЕДА: по Свердловской области это никогда не
|
|
||||||
срабатывало (вторичка влезает в сид-брекеты), а по Москве и области, где
|
|
||||||
лотов десятки тысяч, прогон забирал верхушку и рапортовал успех.
|
|
||||||
|
|
||||||
Объект МУТАБЕЛЕН и передаётся в `walk_price_range` опционально: по умолчанию
|
|
||||||
``None`` — ни одной новой ветки в обходе, дерево рекурсии байт-в-байт прежнее
|
|
||||||
(golden-parity провайдеров не двигается). Вызывающий складывает
|
|
||||||
`missed_estimate` в счётчики прогона.
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Сколько раз дёрнули provider-probe (стоимость планирования в запросах).
|
|
||||||
probes: int = 0
|
|
||||||
#: Сколько диапазонов дошло до пагинации.
|
|
||||||
leaves: int = 0
|
|
||||||
#: Листья, где `count > cap` — поделить не вышло, выдача будет обрезана.
|
|
||||||
truncated_leaves: int = 0
|
|
||||||
#: Оценка недобора: сумма `count - cap` по усечённым листьям.
|
|
||||||
missed_estimate: int = 0
|
|
||||||
#: Листья с непрочитанным счётчиком (полнота недоказуема, не то же, что 0).
|
|
||||||
unknown_leaves: int = 0
|
|
||||||
#: Диапазоны, брошенные по ProbeFailPolicy.SKIP — тоже дыра в покрытии.
|
|
||||||
skipped_probe_fail: int = 0
|
|
||||||
#: Переходы в «пагинировать-до-пустоты» без знания total (yandex DEGRADE).
|
|
||||||
degraded: int = 0
|
|
||||||
max_depth_reached: int = 0
|
|
||||||
|
|
||||||
def record_probe(self, depth: int) -> None:
|
|
||||||
self.probes += 1
|
|
||||||
self.max_depth_reached = max(self.max_depth_reached, depth)
|
|
||||||
|
|
||||||
def record_leaf(self, count: int | None, cap: int) -> None:
|
|
||||||
self.leaves += 1
|
|
||||||
if count is None:
|
|
||||||
self.unknown_leaves += 1
|
|
||||||
return
|
|
||||||
if count > cap:
|
|
||||||
self.truncated_leaves += 1
|
|
||||||
self.missed_estimate += count - cap
|
|
||||||
|
|
||||||
@property
|
|
||||||
def complete(self) -> bool:
|
|
||||||
"""Обход покрыл выборку целиком — ни усечений, ни пропусков, ни слепых листьев."""
|
|
||||||
return (
|
|
||||||
self.truncated_leaves == 0
|
|
||||||
and self.unknown_leaves == 0
|
|
||||||
and self.skipped_probe_fail == 0
|
|
||||||
and self.degraded == 0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BisectionConfig:
|
class BisectionConfig:
|
||||||
"""Per-provider константы/политики бисекции (все различия провайдеров тут).
|
"""Per-provider константы/политики бисекции (все различия провайдеров тут).
|
||||||
|
|
@ -279,7 +222,6 @@ async def walk_price_range(
|
||||||
on_leaf: LeafFn,
|
on_leaf: LeafFn,
|
||||||
on_degraded: DegradedFn | None = None,
|
on_degraded: DegradedFn | None = None,
|
||||||
should_skip: SkipFn | None = None,
|
should_skip: SkipFn | None = None,
|
||||||
stats: BisectionStats | None = None,
|
|
||||||
depth: int = 0,
|
depth: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией.
|
"""Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией.
|
||||||
|
|
@ -307,10 +249,6 @@ async def walk_price_range(
|
||||||
(:func:`done_range_skipper`, #3315). Проверяется на КАЖДОМ узле
|
(:func:`done_range_skipper`, #3315). Проверяется на КАЖДОМ узле
|
||||||
рекурсии: у частично покрытого диапазона probe остаётся, а его уже
|
рекурсии: у частично покрытого диапазона probe остаётся, а его уже
|
||||||
готовые половины отсекаются на входе, а не в листе после probe.
|
готовые половины отсекаются на входе, а не в листе после probe.
|
||||||
stats: опциональный :class:`BisectionStats` — мутабельный аккумулятор
|
|
||||||
probe/leaf/усечений. ``None`` (умолчание) = ни одной новой ветки в
|
|
||||||
обходе. Передан — вызывающий получает `missed_estimate` и обязан
|
|
||||||
донести его до счётчиков прогона, иначе усечение снова станет тишиной.
|
|
||||||
depth: текущая глубина рекурсии (для ``max_depth``).
|
depth: текущая глубина рекурсии (для ``max_depth``).
|
||||||
"""
|
"""
|
||||||
if should_skip is not None and should_skip(lo, hi):
|
if should_skip is not None and should_skip(lo, hi):
|
||||||
|
|
@ -318,34 +256,22 @@ async def walk_price_range(
|
||||||
|
|
||||||
result = await probe(lo, hi, depth)
|
result = await probe(lo, hi, depth)
|
||||||
total = result.count
|
total = result.count
|
||||||
if stats is not None:
|
|
||||||
stats.record_probe(depth)
|
|
||||||
|
|
||||||
# ── Probe-fail: счётчик недоступен ────────────────────────────────────────
|
# ── Probe-fail: счётчик недоступен ────────────────────────────────────────
|
||||||
if total is None:
|
if total is None:
|
||||||
policy = config.probe_fail_policy
|
policy = config.probe_fail_policy
|
||||||
if policy is ProbeFailPolicy.SKIP:
|
if policy is ProbeFailPolicy.SKIP:
|
||||||
if stats is not None:
|
|
||||||
stats.skipped_probe_fail += 1
|
|
||||||
return
|
return
|
||||||
if policy is ProbeFailPolicy.DEGRADE:
|
if policy is ProbeFailPolicy.DEGRADE:
|
||||||
if stats is not None:
|
|
||||||
stats.degraded += 1
|
|
||||||
if on_degraded is not None:
|
if on_degraded is not None:
|
||||||
await on_degraded(lo, hi)
|
await on_degraded(lo, hi)
|
||||||
return
|
return
|
||||||
# SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый
|
# SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый
|
||||||
# ещё делимый — принудительно делим; иначе пропускаем (хвост теряем).
|
# ещё делимый — принудительно делим; иначе пропускаем (хвост теряем).
|
||||||
if hi is None:
|
if hi is None:
|
||||||
if stats is not None:
|
|
||||||
stats.record_leaf(None, config.cap)
|
|
||||||
await on_leaf(lo, hi, result)
|
await on_leaf(lo, hi, result)
|
||||||
return
|
return
|
||||||
_lo = lo if lo is not None else config.open_split_floor
|
_lo = lo if lo is not None else config.open_split_floor
|
||||||
if not _can_split_span(hi - _lo, config) and stats is not None:
|
|
||||||
# Закрытый неделимый диапазон без счётчика просто бросается — это
|
|
||||||
# такая же дыра в покрытии, как SKIP, и она обязана быть видна.
|
|
||||||
stats.skipped_probe_fail += 1
|
|
||||||
if _can_split_span(hi - _lo, config):
|
if _can_split_span(hi - _lo, config):
|
||||||
mid = (_lo + hi) // 2
|
mid = (_lo + hi) // 2
|
||||||
await walk_price_range(
|
await walk_price_range(
|
||||||
|
|
@ -356,7 +282,6 @@ async def walk_price_range(
|
||||||
on_leaf=on_leaf,
|
on_leaf=on_leaf,
|
||||||
on_degraded=on_degraded,
|
on_degraded=on_degraded,
|
||||||
should_skip=should_skip,
|
should_skip=should_skip,
|
||||||
stats=stats,
|
|
||||||
depth=depth + 1,
|
depth=depth + 1,
|
||||||
)
|
)
|
||||||
await walk_price_range(
|
await walk_price_range(
|
||||||
|
|
@ -367,7 +292,6 @@ async def walk_price_range(
|
||||||
on_leaf=on_leaf,
|
on_leaf=on_leaf,
|
||||||
on_degraded=on_degraded,
|
on_degraded=on_degraded,
|
||||||
should_skip=should_skip,
|
should_skip=should_skip,
|
||||||
stats=stats,
|
|
||||||
depth=depth + 1,
|
depth=depth + 1,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -379,8 +303,6 @@ async def walk_price_range(
|
||||||
# Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя —
|
# Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя —
|
||||||
# пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
|
# пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
|
||||||
if hi is None and config.open_split_ceiling is None:
|
if hi is None and config.open_split_ceiling is None:
|
||||||
if stats is not None:
|
|
||||||
stats.record_leaf(total, config.cap)
|
|
||||||
await on_leaf(lo, hi, result)
|
await on_leaf(lo, hi, result)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -407,7 +329,6 @@ async def walk_price_range(
|
||||||
on_leaf=on_leaf,
|
on_leaf=on_leaf,
|
||||||
on_degraded=on_degraded,
|
on_degraded=on_degraded,
|
||||||
should_skip=should_skip,
|
should_skip=should_skip,
|
||||||
stats=stats,
|
|
||||||
depth=depth + 1,
|
depth=depth + 1,
|
||||||
)
|
)
|
||||||
await walk_price_range(
|
await walk_price_range(
|
||||||
|
|
@ -418,15 +339,9 @@ async def walk_price_range(
|
||||||
on_leaf=on_leaf,
|
on_leaf=on_leaf,
|
||||||
on_degraded=on_degraded,
|
on_degraded=on_degraded,
|
||||||
should_skip=should_skip,
|
should_skip=should_skip,
|
||||||
stats=stats,
|
|
||||||
depth=depth + 1,
|
depth=depth + 1,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Leaf-бакет: пагинация + дедуп + on_bucket — целиком на стороне провайдера.
|
# Leaf-бакет: пагинация + дедуп + on_bucket — целиком на стороне провайдера.
|
||||||
# Сюда же приезжает переполненный диапазон, который НЕ дали поделить
|
|
||||||
# min_bracket / max_depth: пагинация дорежет его до cap, и без stats разница
|
|
||||||
# count-cap пропала бы бесследно.
|
|
||||||
if stats is not None:
|
|
||||||
stats.record_leaf(total, config.cap)
|
|
||||||
await on_leaf(lo, hi, result)
|
await on_leaf(lo, hi, result)
|
||||||
|
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
"""Чистый планировщик ценовых коридоров под жёсткий потолок выдачи площадки.
|
|
||||||
|
|
||||||
ЗАЧЕМ. У каждой площадки жёсткий cap на набор фильтров: Avito ~1500 позиций,
|
|
||||||
Cian 1512, Yandex 500, DomClick 2000. По Свердловской области это не мешало —
|
|
||||||
вторичка целиком влезает в сид-брекеты (`scraper_kit.price_brackets`). По Москве
|
|
||||||
и области лотов десятки тысяч (Cian 153k на объединённом скоупе, Yandex 31k,
|
|
||||||
DomClick 17.5k по области), и без дробления по цене прогон ТИХО забирает верхушку
|
|
||||||
выдачи и рапортует успех. Этот модуль превращает молчаливое усечение в число.
|
|
||||||
|
|
||||||
ЧЕМ ОТЛИЧАЕТСЯ ОТ `pricing.bisection`. `walk_price_range` — боевой async-движок
|
|
||||||
обхода: он ходит в сеть, пагинирует листья и завязан на provider-callback'и, а
|
|
||||||
его арифметика деления — `mid = (lo + hi) // 2`. Здесь СИНХРОННАЯ ЧИСТАЯ функция:
|
|
||||||
на вход «сколько позиций в коридоре», на выход список коридоров + честная
|
|
||||||
статистика. Ни одного сетевого вызова внутри, поэтому она тестируется как
|
|
||||||
обычная функция и её можно прогнать на модели распределения до прогона.
|
|
||||||
|
|
||||||
Алгоритм перенесён из обкатанного живьём `scripts/local-avito-msk/collect.py`
|
|
||||||
(:245-249, :303-336, :1408-1434) и сохраняет три его решения:
|
|
||||||
|
|
||||||
1. **Геометрическая середина** `sqrt(lo*hi)`, а не арифметическая. Цены
|
|
||||||
логнормальны: арифметическая середина коридора 1М..100М (≈50М) отрезает
|
|
||||||
вырожденно-пустую верхнюю половину, и дерево вырождается в лестницу —
|
|
||||||
деление идёт, а переполненный левый край так и не разъезжается.
|
|
||||||
2. **Верхняя граница ищется удвоением** от `probe_start` (8 млн ₽), а не берётся
|
|
||||||
константой: потолок рынка по региону заранее неизвестен.
|
|
||||||
3. **Два предохранителя** — `MAX_DEPTH` и `MIN_WIDTH_RATIO`. Неделимый
|
|
||||||
переполненный коридор НЕ молчит: он помечается `truncated` и его недобор
|
|
||||||
попадает в `PlanStats.missed_estimate`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
# Нижняя граница осмысленного коридора, ₽ (подстановка вместо None-lo в арифметике).
|
|
||||||
PRICE_FLOOR = 500_000
|
|
||||||
# Старт удвоения при поиске верхней границы открытого коридора, ₽.
|
|
||||||
PRICE_PROBE_START = 8_000_000
|
|
||||||
# Абсолютный потолок поиска верхней границы, ₽.
|
|
||||||
PRICE_CEIL = 2_000_000_000
|
|
||||||
# Уже этого (по ГЕОМЕТРИЧЕСКОЙ ширине hi/lo) коридор не делим.
|
|
||||||
MIN_WIDTH_RATIO = 1.05
|
|
||||||
# Жёсткий предел глубины рекурсии: гарантия остановки, не более 2^12 листьев.
|
|
||||||
MAX_DEPTH = 12
|
|
||||||
|
|
||||||
# Свердловская область: текущий боевой скоуп, где бисекция не нужна (см.
|
|
||||||
# `price_bisection_enabled`).
|
|
||||||
EKB_REGION_CODE = 66
|
|
||||||
|
|
||||||
#: «Сколько всего позиций в коридоре [lo, hi]». ``None`` — счётчик не прочитался
|
|
||||||
#: (captcha / битая страница), это НЕ ноль. Границы включительные, ``None`` = без
|
|
||||||
#: границы.
|
|
||||||
CountFn = Callable[[int | None, int | None], int | None]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Corridor:
|
|
||||||
"""Один ценовой коридор плана."""
|
|
||||||
|
|
||||||
lo: int | None
|
|
||||||
hi: int | None
|
|
||||||
count: int | None = None
|
|
||||||
#: Коридор заведомо не влезает в потолок площадки и поделить его уже нельзя.
|
|
||||||
truncated: bool = False
|
|
||||||
#: Оценка недобора: ``count - hard_cap`` (0, если count неизвестен).
|
|
||||||
missed: int = 0
|
|
||||||
depth: int = 0
|
|
||||||
#: Счётчик не прочитался — коридор уходит в прогон, но за полноту не ручаемся.
|
|
||||||
unknown_count: bool = False
|
|
||||||
|
|
||||||
def label(self) -> str:
|
|
||||||
lo = "-" if self.lo is None else f"{self.lo:_}"
|
|
||||||
hi = "-" if self.hi is None else f"{self.hi:_}"
|
|
||||||
return f"[{lo} .. {hi}]"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PlanStats:
|
|
||||||
"""Честная сводка по плану. Усечение обязано быть видно здесь, а не в логе."""
|
|
||||||
|
|
||||||
corridors: int = 0
|
|
||||||
truncated_corridors: int = 0
|
|
||||||
#: Сколько позиций заведомо не будет собрано — сумма `missed` по усечённым.
|
|
||||||
missed_estimate: int = 0
|
|
||||||
#: Коридоры с непрочитанным счётчиком (полнота недоказуема).
|
|
||||||
unknown_corridors: int = 0
|
|
||||||
#: Сколько раз дёрнули `count_of` (стоимость планирования в запросах).
|
|
||||||
probes: int = 0
|
|
||||||
max_depth_reached: int = 0
|
|
||||||
#: Счётчик по корневому запросу — сколько всего в скоупе.
|
|
||||||
root_count: int | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def complete(self) -> bool:
|
|
||||||
"""План покрывает выборку целиком (ни усечений, ни слепых коридоров)."""
|
|
||||||
return self.truncated_corridors == 0 and self.unknown_corridors == 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PricePlan:
|
|
||||||
corridors: list[Corridor] = field(default_factory=list)
|
|
||||||
stats: PlanStats = field(default_factory=PlanStats)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def truncated(self) -> list[Corridor]:
|
|
||||||
return [c for c in self.corridors if c.truncated]
|
|
||||||
|
|
||||||
|
|
||||||
def geometric_mid(lo: int | None, hi: int, *, price_floor: int = PRICE_FLOOR) -> int:
|
|
||||||
"""Геометрическая середина коридора: ``sqrt(lo*hi)``.
|
|
||||||
|
|
||||||
Цены логнормальны, поэтому арифметическая середина делит ВЫБОРКУ крайне
|
|
||||||
неровно. Результат зажат в ``[lo+1, hi-1]``, чтобы деление всегда давало две
|
|
||||||
непустые половины и рекурсия не могла зациклиться на границе.
|
|
||||||
|
|
||||||
ПРЕДУСЛОВИЕ: ``hi - lo >= 2``. У атомарного коридора (``hi - lo <= 1``)
|
|
||||||
строгой внутренней точки не существует вовсе, и зажим вернул бы саму границу
|
|
||||||
— половина совпала бы с родителем, то есть рекурсия по себе. Делимость
|
|
||||||
проверяет вызывающий (`plan_price_corridors`), см. там ветку `upper - eff_lo
|
|
||||||
<= 1`; в исходнике `collect.py` этой проверки нет, и от бесконечного спуска
|
|
||||||
его спасает только MAX_DEPTH.
|
|
||||||
"""
|
|
||||||
low = max(int(lo or price_floor), 1)
|
|
||||||
mid = int(math.sqrt(low * float(hi)))
|
|
||||||
return max(low + 1, min(hi - 1, mid))
|
|
||||||
|
|
||||||
|
|
||||||
def width_ratio(lo: int | None, hi: int | None, *, price_floor: int = PRICE_FLOOR) -> float:
|
|
||||||
"""Геометрическая ширина коридора ``hi/lo`` (``inf`` для открытого верха)."""
|
|
||||||
if hi is None:
|
|
||||||
return float("inf")
|
|
||||||
return float(hi) / max(float(lo or price_floor), 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def price_bisection_enabled(region_code: int | None) -> bool:
|
|
||||||
"""Нужна ли ценовая бисекция для региона.
|
|
||||||
|
|
||||||
УМОЛЧАНИЕ — ВЫКЛЮЧЕНО ДЛЯ 66 (Свердловская обл.) И ДЛЯ НЕИЗВЕСТНОГО РЕГИОНА.
|
|
||||||
Причина не в том, что бисекция там вредна, а в том, что она там НЕ НУЖНА и
|
|
||||||
поэтому её включение было бы чистым регрессом: вторичка области целиком
|
|
||||||
влезает в сид-брекеты `price_brackets`, счётчик ни в одном брекете не бьётся
|
|
||||||
в cap, и каждый лишний probe — это лишний запрос к площадке под антибот. То
|
|
||||||
есть для 66 план обязан быть РОВНО ТЕМ ЖЕ обходом, что и сегодня.
|
|
||||||
|
|
||||||
``None`` (регион не передали) трактуем как 66, а не как «включить»: провайдеры
|
|
||||||
фоллбэчат на ЕКБ-дефолт, когда регион не задан, и «безопаснее включить»
|
|
||||||
здесь означало бы молча поменять боевое поведение прогонов, которые просто
|
|
||||||
не прокинули параметр.
|
|
||||||
|
|
||||||
Для всех остальных регионов — включено: именно там (77/50 — Москва и область)
|
|
||||||
выдача кратно превышает cap площадки.
|
|
||||||
"""
|
|
||||||
if region_code is None:
|
|
||||||
return False
|
|
||||||
return int(region_code) != EKB_REGION_CODE
|
|
||||||
|
|
||||||
|
|
||||||
def plan_price_corridors(
|
|
||||||
*,
|
|
||||||
count_of: CountFn,
|
|
||||||
target: int,
|
|
||||||
hard_cap: int | None = None,
|
|
||||||
lo: int | None = None,
|
|
||||||
hi: int | None = None,
|
|
||||||
enabled: bool = True,
|
|
||||||
max_depth: int = MAX_DEPTH,
|
|
||||||
min_width_ratio: float = MIN_WIDTH_RATIO,
|
|
||||||
price_floor: int = PRICE_FLOOR,
|
|
||||||
probe_start: int = PRICE_PROBE_START,
|
|
||||||
price_ceil: int = PRICE_CEIL,
|
|
||||||
) -> PricePlan:
|
|
||||||
"""Разбить ценовой диапазон на коридоры, влезающие в потолок выдачи.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
count_of: «сколько позиций в [lo, hi]»; ``None`` — счётчик не прочитался.
|
|
||||||
target: целевой размер коридора. Берётся С ЗАПАСОМ ниже ``hard_cap``:
|
|
||||||
счётчик площадки врёт в обе стороны и выдача плывёт между probe и
|
|
||||||
пагинацией, поэтому коридор ровно в cap приезжает усечённым.
|
|
||||||
hard_cap: жёсткий потолок выдачи площадки; по умолчанию ``target``.
|
|
||||||
Недобор считается от него, а не от ``target``.
|
|
||||||
lo / hi: границы корневого диапазона (``None`` = без границы).
|
|
||||||
enabled: ``False`` -> ровно ОДИН коридор на весь диапазон, ноль делений
|
|
||||||
(режим региона 66, см. :func:`price_bisection_enabled`). Корневой
|
|
||||||
probe всё равно делается — чтобы усечение, если оно там есть, попало
|
|
||||||
в статистику, а не осталось незамеченным.
|
|
||||||
max_depth / min_width_ratio: предохранители, см. модульный docstring.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
:class:`PricePlan` — коридоры в порядке обхода + :class:`PlanStats`.
|
|
||||||
"""
|
|
||||||
cap = hard_cap if hard_cap is not None else target
|
|
||||||
corridors: list[Corridor] = []
|
|
||||||
probes = 0
|
|
||||||
deepest = 0
|
|
||||||
|
|
||||||
def probe(p_lo: int | None, p_hi: int | None) -> int | None:
|
|
||||||
nonlocal probes
|
|
||||||
probes += 1
|
|
||||||
return count_of(p_lo, p_hi)
|
|
||||||
|
|
||||||
def emit(c_lo: int | None, c_hi: int | None, count: int | None, depth: int) -> None:
|
|
||||||
nonlocal deepest
|
|
||||||
deepest = max(deepest, depth)
|
|
||||||
if count is None:
|
|
||||||
# Слепой коридор: собираем, но полноту не декларируем.
|
|
||||||
corridors.append(
|
|
||||||
Corridor(
|
|
||||||
lo=c_lo,
|
|
||||||
hi=c_hi,
|
|
||||||
count=None,
|
|
||||||
truncated=True,
|
|
||||||
missed=0,
|
|
||||||
depth=depth,
|
|
||||||
unknown_count=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
over = count > cap
|
|
||||||
corridors.append(
|
|
||||||
Corridor(
|
|
||||||
lo=c_lo,
|
|
||||||
hi=c_hi,
|
|
||||||
count=count,
|
|
||||||
truncated=over,
|
|
||||||
missed=max(0, count - cap) if over else 0,
|
|
||||||
depth=depth,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def find_upper(f_lo: int | None) -> int:
|
|
||||||
"""Верхнюю границу открытого коридора ищем удвоением от probe_start."""
|
|
||||||
cand = max(int(f_lo or price_floor) * 2, probe_start)
|
|
||||||
while cand < price_ceil:
|
|
||||||
cnt = probe(cand, None)
|
|
||||||
if cnt is not None and cnt <= target:
|
|
||||||
return cand
|
|
||||||
cand *= 2
|
|
||||||
return cand
|
|
||||||
|
|
||||||
def split(s_lo: int | None, s_hi: int | None, depth: int, count: int | None) -> None:
|
|
||||||
if count is None or count <= target:
|
|
||||||
emit(s_lo, s_hi, count, depth)
|
|
||||||
return
|
|
||||||
too_narrow = width_ratio(s_lo, s_hi, price_floor=price_floor) <= min_width_ratio
|
|
||||||
if depth >= max_depth or too_narrow:
|
|
||||||
# Предохранитель сработал: делить больше нельзя. НЕ молчим — коридор
|
|
||||||
# уезжает в план помеченным, недобор попадает в missed_estimate.
|
|
||||||
emit(s_lo, s_hi, count, depth)
|
|
||||||
return
|
|
||||||
upper = s_hi if s_hi is not None else find_upper(s_lo)
|
|
||||||
if upper - (s_lo if s_lo is not None else price_floor) <= 1:
|
|
||||||
# Атомарный коридор: строгой внутренней точки нет, делить нечем.
|
|
||||||
# Третий предохранитель рядом с MAX_DEPTH / MIN_WIDTH_RATIO — при
|
|
||||||
# дефолтном min_width_ratio=1.05 недостижим, но min_width_ratio
|
|
||||||
# параметризуем, и без этой ветки деление ушло бы в рекурсию по себе.
|
|
||||||
emit(s_lo, s_hi, count, depth)
|
|
||||||
return
|
|
||||||
if s_hi is None:
|
|
||||||
# Хвост выше найденной границы закрывается отдельным коридором:
|
|
||||||
# иначе верх рынка просто выпал бы из плана.
|
|
||||||
emit(upper, None, probe(upper, None), depth + 1)
|
|
||||||
mid = geometric_mid(s_lo, upper, price_floor=price_floor)
|
|
||||||
for sub_lo, sub_hi in ((s_lo, mid), (mid, upper)):
|
|
||||||
split(sub_lo, sub_hi, depth + 1, probe(sub_lo, sub_hi))
|
|
||||||
|
|
||||||
root_count = probe(lo, hi)
|
|
||||||
if enabled:
|
|
||||||
split(lo, hi, 0, root_count)
|
|
||||||
else:
|
|
||||||
emit(lo, hi, root_count, 0)
|
|
||||||
|
|
||||||
truncated = [c for c in corridors if c.truncated]
|
|
||||||
stats = PlanStats(
|
|
||||||
corridors=len(corridors),
|
|
||||||
truncated_corridors=len(truncated),
|
|
||||||
missed_estimate=sum(c.missed for c in truncated),
|
|
||||||
unknown_corridors=sum(1 for c in corridors if c.unknown_count),
|
|
||||||
probes=probes,
|
|
||||||
max_depth_reached=deepest,
|
|
||||||
root_count=root_count,
|
|
||||||
)
|
|
||||||
return PricePlan(corridors=corridors, stats=stats)
|
|
||||||
|
|
@ -398,7 +398,6 @@ class AvitoScraper(BaseScraper):
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
target_city_slug: str | None = None,
|
target_city_slug: str | None = None,
|
||||||
proxy_provider: ProxyProvider | None = None,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
filter_cards_by_city_slug: bool = True,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
||||||
|
|
@ -421,14 +420,6 @@ class AvitoScraper(BaseScraper):
|
||||||
# None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only,
|
# None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only,
|
||||||
# чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/.
|
# чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/.
|
||||||
self._target_city_slug = target_city_slug
|
self._target_city_slug = target_city_slug
|
||||||
# Слаг РЕГИОНА (moskva_i_mo покрывает Москву и всю область одним скоупом) в
|
|
||||||
# ссылках карточек не встречается — там /moskva/, /himki/, /balashiha/… .
|
|
||||||
# Поэтому per-card фильтр по слагу города-цели на таком скоупе выбросил бы
|
|
||||||
# 100% выдачи (замер 10-12.09.2026). False — фильтр выключен ДЛЯ ЭТОГО
|
|
||||||
# прогона, независимо от глобального config.avito_serp_ekb_only: флаг
|
|
||||||
# настройки говорит «отбрасывать добивку по всей России», а не «собирать
|
|
||||||
# только Екатеринбург», и на региональном слаге он неприменим физически.
|
|
||||||
self._filter_cards_by_city_slug = filter_cards_by_city_slug
|
|
||||||
# #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время
|
# #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время
|
||||||
# fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы
|
# fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы
|
||||||
# остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча.
|
# остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча.
|
||||||
|
|
@ -2120,7 +2111,7 @@ class AvitoScraper(BaseScraper):
|
||||||
ts_map = _build_sort_timestamp_map(html)
|
ts_map = _build_sort_timestamp_map(html)
|
||||||
cards = tree.css('[data-marker="item"]')
|
cards = tree.css('[data-marker="item"]')
|
||||||
lots: list[ScrapedLot] = []
|
lots: list[ScrapedLot] = []
|
||||||
ekb_only = self._config.avito_serp_ekb_only and self._filter_cards_by_city_slug
|
ekb_only = self._config.avito_serp_ekb_only
|
||||||
# #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" —
|
# #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" —
|
||||||
# ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load).
|
# ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load).
|
||||||
kept_slug = self._target_city_slug or "ekaterinburg"
|
kept_slug = self._target_city_slug or "ekaterinburg"
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,6 @@ _MIN_BRACKET = 50_000
|
||||||
# Cian SERP показывает ~28 офферов на страницу.
|
# Cian SERP показывает ~28 офферов на страницу.
|
||||||
_CIAN_OFFERS_PER_PAGE = 28
|
_CIAN_OFFERS_PER_PAGE = 28
|
||||||
|
|
||||||
|
|
||||||
# Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся
|
# Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся
|
||||||
# per-call (price_cap_per_bucket) — здесь только структурные константы/политики.
|
# per-call (price_cap_per_bucket) — здесь только структурные константы/политики.
|
||||||
# probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается.
|
# probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается.
|
||||||
|
|
@ -98,7 +97,6 @@ def _cian_bisection_config(cap: int) -> BisectionConfig:
|
||||||
probe_fail_policy=ProbeFailPolicy.SKIP,
|
probe_fail_policy=ProbeFailPolicy.SKIP,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Комнатности по умолчанию для exhaustive-загрузки.
|
# Комнатности по умолчанию для exhaustive-загрузки.
|
||||||
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
|
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
|
||||||
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
|
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
|
||||||
|
|
@ -142,7 +140,6 @@ class CianScraper(BaseScraper):
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
proxy_provider: ProxyProvider | None = None,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
city_region_id: int | None = None,
|
city_region_id: int | None = None,
|
||||||
city_host: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
||||||
|
|
@ -156,17 +153,9 @@ class CianScraper(BaseScraper):
|
||||||
# None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас).
|
# None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас).
|
||||||
self._proxy_provider = proxy_provider
|
self._proxy_provider = proxy_provider
|
||||||
self._browser: BrowserFetcher | None = None
|
self._browser: BrowserFetcher | None = None
|
||||||
# #12 (oblast rollout): region= города-цели SERP-запроса. None → ЕКБ-дефолт
|
# #12 (oblast rollout): region= города-цели SERP-запроса (ekb.cian.ru — общий
|
||||||
# (CIAN_EKB_REGION_ID).
|
# поддомен всей Свердловской обл., НЕ меняется). None → ЕКБ-дефолт (CIAN_EKB_REGION_ID).
|
||||||
self._region_id = city_region_id or CIAN_EKB_REGION_ID
|
self._region_id = city_region_id or CIAN_EKB_REGION_ID
|
||||||
# Поддомен Циана — РЕГИОНАЛЬНЫЙ, а не городской: ekb.cian.ru общий для всей
|
|
||||||
# Свердловской обл. (поэтому раньше был захардкожен классовым base_url), но
|
|
||||||
# Москву и область он не обслуживает — там www.cian.ru. Приходит из реестра
|
|
||||||
# города (CityLocation.cian_host в orchestration/pipeline.py) вместе с region=,
|
|
||||||
# чтобы хост и region не разъехались. None → класс-дефолт ЕКБ: URL прежних
|
|
||||||
# свердловских развёрток остаются байт в байт.
|
|
||||||
if city_host is not None:
|
|
||||||
self.base_url = city_host
|
|
||||||
# #2625: счётчики Redux-state extraction за время жизни этого scraper-инстанса.
|
# #2625: счётчики Redux-state extraction за время жизни этого scraper-инстанса.
|
||||||
# Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены
|
# Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены
|
||||||
# вёрстки (state extraction провалилась → None). Читаются вызывающим кодом
|
# вёрстки (state extraction провалилась → None). Читаются вызывающим кодом
|
||||||
|
|
@ -307,9 +296,8 @@ class CianScraper(BaseScraper):
|
||||||
) -> str:
|
) -> str:
|
||||||
"""URL для Cian каталога вторички города-цели (по умолчанию ЕКБ).
|
"""URL для Cian каталога вторички города-цели (по умолчанию ЕКБ).
|
||||||
|
|
||||||
Хост берётся из self.base_url (региональный поддомен: ekb.cian.ru — вся
|
Используем ekb.cian.ru (city-specific subdomain — общий для всей Свердловской
|
||||||
Свердловская обл., www.cian.ru — Москва и МО; задаётся city_host в __init__,
|
обл., НЕ меняется per-city). region= param — фактический скоуп выборки
|
||||||
по умолчанию ЕКБ). region= param — фактический скоуп выборки
|
|
||||||
(#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан).
|
(#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан).
|
||||||
min_price / max_price — опциональные ценовые границы (руб.) для price-бакетов.
|
min_price / max_price — опциональные ценовые границы (руб.) для price-бакетов.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
@ -50,6 +49,8 @@ logger = logging.getLogger(__name__)
|
||||||
# ── API constants ─────────────────────────────────────────────────────────────
|
# ── API constants ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_BFF_BASE = "https://bff-search-web.domclick.ru"
|
_BFF_BASE = "https://bff-search-web.domclick.ru"
|
||||||
|
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
|
||||||
|
_EKB_AREA_ID = "20561"
|
||||||
|
|
||||||
# Buckets to sweep — порядок влияет на логи.
|
# Buckets to sweep — порядок влияет на логи.
|
||||||
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
|
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
|
||||||
|
|
@ -80,123 +81,13 @@ _DOMCLICK_BISECTION = BisectionConfig(
|
||||||
open_split_floor=0,
|
open_split_floor=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Гео-профиль региона ───────────────────────────────────────────────────────
|
# ── EKB geo guard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_EKB_LAT_MIN: float = 56.6
|
||||||
@dataclass(frozen=True)
|
_EKB_LAT_MAX: float = 57.0
|
||||||
class DomClickGeoProfile:
|
_EKB_LON_MIN: float = 60.2
|
||||||
"""Всё, что отличает свип одного региона от свипа другого.
|
_EKB_LON_MAX: float = 60.9
|
||||||
|
_EKB_REGION_NAME: str = "Екатеринбург"
|
||||||
Раньше эти величины были модульными константами `_EKB_*`, и прогон с
|
|
||||||
region_code != 66 собрал бы Екатеринбург, проштамповав его чужим регионом.
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
address_guid — параметр `address` BFF-запроса (регион/локалити DomClick).
|
|
||||||
aids — параметр `aids` (сужение выдачи). ``None`` = параметр НЕ
|
|
||||||
добавлять: у Москвы и области счётчик без него совпадает
|
|
||||||
с сайтом (замер 12.09.2026), у ЕКБ aids=20561 обязателен.
|
|
||||||
origin_url — городская страница выдачи, на которой прогревается
|
|
||||||
контекст перед запросом к BFF (#3264). BFF — не страница,
|
|
||||||
а JSON-ручка SPA: навигацией на неё приходит только
|
|
||||||
ChallengeTimeout, а подзапросом из прогретой выдачи —
|
|
||||||
HTTP 200. Прогревать нужно СВОЙ город.
|
|
||||||
bbox — (lat_min, lat_max, lon_min, lon_max) гео-гарда выдачи.
|
|
||||||
region_name — ожидаемый ``item["offerRegionName"]``. ``None`` = НЕ
|
|
||||||
сверять имя, гард только по bbox: вне ЕКБ поле ненадёжно,
|
|
||||||
офферы Новой Москвы приходят как «г. Говорово», а не
|
|
||||||
«Москва» (замер 12.09.2026).
|
|
||||||
city_name — что писать в listings.city. ``None`` = не угадывать
|
|
||||||
(у области нет одного города).
|
|
||||||
"""
|
|
||||||
|
|
||||||
region_code: int
|
|
||||||
address_guid: str
|
|
||||||
aids: str | None
|
|
||||||
origin_url: str
|
|
||||||
bbox: tuple[float, float, float, float]
|
|
||||||
region_name: str | None
|
|
||||||
city_name: str | None
|
|
||||||
|
|
||||||
|
|
||||||
# Реестр профилей по коду региона.
|
|
||||||
#
|
|
||||||
# bbox для 77 и 50 — ДУБЛЬ значений backend/app/services/regions.py
|
|
||||||
# REGIONS[code].bbox_region (порядок тот же: lat_min, lat_max, lon_min, lon_max).
|
|
||||||
# Дубль намеренный и неустранимый: kit не имеет права импортировать app.* (это
|
|
||||||
# гейтится тестом), а общего места у kit и backend нет. Правишь bbox_region
|
|
||||||
# там — поправь здесь.
|
|
||||||
#
|
|
||||||
# bbox для 66 — НЕ bbox_region Свердловской области, а исторический ГОРОДСКОЙ
|
|
||||||
# bbox ЕКБ-гарда: свип 66 citywide по Екатеринбургу, а не по всей области.
|
|
||||||
# Значения 66 (GUID, aids, origin, bbox, имя региона) сохранены байт в байт,
|
|
||||||
# чтобы параметризация не изменила поведение прод-прогонов.
|
|
||||||
DOMCLICK_GEO_PROFILES: dict[int, DomClickGeoProfile] = {
|
|
||||||
66: DomClickGeoProfile(
|
|
||||||
region_code=66,
|
|
||||||
address_guid="0d475b79-88de-4054-818c-37d8f9d0d440",
|
|
||||||
aids="20561",
|
|
||||||
origin_url="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
|
||||||
bbox=(56.6, 57.0, 60.2, 60.9),
|
|
||||||
region_name="Екатеринбург",
|
|
||||||
city_name="Екатеринбург",
|
|
||||||
),
|
|
||||||
# GUID Москвы и области проверены живым запросом 12.09.2026 (у Москвы region и
|
|
||||||
# locality — один и тот же GUID), см. scripts/local-avito-msk/collect.py
|
|
||||||
# MSK_DOMCLICK_GUID / MO_DOMCLICK_GUID. aids для обоих НЕ нужен: без него
|
|
||||||
# счётчик листинга совпадает с сайтом.
|
|
||||||
#
|
|
||||||
# Страница прогрева 77/50 — апексный домен, проверено запросом 15.09.2026:
|
|
||||||
# msk.domclick.ru -> 301 https://domclick.ru/...
|
|
||||||
# moskovskaya-oblast.domclick.ru-> 301 https://domclick.ru/...
|
|
||||||
# domclick.ru -> 401 (QRATOR-челлендж, как у ЕКБ)
|
|
||||||
# ekaterinburg.domclick.ru -> 401 (контроль)
|
|
||||||
# Схема `<город>.domclick.ru` работает НЕ для всех городов: у Москвы своего
|
|
||||||
# субдомена нет, апекс и есть московская выдача. 301 увёл бы прогрев на
|
|
||||||
# другой URL и с большой вероятностью потерял бы куку QRATOR.
|
|
||||||
77: DomClickGeoProfile(
|
|
||||||
region_code=77,
|
|
||||||
address_guid="1d1463ae-c80f-4d19-9331-a1b68a85b553",
|
|
||||||
aids=None,
|
|
||||||
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
|
|
||||||
bbox=(55.10, 56.10, 36.80, 38.10),
|
|
||||||
region_name=None,
|
|
||||||
city_name="Москва",
|
|
||||||
),
|
|
||||||
50: DomClickGeoProfile(
|
|
||||||
region_code=50,
|
|
||||||
address_guid="9930cc20-32c6-4f6f-a55e-cd67086c5171",
|
|
||||||
aids=None,
|
|
||||||
# Область выдаётся с того же апексного домена; см. оговорку выше.
|
|
||||||
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
|
|
||||||
bbox=(54.20, 56.96, 35.14, 40.21),
|
|
||||||
region_name=None,
|
|
||||||
city_name=None,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_GEO_PROFILE: DomClickGeoProfile = DOMCLICK_GEO_PROFILES[66]
|
|
||||||
|
|
||||||
# Обратная совместимость: страница прогрева ЕКБ как модульная константа
|
|
||||||
# (импортируется тестами subresource-режима).
|
|
||||||
_SWEEP_ORIGIN = DEFAULT_GEO_PROFILE.origin_url
|
|
||||||
|
|
||||||
|
|
||||||
def geo_profile_for_region(region_code: int) -> DomClickGeoProfile:
|
|
||||||
"""Профиль по коду региона. ValueError на неизвестном коде.
|
|
||||||
|
|
||||||
Молчаливый fallback на ЕКБ здесь недопустим: он и есть тот баг, ради
|
|
||||||
которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал его
|
|
||||||
под чужим region_code со штатными счётчиками.
|
|
||||||
"""
|
|
||||||
profile = DOMCLICK_GEO_PROFILES.get(region_code)
|
|
||||||
if profile is None:
|
|
||||||
_known = ", ".join(str(code) for code in sorted(DOMCLICK_GEO_PROFILES))
|
|
||||||
raise ValueError(
|
|
||||||
f"domclick: нет гео-профиля для region_code={region_code} "
|
|
||||||
f"(известны: {_known}); заведи запись в DOMCLICK_GEO_PROFILES "
|
|
||||||
"(address GUID, aids, страница прогрева, bbox) перед прогоном"
|
|
||||||
)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
# ── JSON extraction ───────────────────────────────────────────────────────────
|
# ── JSON extraction ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -261,19 +152,14 @@ def _build_offers_url(
|
||||||
price_gte: int | None,
|
price_gte: int | None,
|
||||||
price_lte: int | None,
|
price_lte: int | None,
|
||||||
offset: int,
|
offset: int,
|
||||||
*,
|
|
||||||
address_guid: str,
|
|
||||||
aids: str | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Строит URL для GET /api/offers/v1 с пагинацией.
|
"""Строит URL для GET /api/offers/v1 с пагинацией.
|
||||||
|
|
||||||
urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject).
|
urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject).
|
||||||
aids=None — параметр не добавляется (Москва и область, см. DomClickGeoProfile).
|
|
||||||
"""
|
"""
|
||||||
params: list[tuple[str, str]] = [("address", address_guid)]
|
params: list[tuple[str, str]] = [
|
||||||
if aids is not None:
|
("address", _EKB_ADDRESS_GUID),
|
||||||
params.append(("aids", aids))
|
("aids", _EKB_AREA_ID),
|
||||||
params += [
|
|
||||||
("deal_type", "sale"),
|
("deal_type", "sale"),
|
||||||
("category", "living"),
|
("category", "living"),
|
||||||
("offer_type", "flat"),
|
("offer_type", "flat"),
|
||||||
|
|
@ -290,19 +176,25 @@ def _build_offers_url(
|
||||||
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
|
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
# Страница, на которой прогревается контекст перед запросом к BFF (#3264).
|
||||||
|
# BFF — не страница, а JSON-ручка SPA; навигацией браузера на неё через мобильные узлы
|
||||||
|
# пула приходит только ChallengeTimeout (прод, прогоны 5330 и 5351 — 9/9 и 6/6 запросов
|
||||||
|
# зависли), а подзапросом из прогретой выдачи — HTTP 200 за 6 секунд на всех четырёх
|
||||||
|
# узлах и всех шести комнатных корзинах (замер 30.08.2026, суммарно 6359 офферов против
|
||||||
|
# 6367, снятых напрямую с резидентного IP). Прогрев обязателен: рукопожатие QRATOR
|
||||||
|
# попадает в куки контекста именно при заходе на страницу.
|
||||||
|
_SWEEP_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
|
||||||
|
|
||||||
|
|
||||||
def _build_count_url(
|
def _build_count_url(
|
||||||
rooms: str,
|
rooms: str,
|
||||||
price_gte: int | None,
|
price_gte: int | None,
|
||||||
price_lte: int | None,
|
price_lte: int | None,
|
||||||
*,
|
|
||||||
address_guid: str,
|
|
||||||
aids: str | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
|
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
|
||||||
params: list[tuple[str, str]] = [("address", address_guid)]
|
params: list[tuple[str, str]] = [
|
||||||
if aids is not None:
|
("address", _EKB_ADDRESS_GUID),
|
||||||
params.append(("aids", aids))
|
("aids", _EKB_AREA_ID),
|
||||||
params += [
|
|
||||||
("deal_type", "sale"),
|
("deal_type", "sale"),
|
||||||
("category", "living"),
|
("category", "living"),
|
||||||
("offer_type", "flat"),
|
("offer_type", "flat"),
|
||||||
|
|
@ -369,7 +261,7 @@ class DomClickScraper(BaseScraper):
|
||||||
|
|
||||||
Counters (публичные после fetch_city):
|
Counters (публичные после fetch_city):
|
||||||
parse_failures — офферы с ошибкой маппинга
|
parse_failures — офферы с ошибкой маппинга
|
||||||
geo_filtered — офферы вне bbox профиля или с неверным offerRegionName
|
geo_filtered — офферы вне ЕКБ bbox или с неверным offerRegionName
|
||||||
blocked — True если sweep был прерван QRATOR-блоком
|
blocked — True если sweep был прерван QRATOR-блоком
|
||||||
fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape)
|
fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape)
|
||||||
buckets_total — сколько комнатных бакетов прогон собирался пройти
|
buckets_total — сколько комнатных бакетов прогон собирался пройти
|
||||||
|
|
@ -390,12 +282,8 @@ class DomClickScraper(BaseScraper):
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
proxy_provider: ProxyProvider | None = None,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
cookies: dict[str, str] | None = None,
|
cookies: dict[str, str] | None = None,
|
||||||
geo_profile: DomClickGeoProfile | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Гео-скоп прогона: GUID/aids в URL, страница прогрева и bbox гарда.
|
|
||||||
# Дефолт — ЕКБ (прежнее поведение всех вызовов без явного профиля).
|
|
||||||
self._geo = geo_profile or DEFAULT_GEO_PROFILE
|
|
||||||
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
||||||
# BrowserFetcher, delay_provider заменяет прямой импорт
|
# BrowserFetcher, delay_provider заменяет прямой импорт
|
||||||
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
||||||
|
|
@ -464,12 +352,11 @@ class DomClickScraper(BaseScraper):
|
||||||
|
|
||||||
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
|
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
|
||||||
вызывающим кодом (run_domclick_city_sweep), но:
|
вызывающим кодом (run_domclick_city_sweep), но:
|
||||||
- city_id — vestigial: гео-скоп берётся из geo_profile (GUID/aids/bbox),
|
- city_id — vestigial (EКБ захардкожен через GUID).
|
||||||
а не из city_id.
|
|
||||||
- rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
|
- rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
city_id: игнорируется (гео-скоп — в geo_profile скрейпера).
|
city_id: игнорируется (EKB захардкожен).
|
||||||
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
|
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
|
||||||
pages: максимальное число страниц на бакет (safety cap).
|
pages: максимальное число страниц на бакет (safety cap).
|
||||||
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
|
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
|
||||||
|
|
@ -621,18 +508,12 @@ class DomClickScraper(BaseScraper):
|
||||||
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0
|
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0
|
||||||
(бакет пропускается, sweep продолжается).
|
(бакет пропускается, sweep продолжается).
|
||||||
"""
|
"""
|
||||||
url = _build_count_url(
|
url = _build_count_url(rooms, price_gte, price_lte)
|
||||||
rooms,
|
|
||||||
price_gte,
|
|
||||||
price_lte,
|
|
||||||
address_guid=self._geo.address_guid,
|
|
||||||
aids=self._geo.aids,
|
|
||||||
)
|
|
||||||
logger.debug("domklik: count url=%s", url)
|
logger.debug("domklik: count url=%s", url)
|
||||||
html = await fetcher.fetch(
|
html = await fetcher.fetch(
|
||||||
url,
|
url,
|
||||||
origin=self._geo.origin_url,
|
origin=_SWEEP_ORIGIN,
|
||||||
referer=self._geo.origin_url,
|
referer=_SWEEP_ORIGIN,
|
||||||
cookies=self._cookies,
|
cookies=self._cookies,
|
||||||
fetch_mode="subresource",
|
fetch_mode="subresource",
|
||||||
)
|
)
|
||||||
|
|
@ -722,19 +603,12 @@ class DomClickScraper(BaseScraper):
|
||||||
offset = page_idx * PAGE_SIZE
|
offset = page_idx * PAGE_SIZE
|
||||||
if offset >= OFFSET_CAP:
|
if offset >= OFFSET_CAP:
|
||||||
break
|
break
|
||||||
url = _build_offers_url(
|
url = _build_offers_url(rooms, price_gte, price_lte, offset)
|
||||||
rooms,
|
|
||||||
price_gte,
|
|
||||||
price_lte,
|
|
||||||
offset,
|
|
||||||
address_guid=self._geo.address_guid,
|
|
||||||
aids=self._geo.aids,
|
|
||||||
)
|
|
||||||
logger.debug("domklik: offers url=%s", url)
|
logger.debug("domklik: offers url=%s", url)
|
||||||
html = await fetcher.fetch(
|
html = await fetcher.fetch(
|
||||||
url,
|
url,
|
||||||
origin=self._geo.origin_url,
|
origin=_SWEEP_ORIGIN,
|
||||||
referer=self._geo.origin_url,
|
referer=_SWEEP_ORIGIN,
|
||||||
cookies=self._cookies,
|
cookies=self._cookies,
|
||||||
fetch_mode="subresource",
|
fetch_mode="subresource",
|
||||||
)
|
)
|
||||||
|
|
@ -778,23 +652,22 @@ class DomClickScraper(BaseScraper):
|
||||||
break
|
break
|
||||||
|
|
||||||
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
|
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
|
||||||
"""Гео-гард по bbox профиля (и по имени региона, если оно задано).
|
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
|
||||||
|
|
||||||
У ЕКБ aids=20561 и так даёт чистую выдачу, а сверка имени остаётся
|
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
|
||||||
defensive-проверкой. Вне 66 profile.region_name обязан быть None:
|
|
||||||
offerRegionName там ненадёжен — офферы Новой Москвы приходят как
|
|
||||||
«г. Говорово», и сверка по имени вырезала бы законную выдачу.
|
|
||||||
"""
|
"""
|
||||||
expected_region = self._geo.region_name
|
region = item.get("offerRegionName", "")
|
||||||
if expected_region is not None and item.get("offerRegionName", "") != expected_region:
|
if region != _EKB_REGION_NAME:
|
||||||
return False
|
return False
|
||||||
loc = item.get("location") or {}
|
loc = item.get("location") or {}
|
||||||
lat = loc.get("lat")
|
lat = loc.get("lat")
|
||||||
lon = loc.get("lon")
|
lon = loc.get("lon")
|
||||||
if lat is None or lon is None:
|
if lat is None or lon is None:
|
||||||
return False
|
return False
|
||||||
lat_min, lat_max, lon_min, lon_max = self._geo.bbox
|
return (
|
||||||
return lat_min <= float(lat) <= lat_max and lon_min <= float(lon) <= lon_max
|
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
|
||||||
|
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
|
||||||
|
)
|
||||||
|
|
||||||
def _map_item(
|
def _map_item(
|
||||||
self, item: dict[str, Any], *, force_rooms: int | None = None
|
self, item: dict[str, Any], *, force_rooms: int | None = None
|
||||||
|
|
@ -971,7 +844,6 @@ async def scrape_domclick_city(
|
||||||
pages: int = 100,
|
pages: int = 100,
|
||||||
*,
|
*,
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
geo_profile: DomClickGeoProfile | None = None,
|
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
"""Удобная точка входа для вызова из Celery tasks.
|
"""Удобная точка входа для вызова из Celery tasks.
|
||||||
|
|
||||||
|
|
@ -980,7 +852,5 @@ async def scrape_domclick_city(
|
||||||
import asyncio
|
import asyncio
|
||||||
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
|
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
|
||||||
"""
|
"""
|
||||||
async with DomClickScraper(
|
async with DomClickScraper(config, delay_provider=delay_provider) as scraper:
|
||||||
config, delay_provider=delay_provider, geo_profile=geo_profile
|
|
||||||
) as scraper:
|
|
||||||
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||||
|
|
|
||||||
|
|
@ -80,37 +80,18 @@ python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
|
||||||
|
|
||||||
# 3) продолжить прерванный прогон по сохранённому плану коридоров
|
# 3) продолжить прерванный прогон по сохранённому плану коридоров
|
||||||
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
|
||||||
|
|
||||||
# 4) DomClick по Московской области (--region — только для domclick)
|
|
||||||
python .\collect.py --platform domclick --region 50 --full `
|
|
||||||
--batch-id mo-serp-domclick-20260912-1200
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
|
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
|
||||||
Дефолт — `--platform avito --region 77` (Москва).
|
Дефолт — `--platform avito`.
|
||||||
|
|
||||||
`--region {77,50}` переключает регион **только для `--platform domclick`**: у DomClick
|
Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками,
|
||||||
Москва и область — разные GUID листинга (`MSK_DOMCLICK_GUID` / `MO_DOMCLICK_GUID` в
|
|
||||||
`collect.py`) и разные прогоны. У Авито/Циана/Яндекса единый скоуп «Москва и МО»
|
|
||||||
собирается одним прогоном (`moskva_i_mo` / `region=-1` / `rgid=741964`) и делится на
|
|
||||||
регионы уже при импорте в `listings` — отдельного прогона по области у них нет, и
|
|
||||||
`--region 50` с любой площадкой кроме domclick падает `SystemExit` при разборе аргументов.
|
|
||||||
Скрипт также сверяет GUID, зашитый в фактический `base_url` (параметр `address`), с
|
|
||||||
GUID, ожидаемым для выбранного `--region` — расхождение (например, ручной `--base-url`
|
|
||||||
с московским GUID при `--region 50`) останавливает прогон до первого запроса, а не
|
|
||||||
заливает чужой регион под областной `batch_id`.
|
|
||||||
|
|
||||||
Ключи: `--platform {avito,cian,yandex,domclick}` (дефолт avito), `--region {77,50}`
|
|
||||||
(дефолт 77, только domclick), `--delay` (пауза между загрузками,
|
|
||||||
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
|
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
|
||||||
`request_delay_sec` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
|
`request_delay_sec` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
|
||||||
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
|
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
|
||||||
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
|
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
|
||||||
от `--platform`/`--region`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>`, а при
|
от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени,
|
||||||
`--platform domclick --region 50` — `mo-serp-domclick-<UTC>`: платформа/регион в имени,
|
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
|
||||||
чтобы прогоны разных площадок и регионов не затирали друг другу план/CSV — регион в
|
|
||||||
таблице `msk_raw.domclick_cards` иначе не выражается, отдельной колонки под него нет),
|
|
||||||
`--out-dir`,
|
|
||||||
`--ssh-host/--container/--db-user/--db-name`.
|
`--ssh-host/--container/--db-user/--db-name`.
|
||||||
|
|
||||||
`AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ —
|
`AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ —
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,9 @@
|
||||||
(подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
|
(подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
|
||||||
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
|
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
|
||||||
|
|
||||||
Платформа выбирается ключом --platform {avito,cian,yandex,domclick} (дефолт
|
Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) — см.
|
||||||
avito) — см. класс PlatformAdapter ниже. У каждой платформы свой потолок
|
класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой
|
||||||
пагинации, свой билдер URL коридора и своя целевая таблица в msk_raw.
|
билдер URL коридора и своя целевая таблица в msk_raw.
|
||||||
|
|
||||||
--region {77,50} (дефолт 77 — Москва) переключает регион сбора и работает
|
|
||||||
ТОЛЬКО с --platform domclick: у DomClick Москва и область — разные GUID и
|
|
||||||
разные прогоны. У Авито/Циана/Яндекса корпус Москва+МО собирается одним
|
|
||||||
прогоном (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже
|
|
||||||
при импорте, поэтому --region 50 с любой другой площадкой падает SystemExit.
|
|
||||||
|
|
||||||
Пример прогона по Московской области (DomClick, полный проход):
|
|
||||||
python collect.py --platform domclick --region 50 --full \
|
|
||||||
--batch-id mo-serp-domclick-20260912-1200
|
|
||||||
|
|
||||||
Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
|
Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
|
||||||
"""
|
"""
|
||||||
|
|
@ -163,9 +153,7 @@ YANDEX_MAX_PAGES = 25
|
||||||
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
|
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
|
||||||
YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
|
YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
|
||||||
|
|
||||||
# DomClick: JSON BFF listing API, Москва + отдельно Московская область
|
# DomClick: JSON BFF listing API, Москва (эпик #2989, трек 1).
|
||||||
# (эпик #2989, трек 1). У DomClick, в отличие от Авито/Циана/Яндекса, Москва и
|
|
||||||
# область — РАЗНЫЕ прогоны с разными GUID (--region), а не один общий скоуп.
|
|
||||||
#
|
#
|
||||||
# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот
|
# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот
|
||||||
# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ —
|
# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ —
|
||||||
|
|
@ -174,74 +162,28 @@ YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
|
||||||
# листинга точно совпадает с сайтом.
|
# листинга точно совпадает с сайтом.
|
||||||
MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553"
|
MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553"
|
||||||
|
|
||||||
# GUID Московской области проверен живым запросом 12.09: тот же listing-эндпойнт
|
DEFAULT_DOMCLICK_BASE_URL = (
|
||||||
# с этим address отдаёт result.pagination.total = 17572, aids тоже не нужен.
|
|
||||||
MO_DOMCLICK_GUID = "9930cc20-32c6-4f6f-a55e-cd67086c5171"
|
|
||||||
|
|
||||||
|
|
||||||
def _domclick_base_url(guid: str) -> str:
|
|
||||||
"""Base_url DomClick BFF listing API под конкретный GUID региона.
|
|
||||||
|
|
||||||
Обе региональные константы (MSK/MO) обязаны строиться этой функцией, а не
|
|
||||||
двумя независимыми f-строками, — иначе расхождение параметров (limit,
|
|
||||||
offset, sort) между Москвой и областью было бы легко внести незаметно.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
"https://bff-search-web.domclick.ru/api/offers/v1"
|
"https://bff-search-web.domclick.ru/api/offers/v1"
|
||||||
f"?address={guid}&deal_type=sale&category=living&offer_type=flat"
|
f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat"
|
||||||
"&sort=qi&sort_dir=desc&limit=20&offset=0"
|
"&sort=qi&sort_dir=desc&limit=20&offset=0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_DOMCLICK_BASE_URL = _domclick_base_url(MSK_DOMCLICK_GUID)
|
|
||||||
MO_DOMCLICK_BASE_URL = _domclick_base_url(MO_DOMCLICK_GUID)
|
|
||||||
|
|
||||||
|
|
||||||
def _domclick_extract_guid(base_url: str) -> str | None:
|
|
||||||
"""GUID региона, зашитый в параметр address базового URL DomClick."""
|
|
||||||
return dict(parse_qsl(urlsplit(base_url).query)).get("address")
|
|
||||||
|
|
||||||
|
|
||||||
# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную
|
# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную
|
||||||
# страницу (20 items), offset=2000 отвечает HTTP 400
|
# страницу (20 items), offset=2000 отвечает HTTP 400
|
||||||
# {"statusCode":400,"error":"Bad Request"}. Одинаково и для Москвы, и для
|
# {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации —
|
||||||
# области (проверено на обоих GUID) — значит жёсткий потолок пагинации общий:
|
|
||||||
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
|
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
|
||||||
DOMCLICK_PAGE_SIZE = 20
|
DOMCLICK_PAGE_SIZE = 20
|
||||||
DOMCLICK_MAX_PAGES = 100
|
DOMCLICK_MAX_PAGES = 100
|
||||||
|
|
||||||
# bbox — единственный надёжный гео-гард для DomClick, отдельно для Москвы (с
|
# bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick.
|
||||||
# ТиНАО) и для Московской области. offerRegionName использовать НЕЛЬЗЯ: часть
|
# offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с
|
||||||
# офферов Новой Москвы приходит с именами вида "г. Говорово", а не "Москва" —
|
# именами вида "г. Говорово", а не "Москва" — гард по имени региона молча
|
||||||
# гард по имени региона молча вырезал бы легитимные лоты. scraper._is_geo_ok()
|
# вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит:
|
||||||
# кита сюда тоже не подходит: он захардкожен на offerRegionName ==
|
# он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально
|
||||||
# "Екатеринбург" и отбросил бы буквально всю выдачу обоих регионов.
|
# всю московскую выдачу.
|
||||||
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
|
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
|
||||||
_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97
|
_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97
|
||||||
|
|
||||||
# bbox Московской области — литерал, РАВНЫЙ bbox_region региона 50 в реестре
|
|
||||||
# tradein-mvp/backend/app/services/regions.py (сослались на реестр, значение
|
|
||||||
# впечатано сюда буквально: этот скрипт лежит вне пакета backend и не может
|
|
||||||
# его импортировать). Московский bbox область не накрывает: Серпухов
|
|
||||||
# (lat~54.9), Коломна (lon~39.4), Дубна (lat~56.7) все лежат вне _MSK_*.
|
|
||||||
_MO_LAT_MIN, _MO_LAT_MAX = 54.20, 56.96
|
|
||||||
_MO_LON_MIN, _MO_LON_MAX = 35.14, 40.21
|
|
||||||
|
|
||||||
_MSK_BBOX: tuple[float, float, float, float] = (
|
|
||||||
_MSK_LAT_MIN, _MSK_LAT_MAX, _MSK_LON_MIN, _MSK_LON_MAX,
|
|
||||||
)
|
|
||||||
_MO_BBOX: tuple[float, float, float, float] = (
|
|
||||||
_MO_LAT_MIN, _MO_LAT_MAX, _MO_LON_MIN, _MO_LON_MAX,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Активный гео-гард, который реально читает _domclick_parse_cards. Модульная
|
|
||||||
# переменная, а НЕ аргумент функции: сигнатура parse_cards зафиксирована типом
|
|
||||||
# PlatformAdapter.parse_cards (Callable[[Any, str], list[Any]]) и завязана на
|
|
||||||
# него все остальные платформы, менять её ради одного DomClick нельзя. collect()
|
|
||||||
# выставляет это значение один раз при старте прогона, по --region, до первого
|
|
||||||
# запроса к сети.
|
|
||||||
_DOMCLICK_BBOX: tuple[float, float, float, float] = _MSK_BBOX
|
|
||||||
|
|
||||||
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
|
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
|
||||||
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
||||||
PRICE_CEIL = 2_000_000_000
|
PRICE_CEIL = 2_000_000_000
|
||||||
|
|
@ -770,8 +712,8 @@ def _domclick_parse_cards(scraper: Any, html: str) -> list[Any]:
|
||||||
lon = float(loc["lon"])
|
lon = float(loc["lon"])
|
||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
lat_min, lat_max, lon_min, lon_max = _DOMCLICK_BBOX
|
if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX
|
||||||
if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max):
|
and _MSK_LON_MIN <= lon <= _MSK_LON_MAX):
|
||||||
continue
|
continue
|
||||||
lot = scraper._map_item(item)
|
lot = scraper._map_item(item)
|
||||||
if lot is not None:
|
if lot is not None:
|
||||||
|
|
@ -1469,31 +1411,6 @@ async def collect(args: argparse.Namespace) -> int:
|
||||||
)
|
)
|
||||||
base_url = plan.base_url if plan is not None else args.base_url
|
base_url = plan.base_url if plan is not None else args.base_url
|
||||||
|
|
||||||
# Гвард региона DomClick — САМОЕ ВАЖНОЕ в этом прогоне. --base-url можно
|
|
||||||
# передать вручную с любым GUID, а base_url резюме приходит из сохранённого
|
|
||||||
# плана в обход --region вовсе; расхождение GUID и --region залило бы
|
|
||||||
# Москву под областным batch_id (или наоборот) и отравило бы корпус.
|
|
||||||
# Проверка стоит здесь намеренно: этой точки достигают все три источника
|
|
||||||
# base_url — дефолтный (выставлен в parse_args по --region), переданный
|
|
||||||
# вручную через --base-url и восстановленный из плана при --resume — уже
|
|
||||||
# ПОСЛЕ сверки plan.base_url != args.base_url чуть выше, так что при
|
|
||||||
# --resume расхождение платформы/URL с CLI отловится раньше и с более
|
|
||||||
# точным сообщением.
|
|
||||||
if args.platform == "domclick":
|
|
||||||
expected_guid = MO_DOMCLICK_GUID if args.region == 50 else MSK_DOMCLICK_GUID
|
|
||||||
actual_guid = _domclick_extract_guid(base_url)
|
|
||||||
if actual_guid != expected_guid:
|
|
||||||
raise SystemExit(
|
|
||||||
f"--region {args.region} ожидает GUID {expected_guid!r} в base_url"
|
|
||||||
f" (параметр address), а фактический base_url несёт GUID"
|
|
||||||
f" {actual_guid!r}. Поправь --region либо --base-url — иначе"
|
|
||||||
f" прогон зальёт чужой регион под batch_id={args.batch_id}."
|
|
||||||
)
|
|
||||||
global _DOMCLICK_BBOX
|
|
||||||
_DOMCLICK_BBOX = _MO_BBOX if args.region == 50 else _MSK_BBOX
|
|
||||||
print(f"DomClick: --region={args.region}, guid={expected_guid}, "
|
|
||||||
f"bbox={_DOMCLICK_BBOX}", flush=True)
|
|
||||||
|
|
||||||
page_budget = None if args.full else args.measure
|
page_budget = None if args.full else args.measure
|
||||||
mode = "FULL" if args.full else f"MEASURE<={page_budget}"
|
mode = "FULL" if args.full else f"MEASURE<={page_budget}"
|
||||||
print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; "
|
print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; "
|
||||||
|
|
@ -1606,11 +1523,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
)
|
)
|
||||||
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
|
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
|
||||||
help="площадка сбора (дефолт avito)")
|
help="площадка сбора (дефолт avito)")
|
||||||
p.add_argument("--region", type=int, choices=(77, 50), default=77,
|
|
||||||
help="регион сбора: 77 Москва (дефолт), 50 Московская область"
|
|
||||||
" (поддерживается только --platform domclick)")
|
|
||||||
p.add_argument("--base-url", default=None,
|
p.add_argument("--base-url", default=None,
|
||||||
help="базовый URL выдачи (дефолт зависит от --platform/--region)")
|
help="базовый URL выдачи (дефолт зависит от --platform)")
|
||||||
p.add_argument("--measure", type=int, default=100, metavar="N",
|
p.add_argument("--measure", type=int, default=100, metavar="N",
|
||||||
help="режим замера: не больше N загрузок страниц (дефолт 100)")
|
help="режим замера: не больше N загрузок страниц (дефолт 100)")
|
||||||
p.add_argument("--full", action="store_true",
|
p.add_argument("--full", action="store_true",
|
||||||
|
|
@ -1637,42 +1551,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
p.add_argument("--db-user", default="tradein")
|
p.add_argument("--db-user", default="tradein")
|
||||||
p.add_argument("--db-name", default="tradein")
|
p.add_argument("--db-name", default="tradein")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
if args.region == 50 and args.platform != "domclick":
|
|
||||||
p.error(
|
|
||||||
"--region 50 поддерживается только для --platform domclick: корпус"
|
|
||||||
" Авито/Циана/Яндекса по Москве и области собирается ОДНИМ прогоном"
|
|
||||||
" (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже"
|
|
||||||
" при импорте — отдельного областного прогона у этих площадок нет."
|
|
||||||
)
|
|
||||||
if args.base_url is None:
|
if args.base_url is None:
|
||||||
if args.platform == "domclick" and args.region == 50:
|
|
||||||
args.base_url = MO_DOMCLICK_BASE_URL
|
|
||||||
else:
|
|
||||||
args.base_url = ADAPTERS[args.platform].default_base_url
|
args.base_url = ADAPTERS[args.platform].default_base_url
|
||||||
if args.target_count is None:
|
if args.target_count is None:
|
||||||
args.target_count = ADAPTERS[args.platform].default_target
|
args.target_count = ADAPTERS[args.platform].default_target
|
||||||
if args.batch_id is None:
|
if args.batch_id is None:
|
||||||
prefix = (
|
args.batch_id = (
|
||||||
"mo-serp-domclick" if (args.platform == "domclick" and args.region == 50)
|
f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
else f"msk-serp-{args.platform}"
|
|
||||||
)
|
)
|
||||||
args.batch_id = prefix + "-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
||||||
if not _BATCH_ID_RE.match(args.batch_id):
|
if not _BATCH_ID_RE.match(args.batch_id):
|
||||||
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
|
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
|
||||||
# batch_id — ЕДИНСТВЕННАЯ метка региона в msk_raw.domclick_cards: колонки
|
|
||||||
# региона там нет, партии потом разделяются по префиксу имени батча. Имя,
|
|
||||||
# заданное руками вразрез с --region, сделало бы областную партию
|
|
||||||
# неотличимой от московской уже после заливки, когда чинить поздно. Поэтому
|
|
||||||
# префикс сверяется с регионом так же строго, как GUID в base_url.
|
|
||||||
if args.platform == "domclick":
|
|
||||||
want = "mo-serp-domclick" if args.region == 50 else "msk-serp-domclick"
|
|
||||||
other = "msk-serp-domclick" if args.region == 50 else "mo-serp-domclick"
|
|
||||||
if args.batch_id.startswith(other) or not args.batch_id.startswith(want):
|
|
||||||
p.error(
|
|
||||||
f"--batch-id должен начинаться с {want!r} при --region"
|
|
||||||
f" {args.region}: имя батча — единственный признак региона в"
|
|
||||||
f" msk_raw.domclick_cards, колонки региона там нет."
|
|
||||||
)
|
|
||||||
if args.measure < 1:
|
if args.measure < 1:
|
||||||
p.error("--measure должен быть >= 1")
|
p.error("--measure должен быть >= 1")
|
||||||
return args
|
return args
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue