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 ──────────────────────────────────────────────────────────────── # ── Constants ────────────────────────────────────────────────────────────────
DEFAULT_RADIUS_M = 800 DEFAULT_RADIUS_M = 1000 # ПО ВСТРЕЧЕ ПТИЦЫ: «локация не дальше 800-1000 м»
FALLBACK_RADIUS_M = 2000 FALLBACK_RADIUS_M = 2000
AREA_TOLERANCE = 0.15 # ±15% площади AREA_TOLERANCE = 0.15 # ±15% площади
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней

View file

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

View file

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