Compare commits

..

8 commits

Author SHA1 Message Date
5ed4a4ec25 fix(mera/b2c): на мобильном липкий хром съедал 42% экрана
Жалоба «много всплывашек, экрана не видно» — это не всплывашки, а постоянный
хром: на 375×812 липкая шапка (235 px) и фиксированная нижняя панель (109 px)
вместе занимали 42% экрана и отъедали их при любой прокрутке. Замер тем же
способом (getBoundingClientRect по элементам с position fixed/sticky): было
366 px = 45,1% высоты экрана, стало 119,5 px = 14,7%. На 1280 — 135,5 px до и
после, без изменений.

Шапка на узком экране сведена к одной строке (лого + «Для бизнеса»):

  - навигация по секциям скрыта — четыре пункта переносились в две строки, а
    на мобильном к секциям всё равно доезжают прокруткой; «Статьи» и «МЕРА для
    бизнеса» остаются ссылками в подвале, «Продажа под ключ» и на десктопе
    неактивная заглушка;

  - CTA скрыт — он вёл туда же, куда нижняя панель, и два одинаковых призыва
    на одном экране это и есть жалоба; панель осталась, шапка уступила.

Селектор города (CityPicker) удалён совсем, а не спрятан: он не владел своим
выбором — состояние жило внутри компонента и никуда не отправлялось, — тогда
как гео-гейт расчёта стоит на городе из формы (FreeCheckCard, #2576). Два
места, задающих одно значение, из которых работает одно, — дефект сам по себе;
то же правило уже записано в InnerHeader. Граница покрытия, ради которой
выпадашку открывали, стоит текстом в герое.

Нижняя панель на узком экране — только кнопка: её строка переносилась на две-
три и делала панель втрое выше кнопки. То же обещание остаётся словами героя.

Шаг медиазапроса — 1000 px, а не общий для файла 720: на 720 шапка макета всё
ещё переносится в две строки (замерено: 109 px хрома), то есть возвращать её
там значило бы вернуть тот же дефект планшету.
2026-08-29 23:20:54 +05:00
1f3d8d0b15 fix(mera/b2c): вернуть координаты сделки, снесённые моим же коммитом про студию
Коммит 555cce44 обещал в сообщении одно — подписать студию вместо «0-к», — а
сделал ещё и другое: удалил миграцию 280_landing_showcase_deals_coords.sql и
откатил правки mera.py, landing_showcase_deals.py и двух тестов из коммита
cd2a7671. Заметил не я: об этом написал агент, которому потом достался номер
281 и который увидел дыру на 280.

ПРИЧИНА. `git commit` фиксирует ИНДЕКС ЦЕЛИКОМ, а не то, что было добавлено
последним `git add`. В индексе главного рабочего дерева лежали staged-удаления,
оставшиеся от параллельных агентов (они работают в своих worktree, но индекс
основного дерева переживает переключения веток). Я добавил два файла, а
закоммитил вместе с ними чужие удаления.

Что восстановлено: миграция 280 целиком, поля lat/lon в ShowcaseDeal, перенос
координат в пересчёте витрины, оба теста.

Обе величины нужны и не заменяют друг друга: схема улицы (281) закрывает 92%
сделок, координата (280) — карту района для остальных 8%. Конфликты сведены
вручную: механическое «оставить обе стороны» задвоило список колонок в INSERT
и в SELECT, что тесты бы пропустили, а прод — нет.

Проверено: 5085 passed, 35 skipped; миграции 275-281 без дыры; список колонок
INSERT сверен со списком значений программно (15 и 15, порядок совпадает).
2026-08-29 22:47:46 +05:00
0e22a1ee22 feat(mera/b2c): в карточке игры схема улицы сделки, а не пятно района
Карта показывала ПОЛИГОН РАЙОНА — десятки квадратных километров, на которых
одинаково выглядят сделка у парка и сделка у ТЭЦ. Схема улиц уже приезжает
строкой витрины (`street_scheme`), рисовать её было нечем.

`StreetMapV3` — серверный компонент, инлайновый <svg>, НИ ОДНОГО внешнего
запроса: тайлы отдали бы провайдеру IP посетителя и то, что он смотрит, на
странице, которая обещает «без звонков и регистрации» (правило шапки
layout.tsx, по нему же в дереве self-hosted шрифты). Слои снизу вверх: плашка
и приглушённая сетка (уже были), вода, фоновые дороги толщиной по классу OSM,
целевая улица акцентом поверх мягкого ореола, подписи соседних улиц
моноширинным.

DealMapV3 ОСТАЁТСЯ и не тронут. Улица матчится у 92.1% строк витрины, у
остальных схемы нет — там по-прежнему район. Ветвление стоит в GuessGameV3,
чтобы обе карты остались тупыми: каждая рисует то, что ей дали, и ни одна не
подставляет вместо отсутствующих данных правдоподобное.

ЧТО КАРТА ГОВОРИТ О СЕБЕ. Подпись — «<УЛИЦА> · ДОМ НЕ ИЗВЕСТЕН». Адрес сделки
уровня улицы, номер дома известен у 2.7% сделок, координата окна — центроид
улицы. Слов «объект» и «адрес» в карточке нет; тест держит и то и другое.
Точку дома поставить нечем и по построению: в `street_scheme` нет ни констант
проекции, ни координат окна.

ЧЕГО НЕ РИСУЕМ. Зданий: `cad_buildings` — 18 307 контуров на город, в плотном
центре 51 здание на радиус 450 м, где их в разы больше; нарисованная застройка
заявляла бы полноту, которой нет. Улицы — фильтрованная выгрузка «источников
шума», дворовых и служебных проездов в ней нет, поэтому фоновые дороги
приглушены: это контекст, а не план квартала.

КАДР — `slice`, а не `meet`: окно квадратное, карточка нет, и `meet` оставил бы
поля по бокам. Обрезка безопасна ровно потому, что целевая улица лежит в ЦЕНТРЕ
окна по построению; обрезаются края с частью подписей соседей.

ДОСТУПНОСТЬ — как у DealMapV3, `aria-hidden`: всё, что схема сообщает, стоит
рядом текстом. `role="img"` заставил бы прочитать то же самое дважды, а
названия соседних улиц без их взаимного расположения ничего не значат.

ЦВЕТА — токенами: `--b2c-water` (вода отдельным оттенком, иначе склеивается с
дорогами) и `--b2c-muted-dark` (подписи на тёмной плашке: `muted` даёт там
3.2:1). Литералов в CSS не добавлено.

Подпись объекта лежит ПОВЕРХ карты, и карта теперь доходит до краёв — добавлено
затемнение под ней и плашка под подписью карты; без них белый текст пересекался
с подсвеченной улицей. Псевдоэлемент позиционирован, поэтому детям
`.gameMapBottom` задан `position: relative` — иначе затемнение красится поверх
текста и стирает его (поймано скриншотом, а не рассуждением).

Тесты (каждый сломан вручную и покраснел): кадр из данных и `slice`; ореол шире
линии и по одному на путь; иерархия дорог и незнакомый класс не дают нулевой
толщины; вода отделена; подпись называет улицу и говорит про дом; `aria-hidden`;
ветвление карточки в обе стороны.

Проверено глазами на dev-сервере, стаб — реальные строки прода плюс схемы,
собранные той же проекцией по геометрии gendesign: улица сматчилась; не
сматчилась (виден район); полей схемы нет вовсе (тот же район, без падения).
2026-08-29 22:41:07 +05:00
b2b702dca7 feat(mera/b2c): улица сделки доезжает до витрины готовой схемой, а не геометрией
Карта в карточке игры показывала полигон района — единственную геометрию, до
которой у tradein был доступ. Улицы живут в базе gendesign, foreign table и
гранта на них не было.

Мост по образцу соседей (v_tradein_cad_buildings / v_tradein_osm_poi_ekb):
gendesign 195 — вьюха v_tradein_osm_roads_ekb (highway + water) с GRANT В ТОМ
ЖЕ ФАЙЛЕ (после #3227 грант отдельной миграцией теряется при пересоздании);
tradein 281 — foreign table gendesign_osm_roads_ekb плюс колонки street_name /
street_scheme в landing_showcase_deals.

Пересчёт витрины кладёт в строку УЖЕ СПРОЕЦИРОВАННЫЕ SVG-пути окна 840x840 м
вокруг центра улицы. Проекция — та же равнопромежуточная с cos(широты), что в
export_ekb_districts_svg.py; второй в проекте нет. Замер 2026-08-29: GeoJSON
того же окна 7-8 КБ на строку, схема — 2.8-2.9 КБ.

ЧТО ДАННЫЕ ВЫДЕРЖИВАЮТ, И НИ СЛОВОМ БОЛЬШЕ
  · Это УЛИЦА, а не дом: deals.address уровня улицы, номер дома у 2.7% сделок.
    В схеме намеренно НЕТ координат окна и констант проекции — точку дома по
    ней нельзя поставить даже случайно. Это замок, а не забывчивость.
  · Зданий нет: cad_buildings — 18 307 контуров на город, в плотном центре 51
    здание на радиус 450 м, где их в разы больше. Нарисованная застройка
    заявляла бы полноту, которой в данных нет.
  · Улицы — фильтрованная выгрузка «источников шума»: именованные покрыты
    хорошо, дворовые и служебные проезды отсутствуют.
  · Улица сматчилась у 550 названий из 654 — 31 410 сделок из 34 021 (92.3%).
    Остальным street_scheme = NULL, и это штатно: фронт показывает район,
    который для этого и оставлен.
  · Перекрёсток («Челюскинцев/Шейнкмана») берём первой улицей: таких адресов
    три на 34 021 сделку, и обе улицы одинаково верны на уровне улицы.
  · Наличие схемы НА ОТБОР СТРОК НЕ ВЛИЯЕТ — то же правило, что запрещает
    отбор по величине ошибки: иначе витрина показывала бы не работу оценщика,
    а те 92% адресов, что удобно легли на OSM.

Тесты (каждый сломан вручную и покраснел): нормализация на реальных адресах
включая ё/е и «8 Марта»; недоступная вьюха и упавший запрос дают None, а не
исключение; схема не раздувается — потолок в байтах на реальной плотности плюс
прямая проверка округления до 0.1.
2026-08-29 22:16:05 +05:00
555cce44ad fix(mera/b2c): студия подписывалась как «0-к» на витрине сделок
Увидел на скриншоте карточки игры: «0-к, 25,9 м²». Замер на проде — 3 строки
витрины из 20 имеют rooms = 0, то есть каждая шестая карточка так и выглядит.
«0-к» читается как ошибка выгрузки, а не как тип квартиры.

Студией её называет и наш собственный бэктест (per_rooms.label в
scripts/backtest_estimator.py), и рынок.

Тест двусторонний и фальсифицирован: возврат «0-к» для rooms=0 красит его по
значению, обратная правка — снова зелено.
2026-08-29 21:36:29 +05:00
08ee1dcc24 feat(mera/b2c): в карточке игры настоящая карта вместо декорации — и подпись, которую данные выдерживают
Блок `gameMap` рисовал CSS-сетку и два прямоугольника-«дороги», а подпись
говорила «ОБЪЕКТ · РАЙОН» — то есть заявляла местоположение объекта. Географии
там не было вовсе.

Что сделано: `DealMapV3` — инлайновый <svg> по статике `ekb-districts.ts`, без
единого внешнего запроса (то же правило, по которому в этом дереве self-hosted
шрифты). Район сделки — заливка и обводка акцентом, соседние — контур; кадр по
bbox района с запасом, не по городу: в городском кадре район занимает несколько
процентов площади. Точка — по `project(lon, lat)` координаты витрины.

ПОДПИСЬ. Координата в данных — ЦЕНТРОИД УЛИЦЫ, не дом: 34 021 сделка, 34 017 с
координатой, различных точек 991, номер дома известен у 2.7% (прод, 29.08.2026).
Поэтому карта подписана «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · РАЙОН» — она произносит
границу вслух, а не оставляет читателю догадываться по размеру маркера. Слова
«объект» и «адрес» в карточке не появляются.

ТОЧКИ НЕТ — ДВА СЛУЧАЯ, оба дают карту с подсвеченным районом и без прочерка:
координаты нет; координата есть, но лежит вне кадра. Второе не теоретическое —
2 132 строки из 34 021 (6.3%) в той же выборке имеют координату за пределами
города, встречаются точки за сотни километров. Прижать такую к краю значило бы
показать место, которого в данных нет; подпись в обоих случаях — «КООРДИНАТЫ
НЕТ».

Доступность: <svg> aria-hidden, всё его содержание (район) стоит рядом текстом
дважды — в подписи карты и в мете сделки.

Гейт витринных чисел: из скана исключён сгенерированный `ekb-districts.ts` —
координаты полигонов не утверждают ничего о рынке. Исключение не бланковое:
добавлена проверка, что исключённый файл несёт маркер генератора.

Проверено глазами на dev-сервере с заглушкой бэкенда: три состояния (Кировский с
точкой, Чкаловский — кадр едет, сделка без координаты).
2026-08-29 21:32:32 +05:00
cd2a767178 feat(mera/b2c): координата сделки доезжает до витрины — с записанной границей честности
Карта лэндинга не может показать точку, пока её нет в витрине: район, комнаты,
площадь и квартал в `landing_showcase_deals` есть, координаты не было.

Что сделано: миграция 280 добавляет lat/lon (double precision, NULLABLE),
задача пересчёта переносит их из `deals`, ручка /showcase отдаёт их как
Optional[float].

ГРАНИЦА ЧЕСТНОСТИ, записанная в трёх местах (COMMENT колонок, `note` каждой
строки, докстринг задачи), а не только в голове автора: это ЦЕНТРОИД УЛИЦЫ, а
не дом. Замер на проде 2026-08-29 по той самой выборке, из которой набирается
витрина (deals, city='Екатеринбург', deal_date >= '2025-01-01'): 34 021 сделка,
34 017 с координатой, но РАЗЛИЧНЫХ точек всего 991 — ≈34 сделки в одной точке,
при 2.7% известных номеров дома. Точка верна на масштабе района и улицы и
неверна на масштабе дома; `note` едет на фронт вместе с числами, поэтому
следующий, кто возьмётся зумить карту, об этом споткнётся.

NULLABLE и без отбраковки: строка без координаты остаётся на витрине с
lat=lon=None. Выбрасывать сделку за отсутствие точки — отбор по признаку, не
связанному с качеством оценки, то есть та же порча витрины, которую здесь уже
чинили (порог по величине ошибки).

Тесты двусторонние, каждый проверен фальсификацией — краснеет по ЗНАЧЕНИЮ:
  * перестановка lat/lon в build_row  → 60.6055 == 56.8386
  * `if lat is None: return None`     → None is not None
  * перестановка lat/lon в ручке      → 60.6055 == 56.8386
  * фильтр строк без координат в ручке → len([]) == 1
Перестановку широты и долготы не ловит ни схема, ни тип (обе float), поэтому
в фикстурах намеренно непохожие величины: 56.8386 против 60.6055.

Фронтенд не тронут — его делает следующий шаг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:10:02 +05:00
f88e06c090 feat(mera/b2c): границы районов ЕКБ статикой — основа настоящей карты в игре
Карточка игры «Сыграйте против МЕРЫ» показывает декоративный прямоугольник:
сетка и две «дороги», нарисованные CSS. Заменяем настоящей геометрией.

ПОЧЕМУ СТАТИКА, А НЕ ТАЙЛЫ. Публичное дерево mera-public держится без единого
внешнего запроса — шрифты self-hosted, Leaflet с unpkg сюда намеренно не тянули
(шапка layout.tsx). Тайловый провайдер получал бы IP посетителя и то, что он
смотрит, на странице, которая обещает «без звонков и регистрации». Границы
районов не меняются годами, раздавать их в рантайме незачем.

Что в коммите:
· backend/scripts/export_ekb_districts_svg.py — генератор. У данных обязано быть
  проверяемое происхождение: источник — боевая gendesign_ekb_districts_geom
  (OSM через FDW, читается с 29.08 — грант #3227), допуск упрощения и проекция
  названы в докстринге вместе с причиной выбора.
· _components/v3/ekb-districts.ts — сгенерированное, руками не править.
  8 районов, 7826 символов путей, плюс project() для точки (lon,lat).

Упрощение 0.0012° (~70-130 м) выбрано под масштаб карточки — она показывает
район целиком, где такая ошибка меньше толщины линии.
2026-08-29 21:02:27 +05:00
23 changed files with 281 additions and 1145 deletions

View file

@ -150,6 +150,13 @@ NOISE_L_BASE: dict[str, float] = {
} }
def _wind_label(deg: float) -> str:
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
idx = round(deg / 45) % 8
return rose[idx]
# Координаты центра ЕКБ — Площадь 1905 года # Координаты центра ЕКБ — Площадь 1905 года
EKB_CENTER_LAT: float = 56.838011 EKB_CENTER_LAT: float = 56.838011
EKB_CENTER_LON: float = 60.597474 EKB_CENTER_LON: float = 60.597474

View file

@ -6,6 +6,29 @@ from pydantic import BaseModel, ConfigDict, Field
# ── #105 Phase 5: Recent permits schemas ────────────────────────────────────── # ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────
class RecentPermit(BaseModel):
"""Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""
permit_type: str
permit_number: str
issue_date: str | None
developer_name: str | None
developer_inn: str | None
object_name: str | None
object_type: str | None
construction_address: str | None
total_area_sqm: float | None
class PermitsSummary(BaseModel):
"""Агрегированная сводка по разрешениям в квартале."""
rns_count: int
rve_count: int
rns_total_area_sqm: float
by_developer: list[dict[str, Any]]
# ── Connection points schemas (issue #115) ──────────────────────────────────── # ── Connection points schemas (issue #115) ────────────────────────────────────
@ -541,6 +564,16 @@ class DeveloperAttributionResult(BaseModel):
# ── Layout analysis (Issue #113) ─────────────────────────────────────────── # ── Layout analysis (Issue #113) ───────────────────────────────────────────
class LayoutSignature(BaseModel):
"""Минимальная сигнатура планировки = (room_bucket, area_bin).
Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
"""
room_bucket: Literal["studio", "1", "2", "3", "4+"]
area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]
class BestLayoutsRequest(BaseModel): class BestLayoutsRequest(BaseModel):
"""Параметры запроса top-планировок в радиусе вокруг участка.""" """Параметры запроса top-планировок в радиусе вокруг участка."""

View file

@ -181,3 +181,13 @@ def upsert_documents(db: Session, obj_id: int, docs: list[dict[str, Any]]) -> tu
# ── Stub for future Celery download task ────────────────────────────────────── # ── Stub for future Celery download task ──────────────────────────────────────
def download_document_stub(obj_id: int, doc_id: int, file_url: str) -> None:
"""Placeholder для будущей Celery-задачи download_domrf_documents.
Скачивает PDF, сохраняет в data/raw/domrf_docs/{obj_id}/{filename},
обновляет domrf_kn_documents.local_path + downloaded_at.
Реализация отдельный PR (future task).
"""
raise NotImplementedError("PDF download not implemented in 22i. See issue #297 future PR.")

View file

@ -1807,6 +1807,55 @@ async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict
return _flatten_table(payload) return _flatten_table(payload)
async def probe_endpoint(
url_or_path: str,
region_code: int = 66,
headed: bool = False,
load_state: str | None = None,
) -> dict[str, Any]:
"""Quick one-shot probe through a real Playwright session for debugging.
Returns {status, content_type, body_preview, full_url}. Use to verify that
a URL works in our session before/after a failing scraper run.
"""
if url_or_path.startswith("http"):
# split into path+query for BrowserSession.get_json
from urllib.parse import parse_qsl, urlsplit
s = urlsplit(url_or_path)
path = s.path
params = dict(parse_qsl(s.query, keep_blank_values=True))
else:
path = url_or_path.split("?", 1)[0]
params = {}
if "?" in url_or_path:
from urllib.parse import parse_qsl
params = dict(parse_qsl(url_or_path.split("?", 1)[1], keep_blank_values=True))
async with BrowserSession(
region_code=region_code, headed=headed, load_state=load_state
) as sess:
try:
payload = await sess.get_json(path, params)
body = json.dumps(payload, ensure_ascii=False)[:2000]
qs = "&".join(f"{k}={v}" for k, v in params.items())
full_url = f"{BASE_URL}{path}" + (f"?{qs}" if qs else "")
return {
"status": 200,
"content_type": "application/json",
"body_preview": body,
"full_url": full_url,
}
except Exception as e:
return {
"status": _http_status_of(e),
"content_type": "error",
"body_preview": str(e)[:2000],
"full_url": f"{BASE_URL}{path}",
}
def log_progress( def log_progress(
db: Session, db: Session,
run_id: int, run_id: int,

View file

@ -133,6 +133,21 @@ def fetch_geoportal(
raise NspdLiteError(f"Network error: {e}") from e raise NspdLiteError(f"Network error: {e}") from e
def fetch_quarter(quarter_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретный кадастровый квартал (typed wrapper)."""
return fetch_geoportal(quarter_cad_num, thematic_id=THEMATIC["quarter"], **kwargs)
def fetch_parcel(parcel_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретный участок ЗУ (typed wrapper)."""
return fetch_geoportal(parcel_cad_num, thematic_id=THEMATIC["parcel"], **kwargs)
def fetch_building(building_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретное здание ОКС (typed wrapper)."""
return fetch_geoportal(building_cad_num, thematic_id=THEMATIC["building"], **kwargs)
# ── Bulk через rosreestr2coord library ────────────────────────────────────── # ── Bulk через rosreestr2coord library ──────────────────────────────────────

View file

@ -28,7 +28,7 @@ import logging
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date from datetime import date, datetime
from typing import Any from typing import Any
import httpx import httpx
@ -557,3 +557,36 @@ class ObjectiveClient:
# ── удобный one-shot helper ───────────────────────────────────────────────── # ── удобный one-shot helper ─────────────────────────────────────────────────
def fetch_with_raw_log(
fn_name: str,
*,
save_dir: str | None = None,
**kwargs: Any,
) -> tuple[Any, str | None]:
"""Вызывает client.<fn_name>(**kwargs), сохраняет raw JSON в файл если задан save_dir.
Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill."""
from pathlib import Path
client = ObjectiveClient()
try:
method = getattr(client, fn_name)
data = method(**kwargs)
finally:
client.close()
file_path: str | None = None
if save_dir:
Path(save_dir).mkdir(parents=True, exist_ok=True)
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
slug = (
"_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v)
or "all"
)
slug = slug.replace(" ", "_").replace("/", "_")
file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json"
Path(file_path).write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return data, file_path

View file

@ -61,6 +61,18 @@ _KRT_AT_SQL = text("""
""") """)
def _functional_zone_at(
client: EKBGeoportalClient, lon: float, lat: float
) -> dict[str, Any] | None:
"""Функц.зона генплана (#1058) в точке — properties первой WFS-фичи; None при сбое/пусто."""
try:
feats = client.features_at_point("functional_zone", lon, lat)
except Exception as exc: # внешний геопортал, graceful
logger.warning("ird_analyze: functional_zone WFS failed: %s", exc)
return None
return feats[0].properties if feats else None
def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]: def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry. """КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.

View file

@ -15,6 +15,7 @@ Graceful: нет таблицы / пустой WKT / исключение БД
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import text
@ -97,6 +98,12 @@ _PLANNING_NO_MATCH_SQL = text(
) )
def _extract_paga_num(full_name: str) -> str | None:
"""Извлечь номер ПАГЕ из заголовка planning_project.full_name."""
m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name)
return m.group(1) if m else None
def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]: def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал. """КРТ-реквизиты для участка: застройщик / цена / градпотенциал.

View file

@ -472,6 +472,18 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]:
return result_dict return result_dict
async def load_utility_infrastructure(db: Session | None = None) -> dict[str, int]:
"""Async-обёртка для прямого вызова из async-контекста (#1746 spec).
Тянет Overpass и UPSERT-ит в таблицу. ``db`` принимается для сигнатурной
совместимости со спекой, но UPSERT идёт через собственную SessionLocal
(как noise/poi sync) Overpass-fetch async, запись в БД sync. Возвращает
те же per-kind counts, что ``sync_utility_infrastructure_to_db``.
"""
elements = await fetch_overpass_utility()
return _upsert_elements(elements)
# Junk-значения OSM tags, которые не несут смысла для попапа. # Junk-значения OSM tags, которые не несут смысла для попапа.
_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"}) _JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"})

