feat(tradein-imv): retry with cleaned address + categorize errors (#505)
This commit is contained in:
parent
099b8e4b59
commit
7f23a9b3f1
3 changed files with 168 additions and 4 deletions
|
|
@ -22,6 +22,7 @@ from __future__ import annotations
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
@ -34,7 +35,9 @@ from app.services.geocoder import GeocodeResult, geocode
|
||||||
from app.services.house_metadata import get_house_metadata
|
from app.services.house_metadata import get_house_metadata
|
||||||
from app.services.scrapers.avito_imv import (
|
from app.services.scrapers.avito_imv import (
|
||||||
IMVAddressNotFoundError,
|
IMVAddressNotFoundError,
|
||||||
|
IMVAuthError,
|
||||||
IMVEvaluation,
|
IMVEvaluation,
|
||||||
|
IMVTransientError,
|
||||||
compute_imv_cache_key,
|
compute_imv_cache_key,
|
||||||
evaluate_via_imv,
|
evaluate_via_imv,
|
||||||
save_imv_evaluation,
|
save_imv_evaluation,
|
||||||
|
|
@ -105,6 +108,13 @@ def _repair_coefficient(repair_state: str | None) -> float:
|
||||||
# ── Avito IMV cache lookup (Stage 3) ────────────────────────────────────────
|
# ── Avito IMV cache lookup (Stage 3) ────────────────────────────────────────
|
||||||
IMV_CACHE_TTL_HOURS = 24
|
IMV_CACHE_TTL_HOURS = 24
|
||||||
|
|
||||||
|
# Префиксы в адресе, которые Avito-геокодер не распознаёт (не жилые назначения).
|
||||||
|
# Пример: "Склад, ул. Заводская, д. 44-а" → "ул. Заводская, д. 44-а"
|
||||||
|
_NOISE_PREFIX_RE = re.compile(
|
||||||
|
r"(Склад|Гараж|Подсобка|Нежилое|Помещение|Цех),\s*",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
YANDEX_VALUATION_CACHE_TTL_HOURS = 24
|
YANDEX_VALUATION_CACHE_TTL_HOURS = 24
|
||||||
YANDEX_VALUATION_DEFAULT_CATEGORY = "APARTMENT"
|
YANDEX_VALUATION_DEFAULT_CATEGORY = "APARTMENT"
|
||||||
YANDEX_VALUATION_DEFAULT_TYPE = "SELL"
|
YANDEX_VALUATION_DEFAULT_TYPE = "SELL"
|
||||||
|
|
@ -209,6 +219,47 @@ async def _get_or_fetch_imv_cached(
|
||||||
|
|
||||||
except IMVAddressNotFoundError as e:
|
except IMVAddressNotFoundError as e:
|
||||||
logger.warning("imv: address not found in Avito geocoder: %s", e)
|
logger.warning("imv: address not found in Avito geocoder: %s", e)
|
||||||
|
# Retry once with noise prefixes stripped (e.g. "Склад, ул. X" → "ул. X")
|
||||||
|
cleaned = _NOISE_PREFIX_RE.sub("", address)
|
||||||
|
if cleaned != address:
|
||||||
|
logger.info(
|
||||||
|
"imv: retry with cleaned address %r → %r",
|
||||||
|
address[:60],
|
||||||
|
cleaned[:60],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await evaluate_via_imv(
|
||||||
|
address=cleaned,
|
||||||
|
rooms=rooms,
|
||||||
|
area_m2=area_m2,
|
||||||
|
floor=floor,
|
||||||
|
floor_at_home=floor_at_home,
|
||||||
|
house_type=house_type,
|
||||||
|
renovation_type=renovation_type,
|
||||||
|
has_balcony=has_balcony,
|
||||||
|
has_loggia=has_loggia,
|
||||||
|
)
|
||||||
|
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
|
||||||
|
logger.info(
|
||||||
|
"imv: retry OK recommended=%d range=(%d, %d) count=%d",
|
||||||
|
result.recommended_price,
|
||||||
|
result.lower_price,
|
||||||
|
result.higher_price,
|
||||||
|
result.market_count or 0,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except IMVAddressNotFoundError:
|
||||||
|
logger.warning("imv: cleaned address also not found — giving up")
|
||||||
|
except Exception as retry_exc:
|
||||||
|
logger.warning("imv: retry failed: %s", retry_exc)
|
||||||
|
return None
|
||||||
|
except IMVAuthError as e:
|
||||||
|
logger.error(
|
||||||
|
"imv: auth/quota error — manual action required: %s", e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except IMVTransientError as e:
|
||||||
|
logger.warning("imv: transient error, skipping retry in estimator context: %s", e)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
|
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,14 @@ class IMVCityMismatchError(Exception):
|
||||||
"""Raised when locationId не входит в ожидаемый город (sanity check)."""
|
"""Raised when locationId не входит в ожидаемый город (sanity check)."""
|
||||||
|
|
||||||
|
|
||||||
|
class IMVAuthError(Exception):
|
||||||
|
"""Avito API rejected auth / quota exceeded (HTTP 401 / 403)."""
|
||||||
|
|
||||||
|
|
||||||
|
class IMVTransientError(Exception):
|
||||||
|
"""Network / 5xx / timeout — retryable."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dataclasses
|
# Dataclasses
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -309,14 +317,33 @@ async def evaluate_via_imv(
|
||||||
return evaluation
|
return evaluation
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_for_status_categorized(resp: Any, context: str) -> None:
|
||||||
|
"""Raise typed IMV exception based on HTTP status code.
|
||||||
|
|
||||||
|
Используется вместо bare raise_for_status() чтобы caller мог различать
|
||||||
|
auth-failure (требует human attention) от transient (retryable).
|
||||||
|
"""
|
||||||
|
status = resp.status_code
|
||||||
|
if status in (401, 403):
|
||||||
|
raise IMVAuthError(f"Avito {context}: HTTP {status} — auth rejected / quota exceeded")
|
||||||
|
if status >= 500:
|
||||||
|
raise IMVTransientError(f"Avito {context}: HTTP {status} — transient server error")
|
||||||
|
if status >= 400:
|
||||||
|
resp.raise_for_status() # other 4xx → standard HTTPStatusError
|
||||||
|
|
||||||
|
|
||||||
async def _geocode(session: Any, address: str) -> IMVGeo:
|
async def _geocode(session: Any, address: str) -> IMVGeo:
|
||||||
"""Request 1: GET /web/1/coords/by_address → IMVGeo."""
|
"""Request 1: GET /web/1/coords/by_address → IMVGeo."""
|
||||||
params = urlencode({"address": address})
|
params = urlencode({"address": address})
|
||||||
url = f"{AVITO_BASE}{COORDS_ENDPOINT}?{params}"
|
url = f"{AVITO_BASE}{COORDS_ENDPOINT}?{params}"
|
||||||
logger.info("IMV geocode: address=%r", address)
|
logger.info("IMV geocode: address=%r", address)
|
||||||
|
|
||||||
resp = await session.get(url, headers=_COMMON_HEADERS)
|
try:
|
||||||
resp.raise_for_status()
|
resp = await session.get(url, headers=_COMMON_HEADERS)
|
||||||
|
except Exception as exc:
|
||||||
|
# Timeout / connection error — retryable
|
||||||
|
raise IMVTransientError(f"Avito geocode network error: {exc}") from exc
|
||||||
|
_raise_for_status_categorized(resp, "geocode")
|
||||||
data: dict[str, Any] = resp.json()
|
data: dict[str, Any] = resp.json()
|
||||||
|
|
||||||
geo_hash = data.get("geoHash")
|
geo_hash = data.get("geoHash")
|
||||||
|
|
@ -381,8 +408,11 @@ async def _imv_evaluate(
|
||||||
floor_at_home,
|
floor_at_home,
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = await session.post(url, json=body, headers=headers)
|
try:
|
||||||
resp.raise_for_status()
|
resp = await session.post(url, json=body, headers=headers)
|
||||||
|
except Exception as exc:
|
||||||
|
raise IMVTransientError(f"Avito IMV network error: {exc}") from exc
|
||||||
|
_raise_for_status_categorized(resp, "imv-evaluate")
|
||||||
data: dict[str, Any] = resp.json()
|
data: dict[str, Any] = resp.json()
|
||||||
|
|
||||||
price_block: dict[str, Any] = data.get("price") or {}
|
price_block: dict[str, Any] = data.get("price") or {}
|
||||||
|
|
|
||||||
83
tradein-mvp/scripts/probe-imv-by-address.py
Normal file
83
tradein-mvp/scripts/probe-imv-by-address.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""Diagnose IMV API behavior for a specific address.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/probe-imv-by-address.py "Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а"
|
||||||
|
|
||||||
|
Prints: raw API response, error category, cleaned-address retry result.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
async def main(address: str) -> None:
|
||||||
|
from app.services.scrapers.avito_imv import (
|
||||||
|
IMVAddressNotFoundError,
|
||||||
|
IMVAuthError,
|
||||||
|
IMVTransientError,
|
||||||
|
evaluate_via_imv,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default params for ЕКБ 3-room 60м²
|
||||||
|
params = dict(
|
||||||
|
rooms=3,
|
||||||
|
area_m2=60.0,
|
||||||
|
floor=5,
|
||||||
|
floor_at_home=17,
|
||||||
|
house_type="panel",
|
||||||
|
renovation_type="cosmetic",
|
||||||
|
has_balcony=False,
|
||||||
|
has_loggia=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n=== Attempt 1: original address ===\n{address}\n")
|
||||||
|
try:
|
||||||
|
result = await evaluate_via_imv(address=address, **params) # type: ignore[arg-type]
|
||||||
|
print(f"OK: recommended={result.recommended_price}")
|
||||||
|
return
|
||||||
|
except IMVAddressNotFoundError as e:
|
||||||
|
print(f"FAIL: address not found (IMVAddressNotFoundError): {e}")
|
||||||
|
except IMVAuthError as e:
|
||||||
|
print(f"FAIL: auth/quota error (IMVAuthError): {e}")
|
||||||
|
return
|
||||||
|
except IMVTransientError as e:
|
||||||
|
print(f"FAIL: transient/network error (IMVTransientError): {e}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL: {type(e).__name__}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
cleaned = re.sub(
|
||||||
|
r"(Склад|Гараж|Подсобка|Нежилое|Помещение|Цех),\s*",
|
||||||
|
"",
|
||||||
|
address,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if cleaned != address:
|
||||||
|
print(f"\n=== Attempt 2: cleaned address ===\n{cleaned}\n")
|
||||||
|
try:
|
||||||
|
result = await evaluate_via_imv(address=cleaned, **params) # type: ignore[arg-type]
|
||||||
|
print(f"OK on retry: recommended={result.recommended_price}")
|
||||||
|
except IMVAddressNotFoundError as e:
|
||||||
|
print(f"FAIL on retry (IMVAddressNotFoundError): {e}")
|
||||||
|
except IMVAuthError as e:
|
||||||
|
print(f"FAIL on retry (IMVAuthError — quota/auth): {e}")
|
||||||
|
except IMVTransientError as e:
|
||||||
|
print(f"FAIL on retry (IMVTransientError — network/5xx): {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL on retry: {type(e).__name__}: {e}")
|
||||||
|
else:
|
||||||
|
print("\nNo noise prefix found — cleaned address identical, skipping retry.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_address = (
|
||||||
|
sys.argv[1]
|
||||||
|
if len(sys.argv) > 1
|
||||||
|
else "Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а"
|
||||||
|
)
|
||||||
|
asyncio.run(main(_address))
|
||||||
Loading…
Add table
Reference in a new issue