feat(tradein-scripts): scrapers + probes + Phase C improvements (#551)
This commit is contained in:
parent
6bbb026cb4
commit
d2a0257639
17 changed files with 2546 additions and 1 deletions
371
tradein-mvp/scripts/avito-playwright-sweep.py
Normal file
371
tradein-mvp/scripts/avito-playwright-sweep.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""Playwright-based Avito sweep — обходит Qrator JS challenge через real headless Chromium.
|
||||||
|
|
||||||
|
Use case: curl_cffi заблочен Qrator anti-DDoS (429 + captcha challenge). Real
|
||||||
|
browser proходит JS challenge автоматически.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
cd tradein-mvp/backend
|
||||||
|
uv pip install playwright
|
||||||
|
uv run playwright install chromium
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
export DATABASE_URL='postgresql+psycopg://tradein:<PASS>@localhost:15433/tradein'
|
||||||
|
uv run --project tradein-mvp/backend python tradein-mvp/scripts/avito-playwright-sweep.py
|
||||||
|
# options: --pages N --delay S --mode {citywide|byrooms} --headless
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Banner FIRST — даже до imports чтобы видно что скрипт стартанул:
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
print("=" * 60, flush=True)
|
||||||
|
print("AVITO PLAYWRIGHT SWEEP — starting...", flush=True)
|
||||||
|
print("=" * 60, flush=True)
|
||||||
|
_sys.stdout.flush()
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# Force unbuffered output
|
||||||
|
os.environ["PYTHONUNBUFFERED"] = "1"
|
||||||
|
|
||||||
|
_BACKEND = Path(__file__).resolve().parent.parent / "backend"
|
||||||
|
if str(_BACKEND) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND))
|
||||||
|
|
||||||
|
# WeasyPrint stub (Windows без GTK)
|
||||||
|
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})
|
||||||
|
|
||||||
|
from playwright.async_api import Browser, BrowserContext, async_playwright # noqa: E402
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright_stealth import Stealth # noqa: E402
|
||||||
|
_STEALTH_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_STEALTH_AVAILABLE = False
|
||||||
|
print("[warn] playwright-stealth not installed — без stealth, может triggered больше CAPTCHA", flush=True)
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal # noqa: E402
|
||||||
|
from app.services.scrapers.avito import AvitoScraper # noqa: E402
|
||||||
|
from app.services.scrapers.base import save_listings # noqa: E402
|
||||||
|
|
||||||
|
COOKIES_FILE = Path(__file__).resolve().parent / ".avito-cookies.json"
|
||||||
|
AVITO_BASE = "https://www.avito.ru"
|
||||||
|
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
# Reuse AvitoScraper's parser (DOM cards extraction)
|
||||||
|
_parser_instance = AvitoScraper()
|
||||||
|
_parser_instance._anchor_lat = 0.0 # noqa: SLF001
|
||||||
|
_parser_instance._anchor_lon = 0.0 # noqa: SLF001
|
||||||
|
_parser_instance._anchor_radius_m = 1000 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
|
def load_cookies() -> list[dict]:
|
||||||
|
"""Load DevTools-exported cookies → Playwright format."""
|
||||||
|
if not COOKIES_FILE.exists():
|
||||||
|
print(f"WARN: cookies file not found at {COOKIES_FILE} — будем без cookies")
|
||||||
|
return []
|
||||||
|
raw = json.loads(COOKIES_FILE.read_text(encoding="utf-8"))
|
||||||
|
out = []
|
||||||
|
for c in raw:
|
||||||
|
ck = {
|
||||||
|
"name": c["name"],
|
||||||
|
"value": c["value"],
|
||||||
|
"domain": c["domain"],
|
||||||
|
"path": c.get("path", "/"),
|
||||||
|
"secure": c.get("secure", True),
|
||||||
|
"httpOnly": c.get("httpOnly", False),
|
||||||
|
}
|
||||||
|
if c.get("expirationDate"):
|
||||||
|
ck["expires"] = int(c["expirationDate"])
|
||||||
|
ss = c.get("sameSite")
|
||||||
|
if ss == "no_restriction":
|
||||||
|
ck["sameSite"] = "None"
|
||||||
|
elif ss == "lax":
|
||||||
|
ck["sameSite"] = "Lax"
|
||||||
|
elif ss == "strict":
|
||||||
|
ck["sameSite"] = "Strict"
|
||||||
|
out.append(ck)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_citywide_url(page: int) -> str:
|
||||||
|
return f"{AVITO_BASE}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode({'s':104,'p':page})}"
|
||||||
|
|
||||||
|
|
||||||
|
ROOM_SLUGS: list[tuple[str, str]] = [
|
||||||
|
("студии", "studii-ASgBAgICAkSSA8YQygj~WA"),
|
||||||
|
("1-комн.", "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ"),
|
||||||
|
("2-комн.", "2-komnatnye-ASgBAgICAkSSA8YQygiCWQ"),
|
||||||
|
("3-комн.", "3-komnatnye-ASgBAgICAkSSA8YQygiEWQ"),
|
||||||
|
("4-комн.", "4-komnatnye-ASgBAgICAkSSA8YQygiGWQ"),
|
||||||
|
("5+комн.", "5-komnatnye-ASgBAgICAkSSA8YQygiIWQ"),
|
||||||
|
("своб.планир.", "svobodnaya_planirovka-ASgBAgICAkSSA8YQygj8zzI"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_rooms_url(slug: str, page: int) -> str:
|
||||||
|
return f"{AVITO_BASE}/ekaterinburg/kvartiry/prodam/{slug}?{urlencode({'s':104,'p':page})}"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_page(context: BrowserContext, url: str) -> tuple[int, str | None]:
|
||||||
|
"""Open page in Playwright, wait for content, return (status, html or None).
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. goto с wait_until='domcontentloaded' (быстро, не ждём всех scripts)
|
||||||
|
2. Если status 200 → wait_for_selector data-marker=item (Qrator challenge должен resolve пока)
|
||||||
|
3. Иначе если 429/403 — challenge не прошёл, дать ещё 5s + retry content (maybe challenge resolves async)
|
||||||
|
"""
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
response = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" goto FAIL: {type(e).__name__}: {str(e)[:80]}")
|
||||||
|
return 0, None
|
||||||
|
status = response.status if response else 0
|
||||||
|
if status in (429, 403):
|
||||||
|
# Qrator challenge — wait для JS auto-resolve, потом reload
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
try:
|
||||||
|
response = await page.reload(wait_until="domcontentloaded", timeout=30000)
|
||||||
|
status = response.status if response else status
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if status != 200:
|
||||||
|
return status, None
|
||||||
|
# Wait for item cards
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector('[data-marker="item"]', timeout=15000)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
html = await page.content()
|
||||||
|
return status, html
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def sweep_urls(
|
||||||
|
urls_with_label: list[tuple[str, str]],
|
||||||
|
delay: float,
|
||||||
|
headless: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Generic sweep: list of (label, url) → fetch + parse + save per URL."""
|
||||||
|
cookies = load_cookies()
|
||||||
|
print(f"loaded {len(cookies)} cookies")
|
||||||
|
total_fetched = 0
|
||||||
|
total_ins = 0
|
||||||
|
total_upd = 0
|
||||||
|
challenge_hits = 0
|
||||||
|
print("=== Launching Playwright browser ===", flush=True)
|
||||||
|
async with async_playwright() as p:
|
||||||
|
print(f" headless={headless}", flush=True)
|
||||||
|
browser: Browser = await p.chromium.launch(
|
||||||
|
headless=headless,
|
||||||
|
args=[
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
"--no-sandbox",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
print(" browser launched, creating context...", flush=True)
|
||||||
|
context: BrowserContext = await browser.new_context(
|
||||||
|
user_agent=UA,
|
||||||
|
locale="ru-RU",
|
||||||
|
timezone_id="Asia/Yekaterinburg",
|
||||||
|
viewport={"width": 1366, "height": 900},
|
||||||
|
)
|
||||||
|
# Stealth — optional, skip on error
|
||||||
|
if _STEALTH_AVAILABLE:
|
||||||
|
try:
|
||||||
|
stealth = Stealth()
|
||||||
|
await stealth.apply_stealth_async(context)
|
||||||
|
print(" stealth applied", flush=True)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" stealth skipped (err): {type(e).__name__}: {e}", flush=True)
|
||||||
|
if cookies:
|
||||||
|
await context.add_cookies(cookies)
|
||||||
|
print(f" cookies added ({len(cookies)})", flush=True)
|
||||||
|
|
||||||
|
# Initial warmup: open main Avito + wait для user pass CAPTCHA если нужно
|
||||||
|
print("\n=== WARMUP: opening Avito initial page ===", flush=True)
|
||||||
|
print(" creating new page tab...", flush=True)
|
||||||
|
warmup_page = await context.new_page()
|
||||||
|
print(" navigating to Avito (timeout 30s)...", flush=True)
|
||||||
|
try:
|
||||||
|
await warmup_page.goto(
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?s=104&p=1",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
print(" goto done, reading content...", flush=True)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" goto FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
# Check if challenge page — reliable detector: NO listing cards в HTML.
|
||||||
|
# Слово "captcha" может быть в JS embed страницы normally (false positive).
|
||||||
|
warmup_html = await warmup_page.content()
|
||||||
|
print(f" content read: {len(warmup_html)} bytes", flush=True)
|
||||||
|
try:
|
||||||
|
await warmup_page.wait_for_selector('[data-marker="item"]', timeout=5000)
|
||||||
|
has_items = True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
has_items = False
|
||||||
|
is_qrator = "qrator" in warmup_html.lower() and len(warmup_html) < 100000
|
||||||
|
challenge_present = (not has_items) and (is_qrator or len(warmup_html) < 50000)
|
||||||
|
print(f" has_items={has_items} qrator={is_qrator} size={len(warmup_html)} challenge={challenge_present}", flush=True)
|
||||||
|
if challenge_present:
|
||||||
|
print("\n🚨 CAPTCHA detected на initial page!")
|
||||||
|
print("👉 Окно браузера открыто — пройди капчу руками,")
|
||||||
|
print("👉 затем нажми Enter здесь чтобы продолжить sweep...")
|
||||||
|
try:
|
||||||
|
input(">>> Press ENTER when ready: ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("aborted")
|
||||||
|
await browser.close()
|
||||||
|
return 0
|
||||||
|
# Verify challenge passed
|
||||||
|
await warmup_page.reload(wait_until="domcontentloaded", timeout=30000)
|
||||||
|
new_html = await warmup_page.content()
|
||||||
|
if any(m in new_html.lower() for m in ("captcha", "qrator")):
|
||||||
|
print("⚠️ всё ещё CAPTCHA — повторно пройди или Ctrl+C")
|
||||||
|
input(">>> Retry ENTER: ")
|
||||||
|
else:
|
||||||
|
print("✅ CAPTCHA пройдена, продолжаю sweep")
|
||||||
|
else:
|
||||||
|
print("✅ Initial page OK (no challenge)")
|
||||||
|
await warmup_page.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Per-category skip flag: 0 lots на странице категории = end of pagination
|
||||||
|
# ДЛЯ ТЕКУЩЕЙ КАТЕГОРИИ, а не для всего sweep'а (был bug: break ломал всё).
|
||||||
|
current_cat: str | None = None
|
||||||
|
skip_until_next_cat = False
|
||||||
|
for i, (label, url) in enumerate(urls_with_label, 1):
|
||||||
|
# Категория = всё до " page=" в label ("1-комн. page=5/60" → "1-комн.")
|
||||||
|
cat = label.split(" page=")[0] if " page=" in label else label
|
||||||
|
if cat != current_cat:
|
||||||
|
current_cat = cat
|
||||||
|
skip_until_next_cat = False
|
||||||
|
challenge_hits = 0 # reset 429-counter на границе категорий
|
||||||
|
if skip_until_next_cat:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{i}/{len(urls_with_label)}] {label}", flush=True)
|
||||||
|
status, html = await fetch_page(context, url)
|
||||||
|
if status != 200 or not html:
|
||||||
|
print(f" HTTP {status} → skip", flush=True)
|
||||||
|
if status in (429, 403):
|
||||||
|
challenge_hits += 1
|
||||||
|
if challenge_hits >= 3:
|
||||||
|
print(f" ⚠️ {challenge_hits}x challenge hits — sleep 60s extra", flush=True)
|
||||||
|
print(" если CAPTCHA в окне — пройди и нажми ENTER:", flush=True)
|
||||||
|
try:
|
||||||
|
input(" >>> ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
break
|
||||||
|
challenge_hits = 0
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
# Parse + save
|
||||||
|
lots = _parser_instance._parse_html(html, source_url_base=url) # noqa: SLF001
|
||||||
|
if not lots:
|
||||||
|
print(f" 0 lots — end of pagination for {cat!r}", flush=True)
|
||||||
|
if " page=" in label:
|
||||||
|
# 0 lots на пагинированной странице = end of category, не sweep
|
||||||
|
skip_until_next_cat = True
|
||||||
|
continue
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ins, upd = save_listings(db, lots)
|
||||||
|
total_fetched += len(lots)
|
||||||
|
total_ins += ins
|
||||||
|
total_upd += upd
|
||||||
|
print(
|
||||||
|
f" {len(lots)} lots → ins={ins} upd={upd} "
|
||||||
|
f"[cum: tot={total_fetched} new={total_ins}]",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" save FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# Jittered delay
|
||||||
|
if i < len(urls_with_label):
|
||||||
|
j = delay * random.uniform(0.7, 1.3)
|
||||||
|
await asyncio.sleep(j)
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
await browser.close()
|
||||||
|
print(
|
||||||
|
f"\n=== TOTAL: fetched={total_fetched} ins={total_ins} upd={total_upd} ===",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return total_fetched
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
# Diagnostic — show env vars used:
|
||||||
|
db_url = os.environ.get("DATABASE_URL", "<NOT SET>")
|
||||||
|
# Mask password for logging
|
||||||
|
if "://" in db_url and "@" in db_url:
|
||||||
|
before_at, after_at = db_url.rsplit("@", 1)
|
||||||
|
scheme_user = before_at.split("://", 1)
|
||||||
|
if len(scheme_user) == 2 and ":" in scheme_user[1]:
|
||||||
|
user = scheme_user[1].split(":", 1)[0]
|
||||||
|
masked = f"{scheme_user[0]}://{user}:***@{after_at}"
|
||||||
|
else:
|
||||||
|
masked = db_url[:50] + "..."
|
||||||
|
else:
|
||||||
|
masked = db_url
|
||||||
|
print(f"DATABASE_URL: {masked}", flush=True)
|
||||||
|
print(f"ENVIRONMENT: {os.environ.get('ENVIRONMENT', '<default>')}", flush=True)
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--mode", choices=["citywide", "byrooms"], default="citywide")
|
||||||
|
p.add_argument("--pages", type=int, default=100, help="pages per query")
|
||||||
|
p.add_argument("--delay", type=float, default=7, help="sec между requests")
|
||||||
|
p.add_argument("--headless", action="store_true", default=True)
|
||||||
|
p.add_argument("--no-headless", dest="headless", action="store_false")
|
||||||
|
p.add_argument(
|
||||||
|
"--start-cat",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="byrooms: с какой категории начать (1-based). 1=студии, 2=1-комн, "
|
||||||
|
"3=2-комн, 4=3-комн, 5=4-комн, 6=5+комн, 7=своб.планир.",
|
||||||
|
)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
urls: list[tuple[str, str]] = []
|
||||||
|
if args.mode == "citywide":
|
||||||
|
for page in range(1, args.pages + 1):
|
||||||
|
urls.append((f"citywide page={page}/{args.pages}", build_citywide_url(page)))
|
||||||
|
else: # byrooms
|
||||||
|
cats = ROOM_SLUGS[args.start_cat - 1:]
|
||||||
|
if not cats:
|
||||||
|
print(f"ERROR: --start-cat={args.start_cat} > {len(ROOM_SLUGS)} cats", flush=True)
|
||||||
|
return
|
||||||
|
print(f"byrooms: starting from cat #{args.start_cat} ({cats[0][0]!r}), "
|
||||||
|
f"{len(cats)} cats total", flush=True)
|
||||||
|
for cat_name, slug in cats:
|
||||||
|
for page in range(1, args.pages + 1):
|
||||||
|
urls.append((f"{cat_name} page={page}/{args.pages}", build_rooms_url(slug, page)))
|
||||||
|
|
||||||
|
print(f"Mode: {args.mode}, pages: {args.pages}, delay: {args.delay}s, total URLs: {len(urls)}")
|
||||||
|
print(f"Estimated time: {len(urls) * args.delay / 60:.0f} min (без challenge)")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
await sweep_urls(urls, args.delay, args.headless)
|
||||||
|
print(f"\nDONE in {(time.perf_counter() - t0) / 60:.1f} min")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -51,6 +51,19 @@ logging.basicConfig(
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("phase-c")
|
logger = logging.getLogger("phase-c")
|
||||||
|
|
||||||
|
# Avito raw log — pipe everything from app.services.scrapers.avito_imv to a file
|
||||||
|
# so we keep full request/response history for diagnosing contract changes.
|
||||||
|
_AVITO_LOG_PATH = os.environ.get("AVITO_LOG_PATH", "avito-raw.log")
|
||||||
|
_avito_fh = logging.FileHandler(_AVITO_LOG_PATH, encoding="utf-8")
|
||||||
|
_avito_fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s"))
|
||||||
|
_avito_fh.setLevel(logging.DEBUG)
|
||||||
|
_avito_logger = logging.getLogger("app.services.scrapers.avito_imv")
|
||||||
|
_avito_logger.addHandler(_avito_fh)
|
||||||
|
_avito_logger.setLevel(logging.DEBUG)
|
||||||
|
# Also pipe phase-c progress lines to the file
|
||||||
|
logger.addHandler(_avito_fh)
|
||||||
|
logger.info("avito raw log writing to %s", os.path.abspath(_AVITO_LOG_PATH))
|
||||||
|
|
||||||
# Avito IMV API accepts only these house_type values (verified 2026-05-24).
|
# Avito IMV API accepts only these house_type values (verified 2026-05-24).
|
||||||
# Our `listings.house_type` may contain pre-2026 values like 'monolith' / 'monolith_brick'.
|
# Our `listings.house_type` may contain pre-2026 values like 'monolith' / 'monolith_brick'.
|
||||||
_HOUSE_TYPE_TO_IMV = {
|
_HOUSE_TYPE_TO_IMV = {
|
||||||
|
|
@ -147,8 +160,10 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
|
||||||
{"hid": house_id},
|
{"hid": house_id},
|
||||||
).mappings().first()
|
).mappings().first()
|
||||||
|
|
||||||
|
# Avito IMV: rooms=0 (студия) rejected with HTTP 400. Clamp to 1.
|
||||||
|
rooms_raw = int(row["rooms"])
|
||||||
return {
|
return {
|
||||||
"rooms": int(row["rooms"]),
|
"rooms": max(rooms_raw, 1),
|
||||||
"area_m2": float(row["area_m2"]),
|
"area_m2": float(row["area_m2"]),
|
||||||
"floor": int(row["floor"] or 1),
|
"floor": int(row["floor"] or 1),
|
||||||
"floor_at_home": int(row["total_floors"] or (house and house["total_floors"]) or 9),
|
"floor_at_home": int(row["total_floors"] or (house and house["total_floors"]) or 9),
|
||||||
|
|
|
||||||
173
tradein-mvp/scripts/backfill-yandex-addresses-ekb.py
Normal file
173
tradein-mvp/scripts/backfill-yandex-addresses-ekb.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""Backfill listings.address with full address (street + house number) for
|
||||||
|
active EKB yandex listings that currently have street-only addresses.
|
||||||
|
|
||||||
|
Why: Yandex SERP returns only street (e.g. 'улица Горького') in card snippet.
|
||||||
|
Detail page <title> has the full address ('Екатеринбург, улица Горького, 36').
|
||||||
|
With house number we can call /otsenka-kvartiry-po-adresu-onlayn/ and get the
|
||||||
|
building's history.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd tradein-mvp/backend
|
||||||
|
uv run python ../scripts/backfill-yandex-addresses-ekb.py --limit 5 # smoke
|
||||||
|
uv run python ../scripts/backfill-yandex-addresses-ekb.py # full
|
||||||
|
uv run python ../scripts/backfill-yandex-addresses-ekb.py --dry-run # no DB write
|
||||||
|
|
||||||
|
Requires SSH tunnel + DATABASE_URL env (see sweep-yandex-valuation-ekb.py docstring).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# sys.path injection
|
||||||
|
_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-less)
|
||||||
|
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})
|
||||||
|
|
||||||
|
from curl_cffi.requests import AsyncSession # noqa: E402
|
||||||
|
from sqlalchemy import text # noqa: E402
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal # noqa: E402
|
||||||
|
|
||||||
|
# Extract '<City>, <addr> — id <number>' from <title>
|
||||||
|
RE_TITLE_ADDRESS = re.compile(
|
||||||
|
r"<title>[^<]*?—\s*"
|
||||||
|
r"(?:[^—,]*?,\s*)?" # optional ЖК/жилой комплекс prefix
|
||||||
|
r"(Екатеринбург,\s*[^<]+?)"
|
||||||
|
r"\s*—\s*id\s*\d+\s*</title>",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_listings(limit: int | None) -> list[dict]:
|
||||||
|
"""Active EKB yandex listings without house number in address."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
sql = text(
|
||||||
|
"""
|
||||||
|
SELECT id, source_id, address, source_url
|
||||||
|
FROM listings
|
||||||
|
WHERE source = 'yandex'
|
||||||
|
AND region_code = 66
|
||||||
|
AND is_active = TRUE
|
||||||
|
AND address IS NOT NULL
|
||||||
|
AND NOT (address ~ ',\\s*\\d+')
|
||||||
|
AND source_url IS NOT NULL
|
||||||
|
ORDER BY scraped_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = db.execute(sql).mappings().all()
|
||||||
|
out = [dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if limit is not None and limit > 0:
|
||||||
|
out = out[:limit]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _update_listing_address(listing_id: int, new_address: str) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE listings SET address = :addr WHERE id = :id"),
|
||||||
|
{"addr": new_address, "id": listing_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_address(session: AsyncSession, url: str) -> str | None:
|
||||||
|
"""Fetch detail page, extract full address from <title>."""
|
||||||
|
try:
|
||||||
|
r = await session.get(url, timeout=30)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" fetch FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
return None
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f" HTTP {r.status_code}", flush=True)
|
||||||
|
return None
|
||||||
|
html = r.text.replace("\xa0", " ")
|
||||||
|
m = RE_TITLE_ADDRESS.search(html)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
addr = m.group(1).strip().rstrip(",").strip()
|
||||||
|
return addr if addr else None
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Backfill yandex listings.address with house number from detail page"
|
||||||
|
)
|
||||||
|
p.add_argument("--limit", type=int, default=None, help="Cap items (smoke testing)")
|
||||||
|
p.add_argument("--delay", type=float, default=3.0, help="Sec between requests")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="Don't UPDATE DB")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
items = _eligible_listings(args.limit)
|
||||||
|
if not items:
|
||||||
|
print("No eligible listings.", flush=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"=== Backfill {len(items)} listings (delay={args.delay}s dry_run={args.dry_run}) ===",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
enriched = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
async with AsyncSession(impersonate="chrome120") as session:
|
||||||
|
for i, item in enumerate(items, 1):
|
||||||
|
url = item["source_url"]
|
||||||
|
old_addr = item["address"]
|
||||||
|
print(
|
||||||
|
f"[{i}/{len(items)}] id={item['id']} old={old_addr!r}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
new_addr = await _fetch_address(session, url)
|
||||||
|
if new_addr is None:
|
||||||
|
print(" -> no address extracted", flush=True)
|
||||||
|
failed += 1
|
||||||
|
elif new_addr == old_addr:
|
||||||
|
print(" -> unchanged", flush=True)
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
print(f" -> {new_addr!r}", flush=True)
|
||||||
|
if not args.dry_run:
|
||||||
|
_update_listing_address(item["id"], new_addr)
|
||||||
|
enriched += 1
|
||||||
|
if i < len(items):
|
||||||
|
await asyncio.sleep(args.delay * random.uniform(0.85, 1.3))
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
print(
|
||||||
|
f"\n=== DONE in {elapsed / 60:.1f} min. "
|
||||||
|
f"enriched={enriched} skipped={skipped} failed={failed} ===",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
90
tradein-mvp/scripts/inspect-houses-html.py
Normal file
90
tradein-mvp/scripts/inspect-houses-html.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""One-off: fetch Avito houses HTML и анализ markers для понимания новой структуры.
|
||||||
|
|
||||||
|
После 2026-05-23 Avito поменяли rendering — старый `window.__preloadedState__`
|
||||||
|
больше не работает (Stage 2c parser fails). Этот скрипт fetch'ит один houses
|
||||||
|
catalog page локально через residential IP и сохраняет HTML + проверяет markers
|
||||||
|
чтобы понять new format (__NEXT_DATA__? React SSR? иной).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_BACKEND = Path(__file__).resolve().parent.parent / "backend"
|
||||||
|
if str(_BACKEND) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND))
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
import asyncio # noqa: E402
|
||||||
|
|
||||||
|
from curl_cffi.requests import AsyncSession # noqa: E402
|
||||||
|
|
||||||
|
URLS = [
|
||||||
|
"https://www.avito.ru/catalog/houses/ekaterinburg/ul_yumasheva_6/115188",
|
||||||
|
"https://www.avito.ru/catalog/houses/ekaterinburg/ul_akademika_postovskogo_17a/3171365",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with AsyncSession(
|
||||||
|
impersonate="chrome120",
|
||||||
|
timeout=25,
|
||||||
|
headers={
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
},
|
||||||
|
) as s:
|
||||||
|
for url in URLS:
|
||||||
|
print(f"\n=== {url} ===")
|
||||||
|
try:
|
||||||
|
r = await s.get(url)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" FAIL fetch: {type(e).__name__}: {e}")
|
||||||
|
continue
|
||||||
|
print(f" status={r.status_code} size={len(r.content)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
text = r.text
|
||||||
|
# Save first one для inspection
|
||||||
|
if "yumasheva" in url:
|
||||||
|
with Path("/tmp/avito-house-sample.html").open("wb") as f:
|
||||||
|
f.write(r.content)
|
||||||
|
print(" saved → /tmp/avito-house-sample.html")
|
||||||
|
markers = {
|
||||||
|
"__preloadedState__": "__preloadedState__" in text,
|
||||||
|
"__NEXT_DATA__": "__NEXT_DATA__" in text,
|
||||||
|
"__INITIAL_STATE__": "__INITIAL_STATE__" in text,
|
||||||
|
"datadome": "datadome" in text.lower(),
|
||||||
|
"captcha": "captcha" in text.lower(),
|
||||||
|
"data-marker": "data-marker" in text,
|
||||||
|
"data-app-state": "data-app-state" in text,
|
||||||
|
"developmentData": "developmentData" in text,
|
||||||
|
"avitoId": "avitoId" in text,
|
||||||
|
"window.__": "window.__" in text,
|
||||||
|
"<script id=": "<script id=" in text,
|
||||||
|
"ssr-state": "ssr-state" in text,
|
||||||
|
"__NUXT__": "__NUXT__" in text,
|
||||||
|
"Не найдено": "Не найдено" in text,
|
||||||
|
"недоступна": "недоступна" in text,
|
||||||
|
}
|
||||||
|
for k, v in markers.items():
|
||||||
|
print(f" {'✓' if v else '✗'} {k}")
|
||||||
|
# Look for script id="..." patterns (Next.js typical)
|
||||||
|
import re
|
||||||
|
script_ids = re.findall(r'<script[^>]*id="([^"]+)"', text)
|
||||||
|
if script_ids:
|
||||||
|
print(f" script IDs found: {script_ids[:10]}")
|
||||||
|
data_attrs = re.findall(r'<\w+\s+(data-[\w-]+)=', text)
|
||||||
|
if data_attrs:
|
||||||
|
unique = sorted(set(data_attrs))[:15]
|
||||||
|
print(f" data attrs sample: {unique}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
55
tradein-mvp/scripts/inspect-search-html.py
Normal file
55
tradein-mvp/scripts/inspect-search-html.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Fetch Avito search page и анализ URL slugs для room-filter."""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_BACKEND = Path(__file__).resolve().parent.parent / "backend"
|
||||||
|
if str(_BACKEND) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND))
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
import asyncio # noqa: E402
|
||||||
|
|
||||||
|
from curl_cffi.requests import AsyncSession # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
url = "https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?s=104&p=1"
|
||||||
|
async with AsyncSession(
|
||||||
|
impersonate="chrome120",
|
||||||
|
headers={
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
},
|
||||||
|
) as s:
|
||||||
|
r = await s.get(url)
|
||||||
|
print(f"status={r.status_code} size={len(r.content)}")
|
||||||
|
text = r.text
|
||||||
|
with Path("/tmp/avito-search-sample.html").open("wb") as f:
|
||||||
|
f.write(r.content)
|
||||||
|
print("saved → /tmp/avito-search-sample.html")
|
||||||
|
# Find filter links — pattern /ekaterinburg/kvartiry/prodam/<slug>-ASg...
|
||||||
|
links = re.findall(r'href="(/ekaterinburg/kvartiry/prodam/[^"]+)"', text)
|
||||||
|
unique = sorted(set(links))
|
||||||
|
print(f"\nUnique filter links ({len(unique)} found):")
|
||||||
|
for link in unique[:30]:
|
||||||
|
# Decode shortened
|
||||||
|
if "komnatn" in link.lower() or "studii" in link.lower() or "svobodnaya" in link.lower():
|
||||||
|
print(f" ROOMS: {link}")
|
||||||
|
else:
|
||||||
|
print(f" other: {link[:120]}")
|
||||||
|
# Also check direct headings
|
||||||
|
room_headings = re.findall(r'>(Студии?|[0-9]-[Кк]омнатн\w+|Свободная планировка)<', text)
|
||||||
|
print(f"\nRoom headings in HTML: {sorted(set(room_headings))}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
359
tradein-mvp/scripts/local-sweep-ekb-cian.py
Normal file
359
tradein-mvp/scripts/local-sweep-ekb-cian.py
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
"""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:<PASS>@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<mfe>[\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())
|
||||||
376
tradein-mvp/scripts/local-sweep-ekb-yandex.py
Normal file
376
tradein-mvp/scripts/local-sweep-ekb-yandex.py
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
"""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())
|
||||||
443
tradein-mvp/scripts/local-sweep-ekb.py
Normal file
443
tradein-mvp/scripts/local-sweep-ekb.py
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
"""Full ЕКБ sweep + houses enrichment — runs locally через residential IP.
|
||||||
|
|
||||||
|
Why: prod VPS Beget IP заблокирован Avito (datacenter fingerprint → 429).
|
||||||
|
Local IP (домашний ISP) = residential → проходит. Скрипт пишет в prod DB
|
||||||
|
через SSH tunnel (localhost:15433 → tradein-postgres docker container).
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python ../scripts/local-sweep-ekb.py # defaults
|
||||||
|
uv run python ../scripts/local-sweep-ekb.py --pages 3 --detail-top 20 --delay 30
|
||||||
|
uv run python ../scripts/local-sweep-ekb.py --skip-search # только detail + houses
|
||||||
|
uv run python ../scripts/local-sweep-ekb.py --skip-houses # без houses
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# stdlib
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# Auto-inject backend/ в sys.path чтобы найти app.* package
|
||||||
|
# (скрипт лежит в tradein-mvp/scripts/, app — в tradein-mvp/backend/)
|
||||||
|
_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 sqlalchemy import text # noqa: E402
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal # noqa: E402
|
||||||
|
from app.services.scrapers.avito import AvitoScraper # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class CityWideAvitoScraper(AvitoScraper):
|
||||||
|
"""Subclass без geo-фильтра — search всего города (ЕКБ) через pagination.
|
||||||
|
|
||||||
|
Override `_build_web_url` чтобы НЕ добавлять `geoCoords`/`radius` — Avito
|
||||||
|
отдаёт ВСЕ объявления в городе sorted by date, до 100 pages (cap ~5000).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str: # noqa: ARG002
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
params = {"s": 104, "p": page} # s=104 sort by date
|
||||||
|
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
# Avito URL slugs для room-filter (extracted из real search page HTML 2026-05-23)
|
||||||
|
ROOM_SLUGS: list[tuple[str, str]] = [
|
||||||
|
("студии", "studii-ASgBAgICAkSSA8YQygj~WA"),
|
||||||
|
("1-комн.", "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ"),
|
||||||
|
("2-комн.", "2-komnatnye-ASgBAgICAkSSA8YQygiCWQ"),
|
||||||
|
("3-комн.", "3-komnatnye-ASgBAgICAkSSA8YQygiEWQ"),
|
||||||
|
("4-комн.", "4-komnatnye-ASgBAgICAkSSA8YQygiGWQ"),
|
||||||
|
("5+комн.", "5-komnatnye-ASgBAgICAkSSA8YQygiIWQ"),
|
||||||
|
("своб.планир.", "svobodnaya_planirovka-ASgBAgICAkSSA8YQygj8zzI"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CityRoomsAvitoScraper(AvitoScraper):
|
||||||
|
"""Subclass с URL slug filter по комнатности — для splitting когда total > 5000.
|
||||||
|
|
||||||
|
URL pattern: /ekaterinburg/kvartiry/prodam/<slug>?s=104&p=N
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, slug: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.room_slug = slug
|
||||||
|
|
||||||
|
def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str: # noqa: ARG002
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
params = {"s": 104, "p": page}
|
||||||
|
return f"{self.base_url}/ekaterinburg/kvartiry/prodam/{self.room_slug}?{urlencode(params)}"
|
||||||
|
from app.services.scrapers.avito_detail import ( # noqa: E402
|
||||||
|
fetch_detail,
|
||||||
|
save_detail_enrichment,
|
||||||
|
)
|
||||||
|
from app.services.scrapers.avito_houses import ( # noqa: E402
|
||||||
|
fetch_house_catalog,
|
||||||
|
save_house_catalog_enrichment,
|
||||||
|
)
|
||||||
|
from app.services.scrapers.base import save_listings # noqa: E402
|
||||||
|
|
||||||
|
# 10 anchors покрывающих центральные/северные/южные/восточные/западные районы ЕКБ
|
||||||
|
ANCHORS: list[tuple[str, float, float]] = [
|
||||||
|
("Центр", 56.8400, 60.6050),
|
||||||
|
("ЮЗ", 56.7950, 60.5300),
|
||||||
|
("Уралмаш", 56.8970, 60.6100),
|
||||||
|
("Академический", 56.7700, 60.5500),
|
||||||
|
("Пионерский", 56.8650, 60.6200),
|
||||||
|
("Эльмаш", 56.8567, 60.6700),
|
||||||
|
("Втузгородок", 56.8400, 60.6650),
|
||||||
|
("ВИЗ", 56.8500, 60.5650),
|
||||||
|
("Юго-Запад", 56.7770, 60.5800),
|
||||||
|
("Сортировка", 56.8950, 60.5600),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def phase_citywide(pages: int, delay: float) -> int:
|
||||||
|
"""City-wide pagination — ВСЕ объявления ЕКБ (no geo filter), per-page save.
|
||||||
|
|
||||||
|
Per-page save важно: visible прогресс + не теряем данные если crash mid-way.
|
||||||
|
"""
|
||||||
|
print(
|
||||||
|
f"\n=== PHASE CITYWIDE: full ЕКБ pagination ({pages} pages, delay {delay}s) ===\n",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
total = 0
|
||||||
|
cum_ins = 0
|
||||||
|
cum_upd = 0
|
||||||
|
async with CityWideAvitoScraper() as s:
|
||||||
|
for page in range(1, pages + 1):
|
||||||
|
url = s._build_web_url(0.0, 0.0, 1, page=page)
|
||||||
|
try:
|
||||||
|
assert s._cffi is not None
|
||||||
|
response = await s._cffi.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f" ⚠️ page={page} HTTP {response.status_code} — stop", flush=True)
|
||||||
|
break
|
||||||
|
lots = s._parse_html(response.text, source_url_base=url)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" page={page} FAIL fetch: {type(e).__name__}: {e}", flush=True)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
if not lots:
|
||||||
|
print(f" page={page}: 0 lots — end of pagination", flush=True)
|
||||||
|
break
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ins, upd = save_listings(db, lots)
|
||||||
|
cum_ins += ins
|
||||||
|
cum_upd += upd
|
||||||
|
total += len(lots)
|
||||||
|
print(
|
||||||
|
f" page={page}/{pages}: {len(lots)} lots → ins={ins} upd={upd} "
|
||||||
|
f"[cum: total={total} new={cum_ins} upd={cum_upd}]",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" page={page} FAIL save: {type(e).__name__}: {e}", flush=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if page < pages:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
print(f" PHASE CITYWIDE TOTAL: fetched={total} ins={cum_ins} upd={cum_upd}", flush=True)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
async def phase_byrooms(pages: int, delay: float) -> int:
|
||||||
|
"""Rooms-split sweep — 7 categories × pages, per-page save.
|
||||||
|
|
||||||
|
Для total > 5000 (Avito search cap) split по комнатности позволяет получить
|
||||||
|
все listings (каждый segment <5000).
|
||||||
|
"""
|
||||||
|
print(
|
||||||
|
f"\n=== PHASE BYROOMS: 7 категорий × {pages} pages, delay {delay}s ===\n",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
grand_total = 0
|
||||||
|
grand_ins = 0
|
||||||
|
grand_upd = 0
|
||||||
|
import random
|
||||||
|
rate_limit_hits = 0
|
||||||
|
for ci, (name, slug) in enumerate(ROOM_SLUGS, 1):
|
||||||
|
print(f"\n--- [{ci}/{len(ROOM_SLUGS)}] Категория: {name} (slug={slug[:30]}...) ---", flush=True)
|
||||||
|
cat_total = 0
|
||||||
|
cat_ins = 0
|
||||||
|
cat_upd = 0
|
||||||
|
async with CityRoomsAvitoScraper(slug) as s:
|
||||||
|
for page in range(1, pages + 1):
|
||||||
|
url = s._build_web_url(0.0, 0.0, 1, page=page)
|
||||||
|
try:
|
||||||
|
assert s._cffi is not None
|
||||||
|
response = await s._cffi.get(url)
|
||||||
|
if response.status_code == 429:
|
||||||
|
# Exponential backoff на rate-limit: 60s × 2^N (max 30 min)
|
||||||
|
rate_limit_hits += 1
|
||||||
|
backoff = min(60 * (2 ** (rate_limit_hits - 1)), 1800)
|
||||||
|
print(f" {name} p={page} HTTP 429 (hit #{rate_limit_hits}) → backoff {backoff}s", flush=True)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
# retry same page after backoff
|
||||||
|
try:
|
||||||
|
response = await s._cffi.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f" {name} p={page} retry HTTP {response.status_code} — skip page", flush=True)
|
||||||
|
await asyncio.sleep(delay * 2)
|
||||||
|
continue
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" {name} p={page} retry FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
continue
|
||||||
|
elif response.status_code != 200:
|
||||||
|
print(f" {name} p={page} HTTP {response.status_code} — skip page", flush=True)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
lots = s._parse_html(response.text, source_url_base=url)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" {name} p={page} FAIL fetch: {type(e).__name__}: {e}", flush=True)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
if not lots:
|
||||||
|
print(f" {name} page={page}: 0 lots — end of category", flush=True)
|
||||||
|
break
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ins, upd = save_listings(db, lots)
|
||||||
|
cat_ins += ins
|
||||||
|
cat_upd += upd
|
||||||
|
cat_total += len(lots)
|
||||||
|
print(
|
||||||
|
f" {name} p={page}/{pages}: {len(lots)} lots → ins={ins} upd={upd} "
|
||||||
|
f"[cat: tot={cat_total} new={cat_ins}]",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" {name} page={page} FAIL save: {type(e).__name__}: {e}", flush=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if page < pages:
|
||||||
|
# Jitter: ±30% от delay чтобы выглядеть менее robotic
|
||||||
|
jittered = delay * random.uniform(0.7, 1.3)
|
||||||
|
await asyncio.sleep(jittered)
|
||||||
|
print(
|
||||||
|
f" --- {name} TOTAL: fetched={cat_total} ins={cat_ins} upd={cat_upd} ---",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
grand_total += cat_total
|
||||||
|
grand_ins += cat_ins
|
||||||
|
grand_upd += cat_upd
|
||||||
|
print(
|
||||||
|
f"\n PHASE BYROOMS GRAND TOTAL: fetched={grand_total} new={grand_ins} updated={grand_upd}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return grand_total
|
||||||
|
|
||||||
|
|
||||||
|
async def phase_a_search(pages: int, delay: float) -> int:
|
||||||
|
"""Search всех anchors × pages, save listings."""
|
||||||
|
print(
|
||||||
|
f"\n=== PHASE A: search ({len(ANCHORS)} anchors × {pages} pages, "
|
||||||
|
f"delay {delay}s) ===\n",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
total = 0
|
||||||
|
for i, (name, lat, lon) in enumerate(ANCHORS, 1):
|
||||||
|
print(f"[{i}/{len(ANCHORS)}] {name} ({lat},{lon})", flush=True)
|
||||||
|
try:
|
||||||
|
async with AvitoScraper() as s:
|
||||||
|
lots = await s.fetch_around(
|
||||||
|
lat, lon, 1500, pages=pages, delay_override_sec=delay
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" FAIL fetch: {type(e).__name__}: {e}", flush=True)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
print(f" fetched {len(lots)} lots", flush=True)
|
||||||
|
if not lots:
|
||||||
|
print(" ⚠️ 0 lots — possible 429, stop phase A", flush=True)
|
||||||
|
break
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ins, upd = save_listings(db, lots)
|
||||||
|
print(f" saved: ins={ins} upd={upd}", flush=True)
|
||||||
|
total += len(lots)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" FAIL save: {type(e).__name__}: {e}", flush=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if i < len(ANCHORS):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
async def phase_b_detail(top_n: int, delay: float) -> tuple[int, set[str]]:
|
||||||
|
"""Detail enrichment для top-N recent listings без detail.
|
||||||
|
|
||||||
|
Returns: (enriched_count, set of unique house_catalog_url paths extracted)
|
||||||
|
"""
|
||||||
|
print(
|
||||||
|
f"\n=== PHASE B: detail enrichment (top {top_n}, delay {delay}s) ===\n",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT source_url, source_id FROM listings
|
||||||
|
WHERE source='avito' AND detail_enriched_at IS NULL
|
||||||
|
AND source_url IS NOT NULL AND price_rub > 0
|
||||||
|
ORDER BY scraped_at DESC NULLS LAST
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"limit": top_n},
|
||||||
|
).mappings().all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
print(f" queue: {len(rows)} listings to enrich", flush=True)
|
||||||
|
enriched = 0
|
||||||
|
failed = 0
|
||||||
|
house_urls: set[str] = set()
|
||||||
|
for i, row in enumerate(rows, 1):
|
||||||
|
url = row["source_url"]
|
||||||
|
path = urlparse(url).path if url.startswith("http") else url
|
||||||
|
print(f"[{i}/{len(rows)}] detail {row['source_id']}", flush=True)
|
||||||
|
try:
|
||||||
|
enr = await fetch_detail(path)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ok = save_detail_enrichment(db, enr)
|
||||||
|
print(
|
||||||
|
f" enriched: ok={ok} "
|
||||||
|
f"rooms={enr.rooms} area={enr.area_m2} "
|
||||||
|
f"kitchen={enr.kitchen_area_m2} owners={enr.owners_count} "
|
||||||
|
f"house_url={enr.house_catalog_url}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
enriched += 1
|
||||||
|
# Извлекаем house path (strip query string + filter только /catalog/houses/)
|
||||||
|
if enr.house_catalog_url:
|
||||||
|
p = urlparse(enr.house_catalog_url).path
|
||||||
|
if p.startswith("/catalog/houses/"):
|
||||||
|
house_urls.add(p)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
failed += 1
|
||||||
|
if i < len(rows):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
print(f" total: enriched={enriched} failed={failed} unique_houses_extracted={len(house_urls)}", flush=True)
|
||||||
|
return enriched, house_urls
|
||||||
|
|
||||||
|
|
||||||
|
async def phase_c_houses(delay: float, extra_urls: set[str] | None = None) -> int:
|
||||||
|
"""Houses enrichment — unique house_url из listings (DB) + extra_urls (из Phase B in-memory).
|
||||||
|
|
||||||
|
save_detail_enrichment не пишет house_url в listings (баг Stage 2b),
|
||||||
|
поэтому Phase C принимает extra_urls собранные Phase B вручную.
|
||||||
|
"""
|
||||||
|
print("\n=== PHASE C: houses enrichment ===\n", flush=True)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT house_url FROM listings
|
||||||
|
WHERE source='avito' AND house_url IS NOT NULL
|
||||||
|
AND house_id_fk IS NULL
|
||||||
|
ORDER BY house_url
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
urls_from_db = {r[0] for r in rows if r[0]}
|
||||||
|
urls = urls_from_db | (extra_urls or set())
|
||||||
|
print(f" queue: {len(urls)} unique houses (db={len(urls_from_db)} +extras={len(extra_urls or set())})", flush=True)
|
||||||
|
if not urls:
|
||||||
|
print(" no houses to enrich (skip)", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
enriched = 0
|
||||||
|
failed = 0
|
||||||
|
for i, url in enumerate(sorted(urls), 1):
|
||||||
|
path = urlparse(url).path if url.startswith("http") else url
|
||||||
|
print(f"[{i}/{len(urls)}] {path}", flush=True)
|
||||||
|
try:
|
||||||
|
enr = await fetch_house_catalog(path)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
counters = save_house_catalog_enrichment(db, enr)
|
||||||
|
print(f" saved: {counters}", flush=True)
|
||||||
|
enriched += 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
failed += 1
|
||||||
|
if i < len(urls):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
print(f" total: enriched={enriched} failed={failed}", flush=True)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["anchors", "citywide", "byrooms", "both"],
|
||||||
|
default="anchors",
|
||||||
|
help="anchors=10 anchors; citywide=full pagination без geo; byrooms=7 категорий комнат (для total>5000); both=anchors+citywide",
|
||||||
|
)
|
||||||
|
p.add_argument("--pages", type=int, default=5, help="pages per anchor (anchors mode) или total pages (citywide)")
|
||||||
|
p.add_argument("--detail-top", type=int, default=50, help="top-N для detail enrichment")
|
||||||
|
p.add_argument("--delay", type=float, default=30, help="sec между HTTP requests")
|
||||||
|
p.add_argument("--skip-search", action="store_true")
|
||||||
|
p.add_argument("--skip-detail", action="store_true")
|
||||||
|
p.add_argument("--skip-houses", action="store_true")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
if not args.skip_search:
|
||||||
|
if args.mode in ("anchors", "both"):
|
||||||
|
await phase_a_search(args.pages, args.delay)
|
||||||
|
if args.mode in ("citywide", "both"):
|
||||||
|
cw_pages = args.pages if args.mode == "citywide" else 100
|
||||||
|
await phase_citywide(cw_pages, args.delay)
|
||||||
|
if args.mode == "byrooms":
|
||||||
|
await phase_byrooms(args.pages, args.delay)
|
||||||
|
extracted_house_urls: set[str] = set()
|
||||||
|
if not args.skip_detail:
|
||||||
|
_, extracted_house_urls = await phase_b_detail(args.detail_top, args.delay)
|
||||||
|
if not args.skip_houses:
|
||||||
|
await phase_c_houses(args.delay, extra_urls=extracted_house_urls)
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
print(f"\n=== ALL DONE in {elapsed / 60:.1f} min ===", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
103
tradein-mvp/scripts/probe-avito-1anchor.py
Normal file
103
tradein-mvp/scripts/probe-avito-1anchor.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""Avito probe: 1 anchor × 1 page с настраиваемой задержкой.
|
||||||
|
|
||||||
|
Минимальный dry-run чтобы проверить bans / detection после anti-bot
|
||||||
|
hardening (PR #487) + global delay setting (PR #485).
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
ssh -f -N -L 15433:172.20.0.2:5432 gendesign
|
||||||
|
cd tradein-mvp/backend
|
||||||
|
$env:DATABASE_URL = 'postgresql+psycopg://tradein:<PASS>@localhost:15433/tradein'
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python ../scripts/probe-avito-1anchor.py
|
||||||
|
uv run python ../scripts/probe-avito-1anchor.py --delay 5 --radius 1000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend"
|
||||||
|
if str(_BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND_DIR))
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal # noqa: E402
|
||||||
|
from app.services.scrapers.avito import AvitoScraper # noqa: E402
|
||||||
|
from app.services.scrapers.base import save_listings # noqa: E402
|
||||||
|
|
||||||
|
ANCHOR_NAME = "Центр ЕКБ"
|
||||||
|
ANCHOR_LAT = 56.8400
|
||||||
|
ANCHOR_LON = 60.6050
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--delay", type=float, default=3.0, help="sec между HTTP requests")
|
||||||
|
p.add_argument("--radius", type=int, default=1500, help="метры")
|
||||||
|
p.add_argument("--pages", type=int, default=1)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"=== Avito probe ===\n"
|
||||||
|
f" anchor: {ANCHOR_NAME} ({ANCHOR_LAT}, {ANCHOR_LON})\n"
|
||||||
|
f" radius: {args.radius}m\n"
|
||||||
|
f" pages: {args.pages}\n"
|
||||||
|
f" delay: {args.delay}s\n",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
async with AvitoScraper() as s:
|
||||||
|
lots = await s.fetch_around(
|
||||||
|
ANCHOR_LAT, ANCHOR_LON, args.radius,
|
||||||
|
pages=args.pages, delay_override_sec=args.delay,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"FETCH FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
fetched = len(lots)
|
||||||
|
print(f"fetched: {fetched} lots in {time.perf_counter() - t0:.1f}s", flush=True)
|
||||||
|
if fetched == 0:
|
||||||
|
print("⚠️ 0 lots — вероятно 429/ban. Смотри backend logs.", flush=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ins, upd = save_listings(db, lots)
|
||||||
|
print(f"saved: ins={ins} upd={upd}", flush=True)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"SAVE FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
sample = lots[:3]
|
||||||
|
print("\nsample listings:", flush=True)
|
||||||
|
for lot in sample:
|
||||||
|
addr = (lot.address or "")[:60]
|
||||||
|
print(
|
||||||
|
f" source_id={lot.source_id} price={lot.price_rub}₽ "
|
||||||
|
f"rooms={lot.rooms} area={lot.area_m2}м² floor={lot.floor}/{lot.total_floors} "
|
||||||
|
f"addr={addr!r}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
58
tradein-mvp/scripts/probe-db-counts.py
Normal file
58
tradein-mvp/scripts/probe-db-counts.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Quick DB diagnostics: Avito listings count + recent insert rate."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
conn = psycopg.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT count(*) FROM listings WHERE source = 'avito'")
|
||||||
|
print("avito total:", cur.fetchone()[0])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM listings "
|
||||||
|
"WHERE source='avito' AND scraped_at > NOW() - interval '20 minutes'"
|
||||||
|
)
|
||||||
|
print("avito scraped last 20min:", cur.fetchone()[0])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name='listings' AND column_name IN
|
||||||
|
('created_at','first_seen_at','first_seen','inserted_at')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cols = [r[0] for r in cur.fetchall()]
|
||||||
|
print(f"timestamp cols available: {cols}")
|
||||||
|
if "first_seen_at" in cols:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM listings "
|
||||||
|
"WHERE source='avito' AND first_seen_at > NOW() - interval '20 minutes'"
|
||||||
|
)
|
||||||
|
print("avito first_seen last 20min (new):", cur.fetchone()[0])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT date_trunc('minute', scraped_at) AS m, count(*)
|
||||||
|
FROM listings
|
||||||
|
WHERE source='avito' AND scraped_at > NOW() - interval '30 minutes'
|
||||||
|
GROUP BY 1 ORDER BY 1 DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print("\nper-minute scraped (last 30min):")
|
||||||
|
for row in cur.fetchall():
|
||||||
|
print(f" {row[0]} {row[1]}")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT rooms, count(*)
|
||||||
|
FROM listings
|
||||||
|
WHERE source='avito' AND scraped_at > NOW() - interval '30 minutes'
|
||||||
|
GROUP BY 1 ORDER BY 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print("\nby rooms (last 30min):")
|
||||||
|
for row in cur.fetchall():
|
||||||
|
print(f" rooms={row[0]} count={row[1]}")
|
||||||
129
tradein-mvp/scripts/probe-db-leak-full.py
Normal file
129
tradein-mvp/scripts/probe-db-leak-full.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""Полная проверка региональной утечки в БД Avito listings.
|
||||||
|
|
||||||
|
Угол 1: source_url slug (canonical, надёжный)
|
||||||
|
Угол 2: address с маркером области (квартиры в ЕКБ не содержат "обл.")
|
||||||
|
Угол 3: address contains имя пригорода/соседнего города
|
||||||
|
Угол 4: top самых частых первых слов address (sanity check)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
conn = psycopg.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT count(*) FROM listings WHERE source='avito'")
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
print(f"=== Avito total: {total} ===\n")
|
||||||
|
|
||||||
|
# Угол 1: URL slug (canonical)
|
||||||
|
print("[1] URL slug check:")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE source_url ~ 'avito\\.ru/ekaterinburg/') AS ekb,
|
||||||
|
count(*) FILTER (WHERE source_url IS NOT NULL AND source_url !~ 'avito\\.ru/ekaterinburg/') AS not_ekb,
|
||||||
|
count(*) FILTER (WHERE source_url IS NULL) AS no_url
|
||||||
|
FROM listings WHERE source='avito'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
ekb, not_ekb, no_url = cur.fetchone()
|
||||||
|
print(f" ekaterinburg URL: {ekb}")
|
||||||
|
print(f" NOT ekaterinburg: {not_ekb} {'← LEAK' if not_ekb else '✓ clean'}")
|
||||||
|
print(f" NULL url: {no_url}")
|
||||||
|
|
||||||
|
# Угол 2: address с "область" / "обл."
|
||||||
|
print("\n[2] Address contains region marker (область/обл.):")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) FROM listings
|
||||||
|
WHERE source='avito'
|
||||||
|
AND address IS NOT NULL
|
||||||
|
AND (address ILIKE '%область%' OR address ~* '\\bобл\\b' OR address ~* '\\bобл\\.')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
n = cur.fetchone()[0]
|
||||||
|
print(f" count: {n} {'← suspicious' if n > 5 else '✓ ok'}")
|
||||||
|
if n > 0:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_url, address FROM listings
|
||||||
|
WHERE source='avito' AND address IS NOT NULL
|
||||||
|
AND (address ILIKE '%область%' OR address ~* '\\bобл\\b' OR address ~* '\\bобл\\.')
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for url, addr in cur.fetchall():
|
||||||
|
slug = url.split('avito.ru/')[1].split('/')[0] if url and 'avito.ru/' in url else '?'
|
||||||
|
print(f" [{slug}] {addr[:90]!r}")
|
||||||
|
|
||||||
|
# Угол 3: address contains имя другого города/пригорода
|
||||||
|
print("\n[3] Address contains satellite-city marker:")
|
||||||
|
satellites = [
|
||||||
|
"Верхняя Пышма", "Арамиль", "Сысерть", "Дегтярск", "Первоуральск",
|
||||||
|
"Заречный", "Невьянск", "Нижний Тагил", "Кыштым", "Челябинск",
|
||||||
|
"Алапаевск", "Краснофимск", "Озёрск", "Озерск", "Снежинск", "Касли",
|
||||||
|
"Рефтинский", "Свободный", "Большой Исток", "Пышма", "Покровское",
|
||||||
|
"Реж", "Лесной", "Полевской", "Берёзовск", "Березовск", "Михайловск",
|
||||||
|
"Туринск", "Североуральск", "Тавда", "Богданович", "Артёмовский",
|
||||||
|
"Артемовский", "Качканар", "Ивдель", "Серов", "Ирбит", "Новоуральск",
|
||||||
|
"Камышлов", "Сухой Лог",
|
||||||
|
]
|
||||||
|
for city in satellites:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM listings WHERE source='avito' AND address ILIKE %s",
|
||||||
|
(f"%{city}%",),
|
||||||
|
)
|
||||||
|
n = cur.fetchone()[0]
|
||||||
|
if n > 0:
|
||||||
|
print(f" {city}: {n}")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) FROM listings WHERE source='avito' AND (
|
||||||
|
address ILIKE '%Верхняя Пышма%' OR address ILIKE '%Арамиль%' OR
|
||||||
|
address ILIKE '%Сысерть%' OR address ILIKE '%Дегтярск%' OR
|
||||||
|
address ILIKE '%Первоуральск%' OR address ILIKE '%Заречный%' OR
|
||||||
|
address ILIKE '%Невьянск%' OR address ILIKE '%Нижний Тагил%' OR
|
||||||
|
address ILIKE '%Кыштым%' OR address ILIKE '%Челябинск%' OR
|
||||||
|
address ILIKE '%Алапаевск%' OR address ILIKE '%Краснофимск%' OR
|
||||||
|
address ILIKE '%Озёрск%' OR address ILIKE '%Озерск%' OR
|
||||||
|
address ILIKE '%Снежинск%' OR address ILIKE '%Касли%' OR
|
||||||
|
address ILIKE '%Рефтинский%' OR address ILIKE '%Большой Исток%' OR
|
||||||
|
address ILIKE '%Реж%' OR address ILIKE '%Лесной%' OR address ILIKE '%Полевской%' OR
|
||||||
|
address ILIKE '%Берёзовск%' OR address ILIKE '%Березовск%'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
sat_total = cur.fetchone()[0]
|
||||||
|
print(f" TOTAL satellite-city leaks: {sat_total}")
|
||||||
|
|
||||||
|
# Угол 4: top first-words of address
|
||||||
|
print("\n[4] Top 20 first-words in address (sanity check):")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT split_part(trim(address), ' ', 1) AS first_word, count(*) AS n
|
||||||
|
FROM listings WHERE source='avito' AND COALESCE(address, '') != ''
|
||||||
|
GROUP BY 1 ORDER BY 2 DESC LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for word, n in cur.fetchall():
|
||||||
|
print(f" {n:>5} {word!r}")
|
||||||
|
|
||||||
|
# Угол 5: address NULL — сколько неopределённых
|
||||||
|
print("\n[5] Coverage:")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE COALESCE(address, '') = '') AS no_addr,
|
||||||
|
count(*) FILTER (WHERE COALESCE(address, '') != '') AS has_addr
|
||||||
|
FROM listings WHERE source='avito'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
no_a, has_a = cur.fetchone()
|
||||||
|
print(f" has address: {has_a}")
|
||||||
|
print(f" no address: {no_a}")
|
||||||
75
tradein-mvp/scripts/probe-db-region-leak.py
Normal file
75
tradein-mvp/scripts/probe-db-region-leak.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Проверка: есть ли в DB Avito листинги из других городов (region leak)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
conn = psycopg.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT count(*) FROM listings WHERE source='avito'")
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
print(f"avito total: {total}")
|
||||||
|
|
||||||
|
# Все с lat/lon — проверим bounding box ЕКБ (56.7..57.0, 60.4..60.8)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE lat BETWEEN 56.5 AND 57.0 AND lon BETWEEN 60.4 AND 61.0) as in_ekb,
|
||||||
|
count(*) FILTER (WHERE lat IS NOT NULL AND (lat < 56.5 OR lat > 57.0 OR lon < 60.4 OR lon > 61.0)) as outside_ekb,
|
||||||
|
count(*) FILTER (WHERE lat IS NULL) as no_coords
|
||||||
|
FROM listings WHERE source='avito'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
in_ekb, outside, no_coords = cur.fetchone()
|
||||||
|
print(f" in EKB bbox: {in_ekb}")
|
||||||
|
print(f" outside EKB bbox: {outside} ← out-of-region leak!")
|
||||||
|
print(f" no coords yet: {no_coords}")
|
||||||
|
|
||||||
|
# Sample outside
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_id, lat, lon, address, price_rub, rooms
|
||||||
|
FROM listings
|
||||||
|
WHERE source='avito' AND lat IS NOT NULL
|
||||||
|
AND (lat < 56.5 OR lat > 57.0 OR lon < 60.4 OR lon > 61.0)
|
||||||
|
ORDER BY scraped_at DESC NULLS LAST
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print("\nsample non-EKB listings:")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
sid, lat, lon, addr, price, rooms = r
|
||||||
|
print(f" {sid} ({lat:.3f},{lon:.3f}) rooms={rooms} {price:,}₽ — {(addr or '')[:80]!r}")
|
||||||
|
|
||||||
|
# Address-based leak (без lat/lon) — поиск явных маркеров других городов
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) FROM listings
|
||||||
|
WHERE source='avito'
|
||||||
|
AND lat IS NULL
|
||||||
|
AND address IS NOT NULL
|
||||||
|
AND (address ILIKE '%москв%' OR address ILIKE '%омск%' OR address ILIKE '%чебок%'
|
||||||
|
OR address ILIKE '%петерб%' OR address ILIKE '%казан%' OR address ILIKE '%новосиб%')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(f"\naddress-based leak (no coords, other-city keyword): {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
# По комнатам — где leak вероятнее всего
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT rooms,
|
||||||
|
count(*) AS total,
|
||||||
|
count(*) FILTER (WHERE lat IS NOT NULL AND (lat < 56.5 OR lat > 57.0 OR lon < 60.4 OR lon > 61.0)) AS leak
|
||||||
|
FROM listings WHERE source='avito'
|
||||||
|
GROUP BY rooms
|
||||||
|
ORDER BY rooms NULLS LAST
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print("\nleak by rooms:")
|
||||||
|
print(f" {'rooms':<8} {'total':>8} {'leak':>8}")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
rooms, total, leak = r
|
||||||
|
pct = (leak * 100.0 / total) if total else 0
|
||||||
|
print(f" {str(rooms):<8} {total:>8} {leak:>8} ({pct:.0f}%)")
|
||||||
51
tradein-mvp/scripts/probe-db-url-leak.py
Normal file
51
tradein-mvp/scripts/probe-db-url-leak.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""URL-based region leak check для Avito listings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
conn = psycopg.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT source_url FROM listings WHERE source='avito'")
|
||||||
|
slug_re = re.compile(r"avito\.ru/([a-z_]+)/")
|
||||||
|
|
||||||
|
cities = Counter()
|
||||||
|
no_match = 0
|
||||||
|
for (url,) in cur.fetchall():
|
||||||
|
if not url:
|
||||||
|
no_match += 1
|
||||||
|
continue
|
||||||
|
m = slug_re.search(url)
|
||||||
|
if m:
|
||||||
|
cities[m.group(1)] += 1
|
||||||
|
else:
|
||||||
|
no_match += 1
|
||||||
|
|
||||||
|
print("city slug distribution in source_url:")
|
||||||
|
for city, n in cities.most_common():
|
||||||
|
flag = " ← EKB" if city == "ekaterinburg" else (" ← OTHER CITY" if city not in ("ekaterinburg",) else "")
|
||||||
|
print(f" {city:<30} {n}{flag}")
|
||||||
|
print(f" (no slug parse): {no_match}")
|
||||||
|
|
||||||
|
# Дальше — sample non-EKB
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_id, source_url, address, price_rub
|
||||||
|
FROM listings
|
||||||
|
WHERE source='avito'
|
||||||
|
AND source_url IS NOT NULL
|
||||||
|
AND source_url !~ 'avito\\.ru/ekaterinburg/'
|
||||||
|
ORDER BY scraped_at DESC NULLS LAST
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print("\nsample non-EKB listings (by URL):")
|
||||||
|
for sid, url, addr, price in cur.fetchall():
|
||||||
|
print(f" {sid} {price:,}₽")
|
||||||
|
print(f" URL: {url}")
|
||||||
|
print(f" addr: {(addr or '')[:80]!r}")
|
||||||
74
tradein-mvp/scripts/probe-migration-028.py
Normal file
74
tradein-mvp/scripts/probe-migration-028.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Проверка состояния миграции 028_matching_tables.sql на прод tradein-postgres."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
conn = psycopg.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
EXPECTED_COLUMNS = [
|
||||||
|
("houses", "address_fingerprint"),
|
||||||
|
("listings", "canonical"),
|
||||||
|
("listings", "merged_into"),
|
||||||
|
]
|
||||||
|
EXPECTED_INDEXES = [
|
||||||
|
"houses_addr_fp_idx",
|
||||||
|
"listings_merged_into_idx",
|
||||||
|
"house_sources_house_idx",
|
||||||
|
"house_sources_low_conf_idx",
|
||||||
|
"listing_sources_listing_idx",
|
||||||
|
"listing_sources_low_conf_idx",
|
||||||
|
"listing_sources_scraped_idx",
|
||||||
|
"haa_house_idx",
|
||||||
|
"haa_fingerprint_idx",
|
||||||
|
]
|
||||||
|
EXPECTED_TABLES = ["house_sources", "listing_sources", "house_address_aliases"]
|
||||||
|
|
||||||
|
print("=== Columns ===")
|
||||||
|
for tbl, col in EXPECTED_COLUMNS:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.columns WHERE table_name=%s AND column_name=%s",
|
||||||
|
(tbl, col),
|
||||||
|
)
|
||||||
|
status = "✓" if cur.fetchone() else "✗ MISSING"
|
||||||
|
print(f" {tbl}.{col:<25} {status}")
|
||||||
|
|
||||||
|
print("\n=== Tables ===")
|
||||||
|
for tbl in EXPECTED_TABLES:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_name=%s AND table_schema='public'",
|
||||||
|
(tbl,),
|
||||||
|
)
|
||||||
|
status = "✓" if cur.fetchone() else "✗ MISSING"
|
||||||
|
print(f" {tbl:<30} {status}")
|
||||||
|
|
||||||
|
print("\n=== Indexes ===")
|
||||||
|
for idx in EXPECTED_INDEXES:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM pg_indexes WHERE indexname=%s",
|
||||||
|
(idx,),
|
||||||
|
)
|
||||||
|
status = "✓" if cur.fetchone() else "✗ MISSING"
|
||||||
|
print(f" {idx:<35} {status}")
|
||||||
|
|
||||||
|
# Проверим есть ли активные транзакции/locks от deploy которые ещё висят
|
||||||
|
print("\n=== Active transactions (locks on relevant tables) ===")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT pid, usename, application_name, state, wait_event_type, wait_event,
|
||||||
|
query_start, substring(query, 1, 80) AS query_head
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE state IS DISTINCT FROM 'idle'
|
||||||
|
AND pid != pg_backend_pid()
|
||||||
|
ORDER BY query_start NULLS LAST
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
print(" (none — clean)")
|
||||||
|
else:
|
||||||
|
for r in rows:
|
||||||
|
print(f" pid={r[0]} user={r[1]} app={r[2]} state={r[3]} wait={r[4]}/{r[5]} started={r[6]}")
|
||||||
|
print(f" query: {r[7]!r}")
|
||||||
90
tradein-mvp/scripts/probe-migration-status.py
Normal file
90
tradein-mvp/scripts/probe-migration-status.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Проверка какие миграции 028..054 НЕ применены."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
conn = psycopg.connect(dsn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Маркеры по миграциям (table/column/index/etc)
|
||||||
|
CHECKS = [
|
||||||
|
("028 matching_tables", "table", "house_sources"),
|
||||||
|
("029 extend matching/valuation/dynamics", "column", ("listing_sources", "price_divergence_pct")),
|
||||||
|
("030 avito_imv cache_key unique", "index_unique", ("avito_imv_evaluations", "cache_key")),
|
||||||
|
("030 listings_alter_yandex", "column", ("listings", "yandex_id")),
|
||||||
|
("031 houses_alter_yandex", "column", ("houses", "yandex_id")),
|
||||||
|
("032 yandex_history", "table", "yandex_house_history"),
|
||||||
|
("040 houses_extend", "column", ("houses", "year_renovated")),
|
||||||
|
("041 house_sources noop", "noop", None),
|
||||||
|
("042 listing_sources price_divergence_idx", "index", "listing_sources_price_divergence_idx"),
|
||||||
|
("043 house_reviews_extend", "column", ("house_reviews", "review_text")),
|
||||||
|
("044 external_valuations_link", "column", ("external_valuations", "link_url")),
|
||||||
|
("045 house_placement_history_extend", "column", ("house_placement_history", "first_seen_at")),
|
||||||
|
("046 views", "view", "v_house_canonical"),
|
||||||
|
("050 search_optimization", "matview", "listings_search_mv"),
|
||||||
|
("051 scrape_runs_extend", "column", ("scrape_runs", "heartbeat_at")),
|
||||||
|
("052 scrape_schedules", "table", "scrape_schedules"),
|
||||||
|
("053 scraper_settings", "table", "scraper_settings"),
|
||||||
|
("054 scraper_settings_global", "row", ("scraper_settings", "source='global'")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def check(kind: str, target) -> bool:
|
||||||
|
if kind == "noop":
|
||||||
|
return True
|
||||||
|
if kind == "table":
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=%s",
|
||||||
|
(target,),
|
||||||
|
)
|
||||||
|
elif kind == "view":
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.views WHERE table_schema='public' AND table_name=%s",
|
||||||
|
(target,),
|
||||||
|
)
|
||||||
|
elif kind == "matview":
|
||||||
|
cur.execute("SELECT 1 FROM pg_matviews WHERE matviewname=%s", (target,))
|
||||||
|
elif kind == "column":
|
||||||
|
tbl, col = target
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.columns WHERE table_name=%s AND column_name=%s",
|
||||||
|
(tbl, col),
|
||||||
|
)
|
||||||
|
elif kind == "index":
|
||||||
|
cur.execute("SELECT 1 FROM pg_indexes WHERE indexname=%s", (target,))
|
||||||
|
elif kind == "index_unique":
|
||||||
|
tbl, col = target
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT 1 FROM pg_indexes WHERE tablename=%s AND indexdef ~ %s""",
|
||||||
|
(tbl, f"UNIQUE.*{col}"),
|
||||||
|
)
|
||||||
|
elif kind == "row":
|
||||||
|
tbl, where = target
|
||||||
|
cur.execute(f"SELECT 1 FROM {tbl} WHERE {where}")
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
print(f"{'Migration':<48} Status")
|
||||||
|
print("-" * 60)
|
||||||
|
missing = []
|
||||||
|
for name, kind, target in CHECKS:
|
||||||
|
try:
|
||||||
|
ok = check(kind, target)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
ok = False
|
||||||
|
print(f"{name:<48} ✗ ERROR: {type(e).__name__}: {str(e)[:50]}")
|
||||||
|
conn.rollback()
|
||||||
|
missing.append(name)
|
||||||
|
continue
|
||||||
|
status = "✓" if ok else "✗ MISSING"
|
||||||
|
print(f"{name:<48} {status}")
|
||||||
|
if not ok:
|
||||||
|
missing.append(name)
|
||||||
|
|
||||||
|
print(f"\nMissing: {len(missing)}/{len(CHECKS)}")
|
||||||
|
for m in missing:
|
||||||
|
print(f" - {m}")
|
||||||
49
tradein-mvp/scripts/probe-parser-region-filter.py
Normal file
49
tradein-mvp/scripts/probe-parser-region-filter.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Прогнать current parser на HTML из Playwright debug-страницы (своб.планир p=10).
|
||||||
|
|
||||||
|
Проверяет что URL-filter работает: до фикса parser возвращал ~30 карточек других
|
||||||
|
городов, после — должен 0 или только ЕКБ.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_BACKEND = Path(__file__).resolve().parent.parent / "backend"
|
||||||
|
if str(_BACKEND) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND))
|
||||||
|
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})
|
||||||
|
|
||||||
|
from app.services.scrapers.avito import AvitoScraper # noqa: E402
|
||||||
|
|
||||||
|
URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam/svobodnaya_planirovka-ASgBAgICAkSSA8YQygj8zzI?p=10&s=104"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with AvitoScraper() as s:
|
||||||
|
assert s._cffi is not None
|
||||||
|
# Use scraper's own session — может попасть в 403 (curl_cffi), это OK,
|
||||||
|
# тогда переключимся на reading from Playwright MCP page.
|
||||||
|
print(f"Fetching {URL[:80]}...")
|
||||||
|
try:
|
||||||
|
r = await s._cffi.get(URL)
|
||||||
|
print(f" HTTP {r.status_code}, html size: {len(r.text)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(" curl_cffi 403 — Avito не отдал. Пробни через MCP / browser save.")
|
||||||
|
return
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" fetch fail: {type(e).__name__}: {e}")
|
||||||
|
return
|
||||||
|
lots = s._parse_html(r.text, source_url_base=URL)
|
||||||
|
print(f"\nParsed {len(lots)} EKB lots (после фильтра).")
|
||||||
|
for lot in lots[:5]:
|
||||||
|
print(f" {lot.source_id} {lot.source_url[:80]}")
|
||||||
|
print(f" {lot.address or '-'!r}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
34
tradein-mvp/scripts/test-playwright.py
Normal file
34
tradein-mvp/scripts/test-playwright.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Minimal Playwright test — открывает Chrome окно с Avito на 30 sec."""
|
||||||
|
import asyncio
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
async with async_playwright() as p:
|
||||||
|
print("launching browser...", flush=True)
|
||||||
|
browser = await p.chromium.launch(headless=False)
|
||||||
|
print("browser launched, opening page...", flush=True)
|
||||||
|
page = await browser.new_page()
|
||||||
|
try:
|
||||||
|
await page.goto(
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?s=104&p=1",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"goto FAIL: {type(e).__name__}: {e}", flush=True)
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
await browser.close()
|
||||||
|
return
|
||||||
|
print("page loaded", flush=True)
|
||||||
|
try:
|
||||||
|
title = await page.title()
|
||||||
|
print(f"title: {title}", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"title FAIL: {e}", flush=True)
|
||||||
|
print("окно должно быть видно ~30 сек", flush=True)
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(go())
|
||||||
Loading…
Add table
Reference in a new issue