"""Cian ЕКБ vtorichka sweep — local residential IP → prod tradein DB через SSH tunnel. Why: ekb.cian.ru SERP отдаёт ВЕСЬ город (без geo-фильтра по lat/lon), поэтому "10 anchors" из Avito-эталона тут бесполезны — одинаковый ответ × 10. Вместо anchors — proper room-segmented citywide pagination (rooms 1/2/3/4 × N pages × ~28 listings), per-page save (visible progress + не теряем данные при крэше). Cian реально менее агрессивен чем Avito, но **residential IP всё равно может попасть в blacklist при слишком частых запросах** — default delay 15s. Setup (один раз): ssh -f -N -L 15433:172.20.0.2:5432 gendesign cd tradein-mvp/backend $env:DATABASE_URL='postgresql+psycopg://tradein:@localhost:15433/tradein' $env:ENVIRONMENT='local'; $env:GLITCHTIP_DSN='' Usage: uv run python ../scripts/local-sweep-ekb-cian.py # default: 4 rooms × 8 pages uv run python ../scripts/local-sweep-ekb-cian.py --pages 20 # больше глубины uv run python ../scripts/local-sweep-ekb-cian.py --delay 10 # быстрее (risk) uv run python ../scripts/local-sweep-ekb-cian.py --rooms 0 # всё (включая студии — no room param) """ from __future__ import annotations # stdlib import argparse import asyncio import json import sys import time import types from pathlib import Path # 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 import logging # noqa: E402 import re # noqa: E402 from app.core.db import SessionLocal # noqa: E402 from app.services.scrapers.base import ScrapedLot, save_listings # noqa: E402 from app.services.scrapers.cian import CianScraper # noqa: E402 # Шумный WARNING из upstream cian_state_parser ('state extraction failed') — у нас # fallback на новый .concat() markup отрабатывает, warning нерелевантен. logging.getLogger("app.services.scrapers.cian").setLevel(logging.ERROR) # ── New-markup fallback parser ─────────────────────────────────────────────── # Cian переключил MFE bootstrap с .push({...}) на .concat([{...},{...}]): # window._cianConfig['frontend-serp'] = (window._cianConfig['frontend-serp'] || []).concat([…]) # Существующий cian_state_parser.py не ловит этот шаблон → 0 lots на свежей выдаче. # Чтобы не трогать shared scraper module (правилами запрещено), парсим тут. _RE_CONCAT_START = re.compile( r"window\._cianConfig\['(?P[\w-]+)'\]\s*=\s*" r"\(window\._cianConfig\['[\w-]+'\]\s*\|\|\s*\[\]\)\.concat\(\[" ) def _extract_balanced_array(text: str, start: int) -> str | None: """Из позиции `start` (где '[') вернуть текст массива включая внешние '[' и ']'. JSON-aware: учитывает strings и escapes, чтобы не сбить счётчик '[' ']' на скобках внутри string literals. """ if text[start] != "[": return None depth = 0 i = start in_str = False esc = False while i < len(text): ch = text[i] if in_str: if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False else: if ch == '"': in_str = True elif ch == "[": depth += 1 elif ch == "]": depth -= 1 if depth == 0: return text[start : i + 1] i += 1 return None def _parse_serp_v2(scraper: CianScraper, html: str) -> list[ScrapedLot]: """Fallback парсер для нового шаблона .concat([...]) — ekb.cian.ru/cat.php c 2026-05.""" state: dict | None = None for m in _RE_CONCAT_START.finditer(html): if m.group("mfe") != "frontend-serp": continue # Позиция '[' — это последний символ матча минус 1 (regex кончается на '[') arr_start = m.end() - 1 arr_str = _extract_balanced_array(html, arr_start) if arr_str is None: continue try: entries = json.loads(arr_str) except json.JSONDecodeError: continue for entry in entries: if ( isinstance(entry, dict) and entry.get("key") == "initialState" and isinstance(entry.get("value"), dict) ): state = entry["value"] break if state is not None: break if state is None: return [] offers = (state.get("results") or {}).get("offers") or [] lots: list[ScrapedLot] = [] for offer in offers: # Cian свежий markup: descriptionMinhash = list[int] (multi-hash signatures). # ScrapedLot ожидает str → сбрасываем чтобы _offer_to_lot вычислил SHA1 от description. if isinstance(offer.get("descriptionMinhash"), list): offer["descriptionMinhash"] = None lot = scraper._offer_to_lot(offer) if lot is not None: lots.append(lot) return lots def _load_cookies_file(path: Path) -> dict[str, str]: """Загрузить cookies из JSON-файла. Поддерживает два формата: - browser export (list[{name,value,...}]) — DevTools / EditThisCookie / Cookie-Editor - plain dict {name: value} """ raw = json.loads(path.read_text(encoding="utf-8")) if isinstance(raw, list): return {c["name"]: c["value"] for c in raw if c.get("name") and c.get("value")} if isinstance(raw, dict): return {str(k): str(v) for k, v in raw.items()} raise SystemExit(f"--cookies-file: unknown JSON shape (got {type(raw).__name__})") async def phase_citywide( pages: int, delay: float, room_segments: tuple[tuple[int, ...] | None, ...], cookies_file: Path | None = None, start_page: int = 1, ) -> int: """Room-segmented citywide pagination — per-page save, incremental. room_segments — кортеж из tuple (1,) / (2,) / ... / None (= no room filter). None segment даёт смешанную выдачу (студии + всё подряд) — Cian не различает их без room param. """ seg_labels = [ ("r" + str(seg[0]) if seg else "all") for seg in room_segments ] print( f"\n=== PHASE CITYWIDE: Cian ЕКБ vtorichka " f"({len(room_segments)} segments={seg_labels} × {pages} pages, delay {delay}s) ===\n", flush=True, ) total = 0 cum_ins = 0 cum_upd = 0 async with CianScraper() as s: # Override per-request delay (in addition to between-anchors delay) s.request_delay_sec = delay # Inject cookies (если есть): прокидываем в curl_cffi session перед warm-up if cookies_file is not None: cookies = _load_cookies_file(cookies_file) assert s._cffi is not None s._cffi.cookies.update(cookies) print(f" loaded {len(cookies)} cookies from {cookies_file.name}", flush=True) # Warm-up: посетить homepage чтобы получить _CIAN_GK / _yasc + не выглядеть как наглый бот try: assert s._cffi is not None r_warm = await s._cffi.get(f"{s.base_url}/") print( f" warmup GET / → HTTP {r_warm.status_code} " f"(cookies={len(list(s._cffi.cookies.jar))})", flush=True, ) # Subsequent SERP requests should look like in-site navigation s._cffi.headers.update( { "Referer": f"{s.base_url}/", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-User": "?1", "Sec-Fetch-Dest": "document", } ) await asyncio.sleep(min(5.0, delay)) except Exception as e: # noqa: BLE001 print(f" warmup FAIL: {type(e).__name__}: {e}", flush=True) for seg_idx, seg in enumerate(room_segments): seg_label = "r" + str(seg[0]) if seg else "all" # --start-page применяется только к первому segment'у — продолжение прерванного sweep page_from = start_page if seg_idx == 0 else 1 for page in range(page_from, pages + 1): # Manual fetch+parse чтобы можно было дампнуть HTML при empty results try: url_ = s._build_url(rooms=seg, page=page) assert s._cffi is not None resp = await s._cffi.get(url_) if resp.status_code != 200: print( f" seg={seg_label} page={page} HTTP {resp.status_code} → skip", flush=True, ) await asyncio.sleep(delay) continue lots = s._parse_serp_html(resp.text) if not lots: # Fallback: новый markup .concat([...]) lots = _parse_serp_v2(s, resp.text) if lots: print( f" (parsed via v2 .concat fallback)", flush=True, ) if not lots: # Дамп для разбора: captcha vs пустая страница vs изменение markup dump_path = Path(f"cian-empty-seg-{seg_label}-p{page}.html") dump_path.write_text(resp.text, encoding="utf-8") print(f" DUMP: {dump_path} ({len(resp.text)} bytes)", flush=True) await s.sleep_between_requests() except Exception as e: # noqa: BLE001 print( f" seg={seg_label} page={page} FAIL fetch: " f"{type(e).__name__}: {e}", flush=True, ) await asyncio.sleep(delay) continue if not lots: print( f" seg={seg_label} page={page}: 0 lots — end of segment " "(либо captcha)", flush=True, ) break db = SessionLocal() try: ins, upd = save_listings(db, lots) cum_ins += ins cum_upd += upd total += len(lots) print( f" seg={seg_label} page={page}/{pages}: {len(lots)} lots → " f"ins={ins} upd={upd} " f"[cum: total={total} new={cum_ins} upd={cum_upd}]", flush=True, ) except Exception as e: # noqa: BLE001 print( f" seg={seg_label} page={page} FAIL save: " f"{type(e).__name__}: {e}", flush=True, ) finally: db.close() # scraper.fetch_around() уже спит request_delay_sec * jitter — extra sleep не нужен print( f" PHASE CITYWIDE TOTAL: fetched={total} ins={cum_ins} upd={cum_upd}", flush=True, ) return total async def main() -> None: p = argparse.ArgumentParser() p.add_argument( "--pages", type=int, default=8, help="страниц на room-segment (default 8 → ~28×4×8 = ~900 до дедупа)", ) p.add_argument( "--delay", type=float, default=15, help="sec между HTTP requests (residential-safe ≥10, default 15)", ) p.add_argument( "--rooms", type=str, default="1,2,3,4", help="room segments через запятую (1,2,3,4) или 'all' для одного citywide " "запроса без room param (default '1,2,3,4')", ) p.add_argument( "--cookies-file", type=Path, default=None, help="JSON-файл с Cian cookies (browser-export или {name:value}). " "Опционально — для обхода captcha. См. scripts/.cian-cookies.json", ) p.add_argument( "--start-page", type=int, default=1, help="С какой страницы начать ПЕРВЫЙ сегмент (для resume прерванного sweep). " "Остальные segments идут с p=1. Default 1.", ) args = p.parse_args() # Parse rooms argument if args.rooms.strip().lower() == "all": segments: tuple[tuple[int, ...] | None, ...] = (None,) else: try: nums = [int(x.strip()) for x in args.rooms.split(",") if x.strip()] except ValueError as e: raise SystemExit(f"--rooms must be comma-separated ints or 'all': {e}") from e segments = tuple((n,) for n in nums) cookies_file: Path | None = args.cookies_file if cookies_file is not None and not cookies_file.exists(): raise SystemExit(f"--cookies-file not found: {cookies_file}") t0 = time.perf_counter() await phase_citywide( args.pages, args.delay, segments, cookies_file=cookies_file, start_page=args.start_page, ) elapsed = time.perf_counter() - t0 print(f"\n=== ALL DONE in {elapsed / 60:.1f} min ===", flush=True) if __name__ == "__main__": asyncio.run(main())