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 года
|
# Координаты центра ЕКБ — Площадь 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
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,6 @@ 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) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -564,16 +541,6 @@ 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-планировок в радиусе вокруг участка."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,13 +181,3 @@ 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.")
|
|
||||||
|
|
|
||||||
|
|
@ -1807,55 +1807,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -133,21 +133,6 @@ 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 ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, datetime
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -557,36 +557,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -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]]:
|
def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
|
||||||
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.
|
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ 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
|
||||||
|
|
@ -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]]:
|
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
|
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", "-"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,69 +150,6 @@ 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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) — готовит почву под PR-D3 (роутер) и PR-D4
|
# 6. Платёжный периметр. Заведён в PR-D2 как канарейка «ожидания обновляют
|
||||||
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
|
# вместе с тем PR, который путь открывает». Канарейка отработала: PR-D3
|
||||||
# СТОРОН прямо сейчас:
|
# (#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, до бэкенда не доходит. Это ВСЁ ЕЩЁ
|
||||||
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
|
# канарейка: PR-D4 не смержен, 404 должен держаться;
|
||||||
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
|
# - gendsgn.ru: `checkout` по-прежнему требует X-Authenticated-User → 401
|
||||||
# платёжные пути и требует X-Authenticated-User → 401 анониму.
|
# анониму (он не публичный и не станет им);
|
||||||
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
|
# - gendsgn.ru: `notify` — публичный по построению (вебхук банка), см.
|
||||||
# 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
|
||||||
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
|
# notify — ЕДИНСТВЕННЫЙ платёжный путь, который PR-D3 (#3231) открыл анониму
|
||||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
|
# осознанно: это вебхук банка, он обязан быть достижим без наших заголовков, и
|
||||||
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
|
# потому внесён в `_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
|
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
|
||||||
|
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|
|
||||||
|
|
@ -2405,6 +2405,112 @@ 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 на цену).
|
||||||
|
|
||||||
|
|
@ -4040,7 +4146,39 @@ 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
|
||||||
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(
|
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,
|
||||||
|
|
@ -4051,6 +4189,8 @@ 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) — на
|
||||||
|
|
@ -4097,7 +4237,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=payload.total_floors,
|
total_floors=target_total_floors,
|
||||||
cohort_year_min=cy_min,
|
cohort_year_min=cy_min,
|
||||||
cohort_year_max=cy_max,
|
cohort_year_max=cy_max,
|
||||||
)
|
)
|
||||||
|
|
@ -4122,7 +4262,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=payload.total_floors,
|
total_floors=target_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.
|
||||||
|
|
@ -4142,7 +4282,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=payload.total_floors,
|
total_floors=target_total_floors,
|
||||||
)
|
)
|
||||||
if len(listings_wide) > len(listings):
|
if len(listings_wide) > len(listings):
|
||||||
listings = listings_wide
|
listings = listings_wide
|
||||||
|
|
@ -4166,7 +4306,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=payload.total_floors,
|
total_floors=target_total_floors,
|
||||||
)
|
)
|
||||||
if len(listings_widearea) > len(listings):
|
if len(listings_widearea) > len(listings):
|
||||||
listings = listings_widearea
|
listings = listings_widearea
|
||||||
|
|
@ -4225,7 +4365,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=payload.total_floors,
|
total_floors=target_total_floors,
|
||||||
)
|
)
|
||||||
if len(candidate) > len(listings):
|
if len(candidate) > len(listings):
|
||||||
return candidate, tier
|
return candidate, tier
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,13 @@
|
||||||
-- 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): DomClick has no IP-rotation recovery
|
-- max_consecutive_blocks -- 3 (vs Avito's 5): aborting sooner avoids hammering an
|
||||||
-- on block (one dedicated residential proxy, not a pool) -- aborting
|
-- already-reputation-damaged proxy/session and preserves it for the
|
||||||
-- sooner avoids hammering an already-reputation-damaged proxy/session
|
-- next scheduled window. NB the original rationale here ("one
|
||||||
-- and preserves it for the next scheduled window.
|
-- 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
|
-- 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
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ 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 (
|
||||||
|
|
@ -438,6 +440,61 @@ 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) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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 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 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:
|
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}
|
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)
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ import asyncio
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
@ -961,7 +961,17 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
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" на месте и не изменился — клиент, читающий
|
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
|
||||||
# только его, ничего не заметит. "status" может быть null (goto вернул None).
|
# только его, ничего не заметит. "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)
|
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 у него не образуют отдельной иерархии для
|
||||||
|
|
@ -1261,8 +1320,14 @@ async def _content_during_navigation(page: object) -> str | None:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
async def _wait_out_pow_challenge(
|
||||||
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
|
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`) после
|
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
|
||||||
решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
решения 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, а не тихая отдача
|
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_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]
|
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_pow_challenge(html):
|
if html is None or is_pending(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}"
|
||||||
|
|
@ -1432,9 +1503,37 @@ 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-байтную заглушку как контент.
|
||||||
if _is_pow_challenge(html):
|
elif _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 — это отказ, а не проверка:
|
||||||
|
|
@ -1443,7 +1542,9 @@ async def _fetch_once(
|
||||||
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
|
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
|
||||||
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
|
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
|
||||||
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
|
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
|
||||||
# BanPageDetectedError как блок.
|
# BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда —
|
||||||
|
# у 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}"
|
||||||
|
|
|
||||||
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,12 @@ class _FakePage:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def content(self) -> str:
|
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:
|
async def close(self) -> None:
|
||||||
self.closed += 1
|
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)
|
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.new_page_calls == 1
|
||||||
assert browser.contexts_created == []
|
assert browser.contexts_created == []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,51 @@ _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()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
|
||||||
|
|
||||||
|
|
@ -153,6 +198,17 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ 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,
|
||||||
|
|
@ -544,6 +545,20 @@ 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: это не подтверждённый маркер-бан площадки, а
|
||||||
|
|
|
||||||
|
|
@ -327,10 +327,12 @@ class DomClickScraper(BaseScraper):
|
||||||
комнатности 2+ не собирались никогда. Замер 19.08 по активным
|
комнатности 2+ не собирались никогда. Замер 19.08 по активным
|
||||||
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
|
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
|
||||||
площадок 2+ комнат — около двух третей выдачи.
|
площадок 2+ комнат — около двух третей выдачи.
|
||||||
Сдвиг НЕ лечит блок, он распределяет то, что успевает собраться, по
|
Сдвиг НЕ лечит обрыв, он распределяет то, что успевает собраться,
|
||||||
всем корзинам вместо одной. Работает при единственном свободном узле —
|
по всем корзинам вместо одной, и работает даже когда свободен один
|
||||||
в отличие от ротации lease, которой сейчас упираться некуда: в пуле 3
|
узел. Ротация lease тут ни при чём: свип кладёт не счётчик провалов,
|
||||||
включённых узла, 2 забанены Домкликом.
|
а break ниже по первому же DomClickBlockedError (#2854). В пуле
|
||||||
|
четыре включённых узла, у всех provider_affinity='any' —
|
||||||
|
выделенного домклик-прокси нет с миграции 253 (#2800).
|
||||||
skip_buckets: корзины, уже собранные предыдущим оборванным прогоном
|
skip_buckets: корзины, уже собранные предыдущим оборванным прогоном
|
||||||
(#3118, чекпоинт done_buckets) — по ним ни одного запроса. Вместе
|
(#3118, чекпоинт done_buckets) — по ним ни одного запроса. Вместе
|
||||||
со сдвигом #2854 это превращает случайную ротацию в систематический
|
со сдвигом #2854 это превращает случайную ротацию в систематический
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue