"""Avito detail page parser — Stage 2b.
Извлекает 30+ полей из страницы объявления:
- Identity (item_id, title, price, publish_date, views)
- Apartment params (rooms, area, floor, balcony_loggia, room_layout,
bathroom_type, windows_view, repair_state, sale_type, mortgage_available)
- Location (lat, lon, avito_location_id, metro_stations[], address_full)
- House params (house_type, total_floors_house, lifts, concierge, closed_yard,
house_catalog_url) — собираются но НЕ сохраняются в БД (Stage 2c)
- Description
- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date,
encumbrances_clean, registry_match)
- Gallery (photo_urls[])
Точка входа для batch-обогащения:
enrichment = await fetch_detail(item_url)
saved = save_detail_enrichment(db, enrichment)
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import re
from dataclasses import dataclass, field
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import quote, urljoin, urlparse
from curl_cffi.requests import AsyncSession
from selectolax.parser import HTMLParser, Node
from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.avito_exceptions import (
AvitoBlockedError,
AvitoListingGoneError,
AvitoRateLimitedError,
)
from scraper_kit.geo import is_within_ekb_bbox_wide
from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page
from scraper_kit.providers.avito.shared import RUS_MONTHS
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
if TYPE_CHECKING:
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.contracts import ScraperConfig
logger = logging.getLogger(__name__)
AVITO_BASE = "https://www.avito.ru"
# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET
# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая
# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22
# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы.
_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote(
"купить квартиру"
)
_AVITO_WARM_YANDEX_URL = "https://yandex.ru/search/?text=" + quote(
"купить квартиру екатеринбург авито"
)
_AVITO_WARM_YANDEX_REFERER = "https://yandex.ru/"
# Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса
# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси;
# пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape.
# 429 = transient conn-limit: сначала короткий retry на ТОЙ ЖЕ сессии. Но keep-alive
# detail-fetch переиспользует ОДИН backconnect exit-IP на последовательные запросы →
# avito лочит этот IP, и short-retry той же сессии не помогает (run 217: 5 подряд 429,
# abort на 19 enriched). Поэтому при исчерпании short-retry под backconnect делаем ещё
# _AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP).
_AVITO_DETAIL_403_MAX_RETRIES = 5
_AVITO_DETAIL_403_BACKOFF_SEC = 2.0
_AVITO_DETAIL_429_MAX_RETRIES = 8
_AVITO_DETAIL_429_BACKOFF_SEC = 1.5
_AVITO_DETAIL_429_RECONNECT_RETRIES = 3
# ── Regex ────────────────────────────────────────────────────────────────────
_ITEM_ID_RE = re.compile(r"№\s*(\d+)")
_VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр")
_VIEWS_TODAY_RE = re.compile(r"\+\s*(\d+)\s+сегодня")
_PUBLISH_DATE_RE = re.compile(
r"(\d{1,2})\s+(января|февраля|марта|апреля|мая|июня|"
r"июля|августа|сентября|октября|ноября|декабря)\s+в",
re.IGNORECASE,
)
_FLOAT_RE = re.compile(r"[\d.,]+")
_INT_RE = re.compile(r"\d+")
_FLOOR_SPLIT_RE = re.compile(r"(\d+)\s+из\s+(\d+)")
OWNERS_RE = re.compile(r"^(\d+)\s+собственник(?:\s+или\s+больше)?", re.IGNORECASE)
OWNER_CHANGE_RE = re.compile(r"смена собственника\s+(\d+)\s+(\w+)\s+(\d{4})", re.IGNORECASE)
ENCUMBRANCES_RE = re.compile(r"Не\s+найдены\s+ограничения", re.IGNORECASE)
REGISTRY_MATCH_RE = re.compile(r"Совпадают\s+площадь", re.IGNORECASE)
METRO_RE = re.compile(
r"([А-ЯЁ][а-яё]+(?:ская|инская|итская|овская|енская))\s*"
r"(?:(\d+)[–\-](\d+)|от\s+(\d+))\s*мин",
re.IGNORECASE,
)
# ── Маппинг RUS полей params-блока ───────────────────────────────────────────
# (rus_label) -> (field_name, conversion_type)
RUS_FIELD_MAP: dict[str, tuple[str, str | None]] = {
"Количество комнат": ("rooms", "int"),
"Общая площадь": ("area_m2", "float_m2"),
"Площадь кухни": ("kitchen_area_m2", "float_m2"),
"Жилая площадь": ("living_area_m2", "float_m2"),
"Высота потолков": ("ceiling_height_m", "height_m"),
"Отделка": ("finishing", "text_lower"),
"Этаж": ("floor_split", None),
"Балкон или лоджия": ("balcony_loggia", "balcony"),
"Тип комнат": ("room_layout", "layout"),
"Санузел": ("bathroom_type", "bathroom"),
"Окна": ("windows_view", "windows"),
"Ремонт": ("repair_state", "repair"),
"Способ продажи": ("sale_type", "sale_type"),
"Условия продажи": ("sale_conditions_raw", None),
"Тип дома": ("house_type", "house_type"),
"Этажей в доме": ("total_floors_house", "int"),
"Пассажирский лифт": ("passenger_elevators", "int"),
"Грузовой лифт": ("cargo_elevators", "int"),
"В доме": ("in_house_features_raw", None),
"Двор": ("yard_features_raw", None),
}
# ── Enum-маппинги ─────────────────────────────────────────────────────────────
_BALCONY_MAP: dict[str, str] = {
"балкон": "balcony",
"лоджия": "loggia",
"балкон, лоджия": "both",
"лоджия, балкон": "both",
"нет": "none",
}
_LAYOUT_MAP: dict[str, str] = {
"изолированные": "isolated",
"смежные": "adjacent",
"смешанные": "mixed",
}
_BATHROOM_MAP: dict[str, str] = {
"совмещенный": "combined",
"раздельный": "separate",
"совмещенный, раздельный": "both",
"раздельный, совмещенный": "both",
}
_WINDOWS_MAP: dict[str, str] = {
"во двор": "yard",
"на улицу": "street",
"во двор и на улицу": "both",
"на улицу и во двор": "both",
"в парк": "park",
}
_REPAIR_MAP: dict[str, str] = {
# Русские значения из Avito HTML → каноничный enum (needs_repair/standard/good/excellent)
# Маппинг выровнен по repair_state_normalizer.normalize_repair_state().
"косметический": "standard",
"евро": "good",
"дизайнерский": "excellent",
"требуется": "needs_repair",
"без ремонта": "needs_repair",
}
_SALE_TYPE_MAP: dict[str, str] = {
"свободная": "free",
"альтернатива": "alternative",
"аукцион": "auction",
"переуступка": "assignment",
}
_HOUSE_TYPE_MAP: dict[str, str] = {
"монолитный": "monolith",
"панельный": "panel",
"кирпичный": "brick",
"монолитно-кирпичный": "monolith_brick",
"блочный": "block",
"деревянный": "wood",
}
# ── DetailEnrichment dataclass ────────────────────────────────────────────────
@dataclass
class DetailEnrichment:
"""Результат парсинга detail page Avito.
Содержит 30+ полей из страницы объявления.
save_detail_enrichment() пишет подмножество в listings (house-level поля пропускаются
— они canonical из Houses Catalog Stage 2c).
"""
# Identity
item_id: str
source_url: str
title: str | None = None
price_rub: int | None = None
publish_date: date | None = None
views_total: int | None = None
views_today: int | None = None
# Apartment params
rooms: int | None = None
area_m2: float | None = None
kitchen_area_m2: float | None = None
living_area_m2: float | None = None
ceiling_height_m: float | None = None
finishing: str | None = None
floor: int | None = None
total_floors: int | None = None
balcony_loggia: str | None = None
room_layout: str | None = None
bathroom_type: str | None = None
windows_view: str | None = None
repair_state: str | None = None
sale_type: str | None = None
mortgage_available: bool | None = None
# House params (собираем, но НЕ сохраняем в listings — Stage 2c)
house_type: str | None = None
total_floors_house: int | None = None
passenger_elevators: int | None = None
cargo_elevators: int | None = None
has_concierge: bool | None = None
closed_yard: bool | None = None
house_catalog_url: str | None = None
# Location
address_full: str | None = None
lat: float | None = None
lon: float | None = None
avito_location_id: int | None = None
metro_stations: list[dict[str, Any]] = field(default_factory=list)
# Description
description: str | None = None
# Domoteka (5 полей)
owners_count: int | None = None
owners_at_least: bool | None = None
last_owner_change_date: date | None = None
encumbrances_clean: bool | None = None
registry_match: bool | None = None
# Gallery
photo_urls: list[str] = field(default_factory=list)
# Raw для retrofit / debug
raw_html_meta: dict[str, Any] = field(default_factory=dict)
def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession:
"""Создать curl_cffi-сессию для detail-fetch (impersonate chrome120, через прокси
если задан config.scraper_proxy_url).
Вынесено из own-session-ветки fetch_detail чтобы reconnect-retry мог пересоздать
эфемерную сессию (новый CONNECT-туннель = свежий backconnect exit-IP) под 403.
proxy=None → прямое подключение.
Strangler-инжекция (#2133): прокси приходит из инжектируемого `ScraperConfig`
(scraper_kit.contracts) вместо прямого импорта app.core.config.settings.
"""
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
return AsyncSession(
impersonate="chrome120",
timeout=25,
proxies=proxies,
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",
},
)
async def warm_up_session(session: AsyncSession) -> bool:
"""Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search
(Referer=yandex). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
без 403. Возвращает True если search-страница загрузилась (не firewall).
"""
try:
await session.get(_AVITO_WARM_YANDEX_URL) # органический referer-источник, best-effort
except Exception as exc:
logger.debug("avito warm-up: yandex GET failed (non-fatal): %s", exc)
await asyncio.sleep(random.uniform(2.0, 3.5))
try:
r = await session.get(
_AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER}
)
except Exception as exc:
logger.warning("avito warm-up: search fetch error: %s", exc)
return False
ok = r.status_code == 200 and not _is_firewall_page(r.text)
if not ok:
logger.warning("avito warm-up: search page blocked sc=%s", r.status_code)
return ok
async def research_in_session(session: AsyncSession) -> bool:
"""Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=yandex) на
ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort (search-страница
иногда 429, это не ломает detail-фетчи). Возвращает True если страница не firewall.
"""
try:
r = await session.get(
_AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER}
)
except Exception as exc:
logger.warning("avito re-search: fetch error: %s", exc)
return False
ok = r.status_code == 200 and not _is_firewall_page(r.text)
if not ok:
logger.info("avito re-search: search page sc=%s (non-fatal)", r.status_code)
return ok
async def build_warmed_session() -> AsyncSession:
"""Построить detail-сессию (_build_detail_session) и прогреть её. Возвращает
прогретую сессию (даже если warm-up soft-failed — caller может всё равно пробовать).
"""
session = _build_detail_session()
await warm_up_session(session)
return session
# ── Soft-block detection (#1967 / #1950) ──────────────────────────────────────
# Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ
# витриной (
Авито — Объявления на сайте Авито) вместо запрошенного
# объявления — мягкий анти-бот deflect. У такой страницы НЕТ ни firewall-маркеров
# (_is_firewall_page=False), ни item-view → parse_detail_html падал
# ValueError("Cannot extract item_id") → generic failed, IP НЕ ротировался, enriched
# каскадно падал к 0 (прод runs 431→437: 115→82→8→2→1→0 при blocked=0). Детектим
# как блок → reconnect свежей exit-IP сессией / loop-ротация.
_DETAIL_SOFT_BLOCK_TITLE_MARKERS = ("объявления на сайте авито",)
def _is_detail_soft_block(html: str) -> bool:
"""True если detail-fetch вернул HTTP 200, но это анти-бот deflect — общая
витрина Авито вместо объявления.
Сигнатура строго специфична deflect-витрине, чтобы НЕ задевать легитимные
не-блок ответы:
* нормальное объявление содержит item-view-маркеры → False;
* снятое объявление Avito рендерит как item-view + баннер «снято» → False;
* 404 / не-200 обрабатывается выше (ValueError → failed), сюда не попадает.
Положительный сигнал: НЕТ item-view-структуры И generic-витринный .
"""
if "item-view/item-id" in html or "item-view/title-info" in html:
return False
head = html[:16384].lower()
return any(m in head for m in _DETAIL_SOFT_BLOCK_TITLE_MARKERS)
def _is_detail_not_found(html: str) -> bool:
"""True if the page is Avito's 404 / removed-listing page (not an anti-bot deflect).
Guard: a real listing has item-view → False. Keyed on the specific 404 title so it
does NOT catch the soft-block deflect ("объявления на сайте авито" without 404 text)."""
if "item-view/item-id" in html or "item-view/title-info" in html:
return False
head = html[:16384].lower()
return "страница не найдена" in head or "ошибка 404" in head
# ── fetch_detail ──────────────────────────────────────────────────────────────
async def fetch_detail(
item_url: str,
*,
cffi_session: AsyncSession | None = None,
browser_fetcher: BrowserFetcher | None = None,
referer: str | None = None,
reconnect_on_block: bool = True,
config: ScraperConfig | None = None,
) -> DetailEnrichment:
"""GET /{item_url} → parse HTML via selectolax → DetailEnrichment.
Если browser_fetcher передан — использует браузерный fetch (browser mode).
Если cffi_session не передана — создаёт новую (impersonate='chrome120').
referer — если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551:
referer = URL avito-search страницы, на которой прогрелась сессия).
reconnect_on_block — если False, 403/firewall поднимает AvitoBlockedError СРАЗУ
(без холодных reconnect'ов — ими займётся outer-loop через rebuild+rewarm прогретой
сессии); 429 после short-retry поднимает AvitoRateLimitedError. Дефолт True
сохраняет старое поведение own-session / scrape_pipeline путей.
Raises:
httpx.HTTPError — если status != 200 (через raise_for_status-like).
ValueError — если item_id не извлечён из HTML.
"""
if browser_fetcher is not None:
# Browser-only режим (#1814): когда передан browser_fetcher, fetch идёт
# СТРОГО через camoufox-сервис. Никакой curl_cffi-сессии и никакого
# curl-fallback — avito-антибот банит curl-фингерпринт под нагрузкой
# (429 CONNECT tunnel failed), а browser проходит. Любой firewall/ошибка
# браузера → AvitoBlockedError; caller (backfill) пометит failed и пойдёт
# дальше, НЕ трогая curl.
full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
try:
html = await browser_fetcher.fetch(full_url)
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception as exc:
# browser-сервис вернул HTTPError / ConnectError / 502 (firewall, dead
# listing, redirect). НЕ откатываемся в curl — поднимаем block, чтобы
# caller пометил listing и продолжил без curl-фингерпринта.
raise AvitoBlockedError(
f"Avito detail browser fetch failed for {full_url}: {exc}"
) from exc
# 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без
# item-view. Это НЕ анти-бот deflect — отдельный сигнал, чтобы backfill
# пометил листинг is_active=FALSE и НЕ крутил consecutive-block breaker
# (run 458: 5 dead подряд → abort до live-листингов). Проверяем ДО
# firewall/soft-block (иначе 404-страница ловится как soft-block).
if _is_detail_not_found(html):
raise AvitoListingGoneError(
f"Avito detail 404/listing-gone (browser-mode) for {full_url}"
)
if _is_firewall_page(html) or _is_detail_soft_block(html):
raise AvitoBlockedError(
f"Avito detail firewall/soft-block (browser-mode) for {full_url}"
)
return parse_detail_html(html, full_url)
own_session = cffi_session is None
if cffi_session is None:
# Mobile proxy wiring (#806 follow-up): own-session path (no shared session passed,
# e.g. admin endpoint). Caller-provided sessions (scrape_pipeline) already have proxy
# from AvitoScraper.__aenter__ — don't override. proxy=None → прямое подключение.
session = _build_detail_session(config)
else:
session = cffi_session
full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
# curl_cffi egress всегда через scraper_proxy_url (backconnect mproxy). 403/firewall =
# залочен лишь текущий exit-IP → reconnect новой сессией = свежий IP. Гейтим reconnect
# ТОЛЬКО на наличие proxy (зеркало SERP-фикса #1765). Без proxy — старое поведение
# (raise на первом 403). Caller-provided сессию НЕ трогаем: reconnect использует
# эфемерную retry_session, которую сами закрываем в finally.
# reconnect_on_block=False (warm-batch #1551): не делаем холодных per-card
# reconnect'ов — блок поднимает AvitoBlockedError сразу, outer-loop пересоздаёт+
# прогревает сессию (свежий exit-IP). reconnect_on_block=True (own-session /
# scrape_pipeline) — старое поведение: backconnect-reconnect эфемерной сессией.
backconnect = (
bool(config.scraper_proxy_url if config is not None else None) and reconnect_on_block
)
req_headers = {"Referer": referer} if referer else None
r403 = 0
r429 = 0
r429_reconnect = 0
attempt_session = session
retry_session: AsyncSession | None = None
try:
while True:
response = await attempt_session.get(full_url, headers=req_headers)
sc = response.status_code
is_firewall = sc == 200 and (
_is_firewall_page(response.text) or _is_detail_soft_block(response.text)
)
# 429 через backconnect = transient conn-limit, НЕ IP-ban: короткий retry той
# же сессии без reconnect (зеркало SERP). Только при исчерпании — raise.
if sc == 429 and r429 < _AVITO_DETAIL_429_MAX_RETRIES:
r429 += 1
logger.info(
"avito detail HTTP 429 (backconnect conn-limit) — short retry %d/%d",
r429,
_AVITO_DETAIL_429_MAX_RETRIES,
)
await asyncio.sleep(_AVITO_DETAIL_429_BACKOFF_SEC)
continue
if sc == 403 or is_firewall:
if backconnect and r403 < _AVITO_DETAIL_403_MAX_RETRIES:
r403 += 1
# Эфемерная свежая сессия (новый CONNECT-туннель = свежий exit-IP).
# Caller-provided/own session НЕ пересоздаём — только retry_session.
if retry_session is not None:
try:
await retry_session.close()
except Exception:
pass
retry_session = _build_detail_session()
attempt_session = retry_session
logger.info(
"avito detail 403/firewall/soft-block %s — backconnect reconnect "
"retry %d/%d",
full_url,
r403,
_AVITO_DETAIL_403_MAX_RETRIES,
)
await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC)
continue
raise AvitoBlockedError(f"Avito detail HTTP 403 for {full_url}")
if sc == 429:
# short-retry (та же сессия) исчерпан. Под backconnect — ещё несколько
# попыток через эфемерную сессию (свежий exit-IP): keep-alive держал ОДИН
# залоченный IP, новый CONNECT-туннель уходит на другой. Делит ту же
# retry_session-машинерию что и 403-путь (close-prev → rebuild → retry,
# cleanup в finally). Только потом raise.
if backconnect and r429_reconnect < _AVITO_DETAIL_429_RECONNECT_RETRIES:
r429_reconnect += 1
# Caller-provided/own session НЕ пересоздаём — только retry_session.
if retry_session is not None:
try:
await retry_session.close()
except Exception:
pass
retry_session = _build_detail_session()
attempt_session = retry_session
logger.info(
"avito detail HTTP 429 short-retry exhausted %s — "
"backconnect reconnect retry %d/%d",
full_url,
r429_reconnect,
_AVITO_DETAIL_429_RECONNECT_RETRIES,
)
await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC)
continue
raise AvitoRateLimitedError(f"Avito detail HTTP 429 for {full_url}")
if sc != 200:
raise ValueError(f"avito detail HTTP {sc} for {full_url}")
return parse_detail_html(response.text, full_url)
finally:
if own_session:
await session.close()
if retry_session is not None and retry_session is not session:
try:
await retry_session.close()
except Exception:
pass
# ── parse_detail_html ─────────────────────────────────────────────────────────
def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
"""Pure parser — отдельно для тестов на fixtures без сети.
Raises:
ValueError — если item_id не извлечён.
"""
tree = HTMLParser(html)
# ── Identity ────────────────────────────────────────────────────────────
item_id = _extract_item_id(tree)
if not item_id:
raise ValueError(f"Cannot extract item_id from {source_url}")
title = _text(tree, "[data-marker='item-view/title-info']")
price_rub = _extract_price(tree)
publish_date, views_total, views_today = _extract_meta(tree)
# ── Location ────────────────────────────────────────────────────────────
map_el = tree.css_first("[data-marker='item-map-wrapper']")
lat: float | None = None
lon: float | None = None
avito_location_id: int | None = None
if map_el is not None:
lat = _try_float(map_el.attributes.get("data-map-lat"))
lon = _try_float(map_el.attributes.get("data-map-lon"))
avito_location_id = _try_int(map_el.attributes.get("data-location-id"))
# bbox-guard (#1871): detail-страница иногда отдаёт координаты вне ЕКБ
# (Питер/Тюмень/Уфа — кросс-региональные дубли объявлений). Эти координаты
# попадают в UPDATE COALESCE без bbox-валидации (в отличие от geocoder.py,
# где bbox-фильтр везде). Сбрасываем в None → листинг уходит в geocode-cron
# путь, который сам отфильтрует по адресу. SERP-ingest координат не даёт вовсе.
if lat is not None and lon is not None and not is_within_ekb_bbox_wide(lat, lon):
logger.warning(
"avito_detail #1871: dropped non-EKB coords item=%s lat=%s lon=%s",
item_id,
lat,
lon,
)
lat = lon = None
address_full = _clean_address(_text(tree, "[itemprop='address']"))
# ── Description ─────────────────────────────────────────────────────────
description = _text(tree, "[data-marker='item-view/item-description']")
# ── Metro from description ───────────────────────────────────────────────
metro_stations: list[dict[str, Any]] = []
if description:
metro_stations = _extract_metro(description)
# ── House catalog link ───────────────────────────────────────────────────
# Новостройки отдают кнопку ЖК (nd-jk-details-button); вторичка — ссылку
# «узнать больше о доме» под другим маркером. Fallback на любой /catalog/houses/
# якорь: verified 2026-05-31 (#871) — эта ссылка есть в SSR detail-страницы,
# даже когда отсутствует в JS-рендеренной SERP-карточке (откуда мы брали 0).
house_catalog_url: str | None = None
house_link = tree.css_first("[data-marker='nd-jk-details-button']")
if house_link is None:
house_link = tree.css_first("a[href*='/catalog/houses/']")
if house_link is not None:
href = house_link.attributes.get("href", "")
if href:
# отбрасываем ?context-трекинг → чистый catalog-путь
house_catalog_url = urljoin(AVITO_BASE, urlparse(href).path)
# ── Photo gallery ────────────────────────────────────────────────────────
photo_urls: list[str] = []
for img in tree.css("[data-marker='image-preview/item'] img"):
src = img.attributes.get("src") or ""
if src and src not in photo_urls:
photo_urls.append(src)
# ── Params blocks ────────────────────────────────────────────────────────
params_block = tree.css_first("[data-marker='item-view/item-params']")
apt_params: dict[str, str] = {}
house_params: dict[str, str] = {}
if params_block is not None:
ul_els = params_block.css("ul")
if len(ul_els) >= 1:
apt_params = _parse_params_ul(ul_els[0])
if len(ul_els) >= 2:
house_params = _parse_params_ul(ul_els[1])
# Merge house_params into apt_params — некоторые страницы совмещают всё в одном ul
all_params = {**apt_params, **house_params}
# ── Parse apartment fields ───────────────────────────────────────────────
rooms: int | None = None
area_m2: float | None = None
kitchen_area_m2: float | None = None
living_area_m2: float | None = None
ceiling_height_m: float | None = None
finishing: str | None = None
floor: int | None = None
total_floors: int | None = None
balcony_loggia: str | None = None
room_layout: str | None = None
bathroom_type: str | None = None
windows_view: str | None = None
repair_state: str | None = None
sale_type: str | None = None
mortgage_available: bool | None = None
house_type: str | None = None
total_floors_house: int | None = None
passenger_elevators: int | None = None
cargo_elevators: int | None = None
has_concierge: bool | None = None
closed_yard: bool | None = None
for rus_label, val in all_params.items():
mapping = RUS_FIELD_MAP.get(rus_label)
if mapping is None:
continue
field_name, _conv = mapping
if field_name == "rooms":
rooms = _try_int(val)
elif field_name == "area_m2":
area_m2 = _parse_float_m2(val)
elif field_name == "kitchen_area_m2":
kitchen_area_m2 = _parse_float_m2(val)
elif field_name == "living_area_m2":
living_area_m2 = _parse_float_m2(val)
elif field_name == "ceiling_height_m":
ceiling_height_m = _parse_height_m(val)
elif field_name == "finishing":
finishing = val.strip().lower() or None
elif field_name == "floor_split":
floor, total_floors = _parse_floor_split(val)
elif field_name == "balcony_loggia":
balcony_loggia = _map_lower(val, _BALCONY_MAP)
elif field_name == "room_layout":
room_layout = _map_lower(val, _LAYOUT_MAP)
elif field_name == "bathroom_type":
bathroom_type = _map_lower(val, _BATHROOM_MAP)
elif field_name == "windows_view":
windows_view = _map_lower(val, _WINDOWS_MAP)
elif field_name == "repair_state":
repair_state = _map_lower(val, _REPAIR_MAP)
elif field_name == "sale_type":
sale_type = _map_lower(val, _SALE_TYPE_MAP)
elif field_name == "sale_conditions_raw":
if "возможна ипотека" in val.lower():
mortgage_available = True
elif field_name == "house_type":
house_type = _map_lower(val, _HOUSE_TYPE_MAP)
elif field_name == "total_floors_house":
total_floors_house = _try_int(val)
elif field_name == "passenger_elevators":
passenger_elevators = _try_int(val)
elif field_name == "cargo_elevators":
cargo_elevators = _try_int(val)
elif field_name == "in_house_features_raw":
if "консьерж" in val.lower():
has_concierge = True
elif field_name == "yard_features_raw":
if "закрытая территория" in val.lower():
closed_yard = True
# ── Fallback: repair_state из описания, если структурное поле пустое (#622) ──
if repair_state is None and description:
repair_state = infer_repair_state_from_text(description)
# ── Domoteka ─────────────────────────────────────────────────────────────
owners_count: int | None = None
owners_at_least: bool | None = None
last_owner_change_date: date | None = None
encumbrances_clean: bool | None = None
registry_match: bool | None = None
domoteka_block = tree.css_first("[data-marker='domoteka-entry-block']")
if domoteka_block is not None:
badges = domoteka_block.css("p[data-marker='TeaserData.item']")
for badge in badges:
badge_text = badge.text(strip=True)
if not badge_text:
continue
m_owners = OWNERS_RE.match(badge_text)
if m_owners:
owners_count = int(m_owners.group(1))
owners_at_least = "или больше" in badge_text.lower()
continue
m_change = OWNER_CHANGE_RE.search(badge_text)
if m_change:
try:
day = int(m_change.group(1))
month_word = m_change.group(2).lower()
year = int(m_change.group(3))
month = RUS_MONTHS.get(month_word)
if month:
last_owner_change_date = date(year, month, day)
except (ValueError, KeyError):
logger.warning("Cannot parse owner change date: %s", badge_text)
continue
if ENCUMBRANCES_RE.search(badge_text):
encumbrances_clean = True
continue
if REGISTRY_MATCH_RE.search(badge_text):
registry_match = True
continue
return DetailEnrichment(
item_id=item_id,
source_url=source_url,
title=title,
price_rub=price_rub,
publish_date=publish_date,
views_total=views_total,
views_today=views_today,
rooms=rooms,
area_m2=area_m2,
kitchen_area_m2=kitchen_area_m2,
living_area_m2=living_area_m2,
ceiling_height_m=ceiling_height_m,
finishing=finishing,
floor=floor,
total_floors=total_floors,
balcony_loggia=balcony_loggia,
room_layout=room_layout,
bathroom_type=bathroom_type,
windows_view=windows_view,
repair_state=repair_state,
sale_type=sale_type,
mortgage_available=mortgage_available,
house_type=house_type,
total_floors_house=total_floors_house,
passenger_elevators=passenger_elevators,
cargo_elevators=cargo_elevators,
has_concierge=has_concierge,
closed_yard=closed_yard,
house_catalog_url=house_catalog_url,
address_full=address_full,
lat=lat,
lon=lon,
avito_location_id=avito_location_id,
metro_stations=metro_stations,
description=description,
owners_count=owners_count,
owners_at_least=owners_at_least,
last_owner_change_date=last_owner_change_date,
encumbrances_clean=encumbrances_clean,
registry_match=registry_match,
photo_urls=photo_urls,
)
# ── save_detail_enrichment ────────────────────────────────────────────────────
def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
"""UPDATE listings SET <25+ cols> WHERE source='avito' AND source_id=:item_id.
Returns True если строка обновлена, False если listing не найден.
Устанавливает detail_enriched_at=NOW().
house_type СОХРАНЯЕТСЯ (уже нормализован парсером → канон panel/brick/monolith/
monolith_brick/block/wood) — нужен estimator soft-penalty по типу дома.
house_catalog_url СОХРАНЯЕТСЯ в listings.house_url (линковка дома); отдельной
house_catalog_url колонки нет. house_type — COALESCE existing-first (не затираем
canonical из Houses Catalog Stage 2c); house_url — new-first additive (avito-owned
поле, нет конкурирующего canonical-писателя).
Остальные house-level поля (passenger_elevators, cargo_elevators, has_concierge,
closed_yard, total_floors_house) по-прежнему опускаются — нет колонок в listings,
canonical приходит из Houses Catalog Stage 2c (отдельный follow-up).
"""
result = db.execute(
text("""
UPDATE listings SET
kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
living_area_m2 = COALESCE(:living_area_m2, living_area_m2),
ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),
finishing = COALESCE(:finishing, finishing),
balcony_loggia = COALESCE(:balcony_loggia, balcony_loggia),
room_layout = COALESCE(:room_layout, room_layout),
bathroom_type = COALESCE(:bathroom_type, bathroom_type),
windows_view = COALESCE(:windows_view, windows_view),
sale_type = COALESCE(:sale_type, sale_type),
mortgage_available = COALESCE(:mortgage_available, mortgage_available),
views_total = COALESCE(:views_total, views_total),
views_today = COALESCE(:views_today, views_today),
publish_date = COALESCE(:publish_date, publish_date),
description = COALESCE(:description, description),
owners_count = COALESCE(:owners_count, owners_count),
owners_at_least = COALESCE(:owners_at_least, owners_at_least),
last_owner_change_date = COALESCE(:last_owner_change_date, last_owner_change_date),
encumbrances_clean = COALESCE(:encumbrances_clean, encumbrances_clean),
registry_match = COALESCE(:registry_match, registry_match),
metro_stations = COALESCE(CAST(:metro_stations AS jsonb), metro_stations),
avito_location_id = COALESCE(:avito_location_id, avito_location_id),
lat = COALESCE(:lat, lat),
lon = COALESCE(:lon, lon),
address = COALESCE(:address, address),
rooms = COALESCE(:rooms, rooms),
area_m2 = COALESCE(:area_m2, area_m2),
floor = COALESCE(:floor, floor),
total_floors = COALESCE(:total_floors, total_floors),
repair_state = COALESCE(:repair_state, repair_state),
price_rub = COALESCE(:price_rub, price_rub),
house_type = COALESCE(house_type, :house_type),
house_url = COALESCE(:house_catalog_url, house_url),
detail_enriched_at = NOW()
WHERE source = 'avito' AND source_id = :item_id
"""),
{
"item_id": e.item_id,
"kitchen_area_m2": e.kitchen_area_m2,
"living_area_m2": e.living_area_m2,
"ceiling_height_m": e.ceiling_height_m,
"finishing": e.finishing,
"balcony_loggia": e.balcony_loggia,
"room_layout": e.room_layout,
"bathroom_type": e.bathroom_type,
"windows_view": e.windows_view,
"sale_type": e.sale_type,
"mortgage_available": e.mortgage_available,
"views_total": e.views_total,
"views_today": e.views_today,
"publish_date": e.publish_date,
"description": e.description,
"owners_count": e.owners_count,
"owners_at_least": e.owners_at_least,
"last_owner_change_date": e.last_owner_change_date,
"encumbrances_clean": e.encumbrances_clean,
"registry_match": e.registry_match,
"metro_stations": (
json.dumps(e.metro_stations, ensure_ascii=False) if e.metro_stations else None
),
"avito_location_id": e.avito_location_id,
"lat": e.lat,
"lon": e.lon,
"address": e.address_full,
"rooms": e.rooms,
"area_m2": e.area_m2,
"floor": e.floor,
"total_floors": e.total_floors,
"repair_state": e.repair_state,
"price_rub": e.price_rub,
"house_type": e.house_type,
"house_catalog_url": e.house_catalog_url,
},
)
db.commit()
found = result.rowcount > 0
if not found:
logger.warning("save_detail_enrichment: listing not found in DB for item_id=%s", e.item_id)
else:
logger.info("save_detail_enrichment: updated listing item_id=%s", e.item_id)
return found
# ── Internal helpers ─────────────────────────────────────────────────────────
def _text(tree: HTMLParser, selector: str) -> str | None:
"""Вернуть stripped text первого совпавшего элемента или None."""
el = tree.css_first(selector)
if el is None:
return None
t = el.text(strip=True)
return t or None
def _try_float(val: Any) -> float | None:
if val is None:
return None
try:
return float(str(val).replace(",", "."))
except (ValueError, TypeError):
return None
def _try_int(val: Any) -> int | None:
if val is None:
return None
s = str(val).strip()
m = _INT_RE.search(s)
if m:
try:
return int(m.group())
except ValueError:
return None
return None
def _parse_float_m2(val: str) -> float | None:
"""'90.1 м²' → 90.1. '75 м²' → 75.0."""
m = _FLOAT_RE.search(val)
if m:
try:
return float(m.group().replace(",", "."))
except ValueError:
return None
return None
def _parse_height_m(val: str) -> float | None:
"""'2.7 м' / '2,7\xa0м' / '3 м' → float метры. Junk → None."""
m = _FLOAT_RE.search(val)
if m:
try:
return float(m.group().replace(",", "."))
except ValueError:
return None
return None
def _parse_floor_split(val: str) -> tuple[int | None, int | None]:
"""'20 из 26' → (20, 26)."""
m = _FLOOR_SPLIT_RE.search(val)
if m:
return _try_int(m.group(1)), _try_int(m.group(2))
return None, None
def _map_lower(val: str, mapping: dict[str, str]) -> str | None:
"""Нижний регистр + strip → lookup в mapping. None если не найдено."""
key = val.strip().lower()
return mapping.get(key)
def _extract_item_id(tree: HTMLParser) -> str | None:
"""Извлечь item_id из [data-marker='item-view/item-id'] text."""
el = tree.css_first("[data-marker='item-view/item-id']")
if el is None:
return None
text_val = el.text(strip=True)
m = _ITEM_ID_RE.search(text_val)
return m.group(1) if m else None
def _extract_price(tree: HTMLParser) -> int | None:
"""Извлечь price из [itemprop='price'] content attribute."""
el = tree.css_first("[itemprop='price']")
if el is None:
return None
content = el.attributes.get("content")
return _try_int(content)
def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None]:
"""Извлечь publish_date, views_total, views_today из item-id блока."""
el = tree.css_first("[data-marker='item-view/item-id']")
if el is None:
return None, None, None
# Поиск по всему тексту включая дочерние элементы
full_text = el.text(strip=False)
publish_date: date | None = None
m_date = _PUBLISH_DATE_RE.search(full_text)
if m_date:
try:
day = int(m_date.group(1))
month_word = m_date.group(2).lower()
month = RUS_MONTHS.get(month_word)
# Год — текущий (Avito не показывает год для свежих объявлений)
import datetime
current_year = datetime.date.today().year
if month:
publish_date = date(current_year, month, day)
except (ValueError, KeyError):
pass
views_total: int | None = None
m_total = _VIEWS_TOTAL_RE.search(full_text)
if m_total:
views_total = int(m_total.group(1))
views_today: int | None = None
m_today = _VIEWS_TODAY_RE.search(full_text)
if m_today:
views_today = int(m_today.group(1))
# Попробуем найти views в отдельных data-marker элементах (новый Avito layout)
if views_total is None:
v_el = tree.css_first("[data-marker='item-view/total-views']")
if v_el is not None:
m = _VIEWS_TOTAL_RE.search(v_el.text(strip=True))
if m:
views_total = int(m.group(1))
if views_today is None:
t_el = tree.css_first("[data-marker='item-view/today-views']")
if t_el is not None:
m = _VIEWS_TODAY_RE.search(t_el.text(strip=True))
if m:
views_today = int(m.group(1))
return publish_date, views_total, views_today
def _parse_params_ul(ul: Node) -> dict[str, str]:
"""Парсим с - Label: Value
→ {label: value} dict."""
result: dict[str, str] = {}
for li in ul.css("li"):
text_val = li.text(strip=True)
if ":" in text_val:
label, _, value = text_val.partition(":")
label = label.strip()
value = value.strip()
if label and value:
result[label] = value
return result
def _extract_metro(text_val: str) -> list[dict[str, Any]]:
"""Извлечь станции метро из текста описания.
Примеры: 'Чкаловская 11-15 мин пешком', 'от 5 мин пешком'.
"""
stations: list[dict[str, Any]] = []
for m in METRO_RE.finditer(text_val):
name = m.group(1)
if m.group(2) and m.group(3):
# диапазон: 11-15
min_from = int(m.group(2))
min_to = int(m.group(3))
elif m.group(4):
# "от N мин"
min_from = None
min_to = int(m.group(4))
else:
continue
stations.append(
{
"name": name,
"min_to": min_to,
"min_from": min_from,
"mode": "walk",
}
)
return stations