feat(tradein/avito): per-card ЕКБ гео-фильтр (отсечь padding по России) #1840

Merged
lekss361 merged 1 commit from feat/tradein-avito-ekb-filter-room-split into main 2026-06-20 14:14:03 +00:00
3 changed files with 212 additions and 2 deletions

View file

@ -319,6 +319,15 @@ class Settings(BaseSettings):
# допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS. # допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS.
avito_stale_ttl_days: int = 10 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) ─────────────────────────────────────── # ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]). # Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при # Если задан и файл существует — cookies передаются в curl_cffi-сессию при

View file

@ -1743,15 +1743,62 @@ class AvitoScraper(BaseScraper):
Поля карточки берём из DOM (`data-marker="item-*"`). Дату публикации из Поля карточки берём из DOM (`data-marker="item-*"`). Дату публикации из
embedded per-item JSON (`sortTimeStamp`), т.к. DOM `item-date` рендерится embedded per-item JSON (`sortTimeStamp`), т.к. DOM `item-date` рендерится
лениво только у верхних карточек (#726); DOM-marker остаётся fallback. лениво только у верхних карточек (#726); DOM-marker остаётся fallback.
Если settings.avito_serp_ekb_only=True (дефолт), отбрасываем карточки,
чей per-card source_url не содержит /ekaterinburg/ Avito добивает выдачу
«по всей России» при малом числе ЕКБ-результатов (4+ комн., дорогие сегменты).
Карточки с нераспознанным city-slug (href без ведущего /city/) пропускаются
консервативно (не дропаются по ошибке).
""" """
tree = HTMLParser(html) tree = HTMLParser(html)
ts_map = _build_sort_timestamp_map(html) ts_map = _build_sort_timestamp_map(html)
cards = tree.css('[data-marker="item"]') cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = [] lots: list[ScrapedLot] = []
ekb_only = settings.avito_serp_ekb_only
dropped = 0
no_city = 0
for card in cards: for card in cards:
lot = self._dom_card_to_lot(card, source_url_base, ts_map) lot = self._dom_card_to_lot(card, source_url_base, ts_map)
if lot is not None: if lot is None:
lots.append(lot) 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 return lots
def _dom_card_to_lot( def _dom_card_to_lot(

View file

@ -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"""
<div data-marker="item" data-item-id="{item_id}">
<a data-marker="item-title" href="{href}">{title}</a>
<meta itemprop="price" content="{price}"/>
<div data-marker="item-location"><p>ул. Тестовая, 1</p></div>
</div>
"""
def _build_serp_html(*cards: str) -> str:
"""Обернуть карточки в минимальный SERP-HTML."""
inner = "".join(cards)
return f"<html><body>{inner}</body></html>"
# ---------------------------------------------------------------------------
# 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 = """
<div data-marker="item" data-item-id="NOCITY">
<a data-marker="item-title" href="/kvartiry/test-99999">2-к. квартира, 50 м², 3/9 эт.</a>
<meta itemprop="price" content="5000000"/>
<div data-marker="item-location"><p>ул. Тестовая, 1</p></div>
</div>
"""
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)