diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 4916478e..5aaeb354 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -319,6 +319,15 @@ class Settings(BaseSettings): # допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS. avito_stale_ttl_days: int = 10 + # ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ───────────────────── + # Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России» + # (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug + # (/ekaterinburg/, /moskva/, /ufa/ и пр.). True = отбрасывать карточки, у + # которых source_url не содержит /ekaterinburg/ (padding по России). Карточки + # без распознанного city-slug (href без /city/) пропускаются консервативно. + # False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY. + avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY") + # ── Yandex SERP cookies (#801/T4) ─────────────────────────────────────── # Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]). # Если задан и файл существует — cookies передаются в curl_cffi-сессию при diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 2acdd7ad..9f4de0df 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -1743,15 +1743,62 @@ class AvitoScraper(BaseScraper): Поля карточки берём из DOM (`data-marker="item-*"`). Дату публикации — из embedded per-item JSON (`sortTimeStamp`), т.к. DOM `item-date` рендерится лениво только у верхних карточек (#726); DOM-marker остаётся fallback. + + Если settings.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 = settings.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 not None: - lots.append(lot) + 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( diff --git a/tradein-mvp/backend/tests/test_avito_ekb_geo_filter.py b/tradein-mvp/backend/tests/test_avito_ekb_geo_filter.py new file mode 100644 index 00000000..6096803b --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_ekb_geo_filter.py @@ -0,0 +1,154 @@ +"""Тест per-card ЕКБ гео-фильтра в AvitoScraper._parse_html. + +Avito при редких комбо (4+ комн., дорогой сегмент) добивает выдачу карточками +«по всей России» (Москва, Уфа, Челябинск и т.д.). Каждая карточка несёт +СВОЙ href с city-slug (/ekaterinburg/, /moskva/, ...). Фильтр (за флагом +settings.avito_serp_ekb_only) отбрасывает non-EKB карточки. + +Тесты: +1. EKB + non-EKB в SERP → только ЕКБ остаётся при ekb_only=True. +2. ekb_only=False → все карточки проходят (старое поведение). +3. Карточка без распознанного city-slug → пропускается консервативно (не дропается). +4. Только EKB карточки → ничего не дропается (нет лишних логов). +""" + +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + + +def _make_card_html( + item_id: str, + city_slug: str, + price: int = 5_000_000, + title: str = "2-к. квартира, 50 м², 3/9 эт.", +) -> str: + """Минимальная Avito SERP-карточка с указанным city-slug в href.""" + href = f"/{city_slug}/kvartiry/{item_id}-12345" + return f""" +
ул. Тестовая, 1
ул. Тестовая, 1