View file

@ -150,6 +150,69 @@ def _build_beat_schedule_from_db() -> dict:
return schedule return schedule
def _build_beat_schedule_fallback() -> dict:
"""Fallback-расписание из env vars — используется если БД недоступна на старте."""
logger.warning("beat_schedule: строим fallback из env vars")
schedule: dict = {}
# scrape_kn
try:
for rc in _default_regions():
schedule[f"kn-region-{rc}"] = {
"task": "tasks.scrape_kn.scrape_kn_region",
"schedule": _parse_cron(settings.scrape_kn_cron),
"args": [rc, None],
}
except Exception as e:
logger.warning("beat_schedule fallback: scrape_kn failed: %s", e)
# objective_sync
try:
from app.core.db import SessionLocal
from app.services.objective_sync_config import get_cron_schedule_safe
cron_str = get_cron_schedule_safe(SessionLocal)
except Exception:
cron_str = settings.objective_sync_cron
try:
schedule["objective-sync"] = {
"task": "tasks.scrape_objective.sync_all_groups",
"schedule": _parse_cron(cron_str),
"kwargs": {"triggered_by": "beat"},
}
except Exception as e:
logger.warning("beat_schedule fallback: objective_sync failed: %s", e)
# refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК
try:
schedule["refresh-ekb-districts-medians"] = {
"task": "tasks.refresh_analytics.refresh_ekb_districts_medians",
"schedule": _parse_cron("0 4 5 * *"),
"kwargs": {"window_months": 24, "min_deals": 50},
}
except Exception as e:
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"options": {"queue": "celery"},
}
return schedule
def build_beat_schedule() -> dict: def build_beat_schedule() -> dict:
"""Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries. """Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries.

