425 lines
18 KiB
Python
425 lines
18 KiB
Python
"""Avito.ru scraper — парсинг вторички вокруг точки.
|
||
|
||
Стратегия: HTML scrape карточек объявлений через DOM.
|
||
|
||
URL pattern (EKB):
|
||
https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ
|
||
?geoCoords=56.838,60.605
|
||
&radius=1 # в км
|
||
&s=104 # sort by date
|
||
&p=1 # страница
|
||
|
||
ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP.
|
||
Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from datetime import date, timedelta
|
||
from typing import Any
|
||
from urllib.parse import urlencode, urljoin
|
||
|
||
from curl_cffi.requests import AsyncSession
|
||
from selectolax.parser import HTMLParser
|
||
|
||
from app.services.scraper_settings import get_scraper_delay
|
||
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||
|
||
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
|
||
|
||
|
||
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
|
||
|
||
async def __aenter__(self) -> AvitoScraper:
|
||
await super().__aenter__()
|
||
self._cffi = AsyncSession(
|
||
impersonate="chrome120",
|
||
timeout=25,
|
||
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._cffi is not None:
|
||
await self._cffi.close()
|
||
await super().__aexit__(*args)
|
||
|
||
# ── 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:
|
||
assert self._cffi is not None
|
||
response = await self._cffi.get(url)
|
||
if response.status_code == 403:
|
||
logger.error(
|
||
"avito SERP HTTP 403 (IP blocked) page=%d url=%s", page, url
|
||
)
|
||
raise AvitoBlockedError(
|
||
f"Avito SERP returned 403 — IP blocked at page={page}"
|
||
)
|
||
if response.status_code == 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}"
|
||
)
|
||
if response.status_code != 200:
|
||
logger.warning(
|
||
"avito HTML page=%d returned %d for %s", page, response.status_code, url
|
||
)
|
||
break
|
||
except (AvitoBlockedError, AvitoRateLimitedError):
|
||
raise
|
||
except Exception:
|
||
logger.exception("avito HTML page=%d fetch failed for %s", page, url)
|
||
break
|
||
|
||
lots = self._parse_html(response.text, source_url_base=url)
|
||
if not lots:
|
||
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 _parse_html(self, html: str, source_url_base: str) -> list[ScrapedLot]:
|
||
"""Парсим карточки объявлений из HTML через DOM scrape.
|
||
|
||
Avito state давно пустой — используем только DOM путь.
|
||
"""
|
||
tree = HTMLParser(html)
|
||
cards = tree.css('[data-marker="item"]')
|
||
lots: list[ScrapedLot] = []
|
||
for card in cards:
|
||
lot = self._dom_card_to_lot(card, source_url_base)
|
||
if lot is not None:
|
||
lots.append(lot)
|
||
return lots
|
||
|
||
def _dom_card_to_lot(self, card: Any, source_url_base: str) -> 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
|
||
|
||
# Дата публикации: data-marker="item-date" / data-marker="item-date-info"
|
||
listing_date: date | None = 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:
|
||
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)
|
||
|
||
_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
|