gendesign/tradein-mvp/backend/app/services/scrapers/avito_detail.py
lekss361 e7f97f1355
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
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) Has been cancelled
fix(tradein/avito): консолидировать house_type normalizer + актуализировать docstring (#2009) (#2340)
2026-07-03 22:47:05 +00:00

1075 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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) — house_type + house_catalog_url сохраняются в listings
(#2009); total_floors_house/lifts/concierge/closed_yard остаются canonical
из Houses Catalog (Stage 2c, avito_houses.py → houses table)
- 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 app.services.geocoder import is_within_ekb_bbox_wide
from app.services.scrapers.avito import _clean_address, _is_firewall_page
from app.services.scrapers.avito_exceptions import (
AvitoBlockedError,
AvitoListingGoneError,
AvitoRateLimitedError,
)
from app.services.scrapers.avito_shared import RUS_MONTHS
from app.services.scrapers.house_type_normalizer import normalize_house_type
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
if TYPE_CHECKING:
from app.services.scrapers.browser_fetcher import BrowserFetcher
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: канон panel/brick/monolith/monolith_brick/block/wood — через shared
# normalize_house_type() (#2009, консолидация с #2007/#2008; см.
# house_type_normalizer.py — русские лейблы avito добавлены туда же вокабуляром,
# НЕ дублируются локально).
# ── DetailEnrichment dataclass ────────────────────────────────────────────────
@dataclass
class DetailEnrichment:
"""Результат парсинга detail page Avito.
Содержит 30+ полей из страницы объявления.
save_detail_enrichment() пишет подмножество в listings; house_type + house_catalog_url
сохраняются (#2009), остальные house-level поля (total_floors_house, elevators,
concierge, closed_yard) пропускаются — они 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 — house_type/house_catalog_url сохраняются в listings (#2009);
# остальные пропускаются, canonical источник — Houses Catalog (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() -> AsyncSession:
"""Создать curl_cffi-сессию для detail-fetch (impersonate chrome120, через прокси
если задан settings.scraper_proxy_url).
Вынесено из own-session-ветки fetch_detail чтобы reconnect-retry мог пересоздать
эфемерную сессию (новый CONNECT-туннель = свежий backconnect exit-IP) под 403.
proxy=None → прямое подключение.
"""
from app.core.config import settings
proxy_url = settings.scraper_proxy_url
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 с ОБЩЕЙ
# витриной (<title>Авито — Объявления на сайте Авито</title>) вместо запрошенного
# объявления — мягкий анти-бот 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-витринный <title>.
"""
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,
) -> DetailEnrichment:
"""GET <avito>/{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)
from app.core.config import settings
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()
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(settings.scraper_proxy_url) 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 = normalize_house_type(val)
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]:
"""Парсим <ul> с <li>Label: Value</li> → {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