View file

@ -174,38 +174,25 @@ check "www.meraocenka.ru — 301 to canonical" "https://www.meraocenka.ru/" 301
check "www.merahome.ru — 301 to canonical" "https://www.merahome.ru/" 301 check "www.merahome.ru — 301 to canonical" "https://www.merahome.ru/" 301
check "www.meraotsenka.ru — 301 to canonical" "https://www.meraotsenka.ru/" 301 check "www.meraotsenka.ru — 301 to canonical" "https://www.meraotsenka.ru/" 301
# 6. Платёжный периметр. Заведён в PR-D2 как канарейка «ожидания обновляют # 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
# вместе с тем PR, который путь открывает». Канарейка отработала: PR-D3 # (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
# (#3231, роутер) смержен 29.08 и внёс notify в `_PUBLIC_PATHS`, ожидание # СТОРОН прямо сейчас:
# здесь обновлено тем же днём — но уже ПОСЛЕ красного деплоя, а не вместе с
# ним. Кто будет делать PR-D4 — правьте ожидания в одном PR с ним.
#
# Состояние на 29.08, чего ждём сейчас:
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default, # - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит. Это ВСЁ ЕЩЁ # см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
# канарейка: PR-D4 не смержен, 404 должен держаться; # - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
# - gendsgn.ru: `checkout` по-прежнему требует X-Authenticated-User → 401 # (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
# анониму (он не публичный и не станет им); # платёжные пути и требует X-Authenticated-User → 401 анониму.
# - gendsgn.ru: `notify` — публичный по построению (вебхук банка), см. # Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
# комментарий у самой проверки ниже. # PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \ check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404 "$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \ check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404 "$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
# notify — ЕДИНСТВЕННЫЙ платёжный путь, который PR-D3 (#3231) открыл анониму check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
# осознанно: это вебхук банка, он обязан быть достижим без наших заголовков, и "$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
# потому внесён в `_PUBLIC_PATHS`. Канарейка выше отработала как задумано — 401 check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
# здесь стал бы теперь признаком ПОЛОМКИ (банк получил бы отказ), а не защиты.
#
# Проверяем POST и ждём 503: это строже прежнего 401 — утверждает сразу, что
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
check "trade-in payments/notify — 405 на GET (только POST)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401 "$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
echo "========================================" echo "========================================"

