feat(scraper-kit): copy avito providers with protocol injection, strangler (#2133 avito)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 1m1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 1m1s
This commit is contained in:
parent
97acf5273c
commit
df5e9003df
11 changed files with 5774 additions and 0 deletions
|
|
@ -0,0 +1,206 @@
|
|||
"""Golden-parity: `scraper_kit.providers.avito.*` парсинг ≡ `app.services.scrapers.avito*`.
|
||||
|
||||
Strangler-инвариант (#2133): новая scraper_kit-копия avito-провайдера должна давать
|
||||
БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе.
|
||||
Развязка (settings→ScraperConfig, get_scraper_delay→delay_provider,
|
||||
geocoder→scraper_kit.geo, matcher→HouseMatcher) НЕ меняет распарсенные данные.
|
||||
|
||||
Гоняем ОБА модуля на одних фикстурах и сравниваем результат:
|
||||
- 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}"
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
33
tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py
Normal file
33
tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py
Normal 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)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"""Провайдер-специфичные парсеры scraper_kit (avito / cian / yandex / domclick).
|
||||
|
||||
Strangler-копии (#2131) боевых `app.services.scrapers.<provider>*`. Развязаны от
|
||||
`app.*` через Protocol-инжекцию (`scraper_kit.contracts`) и локальные копии чистых
|
||||
хелперов (`scraper_kit.geo`). Боевой код пока импортирует старые модули; переключение
|
||||
рантайма — отдельный поздний шаг (не здесь).
|
||||
"""
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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:00–03: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:00–23: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
2
tradein-mvp/uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue