"""Yandex.Недвижимость ЕКБ vtorichka sweep — runs locally через residential IP. Why local: Yandex anti-bot (captcha redirect) включается быстрее на datacenter IP + httpx fingerprint. Запуск с residential IP + Chrome120 TLS impersonation (curl_cffi) + browser session cookies — проходит. Скрипт пишет в prod tradein DB через SSH tunnel (localhost:15433 → tradein-postgres docker container). Modes: --mode citywide (default): один общий /vtorichniy-rynok/ sweep, ~25 страниц × 23 cards = ~575 уникальных. Yandex SERP лимит. --mode combos: итерация 5 rooms × 6 price ranges = 30 combos × до 25 pages. DB-level дедуп → coverage близко к total (~4000+ vtorichka ЕКБ). ~75 min полный прогон. Setup: ssh -f -N -L 15433:172.20.0.2:5432 gendesign # tradein-postgres tunnel cd tradein-mvp/backend export DATABASE_URL='postgresql+psycopg://tradein:@localhost:15433/tradein' export ENVIRONMENT='local' export GLITCHTIP_DSN='' Usage: uv run python ../scripts/local-sweep-ekb-yandex.py # citywide uv run python ../scripts/local-sweep-ekb-yandex.py --mode combos # full ~4000 uv run python ../scripts/local-sweep-ekb-yandex.py --mode combos \\ --rooms 1,2 --price-ranges 5-7,7-10 # subset uv run python ../scripts/local-sweep-ekb-yandex.py --pages 1 # smoke """ from __future__ import annotations # stdlib import argparse import asyncio import json import random import sys import time import types from pathlib import Path from urllib.parse import urlencode # Auto-inject backend/ в sys.path чтобы найти app.* package _BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend" if str(_BACKEND_DIR) not in sys.path: sys.path.insert(0, str(_BACKEND_DIR)) # Stub weasyprint (Windows без GTK ломает import chain через trade_in.py) for _m in ( "weasyprint", "weasyprint.text", "weasyprint.text.ffi", "weasyprint.css", "weasyprint.css.targets", ): sys.modules[_m] = types.ModuleType(_m) sys.modules["weasyprint"].HTML = type("HTML", (), {"__init__": lambda self, *a, **kw: None}) sys.modules["weasyprint"].CSS = type("CSS", (), {"__init__": lambda self, *a, **kw: None}) # app imports после stubs from curl_cffi.requests import AsyncSession # noqa: E402 from app.core.db import SessionLocal # noqa: E402 from app.services.scrapers.base import save_listings # noqa: E402 from app.services.scrapers.yandex_realty import YandexRealtyScraper # noqa: E402 DEFAULT_CITY = "ekaterinburg" DEFAULT_COOKIES_FILE = Path(__file__).resolve().parent / ".yandex-cookies.json" # Path-сегменты Yandex для room buckets (после канонизации). Ключ — slug для # --rooms CLI; значение — segment в URL. ROOM_PATH: dict[str, str] = { "studio": "studiya", "1": "odnokomnatnaya", "2": "dvuhkomnatnaya", "3": "trehkomnatnaya", "4+": "4-i-bolee", } # Price ranges (price_min, price_max) в рублях. None — open-ended boundary. PRICE_RANGES: list[tuple[int | None, int | None]] = [ (None, 5_000_000), (5_000_000, 7_000_000), (7_000_000, 10_000_000), (10_000_000, 15_000_000), (15_000_000, 25_000_000), (25_000_000, None), ] _CAPTCHA_MARKERS = ("/showcaptcha", "smartcaptcha", "checkboxcaptcha") def _load_cookies(path: Path) -> dict[str, str]: if not path.exists(): return {} raw = json.loads(path.read_text(encoding="utf-8")) return {item["name"]: item["value"] for item in raw if "name" in item and "value" in item} def _is_captcha(html: str) -> bool: head = html[:5000].lower() return any(m in head for m in _CAPTCHA_MARKERS) or "докажите, что вы не робот" in head def _build_url( city: str, page: int = 0, rooms: str | None = None, price_min: int | None = None, price_max: int | None = None, ) -> str: """Yandex SERP URL. Если rooms задан — path-based бакет; иначе общий vtorichka. newFlat=NO_DEAL принудительно отфильтровывает новостройки (нужно при combo, т.к. Yandex canonicalize иногда дропает /vtorichniy-rynok/). """ if rooms: path = f"/{city}/kupit/kvartira/{ROOM_PATH[rooms]}/vtorichniy-rynok/" else: path = f"/{city}/kupit/kvartira/vtorichniy-rynok/" base = f"https://realty.yandex.ru{path}" params: dict[str, str | int] = {} if price_min is not None: params["priceMin"] = price_min if price_max is not None: params["priceMax"] = price_max if price_min is not None or price_max is not None: # Гарантия vtorichka — Yandex иногда дропает /vtorichniy-rynok/ при canonicalize params["newFlat"] = "NO_DEAL" if page > 0: params["page"] = page if params: return f"{base}?{urlencode(params)}" return base def _combo_label(rooms: str | None, price_min: int | None, price_max: int | None) -> str: r = rooms or "all-rooms" if price_min is None and price_max is None: return f"{r}/any-price" lo = f"{price_min // 1_000_000}M" if price_min else "0" hi = f"{price_max // 1_000_000}M" if price_max else "inf" return f"{r}/{lo}-{hi}" async def sweep_one( session: AsyncSession, parser: YandexRealtyScraper, city: str, pages: int, delay: float, rooms: str | None = None, price_min: int | None = None, price_max: int | None = None, ) -> tuple[int, int, int]: """Один scan по фиксированному (rooms, price) — пагинация до pages или empty. Returns: (fetched, inserted, updated) """ label = _combo_label(rooms, price_min, price_max) total = cum_ins = cum_upd = 0 for page in range(pages): url = _build_url(city, page=page, rooms=rooms, price_min=price_min, price_max=price_max) try: resp = await session.get(url, timeout=20) except Exception as e: # noqa: BLE001 print(f" [{label}] page={page} FAIL fetch: {type(e).__name__}: {e}", flush=True) await asyncio.sleep(delay) continue if resp.status_code != 200: print(f" [{label}] page={page} HTTP {resp.status_code} — stop combo", flush=True) break if _is_captcha(resp.text): print( f" [{label}] page={page} ⚠️ CAPTCHA — stop combo. " f"Подожди 30-60min или refresh .yandex-cookies.json", flush=True, ) raise RuntimeError("captcha") # NBSP → space: yandex_helpers.parse_rub() багует на \xa0 в group(1) → price None html = resp.text.replace("\xa0", " ") lots = parser._parse_html(html, page=page) if not lots: print(f" [{label}] page={page}: 0 lots — end of pagination, stop", flush=True) break db = SessionLocal() try: ins, upd = save_listings(db, lots) cum_ins += ins cum_upd += upd total += len(lots) print( f" [{label}] page={page + 1}/{pages}: {len(lots)} lots → " f"ins={ins} upd={upd} [combo: {total}/{cum_ins}/{cum_upd}]", flush=True, ) except Exception as e: # noqa: BLE001 print(f" [{label}] page={page} FAIL save: {type(e).__name__}: {e}", flush=True) finally: db.close() if page + 1 < pages: await asyncio.sleep(delay * random.uniform(0.85, 1.3)) return total, cum_ins, cum_upd async def phase_citywide(pages: int, delay: float, city: str, cookies: dict[str, str]) -> None: print( f"\n=== PHASE CITYWIDE: Yandex vtorichka (city={city}, {pages} pages, " f"delay {delay}s) ===\n", flush=True, ) parser = YandexRealtyScraper(city=city) async with AsyncSession(impersonate="chrome120", cookies=cookies or {}) as session: total, ins, upd = await sweep_one(session, parser, city, pages, delay) print(f" CITYWIDE TOTAL: fetched={total} ins={ins} upd={upd}", flush=True) async def phase_combos( pages: int, delay: float, city: str, cookies: dict[str, str], rooms_list: list[str], price_ranges: list[tuple[int | None, int | None]], combo_delay: float, ) -> None: combos = [(r, lo, hi) for r in rooms_list for lo, hi in price_ranges] print( f"\n=== PHASE COMBOS: {len(combos)} combos × ≤{pages} pages " f"(city={city}, delay {delay}s, combo-delay {combo_delay}s) ===", flush=True, ) parser = YandexRealtyScraper(city=city) grand_fetched = grand_ins = grand_upd = 0 async with AsyncSession(impersonate="chrome120", cookies=cookies or {}) as session: for i, (rooms, lo, hi) in enumerate(combos, 1): label = _combo_label(rooms, lo, hi) print(f"\n [{i}/{len(combos)}] combo={label}", flush=True) try: f, ins, upd = await sweep_one( session, parser, city, pages, delay, rooms=rooms, price_min=lo, price_max=hi, ) except RuntimeError as e: if str(e) == "captcha": print(" ⚠️ stopping combo run — captcha", flush=True) break raise grand_fetched += f grand_ins += ins grand_upd += upd print( f" [{i}/{len(combos)}] {label} done: f={f} i={ins} u={upd} " f"[grand: f={grand_fetched} i={grand_ins} u={grand_upd}]", flush=True, ) if i < len(combos): await asyncio.sleep(combo_delay * random.uniform(0.85, 1.3)) print( f"\n COMBOS GRAND TOTAL: fetched={grand_fetched} " f"ins={grand_ins} upd={grand_upd}", flush=True, ) def _parse_rooms_arg(s: str) -> list[str]: if s == "all": return list(ROOM_PATH) out: list[str] = [] for tok in s.split(","): tok = tok.strip() if tok not in ROOM_PATH: raise argparse.ArgumentTypeError( f"unknown room '{tok}'. Allowed: {sorted(ROOM_PATH)} or 'all'" ) out.append(tok) return out def _parse_price_ranges_arg(s: str) -> list[tuple[int | None, int | None]]: if s == "all": return list(PRICE_RANGES) out: list[tuple[int | None, int | None]] = [] for tok in s.split(","): tok = tok.strip() if "-" not in tok: raise argparse.ArgumentTypeError(f"range '{tok}' missing '-'") lo_s, hi_s = tok.split("-", 1) lo = int(float(lo_s) * 1_000_000) if lo_s else None hi = int(float(hi_s) * 1_000_000) if hi_s else None out.append((lo, hi)) return out async def main() -> None: p = argparse.ArgumentParser(description="Yandex.Недвижимость ЕКБ sweep") p.add_argument( "--mode", choices=["citywide", "combos"], default="citywide", help="citywide = один общий vtorichka sweep (~575 уникальных). " "combos = rooms × price = ~4000+", ) p.add_argument( "--pages", type=int, default=25, help="max pages per sweep (Yandex SERP cap ≈ 25; stops earlier on empty)", ) p.add_argument( "--delay", type=float, default=15, help="sec между HTTP requests внутри combo (default 15, residential-safe)", ) p.add_argument( "--combo-delay", type=float, default=20, help="sec между combos (default 20, дополнительный jitter combos mode)", ) p.add_argument( "--rooms", type=_parse_rooms_arg, default="all", help=f"combos: comma-list из {sorted(ROOM_PATH)} или 'all' (default)", ) p.add_argument( "--price-ranges", type=_parse_price_ranges_arg, default="all", help="combos: comma-list 'lo-hi' в млн руб ('-5,5-7,7-10,10-15,15-25,25-') " "или 'all' (default). Empty side = open-ended.", ) p.add_argument( "--city", type=str, default=DEFAULT_CITY, help=f"Yandex city slug (default '{DEFAULT_CITY}')", ) p.add_argument( "--cookies-file", type=Path, default=DEFAULT_COOKIES_FILE, help=f"Browser cookies JSON (default '{DEFAULT_COOKIES_FILE.name}'). " f"Empty/missing → start без cookies (риск captcha).", ) args = p.parse_args() cookies = _load_cookies(args.cookies_file) if cookies: print(f"loaded {len(cookies)} cookies from {args.cookies_file}", flush=True) else: print( f"⚠️ no cookies loaded ({args.cookies_file}, exists={args.cookies_file.exists()}). " f"Yandex anti-bot risk.", flush=True, ) t0 = time.perf_counter() if args.mode == "citywide": await phase_citywide(args.pages, args.delay, args.city, cookies) else: await phase_combos( args.pages, args.delay, args.city, cookies, rooms_list=args.rooms, price_ranges=args.price_ranges, combo_delay=args.combo_delay, ) elapsed = time.perf_counter() - t0 print(f"\n=== ALL DONE in {elapsed / 60:.1f} min ===", flush=True) if __name__ == "__main__": asyncio.run(main())