Merge pull request 'fix(tradein/domclick): рекан скрейпера на BFF JSON API + честный статус (#1846, #1968)' (#1979) from fix/tradein-domclick-bff-rewrite into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m23s
Deploy Trade-In / build-backend (push) Successful in 50s
Deploy Trade-In / deploy (push) Successful in 49s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m23s
Deploy Trade-In / build-backend (push) Successful in 50s
Deploy Trade-In / deploy (push) Successful in 49s
This commit is contained in:
commit
684385263e
6 changed files with 1153 additions and 516 deletions
|
|
@ -3087,10 +3087,10 @@ async def run_avito_full_load(
|
|||
class DomClickCitySweepCounters:
|
||||
"""Aggregate counters для DomClick citywide sweep run.
|
||||
|
||||
DomClick SERP — citywide (city_id), не geo-bbox: anchor-loop отсутствует.
|
||||
Один проход fetch_city перебирает rooms × pages внутри scraper'а.
|
||||
Поля симметричны sibling-counters, но без detail_*/houses_* (DomClick SERP-only)
|
||||
и без anchors_* (нет anchor-фазы). pages_fetched = rooms × pages worst-case.
|
||||
DomClick BFF — citywide (city_id), не geo-bbox: anchor-loop отсутствует.
|
||||
Один проход fetch_city перебирает ROOM_BUCKETS × pages внутри scraper'а.
|
||||
Поля симметричны sibling-counters, но без detail_*/houses_* (SERP-only)
|
||||
и без anchors_* (нет anchor-фазы). pages_fetched = 6 buckets × pages worst-case.
|
||||
"""
|
||||
|
||||
lots_fetched: int = 0
|
||||
|
|
@ -3098,17 +3098,21 @@ class DomClickCitySweepCounters:
|
|||
lots_updated: int = 0
|
||||
pages_fetched: int = 0
|
||||
errors_count: int = 0
|
||||
blocked: int = 0 # 1 если QRATOR-блок был во время sweep
|
||||
geo_filtered: int = 0 # число офферов отфильтрованных geo-guard
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||
|
||||
|
||||
# Дефолтные параметры sweep'а (EKB city_id=4, все комнатности).
|
||||
# Дефолтные параметры sweep'а (EKB city_id=4).
|
||||
DOMCLICK_DEFAULT_CITY_ID: int = 4
|
||||
DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4]
|
||||
# Оценка времени одного fetch'а (network + camoufox render + parse) для watchdog.
|
||||
DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets
|
||||
# Число BFF-бакетов (st/1/2/3/4/5+) — фиксировано; используется для watchdog.
|
||||
_DOMCLICK_NUM_BUCKETS: int = 6
|
||||
# Оценка времени одного fetch'а (network + parse) для watchdog.
|
||||
_DOMCLICK_PER_FETCH_S: float = 12.0
|
||||
# Буфер сверху расчётного бюджета (cold browser start, save-фаза).
|
||||
# Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits).
|
||||
_DOMCLICK_SWEEP_BUDGET_S: float = 300.0
|
||||
|
||||
|
||||
|
|
@ -3118,46 +3122,51 @@ async def run_domclick_city_sweep(
|
|||
run_id: int,
|
||||
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 5,
|
||||
pages: int = 100,
|
||||
request_delay_sec: float | None = None,
|
||||
) -> DomClickCitySweepCounters:
|
||||
"""DomClick citywide sweep: SERP по city_id × rooms × pages → save → house-match.
|
||||
"""DomClick citywide sweep через BFF JSON API.
|
||||
|
||||
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
|
||||
DomClick SERP не поддерживает geo-radius (fetch_around → NotImplementedError),
|
||||
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city уже перебирает
|
||||
rooms × pages через внутренний BrowserFetcher (camoufox headless) и
|
||||
дедуплицирует по source_id.
|
||||
DomClick не поддерживает geo-radius (fetch_around → NotImplementedError),
|
||||
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
|
||||
ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy →
|
||||
обходит QRATOR.
|
||||
|
||||
Фазы (единственный citywide проход):
|
||||
1. SERP: DomClickScraper().fetch_city(city_id, rooms, pages).
|
||||
2. SAVE: save_listings(db, lots, run_id=run_id) — BaseScraper save path,
|
||||
триггерит существующий house-match hook (адрес-fingerprint tier, т.к.
|
||||
DomClick SERP не отдаёт lat/lon).
|
||||
2. SAVE: save_listings(db, lots, run_id=run_id) — house-match по lat/lon и
|
||||
address-fingerprint (BFF отдаёт координаты).
|
||||
|
||||
Watchdog: весь fetch_city оборачивается в asyncio.wait_for. Таймаут считается
|
||||
из rooms × pages × per-fetch + buffer (минимум ANCHOR_TIMEOUT_SEC).
|
||||
из 6 buckets × pages × per-fetch + buffer (минимум ANCHOR_TIMEOUT_SEC).
|
||||
Реальный sweep ≈ 330 страниц (rooms=2 требует price-split).
|
||||
|
||||
Cooperative cancel: is_cancelled(db, run_id) проверяется перед SERP-фазой.
|
||||
mark_done вызывается ВСЕГДА (кроме cancel / fatal). lat=lon=None у всех lots →
|
||||
house-match использует только address_fingerprint tier.
|
||||
|
||||
ЧЕСТНЫЙ СТАТУС (#1968): если scraper сообщил QRATOR-блок И lots == 0 →
|
||||
mark_failed с объяснением. Иначе mark_done (в т.ч. при lots==0 без блока:
|
||||
genuinely empty run).
|
||||
|
||||
Возвращает DomClickCitySweepCounters.
|
||||
"""
|
||||
from app.services.scrapers.domclick import DomClickScraper
|
||||
from app.services.scrapers.domclick import ROOM_BUCKETS, DomClickScraper
|
||||
|
||||
_rooms = rooms if rooms is not None else list(DOMCLICK_DEFAULT_ROOMS)
|
||||
_resolved_delay = request_delay_sec if request_delay_sec is not None else 8.0
|
||||
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
|
||||
counters = DomClickCitySweepCounters()
|
||||
|
||||
# Watchdog-таймаут: число fetch'ей = len(rooms) × pages (worst-case, без
|
||||
# early-break на пустой странице). Каждый fetch ≈ delay + render/parse overhead.
|
||||
_num_fetches = max(1, len(_rooms)) * max(1, pages)
|
||||
# Watchdog: 6 buckets × pages × per_fetch + budget.
|
||||
# Реальный worst-case (rooms=2 price-split) ≈ 330 страниц × (delay+12s).
|
||||
# pages=100 → 600 fetch'ей × ~18s + 300 ≈ 11100s (~3ч) — намеренно generous.
|
||||
_num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages)
|
||||
_sweep_timeout = max(
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
|
||||
)
|
||||
|
||||
# Мутируемый контейнер для захвата scraper-ссылки из замыкания.
|
||||
_scraper_ref: list[DomClickScraper] = []
|
||||
|
||||
try:
|
||||
# ── Cooperative cancel перед SERP-фазой ──────────────────────────────
|
||||
if scrape_runs.is_cancelled(db, run_id):
|
||||
|
|
@ -3166,10 +3175,11 @@ async def run_domclick_city_sweep(
|
|||
return counters
|
||||
|
||||
logger.info(
|
||||
"domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d (watchdog %ds)",
|
||||
"domclick-sweep run_id=%d: BFF citywide sweep city_id=%d "
|
||||
"buckets=%d pages_cap=%d (watchdog %ds)",
|
||||
run_id,
|
||||
city_id,
|
||||
_rooms,
|
||||
len(ROOM_BUCKETS),
|
||||
pages,
|
||||
_sweep_timeout,
|
||||
)
|
||||
|
|
@ -3179,10 +3189,11 @@ async def run_domclick_city_sweep(
|
|||
async def _domclick_phase() -> None:
|
||||
"""Единственная citywide-фаза: fetch_city + save."""
|
||||
nonlocal lots
|
||||
async with DomClickScraper() as scraper:
|
||||
async with DomClickScraper() as _scraper:
|
||||
_scraper_ref.append(_scraper)
|
||||
if request_delay_sec is not None:
|
||||
scraper.request_delay_sec = _resolved_delay
|
||||
lots = await scraper.fetch_city(city_id=city_id, rooms=_rooms, pages=pages)
|
||||
_scraper.request_delay_sec = _resolved_delay
|
||||
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
inserted, updated = save_listings(db, lots, run_id=run_id)
|
||||
|
|
@ -3202,18 +3213,53 @@ async def run_domclick_city_sweep(
|
|||
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
|
||||
counters.errors_count += 1
|
||||
|
||||
# pages_fetched: worst-case число запрошенных страниц (rooms × pages).
|
||||
# Перенести счётчики scraper'а в counters (если scraper был создан).
|
||||
if _scraper_ref:
|
||||
_s = _scraper_ref[0]
|
||||
counters.blocked = 1 if _s.blocked else 0
|
||||
counters.geo_filtered = _s.geo_filtered
|
||||
# Не-block fetch-ошибки скрейпера учитываем в errors_count: они должны
|
||||
# влиять на honest-status (0 lots + fetch_errors → НЕ ложный mark_done).
|
||||
counters.errors_count += _s.fetch_errors
|
||||
|
||||
# pages_fetched: worst-case число страниц (buckets × pages cap).
|
||||
counters.pages_fetched = _num_fetches
|
||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||
scrape_runs.mark_done(db, run_id, counters.to_dict())
|
||||
|
||||
# ── ЧЕСТНЫЙ СТАТУС (#1968) ────────────────────────────────────────────
|
||||
# 0 lots + (block ИЛИ любая ошибка) → failed: run без результата по
|
||||
# внешней причине не должен маскироваться как 'done'. Нераспознанный
|
||||
# block-вариант (нет литерал-маркера) приходит как fetch_error, не blocked —
|
||||
# поэтому errors_count тоже триггерит failed. Genuinely empty 0-error run
|
||||
# → done. Partial run с lots>0 остаётся done даже если часть бакетов упала.
|
||||
if counters.lots_fetched == 0 and (counters.blocked or counters.errors_count > 0):
|
||||
logger.error(
|
||||
"domclick-sweep run_id=%d: 0 listings with blocked=%d errors=%d "
|
||||
"— marking failed",
|
||||
run_id,
|
||||
counters.blocked,
|
||||
counters.errors_count,
|
||||
)
|
||||
scrape_runs.mark_failed(
|
||||
db,
|
||||
run_id,
|
||||
"QRATOR block or fetch errors — 0 listings",
|
||||
counters.to_dict(),
|
||||
)
|
||||
else:
|
||||
scrape_runs.mark_done(db, run_id, counters.to_dict())
|
||||
|
||||
logger.info(
|
||||
"domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) pages=%d errors=%d",
|
||||
"domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) "
|
||||
"pages=%d errors=%d blocked=%d geo_filtered=%d",
|
||||
run_id,
|
||||
counters.lots_fetched,
|
||||
counters.lots_inserted,
|
||||
counters.lots_updated,
|
||||
counters.pages_fetched,
|
||||
counters.errors_count,
|
||||
counters.blocked,
|
||||
counters.geo_filtered,
|
||||
)
|
||||
return counters
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,32 @@
|
|||
"""DomClick.ru scraper — вторичка через headless BrowserFetcher (#796).
|
||||
"""DomClick.ru scraper — вторичка через JSON BFF API (#1968).
|
||||
|
||||
Стратегия: HTML scrape через camoufox (AsyncCamoufox) — единственный
|
||||
рабочий путь: DataDome пропускает headless Playwright, curl_cffi -> 401.
|
||||
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
|
||||
BrowserFetcher(source="domclick") (generic provider → shared mobile proxy).
|
||||
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
|
||||
|
||||
URL шаблон:
|
||||
https://domclick.ru/search?deal_type=sale&category=living&offer_type=flat
|
||||
&city_id={city_id}&rooms={r}&p={page}
|
||||
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
|
||||
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
|
||||
|
||||
Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__<id>`.
|
||||
Координаты SERP не отдаёт (lat = lon = None).
|
||||
Стратегия нумерации комнат (ROOM_BUCKETS):
|
||||
"st" — студии (force rooms=0 в ScrapedLot)
|
||||
"1"–"4" — соответственно
|
||||
"5+" — 5 и более комнат
|
||||
|
||||
Важно: selectolax `.text()` объединяет все дочерние текстовые узлы без
|
||||
разделителей. Числа из адреса (номер дома) могут слипнуться с ценой.
|
||||
Поэтому цену извлекаем из каждого дочернего элемента отдельно (а не из
|
||||
суммарного card_text), где элемент содержит символ рубля.
|
||||
Пагинация: offset 0, 20, 40, …, cap=2000. Если snippetsCount > 2000
|
||||
для бакета, рекурсивно делим по цене (binary split) до тех пор пока
|
||||
каждая ветка укладывается в cap.
|
||||
|
||||
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
|
||||
через detail-эндпоинт после первоначального сбора.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from selectolax.parser import HTMLParser, Node
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||
|
|
@ -30,112 +34,179 @@ from app.services.scrapers.domclick_exceptions import DomClickBlockedError
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── DataDome block detection ─────────────────────────────────────────────────
|
||||
_DATADOME_MARKERS = ("datadome", "blocked", "access denied", "bot detected")
|
||||
# ── API constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
_BFF_BASE = "https://bff-search-web.domclick.ru"
|
||||
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
|
||||
_EKB_AREA_ID = "20561"
|
||||
|
||||
def _is_blocked_page(html: str) -> bool:
|
||||
head = html[:2048].lower()
|
||||
return any(m in head for m in _DATADOME_MARKERS)
|
||||
# Buckets to sweep — порядок влияет на логи.
|
||||
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
|
||||
|
||||
OFFSET_CAP: int = 2000 # max offset принятый BFF API
|
||||
PAGE_SIZE: int = 20 # items per page (жёстко задан API)
|
||||
# Потолок цены для binary split. 1 млрд ₽ — безопасно выше любой ЕКБ-квартиры;
|
||||
# выбран так чтобы НЕ отбрасывать листинги дороже потолка при сплите unbounded-бакета
|
||||
# (родительский count не ограничен сверху → clamped _lte должен покрывать весь хвост).
|
||||
LTE_MAX: int = 1_000_000_000
|
||||
MIN_PRICE_SPAN: int = 100_000 # ниже этого span прекращаем делить
|
||||
|
||||
# ── Regex helpers ────────────────────────────────────────────────────────────
|
||||
# _RE_ROOMS расширен относительно avito.py: DomClick пишет «2-комн. квартира»
|
||||
# (с «комн.») — avito.py-вариант ловит только «N-к. кв.» / «N-к квартира».
|
||||
_RE_ROOMS = re.compile(
|
||||
r"(\d)-(?:комн\.?\s*(?:квартира|кв\.?)?|к\.?\s*(?:квартира|кв\.?))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_STUDIO = re.compile(r"\bстуди[яиюей]\b", re.IGNORECASE)
|
||||
_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м[2²]", re.IGNORECASE)
|
||||
_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт\.?", re.IGNORECASE)
|
||||
# ── QRATOR block detection ────────────────────────────────────────────────────
|
||||
|
||||
# Цена: парсим из одного элемента DOM (не из суммарного card_text) —
|
||||
# иначе число дома может слипнуться с ценой. Цена всегда в элементе с «₽».
|
||||
_RE_PRICE_EL = re.compile(r"([\d\s ]+)\s*[₽р](?:уб\.?)?", re.IGNORECASE)
|
||||
|
||||
# source_id из href вида /card/sale__flat__2075671636
|
||||
_RE_SOURCE_ID = re.compile(r"sale__flat__(\d+)")
|
||||
|
||||
# Адресные ключевые слова (эвристика)
|
||||
_RE_ADDR_KW = re.compile(
|
||||
r"ул\.|улица|пер\.|переулок|пр-т|проспект"
|
||||
r"|бул\.|бульвар|шоссе|наб\.|набережная"
|
||||
r"|пл\.|площадь|тракт|д\.\s*\d",
|
||||
re.IGNORECASE,
|
||||
_QRATOR_MARKERS: tuple[str, ...] = (
|
||||
"qrator",
|
||||
"bot_mitigation",
|
||||
"система защиты",
|
||||
"403 | домклик",
|
||||
"captcha",
|
||||
"access denied",
|
||||
)
|
||||
|
||||
# ── EKB geo guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Вспомогательные функции ──────────────────────────────────────────────────
|
||||
_EKB_LAT_MIN: float = 56.6
|
||||
_EKB_LAT_MAX: float = 57.0
|
||||
_EKB_LON_MIN: float = 60.2
|
||||
_EKB_LON_MAX: float = 60.9
|
||||
_EKB_REGION_NAME: str = "Екатеринбург"
|
||||
|
||||
|
||||
def _extract_rooms(text: str) -> int | None:
|
||||
if _RE_STUDIO.search(text):
|
||||
return 0
|
||||
m = _RE_ROOMS.search(text)
|
||||
return int(m.group(1)) if m else None
|
||||
# ── JSON extraction ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_area(text: str) -> float | None:
|
||||
m = _RE_AREA.search(text)
|
||||
return float(m.group(1).replace(",", ".")) if m else None
|
||||
def _extract_json(html: str) -> dict[str, Any]:
|
||||
"""Извлекает JSON-объект из ответа BrowserFetcher.
|
||||
|
||||
BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
|
||||
1. Bare body: ``{"result": ...}`` напрямую в <body>.
|
||||
2. <pre>-wrapped: ``<html><body><pre>{"result": ...}</pre></body></html>``.
|
||||
|
||||
def _extract_floor(text: str) -> tuple[int | None, int | None]:
|
||||
m = _RE_FLOOR.search(text)
|
||||
if m:
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
return None, None
|
||||
Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов.
|
||||
|
||||
|
||||
def _extract_price_from_element(text: str) -> int | None:
|
||||
"""Извлекаем цену из текста ОДНОГО элемента DOM.
|
||||
|
||||
Парсим из одного span/div — не из суммарного card_text. Это исключает
|
||||
случай, когда адресный номер дома стоит вплотную перед ценой в
|
||||
объединённом тексте карточки («Ленина, 503 100 000 руб.»).
|
||||
Raises:
|
||||
DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
|
||||
ValueError: если JSON не найден или не является dict.
|
||||
"""
|
||||
m = _RE_PRICE_EL.search(text)
|
||||
if m:
|
||||
# Нормализуем пробелы любого вида (обычный, неразрывный, узкий)
|
||||
raw = re.sub(r"[\s ]", "", m.group(1))
|
||||
if raw.isdigit():
|
||||
val = int(raw)
|
||||
if val > 0:
|
||||
return val
|
||||
return None
|
||||
# Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
|
||||
# стоять за пределами head в крупных challenge-страницах.
|
||||
html_lower = html.lower()
|
||||
if any(m in html_lower for m in _QRATOR_MARKERS):
|
||||
raise DomClickBlockedError(
|
||||
f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
|
||||
)
|
||||
|
||||
start = html.find("{")
|
||||
end = html.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
raise ValueError(f"No JSON object found in BFF response (len={len(html)})")
|
||||
|
||||
data = json.loads(html[start : end + 1])
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"BFF response JSON is not a dict: {type(data)}")
|
||||
return data # type: ignore[return-value]
|
||||
|
||||
|
||||
def _extract_source_id(href: str) -> str | None:
|
||||
"""Числовой ID из href типа /card/sale__flat__2075671636."""
|
||||
m = _RE_SOURCE_ID.search(href)
|
||||
return m.group(1) if m else None
|
||||
# ── URL builders ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_offers_url(
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
offset: int,
|
||||
) -> str:
|
||||
"""Строит URL для GET /api/offers/v1 с пагинацией.
|
||||
|
||||
urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject).
|
||||
"""
|
||||
params: list[tuple[str, str]] = [
|
||||
("address", _EKB_ADDRESS_GUID),
|
||||
("aids", _EKB_AREA_ID),
|
||||
("deal_type", "sale"),
|
||||
("category", "living"),
|
||||
("offer_type", "flat"),
|
||||
("rooms", rooms),
|
||||
("sort", "qi"),
|
||||
("sort_dir", "desc"),
|
||||
("offset", str(offset)),
|
||||
("limit", str(PAGE_SIZE)),
|
||||
]
|
||||
if price_gte is not None:
|
||||
params.append(("sale_price__gte", str(price_gte)))
|
||||
if price_lte is not None:
|
||||
params.append(("sale_price__lte", str(price_lte)))
|
||||
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
|
||||
|
||||
|
||||
def _build_count_url(
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
) -> str:
|
||||
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
|
||||
params: list[tuple[str, str]] = [
|
||||
("address", _EKB_ADDRESS_GUID),
|
||||
("aids", _EKB_AREA_ID),
|
||||
("deal_type", "sale"),
|
||||
("category", "living"),
|
||||
("offer_type", "flat"),
|
||||
("rooms", rooms),
|
||||
("sort", "qi"),
|
||||
("sort_dir", "desc"),
|
||||
]
|
||||
if price_gte is not None:
|
||||
params.append(("sale_price__gte", str(price_gte)))
|
||||
if price_lte is not None:
|
||||
params.append(("sale_price__lte", str(price_lte)))
|
||||
return f"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}"
|
||||
|
||||
|
||||
def _parse_publish_date(iso: str | None) -> date | None:
|
||||
"""Парсит ISO8601 дату публикации → date. None при ошибке."""
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(iso[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ── DomClickScraper ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DomClickScraper(BaseScraper):
|
||||
"""DomClick вторичка parser. Источник = 'domklik'.
|
||||
"""DomClick вторичка через JSON BFF API. Источник = 'domklik'.
|
||||
|
||||
Использует BrowserFetcher (camoufox headless Firefox) — DataDome пропускает
|
||||
его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок).
|
||||
Использует BrowserFetcher(source="domclick") (generic provider → shared mobile
|
||||
proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP
|
||||
блокируются QRATOR.
|
||||
|
||||
Основной метод: fetch_city(city_id, rooms, pages).
|
||||
fetch_around() не реализован: DomClick не поддерживает geo-radius в URL.
|
||||
|
||||
Counters (публичные после fetch_city):
|
||||
parse_failures — офферы с ошибкой маппинга
|
||||
geo_filtered — офферы вне ЕКБ bbox или с неверным offerRegionName
|
||||
blocked — True если sweep был прерван QRATOR-блоком
|
||||
fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape)
|
||||
"""
|
||||
|
||||
name = "domklik"
|
||||
source = "domklik"
|
||||
base_url = "https://domclick.ru"
|
||||
# DataDome — консервативная задержка между страницами (fallback до init)
|
||||
# Консервативная задержка между страницами (fallback до init)
|
||||
request_delay_sec = 8.0
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.request_delay_sec = get_scraper_delay(self.name)
|
||||
# Счётчик карточек с неожиданной DOM-структурой (для observability)
|
||||
self.parse_failures: int = 0
|
||||
self.geo_filtered: int = 0
|
||||
self.blocked: bool = False
|
||||
# Не-block ошибки извлечения JSON (truncated/garbled response, неверная
|
||||
# структура). В отличие от parse_failures (per-item), это per-fetch ошибки,
|
||||
# которые ограничивают сбор бакета. Учитываются в honest-status pipeline.
|
||||
self.fetch_errors: int = 0
|
||||
|
||||
async def __aenter__(self) -> DomClickScraper:
|
||||
await super().__aenter__()
|
||||
|
|
@ -155,237 +226,361 @@ class DomClickScraper(BaseScraper):
|
|||
self,
|
||||
city_id: int,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 20,
|
||||
pages: int = 100,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Citywide sweep: все страницы SERP для city_id.
|
||||
"""Citywide sweep через BFF JSON API.
|
||||
|
||||
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
|
||||
вызывающим кодом (run_domclick_city_sweep), но:
|
||||
- city_id — vestigial (EКБ захардкожен через GUID).
|
||||
- rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
|
||||
|
||||
Args:
|
||||
city_id: числовой ID города в DomClick (например, 4 = Екатеринбург).
|
||||
rooms: список значений комнатности (0=студия, 1, 2, 3, 4, ...).
|
||||
None -> без фильтра комнатности (все квартиры сразу).
|
||||
pages: максимальное число страниц на один sweep. Break on empty.
|
||||
city_id: игнорируется (EKB захардкожен).
|
||||
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
|
||||
pages: максимальное число страниц на бакет (safety cap).
|
||||
|
||||
Returns:
|
||||
Дедуплицированный по source_id список ScrapedLot.
|
||||
"""
|
||||
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
|
||||
|
||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||
|
||||
all_lots: list[ScrapedLot] = []
|
||||
out_lots: list[ScrapedLot] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
# Формируем список room-значений для sweep'ов
|
||||
room_values: list[int | None] = [None] if not rooms else list(rooms)
|
||||
|
||||
async with BrowserFetcher(source="domclick") as fetcher:
|
||||
for room_val in room_values:
|
||||
room_label = f"rooms={room_val}" if room_val is not None else "all_rooms"
|
||||
for bucket in ROOM_BUCKETS:
|
||||
logger.info(
|
||||
"domklik: city_id=%d %s sweep (max %d pages)", city_id, room_label, pages
|
||||
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
||||
bucket,
|
||||
city_id,
|
||||
pages,
|
||||
)
|
||||
|
||||
for page_num in range(1, pages + 1):
|
||||
url = self._build_url(city_id, room_val, page_num)
|
||||
logger.debug("domklik: fetch %s", url)
|
||||
|
||||
try:
|
||||
html = await fetcher.fetch(url)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"domklik: fetch failed city_id=%d page=%d: %s",
|
||||
city_id,
|
||||
page_num,
|
||||
exc,
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
lots = self._parse_html(html)
|
||||
except DomClickBlockedError:
|
||||
logger.warning(
|
||||
"domklik: DataDome block at city_id=%d page=%d — stopping sweep",
|
||||
city_id,
|
||||
page_num,
|
||||
)
|
||||
break
|
||||
|
||||
if not lots:
|
||||
logger.info(
|
||||
"domklik: empty page city_id=%d %s page=%d — stopping sweep",
|
||||
city_id,
|
||||
room_label,
|
||||
page_num,
|
||||
)
|
||||
break
|
||||
|
||||
# Дедупликация по source_id (кросс-sweep и кросс-страница)
|
||||
new_lots: list[ScrapedLot] = []
|
||||
for lot in lots:
|
||||
key = lot.source_id or lot.source_url
|
||||
if key not in seen_ids:
|
||||
seen_ids.add(key)
|
||||
new_lots.append(lot)
|
||||
|
||||
all_lots.extend(new_lots)
|
||||
logger.info(
|
||||
"domklik: city_id=%d %s page=%d -> %d new (total %d)",
|
||||
city_id,
|
||||
room_label,
|
||||
page_num,
|
||||
len(new_lots),
|
||||
len(all_lots),
|
||||
try:
|
||||
await self._sweep_bucket(
|
||||
fetcher=fetcher,
|
||||
rooms=bucket,
|
||||
price_gte=None,
|
||||
price_lte=None,
|
||||
seen_ids=seen_ids,
|
||||
out_lots=out_lots,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
# Пауза между страницами (anti-DataDome)
|
||||
if page_num < pages:
|
||||
await self.sleep_between_requests()
|
||||
except DomClickBlockedError:
|
||||
self.blocked = True
|
||||
logger.error(
|
||||
"domklik: QRATOR block during rooms=%r — aborting all buckets",
|
||||
bucket,
|
||||
)
|
||||
break
|
||||
except (ValueError, TypeError) as exc:
|
||||
# Defensive: bucket-level ошибка не должна убивать весь sweep.
|
||||
# _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
|
||||
# но если что-то всё же всплыло — переходим к следующему бакету.
|
||||
self.fetch_errors += 1
|
||||
logger.warning(
|
||||
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||
bucket,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"domklik: fetch_city done city_id=%d total=%d parse_failures=%d",
|
||||
"domklik: fetch_city done city_id=%d total=%d "
|
||||
"parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s",
|
||||
city_id,
|
||||
len(all_lots),
|
||||
len(out_lots),
|
||||
self.parse_failures,
|
||||
self.geo_filtered,
|
||||
self.fetch_errors,
|
||||
self.blocked,
|
||||
)
|
||||
return all_lots
|
||||
return out_lots
|
||||
|
||||
# ── URL builder ───────────────────────────────────────────────────────────
|
||||
# ── Internal sweep helpers ────────────────────────────────────────────────
|
||||
|
||||
def _build_url(self, city_id: int, rooms: int | None, page: int) -> str:
|
||||
"""Строит URL SERP DomClick."""
|
||||
url = (
|
||||
f"{self.base_url}/search"
|
||||
f"?deal_type=sale&category=living&offer_type=flat"
|
||||
f"&city_id={city_id}"
|
||||
)
|
||||
if rooms is not None:
|
||||
url += f"&rooms={rooms}"
|
||||
url += f"&p={page}"
|
||||
return url
|
||||
async def _count(
|
||||
self,
|
||||
fetcher: Any,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
) -> int:
|
||||
"""Запрашивает snippetsCount для данного бакета (rooms + price range).
|
||||
|
||||
# ── HTML parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_html(self, html: str) -> list[ScrapedLot]:
|
||||
"""Парсим карточки из HTML через selectolax.
|
||||
|
||||
Селектор: `a[href*="/card/"]` с паттерном `/card/sale__flat__<id>`.
|
||||
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
|
||||
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0
|
||||
(бакет пропускается, sweep продолжается).
|
||||
"""
|
||||
if _is_blocked_page(html):
|
||||
logger.warning("domklik: DataDome block page detected, 0 cards returned")
|
||||
raise DomClickBlockedError("DomClick returned DataDome block page")
|
||||
tree = HTMLParser(html)
|
||||
card_links = tree.css('a[href*="/card/"]')
|
||||
url = _build_count_url(rooms, price_gte, price_lte)
|
||||
logger.debug("domklik: count url=%s", url)
|
||||
html = await fetcher.fetch(url)
|
||||
try:
|
||||
data = _extract_json(html)
|
||||
except DomClickBlockedError:
|
||||
raise
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
self.fetch_errors += 1
|
||||
logger.warning(
|
||||
"domklik: _count JSON extract failed rooms=%r price=[%s,%s] — skip bucket",
|
||||
rooms,
|
||||
price_gte,
|
||||
price_lte,
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
# {"result": null} → None, {"snippetsCount": null} → None: harden обе.
|
||||
res = data.get("result") or {}
|
||||
raw = res.get("snippetsCount")
|
||||
return int(raw) if raw else 0
|
||||
|
||||
seen_hrefs: set[str] = set()
|
||||
lots: list[ScrapedLot] = []
|
||||
async def _sweep_bucket(
|
||||
self,
|
||||
fetcher: Any,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
seen_ids: set[str],
|
||||
out_lots: list[ScrapedLot],
|
||||
pages: int,
|
||||
) -> None:
|
||||
"""Рекурсивный sweep бакета (rooms, price_gte, price_lte).
|
||||
|
||||
for link in card_links:
|
||||
href = link.attributes.get("href", "")
|
||||
# Только карточки вторичного жилья
|
||||
if "sale__flat__" not in href:
|
||||
continue
|
||||
if href in seen_hrefs:
|
||||
continue
|
||||
seen_hrefs.add(href)
|
||||
Если snippetsCount > OFFSET_CAP — делим диапазон цен пополам и
|
||||
рекурсируем в каждую половину. Остановка рекурсии:
|
||||
- span <= MIN_PRICE_SPAN → пагинируем как есть (с предупреждением о truncation)
|
||||
- snippetsCount == 0 → пропускаем
|
||||
- snippetsCount <= OFFSET_CAP → пагинируем
|
||||
|
||||
lot = self._card_link_to_lot(link, href)
|
||||
if lot is not None:
|
||||
lots.append(lot)
|
||||
Raises:
|
||||
DomClickBlockedError: если QRATOR-блок — propagate наверх.
|
||||
"""
|
||||
count = await self._count(fetcher, rooms, price_gte, price_lte)
|
||||
if count == 0:
|
||||
return
|
||||
|
||||
return lots
|
||||
if count > OFFSET_CAP:
|
||||
_gte = price_gte if price_gte is not None else 0
|
||||
_lte = price_lte if price_lte is not None else LTE_MAX
|
||||
span = _lte - _gte
|
||||
if span <= MIN_PRICE_SPAN:
|
||||
logger.warning(
|
||||
"domklik: rooms=%r price=[%s,%s] count=%d > cap=%d "
|
||||
"but span=%d <= min=%d — paginating as-is (bucket truncated at %d)",
|
||||
rooms,
|
||||
price_gte,
|
||||
price_lte,
|
||||
count,
|
||||
OFFSET_CAP,
|
||||
span,
|
||||
MIN_PRICE_SPAN,
|
||||
OFFSET_CAP,
|
||||
)
|
||||
await self._paginate(
|
||||
fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages
|
||||
)
|
||||
return
|
||||
mid = (_gte + _lte) // 2
|
||||
logger.debug(
|
||||
"domklik: rooms=%r count=%d > cap=%d — price-split [%d,%d] → [%d,%d]+[%d,%d]",
|
||||
rooms,
|
||||
count,
|
||||
OFFSET_CAP,
|
||||
_gte,
|
||||
_lte,
|
||||
_gte,
|
||||
mid,
|
||||
mid + 1,
|
||||
_lte,
|
||||
)
|
||||
await self._sweep_bucket(fetcher, rooms, _gte, mid, seen_ids, out_lots, pages)
|
||||
await self._sweep_bucket(fetcher, rooms, mid + 1, _lte, seen_ids, out_lots, pages)
|
||||
else:
|
||||
await self._paginate(fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages)
|
||||
|
||||
def _card_link_to_lot(self, link: Node, href: str) -> ScrapedLot | None:
|
||||
"""Парсинг одной карточки-ссылки -> ScrapedLot.
|
||||
async def _paginate(
|
||||
self,
|
||||
fetcher: Any,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
seen_ids: set[str],
|
||||
out_lots: list[ScrapedLot],
|
||||
pages: int,
|
||||
) -> None:
|
||||
"""Пагинирует один бакет (rooms, price range) до исчерпания или cap.
|
||||
|
||||
DomClick SERP оборачивает карточку в `<a href="/card/...">` — вся
|
||||
информация (title, цена, адрес) внутри этого элемента.
|
||||
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
|
||||
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и break
|
||||
(трактуем как конец бакета — уже собранные lots сохраняются).
|
||||
|
||||
Цену извлекаем из отдельных дочерних элементов (не из объединённого
|
||||
card_text) чтобы исключить слипание числа дома с ценой.
|
||||
Raises:
|
||||
DomClickBlockedError: propagate из _extract_json при QRATOR-блоке.
|
||||
"""
|
||||
force_rooms = 0 if rooms == "st" else None
|
||||
page_idx = 0
|
||||
while page_idx < pages:
|
||||
offset = page_idx * PAGE_SIZE
|
||||
if offset >= OFFSET_CAP:
|
||||
break
|
||||
url = _build_offers_url(rooms, price_gte, price_lte, offset)
|
||||
logger.debug("domklik: offers url=%s", url)
|
||||
html = await fetcher.fetch(url)
|
||||
try:
|
||||
data = _extract_json(html)
|
||||
except DomClickBlockedError:
|
||||
raise
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
self.fetch_errors += 1
|
||||
logger.warning(
|
||||
"domklik: _paginate JSON extract failed rooms=%r offset=%d "
|
||||
"— ending bucket (lots so far preserved)",
|
||||
rooms,
|
||||
offset,
|
||||
exc_info=True,
|
||||
)
|
||||
break
|
||||
|
||||
# {"result": null} → None; harden до .get("items").
|
||||
items: list[dict[str, Any]] = (data.get("result") or {}).get("items") or []
|
||||
if not items:
|
||||
break
|
||||
|
||||
for item in items:
|
||||
lot = self._map_item(item, force_rooms=force_rooms)
|
||||
if lot is None:
|
||||
continue
|
||||
if not self._is_geo_ok(item):
|
||||
self.geo_filtered += 1
|
||||
continue
|
||||
key = lot.source_id or lot.source_url
|
||||
if key in seen_ids:
|
||||
continue
|
||||
seen_ids.add(key)
|
||||
out_lots.append(lot)
|
||||
|
||||
page_idx += 1
|
||||
if page_idx < pages and len(items) == PAGE_SIZE:
|
||||
await self.sleep_between_requests()
|
||||
elif not items or len(items) < PAGE_SIZE:
|
||||
break
|
||||
|
||||
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
|
||||
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
|
||||
|
||||
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
|
||||
"""
|
||||
region = item.get("offerRegionName", "")
|
||||
if region != _EKB_REGION_NAME:
|
||||
return False
|
||||
loc = item.get("location") or {}
|
||||
lat = loc.get("lat")
|
||||
lon = loc.get("lon")
|
||||
if lat is None or lon is None:
|
||||
return False
|
||||
return (
|
||||
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
|
||||
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
|
||||
)
|
||||
|
||||
def _map_item(
|
||||
self, item: dict[str, Any], *, force_rooms: int | None = None
|
||||
) -> ScrapedLot | None:
|
||||
"""Маппинг offer-item из BFF API → ScrapedLot.
|
||||
|
||||
Возвращает None если price <= 0 или при ошибке парсинга.
|
||||
"""
|
||||
try:
|
||||
source_id = _extract_source_id(href)
|
||||
source_url = urljoin(self.base_url, href)
|
||||
|
||||
# Суммарный текст — для rooms/area/floor (эти поля не подвержены
|
||||
# проблеме слипания, т.к. используют специфичные маркеры: «м²», «эт.»)
|
||||
card_text = link.text(strip=True)
|
||||
if not card_text:
|
||||
price = item.get("price", 0) or 0
|
||||
if price <= 0:
|
||||
return None
|
||||
|
||||
rooms = _extract_rooms(card_text)
|
||||
area = _extract_area(card_text)
|
||||
floor, total_floors = _extract_floor(card_text)
|
||||
item_id = item.get("id")
|
||||
source_id = str(item_id) if item_id is not None else None
|
||||
source_url: str = item.get("path", "") or ""
|
||||
|
||||
# Цена: ищем в каждом дочернем элементе отдельно чтобы не слипались
|
||||
# числа дома с ценой при конкатенации card_text
|
||||
price = self._extract_price_from_children(link)
|
||||
if not price or price <= 0:
|
||||
return None
|
||||
loc = item.get("location") or {}
|
||||
lat: float | None = loc.get("lat")
|
||||
lon: float | None = loc.get("lon")
|
||||
|
||||
address = self._extract_address(link)
|
||||
address_obj = item.get("address") or {}
|
||||
address: str | None = address_obj.get("displayName")
|
||||
|
||||
# 0 — sentinel «неизвестно» для area/floor/total_floors/buildYear
|
||||
# (DomClick эмитит 0 для under-construction / unknown) → None.
|
||||
obj_info = item.get("objectInfo") or {}
|
||||
area_raw = obj_info.get("area")
|
||||
area_m2: float | None = float(area_raw) if area_raw else None
|
||||
floor_raw = obj_info.get("floor")
|
||||
floor: int | None = int(floor_raw) if floor_raw else None
|
||||
|
||||
house = item.get("house") or {}
|
||||
floors_raw = house.get("floors")
|
||||
total_floors: int | None = int(floors_raw) if floors_raw else None
|
||||
year_raw = house.get("buildYear")
|
||||
year_built: int | None = int(year_raw) if year_raw else None
|
||||
|
||||
# rooms: 0 — ВАЛИДНОЕ значение (студия), поэтому проверка is not None,
|
||||
# а не truthy. force_rooms=0 (st-бакет) перебивает objectInfo.
|
||||
if force_rooms is not None:
|
||||
rooms: int | None = force_rooms
|
||||
else:
|
||||
rooms_raw = obj_info.get("rooms")
|
||||
rooms = int(rooms_raw) if rooms_raw is not None else None
|
||||
|
||||
square_price_raw = item.get("squarePrice")
|
||||
price_per_m2: int | None = int(square_price_raw) if square_price_raw else None
|
||||
if price_per_m2 is None and area_m2 and area_m2 > 0:
|
||||
price_per_m2 = int(price / area_m2)
|
||||
|
||||
flat_complex = item.get("flatComplex") or {}
|
||||
raw_payload: dict[str, Any] = {
|
||||
"isRosreestrApproved": item.get("isRosreestrApproved"),
|
||||
"squarePrice": square_price_raw,
|
||||
"lastPriceHistoryState": item.get("lastPriceHistoryState"),
|
||||
"flatComplex": {
|
||||
"name": flat_complex.get("name"),
|
||||
"id": flat_complex.get("id"),
|
||||
"slug": flat_complex.get("slug"),
|
||||
}
|
||||
if flat_complex
|
||||
else None,
|
||||
"updatedDate": item.get("updatedDate"),
|
||||
}
|
||||
|
||||
publish_date = _parse_publish_date(item.get("publishedDate"))
|
||||
|
||||
return ScrapedLot(
|
||||
source="domklik",
|
||||
source_url=source_url,
|
||||
source_id=source_id,
|
||||
address=address,
|
||||
lat=None, # DomClick SERP координаты не отдаёт
|
||||
lon=None,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
rooms=rooms,
|
||||
area_m2=area,
|
||||
area_m2=area_m2,
|
||||
floor=floor,
|
||||
total_floors=total_floors,
|
||||
price_rub=price,
|
||||
year_built=year_built,
|
||||
price_rub=int(price),
|
||||
price_per_m2=price_per_m2,
|
||||
listing_segment="vtorichka",
|
||||
raw_payload={"card_text": card_text[:500]},
|
||||
publish_date=publish_date,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
except Exception:
|
||||
self.parse_failures += 1
|
||||
logger.warning(
|
||||
"domclick _card_link_to_lot: parse failed href=%r (parse_failures=%d)",
|
||||
href,
|
||||
"domklik: _map_item failed for item id=%r (parse_failures=%d)",
|
||||
item.get("id"),
|
||||
self.parse_failures,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _extract_price_from_children(self, link: Node) -> int | None:
|
||||
"""Ищем цену в дочерних элементах карточки по символу рубля.
|
||||
|
||||
Итерируем по всем потомкам: первый элемент с рублёвым символом
|
||||
в тексте — источник цены.
|
||||
"""
|
||||
for selector in ("span", "div", "p", "strong", "b"):
|
||||
for el in link.css(selector):
|
||||
text = el.text(strip=True)
|
||||
price = _extract_price_from_element(text)
|
||||
if price:
|
||||
return price
|
||||
# Fallback: суммарный текст карточки (если структура нестандартная)
|
||||
return _extract_price_from_element(link.text(strip=True))
|
||||
|
||||
def _extract_address(self, link: Node) -> str | None:
|
||||
"""Эвристика извлечения адреса из DOM карточки.
|
||||
|
||||
DomClick помещает адрес в отдельный текстовый блок внутри карточки.
|
||||
Обходим вложенные элементы и ищем первый текст с адресными ключевыми
|
||||
словами, пропуская строки с площадью/этажом/ценой.
|
||||
|
||||
Возвращает None если адрес не найден.
|
||||
"""
|
||||
for selector in ("span", "p", "div"):
|
||||
for el in link.css(selector):
|
||||
text = el.text(strip=True)
|
||||
if not text or len(text) < 5 or len(text) > 200:
|
||||
continue
|
||||
# Пропускаем блоки с площадью / этажом / ценой
|
||||
if _RE_AREA.search(text) or _RE_FLOOR.search(text):
|
||||
continue
|
||||
if _RE_PRICE_EL.search(text):
|
||||
continue
|
||||
if _RE_ADDR_KW.search(text):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
# ── Convenience runner (для Celery tasks) ────────────────────────────────────
|
||||
|
||||
|
|
@ -393,14 +588,14 @@ class DomClickScraper(BaseScraper):
|
|||
async def scrape_domclick_city(
|
||||
city_id: int,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 20,
|
||||
pages: int = 100,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Удобная точка входа для вызова из Celery tasks.
|
||||
|
||||
Пример::
|
||||
|
||||
import asyncio
|
||||
lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=[1, 2, 3]))
|
||||
lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=None))
|
||||
"""
|
||||
async with DomClickScraper() as scraper:
|
||||
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,12 @@
|
|||
|
||||
|
||||
class DomClickBlockedError(Exception):
|
||||
"""DomClick вернул DataDome block-страницу (HTTP 200 + block HTML)."""
|
||||
"""DomClick BFF вернул QRATOR block-страницу (HTTP 200 + block HTML).
|
||||
|
||||
QRATOR (qrator.net) — WAF/DDoS-защита domclick.ru. Блокирует datacenter-IP
|
||||
и возвращает HTML с маркерами: "qrator", "bot_mitigation", "система защиты",
|
||||
"403 | домклик", "captcha", "access denied".
|
||||
|
||||
Обходится через shared mobile proxy (BrowserFetcher(source="domclick") →
|
||||
generic provider → мобильный egress).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
-- 138_domclick_bff_rewrite_schedule.sql
|
||||
--
|
||||
-- Обновляет параметры расписания domclick_city_sweep после переписки скрейпера
|
||||
-- на BFF JSON API (https://bff-search-web.domclick.ru/api/offers/v1).
|
||||
--
|
||||
-- DORMANT: enabled остаётся false до тех пор пока оператор не верифицирует
|
||||
-- прод-smoke нового BFF-скрейпера вручную:
|
||||
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'domclick_city_sweep';
|
||||
--
|
||||
-- Ключевые изменения относительно 122_enable_domclick_city_sweep.sql:
|
||||
-- - pages_per_anchor поднят до 100 (каждый бакет до 2000 листингов,
|
||||
-- rooms=2 нуждается в price-split → реально ≈330 страниц)
|
||||
-- - rooms убран из default_params — скрейпер всегда обходит все ROOM_BUCKETS
|
||||
-- (st/1/2/3/4/5+) внутри независимо от этого параметра
|
||||
-- - request_delay_sec оставлен 6 (как в 122; BFF JSON легче HTML-рендера)
|
||||
--
|
||||
-- Зависимости: 052_scrape_schedules.sql (таблица), 122_enable_domclick_city_sweep.sql
|
||||
-- (строка должна существовать — INSERT ON CONFLICT DO NOTHING в 122).
|
||||
-- Idempotent: UPDATE не упадёт если строка уже обновлена (runs повторно безопасно).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET enabled = false,
|
||||
default_params = jsonb_build_object(
|
||||
'city_id', 4,
|
||||
'pages_per_anchor', 100,
|
||||
'request_delay_sec', 6
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE source = 'domclick_city_sweep';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,252 +1,526 @@
|
|||
"""Unit-тесты для DomClick scraper (#796).
|
||||
"""Unit-тесты для DomClick BFF scraper (#1968).
|
||||
|
||||
Тестируем:
|
||||
- Парсинг title-паттерна (rooms/area/floor) из текста карточки
|
||||
- Парсинг цены из отдельного DOM-элемента
|
||||
- Извлечение source_id из href
|
||||
- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML
|
||||
- _extract_json: bare + <pre>-wrapped → dict; QRATOR-маркер → DomClickBlockedError
|
||||
- _build_offers_url / _build_count_url: корректные параметры, "5+" → "5%2B",
|
||||
offers имеет offset/limit, count — нет
|
||||
- _map_item: маппинг realistic offer dict → ScrapedLot; price=0 → None; studio
|
||||
- _is_geo_ok: гео-гард по bbox + offerRegionName
|
||||
- _sweep_bucket: price-split при count > OFFSET_CAP
|
||||
- DomClickScraper: source/name, fetch_around raises NotImplementedError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.domclick import (
|
||||
LTE_MAX,
|
||||
OFFSET_CAP,
|
||||
DomClickScraper,
|
||||
_extract_area,
|
||||
_extract_floor,
|
||||
_extract_price_from_element,
|
||||
_extract_rooms,
|
||||
_extract_source_id,
|
||||
_build_count_url,
|
||||
_build_offers_url,
|
||||
_extract_json,
|
||||
)
|
||||
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
|
||||
|
||||
# ── Rooms / studio ───────────────────────────────────────────────────────────
|
||||
# ── _extract_json ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected",
|
||||
[
|
||||
("2-комн. квартира 53,5 м² 5/15 эт.", 2),
|
||||
("1-к. квартира 32,1 м² 3/9 эт.", 1),
|
||||
("3-комн. кв. 78 м² 10/12 эт.", 3),
|
||||
("4-комн. квартира 95 м² 2/5 эт.", 4),
|
||||
("студия 24 м² 1/10 эт.", 0),
|
||||
("Студия 30,5 м² 8/22 эт.", 0),
|
||||
("Нет комнат — просто текст", None),
|
||||
],
|
||||
)
|
||||
def test_extract_rooms(text: str, expected: int | None) -> None:
|
||||
assert _extract_rooms(text) == expected
|
||||
def test_extract_json_bare_body() -> None:
|
||||
"""Bare JSON body (без HTML-обёртки) корректно парсится."""
|
||||
html = '{"result": {"items": [], "snippetsCount": 42}}'
|
||||
data = _extract_json(html)
|
||||
assert data["result"]["snippetsCount"] == 42
|
||||
|
||||
|
||||
# ── Area ─────────────────────────────────────────────────────────────────────
|
||||
def test_extract_json_pre_wrapped() -> None:
|
||||
"""JSON в <pre>-теге корректно парсится."""
|
||||
html = (
|
||||
"<html><head><title>DomClick</title></head>"
|
||||
'<body><pre>{"result": {"items": [{"id": 1}]}}</pre></body></html>'
|
||||
)
|
||||
data = _extract_json(html)
|
||||
assert data["result"]["items"][0]["id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected",
|
||||
[
|
||||
("2-комн. квартира 53,5 м² 5/15 эт.", 53.5),
|
||||
("студия 24 м² 1/10 эт.", 24.0),
|
||||
("78м2 на этаже", 78.0),
|
||||
("площадь 101.7 м²", 101.7),
|
||||
("Нет площади", None),
|
||||
],
|
||||
)
|
||||
def test_extract_area(text: str, expected: float | None) -> None:
|
||||
assert _extract_area(text) == expected
|
||||
def test_extract_json_qrator_marker_raises() -> None:
|
||||
"""HTML с QRATOR-маркером вызывает DomClickBlockedError."""
|
||||
blocked_html = (
|
||||
"<html><head><title>QRATOR protection</title></head>"
|
||||
"<body>Access denied by bot_mitigation system</body></html>"
|
||||
)
|
||||
with pytest.raises(DomClickBlockedError):
|
||||
_extract_json(blocked_html)
|
||||
|
||||
|
||||
# ── Floor ─────────────────────────────────────────────────────────────────────
|
||||
def test_extract_json_captcha_raises() -> None:
|
||||
"""HTML с captcha-маркером вызывает DomClickBlockedError."""
|
||||
html = "<html><body>Please solve the captcha to continue</body></html>"
|
||||
with pytest.raises(DomClickBlockedError):
|
||||
_extract_json(html)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, floor, total",
|
||||
[
|
||||
("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15),
|
||||
("студия 24 м² 1/10 эт.", 1, 10),
|
||||
("12/24 эт.", 12, 24),
|
||||
("нет этажа", None, None),
|
||||
],
|
||||
)
|
||||
def test_extract_floor(text: str, floor: int | None, total: int | None) -> None:
|
||||
assert _extract_floor(text) == (floor, total)
|
||||
def test_extract_json_no_json_raises() -> None:
|
||||
"""HTML без JSON-объекта вызывает ValueError."""
|
||||
with pytest.raises(ValueError, match="No JSON"):
|
||||
_extract_json("<html><body>plain text</body></html>")
|
||||
|
||||
|
||||
# ── Price (per-element) ───────────────────────────────────────────────────────
|
||||
# Цена парсится из отдельного DOM-элемента (не из суммарного card_text),
|
||||
# поэтому числа адреса не загрязняют парсинг цены.
|
||||
# ── _build_offers_url ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected",
|
||||
[
|
||||
("5 200 000 ₽", 5_200_000),
|
||||
("12 345 678 ₽", 12_345_678),
|
||||
("3 990 000 руб.", 3_990_000),
|
||||
("от 4 500 000 ₽", 4_500_000),
|
||||
("без цены", None),
|
||||
("0 ₽", None),
|
||||
],
|
||||
)
|
||||
def test_extract_price_from_element(text: str, expected: int | None) -> None:
|
||||
assert _extract_price_from_element(text) == expected
|
||||
def test_build_offers_url_basic_params() -> None:
|
||||
url = _build_offers_url("1", None, None, 0)
|
||||
assert "deal_type=sale" in url
|
||||
assert "category=living" in url
|
||||
assert "offer_type=flat" in url
|
||||
assert "rooms=1" in url
|
||||
assert "offset=0" in url
|
||||
assert "limit=20" in url
|
||||
assert "sale_price__gte" not in url
|
||||
assert "sale_price__lte" not in url
|
||||
|
||||
|
||||
# ── source_id from href ───────────────────────────────────────────────────────
|
||||
def test_build_offers_url_5plus_encoded() -> None:
|
||||
"""rooms='5+' должен кодироваться как '5%2B' в URL."""
|
||||
url = _build_offers_url("5+", None, None, 0)
|
||||
assert "5%2B" in url
|
||||
# literal '+' в URL не должно быть для значения rooms
|
||||
assert "rooms=5+" not in url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"href, expected",
|
||||
[
|
||||
("/card/sale__flat__2075671636", "2075671636"),
|
||||
("https://domclick.ru/card/sale__flat__123456789", "123456789"),
|
||||
("/card/sale__flat__1", "1"),
|
||||
("/card/something_else", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_extract_source_id(href: str, expected: str | None) -> None:
|
||||
assert _extract_source_id(href) == expected
|
||||
def test_build_offers_url_with_price_range() -> None:
|
||||
url = _build_offers_url("2", 3_000_000, 6_000_000, 40)
|
||||
assert "sale_price__gte=3000000" in url
|
||||
assert "sale_price__lte=6000000" in url
|
||||
assert "offset=40" in url
|
||||
|
||||
|
||||
# ── _parse_html -> list[ScrapedLot] ──────────────────────────────────────────
|
||||
# Цена в каждой карточке вынесена в отдельный <span> с символом рубля,
|
||||
# чтобы не сливаться с адресным номером дома при конкатенации card_text.
|
||||
|
||||
_MOCK_HTML = """\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<!-- Карточка 1: комнатность, площадь, этаж, адрес, цена в отдельных span -->
|
||||
<a href="/card/sale__flat__2075671636">
|
||||
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
|
||||
<span>ул. Малышева, 1</span>
|
||||
<span>5 200 000 ₽</span>
|
||||
</a>
|
||||
|
||||
<!-- Карточка 2: студия -->
|
||||
<a href="/card/sale__flat__1111111111">
|
||||
<span>Студия 28 м² 3/10 эт.</span>
|
||||
<span>пр-т Ленина, 50</span>
|
||||
<span>3 100 000 ₽</span>
|
||||
</a>
|
||||
|
||||
<!-- Карточка 3: без цены -> должна быть отфильтрована -->
|
||||
<a href="/card/sale__flat__9999999999">
|
||||
<span>1-комн. квартира 32 м² 2/9 эт.</span>
|
||||
</a>
|
||||
|
||||
<!-- Не карточка (без sale__flat__) -> должна быть отфильтрована -->
|
||||
<a href="/card/sale__apartment__123">
|
||||
<span>что-то другое</span>
|
||||
</a>
|
||||
|
||||
<!-- Дубликат первой карточки -> один раз на страницу -->
|
||||
<a href="/card/sale__flat__2075671636">
|
||||
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
|
||||
<span>5 200 000 ₽</span>
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
def test_build_offers_url_ekb_guid_present() -> None:
|
||||
url = _build_offers_url("3", None, None, 0)
|
||||
assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url
|
||||
assert "aids=20561" in url
|
||||
|
||||
|
||||
def test_parse_html_returns_lots() -> None:
|
||||
scraper = DomClickScraper()
|
||||
lots = scraper._parse_html(_MOCK_HTML)
|
||||
|
||||
# Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован
|
||||
assert len(lots) == 2
|
||||
def test_build_offers_url_bff_host() -> None:
|
||||
url = _build_offers_url("1", None, None, 0)
|
||||
assert url.startswith("https://bff-search-web.domclick.ru/api/offers/v1?")
|
||||
|
||||
|
||||
def test_parse_html_card1_fields() -> None:
|
||||
scraper = DomClickScraper()
|
||||
lots = scraper._parse_html(_MOCK_HTML)
|
||||
|
||||
card1 = next(lot for lot in lots if lot.source_id == "2075671636")
|
||||
assert card1.source == "domklik"
|
||||
assert card1.source_url == "https://domclick.ru/card/sale__flat__2075671636"
|
||||
assert card1.rooms == 2
|
||||
assert card1.area_m2 == 53.5
|
||||
assert card1.floor == 5
|
||||
assert card1.total_floors == 15
|
||||
assert card1.price_rub == 5_200_000
|
||||
assert card1.lat is None
|
||||
assert card1.lon is None
|
||||
assert card1.listing_segment == "vtorichka"
|
||||
# ── _build_count_url ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_html_studio() -> None:
|
||||
scraper = DomClickScraper()
|
||||
lots = scraper._parse_html(_MOCK_HTML)
|
||||
|
||||
studio = next(lot for lot in lots if lot.source_id == "1111111111")
|
||||
assert studio.rooms == 0 # студия
|
||||
assert studio.area_m2 == 28.0
|
||||
assert studio.price_rub == 3_100_000
|
||||
def test_build_count_url_no_offset_limit() -> None:
|
||||
"""count url не должен содержать offset и limit."""
|
||||
url = _build_count_url("2", None, None)
|
||||
assert "offset" not in url
|
||||
assert "limit" not in url
|
||||
|
||||
|
||||
def test_parse_html_no_price_filtered() -> None:
|
||||
scraper = DomClickScraper()
|
||||
lots = scraper._parse_html(_MOCK_HTML)
|
||||
|
||||
source_ids = {lot.source_id for lot in lots}
|
||||
assert "9999999999" not in source_ids
|
||||
def test_build_count_url_5plus_encoded() -> None:
|
||||
url = _build_count_url("5+", None, None)
|
||||
assert "5%2B" in url
|
||||
assert "rooms=5+" not in url
|
||||
|
||||
|
||||
def test_parse_html_dedup_same_href() -> None:
|
||||
"""Дубликат href на одной странице должен давать одну карточку."""
|
||||
scraper = DomClickScraper()
|
||||
lots = scraper._parse_html(_MOCK_HTML)
|
||||
def test_build_count_url_with_price() -> None:
|
||||
url = _build_count_url("1", 1_000_000, 4_000_000)
|
||||
assert "sale_price__gte=1000000" in url
|
||||
assert "sale_price__lte=4000000" in url
|
||||
|
||||
ids_2075 = [lot for lot in lots if lot.source_id == "2075671636"]
|
||||
assert len(ids_2075) == 1
|
||||
|
||||
def test_build_count_url_bff_host() -> None:
|
||||
url = _build_count_url("1", None, None)
|
||||
assert url.startswith("https://bff-search-web.domclick.ru/api/offers/count/v1?")
|
||||
|
||||
|
||||
# ── _map_item → ScrapedLot ────────────────────────────────────────────────────
|
||||
|
||||
_REALISTIC_ITEM: dict[str, Any] = {
|
||||
"id": 2075671636,
|
||||
"path": "https://domclick.ru/card/sale__flat__2075671636",
|
||||
"location": {"lat": 56.838, "lon": 60.612},
|
||||
"address": {"displayName": "Екатеринбург, улица Малышева, 1"},
|
||||
"objectInfo": {"area": 53.5, "rooms": 2, "floor": 5, "isApartment": False},
|
||||
"house": {"floors": 15, "buildYear": 2003},
|
||||
"price": 5_200_000,
|
||||
"squarePrice": 97196,
|
||||
"flatComplex": {"id": 12345, "name": "ЖК Тест", "slug": "zhk-test"},
|
||||
"isRosreestrApproved": True,
|
||||
"publishedDate": "2026-06-01T10:00:00+05:00",
|
||||
"updatedDate": "2026-06-15T12:00:00+05:00",
|
||||
"lastPriceHistoryState": {"direction": "DECREASED"},
|
||||
"offerRegionName": "Екатеринбург",
|
||||
}
|
||||
|
||||
|
||||
def test_map_item_basic_fields() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
lot = scraper._map_item(_REALISTIC_ITEM)
|
||||
assert lot is not None
|
||||
assert lot.source == "domklik"
|
||||
assert lot.source_id == "2075671636"
|
||||
assert lot.source_url == "https://domclick.ru/card/sale__flat__2075671636"
|
||||
assert lot.lat == pytest.approx(56.838)
|
||||
assert lot.lon == pytest.approx(60.612)
|
||||
assert lot.address == "Екатеринбург, улица Малышева, 1"
|
||||
assert lot.rooms == 2
|
||||
assert lot.area_m2 == pytest.approx(53.5)
|
||||
assert lot.floor == 5
|
||||
assert lot.total_floors == 15
|
||||
assert lot.year_built == 2003
|
||||
assert lot.price_rub == 5_200_000
|
||||
assert lot.price_per_m2 == 97196
|
||||
assert lot.listing_segment == "vtorichka"
|
||||
|
||||
|
||||
def test_map_item_publish_date() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
from datetime import date
|
||||
|
||||
lot = scraper._map_item(_REALISTIC_ITEM)
|
||||
assert lot is not None
|
||||
assert lot.publish_date == date(2026, 6, 1)
|
||||
|
||||
|
||||
def test_map_item_raw_payload() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
lot = scraper._map_item(_REALISTIC_ITEM)
|
||||
assert lot is not None
|
||||
assert lot.raw_payload is not None
|
||||
assert lot.raw_payload["isRosreestrApproved"] is True
|
||||
assert lot.raw_payload["flatComplex"]["name"] == "ЖК Тест"
|
||||
assert lot.raw_payload["lastPriceHistoryState"]["direction"] == "DECREASED"
|
||||
|
||||
|
||||
def test_map_item_studio_force_rooms_zero() -> None:
|
||||
"""force_rooms=0 (студия-бакет) перебивает objectInfo.rooms."""
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
item = {**_REALISTIC_ITEM, "objectInfo": {"area": 28.0, "rooms": 1, "floor": 3}}
|
||||
lot = scraper._map_item(item, force_rooms=0)
|
||||
assert lot is not None
|
||||
assert lot.rooms == 0
|
||||
|
||||
|
||||
def test_map_item_zero_price_returns_none() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
item = {**_REALISTIC_ITEM, "price": 0}
|
||||
assert scraper._map_item(item) is None
|
||||
|
||||
|
||||
def test_map_item_zero_sentinels_become_none() -> None:
|
||||
"""buildYear/area/floor/total_floors == 0 (sentinel «неизвестно») → None.
|
||||
|
||||
rooms=0 НЕ затрагивается (валидная студия), но здесь force_rooms не задан и
|
||||
objectInfo.rooms=2, так что rooms остаётся 2.
|
||||
"""
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
item = {
|
||||
**_REALISTIC_ITEM,
|
||||
"objectInfo": {"area": 0, "rooms": 2, "floor": 0},
|
||||
"house": {"floors": 0, "buildYear": 0},
|
||||
}
|
||||
lot = scraper._map_item(item)
|
||||
assert lot is not None
|
||||
assert lot.area_m2 is None
|
||||
assert lot.floor is None
|
||||
assert lot.total_floors is None
|
||||
assert lot.year_built is None
|
||||
assert lot.rooms == 2 # rooms 0-sentinel НЕ применяется
|
||||
|
||||
|
||||
def test_map_item_rooms_zero_studio_preserved() -> None:
|
||||
"""force_rooms=0 даёт rooms=0 (студия) — 0 здесь валиден, не sentinel."""
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
item = {**_REALISTIC_ITEM, "objectInfo": {"area": 24.0, "rooms": 0, "floor": 2}}
|
||||
lot = scraper._map_item(item, force_rooms=0)
|
||||
assert lot is not None
|
||||
assert lot.rooms == 0
|
||||
|
||||
|
||||
def test_map_item_price_per_m2_fallback() -> None:
|
||||
"""Если squarePrice отсутствует, price_per_m2 вычисляется из price / area_m2."""
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
item = {**_REALISTIC_ITEM, "squarePrice": None}
|
||||
lot = scraper._map_item(item)
|
||||
assert lot is not None
|
||||
assert lot.price_per_m2 == int(5_200_000 / 53.5)
|
||||
|
||||
|
||||
# ── _is_geo_ok — гео-гард ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_geo_ok_valid_ekb_item() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
assert scraper._is_geo_ok(_REALISTIC_ITEM) is True
|
||||
|
||||
|
||||
def test_is_geo_ok_wrong_region_name() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
item = {**_REALISTIC_ITEM, "offerRegionName": "Москва"}
|
||||
assert scraper._is_geo_ok(item) is False
|
||||
|
||||
|
||||
def test_is_geo_ok_out_of_bbox_lat() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
item = {**_REALISTIC_ITEM, "location": {"lat": 55.5, "lon": 60.6}}
|
||||
assert scraper._is_geo_ok(item) is False
|
||||
|
||||
|
||||
def test_is_geo_ok_out_of_bbox_lon() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
item = {**_REALISTIC_ITEM, "location": {"lat": 56.8, "lon": 61.5}}
|
||||
assert scraper._is_geo_ok(item) is False
|
||||
|
||||
|
||||
def test_is_geo_ok_missing_coords() -> None:
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
item = {**_REALISTIC_ITEM, "location": {"lat": None, "lon": None}}
|
||||
assert scraper._is_geo_ok(item) is False
|
||||
|
||||
|
||||
# ── _sweep_bucket price-split ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_sweep_bucket_price_split_boundaries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""_sweep_bucket: top-level unbounded split даёт contiguous non-overlapping ranges.
|
||||
|
||||
rooms="2" count>cap один раз, потом ≤cap → ровно 2 leaf-бакета, покрывающих
|
||||
[0, LTE_MAX] без перекрытия и без зазора: [(0, mid), (mid+1, LTE_MAX)].
|
||||
"""
|
||||
call_count: dict[str, int] = {"n": 0}
|
||||
paginate_calls: list[tuple[str, int | None, int | None]] = []
|
||||
|
||||
async def fake_count(
|
||||
self: DomClickScraper,
|
||||
fetcher: object,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
) -> int:
|
||||
call_count["n"] += 1
|
||||
# Первый вызов (unbounded) → over cap → price-split; остальные → под cap
|
||||
return OFFSET_CAP + 1 if call_count["n"] == 1 else 1
|
||||
|
||||
async def fake_paginate(
|
||||
self: DomClickScraper,
|
||||
fetcher: object,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
seen_ids: set[str],
|
||||
out_lots: list,
|
||||
pages: int,
|
||||
) -> None:
|
||||
paginate_calls.append((rooms, price_gte, price_lte))
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "_count", fake_count)
|
||||
monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate)
|
||||
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
scraper.geo_filtered = 0
|
||||
scraper.blocked = False
|
||||
scraper.fetch_errors = 0
|
||||
scraper.request_delay_sec = 0.0
|
||||
|
||||
await scraper._sweep_bucket(
|
||||
fetcher=object(),
|
||||
rooms="2",
|
||||
price_gte=None,
|
||||
price_lte=None,
|
||||
seen_ids=set(),
|
||||
out_lots=[],
|
||||
pages=10,
|
||||
)
|
||||
|
||||
# 1 count call (over cap) + 2 recursive count calls (≤ cap) = 3 total
|
||||
assert call_count["n"] == 3
|
||||
# Both sub-buckets paginated, с точными границами split'а unbounded-диапазона.
|
||||
mid = (0 + LTE_MAX) // 2
|
||||
assert paginate_calls == [("2", 0, mid), ("2", mid + 1, LTE_MAX)]
|
||||
# Sanity: contiguous (нет зазора) + non-overlapping + покрывают весь хвост.
|
||||
assert paginate_calls[0][2] + 1 == paginate_calls[1][1] # mid + 1
|
||||
assert paginate_calls[0][1] == 0
|
||||
assert paginate_calls[1][2] == LTE_MAX
|
||||
|
||||
|
||||
async def test_sweep_bucket_zero_count_no_paginate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""_sweep_bucket при count==0 не вызывает _paginate."""
|
||||
paginate_called = False
|
||||
|
||||
async def fake_count(self: DomClickScraper, *_args: object, **_kwargs: object) -> int:
|
||||
return 0
|
||||
|
||||
async def fake_paginate(self: DomClickScraper, *_args: object, **_kwargs: object) -> None:
|
||||
nonlocal paginate_called
|
||||
paginate_called = True
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "_count", fake_count)
|
||||
monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate)
|
||||
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
scraper.geo_filtered = 0
|
||||
scraper.blocked = False
|
||||
scraper.fetch_errors = 0
|
||||
scraper.request_delay_sec = 0.0
|
||||
|
||||
await scraper._sweep_bucket(
|
||||
fetcher=object(),
|
||||
rooms="3",
|
||||
price_gte=None,
|
||||
price_lte=None,
|
||||
seen_ids=set(),
|
||||
out_lots=[],
|
||||
pages=5,
|
||||
)
|
||||
assert paginate_called is False
|
||||
|
||||
|
||||
# ── _paginate resilience — mid-bucket parse error ────────────────────────────
|
||||
|
||||
|
||||
class _FakeFetcher:
|
||||
"""Стаб BrowserFetcher: возвращает заранее заданные ответы по порядку вызовов."""
|
||||
|
||||
def __init__(self, responses: list[str]) -> None:
|
||||
self._responses = responses
|
||||
self._idx = 0
|
||||
|
||||
async def fetch(self, _url: str) -> str:
|
||||
resp = self._responses[min(self._idx, len(self._responses) - 1)]
|
||||
self._idx += 1
|
||||
return resp
|
||||
|
||||
|
||||
async def test_paginate_mid_bucket_parse_error_preserves_lots() -> None:
|
||||
"""Garbled JSON на page 2 → bucket breaks, page-1 lots survive, fetch_errors++.
|
||||
|
||||
Page 1 — валидный JSON с 20 items (PAGE_SIZE) → продолжаем; page 2 — мусор
|
||||
(ValueError из _extract_json) → fetch_errors++ и break, уже собранные lots целы.
|
||||
"""
|
||||
import json
|
||||
|
||||
page1_items = [
|
||||
{
|
||||
"id": 1000 + i,
|
||||
"path": f"https://domclick.ru/card/sale__flat__{1000 + i}",
|
||||
"location": {"lat": 56.84, "lon": 60.6},
|
||||
"address": {"displayName": "Екатеринбург, тест"},
|
||||
"objectInfo": {"area": 40.0, "rooms": 1, "floor": 3},
|
||||
"house": {"floors": 9, "buildYear": 2005},
|
||||
"price": 4_000_000 + i,
|
||||
"squarePrice": 100000,
|
||||
"offerRegionName": "Екатеринбург",
|
||||
}
|
||||
for i in range(20)
|
||||
]
|
||||
page1 = json.dumps({"result": {"items": page1_items}})
|
||||
garbled = "<html><body><pre>{not valid json at all</pre></body></html>"
|
||||
fetcher = _FakeFetcher([page1, garbled])
|
||||
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
scraper.geo_filtered = 0
|
||||
scraper.blocked = False
|
||||
scraper.fetch_errors = 0
|
||||
scraper.request_delay_sec = 0.0
|
||||
|
||||
out_lots: list = []
|
||||
await scraper._paginate(
|
||||
fetcher=fetcher,
|
||||
rooms="1",
|
||||
price_gte=None,
|
||||
price_lte=None,
|
||||
seen_ids=set(),
|
||||
out_lots=out_lots,
|
||||
pages=10,
|
||||
)
|
||||
|
||||
assert len(out_lots) == 20 # page-1 lots сохранены
|
||||
assert scraper.fetch_errors == 1 # page-2 garbled посчитан
|
||||
|
||||
|
||||
async def test_fetch_city_bucket_error_continues_to_next_bucket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ошибка в одном бакете НЕ убивает весь sweep — остальные бакеты отрабатывают."""
|
||||
swept: list[str] = []
|
||||
|
||||
async def fake_sweep_bucket(
|
||||
self: DomClickScraper,
|
||||
fetcher: object,
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
seen_ids: set[str],
|
||||
out_lots: list,
|
||||
pages: int,
|
||||
) -> None:
|
||||
swept.append(rooms)
|
||||
if rooms == "2":
|
||||
raise ValueError("garbled bucket")
|
||||
|
||||
# Заглушка BrowserFetcher через async context manager
|
||||
class _BFStub:
|
||||
async def __aenter__(self) -> _BFStub:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_a: object) -> None:
|
||||
return None
|
||||
|
||||
async def fetch(self, _url: str) -> str:
|
||||
return "{}"
|
||||
|
||||
import app.services.scrapers.browser_fetcher as bf_mod
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", fake_sweep_bucket)
|
||||
monkeypatch.setattr(bf_mod, "BrowserFetcher", lambda **_k: _BFStub())
|
||||
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
scraper.geo_filtered = 0
|
||||
scraper.blocked = False
|
||||
scraper.fetch_errors = 0
|
||||
scraper.request_delay_sec = 0.0
|
||||
|
||||
lots = await scraper.fetch_city(city_id=4, rooms=None, pages=5)
|
||||
|
||||
# Все 6 бакетов посещены несмотря на ошибку в "2"
|
||||
assert swept == ["st", "1", "2", "3", "4", "5+"]
|
||||
assert scraper.fetch_errors == 1
|
||||
assert scraper.blocked is False
|
||||
assert lots == []
|
||||
|
||||
|
||||
# ── DomClickScraper — class attrs ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_scraper_source_name() -> None:
|
||||
scraper = DomClickScraper()
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
scraper.parse_failures = 0
|
||||
scraper.geo_filtered = 0
|
||||
scraper.blocked = False
|
||||
scraper.request_delay_sec = 0.0
|
||||
assert scraper.name == "domklik"
|
||||
assert scraper.source == "domklik"
|
||||
assert scraper.base_url == "https://domclick.ru"
|
||||
# request_delay_sec установлен из get_scraper_delay (DB или fallback 8.0)
|
||||
assert scraper.request_delay_sec >= 0.0
|
||||
|
||||
|
||||
def test_fetch_around_raises() -> None:
|
||||
scraper = DomClickScraper()
|
||||
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||
with pytest.raises(NotImplementedError, match="geo-radius"):
|
||||
asyncio.run(scraper.fetch_around(56.8, 60.6))
|
||||
|
||||
|
||||
def test_build_url_no_rooms() -> None:
|
||||
scraper = DomClickScraper()
|
||||
url = scraper._build_url(city_id=4, rooms=None, page=1)
|
||||
assert "city_id=4" in url
|
||||
assert "rooms" not in url
|
||||
assert "p=1" in url
|
||||
|
||||
|
||||
def test_build_url_with_rooms() -> None:
|
||||
scraper = DomClickScraper()
|
||||
url = scraper._build_url(city_id=4, rooms=2, page=3)
|
||||
assert "rooms=2" in url
|
||||
assert "p=3" in url
|
||||
|
||||
|
||||
def test_parse_failures_counter() -> None:
|
||||
"""parse_failures счётчик начинается с 0."""
|
||||
scraper = DomClickScraper()
|
||||
assert scraper.parse_failures == 0
|
||||
|
||||
|
||||
def test_blocked_page_raises_domclick_blocked_error() -> None:
|
||||
"""HTML с datadome в начале вызывает DomClickBlockedError."""
|
||||
scraper = DomClickScraper()
|
||||
blocked_html = (
|
||||
"<html><head><title>datadome protection</title></head><body>blocked</body></html>"
|
||||
)
|
||||
with pytest.raises(DomClickBlockedError):
|
||||
scraper._parse_html(blocked_html)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ def _stub_domclick_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
def _init(self: DomClickScraper) -> None:
|
||||
self.request_delay_sec = 0.0
|
||||
self.parse_failures = 0
|
||||
self.geo_filtered = 0
|
||||
self.blocked = False
|
||||
self.fetch_errors = 0
|
||||
|
||||
async def _aenter(self: DomClickScraper) -> DomClickScraper:
|
||||
return self
|
||||
|
|
@ -109,6 +112,8 @@ def test_domclick_sweep_counters_defaults() -> None:
|
|||
assert c.lots_updated == 0
|
||||
assert c.pages_fetched == 0
|
||||
assert c.errors_count == 0
|
||||
assert c.blocked == 0
|
||||
assert c.geo_filtered == 0
|
||||
|
||||
|
||||
def test_domclick_sweep_counters_to_dict_all_keys() -> None:
|
||||
|
|
@ -233,8 +238,13 @@ async def test_cancel_before_serp_skips_fetch(monkeypatch: pytest.MonkeyPatch) -
|
|||
assert fake.done is None
|
||||
|
||||
|
||||
async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Исключение в fetch_city → errors_count++, sweep завершается mark_done (не fatal)."""
|
||||
async def test_fetch_city_exception_no_reraise(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Исключение в fetch_city → errors_count++, sweep НЕ ре-райзит (graceful).
|
||||
|
||||
Honest-status (#1968): полный крах SERP-фазы (errors_count=1, 0 lots) → mark_failed,
|
||||
НЕ mark_done. Run без единого листинга по внешней причине — это реальный failure,
|
||||
а не успешный пустой прогон. Re-raise при этом НЕ происходит (task завершается чисто).
|
||||
"""
|
||||
|
||||
async def fake_fetch_fail(
|
||||
self: DomClickScraper,
|
||||
|
|
@ -242,7 +252,7 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) ->
|
|||
rooms: list[int] | None = None,
|
||||
pages: int = 20,
|
||||
) -> list[ScrapedLot]:
|
||||
raise RuntimeError("camoufox crashed")
|
||||
raise RuntimeError("browser crashed")
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_fail)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0))
|
||||
|
|
@ -256,36 +266,107 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) ->
|
|||
|
||||
assert counters.errors_count == 1
|
||||
assert counters.lots_fetched == 0
|
||||
assert fake.done is not None, "graceful: mark_done вызывается несмотря на ошибку SERP"
|
||||
assert fake.failed is None
|
||||
# 0 lots + error → honest mark_failed (не маскируем как done)
|
||||
assert fake.failed is not None
|
||||
assert fake.done is None
|
||||
|
||||
|
||||
# ── _parse_html parser unit test ────────────────────────────────────────────
|
||||
async def test_fetch_errors_and_no_lots_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Scraper fetch_errors>0 + 0 лотов (без block) → mark_failed (FIX 3 / closes #1968).
|
||||
|
||||
|
||||
def test_parse_html_minimal_card() -> None:
|
||||
"""Минимальная DomClick карточка → ScrapedLot с rooms/area/floor/price/source_id."""
|
||||
# __init__ застаблен фикстурой _stub_domclick_lifecycle → нет DB/browser
|
||||
scraper = DomClickScraper()
|
||||
html = """
|
||||
<html><body>
|
||||
<a href="/card/sale__flat__2075671636">
|
||||
<div>2-комн. квартира</div>
|
||||
<span>52,3 м²</span>
|
||||
<span>5 / 16 эт.</span>
|
||||
<span>Екатеринбург, улица Ленина, 10</span>
|
||||
<span>6 500 000 ₽</span>
|
||||
</a>
|
||||
</body></html>
|
||||
Нераспознанный block-вариант приходит как fetch_error (не blocked=True), но
|
||||
honest-status всё равно обязан пометить run failed при пустом результате.
|
||||
"""
|
||||
lots = scraper._parse_html(html)
|
||||
assert len(lots) == 1
|
||||
lot = lots[0]
|
||||
|
||||
async def fake_fetch_errs(
|
||||
self: DomClickScraper,
|
||||
city_id: int,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 100,
|
||||
) -> list[ScrapedLot]:
|
||||
# Имитируем не-block ошибки извлечения JSON: blocked остаётся False
|
||||
self.fetch_errors = 3
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_errs)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0))
|
||||
|
||||
fake = _FakeRuns()
|
||||
_install_runs(monkeypatch, fake)
|
||||
|
||||
counters = await scrape_pipeline.run_domclick_city_sweep(
|
||||
db=MagicMock(), run_id=6, request_delay_sec=0.0
|
||||
)
|
||||
|
||||
assert counters.lots_fetched == 0
|
||||
assert counters.blocked == 0
|
||||
assert counters.errors_count >= 3 # fetch_errors свёрнуты в errors_count
|
||||
assert fake.failed is not None
|
||||
assert fake.done is None
|
||||
|
||||
|
||||
async def test_blocked_and_no_lots_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""QRATOR-блок + 0 лотов → mark_failed (честный статус, closes #1968)."""
|
||||
|
||||
async def fake_fetch_blocked(
|
||||
self: DomClickScraper,
|
||||
city_id: int,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 100,
|
||||
) -> list[ScrapedLot]:
|
||||
# Имитируем QRATOR-блок: установим флаг на scraper и вернём пустой список
|
||||
self.blocked = True
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_blocked)
|
||||
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0))
|
||||
|
||||
fake = _FakeRuns()
|
||||
_install_runs(monkeypatch, fake)
|
||||
|
||||
counters = await scrape_pipeline.run_domclick_city_sweep(
|
||||
db=MagicMock(), run_id=5, request_delay_sec=0.0
|
||||
)
|
||||
|
||||
assert counters.lots_fetched == 0
|
||||
assert counters.blocked == 1
|
||||
# Честный статус: mark_failed должен быть вызван, mark_done — нет
|
||||
assert fake.failed is not None
|
||||
assert "QRATOR" in fake.failed[0]
|
||||
assert fake.done is None
|
||||
|
||||
|
||||
# ── _map_item via sweep — quick integration smoke ────────────────────────────
|
||||
|
||||
|
||||
def test_map_item_basic_mapping() -> None:
|
||||
"""DomClickScraper._map_item маппит BFF offer-item → ScrapedLot корректно."""
|
||||
scraper = DomClickScraper() # __init__ stubbed
|
||||
item = {
|
||||
"id": 111222333,
|
||||
"path": "https://domclick.ru/card/sale__flat__111222333",
|
||||
"location": {"lat": 56.838, "lon": 60.612},
|
||||
"address": {"displayName": "Екатеринбург, улица Ленина, 10"},
|
||||
"objectInfo": {"area": 52.3, "rooms": 2, "floor": 5},
|
||||
"house": {"floors": 16, "buildYear": 2010},
|
||||
"price": 6_500_000,
|
||||
"squarePrice": 124282,
|
||||
"flatComplex": None,
|
||||
"isRosreestrApproved": False,
|
||||
"publishedDate": "2026-06-01T10:00:00+05:00",
|
||||
"updatedDate": None,
|
||||
"lastPriceHistoryState": None,
|
||||
"offerRegionName": "Екатеринбург",
|
||||
}
|
||||
lot = scraper._map_item(item)
|
||||
assert lot is not None
|
||||
assert lot.source == "domklik"
|
||||
assert lot.source_id == "2075671636"
|
||||
assert lot.source_id == "111222333"
|
||||
assert lot.rooms == 2
|
||||
assert lot.area_m2 == pytest.approx(52.3)
|
||||
assert lot.floor == 5
|
||||
assert lot.total_floors == 16
|
||||
assert lot.price_rub == 6_500_000
|
||||
assert lot.lat is None and lot.lon is None
|
||||
assert lot.listing_segment == "vtorichka"
|
||||
assert lot.lat == pytest.approx(56.838)
|
||||
assert lot.lon == pytest.approx(60.612)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue