feat(scraper-kit): copy avito providers with protocol injection, strangler (#2133 avito) #2155

Merged
lekss361 merged 1 commit from feat/scraper-kit-provider-avito into main 2026-07-02 15:58:01 +00:00
11 changed files with 5774 additions and 0 deletions

View file

@ -0,0 +1,206 @@
"""Golden-parity: `scraper_kit.providers.avito.*` парсинг ≡ `app.services.scrapers.avito*`.
Strangler-инвариант (#2133): новая scraper_kit-копия avito-провайдера должна давать
БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе.
Развязка (settingsScraperConfig, get_scraper_delaydelay_provider,
geocoderscraper_kit.geo, matcherHouseMatcher) НЕ меняет распарсенные данные.
Гоняем ОБА модуля на одних фикстурах и сравниваем результат:
- SERP `_parse_html` (DOM-карточки list[ScrapedLot]) на avito_serp_sample.html,
при ekb_only=False и ekb_only=True (фильтр padding-карточек);
- `_extract_total_count` (счётчик выдачи);
- `_build_sort_timestamp_map` + `_unix_to_date` (sortTimeStamp date, MSK);
- detail `parse_detail_html` (item-view HTML DetailEnrichment);
- чистые title/address-хелперы (_clean_address, _extract_rooms/area/floor_from_title).
Идентичность результата = kit-парсер верно скопирован (развязка не задела логику).
"""
from __future__ import annotations
import dataclasses
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
# Старый app.services.scrapers.avito импортирует app.core.config.settings=Settings(),
# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# НОВЫЙ (scraper_kit) и СТАРЫЙ (app.services.scrapers) avito-парсеры — оба в одном
# тесте; ruff сортирует scraper_kit (first-party) выше app-импортов.
from scraper_kit.providers.avito.detail import parse_detail_html as new_parse_detail
from scraper_kit.providers.avito.serp import AvitoScraper as NewAvitoScraper
from scraper_kit.providers.avito.serp import (
_build_sort_timestamp_map as new_build_ts_map,
)
from scraper_kit.providers.avito.serp import _clean_address as new_clean_address
from scraper_kit.providers.avito.serp import _extract_area_from_title as new_area
from scraper_kit.providers.avito.serp import _extract_floor_from_title as new_floor
from scraper_kit.providers.avito.serp import _extract_rooms_from_title as new_rooms
from scraper_kit.providers.avito.shared import _unix_to_date as new_unix_to_date
from app.services.scrapers.avito import AvitoScraper as OldAvitoScraper
from app.services.scrapers.avito import (
_build_sort_timestamp_map as old_build_ts_map,
)
from app.services.scrapers.avito import _clean_address as old_clean_address
from app.services.scrapers.avito import _extract_area_from_title as old_area
from app.services.scrapers.avito import _extract_floor_from_title as old_floor
from app.services.scrapers.avito import _extract_rooms_from_title as old_rooms
from app.services.scrapers.avito import _unix_to_date as old_unix_to_date
from app.services.scrapers.avito_detail import parse_detail_html as old_parse_detail
_FIXTURES = Path(__file__).parent / "fixtures"
_SERP_HTML = (_FIXTURES / "avito_serp_sample.html").read_text(encoding="utf-8")
# Минимальный detail item-view HTML (зеркало test_avito_detail_parse.MINIMAL_HTML).
_DETAIL_HTML = """
<html><body>
<div data-marker="item-view/item-id"> 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)</div>
<span itemprop="price" content="11990000">11 990 000 </span>
<div data-marker="item-map-wrapper" data-map-lat="56.797713" data-map-lon="60.609135" data-location-id="654070"></div>
<div data-marker="item-view/item-params">
<ul>
<li>Количество комнат: 3</li>
<li>Общая площадь: 75 м²</li>
<li>Площадь кухни: 15 м²</li>
<li>Этаж: 20 из 26</li>
<li>Балкон или лоджия: лоджия</li>
<li>Ремонт: евро</li>
</ul>
<ul>
<li>Тип дома: монолитный</li>
<li>Этажей в доме: 26</li>
</ul>
</div>
<a data-marker="nd-jk-details-button" href="/catalog/houses/ekaterinburg/ul_postovskogo_17a/3171365">Дом</a>
<div data-marker="item-view/item-description">Отличная квартира в новом ЖК.</div>
</body></html>
""" # noqa: E501
_SOURCE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/test_7986882804"
def _make_old(ekb_only: bool) -> tuple[OldAvitoScraper, SimpleNamespace]:
"""Старый AvitoScraper без DB: patch get_scraper_delay + settings.avito_serp_ekb_only."""
with patch("app.services.scrapers.avito.get_scraper_delay", return_value=7.0):
scraper = OldAvitoScraper()
# _parse_html читает settings.avito_serp_ekb_only — подменяем на детерминированное.
scraper_settings = SimpleNamespace(avito_serp_ekb_only=ekb_only)
return scraper, scraper_settings
def _make_new(ekb_only: bool) -> NewAvitoScraper:
"""Новый AvitoScraper с инжектируемым ScraperConfig (duck-typed namespace)."""
config = SimpleNamespace(avito_serp_ekb_only=ekb_only)
return NewAvitoScraper(config=config)
def _dump_lots(lots: list[Any]) -> list[dict[str, Any]]:
return [lot.model_dump() for lot in lots]
# ── SERP _parse_html parity ──────────────────────────────────────────────────
def _run_old_parse_html(html: str, base: str, ekb_only: bool) -> list[Any]:
old, old_settings = _make_old(ekb_only)
with patch("app.services.scrapers.avito.settings", old_settings):
return old._parse_html(html, base)
def test_serp_parse_html_parity_ekb_only_false() -> None:
"""DOM-карточки → ScrapedLot идентичны (фильтр padding выключен)."""
base = "https://www.avito.ru/ekaterinburg/kvartiry"
old_lots = _run_old_parse_html(_SERP_HTML, base, ekb_only=False)
new_lots = _make_new(ekb_only=False)._parse_html(_SERP_HTML, base)
assert len(old_lots) == len(new_lots)
assert len(new_lots) > 0, "фикстура должна содержать хотя бы одну карточку"
assert _dump_lots(old_lots) == _dump_lots(new_lots)
def test_serp_parse_html_parity_ekb_only_true() -> None:
"""С включённым ekb_only-фильтром padding-дроп идентичен."""
base = "https://www.avito.ru/ekaterinburg/kvartiry"
old_lots = _run_old_parse_html(_SERP_HTML, base, ekb_only=True)
new_lots = _make_new(ekb_only=True)._parse_html(_SERP_HTML, base)
assert len(old_lots) == len(new_lots)
assert _dump_lots(old_lots) == _dump_lots(new_lots)
def test_serp_extract_total_count_parity() -> None:
"""_extract_total_count на SERP-фикстуре идентичен."""
old, _ = _make_old(ekb_only=False)
new = _make_new(ekb_only=False)
assert old._extract_total_count(_SERP_HTML) == new._extract_total_count(_SERP_HTML)
def test_build_sort_timestamp_map_parity() -> None:
"""_build_sort_timestamp_map (sortTimeStamp → date, MSK) идентичен."""
assert old_build_ts_map(_SERP_HTML) == new_build_ts_map(_SERP_HTML)
def test_unix_to_date_parity() -> None:
"""_unix_to_date (MSK) идентичен на репрезентативных epoch-значениях."""
for ts in [None, 0, 1756077885, 1735689600, 1700000000.0, 1_600_000_000]:
assert old_unix_to_date(ts) == new_unix_to_date(ts)
# ── detail parse_detail_html parity ──────────────────────────────────────────
def test_parse_detail_html_parity() -> None:
"""parse_detail_html → DetailEnrichment идентичен (все поля)."""
old_res = old_parse_detail(_DETAIL_HTML, _SOURCE_URL)
new_res = new_parse_detail(_DETAIL_HTML, _SOURCE_URL)
assert dataclasses.asdict(old_res) == dataclasses.asdict(new_res)
# ── чистые title/address-хелперы ──────────────────────────────────────────────
def test_pure_title_helpers_parity() -> None:
titles = [
"3-к. квартира, 75 м², 20/26 эт.",
"Квартира-студия, 24,5 м², 12/25 эт.",
"1-к. квартира, 38 м², 5/9 эт.",
"2-к. квартира, 52,3 м², 7/16 эт.",
"Комната, 18 м², 3/5 эт.",
]
for t in titles:
assert old_rooms(t) == new_rooms(t)
assert old_area(t) == new_area(t)
assert old_floor(t) == new_floor(t)
def test_clean_address_parity() -> None:
addrs = [
None,
"Екатеринбург, ул. Постовского, 17А",
"Свердловская область, Екатеринбург, Малышева, 51",
" Екатеринбург, Ленина 1 ",
]
for a in addrs:
assert old_clean_address(a) == new_clean_address(a)
# ── strangler-guard: kit-провайдер не импортирует app.* ──────────────────────
def test_kit_avito_has_no_app_imports() -> None:
"""Ни один модуль scraper_kit.providers.avito.* не импортирует app.* (развязка)."""
import inspect
from scraper_kit.providers.avito import detail, houses, imv, serp, shared
for mod in (serp, detail, houses, imv, shared):
source = inspect.getsource(mod)
for line in source.splitlines():
stripped = line.strip()
assert not stripped.startswith("from app"), f"{mod.__name__}: {line!r}"
assert not stripped.startswith("import app"), f"{mod.__name__}: {line!r}"

View file

@ -10,6 +10,7 @@ dependencies = [
"httpx[socks]>=0.27.0",
"tenacity>=9.0.0",
"selectolax>=0.3.0",
"curl-cffi>=0.7.0",
]
[build-system]

View file

@ -0,0 +1,33 @@
"""Геометрические хелперы scraper_kit — чистая математика, без `app.*` / БД.
Strangler-копия (#2133) bbox-констант/предикатов из `app.services.geocoder`,
используемых скрапперами как ingest-guard (валидация координат detail-страниц
перед записью). Здесь только чистые функции; продуктовый geocoder (Nominatim /
Yandex запросы, БД) НЕ копируется.
"""
from __future__ import annotations
# ЕКБ bbox = (lat_min, lat_max, lon_min, lon_max).
# TIGHT — центр агломерации; WIDE — с приграничьем, но всё ещё отсекает
# Питер/Тюмень/Уфу (#1871). WIDE строго содержит TIGHT.
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9)
def is_within_ekb_bbox(
lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT
) -> bool:
"""True если (lat, lon) внутри bbox (inclusive). bbox = (lat_min, lat_max, lon_min, lon_max)."""
lat_min, lat_max, lon_min, lon_max = bbox
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
"""Ingest-guard: True если координаты в широком ЕКБ-bbox (#1871).
Используется для валидации координат из avito detail-страниц перед записью в БД.
Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ
(Питер/Тюмень/Уфа).
"""
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)

