fix(tradein): extract_street_name handles Nominatim reverse format (#557)
This commit is contained in:
parent
4d1c409e18
commit
8c540ecec6
2 changed files with 83 additions and 29 deletions
|
|
@ -1112,49 +1112,70 @@ def _extract_short_addr(full_address: str | None) -> str | None:
|
|||
return s.strip(" ,.") or None
|
||||
|
||||
|
||||
# Strips trailing house-number and any subsequent parts from a short address.
|
||||
_TRAILING_HOUSE_RE = re.compile(
|
||||
r",\s*\d+.*$",
|
||||
flags=re.UNICODE,
|
||||
# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе.
|
||||
# Работает для FORWARD и REVERSE форматов Nominatim.
|
||||
_STREET_KW_RE = re.compile(
|
||||
r"(?<![А-Яа-яёЁa-zA-Z])"
|
||||
r"(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
|
||||
r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|"
|
||||
r"пл\.|площадь|мкр\.|мкр|микрорайон)"
|
||||
r"\s+",
|
||||
flags=re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
# Strips leading street-type prefix (ул., проспект, пер., etc.) from a street token.
|
||||
_STREET_PREFIX_RE = re.compile(
|
||||
r"^(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
|
||||
r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|пл\.|площадь|"
|
||||
r"мкр\.?|микрорайон)\s+",
|
||||
flags=re.IGNORECASE | re.UNICODE,
|
||||
# После keyword: 1-3 слова имени улицы со стопом на запятую или номер дома.
|
||||
# Поддерживает "8 Марта" (цифра + слово) и "Большая Конюшенная" (несколько слов).
|
||||
_STREET_NAME_RE = re.compile(
|
||||
r"^([0-9]+\s+[А-Яа-яёЁ][А-Яа-яёЁ-]+"
|
||||
r"|[А-Яа-яёЁ][А-Яа-яёЁ-]+(?:\s+[А-Яа-яёЁ][А-Яа-яёЁ-]+){0,2})"
|
||||
r"(?=,|\s+(?:д\.?\s*)?\d|\s*$)",
|
||||
flags=re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def extract_street_name(full_address: str | None) -> str | None:
|
||||
"""Извлекает только название улицы (без типа и номера дома) из полного адреса.
|
||||
|
||||
Используется для запросов в open dataset Росреестра, где адреса агрегированы
|
||||
до уровня улицы (без номера дома).
|
||||
"""Извлекает чистое имя улицы из адреса в FORWARD или REVERSE формате.
|
||||
|
||||
Примеры:
|
||||
"Свердловская обл., г. Екатеринбург, ул. Космонавтов, 12" → "Космонавтов"
|
||||
"Екатеринбург, проспект Ленина, 50" → "Ленина"
|
||||
"Екатеринбург, ул. 8 Марта, 18" → "8 Марта"
|
||||
"г. Екатеринбург, пер. Красный, 4" → "Красный"
|
||||
"Екатеринбург, ул. Космонавтов, 50" → "Космонавтов"
|
||||
"80, улица 8 Марта, Артек, ..., Россия" → "8 Марта"
|
||||
"проспект Ленина 50" → "Ленина"
|
||||
"Россия, Екатеринбург, ул. Малышева, 1" → "Малышева"
|
||||
"ул. Большая Конюшенная, 25" → "Большая Конюшенная"
|
||||
"" → None
|
||||
|
||||
Алгоритм:
|
||||
1. _extract_short_addr → «ул. Космонавтов, 12» (или fallback строка).
|
||||
2. Отрезаем trailing «, 12...» (house number и всё после).
|
||||
3. Отрезаем leading street-type prefix («ул.», «проспект» и т.д.).
|
||||
4. None если ничего не осталось.
|
||||
1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive.
|
||||
2. После keyword берём 1-3 слова до запятой или номера дома.
|
||||
3. Если keyword не нашёлся — пытаемся первый capitalized токен с
|
||||
поиском до запятой или номера (fallback для адресов без keyword'а).
|
||||
|
||||
Returns None если ничего не извлеклось.
|
||||
"""
|
||||
short = _extract_short_addr(full_address)
|
||||
if not short:
|
||||
if not full_address or not full_address.strip():
|
||||
return None
|
||||
|
||||
# Strip trailing house number: ", 12" / ", 12А" / ", 48/2"
|
||||
without_house = _TRAILING_HOUSE_RE.sub("", short).strip(" ,.")
|
||||
s = full_address.strip()
|
||||
|
||||
# Strip leading type prefix
|
||||
street_name = _STREET_PREFIX_RE.sub("", without_house).strip()
|
||||
return street_name or None
|
||||
# 1. Keyword-based extraction (работает для обоих форматов: forward и reverse)
|
||||
m = _STREET_KW_RE.search(s)
|
||||
if m:
|
||||
rest = s[m.end():].lstrip()
|
||||
nm = _STREET_NAME_RE.match(rest)
|
||||
if nm:
|
||||
return nm.group(1).strip()
|
||||
|
||||
# 2. Fallback: нет keyword — пробуем первый capitalized токен
|
||||
# Используется для "Большая Конюшенная, 25" без "ул."
|
||||
nm = _STREET_NAME_RE.match(s)
|
||||
if nm:
|
||||
candidate = nm.group(1).strip()
|
||||
# Отсеиваем очевидные административные слова
|
||||
bad = {"Россия", "Москва", "Санкт-Петербург", "область", "район", "округ", "край"}
|
||||
if not any(b in candidate for b in bad):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _stratify_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
|
|
|||
|
|
@ -74,6 +74,39 @@ def test_extract_street_name_no_street_keyword() -> None:
|
|||
assert result is None or isinstance(result, str)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
# Forward format (user-typed)
|
||||
("Екатеринбург, ул. Космонавтов, 50", "Космонавтов"),
|
||||
("Россия, Екатеринбург, ул. Малышева, 1", "Малышева"),
|
||||
("проспект Ленина 50", "Ленина"),
|
||||
("ул. Большая Конюшенная, 25", "Большая Конюшенная"),
|
||||
# Reverse format (from Nominatim geocoder)
|
||||
(
|
||||
"80, улица 8 Марта, Артек, Форум-Сити, Ленинский район, Екатеринбург,"
|
||||
" городской округ Екатеринбург, Свердловская область,"
|
||||
" Уральский федеральный округ, 640144, Россия",
|
||||
"8 Марта",
|
||||
),
|
||||
(
|
||||
"50, улица Космонавтов, Орджоникидзевский район, Екатеринбург, Россия",
|
||||
"Космонавтов",
|
||||
),
|
||||
# Edge cases
|
||||
("", None),
|
||||
(None, None),
|
||||
("just plain text", None),
|
||||
("Москва", None),
|
||||
],
|
||||
)
|
||||
def test_extract_street_name_parametrized(address: str | None, expected: str | None) -> None:
|
||||
"""Parametrized cases covering forward, reverse (Nominatim), and edge cases."""
|
||||
from app.services.estimator import extract_street_name
|
||||
|
||||
assert extract_street_name(address) == expected
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue