"""T10: Yandex listing address backfill from detail-page . 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 '<City>, <full address>' from <title> — matches any city, not just Ekaterinburg. # Handles EKB metro-area satellite towns: Верхняя Пышма, Берёзовский, Среднеуральск, Арамиль, etc. # Examples: # «Продажа квартиры — Екатеринбург, улица Горького, 36 — id 7654321 на Яндекс.Недвижимости» # «Купить квартиру … — ЖК «Дуэт», Верхняя Пышма, улица Мальцева, 14 — id 75332…» # «Купить квартиру … — ЖК «Уют-Сити», Берёзовский, Александровский проспект, 3 — id 3988…» # Group 1 = «<City>, <street/house>» (stripped of trailing punctuation/spaces) # The optional ЖК-prefix (?:[^—,]*?,\s*) consumes «ЖК «Дуэт», » before the city name. # City capture starts with [А-ЯЁ] to ensure the prefix never swallows the city itself. _RE_TITLE_ADDRESS = re.compile( r"<title>[^<]*?—\s*" r"(?:(?![А-ЯЁ][а-яё])[^—,]*?,\s*)?" # optional ЖК-prefix — fires only when NOT a city name r"([А-ЯЁ][^—<]+?,\s*[^<—]+?)" # <City>, <street/house> — any city (uppercase Cyrillic start) r"\s*—\s*id\s*\d+[^<]*", # allow trailing text after id NNN (e.g. 'на Яндекс...') ) # 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 . City-agnostic: works for EKB and satellite towns (Верхняя Пышма, Берёзовский, etc.). Returns «<City>, <street>, <house>» or None if the title format is not recognised. """ 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