Compare commits
19 commits
5ed4a4ec25
...
66660bd366
| Author | SHA1 | Date | |
|---|---|---|---|
| 66660bd366 | |||
| 2390eec740 | |||
| a5ca2a326a | |||
| ca3f073b0e | |||
| 4e3279d448 | |||
| 272d807f55 | |||
| f3401badd0 | |||
| 7b12b07935 | |||
| 8f0c2ce1f4 | |||
| 6dd8d131ae | |||
| 3d51c08e44 | |||
| 8f3912cdf6 | |||
|
|
c5784e85bc | ||
|
|
3059f09e53 | ||
| 9e243fd643 | |||
|
|
e13374e319 | ||
| 84505b1e0b | |||
| d43d8b0c60 | |||
|
|
5391a36880 |
23 changed files with 1145 additions and 281 deletions
|
|
@ -150,13 +150,6 @@ NOISE_L_BASE: dict[str, float] = {
|
|||
}
|
||||
|
||||
|
||||
def _wind_label(deg: float) -> str:
|
||||
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
|
||||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||||
idx = round(deg / 45) % 8
|
||||
return rose[idx]
|
||||
|
||||
|
||||
# Координаты центра ЕКБ — Площадь 1905 года
|
||||
EKB_CENTER_LAT: float = 56.838011
|
||||
EKB_CENTER_LON: float = 60.597474
|
||||
|
|
|
|||
|
|
@ -6,29 +6,6 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||
# ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────
|
||||
|
||||
|
||||
class RecentPermit(BaseModel):
|
||||
"""Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""
|
||||
|
||||
permit_type: str
|
||||
permit_number: str
|
||||
issue_date: str | None
|
||||
developer_name: str | None
|
||||
developer_inn: str | None
|
||||
object_name: str | None
|
||||
object_type: str | None
|
||||
construction_address: str | None
|
||||
total_area_sqm: float | None
|
||||
|
||||
|
||||
class PermitsSummary(BaseModel):
|
||||
"""Агрегированная сводка по разрешениям в квартале."""
|
||||
|
||||
rns_count: int
|
||||
rve_count: int
|
||||
rns_total_area_sqm: float
|
||||
by_developer: list[dict[str, Any]]
|
||||
|
||||
|
||||
# ── Connection points schemas (issue #115) ────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -564,16 +541,6 @@ class DeveloperAttributionResult(BaseModel):
|
|||
# ── Layout analysis (Issue #113) ───────────────────────────────────────────
|
||||
|
||||
|
||||
class LayoutSignature(BaseModel):
|
||||
"""Минимальная сигнатура планировки = (room_bucket, area_bin).
|
||||
|
||||
Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
|
||||
"""
|
||||
|
||||
room_bucket: Literal["studio", "1", "2", "3", "4+"]
|
||||
area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]
|
||||
|
||||
|
||||
class BestLayoutsRequest(BaseModel):
|
||||
"""Параметры запроса top-планировок в радиусе вокруг участка."""
|
||||
|
||||
|
|
|
|||
|
|
@ -181,13 +181,3 @@ def upsert_documents(db: Session, obj_id: int, docs: list[dict[str, Any]]) -> tu
|
|||
|
||||
# ── Stub for future Celery download task ──────────────────────────────────────
|
||||
|
||||
|
||||
def download_document_stub(obj_id: int, doc_id: int, file_url: str) -> None:
|
||||
"""Placeholder для будущей Celery-задачи download_domrf_documents.
|
||||
|
||||
Скачивает PDF, сохраняет в data/raw/domrf_docs/{obj_id}/{filename},
|
||||
обновляет domrf_kn_documents.local_path + downloaded_at.
|
||||
|
||||
Реализация — отдельный PR (future task).
|
||||
"""
|
||||
raise NotImplementedError("PDF download not implemented in 22i. See issue #297 future PR.")
|
||||
|
|
|
|||
|
|
@ -1807,55 +1807,6 @@ async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict
|
|||
return _flatten_table(payload)
|
||||
|
||||
|
||||
async def probe_endpoint(
|
||||
url_or_path: str,
|
||||
region_code: int = 66,
|
||||
headed: bool = False,
|
||||
load_state: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Quick one-shot probe through a real Playwright session for debugging.
|
||||
|
||||
Returns {status, content_type, body_preview, full_url}. Use to verify that
|
||||
a URL works in our session before/after a failing scraper run.
|
||||
"""
|
||||
if url_or_path.startswith("http"):
|
||||
# split into path+query for BrowserSession.get_json
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
s = urlsplit(url_or_path)
|
||||
path = s.path
|
||||
params = dict(parse_qsl(s.query, keep_blank_values=True))
|
||||
else:
|
||||
path = url_or_path.split("?", 1)[0]
|
||||
params = {}
|
||||
if "?" in url_or_path:
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
params = dict(parse_qsl(url_or_path.split("?", 1)[1], keep_blank_values=True))
|
||||
|
||||
async with BrowserSession(
|
||||
region_code=region_code, headed=headed, load_state=load_state
|
||||
) as sess:
|
||||
try:
|
||||
payload = await sess.get_json(path, params)
|
||||
body = json.dumps(payload, ensure_ascii=False)[:2000]
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
full_url = f"{BASE_URL}{path}" + (f"?{qs}" if qs else "")
|
||||
return {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"body_preview": body,
|
||||
"full_url": full_url,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": _http_status_of(e),
|
||||
"content_type": "error",
|
||||
"body_preview": str(e)[:2000],
|
||||
"full_url": f"{BASE_URL}{path}",
|
||||
}
|
||||
|
||||
|
||||
def log_progress(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
|
|
|
|||
|
|
@ -133,21 +133,6 @@ def fetch_geoportal(
|
|||
raise NspdLiteError(f"Network error: {e}") from e
|
||||
|
||||
|
||||
def fetch_quarter(quarter_cad_num: str, **kwargs) -> dict[str, Any]:
|
||||
"""Конкретный кадастровый квартал (typed wrapper)."""
|
||||
return fetch_geoportal(quarter_cad_num, thematic_id=THEMATIC["quarter"], **kwargs)
|
||||
|
||||
|
||||
def fetch_parcel(parcel_cad_num: str, **kwargs) -> dict[str, Any]:
|
||||
"""Конкретный участок ЗУ (typed wrapper)."""
|
||||
return fetch_geoportal(parcel_cad_num, thematic_id=THEMATIC["parcel"], **kwargs)
|
||||
|
||||
|
||||
def fetch_building(building_cad_num: str, **kwargs) -> dict[str, Any]:
|
||||
"""Конкретное здание ОКС (typed wrapper)."""
|
||||
return fetch_geoportal(building_cad_num, thematic_id=THEMATIC["building"], **kwargs)
|
||||
|
||||
|
||||
# ── Bulk через rosreestr2coord library ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import logging
|
|||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -557,36 +557,3 @@ class ObjectiveClient:
|
|||
|
||||
# ── удобный one-shot helper ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fetch_with_raw_log(
|
||||
fn_name: str,
|
||||
*,
|
||||
save_dir: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[Any, str | None]:
|
||||
"""Вызывает client.<fn_name>(**kwargs), сохраняет raw JSON в файл если задан save_dir.
|
||||
Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill."""
|
||||
from pathlib import Path
|
||||
|
||||
client = ObjectiveClient()
|
||||
try:
|
||||
method = getattr(client, fn_name)
|
||||
data = method(**kwargs)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
file_path: str | None = None
|
||||
if save_dir:
|
||||
Path(save_dir).mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
slug = (
|
||||
"_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v)
|
||||
or "all"
|
||||
)
|
||||
slug = slug.replace(" ", "_").replace("/", "_")
|
||||
file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json"
|
||||
Path(file_path).write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return data, file_path
|
||||
|
|
|
|||
|
|
@ -61,18 +61,6 @@ _KRT_AT_SQL = text("""
|
|||
""")
|
||||
|
||||
|
||||
def _functional_zone_at(
|
||||
client: EKBGeoportalClient, lon: float, lat: float
|
||||
) -> dict[str, Any] | None:
|
||||
"""Функц.зона генплана (#1058) в точке — properties первой WFS-фичи; None при сбое/пусто."""
|
||||
try:
|
||||
feats = client.features_at_point("functional_zone", lon, lat)
|
||||
except Exception as exc: # внешний геопортал, graceful
|
||||
logger.warning("ird_analyze: functional_zone WFS failed: %s", exc)
|
||||
return None
|
||||
return feats[0].properties if feats else None
|
||||
|
||||
|
||||
def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
|
||||
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ Graceful: нет таблицы / пустой WKT / исключение БД
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
|
@ -98,12 +97,6 @@ _PLANNING_NO_MATCH_SQL = text(
|
|||
)
|
||||
|
||||
|
||||
def _extract_paga_num(full_name: str) -> str | None:
|
||||
"""Извлечь номер ПАГЕ из заголовка planning_project.full_name."""
|
||||
m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
|
||||
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал.
|
||||
|
||||
|
|
|
|||
|
|
@ -472,18 +472,6 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]:
|
|||
return result_dict
|
||||
|
||||
|
||||
async def load_utility_infrastructure(db: Session | None = None) -> dict[str, int]:
|
||||
"""Async-обёртка для прямого вызова из async-контекста (#1746 spec).
|
||||
|
||||
Тянет Overpass и UPSERT-ит в таблицу. ``db`` принимается для сигнатурной
|
||||
совместимости со спекой, но UPSERT идёт через собственную SessionLocal
|
||||
(как noise/poi sync) — Overpass-fetch async, запись в БД sync. Возвращает
|
||||
те же per-kind counts, что ``sync_utility_infrastructure_to_db``.
|
||||
"""
|
||||
elements = await fetch_overpass_utility()
|
||||
return _upsert_elements(elements)
|
||||
|
||||
|
||||
# Junk-значения OSM tags, которые не несут смысла для попапа.
|
||||
_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"})
|
||||
|
||||
|
|
|
|||
|
|
@ -150,69 +150,6 @@ def _build_beat_schedule_from_db() -> dict:
|
|||
return schedule
|
||||
|
||||
|
||||
def _build_beat_schedule_fallback() -> dict:
|
||||
"""Fallback-расписание из env vars — используется если БД недоступна на старте."""
|
||||
logger.warning("beat_schedule: строим fallback из env vars")
|
||||
schedule: dict = {}
|
||||
|
||||
# scrape_kn
|
||||
try:
|
||||
for rc in _default_regions():
|
||||
schedule[f"kn-region-{rc}"] = {
|
||||
"task": "tasks.scrape_kn.scrape_kn_region",
|
||||
"schedule": _parse_cron(settings.scrape_kn_cron),
|
||||
"args": [rc, None],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("beat_schedule fallback: scrape_kn failed: %s", e)
|
||||
|
||||
# objective_sync
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.objective_sync_config import get_cron_schedule_safe
|
||||
|
||||
cron_str = get_cron_schedule_safe(SessionLocal)
|
||||
except Exception:
|
||||
cron_str = settings.objective_sync_cron
|
||||
|
||||
try:
|
||||
schedule["objective-sync"] = {
|
||||
"task": "tasks.scrape_objective.sync_all_groups",
|
||||
"schedule": _parse_cron(cron_str),
|
||||
"kwargs": {"triggered_by": "beat"},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("beat_schedule fallback: objective_sync failed: %s", e)
|
||||
|
||||
# refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК
|
||||
try:
|
||||
schedule["refresh-ekb-districts-medians"] = {
|
||||
"task": "tasks.refresh_analytics.refresh_ekb_districts_medians",
|
||||
"schedule": _parse_cron("0 4 5 * *"),
|
||||
"kwargs": {"window_months": 24, "min_deals": 50},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
|
||||
|
||||
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
|
||||
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
|
||||
schedule["ekb-krt-sync-weekly"] = {
|
||||
"task": "tasks.ekb_krt_sync.sync_krt_sites",
|
||||
"schedule": _parse_cron("0 5 * * wed"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
|
||||
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
|
||||
schedule["riasurt-sverdl-harvest-quarterly"] = {
|
||||
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
|
||||
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
def build_beat_schedule() -> dict:
|
||||
"""Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries.
|
||||
|
||||
|
|
|
|||
|
|
@ -174,25 +174,38 @@ check "www.meraocenka.ru — 301 to canonical" "https://www.meraocenka.ru/" 301
|
|||
check "www.merahome.ru — 301 to canonical" "https://www.merahome.ru/" 301
|
||||
check "www.meraotsenka.ru — 301 to canonical" "https://www.meraotsenka.ru/" 301
|
||||
|
||||
# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
|
||||
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
|
||||
# СТОРОН прямо сейчас:
|
||||
# 6. Платёжный периметр. Заведён в PR-D2 как канарейка «ожидания обновляют
|
||||
# вместе с тем PR, который путь открывает». Канарейка отработала: PR-D3
|
||||
# (#3231, роутер) смержен 29.08 и внёс notify в `_PUBLIC_PATHS`, ожидание
|
||||
# здесь обновлено тем же днём — но уже ПОСЛЕ красного деплоя, а не вместе с
|
||||
# ним. Кто будет делать PR-D4 — правьте ожидания в одном PR с ним.
|
||||
#
|
||||
# Состояние на 29.08, чего ждём сейчас:
|
||||
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
|
||||
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
|
||||
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
|
||||
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
|
||||
# платёжные пути и требует X-Authenticated-User → 401 анониму.
|
||||
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
|
||||
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
|
||||
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
|
||||
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
|
||||
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит. Это ВСЁ ЕЩЁ
|
||||
# канарейка: PR-D4 не смержен, 404 должен держаться;
|
||||
# - gendsgn.ru: `checkout` по-прежнему требует X-Authenticated-User → 401
|
||||
# анониму (он не публичный и не станет им);
|
||||
# - gendsgn.ru: `notify` — публичный по построению (вебхук банка), см.
|
||||
# комментарий у самой проверки ниже.
|
||||
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
|
||||
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
|
||||
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
|
||||
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
|
||||
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
|
||||
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
|
||||
# notify — ЕДИНСТВЕННЫЙ платёжный путь, который PR-D3 (#3231) открыл анониму
|
||||
# осознанно: это вебхук банка, он обязан быть достижим без наших заголовков, и
|
||||
# потому внесён в `_PUBLIC_PATHS`. Канарейка выше отработала как задумано — 401
|
||||
# здесь стал бы теперь признаком ПОЛОМКИ (банк получил бы отказ), а не защиты.
|
||||
#
|
||||
# Проверяем POST и ждём 503: это строже прежнего 401 — утверждает сразу, что
|
||||
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
|
||||
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
|
||||
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
|
||||
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
|
||||
check "trade-in payments/notify — 405 на GET (только POST)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
|
||||
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
|
||||
|
||||
echo "========================================"
|
||||
|
|
|
|||
|
|
@ -2405,6 +2405,112 @@ def _fetch_anchor_comps(
|
|||
return [], None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _HouseFacts:
|
||||
"""Характеристики дома из справочника `houses` (#3234).
|
||||
|
||||
total_floors / year_built / house_type — fallback для пропущенных
|
||||
payload-полей ПЕРЕД OSM Overpass (houses на два порядка полнее и не тратит
|
||||
внешний вызов). material_walls читается на будущее — потребителя ниже по
|
||||
estimator пока нет, но данные есть: 7838 домов из 10221 (77%) на проде после
|
||||
наполнения из ГИС ЖКХ + DOM.РФ 2026-08-29.
|
||||
"""
|
||||
|
||||
house_id: int
|
||||
total_floors: int | None
|
||||
year_built: int | None
|
||||
house_type: str | None
|
||||
material_walls: str | None
|
||||
|
||||
|
||||
def _lookup_house_facts(
|
||||
db: Session,
|
||||
*,
|
||||
target_house_id: int | None,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
) -> _HouseFacts | None:
|
||||
"""Читает total_floors/year_built/house_type/material_walls из `houses`.
|
||||
|
||||
Ключ — canonical target_house_id (уже резолвлен match_house_readonly выше
|
||||
по estimate-функции); если он None — фолбэк на ближайший дом в радиусе 60м
|
||||
от subject lat/lon (ST_DWithin + сортировка по расстоянию, LIMIT 1).
|
||||
|
||||
Best-effort: любая ошибка БД → None + WARNING, оценка не должна падать
|
||||
(тот же паттерн try/except + db.rollback(), см. _fetch_anchor_comps выше).
|
||||
"""
|
||||
try:
|
||||
if target_house_id is not None:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, total_floors, year_built, house_type, material_walls
|
||||
FROM houses
|
||||
WHERE id = CAST(:hid AS bigint)
|
||||
"""
|
||||
),
|
||||
{"hid": target_house_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
elif lat is not None and lon is not None:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, total_floors, year_built, house_type, material_walls
|
||||
FROM houses
|
||||
WHERE geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_MakePoint(:lon, :lat)::geography,
|
||||
60
|
||||
)
|
||||
ORDER BY geom::geography <-> ST_MakePoint(:lon, :lat)::geography
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"lon": lon, "lat": lat},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
return None
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.warning("house facts lookup failed (graceful): %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
# Guard типов строки: houses.id — NOT NULL bigint, любая другая форма (в т.ч.
|
||||
# тестовый db-double без явного мока запроса) трактуется как «строки нет» —
|
||||
# тот же best-effort, что и явная ошибка выше. Остальные поля best-effort
|
||||
# каждое по отдельности — неожиданный тип поля не должен ронять facts целиком.
|
||||
house_id = row["id"]
|
||||
if not isinstance(house_id, int):
|
||||
return None
|
||||
|
||||
def _as_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
def _as_str(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
return _HouseFacts(
|
||||
house_id=house_id,
|
||||
total_floors=_as_int(row["total_floors"]),
|
||||
year_built=_as_int(row["year_built"]),
|
||||
house_type=_as_str(row["house_type"]),
|
||||
material_walls=_as_str(row["material_walls"]),
|
||||
)
|
||||
|
||||
|
||||
def _band_haircut(anchor_ppm2: float) -> float:
|
||||
"""asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену).
|
||||
|
||||
|
|
@ -4040,7 +4146,39 @@ async def estimate_quality(
|
|||
# gateway-таймауту; деградируем в None при превышении budget.
|
||||
target_year = payload.year_built
|
||||
target_house_type = payload.house_type
|
||||
if target_year is None or target_house_type is None:
|
||||
target_total_floors = payload.total_floors
|
||||
|
||||
# 2a. #3234: houses — наш справочник, приоритет ПЕРЕД OSM Overpass (year
|
||||
# 86.8% / floors 98.3% покрытия на проде против 68 строк в
|
||||
# house_metadata-кэше). payload остаётся главнее в любом случае — houses
|
||||
# заполняет только то, что пользователь не указал.
|
||||
if target_year is None or target_house_type is None or target_total_floors is None:
|
||||
house_facts = await asyncio.to_thread(
|
||||
_lookup_house_facts,
|
||||
db,
|
||||
target_house_id=target_house_id,
|
||||
lat=geo.lat,
|
||||
lon=geo.lon,
|
||||
)
|
||||
if house_facts is not None:
|
||||
filled: list[str] = []
|
||||
if target_year is None and house_facts.year_built is not None:
|
||||
target_year = house_facts.year_built
|
||||
filled.append(f"year_built={target_year}")
|
||||
if target_house_type is None and house_facts.house_type is not None:
|
||||
target_house_type = house_facts.house_type
|
||||
filled.append(f"house_type={target_house_type}")
|
||||
if target_total_floors is None and house_facts.total_floors is not None:
|
||||
target_total_floors = house_facts.total_floors
|
||||
filled.append(f"total_floors={target_total_floors}")
|
||||
if filled:
|
||||
logger.info(
|
||||
"estimate: house facts fallback house_id=%s → %s",
|
||||
house_facts.house_id,
|
||||
", ".join(filled),
|
||||
)
|
||||
|
||||
if target_year is None or target_house_type is None or target_total_floors is None:
|
||||
house_meta = await _with_budget(
|
||||
get_house_metadata(geo.lat, geo.lon, db),
|
||||
settings.estimate_house_meta_timeout_s,
|
||||
|
|
@ -4051,6 +4189,8 @@ async def estimate_quality(
|
|||
target_year = house_meta.year_built
|
||||
if target_house_type is None:
|
||||
target_house_type = house_meta.house_type
|
||||
if target_total_floors is None:
|
||||
target_total_floors = house_meta.total_floors
|
||||
|
||||
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
|
||||
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
|
||||
|
|
@ -4097,7 +4237,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
cohort_year_min=cy_min,
|
||||
cohort_year_max=cy_max,
|
||||
)
|
||||
|
|
@ -4122,7 +4262,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
# #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
|
||||
# surfaced later as the "снят фильтр по году постройки" relaxation label.
|
||||
|
|
@ -4142,7 +4282,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
if len(listings_wide) > len(listings):
|
||||
listings = listings_wide
|
||||
|
|
@ -4166,7 +4306,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
if len(listings_widearea) > len(listings):
|
||||
listings = listings_widearea
|
||||
|
|
@ -4225,7 +4365,7 @@ async def estimate_quality(
|
|||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
house_type=target_house_type,
|
||||
total_floors=payload.total_floors,
|
||||
total_floors=target_total_floors,
|
||||
)
|
||||
if len(candidate) > len(listings):
|
||||
return candidate, tier
|
||||
|
|
|
|||
|
|
@ -38,10 +38,13 @@
|
|||
-- same-site SERP-origin navigation cadence from PR #2430 -- QRATOR is
|
||||
-- reputation-based, not purely rate-based, but a more human cadence
|
||||
-- reduces the odds of tripping it in the first place.
|
||||
-- max_consecutive_blocks -- 3 (vs Avito's 5): DomClick has no IP-rotation recovery
|
||||
-- on block (one dedicated residential proxy, not a pool) -- aborting
|
||||
-- sooner avoids hammering an already-reputation-damaged proxy/session
|
||||
-- and preserves it for the next scheduled window.
|
||||
-- max_consecutive_blocks -- 3 (vs Avito's 5): aborting sooner avoids hammering an
|
||||
-- already-reputation-damaged proxy/session and preserves it for the
|
||||
-- next scheduled window. NB the original rationale here ("one
|
||||
-- dedicated residential proxy, not a pool") is obsolete: migration
|
||||
-- 253 (#2800) dropped the reservation, and the backfill was wired
|
||||
-- into the shared pool in PR #3222 -- all four nodes carry
|
||||
-- provider_affinity='any'.
|
||||
--
|
||||
-- Seeded with enabled=false: activation is a deliberate, separate manual step after
|
||||
-- this migration deploys and the orchestrator is smoke-tested against a live cookie
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ import json
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||
from scraper_kit.providers.domclick.detail import (
|
||||
|
|
@ -438,6 +440,61 @@ async def test_fetch_detail_transport_failure_does_not_report_ban() -> None:
|
|||
bf.report_ban.assert_not_called()
|
||||
|
||||
|
||||
# ── #3239: бан-страница ОТ САЙДКАРА — тоже генуинный маркер-детект ────────────
|
||||
#
|
||||
# #3237 перенёс распознавание статического отказа Домклика из parse_detail_html в
|
||||
# сайдкар (иначе незавершённое QRATOR-рукопожатие считалось блоком). Отказ стал
|
||||
# приезжать обычной 500-кой и попадать в транспортную ветку, где report_ban по
|
||||
# построению НЕ зовётся → ban_kind 'unknown' вместо 'platform' → ротация IP не
|
||||
# запускалась вовсе. Тесты ниже держат обе ветки РАЗДЕЛЬНО: подтверждённый отказ
|
||||
# банит, голый транспортный сбой — нет (тест выше).
|
||||
|
||||
|
||||
def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPageError:
|
||||
return SidecarBanPageError(
|
||||
"Server error '500' | tradein-browser: BanPageDetectedError: статический отказ",
|
||||
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||
response=httpx.Response(500, request=httpx.Request("POST", "http://x/fetch")),
|
||||
upstream_status=upstream_status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
|
||||
bf = MagicMock()
|
||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
||||
with pytest.raises(DomClickBlockedError):
|
||||
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||
bf.report_ban.assert_called_once()
|
||||
assert _CARD_URL in bf.report_ban.call_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_detail_sidecar_ban_page_carries_upstream_status() -> None:
|
||||
"""Статус берётся ИЗ ИСКЛЮЧЕНИЯ, а не из last_response_status.
|
||||
|
||||
Несущая деталь #3239: на error-пути fetch() обнуляет last_response_status,
|
||||
поэтому у Домклика (отказ приходит с 401) классификатор без этого поля
|
||||
поставил бы 'unknown'. MagicMock отдаёт last_response_status как Mock —
|
||||
если бы код читал его, ассерт ниже упал бы.
|
||||
"""
|
||||
bf = MagicMock()
|
||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(401))
|
||||
with pytest.raises(DomClickBlockedError) as excinfo:
|
||||
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||
assert excinfo.value.status == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_detail_sidecar_ban_page_without_status_stays_none() -> None:
|
||||
"""Сайдкар не отдал статус → None, а не выдуманный код (#2764)."""
|
||||
bf = MagicMock()
|
||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(None))
|
||||
with pytest.raises(DomClickBlockedError) as excinfo:
|
||||
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||
assert excinfo.value.status is None
|
||||
|
||||
|
||||
# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ─────────────
|
||||
|
||||
|
||||
|
|
|
|||
294
tradein-mvp/backend/tests/test_3234_house_facts_fallback.py
Normal file
294
tradein-mvp/backend/tests/test_3234_house_facts_fallback.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"""#3234: `houses` как fallback-источник total_floors/year_built/house_type в estimate.
|
||||
|
||||
Приоритет: payload (пользователь) → houses (наш справочник, локальный, ~87%
|
||||
покрытия года / ~98% этажности на проде) → OSM Overpass (get_house_metadata,
|
||||
внешний вызов с таймаутом, house_metadata-кэш — всего 68 строк на базу).
|
||||
|
||||
payload НИКОГДА не переопределяется: houses/OSM заполняют только то, что
|
||||
payload не задал. get_house_metadata должен вызываться ТОЛЬКО если после
|
||||
payload+houses что-то всё ещё неизвестно (экономия внешнего вызова).
|
||||
|
||||
year_built из houses обязан пройти через тот же _sanitize_build_year, что и
|
||||
payload/OSM (Mera-audit 2026-08-02, прод-инцидент year_built=1829 — см.
|
||||
test_estimator_implausible_year.py).
|
||||
|
||||
Два уровня тестов:
|
||||
1. _lookup_house_facts() изолированно — маршрутизация SQL (house_id vs
|
||||
геофолбэк) + best-effort на ошибке БД (без сети).
|
||||
2. estimate_quality() целиком, offline — зеркалит паттерн
|
||||
test_estimator_client_coords.py: все внешние вызовы замоканы,
|
||||
_fetch_analogs захватывает kwargs, чтобы проверить что именно уехало
|
||||
вниз по пайплайну.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
# Settings требует DATABASE_URL при импорте app.core.config — до любого app-импорта.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
# ── Level 1: _lookup_house_facts() изолированно (без estimate_quality) ─────
|
||||
|
||||
|
||||
def test_lookup_house_facts_uses_house_id_when_present() -> None:
|
||||
"""target_house_id задан → SQL идёт по id, геофолбэк (ST_DWithin) не участвует."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"id": 42,
|
||||
"total_floors": 9,
|
||||
"year_built": 1975,
|
||||
"house_type": "panel",
|
||||
"material_walls": None,
|
||||
}
|
||||
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
|
||||
|
||||
assert facts is not None
|
||||
assert (facts.house_id, facts.total_floors, facts.year_built, facts.house_type) == (
|
||||
42,
|
||||
9,
|
||||
1975,
|
||||
"panel",
|
||||
)
|
||||
sql_text = str(db.execute.call_args.args[0])
|
||||
assert "WHERE id = " in sql_text
|
||||
assert "ST_DWithin" not in sql_text
|
||||
|
||||
|
||||
def test_lookup_house_facts_falls_back_to_geo_radius_when_no_house_id() -> None:
|
||||
"""target_house_id=None → SQL идёт по ST_DWithin(60м) + ближайший к lat/lon."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"id": 7,
|
||||
"total_floors": 5,
|
||||
"year_built": 1968,
|
||||
"house_type": "brick",
|
||||
"material_walls": None,
|
||||
}
|
||||
facts = _lookup_house_facts(db, target_house_id=None, lat=56.838, lon=60.595)
|
||||
|
||||
assert facts is not None
|
||||
assert facts.house_id == 7
|
||||
sql_text = str(db.execute.call_args.args[0])
|
||||
assert "ST_DWithin" in sql_text
|
||||
assert "60" in sql_text
|
||||
|
||||
|
||||
def test_lookup_house_facts_no_key_returns_none_without_query() -> None:
|
||||
"""Ни house_id, ни lat/lon — запроса к БД нет вообще, сразу None."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
facts = _lookup_house_facts(db, target_house_id=None, lat=None, lon=None)
|
||||
|
||||
assert facts is None
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_lookup_house_facts_db_error_is_graceful() -> None:
|
||||
"""Любая ошибка БД → None + rollback, estimate не должен падать (best-effort)."""
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
|
||||
|
||||
assert facts is None
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
# ── Level 2: estimate_quality() целиком, offline ────────────────────────────
|
||||
|
||||
|
||||
def _make_fake_geo():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload(*, year_built=None, house_type=None, total_floors=None):
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Учителей, 18",
|
||||
area_m2=38.8,
|
||||
rooms=1,
|
||||
floor=4,
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
year_built=year_built,
|
||||
house_type=house_type,
|
||||
total_floors=total_floors,
|
||||
)
|
||||
|
||||
|
||||
def _house_facts(*, house_id=777, total_floors=9, year_built=1975, house_type="panel"):
|
||||
from app.services.estimator import _HouseFacts
|
||||
|
||||
return _HouseFacts(
|
||||
house_id=house_id,
|
||||
total_floors=total_floors,
|
||||
year_built=year_built,
|
||||
house_type=house_type,
|
||||
material_walls=None,
|
||||
)
|
||||
|
||||
|
||||
def _house_meta(*, total_floors, year_built, house_type):
|
||||
from app.services.house_metadata import HouseMetadata
|
||||
|
||||
return HouseMetadata(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
year_built=year_built,
|
||||
total_floors=total_floors,
|
||||
house_type=house_type,
|
||||
total_units=None,
|
||||
source="osm",
|
||||
)
|
||||
|
||||
|
||||
def _run_estimate(payload, *, house_facts, house_meta):
|
||||
"""Прогоняет estimate_quality offline. Возвращает (result, _fetch_analogs mock,
|
||||
get_house_metadata mock) — mocks выжившие после await для инспекции call_args."""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
||||
house_facts_mock = MagicMock(return_value=house_facts)
|
||||
house_meta_mock = AsyncMock(return_value=house_meta)
|
||||
analogs_mock = MagicMock(return_value=([], False, "W"))
|
||||
|
||||
async def _go():
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in (
|
||||
patch("app.services.estimator.geocode", new=geocode_mock),
|
||||
patch("app.services.estimator._lookup_house_facts", new=house_facts_mock),
|
||||
patch("app.services.estimator.get_house_metadata", new=house_meta_mock),
|
||||
patch("app.services.estimator._fetch_analogs", new=analogs_mock),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
stack.enter_context(cm)
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
result = anyio.run(_go)
|
||||
return result, analogs_mock, house_meta_mock
|
||||
|
||||
|
||||
def _floors_seen(mock) -> set:
|
||||
return {c.kwargs["total_floors"] for c in mock.call_args_list}
|
||||
|
||||
|
||||
def _years_seen(mock) -> set:
|
||||
return {c.kwargs["year_built"] for c in mock.call_args_list}
|
||||
|
||||
|
||||
def _house_types_seen(mock) -> set:
|
||||
return {c.kwargs["house_type"] for c in mock.call_args_list}
|
||||
|
||||
|
||||
def test_empty_payload_uses_houses_facts() -> None:
|
||||
"""payload пустой, houses знает всё → его значения уезжают в _fetch_analogs."""
|
||||
payload = _make_payload()
|
||||
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
assert analogs_mock.call_args_list, "estimate должен вызвать _fetch_analogs хотя бы раз"
|
||||
assert _floors_seen(analogs_mock) == {9}
|
||||
assert _years_seen(analogs_mock) == {1975}
|
||||
assert _house_types_seen(analogs_mock) == {"panel"}
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_payload_wins_over_conflicting_houses_facts() -> None:
|
||||
"""payload задан, houses знает ДРУГОЕ → побеждает payload (user главнее houses)."""
|
||||
payload = _make_payload(year_built=2015, house_type="brick", total_floors=5)
|
||||
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
assert _floors_seen(analogs_mock) == {5}
|
||||
assert _years_seen(analogs_mock) == {2015}
|
||||
assert _house_types_seen(analogs_mock) == {"brick"}
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_neither_payload_nor_houses_falls_back_to_osm() -> None:
|
||||
"""Ни payload, ни houses ничего не знают → OSM get_house_metadata вызывается
|
||||
(старое поведение сохранено, houses тут ничем не помогает)."""
|
||||
payload = _make_payload()
|
||||
meta = _house_meta(total_floors=5, year_built=1968, house_type="brick")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=None, house_meta=meta
|
||||
)
|
||||
|
||||
house_meta_mock.assert_awaited()
|
||||
assert _floors_seen(analogs_mock) == {5}
|
||||
assert _years_seen(analogs_mock) == {1968}
|
||||
assert _house_types_seen(analogs_mock) == {"brick"}
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_payload_and_houses_cover_everything_skips_osm() -> None:
|
||||
"""payload частично + houses добивает остальное → OSM НЕ вызывается (экономия
|
||||
внешнего бюджетируемого вызова, п.5 задачи)."""
|
||||
payload = _make_payload(year_built=2015) # house_type/total_floors придут из houses
|
||||
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert _floors_seen(analogs_mock) == {9}
|
||||
assert _years_seen(analogs_mock) == {2015} # payload побеждает для года
|
||||
assert _house_types_seen(analogs_mock) == {"panel"}
|
||||
assert result.estimate_id is not None
|
||||
|
||||
|
||||
def test_implausible_year_from_houses_is_sanitized() -> None:
|
||||
"""houses отдал неправдоподобный год (1829, прод-паттерн) → _sanitize_build_year
|
||||
отбивает его в None; total_floors/house_type из houses при этом всё равно
|
||||
применяются (год — независимый гейт, не блокирует остальные поля)."""
|
||||
payload = _make_payload()
|
||||
facts = _house_facts(total_floors=9, year_built=1829, house_type="panel")
|
||||
result, analogs_mock, house_meta_mock = _run_estimate(
|
||||
payload, house_facts=facts, house_meta=None
|
||||
)
|
||||
|
||||
assert _years_seen(analogs_mock) == {None}
|
||||
assert _floors_seen(analogs_mock) == {9}
|
||||
assert _house_types_seen(analogs_mock) == {"panel"}
|
||||
house_meta_mock.assert_not_awaited()
|
||||
assert result.estimate_id is not None
|
||||
|
|
@ -27,8 +27,14 @@ from __future__ import annotations
|
|||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
|
||||
from scraper_kit.browser_fetcher import (
|
||||
BrowserFetcher,
|
||||
SidecarBanPageError,
|
||||
_raise_for_sidecar_status,
|
||||
ban_kind_from_status,
|
||||
)
|
||||
|
||||
|
||||
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
|
||||
|
|
@ -158,3 +164,73 @@ def test_ban_kind_values_fit_scrape_runs_check() -> None:
|
|||
allowed = {"platform", "infra", "unknown", None}
|
||||
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
|
||||
assert ban_kind_from_status(status) in allowed
|
||||
|
||||
|
||||
# ── #3239: подтверждённая бан-страница отличима от прочих 500-ок ──────────────
|
||||
#
|
||||
# Тип, а не подстрока: _raise_for_sidecar_status обрезает тело до 300 символов, и
|
||||
# формулировка отказа у сайдкара менялась дважды за месяц. upstream_status нужен
|
||||
# отдельным полем — на error-пути fetch() обнуляет last_response_status, и без
|
||||
# него у DomClick (отказ приходит с 401) диагноз стал бы 'unknown' вместо
|
||||
# 'platform', то есть ротация IP не запустилась бы вовсе.
|
||||
|
||||
|
||||
def _sidecar_response(body: Any) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
500, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||
)
|
||||
|
||||
|
||||
def test_ban_page_body_raises_typed_error_with_upstream_status() -> None:
|
||||
resp = _sidecar_response(
|
||||
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401}
|
||||
)
|
||||
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert excinfo.value.upstream_status == 401
|
||||
|
||||
|
||||
def test_ban_page_error_is_httpx_status_error() -> None:
|
||||
"""Подкласс — иначе retry-политика fetch() и ловля у прочих поставщиков сломались бы."""
|
||||
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": 403})
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_raise_for_sidecar_status(resp)
|
||||
|
||||
|
||||
def test_plain_500_stays_plain_status_error() -> None:
|
||||
resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"})
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert not isinstance(excinfo.value, SidecarBanPageError)
|
||||
|
||||
|
||||
def test_ban_page_without_status_gives_none() -> None:
|
||||
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": None})
|
||||
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert excinfo.value.upstream_status is None
|
||||
|
||||
|
||||
def test_ban_page_true_as_bool_status_is_rejected() -> None:
|
||||
"""JSON true не должен уехать статусом: bool — подтип int (та же ловушка, что в #3196)."""
|
||||
resp = _sidecar_response({"error": "x", "ban_page": True, "status": True})
|
||||
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert excinfo.value.upstream_status is None
|
||||
|
||||
|
||||
def test_non_json_error_body_does_not_break() -> None:
|
||||
resp = httpx.Response(
|
||||
500, text="not json", request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
_raise_for_sidecar_status(resp)
|
||||
assert not isinstance(excinfo.value, SidecarBanPageError)
|
||||
|
||||
|
||||
def test_success_response_raises_nothing() -> None:
|
||||
resp = httpx.Response(
|
||||
200, json={"html": "<html/>", "status": 200},
|
||||
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||
)
|
||||
_raise_for_sidecar_status(resp)
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ import asyncio
|
|||
import base64
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -961,7 +961,17 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
||||
error_body: dict = {"error": f"{type(exc).__name__}: {exc}"}
|
||||
if isinstance(exc, BanPageDetectedError):
|
||||
# #3239: бан-страница — ПОДТВЕРЖДЁННЫЙ маркер-детект, а не сбой
|
||||
# транспорта, и только за первым стоит report_ban на клиенте.
|
||||
# До этой правки оба случая приезжали одинаковой 500-кой, клиент
|
||||
# различить их не мог и настоящий отказ площадки переставал
|
||||
# ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500
|
||||
# завязана classify_browser_probe, признак несёт тело.
|
||||
error_body["ban_page"] = True
|
||||
error_body["status"] = _last_response_status.get(provider)
|
||||
return web.json_response(error_body, status=500)
|
||||
|
||||
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
|
||||
# только его, ничего не заметит. "status" может быть null (goto вернул None).
|
||||
|
|
@ -1230,6 +1240,55 @@ def _is_ban_page(html: str) -> bool:
|
|||
return any(marker in lower for marker in _BAN_MARKERS)
|
||||
|
||||
|
||||
# ── QRATOR-рукопожатие DomClick (#3196, замер прода 29.08.2026) ─────────────────
|
||||
# У DomClick, в отличие от Авито, у промежуточной (нерендеренной) PoW-страницы НЕТ
|
||||
# ни одного стабильного маркера вообще — ни __qrator, ни startpow, ни текста отказа
|
||||
# (замер: 3 запроса подряд на здоровом узле — 1-й вернул 401/6898 байт без единого
|
||||
# опознаваемого признака, 2-й и 3-й — уже 200/готовая карточка). Строить детектор
|
||||
# «это челлендж» тут не на чем: любой угаданный маркер протухнет на следующей смене
|
||||
# вёрстки, а до тех пор будет ловить призрак несуществующего состояния.
|
||||
#
|
||||
# Поэтому логика инвертирована: опознаём положительно два КРАЙНИХ состояния —
|
||||
# «точно готово» и «точно отказ», а всё остальное (загрузчик рукопожатия на 279
|
||||
# байт, нерендеренная PoW-страница на ~6.9-7.1 КБ) считаем «рукопожатие ещё идёт»
|
||||
# и уходим в _wait_out_pow_challenge вместо того, чтобы гадать по размеру —
|
||||
# отказ у DomClick тоже статическая страница фиксированного размера (~26.6 КБ),
|
||||
# см. _DOMCLICK_REFUSAL_MARKERS, поэтому размер как единственный признак ненадёжен.
|
||||
#
|
||||
# Признак успеха — JS-глобаль __SSR_STATE__, литеральная строка, которую кладёт
|
||||
# в разметку SSR-рендер готовой карточки (та же строка, из-за отсутствия которой
|
||||
# парсер бэкфилла поднимал ложный «блок», см. #3196). Сайдкар в остальном
|
||||
# провайдер-агностичен и не обязан знать про внутренний контракт парсера, но
|
||||
# альтернатива — «отсутствие маркеров отказа + размер» — здесь СЛАБЕЕ, а не чище:
|
||||
# и загрузчик (279 байт), и промежуточная PoW-страница (~7 КБ), и будущая
|
||||
# перекроенная вёрстка отказа тоже прошли бы такую проверку как «успех». Прямой
|
||||
# маркер успеха — единственный вариант, который не размывается на промежуточных
|
||||
# состояниях.
|
||||
_DOMCLICK_SUCCESS_MARKER = "__SSR_STATE__"
|
||||
|
||||
# Признак ОТКАЗА площадки (не рукопожатия) — статическая страница «403 | Домклик»
|
||||
# с текстом про подозрительный запрос, ~26.6 КБ, приходит с HTTP 401 (см.
|
||||
# _REFUSAL_STATUSES ниже — статус НЕ используется как сигнал для DomClick, тело
|
||||
# приходит с тем же кодом, что и у здорового рукопожатия и у одной из успешных
|
||||
# страниц в замере). Ждать тут нечего — статическая страница сама себя не
|
||||
# перезагрузит.
|
||||
_DOMCLICK_REFUSAL_MARKERS: tuple[str, ...] = (
|
||||
"похоже, ваш запрос выглядит необычно",
|
||||
"<title>403 | домклик</title>",
|
||||
)
|
||||
|
||||
|
||||
def _is_domclick_success(html: str) -> bool:
|
||||
"""True, если HTML — отрендеренная карточка DomClick (см. _DOMCLICK_SUCCESS_MARKER)."""
|
||||
return _DOMCLICK_SUCCESS_MARKER in html
|
||||
|
||||
|
||||
def _is_domclick_refusal(html: str) -> bool:
|
||||
"""True, если HTML — статический отказ площадки DomClick (см. _DOMCLICK_REFUSAL_MARKERS)."""
|
||||
lower = html.lower()
|
||||
return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS)
|
||||
|
||||
|
||||
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
|
||||
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
|
||||
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
|
||||
|
|
@ -1261,8 +1320,14 @@ async def _content_during_navigation(page: object) -> str | None:
|
|||
raise
|
||||
|
||||
|
||||
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
||||
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
|
||||
async def _wait_out_pow_challenge(
|
||||
page: object,
|
||||
provider: str,
|
||||
url: str,
|
||||
*,
|
||||
is_pending: Callable[[str], bool] = _is_pow_challenge,
|
||||
) -> str:
|
||||
"""Опрашивает page.content() пока `is_pending(html)` не станет False.
|
||||
|
||||
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
|
||||
решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
||||
|
|
@ -1270,18 +1335,24 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
|||
BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача
|
||||
challenge-страницы как будто это валидный контент.
|
||||
|
||||
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
|
||||
провайдеров не меняется) — предикат «страница ещё не устоялась, опроси
|
||||
снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного
|
||||
маркера самого челленджа, есть только «точно успех» / «точно отказ»
|
||||
(#3196), поэтому pending = «ни то, ни другое».
|
||||
|
||||
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
|
||||
каким ждём обычную навигацию (второй таймаут не изобретаем).
|
||||
"""
|
||||
poll_interval_ms = 1000
|
||||
elapsed_ms = 0
|
||||
html: str | None = await _content_during_navigation(page)
|
||||
while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
||||
while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
||||
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||||
elapsed_ms += poll_interval_ms
|
||||
html = await _content_during_navigation(page)
|
||||
|
||||
if html is None or _is_pow_challenge(html):
|
||||
if html is None or is_pending(html):
|
||||
raise ChallengeTimeoutError(
|
||||
f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
|
||||
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
|
||||
|
|
@ -1432,9 +1503,37 @@ async def _fetch_once(
|
|||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
|
||||
)
|
||||
if provider == "domclick":
|
||||
# DomClick — своя ветка (#3196): нет отдельного маркера самого
|
||||
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
|
||||
# статус НЕ смотрим вовсе (в отличие от общей ветки ниже) — 401
|
||||
# приходит и у здорового рукопожатия, и у отказа, и у успешной
|
||||
# страницы в замере 29.08.2026, различать нужно ТОЛЬКО по телу.
|
||||
if _is_domclick_refusal(html):
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: статический отказ площадки "
|
||||
f"url={url!r}"
|
||||
)
|
||||
if not _is_domclick_success(html):
|
||||
html = await _wait_out_pow_challenge(
|
||||
page,
|
||||
provider,
|
||||
url,
|
||||
is_pending=lambda h: not _is_domclick_success(h)
|
||||
and not _is_domclick_refusal(h),
|
||||
)
|
||||
if _is_domclick_refusal(html):
|
||||
# Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса —
|
||||
# is_pending вернул False (страница устоялась), но устоялась
|
||||
# она в состояние «отказ», а не «успех». Наверх идёт то же
|
||||
# исключение, что и для мгновенного отказа выше.
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: отказ площадки после "
|
||||
f"ожидания рукопожатия url={url!r}"
|
||||
)
|
||||
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
|
||||
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
|
||||
if _is_pow_challenge(html):
|
||||
elif _is_pow_challenge(html):
|
||||
status = _last_response_status.get(provider)
|
||||
if status in _REFUSAL_STATUSES:
|
||||
# Маркеры челленджа при 403/429 — это отказ, а не проверка:
|
||||
|
|
@ -1443,7 +1542,9 @@ async def _fetch_once(
|
|||
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
|
||||
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
|
||||
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
|
||||
# BanPageDetectedError как блок.
|
||||
# BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда —
|
||||
# у DomClick (единственного, кто отдаёт 401) отказ распознаётся
|
||||
# выше по телу, а не по коду, см. ветку provider == "domclick".
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа "
|
||||
f"— отказ площадки, ждать нечего url={url!r}"
|
||||
|
|
|
|||
284
tradein-mvp/browser/test_server_domclick_challenge.py
Normal file
284
tradein-mvp/browser/test_server_domclick_challenge.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
"""test_server_domclick_challenge.py — QRATOR-рукопожатие DomClick в /fetch (#3196).
|
||||
|
||||
Проблема, установленная замером прода 29.08.2026: у DomClick, в отличие от
|
||||
Авито, промежуточная (нерендеренная) PoW-страница НЕ содержит ни одного
|
||||
стабильного маркера вообще — ни `__qrator`, ни `startpow`, ни текста отказа.
|
||||
`_CHALLENGE_MARKERS` (сняты живьём с Авито, #3045) на DomClick никогда не
|
||||
матчатся, поэтому ветка `_wait_out_pow_challenge` для него не включалась —
|
||||
недосчитанная страница уезжала наверх как валидный контент, парсер не находил
|
||||
`__SSR_STATE__` и поднимал ложный "блок" (двухнедельный
|
||||
`attempted=3, blocked=3, enriched=0` у `domclick_detail_backfill`).
|
||||
|
||||
Логика для DomClick инвертирована: опознаём положительно ДВА крайних
|
||||
состояния — «точно успех» (`_DOMCLICK_SUCCESS_MARKER`, JS-глобаль
|
||||
`__SSR_STATE__`) и «точно отказ» (`_DOMCLICK_REFUSAL_MARKERS`, статическая
|
||||
страница «403 | Домклик» ~26.6 КБ) — а всё остальное (загрузчик рукопожатия
|
||||
279 байт, нерендеренная PoW-страница ~7 КБ) трактуется как «рукопожатие ещё
|
||||
идёт» и уходит в существующий `_wait_out_pow_challenge` с кастомным
|
||||
`is_pending`.
|
||||
|
||||
Тестируется _fetch_once целиком (зеркалит test_server_pow_challenge.py),
|
||||
camoufox не запускается — фейковые browser/page возвращают заранее заданную
|
||||
последовательность content(). wait_for_timeout на фейковой page — no-op.
|
||||
|
||||
Запуск (из tradein-mvp/browser/)::
|
||||
|
||||
python -m pytest test_server_domclick_challenge.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
server = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(server)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py)."""
|
||||
monkeypatch.setattr(server, "_browsers", {})
|
||||
monkeypatch.setattr(server, "_browser_cms", {})
|
||||
monkeypatch.setattr(server, "_page_counters", {})
|
||||
monkeypatch.setattr(server, "_locks", {})
|
||||
monkeypatch.setattr(server, "_retry_tasks", {})
|
||||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||
|
||||
|
||||
# Приблизительные образцы трёх состояний, снятых живьём 29.08.2026 (см. докстринг
|
||||
# задачи) — точный размер в байтах для теста не важен, важно отсутствие/наличие
|
||||
# опознаваемых строк.
|
||||
_HANDSHAKE_LOADER_HTML = (
|
||||
'<html><head><script src="/__qrator/qauth_utm_v2d_v9118.js"></script></head>'
|
||||
"<body></body></html>"
|
||||
)
|
||||
_RENDERED_POW_HTML = (
|
||||
"<html><body><script>window.location = location.href;</script>"
|
||||
"<div>ожидание...</div></body></html>"
|
||||
)
|
||||
_REFUSAL_HTML = (
|
||||
"<html><head><title>403 | Домклик</title></head>"
|
||||
"<body>Похоже, ваш запрос выглядит необычно. Мы усилили защиту.</body></html>"
|
||||
)
|
||||
_SUCCESS_HTML = '<html><body><script>window.__SSR_STATE__ = {"card": {}};</script></body></html>'
|
||||
|
||||
|
||||
class _DomclickPage:
|
||||
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
|
||||
|
||||
После исчерпания списка повторяет последний элемент (имитирует «страница
|
||||
осталась в этом состоянии»). Зеркалит _ChallengePage из
|
||||
test_server_pow_challenge.py.
|
||||
"""
|
||||
|
||||
def __init__(self, html_sequence: list[str]) -> None:
|
||||
self._html_sequence = html_sequence
|
||||
self._call_count = 0
|
||||
self.goto_urls: list[str] = []
|
||||
self.wait_for_timeout_calls: list[int] = []
|
||||
self.closed = 0
|
||||
|
||||
async def route(self, pattern: str, handler: Any) -> None:
|
||||
return None
|
||||
|
||||
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||
self.goto_urls.append(url)
|
||||
|
||||
async def wait_for_timeout(self, ms: int) -> None:
|
||||
self.wait_for_timeout_calls.append(ms)
|
||||
|
||||
async def content(self) -> str:
|
||||
idx = min(self._call_count, len(self._html_sequence) - 1)
|
||||
item = self._html_sequence[idx]
|
||||
self._call_count += 1
|
||||
return item
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed += 1
|
||||
|
||||
|
||||
class _DomclickBrowser:
|
||||
def __init__(self, page: _DomclickPage) -> None:
|
||||
self._page = page
|
||||
|
||||
async def new_page(self) -> _DomclickPage:
|
||||
return self._page
|
||||
|
||||
|
||||
def _install(
|
||||
monkeypatch: pytest.MonkeyPatch, page: _DomclickPage, provider: str = "domclick"
|
||||
) -> None:
|
||||
server._browsers[provider] = _DomclickBrowser(page)
|
||||
monkeypatch.setattr(
|
||||
server, "_RECYCLE_PAGES_BY_PROVIDER",
|
||||
dict.fromkeys(server.PROVIDERS, 10_000),
|
||||
)
|
||||
|
||||
|
||||
# ── детекторы: чистые функции ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_domclick_success_true_on_ssr_state_marker() -> None:
|
||||
assert server._is_domclick_success(_SUCCESS_HTML) is True
|
||||
|
||||
|
||||
def test_is_domclick_success_false_on_handshake_loader() -> None:
|
||||
assert server._is_domclick_success(_HANDSHAKE_LOADER_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_success_false_on_rendered_pow_page() -> None:
|
||||
"""Ключевой случай задачи: у рендеренной PoW-страницы НЕТ стабильных маркеров
|
||||
вообще — детектор успеха не должен ложно сработать на ней."""
|
||||
assert server._is_domclick_success(_RENDERED_POW_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_success_false_on_refusal_page() -> None:
|
||||
assert server._is_domclick_success(_REFUSAL_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_refusal_true_on_title_and_text_markers() -> None:
|
||||
assert server._is_domclick_refusal(_REFUSAL_HTML) is True
|
||||
|
||||
|
||||
def test_is_domclick_refusal_false_on_handshake_loader() -> None:
|
||||
assert server._is_domclick_refusal(_HANDSHAKE_LOADER_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_refusal_false_on_rendered_pow_page() -> None:
|
||||
assert server._is_domclick_refusal(_RENDERED_POW_HTML) is False
|
||||
|
||||
|
||||
def test_is_domclick_refusal_false_on_success_page() -> None:
|
||||
assert server._is_domclick_refusal(_SUCCESS_HTML) is False
|
||||
|
||||
|
||||
# ── _fetch_once: сценарии DomClick ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fetch_once_refusal_page_raises_immediately_without_waiting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Страница отказа → BanPageDetectedError СРАЗУ, без ухода в опрос (пункт 1)."""
|
||||
page = _DomclickPage([_REFUSAL_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
with pytest.raises(server.BanPageDetectedError):
|
||||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||||
|
||||
assert page.closed == 1
|
||||
# Единственная пауза — обычная гидро-пауза ДО детекта; цикла опроса (доп.
|
||||
# паузы по 1000мс) не было — бюджет ожидания не потрачен.
|
||||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||
|
||||
|
||||
def test_fetch_once_page_without_markers_waits_then_returns_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ни успеха, ни отказа (загрузчик/рендеренная PoW-страница) → уходит в
|
||||
ожидание, а не отдаёт недосчитанную страницу как контент (пункт 2)."""
|
||||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _RENDERED_POW_HTML, _SUCCESS_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
html = asyncio.run(
|
||||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||||
)
|
||||
|
||||
assert html == _SUCCESS_HTML
|
||||
assert page.closed == 1
|
||||
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
|
||||
assert 1000 in page.wait_for_timeout_calls # шаг опроса — бюджет реально тратился
|
||||
|
||||
|
||||
def test_fetch_once_success_page_returned_immediately_without_waiting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Успех с первого раза → отдаётся сразу, БЕЗ ухода в polling-ветку (пункт 3)."""
|
||||
page = _DomclickPage([_SUCCESS_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
html = asyncio.run(
|
||||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||||
)
|
||||
|
||||
assert html == _SUCCESS_HTML
|
||||
assert page.closed == 1
|
||||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||
|
||||
|
||||
def test_fetch_once_settles_into_refusal_during_wait_raises_ban_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса → тоже BanPageDetectedError,
|
||||
а не отдача отказной страницы как валидного контента."""
|
||||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _REFUSAL_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
with pytest.raises(server.BanPageDetectedError):
|
||||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||||
|
||||
assert page.closed == 1
|
||||
|
||||
|
||||
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ни успех, ни отказ так и не наступают → ChallengeTimeoutError по истечении бюджета."""
|
||||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
|
||||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML]) # повторяется бесконечно
|
||||
_install(monkeypatch, page)
|
||||
|
||||
with pytest.raises(server.ChallengeTimeoutError):
|
||||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||||
|
||||
assert page.closed == 1
|
||||
|
||||
|
||||
def test_fetch_once_401_status_is_not_used_as_refusal_signal_for_domclick(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""HTTP 401 — статус здорового рукопожатия DomClick (замер 29.08.2026), НЕ должен
|
||||
трактоваться как отказ: только тело страницы решает."""
|
||||
|
||||
class _Response:
|
||||
status = 401
|
||||
|
||||
class _StatusPage(_DomclickPage):
|
||||
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
|
||||
self.goto_urls.append(url)
|
||||
return _Response()
|
||||
|
||||
page = _StatusPage([_HANDSHAKE_LOADER_HTML, _SUCCESS_HTML])
|
||||
_install(monkeypatch, page)
|
||||
|
||||
html = asyncio.run(
|
||||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||||
)
|
||||
|
||||
assert html == _SUCCESS_HTML
|
||||
|
||||
|
||||
# ── не-домклик провайдеры: поведение не изменилось (пункт 4) ────────────────────
|
||||
|
||||
|
||||
def test_fetch_once_non_domclick_provider_ignores_domclick_markers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""avito с HTML, который матчил бы _is_domclick_success/_is_domclick_refusal,
|
||||
не должен уходить ни в какую специальную ветку — DomClick-логика провайдер-
|
||||
скопирована строго под `provider == "domclick"`."""
|
||||
page = _DomclickPage([_REFUSAL_HTML]) # содержит "похоже, ваш запрос..." — не avito-маркер
|
||||
_install(monkeypatch, page, provider="avito")
|
||||
|
||||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||||
|
||||
assert html == _REFUSAL_HTML # отдан как обычный контент — avito его не распознаёт
|
||||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||
|
|
@ -293,3 +293,62 @@ def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPa
|
|||
body = _json_body(response)
|
||||
assert body["html"] == _REAL_HTML
|
||||
assert body["status"] is None
|
||||
|
||||
|
||||
# ── #3239: 500-ка бан-страницы отличима от 500-ки транспорта ──────────────────
|
||||
|
||||
|
||||
def _fetch_handler_error_body(
|
||||
monkeypatch: pytest.MonkeyPatch, exc: Exception, upstream_status: int | None
|
||||
) -> tuple[int, dict]:
|
||||
monkeypatch.setattr(server, "IS_PROD", False)
|
||||
|
||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||
return True
|
||||
|
||||
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
|
||||
server._last_response_status[provider] = upstream_status
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||||
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"})))
|
||||
return response.status, _json_body(response)
|
||||
|
||||
|
||||
def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Бан-страница несёт ban_page + апстрим-статус.
|
||||
|
||||
Без этого признака клиент видит обычную 500-ку, уводит отказ в транспортную
|
||||
ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел —
|
||||
регрессия, которую #3237 внёс, а #3239 чинит.
|
||||
"""
|
||||
status, body = _fetch_handler_error_body(
|
||||
monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401
|
||||
)
|
||||
assert status == 500 # код НЕ меняем: на него завязана classify_browser_probe
|
||||
assert body["ban_page"] is True
|
||||
assert body["status"] == 401
|
||||
assert "BanPageDetectedError" in body["error"]
|
||||
|
||||
|
||||
def test_fetch_handler_transport_error_has_no_ban_page_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Обычный сбой — признака нет вовсе, клиент трактует его как раньше."""
|
||||
status, body = _fetch_handler_error_body(
|
||||
monkeypatch, RuntimeError("NS_ERROR_PROXY_BAD_GATEWAY"), None
|
||||
)
|
||||
assert status == 500
|
||||
assert "ban_page" not in body
|
||||
|
||||
|
||||
def test_fetch_handler_ban_page_without_status_stays_null(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Апстрим-статуса нет — отдаём null, а не выдуманный код (#2764)."""
|
||||
_status, body = _fetch_handler_error_body(
|
||||
monkeypatch, server.BanPageDetectedError("бан-страница"), None
|
||||
)
|
||||
assert body["ban_page"] is True
|
||||
assert body["status"] is None
|
||||
|
|
|
|||
|
|
@ -73,7 +73,12 @@ class _FakePage:
|
|||
return None
|
||||
|
||||
async def content(self) -> str:
|
||||
return "<html>ok</html>"
|
||||
# Содержит __SSR_STATE__ (#3196): для provider="domclick" это единственный
|
||||
# признак "успешной" страницы (см. server._is_domclick_success) — без него
|
||||
# generic-контент теста уходил бы в ветку ожидания рукопожатия и падал по
|
||||
# ChallengeTimeoutError. avito/cian игнорируют эту строку (их детекторы
|
||||
# ищут другие маркеры), так что для них поведение не меняется.
|
||||
return "<html>ok<script>window.__SSR_STATE__={}</script></html>"
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed += 1
|
||||
|
|
@ -205,7 +210,7 @@ def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None:
|
|||
server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True)
|
||||
)
|
||||
|
||||
assert html == "<html>ok</html>"
|
||||
assert html == "<html>ok<script>window.__SSR_STATE__={}</script></html>"
|
||||
assert browser.new_page_calls == 1
|
||||
assert browser.contexts_created == []
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,51 @@ _PROXY_FAIL_MARKERS: tuple[str, ...] = (
|
|||
_LEASE_ROTATE_AFTER_FAILS: int = 3
|
||||
|
||||
|
||||
class SidecarBanPageError(httpx.HTTPStatusError):
|
||||
"""Сайдкар подтвердил бан-страницу по маркерам её тела (#3239).
|
||||
|
||||
Подкласс ``HTTPStatusError``, а не самостоятельный тип: ловля по
|
||||
``httpx.HTTPError`` у всех прочих поставщиков и retry-политика
|
||||
``fetch()`` продолжают работать не зная о нём. Отличать его нужно ровно
|
||||
там, где решается судьба узла: за подтверждённым маркером стоит
|
||||
``report_ban`` (площадка отказала этому IP), за обычной 500-кой — нет
|
||||
(сбой транспорта, #2600 п.4).
|
||||
|
||||
``upstream_status`` — HTTP-код САМОЙ целевой навигации, а не ответа
|
||||
сайдкара. На error-пути ``fetch()`` обнуляет ``last_response_status``,
|
||||
поэтому иначе диагноз узнать неоткуда: у DomClick отказ приходит с 401
|
||||
и без него классификатор ставит 'unknown' вместо 'platform'.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
request: httpx.Request,
|
||||
response: httpx.Response,
|
||||
upstream_status: int | None,
|
||||
) -> None:
|
||||
super().__init__(message, request=request, response=response)
|
||||
self.upstream_status = upstream_status
|
||||
|
||||
|
||||
def _sidecar_ban_page_status(resp: httpx.Response) -> tuple[bool, int | None]:
|
||||
"""(это бан-страница?, апстрим-статус) из тела ошибки сайдкара (#3239).
|
||||
|
||||
Тело — ``{"error": ..., "ban_page": true, "status": <int|null>}``. Сайдкар
|
||||
старой сборки ключей не отдаёт → (False, None), поведение как до правки.
|
||||
``bool`` отсекаем явно: он подтип ``int`` и JSON ``true`` уехал бы статусом.
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
return False, None
|
||||
if not isinstance(body, dict) or body.get("ban_page") is not True:
|
||||
return False, None
|
||||
raw = body.get("status")
|
||||
return True, raw if isinstance(raw, int) and not isinstance(raw, bool) else None
|
||||
|
||||
|
||||
def _raise_for_sidecar_status(resp: httpx.Response) -> None:
|
||||
"""`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
|
||||
|
||||
|
|
@ -153,6 +198,17 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None:
|
|||
# Тело не прочиталось/не декодируется — причина не обязана быть; отдаём
|
||||
# исходную ошибку, а не роняем вызывающего на разборе тела.
|
||||
raise exc from None
|
||||
is_ban_page, upstream_status = _sidecar_ban_page_status(resp)
|
||||
if is_ban_page:
|
||||
# #3239: тип несёт диагноз наверх — подстрокой в тексте его искать
|
||||
# нельзя, detail обрезан до 300 символов и формулировка отказа
|
||||
# менялась дважды за месяц.
|
||||
raise SidecarBanPageError(
|
||||
f"{exc} | tradein-browser: {detail or 'ban page'}",
|
||||
request=exc.request,
|
||||
response=exc.response,
|
||||
upstream_status=upstream_status,
|
||||
) from exc
|
||||
if not detail:
|
||||
raise
|
||||
raise httpx.HTTPStatusError(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from scraper_kit.domclick_exceptions import (
|
|||
DomClickBlockedError,
|
||||
DomClickParseError,
|
||||
)
|
||||
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||
from scraper_kit.repair_state_normalizer import (
|
||||
infer_repair_state_from_text,
|
||||
|
|
@ -544,6 +545,20 @@ async def fetch_detail(
|
|||
# но НЕ сообщаем пулу здесь: неизвестно, был ли это реальный маркер-бан или
|
||||
# обёртка сетевой ошибки — не смешиваем "сеть" с "бан" (issue #2600 п.4).
|
||||
raise
|
||||
except SidecarBanPageError as exc:
|
||||
# #3239: сайдкар опознал статический отказ площадки по маркерам тела
|
||||
# (#3237 перенёс это распознавание из parse_detail_html в сайдкар — иначе
|
||||
# незавершённое QRATOR-рукопожатие считалось блоком). Это ГЕНУИННЫЙ
|
||||
# ban-сигнал, ровно того же рода, что и ветка parse_detail_html ниже,
|
||||
# поэтому здесь — report_ban, в отличие от транспортной ветки следом.
|
||||
# Статус берём из исключения: на error-пути fetch() обнулил
|
||||
# last_response_status, а без 401 классификатор поставил бы 'unknown'
|
||||
# вместо 'platform' и ротация IP не запустилась бы вовсе.
|
||||
browser_fetcher.report_ban(f"domclick detail: sidecar ban page for {card_url}")
|
||||
raise DomClickBlockedError(
|
||||
f"DomClick detail: sidecar detected platform refusal for {card_url}: {exc}",
|
||||
status=exc.upstream_status,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
# Сетевая/инфраструктурная ошибка самого fetch() (timeout/5xx/transport) —
|
||||
# НЕ репортим mark_banned: это не подтверждённый маркер-бан площадки, а
|
||||
|
|
|
|||
|
|
@ -327,10 +327,12 @@ class DomClickScraper(BaseScraper):
|
|||
комнатности 2+ не собирались никогда. Замер 19.08 по активным
|
||||
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
|
||||
площадок 2+ комнат — около двух третей выдачи.
|
||||
Сдвиг НЕ лечит блок, он распределяет то, что успевает собраться, по
|
||||
всем корзинам вместо одной. Работает при единственном свободном узле —
|
||||
в отличие от ротации lease, которой сейчас упираться некуда: в пуле 3
|
||||
включённых узла, 2 забанены Домкликом.
|
||||
Сдвиг НЕ лечит обрыв, он распределяет то, что успевает собраться,
|
||||
по всем корзинам вместо одной, и работает даже когда свободен один
|
||||
узел. Ротация lease тут ни при чём: свип кладёт не счётчик провалов,
|
||||
а break ниже по первому же DomClickBlockedError (#2854). В пуле
|
||||
четыре включённых узла, у всех provider_affinity='any' —
|
||||
выделенного домклик-прокси нет с миграции 253 (#2800).
|
||||
skip_buckets: корзины, уже собранные предыдущим оборванным прогоном
|
||||
(#3118, чекпоинт done_buckets) — по ним ни одного запроса. Вместе
|
||||
со сдвигом #2854 это превращает случайную ротацию в систематический
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue