gendesign/tradein-mvp/backend/app/services/scrapers/avito.py
Light1YT f5c87201f1
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 49s
Deploy Trade-In / deploy (push) Successful in 36s
Deploy Trade-In / changes (push) Successful in 1m28s
Deploy Trade-In / build-frontend (push) Has been skipped
fix(tradein): Avito listing_date extraction (3 формата) (#601)
2026-05-27 12:55:27 +00:00

425 lines
18 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 — парсинг вторички вокруг точки.
Стратегия: 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