feat(tradein): радиус 1000м + пагинация Циан/N1 #378

Merged
lekss361 merged 1 commit from feat/tradein-radius-pagination into main 2026-05-21 08:59:37 +00:00
3 changed files with 54 additions and 32 deletions

View file

@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────────────────
DEFAULT_RADIUS_M = 800
DEFAULT_RADIUS_M = 1000 # ПО ВСТРЕЧЕ ПТИЦЫ: «локация не дальше 800-1000 м»
FALLBACK_RADIUS_M = 2000
AREA_TOLERANCE = 0.15 # ±15% площади
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней

View file

@ -65,17 +65,19 @@ class CianScraper(BaseScraper):
await super().__aexit__(*args)
async def fetch_around(
self, lat: float, lon: float, radius_m: int = 1000, rooms: tuple[int, ...] | None = None
self, lat: float, lon: float, radius_m: int = 1000,
rooms: tuple[int, ...] | None = None, page: int = 1,
) -> list[ScrapedLot]:
"""Найти объявления Циан вокруг (lat, lon) в radius_m метрах.
rooms список из 1,2,3,4 (Cian's room codes). Если None — все.
page страница выдачи (Cian ~28 на страницу).
"""
self._anchor_lat = lat
self._anchor_lon = lon
self._anchor_radius_m = radius_m
url = self._build_url(lat, lon, radius_m, rooms)
url = self._build_url(lat, lon, radius_m, rooms, page)
try:
assert self._cffi is not None
response = await self._cffi.get(url)
@ -88,28 +90,34 @@ class CianScraper(BaseScraper):
lots = self._parse_html(response.text)
logger.info(
"cian: %d lots around (%.4f, %.4f) rooms=%s", len(lots), lat, lon, rooms
"cian: %d lots around (%.4f, %.4f) rooms=%s page=%d",
len(lots), lat, lon, rooms, page,
)
await self.sleep_between_requests()
return lots
async def fetch_around_multi_room(
self, lat: float, lon: float, radius_m: int = 1000
self, lat: float, lon: float, radius_m: int = 1000, pages: int = 3,
) -> list[ScrapedLot]:
"""Скрейп Циан отдельно по 1к/2к/3к/4+ — каждый запрос ~25 лотов, всего ~100.
Циан умеет несколько rooms[]=N сразу, но для расширения выборки делаем отдельно.
"""Скрейп Циан по 1к/2к/3к/4+ × N страниц — расширяет выборку.
4 комнаты × 3 страницы × ~28 = до ~330 лотов до дедупликации.
"""
seen: dict[str, ScrapedLot] = {}
for rooms in ((1,), (2,), (3,), (4,)):
try:
lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms)
except Exception:
logger.exception("cian multi-room fetch failed for rooms=%s", rooms)
continue
for lot in lots:
key = lot.source_id or lot.source_url
if key and key not in seen:
seen[key] = lot
for page in range(1, pages + 1):
try:
lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms, page=page)
except Exception:
logger.exception(
"cian multi-room fetch failed rooms=%s page=%d", rooms, page
)
continue
if not lots:
break # пустая страница → дальше нет смысла
for lot in lots:
key = lot.source_id or lot.source_url
if key and key not in seen:
seen[key] = lot
logger.info(
"cian multi-room: %d unique lots around (%.4f, %.4f)", len(seen), lat, lon
)
@ -121,6 +129,7 @@ class CianScraper(BaseScraper):
lon: float,
radius_m: int,
rooms: tuple[int, ...] | None = None,
page: int = 1,
) -> str:
"""URL для Cian каталога с регионом ЕКБ.
@ -138,6 +147,8 @@ class CianScraper(BaseScraper):
for r in rooms:
# Cian rooms[]=1 для 1к, rooms[]=2 для 2к, и т.д.
params.append((f"room{r}", "1"))
if page > 1:
params.append(("p", page))
return f"{self.base_url}/cat.php?{urlencode(params)}"
def _parse_html(self, html: str) -> list[ScrapedLot]:

View file

@ -63,13 +63,14 @@ class N1Scraper(BaseScraper):
await super().__aexit__(*args)
async def fetch_around(
self, lat: float, lon: float, radius_m: int = 1000, rooms: int | None = None,
self, lat: float, lon: float, radius_m: int = 1000,
rooms: int | None = None, page: int = 1,
) -> list[ScrapedLot]:
self._anchor_lat = lat
self._anchor_lon = lon
self._anchor_radius_m = radius_m
url = self._build_url(rooms)
url = self._build_url(rooms, page)
try:
assert self._cffi is not None
response = await self._cffi.get(url)
@ -82,35 +83,42 @@ class N1Scraper(BaseScraper):
lots = self._parse_html(response.text)
logger.info(
"n1: %d lots around (%.4f, %.4f) rooms=%s", len(lots), lat, lon, rooms
"n1: %d lots around (%.4f, %.4f) rooms=%s page=%d",
len(lots), lat, lon, rooms, page,
)
await self.sleep_between_requests()
return lots
async def fetch_around_multi_room(
self, lat: float, lon: float, radius_m: int = 1000
self, lat: float, lon: float, radius_m: int = 1000, pages: int = 3,
) -> list[ScrapedLot]:
"""Скрейп N1 по сегментам комнат 1/2/3/4 — расширяет выборку."""
"""Скрейп N1 по сегментам комнат 1/2/3/4 × N страниц — расширяет выборку."""
seen: dict[str, ScrapedLot] = {}
for rooms in (1, 2, 3, 4):
try:
lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms)
except Exception:
logger.exception("n1 multi-room fetch failed for rooms=%s", rooms)
continue
for lot in lots:
key = lot.source_id or lot.source_url
if key and key not in seen:
seen[key] = lot
for page in range(1, pages + 1):
try:
lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms, page=page)
except Exception:
logger.exception(
"n1 multi-room fetch failed rooms=%s page=%d", rooms, page
)
continue
if not lots:
break # пустая страница → дальше нет смысла
for lot in lots:
key = lot.source_id or lot.source_url
if key and key not in seen:
seen[key] = lot
logger.info(
"n1 multi-room: %d unique lots around (%.4f, %.4f)", len(seen), lat, lon
)
return list(seen.values())
def _build_url(self, rooms: int | None = None) -> str:
def _build_url(self, rooms: int | None = None, page: int = 1) -> str:
"""URL каталога вторички ЕКБ.
N1 фильтр комнат: /kupit/kvartiry/vtorichka/1-komnatnye/ итд.
Пагинация: ?page=N.
"""
rooms_segment = {
1: "1-komnatnye/",
@ -118,7 +126,10 @@ class N1Scraper(BaseScraper):
3: "3-komnatnye/",
4: "4-komnatnye/",
}.get(rooms or 0, "")
return f"{self.base_url}/kupit/kvartiry/vtorichka/{rooms_segment}"
url = f"{self.base_url}/kupit/kvartiry/vtorichka/{rooms_segment}"
if page > 1:
url += f"?page={page}"
return url
def _parse_html(self, html: str) -> list[ScrapedLot]:
tree = HTMLParser(html)