gendesign/tradein-mvp/scripts/local-sweep-ekb-yandex.py
lekss361 d2a0257639
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 32s
feat(tradein-scripts): scrapers + probes + Phase C improvements (#551)
2026-05-24 19:58:38 +00:00

376 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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:<PASS>@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())