feat(tradein): bulk sweep CLI for Yandex valuation house-history (EKB)
- New tradein-mvp/scripts/sweep-yandex-valuation-ekb.py - Iterates DISTINCT addresses from listings (source='yandex' AND region_code=66) - Calls existing estimator helpers (_get_or_fetch_yandex_valuation_cached + _save_yandex_history_items) — page=1 uses 24h cache; pages 2..N go direct - Idempotent — skips addresses with external_valuations.fetched_at < N days - CLI: --limit / --max-pages / --delay / --address-delay / --skip-recent-days / --address (single-address mode) - Backfills the 160 orphan rows now that PR #526 (parser) + #531 (house_id link) are wired
This commit is contained in:
parent
c8675ce6e2
commit
4f26720dc8
1 changed files with 258 additions and 0 deletions
258
tradein-mvp/scripts/sweep-yandex-valuation-ekb.py
Normal file
258
tradein-mvp/scripts/sweep-yandex-valuation-ekb.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""Bulk sweep of Yandex.Недвижимость house-history for all EKB yandex addresses.
|
||||
|
||||
Iterates `SELECT DISTINCT address FROM listings WHERE source='yandex' AND region_code=66
|
||||
AND address IS NOT NULL AND is_active=TRUE` and for each address calls the same
|
||||
cached fetch + save pipeline used by estimator. Idempotent — repeat runs deduplicate
|
||||
via house_placement_history UNIQUE (source, ext_item_id) and external_valuations cache.
|
||||
|
||||
Why this script:
|
||||
- 160 orphan rows in house_placement_history have house_id=NULL because match
|
||||
pipeline was wired only after PRs #526 + #531 (2026-05-24).
|
||||
- 3704 yandex listings on prod with ~hundreds of unique addresses → after one
|
||||
sweep we get a population of house-history rows linked to houses + with
|
||||
removed_date / total_floors / source_confidence filled.
|
||||
|
||||
Setup (same as local-sweep-ekb-yandex.py):
|
||||
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/sweep-yandex-valuation-ekb.py # full
|
||||
uv run python ../scripts/sweep-yandex-valuation-ekb.py --limit 10 # smoke
|
||||
uv run python ../scripts/sweep-yandex-valuation-ekb.py --max-pages 3
|
||||
uv run python ../scripts/sweep-yandex-valuation-ekb.py --skip-recent-days 0 # force re-scrape
|
||||
uv run python ../scripts/sweep-yandex-valuation-ekb.py \
|
||||
--address "Екатеринбург, улица Куйбышева, 106"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
# Auto-inject backend/ in sys.path
|
||||
_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 import chain compat)
|
||||
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})
|
||||
|
||||
# Backend imports
|
||||
from sqlalchemy import text # noqa: E402
|
||||
|
||||
from app.core.db import SessionLocal # noqa: E402
|
||||
from app.services.estimator import ( # noqa: E402
|
||||
_get_or_fetch_yandex_valuation_cached,
|
||||
_save_yandex_history_items,
|
||||
)
|
||||
|
||||
|
||||
def _addresses_to_sweep(
|
||||
limit: int | None,
|
||||
skip_recent_days: int,
|
||||
single_address: str | None,
|
||||
) -> list[str]:
|
||||
"""Pull unique active EKB yandex addresses, optionally skipping recently-cached ones."""
|
||||
if single_address:
|
||||
return [single_address.strip()]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if skip_recent_days > 0:
|
||||
sql = text(
|
||||
"""
|
||||
SELECT DISTINCT l.address
|
||||
FROM listings l
|
||||
WHERE l.source = 'yandex'
|
||||
AND l.region_code = 66
|
||||
AND l.is_active = TRUE
|
||||
AND l.address IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM external_valuations ev
|
||||
WHERE ev.source = 'yandex_valuation'
|
||||
AND ev.address = l.address
|
||||
AND ev.fetched_at > NOW() - (:days || ' days')::interval
|
||||
)
|
||||
ORDER BY l.address
|
||||
"""
|
||||
)
|
||||
rows = db.execute(sql, {"days": skip_recent_days}).mappings().all()
|
||||
else:
|
||||
sql = text(
|
||||
"""
|
||||
SELECT DISTINCT address
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND region_code = 66
|
||||
AND is_active = TRUE
|
||||
AND address IS NOT NULL
|
||||
ORDER BY address
|
||||
"""
|
||||
)
|
||||
rows = db.execute(sql).mappings().all()
|
||||
addrs = [r["address"] for r in rows if r.get("address")]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if limit is not None and limit > 0:
|
||||
addrs = addrs[:limit]
|
||||
return addrs
|
||||
|
||||
|
||||
async def _sweep_one_address(
|
||||
address: str,
|
||||
max_pages: int,
|
||||
delay: float,
|
||||
) -> tuple[int, int]:
|
||||
"""Fetch+save up to max_pages for one address.
|
||||
|
||||
Returns (items_fetched_total, items_saved_total).
|
||||
"""
|
||||
total_fetched = 0
|
||||
total_saved = 0
|
||||
for page in range(1, max_pages + 1):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# _get_or_fetch_yandex_valuation_cached signature accepts address+offer_*;
|
||||
# page is not in its signature — for paginated walk we must call the
|
||||
# scraper directly. Use the cached helper for page=1 only, then call
|
||||
# YandexValuationScraper.fetch_house_history for pages >= 2.
|
||||
if page == 1:
|
||||
result = await _get_or_fetch_yandex_valuation_cached(
|
||||
db,
|
||||
address=address,
|
||||
)
|
||||
else:
|
||||
from app.services.scrapers.yandex_valuation import YandexValuationScraper
|
||||
|
||||
async with YandexValuationScraper() as scraper:
|
||||
result = await scraper.fetch_house_history(
|
||||
address=address,
|
||||
offer_category="APARTMENT",
|
||||
offer_type="SELL",
|
||||
page=page,
|
||||
)
|
||||
if result is None or not result.history_items:
|
||||
break
|
||||
|
||||
saved = _save_yandex_history_items(db, result)
|
||||
total_fetched += len(result.history_items)
|
||||
total_saved += saved
|
||||
print(
|
||||
f" page={page}/{max_pages}: items={len(result.history_items)} saved={saved}",
|
||||
flush=True,
|
||||
)
|
||||
# Stop early if Yandex doesn't paginate further (less than full page returned)
|
||||
if len(result.history_items) < 13:
|
||||
break
|
||||
except Exception as e:
|
||||
print(
|
||||
f" page={page} FAIL: {type(e).__name__}: {e}",
|
||||
flush=True,
|
||||
)
|
||||
break
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if page < max_pages:
|
||||
await asyncio.sleep(delay * random.uniform(0.85, 1.3))
|
||||
|
||||
return total_fetched, total_saved
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Bulk sweep Yandex.Недвижимость house-history for EKB yandex addresses"
|
||||
)
|
||||
p.add_argument("--limit", type=int, default=None, help="Cap number of addresses (default all)")
|
||||
p.add_argument(
|
||||
"--max-pages",
|
||||
type=int,
|
||||
default=12,
|
||||
help="Max pages per address (Yandex pagination cap; default 12)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Sec between page requests within one address (default 5)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--address-delay",
|
||||
type=float,
|
||||
default=15.0,
|
||||
help="Sec between switching to next address (default 15)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--skip-recent-days",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Skip addresses cached within N days (default 1; 0 = no skip)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--address",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Single address mode — sweep only this address",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
addresses = _addresses_to_sweep(args.limit, args.skip_recent_days, args.address)
|
||||
if not addresses:
|
||||
print("No addresses to sweep (all recently cached or none match query).", flush=True)
|
||||
return
|
||||
|
||||
print(
|
||||
f"=== Sweep {len(addresses)} addresses, max_pages={args.max_pages}, "
|
||||
f"delay={args.delay}s, address-delay={args.address_delay}s, "
|
||||
f"skip-recent={args.skip_recent_days}d ===",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
grand_fetched = 0
|
||||
grand_saved = 0
|
||||
for i, addr in enumerate(addresses, 1):
|
||||
print(f"\n[{i}/{len(addresses)}] {addr[:90]}", flush=True)
|
||||
try:
|
||||
fetched, saved = await _sweep_one_address(addr, args.max_pages, args.delay)
|
||||
except Exception as e:
|
||||
print(f" FATAL on this address: {type(e).__name__}: {e}", flush=True)
|
||||
continue
|
||||
grand_fetched += fetched
|
||||
grand_saved += saved
|
||||
print(
|
||||
f" done: fetched={fetched} saved={saved} [grand: f={grand_fetched} s={grand_saved}]",
|
||||
flush=True,
|
||||
)
|
||||
if i < len(addresses):
|
||||
await asyncio.sleep(args.address_delay * random.uniform(0.85, 1.3))
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
print(
|
||||
f"\n=== ALL DONE in {elapsed / 60:.1f} min. "
|
||||
f"addresses={len(addresses)} fetched={grand_fetched} saved={grand_saved} ===",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Add table
Reference in a new issue