feat(tradein): admin backfill endpoints — cian price-history (T8a) + yandex address (T10) + house IMV (T7)
T8a: POST /scrape/cian-price-history — fetches Cian detail pages (curl_cffi), extracts priceChanges from _cianConfig defaultState, upserts offer_price_history. Wraps existing cian_detail.fetch_detail + save_detail_enrichment. T10: POST /scrape/yandex-address-backfill — enriches yandex listings.address with house number from detail-page <title> regex; resets geocode_tried_at for re-geocoding. Targets active EKB listings without a house number in address. T7: POST /scrape/house-imv-backfill — bulk Avito IMV evaluation per house. Picks median lot-params from linked listings, calls evaluate_via_imv, persists house_imv_evaluations + house_placement_history + house_suggestions. Resumable via imv_status markers. Runs in foreground (BackgroundTasks for long batches can be added later if needed). All three: NOT wired to scheduler (anti-rate-limit, intentional manual trigger). All three: idempotent / graceful per-item error handling + counters response. 27 unit tests added in tests/test_backfill_wave2.py (all passing).
This commit is contained in:
parent
99c751f834
commit
e51722b3f1
5 changed files with 1623 additions and 0 deletions
|
|
@ -1186,3 +1186,188 @@ async def scrape_cian_backfill_history(
|
|||
dry_run=dry_run,
|
||||
)
|
||||
return CianBackfillResp(ok=True, **result.__dict__)
|
||||
|
||||
|
||||
# ── T8a: Cian price-history backfill ─────────────────────────────────────────
|
||||
|
||||
|
||||
class CianPriceHistoryRequest(BaseModel):
|
||||
batch_size: int = Field(default=50, ge=1, le=500)
|
||||
listing_id: int | None = Field(
|
||||
default=None,
|
||||
description="Если указан — обработать один конкретный листинг.",
|
||||
)
|
||||
|
||||
|
||||
class BackfillCountersResp(BaseModel):
|
||||
ok: bool
|
||||
checked: int
|
||||
saved: int
|
||||
skipped: int
|
||||
errors: int
|
||||
duration_sec: float
|
||||
|
||||
|
||||
@router.post("/scrape/cian-price-history", response_model=BackfillCountersResp)
|
||||
async def scrape_cian_price_history(
|
||||
payload: CianPriceHistoryRequest,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> BackfillCountersResp:
|
||||
"""T8a: Backfill offer_price_history для Cian-листингов.
|
||||
|
||||
Fetches Cian detail pages (curl_cffi, без Playwright) для листингов без строк
|
||||
в offer_price_history, парсит priceChanges из _cianConfig defaultState.
|
||||
|
||||
batch_size: сколько листингов обработать за запуск (default 50).
|
||||
listing_id: обработать один конкретный листинг (debug / точечная перезаливка).
|
||||
|
||||
Rate-limit: задержка из scraper_settings['cian'] (default 5s) между страницами.
|
||||
Idempotent: повторный запуск безопасен (ON CONFLICT DO NOTHING).
|
||||
"""
|
||||
from app.services.cian_price_history import (
|
||||
CianPriceHistoryResult,
|
||||
backfill_cian_price_history,
|
||||
)
|
||||
|
||||
result: CianPriceHistoryResult = await backfill_cian_price_history(
|
||||
db,
|
||||
batch_size=payload.batch_size,
|
||||
listing_id=payload.listing_id,
|
||||
)
|
||||
return BackfillCountersResp(
|
||||
ok=True,
|
||||
checked=result.checked,
|
||||
saved=result.saved,
|
||||
skipped=result.skipped,
|
||||
errors=result.errors,
|
||||
duration_sec=round(result.duration_sec, 2),
|
||||
)
|
||||
|
||||
|
||||
# ── T10: Yandex address backfill ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class YandexAddressBackfillRequest(BaseModel):
|
||||
limit: int = Field(default=200, ge=1, le=2000)
|
||||
request_delay_sec: float = Field(default=3.0, ge=1.0, le=15.0)
|
||||
|
||||
|
||||
@router.post("/scrape/yandex-address-backfill", response_model=BackfillCountersResp)
|
||||
async def scrape_yandex_address_backfill(
|
||||
payload: YandexAddressBackfillRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> BackfillCountersResp:
|
||||
"""T10: Backfill listings.address для Yandex-листингов без номера дома.
|
||||
|
||||
Yandex SERP-карточки содержат только улицу («улица Горького»). Этот endpoint
|
||||
фетчит detail-страницы через curl_cffi (chrome120), извлекает полный адрес
|
||||
из <title> («Екатеринбург, улица Горького, 36 — id …»), обновляет
|
||||
listings.address и сбрасывает geocode_tried_at для перегеокодирования.
|
||||
|
||||
limit: сколько листингов обработать за запуск (default 200).
|
||||
request_delay_sec: пауза между запросами (default 3.0s).
|
||||
|
||||
Запускает задачу в foreground (не в BackgroundTasks), т.к. batch небольшой.
|
||||
Для > 500 листингов рекомендуется вызывать несколько раз.
|
||||
"""
|
||||
from app.services.yandex_address_backfill import (
|
||||
YandexAddressBackfillResult,
|
||||
backfill_yandex_addresses,
|
||||
)
|
||||
|
||||
result: YandexAddressBackfillResult = await backfill_yandex_addresses(
|
||||
db,
|
||||
limit=payload.limit,
|
||||
request_delay_sec=payload.request_delay_sec,
|
||||
)
|
||||
return BackfillCountersResp(
|
||||
ok=True,
|
||||
checked=result.checked,
|
||||
saved=result.saved,
|
||||
skipped=result.skipped,
|
||||
errors=result.errors,
|
||||
duration_sec=round(result.duration_sec, 2),
|
||||
)
|
||||
|
||||
|
||||
# ── T7: House IMV bulk backfill ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class HouseIMVBackfillRequest(BaseModel):
|
||||
batch_size: int = Field(default=50, ge=1, le=500)
|
||||
request_delay_sec: float = Field(
|
||||
default=5.0,
|
||||
ge=1.0,
|
||||
le=30.0,
|
||||
description="Пауза между Avito IMV вызовами (default 5s — anti-captcha).",
|
||||
)
|
||||
only_status: str = Field(
|
||||
default="pending",
|
||||
description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.",
|
||||
)
|
||||
house_id: int | None = Field(
|
||||
default=None,
|
||||
description="Обработать один конкретный дом (debug).",
|
||||
)
|
||||
|
||||
|
||||
class HouseIMVBackfillResp(BaseModel):
|
||||
ok: bool
|
||||
checked: int
|
||||
saved: int
|
||||
skipped: int
|
||||
errors: int
|
||||
duration_sec: float
|
||||
status_counts: dict[str, int]
|
||||
|
||||
|
||||
@router.post("/scrape/house-imv-backfill", response_model=HouseIMVBackfillResp)
|
||||
async def scrape_house_imv_backfill(
|
||||
payload: HouseIMVBackfillRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> HouseIMVBackfillResp:
|
||||
"""T7: Avito IMV bulk backfill на уровне домов (house_imv_evaluations).
|
||||
|
||||
Итерирует дома с imv_status='pending' (или 'transient_error'), вычисляет
|
||||
медианные параметры квартиры из связанных листингов, вызывает Avito IMV API,
|
||||
сохраняет результаты в house_imv_evaluations + house_placement_history +
|
||||
house_suggestions.
|
||||
|
||||
Resumable: дома помечаются imv_status='ok'/'not_found'/'error' после обработки.
|
||||
Idempotent: повторный запуск не дублирует данные (UPSERT on house_id).
|
||||
|
||||
batch_size: сколько домов обработать за запуск (default 50).
|
||||
request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV
|
||||
реагирует на частые запросы с datacenter-IP. Не снижать < 3s.
|
||||
only_status: по умолчанию 'pending'. Для retry failed — 'transient_error'.
|
||||
house_id: обработать один дом (debug).
|
||||
|
||||
Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию.
|
||||
settings.scraper_proxy_url прокидывается в неё автоматически через avito_imv.py
|
||||
(evaluate_via_imv создаёт сессию без явного proxy-параметра — сессия использует
|
||||
env HTTP_PROXY/HTTPS_PROXY если задан. При необходимости — добавить явный
|
||||
proxy-параметр в evaluate_via_imv / backfill_house_imv на уровне сервиса).
|
||||
"""
|
||||
from app.services.house_imv_backfill import (
|
||||
HouseIMVBackfillResult,
|
||||
backfill_house_imv,
|
||||
)
|
||||
|
||||
result: HouseIMVBackfillResult = await backfill_house_imv(
|
||||
db,
|
||||
batch_size=payload.batch_size,
|
||||
request_delay_sec=payload.request_delay_sec,
|
||||
only_status=payload.only_status,
|
||||
house_id=payload.house_id,
|
||||
)
|
||||
return HouseIMVBackfillResp(
|
||||
ok=True,
|
||||
checked=result.checked,
|
||||
saved=result.saved,
|
||||
skipped=result.skipped,
|
||||
errors=result.errors,
|
||||
duration_sec=round(result.duration_sec, 2),
|
||||
status_counts=result.status_counts,
|
||||
)
|
||||
|
|
|
|||
153
tradein-mvp/backend/app/services/cian_price_history.py
Normal file
153
tradein-mvp/backend/app/services/cian_price_history.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""T8a: Cian offer price-history backfill service.
|
||||
|
||||
Fetches Cian detail pages (curl_cffi, без Playwright) for listings that have
|
||||
no rows in offer_price_history, extracts priceChanges from
|
||||
_cianConfig['frontend-offer-card'] defaultState, writes to offer_price_history.
|
||||
|
||||
Deliberately NOT wired into scheduler — rate-limit risk with datacenter IPs.
|
||||
Triggered manually via POST /api/v1/admin/scrape/cian-price-history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
from app.services.scrapers.cian_detail import fetch_detail, save_detail_enrichment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CianPriceHistoryResult:
|
||||
checked: int = 0
|
||||
saved: int = 0
|
||||
skipped: int = 0
|
||||
errors: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
||||
async def backfill_cian_price_history(
|
||||
db: Session,
|
||||
*,
|
||||
batch_size: int = 50,
|
||||
listing_id: int | None = None,
|
||||
) -> CianPriceHistoryResult:
|
||||
"""Fetch Cian detail pages and write missing price-history rows.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session (caller-owned; commits internally per listing).
|
||||
batch_size: max listings to process when listing_id is None.
|
||||
listing_id: process a single specific listing (ignores batch_size).
|
||||
|
||||
Selection query picks cian listings with no existing offer_price_history rows.
|
||||
Idempotent: re-run safe via ON CONFLICT DO NOTHING in save_detail_enrichment.
|
||||
"""
|
||||
result = CianPriceHistoryResult()
|
||||
t0 = time.time()
|
||||
delay = get_scraper_delay("cian") # default 5.0s
|
||||
|
||||
if listing_id is not None:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT l.id, l.source_url
|
||||
FROM listings l
|
||||
WHERE l.id = CAST(:lid AS bigint)
|
||||
AND l.source = 'cian'
|
||||
AND l.source_url IS NOT NULL
|
||||
"""),
|
||||
{"lid": listing_id},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT l.id, l.source_url
|
||||
FROM listings l
|
||||
LEFT JOIN offer_price_history oph ON oph.listing_id = l.id
|
||||
WHERE l.source = 'cian'
|
||||
AND l.source_url IS NOT NULL
|
||||
AND oph.listing_id IS NULL
|
||||
ORDER BY l.id
|
||||
LIMIT :lim
|
||||
"""),
|
||||
{"lim": batch_size},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
result.checked = len(rows)
|
||||
logger.info(
|
||||
"cian_price_history backfill: checked=%d delay=%.1fs",
|
||||
result.checked,
|
||||
delay,
|
||||
)
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
lid: int = row["id"]
|
||||
url: str = row["source_url"]
|
||||
|
||||
try:
|
||||
enrichment = await fetch_detail(url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
||||
lid,
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
result.errors += 1
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
if enrichment is None:
|
||||
logger.warning(
|
||||
"cian_price_history: fetch returned None listing_id=%s url=%s",
|
||||
lid,
|
||||
url,
|
||||
)
|
||||
result.errors += 1
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
if not enrichment.price_changes:
|
||||
logger.debug("cian_price_history: no price_changes listing_id=%s", lid)
|
||||
result.skipped += 1
|
||||
else:
|
||||
try:
|
||||
save_detail_enrichment(db, lid, enrichment)
|
||||
result.saved += len(enrichment.price_changes)
|
||||
except Exception as exc:
|
||||
logger.warning("cian_price_history: save failed listing_id=%s: %s", lid, exc)
|
||||
result.errors += 1
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
if i < len(rows) - 1:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
result.duration_sec = time.time() - t0
|
||||
logger.info(
|
||||
"cian_price_history done: checked=%d saved=%d skipped=%d errors=%d %.1fs",
|
||||
result.checked,
|
||||
result.saved,
|
||||
result.skipped,
|
||||
result.errors,
|
||||
result.duration_sec,
|
||||
)
|
||||
return result
|
||||
504
tradein-mvp/backend/app/services/house_imv_backfill.py
Normal file
504
tradein-mvp/backend/app/services/house_imv_backfill.py
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
"""T7: House-level IMV backfill service.
|
||||
|
||||
Iterates houses with imv_status='pending' (or 'transient_error') that have
|
||||
lat/lon coordinates and at least one linked listing with rooms+area data.
|
||||
|
||||
For each house:
|
||||
1. Pick median lot-params from linked listings (rooms, area_m2, floor,
|
||||
total_floors, house_type).
|
||||
2. Enrich address with region prefix (prevents Avito geocoder ambiguity).
|
||||
3. Call avito_imv.evaluate_via_imv().
|
||||
4. Persist house_imv_evaluations + house_placement_history + house_suggestions.
|
||||
5. Mark houses.imv_status accordingly (ok/not_found/no_params/error/transient_error).
|
||||
|
||||
Resumable: re-running picks up from where the previous run stopped.
|
||||
Deliberately NOT wired into scheduler — triggered manually via admin API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.scrapers.avito_imv import (
|
||||
IMVAddressNotFoundError,
|
||||
IMVAuthError,
|
||||
IMVEvaluation,
|
||||
IMVTransientError,
|
||||
evaluate_via_imv,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── house_type normalisation ─────────────────────────────────────────────────
|
||||
|
||||
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
|
||||
"panel": "panel",
|
||||
"brick": "brick",
|
||||
"monolith": "monolithic",
|
||||
"monolithic": "monolithic",
|
||||
"monolith_brick": "monolithic", # Avito API не принимает гибриды
|
||||
"block": "block",
|
||||
"wood": "wood",
|
||||
}
|
||||
_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ
|
||||
|
||||
|
||||
def _map_house_type(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return _HOUSE_TYPE_DEFAULT
|
||||
return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT)
|
||||
|
||||
|
||||
# ── Region bbox prefix для Avito geocoder ────────────────────────────────────
|
||||
|
||||
_REGION_BBOX: list[tuple[str, float, float, float, float]] = [
|
||||
# (region_name, lat_min, lat_max, lon_min, lon_max)
|
||||
("Свердловская область, Екатеринбург", 56.5, 57.1, 59.9, 61.0),
|
||||
("Свердловская область", 56.0, 61.0, 58.0, 65.0),
|
||||
("Кировская область, Киров", 58.4, 58.8, 49.4, 49.9),
|
||||
("Ставропольский край, Ставрополь", 44.9, 45.2, 41.7, 42.2),
|
||||
("Тюменская область, Тюмень", 57.0, 57.3, 65.3, 65.8),
|
||||
("Челябинская область, Челябинск", 55.0, 55.4, 61.2, 61.7),
|
||||
]
|
||||
_REGION_DEFAULT = "Свердловская область, Екатеринбург"
|
||||
|
||||
|
||||
def _detect_region_prefix(lat: float | None, lon: float | None) -> str:
|
||||
if lat is None or lon is None:
|
||||
return _REGION_DEFAULT
|
||||
for name, lat_min, lat_max, lon_min, lon_max in _REGION_BBOX:
|
||||
if lat_min <= lat <= lat_max and lon_min <= lon <= lon_max:
|
||||
return name
|
||||
return _REGION_DEFAULT
|
||||
|
||||
|
||||
def _enrich_address_for_imv(raw: str, lat: float | None, lon: float | None) -> str:
|
||||
"""Prepend region prefix if address lacks a region marker."""
|
||||
addr = (raw or "").strip()
|
||||
addr_lower = addr.lower()
|
||||
if any(
|
||||
m in addr_lower for m in ("область,", " обл.,", " обл,", "край,", "респ.,", "республика,")
|
||||
):
|
||||
return addr
|
||||
prefix = _detect_region_prefix(lat, lon)
|
||||
return f"{prefix}, {addr}"
|
||||
|
||||
|
||||
# ── DB helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def pick_lot_params(db: Session, house_id: int) -> dict:
|
||||
"""Pick representative lot-params — median values from linked listings."""
|
||||
row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY rooms)
|
||||
AS integer) AS rooms,
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY area_m2)
|
||||
AS numeric(8,2)) AS area_m2,
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY floor)
|
||||
AS integer) AS floor,
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors)
|
||||
AS integer) AS total_floors,
|
||||
mode() WITHIN GROUP (ORDER BY house_type) AS house_type
|
||||
FROM listings
|
||||
WHERE house_id_fk = :hid
|
||||
AND rooms IS NOT NULL
|
||||
AND area_m2 IS NOT NULL
|
||||
"""),
|
||||
{"hid": house_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
if row is None or row["rooms"] is None:
|
||||
return {}
|
||||
|
||||
house = (
|
||||
db.execute(
|
||||
text("SELECT house_type, total_floors FROM houses WHERE id = :hid"),
|
||||
{"hid": house_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
rooms_raw = int(row["rooms"])
|
||||
return {
|
||||
"rooms": max(rooms_raw, 1), # Avito IMV отклоняет rooms=0 (студия)
|
||||
"area_m2": float(row["area_m2"]),
|
||||
"floor": int(row["floor"] or 1),
|
||||
"floor_at_home": int(row["total_floors"] or (house and house["total_floors"]) or 9),
|
||||
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])),
|
||||
"renovation_type": "cosmetic",
|
||||
"has_balcony": True,
|
||||
"has_loggia": False,
|
||||
}
|
||||
|
||||
|
||||
def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluation) -> None:
|
||||
"""Persist evaluation to house_imv_evaluations, house_placement_history, house_suggestions."""
|
||||
# 1. House-level evaluation (UPSERT on house_id)
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO house_imv_evaluations (
|
||||
house_id, cache_key, recommended_price, lower_price, higher_price,
|
||||
market_count, raw_response, fetched_at,
|
||||
rooms, area_m2, floor, floor_at_home, house_type,
|
||||
renovation_type, has_balcony, has_loggia
|
||||
) VALUES (
|
||||
:hid, :ck, :rec, :lo, :hi,
|
||||
:mc, CAST(:raw AS jsonb), NOW(),
|
||||
:rooms, CAST(:area AS numeric), :floor, :fah, :htype,
|
||||
:rtype, :balcony, :loggia
|
||||
)
|
||||
ON CONFLICT (house_id) DO UPDATE SET
|
||||
cache_key = EXCLUDED.cache_key,
|
||||
recommended_price = EXCLUDED.recommended_price,
|
||||
lower_price = EXCLUDED.lower_price,
|
||||
higher_price = EXCLUDED.higher_price,
|
||||
market_count = EXCLUDED.market_count,
|
||||
raw_response = EXCLUDED.raw_response,
|
||||
fetched_at = NOW(),
|
||||
rooms = EXCLUDED.rooms,
|
||||
area_m2 = EXCLUDED.area_m2,
|
||||
floor = EXCLUDED.floor,
|
||||
floor_at_home = EXCLUDED.floor_at_home,
|
||||
house_type = EXCLUDED.house_type,
|
||||
renovation_type = EXCLUDED.renovation_type,
|
||||
has_balcony = EXCLUDED.has_balcony,
|
||||
has_loggia = EXCLUDED.has_loggia
|
||||
"""),
|
||||
{
|
||||
"hid": house_id,
|
||||
"ck": result.cache_key,
|
||||
"rec": result.recommended_price,
|
||||
"lo": result.lower_price,
|
||||
"hi": result.higher_price,
|
||||
"mc": result.market_count,
|
||||
"raw": (
|
||||
json.dumps(result.raw_response, ensure_ascii=False) if result.raw_response else None
|
||||
),
|
||||
"rooms": params["rooms"],
|
||||
"area": params["area_m2"],
|
||||
"floor": params["floor"],
|
||||
"fah": params["floor_at_home"],
|
||||
"htype": params["house_type"],
|
||||
"rtype": params["renovation_type"],
|
||||
"balcony": params["has_balcony"],
|
||||
"loggia": params["has_loggia"],
|
||||
},
|
||||
)
|
||||
|
||||
# 2. Placement history items
|
||||
for item in result.placement_history:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO house_placement_history (
|
||||
source, house_id, ext_item_id, title, rooms, area_m2,
|
||||
floor, total_floors, start_price, start_price_date,
|
||||
last_price, last_price_date, removed_date, exposure_days,
|
||||
raw_payload, scraped_at
|
||||
) VALUES (
|
||||
'avito_imv', :hid, :ext, :title, :rooms, CAST(:area AS numeric),
|
||||
:floor, :total_floors, :sp, :spd, :lp, :lpd, :rmd, :exp,
|
||||
CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (source, ext_item_id) DO UPDATE SET
|
||||
house_id = EXCLUDED.house_id,
|
||||
title = EXCLUDED.title,
|
||||
rooms = EXCLUDED.rooms,
|
||||
area_m2 = EXCLUDED.area_m2,
|
||||
floor = EXCLUDED.floor,
|
||||
total_floors = EXCLUDED.total_floors,
|
||||
start_price = EXCLUDED.start_price,
|
||||
start_price_date = EXCLUDED.start_price_date,
|
||||
last_price = EXCLUDED.last_price,
|
||||
last_price_date = EXCLUDED.last_price_date,
|
||||
removed_date = EXCLUDED.removed_date,
|
||||
exposure_days = EXCLUDED.exposure_days,
|
||||
raw_payload = EXCLUDED.raw_payload,
|
||||
scraped_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"hid": house_id,
|
||||
"ext": item.ext_item_id,
|
||||
"title": item.title,
|
||||
"rooms": item.rooms,
|
||||
"area": item.area_m2,
|
||||
"floor": item.floor,
|
||||
"total_floors": item.total_floors,
|
||||
"sp": item.start_price,
|
||||
"spd": item.start_price_date,
|
||||
"lp": item.last_price,
|
||||
"lpd": item.last_price_date,
|
||||
"rmd": item.removed_date,
|
||||
"exp": item.exposure_days,
|
||||
"raw": (
|
||||
json.dumps(item.raw_payload, ensure_ascii=False) if item.raw_payload else None
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# 3. Suggestions
|
||||
for sug in result.suggestions:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO house_suggestions (
|
||||
house_id, ext_item_id, title, address, price_rub,
|
||||
exposure_days, publish_date,
|
||||
item_link, metro_name, metro_distance,
|
||||
has_good_price_badge, raw_payload, fetched_at
|
||||
) VALUES (
|
||||
:hid, :ext, :title, :addr, :price,
|
||||
:exp, :pdate,
|
||||
:link, :mname, :mdist,
|
||||
:gpb, CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (house_id, ext_item_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
price_rub = EXCLUDED.price_rub,
|
||||
exposure_days = EXCLUDED.exposure_days,
|
||||
publish_date = EXCLUDED.publish_date,
|
||||
item_link = EXCLUDED.item_link,
|
||||
metro_name = EXCLUDED.metro_name,
|
||||
metro_distance = EXCLUDED.metro_distance,
|
||||
has_good_price_badge = EXCLUDED.has_good_price_badge,
|
||||
raw_payload = EXCLUDED.raw_payload,
|
||||
fetched_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"hid": house_id,
|
||||
"ext": sug.ext_item_id,
|
||||
"title": sug.title,
|
||||
"addr": sug.address,
|
||||
"price": sug.price_rub,
|
||||
"exp": sug.exposure_days,
|
||||
"pdate": sug.publish_date,
|
||||
"link": sug.item_url,
|
||||
"mname": sug.metro_name,
|
||||
"mdist": sug.metro_distance,
|
||||
"gpb": sug.has_good_price_badge,
|
||||
"raw": (
|
||||
json.dumps(sug.raw_payload, ensure_ascii=False) if sug.raw_payload else None
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# 4. Mark house success
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE houses
|
||||
SET imv_status = 'ok',
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = NULL
|
||||
WHERE id = :hid
|
||||
"""),
|
||||
{"hid": house_id},
|
||||
)
|
||||
|
||||
|
||||
def _mark_status(
|
||||
db: Session,
|
||||
house_id: int,
|
||||
status: str,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE houses
|
||||
SET imv_status = :s,
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = :r
|
||||
WHERE id = :hid
|
||||
"""),
|
||||
{"hid": house_id, "s": status, "r": reason},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Top-level orchestrator ────────────────────────────────────────────────────
|
||||
|
||||
_IMVStatus = Literal[
|
||||
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error"
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HouseIMVBackfillResult:
|
||||
checked: int = 0
|
||||
saved: int = 0
|
||||
skipped: int = 0
|
||||
errors: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
status_counts: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
async def backfill_house_imv(
|
||||
db: Session,
|
||||
*,
|
||||
batch_size: int = 50,
|
||||
request_delay_sec: float = 5.0,
|
||||
only_status: str = "pending",
|
||||
house_id: int | None = None,
|
||||
) -> HouseIMVBackfillResult:
|
||||
"""Run Avito IMV evaluation for each house in scope, save results.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session.
|
||||
batch_size: max houses to process (ignored when house_id given).
|
||||
request_delay_sec: sleep between Avito API calls (default 5s — anti-bot).
|
||||
only_status: process houses with this imv_status (default 'pending').
|
||||
Use 'transient_error' to retry failures.
|
||||
house_id: process a single specific house (debug).
|
||||
|
||||
Returns:
|
||||
HouseIMVBackfillResult with aggregate counters.
|
||||
"""
|
||||
result = HouseIMVBackfillResult()
|
||||
t0 = time.time()
|
||||
|
||||
if house_id is not None:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE id = CAST(:hid AS bigint)
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
"""),
|
||||
{"hid": house_id},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE imv_status = :status
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND address IS NOT NULL
|
||||
ORDER BY last_imv_attempt_at NULLS FIRST, id
|
||||
LIMIT :batch
|
||||
"""),
|
||||
{"status": only_status, "batch": batch_size},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
result.checked = len(rows)
|
||||
if not rows:
|
||||
logger.info("house_imv_backfill: nothing to process (status=%r)", only_status)
|
||||
result.duration_sec = time.time() - t0
|
||||
return result
|
||||
|
||||
logger.info(
|
||||
"house_imv_backfill: %d houses (status=%r delay=%.1fs)",
|
||||
result.checked,
|
||||
only_status,
|
||||
request_delay_sec,
|
||||
)
|
||||
|
||||
for i, house in enumerate(rows):
|
||||
hid: int = house["id"]
|
||||
status_str = await _process_one_house(db, dict(house))
|
||||
|
||||
result.status_counts[status_str] = result.status_counts.get(status_str, 0) + 1
|
||||
if status_str == "ok":
|
||||
result.saved += 1
|
||||
elif status_str in ("no_params", "no_address"):
|
||||
result.skipped += 1
|
||||
else:
|
||||
result.errors += 1
|
||||
|
||||
logger.info(
|
||||
"[%d/%d] house_id=%d status=%s",
|
||||
i + 1,
|
||||
result.checked,
|
||||
hid,
|
||||
status_str,
|
||||
)
|
||||
|
||||
if i < len(rows) - 1:
|
||||
if status_str == "auth_error":
|
||||
logger.warning("IMV auth error — extra delay 60s before next house")
|
||||
await asyncio.sleep(60)
|
||||
else:
|
||||
await asyncio.sleep(request_delay_sec)
|
||||
|
||||
result.duration_sec = time.time() - t0
|
||||
logger.info(
|
||||
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s",
|
||||
result.checked,
|
||||
result.saved,
|
||||
result.skipped,
|
||||
result.errors,
|
||||
result.duration_sec,
|
||||
result.status_counts,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _process_one_house(db: Session, house: dict) -> str:
|
||||
"""Process a single house. Returns final status string."""
|
||||
hid: int = house["id"]
|
||||
|
||||
params = pick_lot_params(db, hid)
|
||||
if not params:
|
||||
_mark_status(db, hid, "no_params", "no listings with rooms+area")
|
||||
return "no_params"
|
||||
|
||||
address = house.get("address") or house.get("full_address")
|
||||
if not address:
|
||||
_mark_status(db, hid, "no_address", "house.address is NULL")
|
||||
return "no_address"
|
||||
|
||||
enriched = _enrich_address_for_imv(address, house.get("lat"), house.get("lon"))
|
||||
if enriched != address:
|
||||
logger.debug("house_imv: enriched address house=%d %r -> %r", hid, address, enriched)
|
||||
|
||||
try:
|
||||
eval_result = await evaluate_via_imv(address=enriched, **params)
|
||||
except IMVAddressNotFoundError as exc:
|
||||
_mark_status(db, hid, "not_found", str(exc)[:200])
|
||||
return "not_found"
|
||||
except IMVAuthError as exc:
|
||||
_mark_status(db, hid, "transient_error", f"auth: {exc!s}"[:200])
|
||||
return "auth_error"
|
||||
except IMVTransientError as exc:
|
||||
_mark_status(db, hid, "transient_error", str(exc)[:200])
|
||||
return "transient"
|
||||
except Exception as exc:
|
||||
_mark_status(db, hid, "error", repr(exc)[:200])
|
||||
logger.error("house_imv: unexpected error house=%d: %r", hid, exc)
|
||||
return "error"
|
||||
|
||||
try:
|
||||
save_imv_result(db, hid, params, eval_result)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.error("house_imv: save failed house=%d: %r", hid, exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
_mark_status(db, hid, "error", f"save_failed: {exc!s}"[:200])
|
||||
return "error"
|
||||
|
||||
return "ok"
|
||||
218
tradein-mvp/backend/app/services/yandex_address_backfill.py
Normal file
218
tradein-mvp/backend/app/services/yandex_address_backfill.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""T10: Yandex listing address backfill from detail-page <title>.
|
||||
|
||||
Yandex SERP cards contain only street-level addresses (e.g. «улица Горького»).
|
||||
The offer detail page <title> has the full address including house number:
|
||||
«Екатеринбург, улица Горького, 36 — id 12345».
|
||||
|
||||
This service finds active yandex listings without a house number in `address`,
|
||||
fetches each detail page via curl_cffi(chrome120), extracts the full address
|
||||
from the <title> regex, and UPDATE listings.address.
|
||||
|
||||
Deliberately NOT wired into scheduler — manual trigger only.
|
||||
Triggered via POST /api/v1/admin/scrape/yandex-address-backfill.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from curl_cffi.requests import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Extract 'Екатеринбург, <full address>' from <title> — matches:
|
||||
# «Продажа квартиры — Екатеринбург, улица Горького, 36 — id 7654321 на Яндекс.Недвижимости»
|
||||
# Group 1 = «Екатеринбург, улица Горького, 36» (stripped of trailing punctuation/spaces)
|
||||
_RE_TITLE_ADDRESS = re.compile(
|
||||
r"<title>[^<]*?—\s*"
|
||||
r"(?:[^—,]*?,\s*)?" # optional ЖК/жилой комплекс prefix
|
||||
r"(Екатеринбург,\s*[^<—]+?)"
|
||||
r"\s*—\s*id\s*\d+[^<]*</title>", # allow trailing text after id NNN (e.g. 'на Яндекс...')
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Listings without a house number: address contains no digit after comma-space
|
||||
# (e.g. «улица Горького» but not «улица Горького, 36»).
|
||||
# This regex matches a number right after «, » (house number pattern).
|
||||
_RE_HAS_HOUSE_NUMBER = re.compile(r",\s*\d+")
|
||||
|
||||
|
||||
@dataclass
|
||||
class YandexAddressBackfillResult:
|
||||
checked: int = 0
|
||||
saved: int = 0
|
||||
skipped: int = 0
|
||||
errors: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
||||
def _extract_address_from_title(html: str) -> str | None:
|
||||
"""Extract full address from Yandex offer detail page <title>."""
|
||||
html_clean = html.replace("\xa0", " ")
|
||||
m = _RE_TITLE_ADDRESS.search(html_clean)
|
||||
if not m:
|
||||
return None
|
||||
addr = m.group(1).strip().rstrip(",").strip()
|
||||
return addr if addr else None
|
||||
|
||||
|
||||
def _eligible_listings(db: Session, limit: int) -> list[dict]:
|
||||
"""Active EKB yandex listings without house number in address."""
|
||||
rows = (
|
||||
db.execute(
|
||||
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
|
||||
LIMIT :lim
|
||||
"""),
|
||||
{"lim": limit},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def backfill_yandex_addresses(
|
||||
db: Session,
|
||||
*,
|
||||
limit: int = 200,
|
||||
request_delay_sec: float = 3.0,
|
||||
) -> YandexAddressBackfillResult:
|
||||
"""Fetch Yandex detail pages and update address with full street+house number.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session.
|
||||
limit: max listings to process.
|
||||
request_delay_sec: sleep between requests (anti-bot; default 3.0s).
|
||||
|
||||
Returns:
|
||||
YandexAddressBackfillResult with checked/saved/skipped/errors counters.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
result = YandexAddressBackfillResult()
|
||||
t0 = time.time()
|
||||
|
||||
items = _eligible_listings(db, limit)
|
||||
result.checked = len(items)
|
||||
|
||||
if not items:
|
||||
logger.info("yandex_address_backfill: no eligible listings")
|
||||
result.duration_sec = time.time() - t0
|
||||
return result
|
||||
|
||||
logger.info(
|
||||
"yandex_address_backfill: %d listings, delay=%.1fs",
|
||||
result.checked,
|
||||
request_delay_sec,
|
||||
)
|
||||
|
||||
_proxy_url = settings.scraper_proxy_url
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
|
||||
async with AsyncSession(
|
||||
impersonate="chrome120",
|
||||
timeout=30.0,
|
||||
proxies=_proxies,
|
||||
headers={
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
},
|
||||
) as session:
|
||||
for i, item in enumerate(items):
|
||||
lid: int = item["id"]
|
||||
old_addr: str = item["address"]
|
||||
url: str = item["source_url"]
|
||||
|
||||
try:
|
||||
resp = await session.get(url, allow_redirects=True)
|
||||
except Exception as exc:
|
||||
logger.warning("yandex_address_backfill: fetch failed listing_id=%s: %s", lid, exc)
|
||||
result.errors += 1
|
||||
await asyncio.sleep(request_delay_sec)
|
||||
continue
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"yandex_address_backfill: HTTP %d listing_id=%s", resp.status_code, lid
|
||||
)
|
||||
result.errors += 1
|
||||
await asyncio.sleep(request_delay_sec)
|
||||
continue
|
||||
|
||||
new_addr = _extract_address_from_title(resp.text)
|
||||
|
||||
if new_addr is None:
|
||||
logger.debug("yandex_address_backfill: no address extracted listing_id=%s", lid)
|
||||
result.skipped += 1
|
||||
elif new_addr == old_addr:
|
||||
logger.debug(
|
||||
"yandex_address_backfill: unchanged listing_id=%s addr=%r", lid, old_addr
|
||||
)
|
||||
result.skipped += 1
|
||||
elif not _RE_HAS_HOUSE_NUMBER.search(new_addr):
|
||||
# Extracted address still has no house number — skip to avoid overwrite
|
||||
# with another street-only value.
|
||||
logger.debug(
|
||||
"yandex_address_backfill: still no house number listing_id=%s addr=%r",
|
||||
lid,
|
||||
new_addr,
|
||||
)
|
||||
result.skipped += 1
|
||||
else:
|
||||
logger.info(
|
||||
"yandex_address_backfill: updating listing_id=%s %r -> %r",
|
||||
lid,
|
||||
old_addr,
|
||||
new_addr,
|
||||
)
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE listings
|
||||
SET address = :addr,
|
||||
geocode_tried_at = NULL
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
"""),
|
||||
{"addr": new_addr, "id": lid},
|
||||
)
|
||||
db.commit()
|
||||
result.saved += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"yandex_address_backfill: DB update failed listing_id=%s: %s",
|
||||
lid,
|
||||
exc,
|
||||
)
|
||||
result.errors += 1
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if i < len(items) - 1:
|
||||
await asyncio.sleep(request_delay_sec)
|
||||
|
||||
result.duration_sec = time.time() - t0
|
||||
logger.info(
|
||||
"yandex_address_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs",
|
||||
result.checked,
|
||||
result.saved,
|
||||
result.skipped,
|
||||
result.errors,
|
||||
result.duration_sec,
|
||||
)
|
||||
return result
|
||||
563
tradein-mvp/backend/tests/test_backfill_wave2.py
Normal file
563
tradein-mvp/backend/tests/test_backfill_wave2.py
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
"""Unit tests for T7/T8a/T10 backfill services (wave-2).
|
||||
|
||||
All tests are offline — no DB, no network required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ============================================================================
|
||||
# T10 — yandex_address_backfill: _extract_address_from_title
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestYandexTitleExtract:
|
||||
"""Tests for _extract_address_from_title helper."""
|
||||
|
||||
def _extract(self, html: str) -> str | None:
|
||||
from app.services.yandex_address_backfill import _extract_address_from_title
|
||||
|
||||
return _extract_address_from_title(html)
|
||||
|
||||
def test_extracts_full_address_standard(self):
|
||||
html = (
|
||||
"<title>Продажа квартиры — Екатеринбург, улица Горького, 36 — id 7654321 "
|
||||
"на Яндекс.Недвижимости</title>"
|
||||
)
|
||||
result = self._extract(html)
|
||||
assert result == "Екатеринбург, улица Горького, 36"
|
||||
|
||||
def test_extracts_address_with_corpus(self):
|
||||
html = (
|
||||
"<title>Продажа квартиры — Екатеринбург, улица Ленина, 52/1 — id 9999 "
|
||||
"на Яндекс.Недвижимости</title>"
|
||||
)
|
||||
result = self._extract(html)
|
||||
assert result == "Екатеринбург, улица Ленина, 52/1"
|
||||
|
||||
def test_returns_none_when_no_id_marker(self):
|
||||
html = "<title>Продажа квартиры — Екатеринбург, улица Ленина, 52/1</title>"
|
||||
result = self._extract(html)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_no_title(self):
|
||||
html = "<html><body>No title here</body></html>"
|
||||
result = self._extract(html)
|
||||
assert result is None
|
||||
|
||||
def test_strips_trailing_punctuation(self):
|
||||
html = (
|
||||
"<title>Купить квартиру — Екатеринбург, улица Гагарина, 11, — id 111 "
|
||||
"на Яндекс.Недвижимости</title>"
|
||||
)
|
||||
result = self._extract(html)
|
||||
# should strip trailing comma
|
||||
assert result is not None
|
||||
assert not result.endswith(",")
|
||||
|
||||
def test_nbsp_replaced(self):
|
||||
html = (
|
||||
"<title>— Екатеринбург,\xa0улица Горького, 36 — id 12 " "на Яндекс.Недвижимости</title>"
|
||||
)
|
||||
result = self._extract(html)
|
||||
assert result is not None
|
||||
assert "\xa0" not in result
|
||||
|
||||
def test_case_insensitive_city(self):
|
||||
html = (
|
||||
"<title>— ЕКАТЕРИНБУРГ, улица Горького, 36 — id 777 " "на Яндекс.Недвижимости</title>"
|
||||
)
|
||||
# regex is case-insensitive; city name retains original case
|
||||
result = self._extract(html)
|
||||
assert result is not None
|
||||
assert "Горького, 36" in result
|
||||
|
||||
|
||||
class TestYandexHouseNumberRegex:
|
||||
def test_no_house_number_pattern(self):
|
||||
from app.services.yandex_address_backfill import _RE_HAS_HOUSE_NUMBER
|
||||
|
||||
assert not _RE_HAS_HOUSE_NUMBER.search("улица Горького")
|
||||
assert not _RE_HAS_HOUSE_NUMBER.search("Екатеринбург, улица Горького")
|
||||
|
||||
def test_has_house_number_pattern(self):
|
||||
from app.services.yandex_address_backfill import _RE_HAS_HOUSE_NUMBER
|
||||
|
||||
assert _RE_HAS_HOUSE_NUMBER.search("Екатеринбург, улица Горького, 36")
|
||||
assert _RE_HAS_HOUSE_NUMBER.search("улица Ленина, 52/1")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# T10 — backfill_yandex_addresses: integration (mocked)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_yandex_addresses_saves_enriched():
|
||||
"""Happy path: fetch returns full address → saved=1."""
|
||||
from app.services.yandex_address_backfill import backfill_yandex_addresses
|
||||
|
||||
html_with_addr = (
|
||||
"<title>Продажа — Екатеринбург, улица Горького, 36 — id 11 "
|
||||
"на Яндекс.Недвижимости</title>"
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = html_with_addr
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.execute = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": 42,
|
||||
"source_id": "yandex-42",
|
||||
"address": "улица Горького",
|
||||
"source_url": "https://realty.yandex.ru/offer/11/",
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.yandex_address_backfill._eligible_listings",
|
||||
return_value=items,
|
||||
),
|
||||
patch(
|
||||
"app.services.yandex_address_backfill.AsyncSession",
|
||||
return_value=mock_session,
|
||||
),
|
||||
):
|
||||
result = await backfill_yandex_addresses(mock_db, limit=10)
|
||||
|
||||
assert result.checked == 1
|
||||
assert result.saved == 1
|
||||
assert result.errors == 0
|
||||
assert result.skipped == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_yandex_addresses_skips_no_house_number():
|
||||
"""Address extracted but still has no house number → skipped."""
|
||||
from app.services.yandex_address_backfill import backfill_yandex_addresses
|
||||
|
||||
# Title without house number in extracted part
|
||||
html = "<title>— Екатеринбург, улица Горького — id 22 на Яндекс.Недвижимости</title>"
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = html
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": 99,
|
||||
"source_id": "y-99",
|
||||
"address": "улица Горького",
|
||||
"source_url": "https://realty.yandex.ru/offer/22/",
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.yandex_address_backfill._eligible_listings",
|
||||
return_value=items,
|
||||
),
|
||||
patch(
|
||||
"app.services.yandex_address_backfill.AsyncSession",
|
||||
return_value=mock_session,
|
||||
),
|
||||
):
|
||||
result = await backfill_yandex_addresses(MagicMock(), limit=10)
|
||||
|
||||
assert result.skipped == 1
|
||||
assert result.saved == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_yandex_addresses_error_on_non_200():
|
||||
"""Non-200 response → errors counter incremented, no crash."""
|
||||
from app.services.yandex_address_backfill import backfill_yandex_addresses
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
mock_resp.text = ""
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": 55,
|
||||
"source_id": "y-55",
|
||||
"address": "улица Горького",
|
||||
"source_url": "https://realty.yandex.ru/offer/55/",
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.yandex_address_backfill._eligible_listings",
|
||||
return_value=items,
|
||||
),
|
||||
patch(
|
||||
"app.services.yandex_address_backfill.AsyncSession",
|
||||
return_value=mock_session,
|
||||
),
|
||||
):
|
||||
result = await backfill_yandex_addresses(MagicMock(), limit=10)
|
||||
|
||||
assert result.errors == 1
|
||||
assert result.saved == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# T8a — cian_price_history: backfill_cian_price_history
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_price_history_saves_changes():
|
||||
"""Happy path: fetch returns DetailEnrichment with price_changes → saved."""
|
||||
from app.services.cian_price_history import backfill_cian_price_history
|
||||
from app.services.scrapers.cian_detail import DetailEnrichment
|
||||
|
||||
enrichment = DetailEnrichment(
|
||||
cian_id=123456,
|
||||
price_changes=[
|
||||
{"change_time": "2026-04-01T00:00:00Z", "price_rub": 5000000, "diff_percent": None},
|
||||
{"change_time": "2026-04-15T00:00:00Z", "price_rub": 4900000, "diff_percent": -2.0},
|
||||
],
|
||||
)
|
||||
|
||||
mock_db = MagicMock()
|
||||
# Simulate listings rows returned by SELECT
|
||||
rows = [{"id": 10, "source_url": "https://ekb.cian.ru/sale/flat/123456/"}]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.cian_price_history.fetch_detail",
|
||||
new_callable=AsyncMock,
|
||||
return_value=enrichment,
|
||||
) as mock_fetch,
|
||||
patch("app.services.cian_price_history.save_detail_enrichment") as mock_save,
|
||||
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = rows
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_cian_price_history(mock_db, batch_size=10)
|
||||
|
||||
assert result.checked == 1
|
||||
assert result.saved == 2 # 2 price_changes
|
||||
assert result.errors == 0
|
||||
mock_fetch.assert_called_once()
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_price_history_skips_no_changes():
|
||||
"""Fetch succeeds but no price_changes → skipped (not error)."""
|
||||
from app.services.cian_price_history import backfill_cian_price_history
|
||||
from app.services.scrapers.cian_detail import DetailEnrichment
|
||||
|
||||
enrichment = DetailEnrichment(cian_id=111, price_changes=[])
|
||||
|
||||
mock_db = MagicMock()
|
||||
rows = [{"id": 20, "source_url": "https://ekb.cian.ru/sale/flat/111/"}]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.cian_price_history.fetch_detail",
|
||||
new_callable=AsyncMock,
|
||||
return_value=enrichment,
|
||||
),
|
||||
patch("app.services.cian_price_history.save_detail_enrichment") as mock_save,
|
||||
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = rows
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_cian_price_history(mock_db, batch_size=10)
|
||||
|
||||
assert result.skipped == 1
|
||||
assert result.saved == 0
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cian_price_history_handles_fetch_none():
|
||||
"""fetch_detail returns None → errors+1, no crash."""
|
||||
from app.services.cian_price_history import backfill_cian_price_history
|
||||
|
||||
mock_db = MagicMock()
|
||||
rows = [{"id": 30, "source_url": "https://ekb.cian.ru/sale/flat/999/"}]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.cian_price_history.fetch_detail",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = rows
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_cian_price_history(mock_db, batch_size=10)
|
||||
|
||||
assert result.errors == 1
|
||||
assert result.saved == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# T7 — house_imv_backfill: helpers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestHouseTypeMap:
|
||||
def test_known_types(self):
|
||||
from app.services.house_imv_backfill import _map_house_type
|
||||
|
||||
assert _map_house_type("panel") == "panel"
|
||||
assert _map_house_type("brick") == "brick"
|
||||
assert _map_house_type("monolith") == "monolithic"
|
||||
assert _map_house_type("monolith_brick") == "monolithic"
|
||||
assert _map_house_type("monolithic") == "monolithic"
|
||||
assert _map_house_type("block") == "block"
|
||||
assert _map_house_type("wood") == "wood"
|
||||
|
||||
def test_unknown_falls_back_to_panel(self):
|
||||
from app.services.house_imv_backfill import _map_house_type
|
||||
|
||||
assert _map_house_type("unknown_type") == "panel"
|
||||
assert _map_house_type(None) == "panel"
|
||||
assert _map_house_type("") == "panel"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
from app.services.house_imv_backfill import _map_house_type
|
||||
|
||||
assert _map_house_type("PANEL") == "panel"
|
||||
assert _map_house_type("Brick") == "brick"
|
||||
assert _map_house_type("MONOLITH_BRICK") == "monolithic"
|
||||
|
||||
|
||||
class TestRegionPrefix:
|
||||
def test_ekb_coords(self):
|
||||
from app.services.house_imv_backfill import _detect_region_prefix
|
||||
|
||||
prefix = _detect_region_prefix(56.8296, 60.6001)
|
||||
assert prefix == "Свердловская область, Екатеринбург"
|
||||
|
||||
def test_none_coords_default(self):
|
||||
from app.services.house_imv_backfill import _detect_region_prefix
|
||||
|
||||
prefix = _detect_region_prefix(None, None)
|
||||
assert prefix == "Свердловская область, Екатеринбург"
|
||||
|
||||
def test_out_of_any_region_default(self):
|
||||
from app.services.house_imv_backfill import _detect_region_prefix
|
||||
|
||||
# Moscow coords — not in any bbox → default EKB
|
||||
prefix = _detect_region_prefix(55.75, 37.6)
|
||||
assert prefix == "Свердловская область, Екатеринбург"
|
||||
|
||||
|
||||
class TestEnrichAddress:
|
||||
def test_prepends_region_when_no_marker(self):
|
||||
from app.services.house_imv_backfill import _enrich_address_for_imv
|
||||
|
||||
result = _enrich_address_for_imv("улица Горького, 36", 56.83, 60.60)
|
||||
assert result.startswith("Свердловская область, Екатеринбург,")
|
||||
assert "улица Горького, 36" in result
|
||||
|
||||
def test_does_not_prepend_when_region_already_present(self):
|
||||
from app.services.house_imv_backfill import _enrich_address_for_imv
|
||||
|
||||
addr = "Свердловская область, Екатеринбург, улица Горького, 36"
|
||||
result = _enrich_address_for_imv(addr, 56.83, 60.60)
|
||||
assert result == addr
|
||||
|
||||
def test_does_not_prepend_when_kraj_present(self):
|
||||
from app.services.house_imv_backfill import _enrich_address_for_imv
|
||||
|
||||
addr = "Ставропольский край, Ставрополь, улица Ленина, 1"
|
||||
result = _enrich_address_for_imv(addr, 45.05, 41.97)
|
||||
assert result == addr
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# T7 — backfill_house_imv: integration (mocked)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_house_imv_ok_path():
|
||||
"""Happy path: picks params, calls IMV, saves → saved=1."""
|
||||
from app.services.house_imv_backfill import backfill_house_imv
|
||||
from app.services.scrapers.avito_imv import IMVEvaluation, IMVGeo
|
||||
|
||||
fake_geo = IMVGeo(geo_hash="tok", lat=56.83, lon=60.60)
|
||||
fake_eval = IMVEvaluation(
|
||||
cache_key="abc123",
|
||||
address="ЕКБ ул. Горького, 36",
|
||||
rooms=2,
|
||||
area_m2=52.0,
|
||||
floor=3,
|
||||
floor_at_home=10,
|
||||
house_type="panel",
|
||||
renovation_type="cosmetic",
|
||||
has_balcony=True,
|
||||
has_loggia=False,
|
||||
geo=fake_geo,
|
||||
recommended_price=5_000_000,
|
||||
lower_price=4_500_000,
|
||||
higher_price=5_500_000,
|
||||
market_count=24,
|
||||
placement_history=[],
|
||||
suggestions=[],
|
||||
)
|
||||
|
||||
fake_params = {
|
||||
"rooms": 2,
|
||||
"area_m2": 52.0,
|
||||
"floor": 3,
|
||||
"floor_at_home": 10,
|
||||
"house_type": "panel",
|
||||
"renovation_type": "cosmetic",
|
||||
"has_balcony": True,
|
||||
"has_loggia": False,
|
||||
}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
houses = [
|
||||
{"id": 7, "address": "улица Горького, 36", "full_address": None, "lat": 56.83, "lon": 60.60}
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.house_imv_backfill.pick_lot_params",
|
||||
return_value=fake_params,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.evaluate_via_imv",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_eval,
|
||||
),
|
||||
patch("app.services.house_imv_backfill.save_imv_result") as mock_save,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
||||
assert result.checked == 1
|
||||
assert result.saved == 1
|
||||
assert result.errors == 0
|
||||
assert result.status_counts.get("ok") == 1
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_house_imv_no_params():
|
||||
"""pick_lot_params returns {} → skipped (no_params status)."""
|
||||
from app.services.house_imv_backfill import backfill_house_imv
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.execute = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
houses = [
|
||||
{"id": 8, "address": "улица Ленина, 1", "full_address": None, "lat": 56.83, "lon": 60.60}
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.house_imv_backfill.pick_lot_params",
|
||||
return_value={},
|
||||
),
|
||||
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
||||
assert result.skipped == 1
|
||||
assert result.saved == 0
|
||||
mock_mark.assert_called_once_with(mock_db, 8, "no_params", "no listings with rooms+area")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_house_imv_not_found():
|
||||
"""IMVAddressNotFoundError → status not_found, no crash."""
|
||||
from app.services.house_imv_backfill import backfill_house_imv
|
||||
from app.services.scrapers.avito_imv import IMVAddressNotFoundError
|
||||
|
||||
fake_params = {
|
||||
"rooms": 2,
|
||||
"area_m2": 52.0,
|
||||
"floor": 3,
|
||||
"floor_at_home": 10,
|
||||
"house_type": "panel",
|
||||
"renovation_type": "cosmetic",
|
||||
"has_balcony": True,
|
||||
"has_loggia": False,
|
||||
}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
houses = [
|
||||
{
|
||||
"id": 9,
|
||||
"address": "несуществующий адрес",
|
||||
"full_address": None,
|
||||
"lat": 56.83,
|
||||
"lon": 60.60,
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.house_imv_backfill.pick_lot_params",
|
||||
return_value=fake_params,
|
||||
),
|
||||
patch(
|
||||
"app.services.house_imv_backfill.evaluate_via_imv",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=IMVAddressNotFoundError("not found"),
|
||||
),
|
||||
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
||||
assert result.errors == 1
|
||||
assert result.saved == 0
|
||||
mock_mark.assert_called_once_with(mock_db, 9, "not_found", "not found")
|
||||
Loading…
Add table
Reference in a new issue