View file

@ -2405,112 +2405,6 @@ def _fetch_anchor_comps(
return [], None return [], None
@dataclass(frozen=True)
class _HouseFacts:
"""Характеристики дома из справочника `houses` (#3234).
total_floors / year_built / house_type fallback для пропущенных
payload-полей ПЕРЕД OSM Overpass (houses на два порядка полнее и не тратит
внешний вызов). material_walls читается на будущее потребителя ниже по
estimator пока нет, но данные есть: 7838 домов из 10221 (77%) на проде после
наполнения из ГИС ЖКХ + DOM.РФ 2026-08-29.
"""
house_id: int
total_floors: int | None
year_built: int | None
house_type: str | None
material_walls: str | None
def _lookup_house_facts(
db: Session,
*,
target_house_id: int | None,
lat: float | None,
lon: float | None,
) -> _HouseFacts | None:
"""Читает total_floors/year_built/house_type/material_walls из `houses`.
Ключ canonical target_house_id (уже резолвлен match_house_readonly выше
по estimate-функции); если он None фолбэк на ближайший дом в радиусе 60м
от subject lat/lon (ST_DWithin + сортировка по расстоянию, LIMIT 1).
Best-effort: любая ошибка БД None + WARNING, оценка не должна падать
(тот же паттерн try/except + db.rollback(), см. _fetch_anchor_comps выше).
"""
try:
if target_house_id is not None:
row = (
db.execute(
text(
"""
SELECT id, total_floors, year_built, house_type, material_walls
FROM houses
WHERE id = CAST(:hid AS bigint)
"""
),
{"hid": target_house_id},
)
.mappings()
.first()
)
elif lat is not None and lon is not None:
row = (
db.execute(
text(
"""
SELECT id, total_floors, year_built, house_type, material_walls
FROM houses
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
60
)
ORDER BY geom::geography <-> ST_MakePoint(:lon, :lat)::geography
LIMIT 1
"""
),
{"lon": lon, "lat": lat},
)
.mappings()
.first()
)
else:
return None
except Exception as exc: # pragma: no cover — defensive
logger.warning("house facts lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception: # pragma: no cover — defensive
pass
return None
if row is None:
return None
# Guard типов строки: houses.id — NOT NULL bigint, любая другая форма (в т.ч.
# тестовый db-double без явного мока запроса) трактуется как «строки нет» —
# тот же best-effort, что и явная ошибка выше. Остальные поля best-effort
# каждое по отдельности — неожиданный тип поля не должен ронять facts целиком.
house_id = row["id"]
if not isinstance(house_id, int):
return None
def _as_int(value: Any) -> int | None:
return value if isinstance(value, int) else None
def _as_str(value: Any) -> str | None:
return value if isinstance(value, str) else None
return _HouseFacts(
house_id=house_id,
total_floors=_as_int(row["total_floors"]),
year_built=_as_int(row["year_built"]),
house_type=_as_str(row["house_type"]),
material_walls=_as_str(row["material_walls"]),
)
def _band_haircut(anchor_ppm2: float) -> float: def _band_haircut(anchor_ppm2: float) -> float:
"""asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену). """asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену).
@ -4146,39 +4040,7 @@ async def estimate_quality(
# gateway-таймауту; деградируем в None при превышении budget. # gateway-таймауту; деградируем в None при превышении budget.
target_year = payload.year_built target_year = payload.year_built
target_house_type = payload.house_type target_house_type = payload.house_type
target_total_floors = payload.total_floors if target_year is None or target_house_type is None:
# 2a. #3234: houses — наш справочник, приоритет ПЕРЕД OSM Overpass (year
# 86.8% / floors 98.3% покрытия на проде против 68 строк в
# house_metadata-кэше). payload остаётся главнее в любом случае — houses
# заполняет только то, что пользователь не указал.
if target_year is None or target_house_type is None or target_total_floors is None:
house_facts = await asyncio.to_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,
lat=geo.lat,
lon=geo.lon,
)
if house_facts is not None:
filled: list[str] = []
if target_year is None and house_facts.year_built is not None:
target_year = house_facts.year_built
filled.append(f"year_built={target_year}")
if target_house_type is None and house_facts.house_type is not None:
target_house_type = house_facts.house_type
filled.append(f"house_type={target_house_type}")
if target_total_floors is None and house_facts.total_floors is not None:
target_total_floors = house_facts.total_floors
filled.append(f"total_floors={target_total_floors}")
if filled:
logger.info(
"estimate: house facts fallback house_id=%s%s",
house_facts.house_id,
", ".join(filled),
)
if target_year is None or target_house_type is None or target_total_floors is None:
house_meta = await _with_budget( house_meta = await _with_budget(
get_house_metadata(geo.lat, geo.lon, db), get_house_metadata(geo.lat, geo.lon, db),
settings.estimate_house_meta_timeout_s, settings.estimate_house_meta_timeout_s,
@ -4189,8 +4051,6 @@ async def estimate_quality(
target_year = house_meta.year_built target_year = house_meta.year_built
if target_house_type is None: if target_house_type is None:
target_house_type = house_meta.house_type target_house_type = house_meta.house_type
if target_total_floors is None:
target_total_floors = house_meta.total_floors
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме, # 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на # либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
@ -4237,7 +4097,7 @@ async def estimate_quality(
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
house_type=target_house_type, house_type=target_house_type,
total_floors=target_total_floors, total_floors=payload.total_floors,
cohort_year_min=cy_min, cohort_year_min=cy_min,
cohort_year_max=cy_max, cohort_year_max=cy_max,
) )
@ -4262,7 +4122,7 @@ async def estimate_quality(
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
house_type=target_house_type, house_type=target_house_type,
total_floors=target_total_floors, total_floors=payload.total_floors,
) )
# #oblast-F: True only when there WAS a cohort (year_built) filter to drop — # #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
# surfaced later as the "снят фильтр по году постройки" relaxation label. # surfaced later as the "снят фильтр по году постройки" relaxation label.
@ -4282,7 +4142,7 @@ async def estimate_quality(
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
house_type=target_house_type, house_type=target_house_type,
total_floors=target_total_floors, total_floors=payload.total_floors,
) )
if len(listings_wide) > len(listings): if len(listings_wide) > len(listings):
listings = listings_wide listings = listings_wide
@ -4306,7 +4166,7 @@ async def estimate_quality(
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
house_type=target_house_type, house_type=target_house_type,
total_floors=target_total_floors, total_floors=payload.total_floors,
) )
if len(listings_widearea) > len(listings): if len(listings_widearea) > len(listings):
listings = listings_widearea listings = listings_widearea
@ -4365,7 +4225,7 @@ async def estimate_quality(
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
house_type=target_house_type, house_type=target_house_type,
total_floors=target_total_floors, total_floors=payload.total_floors,
) )
if len(candidate) > len(listings): if len(candidate) > len(listings):
return candidate, tier return candidate, tier

View file

