gendesign/tradein-mvp/backend/app/services/yandex_address_backfill.py
bot-backend 5373f5db63
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 28s
Deploy Trade-In / build-backend (push) Successful in 39s
Deploy Trade-In / deploy (push) Successful in 34s
feat(tradein): admin backfill endpoints — cian price-history (T8a) + yandex address (T10) + house IMV (T7) (#856)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-31 06:37:10 +00:00

218 lines
7.4 KiB
Python

"""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