Compare commits
No commits in common. "98b0632dae62ac874f7d8bca390745fb6c2f042f" and "91d51e60d6135ee45ac98da0835a5f0b2d7b7f30" have entirely different histories.
98b0632dae
...
91d51e60d6
5 changed files with 5 additions and 669 deletions
|
|
@ -157,8 +157,6 @@ class Settings(BaseSettings):
|
||||||
"realty.yandex.ru",
|
"realty.yandex.ru",
|
||||||
"ekaterinburg.n1.ru",
|
"ekaterinburg.n1.ru",
|
||||||
"n1.ru",
|
"n1.ru",
|
||||||
"domclick.ru",
|
|
||||||
"www.domclick.ru",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
|
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ _CACHE_TTL_SEC = 60.0
|
||||||
_DEFAULT_DELAY_BY_SOURCE: dict[str, float] = {
|
_DEFAULT_DELAY_BY_SOURCE: dict[str, float] = {
|
||||||
"avito": 7.0,
|
"avito": 7.0,
|
||||||
"cian": 5.0,
|
"cian": 5.0,
|
||||||
"domklik": 8.0,
|
|
||||||
"n1": 5.0,
|
"n1": 5.0,
|
||||||
"yandex": 5.0,
|
"yandex": 5.0,
|
||||||
"yandex_detail": 5.0,
|
"yandex_detail": 5.0,
|
||||||
|
|
@ -83,7 +82,9 @@ def _get_setting_cached(key: str) -> float:
|
||||||
try:
|
try:
|
||||||
with _open_session() as db:
|
with _open_session() as db:
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text("SELECT request_delay_sec FROM scraper_settings WHERE source = :s"),
|
text(
|
||||||
|
"SELECT request_delay_sec FROM scraper_settings WHERE source = :s"
|
||||||
|
),
|
||||||
{"s": key},
|
{"s": key},
|
||||||
).first()
|
).first()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
|
|
@ -95,8 +96,8 @@ def _get_setting_cached(key: str) -> float:
|
||||||
value = _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY)
|
value = _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("scraper_settings: load failed for %s -- using default: %s", key, e)
|
logger.warning("scraper_settings: load failed for %s -- using default: %s", key, e)
|
||||||
value = (
|
value = 0.0 if key == _GLOBAL_KEY else _DEFAULT_DELAY_BY_SOURCE.get(
|
||||||
0.0 if key == _GLOBAL_KEY else _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY)
|
key, _GLOBAL_DEFAULT_DELAY
|
||||||
)
|
)
|
||||||
|
|
||||||
with _CACHE_LOCK:
|
with _CACHE_LOCK:
|
||||||
|
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
"""DomClick.ru scraper — вторичка через headless BrowserFetcher (#796).
|
|
||||||
|
|
||||||
Стратегия: HTML scrape через camoufox (AsyncCamoufox) — единственный
|
|
||||||
рабочий путь: DataDome пропускает headless Playwright, curl_cffi -> 401.
|
|
||||||
|
|
||||||
URL шаблон:
|
|
||||||
https://domclick.ru/search?deal_type=sale&category=living&offer_type=flat
|
|
||||||
&city_id={city_id}&rooms={r}&p={page}
|
|
||||||
|
|
||||||
Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__<id>`.
|
|
||||||
Координаты SERP не отдаёт (lat = lon = None).
|
|
||||||
|
|
||||||
Важно: selectolax `.text()` объединяет все дочерние текстовые узлы без
|
|
||||||
разделителей. Числа из адреса (номер дома) могут слипнуться с ценой.
|
|
||||||
Поэтому цену извлекаем из каждого дочернего элемента отдельно (а не из
|
|
||||||
суммарного card_text), где элемент содержит символ рубля.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from selectolax.parser import HTMLParser, Node
|
|
||||||
|
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
|
||||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
|
||||||
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── DataDome block detection ─────────────────────────────────────────────────
|
|
||||||
_DATADOME_MARKERS = ("datadome", "blocked", "access denied", "bot detected")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_blocked_page(html: str) -> bool:
|
|
||||||
head = html[:2048].lower()
|
|
||||||
return any(m in head for m in _DATADOME_MARKERS)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 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)
|
|
||||||
|
|
||||||
# Цена: парсим из одного элемента 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Вспомогательные функции ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_area(text: str) -> float | None:
|
|
||||||
m = _RE_AREA.search(text)
|
|
||||||
return float(m.group(1).replace(",", ".")) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
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 руб.»).
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ── DomClickScraper ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class DomClickScraper(BaseScraper):
|
|
||||||
"""DomClick вторичка parser. Источник = 'domklik'.
|
|
||||||
|
|
||||||
Использует BrowserFetcher (camoufox headless Firefox) — DataDome пропускает
|
|
||||||
его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок).
|
|
||||||
|
|
||||||
Основной метод: fetch_city(city_id, rooms, pages).
|
|
||||||
fetch_around() не реализован: DomClick не поддерживает geo-radius в URL.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "domklik"
|
|
||||||
source = "domklik"
|
|
||||||
base_url = "https://domclick.ru"
|
|
||||||
# DataDome — консервативная задержка между страницами (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
|
|
||||||
|
|
||||||
async def __aenter__(self) -> DomClickScraper:
|
|
||||||
await super().__aenter__()
|
|
||||||
return self
|
|
||||||
|
|
||||||
# ── fetch_around — stub ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list[ScrapedLot]:
|
|
||||||
"""DomClick не поддерживает geo-radius. Используй fetch_city()."""
|
|
||||||
raise NotImplementedError(
|
|
||||||
"DomClick не поддерживает geo-radius; используй fetch_city(city_id=...)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Основной метод ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def fetch_city(
|
|
||||||
self,
|
|
||||||
city_id: int,
|
|
||||||
rooms: list[int] | None = None,
|
|
||||||
pages: int = 20,
|
|
||||||
) -> list[ScrapedLot]:
|
|
||||||
"""Citywide sweep: все страницы SERP для city_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
city_id: числовой ID города в DomClick (например, 4 = Екатеринбург).
|
|
||||||
rooms: список значений комнатности (0=студия, 1, 2, 3, 4, ...).
|
|
||||||
None -> без фильтра комнатности (все квартиры сразу).
|
|
||||||
pages: максимальное число страниц на один sweep. Break on empty.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Дедуплицированный по source_id список ScrapedLot.
|
|
||||||
"""
|
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
|
||||||
|
|
||||||
all_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() as fetcher:
|
|
||||||
for room_val in room_values:
|
|
||||||
room_label = f"rooms={room_val}" if room_val is not None else "all_rooms"
|
|
||||||
logger.info(
|
|
||||||
"domklik: city_id=%d %s sweep (max %d pages)", city_id, room_label, 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),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Пауза между страницами (anti-DataDome)
|
|
||||||
if page_num < pages:
|
|
||||||
await self.sleep_between_requests()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"domklik: fetch_city done city_id=%d total=%d parse_failures=%d",
|
|
||||||
city_id,
|
|
||||||
len(all_lots),
|
|
||||||
self.parse_failures,
|
|
||||||
)
|
|
||||||
return all_lots
|
|
||||||
|
|
||||||
# ── URL builder ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# ── HTML parsing ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _parse_html(self, html: str) -> list[ScrapedLot]:
|
|
||||||
"""Парсим карточки из HTML через selectolax.
|
|
||||||
|
|
||||||
Селектор: `a[href*="/card/"]` с паттерном `/card/sale__flat__<id>`.
|
|
||||||
"""
|
|
||||||
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/"]')
|
|
||||||
|
|
||||||
seen_hrefs: set[str] = set()
|
|
||||||
lots: list[ScrapedLot] = []
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
lot = self._card_link_to_lot(link, href)
|
|
||||||
if lot is not None:
|
|
||||||
lots.append(lot)
|
|
||||||
|
|
||||||
return lots
|
|
||||||
|
|
||||||
def _card_link_to_lot(self, link: Node, href: str) -> ScrapedLot | None:
|
|
||||||
"""Парсинг одной карточки-ссылки -> ScrapedLot.
|
|
||||||
|
|
||||||
DomClick SERP оборачивает карточку в `<a href="/card/...">` — вся
|
|
||||||
информация (title, цена, адрес) внутри этого элемента.
|
|
||||||
|
|
||||||
Цену извлекаем из отдельных дочерних элементов (не из объединённого
|
|
||||||
card_text) чтобы исключить слипание числа дома с ценой.
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
return None
|
|
||||||
|
|
||||||
rooms = _extract_rooms(card_text)
|
|
||||||
area = _extract_area(card_text)
|
|
||||||
floor, total_floors = _extract_floor(card_text)
|
|
||||||
|
|
||||||
# Цена: ищем в каждом дочернем элементе отдельно чтобы не слипались
|
|
||||||
# числа дома с ценой при конкатенации card_text
|
|
||||||
price = self._extract_price_from_children(link)
|
|
||||||
if not price or price <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
address = self._extract_address(link)
|
|
||||||
|
|
||||||
return ScrapedLot(
|
|
||||||
source="domklik",
|
|
||||||
source_url=source_url,
|
|
||||||
source_id=source_id,
|
|
||||||
address=address,
|
|
||||||
lat=None, # DomClick SERP координаты не отдаёт
|
|
||||||
lon=None,
|
|
||||||
rooms=rooms,
|
|
||||||
area_m2=area,
|
|
||||||
floor=floor,
|
|
||||||
total_floors=total_floors,
|
|
||||||
price_rub=price,
|
|
||||||
listing_segment="vtorichka",
|
|
||||||
raw_payload={"card_text": card_text[:500]},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.parse_failures += 1
|
|
||||||
logger.warning(
|
|
||||||
"domclick _card_link_to_lot: parse failed href=%r (parse_failures=%d)",
|
|
||||||
href,
|
|
||||||
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) ────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def scrape_domclick_city(
|
|
||||||
city_id: int,
|
|
||||||
rooms: list[int] | None = None,
|
|
||||||
pages: int = 20,
|
|
||||||
) -> list[ScrapedLot]:
|
|
||||||
"""Удобная точка входа для вызова из Celery tasks.
|
|
||||||
|
|
||||||
Пример::
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=[1, 2, 3]))
|
|
||||||
"""
|
|
||||||
async with DomClickScraper() as scraper:
|
|
||||||
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""DomClick-specific exceptions для anti-bot detection."""
|
|
||||||
|
|
||||||
|
|
||||||
class DomClickBlockedError(Exception):
|
|
||||||
"""DomClick вернул DataDome block-страницу (HTTP 200 + block HTML)."""
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
"""Unit-тесты для DomClick scraper (#796).
|
|
||||||
|
|
||||||
Тестируем:
|
|
||||||
- Парсинг title-паттерна (rooms/area/floor) из текста карточки
|
|
||||||
- Парсинг цены из отдельного DOM-элемента
|
|
||||||
- Извлечение source_id из href
|
|
||||||
- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.scrapers.domclick import (
|
|
||||||
DomClickScraper,
|
|
||||||
_extract_area,
|
|
||||||
_extract_floor,
|
|
||||||
_extract_price_from_element,
|
|
||||||
_extract_rooms,
|
|
||||||
_extract_source_id,
|
|
||||||
)
|
|
||||||
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
|
|
||||||
|
|
||||||
# ── Rooms / studio ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
# ── Area ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
# ── Floor ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Price (per-element) ───────────────────────────────────────────────────────
|
|
||||||
# Цена парсится из отдельного DOM-элемента (не из суммарного card_text),
|
|
||||||
# поэтому числа адреса не загрязняют парсинг цены.
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
# ── source_id from href ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
# ── _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_parse_html_returns_lots() -> None:
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
lots = scraper._parse_html(_MOCK_HTML)
|
|
||||||
|
|
||||||
# Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован
|
|
||||||
assert len(lots) == 2
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
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_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_parse_html_dedup_same_href() -> None:
|
|
||||||
"""Дубликат href на одной странице должен давать одну карточку."""
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
lots = scraper._parse_html(_MOCK_HTML)
|
|
||||||
|
|
||||||
ids_2075 = [lot for lot in lots if lot.source_id == "2075671636"]
|
|
||||||
assert len(ids_2075) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_scraper_source_name() -> None:
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
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()
|
|
||||||
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)
|
|
||||||
Loading…
Add table
Reference in a new issue