Merge pull request 'feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у geocode-missing (#3051)' (#3432) from feat/3051-geocoder-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
6de69c4d05
6 changed files with 256 additions and 93 deletions
|
|
@ -268,6 +268,7 @@ async def geocode_missing(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 100,
|
||||
target: Literal["listings", "deals"] = "listings",
|
||||
region_code: int = 66,
|
||||
) -> dict:
|
||||
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
||||
|
||||
|
|
@ -279,6 +280,10 @@ async def geocode_missing(
|
|||
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
|
||||
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
||||
geom обновляется автоматически триггером.
|
||||
|
||||
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
|
||||
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66 —
|
||||
прежнее поведение без изменений (cron не меняется, follow-up ниже).
|
||||
"""
|
||||
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
||||
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
||||
|
|
@ -291,13 +296,14 @@ async def geocode_missing(
|
|||
WHERE lat IS NULL
|
||||
AND COALESCE(address, '') != ''
|
||||
{extra_filter}
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
AND (geocode_tried_at IS NULL
|
||||
OR geocode_tried_at < NOW() - interval '7 days')
|
||||
ORDER BY geocode_tried_at NULLS FIRST
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"limit": limit},
|
||||
{"limit": limit, "region_code": region_code},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
@ -332,8 +338,8 @@ async def geocode_missing(
|
|||
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
||||
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
||||
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
||||
city = known_city_hint(row.get("city"))
|
||||
result = await geocode(clean, db, city_hint=city)
|
||||
city = known_city_hint(row.get("city"), region_code)
|
||||
result = await geocode(clean, db, city_hint=city, region_code=region_code)
|
||||
if result is None:
|
||||
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
||||
db.execute(
|
||||
|
|
@ -362,10 +368,12 @@ async def geocode_missing(
|
|||
WHERE lat IS NULL
|
||||
AND COALESCE(address, '') != ''
|
||||
{extra_filter}
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
AND (geocode_tried_at IS NULL
|
||||
OR geocode_tried_at < NOW() - interval '7 days')
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"region_code": region_code},
|
||||
).scalar()
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,16 @@ from tenacity import retry, stop_after_attempt, wait_exponential
|
|||
from app.core.config import settings
|
||||
from app.services import dadata
|
||||
from app.services.regions import REGIONS as _ALL_REGIONS
|
||||
from app.services.regions import Region, is_within_bbox
|
||||
|
||||
_REGION_66 = _ALL_REGIONS[66]
|
||||
|
||||
# #3051: маркер `address.state` Nominatim по региону, для region cross-check
|
||||
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||||
# без записи здесь получают `marker=None` → cross-check пропускается
|
||||
# (fallback на bbox-only, прежнее поведение).
|
||||
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
|
||||
|
|
@ -170,8 +177,15 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
|||
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
|
||||
|
||||
|
||||
def known_city_hint(value: str | None) -> str | None:
|
||||
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе None.
|
||||
def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
|
||||
"""`value` как city_hint, если это узнаваемое имя города `REGIONS[region_code]`, иначе None.
|
||||
|
||||
#3051: `region_code` (дефолт 66) — параметризация под трек «Москва»: словарь
|
||||
городов берётся из `REGIONS[region_code].cities`, а не жёстко из
|
||||
`SVERDLOVSK_OBLAST_CITIES`. Для `region_code=66` (дефолт, все существующие
|
||||
вызовы без аргумента) — byte-identical прежнему поведению: `REGIONS[66].cities
|
||||
is SVERDLOVSK_OBLAST_CITIES` (тот же frozenset-объект, см. модульный уровень).
|
||||
Неизвестный `region_code` → ValueError (явная ошибка, не молчаливый None).
|
||||
|
||||
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
|
||||
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
|
||||
|
|
@ -200,7 +214,14 @@ def known_city_hint(value: str | None) -> str | None:
|
|||
"""
|
||||
if not value:
|
||||
return None
|
||||
return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None
|
||||
if region_code == 66:
|
||||
cities = SVERDLOVSK_OBLAST_CITIES
|
||||
else:
|
||||
try:
|
||||
cities = _ALL_REGIONS[region_code].cities
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"unknown region_code={region_code!r}") from exc
|
||||
return value if " ".join(value.lower().split()) in cities else None
|
||||
|
||||
|
||||
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
|
||||
|
|
@ -231,20 +252,41 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
|
|||
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
||||
|
||||
|
||||
def _has_oblast_marker(text_lower: str) -> bool:
|
||||
"""True если текст уже содержит упоминание области/города региона 66.
|
||||
# region_code → скомпилированный regex городов региона (word-boundary), кэш по
|
||||
# коду. 66 — literal reuse `_OBLAST_CITY_RE` (тот же объект, byte-identical),
|
||||
# остальные регионы строятся из `REGIONS[region_code].cities` при первом
|
||||
# обращении (#3051).
|
||||
_REGION_CITY_RE: dict[int, re.Pattern[str]] = {66: _OBLAST_CITY_RE}
|
||||
|
||||
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
|
||||
уже привязан к другому городу/области — иначе получим двойной город
|
||||
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
|
||||
|
||||
Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
|
||||
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||||
def _region_city_re(region_code: int) -> re.Pattern[str]:
|
||||
cached = _REGION_CITY_RE.get(region_code)
|
||||
if cached is not None:
|
||||
return cached
|
||||
cities = _ALL_REGIONS[region_code].cities
|
||||
compiled = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in cities) + r")\b")
|
||||
_REGION_CITY_RE[region_code] = compiled
|
||||
return compiled
|
||||
|
||||
|
||||
def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool:
|
||||
"""True если текст уже содержит упоминание области/города `region_code`.
|
||||
|
||||
Используется чтобы НЕ навязывать "Екатеринбург, "/"Москва, " в запрос,
|
||||
когда адрес уже привязан к другому городу/области — иначе получим двойной
|
||||
город ("Екатеринбург, Нижний Тагил, Ленина 10" / "Москва, Москва, Тверская
|
||||
1", #3051 п. б) и провайдер вернёт мусор/пусто.
|
||||
|
||||
Матчинг — по границе слова/фразы (`_region_city_re`), НЕ substring — и с
|
||||
исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||||
`region_code=66` (дефолт) дополнительно матчит "свердловск*" — областной
|
||||
маркер без города; у прочих регионов такого обобщённого маркера нет,
|
||||
город региона уже покрывает случай (для 77 — "москва" в `region.cities`).
|
||||
"""
|
||||
normalized = " ".join(text_lower.split())
|
||||
if _OBLAST_MARKER_RE.search(normalized):
|
||||
if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
|
||||
return True
|
||||
for m in _OBLAST_CITY_RE.finditer(normalized):
|
||||
for m in _region_city_re(region_code).finditer(normalized):
|
||||
prefix_words = normalized[: m.start()].split()
|
||||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
||||
|
|
@ -252,12 +294,14 @@ def _has_oblast_marker(text_lower: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
|
||||
def _resolve_city_for_geocode(
|
||||
address: str, city_hint: str | None, region_code: int = 66
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Определяет, какой город подставлять в запрос внешнему провайдеру
|
||||
(Nominatim), когда сам текст адреса города не называет.
|
||||
|
||||
Приоритет:
|
||||
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
|
||||
1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
|
||||
→ город уже указан пользователем в тексте адреса, ничего подставлять не
|
||||
нужно. Возвращает (None, True).
|
||||
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
|
||||
|
|
@ -266,15 +310,18 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
|
|||
— для жителей других городов области это давало уверенно неверную цену
|
||||
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
|
||||
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
|
||||
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
|
||||
привязки к конкретному городу. Возвращает
|
||||
провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
|
||||
`_region_viewbox`), без привязки к конкретному городу. Возвращает
|
||||
(None, False) — второй элемент False сигнализирует, что город
|
||||
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
|
||||
|
||||
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
|
||||
всех вызовов без аргумента.
|
||||
|
||||
Returns:
|
||||
(city_or_none, city_specified_by_user).
|
||||
"""
|
||||
if _has_oblast_marker(address.lower()):
|
||||
if _has_oblast_marker(address.lower(), region_code):
|
||||
return None, True
|
||||
hint = (city_hint or "").strip()
|
||||
if hint:
|
||||
|
|
@ -678,13 +725,15 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
|
|||
|
||||
|
||||
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
||||
def _nominatim_region_ok(item: dict) -> bool | None:
|
||||
def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None:
|
||||
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
||||
|
||||
True/False если state однозначно про/не про Свердловскую область. None если
|
||||
поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
|
||||
True/False если state однозначно про/не про регион `region_code`. None если
|
||||
поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
|
||||
— тогда accept-логика падает обратно на bbox.
|
||||
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
||||
(специально щедрого), но их state явно другой регион.
|
||||
(специально щедрого), но их state явно другой регион. `region_code=66`
|
||||
(дефолт) — byte-identical прежнему поведению (`"свердловск" in state.lower()`).
|
||||
"""
|
||||
addr = item.get("address")
|
||||
if not isinstance(addr, dict):
|
||||
|
|
@ -692,22 +741,41 @@ def _nominatim_region_ok(item: dict) -> bool | None:
|
|||
state = addr.get("state")
|
||||
if not isinstance(state, str) or not state:
|
||||
return None
|
||||
return "свердловск" in state.lower()
|
||||
marker = _REGION_STATE_MARKERS.get(region_code)
|
||||
if marker is None:
|
||||
return None
|
||||
return marker in state.lower()
|
||||
|
||||
|
||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
||||
def _region_viewbox(region: Region) -> str:
|
||||
"""Nominatim `viewbox` (lon_min,lat_max,lon_max,lat_min) из `region.bbox_region`.
|
||||
|
||||
Для region 66 см. `OBLAST66_VIEWBOX["viewbox"]` — литеральная константа
|
||||
(byte-identical), эта функция для 66 не вызывается.
|
||||
"""
|
||||
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
||||
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
|
||||
|
||||
|
||||
async def _nominatim_query(
|
||||
client: httpx.AsyncClient, address: str, region_code: int = 66
|
||||
) -> dict | None:
|
||||
"""Single Nominatim search. Возвращает лучший item или None.
|
||||
|
||||
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
|
||||
опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
|
||||
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
|
||||
ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
|
||||
при опечатках не возвращать Пермский край / Челябинск — но не резать
|
||||
легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
|
||||
|
||||
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
||||
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
|
||||
не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
|
||||
(`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
|
||||
т.п.), даже если координаты попали в генеральный bbox.
|
||||
bbox региона (byte-identical для region_code=66, даже если Nominatim
|
||||
ранжировал его не первым) — иначе первый кандидат внутри генерального bbox
|
||||
региона. Плюс region cross-check (`address.state`) — отсекает кандидатов
|
||||
ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
|
||||
генеральный bbox. `region_code=66` (дефолт) — byte-identical прежнему
|
||||
поведению (те же bbox-значения и та же viewbox-строка).
|
||||
"""
|
||||
region = _ALL_REGIONS[region_code]
|
||||
viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region)
|
||||
await _nominatim_throttle()
|
||||
response = await client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
|
|
@ -717,8 +785,8 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
|||
"limit": "3",
|
||||
"countrycodes": "ru",
|
||||
"addressdetails": "1",
|
||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
||||
"bounded": "1", # строго в пределах области (region 66)
|
||||
"viewbox": viewbox,
|
||||
"bounded": "1", # строго в пределах региона
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
|
@ -730,11 +798,11 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
|||
lon_f = float(item["lon"])
|
||||
except Exception:
|
||||
continue
|
||||
if _nominatim_region_ok(item) is False:
|
||||
continue # регион явно не Свердловская область — не рассматриваем
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
|
||||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||||
if _nominatim_region_ok(item, region_code) is False:
|
||||
continue # регион явно не тот, что запрошен — не рассматриваем
|
||||
if is_within_bbox(lat_f, lon_f, region.bbox_tight):
|
||||
return item # tight-bbox приоритетнее — тот же результат, что и раньше
|
||||
if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
|
||||
oblast_fallback = item
|
||||
return oblast_fallback
|
||||
|
||||
|
|
@ -756,21 +824,25 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
|||
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
||||
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||||
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
async def _nominatim_lookup(
|
||||
address: str, city_hint: str | None = None, region_code: int = 66
|
||||
) -> GeocodeResult | None:
|
||||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||
|
||||
Бан-policy: User-Agent с email обязателен.
|
||||
Tier 1: bounded область (region 66) на оригинальный адрес.
|
||||
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
|
||||
Tier 1: bounded регион `region_code` на оригинальный адрес.
|
||||
Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга → Цвиллинга).
|
||||
|
||||
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
|
||||
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break
|
||||
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
|
||||
(напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город.
|
||||
Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри
|
||||
области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim —
|
||||
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
|
||||
city_hint должен реально влиять на его результат, не только на кэш-ключ.
|
||||
него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
|
||||
(предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
|
||||
региона (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не
|
||||
различает город. Эмпирически подтверждено: "Ленина 1" без города →
|
||||
случайное село внутри области; "Нижний Тагил, Ленина 1" → корректно
|
||||
резолвится. Nominatim — единственный живой внешний провайдер (#2593:
|
||||
Yandex Geocoder удалён) — city_hint должен реально влиять на его результат,
|
||||
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
|
||||
прежнему поведению для всех вызовов без аргумента.
|
||||
"""
|
||||
headers = {
|
||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||
|
|
@ -778,18 +850,18 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
|
|||
"Accept-Language": "ru,en;q=0.8",
|
||||
"Referer": "https://tradein-mvp.local/",
|
||||
}
|
||||
city, _ = _resolve_city_for_geocode(address, city_hint)
|
||||
city, _ = _resolve_city_for_geocode(address, city_hint, region_code)
|
||||
query = f"{city}, {address}" if city else address
|
||||
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
||||
# Tier 1: оригинал
|
||||
item = await _nominatim_query(client, query)
|
||||
item = await _nominatim_query(client, query, region_code)
|
||||
|
||||
# Tier 2: typo-variants
|
||||
if item is None:
|
||||
for variant in _typo_variants(address, limit=4):
|
||||
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
|
||||
variant_city, _ = _resolve_city_for_geocode(variant, city_hint, region_code)
|
||||
variant_query = f"{variant_city}, {variant}" if variant_city else variant
|
||||
item = await _nominatim_query(client, variant_query)
|
||||
item = await _nominatim_query(client, variant_query, region_code)
|
||||
if item is not None:
|
||||
logger.info("nominatim typo-fixed: %s → %s", address, variant)
|
||||
break
|
||||
|
|
@ -1753,7 +1825,9 @@ async def suggest(
|
|||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
async def geocode(
|
||||
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||||
) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
||||
|
||||
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
||||
|
|
@ -1771,8 +1845,21 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
|||
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
||||
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
||||
видимая, а не отказ.
|
||||
|
||||
`region_code` (дефолт 66, #3051) — какой `REGIONS`-регион искать (bbox,
|
||||
city-словарь, ЕКБ-only локальные тиры). Неизвестный код → `ValueError`
|
||||
сразу, а не глубоко внутри `_nominatim_query`. Все существующие вызовы без
|
||||
аргумента получают region_code=66 — byte-identical прежнему поведению.
|
||||
`_city_substituted` region_code не принимает: инвариант завязан на
|
||||
ЕКБ-bbox координат результата (`is_within_ekb_bbox`), который для другого
|
||||
региона (Москва и т.п.) структурно не совпадает — условие 3 инварианта
|
||||
никогда не сработает, ложного понижения confidence до "locality" не будет.
|
||||
"""
|
||||
result = await _geocode_resolve(address, db, city_hint)
|
||||
try:
|
||||
_ALL_REGIONS[region_code]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"geocode: unknown region_code={region_code!r}") from exc
|
||||
result = await _geocode_resolve(address, db, city_hint, region_code)
|
||||
if result is None or not _city_substituted(address, result):
|
||||
return result
|
||||
logger.warning(
|
||||
|
|
@ -1788,7 +1875,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
|||
|
||||
|
||||
async def _geocode_resolve(
|
||||
address: str, db: Session, city_hint: str | None = None
|
||||
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||||
) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||||
|
||||
|
|
@ -1802,6 +1889,10 @@ async def _geocode_resolve(
|
|||
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
|
||||
разных городов по одному и тому же тексту адреса не перезатирали
|
||||
друг друга.
|
||||
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
|
||||
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66 —
|
||||
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
|
||||
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
|
||||
|
||||
Returns:
|
||||
GeocodeResult или None если ни один провайдер не отвечает.
|
||||
|
|
@ -1812,7 +1903,7 @@ async def _geocode_resolve(
|
|||
if not address or len(address.strip()) < 3:
|
||||
return None
|
||||
|
||||
_, city_specified = _resolve_city_for_geocode(address, city_hint)
|
||||
_, city_specified = _resolve_city_for_geocode(address, city_hint, region_code)
|
||||
city_ambiguous = not city_specified
|
||||
|
||||
addr_norm = _cache_key(normalize_address(address), city_hint)
|
||||
|
|
@ -1838,7 +1929,14 @@ async def _geocode_resolve(
|
|||
# Раньше решение по тексту адреса принималось от противного (список из 37
|
||||
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
|
||||
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
|
||||
use_local_ekb = _ekb_local_tiers_allowed(address, city_hint)
|
||||
#
|
||||
# #3051: `region_code != 66` закрывает эти тиры целиком, ДО вызова
|
||||
# `_ekb_local_tiers_allowed` — geoportal/cad_buildings/houses физически не
|
||||
# содержат данных других регионов (не "город не распознан словарём 66", а
|
||||
# "реестра для этого региона нет вовсе"), а сама `_ekb_local_tiers_allowed`
|
||||
# (её ЕКБ-словари: `_names_non_ekb_city`/`_names_unrecognized_locality`)
|
||||
# region_code не принимает — умышленно не трогаем её сигнатуру.
|
||||
use_local_ekb = region_code == 66 and _ekb_local_tiers_allowed(address, city_hint)
|
||||
|
||||
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||
if use_local_ekb and parsed is not None:
|
||||
|
|
@ -1914,7 +2012,7 @@ async def _geocode_resolve(
|
|||
|
||||
# 3. Nominatim fallback
|
||||
try:
|
||||
result = await _nominatim_lookup(address, city_hint)
|
||||
result = await _nominatim_lookup(address, city_hint, region_code)
|
||||
if result is not None:
|
||||
result = replace(result, city_ambiguous=city_ambiguous)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ Flags:
|
|||
plus the geocode hit/miss split. No DB writes.
|
||||
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
|
||||
--stale-days N retry addresses last tried more than N days ago (default 30).
|
||||
--region-code N which `deals.region_code` to geocode (default 66, Sverdlovsk
|
||||
oblast). #3051: 77 (Москва) — every deal there has
|
||||
address='Москва, <street>' with no city already recognised by
|
||||
`known_city_hint`'s region-66 default, so this flag threads
|
||||
through both the SQL filter (`AND region_code = N`) and
|
||||
`geocoder.{geocode,known_city_hint}(..., region_code=N)`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -182,7 +188,9 @@ class Stats:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> list[AddressGroup]:
|
||||
def _select_pending_addresses(
|
||||
db: Session, *, limit: int, stale_days: int, region_code: int = 66
|
||||
) -> list[AddressGroup]:
|
||||
"""Distinct (address, city) pairs still needing coords — resume-safe set.
|
||||
|
||||
Combines the `deals_geocode_pending_idx` partial index predicate
|
||||
|
|
@ -215,6 +223,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
|||
" WHERE lat IS NULL "
|
||||
" AND address IS NOT NULL "
|
||||
" AND length(trim(address)) >= 3 "
|
||||
" AND region_code = CAST(:region_code AS int) "
|
||||
" AND (geocode_tried_at IS NULL "
|
||||
" OR geocode_tried_at < NOW() "
|
||||
" - make_interval(days => CAST(:stale_days AS int))) "
|
||||
|
|
@ -223,7 +232,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
|||
"WHERE running_rows - deals_count < CAST(:limit AS int) "
|
||||
"ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST"
|
||||
),
|
||||
{"limit": limit, "stale_days": stale_days},
|
||||
{"limit": limit, "stale_days": stale_days, "region_code": region_code},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
@ -238,7 +247,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
|||
]
|
||||
|
||||
|
||||
def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
||||
def _count_pending_total(db: Session, *, stale_days: int, region_code: int = 66) -> tuple[int, int]:
|
||||
"""Full backlog: (distinct (address, city) pairs, total rows) this pass.
|
||||
|
||||
Denominators for the dry-run projection — counts every lat-IS-NULL deal
|
||||
|
|
@ -257,11 +266,12 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
|||
"WHERE lat IS NULL "
|
||||
" AND address IS NOT NULL "
|
||||
" AND length(trim(address)) >= 3 "
|
||||
" AND region_code = CAST(:region_code AS int) "
|
||||
" AND (geocode_tried_at IS NULL "
|
||||
" OR geocode_tried_at < NOW() "
|
||||
" - make_interval(days => CAST(:stale_days AS int)))"
|
||||
),
|
||||
{"stale_days": stale_days},
|
||||
{"stale_days": stale_days, "region_code": region_code},
|
||||
).first()
|
||||
if row is None:
|
||||
return (0, 0)
|
||||
|
|
@ -274,9 +284,15 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
|||
|
||||
|
||||
def _update_deals_geocoded(
|
||||
db: Session, *, address: str, city: str | None, lat: float, lon: float
|
||||
db: Session,
|
||||
*,
|
||||
address: str,
|
||||
city: str | None,
|
||||
lat: float,
|
||||
lon: float,
|
||||
region_code: int = 66,
|
||||
) -> int:
|
||||
"""UPDATE every lat-IS-NULL deal on (address, city); geom via trigger.
|
||||
"""UPDATE every lat-IS-NULL deal on (address, city, region_code); geom via trigger.
|
||||
|
||||
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
||||
(002_core_tables.sql, reuses listings_set_geom()) populates geom from the
|
||||
|
|
@ -290,6 +306,12 @@ def _update_deals_geocoded(
|
|||
FROM` treats NULL=NULL as a match while staying strict for a real city, so
|
||||
the same street text in another city keeps its own coords (#2603).
|
||||
|
||||
`region_code` (#3051) — an extra guard on top of (address, city): the same
|
||||
street text can legitimately exist in two different regions (e.g. a
|
||||
"Ленина" in both oblast 66 and Moscow's region-77 corpus), so a run scoped
|
||||
to one region must never touch the other's rows even if address/city
|
||||
happen to collide.
|
||||
|
||||
Returns the number of deal rows updated.
|
||||
"""
|
||||
result = db.execute(
|
||||
|
|
@ -300,14 +322,15 @@ def _update_deals_geocoded(
|
|||
" geocode_tried_at = NOW() "
|
||||
" WHERE address = CAST(:addr AS text) "
|
||||
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
||||
" AND region_code = CAST(:region_code AS int) "
|
||||
" AND lat IS NULL"
|
||||
),
|
||||
{"addr": address, "city": city, "lat": lat, "lon": lon},
|
||||
{"addr": address, "city": city, "lat": lat, "lon": lon, "region_code": region_code},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
||||
def _mark_deals_tried(db: Session, *, address: str, city: str | None, region_code: int = 66) -> int:
|
||||
"""Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss).
|
||||
|
||||
Critical for resume: an address the geocoder can't resolve must still drop
|
||||
|
|
@ -316,8 +339,9 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
|||
it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success
|
||||
can't be downgraded.
|
||||
|
||||
Scoped to the (address, city) pair for the same reason as the coords write
|
||||
— a miss in one city must not defer the other city's retry (#2603).
|
||||
Scoped to the (address, city, region_code) pair for the same reason as the
|
||||
coords write — a miss in one city/region must not defer another
|
||||
region's retry (#2603, #3051).
|
||||
|
||||
Returns the number of deal rows stamped.
|
||||
"""
|
||||
|
|
@ -327,9 +351,10 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
|||
" SET geocode_tried_at = NOW() "
|
||||
" WHERE address = CAST(:addr AS text) "
|
||||
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
||||
" AND region_code = CAST(:region_code AS int) "
|
||||
" AND lat IS NULL"
|
||||
),
|
||||
{"addr": address, "city": city},
|
||||
{"addr": address, "city": city, "region_code": region_code},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
|
@ -345,6 +370,7 @@ async def _run_backfill(
|
|||
*,
|
||||
batch: str,
|
||||
dry_run: bool,
|
||||
region_code: int = 66,
|
||||
) -> Stats:
|
||||
"""For each (address, city) pair: geocode once, then UPDATE all its deals.
|
||||
|
||||
|
|
@ -357,17 +383,21 @@ async def _run_backfill(
|
|||
retry/backoff), so we don't add our own sleep here — that would double the
|
||||
walltime. A geocode that raises is treated as a failure for THIS run but is
|
||||
NOT stamped (left for the next pass to retry sooner than a clean miss).
|
||||
|
||||
`region_code` (#3051, default 66) threads into `known_city_hint`/`geocode`
|
||||
(region's own city dictionary + bbox/viewbox) and into the two DB writers
|
||||
(extra WHERE guard, matches `_select_pending_addresses`'s filter).
|
||||
"""
|
||||
stats = Stats()
|
||||
|
||||
for i, group in enumerate(groups, start=1):
|
||||
address = group.address
|
||||
city = group.city
|
||||
# Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr
|
||||
# values degrade to None (geocoder.known_city_hint — shared with the
|
||||
# other DB-column callers). The raw `city` is still used for the UPDATE
|
||||
# scope — it identifies the group either way.
|
||||
hint = known_city_hint(city)
|
||||
# Only a recognised city of `region_code` is fed to the geocoder; junk
|
||||
# Rosreestr values degrade to None (geocoder.known_city_hint — shared
|
||||
# with the other DB-column callers). The raw `city` is still used for
|
||||
# the UPDATE scope — it identifies the group either way.
|
||||
hint = known_city_hint(city, region_code)
|
||||
|
||||
# The geocoder itself rejects <3 chars, but skip here too so the dry-run
|
||||
# report and counters stay honest (no phantom "processed" address).
|
||||
|
|
@ -377,7 +407,7 @@ async def _run_backfill(
|
|||
|
||||
result: GeocodeResult | None = None
|
||||
try:
|
||||
result = await geocode(address, db, city_hint=hint)
|
||||
result = await geocode(address, db, city_hint=hint, region_code=region_code)
|
||||
except Exception as exc: # defensive — one geocode error must not kill batch
|
||||
stats.geocode_failed += 1
|
||||
stats.processed += 1
|
||||
|
|
@ -407,7 +437,7 @@ async def _run_backfill(
|
|||
else:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
_mark_deals_tried(db, address=address, city=city)
|
||||
_mark_deals_tried(db, address=address, city=city, region_code=region_code)
|
||||
db.commit()
|
||||
except Exception as exc: # defensive — isolate one bad UPDATE
|
||||
db.rollback()
|
||||
|
|
@ -441,7 +471,12 @@ async def _run_backfill(
|
|||
try:
|
||||
with db.begin_nested():
|
||||
n = _update_deals_geocoded(
|
||||
db, address=address, city=city, lat=result.lat, lon=result.lon
|
||||
db,
|
||||
address=address,
|
||||
city=city,
|
||||
lat=result.lat,
|
||||
lon=result.lon,
|
||||
region_code=region_code,
|
||||
)
|
||||
# Per-address commit so resume picks up exactly where we crashed.
|
||||
db.commit()
|
||||
|
|
@ -562,6 +597,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
f"{_DEFAULT_STALE_DAYS}). Bounds retries on un-geocodable addresses."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--region-code",
|
||||
type=int,
|
||||
default=66,
|
||||
help=(
|
||||
"deals.region_code to geocode (default 66, Sverdlovsk oblast). #3051: "
|
||||
"77 (Moscow) threads through the SQL filter, geocoder.geocode/"
|
||||
"known_city_hint, and the DB writers' WHERE guard."
|
||||
),
|
||||
)
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
|
|
@ -575,34 +620,44 @@ async def main(argv: list[str] | None = None) -> int:
|
|||
"""
|
||||
args = _parse_args(argv)
|
||||
logger.info(
|
||||
"starting batch=%s limit=%s stale_days=%s dry_run=%s",
|
||||
"starting batch=%s limit=%s stale_days=%s region_code=%s dry_run=%s",
|
||||
args.batch,
|
||||
args.limit,
|
||||
args.stale_days,
|
||||
args.region_code,
|
||||
args.dry_run,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
groups = _select_pending_addresses(db, limit=args.limit, stale_days=args.stale_days)
|
||||
groups = _select_pending_addresses(
|
||||
db, limit=args.limit, stale_days=args.stale_days, region_code=args.region_code
|
||||
)
|
||||
total_rows = sum(g.deals_count for g in groups)
|
||||
logger.info(
|
||||
"loaded %d distinct (address, city) groups (%d deal rows) needing coords",
|
||||
"loaded %d distinct (address, city) groups (%d deal rows) needing coords "
|
||||
"region_code=%s",
|
||||
len(groups),
|
||||
total_rows,
|
||||
args.region_code,
|
||||
)
|
||||
if not groups:
|
||||
logger.info(
|
||||
"nothing to do — no deals with lat IS NULL eligible (all tried "
|
||||
"within the last %d days, or no addressable rows)",
|
||||
"within the last %d days, or no addressable rows) region_code=%s",
|
||||
args.stale_days,
|
||||
args.region_code,
|
||||
)
|
||||
return 0
|
||||
|
||||
stats = await _run_backfill(db, groups, batch=args.batch, dry_run=args.dry_run)
|
||||
stats = await _run_backfill(
|
||||
db, groups, batch=args.batch, dry_run=args.dry_run, region_code=args.region_code
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
total_streets, backlog_rows = _count_pending_total(db, stale_days=args.stale_days)
|
||||
total_streets, backlog_rows = _count_pending_total(
|
||||
db, stale_days=args.stale_days, region_code=args.region_code
|
||||
)
|
||||
_report_dry_run(
|
||||
stats,
|
||||
total_streets=total_streets,
|
||||
|
|
@ -611,9 +666,10 @@ async def main(argv: list[str] | None = None) -> int:
|
|||
)
|
||||
|
||||
logger.info(
|
||||
"done: batch=%s processed=%d geocoded=%d geocode_failed=%d "
|
||||
"done: batch=%s region_code=%s processed=%d geocoded=%d geocode_failed=%d "
|
||||
"skipped=%d deals_updated=%d cache=(hit=%d miss=%d)",
|
||||
args.batch,
|
||||
args.region_code,
|
||||
stats.processed,
|
||||
stats.geocoded,
|
||||
stats.geocode_failed,
|
||||
|
|
|
|||
|
|
@ -337,6 +337,7 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom():
|
|||
"city": "Екатеринбург",
|
||||
"lat": 56.1,
|
||||
"lon": 60.2,
|
||||
"region_code": 66,
|
||||
}
|
||||
assert n == 7
|
||||
|
||||
|
|
@ -492,7 +493,7 @@ async def test_run_backfill_passes_known_city_as_hint():
|
|||
) as mock_geo:
|
||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||
|
||||
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил")
|
||||
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
|
||||
assert coord_updates[0]["city"] == "Нижний Тагил"
|
||||
|
||||
|
||||
|
|
@ -507,7 +508,7 @@ async def test_run_backfill_junk_city_geocodes_without_hint_but_scopes_update():
|
|||
) as mock_geo:
|
||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||
|
||||
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None)
|
||||
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None, region_code=66)
|
||||
assert coord_updates[0]["city"] == "Бессонова"
|
||||
|
||||
|
||||
|
|
@ -547,7 +548,7 @@ async def test_run_backfill_miss_marks_only_its_own_city():
|
|||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||
|
||||
assert coord_updates == []
|
||||
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил"}]
|
||||
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил", "region_code": 66}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -806,7 +806,7 @@ async def test_admin_geocode_missing_passes_city_hint(target: str) -> None:
|
|||
target=target, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил")
|
||||
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
|
||||
assert result["geocoded"] == 1
|
||||
assert result["skipped"] == 0
|
||||
# SELECT адресован именно запрошенной таблице (обе несут колонку city).
|
||||
|
|
@ -854,7 +854,7 @@ async def test_admin_geocode_missing_drops_junk_city_hint(target: str) -> None:
|
|||
target=target, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None)
|
||||
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None, region_code=66)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ async def test_geocode_cache_does_not_mix_cities() -> None:
|
|||
def fake_cache_put(db, addr_norm, result):
|
||||
store[addr_norm] = result
|
||||
|
||||
async def fake_nominatim_lookup(address, city_hint=None):
|
||||
async def fake_nominatim_lookup(address, city_hint=None, region_code=66):
|
||||
if city_hint == "Нижний Тагил":
|
||||
return GeocodeResult(
|
||||
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue