diff --git a/tradein-mvp/backend/app/services/cian_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index 9069cdb8..93d155e5 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -15,9 +15,10 @@ import logging import time from dataclasses import dataclass, field -# #2306: fetch_detail/save_detail_enrichment migrated to scraper_kit (golden-parity -# with legacy app.services.scrapers.cian_detail, см. tests/test_scraper_kit_cian_golden_parity.py -# + tests/test_scraper_kit_pricehistory_session_parity.py). RealScraperConfig — тот же +# #2306: fetch_detail/save_detail_enrichment migrated to scraper_kit (byte-identical +# golden-parity была доказана против legacy app.services.scrapers.cian_detail до его +# удаления, #2397 Part E2 — см. tests/test_scraper_kit_pricehistory_session_parity.py +# для оставшейся extract_state/ScrapedLot parity). RealScraperConfig — тот же # read-only адаптер над settings, что и остальные kit-инжекции (#2131) — сохраняет # proxy-поведение (config.cian_proxy_url) идентичным прежнему прямому импорту settings. from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment diff --git a/tradein-mvp/backend/app/services/scrapers/avito_houses.py b/tradein-mvp/backend/app/services/scrapers/avito_houses.py deleted file mode 100644 index 60c238f9..00000000 --- a/tradein-mvp/backend/app/services/scrapers/avito_houses.py +++ /dev/null @@ -1,1392 +0,0 @@ -"""avito_houses.py — Avito Houses Catalog parser (Stage 2c). - -Парсит страницу /catalog/houses//, извлекает window.__preloadedState__ -и разбирает 10 виджетов: housePage, reviews, miniSerp, housePlacementHistory, -recommendations. - -Avito (≈2026, MFE) перенёс полезную нагрузку виджетов на уровень глубже — -под ключ с именем самого виджета (props['reviews']['entries'], -props['miniSerp']['items'], props['housePlacementHistory']['items'] и т.д.). -Разворачивание делает _widget_payload с fallback на старую форму (props напрямую). - -Flow: - fetch_house_catalog(house_url) - → HTTP GET (curl_cffi chrome120) - → regex extract __preloadedState__ - → JS-unescape + json.loads - → parse_houses_state(state, house_url) - → HouseCatalogEnrichment - -Таблицы: - houses (009_houses.sql + 010_houses_alter.sql) - house_reviews (014_house_reviews.sql) - sellers (012_sellers.sql) - listings.seller_id_fk (013_listings_alter_seller.sql) - house_placement_history (017_house_placement_history.sql) -""" - -from __future__ import annotations - -import json -import logging -import re -import urllib.parse -from dataclasses import dataclass, field -from datetime import date -from typing import TYPE_CHECKING, Any - -from sqlalchemy import text -from sqlalchemy.orm import Session - -from app.services.matching.houses import match_or_create_house -from app.services.scrapers.avito import _is_firewall_page -from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError -from app.services.scrapers.avito_shared import RUS_MONTHS, _unix_to_date - -if TYPE_CHECKING: - from app.services.scrapers.browser_fetcher import BrowserFetcher - -logger = logging.getLogger(__name__) - -AVITO_BASE = "https://www.avito.ru" - -# Regex для извлечения __preloadedState__ из HTML. -# -# Avito (≈2026) сменил формат state-блоба: раньше был -# window.__preloadedState__ = JSON.parse(""); -# теперь — URL-encoded строковый литерал: -# window.__preloadedState__ = "%7B%22abCentral%22%3A..."; (%7B='{', %22='"') -# -# PRELOADED_URLENC_RE — текущая форма (декод через urllib.parse.unquote). -# PRELOADED_JSPARSE_RE — старая форма (оставлена как fallback). -PRELOADED_URLENC_RE = re.compile( - r'window\.__preloadedState__\s*=\s*"((?:[^"\\]|\\.)*)"\s*;', - re.DOTALL, -) -PRELOADED_JSPARSE_RE = re.compile( - r'window\.__preloadedState__\s*=\s*JSON\.parse\("(.*?)"\)\s*;', - re.DOTALL, -) - -# Маппинг expandParams type → (field_name, cast_type) -EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = { - "Год постройки": ("year_built", "int"), - "Этажей": ("total_floors", "int"), - "Тип дома": ("house_type", "house_type"), - "Горячее водоснабжение": ("hot_water", "str"), - "Пассажирский лифт": ("passenger_elevators", "int"), - "Грузовой лифт": ("cargo_elevators", "int"), - "Консьерж": ("has_concierge", "bool_da"), - "Перекрытия": ("material_floors", "str"), - "Парковка": ("parking_type", "str"), - "Детская площадка": ("has_playground", "bool_da"), - "Закрытый двор": ("closed_yard", "bool_da"), - # Новостройки (developmentPage) — другие подписи параметров. Лифты во - # множественном числе; "Этажность" — диапазон ("от 4 до 24"), int-каст - # вернёт None (хранится только в raw_characteristics). - "Пассажирских лифтов": ("passenger_elevators", "int"), - "Грузовых лифтов": ("cargo_elevators", "int"), - # Класс жилья (#2003) — ТОЛЬКО developmentPage (новостройки); housePage - # (вторичка) этот параметр не отдаёт. Сырое значение "Комфорт"/"Бизнес"/... → - # каноничный enum houses.house_class через _normalize_house_class. - "Класс жилья": ("house_class", "house_class"), -} - -# URL-маркер новостройки: /catalog/novostroyki/... (vs вторичка /catalog/houses/...). -NOVOSTROYKA_URL_MARKER = "/catalog/novostroyki/" - -# Нормализация типа дома из Avito текста в DB enum -HOUSE_TYPE_MAP: dict[str, str] = { - "монолитный": "monolith", - "монолит": "monolith", - "панельный": "panel", - "панель": "panel", - "кирпичный": "brick", - "кирпич": "brick", - "монолитно-кирпичный": "monolith_brick", - "блочный": "block", - "деревянный": "wood", -} - -# Нормализация класса жилья из Avito "Класс жилья" в canonical houses.house_class -# (#2003). Avito сам отдаёт только 4 значения (Комфорт/Бизнес/Премиум/Эконом — -# live-подтверждено), но карта дополнена "Комфорт+"/"Элит" для согласованности с -# вокабуляром yandex_helpers.parse_house_class (тот же столбец пишут оба источника). -HOUSE_CLASS_MAP: dict[str, str] = { - "эконом": "economy", - "комфорт": "comfort", - "комфорт+": "comfort_plus", - "комфорт плюс": "comfort_plus", - "бизнес": "business", - "премиум": "premium", - "элит": "elite", - "элитный": "elite", -} - - -# --------------------------------------------------------------------------- -# Dataclasses -# --------------------------------------------------------------------------- - - -@dataclass -class HouseInfo: - ext_id: int # avitoId (int) - ext_id_hash: str | None = None # base64 internal ID - title: str | None = None - short_address: str | None = None # краткий адрес - full_address: str | None = None # полный адрес - lat: float | None = None - lon: float | None = None - year_built: int | None = None - total_floors: int | None = None - house_type: str | None = None # нормализован: monolith/panel/brick/... - house_class: str | None = None # нормализован: comfort/business/premium/economy/... (#2003) - material_floors: str | None = None - hot_water: str | None = None - passenger_elevators: int | None = None - cargo_elevators: int | None = None - has_concierge: bool | None = None - parking_type: str | None = None - has_playground: bool | None = None - closed_yard: bool | None = None - developer_name: str | None = None - developer_key: str | None = None - infrastructure_summary: str | None = None - infrastructure_walk_distance: str | None = None - rating_score: float | None = None - rating_string: str | None = None - reviews_count: int | None = None - rating_distribution: list[dict[str, Any]] = field(default_factory=list) - map_pins: list[dict[str, Any]] = field(default_factory=list) - raw_characteristics: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass -class HouseReview: - ext_review_id: int - author_name: str | None = None - review_title: str | None = None - score: int | None = None - model_experience: str | None = None - rated_date: date | None = None - text_main: str | None = None - text_pros: str | None = None - text_cons: str | None = None - raw_payload: dict[str, Any] | None = None - - -@dataclass -class PlacementHistoryItem: - ext_item_id: str - title: str | None = None - start_price: int | None = None - start_price_date: date | None = None - last_price: int | None = None - last_price_date: date | None = None - exposure_days: int | None = None - removed_date: date | None = None # ВСЕГДА None для source='avito_widget' - raw_payload: dict[str, Any] | None = None - - -@dataclass -class SellerInfo: - ext_seller_id: str - name: str - seller_type: str | None = None - on_platform_since: str | None = None - profile_url: str | None = None - - -@dataclass -class MiniSerpListing: - ext_item_id: int - title: str | None = None - price_rub: int | None = None - description: str | None = None - url: str = "" - metro_text: str | None = None - metro_color: str | None = None - seller: SellerInfo | None = None - - -@dataclass -class RecommendationItem: - title: str | None = None - price_text: str | None = None - address: str | None = None - url: str = "" - image_url: str | None = None - - -@dataclass -class HouseCatalogEnrichment: - house_url: str - house: HouseInfo - reviews: list[HouseReview] = field(default_factory=list) - placement_history: list[PlacementHistoryItem] = field(default_factory=list) - mini_serp: list[MiniSerpListing] = field(default_factory=list) - recommendations: list[RecommendationItem] = field(default_factory=list) - reviews_next_page_url: str | None = None - - -# --------------------------------------------------------------------------- -# Helper functions -# --------------------------------------------------------------------------- - - -def _parse_ru_date(text_val: str | None) -> date | None: - """Парсит дату вида "9 октября 2025" → date(2025, 10, 9).""" - if not text_val: - return None - m = re.match(r"(\d+)\s+([а-я]+)\s+(\d{4})", text_val.strip(), re.IGNORECASE) - if not m: - return None - day = int(m.group(1)) - month_word = m.group(2).lower() - year = int(m.group(3)) - month = RUS_MONTHS.get(month_word) - return date(year, month, day) if month else None - - -def _strip_price(price_str: str | None) -> int | None: - """Извлекает целое число из строки "11 990 000 ₽" → 11990000.""" - if not price_str: - return None - digits = re.sub(r"[^\d]", "", price_str) - return int(digits) if digits else None - - -def _normalize_house_type(raw: str | None) -> str | None: - """Нормализует тип дома: "Монолитный" → "monolith".""" - if not raw: - return None - return HOUSE_TYPE_MAP.get(raw.lower(), "other") - - -def _normalize_house_class(raw: str | None) -> str | None: - """Нормализует класс жилья: "Комфорт" → "comfort" (#2003). - - В отличие от _normalize_house_type, неизвестное значение → None (НЕ "other"): - house_class используется как точный фильтр (`= ANY(...)` в search_query.py) — - мусорный bucket-токен там бесполезен и только засоряет список значений. - """ - if not raw: - return None - return HOUSE_CLASS_MAP.get(raw.strip().lower()) - - -def _cast_expand_param(value: str, cast_type: str) -> Any: - """Кастует строковое значение expandParams к нужному типу.""" - if cast_type == "int": - try: - return int(value) - except (ValueError, TypeError): - return None - if cast_type == "bool_da": - return value.strip().lower() == "да" - if cast_type == "house_type": - return _normalize_house_type(value) - if cast_type == "house_class": - return _normalize_house_class(value) - # str - return value - - -def _parse_expand_params(items: list[dict[str, Any]]) -> dict[str, Any]: - """Извлекает поля из expandParams['items'] по EXPAND_PARAMS_MAP. - - Возвращает dict с распарсенными полями + raw_characteristics (полный список). - """ - result: dict[str, Any] = {} - for category in items: - for param in category.get("params", []): - param_type = param.get("type", "") - if param_type in EXPAND_PARAMS_MAP: - field_name, cast_type = EXPAND_PARAMS_MAP[param_type] - raw_value = param.get("value", "") - result[field_name] = _cast_expand_param(str(raw_value), cast_type) - return result - - -def _flatten_widgets(placeholders: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Возвращает плоский список виджетов из placeholders. - - Avito (≈2026) обернул 10 виджетов в контейнер: placeholders теперь - [{"id": ..., "widgets": [{"type": "housePage", ...}, ...]}] - раньше виджеты лежали в placeholders напрямую: - [{"type": "housePage", ...}, {"type": "reviews", ...}, ...] - - Хелпер раскрывает любой элемент с ключом "widgets" (список) в его виджеты, - остальные элементы оставляет как есть → поддержка обеих форм. - """ - flat: list[dict[str, Any]] = [] - for entry in placeholders: - if isinstance(entry, dict) and isinstance(entry.get("widgets"), list): - flat.extend(w for w in entry["widgets"] if isinstance(w, dict)) - elif isinstance(entry, dict): - flat.append(entry) - return flat - - -def _get_widget(placeholders: list[dict[str, Any]], widget_type: str) -> dict[str, Any] | None: - """Ищет виджет по type в массиве placeholders (уже flat).""" - for w in placeholders: - if w.get("type") == widget_type: - return w - return None - - -def _widget_payload(widget: dict[str, Any], widget_type: str) -> dict[str, Any]: - """Возвращает полезную нагрузку виджета, разворачивая type-вложенность. - - Avito (≈2026, MFE) переместил данные виджета на уровень глубже — - под ключ с именем самого виджета: - новый: widget['props'][widget_type] = {... entries / items ...} - старый: widget['props'] = {... entries / items ...} - - Хелпер пробует новую форму (props[widget_type]), fallback на старую - (props напрямую) → поддержка обеих форм. - """ - props = widget.get("props", {}) - if not isinstance(props, dict): - return {} - nested = props.get(widget_type) - if isinstance(nested, dict): - return nested - return props - - -# --------------------------------------------------------------------------- -# Widget parsers -# --------------------------------------------------------------------------- - - -def _parse_house_page(widget: dict[str, Any]) -> HouseInfo: - """Парсит виджет housePage → HouseInfo. - - developmentData — основной объект с данными дома. Avito (≈2026) изменил путь: - новый: widget['props']['housePage']['developmentData'] - старый: widget['props']['developmentData'] - Аналогично рейтинг переехал в developmentData['ratingPreview'] - (раньше ratingBadge / ratingSummaryStat лежали прямо в developmentData). - Хелпер пробует новые пути с fallback на старые → поддержка обеих форм. - """ - props = widget.get("props", {}) - dd = props.get("housePage", {}).get("developmentData") or props.get("developmentData", {}) - - # expandParams → характеристики дома - about = dd.get("aboutDevelopment", {}) - expand_params_items: list[dict[str, Any]] = about.get("expandParams", {}).get("items", []) - parsed_params = _parse_expand_params(expand_params_items) - - # Рейтинг: новая форма — внутри ratingPreview, старая — прямо в developmentData - rating_root = dd.get("ratingPreview") or dd - rating_badge = rating_root.get("ratingBadge", {}) - rating_info = rating_badge.get("info", {}) - - rating_summary = rating_root.get("ratingSummaryStat", {}) - rating_stat: list[dict[str, Any]] = rating_summary.get("ratingStat", []) - - # Карта/инфраструктура - map_preview = dd.get("mapPreview", {}) - - # Разработчик - developer = dd.get("developer", {}) - - # Координаты - coords = dd.get("coords", {}) - - return HouseInfo( - ext_id=dd.get("avitoId", 0), - ext_id_hash=dd.get("id"), - title=dd.get("title"), - short_address=dd.get("address"), - full_address=dd.get("fullAddress"), - lat=coords.get("lat"), - lon=coords.get("lng"), - year_built=parsed_params.get("year_built"), - total_floors=parsed_params.get("total_floors"), - house_type=parsed_params.get("house_type"), - house_class=parsed_params.get("house_class"), - material_floors=parsed_params.get("material_floors"), - hot_water=parsed_params.get("hot_water"), - passenger_elevators=parsed_params.get("passenger_elevators"), - cargo_elevators=parsed_params.get("cargo_elevators"), - has_concierge=parsed_params.get("has_concierge"), - parking_type=parsed_params.get("parking_type"), - has_playground=parsed_params.get("has_playground"), - closed_yard=parsed_params.get("closed_yard"), - developer_name=developer.get("name"), - developer_key=developer.get("key"), - infrastructure_summary=map_preview.get("objects"), - infrastructure_walk_distance=map_preview.get("distance"), - rating_score=rating_info.get("score"), - rating_string=rating_info.get("scoreString"), - reviews_count=rating_summary.get("reviewCount"), - rating_distribution=rating_stat, - map_pins=map_preview.get("pins", []), - raw_characteristics=expand_params_items, - ) - - -def _is_novostroyka_url(house_url: str) -> bool: - """True если house_url — страница новостройки (/catalog/novostroyki/...). - - Структура новостройки отличается от вторички: вместо виджета 'housePage' - используется 'developmentPage', нет отзывов/истории размещений. - """ - return NOVOSTROYKA_URL_MARKER in house_url - - -def _parse_development_page(widget: dict[str, Any]) -> HouseInfo: - """Парсит виджет 'developmentPage' (новостройка) → HouseInfo. - - Структура отличается от 'housePage' (вторичка): - - данные под props['developmentPage']['developmentData'] (fallback props['developmentData']) - - характеристики не в developmentData.aboutDevelopment.expandParams.items, - а в aboutDevelopment.tabs[*].expandParams.items[*].params (плоский список params). - - НЕТ рейтинга (ratingBadge/ratingSummaryStat), года постройки (дом строится — - есть "Срок сдачи"), отзывов, истории размещений. - Извлекаем то, что есть: id/адрес/координаты/застройщик/инфраструктура + тип дома, - этажность (диапазон → raw_characteristics), лифты из таблицы "Параметры". - """ - props = widget.get("props", {}) - dd = props.get("developmentPage", {}).get("developmentData") or props.get("developmentData", {}) - - # Характеристики новостройки лежат в tabs[*].expandParams.items[*].params. - # Собираем их в форму, совместимую с _parse_expand_params (category → params). - char_categories: list[dict[str, Any]] = [] - about = dd.get("aboutDevelopment", {}) - for tab in about.get("tabs", []): - if not isinstance(tab, dict): - continue - items = tab.get("expandParams", {}).get("items", []) - for item in items: - if isinstance(item, dict) and isinstance(item.get("params"), list): - char_categories.append(item) - parsed_params = _parse_expand_params(char_categories) - - map_preview = dd.get("mapPreview", {}) - developer = dd.get("developer", {}) - coords = dd.get("coords", {}) - - return HouseInfo( - ext_id=dd.get("avitoId", 0), - ext_id_hash=dd.get("id"), - title=dd.get("title"), - short_address=dd.get("address"), - full_address=dd.get("fullAddress") or None, - lat=coords.get("lat"), - lon=coords.get("lng"), - year_built=parsed_params.get("year_built"), # обычно None — дом строится - total_floors=parsed_params.get("total_floors"), - house_type=parsed_params.get("house_type"), - house_class=parsed_params.get("house_class"), - material_floors=parsed_params.get("material_floors"), - hot_water=parsed_params.get("hot_water"), - passenger_elevators=parsed_params.get("passenger_elevators"), - cargo_elevators=parsed_params.get("cargo_elevators"), - has_concierge=parsed_params.get("has_concierge"), - parking_type=parsed_params.get("parking_type"), - has_playground=parsed_params.get("has_playground"), - closed_yard=parsed_params.get("closed_yard"), - developer_name=developer.get("name"), - developer_key=developer.get("key"), - infrastructure_summary=map_preview.get("objects"), - infrastructure_walk_distance=map_preview.get("distance"), - rating_score=None, - rating_string=None, - reviews_count=None, - rating_distribution=[], - map_pins=map_preview.get("pins", []), - raw_characteristics=char_categories, - ) - - -def _parse_single_review(r: dict[str, Any]) -> HouseReview: - """Парсит один объект отзыва из entries.""" - text_main: str | None = None - text_pros: str | None = None - text_cons: str | None = None - - for section in r.get("textSections", []): - title = section.get("title", "") - section_text = section.get("text") - if title == "": - text_main = section_text - elif title == "Преимущества": - text_pros = section_text - elif title == "Недостатки": - text_cons = section_text - - author = r.get("author", {}) - return HouseReview( - ext_review_id=r["id"], - author_name=author.get("title"), - review_title=r.get("reviewTitle"), - score=r.get("score"), - model_experience=r.get("modelExperience"), - rated_date=_parse_ru_date(r.get("rated")), - text_main=text_main, - text_pros=text_pros, - text_cons=text_cons, - raw_payload=r, - ) - - -def _parse_reviews(widget: dict[str, Any]) -> tuple[list[HouseReview], str | None]: - """Парсит виджет reviews → (список HouseReview, следующая страница URL). - - Entries — смешанный список с type='rating' (отзывы) и type='pages' (пагинация). - """ - reviews: list[HouseReview] = [] - next_page_url: str | None = None - - payload = _widget_payload(widget, "reviews") - entries = payload.get("entries", []) - for entry in entries: - entry_type = entry.get("type") - value = entry.get("value", {}) - - if entry_type == "rating": - try: - reviews.append(_parse_single_review(value)) - except (KeyError, TypeError) as exc: - logger.warning("Не удалось распарсить отзыв: %r — %s", value.get("id"), exc) - elif entry_type == "pages": - next_page_url = value.get("nextPageUrl") - - return reviews, next_page_url - - -def _parse_seller(seller_raw: dict[str, Any]) -> SellerInfo | None: - """Парсит seller из miniSerp item → SellerInfo.""" - if not seller_raw: - return None - name = seller_raw.get("name", "") - if not name: - return None - - profile_url = seller_raw.get("url", "") - # ext_seller_id — последний сегмент URL: "/brands/domrf66" → "domrf66" - ext_seller_id = profile_url.strip("/").split("/")[-1] if profile_url else "" - if not ext_seller_id: - logger.warning("Не удалось извлечь ext_seller_id из seller URL: %r", profile_url) - return None - - return SellerInfo( - ext_seller_id=ext_seller_id, - name=name, - seller_type=seller_raw.get("type"), - on_platform_since=seller_raw.get("from"), - profile_url=profile_url or None, - ) - - -def _parse_mini_serp(widget: dict[str, Any]) -> list[MiniSerpListing]: - """Парсит виджет miniSerp → список активных объявлений в доме.""" - listings: list[MiniSerpListing] = [] - - items = _widget_payload(widget, "miniSerp").get("items", []) - for item in items: - geo = item.get("geo", {}) - colors: list[str] = geo.get("colors", []) - - seller_raw = item.get("seller") - seller = _parse_seller(seller_raw) if seller_raw else None - - price_raw = item.get("price") - price_rub = _strip_price(price_raw) if isinstance(price_raw, str) else price_raw - - listings.append( - MiniSerpListing( - ext_item_id=item["id"], - title=item.get("title"), - price_rub=price_rub, - description=item.get("description"), - url=item.get("url", ""), - metro_text=geo.get("content"), - metro_color=colors[0] if colors else None, - seller=seller, - ) - ) - - return listings - - -def _parse_placement_history(widget: dict[str, Any]) -> list[PlacementHistoryItem]: - """Парсит виджет housePlacementHistory → список PlacementHistoryItem. - - ВАЖНО: removed_date ВСЕГДА None — виджет не отдаёт эту дату. - Точная дата только из IMV API (Stage 2d). - """ - result: list[PlacementHistoryItem] = [] - - items = _widget_payload(widget, "housePlacementHistory").get("items", []) - for item in items: - item_copy = {k: v for k, v in item.items() if k != "itemImage"} - result.append( - PlacementHistoryItem( - ext_item_id=str(item["id"]), - title=item.get("title"), - start_price=item.get("startPrice"), - start_price_date=_unix_to_date(item.get("startPriceDate")), - last_price=item.get("lastPrice"), - last_price_date=_unix_to_date(item.get("lastPriceDate")), - exposure_days=item.get("exposure"), - removed_date=None, # widget не отдаёт! IMV API Stage 2d - raw_payload=item_copy, - ) - ) - - return result - - -def _parse_recommendations(widget: dict[str, Any]) -> list[RecommendationItem]: - """Парсит виджет recommendations → lightweight список похожих объявлений.""" - result: list[RecommendationItem] = [] - - items = _widget_payload(widget, "recommendations").get("items", []) - for item in items: - additional_info: list[str] = item.get("additionalInfo", []) - address = additional_info[0] if additional_info else None - - images: list[dict[str, Any]] = item.get("images", []) - image_url: str | None = None - if images: - image_url = images[0].get("208x156") - - result.append( - RecommendationItem( - title=item.get("title"), - price_text=item.get("priceRange"), - address=address, - url=item.get("url", ""), - image_url=image_url, - ) - ) - - return result - - -# --------------------------------------------------------------------------- -# Core parser (pure, no network) -# --------------------------------------------------------------------------- - - -def _extract_preloaded_state(html: str) -> dict[str, Any] | None: - """Извлекает window.__preloadedState__ из HTML → распарсенный dict. - - Pure функция (нет I/O) — тестируется на фикстуре, переиспользуется из - fetch_house_catalog. - - Две формы (см. PRELOADED_*_RE): - 1. URL-encoded строковый литерал (текущая, ≈2026): - window.__preloadedState__ = "%7B%22...%22%7D"; - → urllib.parse.unquote + json.loads - 2. JSON.parse с js-экранированием (старая, fallback): - window.__preloadedState__ = JSON.parse("{\\"...\\"}"); - → ручной unescape + json.loads - - Возвращает dict или None, если маркер не найден / не распарсился. - """ - # Форма 1 — URL-encoded литерал (приоритет) - m = PRELOADED_URLENC_RE.search(html) - if m is not None: - try: - return json.loads(urllib.parse.unquote(m.group(1))) - except (json.JSONDecodeError, ValueError) as exc: - logger.warning("URL-encoded __preloadedState__ не распарсился: %s", exc) - - # Форма 2 — старый JSON.parse("...") (fallback) - m = PRELOADED_JSPARSE_RE.search(html) - if m is not None: - # JS-стиль unescaping: \\" → " , \\\\ → \ , \\/ → / - # НЕ .decode("unicode_escape") — ломает многобайтный UTF-8 (кириллица). - raw = m.group(1).replace('\\"', '"').replace("\\\\", "\\").replace("\\/", "/") - try: - return json.loads(raw) - except json.JSONDecodeError: - logger.warning("JSON.parse-форма с replace-unescape не удалась, пробуем unicode_escape") - try: - return json.loads(bytes(raw, "utf-8").decode("unicode_escape")) - except (json.JSONDecodeError, UnicodeDecodeError) as exc2: - logger.warning("JSON.parse-форма __preloadedState__ не распарсилась: %s", exc2) - - return None - - -def _parse_development_or_skip( - placeholders: list[dict[str, Any]], house_url: str -) -> HouseCatalogEnrichment: - """Обрабатывает страницу без виджета 'housePage' (новостройка или неизвестная форма). - - - Есть виджет 'developmentPage' → парсим его (partial: дом/застройщик/тип/лифты, - без отзывов/истории/рейтинга). - - Нет developmentPage, но URL — новостройка → graceful skip: warning + минимальное - обогащение (ext_id из URL, без характеристик). НЕ кидаем ValueError, чтобы один - незнакомый шейп новостройки не валил весь enrichment-loop pipeline. - - Иначе (вторичка без housePage) → ValueError (регрессия шейпа вторички). - """ - dev_widget = _get_widget(placeholders, "developmentPage") - if dev_widget is not None: - house_info = _parse_development_page(dev_widget) - logger.info( - "developmentPage (новостройка) распарсен: ext_id=%s type=%s url=%s", - house_info.ext_id, - house_info.house_type, - house_url, - ) - return HouseCatalogEnrichment(house_url=house_url, house=house_info) - - if _is_novostroyka_url(house_url): - logger.warning( - "Новостройка без виджета 'developmentPage'/'housePage' (%s) — " - "graceful skip, обогащение пропущено", - house_url, - ) - ext_id = _ext_id_from_url(house_url) - return HouseCatalogEnrichment(house_url=house_url, house=HouseInfo(ext_id=ext_id)) - - raise ValueError("Виджет 'housePage' не найден в placeholders") - - -def _ext_id_from_url(house_url: str) -> int: - """Извлекает числовой avitoId из хвоста house_url (...//). 0 если нет.""" - m = re.search(r"/(\d+)/?(?:\?|$)", house_url) - return int(m.group(1)) if m else 0 - - -def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnrichment: - """Парсит уже разобранный __preloadedState__ dict → HouseCatalogEnrichment. - - Pure функция: нет HTTP, нет I/O. Принимает state dict и house_url (для поля - HouseCatalogEnrichment.house_url). Используется в тестах и из fetch_house_catalog. - - Новостройки (/catalog/novostroyki/...) не имеют виджета 'housePage' — вместо него - 'developmentPage'. Для них парсим что есть (id/адрес/застройщик/тип дома/лифты) и - возвращаем partial-обогащение без отзывов/истории. Если структура совсем не - распознана — graceful: warning + минимальное обогащение (НЕ ValueError). - - Raises: - ValueError если placeholders не массив, или (только для вторички) housePage - виджет не найден. - """ - try: - raw_placeholders: list[dict[str, Any]] = state["data"]["data"]["page"]["placeholders"] - except (KeyError, TypeError) as exc: - raise ValueError(f"Не удалось найти placeholders в __preloadedState__: {exc}") from exc - - if not isinstance(raw_placeholders, list): - raise ValueError( - f"placeholders должен быть массивом, получен: {type(raw_placeholders).__name__}" - ) - - # Avito (≈2026) оборачивает виджеты в контейнер {id, widgets:[...]} — - # раскрываем в плоский список (поддержка старой и новой формы). - placeholders = _flatten_widgets(raw_placeholders) - - # housePage — обязательный виджет для вторички. Новостройки используют - # 'developmentPage' и не имеют housePage — обрабатываем отдельно (graceful). - house_page_widget = _get_widget(placeholders, "housePage") - if house_page_widget is None: - return _parse_development_or_skip(placeholders, house_url) - - house_info = _parse_house_page(house_page_widget) - - # reviews — опциональный - reviews: list[HouseReview] = [] - next_page_url: str | None = None - reviews_widget = _get_widget(placeholders, "reviews") - if reviews_widget is not None: - reviews, next_page_url = _parse_reviews(reviews_widget) - if not reviews: - logger.warning("Виджет 'reviews' найден, но 0 отзывов распарсено — проверь шейп props") - # #1789: рейтинг переехал из housePage.developmentData.ratingPreview в - # reviews.filterEntries[type=="score"]. Применяем как fallback — только - # если housePage не дал рейтинг (чтобы не затирать уже правильный источник). - if house_info.rating_score is None: - reviews_payload = _widget_payload(reviews_widget, "reviews") - filter_entries: list[dict[str, Any]] = reviews_payload.get("filterEntries", []) - for fe in filter_entries: - if fe.get("type") == "score": - score_value = fe.get("value", {}) - if isinstance(score_value, dict): - score_float = score_value.get("scoreFloat") - if score_float is not None: - house_info.rating_score = float(score_float) - house_info.rating_string = score_value.get("scoreString") - rc = score_value.get("reviewCount") - if rc is not None: - house_info.reviews_count = int(rc) - rating_stat = score_value.get("ratingStat") - if isinstance(rating_stat, list): - house_info.rating_distribution = rating_stat - logger.info( - "parse_houses_state: rating из reviews.filterEntries " - "url=%s score=%.3f string=%s reviews=%s", - house_url, - house_info.rating_score, - house_info.rating_string, - house_info.reviews_count, - ) - break - else: - logger.debug("Виджет 'reviews' не найден — пропускаем") - - # miniSerp — опциональный - mini_serp: list[MiniSerpListing] = [] - mini_serp_widget = _get_widget(placeholders, "miniSerp") - if mini_serp_widget is not None: - mini_serp = _parse_mini_serp(mini_serp_widget) - else: - logger.debug("Виджет 'miniSerp' не найден — пропускаем") - - # housePlacementHistory — опциональный - placement_history: list[PlacementHistoryItem] = [] - history_widget = _get_widget(placeholders, "housePlacementHistory") - if history_widget is not None: - placement_history = _parse_placement_history(history_widget) - if not placement_history: - logger.warning( - "Виджет 'housePlacementHistory' найден, но 0 items распарсено — проверь шейп props" - ) - else: - logger.debug("Виджет 'housePlacementHistory' не найден — пропускаем") - - # recommendations — опциональный - recommendations: list[RecommendationItem] = [] - recs_widget = _get_widget(placeholders, "recommendations") - if recs_widget is not None: - recommendations = _parse_recommendations(recs_widget) - else: - logger.debug("Виджет 'recommendations' не найден — пропускаем") - - return HouseCatalogEnrichment( - house_url=house_url, - house=house_info, - reviews=reviews, - placement_history=placement_history, - mini_serp=mini_serp, - recommendations=recommendations, - reviews_next_page_url=next_page_url, - ) - - -# --------------------------------------------------------------------------- -# HTTP fetch (thin wrapper, requires curl_cffi) -# --------------------------------------------------------------------------- - - -async def fetch_house_catalog( - house_url: str, - *, - cffi_session: Any | None = None, - browser_fetcher: BrowserFetcher | None = None, -) -> HouseCatalogEnrichment: - """GET {AVITO_BASE}{house_url} → extract window.__preloadedState__ → parse 10 виджетов. - - Параметры: - house_url — относительный путь: - /catalog/houses/ekaterinburg/ul_postovskogo/3171365 - cffi_session — опциональная curl_cffi AsyncSession (для переиспользования пула соединений). - Если None — создаётся временная сессия. - browser_fetcher — если передан, используется браузерный fetch вместо curl_cffi. - - Raises: - RuntimeError если curl_cffi не установлен (в curl-режиме). - httpx.HTTPStatusError / curl_cffi аналог если status != 200. - ValueError если __preloadedState__ не найден или placeholders не массив. - """ - url = f"{AVITO_BASE}{house_url}" - logger.info("Houses Catalog fetch: %s", url) - - if browser_fetcher is not None: - html = await browser_fetcher.fetch(url) - if _is_firewall_page(html): - raise AvitoBlockedError(f"Avito houses firewall (browser-mode) for {url}") - state = _extract_preloaded_state(html) - if state is None: - raise ValueError( - f"window.__preloadedState__ не найден / не распарсился в HTML страницы: {url}" - ) - logger.info("Houses Catalog state extracted (browser-mode), parsing widgets") - return parse_houses_state(state, house_url) - - try: - from curl_cffi.requests import AsyncSession as CffiAsyncSession - except ImportError as exc: - raise RuntimeError( - "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml." - ) from exc - - _own_session = False - if cffi_session is None: - cffi_session = CffiAsyncSession(impersonate="chrome120") - _own_session = True - - try: - resp = await cffi_session.get(url) - if resp.status_code == 403: - raise AvitoBlockedError(f"Avito houses HTTP 403 for {url}") - if resp.status_code == 429: - raise AvitoRateLimitedError(f"Avito houses HTTP 429 for {url}") - resp.raise_for_status() - html = resp.text - - # Извлекаем + декодируем window.__preloadedState__ (URL-encoded или JSON.parse). - state = _extract_preloaded_state(html) - if state is None: - raise ValueError( - f"window.__preloadedState__ не найден / не распарсился в HTML страницы: {url}" - ) - - logger.info("Houses Catalog state extracted, parsing widgets") - return parse_houses_state(state, house_url) - - finally: - if _own_session: - await cffi_session.close() - - -# --------------------------------------------------------------------------- -# Save functions -# --------------------------------------------------------------------------- - - -def upsert_house(db: Session, h: HouseInfo) -> int | None: - """Match-or-create канонический дом через match_or_create_house, затем обновить enrichment. - - Source='avito'. Возвращает houses.id, либо None если matcher отказал (P1: - безномерный адрес без кадастра, method='no_house_number'). - Делегирует в _persist_house (без url — url пуст как раньше для этого пути). - - Маппинг полей HouseInfo → DDL колонки (009 + 010): - ext_id → ext_house_id (text) - ext_id_hash → avito_id_hash - short_address → short_address (010) - full_address → full_address (010) - address (DDL) → используем short_address как fallback - rating_score → rating_score (010, numeric(4,2)) - rating (DDL) → округлённый numeric(2,1) из rating_score - """ - return _persist_house(db, h, "") - - -def _upsert_house_with_url(db: Session, h: HouseInfo, house_url: str) -> int | None: - """Внутренний вариант upsert_house с правильным URL. - - Делегирует в _persist_house — используй его вместо прямого вызова. - Возвращает None если matcher отказал (P1 numberless без кадастра). - """ - return _persist_house(db, h, house_url) - - -def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int | None: - """Канонический путь сохранения дома-avito через match_or_create_house. - - Шаги: - 1. match_or_create_house — находит или создаёт канонический houses-ряд - (3-tier: source_exact → fingerprint → geo → new), защищён advisory lock. - 2. UPDATE houses SET ... — записывает все avito-enrichment поля в найденный/новый дом. - - Avito-авторитетные поля (rating*, developer*, технические хар-ки, url, - avito_id_hash) перезаписываются напрямую. - - address/lat/lon/year_built — через COALESCE чтобы не затирать данные - канонического дома от cian/кадастра. - - house_class (#2003) — COALESCE(new, existing): свежее avito-значение - (топ-приоритет источник) побеждает, но None не затирает значение от - более низкоприоритетного источника (см. HOUSE_FIELD_PRIORITY). - 3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment - (advisory lock xact-scoped, должен держаться до commit). - - Returns: - houses.id (int) канонического дома. - """ - house_id, _conf, _method = match_or_create_house( - db, - ext_source="avito", - ext_id=str(h.ext_id), - # short preferred: aliases have no city/region prefix; full is last resort - address=h.short_address or h.full_address, - lat=h.lat, - lon=h.lon, - year_built=h.year_built, - source_url=house_url or None, - ) - - # P1: matcher refuses a numberless address without a cadastral number - # (method 'no_house_number' → house_id None). No canonical row exists to enrich, - # so skip the UPDATE and signal the graceful skip up to save_house_catalog_enrichment. - if house_id is None: - logger.info( - "_persist_house: skip numberless avito house ext_id=%s addr=%r (no cadastral)", - h.ext_id, - h.short_address or h.full_address, - ) - return None - - db.execute( - text(""" - UPDATE houses SET - url = CAST(:url AS text), - short_address = CAST(:short_address AS text), - full_address = CAST(:full_address AS text), - avito_id_hash = CAST(:avito_id_hash AS text), - house_type = CAST(:house_type AS text), - -- house_class (#2003): avito_houses_catalog топ-приоритет источник - -- (conflict_resolution.HOUSE_FIELD_PRIORITY) — свежее avito-значение - -- всегда побеждает, но None (вторичка не отдаёт "Класс жилья") не - -- затирает значение, уже проставленное более низкоприоритетным - -- источником (напр. yandex_realty_nb). - house_class = COALESCE(CAST(:house_class AS text), houses.house_class), - total_floors = CAST(:total_floors AS int), - passenger_elevators = CAST(:passenger_elevators AS int), - cargo_elevators = CAST(:cargo_elevators AS int), - has_concierge = CAST(:has_concierge AS boolean), - closed_yard = CAST(:closed_yard AS boolean), - material_floors = CAST(:material_floors AS text), - hot_water = CAST(:hot_water AS text), - parking_type = CAST(:parking_type AS text), - has_playground = CAST(:has_playground AS boolean), - developer_name = CAST(:developer_name AS text), - developer_key = CAST(:developer_key AS text), - infrastructure_summary = CAST(:infrastructure_summary AS text), - infrastructure_walk_distance = CAST(:infrastructure_walk_distance AS text), - rating = CAST(:rating AS numeric), - rating_score = CAST(:rating_score AS numeric), - rating_string = CAST(:rating_string AS text), - reviews_count = CAST(:reviews_count AS int), - rating_distribution = CAST(:rating_distribution AS jsonb), - map_pins = CAST(:map_pins AS jsonb), - raw_characteristics = CAST(:raw_characteristics AS jsonb), - address = COALESCE(houses.address, CAST(:address AS text)), - lat = COALESCE(houses.lat, CAST(:lat AS double precision)), - lon = COALESCE(houses.lon, CAST(:lon AS double precision)), - year_built = COALESCE(houses.year_built, CAST(:year_built AS int)), - last_scraped_at = NOW(), - avito_validated_at = NOW() - WHERE id = CAST(:house_id AS bigint) - """), - { - "house_id": house_id, - "url": house_url or "", - "short_address": h.short_address, - "full_address": h.full_address, - "avito_id_hash": h.ext_id_hash, - "house_type": h.house_type, - "house_class": h.house_class, - "total_floors": h.total_floors, - "passenger_elevators": h.passenger_elevators, - "cargo_elevators": h.cargo_elevators, - "has_concierge": h.has_concierge, - "closed_yard": h.closed_yard, - "material_floors": h.material_floors, - "hot_water": h.hot_water, - "parking_type": h.parking_type, - "has_playground": h.has_playground, - "developer_name": h.developer_name, - "developer_key": h.developer_key, - "infrastructure_summary": h.infrastructure_summary, - "infrastructure_walk_distance": h.infrastructure_walk_distance, - # rating (numeric(2,1)) — округлённое значение для совместимости с 009 DDL - "rating": round(h.rating_score, 1) if h.rating_score is not None else None, - "rating_score": h.rating_score, - "rating_string": h.rating_string, - "reviews_count": h.reviews_count, - "rating_distribution": json.dumps(h.rating_distribution, ensure_ascii=False), - "map_pins": json.dumps(h.map_pins, ensure_ascii=False), - "raw_characteristics": json.dumps(h.raw_characteristics, ensure_ascii=False), - "address": h.short_address, - "lat": h.lat, - "lon": h.lon, - "year_built": h.year_built, - }, - ) - - logger.info( - "_persist_house: ext_id=%s url=%s → houses.id=%s (method=%s)", - h.ext_id, - house_url, - house_id, - _method, - ) - return int(house_id) - - -def save_house_reviews(db: Session, house_id: int, reviews: list[HouseReview]) -> int: - """INSERT INTO house_reviews ON CONFLICT (source, ext_review_id) DO UPDATE. - - Source='avito'. Возвращает количество вставленных/обновлённых строк. - """ - if not reviews: - return 0 - - count = 0 - for r in reviews: - db.execute( - text(""" - INSERT INTO house_reviews ( - house_id, source, ext_review_id, - author_name, review_title, score, - model_experience, rated_date, - text_main, text_pros, text_cons, - raw_payload, scraped_at - ) VALUES ( - CAST(:house_id AS bigint), 'avito', CAST(:ext_review_id AS bigint), - CAST(:author_name AS text), CAST(:review_title AS text), CAST(:score AS int), - CAST(:model_experience AS text), CAST(:rated_date AS date), - CAST(:text_main AS text), CAST(:text_pros AS text), CAST(:text_cons AS text), - CAST(:raw_payload AS jsonb), NOW() - ) - ON CONFLICT (source, ext_review_id) DO UPDATE SET - author_name = EXCLUDED.author_name, - review_title = EXCLUDED.review_title, - score = EXCLUDED.score, - model_experience = EXCLUDED.model_experience, - rated_date = EXCLUDED.rated_date, - text_main = EXCLUDED.text_main, - text_pros = EXCLUDED.text_pros, - text_cons = EXCLUDED.text_cons, - raw_payload = EXCLUDED.raw_payload, - scraped_at = NOW() - """), - { - "house_id": house_id, - "ext_review_id": r.ext_review_id, - "author_name": r.author_name, - "review_title": r.review_title, - "score": r.score, - "model_experience": r.model_experience, - "rated_date": r.rated_date, - "text_main": r.text_main, - "text_pros": r.text_pros, - "text_cons": r.text_cons, - "raw_payload": ( - json.dumps(r.raw_payload, ensure_ascii=False) if r.raw_payload else None - ), - }, - ) - count += 1 - - logger.info("save_house_reviews: house_id=%s, saved=%d", house_id, count) - return count - - -def save_sellers(db: Session, sellers: list[SellerInfo]) -> dict[str, int]: - """INSERT INTO sellers ON CONFLICT (source, ext_seller_id) DO UPDATE. - - Source='avito'. Возвращает {ext_seller_id: sellers.id}. - """ - result: dict[str, int] = {} - for s in sellers: - row = db.execute( - text(""" - INSERT INTO sellers ( - source, ext_seller_id, - name, seller_type, on_platform_since, profile_url, - last_seen_at - ) VALUES ( - 'avito', CAST(:ext_seller_id AS text), - CAST(:name AS text), CAST(:seller_type AS text), - CAST(:on_platform_since AS text), CAST(:profile_url AS text), - NOW() - ) - ON CONFLICT (source, ext_seller_id) DO UPDATE SET - name = EXCLUDED.name, - seller_type = EXCLUDED.seller_type, - on_platform_since = EXCLUDED.on_platform_since, - profile_url = EXCLUDED.profile_url, - last_seen_at = NOW() - RETURNING id - """), - { - "ext_seller_id": s.ext_seller_id, - "name": s.name, - "seller_type": s.seller_type, - "on_platform_since": s.on_platform_since, - "profile_url": s.profile_url, - }, - ) - seller_db_id: int = row.scalar_one() - result[s.ext_seller_id] = seller_db_id - - logger.info("save_sellers: saved=%d", len(result)) - return result - - -def link_listings_to_seller(db: Session, ext_item_id_to_seller_db_id: dict[int, int]) -> int: - """UPDATE listings SET seller_id_fk = :seller_id WHERE source='avito' AND source_id = :item_id. - - Возвращает количество обновлённых строк. - """ - if not ext_item_id_to_seller_db_id: - return 0 - - total = 0 - for item_id, seller_db_id in ext_item_id_to_seller_db_id.items(): - result = db.execute( - text(""" - UPDATE listings - SET seller_id_fk = CAST(:seller_id AS bigint) - WHERE source = 'avito' - AND source_id = CAST(:item_id AS text) - AND seller_id_fk IS DISTINCT FROM CAST(:seller_id AS bigint) - """), - {"seller_id": seller_db_id, "item_id": str(item_id)}, - ) - total += result.rowcount - - logger.info("link_listings_to_seller: updated=%d listings", total) - return total - - -def save_placement_history(db: Session, items: list[PlacementHistoryItem]) -> int: - """INSERT INTO house_placement_history с source='avito_widget' и removed_date=NULL. - - ON CONFLICT (source, ext_item_id) DO UPDATE last_price/last_price_date/exposure_days. - Возвращает количество строк. - """ - if not items: - return 0 - - count = 0 - for item in items: - db.execute( - text(""" - INSERT INTO house_placement_history ( - source, ext_item_id, - title, - start_price, start_price_date, - last_price, last_price_date, - exposure_days, - removed_date, - raw_payload, scraped_at - ) VALUES ( - 'avito_widget', CAST(:ext_item_id AS text), - CAST(:title AS text), - CAST(:start_price AS bigint), CAST(:start_price_date AS date), - CAST(:last_price AS bigint), CAST(:last_price_date AS date), - CAST(:exposure_days AS int), - NULL, - CAST(:raw_payload AS jsonb), NOW() - ) - ON CONFLICT (source, ext_item_id) DO UPDATE SET - title = EXCLUDED.title, - last_price = EXCLUDED.last_price, - last_price_date = EXCLUDED.last_price_date, - exposure_days = EXCLUDED.exposure_days, - raw_payload = EXCLUDED.raw_payload, - scraped_at = NOW() - """), - { - "ext_item_id": item.ext_item_id, - "title": item.title, - "start_price": item.start_price, - "start_price_date": item.start_price_date, - "last_price": item.last_price, - "last_price_date": item.last_price_date, - "exposure_days": item.exposure_days, - "raw_payload": ( - json.dumps(item.raw_payload, ensure_ascii=False) if item.raw_payload else None - ), - }, - ) - count += 1 - - logger.info("save_placement_history: saved=%d items (source=avito_widget)", count) - return count - - -def save_house_catalog_enrichment( - db: Session, - e: HouseCatalogEnrichment, -) -> dict[str, int]: - """Orchestrator — полный pipeline сохранения одного обогащения. - - Операции (в порядке): - 1. upsert_house → houses.id - 2. save_house_reviews(house_id, reviews) - 3. save_sellers([m.seller for m in mini_serp if m.seller]) → {ext_seller_id: db_id} - 4. link_listings_to_seller({item_id: seller_db_id}) - 5. save_placement_history(placement_history) - - Возвращает: - {'house_id': N, 'reviews': N, 'sellers': N, 'listings_linked': N, 'placement_history': N} - """ - # 1. Upsert дом с правильным URL из enrichment. - # Гард #1820: id-less graceful-skip новостройки (ext_id=0, нет адреса/координат) — - # НЕ персистим canonical house, иначе все безымянные URL склеиваются в один bogus - # avito:0 через source_exact-tier и плодят мусорную house_sources("avito","0"). - if not e.house.ext_id and not (e.house.short_address or e.house.full_address): - logger.warning( - "save_house_catalog_enrichment: пропуск id-less новостройки без адреса" - " (ext_id=%s url=%s) — не создаём bogus avito:0 в house_sources", - e.house.ext_id, - e.house_url, - ) - return { - "house_id": 0, - "reviews": 0, - "sellers": 0, - "listings_linked": 0, - "placement_history": 0, - } - house_id = _upsert_house_with_url(db, e.house, e.house_url) - - # P1: matcher refused to create a canonical house for a numberless address without a - # cadastral number (house_id None). Downstream saves (reviews / placement history) are - # house-scoped and meaningless with a NULL house_id, so graceful-skip the whole - # enrichment — same zero-counter contract as the id-less guard above. - if house_id is None: - logger.warning( - "save_house_catalog_enrichment: пропуск дома с безномерным адресом без кадастра" - " (ext_id=%s addr=%r) — matcher вернул no_house_number", - e.house.ext_id, - e.house.short_address or e.house.full_address, - ) - return { - "house_id": 0, - "reviews": 0, - "sellers": 0, - "listings_linked": 0, - "placement_history": 0, - } - - # 2. Сохранить отзывы - reviews_count = save_house_reviews(db, house_id, e.reviews) - - # 3. Сохранить продавцов (дедуп по ext_seller_id) - sellers_to_save = [m.seller for m in e.mini_serp if m.seller is not None] - seen_seller_ids: set[str] = set() - unique_sellers: list[SellerInfo] = [] - for s in sellers_to_save: - if s.ext_seller_id not in seen_seller_ids: - unique_sellers.append(s) - seen_seller_ids.add(s.ext_seller_id) - - seller_id_map = save_sellers(db, unique_sellers) - - # 4. Слинковать объявления с продавцами - # Маппинг: ext_item_id (int) → seller db_id - item_to_seller: dict[int, int] = {} - for listing in e.mini_serp: - if listing.seller and listing.seller.ext_seller_id in seller_id_map: - item_to_seller[listing.ext_item_id] = seller_id_map[listing.seller.ext_seller_id] - - listings_linked = link_listings_to_seller(db, item_to_seller) - - # 5. Сохранить историю размещений - history_count = save_placement_history(db, e.placement_history) - - db.commit() - - result = { - "house_id": house_id, - "reviews": reviews_count, - "sellers": len(seller_id_map), - "listings_linked": listings_linked, - "placement_history": history_count, - } - logger.info("save_house_catalog_enrichment: %s", result) - return result diff --git a/tradein-mvp/backend/app/services/scrapers/cian.py b/tradein-mvp/backend/app/services/scrapers/cian.py deleted file mode 100644 index 4b514c6a..00000000 --- a/tradein-mvp/backend/app/services/scrapers/cian.py +++ /dev/null @@ -1,1185 +0,0 @@ -"""Cian.ru scraper — state-based SERP parser (137 fields per offer). - -Стратегия (Stage 3 рефактор): -- Cian React micro-frontends хранят state в window._cianConfig['frontend-serp'].push(). -- URL pattern: https://ekb.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2 - ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema_Cian_SERP_Inventory sec 13). - -ВАЖНО: Циан блокирует httpx/curl по TLS+fingerprint и сыплет Google reCAPTCHA на -датацентр-IP. Транспорт SERP переведён на BrowserFetcher (camoufox real-browser -fingerprint через tradein-browser сервис, #1806 / epic #883 Phase 2). Браузер -отдаёт полный SSR-HTML с Redux initialState — парсинг state не меняется. -Мобильный прокси (ha.mobileproxy.space) применяется server-side в браузер-контейнере -и ротирует IP сам, поэтому code-side _rotate_ip (changeip) больше не нужен. - -НЕ используем anchor jitter: offer.geo.coordinates.{lat,lng} — точные координаты -прямо из SERP state. Jitter запрещён per implementation plan. - -Exhaustive load (fetch_all_secondary): -- Cian SERP — регион-wide (не geo-bbox). Anchor-loop в существующем city-sweep - избыточен для Cian (все anchor'ы дают одну выдачу). Полный сбор = ОДИН проход - с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное деление диапазона). -""" - -from __future__ import annotations - -import asyncio -import hashlib -import inspect -import logging -import math -import re -from collections.abc import Callable -from datetime import UTC, datetime -from typing import Any -from urllib.parse import urlencode - -import sentry_sdk - -from app.core.config import settings -from app.services.scraper_settings import get_scraper_delay -from app.services.scrapers.base import BaseScraper, ScrapedLot -from app.services.scrapers.browser_fetcher import BrowserFetcher -from app.services.scrapers.cian_state_parser import extract_state -from app.services.scrapers.house_type_normalizer import normalize_house_type -from app.services.scrapers.price_brackets import get_price_seed_brackets -from app.services.scrapers.repair_state_normalizer import ( - infer_repair_state_from_text, - normalize_repair_state, -) - -logger = logging.getLogger(__name__) - -# Регион 4743 = Свердловская область (Cian internal region ID) -CIAN_EKB_REGION_ID = 4743 - -# SERP MFE name и state key (per Schema sec 1.2) -_MFE_SERP = "frontend-serp" -_STATE_KEY = "initialState" - -# ── Константы exhaustive-загрузки ────────────────────────────────────────────── - -# Верхняя граница цены при первом рекурсивном делении (нет явного hi). -# 200 млн ₽ заведомо выше ТОПа ЕКБ — дальние бакеты дадут 0 офферов. -_MAX_PRICE = 200_000_000 - -# Минимальный ценовой диапазон для рекурсии; при hi - lo < MIN_BRACKET -# прекращаем деление и пагинируем как есть (принимаем возможный «хвост»). -_MIN_BRACKET = 50_000 - -# Cian SERP показывает ~28 офферов на страницу. -_CIAN_OFFERS_PER_PAGE = 28 - -# Комнатности по умолчанию для exhaustive-загрузки. -# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+. -# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param. -# Если добавить room9 — запрос может давать 0 офферов или смешивать типы. -# До верификации кода студии — студии НЕ включаем в exhaustive-загрузку. -_DEFAULT_ROOMS_BUCKETS: list[tuple[int, ...]] = [ - (1,), - (2,), - (3,), - (4,), - (5,), - (6,), -] - - -class CianScraper(BaseScraper): - """Cian SERP scraper. Тянет SSR-HTML через BrowserFetcher (camoufox). - - Извлекает 137 полей на offer из Redux initialState через cian_state_parser. - Координаты точные — НЕТ anchor jitter (offer.geo.coordinates). - - Транспорт: одна BrowserFetcher(source="cian") сессия открывается в __aenter__ - и переиспользуется на всех page-fetch'ах (#1806). camoufox-fingerprint + мобильный - прокси server-side обходят Cian reCAPTCHA; IP ротирует сам прокси. - """ - - name = "cian" - # ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema sec 13, closed Q4) - base_url = "https://ekb.cian.ru" - # Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра. - request_delay_sec = 5.0 # консервативно: Cian менее агрессивен чем Avito, но 5s безопасно - - def __init__(self) -> None: - super().__init__() - self.request_delay_sec = get_scraper_delay(self.name) - self._browser: BrowserFetcher | None = None - - async def __aenter__(self) -> CianScraper: - await super().__aenter__() - # Одна BrowserFetcher(source="cian") сессия на весь scrape — camoufox real-browser - # fingerprint через tradein-browser сервис (#1806). Сервер роутит /fetch на - # отдельный браузер+мобильный прокси для cian. Прокси ротирует IP сам, поэтому - # code-side warm-up cookies и changeip-ротация больше не нужны. - self._browser = BrowserFetcher(source="cian") - await self._browser.__aenter__() - return self - - async def __aexit__(self, *args: Any) -> None: - if self._browser is not None: - await self._browser.__aexit__(*args) - self._browser = None - await super().__aexit__(*args) - - # ── Anti-block: IP rotation ─────────────────────────────────────────────── - - async def _rotate_ip(self) -> bool: - """No-op после миграции на BrowserFetcher (#1806). - - Мобильный прокси в браузер-контейнере ротирует IP сам, code-side changeip - больше не нужен. Метод сохранён (returns False) для обратной совместимости - с вызовом в _walk_price_range и тестами, которые его мокают. - """ - return False - - async def fetch_around( - self, - lat: float, - lon: float, - radius_m: int = 1000, - rooms: tuple[int, ...] | None = None, - page: int = 1, - ) -> list[ScrapedLot]: - """Найти объявления Циан по ЕКБ (lat/lon для reference; Cian не поддерживает bbox). - - Cian SERP отдаёт весь ЕКБ — фильтрация по радиусу происходит в postgres - через ST_DWithin после сохранения (точные coords из state). - - rooms — список из 1,2,3,4 (Cian's room codes). Если None — все. - page — страница выдачи (Cian ~28 объявлений на страницу). - """ - url = self._build_url(rooms, page) - try: - assert self._browser is not None - html = await self._browser.fetch(url) - except Exception: - logger.exception("cian BrowserFetcher fetch failed for url=%s", url) - return [] - - lots = self._parse_serp_html(html) - logger.info( - "cian: %d lots fetched rooms=%s page=%d url=%s", - len(lots), - rooms, - page, - url, - ) - await self.sleep_between_requests() - return lots - - async def fetch_around_multi_room( - self, - lat: float, - lon: float, - radius_m: int = 1000, - pages: int = 8, - ) -> list[ScrapedLot]: - """Скрейп Циан по 1к/2к/3к/4+ × N страниц — расширяет выборку. - - 4 комнаты × N страниц × ~28 = до ~330+ лотов до дедупликации. - """ - seen: dict[str, ScrapedLot] = {} - for rooms in ((1,), (2,), (3,), (4,)): - for page in range(1, pages + 1): - try: - lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms, page=page) - except Exception: - logger.exception("cian multi-room fetch failed rooms=%s page=%d", rooms, page) - continue - if not lots: - break # пустая страница → дальше смысла нет - for lot in lots: - key = lot.source_id or lot.source_url - if key and key not in seen: - seen[key] = lot - logger.info( - "cian multi-room: %d unique lots (lat=%.4f lon=%.4f radius=%dm)", - len(seen), - lat, - lon, - radius_m, - ) - return list(seen.values()) - - def _build_url( - self, - rooms: tuple[int, ...] | None = None, - page: int = 1, - min_price: int | None = None, - max_price: int | None = None, - ) -> str: - """URL для Cian каталога вторички ЕКБ. - - Используем ekb.cian.ru (city-specific subdomain). - Регион задаётся через region= param как fallback для reliability. - min_price / max_price — опциональные ценовые границы (руб.) для price-бакетов. - """ - params: list[tuple[str, Any]] = [ - ("deal_type", "sale"), - ("engine_version", "2"), - ("offer_type", "flat"), - ("region", CIAN_EKB_REGION_ID), - ("sort", "creation_date_desc"), - ] - if rooms: - for r in rooms: - params.append((f"room{r}", "1")) - if min_price is not None: - params.append(("minprice", min_price)) - if max_price is not None: - params.append(("maxprice", max_price)) - if page > 1: - params.append(("p", page)) - return f"{self.base_url}/cat.php?{urlencode(params)}" - - def _extract_total_offers(self, html: str) -> int | None: - """Извлечь totalOffers из Redux state Cian SERP. - - Использует тот же extract_state, что и _parse_serp_html. - Возвращает None при captcha/ошибке парсинга. - """ - state = extract_state(html, mfe=_MFE_SERP, key=_STATE_KEY) - if state is None: - return None - total = state.get("results", {}).get("totalOffers") - if total is None: - return None - try: - return int(total) - except (TypeError, ValueError): - return None - - async def _fetch_page_html( - self, - rooms: tuple[int, ...] | None, - page: int, - min_price: int | None, - max_price: int | None, - ) -> str | None: - """GET одной SERP-страницы через BrowserFetcher, возвращает HTML или None при ошибке.""" - assert self._browser is not None - url = self._build_url(rooms, page, min_price, max_price) - try: - return await self._browser.fetch(url) - except Exception: - logger.exception("cian: BrowserFetcher fetch failed url=%s", url) - return None - - async def fetch_all_secondary( - self, - *, - rooms_buckets: list[tuple[int, ...]] | None = None, - price_cap_per_bucket: int = 1400, - max_pages_per_bucket: int = 54, - concurrency: int = 5, - secondary_only: bool = True, - on_bucket: Callable[..., Any] | None = None, - on_progress: Callable[[int], None] | None = None, - skip_buckets: set[str] | None = None, - ) -> list[ScrapedLot]: - """Exhaustive-загрузка Cian ЕКБ вторички через партиционирование КОМНАТНОСТЬ × ЦЕНА. - - Обходит Cian SERP-cap (~54 стр/запрос ≈ 1500 результатов на запрос). Вместо - одной корень-бисекции [0, _MAX_PRICE] на комнатность (холостые probe-спуски - через пустой верх) — стартуем с общей data-driven seed-сетки ценовых брекетов - (get_price_seed_brackets() — shared EKB grid, общая с avito/yandex). Каждый - (комнатность × seed-брекет) дальше дробится _walk_price_range если - totalOffers > price_cap_per_bucket. Последний seed-брекет ОТКРЫТ (hi=None → - maxprice не ставится) — ловит весь хвост люкса без потолка. - Страницы внутри leaf-бакета запрашиваются параллельно (asyncio.gather + Semaphore). - Дедуп по source_id (dict seen), ОБЩИЙ по всем брекетам и комнатностям. - - Параметры: - rooms_buckets: список room-кодов Cian для перебора (default: 1-6). - price_cap_per_bucket: максимум офферов в бакете перед делением (< 1500). - max_pages_per_bucket: Cian hard cap ~54; не превышать. - concurrency: максимум параллельных page-фетчей в leaf-бакете (default=5). - secondary_only: если True (default) — отбрасывает новостройки - (listing_segment=="novostroyki") после парсинга, до save/on_bucket. - object_type=1 SERP-параметр Cian ненадёжен (~5% выдачи), поэтому - фильтруем по authoritative listing_segment из offer.newbuilding.id. - on_bucket: опциональный callback(bucket_key, list[ScrapedLot]) после каждого - leaf-бакета. Может быть async или sync. Если кидает исключение — прерывает прогон. - on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket). - skip_buckets: множество ключей «room_label:lo:hi» уже завершённых бакетов — - пагинация и on_bucket для них пропускаются. Probe-запросы (для split-решения) - всё равно выполняются (их мало по сравнению с пагинацией). - - Возвращает list[ScrapedLot] уникальных лотов (дедуп по source_id/source_url). - """ - _buckets = rooms_buckets if rooms_buckets is not None else _DEFAULT_ROOMS_BUCKETS - seen: dict[str, ScrapedLot] = {} - - for rooms in _buckets: - room_label = f"room{'_'.join(str(r) for r in rooms)}" - logger.info( - "cian exhaustive: starting %s (price_cap=%d concurrency=%d secondary_only=%s)", - room_label, - price_cap_per_bucket, - concurrency, - secondary_only, - ) - before = len(seen) - # Перебираем seed-брекеты: для ЗАКРЫТЫХ передаём hi = br_hi - 1 - # (maxprice эксклюзивный сверху → соседние брекеты не пересекаются), для - # ОТКРЫТОГО (br_hi is None) — hi=None (без потолка). Дедуп по source_id - # в общем `seen` страхует на стыках. - for br_lo, br_hi in get_price_seed_brackets(): - walk_hi = br_hi - 1 if br_hi is not None else None - await self._walk_price_range( - rooms=rooms, - lo=br_lo, - hi=walk_hi, - seen=seen, - price_cap_per_bucket=price_cap_per_bucket, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - secondary_only=secondary_only, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - ) - room_collected = len(seen) - before - logger.info( - "cian exhaustive: %s done — collected %d (total unique=%d)", - room_label, - room_collected, - len(seen), - ) - if on_progress is not None: - on_progress(len(seen)) - - logger.info("cian exhaustive: DONE — total unique=%d lots", len(seen)) - return list(seen.values()) - - async def _walk_price_range( - self, - *, - rooms: tuple[int, ...], - lo: int, - hi: int | None, - seen: dict[str, ScrapedLot], - price_cap_per_bucket: int, - max_pages_per_bucket: int, - concurrency: int = 5, - secondary_only: bool = True, - on_bucket: Callable[..., Any] | None = None, - skip_buckets: set[str] | None = None, - _depth: int = 0, - ) -> None: - """Рекурсивное адаптивное бинарное партиционирование ценового диапазона [lo, hi]. - - Алгоритм для ЗАКРЫТОГО брекета (hi is not None): - 1. Запросить page=1 с min_price=lo, max_price=hi → totalOffers. - 2. Если totalOffers <= cap → пагинировать leaf-бакет параллельно (concurrency). - 3. Если totalOffers > cap → разбить бакет пополам (рекурсия). - Guard: hi - lo < _MIN_BRACKET → пагинировать как есть (логируем WARNING). - - ОТКРЫТЫЙ брекет (hi is None) — верхний seed-брекет без потолка (maxprice не - ставится, Cian отдаёт всё ≥lo). Делить нельзя (mid=(lo+hi)//2 невозможен) — - probe + пагинируем leaf напрямую. Хвост 250М+ крошечный; за cap обычно не - выходит, если вышел — пагинируем до max_pages + WARNING (tail-loss accepted). - - После пагинации leaf-бакета вызывает on_bucket(bucket_key, bucket_lots) если задан. - bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый) — - checkpoint-ключ для resume. - on_bucket может быть async или sync. Исключение в on_bucket прерывает прогон. - skip_buckets: если bucket_key в skip_buckets — пагинацию и on_bucket пропускаем. - secondary_only: если True — новостройки (listing_segment=="novostroyki") отбрасываются - после сбора bucket_lots, до дедупа в seen и вызова on_bucket. - """ - room_label = f"room{'_'.join(str(r) for r in rooms)}" - _lo_param = lo if lo > 0 else None - _hi_repr = "open" if hi is None else str(hi) - - # ── Шаг 1: probe page 1 ──────────────────────────────────────────────── - html = await self._fetch_page_html(rooms, 1, _lo_param, hi) - await self.sleep_between_requests() - - total: int | None = None - if html is not None: - total = self._extract_total_offers(html) - - # Ретрай на captcha/ошибку: 1 повторный fetch. Мобильный прокси в браузер- - # контейнере ротирует IP сам (#1806), поэтому отдельный changeip-вызов - # (_rotate_ip) больше не нужен — просто повторяем запрос на чистом IP. - if total is None: - logger.warning( - "cian: totalOffers=None for %s [%d, %s] depth=%d — retry (proxy auto-rotates IP)", - room_label, - lo, - _hi_repr, - _depth, - ) - await self._rotate_ip() # no-op (back-compat); прокси ротирует IP сам - html = await self._fetch_page_html(rooms, 1, _lo_param, hi) - await self.sleep_between_requests() - if html is not None: - total = self._extract_total_offers(html) - - if total is None: - logger.error( - "cian: skipping bucket %s [%d, %s] — totalOffers unavailable after retry", - room_label, - lo, - _hi_repr, - ) - return - - logger.info( - "cian: %s [%d, %s] totalOffers=%d depth=%d", - room_label, - lo, - _hi_repr, - total, - _depth, - ) - - if total == 0: - return - - # ── ОТКРЫТЫЙ брекет (hi is None): делить нельзя — пагинируем leaf напрямую ─ - # total известен из probe → max_pages = min(ceil(total/per_page), cap). - # Если total > cap (не должно для 250М+, но guard) — пагинируем как есть до - # max_pages_per_bucket с WARNING (хвост люкса крошечный, tail-loss accepted). - if hi is None: - if total > price_cap_per_bucket: - logger.warning( - "cian: OPEN bucket %s [%d, open] totalOffers=%d > cap=%d — paginating " - "without split (tail-loss accepted, lux tail tiny)", - room_label, - lo, - total, - price_cap_per_bucket, - ) - await self._paginate_open_bucket( - rooms=rooms, - room_label=room_label, - lo=lo, - html=html, - total=total, - seen=seen, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - secondary_only=secondary_only, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - ) - return - - # ── Шаг 2: деление или пагинация (только ЗАКРЫТЫЙ брекет, hi: int) ───── - bracket_size = hi - lo - need_split = total > price_cap_per_bucket - too_narrow = bracket_size < _MIN_BRACKET - - if need_split and too_narrow: - logger.warning( - "cian: %s [%d, %d] totalOffers=%d > cap=%d but bracket=%d < MIN_BRACKET=%d " - "— paginating as-is (tail loss ~%d)", - room_label, - lo, - hi, - total, - price_cap_per_bucket, - bracket_size, - _MIN_BRACKET, - max(0, total - price_cap_per_bucket), - ) - need_split = False # принудительно пагинируем - - if need_split: - mid = (lo + hi) // 2 - # [lo, mid] - await self._walk_price_range( - rooms=rooms, - lo=lo, - hi=mid, - seen=seen, - price_cap_per_bucket=price_cap_per_bucket, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - secondary_only=secondary_only, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - _depth=_depth + 1, - ) - # [mid+1, hi] - await self._walk_price_range( - rooms=rooms, - lo=mid + 1, - hi=hi, - seen=seen, - price_cap_per_bucket=price_cap_per_bucket, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - secondary_only=secondary_only, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - _depth=_depth + 1, - ) - return - - # Закрытый leaf-бакет — общая пагинация (см. _paginate_leaf_bucket). - await self._paginate_leaf_bucket( - rooms=rooms, - room_label=room_label, - lo=lo, - hi=hi, - html=html, - total=total, - seen=seen, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - secondary_only=secondary_only, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - ) - - async def _paginate_open_bucket( - self, - *, - rooms: tuple[int, ...], - room_label: str, - lo: int, - html: str | None, - total: int, - seen: dict[str, ScrapedLot], - max_pages_per_bucket: int, - concurrency: int, - secondary_only: bool, - on_bucket: Callable[..., Any] | None, - skip_buckets: set[str] | None, - ) -> None: - """Пагинация ОТКРЫТОГО leaf-бакета (hi=None, без maxprice).""" - await self._paginate_leaf_bucket( - rooms=rooms, - room_label=room_label, - lo=lo, - hi=None, - html=html, - total=total, - seen=seen, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - secondary_only=secondary_only, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - ) - - async def _paginate_leaf_bucket( - self, - *, - rooms: tuple[int, ...], - room_label: str, - lo: int, - hi: int | None, - html: str | None, - total: int, - seen: dict[str, ScrapedLot], - max_pages_per_bucket: int, - concurrency: int, - secondary_only: bool, - on_bucket: Callable[..., Any] | None, - skip_buckets: set[str] | None, - ) -> None: - """Параллельная пагинация leaf-бакета [lo, hi] (закрытого или открытого). - - ОТКРЫТЫЙ (hi is None): maxprice не ставится (_build_url отдаёт всё ≥lo), - bucket_key = "room_label:lo:open". Страница 1 берётся из probe-`html`. - """ - _lo_param = lo if lo > 0 else None - _hi_repr = "open" if hi is None else str(hi) - bucket_key = f"{room_label}:{lo}:{_hi_repr}" - - # Если бакет уже завершён в предыдущем запуске — пропускаем пагинацию и on_bucket. - if skip_buckets and bucket_key in skip_buckets: - logger.info( - "cian: skip bucket %s — already done (resume)", - bucket_key, - ) - return - - pages_needed = math.ceil(total / _CIAN_OFFERS_PER_PAGE) - max_pages = min(pages_needed, max_pages_per_bucket) - - if pages_needed > max_pages_per_bucket: - # Cian hard-cap срабатывает: часть офферов недостижима через пагинацию. - # Это происходит когда bracket < _MIN_BRACKET но totalOffers всё равно > cap. - tail_loss = total - max_pages_per_bucket * _CIAN_OFFERS_PER_PAGE - logger.warning( - "cian: %s [%d, %s] totalOffers=%d exceeds page cap " - "(max_pages=%d × %d=%d offers) — tail loss ~%d offers, " - "consider narrowing price_cap_per_bucket or _MIN_BRACKET", - room_label, - lo, - _hi_repr, - total, - max_pages_per_bucket, - _CIAN_OFFERS_PER_PAGE, - max_pages_per_bucket * _CIAN_OFFERS_PER_PAGE, - tail_loss, - ) - - # Страница 1 уже есть (html из probe выше); остальные — параллельно. - sem = asyncio.Semaphore(concurrency) - - async def _one_page(p: int) -> list[ScrapedLot]: - if p == 1: - # Используем уже полученный HTML от probe - return self._parse_serp_html(html) if html else [] - async with sem: - page_html = await self._fetch_page_html(rooms, p, _lo_param, hi) - await asyncio.sleep(self.request_delay_sec) - if page_html is None: - logger.warning( - "cian: page_html=None %s [%d, %s] page=%d — skipping page", - room_label, - lo, - _hi_repr, - p, - ) - return [] - return self._parse_serp_html(page_html) - - page_results = await asyncio.gather( - *[_one_page(p) for p in range(1, max_pages + 1)], - return_exceptions=True, - ) - - bucket_lots: list[ScrapedLot] = [] - for p_idx, res in enumerate(page_results, start=1): - if isinstance(res, BaseException): - logger.warning( - "cian: page exception %s [%d, %s] page=%d — %r", - room_label, - lo, - _hi_repr, - p_idx, - res, - ) - else: - bucket_lots.extend(res) - - collected_this_bucket = len(bucket_lots) - - # ── Фильтр новостроек (secondary_only) ──────────────────────────────── - dropped_nb = 0 - if secondary_only: - filtered = [lot for lot in bucket_lots if lot.listing_segment != "novostroyki"] - dropped_nb = collected_this_bucket - len(filtered) - bucket_lots = filtered - - # Дедуп в общий seen - for lot in bucket_lots: - key = lot.source_id or lot.source_url - if key: - seen[key] = lot - - logger.info( - "cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d", - room_label, - lo, - _hi_repr, - max_pages, - collected_this_bucket, - dropped_nb, - len(seen), - ) - - # ── on_bucket callback: инкрементальный save ────────────────────────── - if on_bucket is not None and bucket_lots: - res_cb = on_bucket(bucket_key, bucket_lots) - if inspect.isawaitable(res_cb): - await res_cb - - def _parse_serp_html(self, html: str) -> list[ScrapedLot]: - """Извлечь offers из Cian Redux state. - - Использует cian_state_parser.extract_state() — общая утилита Stage 2. - Возвращает пустой список если state не найден. - """ - state = extract_state(html, mfe=_MFE_SERP, key=_STATE_KEY) - if state is None: - logger.warning( - "cian SERP state extraction failed (mfe=%s key=%s) — " - "возможно Cian изменил структуру или вернул captcha", - _MFE_SERP, - _STATE_KEY, - ) - return [] - - offers_data: list[dict[str, Any]] = state.get("results", {}).get("offers", []) - if not offers_data: - logger.warning( - "cian state found but results.offers пуст (totalOffers=%s)", - state.get("results", {}).get("totalOffers", "?"), - ) - return [] - - logger.info( - "cian SERP state ok: %d offers (totalOffers=%s)", - len(offers_data), - state.get("results", {}).get("totalOffers", "?"), - ) - - lots: list[ScrapedLot] = [] - for offer in offers_data: - lot = self._offer_to_lot(offer) - if lot is not None: - lots.append(lot) - - raw_count = len(offers_data) - saved_count = len(lots) - # Охрана от silent-failure: если получили offers, но ни один не прошёл парсинг - # — likely schema regression (как descriptionMinhash str→list[int] 2026-05-23). - if raw_count > 0 and saved_count == 0: - logger.error( - "cian SERP: 0/%d offers прошли _offer_to_lot — возможна schema regression", - raw_count, - ) - try: - if settings.glitchtip_dsn: - sentry_sdk.capture_message( - f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot" - " — possible schema regression", - level="error", - ) - except Exception: - pass # sentry_sdk not initialised in dev - - return lots - - def _offer_to_lot(self, offer: dict[str, Any]) -> ScrapedLot | None: - """Парсинг одного Cian offer (137 fields) → ScrapedLot. - - Маппинг полей per Schema_Cian_SERP_Inventory sec 3, sec 8, sec 11. - Без anchor jitter: координаты из offer.geo.coordinates (точные). - """ - try: - # ── Identity ───────────────────────────────────────────────────── - offer_id = offer.get("cianId") or offer.get("id") - if not offer_id: - logger.debug("cian offer пропущен: нет cianId/id") - return None - - source_id = str(offer_id) - url = offer.get("fullUrl") - if not url: - url = f"{self.base_url}/sale/flat/{offer_id}/" - - # ── Цена ───────────────────────────────────────────────────────── - bargain: dict[str, Any] = offer.get("bargainTerms") or {} - price = bargain.get("priceRur") or bargain.get("price") - if not price: - logger.debug("cian offer %s пропущен: нет цены", offer_id) - return None - try: - price_rub = int(price) - except (TypeError, ValueError): - logger.debug("cian offer %s: не удалось распарсить цену %r", offer_id, price) - return None - if price_rub <= 0: - return None - - # ── Параметры квартиры ─────────────────────────────────────────── - rooms: int | None = offer.get("roomsCount") - # Студия: flatType == 'studio' → 0 комнат - if offer.get("flatType") == "studio" and rooms is None: - rooms = 0 - - area_raw = offer.get("totalArea") - area_m2: float | None = None - if area_raw is not None: - try: - area_m2 = float(area_raw) - except (TypeError, ValueError): - pass - - living_area_raw = offer.get("livingArea") - living_area_m2: float | None = None - if living_area_raw is not None: - try: - living_area_m2 = float(living_area_raw) - except (TypeError, ValueError): - pass - - # kitchen_area: сырое (str) в raw_payload + промоутим float в ScrapedLot - # колонку kitchen_area_m2 (#2008, Class B — дашборд/matching, не valuation). - kitchen_area_raw = offer.get("kitchenArea") - kitchen_area_m2: float | None = None - if kitchen_area_raw is not None: - try: - kitchen_area_m2 = float(kitchen_area_raw) - except (TypeError, ValueError): - pass - - floor: int | None = offer.get("floorNumber") - - # ── Здание ─────────────────────────────────────────────────────── - building: dict[str, Any] = offer.get("building") or {} - total_floors: int | None = building.get("floorsCount") - year_built: int | None = building.get("buildYear") - # house_type: cian materialType (camelCase: monolithBrick / gasSilicateBlock - # / stalin / ...) НЕ равен каноничному enum → estimator soft-penalty ложно - # штрафовал аналог. Нормализуем на ингесте (#2008, Class A); сырьё — в - # raw_payload['raw_material_type'] для аудита. - raw_material_type: str | None = building.get("materialType") - house_type: str | None = normalize_house_type(raw_material_type) - - # Newbuilding deadline year — если нет buildYear - if year_built is None: - deadline = building.get("deadline") or {} - year_built = deadline.get("year") - - # ── Кадастр ───────────────────────────────────────────────────── - # cadastralNumber — кадастр КВАРТИРЫ - cadastral_number: str | None = offer.get("cadastralNumber") - # buildingCadastralNumber — кадастр ДОМА - building_cadastral_number: str | None = offer.get("buildingCadastralNumber") - - # ── Геолокация (точные координаты — NO jitter) ─────────────────── - geo: dict[str, Any] = offer.get("geo") or {} - coords: dict[str, Any] = geo.get("coordinates") or {} - lat: float | None = coords.get("lat") - lon: float | None = coords.get("lng") - # Если нет coords → сохраняем без lat/lon (geocode-missing обработает позже) - if lat is None or lon is None: - logger.debug( - "cian offer %s: нет точных координат в state (geo=%s)", - offer_id, - coords, - ) - lat = lon = None - - # ── Адрес из geo.address[] ─────────────────────────────────────── - # newbuilding нужен ДО _format_address: для ЖК geo.address[] часто без - # части {type:"house"} → дом-номер восстанавливаем из building/кадастра/ - # названия ЖК (#1773), иначе все корпуса схлопываются в house-less - # catch-all и same-building anchor рвётся. - address_parts = geo.get("address") or [] - newbuilding: dict[str, Any] = offer.get("newbuilding") or {} - nb_id = newbuilding.get("id") - is_newbuilding = bool(nb_id) and int(nb_id) > 0 - address = _format_address( - address_parts, - building=building, - building_cadastral_number=building_cadastral_number, - newbuilding=newbuilding, - is_newbuilding=is_newbuilding, - ) - - # ── Metro stations ─────────────────────────────────────────────── - undergrounds: list[dict[str, Any]] = geo.get("undergrounds") or [] - metro_stations = [ - { - "name": u.get("name"), - "time": u.get("time"), - "mode": u.get("transportType"), - "line_color": u.get("lineColor"), - "line_id": u.get("lineId"), - "is_default": u.get("isDefault", False), - } - for u in undergrounds - if u.get("name") - ] - - # ── Newbuilding link ───────────────────────────────────────────── - # newbuilding / nb_id / is_newbuilding уже вычислены выше (до адреса). - # id == 0 — sentinel для вторички (per Schema sec 20.5) - house_source: str | None = None - house_ext_id: str | None = None - listing_segment: str | None = None - - if is_newbuilding: - # Предпочитаем кадастр ДОМА как same-building anchor (#1773): nb_id - # группирует по ЖК целиком, а один ЖК — это несколько корпусов - # (6679/11110/7019). buildingCadastralNumber стабильно идентифицирует - # ФИЗИЧЕСКИЙ дом → Tier 0 cadastr_exact в match_or_create_house - # схлопывает раздробленные корпуса. nb_id оставляем fallback'ом. - if building_cadastral_number: - house_source = "cian_building_cad" - house_ext_id = building_cadastral_number - else: - house_source = "cian_newbuilding" - house_ext_id = str(nb_id) - listing_segment = "novostroyki" - else: - house_source = "cian" - listing_segment = "vtorichka" - - # ── Дополнительные характеристики ──────────────────────────────── - balconies_count: int | None = offer.get("balconiesCount") - loggias_count: int | None = offer.get("loggiasCount") - bedrooms_count: int | None = offer.get("bedroomsCount") - - has_balcony: bool | None = None - if balconies_count is not None: - has_balcony = balconies_count > 0 - elif loggias_count is not None: - has_balcony = loggias_count > 0 - - # Мебель и repair_state - # hasFurniture (мебель вообще) и isSoldFurnished (продаётся с мебелью) — - # разная семантика, нельзя OR-склеивать: явный False у hasFurniture - # затирался бы коалесингом. Берём hasFurniture; только если он None, - # падаем на isSoldFurnished как fallback (#1388). - _has_furniture = offer.get("hasFurniture") - has_furniture: bool | None = ( - _has_furniture if _has_furniture is not None else offer.get("isSoldFurnished") - ) - # decoration — для новостроек (отделка); repair_state — для вторички - # Cian SERP: offer.decoration содержит raw значения (without/cosmetic/euro/design/...) - # Нормализуем до enum needs_repair/standard/good/excellent при инgesте. - repair_state: str | None = normalize_repair_state(offer.get("decoration")) - - # ── Продавец ───────────────────────────────────────────────────── - # PII purge (#2217): телефоны продавцов больше не сохраняем. - # ScrapedLot.phones остаётся в модели (см. base.py), но на вход - # всегда передаём пустой список — write-path на listings.phones - # выключен здесь, а не на уровне upsert. - phones: list[dict[str, Any]] = [] - is_homeowner: bool | None = offer.get("isByHomeowner") - is_pro_seller: bool | None = offer.get("isPro") - - # ── Сделка ─────────────────────────────────────────────────────── - sale_type: str | None = bargain.get("saleType") - bargain_allowed: bool | None = bargain.get("bargainAllowed") - - # ── Class B promote (#2008): ипотека / апартаменты / проверка ЕГРН ── - # Раньше лежали только в raw_payload; промоутим в колонки listings для - # дашборда покрытия и matching (НЕ для valuation — estimator их не читает). - mortgage_available: bool | None = bargain.get("mortgageAllowed") - is_apartments: bool | None = offer.get("isApartments") - is_rosreestr_checked: bool | None = offer.get("isRosreestrChecked") - - # ── Description + minhash ───────────────────────────────────────── - description: str | None = offer.get("description") - # Cian предоставляет готовый minhash для dedup и cross-source matching. - # С ~2026-05-23 Cian изменил тип descriptionMinhash: str → list[int]. - # Нормализуем оба варианта; НЕ sorted() — порядок band-ов значим для LSH. - _raw_minhash = offer.get("descriptionMinhash") - if isinstance(_raw_minhash, list): - description_minhash: str | None = ",".join(str(x) for x in _raw_minhash) or None - elif isinstance(_raw_minhash, str): - description_minhash = _raw_minhash or None - else: - description_minhash = None - # Если Cian не дал minhash — вычисляем простой SHA1 из description - if not description_minhash and description: - description_minhash = hashlib.sha1( - description.lower().encode("utf-8", errors="replace") - ).hexdigest()[:32] - - # Fallback: repair_state из описания, если decoration отсутствует (#622) - if repair_state is None and description: - repair_state = infer_repair_state_from_text(description) - - # ── Фото ───────────────────────────────────────────────────────── - photo_urls: list[str] = [ - p["fullUrl"] - for p in (offer.get("photos") or []) - if isinstance(p, dict) and p.get("fullUrl") - ] - - # ── Дата публикации ─────────────────────────────────────────────── - added_ts = offer.get("addedTimestamp") - listing_date = None - if added_ts: - try: - listing_date = datetime.fromtimestamp(int(added_ts), tz=UTC).date() - except (TypeError, ValueError, OSError): - pass - - # ── Raw payload (полный offer для enrichment в будущем) ─────────── - raw_payload: dict[str, Any] = { - "cian_id": offer_id, - "flat_type": offer.get("flatType"), - "is_apartments": offer.get("isApartments"), - "offer_type": offer.get("offerType"), - "category": offer.get("category"), - "has_furniture": has_furniture, - # сырой kitchenArea (str) — колонка kitchen_area_m2 (float) промоучена выше - "kitchen_area_m2": kitchen_area_raw, - # сырой materialType (camelCase) — house_type нормализован выше (#2008) - "raw_material_type": raw_material_type, - "mortgage_allowed": bargain.get("mortgageAllowed"), - "sale_type": sale_type, - "newbuilding_name": newbuilding.get("name"), - "is_from_developer": ( - offer.get("fromDeveloper") or newbuilding.get("isFromDeveloper") - ), - "builders_ids": offer.get("buildersIds"), - "is_rosreestr_checked": offer.get("isRosreestrChecked"), - "is_layout_approved": offer.get("isLayoutApproved"), - "user_id": offer.get("userId"), - "published_user_id": offer.get("publishedUserId"), - "is_by_commercial_owner": offer.get("isByCommercialOwner"), - "is_cian_partner": offer.get("isCianPartner"), - "description_words_highlighted": offer.get("descriptionWordsHighlighted"), - "building_parking": building.get("parking"), - "building_total_area": building.get("totalArea"), - } - - return ScrapedLot( - source="cian", - source_url=url, - source_id=source_id, - address=address, - lat=lat, - lon=lon, - rooms=rooms, - area_m2=area_m2, - floor=floor, - total_floors=total_floors, - year_built=year_built, - house_type=house_type, - repair_state=repair_state, - has_balcony=has_balcony, - kitchen_area_m2=kitchen_area_m2, - mortgage_available=mortgage_available, - is_apartments=is_apartments, - is_rosreestr_checked=is_rosreestr_checked, - kadastr_num=cadastral_number, - house_source=house_source, - house_ext_id=house_ext_id, - listing_segment=listing_segment, - price_rub=price_rub, - price_per_m2=None, # compute_price_per_m2() вычислит - listing_date=listing_date, - photo_urls=photo_urls, - raw_payload=raw_payload, - # Cian-specific (Stage 2 fields) - living_area_m2=living_area_m2, - bedrooms_count=bedrooms_count, - balconies_count=balconies_count, - loggias_count=loggias_count, - description_minhash=description_minhash, - cadastral_number=cadastral_number, - building_cadastral_number=building_cadastral_number, - phones=phones, - is_homeowner=is_homeowner, - is_pro_seller=is_pro_seller, - bargain_allowed=bargain_allowed, - sale_type=sale_type, - metro_stations=metro_stations, - ) - - except Exception: - _oid = offer.get("cianId") or offer.get("id") - logger.exception("cian _offer_to_lot failed for offer_id=%s", _oid) - return None - - -# ── Address formatter ──────────────────────────────────────────────────────── - -# Дом-токен в хвосте строки: «29», «29А», «12 к1», «5/2», «10 стр.3», «1С7». -# Якорим в конец строки (\Z) — берём именно завершающий номер, не цифры из -# названия ЖК («Суходольский квартал»). Не матчим «р-н», «мкр» и пр. слова. -_HOUSE_TOKEN_RE = re.compile( - r"(\d+[А-Яа-яA-Za-z]?" # базовый номер: 29 / 29А / 1С - r"(?:\s*(?:к|корп\.?|стр\.?|с)\s*\d+[А-Яа-яA-Za-z]?)?" # корпус/строение - r"(?:\s*/\s*\d+[А-Яа-яA-Za-z]?)?)" # дробь 5/2 - r"\s*\Z" -) - - -def _recover_newbuilding_house( - *, - building: dict[str, Any] | None, - building_cadastral_number: str | None, - newbuilding: dict[str, Any] | None, -) -> str | None: - """Восстановить дом-токен для новостройки, когда geo.address[] без {type:"house"}. - - Приоритет (#1773): - (a) building.houseNumber / building.address — если SERP-state их отдал; - (b) building_cadastral_number — стабильный same-building anchor: если ни - номер, ни название не дали дом, добавляем «кад. 66:41:...:11396» как - ключ-якорь, чтобы раздробленные корпуса схлопывались по кадастру; - (c) хвостовой дом-токен из newbuilding.name («… 29» / «… 29А к1»). - - НЕ выдумываем catch-all номер: если ничего из (a)/(b)/(c) не нашли — None. - Кадастр (b) предпочтительнее как house anchor (см. _offer_to_lot). - """ - building = building or {} - newbuilding = newbuilding or {} - - # (a) building.houseNumber / building.address — прямые поля SERP-state - house_number = building.get("houseNumber") - if house_number is not None: - token = str(house_number).strip() - if token: - return token - b_addr = (building.get("address") or "").strip() - if b_addr: - m = _HOUSE_TOKEN_RE.search(b_addr) - if m: - return m.group(1).strip() - - # (c) хвостовой дом-токен из названия ЖК (до кадастра — он читабельнее) - nb_name = (newbuilding.get("name") or "").strip() - if nb_name: - m = _HOUSE_TOKEN_RE.search(nb_name) - if m: - return m.group(1).strip() - - # (b) кадастр дома как anchor-ключ (последний приоритет в строке адреса, - # но самый надёжный для матчинга — Tier 0 cadastr_exact) - if building_cadastral_number: - cad = building_cadastral_number.strip() - if cad: - return f"кад. {cad}" - - return None - - -def _format_address( - address_parts: list[dict[str, Any]], - *, - building: dict[str, Any] | None = None, - building_cadastral_number: str | None = None, - newbuilding: dict[str, Any] | None = None, - is_newbuilding: bool = False, -) -> str: - """Сформировать читаемый адрес из geo.address[] Cian. - - Пропускаем location (страна/регион) и metro — берём раион/улицу/дом. - Пример: [location=Москва, raion=Пресненский, street=..., house=1С7, metro=Москва-Сити] - → 'Пресненский, улица ..., 1С7' - - #1773: для новостроек (is_newbuilding) geo.address[] часто без {type:"house"}. - Если части house нет — восстанавливаем номер дома через _recover_newbuilding_house - (building → название ЖК → кадастр) и дописываем его в конец строки, чтобы - same-building anchor (fingerprint/кадастр) не рвался по house-less улице. - """ - if not address_parts: - return "Екатеринбург (Cian)" - - skip_types = {"location", "metro"} - parts: list[str] = [] - has_house = False - for part in address_parts: - if not isinstance(part, dict): - continue - ptype = part.get("type", "") - if ptype == "house": - has_house = True - if ptype in skip_types: - continue - name = (part.get("fullName") or part.get("name") or "").strip() - if name: - parts.append(name) - - # Восстановление дома только для новостроек без части house — вторичка с - # house-less адресом не трогается (не выдумываем номер). - if is_newbuilding and not has_house: - recovered = _recover_newbuilding_house( - building=building, - building_cadastral_number=building_cadastral_number, - newbuilding=newbuilding, - ) - if recovered: - parts.append(recovered) - - return ", ".join(parts) if parts else "Екатеринбург (Cian)" diff --git a/tradein-mvp/backend/app/services/scrapers/cian_detail.py b/tradein-mvp/backend/app/services/scrapers/cian_detail.py deleted file mode 100644 index d3b03742..00000000 --- a/tradein-mvp/backend/app/services/scrapers/cian_detail.py +++ /dev/null @@ -1,464 +0,0 @@ -"""Cian.ru detail page scraper — single offer enrichment. - -Different from SERP (cian.py): -- URL: https://ekb.cian.ru/sale/flat// or https://www.cian.ru/sale/flat// -- MFE: 'frontend-offer-card' -- State KEY: 'defaultState' (NOT 'initialState' — SERP uses initialState) -- Sister containers in same _cianConfig: bti, priceChanges, stats, agent, newObject - -Parses ~88 offer fields + sister data → DetailEnrichment dataclass. -Stage 5 of CianScraper v1. -""" - -from __future__ import annotations - -import json -import logging -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any - -from curl_cffi.requests import AsyncSession -from sqlalchemy import text -from sqlalchemy.orm import Session - -from app.core.config import settings -from app.services.scrapers.cian_state_parser import extract_all_states, extract_state -from app.services.scrapers.repair_state_normalizer import ( - infer_repair_state_from_text, - normalize_repair_state, -) -from app.services.scrapers.snapshot_writer import upsert_listing_snapshot - -if TYPE_CHECKING: - from app.services.scrapers.browser_fetcher import BrowserFetcher - -logger = logging.getLogger(__name__) - -# offer_price_history.diff_percent — NUMERIC(8,2) (миграция -# 131_fix_diff_percent_overflow.sql). Прямой INSERT ниже читает diff_percent из -# скрапленного Cian JSON и не проходит через БД-триггер record_listing_price_change() -# (тот срабатывает только на UPDATE listings) — гарбадж/экстремальное значение -# с редизайна source-страницы иначе переполняет колонку. -_DIFF_PERCENT_BOUND = 999999.99 - - -def _clamp_diff_percent(value: float | int | None) -> float | None: - """Клампит diff_percent в границы NUMERIC(8,2) (зеркало migration 131 trigger-clamp).""" - if value is None or isinstance(value, bool): - return None - try: - numeric = float(value) - except (TypeError, ValueError): - return None - return min(max(numeric, -_DIFF_PERCENT_BOUND), _DIFF_PERCENT_BOUND) - - -@dataclass -class DetailEnrichment: - """Result of cian_detail.fetch_detail() — fields для UPDATE listings + sister rows.""" - - # Core listing enrichment (extend existing listings row) - cian_id: int | None = None - windows_view_type: str | None = None # 'yard' / 'street' / 'yardAndStreet' - separate_wcs_count: int | None = None - combined_wcs_count: int | None = None - ceiling_height: float | None = None # meters — offer.building.ceilingHeight (#1791) - repair_type: str | None = None # raw Cian: offer.repairType OR offer.decoration (#1791) - repair_state: str | None = None # enum: needs_repair/standard/good/excellent - kitchen_area_m2: float | None = None # offer.kitchenArea (м²) - description: str | None = None # offer.description (текст объявления) - views_total: int | None = None # Cian stats.totalViewsFormattedString → int - views_today: int | None = None - - # BTI (вторичка only — primary buildings don't have БТИ) - bti_data: dict[str, Any] | None = None # raw bti.houseData snapshot - - # Price history (offer.priceChanges) - price_changes: list[dict[str, Any]] = field(default_factory=list) - - # Agent profile (offer.agent.* expanded) - agent_profile: dict[str, Any] | None = None - - # Newbuilding link (if offer is from a newbuilding) - newbuilding_data: dict[str, Any] | None = None - - # Raw payload для debugging / future-extraction - raw_offer: dict[str, Any] | None = None - raw_sister_states: dict[str, Any] = field(default_factory=dict) - - -async def fetch_detail( - offer_url: str, - *, - session: AsyncSession | None = None, - browser_fetcher: BrowserFetcher | None = None, -) -> DetailEnrichment | None: - """Fetch detail page and extract all containers. - - Args: - offer_url: e.g. 'https://ekb.cian.ru/sale/flat/327830237/' - session: optional shared curl_cffi session (для batched scrapes). - Ignored when browser_fetcher is provided. - browser_fetcher: optional BrowserFetcher instance (camoufox HTTP service). - When provided, fetches JS-rendered HTML via the browser service instead of - curl_cffi — enables priceChanges extraction which requires JS rendering. - Caller is responsible for the context-manager lifecycle of the fetcher. - - Returns: DetailEnrichment, or None если fetch / parse failed. - """ - if browser_fetcher is not None: - # Browser path: get fully JS-rendered HTML; same parse path follows. - try: - html = await browser_fetcher.fetch(offer_url) - except Exception as exc: - logger.warning("Cian detail browser fetch failed %s: %s", offer_url, exc) - return None - else: - # curl_cffi path (legacy, back-compat). - close_session = False - if session is None: - # proxies: mobile-proxy egress (#806) — без прокси datacenter-IP блокируется Cian. - # Пусто (env не задан) → прямое подключение (dev/no-op). - _proxy_url = settings.cian_proxy_url - _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None - session = AsyncSession( - impersonate="chrome120", - timeout=25.0, - proxies=_proxies, - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", - }, - ) - close_session = True - - try: - resp = await session.get(offer_url, allow_redirects=True) - if resp.status_code != 200: - logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code) - return None - html = resp.text - finally: - if close_session: - await session.close() - - # ── Shared parse path (identical regardless of HTML source) ────────────── - - # Primary state container — defaultState in frontend-offer-card MFE - # NOTE: detail pages use 'defaultState', SERP uses 'initialState' - offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState") - if offer_state is None: - logger.warning("Cian detail %s: defaultState extraction failed", offer_url) - return None - - offer_data = offer_state.get("offerData", {}) - offer = offer_data.get("offer", {}) - - result = DetailEnrichment( - cian_id=offer.get("cianId") or offer.get("id"), - windows_view_type=_extract_windows_view(offer), - separate_wcs_count=offer.get("separateWcsCount"), - combined_wcs_count=offer.get("combinedWcsCount"), - ceiling_height=_extract_ceiling_height(offer), - repair_type=_extract_repair_type(offer), - repair_state=_extract_repair_state(offer), - kitchen_area_m2=_parse_float(offer.get("kitchenArea")), - description=offer.get("description") or None, - raw_offer=offer, - ) - - # Stats (views) — from stats key in offerData or top-level state - stats = offer_data.get("stats") or offer_state.get("stats") or {} - result.views_total = _parse_views(stats.get("totalViewsFormattedString")) - result.views_today = _parse_views(stats.get("todayViewsFormattedString")) - - # Price changes — browser HTML: offerData.priceChanges (priceData.price format) - # curl_cffi HTML: offer.priceChanges or state.priceChanges (flat price field) - result.price_changes = _extract_price_changes(offer, offer_data, offer_state) - - # Agent profile expansion - result.agent_profile = _extract_agent_profile(offer) - - # Sister state containers: bti, newObject, plus raw stash of everything else - all_states = extract_all_states(html) - offer_card_states = all_states.get("frontend-offer-card", {}) - - bti_state = offer_card_states.get("bti") - if bti_state: - result.bti_data = bti_state.get("houseData") - result.raw_sister_states["bti"] = bti_state - - # Newbuilding link (if applicable) - newbuilding = offer.get("newbuilding") - if newbuilding and newbuilding.get("id"): - result.newbuilding_data = newbuilding - - # Stash all non-defaultState sister containers for debugging / future-extraction - for k, v in offer_card_states.items(): - if k != "defaultState": - result.raw_sister_states[k] = v - - return result - - -# ── Field extractors ────────────────────────────────────────────────────────── - - -def _extract_windows_view(offer: dict[str, Any]) -> str | None: - return offer.get("windowsViewType") - - -def _extract_ceiling_height(offer: dict[str, Any]) -> float | None: - """Высота потолков (м). - - На detail-странице (defaultState) значение лежит в ``offer.building.ceilingHeight``, - а НЕ в ``offer.ceilingHeight`` (последнее всегда null) — #1791. Top-level оставлен - как fallback на случай иной структуры карточки. - """ - building = offer.get("building") or {} - return _parse_float(building.get("ceilingHeight") or offer.get("ceilingHeight")) - - -def _extract_repair_type(offer: dict[str, Any]) -> str | None: - # Cian: 'cosmetic' / 'design' / 'no' / 'euro' / ... - # На detail-странице структурное поле чаще приходит как `decoration` - # ('preFine'/'fine'/...), а `repairType` пустое (#1791) — читаем оба. - return offer.get("repairType") or offer.get("decoration") - - -def _extract_repair_state(offer: dict[str, Any]) -> str | None: - """Нормализованный enum из repairType/decoration → needs_repair/standard/good/excellent. - - Если структурное поле пустое — fallback на инференс из описания (#622). - Структурное значение всегда в приоритете. - """ - state = normalize_repair_state(offer.get("repairType") or offer.get("decoration")) - if state is None: - state = infer_repair_state_from_text(offer.get("description")) - return state - - -def _parse_float(value: Any) -> float | None: - if value is None: - return None - try: - return float(value) - except (ValueError, TypeError): - return None - - -def _parse_views(formatted_str: str | None) -> int | None: - """Cian's stats.totalViewsFormattedString — '1 234' → 1234.""" - if not formatted_str: - return None - try: - return int(str(formatted_str).replace(" ", "").replace(",", "")) - except (ValueError, TypeError): - return None - - -def _extract_price_changes( - offer: dict[str, Any], - offer_data: dict[str, Any], - state: dict[str, Any], -) -> list[dict[str, Any]]: - """Normalize priceChanges from any of the known Cian locations/formats. - - Lookup priority: - 1. offer.priceChanges — curl_cffi flat format: {changeTime, price, diffPercent} - 2. offerData.priceChanges — browser-rendered format: {changeTime, priceData:{price}} - 3. state.priceChanges — top-level fallback (rare) - - Normalizes to: {change_time, price_rub, diff_percent}. - """ - raw = ( - offer.get("priceChanges") - or offer_data.get("priceChanges") - or state.get("priceChanges") - or [] - ) - if not isinstance(raw, list): - return [] - result = [] - for entry in raw: - if not isinstance(entry, dict): - continue - # price_rub: flat field (curl_cffi) OR nested priceData.price (browser) - price_data = entry.get("priceData") or {} - price_rub = entry.get("price") or entry.get("priceRub") or price_data.get("price") - result.append( - { - "change_time": entry.get("changeTime") or entry.get("date"), - "price_rub": price_rub, - "diff_percent": entry.get("diffPercent") or entry.get("diff_percent"), - } - ) - return result - - -def _extract_agent_profile(offer: dict[str, Any]) -> dict[str, Any] | None: - """offer.agent or offer.agency or offer.user — extract canonical profile shape.""" - agent = offer.get("agent") or {} - user = offer.get("user") or {} - if not agent and not user: - return None - return { - "ext_agent_id": agent.get("id") or user.get("cianUserId"), - "company_name": agent.get("companyName") or user.get("companyName"), - "is_pro": agent.get("isPro") or user.get("isPro"), - "skills": agent.get("skills") or [], - "account_type": agent.get("accountType") or user.get("accountType"), - } - - -# ── Save helpers ────────────────────────────────────────────────────────────── - - -def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnrichment) -> None: - """Persist DetailEnrichment to DB: - - - UPDATE listings SET windows_view_type, ceiling_height, ... WHERE id = listing_id - - INSERT INTO offer_price_history rows (if price_changes non-empty) - - INSERT INTO agents (if agent_profile) and link via agent_id_fk - - bti_data не сохраняется здесь — это задача Stage 6 (houses), где есть house_id - - Idempotency: re-running безопасно (UPSERT / ON CONFLICT DO NOTHING semantics). - """ - # Extend listings row with detail-enriched fields - db.execute( - text(""" - UPDATE listings SET - windows_view_type = COALESCE(:wvt, windows_view_type), - separate_wcs_count = COALESCE(:swc, separate_wcs_count), - combined_wcs_count = COALESCE(:cwc, combined_wcs_count), - ceiling_height = COALESCE(:ch, ceiling_height), - repair_type = COALESCE(:rt, repair_type), - repair_state = COALESCE(:rs, repair_state), - kitchen_area_m2 = COALESCE(CAST(:ka AS double precision), kitchen_area_m2), - description = COALESCE(:descr, description), - views_total = COALESCE(:vt, views_total), - views_today = COALESCE(:vd, views_today), - detail_enriched_at = NOW() - WHERE id = CAST(:lid AS bigint) - """), - { - "lid": listing_id, - "wvt": enrichment.windows_view_type, - "swc": enrichment.separate_wcs_count, - "cwc": enrichment.combined_wcs_count, - "ch": enrichment.ceiling_height, - "rt": enrichment.repair_type, - "rs": enrichment.repair_state, - "ka": enrichment.kitchen_area_m2, - "descr": enrichment.description, - "vt": enrichment.views_total, - "vd": enrichment.views_today, - }, - ) - - # Snapshot: point-in-time observation в listings_snapshots. - # Берём price_rub / price_per_m2 из listings строки (только что обновлённой). - # Используем COALESCE — на случай если listings.price_rub NULL (не должно быть, но safe). - _snap_row = db.execute( - text(""" - SELECT price_rub, price_per_m2 - FROM listings - WHERE id = CAST(:lid AS bigint) - """), - {"lid": listing_id}, - ).fetchone() - _snap_written = False - if _snap_row is not None and _snap_row.price_rub is not None: - try: - with db.begin_nested(): - upsert_listing_snapshot( - db, - listing_id=listing_id, - price_rub=int(_snap_row.price_rub), - price_per_m2=int(_snap_row.price_per_m2) if _snap_row.price_per_m2 else None, - run_id=None, - status="active", - ) - _snap_written = True - except Exception as exc: - logger.warning( - "save_detail_enrichment: snapshot write failed listing_id=%s: %s", - listing_id, - exc, - ) - - # Price changes — INSERT new rows, de-dup by listing_id + change_time - for change in enrichment.price_changes: - if not change.get("change_time") or not change.get("price_rub"): - continue - db.execute( - text(""" - INSERT INTO offer_price_history - (listing_id, change_time, price_rub, diff_percent, source) - VALUES ( - CAST(:lid AS bigint), - CAST(:ct AS timestamptz), - CAST(:price AS numeric), - CAST(:diff AS numeric), - 'cian' - ) - ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq DO NOTHING - """), - { - "lid": listing_id, - "ct": change["change_time"], - "price": change["price_rub"], - "diff": _clamp_diff_percent(change.get("diff_percent")), - }, - ) - - # Agent profile UPSERT — store agent row and link to listing - if enrichment.agent_profile and enrichment.agent_profile.get("ext_agent_id"): - ap = enrichment.agent_profile - row = db.execute( - text(""" - INSERT INTO agents - (ext_source, ext_agent_id, company_name, is_pro, skills, account_type) - VALUES ( - 'cian', - :eai, - :cn, - :ip, - CAST(:sk AS jsonb), - :at - ) - ON CONFLICT (ext_source, ext_agent_id) DO UPDATE SET - company_name = COALESCE(EXCLUDED.company_name, agents.company_name), - is_pro = COALESCE(EXCLUDED.is_pro, agents.is_pro), - skills = COALESCE(EXCLUDED.skills, agents.skills), - account_type = COALESCE(EXCLUDED.account_type, agents.account_type) - RETURNING id - """), - { - "eai": str(ap["ext_agent_id"]), - "cn": ap.get("company_name"), - "ip": ap.get("is_pro"), - "sk": json.dumps(ap.get("skills") or []), - "at": ap.get("account_type"), - }, - ) - agent_db_id = row.scalar_one_or_none() - if agent_db_id is not None: - db.execute( - text(""" - UPDATE listings - SET agent_id_fk = CAST(:aid AS bigint) - WHERE id = CAST(:lid AS bigint) - AND agent_id_fk IS NULL - """), - {"aid": agent_db_id, "lid": listing_id}, - ) - - db.commit() - logger.info( - "Cian detail enrichment saved for listing_id=%s (price_changes=%d, agent=%s, snapshot=%s)", - listing_id, - len(enrichment.price_changes), - bool(enrichment.agent_profile), - _snap_written, - ) diff --git a/tradein-mvp/backend/app/services/scrapers/domclick.py b/tradein-mvp/backend/app/services/scrapers/domclick.py deleted file mode 100644 index 4ed48e0b..00000000 --- a/tradein-mvp/backend/app/services/scrapers/domclick.py +++ /dev/null @@ -1,667 +0,0 @@ -"""DomClick.ru scraper — вторичка через JSON BFF API (#1968). - -Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через -BrowserFetcher(source="domclick") (generic provider → shared mobile proxy). -QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy. - -Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (
 или bare body).
-Парсинг: _extract_json() вытаскивает первый {...} из ответа.
-
-Стратегия нумерации комнат (ROOM_BUCKETS):
-  "st"  — студии (force rooms=0 в ScrapedLot)
-  "1"–"4" — соответственно
-  "5+"  — 5 и более комнат
-
-Пагинация: 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 hashlib
-import json
-import logging
-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
-from app.services.scrapers.domclick_exceptions import DomClickBlockedError
-from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
-
-logger = logging.getLogger(__name__)
-
-# ── API constants ─────────────────────────────────────────────────────────────
-
-_BFF_BASE = "https://bff-search-web.domclick.ru"
-_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
-_EKB_AREA_ID = "20561"
-
-# 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 прекращаем делить
-
-# ── QRATOR block detection ────────────────────────────────────────────────────
-
-_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 = "Екатеринбург"
-
-
-# ── JSON extraction ───────────────────────────────────────────────────────────
-
-
-def _extract_json(html: str) -> dict[str, Any]:
-    """Извлекает JSON-объект из ответа BrowserFetcher.
-
-    BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
-    1. Bare body: ``{"result": ...}`` напрямую в .
-    2. 
-wrapped: ``
{"result": ...}
``. - - Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов. - - Raises: - DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры. - ValueError: если JSON не найден или не является dict. - """ - # Сканируем ВЕСЬ ответ (а не только первые 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] - - -# ── 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 - - -def _extract_agency_name(seller: dict[str, Any]) -> str | None: - """Извлечь читаемое имя агентства/агента из seller-блока BFF offer. - - seller.company / seller.agent могут быть строкой ИЛИ dict (defensive — - реальная форма поля варьируется). Приоритет: company → agent. Для dict - пробуем типичные name-поля. Возвращает None если ничего читаемого нет. - """ - for key in ("company", "agent"): - val = seller.get(key) - if isinstance(val, str): - name = val.strip() - if name: - return name - elif isinstance(val, dict): - for name_key in ("name", "title", "fullName"): - nm = val.get(name_key) - if isinstance(nm, str) and nm.strip(): - return nm.strip() - return None - - -# ── DomClickScraper ────────────────────────────────────────────────────────── - - -class DomClickScraper(BaseScraper): - """DomClick вторичка через JSON BFF API. Источник = 'domklik'. - - Использует 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" - # Консервативная задержка между страницами (fallback до init) - request_delay_sec = 8.0 - - def __init__(self) -> None: - super().__init__() - self.request_delay_sec = get_scraper_delay(self.name) - 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__() - 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 = 100, - ) -> list[ScrapedLot]: - """Citywide sweep через BFF JSON API. - - Аргументы city_id и rooms принимаются для совместимости сигнатуры с - вызывающим кодом (run_domclick_city_sweep), но: - - city_id — vestigial (EКБ захардкожен через GUID). - - rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри. - - Args: - 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 - - out_lots: list[ScrapedLot] = [] - seen_ids: set[str] = set() - - async with BrowserFetcher(source="domclick") as fetcher: - for bucket in ROOM_BUCKETS: - logger.info( - "domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d", - bucket, - city_id, - pages, - ) - 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, - ) - 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 geo_filtered=%d fetch_errors=%d blocked=%s", - city_id, - len(out_lots), - self.parse_failures, - self.geo_filtered, - self.fetch_errors, - self.blocked, - ) - return out_lots - - # ── Internal sweep helpers ──────────────────────────────────────────────── - - async def _count( - self, - fetcher: Any, - rooms: str, - price_gte: int | None, - price_lte: int | None, - ) -> int: - """Запрашивает snippetsCount для данного бакета (rooms + price range). - - DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки - извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0 - (бакет пропускается, sweep продолжается). - """ - 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 - - 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). - - Если snippetsCount > OFFSET_CAP — делим диапазон цен пополам и - рекурсируем в каждую половину. Остановка рекурсии: - - span <= MIN_PRICE_SPAN → пагинируем как есть (с предупреждением о truncation) - - snippetsCount == 0 → пропускаем - - snippetsCount <= OFFSET_CAP → пагинируем - - Raises: - DomClickBlockedError: если QRATOR-блок — propagate наверх. - """ - count = await self._count(fetcher, rooms, price_gte, price_lte) - if count == 0: - return - - 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) - - 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. - - DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки - извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и break - (трактуем как конец бакета — уже собранные lots сохраняются). - - 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: - price = item.get("price", 0) or 0 - if price <= 0: - return None - - 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 "" - - loc = item.get("location") or {} - lat: float | None = loc.get("lat") - lon: float | None = loc.get("lon") - - 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) - - # ── Описание + repair-инференс + minhash (Layer A enrichment) ───── - # DomClick search не отдаёт renovation-enum: repair_state выводим из - # текста описания (как cian #622 fallback — «евроремонт»/«без отделки»). - # description_minhash — sha1 от описания (mirror cian) для cross-source - # дедупа. - description_raw = item.get("description") - description: str | None = None - if isinstance(description_raw, str): - description = description_raw.strip() or None - - repair_state: str | None = None - description_minhash: str | None = None - if description: - repair_state = infer_repair_state_from_text(description) - description_minhash = hashlib.sha1( - description.lower().encode("utf-8", errors="replace") - ).hexdigest()[:32] - - # ── Продавец / агентство ────────────────────────────────────────── - seller = item.get("seller") - if not isinstance(seller, dict): - seller = {} - agency_name = _extract_agency_name(seller) - - 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"), - } - - # ── Доп. SERP-поля (Layer A enrichment) — мержим в raw_payload, ──── - # сбрасывая None-значения чтобы payload оставался компактным; ничего - # из существующих ключей не теряем. - seller_compact = { - k: seller.get(k) for k in ("agent", "company") if seller.get(k) is not None - } - _enrich: dict[str, Any] = { - "is_sber_collateral": item.get("isSberCollateral"), - "has_discount": item.get("hasDiscount"), - "discount_value": item.get("discountValue"), - "duplicates_offer_count": item.get("duplicatesOfferCount"), - "seller": seller_compact or None, - } - raw_payload.update({k: v for k, v in _enrich.items() if v is not None}) - - publish_date = _parse_publish_date(item.get("publishedDate")) - - return ScrapedLot( - source="domklik", - source_url=source_url, - source_id=source_id, - address=address, - lat=lat, - lon=lon, - rooms=rooms, - area_m2=area_m2, - floor=floor, - total_floors=total_floors, - year_built=year_built, - price_rub=int(price), - price_per_m2=price_per_m2, - listing_segment="vtorichka", - publish_date=publish_date, - description=description, - repair_state=repair_state, - description_minhash=description_minhash, - agency_name=agency_name, - raw_payload=raw_payload, - ) - except Exception: - self.parse_failures += 1 - logger.warning( - "domklik: _map_item failed for item id=%r (parse_failures=%d)", - item.get("id"), - self.parse_failures, - exc_info=True, - ) - return None - - -# ── Convenience runner (для Celery tasks) ──────────────────────────────────── - - -async def scrape_domclick_city( - city_id: int, - rooms: list[int] | None = None, - pages: int = 100, -) -> list[ScrapedLot]: - """Удобная точка входа для вызова из Celery tasks. - - Пример:: - - import asyncio - 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) diff --git a/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py b/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py deleted file mode 100644 index 247de7fe..00000000 --- a/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py +++ /dev/null @@ -1,27 +0,0 @@ -"""DomClick-specific exceptions для anti-bot detection.""" - - -class DomClickBlockedError(Exception): - """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). - """ - - -class DomClickParseError(Exception): - """DomClick detail-карточка получена (HTTP 200, не block), но SSR-стейт - не извлекается / не парсится. - - Возникает в Layer B (domclick_detail.py) когда: - - `window.__SSR_STATE__` не найден в HTML (но это и не challenge-страница); - - object-литерал не сбалансирован по скобкам; - - json.loads падает после санитайза bare `undefined` → `null`. - - В отличие от DomClickBlockedError (anti-bot), это сигнал дрейфа схемы карточки - или повреждённого ответа — оркестратор считает его parse-failure, а не блоком. - """ diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py deleted file mode 100644 index 640a97f2..00000000 --- a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py +++ /dev/null @@ -1,1107 +0,0 @@ -"""Yandex.Nedvizhimost scraper (realty.yandex.ru) -- gate-API JSON parser. - -Transport: BrowserFetcher (camoufox real-browser fingerprint via tradein-browser service). -camoufox's fingerprint is not tarpitted by yandex and does not burn the mobile-proxy IP; -raw system-curl over SOCKS5 gets the proxy IP flagged. One BrowserFetcher context is opened -per scraper session (__aenter__) and reused across all page fetches. - -camoufox returns the gate-API JSON wrapped in HTML (verified prod 2026-06-17): - ...
{"response":...}
-JSON is extracted via _extract_json_from_content() (handles the
 wrapper).
-
-API path: GET https://realty.yandex.ru/gate/react-page/get/
-  _pageType=search&_providers=react-search-results-data
-  type=SELL&category=APARTMENT&newFlat=NO&rgid=559132 (EKB)
-  roomsTotal=&priceMin=...&priceMax=...&page=<1-based>
-
-Response shape: response.search.offers.{entities[], pager{page,pageSize,totalItems,totalPages}}
-
-Page param is 1-based: page=1 -> first page (pager.page=0 in response).
-page=0 -> API returns error.  Paginate from page=1 to totalPages (inclusive).
-
-Studio roomsTotal:
-  Request: roomsTotal=STUDIO
-  Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention).
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import logging
-import math
-import re
-from dataclasses import dataclass
-from datetime import UTC, date, datetime
-from typing import Any
-from urllib.parse import urlencode
-
-from curl_cffi.requests import AsyncSession as _CurlCffiSession
-
-from app.services.scraper_settings import get_scraper_delay
-from app.services.scrapers.base import BaseScraper, ScrapedLot
-from app.services.scrapers.browser_fetcher import BrowserFetcher
-from app.services.scrapers.house_type_normalizer import normalize_house_type
-from app.services.scrapers.price_brackets import get_price_seed_brackets
-
-logger = logging.getLogger(__name__)
-
-# camoufox renders JSON endpoints as 
{"response":...}
inside an HTML wrapper. -_RE_PRE_JSON = re.compile("]*>(\\{.*?)
", re.DOTALL | re.IGNORECASE) - -DEFAULT_CITY = "ekaterinburg" -_GATE_MAX_PAGES_CAP = 50 -_EKB_RGID = 559132 -_GATE_URL = "https://realty.yandex.ru/gate/react-page/get/" - -ROOM_GATE_PARAM: dict[str, str | int] = { - "studio": "STUDIO", - "1": 1, - "2": 2, - "3": 3, - "4+": 4, -} - -ROOM_PATH: dict[str, str] = { - "studio": "studiya", - "1": "odnokomnatnaya", - "2": "dvuhkomnatnaya", - "3": "trehkomnatnaya", - "4+": "4-i-bolee", -} - -DEFAULT_PRICE_RANGES: list[tuple[int | None, int | None]] = [ - (None, 5_000_000), - (5_000_000, 7_000_000), - (7_000_000, 10_000_000), - (10_000_000, 15_000_000), - (15_000_000, 25_000_000), - (25_000_000, None), -] - -_YANDEX_MAX_PRICE = 200_000_000 -_YANDEX_MIN_BRACKET = 50_000 -_GATE_PAGE_SIZE = 20 -_YANDEX_TARPIT_MAX_RETRIES: int = 2 - -# Таймаут httpx-клиента BrowserFetcher для yandex-пути: 30s вместо глобального 120s. -# Один проблемный combo занимает ≤30s×(1+_YANDEX_TARPIT_MAX_RETRIES)=90s вместо 360s. -_YANDEX_BROWSER_FETCH_TIMEOUT_S: float = 30.0 - - -@dataclass -class _CurlResponse: - status_code: int - text: str - - -def _is_gate_error(payload: dict[str, Any]) -> bool: - """Return True when gate-API returned an error object instead of search results.""" - return "error" in payload or "response" not in payload - - -def _extract_gate_data( - payload: dict[str, Any], -) -> tuple[list[dict[str, Any]], dict[str, Any]] | None: - """Extract (entities, pager) from valid gate-API response. None on missing path.""" - try: - offers = payload["response"]["search"]["offers"] - entities: list[dict[str, Any]] = offers["entities"] - pager: dict[str, Any] = offers["pager"] - return entities, pager - except (KeyError, TypeError): - return None - - -def _extract_json_from_content(content: str) -> str | None: - """Extract raw JSON text from camoufox HTML-wrapped content. - - camoufox renders a JSON endpoint as: - ...
{"response":...}
- - Strategy: - 1. Try
...
regex (primary -- matches prod output). - 2. Fallback: slice from the first '{' (handles raw-JSON response, no wrapper). - - Returns the JSON string, or None if no '{' is found. - """ - m = _RE_PRE_JSON.search(content) - if m: - return m.group(1) - - idx = content.find("{") - if idx != -1: - return content[idx:] - - return None - - -def _segment_for(new_flat: str) -> str: - """Map a newFlat gate-API value to the listings.listing_segment value.""" - return "novostroyki" if new_flat == "YES" else "vtorichka" - - -def _parse_creation_date(raw: Any) -> tuple[date | None, int | None]: - """Parse gate-API creationDate ('2026-05-24T15:10:27Z') → (date, days_on_market). - - days_on_market = floor((now - creationDate).days), clamped to >= 0. - Returns (None, None) when raw is absent or unparseable (never raises). - """ - if not raw or not isinstance(raw, str): - return None, None - txt = raw.strip() - # datetime.fromisoformat handles 'Z' only from py3.11+, but normalise defensively. - if txt.endswith("Z"): - txt = txt[:-1] + "+00:00" - try: - dt = datetime.fromisoformat(txt) - except ValueError: - return None, None - if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) - listing_date = dt.date() - delta_days = (datetime.now(UTC) - dt).days - days_on_market = max(delta_days, 0) - return listing_date, days_on_market - - -def _author_fields(author: dict[str, Any]) -> tuple[bool | None, bool | None, str | None]: - """Map entity.author → (is_homeowner, is_pro_seller, agency_name). - - author.category ∈ {OWNER, AGENCY, AGENT, ...}. - OWNER → is_homeowner=True - AGENCY / AGENT → is_pro_seller=True, agency_name = organization or agentName - Returns (None, None, None) when author/category absent. - """ - category = (author.get("category") or "").upper() - if not category: - return None, None, None - if category == "OWNER": - return True, None, None - if category in {"AGENCY", "AGENT"}: - agency_name = author.get("organization") or author.get("agentName") or None - return None, True, agency_name - return None, None, None - - -def _to_bigint(raw: Any) -> int | None: - """Coerce a gate-API numeric value (str or number) to int. None when unparseable.""" - if raw is None: - return None - try: - return int(float(raw)) - except (TypeError, ValueError): - return None - - -def _predicted_prices(entity: dict[str, Any]) -> tuple[int | None, int | None, int | None]: - """Extract (value, min, max) from entity.predictions.predictedPrice. - - Yandex's own per-offer valuation. The gate-API nests it under - ``predictions.predictedPrice`` (NOT ``predictedPrice.predictedPrice`` — - that key does not exist; verified live 2026-06-18 against the gate-API). - Fields are strings (e.g. "7554000"). Returns (None, None, None) when the - nested path is absent (e.g. novostroyki offers carry no valuation). - """ - outer = entity.get("predictions") or {} - inner = outer.get("predictedPrice") or {} - if not isinstance(inner, dict): - return None, None, None - return ( - _to_bigint(inner.get("value")), - _to_bigint(inner.get("min")), - _to_bigint(inner.get("max")), - ) - - -def _metro_stations(location: dict[str, Any]) -> list[dict[str, Any]]: - """Build metro_stations [{name, min}] from location.metroList[] (+ location.metro).""" - stations: list[dict[str, Any]] = [] - raw_list = location.get("metroList") - if isinstance(raw_list, list): - for m in raw_list: - if isinstance(m, dict) and m.get("name"): - stations.append({"name": m.get("name"), "min": m.get("timeToMetro")}) - if not stations: - single = location.get("metro") - if isinstance(single, dict) and single.get("name"): - stations.append({"name": single.get("name"), "min": single.get("timeToMetro")}) - return stations - - -def _parse_gate_json( - payload: dict[str, Any], page_param: int = 1, new_flat: str = "NO" -) -> list[ScrapedLot]: - """Parse gate-API JSON payload into a list of ScrapedLot. - - Verified field mapping (from .issdump/yandex_gate_api_sample.json + live prod 2026-06-17): - offerId -> source_id - url (//realty.yandex.ru/offer/) -> source_url (canonical per-offer, #2235: - partner/redirect url -> offer_id-based) - price.value -> price_rub - price.valuePerPart -> price_per_m2 - area.value -> area_m2 - livingSpace.value -> living_area_m2 - kitchenSpace.value -> kitchen_area_m2 (+ raw_payload["kitchen_area_m2"]) - roomsTotal (int | None) -> rooms (None -> 0 for studio) - floorsOffered[0] -> floor - floorsTotal -> total_floors - building.builtYear -> year_built - building.buildingType -> house_type (normalize_house_type: MONOLIT->monolith) - ceilingHeight -> ceiling_height_m (+ raw_payload["ceiling_height"]) - location.point.latitude/longitude -> lat / lon - location.geocoderAddress -> address (full, with house number) - mainImages[] -> photo_urls (prepend "https:", up to 5) - building.siteId -> house_ext_id (JK linkage) - listing_segment -> "novostroyki" (newFlat=YES) | "vtorichka" (newFlat=NO) - creationDate (ISO Z) -> listing_date / publish_date + days_on_market - description -> description - author.category/organization -> is_homeowner / is_pro_seller / agency_name - predictedPrice.predictedPrice.* -> predicted_price_rub / _min / _max - price.trend / price.previous -> price_trend / price_previous_rub - location.metroList[] -> metro_stations [{name, min}] - offerId -> yandex_offer_id - """ - result = _extract_gate_data(payload) - if result is None: - logger.warning("yandex gate: response missing response.search.offers path") - return [] - - entities, _pager = result - lots: list[ScrapedLot] = [] - for entity in entities: - lot = _entity_to_lot(entity, page_param=page_param, new_flat=new_flat) - if lot is not None: - lots.append(lot) - return lots - - -def _canonical_source_url(url_raw: str, offer_id: str) -> str: - """Каноничный per-offer source_url для yandex-оффера (#2235). - - gate-API `url` для вторички = `//realty.yandex.ru/offer/` (уникален на - оффер). Для новостроек/partner-офферов там НЕканоническая ссылка на сайт - застройщика или рекламный редирект (na100.pro/go.php, atlasdevelopment.su, …), - которую делят МНОГО разных квартир одного ЖК → source_url перестаёт быть - уникальным (204 «дубля» среди is_active). Возвращаем стабильный уникальный - URL из offer_id, если исходный не ведёт на realty.yandex; иначе — исходный. - """ - source_url = f"https:{url_raw}" if url_raw.startswith("//") else url_raw - is_yandex_realty = "realty.yandex" in source_url or "realty.ya.ru" in source_url - if not source_url or not is_yandex_realty: - return f"https://realty.yandex.ru/offer/{offer_id}/" - return source_url - - -def _entity_to_lot( - entity: dict[str, Any], page_param: int = 1, new_flat: str = "NO" -) -> ScrapedLot | None: - """Convert one gate-API entity dict to ScrapedLot. None on missing required fields.""" - try: - offer_id = str(entity.get("offerId") or "") - if not offer_id: - return None - - # #2235: для новостроек gate-API кладёт в `url` НЕканоническую ссылку — - # сайт застройщика / рекламный редирект (na100.pro/go.php, atlasdevelopment.su, - # unistroyrf.ru, …). Много разных офферов одного ЖК делят ОДНУ такую страницу - # → 204 «дубля» source_url среди is_active (разные source_id, разные квартиры). - # Канонизируем: если `url` не ведёт на realty.yandex — строим стабильный - # per-offer URL из offer_id (он всегда есть, см. проверку выше). Вторичный - # //realty.yandex.ru/offer/ оставляем как есть. - source_url = _canonical_source_url(entity.get("url") or "", offer_id) - - price_dict = entity.get("price") or {} - price_val = price_dict.get("value") - if not price_val or price_val <= 0: - return None - price_rub = int(price_val) - - price_per_m2_raw = price_dict.get("valuePerPart") - price_per_m2 = int(price_per_m2_raw) if price_per_m2_raw else None - - area_dict = entity.get("area") or {} - area_m2_raw = area_dict.get("value") - area_m2 = float(area_m2_raw) if area_m2_raw is not None else None - - living_dict = entity.get("livingSpace") or {} - living_area_raw = living_dict.get("value") - living_area_m2 = float(living_area_raw) if living_area_raw is not None else None - - kitchen_dict = entity.get("kitchenSpace") or {} - kitchen_area_raw = kitchen_dict.get("value") - kitchen_area_m2 = float(kitchen_area_raw) if kitchen_area_raw is not None else None - - rooms_raw = entity.get("roomsTotal") - if rooms_raw is None: - rooms = 0 # studio - else: - try: - rooms = int(rooms_raw) - except (TypeError, ValueError): - rooms = None - - floors_offered = entity.get("floorsOffered") or [] - floor = int(floors_offered[0]) if floors_offered else None - floors_total_raw = entity.get("floorsTotal") - total_floors = int(floors_total_raw) if floors_total_raw is not None else None - - ceiling_height = entity.get("ceilingHeight") - # Колонка ceiling_height — numeric(3,2), max 9.99. Yandex SERP отдаёт мусор - # (видели 18 м) → запись out-of-range уронила бы весь батч DataError'ом - # (per-lot SAVEPOINT ловит только IntegrityError). Берём только правдоподобный - # диапазон 2.0–6.0 м, иначе None; сырое значение остаётся в raw_payload. (#2007) - _ceiling_raw = float(ceiling_height) if ceiling_height is not None else None - ceiling_height_m = ( - _ceiling_raw if _ceiling_raw is not None and 2.0 <= _ceiling_raw <= 6.0 else None - ) - - building = entity.get("building") or {} - year_built_raw = building.get("builtYear") - year_built = int(year_built_raw) if year_built_raw is not None else None - # buildingType — SCREAMING (MONOLIT/BRICK/...). Нормализуем в канон, иначе - # estimator soft-penalty штрафует yandex-аналоги (#2007). - raw_building_type = building.get("buildingType") - house_type = normalize_house_type(raw_building_type) - - site_id = building.get("siteId") - site_name = building.get("siteName") - house_source: str | None = None - house_ext_id: str | None = None - if site_id: - house_source = "yandex_realty_nb" - house_ext_id = str(site_id) - - location = entity.get("location") or {} - point = location.get("point") or {} - lat_raw = point.get("latitude") - lon_raw = point.get("longitude") - lat = float(lat_raw) if lat_raw is not None else None - lon = float(lon_raw) if lon_raw is not None else None - - address = location.get("geocoderAddress") or location.get("streetAddress") or None - - # ── Rich gate-API fields ─────────────────────────────────────────── - listing_date, days_on_market = _parse_creation_date(entity.get("creationDate")) - description = entity.get("description") or None - - author = entity.get("author") or {} - is_homeowner, is_pro_seller, agency_name = _author_fields(author) - - predicted_value, predicted_min, predicted_max = _predicted_prices(entity) - - price_trend = price_dict.get("trend") or None - price_previous_rub = _to_bigint(price_dict.get("previous")) - - metro_stations = _metro_stations(location) - - photo_urls: list[str] = [] - for img in entity.get("mainImages") or []: - if img: - photo_urls.append(f"https:{img}" if img.startswith("//") else img) - if len(photo_urls) >= 5: - break - - return ScrapedLot( - source="yandex", - source_url=source_url, - source_id=offer_id, - address=address, - lat=lat, - lon=lon, - rooms=rooms, - area_m2=area_m2, - living_area_m2=living_area_m2, - floor=floor, - total_floors=total_floors, - year_built=year_built, - house_type=house_type, - kitchen_area_m2=kitchen_area_m2, - ceiling_height_m=ceiling_height_m, - price_rub=price_rub, - price_per_m2=price_per_m2, - photo_urls=photo_urls, - house_source=house_source, - house_ext_id=house_ext_id, - house_url=None, - listing_segment=_segment_for(new_flat), - listing_date=listing_date, - publish_date=listing_date, - days_on_market=days_on_market, - description=description, - is_homeowner=is_homeowner, - is_pro_seller=is_pro_seller, - agency_name=agency_name, - metro_stations=metro_stations, - yandex_offer_id=offer_id, - predicted_price_rub=predicted_value, - predicted_price_min=predicted_min, - predicted_price_max=predicted_max, - price_trend=price_trend, - price_previous_rub=price_previous_rub, - raw_payload={ - "page_param": page_param, - "ceiling_height": ceiling_height, - "kitchen_area_m2": kitchen_area_raw, - "raw_building_type": raw_building_type, # сырой SCREAMING — для отладки - "site_name": site_name, - "offer_id": offer_id, - }, - ) - except Exception: - logger.exception("yandex gate: entity parse failed offer_id=%s", entity.get("offerId")) - return None - - -class YandexRealtyScraper(BaseScraper): - name = "yandex" - base_url = "https://realty.yandex.ru" - request_delay_sec = 5.0 - - def __init__(self, city: str = DEFAULT_CITY) -> None: - super().__init__() - self.city = city - self.request_delay_sec = get_scraper_delay(self.name) - self._browser: BrowserFetcher | None = None - # _cffi_session retained only for _rotate_ip (changeip call). - self._cffi_session: _CurlCffiSession | None = None - self._cookies: dict[str, str] = {} - - async def __aenter__(self) -> YandexRealtyScraper: # type: ignore[override] - """Open ONE BrowserFetcher (camoufox) session, reused across all page fetches. - - camoufox's real-browser fingerprint avoids the mobile-proxy IP tarpit - escalation that raw system-curl over SOCKS5 triggers. Opening per-page is - expensive and unnecessary. - - fetch_timeout_s=_YANDEX_BROWSER_FETCH_TIMEOUT_S (30s) ограничивает потери - на один тайм-аутовый запрос: worst-case 30s×(1+retries)=90s на combo вместо 360s. - """ - self._browser = BrowserFetcher( - source="yandex", fetch_timeout_s=_YANDEX_BROWSER_FETCH_TIMEOUT_S - ) - await self._browser.__aenter__() - return self - - async def __aexit__(self, *args: object) -> None: # type: ignore[override] - if self._browser is not None: - await self._browser.__aexit__(*args) - self._browser = None - if self._cffi_session is not None: - await self._cffi_session.close() - self._cffi_session = None - - async def _http_get(self, url: str, **kwargs: object) -> _CurlResponse: # type: ignore[override] - """Fetch gate-API URL via the shared BrowserFetcher (camoufox). - - camoufox returns HTML-wrapped JSON; the JSON is extracted and returned as - the body of a _CurlResponse so existing callers (json.loads(resp.text), - resp.status_code checks) keep working unchanged. - - Maps outcomes onto the curl-style status contract: - - JSON extracted -> status_code=200, text= - - fetch raised / no JSON found -> status_code=0 (treated as transient - failure by the caller retry logic) - """ - if self._browser is None: - raise RuntimeError("YandexRealtyScraper must be used as async context manager") - - try: - content = await self._browser.fetch(url) - except Exception: - logger.warning("yandex gate: BrowserFetcher.fetch failed url=%s", url, exc_info=True) - return _CurlResponse(status_code=0, text="") - - json_text = _extract_json_from_content(content) - if not json_text: - logger.warning( - "yandex gate: could not extract JSON from content url=%s first200=%r", - url, - content[:200], - ) - return _CurlResponse(status_code=0, text="") - - return _CurlResponse(status_code=200, text=json_text) - - async def _rotate_ip(self) -> bool: - """Rotate mobile proxy IP via changeip URL. Returns True on success.""" - from app.core.config import settings as _settings - - rotate_url = _settings.yandex_proxy_rotate_url or _settings.avito_proxy_rotate_url - if not rotate_url: - return False - sep = "&" if "?" in rotate_url else "?" - try: - async with _CurlCffiSession(timeout=30) as rot: - await rot.get(f"{rotate_url}{sep}format=json") - await asyncio.sleep(9) - logger.info("yandex proxy: IP rotated via changeip") - return True - except Exception: - logger.warning("yandex proxy: IP rotation failed", exc_info=True) - return False - - def _build_url( - self, - page: int = 1, - rooms: str | None = None, - price_min: int | None = None, - price_max: int | None = None, - new_flat: str = "NO", - ) -> str: - """Build gate-API URL. page: 1-based (page=0 -> API error). - - new_flat: "NO" -> vtorichka (default, preserves legacy behavior), - "YES" -> novostroyki (primary-sale / reassignment segment). - """ - params: dict[str, str | int] = { - "rgid": _EKB_RGID, - "type": "SELL", - "category": "APARTMENT", - "newFlat": new_flat, - "_pageType": "search", - "_providers": "react-search-results-data", - "page": page, - } - if rooms and rooms in ROOM_GATE_PARAM: - params["roomsTotal"] = ROOM_GATE_PARAM[rooms] - if price_min is not None: - params["priceMin"] = price_min - if price_max is not None: - params["priceMax"] = price_max - return f"{_GATE_URL}?{urlencode(params)}" - - async def _fetch_page_json( - self, - rooms: str | None, - page: int, - price_min: int | None, - price_max: int | None, - new_flat: str = "NO", - ) -> dict[str, Any] | None: - """Fetch one gate-API page. Returns parsed JSON dict or None on error/invalid.""" - url = self._build_url( - page=page, rooms=rooms, price_min=price_min, price_max=price_max, new_flat=new_flat - ) - try: - resp = await self._http_get(url, timeout=60) - except Exception: - logger.exception("yandex gate: GET failed url=%s", url) - return None - if resp.status_code != 200: # type: ignore[union-attr] - logger.warning("yandex gate: HTTP %d url=%s", resp.status_code, url) # type: ignore[union-attr] - return None - try: - payload: dict[str, Any] = json.loads(resp.text) # type: ignore[union-attr] - except (json.JSONDecodeError, ValueError): - logger.warning("yandex gate: JSON parse failed url=%s", url) - return None - if _is_gate_error(payload): - logger.warning("yandex gate: error response url=%s keys=%s", url, list(payload.keys())) - return None - return payload - - async def fetch_around( - self, - lat: float, - lon: float, - radius_m: int = 1000, - page: int = 1, - rooms: str | None = None, - price_min: int | None = None, - price_max: int | None = None, - new_flat: str = "NO", - ) -> list[ScrapedLot]: - """Fetch ONE page of gate-API results. lat/lon/radius_m ignored (uses rgid). - - page: 1-based. Tarpit resilience: status_code==0 or JSON error - -> rotate IP + retry up to _YANDEX_TARPIT_MAX_RETRIES times. - Non-tarpit failures (4xx/5xx) are not retried. - new_flat: "NO" -> vtorichka, "YES" -> novostroyki. - """ - url = self._build_url( - page=page, rooms=rooms, price_min=price_min, price_max=price_max, new_flat=new_flat - ) - - payload: dict[str, Any] | None = None - for attempt in range(1 + _YANDEX_TARPIT_MAX_RETRIES): - try: - response = await self._http_get(url, timeout=60) - except Exception: - logger.exception("yandex gate fetch failed: %s", url) - return [] - - status = response.status_code # type: ignore[union-attr] - - if status == 0: - logger.warning( - "yandex gate: status=0 (tarpit?) rooms=%s page=%d" - " -- rotating IP + retry attempt %d", - rooms, - page, - attempt + 1, - ) - await self._rotate_ip() - await asyncio.sleep(2) - continue - - if status != 200: - logger.warning( - "yandex gate: HTTP %d rooms=%s page=%d url=%s", status, rooms, page, url - ) - return [] - - try: - payload = json.loads(response.text) # type: ignore[union-attr] - except (json.JSONDecodeError, ValueError): - logger.warning( - "yandex gate: JSON parse failed rooms=%s page=%d -- rotating + retry %d", - rooms, - page, - attempt + 1, - ) - await self._rotate_ip() - await asyncio.sleep(2) - continue - - if _is_gate_error(payload): - logger.warning( - "yandex gate: error payload rooms=%s page=%d keys=%s -- rotating + retry %d", - rooms, - page, - list(payload.keys()), - attempt + 1, - ) - await self._rotate_ip() - await asyncio.sleep(2) - payload = None - continue - - break - - if payload is None: - logger.warning( - "yandex gate: all retries exhausted rooms=%s page=%d -- returning empty", - rooms, - page, - ) - return [] - - lots = _parse_gate_json(payload, page_param=page, new_flat=new_flat) - logger.info( - "yandex gate page=%d rooms=%s price=[%s-%s] newFlat=%s: %d cards", - page, - rooms or "all", - price_min, - price_max, - new_flat, - len(lots), - ) - await self.sleep_between_requests() - return lots - - async def fetch_around_multi_room( - self, - lat: float, - lon: float, - radius_m: int = 1000, - max_pages: int = _GATE_MAX_PAGES_CAP, - rooms_list: list[str] | None = None, - price_ranges: list[tuple[int | None, int | None]] | None = None, - segments: list[str] | None = None, - on_combo: Any = None, - **_legacy_kwargs: Any, - ) -> list[ScrapedLot]: - """Fetch via segment x rooms x price-range combos; paginate each combo to totalPages. - - Pagination driven by pager.totalPages from first page response. - Each combo iterates page=1 .. min(totalPages, max_pages, _GATE_MAX_PAGES_CAP). - Deduplicates by source_id/source_url across ALL combos AND segments (one `seen`). - Legacy mode (rooms_list=None, price_ranges=None): single citywide sweep. - segments: list of newFlat values, default ["NO"] (vtorichka only). Pass - ["NO", "YES"] to sweep both vtorichka and novostroyki in one call. - - on_combo: опциональный callback(combo_label: str, new_lots: list[ScrapedLot]) -> None. - Вызывается после каждого combo с новыми (не повторными) лотами из него. - Позволяет инкрементальный save: вызывающий код сохраняет new_lots сразу, - не дожидаясь конца sweep'а. Дубли между combo НЕ передаются повторно. - """ - seen: dict[str, ScrapedLot] = {} - _segments = segments or ["NO"] - combos_skipped = 0 - - if rooms_list is None and price_ranges is None: - combos: list[tuple[str | None, int | None, int | None]] = [(None, None, None)] - else: - r_list = rooms_list or list(ROOM_PATH.keys()) - p_ranges = price_ranges or DEFAULT_PRICE_RANGES - combos = [(r, lo, hi) for r in r_list for lo, hi in p_ranges] - - for new_flat in _segments: - _seg = _segment_for(new_flat) - for rooms, price_min, price_max in combos: - combo_label = f"{_seg}/{_combo_label(rooms, price_min, price_max)}" - total_pages: int | None = None - combo_new_lots: list[ScrapedLot] = [] - combo_skipped = False - - for page in range(1, max_pages + 1): - if page == 1: - payload_p1: dict[str, Any] | None = None - p1_url = self._build_url( - page=1, - rooms=rooms, - price_min=price_min, - price_max=price_max, - new_flat=new_flat, - ) - for _attempt in range(1 + _YANDEX_TARPIT_MAX_RETRIES): - try: - resp = await self._http_get(p1_url, timeout=60) - except Exception: - logger.exception( - "yandex gate combo fetch failed combo=%s", combo_label - ) - break - if resp.status_code == 0: # type: ignore[union-attr] - logger.warning( - "yandex gate: tarpit combo=%s page=1 attempt=%d -- rotating", - combo_label, - _attempt + 1, - ) - await self._rotate_ip() - await asyncio.sleep(2) - continue - if resp.status_code != 200: # type: ignore[union-attr] - logger.warning( - "yandex gate: HTTP %d combo=%s page=1", - resp.status_code, - combo_label, # type: ignore[union-attr] - ) - break - try: - payload_p1 = json.loads(resp.text) # type: ignore[union-attr] - except (json.JSONDecodeError, ValueError): - logger.warning( - "yandex gate: JSON parse error combo=%s page=1", combo_label - ) - payload_p1 = None - await self._rotate_ip() - await asyncio.sleep(2) - continue - if _is_gate_error(payload_p1): - logger.warning( - "yandex gate: error payload combo=%s page=1", combo_label - ) - payload_p1 = None - break - break - - if payload_p1 is None: - logger.warning( - "yandex gate combo [%s] page=1: all retries exhausted -- skipping", - combo_label, - ) - combo_skipped = True - combos_skipped += 1 - break - - result = _extract_gate_data(payload_p1) - if result is None: - combo_skipped = True - combos_skipped += 1 - break - _entities_p1, pager_p1 = result - total_pages = min( - pager_p1.get("totalPages", 1), - max_pages, - _GATE_MAX_PAGES_CAP, - ) - lots_p1 = _parse_gate_json(payload_p1, page_param=1, new_flat=new_flat) - if not lots_p1: - logger.debug( - "yandex gate combo [%s] page=1: empty -- stopping", combo_label - ) - break - for lot in lots_p1: - key = lot.source_id or lot.source_url - if key and key not in seen: - seen[key] = lot - combo_new_lots.append(lot) - await self.sleep_between_requests() - if total_pages <= 1: - break - continue - - if total_pages is not None and page > total_pages: - break - - lots = await self.fetch_around( - lat, - lon, - radius_m, - page=page, - rooms=rooms, - price_min=price_min, - price_max=price_max, - new_flat=new_flat, - ) - if not lots: - logger.debug( - "yandex gate combo [%s] page=%d: empty -- stopping", - combo_label, - page, - ) - break - for lot in lots: - key = lot.source_id or lot.source_url - if key and key not in seen: - seen[key] = lot - combo_new_lots.append(lot) - - # Инкрементальный save: вызываем on_combo если есть новые лоты. - # Скипнутые combo (combo_skipped=True) не вызывают on_combo. - if on_combo is not None and combo_new_lots and not combo_skipped: - try: - on_combo(combo_label, combo_new_lots) - except Exception: - logger.exception( - "yandex gate: on_combo callback failed combo=%s", combo_label - ) - - logger.info( - "yandex gate aggregate: %d unique lots (%d combos x %d segments=%s, skipped=%d)", - len(seen), - len(combos), - len(_segments), - _segments, - combos_skipped, - ) - return list(seen.values()) - - async def fetch_all_secondary( - self, - *, - rooms_buckets: list[str] | None = None, - price_cap_per_bucket: int = 500, - max_pages_per_bucket: int = _GATE_MAX_PAGES_CAP, - concurrency: int = 4, - on_bucket: Any = None, - on_progress: Any = None, - skip_buckets: set[str] | None = None, - ) -> list[ScrapedLot]: - """Exhaustive EKB vtorichka load via KOMNATNOST x TSENA partitioning. - - Uses gate-API JSON (totalItems from pager) instead of DOM state extraction. - Стартует с общей data-driven seed-сетки ценовых брекетов - (get_price_seed_brackets() — shared EKB grid, общая с avito/cian) вместо - одной корень-бисекции [0, _YANDEX_MAX_PRICE]. Закрытые seed-брекеты передают - hi = br_hi - 1 (priceMax эксклюзивный сверху → нет пересечений), открытый - (br_hi is None) — hi=None (без priceMax, весь хвост люкса). Дедуп по - source_id в общем seen страхует на стыках. - """ - _buckets = rooms_buckets if rooms_buckets is not None else list(ROOM_PATH.keys()) - seen: dict[str, ScrapedLot] = {} - - for rooms_key in _buckets: - logger.info( - "yandex gate exhaustive: starting rooms=%s (price_cap=%d concurrency=%d)", - rooms_key, - price_cap_per_bucket, - concurrency, - ) - before = len(seen) - for br_lo, br_hi in get_price_seed_brackets(): - walk_hi = br_hi - 1 if br_hi is not None else None - await self._walk_price_range( - rooms=rooms_key, - lo=br_lo, - hi=walk_hi, - seen=seen, - price_cap_per_bucket=price_cap_per_bucket, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - ) - room_collected = len(seen) - before - logger.info( - "yandex gate exhaustive: rooms=%s done -- collected %d (total unique=%d)", - rooms_key, - room_collected, - len(seen), - ) - if on_progress is not None: - on_progress(len(seen)) - - logger.info("yandex gate exhaustive: DONE -- total unique=%d lots", len(seen)) - return list(seen.values()) - - async def _walk_price_range( - self, - *, - rooms: str | None, - lo: int, - hi: int | None, - seen: dict[str, ScrapedLot], - price_cap_per_bucket: int, - max_pages_per_bucket: int, - concurrency: int = 4, - on_bucket: Any = None, - skip_buckets: set[str] | None = None, - _depth: int = 0, - ) -> None: - """Recursive binary price-range partitioning. - - Probe page=1 -> read totalItems from pager. - Closed bracket (hi is not None): totalItems > cap and bracket >= MIN_BRACKET - -> split; else paginate leaf. - Open bracket (hi is None): top seed-bracket without priceMax ceiling. Cannot - split (no mid) -> paginate leaf directly (lux tail tiny, tail-loss accepted). - Fallback: totalItems=None -> paginate-until-empty (safe degradation). - skip_buckets: resume checkpoint -- skip leaf buckets already done. - """ - _lo_param = lo if lo > 0 else None - _hi_repr = "open" if hi is None else str(hi) - - probe_payload = await self._fetch_page_json(rooms, 1, _lo_param, hi) - await asyncio.sleep(self.request_delay_sec) - - total: int | None = None - probe_lots: list[ScrapedLot] = [] - - if probe_payload is None: - logger.warning( - "yandex gate: probe failed rooms=%s [%d, %s] depth=%d -- rotating + retry", - rooms, - lo, - _hi_repr, - _depth, - ) - rotated = await self._rotate_ip() - if rotated: - probe_payload = await self._fetch_page_json(rooms, 1, _lo_param, hi) - await asyncio.sleep(self.request_delay_sec) - - if probe_payload is not None: - result = _extract_gate_data(probe_payload) - if result is not None: - _, pager = result - total = pager.get("totalItems") - probe_lots = _parse_gate_json(probe_payload, page_param=1) - else: - logger.warning( - "yandex gate: probe data extract failed rooms=%s [%d, %s]", rooms, lo, _hi_repr - ) - - if total is None: - # Degraded path: paginate-until-empty without knowing total - logger.warning( - "yandex gate: totalItems unknown rooms=%s [%d, %s] -- paginate until empty", - rooms, - lo, - _hi_repr, - ) - page = 1 - bucket_key = _combo_label(rooms, lo, hi) - if skip_buckets and bucket_key in skip_buckets: - return - pages_fetched = 0 - while pages_fetched < max_pages_per_bucket: - payload = await self._fetch_page_json(rooms, page, _lo_param, hi) - await asyncio.sleep(self.request_delay_sec) - if payload is None: - break - lots = _parse_gate_json(payload, page_param=page) - if not lots: - break - for lot in lots: - if lot.source_id and lot.source_id not in seen: - seen[lot.source_id] = lot - page += 1 - pages_fetched += 1 - if on_bucket is not None: - on_bucket(bucket_key, len(seen)) - return - - # Split or paginate. ОТКРЫТЫЙ брекет (hi is None) делить нельзя (нет mid) — - # сразу пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted). - cap = price_cap_per_bucket - _min_bracket = 500_000 - - if hi is not None and total > cap and (hi - lo) >= _min_bracket and _depth < 8: - mid = (lo + hi) // 2 - await self._walk_price_range( - rooms=rooms, - lo=lo, - hi=mid, - seen=seen, - price_cap_per_bucket=cap, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - _depth=_depth + 1, - ) - await self._walk_price_range( - rooms=rooms, - lo=mid + 1, - hi=hi, - seen=seen, - price_cap_per_bucket=cap, - max_pages_per_bucket=max_pages_per_bucket, - concurrency=concurrency, - on_bucket=on_bucket, - skip_buckets=skip_buckets, - _depth=_depth + 1, - ) - return - - # Leaf bucket: paginate - bucket_key = _combo_label(rooms, lo, hi) - if skip_buckets and bucket_key in skip_buckets: - logger.debug("yandex gate: skip bucket %s (checkpoint)", bucket_key) - return - - total_pages = min( - math.ceil(total / 20), - _GATE_MAX_PAGES_CAP, - max_pages_per_bucket, - ) - logger.info("yandex gate: leaf bucket %s total=%d pages=%d", bucket_key, total, total_pages) - - # Add probe lots (page 1 already fetched) - for lot in probe_lots: - if lot.source_id and lot.source_id not in seen: - seen[lot.source_id] = lot - - if total_pages <= 1: - if on_bucket is not None: - on_bucket(bucket_key, len(seen)) - return - - # Paginate pages 2..total_pages with concurrency - sem = asyncio.Semaphore(concurrency) - - async def _fetch_leaf_page(pg: int) -> list[ScrapedLot]: - async with sem: - payload = await self._fetch_page_json(rooms, pg, _lo_param, hi) - await asyncio.sleep(self.request_delay_sec) - if payload is None: - return [] - return _parse_gate_json(payload, page_param=pg) - - tasks = [_fetch_leaf_page(pg) for pg in range(2, total_pages + 1)] - results = await asyncio.gather(*tasks) - for page_lots in results: - for lot in page_lots: - if lot.source_id and lot.source_id not in seen: - seen[lot.source_id] = lot - - if on_bucket is not None: - on_bucket(bucket_key, len(seen)) - - -def _combo_label(rooms: str | None, lo: int | None, hi: int | None) -> str: - """Уникальная метка бакета для checkpoint-логики.""" - return f"{rooms or 'any'}:{lo}-{hi}" diff --git a/tradein-mvp/backend/tests/scrapers/test_admin_avito_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_admin_avito_kit_parity.py deleted file mode 100644 index 540d4766..00000000 --- a/tradein-mvp/backend/tests/scrapers/test_admin_avito_kit_parity.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Parity-proof: admin.py Avito debug-роуты (#2305, Group A) — legacy vs scraper_kit. - -`app/api/v1/admin.py::scrape_avito_house` теперь зовёт `scraper_kit.providers.avito.houses` -вместо `app.services.scrapers.avito_houses` (тот же `/scrape` root-роут — -`scraper_kit.providers.avito.serp.AvitoScraper`). Каждая пара функций ниже — чистое, -offline-тестируемое parsing-ядро, которое реально используется соответствующим -debug-эндпоинтом (не сам HTTP-fetch — см. `tests/support/README.md`: "Live network/DB -в parity-тестах избегайте"). - -- `_parse_house_page` — `scrape_avito_house` → `fetch_house_catalog` парсит виджет - `housePage` этой функцией. -- `_extract_rooms_from_title` / `_extract_area_from_title` — использует - `AvitoScraper` (SERP-парсинг `/scrape` root-роута) для title-fallback полей. - -Легаси `app.services.scrapers.avito_imv` (`scrape_avito_imv` → `evaluate_via_imv` → -`_parse_price`) удалён (#2277 финальный шаг scraper_kit-миграции, 0 runtime -importers) — parity-проверка `_parse_price` убрана вместе с ним; kit-сторона -`_parse_price` покрыта отдельно (`tests/scrapers/test_avito_imv_kit_parity.py` / -`tests/test_avito_imv_parse.py`). -""" - -from __future__ import annotations - -import dataclasses -import os - -# Соседние модули app.services.scrapers.* импортируют app.core.config при импорте -# пакета — фиктивный DSN мостит офлайн-запуск (mirror test_avito_detail_kit_parity.py). -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -from scraper_kit.providers.avito.houses import _parse_house_page as kit_parse_house_page -from scraper_kit.providers.avito.serp import ( - _extract_area_from_title as kit_extract_area_from_title, -) -from scraper_kit.providers.avito.serp import ( - _extract_rooms_from_title as kit_extract_rooms_from_title, -) - -from app.services.scrapers.avito import _extract_area_from_title as legacy_extract_area_from_title -from app.services.scrapers.avito import ( - _extract_rooms_from_title as legacy_extract_rooms_from_title, -) -from app.services.scrapers.avito_houses import _parse_house_page as legacy_parse_house_page -from tests.support.parity import assert_parity - -_HOUSE_WIDGET = { - "props": { - "developmentData": { - "avitoId": 3171365, - "id": "abc123hash", - "title": "ЖК Тестовый", - "address": "ул. Тестовая, 1", - "fullAddress": "Екатеринбург, ул. Тестовая, 1", - "coords": {"lat": 56.8, "lng": 60.6}, - "aboutDevelopment": { - "expandParams": { - "items": [ - { - "params": [ - {"type": "Год постройки", "value": "2020"}, - {"type": "Этажей", "value": "16"}, - {"type": "Пассажирский лифт", "value": "2"}, - ] - } - ] - } - }, - "ratingBadge": {"info": {"score": 4.5, "scoreString": "4.5"}}, - "ratingSummaryStat": {"reviewCount": 10, "ratingStat": []}, - "mapPreview": {"objects": "школа рядом", "distance": "5 мин", "pins": []}, - "developer": {"name": "ООО Застройщик", "key": "dev1"}, - } - } -} - - -def test_avito_house_page_parse_are_class_distinct() -> None: - """Sanity: legacy/kit HouseInfo — разные классы, обычный `==` даёт False.""" - legacy_result = legacy_parse_house_page(_HOUSE_WIDGET) - kit_result = kit_parse_house_page(_HOUSE_WIDGET) - - assert type(legacy_result) is not type(kit_result) - assert legacy_result != kit_result - assert dataclasses.asdict(legacy_result) == dataclasses.asdict(kit_result) - - -def test_parity_avito_house_page() -> None: - """`scrape_avito_house` (admin.py) → fetch_house_catalog → _parse_house_page.""" - assert_parity( - legacy_fn=legacy_parse_house_page, - kit_fn=kit_parse_house_page, - fixtures=[(_HOUSE_WIDGET,)], - ) - - -def test_parity_avito_serp_title_extract() -> None: - """`/scrape` root-роут (admin.py) → AvitoScraper SERP title-fallback fields.""" - titles = [ - "2-к. квартира, 54.3 м², 3/9 эт.", - "Студия, 25 м², 1/5 эт.", - "3-к. кв., 75,5 м², 12/16 эт.", - ] - assert_parity( - legacy_fn=legacy_extract_rooms_from_title, - kit_fn=kit_extract_rooms_from_title, - fixtures=titles, - ) - assert_parity( - legacy_fn=legacy_extract_area_from_title, - kit_fn=kit_extract_area_from_title, - fixtures=titles, - ) diff --git a/tradein-mvp/backend/tests/scrapers/test_admin_cian_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_admin_cian_kit_parity.py deleted file mode 100644 index 39fdc616..00000000 --- a/tradein-mvp/backend/tests/scrapers/test_admin_cian_kit_parity.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Parity-proof: admin.py `scrape_cian_detail` (#2305, Group A) — legacy vs scraper_kit. - -`app/api/v1/admin.py::scrape_cian_detail` теперь зовёт -`scraper_kit.providers.cian.detail.fetch_detail/save_detail_enrichment` вместо -`app.services.scrapers.cian_detail`. `_extract_price_changes` — чистая, -offline-тестируемая normalize-функция, которую `fetch_detail` вызывает при -сборке `DetailEnrichment.price_changes` (см. `tests/support/README.md`: -"Live network/DB в parity-тестах избегайте"). -""" - -from __future__ import annotations - -import os - -# cian_detail.py импортирует app.core.config при module-level import — фиктивный -# DSN мостит офлайн-запуск (mirror test_avito_detail_kit_parity.py). -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -from scraper_kit.providers.cian.detail import _extract_price_changes as kit_extract_price_changes - -from app.services.scrapers.cian_detail import ( - _extract_price_changes as legacy_extract_price_changes, -) -from tests.support.parity import assert_parity - -# Три реальных формы priceChanges: curl_cffi flat, browser-rendered nested, оба-пусто. -_OFFER_FLAT = { - "priceChanges": [ - {"changeTime": "2026-01-15T00:00:00", "price": 9000000, "diffPercent": -2.5}, - {"changeTime": "2026-02-01T00:00:00", "priceRub": 8800000, "diff_percent": -2.2}, - ] -} -_OFFER_DATA_NESTED = { - "priceChanges": [ - {"changeTime": "2026-01-15T00:00:00", "priceData": {"price": 9000000}}, - ] -} -_STATE_EMPTY: dict[str, object] = {} - - -def test_parity_cian_detail_price_changes() -> None: - """`scrape_cian_detail` (admin.py) → fetch_detail → _extract_price_changes.""" - assert_parity( - legacy_fn=legacy_extract_price_changes, - kit_fn=kit_extract_price_changes, - fixtures=[ - (_OFFER_FLAT, {}, _STATE_EMPTY), - ({}, _OFFER_DATA_NESTED, _STATE_EMPTY), - ({}, {}, _STATE_EMPTY), - ], - ) diff --git a/tradein-mvp/backend/tests/scrapers/test_admin_yandex_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_admin_yandex_kit_parity.py deleted file mode 100644 index 67cd2bce..00000000 --- a/tradein-mvp/backend/tests/scrapers/test_admin_yandex_kit_parity.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Parity-proof: admin.py Yandex debug-роуты (#2305, Group A) — legacy vs scraper_kit. - -`app/api/v1/admin.py::scrape_yandex_detail` / `scrape_yandex_valuation` теперь -зовут `scraper_kit.providers.yandex.{detail,valuation}` вместо -`app.services.scrapers.yandex_{detail,valuation}` (тот же `/scrape` root-роут — -`scraper_kit.providers.yandex.serp.YandexRealtyScraper`). Каждая функция ниже — -чистое, offline-тестируемое parsing-ядро (см. `tests/support/README.md`: -"Live network/DB в parity-тестах избегайте"). - -- `_entity_to_lot` — `/scrape` root-роут → `YandexRealtyScraper.fetch_around` - конвертирует gate-API entity → ScrapedLot этой функцией (также доказывает - ScrapedLot-контракт, общий с `save_listings`/`scripts/ingest_domclick_jsonl.py`). - -Легаси `app.services.scrapers.yandex_detail` / `yandex_valuation` удалены (#2277 -финальный шаг scraper_kit-миграции, 0 runtime importers) — `_parse_card_fields` / -`_parse_house_meta` parity-проверки убраны отсюда вместе с ними; kit-side coverage -для обеих функций сохранена standalone в `tests/test_yandex_detail_structural.py` / -`tests/test_yandex_valuation.py`. `yandex_realty` остаётся легаси (не мигрирован) — -его `_entity_to_lot` parity-проверка не тронута. -""" - -from __future__ import annotations - -import os - -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -from scraper_kit.providers.yandex.serp import _entity_to_lot as kit_entity_to_lot - -from app.services.scrapers.yandex_realty import _entity_to_lot as legacy_entity_to_lot -from tests.support.parity import assert_parity - -# gate-API entity (realty.yandex.ru), без creationDate/author — исключает -# time-dependent поля (days_on_market) из фикстуры (deterministic offline). -_YANDEX_ENTITY = { - "offerId": "12345", - "url": "//realty.yandex.ru/offer/12345", - "price": {"value": 8500000, "valuePerPart": 157000}, - "area": {"value": 54.3}, - "livingSpace": {"value": 32.1}, - "kitchenSpace": {"value": 9.5}, - "roomsTotal": 2, - "floorsOffered": [5], - "floorsTotal": 9, - "ceilingHeight": 2.7, - "building": {"builtYear": 2015, "buildingType": "MONOLIT"}, - "location": { - "point": {"latitude": 56.83, "longitude": 60.6}, - "geocoderAddress": "Екатеринбург, ул. Тестовая, 1", - }, - "mainImages": ["//avatars.mds.yandex.net/img1.jpg"], -} - - -def test_yandex_entity_to_lot_are_class_distinct() -> None: - """Sanity: legacy/kit ScrapedLot — разные классы (pydantic), обычный `==` даёт False.""" - legacy_result = legacy_entity_to_lot(_YANDEX_ENTITY) - kit_result = kit_entity_to_lot(_YANDEX_ENTITY) - - assert legacy_result is not None - assert kit_result is not None - assert type(legacy_result) is not type(kit_result) - assert legacy_result.model_dump() == kit_result.model_dump() - - -def test_parity_yandex_serp_entity_to_lot() -> None: - """`/scrape` root-роут (admin.py) → YandexRealtyScraper.fetch_around → _entity_to_lot. - - Также доказывает ScrapedLot-контракт, общий с `save_listings` - (используется и `/scrape`, и `scripts/ingest_domclick_jsonl.py`). - """ - assert_parity( - legacy_fn=legacy_entity_to_lot, - kit_fn=kit_entity_to_lot, - fixtures=[(_YANDEX_ENTITY,)], - ) - - -def test_parity_harness_catches_yandex_entity_divergence() -> None: - """Harness реально ЛОВИТ расхождение — мутируем price_rub в kit-результате.""" - import pytest - - from tests.support.parity import ParityMismatchError - - legacy_result = legacy_entity_to_lot(_YANDEX_ENTITY) - kit_result = kit_entity_to_lot(_YANDEX_ENTITY) - assert legacy_result is not None - assert kit_result is not None - - # ScrapedLot — pydantic BaseModel (НЕ dataclass) → model_copy, не dataclasses.replace. - mutated_kit_result = kit_result.model_copy(update={"price_rub": kit_result.price_rub + 1}) - - with pytest.raises(ParityMismatchError) as exc_info: - assert_parity( - legacy_fn=lambda: legacy_result, - kit_fn=lambda: mutated_kit_result, - fixtures=[()], - ) - assert "price_rub" in str(exc_info.value) diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py b/tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py index 683339be..4ee0ab61 100644 --- a/tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py +++ b/tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py @@ -3,7 +3,7 @@ До фикса было 3 независимые копии с разными timezone: - avito.py (inline в `_build_sort_timestamp_map`) — MSK (документировано #726) - - avito_houses.py — UTC + - avito_houses.py (легаси, удалён #2397 Part E2) — UTC - avito_imv.py (легаси, удалён #2277) — naive/system-local (`datetime.fromtimestamp(ts)` без tz) @@ -17,6 +17,11 @@ avito_imv_getdata.json: removedDate=1756077885 → UTC 2025-08-24, MSK 2025-08-2 kit-эквивалент `scraper_kit.providers.avito.imv`, который использует ТОТ ЖЕ паттерн (`from scraper_kit.providers.avito.shared import _unix_to_date`) — регрессия "3 независимые копии с разными TZ" остаётся под наблюдением и на kit-стороне. + +Легаси `app.services.scrapers.avito_houses` удалён (#2397 Part E2, 0 runtime +importers) — houses-ветка consolidation-проверки переведена на kit-эквивалент +`scraper_kit.providers.avito.houses`, который использует ТОТ ЖЕ паттерн +(`from scraper_kit.providers.avito.shared import RUS_MONTHS, _unix_to_date`). """ from __future__ import annotations @@ -26,10 +31,11 @@ from datetime import date os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") +from scraper_kit.providers.avito import houses as kit_avito_houses from scraper_kit.providers.avito import imv as kit_avito_imv from scraper_kit.providers.avito import shared as kit_avito_shared -from app.services.scrapers import avito, avito_houses, avito_shared +from app.services.scrapers import avito, avito_shared # Живой пример из fixtures/avito_imv_getdata.json (placementHistory.items[0].removedDate) — # именно тот timestamp, на котором UTC и MSK расходились в date() ДО фикса @@ -44,14 +50,15 @@ def test_shared_unix_to_date_uses_msk() -> None: def test_avito_houses_reexports_shared_unix_to_date() -> None: - """avito_houses.py больше не держит свою копию — импортирует общую функцию.""" - assert avito_houses._unix_to_date is avito_shared._unix_to_date - assert avito_houses._unix_to_date(_EPOCH_SECONDS) == _EXPECTED_MSK_DATE + """kit houses.py не держит свою копию — импортирует общую функцию (тот же паттерн, + что и легаси avito_houses.py до удаления, #2397 Part E2).""" + assert kit_avito_houses._unix_to_date is kit_avito_shared._unix_to_date + assert kit_avito_houses._unix_to_date(_EPOCH_SECONDS) == _EXPECTED_MSK_DATE def test_kit_avito_imv_reexports_shared_unix_to_date() -> None: """kit imv.py не держит свою копию — импортирует общую функцию (тот же паттерн, - что и легаси avito_houses.py/avito_shared.py после фикса #2129).""" + что и kit houses.py/avito_shared.py после фикса #2129).""" assert kit_avito_imv._unix_to_date is kit_avito_shared._unix_to_date assert kit_avito_imv._unix_to_date(_EPOCH_SECONDS) == _EXPECTED_MSK_DATE @@ -66,11 +73,11 @@ def test_avito_serp_sort_timestamp_matches_shared_date() -> None: ) ts_map = avito._build_sort_timestamp_map(html) assert ts_map["8043936560"] == _EXPECTED_MSK_DATE - # Легаси avito/avito_houses/avito_shared + kit avito_imv все согласованы на одном + # Легаси avito/avito_shared + kit avito_houses/avito_imv все согласованы на одном # моменте времени: assert ( ts_map["8043936560"] - == avito_houses._unix_to_date(_EPOCH_SECONDS) + == kit_avito_houses._unix_to_date(_EPOCH_SECONDS) == kit_avito_imv._unix_to_date(_EPOCH_SECONDS) == avito_shared._unix_to_date(_EPOCH_SECONDS) ) @@ -81,7 +88,7 @@ def test_rus_months_single_source() -> None: from app.services.scrapers import avito_detail assert avito_detail.RUS_MONTHS is avito_shared.RUS_MONTHS - assert avito_houses.RUS_MONTHS is avito_shared.RUS_MONTHS + assert kit_avito_houses.RUS_MONTHS is kit_avito_shared.RUS_MONTHS assert avito_shared.RUS_MONTHS["декабря"] == 12 diff --git a/tradein-mvp/backend/tests/scrapers/test_cian_exhaustive.py b/tradein-mvp/backend/tests/scrapers/test_cian_exhaustive.py deleted file mode 100644 index 888918ce..00000000 --- a/tradein-mvp/backend/tests/scrapers/test_cian_exhaustive.py +++ /dev/null @@ -1,792 +0,0 @@ -"""Тесты exhaustive-загрузки Cian: fetch_all_secondary + _walk_price_range. - -Мокаем сетевые вызовы (_fetch_page_html, _extract_total_offers, sleep_between_requests) -для детерминированного контроля поведения без реального HTTP. -""" - -from __future__ import annotations - -import os -from unittest.mock import AsyncMock, MagicMock, patch - -# Settings requires DATABASE_URL at import time — set dummy DSN before any app import. -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") - -import logging - -import pytest - -from app.services.scrapers.base import ScrapedLot -from app.services.scrapers.cian import ( - _CIAN_OFFERS_PER_PAGE, - _MAX_PRICE, - _MIN_BRACKET, - CianScraper, -) -from app.services.scrapers.price_brackets import get_price_seed_brackets - -_SEED = get_price_seed_brackets() - - -def _make_lot(source_id: str, price: int = 3_000_000) -> ScrapedLot: - """Минимальный ScrapedLot для теста.""" - return ScrapedLot( - source="cian", - source_url=f"https://ekb.cian.ru/sale/flat/{source_id}/", - source_id=source_id, - address="Екатеринбург", - price_rub=price, - ) - - -def _make_html_stub(total_offers: int, lot_ids: list[str]) -> str: - """Заглушка HTML — не парсится напрямую; используется через мокнутые методы.""" - return f"total={total_offers} ids={lot_ids}" - - -@pytest.fixture -def scraper() -> CianScraper: - """CianScraper без реального __aenter__ (без HTTP warm-up).""" - s = CianScraper.__new__(CianScraper) - s.name = "cian" - s.base_url = "https://ekb.cian.ru" - s.request_delay_sec = 0.0 - s._browser = MagicMock() # не None → assert self._browser is not None пройдёт - return s - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_splits_on_cap(scraper: CianScraper) -> None: - """totalOffers > cap → бакет разбивается; в итоге оба суб-бакета пагинируются.""" - # Настройка: rooms=(1,), один бакет [0, None] - # Probe [0, None]: totalOffers=3000 > cap=1400 → split на [0, MAX_PRICE] - # Probe [0, MAX_PRICE]: totalOffers=3000 > 1400 → split на [0, mid] и [mid+1, MAX_PRICE] - # Probe [0, mid] (mid = MAX_PRICE//2 = 100_000_000): totalOffers=800 <= cap → пагинировать - # Probe [mid+1, MAX_PRICE]: totalOffers=700 <= cap → пагинировать - - mid = _MAX_PRICE // 2 - - call_log: list[tuple] = [] - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - call_log.append((rooms, page, min_price, max_price)) - return f"page={page} min={min_price} max={max_price}" - - def fake_extract_total_offers(html: str) -> int | None: - # Парсим min/max из fake html - import re - - min_m = re.search(r"min=(\w+)", html) - max_m = re.search(r"max=(\w+)", html) - min_p = int(min_m.group(1)) if min_m and min_m.group(1) != "None" else 0 - max_p = int(max_m.group(1)) if max_m and max_m.group(1) != "None" else _MAX_PRICE - - # Имитируем разделение: весь диапазон > 1400, левая и правая половины <= 1400 - if max_p is None or max_p >= _MAX_PRICE: - if min_p == 0: - return 3000 # весь диапазон — нужно split - if max_p == mid: - return 800 # левая половина — пагинировать - if min_p == mid + 1: - return 700 # правая половина — пагинировать - return 3000 # всё остальное — split - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - min_m = re.search(r"min=(\w+)", html) - max_m = re.search(r"max=(\w+)", html) - page = int(page_m.group(1)) if page_m else 1 - min_p = min_m.group(1) if min_m else "0" - max_p = max_m.group(1) if max_m else "MAX" - # Возвращаем по 2 лота на первой странице каждого пагинируемого бакета - if page == 1: - return [ - _make_lot(f"lot_{min_p}_{max_p}_p{page}_1"), - _make_lot(f"lot_{min_p}_{max_p}_p{page}_2"), - ] - return [] # страница 2+ — пусто → early stop - - async def fake_sleep(*_: object, **__: object) -> None: - pass - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - lots = await scraper.fetch_all_secondary( - rooms_buckets=[(1,)], - price_cap_per_bucket=1400, - ) - - # Должны получить лоты из ОБОИХ суб-бакетов после split - assert len(lots) > 0, "Должны получить лоты после разделения бакета" - - # Проверяем что был probe с большим диапазоном И с sub-бакетами - probed_ranges = [(min_p, max_p) for (_, page, min_p, max_p) in call_log if page == 1] - # Должен быть probe [0, None] или [0, MAX] — начальный probe всего диапазона - initial_probes = [r for r in probed_ranges if r[0] is None or r[0] == 0] - assert ( - len(initial_probes) >= 1 - ), f"Ожидался probe всего диапазона, probed_ranges={probed_ranges}" - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_paginates_when_under_cap(scraper: CianScraper) -> None: - """totalOffers <= cap → пагинирует бакет полностью (страница за страницей).""" - pages_fetched: list[int] = [] - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - pages_fetched.append(page) - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 56 # 56 офферов → ceil(56/28) = 2 страницы - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - if page <= 2: - return [_make_lot(f"lot_p{page}_{i}") for i in range(28)] - return [] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - lots = await scraper.fetch_all_secondary( - rooms_buckets=[(2,)], - price_cap_per_bucket=1400, - ) - - # 28 лотов на стр 1 + 28 на стр 2 = 56, дедуп по source_id - assert len(lots) == 56, f"Ожидалось 56 уникальных лотов, получено {len(lots)}" - # Должно быть 2 страницы пагинации (page=1 probe + page=2) - assert 2 in pages_fetched, "Должна быть запрошена страница 2" - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_min_bracket_guard(scraper: CianScraper) -> None: - """Если hi - lo < MIN_BRACKET → пагинируем как есть (не делим бесконечно).""" - call_count = 0 - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - nonlocal call_count - call_count += 1 - if call_count > 100: - raise RuntimeError("Бесконечная рекурсия — MIN_BRACKET guard не сработал") - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - # Всегда > cap → без guard делилось бы бесконечно - return 5000 - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - return [] # пустой → early stop в пагинации - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - # Запускаем с очень узким диапазоном — guard должен остановить деление - await scraper._walk_price_range( - rooms=(1,), - lo=1_000_000, - hi=1_000_000 + _MIN_BRACKET - 1, # bracket < MIN_BRACKET - seen={}, - price_cap_per_bucket=1400, - max_pages_per_bucket=54, - ) - - # Guard сработал: нет RuntimeError (бесконечная рекурсия не возникла). - # Параллельная пагинация запускает до max_pages_per_bucket=54 страниц единовременно - # (все страницы бакета параллельны), поэтому call_count может быть до 54. - # Важно что нет рекурсии (bracket < MIN_BRACKET → один проход пагинации, не деление). - assert call_count <= 55, f"Слишком много вызовов ({call_count}) — guard не сработал" - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_on_bucket_called_per_leaf(scraper: CianScraper) -> None: - """on_bucket вызывается после каждого leaf-бакета с лотами бакета.""" - # totalOffers=56 ≤ cap → leaf-бакет, пагинируется параллельно 2 страницы - bucket_calls: list[tuple[str, list[ScrapedLot]]] = [] - - def fake_on_bucket(bucket_key: str, lots: list[ScrapedLot]) -> None: - bucket_calls.append((bucket_key, list(lots))) - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 56 # ceil(56/28) = 2 страницы - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - return [_make_lot(f"lot_p{page}_{i}") for i in range(10)] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - await scraper.fetch_all_secondary( - rooms_buckets=[(1,)], - price_cap_per_bucket=1400, - concurrency=3, - on_bucket=fake_on_bucket, - ) - - # on_bucket вызван по разу на каждый seed-брекет (все непустые leaf-бакеты). - assert len(bucket_calls) == len( - _SEED - ), f"Ожидалось {len(_SEED)} вызовов on_bucket, получено {len(bucket_calls)}" - key, lots = bucket_calls[0] - # Ключ бакета содержит room_label:lo:hi - assert "room1" in key, f"bucket_key должен содержать room1, got {key!r}" - # Лоты из обеих страниц переданы в on_bucket - assert len(lots) == 20, f"Ожидалось 20 лотов в on_bucket (2 стр × 10), получено {len(lots)}" - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_on_bucket_cancel_stops_run(scraper: CianScraper) -> None: - """Если on_bucket кидает RuntimeError('cancelled') — прогон прерывается.""" - - def cancel_on_bucket(bucket_key: str, lots: list[ScrapedLot]) -> None: - raise RuntimeError("cancelled") - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 28 # один лист → 1 страница - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - return [_make_lot("lot_1")] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - with pytest.raises(RuntimeError, match="cancelled"): - await scraper.fetch_all_secondary( - rooms_buckets=[(1,), (2,)], - price_cap_per_bucket=1400, - on_bucket=cancel_on_bucket, - ) - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_concurrent_pages_deduped(scraper: CianScraper) -> None: - """Параллельная пагинация не ломает дедупликацию по source_id.""" - fetch_pages: list[int] = [] - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - fetch_pages.append(page) - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 84 # ceil(84/28) = 3 страницы - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - # Страница 1 и 2 дают уникальные лоты; страница 3 дублирует страницу 1 - if page == 3: - return [_make_lot(f"lot_p1_{i}") for i in range(10)] # дубликаты - return [_make_lot(f"lot_p{page}_{i}") for i in range(10)] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - lots = await scraper.fetch_all_secondary( - rooms_buckets=[(1,)], - price_cap_per_bucket=1400, - concurrency=5, - ) - - # Страницы 1+2 = 20 уникальных, страница 3 дублирует → итого 20 - assert len(lots) == 20, f"Ожидалось 20 уникальных лотов (дедуп), получено {len(lots)}" - # Все 3 страницы запрошены (параллельно или нет — нам важен факт) - assert 3 in fetch_pages or 3 in [ - p for p in fetch_pages - ], f"Страница 3 не была запрошена, fetch_pages={fetch_pages}" - - -@pytest.mark.asyncio -async def test_skip_buckets_skips_pagination_and_on_bucket(scraper: CianScraper) -> None: - """skip_buckets: для скипнутого бакета пагинация не вызывается, on_bucket не вызывается.""" - fetch_calls: list[tuple] = [] - on_bucket_calls: list[str] = [] - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - fetch_calls.append((rooms, page, min_price, max_price)) - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 28 # один leaf-бакет, 1 страница - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - return [_make_lot("lot_1")] - - def fake_on_bucket(bucket_key: str, lots: list[ScrapedLot]) -> None: - on_bucket_calls.append(bucket_key) - - # Скипаем ВСЕ seed-бакеты комнатности room1. Закрытые → ':', открытый → ':open'. - skip_set: set[str] = set() - for br_lo, br_hi in _SEED: - hi_repr = "open" if br_hi is None else str(br_hi - 1) - skip_set.add(f"room1:{br_lo}:{hi_repr}") - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - lots = await scraper.fetch_all_secondary( - rooms_buckets=[(1,)], - price_cap_per_bucket=1400, - on_bucket=fake_on_bucket, - skip_buckets=skip_set, - ) - - # Probe (page=1) ВСЁ РАВНО вызывается на каждый seed-брекет (нужен для split-решения), - # но НЕ должно быть страниц пагинации (все бакеты в skip_buckets). - # Probe = page=1; пагинация пропущена ДО fetch остальных страниц → ровно len(_SEED) probe. - assert len(fetch_calls) == len( - _SEED - ), f"Ожидалось {len(_SEED)} probe-вызовов, получено {len(fetch_calls)}: {fetch_calls}" - assert all(c[1] == 1 for c in fetch_calls), "Все вызовы должны быть probe page=1" - # on_bucket не вызывается для skip-бакетов - assert ( - len(on_bucket_calls) == 0 - ), f"on_bucket не должен вызываться для skip-бакетов, got: {on_bucket_calls}" - # lots пустой — ничего не собрали (пагинация пропущена) - assert len(lots) == 0, f"Ожидалось 0 лотов для skip-бакетов, got {len(lots)}" - - -@pytest.mark.asyncio -async def test_on_bucket_receives_key_and_lots(scraper: CianScraper) -> None: - """on_bucket получает (bucket_key, lots) — bucket_key в формате room_label:lo:hi.""" - received: list[tuple[str, int]] = [] # (key, len(lots)) - - def fake_on_bucket(bucket_key: str, lots: list[ScrapedLot]) -> None: - received.append((bucket_key, len(lots))) - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 28 # 1 страница - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - return [_make_lot(f"lot_p{page}_{i}") for i in range(5)] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - await scraper.fetch_all_secondary( - rooms_buckets=[(2,)], - price_cap_per_bucket=1400, - on_bucket=fake_on_bucket, - ) - - assert len(received) == len( - _SEED - ), f"Ожидалось {len(_SEED)} вызовов on_bucket, получено {len(received)}" - keys = [k for (k, _c) in received] - # Закрытые брекеты: hi = br_hi - 1. Открытый (последний): ':open'. - first_lo, first_hi = _SEED[0] - assert first_hi is not None - assert keys[0] == f"room2:{first_lo}:{first_hi - 1}", f"Неверный ключ: {keys[0]!r}" - last_lo, last_hi = _SEED[-1] - assert last_hi is None - assert keys[-1] == f"room2:{last_lo}:open", f"Открытый ключ неверен: {keys[-1]!r}" - assert all(count == 5 for (_k, count) in received) - - -# ── secondary_only фильтр ───────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_secondary_only_drops_novostroyki(scraper: CianScraper) -> None: - """secondary_only=True: новостройки (listing_segment=='novostroyki') не попадают - в on_bucket и в итоговый список; вторичка и None-сегмент — пропускаются. - """ - on_bucket_calls: list[list[ScrapedLot]] = [] - - def fake_on_bucket(bucket_key: str, lots: list[ScrapedLot]) -> None: - on_bucket_calls.append(list(lots)) - - # Микс лотов: 2 новостройки + 2 вторички + 1 None-сегмент - mixed_lots = [ - ScrapedLot( - source="cian", - source_url="https://ekb.cian.ru/sale/flat/nb_1/", - source_id="nb_1", - address="ЖК Новый", - price_rub=5_000_000, - listing_segment="novostroyki", - ), - ScrapedLot( - source="cian", - source_url="https://ekb.cian.ru/sale/flat/nb_2/", - source_id="nb_2", - address="ЖК Новый 2", - price_rub=6_000_000, - listing_segment="novostroyki", - ), - ScrapedLot( - source="cian", - source_url="https://ekb.cian.ru/sale/flat/vt_1/", - source_id="vt_1", - address="ул. Ленина", - price_rub=3_500_000, - listing_segment="vtorichka", - ), - ScrapedLot( - source="cian", - source_url="https://ekb.cian.ru/sale/flat/vt_2/", - source_id="vt_2", - address="ул. Мира", - price_rub=4_000_000, - listing_segment="vtorichka", - ), - ScrapedLot( - source="cian", - source_url="https://ekb.cian.ru/sale/flat/none_seg/", - source_id="none_seg", - address="ул. Пушкина", - price_rub=3_000_000, - listing_segment=None, - ), - ] - - page_call_count = 0 - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - nonlocal page_call_count - page_call_count += 1 - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 5 # <= cap → leaf-бакет - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - # Первая страница отдаёт весь микс; вторая — пусто - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - return mixed_lots if page == 1 else [] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - lots = await scraper.fetch_all_secondary( - rooms_buckets=[(1,)], - price_cap_per_bucket=1400, - secondary_only=True, - on_bucket=fake_on_bucket, - ) - - # Итоговый список: только vtorichka + None-сегмент (3 лота), без новостроек - assert len(lots) == 3, f"Ожидалось 3 лота (без novostroyki), получено {len(lots)}" - lot_ids = {lot.source_id for lot in lots} - assert "nb_1" not in lot_ids, "nb_1 (novostroyki) не должен присутствовать" - assert "nb_2" not in lot_ids, "nb_2 (novostroyki) не должен присутствовать" - assert "vt_1" in lot_ids, "vt_1 (vtorichka) должен присутствовать" - assert "vt_2" in lot_ids, "vt_2 (vtorichka) должен присутствовать" - assert "none_seg" in lot_ids, "none_seg (listing_segment=None) должен присутствовать" - - # on_bucket вызывается на каждый seed-брекет (тот же микс на page=1 каждого), - # но новостройки отфильтрованы в каждом вызове. - assert len(on_bucket_calls) == len( - _SEED - ), f"Ожидалось {len(_SEED)} вызовов on_bucket, получено {len(on_bucket_calls)}" - for call_lots in on_bucket_calls: - bucket_ids = {lot.source_id for lot in call_lots} - assert "nb_1" not in bucket_ids - assert "nb_2" not in bucket_ids - assert len(call_lots) == 3 - - -@pytest.mark.asyncio -async def test_secondary_only_false_passes_all(scraper: CianScraper) -> None: - """secondary_only=False: новостройки НЕ отфильтровываются — все лоты проходят.""" - mixed_lots = [ - ScrapedLot( - source="cian", - source_url="https://ekb.cian.ru/sale/flat/nb_1/", - source_id="nb_1", - address="ЖК Новый", - price_rub=5_000_000, - listing_segment="novostroyki", - ), - ScrapedLot( - source="cian", - source_url="https://ekb.cian.ru/sale/flat/vt_1/", - source_id="vt_1", - address="ул. Ленина", - price_rub=3_500_000, - listing_segment="vtorichka", - ), - ] - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 2 - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - return mixed_lots if page == 1 else [] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - lots = await scraper.fetch_all_secondary( - rooms_buckets=[(1,)], - price_cap_per_bucket=1400, - secondary_only=False, - ) - - assert len(lots) == 2, f"Ожидалось 2 лота (фильтр выключен), получено {len(lots)}" - lot_ids = {lot.source_id for lot in lots} - assert "nb_1" in lot_ids, "nb_1 должен присутствовать при secondary_only=False" - assert "vt_1" in lot_ids - - -# ── #1393: tail-loss warning при превышении page cap ───────────────────────── - - -@pytest.mark.asyncio -async def test_walk_price_range_warns_on_page_cap_tail_loss( - scraper: CianScraper, caplog: pytest.LogCaptureFixture -) -> None: - """#1393: когда totalOffers > max_pages_per_bucket × offers_per_page, - _walk_price_range логирует WARNING с числом потерянных офферов. - - Сценарий: bracket < _MIN_BRACKET (guard срабатывает → force-paginate), - totalOffers = max_pages * offers_per_page + 50 → tail_loss = 50. - """ - max_pages_cap = 5 # нарочно мало, чтобы быстро дойти до cap - offers_per_page = _CIAN_OFFERS_PER_PAGE # 28 - # total > cap (2000 > любой price_cap_per_bucket=1400) И bracket < _MIN_BRACKET - # → force-paginate path; pages_needed = ceil(2000/28) = 72 > max_pages_cap=5 - total_offers = max_pages_cap * offers_per_page + 50 # 190; tail_loss=50 - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return total_offers - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - return [_make_lot(f"lot_p{page}_{i}") for i in range(offers_per_page)] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - caplog.at_level(logging.WARNING, logger="app.services.scrapers.cian"), - ): - await scraper._walk_price_range( - rooms=(1,), - # Узкий диапазон: bracket < _MIN_BRACKET → force-paginate guard - lo=1_000_000, - hi=1_000_000 + _MIN_BRACKET - 1, - seen={}, - price_cap_per_bucket=1400, # total(190) < 1400 → нет split, только leaf - max_pages_per_bucket=max_pages_cap, - ) - - # Должно быть предупреждение о tail-loss - tail_warnings = [ - r for r in caplog.records if "tail loss" in r.message and r.levelno == logging.WARNING - ] - assert tail_warnings, ( - f"Ожидалось WARNING о tail loss, но записей нет. " - f"Все warnings: {[r.message for r in caplog.records if r.levelno == logging.WARNING]}" - ) - # Сообщение должно содержать число потерянных офферов (50) - assert any( - "50" in r.message for r in tail_warnings - ), f"WARNING должен содержать tail_loss=50, got: {[r.message for r in tail_warnings]}" - - -@pytest.mark.asyncio -async def test_walk_price_range_no_warning_when_within_page_cap( - scraper: CianScraper, caplog: pytest.LogCaptureFixture -) -> None: - """#1393: когда totalOffers <= max_pages_per_bucket × offers_per_page, - tail-loss WARNING не должен эмитироваться (данные собраны полностью). - """ - max_pages_cap = 54 - total_offers = 56 # ceil(56/28) = 2 страницы < 54 cap → нет потерь - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return total_offers - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - return [_make_lot(f"lot_p{page}_{i}") for i in range(10)] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - caplog.at_level(logging.WARNING, logger="app.services.scrapers.cian"), - ): - await scraper._walk_price_range( - rooms=(1,), - lo=0, - hi=_MAX_PRICE, - seen={}, - price_cap_per_bucket=1400, - max_pages_per_bucket=max_pages_cap, - ) - - tail_warnings = [ - r for r in caplog.records if "tail loss" in r.message and r.levelno == logging.WARNING - ] - assert not tail_warnings, ( - f"Не ожидалось WARNING о tail loss при total={total_offers} <= page cap, " - f"но получено: {[r.message for r in tail_warnings]}" - ) - - -@pytest.mark.asyncio -async def test_walk_price_range_open_bracket_no_split(scraper: CianScraper) -> None: - """Открытый брекет (hi=None) не делится даже при total > cap — leaf без maxprice. - - bucket_key оканчивается на ':open'; maxprice в запросах всегда None (без потолка). - """ - on_bucket_keys: list[str] = [] - seen_max: list[int | None] = [] - - async def fake_fetch_page_html( - rooms: tuple, page: int, min_price: int | None, max_price: int | None - ) -> str: - seen_max.append(max_price) - return f"page={page}" - - def fake_extract_total_offers(html: str) -> int | None: - return 5000 # > cap, но открытый брекет делить нельзя → leaf - - def fake_parse_serp_html(html: str) -> list[ScrapedLot]: - import re - - page_m = re.search(r"page=(\d+)", html) - page = int(page_m.group(1)) if page_m else 1 - return [_make_lot(f"lot_p{page}")] if page == 1 else [] - - with ( - patch.object(scraper, "_fetch_page_html", side_effect=fake_fetch_page_html), - patch.object(scraper, "_extract_total_offers", side_effect=fake_extract_total_offers), - patch.object(scraper, "_parse_serp_html", side_effect=fake_parse_serp_html), - patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock), - patch.object(scraper, "_rotate_ip", return_value=False), - ): - await scraper._walk_price_range( - rooms=(1,), - lo=250_000_000, - hi=None, - seen={}, - price_cap_per_bucket=1400, - max_pages_per_bucket=54, - on_bucket=lambda k, lots: on_bucket_keys.append(k), - ) - - # Открытый брекет: ровно один leaf-бакет с ключом ':open', maxprice всегда None. - assert on_bucket_keys == [ - "room1:250000000:open" - ], f"Открытый брекет должен дать один ':open'-бакет, got {on_bucket_keys}" - assert all( - m is None for m in seen_max - ), f"Открытый брекет: maxprice всегда None (без потолка), got {seen_max}" diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick.py b/tradein-mvp/backend/tests/scrapers/test_domclick.py deleted file mode 100644 index dc613020..00000000 --- a/tradein-mvp/backend/tests/scrapers/test_domclick.py +++ /dev/null @@ -1,630 +0,0 @@ -"""Unit-тесты для DomClick BFF scraper (#1968). - -Тестируем: -- _extract_json: bare +
-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,
-    _build_count_url,
-    _build_offers_url,
-    _extract_json,
-)
-from app.services.scrapers.domclick_exceptions import DomClickBlockedError
-
-# ── _extract_json ─────────────────────────────────────────────────────────────
-
-
-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
-
-
-def test_extract_json_pre_wrapped() -> None:
-    """JSON в 
-теге корректно парсится."""
-    html = (
-        "DomClick"
-        '
{"result": {"items": [{"id": 1}]}}
' - ) - data = _extract_json(html) - assert data["result"]["items"][0]["id"] == 1 - - -def test_extract_json_qrator_marker_raises() -> None: - """HTML с QRATOR-маркером вызывает DomClickBlockedError.""" - blocked_html = ( - "QRATOR protection" - "Access denied by bot_mitigation system" - ) - with pytest.raises(DomClickBlockedError): - _extract_json(blocked_html) - - -def test_extract_json_captcha_raises() -> None: - """HTML с captcha-маркером вызывает DomClickBlockedError.""" - html = "Please solve the captcha to continue" - with pytest.raises(DomClickBlockedError): - _extract_json(html) - - -def test_extract_json_no_json_raises() -> None: - """HTML без JSON-объекта вызывает ValueError.""" - with pytest.raises(ValueError, match="No JSON"): - _extract_json("plain text") - - -# ── _build_offers_url ──────────────────────────────────────────────────────── - - -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 - - -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 - - -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 - - -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_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?") - - -# ── _build_count_url ───────────────────────────────────────────────────────── - - -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_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_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 - - -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) - - -# ── _map_item Layer A enrichment (description / repair / minhash / seller) ───── - -_VALID_REPAIR_ENUM = {"needs_repair", "standard", "good", "excellent"} - - -def test_map_item_description_repair_minhash() -> None: - """Offer с описанием → description set, repair_state выведен, minhash len 32.""" - scraper = DomClickScraper.__new__(DomClickScraper) - scraper.parse_failures = 0 - item = { - **_REALISTIC_ITEM, - "description": "Продаётся светлая квартира с евроремонтом, рядом метро.", - } - lot = scraper._map_item(item) - assert lot is not None - assert lot.description == "Продаётся светлая квартира с евроремонтом, рядом метро." - # «евроремонт» → good (см. repair_state_normalizer._TEXT_PATTERNS) - assert lot.repair_state == "good" - assert lot.repair_state in _VALID_REPAIR_ENUM - assert lot.description_minhash is not None - assert len(lot.description_minhash) == 32 - - -def test_map_item_description_stripped() -> None: - """description триммится; пустой/whitespace → None.""" - scraper = DomClickScraper.__new__(DomClickScraper) - scraper.parse_failures = 0 - item = {**_REALISTIC_ITEM, "description": " Без отделки, требуется ремонт. "} - lot = scraper._map_item(item) - assert lot is not None - assert lot.description == "Без отделки, требуется ремонт." - # «без отделк» → needs_repair - assert lot.repair_state == "needs_repair" - - -def test_map_item_enrichment_raw_payload() -> None: - """Новые SERP-поля попадают в raw_payload (is_sber_collateral и т.д.).""" - scraper = DomClickScraper.__new__(DomClickScraper) - scraper.parse_failures = 0 - item = { - **_REALISTIC_ITEM, - "isSberCollateral": True, - "hasDiscount": True, - "discountValue": 2.3, - "duplicatesOfferCount": 3, - "seller": {"company": "Этажи", "agent": "Иван Петров"}, - } - lot = scraper._map_item(item) - assert lot is not None - assert lot.raw_payload is not None - assert lot.raw_payload["is_sber_collateral"] is True - assert lot.raw_payload["has_discount"] is True - assert lot.raw_payload["discount_value"] == 2.3 - assert lot.raw_payload["duplicates_offer_count"] == 3 - assert lot.raw_payload["seller"] == {"company": "Этажи", "agent": "Иван Петров"} - # agency_name извлекается из seller.company (приоритет company → agent) - assert lot.agency_name == "Этажи" - - -def test_map_item_agency_name_from_agent_dict() -> None: - """seller.company отсутствует, agent — dict с name → agency_name = name.""" - scraper = DomClickScraper.__new__(DomClickScraper) - scraper.parse_failures = 0 - item = {**_REALISTIC_ITEM, "seller": {"agent": {"name": "Мария С."}}} - lot = scraper._map_item(item) - assert lot is not None - assert lot.agency_name == "Мария С." - assert lot.raw_payload is not None - assert lot.raw_payload["seller"] == {"agent": {"name": "Мария С."}} - - -def test_map_item_no_description_no_crash() -> None: - """Offer без description: не падает, description/repair_state/minhash = None.""" - scraper = DomClickScraper.__new__(DomClickScraper) - scraper.parse_failures = 0 - # _REALISTIC_ITEM не содержит description/seller/enrichment-полей - lot = scraper._map_item(_REALISTIC_ITEM) - assert lot is not None - assert lot.description is None - assert lot.repair_state is None - assert lot.description_minhash is None - assert lot.agency_name is None - - -def test_map_item_enrichment_preserves_existing_raw_payload_and_drops_none() -> None: - """Существующие raw_payload-ключи не теряются; None-enrich-ключи отброшены.""" - 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" - assert "updatedDate" in lot.raw_payload - # None-значные enrich-ключи отсутствуют (компактный payload) - assert "is_sber_collateral" not in lot.raw_payload - assert "has_discount" not in lot.raw_payload - assert "discount_value" not in lot.raw_payload - assert "duplicates_offer_count" not in lot.raw_payload - assert "seller" not in lot.raw_payload - - -# ── _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 = "
{not valid json at all
" - 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.__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" - - -def test_fetch_around_raises() -> None: - scraper = DomClickScraper.__new__(DomClickScraper) - with pytest.raises(NotImplementedError, match="geo-radius"): - asyncio.run(scraper.fetch_around(56.8, 60.6)) diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index cd629987..ff80ca8e 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -24,9 +24,10 @@ test_cian_detail.py — в проекте нет live-DB фикстуры, ид scraper_kit-миграции, 0 runtime importers) — тесты переведены на kit-эквивалент `scraper_kit.providers.domclick.detail` без изменения покрытия. Exceptions тоже переведены на `scraper_kit.domclick_exceptions` (kit держит свою независимую копию, -`parse_detail_html`(kit) поднимает именно её — legacy `app.services.scrapers. -domclick_exceptions` остаётся живым модулем для `domclick.py`, но здесь больше не -подходящий класс для `pytest.raises`). +`parse_detail_html`(kit) поднимает именно её). Legacy `app.services.scrapers. +domclick`/`domclick_exceptions` удалены целиком (#2397 Part E2, 0 runtime +importers) — `scraper_kit.domclick_exceptions` остаётся единственным живым классом +для `pytest.raises`. """ from __future__ import annotations diff --git a/tradein-mvp/backend/tests/test_avito_houses_dedup.py b/tradein-mvp/backend/tests/test_avito_houses_dedup.py deleted file mode 100644 index d32ad4b9..00000000 --- a/tradein-mvp/backend/tests/test_avito_houses_dedup.py +++ /dev/null @@ -1,381 +0,0 @@ -"""Tests for avito_houses dedup fix — _persist_house делегирует в match_or_create_house. - -Проверяет: - 1. Два HouseInfo с разными ext_id но одинаковым адресом и координатами - → _persist_house возвращает ОДИН house_id (не плодит дубль). - 2. Enrichment-поля (rating_score, developer_name) реально записываются в UPDATE. - 3. _persist_house не вызывает db.commit(). - 4. upsert_house и _upsert_house_with_url делегируют в _persist_house. - 5. #1820: два id-less URL (ext_id=0, без адреса) НЕ создают house_sources(avito,"0"). - -Использует тот же паттерн mock-DB что в test_matching.py и test_same_building_match.py. -""" - -import json -import os - -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -from unittest.mock import MagicMock, patch - -from app.services.scrapers.avito_houses import ( - HouseCatalogEnrichment, - HouseInfo, - _persist_house, - _upsert_house_with_url, - save_house_catalog_enrichment, - upsert_house, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_house(ext_id: int = 111, address: str = "ул. Ленина, 5") -> HouseInfo: - return HouseInfo( - ext_id=ext_id, - ext_id_hash="hash_" + str(ext_id), - short_address=address, - full_address="Свердловская обл., Екатеринбург, " + address, - lat=56.8378, - lon=60.5946, - year_built=2010, - house_type="monolith", - total_floors=16, - passenger_elevators=2, - cargo_elevators=1, - has_concierge=True, - closed_yard=False, - material_floors="concrete", - hot_water="central", - parking_type="underground", - has_playground=True, - developer_name="ПИК", - developer_key="pik", - infrastructure_summary="Школа, детсад", - infrastructure_walk_distance="5 мин", - rating_score=4.5, - rating_string="4.5 из 5", - reviews_count=120, - rating_distribution=[{"score": 5, "count": 80}], - map_pins=[{"type": "metro", "name": "Уральская"}], - raw_characteristics=[{"title": "Год постройки", "value": "2010"}], - ) - - -def _make_db_for_persist(canonical_id: int = 42) -> MagicMock: - """Mock DB для _persist_house: match_or_create_house ходит через db.execute, - поэтому патчим match_or_create_house целиком и проверяем вызов UPDATE. - """ - db = MagicMock() - # db.execute для UPDATE houses SET ... — просто возвращаем None (rowcount не важен) - db.execute.return_value = MagicMock() - return db - - -# --------------------------------------------------------------------------- -# Test 1: два HouseInfo с разными ext_id, одинаковый адрес → один house_id -# --------------------------------------------------------------------------- - - -def test_persist_house_same_address_returns_same_id(): - """Два HouseInfo с разными ext_id но одинаковым адресом → один canonical house_id. - - Моделируем случай: первый вызов (ext_id=111) → match_or_create_house вернул 42 (new). - Второй вызов (ext_id=222, тот же адрес) → match_or_create_house вернул 42 (fingerprint). - Оба _persist_house должны вернуть 42. - """ - db = MagicMock() - db.execute.return_value = MagicMock() - - h1 = _make_house(ext_id=111) - h2 = _make_house(ext_id=222) # другой ext_id, тот же адрес/координаты - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - side_effect=[(42, 1.0, "new"), (42, 0.9, "fingerprint")], - ) as mock_match: - id1 = _persist_house(db, h1, "https://avito.ru/house/111") - id2 = _persist_house(db, h2, "https://avito.ru/house/222") - - assert id1 == 42, f"Первый вызов должен вернуть 42, получили {id1}" - assert id2 == 42, f"Второй вызов должен вернуть тот же 42, получили {id2}" - assert id1 == id2, "Оба вызова должны вернуть один house_id (нет дубля)" - assert mock_match.call_count == 2 - - -# --------------------------------------------------------------------------- -# Test 2: enrichment-поля записываются в UPDATE -# --------------------------------------------------------------------------- - - -def test_persist_house_enrichment_fields_written(): - """Enrichment-поля (rating_score, developer_name) передаются в UPDATE-запрос.""" - db = MagicMock() - db.execute.return_value = MagicMock() - - h = _make_house(ext_id=111) - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(42, 1.0, "source_exact"), - ): - _persist_house(db, h, "https://avito.ru/house/111") - - # db.execute должен быть вызван 1 раз (UPDATE houses SET ...) - assert ( - db.execute.call_count == 1 - ), f"Ожидался 1 вызов db.execute (UPDATE), получили {db.execute.call_count}" - - # Проверяем параметры bind - call_args = db.execute.call_args - sql_obj = call_args[0][0] - bind = call_args[0][1] - - assert "UPDATE houses" in str(sql_obj), "SQL должен содержать UPDATE houses" - assert bind["rating_score"] == 4.5, f"rating_score={bind.get('rating_score')!r}" - assert bind["developer_name"] == "ПИК", f"developer_name={bind.get('developer_name')!r}" - assert bind["house_id"] == 42, f"house_id={bind.get('house_id')!r}" - - # avito_id_hash должен быть передан - assert bind["avito_id_hash"] == "hash_111", f"avito_id_hash={bind.get('avito_id_hash')!r}" - - # rating_distribution должен быть JSON-строкой - rd = bind.get("rating_distribution") - assert isinstance(rd, str), f"rating_distribution должен быть str (JSON), получили {type(rd)}" - assert json.loads(rd) == [{"score": 5, "count": 80}] - - -# --------------------------------------------------------------------------- -# Test 3: _persist_house не вызывает db.commit() -# --------------------------------------------------------------------------- - - -def test_persist_house_no_commit(): - """_persist_house не должен вызывать db.commit() — коммитит вызывающий.""" - db = MagicMock() - db.execute.return_value = MagicMock() - - h = _make_house() - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(7, 1.0, "new"), - ): - _persist_house(db, h, "") - - db.commit.assert_not_called() - - -# --------------------------------------------------------------------------- -# Test 4: upsert_house делегирует в _persist_house (с пустым url) -# --------------------------------------------------------------------------- - - -def test_upsert_house_delegates_to_persist_house(): - """upsert_house(db, h) → _persist_house(db, h, "") → возвращает house_id.""" - db = MagicMock() - db.execute.return_value = MagicMock() - h = _make_house() - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(55, 1.0, "new"), - ): - result = upsert_house(db, h) - - assert result == 55 - - # Проверяем что UPDATE был вызван с url="" (пустая строка — нет url в этом пути) - bind = db.execute.call_args[0][1] - assert bind["url"] == "", f"url должен быть пустой строкой, получили {bind.get('url')!r}" - - -# --------------------------------------------------------------------------- -# Test 5: _upsert_house_with_url делегирует в _persist_house (с реальным url) -# --------------------------------------------------------------------------- - - -def test_upsert_house_with_url_delegates_to_persist_house(): - """_upsert_house_with_url(db, h, url) → _persist_house(db, h, url) → house_id.""" - db = MagicMock() - db.execute.return_value = MagicMock() - h = _make_house() - url = "https://www.avito.ru/catalog/houses/ekb/dom-1234" - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(99, 1.0, "new"), - ): - result = _upsert_house_with_url(db, h, url) - - assert result == 99 - - # Проверяем что правильный url передан в UPDATE - bind = db.execute.call_args[0][1] - assert bind["url"] == url, f"url должен быть {url!r}, получили {bind.get('url')!r}" - - -# --------------------------------------------------------------------------- -# Test 6: match_or_create_house вызывается с правильными аргументами -# --------------------------------------------------------------------------- - - -def test_persist_house_calls_match_with_correct_args(): - """_persist_house передаёт правильные аргументы в match_or_create_house.""" - db = MagicMock() - db.execute.return_value = MagicMock() - - h = _make_house(ext_id=333) - url = "https://avito.ru/333" - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(10, 1.0, "new"), - ) as mock_match: - _persist_house(db, h, url) - - mock_match.assert_called_once_with( - db, - ext_source="avito", - ext_id="333", - address=h.short_address or h.full_address, - lat=h.lat, - lon=h.lon, - year_built=h.year_built, - source_url=url, - ) - - -# --------------------------------------------------------------------------- -# Test 7: COALESCE — адрес/координаты не затираются через UPDATE параметры -# (проверяем что bind содержит address/lat/lon для COALESCE в SQL) -# --------------------------------------------------------------------------- - - -def test_persist_house_coalesce_fields_in_bind(): - """UPDATE передаёт address/lat/lon в bind для COALESCE(houses., CAST(:x AS ...)).""" - db = MagicMock() - db.execute.return_value = MagicMock() - - h = _make_house() - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(1, 1.0, "new"), - ): - _persist_house(db, h, "") - - bind = db.execute.call_args[0][1] - assert bind["address"] == h.short_address - assert bind["lat"] == h.lat - assert bind["lon"] == h.lon - assert bind["year_built"] == h.year_built - - -# --------------------------------------------------------------------------- -# Test 7b: #2003 — house_class передаётся в UPDATE bind (COALESCE fill-only) -# --------------------------------------------------------------------------- - - -def test_persist_house_house_class_in_bind(): - """UPDATE передаёт house_class в bind для COALESCE(CAST(:house_class AS text), houses.*).""" - db = MagicMock() - db.execute.return_value = MagicMock() - - h = _make_house() - h.house_class = "comfort" - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(1, 1.0, "new"), - ): - _persist_house(db, h, "") - - bind = db.execute.call_args[0][1] - assert bind["house_class"] == "comfort" - - sql_obj = db.execute.call_args[0][0] - assert "house_class" in str(sql_obj) - - -def test_persist_house_house_class_none_does_not_null_via_coalesce(): - """house_class=None в bind — SQL использует COALESCE(new, existing), не голый overwrite.""" - db = MagicMock() - db.execute.return_value = MagicMock() - - h = _make_house() # house_class остаётся дефолтным None - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(1, 1.0, "new"), - ): - _persist_house(db, h, "") - - bind = db.execute.call_args[0][1] - assert bind["house_class"] is None - - sql_obj = str(db.execute.call_args[0][0]) - # SQL должен явно защищать существующее значение через COALESCE, а не - # безусловный overwrite (который занулил бы значение от другого источника). - assert "COALESCE(CAST(:house_class AS text), houses.house_class)" in sql_obj - - -# --------------------------------------------------------------------------- -# Test 8: #1820 — ext_id=0 graceful-skip гард -# Два id-less URL (ext_id=0, без адреса) → match_or_create_house НЕ вызывается, -# house_sources("avito","0") НЕ создаётся, возвращается sentinel dict с house_id=0. -# --------------------------------------------------------------------------- - - -def test_save_house_catalog_enrichment_skips_zero_ext_id_no_address(): - """#1820: ext_id=0 + нет адреса → гард пропускает персистирование canonical house.""" - db = MagicMock() - - # Создаём два id-less graceful-skip enrichment (как _parse_development_or_skip возвращает) - enrichment_a = HouseCatalogEnrichment( - house_url="https://www.avito.ru/catalog/novostroyki/dom-A/", - house=HouseInfo(ext_id=0), - ) - enrichment_b = HouseCatalogEnrichment( - house_url="https://www.avito.ru/catalog/novostroyki/dom-B/", - house=HouseInfo(ext_id=0), - ) - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - ) as mock_match: - result_a = save_house_catalog_enrichment(db, enrichment_a) - result_b = save_house_catalog_enrichment(db, enrichment_b) - - # match_or_create_house НЕ должен вызываться — гард сработал до него - mock_match.assert_not_called() - - # Возвращается sentinel dict с house_id=0, не плодятся дубли - assert result_a["house_id"] == 0, f"ожидали house_id=0, получили {result_a['house_id']}" - assert result_b["house_id"] == 0, f"ожидали house_id=0, получили {result_b['house_id']}" - - # db.commit() НЕ вызывается (нет реальной транзакции) - db.commit.assert_not_called() - - -def test_save_house_catalog_enrichment_with_address_persists_despite_zero_ext_id(): - """#1820: ext_id=0 НО есть адрес → НЕ пропускаем (developmentPage с адресом, без avitoId).""" - db = MagicMock() - db.execute.return_value = MagicMock() - - enrichment = HouseCatalogEnrichment( - house_url="https://www.avito.ru/catalog/novostroyki/dom-C/", - house=HouseInfo(ext_id=0, short_address="ул. Ленина, 1"), - ) - - with patch( - "app.services.scrapers.avito_houses.match_or_create_house", - return_value=(77, 0.9, "fingerprint"), - ) as mock_match: - result = save_house_catalog_enrichment(db, enrichment) - - # Адрес есть → персистим через fingerprint tier - mock_match.assert_called_once() - assert result["house_id"] == 77 diff --git a/tradein-mvp/backend/tests/test_avito_houses_parse.py b/tradein-mvp/backend/tests/test_avito_houses_parse.py deleted file mode 100644 index 69323072..00000000 --- a/tradein-mvp/backend/tests/test_avito_houses_parse.py +++ /dev/null @@ -1,790 +0,0 @@ -"""Offline smoke for avito_houses parser. - -Uses minimal JSON fixture mimicking Avito __preloadedState__ structure. -Не требует сети, БД или curl_cffi — тестирует только чистый парсер. -""" - -from datetime import date -from pathlib import Path - -import pytest - -from app.services.scrapers.avito_houses import ( - HouseCatalogEnrichment, - HouseInfo, - HouseReview, - MiniSerpListing, - PlacementHistoryItem, - RecommendationItem, - SellerInfo, - _extract_preloaded_state, - _parse_ru_date, - parse_houses_state, -) - -FIXTURES_DIR = Path(__file__).parent / "fixtures" - - -# --------------------------------------------------------------------------- -# Minimal __preloadedState__ fixture (Python dict — не raw HTML) -# --------------------------------------------------------------------------- - -MINIMAL_STATE: dict = { - "data": { - "data": { - "page": { - "placeholders": [ - {"type": "breadcrumbs"}, - {"type": "title"}, - {"type": "gallery", "props": {}}, - { - "type": "housePage", - "props": { - "developmentData": { - "avitoId": 3171365, - "id": "MTE3LjM3MDg5MA", - "title": "Akademika Postovskogo 17a", - "address": "ул. Постовского, 17а", - "fullAddress": ( - "Свердловская обл., Екатеринбург, ул. Постовского, 17а" - ), - "coords": {"lat": 56.790699, "lng": 60.580191}, - "aboutDevelopment": { - "expandParams": { - "items": [ - { - "title": "Параметры дома", - "params": [ - {"type": "Год постройки", "value": "2019"}, - {"type": "Этажей", "value": "25"}, - {"type": "Тип дома", "value": "Монолитный"}, - {"type": "Пассажирский лифт", "value": "2"}, - {"type": "Консьерж", "value": "Да"}, - {"type": "Закрытый двор", "value": "Да"}, - {"type": "Детская площадка", "value": "Да"}, - ], - }, - ] - } - }, - "developer": {"name": "Атомстройкомплекс", "key": "atomstroy"}, - "mapPreview": { - "distance": "в 5 минутах ходьбы", - "objects": "школа, парк, аптека", - "pins": [{"lat": 56.79, "lng": 60.58, "type": "school"}], - }, - "ratingBadge": {"info": {"score": 4.666, "scoreString": "4,7"}}, - "ratingSummaryStat": { - "ratingStat": [ - {"score": 5, "count": 4}, - {"score": 4, "count": 2}, - ], - "reviewCount": 6, - }, - } - }, - }, - { - "type": "reviews", - "props": { - "entries": [ - { - "type": "rating", - "value": { - "id": 12345, - "author": {"title": "Иван"}, - "reviewTitle": "Хорошо", - "score": 5, - "modelExperience": "Живу в своей квартире", - "rated": "9 октября 2025", - "textSections": [ - {"title": "", "text": "Общий отзыв"}, - {"title": "Преимущества", "text": "Парк рядом"}, - {"title": "Недостатки", "text": "Парковки мало"}, - ], - }, - }, - {"type": "pages", "value": {"nextPageUrl": "/reviews?page=2"}}, - ] - }, - }, - { - "type": "miniSerp", - "props": { - "items": [ - { - "id": 7986882804, - "title": "3-к. квартира", - "description": "Описание", - "url": "/ekaterinburg/kvartiry/test_7986882804", - "price": "11 990 000 ₽", - "geo": {"content": "Чкаловская, 2,7 км", "colors": ["#cf2734"]}, - "seller": { - "name": "DOMRF66", - "type": "Компания", - "from": "На Авито с июля 2012", - "url": "/brands/domrf66", - "logos": [], - }, - }, - ] - }, - }, - { - "type": "housePlacementHistory", - "props": { - "items": [ - { - "id": 7000000001, - "title": "1-к. квартира", - "startPrice": 5000000, - "startPriceDate": 1697000000, - "lastPrice": 4900000, - "lastPriceDate": 1715000000, - "exposure": 210, - "itemImage": {}, - }, - ] - }, - }, - {"type": "uxFeedback"}, - { - "type": "recommendations", - "props": { - "items": [ - { - "title": "Похожая", - "priceRange": "от 6 млн", - "additionalInfo": ["Адрес 1"], - "url": "/rec1", - "images": [{"208x156": "https://avito.ru/img1.jpg"}], - }, - ] - }, - }, - {"type": "padding"}, - ] - } - } - } -} - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def test_ru_date_parse() -> None: - assert _parse_ru_date("9 октября 2025") == date(2025, 10, 9) - assert _parse_ru_date("31 декабря 2020") == date(2020, 12, 31) - assert _parse_ru_date("1 января 2000") == date(2000, 1, 1) - assert _parse_ru_date(None) is None - assert _parse_ru_date("bogus") is None - assert _parse_ru_date("") is None - - -# --------------------------------------------------------------------------- -# Main parse test -# --------------------------------------------------------------------------- - - -def test_parse_minimal_state() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - - assert isinstance(e, HouseCatalogEnrichment) - assert e.house_url == "/catalog/houses/ekaterinburg/test/3171365" - - -def test_house_info() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - h = e.house - - assert isinstance(h, HouseInfo) - assert h.ext_id == 3171365 - assert h.ext_id_hash == "MTE3LjM3MDg5MA" - assert h.title == "Akademika Postovskogo 17a" - assert h.short_address == "ул. Постовского, 17а" - assert h.full_address == "Свердловская обл., Екатеринбург, ул. Постовского, 17а" - assert h.lat == 56.790699 - assert h.lon == 60.580191 - - -def test_house_expand_params() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - h = e.house - - assert h.year_built == 2019 - assert h.total_floors == 25 - assert h.house_type == "monolith" - assert h.passenger_elevators == 2 - assert h.has_concierge is True - assert h.closed_yard is True - assert h.has_playground is True - - -def test_house_developer_and_rating() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - h = e.house - - assert h.developer_name == "Атомстройкомплекс" - assert h.developer_key == "atomstroy" - assert h.rating_score == 4.666 - assert h.rating_string == "4,7" - assert h.reviews_count == 6 - assert len(h.rating_distribution) == 2 - - -def test_house_map_and_infra() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - h = e.house - - assert h.infrastructure_walk_distance == "в 5 минутах ходьбы" - assert h.infrastructure_summary == "школа, парк, аптека" - assert len(h.map_pins) == 1 - assert h.map_pins[0]["type"] == "school" - - -def test_house_raw_characteristics() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - # raw_characteristics = полный expandParams items (1 категория) - assert len(e.house.raw_characteristics) == 1 - assert e.house.raw_characteristics[0]["title"] == "Параметры дома" - - -def test_reviews() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - - assert len(e.reviews) == 1 - assert e.reviews_next_page_url == "/reviews?page=2" - - r = e.reviews[0] - assert isinstance(r, HouseReview) - assert r.ext_review_id == 12345 - assert r.author_name == "Иван" - assert r.review_title == "Хорошо" - assert r.score == 5 - assert r.model_experience == "Живу в своей квартире" - assert r.rated_date == date(2025, 10, 9) - assert r.text_main == "Общий отзыв" - assert r.text_pros == "Парк рядом" - assert r.text_cons == "Парковки мало" - assert r.raw_payload is not None - - -def test_mini_serp() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - - assert len(e.mini_serp) == 1 - m = e.mini_serp[0] - assert isinstance(m, MiniSerpListing) - assert m.ext_item_id == 7986882804 - assert m.price_rub == 11990000 - assert m.metro_text == "Чкаловская, 2,7 км" - assert m.metro_color == "#cf2734" - assert m.description == "Описание" - - -def test_seller_extraction() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - m = e.mini_serp[0] - - assert m.seller is not None - s = m.seller - assert isinstance(s, SellerInfo) - assert s.ext_seller_id == "domrf66" - assert s.name == "DOMRF66" - assert s.seller_type == "Компания" - assert s.on_platform_since == "На Авито с июля 2012" - assert s.profile_url == "/brands/domrf66" - - -def test_placement_history() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - - assert len(e.placement_history) == 1 - p = e.placement_history[0] - assert isinstance(p, PlacementHistoryItem) - assert p.ext_item_id == "7000000001" - assert p.title == "1-к. квартира" - assert p.start_price == 5000000 - assert p.last_price == 4900000 - assert p.exposure_days == 210 - # removed_date ВСЕГДА None — виджет не отдаёт! - assert p.removed_date is None - # raw_payload не содержит itemImage (скипается) - assert p.raw_payload is not None - assert "itemImage" not in p.raw_payload - # start_price_date и last_price_date — из unix timestamp - assert isinstance(p.start_price_date, date) - assert isinstance(p.last_price_date, date) - - -def test_recommendations() -> None: - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - - assert len(e.recommendations) == 1 - rec = e.recommendations[0] - assert isinstance(rec, RecommendationItem) - assert rec.title == "Похожая" - assert rec.price_text == "от 6 млн" - assert rec.address == "Адрес 1" - assert rec.url == "/rec1" - assert rec.image_url == "https://avito.ru/img1.jpg" - - -def test_missing_optional_widgets() -> None: - """Парсер не падает если опциональные виджеты отсутствуют.""" - state_minimal: dict = { - "data": { - "data": { - "page": { - "placeholders": [ - { - "type": "housePage", - "props": { - "developmentData": { - "avitoId": 9999, - "coords": {"lat": 56.0, "lng": 60.0}, - "aboutDevelopment": {"expandParams": {"items": []}}, - "developer": {}, - "mapPreview": {}, - "ratingBadge": {"info": {}}, - "ratingSummaryStat": {}, - } - }, - }, - ] - } - } - } - } - e = parse_houses_state(state_minimal, "/catalog/houses/test/9999") - assert e.house.ext_id == 9999 - assert e.reviews == [] - assert e.mini_serp == [] - assert e.placement_history == [] - assert e.recommendations == [] - assert e.reviews_next_page_url is None - - -def test_invalid_state_raises() -> None: - """ValueError если placeholders не найден.""" - with pytest.raises(ValueError, match="placeholders"): - parse_houses_state({}, "/catalog/houses/test/1") - - -def test_missing_house_page_raises() -> None: - """ValueError если housePage виджет отсутствует (вторичка — /catalog/houses/).""" - state: dict = { - "data": { - "data": { - "page": { - "placeholders": [ - {"type": "breadcrumbs"}, - ] - } - } - } - } - with pytest.raises(ValueError, match="housePage"): - parse_houses_state(state, "/catalog/houses/test/1") - - -def test_novostroyka_minimal_development_page() -> None: - """Новостройка: виджет 'developmentPage' парсится в partial HouseInfo без ValueError.""" - state: dict = { - "data": { - "data": { - "page": { - "placeholders": [ - {"type": "reNavigation"}, - { - "type": "developmentPage", - "props": { - "developmentPage": { - "developmentData": { - "avitoId": 12879380, - "id": "MGDKHN70PQ", - "title": "ЖК Береговой", - "address": "ул. Щербакова, жилые дома", - "fullAddress": "", - "coords": {"lat": 56.77005, "lng": 60.67244}, - "developer": {"name": "Талан", "key": "talan-1"}, - "mapPreview": { - "distance": "в 15 минутах ходьбы", - "objects": "школа, детский сад", - "pins": [{"lat": 56.77, "lng": 60.67}], - }, - "aboutDevelopment": { - "tabs": [ - { - "title": "Параметры", - "expandParams": { - "items": [ - { - "params": [ - { - "type": "Тип дома", - "value": "Монолит", - }, - { - "type": "Этажность", - "value": "от 4 до 24", - }, - { - "type": ( - "Пассажирских лифтов" - ), - "value": "42", - }, - { - "type": "Грузовых лифтов", - "value": "60", - }, - ] - } - ] - }, - } - ] - }, - } - } - }, - }, - {"type": "uxFeedback"}, - ] - } - } - } - } - url = "/catalog/novostroyki/ekaterinburg/zhk_beregovoy/12879380" - e = parse_houses_state(state, url) - - assert isinstance(e, HouseCatalogEnrichment) - assert e.house_url == url - h = e.house - assert h.ext_id == 12879380 - assert h.ext_id_hash == "MGDKHN70PQ" - assert h.title == "ЖК Береговой" - assert h.short_address == "ул. Щербакова, жилые дома" - assert h.lat == 56.77005 - assert h.lon == 60.67244 - assert h.house_type == "monolith" # "Монолит" → monolith - assert h.passenger_elevators == 42 - assert h.cargo_elevators == 60 - # Этажность = диапазон "от 4 до 24" → int-каст None (только в raw_characteristics) - assert h.total_floors is None - assert h.year_built is None # дом строится, года постройки нет - assert h.developer_name == "Талан" - assert h.developer_key == "talan-1" - assert h.infrastructure_summary == "школа, детский сад" - assert h.infrastructure_walk_distance == "в 15 минутах ходьбы" - assert len(h.map_pins) == 1 - # Нет рейтинга/отзывов/истории у новостройки - assert h.rating_score is None - assert e.reviews == [] - assert e.placement_history == [] - assert e.recommendations == [] - - -def test_novostroyka_no_development_page_graceful_skip() -> None: - """Новостройка без developmentPage/housePage → graceful skip (НЕ ValueError).""" - state: dict = { - "data": { - "data": { - "page": { - "placeholders": [ - {"type": "reNavigation"}, - {"type": "uxFeedback"}, - ] - } - } - } - } - url = "/catalog/novostroyki/ekaterinburg/zhk_unknown/55667788" - e = parse_houses_state(state, url) - - assert isinstance(e, HouseCatalogEnrichment) - assert e.house_url == url - # ext_id извлечён из хвоста URL - assert e.house.ext_id == 55667788 - assert e.reviews == [] - assert e.placement_history == [] - - -def test_novostroyka_real_fixture_end_to_end() -> None: - """Реальная фикстура новостройки: parse не кидает ValueError, отдаёт partial-обогащение.""" - html = (FIXTURES_DIR / "avito_novostroyka_12879380.html").read_text(encoding="utf-8") - state = _extract_preloaded_state(html) - assert state is not None, "URL-encoded __preloadedState__ должен распарситься" - - url = "/catalog/novostroyki/ekaterinburg/semeynyy_kvartal_beregovoy/12879380" - e = parse_houses_state(state, url) - - assert isinstance(e, HouseCatalogEnrichment) - h = e.house - assert h.ext_id == 12879380 - assert h.ext_id_hash # base64 internal id - assert h.house_type == "monolith" - assert h.passenger_elevators is not None and h.passenger_elevators > 0 - assert h.lat is not None and h.lon is not None - assert h.developer_name # застройщик присутствует - # Новостройка: нет отзывов/истории/года постройки - assert h.year_built is None - assert e.reviews == [] - assert e.placement_history == [] - - -def test_house_type_normalization() -> None: - """Все варианты типа дома нормализуются корректно.""" - from app.services.scrapers.avito_houses import _normalize_house_type - - assert _normalize_house_type("Монолитный") == "monolith" - assert _normalize_house_type("Панельный") == "panel" - assert _normalize_house_type("Кирпичный") == "brick" - assert _normalize_house_type("Монолитно-кирпичный") == "monolith_brick" - assert _normalize_house_type("Блочный") == "block" - assert _normalize_house_type("Деревянный") == "wood" - assert _normalize_house_type("Неизвестный") == "other" - assert _normalize_house_type(None) is None - - -def test_house_class_normalization() -> None: - """Все варианты класса жилья нормализуются корректно (#2003).""" - from app.services.scrapers.avito_houses import _normalize_house_class - - assert _normalize_house_class("Комфорт") == "comfort" - assert _normalize_house_class("Бизнес") == "business" - assert _normalize_house_class("Премиум") == "premium" - assert _normalize_house_class("Эконом") == "economy" - assert _normalize_house_class("Комфорт+") == "comfort_plus" - assert _normalize_house_class("Элит") == "elite" - # Регистронезависимость + пробелы по краям - assert _normalize_house_class(" комфорт ") == "comfort" - # Неизвестное значение → None (НЕ "other" — house_class точный ANY(...) фильтр) - assert _normalize_house_class("Неизвестный класс") is None - assert _normalize_house_class(None) is None - assert _normalize_house_class("") is None - - -def test_house_class_parsed_from_novostroyka_expand_params() -> None: - """ "Класс жилья" из developmentPage expandParams → HouseInfo.house_class (#2003).""" - state: dict = { - "data": { - "data": { - "page": { - "placeholders": [ - { - "type": "developmentPage", - "props": { - "developmentPage": { - "developmentData": { - "avitoId": 42, - "coords": {"lat": 56.8, "lng": 60.6}, - "developer": {}, - "mapPreview": {}, - "aboutDevelopment": { - "tabs": [ - { - "expandParams": { - "items": [ - { - "params": [ - { - "type": "Класс жилья", - "value": "Комфорт", - }, - ] - } - ] - } - } - ] - }, - } - } - }, - }, - ] - } - } - } - } - e = parse_houses_state(state, "/catalog/novostroyki/ekaterinburg/test/42") - assert e.house.house_class == "comfort" - - -def test_house_class_absent_on_secondary_listing() -> None: - """Вторичка (housePage) не отдаёт "Класс жилья" → house_class остаётся None.""" - e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") - assert e.house.house_class is None - - -def test_strip_price() -> None: - """_strip_price корректно извлекает числа из строк Avito.""" - from app.services.scrapers.avito_houses import _strip_price - - assert _strip_price("11 990 000 ₽") == 11990000 - assert _strip_price("5 000 000 ₽") == 5000000 - assert _strip_price(None) is None - assert _strip_price("") is None - - -def test_mini_serp_no_seller() -> None: - """Объявление без продавца не вызывает ошибку.""" - state: dict = { - "data": { - "data": { - "page": { - "placeholders": [ - { - "type": "housePage", - "props": { - "developmentData": { - "avitoId": 111, - "coords": {"lat": 56.0, "lng": 60.0}, - "aboutDevelopment": {"expandParams": {"items": []}}, - "developer": {}, - "mapPreview": {}, - "ratingBadge": {"info": {}}, - "ratingSummaryStat": {}, - } - }, - }, - { - "type": "miniSerp", - "props": { - "items": [ - { - "id": 12345, - "title": "1-к.", - "url": "/test", - "price": "3 000 000 ₽", - "geo": {"content": "Центр", "colors": []}, - }, - ] - }, - }, - ] - } - } - } - } - e = parse_houses_state(state, "/catalog/houses/test/111") - assert len(e.mini_serp) == 1 - assert e.mini_serp[0].seller is None - assert e.mini_serp[0].price_rub == 3000000 - - -# --------------------------------------------------------------------------- -# Real-HTML fixture (regression for issue #566 — state-blob encoding/shape change) -# --------------------------------------------------------------------------- -# -# Fixture: реальная страница /catalog/houses/ekaterinburg/ul_yumasheva_6/115188, -# захвачена curl_cffi chrome120 (2026-05-28). Avito сменил формат __preloadedState__ -# на URL-encoded литерал + обернул виджеты в placeholders[0]['widgets'] + -# вложил developmentData под props['housePage']. Этот тест ловит регрессию декода/shape. - - -def test_extract_preloaded_state_from_real_html() -> None: - """_extract_preloaded_state декодирует текущий URL-encoded __preloadedState__.""" - html = (FIXTURES_DIR / "avito_houses_sample.html").read_text(encoding="utf-8") - - state = _extract_preloaded_state(html) - - assert state is not None, "URL-encoded __preloadedState__ должен распарситься" - # Avito SSR обёртка — успешный ответ - assert state["data"]["status"] == 200 - # Inner path до placeholders сохранился - placeholders = state["data"]["data"]["page"]["placeholders"] - assert isinstance(placeholders, list) and placeholders, "placeholders непустой массив" - - -def test_parse_real_fixture_end_to_end() -> None: - """Фикстура HTML → _extract_preloaded_state → parse_houses_state → HouseInfo.ext_id.""" - html = (FIXTURES_DIR / "avito_houses_sample.html").read_text(encoding="utf-8") - state = _extract_preloaded_state(html) - assert state is not None - - e = parse_houses_state(state, "/catalog/houses/ekaterinburg/ul_yumasheva_6/115188") - - assert isinstance(e, HouseCatalogEnrichment) - # housePage распарсился — ext_id (avitoId) присутствует и совпадает с URL - assert e.house.ext_id == 115188 - assert e.house.ext_id_hash # base64 internal id присутствует - # expandParams распарсились (год постройки реальной фикстуры — 2016) - assert e.house.year_built == 2016 - assert e.house.total_floors == 17 - assert e.house.house_type == "monolith" - assert e.house.lat is not None and e.house.lon is not None - - -def test_extract_preloaded_state_jsparse_fallback() -> None: - """Старая форма JSON.parse("...") всё ещё декодируется (fallback-ветка).""" - inner = '{\\"data\\":{\\"data\\":{\\"page\\":{\\"placeholders\\":[]}}}}' - html = f'' - - state = _extract_preloaded_state(html) - - assert state is not None - assert state["data"]["data"]["page"]["placeholders"] == [] - - -def test_extract_preloaded_state_missing_returns_none() -> None: - """Нет маркера → None (а не исключение).""" - assert _extract_preloaded_state("no state here") is None - - -# --------------------------------------------------------------------------- -# Real-HTML fixture (regression for issue #1789 — MFE props nesting) -# --------------------------------------------------------------------------- -# -# Fixture: реальная страница /catalog/houses/ekaterinburg/ul_soni_morozovoy_190/572538, -# захвачена браузер-сервисом (2026-06-19). Avito перенёс данные виджетов на уровень -# глубже — под ключ с именем виджета: -# reviews: props['reviews']['entries'] (было props['entries']) -# miniSerp: props['miniSerp']['items'] (было props['items']) -# housePlacementHistory: props['housePlacementHistory']['items'] (было props['items']) -# recommendations: props['recommendations']['items'] (было props['items']) -# Из-за этого reviews/placement/seller-обогащение НЕ долетало в БД (0 строк). -# Этот тест ловит регрессию шейпа props. - - -def test_parse_mfe_fixture_widgets_reach_db() -> None: - """Фикстура MFE: reviews/placement/miniSerp/house_type/year_built распарсены непусто.""" - html = (FIXTURES_DIR / "avito_house_572538.html").read_text(encoding="utf-8") - state = _extract_preloaded_state(html) - assert state is not None, "URL-encoded __preloadedState__ должен распарситься" - - e = parse_houses_state(state, "/catalog/houses/ekaterinburg/ul_soni_morozovoy_190/572538") - - # housePage — тип дома и год постройки долетают - assert e.house.ext_id == 572538 - assert e.house.house_type is not None, "house_type должен парситься из expandParams" - assert e.house.house_type == "brick" - assert e.house.year_built == 1994 - assert e.house.reviews_count == 3 - - # reviews — >=1 отзыв с текстом, автором, оценкой - assert len(e.reviews) >= 1, "reviews должны распарситься из props['reviews']['entries']" - r = e.reviews[0] - assert r.ext_review_id, "у отзыва должен быть id" - assert r.author_name, "у отзыва должен быть автор" - assert r.score is not None, "у отзыва должна быть оценка" - assert r.text_main, "у отзыва должен быть основной текст (textSections)" - - # placement — >=1 item из props['housePlacementHistory']['items'] - assert len(e.placement_history) >= 1, "placement должен распарситься" - p = e.placement_history[0] - assert p.ext_item_id, "у placement item должен быть id" - assert p.start_price is not None - assert p.last_price is not None - assert isinstance(p.last_price_date, date) - - # miniSerp — >=1 объявление из props['miniSerp']['items'] - assert len(e.mini_serp) >= 1, "miniSerp должен распарситься" - m = e.mini_serp[0] - assert m.ext_item_id - assert m.price_rub is not None diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 158aeb8b..8aaefd7b 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -234,9 +234,10 @@ async def test_backfill_yandex_addresses_error_on_non_200(): @pytest.mark.asyncio async def test_cian_price_history_saves_changes(): """Happy path: fetch returns DetailEnrichment with price_changes → saved.""" + from scraper_kit.providers.cian.detail import DetailEnrichment + from app.services.cian_price_history import backfill_cian_price_history from app.services.scraper_adapters import RealScraperConfig - from app.services.scrapers.cian_detail import DetailEnrichment enrichment = DetailEnrichment( cian_id=123456, @@ -290,9 +291,10 @@ async def test_cian_price_history_saves_changes(): @pytest.mark.asyncio async def test_cian_price_history_skips_no_changes(): """Fetch succeeds but no price_changes → skipped (not error).""" + from scraper_kit.providers.cian.detail import DetailEnrichment + from app.services.cian_price_history import backfill_cian_price_history from app.services.scraper_adapters import RealScraperConfig - from app.services.scrapers.cian_detail import DetailEnrichment enrichment = DetailEnrichment(cian_id=111, price_changes=[]) diff --git a/tradein-mvp/backend/tests/test_cian_detail.py b/tradein-mvp/backend/tests/test_cian_detail.py deleted file mode 100644 index e23323f5..00000000 --- a/tradein-mvp/backend/tests/test_cian_detail.py +++ /dev/null @@ -1,830 +0,0 @@ -"""Tests for cian_detail.py — Stage 5 of CianScraper v1.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from app.services.scrapers.cian_detail import ( - DetailEnrichment, - _extract_agent_profile, - _extract_ceiling_height, - _extract_price_changes, - _extract_repair_state, - _extract_repair_type, - _parse_views, - fetch_detail, - save_detail_enrichment, -) - -_FIXTURE_DIR = Path(__file__).parent / "fixtures" - -# ── _parse_views ────────────────────────────────────────────────────────────── - - -def test_parse_views_space_formatted(): - assert _parse_views("1 234") == 1234 - - -def test_parse_views_plain(): - assert _parse_views("567") == 567 - - -def test_parse_views_none(): - assert _parse_views(None) is None - - -def test_parse_views_invalid(): - assert _parse_views("invalid") is None - - -def test_parse_views_comma_formatted(): - assert _parse_views("1,234") == 1234 - - -# ── _extract_repair_state — структурное поле в приоритете, fallback на текст (#622) ── - - -def test_repair_state_structured_wins_over_description(): - """repairType присутствует → используется он, описание игнорируется.""" - offer = { - "repairType": "cosmetic", # → standard - "description": "Шикарный дизайнерский ремонт", # инференс дал бы excellent - } - assert _extract_repair_state(offer) == "standard" - - -def test_repair_state_falls_back_to_description_when_structured_missing(): - """repairType отсутствует → инференс из описания.""" - offer = { - "repairType": None, - "description": "Сделан свежий евроремонт, заезжай и живи", - } - assert _extract_repair_state(offer) == "good" - - -def test_repair_state_none_when_both_missing(): - """Нет structured и нет сигнала в тексте → None (не фабрикуем).""" - offer = {"description": "Светлая квартира рядом с парком"} - assert _extract_repair_state(offer) is None - - -# ── _extract_ceiling_height — offer.building.ceilingHeight, не offer.ceilingHeight (#1791) ── - - -def test_ceiling_height_from_building_node(): - """На detail-странице высота лежит в offer.building.ceilingHeight.""" - offer = {"ceilingHeight": None, "building": {"ceilingHeight": "3.0"}} - assert _extract_ceiling_height(offer) == 3.0 - - -def test_ceiling_height_falls_back_to_top_level(): - """Если building.ceilingHeight отсутствует — fallback на offer.ceilingHeight.""" - offer = {"ceilingHeight": "2.75", "building": {}} - assert _extract_ceiling_height(offer) == 2.75 - - -def test_ceiling_height_none_when_absent_everywhere(): - assert _extract_ceiling_height({"building": {}}) is None - assert _extract_ceiling_height({}) is None - - -# ── _extract_repair_type / state — offer.decoration fallback (#1791) ────────── - - -def test_repair_type_prefers_repair_type_then_decoration(): - assert _extract_repair_type({"repairType": "euro", "decoration": "cosmetic"}) == "euro" - assert _extract_repair_type({"repairType": None, "decoration": "preFine"}) == "preFine" - assert _extract_repair_type({}) is None - - -def test_repair_state_from_decoration_when_repair_type_missing(): - """offer.decoration='preFine' → needs_repair (через нормализатор).""" - offer = {"repairType": None, "decoration": "preFine", "description": ""} - assert _extract_repair_state(offer) == "needs_repair" - - -# ── _extract_price_changes ──────────────────────────────────────────────────── - - -def test_extract_price_changes_normalizes(): - """curl_cffi flat format: {changeTime, price, diffPercent}.""" - offer = { - "priceChanges": [ - {"changeTime": "2026-04-01T00:00:00Z", "price": 5000000, "diffPercent": -2.5}, - {"changeTime": "2026-04-10T00:00:00Z", "price": 4900000, "diffPercent": -2.0}, - ] - } - result = _extract_price_changes(offer, {}, {}) - assert len(result) == 2 - assert result[0]["change_time"] == "2026-04-01T00:00:00Z" - assert result[0]["price_rub"] == 5000000 - assert result[0]["diff_percent"] == -2.5 - - -def test_extract_price_changes_handles_empty_offer(): - assert _extract_price_changes({}, {}, {}) == [] - - -def test_extract_price_changes_handles_none_value(): - assert _extract_price_changes({"priceChanges": None}, {}, {}) == [] - - -def test_extract_price_changes_falls_back_to_state(): - state = { - "priceChanges": [ - {"changeTime": "2026-03-01T00:00:00Z", "price": 6000000, "diffPercent": 0}, - ] - } - result = _extract_price_changes({}, {}, state) - assert len(result) == 1 - assert result[0]["price_rub"] == 6000000 - - -def test_extract_price_changes_skips_non_dict_entries(): - offer = {"priceChanges": [{"changeTime": "2026-04-01T00:00:00Z", "price": 5000000}, "bad"]} - result = _extract_price_changes(offer, {}, {}) - assert len(result) == 1 - - -def test_extract_price_changes_offer_data_browser_format(): - """Browser-rendered HTML: offerData.priceChanges with nested priceData.price.""" - offer_data = { - "priceChanges": [ - { - "changeTime": "2026-06-05T15:32:41Z", - "priceData": {"currency": "rur", "price": 13300000}, - }, - { - "changeTime": "2026-06-03T08:05:04Z", - "priceData": {"currency": "rur", "price": 13600000}, - }, - ] - } - result = _extract_price_changes({}, offer_data, {}) - assert len(result) == 2 - assert result[0]["change_time"] == "2026-06-05T15:32:41Z" - assert result[0]["price_rub"] == 13300000 - assert result[1]["price_rub"] == 13600000 - - -def test_extract_price_changes_offer_wins_over_offer_data(): - """offer.priceChanges takes priority over offerData.priceChanges (flat format wins).""" - offer = { - "priceChanges": [ - {"changeTime": "2026-04-01T00:00:00Z", "price": 5000000}, - ] - } - offer_data = { - "priceChanges": [ - {"changeTime": "2026-06-01T00:00:00Z", "priceData": {"price": 9000000}}, - ] - } - result = _extract_price_changes(offer, offer_data, {}) - assert len(result) == 1 - assert result[0]["price_rub"] == 5000000 - - -# ── _extract_agent_profile ──────────────────────────────────────────────────── - - -def test_extract_agent_profile_from_agent_key(): - offer = { - "agent": { - "id": 12345, - "companyName": "Новосёл", - "isPro": True, - "skills": [{"id": 1, "name": "Продажа"}], - "accountType": "specialist", - } - } - profile = _extract_agent_profile(offer) - assert profile is not None - assert profile["ext_agent_id"] == 12345 - assert profile["company_name"] == "Новосёл" - assert profile["is_pro"] is True - assert profile["account_type"] == "specialist" - - -def test_extract_agent_profile_from_user_key(): - offer = { - "user": { - "cianUserId": 99999, - "companyName": "Агентство Икс", - "isPro": False, - "accountType": "agency", - } - } - profile = _extract_agent_profile(offer) - assert profile is not None - assert profile["ext_agent_id"] == 99999 - assert profile["company_name"] == "Агентство Икс" - - -def test_extract_agent_profile_returns_none_when_no_agent(): - assert _extract_agent_profile({}) is None - assert _extract_agent_profile({"agent": {}, "user": {}}) is None - - -# ── DetailEnrichment defaults ───────────────────────────────────────────────── - - -def test_dataclass_defaults(): - enrich = DetailEnrichment() - assert enrich.cian_id is None - assert enrich.price_changes == [] - assert enrich.raw_sister_states == {} - assert enrich.bti_data is None - assert enrich.agent_profile is None - assert enrich.newbuilding_data is None - - -# ── fetch_detail integration (mocked) ──────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_fetch_detail_returns_none_on_no_state(monkeypatch): - """If state extraction returns None → fetch_detail returns None gracefully.""" - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: None, - ) - - response = MagicMock() - response.status_code = 200 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/123/", session=session) - assert result is None - - -@pytest.mark.asyncio -async def test_fetch_detail_returns_none_on_non_200(monkeypatch): - """Non-200 HTTP response → returns None, no exception raised.""" - response = MagicMock() - response.status_code = 403 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/456/", session=session) - assert result is None - - -@pytest.mark.asyncio -async def test_fetch_detail_extracts_core_fields(monkeypatch): - """Verify core fields are extracted correctly from mocked state.""" - fake_state = { - "offerData": { - "offer": { - "cianId": 327830237, - "windowsViewType": "yardAndStreet", - "separateWcsCount": 1, - "combinedWcsCount": 0, - "ceilingHeight": 2.65, - "repairType": "cosmetic", - "priceChanges": [ - { - "changeTime": "2026-04-01T00:00:00Z", - "price": 5500000, - "diffPercent": -1.8, - } - ], - "agent": { - "id": 7654321, - "companyName": "Первичка Плюс", - "isPro": True, - "skills": [], - "accountType": "specialist", - }, - }, - "stats": { - "totalViewsFormattedString": "1 042", - "todayViewsFormattedString": "7", - }, - } - } - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: {}, - ) - - response = MagicMock() - response.status_code = 200 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/327830237/", session=session) - assert result is not None - assert result.cian_id == 327830237 - assert result.windows_view_type == "yardAndStreet" - assert result.separate_wcs_count == 1 - assert result.combined_wcs_count == 0 - assert result.ceiling_height == 2.65 - assert result.repair_type == "cosmetic" - assert result.views_total == 1042 - assert result.views_today == 7 - assert len(result.price_changes) == 1 - assert result.price_changes[0]["price_rub"] == 5500000 - assert result.agent_profile is not None - assert result.agent_profile["ext_agent_id"] == 7654321 - assert result.agent_profile["company_name"] == "Первичка Плюс" - - -@pytest.mark.asyncio -async def test_fetch_detail_extracts_bti_from_sister_states(monkeypatch): - """BTI sister state is captured into bti_data.""" - fake_state = { - "offerData": { - "offer": { - "cianId": 111111, - } - } - } - fake_all_states = { - "frontend-offer-card": { - "bti": { - "houseData": { - "seriesName": "П-44Т", - "floorsCount": 17, - "flatCount": 120, - } - } - } - } - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: fake_all_states, - ) - - response = MagicMock() - response.status_code = 200 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/111111/", session=session) - assert result is not None - assert result.bti_data is not None - assert result.bti_data["seriesName"] == "П-44Т" - assert "bti" in result.raw_sister_states - - -@pytest.mark.asyncio -async def test_fetch_detail_extracts_newbuilding_link(monkeypatch): - """Newbuilding reference is captured from offer.newbuilding.""" - fake_state = { - "offerData": { - "offer": { - "cianId": 222222, - "newbuilding": { - "id": 54016, - "name": "Екатерининский парк", - "url": "https://zhk-ekaterininskiy-park-ekb-i.cian.ru/", - }, - } - } - } - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: {}, - ) - - response = MagicMock() - response.status_code = 200 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/222222/", session=session) - assert result is not None - assert result.newbuilding_data is not None - assert result.newbuilding_data["id"] == 54016 - - -# ── kitchen_area_m2 extraction ──────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_fetch_detail_extracts_kitchen_area(monkeypatch): - """kitchenArea из offer → kitchen_area_m2 в DetailEnrichment.""" - fake_state = { - "offerData": { - "offer": { - "cianId": 333333, - "kitchenArea": 9.5, - } - } - } - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: {}, - ) - - response = MagicMock() - response.status_code = 200 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/333333/", session=session) - assert result is not None - assert result.kitchen_area_m2 == 9.5 - - -@pytest.mark.asyncio -async def test_fetch_detail_kitchen_area_none_when_missing(monkeypatch): - """Если kitchenArea отсутствует в offer — kitchen_area_m2 остаётся None.""" - fake_state = { - "offerData": { - "offer": { - "cianId": 444444, - } - } - } - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: {}, - ) - - response = MagicMock() - response.status_code = 200 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/444444/", session=session) - assert result is not None - assert result.kitchen_area_m2 is None - - -@pytest.mark.asyncio -async def test_fetch_detail_extracts_description(monkeypatch): - """offer.description → DetailEnrichment.description (для persist в listings).""" - fake_state = { - "offerData": { - "offer": { - "cianId": 555555, - "description": "Продаётся уютная квартира с видом на парк.", - } - } - } - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: {}, - ) - - response = MagicMock() - response.status_code = 200 - response.text = "" - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://ekb.cian.ru/sale/flat/555555/", session=session) - assert result is not None - assert result.description == "Продаётся уютная квартира с видом на парк." - - -# ── save_detail_enrichment — kitchen_area_m2 persisted ─────────────────────── - - -def test_save_detail_enrichment_writes_kitchen_area(): - """save_detail_enrichment передаёт kitchen_area_m2 в UPDATE listings.""" - db = MagicMock() - - # Мокаем fetchone для snapshot-запроса — price_rub=None → snapshot не пишется - db.execute.return_value.fetchone.return_value = None - - enrichment = DetailEnrichment( - windows_view_type=None, - separate_wcs_count=None, - combined_wcs_count=None, - ceiling_height=None, - repair_type=None, - repair_state=None, - kitchen_area_m2=8.3, - views_total=None, - views_today=None, - ) - - save_detail_enrichment(db, listing_id=999, enrichment=enrichment) - - # Проверяем что UPDATE listings вызван с параметром ka=8.3 - update_call_params = db.execute.call_args_list[0][0][1] - assert update_call_params["ka"] == 8.3 - - -def test_save_detail_enrichment_kitchen_area_none_passthrough(): - """Если kitchen_area_m2 = None — параметр ka=None, COALESCE сохраняет существующее.""" - db = MagicMock() - db.execute.return_value.fetchone.return_value = None - - enrichment = DetailEnrichment(kitchen_area_m2=None) - - save_detail_enrichment(db, listing_id=1001, enrichment=enrichment) - - update_call_params = db.execute.call_args_list[0][0][1] - assert update_call_params["ka"] is None - - -# ── save_detail_enrichment — description + detail_enriched_at stamp ─────────── - - -def test_save_detail_enrichment_writes_description(): - """save_detail_enrichment передаёт description в UPDATE listings.""" - db = MagicMock() - db.execute.return_value.fetchone.return_value = None - - enrichment = DetailEnrichment(description="Светлая квартира рядом с парком") - - save_detail_enrichment(db, listing_id=1002, enrichment=enrichment) - - update_call_params = db.execute.call_args_list[0][0][1] - assert update_call_params["descr"] == "Светлая квартира рядом с парком" - - -def test_save_detail_enrichment_stamps_detail_enriched_at(): - """UPDATE listings содержит detail_enriched_at = NOW() — иначе строка - переобогащается каждый run (detail_enriched_at IS NULL filter).""" - db = MagicMock() - db.execute.return_value.fetchone.return_value = None - - enrichment = DetailEnrichment(kitchen_area_m2=8.3) - - save_detail_enrichment(db, listing_id=1003, enrichment=enrichment) - - update_sql = str(db.execute.call_args_list[0][0][0]) - assert "detail_enriched_at = NOW()" in update_sql - - -# ── save_detail_enrichment — diff_percent клампится перед INSERT (#2277) ────── -# offer_price_history.diff_percent — NUMERIC(8,2) (миграция 131). Этот прямой -# INSERT не проходит через БД-триггер record_listing_price_change() (тот -# срабатывает только на UPDATE listings) — garbage/экстремальный diff_percent -# из скрапленного Cian JSON иначе переполняет колонку. - - -def _insert_diff_param(db: MagicMock) -> float | None: - insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])] - assert len(insert_calls) == 1 - return insert_calls[0][0][1]["diff"] - - -def test_save_detail_enrichment_clamps_extreme_positive_diff(): - db = MagicMock() - db.execute.return_value.fetchone.return_value = None - enrichment = DetailEnrichment( - price_changes=[ - {"change_time": "2026-04-01T00:00:00Z", "price_rub": 5000000, "diff_percent": 5000000} - ] - ) - save_detail_enrichment(db, listing_id=2001, enrichment=enrichment) - assert _insert_diff_param(db) == 999999.99 - - -def test_save_detail_enrichment_clamps_extreme_negative_diff(): - db = MagicMock() - db.execute.return_value.fetchone.return_value = None - enrichment = DetailEnrichment( - price_changes=[ - { - "change_time": "2026-04-01T00:00:00Z", - "price_rub": 5000000, - "diff_percent": -5000000, - } - ] - ) - save_detail_enrichment(db, listing_id=2002, enrichment=enrichment) - assert _insert_diff_param(db) == -999999.99 - - -def test_save_detail_enrichment_normal_diff_unchanged(): - db = MagicMock() - db.execute.return_value.fetchone.return_value = None - enrichment = DetailEnrichment( - price_changes=[ - {"change_time": "2026-04-01T00:00:00Z", "price_rub": 5000000, "diff_percent": -2.5} - ] - ) - save_detail_enrichment(db, listing_id=2003, enrichment=enrichment) - assert _insert_diff_param(db) == -2.5 - - -def test_save_detail_enrichment_none_diff_unchanged(): - db = MagicMock() - db.execute.return_value.fetchone.return_value = None - enrichment = DetailEnrichment( - price_changes=[ - {"change_time": "2026-04-01T00:00:00Z", "price_rub": 5000000, "diff_percent": None} - ] - ) - save_detail_enrichment(db, listing_id=2004, enrichment=enrichment) - assert _insert_diff_param(db) is None - - -# ── fetch_detail browser_fetcher param ─────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_fetch_detail_browser_fetcher_uses_browser_html(monkeypatch): - """browser_fetcher provided → HTML comes from BrowserFetcher.fetch, not curl_cffi. - - Simulates the browser-rendered format where priceChanges is at offerData level - with nested priceData.price (verified on prod 2026-06-16). - """ - fake_state = { - "offerData": { - "offer": { - "cianId": 328647045, - }, - # Browser-rendered: priceChanges at offerData level, priceData.price format - "priceChanges": [ - { - "changeTime": "2026-06-05T15:32:41Z", - "priceData": {"currency": "rur", "price": 13300000}, - }, - { - "changeTime": "2026-06-03T08:05:04Z", - "priceData": {"currency": "rur", "price": 13600000}, - }, - ], - } - } - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: {}, - ) - - # Mock BrowserFetcher: fetch() returns HTML tagged so we can verify it was called. - mock_bf = MagicMock() - mock_bf.fetch = AsyncMock(return_value="browser-rendered") - - result = await fetch_detail( - "https://ekb.cian.ru/sale/flat/328647045/", - browser_fetcher=mock_bf, - ) - - assert result is not None - assert result.cian_id == 328647045 - # price_changes populated from browser-rendered HTML via offerData.priceChanges - assert len(result.price_changes) == 2 - assert result.price_changes[0]["price_rub"] == 13300000 - assert result.price_changes[0]["change_time"] == "2026-06-05T15:32:41Z" - assert result.price_changes[1]["price_rub"] == 13600000 - # BrowserFetcher.fetch was called with the offer URL - mock_bf.fetch.assert_called_once_with("https://ekb.cian.ru/sale/flat/328647045/") - - -@pytest.mark.asyncio -async def test_fetch_detail_browser_fetcher_no_curl_session_used(monkeypatch): - """When browser_fetcher is given, curl_cffi AsyncSession is NOT instantiated.""" - fake_state = {"offerData": {"offer": {"cianId": 555555}}} - - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: fake_state if key == "defaultState" else None, - ) - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_all_states", - lambda html: {}, - ) - - # Patch AsyncSession so we can assert it was NOT called. - mock_session_cls = MagicMock() - monkeypatch.setattr( - "app.services.scrapers.cian_detail.AsyncSession", - mock_session_cls, - ) - - mock_bf = MagicMock() - mock_bf.fetch = AsyncMock(return_value="") - - result = await fetch_detail( - "https://ekb.cian.ru/sale/flat/555555/", - browser_fetcher=mock_bf, - ) - - assert result is not None - mock_session_cls.assert_not_called() - - -@pytest.mark.asyncio -async def test_fetch_detail_browser_fetcher_returns_none_on_exception(monkeypatch): - """If BrowserFetcher.fetch raises, fetch_detail returns None (no crash).""" - mock_bf = MagicMock() - mock_bf.fetch = AsyncMock(side_effect=RuntimeError("browser service unavailable")) - - result = await fetch_detail( - "https://ekb.cian.ru/sale/flat/999999/", - browser_fetcher=mock_bf, - ) - - assert result is None - - -@pytest.mark.asyncio -async def test_fetch_detail_browser_fetcher_state_missing_returns_none(monkeypatch): - """browser HTML parsed but no defaultState → fetch_detail returns None.""" - monkeypatch.setattr( - "app.services.scrapers.cian_detail.extract_state", - lambda html, mfe, key: None, - ) - - mock_bf = MagicMock() - mock_bf.fetch = AsyncMock(return_value="no state here") - - result = await fetch_detail( - "https://ekb.cian.ru/sale/flat/777777/", - browser_fetcher=mock_bf, - ) - - assert result is None - - -# ── Real-card fixture (#1791) — снята с www.cian.ru/sale/flat/330982715/ ────── -# Регрессия на field-drift: ceiling/kitchen/description должны извлекаться из -# актуальной structure detail-страницы (defaultState, concat-формат). - - -@pytest.mark.asyncio -async def test_fetch_detail_real_fixture_extracts_drifted_fields(): - """На реальной карточке ceiling берётся из offer.building.ceilingHeight, - kitchen/description — из offer top-level. До #1791 ceiling был 0%.""" - html = (_FIXTURE_DIR / "cian_flat_330982715.html").read_text(encoding="utf-8") - - response = MagicMock() - response.status_code = 200 - response.text = html - - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_detail("https://www.cian.ru/sale/flat/330982715/", session=session) - - assert result is not None - # ceiling_height — РАНЬШЕ был None (читали offer.ceilingHeight=null); - # теперь из offer.building.ceilingHeight=3.0 - assert result.ceiling_height == 3.0 - # kitchen_area_m2 — offer.kitchenArea='14.5' (строка → float) - assert result.kitchen_area_m2 == 14.5 - # description — непустой текст объявления - assert result.description is not None - assert len(result.description) > 50 - assert result.cian_id is not None diff --git a/tradein-mvp/backend/tests/test_cian_serp_scraper.py b/tradein-mvp/backend/tests/test_cian_serp_scraper.py deleted file mode 100644 index 0bb42868..00000000 --- a/tradein-mvp/backend/tests/test_cian_serp_scraper.py +++ /dev/null @@ -1,792 +0,0 @@ -"""Smoke тесты для cian.py SERP scraper (Stage 3 рефактор). - -Проверяют парсинг 137 полей из синтетического Redux state без сетевых запросов. -Fixture формирует минимальный JS push() conforming to _cianConfig pattern. -""" - -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from app.services.scrapers.cian import CianScraper, _format_address - -# ── Fixture helpers ────────────────────────────────────────────────────────── - - -def _make_cian_serp_html(offers: list[dict]) -> str: - """Обернуть offers в валидный _cianConfig['frontend-serp'].push() HTML.""" - state = { - "results": { - "totalOffers": len(offers), - "offers": offers, - } - } - state_json = json.dumps(state, ensure_ascii=False) - return f""" - -""" - - -def _make_full_offer( - cian_id: int = 328739308, - price_rur: int = 6500000, - rooms: int = 2, - total_area: str = "52.0", - living_area: str = "35.0", - kitchen_area: str = "11.5", - floor: int = 7, - floors_count: int = 16, - year_built: int = 2010, - material_type: str = "panel", - lat: float = 56.8385, - lng: float = 60.5940, - cadastral_number: str = "66:41:0204016:1234", - building_cadastral_number: str = "66:41:0204016:100", - description: str = "Продаётся отличная квартира", - description_minhash: str | list[int] = "abc123def456abc1", - newbuilding_id: int = 0, - has_phones: bool = True, - is_homeowner: bool = True, - is_pro: bool = False, - bargain_allowed: bool = True, - sale_type: str = "free", -) -> dict: - """Синтетический Cian offer с полным набором полей (137 полей subset).""" - offer: dict = { - "cianId": cian_id, - "id": cian_id, - "fullUrl": f"https://ekb.cian.ru/sale/flat/{cian_id}/", - "category": "flatSale", - "dealType": "sale", - "offerType": "flat", - "flatType": "rooms", - "isApartments": False, - "roomsCount": rooms, - "status": "published", - # Area - "totalArea": total_area, - "livingArea": living_area, - "kitchenArea": kitchen_area, - "balconiesCount": 1, - "loggiasCount": 0, - "bedroomsCount": None, - "hasFurniture": False, - "isSoldFurnished": False, - "decoration": None, - # Floor / building - "floorNumber": floor, - "building": { - "floorsCount": floors_count, - "materialType": material_type, - "buildYear": year_built, - "deadline": None, - "parking": None, - }, - # Cadastral - "cadastralNumber": cadastral_number, - "buildingCadastralNumber": building_cadastral_number, - # Price - "bargainTerms": { - "priceRur": price_rur, - "price": price_rur, - "currency": "rur", - "saleType": sale_type, - "mortgageAllowed": True, - "bargainAllowed": bargain_allowed, - "priceType": "all", - }, - "formattedShortPrice": f"{price_rur // 1000000} млн ₽", - # Geo - "geo": { - "coordinates": {"lat": lat, "lng": lng}, - "address": [ - {"type": "location", "name": "Свердловская область"}, - {"type": "city", "name": "Екатеринбург"}, - {"type": "street", "name": "улица Постовского"}, - {"type": "house", "name": "17А"}, - ], - "undergrounds": [ - { - "name": "Геологическая", - "time": 10, - "transportType": "walk", - "lineId": 10, - "lineColor": "FF0000", - "isDefault": True, - "id": 100, - } - ], - "districts": [], - }, - # Newbuilding - "newbuilding": {"id": newbuilding_id, "name": ""}, - "buildersIds": [], - # Seller - "phones": [{"number": "9012345678", "countryCode": "7"}] if has_phones else [], - "userId": 13332612, - "cianUserId": 13332612, - "publishedUserId": 13332612, - "isByHomeowner": is_homeowner, - "isByCommercialOwner": False, - "isCianPartner": False, - "isPro": is_pro, - "isCalltrackingEnabled": False, - # Description - "description": description, - "descriptionMinhash": description_minhash, - "isDuplicatedDescription": False, - # Time - "addedTimestamp": 1716451200, # 2024-05-23 00:00:00 UTC - "added": "вчера", - # Photos - "photos": [ - { - "id": 1001, - "fullUrl": f"https://images.cdn-cian.ru/images/{cian_id}-1.jpg", - "thumbnailUrl": f"https://images.cdn-cian.ru/images/{cian_id}-2.jpg", - } - ], - # Verification - "isRosreestrChecked": True, - "isLayoutApproved": None, - # Misc - "isNew": False, - "isTop3": False, - "isPremium": False, - } - return offer - - -# ── Tests ──────────────────────────────────────────────────────────────────── - - -def test_cian_serp_parses_full_offer(): - """Базовый тест: полный offer → ScrapedLot с ключевыми полями.""" - offer = _make_full_offer() - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - assert len(lots) == 1 - lot = lots[0] - - assert lot.source == "cian" - assert lot.source_id == "328739308" - assert lot.source_url == "https://ekb.cian.ru/sale/flat/328739308/" - - -def test_cian_serp_coords_no_jitter(): - """Координаты берутся точно из state — NO anchor jitter.""" - offer = _make_full_offer(lat=56.8385, lng=60.5940) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - assert len(lots) == 1 - lot = lots[0] - assert lot.lat == pytest.approx(56.8385, abs=1e-9) - assert lot.lon == pytest.approx(60.5940, abs=1e-9) - - -def test_cian_serp_kitchen_area_in_raw_payload(): - """Сырой kitchenArea (str) остаётся в raw_payload (#2008 промоутит float - в колонку ScrapedLot.kitchen_area_m2 отдельно — см. соответств. тест).""" - offer = _make_full_offer(kitchen_area="11.5") - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.raw_payload is not None - assert lot.raw_payload["kitchen_area_m2"] == "11.5" - - -def test_cian_serp_description_minhash(): - """description_minhash берётся из Cian state, если есть.""" - offer = _make_full_offer( - description_minhash="cian_minhash_abc123", - description="Тест описания", - ) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.description_minhash == "cian_minhash_abc123" - - -def test_cian_serp_description_minhash_fallback(): - """Если Cian не дал minhash — вычисляем SHA1 из description.""" - offer = _make_full_offer(description_minhash="", description="Уникальное описание квартиры") - offer["descriptionMinhash"] = "" # явно пусто - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.description_minhash is not None - assert len(lot.description_minhash) == 32 # SHA1 hex [:32] - - -def test_cian_serp_description_minhash_list_int(): - """descriptionMinhash как list[int] (новый формат Cian ~2026-05-23) → строка через запятую. - - Порядок элементов сохраняется как-есть — sorted() НЕ применяется, - т.к. порядок LSH-band'ов значим для dedup/cross-source matching. - Offer должен быть принят (не отброшен) — возвращает ScrapedLot, не None. - """ - minhash_list = [8439545, 5781654, 555882, 407717] - offer = _make_full_offer( - description_minhash=minhash_list, # type: ignore[arg-type] - description="Продаётся квартира с видом на парк", - ) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - assert len(lots) == 1, "offer с list[int] minhash должен быть принят, не отброшен" - lot = lots[0] - assert lot.description_minhash == "8439545,5781654,555882,407717" - - -def test_cian_serp_phones(): - """PII purge (#2217): даже если raw offer содержит phones, lot.phones пуст — - скрейпер больше не читает offer.get("phones").""" - offer = _make_full_offer(has_phones=True) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert isinstance(lot.phones, list) - assert lot.phones == [] - - -def test_cian_serp_house_ext_id_newbuilding(): - """Новостройка с кадастром дома → anchor = building cad (#1773), не nb_id. - - _make_full_offer несёт building_cadastral_number, поэтому same-building anchor - предпочитает кадастр дома (надёжнее, чем nb_id, который группирует весь ЖК).""" - offer = _make_full_offer(newbuilding_id=54016) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.house_source == "cian_building_cad" - assert lot.house_ext_id == "66:41:0204016:100" - assert lot.listing_segment == "novostroyki" - - -def test_cian_serp_house_ext_id_newbuilding_nb_id_fallback(): - """Без кадастра дома anchor падает на nb_id (ЖК) — back-compat fallback.""" - offer = _make_full_offer(newbuilding_id=54016, building_cadastral_number="") - offer["buildingCadastralNumber"] = None - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.house_source == "cian_newbuilding" - assert lot.house_ext_id == "54016" - assert lot.listing_segment == "novostroyki" - - -def test_cian_serp_vtorichka_no_house_ext_id(): - """Вторичка (newbuilding.id == 0) — house_ext_id None.""" - offer = _make_full_offer(newbuilding_id=0) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.house_source == "cian" - assert lot.house_ext_id is None - assert lot.listing_segment == "vtorichka" - - -def test_cian_serp_metro_stations(): - """metro_stations — список dicts с name/time/mode/line_color.""" - offer = _make_full_offer() - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert isinstance(lot.metro_stations, list) - assert len(lot.metro_stations) == 1 - station = lot.metro_stations[0] - assert station["name"] == "Геологическая" - assert station["time"] == 10 - assert station["mode"] == "walk" - assert station["line_color"] == "FF0000" - - -def test_cian_serp_cadastral_numbers(): - """Кадастровые номера квартиры и здания.""" - offer = _make_full_offer( - cadastral_number="66:41:0204016:1234", - building_cadastral_number="66:41:0204016:100", - ) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.cadastral_number == "66:41:0204016:1234" - assert lot.building_cadastral_number == "66:41:0204016:100" - # kadastr_num (base field) тоже заполнен - assert lot.kadastr_num == "66:41:0204016:1234" - - -def test_cian_serp_is_homeowner_seller(): - """is_homeowner и is_pro_seller из offer.""" - offer = _make_full_offer(is_homeowner=True, is_pro=False) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.is_homeowner is True - assert lot.is_pro_seller is False - - -def test_cian_serp_bargain_and_sale_type(): - """bargain_allowed и sale_type из bargainTerms.""" - offer = _make_full_offer(bargain_allowed=True, sale_type="alternative") - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.bargain_allowed is True - assert lot.sale_type == "alternative" - - -def test_cian_serp_building_fields(): - """Поля здания: year_built, house_type, total_floors.""" - offer = _make_full_offer(year_built=1988, material_type="brick", floors_count=9) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.year_built == 1988 - assert lot.house_type == "brick" - assert lot.total_floors == 9 - - -def test_cian_serp_promotes_class_b_fields(): - """#2008: kitchen_area_m2 / mortgage_available / is_apartments / - is_rosreestr_checked промоутятся в колонки ScrapedLot (Class B — - дашборд/matching, НЕ valuation).""" - # _make_full_offer: mortgageAllowed=True, isApartments=False, isRosreestrChecked=True - offer = _make_full_offer(kitchen_area="11.5") - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.kitchen_area_m2 == pytest.approx(11.5) - assert lot.mortgage_available is True - assert lot.is_apartments is False - assert lot.is_rosreestr_checked is True - - -def test_cian_serp_house_type_normalized_monolith_brick(): - """#2008 (Class A): cian materialType 'monolithBrick' → канон 'monolith_brick'; - сырьё остаётся в raw_payload['raw_material_type'].""" - offer = _make_full_offer(material_type="monolithBrick") - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.house_type == "monolith_brick" - assert lot.raw_payload is not None - assert lot.raw_payload["raw_material_type"] == "monolithBrick" - - -def test_cian_serp_house_type_block_synonyms_normalized(): - """#2008: gasSilicate/aerocrete/foamConcrete Block + stalin → канон.""" - scraper = CianScraper() - for raw, canon in ( - ("gasSilicateBlock", "block"), - ("aerocreteBlock", "block"), - ("foamConcreteBlock", "block"), - ("stalin", "brick"), # «сталинка» = кирпич - ): - offer = _make_full_offer(material_type=raw) - lots = scraper._parse_serp_html(_make_cian_serp_html([offer])) - assert lots[0].house_type == canon, f"{raw} → {lots[0].house_type} != {canon}" - - -def test_cian_serp_house_type_canonical_passthrough(): - """#2008: уже-каноничные monolith/panel остаются неизменными (idempotency).""" - scraper = CianScraper() - for raw in ("monolith", "panel", "brick", "block", "wood"): - offer = _make_full_offer(material_type=raw) - lots = scraper._parse_serp_html(_make_cian_serp_html([offer])) - assert lots[0].house_type == raw - - -def test_cian_serp_house_type_unknown_becomes_none(): - """#2008: неизвестный materialType → None (нейтрально для estimator soft-penalty).""" - offer = _make_full_offer(material_type="someUnknownMaterial") - lots = CianScraper()._parse_serp_html(_make_cian_serp_html([offer])) - assert lots[0].house_type is None - - -def test_cian_serp_living_area(): - """living_area_m2 из offer.livingArea.""" - offer = _make_full_offer(living_area="35.0") - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.living_area_m2 == pytest.approx(35.0) - - -def test_cian_serp_state_missing_returns_empty(): - """Если state нет в HTML — возвращаем пустой список.""" - html = "

no cian state here

" - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - assert lots == [] - - -def test_cian_serp_skips_offer_without_price(): - """Offer без цены — пропускаем.""" - offer = _make_full_offer() - del offer["bargainTerms"]["priceRur"] - del offer["bargainTerms"]["price"] - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - assert lots == [] - - -def test_cian_serp_multiple_offers(): - """Несколько offers → список ScrapedLot.""" - offers = [_make_full_offer(cian_id=100 + i) for i in range(5)] - html = _make_cian_serp_html(offers) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - assert len(lots) == 5 - ids = [lot.source_id for lot in lots] - assert sorted(ids) == ["100", "101", "102", "103", "104"] - - -def test_cian_serp_photo_urls(): - """photo_urls — полные URL из offer.photos[].fullUrl.""" - offer = _make_full_offer(cian_id=999) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert len(lot.photo_urls) == 1 - assert "cdn-cian.ru" in lot.photo_urls[0] - assert "999-1.jpg" in lot.photo_urls[0] - - -def test_cian_serp_no_coords_does_not_crash(): - """Если координаты отсутствуют — lot.lat/lon = None, не краш.""" - offer = _make_full_offer() - del offer["geo"]["coordinates"] - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - assert len(lots) == 1 - assert lots[0].lat is None - assert lots[0].lon is None - - -# ── Transport: BrowserFetcher (camoufox) миграция (#1806) ──────────────────── - - -@pytest.mark.asyncio -async def test_cian_aenter_opens_browser_fetcher() -> None: - """__aenter__ открывает ОДНУ BrowserFetcher(source="cian") сессию (camoufox).""" - mock_browser = MagicMock() - mock_browser.__aenter__ = AsyncMock(return_value=mock_browser) - mock_browser.__aexit__ = AsyncMock(return_value=None) - - captured: dict = {} - - def _fake_browser(*args, **kwargs): - captured["source"] = kwargs.get("source") if kwargs else (args[0] if args else None) - return mock_browser - - scraper = CianScraper() - with patch("app.services.scrapers.cian.BrowserFetcher", _fake_browser): - await scraper.__aenter__() - - mock_browser.__aenter__.assert_awaited_once() - assert scraper._browser is mock_browser - assert captured["source"] == "cian" - - await scraper.__aexit__(None, None, None) - mock_browser.__aexit__.assert_awaited_once() - assert scraper._browser is None - - -@pytest.mark.asyncio -async def test_cian_fetch_page_html_uses_browser_fetcher() -> None: - """_fetch_page_html тянет HTML через BrowserFetcher.fetch, не через curl_cffi.""" - scraper = CianScraper.__new__(CianScraper) - scraper.name = "cian" - scraper.base_url = "https://ekb.cian.ru" - scraper.request_delay_sec = 0.0 - - fetched_urls: list[str] = [] - - async def fake_fetch(url: str) -> str: - fetched_urls.append(url) - return "serp-page" - - scraper._browser = MagicMock() - scraper._browser.fetch = AsyncMock(side_effect=fake_fetch) - - html = await scraper._fetch_page_html((2,), 1, None, None) - - assert html == "serp-page" - assert len(fetched_urls) == 1 - assert "cat.php" in fetched_urls[0] - - -@pytest.mark.asyncio -async def test_cian_fetch_page_html_returns_none_on_browser_error() -> None: - """Если BrowserFetcher.fetch падает — _fetch_page_html возвращает None (не краш).""" - scraper = CianScraper.__new__(CianScraper) - scraper.name = "cian" - scraper.base_url = "https://ekb.cian.ru" - scraper.request_delay_sec = 0.0 - - scraper._browser = MagicMock() - scraper._browser.fetch = AsyncMock(side_effect=RuntimeError("browser down")) - - html = await scraper._fetch_page_html((1,), 2, 1_000_000, 5_000_000) - assert html is None - - -@pytest.mark.asyncio -async def test_cian_fetch_around_parses_browser_html() -> None: - """fetch_around парсит лоты из SSR-HTML, отданного BrowserFetcher.""" - offer = _make_full_offer(cian_id=777) - serp_html = _make_cian_serp_html([offer]) - - scraper = CianScraper.__new__(CianScraper) - scraper.name = "cian" - scraper.base_url = "https://ekb.cian.ru" - scraper.request_delay_sec = 0.0 - scraper._browser = MagicMock() - scraper._browser.fetch = AsyncMock(return_value=serp_html) - - with patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock): - lots = await scraper.fetch_around(56.84, 60.60, rooms=(2,), page=1) - - assert len(lots) == 1 - assert lots[0].source_id == "777" - scraper._browser.fetch.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_cian_rotate_ip_is_noop() -> None: - """_rotate_ip после миграции — no-op, всегда False (прокси ротирует IP сам).""" - scraper = CianScraper.__new__(CianScraper) - result = await scraper._rotate_ip() - assert result is False - - -# ── Address formatter tests ────────────────────────────────────────────────── - - -def test_format_address_standard(): - """Стандартный geo.address[].""" - parts = [ - {"type": "location", "name": "Свердловская область"}, - {"type": "city", "name": "Екатеринбург"}, - {"type": "district", "name": "Чкаловский"}, - {"type": "street", "name": "улица Постовского"}, - {"type": "house", "name": "17А"}, - {"type": "metro", "name": "Геологическая"}, - ] - result = _format_address(parts) - # location и metro пропускаем - assert "Свердловская" not in result - assert "Геологическая" not in result - assert "Екатеринбург" in result - assert "17А" in result - - -def test_format_address_empty(): - """Пустой address[] → fallback string.""" - assert _format_address([]) == "Екатеринбург (Cian)" - - -# ── #1773: новостройка без part{type:"house"} → восстановление номера дома ───── - - -def _make_newbuilding_houseless_offer( - cian_id: int = 330047129, - newbuilding_id: int = 54016, - newbuilding_name: str = "Суходольский квартал", - building_cadastral_number: str | None = "66:41:0313010:11396", - building_house_number: str | None = None, -) -> dict: - """Cian-оффер новостройки: geo.address[] БЕЗ {type:"house"} (ЖК Суходольский, - ул. Евгения Савкова 29, cian 330047129 — реальный кейс из #1773).""" - offer = _make_full_offer( - cian_id=cian_id, - newbuilding_id=newbuilding_id, - building_cadastral_number=building_cadastral_number or "", - ) - # geo.address[] без части house — корень бага: «…улица Евгения Савкова» без «29» - offer["geo"]["address"] = [ - {"type": "location", "name": "Свердловская область"}, - {"type": "city", "name": "Екатеринбург"}, - {"type": "district", "name": "Академический"}, - {"type": "street", "name": "улица Евгения Савкова"}, - ] - offer["newbuilding"] = {"id": newbuilding_id, "name": newbuilding_name} - building = offer.setdefault("building", {}) - if building_house_number is not None: - building["houseNumber"] = building_house_number - if building_cadastral_number is None: - offer["buildingCadastralNumber"] = None - return offer - - -def test_cian_newbuilding_cad_number_anchor(): - """Новостройка с кадастром дома → house_source/house_ext_id = building cad - (same-building anchor #1773), адрес дополнен кадастром.""" - offer = _make_newbuilding_houseless_offer( - building_cadastral_number="66:41:0313010:11396", - ) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - assert len(lots) == 1 - lot = lots[0] - # Кадастр дома предпочтён как anchor-ключ вместо nb_id (ЖК целиком) - assert lot.house_source == "cian_building_cad" - assert lot.house_ext_id == "66:41:0313010:11396" - assert lot.building_cadastral_number == "66:41:0313010:11396" - assert lot.listing_segment == "novostroyki" - # geo.address[] был без house → кадастр дописан в адрес как якорь - assert "Евгения Савкова" in (lot.address or "") - assert "66:41:0313010:11396" in (lot.address or "") - - -def test_cian_newbuilding_recovers_house_from_building_number(): - """building.houseNumber имеет приоритет (a): «29» восстановлен в адрес, - кадастр остаётся anchor-ключом.""" - offer = _make_newbuilding_houseless_offer( - building_cadastral_number="66:41:0313010:11396", - building_house_number="29", - ) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - addr = lot.address or "" - assert "Евгения Савкова" in addr - assert "29" in addr # номер дома восстановлен из building.houseNumber - # анкор по-прежнему кадастр дома (надёжнее nb_id) - assert lot.house_ext_id == "66:41:0313010:11396" - - -def test_cian_newbuilding_recovers_house_from_name_when_no_cad(): - """Без кадастра дома: дом-токен «29» вытягивается из названия ЖК (приоритет c), - anchor падает на nb_id.""" - offer = _make_newbuilding_houseless_offer( - newbuilding_name="ЖК на Савкова 29", - building_cadastral_number=None, - ) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - assert lot.building_cadastral_number is None - # нет кадастра → anchor = nb_id (ЖК) - assert lot.house_source == "cian_newbuilding" - assert lot.house_ext_id == "54016" - addr = lot.address or "" - assert "29" in addr # дом восстановлен из названия ЖК - - -def test_cian_newbuilding_no_house_no_fabrication(): - """Ни building, ни кадастра, ни номера в названии → номер НЕ выдумываем.""" - offer = _make_newbuilding_houseless_offer( - newbuilding_name="Суходольский квартал", # без числа-хвоста - building_cadastral_number=None, - ) - html = _make_cian_serp_html([offer]) - - scraper = CianScraper() - lots = scraper._parse_serp_html(html) - - lot = lots[0] - addr = lot.address or "" - assert "Евгения Савкова" in addr - # никаких фейковых catch-all номеров (например 7019) в адресе - assert not addr.rstrip().endswith("7019") - - -def test_format_address_vtorichka_houseless_untouched(): - """Вторичка с house-less адресом НЕ трогается — номер не выдумываем.""" - parts = [ - {"type": "city", "name": "Екатеринбург"}, - {"type": "street", "name": "улица Евгения Савкова"}, - ] - result = _format_address( - parts, - building={"houseNumber": "29"}, - building_cadastral_number="66:41:0313010:11396", - newbuilding={"id": 0, "name": ""}, - is_newbuilding=False, - ) - # вторичка: is_newbuilding=False → ни «29», ни кадастр не дописываются - assert "29" not in result - assert "66:41" not in result diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index e7b6908c..0476100c 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -6,21 +6,25 @@ cancel, honest mark_done/mark_failed statuses) удалён вместе с теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_{done, blocked_failed}` (kit `run_domclick_city_sweep`). +`app.services.scrapers.domclick` удалён (#2397 Part E2, 0 runtime-импортёров) — +`_map_item` ретаргетирован на kit `scraper_kit.providers.domclick.serp.DomClickScraper` +(единственный живой путь). + Остались: - DomClickCitySweepCounters (dataclass, из scraper_kit) — defaults + to_dict - - DomClickScraper._map_item — BFF offer-item → ScrapedLot маппинг (не зависит - от оркестрации, `app.services.scrapers.domclick` живёт до Part E2) + - DomClickScraper._map_item (kit) — BFF offer-item → ScrapedLot маппинг (не + зависит от оркестрации) """ from __future__ import annotations import os +from types import SimpleNamespace os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest - -from app.services.scrapers.domclick import DomClickScraper +from scraper_kit.providers.domclick.serp import DomClickScraper # ── DomClickCitySweepCounters ─────────────────────────────────────────────── @@ -54,7 +58,8 @@ def test_domclick_sweep_counters_to_dict_all_keys() -> None: def test_map_item_basic_mapping() -> None: """DomClickScraper._map_item маппит BFF offer-item → ScrapedLot корректно.""" - scraper = DomClickScraper() + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") + scraper = DomClickScraper(config, delay_provider=lambda _name: 8.0) item = { "id": 111222333, "path": "https://domclick.ru/card/sale__flat__111222333", diff --git a/tradein-mvp/backend/tests/test_houses_rating_filter_entries_1789.py b/tradein-mvp/backend/tests/test_houses_rating_filter_entries_1789.py deleted file mode 100644 index 428c28b7..00000000 --- a/tradein-mvp/backend/tests/test_houses_rating_filter_entries_1789.py +++ /dev/null @@ -1,189 +0,0 @@ -"""#1789 — rating_score из reviews.filterEntries[type==score] (fallback). - -Avito перенёс сводный рейтинг из housePage.developmentData.ratingPreview в виджет -reviews.filterEntries[{type: 'score', value: {scoreFloat, scoreString, reviewCount, ratingStat}}]. - -Тест 1: когда housePage НЕ даёт рейтинг (ratingBadge пуст), rating берётся из - reviews.filterEntries — проверяем rating_score/rating_string/reviews_count/ - rating_distribution. - -Тест 2: housePage даёт рейтинг (старый путь ratingBadge) → filterEntries НЕ - перезаписывает (приоритет housePage). - -Тест 3: новостройка (developmentPage) без reviews-виджета → graceful, нет краша. - -Без сети, без БД. -""" - -from __future__ import annotations - -import os - -import pytest - -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -from app.services.scrapers.avito_houses import parse_houses_state - - -def _make_state( - *, - house_page_rating: dict | None = None, - reviews_filter_entries: list | None = None, -) -> dict: - """Минимальный __preloadedState__ с housePage и опциональными reviews. - - house_page_rating — если задан, проставляем в developmentData (старый путь). - reviews_filter_entries — если задан, проставляем в reviews.props.reviews.filterEntries - """ - dev_data: dict = { - "avitoId": 555666, - "id": "aGFzaA==", - "title": "Test House", - "address": "ул. Тестовая, 1", - "coords": {"lat": 56.8, "lng": 60.6}, - "aboutDevelopment": {"expandParams": {"items": []}}, - "developer": {}, - "mapPreview": {}, - } - if house_page_rating is not None: - dev_data.update(house_page_rating) - - placeholders: list = [ - { - "type": "housePage", - "props": {"developmentData": dev_data}, - }, - ] - - if reviews_filter_entries is not None: - placeholders.append( - { - "type": "reviews", - "props": { - # MFE-форма: данные под ключом "reviews" внутри props - "reviews": { - "entries": [], - "filterEntries": reviews_filter_entries, - } - }, - } - ) - - return {"data": {"data": {"page": {"placeholders": placeholders}}}} - - -# --------------------------------------------------------------------------- -# Тест 1: рейтинг из reviews.filterEntries (housePage пуст) -# --------------------------------------------------------------------------- - - -def test_rating_from_filter_entries_when_housepage_empty() -> None: - """Когда housePage не даёт рейтинг — берём из reviews.filterEntries.""" - state = _make_state( - reviews_filter_entries=[ - { - "type": "score", - "value": { - "scoreFloat": 4.666, - "scoreString": "4,7", - "reviewCount": 3, - "ratingStat": [ - {"score": 5, "count": 2}, - {"score": 4, "count": 1}, - ], - }, - } - ] - ) - - e = parse_houses_state(state, "/catalog/houses/ekaterinburg/test/555666") - h = e.house - - assert h.rating_score == pytest.approx(4.666, rel=1e-3) - assert h.rating_string == "4,7" - assert h.reviews_count == 3 - assert len(h.rating_distribution) == 2 - assert h.rating_distribution[0]["score"] == 5 - - -def test_rating_filter_entries_float_type() -> None: - """scoreFloat из filterEntries конвертируется в float (не остаётся int/str).""" - state = _make_state( - reviews_filter_entries=[ - { - "type": "score", - "value": {"scoreFloat": 5, "scoreString": "5,0", "reviewCount": 1}, - } - ] - ) - e = parse_houses_state(state, "/catalog/houses/ekaterinburg/test/555666") - assert isinstance(e.house.rating_score, float) - assert e.house.rating_score == 5.0 - - -# --------------------------------------------------------------------------- -# Тест 2: housePage даёт рейтинг → filterEntries НЕ перезаписывает -# --------------------------------------------------------------------------- - - -def test_housepage_rating_takes_priority_over_filter_entries() -> None: - """Если housePage.ratingBadge уже заполнен — filterEntries не перезаписывает.""" - state = _make_state( - house_page_rating={ - "ratingBadge": {"info": {"score": 3.9, "scoreString": "3,9"}}, - "ratingSummaryStat": {"reviewCount": 10, "ratingStat": []}, - }, - reviews_filter_entries=[ - { - "type": "score", - "value": { - "scoreFloat": 4.9, - "scoreString": "4,9", - "reviewCount": 99, - "ratingStat": [], - }, - } - ], - ) - - e = parse_houses_state(state, "/catalog/houses/ekaterinburg/test/555666") - h = e.house - - # Значение из housePage должно сохраниться - assert h.rating_score == pytest.approx(3.9, rel=1e-3) - assert h.rating_string == "3,9" - assert h.reviews_count == 10 - - -# --------------------------------------------------------------------------- -# Тест 3: нет reviews-виджета вообще → None, не падаем -# --------------------------------------------------------------------------- - - -def test_no_reviews_widget_rating_is_none() -> None: - """Без виджета reviews и без ratingBadge → rating_score=None, graceful.""" - state = _make_state() # нет reviews_filter_entries - - e = parse_houses_state(state, "/catalog/houses/ekaterinburg/test/555666") - h = e.house - - assert h.rating_score is None - assert h.rating_string is None - assert h.reviews_count is None - - -# --------------------------------------------------------------------------- -# Тест 4: filterEntries без entry type==score → rating остаётся None -# --------------------------------------------------------------------------- - - -def test_filter_entries_without_score_type() -> None: - """filterEntries без type==score → рейтинг не заполняется.""" - state = _make_state( - reviews_filter_entries=[ - {"type": "something_else", "value": {"scoreFloat": 9.9}}, - ] - ) - e = parse_houses_state(state, "/catalog/houses/ekaterinburg/test/555666") - assert e.house.rating_score is None diff --git a/tradein-mvp/backend/tests/test_pipeline_browser_routing.py b/tradein-mvp/backend/tests/test_pipeline_browser_routing.py index 9d86d8e9..48530e6b 100644 --- a/tradein-mvp/backend/tests/test_pipeline_browser_routing.py +++ b/tradein-mvp/backend/tests/test_pipeline_browser_routing.py @@ -12,6 +12,10 @@ Legacy `run_avito_pipeline` browser/curl-mode integration tests (было ран удалены вместе с `app.services.scrape_pipeline` (#2397 Part E1) — browser-routing покрытие на уровне `fetch_detail`/`fetch_house_catalog`/`AvitoScraper._fetch_serp_html` ниже остаётся нетронутым. + +`fetch_house_catalog` ретаргетирован на `scraper_kit.providers.avito.houses` (#2397 +Part E2) — legacy `app.services.scrapers.avito_houses` удалён (0 runtime-импортёров, +kit — единственный живой путь). """ from __future__ import annotations @@ -26,9 +30,11 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") +from scraper_kit.avito_exceptions import AvitoBlockedError as KitAvitoBlockedError +from scraper_kit.providers.avito.houses import fetch_house_catalog + from app.services.scrapers.avito_detail import fetch_detail from app.services.scrapers.avito_exceptions import AvitoBlockedError -from app.services.scrapers.avito_houses import fetch_house_catalog FIXTURES = Path(__file__).parent / "fixtures" @@ -205,10 +211,10 @@ async def test_fetch_house_catalog_browser_no_curl_used() -> None: @pytest.mark.asyncio async def test_fetch_house_catalog_browser_firewall_raises() -> None: - """Firewall HTML через browser path → AvitoBlockedError.""" + """Firewall HTML через browser path → AvitoBlockedError (kit).""" mock_fetcher = make_mock_fetcher(FIREWALL_HTML) - with pytest.raises(AvitoBlockedError, match="browser-mode"): + with pytest.raises(KitAvitoBlockedError, match="browser-mode"): await fetch_house_catalog( "/catalog/houses/ekaterinburg/ul_test/9999", browser_fetcher=mock_fetcher, diff --git a/tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py index 011c8d70..44e1508c 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py @@ -1,14 +1,15 @@ -"""Golden-parity бисекции: старый боевой обход `app.services.scrapers.` +"""Golden-parity бисекции: старый боевой обход `app.services.scrapers.avito` ≡ новый единый движок `scraper_kit.pricing.walk_price_range` (#2134). Четыре независимые копии адаптивной бисекции «комнатность × цена» были консолидированы в один движок (`scraper_kit/pricing/bisection.py`). Провайдеры -(avito/cian/yandex/domclick) теперь передают в движок свои probe/paginate + свой -`BisectionConfig`. Этот тест доказывает, что unification НЕ изменил ОБХОД: для -каждого провайдера при одинаковом входе (детерминированный мок счётчика по -диапазону) старая боевая реализация и новая (движок) посещают ИДЕНТИЧНУЮ -последовательность бакетов — probe-запросы, leaf-пагинации, деления, ранние -возвраты на нуле, probe-fail-ветки. +cian/domclick/yandex_realty удалены (#2397 Part E2, 0 runtime-импортёров) — +их legacy-vs-kit секции обхода убраны отсюда вместе с ними (легаси-эталон +исчез). `avito` остаётся легаси (не мигрирован) — его parity-проверка ниже +доказывает, что unification НЕ изменил ОБХОД: при одинаковом входе +(детерминированный мок счётчика по диапазону) старая боевая реализация и +новая (движок) посещают ИДЕНТИЧНУЮ последовательность бакетов — probe-запросы, +leaf-пагинации, деления, ранние возвраты на нуле, probe-fail-ветки. Механика: на ОБЕ реализации (старую и новую) ставятся ОДИНАКОВЫЕ моки на низкоуровневые методы (probe-fetch + leaf-пагинатор), которые (а) возвращают @@ -22,7 +23,7 @@ import os from collections.abc import Callable from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -30,14 +31,8 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ from scraper_kit.price_brackets import get_price_seed_brackets from scraper_kit.providers.avito.serp import AvitoScraper as NewAvito -from scraper_kit.providers.cian.serp import CianScraper as NewCian -from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClick -from scraper_kit.providers.yandex.serp import YandexRealtyScraper as NewYandex from app.services.scrapers.avito import AvitoScraper as OldAvito -from app.services.scrapers.cian import CianScraper as OldCian -from app.services.scrapers.domclick import DomClickScraper as OldDomClick -from app.services.scrapers.yandex_realty import YandexRealtyScraper as OldYandex # ── Оракул счётчиков ───────────────────────────────────────────────────────── @@ -55,7 +50,7 @@ def make_oracle( span считается по клампнутым границам (None-lo → 0, None-hi → open_ceiling), чтобы широкие брекеты делились, а узкие уходили в leaf. `zeros` форсит 0 (ранний - возврат / leaf-на-нуле у yandex), `nones` форсит None (probe-fail). + возврат), `nones` форсит None (probe-fail). """ def oracle(lo: Any, hi: Any) -> int | None: @@ -71,10 +66,10 @@ def make_oracle( return oracle -# ── Провайдер-специфичные «сборщики событий» ───────────────────────────────── +# ── avito «сборщик событий» ────────────────────────────────────────────────── # -# Каждый ставит одинаковые моки на старый/новый экземпляр и возвращает список -# событий обхода. `("probe", lo, hi)` — запрос счётчика бакета; `("leaf", ...)` — +# Ставит одинаковые моки на старый/новый экземпляр и возвращает список событий +# обхода. `("probe", lo, hi)` — запрос счётчика бакета; `("leaf", ...)` — # пагинация leaf-бакета. @@ -91,81 +86,6 @@ def _patch_avito(scraper: Any, oracle: Oracle, events: list[Any]) -> None: scraper._rotate_ip = AsyncMock(return_value=False) -def _patch_cian(scraper: Any, oracle: Oracle, events: list[Any]) -> None: - holder: dict[str, tuple[Any, Any]] = {} - - def fph(rooms: Any, page: int, min_price: Any, max_price: Any) -> str: - events.append(("probe", min_price, max_price)) - holder["b"] = (min_price, max_price) - return "H" - - def eto(html: str) -> int | None: - return oracle(*holder["b"]) - - def leaf(**kw: Any) -> None: - events.append(("leaf", kw["lo"], kw["hi"], kw["total"])) - - scraper._fetch_page_html = AsyncMock(side_effect=fph) - scraper._extract_total_offers = MagicMock(side_effect=eto) - scraper._paginate_leaf_bucket = AsyncMock(side_effect=leaf) - scraper._rotate_ip = AsyncMock(return_value=False) - scraper.sleep_between_requests = AsyncMock() - - -def _patch_domclick(scraper: Any, oracle: Oracle, events: list[Any]) -> None: - def count(fetcher: Any, rooms: Any, gte: Any, lte: Any) -> int: - events.append(("probe", gte, lte)) - val = oracle(gte, lte) - return val if val is not None else 0 - - def paginate( - fetcher: Any, rooms: Any, gte: Any, lte: Any, seen_ids: Any, out_lots: Any, pages: Any - ) -> None: - events.append(("leaf", gte, lte)) - - scraper._count = AsyncMock(side_effect=count) - scraper._paginate = AsyncMock(side_effect=paginate) - - -def _patch_yandex( - scraper: Any, oracle: Oracle, events: list[Any], *, record_pages: bool = False -) -> None: - """Yandex: leaf-пагинация встроена в walk (нет отдельного метода-хука). - - Стабим `_fetch_page_json` (page=1 отдаёт payload с totalItems из оракула; - page>1 — пустой payload) и передаём `on_bucket`, который пишет завершение - leaf/degraded бакета. probe = page-1-fetch; leaf-страницы 2..N идут через - asyncio.gather (порядок недетерминирован) — их НЕ пишем в норм-режиме; факт - leaf-обхода фиксируется через on_bucket. Для degraded-теста (пагинация - последовательная) `record_pages=True` пишет все страницы. - """ - - def gate(total: int | None, entities: list[Any] | None = None) -> dict[str, Any]: - pager: dict[str, Any] = {} - if total is not None: - pager["totalItems"] = total - return {"response": {"search": {"offers": {"entities": entities or [], "pager": pager}}}} - - def fpj(rooms: Any, page: int, price_min: Any, price_max: Any, new_flat: str = "NO") -> Any: - if page == 1: - events.append(("probe", price_min, price_max)) - return gate(oracle(price_min, price_max)) - if record_pages: - events.append(("page", price_min, price_max, page)) - return gate(0, entities=[]) - - scraper._fetch_page_json = AsyncMock(side_effect=fpj) - scraper._rotate_ip = AsyncMock(return_value=False) - scraper.request_delay_sec = 0.0 - - -def _yandex_on_bucket(events: list[Any]) -> Callable[[str, int], None]: - def on_bucket(bucket_key: str, count: int) -> None: - events.append(("bucket", bucket_key)) - - return on_bucket - - # ── Конструкторы старых/новых экземпляров (offline, без DB) ─────────────────── _KIT_CONFIG = SimpleNamespace( @@ -185,42 +105,14 @@ def _old_avito() -> Any: return OldAvito() -def _old_cian() -> Any: - with patch("app.services.scrapers.cian.get_scraper_delay", return_value=5.0): - return OldCian() +# ── avito: probe → split → leaf ─────────────────────────────────────────────── - -def _old_yandex() -> Any: - with patch("app.services.scrapers.yandex_realty.get_scraper_delay", return_value=5.0): - return OldYandex() - - -def _old_domclick() -> Any: - with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0): - return OldDomClick() - - -# ── avito / cian: одинаковый скелет (probe → split → leaf) ──────────────────── - - -# Провайдер-специфичные kwargs для _walk_price_range (avito идентифицирует бакет -# room_slug/room_label; cian — кортежем rooms). _AVITO_WALK_EXTRA = {"room_slug": "room1", "room_label": "1к"} -_CIAN_WALK_EXTRA = {"rooms": (1,)} @pytest.mark.asyncio -@pytest.mark.parametrize( - "old_factory, new_factory, patch_fn, walk_extra", - [ - (_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA), - (_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA), - ], -) -async def test_bisection_parity_avito_cian( - old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any] -) -> None: - """avito/cian: split-дерево + leaf + ранний возврат на нуле — обход идентичен. +async def test_bisection_parity_avito() -> None: + """avito: split-дерево + leaf + ранний возврат на нуле — обход идентичен. Оракул: divisor=1000, cap=1400 → брекет 8М делится 3 уровня (8М→4М→2М→1М-leaf); один под-брекет форсим в 0 (ранний возврат без leaf). @@ -237,7 +129,7 @@ async def test_bisection_parity_avito_cian( async def run(scraper: Any) -> list[Any]: events: list[Any] = [] - patch_fn(scraper, oracle, events) + _patch_avito(scraper, oracle, events) for lo, hi in brackets: await scraper._walk_price_range( lo=lo, @@ -249,12 +141,12 @@ async def test_bisection_parity_avito_cian( secondary_only=True, on_bucket=None, skip_buckets=None, - **walk_extra, + **_AVITO_WALK_EXTRA, ) return events - old_events = await run(old_factory()) - new_events = await run(new_factory()) + old_events = await run(_old_avito()) + new_events = await run(NewAvito(config=_KIT_CONFIG)) assert old_events == new_events # Санити: обход реально нетривиален (несколько probe + несколько leaf). assert sum(1 for e in old_events if e[0] == "leaf") >= 4 @@ -300,140 +192,18 @@ async def test_bisection_parity_avito_probe_fail() -> None: assert ("leaf", 25_000_000, None, 100) in old_events -# ── domclick: count-endpoint + None-both-ends + split открытого корня ───────── - - -@pytest.mark.asyncio -async def test_bisection_parity_domclick() -> None: - """domclick: unbounded-корень (None,None) клампится к [0,1e9] и делится; - count-endpoint; span<=MIN_PRICE_SPAN строго → leaf. Обход идентичен старому.""" - oracle = make_oracle(divisor=100_000, open_ceiling=1_000_000_000) - - async def run(scraper: Any) -> list[Any]: - events: list[Any] = [] - _patch_domclick(scraper, oracle, events) - await scraper._sweep_bucket( - fetcher=None, - rooms="1", - price_gte=None, - price_lte=None, - seen_ids=set(), - out_lots=[], - pages=100, - ) - return events - - old_events = await run(_old_domclick()) - new_events = await run(NewDomClick(_make_domclick_config())) - assert old_events == new_events - assert sum(1 for e in old_events if e[0] == "leaf") >= 4 - # Корень (None,None) обязательно пробится первым. - assert old_events[0] == ("probe", None, None) - - -def _make_domclick_config() -> Any: - return SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") - - -# ── yandex: max_depth=8, min_bracket=500k, count-based on_bucket ────────────── - - -@pytest.mark.asyncio -async def test_bisection_parity_yandex_normal() -> None: - """yandex: split-дерево (порог 500k, cap 500) + leaf-on-bucket + zero-as-leaf. - - Оракул divisor=1000, cap=500 → 4М→2М→1М→500k-leaf. Один под-брекет=0 - (yandex вызывает on_bucket даже на нуле — treat_zero_as_leaf). - """ - oracle = make_oracle( - divisor=1000, - open_ceiling=30_000_000, - zeros=frozenset({(2_000_000, 2_999_999)}), - ) - brackets: list[tuple[int, int | None]] = [(0, 3_999_999), (25_000_000, None)] - - async def run(scraper: Any) -> list[Any]: - events: list[Any] = [] - _patch_yandex(scraper, oracle, events) - for lo, hi in brackets: - await scraper._walk_price_range( - rooms="1", - lo=lo, - hi=hi, - seen={}, - price_cap_per_bucket=500, - max_pages_per_bucket=50, - concurrency=4, - on_bucket=_yandex_on_bucket(events), - skip_buckets=None, - ) - return events - - old_events = await run(_old_yandex()) - new_events = await run(NewYandex(config=_KIT_CONFIG)) - assert old_events == new_events - assert sum(1 for e in old_events if e[0] == "bucket") >= 4 - assert sum(1 for e in old_events if e[0] == "probe") >= 6 - - -@pytest.mark.asyncio -async def test_bisection_parity_yandex_degraded() -> None: - """yandex probe-fail (totalItems неизвестен) → degraded paginate-until-empty. - - Оракул форсит None на бакете → probe page1 payload без totalItems → total=None - → degraded последовательно пагинирует. record_pages=True фиксирует все страницы. - """ - oracle = make_oracle( - divisor=1000, - open_ceiling=30_000_000, - nones=frozenset({(None, 3_999_999)}), - ) - - async def run(scraper: Any) -> list[Any]: - events: list[Any] = [] - _patch_yandex(scraper, oracle, events, record_pages=True) - await scraper._walk_price_range( - rooms="1", - lo=0, - hi=3_999_999, - seen={}, - price_cap_per_bucket=500, - max_pages_per_bucket=50, - concurrency=4, - on_bucket=_yandex_on_bucket(events), - skip_buckets=None, - ) - return events - - old_events = await run(_old_yandex()) - new_events = await run(NewYandex(config=_KIT_CONFIG)) - assert old_events == new_events - # degraded: probe page1 + затем последовательная пагинация страниц 1..K до пустой. - assert ("probe", None, 3_999_999) in old_events - assert any(e[0] == "bucket" for e in old_events) - - # ── Full seed-grid обход (все seed-брекеты одной комнатности) ───────────────── @pytest.mark.asyncio -@pytest.mark.parametrize( - "old_factory, new_factory, patch_fn, walk_extra", - [ - (_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA), - (_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA), - ], -) -async def test_bisection_parity_full_seed_grid( - old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any] -) -> None: +async def test_bisection_parity_full_seed_grid() -> None: """Полный обход всех seed-брекетов (как в fetch_all_secondary) — включая - открытый catch-all — идентичен между старым и новым для avito/cian.""" + открытый catch-all — идентичен между старым и новым для avito.""" oracle = make_oracle(divisor=1500, open_ceiling=300_000_000) async def run(scraper: Any) -> list[Any]: events: list[Any] = [] - patch_fn(scraper, oracle, events) + _patch_avito(scraper, oracle, events) for br_lo, br_hi in get_price_seed_brackets(): walk_hi = br_hi - 1 if br_hi is not None else None await scraper._walk_price_range( @@ -446,10 +216,10 @@ async def test_bisection_parity_full_seed_grid( secondary_only=True, on_bucket=None, skip_buckets=None, - **walk_extra, + **_AVITO_WALK_EXTRA, ) return events - old_events = await run(old_factory()) - new_events = await run(new_factory()) + old_events = await run(_old_avito()) + new_events = await run(NewAvito(config=_KIT_CONFIG)) assert old_events == new_events diff --git a/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py index 9c36f8f5..15378f5c 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py @@ -1,20 +1,22 @@ -"""Golden-parity: `scraper_kit.providers.cian.*` парсинг ≡ `app.services.scrapers.cian*`. +"""Golden-parity: `scraper_kit.providers.cian.*` парсинг ≡ ещё живым `app.services.scrapers.cian*`. Strangler-инвариант (#2133 cian): новая scraper_kit-копия cian-провайдера должна давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. Развязка (settings→ScraperConfig, get_scraper_delay→delay_provider, cian_session→локальная копия) НЕ меняет распарсенные данные. -Гоняем ОБА модуля на одних фикстурах и сравниваем результат: - - SERP `_parse_serp_html` (Redux state → list[ScrapedLot]) на синтетическом _cianConfig - push-HTML (вторичка + новостройка + студия), сравнение model_dump(); - - SERP `_offer_to_lot` пооферно + `_format_address` (pure, восстановление дома ЖК); - - `_extract_total_offers` (totalOffers из state); - - `extract_state` — app-версия ≡ kit-версия на одном HTML (общий парсер state); - - detail `fetch_detail` (defaultState + bti sister → DetailEnrichment, dataclasses.asdict); - - valuation `_parse_valuation_state` (estimation/chart/houseInfo → CianValuationResult); - - newbuilding pure-экстракторы (_extract_chart / _extract_reliability_checks / - _extract_nested_offers / _extract_zhk_url_from_serp). +`app.services.scrapers.cian` / `cian_detail` удалены (#2397 Part E2, 0 runtime- +импортёров — admin.py/cian_history_backfill.py уже на kit). Parity-тесты SERP +`_parse_serp_html`/`_offer_to_lot`/`_extract_total_offers`/`_format_address` и +detail `fetch_detail` удалены вместе с ними (легаси-эталон исчез). + +Оставшееся здесь сравнивает kit ≡ ЕЩЁ ЖИВЫЕ легаси-модули: + - `extract_state` — `app.services.scrapers.cian_state_parser` (живой, общий парсер state); + - valuation `_parse_valuation_state` / `estimate_via_cian_valuation` — + `app.services.scrapers.cian_valuation` (живой); + - newbuilding pure-экстракторы — `app.services.scrapers.cian_newbuilding` (живой); + - kit mandatory-config guards (`estimate_via_cian_valuation`, `cian.session`) — kit-only; + - strangler-guard: kit-провайдер не импортирует `app.*`. Идентичность результата = kit-парсер верно скопирован (развязка не задела логику). """ @@ -27,17 +29,17 @@ import json import os from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import pytest -# Старый app.services.scrapers.cian* импортирует app.core.config.settings=Settings(), -# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен. +# Старый app.services.scrapers.cian_state_parser/cian_valuation/cian_newbuilding +# импортируют app.core.config.settings=Settings(), которому нужен DATABASE_URL. +# Офлайн-парсинг БД не трогает — фиктивный DSN достаточен. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") # НОВЫЙ (scraper_kit) cian-провайдер. from scraper_kit.cian_state_parser import extract_state as new_extract_state -from scraper_kit.providers.cian.detail import fetch_detail as new_fetch_detail from scraper_kit.providers.cian.newbuilding import _extract_chart as new_extract_chart from scraper_kit.providers.cian.newbuilding import ( _extract_nested_offers as new_extract_nested_offers, @@ -48,8 +50,6 @@ from scraper_kit.providers.cian.newbuilding import ( from scraper_kit.providers.cian.newbuilding import ( _extract_zhk_url_from_serp as new_extract_zhk_url, ) -from scraper_kit.providers.cian.serp import CianScraper as NewCianScraper -from scraper_kit.providers.cian.serp import _format_address as new_format_address from scraper_kit.providers.cian.valuation import ( _parse_valuation_state as new_parse_valuation, ) @@ -62,10 +62,7 @@ from scraper_kit.providers.cian.valuation import ( # #2306's cian_price_history.py (см. app/services/scraper_adapters.py). from app.services.scraper_adapters import RealScraperConfig -# СТАРЫЙ (app.services.scrapers) cian-провайдер. -from app.services.scrapers.cian import CianScraper as OldCianScraper -from app.services.scrapers.cian import _format_address as old_format_address -from app.services.scrapers.cian_detail import fetch_detail as old_fetch_detail +# СТАРЫЙ (app.services.scrapers) — ещё живые cian-модули (cian.py/cian_detail.py удалены). from app.services.scrapers.cian_newbuilding import _extract_chart as old_extract_chart from app.services.scrapers.cian_newbuilding import ( _extract_nested_offers as old_extract_nested_offers, @@ -85,14 +82,11 @@ from app.services.scrapers.cian_valuation import ( ) from tests.support.parity import assert_parity -# ── Fixtures: SERP offers → _cianConfig push HTML ──────────────────────────── - -_CONFIG = SimpleNamespace(glitchtip_dsn=None) +# ── extract_state parity (state-парсер, живой в cian_state_parser.py) ──────── -def _serp_html(offers: list[dict[str, Any]]) -> str: - """Обернуть offers в валидный _cianConfig['frontend-serp'].push() HTML.""" - state = {"results": {"totalOffers": len(offers), "offers": offers}} +def _serp_html_for_state(state: dict[str, Any]) -> str: + """Обернуть произвольный state в валидный _cianConfig['frontend-serp'].push() HTML.""" state_json = json.dumps(state, ensure_ascii=False) return ( "" - ) - - -def _mock_session(html: str) -> MagicMock: - response = MagicMock() - response.status_code = 200 - response.text = html - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - return session - - -@pytest.mark.asyncio -async def test_detail_fetch_parity() -> None: - """fetch_detail (defaultState + bti) → DetailEnrichment идентичен (все поля).""" - html = _detail_html() - url = "https://ekb.cian.ru/sale/flat/327830237/" - - old_res = await old_fetch_detail(url, session=_mock_session(html)) - new_res = await new_fetch_detail(url, session=_mock_session(html)) - - assert old_res is not None - assert new_res is not None - assert dataclasses.asdict(old_res) == dataclasses.asdict(new_res) - - -# ── valuation _parse_valuation_state parity ────────────────────────────────── +# ── valuation _parse_valuation_state parity (живой cian_valuation.py) ──────── def _valuation_state() -> dict[str, Any]: @@ -595,7 +312,7 @@ def test_kit_cian_session_config_requirement_asymmetry() -> None: db2.execute.assert_called_once() -# ── newbuilding pure-экстракторы parity ────────────────────────────────────── +# ── newbuilding pure-экстракторы parity (живой cian_newbuilding.py) ────────── def test_newbuilding_extractors_parity() -> None: diff --git a/tradein-mvp/backend/tests/test_scraper_kit_domclick_golden_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_domclick_golden_parity.py deleted file mode 100644 index 312fc866..00000000 --- a/tradein-mvp/backend/tests/test_scraper_kit_domclick_golden_parity.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Golden-parity: `scraper_kit.providers.domclick.serp` ≡ `app.services.scrapers.domclick`. - -Strangler-инвариант (#2133): новая scraper_kit-копия domclick-провайдера должна -давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. -Развязка (get_scraper_delay→delay_provider, base/exceptions/repair-normalizer→ -scraper_kit.*) НЕ меняет распарсенные данные. - -Гоняем ОБА модуля на одной BFF-JSON-фикстуре и сравниваем: - - `_extract_json` (JSON из HTML-обёртки + QRATOR-block detection); - - `_map_item` (offer → ScrapedLot): студия rooms=0, sentinel 0→None, agency; - - `_is_geo_ok` (bbox + offerRegionName guard); - - url-билдеры и чистые хелперы (_build_offers_url/_build_count_url/ - _parse_publish_date/_extract_agency_name). - -Идентичность результата = kit-парсер верно скопирован (развязка не задела логику). -""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import patch - -# Старый app.services.scrapers.domclick тянет app.core.config.settings=Settings(), -# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен. -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -# НОВЫЙ (scraper_kit) — ruff сортирует scraper_kit (first-party) выше app-импортов. -from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClickScraper -from scraper_kit.providers.domclick.serp import _build_count_url as new_build_count_url -from scraper_kit.providers.domclick.serp import _build_offers_url as new_build_offers_url -from scraper_kit.providers.domclick.serp import _extract_agency_name as new_extract_agency -from scraper_kit.providers.domclick.serp import _extract_json as new_extract_json -from scraper_kit.providers.domclick.serp import _parse_publish_date as new_parse_publish_date - -from app.services.scrapers.domclick import DomClickScraper as OldDomClickScraper -from app.services.scrapers.domclick import _build_count_url as old_build_count_url -from app.services.scrapers.domclick import _build_offers_url as old_build_offers_url -from app.services.scrapers.domclick import _extract_agency_name as old_extract_agency -from app.services.scrapers.domclick import _extract_json as old_extract_json -from app.services.scrapers.domclick import _parse_publish_date as old_parse_publish_date - -_FIXTURES = Path(__file__).parent / "fixtures" -_BFF_JSON = (_FIXTURES / "domclick_bff_offers_sample.json").read_text(encoding="utf-8") -_BFF_ITEMS: list[dict[str, Any]] = json.loads(_BFF_JSON)["result"]["items"] - - -def _make_old() -> OldDomClickScraper: - """Старый DomClickScraper без DB: patch get_scraper_delay.""" - with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0): - return OldDomClickScraper() - - -def _make_new() -> NewDomClickScraper: - """Новый DomClickScraper с инжектируемым ScraperConfig + delay_provider. - - Парсинг-тесты не дёргают fetch_city → browser_http_endpoint не используется, - но конструктор требует config (duck-typed namespace достаточен). - """ - config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") - return NewDomClickScraper(config, delay_provider=lambda _name: 8.0) - - -# ── _extract_json parity ────────────────────────────────────────────────────── - - -def test_extract_json_parity_bare() -> None: - """_extract_json на bare JSON-теле идентичен.""" - assert old_extract_json(_BFF_JSON) == new_extract_json(_BFF_JSON) - - -def test_extract_json_parity_pre_wrapped() -> None: - """_extract_json на
-обёрнутом JSON идентичен (реальная форма ответа)."""
-    wrapped = f"
{_BFF_JSON}
" - assert old_extract_json(wrapped) == new_extract_json(wrapped) - - -def test_extract_json_parity_qrator_block() -> None: - """QRATOR block-страница → DomClickBlockedError в обоих (идентичное поведение).""" - from scraper_kit.domclick_exceptions import DomClickBlockedError as NewBlocked - - from app.services.scrapers.domclick_exceptions import DomClickBlockedError as OldBlocked - - block_html = "Система защиты QRATOR: captcha" - old_raised = new_raised = False - try: - old_extract_json(block_html) - except OldBlocked: - old_raised = True - try: - new_extract_json(block_html) - except NewBlocked: - new_raised = True - assert old_raised and new_raised - - -# ── _map_item parity (offer → ScrapedLot) ───────────────────────────────────── - - -def test_map_item_parity_all_items() -> None: - """Каждый offer-item → ScrapedLot идентичен (студия rooms=0, sentinel 0→None).""" - old = _make_old() - new = _make_new() - for item in _BFF_ITEMS: - # force_rooms=0 для студий (st-бакет) — проверяем обе ветки маппинга. - for force_rooms in (None, 0): - old_lot = old._map_item(item, force_rooms=force_rooms) - new_lot = new._map_item(item, force_rooms=force_rooms) - if old_lot is None or new_lot is None: - assert old_lot is None and new_lot is None - continue - assert old_lot.model_dump() == new_lot.model_dump() - # Счётчик parse-ошибок эволюционирует одинаково. - assert old.parse_failures == new.parse_failures - - -# ── _is_geo_ok parity (bbox + region guard) ─────────────────────────────────── - - -def test_is_geo_ok_parity_all_items() -> None: - """Гео-гард (offerRegionName + bbox) идентичен на всех items.""" - old = _make_old() - new = _make_new() - for item in _BFF_ITEMS: - assert old._is_geo_ok(item) == new._is_geo_ok(item) - - -# ── url-билдеры + чистые хелперы ────────────────────────────────────────────── - - -def test_url_builders_parity() -> None: - """_build_offers_url / _build_count_url идентичны (вкл. '5+' → '5%2B').""" - cases: list[tuple[str, int | None, int | None]] = [ - ("st", None, None), - ("2", 3_000_000, 8_000_000), - ("5+", 15_000_000, None), - ("1", None, 5_000_000), - ] - for rooms, gte, lte in cases: - assert old_build_count_url(rooms, gte, lte) == new_build_count_url(rooms, gte, lte) - for offset in (0, 20, 1980): - assert old_build_offers_url(rooms, gte, lte, offset) == new_build_offers_url( - rooms, gte, lte, offset - ) - - -def test_parse_publish_date_parity() -> None: - """_parse_publish_date идентичен на валидных/битых ISO-строках.""" - for iso in [None, "", "2026-06-28T09:15:00+05:00", "2026-06-27", "not-a-date", "2026-13-40"]: - assert old_parse_publish_date(iso) == new_parse_publish_date(iso) - - -def test_extract_agency_name_parity() -> None: - """_extract_agency_name идентичен на строковых/dict/пустых seller-блоках.""" - sellers: list[dict[str, Any]] = [ - {}, - {"company": "АН Этажи", "agent": "Иванов"}, - {"company": " ", "agent": {"name": "Резерв"}}, - {"agent": {"fullName": "Петров П."}}, - {"company": {"title": "СберРиелт"}}, - {"agent": 12345}, - ] - for seller in sellers: - assert old_extract_agency(seller) == new_extract_agency(seller) - - -# ── strangler-guard: kit-провайдер не импортирует app.* ─────────────────────── - - -def test_kit_domclick_has_no_app_imports() -> None: - """Ни один модуль scraper_kit.providers.domclick.* не импортирует app.* (развязка).""" - import inspect - - from scraper_kit.providers.domclick import serp - - source = inspect.getsource(serp) - for line in source.splitlines(): - stripped = line.strip() - assert not stripped.startswith("from app"), f"serp: {line!r}" - assert not stripped.startswith("import app"), f"serp: {line!r}" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py index 26edff4b..6fd9acf0 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py @@ -32,16 +32,21 @@ Migrates legacy scraper imports of these files to their `scraper_kit` equivalent This file proves the DELTA introduced by the #2310 migration specifically — general avito/cian-provider parsing golden-parity is already covered by -`tests/test_scraper_kit_avito_golden_parity.py` and +`tests/test_scraper_kit_avito_golden_parity.py` and (while it existed) `tests/test_scraper_kit_cian_golden_parity.py`. Uses `tests/support/parity.assert_parity` per the epic's parity-gate convention (issue #2304). + +`test_cian_fetch_detail_via_browser_fetcher_parity` (legacy `app.services.scrapers. +cian_detail.fetch_detail` vs kit, browser_fetcher call shape) removed (#2397 Part E2) +— `app.services.scrapers.cian_detail` deleted, 0 runtime importers (cian_history_backfill.py +already calls the kit `fetch_detail` exclusively, proven by +`test_cian_history_backfill_browser_fetcher_uses_settings_endpoint` below). """ from __future__ import annotations import asyncio import dataclasses -import json import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -53,12 +58,10 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ from scraper_kit.avito_exceptions import AvitoBlockedError as KitAvitoBlockedError from scraper_kit.providers.avito.detail import fetch_detail as kit_avito_fetch_detail from scraper_kit.providers.avito.serp import AvitoScraper as KitAvitoScraper -from scraper_kit.providers.cian.detail import fetch_detail as kit_cian_fetch_detail from app.core.config import settings from app.services.scraper_adapters import RealScraperConfig from app.services.scrapers.avito_detail import fetch_detail as legacy_avito_fetch_detail -from app.services.scrapers.cian_detail import fetch_detail as legacy_cian_fetch_detail from tests.support.parity import assert_parity # ── avito fetch_detail: backconnect config-gate (avito_detail_backfill.py delta) ── @@ -144,30 +147,7 @@ def test_avito_scraper_rotate_ip_reads_injected_config() -> None: assert scraper._config.avito_proxy_rotate_url == settings.avito_proxy_rotate_url -# ── cian fetch_detail via browser_fetcher (cian_history_backfill.py call shape) ── - - -def _cian_detail_html() -> str: - default_state = { - "offerData": { - "offer": { - "cianId": 777, - "priceChanges": [ - {"changeTime": "2026-06-01T00:00:00Z", "price": 5900000, "diffPercent": -0.5} - ], - }, - "stats": {}, - } - } - ds_json = json.dumps(default_state, ensure_ascii=False) - return ( - "" - ) +# ── avito fetch_detail via browser_fetcher (avito_detail_backfill.py browser_mode) ── def _fake_browser_fetcher(html: str) -> Any: @@ -176,30 +156,6 @@ def _fake_browser_fetcher(html: str) -> Any: return bf -def test_cian_fetch_detail_via_browser_fetcher_parity() -> None: - """cian_history_backfill.py's listings block ALWAYS calls fetch_detail(url, - browser_fetcher=bf) — never the curl_cffi/config own-session path exercised by - tests/test_scraper_kit_pricehistory_session_parity.py (cian_price_history.py's - call shape). Proves parity holds for THIS call shape too.""" - html = _cian_detail_html() - url = "https://ekb.cian.ru/sale/flat/777/" - - def _run_legacy() -> Any: - return dataclasses.asdict( - asyncio.run(legacy_cian_fetch_detail(url, browser_fetcher=_fake_browser_fetcher(html))) - ) - - def _run_kit() -> Any: - return dataclasses.asdict( - asyncio.run(kit_cian_fetch_detail(url, browser_fetcher=_fake_browser_fetcher(html))) - ) - - assert_parity(legacy_fn=_run_legacy, kit_fn=_run_kit, fixtures=[()]) - - -# ── avito fetch_detail via browser_fetcher (avito_detail_backfill.py browser_mode) ── - - def test_avito_fetch_detail_via_browser_fetcher_parity() -> None: """browser_mode branch of avito_detail_backfill.py calls fetch_detail(url, browser_fetcher=browser_fetcher) — config is never read on this path (early diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pricehistory_session_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pricehistory_session_parity.py index 8ce767e5..6daf481e 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pricehistory_session_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pricehistory_session_parity.py @@ -17,32 +17,31 @@ cian-provider / ScrapedLot golden-parity is already covered by `tests/test_scraper_kit_cian_golden_parity.py` and `tests/test_scraper_kit_base_golden_parity.py`. Uses `tests/support/parity.assert_parity` per the epic's parity-gate convention (issue #2304). + +`test_fetch_detail_with_config_injection_parity` / `test_fetch_detail_own_session_proxy_ +wiring_parity` (legacy `app.services.scrapers.cian_detail.fetch_detail` vs kit) removed +(#2397 Part E2) — `cian_detail` deleted, 0 runtime importers. `extract_state` +(`cian_state_parser`, still legacy-live) and `ScrapedLot` (`base`, still legacy-live) +parity below is unaffected. """ from __future__ import annotations -import asyncio -import dataclasses import json import os from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch -# Старый app.services.scrapers.cian* / app.core.config.settings требует DATABASE_URL -# при импорте (тот же паттерн, что в test_scraper_kit_cian_golden_parity.py). +# Старый app.services.scrapers.cian_state_parser/base / app.core.config.settings +# требует DATABASE_URL при импорте (тот же паттерн, что в +# test_scraper_kit_cian_golden_parity.py). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") # Kit — то, на что теперь ссылаются мигрированные файлы (#2306). from scraper_kit.base import ScrapedLot as KitScrapedLot from scraper_kit.cian_state_parser import extract_state as kit_extract_state -from scraper_kit.providers.cian.detail import fetch_detail as kit_fetch_detail -from app.core.config import settings -from app.services.scraper_adapters import RealScraperConfig - -# Legacy — эталон сравнения (эти модули НЕ меняются в #2306). +# Legacy — эталон сравнения (эти модули НЕ меняются в #2306, остаются live). from app.services.scrapers.base import ScrapedLot as LegacyScrapedLot -from app.services.scrapers.cian_detail import fetch_detail as legacy_fetch_detail from app.services.scrapers.cian_state_parser import extract_state as legacy_extract_state from tests.support.parity import assert_parity @@ -108,113 +107,3 @@ def test_scraped_lot_model_dump_parity() -> None: kit_fn=lambda kw: KitScrapedLot(**kw).model_dump(), fixtures=[kwargs], ) - - -# ── fetch_detail / DetailEnrichment parity (cian_price_history.py dependency) ── - - -def _detail_html() -> str: - default_state = { - "offerData": { - "offer": { - "cianId": 555111, - "priceChanges": [ - {"changeTime": "2026-05-01T00:00:00Z", "price": 6100000, "diffPercent": -1.2} - ], - }, - "stats": {}, - } - } - ds_json = json.dumps(default_state, ensure_ascii=False) - return ( - "" - ) - - -def _mock_session(html: str) -> MagicMock: - response = MagicMock() - response.status_code = 200 - response.text = html - session = MagicMock() - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - return session - - -def _run_legacy_fetch(url: str, html: str) -> Any: - return dataclasses.asdict(asyncio.run(legacy_fetch_detail(url, session=_mock_session(html)))) - - -def _run_kit_fetch(url: str, html: str) -> Any: - # Точно тот вызов, что теперь делает cian_price_history.backfill_cian_price_history: - # config=RealScraperConfig() добавлен по миграции (#2306); session= общий mock здесь - # только чтобы избежать сетевого I/O — сам config не участвует в session-пути, так - # что этот тест доказывает: инжекция config= не меняет результат парсинга. - result = asyncio.run( - kit_fetch_detail(url, config=RealScraperConfig(), session=_mock_session(html)) - ) - return dataclasses.asdict(result) - - -def test_fetch_detail_with_config_injection_parity() -> None: - html = _detail_html() - url = "https://ekb.cian.ru/sale/flat/555111/" - assert_parity( - legacy_fn=_run_legacy_fetch, - kit_fn=_run_kit_fetch, - fixtures=[(url, html)], - ) - - -def test_fetch_detail_own_session_proxy_wiring_parity(monkeypatch: Any) -> None: - """cian_price_history calls fetch_detail WITHOUT session= (own-session curl_cffi path). - - Kit fetch_detail without config= silently drops the proxy (direct connection) — a - real behaviour regression risk (#806: datacenter-IP gets banned by Cian without a - proxy). This proves config=RealScraperConfig() (our actual call site) constructs - AsyncSession(proxies=...) identically to the legacy direct-settings path. - - #2362 (F4b): kit's own-session branch now builds the session via - `scraper_kit.providers._base.build_curl_cffi_session()` instead of constructing - `AsyncSession` directly in `detail.py` — so the spy patches `AsyncSession` where - the constructor call now actually happens (`_base`), not in `detail` anymore. - The asserted kwargs (proxies=...) are unchanged. - """ - monkeypatch.setattr(settings, "cian_proxy_url_env", "http://test-proxy.local:8080") - - captured: dict[str, dict[str, Any]] = {} - - def _make_fake_session(name: str) -> Any: - class _FakeSession: - def __init__(self, **kwargs: Any) -> None: - captured[name] = kwargs - - async def get(self, *_args: Any, **_kwargs: Any) -> Any: - response = MagicMock() - response.status_code = 200 - response.text = _detail_html() - return response - - async def close(self) -> None: - pass - - return _FakeSession - - url = "https://ekb.cian.ru/sale/flat/1/" - - with patch("app.services.scrapers.cian_detail.AsyncSession", _make_fake_session("legacy")): - asyncio.run(legacy_fetch_detail(url)) - - with patch("scraper_kit.providers._base.AsyncSession", _make_fake_session("kit")): - asyncio.run(kit_fetch_detail(url, config=RealScraperConfig())) - - assert captured["legacy"].get("proxies") == captured["kit"].get("proxies") - assert captured["kit"]["proxies"] == { - "http": "http://test-proxy.local:8080", - "https": "http://test-proxy.local:8080", - } diff --git a/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py index 85bfb7b2..eec7fd3f 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py @@ -1,4 +1,5 @@ -"""Golden-parity: `scraper_kit.providers.yandex.*` парсинг ≡ `app.services.scrapers.yandex_*`. +"""Golden-parity: `scraper_kit.providers.yandex.*` парсинг ≡ ещё живым +`app.services.scrapers.yandex_*`. Strangler-инвариант (#2133): новая scraper_kit-копия yandex-провайдера должна давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. @@ -6,34 +7,34 @@ Strangler-инвариант (#2133): новая scraper_kit-копия yandex- house_type_normalizer→providers.yandex.shared, base/browser_fetcher/helpers→scraper_kit.*) НЕ меняет распарсенные данные. -Гоняем ОБА модуля на одних фикстурах и сравниваем результат: - - SERP `_entity_to_lot` (gate-API entity → ScrapedLot) на репрезентативных entity; - - SERP `_parse_gate_json` (payload → list[ScrapedLot]) с новостройкой/вторичкой; - - `normalize_house_type` (SCREAMING/camelCase → канон); - - newbuilding `parse` (ЖК-лендинг → YandexNewbuildingInfo) на синтетике; - - `_build_url` (gate-API URL builder) — детерминированность. +`app.services.scrapers.yandex_realty` удалён (#2397 Part E2, 0 runtime-импортёров). +SERP `_entity_to_lot`/`_parse_gate_json`/`_build_url` parity-тесты (сравнение с +`YandexRealtyScraper`) удалены вместе с ним (легаси-эталон исчез). -Идентичность результата = kit-парсер верно скопирован (развязка не задела логику). +Оставшееся сравнивает kit ≡ ЕЩЁ ЖИВЫЕ легаси-модули: + - `normalize_house_type` — `app.services.scrapers.house_type_normalizer` (живой); + - newbuilding `parse` (ЖК-лендинг → YandexNewbuildingInfo) — + `app.services.scrapers.yandex_newbuilding` (живой); + - strangler-guard: kit-провайдер не импортирует `app.*`. Легаси `app.services.scrapers.yandex_detail` / `yandex_valuation` удалены (#2277 финальный шаг scraper_kit-миграции, 0 runtime importers) — detail/valuation -parity-проверки убраны отсюда вместе с ними. Kit-side coverage для detail/valuation +parity-проверки убраны отсюда ранее. Kit-side coverage для detail/valuation `.parse()` сохранена без изменений в `tests/test_yandex_detail.py` / `tests/test_yandex_detail_structural.py` / `tests/test_yandex_valuation.py` / `tests/test_yandex_valuation_save.py` (переведены на kit-эквиваленты тем же PR). -`yandex_realty` / `yandex_newbuilding` / `house_type_normalizer` остаются легаси -(не мигрированы) — их parity-проверки не тронуты. + +Идентичность результата = kit-парсер верно скопирован (развязка не задела логику). """ from __future__ import annotations import inspect import os -from types import SimpleNamespace -from typing import Any -# Старый app.services.scrapers.yandex_* импортирует app.core.config.settings=Settings(), -# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен. +# Старый app.services.scrapers.house_type_normalizer/yandex_newbuilding импортирует +# app.core.config.settings=Settings(), которому нужен DATABASE_URL. Офлайн-парсинг +# БД не трогает — фиктивный DSN достаточен. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") # НОВЫЙ (scraper_kit) и СТАРЫЙ (app.services.scrapers) yandex-парсеры — оба в одном @@ -41,9 +42,6 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ from scraper_kit.providers.yandex.newbuilding import ( YandexNewbuildingScraper as NewNewbuildingScraper, ) -from scraper_kit.providers.yandex.serp import YandexRealtyScraper as NewRealtyScraper -from scraper_kit.providers.yandex.serp import _entity_to_lot as new_entity_to_lot -from scraper_kit.providers.yandex.serp import _parse_gate_json as new_parse_gate_json from scraper_kit.providers.yandex.shared import normalize_house_type as new_normalize_house_type from app.services.scrapers.house_type_normalizer import ( @@ -52,192 +50,6 @@ from app.services.scrapers.house_type_normalizer import ( from app.services.scrapers.yandex_newbuilding import ( YandexNewbuildingScraper as OldNewbuildingScraper, ) -from app.services.scrapers.yandex_realty import YandexRealtyScraper as OldRealtyScraper -from app.services.scrapers.yandex_realty import _entity_to_lot as old_entity_to_lot -from app.services.scrapers.yandex_realty import _parse_gate_json as old_parse_gate_json - -# Инжектируемый конфиг для kit-скрапперов (duck-typed namespace, structurally -# удовлетворяет scraper_kit.contracts.ScraperConfig в объёме читаемых полей). -_KIT_CONFIG = SimpleNamespace( - scraper_proxy_url=None, - yandex_proxy_rotate_url=None, - avito_proxy_rotate_url=None, -) - - -# ── Fixtures: gate-API entities (mirror test_yandex_realty_serp) ───────────── - -_ENTITY_FULL: dict = { - "offerId": 4740475460451078271, - "url": "//realty.yandex.ru/offer/4740475460451078271", - "price": {"currency": "RUR", "value": 4500000, "valuePerPart": 119048}, - "area": {"value": 37.8, "unit": "SQUARE_METER"}, - "livingSpace": {"value": 20, "unit": "SQUARE_METER"}, - "kitchenSpace": {"value": 12.7, "unit": "SQUARE_METER"}, - "roomsTotal": 1, - "floorsOffered": [8], - "floorsTotal": 25, - "ceilingHeight": 2.7, - "building": { - "builtYear": 2016, - "buildingType": "MONOLIT", - "siteId": 280938, - "siteName": "Peremen", - }, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Evgenia Savkova, 8", - "point": {"latitude": 56.79512, "longitude": 60.49186}, - }, - "mainImages": [ - "//avatars.mds.yandex.net/get-realty-offers/11904018/abc/main", - "//avatars.mds.yandex.net/get-realty-offers/14717586/def/main", - ], -} - -_ENTITY_STUDIO: dict = { - "offerId": 9999999, - "url": "//realty.yandex.ru/offer/9999999", - "price": {"value": 3200000, "valuePerPart": 114285}, - "area": {"value": 28.0}, - "livingSpace": None, - "kitchenSpace": None, - "roomsTotal": None, # studio -> rooms=0 - "floorsOffered": [2], - "floorsTotal": 9, - "ceilingHeight": None, - "building": {"builtYear": 2010, "buildingType": "PANEL", "siteId": None, "siteName": None}, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Lenina, 5", - "point": {"latitude": 56.838, "longitude": 60.597}, - }, - "mainImages": [], -} - -_ENTITY_RICH_OWNER: dict = { - "offerId": 5500000000000000001, - "url": "//realty.yandex.ru/offer/5500000000000000001", - "creationDate": "2026-05-24T15:10:27Z", - "description": "Продаётся светлая квартира с видом на парк.", - "author": {"category": "OWNER", "agentName": "Иван", "organization": None}, - "price": {"value": 7200000, "valuePerPart": 150000, "trend": "DECREASED", "previous": 7500000}, - "predictions": {"predictedPrice": {"value": "7554000", "min": "7100000", "max": "8000000"}}, - "area": {"value": 48.0}, - "roomsTotal": 2, - "floorsOffered": [5], - "floorsTotal": 12, - "building": {"builtYear": 2018, "buildingType": "MONOLIT"}, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Mira, 10", - "point": {"latitude": 56.84, "longitude": 60.61}, - "metroList": [ - {"name": "Ploshchad 1905 goda", "timeToMetro": 7}, - {"name": "Geologicheskaya", "timeToMetro": 12}, - ], - }, - "mainImages": [], -} - -_ENTITY_RICH_AGENCY: dict = { - "offerId": 5500000000000000002, - "url": "//realty.yandex.ru/offer/5500000000000000002", - "creationDate": "2026-06-01T09:00:00Z", - "description": "Агентская продажа.", - "author": {"category": "AGENCY", "agentName": "Петров", "organization": "Агентство «Диал»"}, - "price": {"value": 9900000, "trend": "UNCHANGED"}, - "area": {"value": 60.0}, - "roomsTotal": 3, - "floorsOffered": [3], - "floorsTotal": 9, - "building": {"builtYear": 2012, "buildingType": "BRICK"}, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Lenina, 1", - "point": {"latitude": 56.83, "longitude": 60.6}, - "metro": {"name": "Dinamo", "timeToMetro": 4}, - }, - "mainImages": [], -} - -# #2235: novostroyka offer whose gate-API `url` is a developer/redirect landing -# (shared by many flats). Both parsers must canonicalise it to offer_id-based url. -_ENTITY_PARTNER_URL: dict = { - "offerId": 7416316697684447350, - "url": "http://na100.pro/go.php?link=LtWgA6kWd3xjlrUBCLl6VGjo", - "price": {"value": 7790000, "valuePerPart": 130000}, - "area": {"value": 60.0}, - "roomsTotal": 2, - "floorsOffered": [5], - "floorsTotal": 16, - "building": {"builtYear": 2024, "buildingType": "MONOLIT", "siteId": 999001}, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Titova, 1", - "point": {"latitude": 56.8, "longitude": 60.6}, - }, - "mainImages": [], -} - -_ALL_ENTITIES = [ - _ENTITY_FULL, - _ENTITY_STUDIO, - _ENTITY_RICH_OWNER, - _ENTITY_RICH_AGENCY, - _ENTITY_PARTNER_URL, -] - - -def _make_gate_payload(entities: list, pager: dict | None = None) -> dict: - if pager is None: - pager = {"page": 0, "pageSize": 20, "totalItems": len(entities), "totalPages": 1} - return {"response": {"search": {"offers": {"entities": entities, "pager": pager}}}} - - -def _dump_lots(lots: list[Any]) -> list[dict[str, Any]]: - return [lot.model_dump() for lot in lots] - - -# ── SERP _entity_to_lot parity ─────────────────────────────────────────────── - - -def test_entity_to_lot_parity_all_entities() -> None: - """gate-API entity → ScrapedLot идентичен на всех репрезентативных entity.""" - for entity in _ALL_ENTITIES: - old_lot = old_entity_to_lot(entity) - new_lot = new_entity_to_lot(entity) - assert (old_lot is None) == (new_lot is None) - assert old_lot is not None and new_lot is not None - assert old_lot.model_dump() == new_lot.model_dump() - - -def test_entity_to_lot_parity_new_flat_segments() -> None: - """new_flat=YES/NO → listing_segment идентичен (новостройка/вторичка).""" - for new_flat in ("NO", "YES"): - old_lot = old_entity_to_lot(_ENTITY_FULL, new_flat=new_flat) - new_lot = new_entity_to_lot(_ENTITY_FULL, new_flat=new_flat) - assert old_lot is not None and new_lot is not None - assert old_lot.model_dump() == new_lot.model_dump() - - -def test_entity_to_lot_parity_ceiling_out_of_range() -> None: - """ceilingHeight мусор (18 / 1.6) → одинаково дропается в None (#2007).""" - for bad_ceiling in (18, 1.6): - entity = {**_ENTITY_FULL, "ceilingHeight": bad_ceiling} - old_lot = old_entity_to_lot(entity) - new_lot = new_entity_to_lot(entity) - assert old_lot is not None and new_lot is not None - assert old_lot.model_dump() == new_lot.model_dump() - - -# ── SERP _parse_gate_json parity ───────────────────────────────────────────── - - -def test_parse_gate_json_parity() -> None: - """payload → list[ScrapedLot] идентичен (вторичка и новостройка).""" - payload = _make_gate_payload(_ALL_ENTITIES) - for new_flat in ("NO", "YES"): - old_lots = old_parse_gate_json(payload, page_param=2, new_flat=new_flat) - new_lots = new_parse_gate_json(payload, page_param=2, new_flat=new_flat) - assert len(old_lots) == len(new_lots) > 0 - assert _dump_lots(old_lots) == _dump_lots(new_lots) - # ── normalize_house_type parity ────────────────────────────────────────────── @@ -296,23 +108,6 @@ def test_newbuilding_parse_parity() -> None: assert old_res.model_dump() == new_res.model_dump() -# ── _build_url parity ──────────────────────────────────────────────────────── - - -def test_build_url_parity() -> None: - """gate-API URL builder идентичен на репрезентативных комбо.""" - old_scraper = OldRealtyScraper() - new_scraper = NewRealtyScraper(config=_KIT_CONFIG) - combos = [ - dict(page=1), - dict(page=3, rooms="2", price_min=5_000_000, price_max=7_000_000), - dict(page=1, rooms="studio", new_flat="YES"), - dict(page=2, rooms="4+", price_min=25_000_000), - ] - for combo in combos: - assert old_scraper._build_url(**combo) == new_scraper._build_url(**combo) - - # ── strangler-guard: kit-провайдер не импортирует app.* ────────────────────── diff --git a/tradein-mvp/backend/tests/test_scraper_proxy.py b/tradein-mvp/backend/tests/test_scraper_proxy.py index 98bd116e..bb52143f 100644 --- a/tradein-mvp/backend/tests/test_scraper_proxy.py +++ b/tradein-mvp/backend/tests/test_scraper_proxy.py @@ -177,30 +177,13 @@ _EXPECTED_PROXIES = {"http": _PROXY_URL, "https": _PROXY_URL} # ── YandexRealtyScraper ────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_yandex_realty_opens_browser_fetcher(monkeypatch): - """YandexRealtyScraper.__aenter__ opens ONE BrowserFetcher (camoufox) session. - - Transport moved off the proxied curl_cffi session to BrowserFetcher (camoufox); - the mobile proxy is applied server-side by the tradein-browser service, not via a - proxies= kwarg here. - """ - from app.services.scrapers.yandex_realty import YandexRealtyScraper - - mock_browser = MagicMock() - mock_browser.__aenter__ = AsyncMock(return_value=mock_browser) - mock_browser.__aexit__ = AsyncMock(return_value=None) - - monkeypatch.setattr( - "app.services.scrapers.yandex_realty.BrowserFetcher", lambda *a, **k: mock_browser - ) - scraper = YandexRealtyScraper() - await scraper.__aenter__() - - mock_browser.__aenter__.assert_awaited_once() - assert scraper._browser is mock_browser +# +# `test_yandex_realty_opens_browser_fetcher` (legacy `app.services.scrapers. +# yandex_realty.YandexRealtyScraper.__aenter__` opens ONE BrowserFetcher session) +# removed (#2397 Part E2) — `yandex_realty` deleted, 0 runtime importers. Kit +# equivalent already covered (opens exactly one BrowserFetcher session via +# `build_browser_fetcher`) by `test_kit_serp_proxy_pool.py:: +# test_yandex_serp_no_pool_by_default` (and the pool-wiring variants alongside it). # ── YandexValuationScraper ─────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_snapshot_writer.py b/tradein-mvp/backend/tests/test_snapshot_writer.py index b259a78d..8c023c64 100644 --- a/tradein-mvp/backend/tests/test_snapshot_writer.py +++ b/tradein-mvp/backend/tests/test_snapshot_writer.py @@ -9,14 +9,23 @@ 5. Ошибка snapshot не прерывает основной INSERT в save_listings. Используем unittest.mock — без реальной БД. + +`app.services.scrapers.cian_detail` удалён (#2397 Part E2, 0 runtime-импортёров) — +`DetailEnrichment`/`save_detail_enrichment` ретаргетированы на +`scraper_kit.providers.cian.detail` (byte-identical strangler-копия; `snapshot_writer` +тестируется по-прежнему через живой `app.services.scrapers.snapshot_writer` — +kit и app-версии этого модуля идентичны, kit's `save_detail_enrichment` импортирует +`upsert_listing_snapshot` из `scraper_kit.snapshot_writer`). """ + from __future__ import annotations from contextlib import contextmanager from datetime import date from unittest.mock import MagicMock, patch -from app.services.scrapers.cian_detail import DetailEnrichment, save_detail_enrichment +from scraper_kit.providers.cian.detail import DetailEnrichment, save_detail_enrichment + from app.services.scrapers.snapshot_writer import upsert_listing_snapshot # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -220,7 +229,7 @@ def test_save_detail_enrichment_snapshot_failure_does_not_abort(): db = _mock_db_detail(price_rub=4_000_000) with patch( - "app.services.scrapers.cian_detail.upsert_listing_snapshot", + "scraper_kit.providers.cian.detail.upsert_listing_snapshot", side_effect=RuntimeError("db snapshot blip"), ): enrichment = DetailEnrichment(ceiling_height=2.5) @@ -261,9 +270,9 @@ def test_save_detail_enrichment_oph_on_conflict_constraint(): sqls = _get_all_sqls(db) oph_sqls = [s for s in sqls if "offer_price_history" in s] assert oph_sqls, "INSERT offer_price_history не найден" - assert "offer_price_history_listing_change_uq" in oph_sqls[0], ( - "ON CONFLICT должен ссылаться на UNIQUE constraint" - ) + assert ( + "offer_price_history_listing_change_uq" in oph_sqls[0] + ), "ON CONFLICT должен ссылаться на UNIQUE constraint" def test_save_detail_enrichment_skips_price_change_without_change_time(): @@ -271,7 +280,7 @@ def test_save_detail_enrichment_skips_price_change_without_change_time(): db = _mock_db_detail(price_rub=5_000_000) enrichment = DetailEnrichment( price_changes=[ - {"change_time": None, "price_rub": 5_000_000}, # пропустить + {"change_time": None, "price_rub": 5_000_000}, # пропустить {"change_time": "2026-04-01T00:00:00Z", "price_rub": None}, # пропустить {"change_time": "2026-04-01T00:00:00Z", "price_rub": 5_000_000}, # записать ] diff --git a/tradein-mvp/backend/tests/test_yandex_city_sweep.py b/tradein-mvp/backend/tests/test_yandex_city_sweep.py index df2fbccc..b50729a9 100644 --- a/tradein-mvp/backend/tests/test_yandex_city_sweep.py +++ b/tradein-mvp/backend/tests/test_yandex_city_sweep.py @@ -11,7 +11,9 @@ Part E1) — эквивалентная regression-coverage теперь в Coverage: - YandexCitySweepCounters корректно заполняются (dataclass shape) - - Combos watchdog timeout существенно больше ANCHOR_TIMEOUT_SEC (arithmetic-only) + - Combos watchdog timeout существенно больше ANCHOR_TIMEOUT_SEC (arithmetic-only; + `DEFAULT_PRICE_RANGES`/`ROOM_PATH` — kit `scraper_kit.providers.yandex.serp`, + ретаргетировано с удалённого `app.services.scrapers.yandex_realty`, #2397 Part E2) - Admin endpoints (start/cancel/list-runs) — kit sweep вызван с DI (config/matcher/enrichment) """ @@ -194,8 +196,7 @@ def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None _YANDEX_COMBOS_PER_FETCH_S, ANCHOR_TIMEOUT_SEC, ) - - from app.services.scrapers.yandex_realty import DEFAULT_PRICE_RANGES, ROOM_PATH + from scraper_kit.providers.yandex.serp import DEFAULT_PRICE_RANGES, ROOM_PATH # Параметры prod-режима rooms_list = list(ROOM_PATH.keys()) # 5 комнатностей diff --git a/tradein-mvp/backend/tests/test_yandex_realty_serp.py b/tradein-mvp/backend/tests/test_yandex_realty_serp.py deleted file mode 100644 index 1fdf0d40..00000000 --- a/tradein-mvp/backend/tests/test_yandex_realty_serp.py +++ /dev/null @@ -1,1363 +0,0 @@ -"""Unit tests for YandexRealtyScraper gate-API JSON parser. - -Replaced DOM/HTML SERP tests (2026-06-17): new transport uses -GET realty.yandex.ru/gate/react-page/get/ (JSON gate-API) instead of HTML SERP. -""" - -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from app.services.scrapers.base import ScrapedLot -from app.services.scrapers.yandex_realty import ( - _GATE_MAX_PAGES_CAP, - DEFAULT_CITY, - DEFAULT_PRICE_RANGES, - ROOM_PATH, - YandexRealtyScraper, - _canonical_source_url, - _combo_label, - _CurlResponse, - _entity_to_lot, - _extract_gate_data, - _extract_json_from_content, - _is_gate_error, - _parse_gate_json, -) - -# --------------------------------------------------------------------------- -# Minimal gate-API JSON fixtures (derived from real captured sample 2026-06-17) -# pager shape: {page, pageSize, totalItems, totalPages} -# --------------------------------------------------------------------------- - -_ENTITY_FULL: dict = { - "offerId": 4740475460451078271, - "url": "//realty.yandex.ru/offer/4740475460451078271", - "price": { - "currency": "RUR", - "value": 4500000, - "valuePerPart": 119048, - }, - "area": {"value": 37.8, "unit": "SQUARE_METER"}, - "livingSpace": {"value": 20, "unit": "SQUARE_METER"}, - "kitchenSpace": {"value": 12.7, "unit": "SQUARE_METER"}, - "roomsTotal": 1, - "floorsOffered": [8], - "floorsTotal": 25, - "ceilingHeight": 2.7, - "building": { - "builtYear": 2016, - "buildingType": "MONOLIT", - "siteId": 280938, - "siteName": "Peremen", - }, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Evgenia Savkova, 8", - "point": {"latitude": 56.79512, "longitude": 60.49186}, - }, - "mainImages": [ - "//avatars.mds.yandex.net/get-realty-offers/11904018/abc/main", - "//avatars.mds.yandex.net/get-realty-offers/14717586/def/main", - ], -} - -_ENTITY_STUDIO: dict = { - "offerId": 9999999, - "url": "//realty.yandex.ru/offer/9999999", - "price": {"value": 3200000, "valuePerPart": 114285}, - "area": {"value": 28.0}, - "livingSpace": None, - "kitchenSpace": None, - "roomsTotal": None, # studio -> rooms=0 - "floorsOffered": [2], - "floorsTotal": 9, - "ceilingHeight": None, - "building": {"builtYear": 2010, "buildingType": "PANEL", "siteId": None, "siteName": None}, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Lenina, 5", - "point": {"latitude": 56.838, "longitude": 60.597}, - }, - "mainImages": [], -} - -_ENTITY_NO_PRICE: dict = { - "offerId": 1111, - "url": "//realty.yandex.ru/offer/1111", - "price": {"value": 0}, - "area": {"value": 40.0}, - "roomsTotal": 1, - "floorsOffered": [3], - "floorsTotal": 5, - "building": {}, - "location": {"point": {"latitude": 56.8, "longitude": 60.6}}, - "mainImages": [], -} - -_ENTITY_NO_OFFER_ID: dict = { - "offerId": None, - "price": {"value": 5000000}, - "area": {"value": 50.0}, -} - - -# Entity carrying the rich gate-API fields (creationDate, description, author, -# predictedPrice, price.trend/previous, location.metroList) — verified live 2026-06-17. -_ENTITY_RICH_OWNER: dict = { - "offerId": 5500000000000000001, - "url": "//realty.yandex.ru/offer/5500000000000000001", - "creationDate": "2026-05-24T15:10:27Z", - "description": "Продаётся светлая квартира с видом на парк.", - "author": {"category": "OWNER", "agentName": "Иван", "organization": None}, - "price": { - "value": 7200000, - "valuePerPart": 150000, - "trend": "DECREASED", - "previous": 7500000, - }, - # Real gate-API nests valuation under predictions.predictedPrice (verified - # live 2026-06-18) — NOT predictedPrice.predictedPrice. - "predictions": {"predictedPrice": {"value": "7554000", "min": "7100000", "max": "8000000"}}, - "area": {"value": 48.0}, - "roomsTotal": 2, - "floorsOffered": [5], - "floorsTotal": 12, - "building": {"builtYear": 2018, "buildingType": "MONOLIT"}, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Mira, 10", - "point": {"latitude": 56.84, "longitude": 60.61}, - "metroList": [ - {"name": "Ploshchad 1905 goda", "timeToMetro": 7}, - {"name": "Geologicheskaya", "timeToMetro": 12}, - ], - }, - "mainImages": [], -} - -_ENTITY_RICH_AGENCY: dict = { - "offerId": 5500000000000000002, - "url": "//realty.yandex.ru/offer/5500000000000000002", - "creationDate": "2026-06-01T09:00:00Z", - "description": "Агентская продажа.", - "author": {"category": "AGENCY", "agentName": "Петров", "organization": "Агентство «Диал»"}, - "price": {"value": 9900000, "trend": "UNCHANGED"}, - "area": {"value": 60.0}, - "roomsTotal": 3, - "floorsOffered": [3], - "floorsTotal": 9, - "building": {"builtYear": 2012, "buildingType": "BRICK"}, - "location": { - "geocoderAddress": "Ekaterinburg, ulitsa Lenina, 1", - "point": {"latitude": 56.83, "longitude": 60.6}, - "metro": {"name": "Dinamo", "timeToMetro": 4}, - }, - "mainImages": [], -} - - -def _make_gate_payload(entities: list, pager: dict | None = None) -> dict: - """Wrap entities + pager into gate-API response shape.""" - if pager is None: - pager = {"page": 0, "pageSize": 20, "totalItems": len(entities), "totalPages": 1} - return { - "response": { - "search": { - "offers": { - "entities": entities, - "pager": pager, - } - } - } - } - - -# --------------------------------------------------------------------------- -# PART A: pure helper functions -# --------------------------------------------------------------------------- - - -def test_is_gate_error_on_error_key(): - assert _is_gate_error({"error": "bad request"}) is True - - -def test_is_gate_error_on_missing_response(): - assert _is_gate_error({"something": "else"}) is True - - -def test_is_gate_error_on_valid_payload(): - payload = _make_gate_payload([_ENTITY_FULL]) - assert _is_gate_error(payload) is False - - -def test_extract_gate_data_returns_entities_and_pager(): - payload = _make_gate_payload([_ENTITY_FULL, _ENTITY_STUDIO]) - result = _extract_gate_data(payload) - assert result is not None - entities, pager = result - assert len(entities) == 2 - assert "totalItems" in pager - - -def test_extract_gate_data_returns_none_on_bad_shape(): - assert _extract_gate_data({}) is None - assert _extract_gate_data({"response": {}}) is None - assert _extract_gate_data({"response": {"search": {}}}) is None - - -# --------------------------------------------------------------------------- -# PART B: _entity_to_lot field mapping -# --------------------------------------------------------------------------- - - -def test_entity_to_lot_full_entity(): - lot = _entity_to_lot(_ENTITY_FULL) - assert lot is not None - assert lot.source == "yandex" - assert lot.source_id == "4740475460451078271" - assert lot.source_url == "https://realty.yandex.ru/offer/4740475460451078271" - assert lot.price_rub == 4_500_000 - assert lot.price_per_m2 == 119_048 - assert lot.area_m2 == pytest.approx(37.8) - assert lot.living_area_m2 == pytest.approx(20.0) - assert lot.rooms == 1 - assert lot.floor == 8 - assert lot.total_floors == 25 - assert lot.year_built == 2016 - assert lot.house_type == "monolith" # normalize_house_type(MONOLIT) — #2007 - assert lot.kitchen_area_m2 == pytest.approx(12.7) # promoted to column — #2007 - assert lot.ceiling_height_m == pytest.approx(2.7) # promoted to column — #2007 - assert lot.lat == pytest.approx(56.79512) - assert lot.lon == pytest.approx(60.49186) - assert lot.address == "Ekaterinburg, ulitsa Evgenia Savkova, 8" - assert lot.house_source == "yandex_realty_nb" - assert lot.house_ext_id == "280938" - assert lot.listing_segment == "vtorichka" - assert len(lot.photo_urls) == 2 - assert all(u.startswith("https://avatars.mds.yandex.net") for u in lot.photo_urls) - assert lot.raw_payload is not None - assert lot.raw_payload["ceiling_height"] == pytest.approx(2.7) - assert lot.raw_payload["kitchen_area_m2"] == pytest.approx(12.7) - assert lot.raw_payload["raw_building_type"] == "MONOLIT" # сырой SCREAMING сохранён - - -def test_entity_to_lot_ceiling_out_of_range_dropped(): - """ceiling_height — numeric(3,2) (max 9.99); неправдоподобный yandex SERP мусор - (видели 18 м) отсекаем в None, иначе DataError уронил бы весь батч. Сырое - значение остаётся в raw_payload. (#2007)""" - lot_hi = _entity_to_lot({**_ENTITY_FULL, "ceilingHeight": 18}) - assert lot_hi is not None - assert lot_hi.ceiling_height_m is None - assert lot_hi.raw_payload["ceiling_height"] == 18 # сырое сохранено - lot_lo = _entity_to_lot({**_ENTITY_FULL, "ceilingHeight": 1.6}) - assert lot_lo is not None - assert lot_lo.ceiling_height_m is None - - -def test_entity_to_lot_segment_vtorichka_default(): - """Default new_flat='NO' -> listing_segment='vtorichka'.""" - lot = _entity_to_lot(_ENTITY_FULL) - assert lot is not None - assert lot.listing_segment == "vtorichka" - - -def test_entity_to_lot_segment_novostroyki_on_new_flat_yes(): - """new_flat='YES' -> listing_segment='novostroyki'.""" - lot = _entity_to_lot(_ENTITY_FULL, new_flat="YES") - assert lot is not None - assert lot.listing_segment == "novostroyki" - - -def test_parse_gate_json_propagates_segment(): - """_parse_gate_json threads new_flat down to each lot's listing_segment.""" - payload = _make_gate_payload([_ENTITY_FULL, _ENTITY_STUDIO]) - lots_nb = _parse_gate_json(payload, new_flat="YES") - assert lots_nb and all(lot.listing_segment == "novostroyki" for lot in lots_nb) - lots_vt = _parse_gate_json(payload, new_flat="NO") - assert lots_vt and all(lot.listing_segment == "vtorichka" for lot in lots_vt) - - -def test_entity_to_lot_studio_rooms_zero(): - lot = _entity_to_lot(_ENTITY_STUDIO) - assert lot is not None - assert lot.rooms == 0 - assert lot.area_m2 == pytest.approx(28.0) - assert lot.floor == 2 - assert lot.total_floors == 9 - assert lot.house_source is None - assert lot.house_ext_id is None - assert lot.photo_urls == [] - - -def test_entity_to_lot_no_price_returns_none(): - lot = _entity_to_lot(_ENTITY_NO_PRICE) - assert lot is None - - -def test_entity_to_lot_no_offer_id_returns_none(): - lot = _entity_to_lot(_ENTITY_NO_OFFER_ID) - assert lot is None - - -def test_entity_to_lot_photo_url_prefix(): - """mainImages starting with // must get https: prepended.""" - lot = _entity_to_lot(_ENTITY_FULL) - assert lot is not None - # Both URLs must start with https:// (not protocol-relative //) - assert all(u.startswith("https://") for u in lot.photo_urls) - assert not any(u.startswith("//") for u in lot.photo_urls) - - -def test_entity_to_lot_photo_capped_at_5(): - entity = dict(_ENTITY_FULL) - entity["mainImages"] = [f"//avatars.mds.yandex.net/img{i}/main" for i in range(10)] - lot = _entity_to_lot(entity) - assert lot is not None - assert len(lot.photo_urls) == 5 - - -def test_entity_to_lot_url_fallback_when_missing(): - """When url field absent, fall back to constructed /offer// URL.""" - entity = dict(_ENTITY_FULL) - entity["url"] = None - lot = _entity_to_lot(entity) - assert lot is not None - expected_id = _ENTITY_FULL["offerId"] - assert lot.source_url == f"https://realty.yandex.ru/offer/{expected_id}/" - - -# --------------------------------------------------------------------------- -# #2235: source_url canonicalisation — partner/redirect urls of novostroyki -# offers must NOT be stored verbatim (many flats of one ЖК share one url → -# duplicate active source_url). They must become offer_id-based canonical urls. -# --------------------------------------------------------------------------- - - -def test_canonical_source_url_keeps_yandex_realty_url(): - """A real //realty.yandex.ru/offer/ url is preserved verbatim (vtorichka).""" - assert ( - _canonical_source_url("//realty.yandex.ru/offer/123", "123") - == "https://realty.yandex.ru/offer/123" - ) - - -@pytest.mark.parametrize( - "partner_url", - [ - "http://na100.pro/go.php?link=LtWgA6kWd3xjlrUBCLl6VGjo", # ad redirect - "https://atlasdevelopment.su/", # ЖК landing shared by many flats - "https://unistroyrf.ru/", - "https://vtmn.ru/complexes/vitamin-kvartal-na-titova", - "", # missing url - ], -) -def test_canonical_source_url_rewrites_partner_urls(partner_url): - """Non-realty.yandex partner/redirect urls become offer_id-based canonical urls.""" - assert ( - _canonical_source_url(partner_url, "777888999") - == "https://realty.yandex.ru/offer/777888999/" - ) - - -def test_entity_to_lot_novostroyka_partner_url_is_canonicalised(): - """Two distinct offers sharing one developer url get DISTINCT canonical source_urls. - - Reproduces the #2235 root cause: the gate-API `url` for novostroyki points to - the developer site (shared by many flats). Verbatim storage produced 204 - active source_url duplicates. After canonicalisation each offer_id yields its - own unique url, so the duplication disappears at the source. - """ - shared = "http://na100.pro/go.php?link=LtWgA6kWd3xjlrUBCLl6VGjo" - ent_a = {**_ENTITY_FULL, "offerId": 1111111111111111111, "url": shared} - ent_b = {**_ENTITY_FULL, "offerId": 2222222222222222222, "url": shared} - lot_a = _entity_to_lot(ent_a, new_flat="YES") - lot_b = _entity_to_lot(ent_b, new_flat="YES") - assert lot_a is not None and lot_b is not None - assert lot_a.source_url == "https://realty.yandex.ru/offer/1111111111111111111/" - assert lot_b.source_url == "https://realty.yandex.ru/offer/2222222222222222222/" - assert lot_a.source_url != lot_b.source_url - - -def test_entity_to_lot_no_house_when_site_id_absent(): - """Entity without siteId produces house_source=None, house_ext_id=None.""" - entity = dict(_ENTITY_FULL) - entity["building"] = {"builtYear": 2015, "buildingType": "BRICK", "siteId": None} - lot = _entity_to_lot(entity) - assert lot is not None - assert lot.house_source is None - assert lot.house_ext_id is None - - -def test_entity_to_lot_page_param_in_raw(): - """page_param passed to _entity_to_lot appears in raw_payload.""" - lot = _entity_to_lot(_ENTITY_FULL, page_param=3) - assert lot is not None - assert lot.raw_payload["page_param"] == 3 - - -# --------------------------------------------------------------------------- -# PART B2: rich gate-API fields (predictedPrice, seller, dates, trend, metro) -# --------------------------------------------------------------------------- - - -def test_entity_to_lot_rich_owner_fields(): - lot = _entity_to_lot(_ENTITY_RICH_OWNER) - assert lot is not None - # creationDate → listing_date / publish_date / days_on_market - assert lot.listing_date is not None - assert lot.listing_date.isoformat() == "2026-05-24" - assert lot.publish_date == lot.listing_date - assert lot.days_on_market is not None - assert lot.days_on_market >= 0 - # description - assert lot.description == "Продаётся светлая квартира с видом на парк." - # author OWNER - assert lot.is_homeowner is True - assert lot.is_pro_seller is None - assert lot.agency_name is None - # predictedPrice - assert lot.predicted_price_rub == 7_554_000 - assert lot.predicted_price_min == 7_100_000 - assert lot.predicted_price_max == 8_000_000 - # price trend / previous - assert lot.price_trend == "DECREASED" - assert lot.price_previous_rub == 7_500_000 - # metro_stations from metroList - assert lot.metro_stations == [ - {"name": "Ploshchad 1905 goda", "min": 7}, - {"name": "Geologicheskaya", "min": 12}, - ] - # yandex_offer_id - assert lot.yandex_offer_id == "5500000000000000001" - - -def test_entity_to_lot_rich_agency_fields(): - lot = _entity_to_lot(_ENTITY_RICH_AGENCY) - assert lot is not None - # AGENCY → pro seller + agency_name from organization - assert lot.is_pro_seller is True - assert lot.is_homeowner is None - assert lot.agency_name == "Агентство «Диал»" - # price.trend present, no previous → price_previous_rub None - assert lot.price_trend == "UNCHANGED" - assert lot.price_previous_rub is None - # no predictedPrice path → all None - assert lot.predicted_price_rub is None - assert lot.predicted_price_min is None - assert lot.predicted_price_max is None - # single location.metro fallback - assert lot.metro_stations == [{"name": "Dinamo", "min": 4}] - - -def test_entity_to_lot_no_rich_fields_are_none(): - """Base fixture lacks rich fields → all new fields default to None/[] (no crash).""" - lot = _entity_to_lot(_ENTITY_FULL) - assert lot is not None - assert lot.listing_date is None - assert lot.days_on_market is None - assert lot.description is None - assert lot.is_homeowner is None - assert lot.is_pro_seller is None - assert lot.agency_name is None - assert lot.predicted_price_rub is None - assert lot.price_trend is None - assert lot.price_previous_rub is None - assert lot.metro_stations == [] - # yandex_offer_id is always populated from offerId - assert lot.yandex_offer_id == "4740475460451078271" - - -def test_entity_to_lot_bad_creation_date_is_safe(): - """Unparseable creationDate → listing_date/days_on_market None, no exception.""" - entity = dict(_ENTITY_RICH_OWNER) - entity["creationDate"] = "not-a-date" - lot = _entity_to_lot(entity) - assert lot is not None - assert lot.listing_date is None - assert lot.days_on_market is None - - -# --------------------------------------------------------------------------- -# PART C: _parse_gate_json (list-level) -# --------------------------------------------------------------------------- - - -def test_parse_gate_json_returns_list(): - payload = _make_gate_payload([_ENTITY_FULL, _ENTITY_STUDIO]) - lots = _parse_gate_json(payload) - assert len(lots) == 2 - assert all(isinstance(lot, ScrapedLot) for lot in lots) - - -def test_parse_gate_json_skips_invalid(): - """Entities that fail validation (no price, no id) are silently skipped.""" - payload = _make_gate_payload([_ENTITY_FULL, _ENTITY_NO_PRICE, _ENTITY_NO_OFFER_ID]) - lots = _parse_gate_json(payload) - assert len(lots) == 1 - assert lots[0].source_id == str(_ENTITY_FULL["offerId"]) - - -def test_parse_gate_json_empty_entities(): - payload = _make_gate_payload([]) - lots = _parse_gate_json(payload) - assert lots == [] - - -def test_parse_gate_json_bad_payload(): - """Error/missing-path payload returns empty list without raising.""" - assert _parse_gate_json({}) == [] - assert _parse_gate_json({"error": "bad"}) == [] - - -# --------------------------------------------------------------------------- -# PART D: _build_url gate-API URL shape -# --------------------------------------------------------------------------- - - -def test_build_url_contains_required_params(): - s = YandexRealtyScraper() - url = s._build_url(page=1) - assert "realty.yandex.ru/gate/react-page/get/" in url - assert "rgid=559132" in url - assert "type=SELL" in url - assert "category=APARTMENT" in url - assert "newFlat=NO" in url - assert "_pageType=search" in url - assert "_providers=react-search-results-data" in url - assert "page=1" in url - - -def test_build_url_page_1_based(): - """page param is 1-based; page=1 is first page.""" - s = YandexRealtyScraper() - url1 = s._build_url(page=1) - url2 = s._build_url(page=2) - assert "page=1" in url1 - assert "page=2" in url2 - - -def test_build_url_rooms_studio(): - s = YandexRealtyScraper() - url = s._build_url(page=1, rooms="studio") - assert "roomsTotal=STUDIO" in url - - -def test_build_url_rooms_1(): - s = YandexRealtyScraper() - url = s._build_url(page=1, rooms="1") - assert "roomsTotal=1" in url - - -def test_build_url_rooms_4plus(): - s = YandexRealtyScraper() - url = s._build_url(page=1, rooms="4+") - assert "roomsTotal=4" in url - - -def test_build_url_price_range(): - s = YandexRealtyScraper() - url = s._build_url(page=1, price_min=5_000_000, price_max=7_000_000) - assert "priceMin=5000000" in url - assert "priceMax=7000000" in url - - -def test_build_url_unknown_rooms_no_rooms_param(): - """Unknown rooms key must not produce a roomsTotal param.""" - s = YandexRealtyScraper() - url = s._build_url(page=1, rooms="99-room-penthouse") - assert "roomsTotal" not in url - - -def test_build_url_combos_are_unique(): - """All rooms x price-range combos produce distinct URLs.""" - s = YandexRealtyScraper() - urls = set() - for rooms in ROOM_PATH.keys(): - for lo, hi in DEFAULT_PRICE_RANGES: - urls.add(s._build_url(page=1, rooms=rooms, price_min=lo, price_max=hi)) - expected = len(ROOM_PATH) * len(DEFAULT_PRICE_RANGES) - assert len(urls) == expected, f"URL collisions: {expected} combos but {len(urls)} unique" - - -def test_build_url_new_flat_default_no(): - """Default new_flat must keep newFlat=NO (vtorichka) — backward compat.""" - s = YandexRealtyScraper() - url = s._build_url(page=1) - assert "newFlat=NO" in url - assert "newFlat=YES" not in url - - -def test_build_url_new_flat_yes(): - """new_flat='YES' must produce newFlat=YES (novostroyki segment).""" - s = YandexRealtyScraper() - url = s._build_url(page=1, new_flat="YES") - assert "newFlat=YES" in url - - -def test_default_city(): - assert DEFAULT_CITY == "ekaterinburg" - - -def test_gate_max_pages_cap(): - assert _GATE_MAX_PAGES_CAP == 50 - - -# --------------------------------------------------------------------------- -# PART E: _combo_label -# --------------------------------------------------------------------------- - - -def test_combo_label_none_none(): - result = _combo_label(None, 0, 200_000_000) - assert isinstance(result, str) - assert "any" in result - - -def test_combo_label_room_and_price(): - result = _combo_label("1", 5_000_000, 7_000_000) - assert "1" in result - assert "5000000" in result - assert "7000000" in result - - -def test_combo_label_different_ranges_differ(): - k1 = _combo_label("1", 5_000_000, 7_000_000) - k2 = _combo_label("1", 7_000_000, 10_000_000) - k3 = _combo_label("2", 5_000_000, 7_000_000) - assert k1 != k2 - assert k1 != k3 - - -# --------------------------------------------------------------------------- -# PART F: transport layer -# --------------------------------------------------------------------------- - - -# camoufox renders the gate-API JSON wrapped in an HTML
 (verified prod 2026-06-17):
-_CAMOUFOX_HTML_PREFIX = (
-    '
'
-)
-_CAMOUFOX_HTML_SUFFIX = "
" - - -def _wrap_camoufox(json_text: str) -> str: - """Wrap a JSON string the way camoufox renders a JSON endpoint.""" - return f"{_CAMOUFOX_HTML_PREFIX}{json_text}{_CAMOUFOX_HTML_SUFFIX}" - - -def test_extract_json_from_content_unwraps_camoufox_pre(): - """_extract_json_from_content must unwrap camoufox's
{json}
HTML. - - Feeds the real prod-observed wrapper and asserts the extracted text parses - back to the original gate-API payload. - """ - payload = _make_gate_payload([_ENTITY_FULL]) - wrapped = _wrap_camoufox(json.dumps(payload)) - - extracted = _extract_json_from_content(wrapped) - assert extracted is not None - assert extracted.startswith('{"response"') - assert json.loads(extracted) == payload - - -def test_extract_json_from_content_raw_json_fallback(): - """Raw JSON (no HTML wrapper) is returned via the first-'{' fallback.""" - raw = json.dumps(_make_gate_payload([])) - assert _extract_json_from_content(raw) == raw - - -def test_extract_json_from_content_returns_none_when_no_json(): - assert _extract_json_from_content("nope") is None - - -@pytest.mark.asyncio -async def test_aexit_closes_browser_and_session(): - s = YandexRealtyScraper() - mock_browser = AsyncMock() - mock_session = AsyncMock() - s._browser = mock_browser - s._cffi_session = mock_session - await s.__aexit__(None, None, None) - mock_browser.__aexit__.assert_awaited_once() - mock_session.close.assert_awaited_once() - assert s._browser is None - assert s._cffi_session is None - - -@pytest.mark.asyncio -async def test_http_get_raises_when_no_browser(): - """_http_get must raise RuntimeError outside async context manager (no browser).""" - s = YandexRealtyScraper() - with pytest.raises(RuntimeError, match="async context manager"): - await s._http_get("https://realty.yandex.ru/test/") - - -@pytest.mark.asyncio -async def test_http_get_unwraps_camoufox_html_to_json_body(): - """_http_get fetches via BrowserFetcher and returns extracted JSON with status 200.""" - s = YandexRealtyScraper() - payload = _make_gate_payload([_ENTITY_FULL]) - wrapped = _wrap_camoufox(json.dumps(payload)) - - mock_browser = AsyncMock() - mock_browser.fetch = AsyncMock(return_value=wrapped) - s._browser = mock_browser - - resp = await s._http_get("https://realty.yandex.ru/test/", timeout=30) - - assert isinstance(resp, _CurlResponse) - assert resp.status_code == 200 - assert json.loads(resp.text) == payload - mock_browser.fetch.assert_awaited_once_with("https://realty.yandex.ru/test/") - - -@pytest.mark.asyncio -async def test_http_get_status_0_on_fetch_exception(): - """BrowserFetcher.fetch raising -> _CurlResponse(status_code=0) (transient failure).""" - s = YandexRealtyScraper() - mock_browser = AsyncMock() - mock_browser.fetch = AsyncMock(side_effect=RuntimeError("browser down")) - s._browser = mock_browser - - resp = await s._http_get("https://realty.yandex.ru/test/") - - assert resp.status_code == 0 - assert resp.text == "" - - -@pytest.mark.asyncio -async def test_http_get_status_0_when_no_json_in_content(): - """Content with no JSON blob -> _CurlResponse(status_code=0).""" - s = YandexRealtyScraper() - mock_browser = AsyncMock() - mock_browser.fetch = AsyncMock(return_value="blocked") - s._browser = mock_browser - - resp = await s._http_get("https://realty.yandex.ru/test/") - - assert resp.status_code == 0 - - -# --------------------------------------------------------------------------- -# PART G: fetch_around — tarpit resilience -# --------------------------------------------------------------------------- - - -def _valid_gate_text(entity: dict | None = None) -> str: - ent = entity or _ENTITY_FULL - pager = {"page": 0, "pageSize": 20, "totalItems": 1, "totalPages": 1} - return json.dumps(_make_gate_payload([ent], pager)) - - -@pytest.mark.asyncio -async def test_fetch_around_returns_lots_on_200_json(): - s = YandexRealtyScraper() - body = _valid_gate_text() - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - return _CurlResponse(status_code=200, text=body) - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - lots = await s.fetch_around(lat=56.8, lon=60.6, rooms="1") - - assert len(lots) == 1 - assert lots[0].source_id == str(_ENTITY_FULL["offerId"]) - - -@pytest.mark.asyncio -async def test_fetch_around_rotates_and_retries_on_status_0(): - """fetch_around rotates IP and retries on status_code==0 (tarpit).""" - s = YandexRealtyScraper() - call_count = 0 - body = _valid_gate_text() - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - nonlocal call_count - call_count += 1 - if call_count == 1: - return _CurlResponse(status_code=0, text="") - return _CurlResponse(status_code=200, text=body) - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "_rotate_ip", new_callable=AsyncMock) as mock_rotate: - with patch("asyncio.sleep", new_callable=AsyncMock): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - lots = await s.fetch_around(lat=56.84, lon=60.60, rooms="1") - - assert mock_rotate.call_count == 1 - assert call_count == 2 - assert len(lots) >= 1 - - -@pytest.mark.asyncio -async def test_fetch_around_rotates_on_json_error(): - """fetch_around rotates when response body is not valid JSON.""" - s = YandexRealtyScraper() - call_count = 0 - body = _valid_gate_text() - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - nonlocal call_count - call_count += 1 - if call_count == 1: - return _CurlResponse(status_code=200, text="not json") - return _CurlResponse(status_code=200, text=body) - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "_rotate_ip", new_callable=AsyncMock) as mock_rotate: - with patch("asyncio.sleep", new_callable=AsyncMock): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - lots = await s.fetch_around(lat=56.84, lon=60.60, rooms="2") - - assert mock_rotate.call_count == 1 - assert len(lots) >= 1 - - -@pytest.mark.asyncio -async def test_fetch_around_exhausts_retries_returns_empty(): - """fetch_around returns [] when all retries exhausted.""" - s = YandexRealtyScraper() - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - return _CurlResponse(status_code=0, text="") - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "_rotate_ip", new_callable=AsyncMock) as mock_rotate: - with patch("asyncio.sleep", new_callable=AsyncMock): - lots = await s.fetch_around(lat=56.84, lon=60.60, rooms="1") - - assert mock_rotate.call_count >= 1 - assert lots == [] - - -@pytest.mark.asyncio -async def test_fetch_around_no_rotate_on_404(): - """fetch_around does NOT rotate on 404 -- only on tarpit (status==0) / bad JSON.""" - s = YandexRealtyScraper() - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - return _CurlResponse(status_code=404, text="Not Found") - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "_rotate_ip", new_callable=AsyncMock) as mock_rotate: - lots = await s.fetch_around(lat=56.84, lon=60.60) - - assert mock_rotate.call_count == 0 - assert lots == [] - - -# --------------------------------------------------------------------------- -# PART H: fetch_around_multi_room -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fetch_around_multi_room_dedup(): - """Multi-room fetch deduplicates offers seen in multiple combos. - - fetch_around_multi_room fetches page=1 inline via _http_get, not via - fetch_around. We mock _http_get to return JSON payloads with shared/unique - offer IDs to verify deduplication. - """ - s = YandexRealtyScraper() - - # Combo 0: shared_offer + unique_0 - # Combo 1: shared_offer + unique_1 - entity_shared = dict( - _ENTITY_FULL, offerId="shared_offer_1234", url="//realty.yandex.ru/offer/shared_offer_1234" - ) - entity_unique_0 = dict( - _ENTITY_FULL, offerId="unique_offer_0", url="//realty.yandex.ru/offer/unique_offer_0" - ) - entity_unique_1 = dict( - _ENTITY_FULL, offerId="unique_offer_1", url="//realty.yandex.ru/offer/unique_offer_1" - ) - - pager = {"page": 0, "pageSize": 20, "totalItems": 2, "totalPages": 1} - payload_combo0 = _make_gate_payload([entity_shared, entity_unique_0], pager) - payload_combo1 = _make_gate_payload([entity_shared, entity_unique_1], pager) - - combo_payloads = [payload_combo0, payload_combo1] - call_idx = 0 - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - nonlocal call_idx - idx = call_idx - call_idx += 1 - if idx < len(combo_payloads): - return _CurlResponse(status_code=200, text=json.dumps(combo_payloads[idx])) - return _CurlResponse(status_code=200, text=json.dumps(_make_gate_payload([]))) - - mock_settings = MagicMock() - mock_settings.yandex_proxy_url = None - - with patch.dict("sys.modules", {"app.core.config": MagicMock(settings=mock_settings)}): - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - lots = await s.fetch_around_multi_room( - lat=0.0, - lon=0.0, - rooms_list=["1"], - price_ranges=[(None, 5_000_000), (5_000_000, 7_000_000)], - max_pages=1, - ) - - ids = {lot.source_id for lot in lots} - assert "shared_offer_1234" in ids - assert len(ids) == 3 - - -@pytest.mark.asyncio -async def test_fetch_around_multi_room_runs_both_segments_with_shared_dedup(): - """segments=['NO','YES'] sweeps both vtorichka+novostroyki; dedup spans both passes. - - Mocks _http_get and inspects requested URLs: both newFlat=NO and newFlat=YES - must be requested. The same offerId returned in both passes must be deduped to - one lot (single shared `seen` across passes), tagged by the FIRST pass's segment. - """ - s = YandexRealtyScraper() - - requested_new_flat: list[str] = [] - - entity_shared = dict( - _ENTITY_FULL, offerId="shared_xseg", url="//realty.yandex.ru/offer/shared_xseg" - ) - entity_vt = dict(_ENTITY_FULL, offerId="vt_only", url="//realty.yandex.ru/offer/vt_only") - entity_nb = dict(_ENTITY_FULL, offerId="nb_only", url="//realty.yandex.ru/offer/nb_only") - pager = {"page": 0, "pageSize": 20, "totalItems": 2, "totalPages": 1} - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - if "newFlat=YES" in url: - requested_new_flat.append("YES") - payload = _make_gate_payload([entity_shared, entity_nb], pager) - else: - requested_new_flat.append("NO") - payload = _make_gate_payload([entity_shared, entity_vt], pager) - return _CurlResponse(status_code=200, text=json.dumps(payload)) - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - lots = await s.fetch_around_multi_room( - lat=0.0, - lon=0.0, - rooms_list=["1"], - price_ranges=[(None, 5_000_000)], - segments=["NO", "YES"], - max_pages=1, - ) - - assert "NO" in requested_new_flat - assert "YES" in requested_new_flat - - by_id = {lot.source_id: lot for lot in lots} - assert set(by_id) == {"shared_xseg", "vt_only", "nb_only"} - # Shared offer deduped to ONE lot, tagged by the first pass (NO -> vtorichka). - assert by_id["shared_xseg"].listing_segment == "vtorichka" - assert by_id["vt_only"].listing_segment == "vtorichka" - assert by_id["nb_only"].listing_segment == "novostroyki" - - -@pytest.mark.asyncio -async def test_fetch_around_multi_room_default_segment_no_only(): - """Without segments arg, only newFlat=NO (vtorichka) is requested (legacy default).""" - s = YandexRealtyScraper() - requested_new_flat: list[str] = [] - pager = {"page": 0, "pageSize": 20, "totalItems": 1, "totalPages": 1} - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - requested_new_flat.append("YES" if "newFlat=YES" in url else "NO") - body = json.dumps(_make_gate_payload([_ENTITY_FULL], pager)) - return _CurlResponse(status_code=200, text=body) - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - await s.fetch_around_multi_room( - lat=0.0, - lon=0.0, - rooms_list=["1"], - price_ranges=[(None, 5_000_000)], - max_pages=1, - ) - - assert set(requested_new_flat) == {"NO"} - - -@pytest.mark.asyncio -async def test_fetch_around_multi_room_legacy_single_sweep(): - """Legacy mode (no rooms_list/price_ranges) uses single citywide sweep.""" - s = YandexRealtyScraper() - calls: list[dict] = [] - - async def fake_fetch_around( - lat, lon, radius_m=1000, page=1, rooms=None, price_min=None, price_max=None - ): - calls.append({"page": page, "rooms": rooms, "price_min": price_min}) - return [] - - with patch.object(s, "fetch_around", side_effect=fake_fetch_around): - await s.fetch_around_multi_room(lat=0.0, lon=0.0, max_pages=3) - - assert all(c["rooms"] is None for c in calls) - assert all(c["price_min"] is None for c in calls) - - -# --------------------------------------------------------------------------- -# PART I: exhaustive load -- fetch_all_secondary + _walk_price_range -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_walk_price_range_splits_when_total_exceeds_cap(): - """When totalItems > cap, _walk_price_range splits into sub-ranges recursively.""" - s = YandexRealtyScraper() - - call_ranges: list[tuple] = [] - call_count = 0 - - async def fake_fetch_page_json(rooms, page, price_min, price_max): - nonlocal call_count - call_count += 1 - call_ranges.append((price_min, price_max)) - # First call (wide range): 600 items -> triggers split - total = 600 if call_count == 1 else 10 - entities = [dict(_ENTITY_FULL, offerId=f"offer_{call_count}")] - pager = { - "page": 0, - "pageSize": 20, - "totalItems": total, - "totalPages": max(1, total // 20), - } - return _make_gate_payload(entities, pager) - - seen: dict = {} - with patch.object(s, "_fetch_page_json", side_effect=fake_fetch_page_json): - with patch("asyncio.sleep", new_callable=AsyncMock): - await s._walk_price_range( - rooms="1", - lo=0, - hi=10_000_000, - seen=seen, - price_cap_per_bucket=500, - max_pages_per_bucket=50, - concurrency=2, - ) - - assert len(call_ranges) >= 3, f"Expected >=3 probes, got {call_ranges}" - assert call_ranges[0] == (None, 10_000_000) # lo=0 -> _lo_param=None - - -@pytest.mark.asyncio -async def test_walk_price_range_fallback_when_total_none(): - """totalItems absent in pager triggers paginate-until-empty fallback.""" - s = YandexRealtyScraper() - call_count = 0 - - async def fake_fetch_page_json(rooms, page, price_min, price_max): - nonlocal call_count - call_count += 1 - if call_count == 1: - # Probe: pager without totalItems - pager = {"page": 0, "pageSize": 20} - return _make_gate_payload([_ENTITY_FULL], pager) - if call_count == 2: - pager = {"page": 0, "pageSize": 20} - return _make_gate_payload([dict(_ENTITY_FULL, offerId=f"offer_{call_count}")], pager) - return _make_gate_payload([]) - - seen: dict = {} - bucket_calls: list = [] - - with patch.object(s, "_fetch_page_json", side_effect=fake_fetch_page_json): - with patch("asyncio.sleep", new_callable=AsyncMock): - await s._walk_price_range( - rooms="2", - lo=0, - hi=15_000_000, - seen=seen, - price_cap_per_bucket=500, - max_pages_per_bucket=10, - concurrency=2, - on_bucket=lambda k, n: bucket_calls.append((k, n)), - ) - - assert len(bucket_calls) >= 1, "on_bucket must fire in fallback path" - - -@pytest.mark.asyncio -async def test_walk_price_range_fires_on_bucket_for_leaf(): - """on_bucket callback fires once for a leaf bucket with small totalItems.""" - s = YandexRealtyScraper() - bucket_calls: list = [] - - async def fake_fetch_page_json(rooms, page, price_min, price_max): - pager = {"page": 0, "pageSize": 20, "totalItems": 5, "totalPages": 1} - return _make_gate_payload([_ENTITY_FULL], pager) - - seen: dict = {} - with patch.object(s, "_fetch_page_json", side_effect=fake_fetch_page_json): - with patch("asyncio.sleep", new_callable=AsyncMock): - await s._walk_price_range( - rooms="1", - lo=0, - hi=20_000_000, - seen=seen, - price_cap_per_bucket=500, - max_pages_per_bucket=5, - concurrency=2, - on_bucket=lambda k, n: bucket_calls.append(k), - ) - - assert len(bucket_calls) >= 1 - - -@pytest.mark.asyncio -async def test_walk_price_range_skip_buckets(): - """skip_buckets prevents on_bucket from firing for already-done bucket.""" - s = YandexRealtyScraper() - on_bucket_calls: list = [] - - async def fake_fetch_page_json(rooms, page, price_min, price_max): - pager = {"page": 0, "pageSize": 20, "totalItems": 10, "totalPages": 1} - return _make_gate_payload([_ENTITY_FULL], pager) - - # lo=0 -> _lo_param=None -> bucket key uses None - skip_set = {_combo_label("1", 0, 10_000_000)} - seen: dict = {} - with patch.object(s, "_fetch_page_json", side_effect=fake_fetch_page_json): - with patch("asyncio.sleep", new_callable=AsyncMock): - await s._walk_price_range( - rooms="1", - lo=0, - hi=10_000_000, - seen=seen, - price_cap_per_bucket=500, - max_pages_per_bucket=5, - concurrency=2, - on_bucket=lambda k, n: on_bucket_calls.append(k), - skip_buckets=skip_set, - ) - - assert skip_set.pop() not in on_bucket_calls - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_deduplicates_across_rooms(): - """fetch_all_secondary deduplicates offers across room buckets.""" - s = YandexRealtyScraper() - - shared_lot = MagicMock(spec=ScrapedLot) - shared_lot.source_id = "shared_999" - shared_lot.source_url = "https://realty.yandex.ru/offer/shared_999/" - - unique_lot_1 = MagicMock(spec=ScrapedLot) - unique_lot_1.source_id = "unique_001" - unique_lot_1.source_url = "https://realty.yandex.ru/offer/unique_001/" - - unique_lot_2 = MagicMock(spec=ScrapedLot) - unique_lot_2.source_id = "unique_002" - unique_lot_2.source_url = "https://realty.yandex.ru/offer/unique_002/" - - call_count = 0 - - async def fake_walk(*, rooms, lo, hi, seen, **kwargs): - nonlocal call_count - seen["shared_999"] = shared_lot - if call_count == 0: - seen["unique_001"] = unique_lot_1 - else: - seen["unique_002"] = unique_lot_2 - call_count += 1 - - with patch.object(s, "_walk_price_range", side_effect=fake_walk): - result = await s.fetch_all_secondary(rooms_buckets=["1", "2"]) - - ids = {lot.source_id for lot in result} - assert "shared_999" in ids - assert "unique_001" in ids - assert "unique_002" in ids - assert len(ids) == 3 - - -@pytest.mark.asyncio -async def test_fetch_all_secondary_walks_shared_seed_grid(): - """fetch_all_secondary итерирует общую seed-сетку: len(_SEED) walk-вызовов/комнатность. - - Закрытые брекеты передают hi = br_hi - 1; открытый (последний) — hi=None. - """ - from app.services.scrapers.price_brackets import get_price_seed_brackets - - seed = get_price_seed_brackets() - s = YandexRealtyScraper() - walk_args: list[tuple[int, int | None]] = [] - - async def fake_walk(*, rooms, lo, hi, seen, **kwargs): - walk_args.append((lo, hi)) - - with patch.object(s, "_walk_price_range", side_effect=fake_walk): - await s.fetch_all_secondary(rooms_buckets=["1"]) - - assert len(walk_args) == len( - seed - ), f"Ожидалось {len(seed)} walk-вызовов (seed-брекеты), получено {len(walk_args)}" - # Первый брекет закрыт: hi = seed[0].hi - 1. - first_lo, first_hi = seed[0] - assert first_hi is not None - assert walk_args[0] == (first_lo, first_hi - 1) - # Последний брекет открыт: hi=None (без priceMax-потолка). - last_lo, last_hi = seed[-1] - assert last_hi is None - assert walk_args[-1] == (last_lo, None) - - -@pytest.mark.asyncio -async def test_walk_price_range_open_bracket_no_split(): - """Открытый брекет (hi=None) не делится — пагинируется leaf напрямую (one bucket). - - totalItems > cap, но hi=None → split невозможен → ровно один leaf-бакет. - """ - s = YandexRealtyScraper() - bucket_calls: list = [] - seen_prices: list[int | None] = [] - - async def fake_fetch_page_json(rooms, page, price_min, price_max): - seen_prices.append(price_max) - # total > cap, но 1 страница лотов → leaf пагинируется без split - pager = {"page": 0, "pageSize": 20, "totalItems": 9999, "totalPages": 1} - return _make_gate_payload([_ENTITY_FULL], pager) - - seen: dict = {} - with patch.object(s, "_fetch_page_json", side_effect=fake_fetch_page_json): - with patch("asyncio.sleep", new_callable=AsyncMock): - await s._walk_price_range( - rooms="1", - lo=250_000_000, - hi=None, - seen=seen, - price_cap_per_bucket=500, - max_pages_per_bucket=5, - concurrency=2, - on_bucket=lambda k, n: bucket_calls.append(k), - ) - - # Открытый брекет: НЕ делится (один on_bucket), priceMax всегда None (без потолка). - assert len(bucket_calls) == 1, f"Открытый брекет не должен делиться, got {bucket_calls}" - assert all( - pm is None for pm in seen_prices - ), f"Открытый брекет: priceMax всегда None, got {seen_prices}" - - -# --------------------------------------------------------------------------- -# PART J: fetch_around_multi_room on_combo callback (FIX A — incremental save) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fetch_around_multi_room_on_combo_called_per_combo(): - """on_combo вызывается после каждого combo с новыми (деduped) лотами. - - 2 combo (rooms_list=['1'], 2 price_ranges): каждый имеет уникальный лот. - on_combo должен быть вызван дважды — по одному разу на combo. - """ - s = YandexRealtyScraper() - - entity_0 = dict(_ENTITY_FULL, offerId="combo0_lot", url="//realty.yandex.ru/offer/combo0_lot") - entity_1 = dict(_ENTITY_FULL, offerId="combo1_lot", url="//realty.yandex.ru/offer/combo1_lot") - pager = {"page": 0, "pageSize": 20, "totalItems": 1, "totalPages": 1} - - call_idx = 0 - payloads = [ - _make_gate_payload([entity_0], pager), - _make_gate_payload([entity_1], pager), - ] - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - nonlocal call_idx - idx = call_idx - call_idx += 1 - if idx < len(payloads): - return _CurlResponse(status_code=200, text=json.dumps(payloads[idx])) - return _CurlResponse(status_code=200, text=json.dumps(_make_gate_payload([]))) - - on_combo_calls: list[tuple[str, list]] = [] - - def on_combo(label: str, new_lots: list) -> None: - on_combo_calls.append((label, list(new_lots))) - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - lots = await s.fetch_around_multi_room( - lat=0.0, - lon=0.0, - rooms_list=["1"], - price_ranges=[(None, 5_000_000), (5_000_000, 10_000_000)], - max_pages=1, - on_combo=on_combo, - ) - - # Два уникальных лота, два combo. - assert len(lots) == 2 - # on_combo вызван ровно дважды — по одному на combo. - assert len(on_combo_calls) == 2, f"Ожидалось 2 on_combo вызова, получено {len(on_combo_calls)}" - # Каждый on_combo получил ровно один новый лот. - for _label, combo_lots in on_combo_calls: - assert len(combo_lots) == 1 - # Конкретные source_id в правильных batch'ах. - all_ids = {lot.source_id for _, combo_lots in on_combo_calls for lot in combo_lots} - assert all_ids == {"combo0_lot", "combo1_lot"} - - -@pytest.mark.asyncio -async def test_fetch_around_multi_room_on_combo_not_called_for_failed_combo(): - """on_combo НЕ вызывается для combo, у которого все page-1 retries исчерпаны. - - FIX B: bounded retry — если status_code=0 на всех попытках page=1, - combo пропускается. on_combo не должен быть вызван для пропущенного combo. - Успешный combo (второй) — on_combo должен сработать. - """ - s = YandexRealtyScraper() - - entity_ok = dict(_ENTITY_FULL, offerId="ok_lot", url="//realty.yandex.ru/offer/ok_lot") - pager = {"page": 0, "pageSize": 20, "totalItems": 1, "totalPages": 1} - - call_idx = 0 - - async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: - nonlocal call_idx - call_idx += 1 - # Первые (1 + _YANDEX_TARPIT_MAX_RETRIES) запросы — это retries для combo[0] → fail. - from app.services.scrapers.yandex_realty import _YANDEX_TARPIT_MAX_RETRIES - - if call_idx <= 1 + _YANDEX_TARPIT_MAX_RETRIES: - return _CurlResponse(status_code=0, text="") - # Следующий запрос — combo[1] page=1 → success. - return _CurlResponse( - status_code=200, text=json.dumps(_make_gate_payload([entity_ok], pager)) - ) - - on_combo_calls: list[str] = [] - - def on_combo(label: str, new_lots: list) -> None: - on_combo_calls.append(label) - - with patch.object(s, "_http_get", side_effect=fake_http_get): - with patch.object(s, "_rotate_ip", new_callable=AsyncMock): - with patch("asyncio.sleep", new_callable=AsyncMock): - with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): - lots = await s.fetch_around_multi_room( - lat=0.0, - lon=0.0, - rooms_list=["1"], - price_ranges=[(None, 5_000_000), (5_000_000, 10_000_000)], - max_pages=1, - on_combo=on_combo, - ) - - # combo[0] скипнут, combo[1] успешен → 1 лот в итоге. - assert len(lots) == 1 - assert lots[0].source_id == "ok_lot" - # on_combo вызван только для успешного combo, не для скипнутого. - assert len(on_combo_calls) == 1, ( - f"on_combo должен быть вызван только 1 раз (успешный combo), " - f"получено {len(on_combo_calls)}: {on_combo_calls}" - ) diff --git a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py index ea6b61ef..43d7f54e 100644 --- a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py +++ b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py @@ -1,8 +1,10 @@ """Smoke: each Yandex scraper instance reads request_delay_sec from scraper_settings. Легаси `app.services.scrapers.yandex_detail` / `yandex_valuation` удалены (#2277 -финальный шаг scraper_kit-миграции). `yandex_realty` / `yandex_newbuilding` пока -остаются легаси (не мигрированы) — их delay-wiring тесты не тронуты. +финальный шаг scraper_kit-миграции). `yandex_newbuilding` пока остаётся легаси (не +мигрирован) — его delay-wiring тест не тронут. `yandex_realty` удалён (#2397 Part +E2, 0 runtime-импортёров) — его delay-wiring тест убран (kit `YandexRealtyScraper` +delay-provider wiring покрыт `test_kit_serp_proxy_pool.py`). `yandex_detail`/`yandex_valuation` переведены на kit-эквиваленты, которые используют инжектируемый `delay_provider=` (strangler DI, #2133) вместо прямого импорта `get_scraper_delay` — тест адаптирован под реальный контракт kit-класса. @@ -15,7 +17,6 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper from scraper_kit.providers.yandex.valuation import YandexValuationScraper from app.services.scrapers.yandex_newbuilding import YandexNewbuildingScraper -from app.services.scrapers.yandex_realty import YandexRealtyScraper _KIT_CONFIG = SimpleNamespace( scraper_proxy_url=None, @@ -24,12 +25,6 @@ _KIT_CONFIG = SimpleNamespace( ) -def test_yandex_realty_picks_up_delay(): - with patch("app.services.scrapers.yandex_realty.get_scraper_delay", return_value=9.5): - s = YandexRealtyScraper() - assert s.request_delay_sec == 9.5 - - def test_yandex_detail_picks_up_delay(): """Kit YandexDetailScraper: delay_provider callable вызывается с self.name.""" s = YandexDetailScraper(delay_provider=lambda _name: 9.5) diff --git a/tradein-mvp/scripts/local-sweep-ekb-cian.py b/tradein-mvp/scripts/local-sweep-ekb-cian.py deleted file mode 100644 index 6250e8ec..00000000 --- a/tradein-mvp/scripts/local-sweep-ekb-cian.py +++ /dev/null @@ -1,359 +0,0 @@ -"""Cian ЕКБ vtorichka sweep — local residential IP → prod tradein DB через SSH tunnel. - -Why: ekb.cian.ru SERP отдаёт ВЕСЬ город (без geo-фильтра по lat/lon), поэтому -"10 anchors" из Avito-эталона тут бесполезны — одинаковый ответ × 10. -Вместо anchors — proper room-segmented citywide pagination -(rooms 1/2/3/4 × N pages × ~28 listings), per-page save (visible progress + -не теряем данные при крэше). - -Cian реально менее агрессивен чем Avito, но **residential IP всё равно может попасть -в blacklist при слишком частых запросах** — default delay 15s. - -Setup (один раз): - ssh -f -N -L 15433:172.20.0.2:5432 gendesign - cd tradein-mvp/backend - $env:DATABASE_URL='postgresql+psycopg://tradein:@localhost:15433/tradein' - $env:ENVIRONMENT='local'; $env:GLITCHTIP_DSN='' - -Usage: - uv run python ../scripts/local-sweep-ekb-cian.py # default: 4 rooms × 8 pages - uv run python ../scripts/local-sweep-ekb-cian.py --pages 20 # больше глубины - uv run python ../scripts/local-sweep-ekb-cian.py --delay 10 # быстрее (risk) - uv run python ../scripts/local-sweep-ekb-cian.py --rooms 0 # всё (включая студии — no room param) -""" -from __future__ import annotations - -# stdlib -import argparse -import asyncio -import json -import sys -import time -import types -from pathlib import Path - -# Auto-inject backend/ в sys.path чтобы найти app.* package -_BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend" -if str(_BACKEND_DIR) not in sys.path: - sys.path.insert(0, str(_BACKEND_DIR)) - -# Stub weasyprint (Windows без GTK ломает import chain через trade_in.py) -for _m in ( - "weasyprint", - "weasyprint.text", - "weasyprint.text.ffi", - "weasyprint.css", - "weasyprint.css.targets", -): - sys.modules[_m] = types.ModuleType(_m) -sys.modules["weasyprint"].HTML = type("HTML", (), {"__init__": lambda self, *a, **kw: None}) -sys.modules["weasyprint"].CSS = type("CSS", (), {"__init__": lambda self, *a, **kw: None}) - -# app imports после stubs -import logging # noqa: E402 -import re # noqa: E402 - -from app.core.db import SessionLocal # noqa: E402 -from app.services.scrapers.base import ScrapedLot, save_listings # noqa: E402 -from app.services.scrapers.cian import CianScraper # noqa: E402 - -# Шумный WARNING из upstream cian_state_parser ('state extraction failed') — у нас -# fallback на новый .concat() markup отрабатывает, warning нерелевантен. -logging.getLogger("app.services.scrapers.cian").setLevel(logging.ERROR) - -# ── New-markup fallback parser ─────────────────────────────────────────────── -# Cian переключил MFE bootstrap с .push({...}) на .concat([{...},{...}]): -# window._cianConfig['frontend-serp'] = (window._cianConfig['frontend-serp'] || []).concat([…]) -# Существующий cian_state_parser.py не ловит этот шаблон → 0 lots на свежей выдаче. -# Чтобы не трогать shared scraper module (правилами запрещено), парсим тут. -_RE_CONCAT_START = re.compile( - r"window\._cianConfig\['(?P[\w-]+)'\]\s*=\s*" - r"\(window\._cianConfig\['[\w-]+'\]\s*\|\|\s*\[\]\)\.concat\(\[" -) - - -def _extract_balanced_array(text: str, start: int) -> str | None: - """Из позиции `start` (где '[') вернуть текст массива включая внешние '[' и ']'. - - JSON-aware: учитывает strings и escapes, чтобы не сбить счётчик '[' ']' на - скобках внутри string literals. - """ - if text[start] != "[": - return None - depth = 0 - i = start - in_str = False - esc = False - while i < len(text): - ch = text[i] - if in_str: - if esc: - esc = False - elif ch == "\\": - esc = True - elif ch == '"': - in_str = False - else: - if ch == '"': - in_str = True - elif ch == "[": - depth += 1 - elif ch == "]": - depth -= 1 - if depth == 0: - return text[start : i + 1] - i += 1 - return None - - -def _parse_serp_v2(scraper: CianScraper, html: str) -> list[ScrapedLot]: - """Fallback парсер для нового шаблона .concat([...]) — ekb.cian.ru/cat.php c 2026-05.""" - state: dict | None = None - for m in _RE_CONCAT_START.finditer(html): - if m.group("mfe") != "frontend-serp": - continue - # Позиция '[' — это последний символ матча минус 1 (regex кончается на '[') - arr_start = m.end() - 1 - arr_str = _extract_balanced_array(html, arr_start) - if arr_str is None: - continue - try: - entries = json.loads(arr_str) - except json.JSONDecodeError: - continue - for entry in entries: - if ( - isinstance(entry, dict) - and entry.get("key") == "initialState" - and isinstance(entry.get("value"), dict) - ): - state = entry["value"] - break - if state is not None: - break - if state is None: - return [] - offers = (state.get("results") or {}).get("offers") or [] - lots: list[ScrapedLot] = [] - for offer in offers: - # Cian свежий markup: descriptionMinhash = list[int] (multi-hash signatures). - # ScrapedLot ожидает str → сбрасываем чтобы _offer_to_lot вычислил SHA1 от description. - if isinstance(offer.get("descriptionMinhash"), list): - offer["descriptionMinhash"] = None - lot = scraper._offer_to_lot(offer) - if lot is not None: - lots.append(lot) - return lots - - -def _load_cookies_file(path: Path) -> dict[str, str]: - """Загрузить cookies из JSON-файла. - - Поддерживает два формата: - - browser export (list[{name,value,...}]) — DevTools / EditThisCookie / Cookie-Editor - - plain dict {name: value} - """ - raw = json.loads(path.read_text(encoding="utf-8")) - if isinstance(raw, list): - return {c["name"]: c["value"] for c in raw if c.get("name") and c.get("value")} - if isinstance(raw, dict): - return {str(k): str(v) for k, v in raw.items()} - raise SystemExit(f"--cookies-file: unknown JSON shape (got {type(raw).__name__})") - - -async def phase_citywide( - pages: int, - delay: float, - room_segments: tuple[tuple[int, ...] | None, ...], - cookies_file: Path | None = None, - start_page: int = 1, -) -> int: - """Room-segmented citywide pagination — per-page save, incremental. - - room_segments — кортеж из tuple (1,) / (2,) / ... / None (= no room filter). - None segment даёт смешанную выдачу (студии + всё подряд) — Cian - не различает их без room param. - """ - seg_labels = [ - ("r" + str(seg[0]) if seg else "all") - for seg in room_segments - ] - print( - f"\n=== PHASE CITYWIDE: Cian ЕКБ vtorichka " - f"({len(room_segments)} segments={seg_labels} × {pages} pages, delay {delay}s) ===\n", - flush=True, - ) - total = 0 - cum_ins = 0 - cum_upd = 0 - async with CianScraper() as s: - # Override per-request delay (in addition to between-anchors delay) - s.request_delay_sec = delay - # Inject cookies (если есть): прокидываем в curl_cffi session перед warm-up - if cookies_file is not None: - cookies = _load_cookies_file(cookies_file) - assert s._cffi is not None - s._cffi.cookies.update(cookies) - print(f" loaded {len(cookies)} cookies from {cookies_file.name}", flush=True) - # Warm-up: посетить homepage чтобы получить _CIAN_GK / _yasc + не выглядеть как наглый бот - try: - assert s._cffi is not None - r_warm = await s._cffi.get(f"{s.base_url}/") - print( - f" warmup GET / → HTTP {r_warm.status_code} " - f"(cookies={len(list(s._cffi.cookies.jar))})", - flush=True, - ) - # Subsequent SERP requests should look like in-site navigation - s._cffi.headers.update( - { - "Referer": f"{s.base_url}/", - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-User": "?1", - "Sec-Fetch-Dest": "document", - } - ) - await asyncio.sleep(min(5.0, delay)) - except Exception as e: # noqa: BLE001 - print(f" warmup FAIL: {type(e).__name__}: {e}", flush=True) - for seg_idx, seg in enumerate(room_segments): - seg_label = "r" + str(seg[0]) if seg else "all" - # --start-page применяется только к первому segment'у — продолжение прерванного sweep - page_from = start_page if seg_idx == 0 else 1 - for page in range(page_from, pages + 1): - # Manual fetch+parse чтобы можно было дампнуть HTML при empty results - try: - url_ = s._build_url(rooms=seg, page=page) - assert s._cffi is not None - resp = await s._cffi.get(url_) - if resp.status_code != 200: - print( - f" seg={seg_label} page={page} HTTP {resp.status_code} → skip", - flush=True, - ) - await asyncio.sleep(delay) - continue - lots = s._parse_serp_html(resp.text) - if not lots: - # Fallback: новый markup .concat([...]) - lots = _parse_serp_v2(s, resp.text) - if lots: - print( - f" (parsed via v2 .concat fallback)", - flush=True, - ) - if not lots: - # Дамп для разбора: captcha vs пустая страница vs изменение markup - dump_path = Path(f"cian-empty-seg-{seg_label}-p{page}.html") - dump_path.write_text(resp.text, encoding="utf-8") - print(f" DUMP: {dump_path} ({len(resp.text)} bytes)", flush=True) - await s.sleep_between_requests() - except Exception as e: # noqa: BLE001 - print( - f" seg={seg_label} page={page} FAIL fetch: " - f"{type(e).__name__}: {e}", - flush=True, - ) - await asyncio.sleep(delay) - continue - if not lots: - print( - f" seg={seg_label} page={page}: 0 lots — end of segment " - "(либо captcha)", - flush=True, - ) - break - db = SessionLocal() - try: - ins, upd = save_listings(db, lots) - cum_ins += ins - cum_upd += upd - total += len(lots) - print( - f" seg={seg_label} page={page}/{pages}: {len(lots)} lots → " - f"ins={ins} upd={upd} " - f"[cum: total={total} new={cum_ins} upd={cum_upd}]", - flush=True, - ) - except Exception as e: # noqa: BLE001 - print( - f" seg={seg_label} page={page} FAIL save: " - f"{type(e).__name__}: {e}", - flush=True, - ) - finally: - db.close() - # scraper.fetch_around() уже спит request_delay_sec * jitter — extra sleep не нужен - print( - f" PHASE CITYWIDE TOTAL: fetched={total} ins={cum_ins} upd={cum_upd}", - flush=True, - ) - return total - - -async def main() -> None: - p = argparse.ArgumentParser() - p.add_argument( - "--pages", - type=int, - default=8, - help="страниц на room-segment (default 8 → ~28×4×8 = ~900 до дедупа)", - ) - p.add_argument( - "--delay", - type=float, - default=15, - help="sec между HTTP requests (residential-safe ≥10, default 15)", - ) - p.add_argument( - "--rooms", - type=str, - default="1,2,3,4", - help="room segments через запятую (1,2,3,4) или 'all' для одного citywide " - "запроса без room param (default '1,2,3,4')", - ) - p.add_argument( - "--cookies-file", - type=Path, - default=None, - help="JSON-файл с Cian cookies (browser-export или {name:value}). " - "Опционально — для обхода captcha. См. scripts/.cian-cookies.json", - ) - p.add_argument( - "--start-page", - type=int, - default=1, - help="С какой страницы начать ПЕРВЫЙ сегмент (для resume прерванного sweep). " - "Остальные segments идут с p=1. Default 1.", - ) - args = p.parse_args() - - # Parse rooms argument - if args.rooms.strip().lower() == "all": - segments: tuple[tuple[int, ...] | None, ...] = (None,) - else: - try: - nums = [int(x.strip()) for x in args.rooms.split(",") if x.strip()] - except ValueError as e: - raise SystemExit(f"--rooms must be comma-separated ints or 'all': {e}") from e - segments = tuple((n,) for n in nums) - - cookies_file: Path | None = args.cookies_file - if cookies_file is not None and not cookies_file.exists(): - raise SystemExit(f"--cookies-file not found: {cookies_file}") - - t0 = time.perf_counter() - await phase_citywide( - args.pages, - args.delay, - segments, - cookies_file=cookies_file, - start_page=args.start_page, - ) - elapsed = time.perf_counter() - t0 - print(f"\n=== ALL DONE in {elapsed / 60:.1f} min ===", flush=True) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tradein-mvp/scripts/local-sweep-ekb-yandex.py b/tradein-mvp/scripts/local-sweep-ekb-yandex.py deleted file mode 100644 index 9fc87cf9..00000000 --- a/tradein-mvp/scripts/local-sweep-ekb-yandex.py +++ /dev/null @@ -1,376 +0,0 @@ -"""Yandex.Недвижимость ЕКБ vtorichka sweep — runs locally через residential IP. - -Why local: Yandex anti-bot (captcha redirect) включается быстрее на datacenter -IP + httpx fingerprint. Запуск с residential IP + Chrome120 TLS impersonation -(curl_cffi) + browser session cookies — проходит. Скрипт пишет в prod tradein -DB через SSH tunnel (localhost:15433 → tradein-postgres docker container). - -Modes: - --mode citywide (default): один общий /vtorichniy-rynok/ sweep, ~25 страниц - × 23 cards = ~575 уникальных. Yandex SERP лимит. - --mode combos: итерация 5 rooms × 6 price ranges = 30 combos × до 25 pages. - DB-level дедуп → coverage близко к total (~4000+ vtorichka - ЕКБ). ~75 min полный прогон. - -Setup: - ssh -f -N -L 15433:172.20.0.2:5432 gendesign # tradein-postgres tunnel - cd tradein-mvp/backend - export DATABASE_URL='postgresql+psycopg://tradein:@localhost:15433/tradein' - export ENVIRONMENT='local' - export GLITCHTIP_DSN='' - -Usage: - uv run python ../scripts/local-sweep-ekb-yandex.py # citywide - uv run python ../scripts/local-sweep-ekb-yandex.py --mode combos # full ~4000 - uv run python ../scripts/local-sweep-ekb-yandex.py --mode combos \\ - --rooms 1,2 --price-ranges 5-7,7-10 # subset - uv run python ../scripts/local-sweep-ekb-yandex.py --pages 1 # smoke -""" -from __future__ import annotations - -# stdlib -import argparse -import asyncio -import json -import random -import sys -import time -import types -from pathlib import Path -from urllib.parse import urlencode - -# Auto-inject backend/ в sys.path чтобы найти app.* package -_BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend" -if str(_BACKEND_DIR) not in sys.path: - sys.path.insert(0, str(_BACKEND_DIR)) - -# Stub weasyprint (Windows без GTK ломает import chain через trade_in.py) -for _m in ( - "weasyprint", - "weasyprint.text", - "weasyprint.text.ffi", - "weasyprint.css", - "weasyprint.css.targets", -): - sys.modules[_m] = types.ModuleType(_m) -sys.modules["weasyprint"].HTML = type("HTML", (), {"__init__": lambda self, *a, **kw: None}) -sys.modules["weasyprint"].CSS = type("CSS", (), {"__init__": lambda self, *a, **kw: None}) - -# app imports после stubs -from curl_cffi.requests import AsyncSession # noqa: E402 - -from app.core.db import SessionLocal # noqa: E402 -from app.services.scrapers.base import save_listings # noqa: E402 -from app.services.scrapers.yandex_realty import YandexRealtyScraper # noqa: E402 - -DEFAULT_CITY = "ekaterinburg" -DEFAULT_COOKIES_FILE = Path(__file__).resolve().parent / ".yandex-cookies.json" - -# Path-сегменты Yandex для room buckets (после канонизации). Ключ — slug для -# --rooms CLI; значение — segment в URL. -ROOM_PATH: dict[str, str] = { - "studio": "studiya", - "1": "odnokomnatnaya", - "2": "dvuhkomnatnaya", - "3": "trehkomnatnaya", - "4+": "4-i-bolee", -} - -# Price ranges (price_min, price_max) в рублях. None — open-ended boundary. -PRICE_RANGES: list[tuple[int | None, int | None]] = [ - (None, 5_000_000), - (5_000_000, 7_000_000), - (7_000_000, 10_000_000), - (10_000_000, 15_000_000), - (15_000_000, 25_000_000), - (25_000_000, None), -] - -_CAPTCHA_MARKERS = ("/showcaptcha", "smartcaptcha", "checkboxcaptcha") - - -def _load_cookies(path: Path) -> dict[str, str]: - if not path.exists(): - return {} - raw = json.loads(path.read_text(encoding="utf-8")) - return {item["name"]: item["value"] for item in raw if "name" in item and "value" in item} - - -def _is_captcha(html: str) -> bool: - head = html[:5000].lower() - return any(m in head for m in _CAPTCHA_MARKERS) or "докажите, что вы не робот" in head - - -def _build_url( - city: str, - page: int = 0, - rooms: str | None = None, - price_min: int | None = None, - price_max: int | None = None, -) -> str: - """Yandex SERP URL. Если rooms задан — path-based бакет; иначе общий vtorichka. - - newFlat=NO_DEAL принудительно отфильтровывает новостройки (нужно при - combo, т.к. Yandex canonicalize иногда дропает /vtorichniy-rynok/). - """ - if rooms: - path = f"/{city}/kupit/kvartira/{ROOM_PATH[rooms]}/vtorichniy-rynok/" - else: - path = f"/{city}/kupit/kvartira/vtorichniy-rynok/" - base = f"https://realty.yandex.ru{path}" - - params: dict[str, str | int] = {} - if price_min is not None: - params["priceMin"] = price_min - if price_max is not None: - params["priceMax"] = price_max - if price_min is not None or price_max is not None: - # Гарантия vtorichka — Yandex иногда дропает /vtorichniy-rynok/ при canonicalize - params["newFlat"] = "NO_DEAL" - if page > 0: - params["page"] = page - - if params: - return f"{base}?{urlencode(params)}" - return base - - -def _combo_label(rooms: str | None, price_min: int | None, price_max: int | None) -> str: - r = rooms or "all-rooms" - if price_min is None and price_max is None: - return f"{r}/any-price" - lo = f"{price_min // 1_000_000}M" if price_min else "0" - hi = f"{price_max // 1_000_000}M" if price_max else "inf" - return f"{r}/{lo}-{hi}" - - -async def sweep_one( - session: AsyncSession, - parser: YandexRealtyScraper, - city: str, - pages: int, - delay: float, - rooms: str | None = None, - price_min: int | None = None, - price_max: int | None = None, -) -> tuple[int, int, int]: - """Один scan по фиксированному (rooms, price) — пагинация до pages или empty. - - Returns: (fetched, inserted, updated) - """ - label = _combo_label(rooms, price_min, price_max) - total = cum_ins = cum_upd = 0 - for page in range(pages): - url = _build_url(city, page=page, rooms=rooms, price_min=price_min, price_max=price_max) - try: - resp = await session.get(url, timeout=20) - except Exception as e: # noqa: BLE001 - print(f" [{label}] page={page} FAIL fetch: {type(e).__name__}: {e}", flush=True) - await asyncio.sleep(delay) - continue - if resp.status_code != 200: - print(f" [{label}] page={page} HTTP {resp.status_code} — stop combo", flush=True) - break - if _is_captcha(resp.text): - print( - f" [{label}] page={page} ⚠️ CAPTCHA — stop combo. " - f"Подожди 30-60min или refresh .yandex-cookies.json", - flush=True, - ) - raise RuntimeError("captcha") - # NBSP → space: yandex_helpers.parse_rub() багует на \xa0 в group(1) → price None - html = resp.text.replace("\xa0", " ") - lots = parser._parse_html(html, page=page) - if not lots: - print(f" [{label}] page={page}: 0 lots — end of pagination, stop", flush=True) - break - db = SessionLocal() - try: - ins, upd = save_listings(db, lots) - cum_ins += ins - cum_upd += upd - total += len(lots) - print( - f" [{label}] page={page + 1}/{pages}: {len(lots)} lots → " - f"ins={ins} upd={upd} [combo: {total}/{cum_ins}/{cum_upd}]", - flush=True, - ) - except Exception as e: # noqa: BLE001 - print(f" [{label}] page={page} FAIL save: {type(e).__name__}: {e}", flush=True) - finally: - db.close() - if page + 1 < pages: - await asyncio.sleep(delay * random.uniform(0.85, 1.3)) - return total, cum_ins, cum_upd - - -async def phase_citywide(pages: int, delay: float, city: str, cookies: dict[str, str]) -> None: - print( - f"\n=== PHASE CITYWIDE: Yandex vtorichka (city={city}, {pages} pages, " - f"delay {delay}s) ===\n", - flush=True, - ) - parser = YandexRealtyScraper(city=city) - async with AsyncSession(impersonate="chrome120", cookies=cookies or {}) as session: - total, ins, upd = await sweep_one(session, parser, city, pages, delay) - print(f" CITYWIDE TOTAL: fetched={total} ins={ins} upd={upd}", flush=True) - - -async def phase_combos( - pages: int, - delay: float, - city: str, - cookies: dict[str, str], - rooms_list: list[str], - price_ranges: list[tuple[int | None, int | None]], - combo_delay: float, -) -> None: - combos = [(r, lo, hi) for r in rooms_list for lo, hi in price_ranges] - print( - f"\n=== PHASE COMBOS: {len(combos)} combos × ≤{pages} pages " - f"(city={city}, delay {delay}s, combo-delay {combo_delay}s) ===", - flush=True, - ) - parser = YandexRealtyScraper(city=city) - grand_fetched = grand_ins = grand_upd = 0 - async with AsyncSession(impersonate="chrome120", cookies=cookies or {}) as session: - for i, (rooms, lo, hi) in enumerate(combos, 1): - label = _combo_label(rooms, lo, hi) - print(f"\n [{i}/{len(combos)}] combo={label}", flush=True) - try: - f, ins, upd = await sweep_one( - session, parser, city, pages, delay, - rooms=rooms, price_min=lo, price_max=hi, - ) - except RuntimeError as e: - if str(e) == "captcha": - print(" ⚠️ stopping combo run — captcha", flush=True) - break - raise - grand_fetched += f - grand_ins += ins - grand_upd += upd - print( - f" [{i}/{len(combos)}] {label} done: f={f} i={ins} u={upd} " - f"[grand: f={grand_fetched} i={grand_ins} u={grand_upd}]", - flush=True, - ) - if i < len(combos): - await asyncio.sleep(combo_delay * random.uniform(0.85, 1.3)) - print( - f"\n COMBOS GRAND TOTAL: fetched={grand_fetched} " - f"ins={grand_ins} upd={grand_upd}", - flush=True, - ) - - -def _parse_rooms_arg(s: str) -> list[str]: - if s == "all": - return list(ROOM_PATH) - out: list[str] = [] - for tok in s.split(","): - tok = tok.strip() - if tok not in ROOM_PATH: - raise argparse.ArgumentTypeError( - f"unknown room '{tok}'. Allowed: {sorted(ROOM_PATH)} or 'all'" - ) - out.append(tok) - return out - - -def _parse_price_ranges_arg(s: str) -> list[tuple[int | None, int | None]]: - if s == "all": - return list(PRICE_RANGES) - out: list[tuple[int | None, int | None]] = [] - for tok in s.split(","): - tok = tok.strip() - if "-" not in tok: - raise argparse.ArgumentTypeError(f"range '{tok}' missing '-'") - lo_s, hi_s = tok.split("-", 1) - lo = int(float(lo_s) * 1_000_000) if lo_s else None - hi = int(float(hi_s) * 1_000_000) if hi_s else None - out.append((lo, hi)) - return out - - -async def main() -> None: - p = argparse.ArgumentParser(description="Yandex.Недвижимость ЕКБ sweep") - p.add_argument( - "--mode", - choices=["citywide", "combos"], - default="citywide", - help="citywide = один общий vtorichka sweep (~575 уникальных). " - "combos = rooms × price = ~4000+", - ) - p.add_argument( - "--pages", - type=int, - default=25, - help="max pages per sweep (Yandex SERP cap ≈ 25; stops earlier on empty)", - ) - p.add_argument( - "--delay", - type=float, - default=15, - help="sec между HTTP requests внутри combo (default 15, residential-safe)", - ) - p.add_argument( - "--combo-delay", - type=float, - default=20, - help="sec между combos (default 20, дополнительный jitter combos mode)", - ) - p.add_argument( - "--rooms", - type=_parse_rooms_arg, - default="all", - help=f"combos: comma-list из {sorted(ROOM_PATH)} или 'all' (default)", - ) - p.add_argument( - "--price-ranges", - type=_parse_price_ranges_arg, - default="all", - help="combos: comma-list 'lo-hi' в млн руб ('-5,5-7,7-10,10-15,15-25,25-') " - "или 'all' (default). Empty side = open-ended.", - ) - p.add_argument( - "--city", - type=str, - default=DEFAULT_CITY, - help=f"Yandex city slug (default '{DEFAULT_CITY}')", - ) - p.add_argument( - "--cookies-file", - type=Path, - default=DEFAULT_COOKIES_FILE, - help=f"Browser cookies JSON (default '{DEFAULT_COOKIES_FILE.name}'). " - f"Empty/missing → start без cookies (риск captcha).", - ) - args = p.parse_args() - - cookies = _load_cookies(args.cookies_file) - if cookies: - print(f"loaded {len(cookies)} cookies from {args.cookies_file}", flush=True) - else: - print( - f"⚠️ no cookies loaded ({args.cookies_file}, exists={args.cookies_file.exists()}). " - f"Yandex anti-bot risk.", - flush=True, - ) - - t0 = time.perf_counter() - if args.mode == "citywide": - await phase_citywide(args.pages, args.delay, args.city, cookies) - else: - await phase_combos( - args.pages, args.delay, args.city, cookies, - rooms_list=args.rooms, - price_ranges=args.price_ranges, - combo_delay=args.combo_delay, - ) - elapsed = time.perf_counter() - t0 - print(f"\n=== ALL DONE in {elapsed / 60:.1f} min ===", flush=True) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tradein-mvp/scripts/local-sweep-ekb.py b/tradein-mvp/scripts/local-sweep-ekb.py deleted file mode 100644 index 35c2dd98..00000000 --- a/tradein-mvp/scripts/local-sweep-ekb.py +++ /dev/null @@ -1,443 +0,0 @@ -"""Full ЕКБ sweep + houses enrichment — runs locally через residential IP. - -Why: prod VPS Beget IP заблокирован Avito (datacenter fingerprint → 429). -Local IP (домашний ISP) = residential → проходит. Скрипт пишет в prod DB -через SSH tunnel (localhost:15433 → tradein-postgres docker container). - -Setup: - ssh -f -N -L 15433:172.20.0.2:5432 gendesign # tradein-postgres tunnel - cd tradein-mvp/backend - export DATABASE_URL='postgresql+psycopg://tradein:@localhost:15433/tradein' - -Usage: - uv run python ../scripts/local-sweep-ekb.py # defaults - uv run python ../scripts/local-sweep-ekb.py --pages 3 --detail-top 20 --delay 30 - uv run python ../scripts/local-sweep-ekb.py --skip-search # только detail + houses - uv run python ../scripts/local-sweep-ekb.py --skip-houses # без houses -""" -from __future__ import annotations - -# stdlib -import argparse -import asyncio -import sys -import time -import types -from pathlib import Path -from urllib.parse import urlparse - -# Auto-inject backend/ в sys.path чтобы найти app.* package -# (скрипт лежит в tradein-mvp/scripts/, app — в tradein-mvp/backend/) -_BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend" -if str(_BACKEND_DIR) not in sys.path: - sys.path.insert(0, str(_BACKEND_DIR)) - -# Stub weasyprint (Windows без GTK ломает import chain через trade_in.py) -for _m in ( - "weasyprint", - "weasyprint.text", - "weasyprint.text.ffi", - "weasyprint.css", - "weasyprint.css.targets", -): - sys.modules[_m] = types.ModuleType(_m) -sys.modules["weasyprint"].HTML = type("HTML", (), {"__init__": lambda self, *a, **kw: None}) -sys.modules["weasyprint"].CSS = type("CSS", (), {"__init__": lambda self, *a, **kw: None}) - -# app imports после stubs -from sqlalchemy import text # noqa: E402 - -from app.core.db import SessionLocal # noqa: E402 -from app.services.scrapers.avito import AvitoScraper # noqa: E402 - - -class CityWideAvitoScraper(AvitoScraper): - """Subclass без geo-фильтра — search всего города (ЕКБ) через pagination. - - Override `_build_web_url` чтобы НЕ добавлять `geoCoords`/`radius` — Avito - отдаёт ВСЕ объявления в городе sorted by date, до 100 pages (cap ~5000). - """ - - def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str: # noqa: ARG002 - from urllib.parse import urlencode - params = {"s": 104, "p": page} # s=104 sort by date - return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}" - - -# Avito URL slugs для room-filter (extracted из real search page HTML 2026-05-23) -ROOM_SLUGS: list[tuple[str, str]] = [ - ("студии", "studii-ASgBAgICAkSSA8YQygj~WA"), - ("1-комн.", "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ"), - ("2-комн.", "2-komnatnye-ASgBAgICAkSSA8YQygiCWQ"), - ("3-комн.", "3-komnatnye-ASgBAgICAkSSA8YQygiEWQ"), - ("4-комн.", "4-komnatnye-ASgBAgICAkSSA8YQygiGWQ"), - ("5+комн.", "5-komnatnye-ASgBAgICAkSSA8YQygiIWQ"), - ("своб.планир.", "svobodnaya_planirovka-ASgBAgICAkSSA8YQygj8zzI"), -] - - -class CityRoomsAvitoScraper(AvitoScraper): - """Subclass с URL slug filter по комнатности — для splitting когда total > 5000. - - URL pattern: /ekaterinburg/kvartiry/prodam/?s=104&p=N - """ - - def __init__(self, slug: str) -> None: - super().__init__() - self.room_slug = slug - - def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str: # noqa: ARG002 - from urllib.parse import urlencode - params = {"s": 104, "p": page} - return f"{self.base_url}/ekaterinburg/kvartiry/prodam/{self.room_slug}?{urlencode(params)}" -from app.services.scrapers.avito_detail import ( # noqa: E402 - fetch_detail, - save_detail_enrichment, -) -from app.services.scrapers.avito_houses import ( # noqa: E402 - fetch_house_catalog, - save_house_catalog_enrichment, -) -from app.services.scrapers.base import save_listings # noqa: E402 - -# 10 anchors покрывающих центральные/северные/южные/восточные/западные районы ЕКБ -ANCHORS: list[tuple[str, float, float]] = [ - ("Центр", 56.8400, 60.6050), - ("ЮЗ", 56.7950, 60.5300), - ("Уралмаш", 56.8970, 60.6100), - ("Академический", 56.7700, 60.5500), - ("Пионерский", 56.8650, 60.6200), - ("Эльмаш", 56.8567, 60.6700), - ("Втузгородок", 56.8400, 60.6650), - ("ВИЗ", 56.8500, 60.5650), - ("Юго-Запад", 56.7770, 60.5800), - ("Сортировка", 56.8950, 60.5600), -] - - -async def phase_citywide(pages: int, delay: float) -> int: - """City-wide pagination — ВСЕ объявления ЕКБ (no geo filter), per-page save. - - Per-page save важно: visible прогресс + не теряем данные если crash mid-way. - """ - print( - f"\n=== PHASE CITYWIDE: full ЕКБ pagination ({pages} pages, delay {delay}s) ===\n", - flush=True, - ) - total = 0 - cum_ins = 0 - cum_upd = 0 - async with CityWideAvitoScraper() as s: - for page in range(1, pages + 1): - url = s._build_web_url(0.0, 0.0, 1, page=page) - try: - assert s._cffi is not None - response = await s._cffi.get(url) - if response.status_code != 200: - print(f" ⚠️ page={page} HTTP {response.status_code} — stop", flush=True) - break - lots = s._parse_html(response.text, source_url_base=url) - except Exception as e: # noqa: BLE001 - print(f" page={page} FAIL fetch: {type(e).__name__}: {e}", flush=True) - await asyncio.sleep(delay) - continue - if not lots: - print(f" page={page}: 0 lots — end of pagination", flush=True) - break - db = SessionLocal() - try: - ins, upd = save_listings(db, lots) - cum_ins += ins - cum_upd += upd - total += len(lots) - print( - f" page={page}/{pages}: {len(lots)} lots → ins={ins} upd={upd} " - f"[cum: total={total} new={cum_ins} upd={cum_upd}]", - flush=True, - ) - except Exception as e: # noqa: BLE001 - print(f" page={page} FAIL save: {type(e).__name__}: {e}", flush=True) - finally: - db.close() - if page < pages: - await asyncio.sleep(delay) - print(f" PHASE CITYWIDE TOTAL: fetched={total} ins={cum_ins} upd={cum_upd}", flush=True) - return total - - -async def phase_byrooms(pages: int, delay: float) -> int: - """Rooms-split sweep — 7 categories × pages, per-page save. - - Для total > 5000 (Avito search cap) split по комнатности позволяет получить - все listings (каждый segment <5000). - """ - print( - f"\n=== PHASE BYROOMS: 7 категорий × {pages} pages, delay {delay}s ===\n", - flush=True, - ) - grand_total = 0 - grand_ins = 0 - grand_upd = 0 - import random - rate_limit_hits = 0 - for ci, (name, slug) in enumerate(ROOM_SLUGS, 1): - print(f"\n--- [{ci}/{len(ROOM_SLUGS)}] Категория: {name} (slug={slug[:30]}...) ---", flush=True) - cat_total = 0 - cat_ins = 0 - cat_upd = 0 - async with CityRoomsAvitoScraper(slug) as s: - for page in range(1, pages + 1): - url = s._build_web_url(0.0, 0.0, 1, page=page) - try: - assert s._cffi is not None - response = await s._cffi.get(url) - if response.status_code == 429: - # Exponential backoff на rate-limit: 60s × 2^N (max 30 min) - rate_limit_hits += 1 - backoff = min(60 * (2 ** (rate_limit_hits - 1)), 1800) - print(f" {name} p={page} HTTP 429 (hit #{rate_limit_hits}) → backoff {backoff}s", flush=True) - await asyncio.sleep(backoff) - # retry same page after backoff - try: - response = await s._cffi.get(url) - if response.status_code != 200: - print(f" {name} p={page} retry HTTP {response.status_code} — skip page", flush=True) - await asyncio.sleep(delay * 2) - continue - except Exception as e: # noqa: BLE001 - print(f" {name} p={page} retry FAIL: {type(e).__name__}: {e}", flush=True) - continue - elif response.status_code != 200: - print(f" {name} p={page} HTTP {response.status_code} — skip page", flush=True) - await asyncio.sleep(delay) - continue - lots = s._parse_html(response.text, source_url_base=url) - except Exception as e: # noqa: BLE001 - print(f" {name} p={page} FAIL fetch: {type(e).__name__}: {e}", flush=True) - await asyncio.sleep(delay) - continue - if not lots: - print(f" {name} page={page}: 0 lots — end of category", flush=True) - break - db = SessionLocal() - try: - ins, upd = save_listings(db, lots) - cat_ins += ins - cat_upd += upd - cat_total += len(lots) - print( - f" {name} p={page}/{pages}: {len(lots)} lots → ins={ins} upd={upd} " - f"[cat: tot={cat_total} new={cat_ins}]", - flush=True, - ) - except Exception as e: # noqa: BLE001 - print(f" {name} page={page} FAIL save: {type(e).__name__}: {e}", flush=True) - finally: - db.close() - if page < pages: - # Jitter: ±30% от delay чтобы выглядеть менее robotic - jittered = delay * random.uniform(0.7, 1.3) - await asyncio.sleep(jittered) - print( - f" --- {name} TOTAL: fetched={cat_total} ins={cat_ins} upd={cat_upd} ---", - flush=True, - ) - grand_total += cat_total - grand_ins += cat_ins - grand_upd += cat_upd - print( - f"\n PHASE BYROOMS GRAND TOTAL: fetched={grand_total} new={grand_ins} updated={grand_upd}", - flush=True, - ) - return grand_total - - -async def phase_a_search(pages: int, delay: float) -> int: - """Search всех anchors × pages, save listings.""" - print( - f"\n=== PHASE A: search ({len(ANCHORS)} anchors × {pages} pages, " - f"delay {delay}s) ===\n", - flush=True, - ) - total = 0 - for i, (name, lat, lon) in enumerate(ANCHORS, 1): - print(f"[{i}/{len(ANCHORS)}] {name} ({lat},{lon})", flush=True) - try: - async with AvitoScraper() as s: - lots = await s.fetch_around( - lat, lon, 1500, pages=pages, delay_override_sec=delay - ) - except Exception as e: # noqa: BLE001 - print(f" FAIL fetch: {type(e).__name__}: {e}", flush=True) - await asyncio.sleep(delay) - continue - print(f" fetched {len(lots)} lots", flush=True) - if not lots: - print(" ⚠️ 0 lots — possible 429, stop phase A", flush=True) - break - db = SessionLocal() - try: - ins, upd = save_listings(db, lots) - print(f" saved: ins={ins} upd={upd}", flush=True) - total += len(lots) - except Exception as e: # noqa: BLE001 - print(f" FAIL save: {type(e).__name__}: {e}", flush=True) - finally: - db.close() - if i < len(ANCHORS): - await asyncio.sleep(delay) - return total - - -async def phase_b_detail(top_n: int, delay: float) -> tuple[int, set[str]]: - """Detail enrichment для top-N recent listings без detail. - - Returns: (enriched_count, set of unique house_catalog_url paths extracted) - """ - print( - f"\n=== PHASE B: detail enrichment (top {top_n}, delay {delay}s) ===\n", - flush=True, - ) - db = SessionLocal() - try: - rows = db.execute( - text( - """ - SELECT source_url, source_id FROM listings - WHERE source='avito' AND detail_enriched_at IS NULL - AND source_url IS NOT NULL AND price_rub > 0 - ORDER BY scraped_at DESC NULLS LAST - LIMIT :limit - """ - ), - {"limit": top_n}, - ).mappings().all() - finally: - db.close() - - print(f" queue: {len(rows)} listings to enrich", flush=True) - enriched = 0 - failed = 0 - house_urls: set[str] = set() - for i, row in enumerate(rows, 1): - url = row["source_url"] - path = urlparse(url).path if url.startswith("http") else url - print(f"[{i}/{len(rows)}] detail {row['source_id']}", flush=True) - try: - enr = await fetch_detail(path) - db = SessionLocal() - try: - ok = save_detail_enrichment(db, enr) - print( - f" enriched: ok={ok} " - f"rooms={enr.rooms} area={enr.area_m2} " - f"kitchen={enr.kitchen_area_m2} owners={enr.owners_count} " - f"house_url={enr.house_catalog_url}", - flush=True, - ) - if ok: - enriched += 1 - # Извлекаем house path (strip query string + filter только /catalog/houses/) - if enr.house_catalog_url: - p = urlparse(enr.house_catalog_url).path - if p.startswith("/catalog/houses/"): - house_urls.add(p) - finally: - db.close() - except Exception as e: # noqa: BLE001 - print(f" FAIL: {type(e).__name__}: {e}", flush=True) - failed += 1 - if i < len(rows): - await asyncio.sleep(delay) - print(f" total: enriched={enriched} failed={failed} unique_houses_extracted={len(house_urls)}", flush=True) - return enriched, house_urls - - -async def phase_c_houses(delay: float, extra_urls: set[str] | None = None) -> int: - """Houses enrichment — unique house_url из listings (DB) + extra_urls (из Phase B in-memory). - - save_detail_enrichment не пишет house_url в listings (баг Stage 2b), - поэтому Phase C принимает extra_urls собранные Phase B вручную. - """ - print("\n=== PHASE C: houses enrichment ===\n", flush=True) - db = SessionLocal() - try: - rows = db.execute( - text( - """ - SELECT DISTINCT house_url FROM listings - WHERE source='avito' AND house_url IS NOT NULL - AND house_id_fk IS NULL - ORDER BY house_url - """ - ) - ).fetchall() - finally: - db.close() - - urls_from_db = {r[0] for r in rows if r[0]} - urls = urls_from_db | (extra_urls or set()) - print(f" queue: {len(urls)} unique houses (db={len(urls_from_db)} +extras={len(extra_urls or set())})", flush=True) - if not urls: - print(" no houses to enrich (skip)", flush=True) - return 0 - - enriched = 0 - failed = 0 - for i, url in enumerate(sorted(urls), 1): - path = urlparse(url).path if url.startswith("http") else url - print(f"[{i}/{len(urls)}] {path}", flush=True) - try: - enr = await fetch_house_catalog(path) - db = SessionLocal() - try: - counters = save_house_catalog_enrichment(db, enr) - print(f" saved: {counters}", flush=True) - enriched += 1 - finally: - db.close() - except Exception as e: # noqa: BLE001 - print(f" FAIL: {type(e).__name__}: {e}", flush=True) - failed += 1 - if i < len(urls): - await asyncio.sleep(delay) - print(f" total: enriched={enriched} failed={failed}", flush=True) - return enriched - - -async def main() -> None: - p = argparse.ArgumentParser() - p.add_argument( - "--mode", - choices=["anchors", "citywide", "byrooms", "both"], - default="anchors", - help="anchors=10 anchors; citywide=full pagination без geo; byrooms=7 категорий комнат (для total>5000); both=anchors+citywide", - ) - p.add_argument("--pages", type=int, default=5, help="pages per anchor (anchors mode) или total pages (citywide)") - p.add_argument("--detail-top", type=int, default=50, help="top-N для detail enrichment") - p.add_argument("--delay", type=float, default=30, help="sec между HTTP requests") - p.add_argument("--skip-search", action="store_true") - p.add_argument("--skip-detail", action="store_true") - p.add_argument("--skip-houses", action="store_true") - args = p.parse_args() - - t0 = time.perf_counter() - if not args.skip_search: - if args.mode in ("anchors", "both"): - await phase_a_search(args.pages, args.delay) - if args.mode in ("citywide", "both"): - cw_pages = args.pages if args.mode == "citywide" else 100 - await phase_citywide(cw_pages, args.delay) - if args.mode == "byrooms": - await phase_byrooms(args.pages, args.delay) - extracted_house_urls: set[str] = set() - if not args.skip_detail: - _, extracted_house_urls = await phase_b_detail(args.detail_top, args.delay) - if not args.skip_houses: - await phase_c_houses(args.delay, extra_urls=extracted_house_urls) - elapsed = time.perf_counter() - t0 - print(f"\n=== ALL DONE in {elapsed / 60:.1f} min ===", flush=True) - - -if __name__ == "__main__": - asyncio.run(main())