diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 831623a7..e97f4e5c 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -9,6 +9,8 @@ Sources: - avito_city_sweep → run_avito_city_sweep (scrape_pipeline.py) + - avito_newbuilding_sweep → run_avito_newbuilding_sweep (scrape_pipeline.py; dedicated + novostroyka-filtered citywide SERP → save, 100% new-build cards) - yandex_city_sweep → run_yandex_city_sweep (scrape_pipeline.py, #561; shipped DORMANT) - cian_history_backfill → run_cian_backfill (this module, #560) - rosreestr_dkp_import → import_rosreestr_dkp (this module, #563) @@ -85,6 +87,7 @@ from app.core.db import SessionLocal from app.services import scrape_runs as runs_mod from app.services.scrape_pipeline import ( run_avito_city_sweep, + run_avito_newbuilding_sweep, run_cian_city_sweep, run_domclick_city_sweep, run_yandex_city_sweep, @@ -299,6 +302,44 @@ async def trigger_avito_city_sweep_run(db: Session, schedule_row: dict[str, Any] return run_id +async def trigger_avito_newbuilding_sweep_run( + db: Session, schedule_row: dict[str, Any] +) -> int | None: + """Создать новый scrape_runs + launch run_avito_newbuilding_sweep в asyncio.create_task. + + Зеркало trigger_avito_city_sweep_run, но citywide novostroyka-обход (без anchor/ + houses/detail-параметров): dedicated novostroyka-SERP → save. + + Returns run_id (или None если skip — есть running run). + """ + run_id = _claim_run(db, schedule_row) + if run_id is None: + return None + + params = schedule_row.get("default_params") or {} + + # Spawn asyncio task — pass NEW session (avoid sharing with this scheduler tick) + async def _run() -> None: + run_db = SessionLocal() + try: + await run_avito_newbuilding_sweep( + run_db, + run_id=run_id, + pages=int(params.get("pages", 20)), + request_delay_sec=float(params.get("request_delay_sec", 7.0)), + ) + except Exception: + logger.exception("scheduler: run_avito_newbuilding_sweep crashed run_id=%d", run_id) + finally: + run_db.close() + + task = asyncio.create_task(_run()) + # Keep reference to avoid GC before task completes (RUF006) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) + logger.info("scheduler: triggered newbuilding_sweep run_id=%d", run_id) + return run_id + + async def trigger_yandex_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None: """Создать новый scrape_runs + launch run_yandex_city_sweep в asyncio.create_task. @@ -1350,6 +1391,8 @@ async def scheduler_loop() -> None: source = sch["source"] if source == "avito_city_sweep": await trigger_avito_city_sweep_run(db, sch) + elif source == "avito_newbuilding_sweep": + await trigger_avito_newbuilding_sweep_run(db, sch) elif source == "yandex_city_sweep": await trigger_yandex_city_sweep_run(db, sch) elif source == "cian_history_backfill": diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py index c8b2531d..29c5f99a 100644 --- a/tradein-mvp/backend/app/services/scrape_pipeline.py +++ b/tradein-mvp/backend/app/services/scrape_pipeline.py @@ -990,6 +990,114 @@ async def run_avito_city_sweep( raise +# ── Avito newbuilding (novostroyka) citywide sweep ──────────────── + + +@dataclass +class NewbuildingSweepCounters: + """Aggregate counters для Avito novostroyka citywide sweep run.""" + + lots_fetched: int = 0 + lots_inserted: int = 0 + lots_updated: int = 0 + errors_count: int = 0 + + def to_dict(self) -> dict[str, int]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +async def run_avito_newbuilding_sweep( + db: Session, + *, + run_id: int, + pages: int = 20, + request_delay_sec: float = 7.0, +) -> NewbuildingSweepCounters: + """Citywide-обход ЕКБ-выборки только новостроек (novostroyka-filter) → save. + + В отличие от run_avito_city_sweep (anchor-based fetch_around + houses/detail/IMV), + это простой citywide paginated-обход через AvitoScraper.fetch_newbuildings: + dedicated novostroyka-SERP отдаёт 100% new-build карточки (listing_segment= + "novostroyki", newbuilding_id/newbuilding_url заполнены парсером). Сохраняем через + стандартный save_listings, ведём SERP-счётчики через scrape_runs. + + - Single shared AsyncSession/BrowserFetcher на весь sweep (один TLS fingerprint) + - AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned') + - is_cancelled-чек до старта SERP-фазы + """ + counters = NewbuildingSweepCounters() + + browser_mode = settings.scraper_fetch_mode == "browser" + async with AsyncExitStack() as stack: + session: AsyncSession | None = None + shared_bf: BrowserFetcher | None = None + if browser_mode: + shared_bf = await stack.enter_async_context(BrowserFetcher(source="avito")) + else: + session = await stack.enter_async_context( + AsyncSession( + impersonate="chrome120", + timeout=25, + headers=_CHROME_HEADERS, + proxies=_avito_proxies(), + ) + ) + try: + if scrape_runs.is_cancelled(db, run_id): + logger.info("nb-sweep run_id=%d: cancelled before SERP phase", run_id) + scrape_runs.mark_done(db, run_id, counters.to_dict()) + return counters + + scraper = AvitoScraper() + if browser_mode: + scraper._browser = shared_bf + else: + scraper._cffi = session + + try: + lots: list[ScrapedLot] = await scraper.fetch_newbuildings( + pages=pages, + delay_override_sec=request_delay_sec, + ) + except (AvitoBlockedError, AvitoRateLimitedError) as e: + logger.error("nb-sweep run_id=%d: SERP BLOCKED — %s", run_id, e) + scrape_runs.mark_banned(db, run_id, str(e), counters.to_dict()) + return counters + + counters.lots_fetched += len(lots) + if lots: + try: + ins, upd = save_listings(db, lots) + counters.lots_inserted += ins + counters.lots_updated += upd + except Exception as save_exc: + logger.exception( + "nb-sweep run_id=%d: save_listings failed: %s", run_id, save_exc + ) + counters.errors_count += 1 + try: + db.rollback() + except Exception: + pass + + scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) + scrape_runs.mark_done(db, run_id, counters.to_dict()) + logger.info( + "nb-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) errors=%d", + run_id, + counters.lots_fetched, + counters.lots_inserted, + counters.lots_updated, + counters.errors_count, + ) + return counters + + except Exception as exc: + logger.exception("nb-sweep run_id=%d: fatal error", run_id) + scrape_runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + raise + + # ── Yandex city sweep ─────────────────────────────────────────── @@ -2266,8 +2374,7 @@ async def run_domclick_city_sweep( return counters logger.info( - "domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d " - "(watchdog %ds)", + "domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d (watchdog %ds)", run_id, city_id, _rooms, @@ -2333,11 +2440,13 @@ __all__ = [ "CianFullLoadCounters", "CitySweepCounters", "DomClickCitySweepCounters", + "NewbuildingSweepCounters", "PipelineCounters", "PipelineResult", "YandexCitySweepCounters", "YandexFullLoadCounters", "run_avito_city_sweep", + "run_avito_newbuilding_sweep", "run_avito_pipeline", "run_cian_city_sweep", "run_cian_full_load", diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index d5002826..d2304fb7 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio import logging import re +from collections.abc import Callable from datetime import date, datetime, timedelta, timezone from typing import Any from urllib.parse import urlencode, urljoin, urlparse, urlunparse @@ -216,6 +217,12 @@ def _build_sort_timestamp_map(html: str) -> dict[str, date]: # маркерам в начале документа (firewall-страница объёмна, не сканируем целиком). _FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip", "firewall-container") +# EKB-фильтр «Новостройка» (slug категории kvartiry/prodam). Кодирует +# ASgB-параметры выборки только новостроек — SERP возвращает 100% new-build +# карточки (с data-marker="item-development-name"). Извлечён из реального +# search-URL: /ekaterinburg/kvartiry/prodam/novostroyka-ASgBAgICAkSSA8YQ5geOUg +NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg" + def _is_firewall_page(html: str) -> bool: """True если Avito вернул firewall-страницу IP-блока (на HTTP 200).""" @@ -500,6 +507,16 @@ class AvitoScraper(BaseScraper): params = {"s": 104, "p": page} return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}" + def _build_newbuilding_url(self, page: int = 1) -> str: + """URL ЕКБ-выборки только новостроек (novostroyka-filter), сортировка по дате. + + Тот же shape параметров, что _build_citywide_url (s=104, p=page), но slug + категории NOVOSTROYKA_SLUG отдаёт исключительно new-build карточки. + """ + params = {"s": 104, "p": page} + path = f"/ekaterinburg/kvartiry/prodam/{NOVOSTROYKA_SLUG}" + return f"{self.base_url}{path}?{urlencode(params)}" + def _build_rooms_url(self, room_slug: str, page: int = 1) -> str: """T6: URL с фильтром по комнатности для всего ЕКБ (no geo). @@ -532,6 +549,30 @@ class AvitoScraper(BaseScraper): Returns: Список ScrapedLot (все страницы, дедуп по source_id). """ + all_lots = await self._paginate_sweep( + pages, + self._build_citywide_url, + label="citywide", + delay_override_sec=delay_override_sec, + ) + logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots)) + return all_lots + + async def _paginate_sweep( + self, + pages: int, + url_builder: Callable[[int], str], + *, + label: str, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """Общий paginated-обход ЕКБ (citywide / novostroyka) с break-on-empty. + + url_builder(page) — функция построения URL страницы (citywide или + novostroyka). Сохраняет anti-block pipeline (_fetch_serp_html: firewall, + IP rotation, ретраи), дедуп по source_id, break-on-empty. label — только + для логов. Не меняет наблюдаемое поведение fetch_city_wide. + """ if delay_override_sec is not None: self.request_delay_sec = delay_override_sec @@ -539,21 +580,21 @@ class AvitoScraper(BaseScraper): seen_ids: set[str] = set() for page in range(1, pages + 1): - url = self._build_citywide_url(page) + url = url_builder(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) + logger.exception("avito %s page=%d fetch failed url=%s", label, page, url) break if html is None: - logger.info("avito citywide page=%d: non-200 — end of pagination", page) + logger.info("avito %s page=%d: non-200 — end of pagination", label, 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) + logger.info("avito %s page=%d: 0 lots — end of pagination", label, page) break new_lots = [lot for lot in lots if lot.source_id not in seen_ids] @@ -562,7 +603,8 @@ class AvitoScraper(BaseScraper): 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)", + "avito %s page=%d: %d lots (%d new, %d dedup'd, total=%d)", + label, page, len(lots), len(new_lots), @@ -571,8 +613,37 @@ class AvitoScraper(BaseScraper): ) if page < pages: await self.sleep_between_requests() + return all_lots - logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots)) + async def fetch_newbuildings( + self, + pages: int = 30, + *, + delay_override_sec: float | None = None, + ) -> list[ScrapedLot]: + """Обход ЕКБ-выборки только новостроек (novostroyka-filter), paginated. + + Avito отдаёт dedicated SERP с фильтром «Новостройка» — 100% new-build + карточек (с маркером застройщика → listing_segment="novostroyki", + newbuilding_id/newbuilding_url заполнены). Citywide (без geo/anchor). + Возвращает дедуплицированный список (по source_id), break-on-empty. + Сохраняет весь anti-block pipeline. Не трогает fetch_city_wide/fetch_around. + + Args: + pages: максимальное число страниц (default 30). + delay_override_sec: если задан — переопределяет request_delay_sec для + этого вызова. + + Returns: + Список ScrapedLot новостроек (все страницы, дедуп по source_id). + """ + all_lots = await self._paginate_sweep( + pages, + self._build_newbuilding_url, + label="newbuilding", + delay_override_sec=delay_override_sec, + ) + logger.info("avito fetch_newbuildings pages=%d total_lots=%d", pages, len(all_lots)) return all_lots async def fetch_by_rooms( diff --git a/tradein-mvp/backend/data/sql/123_avito_newbuilding_sweep_schedule.sql b/tradein-mvp/backend/data/sql/123_avito_newbuilding_sweep_schedule.sql new file mode 100644 index 00000000..de012f0c --- /dev/null +++ b/tradein-mvp/backend/data/sql/123_avito_newbuilding_sweep_schedule.sql @@ -0,0 +1,41 @@ +-- 123_avito_newbuilding_sweep_schedule.sql +-- Seed row для Avito newbuilding (novostroyka) citywide sweep. +-- +-- Avito отдаёт dedicated novostroyka-filtered SERP (slug +-- novostroyka-ASgBAgICAkSSA8YQ5geOUg) — 100% new-build карточки с маркером +-- застройщика (listing_segment='novostroyki', newbuilding_id заполнен парсером). +-- Текущий general sweep ловит новостройки лишь инцидентно (~20 строк); этот +-- dedicated обход даёт сотни. Citywide (без anchor/geo), save через стандартный путь. +-- +-- default_params: +-- pages — макс. число страниц SERP за прогон. +-- request_delay_sec — пауза между страницами (anti-block). +-- +-- next_run_at = завтра 02:00 UTC — чтобы НЕ выстрелить мгновенно на деплое (enabled +-- row с NULL next_run_at трактуется due немедленно); первый прогон в окне 02:00-05:00. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно. + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'avito_newbuilding_sweep', + true, + 2, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages": 20, "request_delay_sec": 8}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMIT; diff --git a/tradein-mvp/backend/tests/fixtures/avito_serp_novostroyka.html b/tradein-mvp/backend/tests/fixtures/avito_serp_novostroyka.html new file mode 100644 index 00000000..3d79c627 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/avito_serp_novostroyka.html @@ -0,0 +1,35 @@ + + +
ул. Меридиан, 1
2 часа назад
+ул. Северная, 7
3 часа назад
+