gendesign/tradein-mvp/backend/app/services/yandex_address_backfill.py
bot-backend d9b8047a7d
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 29s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 36s
fix(tradein): yandex address-enrich — city-agnostic title regex (спутники ЕКБ) (#875)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-31 09:08:29 +00:00

227 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 '<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+[^<]*</title>", # 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 <title>.
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