From df5e9003df0bb6d086029c062b3b704b14e3dab4 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 2 Jul 2026 15:58:00 +0000 Subject: [PATCH] feat(scraper-kit): copy avito providers with protocol injection, strangler (#2133 avito) --- .../test_scraper_kit_avito_golden_parity.py | 206 ++ .../packages/scraper-kit/pyproject.toml | 1 + .../scraper-kit/src/scraper_kit/geo.py | 33 + .../src/scraper_kit/providers/__init__.py | 7 + .../scraper_kit/providers/avito/__init__.py | 15 + .../src/scraper_kit/providers/avito/detail.py | 1077 ++++++++ .../src/scraper_kit/providers/avito/houses.py | 1320 ++++++++++ .../src/scraper_kit/providers/avito/imv.py | 853 +++++++ .../src/scraper_kit/providers/avito/serp.py | 2181 +++++++++++++++++ .../src/scraper_kit/providers/avito/shared.py | 79 + tradein-mvp/uv.lock | 2 + 11 files changed, 5774 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_scraper_kit_avito_golden_parity.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/__init__.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/shared.py diff --git a/tradein-mvp/backend/tests/test_scraper_kit_avito_golden_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_avito_golden_parity.py new file mode 100644 index 00000000..de2bea71 --- /dev/null +++ b/tradein-mvp/backend/tests/test_scraper_kit_avito_golden_parity.py @@ -0,0 +1,206 @@ +"""Golden-parity: `scraper_kit.providers.avito.*` парсинг ≡ `app.services.scrapers.avito*`. + +Strangler-инвариант (#2133): новая scraper_kit-копия avito-провайдера должна давать +БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. +Развязка (settings→ScraperConfig, get_scraper_delay→delay_provider, +geocoder→scraper_kit.geo, matcher→HouseMatcher) НЕ меняет распарсенные данные. + +Гоняем ОБА модуля на одних фикстурах и сравниваем результат: + - SERP `_parse_html` (DOM-карточки → list[ScrapedLot]) на avito_serp_sample.html, + при ekb_only=False и ekb_only=True (фильтр padding-карточек); + - `_extract_total_count` (счётчик выдачи); + - `_build_sort_timestamp_map` + `_unix_to_date` (sortTimeStamp → date, MSK); + - detail `parse_detail_html` (item-view HTML → DetailEnrichment); + - чистые title/address-хелперы (_clean_address, _extract_rooms/area/floor_from_title). + +Идентичность результата = kit-парсер верно скопирован (развязка не задела логику). +""" + +from __future__ import annotations + +import dataclasses +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +# Старый app.services.scrapers.avito импортирует 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) avito-парсеры — оба в одном +# тесте; ruff сортирует scraper_kit (first-party) выше app-импортов. +from scraper_kit.providers.avito.detail import parse_detail_html as new_parse_detail +from scraper_kit.providers.avito.serp import AvitoScraper as NewAvitoScraper +from scraper_kit.providers.avito.serp import ( + _build_sort_timestamp_map as new_build_ts_map, +) +from scraper_kit.providers.avito.serp import _clean_address as new_clean_address +from scraper_kit.providers.avito.serp import _extract_area_from_title as new_area +from scraper_kit.providers.avito.serp import _extract_floor_from_title as new_floor +from scraper_kit.providers.avito.serp import _extract_rooms_from_title as new_rooms +from scraper_kit.providers.avito.shared import _unix_to_date as new_unix_to_date + +from app.services.scrapers.avito import AvitoScraper as OldAvitoScraper +from app.services.scrapers.avito import ( + _build_sort_timestamp_map as old_build_ts_map, +) +from app.services.scrapers.avito import _clean_address as old_clean_address +from app.services.scrapers.avito import _extract_area_from_title as old_area +from app.services.scrapers.avito import _extract_floor_from_title as old_floor +from app.services.scrapers.avito import _extract_rooms_from_title as old_rooms +from app.services.scrapers.avito import _unix_to_date as old_unix_to_date +from app.services.scrapers.avito_detail import parse_detail_html as old_parse_detail + +_FIXTURES = Path(__file__).parent / "fixtures" +_SERP_HTML = (_FIXTURES / "avito_serp_sample.html").read_text(encoding="utf-8") + +# Минимальный detail item-view HTML (зеркало test_avito_detail_parse.MINIMAL_HTML). +_DETAIL_HTML = """ + +
№ 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)
+11 990 000 ₽ +
+
+ + +
+Дом +
Отличная квартира в новом ЖК.
+ +""" # noqa: E501 + +_SOURCE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/test_7986882804" + + +def _make_old(ekb_only: bool) -> tuple[OldAvitoScraper, SimpleNamespace]: + """Старый AvitoScraper без DB: patch get_scraper_delay + settings.avito_serp_ekb_only.""" + with patch("app.services.scrapers.avito.get_scraper_delay", return_value=7.0): + scraper = OldAvitoScraper() + # _parse_html читает settings.avito_serp_ekb_only — подменяем на детерминированное. + scraper_settings = SimpleNamespace(avito_serp_ekb_only=ekb_only) + return scraper, scraper_settings + + +def _make_new(ekb_only: bool) -> NewAvitoScraper: + """Новый AvitoScraper с инжектируемым ScraperConfig (duck-typed namespace).""" + config = SimpleNamespace(avito_serp_ekb_only=ekb_only) + return NewAvitoScraper(config=config) + + +def _dump_lots(lots: list[Any]) -> list[dict[str, Any]]: + return [lot.model_dump() for lot in lots] + + +# ── SERP _parse_html parity ────────────────────────────────────────────────── + + +def _run_old_parse_html(html: str, base: str, ekb_only: bool) -> list[Any]: + old, old_settings = _make_old(ekb_only) + with patch("app.services.scrapers.avito.settings", old_settings): + return old._parse_html(html, base) + + +def test_serp_parse_html_parity_ekb_only_false() -> None: + """DOM-карточки → ScrapedLot идентичны (фильтр padding выключен).""" + base = "https://www.avito.ru/ekaterinburg/kvartiry" + old_lots = _run_old_parse_html(_SERP_HTML, base, ekb_only=False) + new_lots = _make_new(ekb_only=False)._parse_html(_SERP_HTML, base) + + assert len(old_lots) == len(new_lots) + assert len(new_lots) > 0, "фикстура должна содержать хотя бы одну карточку" + assert _dump_lots(old_lots) == _dump_lots(new_lots) + + +def test_serp_parse_html_parity_ekb_only_true() -> None: + """С включённым ekb_only-фильтром padding-дроп идентичен.""" + base = "https://www.avito.ru/ekaterinburg/kvartiry" + old_lots = _run_old_parse_html(_SERP_HTML, base, ekb_only=True) + new_lots = _make_new(ekb_only=True)._parse_html(_SERP_HTML, base) + + assert len(old_lots) == len(new_lots) + assert _dump_lots(old_lots) == _dump_lots(new_lots) + + +def test_serp_extract_total_count_parity() -> None: + """_extract_total_count на SERP-фикстуре идентичен.""" + old, _ = _make_old(ekb_only=False) + new = _make_new(ekb_only=False) + assert old._extract_total_count(_SERP_HTML) == new._extract_total_count(_SERP_HTML) + + +def test_build_sort_timestamp_map_parity() -> None: + """_build_sort_timestamp_map (sortTimeStamp → date, MSK) идентичен.""" + assert old_build_ts_map(_SERP_HTML) == new_build_ts_map(_SERP_HTML) + + +def test_unix_to_date_parity() -> None: + """_unix_to_date (MSK) идентичен на репрезентативных epoch-значениях.""" + for ts in [None, 0, 1756077885, 1735689600, 1700000000.0, 1_600_000_000]: + assert old_unix_to_date(ts) == new_unix_to_date(ts) + + +# ── detail parse_detail_html parity ────────────────────────────────────────── + + +def test_parse_detail_html_parity() -> None: + """parse_detail_html → DetailEnrichment идентичен (все поля).""" + old_res = old_parse_detail(_DETAIL_HTML, _SOURCE_URL) + new_res = new_parse_detail(_DETAIL_HTML, _SOURCE_URL) + assert dataclasses.asdict(old_res) == dataclasses.asdict(new_res) + + +# ── чистые title/address-хелперы ────────────────────────────────────────────── + + +def test_pure_title_helpers_parity() -> None: + titles = [ + "3-к. квартира, 75 м², 20/26 эт.", + "Квартира-студия, 24,5 м², 12/25 эт.", + "1-к. квартира, 38 м², 5/9 эт.", + "2-к. квартира, 52,3 м², 7/16 эт.", + "Комната, 18 м², 3/5 эт.", + ] + for t in titles: + assert old_rooms(t) == new_rooms(t) + assert old_area(t) == new_area(t) + assert old_floor(t) == new_floor(t) + + +def test_clean_address_parity() -> None: + addrs = [ + None, + "Екатеринбург, ул. Постовского, 17А", + "Свердловская область, Екатеринбург, Малышева, 51", + " Екатеринбург, Ленина 1 ", + ] + for a in addrs: + assert old_clean_address(a) == new_clean_address(a) + + +# ── strangler-guard: kit-провайдер не импортирует app.* ────────────────────── + + +def test_kit_avito_has_no_app_imports() -> None: + """Ни один модуль scraper_kit.providers.avito.* не импортирует app.* (развязка).""" + import inspect + + from scraper_kit.providers.avito import detail, houses, imv, serp, shared + + for mod in (serp, detail, houses, imv, shared): + source = inspect.getsource(mod) + for line in source.splitlines(): + stripped = line.strip() + assert not stripped.startswith("from app"), f"{mod.__name__}: {line!r}" + assert not stripped.startswith("import app"), f"{mod.__name__}: {line!r}" diff --git a/tradein-mvp/packages/scraper-kit/pyproject.toml b/tradein-mvp/packages/scraper-kit/pyproject.toml index 8f670152..494e89d8 100644 --- a/tradein-mvp/packages/scraper-kit/pyproject.toml +++ b/tradein-mvp/packages/scraper-kit/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "httpx[socks]>=0.27.0", "tenacity>=9.0.0", "selectolax>=0.3.0", + "curl-cffi>=0.7.0", ] [build-system] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py new file mode 100644 index 00000000..ff5d661b --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py @@ -0,0 +1,33 @@ +"""Геометрические хелперы scraper_kit — чистая математика, без `app.*` / БД. + +Strangler-копия (#2133) bbox-констант/предикатов из `app.services.geocoder`, +используемых скрапперами как ingest-guard (валидация координат detail-страниц +перед записью). Здесь — только чистые функции; продуктовый geocoder (Nominatim / +Yandex запросы, БД) НЕ копируется. +""" + +from __future__ import annotations + +# ЕКБ bbox = (lat_min, lat_max, lon_min, lon_max). +# TIGHT — центр агломерации; WIDE — с приграничьем, но всё ещё отсекает +# Питер/Тюмень/Уфу (#1871). WIDE строго содержит TIGHT. +EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85) +EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9) + + +def is_within_ekb_bbox( + lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT +) -> bool: + """True если (lat, lon) внутри bbox (inclusive). bbox = (lat_min, lat_max, lon_min, lon_max).""" + lat_min, lat_max, lon_min, lon_max = bbox + return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max + + +def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool: + """Ingest-guard: True если координаты в широком ЕКБ-bbox (#1871). + + Используется для валидации координат из avito detail-страниц перед записью в БД. + Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ + (Питер/Тюмень/Уфа). + """ + return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/__init__.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/__init__.py new file mode 100644 index 00000000..8eb67b87 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/__init__.py @@ -0,0 +1,7 @@ +"""Провайдер-специфичные парсеры scraper_kit (avito / cian / yandex / domclick). + +Strangler-копии (#2131) боевых `app.services.scrapers.*`. Развязаны от +`app.*` через Protocol-инжекцию (`scraper_kit.contracts`) и локальные копии чистых +хелперов (`scraper_kit.geo`). Боевой код пока импортирует старые модули; переключение +рантайма — отдельный поздний шаг (не здесь). +""" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py new file mode 100644 index 00000000..5d08b524 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py @@ -0,0 +1,15 @@ +"""Avito-провайдер scraper_kit — strangler-копия `app.services.scrapers.avito*`. + +Модули: + - serp — SERP-sweep ядро (AvitoScraper, price-range walk, DOM-card → ScrapedLot) + - detail — detail-страница объявления (parse_detail_html, координаты, история цен) + - houses — каталог домов Avito (housePlacementHistory) + - imv — Avito IMV-оценка (placementHistory) + - shared — общие константы/хелперы (RUS_MONTHS, _unix_to_date) + +Развязка от `app.*`: + - `app.core.config.settings` → инжектируемый `ScraperConfig` (scraper_kit.contracts) + - `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider` + - `app.services.geocoder.is_within_ekb_bbox_wide` → `scraper_kit.geo` + - `app.services.matching.houses.match_or_create_house` → инжектируемый `HouseMatcher` +""" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py new file mode 100644 index 00000000..17523dc7 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py @@ -0,0 +1,1077 @@ +"""Avito detail page parser — Stage 2b. + +Извлекает 30+ полей из страницы объявления: +- Identity (item_id, title, price, publish_date, views) +- Apartment params (rooms, area, floor, balcony_loggia, room_layout, + bathroom_type, windows_view, repair_state, sale_type, mortgage_available) +- Location (lat, lon, avito_location_id, metro_stations[], address_full) +- House params (house_type, total_floors_house, lifts, concierge, closed_yard, + house_catalog_url) — собираются но НЕ сохраняются в БД (Stage 2c) +- Description +- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date, + encumbrances_clean, registry_match) +- Gallery (photo_urls[]) + +Точка входа для batch-обогащения: + enrichment = await fetch_detail(item_url) + saved = save_detail_enrichment(db, enrichment) +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import random +import re +from dataclasses import dataclass, field +from datetime import date +from typing import TYPE_CHECKING, Any +from urllib.parse import quote, urljoin, urlparse + +from curl_cffi.requests import AsyncSession +from selectolax.parser import HTMLParser, Node +from sqlalchemy import text +from sqlalchemy.orm import Session + +from scraper_kit.avito_exceptions import ( + AvitoBlockedError, + AvitoListingGoneError, + AvitoRateLimitedError, +) +from scraper_kit.geo import is_within_ekb_bbox_wide +from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page +from scraper_kit.providers.avito.shared import RUS_MONTHS +from scraper_kit.repair_state_normalizer import infer_repair_state_from_text + +if TYPE_CHECKING: + from scraper_kit.browser_fetcher import BrowserFetcher + from scraper_kit.contracts import ScraperConfig + +logger = logging.getLogger(__name__) + +AVITO_BASE = "https://www.avito.ru" + +# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET +# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая +# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22 +# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы. +_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote( + "купить квартиру" +) +_AVITO_WARM_YANDEX_URL = "https://yandex.ru/search/?text=" + quote( + "купить квартиру екатеринбург авито" +) +_AVITO_WARM_YANDEX_REFERER = "https://yandex.ru/" + +# Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса +# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси; +# пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape. +# 429 = transient conn-limit: сначала короткий retry на ТОЙ ЖЕ сессии. Но keep-alive +# detail-fetch переиспользует ОДИН backconnect exit-IP на последовательные запросы → +# avito лочит этот IP, и short-retry той же сессии не помогает (run 217: 5 подряд 429, +# abort на 19 enriched). Поэтому при исчерпании short-retry под backconnect делаем ещё +# _AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP). +_AVITO_DETAIL_403_MAX_RETRIES = 5 +_AVITO_DETAIL_403_BACKOFF_SEC = 2.0 +_AVITO_DETAIL_429_MAX_RETRIES = 8 +_AVITO_DETAIL_429_BACKOFF_SEC = 1.5 +_AVITO_DETAIL_429_RECONNECT_RETRIES = 3 + +# ── Regex ──────────────────────────────────────────────────────────────────── +_ITEM_ID_RE = re.compile(r"№\s*(\d+)") +_VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр") +_VIEWS_TODAY_RE = re.compile(r"\+\s*(\d+)\s+сегодня") +_PUBLISH_DATE_RE = re.compile( + r"(\d{1,2})\s+(января|февраля|марта|апреля|мая|июня|" + r"июля|августа|сентября|октября|ноября|декабря)\s+в", + re.IGNORECASE, +) +_FLOAT_RE = re.compile(r"[\d.,]+") +_INT_RE = re.compile(r"\d+") +_FLOOR_SPLIT_RE = re.compile(r"(\d+)\s+из\s+(\d+)") + +OWNERS_RE = re.compile(r"^(\d+)\s+собственник(?:\s+или\s+больше)?", re.IGNORECASE) +OWNER_CHANGE_RE = re.compile(r"смена собственника\s+(\d+)\s+(\w+)\s+(\d{4})", re.IGNORECASE) +ENCUMBRANCES_RE = re.compile(r"Не\s+найдены\s+ограничения", re.IGNORECASE) +REGISTRY_MATCH_RE = re.compile(r"Совпадают\s+площадь", re.IGNORECASE) + +METRO_RE = re.compile( + r"([А-ЯЁ][а-яё]+(?:ская|инская|итская|овская|енская))\s*" + r"(?:(\d+)[–\-](\d+)|от\s+(\d+))\s*мин", + re.IGNORECASE, +) + +# ── Маппинг RUS полей params-блока ─────────────────────────────────────────── +# (rus_label) -> (field_name, conversion_type) +RUS_FIELD_MAP: dict[str, tuple[str, str | None]] = { + "Количество комнат": ("rooms", "int"), + "Общая площадь": ("area_m2", "float_m2"), + "Площадь кухни": ("kitchen_area_m2", "float_m2"), + "Жилая площадь": ("living_area_m2", "float_m2"), + "Высота потолков": ("ceiling_height_m", "height_m"), + "Отделка": ("finishing", "text_lower"), + "Этаж": ("floor_split", None), + "Балкон или лоджия": ("balcony_loggia", "balcony"), + "Тип комнат": ("room_layout", "layout"), + "Санузел": ("bathroom_type", "bathroom"), + "Окна": ("windows_view", "windows"), + "Ремонт": ("repair_state", "repair"), + "Способ продажи": ("sale_type", "sale_type"), + "Условия продажи": ("sale_conditions_raw", None), + "Тип дома": ("house_type", "house_type"), + "Этажей в доме": ("total_floors_house", "int"), + "Пассажирский лифт": ("passenger_elevators", "int"), + "Грузовой лифт": ("cargo_elevators", "int"), + "В доме": ("in_house_features_raw", None), + "Двор": ("yard_features_raw", None), +} + +# ── Enum-маппинги ───────────────────────────────────────────────────────────── +_BALCONY_MAP: dict[str, str] = { + "балкон": "balcony", + "лоджия": "loggia", + "балкон, лоджия": "both", + "лоджия, балкон": "both", + "нет": "none", +} + +_LAYOUT_MAP: dict[str, str] = { + "изолированные": "isolated", + "смежные": "adjacent", + "смешанные": "mixed", +} + +_BATHROOM_MAP: dict[str, str] = { + "совмещенный": "combined", + "раздельный": "separate", + "совмещенный, раздельный": "both", + "раздельный, совмещенный": "both", +} + +_WINDOWS_MAP: dict[str, str] = { + "во двор": "yard", + "на улицу": "street", + "во двор и на улицу": "both", + "на улицу и во двор": "both", + "в парк": "park", +} + +_REPAIR_MAP: dict[str, str] = { + # Русские значения из Avito HTML → каноничный enum (needs_repair/standard/good/excellent) + # Маппинг выровнен по repair_state_normalizer.normalize_repair_state(). + "косметический": "standard", + "евро": "good", + "дизайнерский": "excellent", + "требуется": "needs_repair", + "без ремонта": "needs_repair", +} + +_SALE_TYPE_MAP: dict[str, str] = { + "свободная": "free", + "альтернатива": "alternative", + "аукцион": "auction", + "переуступка": "assignment", +} + +_HOUSE_TYPE_MAP: dict[str, str] = { + "монолитный": "monolith", + "панельный": "panel", + "кирпичный": "brick", + "монолитно-кирпичный": "monolith_brick", + "блочный": "block", + "деревянный": "wood", +} + + +# ── DetailEnrichment dataclass ──────────────────────────────────────────────── +@dataclass +class DetailEnrichment: + """Результат парсинга detail page Avito. + + Содержит 30+ полей из страницы объявления. + save_detail_enrichment() пишет подмножество в listings (house-level поля пропускаются + — они canonical из Houses Catalog Stage 2c). + """ + + # Identity + item_id: str + source_url: str + title: str | None = None + price_rub: int | None = None + publish_date: date | None = None + views_total: int | None = None + views_today: int | None = None + + # Apartment params + rooms: int | None = None + area_m2: float | None = None + kitchen_area_m2: float | None = None + living_area_m2: float | None = None + ceiling_height_m: float | None = None + finishing: str | None = None + floor: int | None = None + total_floors: int | None = None + balcony_loggia: str | None = None + room_layout: str | None = None + bathroom_type: str | None = None + windows_view: str | None = None + repair_state: str | None = None + sale_type: str | None = None + mortgage_available: bool | None = None + + # House params (собираем, но НЕ сохраняем в listings — Stage 2c) + house_type: str | None = None + total_floors_house: int | None = None + passenger_elevators: int | None = None + cargo_elevators: int | None = None + has_concierge: bool | None = None + closed_yard: bool | None = None + house_catalog_url: str | None = None + + # Location + address_full: str | None = None + lat: float | None = None + lon: float | None = None + avito_location_id: int | None = None + metro_stations: list[dict[str, Any]] = field(default_factory=list) + + # Description + description: str | None = None + + # Domoteka (5 полей) + owners_count: int | None = None + owners_at_least: bool | None = None + last_owner_change_date: date | None = None + encumbrances_clean: bool | None = None + registry_match: bool | None = None + + # Gallery + photo_urls: list[str] = field(default_factory=list) + + # Raw для retrofit / debug + raw_html_meta: dict[str, Any] = field(default_factory=dict) + + +def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession: + """Создать curl_cffi-сессию для detail-fetch (impersonate chrome120, через прокси + если задан config.scraper_proxy_url). + + Вынесено из own-session-ветки fetch_detail чтобы reconnect-retry мог пересоздать + эфемерную сессию (новый CONNECT-туннель = свежий backconnect exit-IP) под 403. + proxy=None → прямое подключение. + + Strangler-инжекция (#2133): прокси приходит из инжектируемого `ScraperConfig` + (scraper_kit.contracts) вместо прямого импорта app.core.config.settings. + """ + proxy_url = config.scraper_proxy_url if config is not None else None + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + return AsyncSession( + impersonate="chrome120", + timeout=25, + 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", + "Cache-Control": "max-age=0", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", + }, + ) + + +async def warm_up_session(session: AsyncSession) -> bool: + """Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search + (Referer=yandex). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов + без 403. Возвращает True если search-страница загрузилась (не firewall). + """ + try: + await session.get(_AVITO_WARM_YANDEX_URL) # органический referer-источник, best-effort + except Exception as exc: + logger.debug("avito warm-up: yandex GET failed (non-fatal): %s", exc) + await asyncio.sleep(random.uniform(2.0, 3.5)) + try: + r = await session.get( + _AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER} + ) + except Exception as exc: + logger.warning("avito warm-up: search fetch error: %s", exc) + return False + ok = r.status_code == 200 and not _is_firewall_page(r.text) + if not ok: + logger.warning("avito warm-up: search page blocked sc=%s", r.status_code) + return ok + + +async def research_in_session(session: AsyncSession) -> bool: + """Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=yandex) на + ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort (search-страница + иногда 429, это не ломает detail-фетчи). Возвращает True если страница не firewall. + """ + try: + r = await session.get( + _AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER} + ) + except Exception as exc: + logger.warning("avito re-search: fetch error: %s", exc) + return False + ok = r.status_code == 200 and not _is_firewall_page(r.text) + if not ok: + logger.info("avito re-search: search page sc=%s (non-fatal)", r.status_code) + return ok + + +async def build_warmed_session() -> AsyncSession: + """Построить detail-сессию (_build_detail_session) и прогреть её. Возвращает + прогретую сессию (даже если warm-up soft-failed — caller может всё равно пробовать). + """ + session = _build_detail_session() + await warm_up_session(session) + return session + + +# ── Soft-block detection (#1967 / #1950) ────────────────────────────────────── +# Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ +# витриной (Авито — Объявления на сайте Авито) вместо запрошенного +# объявления — мягкий анти-бот deflect. У такой страницы НЕТ ни firewall-маркеров +# (_is_firewall_page=False), ни item-view → parse_detail_html падал +# ValueError("Cannot extract item_id") → generic failed, IP НЕ ротировался, enriched +# каскадно падал к 0 (прод runs 431→437: 115→82→8→2→1→0 при blocked=0). Детектим +# как блок → reconnect свежей exit-IP сессией / loop-ротация. +_DETAIL_SOFT_BLOCK_TITLE_MARKERS = ("объявления на сайте авито",) + + +def _is_detail_soft_block(html: str) -> bool: + """True если detail-fetch вернул HTTP 200, но это анти-бот deflect — общая + витрина Авито вместо объявления. + + Сигнатура строго специфична deflect-витрине, чтобы НЕ задевать легитимные + не-блок ответы: + * нормальное объявление содержит item-view-маркеры → False; + * снятое объявление Avito рендерит как item-view + баннер «снято» → False; + * 404 / не-200 обрабатывается выше (ValueError → failed), сюда не попадает. + Положительный сигнал: НЕТ item-view-структуры И generic-витринный . + """ + if "item-view/item-id" in html or "item-view/title-info" in html: + return False + head = html[:16384].lower() + return any(m in head for m in _DETAIL_SOFT_BLOCK_TITLE_MARKERS) + + +def _is_detail_not_found(html: str) -> bool: + """True if the page is Avito's 404 / removed-listing page (not an anti-bot deflect). + Guard: a real listing has item-view → False. Keyed on the specific 404 title so it + does NOT catch the soft-block deflect ("объявления на сайте авито" without 404 text).""" + if "item-view/item-id" in html or "item-view/title-info" in html: + return False + head = html[:16384].lower() + return "страница не найдена" in head or "ошибка 404" in head + + +# ── fetch_detail ────────────────────────────────────────────────────────────── +async def fetch_detail( + item_url: str, + *, + cffi_session: AsyncSession | None = None, + browser_fetcher: BrowserFetcher | None = None, + referer: str | None = None, + reconnect_on_block: bool = True, + config: ScraperConfig | None = None, +) -> DetailEnrichment: + """GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment. + + Если browser_fetcher передан — использует браузерный fetch (browser mode). + Если cffi_session не передана — создаёт новую (impersonate='chrome120'). + referer — если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551: + referer = URL avito-search страницы, на которой прогрелась сессия). + reconnect_on_block — если False, 403/firewall поднимает AvitoBlockedError СРАЗУ + (без холодных reconnect'ов — ими займётся outer-loop через rebuild+rewarm прогретой + сессии); 429 после short-retry поднимает AvitoRateLimitedError. Дефолт True + сохраняет старое поведение own-session / scrape_pipeline путей. + Raises: + httpx.HTTPError — если status != 200 (через raise_for_status-like). + ValueError — если item_id не извлечён из HTML. + """ + if browser_fetcher is not None: + # Browser-only режим (#1814): когда передан browser_fetcher, fetch идёт + # СТРОГО через camoufox-сервис. Никакой curl_cffi-сессии и никакого + # curl-fallback — avito-антибот банит curl-фингерпринт под нагрузкой + # (429 CONNECT tunnel failed), а browser проходит. Любой firewall/ошибка + # браузера → AvitoBlockedError; caller (backfill) пометит failed и пойдёт + # дальше, НЕ трогая curl. + full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url) + try: + html = await browser_fetcher.fetch(full_url) + except (AvitoBlockedError, AvitoRateLimitedError): + raise + except Exception as exc: + # browser-сервис вернул HTTPError / ConnectError / 502 (firewall, dead + # listing, redirect). НЕ откатываемся в curl — поднимаем block, чтобы + # caller пометил listing и продолжил без curl-фингерпринта. + raise AvitoBlockedError( + f"Avito detail browser fetch failed for {full_url}: {exc}" + ) from exc + # 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без + # item-view. Это НЕ анти-бот deflect — отдельный сигнал, чтобы backfill + # пометил листинг is_active=FALSE и НЕ крутил consecutive-block breaker + # (run 458: 5 dead подряд → abort до live-листингов). Проверяем ДО + # firewall/soft-block (иначе 404-страница ловится как soft-block). + if _is_detail_not_found(html): + raise AvitoListingGoneError( + f"Avito detail 404/listing-gone (browser-mode) for {full_url}" + ) + if _is_firewall_page(html) or _is_detail_soft_block(html): + raise AvitoBlockedError( + f"Avito detail firewall/soft-block (browser-mode) for {full_url}" + ) + return parse_detail_html(html, full_url) + + own_session = cffi_session is None + if cffi_session is None: + # Mobile proxy wiring (#806 follow-up): own-session path (no shared session passed, + # e.g. admin endpoint). Caller-provided sessions (scrape_pipeline) already have proxy + # from AvitoScraper.__aenter__ — don't override. proxy=None → прямое подключение. + session = _build_detail_session(config) + else: + session = cffi_session + + full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url) + # curl_cffi egress всегда через scraper_proxy_url (backconnect mproxy). 403/firewall = + # залочен лишь текущий exit-IP → reconnect новой сессией = свежий IP. Гейтим reconnect + # ТОЛЬКО на наличие proxy (зеркало SERP-фикса #1765). Без proxy — старое поведение + # (raise на первом 403). Caller-provided сессию НЕ трогаем: reconnect использует + # эфемерную retry_session, которую сами закрываем в finally. + # reconnect_on_block=False (warm-batch #1551): не делаем холодных per-card + # reconnect'ов — блок поднимает AvitoBlockedError сразу, outer-loop пересоздаёт+ + # прогревает сессию (свежий exit-IP). reconnect_on_block=True (own-session / + # scrape_pipeline) — старое поведение: backconnect-reconnect эфемерной сессией. + backconnect = ( + bool(config.scraper_proxy_url if config is not None else None) and reconnect_on_block + ) + req_headers = {"Referer": referer} if referer else None + r403 = 0 + r429 = 0 + r429_reconnect = 0 + attempt_session = session + retry_session: AsyncSession | None = None + try: + while True: + response = await attempt_session.get(full_url, headers=req_headers) + sc = response.status_code + is_firewall = sc == 200 and ( + _is_firewall_page(response.text) or _is_detail_soft_block(response.text) + ) + + # 429 через backconnect = transient conn-limit, НЕ IP-ban: короткий retry той + # же сессии без reconnect (зеркало SERP). Только при исчерпании — raise. + if sc == 429 and r429 < _AVITO_DETAIL_429_MAX_RETRIES: + r429 += 1 + logger.info( + "avito detail HTTP 429 (backconnect conn-limit) — short retry %d/%d", + r429, + _AVITO_DETAIL_429_MAX_RETRIES, + ) + await asyncio.sleep(_AVITO_DETAIL_429_BACKOFF_SEC) + continue + + if sc == 403 or is_firewall: + if backconnect and r403 < _AVITO_DETAIL_403_MAX_RETRIES: + r403 += 1 + # Эфемерная свежая сессия (новый CONNECT-туннель = свежий exit-IP). + # Caller-provided/own session НЕ пересоздаём — только retry_session. + if retry_session is not None: + try: + await retry_session.close() + except Exception: + pass + retry_session = _build_detail_session() + attempt_session = retry_session + logger.info( + "avito detail 403/firewall/soft-block %s — backconnect reconnect " + "retry %d/%d", + full_url, + r403, + _AVITO_DETAIL_403_MAX_RETRIES, + ) + await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC) + continue + raise AvitoBlockedError(f"Avito detail HTTP 403 for {full_url}") + + if sc == 429: + # short-retry (та же сессия) исчерпан. Под backconnect — ещё несколько + # попыток через эфемерную сессию (свежий exit-IP): keep-alive держал ОДИН + # залоченный IP, новый CONNECT-туннель уходит на другой. Делит ту же + # retry_session-машинерию что и 403-путь (close-prev → rebuild → retry, + # cleanup в finally). Только потом raise. + if backconnect and r429_reconnect < _AVITO_DETAIL_429_RECONNECT_RETRIES: + r429_reconnect += 1 + # Caller-provided/own session НЕ пересоздаём — только retry_session. + if retry_session is not None: + try: + await retry_session.close() + except Exception: + pass + retry_session = _build_detail_session() + attempt_session = retry_session + logger.info( + "avito detail HTTP 429 short-retry exhausted %s — " + "backconnect reconnect retry %d/%d", + full_url, + r429_reconnect, + _AVITO_DETAIL_429_RECONNECT_RETRIES, + ) + await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC) + continue + raise AvitoRateLimitedError(f"Avito detail HTTP 429 for {full_url}") + if sc != 200: + raise ValueError(f"avito detail HTTP {sc} for {full_url}") + return parse_detail_html(response.text, full_url) + finally: + if own_session: + await session.close() + if retry_session is not None and retry_session is not session: + try: + await retry_session.close() + except Exception: + pass + + +# ── parse_detail_html ───────────────────────────────────────────────────────── +def parse_detail_html(html: str, source_url: str) -> DetailEnrichment: + """Pure parser — отдельно для тестов на fixtures без сети. + + Raises: + ValueError — если item_id не извлечён. + """ + tree = HTMLParser(html) + + # ── Identity ──────────────────────────────────────────────────────────── + item_id = _extract_item_id(tree) + if not item_id: + raise ValueError(f"Cannot extract item_id from {source_url}") + + title = _text(tree, "[data-marker='item-view/title-info']") + price_rub = _extract_price(tree) + publish_date, views_total, views_today = _extract_meta(tree) + + # ── Location ──────────────────────────────────────────────────────────── + map_el = tree.css_first("[data-marker='item-map-wrapper']") + lat: float | None = None + lon: float | None = None + avito_location_id: int | None = None + if map_el is not None: + lat = _try_float(map_el.attributes.get("data-map-lat")) + lon = _try_float(map_el.attributes.get("data-map-lon")) + avito_location_id = _try_int(map_el.attributes.get("data-location-id")) + + # bbox-guard (#1871): detail-страница иногда отдаёт координаты вне ЕКБ + # (Питер/Тюмень/Уфа — кросс-региональные дубли объявлений). Эти координаты + # попадают в UPDATE COALESCE без bbox-валидации (в отличие от geocoder.py, + # где bbox-фильтр везде). Сбрасываем в None → листинг уходит в geocode-cron + # путь, который сам отфильтрует по адресу. SERP-ingest координат не даёт вовсе. + if lat is not None and lon is not None and not is_within_ekb_bbox_wide(lat, lon): + logger.warning( + "avito_detail #1871: dropped non-EKB coords item=%s lat=%s lon=%s", + item_id, + lat, + lon, + ) + lat = lon = None + + address_full = _clean_address(_text(tree, "[itemprop='address']")) + + # ── Description ───────────────────────────────────────────────────────── + description = _text(tree, "[data-marker='item-view/item-description']") + + # ── Metro from description ─────────────────────────────────────────────── + metro_stations: list[dict[str, Any]] = [] + if description: + metro_stations = _extract_metro(description) + + # ── House catalog link ─────────────────────────────────────────────────── + # Новостройки отдают кнопку ЖК (nd-jk-details-button); вторичка — ссылку + # «узнать больше о доме» под другим маркером. Fallback на любой /catalog/houses/ + # якорь: verified 2026-05-31 (#871) — эта ссылка есть в SSR detail-страницы, + # даже когда отсутствует в JS-рендеренной SERP-карточке (откуда мы брали 0). + house_catalog_url: str | None = None + house_link = tree.css_first("[data-marker='nd-jk-details-button']") + if house_link is None: + house_link = tree.css_first("a[href*='/catalog/houses/']") + if house_link is not None: + href = house_link.attributes.get("href", "") + if href: + # отбрасываем ?context-трекинг → чистый catalog-путь + house_catalog_url = urljoin(AVITO_BASE, urlparse(href).path) + + # ── Photo gallery ──────────────────────────────────────────────────────── + photo_urls: list[str] = [] + for img in tree.css("[data-marker='image-preview/item'] img"): + src = img.attributes.get("src") or "" + if src and src not in photo_urls: + photo_urls.append(src) + + # ── Params blocks ──────────────────────────────────────────────────────── + params_block = tree.css_first("[data-marker='item-view/item-params']") + apt_params: dict[str, str] = {} + house_params: dict[str, str] = {} + if params_block is not None: + ul_els = params_block.css("ul") + if len(ul_els) >= 1: + apt_params = _parse_params_ul(ul_els[0]) + if len(ul_els) >= 2: + house_params = _parse_params_ul(ul_els[1]) + + # Merge house_params into apt_params — некоторые страницы совмещают всё в одном ul + all_params = {**apt_params, **house_params} + + # ── Parse apartment fields ─────────────────────────────────────────────── + rooms: int | None = None + area_m2: float | None = None + kitchen_area_m2: float | None = None + living_area_m2: float | None = None + ceiling_height_m: float | None = None + finishing: str | None = None + floor: int | None = None + total_floors: int | None = None + balcony_loggia: str | None = None + room_layout: str | None = None + bathroom_type: str | None = None + windows_view: str | None = None + repair_state: str | None = None + sale_type: str | None = None + mortgage_available: bool | None = None + house_type: str | None = None + total_floors_house: int | None = None + passenger_elevators: int | None = None + cargo_elevators: int | None = None + has_concierge: bool | None = None + closed_yard: bool | None = None + + for rus_label, val in all_params.items(): + mapping = RUS_FIELD_MAP.get(rus_label) + if mapping is None: + continue + field_name, _conv = mapping + + if field_name == "rooms": + rooms = _try_int(val) + elif field_name == "area_m2": + area_m2 = _parse_float_m2(val) + elif field_name == "kitchen_area_m2": + kitchen_area_m2 = _parse_float_m2(val) + elif field_name == "living_area_m2": + living_area_m2 = _parse_float_m2(val) + elif field_name == "ceiling_height_m": + ceiling_height_m = _parse_height_m(val) + elif field_name == "finishing": + finishing = val.strip().lower() or None + elif field_name == "floor_split": + floor, total_floors = _parse_floor_split(val) + elif field_name == "balcony_loggia": + balcony_loggia = _map_lower(val, _BALCONY_MAP) + elif field_name == "room_layout": + room_layout = _map_lower(val, _LAYOUT_MAP) + elif field_name == "bathroom_type": + bathroom_type = _map_lower(val, _BATHROOM_MAP) + elif field_name == "windows_view": + windows_view = _map_lower(val, _WINDOWS_MAP) + elif field_name == "repair_state": + repair_state = _map_lower(val, _REPAIR_MAP) + elif field_name == "sale_type": + sale_type = _map_lower(val, _SALE_TYPE_MAP) + elif field_name == "sale_conditions_raw": + if "возможна ипотека" in val.lower(): + mortgage_available = True + elif field_name == "house_type": + house_type = _map_lower(val, _HOUSE_TYPE_MAP) + elif field_name == "total_floors_house": + total_floors_house = _try_int(val) + elif field_name == "passenger_elevators": + passenger_elevators = _try_int(val) + elif field_name == "cargo_elevators": + cargo_elevators = _try_int(val) + elif field_name == "in_house_features_raw": + if "консьерж" in val.lower(): + has_concierge = True + elif field_name == "yard_features_raw": + if "закрытая территория" in val.lower(): + closed_yard = True + + # ── Fallback: repair_state из описания, если структурное поле пустое (#622) ── + if repair_state is None and description: + repair_state = infer_repair_state_from_text(description) + + # ── Domoteka ───────────────────────────────────────────────────────────── + owners_count: int | None = None + owners_at_least: bool | None = None + last_owner_change_date: date | None = None + encumbrances_clean: bool | None = None + registry_match: bool | None = None + + domoteka_block = tree.css_first("[data-marker='domoteka-entry-block']") + if domoteka_block is not None: + badges = domoteka_block.css("p[data-marker='TeaserData.item']") + for badge in badges: + badge_text = badge.text(strip=True) + if not badge_text: + continue + + m_owners = OWNERS_RE.match(badge_text) + if m_owners: + owners_count = int(m_owners.group(1)) + owners_at_least = "или больше" in badge_text.lower() + continue + + m_change = OWNER_CHANGE_RE.search(badge_text) + if m_change: + try: + day = int(m_change.group(1)) + month_word = m_change.group(2).lower() + year = int(m_change.group(3)) + month = RUS_MONTHS.get(month_word) + if month: + last_owner_change_date = date(year, month, day) + except (ValueError, KeyError): + logger.warning("Cannot parse owner change date: %s", badge_text) + continue + + if ENCUMBRANCES_RE.search(badge_text): + encumbrances_clean = True + continue + + if REGISTRY_MATCH_RE.search(badge_text): + registry_match = True + continue + + return DetailEnrichment( + item_id=item_id, + source_url=source_url, + title=title, + price_rub=price_rub, + publish_date=publish_date, + views_total=views_total, + views_today=views_today, + rooms=rooms, + area_m2=area_m2, + kitchen_area_m2=kitchen_area_m2, + living_area_m2=living_area_m2, + ceiling_height_m=ceiling_height_m, + finishing=finishing, + floor=floor, + total_floors=total_floors, + balcony_loggia=balcony_loggia, + room_layout=room_layout, + bathroom_type=bathroom_type, + windows_view=windows_view, + repair_state=repair_state, + sale_type=sale_type, + mortgage_available=mortgage_available, + house_type=house_type, + total_floors_house=total_floors_house, + passenger_elevators=passenger_elevators, + cargo_elevators=cargo_elevators, + has_concierge=has_concierge, + closed_yard=closed_yard, + house_catalog_url=house_catalog_url, + address_full=address_full, + lat=lat, + lon=lon, + avito_location_id=avito_location_id, + metro_stations=metro_stations, + description=description, + owners_count=owners_count, + owners_at_least=owners_at_least, + last_owner_change_date=last_owner_change_date, + encumbrances_clean=encumbrances_clean, + registry_match=registry_match, + photo_urls=photo_urls, + ) + + +# ── save_detail_enrichment ──────────────────────────────────────────────────── +def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool: + """UPDATE listings SET <25+ cols> WHERE source='avito' AND source_id=:item_id. + + Returns True если строка обновлена, False если listing не найден. + Устанавливает detail_enriched_at=NOW(). + + house_type СОХРАНЯЕТСЯ (уже нормализован парсером → канон panel/brick/monolith/ + monolith_brick/block/wood) — нужен estimator soft-penalty по типу дома. + house_catalog_url СОХРАНЯЕТСЯ в listings.house_url (линковка дома); отдельной + house_catalog_url колонки нет. house_type — COALESCE existing-first (не затираем + canonical из Houses Catalog Stage 2c); house_url — new-first additive (avito-owned + поле, нет конкурирующего canonical-писателя). + Остальные house-level поля (passenger_elevators, cargo_elevators, has_concierge, + closed_yard, total_floors_house) по-прежнему опускаются — нет колонок в listings, + canonical приходит из Houses Catalog Stage 2c (отдельный follow-up). + """ + result = db.execute( + text(""" + UPDATE listings SET + kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2), + living_area_m2 = COALESCE(:living_area_m2, living_area_m2), + ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m), + finishing = COALESCE(:finishing, finishing), + balcony_loggia = COALESCE(:balcony_loggia, balcony_loggia), + room_layout = COALESCE(:room_layout, room_layout), + bathroom_type = COALESCE(:bathroom_type, bathroom_type), + windows_view = COALESCE(:windows_view, windows_view), + sale_type = COALESCE(:sale_type, sale_type), + mortgage_available = COALESCE(:mortgage_available, mortgage_available), + views_total = COALESCE(:views_total, views_total), + views_today = COALESCE(:views_today, views_today), + publish_date = COALESCE(:publish_date, publish_date), + description = COALESCE(:description, description), + owners_count = COALESCE(:owners_count, owners_count), + owners_at_least = COALESCE(:owners_at_least, owners_at_least), + last_owner_change_date = COALESCE(:last_owner_change_date, last_owner_change_date), + encumbrances_clean = COALESCE(:encumbrances_clean, encumbrances_clean), + registry_match = COALESCE(:registry_match, registry_match), + metro_stations = COALESCE(CAST(:metro_stations AS jsonb), metro_stations), + avito_location_id = COALESCE(:avito_location_id, avito_location_id), + lat = COALESCE(:lat, lat), + lon = COALESCE(:lon, lon), + address = COALESCE(:address, address), + rooms = COALESCE(:rooms, rooms), + area_m2 = COALESCE(:area_m2, area_m2), + floor = COALESCE(:floor, floor), + total_floors = COALESCE(:total_floors, total_floors), + repair_state = COALESCE(:repair_state, repair_state), + price_rub = COALESCE(:price_rub, price_rub), + house_type = COALESCE(house_type, :house_type), + house_url = COALESCE(:house_catalog_url, house_url), + detail_enriched_at = NOW() + WHERE source = 'avito' AND source_id = :item_id + """), + { + "item_id": e.item_id, + "kitchen_area_m2": e.kitchen_area_m2, + "living_area_m2": e.living_area_m2, + "ceiling_height_m": e.ceiling_height_m, + "finishing": e.finishing, + "balcony_loggia": e.balcony_loggia, + "room_layout": e.room_layout, + "bathroom_type": e.bathroom_type, + "windows_view": e.windows_view, + "sale_type": e.sale_type, + "mortgage_available": e.mortgage_available, + "views_total": e.views_total, + "views_today": e.views_today, + "publish_date": e.publish_date, + "description": e.description, + "owners_count": e.owners_count, + "owners_at_least": e.owners_at_least, + "last_owner_change_date": e.last_owner_change_date, + "encumbrances_clean": e.encumbrances_clean, + "registry_match": e.registry_match, + "metro_stations": ( + json.dumps(e.metro_stations, ensure_ascii=False) if e.metro_stations else None + ), + "avito_location_id": e.avito_location_id, + "lat": e.lat, + "lon": e.lon, + "address": e.address_full, + "rooms": e.rooms, + "area_m2": e.area_m2, + "floor": e.floor, + "total_floors": e.total_floors, + "repair_state": e.repair_state, + "price_rub": e.price_rub, + "house_type": e.house_type, + "house_catalog_url": e.house_catalog_url, + }, + ) + db.commit() + found = result.rowcount > 0 + if not found: + logger.warning("save_detail_enrichment: listing not found in DB for item_id=%s", e.item_id) + else: + logger.info("save_detail_enrichment: updated listing item_id=%s", e.item_id) + return found + + +# ── Internal helpers ───────────────────────────────────────────────────────── +def _text(tree: HTMLParser, selector: str) -> str | None: + """Вернуть stripped text первого совпавшего элемента или None.""" + el = tree.css_first(selector) + if el is None: + return None + t = el.text(strip=True) + return t or None + + +def _try_float(val: Any) -> float | None: + if val is None: + return None + try: + return float(str(val).replace(",", ".")) + except (ValueError, TypeError): + return None + + +def _try_int(val: Any) -> int | None: + if val is None: + return None + s = str(val).strip() + m = _INT_RE.search(s) + if m: + try: + return int(m.group()) + except ValueError: + return None + return None + + +def _parse_float_m2(val: str) -> float | None: + """'90.1 м²' → 90.1. '75 м²' → 75.0.""" + m = _FLOAT_RE.search(val) + if m: + try: + return float(m.group().replace(",", ".")) + except ValueError: + return None + return None + + +def _parse_height_m(val: str) -> float | None: + """'2.7 м' / '2,7\xa0м' / '3 м' → float метры. Junk → None.""" + m = _FLOAT_RE.search(val) + if m: + try: + return float(m.group().replace(",", ".")) + except ValueError: + return None + return None + + +def _parse_floor_split(val: str) -> tuple[int | None, int | None]: + """'20 из 26' → (20, 26).""" + m = _FLOOR_SPLIT_RE.search(val) + if m: + return _try_int(m.group(1)), _try_int(m.group(2)) + return None, None + + +def _map_lower(val: str, mapping: dict[str, str]) -> str | None: + """Нижний регистр + strip → lookup в mapping. None если не найдено.""" + key = val.strip().lower() + return mapping.get(key) + + +def _extract_item_id(tree: HTMLParser) -> str | None: + """Извлечь item_id из [data-marker='item-view/item-id'] text.""" + el = tree.css_first("[data-marker='item-view/item-id']") + if el is None: + return None + text_val = el.text(strip=True) + m = _ITEM_ID_RE.search(text_val) + return m.group(1) if m else None + + +def _extract_price(tree: HTMLParser) -> int | None: + """Извлечь price из [itemprop='price'] content attribute.""" + el = tree.css_first("[itemprop='price']") + if el is None: + return None + content = el.attributes.get("content") + return _try_int(content) + + +def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None]: + """Извлечь publish_date, views_total, views_today из item-id блока.""" + el = tree.css_first("[data-marker='item-view/item-id']") + if el is None: + return None, None, None + + # Поиск по всему тексту включая дочерние элементы + full_text = el.text(strip=False) + + publish_date: date | None = None + m_date = _PUBLISH_DATE_RE.search(full_text) + if m_date: + try: + day = int(m_date.group(1)) + month_word = m_date.group(2).lower() + month = RUS_MONTHS.get(month_word) + # Год — текущий (Avito не показывает год для свежих объявлений) + import datetime + + current_year = datetime.date.today().year + if month: + publish_date = date(current_year, month, day) + except (ValueError, KeyError): + pass + + views_total: int | None = None + m_total = _VIEWS_TOTAL_RE.search(full_text) + if m_total: + views_total = int(m_total.group(1)) + + views_today: int | None = None + m_today = _VIEWS_TODAY_RE.search(full_text) + if m_today: + views_today = int(m_today.group(1)) + + # Попробуем найти views в отдельных data-marker элементах (новый Avito layout) + if views_total is None: + v_el = tree.css_first("[data-marker='item-view/total-views']") + if v_el is not None: + m = _VIEWS_TOTAL_RE.search(v_el.text(strip=True)) + if m: + views_total = int(m.group(1)) + + if views_today is None: + t_el = tree.css_first("[data-marker='item-view/today-views']") + if t_el is not None: + m = _VIEWS_TODAY_RE.search(t_el.text(strip=True)) + if m: + views_today = int(m.group(1)) + + return publish_date, views_total, views_today + + +def _parse_params_ul(ul: Node) -> dict[str, str]: + """Парсим <ul> с <li>Label: Value</li> → {label: value} dict.""" + result: dict[str, str] = {} + for li in ul.css("li"): + text_val = li.text(strip=True) + if ":" in text_val: + label, _, value = text_val.partition(":") + label = label.strip() + value = value.strip() + if label and value: + result[label] = value + return result + + +def _extract_metro(text_val: str) -> list[dict[str, Any]]: + """Извлечь станции метро из текста описания. + + Примеры: 'Чкаловская 11-15 мин пешком', 'от 5 мин пешком'. + """ + stations: list[dict[str, Any]] = [] + for m in METRO_RE.finditer(text_val): + name = m.group(1) + if m.group(2) and m.group(3): + # диапазон: 11-15 + min_from = int(m.group(2)) + min_to = int(m.group(3)) + elif m.group(4): + # "от N мин" + min_from = None + min_to = int(m.group(4)) + else: + continue + + stations.append( + { + "name": name, + "min_to": min_to, + "min_from": min_from, + "mode": "walk", + } + ) + return stations diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py new file mode 100644 index 00000000..714235a7 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py @@ -0,0 +1,1320 @@ +"""avito_houses.py — Avito Houses Catalog parser (Stage 2c). + +Парсит страницу /catalog/houses/<slug>/<id>, извлекает 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 scraper_kit.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError +from scraper_kit.providers.avito.serp import _is_firewall_page +from scraper_kit.providers.avito.shared import RUS_MONTHS, _unix_to_date + +if TYPE_CHECKING: + from scraper_kit.browser_fetcher import BrowserFetcher + from scraper_kit.contracts import HouseMatcher + +logger = logging.getLogger(__name__) + +AVITO_BASE = "https://www.avito.ru" + +# Regex для извлечения __preloadedState__ из HTML. +# +# Avito (≈2026) сменил формат state-блоба: раньше был +# window.__preloadedState__ = JSON.parse("<js-escaped json>"); +# теперь — 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"), +} + +# 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", +} + + +# --------------------------------------------------------------------------- +# 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/... + 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 _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) + # 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"), + 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"), + 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 (.../<slug>/<id>). 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, *, matcher: HouseMatcher) -> int: + """Match-or-create канонический дом через matcher, затем обновить enrichment. + + Source='avito'. Возвращает houses.id. + Делегирует в _persist_house (без url — url пуст как раньше для этого пути). + `matcher` — инжектируемый `HouseMatcher` (scraper_kit.contracts, #2133) вместо + прямого импорта app.services.matching.houses.match_or_create_house. + + Маппинг полей 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, "", matcher=matcher) + + +def _upsert_house_with_url( + db: Session, h: HouseInfo, house_url: str, *, matcher: HouseMatcher +) -> int: + """Внутренний вариант upsert_house с правильным URL. + + Делегирует в _persist_house — используй его вместо прямого вызова. + """ + return _persist_house(db, h, house_url, matcher=matcher) + + +def _persist_house(db: Session, h: HouseInfo, house_url: str, *, matcher: HouseMatcher) -> int: + """Канонический путь сохранения дома-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/кадастра. + 3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment + (advisory lock xact-scoped, должен держаться до commit). + + Returns: + houses.id (int) канонического дома. + """ + house_id, _conf, _method = matcher.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, + ) + + 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), + 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, + "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, + *, + matcher: HouseMatcher, +) -> 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, matcher=matcher) + + # 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/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py new file mode 100644 index 00000000..b5c321a7 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py @@ -0,0 +1,853 @@ +"""avito_imv.py — Avito IMV evaluation API client (3-request flow, 2026-05+). + +Stage 2d: geocode + IMV evaluation. + +Flow (current contract, подтверждён живым capture 2026-05 — fixtures +tests/fixtures/avito_imv_geo_position.json + avito_imv_getdata.json): + 0) GET /evaluation/realty → warm-up (seed anti-bot cookies) + 1) GET /web/1/coords/by_address?address=<urlenc> → normalize + lat/lon + 2) POST /js/v2/geo/position → rich JWT (geoFieldsHash) + 3) POST /web/1/realty-imv/get-data → price + placementHistory? + suggestions + +Anti-bot: zerkalim AvitoScraper (avito.py) — chrome120 TLS + document-заголовки + +warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят 403 с server-IP. + +Таблицы: + avito_imv_evaluations (018_avito_imv_evaluations.sql) + house_placement_history (017_house_placement_history.sql) +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import re +from dataclasses import dataclass, field +from datetime import date +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from scraper_kit.providers.avito.shared import _unix_to_date + +if TYPE_CHECKING: + # Импорт под TYPE_CHECKING (как в detail.py/houses.py) — избегаем + # hard import cycle browser_fetcher → config → ... и лишней зависимости в + # offline-тестах парсинга, которым browser не нужен. + from scraper_kit.browser_fetcher import BrowserFetcher + from scraper_kit.contracts import ScraperConfig + +logger = logging.getLogger(__name__) + +AVITO_BASE = "https://www.avito.ru" +COORDS_ENDPOINT = "/web/1/coords/by_address" +POSITION_ENDPOINT = "/js/v2/geo/position" +IMV_ENDPOINT = "/web/1/realty-imv/get-data" +# Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR. +WARMUP_URL = f"{AVITO_BASE}/evaluation/realty" + +# Заголовки document-навигации (warm-up GET) — зеркалят production-набор +# из avito.py (AvitoScraper.__aenter__). Нужны чтобы пройти TLS+header anti-bot. +_DOC_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", + "Cache-Control": "max-age=0", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", +} + +# Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют +# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch. +_COMMON_HEADERS = { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", + "Referer": "https://www.avito.ru/evaluation/realty", + "X-Requested-With": "XMLHttpRequest", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", +} + +# Timeout для всех Avito-вызовов (без него curl_cffi висит на anti-bot stall). +_HTTP_TIMEOUT_SEC = 25 + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class IMVAddressNotFoundError(Exception): + """Raised when /coords/by_address returns no geoHash for given address.""" + + +class IMVCityMismatchError(Exception): + """Raised when locationId не входит в ожидаемый город (sanity check).""" + + +class IMVAuthError(Exception): + """Avito API rejected auth / quota exceeded (HTTP 401 / 403).""" + + +class IMVTransientError(Exception): + """Network / 5xx / timeout — retryable.""" + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class IMVGeo: + geo_hash: str # JWT — не сохраняем как отдельную колонку, только в raw_response + lat: float | None = None + lon: float | None = None + avito_address_id: int | None = None + avito_location_id: int | None = None + avito_metro_id: int | None = None + avito_district_id: int | None = None + + +@dataclass +class IMVPlacementHistoryItem: + ext_item_id: str # str(item['id']) + title: str | None = None + rooms: int | None = None # парсится из title (опционально) + area_m2: float | None = None # парсится из title (опционально) + floor: int | None = None # парсится из title (опционально) + total_floors: int | None = None # парсится из title (опционально) + start_price: int | None = None + start_price_date: date | None = None # unix → date + last_price: int | None = None + last_price_date: date | None = None + removed_date: date | None = None # только IMV API (в widget housePlacementHistory нет) + exposure_days: int | None = None + raw_payload: dict[str, Any] | None = None + + +@dataclass +class IMVSuggestion: + ext_item_id: str + title: str | None = None + address: str | None = None + price_rub: int | None = None + exposure_days: int | None = None + publish_date: date | None = None + item_url: str | None = None + metro_name: str | None = None + metro_distance: str | None = None + metro_color: str | None = None + has_good_price_badge: bool = False + raw_payload: dict[str, Any] | None = None + + +@dataclass +class IMVEvaluation: + cache_key: str # sha256 + address: str + rooms: int + area_m2: float + floor: int + floor_at_home: int + house_type: str + renovation_type: str + has_balcony: bool + has_loggia: bool + + geo: IMVGeo + recommended_price: int + lower_price: int + higher_price: int + market_count: int | None = None + + placement_history: list[IMVPlacementHistoryItem] = field(default_factory=list) + suggestions: list[IMVSuggestion] = field(default_factory=list) + search_similar_url: str | None = None + raw_response: dict[str, Any] | None = None + + +# --------------------------------------------------------------------------- +# Cache key +# --------------------------------------------------------------------------- + + +def compute_imv_cache_key( + address: str, + rooms: int, + area_m2: float, + floor: int, + floor_at_home: int, + house_type: str, + renovation_type: str, + has_balcony: bool, + has_loggia: bool, +) -> str: + """Детерминированный sha256 ключ для 24h-кэша IMV оценки.""" + key_src = ( + f"{address}|{rooms}|{area_m2}|{floor}|{floor_at_home}" + f"|{house_type}|{renovation_type}|{int(has_balcony)}|{int(has_loggia)}" + ) + return hashlib.sha256(key_src.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# Parse helpers +# --------------------------------------------------------------------------- + + +# Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт." +_TITLE_RE = re.compile( + r"^(?P<rooms>\d+)-к[.\s].*?(?P<area>[\d,]+)\s*м².*?(?P<floor>\d+)/(?P<total>\d+)\s*эт", + re.IGNORECASE, +) + + +def _parse_title(title: str | None) -> dict[str, int | float | None]: + """Парсит rooms, area_m2, floor, total_floors из Avito-заголовка объявления.""" + result: dict[str, int | float | None] = { + "rooms": None, + "area_m2": None, + "floor": None, + "total_floors": None, + } + if not title: + return result + m = _TITLE_RE.match(title.strip()) + if not m: + return result + try: + result["rooms"] = int(m.group("rooms")) + result["area_m2"] = float(m.group("area").replace(",", ".")) + result["floor"] = int(m.group("floor")) + result["total_floors"] = int(m.group("total")) + except (ValueError, AttributeError): + pass + return result + + +def _parse_placement_item(raw: dict[str, Any]) -> IMVPlacementHistoryItem: + """Парсит один элемент из placementHistory.items.""" + title = raw.get("title") + parsed = _parse_title(title) + + # Фильтруем itemImage из raw_payload (не нужны) + clean_raw = {k: v for k, v in raw.items() if k not in ("itemImage",)} + + return IMVPlacementHistoryItem( + ext_item_id=str(raw["id"]), + title=title, + rooms=parsed["rooms"], # type: ignore[arg-type] + area_m2=parsed["area_m2"], # type: ignore[arg-type] + floor=parsed["floor"], # type: ignore[arg-type] + total_floors=parsed["total_floors"], # type: ignore[arg-type] + start_price=raw.get("startPrice"), + start_price_date=_unix_to_date(raw.get("startPriceDate")), + last_price=raw.get("lastPrice"), + last_price_date=_unix_to_date(raw.get("lastPriceDate")), + removed_date=_unix_to_date(raw.get("removedDate")), + exposure_days=raw.get("exposure"), + raw_payload=clean_raw, + ) + + +def _parse_suggestion(raw: dict[str, Any]) -> IMVSuggestion: + """Парсит один элемент из suggestions.items.""" + metro = raw.get("metro") or {} + colors: list[str] = metro.get("colors") or [] + + # Фильтруем imageLink из raw_payload + clean_raw = {k: v for k, v in raw.items() if k not in ("imageLink",)} + + return IMVSuggestion( + ext_item_id=str(raw["id"]), + title=raw.get("title"), + address=raw.get("address"), + price_rub=raw.get("price"), + exposure_days=raw.get("exposure"), + publish_date=_unix_to_date(raw.get("publishDate")), + item_url=raw.get("itemLink"), + metro_name=metro.get("name"), + metro_distance=metro.get("distance"), + metro_color=colors[0] if colors else None, + has_good_price_badge=bool(raw.get("hasGoodPriceBadge", False)), + raw_payload=clean_raw, + ) + + +def _parse_geo_position(data_b: dict[str, Any], *, lat: float, lon: float) -> IMVGeo: + """Парсит ответ POST /js/v2/geo/position (step B) → IMVGeo. + + Контракт (подтверждён живым capture 2026-05, fixture avito_imv_geo_position.json): + {address, addressId, districtId, geoFieldsHash, latitude, longitude, + locationId, parentLocationId, metroId?, errorText, ...} + + `geoFieldsHash` — JWT, payload которого кодирует {latitude, longitude, + address, addressId, locationId, districtId}; это то, что step C ожидает как + `geoHash`. metroId присутствует только для метро-городов (None для ЕКБ-окраин). + + Raises: + IMVAddressNotFoundError если geoFieldsHash пустой/отсутствует. + """ + jwt = data_b.get("geoFieldsHash") + if not jwt: + raise IMVAddressNotFoundError( + f"Avito /js/v2/geo/position не вернул geoFieldsHash для " + f"{str(data_b.get('address'))[:60]!r}: errorText={data_b.get('errorText')!r}" + ) + return IMVGeo( + geo_hash=jwt, + lat=lat, + lon=lon, + avito_address_id=data_b.get("addressId"), + avito_location_id=data_b.get("locationId"), + avito_metro_id=data_b.get("metroId"), + avito_district_id=data_b.get("districtId"), + ) + + +def _parse_price(data: dict[str, Any]) -> tuple[int, int, int, int | None]: + """Парсит top-level `price` блок из realty-imv/get-data (step C). + + Контракт (fixture avito_imv_getdata.json): + price = {recommendedPrice, lowerPrice, higherPrice, count, addItemUrl} + + Returns: (recommended_price, lower_price, higher_price, market_count). + Отсутствующие цены → 0 (caller трактует как "нет оценки"); count → None. + """ + price_block: dict[str, Any] = data.get("price") or {} + recommended_price: int = price_block.get("recommendedPrice") or 0 + lower_price: int = price_block.get("lowerPrice") or 0 + higher_price: int = price_block.get("higherPrice") or 0 + market_count: int | None = price_block.get("count") + return recommended_price, lower_price, higher_price, market_count + + +def _parse_placement_history(data: dict[str, Any]) -> list[IMVPlacementHistoryItem]: + """Парсит опциональный `placementHistory.items` блок (step C). + + placementHistory отсутствует для части адресов (живой capture: проспект + Ленина вернул только itemParams/price/suggestions) — тогда возвращаем []. + Битые элементы пропускаем с warning, не роняя весь ответ. + """ + history_block: dict[str, Any] = data.get("placementHistory") or {} + items: list[IMVPlacementHistoryItem] = [] + for raw_item in history_block.get("items") or []: + try: + items.append(_parse_placement_item(raw_item)) + except (KeyError, TypeError) as exc: + logger.warning("IMV: не удалось распарсить placementHistory item: %s", exc) + return items + + +def _parse_suggestions(data: dict[str, Any]) -> tuple[list[IMVSuggestion], str | None]: + """Парсит `suggestions` блок (step C) → (items, searchSimilarUrl).""" + suggestions_block: dict[str, Any] = data.get("suggestions") or {} + suggestions: list[IMVSuggestion] = [] + for raw_sugg in suggestions_block.get("items") or []: + try: + suggestions.append(_parse_suggestion(raw_sugg)) + except (KeyError, TypeError) as exc: + logger.warning("IMV: не удалось распарсить suggestion: %s", exc) + return suggestions, suggestions_block.get("searchSimilarUrl") + + +# --------------------------------------------------------------------------- +# Browser-transport adapter (#915 Stage 3) +# --------------------------------------------------------------------------- + + +class _BrowserResponse: + """Мимикрия под curl_cffi Response поверх ответа BrowserFetcher.fetch_json. + + IMV-флоу читает только .status_code / .text / .json() / .raise_for_status() — + адаптируем ровно эти четыре (подтверждено по avito_imv.py: warm-up, _geocode, + _imv_evaluate, _raise_for_status_categorized). + """ + + def __init__(self, status: int, body: str) -> None: + self.status_code = status + self.text = body + + def json(self) -> Any: + return json.loads(self.text) + + def raise_for_status(self) -> None: + if self.status_code >= 400: + # _raise_for_status_categorized уже обрабатывает 401/403/5xx ДО вызова + # raise_for_status(); сюда попадают только "прочие 4xx" (fall-through). + # Зеркалим curl_cffi/httpx-семантику ровно настолько, чтобы caller + # получил исключение. + raise RuntimeError(f"HTTP {self.status_code}") + + +class _BrowserSessionAdapter: + """Адаптер: IMV-флоу думает что это curl_cffi-сессия, а ходит через камуфокс + in-page fetch (same-origin avito.ru). Куки/JWT живут в контексте браузера + провайдера, поэтому 4 запроса IMV переиспользуют warmed-сессию. Прокси и + fingerprint — из sidecar (обходит datacenter-403, #562/#853).""" + + def __init__(self, browser_fetcher: BrowserFetcher, *, origin: str) -> None: + self._bf = browser_fetcher + self._origin = origin + + async def get(self, url: str, *, headers: dict | None = None) -> _BrowserResponse: + r = await self._bf.fetch_json(url, method="GET", headers=headers, origin=self._origin) + return _BrowserResponse(r["status"], r["body"]) + + async def post( + self, url: str, *, headers: dict | None = None, json: Any = None + ) -> _BrowserResponse: + import json as _json + + body = _json.dumps(json) if json is not None else None + r = await self._bf.fetch_json( + url, method="POST", headers=headers, body=body, origin=self._origin + ) + return _BrowserResponse(r["status"], r["body"]) + + async def close(self) -> None: + # browser принадлежит вызывающему (backfill открывает один на батч) — no-op. + return None + + +# --------------------------------------------------------------------------- +# Main async function +# --------------------------------------------------------------------------- + + +async def evaluate_via_imv( + address: str, + rooms: int, + area_m2: float, + floor: int, + floor_at_home: int, + house_type: str, # panel/brick/monolith/monolith_brick/block/wood + renovation_type: str, # required/cosmetic/euro/designer + has_balcony: bool, + has_loggia: bool, + *, + cffi_session: Any | None = None, + browser_fetcher: BrowserFetcher | None = None, + config: ScraperConfig | None = None, +) -> IMVEvaluation: + """Avito IMV — 3 HTTP requests (current contract 2026-05+): + + 1) GET /web/1/coords/by_address?address=<urlenc> → normalize + lat/lon + 2) POST /js/v2/geo/position → rich JWT (geoFieldsHash) + 3) POST /web/1/realty-imv/get-data → price + placementHistory + suggestions + + Returns IMVEvaluation с уже вычисленным cache_key. + + Raises: + IMVAddressNotFoundError если point/normalizedAddress пустые (step 1) + или geoFieldsHash не вернулся (step 2) + IMVAuthError на 401/403 + IMVTransientError на 5xx / network errors + """ + # #915 Stage 3: browser-транспорт. Если передан browser_fetcher — ходим через + # камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси + + # warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403 + # (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op, + # браузер принадлежит caller'у — backfill открывает один на батч). Этот путь + # НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке. + if browser_fetcher is not None: + cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL) + _own_session = True + else: + # Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети + try: + from curl_cffi.requests import AsyncSession as CffiAsyncSession + + _own_session = False + if cffi_session is None: + # Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162): + # chrome120 TLS + document-заголовки + timeout. Затем warm-up GET для + # seed anti-bot cookies — bare-session XHR Avito банит на server-IP. + # Mobile proxy wiring (#806 follow-up): только для own-session (переданная + # сессия уже с прокси от AvitoScraper). proxy=None → прямое подключение. + _proxy_url = config.scraper_proxy_url if config is not None else None + _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None + cffi_session = CffiAsyncSession( + impersonate="chrome120", + timeout=_HTTP_TIMEOUT_SEC, + proxies=_proxies, + headers=_DOC_HEADERS, + ) + _own_session = True + except ImportError as exc: + raise RuntimeError( + "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml." + ) from exc + + try: + if _own_session: + await _warmup(cffi_session) + geo = await _geocode(cffi_session, address) + evaluation = await _imv_evaluate( + cffi_session, + geo=geo, + address=address, + rooms=rooms, + area_m2=area_m2, + floor=floor, + floor_at_home=floor_at_home, + house_type=house_type, + renovation_type=renovation_type, + has_balcony=has_balcony, + has_loggia=has_loggia, + ) + finally: + if _own_session: + await cffi_session.close() + + return evaluation + + +async def _warmup(session: Any) -> None: + """GET /evaluation/realty чтобы seed anti-bot cookies (srv_id/_avisc/u/...). + + Без warm-up XHR-шаги (coords/position/get-data) c server-IP ловят 403 anti-bot. + Best-effort: не роняем оценку если страница недоступна — последующие шаги + всё равно попробуют и дадут типизированную ошибку. + """ + try: + resp = await session.get(WARMUP_URL, headers=_DOC_HEADERS) + logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code) + except Exception as exc: + # warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную + # ошибку, если cookies реально нужны. Не re-raise намеренно. + logger.warning("IMV warm-up failed (продолжаем без cookies): %s", exc) + + +def _raise_for_status_categorized(resp: Any, context: str) -> None: + """Raise typed IMV exception based on HTTP status code. + + Используется вместо bare raise_for_status() чтобы caller мог различать + auth-failure (требует human attention) от transient (retryable). + """ + status = resp.status_code + if status in (401, 403): + raise IMVAuthError(f"Avito {context}: HTTP {status} — auth rejected / quota exceeded") + if status >= 500: + raise IMVTransientError(f"Avito {context}: HTTP {status} — transient server error") + if status >= 400: + body_preview = (resp.text or "")[:200] + logger.warning("Avito %s HTTP %d: body=%r", context, status, body_preview) + resp.raise_for_status() # other 4xx → standard HTTPStatusError + + +async def _geocode(session: Any, address: str) -> IMVGeo: + """Two-step Avito IMV geocoder (current contract 2026-05+). + + Step A: GET /web/1/coords/by_address → normalize + lat/lon + Step B: POST /js/v2/geo/position → rich JWT (geoFieldsHash) with + addressId/locationId/metroId/districtId + + JWT from step B is what /realty-imv/get-data expects as `geoHash`. + """ + # Step A — normalize + coords + params = urlencode({"address": address}) + url_a = f"{AVITO_BASE}{COORDS_ENDPOINT}?{params}" + logger.info("IMV geocode A: address=%r", address) + + try: + resp_a = await session.get(url_a, headers=_COMMON_HEADERS) + except Exception as exc: + raise IMVTransientError(f"Avito geocode A network error: {exc}") from exc + _raise_for_status_categorized(resp_a, "geocode-A") + data_a: dict[str, Any] = resp_a.json() + + point = data_a.get("point") or {} + lat = point.get("latitude") + lon = point.get("longitude") + normalized = data_a.get("normalizedAddress") + + if not lat or not lon or not normalized: + raise IMVAddressNotFoundError(f"Avito /coords/by_address не вернул point для {address!r}") + + # Step B — rich JWT + url_b = f"{AVITO_BASE}{POSITION_ENDPOINT}" + payload_b = { + "address": normalized, + "addressId": "", + "categoryId": 24, # 24 = недвижимость / квартиры + "isRadius": False, + "latitude": lat, + "longitude": lon, + } + logger.info("IMV geocode B: position for %r", normalized[:60]) + + try: + resp_b = await session.post( + url_b, + headers={**_COMMON_HEADERS, "Content-Type": "application/json"}, + json=payload_b, + ) + except Exception as exc: + raise IMVTransientError(f"Avito geocode B network error: {exc}") from exc + _raise_for_status_categorized(resp_b, "geocode-B") + data_b: dict[str, Any] = resp_b.json() + + geo = _parse_geo_position(data_b, lat=lat, lon=lon) # raises IMVAddressNotFoundError + + logger.info( + "IMV geocode OK: addressId=%s locationId=%s metroId=%s lat=%s lon=%s", + geo.avito_address_id, + geo.avito_location_id, + geo.avito_metro_id, + lat, + lon, + ) + return geo + + +async def _imv_evaluate( + session: Any, + *, + geo: IMVGeo, + address: str, + rooms: int, + area_m2: float, + floor: int, + floor_at_home: int, + house_type: str, + renovation_type: str, + has_balcony: bool, + has_loggia: bool, +) -> IMVEvaluation: + """Request 2: POST /web/1/realty-imv/get-data → IMVEvaluation.""" + url = f"{AVITO_BASE}{IMV_ENDPOINT}" + body = { + "geoHash": geo.geo_hash, + "floor": floor, + "floorAtHome": floor_at_home, + "rooms": rooms, + "area": area_m2, + "houseType": house_type, + "renovationType": renovation_type, + "hasLoggia": has_loggia, + "hasBalcony": has_balcony, + } + headers = {**_COMMON_HEADERS, "Content-Type": "application/json"} + + logger.info( + "IMV evaluate: address=%r rooms=%d area=%.1f floor=%d/%d", + address, + rooms, + area_m2, + floor, + floor_at_home, + ) + + try: + resp = await session.post(url, json=body, headers=headers) + except Exception as exc: + raise IMVTransientError(f"Avito IMV network error: {exc}") from exc + _raise_for_status_categorized(resp, "imv-evaluate") + data: dict[str, Any] = resp.json() + + recommended_price, lower_price, higher_price, market_count = _parse_price(data) + placement_history = _parse_placement_history(data) + suggestions, search_similar_url = _parse_suggestions(data) + + logger.info( + "IMV evaluate OK: recommended=%d range=(%d, %d) count=%s history=%d suggestions=%d", + recommended_price, + lower_price, + higher_price, + market_count, + len(placement_history), + len(suggestions), + ) + + cache_key = compute_imv_cache_key( + address=address, + rooms=rooms, + area_m2=area_m2, + floor=floor, + floor_at_home=floor_at_home, + house_type=house_type, + renovation_type=renovation_type, + has_balcony=has_balcony, + has_loggia=has_loggia, + ) + + return IMVEvaluation( + cache_key=cache_key, + address=address, + rooms=rooms, + area_m2=area_m2, + floor=floor, + floor_at_home=floor_at_home, + house_type=house_type, + renovation_type=renovation_type, + has_balcony=has_balcony, + has_loggia=has_loggia, + geo=geo, + recommended_price=recommended_price, + lower_price=lower_price, + higher_price=higher_price, + market_count=market_count, + placement_history=placement_history, + suggestions=suggestions, + search_similar_url=search_similar_url, + raw_response=data, + ) + + +# --------------------------------------------------------------------------- +# DB save functions +# --------------------------------------------------------------------------- + + +def save_imv_evaluation( + db: Session, + e: IMVEvaluation, + *, + estimate_id: UUID | None = None, +) -> int: + """INSERT в avito_imv_evaluations ON CONFLICT(cache_key) DO UPDATE. + + Колонки строго по 018_avito_imv_evaluations.sql DDL. + estimate_id — nullable FK на trade_in_estimates. + + Returns: id вставленной/обновлённой строки. + """ + row = db.execute( + text(""" + INSERT INTO avito_imv_evaluations ( + cache_key, estimate_id, + address, rooms, area_m2, floor, floor_at_home, + house_type, renovation_type, has_balcony, has_loggia, + geo_hash, + lat, lon, avito_address_id, avito_location_id, + avito_metro_id, avito_district_id, + recommended_price, lower_price, higher_price, market_count, + raw_response, fetched_at + ) VALUES ( + :cache_key, CAST(:estimate_id AS uuid), + :address, :rooms, :area_m2, :floor, :floor_at_home, + :house_type, :renovation_type, :has_balcony, :has_loggia, + :geo_hash, + :lat, :lon, :avito_address_id, :avito_location_id, + :avito_metro_id, :avito_district_id, + :recommended_price, :lower_price, :higher_price, :market_count, + CAST(:raw_response AS jsonb), NOW() + ) + ON CONFLICT (cache_key) DO UPDATE SET + estimate_id = COALESCE(EXCLUDED.estimate_id, avito_imv_evaluations.estimate_id), + recommended_price = EXCLUDED.recommended_price, + lower_price = EXCLUDED.lower_price, + higher_price = EXCLUDED.higher_price, + market_count = EXCLUDED.market_count, + raw_response = EXCLUDED.raw_response, + fetched_at = NOW() + RETURNING id + """), + { + "cache_key": e.cache_key, + "estimate_id": str(estimate_id) if estimate_id else None, + "address": e.address, + "rooms": e.rooms, + "area_m2": e.area_m2, + "floor": e.floor, + "floor_at_home": e.floor_at_home, + "house_type": e.house_type, + "renovation_type": e.renovation_type, + "has_balcony": e.has_balcony, + "has_loggia": e.has_loggia, + "geo_hash": e.geo.geo_hash, + "lat": e.geo.lat, + "lon": e.geo.lon, + "avito_address_id": e.geo.avito_address_id, + "avito_location_id": e.geo.avito_location_id, + "avito_metro_id": e.geo.avito_metro_id, + "avito_district_id": e.geo.avito_district_id, + "recommended_price": e.recommended_price, + "lower_price": e.lower_price, + "higher_price": e.higher_price, + "market_count": e.market_count, + "raw_response": ( + json.dumps(e.raw_response, ensure_ascii=False) if e.raw_response else None + ), + }, + ).fetchone() + db.commit() + return row.id if row else 0 + + +def save_imv_placement_history( + db: Session, + eval_id: int, + items: list[IMVPlacementHistoryItem], +) -> int: + """INSERT в house_placement_history (source='avito_imv') с removed_date. + + Колонки строго по 017_house_placement_history.sql DDL. + Колонка scraped_at (не fetched_at) — по DDL таблицы. + house_id — NULL (backfill через отдельный процесс). + eval_id принимается для логирования (не сохраняется — нет FK в 017). + + ON CONFLICT (source, ext_item_id) DO UPDATE — refresh dates. + Returns: количество вставленных/обновлённых строк. + """ + count = 0 + for item in items: + db.execute( + text(""" + INSERT INTO house_placement_history ( + source, ext_item_id, + title, rooms, area_m2, floor, total_floors, + start_price, start_price_date, + last_price, last_price_date, + removed_date, exposure_days, + raw_payload, scraped_at + ) VALUES ( + 'avito_imv', :ext_item_id, + :title, :rooms, :area_m2, :floor, :total_floors, + :start_price, :start_price_date, + :last_price, :last_price_date, + :removed_date, :exposure_days, + CAST(:raw_payload AS jsonb), NOW() + ) + ON CONFLICT (source, ext_item_id) DO UPDATE SET + last_price = EXCLUDED.last_price, + last_price_date = EXCLUDED.last_price_date, + removed_date = EXCLUDED.removed_date, + exposure_days = EXCLUDED.exposure_days, + raw_payload = EXCLUDED.raw_payload, + scraped_at = NOW() + """), + { + "ext_item_id": item.ext_item_id, + "title": item.title, + "rooms": item.rooms, + "area_m2": item.area_m2, + "floor": item.floor, + "total_floors": item.total_floors, + "start_price": item.start_price, + "start_price_date": item.start_price_date, + "last_price": item.last_price, + "last_price_date": item.last_price_date, + "removed_date": item.removed_date, + "exposure_days": item.exposure_days, + "raw_payload": ( + json.dumps(item.raw_payload, ensure_ascii=False) if item.raw_payload else None + ), + }, + ) + count += 1 + + if count: + db.commit() + logger.info("IMV placement history saved: eval_id=%d items=%d", eval_id, count) + + return count diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py new file mode 100644 index 00000000..3587416d --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -0,0 +1,2181 @@ +"""Avito.ru scraper — парсинг вторички вокруг точки + citywide/byrooms sweep. + +Стратегия: HTML scrape карточек объявлений через DOM. + +URL patterns (EKB): + geo-mode (default): + https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ + ?geoCoords=56.838,60.605 + &radius=1 # в км + &s=104 # sort by date + &p=1 # страница + + citywide-mode (без geo, весь город, T6): + https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ + ?s=104&p=1 + + byrooms-mode (citywide + фильтр комнатности, T6): + https://www.avito.ru/ekaterinburg/kvartiry/prodam/<room-slug>?s=104&p=1 + +ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP. +Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello. +""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +import math +import random +import re +from collections.abc import Callable +from datetime import date, timedelta +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode, urljoin, urlparse, urlunparse + +import httpx +from curl_cffi.requests import AsyncSession +from selectolax.parser import HTMLParser + +from scraper_kit.avito_exceptions import ( + AvitoBlockedError, + AvitoContentBlockedError, + AvitoRateLimitedError, +) +from scraper_kit.base import BaseScraper, ScrapedLot +from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.price_brackets import get_price_seed_brackets +from scraper_kit.providers.avito.shared import _unix_to_date + +if TYPE_CHECKING: + from scraper_kit.contracts import ScraperConfig + +logger = logging.getLogger(__name__) + +# ── Relative date parsing ──────────────────────────────────────────────────── +_REL_DATE_RE = re.compile( + r"(?P<n>\d+)\s+(?P<unit>" + r"секунд[ауы]?|" + r"минут[ауы]?|" + r"час(?:а|ов)?|" + r"день|дн(?:я|ей?)|" + r"недел[июяь]+|" + r"месяц(?:а|ев)?|" + r"год(?:а|ов)?" + r")\s+назад", + flags=re.I, +) +_UNIT_DAYS: dict[str, int] = { + "секунд": 0, + "минут": 0, + "час": 0, + "день": 1, + "дн": 1, + "недел": 7, + "месяц": 30, + "год": 365, +} +# #1387: Avito SERP часто показывает давность в единственном числе БЕЗ ведущего +# числа — «час назад», «минуту назад», «день назад», «неделю назад», «месяц +# назад», «год назад». Числовой `_REL_DATE_RE` их не матчит (требует `\d+`), +# поэтому отдельная ветка с неявным n=1. Юниты в винительном падеже («минуту», +# «неделю») плюс именительный («час», «день»). +_REL_DATE_SINGULAR_RE = re.compile( + r"(?P<unit>" r"секунду|" r"минуту|" r"час|" r"день|" r"неделю|" r"месяц|" r"год" r")\s+назад", + flags=re.I, +) + +# Russian month names → number. Avito показывает абсолютные даты как «18 мая». +_RU_MONTHS: dict[str, int] = { + "январ": 1, + "феврал": 2, + "март": 3, + "апрел": 4, + "ма": 5, + "июн": 6, + "июл": 7, + "август": 8, + "сентябр": 9, + "октябр": 10, + "ноябр": 11, + "декабр": 12, +} +# «18 мая», «5 июня в 12:30» — day + month (year inferred current). +_ABS_DATE_RE = re.compile( + r"(?P<day>\d{1,2})\s+(?P<month>январ\w*|феврал\w*|март\w*|апрел\w*|" + r"ма[яй]|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)", + flags=re.I, +) + + +def _parse_relative_date(s: str | None) -> date | None: + """Avito показывает дату публикации в трёх форматах: + * '5 дней назад' / 'час назад' / 'неделю назад' — relative numeric + * 'сегодня в 12:30' / 'вчера в 18:45' / 'позавчера' — relative keyword + * '18 мая' / '5 июня в 12:30' — absolute day + month (year inferred) + + PR M-followup 2026-05-27: расширено покрытие — раньше только relative numeric + matched (regex `\\d+ unit назад`), 100% Avito listings заходило с listing_date=None + т.к. Avito SERP для свежих cards использует keyword/absolute форматы. См. + тесты для каждого case. + """ + if not s: + return None + text_lower = s.lower() + + # Keyword shortcuts (no \d+). + if "позавчера" in text_lower: + return date.today() - timedelta(days=2) + if "вчера" in text_lower: + return date.today() - timedelta(days=1) + if "сегодня" in text_lower or "только что" in text_lower: + return date.today() + + # Relative numeric («5 дней назад»). + m = _REL_DATE_RE.search(s) + if m: + n = int(m["n"]) + unit_raw = m["unit"].lower() + for prefix, days in _UNIT_DAYS.items(): + if unit_raw.startswith(prefix): + return date.today() - timedelta(days=n * days) + + # #1387: Relative singular без числа («час назад», «неделю назад»). Неявное + # n=1; те же _UNIT_DAYS. Винительный падеж юнита нормализуем к ключу-префиксу. + m = _REL_DATE_SINGULAR_RE.search(s) + if m: + unit_raw = m["unit"].lower() + # «минуту»→«минут», «неделю»→«недел», «секунду»→«секунд»; остальные уже + # совпадают с префиксами _UNIT_DAYS. + unit_key = {"минуту": "минут", "неделю": "недел", "секунду": "секунд"}.get( + unit_raw, unit_raw + ) + for prefix, days in _UNIT_DAYS.items(): + if unit_key.startswith(prefix): + return date.today() - timedelta(days=days) + + # Absolute («18 мая», «5 июня в 12:30»). Year — current; если месяц + # в будущем относительно сегодня (например сейчас декабрь, увидели + # «5 января») — откатываем к прошлому году. + m = _ABS_DATE_RE.search(s) + if m: + day = int(m["day"]) + month_raw = m["month"].lower() + month: int | None = None + for prefix, num in _RU_MONTHS.items(): + if month_raw.startswith(prefix): + month = num + break + if month is not None and 1 <= day <= 31: + today = date.today() + year = today.year + try: + parsed = date(year, month, day) + except ValueError: + return None + # Если parsed в будущем больше чем на 30 дней — Avito показывает + # прошлогоднюю дату («декабрь» в феврале значит прошлый декабрь). + if (parsed - today).days > 30: + try: + parsed = date(year - 1, month, day) + except ValueError: + return None + return parsed + + return None + + +# #726: Avito SERP лениво рендерит DOM — `data-marker="item-date"` присутствует только +# у верхних (above-the-fold) карточек (~10 из ~46), у остальных его НЕТ в raw-HTML, что +# curl_cffi получает (JS-гидрация не выполняется). Но per-item JSON в той же HTML несёт +# `sortTimeStamp` (epoch-ms) почти у всех карточек — это drift-resistant источник даты. +# Маппим item_id (= DOM `data-item-id`) → дата. `{0,1500}` ограничивает поиск телом +# одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный +# `addressDetailed{…}`, поэтому не `[^{}]`). re.S — sortTimeStamp может быть на новой строке. +# sortTimeStamp — UTC epoch-ms. Avito показывает дату в MSK (UTC+3, без DST с 2014). +# Берём .date() в MSK через общий `_unix_to_date` (avito_shared.py, #2129), чтобы +# совпадало с тем, что видно на сайте (иначе объявления, поднятые в 00:00–03:00 MSK, +# давали бы дату на день раньше). #726. +_SORT_TS_RE = re.compile(r'"id":(?P<id>\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P<ts>\d+)', re.S) + + +def _build_sort_timestamp_map(html: str) -> dict[str, date]: + """item_id → дата публикации из embedded JSON (`sortTimeStamp`, epoch-ms). + + Best-effort: при любой ошибке конкретной записи — пропускаем её (не роняем парс). + Первое вхождение id выигрывает (item-объект идёт раньше recommendations-дублей). + `_unix_to_date` ожидает СЕКУНДЫ — sortTimeStamp в МИЛЛИСЕКУНДАХ, делим на 1000. + """ + out: dict[str, date] = {} + for m in _SORT_TS_RE.finditer(html): + item_id = m.group("id") + if item_id in out: + continue + date_val = _unix_to_date(int(m.group("ts")) / 1000) + if date_val is not None: + out[item_id] = date_val + return out + + +# #623: Avito банит datacenter-IP — на HTTP 200 отдаёт firewall-интерстициал +# «Доступ ограничен: проблема с IP» (title в <head>), а НЕ 403. Детектим по +# маркерам в начале документа (firewall-страница объёмна, не сканируем целиком). +_FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip", "firewall-container") + +# EKB-фильтр «Новостройка» (slug категории kvartiry/prodam). Кодирует +# ASgB-параметры выборки только новостроек — SERP возвращает 100% new-build +# карточки (с data-marker="item-development-name"). Извлечён из реального +# search-URL: /ekaterinburg/kvartiry/prodam/novostroyka-ASgBAgICAkSSA8YQ5geOUg +NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg" + +# ── Exhaustive full-load (room×price bisection) ─────────────────────────────── +# Верхняя граница цены при первом рекурсивном делении (нет явного hi). +# 200 млн ₽ заведомо выше ТОПа ЕКБ — дальние бакеты дадут 0 результатов. +# Больше НЕ корень бисекции (см. _AVITO_PRICE_SEED_BRACKETS) — оставлен для +# совместимости (используется в тестах probe-устойчивости как sentinel). +_AVITO_MAX_PRICE = 200_000_000 +# Минимальный ценовой диапазон для рекурсии; при hi - lo < MIN_BRACKET прекращаем +# деление и пагинируем как есть (принимаем возможный «хвост»). +_AVITO_MIN_BRACKET = 50_000 + +# Seed price-brackets (руб) — data-driven по прод-распределению вторички ЕКБ +# (пик 4-8М, bulk 3-16М). Теперь общий констант для всех exhaustive-скрапперов +# (avito/cian/yandex) — app.services.scrapers.price_brackets.get_price_seed_brackets(). +# Локальный алиас сохранён для читаемости и тестов; значения идентичны shared EKB. +# Каждый (комнатность × брекет) дальше дробится _walk_price_range если total > cap. +# Последний брекет ОТКРЫТ (hi=None → pmax не ставится) — ловит весь хвост люкса +# без потолка; он крошечный (avito >50М ≈ 73), пагинируется напрямую без бисекции. +_AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_brackets() +# Avito SERP показывает ~50 карточек на страницу. +_AVITO_OFFERS_PER_PAGE = 50 + +# HTTP 429 в curl_cffi-режиме через backconnect-прокси (mproxy.site) — НЕ IP-ban, а +# transient «слишком много одновременных соединений» (лимит 5). Проходит на коротком +# retry без ротации IP. Делаем до _AVITO_429_MAX_RETRIES коротких пауз; если они +# исчерпаны под backconnect — ещё _AVITO_429_RECONNECT_RETRIES попыток через +# пересоздание сессии (_reset_cffi): свежее соединение освобождает удерживаемый +# conn-slot и может попасть на менее загруженный путь. Только потом — +# AvitoRateLimitedError (run 216: deep-pagination 429 на page 36 ронял весь sweep). +_AVITO_429_MAX_RETRIES = 8 +_AVITO_429_BACKOFF_SEC = 1.5 +# Reconnect-fallback на исчерпанном 429 short-retry (только backconnect): дольше спим +# (_AVITO_403_BACKOFF_SEC), чтобы дать conn-slot'ам разгрузиться. +_AVITO_429_RECONNECT_RETRIES = 3 + +# HTTP 403 / firewall-интерстициал в curl_cffi-режиме через backconnect-прокси +# (mproxy.site, нет changeip-URL): провайдер выдаёт СВЕЖИЙ exit-IP на каждое НОВОЕ +# соединение (5 последовательных запросов → 5 разных IP). 403 значит залочен лишь +# ТЕКУЩИЙ exit-IP — открытие нового CONNECT-туннеля escape'ит бан. Keep-alive curl_cffi +# сессия переиспользует тот же туннель = тот же IP, поэтому retry на том же self._cffi +# не помогает: нужно пересоздать сессию (_reset_cffi). Делаем до _AVITO_403_MAX_RETRIES +# reconnect-попыток прежде чем свалиться в AvitoBlockedError (mark_banned). Старый +# single-IP changeip-путь и browser-режим не затрагиваются. +_AVITO_403_MAX_RETRIES = 5 +_AVITO_403_BACKOFF_SEC = 2.0 + +# tradein-browser sidecar (POST /fetch) отдаёт HTTP 500 на ЛЮБУЮ внутреннюю +# ошибку страницы (тело {"error": "ErrorType: message"}) и HTTP 503 когда браузер +# не поднимается (прокси упал). Транзиентные сбои (Page.goto Timeout, Target +# closed, прочие NS_ERROR_/5xx) лечатся коротким backoff-retry того же url БЕЗ +# ротации IP; soft-ban (NS_ERROR_PROXY_TOO_MANY_REQUESTS / 503 / browser +# unavailable) — ротацией exit-IP. Когда ОБА бюджета исчерпаны → AvitoRateLimitedError, +# чтобы run_avito_full_load/run_avito_city_sweep ушли в graceful mark_banned путь +# (partial preserved), а не в generic except → mark_failed (прод-инцидент run #356: +# один поздний 500 ронял весь обход после 3186 сохранённых объявлений). +_AVITO_SIDECAR_TRANSIENT_RETRIES = 2 +_AVITO_SIDECAR_TRANSIENT_BACKOFF_SEC = 2.0 + +# Per-bucket block tolerance в fetch_all_secondary: одиночный 429/блок на ОДНОМ +# (комнатность × ценовой брекет) бакете (напр. deep-pagination dense-бакета) НЕ должен +# ронять весь sweep — бакет скипается, обход продолжается. Только N ПОДРЯД заблокированных +# бакетов = реальный hard IP-ban → re-raise (run_avito_full_load → mark_banned). +_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED = 4 + +# Счётчик результатов в шапке SERP: <span data-marker="page-title/count">1 178</span>. +# Текст может содержать NBSP (\xa0),  -entity или обычные пробелы как разделители +# разрядов — чистятся в _extract_total_count. [^<]* нежадно ловит весь текст до </span>. +_TOTAL_COUNT_RE = re.compile( + r'data-marker="page-title/count"[^>]*>(?P<count>[^<]*)<', + flags=re.I, +) + +# Маркер валидной ПУСТОЙ выборки. На 0-результатной странице (напр. студии с +# pmin=17M, которых нет) Avito отдаёт HTTP 200 БЕЗ `page-title/count`, но с +# текстом «ничего не найдено» И ~50 fallback-«похожими» объявлениями (НЕ +# подходящими под ценовой фильтр). Без этого маркера _extract_total_count +# возвращал None → resilience-логика дробила пустой брекет + ротировала IP +# (прод-инцидент run 209: 14+ мин в пустых верхних брекетах, errors=1058). +# Lowercase substring «ничего не найден» ловит «найдено»/«найдены»/любой регистр. +_AVITO_NO_RESULTS_MARKERS = ("ничего не найден",) + + +def _is_firewall_page(html: str) -> bool: + """True если Avito вернул firewall-страницу IP-блока (на HTTP 200).""" + head = html[:4096].lower() + return any(marker in head for marker in _FIREWALL_MARKERS) + + +class AvitoScraper(BaseScraper): + """Avito vtorichka parser. Источник = 'avito'. + + Использует curl_cffi с impersonate=chrome120 для обхода TLS fingerprint бана. + """ + + name = "avito" + base_url = "https://www.avito.ru" + # Avito жёстко мониторит — спим долго между запросами. + # Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра. + request_delay_sec = 7.0 + + def __init__( + self, + config: ScraperConfig, + *, + delay_provider: Callable[[str], float] | None = None, + ) -> None: + super().__init__() + # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи + # вместо прямого импорта app.core.config.settings / + # app.services.scraper_settings.get_scraper_delay. Продуктовая сторона + # передаёт RealScraperConfig + get_scraper_delay; kit не знает про app / БД. + self._config = config + if delay_provider is not None: + self.request_delay_sec = delay_provider(self.name) + self._cffi: AsyncSession | None = None + self._browser: BrowserFetcher | None = None + # #823: счётчик карточек, которые не удалось распарсить из-за неожиданной структуры DOM. + self.parse_failures: int = 0 + + async def __aenter__(self) -> AvitoScraper: + # Проактивная ротация IP в начале sweep (#1731): apw-IP «протухает» по + # Datadome между суточными прогонами, поэтому первые якоря блокируются и + # реактивного бюджета max_rotations не хватает (3 подряд блока → + # mark_banned). Один changeip-вызов до первого SERP-fetch даёт свежий IP. + # _rotate_ip() сам ждёт ~9с (settle) и логирует/глотает ошибку, поэтому + # тут только дополнительный guard: сбой ротации НЕ должен ронять sweep. + if self._config.avito_proxy_rotate_url: + try: + if await self._rotate_ip(): + logger.info("avito: proactive IP rotate at sweep start (#1731)") + else: + logger.warning("avito: proactive IP rotate at sweep start failed — proceeding") + except Exception: + logger.warning( + "avito: proactive IP rotate at sweep start raised — proceeding", exc_info=True + ) + if self._config.scraper_fetch_mode == "browser": + # Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider + # + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как + # FALLBACK — если браузер отдаёт firewall/пусто, _fetch_serp_html делает + # один retry через curl_cffi. _build_cffi_session самодостаточен + # (отдельный AsyncSession, не зависит от BaseScraper.__aenter__). + self._browser = BrowserFetcher(source="avito") + await self._browser.__aenter__() + self._cffi = self._build_cffi_session() + logger.info( + "avito: SERP fetch via BrowserFetcher (camoufox) default, " + "curl_cffi fallback armed — #1805" + ) + return self + # Explicit opt-out: legacy curl_cffi-only путь (SCRAPER_FETCH_MODE=curl_cffi). + await super().__aenter__() + self._cffi = self._build_cffi_session() + return self + + def _build_cffi_session(self) -> AsyncSession: + """Создать curl_cffi-сессию (impersonate chrome120, через прокси если задан). + + Вынесено из __aenter__ чтобы _reset_cffi мог пересоздать сессию (новый + CONNECT-туннель) для escape 403 на залоченном backconnect exit-IP. + """ + proxies = None + if self._config.scraper_proxy_url: + proxies = {"http": self._config.scraper_proxy_url, "https": self._config.scraper_proxy_url} + logger.info("avito: routing through mobile proxy egress (#623)") + return AsyncSession( + impersonate="chrome120", + timeout=25, + 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", + "Cache-Control": "max-age=0", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", + }, + ) + + async def _reset_cffi(self) -> None: + """Закрыть и пересоздать curl_cffi-сессию — новый CONNECT-туннель через + backconnect-прокси = свежий exit-IP (escape 403 на залоченном IP).""" + if self._cffi is not None: + try: + await self._cffi.close() + except Exception: + pass + self._cffi = self._build_cffi_session() + + async def __aexit__(self, *args: Any) -> None: + if self._browser is not None: + await self._browser.__aexit__(*args) + if self._cffi is not None: + await self._cffi.close() + await super().__aexit__(*args) + + # ── Anti-block (#623) ───────────────────────────────────────────────────── + async def _rotate_ip(self) -> bool: + """Сменить мобильный IP через changeip-ссылку mobileproxy (#623). + + Дёргается напрямую (без прокси) — это API провайдера, не Avito. Ждём ~9с: + мобильному модему нужно время поднять новый IP. Returns True при успехе. + """ + rotate_url = self._config.avito_proxy_rotate_url + if not rotate_url: + return False + sep = "&" if "?" in rotate_url else "?" + try: + async with AsyncSession(timeout=30) as rot: + await rot.get(f"{rotate_url}{sep}format=json") + await asyncio.sleep(9) + logger.info("avito proxy: IP rotated via changeip") + return True + except Exception: + logger.warning("avito proxy: IP rotation failed", exc_info=True) + return False + + async def _fetch_serp_html(self, url: str, page: int) -> str | None: + """GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805). + + Если поднят BrowserFetcher (default с #1805) — фетчим через camoufox с + curl_cffi-fallback на firewall (_fetch_serp_html_browser). Иначе + (legacy/explicit curl_cffi opt-out, либо тесты с прямым _cffi) — прямой + curl_cffi-путь (_fetch_serp_html_cffi). + + Returns: + HTML при успехе, либо ``None`` при non-200 (конец пагинации). + Raises: + AvitoBlockedError / AvitoRateLimitedError — если бан не снят. + """ + if self._browser is not None: + return await self._fetch_serp_html_browser(url, page) + return await self._fetch_serp_html_cffi(url, page) + + async def _fetch_serp_html_browser(self, url: str, page: int) -> str | None: + """SERP-фетч через camoufox-браузер (#1805, default) с curl_cffi-fallback. + + Браузер не отдаёт HTTP-статус — бан виден только как rendered firewall-HTML + (camoufox исполняет JS, поэтому firewall-интерстициал приходит готовым + документом; ловим теми же текст-маркерами _is_firewall_page). + + Pipeline: + 1. browser.fetch → firewall? → ротация IP (до max_rotations) + retry. + 2. Ротация исчерпана и всё ещё firewall/пусто → ОДИН retry через curl_cffi + (другой egress/TLS-fingerprint может проскочить). curl-fallback + доступен только если _cffi поднят (standalone __aenter__); в + pipeline-режиме с shared-browser _cffi=None → fallback пропускается. + 3. curl-fallback тоже firewall/пусто (или недоступен) → AvitoBlockedError. + + Отдельная ветка — sidecar HTTP-ошибка (#356): BrowserFetcher пробрасывает + httpx.HTTPStatusError (500/503), если /fetch упал внутри (Page.goto Timeout, + NS_ERROR_PROXY_TOO_MANY_REQUESTS, браузер не поднялся). Раньше она всплывала + неперехваченной → generic except в run_avito_full_load → mark_failed + + re-raise, что роняло весь обход даже после тысяч инкрементально сохранённых + карточек. Теперь классифицируем тело ошибки и роутим в ту же + ротация+retry-машинерию: soft-ban → ротация IP, transient → bounded backoff, + оба бюджета исчерпаны → AvitoRateLimitedError (graceful mark_banned). + """ + assert self._browser is not None + max_rot = self._config.avito_proxy_max_rotations if self._config.avito_proxy_rotate_url else 0 + rot_done = 0 + # Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/ + # crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient) + # уменьшает свой счётчик → цикл всегда движется к условию исчерпания. + transient_left = _AVITO_SIDECAR_TRANSIENT_RETRIES + while True: + try: + html = await self._browser.fetch(url) + except httpx.HTTPStatusError as exc: + # Тело sidecar-ошибки: {"error": "ErrorType: message"}. Парсим + # устойчиво — невалидный JSON не должен ронять обработку. + status = exc.response.status_code + try: + error_text = str(exc.response.json().get("error", "")) + except Exception: + error_text = exc.response.text + lowered = error_text.lower() + + is_soft_ban = ( + "too_many_requests" in lowered + or status == 503 + or "browser unavailable" in lowered + or "proxy may be down" in lowered + ) + if is_soft_ban: + # Soft proxy rate-limit / прокси недоступен → свежий exit-IP. + if rot_done < max_rot and await self._rotate_ip(): + rot_done += 1 + logger.info( + "avito page=%d sidecar soft-ban (status=%d) — " + "retry after rotation #%d: %s", + page, + status, + rot_done, + error_text[:200], + ) + continue + else: + # Transient (Page.goto Timeout / Target closed / прочие 5xx) → + # короткий backoff-retry того же url БЕЗ ротации. + if transient_left > 0: + transient_left -= 1 + backoff = _AVITO_SIDECAR_TRANSIENT_BACKOFF_SEC + random.uniform(0, 0.5) + logger.warning( + "avito page=%d sidecar transient (status=%d) — " + "retry in %.1fs (left=%d): %s", + page, + status, + backoff, + transient_left, + error_text[:200], + ) + await asyncio.sleep(backoff) + continue + # Оба бюджета (ротация + transient) исчерпаны → graceful soft-ban, + # чтобы run_avito_full_load/city_sweep ушли в mark_banned (partial + # preserved), а НЕ в generic except → mark_failed. Намеренно + # AvitoRateLimitedError, не AvitoBlockedError и не bare exception. + logger.error( + "avito page=%d sidecar error budget exhausted (status=%d) url=%s: %s", + page, + status, + url, + error_text[:200], + ) + raise AvitoRateLimitedError( + f"avito SERP browser-sidecar error (page={page}): {error_text[:200]}" + ) from exc + # Пустой рендер ("" / None) = таймаут рендера sidecar'а, НЕ firewall: + # _is_firewall_page("") вернул бы False → пустой HTML утёк бы как + # «валидный». Возвращаем None — caller (fetch_around) трактует это как + # конец пагинации (break), а не как бан. + if not html: + return None + if not _is_firewall_page(html): + return html + if rot_done < max_rot and await self._rotate_ip(): + rot_done += 1 + logger.info( + "avito page=%d browser firewall — retry after rotation #%d", + page, + rot_done, + ) + continue + # Ротация исчерпана/недоступна. Fallback: один retry через curl_cffi. + if self._cffi is not None: + logger.warning( + "avito page=%d browser firewall — falling back to curl_cffi (#1805)", + page, + ) + try: + fb_html = await self._fetch_serp_html_cffi(url, page) + except (AvitoBlockedError, AvitoRateLimitedError): + # curl-fallback тоже забанен → пробрасываем как hard-block. + logger.error("avito page=%d curl_cffi fallback also blocked url=%s", page, url) + raise + if fb_html is None or not _is_firewall_page(fb_html): + # None = non-200/конец пагинации; иначе валидный HTML. + return fb_html + logger.error("avito page=%d curl_cffi fallback still firewall url=%s", page, url) + logger.error("avito SERP firewall in browser-mode page=%d url=%s", page, url) + raise AvitoBlockedError( + f"Avito SERP firewall (browser-mode) at page={page} — IP banned" + ) + + async def _fetch_serp_html_cffi(self, url: str, page: int) -> str | None: + """GET SERP HTML через curl_cffi (legacy/fallback путь) с anti-block-логикой. + + Avito на забаненном datacenter-IP отдаёт HTTP 200 + firewall-страницу (не + 403). 429 через backconnect-прокси — transient conn-limit (short-retry без + ротации). 403/firewall на backconnect — залочен текущий exit-IP, escape + через reconnect (_reset_cffi) либо changeip-ротацию. + + Returns: + HTML при HTTP 200, либо ``None`` при non-200 (конец пагинации). + Raises: + AvitoBlockedError / AvitoRateLimitedError — если бан не снят. + """ + assert self._cffi is not None + max_rot = self._config.avito_proxy_max_rotations if self._config.avito_proxy_rotate_url else 0 + # curl_cffi-сессия всегда egress через self._config.scraper_proxy_url (backconnect + # mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости + # от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому + # reconnect-retry гейтим ТОЛЬКО на наличие proxy, не на отсутствие rotate_url. + backconnect = bool(self._config.scraper_proxy_url) + rot_done = 0 + transient_left = 2 + r429 = 0 + r429_reconnect = 0 + r403 = 0 + while True: + try: + response = await self._cffi.get(url) + except Exception: + if transient_left > 0: + transient_left -= 1 + logger.warning( + "avito page=%d transient fetch error — retry (left=%d)", + page, + transient_left, + exc_info=True, + ) + await asyncio.sleep(3) + continue + raise + + sc = response.status_code + is_firewall = sc == 200 and _is_firewall_page(response.text) + # 429 в curl_cffi через backconnect-прокси — transient conn-limit, НЕ + # IP-ban: короткий retry того же url без ротации. Только если короткие + # retry исчерпаны — проваливаемся в общую блок-логику ниже (rotation/raise). + if sc == 429 and r429 < _AVITO_429_MAX_RETRIES: + r429 += 1 + logger.info( + "avito page=%d HTTP 429 (backconnect conn-limit) — short retry %d/%d", + page, + r429, + _AVITO_429_MAX_RETRIES, + ) + jitter = (r429 % 3) * 0.1 + await asyncio.sleep(_AVITO_429_BACKOFF_SEC + jitter) + continue + if sc in (403, 429) or is_firewall: + if rot_done < max_rot and await self._rotate_ip(): + rot_done += 1 + logger.info( + "avito page=%d blocked (HTTP %d, firewall=%s) — retry after rotation #%d", + page, + sc, + is_firewall, + rot_done, + ) + continue + # backconnect: 403/firewall = только текущий exit-IP залочен. Новый + # CONNECT-туннель (пересоздание сессии) = свежий IP → reconnect-retry. + # Только для 403/firewall (НЕ 429 — у него свой short-retry выше). + if backconnect and (sc == 403 or is_firewall) and r403 < _AVITO_403_MAX_RETRIES: + r403 += 1 + logger.info( + "avito page=%d blocked (HTTP %d, firewall=%s) — " + "backconnect reconnect retry %d/%d", + page, + sc, + is_firewall, + r403, + _AVITO_403_MAX_RETRIES, + ) + await self._reset_cffi() + await asyncio.sleep(_AVITO_403_BACKOFF_SEC) + continue + if sc == 429: + # short-retry (тот же session) исчерпан. Под backconnect одна + # фаза reconnect-fallback: пересоздание сессии освобождает + # удерживаемый conn-slot и может лечь на менее загруженный путь + # (deep-pagination 429 = conn-limit, не IP-ban). Только потом raise. + if backconnect and r429_reconnect < _AVITO_429_RECONNECT_RETRIES: + r429_reconnect += 1 + logger.info( + "avito page=%d HTTP 429 short-retry exhausted — " + "backconnect reconnect retry %d/%d", + page, + r429_reconnect, + _AVITO_429_RECONNECT_RETRIES, + ) + await self._reset_cffi() + await asyncio.sleep(_AVITO_403_BACKOFF_SEC) + continue + logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url) + raise AvitoRateLimitedError( + f"Avito SERP returned 429 — rate limited at page={page}" + ) + logger.error( + "avito SERP blocked page=%d (HTTP %d, firewall=%s) url=%s", + page, + sc, + is_firewall, + url, + ) + raise AvitoBlockedError( + f"Avito SERP blocked (HTTP {sc}, firewall={is_firewall}) " + f"at page={page} — IP banned" + ) + if sc != 200: + logger.warning("avito HTML page=%d returned %d for %s", page, sc, url) + return None + return response.text + + async def _fetch_serp_html_with_retry( + self, url: str, page: int, *, max_retries: int = 2 + ) -> str | None: + """Page-level retry поверх _fetch_serp_html для sweep-обходов (P2 resilience). + + _fetch_serp_html уже ретраит транзиентные fetch-обрывы и ротирует IP, но + если после внутренних попыток он всё равно поднимает generic Exception — + внешний sweep-цикл (citywide/byrooms) делал break и терял весь хвост. + Здесь даём странице ещё ``max_retries`` попыток перед тем как пробросить. + + Поведение: + - AvitoBlockedError / AvitoRateLimitedError — hard-block, пробрасываются + немедленно (НЕ retry). + - generic Exception — если попытки остались: warning + короткий backoff + + следующая попытка; иначе re-raise последнего исключения (внешний цикл + решит break). + - None — это «non-200 / конец пагинации», НЕ ошибка → возвращается как есть + без ретраев. + """ + attempts = max_retries + 1 + backoff = max(2.0, min(self.request_delay_sec, 4.0)) + for attempt in range(1, attempts + 1): + try: + return await self._fetch_serp_html(url, page) + except (AvitoBlockedError, AvitoRateLimitedError): + raise + except Exception: + if attempt >= attempts: + raise + logger.warning( + "avito page=%d transient error — retry (attempt %d/%d)", + page, + attempt, + max_retries, + exc_info=True, + ) + await asyncio.sleep(backoff) + return None # pragma: no cover — loop either returns or raises + + # ── Public ────────────────────────────────────────────────────────────── + async def fetch_around( + self, + lat: float, + lon: float, + radius_m: int = 1000, + *, + pages: int = 1, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """Найти объявления Авито вокруг (lat, lon) в radius_m метрах. + + Avito работает в км — конвертируем. Минимум 1 км. + + pages=1 (default) — backward compat, одна страница (~50 lots). + pages>1 — итерируем p=1..pages, собираем до 50*pages lots. + Между запросами sleep request_delay_sec (или delay_override_sec если передан). + + Coords НЕ заполняем из anchor (избегаем silent corruption через jitter). + Точные lat/lon приходят позже из avito_detail.py — для search-карточек + lat=lon=None, далее geocode-missing cron подтянет из address. + """ + radius_km = max(1, round(radius_m / 1000)) + if delay_override_sec is not None: + self.request_delay_sec = delay_override_sec + + all_lots: list[ScrapedLot] = [] + for page in range(1, pages + 1): + url = self._build_web_url(lat, lon, radius_km, page=page) + try: + html = await self._fetch_serp_html(url, page) + except (AvitoBlockedError, AvitoRateLimitedError): + raise + except Exception: + logger.exception("avito HTML page=%d fetch failed for %s", page, url) + break + if html is None: + break + + lots = self._parse_html(html, source_url_base=url) + if not lots: + if page == 1: + logger.error( + "avito SERP page=1 returned HTTP 200 but 0 cards — " + "likely content-block/captcha url=%s", + url, + ) + raise AvitoContentBlockedError( + "Avito SERP HTTP 200 with 0 cards on page=1 — content-block suspected" + ) + logger.info("avito page=%d: 0 lots — end of pagination", page) + break + all_lots.extend(lots) + logger.info( + "avito page=%d: %d lots (total %d) around (%.4f, %.4f)", + page, + len(lots), + len(all_lots), + lat, + lon, + ) + if page < pages: + await self.sleep_between_requests() + + if pages == 1 and all_lots: + # Backward compat: single-page path does trailing sleep (как раньше) + await self.sleep_between_requests() + elif pages > 1: + logger.info( + "avito fetch_around pages=%d total_lots=%d around (%.4f, %.4f)", + pages, + len(all_lots), + lat, + lon, + ) + + return all_lots + + # ── Strategy B: HTML scrape ───────────────────────────────────────────── + def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str: + params = { + "geoCoords": f"{lat},{lon}", + "radius": radius_km, + "s": 104, # sort by date + "p": page, + } + return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}" + + def _build_citywide_url(self, page: int = 1) -> str: + """T6: URL всего города ЕКБ без geo-фильтра, сортировка по дате. + + Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек). + """ + params = {"s": 104, "p": page} + return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}" + + def _build_newbuilding_url(self, page: int = 1) -> str: + """URL ЕКБ-выборки только новостроек (novostroyka-filter), сортировка по дате. + + Тот же shape параметров, что _build_citywide_url (s=104, p=page), но slug + категории NOVOSTROYKA_SLUG отдаёт исключительно new-build карточки. + """ + params = {"s": 104, "p": page} + path = f"/ekaterinburg/kvartiry/prodam/{NOVOSTROYKA_SLUG}" + return f"{self.base_url}{path}?{urlencode(params)}" + + def _build_rooms_url( + self, + room_slug: str, + page: int = 1, + min_price: int | None = None, + max_price: int | None = None, + ) -> str: + """T6: URL с фильтром по комнатности для всего ЕКБ (no geo). + + Дробление по комнатности позволяет обойти cap ~5000 на category. + room_slug — берётся из ROOM_SLUGS (кодирует ASgB-параметры выборки). + + min_price/max_price — опциональные ценовые границы (Avito `pmin`/`pmax`), + добавляются в query ТОЛЬКО если заданы (backward-compat: вызовы без цены + не меняют URL). Используются exhaustive full-load для room×price бисекции. + """ + params: dict[str, Any] = {"s": 104, "p": page} + if min_price is not None: + params["pmin"] = min_price + if max_price is not None: + params["pmax"] = max_price + return f"{self.base_url}/ekaterinburg/kvartiry/prodam/{room_slug}?{urlencode(params)}" + + def _extract_total_count(self, html: str) -> int | None: + """Извлечь общее число результатов из Avito SERP (`page-title/count`). + + Avito рендерит счётчик в <span data-marker="page-title/count">1 178</span>. + Это аналог cian totalOffers — gate для бисекции (total > cap → split). + Парсинг: текст span → убрать NBSP (\xa0)/пробелы/` ` → int. + + Порядок: сначала `page-title/count` (его несёт ТОЛЬКО непустая выборка), + потом no-results-маркер → 0 (валидная пустая выборка: брекет скипается без + split/rotation и без скрапа ~50 fallback-«похожих»), и только при отсутствии + обоих — None (реально битая/captcha/firewall/незагруженная страница). + + Returns: + int при успехе (включая 0 на no-results-странице), либо None при + captcha/firewall/незагруженной странице (ни count, ни no-results). + """ + m = _TOTAL_COUNT_RE.search(html) + if m is not None: + raw = m.group("count") + # Убрать NBSP (\xa0),  -entity и обычные пробелы-разделители разрядов. + cleaned = raw.replace("\xa0", "").replace(" ", "").replace(" ", "") + if cleaned: + try: + return int(cleaned) + except (TypeError, ValueError): + pass + # Счётчик не найден/не распарсился. Проверяем no-results-маркер: его наличие + # = валидная пустая выборка (total=0), несмотря на ~50 fallback-объявлений. + lowered = html.lower() + if any(marker in lowered for marker in _AVITO_NO_RESULTS_MARKERS): + return 0 + return None + + async def _fetch_rooms_page_html( + self, + room_slug: str, + page: int, + min_price: int | None, + max_price: int | None, + ) -> str | None: + """GET одной room×price SERP-страницы через anti-block pipeline. + + Делегирует _fetch_serp_html (firewall-detect, IP-rotation, транзиентные + ретраи). AvitoBlockedError/AvitoRateLimitedError пробрасываются наверх. + """ + url = self._build_rooms_url(room_slug, page, min_price, max_price) + return await self._fetch_serp_html(url, page) + + async def _probe_total( + self, room_slug: str, min_price: int | None, max_price: int | None + ) -> tuple[str | None, int | None]: + """Probe page=1 бакета и извлекает total, устойчиво к browser-ошибкам. + + tradein-browser на тяжёлой выдаче (огромный диапазон, напр. студии + pmax=200M) может таймаутить на goto → HTTP 500 → ``httpx.HTTPStatusError``, + либо отдавать ``httpx.TimeoutException``/``ReadTimeout``. Раньше эти ошибки + пробрасывались из probe до самого верха и роняли весь full-load с 0 uniq. + Теперь они трактуются как «total недоступен» (``None``) — вызывающий + ``_walk_price_range`` решит: split (бакет делим) или skip (узкий бакет). + + AvitoBlockedError/AvitoRateLimitedError НЕ глушатся — это hard-block, + должен всплыть в pipeline (mark_banned). Возвращает (html, total); при + ошибке fetch'а html=None. + """ + try: + html = await self._fetch_rooms_page_html(room_slug, 1, min_price, max_price) + except (AvitoBlockedError, AvitoRateLimitedError): + # Hard-block — пробрасываем наверх (pipeline пометит run banned). + raise + except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.HTTPError) as exc: + logger.warning( + "avito: probe browser error %s for %s [%s, %s] — treating as total=None", + type(exc).__name__, + room_slug, + min_price, + max_price, + ) + return None, None + except Exception: + logger.warning( + "avito: probe unexpected error for %s [%s, %s] — treating as total=None", + room_slug, + min_price, + max_price, + exc_info=True, + ) + return None, None + + total = self._extract_total_count(html) if html is not None else None + return html, total + + async def fetch_all_secondary( + self, + *, + rooms_buckets: list[tuple[str, str]] | None = None, + price_cap_per_bucket: int = 1400, + max_pages_per_bucket: int = 100, + 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, + since: date | None = None, + ) -> list[ScrapedLot]: + """Exhaustive- ИЛИ инкрементальная загрузка Avito ЕКБ вторички (КОМНАТНОСТЬ × ЦЕНА). + + Обходит Avito SERP-cap (~5000 результатов на запрос). Вместо корень-бисекции + [0, _AVITO_MAX_PRICE] на каждую комнатность (≈5 холостых probe-спусков через + пустой верх, т.к. вторичка ЕКБ почти вся <16М) — стартуем с data-driven + seed-сетки ценовых брекетов (_AVITO_PRICE_SEED_BRACKETS) по прод-распределению. + Каждый (комнатность × seed-брекет) дальше дробится _walk_price_range если + total > price_cap_per_bucket. Последний seed-брекет ОТКРЫТ (hi=None → pmax + не ставится) — ловит весь хвост люкса без потолка. + + Страницы leaf-бакета — параллельно (Semaphore). Дедуп по source_id (dict seen), + ОБЩИЙ по всем брекетам и комнатностям. + + Параметры: + rooms_buckets: список (name, slug) комнатностей (default: все ROOM_SLUGS, + вкл. студии и своб.планировку). + price_cap_per_bucket: максимум результатов в бакете перед делением (< 5000). + max_pages_per_bucket: верхний предел страниц на leaf-бакет (default=100). + concurrency: максимум параллельных page-фетчей в leaf-бакете (default=5). + secondary_only: если True (default) — отбрасывает новостройки + (listing_segment=="novostroyki") после парсинга, до save/on_bucket. + Avito тегирует сегмент по DOM-маркеру item-development-name в _parse_html. + 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-запросы выполняются. + since: если None (default) — EXHAUSTIVE bisection-обход (поведение без + изменений). Если задана date — INCREMENTAL: на каждый (комнатность × + seed-брекет) последовательная пагинация newest-first с ранней остановкой, + как только страница уходит ниже watermark `since`. Бисекция по цене НЕ + выполняется (читаем только верхние свежие страницы, до SERP-cap далеко). + + Возвращает list[ScrapedLot] уникальных лотов (дедуп по source_id/source_url). + """ + _buckets = rooms_buckets if rooms_buckets is not None else _AVITO_DEFAULT_ROOMS + seen: dict[str, ScrapedLot] = {} + # Tolerance к одиночным per-bucket блокам (deep-pagination 429 dense-бакета): + # скипаем заблокированный бакет и продолжаем; N ПОДРЯД блоков = hard ban → re-raise. + consecutive_blocked = 0 + skipped_blocked = 0 + + for name, slug in _buckets: + room_label = _avito_room_label(name) + logger.info( + "avito 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 + # (pmax эксклюзивный сверху → соседние брекеты не пересекаются), для + # ОТКРЫТОГО (br_hi is None) — hi=None (без потолка). Дедуп по source_id + # в общем `seen` страхует на стыках. + for br_lo, br_hi in _AVITO_PRICE_SEED_BRACKETS: + walk_hi = br_hi - 1 if br_hi is not None else None + try: + if since is not None: + # INCREMENTAL: последовательная newest-first пагинация с ранней + # остановкой по watermark `since`; без бисекции по цене. + await self._paginate_incremental_bracket( + room_slug=slug, + room_label=room_label, + lo=br_lo, + hi=walk_hi, + since=since, + seen=seen, + max_pages_per_bucket=max_pages_per_bucket, + secondary_only=secondary_only, + on_bucket=on_bucket, + ) + else: + await self._walk_price_range( + room_slug=slug, + room_label=room_label, + 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, + ) + except (AvitoRateLimitedError, AvitoBlockedError) as exc: + # Одиночный блок на бакете (deep-pagination 429 dense-бакета) НЕ + # роняет sweep: скипаем бакет, продолжаем. N ПОДРЯД блоков = реальный + # hard IP-ban → re-raise (run_avito_full_load → mark_banned). + consecutive_blocked += 1 + skipped_blocked += 1 + logger.warning( + "avito exhaustive: %s bracket [%s..%s] skipped — blocked (%s); " + "consecutive_blocked=%d/%d", + room_label, + br_lo, + walk_hi, + type(exc).__name__, + consecutive_blocked, + _AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED, + ) + if consecutive_blocked >= _AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED: + logger.error( + "avito exhaustive: %d consecutive blocked buckets — " + "treating as hard IP-ban, aborting sweep", + consecutive_blocked, + ) + raise + continue + # Успешный бакет — сбрасываем счётчик подряд-блоков. + consecutive_blocked = 0 + room_collected = len(seen) - before + logger.info( + "avito 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( + "avito exhaustive: DONE — total unique=%d lots skipped_blocked=%d", + len(seen), + skipped_blocked, + ) + return list(seen.values()) + + async def _walk_price_range( + self, + *, + room_slug: str, + room_label: str, + 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 с pmin=lo, pmax=hi → total (page-title/count). + 2. Если total <= cap → пагинировать leaf-бакет параллельно (concurrency). + 3. Если total > cap → разбить бакет пополам (рекурсия). + Guard: hi - lo < _AVITO_MIN_BRACKET → пагинировать как есть (WARNING). + + ОТКРЫТЫЙ брекет (hi is None) — верхний seed-брекет без потолка (pmax не + ставится, avito отдаёт всё ≥lo). Делить нельзя (mid=(lo+hi)//2 невозможен) — + probe + пагинируем leaf напрямую. Хвост 250М+ крошечный (≈73), за cap + обычно не выходит; если вышел — пагинируем до max_pages + WARNING (acceptable). + + После пагинации leaf-бакета вызывает on_bucket(bucket_key, bucket_lots). + bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый). + on_bucket может быть async или sync. Исключение в on_bucket прерывает прогон. + skip_buckets: если bucket_key в skip_buckets — пагинация и on_bucket пропускаются. + secondary_only: новостройки (listing_segment=="novostroyki") отбрасываются после + сбора bucket_lots, до дедупа в seen и вызова on_bucket. + AvitoBlockedError/AvitoRateLimitedError из _fetch_serp_html пробрасываются наверх. + """ + _lo_param = lo if lo > 0 else None + _hi_repr = "open" if hi is None else str(hi) + + # ── Шаг 1: probe page 1 ──────────────────────────────────────────────── + # _probe_total ловит browser-ошибки (HTTP 500 / timeout от tradein-browser + # на тяжёлой выдаче, напр. студии pmax=200M) → total=None вместо краха. + # AvitoBlockedError/AvitoRateLimitedError всплывают наверх (mark_banned). + html, total = await self._probe_total(room_slug, _lo_param, hi) + + # Ретрай на captcha/missing-count/browser-ошибке: rotate IP + 1 retry. + if total is None: + logger.warning( + "avito: total=None for %s [%d, %s] depth=%d — rotating IP + retry", + room_label, + lo, + _hi_repr, + _depth, + ) + rotated = await self._rotate_ip() + if rotated: + html, total = await self._probe_total(room_slug, _lo_param, hi) + + if total is None: + # ОТКРЫТЫЙ брекет: делить нельзя (нет hi). Probe failed → нет total для + # ceil(); пагинируем до max_pages_per_bucket (best-effort, хвост 250М+ + # крошечный) и WARNING. НЕ роняем прогон. + if hi is None: + logger.warning( + "avito: probe failed for OPEN bucket %s [%d, open] depth=%d — " + "paginating up to max_pages (tail-loss accepted)", + room_label, + lo, + _depth, + ) + await self._paginate_leaf_bucket( + room_slug=room_slug, + room_label=room_label, + lo=lo, + hi=None, + html=None, + max_pages=max_pages_per_bucket, + 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, + ) + return + bracket_size = hi - lo + # Probe не удался даже после ретрая. Если бакет ещё делим — НЕ теряем + # его целиком: тяжёлая страница (огромная выдача) скорее всего станет + # загружаемой в более узком ценовом диапазоне → принудительный split. + if bracket_size >= _AVITO_MIN_BRACKET: + logger.warning( + "avito: probe failed for %s [%d, %d] depth=%d — splitting (assume heavy)", + room_label, + lo, + hi, + _depth, + ) + mid = (lo + hi) // 2 + await self._walk_price_range( + room_slug=room_slug, + room_label=room_label, + 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, + ) + await self._walk_price_range( + room_slug=room_slug, + room_label=room_label, + 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 + # Бакет уже узкий (< MIN_BRACKET) — split не поможет, хвост теряем + # осознанно (как cian) и логируем. + logger.error( + "avito: skipping bucket %s [%d, %d] — total unavailable after retry " + "(bracket=%d < MIN_BRACKET=%d, tail loss accepted)", + room_label, + lo, + hi, + bracket_size, + _AVITO_MIN_BRACKET, + ) + return + + logger.info( + "avito: %s [%d, %s] total=%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( + "avito: OPEN bucket %s [%d, open] total=%d > cap=%d — paginating " + "without split (tail-loss accepted, lux tail tiny)", + room_label, + lo, + total, + price_cap_per_bucket, + ) + pages_needed = math.ceil(total / _AVITO_OFFERS_PER_PAGE) + max_pages = min(pages_needed, max_pages_per_bucket) + await self._paginate_leaf_bucket( + room_slug=room_slug, + room_label=room_label, + lo=lo, + hi=None, + html=html, + max_pages=max_pages, + 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, + ) + return + + # ── Шаг 2: деление или пагинация ────────────────────────────────────── + bracket_size = hi - lo + need_split = total > price_cap_per_bucket + too_narrow = bracket_size < _AVITO_MIN_BRACKET + + if need_split and too_narrow: + logger.warning( + "avito: %s [%d, %d] total=%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, + _AVITO_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( + room_slug=room_slug, + room_label=room_label, + 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( + room_slug=room_slug, + room_label=room_label, + 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-бакет: считаем max_pages + warning, делегируем helper'у ─ + pages_needed = math.ceil(total / _AVITO_OFFERS_PER_PAGE) + max_pages = min(pages_needed, max_pages_per_bucket) + + if pages_needed > max_pages_per_bucket: + tail_loss = total - max_pages_per_bucket * _AVITO_OFFERS_PER_PAGE + logger.warning( + "avito: %s [%d, %d] total=%d exceeds page cap " + "(max_pages=%d × %d=%d offers) — tail loss ~%d offers", + room_label, + lo, + hi, + total, + max_pages_per_bucket, + _AVITO_OFFERS_PER_PAGE, + max_pages_per_bucket * _AVITO_OFFERS_PER_PAGE, + tail_loss, + ) + + await self._paginate_leaf_bucket( + room_slug=room_slug, + room_label=room_label, + lo=lo, + hi=hi, + html=html, + max_pages=max_pages, + 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, + ) + + async def _paginate_leaf_bucket( + self, + *, + room_slug: str, + room_label: str, + lo: int, + hi: int | None, + html: str | None, + max_pages: int, + seen: dict[str, ScrapedLot], + price_cap_per_bucket: int, + max_pages_per_bucket: int, + concurrency: int, + secondary_only: bool, + on_bucket: Callable[..., Any] | None, + skip_buckets: set[str] | None, + ) -> None: + """Параллельная пагинация одного leaf-бакета + фильтр + дедуп + on_bucket. + + Общая для ЗАКРЫТЫХ ([lo, hi]) и ОТКРЫТОГО (hi=None → pmax не ставится) + брекетов. ``html`` — HTML страницы 1 из probe (переиспользуем, не + перезапрашиваем); для открытого-без-probe передаётся None → стр.1 фетчится + наравне с остальными. ``max_pages`` уже посчитан вызывающим. + + bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый). + skip_buckets: если bucket_key в skip_buckets — пагинация и on_bucket пропускаются. + AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх. + """ + _lo_param = lo if lo > 0 else None + _hi_param = hi # None → _build_rooms_url не ставит pmax + _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("avito: skip bucket %s — already done (resume)", bucket_key) + return + + # Страница 1 — из probe-html если он есть (закрытый/открытый с probe); + # иначе фетчим её как обычную страницу (открытый брекет без probe-html). + sem = asyncio.Semaphore(concurrency) + first_url = self._build_rooms_url(room_slug, 1, _lo_param, _hi_param) + + async def _one_page(p: int) -> list[ScrapedLot]: + if p == 1 and html is not None: + return self._parse_html(html, source_url_base=first_url) + async with sem: + page_html = await self._fetch_rooms_page_html(room_slug, p, _lo_param, _hi_param) + await asyncio.sleep(self.request_delay_sec) + if page_html is None: + logger.warning( + "avito: page_html=None %s [%d, %s] page=%d — skipping page", + room_label, + lo, + _hi_repr, + p, + ) + return [] + page_url = self._build_rooms_url(room_slug, p, _lo_param, _hi_param) + return self._parse_html(page_html, source_url_base=page_url) + + 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): + # Блокировки пробрасываем наверх (mark_banned в pipeline-обёртке). + if isinstance(res, AvitoBlockedError | AvitoRateLimitedError): + raise res + logger.warning( + "avito: 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( + "avito: %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 + + async def _paginate_incremental_bracket( + self, + *, + room_slug: str, + room_label: str, + lo: int, + hi: int | None, + since: date, + seen: dict[str, ScrapedLot], + max_pages_per_bucket: int, + secondary_only: bool, + on_bucket: Callable[..., Any] | None, + ) -> None: + """INCREMENTAL пагинация одного (комнатность × seed-брекет) с ранней остановкой. + + SERP отсортирован newest-first (s=104). Идём ПОСЛЕДОВАТЕЛЬНО со страницы 1; + как только страница не содержит ни одной карточки с listing_date >= since — + весь остаток бакета старше watermark → останавливаемся. Бисекция по цене НЕ + выполняется: читаем только верхние свежие страницы, до SERP-cap (~5000) далеко. + + Early-stop semantics: + - На каждой странице берём max(listing_date) среди датированных карточек. + Если ни одна карточка не имеет listing_date >= since → страница «старая». + - Карточки с listing_date is None НЕ триггерят стоп сами по себе (трактуем + как «продолжить», чтобы не потерять свежие-но-недатированные карточки). + - Но страница, целиком состоящая из None-или-старых карточек, считается + «не-свежей»; 2 ПОДРЯД не-свежие страницы → стоп (grace против runaway, + когда весь хвост недатирован). + - page_html is None или parse == [] → конец пагинации, стоп. + + bucket_key, secondary_only-фильтр и дедуп в seen — идентичны _paginate_leaf_bucket. + on_bucket вызывается один раз для собранного бакета (async/sync-aware). + AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх. + """ + _lo_param = lo if lo > 0 else None + _hi_param = hi # None → _build_rooms_url не ставит pmax + _hi_repr = "open" if hi is None else str(hi) + bucket_key = f"{room_label}:{lo}:{_hi_repr}" + + bucket_lots: list[ScrapedLot] = [] + pages_fetched = 0 + not_fresh_streak = 0 # подряд идущие не-свежие (all None-или-старые) страницы + stop_reason = "max-pages" # перетирается ниже на реальную причину + + for p in range(1, max_pages_per_bucket + 1): + # Последовательный фетч (НЕ asyncio.gather): early-stop требует читать + # страницы по порядку. Тот же helper, что и в _paginate_leaf_bucket. + page_html = await self._fetch_rooms_page_html(room_slug, p, _lo_param, _hi_param) + await asyncio.sleep(self.request_delay_sec) + pages_fetched += 1 + + if page_html is None: + stop_reason = "end-of-pages (page_html=None)" + break + + page_url = self._build_rooms_url(room_slug, p, _lo_param, _hi_param) + page_lots = self._parse_html(page_html, source_url_base=page_url) + if not page_lots: + stop_reason = "end-of-pages (empty parse)" + break + + bucket_lots.extend(page_lots) + + # Свежесть страницы: есть ли хоть одна карточка с датой >= since. + dated = [lot.listing_date for lot in page_lots if lot.listing_date is not None] + has_fresh = any(d >= since for d in dated) + if has_fresh: + not_fresh_streak = 0 + continue + + # Страница не содержит свежих датированных карточек. + if dated: + # Есть даты, но все < since → newest-first гарантирует, что дальше + # только старее → стоп немедленно. + stop_reason = "early-stop (page all older than since)" + break + # Все карточки undated (None) → не стопим сразу (могут быть свежие без + # даты), но копим streak; 2 подряд недатированные страницы → grace-стоп. + not_fresh_streak += 1 + if not_fresh_streak >= 2: + stop_reason = "early-stop (2 consecutive all-undated pages, grace)" + break + + collected_this_bucket = len(bucket_lots) + + # ── Фильтр новостроек (secondary_only) — идентично _paginate_leaf_bucket ── + 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( + "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d " + "unique_total=%d incremental since=%s stop=%s", + room_label, + lo, + _hi_repr, + pages_fetched, + collected_this_bucket, + dropped_nb, + len(seen), + since.isoformat(), + stop_reason, + ) + + # ── 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 + + # ── T6: citywide / byrooms fetch methods ────────────────────────────────── + + async def fetch_city_wide( + self, + pages: int = 100, + *, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """T6: Обход всего ЕКБ без geo-фильтра (citywide-mode), paginated. + + Возвращает дедуплицированный список лотов (по source_id/source_url). + Break-on-empty: останавливается когда страница отдаёт 0 карточек. + Сохраняет весь anti-block pipeline (_fetch_serp_html: firewall-detect, + IP rotation, транзиентные ретраи). Не трогает fetch_around. + + Args: + pages: максимальное число страниц (default 100 = Avito cap ~5000 лотов). + delay_override_sec: если задан — переопределяет request_delay_sec для + этого вызова. + + Returns: + Список ScrapedLot (все страницы, дедуп по source_id). + """ + all_lots = await self._paginate_sweep( + pages, + self._build_citywide_url, + label="citywide", + delay_override_sec=delay_override_sec, + ) + logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots)) + return all_lots + + async def _paginate_sweep( + self, + pages: int, + url_builder: Callable[[int], str], + *, + label: str, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """Общий paginated-обход ЕКБ (citywide / novostroyka) с break-on-empty. + + url_builder(page) — функция построения URL страницы (citywide или + novostroyka). Сохраняет anti-block pipeline (_fetch_serp_html: firewall, + IP rotation, ретраи), дедуп по source_id, break-on-empty. label — только + для логов. Не меняет наблюдаемое поведение fetch_city_wide. + """ + if delay_override_sec is not None: + self.request_delay_sec = delay_override_sec + + all_lots: list[ScrapedLot] = [] + seen_ids: set[str] = set() + + for page in range(1, pages + 1): + url = url_builder(page) + try: + html = await self._fetch_serp_html_with_retry(url, page) + except (AvitoBlockedError, AvitoRateLimitedError): + raise + except Exception: + logger.exception( + "avito %s page=%d fetch failed after retries url=%s", label, page, url + ) + break + if html is None: + logger.info("avito %s page=%d: non-200 — end of pagination", label, page) + break + + lots = self._parse_html(html, source_url_base=url) + if not lots: + logger.info("avito %s page=%d: 0 lots — end of pagination", label, page) + break + + new_lots = [lot for lot in lots if lot.source_id not in seen_ids] + for lot in new_lots: + if lot.source_id: + seen_ids.add(lot.source_id) + all_lots.extend(new_lots) + logger.info( + "avito %s page=%d: %d lots (%d new, %d dedup'd, total=%d)", + label, + page, + len(lots), + len(new_lots), + len(lots) - len(new_lots), + len(all_lots), + ) + if page < pages: + await self.sleep_between_requests() + return all_lots + + async def fetch_newbuildings( + self, + pages: int = 30, + *, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """Обход ЕКБ-выборки только новостроек (novostroyka-filter), paginated. + + Avito отдаёт dedicated SERP с фильтром «Новостройка» — 100% new-build + карточек (с маркером застройщика → listing_segment="novostroyki", + newbuilding_id/newbuilding_url заполнены). Citywide (без geo/anchor). + Возвращает дедуплицированный список (по source_id), break-on-empty. + Сохраняет весь anti-block pipeline. Не трогает fetch_city_wide/fetch_around. + + Args: + pages: максимальное число страниц (default 30). + delay_override_sec: если задан — переопределяет request_delay_sec для + этого вызова. + + Returns: + Список ScrapedLot новостроек (все страницы, дедуп по source_id). + """ + all_lots = await self._paginate_sweep( + pages, + self._build_newbuilding_url, + label="newbuilding", + delay_override_sec=delay_override_sec, + ) + logger.info("avito fetch_newbuildings pages=%d total_lots=%d", pages, len(all_lots)) + return all_lots + + async def fetch_by_rooms( + self, + pages: int = 100, + *, + delay_override_sec: float | None = None, + room_slugs: list[tuple[str, str]] | None = None, + ) -> list[ScrapedLot]: + """T6: Обход ЕКБ по категориям комнатности (byrooms-mode). + + Дробление по 7 категориям комнатности позволяет обойти Avito cap ~5000 + результатов на поиск: каждый сегмент < 5000. Итого до 7 × 5000 = 35k лотов. + Break-on-empty на каждой категории. Глобальный dedup по source_id. + + Args: + pages: страниц на категорию (default 100). + delay_override_sec: переопределяет request_delay_sec. + room_slugs: список (name, slug) для обхода; по умолчанию ROOM_SLUGS + (все 7 категорий). Передай подмножество для частичного обхода. + + Returns: + Список ScrapedLot (все категории, глобальный дедуп по source_id). + """ + if delay_override_sec is not None: + self.request_delay_sec = delay_override_sec + + slugs = room_slugs if room_slugs is not None else ROOM_SLUGS + all_lots: list[ScrapedLot] = [] + seen_ids: set[str] = set() + + for ci, (name, slug) in enumerate(slugs, 1): + logger.info( + "avito byrooms [%d/%d] category=%s slug_prefix=%.30s", + ci, + len(slugs), + name, + slug, + ) + cat_count = 0 + for page in range(1, pages + 1): + url = self._build_rooms_url(slug, page) + try: + html = await self._fetch_serp_html_with_retry(url, page) + except (AvitoBlockedError, AvitoRateLimitedError): + raise + except Exception: + logger.exception( + "avito byrooms category=%s page=%d fetch failed after retries url=%s", + name, + page, + url, + ) + break + if html is None: + logger.info("avito byrooms category=%s page=%d: non-200 — end", name, page) + break + + lots = self._parse_html(html, source_url_base=url) + if not lots: + logger.info( + "avito byrooms category=%s page=%d: 0 lots — end of category", + name, + page, + ) + break + + new_lots = [lot for lot in lots if lot.source_id not in seen_ids] + for lot in new_lots: + if lot.source_id: + seen_ids.add(lot.source_id) + all_lots.extend(new_lots) + cat_count += len(new_lots) + logger.info( + "avito byrooms category=%s page=%d: %d lots (%d new, total=%d)", + name, + page, + len(lots), + len(new_lots), + len(all_lots), + ) + if page < pages: + await self.sleep_between_requests() + + logger.info( + "avito byrooms category=%s done: cat_new=%d grand_total=%d", + name, + cat_count, + len(all_lots), + ) + + logger.info("avito fetch_by_rooms pages_per_cat=%d total_lots=%d", pages, len(all_lots)) + return all_lots + + def _parse_html(self, html: str, source_url_base: str) -> list[ScrapedLot]: + """Парсим карточки объявлений из HTML через DOM scrape. + + Поля карточки берём из DOM (`data-marker="item-*"`). Дату публикации — из + embedded per-item JSON (`sortTimeStamp`), т.к. DOM `item-date` рендерится + лениво только у верхних карточек (#726); DOM-marker остаётся fallback. + + Если self._config.avito_serp_ekb_only=True (дефолт), отбрасываем карточки, + чей per-card source_url не содержит /ekaterinburg/ — Avito добивает выдачу + «по всей России» при малом числе ЕКБ-результатов (4+ комн., дорогие сегменты). + Карточки с нераспознанным city-slug (href без ведущего /city/) пропускаются + консервативно (не дропаются по ошибке). + """ + tree = HTMLParser(html) + ts_map = _build_sort_timestamp_map(html) + cards = tree.css('[data-marker="item"]') + lots: list[ScrapedLot] = [] + ekb_only = self._config.avito_serp_ekb_only + dropped = 0 + no_city = 0 + for card in cards: + lot = self._dom_card_to_lot(card, source_url_base, ts_map) + if lot is None: + continue + if ekb_only: + card_url = lot.source_url or "" + # Определяем city-slug по первому сегменту пути в абсолютном URL. + # urljoin всегда возвращает абсолютный URL (https://www.avito.ru/...). + # Avito city-slug — первый path-сегмент после netloc. + # Пример: https://www.avito.ru/ekaterinburg/... → /ekaterinburg/ присутствует. + # https://www.avito.ru/moskva/... → нет /ekaterinburg/. + # Для href без явного city (/kvartiry/... — теоретически не должно быть, + # но консервативно пропускаем). + if "/ekaterinburg/" in card_url: + # ЕКБ-карточка — берём + pass + else: + # Проверяем, есть ли вообще city-slug (хотя бы один path-сегмент + # после авито-хоста). Если нет — консервативно берём. + parsed_path = urlparse(card_url).path + # Первый непустой сегмент пути — city-slug в Avito URL. + first_seg = next((s for s in parsed_path.split("/") if s), None) + # Известные не-city сегменты Avito (/kvartiry/, /item/, ...) — + # если первый сегмент — не похоже на city-slug, пропускаем консервативно. + # Простая эвристика: city-slug содержит только строчные буквы/дефис/цифры + # и НЕ является известным non-city сегментом. + _non_city_segs = {"kvartiry", "item", "catalog", "user", "api", ""} + if first_seg is None or first_seg in _non_city_segs: + no_city += 1 + # Нет city-slug → консервативно берём карточку + lots.append(lot) + continue + # Есть city-slug, но не /ekaterinburg/ → padding по России → дропаем + dropped += 1 + continue + lots.append(lot) + if ekb_only and (dropped or no_city): + logger.info( + "avito SERP: dropped %d non-EKB cards (padding), %d cards without city-slug kept", + dropped, + no_city, + ) + return lots + + def _dom_card_to_lot( + self, card: Any, source_url_base: str, ts_map: dict[str, date] | None = None + ) -> ScrapedLot | None: + """Парсинг карточки объявления через DOM. + + Avito state давно пустой — единственный путь. Coords из карточки не отдаются, + оставляем lat=lon=None (geocode-missing cron подтянет из address). + """ + try: + link_el = card.css_first('a[data-marker="item-title"]') + if link_el is None: + return None + href = link_el.attributes.get("href", "") + url = urljoin("https://www.avito.ru", href) + title = link_el.text(strip=True) + + price_el = card.css_first('meta[itemprop="price"]') + price = int(price_el.attributes.get("content", 0)) if price_el is not None else 0 + if price <= 0: + return None + + rooms = _extract_rooms_from_title(title) + area = _extract_area_from_title(title) + floor, total = _extract_floor_from_title(title) + + # Адрес: первый <p> внутри data-marker="item-location" + address: str | None = None + loc_el = card.css_first('[data-marker="item-location"]') + if loc_el is not None: + street_el = loc_el.css_first("p") + if street_el is not None: + address = _clean_address(street_el.text(strip=True)) + + # Фото + photo_urls: list[str] = [] + for img in card.css('img[itemprop="image"]'): + src = img.attributes.get("src") or "" + if src.startswith("http") and src not in photo_urls: + photo_urls.append(src) + if len(photo_urls) >= 5: + break + + # Дата публикации (#726): primary — sortTimeStamp из per-item JSON по + # data-item-id (DOM item-date рендерится лениво только у верхних карточек), + # fallback — DOM marker item-date/item-date-info + relative-date parse. + listing_date: date | None = None + item_id = card.attributes.get("data-item-id") + if ts_map and item_id: + listing_date = ts_map.get(item_id) + if listing_date is None: + date_el = card.css_first('[data-marker="item-date"]') + if date_el is None: + date_el = card.css_first('[data-marker="item-date-info"]') + if date_el is not None: + listing_date = _parse_relative_date(date_el.text(strip=True)) + if listing_date is None: + # Fallback: ищем любой текст «N дней/недель/месяцев назад» в карточке + card_text = card.text(strip=True) + listing_date = _parse_relative_date(card_text) + + # House link: <a href="/catalog/houses/<city>/<slug>/<id>"> + # or /catalog/houses/<slug>/<id> + house_source: str | None = None + house_ext_id: str | None = None + house_url: str | None = None + house_link = card.css_first('a[href^="/catalog/houses/"]') + if house_link is not None: + h_href = house_link.attributes.get("href", "") + parts = [p for p in h_href.strip("/").split("/") if p] + # ['catalog', 'houses', <maybe city>, <slug>, <id>] + if len(parts) >= 4 and parts[-1].isdigit(): + house_source = "avito" + house_ext_id = parts[-1] + house_url = urljoin("https://www.avito.ru", h_href) + + # listing_segment по DOM-маркеру карточки: новостройки несут + # data-marker="item-development-name" (название ЖК/застройщика), + # вторичка — нет. URL-паттерн ненадёжен: каждый avito-URL квартиры + # содержит /kvartiry/ (вкл. новостройки) → ветка всегда vtorichka. + dev_name = card.css_first('[data-marker="item-development-name"]') + listing_segment = "novostroyki" if dev_name is not None else "vtorichka" + + # Newbuilding (ЖК) link: новостройки несут якорь на ЖК-сабдомен + # <a href="https://zhk-<slug>-ekaterinburg.avito.ru?context=...">. + # newbuilding_url — host+path без ?context (mirror urlparse-cleaning), + # newbuilding_id — <slug> = host без 'zhk-' префикса и '.avito.ru' суффикса. + newbuilding_id: str | None = None + newbuilding_url: str | None = None + if dev_name is not None: + nb_link = card.css_first('a[href*="zhk-"][href*=".avito.ru"]') + if nb_link is not None: + nb_href = nb_link.attributes.get("href", "") or "" + parsed = urlparse(nb_href) + host = parsed.netloc + if host.startswith("zhk-") and host.endswith(".avito.ru"): + # strip query/fragment, keep scheme+host+path + newbuilding_url = urlunparse((parsed.scheme, host, parsed.path, "", "", "")) + newbuilding_id = host[len("zhk-") : -len(".avito.ru")] + + return ScrapedLot( + source="avito", + source_url=url, + source_id=card.attributes.get("data-item-id"), + address=address, # БЕЗ fallback "Екатеринбург (Avito)" — пусть None + lat=None, # C-5 fix: НЕ jitter + lon=None, # C-5 fix: НЕ jitter + rooms=rooms, + area_m2=area, + floor=floor, + total_floors=total, + price_rub=price, + photo_urls=photo_urls, + listing_date=listing_date, + raw_payload={"title": title, "address": address, "house_ext_id": house_ext_id}, + house_source=house_source, + house_ext_id=house_ext_id, + house_url=house_url, + listing_segment=listing_segment, + newbuilding_id=newbuilding_id, + newbuilding_url=newbuilding_url, + ) + except Exception: + # #823: логируем каждую сломанную карточку и считаем для observability. + # Карточки с неожиданной DOM-структурой скипаем, но не роняем весь парс. + self.parse_failures += 1 + item_id_attr = getattr(card, "attributes", {}).get("data-item-id", "?") + logger.warning( + "avito _dom_card_to_lot: failed to parse card item_id=%s (parse_failures=%d)", + item_id_attr, + self.parse_failures, + exc_info=True, + ) + return None + + +# ── Helpers: title parser ─────────────────────────────────────────────────── +_RE_ROOMS = re.compile(r"(\d)-к\.?\s*(квартира|кв\.)", re.IGNORECASE) +_RE_STUDIO = re.compile(r"студи[яиюей]", re.IGNORECASE) +_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м[²2]", re.IGNORECASE) +_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт\.?", re.IGNORECASE) + +# ── T6: citywide / byrooms sweep ────────────────────────────────────────────── +# Avito URL slugs для room-filter (извлечены из реальных search-страниц 2026-05-23). +# Каждый slug кодирует ASgB-параметры выборки комнатности. +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"), +] + +# Дефолтные комнатности для exhaustive full-load — все ROOM_SLUGS (вкл. студии и +# своб.планировку), т.к. каждая категория < SERP-cap после room×price бисекции. +_AVITO_DEFAULT_ROOMS: list[tuple[str, str]] = list(ROOM_SLUGS) + +_NON_SLUG_CHARS_RE = re.compile(r"[^a-z0-9]+") + + +def _avito_room_label(name: str) -> str: + """Стабильный bucket_key-префикс из человекочитаемого имени комнатности. + + «1-комн.» → «room_1_komn», «студии» → «room_studii», «5+комн.» → «room_5_komn». + Транслитерация не нужна — берём латиницу/цифры из slug-friendly формы; для + кириллицы используем порядковый индекс категории как fallback стабильности. + """ + # Цифры из имени (комнатность) + транслит ключевых маркеров для читабельности логов. + digits = "".join(ch for ch in name if ch.isdigit()) + base = name.lower() + if "студ" in base: + return "room_studii" + if "планир" in base: + return "room_svobodnaya" + if digits: + return f"room_{digits}_komn" + # Fallback: нормализуем что есть (на случай новых категорий). + norm = _NON_SLUG_CHARS_RE.sub("_", base).strip("_") + return f"room_{norm or 'unknown'}" + + +_CSS_NOISE_RE = re.compile(r"\.?css-[a-z0-9_-]+\s*\{[^}]*\}", flags=re.I) +_NOT_ADDRESS_TAIL_RE = re.compile( + r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+)", + flags=re.I, +) + +# #1773: Avito DOM иногда выдаёт номер дома СЛИПШИМСЯ с маркером района/нас.пункта +# без разделителя — «…Савкова, 29р-н Академический», «17Ар-н», «35/2р-н», «33с1р-н». +# Это ломает извлечение номера дома и матчинг «того же дома» (см. issue #1773). +# Де-слип вставляет «, » между токеном номера дома и следующим маркером. +# +# Группа 1 — токен номера дома: цифры + опц. кириллическая литера (А/Б/...) + +# опц. корпус/строение (/N, сN, кN, корпус N, стр N) — напр. 29, 29А, 35/2, 33с1, 44Б. +# Группа 2 — маркер района/нас.пункта, начинающийся СРАЗУ за номером без пробела. +# +# Тонкость со слипшейся литерой: в «17Ар-н» литера «А» и маркер «р-н» — соседние +# кириллические буквы без границы. Поэтому литеру дома матчим ТОЛЬКО когда сразу +# за ней идёт известный маркер (lookahead на _GLUE_MARKER) — иначе риск съесть +# первую букву обычного слова. Корпус «сN/кN» требует цифры, поэтому не путается +# с маркерами «с.» (село) / «к» — у тех нет цифры вплотную. +_GLUE_MARKER = ( + r"(?:р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок" r"|снт|кв-л|квартал|д\.|с\.|пгт)" +) +_GLUE_HOUSE_TOKEN = ( + r"\d+" # дом: цифры + # опц. одиночная литера (29А) — только если за ней сразу маркер + rf"(?:\s?[А-Яа-я](?={_GLUE_MARKER}))?" + r"(?:\s?(?:/\d+|с\d+|к\d+|корп\.?\s?\d+|стр\.?\s?\d+))?" # опц. корпус/строение +) +# Хвост `(?![а-яёa-z])` гарантирует, что маркер — отдельный токен, а не начало слова. +_DEGLUE_RE = re.compile( + rf"({_GLUE_HOUSE_TOKEN})({_GLUE_MARKER}(?![а-яёa-z]))", + flags=re.IGNORECASE, +) + + +def _deglue_house_marker(addr: str) -> str: + """Вставить «, » между слипшимся номером дома и маркером района/нас.пункта. + + «…Савкова, 29р-н Академический» -> «…Савкова, 29, р-н Академический» + «…, 17Ар-н Пионерский» -> «…, 17А, р-н Пионерский» + «…, 35/2р-н …» -> «…, 35/2, р-н …» + «…, 33с1р-н …» -> «…, 33с1, р-н …» + + Идемпотентно: на уже-расклеенной строке («29, р-н») паттерн не срабатывает, + т.к. между номером и маркером уже есть запятая/пробел. + """ + return _DEGLUE_RE.sub(r"\1, \2", addr) + + +def _clean_address(raw: str | None) -> str | None: + """Strip Emotion CSS and post-address noise that Avito DOM leaks via .text(). + + Examples: + "ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:..." + -> "ул. Токарей, 56к1" + "ул. Малышева, 1.css-xxx{...}" + -> "ул. Малышева, 1" + "ул. Евгения Савкова, 29р-н Академический" + -> "ул. Евгения Савкова, 29, р-н Академический" + """ + if not raw: + return None + cleaned = _CSS_NOISE_RE.sub("", raw) + cleaned = _NOT_ADDRESS_TAIL_RE.split(cleaned, maxsplit=1)[0] + cleaned = _deglue_house_marker(cleaned) + cleaned = cleaned.strip(" ,.\n\t") + return cleaned or None + + +def _extract_rooms_from_title(title: str) -> int | None: + if _RE_STUDIO.search(title): + return 0 + m = _RE_ROOMS.search(title) + if m: + return int(m.group(1)) + return None + + +def _extract_area_from_title(title: str) -> float | None: + m = _RE_AREA.search(title) + if m: + return float(m.group(1).replace(",", ".")) + return None + + +def _extract_floor_from_title(title: str) -> tuple[int | None, int | None]: + m = _RE_FLOOR.search(title) + if m: + return int(m.group(1)), int(m.group(2)) + return None, None diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/shared.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/shared.py new file mode 100644 index 00000000..c499178d --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/shared.py @@ -0,0 +1,79 @@ +"""avito_shared.py — общие константы/хелперы для avito*.py модулей. + +Единая точка правды для: + - `RUS_MONTHS` — рус. месяц (родительный падеж) → номер, для парсинга дат вида + "9 октября 2025" (было продублировано в avito.py/avito_detail.py/avito_houses.py). + - `_unix_to_date` — unix-epoch (в СЕКУНДАХ) → `date`, в таймзоне Europe/Moscow. + +TZ-обоснование (issue #2129): было 3 независимые копии конвертации unix→date с +РАЗНЫМИ timezone — MSK (avito.py, документировано issue #726), UTC (avito_houses.py), +naive/system-local (avito_imv.py). Один и тот же epoch давал разные `date` в +SERP / houses / IMV путях для одного и того же объявления. + +Почему MSK — канонический выбор (не UTC, не local): + - Avito — российская площадка, отображает даты объявлений (`item-date` на SERP, + `publishDate`, история цен `startPriceDate`/`lastPriceDate`/`removedDate`) в + Europe/Moscow. Объявление, поднятое в 00:00–03:00 MSK, при наивной UTC-конвертации + получило бы дату на день раньше, чем видно на самом Avito (обоснование #726). + - avito_houses.py и avito_imv.py используются на одних и тех же данных + (`housePlacementHistory` widget / IMV `placementHistory` — идентичные поля + startPriceDate/lastPriceDate/removedDate, см. fixtures/avito_imv_getdata.json) — + единственный смысл держать их согласованными с уже принятым MSK-решением из #726. + - Проверено на живой fixture: startPriceDate/lastPriceDate — всегда полночь UTC + (00:00:00), поэтому MSK vs UTC не меняет date для них. `removedDate`/`publishDate` + несут реальное время суток — там UTC/naive-local и MSK ДЕЙСТВИТЕЛЬНО расходятся + (пример из fixture: removedDate=1756077885 → UTC 2025-08-24, MSK 2025-08-25). + Это единственный реальный сдвиг данных от этого фикса: `avito_imv_evaluations` / + `house_placement_history`, поля `removed_date`/`publish_date`, у ~12.5% строк + (события в 21:00–23:59 UTC/local) дата сдвинется на +1 день при следующем прогоне + скрейпера. Схему/уже сохранённые строки не трогаем — это единственно правильное + представление на будущее. + - Fixed-offset `timezone(timedelta(hours=3))`, а не `zoneinfo("Europe/Moscow")` — + не зависит от системной tzdata в контейнере; Москва не переходит на DST с 2014, + так что fixed offset корректен всегда. +""" + +from __future__ import annotations + +import logging +from datetime import date, datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + +# Europe/Moscow, UTC+3, без перехода на летнее время (с 2014). +MSK = timezone(timedelta(hours=3)) + +# Русские месяцы (родительный падеж) для парсинга дат вида "9 октября 2025". +RUS_MONTHS: dict[str, int] = { + "января": 1, + "февраля": 2, + "марта": 3, + "апреля": 4, + "мая": 5, + "июня": 6, + "июля": 7, + "августа": 8, + "сентября": 9, + "октября": 10, + "ноября": 11, + "декабря": 12, +} + + +def _unix_to_date(ts: int | float | None) -> date | None: + """Конвертирует unix-epoch (в СЕКУНДАХ) в `date`, в таймзоне Europe/Moscow (MSK). + + Возвращает `None` для `None`/`0` (Avito использует 0 как sentinel «нет даты») и + для значений, которые не конвертируются в валидную дату. + + ВАЖНО: ожидает epoch в СЕКУНДАХ. Avito SERP-поле `sortTimeStamp` отдаёт epoch в + МИЛЛИСЕКУНДАХ — вызывающий код обязан поделить на 1000 до вызова (см. + `avito.py::_build_sort_timestamp_map`). + """ + if not ts: + return None + try: + return datetime.fromtimestamp(float(ts), tz=MSK).date() + except (OSError, OverflowError, ValueError): + logger.warning("Не удалось конвертировать unix timestamp: %r", ts) + return None diff --git a/tradein-mvp/uv.lock b/tradein-mvp/uv.lock index 28de8dbb..e03ef752 100644 --- a/tradein-mvp/uv.lock +++ b/tradein-mvp/uv.lock @@ -1114,6 +1114,7 @@ name = "scraper-kit" version = "0.1.0" source = { editable = "packages/scraper-kit" } dependencies = [ + { name = "curl-cffi" }, { name = "httpx", extra = ["socks"] }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, @@ -1124,6 +1125,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "curl-cffi", specifier = ">=0.7.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "pydantic", specifier = ">=2.7.0" },