gendesign/tradein-mvp/backend/app/services/scrapers/avito.py
bot-backend 80b953d0bc fixup(tradein): avito browser-mode — skip curl client alloc + rotation-false test (#901)
Review #913: move browser early-return before super().__aenter__() so the
unused httpx client is never allocated in browser mode; add test for
_rotate_ip()==False raising AvitoBlockedError; clarify _fetch_serp_html docstring.

Refs #883
2026-05-31 19:46:34 +03:00

827 lines
36 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.ru scraper — парсинг вторички вокруг точки + citywide/byrooms sweep.
Стратегия: HTML scrape карточек объявлений через DOM.
URL patterns (EKB):
geo-mode (default):
https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ
?geoCoords=56.838,60.605
&radius=1 # в км
&s=104 # sort by date
&p=1 # страница
citywide-mode (без geo, весь город, T6):
https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ
?s=104&p=1
byrooms-mode (citywide + фильтр комнатности, T6):
https://www.avito.ru/ekaterinburg/kvartiry/prodam/<room-slug>?s=104&p=1
ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP.
Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello.
"""
from __future__ import annotations
import asyncio
import logging
import re
from datetime import date, datetime, timedelta, timezone
from typing import Any
from urllib.parse import urlencode, urljoin
from curl_cffi.requests import AsyncSession
from selectolax.parser import HTMLParser
from app.core.config import settings
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.avito_exceptions import (
AvitoBlockedError,
AvitoContentBlockedError,
AvitoRateLimitedError,
)
from app.services.scrapers.base import BaseScraper, ScrapedLot
from app.services.scrapers.browser_fetcher import BrowserFetcher
logger = logging.getLogger(__name__)
# ── Relative date parsing ────────────────────────────────────────────────────
_REL_DATE_RE = re.compile(
r"(?P<n>\d+)\s+(?P<unit>"
r"секунд[ауы]?|"
r"минут[ауы]?|"
r"час(?:а|ов)?|"
r"день|дн(?:я|ей?)|"
r"недел[июяь]+|"
r"месяц(?:а|ев)?|"
r"год(?:а|ов)?"
r")\s+назад",
flags=re.I,
)
_UNIT_DAYS: dict[str, int] = {
"секунд": 0,
"минут": 0,
"час": 0,
"день": 1,
"дн": 1,
"недел": 7,
"месяц": 30,
"год": 365,
}
# Russian month names → number. Avito показывает абсолютные даты как «18 мая».
_RU_MONTHS: dict[str, int] = {
"январ": 1,
"феврал": 2,
"март": 3,
"апрел": 4,
"ма": 5,
"июн": 6,
"июл": 7,
"август": 8,
"сентябр": 9,
"октябр": 10,
"ноябр": 11,
"декабр": 12,
}
# «18 мая», «5 июня в 12:30» — day + month (year inferred current).
_ABS_DATE_RE = re.compile(
r"(?P<day>\d{1,2})\s+(?P<month>январ\w*|феврал\w*|март\w*|апрел\w*|"
r"ма[яй]|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)",
flags=re.I,
)
def _parse_relative_date(s: str | None) -> date | None:
"""Avito показывает дату публикации в трёх форматах:
* '5 дней назад' / 'час назад' / 'неделю назад' — relative numeric
* 'сегодня в 12:30' / 'вчера в 18:45' / 'позавчера' — relative keyword
* '18 мая' / '5 июня в 12:30' — absolute day + month (year inferred)
PR M-followup 2026-05-27: расширено покрытие — раньше только relative numeric
matched (regex `\\d+ unit назад`), 100% Avito listings заходило с listing_date=None
т.к. Avito SERP для свежих cards использует keyword/absolute форматы. См.
тесты для каждого case.
"""
if not s:
return None
text_lower = s.lower()
# Keyword shortcuts (no \d+).
if "позавчера" in text_lower:
return date.today() - timedelta(days=2)
if "вчера" in text_lower:
return date.today() - timedelta(days=1)
if "сегодня" in text_lower or "только что" in text_lower:
return date.today()
# Relative numeric («5 дней назад»).
m = _REL_DATE_RE.search(s)
if m:
n = int(m["n"])
unit_raw = m["unit"].lower()
for prefix, days in _UNIT_DAYS.items():
if unit_raw.startswith(prefix):
return date.today() - timedelta(days=n * days)
# Absolute («18 мая», «5 июня в 12:30»). Year — current; если месяц
# в будущем относительно сегодня (например сейчас декабрь, увидели
# «5 января») — откатываем к прошлому году.
m = _ABS_DATE_RE.search(s)
if m:
day = int(m["day"])
month_raw = m["month"].lower()
month: int | None = None
for prefix, num in _RU_MONTHS.items():
if month_raw.startswith(prefix):
month = num
break
if month is not None and 1 <= day <= 31:
today = date.today()
year = today.year
try:
parsed = date(year, month, day)
except ValueError:
return None
# Если parsed в будущем больше чем на 30 дней — Avito показывает
# прошлогоднюю дату («декабрь» в феврале значит прошлый декабрь).
if (parsed - today).days > 30:
try:
parsed = date(year - 1, month, day)
except ValueError:
return None
return parsed
return None
# #726: Avito SERP лениво рендерит DOM — `data-marker="item-date"` присутствует только
# у верхних (above-the-fold) карточек (~10 из ~46), у остальных его НЕТ в raw-HTML, что
# curl_cffi получает (JS-гидрация не выполняется). Но per-item JSON в той же HTML несёт
# `sortTimeStamp` (epoch-ms) почти у всех карточек — это drift-resistant источник даты.
# Маппим item_id (= DOM `data-item-id`) → дата. `{0,1500}` ограничивает поиск телом
# одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный
# `addressDetailed{…}`, поэтому не `[^{}]`). re.S — sortTimeStamp может быть на новой строке.
# sortTimeStamp — UTC epoch-ms. Avito показывает дату в MSK (UTC+3, без DST с 2014).
# Берём .date() в MSK, чтобы совпадало с тем, что видно на сайте (иначе объявления,
# поднятые в 00:0003:00 MSK, давали бы дату на день раньше). Fixed offset, без
# зависимости от системной tzdata в контейнере. #726.
_MSK = timezone(timedelta(hours=3))
_SORT_TS_RE = re.compile(r'"id":(?P<id>\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P<ts>\d+)', re.S)
def _build_sort_timestamp_map(html: str) -> dict[str, date]:
"""item_id → дата публикации из embedded JSON (`sortTimeStamp`, epoch-ms).
Best-effort: при любой ошибке конкретной записи — пропускаем её (не роняем парс).
Первое вхождение id выигрывает (item-объект идёт раньше recommendations-дублей).
"""
out: dict[str, date] = {}
for m in _SORT_TS_RE.finditer(html):
item_id = m.group("id")
if item_id in out:
continue
try:
out[item_id] = datetime.fromtimestamp(int(m.group("ts")) / 1000, tz=_MSK).date()
except (ValueError, OSError, OverflowError):
continue
return out
# #623: Avito банит datacenter-IP — на HTTP 200 отдаёт firewall-интерстициал
# «Доступ ограничен: проблема с IP» (title в <head>), а НЕ 403. Детектим по
# маркерам в начале документа (firewall-страница объёмна, не сканируем целиком).
_FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip", "firewall-container")
def _is_firewall_page(html: str) -> bool:
"""True если Avito вернул firewall-страницу IP-блока (на HTTP 200)."""
head = html[:4096].lower()
return any(marker in head for marker in _FIREWALL_MARKERS)
class AvitoScraper(BaseScraper):
"""Avito vtorichka parser. Источник = 'avito'.
Использует curl_cffi с impersonate=chrome120 для обхода TLS fingerprint бана.
"""
name = "avito"
base_url = "https://www.avito.ru"
# Avito жёстко мониторит — спим долго между запросами.
# Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра.
request_delay_sec = 7.0
def __init__(self) -> None:
super().__init__()
self.request_delay_sec = get_scraper_delay(self.name)
self._cffi: AsyncSession | None = None
self._browser: BrowserFetcher | None = None
# #823: счётчик карточек, которые не удалось распарсить из-за неожиданной структуры DOM.
self.parse_failures: int = 0
async def __aenter__(self) -> AvitoScraper:
if settings.scraper_fetch_mode == "browser":
self._browser = BrowserFetcher()
await self._browser.__aenter__()
logger.info("avito: SERP fetch via BrowserFetcher (camoufox) — #901")
return self
await super().__aenter__()
proxies = None
if settings.scraper_proxy_url:
proxies = {"http": settings.scraper_proxy_url, "https": settings.scraper_proxy_url}
logger.info("avito: routing through mobile proxy egress (#623)")
self._cffi = 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",
},
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._browser is not None:
await self._browser.__aexit__(*args)
if self._cffi is not None:
await self._cffi.close()
await super().__aexit__(*args)
# ── Anti-block (#623) ─────────────────────────────────────────────────────
async def _rotate_ip(self) -> bool:
"""Сменить мобильный IP через changeip-ссылку mobileproxy (#623).
Дёргается напрямую (без прокси) — это API провайдера, не Avito. Ждём ~9с:
мобильному модему нужно время поднять новый IP. Returns True при успехе.
"""
rotate_url = settings.avito_proxy_rotate_url
if not rotate_url:
return False
sep = "&" if "?" in rotate_url else "?"
try:
async with AsyncSession(timeout=30) as rot:
await rot.get(f"{rotate_url}{sep}format=json")
await asyncio.sleep(9)
logger.info("avito proxy: IP rotated via changeip")
return True
except Exception:
logger.warning("avito proxy: IP rotation failed", exc_info=True)
return False
async def _fetch_serp_html(self, url: str, page: int) -> str | None:
"""GET SERP HTML с детектом IP-бана и ротацией мобильного IP (#623).
Avito на забаненном datacenter-IP отдаёт HTTP 200 + firewall-страницу
(не 403). Детектим по тексту; если задана changeip-ссылка — меняем IP и
повторяем до ``avito_proxy_max_rotations`` раз. Транзиентные сетевые
обрывы (мобильный канал) ретраим без ротации.
Returns:
HTML при HTTP 200, либо ``None`` при non-200 (конец пагинации).
В browser-mode HTTP-статус недоступен: метод возвращает HTML или
поднимает ``AvitoBlockedError`` (pagination ends via empty-parse in callers).
Raises:
AvitoBlockedError / AvitoRateLimitedError — если бан не снят ротацией.
"""
if self._browser is not None:
# Browser-mode (#901): no HTTP status code — soft firewall-detect + rotate.
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
rot_done = 0
while True:
html = await self._browser.fetch(url)
if _is_firewall_page(html):
if rot_done < max_rot and await self._rotate_ip():
rot_done += 1
logger.info(
"avito page=%d browser firewall — retry after rotation #%d",
page,
rot_done,
)
continue
logger.error("avito SERP firewall in browser-mode page=%d url=%s", page, url)
raise AvitoBlockedError(
f"Avito SERP firewall (browser-mode) at page={page} — IP banned"
)
return html
assert self._cffi is not None
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
rot_done = 0
transient_left = 2
while True:
try:
response = await self._cffi.get(url)
except Exception:
if transient_left > 0:
transient_left -= 1
logger.warning(
"avito page=%d transient fetch error — retry (left=%d)",
page,
transient_left,
exc_info=True,
)
await asyncio.sleep(3)
continue
raise
sc = response.status_code
is_firewall = sc == 200 and _is_firewall_page(response.text)
if sc in (403, 429) or is_firewall:
if rot_done < max_rot and await self._rotate_ip():
rot_done += 1
logger.info(
"avito page=%d blocked (HTTP %d, firewall=%s) — retry after rotation #%d",
page,
sc,
is_firewall,
rot_done,
)
continue
if sc == 429:
logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url)
raise AvitoRateLimitedError(
f"Avito SERP returned 429 — rate limited at page={page}"
)
logger.error(
"avito SERP blocked page=%d (HTTP %d, firewall=%s) url=%s",
page,
sc,
is_firewall,
url,
)
raise AvitoBlockedError(
f"Avito SERP blocked (HTTP {sc}, firewall={is_firewall}) "
f"at page={page} — IP banned"
)
if sc != 200:
logger.warning("avito HTML page=%d returned %d for %s", page, sc, url)
return None
return response.text
# ── Public ──────────────────────────────────────────────────────────────
async def fetch_around(
self,
lat: float,
lon: float,
radius_m: int = 1000,
*,
pages: int = 1,
delay_override_sec: float | None = None,
) -> list[ScrapedLot]:
"""Найти объявления Авито вокруг (lat, lon) в radius_m метрах.
Avito работает в км — конвертируем. Минимум 1 км.
pages=1 (default) — backward compat, одна страница (~50 lots).
pages>1 — итерируем p=1..pages, собираем до 50*pages lots.
Между запросами sleep request_delay_sec (или delay_override_sec если передан).
Coords НЕ заполняем из anchor (избегаем silent corruption через jitter).
Точные lat/lon приходят позже из avito_detail.py — для search-карточек
lat=lon=None, далее geocode-missing cron подтянет из address.
"""
radius_km = max(1, round(radius_m / 1000))
if delay_override_sec is not None:
self.request_delay_sec = delay_override_sec
all_lots: list[ScrapedLot] = []
for page in range(1, pages + 1):
url = self._build_web_url(lat, lon, radius_km, page=page)
try:
html = await self._fetch_serp_html(url, page)
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception:
logger.exception("avito HTML page=%d fetch failed for %s", page, url)
break
if html is None:
break
lots = self._parse_html(html, source_url_base=url)
if not lots:
if page == 1:
logger.error(
"avito SERP page=1 returned HTTP 200 but 0 cards — "
"likely content-block/captcha url=%s",
url,
)
raise AvitoContentBlockedError(
"Avito SERP HTTP 200 with 0 cards on page=1 — content-block suspected"
)
logger.info("avito page=%d: 0 lots — end of pagination", page)
break
all_lots.extend(lots)
logger.info(
"avito page=%d: %d lots (total %d) around (%.4f, %.4f)",
page,
len(lots),
len(all_lots),
lat,
lon,
)
if page < pages:
await self.sleep_between_requests()
if pages == 1 and all_lots:
# Backward compat: single-page path does trailing sleep (как раньше)
await self.sleep_between_requests()
elif pages > 1:
logger.info(
"avito fetch_around pages=%d total_lots=%d around (%.4f, %.4f)",
pages,
len(all_lots),
lat,
lon,
)
return all_lots
# ── Strategy B: HTML scrape ─────────────────────────────────────────────
def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str:
params = {
"geoCoords": f"{lat},{lon}",
"radius": radius_km,
"s": 104, # sort by date
"p": page,
}
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
def _build_citywide_url(self, page: int = 1) -> str:
"""T6: URL всего города ЕКБ без geo-фильтра, сортировка по дате.
Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек).
"""
params = {"s": 104, "p": page}
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
def _build_rooms_url(self, room_slug: str, page: int = 1) -> str:
"""T6: URL с фильтром по комнатности для всего ЕКБ (no geo).
Дробление по комнатности позволяет обойти cap ~5000 на category.
room_slug — берётся из ROOM_SLUGS (кодирует ASgB-параметры выборки).
"""
params = {"s": 104, "p": page}
return f"{self.base_url}/ekaterinburg/kvartiry/prodam/{room_slug}?{urlencode(params)}"
# ── T6: citywide / byrooms fetch methods ──────────────────────────────────
async def fetch_city_wide(
self,
pages: int = 100,
*,
delay_override_sec: float | None = None,
) -> list[ScrapedLot]:
"""T6: Обход всего ЕКБ без geo-фильтра (citywide-mode), paginated.
Возвращает дедуплицированный список лотов (по source_id/source_url).
Break-on-empty: останавливается когда страница отдаёт 0 карточек.
Сохраняет весь anti-block pipeline (_fetch_serp_html: firewall-detect,
IP rotation, транзиентные ретраи). Не трогает fetch_around.
Args:
pages: максимальное число страниц (default 100 = Avito cap ~5000 лотов).
delay_override_sec: если задан — переопределяет request_delay_sec для
этого вызова.
Returns:
Список ScrapedLot (все страницы, дедуп по source_id).
"""
if delay_override_sec is not None:
self.request_delay_sec = delay_override_sec
all_lots: list[ScrapedLot] = []
seen_ids: set[str] = set()
for page in range(1, pages + 1):
url = self._build_citywide_url(page)
try:
html = await self._fetch_serp_html(url, page)
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception:
logger.exception("avito citywide page=%d fetch failed url=%s", page, url)
break
if html is None:
logger.info("avito citywide page=%d: non-200 — end of pagination", page)
break
lots = self._parse_html(html, source_url_base=url)
if not lots:
logger.info("avito citywide page=%d: 0 lots — end of pagination", page)
break
new_lots = [lot for lot in lots if lot.source_id not in seen_ids]
for lot in new_lots:
if lot.source_id:
seen_ids.add(lot.source_id)
all_lots.extend(new_lots)
logger.info(
"avito citywide page=%d: %d lots (%d new, %d dedup'd, total=%d)",
page,
len(lots),
len(new_lots),
len(lots) - len(new_lots),
len(all_lots),
)
if page < pages:
await self.sleep_between_requests()
logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots))
return all_lots
async def fetch_by_rooms(
self,
pages: int = 100,
*,
delay_override_sec: float | None = None,
room_slugs: list[tuple[str, str]] | None = None,
) -> list[ScrapedLot]:
"""T6: Обход ЕКБ по категориям комнатности (byrooms-mode).
Дробление по 7 категориям комнатности позволяет обойти Avito cap ~5000
результатов на поиск: каждый сегмент < 5000. Итого до 7 × 5000 = 35k лотов.
Break-on-empty на каждой категории. Глобальный dedup по source_id.
Args:
pages: страниц на категорию (default 100).
delay_override_sec: переопределяет request_delay_sec.
room_slugs: список (name, slug) для обхода; по умолчанию ROOM_SLUGS
(все 7 категорий). Передай подмножество для частичного обхода.
Returns:
Список ScrapedLot (все категории, глобальный дедуп по source_id).
"""
if delay_override_sec is not None:
self.request_delay_sec = delay_override_sec
slugs = room_slugs if room_slugs is not None else ROOM_SLUGS
all_lots: list[ScrapedLot] = []
seen_ids: set[str] = set()
for ci, (name, slug) in enumerate(slugs, 1):
logger.info(
"avito byrooms [%d/%d] category=%s slug_prefix=%.30s",
ci,
len(slugs),
name,
slug,
)
cat_count = 0
for page in range(1, pages + 1):
url = self._build_rooms_url(slug, page)
try:
html = await self._fetch_serp_html(url, page)
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception:
logger.exception(
"avito byrooms category=%s page=%d fetch failed url=%s",
name,
page,
url,
)
break
if html is None:
logger.info("avito byrooms category=%s page=%d: non-200 — end", name, page)
break
lots = self._parse_html(html, source_url_base=url)
if not lots:
logger.info(
"avito byrooms category=%s page=%d: 0 lots — end of category",
name,
page,
)
break
new_lots = [lot for lot in lots if lot.source_id not in seen_ids]
for lot in new_lots:
if lot.source_id:
seen_ids.add(lot.source_id)
all_lots.extend(new_lots)
cat_count += len(new_lots)
logger.info(
"avito byrooms category=%s page=%d: %d lots (%d new, total=%d)",
name,
page,
len(lots),
len(new_lots),
len(all_lots),
)
if page < pages:
await self.sleep_between_requests()
logger.info(
"avito byrooms category=%s done: cat_new=%d grand_total=%d",
name,
cat_count,
len(all_lots),
)
logger.info("avito fetch_by_rooms pages_per_cat=%d total_lots=%d", pages, len(all_lots))
return all_lots
def _parse_html(self, html: str, source_url_base: str) -> list[ScrapedLot]:
"""Парсим карточки объявлений из HTML через DOM scrape.
Поля карточки берём из DOM (`data-marker="item-*"`). Дату публикации — из
embedded per-item JSON (`sortTimeStamp`), т.к. DOM `item-date` рендерится
лениво только у верхних карточек (#726); DOM-marker остаётся fallback.
"""
tree = HTMLParser(html)
ts_map = _build_sort_timestamp_map(html)
cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = []
for card in cards:
lot = self._dom_card_to_lot(card, source_url_base, ts_map)
if lot is not None:
lots.append(lot)
return lots
def _dom_card_to_lot(
self, card: Any, source_url_base: str, ts_map: dict[str, date] | None = None
) -> ScrapedLot | None:
"""Парсинг карточки объявления через DOM.
Avito state давно пустой — единственный путь. Coords из карточки не отдаются,
оставляем lat=lon=None (geocode-missing cron подтянет из address).
"""
try:
link_el = card.css_first('a[data-marker="item-title"]')
if link_el is None:
return None
href = link_el.attributes.get("href", "")
url = urljoin("https://www.avito.ru", href)
title = link_el.text(strip=True)
price_el = card.css_first('meta[itemprop="price"]')
price = int(price_el.attributes.get("content", 0)) if price_el is not None else 0
if price <= 0:
return None
rooms = _extract_rooms_from_title(title)
area = _extract_area_from_title(title)
floor, total = _extract_floor_from_title(title)
# Адрес: первый <p> внутри data-marker="item-location"
address: str | None = None
loc_el = card.css_first('[data-marker="item-location"]')
if loc_el is not None:
street_el = loc_el.css_first("p")
if street_el is not None:
address = _clean_address(street_el.text(strip=True))
# Фото
photo_urls: list[str] = []
for img in card.css('img[itemprop="image"]'):
src = img.attributes.get("src") or ""
if src.startswith("http") and src not in photo_urls:
photo_urls.append(src)
if len(photo_urls) >= 5:
break
# Дата публикации (#726): primary — sortTimeStamp из per-item JSON по
# data-item-id (DOM item-date рендерится лениво только у верхних карточек),
# fallback — DOM marker item-date/item-date-info + relative-date parse.
listing_date: date | None = None
item_id = card.attributes.get("data-item-id")
if ts_map and item_id:
listing_date = ts_map.get(item_id)
if listing_date is None:
date_el = card.css_first('[data-marker="item-date"]')
if date_el is None:
date_el = card.css_first('[data-marker="item-date-info"]')
if date_el is not None:
listing_date = _parse_relative_date(date_el.text(strip=True))
if listing_date is None:
# Fallback: ищем любой текст «N дней/недель/месяцев назад» в карточке
card_text = card.text(strip=True)
listing_date = _parse_relative_date(card_text)
# House link: <a href="/catalog/houses/<city>/<slug>/<id>">
# or /catalog/houses/<slug>/<id>
house_source: str | None = None
house_ext_id: str | None = None
house_url: str | None = None
house_link = card.css_first('a[href^="/catalog/houses/"]')
if house_link is not None:
h_href = house_link.attributes.get("href", "")
parts = [p for p in h_href.strip("/").split("/") if p]
# ['catalog', 'houses', <maybe city>, <slug>, <id>]
if len(parts) >= 4 and parts[-1].isdigit():
house_source = "avito"
house_ext_id = parts[-1]
house_url = urljoin("https://www.avito.ru", h_href)
# listing_segment по item URL pattern
listing_segment: str | None = None
if "/kvartiry/" in href:
listing_segment = "vtorichka"
elif "/novostroyki/" in href:
listing_segment = "novostroyki"
return ScrapedLot(
source="avito",
source_url=url,
source_id=card.attributes.get("data-item-id"),
address=address, # БЕЗ fallback "Екатеринбург (Avito)" — пусть None
lat=None, # C-5 fix: НЕ jitter
lon=None, # C-5 fix: НЕ jitter
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total,
price_rub=price,
photo_urls=photo_urls,
listing_date=listing_date,
raw_payload={"title": title, "address": address, "house_ext_id": house_ext_id},
house_source=house_source,
house_ext_id=house_ext_id,
house_url=house_url,
listing_segment=listing_segment,
)
except Exception:
# #823: логируем каждую сломанную карточку и считаем для observability.
# Карточки с неожиданной DOM-структурой скипаем, но не роняем весь парс.
self.parse_failures += 1
item_id_attr = getattr(card, "attributes", {}).get("data-item-id", "?")
logger.warning(
"avito _dom_card_to_lot: failed to parse card item_id=%s (parse_failures=%d)",
item_id_attr,
self.parse_failures,
exc_info=True,
)
return None
# ── Helpers: title parser ───────────────────────────────────────────────────
_RE_ROOMS = re.compile(r"(\d)-к\.?\s*(квартира|кв\.)", re.IGNORECASE)
_RE_STUDIO = re.compile(r"студи[яиюей]", re.IGNORECASE)
_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м[²2]", re.IGNORECASE)
_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт\.?", re.IGNORECASE)
# ── T6: citywide / byrooms sweep ──────────────────────────────────────────────
# Avito URL slugs для room-filter (извлечены из реальных search-страниц 2026-05-23).
# Каждый slug кодирует ASgB-параметры выборки комнатности.
ROOM_SLUGS: list[tuple[str, str]] = [
("студии", "studii-ASgBAgICAkSSA8YQygj~WA"),
("1-комн.", "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ"),
("2-комн.", "2-komnatnye-ASgBAgICAkSSA8YQygiCWQ"),
("3-комн.", "3-komnatnye-ASgBAgICAkSSA8YQygiEWQ"),
("4-комн.", "4-komnatnye-ASgBAgICAkSSA8YQygiGWQ"),
("5+комн.", "5-komnatnye-ASgBAgICAkSSA8YQygiIWQ"),
("своб.планир.", "svobodnaya_planirovka-ASgBAgICAkSSA8YQygj8zzI"),
]
_CSS_NOISE_RE = re.compile(r"\.?css-[a-z0-9_-]+\s*\{[^}]*\}", flags=re.I)
_NOT_ADDRESS_TAIL_RE = re.compile(
r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+)",
flags=re.I,
)
def _clean_address(raw: str | None) -> str | None:
"""Strip Emotion CSS and post-address noise that Avito DOM leaks via .text().
Examples:
"ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:..."
-> "ул. Токарей, 56к1"
"ул. Малышева, 1.css-xxx{...}"
-> "ул. Малышева, 1"
"""
if not raw:
return None
cleaned = _CSS_NOISE_RE.sub("", raw)
cleaned = _NOT_ADDRESS_TAIL_RE.split(cleaned, maxsplit=1)[0]
cleaned = cleaned.strip(" ,.\n\t")
return cleaned or None
def _extract_rooms_from_title(title: str) -> int | None:
if _RE_STUDIO.search(title):
return 0
m = _RE_ROOMS.search(title)
if m:
return int(m.group(1))
return None
def _extract_area_from_title(title: str) -> float | None:
m = _RE_AREA.search(title)
if m:
return float(m.group(1).replace(",", "."))
return None
def _extract_floor_from_title(title: str) -> tuple[int | None, int | None]:
m = _RE_FLOOR.search(title)
if m:
return int(m.group(1)), int(m.group(2))
return None, None