feat(tradein/avito): per-card ЕКБ гео-фильтр (отсечь padding по России) #1840
3 changed files with 212 additions and 2 deletions
|
|
@ -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-сессию при
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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(
|
||||
|
|
|
|||
154
tradein-mvp/backend/tests/test_avito_ekb_geo_filter.py
Normal file
154
tradein-mvp/backend/tests/test_avito_ekb_geo_filter.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue