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.
|
# допуском на перерыв в работе 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-сессию при
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
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