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
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
227 lines
8.3 KiB
Python
227 lines
8.3 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 '<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
|