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""" +
+ {title} + +

ул. Тестовая, 1

+
+ """ + + +def _build_serp_html(*cards: str) -> str: + """Обернуть карточки в минимальный SERP-HTML.""" + inner = "".join(cards) + return f"{inner}" + + +# --------------------------------------------------------------------------- +# 1. EKB + non-EKB → только ЕКБ при ekb_only=True +# --------------------------------------------------------------------------- + + +def test_geo_filter_drops_non_ekb_cards(monkeypatch: pytest.MonkeyPatch) -> None: + """При avito_serp_ekb_only=True карточки других городов отсеиваются.""" + from app.core.config import settings + from app.services.scrapers.avito import AvitoScraper + + monkeypatch.setattr(settings, "avito_serp_ekb_only", True) + + ekb_card = _make_card_html("EKB1", "ekaterinburg") + msk_card = _make_card_html("MSK1", "moskva") + ufa_card = _make_card_html("UFA1", "ufa") + chelya_card = _make_card_html("CHE1", "chelyabinsk") + + html = _build_serp_html(ekb_card, msk_card, ufa_card, chelya_card) + + scraper = AvitoScraper() + lots = scraper._parse_html(html, source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/") + + source_ids = {lot.source_id for lot in lots} + assert "EKB1" in source_ids, "ЕКБ-карточка должна пройти фильтр" + assert "MSK1" not in source_ids, "Московская карточка должна быть отброшена" + assert "UFA1" not in source_ids, "Уфимская карточка должна быть отброшена" + assert "CHE1" not in source_ids, "Челябинская карточка должна быть отброшена" + assert len(lots) == 1 + + +# --------------------------------------------------------------------------- +# 2. ekb_only=False → все карточки проходят +# --------------------------------------------------------------------------- + + +def test_geo_filter_disabled_passes_all(monkeypatch: pytest.MonkeyPatch) -> None: + """При avito_serp_ekb_only=False все карточки проходят (старое поведение).""" + from app.core.config import settings + from app.services.scrapers.avito import AvitoScraper + + monkeypatch.setattr(settings, "avito_serp_ekb_only", False) + + ekb_card = _make_card_html("EKB1", "ekaterinburg") + msk_card = _make_card_html("MSK1", "moskva") + + html = _build_serp_html(ekb_card, msk_card) + + scraper = AvitoScraper() + lots = scraper._parse_html(html, source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/") + + source_ids = {lot.source_id for lot in lots} + assert "EKB1" in source_ids + assert "MSK1" in source_ids + assert len(lots) == 2 + + +# --------------------------------------------------------------------------- +# 3. Карточка без city-slug → консервативно пропускается (не дропается) +# --------------------------------------------------------------------------- + + +def test_geo_filter_keeps_card_without_city_slug(monkeypatch: pytest.MonkeyPatch) -> None: + """Карточка с href без city-slug (/kvartiry/...) пропускается консервативно.""" + from app.core.config import settings + from app.services.scrapers.avito import AvitoScraper + + monkeypatch.setattr(settings, "avito_serp_ekb_only", True) + + # href без city-slug: первый сегмент = "kvartiry" → non-city, консервативно берём + no_city_card = """ +
+ 2-к. квартира, 50 м², 3/9 эт. + +

ул. Тестовая, 1

+
+ """ + msk_card = _make_card_html("MSK1", "moskva") + + html = _build_serp_html(no_city_card, msk_card) + + scraper = AvitoScraper() + lots = scraper._parse_html(html, source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/") + + source_ids = {lot.source_id for lot in lots} + assert "NOCITY" in source_ids, "Карточка без city-slug должна быть сохранена консервативно" + assert "MSK1" not in source_ids, "Московская карточка должна быть отброшена" + + +# --------------------------------------------------------------------------- +# 4. Только EKB карточки → всё проходит +# --------------------------------------------------------------------------- + + +def test_geo_filter_all_ekb_no_drops(monkeypatch: pytest.MonkeyPatch) -> None: + """Если все карточки из ЕКБ — dropped=0, все лоты сохраняются.""" + from app.core.config import settings + from app.services.scrapers.avito import AvitoScraper + + monkeypatch.setattr(settings, "avito_serp_ekb_only", True) + + cards = [_make_card_html(f"EKB{i}", "ekaterinburg") for i in range(3)] + html = _build_serp_html(*cards) + + scraper = AvitoScraper() + lots = scraper._parse_html(html, source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/") + + assert len(lots) == 3 + assert all("EKB" in (lot.source_id or "") for lot in lots)