View file

@ -0,0 +1,7 @@
"""Провайдер-специфичные парсеры scraper_kit (avito / cian / yandex / domclick).
Strangler-копии (#2131) боевых `app.services.scrapers.<provider>*`. Развязаны от
`app.*` через Protocol-инжекцию (`scraper_kit.contracts`) и локальные копии чистых
хелперов (`scraper_kit.geo`). Боевой код пока импортирует старые модули; переключение
рантайма отдельный поздний шаг (не здесь).
"""

View file

@ -0,0 +1,15 @@
"""Avito-провайдер scraper_kit — strangler-копия `app.services.scrapers.avito*`.
Модули:
- serp SERP-sweep ядро (AvitoScraper, price-range walk, DOM-card ScrapedLot)
- detail detail-страница объявления (parse_detail_html, координаты, история цен)
- houses каталог домов Avito (housePlacementHistory)
- imv Avito IMV-оценка (placementHistory)
- shared общие константы/хелперы (RUS_MONTHS, _unix_to_date)
Развязка от `app.*`:
- `app.core.config.settings` инжектируемый `ScraperConfig` (scraper_kit.contracts)
- `app.services.scraper_settings.get_scraper_delay` инжектируемый `delay_provider`
- `app.services.geocoder.is_within_ekb_bbox_wide` `scraper_kit.geo`
- `app.services.matching.houses.match_or_create_house` инжектируемый `HouseMatcher`
"""

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,853 @@
"""avito_imv.py — Avito IMV evaluation API client (3-request flow, 2026-05+).
Stage 2d: geocode + IMV evaluation.
Flow (current contract, подтверждён живым capture 2026-05 fixtures
tests/fixtures/avito_imv_geo_position.json + avito_imv_getdata.json):
0) GET /evaluation/realty warm-up (seed anti-bot cookies)
1) GET /web/1/coords/by_address?address=<urlenc> normalize + lat/lon
2) POST /js/v2/geo/position rich JWT (geoFieldsHash)
3) POST /web/1/realty-imv/get-data price + placementHistory? + suggestions
Anti-bot: zerkalim AvitoScraper (avito.py) chrome120 TLS + document-заголовки +
warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят 403 с server-IP.
Таблицы:
avito_imv_evaluations (018_avito_imv_evaluations.sql)
house_placement_history (017_house_placement_history.sql)
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.providers.avito.shared import _unix_to_date
if TYPE_CHECKING:
# Импорт под TYPE_CHECKING (как в detail.py/houses.py) — избегаем
# hard import cycle browser_fetcher → config → ... и лишней зависимости в
# offline-тестах парсинга, которым browser не нужен.
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.contracts import ScraperConfig
logger = logging.getLogger(__name__)
AVITO_BASE = "https://www.avito.ru"
COORDS_ENDPOINT = "/web/1/coords/by_address"
POSITION_ENDPOINT = "/js/v2/geo/position"
IMV_ENDPOINT = "/web/1/realty-imv/get-data"
# Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR.
WARMUP_URL = f"{AVITO_BASE}/evaluation/realty"
# Заголовки document-навигации (warm-up GET) — зеркалят production-набор
# из avito.py (AvitoScraper.__aenter__). Нужны чтобы пройти TLS+header anti-bot.
_DOC_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
# Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
_COMMON_HEADERS = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Referer": "https://www.avito.ru/evaluation/realty",
"X-Requested-With": "XMLHttpRequest",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
}
# Timeout для всех Avito-вызовов (без него curl_cffi висит на anti-bot stall).
_HTTP_TIMEOUT_SEC = 25
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class IMVAddressNotFoundError(Exception):
"""Raised when /coords/by_address returns no geoHash for given address."""
class IMVCityMismatchError(Exception):
"""Raised when locationId не входит в ожидаемый город (sanity check)."""
class IMVAuthError(Exception):
"""Avito API rejected auth / quota exceeded (HTTP 401 / 403)."""
class IMVTransientError(Exception):
"""Network / 5xx / timeout — retryable."""
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@dataclass
class IMVGeo:
geo_hash: str # JWT — не сохраняем как отдельную колонку, только в raw_response
lat: float | None = None
lon: float | None = None
avito_address_id: int | None = None
avito_location_id: int | None = None
avito_metro_id: int | None = None
avito_district_id: int | None = None
@dataclass
class IMVPlacementHistoryItem:
ext_item_id: str # str(item['id'])
title: str | None = None
rooms: int | None = None # парсится из title (опционально)
area_m2: float | None = None # парсится из title (опционально)
floor: int | None = None # парсится из title (опционально)
total_floors: int | None = None # парсится из title (опционально)
start_price: int | None = None
start_price_date: date | None = None # unix → date
last_price: int | None = None
last_price_date: date | None = None
removed_date: date | None = None # только IMV API (в widget housePlacementHistory нет)
exposure_days: int | None = None
raw_payload: dict[str, Any] | None = None
@dataclass
class IMVSuggestion:
ext_item_id: str
title: str | None = None
address: str | None = None
price_rub: int | None = None
exposure_days: int | None = None
publish_date: date | None = None
item_url: str | None = None
metro_name: str | None = None
metro_distance: str | None = None
metro_color: str | None = None
has_good_price_badge: bool = False
raw_payload: dict[str, Any] | None = None
@dataclass
class IMVEvaluation:
cache_key: str # sha256
address: str
rooms: int
area_m2: float
floor: int
floor_at_home: int
house_type: str
renovation_type: str
has_balcony: bool
has_loggia: bool
geo: IMVGeo
recommended_price: int
lower_price: int
higher_price: int
market_count: int | None = None
placement_history: list[IMVPlacementHistoryItem] = field(default_factory=list)
suggestions: list[IMVSuggestion] = field(default_factory=list)
search_similar_url: str | None = None
raw_response: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# Cache key
# ---------------------------------------------------------------------------
def compute_imv_cache_key(
address: str,
rooms: int,
area_m2: float,
floor: int,
floor_at_home: int,
house_type: str,
renovation_type: str,
has_balcony: bool,
has_loggia: bool,
) -> str:
"""Детерминированный sha256 ключ для 24h-кэша IMV оценки."""
key_src = (
f"{address}|{rooms}|{area_m2}|{floor}|{floor_at_home}"
f"|{house_type}|{renovation_type}|{int(has_balcony)}|{int(has_loggia)}"
)
return hashlib.sha256(key_src.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Parse helpers
# ---------------------------------------------------------------------------
# Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт."
_TITLE_RE = re.compile(
r"^(?P<rooms>\d+)-к[.\s].*?(?P<area>[\d,]+)\s*м².*?(?P<floor>\d+)/(?P<total>\d+)\s*эт",
re.IGNORECASE,
)
def _parse_title(title: str | None) -> dict[str, int | float | None]:
"""Парсит rooms, area_m2, floor, total_floors из Avito-заголовка объявления."""
result: dict[str, int | float | None] = {
"rooms": None,
"area_m2": None,
"floor": None,
"total_floors": None,
}
if not title:
return result
m = _TITLE_RE.match(title.strip())
if not m:
return result
try:
result["rooms"] = int(m.group("rooms"))
result["area_m2"] = float(m.group("area").replace(",", "."))
result["floor"] = int(m.group("floor"))
result["total_floors"] = int(m.group("total"))
except (ValueError, AttributeError):
pass
return result
def _parse_placement_item(raw: dict[str, Any]) -> IMVPlacementHistoryItem:
"""Парсит один элемент из placementHistory.items."""
title = raw.get("title")
parsed = _parse_title(title)
# Фильтруем itemImage из raw_payload (не нужны)
clean_raw = {k: v for k, v in raw.items() if k not in ("itemImage",)}
return IMVPlacementHistoryItem(
ext_item_id=str(raw["id"]),
title=title,
rooms=parsed["rooms"], # type: ignore[arg-type]
area_m2=parsed["area_m2"], # type: ignore[arg-type]
floor=parsed["floor"], # type: ignore[arg-type]
total_floors=parsed["total_floors"], # type: ignore[arg-type]
start_price=raw.get("startPrice"),
start_price_date=_unix_to_date(raw.get("startPriceDate")),
last_price=raw.get("lastPrice"),
last_price_date=_unix_to_date(raw.get("lastPriceDate")),
removed_date=_unix_to_date(raw.get("removedDate")),
exposure_days=raw.get("exposure"),
raw_payload=clean_raw,
)
def _parse_suggestion(raw: dict[str, Any]) -> IMVSuggestion:
"""Парсит один элемент из suggestions.items."""
metro = raw.get("metro") or {}
colors: list[str] = metro.get("colors") or []
# Фильтруем imageLink из raw_payload
clean_raw = {k: v for k, v in raw.items() if k not in ("imageLink",)}
return IMVSuggestion(
ext_item_id=str(raw["id"]),
title=raw.get("title"),
address=raw.get("address"),
price_rub=raw.get("price"),
exposure_days=raw.get("exposure"),
publish_date=_unix_to_date(raw.get("publishDate")),
item_url=raw.get("itemLink"),
metro_name=metro.get("name"),
metro_distance=metro.get("distance"),
metro_color=colors[0] if colors else None,
has_good_price_badge=bool(raw.get("hasGoodPriceBadge", False)),
raw_payload=clean_raw,
)
def _parse_geo_position(data_b: dict[str, Any], *, lat: float, lon: float) -> IMVGeo:
"""Парсит ответ POST /js/v2/geo/position (step B) → IMVGeo.
Контракт (подтверждён живым capture 2026-05, fixture avito_imv_geo_position.json):
{address, addressId, districtId, geoFieldsHash, latitude, longitude,
locationId, parentLocationId, metroId?, errorText, ...}
`geoFieldsHash` JWT, payload которого кодирует {latitude, longitude,
address, addressId, locationId, districtId}; это то, что step C ожидает как
`geoHash`. metroId присутствует только для метро-городов (None для ЕКБ-окраин).
Raises:
IMVAddressNotFoundError если geoFieldsHash пустой/отсутствует.
"""
jwt = data_b.get("geoFieldsHash")
if not jwt:
raise IMVAddressNotFoundError(
f"Avito /js/v2/geo/position не вернул geoFieldsHash для "
f"{str(data_b.get('address'))[:60]!r}: errorText={data_b.get('errorText')!r}"
)
return IMVGeo(
geo_hash=jwt,
lat=lat,
lon=lon,
avito_address_id=data_b.get("addressId"),
avito_location_id=data_b.get("locationId"),
avito_metro_id=data_b.get("metroId"),
avito_district_id=data_b.get("districtId"),
)
def _parse_price(data: dict[str, Any]) -> tuple[int, int, int, int | None]:
"""Парсит top-level `price` блок из realty-imv/get-data (step C).
Контракт (fixture avito_imv_getdata.json):
price = {recommendedPrice, lowerPrice, higherPrice, count, addItemUrl}
Returns: (recommended_price, lower_price, higher_price, market_count).
Отсутствующие цены 0 (caller трактует как "нет оценки"); count None.
"""
price_block: dict[str, Any] = data.get("price") or {}
recommended_price: int = price_block.get("recommendedPrice") or 0
lower_price: int = price_block.get("lowerPrice") or 0
higher_price: int = price_block.get("higherPrice") or 0
market_count: int | None = price_block.get("count")
return recommended_price, lower_price, higher_price, market_count
def _parse_placement_history(data: dict[str, Any]) -> list[IMVPlacementHistoryItem]:
"""Парсит опциональный `placementHistory.items` блок (step C).
placementHistory отсутствует для части адресов (живой capture: проспект
Ленина вернул только itemParams/price/suggestions) тогда возвращаем [].
Битые элементы пропускаем с warning, не роняя весь ответ.
"""
history_block: dict[str, Any] = data.get("placementHistory") or {}
items: list[IMVPlacementHistoryItem] = []
for raw_item in history_block.get("items") or []:
try:
items.append(_parse_placement_item(raw_item))
except (KeyError, TypeError) as exc:
logger.warning("IMV: не удалось распарсить placementHistory item: %s", exc)
return items
def _parse_suggestions(data: dict[str, Any]) -> tuple[list[IMVSuggestion], str | None]:
"""Парсит `suggestions` блок (step C) → (items, searchSimilarUrl)."""
suggestions_block: dict[str, Any] = data.get("suggestions") or {}
suggestions: list[IMVSuggestion] = []
for raw_sugg in suggestions_block.get("items") or []:
try:
suggestions.append(_parse_suggestion(raw_sugg))
except (KeyError, TypeError) as exc:
logger.warning("IMV: не удалось распарсить suggestion: %s", exc)
return suggestions, suggestions_block.get("searchSimilarUrl")
# ---------------------------------------------------------------------------
# Browser-transport adapter (#915 Stage 3)
# ---------------------------------------------------------------------------
class _BrowserResponse:
"""Мимикрия под curl_cffi Response поверх ответа BrowserFetcher.fetch_json.
IMV-флоу читает только .status_code / .text / .json() / .raise_for_status()
адаптируем ровно эти четыре (подтверждено по avito_imv.py: warm-up, _geocode,
_imv_evaluate, _raise_for_status_categorized).
"""
def __init__(self, status: int, body: str) -> None:
self.status_code = status
self.text = body
def json(self) -> Any:
return json.loads(self.text)
def raise_for_status(self) -> None:
if self.status_code >= 400:
# _raise_for_status_categorized уже обрабатывает 401/403/5xx ДО вызова
# raise_for_status(); сюда попадают только "прочие 4xx" (fall-through).
# Зеркалим curl_cffi/httpx-семантику ровно настолько, чтобы caller
# получил исключение.
raise RuntimeError(f"HTTP {self.status_code}")
class _BrowserSessionAdapter:
"""Адаптер: IMV-флоу думает что это curl_cffi-сессия, а ходит через камуфокс
in-page fetch (same-origin avito.ru). Куки/JWT живут в контексте браузера
провайдера, поэтому 4 запроса IMV переиспользуют warmed-сессию. Прокси и
fingerprint из sidecar (обходит datacenter-403, #562/#853)."""
def __init__(self, browser_fetcher: BrowserFetcher, *, origin: str) -> None:
self._bf = browser_fetcher
self._origin = origin
async def get(self, url: str, *, headers: dict | None = None) -> _BrowserResponse:
r = await self._bf.fetch_json(url, method="GET", headers=headers, origin=self._origin)
return _BrowserResponse(r["status"], r["body"])
async def post(
self, url: str, *, headers: dict | None = None, json: Any = None
) -> _BrowserResponse:
import json as _json
body = _json.dumps(json) if json is not None else None
r = await self._bf.fetch_json(
url, method="POST", headers=headers, body=body, origin=self._origin
)
return _BrowserResponse(r["status"], r["body"])
async def close(self) -> None:
# browser принадлежит вызывающему (backfill открывает один на батч) — no-op.
return None
# ---------------------------------------------------------------------------
# Main async function
# ---------------------------------------------------------------------------
async def evaluate_via_imv(
address: str,
rooms: int,
area_m2: float,
floor: int,
floor_at_home: int,
house_type: str, # panel/brick/monolith/monolith_brick/block/wood
renovation_type: str, # required/cosmetic/euro/designer
has_balcony: bool,
has_loggia: bool,
*,
cffi_session: Any | None = None,
browser_fetcher: BrowserFetcher | None = None,
config: ScraperConfig | None = None,
) -> IMVEvaluation:
"""Avito IMV — 3 HTTP requests (current contract 2026-05+):
1) GET /web/1/coords/by_address?address=<urlenc> normalize + lat/lon
2) POST /js/v2/geo/position rich JWT (geoFieldsHash)
3) POST /web/1/realty-imv/get-data price + placementHistory + suggestions
Returns IMVEvaluation с уже вычисленным cache_key.
Raises:
IMVAddressNotFoundError если point/normalizedAddress пустые (step 1)
или geoFieldsHash не вернулся (step 2)
IMVAuthError на 401/403
IMVTransientError на 5xx / network errors
"""
# #915 Stage 3: browser-транспорт. Если передан browser_fetcher — ходим через
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
# браузер принадлежит caller'у — backfill открывает один на батч). Этот путь
# НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке.
if browser_fetcher is not None:
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
_own_session = True
else:
# Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети
try:
from curl_cffi.requests import AsyncSession as CffiAsyncSession
_own_session = False
if cffi_session is None:
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162):
# chrome120 TLS + document-заголовки + timeout. Затем warm-up GET для
# seed anti-bot cookies — bare-session XHR Avito банит на server-IP.
# Mobile proxy wiring (#806 follow-up): только для own-session (переданная
# сессия уже с прокси от AvitoScraper). proxy=None → прямое подключение.
_proxy_url = config.scraper_proxy_url if config is not None else None
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
cffi_session = CffiAsyncSession(
impersonate="chrome120",
timeout=_HTTP_TIMEOUT_SEC,
proxies=_proxies,
headers=_DOC_HEADERS,
)
_own_session = True
except ImportError as exc:
raise RuntimeError(
"curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
) from exc
try:
if _own_session:
await _warmup(cffi_session)
geo = await _geocode(cffi_session, address)
evaluation = await _imv_evaluate(
cffi_session,
geo=geo,
address=address,
rooms=rooms,
area_m2=area_m2,
floor=floor,
floor_at_home=floor_at_home,
house_type=house_type,
renovation_type=renovation_type,
has_balcony=has_balcony,
has_loggia=has_loggia,
)
finally:
if _own_session:
await cffi_session.close()
return evaluation
async def _warmup(session: Any) -> None:
"""GET /evaluation/realty чтобы seed anti-bot cookies (srv_id/_avisc/u/...).
Без warm-up XHR-шаги (coords/position/get-data) c server-IP ловят 403 anti-bot.
Best-effort: не роняем оценку если страница недоступна последующие шаги
всё равно попробуют и дадут типизированную ошибку.
"""
try:
resp = await session.get(WARMUP_URL, headers=_DOC_HEADERS)
logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code)
except Exception as exc:
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную
# ошибку, если cookies реально нужны. Не re-raise намеренно.
logger.warning("IMV warm-up failed (продолжаем без cookies): %s", exc)
def _raise_for_status_categorized(resp: Any, context: str) -> None:
"""Raise typed IMV exception based on HTTP status code.
Используется вместо bare raise_for_status() чтобы caller мог различать
auth-failure (требует human attention) от transient (retryable).
"""
status = resp.status_code
if status in (401, 403):
raise IMVAuthError(f"Avito {context}: HTTP {status} — auth rejected / quota exceeded")
if status >= 500:
raise IMVTransientError(f"Avito {context}: HTTP {status} — transient server error")
if status >= 400:
body_preview = (resp.text or "")[:200]
logger.warning("Avito %s HTTP %d: body=%r", context, status, body_preview)
resp.raise_for_status() # other 4xx → standard HTTPStatusError
async def _geocode(session: Any, address: str) -> IMVGeo:
"""Two-step Avito IMV geocoder (current contract 2026-05+).
Step A: GET /web/1/coords/by_address normalize + lat/lon
Step B: POST /js/v2/geo/position rich JWT (geoFieldsHash) with
addressId/locationId/metroId/districtId
JWT from step B is what /realty-imv/get-data expects as `geoHash`.
"""
# Step A — normalize + coords
params = urlencode({"address": address})
url_a = f"{AVITO_BASE}{COORDS_ENDPOINT}?{params}"
logger.info("IMV geocode A: address=%r", address)
try:
resp_a = await session.get(url_a, headers=_COMMON_HEADERS)
except Exception as exc:
raise IMVTransientError(f"Avito geocode A network error: {exc}") from exc
_raise_for_status_categorized(resp_a, "geocode-A")
data_a: dict[str, Any] = resp_a.json()
point = data_a.get("point") or {}
lat = point.get("latitude")
lon = point.get("longitude")
normalized = data_a.get("normalizedAddress")
if not lat or not lon or not normalized:
raise IMVAddressNotFoundError(f"Avito /coords/by_address не вернул point для {address!r}")
# Step B — rich JWT
url_b = f"{AVITO_BASE}{POSITION_ENDPOINT}"
payload_b = {
"address": normalized,
"addressId": "",
"categoryId": 24, # 24 = недвижимость / квартиры
"isRadius": False,
"latitude": lat,
"longitude": lon,
}
logger.info("IMV geocode B: position for %r", normalized[:60])
try:
resp_b = await session.post(
url_b,
headers={**_COMMON_HEADERS, "Content-Type": "application/json"},
json=payload_b,
)
except Exception as exc:
raise IMVTransientError(f"Avito geocode B network error: {exc}") from exc
_raise_for_status_categorized(resp_b, "geocode-B")
data_b: dict[str, Any] = resp_b.json()
geo = _parse_geo_position(data_b, lat=lat, lon=lon) # raises IMVAddressNotFoundError
logger.info(
"IMV geocode OK: addressId=%s locationId=%s metroId=%s lat=%s lon=%s",
geo.avito_address_id,
geo.avito_location_id,
geo.avito_metro_id,
lat,
lon,
)
return geo
async def _imv_evaluate(
session: Any,
*,
geo: IMVGeo,
address: str,
rooms: int,
area_m2: float,
floor: int,
floor_at_home: int,
house_type: str,
renovation_type: str,
has_balcony: bool,
has_loggia: bool,
) -> IMVEvaluation:
"""Request 2: POST /web/1/realty-imv/get-data → IMVEvaluation."""
url = f"{AVITO_BASE}{IMV_ENDPOINT}"
body = {
"geoHash": geo.geo_hash,
"floor": floor,
"floorAtHome": floor_at_home,
"rooms": rooms,
"area": area_m2,
"houseType": house_type,
"renovationType": renovation_type,
"hasLoggia": has_loggia,
"hasBalcony": has_balcony,
}
headers = {**_COMMON_HEADERS, "Content-Type": "application/json"}
logger.info(
"IMV evaluate: address=%r rooms=%d area=%.1f floor=%d/%d",
address,
rooms,
area_m2,
floor,
floor_at_home,
)
try:
resp = await session.post(url, json=body, headers=headers)
except Exception as exc:
raise IMVTransientError(f"Avito IMV network error: {exc}") from exc
_raise_for_status_categorized(resp, "imv-evaluate")
data: dict[str, Any] = resp.json()
recommended_price, lower_price, higher_price, market_count = _parse_price(data)
placement_history = _parse_placement_history(data)
suggestions, search_similar_url = _parse_suggestions(data)
logger.info(
"IMV evaluate OK: recommended=%d range=(%d, %d) count=%s history=%d suggestions=%d",
recommended_price,
lower_price,
higher_price,
market_count,
len(placement_history),
len(suggestions),
)
cache_key = compute_imv_cache_key(
address=address,
rooms=rooms,
area_m2=area_m2,
floor=floor,
floor_at_home=floor_at_home,
house_type=house_type,
renovation_type=renovation_type,
has_balcony=has_balcony,
has_loggia=has_loggia,
)
return IMVEvaluation(
cache_key=cache_key,
address=address,
rooms=rooms,
area_m2=area_m2,
floor=floor,
floor_at_home=floor_at_home,
house_type=house_type,
renovation_type=renovation_type,
has_balcony=has_balcony,
has_loggia=has_loggia,
geo=geo,
recommended_price=recommended_price,
lower_price=lower_price,
higher_price=higher_price,
market_count=market_count,
placement_history=placement_history,
suggestions=suggestions,
search_similar_url=search_similar_url,
raw_response=data,
)
# ---------------------------------------------------------------------------
# DB save functions
# ---------------------------------------------------------------------------
def save_imv_evaluation(
db: Session,
e: IMVEvaluation,
*,
estimate_id: UUID | None = None,
) -> int:
"""INSERT в avito_imv_evaluations ON CONFLICT(cache_key) DO UPDATE.
Колонки строго по 018_avito_imv_evaluations.sql DDL.
estimate_id nullable FK на trade_in_estimates.
Returns: id вставленной/обновлённой строки.
"""
row = db.execute(
text("""
INSERT INTO avito_imv_evaluations (
cache_key, estimate_id,
address, rooms, area_m2, floor, floor_at_home,
house_type, renovation_type, has_balcony, has_loggia,
geo_hash,
lat, lon, avito_address_id, avito_location_id,
avito_metro_id, avito_district_id,
recommended_price, lower_price, higher_price, market_count,
raw_response, fetched_at
) VALUES (
:cache_key, CAST(:estimate_id AS uuid),
:address, :rooms, :area_m2, :floor, :floor_at_home,
:house_type, :renovation_type, :has_balcony, :has_loggia,
:geo_hash,
:lat, :lon, :avito_address_id, :avito_location_id,
:avito_metro_id, :avito_district_id,
:recommended_price, :lower_price, :higher_price, :market_count,
CAST(:raw_response AS jsonb), NOW()
)
ON CONFLICT (cache_key) DO UPDATE SET
estimate_id = COALESCE(EXCLUDED.estimate_id, avito_imv_evaluations.estimate_id),
recommended_price = EXCLUDED.recommended_price,
lower_price = EXCLUDED.lower_price,
higher_price = EXCLUDED.higher_price,
market_count = EXCLUDED.market_count,
raw_response = EXCLUDED.raw_response,
fetched_at = NOW()
RETURNING id
"""),
{
"cache_key": e.cache_key,
"estimate_id": str(estimate_id) if estimate_id else None,
"address": e.address,
"rooms": e.rooms,
"area_m2": e.area_m2,
"floor": e.floor,
"floor_at_home": e.floor_at_home,
"house_type": e.house_type,
"renovation_type": e.renovation_type,
"has_balcony": e.has_balcony,
"has_loggia": e.has_loggia,
"geo_hash": e.geo.geo_hash,
"lat": e.geo.lat,
"lon": e.geo.lon,
"avito_address_id": e.geo.avito_address_id,
"avito_location_id": e.geo.avito_location_id,
"avito_metro_id": e.geo.avito_metro_id,
"avito_district_id": e.geo.avito_district_id,
"recommended_price": e.recommended_price,
"lower_price": e.lower_price,
"higher_price": e.higher_price,
"market_count": e.market_count,
"raw_response": (
json.dumps(e.raw_response, ensure_ascii=False) if e.raw_response else None
),
},
).fetchone()
db.commit()
return row.id if row else 0
def save_imv_placement_history(
db: Session,
eval_id: int,
items: list[IMVPlacementHistoryItem],
) -> int:
"""INSERT в house_placement_history (source='avito_imv') с removed_date.
Колонки строго по 017_house_placement_history.sql DDL.
Колонка scraped_at (не fetched_at) по DDL таблицы.
house_id NULL (backfill через отдельный процесс).
eval_id принимается для логирования (не сохраняется нет FK в 017).
ON CONFLICT (source, ext_item_id) DO UPDATE refresh dates.
Returns: количество вставленных/обновлённых строк.
"""
count = 0
for item in items:
db.execute(
text("""
INSERT INTO house_placement_history (
source, ext_item_id,
title, rooms, area_m2, floor, total_floors,
start_price, start_price_date,
last_price, last_price_date,
removed_date, exposure_days,
raw_payload, scraped_at
) VALUES (
'avito_imv', :ext_item_id,
:title, :rooms, :area_m2, :floor, :total_floors,
:start_price, :start_price_date,
:last_price, :last_price_date,
:removed_date, :exposure_days,
CAST(:raw_payload AS jsonb), NOW()
)
ON CONFLICT (source, ext_item_id) DO UPDATE SET
last_price = EXCLUDED.last_price,
last_price_date = EXCLUDED.last_price_date,
removed_date = EXCLUDED.removed_date,
exposure_days = EXCLUDED.exposure_days,
raw_payload = EXCLUDED.raw_payload,
scraped_at = NOW()
"""),
{
"ext_item_id": item.ext_item_id,
"title": item.title,
"rooms": item.rooms,
"area_m2": item.area_m2,
"floor": item.floor,
"total_floors": item.total_floors,
"start_price": item.start_price,
"start_price_date": item.start_price_date,
"last_price": item.last_price,
"last_price_date": item.last_price_date,
"removed_date": item.removed_date,
"exposure_days": item.exposure_days,
"raw_payload": (
json.dumps(item.raw_payload, ensure_ascii=False) if item.raw_payload else None
),
},
)
count += 1
if count:
db.commit()
logger.info("IMV placement history saved: eval_id=%d items=%d", eval_id, count)
return count

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,79 @@
"""avito_shared.py — общие константы/хелперы для avito*.py модулей.
Единая точка правды для:
- `RUS_MONTHS` рус. месяц (родительный падеж) номер, для парсинга дат вида
"9 октября 2025" (было продублировано в avito.py/avito_detail.py/avito_houses.py).
- `_unix_to_date` unix-epoch (в СЕКУНДАХ) `date`, в таймзоне Europe/Moscow.
TZ-обоснование (issue #2129): было 3 независимые копии конвертации unix→date с
РАЗНЫМИ timezone MSK (avito.py, документировано issue #726), UTC (avito_houses.py),
naive/system-local (avito_imv.py). Один и тот же epoch давал разные `date` в
SERP / houses / IMV путях для одного и того же объявления.
Почему MSK канонический выбор (не UTC, не local):
- Avito российская площадка, отображает даты объявлений (`item-date` на SERP,
`publishDate`, история цен `startPriceDate`/`lastPriceDate`/`removedDate`) в
Europe/Moscow. Объявление, поднятое в 00:0003:00 MSK, при наивной UTC-конвертации
получило бы дату на день раньше, чем видно на самом Avito (обоснование #726).
- avito_houses.py и avito_imv.py используются на одних и тех же данных
(`housePlacementHistory` widget / IMV `placementHistory` идентичные поля
startPriceDate/lastPriceDate/removedDate, см. fixtures/avito_imv_getdata.json)
единственный смысл держать их согласованными с уже принятым MSK-решением из #726.
- Проверено на живой fixture: startPriceDate/lastPriceDate всегда полночь UTC
(00:00:00), поэтому MSK vs UTC не меняет date для них. `removedDate`/`publishDate`
несут реальное время суток там UTC/naive-local и MSK ДЕЙСТВИТЕЛЬНО расходятся
(пример из fixture: removedDate=1756077885 UTC 2025-08-24, MSK 2025-08-25).
Это единственный реальный сдвиг данных от этого фикса: `avito_imv_evaluations` /
`house_placement_history`, поля `removed_date`/`publish_date`, у ~12.5% строк
(события в 21:0023:59 UTC/local) дата сдвинется на +1 день при следующем прогоне
скрейпера. Схему/уже сохранённые строки не трогаем это единственно правильное
представление на будущее.
- Fixed-offset `timezone(timedelta(hours=3))`, а не `zoneinfo("Europe/Moscow")`
не зависит от системной tzdata в контейнере; Москва не переходит на DST с 2014,
так что fixed offset корректен всегда.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta, timezone
logger = logging.getLogger(__name__)
# Europe/Moscow, UTC+3, без перехода на летнее время (с 2014).
MSK = timezone(timedelta(hours=3))
# Русские месяцы (родительный падеж) для парсинга дат вида "9 октября 2025".
RUS_MONTHS: dict[str, int] = {
"января": 1,
"февраля": 2,
"марта": 3,
"апреля": 4,
"мая": 5,
"июня": 6,
"июля": 7,
"августа": 8,
"сентября": 9,
"октября": 10,
"ноября": 11,
"декабря": 12,
}
def _unix_to_date(ts: int | float | None) -> date | None:
"""Конвертирует unix-epoch (в СЕКУНДАХ) в `date`, в таймзоне Europe/Moscow (MSK).
Возвращает `None` для `None`/`0` (Avito использует 0 как sentinel «нет даты») и
для значений, которые не конвертируются в валидную дату.
ВАЖНО: ожидает epoch в СЕКУНДАХ. Avito SERP-поле `sortTimeStamp` отдаёт epoch в
МИЛЛИСЕКУНДАХ вызывающий код обязан поделить на 1000 до вызова (см.
`avito.py::_build_sort_timestamp_map`).
"""
if not ts:
return None
try:
return datetime.fromtimestamp(float(ts), tz=MSK).date()
except (OSError, OverflowError, ValueError):
logger.warning("Не удалось конвертировать unix timestamp: %r", ts)
return None

2
tradein-mvp/uv.lock generated
View file

@ -1114,6 +1114,7 @@ name = "scraper-kit"
version = "0.1.0"
source = { editable = "packages/scraper-kit" }
dependencies = [
{ name = "curl-cffi" },
{ name = "httpx", extra = ["socks"] },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
@ -1124,6 +1125,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "curl-cffi", specifier = ">=0.7.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
{ name = "pydantic", specifier = ">=2.7.0" },