@ -38,13 +38,10 @@
-- same-site SERP-origin navigation cadence from PR #2430 -- QRATOR is -- same-site SERP-origin navigation cadence from PR #2430 -- QRATOR is
-- reputation-based, not purely rate-based, but a more human cadence -- reputation-based, not purely rate-based, but a more human cadence
-- reduces the odds of tripping it in the first place. -- reduces the odds of tripping it in the first place.
-- max_consecutive_blocks -- 3 (vs Avito's 5): aborting sooner avoids hammering an -- max_consecutive_blocks -- 3 (vs Avito's 5): DomClick has no IP-rotation recovery
-- already-reputation-damaged proxy/session and preserves it for the -- on block (one dedicated residential proxy, not a pool) -- aborting
-- next scheduled window. NB the original rationale here ("one -- sooner avoids hammering an already-reputation-damaged proxy/session
-- dedicated residential proxy, not a pool") is obsolete: migration -- and preserves it for the next scheduled window.
-- 253 (#2800) dropped the reservation, and the backfill was wired
-- into the shared pool in PR #3222 -- all four nodes carry
-- provider_affinity='any'.
-- --
-- Seeded with enabled=false: activation is a deliberate, separate manual step after -- Seeded with enabled=false: activation is a deliberate, separate manual step after
-- this migration deploys and the orchestrator is smoke-tested against a live cookie -- this migration deploys and the orchestrator is smoke-tested against a live cookie

View file

@ -36,9 +36,7 @@ import json
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest import pytest
from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.offer_price_history import clamp_diff_percent from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.providers.domclick.detail import ( from scraper_kit.providers.domclick.detail import (
@ -440,61 +438,6 @@ async def test_fetch_detail_transport_failure_does_not_report_ban() -> None:
bf.report_ban.assert_not_called() bf.report_ban.assert_not_called()
# ── #3239: бан-страница ОТ САЙДКАРА — тоже генуинный маркер-детект ────────────
#
# #3237 перенёс распознавание статического отказа Домклика из parse_detail_html в
# сайдкар (иначе незавершённое QRATOR-рукопожатие считалось блоком). Отказ стал
# приезжать обычной 500-кой и попадать в транспортную ветку, где report_ban по
# построению НЕ зовётся → ban_kind 'unknown' вместо 'platform' → ротация IP не
# запускалась вовсе. Тесты ниже держат обе ветки РАЗДЕЛЬНО: подтверждённый отказ
# банит, голый транспортный сбой — нет (тест выше).
def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPageError:
return SidecarBanPageError(
"Server error '500' | tradein-browser: BanPageDetectedError: статический отказ",
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
response=httpx.Response(500, request=httpx.Request("POST", "http://x/fetch")),
upstream_status=upstream_status,
)
@pytest.mark.asyncio
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _CARD_URL in bf.report_ban.call_args.args[0]
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_carries_upstream_status() -> None:
"""Статус берётся ИЗ ИСКЛЮЧЕНИЯ, а не из last_response_status.
Несущая деталь #3239: на error-пути fetch() обнуляет last_response_status,
поэтому у Домклика (отказ приходит с 401) классификатор без этого поля
поставил бы 'unknown'. MagicMock отдаёт last_response_status как Mock
если бы код читал его, ассерт ниже упал бы.
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(401))
with pytest.raises(DomClickBlockedError) as excinfo:
await fetch_detail(_CARD_URL, browser_fetcher=bf)
assert excinfo.value.status == 401
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_without_status_stays_none() -> None:
"""Сайдкар не отдал статус → None, а не выдуманный код (#2764)."""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(None))
with pytest.raises(DomClickBlockedError) as excinfo:
await fetch_detail(_CARD_URL, browser_fetcher=bf)
assert excinfo.value.status is None
# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ───────────── # ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ─────────────

View file

@ -1,294 +0,0 @@
"""#3234: `houses` как fallback-источник total_floors/year_built/house_type в estimate.
Приоритет: payload (пользователь) houses (наш справочник, локальный, ~87%
покрытия года / ~98% этажности на проде) OSM Overpass (get_house_metadata,
внешний вызов с таймаутом, house_metadata-кэш всего 68 строк на базу).
payload НИКОГДА не переопределяется: houses/OSM заполняют только то, что
payload не задал. get_house_metadata должен вызываться ТОЛЬКО если после
payload+houses что-то всё ещё неизвестно (экономия внешнего вызова).
year_built из houses обязан пройти через тот же _sanitize_build_year, что и
payload/OSM (Mera-audit 2026-08-02, прод-инцидент year_built=1829 см.
test_estimator_implausible_year.py).
Два уровня тестов:
1. _lookup_house_facts() изолированно маршрутизация SQL (house_id vs
геофолбэк) + best-effort на ошибке БД (без сети).
2. estimate_quality() целиком, offline зеркалит паттерн
test_estimator_client_coords.py: все внешние вызовы замоканы,
_fetch_analogs захватывает kwargs, чтобы проверить что именно уехало
вниз по пайплайну.
"""
from __future__ import annotations
import contextlib
import os
# Settings требует DATABASE_URL при импорте app.core.config — до любого app-импорта.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
# ── Level 1: _lookup_house_facts() изолированно (без estimate_quality) ─────
def test_lookup_house_facts_uses_house_id_when_present() -> None:
"""target_house_id задан → SQL идёт по id, геофолбэк (ST_DWithin) не участвует."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"id": 42,
"total_floors": 9,
"year_built": 1975,
"house_type": "panel",
"material_walls": None,
}
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
assert facts is not None
assert (facts.house_id, facts.total_floors, facts.year_built, facts.house_type) == (
42,
9,
1975,
"panel",
)
sql_text = str(db.execute.call_args.args[0])
assert "WHERE id = " in sql_text
assert "ST_DWithin" not in sql_text
def test_lookup_house_facts_falls_back_to_geo_radius_when_no_house_id() -> None:
"""target_house_id=None → SQL идёт по ST_DWithin(60м) + ближайший к lat/lon."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"id": 7,
"total_floors": 5,
"year_built": 1968,
"house_type": "brick",
"material_walls": None,
}
facts = _lookup_house_facts(db, target_house_id=None, lat=56.838, lon=60.595)
assert facts is not None
assert facts.house_id == 7
sql_text = str(db.execute.call_args.args[0])
assert "ST_DWithin" in sql_text
assert "60" in sql_text
def test_lookup_house_facts_no_key_returns_none_without_query() -> None:
"""Ни house_id, ни lat/lon — запроса к БД нет вообще, сразу None."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
facts = _lookup_house_facts(db, target_house_id=None, lat=None, lon=None)
assert facts is None
db.execute.assert_not_called()
def test_lookup_house_facts_db_error_is_graceful() -> None:
"""Любая ошибка БД → None + rollback, estimate не должен падать (best-effort)."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
assert facts is None
db.rollback.assert_called_once()
# ── Level 2: estimate_quality() целиком, offline ────────────────────────────
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
def _make_payload(*, year_built=None, house_type=None, total_floors=None):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=38.8,
rooms=1,
floor=4,
lat=56.838,
lon=60.595,
year_built=year_built,
house_type=house_type,
total_floors=total_floors,
)
def _house_facts(*, house_id=777, total_floors=9, year_built=1975, house_type="panel"):
from app.services.estimator import _HouseFacts
return _HouseFacts(
house_id=house_id,
total_floors=total_floors,
year_built=year_built,
house_type=house_type,
material_walls=None,
)
def _house_meta(*, total_floors, year_built, house_type):
from app.services.house_metadata import HouseMetadata
return HouseMetadata(
lat=56.838,
lon=60.595,
year_built=year_built,
total_floors=total_floors,
house_type=house_type,
total_units=None,
source="osm",
)
def _run_estimate(payload, *, house_facts, house_meta):
"""Прогоняет estimate_quality offline. Возвращает (result, _fetch_analogs mock,
get_house_metadata mock) mocks выжившие после await для инспекции call_args."""
from app.services.estimator import estimate_quality
db = MagicMock()
geocode_mock = AsyncMock(return_value=_make_fake_geo())
house_facts_mock = MagicMock(return_value=house_facts)
house_meta_mock = AsyncMock(return_value=house_meta)
analogs_mock = MagicMock(return_value=([], False, "W"))
async def _go():
with contextlib.ExitStack() as stack:
for cm in (
patch("app.services.estimator.geocode", new=geocode_mock),
patch("app.services.estimator._lookup_house_facts", new=house_facts_mock),
patch("app.services.estimator.get_house_metadata", new=house_meta_mock),
patch("app.services.estimator._fetch_analogs", new=analogs_mock),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
stack.enter_context(cm)
return await estimate_quality(payload, db)
result = anyio.run(_go)
return result, analogs_mock, house_meta_mock
def _floors_seen(mock) -> set:
return {c.kwargs["total_floors"] for c in mock.call_args_list}
def _years_seen(mock) -> set:
return {c.kwargs["year_built"] for c in mock.call_args_list}
def _house_types_seen(mock) -> set:
return {c.kwargs["house_type"] for c in mock.call_args_list}
def test_empty_payload_uses_houses_facts() -> None:
"""payload пустой, houses знает всё → его значения уезжают в _fetch_analogs."""
payload = _make_payload()
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert analogs_mock.call_args_list, "estimate должен вызвать _fetch_analogs хотя бы раз"
assert _floors_seen(analogs_mock) == {9}
assert _years_seen(analogs_mock) == {1975}
assert _house_types_seen(analogs_mock) == {"panel"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None
def test_payload_wins_over_conflicting_houses_facts() -> None:
"""payload задан, houses знает ДРУГОЕ → побеждает payload (user главнее houses)."""
payload = _make_payload(year_built=2015, house_type="brick", total_floors=5)
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert _floors_seen(analogs_mock) == {5}
assert _years_seen(analogs_mock) == {2015}
assert _house_types_seen(analogs_mock) == {"brick"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None
def test_neither_payload_nor_houses_falls_back_to_osm() -> None:
"""Ни payload, ни houses ничего не знают → OSM get_house_metadata вызывается
(старое поведение сохранено, houses тут ничем не помогает)."""
payload = _make_payload()
meta = _house_meta(total_floors=5, year_built=1968, house_type="brick")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=None, house_meta=meta
)
house_meta_mock.assert_awaited()
assert _floors_seen(analogs_mock) == {5}
assert _years_seen(analogs_mock) == {1968}
assert _house_types_seen(analogs_mock) == {"brick"}
assert result.estimate_id is not None
def test_payload_and_houses_cover_everything_skips_osm() -> None:
"""payload частично + houses добивает остальное → OSM НЕ вызывается (экономия
внешнего бюджетируемого вызова, п.5 задачи)."""
payload = _make_payload(year_built=2015) # house_type/total_floors придут из houses
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
house_meta_mock.assert_not_awaited()
assert _floors_seen(analogs_mock) == {9}
assert _years_seen(analogs_mock) == {2015} # payload побеждает для года
assert _house_types_seen(analogs_mock) == {"panel"}
assert result.estimate_id is not None
def test_implausible_year_from_houses_is_sanitized() -> None:
"""houses отдал неправдоподобный год (1829, прод-паттерн) → _sanitize_build_year
отбивает его в None; total_floors/house_type из houses при этом всё равно
применяются (год независимый гейт, не блокирует остальные поля)."""
payload = _make_payload()
facts = _house_facts(total_floors=9, year_built=1829, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert _years_seen(analogs_mock) == {None}
assert _floors_seen(analogs_mock) == {9}
assert _house_types_seen(analogs_mock) == {"panel"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None

View file

@ -27,14 +27,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest import pytest
from scraper_kit.browser_fetcher import ( from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
BrowserFetcher,
SidecarBanPageError,
_raise_for_sidecar_status,
ban_kind_from_status,
)
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock: def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
@ -164,73 +158,3 @@ def test_ban_kind_values_fit_scrape_runs_check() -> None:
allowed = {"platform", "infra", "unknown", None} allowed = {"platform", "infra", "unknown", None}
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600): for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
assert ban_kind_from_status(status) in allowed assert ban_kind_from_status(status) in allowed
# ── #3239: подтверждённая бан-страница отличима от прочих 500-ок ──────────────
#
# Тип, а не подстрока: _raise_for_sidecar_status обрезает тело до 300 символов, и
# формулировка отказа у сайдкара менялась дважды за месяц. upstream_status нужен
# отдельным полем — на error-пути fetch() обнуляет last_response_status, и без
# него у DomClick (отказ приходит с 401) диагноз стал бы 'unknown' вместо
# 'platform', то есть ротация IP не запустилась бы вовсе.
def _sidecar_response(body: Any) -> httpx.Response:
return httpx.Response(
500, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
def test_ban_page_body_raises_typed_error_with_upstream_status() -> None:
resp = _sidecar_response(
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401}
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status == 401
def test_ban_page_error_is_httpx_status_error() -> None:
"""Подкласс — иначе retry-политика fetch() и ловля у прочих поставщиков сломались бы."""
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": 403})
with pytest.raises(httpx.HTTPStatusError):
_raise_for_sidecar_status(resp)
def test_plain_500_stays_plain_status_error() -> None:
resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"})
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_ban_page_without_status_gives_none() -> None:
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": None})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_ban_page_true_as_bool_status_is_rejected() -> None:
"""JSON true не должен уехать статусом: bool — подтип int (та же ловушка, что в #3196)."""
resp = _sidecar_response({"error": "x", "ban_page": True, "status": True})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_non_json_error_body_does_not_break() -> None:
resp = httpx.Response(
500, text="not json", request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_success_response_raises_nothing() -> None:
resp = httpx.Response(
200, json={"html": "<html/>", "status": 200},
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
)
_raise_for_sidecar_status(resp)

View file

@ -122,7 +122,7 @@ import asyncio
import base64 import base64
import logging import logging
import os import os
from collections.abc import Callable, Mapping from collections.abc import Mapping
from urllib.parse import urlparse from urllib.parse import urlparse
from aiohttp import web from aiohttp import web
@ -961,17 +961,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
type(exc).__name__, type(exc).__name__,
exc, exc,
) )
error_body: dict = {"error": f"{type(exc).__name__}: {exc}"} return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
if isinstance(exc, BanPageDetectedError):
# #3239: бан-страница — ПОДТВЕРЖДЁННЫЙ маркер-детект, а не сбой
# транспорта, и только за первым стоит report_ban на клиенте.
# До этой правки оба случая приезжали одинаковой 500-кой, клиент
# различить их не мог и настоящий отказ площадки переставал
# ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500
# завязана classify_browser_probe, признак несёт тело.
error_body["ban_page"] = True
error_body["status"] = _last_response_status.get(provider)
return web.json_response(error_body, status=500)
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий # Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
# только его, ничего не заметит. "status" может быть null (goto вернул None). # только его, ничего не заметит. "status" может быть null (goto вернул None).
@ -1240,55 +1230,6 @@ def _is_ban_page(html: str) -> bool:
return any(marker in lower for marker in _BAN_MARKERS) return any(marker in lower for marker in _BAN_MARKERS)
# ── QRATOR-рукопожатие DomClick (#3196, замер прода 29.08.2026) ─────────────────
# У DomClick, в отличие от Авито, у промежуточной (нерендеренной) PoW-страницы НЕТ
# ни одного стабильного маркера вообще — ни __qrator, ни startpow, ни текста отказа
# (замер: 3 запроса подряд на здоровом узле — 1-й вернул 401/6898 байт без единого
# опознаваемого признака, 2-й и 3-й — уже 200/готовая карточка). Строить детектор
# «это челлендж» тут не на чем: любой угаданный маркер протухнет на следующей смене
# вёрстки, а до тех пор будет ловить призрак несуществующего состояния.
#
# Поэтому логика инвертирована: опознаём положительно два КРАЙНИХ состояния —
# «точно готово» и «точно отказ», а всё остальное (загрузчик рукопожатия на 279
# байт, нерендеренная PoW-страница на ~6.9-7.1 КБ) считаем «рукопожатие ещё идёт»
# и уходим в _wait_out_pow_challenge вместо того, чтобы гадать по размеру —
# отказ у DomClick тоже статическая страница фиксированного размера (~26.6 КБ),
# см. _DOMCLICK_REFUSAL_MARKERS, поэтому размер как единственный признак ненадёжен.
#
# Признак успеха — JS-глобаль __SSR_STATE__, литеральная строка, которую кладёт
# в разметку SSR-рендер готовой карточки (та же строка, из-за отсутствия которой
# парсер бэкфилла поднимал ложный «блок», см. #3196). Сайдкар в остальном
# провайдер-агностичен и не обязан знать про внутренний контракт парсера, но
# альтернатива — «отсутствие маркеров отказа + размер» — здесь СЛАБЕЕ, а не чище:
# и загрузчик (279 байт), и промежуточная PoW-страница (~7 КБ), и будущая
# перекроенная вёрстка отказа тоже прошли бы такую проверку как «успех». Прямой
# маркер успеха — единственный вариант, который не размывается на промежуточных
# состояниях.
_DOMCLICK_SUCCESS_MARKER = "__SSR_STATE__"
# Признак ОТКАЗА площадки (не рукопожатия) — статическая страница «403 | Домклик»
# с текстом про подозрительный запрос, ~26.6 КБ, приходит с HTTP 401 (см.
# _REFUSAL_STATUSES ниже — статус НЕ используется как сигнал для DomClick, тело
# приходит с тем же кодом, что и у здорового рукопожатия и у одной из успешных
# страниц в замере). Ждать тут нечего — статическая страница сама себя не
# перезагрузит.
_DOMCLICK_REFUSAL_MARKERS: tuple[str, ...] = (
"похоже, ваш запрос выглядит необычно",
"<title>403 | домклик</title>",
)
def _is_domclick_success(html: str) -> bool:
"""True, если HTML — отрендеренная карточка DomClick (см. _DOMCLICK_SUCCESS_MARKER)."""
return _DOMCLICK_SUCCESS_MARKER in html
def _is_domclick_refusal(html: str) -> bool:
"""True, если HTML — статический отказ площадки DomClick (см. _DOMCLICK_REFUSAL_MARKERS)."""
lower = html.lower()
return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS)
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по # Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит # тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для # уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
@ -1320,14 +1261,8 @@ async def _content_during_navigation(page: object) -> str | None:
raise raise
async def _wait_out_pow_challenge( async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
page: object, """Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
provider: str,
url: str,
*,
is_pending: Callable[[str], bool] = _is_pow_challenge,
) -> str:
"""Опрашивает page.content() пока `is_pending(html)` не станет False.
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
решения PoW URL не меняется, поэтому page.wait_for_url тут не годится; решения PoW URL не меняется, поэтому page.wait_for_url тут не годится;
@ -1335,24 +1270,18 @@ async def _wait_out_pow_challenge(
BROWSER_CHALLENGE_WAIT_MS ChallengeTimeoutError, а не тихая отдача BROWSER_CHALLENGE_WAIT_MS ChallengeTimeoutError, а не тихая отдача
challenge-страницы как будто это валидный контент. challenge-страницы как будто это валидный контент.
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
провайдеров не меняется) предикат «страница ещё не устоялась, опроси
снова». DomClick передаёт свой (см. _fetch_once) там нет отдельного
маркера самого челленджа, есть только «точно успех» / «точно отказ»
(#3196), поэтому pending = «ни то, ни другое».
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS, После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
каким ждём обычную навигацию (второй таймаут не изобретаем). каким ждём обычную навигацию (второй таймаут не изобретаем).
""" """
poll_interval_ms = 1000 poll_interval_ms = 1000
elapsed_ms = 0 elapsed_ms = 0
html: str | None = await _content_during_navigation(page) html: str | None = await _content_during_navigation(page)
while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS: while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined] await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
elapsed_ms += poll_interval_ms elapsed_ms += poll_interval_ms
html = await _content_during_navigation(page) html = await _content_during_navigation(page)
if html is None or is_pending(html): if html is None or _is_pow_challenge(html):
raise ChallengeTimeoutError( raise ChallengeTimeoutError(
f"tradein-browser[{provider}]: PoW-челлендж не снялся за " f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}" f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
@ -1503,37 +1432,9 @@ async def _fetch_once(
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}" f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
) )
if provider == "domclick":
# DomClick — своя ветка (#3196): нет отдельного маркера самого
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
# статус НЕ смотрим вовсе (в отличие от общей ветки ниже) — 401
# приходит и у здорового рукопожатия, и у отказа, и у успешной
# страницы в замере 29.08.2026, различать нужно ТОЛЬКО по телу.
if _is_domclick_refusal(html):
raise BanPageDetectedError(
f"tradein-browser[{provider}]: статический отказ площадки "
f"url={url!r}"
)
if not _is_domclick_success(html):
html = await _wait_out_pow_challenge(
page,
provider,
url,
is_pending=lambda h: not _is_domclick_success(h)
and not _is_domclick_refusal(h),
)
if _is_domclick_refusal(html):
# Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса —
# is_pending вернул False (страница устоялась), но устоялась
# она в состояние «отказ», а не «успех». Наверх идёт то же
# исключение, что и для мгновенного отказа выше.
raise BanPageDetectedError(
f"tradein-browser[{provider}]: отказ площадки после "
f"ожидания рукопожатия url={url!r}"
)
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его # PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент. # прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
elif _is_pow_challenge(html): if _is_pow_challenge(html):
status = _last_response_status.get(provider) status = _last_response_status.get(provider)
if status in _REFUSAL_STATUSES: if status in _REFUSAL_STATUSES:
# Маркеры челленджа при 403/429 — это отказ, а не проверка: # Маркеры челленджа при 403/429 — это отказ, а не проверка:
@ -1542,9 +1443,7 @@ async def _fetch_once(
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница # контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил # приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует # бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
# BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда — # BanPageDetectedError как блок.
# у DomClick (единственного, кто отдаёт 401) отказ распознаётся
# выше по телу, а не по коду, см. ветку provider == "domclick".
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа " f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа "
f"— отказ площадки, ждать нечего url={url!r}" f"— отказ площадки, ждать нечего url={url!r}"

View file

@ -1,284 +0,0 @@
"""test_server_domclick_challenge.py — QRATOR-рукопожатие DomClick в /fetch (#3196).
Проблема, установленная замером прода 29.08.2026: у DomClick, в отличие от
Авито, промежуточная (нерендеренная) PoW-страница НЕ содержит ни одного
стабильного маркера вообще ни `__qrator`, ни `startpow`, ни текста отказа.
`_CHALLENGE_MARKERS` (сняты живьём с Авито, #3045) на DomClick никогда не
матчатся, поэтому ветка `_wait_out_pow_challenge` для него не включалась
недосчитанная страница уезжала наверх как валидный контент, парсер не находил
`__SSR_STATE__` и поднимал ложный "блок" (двухнедельный
`attempted=3, blocked=3, enriched=0` у `domclick_detail_backfill`).
Логика для DomClick инвертирована: опознаём положительно ДВА крайних
состояния «точно успех» (`_DOMCLICK_SUCCESS_MARKER`, JS-глобаль
`__SSR_STATE__`) и «точно отказ» (`_DOMCLICK_REFUSAL_MARKERS`, статическая
страница «403 | Домклик» ~26.6 КБ) а всё остальное (загрузчик рукопожатия
279 байт, нерендеренная PoW-страница ~7 КБ) трактуется как «рукопожатие ещё
идёт» и уходит в существующий `_wait_out_pow_challenge` с кастомным
`is_pending`.
Тестируется _fetch_once целиком (зеркалит test_server_pow_challenge.py),
camoufox не запускается фейковые browser/page возвращают заранее заданную
последовательность content(). wait_for_timeout на фейковой page no-op.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_domclick_challenge.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py)."""
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_retry_tasks", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
# Приблизительные образцы трёх состояний, снятых живьём 29.08.2026 (см. докстринг
# задачи) — точный размер в байтах для теста не важен, важно отсутствие/наличие
# опознаваемых строк.
_HANDSHAKE_LOADER_HTML = (
'<html><head><script src="/__qrator/qauth_utm_v2d_v9118.js"></script></head>'
"<body></body></html>"
)
_RENDERED_POW_HTML = (
"<html><body><script>window.location = location.href;</script>"
"<div>ожидание...</div></body></html>"
)
_REFUSAL_HTML = (
"<html><head><title>403 | Домклик</title></head>"
"<body>Похоже, ваш запрос выглядит необычно. Мы усилили защиту.</body></html>"
)
_SUCCESS_HTML = '<html><body><script>window.__SSR_STATE__ = {"card": {}};</script></body></html>'
class _DomclickPage:
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
После исчерпания списка повторяет последний элемент (имитирует «страница
осталась в этом состоянии»). Зеркалит _ChallengePage из
test_server_pow_challenge.py.
"""
def __init__(self, html_sequence: list[str]) -> None:
self._html_sequence = html_sequence
self._call_count = 0
self.goto_urls: list[str] = []
self.wait_for_timeout_calls: list[int] = []
self.closed = 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms)
async def content(self) -> str:
idx = min(self._call_count, len(self._html_sequence) - 1)
item = self._html_sequence[idx]
self._call_count += 1
return item
async def close(self) -> None:
self.closed += 1
class _DomclickBrowser:
def __init__(self, page: _DomclickPage) -> None:
self._page = page
async def new_page(self) -> _DomclickPage:
return self._page
def _install(
monkeypatch: pytest.MonkeyPatch, page: _DomclickPage, provider: str = "domclick"
) -> None:
server._browsers[provider] = _DomclickBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
# ── детекторы: чистые функции ────────────────────────────────────────────────────
def test_is_domclick_success_true_on_ssr_state_marker() -> None:
assert server._is_domclick_success(_SUCCESS_HTML) is True
def test_is_domclick_success_false_on_handshake_loader() -> None:
assert server._is_domclick_success(_HANDSHAKE_LOADER_HTML) is False
def test_is_domclick_success_false_on_rendered_pow_page() -> None:
"""Ключевой случай задачи: у рендеренной PoW-страницы НЕТ стабильных маркеров
вообще детектор успеха не должен ложно сработать на ней."""
assert server._is_domclick_success(_RENDERED_POW_HTML) is False
def test_is_domclick_success_false_on_refusal_page() -> None:
assert server._is_domclick_success(_REFUSAL_HTML) is False
def test_is_domclick_refusal_true_on_title_and_text_markers() -> None:
assert server._is_domclick_refusal(_REFUSAL_HTML) is True
def test_is_domclick_refusal_false_on_handshake_loader() -> None:
assert server._is_domclick_refusal(_HANDSHAKE_LOADER_HTML) is False
def test_is_domclick_refusal_false_on_rendered_pow_page() -> None:
assert server._is_domclick_refusal(_RENDERED_POW_HTML) is False
def test_is_domclick_refusal_false_on_success_page() -> None:
assert server._is_domclick_refusal(_SUCCESS_HTML) is False
# ── _fetch_once: сценарии DomClick ───────────────────────────────────────────────
def test_fetch_once_refusal_page_raises_immediately_without_waiting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Страница отказа → BanPageDetectedError СРАЗУ, без ухода в опрос (пункт 1)."""
page = _DomclickPage([_REFUSAL_HTML])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
assert page.closed == 1
# Единственная пауза — обычная гидро-пауза ДО детекта; цикла опроса (доп.
# паузы по 1000мс) не было — бюджет ожидания не потрачен.
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
def test_fetch_once_page_without_markers_waits_then_returns_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ни успеха, ни отказа (загрузчик/рендеренная PoW-страница) → уходит в
ожидание, а не отдаёт недосчитанную страницу как контент (пункт 2)."""
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _RENDERED_POW_HTML, _SUCCESS_HTML])
_install(monkeypatch, page)
html = asyncio.run(
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
)
assert html == _SUCCESS_HTML
assert page.closed == 1
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
assert 1000 in page.wait_for_timeout_calls # шаг опроса — бюджет реально тратился
def test_fetch_once_success_page_returned_immediately_without_waiting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Успех с первого раза → отдаётся сразу, БЕЗ ухода в polling-ветку (пункт 3)."""
page = _DomclickPage([_SUCCESS_HTML])
_install(monkeypatch, page)
html = asyncio.run(
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
)
assert html == _SUCCESS_HTML
assert page.closed == 1
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
def test_fetch_once_settles_into_refusal_during_wait_raises_ban_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса → тоже BanPageDetectedError,
а не отдача отказной страницы как валидного контента."""
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _REFUSAL_HTML])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
assert page.closed == 1
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ни успех, ни отказ так и не наступают → ChallengeTimeoutError по истечении бюджета."""
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
page = _DomclickPage([_HANDSHAKE_LOADER_HTML]) # повторяется бесконечно
_install(monkeypatch, page)
with pytest.raises(server.ChallengeTimeoutError):
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
assert page.closed == 1
def test_fetch_once_401_status_is_not_used_as_refusal_signal_for_domclick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""HTTP 401 — статус здорового рукопожатия DomClick (замер 29.08.2026), НЕ должен
трактоваться как отказ: только тело страницы решает."""
class _Response:
status = 401
class _StatusPage(_DomclickPage):
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
self.goto_urls.append(url)
return _Response()
page = _StatusPage([_HANDSHAKE_LOADER_HTML, _SUCCESS_HTML])
_install(monkeypatch, page)
html = asyncio.run(
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
)
assert html == _SUCCESS_HTML
# ── не-домклик провайдеры: поведение не изменилось (пункт 4) ────────────────────
def test_fetch_once_non_domclick_provider_ignores_domclick_markers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""avito с HTML, который матчил бы _is_domclick_success/_is_domclick_refusal,
не должен уходить ни в какую специальную ветку DomClick-логика провайдер-
скопирована строго под `provider == "domclick"`."""
page = _DomclickPage([_REFUSAL_HTML]) # содержит "похоже, ваш запрос..." — не avito-маркер
_install(monkeypatch, page, provider="avito")
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert html == _REFUSAL_HTML # отдан как обычный контент — avito его не распознаёт
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]

View file

@ -293,62 +293,3 @@ def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPa
body = _json_body(response) body = _json_body(response)
assert body["html"] == _REAL_HTML assert body["html"] == _REAL_HTML
assert body["status"] is None assert body["status"] is None
# ── #3239: 500-ка бан-страницы отличима от 500-ки транспорта ──────────────────
def _fetch_handler_error_body(
monkeypatch: pytest.MonkeyPatch, exc: Exception, upstream_status: int | None
) -> tuple[int, dict]:
monkeypatch.setattr(server, "IS_PROD", False)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
server._last_response_status[provider] = upstream_status
raise exc
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"})))
return response.status, _json_body(response)
def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""Бан-страница несёт ban_page + апстрим-статус.
Без этого признака клиент видит обычную 500-ку, уводит отказ в транспортную
ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел
регрессия, которую #3237 внёс, а #3239 чинит.
"""
status, body = _fetch_handler_error_body(
monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401
)
assert status == 500 # код НЕ меняем: на него завязана classify_browser_probe
assert body["ban_page"] is True
assert body["status"] == 401
assert "BanPageDetectedError" in body["error"]
def test_fetch_handler_transport_error_has_no_ban_page_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Обычный сбой — признака нет вовсе, клиент трактует его как раньше."""
status, body = _fetch_handler_error_body(
monkeypatch, RuntimeError("NS_ERROR_PROXY_BAD_GATEWAY"), None
)
assert status == 500
assert "ban_page" not in body
def test_fetch_handler_ban_page_without_status_stays_null(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Апстрим-статуса нет — отдаём null, а не выдуманный код (#2764)."""
_status, body = _fetch_handler_error_body(
monkeypatch, server.BanPageDetectedError("бан-страница"), None
)
assert body["ban_page"] is True
assert body["status"] is None

View file

@ -73,12 +73,7 @@ class _FakePage:
return None return None
async def content(self) -> str: async def content(self) -> str:
# Содержит __SSR_STATE__ (#3196): для provider="domclick" это единственный return "<html>ok</html>"
# признак "успешной" страницы (см. server._is_domclick_success) — без него
# generic-контент теста уходил бы в ветку ожидания рукопожатия и падал по
# ChallengeTimeoutError. avito/cian игнорируют эту строку (их детекторы
# ищут другие маркеры), так что для них поведение не меняется.
return "<html>ok<script>window.__SSR_STATE__={}</script></html>"
async def close(self) -> None: async def close(self) -> None:
self.closed += 1 self.closed += 1
@ -210,7 +205,7 @@ def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None:
server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True) server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True)
) )
assert html == "<html>ok<script>window.__SSR_STATE__={}</script></html>" assert html == "<html>ok</html>"
assert browser.new_page_calls == 1 assert browser.new_page_calls == 1
assert browser.contexts_created == [] assert browser.contexts_created == []

View file

@ -130,51 +130,6 @@ _PROXY_FAIL_MARKERS: tuple[str, ...] = (
_LEASE_ROTATE_AFTER_FAILS: int = 3 _LEASE_ROTATE_AFTER_FAILS: int = 3
class SidecarBanPageError(httpx.HTTPStatusError):
"""Сайдкар подтвердил бан-страницу по маркерам её тела (#3239).
Подкласс ``HTTPStatusError``, а не самостоятельный тип: ловля по
``httpx.HTTPError`` у всех прочих поставщиков и retry-политика
``fetch()`` продолжают работать не зная о нём. Отличать его нужно ровно
там, где решается судьба узла: за подтверждённым маркером стоит
``report_ban`` (площадка отказала этому IP), за обычной 500-кой нет
(сбой транспорта, #2600 п.4).
``upstream_status`` HTTP-код САМОЙ целевой навигации, а не ответа
сайдкара. На error-пути ``fetch()`` обнуляет ``last_response_status``,
поэтому иначе диагноз узнать неоткуда: у DomClick отказ приходит с 401
и без него классификатор ставит 'unknown' вместо 'platform'.
"""
def __init__(
self,
message: str,
*,
request: httpx.Request,
response: httpx.Response,
upstream_status: int | None,
) -> None:
super().__init__(message, request=request, response=response)
self.upstream_status = upstream_status
def _sidecar_ban_page_status(resp: httpx.Response) -> tuple[bool, int | None]:
"""(это бан-страница?, апстрим-статус) из тела ошибки сайдкара (#3239).
Тело ``{"error": ..., "ban_page": true, "status": <int|null>}``. Сайдкар
старой сборки ключей не отдаёт (False, None), поведение как до правки.
``bool`` отсекаем явно: он подтип ``int`` и JSON ``true`` уехал бы статусом.
"""
try:
body = resp.json()
except Exception:
return False, None
if not isinstance(body, dict) or body.get("ban_page") is not True:
return False, None
raw = body.get("status")
return True, raw if isinstance(raw, int) and not isinstance(raw, bool) else None
def _raise_for_sidecar_status(resp: httpx.Response) -> None: def _raise_for_sidecar_status(resp: httpx.Response) -> None:
"""`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки. """`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
@ -198,17 +153,6 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None:
# Тело не прочиталось/не декодируется — причина не обязана быть; отдаём # Тело не прочиталось/не декодируется — причина не обязана быть; отдаём
# исходную ошибку, а не роняем вызывающего на разборе тела. # исходную ошибку, а не роняем вызывающего на разборе тела.
raise exc from None raise exc from None
is_ban_page, upstream_status = _sidecar_ban_page_status(resp)
if is_ban_page:
# #3239: тип несёт диагноз наверх — подстрокой в тексте его искать
# нельзя, detail обрезан до 300 символов и формулировка отказа
# менялась дважды за месяц.
raise SidecarBanPageError(
f"{exc} | tradein-browser: {detail or 'ban page'}",
request=exc.request,
response=exc.response,
upstream_status=upstream_status,
) from exc
if not detail: if not detail:
raise raise
raise httpx.HTTPStatusError( raise httpx.HTTPStatusError(

View file

@ -61,7 +61,6 @@ from scraper_kit.domclick_exceptions import (
DomClickBlockedError, DomClickBlockedError,
DomClickParseError, DomClickParseError,
) )
from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.offer_price_history import clamp_diff_percent from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.repair_state_normalizer import ( from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text, infer_repair_state_from_text,
@ -545,20 +544,6 @@ async def fetch_detail(
# но НЕ сообщаем пулу здесь: неизвестно, был ли это реальный маркер-бан или # но НЕ сообщаем пулу здесь: неизвестно, был ли это реальный маркер-бан или
# обёртка сетевой ошибки — не смешиваем "сеть" с "бан" (issue #2600 п.4). # обёртка сетевой ошибки — не смешиваем "сеть" с "бан" (issue #2600 п.4).
raise raise
except SidecarBanPageError as exc:
# #3239: сайдкар опознал статический отказ площадки по маркерам тела
# (#3237 перенёс это распознавание из parse_detail_html в сайдкар — иначе
# незавершённое QRATOR-рукопожатие считалось блоком). Это ГЕНУИННЫЙ
# ban-сигнал, ровно того же рода, что и ветка parse_detail_html ниже,
# поэтому здесь — report_ban, в отличие от транспортной ветки следом.
# Статус берём из исключения: на error-пути fetch() обнулил
# last_response_status, а без 401 классификатор поставил бы 'unknown'
# вместо 'platform' и ротация IP не запустилась бы вовсе.
browser_fetcher.report_ban(f"domclick detail: sidecar ban page for {card_url}")
raise DomClickBlockedError(
f"DomClick detail: sidecar detected platform refusal for {card_url}: {exc}",
status=exc.upstream_status,
) from exc
except Exception as exc: except Exception as exc:
# Сетевая/инфраструктурная ошибка самого fetch() (timeout/5xx/transport) — # Сетевая/инфраструктурная ошибка самого fetch() (timeout/5xx/transport) —
# НЕ репортим mark_banned: это не подтверждённый маркер-бан площадки, а # НЕ репортим mark_banned: это не подтверждённый маркер-бан площадки, а

View file

@ -327,12 +327,10 @@ class DomClickScraper(BaseScraper):
комнатности 2+ не собирались никогда. Замер 19.08 по активным комнатности 2+ не собирались никогда. Замер 19.08 по активным
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
площадок 2+ комнат около двух третей выдачи. площадок 2+ комнат около двух третей выдачи.
Сдвиг НЕ лечит обрыв, он распределяет то, что успевает собраться, Сдвиг НЕ лечит блок, он распределяет то, что успевает собраться, по
по всем корзинам вместо одной, и работает даже когда свободен один всем корзинам вместо одной. Работает при единственном свободном узле
узел. Ротация lease тут ни при чём: свип кладёт не счётчик провалов, в отличие от ротации lease, которой сейчас упираться некуда: в пуле 3
а break ниже по первому же DomClickBlockedError (#2854). В пуле включённых узла, 2 забанены Домкликом.
четыре включённых узла, у всех provider_affinity='any'
выделенного домклик-прокси нет с миграции 253 (#2800).
skip_buckets: корзины, уже собранные предыдущим оборванным прогоном skip_buckets: корзины, уже собранные предыдущим оборванным прогоном
(#3118, чекпоинт done_buckets) — по ним ни одного запроса. Вместе (#3118, чекпоинт done_buckets) — по ним ни одного запроса. Вместе
со сдвигом #2854 это превращает случайную ротацию в систематический со сдвигом #2854 это превращает случайную ротацию в систематический